// サイトの計測を Claude につなぐ部品(MCP サーバー。2026-11-06)。 // Claude.ai のコネクタとして https://pixelstomotors.com/mcp/{鍵} を登録すると、Claude が集計済みの数字と、 // その読み方(何が混ざりうるか・いつの数から使えるか)をまとめて受け取れる。 // 毎回の分析で「鍵をどこから読むか・どの表から見るか・どこに落とし穴があるか」を Claude が思い出さなくて済むようにするため。 // // - 読むだけ(数を変える道具は record_name_search の1つだけで、Search Console の数を週ごとに書き足す) // - 鍵は Cloudflare の秘密の値 MCP_KEY。なければ STATS_KEY から作った値(mcpKey())を使う。鍵が違えば 404 // - 通信の形は MCP の Streamable HTTP(POST で JSON-RPC を1つ受け、JSON で1つ返す。状態は持たない) import { kpiRange, todayJst, addDays, weekOf, KPI_FIRST_DAY } from './kpi.js'; import { gscRefresh, gscReady } from './gsc.js'; const PROTOCOLS = ['2025-06-18', '2025-03-26', '2024-11-05']; const SERVER = { name: 'pixelstomotors-stats', title: 'Pixels to Motors のサイトの計測', version: '1.0.0' }; // 読み方の知識(道具の説明と how_to_read で返す)。数字の前提が変わったらここを直す const GUIDE = `# Pixels to Motors(pixelstomotors.com)のサイトの計測の読み方 ## いま主に見る3つの数字(2026-11-06 にエイイチが決めた) サイトのねらい:AI に調べられることで、人の来訪や知名度がいつ増えはじめるかを見る。サイトはもともと検索で人を集める作りにしていない。 1. AI の利用者の依頼で読みに来た数(aiReads):ChatGPT-User・Claude-User・Perplexity-User・MistralAI-User が読んだページの数。/robots.txt は数えない。ChatGPT-User のうち Cloudflare が本物と確かめられなかったものは入れず、unverifiedChatGPTExcluded に別に数える。Claude-User は Cloudflare が確かめないので全部入れる 2. AI のサービスのリンクから来た人の数(aiReferrals):referrer が chatgpt.com などか、リンクの印 utm_source が chatgpt.com などの表示のうち、人の操作(マウス・指・キー)があったものだけ。機械・操作なしは notHumanExcluded に別に数える 3. サイト名で検索して来た人の数(nameSearch):Search Console のサイト名(Pixels to Motors・pixelstomotors など)の検索のクリック数。今は週の分析のときに Claude が Search Console(Chrome 拡張・新旧ドメインの両方)を見て、record_name_search で週ごとに書き足す(2026-11-06 決定。API の自動取得は Google Workspace の組織のポリシーでサービスアカウントの鍵が作れず保留。鍵 GSC_SA_KEY を入れれば毎日自動に切り替わる)。数は2〜3日遅れて確定し、人数の少ない検索語は Search Console が伏せるので少なめに出る。kpi の nameSearch に週ごとの記録(byDay・検索語・検索全体のクリック allClicks)が出る ## 混ざりうるもの(数を読む前に必ず思い出す) - aiReads の viaButton は記事の「AIで読む」ボタン経由。エイイチや Claude の試しが混ざりうる。ボタンの利用そのものは site_usage の押したもの(btn:AIで読む・場所 ai)で人のクリックを見る。crawler_visits の byVia は Amazonbot などのクローラーがボタンの行き先をたどった分が大半で、利用の数としては使えない(2026-11-05 に判明) - aiReads には、エイイチ自身が ChatGPT・Claude にこのサイトのことを聞いた分も入りうる(見分けられない)。トップ(/)が多い日はそれを疑う - Claude のセッションが WebFetch でサイトを読むと Claude-User として入りうる - 人の数は 2026-10-26 から(それより前は人と機械を分けていない)。人の表示の約9割は機械なので、来訪の数は必ず人だけ(who=human)で見る - エイイチ・Claude の確認の閲覧は ?p2m-notrack=1 を付けたブラウザでは送られない。付け忘れた自動のブラウザは navigator.webdriver で機械に入る - crawler_visits(/api/cr-stats)は長い期間を指定すると記録が多すぎて一部しか数えない(truncated が true)。2026-10-25 からの合計は少なめに出ていた。日ごとの数は kpi(日ごとに集計して残したもの)で見る ## 数え方の決まり - 日付は日本時間。週は月曜はじまり - 改善の判断の基準(Project の claude/web_improvement_trial.md):対象の数が30未満なら「判断できない」 - 見た人を見分ける番号は残さないので、「何人か」は数えられない。数は表示・訪問の回数 ## 道具の使い分け - まず kpi(3つの数字・日ごと週ごと) - ページごとの読まれ方・押したもの・来た所は site_usage - どのクローラーが何を読んだかは crawler_visits(期間は短く) - 更新を知らせるハコブくんは hakobu_notice`; const TOOLS = [ { name: 'kpi', title: '3つの数字(日ごと・週ごと)', description: 'いま主に見る3つの数字を返す:AI の利用者の依頼で読みに来た数・AI のサービスのリンクから来た人の数・サイト名で検索して来た人の数(Search Console から記録した分)。日ごとに集計して残したものなので速く、数え漏れがない。混ざりうるものは how_to_read を読むこと。日付は日本時間、週は月曜はじまり。', inputSchema: { type: 'object', properties: { since: { type: 'string', description: '始めの日(YYYY-MM-DD)。省略すると28日前。2026-10-26 より前は数えない' }, until: { type: 'string', description: '終わりの日(YYYY-MM-DD)。省略すると今日(今日は途中の数)' }, detail: { type: 'boolean', description: 'true でよく読まれたページ・来たページなどの内訳も返す' }, } }, annotations: { readOnlyHint: true, openWorldHint: false }, }, { name: 'site_usage', title: 'サイトの使われ方(ページごと・押したもの・来た所)', description: 'ページ表示ごとの記録(/api/ev-stats)。週ごと・ページごとの表示・表示時間・どこまで読んだか・押したもの・リンクでない所を押した・どこから来たか・フォーム。既定は人の操作があった表示だけ(who=human)。', inputSchema: { type: 'object', properties: { since: { type: 'string', description: 'この日以降(YYYY-MM-DD)' }, week: { type: 'string', description: 'ページごと以下の表をこの週だけに(週の月曜の日付)' }, who: { type: 'string', enum: ['human', 'bot', 'unknown', 'old', 'all'], description: '既定 human' }, } }, annotations: { readOnlyHint: true, openWorldHint: false }, }, { name: 'crawler_visits', title: '検索エンジン・AI のクローラーの訪問', description: 'どのクローラーが・どのページを読んだか(/api/cr-stats)。返せなかったページ(400以上)も。長い期間を指定すると一部しか数えない(truncated)ので、since は2週間以内にする。', inputSchema: { type: 'object', properties: { since: { type: 'string', description: 'この日以降(YYYY-MM-DD)。省略すると7日前' }, bot: { type: 'string', description: 'ページごとの表をこのクローラーだけに(例 ChatGPT-User)' }, } }, annotations: { readOnlyHint: true, openWorldHint: false }, }, { name: 'hakobu_notice', title: '更新を知らせるハコブくんの記録', description: 'サイトの更新を知らせてハコブくんが出た・押された・あとで見に来た数(/api/hk-stats)。既定は機械の印のない記録だけ(who=notbot)。', inputSchema: { type: 'object', properties: { who: { type: 'string', enum: ['notbot', 'bot', 'old', 'all'], description: '既定 notbot' } } }, annotations: { readOnlyHint: true, openWorldHint: false }, }, { name: 'how_to_read', title: '計測の読み方', description: '数を読む前の前提:3つの数字の定義、混ざりうるもの(エイイチ・Claude の試し、Amazonbot、人と機械)、いつからの数が使えるか、道具の使い分け。分析を始めるときに最初に読む。', inputSchema: { type: 'object', properties: {} }, annotations: { readOnlyHint: true, openWorldHint: false }, }, { name: 'refresh_name_search', title: 'サイト名の検索の数を Search Console から取り直す', description: '毎日自動で取っているサイト名の検索の数を、今すぐ取り直す。since を付けるとその週から(最初の埋め戻しや、数が確定したあとの取り直し用)。', inputSchema: { type: 'object', properties: { since: { type: 'string', description: 'この日の週から(YYYY-MM-DD)。省略すると先週から' } } }, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true }, }, { name: 'record_name_search', title: 'サイト名の検索の数を記録する(Search Console から)', description: 'Search Console の API が使えないときだけ使う。Search Console で見た、サイト名(Pixels to Motors・pixelstomotors など)で検索された週の数を書き足す。同じ週を送ると上書きする。数は2〜3日遅れて出るので、週が終わって3日以上たってから記録する。', inputSchema: { type: 'object', required: ['week', 'clicks', 'impressions'], properties: { week: { type: 'string', description: '週の月曜の日付(YYYY-MM-DD)' }, clicks: { type: 'integer', minimum: 0, description: 'サイト名の検索から来たクリック数(新旧ドメインの合計)' }, impressions: { type: 'integer', minimum: 0, description: 'サイト名の検索で表示された回数(新旧ドメインの合計)' }, queries: { type: 'array', items: { type: 'object', properties: { query: { type: 'string' }, clicks: { type: 'integer' }, impressions: { type: 'integer' } } }, description: '数えた検索語ごとの内訳' }, note: { type: 'string', description: 'どのプロパティを見たか・気づいたこと' }, } }, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, }, ]; const DAY = /^\d{4}-\d{2}-\d{2}$/; const okDay = (s, d) => (DAY.test(s || '') ? s : d); // MCP の鍵。MCP_KEY があればそれ、なければ STATS_KEY から作る(STATS_KEY を変えればこちらも変わる) export async function mcpKey(env) { if (env.MCP_KEY) return env.MCP_KEY; if (!env.STATS_KEY) return ''; const k = await crypto.subtle.importKey('raw', new TextEncoder().encode(env.STATS_KEY), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']); const sig = new Uint8Array(await crypto.subtle.sign('HMAC', k, new TextEncoder().encode('p2m-mcp-v1'))); return [...sig.slice(0, 16)].map((b) => b.toString(16).padStart(2, '0')).join(''); } async function sameKey(given, key) { if (!given || !key) return false; const [a, b] = await Promise.all([given, key].map((x) => crypto.subtle.digest('SHA-256', new TextEncoder().encode(x)))); return crypto.subtle.timingSafeEqual(a, b); } const rpc = (id, result) => ({ jsonrpc: '2.0', id, result }); const rpcErr = (id, code, message) => ({ jsonrpc: '2.0', id: id ?? null, error: { code, message } }); const text = (o) => ({ content: [{ type: 'text', text: typeof o === 'string' ? o : JSON.stringify(o) }] }); // stats: { evStats, crStats, hkStats }(index.js の数の表。Response を返す関数) export async function handleMcp(request, env, url, stats) { const given = url.pathname.slice('/mcp/'.length).replace(/\/$/, ''); if (!(await sameKey(given, await mcpKey(env)))) return new Response('Not Found', { status: 404, headers: { 'x-robots-tag': 'noindex' } }); const headers = { 'content-type': 'application/json; charset=utf-8', 'x-robots-tag': 'noindex', 'cache-control': 'no-store' }; if (request.method === 'GET') return new Response('Method Not Allowed', { status: 405, headers: { allow: 'POST' } }); if (request.method === 'DELETE') return new Response(null, { status: 204 }); if (request.method !== 'POST') return new Response('Method Not Allowed', { status: 405, headers: { allow: 'POST' } }); let msg; try { msg = JSON.parse((await request.text()).slice(0, 100000)); } catch { return new Response(JSON.stringify(rpcErr(null, -32700, 'Parse error')), { status: 400, headers }); } const batch = Array.isArray(msg); const out = []; for (const m of batch ? msg : [msg]) { const r = await one(m, env, url, stats); if (r) out.push(r); } if (!out.length) return new Response(null, { status: 202 }); // 通知(返事のいらないもの)だけだった return new Response(JSON.stringify(batch ? out : out[0]), { headers }); } async function one(m, env, url, stats) { if (!m || m.jsonrpc !== '2.0' || typeof m.method !== 'string') return rpcErr(m && m.id, -32600, 'Invalid Request'); const isNotice = !('id' in m); try { switch (m.method) { case 'initialize': { const want = m.params && m.params.protocolVersion; return rpc(m.id, { protocolVersion: PROTOCOLS.includes(want) ? want : PROTOCOLS[0], capabilities: { tools: { listChanged: false } }, serverInfo: SERVER, instructions: 'pixelstomotors.com のサイトの計測。分析を始めるときは、まず how_to_read で前提を読み、次に kpi で3つの数字を見る。', }); } case 'ping': return isNotice ? null : rpc(m.id, {}); case 'tools/list': return rpc(m.id, { tools: TOOLS }); case 'tools/call': return rpc(m.id, await call(m.params || {}, env, url, stats)); case 'resources/list': return rpc(m.id, { resources: [] }); case 'prompts/list': return rpc(m.id, { prompts: [] }); default: if (isNotice) return null; // notifications/initialized など return rpcErr(m.id, -32601, `Method not found: ${m.method}`); } } catch (e) { return isNotice ? null : rpcErr(m.id, -32603, String(e && e.message || e)); } } // index.js の数の表を、鍵を付けた形で中から呼んで JSON を受け取る async function statsJson(fn, env, path, params) { const u = new URL(`https://pixelstomotors.com${path}`); u.searchParams.set('format', 'json'); for (const [k, v] of Object.entries(params)) if (v) u.searchParams.set(k, v); const res = await fn(new Request(u, { method: 'GET' }), env, u); return res.json(); } async function call(p, env, url, stats) { const a = p.arguments || {}; const today = todayJst(); switch (p.name) { case 'how_to_read': return text(GUIDE); case 'kpi': { const r = await kpiRange(env, okDay(a.since, addDays(today, -28)), okDay(a.until, today)); if (!a.detail) delete r.detailDays; return text({ note: '定義と混ざりうるものは how_to_read。byDay の partial=true は今日(途中の数)。pendingDays は集計がまだの日(もう一度呼ぶと作る)。', firstDay: KPI_FIRST_DAY, ...r }); } case 'site_usage': { const who = a.who === 'all' ? '' : (a.who || 'human'); return text(await statsJson(stats.evStats, env, '/api/ev-stats', { since: okDay(a.since, ''), week: okDay(a.week, ''), who })); } case 'crawler_visits': return text(await statsJson(stats.crStats, env, '/api/cr-stats', { since: okDay(a.since, addDays(today, -7)), bot: String(a.bot || '').slice(0, 40) })); case 'hakobu_notice': { const who = a.who === 'all' ? '' : (a.who || 'notbot'); return text(await statsJson(stats.hkStats, env, '/api/hk-stats', { who })); } case 'refresh_name_search': { if (!gscReady(env)) return { isError: true, ...text('Search Console の鍵(GSC_SA_KEY)がまだ設定されていない') }; return text(await gscRefresh(env, { since: okDay(a.since, '') })); } case 'record_name_search': { if (!DAY.test(a.week || '') || weekOf(a.week) !== a.week) return { isError: true, ...text('week は週の月曜の日付(YYYY-MM-DD)にする') }; const n = (x) => Math.max(0, Math.floor(Number(x) || 0)); const rec = { week: a.week, clicks: n(a.clicks), impressions: n(a.impressions), queries: (Array.isArray(a.queries) ? a.queries : []).slice(0, 50).map((q) => ({ query: String(q.query || '').slice(0, 100), clicks: n(q.clicks), impressions: n(q.impressions) })), note: String(a.note || '').slice(0, 1000), source: 'Search Console', recordedAt: new Date().toISOString(), }; await env.MESSAGES.put(`kpi-sc/${a.week}.json`, JSON.stringify(rec), { httpMetadata: { contentType: 'application/json' } }); return text({ ok: true, saved: `kpi-sc/${a.week}.json`, ...rec }); } default: return { isError: true, ...text(`知らない道具:${p.name}`) }; } }