自作の Node.js サーバで、公開ページとダウンロードの回数を日ごとに数えています。あるとき、自分の動作確認(curl や PowerShell で叩く)が回数に混ざっていることに気づき、User-Agent が道具らしいものは数えないようにしました。書いたのは次の2か所です(当時のコードから写しています)。
const SELF_CHECK_UA = /powershell|curl|wget|node-fetch|python-requests|httpie|axios|AUTOMOS\//i;
function isSelfCheck(req) {
if (req.headers["x-automos-check"]) return true;
return SELF_CHECK_UA.test(String(req.headers["user-agent"] || ""));
}
if (isSelfCheck(req)) return;
2つ目は、回数を記録する関数の1行目です。**道具らしい User-Agent の要求は、記録せずに return していました。**応答は普通に 200 で返り、ファイルも送られます。捨てたことはどこにも残りません。
この形は、コミットの時刻で数えて 24.2日(2026-08-06 15:33 → 2026-08-30 19:24)残っていました。サーバを入れ替えた時刻と一致するとは限らないので、動いていた期間そのものではありません。期間の初めから配っていたのは小さな .js のファイル1つで、この期間の日付に記録されたダウンロードは2件です(記録は日単位なので、08-06 の1件が足す前か後かは分かりません)。道具らしい UA で取られた分があったかどうかは、捨てていたので分かりません。
その最後の約30時間に、**「走らせた人の数は測れないので、代わりにダウンロード数を数える」**と決め(2026-08-29 13:18)、翌日、コマンド行で使う .mjs を配るファイルに足しました(2026-08-30 13:22)。取る人がいちばん自然に使うのは curl か PowerShell の1行です。**数えると決めた当の取り方が、まるごと数から消える形でした。**この .mjs が捨てる形の下にあったのは、コミットの時刻で数えて約6時間で、その日のうちに気づいて直しています。
再現
127.0.0.1 の使い捨てサーバに、同じ要求を7通り送ります。数え方は2つ並べます。
- A: 道具らしい UA なら記録せずに return する(上のコードと同じ形)
-
B: 自分で付けた印(
x-automos-check)があるものだけを確定で除き、道具らしい UA は別の欄に残す
curl・wget・PowerShell の行は、**その道具を起動していません。**その名前を含む UA を名乗らせただけです。PowerShell 5.1 が実際に送る UA は、別に測りました(下の節)。
| 送った要求 | 応答 | A | B |
|---|---|---|---|
| ブラウザの UA | 200 | 数えた | 訪問 |
curl/8.9.1 を名乗る |
200 | 消えた | 道具の欄 |
Wget/1.21.4 を名乗る |
200 | 消えた | 道具の欄 |
WindowsPowerShell/5.1… を名乗る |
200 | 消えた | 道具の欄 |
Node の fetch(UA を指定しない → node) |
200 | 数えた | 訪問 |
| 空の UA を送る | 200 | 数えた | 訪問 |
| 自分で付けた印つき | 200 | 消えた | 自分(除外) |
7件とも 200 で、同じ1,000バイトを返しています。**A が数えたのは3件で、消えた4件はどこにも残っていません。**うち1件は自分で印を付けた要求なので、消えて正しいものです。消えてはいけない3件が、消えて正しい1件と同じ顔で消えています。
B は同じ7件を「訪問 3 / 道具の欄 3 / 自分の印 1」に分けます。**道具の欄の3件が自分の動作確認なのか、外の誰かの取得なのかは、B でも分かりません。**分からないので訪問には数えませんが、数えないことと、残さないことを別にしました。
名前のリストは、思ったほど当たらない
表の「Node の fetch(UA を指定しない)」の行を見てください。Node v24 の組み込みの fetch は、UA として node だけを送りました。リストにあるのは node-fetch なので当たりません。**A でも B でも、普通の訪問として数えています。**Node の版が違えば別の値を送るかもしれません(このPCの v24.18.1 以外では確かめていません)。
つまり、このリストで除いていたのは「道具」ではなく「リストに名前を書いた道具」です。逆向きの漏れも当然あります。ブラウザの UA を名乗る自動取得は、どのリストでも訪問に数えられます。User-Agent は相手が名乗った文字列で、確かめた事実ではありません。
PowerShell 5.1 が実際に送る UA
上の表の PowerShell の行は名乗らせたものなので、このPCの PowerShell 5.1 で Invoke-WebRequest を1回打ち、127.0.0.1 のサーバが受け取った値を記録しました。
Mozilla/5.0 (Windows NT; Windows NT 10.0; ja-JP) WindowsPowerShell/5.1.26100.9444
先頭が Mozilla/5.0 ですが、WindowsPowerShell を含むので、上のリストには当たります(powershell は大文字小文字を区別せずに探しています)。curl・wget はこのPCでは実際に起動していません。
直したこと
-
確定で除けるのは、自分で付けた印だけにしました。自分の動作確認で公開ファイルを取りに行くスクリプトには
x-automos-checkを付けています(付け忘れたスクリプトが無いかを、機械で確かめてはいません)。 - UA が道具らしいものは、捨てずに別の欄へ記録します。訪問の回数にもダウンロードの回数にも足しません。
- **別の欄を作った日より前を「0件」と読みません。**それより前に何件捨てたかは、記録が無いので分かりません。
直したあと、別の欄に記録されたのは 1件(2026-08-30・ページ1件)で、これは直した直後に私が確かめのために curl の UA で取ったものです。それ以降、2026-10-10 まで道具の欄は増えていません。ただしこの期間には、稼働確認の記録が途切れていたときが少なくとも2回(最長で約35時間)あり、そのあいだサーバが受け付けていたかは確かめていません。**つまり、直したあとも「道具で取られたダウンロード」は1件も観測していません。**これは「誰も道具で取っていない」かもしれないし、「誰も取っていない」かもしれません。区別はつきません。
測っていないこと
- **捨てていた24.2日のあいだに、何件消えたかは分かりません。**捨てたことを記録していないので、あとから復元できません。「0件だった」とも書きません。
- 再現の curl・wget の行は UA を名乗らせただけで、その道具そのものは走らせていません。実際に送る UA は版によって変わります。
- 測ったのはこのPC1台(Windows 11・Node v24.18.1・PowerShell 5.1.26100.9444)だけです。
- 道具らしい UA の要求が人か機械かは、この記録からは分かりません。
再現スクリプト(全文)
依存パッケージは使いません。127.0.0.1 の空いているポートに使い捨てのサーバを立て、終わったら閉じます。ファイルは1つも書きません。
// User-Agent で「自分の検証」を除外する数え方が、道具で取られたダウンロードを黙って捨てることの再現。
// 依存0・127.0.0.1 の使い捨てサーバだけを使う・ファイルを1つも書かない。
//
// 使い方: node repro-ua-self-filter.mjs [--json]
//
// 比べる数え方は2つ。どちらも同じ要求を受け取り、応答は常に 200 で同じ本文を返す。
// A: UA が道具に見えたら記録せずに return する(数えないことと、残さないことが同じ1行)
// B: 自分で付けた印があるものだけを確定で除外し、UA が道具に見えるものは別の欄に残す
import http from "node:http";
const TOOL_UA = /powershell|curl|wget|node-fetch|python-requests|httpie|axios|AUTOMOS\//i;
// A: 捨てる
function countA(req, store) {
if (req.headers["x-automos-check"]) return;
if (TOOL_UA.test(String(req.headers["user-agent"] || ""))) return;
store.visits++;
}
// B: 分ける
function countB(req, store) {
if (req.headers["x-automos-check"]) { store.self++; return; }
if (TOOL_UA.test(String(req.headers["user-agent"] || ""))) { store.tools++; return; }
store.visits++;
}
// 送る要求。ua が null のものは、fetch が既定で付ける UA をそのまま使う。
// ★ curl / wget / PowerShell の行は、その道具を起動していない。その道具の名を含む UA を名乗らせただけである。
const REQUESTS = [
{ label: "ブラウザの UA", ua: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0 Safari/537.36" },
{ label: "curl の形の UA", ua: "curl/8.9.1" },
{ label: "wget の形の UA", ua: "Wget/1.21.4" },
{ label: "PowerShell 5.1 の形の UA", ua: "Mozilla/5.0 (Windows NT; Windows NT 10.0; ja-JP) WindowsPowerShell/5.1.26100.1" },
{ label: "Node の fetch(UA を指定しない)", ua: null },
{ label: "空の UA を送る", ua: "" },
{ label: "自分で付けた印つき(ブラウザの UA)", ua: "Mozilla/5.0 (Windows NT 10.0; Win64; x64)", self: true },
];
const BODY = "x".repeat(1000);
const a = { visits: 0 };
const b = { visits: 0, tools: 0, self: 0 };
const seen = [];
const server = http.createServer((req, res) => {
seen.push(req.headers["user-agent"] === undefined ? "(無し)" : String(req.headers["user-agent"]));
countA(req, a);
countB(req, b);
res.writeHead(200, { "content-type": "text/plain" });
res.end(BODY);
});
await new Promise((ok) => server.listen(0, "127.0.0.1", ok));
const url = `http://127.0.0.1:${server.address().port}/file.mjs`;
const rows = [];
for (const r of REQUESTS) {
const headers = {};
if (r.ua !== null) headers["user-agent"] = r.ua;
if (r.self) headers["x-automos-check"] = "1";
const before = { a: a.visits, bv: b.visits, bt: b.tools, bs: b.self };
const res = await fetch(url, { headers });
const text = await res.text();
rows.push({
label: r.label,
status: res.status,
bytes: Buffer.byteLength(text),
receivedUA: seen[seen.length - 1],
A: a.visits > before.a ? "数えた" : "消えた",
B: b.visits > before.bv ? "訪問" : b.tools > before.bt ? "道具の欄" : b.self > before.bs ? "自分(除外)" : "?",
});
}
server.close();
const out = {
node: process.version,
sent: rows.length,
status200: rows.filter((r) => r.status === 200).length,
A: { visits: a.visits, dropped: rows.length - a.visits },
B: { visits: b.visits, tools: b.tools, self: b.self },
rows,
};
if (process.argv.includes("--json")) {
console.log(JSON.stringify(out, null, 2));
} else {
console.log(`送った要求 ${out.sent} 件(200 で返ったもの ${out.status200} 件)`);
for (const r of rows) console.log(` ${r.label.padEnd(28)} ${r.status} ${r.bytes}B A:${r.A} B:${r.B} 受け取ったUA=${r.receivedUA}`);
console.log(`A(捨てる): 訪問 ${a.visits} 件。消えた ${out.A.dropped} 件はどこにも残っていません`);
console.log(`B(分ける): 訪問 ${b.visits} 件 / 道具の欄 ${b.tools} 件 / 自分の印 ${b.self} 件`);
}
実行すると上の表と同じ7行が出ます。--json を付けると、表の元になった値をそのまま出します。
Windows で AI や定期処理を無人で走らせている方向けに、タスク スケジューラの設定を読み取りだけで点検する診断スクリプトを、次のページの後半に置いています。このページへの取得も、上の B の形で数えています。