はじめに
本記事では、私(タテイシ)がCursorのVibe CodingでAIと対話しながら自作した、Chrome拡張機能「Tab to MD」の開発記録をまとめます。
「開いているタブをまとめて、マークダウンとして保存したい」という素朴な困りごとが出発点でした。
調査や学習で参照するページが増えるほど、手動コピー&ペーストがつらくなるためです。
この記事のスタンス
「ノンエンジニアがAIで作った」と書いている通り、開発の知見は全くありません。
どのように進めたかという記録を記載しています。
- 私はエンジニアではないので、各所で「AIにどう聞いたか」「何を確認して採用したか」を残します。
- 実装の要点(Manifest V3、Service Worker、Chrome APIsの制約など)も載せますが、大体は失敗(エラー)が起きてから、AIに相談して解決したトライ&エラー結果になります。つまずきポイントとして「考え方」が伝われば幸いです。
- 実装だけ追いたい方は、コードブロックと「課題→解決策」だけ拾って読んでも大丈夫です。
プロジェクト概要
機能要件
- 現在のウィンドウで開いているすべてのタブを一括でマークダウン形式に変換
- 1ページ1ファイルとして保存
- 保存先フォルダのカスタマイズ
技術スタック
- Manifest V3: Chrome拡張機能の最新仕様
- Service Worker: バックグラウンド処理
-
Chrome APIs:
tabs,storage,downloads,scripting - Vanilla JavaScript: フレームワークなしの純粋なJavaScript
- Cursor: AIと会話しながら実装・修正を進めるためのエディタ
アーキテクチャ設計
ファイル構成
Tab to MD3/
├── manifest.json # 拡張機能の設定
├── popup/
│ └── popup.html # ポップアップUI
├── scripts/
│ ├── background.js # Service Worker
│ ├── popup.js # ポップアップのロジック
│ └── settings.js # 設定ページのロジック
├── settings/
│ └── settings.html # 設定ページ
└── styles/
├── popup.css
└── settings.css
データフロー
- ユーザーがポップアップで「変換」ボタンをクリック
-
popup.jsが現在のウィンドウのタブ一覧を取得 - 各タブに対して
background.jsにメッセージを送信 -
background.jsがchrome.scripting.executeScriptでページコンテンツを抽出 - マークダウン形式にフォーマット
-
popup.jsがchrome.downloadsAPIでファイルを保存
実装の詳細
1. Manifest V3の設定
{
"manifest_version": 3,
"name": "Tab to MD",
"description": "開いているタブの内容をマークダウン形式で保存するツール",
"version": "1.0.0",
"action": {
"default_title": "Tab to MD",
"default_popup": "popup/popup.html"
},
"options_page": "settings/settings.html",
"permissions": [
"tabs",
"storage",
"downloads",
"scripting"
],
"host_permissions": [
"<all_urls>"
],
"background": {
"service_worker": "scripts/background.js",
"type": "module"
}
}
重要なポイント
-
manifest_version: 3を使用(現行の拡張機能仕様) -
service_workerでバックグラウンド処理を実装(従来のbackground pageから移行) -
type: "module"でES6モジュールを使用可能に -
host_permissionsで<all_urls>を指定し、すべてのWebページにアクセス可能に
AIとのやりとり(ここでやったこと)
- 「Manifest V3の
manifest.jsonの最小構成を作って。必要なpermissionsも理由付きで」 - 「V3のバックグラウンド処理はどこに書く?
service_workerの注意点は?」
2. Service Workerによるコンテンツ抽出
Service Workerは拡張機能のバックグラウンド処理を担当します。Manifest V3では、従来のバックグラウンドページの代わりにService Workerを使用します。
// タブの内容を取得してマークダウンに変換
async function convertTabToMarkdown(tabId) {
try {
const tab = await chrome.tabs.get(tabId);
// アクセスできないページ(chrome://, chrome-extension://, about:など)をチェック
if (!tab.url || tab.url.startsWith('chrome://') ||
tab.url.startsWith('chrome-extension://') ||
tab.url.startsWith('moz-extension://') ||
tab.url.startsWith('about:') ||
tab.url.startsWith('edge://')) {
// アクセスできないページの場合は、タブ情報のみを使用
return formatAsMarkdown(tab, {
title: tab.title || 'Unknown',
url: tab.url || '',
content: 'このページの内容にはアクセスできません。\\n(chrome://、chrome-extension://、about:などの特殊なページは保護されています)'
});
}
const results = await chrome.scripting.executeScript({
target: { tabId: tabId },
func: extractPageContent
});
if (results && results[0] && results[0].result) {
const content = results[0].result;
return formatAsMarkdown(tab, content);
}
return null;
} catch (error) {
// エラーの場合でも、タブ情報のみを使用してマークダウンを生成
try {
const tab = await chrome.tabs.get(tabId);
return formatAsMarkdown(tab, {
title: tab.title || 'Unknown',
url: tab.url || '',
content: `エラー: このページの内容を取得できませんでした。\\n${error.message}`
});
} catch (e) {
console.error('Error converting tab to markdown:', error);
return null;
}
}
}
// ページの内容を抽出
function extractPageContent() {
return {
title: document.title,
url: window.location.href,
content: document.body.innerText || document.body.textContent || ''
};
}
技術的なポイント
-
chrome.scripting.executeScriptの使用- Manifest V3では
chrome.tabs.executeScriptが廃止され、chrome.scripting.executeScriptを使用 -
funcパラメータで関数を直接渡すことができる(インライン関数も可能) - 実行される関数はページのコンテキストで実行されるため、
documentやwindowにアクセス可能
- Manifest V3では
-
保護されたページの処理
-
chrome://、chrome-extension://、about:などの特殊なページはセキュリティ上の理由でアクセス不可 - 事前にURLをチェックし、アクセスできない場合はタブ情報のみを使用してマークダウンを生成
-
-
エラーハンドリング
- CORSエラーやその他のエラーが発生しても、タブ情報(タイトル、URL)だけでもマークダウンを生成
- ユーザー体験を損なわないように、エラー時でも何らかの出力を提供
3. メッセージパッシング
Service Workerとポップアップ間の通信にはchrome.runtime.onMessageを使用します。
// メッセージハンドラー
chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
if (request.action === 'convertTab') {
convertTabToMarkdown(request.tabId)
.then(markdown => {
sendResponse({ success: true, markdown });
})
.catch(error => {
sendResponse({ success: false, error: error.message });
});
return true; // 非同期レスポンスのため
}
});
重要なポイント
-
return trueを返すことで、非同期処理が完了するまでメッセージチャネルを開いたままにする -
return falseや何も返さない場合、sendResponseが呼ばれる前にチャネルが閉じられ、エラーになる
4. ポップアップからのタブ変換処理
convertBtn.addEventListener('click', async () => {
try {
statusDiv.textContent = '変換中...';
// 現在のウィンドウのすべてのタブを取得
const tabs = await chrome.tabs.query({ currentWindow: true });
if (tabs.length === 0) {
statusDiv.textContent = '✗ タブが見つかりません';
return;
}
let successCount = 0;
let errorCount = 0;
// 各タブを1ファイルずつ変換・保存
for (const tab of tabs) {
try {
const response = await chrome.runtime.sendMessage({
action: 'convertTab',
tabId: tab.id
});
if (response.success) {
// ファイル名を安全にする(特殊文字を削除)
const safeFilename = sanitizeFilename(tab.title || 'tab');
await downloadMarkdown(response.markdown, safeFilename);
successCount++;
} else {
errorCount++;
console.error(`Failed to convert tab: ${tab.title}`, response.error);
}
} catch (error) {
errorCount++;
console.error(`Error converting tab: ${tab.title}`, error);
}
}
// 結果を表示
if (errorCount === 0) {
statusDiv.textContent = `✓ ${successCount}個のタブを変換完了`;
} else {
statusDiv.textContent = `✓ ${successCount}個成功、${errorCount}個失敗`;
}
} catch (error) {
statusDiv.textContent = '✗ エラー: ' + error.message;
console.error('Error:', error);
}
});
実装のポイント
-
chrome.tabs.query({ currentWindow: true })で現在のウィンドウのタブのみを取得 - 各タブを順次処理(
for...ofループ) - エラーが発生しても他のタブの処理を継続
- ファイル名のサニタイズ処理(後述)
AIに頼りすぎないために意識したこと
- まず「変換中…→完了」の表示だけは自分で目視確認し、体感が崩れていないかをチェックしました
- うまくいかないときは、コードをいじる前に「何が起きているか(例:どのタブで落ちているか)」をログで切り分けました
5. ファイル名のサニタイズ
ファイルシステムで使用できない文字を除去し、安全なファイル名を生成します。
function sanitizeFilename(filename) {
return filename
.replace(/[<>:"/\\\\|?*]/g, '_') // 特殊文字をアンダースコアに置換
.replace(/\\s+/g, '_') // スペースをアンダースコアに置換
.substring(0, 100); // 長すぎるファイル名を制限
}
6. ダウンロード処理とフォルダ選択の実装
Chrome拡張機能では、chrome.downloads APIを使用してファイルをダウンロードします。しかし、Chrome拡張機能の制約により、ダウンロードフォルダ以外の絶対パスを直接指定することはできません。
相対パスと絶対パスの処理
function resolveFolderInfo(folderSetting) {
if (!folderSetting) {
return null;
}
if (typeof folderSetting === 'string') {
// 後方互換性: 文字列の場合はダウンロードフォルダの相対パスとして扱う
return {
type: 'downloads',
relativePath: normalizeRelativePath(folderSetting),
absolutePath: ''
};
}
if (typeof folderSetting === 'object') {
if (folderSetting.type === 'absolute') {
return {
type: 'absolute',
relativePath: '',
absolutePath: (folderSetting.absolutePath || folderSetting.path || '').replace(/\\\\/g, '/').replace(/\\/+$/g, '')
};
}
return {
type: 'downloads',
relativePath: normalizeRelativePath(folderSetting.relativePath || folderSetting.path || ''),
absolutePath: ''
};
}
return null;
}
ダウンロードオプションの構築
function buildDownloadOptions(folderSetting, baseFileName) {
const info = resolveFolderInfo(folderSetting);
if (!info) {
return {
filename: baseFileName,
saveAs: true
};
}
if (info.type === 'absolute') {
// Chromeのdownloads APIは絶対パスを受け付けないため、
// ファイル名だけを指定してsaveAsダイアログを開く
// ユーザーはダイアログで設定したフォルダを手動で選択する必要がある
return {
filename: baseFileName,
saveAs: true
};
}
const relativePath = info.relativePath;
if (!relativePath) {
return {
filename: baseFileName,
saveAs: true
};
}
return {
filename: `${relativePath}/${baseFileName}`,
saveAs: true
};
}
制約と対応
- ダウンロードフォルダ内の相対パス(例:
MD/勉強)はfilenameパラメータで指定可能 - ダウンロードフォルダ以外の絶対パス(例:
/Users/username/Desktop)は直接指定不可 - 絶対パスが設定されている場合、
saveAs: trueで毎回保存ダイアログを表示
フォルダ選択の実装(ダミーファイル方式)
Chrome拡張機能にはフォルダ選択ダイアログを直接開くAPIが存在しないため、ダミーファイルをダウンロードして、その保存先からフォルダパスを取得するという方法を採用しました。
selectFolderBtn.addEventListener('click', async () => {
try {
folderSelectionInProgress = true;
statusDiv.textContent = 'フォルダ選択ダイアログを開いています...';
// ダミーファイルを作成してダウンロード
const dummyContent = 'This is a dummy file for folder selection. You can delete this file.';
const blob = new Blob([dummyContent], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
let downloadId;
try {
// ダウンロードを開始(ユーザーがフォルダを選択できるように)
downloadId = await chrome.downloads.download({
url: url,
filename: '__FOLDER_SELECTION__.txt',
saveAs: true
});
} catch (error) {
URL.revokeObjectURL(url);
throw error;
}
// ダウンロード完了時にファイルパスからフォルダパスを抽出
const onChanged = (downloadDelta) => {
if (downloadDelta.id === downloadId && downloadDelta.state) {
if (downloadDelta.state.current === 'complete') {
chrome.downloads.search({ id: downloadId }, (results) => {
if (results && results[0] && results[0].filename) {
const fullPath = results[0].filename;
// ファイル名を除いてフォルダパスを取得
const folderPath = fullPath.substring(0, fullPath.lastIndexOf('/'));
// ダウンロードフォルダからの相対パスを計算
extractRelativePath(folderPath, downloadId, cleanupUrl);
}
});
} else if (downloadDelta.state.current === 'interrupted' ||
downloadDelta.state.current === 'cancelled') {
folderSelectionInProgress = false;
cleanupUrl();
statusDiv.textContent = 'フォルダ選択がキャンセルされました';
chrome.downloads.onChanged.removeListener(onChanged);
}
}
};
chrome.downloads.onChanged.addListener(onChanged);
// タイムアウト処理(30秒後にリスナーを削除)
setTimeout(() => {
chrome.downloads.onChanged.removeListener(onChanged);
folderSelectionInProgress = false;
cleanupUrl();
}, 30000);
} catch (error) {
folderSelectionInProgress = false;
statusDiv.textContent = '✗ エラー: ' + error.message;
console.error('Error selecting folder:', error);
}
});
実装のポイント
-
Blob URLの使用
-
URL.createObjectURL()でメモリ上のファイルをURL化 - 使用後は
URL.revokeObjectURL()でメモリを解放
-
-
ダウンロードイベントの監視
-
chrome.downloads.onChangedでダウンロード状態を監視 -
complete、interrupted、cancelledの各状態を処理
-
-
パスの抽出と正規化
- ダウンロードフォルダ内かどうかを判定
- ダウンロードフォルダ内の場合は相対パスとして保存
- それ以外の場合は絶対パスとして保存
-
ダミーファイルの削除
- フォルダ選択後、ダミーファイルを自動削除
-
chrome.downloads.removeFile()とchrome.downloads.erase()を使用
相対パスの抽出ロジック
async function extractRelativePath(fullPath, downloadId, cleanupUrl = null) {
try {
const normalizedFullPath = fullPath.replace(/\\\\/g, '/');
let folderInfo;
if (normalizedFullPath.match(/\\/Downloads\\//i)) {
// ダウンロードフォルダ内のサブフォルダ
const parts = normalizedFullPath.split(/\\/Downloads\\//i);
const afterDownloads = parts[1] || '';
folderInfo = createDownloadsFolderInfo(afterDownloads);
} else if (/\\/Downloads$/i.test(normalizedFullPath)) {
// ダウンロードフォルダ直下
folderInfo = createDownloadsFolderInfo('');
} else {
// ダウンロードフォルダ外(絶対パス)
folderInfo = createAbsoluteFolderInfo(normalizedFullPath);
}
setFolderSelection(folderInfo);
// ダミーファイルを削除
chrome.downloads.removeFile(downloadId, () => {
chrome.downloads.erase({ id: downloadId }, () => {
console.log('Dummy file removed');
});
});
} catch (error) {
console.error('Error extracting relative path:', error);
}
}
7. 設定の保存と読み込み
chrome.storage.syncを使用して設定をクラウドに同期保存します。
// 設定を保存
saveBtn.addEventListener('click', () => {
const folderInfoToSave = selectedFolderInfo
? {
type: selectedFolderInfo.type,
relativePath: selectedFolderInfo.relativePath,
absolutePath: selectedFolderInfo.absolutePath,
displayPath: selectedFolderInfo.displayPath
}
: null;
chrome.storage.sync.set({
includeTimestamp: includeTimestamp.checked,
includeUrl: includeUrl.checked,
defaultFilename: defaultFilename.value,
defaultFolder: folderInfoToSave
}, () => {
statusDiv.textContent = '✓ 設定を保存しました';
});
});
chrome.storage.syncの特徴
- データはChromeアカウントに紐づいて同期される
- 最大100KBの容量制限
- 非同期API(コールバック形式)
8. パスの正規化処理
異なるOSや入力方法に対応するため、パスの正規化処理を実装しています。
function normalizeRelativePath(path) {
if (!path) {
return '';
}
// Windowsのバックスラッシュをスラッシュに統一
const normalizedSeparators = path.replace(/\\\\/g, '/');
// 先頭・末尾のスラッシュを削除
const trimmed = normalizedSeparators.replace(/^\\/+|\\/+$/g, '');
if (!trimmed) {
return '';
}
// 空のセグメントを除去して結合
return trimmed
.split('/')
.filter((segment) => segment.length > 0)
.join('/');
}
9. マークダウン形式への変換
function formatAsMarkdown(tab, pageContent) {
const timestamp = new Date().toISOString();
return `# ${pageContent.title || tab.title}
**URL:** ${pageContent.url || tab.url}
**取得日時:** ${timestamp}
---
${pageContent.content}
`;
}
現在はシンプルな形式ですが、設定に応じてタイムスタンプやURLを含めるかどうかを制御できるように拡張可能な設計になっています。
開発で直面した課題と解決策
AIとの進め方:私が意識した3ステップ
AIと一緒に作る流れとして、私はだいたい次の流れで進めました。
- やりたいことを日本語で指示する(例:「現在のウィンドウの全タブを1クリックでmd保存したい」)
- 制約を先に聞く(例:「Chrome拡張機能で“フォルダ選択ダイアログ”は直接開ける?」)
-
動かして確認する(例:
chrome://系で落ちないか、保存先が想定どおりか)
めちゃくちゃ当たり前の話なのですが、エンジニアの方の記事の多くには仕様をしっかり設定し、漏れなくプロントに起こし込み〜〜という内容が多くあります。
もちろんそれが大正解という前提の上で、こういうラフな方法もVibeCodingの良さの一つだと思ってもらえればと幸いです。
課題1: Manifest V3への移行
問題
- Manifest V2からV3への移行が必要
-
chrome.tabs.executeScriptが廃止 - バックグラウンドページからService Workerへの移行
解決策
-
chrome.scripting.executeScriptを使用 - Service Workerのライフサイクルを理解し、必要な処理を適切に実装
- 非同期処理の扱いに注意(
return trueでメッセージチャネルを保持)
AIに投げた質問(例)
- 「Manifest V3でタブのDOMを取る最小構成を教えて。
executeScriptの書き方は?」 - 「
onMessageの中で非同期処理をしたい。return trueが必要な理由を、初心者向けに説明して」
課題2: フォルダ選択の実装
問題
- Chrome拡張機能にはフォルダ選択ダイアログを直接開くAPIが存在しない
-
chrome.downloadsAPIはダウンロードフォルダ内の相対パスしか指定できない
解決策
- ダミーファイルをダウンロードして、その保存先からフォルダパスを取得
- ダウンロードフォルダ内かどうかを判定し、相対パスと絶対パスを区別
- 絶対パスの場合は、毎回保存ダイアログを表示することをユーザーに明示
AIに投げた質問
- 「Chrome拡張機能でフォルダ選択をやりたい。できないなら代替案教えて」
- 「ダミーファイル方式で、選んだフォルダパスを取得する実装例を出して」
私がやった確認
- 選択した保存先が「1階層上」になっていないか(パスの切り出しミスが出やすい)
- ダウンロードフォルダ外を選んだとき、仕様として
saveAsが毎回出ることをUI文言に反映できているか
課題3: 保護されたページへのアクセス
問題
-
chrome://、chrome-extension://、about:などのページはセキュリティ上の理由でアクセス不可 - CORSエラーが発生するページもある
解決策
- URLを事前にチェックし、アクセスできないページはタブ情報のみを使用
- エラーが発生しても、タイトルとURLだけでもマークダウンを生成
- ユーザーに分かりやすいエラーメッセージを表示
AIに投げた質問(例)
- 「
chrome://系で落ちるのはなぜ?回避方法は?」 - 「例外時でも最低限のmdを生成して、ユーザーに理由を伝える文面を考えて」
課題4: パスの正規化と互換性
問題
- WindowsとmacOSでパス区切り文字が異なる(
\\vs/) - ユーザーが手動で入力したパスに余分なスラッシュが含まれる可能性
解決策
- 正規化関数を実装し、すべてのパスを統一形式に変換
- 空のセグメントを除去
- 先頭・末尾のスラッシュを削除
AIに投げた質問(例)
- 「Windows/macOS混在でも壊れにくい“相対パス正規化”の関数を書いて」
- 「入力が空/スラッシュだけ/連続スラッシュでも破綻しないようにして」
今後の改善点
-
マークダウン形式のカスタマイズ
- 設定に応じてタイムスタンプやURLを含めるかどうかを制御
- カスタムテンプレートの対応
-
コンテンツ抽出の改善
-
innerTextだけでなく、HTMLの構造を保持したマークダウン変換 - 画像の保存やリンクの処理
-
-
エラーハンドリングの強化
- より詳細なエラーメッセージ
- リトライ機能
-
パフォーマンスの最適化
- 大量のタブを処理する際の並列処理
- プログレスバーの表示
Chrome ウェブストアへの公開
開発が完了したら、Chrome ウェブストアに拡張機能を公開することを目指しました。ここでは、申請から公開までのプロセスを詳しく解説します。
申請前の準備
Chrome ウェブストアに公開する前に、以下の準備が必要です:
-
デベロッパーアカウントの登録
- Chrome ウェブストア デベロッパー ダッシュボードにアクセス
- Googleアカウントでログインし、デベロッパー登録を行う(初回のみ$5の登録料が必要)
-
拡張機能のパッケージ化
- 開発用のファイルをZIP形式で圧縮
-
manifest.jsonがルートディレクトリに配置されていることを確認 - 不要なファイル(
.git、node_modulesなど)は除外
-
プライバシーポリシーの準備
- 拡張機能がデータを収集する場合は、プライバシーポリシーのURLが必要
- 本拡張機能はデータを収集しないため、プライバシーポリシーは不要でした
申請プロセス
-
デベロッパー ダッシュボードでの申請
- デベロッパー ダッシュボードにログイン
- 「新しいアイテム」をクリックして申請を開始
- ZIPファイルをアップロード
-
ストア情報の入力
- 名前: 「Tab to MD」
- 説明: 拡張機能の機能や使い方を詳しく説明
- カテゴリ: 「ツール」を選択
- 言語: 日本語
- スクリーンショット: 拡張機能の動作を示す画像をアップロード(必須)
- アイコン: 128x128pxと48x48pxのアイコンを用意
-
権限の説明
- 使用している権限(
tabs、storage、downloads、scripting)について、それぞれの使用目的を明確に説明 - ユーザーデータの取り扱いについても説明が必要
- 使用している権限(
-
審査の提出
- すべての情報を入力後、審査を提出
- 審査には通常1週間程度かかります
審査結果と公開
- 審査期間: 約1週間
- 審査結果: 無事に承認され、公開されました
公開された拡張機能は以下のURLでアクセスできます:
審査で注意すべきポイント
-
権限の最小化
- 必要最小限の権限のみを要求
- 各権限の使用目的を明確に説明
-
プライバシーへの配慮
- ユーザーデータを収集しない場合は、その旨を明記
- データを収集する場合は、プライバシーポリシーが必要
-
説明文の充実
- 拡張機能の機能や使い方を分かりやすく説明
- スクリーンショットで視覚的に理解できるようにする
-
動作の確認
- 申請前に、実際に拡張機能が正常に動作することを確認
- エラーハンドリングが適切に行われていることを確認
公開後の対応
公開後は、ユーザーからのフィードバックやレビューに対応する必要があります。また、バグ修正や機能追加を行った場合は、更新版を申請する必要があります。
まとめ
本記事では、私(タテイシ)が非エンジニアとして、AI(Cursor)と対話しながらChrome拡張機能「Tab to MD」を作った過程をまとめました。
主な技術的なポイント:
- Manifest V3のService Workerを使用した実装
-
chrome.scripting.executeScriptによるページコンテンツの抽出 - ダミーファイルを使ったフォルダ選択の実装
- 相対パスと絶対パスの処理
- エラーハンドリングとユーザー体験の向上
Chrome拡張機能は、APIの制約やセキュリティ上の制限が多く、「やりたいこと」だけでは前に進まない場面がありました。特にフォルダ選択まわりは、直接的なAPIがないため工夫が必要でした。
同じように「まず作ってみたい」という方にとって、AIへの聞き方と確認の仕方のヒントになればうれしいです。





