Chrome拡張機能で業務画面からデータを取得するとき、最初はDOMの表を読むだけで足りるように見えます。
しかし、実際の業務画面がSPA、仮想スクロール、非同期更新、ログイン済み状態、長時間処理を含む場合、単純なquerySelectorAll()と固定待機だけでは安定しません。さらに、取得結果をGoogle Apps Scriptへ送るなら、再送時の二重登録も防ぐ必要があります。
この記事では、Chrome拡張機能でSPA画面を取得し、失敗時に止まりにくく、GAS側へ安全に送るための設計をまとめます。
この記事で扱う設計
- SPA内部状態を読むためのMAINワールド注入
- DOM更新検知による固定待機の削減
- 仮想スクロール表を期間境界まで取得する方法
- 条件単位タイムアウトと末尾再試行キュー
- 再試行結果を追跡する構造化監査ログ
- 複数Chromeから同一GASへ送るときの排他制御と冪等性
- actionバッジによる実行状態の見える化
取得処理と送信処理は別物ですが、運用上は一続きです。取得が不安定なら再試行が必要になり、再試行するなら二重登録対策が必要になります。
SPA内部状態はMAINワールドで読む
Chrome拡張機能のcontent scriptは、通常はページと分離されたisolated worldで動きます。これは安全ですが、ページ側のwindow.__NUXT__やwindow.__NEXT_DATA__のようなSPA内部状態を直接読めません。
この場合は、取得専用スクリプトをworld: 'MAIN'で注入し、ページと同じ実行環境から値を読みます。ただし、MAINワールドではchrome.runtime.sendMessage()のような拡張APIを直接使えません。そこで、MAINワールドとisolated worldをwindow.postMessage()でつなぎます。
// background.js
await chrome.scripting.executeScript({
target: { tabId },
files: ['collector-main.js'],
world: 'MAIN'
});
await chrome.scripting.executeScript({
target: { tabId },
files: ['collector-bridge.js'],
world: 'ISOLATED'
});
// collector-main.js
const payload = {
type: 'APP_COLLECTED',
data: window.__NUXT__ || window.__NEXT_DATA__ || null
};
window.postMessage(payload, location.origin);
// collector-bridge.js
window.addEventListener('message', (event) => {
if (event.origin !== location.origin) return;
if (event.data?.type !== 'APP_COLLECTED') return;
chrome.runtime.sendMessage({ type: 'COLLECTED', data: event.data.data });
});
postMessageの宛先は'*'にせず、原則としてlocation.originに限定します。また、同じタブへ何度も注入する場合は、二重注入を避けるフラグも持たせます。
if (window.__myCollectorInjected) return;
window.__myCollectorInjected = true;
Manifest V3ではService Workerが停止する可能性があります。長い取得ループをService Worker側に置き続けるより、ページに注入した取得側へ寄せ、進捗だけをchrome.storage.localへ保存する方が復旧しやすくなります。
固定待機ではなくDOMの安定を待つ
業務画面では、クリック後にsetTimeout(1200)で待つ実装をよく書きます。短いと取りこぼし、長いと全体が遅くなります。条件が増えるほど、この固定待機は処理時間を押し上げます。
代わりに、画面の状態から軽いシグネチャを作り、一定時間変化しなくなるまで待ちます。
function getTableSignature() {
const rows = [...document.querySelectorAll('[data-row]')];
const first = rows[0]?.textContent?.trim() || '';
const last = rows.at(-1)?.textContent?.trim() || '';
return `${rows.length}|${first}|${last}`;
}
async function waitForStableDom({ timeoutMs = 8000, stableMs = 400 } = {}) {
return new Promise((resolve, reject) => {
let last = getTableSignature();
let stableSince = Date.now();
const started = Date.now();
const timer = setInterval(() => {
const now = getTableSignature();
if (now !== last) {
last = now;
stableSince = Date.now();
}
if (Date.now() - stableSince >= stableMs) {
clearInterval(timer);
resolve(now);
}
if (Date.now() - started >= timeoutMs) {
clearInterval(timer);
reject(new Error('DOM更新待ちがタイムアウトしました'));
}
}, 100);
});
}
MutationObserverを使う場合も、更新のたびに即判定せず、短いdebounceを入れます。描画途中の一瞬を拾うと、行数だけ合って中身が古いという失敗が起きます。
仮想スクロール表は期間境界まで取り切る
仮想スクロールの表は、画面に見えている行だけがDOMにあります。全件取得したつもりでも、DOMを読んだだけでは表示中の数十件しか取れていないことがあります。
終了条件は、単に「最下部に着いた」では足りません。次のような複数条件で止めます。
| 終了条件 | 目的 |
|---|---|
| 期間境界より古い行を確認した | 必要範囲を取り切ったと判断する |
| 最下部に到達し、新しい行が増えない | 全件末尾を確認する |
| 一定時間または最大スクロール数を超えた | 無限ループを防ぐ |
| ユーザーが停止した | 手動停止を最優先する |
行の重複排除には、表示上のキーを使います。
function buildVisibleRowKey(row) {
return [
row.querySelector('.time')?.textContent?.trim(),
row.querySelector('.name')?.textContent?.trim(),
row.querySelector('.amount')?.textContent?.trim(),
row.querySelector('.status')?.textContent?.trim()
].join('\t');
}
これは最終的な一意キーではなく、取得中の重複除去用です。保存時のキーは、日時、対象ID、種別など、業務データとして安定した列で別に作ります。
日付順が完全に保証されていない表では、最初に古い行が1件見えただけで止めない方が安全です。一定数の古い行、または最下部確認と組み合わせます。
失敗した条件は末尾へ戻して再試行する
取得対象が複数条件に分かれる場合、ひとつの条件で詰まって全体を止めると運用しにくくなります。条件ごとにタスク化し、タイムアウトしたものはキュー末尾へ戻します。
async function runQueue(tasks) {
const queue = [...tasks];
const failed = [];
while (queue.length > 0) {
const task = queue.shift();
if (await shouldStop()) break;
try {
await runWithTimeout(task, 30_000);
await markTaskDone(task);
} catch (error) {
task.retries = (task.retries || 0) + 1;
if (task.retries <= 2) {
queue.push(task);
} else {
failed.push({ task, message: error.message });
}
} finally {
await saveCheckpoint({ queue, failed, currentTask: null });
}
}
return { failed };
}
タイムアウト時は、開いたタブを閉じる、処理中フラグを保存する、手動停止を優先する、といった後片付けも必要です。
async function runWithTimeout(task, timeoutMs) {
return Promise.race([
runTask(task),
new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), timeoutMs))
]);
}
監査ログはUI文言ではなく構造化データで残す
失敗原因を後から追うには、画面表示用の文章だけでは足りません。実行ID、タスクID、URL、条件、試行回数、開始終了時刻、失敗ステージ、最終状態を構造化して保存します。
const log = {
runId,
taskId,
url: sanitizeUrl(url),
condition: task.condition,
attempt: task.retries + 1,
startedAt,
finishedAt: new Date().toISOString(),
durationMs,
status: 'failed',
stage: 'fetch',
message: error.message,
finalStatus: 'retry_scheduled'
};
ログには、トークン、Cookie、ページ本文、人名、案件名、内部URLを残さないようにします。必要ならURLのドメインだけ、またはマスク済みの識別子だけを保存します。
通常成功の詳細ログを全件残すと、ストレージがすぐ膨らみます。詳細ログは再試行、失敗、異常終了を中心に残し、成功は件数と時刻だけにするのが現実的です。
GAS送信はLockServiceと冪等キーで守る
複数のChromeから同じGoogle Apps Scriptへ送る場合、GAS側は同時実行されます。LockServiceだけでも競合は減りますが、通信失敗後の再送では二重登録が起きます。
必要なのは次の3層です。
| 層 | 役割 |
|---|---|
| LockService | 同時実行中の書き込み競合を防ぐ |
| requestId | リクエスト単位の二重処理を防ぐ |
| recordKey | レコード単位の重複登録を防ぐ |
Chrome側は、同じ送信を再試行するときに同じrequestIdを使い回します。
const payload = {
requestId: saved.requestId || crypto.randomUUID(),
senderId,
sentAt: new Date().toISOString(),
records
};
GAS側はロック内でrequestIdを確認し、処理済みなら同じ結果を返します。さらにレコードごとの業務キーでも重複を防ぎます。
function doPost(e) {
const lock = LockService.getScriptLock();
lock.waitLock(30_000);
try {
const payload = JSON.parse(e.postData.contents);
if (isProcessedRequest_(payload.requestId)) {
return json_({ ok: true, duplicateRequest: true });
}
const result = upsertRecords_(payload.records);
markRequestProcessed_(payload.requestId, result);
return json_({ ok: true, result });
} finally {
lock.releaseLock();
}
}
Chrome側では、送信結果をconfirmed、rejected、unconfirmedに分けます。ネットワーク切断やタイムアウトのように、GAS側で成功したか分からないものはunconfirmedとして残し、同じrequestIdで再送します。
実行状態はactionバッジにも出す
長時間処理では、ポップアップを閉じると実行中かどうか分からなくなります。Chrome拡張機能のactionバッジに状態を出すと、ユーザーが判断しやすくなります。
async function applyActionBadge(status) {
const table = {
idle: { text: '', color: '#94A3B8' },
running: { text: 'RUN', color: '#2563EB' },
retry_wait: { text: 'R', color: '#F97316' },
error: { text: '!', color: '#DC2626' }
};
const item = table[status] || table.idle;
await chrome.action.setBadgeText({ text: item.text });
await chrome.action.setBadgeBackgroundColor({ color: item.color });
}
chrome.storage.localにも状態を保存し、Chrome起動時や拡張機能更新時に復元します。古いrunningが残っていたら、前回異常終了として扱い、ユーザーが再開または破棄を選べるようにします。
まとめ
Chrome拡張機能の業務取得は、DOMを読む処理よりも、失敗したときにどう戻すかの設計が重要です。
- SPA内部状態はMAINワールドで読み、拡張APIとはpostMessageで橋渡しする
- 固定待機を減らし、DOMの安定状態を待つ
- 仮想スクロールは期間境界と最下部確認で取り切る
- 条件単位でタイムアウトし、失敗タスクは末尾へ戻す
- 監査ログは構造化し、機密情報は残さない
- GAS送信はLockService、requestId、recordKeyで二重登録を防ぐ
- actionバッジで実行状態を常時見えるようにする
このあたりを最初から入れておくと、運用後に「止まった」「同じデータが増えた」「どこまで終わったか分からない」という問題をかなり減らせます。
