GASでPDFをAIに
直接読ませて要約・抽出する
GASにはPDFからテキストを取り出す標準機能がありません。だからといって外部ライブラリを探す必要はありません。 Claude APIのdocumentブロックにPDFをそのまま渡せば、テキストとページの見た目の両方をAIが解釈して、要約や項目抽出まで一度で返してくれます。
Table of Contents
GASでPDFを扱うと詰まる理由
メールで届いた請求書PDFを集計したい、契約書の期限を一覧にしたい。 こうした要件でGASを書き始めると、最初の一歩でつまずきます。 GASにはPDFの中身をテキストとして取り出す標準APIがないためです。
PDFパーサーがない
DriveApp で取得できるのはバイト列(Blob)だけで、本文テキストは取り出せない
Googleドキュメント変換は崩れる
PDFをドキュメントに変換する方法では、表やレイアウトが崩れて項目が対応しなくなる
画像化すると情報が落ちる
ページを画像にして送る方法は、選択可能なテキストの正確さを捨てることになる
外部ライブラリは重い
JS製のPDFパーサーをGASに載せると、実行時間制限とメモリで現実的に回らない
解決策はシンプルで、テキスト抽出をやめてPDFのままAIに渡すことです。 Claude APIのdocumentブロック(PDFなどの文書を1つの入力として渡す仕組み)に対応しており、ベータ用のヘッダーも不要です。 まず共通の呼び出し関数を用意します。APIキーはPropertiesService(設定値を安全に保存する仕組み)に入れ、コードへ直接書きません。
const API_KEY = PropertiesService.getScriptProperties().getProperty("CLAUDE_API_KEY");
function callClaude(payload, betaHeader) {
const headers = {
"x-api-key": API_KEY,
"anthropic-version": "2023-06-01",
};
if (betaHeader) {
headers["anthropic-beta"] = betaHeader; // Files APIを使うときだけ指定する
}
const res = UrlFetchApp.fetch("https://api.anthropic.com/v1/messages", {
method: "post",
contentType: "application/json",
headers: headers,
payload: JSON.stringify(payload),
muteHttpExceptions: true,
});
const code = res.getResponseCode();
const body = JSON.parse(res.getContentText());
if (code !== 200) {
throw new Error("Claude API エラー (" + code + "): " + JSON.stringify(body));
}
return body;
}
// 回答テキストだけを取り出すヘルパー
function textOf(body) {
return body.content
.filter(function (c) { return c.type === "text"; })
.map(function (c) { return c.text; })
.join("");
}documentブロックの基本形(DriveのPDFを送る)
やることは3つだけです。DriveからBlobを取る、Utilities.base64Encode()で文字列に変換する、content配列の先頭にdocumentブロックとして置く。指示文はそのあとに続けます。
function summarizePdf(fileId) {
const blob = DriveApp.getFileById(fileId).getBlob();
// PDFのバイト列をbase64文字列にする(改行を含めない)
const base64 = Utilities.base64Encode(blob.getBytes());
const body = callClaude({
model: "claude-opus-5",
max_tokens: 2000,
messages: [
{
role: "user",
content: [
{
type: "document",
source: {
type: "base64",
media_type: "application/pdf",
data: base64,
},
},
{
type: "text",
text: "このPDFの内容を箇条書き5点で要約してください。数値は原文のまま引用してください。",
},
],
},
],
});
Logger.log(textOf(body));
return textOf(body);
}ポイントはdocumentブロックをtextブロックより前に置くことです。 先に資料を渡してから指示を書く順序のほうが、指示が資料全体に効きます。 1回のメッセージに複数のPDFを並べることもでき、「この2つの見積書の差分を表にしてください」のような比較もそのまま依頼できます。
請求書から項目をJSONで抽出する
業務で使うなら、要約より「決まった項目を決まった形で取り出す」用途が中心になります。 コツは出力する項目名と型をプロンプトに列挙し、読み取れない項目はnullにすると明示することです。これを書かないと、AIは空欄を推測で埋めてしまいます。
const INVOICE_PROMPT = [
"このPDFは請求書です。次の項目をJSONだけで出力してください。前置きや説明は不要です。",
"{",
' "issuer": "請求元の会社名",',
' "issue_date": "発行日(YYYY-MM-DD)",',
' "due_date": "支払期限(YYYY-MM-DD)",',
' "total_excluding_tax": 税抜合計の数値,',
' "total_including_tax": 税込合計の数値,',
' "invoice_number": "請求書番号"',
"}",
"読み取れない項目はnullにしてください。推測で埋めないでください。",
].join("\n");
function extractInvoice(fileId) {
const blob = DriveApp.getFileById(fileId).getBlob();
const body = callClaude({
model: "claude-opus-5",
max_tokens: 1000,
messages: [
{
role: "user",
content: [
{
type: "document",
source: {
type: "base64",
media_type: "application/pdf",
data: Utilities.base64Encode(blob.getBytes()),
},
},
{ type: "text", text: INVOICE_PROMPT },
],
},
],
});
const raw = textOf(body).trim();
try {
return JSON.parse(raw);
} catch (e) {
// コードブロック記法が付く場合に備えて中身だけ取り出す
const m = raw.match(/\{[\s\S]*\}/);
if (!m) throw new Error("JSONとして解析できませんでした: " + raw);
return JSON.parse(m[0]);
}
}JSON.parseは必ずtry/catchで囲みます。コードブロック記法(```)が付いて返ることがあるためです。 より厳密に形を固定したい場合は、出力形式をJSON Schemaで指定するStructured Outputsも使えます。
フォルダ内のPDFをまとめてシートに取り込む
1件動いたら、Driveフォルダを走査して一括処理にします。 PDF1件ごとにAPIを1回呼ぶため、件数が多いとGASの6分の実行時間制限に当たります。 経過時間を見て途中で切り上げ、続きは次回の実行に任せる形が安全です。
function importInvoicesFromFolder() {
const folder = DriveApp.getFolderById("フォルダIDを入れる");
const files = folder.getFilesByType(MimeType.PDF);
const sheet = SpreadsheetApp.getActive().getSheetByName("請求書一覧");
const rows = [];
const started = Date.now();
while (files.hasNext()) {
// 6分の実行時間制限に届く前に切り上げ、続きは次回の実行に回す
if (Date.now() - started > 4 * 60 * 1000) break;
const file = files.next();
try {
const d = extractInvoice(file.getId());
const gap = d.total_including_tax !== null && d.total_excluding_tax !== null
? d.total_including_tax - d.total_excluding_tax
: "";
rows.push([
file.getName(), d.issuer, d.issue_date, d.due_date,
d.total_excluding_tax, d.total_including_tax, gap, d.invoice_number, "",
]);
} catch (e) {
rows.push([file.getName(), "", "", "", "", "", "", "", "失敗: " + e.message]);
}
}
// 1行ずつ書くと極端に遅くなるため、まとめて追記する
if (rows.length > 0) {
sheet.getRange(sheet.getLastRow() + 1, 1, rows.length, rows[0].length).setValues(rows);
}
}上のコードでは税込と税抜の差額も一緒に出しています。 消費税額として妥当な値になっていない行は読み取りを疑う、というようにGAS側で検算できる列を1つ持たせると、目視チェックの手間が大きく下がります。 処理済みのファイルは別フォルダへ移動するか、ファイルIDを記録して二重取り込みを防いでください。
Citationsで根拠ページを添える
就業規則や仕様書への質問では、答えだけでなく「何ページに書いてあるか」が必要です。 documentブロックにcitations: { enabled: true }を付けると、回答の各文に引用元のページ番号が付いて返ります。
function askPdfWithCitations(fileId, question) {
const blob = DriveApp.getFileById(fileId).getBlob();
const body = callClaude({
model: "claude-opus-5",
max_tokens: 1500,
messages: [
{
role: "user",
content: [
{
type: "document",
source: {
type: "base64",
media_type: "application/pdf",
data: Utilities.base64Encode(blob.getBytes()),
},
title: blob.getName(),
citations: { enabled: true }, // 出典を付ける指定
},
{ type: "text", text: question },
],
},
],
});
// 出典付きの回答は、テキストが複数ブロックに分かれて返る
const lines = [];
body.content.forEach(function (block) {
if (block.type !== "text") return;
let line = block.text;
if (block.citations) {
const pages = block.citations
.filter(function (c) { return c.type === "page_location"; })
.map(function (c) { return "p." + c.start_page_number; }); // ページ番号は1始まり
if (pages.length > 0) line += "(" + pages.join(", ") + ")";
}
lines.push(line);
});
return lines.join("");
}PDFの場合、引用位置はpage_locationとして返り、start_page_numberは1始まりです。Citationsを有効にすると回答が複数のtextブロックに分割されるため、1つ目だけを読むと文章が途中で切れます。必ず全ブロックを連結してください。
同じPDFを使い回すならFiles API
同じ資料に何度も質問する場合、毎回base64で送るのは無駄です。 Files APIに一度アップロードしておけば、以降はfile_idを指定するだけで参照できます。GASではpayloadにBlobを入れるだけで自動的にmultipart/form-data形式になるため、実装は数行です。
// 1. PDFをFiles APIにアップロードしてfile_idを得る(1回だけ実行する)
function uploadPdfToFilesApi(fileId) {
const blob = DriveApp.getFileById(fileId).getBlob();
const res = UrlFetchApp.fetch("https://api.anthropic.com/v1/files", {
method: "post",
headers: {
"x-api-key": API_KEY,
"anthropic-version": "2023-06-01",
"anthropic-beta": "files-api-2025-04-14", // ベータ機能なので必須
},
// payloadにBlobを入れると自動でmultipart/form-dataになる
payload: { file: blob },
muteHttpExceptions: true,
});
const body = JSON.parse(res.getContentText());
if (res.getResponseCode() !== 200) {
throw new Error("アップロード失敗: " + res.getContentText());
}
Logger.log(body.id); // file_xxxxx をスクリプトプロパティなどに保存しておく
return body.id;
}
// 2. 2回目以降はfile_idを指定して質問する
function askUploadedPdf(claudeFileId, question) {
const body = callClaude(
{
model: "claude-opus-5",
max_tokens: 1500,
messages: [
{
role: "user",
content: [
{ type: "document", source: { type: "file", file_id: claudeFileId } },
{ type: "text", text: question },
],
},
],
},
"files-api-2025-04-14" // messages側にも同じヘッダーが必要
);
return textOf(body);
}Files APIはベータ機能のため、アップロード時と/v1/messages呼び出し時の両方にanthropic-betaヘッダーが必要です。片方に付け忘れるとエラーになります。 返ってきたfile_idはスクリプトプロパティに保存し、資料を差し替えたときだけ再アップロードしてください。
上限とつまずきポイント
1. サイズとページ数の上限
リクエスト全体で32MB、ページ数は最大600ページです(コンテキストが200Kのモデルは100ページまで)。 base64化するとサイズが約1.33倍になるので、実質24MB程度が目安です。 加えてGAS側にもPOSTのペイロード上限(50MB)があります。大きい資料は章単位に分割して送ってください。
2. ページ数に比例してトークンが増える
PDFはテキストとページの見た目の両方が入力になるため、ページ数が多い資料は入力トークンが大きくなります。 100ページの資料に10回質問すると、その都度全ページ分の入力料金がかかります。 繰り返し質問する運用では、Files APIとプロンプトキャッシュの併用でコストを抑えられます。
3. スキャンPDF・手書きの精度
紙をスキャンしたPDFも読めますが、精度は画質次第です。 金額や日付は「読み取れない場合はnull」と指示し、null件数を監視して人の確認に回す設計にします。 元データがWordやExcelから出力されたPDFなら、テキスト情報が残っているため精度は安定します。
4. パスワード付きPDFは事前に解除が必要
暗号化されたPDFはそのまま送れません。エラーになるため、送信前にパスワードを解除したファイルを用意してください。 機密性が高くパスワードが外せない書類は、そもそも外部送信の可否から検討します。
5. モデルの使い分け
レイアウトが複雑な帳票や、判断を伴う読み取りは上位モデルが有利です。 様式が決まっていて項目位置も一定な書類なら、軽量なモデルに落としてコストを下げられます。 いきなり全件を安いモデルに切り替えず、同じ10件で結果を比べてから決めてください。
まとめ
GASでPDFを扱うなら、テキスト抽出を自作せずdocumentブロックでそのまま渡すのが最短です。 DriveのBlobをbase64にしてcontentの先頭に置く、抽出したい項目とnullの扱いをプロンプトで固定する、根拠が必要ならCitationsを有効にする。 この3点で、請求書の集計や社内文書QAは実務レベルで動きます。 あとは32MB・600ページの上限と、検算できる列を1つ持たせる工夫を入れておけば、人が全件を目視しない運用に踏み出せます。
よくある質問
はい。Claude APIにはPDFをそのまま渡せるdocumentブロックがあり、テキストとページの見た目の両方をAI側が解釈します。GAS側でPDFパーサーを用意する必要はありません。GASにはPDFからテキストを取り出す標準機能がないため、この方式が最も実装が短くなります。
リクエスト全体で32MBまで、ページ数は最大600ページです(コンテキストが200Kのモデルでは100ページまで)。base64にすると元ファイルの約1.33倍のサイズになるため、24MB程度のPDFで上限に近づきます。大きい資料は章ごとに分割して送るか、Files APIの利用を検討してください。
画像だけのPDFでもページ画像として解釈されるため読めますが、精度は元の画質に左右されます。傾き・かすれ・手書きが混ざるとゆらぎが出るので、金額や日付など間違えられない項目は「読み取れない場合はnullを返す」と指示し、人の確認を挟む設計にしてください。
毎回base64で送ることもできますが、Files APIに一度アップロードしてfile_idで参照すると、2回目以降は送信量を節約できます。就業規則や商品カタログのように長期間変わらない資料に向いています。ベータ機能のため専用のヘッダーが必要です。
表は列の対応がずれやすいため、抽出したい項目名と単位をプロンプトで明示するのが有効です。「税抜金額」「税込金額」のようにPDF内の表記そのままで指定し、出力はJSONで項目を固定します。合計値が明細の合計と一致するかをGAS側で検算すると、ずれをその場で検知できます。
契約書や個人情報を含む書類は、送信の可否を社内ルールで先に確認してください。判断が難しい場合は、対象を社外公開資料に限る、氏名や口座番号などをマスキングしてから送る、といった段階的な進め方が現実的です。