土日でChrome拡張機能作ってみた

こんばんは、yuji です。普段は transformers や scikit-learn, matplotlib を使って言語モデルの分析をしています。
今回はそんな研究生活で使えそうな Chrome 拡張機能を開発してみました。
TypeScript は普段全く使わないので、ノリと勢いと勘と雰囲気で書きました。温かい目でお読みください。

はじめに

僕は読んだことある論文や興味のある論文を Notion データベースで管理しています。していたつもりでした。いつからか管理が杜撰になって「あの内容の論文ってどれだったっけ」という逆引きができない状況になっていました。また、読んだ論文の記録が汚いデータベースに行くことを考えると、次第に論文を読むこと自体のモチベも下がりつつありました。

この問題を改善するために、論文の情報を自動で読み取って Notion DB に保存して "後は読むだけ" というフローをもたらす Chrome 拡張機能を作成することにしました。

叶えたいフロー

  1. 文献情報を Notion DB に自動で保存する
    • タイトル, 著者, 論文誌, 発行年, URL, PDF
  2. 論文を読む
  3. 感想をメモする

今回紹介する拡張機能のソースコードはこちらです: https://github.com/yuji96/crx-for-survey

開発

0. 環境構築

Chrome 拡張機能開発を始めたくなったらこちらの記事『【TypeScript】React と CRXJS Vite Plugin で作る Chrome 拡張機能 by Nanao』に行きましょう。基本的なセットアップをするのにとても役立ちました。

使用技術

  • CRXJS Vite Plugin
    • ファイルを保存すると即座に拡張機能が更新されるのでとても快適に開発できます。
  • Vanilla TypeScript
    • 当初、画面を作る予定がなかったので React は採用しませんでした。(嘘です、React 書けないからです)。
    • VSCode で補完が効くのが幸せ。
    • 「JS にコンパイルする設定するの大変そう」と思って敬遠してたけど Vite が勝手にやってくれました。
  • Notion SDK for JavaScript

1. 論文情報を取得する

ページ要素 (DOM) を取得したい場合は chrome.tabs を使います。まず、chrome extensions API の機能を使うときに確認するのは、Permissions などの Manifest 設定です。今回は、対象である論文掲載サイトを以下のように指定しました。

{
   host_permissions: [
    "https://aclanthology.org/*-*/",
    ...
  ],
    content_scripts: [
    {
      matches: ["https://aclanthology.org/*-*/"],
      js: ["src/content_scripts/aclanthology.ts"],
    },
    ...
  ],
}

こうすると、https://aclanthology.org/*-*/ にマッチするサイトを開いたときだけ src/content_scripts/aclanthology.ts が実行されます。

しかし、ページを開いたときに毎回情報取得したいわけではないので、他のイベントが発火したときに実行されるようにします(後述)。そのため、ココでは他のプログラムから「論文情報ちょうだい」という message を受け取ったら、取得して返すという実装になっています。

// src/content_scripts/aclanthology.ts
chrome.runtime.onMessage.addListener((_request, _sender, respond) => {
  const title = document.querySelector(...);
  const author = document.querySelector(...);
  const booktitle = document.querySelector(...);
  const year = parseInt(document.querySelector(...));
  const pdf = document.querySelector(...);
  const url = window.location.href;

  respond({ title, author, booktitle, year, pdf, url });
  return true;
});

2. アイコンをクリックしたら論文情報を取得する

アイコンとは画面の右端のコレです。

このアイコンに関する処理は chrome.action で実装できます。Manifest には空でも良いのでキーに action が必要です。この chrome.action の処理はブラウザの裏側で常に動いている service_worker 上で実装します。

// vite.config.ts
{
  action: {},
  background: {
    service_worker: "src/service_worker/background.ts",
  },
}

公式ドキュメントの action Event の欄を見ると chrome.action.onClicked があります。これを使えばできそうです。

できました↓

// src/service_worker/background.ts
chrome.action.onClicked.addListener(async (tab) => {
  if (tab.id === undefined) return;

  const info = await chrome.tabs.sendMessage(tab.id, {});
  if (info === undefined) {
    console.error("content script failed");
  }

  // 以下で Notion API を叩く
});

こうするとアイコンがクリックされたら今開いているタブに message が送られます。もし、そのタブが論文サイト、すなわち content_scripts が付随しているページであれば chrome.runtime.onMessage でその message を受け取ることができます。

info には chrome.runtime.onMessage 側の respond に渡した引数がそのまま入ります。後はこれを Notion API を通して DB に登録するだけです。

3. Notion DB に登録する

ここはいろんな記事を真似したり API reference を読み込んだりして送るだけです。

const notion = new Client({ auth: APIToken });
notion.pages
  .create({
    parent: { database_id: databaseID! },
    properties: {
      Title: { title: [{ text: { content: info.title } }] },
      "Author(s)": {
        rich_text: [{ type: "text", text: { content: info.author } }]
      },
      Booktitle: {
        rich_text: [{ type: "text", text: { content: info.booktitle } }]
      },
      Year: { number: info.year },
      URL: { url: info.url },
      PDF: { url: info.pdf }
    }
  })
  .then(() => {
    // 成功処理
  })
  .catch((error) => {
    // 失敗処理
  });

と思うじゃないですか。実は、Manifest に API のエンドポイントを

// vite.config.ts
{
  host_permissions: [
    "https://api.notion.com/v1/*",
    ...
  ]
}

のように指定しないと CORS Error が出てしまいます。この記事『Chrome 拡張機能の CORS エラーを回避(Manifest V3)by not13』に出会えなければ詰むところでした。ちなみに、僕の場合は、Vite を使っていても一旦拡張機能を削除しないと設定が反映されませんでした。

これで無事にアイコンを押したら論文情報が Notion DB 保存されました。

おわりに

まあ、ぶっちゃけ今回やったことは「1 論文あたり、1 分で終わることを 10 秒に短縮するために 2 日半掛けた」というエンジニアあるある現象です。塵が積もっても元は取れないでしょう。ただ、いつも寝てばっかりだった土日が有意義なものになり、今までちょっと面倒に感じていた 1 分間がなくなることを鑑みると、体験としてはプラスだなと思います。それに、自分で作った拡張機能のボタンを押すのは結構楽しい。