1
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?

「次へ」を覚える Chrome 拡張機能を作って考えた、壊れにくい CSS セレクタの作り方

1
Posted at

はじめに

Webサイトには「前へ」「次へ」「前の画像」「次の章」のようなボタンがあります。しかし、サイトごとにマークアップやクラス名が異なるため、矢印キーだけで操作したいと思っても、ブラウザ標準の機能だけでは実現できません。

最近では、矢印キーによるページ移動に対応した Web サイトも増えています。しかし、すべてのサイトに同じ機能があるわけではありません。そこで、未対応のサイトでも同じ操作感を得られるよう、この拡張機能を作りました。

この記事では、ページ上のリンクやボタンを一度クリックして記憶し、以後は / キーで対象要素の通常の click 動作を実行する Chrome 拡張機能を題材にします。完成したコードは Arrow Button Mapper で公開しています。

単に CSS セレクタを保存するだけでは、利用者に開発者ツールの知識を要求してしまいます。そこで、操作を記憶する導線、ページ固有設定の優先順位、動的なページ番号を含むリンクの扱い、入力欄を壊さないキーイベント処理、設定の移行までを一つの設計として扱います。

結論

この種の拡張機能では、次の5点を分けて設計すると扱いやすくなります。

論点 設計
操作の登録 利用者が対象要素をクリックし、拡張機能が CSS セレクタを自動生成します。
適用範囲 URL条件を前方一致にし、より具体的な条件を優先します。
キーイベント 入力欄や修飾キー付き操作は無視し、対象要素をクリックできた場合だけページ側のキー処理を止めます。
設定保存 chrome.storage.sync に有効状態と操作ペアを保存し、JSON で移行できます。
失敗時の逃げ道 自動生成したセレクタはポップアップから手編集できます。

この分離により、普段はクリック記憶だけで使え、例外的なページだけ手動調整へ進めます。

なぜ「セレクタを入力してください」では足りないのか

開発者にとっては #nextbutton.next のような CSS セレクタは自然です。しかし一般の利用者は、どの要素を選べばよいか、クラス名が安定しているか、ページ番号を含む href を固定してよいかを判断しにくいものです。

たとえば次ページへのリンクが次のように変化するとします。

<a href="/articles?page=4">次のページへ</a>

このリンクに a[href="/articles?page=4"] を保存すると、次ページへ移動した後には一致しなくなります。ページ番号は状態であり、操作の意味ではありません。拡張機能が保存したいのは「一覧の次へ」という役割です。

そこで、利用者には対象をクリックしてもらい、拡張機能側で意味のある手掛かりからセレクタを組み立てます。自動生成に失敗した場合だけ、保存済みの値を編集できるようにします。

全体構成

Chrome 拡張機能のコンテンツスクリプトは、閲覧中のページの DOM を読み書きできます。また、storage やメッセージングの API にアクセスできます。ページ本体の JavaScript とは通常は分離された実行環境で動くため、DOM 操作を担わせつつ、ページ側の変数や関数へ直接依存しない設計にできます。Chrome の Content scripts では、この実行環境の分離が説明されています。

Arrow Button Mapper では、ページ側の操作を content.js、設定画面をポップアップに分けました。

ポップアップは、現在のタブに「左キー用の操作を記憶する」「右キー用の操作を記憶する」という開始メッセージだけを送ります。ページ側で録画開始が受理されたらポップアップを自動で閉じ、その後の対象クリック、セレクタ生成、保存はコンテンツスクリプト側で完結させます。これにより、開始後にポップアップを閉じるためだけのクリックは不要です。

URL条件は前方一致にして、具体的な条件を優先する

サイト全体向けの設定と、特定画面向けの設定を両立させるには、完全一致だけでは不便です。次のような親子関係を登録できるようにします。

URL条件 用途
https://reader.example.com/ サイト共通の左右操作
https://reader.example.com/book/ 書籍ビューア専用の左右操作
https://reader.example.com/book/42/ 特定の書籍だけに使う操作

現在の URL に複数の条件が一致したときは、固定部分が長い条件を優先します。これにより、/book/42/ を開いた場合はサイト共通設定ではなく、書籍専用または個別設定が選ばれます。

実装では、* を任意文字列として使いつつ、条件全体を正規表現へ変換しています。* がない条件も前方一致にすることで、利用者は https://reader.example.com/book/ のような自然な入力だけで配下ページを対象にできます。

function matchesUrlPattern(url, pattern) {
  const expression = `^${pattern.split("*").map(escapeRegExp).join(".*")}`;
  return new RegExp(expression).test(url);
}

優先順位は、最初の * より前の固定部分の長さ、固定文字列全体の長さ、ワイルドカード数の順に比較します。単に文字数だけで比較すると、ワイルドカードが多い条件を誤って具体的と判断するためです。

クリックを記憶する流れ

録画モードでは、ページに一時的なクリックリスナーをキャプチャ段階で登録します。対象をクリックしたら、そのリンクやボタンを特定し、セレクタを生成して保存します。

重要なのは、記憶中のクリックで通常のリンク遷移と、この時点より後のクリック処理を抑止することです。もし次ページリンクをクリックした瞬間に画面遷移すると、保存結果を確認する前にページが移動してしまいます。preventDefault() は既定動作を抑止し、stopImmediatePropagation() は以後のイベントリスナーを止めます。MDN の preventDefault()stopImmediatePropagation() にあるとおり、すでに先に実行されたページ側リスナーの副作用までは取り消せません。

function handleRecordedClick(event) {
  const target = getRecordableTarget(event);
  if (!target) return;

  event.preventDefault();
  event.stopImmediatePropagation();

  const { direction } = recorder;
  const selector = buildSelector(target);
  saveRecordedOperation(direction, selector);
}

対象にできるのは、abutton、送信ボタン、role="button"role="link" を持つ要素です。テキストやアイコンの子要素をクリックしても、closest() で親の操作要素までたどります。

保存後の矢印キー処理は HTMLElement.click() を呼びます。このメソッドは要素の click イベントを発生させますが、pointerdownmousedown、座標、ドラッグのようなポインター操作全体を再現するものではありません。MDN の HTMLElement.click() も、要素のクリックをシミュレートして click イベントを発生させる API と説明しています。したがって、ドラッグやポインターイベントを前提にした画面では期待どおりに操作できない場合があります。

壊れにくいセレクタを選ぶ順序

自動記憶の品質は、セレクタの選び方で決まります。クラス名だけに頼ると、CSS Modules のハッシュやデザイン変更で壊れやすくなります。一方、DOM の階層だけに頼ると、レイアウト変更の影響を受けます。

そこで、候補を次の順序で試し、ページ内で一意に一致する最初の候補を採用します。

優先順位 候補 理由
1 id ページ内で一意になることが期待でき、意味も読み取りやすいためです。
2 data-testiddata-qaaria-labelname テストやアクセシビリティ、フォーム用途のために付けられた属性は役割が比較的安定しています。
3 既知の動的クエリを除いた href の固定部分とナビゲーション内の位置 pagepoffsetcursor の値が変化する場合に、固定部分を使って次ページ・前ページを区別します。
4 完全な href 固定 URL のリンクでは単純で分かりやすい候補です。
5 DOM 階層と nth-of-type() 安定した属性がない場合の最後の手段です。

ページ番号を含む href では、固定部分を使った候補を先に作ります。さらに nav 内にある場合は、先頭または末尾かどうかを候補に含めます。

const hrefPrefixSelector = 'a[href^="/articles?page="]';
const candidate = `nav ${hrefPrefixSelector}:last-child`;

ただし、この候補は常に有効ではありません。li で各リンクをラップしたページネーションでは、複数のリンクがそれぞれの親の末尾になるため、:last-child を含む候補が一意にならないことがあります。実装は候補が対象要素だけに一意に一致するかを確認し、一意でなければ完全な href や構造セレクタへ進みます。

また、動的な値として特別扱いするのは pagepoffsetcursor という既知のクエリ名だけです。URL の変化規則を推測できないリンクや、ページネーションの構造が複雑なページでは、自動生成したセレクタが次のページでも一致しないことがあります。自動生成は万能ではないため、ポップアップに編集画面を残しておくことが実用上重要です。

矢印キーを奪いすぎない

矢印キーは、テキスト入力、セレクトボックス、スライダー、アクセシブルなコンポーネントでも使われます。ページ全体で無条件に preventDefault() をすると、入力操作を壊してしまいます。

そのため、キーイベントは次の順に判定します。

  1. 録画中、拡張機能が無効、すでに別の処理でキャンセル済み、IME入力中なら何もしません。
  2. Ctrl、Meta、Alt が押されている場合は何もしません。
  3. inputtextareaselectcontenteditable、テキスト入力系 ARIA ロールの内部なら何もしません。
  4. または 以外なら何もしません。
  5. URL条件に対応するセレクタがあり、実際にクリックできたときだけイベントを止めます。
const clicked = clickSelector(selector);
if (clicked) {
  event.preventDefault();
  event.stopImmediatePropagation();
}

この順序なら、設定漏れや無効化されたボタンがあるページでは、ブラウザとサイト本来の挙動を維持できます。

設定を chrome.storage.sync と JSON で扱う

拡張機能の設定は、利用者ごとに保存する必要があります。chrome.storage は拡張機能向けの保存 API であり、コンテンツスクリプトを含む拡張機能の各コンテキストから利用できます。chrome.storage の公式リファレンスでは、sync 領域が同期利用時にログイン中の Chrome 間で設定を共有できることも説明されています。

保存する値は、有効状態と URL条件・左右セレクタの配列だけに絞ります。

{
  "enabled": true,
  "mappings": [
    {
      "urlPattern": "https://reader.example.com/book/",
      "leftSelector": "button.previous",
      "rightSelector": "button.next"
    }
  ]
}

JSON のエクスポートとインポートを用意すると、端末や Chrome プロファイルをまたぐ移行とバックアップに使えます。インポートでは JSON の形式、URL条件、CSS セレクタを確認し、置き換え前に確認ダイアログを出します。

storage.sync には合計約 100 KB、1項目あたり約 8 KB の容量制限があります。mappings 配列全体を1項目として保存する実装では、設定を大量に登録すると保存に失敗する可能性があるため、容量超過を案内します。多数の設定を常用する場合は、storage.local との使い分けも検討します。chrome.storage

対象にできるページの範囲

この拡張機能の Manifest は http://*/*https://*/* にコンテンツスクリプトを登録し、all_framesfalse にしています。したがって対象は通常の http / https ページのトップレベルフレームです。chrome:// ページ、Chrome Web Store、拡張機能ページ、iframe 内にあるボタンは対象外です。

検証は「キー操作」と「記憶」を分ける

この拡張機能では、ブラウザを手で操作する前に Node.js の VM と最小限の DOM モックで主要な分岐を検証しています。キー操作、クリック記憶、ポップアップの責務ごとにスクリプトを分けると、変更の影響を追いやすくなります。

リポジトリ直下で npm test を実行すると、構文チェックと以下の検証をまとめて実行できます。

検証スクリプト 確認すること
verify-content.js 親子 URL条件の優先順位、前方一致、入力欄の保護、無効要素をクリックしないこと
verify-recording.js クリック記憶が遷移前にイベントを止め、動的なページ番号に依存しないセレクタを保存すること
verify-popup.js 録画開始後にポップアップを閉じること、JSON エクスポート、確認付きインポート、保存失敗時に画面状態を保存済みの値へ保つこと

特に、ページ番号が変わる次ページリンクで query を除いた URL条件と href の固定部分が保存されること、左右の録画で対応するセレクタだけが更新されることを確認します。

これらはブラウザを使わない単体寄りの検証です。実サイト上の DOM、イベント伝播の順序、Shadow DOM、SPA の画面遷移、サイト固有のポインター処理を保証するものではありません。公開前には、対象にしたい代表的なサイトで手動確認を追加します。

導入手順

リポジトリを取得したら、Chrome の chrome://extensions でデベロッパーモードを有効にし、manifest.json があるフォルダを「パッケージ化されていない拡張機能」として読み込みます。展開済み拡張機能の読み込み方法は、Chrome Extensions: Hello World でも確認できます。

読み込み後は、対象ページを開き、ポップアップから ← の操作を記憶 または → の操作を記憶 を選んでください。案内が出たら操作したいボタンを一度クリックします。保存後は同じページの URL条件に対して矢印キーが有効になります。

まとめ

ページ固有のボタンをキーボードへ割り当てる仕組みでは、CSS セレクタを保存するだけでは利用者に負担が残ります。クリック記憶を入口にし、URL条件の優先順位、安定したセレクタの選択、入力欄の保護、手動編集、設定移行を組み合わせると、サイトごとの差を吸収しやすくなります。

特に重要なのは、動的な値をそのまま保存しないことと、キーイベントをクリック成功時だけ止めることです。この2点を守ると、ページ送りのような反復操作を便利にしながら、既存サイトの操作感を壊しにくくなります。

参考資料

1
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
1
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?