PHP2Shell の週に、サイトの守りを固める ― AIの書き込みは仕組みで縛った

2026年10月21日、Web のサーバーで広く使われている PHP(ページをその場で組み立てるためのプログラミング言語)に、外からサーバーを乗っ取れる重大な欠陥が2件見つかり、修正版が出ました。通称は PHP2Shell です。予定より前に情報が漏れたため、修正版は予定を1日早めて出され、公開の直後から悪用を試みる動きが報じられました。PHP で動くサイトの運用者は、急いでサーバーを更新する必要がありました。

このサイト(pixelstomotors.com)は、この件で何もする必要がありませんでした。PHP を使っていないからです。ページは記事を公開するときに一度だけ作り切り、Cloudflare(世界中にファイルを配る配信サービス)がそのファイルを配っています。訪れた人のアクセスを受けてサーバーでプログラムが動く、という場面がほとんどありません。PHP2Shell のような「アクセスを受け取るプログラム」を狙う攻撃は、そもそも成り立ちません。

では、それで安全かというと、そうではありませんでした。点検すると、PHP とは関係のない弱点が4つ見つかりました。

  1. 数の表が誰でも開けた:どの記事がどこまで読まれたか、問い合わせが週に何件あったかを集計するページが、住所を知っていれば誰でも見られる状態でした
  2. 送信の口が数えていなかった:閲覧の記録や問い合わせを受け取る入口が、同じ相手から何回送られても受け取っていました。プログラムで偽の記録を大量に送り込めば、2か月かけて集めている測定データを汚せます
  3. 週1回、AI が本線へ直接書き込んでいた:課題地図(/issues)は、予約した AI が毎週ニュースを調べて更新しています。「触ってよいのは2つのファイルだけ」という決まりは、AI への指示文に書いてあるだけでした。この AI は外の Web ページを読むので、ページに仕込まれた悪い指示に引っぱられる経路が残っていました
  4. アカウントに2段階認証がなかった:GitHub(サイトのコードの置き場)と、Cloudflare に入るための Google アカウントです。サイトの作りがどれだけ堅くても、アカウントを乗っ取られれば書き換えられます

攻撃される部分を持たないサイトでも、弱点は「鍵のかかっていない表」「数えていない送信口」「お願いでしか縛っていない AI」「2段階認証のないアカウント」に残っていました。 半日で、この4つを次のように片づけました。

弱点やったこと結果
数の表が誰でも開けた
鍵(Cloudflare に置く秘密の文字列)を付けた
鍵なしでは「ページがない」と返す
送信の口が数えていなかった
このサイトのページから来た送信だけを受け付け、同じ相手からの送りすぎを断る(流量制限)。問い合わせフォームに、人には見えない欄を置いた
外からの直接送信は断る。見えない欄を埋めた自動投稿は捨てる
AI が本線へ直接書き込んでいた
AI は別の枝(ブランチ)に書いて取り込みの依頼を出す。GitHub の仕組みが「変わったのは2ファイルだけか」を確かめ、通ったときだけ取り込む
決まりの外のファイルを混ぜた試験の依頼は、止まって取り込まれなかった
2段階認証がなかった
GitHub と、Google の3つのアカウントで設定した
ログインにスマホの認証アプリが要る

ほかに、全ページへの守りの指定(ほかのサイトに枠で埋め込ませない、など)、依存しているプログラム部品の更新と危険の通知(Dependabot)、エラーの記録(ログ)も入れました。下の図で、送られてきたものがどこで止まり、どこを通るかを切り替えて見られます。

もう一つ、サイトを見ていたエイイチが、記事の本文の「LOG334」のような他の記事の番号がリンクになっていないことに気づきました。これも同じ日に、全記事の本文で記事番号をその記事へのリンクにしました(418か所)。古い記事に残っていた DAY200 という以前の呼び方も、表示を LOG200 にそろえています。

ここから下は、点検の前に考えた Web の行方と、それぞれの作業で書いたコード・設定・確かめたときの出力の全量です。

作業ログ ― PHP2Shell から、サイトの守りを固めるまで

PHP2Shell で起きたこと(2026年10月21日)

最初の質問は、PHP の話題についてでした。

世の中phpの話題で持ちきりですが、今後webの世界はどう変化してくると考えますか?未来予想をお願いします。

1回目の検索では該当するニュースを拾えず、一般論で答えてしまいました。

ん?phpの脆弱性の話は知りませんか?

と言われて調べ直し、次の事実を確かめました(記事化のときに出典を開き直しています)。

項目内容
通称PHP2Shell
番号
CVE-2026-109618(メモリの破損)、CVE-2026-109619(古い状態が残る)
深刻度
どちらも CVSS v4.0 で 9.2(PHP のセキュリティチームによる評価)
場所
PHP 本体。WordPress やプラグインより下の層なので、PHP を更新するしか対策がない
修正版
8.5.12、8.4.27、8.3.36、8.2.35(8.1・8.0・7.4 は最小限の修正のみ。7.3 以前は修正なし)
公開
2026年10月21日 14:00(世界標準時)。10月22日に公開する予定だったが、前日に情報が漏れた
いちばん危険な構成
Apache と mod_php の組み合わせ。PHP-FPM・php-cgi・FrankenPHP・LiteSpeed も対象

Web がどう変わるかを先に考えた

続けて、エイイチから次の問いが出ました。

これからAIの悪用や高度化によりさらにWEBの危険度は増してくる可能性があります。その中でどのように変化するのかも予測してみてください。どのようなサイトが好まれ、運用(AIがやるのか、人がするのか)がどう変化していくのかも考えてください。

このときの Claude の見立ては次のとおりです。

  • 攻撃の側が速くなる:AI で攻撃が自動化され、欠陥の公開から悪用までが数日から数時間に縮む。小さなサイトも無差別に狙われる。「うちは小さいから狙われない」が通用しなくなる
  • 好まれるサイト:動く部分が少ないサイト(ページを事前に作り、配信サービスから配る)/責任ごと預けられるサイト(STUDIO や Shopify のような、運営会社が守るサービス)/持たないサイト(ログインや決済を自分で抱えず専門のサービスに任せる。持っていないデータは漏れない)/運営者の実在や情報の出所を示せるサイト
  • 運用は「AI が回し、人が責任を持つ」形になる:見張り・異常の検知・守りの規則の更新・修正の適用・問題が出たときの切り戻しは AI。サイトを止めるかどうかの判断、どこまで AI に任せるかの線引き、預け先の選定と責任の分け方、関係者への説明は人
  • 中間が淘汰される:安全を預けた身軽なサイトと、本格的な運用体制を持つ大組織のサービスに分かれ、その間の「作りっぱなしで誰も手入れしていないサイト」が攻撃の的になる

身軽で静的なサイトについてより解像度高く捉えて説明してください。

ここで整理したのは、「静的」とは見た目が静かなことではなく、アクセスされた瞬間に自分のサーバーでプログラムが動かないことだ、という点です。中身は3つの層に分かれます。

層いつ・どこで動くかこのサイトでは
作る
記事を公開するとき一度だけ、ページを作り切る
Astro(ページを事前に作る道具)が、GitHub に push されたときに作る
配るできたファイルを配るだけ
Cloudflare が世界中から配る
動く部分
フォーム・計測など、どうしても要るところだけ
Cloudflare Workers(配信サービスの上で動く小さなプログラム)の数本の入口

そして、危険はなくなるのではなく場所を移す、という点も確かめました。外から読み込むスクリプト(2024年に配信元が乗っ取られた Polyfill.io の件のように、そこが汚染されると何十万のサイトに広がる)、管理するアカウント、預け先のサービス、人ごとに変わる情報の扱いです。

pixelstomotors.com を点検した結果

今のうちの現行サイトについてどのようなものになっているか評価してください。また新しいWEBの流れに合わせるWEBサイトとして運用していくためにはどんなことが必要になりますか?よく分析して答えてください。

公開中の記事(サイトを移した記録・SEO の記録・相談の入口の記録・測定の記録・課題地図の記録)を読み、サイトの外から確かめた評価です。

観点評価根拠
作り◎
Astro で事前に作り、Cloudflare から配っている。自前で動くのは問い合わせと計測の入口だけ
運用◎
GitHub に push すると約40秒で公開され、すぐ戻せる。改修・SEO の点検・課題地図の週次更新は AI、判断は人
信頼◎
失敗とコードを含む実験の記録が350本を超え、運営者も実名
守り△
下の弱点が、AI の時代に狙われやすい場所と重なっていた

このとき挙げた弱点は、数の表が誰でも開けること、送信の口に流量制限がないこと、週1回の AI の書き込み範囲が指示文でしか縛られていないこと、AI のクローラー(検索や引用のためにページを読みに来るプログラム)への方針が決まっていないこと、の4つでした。最後の1つは、作業の中で確かめると問題ありませんでした(下の本番の確認ログで、GPTBot・ClaudeBot・PerplexityBot・Googlebot がどれも読めている)。代わりに、作業の後半でアカウントの2段階認証が4つ目の弱点として加わりました。

守りの面ですぐに出来て対応できることは行ってください。

数の表に鍵を付け、送信の口で数えて断る(worker/index.js)

サイトで動いているプログラムは worker/index.js の1本だけです。ここに次の4つを入れました。

  • 数の表(/api/hk-stats・/api/ev-stats)は、鍵 STATS_KEY を付けたときだけ見られる。鍵がなければ「ページがない」(404)を返す。鍵の比べ方は、文字列の長さや一致した位置で時間が変わらない方法(timingSafeEqual)にした
  • 送信の口(/api/message・/api/hk-event・/api/ev)は、guarded() を通す。POST 以外は 405、このサイトのページ以外からの送信は 403、同じ送信元から60秒あたりフォーム5回・記録60回を超えたら 429 で断る
  • フォームに人には見えない欄 website を置き、そこが埋まった送信は保存も通知もせず、受け取ったふりだけをする(自動投稿のプログラムは欄を全部埋めがち)
  • このプログラムが返す応答に、内容の種類をブラウザに推測させない指定(nosniff)を付ける

送信元の IP アドレスは、回数を数えるためだけに使い、記録はしません。プライバシーポリシーにもそう書き足しました。

worker/index.js(全文)

// サイトの入口。ほとんどのページは作っておいた静的ファイル(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        その記録を週ごと・ページごとに数えて見せる(数だけ。誰かは分からない)
//
// 守り(2026-10-23)
// - 数の表(/api/hk-stats・/api/ev-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';

export default {
  async fetch(request, env) {
    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));
    }
    return env.ASSETS.fetch(request);
  },
};

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();
    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; };
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)),
  };
  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' } });
}

wrangler.jsonc(全文)

流量制限(ratelimits)は Cloudflare の設定ファイルに書きます。ログ(observability)の節は、あとで足したものです(後述)。

{
  // Cloudflare Workers の設定。
  // ページは作っておいた静的ファイル(dist/)。画像と動画・メッセージフォームだけ worker/index.js が受け持つ
  "name": "p2m-site",
  "main": "./worker/index.js",
  "compatibility_date": "2026-10-01",
  "assets": {
    "directory": "./dist",
    "binding": "ASSETS",
    "not_found_handling": "404-page",
    "html_handling": "auto-trailing-slash",
    // /media/ と /api/ と旧 URL の形(/pixelstomotors/…)は、静的ファイルより先に worker/index.js へ回す
    "run_worker_first": ["/media/*", "/api/*", "/pixelstomotors", "/pixelstomotors/*"]
  },
  "r2_buckets": [
    // 記事・4コマの画像と動画(GitHub には入れない。tools/upload_media.py で Mac から入れる)
    { "binding": "MEDIA", "bucket_name": "p2m-media" },
    // メッセージフォームで送られた内容
    { "binding": "MESSAGES", "bucket_name": "p2m-messages" }
  ],
  // ログ(エラーの記録)。管理画面でオンにしても、公開のたびにこのファイルの内容で上書きされるので、ここに書く(2026-10-23)
  "observability": {
    "logs": { "enabled": true, "head_sampling_rate": 1, "invocation_logs": true, "persist": true }
  },
  // 送信の口の流量制限(同じ送信元から60秒あたりの回数)。worker/index.js の guarded() が使う。
  // namespace_id はこのアカウントの中で他と重ならない番号なら何でもよい
  "ratelimits": [
    { "name": "RL_MESSAGE", "namespace_id": "2310231", "simple": { "limit": 5, "period": 60 } },
    { "name": "RL_EVENT", "namespace_id": "2310232", "simple": { "limit": 60, "period": 60 } }
  ],
  // メッセージフォームに届いた内容をメールで知らせる(Cloudflare の Email Routing で確認済みの宛先にだけ送れる)
  "send_email": [
    { "name": "MAIL", "destination_address": "pixelstomotors@adaptdesign.jp" }
  ]
}

public/_headers(全文)

全ページに付ける守りの指定です。ほかのサイトの中に枠で埋め込ませない指定(frame-ancestors 'self')は、記事の中の図解(このサイト自身の枠)はそのまま表示できる書き方にしています。

# 全ページの守り(2026-10-23)
# - nosniff:内容の種類をブラウザに推測させない
# - Referrer-Policy:外のサイトへ移るとき、ページの住所の全部は渡さない
# - Permissions-Policy:カメラ・マイク・位置情報などを、このサイトでは使わない
# - Strict-Transport-Security:このドメインは https だけで開く(1年)
# - frame-ancestors 'self':ほかのサイトの中に枠で埋め込ませない(なりすまし・クリックの乗っ取り対策。自分のサイトの図解の枠はそのまま)
/*
  X-Content-Type-Options: nosniff
  Referrer-Policy: strict-origin-when-cross-origin
  Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=(), usb=()
  Strict-Transport-Security: max-age=31536000
  Content-Security-Policy: frame-ancestors 'self'; object-src 'none'; base-uri 'self'

# 記事の中に埋め込んで見せる部品(サムネイル・図解)は、単独のページとして検索結果に出さない
# (indexifembedded:埋め込んだ記事の一部としては読まれる)
/embeds/*
  X-Robots-Tag: noindex, indexifembedded
/thumbs/*
  X-Robots-Tag: noindex, indexifembedded

src/pages/message.astro(全文)

問い合わせフォームです。見えない欄と、送りすぎで断られたとき(429)の案内を足しました。

---
import Base from '../layouts/Base.astro';
---
<Base title="お問い合わせ(メッセージ) | Pixels to Motors" description="Pixels to Motors へのお問い合わせ。ロボットやフィジカルAIの体験設計、HRI デザインなど、仕事のご依頼やご相談をお聞かせください。1〜2営業日以内にお返事します。" path="/message">
  <section class="msg">
    <div class="msg-lead">
      <h1 class="msg-h">Message | メッセージ</h1>
      <p>Pixels to Motorsにご興味を持っていただきありがとうございます。仕事のご依頼や、ご相談内容をお聞かせください。1〜2営業日以内にお返事いたします。</p>
    </div>
    <form class="msg-form" id="msgForm" novalidate>
      <label>お名前<span class="req">*</span><input name="name" type="text" placeholder="氏名" required autocomplete="name" /></label>
      <label>メールアドレス<span class="req">*</span><input name="email" type="email" placeholder="XXXXXXXX@compay.com" required autocomplete="email" /></label>
      <label>ご相談の種類<span class="req">*</span><select name="topic" id="msgTopic" required>
        <option value="" disabled selected>選んでください</option>
        <option value="physical-ai">フィジカルAI・ロボット</option>
        <option value="ai">AI活用・仕組みづくり</option>
        <option value="media">取材・登壇</option>
        <option value="other">その他</option>
      </select></label>
      <label>ご相談内容<span class="req">*</span><textarea name="body" rows="9" placeholder="「現在〇〇の現場でロボット導入を検討中ですが、シミュレーションでどこまで検証できるか知りたいです」「URDFデータはありますが、Isaac Simへの移行で困っています。" required></textarea></label>
      <!-- 人には見えない欄。自動投稿のプログラムだけが埋める(worker/index.js が埋まった送信を捨てる) -->
      <div class="hp" aria-hidden="true"><label>ウェブサイト<input name="website" type="text" tabindex="-1" autocomplete="off" /></label></div>
      <label class="agree"><input name="agree" type="checkbox" required />送信をもって<a href="/privacypolicy">プライバシーポリシー</a>に同意いただいたものとみなします。</label>
      <button class="send" type="submit"><span class="send-ic"><i class="ms">send</i></span><span class="send-t">送信する</span></button>
      <p class="msg-note" id="msgNote" hidden></p>
    </form>
  </section>
</Base>
<script>
  const form = document.getElementById('msgForm') as HTMLFormElement;
  const note = document.getElementById('msgNote') as HTMLParagraphElement;
  const say = (t: string) => { note.textContent = t; note.hidden = false; };
  // 記事の案内(/message?topic=ai など)から来たときは、相談の種類を選んだ状態にする
  const topicSel = document.getElementById('msgTopic') as HTMLSelectElement;
  const preset = new URLSearchParams(location.search).get('topic');
  if (preset && [...topicSel.options].some((o) => o.value === preset && !o.disabled)) topicSel.value = preset;
  form.addEventListener('submit', async (e) => {
    e.preventDefault();
    if (!form.reportValidity()) return;
    const f = new FormData(form);
    const btn = form.querySelector('button')!; btn.disabled = true;
    try {
      const r = await fetch('/api/message', { method: 'POST', headers: { 'content-type': 'application/json' },
        body: JSON.stringify({ name: f.get('name'), email: f.get('email'), topic: f.get('topic'), website: f.get('website') || '', body: f.get('body'), agree: f.get('agree') === 'on' }) });
      if (r.status === 429) { say('短い時間に送信が続いたため、受け付けられませんでした。1分ほど待ってから、もう一度お試しください。'); btn.disabled = false; return; }
      const d = await r.json();
      if (d.ok) { form.reset(); topicSel.value = ''; say('送信しました。1〜2営業日以内にお返事いたします。'); }
      else say(d.error || '送信できませんでした。時間をおいてもう一度お試しください。');
    } catch { say('送信できませんでした。時間をおいてもう一度お試しください。'); }
    btn.disabled = false;
  });
</script>
<style>
  .msg { max-width: 1080px; margin: 0 auto; padding: 104px 24px 104px; display: grid; grid-template-columns: 451px 565px; justify-content: space-between; gap: 40px; }
  .msg-h { margin: 0 0 24px; font: 600 24px/36px var(--sans); }
  .msg-lead p { margin: 0; font: 400 16px/24px var(--sans); }
  .msg-form { display: flex; flex-direction: column; }
  .msg-form label { display: block; margin-bottom: 16px; font: 500 16px/24px var(--sans); }
  .req { color: #ff4000; margin-left: 4px; font: 700 15px/21px var(--sans); }
  .msg-form input[type=text], .msg-form input[type=email], .msg-form select, .msg-form textarea { display: block; width: 100%; margin-top: 8px; padding: 8px 12px;
    background: #fdfdfd; border: 1px solid #aeadab; border-radius: 4px; font: 400 14px/21px var(--sans); color: #333; }
  .msg-form select { height: 39px; -webkit-appearance: none; appearance: none; padding-right: 44px; cursor: pointer;
    background: #fdfdfd url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8' fill='none'%3E%3Cpath d='M1.5 1.5L6 6l4.5-4.5' stroke='%23333' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E") no-repeat right 16px center / 12px 8px; }
  .msg-form textarea { resize: vertical; min-height: 240px; }
  .agree { font: 500 14px/21px var(--sans) !important; display: flex !important; gap: 10px; align-items: flex-start; }
  .agree input { margin-top: 4px; }
  .hp { position: absolute; left: -9999px; width: 1px; height: 1px; overflow: hidden; }
  .agree a { color: #097ac1; font-weight: 700; }
  .send { display: flex; align-items: center; justify-content: center; gap: 16px; height: 120px; border: 0; background: transparent; cursor: pointer; }
  .send-ic { width: 60px; height: 60px; border-radius: 50%; background: var(--dark); color: #fdfdfd; display: inline-flex; align-items: center; justify-content: center; }
  .send-ic .ms { font-size: 28px; }
  .send-t { font: 500 14px/22.4px var(--sans); color: var(--ink); }
  .send:disabled { opacity: .5; }
  .msg-note { margin: 0; font: 500 14px/21px var(--sans); color: var(--ink); text-align: center; }
  @media (max-width: 1100px) { .msg { grid-template-columns: 1fr; padding-top: 88px; } }
</style>

プライバシーポリシーの変更(差分)

commit be726025c38f46b60c23bcc47a32f3a9053942a5
Author: Claude <noreply@anthropic.com>
Date:   Fri Oct 23 04:49:56 2026 +0900

    サイトの守りを固める:数の表に鍵、送信の口に流量制限、全ページに守りの指定
    
    - /api/hk-stats・/api/ev-stats は、Cloudflare のシークレット STATS_KEY を ?key= か Bearer で渡したときだけ見られる(未設定なら 404)
    - /api/message・/api/hk-event・/api/ev は、このサイトのページからの POST だけ受け取り、同じ送信元から60秒あたりフォーム5回・記録60回を超えると 429
    - メッセージフォームに人に見えない欄(website)を足し、埋まった送信は保存も通知もしない。429 のときは待つよう案内する
    - 全ページに nosniff・Referrer-Policy・Permissions-Policy・HSTS・ほかのサイトに枠で埋め込ませない指定(public/_headers)
    - worker が返す応答にも nosniff を付ける
    - Dependabot で依存パッケージの更新を毎週プルリクエストで出す
    - プライバシーポリシーに流量制限での IP アドレスの扱いを追記(最終改定日 2026-10-23)
    
    Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01LRGtjvBcDuz85yRVhSnDQ5

diff --git a/src/pages/privacypolicy.astro b/src/pages/privacypolicy.astro
index ea7db43..a9c2919 100644
--- a/src/pages/privacypolicy.astro
+++ b/src/pages/privacypolicy.astro
@@ -9,12 +9,12 @@ const sections = [
   ['更新のお知らせの記録について', '当サイトでは、新しい記事などが公開されたときにキャラクター(ハコブくん)が画面に出てお知らせします。このお知らせが役に立っているかを確かめるため、「お知らせが出た」「お知らせを押した」「お知らせを押さずに7日以内にその記事を開いた」という出来事を、どの記事のお知らせか・どのページで出たか・画面の幅(広い/狭い)・日付とあわせて記録します。\nIP アドレス・ブラウザの種類・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 を設定することがあります。'],
-  ['個人情報の安全対策', '当方は、個人情報の正確性及び安全性確保のために、セキュリティに万全の対策を講じています。'],
+  ['個人情報の安全対策', '当方は、個人情報の正確性及び安全性確保のために、セキュリティに万全の対策を講じています。\n不正な大量送信を防ぐため、メッセージフォームなどへの送信の回数を、送信元の IP アドレスごとに短い時間だけ数えています(Cloudflare の流量制限)。IP アドレスはこの数えのためだけに使い、記録はしません。'],
   ['ご本人の照会', 'お客さまがご本人の個人情報の照会・修正・削除などをご希望される場合には、<a href="/message">メッセージフォーム</a>からご連絡ください。ご本人であることを確認の上、対応させていただきます。'],
   ['法令、規範の遵守と見直し', '当方は、保有する個人情報に関して適用される日本の法令、その他規範を遵守するとともに、本ポリシーの内容を適宜見直し、その改善に努めます。'],
 ];
 const nl = (s: string) => s.replace(/\n/g, '<br>');
-const revised = '2026年10月18日';
+const revised = '2026年10月23日';
 ---
 <Base title="プライバシーポリシー | Pixels to Motors" description="Pixels to Motors(ADAPT DESIGN)のプライバシーポリシー。お預かりする個人情報と技術データの扱い、メッセージフォームの情報の保存、Cookie を使わないアクセス解析とサイトの使われ方の記録、利用している外部サービスについて説明しています。" path="/privacypolicy">
   <article class="pp">

.github/dependabot.yml(全文)

依存しているプログラム部品(npm のパッケージ)の更新を、毎週月曜に取り込みの依頼として出す設定です。自動では取り込みません。

# 依存パッケージ(npm)の更新を、毎週月曜にプルリクエストで知らせる。自動では取り込まない
# 小さな更新(minor・patch)は1つのプルリクエストにまとめる
version: 2
updates:
  - package-ecosystem: npm
    directory: /
    schedule:
      interval: weekly
      day: monday
      timezone: Asia/Tokyo
    open-pull-requests-limit: 5
    groups:
      minor-and-patch:
        update-types: [minor, patch]

手元で確かめた出力

公開する前に、手元(Claude の作業環境)で Cloudflare と同じ動きをする試験用のサーバー(wrangler dev)を立て、試験用の鍵 test-key-123 を入れて確かめました。記事化のときに同じ手順で取り直した出力です。

B=http://localhost:8790
echo "--- stats"; for q in "" "?key=wrong" "?key=test-key-123&format=json"; do curl -s -o /dev/null -w "ev-stats$q %{http_code}\n" "$B/api/ev-stats$q"; done; curl -s -o /dev/null -w "hk-stats bearer %{http_code}\n" -H "Authorization: Bearer test-key-123" "$B/api/hk-stats?format=json"
echo "--- origin"; curl -s -o /dev/null -w "ev no-origin %{http_code}\n" -X POST $B/api/ev -d '{"v":"abc123def456","p":"/"}'; curl -s -o /dev/null -w "ev evil %{http_code}\n" -X POST -H "origin: https://evil.example" $B/api/ev -d '{"v":"abc123def456","p":"/"}'; curl -s -o /dev/null -w "ev same %{http_code}\n" -X POST -H "origin: $B" $B/api/ev -d '{"v":"abc123def456","p":"/"}'; curl -s -o /dev/null -w "ev sec-fetch %{http_code}\n" -X POST -H "sec-fetch-site: same-origin" $B/api/ev -d '{"v":"abc123def457","p":"/"}'; curl -s -o /dev/null -w "ev GET %{http_code}\n" $B/api/ev
echo "--- honeypot"; curl -s -X POST -H "origin: $B" -H 'content-type: application/json' $B/api/message -d '{"name":"bot","email":"a@b.cc","body":"spam","agree":true,"website":"http://spam"}'; echo
echo "--- rate"; for i in $(seq 1 7); do curl -s -o /dev/null -w "%{http_code} " -X POST -H "origin: $B" -H 'content-type: application/json' $B/api/message -d '{"name":"t","email":"a@b.cc","body":"x","agree":true}'; done; echo
echo "--- headers"; curl -sI $B/ | grep -iE "x-content|referrer|permissions|strict|content-security"; curl -sI "$B/api/ev-stats" | grep -i x-content
--- stats
ev-stats 404
ev-stats?key=wrong 404
ev-stats?key=test-key-123&format=json 200
hk-stats bearer 200
--- origin
ev no-origin 403
ev evil 403
ev same 204
ev sec-fetch 204
ev GET 405
--- honeypot
{"ok":true}
--- rate
200 200 200 200 429 429 429 
--- headers
Strict-Transport-Security: max-age=31536000
content-security-policy: frame-ancestors 'self'; object-src 'none'; base-uri 'self'
permissions-policy: camera=(), microphone=(), geolocation=(), payment=(), usb=()
referrer-policy: strict-origin-when-cross-origin
x-content-type-options: nosniff
x-content-type-options: nosniff
env.RL_MESSAGE (5 requests/60s)                     Rate Limit                local
env.RL_EVENT (60 requests/60s)                      Rate Limit                local
env.STATS_KEY ("(hidden)")                          Environment Variable      local
[wrangler:info] Ready on http://localhost:8790

流量制限の行が「200 が4回のあと 429」になっているのは、その直前の見えない欄の試験(1回)も同じ送信元からの1回に数えられているためです。60秒の間に5回まで受け取り、6回目から断っています。

ブラウザでも、記事の閲覧の記録が今までどおり届くこと、問い合わせフォームが送れること、記事の中の図解が本番と同じ高さで表示されることを確かめてから公開しました。

本番で確かめた出力

公開後、本番(pixelstomotors.com)で確かめた出力です。記事化のときに取り直しました。問い合わせフォームへの送信は、本物の問い合わせとして届かないように、すべて見えない欄を埋めた形で送っています。

B=https://pixelstomotors.com
echo "--- headers (top page)"; curl -sI "$B/?v=$RANDOM" | grep -iE "^(x-content|referrer|permissions|strict|content-security)"
echo "--- stats without key"; curl -s -o /dev/null -w "ev-stats %{http_code}\n" "$B/api/ev-stats?v=$RANDOM"; curl -s -o /dev/null -w "hk-stats %{http_code}\n" "$B/api/hk-stats?v=$RANDOM"; curl -s -o /dev/null -w "ev-stats wrong key %{http_code}\n" "$B/api/ev-stats?key=wrong$RANDOM"
echo "--- send endpoints"; curl -s -o /dev/null -w "ev no-origin %{http_code}\n" -X POST $B/api/ev -d '{}'; curl -s -o /dev/null -w "ev other site %{http_code}\n" -X POST -H "origin: https://evil.example" $B/api/ev -d '{}'; curl -s -o /dev/null -w "message GET %{http_code}\n" $B/api/message
echo "--- honeypot"; curl -s -X POST -H "origin: $B" -H 'content-type: application/json' $B/api/message -d '{"website":"log365-check"}'; echo
echo "--- form has hidden field"; curl -s $B/message | grep -c 'name="website"'
echo "--- AI crawlers"; for ua in "GPTBot/1.2" "ClaudeBot/1.0" "PerplexityBot/1.0" "Googlebot/2.1"; do printf "%s " "$ua"; curl -s -o /dev/null -w "%{http_code}\n" -A "Mozilla/5.0 (compatible; $ua)" "$B/record/site-seo-by-ai?v=$RANDOM"; done
--- headers (top page)
strict-transport-security: max-age=31536000
content-security-policy: frame-ancestors 'self'; object-src 'none'; base-uri 'self'
permissions-policy: camera=(), microphone=(), geolocation=(), payment=(), usb=()
referrer-policy: strict-origin-when-cross-origin
x-content-type-options: nosniff
--- stats without key
ev-stats 404
hk-stats 404
ev-stats wrong key 404
--- send endpoints
ev no-origin 403
ev other site 403
message GET 405
--- honeypot
{"ok":true}
--- form has hidden field
1
--- AI crawlers
GPTBot/1.2 200
ClaudeBot/1.0 200
PerplexityBot/1.0 200
Googlebot/2.1 200

鍵・送信の口・見えない欄・守りの指定は、本番でも手元と同じに動いています。

一方、流量制限は本番で確かめきれていません。続けて送ったときの出力です。

--- live rate (honeypot posts x12)
200 200 200 200 200 200 200 200 200 200 200 200 

手元では6回目から断ったのに、本番では12回とも受け取っています。最初はこれを見て「Cloudflare の自動ビルドが、設定ファイルの流量制限の項目を読んでいない」と判断し、エイイチにビルドのログの確認を頼みました。ところが、エイイチが開いた Cloudflare の管理画面の Bindings(プログラムにつながっている部品の一覧)に、RL_EVENT と RL_MESSAGE がちゃんと入っていました。設定は本番に届いていたので、この判断は誤りでした。

本番で断られない理由として考えられるのは2つです。Claude の作業環境は、送るたびに送信元の IP アドレスが変わる経路を通っている可能性があり、それなら同じ相手として数えが溜まりません。また、Cloudflare の流量制限はデータセンターごとに少し遅れて数える仕組みです。効いていないと断定はできないので、保険として、Cloudflare の WAF(攻撃をふるい落とす仕組み)で /api/ を IP アドレスごとに1分20回までにする規則を1本足す案を残しています(無料のプランで1本まで作れる)。

鍵 STATS_KEY を Cloudflare に入れる

鍵の中身は、コードにも GitHub にも置きません。Cloudflare の管理画面で、Workers → p2m-site → 設定 → 変数とシークレット に入れます。

  • 種類は「シークレット」にする(「テキスト」だと値が画面に見えたままになる)
  • 名前(Key)の欄に STATS_KEY、値(Value)の欄に長いランダムな文字列を入れる。最初は2つの欄を逆に入れかけていたので、送る前に入れ直した
  • 文字列は Mac のターミナルで openssl rand -hex 24 を実行して作り、パスワード管理アプリに控える。チャットには貼らない

設定のあと、Claude の側で鍵なしと間違った鍵では 404 のままであること、サイトが普通に表示されることを確かめ、エイイチが pixelstomotors.com/api/ev-stats?key=… で表が見えることを確かめました。11月から週1回 AI に数字を読ませるときは、その作業の中で鍵を安全に渡す方法が要ります。それはそのときに決めます。

ログの設定を wrangler.jsonc に書いた理由

エラーの記録(Workers のログ)がオフになっていました。オフのままだと、問い合わせの通知メールが送れなかったときなどに、何が起きたかが残りません。エイイチが管理画面でオンにしましたが、このサイトは公開のたびに設定ファイル(wrangler.jsonc)の内容で Cloudflare の設定を上書きします。管理画面だけでオンにすると、次に記事を公開した時点でオフに戻ります。そこで、上の wrangler.jsonc の observability の節に同じ内容を書き足し、公開してからサイトが動いていることを確かめました(top 200)。

GitHub の脆弱性とマルウェアの通知をオンにした

Dependabot の設定ファイルが出すのは、定期的な更新のお知らせだけです。危険が見つかったパッケージの通知は、GitHub の p2m-site の Settings → Advanced Security で別にオンにします。最初は全部オフでした。

項目何をするか状態
Dependency graph
使っているパッケージの一覧を作る(下の3つの前提)
オン
Dependabot alerts
危険が見つかったパッケージを知らせる
オン
Dependabot security updates
危険を直す更新を、取り込みの依頼として出す
オン
Grouped security updates
上の依頼を1つにまとめるオン
Malware alerts
npm のパッケージに悪いコードが混ぜられたときに知らせる
オン

2段階認証:GitHub と Google の3つのアカウント

いまのサイトでいちばん狙われやすいのは、サイトの作りではなくアカウントです。GitHub を乗っ取られれば記事もコードも書き換えられ、Cloudflare に入られれば配信そのものを差し替えられます。

アカウント入り方やったこと
GitHubパスワード
認証アプリの2段階認証を設定。リカバリーコード(スマホをなくしたときの予備の鍵)を保管
Cloudflare
Google アカウントでログイン
Cloudflare 側にパスワードを作らず、入口の Google アカウントで設定
Google(サイト用・管理者・仕事用の3つ)
―
3つとも認証アプリの2段階認証を有効にした。管理者のアカウントは、乗っ取られると全部のアカウントを動かされるので最優先

途中で2つ、手順を変えたところがあります。

1つ目。GitHub の設定の途中で、QR コード(認証アプリに読ませる、2段階認証の合鍵の元)が写った画面写真がチャットに送られました。合鍵の元が会話の記録に残ったことになるので、その QR は使わずに取り消し、作り直した新しい QR で設定しました。それ以降、QR コードとリカバリーコードは写真に撮らず、チャットにも貼らない形で進めています。

2つ目。Cloudflare の2段階認証を設定しようとすると、Cloudflare のパスワードを求められました。

CloudflareはGoogleアカウントで入ってるけど?

Google でログインしている場合、Cloudflare に入れるかどうかは Google アカウントで決まります。ここで Cloudflare 用のパスワードを新しく作ると、Google 以外の入口が1つ増えてしまいます。そこで Cloudflare 側の設定はやめ、Google アカウントの「2段階認証プロセス」を有効にする形に切り替えました。電話番号(SMS)の確認は登録済みでしたが、SMS は乗っ取りの手口があるので、認証アプリを主に使う設定にしています。

ドメインの登録先(Squarespace)は、Google でログインしていれば上の設定で守られます。メールアドレスとパスワードで入っている場合は、Squarespace 側でも2段階認証が要ります。ここはまだ確かめていません。

本文の記事番号を、その記事へのリンクにする

守りの作業の途中で、エイイチから別の質問が来ました。

サイトを見てて思いましたが、記事の中でLOG334といった他記事のテキストがありますが、誘導のためのリンクが設定されていません。遷移のための内部ページへのリンクを設定する方が良いですか?先に意見を聞かせてください(サイト内回遊のメリットが大きい?)

付けたほうがよい、と答えました。理由は回遊よりも、本文を読むための導線だからです。このサイトの記事の多くは「前回(LOG334)で〜したので」と前の回を前提にしていて、リンクがないと、前提を確かめたい人はそこで離れます。手順が複数の記事にまたがるとき、たどれることは「同じ手順で再現できる」ことの一部でもあります。一方で、来る人がまだ少ないので、数字がすぐ大きく動くとは考えていません。手間がほぼなく、外れても失うものが少ないので、やる価値がある、という判断です。

決まりは次のとおりにしました。

  • 記事のファイルは書き換えず、サイトを作るとき(ビルド)にリンクを足す。これから書く記事も、本文に LOG334 と書くだけでリンクになる
  • 同じ記事へのリンクは本文の最初の1か所だけ。書き手が手で付けたリンクがあれば、それを1か所目とみなす
  • 自分自身・存在しない番号・コード・見出し・図の説明の中・ファイル名(LOG339_01_….png)・Log#5 のようなシリーズの中の番号はリンクにしない
  • 英語の記事では、英語版(LOG201EN)があればそちらへつなぐ。中のページなので同じタブで移る

DAY の呼び方を LOG にそろえる

DAY200とかはLOGに変更してページリンクを追加してください。そのあたりの見直しも合わせてお願いします。

記事番号の呼び方は、2026年10月16日に DAY から LOG に変えています(1日に何本も出すようになり、DAY が日数を表さなくなったため。番号は続き)。古い記事の本文には DAY200 が残っていました。これも、表示を LOG200 に直してからリンクにします。見直した結果、次のものは直していません。

直さなかったもの理由
LOG343 の本文の DAY
DAY から LOG への呼び方の変更そのものを書いた記事で、DAY を例として書いている
ファイル名・コード・データの名前(DAY123.mp4、H_PICK_BASE_DAY185、test_day170_speed.py、実機ログの中)
実物の名前なので、変えると記録と合わなくなる
day76 などの「Day 2」「Day 4」
記事番号ではなく、作業の段階の名前
存在しない番号
つなぐ先がないので DAY のまま

day39 の「Day 38」だけは記事番号を指していたので、この記事に限って LOG38 に直しています。

src/lib/links.mjs(linkLogsHtml の全量)

// 本文の記事番号(LOG334 など)を、その記事へのリンクにする(2026-10-23)
// - logs: 記事番号('LOG334'・'LOG201EN')→ slug の対応。self: いまの記事の slug(自分へはリンクしない)
// - 同じ記事へのリンクは、本文の最初の1か所だけ(書き手が手で付けたリンクがあれば、それを1か所目とみなす)
// - 古い呼び方の DAY200 は、表示も LOG200 に直す(2026-10-16 に記事番号の呼び方を DAY から LOG に変えたため。番号は同じ)。
//   ただし renameDay=false の記事(呼び方の変更そのものを書いた LOG343)では、DAY を例として書いているので変えず、リンクも付けない
// - リンク・コード・図の中は変えない。見出しと図の説明の中は、DAY→LOG の書き換えだけしてリンクは付けない
// - 英語の記事の中では、英語版(LOG201EN)があればそちらへつなぐ
// - ファイル名(DAY339_01_….png・LOG346.md)や Log#5(シリーズの中の番号)は触らない
// - リンクの中は、DAY→LOG の書き換えだけする(手で付けたリンクの文字が DAY337 のこともある)
const LOG_KEEP = new Set(['code', 'pre', 'script', 'style', 'textarea', 'iframe', 'svg']);
const LOG_NOLINK = new Set(['a', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'figcaption']);
const LOG_RE = /(?<![A-Za-z0-9_\/#.\-])(LOG|DAY)(\d{1,4})(EN)?(?![A-Za-z0-9_.\-])/g;
// 「Day 38」(大小文字・空白入り)は、記事番号の意味で使っている記事でだけ読む(ほかの記事では作業の段階の名前のことがある)
const LOG_RE_SPACED = /(?<![A-Za-z0-9_\/#.\-])()Day (\d{1,4})()(?![A-Za-z0-9_.\-])/g;

export function linkLogsHtml(html, logs, self, english = false, renameDay = true, spacedDay = false) {
  if (!html || !logs) return html;
  const done = new Set([self]);
  const parts = html.split(/(<[^>]+>)/);
  const stack = [];
  for (let i = 0; i < parts.length; i++) {
    const p = parts[i];
    if (!p) continue;
    if (p[0] === '<') {
      const t = /^<\s*(\/)?\s*([a-zA-Z0-9]+)/.exec(p);
      if (!t) continue;
      const name = t[2].toLowerCase();
      const own = name === 'a' && !t[1] && /\bhref="\/record\/([^"#?]+)/.exec(p);
      if (own) done.add(own[1]);
      if (!(LOG_KEEP.has(name) || LOG_NOLINK.has(name)) || /\/>$/.test(p)) continue;
      if (t[1]) { const k = stack.lastIndexOf(name); if (k >= 0) stack.splice(k); }
      else stack.push(name);
      continue;
    }
    if (stack.some((n) => LOG_KEEP.has(n))) continue;
    const noLink = stack.length > 0;
    const re = spacedDay ? new RegExp(`${LOG_RE.source}|${LOG_RE_SPACED.source}`, 'g') : LOG_RE;
    parts[i] = p.replace(re, (m, k1, n1, e1, k2, n2, e2) => {
      const kind = k1 ?? 'DAY', n = n1 ?? n2, en = e1 ?? e2;
      if (kind === 'DAY' && !renameDay) return m;
      const num = String(Number(n));
      const key = en ? `LOG${num}EN` : english && logs[`LOG${num}EN`] ? `LOG${num}EN` : `LOG${num}`;
      const slug = logs[key];
      if (!slug) return m; // 存在しない番号は、DAY のままにして触らない
      const text = kind === 'DAY' ? `LOG${num}${en || ''}` : m;
      if (noLink || done.has(slug)) return text;
      done.add(slug);
      return `<a href="/record/${slug}">${text}</a>`;
    });
  }
  return parts.join('');
}

src/pages/record/[slug].astro(変更の差分)

commit c962d6a 本文の記事番号(LOG334 など)を、その記事へのリンクにする

diff --git a/src/pages/record/[slug].astro b/src/pages/record/[slug].astro
index 26df572..023d449 100644
--- a/src/pages/record/[slug].astro
+++ b/src/pages/record/[slug].astro
@@ -8,7 +8,7 @@ import RecordCard from '../../components/RecordCard.astro';
 import { getCollection, render } from 'astro:content';
 import { existsSync } from 'node:fs';
 import aiConsult from '../../data/aiConsult.json';
-import { linkifyHtml, emphasizeHtml } from '../../lib/links.mjs';
+import { linkifyHtml, emphasizeHtml, linkLogsHtml } from '../../lib/links.mjs';
 import { roomyTables } from '../../lib/tables.mjs';
 import emphasis from '../../data/emphasis.json';
 
@@ -47,7 +47,10 @@ if (kind === 'studio') {
 }
 // 本文の URL・サイトの住所をリンクにする(中の住所は同じタブ、外のサイトは新しいタブ。src/lib/links.mjs)
 // 記事のいちばん大事な一文を太字にする(src/data/emphasis.json)
-body = linkifyHtml(emphasizeHtml(body, (emphasis as Record<string, string[]>)[r.slug] || []));
+// 本文の記事番号(LOG334 など)を、その記事へのリンクにする(同じ記事へは最初の1か所だけ・自分へはつながない)
+const LOGS = Object.fromEntries((await allCards()).map((c) => [c.day, c.slug]));
+const isEnglish = /-en$/.test(r.slug);
+body = linkifyHtml(linkLogsHtml(emphasizeHtml(body, (emphasis as Record<string, string[]>)[r.slug] || []), LOGS, r.slug, isEnglish));
 // 表を窮屈にしない(本文より広い表は横スクロール・短いセルは折り返さない・長いセルは最小の幅を持たせる。src/lib/tables.mjs)
 body = roomyTables(body);
 
commit c3ed19d 本文と図解の古い記事番号 DAY を LOG に直し、本文ではリンクにする

diff --git a/src/pages/record/[slug].astro b/src/pages/record/[slug].astro
index 023d449..bedb097 100644
--- a/src/pages/record/[slug].astro
+++ b/src/pages/record/[slug].astro
@@ -47,10 +47,11 @@ if (kind === 'studio') {
 }
 // 本文の URL・サイトの住所をリンクにする(中の住所は同じタブ、外のサイトは新しいタブ。src/lib/links.mjs)
 // 記事のいちばん大事な一文を太字にする(src/data/emphasis.json)
-// 本文の記事番号(LOG334 など)を、その記事へのリンクにする(同じ記事へは最初の1か所だけ・自分へはつながない)
+// 本文の記事番号(LOG334 など)を、その記事へのリンクにする(同じ記事へは最初の1か所だけ・自分へはつながない)。
+// 古い呼び方の DAY200 は LOG200 に直す。LOG343(DAY から LOG への呼び方の変更を書いた記事)は DAY を例として書いているので直さない
 const LOGS = Object.fromEntries((await allCards()).map((c) => [c.day, c.slug]));
 const isEnglish = /-en$/.test(r.slug);
-body = linkifyHtml(linkLogsHtml(emphasizeHtml(body, (emphasis as Record<string, string[]>)[r.slug] || []), LOGS, r.slug, isEnglish));
+body = linkifyHtml(linkLogsHtml(emphasizeHtml(body, (emphasis as Record<string, string[]>)[r.slug] || []), LOGS, r.slug, isEnglish, r.slug !== 'studio-shutdown-cloudflare-redirect', r.slug === 'day39'));
 // 表を窮屈にしない(本文より広い表は横スクロール・短いセルは折り返さない・長いセルは最小の幅を持たせる。src/lib/tables.mjs)
 body = roomyTables(body);
 

図解の中の DAY を LOG に書き換えた22か所

図解(記事に埋め込んでいる HTML)の中の記事番号は、ファイルの文字そのものを書き換えました。コメント・コードの行・<pre> の中は対象外にし、姿勢やデータの名前として DAY を使っている2つの図(day172 と LOG334 の図)も外しています。図解の中はリンクにしていません。

#ファイル置き換え前後の文字
1
DAY335_idea_to_script.html
DAY173 → LOG173
械」は気になる。肩の関節の指令がエラーなしで捨てられた記録(LOG173)がある。'},
2
DAY335_idea_to_script.html
DAY222 → LOG222
1 と同じ「黙って起きる失敗」の話として11本目にまとめた(LOG222)。'},
3
day104-xTqyGQKj.html
DAY101 → LOG101
ルツイン → シミュレーション → 実機展開のパイプライン。LOG101でシミュレーションフェーズ完了。12パターン全su
4
day104-xTqyGQKj.html
DAY01 → LOG1
師の業務負担軽減を目指した薬剤搬送ロボットのコンセプト設計。LOG1から全35記録。',
5
day104-xTqyGQKj.html
DAY50 → LOG50
で構造化。状態遷移設計やユーザー起点の基本動作フローを定義。LOG50〜56の7記録。',
6
day136-2UFJuGwY.html
DAY83 → LOG83
LOG83で AIスクリプトがデータを破壊した事故。あのとき
7
day147-C9oEKc7p.html
DAY126 → LOG126
"detail-label">02 — 固有名詞の引力 / LOG126</div>
8
day147-C9oEKc7p.html
DAY126 → LOG126
class="stat-desc">ユニークユーザー<br>LOG126(固有名詞あり)</div>
9
day153-pKJx8RF0.html
DAY152 → LOG152
m: "Claude Codeが生成したPi側スクリプトに、LOG152で禁止確定したsend_coords()が含まれて
10
day153-pKJx8RF0.html
DAY152 → LOG152
(コード生成側のコンテキスト)に書かれていなかった。口頭で「LOG152で確定」と伝えても、生成コンテキストには反映されな
11
day157-02ZPXfWv.html
DAY156 → LOG156
<p>LOG156で手動確認した白箱真上の姿勢。そのときのEE座標は
12
day157-02ZPXfWv.html
DAY154 → LOG154
たeeは手首フランジの位置だった。グリッパ先端ではなかった。LOG154からの統合テーブル213,312点全体が、同じ手首
13
day157-02ZPXfWv.html
DAY154 → LOG154
。10mm以内に3,683点、20mm以内に12,541点。LOG154で実機PICKが成立していた理由が、新座標系で再現
14
day160-nqTsdXQr.html
DAY160 → LOG160
note: 'この構造がLOG160朝の時点で動いていた土台。lookup距離がサブミ
15
day90-QYNCtlsg.html
DAY90 → LOG90
:'イテレーションを重ねるごとにEEはTargetに近づく。LOG90実装では到達精度2mm(dist=0.002)を確
16
day94-Onp1SfC6.html
DAY94 → LOG94
title: 'LIFT — 真上に持ち上げ ✦ LOG94新規',
17
day94-Onp1SfC6.html
DAY94 → LOG94
title: 'LOWER — 地面まで下降 ✦ LOG94新規',
18
day94-Onp1SfC6.html
DAY94 → LOG94
title: 'PLACE — 設置・解放 ✦ LOG94改修',
19
mecharm-reach-control-ssh-overhead-analysis-S8g_cpmD.html
DAY182 → LOG182
ort・MyCobot初期化のオーバーヘッドをゼロにできる。LOG182実測では76Hz(1指令13ms)。set_fre
20
mecharm-static-to-dynamic-VzHfWjMx.html
DAY179 → LOG179
ット端(Z方向=0.304m)のため姿勢制約後の候補が0点。LOG179から続くlookup失敗パターン。fk_scan_
21
mecharm-static-to-dynamic-VzHfWjMx.html
DAY180 → LOG180
mode=1に切り替えると動作中でも次の指令で上書きできる。LOG180の視覚サーボでは未使用だった。',
22
visual-article-builder-yaiqXUST.html
DAY192 → LOG192
・フロー型の3種類のテンプレを作って論点を流し込む案を提示。LOG192の9論点中8つがパターンA(タブ切替)に当てはまる

本文のリンクを数えた出力

ビルドしたサイトの全記事を読み、本文の記事番号リンクを数えた出力です(記事化のときに取り直し)。見出しやコードの中のリンク、自分へのリンクはありません。

記事数 119 本文の記事番号リンク 418 見出し・コードの中 0 自分へのリンク 0

週1回の AI 更新を、GitHub の仕組みで2ファイルに縛る

課題地図(/issues)は、予約した AI が毎週月曜の朝に、世の中の新しい動きを調べて「最近の動き」に足しています。これまでの流れでは、AI が本線(main)に直接書き込み、約40秒後にはサイトに出ていました。触ってよいのは src/data/issues/news.json(最近の動き)と src/data/issues/checks.json(調べた記録)の2つだけ、という決まりは、AI への指示文と手順書に書いてあるだけでした。

決まりを守るかどうかを AI の側に任せず、GitHub の側で確かめる形に変えました。

これまでこれから
AI が書く先本線(main)に直接
bot/issues-weekly-日付 という別の枝
取り込みなし(書いた時点で公開)
取り込みの依頼(プルリクエスト)を出す
確かめなし
GitHub Actions(GitHub の上で決まった手順を自動で動かす仕組み)が、変わったファイル・データの検査・ビルドを確かめる
公開書いた約40秒後
確かめが全部通ったときだけ取り込み、その約40秒後
決まりの外のファイルが入っていたら
そのまま公開される
取り込まずに止め、依頼を開いたまま残す

確かめの中身を、AI の枝から書き換えられないようにする

GitHub Actions は、ふつうは「送られてきた枝にある手順書」で動きます。それだと、AI が自分の枝で確かめの手順そのものを書き換えれば、確かめをすり抜けられます。そこで pull_request_target という起動のしかたを使いました。これは、取り込み先(main)にある手順書で動きます。枝の側で手順書を書き換えても、確かめの中身は変わりません。

もう一つ、pull_request_target には、枝の中のコードを書き込みの権限つきで動かすと危ない、という注意点があります。そのため、枝のコードは動かしていません。main のコードを取り出し、そこに枝の2つの JSON だけを重ねて、データの検査とビルドをします。確かめる仕事(読むだけの権限)と、取り込む仕事(書き込みの権限)も分け、取り込みは確かめが通った版と同じ中身のときだけ行います(--match-head-commit)。

.github/workflows/issues-weekly-gate.yml(全文)

# 課題地図の週1回の自動更新(予約タスクの Claude)を、確かめてから main に取り込む。
#
# - 予約タスクは main に直接 push せず、bot/issues-weekly-YYYY-MM-DD の枝からプルリクエストを出す
# - この仕組みは、変わったファイルが src/data/issues/news.json と checks.json の2つだけかを確かめ、
#   データの検査(tools/check_issues_data.py)とサイトのビルドが通ったときだけ、自動で取り込む
# - それ以外のファイルが入っていたら取り込まず、プルリクエストを開いたまま残す(エイイチが見る)
#
# pull_request_target を使うので、動くのは main にあるこのファイルの中身。
# 枝の側でこのファイルを書き換えても、確かめの中身は変えられない。
# 枝のコードは実行しない(main のコードに、枝の2つの JSON だけを重ねてビルドする)。
name: 課題地図の自動更新を確かめて取り込む

on:
  pull_request_target:
    types: [opened, synchronize, reopened]
    branches: [main]

permissions: {}

concurrency:
  group: issues-weekly-${{ github.event.pull_request.number }}
  cancel-in-progress: true

jobs:
  check:
    if: >-
      startsWith(github.head_ref, 'bot/issues-weekly-') &&
      github.event.pull_request.head.repo.full_name == github.repository
    runs-on: ubuntu-latest
    permissions:
      contents: read
    steps:
      - uses: actions/checkout@v4
        with:
          ref: main
          fetch-depth: 0

      - name: 変わったファイルが2つの JSON だけか確かめ、main に重ねる
        env:
          HEAD_SHA: ${{ github.event.pull_request.head.sha }}
        run: |
          set -eu
          git fetch --no-tags origin "$HEAD_SHA"
          base=$(git merge-base origin/main "$HEAD_SHA")
          changed=$(git diff --no-renames --name-only "$base" "$HEAD_SHA")
          echo "変わったファイル:"
          echo "$changed"
          if [ -z "$changed" ]; then
            echo "::error::変更がありません"
            exit 1
          fi
          bad=$(printf '%s\n' "$changed" | grep -vxE 'src/data/issues/(news|checks)\.json' || true)
          if [ -n "$bad" ]; then
            echo "::error::自動で取り込めないファイルが含まれています(エイイチの確認が要ります): $bad"
            exit 1
          fi
          git -c user.name=gate -c user.email=gate@invalid merge --no-ff --no-commit "$HEAD_SHA"
          git diff --cached --name-only

      - uses: actions/setup-node@v4
        with:
          node-version: 22

      - name: データを検査する
        run: python3 tools/check_issues_data.py

      - name: サイトをビルドする
        run: |
          npm ci
          npm run build

  merge:
    needs: check
    runs-on: ubuntu-latest
    permissions:
      contents: write
      pull-requests: write
    steps:
      - name: 取り込む(確かめた版と同じときだけ)
        env:
          GH_TOKEN: ${{ github.token }}
          PR: ${{ github.event.pull_request.number }}
          HEAD_SHA: ${{ github.event.pull_request.head.sha }}
        run: |
          gh pr merge "$PR" --repo "$GITHUB_REPOSITORY" --squash --delete-branch --match-head-commit "$HEAD_SHA"

手順書 tools/issues_weekly.md の変更(差分)

課題の件数の行(36件→38件)は、このあと取り込んだ課題の分です(後述)。

diff --git a/tools/issues_weekly.md b/tools/issues_weekly.md
index 6ae787c..e55b229 100644
--- a/tools/issues_weekly.md
+++ b/tools/issues_weekly.md
@@ -6,7 +6,7 @@ pixelstomotors.com/issues(フィジカルAIが社会実装される前に解
 
 | 変更 | 扱い |
 |---|---|
-| 「最近の動き」を足す(src/data/issues/news.json) | 自動で公開してよい。main に push する |
+| 「最近の動き」を足す(src/data/issues/news.json) | 自動で公開してよい。bot/issues-weekly-日付 の枝からプルリクエストを出すと、GitHub の仕組みが確かめてから自動で取り込む(5) |
 | 調べた記録を残す(src/data/issues/checks.json) | 自動で公開してよい(何も見つからない週も必ず1行残す) |
 | 緊急度・深刻度・時間軸・分類を変える | 確認が要る。変更案として出す |
 | 規制・制度の状態の文言を変える(meta.json) | 確認が要る。変更案として出す |
@@ -14,6 +14,8 @@ pixelstomotors.com/issues(フィジカルAIが社会実装される前に解
 
 変更案は、main とは別の枝(ブランチ)に入れて、GitHub のプルリクエストとして出す。自分で main に取り込まない。取り込むかどうかはエイイチが決める。
 
+**main には直接 push しない(どの変更も)。** 自動で公開してよい2つのファイルも、枝からプルリクエストを出して、仕組み(`.github/workflows/issues-weekly-gate.yml`)に取り込ませる。仕組みは、変わったファイルが news.json と checks.json の2つだけで、データの検査とビルドが通ったときだけ取り込む。
+
 ## 1. 準備
 
 ```
@@ -27,7 +29,7 @@ python3 tools/check_issues_data.py
 
 ## 2. 何を調べるか
 
-課題は `src/content/issues/*.md` の36件、規制・制度は `src/data/issues/meta.json` の regulations の6件。すべての課題を毎回1件ずつ深く調べる必要はない。次の順で、期間内に新しく出たものを探す。
+課題は `src/content/issues/*.md` の38件、規制・制度は `src/data/issues/meta.json` の regulations の6件。すべての課題を毎回1件ずつ深く調べる必要はない。次の順で、期間内に新しく出たものを探す。
 
 1. 規制・制度の6件:所管の役所(警察庁・国土交通省・厚生労働省・消費者庁・総務省・個人情報保護委員会・経済産業省)の発表、法改正・ガイドライン・パブリックコメント
 2. 安全規格・標準:ISO(産業用ロボットの安全・協働ロボット・生活支援ロボット)、JIS、EU の機械規則や AI 法のうちロボットに関わるもの
@@ -65,17 +67,24 @@ python3 tools/check_issues_data.py
 足すものを JSON の配列で一時ファイルに書き(形は `tools/add_issue_news.py` の先頭の説明のとおり)、次を実行する。
 
 ```
+git checkout main
+git pull origin main
+git checkout -b bot/issues-weekly-YYYY-MM-DD
 python3 tools/add_issue_news.py /tmp/new_news.json --note "今回見た範囲や気づいたことを1文で"
 npm run build
 git add src/data/issues/news.json src/data/issues/checks.json
 git commit -m "課題地図:最近の動きを追加(YYYY-MM-DD・N件)"
-git push origin main
+git push origin bot/issues-weekly-YYYY-MM-DD
+gh pr create --base main --head bot/issues-weekly-YYYY-MM-DD --title "課題地図:最近の動きを追加(YYYY-MM-DD・N件)" --body "週1回の自動更新。news.json と checks.json だけを変える。"
 ```
 
-- 0件の週も、`[]` を渡して実行し、checks.json の記録だけを push する
+- 枝の名前は必ず `bot/issues-weekly-` で始める。この名前の枝のプルリクエストだけを、仕組みが確かめて取り込む
+- この枝では news.json と checks.json 以外を変えない。ほかのファイルが1つでも入っていると、取り込まれずに止まる
+- 0件の週も、`[]` を渡して実行し、checks.json の記録だけを同じ手順で出す
 - `git add .` は使わない。`git clean` は使わない
 - ビルドが失敗したら push しない。原因を直せなければ、何が起きたかを報告して終わる
-- push すると、約40秒後に pixelstomotors.com に反映される
+- プルリクエストを出すと、数分で仕組みが確かめて取り込み、その約40秒後に pixelstomotors.com に反映される。待たずに終えてよい
+- `gh` が使えないときは、枝の push までで終え、報告に枝の名前を書く(エイイチがプルリクエストを出す)
 
 ## 6. 確認が要る変更を、変更案として出す
 

予約タスクへの指示文(変更前・変更後の全文)

変更前:

あなたは pixelstomotors.com/issues(フィジカルAIが社会実装される前に解くべき課題)を、週1回、世の中の新しい動きに合わせて更新する係です。返事も記録もすべて日本語で書く。

1. GitHub の PixelstoMotors/p2m-site を clone する(git clone https://github.com/PixelstoMotors/p2m-site)。clone や push ができなければ、add_repo でこのリポジトリを push 権限つきで足してからやり直す。
2. clone したリポジトリの tools/issues_weekly.md を最初に全部読み、その手順のとおりに作業する。手順書と、この指示が食い違ったら手順書に従う。
3. 守ること(手順書にも書いてある要点):
   - main に push してよいのは src/data/issues/news.json(最近の動き)と src/data/issues/checks.json(調べた記録)の2つだけ。何も見つからない週も checks.json に記録を1行残して push する。
   - 緊急度・深刻度・規制の状態・本文・分類・マップの線を変えたいときは、別の枝に入れて GitHub のプルリクエストとして出すだけにする。自分で main に取り込まない。
   - 最近の動きは、出典のページを開いて事実を確かめたものだけを入れる。要約は出典の文をなぞらず自分の言葉で書き、引用しない。評価や予想は書かない。迷ったら入れない。
   - git add は1ファイルずつ指定する。git add . と git clean は使わない。ビルドが失敗したら push しない。
4. 終わったら、足した動きの件数と見出し、変更案を出したかどうか(出したならその住所)、うまくいかなかったことを、短く報告する。

変更後:

あなたは pixelstomotors.com/issues(フィジカルAIが社会実装される前に解くべき課題)を、週1回、世の中の新しい動きに合わせて更新する係です。返事も記録もすべて日本語で書く。

1. GitHub の PixelstoMotors/p2m-site を clone する(git clone https://github.com/PixelstoMotors/p2m-site)。clone や push ができなければ、add_repo でこのリポジトリを push 権限つきで足してからやり直す。
2. clone したリポジトリの tools/issues_weekly.md を最初に全部読み、その手順のとおりに作業する。手順書と、この指示が食い違ったら手順書に従う。
3. 守ること(手順書にも書いてある要点):
   - main には直接 push しない(どの変更も)。
   - 「最近の動き」の追加と調べた記録は、bot/issues-weekly-YYYY-MM-DD という名前の枝に入れて push し、main 宛てのプルリクエストを出す。その枝で変えてよいのは src/data/issues/news.json と src/data/issues/checks.json の2つだけ。GitHub の仕組みが中身を確かめてから自動で取り込むので、取り込みを待たずに終えてよい。何も見つからない週も checks.json に記録を1行残して同じ手順で出す。
   - 緊急度・深刻度・規制の状態・本文・分類・マップの線を変えたいときは、issues-proposal-YYYY-MM-DD の枝に入れて GitHub のプルリクエストとして出すだけにする。取り込むかどうかはエイイチが決める。
   - 最近の動きは、出典のページを開いて事実を確かめたものだけを入れる。要約は出典の文をなぞらず自分の言葉で書き、引用しない。評価や予想は書かない。迷ったら入れない。
   - git add は1ファイルずつ指定する。git add . と git clean は使わない。ビルドが失敗したら push しない。
4. 終わったら、足した動きの件数と見出し、出したプルリクエストの住所(自動取り込み用と、変更案があればその住所)、うまくいかなかったことを、短く報告する。

取り込まれてはいけない依頼と、取り込まれてよい依頼で試した

本物の更新を待たずに、2つの依頼を作って試しました。

依頼中身期待
#4
checks.json の末尾の改行を消す+README.md に空行を1行足す
止まる(README.md は決まりの外)
#5
checks.json の末尾の改行を消すだけ
取り込まれる
#4 試験:取り込まれてはいけない変更 state=closed merged=false
  Workers Builds: p2m-site: completed failure
  merge: completed skipped
  check: completed failure
#5 試験:取り込まれてよい変更 state=closed merged=true
  merge: completed success
  check: completed success

#4 の確かめ(check)は失敗し、取り込み(merge)は動かずに飛ばされました。失敗の理由として出た文は次のとおりです。

自動で取り込めないファイルが含まれています(エイイチの確認が要ります): README.md

#5 は確かめが通り、自動で取り込まれました。そのあと本線で末尾の改行を戻しています。#4 の行にある「Workers Builds: p2m-site: completed failure」は、Cloudflare が試験の枝の試し公開を作ろうとして失敗したもので、この確かめとは別です。

この仕組みで守れないこと

予約タスクの AI は、エイイチと同じ GitHub の権限を持っています。指示を無視すれば、本線に直接書くことは技術的にはまだできます。今回の仕組みは、ふだんの通り道を「確かめてから取り込む」形にしたもので、直接書き込む道そのものをふさいではいません。完全にふさぐには、本線を「取り込みの依頼でしか変えられない」設定にする必要があります。そうすると、Claude がふだん行っている記事の公開も毎回依頼を経由することになるので、今回は入れていません。

Dependabot が出した取り込みの依頼3件を確かめた

Dependabot を入れた直後に、取り込みの依頼が3件届いていました(1件は週1回の AI が前の週に出したもの)。

番号中身判断
#2
unist-util-visit を 5.0.0 → 5.1.0(小さな更新)
取り込んだ。前後でビルドしたサイトの中身に差がないことを確かめた
#3
Astro(サイトを作る道具)を 5.13.5 → 7.4.1
保留。大きく変わるので、壊れないかを時間を取って確かめる必要がある
#1
週1回の AI が出した、課題を1件足す案
中身はエイイチが判断。本線とぶつかっていて、そのままでは取り込めなかった

#1 が取り込めなかった理由

どんなところが問題ですか?詳しく教えてください

#1 は10月17日、週1回の AI が「37番目の課題」として出した「人のデモとロボットの腕のずれ」です。同じ日に、別の作業で「動作の成功を安く確かめる仕組みの不在」という課題が先に本線に入り、これも37番になっていました。試しに本線と重ねた出力です。

Auto-merging src/data/issues/meta.json
CONFLICT (content): Merge conflict in src/data/issues/meta.json
Auto-merging tools/issues_weekly.md
Automatic merge failed; fix conflicts and then commit the result.
src/data/issues/meta.json
diff --cc src/data/issues/meta.json
index b088b35,e9b119f..0000000
--- a/src/data/issues/meta.json
+++ b/src/data/issues/meta.json
@@@ -261,9 -261,9 +261,15 @@@
      "x": 281.0,
      "y": 291.0
     },
++<<<<<<< HEAD
 +   "evaluation-verification-gap": {
 +    "x": 860.0,
 +    "y": 205.0
++=======
+    "demo-embodiment-gap": {
+     "x": 580.0,
+     "y": 230.0
++>>>>>>> origin/pr1
     }
    },
    "edges": [
src/content/issues/demo-embodiment-gap.md:2:id: 37
src/content/issues/demo-embodiment-gap.md:3:slug: demo-embodiment-gap
src/content/issues/demo-embodiment-gap.md:4:title: 人のデモとロボットの腕のずれ
src/content/issues/demo-embodiment-gap.md:5:category: 学習・データ・モデル
src/content/issues/demo-embodiment-gap.md:6:category_slug: learning-data
src/content/issues/demo-embodiment-gap.md:8:urgency: 4
src/content/issues/demo-embodiment-gap.md:9:severity: 4
src/content/issues/demo-embodiment-gap.md:16:status: draft
src/content/issues/evaluation-verification-gap.md:2:id: 37
src/content/issues/evaluation-verification-gap.md:3:slug: evaluation-verification-gap
src/content/issues/evaluation-verification-gap.md:4:title: 動作の成功を安く確かめる仕組みの不在
src/content/issues/evaluation-verification-gap.md:5:category: 学習・データ・モデル
src/content/issues/evaluation-verification-gap.md:6:category_slug: learning-data
src/content/issues/evaluation-verification-gap.md:8:urgency: 4
src/content/issues/evaluation-verification-gap.md:9:severity: 4
src/content/issues/evaluation-verification-gap.md:16:status: draft

ぶつかっていたのは2か所です。番号が同じ37番であること、課題マップの位置を書く設定ファイル(meta.json)の同じ行に2件とも自分の位置を書いていたこと。課題同士をつなぐ線(5本)はぶつかっていませんでした。

もう一つ、中身にも問題がありました。本文の「何が起きているか」に、SNS で共有された試算(「5,000以上の腕の設計を試したら、既存の腕では3〜6割が再現に失敗した」)が入っていて、本文自身に「※要出典確認」と付いていました。課題地図の決まりは「出典を開いて確かめたものだけを入れる」です。

関連する既存の課題とは、扱う範囲が分かれていました。

課題何の問題か
動作のポータビリティ不在(既存)
あるメーカーの腕で作った動きが、別メーカーの腕で動かない(ロボット → ロボット)
模倣学習のデータ品質(既存)
集めたお手本の品質の基準がない
人のデモとロボットの腕のずれ(#1)
人が手で集めたお手本を、形の違う腕では再現できない(人 → ロボット)

載せる

#1 を直して取り込んだ

  • 番号を38にした
  • 課題マップの位置を2件とも残した(新しい課題といちばん近い課題との間隔は74。マップ全体で課題同士のいちばん近い間隔は26なので、重ならない)
  • SNS の試算の1項目を、本文と出典の欄から外し、更新履歴に1行足した
  • 手順書の課題の件数を38件にした
38 1000 800
[(74, 'sim2real-gap'), (85, 'caregiving-robot-adoption'), (91, 'imitation-data-quality'), (99, 'robot-literate-workforce'), (165, 'real-robot-data-scarcity')]
[26, 41, 61, 66, 71]
src/content/issues/evaluation-verification-gap.md:2:id: 37
src/content/issues/demo-embodiment-gap.md:2:id: 38
OK  課題 38 件/最近の動き 7 件

元の案(10月17日の版)から、取り込んだ版への変更の全量です。

diff --git a/src/content/issues/demo-embodiment-gap.md b/src/content/issues/demo-embodiment-gap.md
index fbd4c3f..c04a039 100644
--- a/src/content/issues/demo-embodiment-gap.md
+++ b/src/content/issues/demo-embodiment-gap.md
@@ -1,5 +1,5 @@
 ---
-id: 37
+id: 38
 slug: demo-embodiment-gap
 title: 人のデモとロボットの腕のずれ
 category: 学習・データ・モデル
@@ -12,7 +12,7 @@ layer: 技術
 actors: [研究者, メーカー, 個人開発者, OSSコミュニティ]
 tags: [模倣学習, 身体の違い, 手持ち収集, 運動学]
 related_regulations: なし
-last_verified: 2026-10-17
+last_verified: 2026-10-23
 status: draft
 ---
 
@@ -47,7 +47,6 @@ status: draft
 - 2026年には、手持ち収集の規模が大きく伸びた。日本の AI ロボット協会(AIRoA)・トヨタ自動車・東京大学などによる YUBI は、指で直接つまむ形の手持ち道具で約8,400時間・119作業のデータを集め、同じ方策を複数メーカーの腕で動かしたと報告している(CoRL 2026 採択)。
 - 人の一人称映像を事前学習に使う研究も進む。北京大学などの HumanScale(2026年6月)は、5,000時間の人の一人称映像で事前学習したモデルが、同じ時間のロボット実機データで事前学習したモデルを、未知の作業で上回ったと報告した(著者自身は予備的な結果としている)。
 - 一方で、腕へ移すときのずれも数字で見え始めた。Mu Robotics の HandUMI(2026年8月)は、手で集めた動きを複数の腕で再生して確かめる仕組みを公開し、オープンソースの腕 OpenArm への再生では位置の誤差が約3〜5cm、3件中1件しか基準を満たさなかったと示している(シミュレーション上の確認)。
-- SNS 上では、5,000以上の卓上アームの設計を手持ち収集のお手本で試したところ、既存の腕では3〜6割が再現に失敗し、腕の設計を変えると失敗が1割以下に下がったという試算も共有されている(シミュレーションのみ・同じ制御器・学習済み方策では未検証。※要出典確認)。
 
 ## なぜ先送りされてきたか
 
@@ -169,6 +168,6 @@ status: draft
 
 ## 出典・関連課題・更新履歴
 
-- 出典:Chi et al.「Universal Manipulation Interface」(RSS 2024)、YUBI(AIRoA ほか、arXiv 2606.10244・CoRL 2026)、HumanScale(Ma et al., arXiv 2606.20521)、HandUMI(Mu Robotics、2026年8月)、Universal Manipulation Exoskeleton(Liang, Xu et al., arXiv 2606.14218)、Ego2Robot(Wang et al., arXiv 2608.02580)。卓上アーム設計の試算は SNS 上の共有(※要出典確認)。
+- 出典:Chi et al.「Universal Manipulation Interface」(RSS 2024)、YUBI(AIRoA ほか、arXiv 2606.10244・CoRL 2026)、HumanScale(Ma et al., arXiv 2606.20521)、HandUMI(Mu Robotics、2026年8月)、Universal Manipulation Exoskeleton(Liang, Xu et al., arXiv 2606.14218)、Ego2Robot(Wang et al., arXiv 2608.02580)。
 - 関連課題:[模倣学習のデータ品質](/issues/imitation-data-quality) / [実機データの圧倒的不足](/issues/real-robot-data-scarcity) / [Sim2Realギャップ](/issues/sim2real-gap) / [動作のポータビリティ不在](/issues/motion-portability) / [現場知のデータ化](/issues/tacit-knowledge-capture)
-- 更新履歴:2026-10-17 初版(draft)
+- 更新履歴:2026-10-17 初版(draft)/2026-10-23 出典を確かめられない SNS 上の試算を外して公開

ビルドが通ったあと、取り込みの依頼 #1 を本線に取り込み、本番で確かめた出力です。

200 https://pixelstomotors.com/issues/demo-embodiment-gap
200 https://pixelstomotors.com/issues
人のデモとロボットの腕のずれ
38件
0

最後の 0 は、公開されたページに「要出典」の文字が残っていないことを示しています。

この日に入れたものと、残っていること

本線に入れた変更の一覧です。

コミット時刻中身
be7260204:49
数の表に鍵、送信の口に流量制限、全ページに守りの指定、Dependabot、プライバシーポリシー
c962d6a04:58
本文の記事番号をその記事へのリンクにする
c3ed19d05:05
本文と図解の古い記事番号 DAY を LOG に直す
8add22708:33
ログの設定を wrangler.jsonc に書く
99c65f209:21
#2 を取り込む(unist-util-visit 5.1.0)
3d8c23109:24
週1回の AI 更新を、枝と取り込みの依頼を経由させ、仕組みで確かめる
2e61c1209:25試験 #5 の取り込み
a707e3d09:27
試験の後始末(末尾の改行を戻す)
2b91ed409:50
#1 を直して取り込む(課題「人のデモとロボットの腕のずれ」・38課題)

README.md に書き足した節(差分)です。

commit be72602 サイトの守りを固める:数の表に鍵、送信の口に流量制限、全ページに守りの指定

diff --git a/README.md b/README.md
index 3fa3c0b..d1e2272 100644
--- a/README.md
+++ b/README.md
@@ -65,3 +65,11 @@ AI活用の記事の末尾には、相談の案内(`/message?topic=ai` への
 - 本文に文字のまま書かれた URL(https://…)と住所(pixelstomotors.com/issues など)は、ビルドのときに自動でリンクになる(`src/lib/links.mjs` の `linkifyHtml`)。このサイトの住所(旧 adaptdesign.jp/pixelstomotors も)は同じタブで開く相対の住所に、外のサイトは新しいタブで開く。もとからある `<a>` も同じ決まりにそろえる。コード・`<pre>`・リンクの中、旧 adaptdesign.jp の /pixelstomotors 以外、/record/ のような途中までの住所はリンクにしない
 - 記事のいちばん大事な一文は太字にする。新しい Markdown の記事は本文に `**…**` で書く。太字が2つ以下だった過去の記事は、`src/data/emphasis.json`(slug → 文の一覧)の文を、本文の最初の1か所だけ `<strong>` で囲む(`emphasizeHtml`)。見出し・コード・リンク・既にある太字の中は囲まない
 - 記事・課題地図の本文の表は、ビルドのときに `src/lib/tables.mjs` の `roomyTables` が整える。表を `<div class="table">` で包んで横スクロールにし、短いセル(14文字以下)には `nw`(折り返さない)、長いセルは中身を `<div class="cw">`(最小の幅を持たせる)で包む。表を本文の幅に押し込むと、列が狭くなって縦に1文字ずつ折り返していたため(2026-10-20)
+
+## 守り(2026-10-23)
+
+- 数の表(`/api/hk-stats`・`/api/ev-stats`)は鍵が要る。Cloudflare の Workers → p2m-site → 設定 → 変数とシークレット に、種類「シークレット」・名前 `STATS_KEY` で長い文字列を入れる。見るときは `/api/ev-stats?key=その文字列`(または `Authorization: Bearer その文字列`)。鍵が入っていないあいだは誰が開いても 404
+- 送信の口(`/api/message`・`/api/hk-event`・`/api/ev`)は `worker/index.js` の `guarded()` を通る。このサイトのページからの POST だけを受け取り、同じ送信元から60秒あたりフォーム5回・記録60回を超えると 429 で断る(`wrangler.jsonc` の `ratelimits`)
+- メッセージフォームには人に見えない欄 `website` がある。埋まった送信は保存も通知もしない
+- 全ページの守りの指定(nosniff・Referrer-Policy・Permissions-Policy・HSTS・ほかのサイトに枠で埋め込ませない)は `public/_headers`。外のサイトに枠で埋め込ませたいページができたら、そこだけ指定を外す
+- 依存パッケージの更新は Dependabot が毎週プルリクエストで出す(`.github/dependabot.yml`)。自動では取り込まない
commit c962d6a 本文の記事番号(LOG334 など)を、その記事へのリンクにする

diff --git a/README.md b/README.md
index d1e2272..96b0a8d 100644
--- a/README.md
+++ b/README.md
@@ -73,3 +73,10 @@ AI活用の記事の末尾には、相談の案内(`/message?topic=ai` への
 - メッセージフォームには人に見えない欄 `website` がある。埋まった送信は保存も通知もしない
 - 全ページの守りの指定(nosniff・Referrer-Policy・Permissions-Policy・HSTS・ほかのサイトに枠で埋め込ませない)は `public/_headers`。外のサイトに枠で埋め込ませたいページができたら、そこだけ指定を外す
 - 依存パッケージの更新は Dependabot が毎週プルリクエストで出す(`.github/dependabot.yml`)。自動では取り込まない
+
+## 本文の記事番号のリンク(2026-10-23)
+
+- 記事の本文に書かれた `LOG334`(古い記事の `DAY83` も)を、ビルドのときにその記事へのリンクにする(`src/lib/links.mjs` の `linkLogsHtml`・`src/pages/record/[slug].astro` で呼ぶ)。記事のファイルは書き換えない
+- 同じ記事へのリンクは本文の最初の1か所だけ。書き手が手でリンクを付けていれば、それを1か所目とみなして足さない
+- 自分自身・存在しない番号・リンク/コード/見出し/図の説明の中・ファイル名(`LOG339_01_….png`・`LOG346.md`)・`Log#5` のようなシリーズの中の番号はリンクにしない
+- 英語の記事では、英語版(`LOG201EN`)があればそちらへつなぐ。中のページなので同じタブで移る
commit c3ed19d 本文と図解の古い記事番号 DAY を LOG に直し、本文ではリンクにする

diff --git a/README.md b/README.md
index 96b0a8d..13fa7e0 100644
--- a/README.md
+++ b/README.md
@@ -76,7 +76,9 @@ AI活用の記事の末尾には、相談の案内(`/message?topic=ai` への
 
 ## 本文の記事番号のリンク(2026-10-23)
 
-- 記事の本文に書かれた `LOG334`(古い記事の `DAY83` も)を、ビルドのときにその記事へのリンクにする(`src/lib/links.mjs` の `linkLogsHtml`・`src/pages/record/[slug].astro` で呼ぶ)。記事のファイルは書き換えない
+- 記事の本文に書かれた `LOG334` を、ビルドのときにその記事へのリンクにする(`src/lib/links.mjs` の `linkLogsHtml`・`src/pages/record/[slug].astro` で呼ぶ)。記事のファイルは書き換えない
+- 古い呼び方の `DAY200` は、表示も `LOG200` に直してからリンクにする(番号は同じ)。存在しない番号は DAY のまま。LOG343(呼び方の変更そのものを書いた記事)は DAY を例として書いているので直さない。`Day 38` の形は、記事番号の意味で使っている day39 でだけ読む
+- 図解(`public/embeds/`)の中の、記事番号としての `DAY101` などは、ファイルの文字を `LOG101` に書き換えた(2026-10-23・12ファイル22か所。コードの中・コメント・姿勢やデータの名前として DAY を使っている day172 と LOG334 の図は変えていない)。図解の中はリンクにしない
 - 同じ記事へのリンクは本文の最初の1か所だけ。書き手が手でリンクを付けていれば、それを1か所目とみなして足さない
 - 自分自身・存在しない番号・リンク/コード/見出し/図の説明の中・ファイル名(`LOG339_01_….png`・`LOG346.md`)・`Log#5` のようなシリーズの中の番号はリンクにしない
 - 英語の記事では、英語版(`LOG201EN`)があればそちらへつなぐ。中のページなので同じタブで移る
commit 3d8c231 課題地図の週1回の自動更新を、枝とプルリクエスト経由にし、仕組みで確かめてから取り込む

diff --git a/README.md b/README.md
index 13fa7e0..36f5326 100644
--- a/README.md
+++ b/README.md
@@ -25,6 +25,7 @@ GitHub の main に push すると、Cloudflare が自動で作り直して公
 - 最近の動き:`src/data/issues/news.json` / 週1回の調査の記録:`src/data/issues/checks.json`
 - ページ:`/issues`(一覧)・`/issues/{slug}`(詳細)・`/issues/category/{分類}`・`/issues/map`・`/issues/sources`・`/issues/updates`
 - 週1回の更新は予約タスクの Claude が `tools/issues_weekly.md` の手順で行う。「最近の動き」は自動で公開し、点数・規制の状態・本文の変更はプルリクエストで出してエイイチが確認する
+- 予約タスクは main に直接 push しない。「最近の動き」も `bot/issues-weekly-日付` の枝からプルリクエストを出し、`.github/workflows/issues-weekly-gate.yml` が「変わったのが news.json と checks.json だけ」「データの検査とビルドが通る」を確かめてから自動で取り込む。ほかのファイルが入っていたら取り込まずに止まる(2026-10-23)
 - `tools/add_issue_news.py <JSON>`:最近の動きを足し、調べた記録を残す / `tools/check_issues_data.py`:データの誤りを確かめる
 - `tools/import_issues.py <physical-ai-todo> .`:旧リポジトリから取り込む(移行時だけ使う)
 

残っていることです。

項目状態
流量制限が本番で効いているか
確かめきれていない。保険として WAF の規則を1本足す案がある
ドメインの登録先(Squarespace)の2段階認証
Google でログインしていなければ、Squarespace 側の設定が要る
Astro 7 への更新(#3)保留
フォームの人間確認(Cloudflare Turnstile)
管理画面で鍵を発行する必要があり、入れていない。見えない欄で当面しのぐ
週1回の AI が数の表を読むときの鍵の渡し方
11月に数の表を読ませ始めるときに決める
本線を「取り込みの依頼でしか変えられない」設定にするか
入れていない。入れるとふだんの公開も毎回依頼を経由する

出典: