// 主に見る3つの数字を、日ごとに集計して残す(2026-11-06)。 // サイトの今のねらいは「AI に調べられることで、人の来訪や知名度がいつ増えはじめるか」を見ること。 // そのために見る数字を3つに絞った(エイイチ 2026-11-06): // 1. aiReads AI の利用者の依頼で読みに来た数(ChatGPT-User・Claude-User・Perplexity-User・MistralAI-User) // 2. aiReferrals AI のサービス(chatgpt.com など)のリンクから来た人の数(人の操作があった表示だけ) // 3. nameSearch サイト名で検索して来た人の数(Search Console の数。ここでは集計できないので、別に記録したものを返す) // // 置き場所は R2(p2m-messages)の kpi/{日付}.json。日本時間で1日が終わったら、5分ごとの scheduled が前の日の分を作る。 // 元の記録(cr/{日付}/・ev/{日付}/)は読むだけで、変えない。 // 作り直すときは kpi/{日付}.json を消すか、KPI_VERSION を上げる(版が古いものは読むときに作り直す)。 export const KPI_VERSION = 1; export const KPI_FIRST_DAY = '2026-10-26'; // 人と機械を分け始めた日(それより前の来訪の数は人の数として使えない) // 1. 利用者の依頼で読む AI(学習・検索のための巡回は入れない) const AI_USER_BOTS = new Set(['ChatGPT-User', 'Claude-User', 'Perplexity-User', 'MistralAI-User']); // 読んだ数に入れないページ(読む前の確かめ) const AI_READ_SKIP = new Set(['/robots.txt']); // 2. AI のサービスの住所(referrer のホスト)と、リンクに付く印(utm_source) const AI_HOSTS = { 'chatgpt.com': 'ChatGPT', 'chat.openai.com': 'ChatGPT', 'perplexity.ai': 'Perplexity', 'www.perplexity.ai': 'Perplexity', 'claude.ai': 'Claude', 'gemini.google.com': 'Gemini', 'bard.google.com': 'Gemini', 'copilot.microsoft.com': 'Copilot', 'copilot.cloud.microsoft': 'Copilot', 'chat.deepseek.com': 'DeepSeek', 'grok.com': 'Grok', 'you.com': 'You.com', 'felo.ai': 'Felo', 'www.genspark.ai': 'Genspark', 'genspark.ai': 'Genspark', 'phind.com': 'Phind', 'www.phind.com': 'Phind', }; const AI_UTM = { 'chatgpt.com': 'ChatGPT', 'chatgpt': 'ChatGPT', 'openai': 'ChatGPT', 'perplexity': 'Perplexity', 'perplexity.ai': 'Perplexity', 'claude.ai': 'Claude', 'claude': 'Claude', 'gemini': 'Gemini', 'gemini.google.com': 'Gemini', 'copilot.com': 'Copilot', 'copilot': 'Copilot', 'copilot.microsoft.com': 'Copilot', }; export const aiSourceOf = (r, us) => AI_HOSTS[r] || AI_UTM[us] || ''; const jstDay = (ms) => new Date(ms + 9 * 3600e3).toISOString().slice(0, 10); export const todayJst = () => jstDay(Date.now()); export const addDays = (day, n) => { const d = new Date(day + 'T00:00:00Z'); d.setUTCDate(d.getUTCDate() + n); return d.toISOString().slice(0, 10); }; export const weekOf = (day) => { const d = new Date(day + 'T00:00:00Z'); d.setUTCDate(d.getUTCDate() - ((d.getUTCDay() + 6) % 7)); return d.toISOString().slice(0, 10); }; const inc = (o, k, n = 1) => { o[k] = (o[k] || 0) + n; return o; }; const topOf = (o, n) => Object.entries(o).sort((a, b) => b[1] - a[1]).slice(0, n).map(([k, v]) => ({ key: k, count: v })); async function listAll(env, prefix) { const out = []; let cursor; do { const r = await env.MESSAGES.list({ prefix, cursor, include: ['customMetadata'], limit: 1000 }); for (const o of r.objects) out.push(o); cursor = r.truncated ? r.cursor : undefined; } while (cursor); return out; } // 1日分を元の記録から数える export async function computeKpiDay(env, day) { // 1. AI の利用者の依頼で読みに来た数(cr/) const ai = { total: 0, byBot: {}, unverifiedChatGPT: 0, viaButton: 0, pages: {} }; for (const o of await listAll(env, `cr/${day}/`)) { const m = o.customMetadata || {}; if (!AI_USER_BOTS.has(m.b) || AI_READ_SKIP.has(m.p)) continue; // ChatGPT-User は Cloudflare が本物かを確かめられる。確かめられなかったもの(偽物が混ざりうる)は数に入れず、別に数える if (m.b === 'ChatGPT-User' && m.ok === '0') { ai.unverifiedChatGPT++; continue; } ai.total++; inc(ai.byBot, m.b); inc(ai.pages, m.p); if (m.v) ai.viaButton++; // 記事の「AIで読む」ボタンから(エイイチ・Claude の試しが混ざりうる) } // 2. AI のサービスのリンクから来た人(ev/。同じ表示を送り直したものは n が最大の1件) const views = new Map(); for (const o of await listAll(env, `ev/${day}/`)) { const m = o.customMetadata || {}; if (!m.v) continue; const prev = views.get(m.v); if (!prev || Number(m.n) >= Number(prev.n)) views.set(m.v, m); } const ref = { total: 0, bySource: {}, landing: {}, notHuman: 0 }; let humanViews = 0, humanEntries = 0; for (const m of views.values()) { const isHuman = m.k === 'h'; if (isHuman) humanViews++; if (isHuman && !m.f) humanEntries++; const src = aiSourceOf(m.r || '', m.us || ''); if (!src) continue; if (!isHuman) { ref.notHuman++; continue; } // 機械・操作なしの表示は数に入れない(別に数える) ref.total++; inc(ref.bySource, src); inc(ref.landing, m.p); } return { version: KPI_VERSION, day, week: weekOf(day), madeAt: new Date().toISOString(), aiReads: { total: ai.total, byBot: ai.byBot, viaButton: ai.viaButton, unverifiedChatGPTExcluded: ai.unverifiedChatGPT, topPages: topOf(ai.pages, 20) }, aiReferrals: { total: ref.total, bySource: ref.bySource, landing: topOf(ref.landing, 20), notHumanExcluded: ref.notHuman }, context: { humanViews, humanEntriesFromOutside: humanEntries }, }; } // 残してある1日分を読む(なければ作って残す。今日の分は途中なので残さない) export async function getKpiDay(env, day, { create = true } = {}) { const today = todayJst(); if (day >= today) return { ...(await computeKpiDay(env, day)), partial: true }; const obj = await env.MESSAGES.get(`kpi/${day}.json`); if (obj) { try { const d = await obj.json(); if (d.version === KPI_VERSION) return d; } catch { /* 壊れていたら作り直す */ } } if (!create) return null; const d = await computeKpiDay(env, day); await env.MESSAGES.put(`kpi/${day}.json`, JSON.stringify(d), { httpMetadata: { contentType: 'application/json' } }); return d; } // 3. サイト名で検索して来た人(Search Console の数。週ごとに kpi-sc/{週の月曜}.json に記録したもの) export async function getNameSearch(env, since, until) { const out = []; const r = await env.MESSAGES.list({ prefix: 'kpi-sc/', limit: 1000 }); for (const o of r.objects) { const w = o.key.slice('kpi-sc/'.length, -'.json'.length); if (w < weekOf(since) || w > until) continue; const obj = await env.MESSAGES.get(o.key); if (obj) { try { out.push(await obj.json()); } catch { /* 読めないものは飛ばす */ } } } return out.sort((a, b) => a.week.localeCompare(b.week)); } // 期間の数をまとめる。足りない日は作る(1回で作るのは maxCreate 日まで。残りは次に読んだときに作る) export async function kpiRange(env, since, until, { maxCreate = 10 } = {}) { since = since < KPI_FIRST_DAY ? KPI_FIRST_DAY : since; const today = todayJst(); if (until > today) until = today; const days = []; let created = 0, pending = []; for (let d = since; d <= until; d = addDays(d, 1)) { let k = await getKpiDay(env, d, { create: false }); if (!k && created < maxCreate) { k = await getKpiDay(env, d); created++; } if (k) days.push(k); else pending.push(d); } const weeks = new Map(); for (const k of days) { const w = weeks.get(k.week) || { week: k.week, days: 0, aiReads: 0, aiReadsViaButton: 0, aiReferrals: 0, humanViews: 0, aiReadsByBot: {}, aiReferralsBySource: {} }; w.days++; w.aiReads += k.aiReads.total; w.aiReadsViaButton += k.aiReads.viaButton; w.aiReferrals += k.aiReferrals.total; w.humanViews += k.context.humanViews; for (const [b, n] of Object.entries(k.aiReads.byBot)) inc(w.aiReadsByBot, b, n); for (const [s, n] of Object.entries(k.aiReferrals.bySource)) inc(w.aiReferralsBySource, s, n); weeks.set(k.week, w); } const nameSearch = await getNameSearch(env, since, until); return { since, until, pendingDays: pending, byWeek: [...weeks.values()].map((w) => ({ ...w, nameSearch: (nameSearch.find((x) => x.week === w.week) || {}).clicks ?? null })), byDay: days.map((k) => ({ day: k.day, partial: !!k.partial, aiReads: k.aiReads.total, aiReadsViaButton: k.aiReads.viaButton, aiReferrals: k.aiReferrals.total, humanViews: k.context.humanViews, aiReadsByBot: k.aiReads.byBot, aiReferralsBySource: k.aiReferrals.bySource })), detailDays: days, nameSearch, }; } // 5分ごとの scheduled から:前の日の分がまだなければ作る export async function kpiCron(env) { const y = addDays(todayJst(), -1); if (await env.MESSAGES.head(`kpi/${y}.json`)) return 'kpi: ok'; await getKpiDay(env, y); return `kpi: made ${y}`; }