観測を自動化し、次の修正へつなぐ
応答の保存と判定を分けて観測する。エラーや未確認を残し、原因ごとに次の修正箇所を決め、同じURLと質問で結果を再比較する。
観測ツールの誤判定を防ぐ
サイトの原稿と外部の検索結果・回答は、異なる時点で変わる。一度の手動確認だけでは、公開後に新資料が反映されたか、後で古い資料が再利用されたかを追いにくい。同じURLと質問を一定周期で観測すれば、変更時点と結果を比較できる。自動収集が別の回答や前の画面を保存すると誤った原因を直すことになるので、原文と判定対象を一緒に残す。
自動インデックス検査では、入力URLと結果画面が判定したURLを比較する。前のURLの結果が残る場合や応答未完了なら未確認として保存する。「インデックス登録未完了」に「登録完了」が含まれるだけで成功にしないよう、否定、待機、不明を先に区別する。画面の言語や表現が変わったら元の結果を保存し、判定規則を再確認する。
AIの引用抽出は、現在の質問に対する完了済み回答とその出典欄に限定する。利用者の入力URL、サイドバーの過去会話、生成中の文、単なる名前の言及は引用リンクと区別する。サイト全体の引用と対象詳細ページの引用も別の値で保存する。
出典一覧が保存上限を超えたり応答が切れたりしたら、欠けた範囲を記録する。全体を確認できないまま引用なしと確定しない。アクセス失敗、ログイン要求、サービス制限、未完成回答、解釈不能な結果は、それぞれ理由付きの未確認値とする。過去の記録に正確性・最新性評価がなければ、後から項目を追加しても過去データを正解で埋めない。
Google URL Inspection APIはインデックスに保存された版の状態を返す。応答の最終クロール日と選択された正規URLを現在のページと比較する。Google Indexing APIはJobPostingとVideoObject内のBroadcastEventページの更新・削除通知に使う。通常の案内はサイトマップと内部リンクでクロール経路を用意する。 URL Inspection · Indexing APIの対象
定期観測の要求数と完了範囲を管理する
自動観測を作る前に、対象URL数、検索サービス数、質問数、AIサービス数、反復回数から要求量を計算する。インデックス検査のURLがU件、エンジンがE、AI質問がQ件、サービスがA、反復がR回なら、一回の基本要求数はU×E + Q×A×R。実際には再試行や応答待ちの要求も加わる。
公開時のキャッシュ更新と、外部検索・AI観測の実行を分ける。公開のたびに全質問を再実行すると、短時間に同じ要求が重なる。一定周期で対象一覧を更新し、変更URLを特定して必要な観測を行う。周期は運用者が決め、新規ページが増えても自動で実行頻度を上げない。
ジョブにはサービス、URL、質問の版、観測回を識別するキーを付ける。複数実行器が同じ仕事を同時に取らないよう、占有期限と完了記録を管理する。再試行回数、間隔、停止条件を決め、制限応答を受けたらそのサービスの次回実行を遅らせる。待機期限が過ぎたら未完了状態と次回再試行時刻を保存する。
管理画面では登録ジョブ数、実際の試行数、正常応答数、失敗・未確認数、最終完了時刻を別々に表示する。完了時刻は応答原文と判定の両方を保存してから更新する。実行、原文保存、判定、管理画面の再取得まで確認し、一部サービスの失敗は範囲を分けて報告する。
観測結果から次の修正箇所を決める
未登録URLはアクセス、robots、正規URL、重複、本文品質と検索ツールの理由を照合する。登録済みなのに関連質問で見つからなければ、質問と本文の対応、内部リンク、根拠の十分さを見る。引用されても答えが誤るなら、混乱を生んだ日付、表現、段階、過去資料への接続を直す。
答えが正確でも質問に合わなければ、ページの対象と条件をタイトル、冒頭、見出しで明示する。不十分なら、申込方法、例外、費用など質問に必要な内容を実際の根拠で補う。修正文と質問IDをつなぎ、次回も同条件で観測する。
運用記録には現在値、前回との差、考えられる原因、次の修正、再観測時点を残す。日曜の質問に平日の時間表を引用した応答なら、その引用URLを記す。ページに週末の条件を補い、次回は日曜の時間と休館例外が答えに反映されるか確認する。
例えば新HTMLに休館条件があり、クロール日も更新されたのにAIが2025年の記録だけを引用するなら、現行・過去文書のタイトル、期間、リンクを調べる。現行文書を引用しながら例外を省くなら、その箇所と質問条件を照合する。文書がすでに明確なら応答の揺れも調べる。原文と引用URLなしに引用率低下だけ分かっても、この選択はできない。
自動検査で画像バイトが急増したら、CMS経路のcurrentSrc、幅、形式を保存し、元画像URLへ変わったか調べる。変換回数だけ増えたなら保存キーと再利用経路を見る。撤回後に200が出るなら、サイズ改善より公開判定とCDN残存ファイルの処理を先に直す。異なる失敗を一つのSEOエラーにまとめないよう、結果項目を分ける。
RSS検査は公開項目ID、リンク、初回公開日を比較する。タイトル修正でguidが変われば購読器に重複ができ、撤回後も残れば公開一覧の更新を直す必要がある。動画検査は区間URLとメディアアクセスを定期確認し、内容変更時は文字起こし、要約、区間時刻を見直す。検索露出とAI回答は技術検査の後、別の日付・質問条件で観測する。
診断で始めた三つの問題を最後まで照合する
架空図書館の最初の三問題を再確認する。開館時間の本文が応答HTMLに入り、詳細canonicalがその記事を指し、過去案内に適用期間と現行案内へのリンクがあれば、サイトの変更結果を確認できたことになる。公開、下書き、存在しない文書の状態と一覧、サイトマップも照合する。
次に、検索ツールがそのURLをいつクロールし、どの正規URLを選んだか記録する。AIには同じ日曜の質問を同条件で再実行する。技術修正後も古い時間表を使うなら、実際の引用URLと原文を読み、過去資料のリンクと時点説明を再調査する。
引用が出ても回答条件が違えば、内容と根拠の対応を直す。アクセス自体が失敗すればサーバー、WAF、キャッシュを直す。技術結果が正常で外部収集記録がまだ変わらないなら、再クロール状態を観測する。原因に沿って次の措置を選び、変わるはずの値を記す。
原文保存から次の修正まで一つの記録でつなぐ
ジョブキーに質問ID、質問の版、サービス、実行回を入れ、完了回答の原文と出典を先に保存する。判定は原文を参照する別記録にする。評価基準を変えても原文を上書きせず判定版を上げられる。再試行は新しい試行として記録し、最初の失敗要求を消さない。
集計入力には応答状態、六つの判定、主張ごとの根拠と理由、原文パスが必要だ。 評価コードは項目の欠けた完了行を拒否する。以下の実習では、原文保存、判定ファイルの読み込み、条件別集計、次の修正記録まで実行する。
URL検査要求と原文保存を実行する
observe.mjsをmetrics.mjs、assessment-fixture.mjsと同じフォルダーに保存する。node observe.mjsは外部要求なしで仮想データを使う。observations/run-…の下にraw/index.json、応答原文、assessments-v1.json、report.jsonを作る。同じ仕事を再実行しても新フォルダーを作り、過去原文は上書きしない。
// Node.js 22。標準はfixture、--liveはURL Inspection要求。
import { mkdir, mkdtemp, writeFile, readFile } from 'node:fs/promises';
import { resolve, join } from 'node:path';
import { pathToFileURL } from 'node:url';
import { grouped } from './metrics.mjs';
import { examples } from './assessment-fixture.mjs';
export async function inspectIndex({ inspectionUrl, siteUrl, token, request = fetch }) {
if (!token) throw new Error('OAuth access token required');
const queriedAt = new Date().toISOString();
try {
const response = await request('https://searchconsole.googleapis.com/v1/urlInspection/index:inspect', {
method: 'POST', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ inspectionUrl, siteUrl, languageCode: 'ko-KR' }),
signal: AbortSignal.timeout(10000),
});
const raw = await response.text();
const base = { inspectionUrl, siteUrl, queriedAt, httpStatus: response.status, raw };
if (!response.ok) return { ...base, status: 'failed',
reason: 'HTTP ' + response.status,
retryable: response.status === 429 || response.status >= 500,
retryAfter: response.headers.get('retry-after') };
let data;
try { data = JSON.parse(raw); } catch { return { ...base, status: 'unknown', reason: 'Invalid JSON' }; }
const result = data.inspectionResult?.indexStatusResult;
if (!result?.verdict) return { ...base, status: 'unknown', reason: 'Missing index verdict' };
return { ...base, status: 'complete', verdict: result.verdict,
lastCrawlTime: result.lastCrawlTime ?? null,
googleCanonical: result.googleCanonical ?? null,
canonicalMatches: result.googleCanonical ? result.googleCanonical === inspectionUrl : null,
coverageState: result.coverageState ?? null };
} catch (error) {
return { inspectionUrl, siteUrl, queriedAt, status: 'failed',
reason: error.name === 'TimeoutError' ? 'Timeout' : 'Network failure', retryable: true };
}
}
export async function runObservation(out, indexRecord) {
await mkdir(out, { recursive: true });
const run = await mkdtemp(join(resolve(out), 'run-'));
await mkdir(join(run, 'raw'));
await writeFile(join(run, 'raw/index.json'), JSON.stringify(indexRecord, null, 2), { flag: 'wx' });
for (const row of examples) {
await writeFile(join(run, row.rawPath), JSON.stringify({
fixture: true, question: row.question, answer: row.answer, reference: row.reference,
status: row.status, failure: row.failure, model: row.model,
sources: row.claims?.filter(c => c.sourceUrl).map(c => ({ url: c.sourceUrl, quote: c.quote })),
}, null, 2), { flag: 'wx' });
}
await writeFile(join(run, 'assessments-v1.json'), JSON.stringify(examples, null, 2), { flag: 'wx' });
// 保存した判定ファイルを読み、条件別に集計する。
const rows = JSON.parse(await readFile(join(run, 'assessments-v1.json'), 'utf8'));
const report = { index: indexRecord.status, groups: grouped(rows),
next: rows.filter(r => r.status === 'complete' && (r.complete === false || r.timely === false))
.map(r => ({ questionId: r.questionId, rawPath: r.rawPath,
action: r.timely === false ? '過去案内の適用期間と現行案内へのリンクを照合する。'
: '現行案内の第一日曜休館の文言と回答の省略を照合する。' })) };
await writeFile(join(run, 'report.json'), JSON.stringify(report, null, 2), { flag: 'wx' });
return { run, report };
}
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
const record = process.argv.includes('--live')
? await inspectIndex({ inspectionUrl: process.env.INSPECTION_URL,
siteUrl: process.env.SEARCH_CONSOLE_SITE, token: process.env.SEARCH_CONSOLE_TOKEN })
: { status: 'complete', verdict: 'PASS', googleCanonical: 'https://example.com/guides/opening-hours',
fixture: true, queriedAt: '2026-10-24T00:00:00Z' };
console.log(JSON.stringify(await runObservation(process.env.OBSERVATION_DIR || './observations', record), null, 2));
}
inspectIndexはinspectionUrlとsiteUrlをPOST本文へ入れ、OAuthアクセストークンをAuthorizationヘッダーで送る。Search Console登録プロパティにアクセスできるアカウントとwebmasters.readonlyまたはwebmastersスコープが必要だ。URLプレフィックスプロパティは末尾の/を含み、ドメインプロパティはsc-domain:example.com形式を使う。トークンは環境変数から読み、保存する観測記録へは入れない。 要求と認証の規約
INSPECTION_URL、SEARCH_CONSOLE_SITE、SEARCH_CONSOLE_TOKENを環境変数で渡し、node observe.mjs --liveを実行するとURL Inspection要求を送る。AI評価入力はassessment-fixture.mjsから読む。観測回答を評価する際は、そのサービスの原文、出典、評価値へ差し替える。
HTTP 401・403は認証とプロパティ権限を直してから再試行する。429と5xxは失敗原文、retryAfter、retryableを残し、今回の実行を終える。スケジューラーはRetry-Afterか所定の遅延後に新試行として記録し、即時の無限再試行をしない。200でもverdictがない、またはJSONが壊れていればunknown。PASSだけでなくlastCrawlTimeとgoogleCanonicalを要求URLと比較する。
URL Inspectionの割当量はプロパティごとに一日2,000回、毎分600回、プロジェクトごとに一日1,000万回、毎分15,000回だ。複数実行器が同じプロパティを検査するなら共有保存先で利用量を合算する。次回要求可能時刻をキューへ保存すれば、429後の再要求集中を減らせる。 公式の割当量
runObservationは原文を先に保存し、判定ファイルを再読してgroupedで集計する。report.nextには休館例外の省略と過去資料利用を裏づける原文パスが残る。次の担当者は原文とページを照合し、修正文、文書revision、再観測質問を記録する。失敗要求や判定不能な応答も集計から消えない。
このコードで原文を先に保存する順序は、判断の再確認を可能にする。検索ツールの規則やAI評価基準が変わっても当時の応答を読み直せる。report.nextの原文パスから誤った期間や欠けた条件を見つけ、修正文と次の観測を結べば、自動化が実際の内容修正につながる。