0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

自作のLINE連携MCPを使ってLINEの返信を自動化した話

0
Last updated at Posted at 2026-09-18

はじめに

LINE の未読がたまりがちなので、Claude に LINE を読ませて「未読の要約」と「自分っぽい返信の下書き」を作らせる MCP サーバーを自作しました。

初めて、このMCPを作ろうとした時に公式のMCPは公式ラインだけでAPIも同様に個人のアカウントは使えませんでした。Claudeに聞いてもできませんの一点張りでした。
しかし、Beeperというサービスを知ってあのサービスはLINEのメッセージをLINE for Chromeからひっぱって来てることを知って同じ方法を使えばMCPサーバーを立てられそうだと思いました。

できるようになったこと:

  • 「LINE の未読を教えて」で、未読のトークを要約してくれる
  • トークを読ませても既読がつかない
  • 「〇〇に返信の下書き作って」で、自分の口調に寄せた返信を LINE の入力欄に入れてくれる
  • 送信ボタンは自分で押す(送信機能は、あえて持たせていない)

LINE には個人アカウントのトークを扱える公式 API がないので、Chrome 拡張版 LINE の内部状態を Chrome DevTools Protocol(CDP)経由で読んでいます。この記事では、そこに至るまでに試したこととハマりどころをまとめます。

実装は Claude Code と対話しながら進めました。

この記事の方法は、LINE Chrome 拡張の非公式な内部構造に依存しています。拡張が更新されると動かなくなる可能性があります。また、LINE の利用規約を確認し、自分のアカウントでの個人利用の範囲にとどめてください。試す場合は自己責任でお願いします。

環境

  • macOS(Apple Silicon)
  • Google Chrome 153
  • LINE Chrome 拡張 3.7.2
  • Node.js 25
  • @modelcontextprotocol/sdk 1.30 / puppeteer-core 25
  • クライアント: Claude Code、Claude Desktop

全体構成

MCP のツールは 4 つです。

ツール 内容
line_list_chats トーク一覧(新しい順・未読数つき)。名前での絞り込み、未読のみも可
line_read_chat トークの直近メッセージ。トーク名(部分一致)か ID で指定
line_reply_style 自分の返信の癖のまとめと、その相手との直近のやりとり数件
line_draft_reply 入力欄に返信の下書きを入れる(送信はしない)

1. Claude in Chrome では拡張のページに入れない

最初は、Chrome 拡張版の Claude(Claude in Chrome)で LINE の画面を読ませればいいと考えていました。ところが、LINE 拡張のページ(chrome-extension://…/index.html)を開こうとすると拒否されます。

Can't interact with browser-internal or unparseable URLs.

拡張機能のページは、他の拡張からは操作できません。そこで、CDP で Chrome に直接つなぐ方針にしました。

2. 普段の Chrome に CDP でつなぐ

Chrome 136 以降、既定のプロファイルでは --remote-debugging-port が効かなくなっています(Cookie を盗むマルウェア対策)。

一方、最近の Chrome には chrome://inspect/#remote-debugging から「今動いている Chrome」のリモートデバッグを有効にする機能があります。有効にすると、プロファイルのディレクトリに DevToolsActivePort というファイルが書き出されるので、そこから接続先を組み立てます。

import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import puppeteer from 'puppeteer-core';

const file = path.join(os.homedir(), 'Library/Application Support/Google/Chrome/DevToolsActivePort');
const [port, wsPath] = (await fs.readFile(file, 'utf8')).trim().split('\n');

const browser = await puppeteer.connect({
  browserWSEndpoint: `ws://127.0.0.1:${port}${wsPath}`,
  defaultViewport: null,
});

// LINE 拡張のタブを探す
const LINE_URL = 'chrome-extension://ophjlpahpchlmihnnnihgmmeilfjmjjc/index.html';
const target = browser.targets().find((t) => t.url().startsWith(LINE_URL) && t.type() === 'page');
const page = await target.page();

Claude in Chrome が入れなかった拡張のページにも、CDP なら page.evaluate で入れます。

3. DOM ではなく Redux ストアを読む

最初は DOM から一覧やメッセージを読むつもりでした。ところが、クラス名が chatlistItem-module__chatlist_item__MOwxh のような CSS Modules のハッシュ付きで、更新ですぐ壊れそうです。

そこで React の fiber をたどってみると、Redux ストアが見つかりました。

// LINE 拡張のページ内で実行
const root = document.getElementById('root');
const key = Object.keys(root).find((k) => k.startsWith('__reactContainer$'));

let store;
const stack = [root[key]];
while (stack.length) {
  const f = stack.pop();
  if (!f) continue;
  if (f.memoizedProps?.store?.getState) {
    store = f.memoizedProps.store;
    break;
  }
  if (f.sibling) stack.push(f.sibling);
  if (f.child) stack.push(f.child);
}

Object.keys(store.getState());
// => ['auth', 'chat', 'common', 'contact', 'message', 'messageBox', 'room', 'talkChat', 'ui']

中身はおおむね次のとおりです。

  • messageBox.entities.messageBoxes:トーク一覧(未読数、最新メッセージの ID)
  • message.entities.messages:メッセージ。復号済みの本文が text に入っている
  • contact.entities.contacts:友だちの表示名(自分で付けた名前は displayNameOverridden)
  • talkChat.entities.talkChats:グループ名

トーク一覧は、これを組み立てるだけで作れます。

4. 既読をつけずに履歴を読む

履歴は保存されていない

ストアに全トークの履歴が入っているわけではありません。持っているのは、各トークの最新メッセージと、画面で開いたことのあるトークの履歴だけです。IndexedDB も見ましたが、入っていたのは E2EE の公開鍵だけで、メッセージは保存されていませんでした。

つまり、履歴を取るにはトークを開く必要があります。しかし、トークを開くと既読がつきます。Claude に読ませただけで既読がつくのは困ります。

既読がどこで送られているか

拡張のバンドル(static/js/main.js)を読むと、既読通知は TalkService/sendChatChecked という API でした。呼んでいるのはトーク画面コンポーネントの useEffect と、一覧の「既読にする」メニューだけです。

一方、履歴の取得は createAsyncThunk で作られた thunk に分かれていました。

// main.js(整形・抜粋。実際は minify されていて createAsyncThunk も別名)
DC = createAsyncThunk('message/fetchMessageLists', async ({ messageBoxIdList, messagesCount }, { dispatch }) => {
  // getRecentMessagesV2 で取得 → 復号 → ストアに格納
});
jC = createAsyncThunk('message/fetchPrevMessageList', async ({ messageBoxId, endMessageId }, { dispatch }) => {
  // getPreviousMessagesV2WithRequest でさかのぼって取得
});

この thunk を画面を開かずに直接 dispatch すれば、既読はつかないはずです。

モジュール内の thunk を [[Scopes]] から取り出す

問題は、この thunk がバンドルのモジュールスコープに閉じていて、外から参照できないことです。webpack の __webpack_require__.c(モジュールキャッシュ)も、本番ビルドでは有効になっていませんでした。

そこで、DevTools で関数を展開したときに見える [[Scopes]] を使いました。CDP の Runtime.getProperties なら、関数が閉じ込めている変数を取り出せます。React のコンポーネント関数は main.js のモジュールスコープを閉じ込めているので、そこから typePrefix を持つ関数(createAsyncThunk の action creator)を探します。

const cdp = await page.createCDPSession();

// fnId: fiber から集めた React コンポーネント関数の objectId
const { internalProperties } = await cdp.send('Runtime.getProperties', { objectId: fnId, ownProperties: false });
const scopes = internalProperties.find((p) => p.name === '[[Scopes]]');
const { result: scopeList } = await cdp.send('Runtime.getProperties', {
  objectId: scopes.value.objectId,
  ownProperties: true,
});

for (const s of scopeList.filter((p) => p.value?.description === 'Closure')) {
  const { result: vars } = await cdp.send('Runtime.getProperties', { objectId: s.value.objectId, ownProperties: true });
  const fns = vars.filter((v) => v.value?.type === 'function');
  if (fns.length < 100) continue; // モジュールスコープは変数が多いので、それ以外は飛ばす

  // 関数をまとめてページ側に渡し、thunk だけ window に置く
  await cdp.send('Runtime.callFunctionOn', {
    objectId: fnId,
    functionDeclaration: `function (...fns) {
      window.__lineThunks ??= {};
      for (const f of fns) {
        try { if (typeof f.typePrefix === 'string') window.__lineThunks[f.typePrefix] = f; } catch {}
      }
    }`,
    arguments: fns.map((v) => ({ objectId: v.value.objectId })),
  });
}

これで message/fetchMessageLists や contact/fetchContacts など 25 個の thunk が取れました。あとは普通に dispatch するだけです。

await store.dispatch(
  window.__lineThunks['message/fetchMessageLists']({ messageBoxIdList: [chatId], messagesCount: 50 }),
);

本当に既読がつかないか確かめる

念のため、読み取り中の通信を page.on('request') で記録しました。未読 7 件のグループを 150 件分さかのぼったときの結果です。

getRecentMessagesV2                1
getPreviousMessagesV2WithRequest   2
getContactsV2                      1   ← 友だちではないメンバーの名前の取得
sendChatChecked                    0

sendChatChecked は一度も呼ばれず、読んだあとも未読数は 7 のままでした。

LINE for Chrome でさかのぼれる履歴は、ログインしてからおおむね直近 2 週間分でした。それより前は、サーバーから返ってきません。

5. 返信の下書きを入力欄に入れる

送信には message/enqueueTextMessage という thunk があり、技術的には送信もできます。ただ、送信は取り消しがききません。そのため、入力欄に下書きを入れるところまでにしました。

入力欄の中身もストアで管理されていて、普通の action で差し替えられます。

store.dispatch({
  type: 'common/updateChatRoomEditorInput',
  payload: { messageBoxId: chatId, input: { value: ['了解!明日10時に駅前で'] } },
});

入力欄は <textarea-ex> という独自要素(中は Shadow DOM の textarea)で、ストアの値がそのまま表示されます。トーク画面を開かないので、この時点でも既読はつきません。あとで自分がトークを開くと下書きが入っているので、確認して送信します。

入力欄に書きかけの文章があるときは上書きせずエラーを返し、ユーザーに確認してから overwrite: true で上書きする仕様にしています。

6. 自分の返信の癖を「学習」させる

モデル自体を再学習するのではなく、自分の過去の返信を Mac 内に貯めて、下書きのときに参考にさせる方式です。

集める

常駐プロセスが 30 分ごとに、自分の送信メッセージと、その直前の相手の発言(最大 3 件) だけを SQLite に保存します。データの置き方は次のようにしています。

  • 相手の名前は保存しない(「相手」「メンバー」に置き換え)
  • 保存先のディレクトリは 700、DB は 600。iCloud の同期対象外の場所に置く
  • ログには件数とエラーだけを書き、本文は書かない

集計する(AI は使わない)

集計はただのコードで行います。1 通の長さの中央値、文末の記号(。 ! ? 〜 笑)の割合、敬語の割合、連投の割合、よく使う絵文字・語尾・短い返事を数えます。

絵文字は、Intl.Segmenter で書記素単位に分けて数えます。そうしないと、🙇‍♂️ が 🙇 と ♂ に分かれて集計されます。

const graphemes = new Intl.Segmenter('ja', { granularity: 'grapheme' });
const IS_EMOJI = /\p{Extended_Pictographic}/u;
const emojisIn = (t) => [...graphemes.segment(t)].map((g) => g.segment).filter((g) => IS_EMOJI.test(g));

line_reply_style の出力はこんな感じです(数値ややりとりはダミーです)。

# あなたの返信の癖(Mac 内で集計)

## 全体
- 返信 500 回(メッセージ 800 件)
- 1 通の長さ: 中央値 8 文字 / 改行を含む 2%
- 連投(2 通以上に分けて送る): 40% / スタンプだけで返す: 3%
- 文末: 記号なし 70%、「!」 10%、「?」 8%、笑い(w/笑/草) 6%
- 敬語(です・ます等)を含む: 15% / 笑い表現を含む: 6%
- よく使う語尾: 〜よね(20) 〜った(18) 〜ない(15)
- 定番の短い返事: 「おけ」(8) 「了解」(6) 「ありがとう」(5)

## 「Aさん」への返信の傾向
- 1 通の長さ: 中央値 6 文字 / 敬語 0% / 笑い表現 12%
  ...

## 直近のやりとり(5 件)
相手: 明日の集合って何時だっけ?
自分: 10時! / 駅前ね

Claude には、下書きを書く前にこれを読んでから文体を合わせるよう、ツールの説明文で指示しています。同じ人でも、友だちには短文を連投し、就活の連絡には敬語で返すといった違いがはっきり出るので、相手ごとの傾向を分けて渡すのが効きました。

7. 「リモートデバッグを許可しますか」が毎回出る問題

ここで一番困りました。chrome://inspect/#remote-debugging で有効にした Chrome は、CDP の接続が来るたびに許可ダイアログを出します。

確認用スクリプトを走らせるたび、Claude Code のセッションを開くたび、自動収集が動くたびにダイアログが出て、とても使えません。

2 つの案を比べました。

  1. LINE 専用の Chrome を別プロファイルで起動する:既定でないプロファイルなら --remote-debugging-port が効くので、ダイアログは出ない。ただし LINE 拡張を入れ直し、ログインし直す必要があり、ウィンドウも普段の Chrome と分かれる
  2. 接続を 1 本にまとめる:常駐プロセスが CDP の接続を持ち続け、MCP サーバーはそれを経由する

2 を採用しました。ダイアログは Chrome を起動したときの 1 回だけになります。

  • 常駐プロセスは launchd の LaunchAgent で動かす
  • MCP サーバーとは Unix ソケット(パーミッション 600)で話す
  • DevToolsActivePort を 5 秒ごとに見張り、Chrome が起動したらすぐ接続する。ダイアログが Chrome 起動の直後に出るので、何の確認か分かりやすい
  • 許可を拒否された接続先には、Chrome が再起動するまで自動では再接続しない(ダイアログを連発しない)
// MCP サーバー側は Chrome に直接つながず、常駐プロセスを呼ぶだけ
server.registerTool(
  'line_read_chat',
  {
    title: 'LINE トークを読む',
    description: '指定したトークの直近のメッセージを古い順に返す。トーク画面を開かずに取得するので既読は付かない。',
    inputSchema: {
      chat: z.string().min(1).describe('トーク名または id'),
      limit: z.number().int().min(1).max(500).optional(),
    },
    annotations: { readOnlyHint: true },
  },
  // call は Unix ソケット経由で常駐プロセスを呼ぶ関数。format(テキストへの整形)は省略
  ({ chat, limit }) => run(async () => format(await call('readChat', { chat, limit: limit ?? 30 }))),
);

その他のハマりどころ

page.evaluate に Infinity を渡すと null になる

「全件」のつもりで limit: Infinity を渡したら、一覧が空になりました。引数は JSON にシリアライズされるので、Infinity は null になり、slice(0, null) で 0 件です。大きな数値を渡すようにしました。

内部関数の取り出しが並行すると壊れる

自動収集と Claude からの呼び出しが重なると、thunk を取り出す処理が 2 本同時に走ります。すると、後から来た方が window.__lineThunks を空で上書きします。取り出しが同時に 1 つしか走らないよう、処理中の Promise を使い回すようにしました。

let bridgeReady = null;
bridgeReady ??= ensureBridge(page).finally(() => (bridgeReady = null));
await bridgeReady;

使ってみた

Claude Code で「LINE の未読を教えて」と頼むと、line_list_chats と line_read_chat を呼んで、次のように分類してまとめてくれます。

  • 友だち・個人(返信が要りそうなもの)
  • グループ
  • 公式アカウント・宣伝

「〇〇に返信の下書き作って」と頼むと、line_reply_style で癖を確認してから line_draft_reply を呼びます。LINE を開くと入力欄に下書きが入っているので、確認して送信するだけです。

プライバシーについて

LINE には友だちのメッセージも含まれるので、データの流れは意識しておく必要があります。

  • Claude に読ませた内容は Anthropic に送られます。Claude はクラウドで動いているので、これは避けられません。個人向けプラン(Free / Pro / Max)では、プライバシー設定で「学習に使うか」を選べます。OFF なら学習には使われず、保存期間は 30 日です(Claude Code のデータの扱い)。私は OFF にしました
  • /feedback や /bug、セッション評価のあとの「会話の記録を送りますか?」で Yes を選ぶと、会話の記録が別途送られます。LINE を扱ったセッションでは使わないようにしています
  • 返信の履歴 DB と集計は Mac 内で完結させ、Claude に渡すのは「癖のまとめ」と「その相手との直近数件」(←自分はこれ無くしてますが、精度上げるなら保持すべき)だけにしています
  • 完全にクラウドに出したくない場合は、下書きをローカル LLM(Ollama など)に書かせるべきです。私はOllamaで動かしてます

制約・注意点

  • 公式 API ではないので、LINE 拡張の更新で壊れる可能性がある
  • 履歴はおおむね直近 2 週間分まで
  • 下書きは LINE 拡張のメモリ上にあるだけなので、LINE のウィンドウを閉じると消える
  • ChatGPT からは使えない。ChatGPT はネット越しに届く MCP サーバーしか使えず、LINE の中身を外から読める入口を作ることになるので見送った

おわりに

「Claude in Chrome で読めばいい」と軽く始めましたが、拡張のページに入れないところから、CDP、React の fiber、[[Scopes]] からの thunk の取り出しと、思ったより深いところまで潜ることになりました。

特に効いた設計判断は次の 3 つです。

  • DOM ではなくアプリ自身の状態(Redux ストア)と処理(thunk)を使うことで、表示の変化に強く、既読もつけずに済んだ
  • 送信は人がやる。下書きまでにしておくと、安心して任せられる
  • 口調の「学習」は、ローカルで数えるだけでもかなり効く

同じように LINE の未読に埋もれている人の参考になればうれしいです。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?