commit 7b50e3e サイトの使われ方の記録に、人と機械を分ける印を足した diff --git a/src/components/SiteMeasure.astro b/src/components/SiteMeasure.astro index b05fd71..8f07cef 100644 --- a/src/components/SiteMeasure.astro +++ b/src/components/SiteMeasure.astro @@ -9,7 +9,9 @@ // 送るもの:ページ・サイト内のどのページから来たか(戻るボタンで戻ったか)・外から来たならそのサイトの名前(ドメイン)だけ・ // 画面の幅(pc/sp)・どこまで読んだか・表示していた時間・押したリンクやボタン(行き先とページのどの部分か)・ // リンクでない所を押した回数・短い間に同じ所を何度も押した回数・コピーした回数・動画を再生した回数・ -// フォームに入力を始めたか・送信ボタンを押した回数 +// フォームに入力を始めたか・送信ボタンを押した回数・ +// 人が操作したか(マウスを動かす・押す・指で触れる・ホイール・キーのどれかがあったか)・ +// 自動で動くブラウザか(navigator.webdriver と、ブラウザの名乗りに Headless・bot などが入っているかの判定結果だけ) // 送らないもの:入力した内容・コピーした文章・IP アドレス・ブラウザの種類・Cookie。 // 番号はこの1回の表示のためだけに作り、ブラウザに保存しない(別のページや次の来訪とはつながらない)。 // @@ -46,6 +48,7 @@ } if (!off) { + const AUTO_UA = /headless|lighthouse|bot\/|bot;|\bbot\b|crawl|spider|slurp|preview|phantom|puppeteer|playwright|selenium/i; const newId = () => { try { return crypto.randomUUID().replace(/-/g, '').slice(0, 12); } catch { return Math.random().toString(36).slice(2, 14); } }; @@ -64,9 +67,14 @@ 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, + // 人と機械を分ける印(2026-10-25)。u=人の操作があった、wd・hl=自動で動くブラウザの印(名乗りそのものは送らない) + u: 0, wd: navigator.webdriver ? 1 : 0, hl: AUTO_UA.test(navigator.userAgent || '') ? 1 : 0, }; }; let st = fresh(); + // 人の操作。1回あれば十分なので、最初の1回だけ印を付ける + const touched = () => { if (!st.u) { st.u = 1; dirty = true; } }; + for (const ev of ['pointermove', 'pointerdown', 'touchstart', 'wheel', 'keydown']) document.addEventListener(ev, touched, { capture: true, passive: true }); let visibleSince = document.hidden ? 0 : Date.now(); let dirty = true; diff --git a/src/pages/privacypolicy.astro b/src/pages/privacypolicy.astro index 8550873..5fd396a 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の種類などを、個人を識別しない形で集計します。
当サイトは広告配信サービスを利用しておらず、当方がお客さまを識別するための Cookie を設定することはありません。'], ['更新のお知らせの記録について', '当サイトでは、新しい記事などが公開されたときにキャラクター(ハコブくん)が画面に出てお知らせします。このお知らせが役に立っているかを確かめるため、「お知らせが出た」「お知らせを押した」「お知らせを押さずに7日以内にその記事を開いた」という出来事を、どの記事のお知らせか・どのページで出たか・画面の幅(広い/狭い)・日付とあわせて記録します。\nIP アドレス・ブラウザの種類・Cookie などは記録せず、閲覧者を見分ける番号も作りません。同じお知らせを繰り返し出さないため、お知らせ済みの記事をお客さまのブラウザ(ローカルストレージ)に覚えておきますが、この情報は当方には送られません。'], - ['サイトの使われ方の記録について', '当サイトでは、読みやすさや使いやすさを改善するため、ページを1回表示するごとに、次の内容を記録します。\n・表示したページと、サイト内のどのページから来たか(戻るボタンで戻ったかどうか)。外のサイトから来た場合は、そのサイトの名前(ドメイン)だけ\n・画面の幅(広い/狭い)、ページをどこまで読み進めたか、ページを表示していた時間\n・押したリンクやボタン(行き先と、ページのどの部分にあったか)、リンクでない所を押した回数、短い間に同じ所を何度も押した回数\n・文章をコピーした回数、動画を再生した回数、メッセージフォームに入力を始めたか・送信ボタンを押した回数\n・日付\nフォームに入力された内容やコピーした文章そのものは記録しません。IP アドレス・ブラウザの種類・Cookie も記録しません。記録をまとめるために1回の表示ごとに一時的な番号を作りますが、この番号はブラウザに保存されず、別のページや次回の来訪とは結び付きません。記録の保存には Cloudflare, Inc. のサービス(Cloudflare R2)を利用しています。\nブラウザで「トラッキングしない」(Do Not Track)または Global Privacy Control を設定している場合は、この記録を行いません。'], + ['サイトの使われ方の記録について', '当サイトでは、読みやすさや使いやすさを改善するため、ページを1回表示するごとに、次の内容を記録します。\n・表示したページと、サイト内のどのページから来たか(戻るボタンで戻ったかどうか)。外のサイトから来た場合は、そのサイトの名前(ドメイン)だけ\n・画面の幅(広い/狭い)、ページをどこまで読み進めたか、ページを表示していた時間\n・押したリンクやボタン(行き先と、ページのどの部分にあったか)、リンクでない所を押した回数、短い間に同じ所を何度も押した回数\n・文章をコピーした回数、動画を再生した回数、メッセージフォームに入力を始めたか・送信ボタンを押した回数\n・人による閲覧か、検索エンジンのクローラーなど自動で巡回するプログラムによる表示か(マウス・指・キーの操作があったかどうかと、ブラウザが自動で動いているかの判定結果だけ)\n・日付\nフォームに入力された内容やコピーした文章そのものは記録しません。IP アドレス・ブラウザの種類・Cookie も記録しません(自動のプログラムかどうかの判定にブラウザの名乗りを使いますが、名乗りそのものは保存しません)。記録をまとめるために1回の表示ごとに一時的な番号を作りますが、この番号はブラウザに保存されず、別のページや次回の来訪とは結び付きません。記録の保存には Cloudflare, Inc. のサービス(Cloudflare R2)を利用しています。\nブラウザで「トラッキングしない」(Do Not Track)または Global Privacy Control を設定している場合は、この記録を行いません。'], ['外部サービスの利用について', '当サイトでは、表示や機能のために次の外部サービスを利用しています。これらのサービスの提供元は、お客さまのブラウザから送られる情報(IP アドレス、ブラウザの種類など)を、それぞれのプライバシーポリシーに基づいて取得することがあります。\n・Cloudflare(サイトの配信・画像と動画の配信・メッセージの保存・アクセス解析・サイトの使われ方の記録):Cloudflare のプライバシーポリシー\n・Google Fonts(文字の表示)/YouTube(動画の埋め込み):Google プライバシーポリシー\nYouTube の動画を再生した場合、YouTube が Cookie を設定することがあります。'], ['個人情報の安全対策', '当方は、個人情報の正確性及び安全性確保のために、セキュリティに万全の対策を講じています。\n不正な大量送信を防ぐため、メッセージフォームなどへの送信の回数を、送信元の IP アドレスごとに短い時間だけ数えています(Cloudflare の流量制限)。IP アドレスはこの数えのためだけに使い、記録はしません。'], ['ご本人の照会', 'お客さまがご本人の個人情報の照会・修正・削除などをご希望される場合には、メッセージフォームからご連絡ください。ご本人であることを確認の上、対応させていただきます。'], diff --git a/worker/index.js b/worker/index.js index 2230ef8..89060c7 100644 --- a/worker/index.js +++ b/worker/index.js @@ -276,6 +276,16 @@ h1{font-size:20px}h2{font-size:16px;margin-top:28px}table{border-collapse:collap 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) { @@ -302,6 +312,7 @@ async function evRecord(request, env, url) { 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); // 日本時間の日付 @@ -318,6 +329,9 @@ 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; @@ -354,12 +368,17 @@ async function evStats(request, env, url) { ...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 rows) { + 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++; @@ -374,10 +393,11 @@ async function evStats(request, env, url) { 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 ? rows.filter((r) => r.week === week) : rows; + 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) { @@ -396,7 +416,7 @@ async function evStats(request, env, url) { } 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, + 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), @@ -420,7 +440,8 @@ async function evStats(request, env, url) { サイトの使われ方の記録

サイトの使われ方の記録

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

-

週ごと

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

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

+

週ごと

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

ページごと

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

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

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

どこから来たか

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