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?

任意の2サイトをAI比較できるChrome拡張をゼロから作ってストア公開するまで

0
Posted at

2つのWebサイトを並べて Gemini AI に比較させる Chrome 拡張機能「DualyzeAI」を作り、Chrome ウェブストアに公開しました。

作ってみて想定外に詰まったポイントが複数あったので、実装の記録として残しておきます。

Chrome Web Storeで公開中

拡張機能の概要

  • 2つのURLを入力するとサイドバイサイドで表示
  • 6種類のAI分析モード(比較・価格比較・メリデメ・要約・要点・おすすめ判定)
  • サイト同士だけでなく「サイト + テキスト」「テキスト + テキスト」にも対応
  • BYOK(Bring Your Own Key)方式で Gemini API キーを使用
  • Manifest V3(MV3)/ 純粋な JavaScript(ビルドツールなし)

スマートフォン2機種を並べてAIが比較表を生成している画面

詰まりポイント① iframe がほぼ全滅する問題

問題

X-Frame-Options: SAMEORIGINContent-Security-Policy: frame-ancestors 'none' を設定しているサイトは iframe でロードできません。Amazon・Google・Apple・Samsung など、比較したいサイトの大半がこれに該当しました。

解決策:declarativeNetRequest でレスポンスヘッダーを除去

Manifest V3(MV3)では declarativeNetRequest API でレスポンスヘッダーをルールベースで書き換えることができます。

rules.json

[
  {
    "id": 1,
    "priority": 1,
    "action": {
      "type": "modifyHeaders",
      "responseHeaders": [
        { "header": "X-Frame-Options", "operation": "remove" },
        { "header": "Content-Security-Policy", "operation": "remove" }
      ]
    },
    "condition": {
      "resourceTypes": ["sub_frame"]
    }
  }
]

manifest.json

{
  "declarative_net_request": {
    "rule_resources": [{
      "id": "ruleset_1",
      "enabled": true,
      "path": "rules.json"
    }]
  },
  "permissions": ["declarativeNetRequest", "declarativeNetRequestFeedback"]
}

sub_frame のみに適用することで、メインフレームのリクエストには影響しません。これでほぼすべてのサイトを表示できるようになりました。

詰まりポイント② iframe 内のページ遷移を追跡できない問題

問題

iframe 内でリンクをクリックしてページが遷移したとき、上部のURLバーを更新したいのですが、iframe.src はリダイレクト後のURLを反映しません。chrome.webNavigation.getAllFrames でURLの完全一致を試みても、リダイレクトが挟まるとマッチしません。

たとえば google.com にアクセスすると www.google.com にリダイレクトされるため、getAllFramesgoogle.com を探しても見つからないケースが発生します。

解決策:onCompleted + ホスト名照合で frameId を特定

let frame1Id = null;
let pendingHost1 = null; // ロード中のホスト名

chrome.webNavigation.onCompleted.addListener((details) => {
  if (!currentTabId || details.tabId !== currentTabId) return;
  if (details.frameId === 0) return; // メインフレームは無視
  if (!details.url.startsWith('http')) return;

  const host = new URL(details.url).hostname.replace(/^www\./, '');

  // 初回: ホスト名マッチで frameId を取得
  if (frame1Id === null && pendingHost1 && host === pendingHost1) {
    frame1Id = details.frameId;
    pendingHost1 = null;
    url1Input.value = details.url;
    return;
  }
  // 以降: frameId で追跡
  if (details.frameId === frame1Id) {
    url1Input.value = details.url;
  }
});

function loadFrame(iframe, url) {
  frame1Id = null;
  pendingHost1 = new URL(url).hostname.replace(/^www\./, '');
  iframe.src = url;
}

「初回だけホスト名で照合して frameId を特定し、それ以降は frameId で追跡する」という2段階の設計にすることで、リダイレクトがあっても確実に追跡できるようになりました。

詰まりポイント③ クォータエラーの自動フォールバックが逆効果

問題

Gemini API の無料枠には上限があり、超えると 429 が返ってきます。最初は Flash が失敗したら自動で Flash Lite に切り替える実装にしていましたが、これだと:

  1. Flash でリクエスト失敗(クォータを消費)
  2. Flash Lite で再リクエスト(さらにクォータを消費)
  3. Flash Lite も失敗

消費が加速する一方で、待ち時間も延びてしまいました。

解決策:ユーザーに確認してから切り替える

// background.js: エラー情報を返す
if (response.status === 429) {
  const retryAfter = parseInt(response.headers.get('Retry-After') ?? '60');
  return {
    error: 'QUOTA_EXCEEDED',
    retryAfter,
    failedModel: model
  };
}

// viewer.js: ダイアログで確認してから切り替え
if (result.error === 'QUOTA_EXCEEDED') {
  const fallback = result.failedModel.includes('flash-lite')
    ? 'gemini-2.5-flash'
    : 'gemini-2.5-flash-lite';

  if (confirm(`${currentModelName}のレート制限に達しました。${fallbackName}に切り替えますか?`)) {
    analyze({ modelOverride: fallback });
  } else {
    startCountdown(result.retryAfter);
  }
}

カウントダウンは Date.now() ベースで実装しています。setInterval はバックグラウンドタブで停止することがあるため、visibilitychange で復帰時に残り時間を再計算しています。

function startCountdown(seconds) {
  const resetAt = Date.now() + seconds * 1000;

  function tick() {
    const remaining = Math.ceil((resetAt - Date.now()) / 1000);
    if (remaining <= 0) {
      countdownEl.textContent = '';
      return;
    }
    countdownEl.textContent = `リセットまで ${remaining}秒`;
    requestAnimationFrame(tick);
  }

  document.addEventListener('visibilitychange', () => {
    if (!document.hidden) tick();
  });
  tick();
}

ラップトップ2機種のメリット・デメリットをAIが分析している画面

Chrome Web Store 審査で気をつけたこと

declarativeNetRequest でヘッダーを書き換える拡張機能は審査で引っかかりやすいと思っていましたが、結果的に一発通過でした(申請から公開まで約6日)。

申請時に意識した点をまとめます。

Why do you need this permission? に具体的なユースケースを書く

「ユーザーが任意の2サイトを並列表示するために X-Frame-Options と CSP を除去する必要がある。対象は sub_frame のみで、メインフレームには影響しない」という内容を記述しました。

プライバシーポリシーを用意する

BYOK なのでサーバーサイドでのデータ収集は一切ありませんが、それでもプライバシーポリシーのページを用意してURLを登録しました。

APIキーの扱いを説明文に明記する

chrome.storage.local に保存するだけで外部サーバーに送信しないことを、ストアの説明文にも明示しました。

まとめ

課題 解決策
iframe が X-Frame-Options でブロックされる declarativeNetRequest でレスポンスヘッダーを除去
iframe 内のナビゲーションを追跡できない onCompleted + ホスト名照合で frameId を特定
自動フォールバックがクォータを加速消費する ユーザー確認ダイアログ方式に変更
バックグラウンドタブでカウントダウンが止まる Date.now() ベース + visibilitychange で再計算

Chrome 拡張(Manifest V3)でiframeを扱う機会がある方の参考になれば幸いです。

公式サイト: https://dualyzeai.com

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?