数字は全部合っているのに、答えが噛み合わない。そういう不気味な壊れ方をしたことがあります。
個人開発で運営している4つのサイトの記事本数や期限、それに App Store へ出す更新の提出予定を、私は Google Docs の台帳にまとめています。それを Apps Script から Gemini に渡し、「今月の期限のうち、まだ着手していないものを挙げてください」と尋ねる。返ってきた答えに並ぶ日付も件数も、たしかに台帳のどこかには存在する値でした。ただ、どのサイトの行の話なのかが入れ替わっていた。
原因は Gemini 側ではありませんでした。渡す前の一行にありました。
値は合っているのに、どの行の話か分からなくなる
最初に書いていたのは、こういうコードです。
function askAboutLedger () {
const text = DocumentApp. openById ( DOC_ID ). getBody (). getText ();
const prompt =
'次の運用台帳から、7月中に期限を迎える項目をサイト名つきで JSON 配列にしてください。 \n\n ' + text;
return callGemini (prompt);
}
getText() は本文をひとつの文字列で返してくれます。手軽で、たいていの用途では十分に見えます。実際、段落だけで構成された議事録や仕様の下書きなら、これで困りません。
困るのは、表が混じったときです。台帳の中身を Logger.log() で覗いてみて、ようやく合点がいきました。
サイト
7月公開
残タスク
期限
担当
Claude Lab
12
3
2026-07-20
本人
Gemini Lab
14
1
2026-08-17
本人
セルの値が改行区切りで縦一列に並んでいます。表という入れ物は消え、中身だけが床にこぼれた状態です。ここから「Gemini Lab の期限」を正しく拾えというのは、5個ずつ数えて位置を復元しろと言っているのと変わりません。列数が固定なら数え上げで復元できることもありますが、空セルがひとつ入った瞬間に、それ以降の対応が全部ずれます。
Gemini は嘘をついていたのではなく、こぼれた値をそれらしく並べ直していたわけです。
getText() が捨てているもの
何が失われるのかを整理しておきます。Apps Script の DocumentApp は本文を要素のツリーとして持っていて、getText() はそのツリーを深さ優先で辿りながらテキストだけを連結します。つまり、テキスト以外の情報はすべて落ちます。
Docs 上の情報 getText() 後 抽出への影響
表のセル境界・行列位置 改行区切りの平文 行と列の対応が消える(最も深刻)
見出しレベル(H1〜H6) ただの段落 セクションの範囲が分からない
リストの入れ子・番号 行の並びのみ 親子関係が消える
リンク先 URL 表示テキストのみ 参照先が辿れない
脚注 本文に現れない 補足が丸ごと欠落する
コメント(吹き出し) 本文に現れない DocumentApp からは元々読めない
最後の行は少し性質が違います。コメントは DocumentApp の管轄外で、Drive API の comments.list を叩かないと取得できません。「本文に書いてあるはずの但し書きが Gemini に届いていない」と悩んだとき、その但し書きがコメントとして残されている可能性は疑ってよいところです。
私自身、脚注に書いた例外条件が抽出結果に一度も反映されず、しばらくプロンプトの書き方を疑って時間を溶かしました。プロンプトの問題ではなく、そもそも届いていなかったのです。
要素ツリーを歩いて構造を残す
対処はまっすぐです。getText() に任せず、自分でツリーを歩いて、構造をタグとして書き出します。出力先は HTML にしました。Gemini は HTML の表をそのまま表として読めますし、Markdown のパイプ記法より崩れに強いからです。
/**
* Docs 本文を構造つき HTML に変換する。
* 段落・見出し・表・リストを保ったまま出力する。
*/
function docToStructuredHtml ( docId ) {
const body = DocumentApp. openById (docId). getBody ();
const out = [];
const total = body. getNumChildren ();
for ( let i = 0 ; i < total; i ++ ) {
const child = body. getChild (i);
const type = child. getType ();
if (type === DocumentApp.ElementType. PARAGRAPH ) {
out. push ( paragraphToHtml (child. asParagraph ()));
} else if (type === DocumentApp.ElementType. TABLE ) {
out. push ( tableToHtml (child. asTable ()));
} else if (type === DocumentApp.ElementType. LIST_ITEM ) {
out. push ( listItemToHtml (child. asListItem ()));
}
// それ以外(区切り線・目次など)は意図的に捨てる
}
return out. filter ( function ( line ) { return line !== '' ; }). join ( ' \n ' );
}
function escapeHtml ( value ) {
return value
. replace ( /&/ g , '&' )
. replace ( /</ g , '<' )
. replace ( />/ g , '>' );
}
function headingTag ( paragraph ) {
const H = DocumentApp.ParagraphHeading;
switch (paragraph. getHeading ()) {
case H . TITLE :
case H . HEADING1 :
return 'h1' ;
case H . HEADING2 :
return 'h2' ;
case H . HEADING3 :
return 'h3' ;
case H . HEADING4 :
case H . HEADING5 :
case H . HEADING6 :
return 'h4' ;
default :
return 'p' ;
}
}
function paragraphToHtml ( paragraph ) {
const text = paragraph. getText (). trim ();
if (text === '' ) return '' ;
const tag = headingTag (paragraph);
return '<' + tag + '>' + escapeHtml (text) + '</' + tag + '>' ;
}
function listItemToHtml ( item ) {
const text = item. getText (). trim ();
if (text === '' ) return '' ;
// 入れ子の深さを属性で持たせる。ul/ol の開閉を組むより壊れにくい
return '<li data-depth="' + item. getNestingLevel () + '">' + escapeHtml (text) + '</li>' ;
}
function tableToHtml ( table ) {
const rows = table. getNumRows ();
if (rows === 0 ) return '' ;
const lines = [ '<table>' ];
for ( let r = 0 ; r < rows; r ++ ) {
const row = table. getRow (r);
const cells = [];
for ( let c = 0 ; c < row. getNumCells (); c ++ ) {
const value = escapeHtml (row. getCell (c). getText (). trim ());
cells. push (r === 0 ? '<th>' + value + '</th>' : '<td>' + value + '</td>' );
}
lines. push ( '<tr>' + cells. join ( '' ) + '</tr>' );
}
lines. push ( '</table>' );
return lines. join ( ' \n ' );
}
なぜリストを <ul> で正しく入れ子にせず、data-depth 属性で済ませているのか。開始タグと終了タグの対応を深さの変化から組み立てると、途中に空のリスト項目がひとつ混じっただけで構造が破綻するからです。深さを属性として素直に持たせるほうが、生成側の実装が短く、モデルの読み取りも安定しました。壊れにくさを、見た目の正しさより優先した判断です。
escapeHtml を挟んでいるのは体裁のためだけではありません。台帳のセルに <要検討> のような山括弧つきの走り書きが入っていたことがあり、これがそのまま出力されるとタグの境界が曖昧になります。
表をどう表現するか — HTML と行レコードの比較
構造を残す方法は HTML だけではありません。表を1行1レコードの JSON にする手もあります。どちらが良いかは机上で決めきれなかったので、手元の台帳で測りました。
条件は次のとおりです。42行×5列の運用台帳(サイト・7月公開数・残タスク・期限・担当)に対して、行と列の交差を尋ねる質問を10問用意し、各表現ごとに3回ずつ流して合計30回答を採点しました。モデルは gemini-flash-latest(2026年7月15日時点では Gemini 3.5 Flash が実体)、temperature は 0 に固定しています。
入力表現 行・列の取り違え 入力トークン(概算) 実装の手数
getText() の平文 11 / 30 約 3,900 1行
HTML テーブル 0 / 30 約 5,200 関数3本
行レコード JSON 1 / 30 約 6,800 関数3本+ヘッダ推定
平文の 11 / 30 という数字は、私にとって十分に不愉快でした。3回に1回強、静かに間違った答えが返ってくる。落ちるなら気づけますが、これは気づけない壊れ方です。
HTML と JSON の差は誤答数だけ見れば小さく、判断の決め手はトークンと手数のほうでした。JSON 化にはヘッダ行の推定が要ります。台帳の1行目が必ずヘッダだと決め打てるならよいのですが、私の台帳には表の上に注記の行が入っている表が混じっていました。そこを吸収する分岐を書き足すくらいなら、セル位置をそのまま残せる HTML で足りる、というのが結論です。約1,300トークンの増加は、静かな誤答を消す対価として安いと私は考えています。
段落が主体で表が数個という文書に限れば、この判断はおそらく多くの場合に当てはまります。逆に、数百行の表が主役で段落がほとんどない文書もあります。この場合はヘッダ推定の分岐を書く手間よりトークン効率のほうが効いてくるため、行レコード JSON をお勧めします。表現は文書の形に合わせて選ぶものだと思っています。
見出し階層をセクション境界に使う
構造を残す利点は、表の復元だけではありません。見出しが <h2> として残っていれば、それを文書の切れ目として使えます。
台帳が育つと、いずれ一度に渡せる量を越えます。そのとき文字数で機械的に切ると、表の途中で分断されて元の木阿弥です。見出しで切れば、意味のまとまりを保ったまま分割できます。
/**
* 構造つき HTML を <h2> 単位のセクションへ分割する。
* 各セクションに見出しのパスを添えて、断片だけでも文脈が残るようにする。
*/
function splitByH2 ( html ) {
const lines = html. split ( ' \n ' );
const sections = [];
let current = { heading: '(冒頭)' , lines: [] };
lines. forEach ( function ( line ) {
const m = line. match ( / ^ <h2>( . *? )< \/ h2> $ / );
if (m) {
if (current.lines. length > 0 ) sections. push (current);
current = { heading: m[ 1 ], lines: [line] };
} else {
current.lines. push (line);
}
});
if (current.lines. length > 0 ) sections. push (current);
return sections. map ( function ( s ) {
return {
heading: s.heading,
html: s.lines. join ( ' \n ' ),
chars: s.lines. join ( ' \n ' ). length ,
};
});
}
各セクションに heading を持たせているのは、後で断片単体をモデルに渡すときに「これは台帳のどの節か」を添えるためです。文脈のない断片は、それ自体が新しい誤読の入口になります。
分割した断片を順に処理する段になると、今度は実行時間の壁にぶつかります。その扱いはApps Script から Gemini でスプレッドシート数千行を処理する — 6分の実行上限を越える分割と冪等性の設計 で書いたやり方がそのまま使えます。
脚注・コメント・リンクは残すか捨てるか
構造を残せるようになると、次は「どこまで残すか」の問題になります。全部入れれば良いというものでもありません。
私の台帳では、次のように線を引きました。
要素 扱い 理由
脚注 本文末に注釈として付ける 期限の例外条件が書かれていた。落とすと答えが変わる
リンク先 URL 表示テキストのみ残す 抽出の判断に使わない。URL はトークンを食うだけだった
コメント 取得しない Drive API の追加スコープが要る。台帳では議論の途中経過しか入っていない
目次・区切り線 捨てる 本文の重複。残すと同じ見出しが二度現れて分割が乱れる
脚注は getFootnotes() から拾えます。
function footnotesToHtml ( docId ) {
const footnotes = DocumentApp. openById (docId). getFootnotes ();
if (footnotes. length === 0 ) return '' ;
const items = footnotes. map ( function ( fn , index ) {
const text = fn. getFootnoteContents (). getText (). trim ();
return '<li data-note="' + (index + 1 ) + '">' + escapeHtml (text) + '</li>' ;
});
return '<h2>脚注</h2> \n ' + items. join ( ' \n ' );
}
コメントを読む選択も一応あります。ただ Drive API のスコープを足すことになるため、自動化の権限は増やさずに済むなら増やさないほうが良い、というのが私の立場です。この線引きの考え方はApps Script × Gemini 自動化の権限を最小に保つ — 明示スコープ設計とスコープ肥大の検知 にまとめています。台帳の但し書きが本当にコメントにしか無いのなら、それは本文へ移すべき情報です。
抽出層の受け入れテスト
抽出層は一度書いて終わりにはなりません。壊れる契機は Docs 側にあります。誰かが表に列を足す。見出しを段落に戻す。そのとき抽出層は例外を投げず、静かに劣化した出力を吐きます。
そこで、変換の前後で構造の不変量を突き合わせています。
/**
* 抽出層の受け入れテスト。
* Docs 側の構造と、生成した HTML の構造が一致するかを検査する。
*/
function assertExtractionIntegrity ( docId , html ) {
const body = DocumentApp. openById (docId). getBody ();
const failures = [];
// 1. 表の数
const docTables = body. getTables (). length ;
const htmlTables = (html. match ( /<table>/ g ) || []). length ;
if (docTables !== htmlTables) {
failures. push ( 'table count: doc=' + docTables + ' html=' + htmlTables);
}
// 2. 全表の行数の合計
const docRows = body. getTables (). reduce ( function ( sum , t ) {
return sum + t. getNumRows ();
}, 0 );
const htmlRows = (html. match ( /<tr>/ g ) || []). length ;
if (docRows !== htmlRows) {
failures. push ( 'row count: doc=' + docRows + ' html=' + htmlRows);
}
// 3. 見出しの数(分割の粒度が変わっていないか)
const htmlH2 = (html. match ( /<h2>/ g ) || []). length ;
if (htmlH2 === 0 ) {
failures. push ( 'no h2: 見出しが段落に戻された可能性' );
}
// 4. 空セルの混入(列ずれの前触れ)
const emptyCells = (html. match ( /<td>< \/ td>/ g ) || []). length ;
if (emptyCells > 0 ) {
failures. push ( 'empty cells: ' + emptyCells + ' 件' );
}
if (failures. length > 0 ) {
throw new Error ( 'extraction integrity failed: \n ' + failures. join ( ' \n ' ));
}
return { tables: docTables, rows: docRows, h2: htmlH2 };
}
4番目の空セル検査だけは、性質が少し違います。空セル自体は不正ではありません。ただ私の台帳では、空セルが現れるのはたいてい列を足した直後で、その日は他の行の列がずれていました。厳密なエラーというより、人間が一度見に来るべき合図として扱っています。実際、7月に入ってから2回この検査に引っかかり、どちらも私の編集ミスでした。
Docs 側を編集する人が自分ひとりでも、この検査は要ります。3週間前の自分は他人だからです。
6分の実行時間と折り合いをつける
抽出層を挟むと、当然ながら処理は重くなります。手元の台帳(42行×5列の表を含む全18ページ)で、getText() が約 0.4 秒だったのに対し、要素ツリーの走査は約 2.1 秒でした。5倍ですが、絶対値としては小さいままです。
重いのは走査ではなく、そのあとの Gemini 呼び出しのほうでした。Apps Script の6分制限に対して、抽出 2.1 秒はほとんど誤差です。ここを惜しんで平文に戻す判断は、私には合理的に見えません。
一方で、抽出結果をキャッシュするかどうかは考えどころです。私は CacheService に置くのをやめました。台帳は日に何度も書き換わり、古い抽出結果を掴むほうが怖かったからです。代わりに、Docs の更新時刻を鍵にしています。
function getStructuredHtmlCached ( docId ) {
const file = DriveApp. getFileById (docId);
const stamp = String (file. getLastUpdated (). getTime ());
const cache = CacheService. getScriptCache ();
const key = 'doc_html_' + docId + '_' + stamp;
const hit = cache. get (key);
if (hit) return hit;
const html = docToStructuredHtml (docId);
// 100KB を越えるとキャッシュに載らない。載らなくても動作は変わらない
if (html. length < 100 * 1024 ) {
cache. put (key, html, 21600 );
}
return html;
}
更新時刻を鍵に含めておけば、台帳が編集された瞬間に鍵が変わり、古い結果は自然に参照されなくなります。明示的な無効化を書かずに済むぶん、消し忘れが起きません。
どこまでを抽出層に任せるか
ここまで書いておいて逆のことを言いますが、抽出層は万能ではありません。
Docs の表が「見た目のためのレイアウト表」だった場合、構造を残しても意味は増えません。セル結合が多用された表も、getText() がセルごとに素直に値を返す都合上、結合前の姿には戻せません。私の台帳でも、装飾目的で2列に割っていた箇所は、結局 Docs 側を直しました。抽出層で吸収するより、元の文書を素直な形に整えるほうが早かったからです。
抽出層が引き受けるべきなのは、あくまで「文書には構造があるのに、渡す途中で失われる」部分だけです。文書そのものに構造が無いなら、直す場所は文書のほうにあります。
もし手元の自動化で、モデルの答えが妙に噛み合わないと感じているなら、プロンプトを直す前に、次の順序で確かめてみてください。
Logger.log(body.getText()) の出力を目で追い、表が改行区切りの平文に潰れていないかを見る
潰れていたら docToStructuredHtml() に差し替え、同じ質問を3回流して答えの揺れを比べる
差が出たら assertExtractionIntegrity() を定期実行に組み込み、Docs 側の改稿で静かに戻らないようにする
渡しているつもりのものと、実際に渡っているものが、そこで食い違っているかもしれません。私の場合はそこでした。
抽出の参考になれば幸いです。お読みいただきありがとうございました。