概要
日々の開発やリサーチで、Google検索の「AIモード(AI Overview)」や「Gemini」を使う機会がすっかり定着しました。
しかし、後から情報を見返したり、社内WikiやNotionにまとめようとしたとき、標準のコピー機能では**「リストのインデント階層が崩れる」「参考リンクのURLが消える」**といった問題があり、手作業でMarkdownを修正するのに地味なストレスを感じていました。
そこで、ワンクリックでAIの質疑応答を**リッチなMarkdown形式(階層構造やリンクを保持)**としてクリップボードにコピーできるChrome拡張機能「Gemini-Search-MD-Copier」を開発しました。
- GitHubリポジトリ: t-inada-shokai/Gemini-Search-MD-Copier
本記事では、ツールの紹介とあわせて、複雑怪奇なGoogleのDOMから美しいMarkdownを生成するための「泥臭い(だけど実用的な)ハック」について解説します。
主な機能と実際の動作
この拡張機能は、単に画面のテキストを抜いてくるだけではありません。以下のフォーマットを自動で解析・補正します。
1. 箇条書きの階層(インデント)を論理的に維持
2. Gemini特有の特殊タグ(<sequence>等)のステップ展開に対応
3. AIが提示する「関連リンク」を <details> タグで折りたたみ可能な形で抽出
4. コードブロックの言語名(js, pyなど)を付与
外部ライブラリにいっさい依存せず、Manifest V3 + Vanilla JavaScriptの軽量構成で作っています。
技術的なハイライト:どうやってDOMをMarkdownに翻訳しているか
GoogleのAI回答部分は、単純に .innerText で取得すると構造が破綻します。今回、パーサーを自作する上で特に苦労・工夫した2つのポイントをご紹介します。
1. リストの「見た目のインデント」を「論理階層」に再計算する
HTMLのネスト構造が複雑な場合、単純にDOMツリーを潜るだけでは箇条書きの階層(深さ)が正しく測れません。
そこで、reIndentMarkdownStyles 関数を実装し、「行頭のスペース数を計測し、辞書構造(indentMap)に記録して階層の深さを再計算する」というアプローチをとりました。
// インデントを論理階層に基づいて再調整するコア処理
let indentMap = { 0: 0 };
let currentMaxDepth = 0; // 「階層の深さ(0, 1, 2...)」を管理
for (let line of lines) {
const originalIndent = line.search(/\S|$/);
// 1. 辞書にない新しいインデントなら登録し、最大深度を更新
if (indentMap[originalIndent] === undefined) {
currentMaxDepth += 1; // 階層を掘り下げる
indentMap[originalIndent] = currentMaxDepth;
}
// 2. 前行と比較してインデントが戻った(浅くなった)場合のクリーンアップ
const lastOriginalIndent = processedLines.length > 0 ? getIndent(lines[processedLines.length - 1]) : 0;
if (originalIndent < lastOriginalIndent) {
// 現在より深い階層の辞書データを削除
Object.keys(indentMap).forEach(key => {
if (parseInt(key) > originalIndent) {
delete indentMap[key];
}
});
// 最大階層を再計算
currentMaxDepth = indentMap[originalIndent] || 0;
}
// 3. 辞書に沿って新しいインデント(スペース)を適用
const newIndentLevel = indentMap[originalIndent];
const indentedLine = indentUnit.repeat(newIndentLevel) + content;
processedLines.push(indentedLine);
}
これにより、画面上の見た目に近い形でインデントされMarkdownとして出力されます。
2. Gemini特有の特殊タグ <sequence> のパース手法
Geminiのチャット画面を解析していて直面したのが、Google特有のカスタムタグ(Web Components)の存在です。
たとえば、Geminiが段階的な手順やプロセスを回答する際、ただの <ul> や <ol> ではなく、<sequence> という独自のタグ構造が使われることがあります。
これを無視するとテキストが欠落してしまうため、パーサー内で明示的に sequence タグとその子要素を拾い上げる処理を追加しました。
// GeminiのDOMパース処理(switch文の一部抜粋)
case 'sequence':
childContent = '';
const sequencelines = element.querySelectorAll("div.sequence-event");
if (sequencelines) {
// インデントレベルの計算
const totalLeftSpace = getLeftPixels(element);
let indentLevel = Math.max(0, Math.floor(totalLeftSpace / 20));
const indentStr = indentUnit.repeat(indentLevel);
sequencelines.forEach(child => {
// マーカー(番号など)、タイトル、サブタイトル、詳細をそれぞれ抽出
const markerEl = child.querySelector("div.sequence-event-marker");
const divmarker = markerEl ? markerEl.innerText.trim() : "";
const divtitle = convertToMarkdownGemini(child.querySelector("div.sequence-event-title"));
const divsubtitle = convertToMarkdownGemini(child.querySelector("div.sequence-event-subtitle"));
const divdetail = convertToMarkdownGemini(child.querySelector("div.sequence-event-description>structured-node-sequence"));
// Markdownリストのフォーマットに組み立て
if (divmarker) {
const zenkakuMarker = toZenkakuNumString(divmarker);
childContent += `${indentStr}- ${zenkakuMarker} ${divtitle.trim()}\n`
+ `${indentStr}${indentUnit}*${divsubtitle.trim()}*\n`
+ `${indentStr}${indentUnit}${divdetail.trim()}\n`;
} else {
// マーカーがないパターンの処理...
}
});
}
return `\n${childContent}\n`;
見慣れないタグが動的に生成されるのはモダンなWebアプリならではですが、このようにクラス名やカスタムタグ名で確実にフックすることで、複雑なUIコンポーネントもMarkdownのリストと斜体(イタリック)を組み合わせたテキストに翻訳することができました。
ちょっと便利な副産物:「関連サイト」の折りたたみ
個人的に気に入っているのが、AI回答の末尾につく「関連サイト」リンク群の処理です。
そのままMarkdownに書き出すとリンクの羅列で縦に長くなりすぎるため、本拡張機能では HTMLの <details> と <summary> タグを用いて折りたたみ可能なアコーディオンとして出力するようにしました。NotionやGitHubのプレビューでもそのまま折りたためるので、ドキュメントがすっきりします。
まとめと今後の展望
今回、UIが頻繁に変わるGoogle検索やGeminiのDOM解析に挑みましたが、「属性(jsname等)」や「カスタムタグ(<sequence>等)」をベースにすることで、ある程度堅牢な抽出ツールを作ることができたと思います。
今後もGoogle側のUI変更を継続的にウォッチしつつ、さらにパースの精度を上げていきたいと考えています。
ソースコードはすべてGitHubにて公開しています。「もっとスマートにパースできるよ!」「この画面だと崩れた!」といったご意見があれば、ぜひスター(⭐)やIssue、Pull Requestをお待ちしています。