売る前に、自分のサイトで2か月測る ― どこまで読み、何を押したか

Cloudflare(サイトを配信し、データも置ける仕組み)と Claude(AI)を組み合わせて、Web の仕事を受けられないか。その案内のページ(LP)を作ろうとして、作るのをやめました。売り物の効果を、まだ自分で一度も測っていなかったからです。 代わりに、このサイトを2か月かけて改善しながら数字を取り、それを見てから売るかどうかを決めることにしました。この記事は、その最初の一歩として、サイトの使われ方を数える仕組みを入れた記録です。

売りたかったのは「サイトを安く作れます」ではありません。AI と一緒に、数字を読み、改善案を出し、直して公開し、また数字を読む。この繰り返しを速く回して、集客や仕事の獲得につなげる仕組みです。ところが、それを言えるだけの材料が手元にありませんでした。どう直したら、どの数字が、どれだけ動いたのか。人の手間はどれだけ減ったのか。どれも答えられません。本人の言葉ではこうです。

まだ現行のサイト改善のデータ取得も出来ていないし、どのように改善してどのような結果が得られるようになって、実際どのくらい業務が楽になり、かつ今までより効果的なマーケティング施策ができる仕組みなのかが分かってから着手したいですね。

測ろうとして、すぐに一つ足りないものが見つかりました。このサイトにはすでに Cloudflare のアクセス解析を入れています。これで分かるのは、ページが何回見られたか、どこから来たか、スマホかパソコンか、といった数です。読んだ人が記事をどこまで読んだのか、どのリンクを押したのか、押せない所を押して迷っていないか、までは分かりません。改善を回すには、そこが見えないと始まりません。

そこで、その足りない数を自前で数える仕組みを作りました。ページを1回開くごとに、読んだ深さ・表示していた時間・押したリンクやボタン・リンクでない所を押した回数などを1件にまとめ、Cloudflare の中に残します。入力した内容、IP アドレス、Cookie は残しません。誰が見たかは分からない形です。

決めたこと・やったこと中身結果
案内のページ(LP)と営業を止める先に自分のサイトで効果を測る2か月後(12月中旬)に、売り物として成り立つかを判断する
測る3つの数字を決める改善の効果/手間がどれだけ減るか/狙いどおりに数字が動いた改善の割合判断の基準は、改善を始める前(10月末)までに決める
足りない数を自前で数える読んだ深さ・押したもの・迷い・ページの移り方・フォーム依頼から公開まで約10分。人が手を動かしたのは0分
自分の閲覧を数から外す特別な住所を一度開くと、そのブラウザからは送らないMac と iPhone で設定した。設定できたかを画面で確かめられるようにした
個人を追わない入力内容・IP・Cookie を残さない。番号はその1回の表示限りプライバシーポリシーに追記した

数える項目は、最初は広く取っておき、データがたまってから見るものを絞ります。これも本人の提案です。何が改善に効くかは、まだ分からないからです。

ここから下は作業ログです。AI(Claude)とのやり取りで決まったこと、入れたコードの全量、手元と本番で確かめたときのコマンドと出力を、作業した順に載せています。

作業ログ ― 相談は「Cloudflare と Claude の Web の仕事を取る LP を作りたい」だった

始まりはこの依頼でした。

Cluadeflareとclaudeを使ったWEBの仕組みの業務を獲得するためののLPを作成したいです。

どのように進めるのがいいか?(営業をどうするか、案件獲得のための他の方法)よく考えてください。

Claude が最初に返したのは「LP は最後に作る」という案です。LP は人を集める道具ではなく、興味を持った人を申し込みまで運ぶ1枚です。なので先に、誰に何を売るかを、付き合いのある相手に話して確かめる。次に、自分のサイトを移した経験を数字つきの事例にする。LP はその後、という順番でした。売り物の中身としては「Studio や WordPress の月額が要らなくなる」「更新を人に頼まなくていい」を挙げていました。

この売り物の捉え方を、本人が直しました。

Cluadeflareとclaudeの組み合わせのメリットはWEBの制作、運用を簡単にできると言うことではなくて、マーケティングやブランディング含めた提案や改修、データ分析、マーケティング支援まで広くclaudeと協働しながらWEBを変えながら、業務改善や案件獲得に繋げられる仕組みを提供できることだと思っています。

「安く作れる」は、すぐに値下げの競争になります。「数字を読み、提案し、直し、また測る」を速く回し続けられることは、一度作って終わりの制作とは別の売り物です。Cloudflare を使う理由もここで決まりました。公開・アクセス解析・フォーム・データの置き場所が一か所にまとまっているので、AI が「数字を読む → 直す → 公開する → 次の数字を読む」を途切れずに回せる。そうなると、証明に要るのは「費用が下がった数字」ではなく「成果が動いた数字」です。

そして冒頭に引いた一言で、LP も営業も、測ってからにすると決まりました。

測る3つの数字と、2か月で判断する期間

Claude が出した期間の案は「基準値に4週間、改善に8週間、3か月後に判断」でした。本人はこう返しています。

2ヶ月で数字を判断に変更。 それ以外はOK記録の型を作っておいてください。

そこで、2か月に収まるように組み直しました。

段階期間やること
基準値10/18〜10/31(2週間)改善はしない。数字だけ取る
改善11/1〜12/12(6週間)週1回、改善を1回まわす(1回1時間まで)
判断12/13〜12/183つの数字を見て、売り物として成り立つかを決める

測る数字は3つです。

  1. 改善の効果:検索での表示回数とクリック数、ページごとの閲覧数、記事からフォームや動画へ進んだ数
  2. 手間:1回の改善(数字を読む → 改善案 → 実装 → 公開)にかかった時間と、そのうち人が手を動かした時間
  3. 施策の質:出した改善案のうち、狙いどおりに数字が動いたものの割合

1回の改善を1時間までにしたのは、研究の時間を食わないためです。このプロジェクトは、ロボットの学習と推論の研究が本体です。サイトの改善はその外側の作業なので、上限を決めておかないと研究が止まります。その上限の中で回せるかどうか自体が、「手間がどれだけ減るか」の答えにもなります。

判断の基準(何がどうなったら「成り立つ」と言うか)は、改善を始める前の10月末までに決めます。始めてから決めると、出た数字に合わせて基準を動かしてしまうからです。

記録の型(週ごとの数字の表と、改善1回ごとの記録欄)は、プロジェクトの文書 claude/web_improvement_trial.md に置きました。

Cloudflare のアクセス解析で取れる数と、取れない数

記録の型を作る途中で、Claude はこう書いていました。

Cloudflare Web Analytics は、ページの閲覧数・流入元・端末などは分かります。一方で、ボタンのクリックや、どこまで読んだかは取れないはずです。「迷いの分析」まで見るなら、フォーム送信や主要なリンクのクリックを数える仕組みを自前で足す必要があります。

本人の返事は短いものでした。

この辺も必要そうです。同時に考えてください。

見るもの取れる場所
検索の表示回数・クリック数・平均順位Google Search Console
訪問数・閲覧数・流入元・端末Cloudflare Web Analytics
どこまで読んだか・表示していた時間自前で数える
押したリンク・ボタン(行き先とページのどの部分か)自前で数える
迷い(リンクでない所を押した・連打・戻るボタンで戻った)自前で数える
ページからページへの移り方自前で数える
フォーム(入力を始めた・送信を押した・実際に届いた)自前で数える

数える項目について、Claude は最初、主要なリンク5種類と読了とフォームに絞る案を出していました。これに本人が返したのが次の一言です。

何を数えるか 最初は広くとっておいてデータがしっかり取れてきた頃に絞っていく形はどう? 計測の実装勧めて

広く取っても、後から絞ることはできます。でも、取らなかった数は後から取り戻せません。改善の候補がまだ分からない段階では、この順番が理にかなっています。

記録する項目の一覧 ― 1回のページ表示を1件にまとめる

作りとしては、ページを開くたびに番号を1つ作り、その表示の間に起きたことを1件にためていきます。ページが見えなくなったとき(閉じる・別のページへ移る・タブを切り替える)に、まとめてサイトの裏側(Cloudflare Workers)へ送ります。受け取った側は、Cloudflare R2(ファイルを置く場所)に1件ずつ残します。

下の図は、その1件がどうたまって、どう送られるかを動かして見るものです。ページの上で操作をすると、送られる中身が増えていきます。

送る中身は次のとおりです。

項目中身例
vその1回の表示だけの番号(ブラウザに保存しない)abc123def456
n同じ表示で何回目の送信か(戻ってきてまた閉じると増える)0
p表示したページ/record/xxx
fサイト内のどのページから来たか。戻るボタンで戻ったときは (戻る)/
r外のサイトから来たときの、そのサイトの名前(ドメイン)だけx.com
w画面の幅(600px 未満なら sp、それ以上は pc)sp
sどこまで読んだか(0・25・50・75・100%)75
tページが見えていた時間(秒)42
c押したリンク・ボタン(行き先とページのどの部分か、8件まで)["/message?topic=ai","consult"]
dリンクでない所を押した回数1
g0.8秒以内に同じ所を3回押した(連打)回数0
cp文章をコピーした回数(中身は送らない)1
vp動画を再生した回数1
fsフォームに入力を始めたか1
fx・fv送信ボタンを押した回数・そのうち入力がそろっていた回数2・1

送らないものも決めました。入力した内容、コピーした文章、IP アドレス、ブラウザの種類、Cookie です。番号 v はその表示のためだけに作り、ブラウザに保存しません。なので、同じ人が別のページを開いても、次の日にまた来ても、つながりません。ブラウザで「トラッキングしない」(Do Not Track・Global Privacy Control)を設定している人からは、何も送りません。

「ページのどの部分か」は、サイトの部品の名前で決めています。本文、目次、記事の末尾の相談の案内、前後の記事、関連記事、ヘッダー、フッター、ハコブくん(サイトの更新を知らせて走るキャラクター)です。これがあると、「相談の案内から来た人がフォームを開いた」と「ヘッダーのボタンから開いた」を分けて数えられます。

記録の部品 SiteMeasure.astro の全量

全ページ共通の枠(src/layouts/Base.astro)に入れた部品です。この記事を書く時点の最新の形を載せています(あとの節で直した2か所も入っています)。

---
// サイトの使われ方の記録(2026-10-18)。Web改善の検証(Project の claude/web_improvement_trial.md)のため、
// Cloudflare Web Analytics では取れない「どこまで読んだか・何を押したか・どこで迷ったか」を数える。
// 最初は広く取り、データがたまってから見るものを絞る方針。
//
// 1回のページ表示 = 1件。ページが見えなくなったとき(閉じる・移る・タブを切り替える)に、まとめて /api/ev へ送る。
// 戻ってきてまた見えなくなったら、同じ番号で中身を足して送り直す(数えるときは番号ごとに最後の1件だけ使う)。
//
// 送るもの:ページ・サイト内のどのページから来たか(戻るボタンで戻ったか)・外から来たならそのサイトの名前(ドメイン)だけ・
// 画面の幅(pc/sp)・どこまで読んだか・表示していた時間・押したリンクやボタン(行き先とページのどの部分か)・
// リンクでない所を押した回数・短い間に同じ所を何度も押した回数・コピーした回数・動画を再生した回数・
// フォームに入力を始めたか・送信ボタンを押した回数
// 送らないもの:入力した内容・コピーした文章・IP アドレス・ブラウザの種類・Cookie。
// 番号はこの1回の表示のためだけに作り、ブラウザに保存しない(別のページや次の来訪とはつながらない)。
//
// 送らない場合:
// - 「トラッキングしないで」(Do Not Track / Global Privacy Control)を設定したブラウザ
// - エイイチ自身の閲覧:どれかのページを ?p2m-notrack=1 を付けて開くと、そのブラウザでは以後送らない(=0 で元に戻す。=check で今の設定を表示)
// 数は /api/ev-stats(表)・/api/ev-stats?format=json で見る
---
<script>
  type Click = [string, string];
  const OPT = 'p2m-notrack';
  let off = false;
  let q: string | null = null;
  let saved = true;
  try {
    q = new URLSearchParams(location.search).get(OPT);
    if (q === '1') localStorage.setItem(OPT, '1');
    if (q === '0') localStorage.removeItem(OPT);
    off = localStorage.getItem(OPT) === '1';
  } catch { saved = false; /* 覚えられない環境では、送るかどうかを毎回の判定だけで決める */ }
  const nav = navigator as Navigator & { globalPrivacyControl?: boolean };
  const dnt = nav.doNotTrack === '1' || nav.globalPrivacyControl === true;
  if (dnt) off = true;
  // ?p2m-notrack=1 / 0 / check で開いたときだけ、このブラウザが今どちらの設定かを画面の上に6秒出す(設定できたかの確認用)
  if (q === '1' || q === '0' || q === 'check') {
    const note = document.createElement('div');
    note.textContent = dnt ? 'このブラウザは「トラッキングしない」設定のため記録しません'
      : !saved ? 'このブラウザでは設定を覚えられません(プライベートブラウズなど)'
      : off ? 'このブラウザの閲覧は記録しない設定です' : 'このブラウザの閲覧は記録する設定です';
    note.setAttribute('role', 'status');
    note.style.cssText = 'position:fixed;top:64px;left:50%;transform:translateX(-50%);z-index:100;width:max-content;max-width:calc(100% - 32px);padding:10px 16px;border-radius:8px;background:#242424;color:#fff;font:500 14px/1.5 sans-serif;box-shadow:0 4px 12px rgba(0,0,0,.2)';
    const show = () => { document.body.appendChild(note); setTimeout(() => note.remove(), 6000); };
    if (document.body) show(); else document.addEventListener('DOMContentLoaded', show);
  }

  if (!off) {
    const newId = () => {
      try { return crypto.randomUUID().replace(/-/g, '').slice(0, 12); } catch { return Math.random().toString(36).slice(2, 14); }
    };
    // サイト内のどこから来たか。外から来たときは、そのサイトの名前(ドメイン)だけ
    const origin = () => {
      try {
        if (!document.referrer) return { f: '', r: '' };
        const u = new URL(document.referrer);
        if (u.host === location.host) return { f: u.pathname.slice(0, 160), r: '' };
        return { f: '', r: u.hostname.slice(0, 80) };
      } catch { return { f: '', r: '' }; }
    };
    const fresh = (from?: string) => {
      const o = origin();
      return {
        v: newId(), n: 0, p: location.pathname.slice(0, 160), f: from ?? o.f, r: from ? '' : o.r,
        w: window.innerWidth < 600 ? 'sp' : 'pc', s: 0, t: 0, c: [] as Click[],
        d: 0, g: 0, cp: 0, vp: 0, fs: 0, fx: 0, fv: 0,
      };
    };
    let st = fresh();
    let visibleSince = document.hidden ? 0 : Date.now();
    let dirty = true;

    // どこまで読んだか(25%刻み)。画面に収まるページは最後まで読んだ扱い
    const depth = () => {
      const h = document.documentElement.scrollHeight;
      if (!h) return;
      const ratio = Math.min(1, (window.scrollY + window.innerHeight) / h);
      const b = ratio >= 0.98 ? 100 : Math.floor(ratio * 4) * 25;
      if (b > st.s) { st.s = b; dirty = true; }
    };
    window.addEventListener('scroll', depth, { passive: true });
    window.addEventListener('load', depth);
    setTimeout(depth, 1500);

    // ページのどの部分か
    const AREAS: [string, string][] = [
      ['.hk-parade', 'hakobu'], ['.site-header', 'header'], ['.site-footer', 'footer'], ['.crumbs', 'crumbs'],
      ['.toc', 'toc'], ['.consult', 'consult'], ['.pager', 'pager'], ['.related', 'related'], ['.richText', 'body'], ['main', 'main'],
    ];
    const areaOf = (el: Element) => {
      const tagged = el.closest('[data-ev-area]') as HTMLElement | null;
      if (tagged && tagged.dataset.evArea) return tagged.dataset.evArea.slice(0, 20);
      for (const [sel, name] of AREAS) if (el.closest(sel)) return name;
      return 'other';
    };
    const addClick = (target: string, area: string) => {
      if (st.c.length < 8) st.c.push([target.slice(0, 100), area]);
      dirty = true;
    };
    // ボタンの文字(アイコンの文字 .ms は除く)
    const textOf = (el: Element) => {
      const aria = el.getAttribute('aria-label');
      if (aria) return aria.slice(0, 24);
      const c = el.cloneNode(true) as Element;
      c.querySelectorAll('.ms').forEach((i) => i.remove());
      return (c.textContent || '').replace(/\s+/g, ' ').trim().slice(0, 24);
    };

    // 短い間に同じ所を何度も押した(3回以上・0.8秒以内・30px 以内)
    let taps: { x: number; y: number; at: number }[] = [];
    const INTERACTIVE = 'a,button,input,select,textarea,label,summary,video,audio,iframe,[role=button],[role=link],[onclick],[tabindex]';

    document.addEventListener('click', (e) => {
      try {
        const el = e.target as Element | null;
        if (!el || !(el instanceof Element)) return;
        const now = Date.now();
        taps = taps.filter((t) => now - t.at < 800 && Math.abs(t.x - e.clientX) < 30 && Math.abs(t.y - e.clientY) < 30);
        taps.push({ x: e.clientX, y: e.clientY, at: now });
        if (taps.length === 3) { st.g++; dirty = true; }

        const link = el.closest('a[href]') as HTMLAnchorElement | null;
        if (link) {
          const label = link.dataset.ev;
          let target = '';
          try {
            const u = new URL(link.href, location.href);
            if (u.protocol === 'mailto:' || u.protocol === 'tel:') target = 'mailto';
            else if (u.host !== location.host) target = u.hostname;
            else if (u.pathname === location.pathname && u.hash) target = '#' + decodeURIComponent(u.hash.slice(1)).slice(0, 40);
            else target = u.pathname + (u.search.includes('topic=') ? u.search : '');
          } catch { target = '(読めないリンク)'; }
          addClick(label ? `${label} ${target}` : target, areaOf(link));
          return;
        }
        const btn = el.closest('button,[role=button],summary') as HTMLElement | null;
        if (btn) { addClick('btn:' + (btn.dataset.ev || btn.id || textOf(btn) || btn.tagName.toLowerCase()), areaOf(btn)); return; }
        if (el.closest(INTERACTIVE)) return;
        // ページの余白(html・body・main そのもの)を押したのは迷いに数えない
        if (el === document.documentElement || el === document.body || el.tagName === 'MAIN') return;
        // リンクでない所を押した(文字を選んでいるときは除く)
        const sel = window.getSelection();
        if (sel && String(sel).length > 0) return;
        st.d++;
        addClick('(リンクなし):' + el.tagName.toLowerCase(), areaOf(el));
      } catch { /* 数え損ねてもサイトの動きには影響させない */ }
    }, { capture: true, passive: true });

    // 埋め込み(図解・YouTube)を押したとき:ページから枠の中は見えないので、枠に入ったことで数える
    window.addEventListener('blur', () => {
      setTimeout(() => {
        const a = document.activeElement;
        if (a && a.tagName === 'IFRAME') {
          let target = 'iframe';
          try {
            const u = new URL((a as HTMLIFrameElement).src, location.href);
            target = 'iframe:' + (u.host === location.host ? u.pathname.slice(0, 60) : u.hostname);
          } catch { /* src が読めないときは iframe とだけ残す */ }
          addClick(target, areaOf(a));
        }
      }, 0);
    });

    document.addEventListener('copy', () => { st.cp++; dirty = true; });
    document.addEventListener('play', () => { st.vp++; dirty = true; }, true);
    document.addEventListener('focusin', (e) => {
      if (!st.fs && (e.target as Element | null)?.closest?.('form')) { st.fs = 1; dirty = true; }
    });
    document.addEventListener('submit', (e) => {
      st.fx++;
      try { if ((e.target as HTMLFormElement).checkValidity()) st.fv++; } catch { /* 判定できないときは押した回数だけ */ }
      dirty = true;
    }, true);

    const send = () => {
      if (visibleSince) { st.t += Math.round((Date.now() - visibleSince) / 1000); visibleSince = 0; }
      st.t = Math.min(st.t, 7200);
      if (!dirty) return;
      depth();
      const body = JSON.stringify(st);
      try {
        if (!navigator.sendBeacon || !navigator.sendBeacon('/api/ev', new Blob([body], { type: 'text/plain' }))) {
          fetch('/api/ev', { method: 'POST', body, keepalive: true }).catch(() => {});
        }
      } catch { /* 送れなくてもサイトの動きには影響させない */ }
      st.n++; dirty = false;
    };
    document.addEventListener('visibilitychange', () => {
      if (document.hidden) send();
      else visibleSince = Date.now();
    });
    window.addEventListener('pagehide', send);
    // 戻るボタンで、前の表示のまま戻ってきたとき:別の1回として数え直す
    window.addEventListener('pageshow', (e) => {
      if (e.persisted) { st = fresh('(戻る)'); visibleSince = Date.now(); dirty = true; setTimeout(depth, 300); }
    });
  }
</script>

全ページの枠に入れたのは、次の2行です。

diff --git a/src/layouts/Base.astro b/src/layouts/Base.astro
index 7b86c83..f0ab90d 100644
--- a/src/layouts/Base.astro
+++ b/src/layouts/Base.astro
@@ -4,6 +4,7 @@
 // alternates: 日本語版・英語版の対応(hreflang)/ jsonLd: 構造化データ(schema.org)
 import { SITE, SITE_NAME, DEFAULT_OG, abs } from '../lib/seo';
 import HakobuParade from '../components/HakobuParade.astro';
+import SiteMeasure from '../components/SiteMeasure.astro';
 interface Props {
   title: string; description?: string; ogImage?: string; path: string;
   lang?: 'ja' | 'en'; ogType?: 'website' | 'article'; noindex?: boolean;
@@ -75,6 +76,7 @@ const ld = jsonLd ? JSON.stringify(jsonLd).replace(/</g, '\\u003c') : null;
   </header>
   <main><slot /></main>
   <HakobuParade />
+  <SiteMeasure />
   <footer class="site-footer">
     <div class="ft-inner">
       <span>© ADAPT DESIGN 2026</span>

記録を受け取って数える口 worker/index.js の追加分の全量

サイトの裏側で動く worker/index.js に、2つの口を足しました。/api/ev は記録を受け取って R2 に残します。/api/ev-stats は残った記録を数えて表にします。

受け取る側では、送られてきた値をもう一度確かめています。ページの住所の形、読んだ深さの値(0・25・50・75・100 のどれか)、押した場所の名前(決めた一覧にあるものだけ)です。ほかのサイトから送られてきたものは受け取りません。

記録は、中身の空のファイルに「付箋」(R2 の customMetadata)として書きます。この形にすると、数えるときにファイルを1件ずつ開かずに、一覧を読むだけで数えられます。ハコブくんの効果を数えたとき(/api/hk-event)と同じ作りです。

フォームから実際に届いたメッセージの数は、フォームの処理には手を入れず、すでに保存されているメッセージのファイルを数えています。フォームの処理は、問い合わせが届かなくなると困る部分です。そこを触らずに済む形にしました。

入口の振り分けに足した行です。

diff --git a/worker/index.js b/worker/index.js
index 72a6a56..3a35b0b 100644
--- a/worker/index.js
+++ b/worker/index.js
@@ -5,6 +5,8 @@
 // - /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        その記録を週ごと・ページごとに数えて見せる(数だけ。誰かは分からない)
 import { EmailMessage } from 'cloudflare:email';
 
 export default {
@@ -20,6 +22,8 @@ export default {
     if (url.pathname === '/api/message') return message(request, env);
     if (url.pathname === '/api/hk-event') return hkEvent(request, env);
     if (url.pathname === '/api/hk-stats') return hkStats(request, env, url);
+    if (url.pathname === '/api/ev') return evRecord(request, env, url);
+    if (url.pathname === '/api/ev-stats') return evStats(request, env, url);
     return env.ASSETS.fetch(request);
   },
 };

末尾に足した全量です。

// ---------- サイトの使われ方の記録(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', '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; };
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 });
  // ほかのサイトから送られたものは受け取らない
  const o = request.headers.get('origin');
  if (o) { try { if (new URL(o).host !== url.host) return new Response(null, { status: 403 }); } catch { return new Response(null, { status: 403 }); } }
  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)),
  };
  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') : '';
  // 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,
    clicks: m.c ? m.c.split('|').map((x) => { const i = x.lastIndexOf('>'); return { target: x.slice(0, i), area: x.slice(i + 1) }; }) : [],
  }));
  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 rows) {
    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,
    };
  });
  // ページごと・移り方・押したもの(week= を付けるとその週だけ。付けなければ全期間)
  const scope = week ? rows.filter((r) => r.week === week) : rows;
  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, truncated, since: since || null, week: week || 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>
<h2>週ごと</h2>${table([['label', '週'], ['views', '表示'], ['medianSeconds', '表示時間(中央値・秒)'], ['read75Pct', '75%以上読んだ(%)'], ['spPct', 'スマホ(%)'], ['backViews', '戻るで戻った'], ['outClicks', '外へのクリック'], ['deadClicks', 'リンクなしを押した'], ['rageClicks', '連打'], ['messageViews', 'フォームの表示'], ['formStarts', '入力を始めた'], ['formSubmits', '送信を押した'], ['formSubmitsValid', 'うち入力がそろっていた'], ['messagesReceived', '届いたメッセージ']], 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' } });
}

プライバシーポリシーと README に足した文

プライバシーポリシーには「サイトの使われ方の記録について」の項を足し、何を記録して何を記録しないかを書きました。最終改定日は2026年10月18日です。サイトの手順書(README)にも、仕組みと数の見方を足しました。

diff --git a/src/pages/privacypolicy.astro b/src/pages/privacypolicy.astro
index 4740f2b..ea7db43 100644
--- a/src/pages/privacypolicy.astro
+++ b/src/pages/privacypolicy.astro
@@ -7,15 +7,16 @@ const sections = [
   ['メッセージフォームでお預かりする情報', 'メッセージフォームから送信いただいた内容(お名前・メールアドレス・ご相談内容)と、送信日時・ご利用のブラウザの種類(ユーザーエージェント)を保存します。これらはお問い合わせへのご返信とご相談への対応のためにのみ使用します。\n保存には Cloudflare, Inc. のサービス(Cloudflare R2)を、当方への通知メールの送信には Cloudflare Email Routing を利用しています。'],
   ['Cookie とアクセス解析について', '当サイトは、サイトの改善のために Cloudflare, Inc. のアクセス解析(Cloudflare Web Analytics)を利用しています。このアクセス解析は Cookie を使わず、ページの閲覧数・表示速度・参照元・おおよその国・ブラウザやOSの種類などを、個人を識別しない形で集計します。<br>当サイトは広告配信サービスを利用しておらず、当方がお客さまを識別するための Cookie を設定することはありません。'],
   ['更新のお知らせの記録について', '当サイトでは、新しい記事などが公開されたときにキャラクター(ハコブくん)が画面に出てお知らせします。このお知らせが役に立っているかを確かめるため、「お知らせが出た」「お知らせを押した」「お知らせを押さずに7日以内にその記事を開いた」という出来事を、どの記事のお知らせか・どのページで出たか・画面の幅(広い/狭い)・日付とあわせて記録します。\nIP アドレス・ブラウザの種類・Cookie などは記録せず、閲覧者を見分ける番号も作りません。同じお知らせを繰り返し出さないため、お知らせ済みの記事をお客さまのブラウザ(ローカルストレージ)に覚えておきますが、この情報は当方には送られません。'],
-  ['外部サービスの利用について', '当サイトでは、表示や機能のために次の外部サービスを利用しています。これらのサービスの提供元は、お客さまのブラウザから送られる情報(IP アドレス、ブラウザの種類など)を、それぞれのプライバシーポリシーに基づいて取得することがあります。\n・Cloudflare(サイトの配信・画像と動画の配信・メッセージの保存・アクセス解析):<a href="https://www.cloudflare.com/ja-jp/privacypolicy/" target="_blank" rel="noopener">Cloudflare のプライバシーポリシー</a>\n・Google Fonts(文字の表示)/YouTube(動画の埋め込み):<a href="https://policies.google.com/privacy?hl=ja" target="_blank" rel="noopener">Google プライバシーポリシー</a>\nYouTube の動画を再生した場合、YouTube が Cookie を設定することがあります。'],
+  ['サイトの使われ方の記録について', '当サイトでは、読みやすさや使いやすさを改善するため、ページを1回表示するごとに、次の内容を記録します。\n・表示したページと、サイト内のどのページから来たか(戻るボタンで戻ったかどうか)。外のサイトから来た場合は、そのサイトの名前(ドメイン)だけ\n・画面の幅(広い/狭い)、ページをどこまで読み進めたか、ページを表示していた時間\n・押したリンクやボタン(行き先と、ページのどの部分にあったか)、リンクでない所を押した回数、短い間に同じ所を何度も押した回数\n・文章をコピーした回数、動画を再生した回数、メッセージフォームに入力を始めたか・送信ボタンを押した回数\n・日付\nフォームに入力された内容やコピーした文章そのものは記録しません。IP アドレス・ブラウザの種類・Cookie も記録しません。記録をまとめるために1回の表示ごとに一時的な番号を作りますが、この番号はブラウザに保存されず、別のページや次回の来訪とは結び付きません。記録の保存には Cloudflare, Inc. のサービス(Cloudflare R2)を利用しています。\nブラウザで「トラッキングしない」(Do Not Track)または Global Privacy Control を設定している場合は、この記録を行いません。'],
+  ['外部サービスの利用について', '当サイトでは、表示や機能のために次の外部サービスを利用しています。これらのサービスの提供元は、お客さまのブラウザから送られる情報(IP アドレス、ブラウザの種類など)を、それぞれのプライバシーポリシーに基づいて取得することがあります。\n・Cloudflare(サイトの配信・画像と動画の配信・メッセージの保存・アクセス解析・サイトの使われ方の記録):<a href="https://www.cloudflare.com/ja-jp/privacypolicy/" target="_blank" rel="noopener">Cloudflare のプライバシーポリシー</a>\n・Google Fonts(文字の表示)/YouTube(動画の埋め込み):<a href="https://policies.google.com/privacy?hl=ja" target="_blank" rel="noopener">Google プライバシーポリシー</a>\nYouTube の動画を再生した場合、YouTube が Cookie を設定することがあります。'],
   ['個人情報の安全対策', '当方は、個人情報の正確性及び安全性確保のために、セキュリティに万全の対策を講じています。'],
   ['ご本人の照会', 'お客さまがご本人の個人情報の照会・修正・削除などをご希望される場合には、<a href="/message">メッセージフォーム</a>からご連絡ください。ご本人であることを確認の上、対応させていただきます。'],
   ['法令、規範の遵守と見直し', '当方は、保有する個人情報に関して適用される日本の法令、その他規範を遵守するとともに、本ポリシーの内容を適宜見直し、その改善に努めます。'],
 ];
 const nl = (s: string) => s.replace(/\n/g, '<br>');
-const revised = '2026年10月17日';
+const revised = '2026年10月18日';
 ---
-<Base title="プライバシーポリシー | Pixels to Motors" description="Pixels to Motors(ADAPT DESIGN)のプライバシーポリシー。お預かりする個人情報と技術データの扱い、メッセージフォームの情報の保存、Cookie を使わないアクセス解析、利用している外部サービスについて説明しています。" path="/privacypolicy">
+<Base title="プライバシーポリシー | Pixels to Motors" description="Pixels to Motors(ADAPT DESIGN)のプライバシーポリシー。お預かりする個人情報と技術データの扱い、メッセージフォームの情報の保存、Cookie を使わないアクセス解析とサイトの使われ方の記録、利用している外部サービスについて説明しています。" path="/privacypolicy">
   <article class="pp">
     <h1 class="pp-h">Privacy policy | プライバシーポリシー</h1>
     <p class="pp-p">Pixels to Motors(運営:ADAPT DESIGN 島津 栄一。以下「当方」といいます。)は、当方の提供するサービス(以下「本サービス」といいます。)における、ユーザーについての個人情報を含む利用者情報の取扱いについて、以下のとおりプライバシーポリシー(以下「本ポリシー」といいます。)を定めます。</p>
diff --git a/README.md b/README.md
index 9fc389f..7fd3be2 100644
--- a/README.md
+++ b/README.md
@@ -31,6 +31,16 @@ GitHub の main に push すると、Cloudflare が自動で作り直して公
 - 知らせた更新は見た人のブラウザに覚える(同じ更新で2回走らない)。初めて来た人には、日本時間で今日公開された分だけ知らせる
 - 効果の記録:「出た・吹き出しを押した・押さずに7日以内に見に来た」を `/api/hk-event` が R2(p2m-messages の `hk-events/`)に1件ずつ残す。数は `/api/hk-stats`(週ごと・日ごと・更新ごと・画面の幅ごとの表。週ごとの行は週が変わると自動で増える)・`/api/hk-stats?format=json` で見る。見た人を見分ける情報は残さない
 
+## サイトの使われ方の記録
+
+Web改善の検証(Project の `claude/web_improvement_trial.md`)のため、Cloudflare Web Analytics では取れない「どこまで読んだか・何を押したか・どこで迷ったか」を数える(2026-10-18)。最初は広く取り、データがたまってから見るものを絞る。
+- 部品:`src/components/SiteMeasure.astro`(Base に入っている)。1回のページ表示=1件で、ページが見えなくなったときに `/api/ev` へまとめて送る。記録先は R2(p2m-messages の `ev/{日付}/`)
+- 数は `/api/ev-stats`(週ごと・ページごと・ページからページへの移り方・どこから来たか・押したもの・押した場所・リンクでない所を押した)・`/api/ev-stats?format=json` で見る。`?week=週の月曜の日付` でその週だけ、`?since=日付` でその日以降だけ
+- 「届いたメッセージ」はフォームの処理には手を入れず、p2m-messages の一番上に保存された .json の数を数えている
+- エイイチや Claude の確認の閲覧を数に入れないため、どれかのページを `?p2m-notrack=1` を付けて開くと、そのブラウザでは以後送らない(`?p2m-notrack=0` で元に戻す)。Do Not Track・Global Privacy Control を設定したブラウザも送らない
+- 記録しない:入力した内容・コピーした文章・IP アドレス・ブラウザの種類・Cookie。番号はその1回の表示だけのもので、ブラウザに保存しない
+- 押したものの「ページのどの部分か」は、クラス名(.richText・.related・.consult など)で決めている。部品に `data-ev-area="名前"` を付けると、その名前で数える(名前は `worker/index.js` の `EV_AREAS` にあるものだけ受け付ける)。リンクやボタンに `data-ev="名前"` を付けると、行き先の前にその名前が付く
+
 記事・4コマの画像と動画は GitHub に入れない。ただし `public/og/` の画像は、GitHub にあるサムネイルの HTML から書き出したものなので GitHub に入れる。サイトの `/media/ファイル名` を `worker/index.js` が R2 から返す。
 メッセージフォームの送信は R2(p2m-messages)に保存する。フォームには「ご相談の種類」(フィジカルAI・ロボット/AI活用・仕組みづくり/取材・登壇/その他)があり、保存する JSON とメールの件名に入る。`/message?topic=ai` のように開くと、その種類を選んだ状態になる。値の一覧はフォーム(`src/pages/message.astro`)と `worker/index.js` の `TOPICS` の2か所にあるので、増やすときは両方を直す。
 

手元で送った記録が、正しく数えられるかの確認

公開する前に、作業環境でサイトの裏側を手元で動かし(wrangler dev --local)、記録を送って数え直しました。最初は、形を手で決めた記録を4件と、受け取ってはいけないものを3件送っています。

cd /home/claude && B=http://localhost:8787
post(){ curl -s -o /dev/null -w "%{http_code} " -X POST "$B/api/ev" -H 'content-type: text/plain' "$@"; }
post -d '{"v":"abc123def456","n":0,"p":"/record/x","f":"/","r":"","w":"sp","s":75,"t":42,"c":[["/message?topic=ai","consult"],["www.youtube.com","body"],["(リンクなし):img","body"]],"d":1,"g":0,"cp":1,"vp":1,"fs":0,"fx":0,"fv":0}'
post -d '{"v":"abc123def456","n":1,"p":"/record/x","f":"/","r":"","w":"sp","s":100,"t":80,"c":[["/message?topic=ai","consult"]],"d":1,"g":1,"cp":1,"vp":1}'
post -d '{"v":"zzz999yyy888","n":0,"p":"/message","f":"(戻る)","r":"","w":"pc","s":100,"t":120,"c":[],"fs":1,"fx":2,"fv":1}'
post -d '{"v":"qqq111","n":0,"p":"/","r":"x.com","w":"pc","s":50,"t":5,"c":[["/record/x","main"]]}'
echo; echo bad:; post -d '{"v":"BAD","p":"/"}'; post -H 'origin: https://evil.example' -d '{"v":"abc123def456","p":"/"}'; post -d 'notjson'
curl -s -o /dev/null -w "%{http_code} msg\n" -X POST $B/api/message -H 'content-type: application/json' -d '{"name":"t","email":"a@b.cc","body":"x","agree":true,"topic":"ai"}'
echo; curl -s "$B/api/ev-stats?format=json" | python3 -m json.tool | head -80

出力(数の表は先頭の80行だけを表示した)です。

204 204 204 204
bad:
400 403 400 200 msg

{
    "total": 3,
    "truncated": false,
    "since": null,
    "week": null,
    "byWeek": [
        {
            "week": "2026-10-12",
            "label": "2026-10-12 〜 10-18",
            "views": 3,
            "medianSeconds": 80,
            "read75Pct": 67,
            "spPct": 33,
            "backViews": 1,
            "outClicks": 0,
            "deadClicks": 1,
            "rageClicks": 1,
            "messageViews": 1,
            "formStarts": 1,
            "formSubmits": 2,
            "formSubmitsValid": 1,
            "messagesReceived": 1
        }
    ],
    "byPage": [
        {
            "path": "/record/x",
            "views": 1,
            "medianSeconds": 80,
            "read25Pct": 100,
            "read50Pct": 100,
            "read75Pct": 100,
            "read100Pct": 100,
            "deadClicks": 1,
            "rageClicks": 1,
            "copies": 1,
            "videoPlays": 1,
            "outClicks": 0
        },
        {
            "path": "/message",
            "views": 1,
            "medianSeconds": 120,
            "read25Pct": 100,
            "read50Pct": 100,
            "read75Pct": 100,
            "read100Pct": 100,
            "deadClicks": 0,
            "rageClicks": 0,
            "copies": 0,
            "videoPlays": 0,
            "outClicks": 0
        },
        {
            "path": "/",
            "views": 1,
            "medianSeconds": 5,
            "read25Pct": 100,
            "read50Pct": 100,
            "read75Pct": 0,
            "read100Pct": 0,
            "deadClicks": 0,
            "rageClicks": 0,
            "copies": 0,
            "videoPlays": 0,
            "outClicks": 0
        }
    ],
    "moves": [
        {
            "move": "/ → /record/x",
            "count": 1
        },
        {
            "move": "(戻る) → /message",
            "count": 1
        }
    ],
    "referrers": [
        {

確かめたかったのは4点です。正しい記録は 204(受け取った)を返すこと。番号の形が違うもの・ほかのサイトから来たもの・形の壊れたものは 400 と 403 で断ること。同じ表示から2回送られた記録(abc123def456 の n=0 と n=1)は、後の1件だけが数えられて、表示が3件になっていること。フォームの送信(200 msg)が、「届いたメッセージ」の1件として数えられていること。

次に、本物のブラウザ(Chromium を画面なしで動かすもの)でサイトを開き、人が操作するのと同じ形で押して回りました。スマホの幅で、トップから記事へ移り、下までスクロールし、本文の段落を3回続けて押し、コピーし、ヘッダーのメッセージのボタンからフォームを開き、名前だけ入れて送信を押し、戻るボタンで戻ります。最後に、記録しない設定にしたあと別のページを開き、何も送られないかを見ました。

import json, sys
from playwright.sync_api import sync_playwright
B='http://localhost:8787'
with sync_playwright() as p:
    try:
        br=p.chromium.launch()
    except Exception as e:
        print('launch fail', str(e)[:300]); sys.exit()
    sent=[]
    ctx=br.new_context(viewport={'width':390,'height':800})
    pg=ctx.new_page()
    pg.on('request', lambda r: sent.append(r.post_data) if r.url.endswith('/api/ev') else None)
    errs=[]; pg.on('pageerror', lambda e: errs.append(str(e)))
    pg.goto(B+'/')
    first=pg.eval_on_selector('a[href^="/record/"]','e=>e.getAttribute("href")')
    pg.click(f'a[href="{first}"]'); pg.wait_for_load_state('load')
    pg.mouse.wheel(0,3000); pg.wait_for_timeout(500)
    # dead click on paragraph x3 (rage)
    box=pg.query_selector('.richText p').bounding_box()
    for _ in range(3): pg.mouse.click(box['x']+5, box['y']+5)
    pg.keyboard.press('Control+c')
    pg.wait_for_timeout(300)
    pg.click('a.hd-msg'); pg.wait_for_load_state('load')
    pg.fill('input[type=text]','テスト'); pg.click('button.send'); pg.wait_for_timeout(300)
    pg.go_back(); pg.wait_for_timeout(500)
    pg.goto('about:blank'); pg.wait_for_timeout(800)
    # opt-out
    pg.goto(B+'/?p2m-notrack=1'); n=len(sent); pg.goto(B+'/privacypolicy'); pg.goto('about:blank'); pg.wait_for_timeout(500)
    print('after notrack new sends:', len(sent)-n)
    print("sends", n)
    for s in []:
        d=json.loads(s); print({k:d[k] for k in ['n','p','f','r','w','s','t','c','d','g','cp','fs','fx','fv']})
    print('errors', errs)
    br.close()

最初の1回は、試す側のスクリプトが送った中身を表示しようとして止まりました(ブラウザの送り方では、送った中身をスクリプトから読めないため)。

after notrack new sends: 0
Traceback (most recent call last):
  File "/tmp/claude-0/-home-claude/0a45c0af-26c3-526d-b214-d8a079c8094d/scratchpad/t/b.py", line 31, in <module>
    d=json.loads(s); print({k:d[k] for k in ['n','p','f','r','w','s','t','c','d','g','cp','fs','fx','fv']})
  File "/usr/lib/python3.13/json/__init__.py", line 345, in loads
    raise TypeError(f'the JSON object must be str, bytes or bytearray, '
                    f'not {s.__class__.__name__}')
TypeError: the JSON object must be str, bytes or bytearray, not NoneType

表示する部分を外して、送った回数だけを出す形(上に載せたスクリプト)にして、もう一度動かしました。そのあと数の表を読み直しています。

cd /tmp/claude-0/-home-claude/0a45c0af-26c3-526d-b214-d8a079c8094d/scratchpad/t && sed -i 's/^    for s in sent\[:n\]:/    print("sends", n)\n    for s in []:/' b.py && python3 b.py && curl -s "http://localhost:8787/api/ev-stats?format=json" | python3 -c "
import json,sys; d=json.load(sys.stdin)
print('total',d['total']); print(d['byWeek'][0])
for k in ['moves','referrers','clicks','areas','deadClicks']: print(k, d[k])
print([ (p['path'],p['views'],p['read100Pct'],p['deadClicks'],p['rageClicks'],p['copies']) for p in d['byPage']])"
after notrack new sends: 0
sends 4
errors []
total 11
{'week': '2026-10-12', 'label': '2026-10-12 〜 10-18', 'views': 11, 'medianSeconds': 1, 'read75Pct': 36, 'spPct': 82, 'backViews': 1, 'outClicks': 0, 'deadClicks': 7, 'rageClicks': 3, 'messageViews': 3, 'formStarts': 3, 'formSubmits': 4, 'formSubmitsValid': 1, 'messagesReceived': 1}
moves [{'move': '/ → /record/drag-teach-waypoint-replay', 'count': 4}, {'move': '/record/drag-teach-waypoint-replay → /message', 'count': 2}, {'move': '/ → /record/x', 'count': 1}, {'move': '(戻る) → /message', 'count': 1}]
referrers [{'from': '(サイト内)', 'count': 8}, {'from': '(直接・不明)', 'count': 2}, {'from': 'x.com', 'count': 1}]
clicks [{'target': '/record/drag-teach-waypoint-replay|ページの中身', 'count': 2}, {'target': '/message|ヘッダー', 'count': 2}, {'target': 'btn:send送信する|ページの中身', 'count': 2}, {'target': '/message?topic=ai|相談の案内', 'count': 1}, {'target': '/record/x|ページの中身', 'count': 1}]
areas [{'area': 'その他', 'count': 6}, {'area': 'ページの中身', 'count': 5}, {'area': 'ヘッダー', 'count': 2}, {'area': '相談の案内', 'count': 1}]
deadClicks [{'where': '/record/drag-teach-waypoint-replay (リンクなし):html', 'count': 6}]
[('/record/drag-teach-waypoint-replay', 4, 0, 6, 2, 2), ('/message', 3, 100, 0, 0, 0), ('/', 3, 0, 0, 0, 0), ('/record/x', 1, 100, 1, 1, 1)]
Shell cwd was reset to /home/claude

記録しない設定にしたあとは、送信が0回でした。ページの移り方(/ → /record/drag-teach-waypoint-replay、/record/drag-teach-waypoint-replay → /message)、連打、コピーも数えられています。ただ、この出力に2つおかしな所がありました。

公開前に見つけた2つの直し ― ボタン名に混ざったアイコンの文字と、余白を押した数

1つめは、押したボタンの名前が btn:send送信する になっていたことです。このサイトのボタンは、アイコンを文字(Material Symbols)で描いています。送信ボタンの中にアイコンの「send」という文字があり、それがボタンの名前に混ざっていました。名前を取るとき、アイコンの部分を外すようにしました。

2つめは、「リンクでない所を押した」の中に (リンクなし):html が6件あったことです。試す側のスクリプトが押した位置が、段落ではなくページの余白(html そのもの)に当たっていました。人が余白を押すのは、たいていスクロールのついでで、迷いではありません。迷いの数に混ざると、数が大きく見えて改善の判断を誤ります。html・body・main そのものを押したときは数えないようにしました。

cd /home/claude/p2m-site && python3 - <<'EOF'
p='src/components/SiteMeasure.astro'
s=open(p,encoding='utf-8').read()
old="""    const textOf = (el: Element) => (el.getAttribute('aria-label') || el.textContent || '').replace(/\\s+/g, ' ').trim().slice(0, 24);"""
new="""    // ボタンの文字(アイコンの文字 .ms は除く)
    const textOf = (el: Element) => {
      const aria = el.getAttribute('aria-label');
      if (aria) return aria.slice(0, 24);
      const c = el.cloneNode(true) as Element;
      c.querySelectorAll('.ms').forEach((i) => i.remove());
      return (c.textContent || '').replace(/\\s+/g, ' ').trim().slice(0, 24);
    };"""
assert old in s; s=s.replace(old,new)
old2="""        if (el.closest(INTERACTIVE)) return;"""
new2="""        if (el.closest(INTERACTIVE)) return;
        // ページの余白(html・body・main そのもの)を押したのは迷いに数えない
        if (el === document.documentElement || el === document.body || el.tagName === 'MAIN') return;"""
assert old2 in s; s=s.replace(old2,new2)
open(p,'w',encoding='utf-8').write(s)
EOF

直したあと、今度はパソコンの幅で、検索から来た形(Google から来た情報つき)で記事を開き、段落を1秒あけて2回押し、記事の画像を押し、フォームで送信を押して確かめました。

from playwright.sync_api import sync_playwright
B='http://localhost:8787'
with sync_playwright() as p:
    br=p.chromium.launch(); pg=br.new_page(viewport={'width':1280,'height':800})
    errs=[]; pg.on('pageerror', lambda e: errs.append(str(e)))
    pg.goto(B+'/record/drag-teach-waypoint-replay', referer='https://www.google.com/')
    pg.evaluate('window.scrollTo(0, document.body.scrollHeight)'); pg.wait_for_timeout(400)
    el=pg.query_selector('.richText p'); el.scroll_into_view_if_needed(); b=el.bounding_box()
    pg.mouse.click(b['x']+10,b['y']+5); pg.wait_for_timeout(1000); pg.mouse.click(b['x']+10,b['y']+5)
    img=pg.query_selector('.richText img')
    if img: img.scroll_into_view_if_needed(); img.click(force=True)
    pg.goto(B+'/message'); pg.fill('input[type=text]','x'); pg.click('button.send'); pg.wait_for_timeout(300)
    pg.goto('about:blank'); pg.wait_for_timeout(800)
    print('errors',errs); br.close()

作り直し(build)と、上のスクリプトの実行、数の読み直しをまとめて動かしました。

cd /home/claude/p2m-site && npm run build 2>&1 | grep -E "Complete|error" ; grep -c "p2m-notrack" dist/index.html; sleep 3; S=/tmp/claude-0/-home-claude/0a45c0af-26c3-526d-b214-d8a079c8094d/scratchpad/t; python3 $S/c.py && curl -s "http://localhost:8787/api/ev-stats?format=json" | python3 -c "
import json,sys; d=json.load(sys.stdin)
for k in ['referrers','clicks','deadClicks']: print(k, d[k][:6])
print([(p['path'],p['views'],p['read100Pct'],p['deadClicks']) for p in d['byPage']])"
09:02:57 [build] ✓ Completed in 704ms.
09:03:00 [build] ✓ Completed in 3.79s.
09:03:03 ✓ Completed in 2.25s.
09:03:03 [build] Complete!
1
errors []
referrers [{'from': '(サイト内)', 'count': 8}, {'from': '(直接・不明)', 'count': 3}, {'from': 'x.com', 'count': 1}, {'from': 'www.google.com', 'count': 1}]
clicks [{'target': '/record/drag-teach-waypoint-replay|ページの中身', 'count': 2}, {'target': '/message|ヘッダー', 'count': 2}, {'target': 'btn:send送信する|ページの中身', 'count': 2}, {'target': '/message?topic=ai|相談の案内', 'count': 1}, {'target': '/record/x|ページの中身', 'count': 1}, {'target': 'btn:送信する|ページの中身', 'count': 1}]
deadClicks [{'where': '/record/drag-teach-waypoint-replay (リンクなし):html', 'count': 6}, {'where': '/record/drag-teach-waypoint-replay (リンクなし):p', 'count': 2}, {'where': '/record/drag-teach-waypoint-replay (リンクなし):img', 'count': 1}]
[('/record/drag-teach-waypoint-replay', 5, 20, 9), ('/message', 4, 75, 0), ('/', 3, 0, 0), ('/record/x', 1, 100, 1)]

新しく増えた分では、ボタンの名前が btn:送信する になり、リンクでない所を押した記録は p(段落)と img(画像)として数えられています。html の6件は直す前に送った分です。来たところに www.google.com が入っていることも確かめました。画像を押した数は、「この画像は拡大できると思われている」という手がかりになります。

最後に、スマホの幅でプライバシーポリシーと数の表を開き、文字が切れずに読めるかを画面で確かめてから公開しました。

公開後の本番の確認と、作るのにかかった時間

サイトは、GitHub に送ると約1分で自動で作り直されます。送ったあと、本番で次の3つを確かめました。

sleep 70; curl -s -o /dev/null -w "%{http_code}\n" https://pixelstomotors.com/api/ev-stats 2>&1; curl -s https://pixelstomotors.com/ | grep -c "p2m-notrack"
200
1
curl -s https://pixelstomotors.com/api/ev-stats?format=json | head -c 300; echo; curl -s https://pixelstomotors.com/privacypolicy | grep -o "サイトの使われ方の記録について" | head -1; curl -s -o /dev/null -w "%{http_code} hk\n" https://pixelstomotors.com/api/hk-stats
{"total":0,"truncated":false,"since":null,"week":null,"byWeek":[{"week":"2026-10-12","label":"2026-10-12 〜 10-18","views":0,"medianSeconds":0,"read75Pct":0,"spPct":0,"backViews":0,"outClicks":0,"deadClicks":0,"rageClicks":0,"messageViews":0,"formStarts":0,"formSubmits":0,"formSubmitsValid":0,"mess
サイトの使われ方の記録について
200 hk

数の表が開くこと、全ページに部品が入っていること、プライバシーポリシーの項が出ていること、前からあるハコブくんの数の表が壊れていないことです。本番の表に1件もないのは、まだ誰も見ていないからです。ただ、「届いたメッセージ」の週の行だけは出ていました。

かかった時間は、依頼(08:56)から公開して確かめ終わる(09:05)まで約10分です。人が手を動かした時間は0分で、依頼の一言だけでした。この時間は記録の型の「計測を作った記録」に書き込みました。お客さんのサイトに同じ仕組みを入れるなら、何に気をつけるかもあわせて書いています。部品の名前の対応表をサイトごとに作り直すこと、プライバシーポリシーの追記がセットで要ること、お客さん自身の閲覧を外す設定を最初に案内することです。

もう一つ、計画からずらした所があります。記録の型には最初「フォームの送信数はフォームの処理側で数える。フォームの処理に手を入れるので、公開前に外部のレビューを挟む」と書いていました。実際には、フォームの処理に手を入れず、保存済みのメッセージを数える形にしました。このプロジェクトでは、公開後に戻しにくい変更(URL・転送・フォーム・データの消去)のときに外部のレビューを挟むと決めています。フォームを触らなかったので、レビューは挟まずに公開しました。

自分の閲覧を数から外す ― ?p2m-notrack=1 と、設定を画面で確かめる表示

数の中に、作っている本人や AI の確認の閲覧が混ざると、少ないアクセスの間は数字が大きくずれます。そこで、どのページでも ?p2m-notrack=1 を付けて一度開くと、そのブラウザからは以後送らないようにしています。本人には Mac と iPhone で開いてもらいました。

pixelstomotors.com/?p2m-notrack=1

macとアイフォンで見ました

本番の記録はその時点で0件のままで、開いた分は数に入っていませんでした。続けて、本人からこう届きました。

サイトブロックなどを入れています。それで届かない可能性もあります。

送られてきた画面写真には、Chrome の拡張機能の一覧が写っていました。サイトを見られなくするもの(BlockSite・SiteBlock)など、いくつかが入っています。

記録しない設定は、ブラウザの中(localStorage)に覚えるだけで、どこにも送りません。なので、拡張機能が通信を止めても、設定そのものは効きます。困るのは、設定が本当に残っているかを外から確かめる手段がなかったことです。iPhone の Safari は、サイトを7日開かないと、ブラウザに覚えた中身を消すことがあります。消えていても、見た目では分かりません。

そこで、?p2m-notrack=1・=0・=check を付けて開いたときだけ、そのブラウザが今どちらの設定かを、画面の上に6秒出すようにしました。

diff --git a/README.md b/README.md
index 7fd3be2..2a5fac1 100644
--- a/README.md
+++ b/README.md
@@ -37,7 +37,7 @@ Web改善の検証(Project の `claude/web_improvement_trial.md`)のため
 - 部品:`src/components/SiteMeasure.astro`(Base に入っている)。1回のページ表示=1件で、ページが見えなくなったときに `/api/ev` へまとめて送る。記録先は R2(p2m-messages の `ev/{日付}/`)
 - 数は `/api/ev-stats`(週ごと・ページごと・ページからページへの移り方・どこから来たか・押したもの・押した場所・リンクでない所を押した)・`/api/ev-stats?format=json` で見る。`?week=週の月曜の日付` でその週だけ、`?since=日付` でその日以降だけ
 - 「届いたメッセージ」はフォームの処理には手を入れず、p2m-messages の一番上に保存された .json の数を数えている
-- エイイチや Claude の確認の閲覧を数に入れないため、どれかのページを `?p2m-notrack=1` を付けて開くと、そのブラウザでは以後送らない(`?p2m-notrack=0` で元に戻す)。Do Not Track・Global Privacy Control を設定したブラウザも送らない
+- エイイチや Claude の確認の閲覧を数に入れないため、どれかのページを `?p2m-notrack=1` を付けて開くと、そのブラウザでは以後送らない(`?p2m-notrack=0` で元に戻す。`?p2m-notrack=check` で、そのブラウザが今どちらの設定かが画面の上に出る)。iPhone の Safari は、サイトを7日開かないとこの設定を消すことがあるため、ときどき check で確かめる。Do Not Track・Global Privacy Control を設定したブラウザも送らない
 - 記録しない:入力した内容・コピーした文章・IP アドレス・ブラウザの種類・Cookie。番号はその1回の表示だけのもので、ブラウザに保存しない
 - 押したものの「ページのどの部分か」は、クラス名(.richText・.related・.consult など)で決めている。部品に `data-ev-area="名前"` を付けると、その名前で数える(名前は `worker/index.js` の `EV_AREAS` にあるものだけ受け付ける)。リンクやボタンに `data-ev="名前"` を付けると、行き先の前にその名前が付く
 
diff --git a/src/components/SiteMeasure.astro b/src/components/SiteMeasure.astro
index 0ba0cd9..87683a9 100644
--- a/src/components/SiteMeasure.astro
+++ b/src/components/SiteMeasure.astro
@@ -15,19 +15,31 @@
 //
 // 送らない場合:
 // - 「トラッキングしないで」(Do Not Track / Global Privacy Control)を設定したブラウザ
-// - エイイチ自身の閲覧:どれかのページを ?p2m-notrack=1 を付けて開くと、そのブラウザでは以後送らない(=0 で元に戻す)
+// - エイイチ自身の閲覧:どれかのページを ?p2m-notrack=1 を付けて開くと、そのブラウザでは以後送らない(=0 で元に戻す。=check で今の設定を表示)
 // 数は /api/ev-stats(表)・/api/ev-stats?format=json で見る
 ---
 <script>
   type Click = [string, string];
   const OPT = 'p2m-notrack';
   let off = false;
+  let q: string | null = null;
+  let saved = true;
   try {
-    const q = new URLSearchParams(location.search).get(OPT);
+    q = new URLSearchParams(location.search).get(OPT);
     if (q === '1') localStorage.setItem(OPT, '1');
     if (q === '0') localStorage.removeItem(OPT);
     off = localStorage.getItem(OPT) === '1';
-  } catch { /* 覚えられない環境では、送るかどうかを毎回の判定だけで決める */ }
+  } catch { saved = false; /* 覚えられない環境では、送るかどうかを毎回の判定だけで決める */ }
+  // ?p2m-notrack=1 / 0 / check で開いたときだけ、このブラウザが今どちらの設定かを画面の上に6秒出す(設定できたかの確認用)
+  if (q === '1' || q === '0' || q === 'check') {
+    const note = document.createElement('div');
+    note.textContent = !saved ? 'このブラウザでは設定を覚えられません(プライベートブラウズなど)'
+      : off ? 'このブラウザの閲覧は記録しない設定です' : 'このブラウザの閲覧は記録する設定です';
+    note.setAttribute('role', 'status');
+    note.style.cssText = 'position:fixed;top:64px;left:50%;transform:translateX(-50%);z-index:100;width:max-content;max-width:calc(100% - 32px);padding:10px 16px;border-radius:8px;background:#242424;color:#fff;font:500 14px/1.5 sans-serif;box-shadow:0 4px 12px rgba(0,0,0,.2)';
+    const show = () => { document.body.appendChild(note); setTimeout(() => note.remove(), 6000); };
+    if (document.body) show(); else document.addEventListener('DOMContentLoaded', show);
+  }
   const nav = navigator as Navigator & { globalPrivacyControl?: boolean };
   if (nav.doNotTrack === '1' || nav.globalPrivacyControl === true) off = true;
 

手元で、表示と送信の有無を確かめました。

from playwright.sync_api import sync_playwright
B='http://localhost:8788'
with sync_playwright() as p:
    br=p.chromium.launch(); pg=br.new_page(viewport={'width':390,'height':844})
    hits=[]; pg.on('request', lambda r: hits.append(r.url) if r.url.endswith('/api/ev') else None)
    pg.goto(B+'/?p2m-notrack=check'); pg.wait_for_timeout(300); print(pg.inner_text('[role=status]'))
    pg.goto(B+'/?p2m-notrack=1'); pg.wait_for_timeout(300); print(pg.inner_text('[role=status]'))
    pg.screenshot(path='/tmp/claude-0/-home-claude/0a45c0af-26c3-526d-b214-d8a079c8094d/scratchpad/t/note.png')
    pg.goto(B+'/privacypolicy'); pg.goto('about:blank'); pg.wait_for_timeout(500)
    print('sends while off:', len(hits))
    pg.goto(B+'/?p2m-notrack=0'); pg.wait_for_timeout(300); print(pg.inner_text('[role=status]'))
    pg.goto('about:blank'); pg.wait_for_timeout(500); print('sends after on:', len(hits))
    br.close()
このブラウザの閲覧は記録する設定です
このブラウザの閲覧は記録しない設定です
sends while off: 1
このブラウザの閲覧は記録する設定です
sends after on: 2

sends while off: 1 の1件は、最初の ?p2m-notrack=check のページ(この時点では記録する設定)から移ったときの送信です。記録しない設定にしたあとのページ(/?p2m-notrack=1 と /privacypolicy)からは送っていません。設定を戻すと、また送られるようになりました(2件目)。

最初の画面写真では、スマホの幅で表示が2行に折れていたので、1行に収まるように幅の指定を足しています。

本番に出したあと、表示の文がサイトの部品に入っていることを確かめました。

curl -s https://pixelstomotors.com/_astro/SiteMeasure.astro_astro_type_script_index_0_lang.NZ7K26hg.js | grep -o "記録しない設定です" | head -1
記録しない設定です

この記事を公開するときに、もう1か所直しました。ブラウザで「トラッキングしない」を設定していると、記録はしないのに、確かめる表示には「記録する設定です」と出てしまう順番になっていました。「トラッキングしない」の判定を先にして、その場合は「このブラウザは「トラッキングしない」設定のため記録しません」と出すようにしています。

diff --git a/src/components/SiteMeasure.astro b/src/components/SiteMeasure.astro
index 87683a9..79c9b57 100644
--- a/src/components/SiteMeasure.astro
+++ b/src/components/SiteMeasure.astro
@@ -30,18 +30,20 @@
     if (q === '0') localStorage.removeItem(OPT);
     off = localStorage.getItem(OPT) === '1';
   } catch { saved = false; /* 覚えられない環境では、送るかどうかを毎回の判定だけで決める */ }
+  const nav = navigator as Navigator & { globalPrivacyControl?: boolean };
+  const dnt = nav.doNotTrack === '1' || nav.globalPrivacyControl === true;
+  if (dnt) off = true;
   // ?p2m-notrack=1 / 0 / check で開いたときだけ、このブラウザが今どちらの設定かを画面の上に6秒出す(設定できたかの確認用)
   if (q === '1' || q === '0' || q === 'check') {
     const note = document.createElement('div');
-    note.textContent = !saved ? 'このブラウザでは設定を覚えられません(プライベートブラウズなど)'
+    note.textContent = dnt ? 'このブラウザは「トラッキングしない」設定のため記録しません'
+      : !saved ? 'このブラウザでは設定を覚えられません(プライベートブラウズなど)'
       : off ? 'このブラウザの閲覧は記録しない設定です' : 'このブラウザの閲覧は記録する設定です';
     note.setAttribute('role', 'status');
     note.style.cssText = 'position:fixed;top:64px;left:50%;transform:translateX(-50%);z-index:100;width:max-content;max-width:calc(100% - 32px);padding:10px 16px;border-radius:8px;background:#242424;color:#fff;font:500 14px/1.5 sans-serif;box-shadow:0 4px 12px rgba(0,0,0,.2)';
     const show = () => { document.body.appendChild(note); setTimeout(() => note.remove(), 6000); };
     if (document.body) show(); else document.addEventListener('DOMContentLoaded', show);
   }
-  const nav = navigator as Navigator & { globalPrivacyControl?: boolean };
-  if (nav.doNotTrack === '1' || nav.globalPrivacyControl === true) off = true;
 
   if (!off) {
     const newId = () => {

10月31日までに決めること ― 判断の基準と、数え始めた表の見方

ここまでで、改善を回す前の準備が終わりました。10月末までの2週間は何も直さず、数がたまるのを待ちます。その間に決めるのは、判断の基準です。改善の効果・手間・施策の質の3つについて、何がどうなったら「売り物として成り立つ」と言うのかを、数字で書いておきます。

たまった数は pixelstomotors.com/api/ev-stats で見られます。週ごと、ページごと、ページの移り方、来たところ、押したもの、押した場所、リンクでない所を押した場所の表が並びます。週の欄の月曜日の日付を ?week= に付けると、その週だけの表になります。週に一度、AI がこの表を JSON で読んで記録の型に写し、数字を読んで改善案を出します。改善の1回目は11月の最初の週です。