はじめに
普段、技術情報や気になった記事をWebクリップする中で、「それらのウェブサイトをMarkdown形式で保存したい」という個人的な需要がありました。
この課題解決と、ブラウザ拡張機能技術の学習を目的として、閲覧しているWebページをクリック一つでMarkdownファイルとしてダウンロードできるブラウザ拡張機能「Web to Markdown」を開発しました。
開発にあたっては、TypeScriptによる型安全なコード記述と、Webpackによるモジュールバンドル環境を構築し、保守性と拡張性を意識した設計を行いました。
本稿では、開発した拡張機能の概要と、実装における技術的な課題と解決策について報告します。
リポジトリはこちらです。
https://github.com/OsawaKousei/simple-web2md-ext
ビルド済の拡張機能もここから入手できます。
概要
「Web to Markdown」は、表示しているWebページをMarkdown形式のファイルに変換して保存するシンプルな拡張機能です。
主な特徴
-
ワンクリック変換: ツールバーのアイコンをクリックするだけで即座に変換・保存できます。
-
高精度な本文抽出: Mozilla Readability.js を利用し、広告やメニューなどの不要な要素を除去して記事本文だけを的確に抽出します。
-
画像・リンクの保持: ページ内の画像やリンクは、Markdown形式に変換された後も絶対パスとして保持されます。
-
マルチブラウザ対応: Google Chrome、Microsoft Edge、Mozilla Firefoxで動作します。
動作環境
以下のブラウザで動作確認済みです。
-
Google Chrome
-
Microsoft Edge
-
Mozilla Firefox
技術的なハイライト
1. 高精度な本文抽出と堅牢なMarkdown変換
Webページから必要な情報だけを抜き出すため、当初はdocument.body.innerHTMLをそのまま変換していましたが、不要な要素が多く含まれる問題がありました。
そこで、Firefoxのリーダービューでも採用されているMozillaのReadability.jsを導入しました。これにより、機械学習ベースで記事の本文だけを的確に抽出できるようになり、Markdownの精度が格段に向上しました。
また、HTMLからMarkdownへの変換にはTurndownライブラリを使用しています。ページ内の画像やリンクの相対パスは、ローカルでファイルを開いた際にリンク切れとなるため、Turndownにカスタムルールを追加し、全てのパスを絶対パスに変換する処理を実装しました。
// 相対URLを絶対URLに変換するためのルール追加
turndownService.addRule("links", {
filter: "a",
replacement: (content, node) => {
const href = (node as HTMLAnchorElement).getAttribute("href");
if (!href) return content;
const absoluteUrl = new URL(href, window.location.href).href;
return `[${content}](${absoluteUrl})`;
},
});
// (画像用のルールも同様)
2. Manifest V3対応とクロスブラウザ設計
本拡張機能は、Chrome/Edgeで標準となりつつあるManifest V3 (MV3)に対応しています。MV3では、Background Scriptがイベント駆動のService Workerとして動作します。
一方でFirefoxはまだMV3に完全対応していないため、従来のBackground Scriptでの実装が必要です。この差異を吸収するため webextension-polyfill を活用し、browser.*という共通APIで両ブラウザに対応しました。
特にファイルのダウンロード処理はブラウザ間の挙動が異なり、Chromeではdata:URLを、Firefoxではblob:URLを使用するように処理を分岐させています。
// URL.createObjectURLが使えるかチェック
if (typeof URL.createObjectURL === "function") {
// Firefox向けの処理
const blob = await dataURLtoBlob(dataUrl);
const blobUrl = URL.createObjectURL(blob);
await browser.downloads.download({ url: blobUrl, filename: safeFilename });
} else {
// Chrome (MV3) 向けの処理
await browser.downloads.download({ url: dataUrl, filename: safeFilename });
}
3. Content Scriptの動的注入と実行コンテキスト
初めてのブラウザ拡張機能開発だったため、Content Scriptの扱いに苦労しました。当初、manifest.jsonで全ページにContent Scriptを適用していましたが、実行時エラーが出てしまい動作しませんでした。
この問題は、拡張機能のアイコンがクリックされたタイミングで、Content Scriptを動的に注入することで解決しました。scripting.executeScript APIを使用することで、必要な時だけスクリプトを対象ページで実行できます。
// Content Scriptを動的に注入
await browser.scripting.executeScript({
target: { tabId: tab.id },
files: ["content.js"],
});
// 2. Content Scriptにメッセージを送信し、応答を待つ
const response = await browser.tabs.sendMessage(tab.id, {
command: "GET_MARKDOWN",
});
また、この過程で「どのスクリプトがどのコンテキストで動くか」を意識することの重要性を学びました。Readability.jsやTurndownのようにページのDOMにアクセスする必要があるライブラリは、Background Scriptではなく、ページのDOMにアクセス権を持つContent Script内で実行する必要があるという知見を得ました。
終わりに
シンプルな機能の拡張機能開発を通して、本文抽出のアルゴリズム、Manifest V3の仕様、クロスブラウザ対応の実際など、Webフロントエンドとブラウザの仕様に関する多くの学びがありました。特に、異なる実行コンテキストを意識した設計は、今後の開発にも活きる良い経験となりました。
この記事が、これからブラウザ拡張機能開発に挑戦する方の参考になれば幸いです。
参考文献
Mozilla Readability.js: 高精度なコンテンツ抽出ライブラリ
Turndown: HTML to Markdown 変換ライブラリ
WebExtension Polyfill: クロスブラウザ対応のためのAPI Polyfill