commit 7f4920e070fc1d6cff050ce128363a5cf7d54bcf
Author: PixelstoMotors <studio@adaptdesign.jp>
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 <noreply@anthropic.com>
    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', `<https://pixelstomotors.com/record/${full[1]}>; 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{
 <h1>クローラーの訪問の記録</h1><p>検索エンジン・AI のクローラーが、どのページを読みに来たか（2026-10-25 から）。名乗りで見分けているので、「Cloudflare が確かめた」が 0 のものは偽物が混ざりうる。日付は日本時間、週は月曜はじまり。?since=日付 で期間を、?bot=名前（例 GPTBot）でページごとの表をそのクローラーだけに絞る。${truncated ? ' 記録が多すぎて一部だけ数えた。' : ''}</p>
 <h2>クローラーごと</h2>${table([['bot', '名前'], ['who', 'だれの・何のため'], ['visits', '読んだ回数'], ['pages', 'ページの数'], ['records', '記録（LOG）の数'], ['verified', 'Cloudflare が確かめた'], ['unverified', '確かめられなかった'], ['errors', '見つからない等（400以上）'], ['last', '最後に来た日']], data.byBot)}
 <h2>週ごと</h2>${table([['week', '週（月曜）'], ['total', '合計'], ['bots', '内訳']], data.byWeek)}
+<h2>「AIで読む」経由（記事のボタンから、全文ページを読みに来た回数）</h2><p>押した AI（via）ごと。copy は「質問文をコピー」。「その他」は名乗りが一覧にない読み手（Gemini・人のブラウザなど）。押した回数は /api/ev-stats の場所 ai。</p>${table([['via', '押した AI'], ['visits', '読みに来た回数'], ['records', '記事の数'], ['bots', '読みに来たのはだれか'], ['last', '最後の日']], data.byVia)}
+${table([['week', '週（月曜）'], ['total', '合計'], ['via', '内訳']], data.byViaWeek)}
 <h2>よく読まれたページ${only ? `（${esc(only)} だけ）` : ''}</h2>${table([['path', 'ページ'], ['visits', '回数'], ['bots', '読みに来たクローラー']], data.byPage)}
 <h2>返せなかったページ（400以上）</h2>${table([['path', 'ページ'], ['status', '返した結果'], ['visits', '回数'], ['bots', 'クローラー'], ['last', '最後の日']], data.errors)}
 <p>記録の件数：${data.total}</p></body></html>`;
