000%

公開 更新

NOTES · SEO / GEO / AEO14 · 2026.10

修正結果を検証し、デプロイ・復旧する

修正前後の同じURLを比較し、本番ビルド・本文・ステータス・キャッシュを確認する。本番ドメインの公開版を確認し、障害の種類に応じてコード・原稿・データを復旧する。

§ 01SEO / GEO / AEO

修正前後で同じURLを使う

ビルドはコードからデプロイ成果物を作れるか確認する。検索に提供する本文・URL・日付が正しいかは、実際の応答を読んで確認する。ビルドが成功しても本番にnoindexが残ったりCDNが古い本文を返したりすれば、検索・回答に必要な資料の配信は変わらない。同じURLの前後比較で、変更コードがどの応答を変えたか確認できる。

診断で作ったURL一覧を検証入力にする。正常な公開詳細、一覧の最初と次のページ、画像・添付資料、旧URL、存在しないURL、下書き・撤回資料を含める。修正前のステータスコード・最終URL・robots・canonical・タイトル・本文の主要文を保存する。修正後も同じ値を集めれば違いを比較できる。

検証表には対象URL、期待値、実測値、確認時刻、デプロイ版を記す。公開一覧に記事があるだけで終えず、詳細に移動して原文と照合する。メタデータだけを直した場合も、元の本文・写真・リンクが保たれたか確認する。

分けて確認する状態
正常な公開詳細
期待結果
意図した本文・代表URL・検索許可
失敗時の確認先
公開クエリ・メタデータ・キャッシュ
下書き・予約時刻前・撤回資料
期待結果
公開一覧・サイトマップから除外し、原文を非表示
失敗時の確認先
公開条件・認証・キャッシュ無効化
存在しないURL
期待結果
404または意図した410とエラー画面
失敗時の確認先
ルーティング・ストリーミング・ホスティング
旧URL
期待結果
指定した代表URLへ恒久転送
失敗時の確認先
転送順序・ループ
一覧の次ページ
期待結果
次の資料とリンクを提供
失敗時の確認先
ページ取得・代表URL
公開画像・添付
期待結果
未ログインアクセスと正しいコンテンツ型
失敗時の確認先
ストレージ権限・メディアプロキシ・WAF
§ 02SEO / GEO / AEO

変更に合うコード検査を選ぶ

公開判定やURL生成関数を変更したら、公開・下書き・予約前・撤回・存在しない文書など、結果が異なる入力を検討する。既存テストがあれば該当範囲を実行する。型検査と本番ビルドでデプロイ可能か確認する。以下のコマンドはpackage.jsonにtypecheckとbuildが定義された例だ。名前が違えばそのリポジトリのコマンドを使う。

npm run typecheck npm run build

型・ビルド検査の後も実際の本番モードでページを開く。ビルド時と実行時で本番ドメイン・公開APIのURLが違わないか確認する。検証サイトのnoindex・認証設定が本番へそのまま入らないよう、環境別の値を比較する。

主要な公開状態が誤る、正常記事がエラーになる、本文・リンクが欠ける場合は変更を直し、関連範囲を再確認する。インデックスや外部引用はこの段階のローカル検査で判定せず、デプロイ後の観測表で扱う。

§ 03SEO / GEO / AEO

レンダリング方式をビルド結果と実際の応答で確認する

まずpackage.jsonのbuildコマンド、Next.jsの版、next.config、データ取得とキャッシュ設定を読む。本番ビルドを実行し、その成果物でページを開く。ビルド出力とデプロイ設定から静的生成・リクエスト時処理・再検証の対象ルートを確認する。output: exportなら出力HTMLとホスティングの直接URL・404処理を確認する。

パーサーは文書文字列を構造として読むプログラムだ。公開詳細URLをcurlで取得し、HTMLパーサーでarticle・h1・本文段落・a hrefを抽出する。原稿の主要文が可視本文要素にあるか照合する。Next.jsのシリアライズデータやscript文字列だけにある文はHTML本文とは異なる。ストリーミングは最後まで受信し、最初のローディング表示だけで本文なしと判断しない。

ブラウザーでは直接URLを開く場合と一覧リンクから移動する場合を両方確認する。主要本文・出典がhydration前後で保たれるか、クリックやスクロールが新しい取得に必要かを見る。JavaScript無効化は依存を探る補助確認だ。スクリプトを使うストリーミング配置も考慮し、元の応答・完成DOM・検索ツールのレンダリングを併せて読む。

メタデータでは一般リクエストと対象クローラーの応答条件を分ける。Next.jsは一部のリクエストでメタデータをストリーミングし、HTMLのみを読むと分類したボットには待って提供する場合がある。User-Agentを変えたcurlで応答分岐を比較する。実際のボット訪問と収集結果はサーバーログと検索ツールで確認する。Metadata APIガイドのストリーミング・ボット処理をインストール済み版と照合する。

存在しない記事にnotFound()を呼んでも、本番HTTPステータスを直接読む。ストリーミング開始後はエラー画面が200で届くことがある。実際の404が必要なルートでは、ヘッダー送信前に存在・公開判定ができる位置を設計し、noindexも確認する。Next.jsのnot-found規則はストリーミング有無によるステータスの違いを説明する。

公開・修正・撤回後は詳細・一覧・サイトマップ・メタデータの現在値を再取得する。静的生成やキャッシュのあるページはキャッシュを満たした状態で変更し、最初の要求と更新完了後の要求を比較する。公開版、期待内容、実際の内容、応答時刻、再検証時刻を記録する。旧原稿が残るならデータキャッシュ・ページキャッシュ・CDNのどこから返ったか絞る。

§ 04SEO / GEO / AEO

応答・メタデータ・本文を検査する

検証環境で以下を実行する。HTMLとヘッダーを別ファイルに保存するため、ヘッダー内のX-Robots-Tagを見落とさず比較できる。検査URLごとにアドレスとファイル名を変える。

CHECK_URL='https://example.com/guides/opening-hours' curl -sS -D after.headers -o after.html "$CHECK_URL" rg -n -i 'HTTP/|location:|x-robots-tag:|content-type:' after.headers rg -n -i 'canonical|noindex|<title|application/ld\+json' after.html

公開代表URLは正常なステータス、意図したcanonical、検索を妨げないrobots指示を返す必要がある。本文に直接の答えと出典リンクがあるか探す。200なのにログイン・ボット確認・「見つかりません」が出る場合はアクセス設定かエラー処理を直す。検索除外を解除するならmetaとヘッダーの両方からnoindexを除く。

ストリーミングは応答を分割し、準備できた部分から送る方式だ。Next.jsのメタデータストリーミングやサーバーレンダリングでタグ位置が変わる場合がある。完成応答とブラウザーDOMを併せて読む。一般とボットの応答が違えばコード・CDN方針を調べる。curlでGooglebotを名乗るのは応答分岐の再現であり、実際のGoogleクロールの証拠はURL検査とサーバー記録から得る。

§ 05SEO / GEO / AEO

例の期待応答を直接比較する

本番モードの実習サーバーへ以下のパスを要求する。公開記事は200と開館時間本文、下書き・欠損記事は404と検索除外、保存ページは生成本文を期待する。サイトマップには公開詳細と保存URLがあり、下書きURLがないことを確認する。

curl -sS -D guide.headers -o guide.html http://localhost:3027/guides/opening-hours curl -sS -D draft.headers -o draft.html http://localhost:3027/guides/draft-guide curl -sS -D missing.headers -o missing.html http://localhost:3027/guides/missing curl -sS http://localhost:3027/archive/opening-2025 curl -sS http://localhost:3027/sitemap.xml

HTMLパーサーでarticleの本文とdata-revisionを読み、title・canonical・JSON-LDが同じ原稿を使うか比較する。script内のシリアライズ文字列と実際の本文要素を区別する。ローカルと本番ドメインの結果は別々に記録する。

/archive/missingと/policies/opening-hoursも要求する。前者は404・noindexでcanonicalなし、後者は200と開館時間案内本文である。コードだけでなくサーバーログの例外も確認する。保存ページのファイルはapp/archive/opening-2025/page.tsxだ。

§ 06SEO / GEO / AEO

HTMLパーサーで本文・URL・日付の不一致を探す

check-pages.mjsと以下のvalidate-dates.mjsを実習フォルダーに保存し、identity-time.mjsも同じ場所に置く。npm install --save-dev cheerio@1.2.0でHTMLパーサーを入れる。本番実習サーバーを起動し、node check-pages.mjs http://localhost:3027を実行する。

// インストール: npm install --save-dev cheerio@1.2.0 // 実行: node check-pages.mjs http://localhost:3027 import assert from 'node:assert/strict'; import { load } from 'cheerio'; import { validateDates } from './validate-dates.mjs'; const base = new URL(process.argv[2] || 'http://localhost:3027'); const origin = 'https://example.com'; // sampleのSITE_ORIGINと同じ値 const results = []; for (const [path, status] of [ ['/guides/opening-hours', 200], ['/guides/draft-guide', 404], ['/guides/missing', 404], ['/archive/opening-2025', 200], ['/archive/missing', 404], ['/policies/opening-hours', 200], ]) { const response = await fetch(new URL(path, base), { redirect: 'manual', signal: AbortSignal.timeout(10000), }); assert.equal(response.status, status, path + ': status'); const $ = load(await response.text()); const canonicals = $('link[rel="canonical"]'); const robots = $('meta[name="robots"]').map((_, e) => $(e).attr('content')).get().join(',') + ',' + (response.headers.get('x-robots-tag') || ''); if (status === 404) { assert.match(robots, /noindex/i, path + ': noindex'); assert.equal(canonicals.length, 0, path + ': inherited canonical'); assert.equal($('article[data-revision]').length, 0, path + ': leaked article'); } else { assert.equal(canonicals.length, 1, path + ': canonical count'); assert.equal(canonicals.attr('href'), origin + path, path + ': canonical'); assert.doesNotMatch(robots, /noindex/i, path + ': index blocked'); } if (path === '/guides/opening-hours') { const data = JSON.parse($('script[type="application/ld+json"]').text()); assert.equal(data.url, origin + path); assert.equal(data.name, $('article h1').text()); validateDates($, data); const body = $('article').clone(); body.find('script').remove(); assert.match(body.text(), /第1日曜日と祝日/); assert.equal($('article a').attr('href'), '/policies/opening-hours'); } results.push({ path, status, passed: true }); } const xml = await fetch(new URL('/sitemap.xml', base)); assert.equal(xml.status, 200); const $xml = load(await xml.text(), { xmlMode: true }); const urls = $xml('loc').map((_, e) => $xml(e).text()).get(); assert(urls.includes(origin + '/guides/opening-hours')); assert(urls.includes(origin + '/archive/opening-2025')); assert(urls.includes(origin + '/policies/opening-hours')); assert(!urls.some(url => /draft-guide|missing/.test(url))); const moved = await fetch(new URL('/guides/library-hours', base), { redirect: 'manual' }); assert.equal(moved.status, 308); assert.equal(new URL(moved.headers.get('location'), base).pathname, '/guides/opening-hours'); console.log(JSON.stringify({ pages: results, sitemap: urls }, null, 2));

validateDatesはdata-dateで公開日と修正日の行を区別し、各ラベル・datetime・可視日付をJSON-LDと照合する。日付値がどこかにあるだけの検査では、公開日と修正日の交換や可視年だけを1999年に変えた誤りを見つけられない。最後に旧slugの308転送とサイトマップも検査する。

結果は6ページの状態とサイトマップ一覧だ。旧slugの308も通過して初めて出力される。datePublished: labelは誤ラベル、dateModified: visible textは可視日付の不一致、inherited canonicalはエラーページの代表URL継承を指す。失敗項目の実HTMLを保存して出力フィールドを直す。

import assert from 'node:assert/strict'; import { formatContentDate } from './identity-time.mjs'; export function validateDates($, data) { for (const [key, label] of [['datePublished', '公開日'], ['dateModified', '修正日']]) { const row = $(`article [data-date="${key}"]`); const expected = data[key]; assert.equal(row.length, expected === undefined ? 0 : 1, key + ': row count'); if (expected === undefined) continue; assert.equal(row.find('[data-label]').text().trim(), label, key + ': label'); assert.equal(row.find('time').length, 1, key + ': time count'); assert.equal(row.find('time').attr('datetime'), expected, key + ': datetime'); assert.equal(row.find('time').text().trim(), formatContentDate(expected), key + ': visible text'); } }

formatContentDateは画面と検査で同じ表示方針を使う。回帰検査ではラベル交換、datetime交換、可視年だけの変更をそれぞれ作り、すべて失敗するか確認する。正常・異常入力を並べ、検査自体が誤りを見逃さないか照合する。

次をvalidate-dates.test.mjsとして保存する。Cheerioを入れた実習フォルダーでnode --test validate-dates.test.mjsを実行すると、正常入力1件と3種類の変形エラーを検査する。

import test from 'node:test'; import assert from 'node:assert/strict'; import { load } from 'cheerio'; import { validateDates } from './validate-dates.mjs'; const data = { datePublished: '2026-10-01', dateModified: '2026-10-23T09:00:00+09:00' }; const html = `<article><p data-date="datePublished"><span data-label>公開日</span><time datetime="2026-10-01">2026年10月1日</time></p><p data-date="dateModified"><span data-label>修正日</span><time datetime="2026-10-23T09:00:00+09:00">2026年10月23日</time></p></article>`; test('正しいラベル・機械日付・表示日付は通過する', () => validateDates(load(html), data)); test('ラベル交換・機械値交換・表示年の誤記はそれぞれ失敗する', () => { const labelSwap = html.replace('公開日', 'TEMP').replace('修正日', '公開日').replace('TEMP', '修正日'); const valueSwap = html.replace('datetime="2026-10-01"', 'datetime="TEMP"') .replace('datetime="2026-10-23T09:00:00+09:00"', 'datetime="2026-10-01"') .replace('datetime="TEMP"', 'datetime="2026-10-23T09:00:00+09:00"'); for (const bad of [labelSwap, valueSwap, html.replace('2026年10月1日', '1999年1月1日')]) assert.throws(() => validateDates(load(bad), data)); });

日付の可視ラベルまで読むのは、同じ数字でも意味が入れ替わるためだ。「公開日」に適用開始日を出すと、未来の開館時間を現在の案内と読ませる場合がある。validateDatesは公開・修正ラベルと値の一致を検査する。適用期間の文と業務事実の一致は原稿と根拠を照合して確認する。

§ 07SEO / GEO / AEO

ブラウザーで本文とリンクを直接使う

未ログイン画面で一覧を開き、最初・中間・最後の記事を押す。次ページと関連記事へ移動し、戻る・再読み込み後も同じ内容が保たれるか確認する。写真・添付・動画は実際に開くか再生する。出典リンクが似たタイトルの別文書につながっていないかも照合する。

広い画面と狭い画面でタイトル・長いURL・コードの折り返しを読む。表が縦配置になるなら各値に列名が付くか見る。テーマ切替で本文と背景のコントラストを比較し、言語切替で段落とリンクが保たれるか確認する。

開発者ツールのNetworkは要求・応答・転送時間、Consoleは実行メッセージとエラーを表示する。文書・画像・スクリプトの失敗応答を確認し、機能への影響を追う。HTMLにない本文がブラウザーだけにある場合は、JavaScript前後のDOMとデータ要求を比較する。Googleの処理結果は公開後にURL検査で再確認する。

§ 08SEO / GEO / AEO

サイトマップ・リンク・構造化データを照合する

サイトマップURL集合と公開データの期待URL集合を比較する。重複・漏れ・撤回URL・転送URLを分ける。サイトマップインデックスなら子ファイルまで読む。全対象の応答を集める場合はサービス負荷を考慮し、同時数と速度を制限して失敗URLと理由を保存する。

詳細のcanonicalとサイトマップURLが一致し、内部リンクも同じURLを使うか確認する。全ページの更新日がデプロイ時刻なら生成コードを直す。hreflangがある場合は相手言語の正常応答と戻り参照を確認する。

ページ内のすべてのapplication/ld+jsonをJSONとして解析する。構文エラーを先に直し、タイトル・日付・執筆者・URLを可視本文と照合する。リッチリザルトは通常リンクに追加情報を示す検索表示だ。Google対応型はRich Results Testで追加確認する。警告は必須項目と推奨項目の欠落を分け、型の文書と比較する。

§ 09SEO / GEO / AEO

公開・修正・撤回・復元を同じ経路で比較する

コンテンツ機能を変えたら1記事のライフサイクルを確認する。下書きが公開面にないこと、公開後の一覧・詳細・サイトマップ・共有情報が一致することを見る。タイトル・日付・根拠の修正後は旧値がキャッシュに残らないか、撤回後は公開リンク・自前検索・チャットボットが使わないか確認する。

復元検査は試験文書を公開し、修正・撤回後に旧revisionを再公開する順で行う。復元本文・出典・画像を読み、現在の作業版も維持されるか確認する。初回公開日は保ち、復元で内容が変わった時点を修正日に記録する。

新しいコンテンツ型の一覧・詳細・サイトマップが同じ公開条件を使うか比較する。画面はあるのにサイトマップ・関連記事・監視対象から抜けるなら登録経路を直す。2つの情報源を統合する一覧で片方の取得が失敗した場合、黙って空として扱わない。画面は部分失敗を知らせられるが、全件を確定するサイトマップ・監視作業は不完全な結果を区別して保存する。

改修したメインと保存ページは原文・写真・リンクを併せて照合する。自動検査外のクリック後領域、動画、ブラウザー別表示も確認範囲に記す。一覧から詳細・関連記事・出典へ移動し、内容と接続URLを比較する。

§ 10SEO / GEO / AEO

デプロイ版と本番ドメインを確認する

デプロイ前に変更ファイルとコンテンツ一覧を確認し、旧正常版の識別子を記録する。コードと原稿を同時に変えたら、その組み合わせを記す。保存構造は既存データを読める順で変える。新フィールド追加、既存データ補完、読み取り切替、旧フィールド整理に分けると中間版の衝突を減らせる。

デプロイツールでビルド完了とコミットまたはリリースIDを確認し、本番ドメインがその版を指すか確認する。プレビューと本番を別々に要求する。影響する公開パスを再要求し、ヘッダー・本文・代表URL・サイトマップを照合する。

CMS・CDNキャッシュがある場合、コンテンツ更新経路の再検証・無効化を確認する。記事は変わったのに一覧・サイトマップ・共有カードが古いなら、それぞれのデータとキャッシュを調べる。最新記事の修正・撤回後、すべての公開経路が同じ状態を示す必要がある。

§ 11SEO / GEO / AEO

本番経路でDNS・TLS・キャッシュ・前段遮断を確認する

digでDNSのA・AAAA・CNAMEを問い合わせる。digはDNSレコード照会ツールだ。ホスティング指定値と実際の応答を比較し、設定したIPv4・IPv6がそれぞれ動くか確認する。以下のexample.comとパスを対象公開URLに置き換える。

dig +noall +answer example.com A dig +noall +answer example.com AAAA dig +noall +answer www.example.com CNAME CHECK_URL='https://example.com/guides/opening-hours' curl -sS --max-time 30 -D infra.headers -o infra.html \ -w 'status=%{http_code} dns=%{time_namelookup} connect=%{time_connect} tls=%{time_appconnect} first_byte=%{time_starttransfer} total=%{time_total}\n' \ "$CHECK_URL" rg -n -i 'HTTP/|location:|cache-control:|age:|etag:|vary:|x-robots-tag:|content-type:' infra.headers

curlはURLへ要求を送るコマンドだ。-Dはヘッダー、-oは本文をファイル保存し、-wは指定測定値を出力する。--max-time 30は要求全体を最大30秒待つ。出力時間は開始から各段階までの累積秒数だ。time_appconnectはTLS接続完了、time_starttransferは最初の応答バイトまでの時間で、前段階も含むためサーバー処理時間そのものではない。

TLS証明書はcurlの標準検証を使う。失敗時はドメイン名・期限・証明書チェーン・接続経路を確認する。-kで検証を省いた結果は証明書の正常性確認に使えない。HTTPも要求し、HTTPS代表URLへ転送されるか見る。

同じURLの初回と再要求のヘッダー・本文を保存する。Ageはキャッシュ応答の経過時間を示す。プラットフォーム固有のキャッシュ状態ヘッダーも読む。ヒットでも本文が最新公開版か照合する。検証資料の修正・撤回後は詳細・一覧・サイトマップ・画像の更新時点まで確認する。

WAF・CDNログで検査要求の時刻とパスを探し、オリジンログにつなぐ。公開記事の代わりに確認画面・403・429が出たら一致ルールを確認する。人とクローラーに届ける主要本文は同じ公開資料を使う。検索ツールの実収集結果でも本文を確認する。

§ 12SEO / GEO / AEO

失敗の種類に応じて戻す

コード公開後に公開詳細が5xxになったり非公開資料が露出したりしたら、まず旧正常版へ復旧するか該当パスの公開を止める。本文が正常でメタデータだけが誤るなら、原因を確認した設定だけ戻せる。復旧後は元の失敗URLと隣接状態を再確認する。

コードのロールバックはCMS本文やDB変更を自動復元しない。誤公開原稿はCMS履歴から復元する。構造変更は旧コードが現データを読めるか確認して扱う。DB全体復元を通常の初動にすると、その後の正常入力まで失いかねない。

復旧記録には症状・影響URL・デプロイID・復旧時刻・復旧後の観測値を残す。本番応答が正常化した後、検索・AI観測で旧内容がどれだけ残るか別途追跡する。再クロール時点はコード公開時点と異なる。

復旧後に外部検索が即座に変わらないなら、まず最新クロール時刻を見る。障害時応答が検索側に残る間は、現サーバーが正常でも旧資料が出る場合がある。復旧応答とその後の収集結果をつなげれば、コード再修正が必要か外部更新確認の段階か判断できる。

§ 13SEO / GEO / AEO

画像・記事・フィードの失敗を要求で再現する

画像最適化では容量・撤回後アクセス・候補幅を一緒に確認する。大きな元画像、200pxの小画像、変換中の撤回、同じ幅の同時要求を入力にする。media.test.mjsを画像実習フォルダーに保存し、node --test media.test.mjsで実行する。サーバーは任意のローカルポートで起動し、終了時に閉じる。

import test from 'node:test'; import assert from 'node:assert/strict'; import sharp from 'sharp'; import { createMediaStore } from './media-store.mjs'; import { createDemoServer, sampleImage } from './media-demo.mjs'; import { article, renderArticle } from './article-jsonld.mjs'; import { renderFeed, escapeXml } from './feed.mjs'; const now = '2026-10-24T09:00:00+09:00'; test('同じ幅の同時要求は1回だけ変換し実ピクセル幅を返す', async () => { const store = await createMediaStore(await sampleImage()); const [a, b] = await Promise.all([store.read(640), store.read(640)]); assert.deepEqual(a.body, b.body); assert.equal(store.stats().transforms, 1); assert.equal((await sharp(a.body).metadata()).width, 640); assert.equal(a.headers['Cache-Control'], 'private, no-store, max-age=0'); assert.equal((await store.read(999)).status, 400); }); test('変換中に撤回すると待機要求も404になり元画像と既存派生画像を遮断する', async () => { const store = await createMediaStore(await sampleImage()); const pending = store.read(640); store.setPublished(false); assert.equal((await pending).status, 404); for (const width of [null, 320, 640, 1280]) assert.equal((await store.read(width)).status, 404); store.setPublished(true); assert.equal((await store.read(640)).status, 200); assert.equal(store.stats().transforms, 1); }); test('小さい元画像を拡大せずsrcsetに重複幅を入れない', async () => { const source = await sharp({ create: { width: 200, height: 100, channels: 3, background: 'red' } }).jpeg().toBuffer(); const store = await createMediaStore(source); assert.equal((await store.read(1280)).width, 200); const server = createDemoServer(store); await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); try { const base = `http://127.0.0.1:${server.address().port}`; const html = await (await fetch(base)).text(); assert.equal((html.match(/ 200w/g) ?? []).length, 1); assert(!html.includes('1280w')); await fetch(base + '/media/photo?w=640'); store.setPublished(false); for (const path of ['/media/photo', '/media/photo?w=640', '/news/new-hours', '/']) assert.equal((await fetch(base + path)).status, 404); const feed = await (await fetch(base + '/rss.xml')).text(); assert(!feed.includes('<item>')); } finally { await new Promise(resolve => server.close(resolve)); } }); test('可視画像・共有画像・Article.image・日付は同じ資料に由来する', () => { const { html, structured } = renderArticle('https://example.com', { path: '/media/photo?w=1280', width: 1280, height: 800 }, now); assert.equal(structured.image[0], 'https://example.com/media/photo?w=1280'); assert(html.includes(`src="${structured.image[0]}"`)); assert(html.includes(`content="${structured.image[0]}"`)); assert(html.includes(`datetime="${structured.datePublished}"`)); assert(html.includes(`datetime="${structured.dateModified}"`)); assert.throws(() => renderArticle('https://example.com', { path: '/photo' }, '2026-09-01T00:00:00Z')); }); test('フィードは下書き・撤回・予約を除外し修正と移転後もIDと初回公開日を保つ', () => { const changed = { ...article, path: '/news/revised-hours', summary: 'A & B < C', contentModifiedAt: now }; const xml = renderFeed([changed, { ...article, id: 'draft', status: 'draft' }, { ...article, id: 'withdrawn', status: 'withdrawn' }, { ...article, id: 'future', publicFrom: '2027-01-01T00:00:00Z' }], 'https://example.com', now); assert.equal((xml.match(/<item>/g) ?? []).length, 1); assert(xml.includes('A &amp;amp; B &amp;lt; C')); assert(xml.includes('/#document-new-hours</guid>')); assert(xml.includes('/news/revised-hours</link>')); assert(xml.includes('Wed, 30 Sep 2026 00:00:00 GMT')); assert(!renderFeed([{ ...article, status: 'withdrawn' }], 'https://example.com', now).includes('<item>')); }); test('フィードは日付精度を保ちXML禁止文字と逆転日付を拒否する', () => { const xml = renderFeed([{ ...article, publishedAt: '2026-09-30' }], 'https://example.com', now); assert(!xml.includes('<pubDate>')); assert.throws(() => escapeXml('bad\u0000')); assert.throws(() => escapeXml('bad\uD800')); assert.throws(() => renderFeed([{ ...article, contentModifiedAt: '2026-09-01' }], 'https://example.com', now)); });

最初は同時2回取得でも変換が1回か、2つ目は変換待ち中の撤回で待機要求と以後の元画像・派生画像を止めるかを検査する。3つ目は200px元画像に1280wを付けないことと実HTTP経路の撤回を確認する。記事検査は日付・画像入力がHTMLとJSON-LDに共通反映されるかを見る。フィードは下書き・予約・撤回の除外、安定ID、日付精度、XML特殊文字を検査する。

ブラウザーでは記事写真のデコードとRSSのXML解析を追加確認する。動画4秒リンクを押し、URLを新窓に貼って同じ案内が出るか見る。画像配信変更後も本文・動画区間移動が動いてこそ、読者は根拠に到達できる。

動画生成後、以下をvideo.test.mjsに保存してnode --test video.test.mjsを実行する。ファイルの先頭・末尾・最後までの範囲を要求し、受信バイトを原本と比較する。範囲外と逆転範囲が416か確認する。

import test from 'node:test'; import assert from 'node:assert/strict'; import { readFile } from 'node:fs/promises'; import { createDemoServer } from './media-demo.mjs'; // node make-video-demo.mjsでtalk.mp4を作成してから実行する。 test('動画のbyte rangeは要求範囲を返しファイル外要求を拒否する', async () => { const file = await readFile(new URL('./talk.mp4', import.meta.url)); const server = createDemoServer(null); await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); try { const url = `http://127.0.0.1:${server.address().port}/talk.mp4`; for (const [range, start, end] of [ ['bytes=0-99', 0, 99], ['bytes=-100', file.length - 100, file.length - 1], [`bytes=${file.length - 100}-`, file.length - 100, file.length - 1], ]) { const response = await fetch(url, { headers: { Range: range } }); assert.equal(response.status, 206); assert.equal(response.headers.get('content-range'), `bytes ${start}-${end}/${file.length}`); assert.deepEqual(Buffer.from(await response.arrayBuffer()), file.subarray(start, end + 1)); } for (const range of ['bytes=999999999-', 'bytes=20-10', 'bytes=-0', 'bytes=-']) assert.equal((await fetch(url, { headers: { Range: range } })).status, 416); } finally { await new Promise(resolve => server.close(resolve)); } });

— FIN —