commit 34f2ba054d5bdd06d5d61891318a3a197c68f738
Author: Claude <noreply@anthropic.com>
Date:   Fri Oct 30 17:22:18 2026 +0900

    計測：リンクの来た元の印（utm_source）を記録、クローラーの表に返せなかったページを追加
    
    AI の回答のリンク（?utm_source=chatgpt.com）から来た人が「直接・不明」に混ざらないようにする。
    プライバシーポリシーに記録する項目を追記。
    
    Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01LevEXSDAgg3MAhAQWAaaXy

diff --git a/src/components/SiteMeasure.astro b/src/components/SiteMeasure.astro
index 8f07cef..1bea227 100644
--- a/src/components/SiteMeasure.astro
+++ b/src/components/SiteMeasure.astro
@@ -61,10 +61,18 @@
         return { f: '', r: u.hostname.slice(0, 80) };
       } catch { return { f: '', r: '' }; }
     };
+    // リンクに付いた来た元の印（utm_source。ChatGPT などの AI は回答のリンクに ?utm_source=chatgpt.com を付ける）。
+    // アプリから開くと referrer が送られず「直接・不明」に混ざるため、印の値だけを送る（2026-10-30）
+    const utm = () => {
+      try {
+        const v = (new URLSearchParams(location.search).get('utm_source') || '').toLowerCase();
+        return /^[a-z0-9._-]{1,40}$/.test(v) ? v : '';
+      } catch { return ''; }
+    };
     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,
+        v: newId(), n: 0, p: location.pathname.slice(0, 160), f: from ?? o.f, r: from ? '' : o.r, us: from ? '' : utm(),
         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,
         // 人と機械を分ける印（2026-10-25）。u＝人の操作があった、wd・hl＝自動で動くブラウザの印（名乗りそのものは送らない）
diff --git a/src/pages/privacypolicy.astro b/src/pages/privacypolicy.astro
index 5fd396a..2644106 100644
--- a/src/pages/privacypolicy.astro
+++ b/src/pages/privacypolicy.astro
@@ -7,7 +7,7 @@ const sections = [
   ['メッセージフォームでお預かりする情報', 'メッセージフォームから送信いただいた内容（お名前・メールアドレス・ご相談内容）と、送信日時・ご利用のブラウザの種類（ユーザーエージェント）を保存します。これらはお問い合わせへのご返信とご相談への対応のためにのみ使用します。\n保存には Cloudflare, Inc. のサービス（Cloudflare R2）を、当方への通知メールの送信には Cloudflare Email Routing を利用しています。'],
   ['Cookie とアクセス解析について', '当サイトは、サイトの改善のために Cloudflare, Inc. のアクセス解析（Cloudflare Web Analytics）を利用しています。このアクセス解析は Cookie を使わず、ページの閲覧数・表示速度・参照元・おおよその国・ブラウザやOSの種類などを、個人を識別しない形で集計します。<br>当サイトは広告配信サービスを利用しておらず、当方がお客さまを識別するための Cookie を設定することはありません。'],
   ['更新のお知らせの記録について', '当サイトでは、新しい記事などが公開されたときにキャラクター（ハコブくん）が画面に出てお知らせします。このお知らせが役に立っているかを確かめるため、「お知らせが出た」「お知らせを押した」「お知らせを押さずに7日以内にその記事を開いた」という出来事を、どの記事のお知らせか・どのページで出たか・画面の幅（広い／狭い）・日付とあわせて記録します。\nIP アドレス・ブラウザの種類・Cookie などは記録せず、閲覧者を見分ける番号も作りません。同じお知らせを繰り返し出さないため、お知らせ済みの記事をお客さまのブラウザ（ローカルストレージ）に覚えておきますが、この情報は当方には送られません。'],
-  ['サイトの使われ方の記録について', '当サイトでは、読みやすさや使いやすさを改善するため、ページを1回表示するごとに、次の内容を記録します。\n・表示したページと、サイト内のどのページから来たか（戻るボタンで戻ったかどうか）。外のサイトから来た場合は、そのサイトの名前（ドメイン）だけ\n・画面の幅（広い／狭い）、ページをどこまで読み進めたか、ページを表示していた時間\n・押したリンクやボタン（行き先と、ページのどの部分にあったか）、リンクでない所を押した回数、短い間に同じ所を何度も押した回数\n・文章をコピーした回数、動画を再生した回数、メッセージフォームに入力を始めたか・送信ボタンを押した回数\n・人による閲覧か、検索エンジンのクローラーなど自動で巡回するプログラムによる表示か（マウス・指・キーの操作があったかどうかと、ブラウザが自動で動いているかの判定結果だけ）\n・日付\nフォームに入力された内容やコピーした文章そのものは記録しません。IP アドレス・ブラウザの種類・Cookie も記録しません（自動のプログラムかどうかの判定にブラウザの名乗りを使いますが、名乗りそのものは保存しません）。記録をまとめるために1回の表示ごとに一時的な番号を作りますが、この番号はブラウザに保存されず、別のページや次回の来訪とは結び付きません。記録の保存には Cloudflare, Inc. のサービス（Cloudflare R2）を利用しています。\nブラウザで「トラッキングしない」（Do Not Track）または Global Privacy Control を設定している場合は、この記録を行いません。'],
+  ['サイトの使われ方の記録について', '当サイトでは、読みやすさや使いやすさを改善するため、ページを1回表示するごとに、次の内容を記録します。\n・表示したページと、サイト内のどのページから来たか（戻るボタンで戻ったかどうか）。外のサイトから来た場合は、そのサイトの名前（ドメイン）だけ。リンクに来た元の印（utm_source。例：chatgpt.com）が付いていた場合は、その印の値\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 を設定することがあります。'],
   ['個人情報の安全対策', '当方は、個人情報の正確性及び安全性確保のために、セキュリティに万全の対策を講じています。\n不正な大量送信を防ぐため、メッセージフォームなどへの送信の回数を、送信元の IP アドレスごとに短い時間だけ数えています（Cloudflare の流量制限）。IP アドレスはこの数えのためだけに使い、記録はしません。'],
   ['ご本人の照会', 'お客さまがご本人の個人情報の照会・修正・削除などをご希望される場合には、<a href="/message">メッセージフォーム</a>からご連絡ください。ご本人であることを確認の上、対応させていただきます。'],
diff --git a/worker/index.js b/worker/index.js
index 05e7f90..2c44687 100644
--- a/worker/index.js
+++ b/worker/index.js
@@ -290,6 +290,7 @@ h1{font-size:20px}h2{font-size:16px;margin-top:28px}table{border-collapse:collap
 <h2>日ごと</h2>${table('日付', data.byDay, 'day')}
 <h2>更新ごと</h2>${table('更新', data.byItem, 'id')}
 <h2>画面の幅（pc＝広い／sp＝スマホ）</h2>${table('幅', data.byWidth, 'w')}
+<h2>返せなかったページ（400以上）</h2>${table([['path', 'ページ'], ['status', '返した結果'], ['visits', '回数'], ['bots', 'クローラー'], ['last', '最後の日']], data.errors)}
 <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' } });
 }
@@ -332,6 +333,7 @@ async function evRecord(request, env, url) {
     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() : '',
+    us: /^[a-z0-9._-]{1,40}$/.test(String(d.us || '')) ? String(d.us) : '',
     w: d.w === 'sp' ? 'sp' : 'pc',
     s: String(EV_DEPTHS.has(s) ? s : 0),
     t: String(evInt(d.t, 7200)),
@@ -425,7 +427,7 @@ async function evStats(request, env, url) {
   // ページごと・移り方・押したもの（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();
+  const pagesMap = new Map(), moves = new Map(), refs = new Map(), utms = 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);
@@ -433,7 +435,9 @@ async function evStats(request, env, url) {
     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 ? '（サイト内）' : '（直接・不明）'));
+    // referrer がなくてもリンクの印（utm_source）があれば、その名前で数える（2026-10-30）
+    count(refs, r.r || (r.us ? `${r.us}（リンクの印）` : (r.f ? '（サイト内）' : '（直接・不明）')));
+    if (r.us) count(utms, r.us);
     for (const c of r.clicks) {
       count(areas, c.area);
       if (c.target.startsWith('(リンクなし)')) count(dead, `${r.p} ${c.target}`);
@@ -451,6 +455,7 @@ async function evStats(request, env, url) {
     })),
     moves: top(moves, 50, 'move'),
     referrers: top(refs, 30, 'from'),
+    utmSources: top(utms, 30, 'source'),
     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'),
@@ -471,6 +476,7 @@ h1{font-size:20px}h2{font-size:16px;margin-top:28px}.wrap{overflow-x:auto}table{
 <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>リンクに付いた来た元の印（utm_source。2026-10-30 から）</h2><p>ChatGPT などの AI は、回答の中のリンクに ?utm_source=chatgpt.com のような印を付ける。アプリから開くと「どこから来たか」が送られないので、この印で AI から来た人を数える。</p>${table([['source', '印'], ['count', '回数']], data.utmSources)}
 <h2>押したリンク・ボタン（行き先｜ページのどの部分か）</h2>${table([['target', '押したもの'], ['count', '回数']], data.clicks)}
 <h2>押した場所（ページのどの部分か）</h2>${table([['area', '部分'], ['count', '回数']], data.areas)}
 <h2>リンクでない所を押した（ページと、押したものの種類）</h2>${table([['where', '場所'], ['count', '回数']], data.deadClicks)}
@@ -548,6 +554,14 @@ async function crStats(request, env, url) {
   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= でそのクローラーだけ）
+  // 返せなかった訪問（400以上）をページ・結果ごとに（2026-10-30）
+  const errMap = new Map();
+  for (const r of rows) {
+    if (Number(r.s) < 400) continue;
+    const k = `${r.p}\t${r.s}`;
+    const e = errMap.get(k) || { path: r.p, status: r.s, visits: 0, bots: new Set(), last: '' };
+    e.visits++; e.bots.add(r.b); if (r.day > e.last) e.last = r.day; errMap.set(k, e);
+  }
   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); }
@@ -558,6 +572,7 @@ async function crStats(request, env, url) {
     })),
     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('、') })),
+    errors: [...errMap.values()].sort((a, b) => b.visits - a.visits).slice(0, 100).map((e) => ({ path: e.path, status: e.status, visits: e.visits, bots: [...e.bots].join('、'), last: e.last })),
   };
   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' } });
@@ -573,6 +588,7 @@ h1{font-size:20px}h2{font-size:16px;margin-top:28px}.wrap{overflow-x:auto}table{
 <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)}
+<h2>返せなかったページ（400以上）</h2>${table([['path', 'ページ'], ['status', '返した結果'], ['visits', '回数'], ['bots', 'クローラー'], ['last', '最後の日']], data.errors)}
 <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' } });
 }
