日次ランキングを「決定論スナップショット」にする — 同じデータなら必ず同じ並び

ランキングを毎日更新する静的サイトを無人で回していると、地味だが本質的な設計判断がひとつある。「順位という数字を、どこに、どうやって持つか」だ。作品マスタに rank フィールドを持たせて毎回書き換える素朴なやり方は、運用を続けるほど破綻する。実際に運用しているアニメランキングサイト anime-trend では、順位を日付つきの決定論スナップショットとして切り出すことでこれを避けている。同じ設計を JavaScript で組み立ててみる。

素朴な rank フィールドが壊れる理由

最初はこう作りたくなる。作品ごとの JSON に順位を直接書く。

1
2
3
4
5
6
7
8
// data/anime/mushoku-tensei-iii.json
{
"slug": "mushoku-tensei-iii",
"title": "無職転生III",
"malScore": 8.6,
"airingStatus": "airing",
"rank": 1 // ← これが問題
}

この設計には2つの穴がある。

  1. 順位が「今日の事実」ではなく「最後に書いた人の気分」になる。 手で rank を入れ替えられてしまうので、なぜその順位なのかが後から検証できない。
  2. 過去のランキングが残らない。 rank を上書きするたびに昨日の並びが消える。「先週この作品は何位だったか」に答えられない。

無人運用では 1 がとくに効く。人手のレビューが無い分、「順位を直接いじれる」こと自体が、いつのまにか捏造を招く導線になる。

順位はデータではなく「導出結果」にする

方針を変える。作品マスタには日によって変わらない事実だけを書き、順位はそこから毎回計算する。

  • malScore … 外部の評価スコア(実データ。取れなければ null
  • airingStatusairing(放送中)/ theatrical(劇場公開中)/ library(恒常人気)

このうち順位付けに使うのは「群(airing → theatrical → library)」と「群の中でのスコア降順」だけにする。乱数も実行時刻も使わない。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const GROUP_ORDER = { airing: 0, theatrical: 1, library: 2 };

function rankAnime(list) {
return [...list]
.sort((a, b) => {
// 1) 群の順
const g = GROUP_ORDER[a.airingStatus] - GROUP_ORDER[b.airingStatus];
if (g !== 0) return g;
// 2) 群内はスコア降順。スコア無しは群の末尾へ
const sa = a.malScore ?? -Infinity;
const sb = b.malScore ?? -Infinity;
if (sa !== sb) return sb - sa;
// 3) 完全な同点は slug の辞書順で安定化(並びをブレさせない)
return a.slug < b.slug ? -1 : a.slug > b.slug ? 1 : 0;
})
.map((item, i) => ({ ...item, rank: i + 1 }));
}

ポイントは最後のタイブレークだ。スコアが同じ(あるいは両方 null)の作品が並んだとき、比較関数が 0 を返すと、Array.prototype.sort の結果は入力順に依存してしまう。仕様上は安定ソートが保証されるようになったとはいえ、入力配列の順番が変われば並びも変わる。slug の辞書順というデータに内在する順序で必ず決着させておくと、入力がどんな順で来ても出力が一意になる。これが「同じデータなら必ず同じ並び」を担保する。

順位を日付つきスナップショットとして残す

計算した順位は、その日の成果物としてファイルに落とす。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const fs = require("fs");

function writeSnapshot(date, ranked) {
const snapshot = {
date, // "2026-07-26"
method: "群(放送中→劇場→恒常)→ 群内はスコア降順 → スコア無しは末尾(slug順で安定化)",
sources: [{ name: "MyAnimeList", url: "https://myanimelist.net/anime/season" }],
entries: ranked.map(({ slug, rank, title, malScore }) => ({
slug, rank, title, malScore,
})),
};
const path = `data/rankings/${date}.json`;
if (fs.existsSync(path)) return { path, skipped: true }; // 同日は上書きしない(冪等)
fs.writeFileSync(path, JSON.stringify(snapshot, null, 2));
return { path, skipped: false };
}

こうすると3つの性質が同時に手に入る。

  • 冪等性: 同じ日に2回実行しても、既存スナップショットがあれば書き換えない。1日2回の自動更新でも安全に回せる。
  • 監査可能性: スナップショットに method(順位の付け方)と sources(参照元)を必ず埋めておくと、「なぜこの順位か」がファイル自身に残る。人手で順位を差し込む余地が無い。
  • 履歴: data/rankings/2026-07-25.json 2026-07-26.json … と日付で積み上がるので、過去の並びをそのまま参照できる。

ビルド側は最新のスナップショットを「本日のランキング」として読むだけでよい。

1
2
3
4
5
6
const snapshots = fs.readdirSync("data/rankings")
.filter((f) => f.endsWith(".json"))
.sort(); // 日付文字列なので辞書順=時系列順
const latest = JSON.parse(
fs.readFileSync(`data/rankings/${snapshots.at(-1)}`, "utf8"),
);

「順位を入れ替えたい」と思ったら、それはデータのバグ

この設計にすると、運用中に「この作品はもっと上のはず」と感じる場面が出てくる。そのとき手が届くのは rank ではなく airingStatusmalScore だけだ。順位を直接いじれないので、違和感の原因は必ず「放送状況かスコアのどちらかが実データと合っていない」ことに還元される。直すのは順位ではなくデータのほうになる。

無人運用で捏造を防ぐいちばん確実な方法は、「捏造できる場所を設計から消す」ことだった。順位を導出結果に変え、日付つきで残す。たったこれだけで、ランキングは「誰かの気分」から「その日のデータの写像」に変わる。

実際に動いているサイトは anime-trend で、毎日この方式でその日のランキングを生成している。


日次ランキングを「決定論スナップショット」にする — 同じデータなら必ず同じ並び
https://blog.hashito.biz/2026/07/26/deterministic-daily-ranking-snapshot-design/
著者
hashito
作成日
2026年7月26日
著作権