commit 7f4920e070fc1d6cff050ce128363a5cf7d54bcf Author: PixelstoMotors Date: Sun Nov 1 10:10:28 2026 +0900 「AIで読む」経由で AI が全文ページを読みに来た数を、押した AI ごとに数える - 質問文の全文ページの住所に ?via=(chatgpt・claude・perplexity・gemini・copy)を付ける - worker がその訪問を、名乗りが一覧にない読み手(Gemini など)も「その他」として残す - /api/cr-stats に「AIで読む経由」の表(押した AI ごと・週ごと) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LevEXUdawHCPzCgXi4FEM9 diff --git a/worker/index.js b/worker/index.js index 79d077a..5d47d18 100644 --- a/worker/index.js +++ b/worker/index.js @@ -47,9 +47,11 @@ export default { res.headers.set('Link', `; rel="canonical"`); } // 検索エンジン・AI のクローラーが読みに来たか(記録の失敗でページを止めない) + // 記事の「AIで読む」から来た全文ページ(?via=chatgpt など)は、名乗りが一覧にない読み手(Gemini など)も「その他」として残す(2026-11-01) try { - const bot = crawlerOf(request, url); - if (bot) ctx.waitUntil(crRecord(request, env, url, bot, res.status).catch(() => {})); + const via = /^\/record\/[^/]+\.md$/.test(url.pathname) && AI_VIA.has(url.searchParams.get('via') || '') ? url.searchParams.get('via') : ''; + const bot = crawlerOf(request, url) || (via ? 'その他' : null); + if (bot) ctx.waitUntil(crRecord(request, env, url, bot, res.status, via).catch(() => {})); } catch { /* 数え損ねてもページは返す */ } return res; }, @@ -300,6 +302,15 @@ async function hkStats(request, env, url) { const byDay = new Map(), byWeek = new Map(), byItem = new Map(), byW = new Map(); for (const r of rows) { add(byDay, r.day, r.e); add(byWeek, weekOf(r.day), r.e); add(byItem, r.id, r.e); add(byW, r.w, r.e); } const KIND_JA = { n: '機械の印なし', b: '機械', o: '印なし(10/28より前)' }; + // 「AIで読む」経由(?via=)。押した AI ごとに、実際に読みに来たのはだれか(2026-11-01) + const viaMap = new Map(); + for (const r of rows) { + if (!r.v) continue; + const e = viaMap.get(r.v) || { via: r.v, visits: 0, records: new Set(), bots: new Map(), last: '' }; + e.visits++; e.records.add(r.p); count(e.bots, r.b); if (r.day > e.last) e.last = r.day; viaMap.set(r.v, e); + } + const viaWeeks = new Map(); + for (const r of rows) { if (!r.v) continue; const w = viaWeeks.get(r.week) || new Map(); count(w, r.v); viaWeeks.set(r.week, w); } const data = { total: rows.length, who: who || null, @@ -484,6 +495,15 @@ async function evStats(request, env, url) { } } const top = (map, n, key) => [...map].sort((a, b) => b[1] - a[1]).slice(0, n).map(([k, v]) => ({ [key]: k, count: v })); + // 「AIで読む」経由(?via=)。押した AI ごとに、実際に読みに来たのはだれか(2026-11-01) + const viaMap = new Map(); + for (const r of rows) { + if (!r.v) continue; + const e = viaMap.get(r.v) || { via: r.v, visits: 0, records: new Set(), bots: new Map(), last: '' }; + e.visits++; e.records.add(r.p); count(e.bots, r.b); if (r.day > e.last) e.last = r.day; viaMap.set(r.v, e); + } + const viaWeeks = new Map(); + for (const r of rows) { if (!r.v) continue; const w = viaWeeks.get(r.week) || new Map(); count(w, r.v); viaWeeks.set(r.week, w); } const data = { total: rows.length, counted: counted.length, truncated, since: since || null, week: week || null, who: who || null, byWeek, @@ -538,6 +558,8 @@ const CRAWLERS = [ ['cohere-ai', 'Cohere'], ['Diffbot', 'Diffbot'], ['YouBot', 'You.com'], ['YandexBot', 'Yandex(検索)'], ['Baiduspider', 'Baidu(検索)'], ['Yeti', 'Naver(検索)'], ]; // 数えるのはページ・テキストだけ(画像・CSS・JS は wrangler.jsonc の run_worker_first で外している) +// 「AIで読む」の質問文で、全文ページの住所に付けている印(src/pages/record/[slug].astro の AI_TARGETS) +const AI_VIA = new Set(['chatgpt', 'claude', 'perplexity', 'gemini', 'copy']); const CR_PAGE = /(\/|\/[^/.]*|\.html|\.md|\.txt|\.xml|\.json)$/i; function crawlerOf(request, url) { if (request.method !== 'GET' && request.method !== 'HEAD') return null; @@ -547,7 +569,7 @@ function crawlerOf(request, url) { for (const [name] of CRAWLERS) if (ua.includes(name.toLowerCase())) return name; return null; } -async function crRecord(request, env, url, bot, status) { +async function crRecord(request, env, url, bot, status, via = '') { let ok = '?'; try { const cf = request.cf || {}; @@ -556,7 +578,7 @@ async function crRecord(request, env, url, bot, status) { } catch { /* 判定がなければ ? のまま */ } const at = new Date(); const day = new Date(at.getTime() + 9 * 3600e3).toISOString().slice(0, 10); - const meta = { b: bot, p: url.pathname.slice(0, 160), s: String(status), ok }; + const meta = { b: bot, p: url.pathname.slice(0, 160), s: String(status), ok, ...(via ? { v: via } : {}) }; await env.MESSAGES.put(`cr/${day}/${at.getTime()}-${Math.random().toString(36).slice(2, 8)}`, '', { customMetadata: meta }); } async function crStats(request, env, url) { @@ -577,7 +599,7 @@ async function crStats(request, env, url) { cursor = r.truncated ? r.cursor : undefined; if (++pages >= 40 && cursor) { truncated = true; break; } } while (cursor); - const label = Object.fromEntries(CRAWLERS); + const label = { ...Object.fromEntries(CRAWLERS), その他: 'その他(「AIで読む」経由で、名乗りが一覧にない読み手)' }; const count = (map, k) => map.set(k, (map.get(k) || 0) + 1); // クローラーごと(全期間) const bots = new Map(); @@ -604,6 +626,15 @@ async function crStats(request, env, url) { const scope = only ? rows.filter((r) => r.b === only) : rows; const paths = new Map(), pathBots = new Map(); for (const r of scope) { count(paths, r.p); const s = pathBots.get(r.p) || new Set(); s.add(r.b); pathBots.set(r.p, s); } + // 「AIで読む」経由(?via=)。押した AI ごとに、実際に読みに来たのはだれか(2026-11-01) + const viaMap = new Map(); + for (const r of rows) { + if (!r.v) continue; + const e = viaMap.get(r.v) || { via: r.v, visits: 0, records: new Set(), bots: new Map(), last: '' }; + e.visits++; e.records.add(r.p); count(e.bots, r.b); if (r.day > e.last) e.last = r.day; viaMap.set(r.v, e); + } + const viaWeeks = new Map(); + for (const r of rows) { if (!r.v) continue; const w = viaWeeks.get(r.week) || new Map(); count(w, r.v); viaWeeks.set(r.week, w); } const data = { total: rows.length, truncated, since: since || null, bot: only || null, byBot: [...bots.values()].sort((a, b) => b.visits - a.visits).map((b) => ({ @@ -611,6 +642,8 @@ async function crStats(request, env, url) { })), byWeek: [...weeks].sort((a, b) => b[0].localeCompare(a[0])).map(([week, m]) => ({ week, total: [...m.values()].reduce((x, y) => x + y, 0), bots: [...m].sort((a, b) => b[1] - a[1]).map(([b, n]) => `${b} ${n}`).join('、') })), byPage: [...paths].sort((a, b) => b[1] - a[1]).slice(0, 100).map(([path, n]) => ({ path, visits: n, bots: [...(pathBots.get(path) || [])].join('、') })), + byVia: [...viaMap.values()].sort((a, b) => b.visits - a.visits).map((e) => ({ via: e.via, visits: e.visits, records: e.records.size, bots: [...e.bots].sort((a, b) => b[1] - a[1]).map(([b, n]) => `${b} ${n}`).join('、'), last: e.last })), + byViaWeek: [...viaWeeks].sort((a, b) => b[0].localeCompare(a[0])).map(([week, m]) => ({ week, total: [...m.values()].reduce((x, y) => x + y, 0), via: [...m].sort((a, b) => b[1] - a[1]).map(([v, n]) => `${v} ${n}`).join('、') })), errors: [...errMap.values()].sort((a, b) => b.visits - a.visits).slice(0, 100).map((e) => ({ path: e.path, status: e.status, visits: e.visits, bots: [...e.bots].join('、'), last: e.last })), }; if (url.searchParams.get('format') === 'json') { @@ -626,6 +659,8 @@ h1{font-size:20px}h2{font-size:16px;margin-top:28px}.wrap{overflow-x:auto}table{

クローラーの訪問の記録

検索エンジン・AI のクローラーが、どのページを読みに来たか(2026-10-25 から)。名乗りで見分けているので、「Cloudflare が確かめた」が 0 のものは偽物が混ざりうる。日付は日本時間、週は月曜はじまり。?since=日付 で期間を、?bot=名前(例 GPTBot)でページごとの表をそのクローラーだけに絞る。${truncated ? ' 記録が多すぎて一部だけ数えた。' : ''}

クローラーごと

${table([['bot', '名前'], ['who', 'だれの・何のため'], ['visits', '読んだ回数'], ['pages', 'ページの数'], ['records', '記録(LOG)の数'], ['verified', 'Cloudflare が確かめた'], ['unverified', '確かめられなかった'], ['errors', '見つからない等(400以上)'], ['last', '最後に来た日']], data.byBot)}

週ごと

${table([['week', '週(月曜)'], ['total', '合計'], ['bots', '内訳']], data.byWeek)} +

「AIで読む」経由(記事のボタンから、全文ページを読みに来た回数)

押した AI(via)ごと。copy は「質問文をコピー」。「その他」は名乗りが一覧にない読み手(Gemini・人のブラウザなど)。押した回数は /api/ev-stats の場所 ai。

${table([['via', '押した AI'], ['visits', '読みに来た回数'], ['records', '記事の数'], ['bots', '読みに来たのはだれか'], ['last', '最後の日']], data.byVia)} +${table([['week', '週(月曜)'], ['total', '合計'], ['via', '内訳']], data.byViaWeek)}

よく読まれたページ${only ? `(${esc(only)} だけ)` : ''}

${table([['path', 'ページ'], ['visits', '回数'], ['bots', '読みに来たクローラー']], data.byPage)}

返せなかったページ(400以上)

${table([['path', 'ページ'], ['status', '返した結果'], ['visits', '回数'], ['bots', 'クローラー'], ['last', '最後の日']], data.errors)}

記録の件数:${data.total}

`;