ブックマークをキーボード操作のみで検索するChrome拡張を作ってみる

# はじめに
ブックマークで指定のURLを開く際にツールバーのツリーを辿りながら開くと思いますが、
マウスを使用しない私には少し扱いが面倒だと感じてしまいます。
そこで勉強も兼ねキーボードのみで指定のURLを開くChrome拡張を作ろうというのが本稿の趣旨です。

# 成果物
成果物のイメージです。ショートカットキーで子画面を呼び出し後に
検索ワードを入れて絞り込み、Enterキーを押下すると選択されたページを開きます。
![image.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/969561/cecd5684-14be-d8a0-f15c-4a9d03cde9dd.gif)



# 実行環境
Windows10 Pro 64bit
Chrome 89
Vue 2.6.12

# chrome-extension
今回の主役であるchrome-extensionの概略です。
chrome-extensionはchromeの機能をWebの技術と専用のAPIを使用し
文字通りChromeの機能拡張を行うことが可能です。
今回使用したブックマークへのアクセスをはじめ、Webページの制御や
ダウンロードの管理などChromeの機能に関する様々な処理を実行可能です。

# 作成内容
冒頭にお見せした様に特定のショートカットキーにより
ブックマーク検索子画面を表示し指定ページを開く機能を作成します。
文字入力により絞りこみを行い、選択は上下キー、決定はEnterとします。

## 構成
今回のディレクトリ構成です。（iconなどは省略）
bundle後のファイルを読み込む方が設定やパフォーマンス的にも良かったのですが、
お試し機能なのでwebpackなどは使用せず作成しています。

```
├── manifest.json
├── background.js
├── lib     
├    └── ...       #子画面用 外部ライブラリ
├── index.html     #子画面用
├── main.js        #子画面用
└── style.css      #子画面用
```

### manifest.json
chrome-extensionの設定ファイルの様なものです。
詳細は[公式のドキュメント](https://developer.chrome.com/docs/extensions/mv3/manifest/)や[他の方のご説明](https://qiita.com/mdstoy/items/9866544e37987337dc79)をご参照ください。

- name: chrome-extensionの名前
- version: 開発バージョン
- manufest_version: manifestファイルのバージョン。現在はver.3を推奨していますが2を使用
- description: 機能概要
- permissions: 権限。今回は主役の「bookmarks」、画面を開く際に「tabs」、後述するキャッシュ保存のため「storage」権限を付与
- commands: キーボードショートカットに使用。今回はCtrl+B(MacはCommand+B)でイベントが発火
- content_security_policy: リソースの実行に関するセキュリティ設定。XSSリスク低減などを目的としたもの。今回は完全ビルド版のVue.jsを使用するため「unsafe-eval」設定を追加
- background: バックグラウンドページ、イベントページの設定。前者はpersistentがtrueの場合であり永続的な稼働、後者はfalseの場合で必要に応じて稼働する設定。今回はショートカットキーによる発火で良いのでイベントページを使用

```json
{
  "name": "keyboard-bookmark",
  "version": "1.0.0",
  "manifest_version": 2,
  "description": "Bookmark Extension for keyboard controll",
  "permissions": [
    "bookmarks",
    "tabs",
    "storage"
  ],
  "commands": {
    "toggle-feature": {
      "suggested_key": {
        "default": "Ctrl+B",
        "mac": "Command+B"
      },
      "description": "Toggle feature"
    }
  },
  "content_security_policy": "script-src 'self' 'unsafe-eval'; object-src 'self'",
  "background": {
    "scripts": ["background.js"],
    "persistent": false
  }
}
```

### background.js
background.jsではmanifest.jsonで指定したショートカットキーが押下された際に
新規ウインドウを開く処理を行っています。
なお、子画面を開いた画面に戻ってくるため「chrome.windows.getCurrent」で
現在のウインドウを取得し、クエリパラメータで新規ウインドウにウインドウIDを渡しています。

```js
chrome.commands.onCommand.addListener(function(command) {
  chrome.windows.getCurrent({}, (window) => {
    chrome.windows.create({
      url: chrome.runtime.getURL("index.html?id=" + encodeURIComponent(window.id)),
      type: "popup",
      width: 600,
      height: 420
    });
  });
});
```

### bookmarks API
ブックマークの操作を行うAPIです。リファレンスは[こちら]
(https://developer.chrome.com/docs/extensions/reference/bookmarks/)
以下は今回使用したメソッドになります。

#### 検索 (search)
指定クエリに対してブックマークのURL及びタイトルに合致する結果を返却します。
URLかタイトルを指定したい場合はオブジェクト形式で渡します。

```js
chrome.bookmarks.search(query, (results) => {
  // queryによる検索結果
})
```

#### 最近追加されたブックマーク (getRecent)
検索のクエリがない時に使用しています。

```js
chrome.bookmarks.search(numberOfItems, (results) => {
  // numberOfItemsの個数分直近のブックマークを取得
})
```

### 指定URLを元のウインドウで開く
chrome.tabs APIを使用してタブの生成を行います。
前述のとおり、子画面を開いた際に元画面のwindowIDをクエリパラメータとして付与しているため
「chrome.tabs.create」でウインドウを指定してURLを開きます。
開いた後は検索子画面が不要となるためcallbackで閉じる処理を行っています。

```js
// クエリパラメータからwindowIDを取得
const windowId = decodeURIComponent(location.search.replace(/^\?id=/, ''));
// 遷移元の画面に選択したブックマークのURLを開く
chrome.tabs.create({ windowId: +windowId, url: [選択したURL] }, () => {
  // 生成後のcallbackで検索子画面を削除
  chrome.windows.getCurrent({}, window => chrome.windows.remove(window.id));
});
```

### キーボード操作受付
vueのお作法にはなりますが、検索用の入力項目に対して
enterと上下キーをそれぞれイベントに割り当てることにより決定・選択行の変更を行います。
それぞれのメソッド内容は割愛。


```vuejs
<input type="text"
       ...
       @keydown.prevent.enter="submit"
       @keydown.prevent.down="movedown"
       @keydown.prevent.up="moveup">
```

## 完成・・・？
以上の要素を組み合わせるとブックマークの検索子画面の生成、検索、
URLを開く、といった流れが可能となるはずです。
・・・がブックマークの情報としては、URLやタイトルの情報しかなく
どうせならサムネイル的な情報が欲しいと思ってしまいました。
この思い付きのせいでここまでかかった時間の倍以上の時間をかける羽目になります。

以下、蛇足的な内容となります。

## 指定URLの画像や詳細情報を取得
たまに見るURLのサムネイルみたいなものはどうやって取得しているのか
知らなかったのですが、__OGP(Open Graph Protcol)__というHTMLのプロパティに
画像や詳細情報を付与するものがありました。主にSEO対策などで用いられているそうです。
以下はgithubのOGPとなります。

```html
<meta property="og:title" content="GitHub">
<meta property="og:locale" content="ja">
<meta property="og:description" content="GitHubはソフトウェア開発のプラットフォームです。GitHubには8000万件以上ものプロジェクトがホスティングされており、2700万人以上のユーザーがプロジェクトを探したり、フォークしたり、コントリビュートしたりしています。">
<meta property="og:url" content="https://github.co.jp/">
<meta property="og:site_name" content="GitHub">
<meta property="og:image" content="https://github.githubassets.com/images/modules/open_graph/github-logo.png">
```

## CORS
OGPの取得はHTMLを取得してDOMから指定情報を引っこ抜けば良いのですが、
そのままfetchなどを行うとCORSに引っ掛かってしまいます。
今回はNodeの「[cors-anywhere](https://github.com/Rob--W/cors-anywhere)」を使用し、プロキシを設定することによってCORSを回避します。
以前は共有サーバがあったようですが、限定利用になりましたのでサーバを立てて使用してください。
※ ``https://[cors-anywhere(proxyUrl)]/https://qiita.com(url)`` の様に使用

```js

const result = await fetch(this.proxyUrl + url);
const text = await result.text();
const dom = new DOMParser().parseFromString(text, "text/html");
// DOMのheaderからog(OGP)のプロパティを抜き出す
return Array.from(dom.head.children)
            .filter(h => /^og.*/.test(h.getAttribute('property')))
            .flatMap(v => [{[v.getAttribute("property").replace('og:', '')]: v.getAttribute("content")}])
            .reduce((accumulator, v) => Object.assign(accumulator, v))
```

## キャッシュ
fetchしてOGPの情報を取得、画像表示までには多少時間がかかります。（c.f. キャッシュ前）
そこでOGPの情報を取得した段階でChrome拡張のstorageの機能を使用して
OGPのデータをキャッシュしておきます。
OGPも変更される可能性があるため無期限キャッシュでない方が良いでしょうが
今回はキャッシュの有効期限は特に考えないこととします。

```js
// 設定
chrome.storage.local.set({[key]: value}, callback);
// 取得
chrome.storage.local.get([key], (result) => {
})
```


キャッシュ前
![image3.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/969561/81c3a241-4d5a-0663-edc7-2ed6a22189dc.gif)


キャッシュ後
![image2.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/969561/59bd8b92-b71a-686c-152f-3b5f9370b0ba.gif)


# おわりに
chrome-extensionからブックマークを操作する処理自体は比較的簡単に作ることが出来ました。
ブックマーク以外にも様々なAPIが用意されているため組み合わせると業務効率化などに
役立ちそうな気がします。

ソースコード: https://github.com/kanaria42/keyboard-bookmark
※エラー処理など色々甘いとこはあります

# 参考文献
公式：
https://developer.chrome.com/docs/extensions/
Chrome 拡張機能のマニフェストファイルの書き方：
https://qiita.com/mdstoy/items/9866544e37987337dc79
Chrome拡張の作り方 (超概要)：
https://qiita.com/RyBB/items/32b2a7b879f21b3edefc
cors-anywhere：
https://github.com/Rob--W/cors-anywhere