サイトを pixelstomotors.com へ ― 動画が再生されなかった原因と、URL から /pixelstomotors を外した理由

前回(LOG340)で、Studio で作っていたこのサイトの 553 ページを、Astro と Cloudflare Workers の仕組みに移しました。移したサイトは仮の住所(workers.dev)に置き、ログインした本人しか見られない鍵をかけていました。その続きです。

やったことを先に書きます。トップの動画が止まっていたのを直し、動画がまったく再生されなかった不具合を直しました。サイト用のドメイン pixelstomotors.com を取り、URL から /pixelstomotors を外して、新しいドメインにつなぎました。旧サイトの URL 554 件がすべて新しい URL で開くこと、サイト内のリンク 15,297 本に切れがないことを、機械で確かめています。新しいドメインにもまだ鍵がかかっていて、公開はこの次です。最後に、訪れた人の動きや迷いを読み取って、サイトを少しずつ変えていく構想を書いています。

使ったコードと、確かめたときのスクリプトと出力は、記事の後半に全文で載せています。

トップの動画が 0:00 の黒い画面で止まっていたのは、自動再生とループが抜けていたから

新しいサイトのトップを開くと、右上の動画が 0:00 の黒い画面のまま止まっていました。

サイトを pixelstomotors.com へ ― 動画が再生されなかった原因と、URL から /pixelstomotors を外した理由|トップの動画が 0:00 の黒い画面で止まっていたのは、自動再生とループが抜けていたから

今のサイト(Studio)で同じ動画がどう設定されているかを、ブラウザで開いて読み取りました。

項目今のサイト(Studio)新しいサイト(直す前)
自動再生(autoplay)ありなし
繰り返し(loop)ありなし
音なし(muted)ありあり
操作バー(controls)ありあり
動画の URL の末尾#t=0.01なし

自動再生と繰り返しが、移すときに抜けていました。#t=0.01 は、再生前でも最初のコマを表示させるための指定で、Studio が付けていたものです。新しいサイトも同じ指定に揃えました。

記事の中の動画(3本の記事に4本)も同じように調べましたが、こちらは Studio でも自動再生なし・操作バーありで、新しいサイトと同じでした。

ただ、これを直しても動画は再生されませんでした。原因は別のところにありました。

動画がまったく再生されなかったのは、Range 応答の Content-Range が NaN だったから

画像と動画は、Cloudflare の保管場所 R2 に置き、サイトの /media/ という住所から Worker(サイトの入口のプログラム)が返しています。まず、R2 に入っている動画そのものを確かめました。

確かめたこと結果
R2 に入っているか入っている(2,310,256 バイト)
種類(Content-Type)video/mp4
映像の形式H.264(High)、848×1056、10.04 秒
再生に必要な情報(moov)の位置13,703 バイト目。中身(mdat)の 71,491 バイト目より前にあり、読み込み途中から再生できる並び

ファイルに問題はありませんでした。次に、Worker を手元で動かし、ブラウザと同じ形で動画を取りに行きました。

ブラウザは動画を、一度に丸ごとではなく「何バイト目から何バイト目まで」と範囲を指定して取りに来ます(Range リクエスト)。送る側は、返した範囲を Content-Range という欄に書いて返します。この欄が壊れていました。

ブラウザの指定返した範囲(直す前)返した範囲(直した後)
bytes=0-1bytes NaN-2310255/2310256bytes 0-1/2310256
bytes=0-bytes NaN-2310255/2310256bytes 0-2310255/2310256
bytes=71491-bytes NaN-2310255/2310256bytes 71491-2310255/2310256
bytes=-500(試していない)bytes 2309756-2310255/2310256
bytes=100-199(試していない)bytes 100-199/2310256

NaN は「数として計算できない」という意味です。始まりの位置が NaN の応答を、ブラウザは壊れたものとして扱い、再生をやめます。画像は範囲を指定せずに丸ごと取るので、この処理を通らず、問題が出ていませんでした。

原因は、範囲の種類を見分ける1行でした。R2 は範囲を {offset, length}(何バイト目から何バイト分)か {suffix}(最後の何バイト)の形で返します。直す前のコードは 'suffix' in r、つまり「suffix という欄があるか」で見分けていました。R2 が返す範囲には、値が空(undefined)の suffix 欄が付いてくることがあり、そのときも「末尾から」の計算に入って、全体の大きさ − undefined が NaN になっていました。欄があるかではなく、値が数かどうかで見分けるように直しました。

// 直す前
const start = 'suffix' in r ? obj.size - r.suffix : (r.offset ?? 0);

// 直した後
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;
}

直した後は、5通りの指定すべてで正しい範囲が返り、bytes=100-199 で返った 100 バイトは元のファイルの同じ位置と1バイトずつ一致しました。手元の確認用ブラウザ(Playwright の Chromium)は H.264 の動画を再生できない作りなので、映像が実際に流れるところは、試作サイトを普段のブラウザで開いて目で確かめました。

将来会社にするなら .com と考えて、pixelstomotors.com を Cloudflare で取った

ここで、新しいドメインを取って移ることにしました。Studio に記事を貼る作業をやめたいというのが、そもそもの目的です。

候補は3つでした。調べた時点で .com と .dev はどちらも空いていました。

候補判断
pixelstomotors.com採用。国や業種を問わず使われていて、取引先にも海外にも通じやすい
pixelstomotors.dev開発者向けの道具という印象が強く、会社の顔としては狭い
pixelstomotors.jpCloudflare では取れないので、取得先と DNS の設定が別になる

決め手は、将来会社にしたときに使い続けられるかどうかでした。会社として登記すると .co.jp も取れるようになるので、そのときは pixelstomotors.co.jp を追加で取って .com へ転送する形にできます。

取得は Cloudflare のドメイン登録(Cloudflare Registrar)で行いました。原価で売る仕組みで、1年 $10.46、更新も同じ値段です。自動更新は ON、期限は 2027年10月16日です。登録者の連絡先は公開されない設定が最初から入っています。サイトと同じ Cloudflare の中で取ったので、DNS の設定を別にする必要がありません。

サイトを pixelstomotors.com へ ― 動画が再生されなかった原因と、URL から /pixelstomotors を外した理由|将来会社にするなら .com と考えて、pixelstomotors.com を Cloudflare で取った
サイトを pixelstomotors.com へ ― 動画が再生されなかった原因と、URL から /pixelstomotors を外した理由|将来会社にするなら .com と考えて、pixelstomotors.com を Cloudflare で取った

URL から /pixelstomotors を外し、旧 URL 554 件がすべて新しい URL で開くことを確かめた

今のサイトの URL は adaptdesign.jp/pixelstomotors/record/記事名 の形です。ドメインがそのままブランド名になるので、そのまま移すと pixelstomotors.com/pixelstomotors/record/記事名 と同じ言葉が2回続きます。

pixelstomotors/は外さないと連続は良くないね

ドメインを移す時点で、全ページの URL はどちらにしても変わります。あとで形を変えると、X や YouTube や記事に貼ったリンクがもう一度切れるので、決めるならこのときでした。外す形にしました。

ページ今のサイト新しいサイト
トップadaptdesign.jp/pixelstomotorspixelstomotors.com/
記事adaptdesign.jp/pixelstomotors/record/{slug}pixelstomotors.com/record/{slug}
4コマadaptdesign.jp/pixelstomotors/manga/{slug}pixelstomotors.com/manga/{slug}
英語版4コマadaptdesign.jp/pixelstomotors/english-4-koma/{slug}pixelstomotors.com/english-4-koma/{slug}
シリーズadaptdesign.jp/pixelstomotors/series1〜7pixelstomotors.com/series1〜7

変えたのは次のところです。

  1. ページのファイルを src/pages/pixelstomotors/ から src/pages/ へ移した。ファイルの階層が1つ浅くなるので、各ページの読み込み先(../../ など)も1段ずつ直した
  2. ページの中のリンク(ヘッダー・フッター・カード・一覧など)から /pixelstomotors を外した
  3. 今のサイトから取り込んだ記事本文の中のリンク(6本の記事)も、/pixelstomotors を外した形に直した
  4. 記事の Markdown の中に https://adaptdesign.jp/pixelstomotors/… と書かれたリンクは、サイトに出すときにサイト内のリンクへ置き換えるようにした。Markdown 自体は書き換えない
  5. 検索エンジンに正しい住所を伝える欄(canonical・og:url)を https://pixelstomotors.com に、サイト名(og:site_name)を ADAPT DESIGN から Pixels to Motors に変えた
  6. 古い形の URL(/pixelstomotors/…)で来た人は、/pixelstomotors を外した URL へ 301(恒久的な移動)で転送するようにした
  7. フッターの「© ADAPT DESIGN」のリンクは、これまで同じドメインの /web_production_list を指していた。ドメインが分かれるので、https://adaptdesign.jp/web_production_list と書くようにした

確かめ方は2つです。今のサイトのサイトマップから Pixels to Motors の URL を全部集め、/pixelstomotors を外した URL のページが新しいサイトにあるかを1件ずつ調べました。それから、新しいサイトの全ページからサイト内へのリンクを全部拾い、行き先のページがあるかを調べました。

確かめたこと結果
今のサイトの URL554 件。新しいサイトで開けないもの 0 件
新しいサイトのサイト内リンク(1回目)15,852 本
行き先がないもの(1回目)6 種類。/web_production_list(全 555 ページのフッター)と、LOG340 の記事の中の5つ
/web_production_list上の7で adaptdesign.jp の絶対 URL に直した
サイト内リンク(直した後)15,297 本。フッターの 555 本がサイトの外へのリンクになった分だけ減った
行き先がないもの(直した後)6 種類。すべて LOG340 の記事に載せたコードの中の文字で、リンクではない(その記事の中の実際のリンクは 0 本)

サイトマップの件数は、LOG340 で数えた 553 から 554 に増えていました。増えた1件も、新しいサイトで開けます。

古い形の URL の転送は、手元で動かして確かめました。

来た URL返したもの
/pixelstomotors301 → /
/pixelstomotors/301 → /
/pixelstomotors/record/day1301 → /record/day1
/pixelstomotors/manga-archive?x=1301 → /manga-archive?x=1
/record/day1・/・/series3・/message200(そのまま表示)

旧サイトの URL を新しいドメインへ転送する方法は、まだ決めていない

新しいサイトの中の転送は、上の6で済みます。残るのは、今の adaptdesign.jp/pixelstomotors/… に来た人を、pixelstomotors.com へ送る転送です。adaptdesign.jp は Studio の上にあるので、Studio 側で転送することになります。

Studio のヘルプで転送の機能を確かめると、条件が合いませんでした。

Studio の転送(リダイレクトページ)中身
転送先Studio の外の URL も指定できる
登録の単位1ページずつ。複数ページをまとめて転送する設定はできない
必要なプランBusiness プラン以上(旧プランは全プラン)

554 ページを1件ずつ手で登録するのは現実的ではありません。案は3つです。

案中身懸念
案1Studio の記事・4コマなどの型のページに、自動で新しい URL へ移る短いコードを入れる型の数だけで済む。Studio のプランでコードを入れられるか確かめる必要がある
案2adaptdesign.jp の DNS を Cloudflare に移し、Cloudflare 側でまとめて 301 で転送する一番きれいだが、DNSSEC を外す順序が要り、ADAPT DESIGN 本体のサイトにも影響しうる(LOG340 の codex の報告の指摘)
案3転送しない。古い URL は Studio にしばらく残し、そのあと消す外部から貼られたリンクが切れる

ここは次に決めます。

リダイレクトページ(301リダイレクト)を設定する — STUDIO ヘルプ

pixelstomotors.com を Worker につなぐと、ログインの鍵も一緒にかかった

ドメインをサイトにつなぐのは、Cloudflare の画面で Worker(p2m-site)の「Domains」タブから「Add Domain」を押し、サブドメインを空欄のまま追加するだけでした。

サイトを pixelstomotors.com へ ― 動画が再生されなかった原因と、URL から /pixelstomotors を外した理由|pixelstomotors.com を Worker につなぐと、ログインの鍵も一緒にかかった
サイトを pixelstomotors.com へ ― 動画が再生されなかった原因と、URL から /pixelstomotors を外した理由|pixelstomotors.com を Worker につなぐと、ログインの鍵も一緒にかかった
サイトを pixelstomotors.com へ ― 動画が再生されなかった原因と、URL から /pixelstomotors を外した理由|pixelstomotors.com を Worker につなぐと、ログインの鍵も一緒にかかった

つないだあと、外から pixelstomotors.com を開くと、302 でログイン画面(Cloudflare Access)へ送られました。仮の住所にかけていた鍵が、新しいドメインにも一緒にかかっていました。鍵の設定が、Worker 全体に対してかかっているためと見ています。

結果として、つないだ時点ではまだ誰にも見えていません。本人のメールでログインして、新しいドメインでサイトが開くことと、トップの動画が再生されることを確かめました。

サイトを pixelstomotors.com へ ― 動画が再生されなかった原因と、URL から /pixelstomotors を外した理由|pixelstomotors.com を Worker につなぐと、ログインの鍵も一緒にかかった

公開するときは、新しいドメインだけ鍵を外し、仮の住所のほうは鍵を残す形にする予定です。

訪れた人がどこで迷ったかを読み取り、サイトを少しずつ変えていく構想

ドメインをつなぐ合間に、これからのサイトの方向について話をしました。

サイトを pixelstomotors.com へ ― 動画が再生されなかった原因と、URL から /pixelstomotors を外した理由|訪れた人がどこで迷ったかを読み取り、サイトを少しずつ変えていく構想

これは、解説動画を何本も並行して作るときの進み具合を見るページです(LOG339)。段階の線の上をハコブくんが進み、動画が公開されると走り回ります。

動画の進み具合の中にハコブくんが表示されたり、動画が投稿されるとハコブくんが走り回ったりと、動きのあるモニターになっていますが、この考え方をベースに現行のサイトを少しづつ変えていこうと思っています。

その中でユーザーが訪れてどのようにサイト内を動いたり、クリックしたり、また悩んでいたりを分析できるようなサイトに構築は可能ですか?

分析情報をもとに定期的にサイトを変更したり、分析情報からユーザー心理を感じれるようなサイトにしてよりユーザーフレンドリーなUIやUXにしたいと考えています。

できます。サイトのコードが全部手元にあり、Cloudflare の上で動くようになったので、Studio のときより自由に組めます。読み取れるものを分けると次のとおりです。

種類分かること
数どのページが何回見られたか、どこから来たか(X・YouTube・検索など)、スマホかパソコンか
動きどこをクリックしたか、記事のどこまで読んだか、どのページからどのページへ移ったか
迷い同じ所の連打、押せそうで押せない所を押す、上下に行ったり来たりするスクロール、開いてすぐ戻る、記事の決まった位置で離れる
録画1人の訪問者の画面の動きを再生して見る(入力した文字は記録しないように隠す)

人の心理がいちばん出るのは「迷い」の行です。組み方は段階に分けます。

  1. 無料の既製の道具を入れる。ページごとの数は Cloudflare Web Analytics、クリックの集まり具合(ヒートマップ)と録画と連打などの迷いは Microsoft Clarity。どちらもサイトに数行足すだけで入る
  2. 自前の記録を足す。クリックやスクロールの記録を、メッセージフォームと同じ R2 に貯める。データが手元にあるので、Claude が定期的に読んで「先週はここで離れる人が多かった、こう変えたらどうか」と提案できる
  3. 貯めた記録でハコブくんを動かす。たとえば、今読んでいる人が多い記事に向かってハコブくんが走る。動画の進み具合のモニターと同じ考え方を、サイトに持ち込む

気をつけることが2つあります。外部の分析ツールに訪問者のデータを送る場合は、その旨を公表することが法律で求められるので、プライバシーポリシーを更新します。もう1つは、訪問者が少ないうちは、集計した数より1人ずつの録画のほうが迷いどころを見つけやすいことです。

すぐに作る話ではありません。公開したあと、1の段階から入れます。体験から逆算して直すという進め方を、サイト自体で回すことになります。

できたものと、残っていること

できたもの:

  • トップの動画が、Studio と同じく自動再生・繰り返しで流れる
  • 動画を範囲指定で返す処理の NaN を直し、全部の動画が再生される
  • pixelstomotors.com を取得し、自動更新を ON にした
  • URL から /pixelstomotors を外し、旧 URL 554 件すべてが新しい URL で開く。サイト内リンク 15,297 本に切れなし
  • 古い形の URL は 301 で新しい形へ転送する
  • pixelstomotors.com をサイトにつないだ(まだ鍵がかかっていて、本人しか見られない)

残っていること:

  • 新しいドメインだけ鍵を外して公開する
  • メッセージフォームに届いた内容をメールで知らせる。今は R2 に貯まるだけで、届いたことに気づけない
  • 旧サイトの URL の転送方法を決める(上の案1〜3)
  • 記事を書くときのリンク先・SNS の URL・YouTube の説明欄・GitHub・プロフィールを、新しいドメインに書き換える
  • 公開したあと、アクセス解析を1の段階から入れる。あわせてプライバシーポリシーを更新する

使ったコードの全文

リポジトリは GitHub の PixelstoMotors/p2m-site(非公開)です。ここに載せるのは、この回に書いたか直したファイルの、直した後の全文です。

worker/index.js — サイトの入口。旧い形の URL の 301 転送を足し、動画の範囲指定(Range)の応答の NaN を直した

// サイトの入口。ほとんどのページは作っておいた静的ファイル(dist/)をそのまま返す。
// ここで処理するのは3つだけ:
// - /pixelstomotors/…   旧 URL の形。/pixelstomotors を外した URL へ 301 で転送する
// - /media/{ファイル名}  記事・4コマの画像と動画を R2(p2m-media)から返す(GitHub には入れないため)
// - /api/message         メッセージフォームの送信を R2(p2m-messages)に保存する
export default {
  async fetch(request, env) {
    const url = new URL(request.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 message(request, env);
    return env.ASSETS.fetch(request);
  },
};

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 });
}

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); }
  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);
  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, body, ua: request.headers.get('user-agent') || '' }, null, 1),
    { httpMetadata: { contentType: 'application/json' } });
  return json({ ok: true });
}

function json(o, status = 200) {
  return new Response(JSON.stringify(o), { status, headers: { 'content-type': 'application/json; charset=utf-8' } });
}

wrangler.jsonc — Cloudflare Workers の設定。旧い形の URL を、静的ファイルより先に worker/index.js へ回すようにした

{
  // 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" }
  ]
}

astro.config.mjs — サイト全体の設定。site を https://pixelstomotors.com にした

import { defineConfig } from 'astro/config';
import remarkMarkers from './src/lib/remark-markers.mjs';

// URL は今の Studio と同じ「末尾の / なし」にそろえる(/series7 → series7.html)
export default defineConfig({
  site: 'https://pixelstomotors.com',
  trailingSlash: 'ignore',
  build: { format: 'file' },
  markdown: {
    // コードは今のサイトと同じく色を付けずに出す
    syntaxHighlight: false,
    remarkPlugins: [remarkMarkers],
  },
});

src/layouts/Base.astro — 全ページ共通の枠。canonical・og:url・og:site_name、ヘッダーとフッターのリンクを直した

---
// 全ページ共通の枠(head・ヘッダー・フッター)
interface Props { title: string; description?: string; ogImage?: string; path: string; }
const { title, description = '', ogImage, path } = Astro.props;
const site = 'https://pixelstomotors.com';
const canonical = site + path;
// 試作の確認用:このページが作られた日時(日本時間)
const built = new Date(Date.now() + 9 * 3600 * 1000).toISOString().replace('T', ' ').slice(0, 16);
---
<!doctype html>
<html lang="ja">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <title>{title}</title>
  <meta name="description" content={description} />
  <link rel="canonical" href={canonical} />
  <meta property="og:site_name" content="Pixels to Motors" />
  <meta property="og:title" content={title} />
  <meta property="og:description" content={description} />
  <meta property="og:type" content="website" />
  <meta property="og:url" content={canonical} />
  {ogImage && <meta property="og:image" content={site + ogImage} />}
  <meta name="twitter:card" content="summary_large_image" />
  <!-- 試作のあいだは検索エンジンに載せない -->
  <meta name="robots" content="noindex, nofollow" />
  <link rel="icon" type="image/png" href="/site/9cad887a-242b-4953-bd36-7b368c27d1b9.png" />
  <link rel="preconnect" href="https://fonts.googleapis.com" />
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
  <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Noto+Sans+JP:wght@300;400;500;700&display=swap" rel="stylesheet" />
  <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,400,0,0&display=block" />
  <slot name="head" />
</head>
<body>
  <header class="site-header">
    <div class="hd-inner">
      <a class="logo" href="/">
        <img class="logo-img" src="/site/pixelstomotors-logo.svg" alt="Pixels to Motors" width="189" height="20" />
      </a>
      <div class="hd-center">
        <a class="hd-manga" href="/manga-archive"><img src="/site/s-500x700_f2c5ae27-2ebe-4d15-8da4-05853d4973ef.webp" alt="" /></a>
        <a class="btn-dark" href="/manga-archive">4コママンガ<i class="ms">arrow_forward</i></a>
        <a class="btn-dark" href="/manga-archive-en">English 4-Koma<i class="ms">arrow_forward</i></a>
      </div>
      <a class="btn-dark hd-msg" href="/message">メッセージ<i class="ms">arrow_forward</i></a>
    </div>
  </header>
  <main><slot /></main>
  <footer class="site-footer">
    <div class="ft-inner">
      <a href="https://adaptdesign.jp/web_production_list">© ADAPT DESIGN 2025</a>
      <a href="/privacypolicy">Privacy policy</a>
      <span class="ft-built">試作版・最終更新 {built}</span>
    </div>
  </footer>
  <script>
    // 図解(隔離した枠)から届いた高さで、枠の高さを合わせる
    window.addEventListener('message', (e) => {
      const h = e.data && e.data.p2mHeight;
      if (typeof h !== 'number' || !isFinite(h) || h <= 0) return;
      for (const f of document.querySelectorAll('iframe[data-embed]')) {
        if (f.contentWindow === e.source) { f.style.height = Math.min(Math.ceil(h), 20000) + 'px'; break; }
      }
    });
  </script>
</body>
</html>

<style is:global>
  :root {
    --ink: #242424; --ink-2: #555; --ink-3: #888; --line: #f2f2f2;
    --bg: #f7f7f7; --panel: #fff; --dark: #242424;
    --sans: Inter, 'Noto Sans JP', sans-serif;
    --mono: Menlo, Monaco, 'Courier New', monospace;
  }
  * { box-sizing: border-box; }
  html { -webkit-text-size-adjust: 100%; }
  /* Studio と同じく、Mac で文字を細めに描く指定(ないと太く濃く見える) */
  body { margin: 0; background: var(--bg); color: var(--ink); font-family: var(--sans);
    -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; }
  a { color: inherit; text-decoration: none; }
  img { max-width: 100%; display: block; }
  .ms { font-family: 'Material Symbols Outlined'; font-style: normal; font-size: 16px; line-height: 1; }

  .site-header { position: fixed; inset: 0 0 auto 0; z-index: 50; height: 48px; padding: 6px 40px;
    background: rgba(255,255,255,.1); backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px);
    box-shadow: 0 4px 4px rgba(0,0,0,.1); }
  .hd-inner { max-width: 1280px; margin: 0 auto; height: 36px; display: flex; align-items: center; justify-content: space-between; gap: 40px; }
  .logo { display: flex; align-items: center; }
  .logo-img { height: 17.3px; width: auto; }
  /* 元は Studio 付属の有料フォント「A1ゴシック M」。移行先では使えないため代用中 */
  .logo span { font-family: 'Noto Sans JP', sans-serif; font-weight: 500; font-size: 20px; letter-spacing: -0.4px; color: var(--ink); }
  .hd-center { display: flex; align-items: center; gap: 8px; }
  .hd-manga img { height: 28px; width: auto; }
  .btn-dark { display: inline-flex; align-items: center; gap: 8px; height: 31px; padding: 4px 16px; border-radius: 4px;
    background: var(--dark); border: 1px solid var(--dark); color: #fff; font: 500 14px/21px 'Noto Sans JP', sans-serif; white-space: nowrap; }
  .site-footer { background: var(--dark); padding: 24px 40px; }
  .ft-inner { max-width: 1280px; margin: 0 auto; display: flex; gap: 40px; }
  .ft-inner a { color: #888; font: 300 14px/21px var(--sans); }
  .ft-inner a:hover { color: #fff; }
  .ft-built { margin-left: auto; color: #666; font: 300 12px/21px var(--sans); }

  /* シリーズ名の札・日付 */
  .tag { display: inline-block; padding: 2px 6px; border-radius: 4px; background: #f0f0f0; color: #888; font: 400 11px/16px var(--sans); }
  .date { color: #aaa; font: 400 11px/16px var(--sans); }
  /* 一覧ページ共通の枠 */
  .panel { max-width: 1280px; margin: 0 auto; background: #fff; border-radius: 16px; padding: 32px; }
  .related, .sec { padding: 40px 80px; }
  .sec-h { margin: 0 0 32px; font: 600 24px/36px var(--sans); }
  .grid3 { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 32px; }
  @media (max-width: 1023px) { .grid3 { grid-template-columns: repeat(2, minmax(0, 1fr)); } .related, .sec { padding: 40px 24px; } }
  @media (max-width: 599px) { .grid3 { grid-template-columns: 1fr; gap: 24px; } .related, .sec { padding: 32px 12px; } .panel { padding: 24px 16px; } }

  @media (max-width: 767px) {
    .site-header { padding: 6px 16px; }
    .hd-inner { gap: 8px; }
    .logo-img { height: 14.8px; }
    .hd-center .btn-dark { display: none; }
    .hd-manga img { height: 32px; }
    .btn-dark { padding: 4px 10px; font-size: 12px; gap: 4px; }
    .site-footer { padding: 24px 16px; }
  }
</style>

src/pages/index.astro — トップ。src/pages/pixelstomotors/index.astro から移し、動画を自動再生・繰り返しにした

---
import Base from '../layouts/Base.astro';
import RecordCard from '../components/RecordCard.astro';
import { ymd } from '../lib/format';
import { media } from '../lib/media';
// SNS アイコン(Font Awesome Free 6.7.2・CC BY 4.0。ライセンス表記は各 SVG の中に残している)
import icGithub from '../icons/github.svg?raw';
import icLinkedin from '../icons/linkedin.svg?raw';
import icX from '../icons/square-x-twitter.svg?raw';
import icYoutube from '../icons/youtube.svg?raw';
import { allCards } from '../lib/records';
import MangaCard from '../components/MangaCard.astro';
import LoadMore from '../components/LoadMore.astro';
import { allManga } from '../lib/records';
// Studio のトップと同じく、4コマは新しい4話
const mangaCards = allManga.slice(0, 4);

const series = [
  { href: '/series1', img: 's-1920x1318_v-frms_4cd7792b-68a1-4239-ad08-0785c4e538c5.webp', en: 'Drug Delivery Robot', ja: '薬剤搬送ロボットハコブくん',
    desc: 'A multidisciplinary documentation of Hakobu — a hospital drug-delivery robot — covering HRI design, UI, autonomous navigation in Unity, and daily development logs, told alongside 4-panel manga.' },
  { href: '/series6', img: 's-1672x941_76620d86-fa7b-491f-b554-99c2f8981d8d.webp', en: 'Physical AI Lab', ja: 'フィジカルAI研究室',
    desc: 'Building a Physical AI research environment at home, from zero — reproducible by anyone. The first build: an arm that learns from failure.' },
  { href: '/series7', img: 's-1800x1200_v-frms_82af05c4-20df-4ea1-b88d-355ed8fabd63.webp', en: 'Image recognition and Object detection', ja: '画像認識と物体検出',
    desc: 'Relearning image recognition from the bottom up, one measurable step at a time — pixels, colour spaces, thresholds, morphology, contours, and the metrics that judge them. Nothing is assumed: every step is measured on real photographs first.' },
  { href: '/series5', img: 's-2528x1694_7a2b46a9-02c8-49d6-92c4-2e7994843726.webp', en: 'Deep Dive', ja: '考察記事',
    desc: 'Essays that go beyond the build log — examining why things work, what they mean, and where the field is heading.' },
  { href: '/series2', img: 's-1920x1318_v-frms_0d775000-730b-4ac2-90b5-d814a9f53fc6.webp', en: 'R2R2R', ja: 'デジタルツイン学習-R2R2R(Real to Render to Real)',
    desc: 'A hands-on project proving that robot arm motion can be designed without a robotics background — using only an iPhone for 3D scanning and Unity for simulation.' },
  { href: '/series4', img: 's-4480x3680_2e3487ec-2968-4b89-8bca-7062b48f6093.webp', en: 'Robotics Design Workflow', ja: 'ロボティクスデザイン実務フロー', desc: '' },
];
const records = await allCards();
---
<Base title="Pixels to Motors | 人とロボットの関わりを、シミュレーションでデザインする"
  description="29年のデザイナー経験を、いまロボティクスに応用しています。テーマはHRIシミュレーション——人がロボットをどう感じ、どう関わるかを、実機の前にデジタル上で設計すること。"
  ogImage="/site/ffb300c2-1071-4714-978c-87d386d5dfd3.png" path="/">

  <section class="hero">
    <div class="hero-text">
      <h1>Pixels to Motors | 人とロボットの関わりをシミュレーションでデザインする</h1>
      <p>29年のデザイナー経験を、いまロボティクスに応用しています。テーマはHRIシミュレーション——人がロボットをどう感じ、どう関わるかを、実機の前にデジタル上で設計すること。Unityで空間もシナリオもゼロから組み、人の間を動いて運び・手渡すサービスロボットの振る舞いを、成否の基準を物理精度ではなく人の体験に置いてつくります。実機はその先。過程の失敗も含めて公開しています。</p>
    </div>
    <video class="hero-video" src={media("/media/s-848x1056_e5500e41-e73b-4177-914e-b09a2d11f83c.mp4") + "#t=0.01"} controls muted autoplay loop playsinline></video>
  </section>

  <!-- 解説動画:YouTube の新着を GitHub の videos.json から読む埋め込み(Studio で使っていたものをそのまま) -->
  <section class="video-sec">
    <div class="embed-wide"><iframe src="/embeds/top-video.html" sandbox="allow-scripts allow-popups allow-popups-to-escape-sandbox" title="解説動画" data-embed style="height:450px"></iframe></div>
  </section>

  <section class="sec"><div class="panel">
    <h2 class="sec-h">Archive | 4コママンガ</h2>
    <div class="grid4">{mangaCards.map((m) => <MangaCard item={m} />)}</div>
    <div class="center"><a class="btn-more" href="/manga-archive">もっと見る<i class="ms">arrow_forward</i></a></div>
  </div></section>

  <section class="sec"><div class="panel">
    <h2 class="sec-h">Series | シリーズ</h2>
    <div class="grid2">
      {series.map((s) => (
        <a class="scard" href={s.href}>
          <img src={media(`/media/${s.img}`)} alt="" loading="lazy" />
          <div class="sc-meta">
            <div class="sc-line"><span class="sc-en">{s.en}</span><span class="sc-ja">{s.ja}</span></div>
            {s.desc && <p class="sc-desc">{s.desc}</p>}
          </div>
        </a>
      ))}
    </div>
  </div></section>

  <section class="sec"><div class="panel">
    <h2 class="sec-h">Record | 記録</h2>
    <LoadMore limit={12} total={records.length}>{records.map((c) => <RecordCard item={c} />)}</LoadMore>
    <div class="embed-banner"><iframe src="/embeds/top-banner.html" sandbox="allow-scripts allow-popups allow-popups-to-escape-sandbox" title="お知らせ" data-embed style="height:240px"></iframe></div>
  </div></section>

  <section class="sec"><div class="panel">
    <h2 class="sec-h">Sole Prop | 事業所について</h2>
    <div class="prop">
      <div class="prop-person">
        <video class="prop-photo" src={media("/media/s-1280x720_ff64009f-cd40-4e7d-8ba3-350ff0d6c3ad.mp4")} poster={media("/media/s-2048x2048_5af0bc9f-8d23-4bdb-85e4-ada51bfd7bbd.webp")} muted autoplay loop playsinline></video>
        <p class="prop-role">Robotics UX &amp; HRI Designer</p>
        <p class="prop-name">島津 栄一</p>
      </div>
      <div class="prop-body">
        <p class="prop-lead">画面(Pixels)のデザインから、動き(Motors)のデザインへ</p>
        <p class="prop-text">29年にわたるデザイナーとしての知見を土台に、人とロボットの関わり(HRI)をシミュレーションで設計するデザイン事務所です。Unityなどのツールで空間もシナリオもゼロから組み、AIエージェント(Claude Code)を設計と実装の相棒に、人がロボットをどう感じ・どう関わるかを実機の前にデザインします。基準は物理精度ではなく体験の成立。画面上の「Pixels(デジタル)」の設計を、やがて現実の「Motors(モーター)」へ——その前半、体験を確かめるフェーズを公開しています。</p>
        <dl class="prop-facts">
          <div><dt>事業所名</dt><dd>ADAPT DESIGN</dd></div>
          <div><dt>設立</dt><dd>2021年10月</dd></div>
          <div class="wide"><dt>研究・実践分野</dt><dd>HRIシミュレーション / ロボティクスUX設計 / デジタルツイン / Unity / Physical AI / R2R2R</dd></div>
          <div><dt>SNS</dt><dd class="sns">
            <a href="https://github.com/PixelstoMotors/r2r2r-content" aria-label="GitHub" set:html={icGithub} />
            <a href="https://www.linkedin.com/in/eiichi-shimazu/" aria-label="LinkedIn" set:html={icLinkedin} />
            <a href="https://x.com/adaptdesignjp" aria-label="X" set:html={icX} />
            <a href="https://www.youtube.com/@PixelstoMotors" aria-label="YouTube" set:html={icYoutube} />
          </dd></div>
        </dl>
      </div>
    </div>
  </div></section>
</Base>

<style>
  .hero { max-width: 964px; margin: 0 auto; padding: 104px 0 40px; display: flex; gap: 40px; align-items: flex-start; }
  .hero-text { flex: 1 1 588px; min-width: 0; }
  .hero h1 { margin: 0 0 16px; font: 600 29px/40.6px var(--sans); }
  .hero p { margin: 0; font: 400 19px/1.6 'Noto Sans JP', sans-serif; color: #333; }
  .hero-video { flex: 0 0 336px; width: 336px; height: 320px; background: #111; border-radius: 4px; object-fit: cover; }
  .video-sec { max-width: 1280px; margin: 0 auto; padding: 0 32px; }
  .embed-wide iframe, .embed-banner iframe { display: block; width: 100%; border: 0; }
  .embed-banner { max-width: 964px; margin: 48px auto 0; }

  .grid4 { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 24px; }

  .grid2 { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 32px; }
  .scard { display: block; background: #fff; border-radius: 8px; box-shadow: 0 8px 16px rgba(0,0,0,.08); overflow: hidden; }
  .scard img { width: 100%; aspect-ratio: 3 / 2; object-fit: cover; }
  .sc-meta { padding: 16px; display: flex; flex-direction: column; gap: 8px; }
  .sc-line { display: flex; align-items: baseline; gap: 8px 12px; flex-wrap: wrap; }
  .sc-en { font: 600 24px/24px var(--sans); }
  .sc-ja { font: 500 14px/16.8px var(--sans); }
  .sc-desc { margin: 0; font: 400 13px/1.6 var(--sans); color: #555; }

  .center { display: flex; justify-content: center; margin-top: 40px; }
  .btn-more { display: inline-flex; align-items: center; gap: 16px; padding: 8px 24px; border-radius: 4px; background: var(--dark); color: #fff; font: 500 20px/30px var(--sans); }

  .prop { display: flex; gap: 40px; }
  .prop-person { flex: 0 0 160px; text-align: center; }
  .prop-photo { width: 160px; height: 160px; border-radius: 50%; object-fit: cover; display: block; }
  .prop-role { margin: 12px 0 4px; font: 300 12px/1.3 var(--sans); color: #888; }
  .prop-name { margin: 0; font: 500 20px/20px var(--sans); }
  .prop-body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 12px; }
  .prop-lead { margin: 0; padding: 10px 8px; background: #f7f7f7; font: 600 18px/1.4 var(--sans); }
  .prop-text { margin: 0; font: 400 14px/1.5 var(--sans); color: #333; }
  .prop-facts { margin: 8px 0 0; display: flex; flex-wrap: wrap; gap: 16px 40px; }
  .prop-facts dt { font: 300 12px/15.6px var(--sans); color: #888; }
  .prop-facts dd { margin: 4px 0 0; font: 400 14px/1.5 var(--sans); }
  .sns { display: flex; gap: 12px; }
  .sns a { display: inline-flex; color: #242424; }
  .sns a :global(svg) { height: 18px; width: auto; display: block; }
  .sns a:hover { color: #666; }

  @media (max-width: 1023px) {
    .hero { padding: 88px 24px 32px; flex-direction: column; }
    .hero-video { width: 100%; flex-basis: auto; }
    .grid4 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
    .video-sec { padding: 0 24px; }
  }
  @media (max-width: 599px) {
    .hero { padding: 72px 24px 24px; }
    .hero h1 { font-size: 24px; line-height: 1.5; }
    .hero p { font-size: 16px; line-height: 1.75; }
    .grid2 { grid-template-columns: 1fr; gap: 24px; }
    .grid4 { gap: 16px; }
    .prop { flex-direction: column; align-items: center; gap: 24px; }
    .video-sec { padding: 0 12px; }
  }
</style>

src/lib/remark-markers.mjs — 記事 Markdown の整形。旧サイトへのリンクをサイト内リンクに置き換える処理を足した

// 記事 Markdown を、サイトに出す形に整える。
// 1. 「【画像】ファイル名」「【図解】ファイル名」の行を、画像と図解の枠に置き換える
//    (article_rules.md の書き方をそのまま使う。行の頭からその行の終わりまでが1つの指定)
// 2. R2R2R の注記(##### ※ プロジェクト名「R2R2R」は…)は、R2R2R シリーズの記事にだけ残す
//    (Studio でも、該当しない記事では手で消していた)
// 3. 旧サイト(adaptdesign.jp/pixelstomotors/…)へのリンクは、このサイトの中のリンク(/…)に直す
//    (記事の Markdown 自体は書き換えない)
import { visit, SKIP } from 'unist-util-visit';
import { toString } from 'mdast-util-to-string';
import { MEDIA_BASE } from './media-base.mjs';

const R2R2R_NOTE = /^※\s*プロジェクト名「R2R2R」/;
const OLD_SITE = /^https?:\/\/(?:www\.)?adaptdesign\.jp\/pixelstomotors(\/[^?#]*)?([?#].*)?$/;

export default function remarkMarkers() {
  const base = MEDIA_BASE.replace(/\/$/, '');
  return (tree, file) => {
    const series = file.data?.astro?.frontmatter?.series;
    if (series !== 'R2R2R') {
      visit(tree, 'heading', (node, index, parent) => {
        if (parent && node.depth === 5 && R2R2R_NOTE.test(toString(node).trim())) {
          parent.children.splice(index, 1);
          return [SKIP, index];
        }
      });
    }
    visit(tree, 'link', (node) => {
      const m = OLD_SITE.exec(node.url || '');
      if (m) node.url = (m[1] && m[1] !== '/' ? m[1] : '/') + (m[2] || '');
    });
    visit(tree, 'paragraph', (node, index, parent) => {
      if (!parent || node.children.length !== 1 || node.children[0].type !== 'text') return;
      const text = node.children[0].value.trim();
      let m = text.match(/^【画像】\s*(\S+)$/);
      if (m) {
        parent.children[index] = { type: 'html', value:
          `<figure><img src="${base}/${encodeURIComponent(m[1])}" alt="" loading="lazy"></figure>` };
        return;
      }
      m = text.match(/^【図解】\s*(\S+\.html)$/);
      if (m) {
        parent.children[index] = { type: 'html', value:
          `<div class="embed"><iframe src="/embeds/${encodeURIComponent(m[1])}" sandbox="allow-scripts allow-popups" loading="lazy" title="図解" data-embed></iframe></div>` };
      }
    });
  };
}

tools/import_all.py — 今のサイトの CMS データから全ページを作る道具。旧サイトへのリンクを、/pixelstomotors を外した形に直すようにした

"""Studio の CMS データ(fetch_live.py で取り直したもの)と site_archive の固定ページから、
新しいサイトのデータと素材を作る。

使い方:
  python3 import_all.py <cmsフォルダ> <site_archive の content/static> <サイトのフォルダ>

出力:
  src/data/records/{slug}.json   記事(Studio から移したもの。Markdown で書いた記事は除く)
  src/data/cards.json            記事の一覧用(全記事)
  src/data/manga/{slug}.json     4コマ(日本語)
  src/data/english/{slug}.json   4コマ(英語版)
  src/data/mangaCards.json / englishCards.json  一覧用
  src/data/static.json           固定ページ(シリーズのページ・プライバシーポリシーなど)
  public/embeds/                 図解などの埋め込み(隔離した枠で開く)
  public/thumbs/                 動くサムネイル

画像と動画は R2(/media/ファイル名)に置く。ここではファイル名だけを書き換える。
"""
import html
import json
import re
import shutil
import sys
import urllib.parse
from pathlib import Path

CMS = Path(sys.argv[1])
STATIC = Path(sys.argv[2])
SITE = Path(sys.argv[3])
DATA = SITE / 'src' / 'data'
PUB = SITE / 'public'

STUDIO_MEDIA = re.compile(r'https://storage\.googleapis\.com/studio-(?:design-asset-files|cms-assets)/projects/[^/]+/([A-Za-z0-9_.\-]+)')
# リンク先だけをサイト内の住所に直す(本文に書かれた URL の文字はそのまま)
# 旧サイトのリンク(絶対・相対どちらも)を新しいサイトの中のリンクに直す: /pixelstomotors/x → /x、/pixelstomotors → /
SITE_LINK = re.compile(r'href="(?:https?://adaptdesign\.jp)?/pixelstomotors(/[^"]*)?"')

# 図解の中から、自分の高さを親ページに知らせる小さな仕組み(隔離した枠の中では高さだけを送る)
HEIGHT_REPORTER = """<script>(function(){var last=0;
function measure(){var b=document.body;if(!b)return 0;var cs=getComputedStyle(b);var bottom=0;
for(var i=0;i<b.children.length;i++){var el=b.children[i];var s=getComputedStyle(el);if(s.position==='fixed'||s.display==='none'||el.tagName==='SCRIPT'||el.tagName==='STYLE')continue;
var r=el.getBoundingClientRect();var mb=parseFloat(s.marginBottom)||0;if(r.bottom+mb>bottom)bottom=r.bottom+mb;}
return Math.ceil(bottom+window.scrollY+(parseFloat(cs.paddingBottom)||0)+(parseFloat(cs.marginBottom)||0));}
function send(){var h=measure();if(h>0&&Math.abs(h-last)>1){last=h;parent.postMessage({p2mHeight:h},'*');}}
window.addEventListener('load',send);window.addEventListener('resize',send);
if(window.ResizeObserver&&document.body){new ResizeObserver(send).observe(document.body);}setInterval(send,1000);send();})();</script>"""


def media(s):
    """Studio の画像・動画の住所を、新しいサイトの /media/ に置き換える"""
    return STUDIO_MEDIA.sub(lambda m: '/media/' + m.group(1), s)


def links(s):
    return SITE_LINK.sub(lambda m: 'href="' + (m.group(1) or '/') + '"', s)


def with_reporter(doc):
    if '</body>' in doc:
        return doc.replace('</body>', HEIGHT_REPORTER + '</body>', 1)
    return doc + HEIGHT_REPORTER


def clean_attrs(s):
    s = re.sub(r'\s(?:data-uid|data-time|data-thread|data-margin-left|data-margin-right)="[^"]*"', '', s)
    s = re.sub(r'\sstyle=""', '', s)
    s = re.sub(r'\s(?:width|height|poster)=""', '', s)
    return s


def sandbox_for(code):
    # 中に外部の iframe(YouTube・Google ドキュメントなど)があるときは、その iframe が動けるよう同じ扱いにする
    if re.search(r'<iframe[^>]+src="https?://', code):
        return 'allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox allow-presentation'
    return 'allow-scripts allow-popups allow-popups-to-escape-sandbox'


def write_embed(name, code):
    (PUB / 'embeds' / name).write_text(with_reporter(code), encoding='utf-8')
    return (f'<div class="embed"><iframe src="/embeds/{name}" sandbox="{sandbox_for(code)}" '
            f'loading="lazy" title="図解" data-embed allowfullscreen></iframe></div>')


def youtube(vid):
    return (f'<div class="yt"><iframe src="https://www.youtube-nocookie.com/embed/{vid}" title="YouTube" loading="lazy" '
            'allowfullscreen allow="accelerometer; encrypted-media; gyroscope; picture-in-picture"></iframe></div>')


def convert_body(body, slug):
    def embed(m):
        attrs, inner = m.group(1), m.group(2)
        uid = re.search(r'data-uid="([^"]+)"', attrs).group(1)
        kind = re.search(r'data-embed-code-type="([^"]+)"', attrs)
        code = urllib.parse.unquote(inner.strip())
        if not code.strip():
            return ''  # 中身が空の埋め込み(Studio でも空の枠だった)
        if kind and kind.group(1) == 'youtube':
            vid = re.search(r'(?:youtu\.be/|v=|embed/)([\w-]{11})', code)
            if vid:
                return youtube(vid.group(1))
        return write_embed(f'{slug}-{uid}.html', media(code))

    body = re.sub(r'<div data-type="embed_code"([^>]*)>(.*?)</div>', embed, body, flags=re.S)
    # 動画(mp4)
    body = re.sub(r'<div data-type="video_block"[^>]*>(<video[^>]*>)</video></div>',
                  lambda m: '<div class="video">' + re.sub(r'\scontrols(="")?', '', m.group(1)).replace('<video', '<video controls playsinline preload="metadata"') + '</video></div>',
                  body)
    body = media(links(body))
    heads = re.findall(r'<h2[^>]*id="([^"]+)"[^>]*>(.*?)</h2>', body, flags=re.S)
    toc = [{'id': i, 'text': html.unescape(re.sub(r'<[^>]+>', '', t))} for i, t in heads if re.sub(r'<[^>]+>', '', t).strip()]
    has_toc = 'data-type="table_of_contents"' in body
    body = re.sub(r'<div data-type="table_of_contents"[^>]*></div>', '<!--TOC-->', body)
    body = re.sub(r'<div data-type="table"[^>]*>', '<div class="table">', body)
    body = re.sub(r'<video([^>]*)\sdata-natural-width="\d+"\sdata-natural-height="\d+"', r'<video\1', body)
    return clean_attrs(body), (toc if has_toc else [])


def ref(v):
    return v if isinstance(v, dict) else {}


def load(kind):
    out = []
    for f in sorted((CMS / kind).glob('*.json')):
        out.append(json.loads(f.read_text(encoding='utf-8'))['item'])
    return out


def main():
    for d in ('records', 'manga', 'english'):
        p = DATA / d
        if p.exists():
            shutil.rmtree(p)
        p.mkdir(parents=True)
    for d in ('embeds', 'thumbs'):
        (PUB / d).mkdir(parents=True, exist_ok=True)

    md_slugs = set()
    for f in (SITE / 'src' / 'content' / 'records').glob('*.md'):
        m = re.search(r'^slug:\s*"?([^"\n]+)"?\s*$', f.read_text(encoding='utf-8'), re.M)
        if m:
            md_slugs.add(m.group(1).strip())

    # 記事
    cards = []
    for it in load('record'):
        slug = it['slug']
        thumb = None
        q = ref(it.get('Qi0bIZEy'))
        if q.get('title'):
            thumb = f'/thumbs/r-{slug}.html'
            (PUB / 'thumbs' / f'r-{slug}.html').write_text(media(q['title']), encoding='utf-8')
        cover = media(it['cover']) if it.get('cover') else None
        card = {'day': it.get('bPskK658') or '', 'series': ref(it.get('HOUwyFeo')).get('title', ''),
                'published': it['_meta']['publishedAt'], 'title': it['title'], 'slug': slug,
                'cover': cover, 'thumb': thumb}
        if slug in md_slugs:
            continue  # Markdown で書いた記事は、Markdown の方を使う(一覧には Markdown 側から入る)
        cards.append(card)
        body, toc = convert_body(it['body'], slug)
        rec = dict(card, lang=ref(it.get('O7HA0yM1')).get('title', ''), description=it.get('WtD0MT_q') or '',
                   updated=it['_meta'].get('updatedAt'), body=body, toc=toc)
        (DATA / 'records' / f'{slug}.json').write_text(json.dumps(rec, ensure_ascii=False), encoding='utf-8')
    (DATA / 'cards.json').write_text(json.dumps(cards, ensure_ascii=False, indent=0), encoding='utf-8')

    # 4コマ(日本語・英語版)
    for kind, listname in (('manga', 'mangaCards'), ('english', 'englishCards')):
        lst = []
        for it in load(kind):
            m = {'slug': it['slug'], 'title': it['title'], 'label': it.get('XLiEhio_') or '',
                 'character': ref(it.get('jy4DazWj')).get('title', ''), 'description': it.get('LRhqVKVx') or '',
                 'published': it['_meta']['publishedAt'], 'cover': media(it.get('cover') or ''),
                 'comic': media(it.get('UeFRSlTz') or ''), 'cover_alt': it.get('JZ09W1er') or '',
                 'comic_alt': it.get('j670Jo5w') or ''}
            body = clean_attrs(media(links(it.get('body') or '')))
            (DATA / kind / f"{it['slug']}.json").write_text(json.dumps(dict(m, body=body), ensure_ascii=False), encoding='utf-8')
            lst.append(m)
        lst.sort(key=lambda x: x['published'], reverse=True)
        (DATA / f'{listname}.json').write_text(json.dumps(lst, ensure_ascii=False, indent=0), encoding='utf-8')

    # 固定ページ(シリーズのページの中身)
    static = {}
    for p in sorted(STATIC.glob('series*')):
        if p.name.endswith(' 2'):
            continue
        d = json.loads((p / 'studio_design.json').read_text(encoding='utf-8'))
        blocks, link = [], None

        def px(node):
            st = node.get('style') if isinstance(node.get('style'), dict) else {}
            h = str(st.get('height', ''))
            m = re.fullmatch(r'(\d+)px', h)
            return int(m.group(1)) if m else ('100%' if h == '100%' else None)

        def walk(n, link=None, hs=()):
            if isinstance(n, list):
                for v in n:
                    walk(v, link, hs)
                return
            if not isinstance(n, dict):
                return
            t = n.get('type')
            if t == 'list':
                return
            lk = n.get('link')
            if isinstance(lk, dict) and isinstance(lk.get('path'), str) and lk['path']:
                link = lk['path']  # この要素の中身全体がリンクになっている
            data = n.get('data') if isinstance(n.get('data'), str) else None
            # 枠の高さ:すぐ外側の箱の高さ、なければその外のまとまりの高さ(Studio の設定のまま)
            near = hs[-1] if hs else None
            height = near if isinstance(near, int) else (next((h for h in reversed(hs[-3:]) if isinstance(h, int)), None) if near == '100%' else None)
            if t == 'text' and data is not None:
                blocks.append({'type': 'text', 'html': re.sub(r'(<br>)+$', '', data), 'link': link})
            elif t == 'iframe' and data:
                if height:  # 高さの決まっていない iframe は Studio でも表示されていなかった
                    blocks.append({'type': 'iframe', 'html': data, 'height': height})
            elif t == 'sandbox' and data:
                name = f'{p.name}-{sum(1 for b in blocks if b["type"] == "embed") + 1}.html'
                blocks.append({'type': 'embed', 'html': write_embed(name, media(data)), 'height': near if isinstance(near, int) else None})
            elif t == 'img' and isinstance(n.get('src'), str) and 'storage.googleapis' in n['src']:
                blocks.append({'type': 'img', 'src': media(n['src']), 'link': link})
            for k, v in n.items():
                if isinstance(v, (list, dict)) and k not in ('style',):
                    walk(v, link, hs + (px(n),))
        walk(d)
        # 最後の「Record | 記録」以降は一覧なので要らない
        cut = next((i for i, b in enumerate(blocks) if b['type'] == 'text' and 'Record' in b['html']), len(blocks))
        static[p.name] = {'blocks': blocks[:cut]}
    (DATA / 'static.json').write_text(json.dumps(static, ensure_ascii=False, indent=1), encoding='utf-8')
    print('記事', len(cards), '4コマ', len(load('manga')), '英語版', len(load('english')), 'シリーズ', list(static))


if __name__ == '__main__':
    main()

README.md — リポジトリの説明

# p2m-site

Pixels to Motors のサイト(pixelstomotors.com)。adaptdesign.jp/pixelstomotors を Studio から移したもの。Astro で静的ページを作り、Cloudflare Workers で配信する。URL は旧サイトから /pixelstomotors を外した形(例: /record/{slug})。旧い形の URL で来たら worker/index.js が 301 で転送する。
GitHub の main に push すると、Cloudflare が自動で作り直して公開する(約40秒)。

- `npm run build` → `dist/` に全ページができる
- `tools/fetch_live.py <出力>`:今の Studio サイトから記事・4コマの CMS データを取り直す(移行時だけ使う)
- `tools/import_all.py <cms> <site_archive/content/static> .`:CMS データと固定ページから `src/data/` と `public/embeds,thumbs` を作る(移行時だけ使う)
- `tools/add_article.py <作業フォルダ> <DAY> <シリーズ名>`:Claude が書いた記事1本(Markdown)を入れる
- `tools/upload_media.py <フォルダ>...`:Mac で動かし、画像と動画を R2(p2m-media)へ入れる。鍵は KIOXIA の `p2m_cloudflare_token.txt`

記事・4コマの画像と動画は GitHub に入れない。サイトの `/media/ファイル名` を `worker/index.js` が R2 から返す。
メッセージフォームの送信は R2(p2m-messages)に保存する。

ページのファイルを移したときのコマンド

cd /home/claude/p2m-site
git rm -q src/pages/index.astro
for f in src/pages/pixelstomotors/*; do git mv "$f" src/pages/; done
rmdir src/pages/pixelstomotors
# 1段浅くなったので、読み込み先を1段ずつ直す
for f in src/pages/*.astro; do [ "$f" = src/pages/404.astro ] && continue; sed -i "s#\(['\"\`]\)\.\./\.\./#\1../#g" "$f"; done
for f in src/pages/*/*.astro; do sed -i "s#\(['\"\`]\)\.\./\.\./\.\./#\1../../#g" "$f"; done

ページの中のリンクと、取り込んだ記事本文の中のリンクから /pixelstomotors を外したスクリプト

import re,glob,io
files=glob.glob('src/pages/**/*.astro',recursive=True)+glob.glob('src/components/*.astro')+glob.glob('src/layouts/*.astro')
pat=re.compile(r"(?<![\w.])/pixelstomotors(?=[/\"'`?#)}]|$)(/)?")
n=0
for f in files:
    t=open(f,encoding='utf-8').read()
    def rep(m):
        return '/' if not m.group(1) else '/'
    # '/pixelstomotors/x' -> '/x' ; '/pixelstomotors' -> '/'
    u=re.sub(r"(?<![\w.])/pixelstomotors/", "/", t)
    u=re.sub(r"(?<![\w.])/pixelstomotors(?=[\"'`?#)}])", "/", u)
    if u!=t: open(f,'w',encoding='utf-8').write(u); n+=1
print('astro files changed',n)
# records JSON: only href attributes
m=0
for f in glob.glob('src/data/**/*.json',recursive=True):
    t=open(f,encoding='utf-8').read()
    u=re.sub(r'href=\\"/pixelstomotors(/[^\\"]*)?\\"', lambda x: 'href=\\"'+(x.group(1) or '/')+'\\"', t)
    if u!=t: open(f,'w',encoding='utf-8').write(u); m+=1
print('json files changed',m)

出力:

astro files changed 14
json files changed 6

リンクを直した記事本文は、building-a-physical-ai-lab-at-home・day14・day141・first-video-end-to-end・motion-uncanny-valley・youtube-channel-identity の6本です。

確かめたときのスクリプトと出力の全文

スクリプトは、作業用のフォルダに置いて実行したものをそのまま載せています。

今のサイトのトップの動画の設定を読み取った(vids.py)

import asyncio
from playwright.async_api import async_playwright
async def main():
    async with async_playwright() as p:
        b=await p.chromium.launch()
        pg=await (await b.new_context(viewport={'width':1440,'height':900})).new_page()
        await pg.goto('https://adaptdesign.jp/pixelstomotors',wait_until='networkidle',timeout=90000)
        await pg.wait_for_timeout(3000)
        r=await pg.evaluate('''()=>[...document.querySelectorAll('video')].map(v=>{const r=v.getBoundingClientRect();return {src:(v.currentSrc||v.src).slice(0),poster:(v.poster||'').slice(0),autoplay:v.autoplay,loop:v.loop,muted:v.muted,controls:v.controls,paused:v.paused,t:v.currentTime,y:Math.round(r.top+scrollY),w:Math.round(r.width),h:Math.round(r.height)}})''')
        for x in r: print(x)
        await pg.screenshot(path='live_top_hero.png',clip={'x':0,'y':0,'width':1440,'height':900})
        await b.close()
asyncio.run(main())

出力(Playwright の Chromium は H.264 を再生できないため、paused は True のままになる):

{'src': 'https://storage.googleapis.com/studio-design-asset-files/projects/p6aoJ4vyqR/s-848x1056_e5500e41-e73b-4177-914e-b09a2d11f83c.mp4#t=0.01', 'poster': '', 'autoplay': True, 'loop': True, 'muted': True, 'controls': True, 'paused': True, 't': 0, 'y': 104, 'w': 336, 'h': 320}
{'src': 'https://storage.googleapis.com/studio-design-asset-files/projects/p6aoJ4vyqR/s-1280x720_ff64009f-cd40-4e7d-8ba3-350ff0d6c3ad.mp4', 'poster': 'https://storage.googleapis.com/studio-design-asset-files/projects/p6aoJ4vyqR/s-2048x2048_5af0bc9f-8d23-4bdb-85e4-ada51bfd7bbd.webp', 'autoplay': True, 'loop': True, 'muted': True, 'controls': False, 'paused': True, 't': 0, 'y': 5465, 'w': 160, 'h': 160}

新しいサイトのトップの動画の指定(直す前と直した後)

-    <video class="hero-video" src={media("/media/s-848x1056_e5500e41-e73b-4177-914e-b09a2d11f83c.mp4")} controls muted playsinline preload="metadata"></video>
+    <video class="hero-video" src={media("/media/s-848x1056_e5500e41-e73b-4177-914e-b09a2d11f83c.mp4") + "#t=0.01"} controls muted autoplay loop playsinline></video>

作り直したときの出力:

18:03:07 [build] 556 page(s) built in 4.94s
18:03:07 [build] Complete!
<video class="hero-video" src="/media/s-848x1056_e5500e41-e73b-4177-914e-b09a2d11f83c.mp4#t=0.01" controls muted autoplay loop playsinline data-astro-cid-fbplxbez>

今のサイトの記事の中の動画の設定を読み取った(vids2.py)

import asyncio
from playwright.async_api import async_playwright
SL=['correction-blocked-by-table-gap','making-explainer-video-with-ai','sam3-mirror-reflection-grasp-problem']
async def main():
    async with async_playwright() as p:
        b=await p.chromium.launch()
        pg=await (await b.new_context(viewport={'width':1440,'height':900})).new_page()
        for s in SL:
            await pg.goto('https://adaptdesign.jp/pixelstomotors/record/'+s,wait_until='networkidle',timeout=90000)
            await pg.wait_for_timeout(2000)
            r=await pg.evaluate('''()=>[...document.querySelectorAll('video')].map(v=>({src:(v.currentSrc||v.src).slice(-50),autoplay:v.autoplay,loop:v.loop,muted:v.muted,controls:v.controls,poster:v.poster}))''')
            print(s,r)
        await b.close()
asyncio.run(main())

出力(poster が記事の URL になっているのは、poster が空のときにブラウザがページの URL を返すため。実際には poster なし):

correction-blocked-by-table-gap [{'src': '-1516x930_45eaf469-f415-47a0-af0a-476a19bb4ac5.mp4', 'autoplay': False, 'loop': False, 'muted': False, 'controls': True, 'poster': 'https://adaptdesign.jp/pixelstomotors/record/correction-blocked-by-table-gap'}]
making-explainer-video-with-ai [{'src': '1920x1080_b273c60b-57bf-4356-b2b2-1b5a4023565f.mp4', 'autoplay': False, 'loop': False, 'muted': False, 'controls': True, 'poster': 'https://adaptdesign.jp/pixelstomotors/record/making-explainer-video-with-ai'}]
sam3-mirror-reflection-grasp-problem [{'src': 's-900x674_a35ecfaf-09a5-4028-bb34-ae14382f9801.mp4', 'autoplay': False, 'loop': False, 'muted': False, 'controls': True, 'poster': 'https://adaptdesign.jp/pixelstomotors/record/sam3-mirror-reflection-grasp-problem'}, {'src': '-1280x720_0e7779e9-0f3a-45fd-aa67-b76c819faabb.mp4', 'autoplay': False, 'loop': False, 'muted': False, 'controls': True, 'poster': 'https://adaptdesign.jp/pixelstomotors/record/sam3-mirror-reflection-grasp-problem'}]

R2 に入っている動画を確かめた(Mac で実行。鍵はファイルから読み、表示しない)

T=$(tr -d ' \r\n' < "$HOME/mnt/KIOXIA/p2m_cloudflare_token.txt"); A=21d7aed1b193796b89287df10913f2dc
for k in s-848x1056_e5500e41-e73b-4177-914e-b09a2d11f83c.mp4 s-1280x720_ff64009f-cd40-4e7d-8ba3-350ff0d6c3ad.mp4; do
echo "== $k"; curl -s -o /dev/null -D - -H "Authorization: Bearer $T" -H "Range: bytes=0-99" "https://api.cloudflare.com/client/v4/accounts/$A/r2/buckets/p2m-media/objects/$k" | grep -i -E "^HTTP|content-type|content-length|content-range"; done

出力:

== s-848x1056_e5500e41-e73b-4177-914e-b09a2d11f83c.mp4
HTTP/2 200 
content-type: video/mp4
content-length: 2310256
x-content-type-options: nosniff
== s-1280x720_ff64009f-cd40-4e7d-8ba3-350ff0d6c3ad.mp4
HTTP/2 200 
content-type: video/mp4
content-length: 296023
x-content-type-options: nosniff

動画ファイルそのものを調べた

curl -s -o hero.mp4 https://storage.googleapis.com/studio-design-asset-files/projects/p6aoJ4vyqR/s-848x1056_e5500e41-e73b-4177-914e-b09a2d11f83c.mp4 && ls -l hero.mp4 && ffprobe -v error -show_entries stream=codec_name,codec_tag_string,width,height,pix_fmt,profile -show_entries format=duration -of compact hero.mp4; python3 -c "
d=open('hero.mp4','rb').read(); print('moov at',d.find(b'moov'),'mdat at',d.find(b'mdat'),'size',len(d))"

出力:

-rw-r--r-- 1 root root 2310256 Oct 16 18:09 hero.mp4
stream|codec_name=h264|profile=High|codec_tag_string=avc1|width=848|height=1056|pix_fmt=yuv420p
stream|codec_name=aac|profile=LC|codec_tag_string=mp4a
stream|codec_name=mjpeg|profile=Baseline|codec_tag_string=[0][0][0][0]|width=848|height=1056|pix_fmt=yuvj420p
format|duration=10.041667
moov at 13703 mdat at 71491 size 2310256

Worker を手元で動かし、範囲指定で動画を取った(直す前)

cd /home/claude/p2m-site
npx wrangler@4 r2 object put p2m-media/s-848x1056_e5500e41-e73b-4177-914e-b09a2d11f83c.mp4 --local --file hero.mp4 --content-type video/mp4
npx wrangler@4 dev --local --port 8799 &
U=http://127.0.0.1:8799/media/s-848x1056_e5500e41-e73b-4177-914e-b09a2d11f83c.mp4
for r in "" "bytes=0-1" "bytes=0-" "bytes=71491-"; do echo "== range: $r"; curl -s -o /dev/null -D - ${r:+-H "Range: $r"} $U | grep -i -E "^HTTP|content-type|content-length|content-range|accept"; done

出力:

4.151.0
Creating object "s-848x1056_e5500e41-e73b-4177-914e-b09a2d11f83c.mp4" in bucket "p2m-media".
Upload complete.
== range: 
HTTP/1.1 200 OK
Content-Length: 2310256
Content-Type: video/mp4
Accept-Ranges: bytes
== range: bytes=0-1
HTTP/1.1 206 Partial Content
Content-Length: 2
Content-Type: video/mp4
Content-Range: bytes NaN-2310255/2310256
Accept-Ranges: bytes
== range: bytes=0-
HTTP/1.1 206 Partial Content
Content-Length: 2310256
Content-Type: video/mp4
Content-Range: bytes NaN-2310255/2310256
Accept-Ranges: bytes
== range: bytes=71491-
HTTP/1.1 206 Partial Content
Content-Length: 2238765
Content-Type: video/mp4
Content-Range: bytes NaN-2310255/2310256
Accept-Ranges: bytes
[wrangler:info] Ready on http://localhost:8799
[wrangler:info] GET /media/s-848x1056_e5500e41-e73b-4177-914e-b09a2d11f83c.mp4 200 OK (20ms)
[wrangler:info] GET /media/s-848x1056_e5500e41-e73b-4177-914e-b09a2d11f83c.mp4 206 Partial Content (66ms)
[wrangler:info] GET /media/s-848x1056_e5500e41-e73b-4177-914e-b09a2d11f83c.mp4 206 Partial Content (5ms)
[wrangler:info] GET /media/s-848x1056_e5500e41-e73b-4177-914e-b09a2d11f83c.mp4 206 Partial Content (7ms)

直した後に、同じ取り方で確かめた

U=http://127.0.0.1:8799/media/s-848x1056_e5500e41-e73b-4177-914e-b09a2d11f83c.mp4
for r in "bytes=0-1" "bytes=0-" "bytes=71491-" "bytes=-500" "bytes=100-199"; do printf "%-14s " "$r"; curl -s -o part.bin -D - -H "Range: $r" $U | grep -i -E "^HTTP|content-range|content-length" | tr -d '\r' | tr '\n' ' '; echo " got=$(stat -c%s part.bin)"; done
curl -s -H "Range: bytes=100-199" $U | cmp - <(dd if=vid/hero.mp4 bs=1 skip=100 count=100 2>/dev/null) && echo "bytes match"

出力:

bytes=0-1      HTTP/1.1 206 Partial Content Content-Length: 2 Content-Range: bytes 0-1/2310256  got=2
bytes=0-       HTTP/1.1 206 Partial Content Content-Length: 2310256 Content-Range: bytes 0-2310255/2310256  got=2310256
bytes=71491-   HTTP/1.1 206 Partial Content Content-Length: 2238765 Content-Range: bytes 71491-2310255/2310256  got=2238765
bytes=-500     HTTP/1.1 206 Partial Content Content-Length: 500 Content-Range: bytes 2309756-2310255/2310256  got=500
bytes=100-199  HTTP/1.1 206 Partial Content Content-Length: 100 Content-Range: bytes 100-199/2310256  got=100
bytes match

手元のブラウザで再生を試した(play.py)

import asyncio
from playwright.async_api import async_playwright
async def main():
    async with async_playwright() as p:
        for name,bt in (('chromium',p.chromium),('webkit',p.webkit)):
            try: b=await bt.launch()
            except Exception as e: print(name,'launch fail',str(e)[:80]); continue
            pg=await b.new_page()
            await pg.goto('http://127.0.0.1:8799/pixelstomotors')
            await pg.wait_for_timeout(4000)
            print(name, await pg.evaluate('''()=>{const v=document.querySelector('.hero-video');return {paused:v.paused,t:v.currentTime.toFixed(2),ready:v.readyState,err:v.error&&v.error.code,dur:v.duration}}'''))
            await b.close()
asyncio.run(main())

出力(error 4 は「この形式は再生できない」。Playwright の Chromium は H.264 を持たない。WebKit は入っていない):

chromium {'paused': True, 't': '0.00', 'ready': 0, 'err': 4, 'dur': nan}
webkit launch fail BrowserType.launch: Executable doesn't exist at /opt/pw-browsers/webkit-2215/pw_

ドメインの空きを確かめた(登録の記録を引く RDAP。404 は登録がないという意味)

for d in pixelstomotors.com pixelstomotors.dev pixelstomotors.jp p2m.dev; do
case $d in *.com) u="https://rdap.verisign.com/com/v1/domain/$d";; *.dev) u="https://pubapi.registry.google/rdap/domain/$d";; *.jp) u="";; esac
if [ -n "$u" ]; then c=$(curl -s -o /dev/null -w '%{http_code}' "$u"); echo "$d rdap=$c"; fi
done; whois pixelstomotors.jp 2>/dev/null | head -5 || echo nowhois

出力(.jp はこの方法では引けなかった):

pixelstomotors.com rdap=404
pixelstomotors.dev rdap=404
p2m.dev rdap=200

URL を変えたあとに作り直した

18:32:33 [ERROR] [vite] ✗ Build failed in 651ms
Could not resolve "../../components/MangaArchive.astro" from "src/pages/manga-archive.astro"
file: /home/claude/p2m-site/src/pages/manga-archive.astro

ページを1段浅い場所へ移したので、読み込み先が合わなくなっていました。読み込み先を1段ずつ直したあと:

18:32:51 [build] 555 page(s) built in 4.92s
18:32:51 [build] Complete!

旧 URL とサイト内リンクを照合した(check_urls.py)

import re,urllib.request,os,glob,html
from urllib.parse import unquote,urlparse
def get(u): return urllib.request.urlopen(urllib.request.Request(u,headers={'User-Agent':'Mozilla/5.0'}),timeout=60).read().decode()
idx=get('https://adaptdesign.jp/sitemap.xml')
locs=[]
for sm in re.findall(r'<loc>([^<]+)</loc>',idx):
    if sm.endswith('.xml'): locs+=re.findall(r'<loc>([^<]+)</loc>',get(sm))
    else: locs.append(sm)
pm=sorted(set(l for l in locs if '/pixelstomotors' in l))
def dist_of(path):
    p=unquote(path).rstrip('/') or '/'
    if p=='/': return 'dist/index.html'
    return 'dist'+p+'.html'
missing=[]
for l in pm:
    path=urlparse(l).path
    new=path[len('/pixelstomotors'):] or '/'
    if not os.path.exists(dist_of(new)): missing.append((path,new))
print('old URLs',len(pm),'missing on new site',len(missing),missing[:5])
# internal links in built pages
bad={};total=0;oldprefix=0
for f in glob.glob('dist/**/*.html',recursive=True):
    if f.startswith(('dist/embeds','dist/thumbs')): continue
    t=open(f,encoding='utf-8').read()
    for h in re.findall(r'href="([^"]+)"',t):
        h=html.unescape(h)
        if not h.startswith('/') or h.startswith('//'): continue
        total+=1
        p=h.split('#')[0].split('?')[0]
        if p.startswith('/pixelstomotors'): oldprefix+=1
        if p.startswith(('/site/','/media/','/embeds/','/thumbs/','/_astro/','/api/')): continue
        if not os.path.exists(dist_of(p)): bad.setdefault(p,set()).add(f)
print('internal links',total,'still /pixelstomotors',oldprefix,'broken',len(bad))
for k,v in list(bad.items())[:15]: print(' ',k,len(v),sorted(v)[0])

1回目の出力:

old URLs 554 missing on new site 0 []
internal links 15852 still /pixelstomotors 9 broken 6
  /web_production_list 555 dist/404.html
  /pixelstomotors 1 dist/record/studio-to-astro-cloudflare.html
  /pixelstomotors/manga-archive 1 dist/record/studio-to-astro-cloudflare.html
  /pixelstomotors/manga-archive-en 1 dist/record/studio-to-astro-cloudflare.html
  /pixelstomotors/message 1 dist/record/studio-to-astro-cloudflare.html
  /pixelstomotors/privacypolicy 1 dist/record/studio-to-astro-cloudflare.html

LOG340 の記事の中の5つが、本当のリンクかどうかを確かめた:

grep -o '<a [^>]*href="/pixelstomotors[^"]*"' dist/record/studio-to-astro-cloudflare.html | head -3; echo "real links: $(grep -c '<a [^>]*href="/pixelstomotors' dist/record/studio-to-astro-cloudflare.html)"

出力:

real links: 0

フッターのリンクを直して作り直したあとの出力(残る6つは、すべて LOG340 の記事に載せたコードの中の文字):

old URLs 554 missing on new site 0 []
internal links 15297 still /pixelstomotors 9 broken 6
  /pixelstomotors 1 dist/record/studio-to-astro-cloudflare.html
  /pixelstomotors/manga-archive 1 dist/record/studio-to-astro-cloudflare.html
  /pixelstomotors/manga-archive-en 1 dist/record/studio-to-astro-cloudflare.html
  /pixelstomotors/message 1 dist/record/studio-to-astro-cloudflare.html
  /web_production_list 1 dist/record/studio-to-astro-cloudflare.html
  /pixelstomotors/privacypolicy 1 dist/record/studio-to-astro-cloudflare.html

canonical と og:url が新しいドメインになっているか

grep -o '<link rel="canonical"[^>]*>\|<meta property="og:url"[^>]*>' dist/record/day1.html dist/index.html

出力:

dist/record/day1.html:<link rel="canonical" href="https://pixelstomotors.com/record/day1">
dist/record/day1.html:<meta property="og:url" content="https://pixelstomotors.com/record/day1">
dist/index.html:<link rel="canonical" href="https://pixelstomotors.com/">
dist/index.html:<meta property="og:url" content="https://pixelstomotors.com/">

古い形の URL の転送を、手元で確かめた

for p in /pixelstomotors /pixelstomotors/ /pixelstomotors/record/day1 "/pixelstomotors/manga-archive?x=1" /record/day1 / /series3 /message; do printf "%-36s " "$p"; curl -s -o /dev/null -w '%{http_code} %{redirect_url}\n' "http://127.0.0.1:8799$p"; done

出力:

/pixelstomotors                      301 http://127.0.0.1:8799/
/pixelstomotors/                     301 http://127.0.0.1:8799/
/pixelstomotors/record/day1          301 http://127.0.0.1:8799/record/day1
/pixelstomotors/manga-archive?x=1    301 http://127.0.0.1:8799/manga-archive?x=1
/record/day1                         200 
/                                    200 
/series3                             200 
/message                             200

pixelstomotors.com をつないだあと、外から開いた

for i in 1 2 3 4 5 6; do c=$(curl -s -o /dev/null -w '%{http_code}' --max-time 15 https://pixelstomotors.com/); echo "try $i: $c"; [ "$c" = 200 ] && break; sleep 20; done
curl -s -o /dev/null -D - --max-time 15 https://pixelstomotors.com/ | grep -i -E "^HTTP|^location|^server"
curl -s -o /dev/null -D - --max-time 15 https://pixelstomotors.com/record/day1 | grep -i -E "^HTTP|^location"

出力(ログイン画面の URL に付く長い識別子 kid と meta は、<KID> と <META> に置き換えています):

try 1: 302
try 2: 302
try 3: 302
try 4: 302
try 5: 302
try 6: 302
HTTP/1.1 200 Connection Established
HTTP/2 302 
location: https://ancient-morning-8c21.cloudflareaccess.com/cdn-cgi/access/login/pixelstomotors.com?kid=<KID>&meta=<META>&redirect_url=%2F
server: cloudflare
HTTP/1.1 200 Connection Established
HTTP/2 302 
location: https://ancient-morning-8c21.cloudflareaccess.com/cdn-cgi/access/login/pixelstomotors.com?kid=<KID>&meta=<META>&redirect_url=%2Frecord%2Fday1