// サイトの入口。ほとんどのページは作っておいた静的ファイル（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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
  const table = (head, list, key) => `<table><tr><th>${head}</th><th>出た</th><th>吹き出しを押した</th><th>押さずにあとで見に来た</th><th>見に行った割合</th></tr>${
    list.map((v) => `<tr><td>${esc(v[key])}</td><td>${v.shown}</td><td>${v.click}</td><td>${v.later}</td><td>${pct(v)}</td></tr>`).join('')}</table>`;
  const html = `<!doctype html><html lang="ja"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="robots" content="noindex">
<title>ハコブくんの知らせの記録</title><style>body{font:14px/1.7 -apple-system,'Noto Sans JP',sans-serif;margin:24px;color:#242424;background:#f7f7f7}
h1{font-size:20px}h2{font-size:16px;margin-top:28px}table{border-collapse:collapse;background:#fff;min-width:min(100%,640px)}th,td{border:1px solid #ddd;padding:6px 10px;text-align:left}th{background:#eee}p{color:#555}</style></head><body>
<h1>ハコブくんの知らせの記録</h1><p>サイトの更新を知らせてハコブくんが走った回数と、そのあと見た人がどう動いたか。数だけを残しており、誰かは分からない。日付は日本時間。</p>
<h2>週ごと（月曜はじまり。週が変わると新しい行が増える）</h2>${table('週', data.byWeek, 'week')}
<h2>日ごと</h2>${table('日付', data.byDay, 'day')}
<h2>更新ごと</h2>${table('更新', data.byItem, 'id')}
<h2>画面の幅（pc＝広い／sp＝スマホ）</h2>${table('幅', data.byWidth, 'w')}
<p>記録の件数：${data.total}</p></body></html>`;
  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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[ch]));
  const table = (cols, list) => list.length
    ? `<div class="wrap"><table><tr>${cols.map(([, h]) => `<th>${h}</th>`).join('')}</tr>${list.map((r) => `<tr>${cols.map(([k]) => `<td>${esc(r[k])}</td>`).join('')}</tr>`).join('')}</table></div>`
    : '<p>まだ記録がありません。</p>';
  const html = `<!doctype html><html lang="ja"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="robots" content="noindex">
<title>サイトの使われ方の記録</title><style>body{font:14px/1.7 -apple-system,'Noto Sans JP',sans-serif;margin:24px;color:#242424;background:#f7f7f7}
h1{font-size:20px}h2{font-size:16px;margin-top:28px}.wrap{overflow-x:auto}table{border-collapse:collapse;background:#fff}th,td{border:1px solid #ddd;padding:6px 10px;text-align:left;white-space:nowrap}th{background:#eee}p{color:#555}</style></head><body>
<h1>サイトの使われ方の記録</h1><p>1回のページ表示ごとに、どこまで読んだか・何を押したか・迷ったかを数えたもの。誰かは分からない。日付は日本時間、週は月曜はじまり。${week ? `ページごと以下の表は ${esc(week)} の週だけ。` : 'ページごと以下の表は全期間。?week=週の月曜の日付 でその週だけになる。'}${truncated ? ' 記録が多すぎて一部だけ数えた（?since=日付 で期間を絞る）。' : ''}</p>
<p>人と機械：「人」はマウス・指・キーの操作があった表示、「機械」はクローラーや自動のブラウザの名乗り・印があった表示、「操作なし」はどちらとも言えない表示、「印なし」は 2026-10-25 より前の記録。いま数えているのは <b>${esc({ human: '人', bot: '機械', unknown: '操作なし', old: '印なし' }[who] || '全部')}</b>（?who=human・bot・unknown・old で絞る。週ごとの表の「人」〜「印なし」の4列は常に全部の内訳）。</p>
<h2>週ごと</h2>${table([['label', '週'], ['views', '表示'], ['medianSeconds', '表示時間（中央値・秒）'], ['read75Pct', '75％以上読んだ（％）'], ['spPct', 'スマホ（％）'], ['backViews', '戻るで戻った'], ['outClicks', '外へのクリック'], ['deadClicks', 'リンクなしを押した'], ['rageClicks', '連打'], ['messageViews', 'フォームの表示'], ['formStarts', '入力を始めた'], ['formSubmits', '送信を押した'], ['formSubmitsValid', 'うち入力がそろっていた'], ['messagesReceived', '届いたメッセージ'], ['humanViews', '人'], ['botViews', '機械'], ['unknownViews', '操作なし'], ['unmarkedViews', '印なし']], byWeek)}
<h2>ページごと</h2>${table([['path', 'ページ'], ['views', '表示'], ['medianSeconds', '表示時間（中央値・秒）'], ['read25Pct', '25％'], ['read50Pct', '50％'], ['read75Pct', '75％'], ['read100Pct', '最後まで'], ['deadClicks', 'リンクなしを押した'], ['rageClicks', '連打'], ['copies', 'コピー'], ['videoPlays', '動画の再生'], ['outClicks', '外へのクリック']], data.byPage)}
<h2>ページからページへの移り方</h2>${table([['move', '移り方'], ['count', '回数']], data.moves)}
<h2>どこから来たか</h2>${table([['from', '来たところ'], ['count', '回数']], data.referrers)}
<h2>押したリンク・ボタン（行き先｜ページのどの部分か）</h2>${table([['target', '押したもの'], ['count', '回数']], data.clicks)}
<h2>押した場所（ページのどの部分か）</h2>${table([['area', '部分'], ['count', '回数']], data.areas)}
<h2>リンクでない所を押した（ページと、押したものの種類）</h2>${table([['where', '場所'], ['count', '回数']], data.deadClicks)}
<p>記録の件数（ページ表示の数）：${data.total}</p></body></html>`;
  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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[ch]));
  const table = (cols, list) => list.length
    ? `<div class="wrap"><table><tr>${cols.map(([, h]) => `<th>${h}</th>`).join('')}</tr>${list.map((r) => `<tr>${cols.map(([k]) => `<td>${esc(r[k])}</td>`).join('')}</tr>`).join('')}</table></div>`
    : '<p>まだ記録がありません。</p>';
  const html = `<!doctype html><html lang="ja"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="robots" content="noindex">
<title>クローラーの訪問の記録</title><style>body{font:14px/1.7 -apple-system,'Noto Sans JP',sans-serif;margin:24px;color:#242424;background:#f7f7f7}
h1{font-size:20px}h2{font-size:16px;margin-top:28px}.wrap{overflow-x:auto}table{border-collapse:collapse;background:#fff}th,td{border:1px solid #ddd;padding:6px 10px;text-align:left;white-space:nowrap}th{background:#eee}p{color:#555}</style></head><body>
<h1>クローラーの訪問の記録</h1><p>検索エンジン・AI のクローラーが、どのページを読みに来たか（2026-10-25 から）。名乗りで見分けているので、「Cloudflare が確かめた」が 0 のものは偽物が混ざりうる。日付は日本時間、週は月曜はじまり。?since=日付 で期間を、?bot=名前（例 GPTBot）でページごとの表をそのクローラーだけに絞る。${truncated ? ' 記録が多すぎて一部だけ数えた。' : ''}</p>
<h2>クローラーごと</h2>${table([['bot', '名前'], ['who', 'だれの・何のため'], ['visits', '読んだ回数'], ['pages', 'ページの数'], ['records', '記録（LOG）の数'], ['verified', 'Cloudflare が確かめた'], ['unverified', '確かめられなかった'], ['errors', '見つからない等（400以上）'], ['last', '最後に来た日']], data.byBot)}
<h2>週ごと</h2>${table([['week', '週（月曜）'], ['total', '合計'], ['bots', '内訳']], data.byWeek)}
<h2>よく読まれたページ${only ? `（${esc(only)} だけ）` : ''}</h2>${table([['path', 'ページ'], ['visits', '回数'], ['bots', '読みに来たクローラー']], data.byPage)}
<p>記録の件数：${data.total}</p></body></html>`;
  return new Response(html, { headers: { 'content-type': 'text/html; charset=utf-8', 'x-robots-tag': 'noindex', 'cache-control': 'no-store' } });
}
