canonical・公開日・修正日と現在・過去を正確に表す
どの文書のどの版がいつ有効かを、正規URL、日付、適用期間、実際のコードで説明する。
URLと時刻は別の質問に答える
「原文はどこか」「いつ公開されたか」「いま適用されるか」は別の質問だ。canonicalは重複・類似文書の正規URLを示し、公開日・修正日は文書の履歴を説明する。適用期間は本文の料金・時間表・資格がいつ有効かを示す。
架空の図書館の2025年の時間表を2026年に新CMSへ移したとする。移行時刻を初回公開日とし、題名を「現在の開館時間」にすると過去資料が最新案内に見える。canonicalまで現行時間表へ向けると、異なる期間の事実を同じ文書へまとめる信号になる。過去の基準を確認できるよう、元の期間・出典・自己参照URLを残し、現行案内へリンクする。
検索システムは文書をまとめ、結果の日付を推定する。生成型回答は質問の時点に合う資料を選ぶ必要がある。URL・期間・本文が合えば、人と道具が比較する根拠になる。Search Consoleでは選ばれた正規URLと最終収集時刻を読み、AI回答では引用ページと文の適用期間を照合する。
canonicalが必要な重複と別文書を分ける
canonicalizationは複数候補から正規URLを決める過程だ。同じ記事に追跡用UTMだけが付いた場合や出力形式だけが違う場合は統一できる。内部リンクとサイトマップも同じURLを使う。Googleはredirectとrel=canonicalを強い手掛かり、サイトマップを弱い手掛かりと説明し、最終的な選択は検索システムが行う。正規URLのガイドライン
canonicalリンクを読んでもブラウザーは元のURLにとどまる。訪問者を新URLへ送るには301または308リダイレクトを使う。非公開文書は認証・権限検査で保護し、公開文書を検索から除く場合はnoindexを提供する。
同じ案内の基本URLとUTM付きURLが別々に収集されると、検索は比較して代表を選ぶ必要がある。canonicalと内部リンクを合わせる目的は、同じ内容の信号を意図したURLへ集め、結果で使うURLを一貫して示すことだ。過去案内と現行案内は答える期間が異なるため別URLを保つ。AIが過去の事実を現在の事実と読む問題は、文書の期間説明と実際の引用でも確認する。
作成・公開・修正・出来事・適用の時刻を分ける
createdAtは作業版を作った内部時刻だ。初回公開日が確認できたらpublishedAtに保存し、JSON-LDのdatePublishedに使う。contentModifiedAtは公開内容の実質的な変更時刻でdateModifiedの入力になる。保存・配信・収集のたびに変えると、どの事実がいつ変わったか分からなくなる。
出来事の日付は文書が説明する出来事の発生日だ。方針の決議日、告知の公開日、新時間表の適用日は異なりうる。本文の「公開」「修正」「適用期間」ラベルも分ける。Googleの日付ガイドは公開・実質修正日を可視本文と構造化データで一貫させ、出来事の日付と混同しないよう説明する。ページの日付ガイド
サイトマップのlastmodはそのURLの重要な変更を示す。本文・主要リンク・構造化データなどの実際の変更記録から作り、正確な記録がなければ省略する。古い記事へ今日の日付だけを入れても、実質的な更新の説明にはならない。サイトマップのガイド · コンテンツ更新のガイド
「10月24日公開、11月1日から開館時間変更」という告知を10月25日に読めば、まだ旧時間が適用される。公開日だけで最新資料を選ぶと、将来の時間を今日の時間として答えうる。公開・修正日は文書が出た時点、適用期間は事実を使える時点を示す。そのため本文にも「11月1日から」と書き、現行案内と予定案内を分ける。
公開状態と現在・過去の適用状態を独立して表す
過去の記録も公開文書になりうる。publicationStatusの下書き・公開・撤回と事実の適用状態を分ける。現在効力がないという理由で削除すると、過去の方針・料金を調べる質問に答えられない。保存価値とアクセス方針を判断して公開可否を決める。
文書IDは同じ記事、revisionは修正版、slugはURL用の名前を識別する。タイトル・slugが変わっても同じ文書なら安定IDと修正履歴を保つ。現行案内と過去記録が別文書ならIDを分け、「現在の案内」「以前の案内」のリンクで関係を示す。supersedesは後続文書が旧文書を置き換える内部関係に使える。
validFrom・validUntilは例のモデルで適用期間を保存する項目だ。WebPageには公開・修正日を入れ、適用期間は本文に「2025年1月1日〜12月31日」のように表示する。Schema.org属性は文書の型ごとに定義されている。WebPageの属性
日付とURLの判定を実行可能な関数にする
以下をidentity-time.mjsとして保存する。Node.js 22で外部パッケージなしで動く。dateOnlyは実在するYYYY-MM-DDを検証し、そのまま返す。instantは秒単位の時刻と明示タイムゾーンを検証する。日付だけが分かる出来事に任意の午前0時やタイムゾーンを加えない。
ISO 8601は日付・時刻の表現形式を定める国際標準だ。この例は2026-10-23T12:00:00+09:00のように日付・時刻・UTCオフセットを秒単位で書く。+09:00はUTCより9時間進み、同じ瞬間のUTC表記は2026-10-23T03:00:00Zだ。地域タイムゾーンの季節変化が必要なサービスでは、その規則も管理する。
canonicalUrlは保存済みパスと内容を変えるページ番号からURLを作る。例では許可パスを英小文字・数字・ハイフンに限る。実サービスで韓国語slugや他のパラメーターに対応するならデータ契約に合わせて拡張する。要求Hostやクエリ全体をコピーしないため、UTM・任意ドメインが正規URLに混ざらない。
// Node.js 22, 外部パッケージなし。時刻は秒単位、公開・修正日は日付のみも可。
export function dateOnly(value) {
if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(value))
throw new Error('Invalid date');
const ms = Date.parse(value + 'T00:00:00Z');
if (!Number.isFinite(ms) || new Date(ms).toISOString().slice(0, 10) !== value)
throw new Error('Invalid date');
return value; // 出来事の日付をタイムゾーンで移動しない。
}
export function instant(value) {
const m = typeof value === 'string' && value.match(
/^(\d{4}-\d{2}-\d{2})T(\d{2}):(\d{2}):(\d{2})(Z|[+-]\d{2}:\d{2})$/);
if (!m) throw new Error('Timezone required');
dateOnly(m[1]);
const offset = m[5] === 'Z' ? [0, 0] : m[5].slice(1).split(':').map(Number);
if (+m[2] > 23 || +m[3] > 59 || +m[4] > 59 || offset[0] > 23 || offset[1] > 59)
throw new Error('Invalid time');
const ms = Date.parse(value);
if (!Number.isFinite(ms)) throw new Error('Invalid time');
return ms;
}
export function canonicalUrl(path, page = 1, origin = 'https://example.com') {
// 要求Host・クエリをコピーせず、保存済みパスと意味のあるページ番号だけを使う。
if (!/^\/[a-z0-9-]+(?:\/[a-z0-9-]+)*$/.test(path)) throw new Error('Invalid path');
if (!Number.isSafeInteger(page) || page < 1) throw new Error('Invalid page');
const url = new URL(path, origin);
if (page > 1) url.searchParams.set('page', String(page));
return url.href;
}
// 公開日はDateか秒単位DateTime。日付しか分からなければ精度を保つ。
export function contentDate(value) {
if (/^\d{4}-\d{2}-\d{2}$/.test(value)) return dateOnly(value);
instant(value);
return value;
}
// 比較用範囲。保存・表示する公開時刻を作らない。
// この実習の日付は図書館のAsia/Seoulの暦に基づく。
function bounds(value) {
contentDate(value);
if (value.length > 10) return [instant(value), instant(value)];
const start = instant(value + 'T00:00:00+09:00');
return [start, start + 86400000 - 1];
}
export function isPublic(doc, now = Date.now()) {
if (!Number.isFinite(now)) throw new Error('Invalid comparison time');
if (!['draft', 'published', 'withdrawn'].includes(doc.status)) throw new Error('Invalid status');
if (doc.publishedAt !== null) contentDate(doc.publishedAt);
if (doc.deletedAt !== null) instant(doc.deletedAt);
const release = doc.publicFrom === null ? null : instant(doc.publicFrom);
return doc.status === 'published' && doc.deletedAt === null
&& release !== null && release <= now;
}
export function documentId(id, origin = 'https://example.com') {
if (typeof id !== 'string' || !/^[a-z0-9-]+$/.test(id)) throw new Error('Invalid id');
return new URL('/#document-' + id, origin).href;
}
export function describeDocument(doc, asOf, origin = 'https://example.com') {
const now = instant(asOf);
// 保存資料の誤りは下書きでも報告。検証後に公開を判定する。
const published = doc.publishedAt === null ? null : bounds(doc.publishedAt);
const modified = doc.contentModifiedAt === null ? null : bounds(doc.contentModifiedAt);
const from = doc.validFrom === null ? null : instant(doc.validFrom);
const until = doc.validUntil === null ? null : instant(doc.validUntil);
if (published && modified && modified[1] < published[0])
throw new Error('Modified before publication');
if (from !== null && until !== null && from >= until) throw new Error('Invalid interval');
if (doc.eventDate !== null) dateOnly(doc.eventDate);
const canonical = canonicalUrl(doc.path, 1, origin);
const id = documentId(doc.id, origin);
if (!isPublic(doc, now)) return null;
if (published && published[0] > now) throw new Error('Future publication');
if (modified && modified[0] > now) throw new Error('Future modification');
const applicability = from === null ? 'unknown'
: now < from ? 'scheduled' : until !== null && now >= until ? 'historical' : 'current';
return {
id, canonical, applicability,
...(published === null ? {} : { datePublished: doc.publishedAt }),
...(modified === null ? {} : { dateModified: doc.contentModifiedAt }),
};
}
export function formatContentDate(value) {
contentDate(value);
// 日付だけの値を他のタイムゾーンへ変換しない。
const day = value.length === 10 ? value : new Intl.DateTimeFormat('sv-SE', {
timeZone: 'Asia/Seoul', year: 'numeric', month: '2-digit', day: '2-digit',
}).format(new Date(value));
const [year, month, date] = day.split('-').map(Number);
return `${year}年${month}月${date}日`;
}
export const example = {
id: 'hours-2025', path: '/archive/opening-2025', revision: 2,
status: 'published', deletedAt: null,
publicFrom: '2024-12-23T09:00:00+09:00',
createdAt: '2024-12-20T09:00:00+09:00',
publishedAt: '2024-12-23T09:00:00+09:00',
contentModifiedAt: '2026-10-01T09:00:00+09:00',
eventDate: '2024-12-19',
validFrom: '2025-01-01T00:00:00+09:00',
validUntil: '2026-01-01T00:00:00+09:00',
};
describeDocumentは文書と比較時刻asOfを受け取る。ID・URL・日付・期間を先に検証し、下書きの不正な日付もエラーにする。有効な入力だけをisPublicへ渡し、非公開ならnullを返す。適用期間は開始を含み終了を含まない[開始, 終了)で比較し、開始不明ならunknownだ。返すid・canonical・datePublished・dateModified・applicabilityは次の実装編で本文とJSON-LDへ共に使う。
publicFromは正確な公開開始時刻でpublishedAtとは独立する。contentDateはDateとDateTimeの精度を保つ。boundsは日付だけの比較範囲を図書館の韓国の暦で計算するが、その境界を公開時刻として保存・表示しない。日付だけでは順序を確定できない同日修正は許容する。他地域の暦を使うサービスは比較方針も明示する必要がある。
applicabilityがhistoricalなら、過去記録の説明と当時の期間を画面に出す。dateModifiedが今日でも適用状態は過去になりうる。別計算にすることで誤字修正が旧時間表を現行案内へ変えるのを防ぐ。canonicalと日付計算を一か所で再利用すれば、タイトル・本文・JSON-LDに違うURLや日付が入る誤りも見つけやすい。
開始・終了境界と不正入力を検査する
同じフォルダーへ以下をidentity-time.test.mjsとして保存し、node --test identity-time.test.mjsを実行する。テストは公開可否、適用期間境界、タイムゾーン、不正日付・URLをそれぞれ確認する。
import test from 'node:test';
import assert from 'node:assert/strict';
import { dateOnly, instant, canonicalUrl, describeDocument, example, isPublic, formatContentDate } from './identity-time.mjs';
const at = '2026-10-23T12:00:00+09:00';
const historical = describeDocument(example, at);
test('過去記録は公開状態と自己参照URLを保つ', () => {
assert.equal(historical.applicability, 'historical');
assert.equal(historical.canonical, 'https://example.com/archive/opening-2025');
assert.equal(historical.datePublished, example.publishedAt);
});
test('適用開始を含み終了を除く', () => {
const d = { ...example, contentModifiedAt: null };
assert.equal(describeDocument(d, '2024-12-31T23:59:59+09:00').applicability, 'scheduled');
assert.equal(describeDocument(d, d.validFrom).applicability, 'current');
assert.equal(describeDocument(d, d.validUntil).applicability, 'historical');
});
test('下書き・撤回・削除・予約前は公開しない', () => {
for (const change of [{ status: 'draft' }, { status: 'withdrawn' }, { deletedAt: at },
{ publicFrom: '2027-01-01T00:00:00Z', contentModifiedAt: null }]) {
assert.equal(describeDocument({ ...example, ...change }, at), null);
}
});
test('適用開始不明を現在と推定しない', () => {
assert.equal(describeDocument({ ...example, validFrom: null }, at).applicability, 'unknown');
});
test('存在しない日付とタイムゾーンなしの時刻を拒否する', () => {
assert.throws(() => instant('2026-02-30T12:00:00+09:00'));
assert.throws(() => instant('2026-10-23T12:00:00'));
assert.throws(() => instant('2026-10-23T25:00:00Z'));
assert.equal(dateOnly('2026-10-23'), '2026-10-23');
});
test('同じ瞬間の別タイムゾーン表記は等しく、出来事の日付はそのまま', () => {
assert.equal(instant(at), instant('2026-10-23T03:00:00Z'));
assert.equal(dateOnly(example.eventDate), '2024-12-19');
});
test('逆転期間・未来修正・公開前修正を拒否する', () => {
assert.throws(() => describeDocument({ ...example, validUntil: example.validFrom }, at));
assert.throws(() => describeDocument({ ...example, contentModifiedAt: '2027-01-01T00:00:00Z' }, at));
assert.throws(() => describeDocument({ ...example, contentModifiedAt: '2020-01-01T00:00:00Z' }, at));
});
test('意味のあるページ番号と不正パスを区別する', () => {
assert.equal(canonicalUrl('/guides', 2), 'https://example.com/guides?page=2');
assert.throws(() => canonicalUrl('//elsewhere.example/a'));
assert.throws(() => canonicalUrl('/guides/../admin'));
assert.throws(() => canonicalUrl('/guides', 0));
});
console.log(JSON.stringify(historical, null, 2));
test('公開検査も存在しない暦日付を拒否する', () => {
assert.throws(() => isPublic({ ...example, publishedAt: '2026-02-30T09:00:00+09:00' }));
});
test('不正な下書き日付は公開除外より先にエラーとする', () => {
assert.throws(() => describeDocument({ ...example, status: 'draft', validFrom: 'bad-date' }, at));
});
test('日付のみと不明の公開日を保つ', () => {
const d = { ...example, publishedAt: '2024-12-23', contentModifiedAt: null };
assert.equal(describeDocument(d, at).datePublished, '2024-12-23');
assert.equal(formatContentDate('2024-12-23'), '2024年12月23日');
assert.equal(describeDocument({ ...d, publishedAt: null }, at).datePublished, undefined);
assert.equal(describeDocument({ ...d, publicFrom: null }, at), null);
});
test('同じ文書のURLを変えてもIDを維持する', () => {
const moved = describeDocument({ ...example, path: '/archive/old-hours' }, at);
assert.equal(moved.id, historical.id);
assert.notEqual(moved.canonical, historical.canonical);
});
出力のapplicabilityはhistorical、canonicalはhttps://example.com/archive/opening-2025だ。初回公開日と実質修正日は入力値を保つ。12件のテストが通る必要がある。終了時刻でcurrentになるなら比較が>=か確認する。2026-02-30が通るなら暦日付の検証を確認する。タイムゾーンなしの入力を許すと、サーバー地域によって同じ文字列を別の瞬間と読んでしまう。
返された公開可否と適用状態をページ出力へ使う。公開処理はDB公開版を切り替え、関連キャッシュを更新する。予約時刻に静的ページを自動変更するなら、その時刻の再生成作業もつなぐ。キャッシュに残る公開版はキャッシュ運用の更新経路で確認する。
本文から現行案内と過去記録を読めるようにする
過去記録の冒頭には「2025年1月1日から12月31日まで適用した時間表です。現在の開館時間は次の案内をご覧ください」のように期間と移動先を書く。機械用の終了値が2026-01-01T00:00:00+09:00なら、最後の適用日は2025年12月31日だ。開発者は境界値と可視文をともに照合する。
現行案内には適用開始日と例外を書き、公開・修正日は別ラベルにする。JSON-LDの日付だけ正確でも、過去文書の題名が「現在の案内」のままでは混乱を招く。タイトル・要約・表・出典・構造化データを同じ時点と条件に合わせる。
再公開・移行では元から確認できた日付と出典を保つ。初回公開日不明を新システムの保存時刻で埋めない。「原文公開日は未確認」のように読者の解釈に必要な範囲で説明し、確認できる修正日・適用日だけを使う。
日付の精度と変更の種類を保つ
旧告知に「2024年12月23日公開」だけが残るなら、publishedAtは2024-12-23で保存する。00:00:00+09:00を加えると未確認の公開時刻を作ってしまう。日付も不明ならnullにしてdatePublishedを省く。サーバー公開予約には別に確認したpublicFromを使う。Googleの公開日ガイドでは時刻は任意とされる。公開日のガイド
const migrated = {
publishedAt: "2024-12-23", // 原資料で確認した日付
publicFrom: "2026-10-24T09:00:00+09:00", // 移行版を公開する時刻
contentModifiedAt: null, // 実質修正の記録なし
}
// JSON-LD: "datePublished": "2024-12-23"
// 画面:公開日 2024年12月23日
誤字修正と開館時間変更では更新する日付が異なる。次表は変更内容に応じて公開日・修正日・lastmod・適用期間を決める例だ。主要規定の出典リンク変更も内容変更に含める。
現行の機関紹介と過去活動記録に別の時点を語らせる
架空の図書館は2025年に「開館準備委員会」が準備し、2026年から「図書館運営チーム」が運営するとする。ホームの題名がまだ準備委員会なら、現在の質問に旧役割を答える資料が残る。逆に2025年の議事録の署名を運営チームへ変えると当時の記録を改変する。現行紹介には現在の役割、議事録には当時の役割と期間を示す。
本文修正日は情報がいつ変わったかを説明し、役割の適用期間はどの時点の質問に答えられるかを説明する。2025年議事録へ2026年の後続リンクを追加して修正日が更新されても、会議の役割と日付は2025年に残る。「最近修正した資料」だけを選ぶ検索はこれらを混同しうる。
CMSでは機関IDを維持し、役割・適用開始・終了・根拠文書IDを別に保存する。現在のヘッダー・紹介・共有タイトルは現行役割から作り、過去ページは当時の文書の役割を使う。独自RAGにも文書ID・役割・適用期間を入れる。「現在」の質問は基準日に有効な文書、「2025年」の質問はその期間の文書を探すよう、質問と検索条件を結ぶ。