Web ページを Markdown に変換する CLI を GitHub Copilot と Bun で作ってみた
こんにちは!普段は Web フロントエンド開発をしている @hush_in です。
今回、Web ページを Markdown に変換して保存する CLI ツールを作りました。GitHub Copilot の Edits や Agent Mode を活用しながら、Bun で単体のバイナリファイルとしてビルドできる機能を試した開発過程を紹介します。
作った背景
Web ページの内容を Markdown として保存したいケースはよくあります。既存のツールや MCP サーバーでも実現できそうですが、以下の理由で自作することにしました。
- GitHub Copilot に参考となる記事を Markdown ファイルで渡したい
- ローカルに Markdown ファイルが残るとあとから参照できて便利
- AI を活用しながら開発する実践的な経験を得たい
また、以下の技術を試してみたいという動機もありました。
- GitHub Copilot の新機能の活用
- Copilot Edits
- Agent Mode
- Bun の機能検証
- Single-file executable binary の作成
作ったもの
インストール
Linux/macOS
curl -fsSL https://raw.githubusercontent.com/hushin/fetchmd/main/scripts/install.sh | bash
Windows (PowerShell)
irm https://raw.githubusercontent.com/hushin/fetchmd/main/scripts/install.ps1 | iex
基本的な使用例
# 単一の URL を処理
fetchmd https://example.com/article
# 出力先ディレクトリを指定
fetchmd -o ~/documents https://example.com/article
# ファイルから複数のURLを処理 (1行1URL)
fetchmd -i urls.txt
# 標準入力から複数のURLを処理
cat urls.txt | fetchmd
ファイル名は {domain}/{pathname}.md
という形式で保存されます。
デフォルトでは ./ref-docs
ディレクトリに保存されます(global の .gitignore に ref-docs
を追加しておくと共同開発時に便利です)。
出力されるファイルには以下のような Frontmatter が付きます。
---
title: '記事タイトル'
url: https://example.com/article
fetchDate: '2024-01-01T12:34:56.789Z'
author: '著者名'
description: '記事の説明'
---
本文...
他のオプションについて詳しくはリポジトリの README を参照ください。
開発プロセス
事前の仕様検討
いきなりコーディングを始めるのではなく、まず GitHub Copilot と対話しながら仕様を固めました。
最終的に以下のようなプロンプトを使って開発を始めました。
URL を渡して、Markdown 形式に変換して保存する CLI を作りたい。
わからないことがあったら質問して。
## 仕様
- コマンド名 `fetchmd`
- 引数で URL を受け取る
- 例 `fetchmd https://example.com/hoge`
- オプション
- 出力先ディレクトリの指定オプション
- 標準入力を受け取ったら 1 行 1URL として複数 URL を処理する
- Frontmatter で情報を残す
- title
- タイトルが取れないときは `(none)`
- url
- fetchDate
- author
- description
- 取れないときは省略
## 実装方針
- TypeScript で書く
- bun の Single-file executable binary でバイナリ化
- 文字エンコーディングは UTF-8 を想定
- 画像はそのままリンクを保持
- エラー時はエラーログを出す。一つの URL で失敗しても続けて処理する。
## 内部処理と技術
- url を 標準 API で fetch
- jsdom でパース
- `@mozilla/readability` で本文抽出
- turndown で Markdown に変換
- Markdown を ファイルに保存
- ファイル名は URL から (domain)/(pathname).md
- pathname の slash `/` は アンダーバー `_` にする
- query, hash があるときはファイル名として使える文字に変換する
- pathname が slash で終わるときは index として解釈する
- ファイル名は先頭 200 文字で打ち切る。
- ファイル名衝突時 は 上書きするかスキップするかユーザーに訪ねて。またどちらかを選択する CLI オプションも用意して
開発での学び
GitHub Copilot (Edits & Agent Mode)
コードの 9 割程度を自動生成でき、残り 1 割を手動で調整する形で開発を進められました。
- Agent Mode でライブラリのセットアップコマンドを提示してくれて便利
- テストコードも自律的に生成・実行・修正してくれる
- 機能追加したらドキュメントも追記してくれる
- Agent Mode は指示していない部分も自発的に実装を進める傾向がある
- 実験的な機能のため、扱いには注意が必要
- スコープを狭めて指示し、成功したら小さい単位でコミットするのが重要
- 編集・実行を制御したい場合は Chat モードの方が適している
- ログが残らないため、後から確認が難しい
Bun での開発
開発体験として、npm や pnpm と比べて各種コマンドの実行時間が早く、体験が良好でした。
bun の console の独自仕様もドキュメントを参照することで適切に実装できました。
ただし、bun build --compile
での単体バイナリビルドで課題に直面しました。
手元でビルドしたものは実行できるのですが、GitHub Actions でビルドしたものを実行すると以下のエラーが出ました。
error: Cannot find module '/home/runner/work/fetchmd/fetchmd/node_modules/jsdom/lib/jsdom/living/xhr/xhr-sync-worker.js' from '/$bunfs/root/fetchmd-linux-x64'
情報が少ない中での調査に手間取りましたが、jsdom 内で動的に読み込んでいるファイルが原因でした。
const syncWorkerFile = require.resolve
? require.resolve('./xhr-sync-worker.js')
: null;
動的に読みに行ってるからビルド時と環境が違うとファイルが存在しないため失敗するようです。
この問題は linkedom というライブラリに切り替えることで解決しました。
その他の気づき:
- 単体バイナリのファイルサイズは予想より大きい(約 100MB)
- bun test は手探り状態
- msw が動作しなかったため、fetch を mock して対応
- http_parser がない https://github.com/oven-sh/bun/issues/13072
- stdin のテストは試行錯誤の末に動作。自信がない
- msw が動作しなかったため、fetch を mock して対応
おわりに
AI との対話を通じた開発は、アイデアを素早く形にできる可能性を感じられる良い経験となりました。筆者は最近 Cline も試しています。アイデアはあるけど手を動かすのが面倒、実装の時間がとれない人にとっては非常に強力な支援ツールになると感じました。
一方で、Bun のビルド問題のように、ネットで調べても情報が見つからない問題の調査は依然として人間の領域であることも実感しました。この分野は今後、徐々に改善されていくと期待しています。
Discussion