// サイトの入口。ほとんどのページは作っておいた静的ファイル(dist/)をそのまま返す。 // ここで処理するのは3つだけ: // - /pixelstomotors/… 旧 URL の形。/pixelstomotors を外した URL へ 301 で転送する // - /media/{ファイル名} 記事・4コマの画像と動画を R2(p2m-media)から返す(GitHub には入れないため) // - /api/message メッセージフォームの送信を R2(p2m-messages)に保存し、メールで知らせる // - /api/hk-event 更新を知らせるハコブくんが「出た・押された・あとで見に来た」を R2(p2m-messages の hk-events/)に1件ずつ残す // - /api/hk-stats その記録を日ごと・更新ごとに数えて見せる(数だけ。誰かは分からない) // - /api/ev サイトの使われ方(1回のページ表示ごとの、読んだ深さ・押したもの・迷い)を R2(p2m-messages の ev/)に残す // - /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) // // 守り(2026-10-23) // - 数の表(/api/hk-stats・/api/ev-stats・/api/cr-stats)は、鍵(Cloudflare の秘密の値 STATS_KEY)を付けたときだけ見られる。鍵がなければ 404 // - 送信の口(/api/message・/api/hk-event・/api/ev)は、このサイトのページから送られたものだけ受け取る // - 同じ送信元からの送りすぎは、Cloudflare の流量制限(wrangler.jsonc の ratelimits)で断る。送信元の IP アドレスは数えるためだけに使い、記録しない // - フォームには人には見えない欄(website)を置き、そこが埋まった送信は保存せずに受け取ったふりをする(自動投稿の対策) // - このファイルが返す応答には、内容の種類を推測させない指定(nosniff)を付ける。ページ側の指定は public/_headers import { EmailMessage } from 'cloudflare:email'; import { snsCron } from './sns_cron.js'; export default { async fetch(request, env, ctx) { const url = new URL(request.url); if (url.pathname.startsWith('/api/') || url.pathname.startsWith('/media/') || url.pathname === '/pixelstomotors' || url.pathname.startsWith('/pixelstomotors/')) { return secured(await route(request, env, url)); } const res = await env.ASSETS.fetch(request); // 検索エンジン・AI のクローラーが読みに来たか(記録の失敗でページを止めない) try { const bot = crawlerOf(request, url); if (bot) ctx.waitUntil(crRecord(request, env, url, bot, res.status).catch(() => {})); } catch { /* 数え損ねてもページは返す */ } return res; }, // 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))); }, }; async function route(request, env, url) { // 旧 URL の形(/pixelstomotors/…)で来たら、/pixelstomotors を外した URL へ恒久転送する if (url.pathname === '/pixelstomotors' || url.pathname.startsWith('/pixelstomotors/')) { const to = new URL(url.pathname.slice('/pixelstomotors'.length) || '/', url); to.search = url.search; return Response.redirect(to.toString(), 301); } if (url.pathname.startsWith('/media/')) return media(request, env, url); if (url.pathname === '/api/message') return guarded(request, env, url, 'RL_MESSAGE', () => message(request, env)); if (url.pathname === '/api/hk-event') return guarded(request, env, url, 'RL_EVENT', () => hkEvent(request, env)); if (url.pathname === '/api/ev') return guarded(request, env, url, 'RL_EVENT', () => evRecord(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(); return env.ASSETS.fetch(request); } const notFound = () => new Response('Not Found', { status: 404, headers: { 'x-robots-tag': 'noindex' } }); // worker が返す応答に、内容の種類を推測させない指定などを付ける(転送の応答は見出しを変えられないので作り直す) function secured(res) { const r = new Response(res.body, res); r.headers.set('x-content-type-options', 'nosniff'); r.headers.set('referrer-policy', 'strict-origin-when-cross-origin'); return r; } // このサイトのページから送られたものか(ブラウザは POST に Origin を付ける。付かないときは Sec-Fetch-Site で見る) function sameSite(request, url) { const o = request.headers.get('origin'); if (o) { try { return new URL(o).host === url.host; } catch { return false; } } return request.headers.get('sec-fetch-site') === 'same-origin'; } // 送信の口の共通の守り:POST 以外・ほかのサイトから・送りすぎを断ってから処理する async function guarded(request, env, url, limiter, handle) { if (request.method !== 'POST') return new Response('Method Not Allowed', { status: 405 }); if (!sameSite(request, url)) return new Response(null, { status: 403 }); const rl = env[limiter]; if (rl) { try { const { success } = await rl.limit({ key: request.headers.get('cf-connecting-ip') || 'unknown' }); if (!success) return new Response(null, { status: 429, headers: { 'retry-after': '60' } }); } catch { /* 流量制限が使えないときは通す(サイトの動きを止めない) */ } } return handle(); } // 数の表を見てよいか。鍵は ?key=… か Authorization: Bearer … で渡す。鍵が設定されていなければ誰にも見せない async function statsAllowed(request, env, url) { const key = env.STATS_KEY; if (!key) return false; const given = url.searchParams.get('key') || (request.headers.get('authorization') || '').replace(/^Bearer\s+/i, ''); if (!given) 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); } async function media(request, env, url) { if (request.method !== 'GET' && request.method !== 'HEAD') return new Response('Method Not Allowed', { status: 405 }); const key = decodeURIComponent(url.pathname.slice('/media/'.length)); if (!key || key.includes('/') || key.startsWith('.')) return new Response('Not Found', { status: 404 }); // 動画の途中から再生できるよう、Range(一部だけの取得)にも答える const obj = await env.MEDIA.get(key, { range: request.headers, onlyIf: request.headers }); if (obj === null) return new Response('Not Found', { status: 404 }); const headers = new Headers(); obj.writeHttpMetadata(headers); headers.set('etag', obj.httpEtag); headers.set('accept-ranges', 'bytes'); headers.set('cache-control', 'public, max-age=86400'); if (!('body' in obj) || !obj.body) return new Response(null, { status: 304, headers }); let status = 200; if (obj.range && request.headers.has('range')) { // R2 の range は {offset, length} か {suffix}。キーがあっても値が undefined のことがあるため、値で判定する // ('suffix' in r で判定すると NaN になり、ブラウザが動画を再生しなかった) const r = obj.range; let start, end; if (typeof r.suffix === 'number') { start = Math.max(0, obj.size - r.suffix); end = obj.size - 1; } else { start = typeof r.offset === 'number' ? r.offset : 0; end = typeof r.length === 'number' ? Math.min(obj.size - 1, start + r.length - 1) : obj.size - 1; } headers.set('content-range', `bytes ${start}-${end}/${obj.size}`); headers.set('content-length', String(end - start + 1)); status = 206; } return new Response(request.method === 'HEAD' ? null : obj.body, { status, headers }); } // メッセージフォームの「ご相談の種類」。値はフォーム(src/pages/message.astro)の option と同じ const TOPICS = { 'physical-ai': 'フィジカルAI・ロボット', ai: 'AI活用・仕組みづくり', media: '取材・登壇', other: 'その他' }; async function message(request, env) { if (request.method !== 'POST') return new Response('Method Not Allowed', { status: 405 }); let d; try { d = await request.json(); } catch { return json({ ok: false, error: '送信の形が正しくありません' }, 400); } // 人には見えない欄が埋まっていたら自動投稿。保存も通知もせず、受け取ったふりをする if (String(d.website || '').trim()) return json({ ok: true }); const name = String(d.name || '').trim().slice(0, 200); const email = String(d.email || '').trim().slice(0, 300); const body = String(d.body || '').trim().slice(0, 10000); // 相談の種類(フォームの選択肢と同じ値)。古いページから送られて種類がないときは「未選択」として受け取る const topic = Object.hasOwn(TOPICS, d.topic) ? d.topic : ''; if (!name || !email || !body || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) || d.agree !== true) { return json({ ok: false, error: '入力を確かめてください' }, 400); } const at = new Date().toISOString(); const key = `${at.replace(/[:.]/g, '-')}-${crypto.randomUUID().slice(0, 8)}.json`; await env.MESSAGES.put(key, JSON.stringify({ at, name, email, topic, body, ua: request.headers.get('user-agent') || '' }, null, 1), { httpMetadata: { contentType: 'application/json' } }); // 保存できたら、メールで知らせる。メールが送れなくても、保存は済んでいるので送信者には成功と返す try { await notify(env, { at, name, email, topic, body, key }); } catch (e) { console.error('メール通知に失敗', e && e.message); } return json({ ok: true }); } // 届いたメッセージを MAIL(Cloudflare の送信の仕組み)でエイイチのメールへ送る。 // 送り元はこのドメインの form@pixelstomotors.com。返信先(Reply-To)に送信者のメールを入れる async function notify(env, m) { if (!env.MAIL) return; const from = 'form@pixelstomotors.com'; const to = 'pixelstomotors@adaptdesign.jp'; const topicName = Object.hasOwn(TOPICS, m.topic) ? TOPICS[m.topic] : '未選択'; const subject = `[Pixels to Motors] ${topicName}: ${m.name}`; const text = [ `種類: ${topicName}`, `名前: ${m.name}`, `メール: ${m.email}`, `受け取り: ${m.at}`, `保存先: p2m-messages/${m.key}`, '', m.body, ].join('\n'); const raw = [ `From: Pixels to Motors <${from}>`, `To: <${to}>`, `Reply-To: ${encodeWord(m.name)} <${m.email}>`, `Subject: ${encodeWord(subject)}`, `Date: ${new Date(m.at).toUTCString()}`, `Message-ID: <${m.key.replace(/\.json$/, '')}@pixelstomotors.com>`, 'MIME-Version: 1.0', 'Content-Type: text/plain; charset=utf-8', 'Content-Transfer-Encoding: base64', '', base64(text), ].join('\r\n'); await env.MAIL.send(new EmailMessage(from, to, raw)); } // 日本語の見出しをメールの決まり(RFC 2047)で符号化する。見出しは1行に収めるので改行を入れない function encodeWord(s) { return `=?utf-8?B?${base64(s, false)}?=`; } // fold=true のとき、本文用に 76 文字ごとに改行を入れる(見出しに入れると途中で切れて壊れる) function base64(s, fold = true) { const bytes = new TextEncoder().encode(s); let bin = ''; for (const b of bytes) bin += String.fromCharCode(b); const out = btoa(bin); return fold ? out.replace(/(.{76})/g, '$1\r\n') : out; } function json(o, status = 200) { return new Response(JSON.stringify(o), { status, headers: { 'content-type': 'application/json; charset=utf-8' } }); } // ---------- 更新を知らせるハコブくんの効果の記録 ---------- // 1件 = 1つの空のファイル。中身は持たず、何が起きたかを付箋(customMetadata)に書く(一覧のときに中身を読まずに数えられる) // 残すのは「出た(shown)/押された(click)/押さずにあとで見に来た(later)」「どの更新か」「どのページで」「画面の幅(pc/sp)」「日付」だけ。 // IP アドレス・ブラウザの種類・Cookie・見た人を見分ける番号は残さない const HK_EVENTS = new Set(['shown', 'click', 'later']); const HK_KINDS = new Set(['record', 'manga', 'issues']); async function hkEvent(request, env) { if (request.method !== 'POST') return new Response('Method Not Allowed', { status: 405 }); let d; try { d = JSON.parse((await request.text()).slice(0, 2000)); } catch { return new Response(null, { status: 400 }); } const e = String(d.e || ''), kind = String(d.kind || ''); const id = String(d.id || '').slice(0, 160), path = String(d.path || '').slice(0, 160), w = d.w === 'sp' ? 'sp' : 'pc'; if (!HK_EVENTS.has(e) || !HK_KINDS.has(kind) || !/^(record|manga|issues):[\w.-]+$/.test(id) || !path.startsWith('/')) { return new Response(null, { status: 400 }); } const at = new Date(); const day = new Date(at.getTime() + 9 * 3600e3).toISOString().slice(0, 10); // 日本時間の日付 const key = `hk-events/${day}/${at.getTime()}-${crypto.randomUUID().slice(0, 8)}`; await env.MESSAGES.put(key, '', { customMetadata: { e, kind, id, path, w } }); return new Response(null, { status: 204 }); } async function hkStats(request, env, url) { if (request.method !== 'GET') return new Response('Method Not Allowed', { status: 405 }); const rows = []; let cursor; do { const r = await env.MESSAGES.list({ prefix: 'hk-events/', cursor, include: ['customMetadata'], limit: 1000 }); for (const o of r.objects) rows.push({ day: o.key.split('/')[1], ...(o.customMetadata || {}) }); cursor = r.truncated ? r.cursor : undefined; } while (cursor); const add = (map, k, ev) => { const v = map.get(k) || { shown: 0, click: 0, later: 0 }; v[ev] = (v[ev] || 0) + 1; map.set(k, v); return v; }; // 週は月曜はじまり(日本時間の日付から、その週の月曜の日付を出す) 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 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 data = { total: rows.length, byWeek: [...byWeek].sort((a, b) => b[0].localeCompare(a[0])).map(([week, v]) => { const end = new Date(week + 'T00:00:00Z'); end.setUTCDate(end.getUTCDate() + 6); return { week: `${week} 〜 ${end.toISOString().slice(5, 10)}`, ...v }; }), byDay: [...byDay].sort((a, b) => b[0].localeCompare(a[0])).map(([day, v]) => ({ day, ...v })), byItem: [...byItem].sort((a, b) => b[1].shown - a[1].shown).map(([id, v]) => ({ id, ...v })), byWidth: [...byW].map(([w, v]) => ({ w, ...v })), }; 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 pct = (v) => (v.shown ? `${Math.round(((v.click + v.later) / v.shown) * 100)}%` : '-'); const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); const table = (head, list, key) => `${ list.map((v) => ``).join('')}
${head}出た吹き出しを押した押さずにあとで見に来た見に行った割合
${esc(v[key])}${v.shown}${v.click}${v.later}${pct(v)}
`; const html = ` ハコブくんの知らせの記録

ハコブくんの知らせの記録

サイトの更新を知らせてハコブくんが走った回数と、そのあと見た人がどう動いたか。数だけを残しており、誰かは分からない。日付は日本時間。

週ごと(月曜はじまり。週が変わると新しい行が増える)

${table('週', data.byWeek, 'week')}

日ごと

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

更新ごと

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

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

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

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

`; return new Response(html, { headers: { 'content-type': 'text/html; charset=utf-8', 'x-robots-tag': 'noindex', 'cache-control': 'no-store' } }); } // ---------- サイトの使われ方の記録(src/components/SiteMeasure.astro が送る) ---------- // 1回のページ表示 = 1件。中身は持たず、付箋(customMetadata)に書く(一覧のときに中身を読まずに数えられる)。 // 同じ表示が戻ってきて送り直したときは n が増える。数えるときは v(その表示だけの番号)ごとに n が最大の1件を使う。 // IP アドレス・ブラウザの種類・Cookie は残さない。v はブラウザに保存されない、その表示だけの番号 const EV_AREAS = new Set(['hakobu', 'header', 'footer', 'crumbs', 'toc', 'consult', 'pager', 'related', 'body', 'main', 'sim', 'other']); const EV_DEPTHS = new Set([0, 25, 50, 75, 100]); const evInt = (x, max) => { const n = Math.floor(Number(x)); return Number.isFinite(n) && n >= 0 ? Math.min(n, max) : 0; }; // 自動で巡回するプログラムの名乗り(検索エンジン・AI のクローラー・SNS のプレビュー・自動のブラウザ)。名乗りそのものは保存しない const EV_BOT_UA = /bot\/|bot;|\bbot\b|crawl|spider|slurp|headless|lighthouse|preview|facebookexternalhit|embedly|python-|curl\/|wget|go-http|okhttp|axios|node-fetch|playwright|puppeteer|selenium|phantom/i; // 人か機械か:b=機械(自動のブラウザ・クローラーの名乗り)/h=人の操作があった/x=どちらとも言えない(操作なし)。2026-10-25 から const evKind = (request, d) => { const ua = request.headers.get('user-agent') || ''; let verified = false; try { verified = !!(request.cf && (request.cf.verifiedBotCategory || (request.cf.botManagement && request.cf.botManagement.verifiedBot))); } catch { /* 使えない環境では名乗りだけで見る */ } if (verified || !ua || EV_BOT_UA.test(ua) || Number(d.wd) === 1 || Number(d.hl) === 1) return 'b'; return Number(d.u) === 1 ? 'h' : 'x'; }; const evText = (x, max) => String(x == null ? '' : x).replace(/[\u0000-\u001f|>]/g, ' ').slice(0, max); async function evRecord(request, env, url) { if (request.method !== 'POST') return new Response('Method Not Allowed', { status: 405 }); // ほかのサイトから送られたもの・送りすぎは、guarded() で断っている let d; try { d = JSON.parse((await request.text()).slice(0, 6000)); } catch { return new Response(null, { status: 400 }); } const v = String(d.v || ''), p = String(d.p || ''); if (!/^[a-z0-9]{6,16}$/.test(v) || !p.startsWith('/') || p.length > 160) return new Response(null, { status: 400 }); const f = String(d.f || ''); const s = Number(d.s); const clicks = (Array.isArray(d.c) ? d.c : []).slice(0, 8) .filter((c) => Array.isArray(c) && EV_AREAS.has(String(c[1]))) .map((c) => `${evText(c[0], 100)}>${c[1]}`); let c = clicks.join('|'); if (c.length > 900) c = c.slice(0, 900); const meta = { v, n: String(evInt(d.n, 50)), p, f: f === '(戻る)' || (f.startsWith('/') && f.length <= 160) ? f : '', r: /^[a-z0-9.-]{1,80}$/i.test(String(d.r || '')) ? String(d.r).toLowerCase() : '', w: d.w === 'sp' ? 'sp' : 'pc', s: String(EV_DEPTHS.has(s) ? s : 0), t: String(evInt(d.t, 7200)), c, d: String(evInt(d.d, 999)), g: String(evInt(d.g, 999)), cp: String(evInt(d.cp, 999)), vp: String(evInt(d.vp, 999)), fs: String(evInt(d.fs, 1)), fx: String(evInt(d.fx, 99)), fv: String(evInt(d.fv, 99)), k: evKind(request, d), }; const at = new Date(); const day = new Date(at.getTime() + 9 * 3600e3).toISOString().slice(0, 10); // 日本時間の日付 await env.MESSAGES.put(`ev/${day}/${at.getTime()}-${v}-${meta.n}`, '', { customMetadata: meta }); return new Response(null, { status: 204 }); } // 週は月曜はじまり(日本時間の日付から、その週の月曜の日付を出す) const evWeekOf = (day) => { const d = new Date(day + 'T00:00:00Z'); d.setUTCDate(d.getUTCDate() - ((d.getUTCDay() + 6) % 7)); return d.toISOString().slice(0, 10); }; const evMedian = (a) => { if (!a.length) return 0; const b = [...a].sort((x, y) => x - y); const m = b.length >> 1; return b.length % 2 ? b[m] : Math.round((b[m - 1] + b[m]) / 2); }; const EV_AREA_JA = { hakobu: 'ハコブくん', header: 'ヘッダー', footer: 'フッター', crumbs: '現在地', toc: '目次', consult: '相談の案内', pager: '前後の記事', related: '関連記事', body: '本文', main: 'ページの中身', other: 'その他' }; async function evStats(request, env, url) { if (request.method !== 'GET') return new Response('Method Not Allowed', { status: 405 }); const since = /^\d{4}-\d{2}-\d{2}$/.test(url.searchParams.get('since') || '') ? url.searchParams.get('since') : ''; const week = /^\d{4}-\d{2}-\d{2}$/.test(url.searchParams.get('week') || '') ? url.searchParams.get('week') : ''; // who=human で人だけ、who=bot で機械だけ、who=unknown で操作のなかった表示だけ、who=old で印を付ける前の記録だけを数える(なければ全部) const WHO = { human: 'h', bot: 'b', unknown: 'x', old: 'o' }; const who = WHO[url.searchParams.get('who')] ? url.searchParams.get('who') : ''; // 1) 記録を読む(v ごとに n が最大の1件) const views = new Map(); let cursor, pages = 0, truncated = false; do { const r = await env.MESSAGES.list({ prefix: 'ev/', cursor, include: ['customMetadata'], limit: 1000, ...(since && !cursor ? { startAfter: `ev/${since}` } : {}) }); for (const o of r.objects) { const m = o.customMetadata || {}; if (!m.v) continue; const day = o.key.split('/')[1]; if (since && day < since) continue; const prev = views.get(m.v); if (!prev || Number(m.n) >= Number(prev.n)) views.set(m.v, { ...m, day }); } cursor = r.truncated ? r.cursor : undefined; if (++pages >= 40 && cursor) { truncated = true; break; } } while (cursor); // 2) メッセージフォームから実際に届いた数(p2m-messages の一番上に置かれた .json の数) const sent = new Map(); cursor = undefined; pages = 0; do { const r = await env.MESSAGES.list({ delimiter: '/', cursor, limit: 1000 }); for (const o of r.objects) { const m = o.key.match(/^(\d{4}-\d{2}-\d{2})T(\d{2})-(\d{2})-(\d{2})-(\d{3})Z/); if (!m) continue; const day = new Date(Date.UTC(+m[1].slice(0, 4), +m[1].slice(5, 7) - 1, +m[1].slice(8, 10), +m[2], +m[3], +m[4]) + 9 * 3600e3).toISOString().slice(0, 10); if (since && day < since) continue; const w = evWeekOf(day); sent.set(w, (sent.get(w) || 0) + 1); } cursor = r.truncated ? r.cursor : undefined; } while (cursor && ++pages < 10); // 3) 数える const rows = [...views.values()].map((m) => ({ ...m, week: evWeekOf(m.day), s: Number(m.s) || 0, t: Number(m.t) || 0, d: Number(m.d) || 0, g: Number(m.g) || 0, cp: Number(m.cp) || 0, vp: Number(m.vp) || 0, fs: Number(m.fs) || 0, fx: Number(m.fx) || 0, fv: Number(m.fv) || 0, k: m.k || 'o', clicks: m.c ? m.c.split('|').map((x) => { const i = x.lastIndexOf('>'); return { target: x.slice(0, i), area: x.slice(i + 1) }; }) : [], })); // 人と機械の内訳は、絞り込む前の全部の記録で週ごとに数える const kinds = new Map(); for (const r of rows) { const x = kinds.get(r.week) || { h: 0, b: 0, x: 0, o: 0 }; x[r.k]++; kinds.set(r.week, x); } const counted = who ? rows.filter((r) => r.k === WHO[who]) : rows; const isMsg = (p) => p === '/message' || p === '/message/'; const outside = (c) => c.area !== 'hakobu' && !c.target.startsWith('/') && !c.target.startsWith('#') && !c.target.startsWith('btn:') && !c.target.startsWith('(リンクなし)') && !c.target.startsWith('iframe'); const weeks = new Map(); for (const r of counted) { const w = weeks.get(r.week) || { week: r.week, views: 0, times: [], read75: 0, dead: 0, rage: 0, out: 0, back: 0, sp: 0, msgViews: 0, formStart: 0, formSubmit: 0, formValid: 0 }; w.views++; w.times.push(r.t); if (r.s >= 75) w.read75++; w.dead += r.d; w.rage += r.g; w.out += r.clicks.filter(outside).length; if (r.f === '(戻る)') w.back++; if (r.w === 'sp') w.sp++; if (isMsg(r.p)) { w.msgViews++; w.formStart += r.fs; w.formSubmit += r.fx; w.formValid += r.fv; } weeks.set(r.week, w); } for (const k of sent.keys()) if (!weeks.has(k)) weeks.set(k, { week: k, views: 0, times: [], read75: 0, dead: 0, rage: 0, out: 0, back: 0, sp: 0, msgViews: 0, formStart: 0, formSubmit: 0, formValid: 0 }); const pct = (a, b) => (b ? Math.round((a / b) * 100) : 0); const byWeek = [...weeks.values()].sort((a, b) => b.week.localeCompare(a.week)).map((w) => { const end = new Date(w.week + 'T00:00:00Z'); end.setUTCDate(end.getUTCDate() + 6); return { week: w.week, label: `${w.week} 〜 ${end.toISOString().slice(5, 10)}`, views: w.views, medianSeconds: evMedian(w.times), read75Pct: pct(w.read75, w.views), spPct: pct(w.sp, w.views), backViews: w.back, outClicks: w.out, deadClicks: w.dead, rageClicks: w.rage, messageViews: w.msgViews, formStarts: w.formStart, formSubmits: w.formSubmit, formSubmitsValid: w.formValid, messagesReceived: sent.get(w.week) || 0, humanViews: (kinds.get(w.week) || {}).h || 0, botViews: (kinds.get(w.week) || {}).b || 0, unknownViews: (kinds.get(w.week) || {}).x || 0, unmarkedViews: (kinds.get(w.week) || {}).o || 0, }; }); // ページごと・移り方・押したもの(week= を付けるとその週だけ。付けなければ全期間) const scope = week ? counted.filter((r) => r.week === week) : counted; const count = (map, k, add = 1) => map.set(k, (map.get(k) || 0) + add); const pagesMap = new Map(), moves = new Map(), refs = new Map(), targets = new Map(), areas = new Map(), dead = new Map(); for (const r of scope) { const pg = pagesMap.get(r.p) || { path: r.p, views: 0, times: [], s25: 0, s50: 0, s75: 0, s100: 0, dead: 0, rage: 0, copies: 0, plays: 0, out: 0 }; pg.views++; pg.times.push(r.t); if (r.s >= 25) pg.s25++; if (r.s >= 50) pg.s50++; if (r.s >= 75) pg.s75++; if (r.s >= 100) pg.s100++; pg.dead += r.d; pg.rage += r.g; pg.copies += r.cp; pg.plays += r.vp; pg.out += r.clicks.filter(outside).length; pagesMap.set(r.p, pg); if (r.f) count(moves, `${r.f} → ${r.p}`); count(refs, r.r || (r.f ? '(サイト内)' : '(直接・不明)')); for (const c of r.clicks) { count(areas, c.area); if (c.target.startsWith('(リンクなし)')) count(dead, `${r.p} ${c.target}`); else count(targets, `${c.target}|${EV_AREA_JA[c.area] || c.area}`); } } const top = (map, n, key) => [...map].sort((a, b) => b[1] - a[1]).slice(0, n).map(([k, v]) => ({ [key]: k, count: v })); const data = { total: rows.length, counted: counted.length, truncated, since: since || null, week: week || null, who: who || null, byWeek, byPage: [...pagesMap.values()].sort((a, b) => b.views - a.views).slice(0, 100).map((p) => ({ path: p.path, views: p.views, medianSeconds: evMedian(p.times), read25Pct: pct(p.s25, p.views), read50Pct: pct(p.s50, p.views), read75Pct: pct(p.s75, p.views), read100Pct: pct(p.s100, p.views), deadClicks: p.dead, rageClicks: p.rage, copies: p.copies, videoPlays: p.plays, outClicks: p.out, })), moves: top(moves, 50, 'move'), referrers: top(refs, 30, 'from'), clicks: top(targets, 60, 'target'), areas: [...areas].sort((a, b) => b[1] - a[1]).map(([a, n]) => ({ area: EV_AREA_JA[a] || a, count: n })), deadClicks: top(dead, 30, 'where'), }; 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 table = (cols, list) => list.length ? `
${cols.map(([, h]) => ``).join('')}${list.map((r) => `${cols.map(([k]) => ``).join('')}`).join('')}
${h}
${esc(r[k])}
` : '

まだ記録がありません。

'; const html = ` サイトの使われ方の記録

サイトの使われ方の記録

1回のページ表示ごとに、どこまで読んだか・何を押したか・迷ったかを数えたもの。誰かは分からない。日付は日本時間、週は月曜はじまり。${week ? `ページごと以下の表は ${esc(week)} の週だけ。` : 'ページごと以下の表は全期間。?week=週の月曜の日付 でその週だけになる。'}${truncated ? ' 記録が多すぎて一部だけ数えた(?since=日付 で期間を絞る)。' : ''}

人と機械:「人」はマウス・指・キーの操作があった表示、「機械」はクローラーや自動のブラウザの名乗り・印があった表示、「操作なし」はどちらとも言えない表示、「印なし」は 2026-10-25 より前の記録。いま数えているのは ${esc({ human: '人', bot: '機械', unknown: '操作なし', old: '印なし' }[who] || '全部')}(?who=human・bot・unknown・old で絞る。週ごとの表の「人」〜「印なし」の4列は常に全部の内訳)。

週ごと

${table([['label', '週'], ['views', '表示'], ['medianSeconds', '表示時間(中央値・秒)'], ['read75Pct', '75%以上読んだ(%)'], ['spPct', 'スマホ(%)'], ['backViews', '戻るで戻った'], ['outClicks', '外へのクリック'], ['deadClicks', 'リンクなしを押した'], ['rageClicks', '連打'], ['messageViews', 'フォームの表示'], ['formStarts', '入力を始めた'], ['formSubmits', '送信を押した'], ['formSubmitsValid', 'うち入力がそろっていた'], ['messagesReceived', '届いたメッセージ'], ['humanViews', '人'], ['botViews', '機械'], ['unknownViews', '操作なし'], ['unmarkedViews', '印なし']], byWeek)}

ページごと

${table([['path', 'ページ'], ['views', '表示'], ['medianSeconds', '表示時間(中央値・秒)'], ['read25Pct', '25%'], ['read50Pct', '50%'], ['read75Pct', '75%'], ['read100Pct', '最後まで'], ['deadClicks', 'リンクなしを押した'], ['rageClicks', '連打'], ['copies', 'コピー'], ['videoPlays', '動画の再生'], ['outClicks', '外へのクリック']], data.byPage)}

ページからページへの移り方

${table([['move', '移り方'], ['count', '回数']], data.moves)}

どこから来たか

${table([['from', '来たところ'], ['count', '回数']], data.referrers)}

押したリンク・ボタン(行き先|ページのどの部分か)

${table([['target', '押したもの'], ['count', '回数']], data.clicks)}

押した場所(ページのどの部分か)

${table([['area', '部分'], ['count', '回数']], data.areas)}

リンクでない所を押した(ページと、押したものの種類)

${table([['where', '場所'], ['count', '回数']], data.deadClicks)}

記録の件数(ページ表示の数):${data.total}

`; return new Response(html, { headers: { 'content-type': 'text/html; charset=utf-8', 'x-robots-tag': 'noindex', 'cache-control': 'no-store' } }); } // ───────── 検索エンジン・AI のクローラーの訪問(2026-10-25)───────── // ページ上の計測(/api/ev)は JavaScript を動かさないクローラーを数えられないので、配信のときに名乗りで数える。 // 残すのは「どのクローラーか・どのページか・返した結果(200 など)・Cloudflare が本物と確かめたか・日時」だけ。名乗りそのものは保存しない。 // 名乗りはまねできるので、「確かめた」が 0 のものは偽物が混ざりうる(Cloudflare が判定を渡さない環境では ? になる) const CRAWLERS = [ ['GPTBot', 'OpenAI(学習)'], ['OAI-SearchBot', 'OpenAI(ChatGPT の検索)'], ['ChatGPT-User', 'ChatGPT(利用者の依頼で読む)'], ['ClaudeBot', 'Anthropic(学習)'], ['Claude-SearchBot', 'Anthropic(Claude の検索)'], ['Claude-User', 'Claude(利用者の依頼で読む)'], ['anthropic-ai', 'Anthropic'], ['PerplexityBot', 'Perplexity(検索)'], ['Perplexity-User', 'Perplexity(利用者の依頼で読む)'], ['Google-CloudVertexBot', 'Google(Vertex AI)'], ['GoogleOther', 'Google(その他)'], ['Googlebot', 'Google(検索)'], ['bingbot', 'Bing(検索。ChatGPT の検索・Copilot も使う)'], ['DuckAssistBot', 'DuckDuckGo(AI の回答)'], ['DuckDuckBot', 'DuckDuckGo(検索)'], ['Applebot', 'Apple(Siri・検索・AI)'], ['Amazonbot', 'Amazon(Alexa・AI)'], ['meta-externalagent', 'Meta(AI)'], ['FacebookBot', 'Meta'], ['Bytespider', 'ByteDance(AI)'], ['CCBot', 'Common Crawl(多くの AI の学習データ)'], ['MistralAI-User', 'Mistral(利用者の依頼で読む)'], ['cohere-ai', 'Cohere'], ['Diffbot', 'Diffbot'], ['YouBot', 'You.com'], ['YandexBot', 'Yandex(検索)'], ['Baiduspider', 'Baidu(検索)'], ['Yeti', 'Naver(検索)'], ]; // 数えるのはページ・テキストだけ(画像・CSS・JS は wrangler.jsonc の run_worker_first で外している) const CR_PAGE = /(\/|\/[^/.]*|\.html|\.md|\.txt|\.xml|\.json)$/i; function crawlerOf(request, url) { if (request.method !== 'GET' && request.method !== 'HEAD') return null; if (!CR_PAGE.test(url.pathname)) return null; const ua = (request.headers.get('user-agent') || '').toLowerCase(); if (!ua) return null; for (const [name] of CRAWLERS) if (ua.includes(name.toLowerCase())) return name; return null; } async function crRecord(request, env, url, bot, status) { let ok = '?'; try { const cf = request.cf || {}; if ('verifiedBotCategory' in cf) ok = cf.verifiedBotCategory ? '1' : '0'; else if (cf.botManagement && 'verifiedBot' in cf.botManagement) ok = cf.botManagement.verifiedBot ? '1' : '0'; } 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 }; await env.MESSAGES.put(`cr/${day}/${at.getTime()}-${Math.random().toString(36).slice(2, 8)}`, '', { customMetadata: meta }); } async function crStats(request, env, url) { if (request.method !== 'GET') return new Response('Method Not Allowed', { status: 405 }); const since = /^\d{4}-\d{2}-\d{2}$/.test(url.searchParams.get('since') || '') ? url.searchParams.get('since') : ''; const only = url.searchParams.get('bot') || ''; const rows = []; let cursor, pages = 0, truncated = false; do { const r = await env.MESSAGES.list({ prefix: 'cr/', cursor, include: ['customMetadata'], limit: 1000, ...(since && !cursor ? { startAfter: `cr/${since}` } : {}) }); for (const o of r.objects) { const m = o.customMetadata || {}; if (!m.b) continue; const day = o.key.split('/')[1]; if (since && day < since) continue; rows.push({ ...m, day, week: evWeekOf(day) }); } cursor = r.truncated ? r.cursor : undefined; if (++pages >= 40 && cursor) { truncated = true; break; } } while (cursor); const label = Object.fromEntries(CRAWLERS); const count = (map, k) => map.set(k, (map.get(k) || 0) + 1); // クローラーごと(全期間) const bots = new Map(); for (const r of rows) { const b = bots.get(r.b) || { bot: r.b, who: label[r.b] || r.b, visits: 0, pages: new Set(), records: new Set(), verified: 0, unverified: 0, errors: 0, last: '' }; b.visits++; b.pages.add(r.p); if (r.p.startsWith('/record/') || r.p.startsWith('/files/')) b.records.add(r.p.split('/').slice(0, 3).join('/')); if (r.ok === '1') b.verified++; else if (r.ok === '0') b.unverified++; if (Number(r.s) >= 400) b.errors++; if (r.day > b.last) b.last = r.day; bots.set(r.b, b); } // 週ごと × クローラー const weeks = new Map(); for (const r of rows) { const w = weeks.get(r.week) || new Map(); count(w, r.b); weeks.set(r.week, w); } // ページごと(?bot= でそのクローラーだけ) 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); } const data = { total: rows.length, truncated, since: since || null, bot: only || null, byBot: [...bots.values()].sort((a, b) => b.visits - a.visits).map((b) => ({ bot: b.bot, who: b.who, visits: b.visits, pages: b.pages.size, records: b.records.size, verified: b.verified, unverified: b.unverified, errors: b.errors, last: b.last, })), 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('、') })), }; 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 table = (cols, list) => list.length ? `
${cols.map(([, h]) => ``).join('')}${list.map((r) => `${cols.map(([k]) => ``).join('')}`).join('')}
${h}
${esc(r[k])}
` : '

まだ記録がありません。

'; const html = ` クローラーの訪問の記録

クローラーの訪問の記録

検索エンジン・AI のクローラーが、どのページを読みに来たか(2026-10-25 から)。名乗りで見分けているので、「Cloudflare が確かめた」が 0 のものは偽物が混ざりうる。日付は日本時間、週は月曜はじまり。?since=日付 で期間を、?bot=名前(例 GPTBot)でページごとの表をそのクローラーだけに絞る。${truncated ? ' 記録が多すぎて一部だけ数えた。' : ''}

クローラーごと

${table([['bot', '名前'], ['who', 'だれの・何のため'], ['visits', '読んだ回数'], ['pages', 'ページの数'], ['records', '記録(LOG)の数'], ['verified', 'Cloudflare が確かめた'], ['unverified', '確かめられなかった'], ['errors', '見つからない等(400以上)'], ['last', '最後に来た日']], data.byBot)}

週ごと

${table([['week', '週(月曜)'], ['total', '合計'], ['bots', '内訳']], data.byWeek)}

よく読まれたページ${only ? `(${esc(only)} だけ)` : ''}

${table([['path', 'ページ'], ['visits', '回数'], ['bots', '読みに来たクローラー']], data.byPage)}

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

`; return new Response(html, { headers: { 'content-type': 'text/html; charset=utf-8', 'x-robots-tag': 'noindex', 'cache-control': 'no-store' } }); }