自分のサーバのアクセス記録(日ごとの JSON)を足し合わせて、ダッシュボードに「閲覧数」を出しています。2026-08-30 に、その日ごとの記録へ _tools という鍵を1つ足しました。値は数ではなく、ファイル名ごとの件数を持つオブジェクトです。
翌日、足す前の集計関数(1つ前のコミットのもの)に、足したあとの記録を通してみると、集計はこう返りました。
| 集計関数の版 | 閲覧数(pageViews) |
型 | 合計(total) |
|---|---|---|---|
鍵を足す前の版(除外は _from だけ) |
62[object Object]1111 |
string | 62[object Object]111131 |
鍵を足した版(除外に _tools を足した) |
66 | number | 97 |
翌日直した版(_ で始まる鍵は数えない) |
66 | number | 97 |
**例外は出ません。**数字の場所に、この文字列がそのまま入ります。
ただし、この文字列が実際の画面に出た経路は見つかっていません。_tools は、除外の一覧に _tools を足したのと同じコミットで初めて記録に入りました。その時点で記録を読んでいたコードは、私が読んだかぎり、どれもこの集計関数を通すか、読む鍵を名前で絞っていました。ただし画面の表示そのものは保存していないので、出なかったことを表示で確かめたわけではありません。
上の1行目は、「一覧を伸ばし忘れていたら」を、実際の記録と実際の1つ前の関数で確かめた数です。その日に一覧を伸ばしたのは、鍵を足すときに「足し込むと数がオブジェクトと混ざって壊れる」と気づいて、除外の行の上にそうコメントを書いたからです。止めたのは人が覚えていたことで、仕組みが止めたわけではありません。
1. 何が起きていたか
1つ前の版の集計は、こういう形でした(実ファイルから、関係する行だけを抜き出しています)。
for (const [k, n] of Object.entries(log[d] || {})) {
if (k === "_from") continue;
if (CRAWLER_FILES.has(k)) crawlerFetches += n; else pageViews += n;
}
日ごとの記録には、ページ名と件数のほかに、流入元の内訳を持つ _from という鍵が入っています。これはページではないので、名前で1つだけ除いていました。
そこへ _tools(値はオブジェクト)が入ると、pageViews += n の右辺がオブジェクトになります。JavaScript の + は、片方が数でもう片方が valueOf も toString も自分で持たない普通のオブジェクト({ ... })なら、オブジェクトを文字列 "[object Object]" にして文字列としてつなぎます(配列や valueOf を持つオブジェクトでは結果が変わります)。そこから先は pageViews が文字列なので、後ろの日の件数も数として足されず、件数の数字がそのまま後ろにつながっていきます(今回はどの件数も 1 だったので、1文字ずつに見えます)。62 は _tools より前に足し終えていた分、末尾の 1111 はその後ろに足した4件です。合計の total は、それにクローラ向けの件数 31 を「つないだ」ものになりました。
固定した数(動かないコミット 47c82bf^ / 47c82bf / 5c330d3 の集計関数と、5c330d3 の記録から出したもの):
- 記録は 24日分(2026-08-05〜2026-08-31)
-
_で始まる鍵は2種類。_fromが22日、_toolsが1日。値はどちらもオブジェクト - 鍵を足したコミットは 2026-08-30 19:24、集計を規則に直したコミットは 2026-08-31 19:20
2. 再現スクリプト
依存はありません。ファイルを読み書きせず、通信もしません。小さな作り物の記録で、3通りの集計を並べます。
// 日別の記録に「数ではない鍵」を1つ足したとき、集計の書き方ごとに何が返るか。
// 依存0・ファイルを読み書きしない・通信しない。
const log = {
"2026-08-29": { "a.html": 3, "_from": { "直接": 3 } },
"2026-08-30": { "a.html": 2, "_from": { "直接": 2 }, "_tools": { "b.html": 1 }, "b.html": 1 },
};
// 1. 除外を名前で列挙する(_from だけを知っている)
function denylist(log) {
let sum = 0;
for (const day of Object.values(log))
for (const [k, n] of Object.entries(day)) {
if (k === "_from") continue;
sum += n;
}
return sum;
}
// 2. 1 に `|| 0` を足す
function orZero(log) {
let sum = 0;
for (const day of Object.values(log))
for (const [k, n] of Object.entries(day)) {
if (k === "_from") continue;
sum += n || 0;
}
return sum;
}
// 3. 規則で外し(_ で始まる鍵はページではない)、数でないものは名指しして残す
function rule(log) {
let sum = 0;
const unreadable = [];
for (const [d, day] of Object.entries(log))
for (const [k, n] of Object.entries(day)) {
if (k.startsWith("_")) continue;
if (!Number.isFinite(n)) { unreadable.push(`${d} ${k}`); continue; }
sum += n;
}
return { sum, unreadable };
}
// 3 に、数でない記録を1件混ぜたもの
const broken = structuredClone(log);
broken["2026-08-30"]["c.html"] = { oops: 1 };
const show = (v) => ({ value: v, type: typeof v });
const out = {
node: process.version,
denylist: show(denylist(log)),
orZero: show(orZero(log)),
rule: rule(log),
ruleWithBroken: rule(broken),
orZeroOfObject: show({ b: 1 } || 0),
numberOfObject: String(Number({ b: 1 })), // NaN は JSON で null になるので文字で持つ
isFiniteOfObject: Number.isFinite({ b: 1 }),
};
if (process.argv.includes("--json")) console.log(JSON.stringify(out, null, 2));
else for (const [k, v] of Object.entries(out)) console.log(k.padEnd(16), JSON.stringify(v));
出力(Node v24.18.1):
node "v24.18.1"
denylist {"value":"5[object Object]1","type":"string"}
orZero {"value":"5[object Object]1","type":"string"}
rule {"sum":6,"unreadable":[]}
ruleWithBroken {"sum":6,"unreadable":["2026-08-30 c.html"]}
orZeroOfObject {"value":{"b":1},"type":"object"}
numberOfObject "NaN"
isFiniteOfObject false
読み方:
-
denylist(名前で1つだけ除く)は、記録に知らない鍵が1つ入るだけで文字列を返します -
orZeroは、n || 0を足しても同じ文字列を返します。||が 0 に置き換えるのは偽の値だけで、オブジェクトは真なので、そのまま通ります(orZeroOfObjectの行) -
rule(_で始まる鍵はページではない、と規則で除く)は 6 を返します。さらに、ページ名の鍵に数でない値が入った記録(ruleWithBroken)では、その1件を足さずにunreadableに名指しして残します
3. 直したこと
翌日(2026-08-31)、集計を次の形にしました。
-
除外を名前の一覧ではなく規則にした。
_で始まる鍵はページではない、と決めました。一覧は、次に鍵を1つ足した日にまた伸ばし忘れられます -
足せなかったものを黙って捨てない。
Number.isFiniteで数でない値を見つけたら、足さずに日付と鍵をunreadableに残し、合計そのものは出します。黙って捨てると、合計が壊れる代わりに静かに小さくなります。壊れた数と小さすぎる数は、画面ではどちらも同じ顔で出ます(これは閲覧数の合計を出す関数の話です。同じ版でも、1ページの件数だけを数える関数は、数でない値を黙って飛ばしています) - **合計を出す場所で
|| 0を守りとして使わない。**上のとおり、オブジェクトは通ります
4. 測っていないこと
- 上の表の1行目は、実際に画面に出た数ではありません。動かないコミット(集計関数は鍵を足す前の版、記録は翌日の版)を組み合わせて計算した数です
-
_tools以外の値の形(配列、文字列の数字、null)が混ざった場合を、実際の記録では試していません。再現スクリプトで試したのは、オブジェクトを1つ混ぜた形だけです - この集計の数字(閲覧数)が人か機械かは、そもそも分けられていません(ユーザーエージェントを保存していません)。直したのは足し算の形だけです
-
_で始まる鍵を規則で外したので、_で始まる名前のページを置けば、そのページは黙って数から外れます。記録の中にそういうページはまだありませんが、第3節で書いた「黙って捨てる」と同じ形です - 動かしたのは Node v24.18.1 の1台だけです
5. やめたこと
- 混ざってはいけない鍵を、名前の一覧で1つずつ除くこと
-
|| 0を「数でないものを 0 にする」守りとして書くこと - 数でない値を、件数を残さずに捨てること
私の無料ページに、Windows で AI を無人で走らせている人向けに、タスクの設定を読み取りだけで確かめる道具を置いています(ページの説明は道具より古く、道具のほうが多くを見ます)。 https://h26005.tailf680df.ts.net:10000/free.html