// サイト名で検索して来た人の数を、Search Console の API から毎日取る(2026-11-06)。 // 主に見る3つの数字の3つ目(worker/kpi.js の nameSearch)。 // // - 鍵は Cloudflare の秘密の値 GSC_SA_KEY(Google Cloud のサービスアカウントの JSON の中身そのもの)。 // そのサービスアカウントのメールを、Search Console のプロパティに「制限付き」のユーザーとして足してある // - 見るプロパティは GSC_SITES(カンマ区切り)。なければ新ドメインと旧ドメインの2つ。権限のないプロパティは飛ばして記録に残す // - 数は2〜3日遅れて確定するので、毎日、今週と先週の分を取り直して上書きする(前の日の3つの数字を作ったときに1回だけ) // - 置き場所は R2(p2m-messages)の kpi-sc/{週の月曜}.json(MCP の record_name_search で手で書いたものと同じ形。byDay が付く) // - Search Console は、人数の少ない検索語を伏せて返す(匿名化)。数は少なめに出る import { KPI_FIRST_DAY, todayJst, addDays, weekOf } from './kpi.js'; const DEFAULT_SITES = ['sc-domain:pixelstomotors.com', 'https://adaptdesign.jp/']; // サイト名の検索とみなす検索語(RE2 の正規表現。大文字小文字は区別しない) const NAME_RE = '(?i)pixels?\\s*to\\s*motors?|pixelstomotors|ピクセルズ\\s*トゥ|ピクセル\\s*トゥ\\s*モーター'; const b64url = (bytes) => btoa(String.fromCharCode(...new Uint8Array(bytes))).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); const b64urlText = (s) => b64url(new TextEncoder().encode(s)); async function accessToken(sa) { const now = Math.floor(Date.now() / 1000); const head = b64urlText(JSON.stringify({ alg: 'RS256', typ: 'JWT' })); const claim = b64urlText(JSON.stringify({ iss: sa.client_email, scope: 'https://www.googleapis.com/auth/webmasters.readonly', aud: 'https://oauth2.googleapis.com/token', iat: now, exp: now + 3600 })); const pem = sa.private_key.replace(/-----[^-]+-----/g, '').replace(/\s+/g, ''); const der = Uint8Array.from(atob(pem), (c) => c.charCodeAt(0)); const key = await crypto.subtle.importKey('pkcs8', der, { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' }, false, ['sign']); const sig = await crypto.subtle.sign('RSASSA-PKCS1-v1_5', key, new TextEncoder().encode(`${head}.${claim}`)); const r = await fetch('https://oauth2.googleapis.com/token', { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: `grant_type=${encodeURIComponent('urn:ietf:params:oauth:grant-type:jwt-bearer')}&assertion=${head}.${claim}.${b64url(sig)}`, }); const j = await r.json(); if (!r.ok || !j.access_token) throw new Error(`Google の鍵の受け取りに失敗: HTTP ${r.status} ${j.error || ''}`); return j.access_token; } async function query(token, site, body) { const r = await fetch(`https://searchconsole.googleapis.com/webmasters/v3/sites/${encodeURIComponent(site)}/searchAnalytics/query`, { method: 'POST', headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' }, body: JSON.stringify(body), }); const j = await r.json().catch(() => ({})); if (!r.ok) throw new Error(`HTTP ${r.status} ${(j.error && j.error.message) || ''}`.trim()); return j.rows || []; } export const gscReady = (env) => !!env.GSC_SA_KEY; // 1週分を取り直して残す async function refreshWeek(env, token, sites, week) { const today = todayJst(); const end = addDays(week, 6) < today ? addDays(week, 6) : addDays(today, -1); if (end < week) return null; const rec = { week, clicks: 0, impressions: 0, queries: [], byDay: {}, allClicks: 0, allImpressions: 0, sites: [], sitesFailed: [], source: 'Search Console API', note: '', recordedAt: new Date().toISOString() }; const qmap = new Map(); for (const site of sites) { try { const named = await query(token, site, { startDate: week, endDate: end, dimensions: ['date', 'query'], rowLimit: 5000, dimensionFilterGroups: [{ filters: [{ dimension: 'query', operator: 'includingRegex', expression: NAME_RE }] }], }); for (const row of named) { const [day, q] = row.keys; rec.clicks += row.clicks; rec.impressions += row.impressions; const d = rec.byDay[day] || { clicks: 0, impressions: 0 }; d.clicks += row.clicks; d.impressions += row.impressions; rec.byDay[day] = d; const e = qmap.get(q) || { query: q, clicks: 0, impressions: 0 }; e.clicks += row.clicks; e.impressions += row.impressions; qmap.set(q, e); } // 参考:検索全体のクリックと表示(サイト名の検索がどれくらいの割合か) for (const row of await query(token, site, { startDate: week, endDate: end, dimensions: ['date'], rowLimit: 100 })) { rec.allClicks += row.clicks; rec.allImpressions += row.impressions; } rec.sites.push(site); } catch (e) { rec.sitesFailed.push(`${site}: ${e.message}`); } } if (!rec.sites.length) throw new Error(`どのプロパティも読めなかった(${rec.sitesFailed.join(' / ')})`); rec.queries = [...qmap.values()].sort((a, b) => b.impressions - a.impressions).slice(0, 50); rec.note = `${week} 〜 ${end} の分(数は2〜3日遅れて確定する。人数の少ない検索語は Search Console が伏せる)`; await env.MESSAGES.put(`kpi-sc/${week}.json`, JSON.stringify(rec), { httpMetadata: { contentType: 'application/json' } }); return rec; } // since の週から今週までを取り直す(毎日は先週と今週だけ) export async function gscRefresh(env, { since } = {}) { if (!gscReady(env)) return { ok: false, reason: 'GSC_SA_KEY がない' }; const sa = JSON.parse(env.GSC_SA_KEY); const sites = (env.GSC_SITES ? env.GSC_SITES.split(',') : DEFAULT_SITES).map((s) => s.trim()).filter(Boolean); const token = await accessToken(sa); const today = todayJst(); let w = weekOf(since && since >= KPI_FIRST_DAY ? since : addDays(today, -7)); const done = []; for (; w <= weekOf(today); w = addDays(w, 7)) { const r = await refreshWeek(env, token, sites, w); if (r) done.push({ week: w, clicks: r.clicks, impressions: r.impressions, sitesFailed: r.sitesFailed }); } return { ok: true, account: sa.client_email, weeks: done }; }