Edgeの拡張機能を自作する(プルダウンの中身を削る)

モダンWebにはJavaScriptが必要。
ということでJavaScriptの練習を兼ねてEdgeの拡張機能を作ってみる。

# 参考

- [ブラウザの拡張機能を自作してみた](https://qiita.com/m_hama123/items/aa3101478a8458d61689)
- [新Edgeの拡張機能の作り方 (コンテキストメニュー編)](https://qiita.com/economist/items/7cd5bd01cbe357646c03)
- [入門編～アイコン・【メモ】manifest.jsonの設定～ via zenn:Chrome拡張機能開発](https://zenn.dev/timtamtaberu/books/91f7b9f1afc643/viewer/000c39)
    - iconsの説明を参考
- [Chrome拡張をつくるための設定ファイルに関するお話](https://zenn.dev/rie_amasato/articles/cd3d209920a650)
    - contents_scriptの説明を参考
- [【JavasScript】use strictとは](https://qiita.com/miri4ech/items/ffcebaf593f5baa1c112)
- [動的なページの読み込みが完了してからChrome拡張機能を実行する方法](https://qiita.com/3mc/items/c3c580ca5de4a2d3990d)
    - window.addEventListenerの説明を参考
- [Document: querySelectorAll() メソッド](https://developer.mozilla.org/ja/docs/Web/API/Document/querySelectorAll)
- [属性セレクター](https://developer.mozilla.org/ja/docs/Web/CSS/Attribute_selectors)
    - query.SelectorAllのなかでどうやって要素を特定するかを参考
- [【技術Tips:2】Webサイトのドロップダウンリスト一括取得／JavaScript](https://www.itschool-1st.com/2022/07/21/tips2-get-dropdown-list/)
- [Chrome extensionでselectのリストをフィルタする](https://qiita.com/ota-meshi/items/08f96d5a7e0a0fa72c4c)
    - 表示/非表示を制御したリストをWebページに反映させる方法
- [JavaScriptの文字列マッチングまとめ(indexOf, lastIndexOf)](https://qiita.com/aqril_1132/items/9f69575bfbcf24bdf7b5)
- 尊敬する先輩が作った拡張機能のソース
    - 残念ながら見せられないよ！

# やりたいこと

とあるWebサイトでドロップダウンリストがあり、何十個もの選択肢がある。
自分が使いたいのはそのうち1個か2個だけなので、他のものは表示してほしくない。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/259373/e4922145-45a6-8da9-fd63-caaf9d23d12b.png)

```html:sample.html
<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
  </head>
  <body>
    <div class="container">
        <h1>ドロップダウンリスト</h1>
        <select name="dd-list">
          <option value="1">選択肢1</option>
          <option value="2">選択肢2</option>
          <option value="3">選択肢3</option>
    </div>
  </body>
</html>
```

- 指定したWebサイトのみで動作
- 指定した選択肢のみに絞る
- チェックボックスを欄外に追加し、チェックするとすべてが表示される

こんなことを実現したい

# manifest.jsonを作る

拡張機能の大枠を定義するものらしい。動作内容というよりメタデータ的な。

```josn:manifest.json
{
    "manifest_version": 3,
    "name": "hoge function",
    "version": "1.0.0",
    "description": "ドロップダウンの選択肢を削るでござるよ",
    "icons": {
        "16": "img/icon16.png",
        "48": "img/icon48.png",
        "128": "img/icon128.png"
    },
    "content_scripts": [
        {
            "matches": [
                "http://127.0.0.1:3000/model03/sample.html",
                "https://fuga.com"
            ],
            "all_frames": true,
            "run_at": document_idle,
            "js": [
                "content.js"
            ]
        }
    ]
}
```

|プロパティ|値|意味|
|---|---|---|
|manifest_version|3|現在は3固定|
|name|hoge function|この拡張機能の名前|
|version|1.0.0|この拡張機能のバージョン|
|description|※上記参照|この拡張機能の概要|
|icons|16x16, 48x48, 128x128のアイコン画像|公式的には左記3つの提供が必要らしい|
|content_scripts/matches|URLのリスト|この拡張機能が動作する先のURL。ワイルドカード`*`も使えるらしい|
|all_frames|true|`<iframe>`などで構成されるページのすべてのフレームに対して動作|
|run_at|document_idle|詳しくは[参考サイト](https://zenn.dev/rie_amasato/articles/cd3d209920a650)を参照
|js|実行するJavaScriptや適用するCSSの相対パスを記入|同左|

なおお試し作成はVSCodeで[Live Preview](https://marketplace.visualstudio.com/items?itemName=ms-vscode.live-server)を使っている。
こいつでhtmlファイルのプレビューを表示するとURLが以下のようになるので、どうやら簡易的にWebサーバを立てているっぽい。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/259373/72cacc11-1af5-3ab5-6d44-3ca071f98bad.png)


# content.js

実際の動作をゴリゴリ書いていく。

## まずはベース

```js:content.js
"use strict";

window.addEventListener("load", function() {
});
```

- `"use strict"`
    - コードチェックを厳格に行われるようになる
    - `strict`モードのコードは高速に実行できる場合がある？
- `window.addEventListener("load", 
    - 動的に生成される要素が表示されてから実行する
    - 今回対象にするページのドロップダウンは恐らく動的に生成されている…んだけど、そういう理解でいい？SPAのようにあとから要素が順に表示されるようなページのことを指してる？

## 該当のプルダウンを検索する

```js:content.js
window.addEventListener("load", function() {

  // 該当のドロップダウンを検索
  const items = document.querySelectorAll('select[name="dd-list"]');
  // 今回はこれで唯一に特定できるため書かないが
  // querySelectAllで取得した結果複数Hitする場合は
  // 以下でより詳細に検索する
  // for (let i = 0; i < items.length; i++) {
  //   const item = items[i];
  //   ～判定処理
})
```

:::note info
属性セレクターの覚書

|構文|効果|
|---|---|
|`[attr]`|`attr`という名前の属性を持つ要素|
|`[attr=value]`|`attr`という名前の属性の値が正確に`value`である要素|
|`[attr~=value`]|`attr`という名前の属性の値が空白区切りの語のリストであり、その内の1つが正確に`value`と一致する要素。`class`とかを対象に使えそう|
|`[attr|=value]`|`attr`という名前の属性の値が正確に`value`と一致するか、`value`で始まり直後にハイフン`-`が続く要素。言語のサブコードの一致によく使うらしい|
|`[attr^=value]`|`attr`という名前の属性の値が`value`で始まる要素|
|`[attr$=value]`|`attr`という名前の属性の値が`value`で終わる要素|
|`[attr*=value]`|`attr`という名前の属性の値に`value`が含まれる要素|
[`[attr xxxx i]`|閉じ括弧の前に`i`をつけると`value`を大文字小文字区別せずに比較される|
[`[attr xxxx s]`|閉じ括弧の前に`i`をつけると`value`を大文字小文字区別して比較される|
:::

## 該当ドロップダウンの中身を取得する

```js:content.js
window.addEventListener("load", function() {

  // 該当のドロップダウンを検索
  const items = document.querySelectorAll('select[name="dd-list"]');
  // ドロップダウンの中身を取得
  const options = ....
});
```

ん？ドロップダウンを見つけたはいいが、その中身ってどうやって見るんだろう。
取り敢えずコンソールに出力してみるか。

```js:content.js
window.addEventListener("load", function() {

  // 該当のドロップダウンを検索
  const items = document.querySelectorAll('select[name="dd-list"]');
  // ドロップダウンの中身を表示
  console.log(items);
 });
```

```text:フォルダ構成
dropdown_ctl\
|`img\
| |`icon16.png
| |`icon48.png
|  `icon128.png
|`manifest.json
 `content.js
```

`icon`は適当にペイントで指定サイズ(16x16, 48x48, 128x128)の水色ベタ塗りのpngを用意して設置した。

結果をココにペロッと貼ろうと思ったが想定の何百倍も出力された。
かつ、出力構成がわけわかめ。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/259373/d1108a3d-63c3-b1c0-4f4c-dcd6a751c4db.png)
なんだこれ

参考：[【技術Tips:2】Webサイトのドロップダウンリスト一括取得／JavaScript](https://www.itschool-1st.com/2022/07/21/tips2-get-dropdown-list/)

ベタで`selector`を引っ張り込んで指定できるらしい。やってみよう。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/259373/3ec640c0-f70d-28b0-a11a-3c3beebc5ac9.png)

Edgeでも同じように`selectorをコピー`はできる。コピーした値は以下のとおり

```text
要素のコピー
<option value="1">選択肢1</option>

outerHMTLをコピー
<option value="1">選択肢1</option>

selectorをコピー
body > div > select > option:nth-child(1)

JSのパスをコピー
document.querySelector("body > div > select > option:nth-child(1)")

スタイルのコピー
※できず

XPathのコピー
/html/body/div/select/option[1]

完全なXPathをコピー
/html/body/div/select/option[1]
```

あ、`JSのパスをコピー`が一番近いんじゃないかな。

```js:content.js
window.addEventListener("load", function() {

    // ドロップダウンの中身を取得
    const options = document.querySelectorAll("body > div > select > option")
    // 配列型に変換
    const optionsMap = Array.from(options);
    // 内部テキスト(innerText)だけ抽出
    const optionsLists = optionsMap.map(x => x.innerText);
    // 内部テキストを表示
    console.log("options");
    console.log(options);
    console.log("optionsMap");
    console.log(optionsMap);
    console.log("optionsLists");
    console.log(optionsLists);
    console.log("optionsLists.join('\\n')");
    console.log(optionsLists.join('\n'));
});
```
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/259373/04d13b54-8b88-1396-0574-ac966f7ba3a9.png)

おぉ、取れた。
でもこれって`select`が複数あった場合どうなるんだろう。

```html:sample.html
<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
  </head>
  <body>
    <div class="container">
        <h1>ドロップダウンリスト</h1>
        <select name="dd-list">
          <option value="1">選択肢1</option>
          <option value="2">選択肢2</option>
          <option value="3">選択肢3</option>
        </select>
        <br>
        <select name="dd-list2">
          <option value="1">選択肢4</option>
          <option value="2">選択肢5</option>
          <option value="3">選択肢6</option>
        </select>
    </div>
  </body>
</html>
```
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/259373/27a3467e-1952-fb43-b97d-c2f517b366f1.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/259373/8d39ee65-d7f3-13d2-5328-bd111b6ba43d.png)

そうだよね。そうなるよね。
`selector`に[属性セレクター](https://developer.mozilla.org/ja/docs/Web/CSS/Attribute_selectors)でもできるのかな。

```js:content.js
window.addEventListener("load", function() {

    // ドロップダウンの中身を取得
    const options = document.querySelectorAll('body > div > select[name="dd-list"] > option');
    // 配列型に変換
    const optionsMap = Array.from(options);
    // 内部テキスト(innerText)だけ抽出
    const optionsLists = optionsMap.map(x => x.innerText);
    // 内部テキストを表示
    console.log("options");
    console.log(options);
    console.log("optionsMap");
    console.log(optionsMap);
    console.log("optionsLists");
    console.log(optionsLists);
    console.log("optionsLists.join('\\n')");
    console.log(optionsLists.join('\n'));
});
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/259373/9a5dd6a6-670f-4ff6-286e-4417439d369b.png)

お、できた！これでよさそう？
# 出力を特定のoptionだけに絞る

実際の画面に近いようにドロップダウンの`innerText`を変更。

```html:sample.html
<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
  </head>
  <body>
    <div class="container">
        <h1>ドロップダウンリスト</h1>
        <select name="dd-list">
          <option value="1">徳川 家康</option>
          <option value="2">徳川 秀忠</option>
          <option value="3">徳川 家光</option>
          <option value="4">徳川 家綱</option>
          <option value="5">徳川 綱吉</option>
          <option value="6">北条 時政</option>
          <option value="7">北条 義時</option>
          <option value="8">北条 泰時</option>
          <option value="9">北条 経時</option>
        </select>
        <br>
        <select name="dd-list2">
          <option value="1">選択肢4</option>
          <option value="2">選択肢5</option>
          <option value="3">選択肢6</option>
        </select>
    </div>
  </body>
</html>
```

これを「北条家」だけに絞る。前方一致で「北条」にする。

と、思いきや先ほどは`innerText`だけに絞り込んでいる。
実際は各`option`の`display`を`false`にして表示/非表示を制御すると思う。

ので、<b>`optionsMap`の時点でループ処理しながら1個ずつ表示したい</b>。

というか、<b>これって表示であって元の要素を書き換えてなくない？</b>
表示/非表示を制御した新しいoptionsを適用し直す方法も調べないといけない。

参考：[Chrome extensionでselectのリストをフィルタする](https://qiita.com/ota-meshi/items/08f96d5a7e0a0fa72c4c)
参考：[JavaScriptの文字列マッチングまとめ(indexOf, lastIndexOf)](https://qiita.com/aqril_1132/items/9f69575bfbcf24bdf7b5)

```js:content.js
window.addEventListener("load", function() {

  // 渡されたoptionのinnerTextが"北条"であるかを判定
  function isHojo(option) {
    return !option.innerText.indexOf("北条");
  }

  // ドロップダウンの中身を取得
  const options = document.querySelectorAll('body > div > select[name="dd-list"] > option');
  // 配列型に変換
  const optionsMap = Array.from(options);
  // 取得した配列でループ処理
  for (var i = 0; i < optionsMap.length; i++) {
    // デバッグ用
    console.log(optionsMap[i].innerText);
    // innerTextが北条であるかを判定
    if(isHojo(optionsMap[i])) {
      // デバッグ用
      console.log("北条が見つかりました");
      // 北条が見つかった場合、表示にする
      optionsMap[i].style.display = '';
    } else {
      // デバッグ用
      console.log("北条以外が見つかりました");
      // 北条以外は非表示にする
      optionsMap[i].style.display = 'none';
    }
  }
});
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/259373/8ae5381f-7920-2c73-a1f3-0fe173b97b89.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/259373/a60768a9-cdad-3593-1126-e0903d8df274.png)

期待通りになったが初期選択されているものが非表示になった「徳川 家康」のままである。一番最初にHitしたものを初期選択にしたい。

```js:content.js
"use strict";

window.addEventListener("load", function() {

  // 渡されたoptionのinnerTextが"北条"であるかを判定
  function isHojo(option) {
    return !option.innerText.indexOf("北条");
  }

  // ドロップダウンの中身を取得
  const options = document.querySelectorAll('body > div > select[name="dd-list"] > option');
  // 配列型に変換
  const optionsMap = Array.from(options);
  // 検索にHitした要素番号を格納するリスト
  let displayOptionIndex = [];
  // 取得した配列でループ処理
  for (var i = 0; i < optionsMap.length; i++) {
    // デバッグ用
    console.log(optionsMap[i].innerText);
    // innerTextが北条であるかを判定
    if(isHojo(optionsMap[i])) {
      // デバッグ用
      console.log("北条が見つかりました");
      // 北条が見つかった場合、表示にする
      optionsMap[i].style.display = '';
      // 検索にHitした要素番号を格納
      displayOptionIndex.push(i);
    } else {
      // デバッグ用
      console.log("北条以外が見つかりました");
      // 北条以外は非表示にする
      optionsMap[i].style.display = 'none';
    }
    // 検索にHitした要素が1件以上であれば、最初の要素を選択状態にする
    if(displayOptionIndex.length > 0) {
      optionsMap[displayOptionIndex[0]].selected = true;
    }
  }
});
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/259373/2d06a0f3-340d-1983-ccf4-bc71809b592b.png)

準備は整った！

# 検索する値をフォームにする

これだとソースにベタで「北条」を入れているので、ここを指定できるようにしたい。

```js:content.js
"use strict";

window.addEventListener("load", function() {

  // 渡されたoptionのinnerTextが"北条"であるかを判定
  function isHojo(option) {
    return !option.innerText.indexOf(filterKeyword);
  }
  // 目的のドロップダウンを見つけた時に、その右にinputを追加する
  let input = document.createElement('input');
  input.type = 'text';
  input.name = 'filterKeyword';
  input.placeholder = 'フィルタするキーワード';
  let select = document.querySelector('body > div > select[name="dd-list"]');
  select.insertAdjacentElement('afterend', input);

  // inputに入力された文字列を取得
  const filterKeyword = document.querySelector('body > div > input[name="filterKeyword"]');
  // ドロップダウンの中身を取得
  const options = document.querySelectorAll('body > div > select[name="dd-list"] > option');
  // 配列型に変換
  const optionsMap = Array.from(options);
  // 検索にHitした要素番号を格納するリスト
  let displayOptionIndex = [];
  // 取得した配列でループ処理
  for (var i = 0; i < optionsMap.length; i++) {
    // デバッグ用
    console.log(optionsMap[i].innerText);
    // innerTextが北条であるかを判定
    if(isHojo(optionsMap[i])) {
      // デバッグ用
      console.log("北条が見つかりました");
      // 北条が見つかった場合、表示にする
      optionsMap[i].style.display = '';
      // 検索にHitした要素番号を格納
      displayOptionIndex.push(i);
    } else {
      // デバッグ用
      console.log("北条以外が見つかりました");
      // 北条以外は非表示にする
      optionsMap[i].style.display = 'none';
    }
    // 検索にHitした要素が1件以上であれば、最初の要素を選択状態にする
    if(displayOptionIndex.length > 0) {
      optionsMap[displayOptionIndex[0]].selected = true;
    }
  }
});
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/259373/db5f3b67-227f-887d-0960-a05e788b2751.png)

しかしこれでは初期が空っぽなのでリストがフィルタされていない。
1. 空っぽの場合はフィルタしないようにする
1. 指定されたら即座にフィルタする

```js:content.js
"use strict";

window.addEventListener("load", function() {

  // filterKeywordがnullまたは空であるかを判定
  function isEmptyFilterKeyword(filterKeyword) {
    return !filterKeyword || !filterKeyword.value;
  }
  // 渡されたoptionのinnerTextがfilterKeywordで始まる文字列であるかを判定
  function isIndexOfFilterKeyword(option, filterKeyword) {
    return !option.innerText.indexOf(filterKeyword);
  }
  // 目的のドロップダウンを見つけた時に、その右にinputを追加する
  let input = document.createElement('input');
  input.type = 'text';
  input.name = 'filterKeyword';
  input.placeholder = 'フィルタするキーワード';
  let select = document.querySelector('body > div > select[name="dd-list"]');
  select.insertAdjacentElement('afterend', input);

  input.oninput = function() {
    // filterKeywordに入力された文字列を取得
    const filterKeyword = document.querySelector('body > div > input[name="filterKeyword"]');
    // ドロップダウンの中身を取得
    const options = document.querySelectorAll('body > div > select[name="dd-list"] > option');
    // 配列型に変換
    const optionsMap = Array.from(options);
    // 検索にHitした要素番号を格納するリスト
    let displayOptionIndex = [];
    // 取得した配列でループ処理
    for (var i = 0; i < optionsMap.length; i++) {
      if(isEmptyFilterKeyword(filterKeyword) || isIndexOfFilterKeyword(optionsMap[i], filterKeyword.value)) {
        // 表示にする
        optionsMap[i].style.display = '';
        // 検索にHitした要素番号を格納
        displayOptionIndex.push(i);
      } else {
        // 非表示にする
        optionsMap[i].style.display = 'none';
      }
      // 検索にHitした要素が1件以上であれば、最初の要素を選択状態にする
      if(displayOptionIndex.length > 0) {
        optionsMap[displayOptionIndex[0]].selected = true;
      }
    }
  }
});
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/259373/bbfe805c-23b0-d337-00b8-1e503e4abaee.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/259373/69eb57af-2dc4-53d7-0224-d717129493b0.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/259373/3c41023e-3b73-6fb2-4048-9603500db1d6.png)

# デフォルトのフィルタキーワードを指定

何でフィルタリングするかは人それぞれなのでベタ指定はできない。
別途`defaultFilterKeyworkd.js`を置いて変数を`export`し、`content.js`で`import`を試みたがうまく動かせなかったので(原因わからんしエラー内容も貼り付けてなくて申し訳ないが時間がない)、ベタ書きすることにした。拡張機能を追加する人が自分で書き換えてほしい。

```js:content.js
"use strict";
// 初期フィルタキーワードを定義
const defaultFilterKeyword = '北条';

window.addEventListener("load", function() {

  // filterKeywordがnullまたは空であるかを判定
  function isEmptyFilterKeyword(filterKeyword) {
    return !filterKeyword || !filterKeyword.value;
  }
  // 渡されたoptionのinnerTextがfilterKeywordで始まる文字列であるかを判定
  function isIndexOfFilterKeyword(option, filterKeyword) {
    return !option.innerText.indexOf(filterKeyword);
  }
  // 目的のドロップダウンを見つけた時に、その右にinputを追加する
  let input = document.createElement('input');
  input.type = 'text';
  input.name = 'filterKeyword';
  input.placeholder = 'フィルタするキーワード';
  input.value = defaultFilterKeyword;
  let select = document.querySelector('body > div > select[name="dd-list"]');
  select.insertAdjacentElement('afterend', input);
  input.oninput = applyFilterKeyword;

  // 書記ロード時にフィルタを適用する
  applyFilterKeyword();

  function applyFilterKeyword() {
    // filterKeywordに入力された文字列を取得
    const filterKeyword = document.querySelector('body > div > input[name="filterKeyword"]');
    // ドロップダウンの中身を取得
    const options = document.querySelectorAll('body > div > select[name="dd-list"] > option');
    // 配列型に変換
    const optionsMap = Array.from(options);
    // 検索にHitした要素番号を格納するリスト
    let displayOptionIndex = [];
    // 取得した配列でループ処理
    for (var i = 0; i < optionsMap.length; i++) {
      if(isEmptyFilterKeyword(filterKeyword) || isIndexOfFilterKeyword(optionsMap[i], filterKeyword.value)) {
        // 表示にする
        optionsMap[i].style.display = '';
        // 検索にHitした要素番号を格納
        displayOptionIndex.push(i);
      } else {
        // 非表示にする
        optionsMap[i].style.display = 'none';
      }
      // 検索にHitした要素が1件以上であれば、最初の要素を選択状態にする
      if(displayOptionIndex.length > 0) {
        optionsMap[displayOptionIndex[0]].selected = true;
      }
    }
  }
});
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/259373/aed5d15c-d4b0-c9a2-0ab2-454818822066.png)

満足。

# 終えてみて

JavaScriptの勉強になるかと思って触ってみたが、JavaScriptそのものよりもJsonの扱い方などに苦戦した。少なくともループやifなど基本構文は身につけられた気がする。