生成物のタグページが消えて翌日戻る|期限切れpruneの差分の読み方

漫画セール情報 は、data/articles.json から静的HTMLを生成するサイトである。2026年9月24日の更新コミットには、記事の追加と一緒に次の削除が入っていた。

1
2
3
4
5
D	public/tags/ドラマ化.html
D public/tags/ハニー.html
D public/tags/全巻100円.html
D public/tags/神アプリ.html
...(計9件)

翌25日のビルドでは、public/tags/全巻100円.html が新規ファイルとしてまた現れた。

1
?? public/tags/全巻100円.html

生成物の差分だけを見ると、「前日にページが壊れて消え、今日直った」ようにも読める。実際にはどちらの日もビルドは仕様どおりに動いている。この記事では、その仕組みと、消えたページが退行なのか仕様なのかを data から判定する方法を書く。

消える理由: 一覧は「終わっていないセール」だけで作る

このサイトの build.js は、セールの終了日(metrics.deadline)を過ぎた記事を一覧系のページから外す。記事ページそのものは残し、直接URLやサイト内検索では引ける。

1
2
3
4
5
6
7
8
function isEnded(a) {
if (a && a.ended === true) return true;
const dl = a && a.metrics && a.metrics.deadline;
return !!dl && daysUntil(dl) < 0;
}
function listedArticles() {
return articles.filter((a) => !isEnded(a));
}

タグページは listedArticles() から集計する。つまりあるタグを持つ記事が全部終了すると、そのタグのページは生成対象から外れる。

もう1つの部品が掃除(prune)である。ビルドが書いたファイルを WRITTEN という集合に記録し、最後に public/ を走査して、今回書かなかった .html を削除する。data から消えたタグのページが、sitemap にも内部リンクにも無いまま本番で配信され続けるのを防ぐためのものだ。

この2つが組み合わさると、次の動きになる。

日付 状態 タグ「全巻100円」のページ
9/23 『ハニー』全巻100円セールが期限当日 生成される
9/24 そのセールが期限切れ。他に同じタグの進行中セールが無い 生成されない → prune で削除
9/25 同じタグを持つ別の全巻100円セール記事を追加 同じファイル名で再生成

消えたのは退行ではなく、そのタグを持つ進行中のセールが0本になったからである。

実際に試す: 最小の再現

Node.js 18 以降で動く(手元では v24.11.1 で確認した)。build-tags.js として保存する。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
"use strict";
const fs = require("fs");
const path = require("path");

/** 期限を過ぎた記事は一覧から外す(記事ページ自体は残す) */
function isEnded(a, today) {
return !!a.deadline && a.deadline < today;
}

/** 一覧に載る記事のタグだけからタグページを作り、今回書かなかった .html を消す */
function build(articles, outDir, today) {
const written = new Set();
const write = (rel, body) => {
const p = path.join(outDir, rel);
fs.mkdirSync(path.dirname(p), { recursive: true });
fs.writeFileSync(p, body);
written.add(rel);
};

const tags = new Map();
for (const a of articles) {
write(`articles/${a.slug}.html`, a.title); // 記事ページは終了後も出す
if (isEnded(a, today)) continue; // タグ一覧には載せない
for (const t of a.tags) {
if (!tags.has(t)) tags.set(t, []);
tags.get(t).push(a.slug);
}
}
for (const [t, list] of tags) write(`tags/${t}.html`, list.join("\n"));

// prune: 今回書いていない .html を消す
const removed = [];
const walk = (dir) => {
for (const name of fs.readdirSync(dir)) {
const full = path.join(dir, name);
if (fs.statSync(full).isDirectory()) { walk(full); continue; }
const rel = path.relative(outDir, full).split(path.sep).join("/");
if (name.endsWith(".html") && !written.has(rel)) {
fs.unlinkSync(full);
removed.push(rel);
}
}
};
walk(outDir);
return { tags: [...tags.keys()].sort(), removed };
}

module.exports = { build, isEnded };

3日分のビルドを流す demo.js。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
"use strict";
const fs = require("fs");
const os = require("os");
const path = require("path");
const { build } = require("./build-tags");

const out = fs.mkdtempSync(path.join(os.tmpdir(), "tags-"));
const articles = [
{ slug: "honey-100yen", title: "ハニー 全8巻 各100円", deadline: "2026-09-23", tags: ["集英社", "全巻100円"] },
{ slug: "kinnikuman-100yen", title: "キン肉マン 各100円", deadline: "2026-09-30", tags: ["集英社"] },
];

console.log("9/23:", build(articles, out, "2026-09-23"));
console.log("9/24:", build(articles, out, "2026-09-24"));

articles.push({ slug: "exeros-100yen", title: "エグゼロス 全12巻 各100円", deadline: "2026-09-30", tags: ["集英社", "全巻100円"] });
console.log("9/25:", build(articles, out, "2026-09-25"));

実行結果。

1
2
3
4
$ node demo.js
9/23: { tags: [ '全巻100円', '集英社' ], removed: [] }
9/24: { tags: [ '集英社' ], removed: [ 'tags/全巻100円.html' ] }
9/25: { tags: [ '全巻100円', '集英社' ], removed: [] }

9/24 に tags/全巻100円.html が消え、9/25 に同じ名前で戻る。本番のコミット履歴で見えた動きがそのまま再現する。

消えたページが退行かどうかを data で判定する

生成物の D を見たときに知りたいのは、「そのタグを持つ記事で、まだ一覧に載るべきものがあるか」である。これは生成物ではなく data を見れば決まる。why-gone.js として保存する。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
"use strict";
// 使い方: node why-gone.js <articles.json> <タグ名> [基準日 YYYY-MM-DD]
const fs = require("fs");

const [file, tag, today = new Date(Date.now() + 9 * 3600e3).toISOString().slice(0, 10)] =
process.argv.slice(2);
const articles = JSON.parse(fs.readFileSync(file, "utf8"));

const holders = articles.filter((a) => (a.tags || []).includes(tag));
const rows = holders.map((a) => {
const dl = a.deadline || (a.metrics && a.metrics.deadline) || null;
const ended = a.ended === true || (!!dl && dl < today);
return { slug: a.slug, deadline: dl, ended };
});
const live = rows.filter((r) => !r.ended);

console.log(JSON.stringify({ tag, today, holders: rows.length, live: live.length }, null, 2));
if (!rows.length) {
console.log("-> このタグを持つ記事が1本も無い。data から消えた(または書き換えられた)");
process.exit(2);
}
if (!live.length) {
console.log("-> 持つ記事は全部終了済み。タグページが消えるのは仕様どおり");
process.exit(0);
}
console.log("-> 一覧に載る記事がある。ページが消えているならビルドの退行を疑う");
process.exit(1);

漫画セール情報の実データ(2026年9月25日時点)に当てると、3つの結果に分かれる。

1
2
3
4
5
6
7
$ node why-gone.js data/articles.json 神アプリ 2026-09-25
{ "tag": "神アプリ", "today": "2026-09-25", "holders": 1, "live": 0 }
-> 持つ記事は全部終了済み。タグページが消えるのは仕様どおり

$ node why-gone.js data/articles.json 全巻100円 2026-09-25
{ "tag": "全巻100円", "today": "2026-09-25", "holders": 4, "live": 1 }
-> 一覧に載る記事がある。ページが消えているならビルドの退行を疑う

(JSON は実際には複数行で出る。ここでは1行に詰めた)

終了コードは、仕様どおりの削除が 0、退行の疑いが 1、data 側からタグが消えたものが 2 である。CI やレビューで git diff --name-status の D public/tags/*.html を拾い、1件ずつこのスクリプトに通せば、人が目で判断する必要がなくなる。

退行ガードとしては、次の3点をテストに固定しておけば足りる。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
"use strict";
const { test } = require("node:test");
const assert = require("node:assert/strict");
const fs = require("fs");
const os = require("os");
const path = require("path");
const { build } = require("./build-tags");

const tmp = () => fs.mkdtempSync(path.join(os.tmpdir(), "tags-"));

test("最後の1本が終わった翌日にタグページが消える", () => {
const out = tmp();
const a = [{ slug: "x", title: "x", deadline: "2026-09-23", tags: ["T"] }];
build(a, out, "2026-09-23");
assert.ok(fs.existsSync(path.join(out, "tags/T.html")));
const r = build(a, out, "2026-09-24");
assert.deepEqual(r.removed, ["tags/T.html"]);
});

test("終了した記事のページ自体は残る", () => {
const out = tmp();
const a = [{ slug: "x", title: "x", deadline: "2026-09-23", tags: ["T"] }];
build(a, out, "2026-09-24");
assert.ok(fs.existsSync(path.join(out, "articles/x.html")));
});

test("同じタグの新しい記事が入れば同じ名前で戻る", () => {
const out = tmp();
const a = [{ slug: "x", title: "x", deadline: "2026-09-23", tags: ["T"] }];
build(a, out, "2026-09-24");
a.push({ slug: "y", title: "y", deadline: "2026-09-30", tags: ["T"] });
const r = build(a, out, "2026-09-25");
assert.ok(r.tags.includes("T"));
assert.ok(fs.existsSync(path.join(out, "tags/T.html")));
});
1
2
3
4
5
6
$ node --test --test-reporter=tap build-tags.test.js
ok 1 - 最後の1本が終わった翌日にタグページが消える
ok 2 - 終了した記事のページ自体は残る
ok 3 - 同じタグの新しい記事が入れば同じ名前で戻る
# pass 3
# fail 0

(TAP の出力から結果の行だけを抜き出した)

この設計で気をつけること

  • 消える日は「期限の翌日」。期限当日はまだ一覧に載る。日付の比較を <= と < で取り違えると、期限当日にページが消える
  • prune はビルドが最後まで通った回だけ呼ぶ。途中で落ちた回に呼ぶと、書き終わっていない正当なページまで消える。漫画セール情報の build.js も、この前提をコメントで明記している
  • タグページのURLは一時的に404になる。検索エンジンに登録済みのタグURLは、一度404を返してから同じURLで戻ることがある。タグを長く残したいなら、終了記事だけのタグを「終了したセール」として別に出すなどの設計が要る
  • 生成物の差分だけで判断しない。D が付いたファイルが退行かどうかは、生成物ではなく入力(data)と規則から決まる

生成物をリポジトリにコミットしている静的サイトでは、ビルドのたびにファイルが増えたり減ったりする。その増減を1件ずつ「data のどの変化で起きたか」に戻せるようにしておくと、日次更新のレビューが速くなる。


生成物のタグページが消えて翌日戻る|期限切れpruneの差分の読み方
https://blog.hashito.biz/2026/09/25/generated-tag-page-deleted-then-returns-prune-by-expiry/
著者
hashito
作成日
2026年9月25日
著作権