Studio を閉じて、旧 URL は Cloudflare で転送する ― 公開・通知メール・DNS 移行・LOG への改番を1日で
前回(LOG342)で、サイト用のドメイン pixelstomotors.com を取り、Cloudflare Workers で動く新しいサイトにつなぎました。ただ、ログインした本人しか見られない鍵がかかったままで、公開はしていませんでした。その続きです。
やったことを先に書きます。鍵を外して pixelstomotors.com を公開し、仮の住所を閉じました。メッセージフォームに届いた内容がメールで届くようにしました。旧サイトの住所 adaptdesign.jp の DNS を Cloudflare に移し、旧 URL を記事まで届く形で転送するようにして、Studio のサイトを非公開にし、プランを Free に下げました。トップの動画の節の体裁を直し、記事の番号の呼び方を DAY から LOG に変えて、一覧を番号順に並べ直しました。最後に、記事を作るときの決まり(article_rules.md)を、Studio に貼る前提から、GitHub に push すると公開される前提に書き換えました。
使ったコードと、確かめたときのコマンドと出力は、記事の後半に全文で載せています。
Access の範囲を「All traffic」から「Previews only」にして、pixelstomotors.com を公開した
前回つないだ時点で、pixelstomotors.com を外から開くとログイン画面(Cloudflare Access)へ回されました。原因は、鍵の設定が Worker 全体にかかっていたことでした。Worker の「Access」タブを開くと、Worker Access の範囲が「All traffic(すべての通信)」になっていました。

「Manage access」を押すと、範囲を選ぶ欄がありました。「Previews only(試しの版だけ)」と「All traffic」の2択です。

| 範囲 | 鍵がかかる住所 |
|---|---|
| All traffic | pixelstomotors.com・仮の住所(workers.dev)・試しの版(*-p2m-site.…workers.dev)のすべて |
| Previews only | 試しの版だけ |
「Previews only」にして保存しました。これで pixelstomotors.com と仮の住所の両方が、誰でも開ける状態になります。赤い「Remove Worker-specific Access」は押していません。押すと試しの版の鍵まで外れます。
公開と同時に、仮の住所は閉じました。同じサイトが2つの住所で見える状態を残さないためです。Worker の Settings にある「Worker URL」の、Production(p2m-site.pixelstomotors.workers.dev)のスイッチを切りました。Preview のスイッチは入れたままです。

外から確かめた結果です。
| 開いた URL | 結果 |
|---|---|
| https://pixelstomotors.com/ | 200 |
| https://pixelstomotors.com/record/day1 | 200 |
| https://pixelstomotors.com/pixelstomotors/record/day1 | 301 → /record/day1 |
| トップの動画(Range 指定) | 206・video/mp4 |
| https://p2m-site.pixelstomotors.workers.dev/ | 404(閉じた) |
ここで、新しいサイトが公開になりました。
メッセージフォームの内容は R2 に貯まるだけだったので、Cloudflare の送信の仕組みでメールにした
公開した時点で、1つ知っていたことがありました。メッセージフォームから送られた内容は、Cloudflare の保管場所(R2 の p2m-messages)に JSON で貯まるだけで、届いたことを知る手段がありませんでした。
Cloudflare には、Worker からメールを送る仕組み(send_email の binding)があります。送れる宛先は、Cloudflare の Email Routing で「確認済み」になっているメールアドレスだけです。その代わり、無料で使えます。
手順は次のとおりでした。
- Cloudflare の Email Routing で pixelstomotors.com を登録する(DNS に必要な行が自動で足される)
- 「Destination addresses」に pixelstomotors@adaptdesign.jp を足し、届いた確認メールのリンクを押す
- wrangler.jsonc に send_email の binding を足し、worker/index.js に送る処理を書く
- GitHub に push する(自動で公開される)

3 は 2 が終わるまで push しませんでした。確認済みでない宛先が binding に書いてあると、公開の作り直しが失敗して、サイトの更新が止まるためです。
worker/index.js では、R2 に保存したあとに notify() を呼びます。メールが送れなくても、保存は済んでいるので、フォームを送った人には成功と返します。送り元は form@pixelstomotors.com、返信先(Reply-To)にフォームを送った人のメールを入れ、そのまま返信できるようにしました。
メールの本文は、Cloudflare の EmailMessage に渡す「生のメール」の形(ヘッダーと本文を改行で区切った文字列)を自分で組み立てています。npm の mimetext を入れれば楽ですが、依存を増やしたくなかったので、必要な行だけ書きました。
件名が壊れた原因は、符号化した文字列を 76 文字ごとに折り返していたこと
1通目の試験メールは、迷惑メールに入り、件名が「=?utf-8?B?W1BpeGVscyB0…」という符号のままで、本文の先頭にヘッダーの残りが流れ込んでいました。

原因はコードの1か所でした。日本語の件名はそのままメールのヘッダーに書けないので、RFC 2047 という決まりで「=?utf-8?B?(base64)?=」の形に符号化します。私は本文と件名で同じ base64() を使っていて、その関数は 76 文字ごとに改行を入れる作りでした。本文(Content-Transfer-Encoding: base64)では正しい作法ですが、件名の中に改行が入ると、ヘッダーがそこで終わったことになります。残りの行はヘッダーとして読めないので、本文として扱われました。迷惑メール扱いも、この壊れたヘッダーが原因と見ています。
| 直す前 | 直した後 |
|---|---|
| 件名も本文も、76 文字ごとに改行を入れた base64 | 件名と返信先の名前は改行なし。本文だけ 76 文字ごとに改行 |
直したあとの2通目は、件名が日本語で読め、本文も正しく並びました。ただ、迷惑メールに入りました。Gmail の説明は「cloudflare-email.net から以前に受信したメールは迷惑メールに分類されたため」でした。1通目を迷惑メールと判断したことを覚えていて、同じ経路の2通目も疑った形です。「迷惑メールでないことを報告」を押すと、受信箱に移りました。

本文に「保存先: p2m-messages/{ファイル名}」を入れてあるので、メールが届かなくても、R2 を見れば同じ内容が残っています。
Studio の転送はトップにしか飛ばず、Free プランでは消えるので、adaptdesign.jp の DNS を Cloudflare に移した
公開したあと、旧サイトの URL(adaptdesign.jp/pixelstomotors/…)で来た人を新しいサイトへ送る転送が要ります。前回の時点で、Studio の転送機能は1ページずつしか登録できないことが分かっていました。エイイチが Studio 側で、adaptdesign.jp 全体を pixelstomotors.com へ転送する設定を入れました。
外から確かめると、2つのことが分かりました。
| 開いた URL | 行き先 |
|---|---|
| adaptdesign.jp/pixelstomotors | pixelstomotors.com/ |
| adaptdesign.jp/pixelstomotors/record/day1 | pixelstomotors.com/(記事ではなくトップ) |
| adaptdesign.jp/(ADAPT DESIGN のトップ) | pixelstomotors.com/ |
| adaptdesign.jp/web_production_list(Web 制作実績) | pixelstomotors.com/ |
1つは、記事の URL が記事ではなくトップに飛ぶこと。Studio の転送は「URL の続きを引き継ぐ」ことができないので、X や LinkedIn に貼った過去のリンクを開いた人は、トップから探すことになります。もう1つは、ADAPT DESIGN 本体のトップや実績ページまで飛ぶこと。これはエイイチに確かめ、このまま全部 pixelstomotors.com へ送ってよい、と決まりました。
ADAPT DESIGN のサイトは、このまま全部 pixelstomotors.com に送って OK
そして、Studio のプランの話です。Studio は Business プラン(月 3,980 円)で、転送だけのために払い続けるのはもったいない。ところが Free に下げると、独自ドメイン(adaptdesign.jp)がつなげなくなり、転送どころか adaptdesign.jp 自体が開かなくなります。


| Studio のプラン | 独自ドメイン | 転送 |
|---|---|---|
| Business(今) | 使える | 1ページずつ。URL の続きは引き継げない |
| Free | 使えない | なし |
結論は、転送を Studio から Cloudflare に移すことでした。adaptdesign.jp の DNS(名前を住所に変える台帳)を Cloudflare に移せば、Cloudflare の Redirect Rules(転送の決まり)が無料で使え、URL の続きを引き継いだ転送ができます。そのあと Studio を Free に下げます。
多分すぐ非公開になるので DNS を移して
ダウングレードの画面に「プラン移行日にサイトが自動的に非公開になります」とあり、移行日がいつか分からなかったので、先に DNS を移すことにしました。
DNS の移行は、控え → Cloudflare に追加 → DNSSEC を外す → DS が消えるのを待つ → ネームサーバーを変える、の順で行った
adaptdesign.jp は Squarespace に登録されていて、DNS は Google Cloud DNS(ns-cloud-c1〜c4.googledomains.com)にありました。メール(Google Workspace)も同じドメインで動いています。DNS を移すときに一番避けたいのは、メールが止まることでした。
順番を決めて進めました。
| 順 | やったこと | 誰が |
|---|---|---|
| 1 | 今の DNS の内容を外から引いて、iCloud の site_archive/dns/ に控える | Claude |
| 2 | Cloudflare に adaptdesign.jp を追加する(Free プラン)。既存の行は自動で読み取られる | エイイチ |
| 3 | 読み取られた行を控えと突き合わせる | Claude |
| 4 | Squarespace で DNSSEC をオフにする | エイイチ |
| 5 | .jp の台帳から DS レコードが消えるのを外から確かめる | Claude |
| 6 | Squarespace でネームサーバーを Cloudflare の2つに変える | エイイチ |
| 7 | 切り替わりを外から確かめる。メールの行(MX)が引けることも確かめる | Claude |
控えた DNS の内容
外から引けたのは次の行でした。A は Studio のサーバー、MX 5本と SPF・DKIM は Google Workspace のメール、TXT の2本は Google と Brave の所有確認です。
| 名前 | 種類 | 値 |
|---|---|---|
| adaptdesign.jp | A | 35.194.122.208 |
| adaptdesign.jp | MX | 1 aspmx.l.google.com / 5 alt1 / 5 alt2 / 10 alt3 / 10 alt4 |
| adaptdesign.jp | TXT | v=spf1 include:_spf.google.com ~all |
| adaptdesign.jp | TXT | google-site-verification=… |
| adaptdesign.jp | TXT | brave-ledger-verification=… |
| google._domainkey.adaptdesign.jp | TXT | v=DKIM1; k=rsa; p=… |
DNSSEC(DNS の改ざんを防ぐ署名)も有効で、DS レコードが .jp の台帳に登録されていました。これが順番を決める理由になります。
Cloudflare はメールの行まで自動で読み取った
Cloudflare で「Connect a domain」から adaptdesign.jp を入れ、Free プランを選ぶと、今の DNS を読み取った一覧が出ました。

| 控え | Cloudflare の一覧 |
|---|---|
| A(Studio) | あり |
| MX 5本 | あり |
| TXT 3本(SPF・Google 確認・Brave) | あり |
| DKIM | あり |
Squarespace が入れていた _domainconnect の CNAME が1行増えていましたが、あってもなくても影響はないので、そのままにしました。次の画面に、Cloudflare のネームサーバーが2つ表示されました。

DNSSEC は先に外す。DS が消えるまで 45 分待った
DNSSEC が有効なまま、ネームサーバーだけを Cloudflare に変えると、.jp の台帳にある鍵(DS)と、Cloudflare が返す中身が食い違います。署名を確かめる DNS サーバーは、その応答を偽物とみなして捨てます。サイトもメールも引けない時間ができる、ということです。前回の codex の報告でも、この順番が指摘されていました。
Squarespace の「DNSSEC」でスイッチをオフにしました。

オフにしたあと、エイイチはすぐにネームサーバーの画面に進みましたが、外から引くと DS レコードはまだ残っていました。Squarespace が .jp の台帳に消去を届け出て、それが反映されるまでに時間がかかります。ここで待ちました。
| 時刻 | DS レコード |
|---|---|
| 20:15 | オフにした直後。まだ1件残っている |
| 20:16 | まだ残っている。ネームサーバーの変更は待つ |
| 21:02 | 消えた |
消えたのを確かめてから、Squarespace の「ドメイン ネームサーバー」で「カスタム ネームサーバーを使用」を選び、Google の4つを消して Cloudflare の2つを入れました。

切り替わりまで 10 分ほど。その間に転送の決まりを作った
ネームサーバーを変えても、.jp の台帳がすぐに新しい値を返すわけではありません。21:07 の時点では Google のまま、21:30 に Cloudflare の2つに変わっていました。待っている間に、Cloudflare 側で転送の決まりを作っておきました。切り替わった瞬間から効くようにするためです。
転送の決まり(Redirect Rules)は2つです。Wildcard pattern(* で URL のパターンを書く形)で作りました。
| 順 | 名前 | 条件(Request URL) | 行き先(Target URL) | 続きの引き継ぎ |
|---|---|---|---|---|
| 1 | P2M paths | https://adaptdesign.jp/pixelstomotors* | https://pixelstomotors.com${1} | あり(query も残す) |
| 2 | Everything else | https://adaptdesign.jp/* | https://pixelstomotors.com/ | なし |
${1} は、* に一致した部分です。/pixelstomotors/record/day1 なら ${1} は /record/day1 になります。/pixelstomotors だけで来たときは ${1} が空なので、pixelstomotors.com に飛びます。決まりは上から順に評価されるので、1 が 2 より上にあることを一覧で確かめました。
最初に Request URL を *adaptdesign.jp/pixelstomotors* と書いたら、「プロトコル(https://)を含めてください。先頭の * は https://subdomain.example.com まで拾ってしまう」という注意が出ました。https:// から書く形に直しています。http:// で来た人は、Cloudflare が先に https:// へ回すので、この2つで拾えます。

切り替わったあとの確認
| 開いた URL | 結果 |
|---|---|
| adaptdesign.jp/pixelstomotors/record/day1 | 301 → pixelstomotors.com/record/day1(最後まで開くと 200) |
| adaptdesign.jp/pixelstomotors | 301 → pixelstomotors.com/ |
| adaptdesign.jp/ | 301 → pixelstomotors.com/ |
| adaptdesign.jp/web_production_list | 301 → pixelstomotors.com/ |
| http://adaptdesign.jp/pixelstomotors/manga/第1話?x=1 | 301 → https://(その先で上の転送) |
| MX(メール) | 5本とも引ける |
| TXT | 3本とも引ける |
記事の URL が記事まで届くようになりました。前回の記事で「次に決める」と書いた、旧 URL の転送方法はこれで決まりです。
Studio は非公開にして Free に下げた。古い DNS のキャッシュから来る 404 は数時間で消える
転送を確かめたあと、Studio の「公開ステータス」を非公開にし、プランを Free に下げました。Studio の CMS の中身は、LOG334 で作ったアーカイブ(iCloud の site_archive)と新しいサイトに全部あるので、Free の上限で消えても困りません。
非公開にした直後、外から何度か開くと、たまに 404 が返りました。この応答には Cloudflare の印(cf-ray というヘッダー)がなく、Cloudflare を通っていません。まだ古いネームサーバー(Google)を覚えている DNS キャッシュが、非公開になった Studio のサーバーに向けていました。ネームサーバーの情報の有効期限は 21600 秒(6 時間)だったので、その時間が過ぎれば全部 Cloudflare 側に切り替わります。
| 試行 | 結果 |
|---|---|
| 1回目 | 301 → pixelstomotors.com/record/day1(Cloudflare) |
| 2回目 | 404(cf-ray なし。古い DNS 経由で Studio へ) |
| 3回目 | 301 → pixelstomotors.com/record/day1(Cloudflare) |
トップの動画の節は、ほかの節と同じ白い枠に入れ、スクロールバーの原因は 16px の食み出しだった
サイトを公開して、エイイチがトップを見て指摘しました。
動画セクションに上下のパディングがないのと、角丸設定、コンテンツ幅などが違います。確認してください。

トップの「Video | 解説動画」は、YouTube の新着を並べる埋め込み(iframe)です。Studio のときから使っていたものをそのまま置いていましたが、ほかの節(Archive・Series・Record)は .sec(上下 40px の余白)の中に .panel(幅 1280px・白い背景・角丸 16px・内側 32px)がある作りで、動画の節だけがその枠に入っていませんでした。埋め込みの中身の背景が白だったので、角丸も余白もない白い帯に見えていました。
直し方は、動画の節もほかと同じ .sec > .panel に入れ、埋め込みの背景を透明にすることです。左右の余白は埋め込み側(32px)をやめて、枠(.panel)に任せます。
直したあとに、もう1つ指摘がありました。
動画セクションにスクロールバーが出ちゃってるのも修正

埋め込みの中のスライダーは、カードの影が切れないように、窓(.ptm-viewport)を左右 16px ずつ外へ広げる作りでした(margin: -8px -16px 0)。前は埋め込み自体に 32px の余白があったので、その中に収まっていました。余白を 0 にしたことで、窓が iframe の幅を 16px はみ出し、横スクロールバーが出て、その高さぶんの縦スクロールバーも出ていました。
直し方は、埋め込み側に左右 16px の余白を戻し、サイト側の枠を同じ 16px だけ外へ出す(.embed-wide { margin: 0 -16px })ことです。カードの端はほかの節の内容と揃い、影の分の余白も確保されます。念のため、埋め込みの html/body を overflow: hidden にしました。

| 項目 | 直す前 | 直した後 |
|---|---|---|
| 枠 | なし(白い帯) | .sec > .panel(ほかの節と同じ) |
| 上下の余白 | 0 | 40px |
| 角丸 | なし | 16px |
| 幅 | 1280px + 左右 32px | 1280px(枠の内側 32px) |
| 埋め込みの背景 | 白 | 透明 |
| 「チャンネルを見る」の上の余白 | 24px | 40px(「もっと見る」と同じ) |
| スクロールバー | 出る | 出ない |
エイイチの画面では直っていないように見えましたが、ブラウザのキャッシュでした。外から取り直すと新しい版が返っていて、⌘+Shift+R で表示も変わりました。
記事の番号は DAY から LOG に変えた。表示と本文は置き換え、ファイル名とコードの中は変えない
DAY を変更したい。もう1日に何度も投稿するようになってしまったため。LOG に変更したい。

記事の番号「DAY342」は、もともと1日1本の記録だった名残です。今は1日に何本も出すので、DAY が日数を表さなくなっていました。LOG に変え、番号は続きにしました(DAY342 の次は LOG343。この記事です)。
URL は変えていません。古い記事の /record/day1 のような URL は、外から貼られたリンクが切れるので、そのまま残します。新しい記事はすでに意味のある slug なので、URL に番号は出ません。
何を変えて何を変えないかを、先に決めました。
| 対象 | 扱い |
|---|---|
| 記事カード・記事の見出しの札(DAY342) | LOG342 に |
| 記事本文・説明文・目次・図解の中の呼び方(「前回(DAY340)で」) | LOG340 に |
| ファイル名(DAY342_01_….png、DAY340.md) | 変えない。R2 やリポジトリの実体と一致させる |
| コードブロック・実機ログの中 | 変えない。再現性の記録のため |
| 識別子(MM_PER_DEG_ELBOW_Z_DAY189) | 変えない |
| CMS 原本の表記ゆれ「DA13」 | LOG13 に |
| 英語版の「DAY187EN」 | LOG187EN に |
置き換えはスクリプトで行いました。見分けの決まりは正規表現1本です。
(?<![A-Za-z0-9_])DAY ?(\d{1,3})(EN)?(?![_\d]|\.[A-Za-z])
前に英数字や下線がない・後ろに下線や数字や「.拡張子」が続かない DAY+数字だけを対象にします。最初は前の境界を \b で書いていて、「後にDAY184で」のように日本語に続く DAY を拾えませんでした(Python の \b は日本語の文字も単語の一部とみなすので、「に」と「D」の間に境界がない)。前を「英数字と下線でない」に書き直して、拾えるようになりました。
HTML の本文では <pre> と <code> の中を飛ばし、Markdown では囲みコード・行内コード・【画像】【図解】の行を飛ばします。図解の HTML は <script>・<style>・タグの中を飛ばして、見える文字だけを置き換えました。
| 対象 | 変えたもの |
|---|---|
| 記事カードの一覧(cards.json) | 341件の札 |
| Studio から移した記事(JSON) | 札 341件、本文の呼び方 570か所 |
| Markdown の記事 | 5本・49か所 |
| 図解の HTML | 58本・140か所 |
| サムネイルのファイル名(public/thumbs/) | DAY{N}.html → LOG{N}.html(26本) |
一覧は番号の大きい順に並べた
あと記事は番号順に並べ直してください。
一覧は公開日時の新しい順でした。1日に複数出すと、公開した時刻の順に並び、番号と前後することがあります。LOG 番号の大きい順に変え、番号が同じか取れないもの(英語版など)だけ公開日時で並べるようにしました。
直す前の先頭は LOG339・LOG340・LOG342 の順で、直した後は LOG342・LOG340・LOG339・LOG338・… になりました。
記事の作り方の決まりを、Studio に貼る前提から、push すると公開される前提に書き換えた
これからはチャットに情報を出さないでもサイト側や、PC やクラウドにデータ情報があれば問題なくなります。
これまで article_rules.md には、記事・サムネイル・図解の HTML をチャットにも全文出力する決まりがありました。エイイチが Studio にコピペで貼るためです。サイトが GitHub から自動で作られるようになったので、この決まりは要らなくなりました。
変えた決まりは次のとおりです。
| 項目 | 前 | 後 |
|---|---|---|
| 公開 | Claude が出した本文を、エイイチが Studio に貼る | Claude が p2m-site に入れて push する(画像は Mac 連携で R2 へ) |
| チャットに出すもの | 記事・HTML・SNS 本文の全文 | 公開した URL と保存場所だけ |
| SNS の本文 | チャット末尾にベタ貼り | txt と json に保存。エイイチは iCloud の txt を開いて投稿する |
| 図解の埋め込み | Studio の embed ブロック | 記事の【図解】の行の位置にサイトが iframe で入れる |
| 番号 | DAY{NNN} | LOG{NNN}(ファイル名・画像フォルダも) |
公開の手順は、画像を R2 へ(tools/upload_media.py)→ 記事を p2m-site へ(tools/add_article.py)→ build が通ることを確かめて push → 公開された URL を開いて確かめる、の4段です。MASTER.md のデータ保管にも、公開サイトのコード(p2m-site)と R2(画像・動画・メッセージ)の行を足しました。
あわせて、X・Facebook・LinkedIn・YouTube・GitHub のプロフィールのリンク、YouTube に公開済みの動画の概要欄、動画の作業フォルダにある概要欄と投稿文のファイル(82ファイル・153か所)の URL を、pixelstomotors.com に書き換えました。プロフィールと YouTube の概要欄はエイイチ、ファイルは Claude が直しています。
できたものと、残っていること
できたもの:
- pixelstomotors.com を公開。仮の住所(workers.dev)は閉じた
- メッセージフォームの内容が pixelstomotors@adaptdesign.jp にメールで届く(R2 にも残る)
- adaptdesign.jp の DNS を Cloudflare に移し、旧 URL は記事まで届く形で pixelstomotors.com へ転送。メールは止めずに移せた
- Studio のサイトを非公開にし、プランを Free に下げた
- トップの動画の節をほかの節と同じ枠にし、スクロールバーを消した
- 記事の番号を DAY から LOG に。一覧は番号順
- article_rules.md と MASTER.md を、GitHub から自動公開する前提に書き換えた
- SNS のプロフィール・YouTube の概要欄・動画の作業ファイルの URL を新ドメインに
残っていること:
- アクセス解析を入れる(Cloudflare Web Analytics・Microsoft Clarity)。あわせてプライバシーポリシーを更新する
- 訪れた人の迷いを読み取って、サイトを少しずつ変えていく仕組み(LOG342 の構想)
- Cloudflare の Email Routing は今は送信にだけ使っている。受信の転送(form@ などへ来たメール)は決めていない
使ったコードの全文
リポジトリは GitHub の PixelstoMotors/p2m-site(非公開)です。この回に書いたか直したファイルの、直した後の全文です。
worker/index.js — サイトの入口。メッセージフォームの保存のあとにメールで知らせる notify() を足した。件名の符号化は改行なし、本文の base64 だけ 76 文字で折り返す
// サイトの入口。ほとんどのページは作っておいた静的ファイル(dist/)をそのまま返す。
// ここで処理するのは3つだけ:
// - /pixelstomotors/… 旧 URL の形。/pixelstomotors を外した URL へ 301 で転送する
// - /media/{ファイル名} 記事・4コマの画像と動画を R2(p2m-media)から返す(GitHub には入れないため)
// - /api/message メッセージフォームの送信を R2(p2m-messages)に保存し、メールで知らせる
import { EmailMessage } from 'cloudflare:email';
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' } });
// 保存できたら、メールで知らせる。メールが送れなくても、保存は済んでいるので送信者には成功と返す
try {
await notify(env, { at, name, email, 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 subject = `[Pixels to Motors] メッセージ: ${m.name}`;
const text = [
`名前: ${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' } });
}
wrangler.jsonc — Cloudflare Workers の設定。send_email の binding(MAIL)を足した。宛先は Email Routing で確認済みのアドレスだけ
{
// 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" }
],
// メッセージフォームに届いた内容をメールで知らせる(Cloudflare の Email Routing で確認済みの宛先にだけ送れる)
"send_email": [
{ "name": "MAIL", "destination_address": "pixelstomotors@adaptdesign.jp" }
]
}
src/lib/records.ts — 記事の一覧。札を LOG に、並び順を LOG 番号の大きい順に
import { getCollection } from 'astro:content';
import cards from '../data/cards.json';
import mangaCards from '../data/mangaCards.json';
import englishCards from '../data/englishCards.json';
export type Card = { day: string; series: string; published: string; title: string; slug: string; cover: string | null; thumb: string | null };
export type MangaCard = { slug: string; title: string; label: string; character: string; description: string; published: string; cover: string; comic: string; cover_alt: string; comic_alt: string };
// 一覧に並べる記事:Claude が書いた Markdown の記事 + Studio から移した記事
// 並び順は LOG 番号の大きい順(1日に何本も出すようになり、公開日時の順だと番号と前後することがあるため)。
// 番号が同じか取れないもの(英語版の LOG187EN など)は公開日時で並べる
export async function allCards(): Promise<Card[]> {
const md = (await getCollection('records')).map((e) => ({
day: `LOG${e.data.day}`, series: e.data.series, published: e.data.date.toISOString(),
title: e.data.title, slug: e.data.slug, cover: null, thumb: `/thumbs/LOG${e.data.day}.html`,
}));
const seen = new Set(md.map((c) => c.slug));
const studio = (cards as Card[]).filter((c) => !seen.has(c.slug));
const num = (c: Card) => { const m = /^LOG(\d+)/.exec(c.day); return m ? Number(m[1]) : -1; };
return [...md, ...studio].sort((a, b) => (num(b) - num(a)) || b.published.localeCompare(a.published));
}
export async function seriesCards(series: string): Promise<Card[]> {
return (await allCards()).filter((c) => c.series === series);
}
export const allManga = mangaCards as MangaCard[];
export const allEnglish = englishCards as MangaCard[];
// シリーズのページ(URL → 記事のシリーズ名)。Studio の一覧の絞り込み条件と同じ
export const SERIES_PAGES: Record<string, { series: string; title: string; pageTitle: string; limit: number }> = {
series1: { series: 'HRI', title: 'Drug Delivery Robot | 薬剤搬送ロボットハコブくん', pageTitle: 'Series1 | 薬剤搬送ロボット', limit: 12 },
series2: { series: 'R2R2R', title: 'R2R2R | デジタルツイン学習-R2R2R(Real to Render to Real)', pageTitle: 'Series2 | R2R2R', limit: 12 },
series3: { series: 'UV-C農業ロボット', title: 'UV-C Agricultural Robot | UV-C農業ロボット', pageTitle: 'Series3 | UV-C農業ロボット', limit: 20 },
series4: { series: 'ロボティクスデザイン', title: 'Robotics Design Workflow | ロボティクスデザイン実務フロー', pageTitle: 'Series4 | ロボティクスデザイン実務フロー', limit: 12 },
series5: { series: '考察記事', title: 'Deep Dive | 考察記事', pageTitle: 'Series5 | 考察記事', limit: 12 },
series6: { series: 'フィジカルAI研究室', title: 'Physical AI Lab | フィジカルAI研究室', pageTitle: 'Series6 | フィジカルAI研究室', limit: 12 },
series7: { series: '画像認識・物体検出', title: 'Image recognition and Object detection | 画像認識と物体検出', pageTitle: 'Series7 | 画像認識と物体検出', limit: 12 },
};
src/pages/index.astro — トップ。解説動画の節を .sec > .panel に入れ、埋め込みの枠を左右 16px 外へ出した
---
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="sec"><div class="panel">
<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>
</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 & 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; }
.embed-wide { margin: 0 -16px; } /* 埋め込み側の左右 16px の余白ぶん外へ出し、カードの端をほかの節と揃える */
.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)); }
}
@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; }
}
</style>
public/embeds/top-video.html — 解説動画の埋め込み。背景を透明に、左右 16px の余白、overflow: hidden
<!DOCTYPE html><html lang="ja"><head><meta charset="utf-8"></head><body>
<!--
Pixels to Motors | Video | 解説動画セクション(Studio 埋め込み用・スライダー版)
■ 動画の一覧は、DATA_URL の videos.json から読む。
videos.json は GitHub(PixelstoMotors/pixelstomotors-videos)の Actions が 6 時間ごとに
YouTube の新着から作り直す(ショートは除く)。
動画を上げたあと、このコードを貼り直す必要はない。
■ 読めないときは、下の FALLBACK_VIDEOS(貼った時点の一覧)を出す。
■ 埋め込みボックスの高さ:PC は 450px 前後(見出し+カード+操作の行+ボタン)。
-->
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="">
<link href="https://fonts.googleapis.com/css?display=swap&family=Inter:300,400,500,600|Noto+Sans+JP:300,400,500,700" rel="stylesheet">
<style>
/* サイト本体と同じ Google Fonts の読み込み(Inter・Noto Sans JP)。
Studio の埋め込みでは <head> の <link> が効かないことがあるため、<style> の中からも読み込む */
@import url("https://fonts.googleapis.com/css?display=swap&family=Inter:300,400,500,600|Noto+Sans+JP:300,400,500,700");
/* 背景はサイト側の白い枠(.panel)に任せ、ここは透明にする */
html, body { margin: 0; padding: 0; background: transparent; overflow: hidden; }
* { box-sizing: border-box; }
.ptm-video {
padding: 0 16px;
font-family: Inter, "Noto Sans JP", sans-serif;
color: #242424;
-webkit-font-smoothing: antialiased;
}
.ptm-video p { margin: 0; }
.ptm-video h2 { margin: 0 0 32px; font-size: 24px; font-weight: 600; line-height: 36px; }
/* スライダーの窓。影が切れないよう上下左右に余白を持たせ、同じだけ外側を引く */
.ptm-viewport { margin: -8px -16px 0; padding: 8px 16px 16px; overflow: hidden; }
/* 左右 16px は、影を切らずにスライダーの窓を外へ広げるための余白。サイト側の枠(.embed-wide)が同じ 16px だけ外へ出している */
.ptm-track {
display: flex;
gap: 32px;
transition: transform .6s cubic-bezier(.22, .61, .36, 1);
will-change: transform;
touch-action: pan-y;
}
.ptm-slide { flex: 0 0 100%; min-width: 0; }
.ptm-card {
display: flex;
background: #fff;
border-radius: 8px;
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.08);
overflow: hidden;
color: inherit;
text-decoration: none;
-webkit-user-drag: none;
}
.ptm-thumb { position: relative; flex: 0 0 432px; width: 432px; height: 243px; background: #141414; }
.ptm-thumb img { display: block; width: 100%; height: 100%; object-fit: cover; transition: opacity .2s ease; -webkit-user-drag: none; }
.ptm-card:hover .ptm-thumb img { opacity: .88; }
.ptm-dur {
position: absolute; right: 8px; bottom: 8px; padding: 2px 6px; border-radius: 4px;
background: rgba(0, 0, 0, 0.8); color: #fff; font-size: 12px; font-weight: 500; line-height: 16px;
}
.ptm-body { flex: 1; min-width: 0; padding: 24px; display: flex; flex-direction: column; gap: 12px; }
.ptm-meta { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 12px; }
.ptm-tag { padding: 2px 6px; border-radius: 2px; background: #EDF0F0; color: #888; font-size: 10px; font-weight: 500; line-height: 13px; }
.ptm-new { padding: 2px 6px; border-radius: 2px; background: #242424; color: #fff; font-size: 10px; font-weight: 600; line-height: 13px; letter-spacing: .06em; }
.ptm-date { color: #888; font-size: 10px; font-weight: 300; line-height: 13px; }
.ptm-title { font-size: 18px; font-weight: 600; line-height: 27px; }
.ptm-desc { color: #555; font-size: 14px; font-weight: 400; line-height: 24px; }
/* 操作の行:左に番号、中央に点、右に矢印 */
.ptm-ctrl { display: flex; align-items: center; gap: 16px; margin-top: 0; }
.ptm-count { min-width: 64px; color: #888; font-size: 12px; font-weight: 500; letter-spacing: .08em; font-variant-numeric: tabular-nums; }
.ptm-count b { color: #242424; font-weight: 600; }
.ptm-dots { flex: 1; display: flex; justify-content: center; gap: 8px; }
.ptm-dot {
width: 8px; height: 8px; padding: 0; border: 0; border-radius: 4px; background: #D6DADA; cursor: pointer;
transition: width .3s ease, background .3s ease;
}
.ptm-dot[aria-current="true"] { width: 28px; background: #242424; }
.ptm-arrows { display: flex; gap: 8px; }
.ptm-arrow, .ptm-dot { font-family: inherit; }
.ptm-arrow {
width: 40px; height: 40px; border-radius: 50%; border: 1px solid #D6DADA; background: #fff; color: #242424;
display: grid; place-items: center; cursor: pointer; transition: border-color .2s ease, background .2s ease;
}
.ptm-arrow:hover { border-color: #242424; }
.ptm-arrow:disabled { opacity: .35; cursor: default; border-color: #D6DADA; }
.ptm-arrow svg { width: 18px; height: 18px; }
.ptm-more { display: flex; justify-content: center; margin-top: 40px; }
.ptm-btn {
display: inline-flex; align-items: center; gap: 16px; min-height: 48px; padding: 8px 24px; border-radius: 4px;
background: #242424; color: #fff; font-size: 20px; font-weight: 500; line-height: 30px; text-decoration: none; transition: opacity .2s ease;
}
.ptm-btn:hover { opacity: .8; }
.ptm-btn svg { width: 20px; height: 20px; flex: none; }
@media (max-width: 923px) {
.ptm-thumb { flex-basis: 320px; width: 320px; height: 180px; }
.ptm-body { padding: 16px 20px; gap: 8px; }
.ptm-desc { display: none; }
}
@media (max-width: 663px) {
.ptm-thumb { flex-basis: 240px; width: 240px; height: 135px; }
.ptm-body { padding: 12px 16px; }
.ptm-title { font-size: 16px; line-height: 24px; }
}
@media (max-width: 507px) {
.ptm-slide { flex-basis: 100%; }
.ptm-card { flex-direction: column; max-width: 382px; }
.ptm-thumb { flex-basis: auto; width: 100%; height: auto; aspect-ratio: 16 / 9; }
.ptm-body { padding: 12px 16px 16px; gap: 8px; }
.ptm-title { font-size: 18px; line-height: 27px; }
.ptm-count { min-width: 48px; }
.ptm-arrow { width: 36px; height: 36px; }
}
@media (max-width: 540px) {
.ptm-video { padding: 0 16px; }
.ptm-btn { min-height: 42px; font-size: 16px; line-height: 26px; }
.ptm-btn svg { width: 16px; height: 16px; }
}
@media (prefers-reduced-motion: reduce) {
.ptm-track { transition: none; }
}
</style>
<section class="ptm-video" aria-roledescription="carousel" aria-label="解説動画">
<h2>Video | 解説動画</h2>
<div class="ptm-viewport">
<div class="ptm-track" id="ptm-track" style="transform: translateX(calc(-100% - 32px));"><div class="ptm-slide" role="group" aria-roledescription="slide" aria-label="1 / 6"><a class="ptm-card" href="https://www.youtube.com/watch?v=drHOxzvrcn8" target="_blank" rel="noopener" draggable="false" tabindex="-1"><div class="ptm-thumb"><img src="https://i.ytimg.com/vi/drHOxzvrcn8/maxresdefault.jpg" data-fallback="https://i.ytimg.com/vi/drHOxzvrcn8/hqdefault.jpg" alt="" width="1280" height="720" draggable="false"><span class="ptm-dur">13:36</span></div><div class="ptm-body"><div class="ptm-meta"><p class="ptm-new">NEW</p><p class="ptm-tag">フィジカルAI解説</p><p class="ptm-date">2026/10/15</p></div><p class="ptm-title">AIエージェントにロボットのコードを書かせたら、原因を7回取り違えた</p><p class="ptm-desc">エイイチは、本業のデザイナーをしながら、ロボティクスの研究と開発をしています。その中で分かったことと、そこから考えた見立てを、ハコブくんがお伝えします。</p></div></a></div><div class="ptm-slide" role="group" aria-roledescription="slide" aria-label="2 / 6"><a class="ptm-card" href="https://www.youtube.com/watch?v=48XTmtYkNRg" target="_blank" rel="noopener" draggable="false" tabindex="0"><div class="ptm-thumb"><img src="https://i.ytimg.com/vi/48XTmtYkNRg/maxresdefault.jpg" data-fallback="https://i.ytimg.com/vi/48XTmtYkNRg/hqdefault.jpg" alt="" width="1280" height="720" loading="lazy" draggable="false"><span class="ptm-dur">10:28</span></div><div class="ptm-body"><div class="ptm-meta"><p class="ptm-new">NEW</p><p class="ptm-tag">フィジカルAI解説</p><p class="ptm-date">2026/10/14</p></div><p class="ptm-title">ジャングリア沖縄の赤字89億円、AIとロボットで埋められるか勝手に試算してみた</p><p class="ptm-desc">エイイチは、本業のデザイナーをしながら、ロボティクスの研究と開発をしています。その中で分かったことと、そこから考えた見立てを、ハコブくんがお伝えします。</p></div></a></div><div class="ptm-slide" role="group" aria-roledescription="slide" aria-label="3 / 6"><a class="ptm-card" href="https://www.youtube.com/watch?v=wFg77gRg8RU" target="_blank" rel="noopener" draggable="false" tabindex="-1"><div class="ptm-thumb"><img src="https://i.ytimg.com/vi/wFg77gRg8RU/maxresdefault.jpg" data-fallback="https://i.ytimg.com/vi/wFg77gRg8RU/hqdefault.jpg" alt="" width="1280" height="720" loading="lazy" draggable="false"><span class="ptm-dur">14:17</span></div><div class="ptm-body"><div class="ptm-meta"><p class="ptm-new">NEW</p><p class="ptm-tag">フィジカルAI解説</p><p class="ptm-date">2026/10/14</p></div><p class="ptm-title">フィジカルAIのお金はどこへ流れているか|日本・米国・中国のスタートアップ投資</p><p class="ptm-desc">エイイチは、本業のデザイナーをしながら、ロボティクスの研究と開発をしています。その中で分かったことと、そこから考えた見立てを、ハコブくんがお伝えします。</p></div></a></div><div class="ptm-slide" role="group" aria-roledescription="slide" aria-label="4 / 6"><a class="ptm-card" href="https://www.youtube.com/watch?v=3lwqALyXiY8" target="_blank" rel="noopener" draggable="false" tabindex="-1"><div class="ptm-thumb"><img src="https://i.ytimg.com/vi/3lwqALyXiY8/maxresdefault.jpg" data-fallback="https://i.ytimg.com/vi/3lwqALyXiY8/hqdefault.jpg" alt="" width="1280" height="720" loading="lazy" draggable="false"><span class="ptm-dur">14:43</span></div><div class="ptm-body"><div class="ptm-meta"><p class="ptm-new">NEW</p><p class="ptm-tag">フィジカルAI解説</p><p class="ptm-date">2026/10/13</p></div><p class="ptm-title">自動運転は「暴走」するのか ― ロボタクシーのリコールの届け出に、ハッキングの記載はなかった</p><p class="ptm-desc">エイイチは、本業のデザイナーをしながら、ロボティクスの研究と開発をしています。その中で分かったことと、そこから考えた見立てを、ハコブくんがお伝えします。</p></div></a></div><div class="ptm-slide" role="group" aria-roledescription="slide" aria-label="5 / 6"><a class="ptm-card" href="https://www.youtube.com/watch?v=wGlD_LJrlPo" target="_blank" rel="noopener" draggable="false" tabindex="-1"><div class="ptm-thumb"><img src="https://i.ytimg.com/vi/wGlD_LJrlPo/maxresdefault.jpg" data-fallback="https://i.ytimg.com/vi/wGlD_LJrlPo/hqdefault.jpg" alt="" width="1280" height="720" loading="lazy" draggable="false"><span class="ptm-dur">12:30</span></div><div class="ptm-body"><div class="ptm-meta"><p class="ptm-new">NEW</p><p class="ptm-tag">フィジカルAIの現在地</p><p class="ptm-date">2026/10/12</p></div><p class="ptm-title">人型ロボットの出荷は4倍。ロボットにも「ChatGPTの瞬間」は来たのか</p><p class="ptm-desc">エイイチは、本業のデザイナーをしながら、ロボティクスの研究と開発をしています。その中で分かったことと、そこから考えた見立てを、ハコブくんがお伝えします。</p></div></a></div><div class="ptm-slide" role="group" aria-roledescription="slide" aria-label="6 / 6"><a class="ptm-card" href="https://www.youtube.com/watch?v=iaJLQYEg_vE" target="_blank" rel="noopener" draggable="false" tabindex="-1"><div class="ptm-thumb"><img src="https://i.ytimg.com/vi/iaJLQYEg_vE/maxresdefault.jpg" data-fallback="https://i.ytimg.com/vi/iaJLQYEg_vE/hqdefault.jpg" alt="" width="1280" height="720" loading="lazy" draggable="false"><span class="ptm-dur">2:54</span></div><div class="ptm-body"><div class="ptm-meta"><p class="ptm-tag">フィジカルAI解説</p><p class="ptm-date">2026/03/24</p></div><p class="ptm-title">R2R2Rプロジェクト - mechArm 270Pi 開封の儀</p><p class="ptm-desc">エイイチは、本業のデザイナーをしながら、ロボティクスの研究と開発をしています。その中で分かったことと、そこから考えた見立てを、ハコブくんがお伝えします。</p></div></a></div></div>
</div>
<div class="ptm-ctrl" id="ptm-ctrl">
<p class="ptm-count" id="ptm-count"><b>02</b> / 06</p>
<div class="ptm-dots" id="ptm-dots" role="tablist"><button class="ptm-dot" type="button" role="tab" aria-label="1本目へ" aria-current="false"></button><button class="ptm-dot" type="button" role="tab" aria-label="2本目へ" aria-current="true"></button><button class="ptm-dot" type="button" role="tab" aria-label="3本目へ" aria-current="false"></button><button class="ptm-dot" type="button" role="tab" aria-label="4本目へ" aria-current="false"></button><button class="ptm-dot" type="button" role="tab" aria-label="5本目へ" aria-current="false"></button><button class="ptm-dot" type="button" role="tab" aria-label="6本目へ" aria-current="false"></button></div>
<div class="ptm-arrows">
<button class="ptm-arrow" id="ptm-prev" type="button" aria-label="前の動画">
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M7.825 13H20v-2H7.825l5.6-5.6L12 4l-8 8 8 8 1.425-1.4-5.6-5.6Z"></path></svg>
</button>
<button class="ptm-arrow" id="ptm-next" type="button" aria-label="次の動画">
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M16.175 13H4v-2h12.175l-5.6-5.6L12 4l8 8-8 8-1.425-1.4 5.6-5.6Z"></path></svg>
</button>
</div>
</div>
<div class="ptm-more">
<a class="ptm-btn" href="https://www.youtube.com/@PixelstoMotors" target="_blank" rel="noopener">
<span>チャンネルを見る</span>
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M16.175 13H4v-2h12.175l-5.6-5.6L12 4l8 8-8 8-1.425-1.4 5.6-5.6Z"></path></svg>
</a>
</div>
</section>
<script>
(function () {
// GitHub Actions が更新する videos.json の URL(読めないときは FALLBACK_VIDEOS を使う)
var DATA_URL = "https://raw.githubusercontent.com/PixelstoMotors/pixelstomotors-videos/main/site/youtube/videos.json";
var AUTOPLAY_MS = 6000; // 自動で次へ送る間隔。0 にすると自動送りしない
var NEW_DAYS = 7; // 公開からこの日数以内は「NEW」を付ける
var FALLBACK_VIDEOS = [
{
"id": "drHOxzvrcn8",
"url": "https://www.youtube.com/watch?v=drHOxzvrcn8",
"thumb": "https://i.ytimg.com/vi/drHOxzvrcn8/maxresdefault.jpg",
"thumb_fallback": "https://i.ytimg.com/vi/drHOxzvrcn8/hqdefault.jpg",
"title": "AIエージェントにロボットのコードを書かせたら、原因を7回取り違えた",
"tag": "フィジカルAI解説",
"date": "2026/10/15",
"published": "2026-10-15T09:30:26+00:00",
"duration": "13:36",
"description": "エイイチは、本業のデザイナーをしながら、ロボティクスの研究と開発をしています。その中で分かったことと、そこから考えた見立てを、ハコブくんがお伝えします。",
"is_short": false
},
{
"id": "48XTmtYkNRg",
"url": "https://www.youtube.com/watch?v=48XTmtYkNRg",
"thumb": "https://i.ytimg.com/vi/48XTmtYkNRg/maxresdefault.jpg",
"thumb_fallback": "https://i.ytimg.com/vi/48XTmtYkNRg/hqdefault.jpg",
"title": "ジャングリア沖縄の赤字89億円、AIとロボットで埋められるか勝手に試算してみた",
"tag": "フィジカルAI解説",
"date": "2026/10/14",
"published": "2026-10-14T10:00:23+00:00",
"duration": "10:28",
"description": "エイイチは、本業のデザイナーをしながら、ロボティクスの研究と開発をしています。その中で分かったことと、そこから考えた見立てを、ハコブくんがお伝えします。",
"is_short": false
},
{
"id": "wFg77gRg8RU",
"url": "https://www.youtube.com/watch?v=wFg77gRg8RU",
"thumb": "https://i.ytimg.com/vi/wFg77gRg8RU/maxresdefault.jpg",
"thumb_fallback": "https://i.ytimg.com/vi/wFg77gRg8RU/hqdefault.jpg",
"title": "フィジカルAIのお金はどこへ流れているか|日本・米国・中国のスタートアップ投資",
"tag": "フィジカルAI解説",
"date": "2026/10/14",
"published": "2026-10-13T23:45:17+00:00",
"duration": "14:17",
"description": "エイイチは、本業のデザイナーをしながら、ロボティクスの研究と開発をしています。その中で分かったことと、そこから考えた見立てを、ハコブくんがお伝えします。",
"is_short": false
},
{
"id": "3lwqALyXiY8",
"url": "https://www.youtube.com/watch?v=3lwqALyXiY8",
"thumb": "https://i.ytimg.com/vi/3lwqALyXiY8/maxresdefault.jpg",
"thumb_fallback": "https://i.ytimg.com/vi/3lwqALyXiY8/hqdefault.jpg",
"title": "自動運転は「暴走」するのか ― ロボタクシーのリコールの届け出に、ハッキングの記載はなかった",
"tag": "フィジカルAI解説",
"date": "2026/10/13",
"published": "2026-10-13T10:30:23+00:00",
"duration": "14:43",
"description": "エイイチは、本業のデザイナーをしながら、ロボティクスの研究と開発をしています。その中で分かったことと、そこから考えた見立てを、ハコブくんがお伝えします。",
"is_short": false
},
{
"id": "wGlD_LJrlPo",
"url": "https://www.youtube.com/watch?v=wGlD_LJrlPo",
"thumb": "https://i.ytimg.com/vi/wGlD_LJrlPo/maxresdefault.jpg",
"thumb_fallback": "https://i.ytimg.com/vi/wGlD_LJrlPo/hqdefault.jpg",
"title": "人型ロボットの出荷は4倍。ロボットにも「ChatGPTの瞬間」は来たのか",
"tag": "フィジカルAIの現在地",
"date": "2026/10/12",
"published": "2026-10-12T07:37:22+00:00",
"duration": "12:30",
"description": "エイイチは、本業のデザイナーをしながら、ロボティクスの研究と開発をしています。その中で分かったことと、そこから考えた見立てを、ハコブくんがお伝えします。",
"is_short": false
},
{
"id": "iaJLQYEg_vE",
"url": "https://www.youtube.com/watch?v=iaJLQYEg_vE",
"thumb": "https://i.ytimg.com/vi/iaJLQYEg_vE/maxresdefault.jpg",
"thumb_fallback": "https://i.ytimg.com/vi/iaJLQYEg_vE/hqdefault.jpg",
"title": "R2R2Rプロジェクト - mechArm 270Pi 開封の儀",
"tag": "フィジカルAI解説",
"date": "2026/03/24",
"published": "2026-03-24T11:34:32+00:00",
"duration": "2:54",
"description": "エイイチは、本業のデザイナーをしながら、ロボティクスの研究と開発をしています。その中で分かったことと、そこから考えた見立てを、ハコブくんがお伝えします。",
"is_short": false
}
];
var track = document.getElementById("ptm-track");
var dotsEl = document.getElementById("ptm-dots");
var countEl = document.getElementById("ptm-count");
var prev = document.getElementById("ptm-prev");
var next = document.getElementById("ptm-next");
var ctrl = document.getElementById("ptm-ctrl");
var videos = [], index = 0, timer = null, paused = false;
function esc(s) {
return String(s == null ? "" : s).replace(/[&<>"']/g, function (c) {
return { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c];
});
}
function isNew(v) {
var t = Date.parse(v.published);
return !isNaN(t) && (Date.now() - t) < NEW_DAYS * 86400000;
}
function pad(n) { return (n < 10 ? "0" : "") + n; }
function render(list) {
videos = list.filter(function (v) { return v && v.id && !v.is_short; });
if (!videos.length) return;
track.innerHTML = videos.map(function (v, i) {
return '<div class="ptm-slide" role="group" aria-roledescription="slide" aria-label="' + (i + 1) + ' / ' + videos.length + '">' +
'<a class="ptm-card" href="' + esc(v.url) + '" target="_blank" rel="noopener" draggable="false">' +
'<div class="ptm-thumb">' +
'<img src="' + esc(v.thumb) + '" data-fallback="' + esc(v.thumb_fallback || "") + '" alt="" width="1280" height="720" ' + (i ? 'loading="lazy"' : '') + ' draggable="false">' +
(v.duration ? '<span class="ptm-dur">' + esc(v.duration) + '</span>' : '') +
'</div>' +
'<div class="ptm-body">' +
'<div class="ptm-meta">' +
(isNew(v) ? '<p class="ptm-new">NEW</p>' : '') +
(v.tag ? '<p class="ptm-tag">' + esc(v.tag) + '</p>' : '') +
'<p class="ptm-date">' + esc(v.date) + '</p>' +
'</div>' +
'<p class="ptm-title">' + esc(v.title) + '</p>' +
(v.description ? '<p class="ptm-desc">' + esc(v.description) + '</p>' : '') +
'</div>' +
'</a></div>';
}).join("");
// maxresdefault が無い動画は hqdefault に切り替える(YouTube は無いとき 120px の灰色画像を返す)
Array.prototype.forEach.call(track.querySelectorAll("img"), function (img) {
function check() {
if (img.naturalWidth && img.naturalWidth < 200 && img.dataset.fallback) {
img.src = img.dataset.fallback; img.dataset.fallback = "";
}
}
img.addEventListener("load", check);
img.addEventListener("error", function () { if (img.dataset.fallback) { img.src = img.dataset.fallback; img.dataset.fallback = ""; } });
if (img.complete) check();
});
dotsEl.innerHTML = videos.map(function (v, i) {
return '<button class="ptm-dot" type="button" role="tab" aria-label="' + (i + 1) + '本目へ"></button>';
}).join("");
Array.prototype.forEach.call(dotsEl.children, function (b, i) {
b.addEventListener("click", function () { go(i); restart(); });
});
ctrl.style.display = videos.length > 1 ? "" : "none";
index = 0;
go(0, true);
restart();
}
function go(i, instant) {
if (!videos.length) return;
index = (i + videos.length) % videos.length;
if (instant) { track.style.transition = "none"; }
track.style.transform = "translateX(calc(" + (-index * 100) + "% - " + (index * 32) + "px))";
if (instant) { track.offsetHeight; track.style.transition = ""; }
Array.prototype.forEach.call(dotsEl.children, function (b, k) { b.setAttribute("aria-current", k === index ? "true" : "false"); });
countEl.innerHTML = "<b>" + pad(index + 1) + "</b> / " + pad(videos.length);
Array.prototype.forEach.call(track.querySelectorAll(".ptm-card"), function (a, k) { a.tabIndex = k === index ? 0 : -1; });
}
function restart() {
if (timer) clearInterval(timer);
if (!AUTOPLAY_MS || videos.length < 2) return;
if (window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
timer = setInterval(function () { if (!paused && !document.hidden) go(index + 1); }, AUTOPLAY_MS);
}
prev.addEventListener("click", function () { go(index - 1); restart(); });
next.addEventListener("click", function () { go(index + 1); restart(); });
var section = document.querySelector(".ptm-video");
section.addEventListener("mouseenter", function () { paused = true; });
section.addEventListener("mouseleave", function () { paused = false; });
section.addEventListener("focusin", function () { paused = true; });
section.addEventListener("focusout", function () { paused = false; });
section.addEventListener("keydown", function (e) {
if (e.key === "ArrowLeft") { go(index - 1); restart(); }
if (e.key === "ArrowRight") { go(index + 1); restart(); }
});
// スワイプ(指・マウスのドラッグ)。少しだけ動かしたときはリンクのクリックとして扱う
var startX = null, startY = 0, dx = 0, dragging = false, moved = false;
track.addEventListener("pointerdown", function (e) {
startX = e.clientX; startY = e.clientY; dx = 0; dragging = false; moved = false;
});
window.addEventListener("pointermove", function (e) {
if (startX === null) return;
dx = e.clientX - startX;
if (!dragging && Math.abs(dx) > 8 && Math.abs(dx) > Math.abs(e.clientY - startY)) {
dragging = true; paused = true; track.style.transition = "none";
}
if (dragging) {
moved = true;
track.style.transform = "translateX(calc(" + (-index * 100) + "% - " + (index * 32) + "px + " + dx + "px))";
}
});
window.addEventListener("pointerup", function () {
if (startX === null) return;
track.style.transition = "";
if (dragging) {
var w = track.parentNode.clientWidth;
if (dx < -w * 0.15) go(index + 1); else if (dx > w * 0.15) go(index - 1); else go(index);
restart();
}
startX = null; dragging = false; paused = false;
});
track.addEventListener("click", function (e) { if (moved) { e.preventDefault(); moved = false; } }, true);
render(FALLBACK_VIDEOS);
if (DATA_URL) {
fetch(DATA_URL + (DATA_URL.indexOf("?") < 0 ? "?" : "&") + "t=" + Math.floor(Date.now() / 600000), { cache: "no-store" })
.then(function (r) { if (!r.ok) throw new Error(r.status); return r.json(); })
.then(function (d) { if (d && d.videos && d.videos.length) render(d.videos); })
.catch(function () { /* 読めなければ FALLBACK_VIDEOS のまま */ });
}
})();
</script>
<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></body></html>
tools/add_article.py — 記事を入れる道具。LOG{N}_article.md・LOG{N}_images/ を読むように
"""記事の作業フォルダ(iCloud)から、記事1本をサイトに入れる。
使い方:
python3 tools/add_article.py <作業フォルダ> <LOG番号> <シリーズ名>
やること:
- LOG{N}_article.md → src/content/records/LOG{N}.md(先頭の項目に series を足す)
- LOG{N}_thumbnail.html → public/thumbs/LOG{N}.html
- 本文の【図解】の HTML → public/embeds/(図解の高さを親ページに知らせる仕組みを足す)
- 本文の【画像】の画像 → あるかを確かめるだけ。画像は GitHub に入れず、Mac で tools/upload_media.py を使って R2 に入れる
"""
import re, shutil, sys
from pathlib import Path
work, day, series = Path(sys.argv[1]), sys.argv[2], sys.argv[3]
site = Path(__file__).resolve().parent.parent
md_src = work / f"LOG{day}_article.md"
text = md_src.read_text(encoding="utf-8")
# 先頭の項目(---で囲まれた部分)に series を入れる
fm_end = text.index("\n---", 3)
fm, body = text[:fm_end], text[fm_end:]
if re.search(r"^series:", fm, flags=re.M):
fm = re.sub(r"^series:.*$", f'series: "{series}"', fm, flags=re.M)
else:
fm += f'\nseries: "{series}"'
out = site / "src/content/records" / f"LOG{day}.md"
out.parent.mkdir(parents=True, exist_ok=True)
out.write_text(fm + body, encoding="utf-8")
reporter = re.search(r'HEIGHT_REPORTER = """(.*?)"""',
(site / "tools/import_all.py").read_text(encoding="utf-8"), re.S).group(1)
thumb = work / f"LOG{day}_thumbnail.html"
if thumb.exists():
(site / "public/thumbs").mkdir(parents=True, exist_ok=True)
shutil.copy2(thumb, site / "public/thumbs" / f"LOG{day}.html")
for name in re.findall(r"^【図解】\s*(\S+\.html)\s*$", body, flags=re.M):
doc = (work / name).read_text(encoding="utf-8")
doc = doc.replace("</body>", reporter + "</body>", 1) if "</body>" in doc else doc + reporter
(site / "public/embeds" / name).write_text(doc, encoding="utf-8")
missing = []
for name in re.findall(r"^【画像】\s*(\S+)\s*$", body, flags=re.M):
src = work / f"LOG{day}_images" / name
if not src.exists():
missing.append(name)
print(f"LOG{day}: 記事 {out.name}, シリーズ {series}" + (f", 見つからない画像 {missing}" if missing else ""))
if missing:
sys.exit(1)
DAY を LOG に置き換えたスクリプト(cards.json・記事の JSON・Markdown)
import json,re,glob,os,subprocess
# 「DAY123」「DAY 123」「DAY123EN」を LOG に。ファイル名(DAY123_…・DAY123.md など)とコードの中は変えない
LABEL=re.compile(r'(?<![A-Za-z0-9_])DAY ?(\d{1,3})(EN)?(?![_\d]|\.[A-Za-z])')
def sub_text(s): return LABEL.sub(lambda m:'LOG'+m.group(1)+(m.group(2) or ''),s)
# HTML: <pre>…</pre> と <code>…</code> の中は触らない
SPLIT_HTML=re.compile(r'(<pre\b.*?</pre>|<code\b.*?</code>)',re.S)
def sub_html(s):
return ''.join(p if SPLIT_HTML.fullmatch(p) else sub_text(p) for p in SPLIT_HTML.split(s))
stats={}
# 1) cards.json
p='src/data/cards.json'; cards=json.load(open(p,encoding='utf-8'))
for c in cards:
if c['day']=='DA13': c['day']='DAY13'
c['day']=sub_text(c['day'])
open(p,'w',encoding='utf-8').write('[\n'+',\n'.join(json.dumps(c,ensure_ascii=False,indent=0) for c in cards)+'\n]\n'); stats['cards']=len(cards)
# 2) record json
n=0;m=0
for f in sorted(glob.glob('src/data/records/*.json')):
d=json.load(open(f,encoding='utf-8')); before=json.dumps(d,ensure_ascii=False)
if d.get('day')=='DA13': d['day']='DAY13'
d['day']=sub_text(d['day'])
for k in ('title','description'):
if isinstance(d.get(k),str): d[k]=sub_text(d[k])
if isinstance(d.get('toc'),list):
for t in d['toc']:
if isinstance(t.get('text'),str): t['text']=sub_text(t['text'])
if isinstance(d.get('body'),str):
nb=sub_html(d['body']); m+=len(LABEL.findall(d['body']))-len(LABEL.findall(nb)); d['body']=nb
if json.dumps(d,ensure_ascii=False)!=before:
open(f,'w',encoding='utf-8').write(json.dumps(d,ensure_ascii=False)); n+=1
stats['json_changed']=n; stats['json_body_replaced']=m
# 3) markdown: frontmatter はそのまま。本文は、囲みコード・行内コード・【画像】【図解】の行を除いて置き換える
INLINE=re.compile(r'(`[^`\n]*`)')
def sub_md(src):
fm_end=src.find('\n---',3); fm,body=src[:fm_end+4],src[fm_end+4:]
out=[];fence=None;cnt=0
for line in body.split('\n'):
s=line.strip()
fm_=re.match(r'^(`{3,}|~{3,})',s)
if fm_:
if fence is None: fence=fm_.group(1)
elif s.startswith(fence[0]) and len(fm_.group(1))>=len(fence): fence=None
out.append(line); continue
if fence or s.startswith('【画像】') or s.startswith('【図解】'): out.append(line); continue
new=''.join(p if INLINE.fullmatch(p) else sub_text(p) for p in INLINE.split(line))
cnt+=len(LABEL.findall(line))-len(LABEL.findall(new)); out.append(new)
# title in frontmatter
fm2=re.sub(r'^(title:.*)$',lambda mm:sub_text(mm.group(1)),fm,flags=re.M)
fm2=re.sub(r'^(meta_description:.*)$',lambda mm:sub_text(mm.group(1)),fm2,flags=re.M)
return fm2+'\n'.join(out),cnt
k=0;mdn=0
for f in sorted(glob.glob('src/content/records/*.md')):
src=open(f,encoding='utf-8').read(); new,c=sub_md(src)
if new!=src: open(f,'w',encoding='utf-8').write(new); mdn+=1; k+=c
stats['md_changed']=mdn; stats['md_replaced']=k
print(stats)
出力(1回目は \b の境界で、2回目は前を「英数字と下線でない」に直して実行):
{'cards': 341, 'json_changed': 341, 'json_body_replaced': 530, 'md_changed': 5, 'md_replaced': 49}
{'cards': 341, 'json_changed': 24, 'json_body_replaced': 40, 'md_changed': 0, 'md_replaced': 0}
図解の HTML の見える文字だけを置き換えたスクリプト
import re,glob
LABEL=re.compile(r'(?<![A-Za-z0-9_])DAY ?(\d{1,3})(EN)?(?![_\d]|\.[A-Za-z])')
SKIP=re.compile(r'(<script\b.*?</script>|<style\b.*?</style>|<pre\b.*?</pre>|<code\b.*?</code>|<!--.*?-->|<[^>]+>)',re.S)
n=0;files=0
for f in sorted(glob.glob('public/embeds/*.html')):
t=open(f,encoding='utf-8').read()
parts=SKIP.split(t); out=[];k=0
for p in parts:
if p and SKIP.fullmatch(p): out.append(p)
else:
new=LABEL.sub(lambda m:'LOG'+m.group(1)+(m.group(2) or ''),p); k+=len(LABEL.findall(p)); out.append(new)
if k: open(f,'w',encoding='utf-8').write(''.join(out)); n+=k; files+=1
print('figure files changed',files,'labels',n)
出力:
figure files changed 58 labels 140
作ったページの中に、見える DAY の札が残っていないかを確かめたスクリプト
import re,glob
SPLIT=re.compile(r'(<pre\b.*?</pre>|<code\b.*?</code>|<script\b.*?</script>|<iframe\b[^>]*>)',re.S)
n=0
for f in glob.glob('dist/**/*.html',recursive=True):
t=open(f,encoding='utf-8').read()
for part in SPLIT.split(t):
if SPLIT.fullmatch(part): continue
n+=len(re.findall(r'(?<![A-Za-z0-9_])DAY ?\d{1,3}(?![_\d]|\.[A-Za-z])',part))
print('visible DAY labels left:',n)
出力(1回目は \b の版のあと、2回目は図解まで直したあと):
visible DAY labels left: 152
visible DAY labels left: 0
adaptdesign.jp の DNS を外から引いた確認(DNS over HTTPS)
import json,urllib.request
def q(n,t,ep='https://cloudflare-dns.com/dns-query'):
r=json.load(urllib.request.urlopen(urllib.request.Request(f'{ep}?name={n}&type={t}',headers={'accept':'application/dns-json'})))
return [a['data'] for a in r.get('Answer',[])]
for t in ['NS','A','MX','TXT','DS']:
print(t,q('adaptdesign.jp',t))
移す前:
NS ['ns-cloud-c1.googledomains.com.', 'ns-cloud-c2.googledomains.com.', 'ns-cloud-c3.googledomains.com.', 'ns-cloud-c4.googledomains.com.']
A ['35.194.122.208']
MX ['1 aspmx.l.google.com.', '5 alt1.aspmx.l.google.com.', '5 alt2.aspmx.l.google.com.', '10 alt3.aspmx.l.google.com.', '10 alt4.aspmx.l.google.com.']
DS ['10579 8 2 14ed8129cd8a4afdac8af195dc5ca4ea773205b0a6da7b2391c292d4641d61ff']
DNSSEC をオフにした直後(20:16)と 21:02:
DS records: 1
DS []
NS ['ns-cloud-c1.googledomains.com.', 'ns-cloud-c3.googledomains.com.', 'ns-cloud-c2.googledomains.com.', 'ns-cloud-c4.googledomains.com.']
ネームサーバーを変えたあと(21:07 と 21:30):
NS (cloudflare resolver): ['ns-cloud-c4.googledomains.com.', 'ns-cloud-c3.googledomains.com.', 'ns-cloud-c1.googledomains.com.', 'ns-cloud-c2.googledomains.com.']
NS (google resolver): ['ns-cloud-c2.googledomains.com.', 'ns-cloud-c4.googledomains.com.', 'ns-cloud-c1.googledomains.com.', 'ns-cloud-c3.googledomains.com.']
NS: ['courtney.ns.cloudflare.com.', 'lennon.ns.cloudflare.com.']
転送の確認(curl)
for u in "https://adaptdesign.jp/pixelstomotors/record/day1" "https://adaptdesign.jp/pixelstomotors" "https://adaptdesign.jp/" "https://adaptdesign.jp/web_production_list" "http://adaptdesign.jp/pixelstomotors/manga/第1話?x=1"; do printf "%s\n -> " "$u"; curl -s -o /dev/null -w '%{http_code} %{redirect_url}\n' --max-time 20 "$u"; done
curl -sL -o /dev/null -w 'final: %{http_code} %{url_effective}\n' --max-time 30 "https://adaptdesign.jp/pixelstomotors/record/day1"
https://adaptdesign.jp/pixelstomotors/record/day1
-> 301 https://pixelstomotors.com/record/day1
https://adaptdesign.jp/pixelstomotors
-> 301 https://pixelstomotors.com/
https://adaptdesign.jp/
-> 301 https://pixelstomotors.com/
https://adaptdesign.jp/web_production_list
-> 301 https://pixelstomotors.com/
http://adaptdesign.jp/pixelstomotors/manga/第1話?x=1
-> 301 https://adaptdesign.jp/pixelstomotors/manga/%e7%ac%ac1%e8%a9%b1?x=1
final: 200 https://pixelstomotors.com/record/day1
MX 5 ['1 aspmx.l.google.com.', '5 alt1.aspmx.l.google.com.']
TXT 3 ['google-site-verification=HYGb4KfK-ISlVJO', 'brave-ledger-verification=8b758d8894b9ab']
Studio を非公開にした直後(古い DNS キャッシュ経由の 404 が混じる):
HTTP/2 301 location: https://pixelstomotors.com/record/day1 cf-ray: a4b722abbe3aa9dd-IAD
HTTP/2 404
HTTP/2 301 location: https://pixelstomotors.com/record/day1 cf-ray: a4b722b1dfa2d6c9-IAD
公開直後の確認と、通知メールの試験
for u in https://pixelstomotors.com/ https://p2m-site.pixelstomotors.workers.dev/ https://pixelstomotors.com/record/day1; do printf "%s " "$u"; curl -s -o /dev/null -w '%{http_code}\n' --max-time 20 "$u"; done
curl -s --max-time 20 -X POST https://pixelstomotors.com/api/message -H 'content-type: application/json' -d '{"name":"通知テスト2(Claude)","email":"studio@adaptdesign.jp","body":"件名を直したあとの試験です。件名が日本語で読めていれば成功です。","agree":true}'
https://pixelstomotors.com/ 200
https://p2m-site.pixelstomotors.workers.dev/ 404
https://pixelstomotors.com/record/day1 200
{"ok":true}
件名の符号化が1行に収まることの確認
const base64=(s,fold=true)=>{const by=new TextEncoder().encode(s);let b='';for(const c of by)b+=String.fromCharCode(c);const o=btoa(b);return fold?o.replace(/(.{76})/g,'$1\r\n'):o;};
const h='=?utf-8?B?'+base64('[Pixels to Motors] メッセージ: 通知テスト(Claude)長い名前でも一行に収まるか確かめる',false)+'?=';
console.log(h.includes('\r\n')?'NG':'OK one line', h.length);
OK one line 164
