diff --git a/worker/index.js b/worker/index.js index 97496c6..bbe919f 100644 --- a/worker/index.js +++ b/worker/index.js @@ -9,7 +9,9 @@ // - /api/ev-stats その記録を週ごと・ページごとに数えて見せる(数だけ。誰かは分からない) // - ページ・llms.txt・全文の Markdown など 静的ファイルを返す。検索エンジン・AI のクローラーが読みに来たら、R2(p2m-messages の cr/)に1件ずつ残す // - /api/cr-stats その記録を、どのクローラーが・いつ・どのページを読んだかで数えて見せる(2026-10-25) -// - 5分ごとの scheduled SNS の予約投稿の時刻が来ていたら GitHub Actions を起こす(worker/sns_cron.js) +// - /api/kpi-stats 主に見る3つの数字(AI の依頼読み・AI のリンクからの来訪・サイト名の検索)を日ごと・週ごとに見せる(worker/kpi.js。2026-11-06) +// - /mcp/{鍵} サイトの計測を Claude につなぐ部品(MCP サーバー。worker/mcp.js。2026-11-06) +// - 5分ごとの scheduled SNS の予約投稿の時刻が来ていたら GitHub Actions を起こす(worker/sns_cron.js)。前の日の3つの数字がまだなければ集計して残す(worker/kpi.js) // // 守り(2026-10-23) // - 数の表(/api/hk-stats・/api/ev-stats・/api/cr-stats)は、鍵(Cloudflare の秘密の値 STATS_KEY)を付けたときだけ見られる。鍵がなければ 404 @@ -19,10 +21,14 @@ // - このファイルが返す応答には、内容の種類を推測させない指定(nosniff)を付ける。ページ側の指定は public/_headers import { EmailMessage } from 'cloudflare:email'; import { snsCron } from './sns_cron.js'; +import { kpiCron, kpiRange, todayJst, addDays } from './kpi.js'; +import { handleMcp } from './mcp.js'; +import { gscRefresh } from './gsc.js'; export default { async fetch(request, env, ctx) { const url = new URL(request.url); + if (url.pathname.startsWith('/mcp/')) return secured(await handleMcp(request, env, url, { evStats, crStats, hkStats })); if (url.pathname.startsWith('/api/') || url.pathname.startsWith('/media/') || url.pathname === '/pixelstomotors' || url.pathname.startsWith('/pixelstomotors/')) { return secured(await route(request, env, url)); } @@ -59,6 +65,11 @@ export default { // 5分ごと(wrangler.jsonc の triggers):SNS の予約投稿の時刻が来ていたら、GitHub Actions を起こす(worker/sns_cron.js) async scheduled(event, env, ctx) { ctx.waitUntil(snsCron(env, event.scheduledTime).then((m) => console.log('sns-cron:', m), (e) => console.error('sns-cron:', e.message))); + // 前の日の分を作ったとき(1日1回)だけ、Search Console からサイト名の検索の数も取り直す(worker/gsc.js) + ctx.waitUntil(kpiCron(env).then(async (m) => { + console.log(m); + if (m.startsWith('kpi: made')) console.log('gsc:', JSON.stringify(await gscRefresh(env))); + }).catch((e) => console.error('kpi-cron:', e.message))); }, }; @@ -101,6 +112,7 @@ async function route(request, env, url) { if (url.pathname === '/api/hk-stats') return (await statsAllowed(request, env, url)) ? hkStats(request, env, url) : notFound(); if (url.pathname === '/api/ev-stats') return (await statsAllowed(request, env, url)) ? evStats(request, env, url) : notFound(); if (url.pathname === '/api/cr-stats') return (await statsAllowed(request, env, url)) ? crStats(request, env, url) : notFound(); + if (url.pathname === '/api/kpi-stats') return (await statsAllowed(request, env, url)) ? kpiStats(request, env, url) : notFound(); return env.ASSETS.fetch(request); } @@ -308,15 +320,6 @@ 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, @@ -346,7 +349,6 @@ h1{font-size:20px}h2{font-size:16px;margin-top:28px}table{border-collapse:collap

日ごと

${table('日付', data.byDay, 'day')}

更新ごと

${table('更新', data.byItem, 'id')}

画面の幅(pc=広い/sp=スマホ)

${table('幅', data.byWidth, 'w')} -

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

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

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

`; return new Response(html, { headers: { 'content-type': 'text/html; charset=utf-8', 'x-robots-tag': 'noindex', 'cache-control': 'no-store' } }); } @@ -501,15 +503,6 @@ 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, @@ -672,3 +665,32 @@ ${table([['week', '週(月曜)'], ['total', '合計'], ['via', '内訳']], d

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

`; return new Response(html, { headers: { 'content-type': 'text/html; charset=utf-8', 'x-robots-tag': 'noindex', 'cache-control': 'no-store' } }); } + +// ───────── 主に見る3つの数字(2026-11-06。集計は worker/kpi.js)───────── +async function kpiStats(request, env, url) { + if (request.method !== 'GET') return new Response('Method Not Allowed', { status: 405 }); + const day = (k, d) => (/^\d{4}-\d{2}-\d{2}$/.test(url.searchParams.get(k) || '') ? url.searchParams.get(k) : d); + const today = todayJst(); + // ?sc=refresh で Search Console からサイト名の検索の数を取り直す(?since= の週から。最初の埋め戻し用) + if (url.searchParams.get('sc') === 'refresh') { + try { return json(await gscRefresh(env, { since: day('since', '') })); } catch (e) { return json({ ok: false, error: e.message }, 500); } + } + const data = await kpiRange(env, day('since', addDays(today, -28)), day('until', today)); + if (url.searchParams.get('format') === 'json') { + return new Response(JSON.stringify(data), { headers: { 'content-type': 'application/json; charset=utf-8', 'x-robots-tag': 'noindex', 'cache-control': 'no-store' } }); + } + const esc = (s) => String(s).replace(/[&<>"]/g, (ch) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[ch])); + const kv = (o) => Object.entries(o || {}).sort((a, b) => b[1] - a[1]).map(([k, v]) => `${k} ${v}`).join('、'); + const table = (cols, list) => list.length + ? `
${cols.map(([, h]) => ``).join('')}${list.map((r) => `${cols.map(([k, , f]) => ``).join('')}`).join('')}
${h}
${esc(f ? f(r[k], r) : (r[k] ?? '-'))}
` + : '

まだ記録がありません。

'; + const html = ` +3つの数字 +

3つの数字

AI に調べられることで、人の来訪や知名度がいつ増えはじめるかを見るための数。日付は日本時間、週は月曜はじまり。${esc(data.since)} 〜 ${esc(data.until)}(?since=・?until= で変える)。${data.pendingDays.length ? ` 集計がまだの日が ${data.pendingDays.length} 日ある(開き直すと作る)。` : ''}

+

AI の依頼読み:ChatGPT-User・Claude-User・Perplexity-User などが読んだページの数(「AIで読む」ボタン経由はエイイチ・Claude の試しが混ざりうる)。AI のリンクから:chatgpt.com などのリンクから来た、人の操作があった表示。サイト名の検索:Search Console から週ごとに記録したクリック数。

+

週ごと

${table([['week', '週(月曜)'], ['days', '日数'], ['aiReads', 'AI の依頼読み'], ['aiReadsViaButton', 'うちボタン経由'], ['aiReferrals', 'AI のリンクから来た人'], ['nameSearch', 'サイト名の検索(クリック)'], ['humanViews', '人の表示(参考)'], ['aiReadsByBot', '依頼読みの内訳', kv], ['aiReferralsBySource', 'リンクの内訳', kv]], [...data.byWeek].reverse())} +

日ごと

${table([['day', '日付', (v, r) => v + (r.partial ? '(途中)' : '')], ['aiReads', 'AI の依頼読み'], ['aiReadsViaButton', 'うちボタン経由'], ['aiReferrals', 'AI のリンクから来た人'], ['humanViews', '人の表示(参考)'], ['aiReadsByBot', '依頼読みの内訳', kv], ['aiReferralsBySource', 'リンクの内訳', kv]], [...data.byDay].reverse())} +`; + return new Response(html, { headers: { 'content-type': 'text/html; charset=utf-8', 'x-robots-tag': 'noindex', 'cache-control': 'no-store' } }); +}