---
title: "4コママンガ『ハコブくん』の英語版は描き直さずに作れた ― 次は英語圏の X へ"
slug: hakobu-4koma-english-without-redrawing
date: 2026-10-31
day: 416
meta_description: "4コママンガ『ハコブくん』の英語版は、絵を描き直さずに吹き出しの日本語を消して英語を組み直し、縦長の29話を0円で公開した。英語圏の X への定期投稿125本も予約した。"
tags: [4コママンガ, ハコブくん, 英語版, 縦長マンガ, X, X API, Claude, AI活用]
ai_consult: true
series: "ひとり研究所の裏側"
---

**4コママンガ『ハコブくん』の英語版は、絵を描き直さずに、吹き出しの日本語を消して英語をフォントで組み直す方法で作り、縦長の29話（116コマ）を費用0円で pixelstomotors.com に載せました。** 絵は日本語版と1画素も変えていません。あわせて、英語版の全125話を、英語圏の読み手に向けて X に平日1日2本ずつ出す予約をしました。

『ハコブくん』は、病院で薬を運ぶロボット・ハコブくんと、看護師さんたちの4コママンガです。ロボットが現場に入ったときに起きるすれ違いを、毎話1つ描いています。最近の話は、1コマを縦長（縦横比 4:5）の1枚の絵として、画像を作る AI（Google の Gemini）で描いています。この描き方では、吹き出しの日本語も絵の一部として描かれます。英語版を作るには、絵を英語で描き直すか、絵の中の日本語だけを入れ替えるかのどちらかになります。

描き直すと、1コマずつお金がかかり、人の顔や手の形も日本語版と少しずつ変わってしまいます。そこで、吹き出しの中だけを白で塗って英語を置く小さなプログラムを書きました。吹き出しの外の画素が1つでも変わったら、保存せずに止まる作りです。名札や画面の日本語は、まわりの色で消して英語を置きました。英語の字は、これまでの英語版のマンガと同じ Comic Neue という手書き風のフォントにそろえました。

X には、リンクを付けずに4コマの絵だけを載せて出すことにしました。**X の API（プログラムから投稿する仕組み）は、リンクの入った投稿が1本0.20ドル、リンクのない投稿が0.015ドルで、リンクを付けたままだと日本語の投稿と合わせて月に約35ドルかかるためです。** リンクを外すと月に約3ドルになります。いいねやリポストが増えてきたら、リンク付きに戻すかを決めます。

やったことの全体は次のとおりです。

| やったこと | なぜ | 結果 |
|---|---|---|
| 第133話で、吹き出しの日本語を消して英語を組む方法を試した | 描き直さずに英語版を作れるかを確かめる | 4コマと表紙ができ、吹き出しの外で変わった画素は0 |
| 英語のフォントを、今までの英語版に合わせた | 最初の版のフォントが英語圏のマンガに見えなかった | 吹き出しは Comic Neue、表紙は Roboto Mono にした |
| 残りの28話を、同じプログラムで作った | 縦長の話を全部英語にする | 139個の吹き出しと24個の名札・画面の字を英語にした |
| 作った人とは別の目で、1コマずつ日本語版と見比べた | 消し残し・はみ出し・取り違えを見つける | 28話のうち22話で、改行や言い方・表紙の一言を直した |
| マンガのページに「一覧へ戻る」リンクを足した | 英語版のページから一覧へ戻る道がなかった | 日本語は「4コママンガ一覧」、英語は「All episodes」 |
| 英語版の全125話を、英語圏の X に出す予約をした | 英語で読む人に届ける | 2026-11-02〜2027-01-27 の平日、米国東部時間 11:00 と 15:00 |
| X の投稿のリンクを外した | リンク入りの投稿の費用が高い | 月に約35ドルの見込みが、約3ドルになった |
| X に画像を上げられるかを確かめた | 英語の投稿は画像を上げてから出す | 鍵・画像のアップロードとも OK（投稿はしていない） |

ここから下は作業ログです。使ったコード・指示・確かめた記録は、あとで同じ手順を追えるように全文を載せています。効果の欄は、数字があるものは測った値、ないものは見込みです。


## 作業ログ ― 英語版を、描き直さずに作る方法を決めた

### 縦長の4コマは、日本語の字が絵の中に入っている

縦長の4コマは、1コマずつ Gemini の API に「この場面を、このセリフで描いて」と頼んで描いています（作り方は LOG410・LOG413）。セリフは、吹き出しの中の字として絵の中に描かれます。日本語の字だけの層があるわけではないので、字を差し替えるには、絵の中の字そのものを消す必要があります。

英語版の作り方は、3つ考えました。

| 作り方 | 費用 | 絵 | 決めたこと |
|---|---|---|---|
| Gemini に、英語のセリフで描き直してもらう | 1コマ約10円（描き直しを含めると増える） | 顔・手・小物が日本語版と少しずつ変わる | 使わない |
| Gemini に、字だけ英語に書き換えてもらう | 1コマ約10円 | 字のまわりの絵が描き変わることがある | 使わない |
| 吹き出しの中を白で塗り、英語をフォントで組む | 0円 | 吹き出しの外は1画素も変えない | これにした |

3つ目は、プログラムで吹き出しの範囲を見つけて、その内側だけを塗るやり方です。画像を作る AI を使わないので、お金はかかりません。そのかわり、吹き出しの形や大きさに合わせて英語の改行と字の大きさを決める必要があります。

- ねらい：日本語版と同じ絵のまま、英語で読めるようにする
- 変わったこと：英語版を作るのに、画像を作る AI を使わなくなった
- 見込める効果：英語版の費用は0円。絵が日本語版と同じなので、描き直したコマの間違いを確かめ直す手間がない（吹き出しの外で変わった画素は、全116コマで0だった）

### 吹き出しの見つけ方：白い1点から塗り広げる

吹き出しの中の白い1点（seed）から、同じくらい白い画素を上下左右に塗り広げていくと、吹き出しの縁の線で止まります。止まった範囲が吹き出しです。画像を編集するソフトの「塗りつぶし」と同じ考え方です。

うまくいかない形が2つありました。1つは、ささやき声などで縁が破線の吹き出しです。破線のすき間から塗りが外へ漏れます。この場合は、縁の黒い線を数画素太らせてすき間を閉じてから塗ります。もう1つは、2人の吹き出しが線でつながって1つの白い領域になっている場合です。この場合は、それぞれの吹き出しの範囲を四角（clip）で指定して、その中に英語を組みます。

見つけた範囲は、内側へ6画素縮めてから白で塗ります。縁の線とその際の灰色の画素を残すためです。英語は44画素の大きさから1画素ずつ小さくして、どの行も吹き出しの幅に収まる大きさを探します。

【図解】LOG416_bubble_swap.html

### 吹き出しの外で1画素でも変わったら止める

塗る範囲を間違えると、人の顔やロボットの体を消してしまいます。そこで、日本語版と英語版の画素を1つずつ比べ、吹き出しと名札などの字の四角の外で1画素でも変わっていたら、保存せずに止まるようにしました。作っている途中で、名札の英語が字の四角から少しはみ出し、ここで止まったことがあります。英語は四角の内側にだけ描くように直しています。第133話では、ホワイトボードの見出しの欄を丸ごと塗ると、すぐ下の人の頭まで消えてしまうことが分かり、見出しの字のかたまりだけを消すようにしました。

名札・画面・張り紙の字は、吹き出しと違って地が白とは限りません。四角の中の画素の明るさを見て、明るい地なら明るいほうの色、黒い板に白い字なら暗いほうの色で塗り、その上に英語を置きます。英語が四角の外にはみ出しそうなときは、四角の内側だけに描きます。


## 作業ログ ― 第133話で試し、フォントを今までの英語版に合わせた

### 最初の版は、英語圏のマンガに見えなかった

第133話「ハンコがそろうまでの2か月」の4コマで、最初の版を作りました。吹き出しの字は、最初はふつうの文章用のフォントで組みました。スマホで見たエイイチの感想は次のとおりです。

> フォントはいわゆる英語圏で使われるフォントにしてほしい。今までの英語版のマンガ参考にして。確認してみて。

pixelstomotors.com には、横長の4コマ（1枚の絵に4コマ）の英語版が、すでに96話あります。そのページの画像を開いて確かめると、吹き出しの字は手書き風のフォントでした。形が近いフォントを探し、[Comic Neue](https://comicneue.com/) の太字に決めました。Comic Neue は、マンガの吹き出しによく使われる Comic Sans を、整えて描き直したフリーのフォントです。

### 表紙の一言は、別のフォントだった

英語版の一覧のページに並ぶ表紙は、上に「EPISODE N」、その下に大きな大文字の一言があり、その下に絵がある形です。最初は、この表紙の一言も Comic Neue で組みました。エイイチから次の指摘がありました。

> 表紙のフォントは違うからね！注意して！

今までの表紙の画像を拡大して字の形を比べると、すべての字の幅が同じ等幅のフォントで、[Roboto Mono](https://fonts.google.com/specimen/Roboto+Mono) の太字と形が一致しました。字の間隔もわずかに詰めてあったので、その詰め方まで合わせました。一言が長いときは、2行（字が小さくなるときは3行）に収まるまで字を小さくします。

【画像】LOG416_02_en_covers.jpg 英語版の表紙6枚（第132・131・130・129・127・118話）。どれも上に EPISODE と話の番号、その下に Roboto Mono の大きな大文字の一言、下にコマの絵がある

フォントのファイルは、[Fontsource](https://fontsource.org/)（フリーのフォントをファイルで配っているところ）から、jsDelivr を通して取りました。作業をしている Mac の中の環境は外へのつながりが限られていて、Google Fonts から直接は取れなかったためです。

【画像】LOG416_01_ep132_ja_en.jpg 第132話3コマ目の日本語版（左）と英語版（右）。絵は同じで、2つの吹き出しの字だけが英語になっている


## 作業ログ ― 残りの28話を作り、別の目で確かめた

### 28話を4つに分け、指示（spec）を書いてもらった

1話ごとに、どの吹き出しにどの英語を入れるかを書いた指示のファイル（spec）を作ります。吹き出しの白い1点の座標・英語・改行の位置・名札などの四角・表紙の一言・英語のページのタイトルと解説文です。

まず、吹き出しの候補を自動で探すプログラム（detect.py・filt.py）で、白くて中に字のある閉じた領域に赤い枠と番号を付けた画像を作りました。候補には、顔やロボットの胴の白い部分も混ざり、逆に小窓の中の小さな吹き出しが抜けることもあります。そこで、28話を7話ずつ4つに分け、Claude の別の作業役4人に、画像を見て spec を書いてもらいました（指示は下の AGENT_TASK.md）。

### 書いた人とは別の作業役が、1コマずつ見比べた

spec からプログラムで英語版を作ったあと、spec を書いた作業役とは別の4人に、日本語版と英語版を1コマずつ並べて見てもらいました（指示は下の QA_TASK.md）。見るのは、日本語の消し残し・英語のはみ出し・1語だけの行・字の小ささ・話者の取り違え・名札の消し方・表紙の一言の7つです。

28話のうち22話で、改行・言い方・名札の四角・表紙の一言を直しました。直した例です。

| 話 | 見つかったこと | 直した形 |
|---|---|---|
| 第93話 | "robot." だけの行ができていた | "I am a / transport robot." の2行に |
| 第129話 | "This was half the price in Japan!" が「日本では半額だった」とも読める | "Half what it costs / here in Japan!" に |
| 第131話 | 張り紙の英語が小さい（29画素） | 四角を紙いっぱいに広げ、手書き風の字で58画素に |
| 第117話 | 表紙の一言が絵（ロボットが向こうを向いている）と合わない | 4コマ目のセリフ "YOU CAN TURN AROUND NOW" に |
| 第126話 | エレベーターの「運転休止」の黒い板が、灰色の帯になって浮いていた | 四角を板の内側いっぱいに広げた（色は灰色のまま） |

直せなかったものも残っています。小窓の中の吹き出しや細い縦長の吹き出しは、日本語版の字も12〜21画素と小さく、英語も16〜25画素までしか大きくできませんでした。名札の字も7〜11画素で、日本語版と同じく読めない大きさです。第99話の表紙は、日本語版の表紙の絵にモニターの日本語の通知が入っていたので、英語にした2コマ目を絵に使って作り直しました。

28話の吹き出しの字の大きさは、139個のうち中央値が44画素（いちばん大きい設定）で、26画素未満は4個でした。

【画像】LOG416_03_archive_en.jpg 英語版の4コマの一覧のページ。第133〜125話の表紙が3列に並び、どれも上に EPISODE と大きな大文字の一言がある

### 公開の形：画像は R2、ページは p2m-site

英語版の画像（1話につき4コマと表紙の5枚、29話で145枚）は webp にして、Cloudflare の R2（画像や動画を置いておくところ）に入れました。R2 は同じ名前のファイルを上書きしない決まりにしているので、作り直した第133話と第99話の表紙は `_v2` の名前で入れています。英語のページは、サイトのコード（GitHub の p2m-site）に1話1つの JSON を足して作ります。145枚がすべて開けること（HTTP 200）と、29話のページが前の話・次の話でつながることを確かめました。


## 作業ログ ― マンガのページに「一覧へ戻る」リンクを足した

英語版のページを見ていたエイイチから、次の指摘がありました。

> UI的にマンガのページに移った時に一覧ページに戻るボタンがないね（日本語はヘッダーで戻れるけど、それでも迷うな）

マンガのページの上、タイトルの前に、矢印と「4コママンガ一覧」（英語版は「All episodes」）のリンクを足しました。どの話のページにも同じ部品（MangaPage.astro）が使われているので、1か所の変更で日本語・英語の全話に出ます。

同じときに、縦長の4コマをめくる部品（MangaSwipe.astro）で、ページを開いた直後に「NaN / 4」と出ることがあるのも直しました。読み込み直後は表示の幅が0で、0で割っていたためです。

- ねらい：英語版のページだけを開いた人が、ほかの話を探せるようにする
- 変わったこと：全話のページの上に、一覧へ戻るリンクが出るようになった
- 見込める効果：1話を読んだ人が、一覧から次の話へ進みやすくなる（効果は、マンガのページから一覧のページへ移った回数で確かめる）

【画像】LOG416_04_back_link.jpg スマホで見た第133話のページ。左が日本語版で上に「← 4コママンガ一覧」、右が英語版で上に「← All episodes」がある


## 作業ログ ― 英語版の全125話を、英語圏の X に出す予約をした

### 投稿の時刻・形・タグを、英語圏に合わせて調べた

エイイチの依頼は次のとおりです。

> 英語版の4コママンガを定期的に英語圏にXでアップしてほしい（今までのマンガも投稿し直し） 英語圏に合わせて文章や投稿時間、タグなどを調べて見直してみてください。

調べて決めたことです。

| 決めたこと | 中身 | もとにしたこと |
|---|---|---|
| 時刻 | 平日（月〜金）の米国東部時間 11:00 と 15:00（日本時間では冬は翌1:00と5:00） | 読む人の地元の時刻で平日の午前が最も反応がよく、土曜と夜は弱い（[Buffer の約870万投稿の分析](https://buffer.com/resources/best-time-to-post-on-twitter/)）。火〜木の昼〜夕方がよく、技術の分野は11〜16時（[Sprout Social](https://sproutsocial.com/insights/best-times-to-post-on-twitter/)）。米国の東部と英国の夕方の両方に届く時刻にした |
| 画像 | 4コマの絵を画像として付ける（縦長の話は4枚、横長の話は1枚） | 画像のある投稿は文だけより表示されやすい。マンガは絵が中身なので、絵そのものを出す |
| 画像の説明 | 1枚ずつ英語の説明（alt テキスト：画面を読み上げる機能や検索が読む文）を付ける | 絵を見られない人にも何の場面か伝わる |
| 本文 | 1行目で手が止まる一文 → 短い前振り1〜2行（4コマ目のオチは書かない）→ `Hakobukun · Ep. N` → タグ | 絵を見に行く理由を残す |
| タグ | 2つ：`#webcomic` と、話題のタグ1つ（`#robotics` `#healthcare` `#nursing` `#AI` `#UX` `#cybersecurity` から） | X ではタグは2つまでが目安。マンガを読む人が追う `#webcomic` を必ず入れる |
| リンク | 本文には入れず、自分への返信に置く（このあと、返信も出さないことにした） | リンクのある投稿は表示が少ないという測り方がある。費用も高い |

文は日本語の直訳にせず、英語で読む人がハコブくんの性格（まじめで、言われたとおりに受け取り、少し機械的）を楽しめる文にしました。第132話の例です。

```
The robot got an AI update overnight. Corners? Silky smooth now.

Nobody checked how it stops, though.

Hakobukun · Ep. 132
#webcomic #AI
```

125話を4つに分け、Claude の別の作業役4人に文と画像の説明を書いてもらいました（指示は下の TASK.md）。1本の長さは137〜208字で、どれもリンクを含みません。

### 投稿の仕組みに、画像と返信を足した

X への投稿は、GitHub の r2r2r-content にある予定表（sns/schedule.json）に時刻を書いておくと、[GitHub Actions](https://docs.github.com/actions)（GitHub の上で決まった時刻にプログラムを動かす仕組み）が sns_autopost.py を動かして出します。これまでは文だけでした。そこに、画像をつなぐ部分（X の /2/media/upload に画像を上げ、/2/media/metadata で説明を付け、投稿に画像の番号を付ける）と、投稿のあとに自分への返信を出す部分を足しました。投稿はせずに画像を上げられるかだけを確かめる verify にも、画像のアップロードを足しています。

予定表には、`MANGA_ep{N}-x-en` という名前で125本を入れました。11:00 は縦長の話（第73話から番号の順）、15:00 は横長の話（第1話から番号の順）です。最初の投稿は 2026-11-02 11:00（米国東部時間）、最後は 2027-01-27 です。

### X の費用を見て、リンクを外した

予約のあと、エイイチから費用について質問がありました。

> 費用の大半は、リンクの入った返信（0.20ドル）です。返信をやめれば1本約0.02〜0.04ドルになります これはうちのサイトのマンガページへのリンクの意味ですか？

> このお金はどこから支払う？

X の API の費用は、[X の開発者向けの管理画面](https://console.x.com)で前もって買っておくクレジットから引かれます。日本語の自動投稿も同じクレジットから払っています。エイイチが画面を開くと、10月の費用は5.40ドル、API を呼んだ回数は27回でした。日本語の投稿はすべて本文にリンクが入っていて、1本0.20ドルなので、数が合います。

【画像】LOG416_05_x_usage.jpg X の開発者向けの管理画面の使用状況。10月1日〜31日の総費用が5.40ドル、リクエストが27回と出ている

X の API の料金は、投稿1本0.015ドル、リンクの入った投稿は1本0.20ドル、画像の説明は1つ0.005ドルです（2026-04-20 からの料金。[料金の解説](https://www.upload-post.com/x-api-pricing/)）。このまま続けた場合の1か月の見込みです。

| 投稿 | リンクあり | リンクなし |
|---|---|---|
| 日本語（1日4本） | 約24ドル | 約1.8ドル |
| 英語のマンガ（平日2本・画像の説明つき） | 約10ドル | 約1.2ドル |
| 合計 | 約35ドル（約5,000円） | 約3ドル |

残高は14.60ドルだったので、リンクありのままだと11月12日ごろに尽きる計算でした。エイイチの決定は次のとおりです。

> 日本語の投稿もリンクをやめる。今は投稿にリンク追加は無しの設定にしてください。いいねやリポストが増えたらリンク付きの投稿に変えます。

sns_autopost.py に、X にリンクを付けるかどうかの切り替え（X_LINKS）を足しました。GitHub のリポジトリの設定（Variables）で X_LINKS を 1 にしたときだけリンクを付け、ないときは本文の URL の行を外し、英語の返信も出しません。投稿文のファイルには URL を残しているので、1 にすればそのまま元の形で出ます。LinkedIn は今までどおりリンクを付けます。予定に入っていた X の272本で、URL が残る投稿がないことを確かめました。

- ねらい：X の費用を、反応が増えるまで小さく抑える
- 変わったこと：日本語・英語とも、X の投稿にリンクが付かなくなった。サイトへの入口は、X のプロフィールのリンクだけになった
- 見込める効果：X の費用が月に約35ドルから約3ドルになる見込み。そのかわり、投稿からサイトに来る人は減る見込みで、これは pixelstomotors.com のアクセスの記録で確かめる

前の「吹き出しの見つけ方」の節の図解の下の段で、リンクありとリンクなしの1か月の費用を切り替えて見られます。

X のプロフィールのリンクは、いまは /consult（フィジカルAI導入の相談のページ）に向いています。投稿を見てプロフィールに来た人が、記事やマンガの続きを探しているなら、トップのほうが合うかもしれません。このまま様子を見ることにしました。


### X に画像を上げられることを、verify で確かめた

英語の投稿は、今までの日本語の投稿と違い、X に画像を上げてから出します。最初の英語の投稿（2026-11-03 1:00 日本時間）の前に、投稿はせずに鍵と画像のアップロードだけを試す verify を動かしました。GitHub のリポジトリの Actions の画面で「SNS自動投稿」を選び、「Run workflow」で「鍵が使えるかだけ確かめる（投稿しない）」にチェックを入れて動かします（このセッションからは、GitHub の権限で手動の実行ができなかったので、エイイチが画面から動かしました）。

結果は23秒で成功しました。ログの最後の3行が、確かめた中身です。

| 確かめたこと | 結果 |
|---|---|
| X の鍵 | OK（@adaptdesignjp として読めた） |
| X に画像を上げる（第133話の1コマ目） | OK（media id が返った。投稿はしていない） |
| LinkedIn の鍵 | OK |

【画像】LOG416_06_verify.jpg GitHub Actions の「SNS自動投稿」#136 の画面。状態が Success、かかった時間が23秒で、post の仕事に緑のチェックが付いている

画面の下に出ている注意（Node.js 20 is deprecated）とお知らせ（ubuntu-latest が Ubuntu 26 に移る）は、GitHub が使う部品の移り変わりを知らせるものです。今回は Node.js 24 で動いて成功しているので、そのままにしています。

#### verify を動かしたときのログ（SNS自動投稿 #136 の post）

鍵の値は GitHub が *** に伏せて出しています。

```text
﻿2026-10-31T08:45:03.1554242Z Current runner version: '2.338.2'
2026-10-31T08:45:03.1633229Z ##[group]Runner Image Provisioner
2026-10-31T08:45:03.1635482Z Hosted Compute Agent
2026-10-31T08:45:03.1636706Z Version: 20261021.618
2026-10-31T08:45:03.1638093Z Commit: 21505ad2541d5be6135ed84fc33697146cf75b8e
2026-10-31T08:45:03.1639557Z Build Date: 2026-10-21T00:45:21Z
2026-10-31T08:45:03.1661476Z Worker ID: {4d0ffd1b-826b-40b0-9c6b-849b714f2644}
2026-10-31T08:45:03.1663012Z Region: eastus
2026-10-31T08:45:03.1664298Z Cloud: Azure
2026-10-31T08:45:03.1665250Z ##[endgroup]
2026-10-31T08:45:03.1667833Z ##[group]Operating System
2026-10-31T08:45:03.1669112Z Ubuntu
2026-10-31T08:45:03.1669981Z 26.04.1
2026-10-31T08:45:03.1671290Z LTS
2026-10-31T08:45:03.1672208Z ##[endgroup]
2026-10-31T08:45:03.1673296Z ##[group]Runner Image
2026-10-31T08:45:03.1674492Z Image: ubuntu-26.04
2026-10-31T08:45:03.1675695Z Version: 20261025.179.1
2026-10-31T08:45:03.1678113Z Included Software: https://github.com/actions/runner-images/blob/ubuntu26/20261025.179/images/ubuntu/Ubuntu2604-Readme.md
2026-10-31T08:45:03.1701696Z Image Release: https://github.com/actions/runner-images/releases/tag/ubuntu26%2F20261025.179
2026-10-31T08:45:03.1703781Z ##[endgroup]
2026-10-31T08:45:03.1705788Z ##[group]GITHUB_TOKEN Permissions
2026-10-31T08:45:03.1709547Z Contents: write
2026-10-31T08:45:03.1710554Z Metadata: read
2026-10-31T08:45:03.1711950Z ##[endgroup]
2026-10-31T08:45:03.1715300Z Secret source: Actions
2026-10-31T08:45:03.1717173Z Cache mode: write
2026-10-31T08:45:03.1718851Z Prepare workflow directory
2026-10-31T08:45:03.2246154Z Prepare all required actions
2026-10-31T08:45:03.2320677Z Getting action download info
2026-10-31T08:45:03.3884400Z Download action repository 'actions/checkout@v4' (SHA:11d5960a326750d5838078e36cf38b85af677262)
2026-10-31T08:45:03.5234287Z Download action repository 'actions/setup-python@v5' (SHA:a26af69be951a213d495a4c3e4e4022e16d87065)
2026-10-31T08:45:03.7161189Z Complete job name: post
2026-10-31T08:45:03.8073995Z ##[group]Run actions/checkout@v4
2026-10-31T08:45:03.8075064Z with:
2026-10-31T08:45:03.8075593Z   repository: PixelstoMotors/r2r2r-content
2026-10-31T08:45:03.8079452Z   token: ***
2026-10-31T08:45:03.8079926Z   ssh-strict: true
2026-10-31T08:45:03.8080410Z   ssh-user: git
2026-10-31T08:45:03.8081387Z   persist-credentials: true
2026-10-31T08:45:03.8081946Z   clean: true
2026-10-31T08:45:03.8082448Z   sparse-checkout-cone-mode: true
2026-10-31T08:45:03.8083256Z   fetch-depth: 1
2026-10-31T08:45:03.8083747Z   fetch-tags: false
2026-10-31T08:45:03.8084235Z   show-progress: true
2026-10-31T08:45:03.8084730Z   lfs: false
2026-10-31T08:45:03.8085188Z   submodules: false
2026-10-31T08:45:03.8085671Z   set-safe-directory: true
2026-10-31T08:45:03.8086230Z   allow-unsafe-pr-checkout: false
2026-10-31T08:45:03.8087135Z ##[endgroup]
2026-10-31T08:45:04.2294331Z Syncing repository: PixelstoMotors/r2r2r-content
2026-10-31T08:45:04.2300592Z ##[group]Getting Git version info
2026-10-31T08:45:04.2302476Z Working directory is '/home/runner/work/r2r2r-content/r2r2r-content'
2026-10-31T08:45:04.2304609Z [command]/usr/bin/git version
2026-10-31T08:45:04.2305702Z git version 2.56.0
2026-10-31T08:45:04.2313619Z ##[endgroup]
2026-10-31T08:45:04.2323025Z Temporarily overriding HOME='/home/runner/work/_temp/3d6f5f20-1f27-466b-9db9-b07611ede2f6' before making global git config changes
2026-10-31T08:45:04.2325838Z Adding repository directory to the temporary git global config as a safe directory
2026-10-31T08:45:04.2328143Z [command]/usr/bin/git config --global --add safe.directory /home/runner/work/r2r2r-content/r2r2r-content
2026-10-31T08:45:04.2336892Z Deleting the contents of '/home/runner/work/r2r2r-content/r2r2r-content'
2026-10-31T08:45:04.2339517Z ##[group]Initializing the repository
2026-10-31T08:45:04.2344721Z [command]/usr/bin/git init /home/runner/work/r2r2r-content/r2r2r-content
2026-10-31T08:45:04.2346948Z hint: Using 'master' as the name for the initial branch. This default branch name
2026-10-31T08:45:04.2349220Z hint: will change to "main" in Git 3.0. To configure the initial branch name
2026-10-31T08:45:04.2352778Z hint: to use in all of your new repositories, which will suppress this warning,
2026-10-31T08:45:04.2354320Z hint: call:
2026-10-31T08:45:04.2355174Z hint:
2026-10-31T08:45:04.2356196Z hint: 	git config --global init.defaultBranch <name>
2026-10-31T08:45:04.2357414Z hint:
2026-10-31T08:45:04.2358509Z hint: Names commonly chosen instead of 'master' are 'main', 'trunk' and
2026-10-31T08:45:04.2360417Z hint: 'development'. The just-created branch can be renamed via this command:
2026-10-31T08:45:04.2363285Z hint:
2026-10-31T08:45:04.2364301Z hint: 	git branch -m <name>
2026-10-31T08:45:04.2365284Z hint:
2026-10-31T08:45:04.2366484Z hint: Disable this message with "git config set advice.defaultBranchName false"
2026-10-31T08:45:04.2368631Z Initialized empty Git repository in /home/runner/work/r2r2r-content/r2r2r-content/.git/
2026-10-31T08:45:04.2373278Z [command]/usr/bin/git remote add origin https://github.com/PixelstoMotors/r2r2r-content
2026-10-31T08:45:04.2378140Z ##[endgroup]
2026-10-31T08:45:04.2379939Z ##[group]Disabling automatic garbage collection
2026-10-31T08:45:04.2383864Z [command]/usr/bin/git config --local gc.auto 0
2026-10-31T08:45:04.2387351Z ##[endgroup]
2026-10-31T08:45:04.2389039Z ##[group]Setting up auth
2026-10-31T08:45:04.2390673Z [command]/usr/bin/git config --local --name-only --get-regexp core\.sshCommand
2026-10-31T08:45:04.2396247Z [command]/usr/bin/git submodule foreach --recursive sh -c "git config --local --name-only --get-regexp 'core\.sshCommand' && git config --local --unset-all 'core.sshCommand' || :"
2026-10-31T08:45:04.2990846Z [command]/usr/bin/git config --local --name-only --get-regexp http\.https\:\/\/github\.com\/\.extraheader
2026-10-31T08:45:04.3053972Z [command]/usr/bin/git submodule foreach --recursive sh -c "git config --local --name-only --get-regexp 'http\.https\:\/\/github\.com\/\.extraheader' && git config --local --unset-all 'http.https://github.com/.extraheader' || :"
2026-10-31T08:45:04.3367380Z [command]/usr/bin/git config --local --name-only --get-regexp ^includeIf\.gitdir:
2026-10-31T08:45:04.3411756Z [command]/usr/bin/git submodule foreach --recursive git config --local --show-origin --name-only --get-regexp remote.origin.url
2026-10-31T08:45:04.3717510Z [command]/usr/bin/git config --local http.https://github.com/.extraheader AUTHORIZATION: basic ***
2026-10-31T08:45:04.3765715Z ##[endgroup]
2026-10-31T08:45:04.3768168Z ##[group]Fetching the repository
2026-10-31T08:45:04.3780818Z [command]/usr/bin/git -c protocol.version=2 fetch --no-tags --prune --no-recurse-submodules --depth=1 origin +4b319651f4c57e72db12fe018a595ad881078e81:refs/remotes/origin/main
2026-10-31T08:45:06.5013875Z From https://github.com/PixelstoMotors/r2r2r-content
2026-10-31T08:45:06.5028653Z  * [new ref]         4b319651f4c57e72db12fe018a595ad881078e81 -> origin/main
2026-10-31T08:45:06.5042271Z ##[endgroup]
2026-10-31T08:45:06.5043436Z ##[group]Determining the checkout info
2026-10-31T08:45:06.5044636Z ##[endgroup]
2026-10-31T08:45:06.5045492Z [command]/usr/bin/git sparse-checkout disable
2026-10-31T08:45:06.5092540Z [command]/usr/bin/git config --local --unset-all extensions.worktreeConfig
2026-10-31T08:45:06.5133429Z ##[group]Checking out the ref
2026-10-31T08:45:06.5135274Z [command]/usr/bin/git checkout --progress --force -B main refs/remotes/origin/main
2026-10-31T08:45:06.9315677Z Switched to a new branch 'main'
2026-10-31T08:45:06.9354183Z branch 'main' set up to track 'origin/main'.
2026-10-31T08:45:06.9389916Z ##[endgroup]
2026-10-31T08:45:06.9473603Z [command]/usr/bin/git log -1 --format=%H
2026-10-31T08:45:06.9510395Z 4b319651f4c57e72db12fe018a595ad881078e81
2026-10-31T08:45:06.9821935Z ##[group]Run actions/setup-python@v5
2026-10-31T08:45:06.9822296Z with:
2026-10-31T08:45:06.9822515Z   python-version: 3.12
2026-10-31T08:45:06.9822761Z   check-latest: false
2026-10-31T08:45:06.9825110Z   token: ***
2026-10-31T08:45:06.9825348Z   update-environment: true
2026-10-31T08:45:06.9825612Z   allow-prereleases: false
2026-10-31T08:45:06.9826196Z   freethreaded: false
2026-10-31T08:45:06.9826424Z ##[endgroup]
2026-10-31T08:45:07.1319695Z ##[group]Installed versions
2026-10-31T08:45:07.1478563Z (node:2112) [DEP0040] DeprecationWarning: The `punycode` module is deprecated. Please use a userland alternative instead.
2026-10-31T08:45:07.1480136Z Successfully set up CPython (3.12.15)
2026-10-31T08:45:07.1481504Z (Use `node --trace-deprecation ...` to show where the warning was created)
2026-10-31T08:45:07.1498463Z ##[endgroup]
2026-10-31T08:45:07.1899648Z ##[group]Run git config user.name "sns-autopost"
2026-10-31T08:45:07.1900419Z [36;1mgit config user.name "sns-autopost"[0m
2026-10-31T08:45:07.1901419Z [36;1mgit config user.email "sns-autopost@users.noreply.github.com"[0m
2026-10-31T08:45:07.1902405Z [36;1mif [ "workflow_dispatch" = "workflow_dispatch" ] && [ "true" = "true" ]; then[0m
2026-10-31T08:45:07.1902897Z [36;1m  python tools/sns_autopost.py verify[0m
2026-10-31T08:45:07.1903647Z [36;1melif [ "workflow_dispatch" = "workflow_dispatch" ] && [ "true" = "true" ]; then[0m
2026-10-31T08:45:07.1904629Z [36;1m  python tools/sns_autopost.py run --dry-run[0m
2026-10-31T08:45:07.1905286Z [36;1melse[0m
2026-10-31T08:45:07.1905816Z [36;1m  python tools/sns_autopost.py run[0m
2026-10-31T08:45:07.1906423Z [36;1mfi[0m
2026-10-31T08:45:07.2021968Z shell: /usr/bin/bash -e {0}
2026-10-31T08:45:07.2022578Z env:
2026-10-31T08:45:07.2023103Z   pythonLocation: /opt/hostedtoolcache/Python/3.12.15/x64
2026-10-31T08:45:07.2024002Z   PKG_CONFIG_PATH: /opt/hostedtoolcache/Python/3.12.15/x64/lib/pkgconfig
2026-10-31T08:45:07.2024761Z   Python_ROOT_DIR: /opt/hostedtoolcache/Python/3.12.15/x64
2026-10-31T08:45:07.2025461Z   Python2_ROOT_DIR: /opt/hostedtoolcache/Python/3.12.15/x64
2026-10-31T08:45:07.2025932Z   Python3_ROOT_DIR: /opt/hostedtoolcache/Python/3.12.15/x64
2026-10-31T08:45:07.2026345Z   LD_LIBRARY_PATH: /opt/hostedtoolcache/Python/3.12.15/x64/lib
2026-10-31T08:45:07.2026684Z   AUTOPOST_GIT: 1
2026-10-31T08:45:07.2026903Z   X_LONG_POSTS: 
2026-10-31T08:45:07.2027132Z   X_LINKS: 
2026-10-31T08:45:07.2027325Z   LINKEDIN_VERSION: 
2026-10-31T08:45:07.2027746Z   X_API_KEY: ***
2026-10-31T08:45:07.2028099Z   X_API_SECRET: ***
2026-10-31T08:45:07.2028424Z   X_ACCESS_TOKEN: ***
2026-10-31T08:45:07.2028746Z   X_ACCESS_SECRET: ***
2026-10-31T08:45:07.2030630Z   LINKEDIN_ACCESS_TOKEN: ***
2026-10-31T08:45:07.2031176Z   LINKEDIN_PERSON_URN: 
2026-10-31T08:45:07.2031493Z ##[endgroup]
2026-10-31T08:45:08.4686106Z X: OK（@adaptdesignjp）
2026-10-31T08:45:08.4687284Z X 画像のアップロード: OK（media id 2116451390314717184。投稿はしていない）
2026-10-31T08:45:08.4688372Z LinkedIn: OK（Eiichi Shimazu 島津 栄一）
2026-10-31T08:45:08.4909105Z Post job cleanup.
2026-10-31T08:45:08.6242296Z (node:2125) [DEP0040] DeprecationWarning: The `punycode` module is deprecated. Please use a userland alternative instead.
2026-10-31T08:45:08.6244191Z (Use `node --trace-deprecation ...` to show where the warning was created)
2026-10-31T08:45:08.6557917Z Post job cleanup.
2026-10-31T08:45:08.7560161Z [command]/usr/bin/git version
2026-10-31T08:45:08.7642997Z git version 2.56.0
2026-10-31T08:45:08.7731431Z Temporarily overriding HOME='/home/runner/work/_temp/2047d84e-526b-4136-8c6e-3759a5347bdb' before making global git config changes
2026-10-31T08:45:08.7733043Z Adding repository directory to the temporary git global config as a safe directory
2026-10-31T08:45:08.7734301Z [command]/usr/bin/git config --global --add safe.directory /home/runner/work/r2r2r-content/r2r2r-content
2026-10-31T08:45:08.7851829Z [command]/usr/bin/git config --local --name-only --get-regexp core\.sshCommand
2026-10-31T08:45:08.7864141Z [command]/usr/bin/git submodule foreach --recursive sh -c "git config --local --name-only --get-regexp 'core\.sshCommand' && git config --local --unset-all 'core.sshCommand' || :"
2026-10-31T08:45:08.8371679Z [command]/usr/bin/git config --local --name-only --get-regexp http\.https\:\/\/github\.com\/\.extraheader
2026-10-31T08:45:08.8374468Z http.https://github.com/.extraheader
2026-10-31T08:45:08.8443577Z [command]/usr/bin/git config --local --unset-all http.https://github.com/.extraheader
2026-10-31T08:45:08.8453021Z [command]/usr/bin/git submodule foreach --recursive sh -c "git config --local --name-only --get-regexp 'http\.https\:\/\/github\.com\/\.extraheader' && git config --local --unset-all 'http.https://github.com/.extraheader' || :"
2026-10-31T08:45:08.8981947Z [command]/usr/bin/git config --local --name-only --get-regexp ^includeIf\.gitdir:
2026-10-31T08:45:08.9036735Z [command]/usr/bin/git submodule foreach --recursive git config --local --show-origin --name-only --get-regexp remote.origin.url
2026-10-31T08:45:08.9690344Z Cleaning up orphan processes
2026-10-31T08:45:09.0061604Z ##[warning]Node.js 20 is deprecated. The following actions target Node.js 20 but are being forced to run on Node.js 24: actions/checkout@v4, actions/setup-python@v5. For more information see: https://github.blog/changelog/2025-09-19-deprecation-of-node-20-on-github-actions-runners/
```

## 作業ログ ― 使ったコード・指示・記録（全文）

ここから下は、この記事でやったことを同じ手順で追えるように、コード・作業役への指示・作ったときの記録・確かめた報告を全文で載せています。作業役の報告は、英語で返ってきたものは英語のまま載せています。

### en133.py（第133話の試作）

第133話の4コマを英語にした最初のプログラムです。吹き出しの位置とホワイトボードの見出しを、このファイルの中に直接書いています。公開している第133話の4コマは、このプログラムで作りました。

```python
# 第133話 英語版：吹き出しの日本語を消して英語をフォントで組む（2026-10-31 試作）
import json, numpy as np
from collections import deque
from PIL import Image, ImageDraw, ImageFont
SRC="../仕上げ/第133話_P{}_1080x1350.png"; OUT="第133話_P{}_EN_1080x1350.png"
import os
FONTS=os.path.expanduser("~/mnt/Fonts")  # Mac の ~/Library/Fonts（Comic Neue。今までの英語版マンガに合わせる）
F_HUMAN=FONTS+"/ComicNeue-Bold.ttf"
F_ROBOT=F_HUMAN
F_BOARD=FONTS+"/ComicNeue-Bold.ttf"
# 吹き出し：(コマ, 吹き出しの中の1点[x,y], 英語, フォント)
BUBBLES=[
 (1,(550,166),"The robot is all set and ready to go!",F_HUMAN),
 (3,(540,213),"I am ready to roll.\nAny time.",F_ROBOT),
 (4,(310,177),"So before we moved\nthe robot, we had to\nmove everyone.",F_HUMAN),
 (4,(809,177),"For the next ward,\nall six sign-offs go out\non day one.",F_HUMAN),
 (4,(545,411),"Heading out.",F_ROBOT),
]
LABELS=["Pharmacy","Nursing","Facilities","IT","Fire\nSafety","Patient\nSafety"]
# ホワイトボード：縦線のx（左の枠〜右の枠）、札の帯のy（上・下）
BOARDS={1:dict(xs=[101,221,324,427,530,633,771],y=(330,389)),
        2:dict(xs=[77,238,393,548,704,858,1017],y=(266,333))}

def fill_region(g, seed, thr=230):
    H,W=g.shape; m=np.zeros((H,W),bool); q=deque([seed]); m[seed[1],seed[0]]=True
    while q:
        x,y=q.popleft()
        for dx,dy in ((1,0),(-1,0),(0,1),(0,-1)):
            xx,yy=x+dx,y+dy
            if 0<=xx<W and 0<=yy<H and not m[yy,xx] and g[yy,xx]>thr:
                m[yy,xx]=True; q.append((xx,yy))
    return m
def interior(comp):
    ys,xs=np.nonzero(comp); y0,y1,x0,x1=ys.min(),ys.max(),xs.min(),xs.max()
    sub=comp[y0:y1+1,x0:x1+1]; h,w=sub.shape
    out=np.zeros((h+2,w+2),bool); pad=np.zeros((h+2,w+2),bool); pad[1:-1,1:-1]=sub
    q=deque([(0,0)]); out[0,0]=True
    while q:
        x,y=q.popleft()
        for dx,dy in ((1,0),(-1,0),(0,1),(0,-1)):
            xx,yy=x+dx,y+dy
            if 0<=xx<w+2 and 0<=yy<h+2 and not out[yy,xx] and not pad[yy,xx]:
                out[yy,xx]=True; q.append((xx,yy))
    filled=~out[1:-1,1:-1]
    full=np.zeros_like(comp); full[y0:y1+1,x0:x1+1]=filled
    return full
def wrap(draw,text,font,maxw):
    if "\n" in text: return text.split("\n")  # 改行を指定したときはその通りに
    words=text.split(); lines=[]; cur=""
    for w in words:
        t=(cur+" "+w).strip()
        if draw.textlength(t,font=font)<=maxw or not cur: cur=t
        else: lines.append(cur); cur=w
    lines.append(cur); return lines
def fit(draw,text,fpath,inside,smax,pad=22):
    ys,xs=np.nonzero(inside)
    spans={y:(xs[ys==y].min(),xs[ys==y].max()) for y in np.unique(ys)}
    widths=np.array([spans[y][1]-spans[y][0] for y in sorted(spans)])
    core=[y for y in sorted(spans) if spans[y][1]-spans[y][0]>=0.6*widths.max()]
    cy=(core[0]+core[-1])/2; cx=np.mean([ (spans[y][0]+spans[y][1])/2 for y in core])
    for size in range(smax,15,-1):
        font=ImageFont.truetype(fpath,size); lh=int(size*1.18)
        for maxw in range(int(widths.max()),60,-10):
            lines=wrap(draw,text,font,maxw); H=lh*len(lines); top=cy-H/2
            ok=True; place=[]
            for i,l in enumerate(lines):
                lw=draw.textlength(l,font=font); a=int(top+i*lh); b=int(top+i*lh+lh)
                x=cx-lw/2
                for y in range(a-pad//2,b+pad//2):
                    if y not in spans or spans[y][0]+pad>x or spans[y][1]-pad<x+lw: ok=False;break
                if not ok: break
                place.append((x,a,l))
            if ok: return font,place,lh
    raise SystemExit(f"収まらない: {text}")

report={}
for p in (1,2,3,4):
    im=Image.open(SRC.format(p)).convert('RGB'); a=np.asarray(im).copy(); g=np.asarray(im.convert('L')).astype(int)
    changed=np.zeros(g.shape,bool); allowed=np.zeros(g.shape,bool)
    jobs=[b for b in BUBBLES if b[0]==p]; texts=[]
    for _,seed,text,fp in jobs:
        comp=None
        for r in range(0,60,3):
            for dx,dy in ((r,0),(-r,0),(0,r),(0,-r)):
                x,y=seed[0]+dx,seed[1]+dy
                if g[y,x]>245:
                    c=fill_region(g,(x,y))
                    if c.sum()>5000: comp=c;break
            if comp is not None: break
        print(p,seed,int(comp.sum()))
        assert not (comp[0].any() or comp[-1].any() or comp[:,0].any() or comp[:,-1].any()), "吹き出しが閉じていない"
        ins=interior(comp)
        # 文字を消す範囲：内側から縁へ6px離す（縁の線を削らない）
        er=ins.copy()
        for _ in range(6):
            e=er.copy(); e[1:]&=er[:-1]; e[:-1]&=er[1:]; e[:,1:]&=er[:,:-1]; e[:,:-1]&=er[:,1:]; er=e
        left=(g<200)&ins&~er
        white=np.median(a[comp],axis=0).astype(np.uint8)
        a[er]=white; allowed|=ins
        texts.append((text,fp,ins,int(left.sum())))
    if p in BOARDS:
        bd=BOARDS[p]; y0,y1=bd["y"]; boxes=[]
        for i,lab in enumerate(LABELS):
            xa,xb=bd["xs"][i]+5,bd["xs"][i+1]-4
            dk=g[y0:y1,xa:xb]<200; cols=np.nonzero(dk.sum(0))[0]
            cl=[];st=cols[0];pv=cols[0]
            for c in cols[1:]:
                if c-pv>10: cl.append((st,pv)); st=c
                pv=c
            cl.append((st,pv))
            # セルの端に触れているかたまりは札ではない（人物・枠）。消さない
            keep=[c for c in cl if c[0]>3 and c[1]<(xb-xa)-4] or cl
            lx0,lx1=keep[0][0],keep[-1][1]
            rows=np.nonzero(dk[:,lx0:lx1+1].sum(1))[0]
            bx0=max(xa,xa+lx0-4); bx1=min(xb,xa+lx1+5); by0=max(y0,y0+rows[0]-4); by1=min(y1,y0+rows[-1]+5)
            cell=np.zeros(g.shape,bool); cell[by0:by1,bx0:bx1]=True
            a[cell]=(254,254,254); allowed|=cell
            # 英語を置ける幅：消さなかったかたまりの手前まで
            right=xb if keep[-1]==cl[-1] else xa+cl[cl.index(keep[-1])+1][0]-4
            left=xa if keep[0]==cl[0] else xa+cl[cl.index(keep[0])-1][1]+4
            area=np.zeros(g.shape,bool); area[y0+3:y1-3,left:right]=True
            stray=int(((g<200)&area&~cell).sum())
            print("P",p,lab,"札の外の暗い画素",stray)
            allowed|=area; boxes.append((lab,area))
        # 6つの札は同じ大きさにそろえる
        d0=ImageDraw.Draw(Image.new("RGB",(10,10)))
        def best(lab,area):
            ys,xs=np.nonzero(area); w=xs.max()-xs.min(); h=ys.max()-ys.min()
            for size in range(44 if p==2 else 30,8,-1):
                f=ImageFont.truetype(F_BOARD,size); lines=wrap(d0,lab,f,w-8); lh=int(size*1.08)
                if max(d0.textlength(l,font=f) for l in lines)<=w-8 and lh*len(lines)<=h: return size
        S=min(best(l,ar) for l,ar in boxes)
        for lab,area in boxes: texts.append((lab,F_BOARD,area,-S))
    img=Image.fromarray(a); d=ImageDraw.Draw(img); info=[]
    for text,fp,ins,left in texts:
        if left<0:
            size=-left; ys,xs=np.nonzero(ins); w=xs.max()-xs.min()
            f=ImageFont.truetype(fp,size); lines=wrap(d,text,f,w-8); lh=int(size*1.08)
            top=(ys.min()+ys.max())/2-lh*len(lines)/2-size*0.08
            for k,l in enumerate(lines):
                d.text(((xs.min()+xs.max())/2-d.textlength(l,font=f)/2,top+k*lh),l,font=f,fill=(25,25,25))
            info.append(dict(text=text,size=size,lines=lines))
        else:
            smax=44
            f,place,lh=fit(d,text,fp,ins,smax)
            for x,y,l in place: d.text((x,y),l,font=f,fill=(15,15,15))
            info.append(dict(text=text,size=f.size,lines=[l for _,_,l in place],縁に残った暗い画素=left))
    out=np.asarray(img); changed=(np.abs(out.astype(int)-np.asarray(im).astype(int)).sum(2)>0)
    report[p]=dict(items=info,吹き出しの外で変わった画素=int((changed&~allowed).sum()))
    img.save(OUT.format(p))
json.dump(report,open("確認_数値.json","w"),ensure_ascii=False,indent=1)
print(json.dumps(report,ensure_ascii=False,indent=1))
```

### en133_cover.py（第133話の表紙の最初の版）

第133話の表紙を Comic Neue で組んだ最初の版です。「表紙のフォントは違う」という指摘で使わなくなり、表紙は en_manga.py の make_cover（Roboto Mono）で作り直しました。

```python
"""第133話 英語版の表紙：日本語版の表紙（_サイト掲載_20261031/covers_v2/make_covers.py）と同じ組み方で、
字は吹き出しと同じ Comic Neue。下の絵は英語の札にした P2（吹き出しのないコマ）"""
import os, numpy as np
from PIL import Image, ImageDraw, ImageFont
FB=os.path.expanduser("~/mnt/Fonts/ComicNeue-Bold.ttf")
W,H=1080,1350; X0=56; MAXW=W-2*X0
font=ImageFont.truetype(FB,96)
p=Image.open("第133話_P2_EN_1080x1350.png").convert('RGB')
a=np.asarray(p).astype(np.int16); a[a.min(2)>232]=255; p=Image.fromarray(a.astype(np.uint8))
head='Episode 133'; t='Six Stamps'; one=f'{head}: {t}'
# 日本語版と同じく1行に収める（入らなければ 88 まで小さくし、それでも入らなければ2行）
while font.getlength(one)>MAXW and font.size>88: font=ImageFont.truetype(FB,font.size-2)
lines=[one] if font.getlength(one)<=MAXW else [head,t]
c=Image.new('RGB',(W,H),'white'); d=ImageDraw.Draw(c); y=62
for ln in lines:
    f=font
    while f.getlength(ln)>MAXW: f=ImageFont.truetype(FB,f.size-4)
    d.text((X0,y),ln,font=f,fill=(36,36,36)); y+=f.size+18
top=y+40; A=H-top
g=np.asarray(p.convert('L'))<235; ys=np.where(g.any(1))[0]; xs=np.where(g.any(0))[0]
cy0,cy1,cx0,cx1=ys.min(),ys.max(),xs.min(),xs.max(); ch=cy1-cy0
s=min(A*0.94/ch,1.45) if ch<A else A/ch
q=p.resize((round(W*s),round(H*s)),Image.LANCZOS)
mx=(cx0+cx1)/2*s; my=(cy0+cy1)/2*s
if s>=1:
    L=int(min(max(mx-W/2,0),q.width-W)); Tp=int(min(max(my-A/2,0),q.height-A))
    b=Image.new('RGB',(W,A),'white'); b.paste(q.crop((L,Tp,min(q.width,L+W),min(q.height,Tp+A))),(0,0)); c.paste(b,(0,top))
else:
    t0=int(cy0*s); b=Image.new('RGB',(q.width,A),'white'); b.paste(q.crop((0,t0,q.width,min(q.height,t0+A))),(0,0)); c.paste(b,((W-q.width)//2,top))
c.save('第133話_表紙_EN_1080x1350.png')
print(lines, font.getlength(one), 's=',round(s,3),'top=',top)
```

### analyze.py（第133話の吹き出しの位置を調べる）

第133話の日本語版のコマで、白い領域のかたまりと大きさを調べたプログラムです。

```python
import sys, numpy as np
from PIL import Image
from collections import deque
SRC="../仕上げ/第133話_P{}_1080x1350.png"
def label(mask):
    H,W=mask.shape; lab=np.zeros((H,W),np.int32); n=0; stats=[]
    for y0 in range(H):
        row=mask[y0]
        for x0 in np.nonzero(row & (lab[y0]==0))[0]:
            if lab[y0,x0]: continue
            n+=1; q=deque([(y0,x0)]); lab[y0,x0]=n; cnt=0; ys=[];xs=[]
            while q:
                y,x=q.popleft(); cnt+=1; ys.append(y); xs.append(x)
                for dy,dx in ((1,0),(-1,0),(0,1),(0,-1)):
                    yy,xx=y+dy,x+dx
                    if 0<=yy<H and 0<=xx<W and mask[yy,xx] and not lab[yy,xx]:
                        lab[yy,xx]=n; q.append((yy,xx))
            stats.append((n,cnt,min(ys),max(ys),min(xs),max(xs)))
    return lab,stats
def run(p):
    im=Image.open(SRC.format(p)).convert('L'); S=2
    g=np.asarray(im.resize((540,675)))
    white=g>230
    lab,st=label(white)
    H,W=g.shape; out=[]
    for n,cnt,y0,y1,x0,x1 in st:
        if cnt<300: continue
        border = y0==0 or x0==0 or y1==H-1 or x1==W-1
        # holes: dark pixels inside bbox not in component but surrounded
        sub=g[y0:y1+1,x0:x1+1]; comp=lab[y0:y1+1,x0:x1+1]==n
        dark=(sub<120)
        # dark pixels strictly inside comp rows span
        inner=0
        for r in range(comp.shape[0]):
            c=np.nonzero(comp[r])[0]
            if len(c): inner+=dark[r,c[0]:c[-1]+1].sum()
        out.append((n,cnt,y0*S,y1*S,x0*S,x1*S,border,inner))
    print("P",p)
    for o in sorted(out,key=lambda o:-o[1])[:12]: print(o)
for p in sys.argv[1:]: run(int(p))
```

### 確認_数値.json（第133話）

en133.py が書き出した記録です。吹き出しごとの英語・字の大きさ・行と、吹き出しの外で変わった画素の数（0）が入っています。

```json
{
 "1": {
  "items": [
   {
    "text": "The robot is all set and ready to go!",
    "size": 41,
    "lines": [
     "The robot is all set",
     "and ready to go!"
    ],
    "縁に残った暗い画素": 0
   },
   {
    "text": "Pharmacy",
    "size": 22,
    "lines": [
     "Pharmacy"
    ]
   },
   {
    "text": "Nursing",
    "size": 22,
    "lines": [
     "Nursing"
    ]
   },
   {
    "text": "Facilities",
    "size": 22,
    "lines": [
     "Facilities"
    ]
   },
   {
    "text": "IT",
    "size": 22,
    "lines": [
     "IT"
    ]
   },
   {
    "text": "Fire\nSafety",
    "size": 22,
    "lines": [
     "Fire",
     "Safety"
    ]
   },
   {
    "text": "Patient\nSafety",
    "size": 22,
    "lines": [
     "Patient",
     "Safety"
    ]
   }
  ],
  "吹き出しの外で変わった画素": 0
 },
 "2": {
  "items": [
   {
    "text": "Pharmacy",
    "size": 28,
    "lines": [
     "Pharmacy"
    ]
   },
   {
    "text": "Nursing",
    "size": 28,
    "lines": [
     "Nursing"
    ]
   },
   {
    "text": "Facilities",
    "size": 28,
    "lines": [
     "Facilities"
    ]
   },
   {
    "text": "IT",
    "size": 28,
    "lines": [
     "IT"
    ]
   },
   {
    "text": "Fire\nSafety",
    "size": 28,
    "lines": [
     "Fire",
     "Safety"
    ]
   },
   {
    "text": "Patient\nSafety",
    "size": 28,
    "lines": [
     "Patient",
     "Safety"
    ]
   }
  ],
  "吹き出しの外で変わった画素": 0
 },
 "3": {
  "items": [
   {
    "text": "I am ready to roll.\nAny time.",
    "size": 44,
    "lines": [
     "I am ready to roll.",
     "Any time."
    ],
    "縁に残った暗い画素": 0
   }
  ],
  "吹き出しの外で変わった画素": 0
 },
 "4": {
  "items": [
   {
    "text": "So before we moved\nthe robot, we had to\nmove everyone.",
    "size": 37,
    "lines": [
     "So before we moved",
     "the robot, we had to",
     "move everyone."
    ],
    "縁に残った暗い画素": 0
   },
   {
    "text": "For the next ward,\nall six sign-offs go out\non day one.",
    "size": 38,
    "lines": [
     "For the next ward,",
     "all six sign-offs go out",
     "on day one."
    ],
    "縁に残った暗い画素": 0
   },
   {
    "text": "Heading out.",
    "size": 37,
    "lines": [
     "Heading out."
    ],
    "縁に残った暗い画素": 0
   }
  ],
  "吹き出しの外で変わった画素": 0
 }
}
```

### en_manga.py（28話を作ったプログラム）

第133話のやり方を、spec のファイルを読んでどの話でも作れる形にしたものです。iCloud の「ハコブくん/4コママンガ作成データ/道具/英語版/」に置いています。

```python
"""縦長4コマの英語版を作る（2026-10-31・第133話のやり方を全話に広げたもの）

  python3 en_manga.py <spec.json> <日本語のコマのフォルダ> <出力フォルダ> <フォントのフォルダ> [--cover <日本語の表紙>]

- spec の bubbles：吹き出しの白い内側の1点（seed）から吹き出しを見つけ、中の日本語を消して英語を組む
  （clip があれば、その四角の中に組む。2つの吹き出しが1つの白い領域でつながっているとき）
- spec の signs：絵に描かれた日本語の字（名札・画面・張り紙）を、まわりの色で消して英語を置く
- 吹き出しと字の外の画素は変えない（変わったら止める）
- 表紙：日本語版の表紙の絵（タイトルの下）を使い、上に EPISODE N と大きな一言を Roboto Mono で組む（今までの英語版の表紙と同じ形）
フォント：吹き出しは Comic Neue Bold（今までの英語版マンガに合わせた）、表紙は Roboto Mono Bold、画面などの字は Inter SemiBold
"""
import json, os, sys
from collections import deque
import numpy as np
from PIL import Image, ImageDraw, ImageFont

def font_path(fdir, *names):
    for n in names:
        p = os.path.join(fdir, n)
        if os.path.exists(p):
            return p
    raise SystemExit(f'フォントがない: {names} in {fdir}')

def flood(mask, seed):
    H, W = mask.shape
    m = np.zeros((H, W), bool)
    x, y = seed
    if not mask[y, x]:
        return m
    q = deque([(x, y)]); m[y, x] = True
    while q:
        x, y = q.popleft()
        for dx, dy in ((1, 0), (-1, 0), (0, 1), (0, -1)):
            xx, yy = x + dx, y + dy
            if 0 <= xx < W and 0 <= yy < H and mask[yy, xx] and not m[yy, xx]:
                m[yy, xx] = True; q.append((xx, yy))
    return m

def dilate(m, r):
    out = m.copy()
    for _ in range(r):
        o = out.copy()
        o[1:] |= out[:-1]; o[:-1] |= out[1:]; o[:, 1:] |= out[:, :-1]; o[:, :-1] |= out[:, 1:]
        out = o
    return out

def erode(m, r):
    return ~dilate(~m, r)

def fill_holes(comp):
    ys, xs = np.nonzero(comp)
    y0, y1, x0, x1 = ys.min(), ys.max(), xs.min(), xs.max()
    sub = comp[y0:y1 + 1, x0:x1 + 1]
    pad = np.zeros((sub.shape[0] + 2, sub.shape[1] + 2), bool); pad[1:-1, 1:-1] = sub
    outside = flood(~pad, (0, 0))
    full = np.zeros_like(comp); full[y0:y1 + 1, x0:x1 + 1] = ~outside[1:-1, 1:-1]
    return full

def touches_border(m):
    return m[0].any() or m[-1].any() or m[:, 0].any() or m[:, -1].any()

def find_bubble(g, seed):
    """seed のある白い領域を吹き出しとして返す。縁が破線で外とつながるときは、縁の線を太らせて閉じる"""
    x, y = seed
    # seed が字の上なら近くの白へずらす
    if g[y, x] <= 230:
        for r in range(1, 40):
            hit = [(x + dx, y + dy) for dx in range(-r, r + 1) for dy in (-r, r)] + [(x + dx, y + dy) for dy in range(-r, r + 1) for dx in (-r, r)]
            hit = [(a, b) for a, b in hit if 0 <= a < g.shape[1] and 0 <= b < g.shape[0] and g[b, a] > 240]
            if hit:
                x, y = hit[0]; break
    for r in (0, 2, 3, 4, 6, 8):
        white = g > 230
        if r:
            white &= ~dilate(g < 160, r)
        if not white[y, x]:
            continue
        comp = flood(white, (x, y))
        if not touches_border(comp) and comp.sum() < 0.35 * g.size:
            if r:  # 太らせた分を戻す（吹き出しの内側の白いところまで）
                comp = dilate(comp, r) & (g > 200) & ~dilate(g < 160, 1) | comp
            return comp, r
    raise RuntimeError(f'吹き出しが閉じていない: seed {seed}')

def wrap(draw, text, font, maxw):
    if '\n' in text:
        return text.split('\n')
    words = text.split(); lines = []; cur = ''
    for w in words:
        t = (cur + ' ' + w).strip()
        if draw.textlength(t, font=font) <= maxw or not cur:
            cur = t
        else:
            lines.append(cur); cur = w
    lines.append(cur)
    return lines

def fit_text(draw, text, fpath, inside, smax=44, smin=15):
    ys, xs = np.nonzero(inside)
    spans = {}
    for yy in np.unique(ys):
        r = xs[ys == yy]; spans[int(yy)] = (int(r.min()), int(r.max()))
    widths = np.array([spans[y][1] - spans[y][0] for y in sorted(spans)])
    wmax = widths.max()
    core = [y for y in sorted(spans) if spans[y][1] - spans[y][0] >= 0.6 * wmax]
    cy = (core[0] + core[-1]) / 2; cx = float(np.mean([(spans[y][0] + spans[y][1]) / 2 for y in core]))
    for keep_breaks in (True, False):
        t = text if keep_breaks else text.replace('\n', ' ')
        for size in range(smax, smin - 1, -1):
            pad = max(10, min(22, int(size * 0.5)))
            font = ImageFont.truetype(fpath, size); lh = int(size * 1.18)
            for maxw in range(int(wmax), 40, -12):
                lines = wrap(draw, t, font, maxw)
                H = lh * len(lines); top = cy - H / 2
                ok = True; place = []
                for i, l in enumerate(lines):
                    lw = draw.textlength(l, font=font); a = int(top + i * lh)
                    x = cx - lw / 2
                    for yy in range(a - pad // 2, a + lh + pad // 2):
                        if yy not in spans or spans[yy][0] + pad > x or spans[yy][1] - pad < x + lw:
                            ok = False; break
                    if not ok:
                        break
                    place.append((x, a, l))
                if ok:
                    return font, place, keep_breaks
                if '\n' in t:
                    break  # 改行を決めているときは幅を変えても同じ
    raise RuntimeError(f'収まらない: {text!r}')

def bg_color(a, box):
    """字の地の色：明るい地なら明るいほう（80%点）、暗い地（黒い板に白い字）なら暗いほう（20%点）"""
    x0, y0, x1, y1 = box
    px = a[y0:y1, x0:x1].reshape(-1, 3).astype(int); lum = px.mean(1)
    q = 80 if lum.mean() >= 110 else 20
    t = np.percentile(lum, q); sel = px[np.abs(lum - t) <= 12]
    return tuple(int(v) for v in np.median(sel if len(sel) else px, axis=0))

def render_panel(src, out, panel, fonts):
    im = Image.open(src).convert('RGB'); a0 = np.asarray(im); a = a0.copy()
    g = np.asarray(im.convert('L')).astype(int)
    allowed = np.zeros(g.shape, bool); jobs = []; report = []
    done = {}
    for b in panel.get('bubbles', []):
        key = tuple(b['seed'])
        comp, r = find_bubble(g, b['seed'])
        ins = fill_holes(comp)
        sig = int(np.argmax(ins))  # 同じ吹き出しを2回消さない
        if sig not in done:
            er = erode(ins, 6)
            white = np.median(a[comp & (g > 240)], axis=0).astype(np.uint8) if (comp & (g > 240)).any() else np.array([255, 255, 255], np.uint8)
            left = int(((g < 200) & ins & ~er & ~dilate(~ins, 2)).sum())
            a[er] = white; allowed |= ins; done[sig] = left
        area = ins.copy()
        if b.get('clip'):
            x0, y0, x1, y1 = b['clip']; c = np.zeros_like(area); c[y0:y1, x0:x1] = True; area &= c
        report.append(dict(kind='bubble', text=b['text'], closed_by=r, rim_dark=done[sig]))
        jobs.append(('bubble', b['text'], area, fonts['bubble'], report[-1]))
    for s in panel.get('signs', []):
        x0, y0, x1, y1 = s['box']
        col = bg_color(a0, s['box'])
        a[y0:y1, x0:x1] = col
        m = np.zeros(g.shape, bool); m[y0:y1, x0:x1] = True; allowed |= m
        ink = (245, 245, 245) if sum(col) < 3 * 128 else (25, 25, 25)
        report.append(dict(kind='sign', text=s['text'], bg=col))
        jobs.append(('sign', s['text'], m, fonts.get('sign_' + s.get('style', 'print')), (ink, report[-1])))
    img = Image.fromarray(a); d = ImageDraw.Draw(img)
    for kind, text, area, fp, extra in jobs:
        if kind == 'bubble':
            rp = extra
            f, place, kept = fit_text(d, text, fp, area)
            for x, y, l in place:
                d.text((x, y), l, font=f, fill=(15, 15, 15))
            rp.update(size=f.size, lines=[l for _, _, l in place], kept_breaks=kept)
        else:
            ink, rp = extra
            ys, xs = np.nonzero(area); w = xs.max() - xs.min() + 1; h = ys.max() - ys.min() + 1
            size = 60
            while size > 6:
                f = ImageFont.truetype(fp, size); lines = wrap(d, text, f, w - 4); lh = int(size * 1.1)
                if max(d.textlength(l, font=f) for l in lines) <= w - 4 and lh * len(lines) <= h:
                    break
                size -= 1
            top = (ys.min() + ys.max()) / 2 - lh * len(lines) / 2 - size * 0.1
            layer = img.copy(); dl = ImageDraw.Draw(layer)
            for k, l in enumerate(lines):
                dl.text(((xs.min() + xs.max()) / 2 - d.textlength(l, font=f) / 2, top + k * lh), l, font=f, fill=ink)
            cur = np.asarray(img).copy(); cur[area] = np.asarray(layer)[area]  # 字の四角の外には描かない
            img.paste(Image.fromarray(cur)); d = ImageDraw.Draw(img)
            rp.update(size=size)
    outa = np.asarray(img)
    changed = (np.abs(outa.astype(int) - a0.astype(int)).sum(2) > 0)
    outside = int((changed & ~allowed).sum())
    if outside:
        raise RuntimeError(f'吹き出しと字の外が {outside} 画素変わった')
    img.save(out)
    return report

def track_text(d, xy, text, font, track, fill):
    x, y = xy
    for ch in text:
        d.text((x, y), ch, font=font, fill=fill); x += d.textlength(ch, font=font) + track

def track_len(d, text, font, track):
    return sum(d.textlength(ch, font=font) for ch in text) + track * (len(text) - 1)

def make_cover(ja_cover, out, ep, line, fonts, art_src=None):
    """今までの英語版の表紙の形：地 #fdfdfd、左上に EPISODE N（小）、その下に大きな一言（Roboto Mono Bold・大文字）、下に絵"""
    W, H = 1080, 1350; BG = (253, 253, 253); INK = (36, 36, 36); X0 = 72; MAXW = W - X0 - 50
    c = Image.new('RGB', (W, H), BG); d = ImageDraw.Draw(c)
    fp = fonts['cover']
    fe = ImageFont.truetype(fp, 64); top_e = 68 - fe.getbbox('E')[1]
    track_text(d, (X0, top_e), f'EPISODE {ep}', fe, -1.5, INK)
    y_title = 68 + (fe.getbbox('E')[3] - fe.getbbox('E')[1]) + 34
    words = line.upper().split()
    best = None
    for size in range(127, 59, -1):
        f = ImageFont.truetype(fp, size); tr = -2 * size / 127
        lines = []; cur = ''
        for w in words:
            t = (cur + ' ' + w).strip()
            if track_len(d, t, f, tr) <= MAXW or not cur: cur = t
            else: lines.append(cur); cur = w
        lines.append(cur)
        if all(track_len(d, l, f, tr) <= MAXW for l in lines) and (len(lines) <= 2 or (len(lines) == 3 and size <= 100)):
            best = (size, f, tr, lines); break
    size, f, tr, lines = best
    cap = f.getbbox('H')[3] - f.getbbox('H')[1]; pitch = int(size * 0.97)
    for i, l in enumerate(lines):
        track_text(d, (X0 - 3, y_title + i * pitch - f.getbbox('H')[1]), l, f, tr, INK)
    text_bottom = y_title + (len(lines) - 1) * pitch + cap
    # 絵：日本語版の表紙のタイトルより下（art_src があればそれを使う）
    if art_src is not None:
        art = Image.open(art_src).convert('RGB')
    else:
        jc = Image.open(ja_cover).convert('RGB'); jg = np.asarray(jc.convert('L'))
        rows = (jg[:420] < 120).any(1); ys = np.nonzero(rows)[0]
        # タイトルの字のかたまり（上から、間が 30 行より広く空くまで）
        last = ys[0]
        for yy in ys[1:]:
            if yy - last > 30: break
            last = yy
        art = jc.crop((0, last + 30, W, H))
    aa = np.asarray(art).astype(np.int16); aa[aa.min(2) > 232] = 253; art = Image.fromarray(aa.astype(np.uint8))
    ag = np.asarray(art.convert('L')) < 235; ys = np.nonzero(ag.any(1))[0]; xs = np.nonzero(ag.any(0))[0]
    art = art.crop((xs.min(), ys.min(), xs.max() + 1, ys.max() + 1))
    top = text_bottom + 56; A = H - top - 24
    s = min(A / art.height, (W - 40) / art.width, 1.45)
    art = art.resize((round(art.width * s), round(art.height * s)), Image.LANCZOS)
    c.paste(art, ((W - art.width) // 2, top + (A - art.height)))
    c.save(out)
    return dict(lines=lines, size=size, art_scale=round(s, 3))

if __name__ == '__main__':
    spec = json.load(open(sys.argv[1])); src, outd, fdir = sys.argv[2:5]
    fonts = dict(bubble=font_path(fdir, 'ComicNeue-Bold.ttf', 'comic-neue-latin-700-normal.woff'),
                 sign_print=font_path(fdir, 'Inter-SemiBold.ttf', 'inter-latin-600-normal.woff'),
                 sign_hand=font_path(fdir, 'ComicNeue-Bold.ttf', 'comic-neue-latin-700-normal.woff'),
                 cover=font_path(fdir, 'RobotoMono-700.woff', 'RobotoMono-Bold.ttf'))
    ep = spec['ep']; os.makedirs(outd, exist_ok=True); rep = {}
    for i, p in enumerate(spec['panels']):
        s = os.path.join(src, f'ep{ep}_p{i + 1}.png')
        if not os.path.exists(s): s = s[:-4] + '.webp'
        rep[f'P{i + 1}'] = render_panel(s, os.path.join(outd, f'ep{ep}_p{i + 1}_EN.png'), p, fonts)
    if '--cover' in sys.argv:
        jc = sys.argv[sys.argv.index('--cover') + 1]
        art = sys.argv[sys.argv.index('--art') + 1] if '--art' in sys.argv else None
        rep['cover'] = make_cover(jc, os.path.join(outd, f'ep{ep}_cover_EN.png'), ep, spec['cover_line'], fonts, art)
    print(json.dumps(rep, ensure_ascii=False))
```

### README.md（道具/英語版）

英語版の道具のフォルダに置いた使い方と決まりです。

```markdown
# 縦長4コマの英語版の作り方（2026-10-31）

公開中の日本語版のコマ（R2 の webp）から、吹き出しの日本語を消して英語を組む。絵は描き直さないので費用は0円。

- `en_manga.py`：作るスクリプト。`python3 en_manga.py spec/epNN.json ja out fonts --cover ja/epNN_cover.webp`
- `spec/epNN.json`：話ごとの指示（吹き出しの白い内側の1点 seed と英語、つながった吹き出しの clip、名札・画面などの字 signs と英語、表紙の一言 cover_line）
- `specs_min.json`：第73〜132話の spec をまとめたもの（spec/ はここから書き出した）
- `ja/`：日本語版のコマと表紙（pixelstomotors.com/media から取ったもの）／`out/`：英語版の PNG／`media/`：R2 に入れた webp
- `fonts/`：吹き出し Comic Neue Bold（今までの英語版マンガと同じ）、表紙 Roboto Mono Bold（今までの英語版の表紙と同じ）、画面などの字 Inter SemiBold

決まり
- 吹き出しの英語はふつうの文の書き方（文頭だけ大文字）。改行は意味の切れ目
- 表紙は「EPISODE N」と、その話の印象に残るセリフ（大文字）を Roboto Mono で上に置き、下に日本語版の表紙の絵（字の入った絵なら英語にしたコマ）
- 擬音（ジリリリ・カラン など）はそのまま
- 吹き出しと字の外の画素が1つでも変わったら、スクリプトが止まる
- 画像の名前は `manga_epNNen_p1〜p4.webp`・`manga_epNNen_cover.webp`。直すときは名前を変える（_v2）。R2 は同じ名前の上書きをしない
- ページは p2m-site の `src/data/english/epNN.json` と `src/data/englishCards.json`
- 第133話だけは先に `縦長/第133話/英語版/` で作った
```

### detect.py（吹き出しの候補を探す）

白くて縁で閉じていて、中に字のある領域を、吹き出しの候補として赤い枠と番号で示します。

```python
# 吹き出しを探す：縁の線で閉じた白い領域のうち、中に文字の暗い画素を持つもの
import sys, json, glob, numpy as np
from scipy import ndimage as ndi
from PIL import Image, ImageDraw, ImageFont
S=sys.argv[1]
out={}
font=ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",56) if glob.glob("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf") else ImageFont.load_default()
for f in sorted(glob.glob(f"{S}/ja/ep*_p[1-4].webp")):
    key=f.split('/')[-1][:-5]
    im=Image.open(f).convert('RGB'); g=np.asarray(im.convert('L')).astype(int)
    white=g>230
    lab,n=ndi.label(white)
    border=set(np.unique(np.concatenate([lab[0],lab[-1],lab[:,0],lab[:,-1]])))
    sizes=ndi.sum(white,lab,range(1,n+1))
    cands=[]
    for i in range(1,n+1):
        if i in border or sizes[i-1]<4000: continue
        comp=lab==i
        filled=ndi.binary_fill_holes(comp)
        holes=filled&~comp
        dark=(g<120)&holes
        nd=int(dark.sum())
        if nd<60: continue
        ys,xs=np.nonzero(filled)
        # 文字らしさ：穴の中の暗い画素が多数の小さなかたまり
        dl,dn=ndi.label(dark)
        if dn<3: continue
        # 内側の点：距離変換の最大
        dt=ndi.distance_transform_edt(comp)
        y,x=np.unravel_index(np.argmax(dt),dt.shape)
        cands.append(dict(seed=[int(x),int(y)],bbox=[int(xs.min()),int(ys.min()),int(xs.max()),int(ys.max())],area=int(filled.sum()),dark=nd,blobs=int(dn)))
    cands.sort(key=lambda c:(round(c['bbox'][1]/150),c['bbox'][0]))
    out[key]=cands
    d=ImageDraw.Draw(im)
    for k,c in enumerate(cands):
        x0,y0,x1,y1=c['bbox']; d.rectangle([x0,y0,x1,y1],outline=(255,0,0),width=6)
        d.text((x0+8,y0+4),str(k+1),font=font,fill=(255,0,0))
    im.save(f"{S}/ja/{key}_det.png")
json.dump(out,open(f"{S}/det.json","w"),indent=0)
# 話ごとの確認シート（2×2）
for ep in sorted({k.split('_')[0] for k in out}):
    sh=Image.new('RGB',(1100,1370),'white')
    for p in range(4):
        t=Image.open(f"{S}/ja/{ep}_p{p+1}_det.png").resize((540,675))
        sh.paste(t,((p%2)*550,(p//2)*685))
    sh.save(f"{S}/ja/{ep}_sheet.png")
print({k:len(v) for k,v in out.items()})
```

### filt.py（候補をしぼる）

候補のうち、形が楕円に近く、中に大きな字のかたまりが2つ以上あるものだけを残します。脚本の吹き出しの数と合わない話を書き出します。

```python
import json,numpy as np,sys
from scipy import ndimage as ndi
from scipy.spatial import ConvexHull
from PIL import Image, ImageDraw, ImageFont
det=json.load(open('det.json')); ja=json.load(open('bubbles_ja.json'))
font=ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",60)
res={}; bad=[]
for key,cs in det.items():
    g=np.asarray(Image.open(f'ja/{key}.webp').convert('L')).astype(int); lab,_=ndi.label(g>230)
    keep=[]
    for c in cs:
        i=lab[c['seed'][1],c['seed'][0]]; comp=lab==i; filled=ndi.binary_fill_holes(comp)
        ys,xs=np.nonzero(filled); hull=ConvexHull(np.c_[xs,ys][::7]); sol=filled.sum()/hull.volume
        bw=c['bbox'][2]-c['bbox'][0]; bh=c['bbox'][3]-c['bbox'][1]; ell=filled.sum()/(np.pi*bw*bh/4)
        c.update(sol=round(sol,2),ell=round(ell,2))
        holes=filled&~comp; dk=(g<120)&holes; dl,dn=ndi.label(dk)
        szs=ndi.sum(dk,dl,range(1,dn+1)) if dn else []
        big=int((np.array(szs)>=40).sum()) if dn else 0
        tiny=float(np.array(szs)[np.array(szs)<12].sum()/max(1,dk.sum())) if dn else 1
        c.update(big=big,tiny=round(tiny,2))
        if sol>=0.80 and ell>=0.65 and c['dark']>=250 and big>=2 and tiny<0.25: keep.append(c)
    keep.sort(key=lambda c:(c['bbox'][1]//120, c['bbox'][0]))
    res[key]=keep
    ep,p=key.split('_'); n=ep[2:]; exp=len(ja[n]['panels'][int(p[1])-1])
    if len(keep)!=exp: bad.append((key,len(keep),exp))
    im=Image.open(f'ja/{key}.webp').convert('RGB'); d=ImageDraw.Draw(im)
    for k,c in enumerate(keep):
        x0,y0,x1,y1=c['bbox']; d.rectangle([x0,y0,x1,y1],outline=(255,0,0),width=7); d.text((x0+10,y0+4),str(k+1),font=font,fill=(255,0,0))
    im.save(f'ja/{key}_det.png')
json.dump(res,open('bub.json','w'),indent=0)
for ep in sorted({k.split('_')[0] for k in res}):
    sh=Image.new('RGB',(1100,1370),'white')
    for p in range(4):
        sh.paste(Image.open(f"ja/{ep}_p{p+1}_det.png").resize((540,675)),((p%2)*550,(p//2)*685))
    sh.save(f"ja/{ep}_sheet.png")
print('mismatch',bad)
```

### AGENT_TASK.md（spec を書く作業役への指示）

28話を7話ずつ4人の作業役に分けて渡した指示です。

````markdown
# 英語版4コマの下ごしらえ（1話ずつ spec を作る）

Pixels to Motors の4コママンガ「ハコブくん」（病院の薬剤搬送ロボットと看護師・医師の話）の日本語版（縦長4コマ・1コマ1枚 1080×1350）から、英語版を作る。
吹き出しの日本語は別のスクリプトが消して英語を組む。あなたの仕事は、そのための指示（spec）を1話1ファイルの JSON で書くこと。

作業フォルダ: /tmp/claude-0/b5fb089a-2072-58da-b9f4-edd7b19154c4/scratchpad/en/

## 見るもの（1話 = epNN）
- `ja/epNN_sheet.png`：4コマを 2×2 に並べた確認用（左上P1・右上P2・左下P3・右下P4）。赤い枠と番号は、スクリプトが「吹き出しかもしれない」と見つけた場所。**本物の吹き出し以外も混ざっている**（顔・ロボットの胴の白い部分など）。逆に、見つかっていない吹き出しもある
- `ja/epNN_pK_det.png`：コマ K の原寸（1080×1350）に同じ枠と番号を描いたもの。座標はこの画像の画素で書く
- `ja/epNN_pK.webp`：枠のないコマ（字を読むとき用）
- `ja/epNN.json`：日本語版のページ（title・description・panel_alts・body）
- `bubbles_ja.json`：脚本上のセリフ（話者つき）。**絵の中の吹き出しと完全には一致しない**（小窓の中の吹き出しなどが抜けている）。字は必ず絵で確かめる
- `bub.json`：見つけた候補（キー `epNN_pK` の配列。番号 n は配列の n-1 番目。seed は候補の内側の1点）

Read ツールで画像を開いて、1コマずつよく見ること。

## 書くもの：`spec/epNN.json`

```json
{
  "ep": 95,
  "title": "英語のページタイトル",
  "description": "英語の説明（170字以内）",
  "cover_line": "COVER LINE IN CAPS",
  "panels": [
    {"bubbles": [ {"seed": [x, y], "text": "English line\nsecond line", "ja": "絵の中の日本語"} ],
     "signs":   [ {"box": [x0, y0, x1, y1], "text": "English", "ja": "絵の中の日本語"} ]},
    {"bubbles": [], "signs": []},
    {"bubbles": [], "signs": []},
    {"bubbles": [], "signs": []}
  ],
  "panel_alts": ["P1 の英語の代替テキスト", "P2", "P3", "P4"],
  "body": "<p>■ Heading<br>Paragraph...</p><p></p><p>■ Heading<br>...</p><p></p><p>■ Conclusion: ...<br>...</p>",
  "notes": "気になったこと（なければ空）"
}
```

### bubbles（吹き出しの中のセリフ）
- 絵の中の**すべての吹き出し**を1つずつ書く（小窓・コマの隅の小さい吹き出しも）。音を絵の上に直接描いた擬音（ピロピロ・バチン など、吹き出しの外の字）は書かない
- `seed`：その吹き出しの**白い内側の1点**（字の上ではなく、字と縁の間の白いところ）。候補の番号が本物の吹き出しなら、`bub.json` のその候補の seed をそのまま使ってよい。見つかっていない吹き出しは、原寸の画像を見て白いところの座標を自分で書く
- 2つの吹き出しが線でつながっていて1つの白い領域になっているときは、それぞれに `"clip": [x0, y0, x1, y1]`（その吹き出しの範囲）を付ける。seed は同じ白い領域の点でよい
- `ja`：絵の中の日本語をそのまま写す（確認用）
- `text`：英語。
  - ふつうの文の書き方（文頭だけ大文字。全部大文字にしない）
  - 改行は `\n` で、意味の切れ目で入れる。横長の吹き出しは1行 14〜22 字程度、縦長で細い吹き出し（日本語が縦書き）は1行 6〜10 字程度にする
  - 日本語より長くしない。吹き出しに収まる短さを最優先にする
  - ハコブくんのカタカナのセリフ（ロボットの話し方）も、ふつうの英語の文にする（全部大文字にしない）。丁寧で少し機械的な言い方でよい
  - 人の名前：大城さん=Oshiro、寺島さん=Terashima、高杉さん=Takasugi、白石さん=Shiraishi、槙原さん=Makihara、小芝先生=Dr. Koshiba、田中先生=Dr. Tanaka、森田さん=Mrs./Mr. Morita、林さん=Ms. Hayashi、中村さん=Mr. Nakamura、千夏さん=Chinatsu、ハコブくん=Hakobukun、ピカリくん=Pikarikun、ツカムくん=Tsukamukun
  - 「…」は「...」ではなく「…」を使ってよい。『』は “ ” にする
  - 例（第133話）：「ロボットの準備は、もう終わってます！」→ "The robot is all set\nand ready to go!"／「ワタシハ、イツデモ走レマス」→ "I am ready to roll.\nAny time."／「ロボットを運ぶ前に、みんなの都合を運んでたのね」→ "So before we moved\nthe robot, we had to\nmove everyone."

### signs（吹き出しの外の、絵に描かれた日本語）
- 名札・張り紙・画面の文字・ホワイトボード・箱のラベル など、**吹き出しの外にある日本語の字**を全部書く（擬音は除く）
- `box`：その日本語の字だけを囲む四角（原寸の画素。字の周りに 4〜6px の余白）。四角の中に字以外の絵（人・線・縁）が入らないようにする。入ってしまう場合は notes に書く
- `text`：英語（短く。名札は "Dr. Koshiba" や "Resident Koshiba" のように）

### title / description / cover_line / panel_alts / body
- title：今までの英語版のタイトルの書き方（教訓を言い切る短い英語）。例: "Say What Still Works Before What Broke"、"Keeping Panic from Becoming the Way In"、"Two Months to Collect Six Stamps"
- description：何が起きて、何の話かを英語で1〜2文（170字以内）。例: "An evening notice urges an immediate robot update. Hakobukun refuses: no signature. The sender is one character off—a manga on update signature verification."
- cover_line：表紙に大きく入れる、この話の印象に残るセリフ1つ（英語・全部大文字・30字前後まで。長くても45字）。例: "PLEASE UPDATE RIGHT NOW"、"CURRENTLY EXPERIENCING A SYSTEM FAILURE"
- panel_alts：4コマそれぞれの英語の代替テキスト（日本語版の panel_alts を英語に。誰が何をしているか1文）
- body：日本語版の body を英語に。構成（■ 見出し<br>本文、段落の間の <p></p>、最後の「■ 結論：」→ "■ Conclusion: "）を保つ。直訳ではなく自然な英語に。日本語の制度の名前などは英語圏の人に分かる言い方に（例：技適マーク → Japan's radio certification mark (Giteki)）

## 終わったら
担当の話の spec を全部書いたら、それぞれのファイル名と、notes に書いたこと（気になった点）を短く報告する。画像を作ったり、ほかのファイルを変えたりはしない。
````

### spec を書いた作業役の報告（ 第73〜97話）

作業役の報告です。

```
All 7 spec files are written, and each one loads with `python3 -c "import json;json.load(...)"` without errors. I opened every panel at full size. Every bubble has its own entry, and every seed point checks as white (a flood-fill from it stays inside its bubble). All descriptions are 170 characters or less.

Files are in /tmp/claude-0/b5fb089a-2072-58da-b9f4-edd7b19154c4/scratchpad/en/spec/:
- ep73.json
- ep76.json
- ep93.json
- ep94.json
- ep95.json
- ep96.json
- ep97.json

Points from the notes:

**Bubbles that needed a `clip`**
- **ep93 P1:** the bubble outline is dashed (a whisper), so its white inside runs into the page background. I set clip [264,34,824,356] around the bubble.
- **ep94 P3:** Hakobukun's three questions are one cloud with three stacked parts and one tail, so the white area is shared. I gave each part its own clip, split at about y 205 and y 385.
- **ep95 P2:** the bubbles "サイズハ、M…" and "10枚デ…" are joined through the tail into one white area. I gave each a clip, split at about y 316.

**Signs**
- **ep73 P3:** I boxed the 4 map labels (受付, 外来, 検査室, 売店). The fingertip touches the bottom of 検査室, so a bit of finger sits inside that box.
- **ep76 P3:** I boxed 到着＝完了 on the laptop screen. The text slants with the screen, and the box's right edge comes close to the screen's slanted edge.
- **Name tags (ep93, ep94 P4, ep95 P2, ep96 P3, ep97 P3/P4):** I wrote these as "Resident\nKoshiba" (P4 of ep93 also has "Dr. Tanaka").
  - The tags are tiny and the drawn characters are garbled, and the box includes a little of the card's frame.
  - ep95 P2 is clearly readable, but the art says 小之優介 instead of 小芝優介.
  - In ep93 P4, the clipboard edge passes close to Dr. Tanaka's tag.
  - Name tags on the other panels are blank.

**Other**
- Narrow vertical bubbles (ep94 P2, ep95 P1, ep96 P4) have short English lines.
- ep96 P1 is a thought bubble; I left its small trailing circles alone.
- ep76 P2 has no text.
- The detector's boxes on faces, the robot's side panel and the drawer front are not bubbles. I left them out.
```

### spec を書いた作業役の報告（ 第99〜114話）

作業役の報告です。

```
I wrote all seven specs. Each one loads with `json.load`, every description is 170 characters or less, and every seed is in clean white inside its bubble.

**Files** (all in `/tmp/claude-0/b5fb089a-2072-58da-b9f4-edd7b19154c4/scratchpad/en/spec/`)
- `ep99.json` "Decide Which Movements Call a Nurse"
- `ep100.json` "At Night, Carry with the Lights Down"
- `ep101.json` "Help Without Touching the Body"
- `ep109.json` "Hand It Over Early and Slowly"
- `ep111.json` "Sit Down So You Don't Fall Down"
- `ep113.json` "Take Off the Assist a Little at a Time"
- `ep114.json` "Watching Over Without Turning On the Lights"

**Main points from the notes**
- **Bubbles the detector missed, added by hand:**
  - ep101 P3: Hakobukun's line "デハ、ワタシハ何ヲ運ベバ".
  - ep109 P1: Oshiro's small vertical bubble "わっ".
- **Small-window lines missing from `bubbles_ja.json`:** these are the operator's lines, and I added all of them.
  - ep109 P1 and P3.
  - ep111 P1.
  - ep113 P1, P3 and P4. The ep113 P3 bubble's tail is cut off by the window's frame.
- **One merged bubble:** in ep109 P3, Hakobukun's two lines share one white area. Each line has a `clip`, split at about y=254.
- **Text outside bubbles (signs):**
  - ep99 P2: the three notifications on the monitor. They are tilted, so each box is drawn tight around the text and its corners slightly touch the pill's outline.
  - ep101 P4: Dr. Koshiba's name badge, "研修医 小芝硬介" → "Resident Koshiba". It is very small, about 40×20 px.
  - ep111 P1: "回線切断" on the controller screen.
  - ep113 P1–P4: the controller screen's "補助：強／なし／中", which appears four times. All of these are tiny. The smallest, ep113 P4, is about 31×8 px, and the screen's frame sits right at the edge of its box.
- **Skipped:** the sound effect "カランカラン" (ep113 P2). Every other name badge is blank, so none of them are listed.
- **Shapes the script should handle:** the bubbles in ep100 P4 and ep114 P3 have dashed outlines. Their white areas are still closed, so their seeds work.
- **No text at all:** ep101 P1, ep111 P2, ep114 P1 and ep114 P2.
- **Not bubbles:** every detector candidate that falls on a robot's body or a face, plus the window frame in ep113 P2-1.
```

### spec を書いた作業役の報告（ 第115〜125話）

作業役の報告です。

```
7話分の spec を作りました。7ファイルとも `python3 -c "import json;json.load(open(...))"` で JSON として読めることを確かめています。各コマは原寸の画像（_det.png と .webp）を開いて読みました。小さい字は切り出して拡大して読み、seed はすべて吹き出しの白い内側に入っていることを画素で確かめました。

**作ったファイル**（`/tmp/claude-0/b5fb089a-2072-58da-b9f4-edd7b19154c4/scratchpad/en/spec/` の下）
- ep115.json
- ep116.json
- ep117.json
- ep118.json
- ep123.json
- ep124.json
- ep125.json

**notes の要点**
- **脚本にない吹き出しを追加：** 小窓の中の操縦者のセリフで、`bubbles_ja.json` に入っていないものが4つありました。絵から読んで足しています。
  - ep116 P1「0.3秒が毎回はさまる…」
  - ep116 P2「先行入力、いける」
  - ep123 P1「少し離席しまーす」
  - ep123 P4「私、動かしてないです…」
- **つながった吹き出し：** ep123 P4 の白石さんと大城さんの吹き出しは、線でつながって1つの白い領域になっています。それぞれに `clip` を付けました。
- **吹き出しの外の字（signs）：**
  - ep124 P3 の壁の張り紙「麻薬は看護師が手で運ぶこと」。下の端に P3 のハコブくんの吹き出しが重なっていますが、四角は張り紙の字だけを囲んでいます。
  - ep124 P4 のポケットの箱のラベル「注射」→ "Inj."。字がラベルの枠いっぱいに書かれていて、四角はほぼ余白なしの枠の内側です。消すときに枠の線に触れないか、見てください。
  - ほかの話の名札・電話・図面・ベルの札などは、白紙か読めない線だけでした。
- **日本語版の文と絵が合わないところ：** 英語の代替テキストと本文は日本語版に合わせています。ep115 だけは、代替テキストを絵に合わせました。
  - ep115 P4：日本語の代替テキストにあるコントローラーが、絵に描かれていません。
  - ep118：床の線は白黒の縞で、「黄色」には見えません。
  - ep124 P2：画面は灰色で、「赤い枠」には見えません。
  - ep125 P4：斜線は黒で、「赤い斜線」には見えません。英語の代替テキストは "hatching" とだけ書きました。
- **吹き出しではない候補：** ロボットの胴・側面・顔・タオル・小窓の枠などの誤検出は、すべて外しました。擬音（ジリリリ・ギィィィ…・コツン・バタン）も書いていません。
```

### spec を書いた作業役の報告（ 第126〜132話）

作業役の報告です。

```
7話分の spec を作り、7つとも JSON として正しく読めることを `json.load` で確かめました。全コマを原寸の画像（`_det.png` と `.webp`）で見ています。吹き出しの seed は、白い領域を塗りつぶしてみて、その吹き出しの中だけが塗られ、外へ漏れないことを1つずつ確かめました。

**作ったファイル**
- /tmp/claude-0/b5fb089a-2072-58da-b9f4-edd7b19154c4/scratchpad/en/spec/ep126.json
- /tmp/claude-0/b5fb089a-2072-58da-b9f4-edd7b19154c4/scratchpad/en/spec/ep127.json
- /tmp/claude-0/b5fb089a-2072-58da-b9f4-edd7b19154c4/scratchpad/en/spec/ep128.json
- /tmp/claude-0/b5fb089a-2072-58da-b9f4-edd7b19154c4/scratchpad/en/spec/ep129.json
- /tmp/claude-0/b5fb089a-2072-58da-b9f4-edd7b19154c4/scratchpad/en/spec/ep130.json
- /tmp/claude-0/b5fb089a-2072-58da-b9f4-edd7b19154c4/scratchpad/en/spec/ep131.json
- /tmp/claude-0/b5fb089a-2072-58da-b9f4-edd7b19154c4/scratchpad/en/spec/ep132.json

**notes の要点**
- **ep126**：吹き出しの外の字は P2 の「運転休止」だけです。黒い板に白い字なので、box は板の内側に収めました。非常ベルの下の板と名札には字がありません。
- **ep127**：P4 のハコブくんの吹き出しは候補に出ていなかったので、seed [280,225] を自分で置きました。日本語版の panel_alts P4 は「画面に譲る項目が表示されている」と書いていますが、絵の画面には顔しかありません。英語の alt は、その表示に触れない書き方にしました。
- **ep128**：P2 には吹き出しがありません。P4 の申請書の字はつぶれて読めないので、signs に入れていません。
- **ep129**：P3 は日本語4行の長いセリフなので、英語も4行にしました。
- **ep130**：P3 のツカムくんの横の「…」は、吹き出しの外に浮いた点なので拾っていません。80W・特別教育は日本の決まりなので、本文に "In Japan" を足しました。
- **ep131**：P4 の「止めてから近づく」は候補では吹き出しとして出ていましたが、壁の張り紙なので signs に入れました（box [150,154,461,216]）。日本語版の body は「画面に『トメテカラ』と出た」ですが、絵では吹き出しなので、英語の body は "Tsukamukun said" にしました。
- **ep132**：P1 の吹き出しはとても横長なので、英語を2行・1行35字前後にしました。指示の「1行14〜22字」を超えているので、組んだときに収まるかを見てください。ノートPCのステッカーとクリップボードには、読める字がありません。

どの話でも、擬音（ジリリリリ・スッ・ソロソロ・チラッ・ガガガ・バチン）は書いていません。顔やロボットの胴の白い部分など、吹き出しでない候補も使っていません。
```

### QA_TASK.md（確かめる作業役への指示）

spec を書いた作業役とは別の4人に渡した指示です。

```markdown
# 英語版4コマの確認（チェック①）と直し

作業フォルダ: /tmp/claude-0/b5fb089a-2072-58da-b9f4-edd7b19154c4/scratchpad/en/

日本語版の縦長4コマ（1コマ 1080×1350）から、スクリプト `en_manga.py` が英語版を作った。あなたは、作った人とは別の目で1枚ずつ確かめ、問題があれば spec を直して作り直す。

## 見るもの（1話 = epNN）
- 日本語版：`ja/epNN_p1.webp`〜`p4.webp`、表紙 `ja/epNN_cover.webp`
- 英語版：`out/epNN_p1_EN.png`〜`p4_EN.png`、表紙 `out/epNN_cover_EN.png`
- 指示：`spec/epNN.json`（書き方は `AGENT_TASK.md`）、作ったときの記録 `out/epNN.log`（各吹き出しの字の大きさ size など）

**日本語版と英語版を、コマごとに必ず両方開いて見比べる**（Read ツール。小さい字は Python で切り出して拡大して見る）。

## 確かめること（コマごとに全部）
1. 吹き出しの中に日本語が残っていない。消し残しの点や線がない
2. 英語が吹き出しの中に収まり、縁の線に触れていない。行の並びが自然（1語だけの行、変な所での改行がない）
3. 英語の大きさが読める（記録の size が 26 未満なら小さすぎ。改行を変える・言い方を短くして大きくする）。同じコマの吹き出しどうしで大きさが極端に違わない
4. どの吹き出しのセリフも、日本語版の同じ吹き出しと同じ話者・同じ意味（取り違えがない）。英語として自然で、英語圏の人に意味が通じる
5. 吹き出しの外の日本語（名札・画面・張り紙・ホワイトボード）が英語になっている。消した四角が絵から浮いていない（色・大きさ）。擬音（ジリリリ・スッ など）はそのままでよい
6. 吹き出しや字の外の絵が、日本語版から変わっていない（スクリプトが確かめているが、目でも見る）
7. 表紙：上に EPISODE NN と大きな一言（Roboto Mono・大文字）、下に絵。一言がこの話の印象に残るセリフになっているか、絵とのつり合い

## 直し方
- spec の text（改行 `\n`・言い方）、seed、clip、signs の box を直して、次で作り直す：
  `cd /tmp/claude-0/b5fb089a-2072-58da-b9f4-edd7b19154c4/scratchpad/en && python3 en_manga.py spec/epNN.json ja out fonts --cover ja/epNN_cover.webp > out/epNN.log 2>&1`
- 作り直したら、もう一度そのコマを見る
- `en_manga.py` は変えない。スクリプトで直せない問題（例：吹き出しの形が特殊で組めない）は、直さずに報告する

## 終わったら
話ごとに「問題なし」か「直したこと」「直せなかったこと」を短く報告する。
```

### 確かめた作業役の報告（第73〜97話）

作業役の報告です。

```
7話とも1コマずつ日本語版と見比べて確かめ、6話で spec を直して作り直しました。全話エラーなく作れ、吹き出しの字はいちばん小さいもので 30（ep94 の "Understood."）なので、基準の 26 を下回るものはありません。どの話も、吹き出しの中に日本語の消し残しはなく、セリフと話者の取り違えも、絵の変化もありませんでした。

**ep73**：直しました
- P3 の地図の「受付」「外来」の英語が 12〜13 と小さすぎたので、字を入れる四角を部屋の線の内側いっぱいまで広げました。
- 「受付」は "Reception" から2行の "Front\ndesk" に変え、字は 30、「外来」の "Outpatient" は 26 になりました。

**ep76**：問題なし

**ep93**：直しました
- P2 の「ワタシハ搬送ロボットデス」が "robot." だけの行になっていたので、"I am a\ntransport robot." に変えました。
- 表紙の一言は3行目が "DICE" だけになっていたので、"DON'T MAKE A ROBOT ROLL DICE" にして2行に収めました。
- 名札4つ（P2・P3・P4）は、白い四角が名札の縁の線まで消していたので、四角を縁の内側に狭めました。

**ep94**：直しました
- P2 の「了解シマシタ」を "Under-/stood." の分かち書きから "Understood."（1行・30）にしました。
- P2 の「…練習にならない」は "for" だけの行があったので、"…Some\npractice\nthis is."（37）に変えました。本文の同じセリフも合わせて直しています。
- P3 は "thing?" だけの行をなくすため "Is that\na bad thing?" にしました。
- P4 の名札の四角を縁の内側に狭めました。

**ep95**：直しました
- P4 の「次カラハ、何ガ必要カ、先ニ聞キマス」が "first." だけの行になっていたので、"Next time, I will ask\nwhat you need first." に変えました（36→42）。本文のセリフも合わせています。
- P2 の名札の四角を縁の内側に狭めました。

**ep96**：直しました
- P4 の「訂正シマス。先週ノ先生ヨリ、2件少ナイデス」が5行で、"week." だけの行があったので、"Correction.\n2 fewer than\nyou had\nlast week." の4行にしました（38→39）。
- P3 の名札の四角が縁の上と右を消していたので、狭めました。

**ep97**：直しました
- P3・P4 の名札の四角を縁の内側に狭めました。セリフは問題なしです。

直せなかったことはありません。ただ、ep93・ep94・ep97 の名札はもともと絵の中でとても小さく、英語は 7〜11 の大きさにしかなりません。日本語版でも読めない大きさなので、そのままにしています。

見比べに使った画像は /tmp/claude-0/b5fb089a-2072-58da-b9f4-edd7b19154c4/scratchpad/qa/ にあります。作り直した英語版は /tmp/claude-0/b5fb089a-2072-58da-b9f4-edd7b19154c4/scratchpad/en/out/、直した spec は /tmp/claude-0/b5fb089a-2072-58da-b9f4-edd7b19154c4/scratchpad/en/spec/ です。
```

### 確かめた作業役の報告（第99〜114話）

作業役の報告です。

```
7話とも、日本語版と英語版の全コマと表紙を並べて見比べました。直したのは spec の text だけで、`en_manga.py` は変えていません。作り直した ep99・101・109・111・113 は5話とも途中で止まらずに出力でき、直したコマはもう一度目で見ました。吹き出しの外の絵が変わっていないことは、スクリプトの確認でも目でも確かめました。

| 話 | 結果 |
|---|---|
| ep99 | **直した**。P2 の画面の3つの通知は、字を消す四角の端に日本語の画の先が残っていた。通知の枠を吹き出しとして扱うよう spec を変え、日本語はきれいに消えた。文は1行の "Mr. Nakamura turned over once / twice"、"Mr. Nakamura coughed once" にした（size 30〜34）。 |
| ep100 | 問題なし |
| ep101 | **直した**。P2 の看護師のセリフで "Wait," が1語だけの行になっていたので "Wait! You / can't do that!" に変えた。 |
| ep109 | **直した**。P1 の小窓の操作者のセリフが size 20 だったので、短くして "Lag…! 0.3s off!"（26）にした。P3 の小窓は "…Oh, right."（27）にした。 |
| ep111 | **直した**。P1 の小窓は "Huh? It / dropped?"（19、改行も不自然）を "Huh? Lost it?"（22）に、画面の字は "Disconnected"（6、読めない）を "Offline"（12）にした。P4 は改行を整え、ピカリくんのセリフを "Sorry, I lost / the connection."、ハコブくんのセリフを "Had you stopped / standing up, you / would have fallen." にした。 |
| ep113 | **直した**。P1 の小窓は "So slow. / Assist off."（24）、P3 の小窓は "…No. Half / for now."（26）、P4 の小窓は "Mid till / I rank up."（15→16）にした。P3 のハコブくんのセリフは "it" が行末に残らない改行にした。画面の字は P1 を "High"（7→13）、P4 を "Med"（6→8）にした。 |
| ep114 | 問題なし |

**直せなかったこと（どれも絵の中の場所そのものが小さいため）**
- size 26 未満のものが残っています。
  - 小窓の吹き出し：ep111 P1（22）、ep113 P1（24）、ep113 P4（16）。日本語版も字の高さが 12〜21px と小さく、言い方を短くしてもこれ以上は大きくできません。
  - ep109 P1 の Oshiro の「わっ」→ "Whoa!"（25）。細い縦の吹き出しで、これ以上大きくできません。
- 画面・名札の字は小さいままです。ep101 の名札 "Resident Koshiba" は 9（日本語版も約 10px）、ep113 P3 の画面の "Assist: Off" は 10、ep113 P4 の "Med" は 8 です。名札は "Koshiba" だけにしても大きさは同じでした。
- ep113 の "High" と "Med" は、"Assist:" を外して強さの段階だけを表示しています。これで "Assist:" が入っているのは P2・P3 の "Assist: Off" だけになり、画面の書き方がそろっていません。お話の中で補助の強さは伝わりますが、そろえるなら P1・P4 にも "Assist:" を戻すことになり、字は 6〜8 に小さくなります。

直す前の spec の控えは /tmp/claude-0/-home-claude/b5fb089a-2072-58da-b9f4-edd7b19154c4/scratchpad/qa7/ に置いてあります（ep99/101/109/111/113.json）。

ほかの作業が使っている共有の確認用フォルダ scratchpad/qa/ の中で、私が作った確認用のスクリプトが途中で別のものに上書きされていました。そのため、以降の確認には自分専用のフォルダ qa7/ を使いました。
```

### 確かめた作業役の報告（第115〜125話）

作業役の報告です。

```
I checked all 7 episodes panel by panel against the Japanese versions, fixed the specs and rebuilt six of them. ep125 needed no changes. Every rebuild finished without errors, and I looked again at every panel I changed.

**ep115**: fixed
- P3: the last line was a single word ("know."). It now reads "…The whole / ward heard / all of that." at size 44.
- Everything else was fine.

**ep116**: fixed
- P1 (small panel): the text was size 16, so I changed it to "Always a / 0.3s lag…" (size 26).
- P2 (small panel): size 24 became 27 with "Buffer inputs. / That works."

**ep117**: fixed
- P4: the two bubbles were 35 and 44. I split the left one into three lines so it is 39.
- Cover: the old line ("IS THAT A CAMERA? ARE YOU RECORDING?") didn't fit the bedside picture, where the robot has turned away. I changed it to the P4 line "YOU CAN TURN AROUND NOW", which now fits on two lines at size 127.

**ep118**: fixed
- P3: the lines broke awkwardly, so it is now "This body can't / go past this line." (size 44).
- P2: "Half a day here by train" was unnatural English. It is now "Half a day away / by train."
- Cover: the last line was the single word "LINE". With "THIS BODY CAN'T GO PAST THE LINE" it fits on two lines.

**ep123**: fixed
- P1 small panel: size 22 became 27 with "Be right back~".
- P4 small panel: size 21 became 27 with "It wasn't me…".
- P3: "on" sat at the end of the first line and "its own!" was left on its own line. It is now "Shiraishi, Pikarikun is / walking on its own!"
- The cover is unchanged.

**ep124**: fixed
- P4 label on the box (注射 → "Inj."): the old box also erased part of the label's slanted frame. A tighter box keeps the frame. The text is now size 20, which is fine for a sign this small.
- P3: reworded to "Huh? But I just got it / from the pharmacy safe…" (size 33).
- P4 right bubble: split into three lines so it is size 39 next to the other bubble's 40.

**ep125**: no problems found.

What I couldn't fix:
- ep124 P3 bubble (size 33): the Japanese line is long, so it stays smaller than the bubble below it (44). The gap isn't extreme.
- ep123 P2 (size 34): the bubble is tall and narrow, so the text can't get bigger.

None of the sizes are below 26 except the ep124 label on the box. No Japanese is left in any bubble, and none of the drawing outside the bubbles and signs changed. `en_manga.py` was not modified.

I only edited the spec files `/tmp/claude-0/b5fb089a-2072-58da-b9f4-edd7b19154c4/scratchpad/en/spec/ep{115,116,117,118,123,124}.json`. The output images and logs in `/tmp/claude-0/b5fb089a-2072-58da-b9f4-edd7b19154c4/scratchpad/en/out/` were replaced by the rebuilds.
```

### 確かめた作業役の報告（第126〜132話）

作業役の報告です。

```
7話とも、日本語版と英語版を全コマ見比べました。吹き出しの消し残し、はみ出し、話者の取り違え、絵の変化はどの話にもありませんでした。直した話は spec を直して作り直し、もう一度見て確かめています。吹き出しの字の大きさは全部 38〜44 です。

- **ep126**：直しました。P2 のエレベーターの「運転休止」の板は、張り直した四角が明るい灰色の帯になり、黒い板の左右と下に地色が残って帯が浮いていました。四角を板の内側いっぱいに広げ、帯が浮かないようにしました（size 17→20）。
  - **直せなかったこと**：板の地は暗いのに、上下で濃淡が変わるため、スクリプトは明るい地と判断します。黒い板に白い字にはならず、灰色の板に黒い字になります。読めて見た目もまとまっていますが、元の絵とは色が違います。
- **ep127**：直しました。表紙の一言「PATIENT'S CRASHING! LET US ON!」は3行目が「ON!」1語だけになり、絵も小さくなっていました。「PATIENT'S CRASHING!」に縮めて2行にし、絵が大きく入るようにしました。
- **ep128**：問題なし。P4 の申請書の字は、潰れて読めない線のままでした。
- **ep129**：直しました。
  - P1 の「This was half the price in Japan!」は、「日本で半額だった」とも読めてしまうので、「Half what it costs / here in Japan!」に直しました。
  - P3 は1行だけ長くて形がいびつだったので、長さのそろった4行にしました（「Without the mark, / just turning it on / in Japan could be / against the law.」）。
  - 表紙の一言も、P1 と同じ読み違いを避けるため「WHERE'S THE GITEKI MARK?」（2行）に替えました。
- **ep130**：問題なし。
- **ep131**：直しました。
  - P1 の吹き出しの「…」が、元の大きな3つの点に比べて小さすぎたので、「• • •」にして同じ見え方にしました。
  - P4 の張り紙「Stop before approaching」は size 29 で小さかったので、四角を紙いっぱいに広げ、手書き風の字（style: hand）に替えました（size 58）。
- **ep132**：直しました。P4 の「Check before you change, okay?」は意味がぼやけていたので、「Check it before / you change it, okay?」にしました（size 38）。本文（body）の同じ引用も合わせて直しています。

直したのは spec だけで、`en_manga.py` は変えていません。

ファイルは /tmp/claude-0/b5fb089a-2072-58da-b9f4-edd7b19154c4/scratchpad/en/ の中にあります。
- spec：spec/ep126.json〜ep132.json
- 英語版：out/epNN_*_EN.png
- 作ったときの記録：out/epNN.log
```

### spec/ep132.json（第132話の指示）

1話ぶんの spec の例です。吹き出しの seed・英語・日本語、英語のページのタイトル・解説文・表紙の一言・画像の説明・本文が入っています。

```json
{
  "ep": 132,
  "title": "On the Day It Gets Smarter, Check How It Stops",
  "description": "After an overnight AI update, Hakobukun corners smoothly but stops 30 cm closer to Oshiro. A manga on rechecking safe stopping distance after every update.",
  "cover_line": "TOO CLOSE!",
  "panels": [
    {
      "bubbles": [
        {
          "seed": [
            137,
            189
          ],
          "text": "I updated Hakobukun's AI overnight.\nIt'll take corners much more smoothly.",
          "ja": "ハコブくんのAI、夜のうちに新しい版にしました。廊下の曲がり方が、ずっと滑らかになります"
        }
      ],
      "signs": []
    },
    {
      "bubbles": [
        {
          "seed": [
            701,
            133
          ],
          "text": "Too close!",
          "ja": "近っ！"
        }
      ],
      "signs": []
    },
    {
      "bubbles": [
        {
          "seed": [
            504,
            195
          ],
          "text": "Turning got better,\nbut the stopping distance\nchanged too…",
          "ja": "曲がり方は良くなったけど、止まる距離まで変わってる…"
        },
        {
          "seed": [
            1020,
            183
          ],
          "text": "Checking how I stop\nwas not on the\nupdate checklist.",
          "ja": "止マリ方ノ確認ハ、更新ノ項目ニアリマセンデシタ"
        }
      ],
      "signs": []
    },
    {
      "bubbles": [
        {
          "seed": [
            700,
            132
          ],
          "text": "Check it before\nyou change it, okay?",
          "ja": "直す前に、確かめてね"
        }
      ],
      "signs": []
    }
  ],
  "panel_alts": [
    "In the morning, Shiraishi explains that she updated Hakobukun's AI to a new version.",
    "Hakobukun comes around a corner and stops right in front of Oshiro, who leans back in surprise.",
    "Shiraishi looks at the log on her laptop and notices that the stopping distance has changed.",
    "Rolled back to the previous version, Hakobukun stops at its usual distance while Shiraishi adds to the checklist."
  ],
  "body": "<p>■ Behind the improvement, another behavior changed<br>Overnight, Shiraishi updated Hakobukun's AI to a new version. It was supposed to take corridor corners much more smoothly. And sure enough, Hakobukun came around the corner smoothly. But it stopped 30 cm closer than usual to Oshiro, who had just stepped out from the corner. \"Too close!\" Oshiro blurted out.</p><p></p><p>■ A trained AI doesn't change only what you aimed at<br>A trained AI (a system that learns how to move from many examples) can change other behaviors even when an update targets just one part. When Shiraishi checked the log (the record of how it moved), the stopping distance had changed along with the turning. As Hakobukun said, checking how it stops was not on the update checklist. When something isn't checked, no one notices if it changes.</p><p></p><p>■ Conclusion: Recheck how it stops for people after every update<br>Shiraishi added \"stopping distance in front of people\" to the checklist used for every update. Rolled back to the previous version, Hakobukun stops at its usual distance. The way a robot stops to protect people should be handled by a mechanism separate from learning. On top of that, confirm and record after every update that it still works. Oshiro's \"Check it before you change it, okay?\" is about that order: verify before it goes out on the floor.<br>Make the day you improve its movement the day you also check how it stops.</p>",
  "notes": "P1 の吹き出しはとても横長（約950px・日本語2行で各25字前後）なので、英語は2行で各35字前後にした。指示の「1行14〜22字」より長いので、組んだときに収まりを確認してほしい。P1 の2番はロボットの胴で吹き出しではない。P1 の白石さんのノートPCのステッカーは字が読めない絵なので signs に入れていない。P4 のクリップボードの表にも字はない。"
}
```

### 28話を作ったときの記録（out/epNN.log）

en_manga.py が話ごとに書き出した記録の全部です。吹き出しごとの英語・字の大きさ（size）・行、縁が破線のとき閉じるのに太らせた画素数（closed_by）、名札などの地の色（bg）、表紙の一言の行と大きさが入っています。

```text
== ep73.log
{"P1": [{"kind": "bubble", "text": "This way, please.", "closed_by": 0, "rim_dark": 0, "size": 44, "lines": ["This way, please."], "kept_breaks": true}], "P2": [{"kind": "bubble", "text": "This way, please.", "closed_by": 0, "rim_dark": 0, "size": 44, "lines": ["This way, please."], "kept_breaks": true}], "P3": [{"kind": "sign", "text": "Front\ndesk", "bg": [255, 255, 255], "size": 30}, {"kind": "sign", "text": "Outpatient", "bg": [255, 255, 255], "size": 26}, {"kind": "sign", "text": "Testing", "bg": [255, 255, 255], "size": 27}, {"kind": "sign", "text": "Shop", "bg": [254, 254, 254], "size": 25}], "P4": [{"kind": "bubble", "text": "Let me be your\npointing finger.", "closed_by": 0, "rim_dark": 0, "size": 44, "lines": ["Let me be your", "pointing finger."], "kept_breaks": true}], "cover": {"lines": ["THIS WAY,", "PLEASE"], "size": 127, "art_scale": 0.802}}
== ep76.log
{"P1": [{"kind": "bubble", "text": "We have arrived.", "closed_by": 0, "rim_dark": 0, "size": 44, "lines": ["We have arrived."], "kept_breaks": true}], "P2": [], "P3": [{"kind": "sign", "text": "Arrival = Done", "bg": [255, 255, 255], "size": 57}], "P4": [{"kind": "bubble", "text": "I will go back once\nI see you handed off.", "closed_by": 0, "rim_dark": 0, "size": 44, "lines": ["I will go back once", "I see you handed off."], "kept_breaks": true}], "cover": {"lines": ["WE HAVE", "ARRIVED"], "size": 127, "art_scale": 0.948}}
== ep93.log
{"P1": [{"kind": "bubble", "text": "Hey, do you think\nI should give the\nfever medicine?", "closed_by": 0, "rim_dark": 0, "size": 44, "lines": ["Hey, do you think", "I should give the", "fever medicine?"], "kept_breaks": true}], "P2": [{"kind": "bubble", "text": "I am a\ntransport robot.", "closed_by": 0, "rim_dark": 0, "size": 44, "lines": ["I am a", "transport robot."], "kept_breaks": true}, {"kind": "bubble", "text": "Then… which medicine\ndo you feel like\ncarrying?", "closed_by": 0, "rim_dark": 0, "size": 38, "lines": ["Then… which medicine", "do you feel like", "carrying?"], "kept_breaks": true}, {"kind": "bubble", "text": "I do not have\nfeelings.", "closed_by": 0, "rim_dark": 0, "size": 44, "lines": ["I do not have", "feelings."], "kept_breaks": true}, {"kind": "sign", "text": "Resident\nKoshiba", "bg": [253, 253, 253], "size": 9}], "P3": [{"kind": "bubble", "text": "Right means fever meds,\nleft means wait and see.\nWhichever way\nHakobukun goes!", "closed_by": 0, "rim_dark": 0, "size": 35, "lines": ["Right means fever meds,", "left means wait and see.", "Whichever way", "Hakobukun goes!"], "kept_breaks": true}, {"kind": "sign", "text": "Resident\nKoshiba", "bg": [254, 254, 254], "size": 8}], "P4": [{"kind": "bubble", "text": "Don't make a robot\nroll the dice.", "closed_by": 0, "rim_dark": 0, "size": 41, "lines": ["Don't make a robot", "roll the dice."], "kept_breaks": true}, {"kind": "bubble", "text": "The doctor decides.\nI do the carrying.", "closed_by": 0, "rim_dark": 0, "size": 41, "lines": ["The doctor decides.", "I do the carrying."], "kept_breaks": true}, {"kind": "sign", "text": "Dr. Tanaka", "bg": [253, 253, 253], "size": 8}, {"kind": "sign", "text": "Resident\nKoshiba", "bg": [253, 253, 253], "size": 9}], "cover": {"lines": ["DON'T MAKE A", "ROBOT ROLL DICE"], "size": 109, "art_scale": 0.971}}
== ep94.log
{"P1": [{"kind": "bubble", "text": "Um, about your test\nresults… your CRP\nis elevated, and…", "closed_by": 0, "rim_dark": 0, "size": 44, "lines": ["Um, about your test", "results… your CRP", "is elevated, and…"], "kept_breaks": true}], "P2": [{"kind": "bubble", "text": "Understood.", "closed_by": 0, "rim_dark": 0, "size": 30, "lines": ["Understood."], "kept_breaks": true}, {"kind": "bubble", "text": "…Some\npractice\nthis is.", "closed_by": 0, "rim_dark": 0, "size": 37, "lines": ["…Some", "practice", "this is."], "kept_breaks": true}], "P3": [{"kind": "bubble", "text": "What is CRP?", "closed_by": 0, "rim_dark": 0, "size": 44, "lines": ["What is CRP?"], "kept_breaks": true}, {"kind": "bubble", "text": "“Elevated”—\nby how much?", "closed_by": 0, "rim_dark": 0, "size": 44, "lines": ["“Elevated”—", "by how much?"], "kept_breaks": true}, {"kind": "bubble", "text": "Is that\na bad thing?", "closed_by": 0, "rim_dark": 0, "size": 44, "lines": ["Is that", "a bad thing?"], "kept_breaks": true}, {"kind": "bubble", "text": "Play the patient.\nIf something isn't\nclear, ask me.", "closed_by": 0, "rim_dark": 0, "size": 44, "lines": ["Play the patient.", "If something isn't", "clear, ask me."], "kept_breaks": true}], "P4": [{"kind": "bubble", "text": "…Glad I got all\nthose questions\nbefore the real one.", "closed_by": 0, "rim_dark": 0, "size": 38, "lines": ["…Glad I got all", "those questions", "before the real one."], "kept_breaks": true}, {"kind": "bubble", "text": "I can also be\nthe one who asks.", "closed_by": 0, "rim_dark": 0, "size": 41, "lines": ["I can also be", "the one who asks."], "kept_breaks": true}, {"kind": "sign", "text": "Resident\nKoshiba", "bg": [253, 253, 253], "size": 10}], "cover": {"lines": ["WHAT IS CRP?"], "size": 127, "art_scale": 1.013}}
== ep95.log
{"P1": [{"kind": "bubble", "text": "Is gauze\nall right?", "closed_by": 0, "rim_dark": 0, "size": 40, "lines": ["Is gauze", "all right?"], "kept_breaks": true}, {"kind": "bubble", "text": "Oh…\nyes.", "closed_by": 0, "rim_dark": 0, "size": 40, "lines": ["Oh…", "yes."], "kept_breaks": true}], "P2": [{"kind": "bubble", "text": "Is size M\nall right?", "closed_by": 0, "rim_dark": 0, "size": 44, "lines": ["Is size M", "all right?"], "kept_breaks": true}, {"kind": "bubble", "text": "Are ten sheets\nall right?", "closed_by": 0, "rim_dark": 0, "size": 44, "lines": ["Are ten sheets", "all right?"], "kept_breaks": true}, {"kind": "bubble", "text": "…Yes.", "closed_by": 0, "rim_dark": 0, "size": 44, "lines": ["…Yes."], "kept_breaks": true}, {"kind": "bubble", "text": "…Yes.", "closed_by": 0, "rim_dark": 0, "size": 44, "lines": ["…Yes."], "kept_breaks": true}, {"kind": "sign", "text": "Resident\nKoshiba", "bg": [253, 253, 253], "size": 19}], "P3": [{"kind": "bubble", "text": "It's all wrong…", "closed_by": 0, "rim_dark": 0, "size": 44, "lines": ["It's all wrong…"], "kept_breaks": true}], "P4": [{"kind": "bubble", "text": "Doctor, your “yes”\ndid not mean\nyou agreed?", "closed_by": 0, "rim_dark": 0, "size": 42, "lines": ["Doctor, your “yes”", "did not mean", "you agreed?"], "kept_breaks": true}, {"kind": "bubble", "text": "I'm bad at\nsaying no…", "closed_by": 0, "rim_dark": 0, "size": 39, "lines": ["I'm bad at", "saying no…"], "kept_breaks": true}, {"kind": "bubble", "text": "Next time, I will ask\nwhat you need first.", "closed_by": 0, "rim_dark": 0, "size": 42, "lines": ["Next time, I will ask", "what you need first."], "kept_breaks": true}], "cover": {"lines": ["IT'S ALL", "WRONG…"], "size": 127, "art_scale": 0.892}}
== ep96.log
{"P1": [{"kind": "bubble", "text": "Nothing but mistakes\nagain today…", "closed_by": 0, "rim_dark": 0, "size": 44, "lines": ["Nothing but mistakes", "again today…"], "kept_breaks": true}], "P2": [{"kind": "bubble", "text": "Tally complete.\nYour mistakes today: 3.\nResident average: 4.", "closed_by": 0, "rim_dark": 0, "size": 44, "lines": ["Tally complete.", "Your mistakes today: 3.", "Resident average: 4."], "kept_breaks": true}], "P3": [{"kind": "bubble", "text": "…“Slightly better\nthan average”?\nIs that supposed\nto cheer me up?", "closed_by": 0, "rim_dark": 0, "size": 44, "lines": ["…“Slightly better", "than average”?", "Is that supposed", "to cheer me up?"], "kept_breaks": true}, {"kind": "bubble", "text": "Yes.", "closed_by": 0, "rim_dark": 0, "size": 44, "lines": ["Yes."], "kept_breaks": true}, {"kind": "sign", "text": "Resident\nKoshiba", "bg": [253, 253, 253], "size": 14}], "P4": [{"kind": "bubble", "text": "Correction.\n2 fewer than\nyou had\nlast week.", "closed_by": 0, "rim_dark": 0, "size": 39, "lines": ["Correction.", "2 fewer than", "you had", "last week."], "kept_breaks": true}, {"kind": "bubble", "text": "…Now that\nmakes me\na little\nhappy.", "closed_by": 0, "rim_dark": 0, "size": 35, "lines": ["…Now that", "makes me", "a little", "happy."], "kept_breaks": true}], "cover": {"lines": ["2 FEWER THAN YOU", "HAD LAST WEEK"], "size": 102, "art_scale": 0.991}}
== ep97.log
{"P1": [{"kind": "bubble", "text": "Hakobukun…", "closed_by": 0, "rim_dark": 0, "size": 44, "lines": ["Hakobukun…"], "kept_breaks": true}], "P2": [{"kind": "bubble", "text": "H-Hakobukun…", "closed_by": 0, "rim_dark": 0, "size": 44, "lines": ["H-Hakobukun…"], "kept_breaks": true}], "P3": [{"kind": "bubble", "text": "Ha-ko-bu-kun!", "closed_by": 0, "rim_dark": 0, "size": 44, "lines": ["Ha-ko-bu-kun!"], "kept_breaks": true}, {"kind": "bubble", "text": "Did you call me?", "closed_by": 0, "rim_dark": 0, "size": 44, "lines": ["Did you", "call me?"], "kept_breaks": true}, {"kind": "sign", "text": "Resident\nKoshiba", "bg": [253, 253, 253], "size": 7}], "P4": [{"kind": "bubble", "text": "I have learned how\nloud your voice is.\nFrom now on, your\nnormal voice will\nreach me.", "closed_by": 0, "rim_dark": 0, "size": 43, "lines": ["I have learned how", "loud your voice is.", "From now on, your", "normal voice will", "reach me."], "kept_breaks": true}, {"kind": "bubble", "text": "…So I didn't need\nto practice?", "closed_by": 0, "rim_dark": 0, "size": 42, "lines": ["…So I didn't need", "to practice?"], "kept_breaks": true}, {"kind": "sign", "text": "Resident\nKoshiba", "bg": [253, 253, 253], "size": 11}], "cover": {"lines": ["HA-KO-BU-KUN!"], "size": 125, "art_scale": 0.968}}
== ep99.log
{"P1": [], "P2": [{"kind": "bubble", "text": "Mr. Nakamura turned over once", "closed_by": 0, "rim_dark": 0, "size": 34, "lines": ["Mr. Nakamura turned over once"], "kept_breaks": true}, {"kind": "bubble", "text": "Mr. Nakamura turned over twice", "closed_by": 0, "rim_dark": 0, "size": 33, "lines": ["Mr. Nakamura turned over twice"], "kept_breaks": true}, {"kind": "bubble", "text": "Mr. Nakamura coughed once", "closed_by": 0, "rim_dark": 0, "size": 30, "lines": ["Mr. Nakamura coughed once"], "kept_breaks": true}], "P3": [{"kind": "bubble", "text": "If everything\ncomes in, what really\nmatters gets buried!", "closed_by": 0, "rim_dark": 0, "size": 44, "lines": ["If everything", "comes in, what really", "matters gets buried!"], "kept_breaks": true}], "P4": [{"kind": "bubble", "text": "Only tell me when\nthey sit up or\nstart to walk.", "closed_by": 0, "rim_dark": 0, "size": 43, "lines": ["Only tell me when", "they sit up or", "start to walk."], "kept_breaks": true}, {"kind": "bubble", "text": "Everything else,\nI will only log.", "closed_by": 0, "rim_dark": 0, "size": 44, "lines": ["Everything else,", "I will only log."], "kept_breaks": true}], "cover": {"lines": ["WHAT REALLY", "MATTERS GETS", "BURIED"], "size": 100, "art_scale": 0.841}}
== ep100.log
{"P1": [{"kind": "bubble", "text": "Coming through.\nComing through.", "closed_by": 0, "rim_dark": 0, "size": 44, "lines": ["Coming through.", "Coming through."], "kept_breaks": true}], "P2": [], "P3": [{"kind": "bubble", "text": "…Is it morning?", "closed_by": 0, "rim_dark": 0, "size": 44, "lines": ["…Is it morning?"], "kept_breaks": true}], "P4": [{"kind": "bubble", "text": "Nights call for\na night way\nof carrying.", "closed_by": 0, "rim_dark": 0, "size": 44, "lines": ["Nights call for", "a night way", "of carrying."], "kept_breaks": true}, {"kind": "bubble", "text": "…Coming\nthrough.", "closed_by": 0, "rim_dark": 0, "size": 36, "lines": ["…Coming", "through."], "kept_breaks": true}], "cover": {"lines": ["COMING THROUGH.", "COMING THROUGH."], "size": 109, "art_scale": 0.88}}
== ep101.log
{"P1": [], "P2": [{"kind": "bubble", "text": "I will carry the\npatient to the\nwheelchair.", "closed_by": 0, "rim_dark": 0, "size": 44, "lines": ["I will carry the", "patient to the", "wheelchair."], "kept_breaks": true}, {"kind": "bubble", "text": "Wait! You\ncan't do that!", "closed_by": 0, "rim_dark": 0, "size": 44, "lines": ["Wait! You", "can't do that!"], "kept_breaks": true}], "P3": [{"kind": "bubble", "text": "Then what shall\nI carry?", "closed_by": 0, "rim_dark": 0, "size": 40, "lines": ["Then what shall", "I carry?"], "kept_breaks": true}, {"kind": "bubble", "text": "Fresh sheets, IV bags,\nthermometers. And take\nsamples to the lab.", "closed_by": 0, "rim_dark": 0, "size": 34, "lines": ["Fresh sheets, IV bags,", "thermometers. And take", "samples to the lab."], "kept_breaks": true}], "P4": [{"kind": "bubble", "text": "Quiet tonight.", "closed_by": 0, "rim_dark": 0, "size": 44, "lines": ["Quiet tonight."], "kept_breaks": true}, {"kind": "bubble", "text": "Because no one\nhad to run.", "closed_by": 0, "rim_dark": 0, "size": 43, "lines": ["Because no one", "had to run."], "kept_breaks": true}, {"kind": "sign", "text": "Resident Koshiba", "bg": [254, 254, 254], "size": 9}], "cover": {"lines": ["I WILL CARRY THE", "PATIENT TO THE", "WHEELCHAIR"], "size": 100, "art_scale": 0.756}}
== ep109.log
{"P1": [{"kind": "bubble", "text": "Lag…! 0.3s off!", "closed_by": 0, "rim_dark": 0, "size": 26, "lines": ["Lag…! 0.3s off!"], "kept_breaks": true}, {"kind": "bubble", "text": "Whoa!", "closed_by": 0, "rim_dark": 0, "size": 25, "lines": ["Whoa!"], "kept_breaks": true}], "P2": [{"kind": "bubble", "text": "It's not fast,\nso how does it\nstop so exactly?", "closed_by": 0, "rim_dark": 0, "size": 44, "lines": ["It's not fast,", "so how does it", "stop so exactly?"], "kept_breaks": true}], "P3": [{"kind": "bubble", "text": "Tsukamukun cannot\nstop instantly either.", "closed_by": 0, "rim_dark": 0, "size": 44, "lines": ["Tsukamukun cannot", "stop instantly either."], "kept_breaks": true}, {"kind": "bubble", "text": "So it slows early.\nIt stops short.", "closed_by": 0, "rim_dark": 0, "size": 44, "lines": ["So it slows early.", "It stops short."], "kept_breaks": true}, {"kind": "bubble", "text": "…Oh, right.", "closed_by": 0, "rim_dark": 0, "size": 27, "lines": ["…Oh, right."], "kept_breaks": true}], "P4": [{"kind": "bubble", "text": "Thanks.\nThat's just right.", "closed_by": 0, "rim_dark": 0, "size": 44, "lines": ["Thanks.", "That's just right."], "kept_breaks": true}], "cover": {"lines": ["THAT 0.3 SECONDS", "THREW ME OFF"], "size": 102, "art_scale": 0.839}}
== ep111.log
{"P1": [{"kind": "bubble", "text": "Huh? Lost it?", "closed_by": 0, "rim_dark": 0, "size": 22, "lines": ["Huh? Lost it?"], "kept_breaks": true}, {"kind": "sign", "text": "Offline", "bg": [254, 254, 254], "size": 12}], "P2": [], "P3": [{"kind": "bubble", "text": "…You are sitting.", "closed_by": 0, "rim_dark": 0, "size": 44, "lines": ["…You are sitting."], "kept_breaks": true}], "P4": [{"kind": "bubble", "text": "Sorry, I lost\nthe connection.", "closed_by": 0, "rim_dark": 0, "size": 44, "lines": ["Sorry, I lost", "the connection."], "kept_breaks": true}, {"kind": "bubble", "text": "If you had frozen\nstanding up,\nyou'd have fallen.", "closed_by": 0, "rim_dark": 0, "size": 43, "lines": ["If you had frozen", "standing up,", "you'd have fallen."], "kept_breaks": true}], "cover": {"lines": ["IF YOU'D FROZEN", "STANDING, YOU'D", "HAVE FALLEN"], "size": 100, "art_scale": 0.803}}
== ep113.log
{"P1": [{"kind": "bubble", "text": "So slow.\nAssist off.", "closed_by": 0, "rim_dark": 0, "size": 24, "lines": ["So slow.", "Assist off."], "kept_breaks": true}, {"kind": "sign", "text": "High", "bg": [254, 254, 254], "size": 13}], "P2": [{"kind": "sign", "text": "Off", "bg": [255, 255, 255], "size": 26}], "P3": [{"kind": "bubble", "text": "…Shall\nI pick\nit up?", "closed_by": 0, "rim_dark": 0, "size": 44, "lines": ["…Shall", "I pick", "it up?"], "kept_breaks": true}, {"kind": "bubble", "text": "…No. Half\nfor now.", "closed_by": 0, "rim_dark": 0, "size": 26, "lines": ["…No. Half", "for now."], "kept_breaks": true}, {"kind": "sign", "text": "Off", "bg": [254, 254, 254], "size": 17}], "P4": [{"kind": "bubble", "text": "Mid till\nI rank up.", "closed_by": 0, "rim_dark": 0, "size": 16, "lines": ["Mid till", "I rank up."], "kept_breaks": true}, {"kind": "bubble", "text": "When you rank up,\nplease show me again.", "closed_by": 0, "rim_dark": 0, "size": 39, "lines": ["When you rank up,", "please show me again."], "kept_breaks": true}, {"kind": "sign", "text": "Med", "bg": [254, 254, 254], "size": 8}], "cover": {"lines": ["TOO SLOW. I'M", "TURNING THE", "ASSIST OFF"], "size": 100, "art_scale": 0.841}}
== ep114.log
{"P1": [], "P2": [], "P3": [{"kind": "bubble", "text": "Going to the restroom?\nTake your time.", "closed_by": 0, "rim_dark": 0, "size": 43, "lines": ["Going to the restroom?", "Take your time."], "kept_breaks": true}], "P4": [{"kind": "bubble", "text": "If you turn on the light,\nI wake right up.", "closed_by": 0, "rim_dark": 0, "size": 42, "lines": ["If you turn on the light,", "I wake right up."], "kept_breaks": true}], "cover": {"lines": ["IF YOU TURN ON", "THE LIGHT, I", "WAKE RIGHT UP"], "size": 100, "art_scale": 0.804}}
== ep115.log
{"P1": [], "P2": [{"kind": "bubble", "text": "What?! Come on,\nthat input didn't register!", "closed_by": 0, "rim_dark": 0, "size": 44, "lines": ["What?! Come on,", "that input didn't register!"], "kept_breaks": true}], "P3": [{"kind": "bubble", "text": "…The whole\nward heard\nall of that.", "closed_by": 0, "rim_dark": 0, "size": 44, "lines": ["…The whole", "ward heard", "all of that."], "kept_breaks": true}], "P4": [{"kind": "bubble", "text": "My apologies.", "closed_by": 0, "rim_dark": 0, "size": 44, "lines": ["My apologies."], "kept_breaks": true}, {"kind": "bubble", "text": "Wow, quick switch.", "closed_by": 0, "rim_dark": 0, "size": 42, "lines": ["Wow, quick switch."], "kept_breaks": true}], "cover": {"lines": ["WHAT?! THAT", "INPUT DIDN'T", "REGISTER!"], "size": 100, "art_scale": 0.893}}
== ep116.log
{"P1": [{"kind": "bubble", "text": "Always a\n0.3s lag…", "closed_by": 0, "rim_dark": 0, "size": 26, "lines": ["Always a", "0.3s lag…"], "kept_breaks": true}], "P2": [{"kind": "bubble", "text": "Buffer inputs.\nThat works.", "closed_by": 0, "rim_dark": 0, "size": 27, "lines": ["Buffer inputs.", "That works."], "kept_breaks": true}], "P3": [], "P4": [{"kind": "bubble", "text": "Fast… yet\nnot scary.", "closed_by": 0, "rim_dark": 0, "size": 44, "lines": ["Fast… yet", "not scary."], "kept_breaks": true}], "cover": {"lines": ["FAST… YET", "NOT SCARY"], "size": 127, "art_scale": 0.892}}
== ep117.log
{"P1": [{"kind": "bubble", "text": "…Is that a camera?\nAre you recording?", "closed_by": 0, "rim_dark": 0, "size": 44, "lines": ["…Is that a camera?", "Are you recording?"], "kept_breaks": true}], "P2": [{"kind": "bubble", "text": "The video only goes\nto my operator.\nNothing is recorded.", "closed_by": 0, "rim_dark": 0, "size": 44, "lines": ["The video only goes", "to my operator.", "Nothing is recorded."], "kept_breaks": true}], "P3": [], "P4": [{"kind": "bubble", "text": "…Thank you.\nYou can turn\naround now.", "closed_by": 0, "rim_dark": 0, "size": 39, "lines": ["…Thank you.", "You can turn", "around now."], "kept_breaks": true}, {"kind": "bubble", "text": "Please take\nyour time.", "closed_by": 0, "rim_dark": 0, "size": 44, "lines": ["Please take", "your time."], "kept_breaks": true}], "cover": {"lines": ["YOU CAN TURN", "AROUND NOW"], "size": 127, "art_scale": 0.852}}
== ep118.log
{"P1": [{"kind": "bubble", "text": "Let me carry your\nbag to the entrance.", "closed_by": 0, "rim_dark": 0, "size": 42, "lines": ["Let me carry your", "bag to the entrance."], "kept_breaks": true}], "P2": [{"kind": "bubble", "text": "So where do\nyou live, nurse?", "closed_by": 0, "rim_dark": 0, "size": 44, "lines": ["So where do", "you live, nurse?"], "kept_breaks": true}, {"kind": "bubble", "text": "Really far.\nHalf a day away\nby train.", "closed_by": 0, "rim_dark": 0, "size": 42, "lines": ["Really far.", "Half a day away", "by train."], "kept_breaks": true}], "P3": [{"kind": "bubble", "text": "This body can't\ngo past this line.", "closed_by": 0, "rim_dark": 0, "size": 44, "lines": ["This body can't", "go past this line."], "kept_breaks": true}], "P4": [], "cover": {"lines": ["THIS BODY CAN'T", "GO PAST THE LINE"], "size": 102, "art_scale": 0.891}}
== ep123.log
{"P1": [{"kind": "bubble", "text": "Be right back~", "closed_by": 0, "rim_dark": 0, "size": 27, "lines": ["Be right back~"], "kept_breaks": true}], "P2": [{"kind": "bubble", "text": "…Its eyes\nare lines,\nbut it's\nmoving?", "closed_by": 0, "rim_dark": 0, "size": 34, "lines": ["…Its eyes", "are lines,", "but it's", "moving?"], "kept_breaks": true}], "P3": [{"kind": "bubble", "text": "Shiraishi, Pikarikun is\nwalking on its own!", "closed_by": 0, "rim_dark": 0, "size": 44, "lines": ["Shiraishi, Pikarikun is", "walking on its own!"], "kept_breaks": true}], "P4": [{"kind": "bubble", "text": "It wasn't me…", "closed_by": 0, "rim_dark": 0, "size": 27, "lines": ["It wasn't me…"], "kept_breaks": true}, {"kind": "bubble", "text": "While you were away,\nsomeone connected\nfrom outside.", "closed_by": 0, "rim_dark": 0, "size": 36, "lines": ["While you were away,", "someone connected", "from outside."], "kept_breaks": true}, {"kind": "bubble", "text": "Its eyes stayed lines\nwhile it walked.\nThat felt off.", "closed_by": 0, "rim_dark": 0, "size": 35, "lines": ["Its eyes stayed lines", "while it walked.", "That felt off."], "kept_breaks": true}], "cover": {"lines": ["…ITS EYES ARE", "LINES, BUT IT'S", "MOVING?"], "size": 100, "art_scale": 0.803}}
== ep124.log
{"P1": [{"kind": "bubble", "text": "Take this to\nRoom 302, please.", "closed_by": 0, "rim_dark": 0, "size": 41, "lines": ["Take this to", "Room 302, please."], "kept_breaks": true}], "P2": [{"kind": "bubble", "text": "I cannot carry this medication.\nRules for locking and logging it\nare not yet in place.", "closed_by": 0, "rim_dark": 0, "size": 44, "lines": ["I cannot carry this medication.", "Rules for locking and logging it", "are not yet in place."], "kept_breaks": true}], "P3": [{"kind": "bubble", "text": "Huh? But I just got it\nfrom the pharmacy safe…", "closed_by": 0, "rim_dark": 0, "size": 33, "lines": ["Huh? But I just got it", "from the pharmacy safe…"], "kept_breaks": true}, {"kind": "bubble", "text": "My drawer\nis not a safe.", "closed_by": 0, "rim_dark": 0, "size": 44, "lines": ["My drawer", "is not a safe."], "kept_breaks": true}, {"kind": "sign", "text": "Opioids must be\ncarried by hand\nby a nurse", "bg": [254, 254, 254], "size": 30}], "P4": [{"kind": "bubble", "text": "Fine, then you\ntake everything else.", "closed_by": 0, "rim_dark": 0, "size": 40, "lines": ["Fine, then you", "take everything else."], "kept_breaks": true}, {"kind": "bubble", "text": "I will carry\nIV bags, sheets,\nand specimens.", "closed_by": 0, "rim_dark": 0, "size": 39, "lines": ["I will carry", "IV bags, sheets,", "and specimens."], "kept_breaks": true}, {"kind": "sign", "text": "Inj.", "bg": [252, 251, 252], "size": 20}], "cover": {"lines": ["I CANNOT CARRY", "THIS MEDICATION"], "size": 109, "art_scale": 0.88}}
== ep125.log
{"P1": [], "P2": [{"kind": "bubble", "text": "This is my usual\nwaiting spot.", "closed_by": 0, "rim_dark": 0, "size": 44, "lines": ["This is my usual", "waiting spot."], "kept_breaks": true}], "P3": [{"kind": "bubble", "text": "You're parked where\nthe fire door shuts!", "closed_by": 0, "rim_dark": 0, "size": 44, "lines": ["You're parked where", "the fire door shuts!"], "kept_breaks": true}], "P4": [{"kind": "bubble", "text": "I will relearn where\nI am allowed to stop.", "closed_by": 0, "rim_dark": 0, "size": 44, "lines": ["I will relearn where", "I am allowed to stop."], "kept_breaks": true}], "cover": {"lines": ["THIS IS MY USUAL", "WAITING SPOT"], "size": 102, "art_scale": 0.891}}
== ep126.log
{"P1": [], "P2": [{"kind": "bubble", "text": "Delivering an IV drip\nto the 5th floor.", "closed_by": 0, "rim_dark": 0, "size": 44, "lines": ["Delivering an IV drip", "to the 5th floor."], "kept_breaks": true}, {"kind": "sign", "text": "Out of Service", "bg": [198, 198, 198], "size": 20}], "P3": [{"kind": "bubble", "text": "No one can get past\nyou there!", "closed_by": 0, "rim_dark": 0, "size": 44, "lines": ["No one can get past", "you there!"], "kept_breaks": true}], "P4": [{"kind": "bubble", "text": "When the bell rings,\nI clear the way.", "closed_by": 0, "rim_dark": 0, "size": 44, "lines": ["When the bell rings,", "I clear the way."], "kept_breaks": true}], "cover": {"lines": ["NO ONE CAN GET", "PAST YOU THERE!"], "size": 109, "art_scale": 0.965}}
== ep127.log
{"P1": [{"kind": "bubble", "text": "Patient's crashing!\nLet us on!", "closed_by": 0, "rim_dark": 0, "size": 40, "lines": ["Patient's crashing!", "Let us on!"], "kept_breaks": true}], "P2": [{"kind": "bubble", "text": "I was here first.\nIt is my turn.", "closed_by": 0, "rim_dark": 0, "size": 44, "lines": ["I was here first.", "It is my turn."], "kept_breaks": true}], "P3": [{"kind": "bubble", "text": "Hakobukun,\nget off!", "closed_by": 0, "rim_dark": 0, "size": 44, "lines": ["Hakobukun,", "get off!"], "kept_breaks": true}], "P4": [{"kind": "bubble", "text": "I will give my turn\nto those in a hurry.", "closed_by": 0, "rim_dark": 0, "size": 44, "lines": ["I will give my turn", "to those in a hurry."], "kept_breaks": true}, {"kind": "bubble", "text": "Remember that\nahead of time.", "closed_by": 0, "rim_dark": 0, "size": 44, "lines": ["Remember that", "ahead of time."], "kept_breaks": true}], "cover": {"lines": ["PATIENT'S", "CRASHING!"], "size": 127, "art_scale": 0.852}}
== ep128.log
{"P1": [{"kind": "bubble", "text": "No more dropped signals,\neven deep in the ward!", "closed_by": 0, "rim_dark": 0, "size": 42, "lines": ["No more dropped signals,", "even deep in the ward!"], "kept_breaks": true}], "P2": [], "P3": [{"kind": "bubble", "text": "Makihara, did you\nclear that antenna\nwith the hospital's\nradio manager?", "closed_by": 0, "rim_dark": 0, "size": 43, "lines": ["Makihara, did you", "clear that antenna", "with the hospital's", "radio manager?"], "kept_breaks": true}, {"kind": "bubble", "text": "…No, I didn't.", "closed_by": 0, "rim_dark": 0, "size": 44, "lines": ["…No, I didn't."], "kept_breaks": true}], "P4": [{"kind": "bubble", "text": "I will get hospital\napproval for radio, too.", "closed_by": 0, "rim_dark": 0, "size": 44, "lines": ["I will get hospital", "approval for radio, too."], "kept_breaks": true}], "cover": {"lines": ["NO MORE DROPPED", "SIGNALS!"], "size": 109, "art_scale": 0.921}}
== ep129.log
{"P1": [{"kind": "bubble", "text": "Half what it costs\nhere in Japan!", "closed_by": 0, "rim_dark": 0, "size": 44, "lines": ["Half what it costs", "here in Japan!"], "kept_breaks": true}], "P2": [{"kind": "bubble", "text": "Where's the\nGiteki mark?", "closed_by": 0, "rim_dark": 0, "size": 44, "lines": ["Where's the", "Giteki mark?"], "kept_breaks": true}], "P3": [{"kind": "bubble", "text": "Without the mark,\njust turning it on\nin Japan could be\nagainst the law.", "closed_by": 0, "rim_dark": 0, "size": 44, "lines": ["Without the mark,", "just turning it on", "in Japan could be", "against the law."], "kept_breaks": true}], "P4": [{"kind": "bubble", "text": "Not half price\nafter all…", "closed_by": 0, "rim_dark": 0, "size": 39, "lines": ["Not half price", "after all…"], "kept_breaks": true}, {"kind": "bubble", "text": "The price includes\nsafety, too.", "closed_by": 0, "rim_dark": 0, "size": 43, "lines": ["The price includes", "safety, too."], "kept_breaks": true}], "cover": {"lines": ["WHERE'S THE", "GITEKI MARK?"], "size": 127, "art_scale": 0.892}}
== ep130.log
{"P1": [{"kind": "bubble", "text": "You teach it by\nmoving it by hand, right?", "closed_by": 0, "rim_dark": 0, "size": 39, "lines": ["You teach it by", "moving it by hand, right?"], "kept_breaks": true}], "P2": [{"kind": "bubble", "text": "Wait! Anyone teaching\nit by hand needs\nspecial training first!", "closed_by": 0, "rim_dark": 0, "size": 39, "lines": ["Wait! Anyone teaching", "it by hand needs", "special training first!"], "kept_breaks": true}], "P3": [{"kind": "bubble", "text": "How long is\nthe training?", "closed_by": 0, "rim_dark": 0, "size": 44, "lines": ["How long is", "the training?"], "kept_breaks": true}, {"kind": "bubble", "text": "Three days.", "closed_by": 0, "rim_dark": 0, "size": 44, "lines": ["Three days."], "kept_breaks": true}, {"kind": "bubble", "text": "Three\ndays?!", "closed_by": 0, "rim_dark": 0, "size": 44, "lines": ["Three", "days?!"], "kept_breaks": true}], "P4": [{"kind": "bubble", "text": "After the training,\nI really see\nwhere the danger is.", "closed_by": 0, "rim_dark": 0, "size": 43, "lines": ["After the training,", "I really see", "where the danger is."], "kept_breaks": true}], "cover": {"lines": ["THREE DAYS?!"], "size": 127, "art_scale": 0.982}}
== ep131.log
{"P1": [{"kind": "bubble", "text": "• • •", "closed_by": 0, "rim_dark": 0, "size": 44, "lines": ["• • •"], "kept_breaks": true}], "P2": [{"kind": "bubble", "text": "I'm just doing\na quick fix.", "closed_by": 0, "rim_dark": 0, "size": 44, "lines": ["I'm just doing", "a quick fix."], "kept_breaks": true}], "P3": [{"kind": "bubble", "text": "Quick fixes are\nthe most dangerous!", "closed_by": 0, "rim_dark": 0, "size": 43, "lines": ["Quick fixes are", "the most dangerous!"], "kept_breaks": true}], "P4": [{"kind": "bubble", "text": "Stop first.", "closed_by": 0, "rim_dark": 0, "size": 44, "lines": ["Stop first."], "kept_breaks": true}, {"kind": "sign", "text": "Stop before approaching", "bg": [255, 255, 255], "size": 58}], "cover": {"lines": ["QUICK FIXES ARE", "THE MOST", "DANGEROUS!"], "size": 100, "art_scale": 0.84}}
== ep132.log
{"P1": [{"kind": "bubble", "text": "I updated Hakobukun's AI overnight.\nIt'll take corners much more smoothly.", "closed_by": 0, "rim_dark": 0, "size": 44, "lines": ["I updated Hakobukun's AI overnight.", "It'll take corners much more smoothly."], "kept_breaks": true}], "P2": [{"kind": "bubble", "text": "Too close!", "closed_by": 0, "rim_dark": 0, "size": 44, "lines": ["Too close!"], "kept_breaks": true}], "P3": [{"kind": "bubble", "text": "Turning got better,\nbut the stopping distance\nchanged too…", "closed_by": 0, "rim_dark": 0, "size": 44, "lines": ["Turning got better,", "but the stopping distance", "changed too…"], "kept_breaks": true}, {"kind": "bubble", "text": "Checking how I stop\nwas not on the\nupdate checklist.", "closed_by": 0, "rim_dark": 0, "size": 39, "lines": ["Checking how I stop", "was not on the", "update checklist."], "kept_breaks": true}], "P4": [{"kind": "bubble", "text": "Check it before\nyou change it, okay?", "closed_by": 0, "rim_dark": 0, "size": 38, "lines": ["Check it before", "you change it, okay?"], "kept_breaks": true}], "cover": {"lines": ["TOO CLOSE!"], "size": 127, "art_scale": 0.91}}
```

### src/data/english/ep132.json（英語のページのデータ）

サイトの英語のページ1話ぶんのデータです。

```json
{"slug": "ep132", "title": "On the Day It Gets Smarter, Check How It Stops", "label": "MANGA EP132EN", "character": "ハコブくん", "description": "After an overnight AI update, Hakobukun corners smoothly but stops 30 cm closer to Oshiro. A manga on rechecking safe stopping distance after every update.", "published": "2026-10-31T05:59:00.000Z", "cover": "/media/manga_ep132en_cover.webp", "comic": "", "cover_alt": "Episode 132 cover: On the Day It Gets Smarter, Check How It Stops", "comic_alt": "", "format": "vertical", "panels": ["/media/manga_ep132en_p1.webp", "/media/manga_ep132en_p2.webp", "/media/manga_ep132en_p3.webp", "/media/manga_ep132en_p4.webp"], "panel_alts": ["In the morning, Shiraishi explains that she updated Hakobukun's AI to a new version.", "Hakobukun comes around a corner and stops right in front of Oshiro, who leans back in surprise.", "Shiraishi looks at the log on her laptop and notices that the stopping distance has changed.", "Rolled back to the previous version, Hakobukun stops at its usual distance while Shiraishi adds to the checklist."], "body": "<p>■ Behind the improvement, another behavior changed<br>Overnight, Shiraishi updated Hakobukun's AI to a new version. It was supposed to take corridor corners much more smoothly. And sure enough, Hakobukun came around the corner smoothly. But it stopped 30 cm closer than usual to Oshiro, who had just stepped out from the corner. \"Too close!\" Oshiro blurted out.</p><p></p><p>■ A trained AI doesn't change only what you aimed at<br>A trained AI (a system that learns how to move from many examples) can change other behaviors even when an update targets just one part. When Shiraishi checked the log (the record of how it moved), the stopping distance had changed along with the turning. As Hakobukun said, checking how it stops was not on the update checklist. When something isn't checked, no one notices if it changes.</p><p></p><p>■ Conclusion: Recheck how it stops for people after every update<br>Shiraishi added \"stopping distance in front of people\" to the checklist used for every update. Rolled back to the previous version, Hakobukun stops at its usual distance. The way a robot stops to protect people should be handled by a mechanism separate from learning. On top of that, confirm and record after every update that it still works. Oshiro's \"Check it before you change it, okay?\" is about that order: verify before it goes out on the floor.<br>Make the day you improve its movement the day you also check how it stops.</p>"}
```

### src/data/englishCards.json に足した29行

英語版の一覧に並ぶカードのデータのうち、この作業で足した29話ぶんです。

```json
{"slug": "ep133", "title": "Two Months to Collect Six Stamps", "label": "MANGA EP133EN", "character": "ハコブくん", "description": "The robot was ready, but six departments had to sign off first: Pharmacy, Nursing, Facilities, IT, Fire Safety, Patient Safety. A manga on why deployment takes months.", "published": "2026-10-31T06:10:00.000Z", "cover": "/media/manga_ep133en_cover_v2.webp", "comic": "", "cover_alt": "Episode 133 cover: The robot is all set! Above the six department columns on the whiteboard", "comic_alt": ""}
{"slug": "ep132", "title": "On the Day It Gets Smarter, Check How It Stops", "label": "MANGA EP132EN", "character": "ハコブくん", "description": "After an overnight AI update, Hakobukun corners smoothly but stops 30 cm closer to Oshiro. A manga on rechecking safe stopping distance after every update.", "published": "2026-10-31T05:59:00.000Z", "cover": "/media/manga_ep132en_cover.webp", "comic": "", "cover_alt": "Episode 132 cover: On the Day It Gets Smarter, Check How It Stops", "comic_alt": ""}
{"slug": "ep131", "title": "For a Quick Fix, Stop It First", "label": "MANGA EP131EN", "character": "ハコブくん", "description": "Makihara reaches between a jittering robot arm and a shelf for a quick fix. Oshiro hits the e-stop. A manga on making \"stop before approaching\" an operating rule.", "published": "2026-10-31T05:58:00.000Z", "cover": "/media/manga_ep131en_cover.webp", "comic": "", "cover_alt": "Episode 131 cover: For a Quick Fix, Stop It First", "comic_alt": ""}
{"slug": "ep130", "title": "The Hands That Teach Must Learn First", "label": "MANGA EP130EN", "character": "ハコブくん", "description": "Takasugi grabs robot arm Tsukamukun to teach it by hand, and Shiraishi stops her: teachers need special training first. A manga on safety training for teaching robots.", "published": "2026-10-31T05:57:00.000Z", "cover": "/media/manga_ep130en_cover.webp", "comic": "", "cover_alt": "Episode 130 cover: The Hands That Teach Must Learn First", "comic_alt": ""}
{"slug": "ep129", "title": "What the Half-Price Part Left Out", "label": "MANGA EP129EN", "character": "ハコブくん", "description": "Makihara shows off a wireless module bought cheaply overseas. Shiraishi finds no Giteki mark. A manga on checking Japan's radio certification for every add-on part.", "published": "2026-10-31T05:56:00.000Z", "cover": "/media/manga_ep129en_cover.webp", "comic": "", "cover_alt": "Episode 129 cover: What the Half-Price Part Left Out", "comic_alt": ""}
{"slug": "ep128", "title": "Radio Waves Are Shared with the Hospital", "label": "MANGA EP128EN", "character": "ハコブくん", "description": "Makihara fits Hakobukun with a stronger antenna. Right after, a wireless ECG monitor flickers. A manga on getting hospital approval before adding radio equipment.", "published": "2026-10-31T05:55:00.000Z", "cover": "/media/manga_ep128en_cover.webp", "comic": "", "cover_alt": "Episode 128 cover: Radio Waves Are Shared with the Hospital", "comic_alt": ""}
{"slug": "ep127", "title": "Give Way to Those in a Hurry", "label": "MANGA EP127EN", "character": "ハコブくん", "description": "As the elevator closes, Dr. Tanaka rushes in with a stretcher. Hakobukun replies, \"I was here first.\" A manga on teaching robots to yield to emergencies in advance.", "published": "2026-10-31T05:54:00.000Z", "cover": "/media/manga_ep127en_cover.webp", "comic": "", "cover_alt": "Episode 127 cover: Give Way to Those in a Hurry", "comic_alt": ""}
{"slug": "ep126", "title": "When the Bell Rings, Clear the Way", "label": "MANGA EP126EN", "character": "ハコブくん", "description": "A fire alarm rings. Hakobukun keeps waiting at a stopped elevator to deliver an IV drip and blocks a wheelchair—a manga on how robots should yield in an emergency.", "published": "2026-10-31T05:53:00.000Z", "cover": "/media/manga_ep126en_cover.webp", "comic": "", "cover_alt": "Episode 126 cover: When the Bell Rings, Clear the Way", "comic_alt": ""}
{"slug": "ep125", "title": "Put Parking Spots on the Map, Too", "label": "MANGA EP125EN", "character": "ハコブくん", "description": "In a fire drill, a closing fire door bumps into Hakobukun, parked beneath it to charge. A manga on keeping a robot's waiting spots off evacuation routes.", "published": "2026-10-31T05:52:00.000Z", "cover": "/media/manga_ep125en_cover.webp", "comic": "", "cover_alt": "Episode 125 cover: Put Parking Spots on the Map, Too", "comic_alt": ""}
{"slug": "ep124", "title": "Decide First What Not to Carry", "label": "MANGA EP124EN", "character": "ハコブくん", "description": "Oshiro tries to send an opioid injection with Hakobukun, but it refuses: its drawer is not a safe. A manga on keeping some items off the robot until custody rules exist.", "published": "2026-10-31T05:51:00.000Z", "cover": "/media/manga_ep124en_cover.webp", "comic": "", "cover_alt": "Episode 124 cover: Decide First What Not to Carry", "comic_alt": ""}
{"slug": "ep123", "title": "Status Signals Are for Everyone Nearby", "label": "MANGA EP123EN", "character": "ハコブくん", "description": "With its operator away, Pikarikun's eyes show flat lines—yet it walks toward drug storage. Oshiro notices, and an outside connection is found. A manga on status displays.", "published": "2026-10-31T05:50:00.000Z", "cover": "/media/manga_ep123en_cover.webp", "comic": "", "cover_alt": "Episode 123 cover: Status Signals Are for Everyone Nearby", "comic_alt": ""}
{"slug": "ep118", "title": "Show the Line Before You Stop at It", "label": "MANGA EP118EN", "character": "ハコブくん", "description": "Pikarikun carries a discharged patient's bag to the entrance, then stops at a yellow floor line it can't cross—a manga on showing a remote robot's limits up front.", "published": "2026-10-31T05:49:00.000Z", "cover": "/media/manga_ep118en_cover.webp", "comic": "", "cover_alt": "Episode 118 cover: Show the Line Before You Stop at It", "comic_alt": ""}
{"slug": "ep117", "title": "Turning Away Is Also Care", "label": "MANGA EP117EN", "character": "ハコブくん", "description": "A visitor asks if Pikarikun's eyes are cameras. When she starts crying, it silently turns to the wall—a manga on robots visibly showing they are not watching.", "published": "2026-10-31T05:48:00.000Z", "cover": "/media/manga_ep117en_cover.webp", "comic": "", "cover_alt": "Episode 117 cover: Turning Away Is Also Care", "comic_alt": ""}
{"slug": "ep116", "title": "Erasing the Pause Between Moves", "label": "MANGA EP116EN", "character": "ハコブくん", "description": "Pikarikun pauses at every step of a towel handoff because input lags. Queuing the next move mid-motion makes it seamless—a manga on hiding remote-control delay.", "published": "2026-10-31T05:47:00.000Z", "cover": "/media/manga_ep116en_cover.webp", "comic": "", "cover_alt": "Episode 116 cover: Erasing the Pause Between Moves", "comic_alt": ""}
{"slug": "ep115", "title": "Choose Which Voice Reaches the Ward", "label": "MANGA EP115EN", "character": "ハコブくん", "description": "Pikarikun drops a stack of papers, and the remote operator's curse booms from its speaker. A push-to-talk button is added—a manga on which voices should reach the ward.", "published": "2026-10-31T05:46:00.000Z", "cover": "/media/manga_ep115en_cover.webp", "comic": "", "cover_alt": "Episode 115 cover: Choose Which Voice Reaches the Ward", "comic_alt": ""}
{"slug": "ep114", "title": "Watching Over Without Turning On the Lights", "label": "MANGA EP114EN", "character": "ハコブくん", "description": "Mrs. Morita gropes her way out of bed in the dark. Seeing through a low-light camera, Pikarikun lines up her slippers without turning on a light. A manga on night care.", "published": "2026-10-31T05:45:00.000Z", "cover": "/media/manga_ep114en_cover.webp", "comic": "", "cover_alt": "Episode 114 cover: Watching Over Without Turning On the Lights", "comic_alt": ""}
{"slug": "ep113", "title": "Take Off the Assist a Little at a Time", "label": "MANGA EP113EN", "character": "ハコブくん", "description": "Pikarikun's impatient operator switches the AI assist off, and the pen keeps slipping. At half, it's picked up first try. A manga on assist that steps down in stages.", "published": "2026-10-31T05:44:00.000Z", "cover": "/media/manga_ep113en_cover.webp", "comic": "", "cover_alt": "Episode 113 cover: Take Off the Assist a Little at a Time", "comic_alt": ""}
{"slug": "ep111", "title": "Sit Down So You Don't Fall Down", "label": "MANGA EP111EN", "character": "ハコブくん", "description": "Pikarikun loses its connection mid-corridor and sits down on the spot—standing, it would have fallen. A manga on how a two-legged robot should stop safely.", "published": "2026-10-31T05:43:00.000Z", "cover": "/media/manga_ep111en_cover.webp", "comic": "", "cover_alt": "Episode 111 cover: Sit Down So You Don't Fall Down", "comic_alt": ""}
{"slug": "ep109", "title": "Hand It Over Early and Slowly", "label": "MANGA EP109EN", "character": "ハコブくん", "description": "Pikarikun overshoots while handing Oshiro a coffee—commands arrive 0.3 s late. Tsukamukun slows well before her hand. A manga on safe handovers despite lag.", "published": "2026-10-31T05:42:00.000Z", "cover": "/media/manga_ep109en_cover.webp", "comic": "", "cover_alt": "Episode 109 cover: Hand It Over Early and Slowly", "comic_alt": ""}
{"slug": "ep101", "title": "Help Without Touching the Body", "label": "MANGA EP101EN", "character": "ハコブくん", "description": "Alone on the night shift, Oshiro is run ragged. Hakobukun offers to carry a patient; she gives it supply runs instead. A manga on what machines should take on.", "published": "2026-10-31T05:41:00.000Z", "cover": "/media/manga_ep101en_cover.webp", "comic": "", "cover_alt": "Episode 101 cover: Help Without Touching the Body", "comic_alt": ""}
{"slug": "ep100", "title": "At Night, Carry with the Lights Down", "label": "MANGA EP100EN", "character": "ハコブくん", "description": "At 2 a.m., Hakobukun rolls down the hall with lights blazing, announcing itself—and wakes the ward. A manga on switching how a robot moves by time of day.", "published": "2026-10-31T05:40:00.000Z", "cover": "/media/manga_ep100en_cover.webp", "comic": "", "cover_alt": "Episode 100 cover: At Night, Carry with the Lights Down", "comic_alt": ""}
{"slug": "ep99", "title": "Decide Which Movements Call a Nurse", "label": "MANGA EP99EN", "character": "ハコブくん", "description": "On night watch, Hakobukun reports every turn and cough to the nurses' station—while another patient starts to leave his bed. A manga on narrowing alerts to what matters.", "published": "2026-10-31T05:39:00.000Z", "cover": "/media/manga_ep99en_cover_v2.webp", "comic": "", "cover_alt": "Episode 99 cover: Decide Which Movements Call a Nurse", "comic_alt": ""}
{"slug": "ep97", "title": "The Machine Should Adjust to Your Voice", "label": "MANGA EP97EN", "character": "ハコブくん", "description": "Dr. Koshiba calls Hakobukun twice, too softly to be heard, then practices alone at night—a manga on machines adapting to each person's voice.", "published": "2026-10-31T05:38:00.000Z", "cover": "/media/manga_ep97en_cover.webp", "comic": "", "cover_alt": "Episode 97 cover: The Machine Should Adjust to Your Voice", "comic_alt": ""}
{"slug": "ep96", "title": "What You Compare With Changes the Number", "label": "MANGA EP96EN", "character": "ハコブくん", "description": "Dr. Koshiba feels he made nothing but mistakes. Hakobukun compares him with the resident average, then with last week's him—a manga on choosing the baseline for numbers.", "published": "2026-10-31T05:37:00.000Z", "cover": "/media/manga_ep96en_cover.webp", "comic": "", "cover_alt": "Episode 96 cover: What You Compare With Changes the Number", "comic_alt": ""}
{"slug": "ep95", "title": "Ask First, Confirm Later", "label": "MANGA EP95EN", "character": "ハコブくん", "description": "Hakobukun asks “Is gauze all right?” before Dr. Koshiba says what he needs, and he keeps saying yes—a manga on confirm questions that people can't say no to.", "published": "2026-10-31T05:36:00.000Z", "cover": "/media/manga_ep95en_cover.webp", "comic": "", "cover_alt": "Episode 95 cover: Ask First, Confirm Later", "comic_alt": ""}
{"slug": "ep94", "title": "A Practice Partner Who Asks Back", "label": "MANGA EP94EN", "character": "ハコブくん", "description": "Dr. Koshiba rehearses explaining test results, but Hakobukun only says “Understood.” Playing the patient, it fires off questions—a manga on practice that prepares you.", "published": "2026-10-31T05:35:00.000Z", "cover": "/media/manga_ep94en_cover.webp", "comic": "", "cover_alt": "Episode 94 cover: A Practice Partner Who Asks Back", "comic_alt": ""}
{"slug": "ep93", "title": "The Human Decides, the Machine Carries", "label": "MANGA EP93EN", "character": "ハコブくん", "description": "On night duty, Dr. Koshiba asks Hakobukun whether to give a fever reducer, then tries to let its direction decide—a manga on handing judgment over to machines.", "published": "2026-10-31T05:34:00.000Z", "cover": "/media/manga_ep93en_cover.webp", "comic": "", "cover_alt": "Episode 93 cover: The Human Decides, the Machine Carries", "comic_alt": ""}
{"slug": "ep76", "title": "Arriving Is Not the Same as Finishing", "label": "MANGA EP76EN", "character": "ハコブくん", "description": "Hakobukun guides Mr. Iwata to rehab, says “We have arrived,” and leaves him alone on crutches—a manga on ending a task at the handoff, not on arrival.", "published": "2026-10-31T05:33:00.000Z", "cover": "/media/manga_ep76en_cover.webp", "comic": "", "cover_alt": "Episode 76 cover: Arriving Is Not the Same as Finishing", "comic_alt": ""}
{"slug": "ep73", "title": "When Words Don't Reach, Point the Way", "label": "MANGA EP73EN", "character": "ハコブくん", "description": "Hakobukun guides Mr. Morita with “This way, please,” but he is hard of hearing. He points at the map instead—a manga on guiding without relying on words.", "published": "2026-10-31T05:32:00.000Z", "cover": "/media/manga_ep73en_cover.webp", "comic": "", "cover_alt": "Episode 73 cover: When Words Don't Reach, Point the Way", "comic_alt": ""}
```

### MangaPage.astro（マンガのページ。一覧へ戻るリンクを足した）

日本語・英語の全話のページに使う部品の全文です。`archive` と `.back` のところが足した部分です。

```astro
---
// 4コマのページ（日本語・英語版で共通）。下に関連する記録（Studio と同じく HRI の新しい6件＋Load more）
import Base from '../layouts/Base.astro';
import RecordCard from './RecordCard.astro';
import LoadMore from './LoadMore.astro';
import MangaSwipe from './MangaSwipe.astro';
import { ymd } from '../lib/format';
import { media, mediaHtml } from '../lib/media';
import { seriesCards, allManga, allEnglish } from '../lib/records';
import { textOf, abs, AUTHOR, PUBLISHER, SITE, SITE_NAME } from '../lib/seo';
interface Props { m: any; path: string }
const { m, path } = Astro.props;
const related = await seriesCards('HRI');
// 英語版は本文が英語なので、説明文も英語の本文から作る（Studio から移した説明文は日本語のままのため）
const isEn = path.startsWith('/english-4-koma');
const description = (isEn ? textOf(m.body, 150) : m.description) || textOf(m.body, 120);
const pairList = isEn ? allManga : allEnglish;
const alternates = pairList.some((x) => x.slug === m.slug)
  ? [{ lang: isEn ? 'ja' : 'en', path: `${isEn ? '/manga' : '/english-4-koma'}/${m.slug}` }] : [];
// 前の話・次の話（話の番号の順。記事の「前の記事・次の記事」と同じカード。2026-10-31）
const epNo = (x: any) => Number((x.slug.match(/^ep(\d+)$/) || [])[1]);
const seq = (isEn ? allEnglish : allManga).filter((x: any) => !Number.isNaN(epNo(x))).sort((a: any, b: any) => epNo(a) - epNo(b));
const at = seq.findIndex((x: any) => x.slug === m.slug);
const prevEp = at > 0 ? seq[at - 1] : null;
const nextEp = at >= 0 && at < seq.length - 1 ? seq[at + 1] : null;
const base = isEn ? '/english-4-koma' : '/manga';
const epLabel = (x: any) => isEn ? `Episode ${epNo(x)}` : `第${epNo(x)}話`;
// 一覧へ戻る（話のページから一覧に戻る道がヘッダーしかなく迷うため。2026-10-31）
const archive = isEn ? { href: '/manga-archive-en', label: 'All episodes' } : { href: '/manga-archive', label: '4コママンガ一覧' };
const title = isEn ? `${m.title} | 4-Koma Manga | ${SITE_NAME}` : `${m.title} | 4コママンガ | ${SITE_NAME}`;
const jsonLd = {
  '@context': 'https://schema.org', '@type': 'Article',
  headline: m.title, description, url: SITE + path, mainEntityOfPage: SITE + path,
  inLanguage: isEn ? 'en' : 'ja', datePublished: m.published, dateModified: m.published,
  image: (m.panels ? m.panels.map((p: string) => abs(media(p))) : [abs(media(m.comic)), abs(media(m.cover))]).filter(Boolean),
  genre: isEn ? '4-panel comic' : '4コママンガ', author: AUTHOR, publisher: PUBLISHER,
};
---
<Base title={title} description={description} ogImage={media(m.cover) || undefined} path={path}
  lang={isEn ? 'en' : 'ja'} ogType="article" published={m.published} alternates={alternates} jsonLd={jsonLd}>
  <article class="manga">
    <a class="back" href={archive.href}><i class="ms" aria-hidden="true">arrow_back</i>{archive.label}</a>
    <div class="mmeta"><span class="mlabel">{m.label}</span><span class="tag">{m.character}</span><span class="date">{ymd(m.published)}</span></div>
    {m.panels ? <MangaSwipe panels={m.panels} alts={m.panel_alts} title={m.title} /> : m.cover && <img class="mcover" src={media(m.cover)} alt={m.cover_alt || `${m.title}（${m.label} 表紙）`} width="1080" height="1350" />}
    {!m.panels && m.comic && <img class="mcomic" src={media(m.comic)} alt={m.comic_alt || `${m.title}（${m.label} 本編）`} width="1080" height="1350" loading="lazy" />}
    <h1 class="mtitle">{m.title}</h1>
    <div class="mbody" set:html={mediaHtml(m.body)} />
    {(prevEp || nextEp) && (
      <nav class="pager" aria-label={isEn ? 'Previous and next episodes' : '前後の話'}>
        {prevEp ? <a class="pg pg-prev" href={`${base}/${prevEp.slug}`}><span class="pg-k">{isEn ? '← Previous' : '← 前の話'}　{epLabel(prevEp)}</span><span class="pg-t">{prevEp.title}</span></a> : <span />}
        {nextEp ? <a class="pg pg-next" href={`${base}/${nextEp.slug}`}><span class="pg-k">{isEn ? 'Next' : '次の話'}　{epLabel(nextEp)} →</span><span class="pg-t">{nextEp.title}</span></a> : <span />}
      </nav>
    )}
  </article>
  <section class="related">
    <div class="panel">
      <h2 class="sec-h">マンガの背景にある記録 | Related Records</h2>
      <LoadMore limit={6} total={related.length}>{related.map((c) => <RecordCard item={c} />)}</LoadMore>
    </div>
  </section>
</Base>
<style>
  .manga { width: 620px; max-width: calc(100% - 48px); margin: 0 auto; padding: 104px 0 80px; }
  .back { display: inline-flex; align-items: center; gap: 6px; margin-bottom: 20px; font: 400 14px/20px var(--sans); color: var(--ink-3); }
  .back .ms { font-size: 18px; }
  .back:hover { color: var(--ink); text-decoration: underline; }
  .mmeta { display: flex; align-items: center; gap: 12px; margin-bottom: 16px; }
  .mlabel { font: 400 28px/28px var(--sans); }
  .mcover, .mcomic { width: 100%; height: auto; }
  .mtitle { margin: 24px 0 12px; font: 600 20px/30px var(--sans); }
  .mbody :global(p) { font: 400 16px/2 'Noto Sans JP', sans-serif; margin: 0 0 16px; color: #333; }
  /* 前の話・次の話：記事ページ（record/[slug].astro）の .pager と同じ形 */
  .pager { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin: 64px 0 0; padding-top: 32px; border-top: 1px solid #ddd; }
  .pg { display: flex; flex-direction: column; gap: 8px; padding: 16px; border-radius: 8px; background: #fff; box-shadow: 0 8px 16px rgba(0,0,0,.08); }
  .pg:hover .pg-t { text-decoration: underline; }
  .pg-next .pg-k { text-align: right; }
  .pg-k { font: 400 12px/18px var(--sans); color: var(--ink-3); }
  .pg-t { font: 600 14px/1.6 var(--sans); color: var(--ink); overflow-wrap: anywhere; }
  @media (max-width: 599px) { .pager { grid-template-columns: 1fr; } .pg-next .pg-k { text-align: left; } }
</style>
```

### MangaSwipe.astro の直し（NaN / 4）

読み込み直後に表示の幅が0のとき、0で割らないようにした差分です。

```diff
diff --git a/src/components/MangaSwipe.astro b/src/components/MangaSwipe.astro
index 09bb53d..8a4ad66 100644
--- a/src/components/MangaSwipe.astro
+++ b/src/components/MangaSwipe.astro
@@ -27,7 +27,7 @@ const n = panels.length;
     const next = box.querySelector<HTMLButtonElement>('.msw-next')!;
     const now = box.querySelector<HTMLElement>('.msw-now')!;
     const n = Number(box.dataset.n) || 1;
-    const at = () => Math.round(track.scrollLeft / track.clientWidth);
+    const at = () => Math.round(track.scrollLeft / (track.clientWidth || 1)); // 幅が 0 のうち（読み込み直後）に NaN にならないように
     // ボタンで送るとき、コマが画面の上下で切れていたら、コマ全体が見える位置まで動かす
     const fit = () => {
       const r = track.getBoundingClientRect();
```

### TASK.md（X の英語の文を書く作業役への指示）

125話を4人の作業役に分けて渡した指示です。

````markdown
# English X posts for the Hakobukun 4-panel manga

Folder: /tmp/claude-0/b5fb089a-2072-58da-b9f4-edd7b19154c4/scratchpad/xen/
Input: `episodes.json` (one object per episode: slug, ep, title, description, body = the design note, url, images, panel_alts, format).

The comic is about Hakobukun, a hospital medicine-delivery robot, and the nurses, doctors, patients and engineers around it (plus Pikarikun, a remote-operated humanoid, and Tsukamukun, a robot arm). Each episode is a small gag built on a real human-robot interaction (HRI) or deployment issue. The creator is a Japanese designer; the audience is English speakers on X (US/UK first): robotics and AI people, healthcare workers, UX/design people, and webcomic readers.

Each post goes out with the comic images attached (vertical episodes: the 4 panels as 4 images; single-strip episodes: one image of the whole strip). The link to the site goes in a self-reply, not in the post.

## Write one JSON file per episode: `out/{slug}.json`

```json
{"slug": "ep132", "x_en": "...", "x_en_reply": "...", "x_en_alts": ["..."]}
```

### x_en (the post)
- Goal: someone scrolling stops, reads the panels, and maybe replies. Natural, conversational American English — it must not read like a translation.
- Line 1 is the hook: the situation, the robot's quirk, or a question people recognize from their own work ("Ever had a 'quick fix' turn into the dangerous part?"). Short.
- 1–2 more short lines of setup. Do NOT give away the last panel's punchline — leave a reason to look at the images.
- Let Hakobukun's personality come through (earnest, literal, polite, a bit robotic).
- Then a line with the series marker exactly like: `Hakobukun · Ep. 132`
- Last line: exactly 2 hashtags. The first is always `#webcomic`. The second is the one topic tag that best fits this episode, chosen from: `#robotics` `#healthcare` `#nursing` `#AI` `#UX` `#cybersecurity`. (Use #nursing only when nurses' work is the center; #healthcare for hospital operations/patients; #AI for model/update/learning stories; #UX for interface/communication design; #cybersecurity for security stories; otherwise #robotics.)
- No URL in x_en. No emoji, or at most one.
- Length: total under 240 characters (count every character; keep it tight).
- Separate lines with `\n`, a blank line before the series line is fine.

Example (ep132, a robot got an overnight AI update; turning got smoother but its stopping distance changed and nobody checked):
```
The robot got an AI update overnight. Corners? Silky smooth now.

Nobody checked how it stops, though.

Hakobukun · Ep. 132
#webcomic #AI
```

### x_en_reply (self-reply that carries the link)
- One short line + the episode URL from the input (`url`), e.g. `Read it with the design note behind the gag: https://...` Vary the wording a little between episodes (3–4 variants are fine). Under 200 characters.

### x_en_alts (image descriptions for screen readers)
- One string per image in `images`, same order.
- Vertical episodes: start from `panel_alts` and make each a clear one-sentence description (keep it under 300 characters). Do not add dialogue that is not in the panel_alts/body.
- Single-strip episodes (one image): one description of the whole 4-panel strip in 1–3 sentences, built from the description and body (who, what happens, without spoiling more than the post does is NOT required here — alt text should describe the image honestly). Under 500 characters.

## Check before finishing
- Every file is valid JSON (`python3 -c "import json;json.load(open(...))"`).
- x_en: no URL, ends with the 2-hashtag line, first hashtag #webcomic, under 240 characters.
- Number of x_en_alts == number of images.
- Report the files you wrote and anything odd (e.g. an episode whose body didn't make the gag clear).
````

### X の文を書いた作業役の報告（ep01–ep38）

作業役の報告です。

```
I wrote all 32 files (ep01 through ep38, list positions 0–31) and all of them pass every check in the TASK.

**Checks:** every file loads as valid JSON. In each `x_en` there is no URL, the series line reads `Hakobukun · Ep. N`, and the last line is `#webcomic` plus one tag from the allowed list. The longest post is 184 characters (the limit is 240). Every reply contains the episode's `url`, and I rotated four different wordings across them. All 32 episodes are single-strip, so each one has exactly 1 alt text, and the longest is 357 characters (the limit is 500).

**Topic tags used:**
| Tag | Episodes |
|---|---|
| #robotics | 13 |
| #UX | 10 |
| #AI | 3 |
| #nursing | 2 |
| #healthcare | 2 |
| #cybersecurity | 1 (ep19, the face-recognition lockout) |

**Things that look odd in the input:**
- **Descriptions that don't match the body.** Several Japanese descriptions are about a different topic from their design note: ep10 (managing expectations vs. counting sighs), ep12 (AI reading context vs. what "fastest" means), ep14 (忖度, reading between the lines), ep15 (the "weak robot" idea) and ep16 (the description fits ep14's non-verbal UI). It looks like they were copied from other episodes or shifted by one. Whenever the two disagreed, I followed the body.
- **Gags I had to infer.** For ep01, ep02, ep04, ep10, ep12, ep14 and ep17, the body explains the design idea but doesn't spell out what happens panel by panel, so the alt texts there describe only what the body supports. These are the ones to check against the actual images.
- **ep35: the second robot isn't named.** The body only says "two robots" cheering each other on. I described it that way and kept Hakobukun out of the alt text.
- **Formatting debris in the bodies.** ep21, ep22, ep28 and ep33 have stray line breaks inside sentences (it looks like leftovers from HTML emphasis), and the ep01 title has a double space. None of this affects the output files.
- **The out/ folder holds 125 files in total.** The other 93 aren't from this batch; I assume other workers wrote them.

The script I used to generate the files is `/tmp/claude-0/-home-claude/b5fb089a-2072-58da-b9f4-edd7b19154c4/scratchpad/gen_xen.py`.

Files are in `/tmp/claude-0/b5fb089a-2072-58da-b9f4-edd7b19154c4/scratchpad/xen/out/`:
- ep01.json
- ep02.json
- ep03.json
- ep04.json
- ep07.json
- ep09.json
- ep10.json
- ep11.json
- ep12.json
- ep14.json
- ep15.json
- ep16.json
- ep17.json
- ep18.json
- ep19.json
- ep20.json
- ep21.json
- ep22.json
- ep23.json
- ep24.json
- ep25.json
- ep26.json
- ep27.json
- ep28.json
- ep29.json
- ep30.json
- ep31.json
- ep33.json
- ep34.json
- ep35.json
- ep37.json
- ep38.json
```

### X の文を書いた作業役の報告（ep39–ep71）

作業役の報告です。

```
I wrote all 32 files for positions 32–63 (ep39 to ep71, including ep56en), and every one passes the TASK checks.

**Files:** `/tmp/claude-0/b5fb089a-2072-58da-b9f4-edd7b19154c4/scratchpad/xen/out/{slug}.json`

**Checks:**
- Every file loads as valid JSON.
- No `x_en` contains a URL, and each ends with `#webcomic` plus one topic tag.
- The longest `x_en` is 189 characters, under the 240 limit.
- Each file has one alt text for its one image. The longest alt is 368 characters, under 500.
- Every reply is under 200 characters, contains the episode's URL, and rotates through 4 wordings.

**Topic tags:**

| Tag | Episodes |
|---|---|
| #UX | 42, 43, 44, 46, 47, 48, 50, 53, 58 |
| #robotics | 39, 40, 41, 49, 51, 57, 60, 66, 67, 69, 71 |
| #AI | 52, 54, 59, 62, 64, 70 |
| #healthcare | 45, 55, 56en, 68 |
| #nursing | 61 |
| #cybersecurity | 65 |

**Things that looked odd:**
- **No ep63 in this range:** the input goes from ep62 straight to ep64, so it may be missing from `episodes.json`.
- **Empty descriptions:** ep47 and ep54 have no `description`, so I built their posts and alts from the design note alone. The gag is still clear in both.
- **ep56en:** the slug is `ep56en` but the `ep` field is 56, so the series line reads `Hakobukun · Ep. 56`. The URL ends in `/ep56en`, which is the one in the reply.
- **ep65:** the design note never says what happens in the last panel; it only gives the lesson (set permissions per function). The alt text describes the lost visitor being refused and stops short of a resolution, because I couldn't tell what the image shows there.
- **ep62:** the note doesn't make clear which panel has the robot stopping and turning back, or what the nurse lost. The alt text stays as general as the note.
```

### X の文を書いた作業役の報告（ep72–ep103）

作業役の報告です。

```
I wrote all 32 files for ep72–ep103 (list positions 64–95), and every file passed the TASK checks.

The files are in `/tmp/claude-0/b5fb089a-2072-58da-b9f4-edd7b19154c4/scratchpad/xen/out/`, named `ep72.json` through `ep103.json`.

**Checks:**
- Every file is valid JSON.
- No `x_en` contains a URL. Each one ends with exactly two hashtags, starting with `#webcomic`, and includes the `Hakobukun · Ep. N` line.
- Post length runs from 148 to 189 characters, all under the 240 limit.
- Every reply contains the episode's URL, stays under 200 characters, and uses one of 4 wording variants in rotation.
- Every episode has as many alt texts as images. Vertical episodes have 4, each under 300 characters. Single-strip episodes have 1, under 500 characters.

**Topic tags:**
- `#UX`: 11 episodes
- `#robotics`: 9
- `#healthcare`: 7
- `#AI`: 3 (ep86, ep90, ep93)
- `#nursing`: 2 (ep99, ep101)
- `#cybersecurity`: 1 (ep84, the patient faking his doctor's voice)

**Odd things to note:**
- **ep72:** the note only says the robot gave "the same line, twice." It never says what the line was, so the post and alt text avoid quoting it.
- **ep102 and ep103:** these are not set in the hospital. ep102 has two Tsukamukun robot arms and a night-shift worker, in an unclear setting. ep103 is a deli factory with an unnamed robot. I didn't call either one a hospital, and the ep103 post doesn't mention Hakobukun except in the series line.
- **ep91:** the resident isn't named. Koshiba is the resident in nearby episodes, but I didn't assume it's him.
- **ep84:** the note only says the patient has "a good impression." I wrote this as a voice impression of his doctor.
- **ep93, ep94, ep95:** the original panel descriptions were good enough to use as they were. In the vertical episodes, my only change was adding "the hospital robot" to the first panel's description.

The script that generated the files is `/tmp/claude-0/b5fb089a-2072-58da-b9f4-edd7b19154c4/scratchpad/xen_gen/gen_72_103.py`.
```

### X の文を書いた作業役の報告（ep104–ep133）

作業役の報告です。

```
I wrote all 29 files, ep104 through ep133, and every one passes the checks in the TASK.

**Check results:**
- Every file is valid JSON.
- No post contains a URL.
- Every post has the `Hakobukun · Ep. N` line and ends with `#webcomic` plus one tag from the allowed list.
- Posts run 137 to 208 characters, all under 240.
- The number of alt texts matches the number of images: 1 for each single-strip episode, 4 for each vertical one.
- Every self-reply contains the episode URL and is under 200 characters. The wording rotates through 4 variants.
- Alt texts stay under 300 characters for vertical episodes and under 500 for single-strip ones.

**Second hashtags:** #healthcare 9, #robotics 9, #UX 7, #cybersecurity 3, #nursing 1 (ep124, the opioid hand-carry), #AI 1 (ep132).

**Things that look odd:**
- **No ep119.** Position 96–124 in the list maps to ep104–ep118 and then ep120–ep133. It is 29 episodes, but there is no ep119 in it. It may be missing from episodes.json on purpose or by accident.
- **ep112 patient name.** The isolated patient "Morita" is a man ("his longest talk"). In ep114 and ep121 the patient is Mrs. Kumiko Morita. I followed each body as written ("Mr. Morita" in ep112's alt text), but the source may be inconsistent.
- **ep125 Terashima.** This character appears only here, and the body doesn't say what their role is, so the alt text uses just the name.
- **Character labels in alt texts.** I added short labels from the episode bodies ("the nurse Oshiro", "the engineer Shiraishi", "Pikarikun, a remote-operated humanoid robot"). No dialogue was added beyond what the panel_alts and body contain.
- **ep133 punctuation.** Its panel_alts had no ending periods, so I added them.

All the gags were clear from the bodies.

The generator script is `/tmp/claude-0/b5fb089a-2072-58da-b9f4-edd7b19154c4/scratchpad/xen_work/gen.py`, and the files are in `/tmp/claude-0/b5fb089a-2072-58da-b9f4-edd7b19154c4/scratchpad/xen/out/` (ep104.json … ep133.json).
```

### sns/MANGA_ep132_sns_posts.json（第132話の投稿文）

日本語の投稿文に、英語の X の本文（x_en）・画像（x_en_images）・画像の説明（x_en_alts）・リンクの返信（x_en_reply）を足した形です。

```json
{
 "meta": {
  "episode": 132,
  "date": "2026-10-31",
  "title": "第132話 更新のあと",
  "url": "https://pixelstomotors.com/manga/ep132",
  "title_en": "Episode 132 On the Day It Gets Smarter, Check How It Stops",
  "url_en": "https://pixelstomotors.com/english-4-koma/ep132"
 },
 "posts": {
  "x": "AIを新しくしたら、曲がり方は滑らかに。でも、人の前で止まる位置が30cm近くなってた。\n\n狙った所だけ良くなる、とはいかないんだよね。更新した日は、止まり方も確かめる日。4コマ『ハコブくん』第132話\nhttps://pixelstomotors.com/manga/ep132",
  "linkedin": "夜のうちに、白石さんがハコブくんのAIを新しい版にした。狙いは、廊下の曲がり方を滑らかにすること。実際、ハコブくんは角をきれいに曲がってきた。ところが、角から出てきた大城さんの手前で止まる位置が、いつもより30cm近い。「近っ！」。病院で物を運ぶロボット、ハコブくんの4コママンガ（フィクション）の第132話だよ。\n\nたくさんの例から動き方を身につけるAIは、一部を良くしようと更新しても、ほかの動きまで変わることがあるんだよね。白石さんがログ（動いた記録）を見ると、曲がり方と一緒に止まる距離も変わっていた。ハコブくんいわく「止マリ方ノ確認ハ、更新ノ項目ニアリマセンデシタ」。確かめていない所は、変わっても誰も気づかない。自分も学習でロボットアームを動かしているから、これは他人事じゃない。\n\nマンガでは、白石さんが更新のたびに確かめる表に「人の前で止まる距離」を書き足して、いったん前の版に戻す。人を守る止まり方は学習とは別の仕組みで持っておいて、そのうえで更新のたびに変わらず効くかを確かめる。大城さんの「直す前に、確かめてね」は、その順番のことなんだと思う。\n\n皆さんの職場では、システムを更新したとき、良くなった所以外に「変わっていないこと」をどう確かめていますか？\n\nhttps://pixelstomotors.com/manga/ep132",
  "facebook": "この回は、自分の研究にいちばん近い話かも。\n\nうちでも小さなロボットアームにAIを入れて、動きを覚えさせる練習をしてる。そうすると、ある動きを良くしたつもりが、別の所までなんとなく変わることがあるんだよね。人がプログラムを書き換えるのと違って、狙った所だけをいじる、というのが難しい。\n\nそれを病院のロボットに置きかえたら、曲がり方は良くなったのに、人の前で止まる位置が30cm近くなった、という場面になった。30cmって、言われると小さいけど、実際に目の前まで来られたらびっくりする距離。大城さんの「近っ！」は、描いてて自分でも笑った。\n\n動きを良くした日は、止まり方も確かめる日。自分の実験でも、ちゃんとそうしようと思う。\n\nhttps://pixelstomotors.com/manga/ep132",
  "x_en": "The robot got an AI update overnight. Corners? Silky smooth now.\n\nNobody checked how it stops, though.\n\nHakobukun · Ep. 132\n#webcomic #AI",
  "x_en_images": [
   "/media/manga_ep132en_p1.webp",
   "/media/manga_ep132en_p2.webp",
   "/media/manga_ep132en_p3.webp",
   "/media/manga_ep132en_p4.webp"
  ],
  "x_en_alts": [
   "In the morning, the engineer Shiraishi explains that she updated the AI of Hakobukun, the delivery robot, to a new version.",
   "Hakobukun comes around a corner and stops right in front of the nurse Oshiro, who leans back in surprise.",
   "Shiraishi looks at the log on her laptop and notices that the stopping distance has changed.",
   "Rolled back to the previous version, Hakobukun stops at its usual distance while Shiraishi adds to the checklist."
  ],
  "x_en_reply": "Why this happens in real deployments, with the full strip: https://pixelstomotors.com/english-4-koma/ep132"
 }
}
```

### sns/schedule.json に足した125行

英語の X の予定の全部です。

```json
{"id": "MANGA_ep73-x-en", "source": "sns/MANGA_ep73_sns_posts.json", "platform": "x", "lang": "en", "post_at": "2026-11-02T11:00:00-05:00"}
{"id": "MANGA_ep1-x-en", "source": "sns/MANGA_ep1_sns_posts.json", "platform": "x", "lang": "en", "post_at": "2026-11-02T15:00:00-05:00"}
{"id": "MANGA_ep76-x-en", "source": "sns/MANGA_ep76_sns_posts.json", "platform": "x", "lang": "en", "post_at": "2026-11-03T11:00:00-05:00"}
{"id": "MANGA_ep2-x-en", "source": "sns/MANGA_ep2_sns_posts.json", "platform": "x", "lang": "en", "post_at": "2026-11-03T15:00:00-05:00"}
{"id": "MANGA_ep93-x-en", "source": "sns/MANGA_ep93_sns_posts.json", "platform": "x", "lang": "en", "post_at": "2026-11-04T11:00:00-05:00"}
{"id": "MANGA_ep3-x-en", "source": "sns/MANGA_ep3_sns_posts.json", "platform": "x", "lang": "en", "post_at": "2026-11-04T15:00:00-05:00"}
{"id": "MANGA_ep94-x-en", "source": "sns/MANGA_ep94_sns_posts.json", "platform": "x", "lang": "en", "post_at": "2026-11-05T11:00:00-05:00"}
{"id": "MANGA_ep4-x-en", "source": "sns/MANGA_ep4_sns_posts.json", "platform": "x", "lang": "en", "post_at": "2026-11-05T15:00:00-05:00"}
{"id": "MANGA_ep95-x-en", "source": "sns/MANGA_ep95_sns_posts.json", "platform": "x", "lang": "en", "post_at": "2026-11-06T11:00:00-05:00"}
{"id": "MANGA_ep7-x-en", "source": "sns/MANGA_ep7_sns_posts.json", "platform": "x", "lang": "en", "post_at": "2026-11-06T15:00:00-05:00"}
{"id": "MANGA_ep96-x-en", "source": "sns/MANGA_ep96_sns_posts.json", "platform": "x", "lang": "en", "post_at": "2026-11-09T11:00:00-05:00"}
{"id": "MANGA_ep9-x-en", "source": "sns/MANGA_ep9_sns_posts.json", "platform": "x", "lang": "en", "post_at": "2026-11-09T15:00:00-05:00"}
{"id": "MANGA_ep97-x-en", "source": "sns/MANGA_ep97_sns_posts.json", "platform": "x", "lang": "en", "post_at": "2026-11-10T11:00:00-05:00"}
{"id": "MANGA_ep10-x-en", "source": "sns/MANGA_ep10_sns_posts.json", "platform": "x", "lang": "en", "post_at": "2026-11-10T15:00:00-05:00"}
{"id": "MANGA_ep99-x-en", "source": "sns/MANGA_ep99_sns_posts.json", "platform": "x", "lang": "en", "post_at": "2026-11-11T11:00:00-05:00"}
{"id": "MANGA_ep11-x-en", "source": "sns/MANGA_ep11_sns_posts.json", "platform": "x", "lang": "en", "post_at": "2026-11-11T15:00:00-05:00"}
{"id": "MANGA_ep100-x-en", "source": "sns/MANGA_ep100_sns_posts.json", "platform": "x", "lang": "en", "post_at": "2026-11-12T11:00:00-05:00"}
{"id": "MANGA_ep12-x-en", "source": "sns/MANGA_ep12_sns_posts.json", "platform": "x", "lang": "en", "post_at": "2026-11-12T15:00:00-05:00"}
{"id": "MANGA_ep101-x-en", "source": "sns/MANGA_ep101_sns_posts.json", "platform": "x", "lang": "en", "post_at": "2026-11-13T11:00:00-05:00"}
{"id": "MANGA_ep14-x-en", "source": "sns/MANGA_ep14_sns_posts.json", "platform": "x", "lang": "en", "post_at": "2026-11-13T15:00:00-05:00"}
{"id": "MANGA_ep109-x-en", "source": "sns/MANGA_ep109_sns_posts.json", "platform": "x", "lang": "en", "post_at": "2026-11-16T11:00:00-05:00"}
{"id": "MANGA_ep15-x-en", "source": "sns/MANGA_ep15_sns_posts.json", "platform": "x", "lang": "en", "post_at": "2026-11-16T15:00:00-05:00"}
{"id": "MANGA_ep111-x-en", "source": "sns/MANGA_ep111_sns_posts.json", "platform": "x", "lang": "en", "post_at": "2026-11-17T11:00:00-05:00"}
{"id": "MANGA_ep16-x-en", "source": "sns/MANGA_ep16_sns_posts.json", "platform": "x", "lang": "en", "post_at": "2026-11-17T15:00:00-05:00"}
{"id": "MANGA_ep113-x-en", "source": "sns/MANGA_ep113_sns_posts.json", "platform": "x", "lang": "en", "post_at": "2026-11-18T11:00:00-05:00"}
{"id": "MANGA_ep17-x-en", "source": "sns/MANGA_ep17_sns_posts.json", "platform": "x", "lang": "en", "post_at": "2026-11-18T15:00:00-05:00"}
{"id": "MANGA_ep114-x-en", "source": "sns/MANGA_ep114_sns_posts.json", "platform": "x", "lang": "en", "post_at": "2026-11-19T11:00:00-05:00"}
{"id": "MANGA_ep18-x-en", "source": "sns/MANGA_ep18_sns_posts.json", "platform": "x", "lang": "en", "post_at": "2026-11-19T15:00:00-05:00"}
{"id": "MANGA_ep115-x-en", "source": "sns/MANGA_ep115_sns_posts.json", "platform": "x", "lang": "en", "post_at": "2026-11-20T11:00:00-05:00"}
{"id": "MANGA_ep19-x-en", "source": "sns/MANGA_ep19_sns_posts.json", "platform": "x", "lang": "en", "post_at": "2026-11-20T15:00:00-05:00"}
{"id": "MANGA_ep116-x-en", "source": "sns/MANGA_ep116_sns_posts.json", "platform": "x", "lang": "en", "post_at": "2026-11-23T11:00:00-05:00"}
{"id": "MANGA_ep20-x-en", "source": "sns/MANGA_ep20_sns_posts.json", "platform": "x", "lang": "en", "post_at": "2026-11-23T15:00:00-05:00"}
{"id": "MANGA_ep117-x-en", "source": "sns/MANGA_ep117_sns_posts.json", "platform": "x", "lang": "en", "post_at": "2026-11-24T11:00:00-05:00"}
{"id": "MANGA_ep21-x-en", "source": "sns/MANGA_ep21_sns_posts.json", "platform": "x", "lang": "en", "post_at": "2026-11-24T15:00:00-05:00"}
{"id": "MANGA_ep118-x-en", "source": "sns/MANGA_ep118_sns_posts.json", "platform": "x", "lang": "en", "post_at": "2026-11-25T11:00:00-05:00"}
{"id": "MANGA_ep22-x-en", "source": "sns/MANGA_ep22_sns_posts.json", "platform": "x", "lang": "en", "post_at": "2026-11-25T15:00:00-05:00"}
{"id": "MANGA_ep123-x-en", "source": "sns/MANGA_ep123_sns_posts.json", "platform": "x", "lang": "en", "post_at": "2026-11-26T11:00:00-05:00"}
{"id": "MANGA_ep23-x-en", "source": "sns/MANGA_ep23_sns_posts.json", "platform": "x", "lang": "en", "post_at": "2026-11-26T15:00:00-05:00"}
{"id": "MANGA_ep124-x-en", "source": "sns/MANGA_ep124_sns_posts.json", "platform": "x", "lang": "en", "post_at": "2026-11-27T11:00:00-05:00"}
{"id": "MANGA_ep24-x-en", "source": "sns/MANGA_ep24_sns_posts.json", "platform": "x", "lang": "en", "post_at": "2026-11-27T15:00:00-05:00"}
{"id": "MANGA_ep125-x-en", "source": "sns/MANGA_ep125_sns_posts.json", "platform": "x", "lang": "en", "post_at": "2026-11-30T11:00:00-05:00"}
{"id": "MANGA_ep25-x-en", "source": "sns/MANGA_ep25_sns_posts.json", "platform": "x", "lang": "en", "post_at": "2026-11-30T15:00:00-05:00"}
{"id": "MANGA_ep126-x-en", "source": "sns/MANGA_ep126_sns_posts.json", "platform": "x", "lang": "en", "post_at": "2026-12-01T11:00:00-05:00"}
{"id": "MANGA_ep26-x-en", "source": "sns/MANGA_ep26_sns_posts.json", "platform": "x", "lang": "en", "post_at": "2026-12-01T15:00:00-05:00"}
{"id": "MANGA_ep127-x-en", "source": "sns/MANGA_ep127_sns_posts.json", "platform": "x", "lang": "en", "post_at": "2026-12-02T11:00:00-05:00"}
{"id": "MANGA_ep27-x-en", "source": "sns/MANGA_ep27_sns_posts.json", "platform": "x", "lang": "en", "post_at": "2026-12-02T15:00:00-05:00"}
{"id": "MANGA_ep128-x-en", "source": "sns/MANGA_ep128_sns_posts.json", "platform": "x", "lang": "en", "post_at": "2026-12-03T11:00:00-05:00"}
{"id": "MANGA_ep28-x-en", "source": "sns/MANGA_ep28_sns_posts.json", "platform": "x", "lang": "en", "post_at": "2026-12-03T15:00:00-05:00"}
{"id": "MANGA_ep129-x-en", "source": "sns/MANGA_ep129_sns_posts.json", "platform": "x", "lang": "en", "post_at": "2026-12-04T11:00:00-05:00"}
{"id": "MANGA_ep29-x-en", "source": "sns/MANGA_ep29_sns_posts.json", "platform": "x", "lang": "en", "post_at": "2026-12-04T15:00:00-05:00"}
{"id": "MANGA_ep130-x-en", "source": "sns/MANGA_ep130_sns_posts.json", "platform": "x", "lang": "en", "post_at": "2026-12-07T11:00:00-05:00"}
{"id": "MANGA_ep30-x-en", "source": "sns/MANGA_ep30_sns_posts.json", "platform": "x", "lang": "en", "post_at": "2026-12-07T15:00:00-05:00"}
{"id": "MANGA_ep131-x-en", "source": "sns/MANGA_ep131_sns_posts.json", "platform": "x", "lang": "en", "post_at": "2026-12-08T11:00:00-05:00"}
{"id": "MANGA_ep31-x-en", "source": "sns/MANGA_ep31_sns_posts.json", "platform": "x", "lang": "en", "post_at": "2026-12-08T15:00:00-05:00"}
{"id": "MANGA_ep132-x-en", "source": "sns/MANGA_ep132_sns_posts.json", "platform": "x", "lang": "en", "post_at": "2026-12-09T11:00:00-05:00"}
{"id": "MANGA_ep33-x-en", "source": "sns/MANGA_ep33_sns_posts.json", "platform": "x", "lang": "en", "post_at": "2026-12-09T15:00:00-05:00"}
{"id": "MANGA_ep133-x-en", "source": "sns/MANGA_ep133_sns_posts.json", "platform": "x", "lang": "en", "post_at": "2026-12-10T11:00:00-05:00"}
{"id": "MANGA_ep34-x-en", "source": "sns/MANGA_ep34_sns_posts.json", "platform": "x", "lang": "en", "post_at": "2026-12-10T15:00:00-05:00"}
{"id": "MANGA_ep35-x-en", "source": "sns/MANGA_ep35_sns_posts.json", "platform": "x", "lang": "en", "post_at": "2026-12-11T11:00:00-05:00"}
{"id": "MANGA_ep37-x-en", "source": "sns/MANGA_ep37_sns_posts.json", "platform": "x", "lang": "en", "post_at": "2026-12-11T15:00:00-05:00"}
{"id": "MANGA_ep38-x-en", "source": "sns/MANGA_ep38_sns_posts.json", "platform": "x", "lang": "en", "post_at": "2026-12-14T11:00:00-05:00"}
{"id": "MANGA_ep39-x-en", "source": "sns/MANGA_ep39_sns_posts.json", "platform": "x", "lang": "en", "post_at": "2026-12-14T15:00:00-05:00"}
{"id": "MANGA_ep40-x-en", "source": "sns/MANGA_ep40_sns_posts.json", "platform": "x", "lang": "en", "post_at": "2026-12-15T11:00:00-05:00"}
{"id": "MANGA_ep41-x-en", "source": "sns/MANGA_ep41_sns_posts.json", "platform": "x", "lang": "en", "post_at": "2026-12-15T15:00:00-05:00"}
{"id": "MANGA_ep42-x-en", "source": "sns/MANGA_ep42_sns_posts.json", "platform": "x", "lang": "en", "post_at": "2026-12-16T11:00:00-05:00"}
{"id": "MANGA_ep43-x-en", "source": "sns/MANGA_ep43_sns_posts.json", "platform": "x", "lang": "en", "post_at": "2026-12-16T15:00:00-05:00"}
{"id": "MANGA_ep44-x-en", "source": "sns/MANGA_ep44_sns_posts.json", "platform": "x", "lang": "en", "post_at": "2026-12-17T11:00:00-05:00"}
{"id": "MANGA_ep45-x-en", "source": "sns/MANGA_ep45_sns_posts.json", "platform": "x", "lang": "en", "post_at": "2026-12-17T15:00:00-05:00"}
{"id": "MANGA_ep46-x-en", "source": "sns/MANGA_ep46_sns_posts.json", "platform": "x", "lang": "en", "post_at": "2026-12-18T11:00:00-05:00"}
{"id": "MANGA_ep47-x-en", "source": "sns/MANGA_ep47_sns_posts.json", "platform": "x", "lang": "en", "post_at": "2026-12-18T15:00:00-05:00"}
{"id": "MANGA_ep48-x-en", "source": "sns/MANGA_ep48_sns_posts.json", "platform": "x", "lang": "en", "post_at": "2026-12-21T11:00:00-05:00"}
{"id": "MANGA_ep49-x-en", "source": "sns/MANGA_ep49_sns_posts.json", "platform": "x", "lang": "en", "post_at": "2026-12-21T15:00:00-05:00"}
{"id": "MANGA_ep50-x-en", "source": "sns/MANGA_ep50_sns_posts.json", "platform": "x", "lang": "en", "post_at": "2026-12-22T11:00:00-05:00"}
{"id": "MANGA_ep51-x-en", "source": "sns/MANGA_ep51_sns_posts.json", "platform": "x", "lang": "en", "post_at": "2026-12-22T15:00:00-05:00"}
{"id": "MANGA_ep52-x-en", "source": "sns/MANGA_ep52_sns_posts.json", "platform": "x", "lang": "en", "post_at": "2026-12-23T11:00:00-05:00"}
{"id": "MANGA_ep53-x-en", "source": "sns/MANGA_ep53_sns_posts.json", "platform": "x", "lang": "en", "post_at": "2026-12-23T15:00:00-05:00"}
{"id": "MANGA_ep54-x-en", "source": "sns/MANGA_ep54_sns_posts.json", "platform": "x", "lang": "en", "post_at": "2026-12-24T11:00:00-05:00"}
{"id": "MANGA_ep55-x-en", "source": "sns/MANGA_ep55_sns_posts.json", "platform": "x", "lang": "en", "post_at": "2026-12-24T15:00:00-05:00"}
{"id": "MANGA_ep56-x-en", "source": "sns/MANGA_ep56_sns_posts.json", "platform": "x", "lang": "en", "post_at": "2026-12-25T11:00:00-05:00"}
{"id": "MANGA_ep57-x-en", "source": "sns/MANGA_ep57_sns_posts.json", "platform": "x", "lang": "en", "post_at": "2026-12-25T15:00:00-05:00"}
{"id": "MANGA_ep58-x-en", "source": "sns/MANGA_ep58_sns_posts.json", "platform": "x", "lang": "en", "post_at": "2026-12-28T11:00:00-05:00"}
{"id": "MANGA_ep59-x-en", "source": "sns/MANGA_ep59_sns_posts.json", "platform": "x", "lang": "en", "post_at": "2026-12-28T15:00:00-05:00"}
{"id": "MANGA_ep60-x-en", "source": "sns/MANGA_ep60_sns_posts.json", "platform": "x", "lang": "en", "post_at": "2026-12-29T11:00:00-05:00"}
{"id": "MANGA_ep61-x-en", "source": "sns/MANGA_ep61_sns_posts.json", "platform": "x", "lang": "en", "post_at": "2026-12-29T15:00:00-05:00"}
{"id": "MANGA_ep62-x-en", "source": "sns/MANGA_ep62_sns_posts.json", "platform": "x", "lang": "en", "post_at": "2026-12-30T11:00:00-05:00"}
{"id": "MANGA_ep64-x-en", "source": "sns/MANGA_ep64_sns_posts.json", "platform": "x", "lang": "en", "post_at": "2026-12-30T15:00:00-05:00"}
{"id": "MANGA_ep65-x-en", "source": "sns/MANGA_ep65_sns_posts.json", "platform": "x", "lang": "en", "post_at": "2026-12-31T11:00:00-05:00"}
{"id": "MANGA_ep66-x-en", "source": "sns/MANGA_ep66_sns_posts.json", "platform": "x", "lang": "en", "post_at": "2026-12-31T15:00:00-05:00"}
{"id": "MANGA_ep67-x-en", "source": "sns/MANGA_ep67_sns_posts.json", "platform": "x", "lang": "en", "post_at": "2027-01-01T11:00:00-05:00"}
{"id": "MANGA_ep68-x-en", "source": "sns/MANGA_ep68_sns_posts.json", "platform": "x", "lang": "en", "post_at": "2027-01-01T15:00:00-05:00"}
{"id": "MANGA_ep69-x-en", "source": "sns/MANGA_ep69_sns_posts.json", "platform": "x", "lang": "en", "post_at": "2027-01-04T11:00:00-05:00"}
{"id": "MANGA_ep70-x-en", "source": "sns/MANGA_ep70_sns_posts.json", "platform": "x", "lang": "en", "post_at": "2027-01-04T15:00:00-05:00"}
{"id": "MANGA_ep71-x-en", "source": "sns/MANGA_ep71_sns_posts.json", "platform": "x", "lang": "en", "post_at": "2027-01-05T11:00:00-05:00"}
{"id": "MANGA_ep72-x-en", "source": "sns/MANGA_ep72_sns_posts.json", "platform": "x", "lang": "en", "post_at": "2027-01-05T15:00:00-05:00"}
{"id": "MANGA_ep74-x-en", "source": "sns/MANGA_ep74_sns_posts.json", "platform": "x", "lang": "en", "post_at": "2027-01-06T11:00:00-05:00"}
{"id": "MANGA_ep75-x-en", "source": "sns/MANGA_ep75_sns_posts.json", "platform": "x", "lang": "en", "post_at": "2027-01-06T15:00:00-05:00"}
{"id": "MANGA_ep77-x-en", "source": "sns/MANGA_ep77_sns_posts.json", "platform": "x", "lang": "en", "post_at": "2027-01-07T11:00:00-05:00"}
{"id": "MANGA_ep78-x-en", "source": "sns/MANGA_ep78_sns_posts.json", "platform": "x", "lang": "en", "post_at": "2027-01-07T15:00:00-05:00"}
{"id": "MANGA_ep79-x-en", "source": "sns/MANGA_ep79_sns_posts.json", "platform": "x", "lang": "en", "post_at": "2027-01-08T11:00:00-05:00"}
{"id": "MANGA_ep80-x-en", "source": "sns/MANGA_ep80_sns_posts.json", "platform": "x", "lang": "en", "post_at": "2027-01-08T15:00:00-05:00"}
{"id": "MANGA_ep81-x-en", "source": "sns/MANGA_ep81_sns_posts.json", "platform": "x", "lang": "en", "post_at": "2027-01-11T11:00:00-05:00"}
{"id": "MANGA_ep82-x-en", "source": "sns/MANGA_ep82_sns_posts.json", "platform": "x", "lang": "en", "post_at": "2027-01-11T15:00:00-05:00"}
{"id": "MANGA_ep83-x-en", "source": "sns/MANGA_ep83_sns_posts.json", "platform": "x", "lang": "en", "post_at": "2027-01-12T11:00:00-05:00"}
{"id": "MANGA_ep84-x-en", "source": "sns/MANGA_ep84_sns_posts.json", "platform": "x", "lang": "en", "post_at": "2027-01-12T15:00:00-05:00"}
{"id": "MANGA_ep85-x-en", "source": "sns/MANGA_ep85_sns_posts.json", "platform": "x", "lang": "en", "post_at": "2027-01-13T11:00:00-05:00"}
{"id": "MANGA_ep86-x-en", "source": "sns/MANGA_ep86_sns_posts.json", "platform": "x", "lang": "en", "post_at": "2027-01-13T15:00:00-05:00"}
{"id": "MANGA_ep87-x-en", "source": "sns/MANGA_ep87_sns_posts.json", "platform": "x", "lang": "en", "post_at": "2027-01-14T11:00:00-05:00"}
{"id": "MANGA_ep88-x-en", "source": "sns/MANGA_ep88_sns_posts.json", "platform": "x", "lang": "en", "post_at": "2027-01-14T15:00:00-05:00"}
{"id": "MANGA_ep89-x-en", "source": "sns/MANGA_ep89_sns_posts.json", "platform": "x", "lang": "en", "post_at": "2027-01-15T11:00:00-05:00"}
{"id": "MANGA_ep90-x-en", "source": "sns/MANGA_ep90_sns_posts.json", "platform": "x", "lang": "en", "post_at": "2027-01-15T15:00:00-05:00"}
{"id": "MANGA_ep91-x-en", "source": "sns/MANGA_ep91_sns_posts.json", "platform": "x", "lang": "en", "post_at": "2027-01-18T11:00:00-05:00"}
{"id": "MANGA_ep92-x-en", "source": "sns/MANGA_ep92_sns_posts.json", "platform": "x", "lang": "en", "post_at": "2027-01-18T15:00:00-05:00"}
{"id": "MANGA_ep98-x-en", "source": "sns/MANGA_ep98_sns_posts.json", "platform": "x", "lang": "en", "post_at": "2027-01-19T11:00:00-05:00"}
{"id": "MANGA_ep102-x-en", "source": "sns/MANGA_ep102_sns_posts.json", "platform": "x", "lang": "en", "post_at": "2027-01-19T15:00:00-05:00"}
{"id": "MANGA_ep103-x-en", "source": "sns/MANGA_ep103_sns_posts.json", "platform": "x", "lang": "en", "post_at": "2027-01-20T11:00:00-05:00"}
{"id": "MANGA_ep104-x-en", "source": "sns/MANGA_ep104_sns_posts.json", "platform": "x", "lang": "en", "post_at": "2027-01-20T15:00:00-05:00"}
{"id": "MANGA_ep105-x-en", "source": "sns/MANGA_ep105_sns_posts.json", "platform": "x", "lang": "en", "post_at": "2027-01-21T11:00:00-05:00"}
{"id": "MANGA_ep106-x-en", "source": "sns/MANGA_ep106_sns_posts.json", "platform": "x", "lang": "en", "post_at": "2027-01-21T15:00:00-05:00"}
{"id": "MANGA_ep107-x-en", "source": "sns/MANGA_ep107_sns_posts.json", "platform": "x", "lang": "en", "post_at": "2027-01-22T11:00:00-05:00"}
{"id": "MANGA_ep108-x-en", "source": "sns/MANGA_ep108_sns_posts.json", "platform": "x", "lang": "en", "post_at": "2027-01-22T15:00:00-05:00"}
{"id": "MANGA_ep110-x-en", "source": "sns/MANGA_ep110_sns_posts.json", "platform": "x", "lang": "en", "post_at": "2027-01-25T11:00:00-05:00"}
{"id": "MANGA_ep112-x-en", "source": "sns/MANGA_ep112_sns_posts.json", "platform": "x", "lang": "en", "post_at": "2027-01-25T15:00:00-05:00"}
{"id": "MANGA_ep120-x-en", "source": "sns/MANGA_ep120_sns_posts.json", "platform": "x", "lang": "en", "post_at": "2027-01-26T11:00:00-05:00"}
{"id": "MANGA_ep121-x-en", "source": "sns/MANGA_ep121_sns_posts.json", "platform": "x", "lang": "en", "post_at": "2027-01-26T15:00:00-05:00"}
{"id": "MANGA_ep122-x-en", "source": "sns/MANGA_ep122_sns_posts.json", "platform": "x", "lang": "en", "post_at": "2027-01-27T11:00:00-05:00"}
```

### tools/sns_autopost.py（X の自動投稿。画像・返信・リンクの切り替えを足した）

GitHub Actions が動かす投稿のプログラムの全文です。x_links_on・texts_for・validate・x_upload_image・post_x・verify が、この作業で足した・変えた部分です。

```python
#!/usr/bin/env python3
"""SNS の予約投稿（X・LinkedIn）。

予定表 sns/schedule.json に入っている投稿のうち、時刻が来たものを投稿し、
結果を sns/posted.json に残す。GitHub Actions から15分ごとに動かす。

使い方:
  python tools/sns_autopost.py check            予定表と投稿文を確かめる（投稿しない）
  python tools/sns_autopost.py run --dry-run    時刻が来ているものを表示するだけ
  python tools/sns_autopost.py run              投稿する（Actions 用）
  python tools/sns_autopost.py preview          日時と本文の一覧 sns/schedule_preview.md を作り直す

二重投稿を防ぐため、投稿の前に「投稿中」と記録して push し、push できなければ投稿しない。
「投稿中」のまま残ったもの（投稿できたか分からないもの）は、自動ではやり直さない。

外部ライブラリは使わない（標準ライブラリだけ）。
"""

import argparse
import base64
import datetime as dt
import hashlib
import hmac
import html
import json
import os
import re
import secrets
import subprocess
import sys
import time
import urllib.error
import urllib.parse
import urllib.request

ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
SCHEDULE = os.path.join(ROOT, "sns", "schedule.json")
POSTED = os.path.join(ROOT, "sns", "posted.json")

PLATFORMS = ("x", "linkedin")
STALE_HOURS = 12            # 予定時刻からこれ以上遅れたら投稿せず「見送り」にする
X_LIMIT = 280               # X の重み付き文字数の上限（Premium なら X_LONG_POSTS=1 で外す）
LINKEDIN_LIMIT = 3000
LINKEDIN_COMMENT_LIMIT = 1250
LINKEDIN_VERSION = os.environ.get("LINKEDIN_VERSION") or "202605"
JST = dt.timezone(dt.timedelta(hours=9))
UA = "PixelstoMotors-autopost/1.0"


# ---------- 予定表・記録 ----------

def load_json(path, default):
    if not os.path.exists(path):
        return default
    with open(path, encoding="utf-8") as f:
        return json.load(f)


def save_json(path, data):
    with open(path, "w", encoding="utf-8") as f:
        json.dump(data, f, ensure_ascii=False, indent=2)
        f.write("\n")


def parse_time(s):
    t = dt.datetime.fromisoformat(s)
    if t.tzinfo is None:
        raise ValueError(f"post_at にタイムゾーンがありません（例 2026-10-25T08:00:00+09:00）: {s}")
    return t


def load_source(entry):
    path = os.path.join(ROOT, entry["source"])
    with open(path, encoding="utf-8") as f:
        return json.load(f)


LANGS = ("ja", "en")


def lang_of(entry):
    return entry.get("lang", "ja")


def x_links_on():
    """X の投稿にリンクを付けるか。リポジトリの Variables の X_LINKS が 1 のときだけ付ける（ないときは付けない）。
    付けないときは、本文の URL の行を外し、リンクの返信もしない。"""
    return os.environ.get("X_LINKS", "0") == "1"


def texts_for(entry, src):
    """投稿文を取り出す。lang が en のときは posts.x_en / linkedin_en / linkedin_comment_en と
    meta.url_en（なければ meta.url）を使う（マンガの英語の投稿）。"""
    posts = src.get("posts", {})
    meta = src.get("meta", {})
    sfx = "_en" if lang_of(entry) == "en" else ""
    if entry["platform"] == "x":
        # 画像（最大4枚）と、自分への返信（リンクはここに置く）。2026-10-31 英語のマンガの投稿から
        imgs = posts.get("x" + sfx + "_images") or []
        site = (meta.get("url" + sfx) or meta.get("url", "") or "https://pixelstomotors.com").split("/", 3)
        base = "/".join(site[:3]) if len(site) >= 3 else "https://pixelstomotors.com"
        text = posts.get("x" + sfx, "").strip()
        if not x_links_on():
            # リンクを外す（2026-10-31 から。リンク入りの投稿は1本0.20ドル、リンクなしは0.015ドル）
            lines = [URL_RE.sub("", l).rstrip() for l in text.split("\n") if URL_RE.sub("", l).strip() or not URL_RE.search(l)]
            text = re.sub(r"\n{3,}", "\n\n", "\n".join(lines)).strip()
        return {"text": text,
                "images": [u if u.startswith("http") else base + u for u in imgs],
                "alts": posts.get("x" + sfx + "_alts") or [],
                "reply": (posts.get("x" + sfx + "_reply") or "").strip()}
    return {
        "text": posts.get("linkedin" + sfx, "").strip(),
        "comment": posts.get("linkedin_comment" + sfx, "").strip(),
        "url": meta.get("url" + sfx) or meta.get("url", ""),
        # YouTube の本編（2026-10-30 から LinkedIn にもリンクで出す）はカードの題を YouTube のタイトルにする
        "title": (meta.get("youtube_title") if meta.get("kind") == "youtube_video" else None)
                 or meta.get("title" + sfx) or meta.get("title", ""),
        "description": meta.get("meta_description" + sfx) or meta.get("meta_description", ""),
    }


# ---------- 文字数 ----------

URL_RE = re.compile(r"https?://\S+")


def x_weighted_length(text):
    """X の数え方：URL は23、ラテン文字などは1、日本語・絵文字などは2。"""
    n = 23 * len(URL_RE.findall(text))
    for ch in URL_RE.sub("", text):
        c = ord(ch)
        light = c <= 4351 or 8192 <= c <= 8205 or 8208 <= c <= 8223 or 8242 <= c <= 8247
        n += 1 if light else 2
    return n


def validate(entry, src):
    """問題があれば説明の文字列のリストを返す。"""
    errs = []
    t = texts_for(entry, src)
    if not t["text"]:
        key = {"x": "x", "linkedin": "linkedin"}[entry["platform"]] + ("_en" if lang_of(entry) == "en" else "")
        errs.append(f"投稿文が空です（posts.{key}）")
    if entry["platform"] == "x":
        n = x_weighted_length(t["text"])
        if n > X_LIMIT and os.environ.get("X_LONG_POSTS") != "1":
            errs.append(f"X の文字数が上限を超えています（{n} / {X_LIMIT}）")
        if len(t["images"]) > 4:
            errs.append(f"X の画像は4枚までです（{len(t['images'])}枚）")
        if t["alts"] and len(t["alts"]) != len(t["images"]):
            errs.append("X の画像の説明（_alts）の数が画像の数と合いません")
        if t["reply"] and x_weighted_length(t["reply"]) > X_LIMIT:
            errs.append(f"X の返信の文字数が上限を超えています（{x_weighted_length(t['reply'])} / {X_LIMIT}）")
    else:
        if len(t["text"]) > LINKEDIN_LIMIT:
            errs.append(f"LinkedIn の文字数が上限を超えています（{len(t['text'])} / {LINKEDIN_LIMIT}）")
        if t["comment"] and len(t["comment"]) > LINKEDIN_COMMENT_LIMIT:
            errs.append(f"LinkedIn のコメントが長すぎます（{len(t['comment'])} / {LINKEDIN_COMMENT_LIMIT}）")
        if not t["url"]:
            errs.append("meta.url（記事の URL）がありません")
    return errs


def check_schedule(schedule):
    errs = []
    ids = set()
    for i, e in enumerate(schedule.get("posts", [])):
        where = f"posts[{i}] {e.get('id', '(id なし)')}"
        for k in ("id", "source", "platform", "post_at"):
            if k not in e:
                errs.append(f"{where}: {k} がありません")
        if any(k not in e for k in ("id", "source", "platform", "post_at")):
            continue
        if e["id"] in ids:
            errs.append(f"{where}: id が重複しています")
        ids.add(e["id"])
        try:
            parse_time(e["post_at"])
        except ValueError as ex:
            errs.append(f"{where}: {ex}")
        if lang_of(e) not in LANGS:
            errs.append(f"{where}: lang は ja か en です")
            continue
        if e["platform"] not in PLATFORMS:
            errs.append(f"{where}: platform は x か linkedin です")
            continue
        try:
            src = load_source(e)
        except (OSError, json.JSONDecodeError) as ex:
            errs.append(f"{where}: 投稿文のファイルが読めません（{ex}）")
            continue
        errs += [f"{where}: {m}" for m in validate(e, src)]
    return errs


# ---------- HTTP ----------

class ApiError(Exception):
    def __init__(self, status, body, where):
        super().__init__(f"{where}: HTTP {status} {body[:500]}")
        self.status = status


def http(method, url, headers=None, body=None, where=""):
    data = body
    if isinstance(body, (dict, list)):
        data = json.dumps(body, ensure_ascii=False).encode("utf-8")
    req = urllib.request.Request(url, data=data, method=method, headers={"User-Agent": UA, **(headers or {})})
    try:
        with urllib.request.urlopen(req, timeout=60) as r:
            raw = r.read()
            return r.status, dict(r.headers), raw
    except urllib.error.HTTPError as ex:
        raise ApiError(ex.code, ex.read().decode("utf-8", "replace"), where) from None


# ---------- X ----------

def oauth1_header(method, url, ck, cs, tk, ts):
    q = lambda s: urllib.parse.quote(str(s), safe="~")
    params = {
        "oauth_consumer_key": ck,
        "oauth_nonce": secrets.token_hex(16),
        "oauth_signature_method": "HMAC-SHA1",
        "oauth_timestamp": str(int(time.time())),
        "oauth_token": tk,
        "oauth_version": "1.0",
    }
    base_params = "&".join(f"{q(k)}={q(v)}" for k, v in sorted(params.items()))
    base = "&".join([method.upper(), q(url), q(base_params)])
    key = f"{q(cs)}&{q(ts)}".encode()
    params["oauth_signature"] = base64.b64encode(hmac.new(key, base.encode(), hashlib.sha1).digest()).decode()
    return "OAuth " + ", ".join(f'{q(k)}="{q(v)}"' for k, v in sorted(params.items()))


def x_env():
    env = {k: os.environ.get(k, "") for k in ("X_API_KEY", "X_API_SECRET", "X_ACCESS_TOKEN", "X_ACCESS_SECRET")}
    missing = [k for k, v in env.items() if not v]
    if missing:
        raise RuntimeError(f"X の鍵が設定されていません: {', '.join(missing)}")
    return env


def x_call(path, body, where):
    env = x_env()
    url = os.environ.get("X_API_BASE", "https://api.x.com") + path
    auth = oauth1_header("POST", url, env["X_API_KEY"], env["X_API_SECRET"], env["X_ACCESS_TOKEN"], env["X_ACCESS_SECRET"])
    _, _, raw = http("POST", url, {"Authorization": auth, "Content-Type": "application/json"}, body, where)
    return json.loads(raw or b"{}")


def x_upload_image(img_url, alt=""):
    """画像をサイトから取って X に上げ、media の id を返す（v2 の /2/media/upload。base64 の JSON で1回で送る）。
    説明（alt）は /2/media/metadata で付ける。説明だけ失敗しても画像は使う。"""
    req = urllib.request.Request(img_url, headers={"User-Agent": UA})
    with urllib.request.urlopen(req, timeout=60) as r:
        data = r.read()
    if len(data) > 5 * 1024 * 1024:
        raise RuntimeError(f"画像が 5MB を超えています: {img_url}")
    res = x_call("/2/media/upload", {"media": base64.b64encode(data).decode(), "media_category": "tweet_image"},
                 "X 画像のアップロード")
    mid = str((res.get("data") or {}).get("id") or res.get("media_id_string") or "")
    if not mid:
        raise RuntimeError(f"X 画像のアップロードの返事に id がありません: {str(res)[:300]}")
    if alt:
        try:
            x_call("/2/media/metadata", {"id": mid, "metadata": {"alt_text": {"text": alt[:1000]}}}, "X 画像の説明")
        except Exception as ex:
            print(f"画像の説明は付けられませんでした（画像はそのまま使う）: {ex}")
    return mid


def post_x(t):
    x_env()
    body = {"text": t["text"]}
    imgs = t.get("images") or []
    if imgs:
        alts = t.get("alts") or [""] * len(imgs)
        body["media"] = {"media_ids": [x_upload_image(u, a) for u, a in zip(imgs, alts)]}
    res = x_call("/2/tweets", body, "X 投稿")
    pid = res["data"]["id"]
    result = {"post_id": pid, "url": f"https://x.com/i/web/status/{pid}"}
    # 自分への返信（リンクを本文に入れないための置き場所）。返信だけ失敗しても本文は出ている
    if t.get("reply") and x_links_on():
        try:
            r2 = x_call("/2/tweets", {"text": t["reply"], "reply": {"in_reply_to_tweet_id": pid}}, "X 返信")
            result["reply_id"] = r2["data"]["id"]
        except Exception as ex:
            result["comment_error"] = str(ex)[:500]
    return result


# ---------- LinkedIn ----------

def li_headers(token, versioned=True):
    h = {"Authorization": f"Bearer {token}", "X-Restli-Protocol-Version": "2.0.0", "Content-Type": "application/json"}
    if versioned:
        h["LinkedIn-Version"] = LINKEDIN_VERSION
    return h


def li_base():
    return os.environ.get("LINKEDIN_API_BASE", "https://api.linkedin.com")


def li_person(token):
    urn = os.environ.get("LINKEDIN_PERSON_URN", "")
    if urn:
        return urn
    _, _, raw = http("GET", li_base() + "/v2/userinfo", {"Authorization": f"Bearer {token}"}, where="LinkedIn 本人確認")
    return f"urn:li:person:{json.loads(raw)['sub']}"


def og_image(page_url):
    """記事ページの og:image の URL を取る。取れなければ None。"""
    try:
        _, _, raw = http("GET", page_url, where="記事ページ")
    except Exception:
        return None
    page = raw.decode("utf-8", "replace")
    for pat in (r'<meta[^>]+property=["\']og:image["\'][^>]+content=["\']([^"\']+)',
                r'<meta[^>]+content=["\']([^"\']+)["\'][^>]+property=["\']og:image["\']'):
        m = re.search(pat, page)
        if m:
            return urllib.parse.urljoin(page_url, html.unescape(m.group(1)))
    return None


def li_thumbnail(token, person, page_url):
    """記事の表紙画像を LinkedIn に上げて image の URN を返す。失敗したら None（表紙なしで投稿する）。"""
    try:
        m = re.search(r"(?:youtu\.be/|youtube\.com/(?:watch\?v=|shorts/))([\w-]{6,})", page_url)
        if m:   # YouTube の動画は、ページを読まずにサムネイルを直接取る（同意の画面などで og:image が取れないことがあるため）
            img = f"https://i.ytimg.com/vi/{m.group(1)}/hqdefault.jpg"
            for size in ("maxresdefault", "hqdefault"):
                try:
                    http("GET", f"https://i.ytimg.com/vi/{m.group(1)}/{size}.jpg", where="YouTube のサムネイル")
                    img = f"https://i.ytimg.com/vi/{m.group(1)}/{size}.jpg"; break
                except Exception:
                    continue
        else:
            img = og_image(page_url)
        if not img:
            return None
        _, _, data = http("GET", img, where="表紙画像")
        _, _, raw = http("POST", li_base() + "/rest/images?action=initializeUpload", li_headers(token),
                         {"initializeUploadRequest": {"owner": person}}, "LinkedIn 画像の準備")
        v = json.loads(raw)["value"]
        # Content-Type を付けないと urllib が form の形（x-www-form-urlencoded）で送り、HTTP 400 になる（2026-10-25 LOG375 で起きた）
        ctype = "image/png" if img.lower().split("?")[0].endswith(".png") else "image/jpeg"
        http("PUT", v["uploadUrl"], {"Authorization": f"Bearer {token}", "Content-Type": ctype}, data, "LinkedIn 画像の送信")
        return v["image"]
    except Exception as ex:
        print(f"  表紙画像は付けずに投稿します（{ex}）")
        return None


def post_linkedin(t):
    token = os.environ.get("LINKEDIN_ACCESS_TOKEN", "")
    if not token:
        raise RuntimeError("LinkedIn のトークンが設定されていません: LINKEDIN_ACCESS_TOKEN")
    person = li_person(token)
    article = {"source": t["url"], "title": t["title"] or t["url"]}
    if t["description"]:
        article["description"] = t["description"]
    thumb = li_thumbnail(token, person, t["url"])
    if thumb:
        article["thumbnail"] = thumb
    body = {
        "author": person,
        "commentary": li_escape(t["text"]),
        "visibility": "PUBLIC",
        "distribution": {"feedDistribution": "MAIN_FEED", "targetEntities": [], "thirdPartyDistributionChannels": []},
        "content": {"article": article},
        "lifecycleState": "PUBLISHED",
        "isReshareDisabledByAuthor": False,
    }
    _, headers, _ = http("POST", li_base() + "/rest/posts", li_headers(token), body, "LinkedIn 投稿")
    urn = {k.lower(): v for k, v in headers.items()}.get("x-restli-id", "")
    result = {"post_id": urn, "url": f"https://www.linkedin.com/feed/update/{urn}/" if urn else ""}
    if t.get("comment") and urn:
        try:
            enc = urllib.parse.quote(urn, safe="")
            payload = {"actor": person, "object": urn, "message": {"text": t["comment"]}}
            try:
                _, h2, _ = http("POST", f"{li_base()}/rest/socialActions/{enc}/comments", li_headers(token),
                                payload, "LinkedIn コメント")
            except ApiError as ex:
                # 版つきの口（rest/）は w_member_social だけでは 403（partnerApiSocialActions。2026-10-25 LOG375 で起きた）。
                # 版なしの口（v2/）で1回だけやり直す
                if ex.status != 403:
                    raise
                h = {"Authorization": f"Bearer {token}", "Content-Type": "application/json", "X-Restli-Protocol-Version": "2.0.0"}
                _, h2, _ = http("POST", f"{li_base()}/v2/socialActions/{enc}/comments", h, payload, "LinkedIn コメント（v2）")
            result["comment_id"] = {k.lower(): v for k, v in h2.items()}.get("x-restli-id", "posted")
        except Exception as ex:
            # 本文は出ているので、コメントだけ失敗として残す（本文はやり直さない）
            result["comment_error"] = str(ex)[:500]
    return result


def li_escape(text):
    """LinkedIn の commentary は little text 形式。記号はエスケープが要る（しないと本文が途中で切れる）。
    ハッシュタグ（#語）はハッシュタグとして残す。"""
    s = re.sub(r"([\\|{}@\[\]()<>#*_~])", r"\\\1", text)
    return re.sub(r"\\#(\w+)", r"{hashtag|\\#|\1}", s)


# ---------- 一覧（sns/schedule_preview.md） ----------

PREVIEW = os.path.join(ROOT, "sns", "schedule_preview.md")
WEEKDAYS = "月火水木金土日"
STATUS_JA = {"posted": "投稿済み", "failed": "失敗", "unknown": "要確認（出たか不明）",
             "posting": "要確認（投稿中で止まった）", "skipped_stale": "見送り"}


def md_quote(text):
    """本文を引用の形にする。改行はそのまま残し、行頭の # が見出しにならないようにする。"""
    lines = []
    for line in text.split("\n"):
        line = re.sub(r"^(\s*)([#>\-*+]|\d+\.)", r"\1\\\2", line)
        lines.append("> " + line + "  " if line else ">")
    return "\n".join(lines)


def write_preview(schedule=None, posted=None):
    schedule = schedule if schedule is not None else load_json(SCHEDULE, {"posts": []})
    posted = posted if posted is not None else load_json(POSTED, {})
    entries = sorted(schedule.get("posts", []), key=lambda e: parse_time(e["post_at"]))
    upcoming = [e for e in entries if e["id"] not in posted]
    done = [e for e in reversed(entries) if e["id"] in posted]

    out = ["# SNS 予約一覧", "",
           "予定表（`sns/schedule.json`）から自動で作る一覧。**このファイルは手で直さない**（次の更新で上書きされる）。",
           "中身を変えたいときは、`sns/LOG{番号}_sns_posts.json` か予定表を直す。", "",
           f"## これから出す（{len(upcoming)}本）", ""]
    if not upcoming:
        out += ["予約はありません。", ""]
    for e in upcoming:
        out += preview_block(e, "予約中")
    out += [f"## 出したもの・止まったもの（{len(done)}本）", ""]
    if not done:
        out += ["まだありません。", ""]
    for e in done:
        p = posted[e["id"]]
        status = STATUS_JA.get(p.get("status"), p.get("status", ""))
        out += preview_block(e, status, p)
    with open(PREVIEW, "w", encoding="utf-8") as f:
        f.write("\n".join(out).rstrip() + "\n")


def preview_block(e, status, result=None):
    t = parse_time(e["post_at"]).astimezone(JST)
    when = f"{t.month}/{t.day}（{WEEKDAYS[t.weekday()]}）{t.hour}:{t.minute:02d}"
    name = {"x": "X", "linkedin": "LinkedIn"}.get(e["platform"], e["platform"])
    name += "（英語）" if lang_of(e) == "en" else "（日本語）"
    lines = [f"### {when} ・ {name} ・ {e['id']} ・ {status}", ""]
    try:
        src = load_source(e)
        tx = texts_for(e, src)
        meta = src.get("meta", {})
        sfx = "_en" if lang_of(e) == "en" else ""
        info = f"リンク先：[{meta.get('title' + sfx) or meta.get('title', '')}]({meta.get('url' + sfx) or meta.get('url', '')})"
        if e["platform"] == "x":
            info += f" ／ 文字数 {x_weighted_length(tx['text'])} / {X_LIMIT}"
            if tx.get("images"):
                info += f" ／ 画像 {len(tx['images'])}枚"
        lines += [info, "", md_quote(tx["text"]), ""]
        if e["platform"] == "x" and tx.get("reply"):
            lines += ["返信：", "", md_quote(tx["reply"]), ""]
    except (OSError, json.JSONDecodeError, KeyError) as ex:
        lines += [f"（投稿文が読めません：{ex}）", ""]
    if result:
        if result.get("url"):
            lines += [f"投稿：{result['url']}", ""]
        if result.get("error"):
            lines += [f"エラー：`{result['error'][:300]}`", ""]
    return lines


# ---------- git ----------

def git(*args):
    return subprocess.run(["git", "-C", ROOT, *args], capture_output=True, text=True)


def commit_and_push(message):
    """posted.json と一覧を commit して push。成功したら True。"""
    if os.environ.get("AUTOPOST_GIT") != "1":
        return True
    try:
        write_preview()
        git("add", "sns/schedule_preview.md")
    except Exception as ex:
        print(f"一覧の更新に失敗しました（投稿には影響しません）: {ex}")
    git("add", "sns/posted.json")
    r = git("commit", "-m", message)
    if r.returncode != 0 and "nothing to commit" not in (r.stdout + r.stderr):
        print(r.stdout, r.stderr)
        return False
    for _ in range(3):
        if git("push", "origin", "HEAD").returncode == 0:
            return True
        # 途中で別の push があったら取り込んでやり直す
        if git("pull", "--rebase", "origin", "HEAD").returncode != 0:
            git("rebase", "--abort")
            return False
    return False


# ---------- 実行 ----------

def now_utc():
    s = os.environ.get("AUTOPOST_NOW")
    return parse_time(s) if s else dt.datetime.now(dt.timezone.utc)


def run(dry_run):
    schedule = load_json(SCHEDULE, {"posts": []})
    errs = check_schedule(schedule)
    if errs:
        print("予定表に問題があります。直すまで投稿しません。")
        for e in errs:
            print("  - " + e)
        return 1

    posted = load_json(POSTED, {})
    now = now_utc()
    failed = False
    due = sorted((e for e in schedule["posts"] if e["id"] not in posted and parse_time(e["post_at"]) <= now),
                 key=lambda e: parse_time(e["post_at"]))
    if not due:
        print("時刻が来ている投稿はありません。")
        return 0

    for e in due:
        late = now - parse_time(e["post_at"])
        stamp = now.astimezone(JST).isoformat(timespec="seconds")
        if late > dt.timedelta(hours=STALE_HOURS):
            print(f"[{e['id']}] 予定から {late} 遅れているので見送ります")
            if not dry_run:
                posted[e["id"]] = {"status": "skipped_stale", "at": stamp, "post_at": e["post_at"]}
                save_json(POSTED, posted)
                commit_and_push(f"SNS自動投稿：{e['id']} を見送り（予定から{STALE_HOURS}時間以上遅れ）")
            continue

        t = texts_for(e, load_source(e))
        if dry_run:
            extra = ""
            if t.get("images"):
                extra += f"\n画像 {len(t['images'])}枚: " + " ".join(t["images"])
            if t.get("reply"):
                extra += f"\n返信:\n{t['reply']}"
            print(f"[{e['id']}] {e['platform']} に投稿します（試し・送らない）\n----\n{t['text']}{extra}\n----")
            continue

        # 1) 先に「投稿中」を push。push できなければ投稿しない（二重投稿を防ぐ）
        posted[e["id"]] = {"status": "posting", "at": stamp, "platform": e["platform"]}
        save_json(POSTED, posted)
        if not commit_and_push(f"SNS自動投稿：{e['id']} を投稿中"):
            print(f"[{e['id']}] 記録を push できなかったので投稿しません")
            return 1

        # 2) 投稿
        try:
            result = post_x(t) if e["platform"] == "x" else post_linkedin(t)
            posted[e["id"]] = {"status": "posted", "at": stamp, "platform": e["platform"], **result}
            print(f"[{e['id']}] 投稿しました {result.get('url', '')}")
            if result.get("comment_error"):
                # 本文は出ているので Actions は赤くしない。コメントは手で付ける（posted.json の comment_error に理由）
                print(f"[{e['id']}] コメント（X は返信）は付けられませんでした（本文は出ています。手で付けてください）: {result['comment_error']}")
        except Exception as ex:
            msg = str(ex)
            if isinstance(ex, ApiError) and ex.status == 401:
                msg = "認証が切れているか鍵が違います。" + (
                    "LinkedIn のトークン（60日で切れる）を作り直してください。 " if e["platform"] == "linkedin" else "") + msg
            # 相手から断られた（HTTP のエラーが返った・鍵がない）なら出ていない＝failed。
            # 通信が途中で切れたなどで分からないときは unknown（手で確かめる）
            status = "failed" if isinstance(ex, (ApiError, RuntimeError)) else "unknown"
            posted[e["id"]] = {"status": status, "at": stamp, "platform": e["platform"], "error": msg[:800]}
            print(f"[{e['id']}] 投稿に失敗しました（{status}）: {msg}")
            failed = True

        # 3) 結果を push
        save_json(POSTED, posted)
        commit_and_push(f"SNS自動投稿：{e['id']} の結果（{posted[e['id']]['status']}）")
    return 1 if failed else 0


def verify():
    """鍵が使えるかを、投稿せずに確かめる（X は自分の名前を読む・LinkedIn は本人確認）。"""
    ok = True
    try:
        env = {k: os.environ.get(k, "") for k in ("X_API_KEY", "X_API_SECRET", "X_ACCESS_TOKEN", "X_ACCESS_SECRET")}
        if not all(env.values()):
            raise RuntimeError("X の鍵が足りません")
        url = os.environ.get("X_API_BASE", "https://api.x.com") + "/2/users/me"
        auth = oauth1_header("GET", url, env["X_API_KEY"], env["X_API_SECRET"], env["X_ACCESS_TOKEN"], env["X_ACCESS_SECRET"])
        _, _, raw = http("GET", url, {"Authorization": auth}, where="X 本人確認")
        print(f"X: OK（@{json.loads(raw)['data']['username']}）")
        # 画像のアップロードも確かめる（上げるだけで投稿はしない。2026-10-31 英語のマンガに画像を付けるようにしたため）
        mid = x_upload_image("https://pixelstomotors.com/media/manga_ep133en_p1.webp", "verify upload")
        print(f"X 画像のアップロード: OK（media id {mid}。投稿はしていない）")
    except Exception as ex:
        ok = False
        print(f"X: NG {ex}")
    try:
        token = os.environ.get("LINKEDIN_ACCESS_TOKEN", "")
        if not token:
            raise RuntimeError("LinkedIn のトークンがありません")
        _, _, raw = http("GET", li_base() + "/v2/userinfo", {"Authorization": f"Bearer {token}"}, where="LinkedIn 本人確認")
        print(f"LinkedIn: OK（{json.loads(raw).get('name', '')}）")
    except Exception as ex:
        ok = False
        print(f"LinkedIn: NG {ex}")
    return 0 if ok else 1


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("command", choices=["check", "run", "preview", "verify"])
    ap.add_argument("--dry-run", action="store_true")
    a = ap.parse_args()
    if a.command == "verify":
        return verify()
    if a.command == "preview":
        write_preview()
        print(f"一覧を書きました: {os.path.relpath(PREVIEW, ROOT)}")
        return 0
    if a.command == "check":
        errs = check_schedule(load_json(SCHEDULE, {"posts": []}))
        for e in errs:
            print("  - " + e)
        print("問題ありません。" if not errs else f"{len(errs)} 件の問題があります。")
        return 1 if errs else 0
    return run(a.dry_run)


if __name__ == "__main__":
    sys.exit(main())
```

### .github/workflows/sns-autopost.yml の差分（X_LINKS を渡す）

リポジトリの設定（Variables）の X_LINKS を、プログラムに渡すようにした差分です。

```diff
diff --git a/.github/workflows/sns-autopost.yml b/.github/workflows/sns-autopost.yml
index 8a425d0..f5479ff 100644
--- a/.github/workflows/sns-autopost.yml
+++ b/.github/workflows/sns-autopost.yml
@@ -42,6 +42,7 @@ jobs:
       - run: python tools/sns_autopost.py check
         env:
           X_LONG_POSTS: ${{ vars.X_LONG_POSTS }}
+          X_LINKS: ${{ vars.X_LINKS }}
       # 日時と本文の一覧（sns/schedule_preview.md）を作り直し、変わっていれば push する
       - name: 一覧を更新
         run: |
@@ -69,6 +70,7 @@ jobs:
         env:
           AUTOPOST_GIT: "1"
           X_LONG_POSTS: ${{ vars.X_LONG_POSTS }}
+          X_LINKS: ${{ vars.X_LINKS }}
           LINKEDIN_VERSION: ${{ vars.LINKEDIN_VERSION }}
           X_API_KEY: ${{ secrets.X_API_KEY }}
           X_API_SECRET: ${{ secrets.X_API_SECRET }}
```

### docs/sns_autopost.md の「X のリンク」と「マンガの英語の X」

自動投稿の手順書のうち、この作業で足した2つの節です。

````markdown
### X のリンク（2026-10-31 から、いまは付けない）

リポジトリの Variables の `X_LINKS` が `1` のときだけ、X にリンクを付ける。ないとき（いま）は、本文の URL の行を外して出し、リンクの返信（`x_en_reply`）も出さない。json の文は URL 入りのまま置いてよい（`X_LINKS` を `1` にすれば、そのまま元の形で出る）。LinkedIn には関係しない。

- 理由：API でリンクのある投稿を出すと1本 0.20 ドル、リンクなしは 0.015 ドル（2026-04-20 からの X の料金）。日本語1日4本・英語平日2本をリンク付きで出すと月に約35ドルかかる。リンクなしだと月に約3ドル
- エイイチの決定（2026-10-31）「今は投稿にリンク追加は無しの設定にしてください。いいねやリポストが増えたらリンク付きの投稿に変えます」。リンクはプロフィールに置く
- 戻すとき：GitHub の r2r2r-content → Settings → Secrets and variables → Actions → Variables で `X_LINKS` = `1`
- 一覧（`schedule_preview.md`）も、その時の設定で出る形を表示する

### マンガの英語の X（2026-10-31 から）

英語の X には、4コマの絵を画像で付け、リンクは自分への返信に置く（いまは上の「X のリンク」のとおり、返信は出さない）。

```json
"posts": {"x_en": "Hook line...\n\nHakobukun · Ep. 132\n#webcomic #AI",
          "x_en_images": ["/media/manga_ep132en_p1.webp", "…p2", "…p3", "…p4"],
          "x_en_alts": ["画像の説明（英語）", "…"],
          "x_en_reply": "Read it with the design note: https://pixelstomotors.com/english-4-koma/ep132"}
```

- `x_en_images`：最大4枚。`/media/…` はサイトの住所で取る（Actions が取りに行き、X の /2/media/upload に上げる）。縦長の話は4コマを4枚、1枚の4コマは1枚
- `x_en_alts`：画像と同じ数。X の /2/media/metadata で付ける（失敗しても画像はそのまま出す）
- `x_en_reply`：本文を出したあと、その投稿への返信として出す。返信だけ失敗しても本文は出ている（posted.json の comment_error に理由）。`X_LINKS` が `1` のときだけ出す
- 1本の費用の目安：本文 0.015 ドル＋説明 0.005 ドル×画像の数（縦長で約 0.035 ドル、1枚の4コマで約 0.02 ドル）。リンクの返信を出すと＋0.20 ドル（2026-04-20 からの X の料金）
- 予定表の id は `MANGA_ep{N}-x-en`、`"lang": "en"`

決めた理由（2026-10-31 に調べたこと）
- 時刻：X の投稿は、読む人の地元の時刻で、平日の午前中（9〜11時）が最も反応がよい（Buffer、約870万投稿）、火〜木の昼〜夕方がよい・技術の分野は11〜16時（Sprout Social）。土曜がいちばん弱く、夜はどの曜日も弱い。英語の読み手の多い米国・英国の両方に届く、米国東部の 11:00（英国 16:00）と 15:00（英国 20:00）にした
- リンク：X は「リンクを減点していない」と言っている（2026-07）が、リンクのある投稿は表示が大きく少ないという測り方がある。また 2026-04-20 から、API でリンクのある投稿を出すと 0.20 ドル（リンクなしは 0.015 ドル）。本文はリンクなし・画像ありにし、リンクは返信に置いた
- 画像：画像のある投稿は、文だけの投稿より広く表示されやすい（業界の目安で 15〜25%）。マンガは絵が中身なので、絵そのものを出す
- タグ：X では2つまでが目安（X のヘルプも、タグの使い過ぎをすすめていない）。マンガを読む人が追う `#webcomic` と、話題のタグ1つにした

### YouTube のショート（sns/SHORT_ep{N}_sns_posts.json）

ショート専用のマンガのショートは、YouTube の公開予約に合わせて X に出す（2026-10-24 エイイチの指示「ショートもXに出す」「19:30が重なったら動画は20:00」）。

```json
{"meta": {"kind": "youtube_short", "episode": 7, "title": "ショート 第7話 ピンポン",
          "youtube_title": "YouTube のタイトル", "manga_url": "https://pixelstomotors.com/manga/ep07",
          "url": "（自動）", "youtube_id": "（自動）"},
 "posts": {"x_template": "本文…\n\n{url}\n#ロボット #病院", "x": "（自動）"}}
```

- 文は動画を作った会話で作って置く（`x_template`。`{url}` がショートの URL に替わる）。タグは話題のタグ2つ、エイイチの話し方
- `tools/sync_youtube_shorts.py --videos youtube_videos.json` が、エイイチの Mac が1時間ごとに書き出す YouTube の一覧（`_制作ベース/youtube_videos.json`）を読み、説明文の `manga/ep{N}`（`manga_ep`）で json と動画を結びつけて、公開の時刻で予定表に入れる。`url` と `posts.x` もそのときに入れる
- 同じ時刻に X の予約があれば、19:30 なら 20:00、それ以外は30分ずつ後ろへずらす（12:30 なら 13:00）
- YouTube で公開の時刻を変えたら、まだ投稿していない予約の時刻も次の同期で直る。公開の時刻を過ぎてから見つけたものは入れない
- LinkedIn には入れない（ショートは LinkedIn に出さない）
- 同期は予定のタスク「ショートと本編のX予約をYouTubeに合わせる」が1日2回（7:47・17:47）動かす（本編も同じ回で入る）。Mac が眠っていると、その回は何もしない
- YouTube へのアップロードと公開予約は、まだ手で行う（API で上げるには Google の審査が要る。審査前に API で上げた動画は非公開から変えられない）

### YouTube の本編（sns/VIDEO_{名前}_sns_posts.json）

本編の動画も、YouTube の公開予約に合わせて X に出す（2026-10-24 エイイチの指示。「何台入れたら店は変わる」から）。ショートと同じ同期（`tools/sync_youtube_shorts.py`）で入る。

```json
{"meta": {"kind": "youtube_video", "title": "動画名（作業フォルダの名前）",
          "youtube_title": "タイトル.txt のタイトル", "title_match": ["配膳ロボット", "何台"], "seconds": 860,
          "url": "（自動）", "youtube_id": "（自動）"},
 "posts": {"x_template": "本文…\n#配膳ロボット #飲食店\n{url}", "x": "（自動）"}}
```

- 文は動画を作った会話で、投稿文_X_LinkedIn.txt の X の文から作って置く（`{url}` が `https://youtu.be/{id}` に替わる。X の数え方で280まで）。書き方は article_rules.md の「SNS の文章の質の決まり」（1行目で手が止まる一文・シミュレーションならそう分かる言葉・小さな感想・話題のタグ2つ）。ファイル名の `{名前}` は英数字とハイフン
- 動画との結びつけ: YouTube のタイトルが `youtube_title` と同じもの。なければ、`title_match` の語をすべて含み、長さ（秒）が `seconds` と3秒以内のもの（上げるときにタイトルを直しても結びつく）
- 時刻の決まり（19:30 が重なったら 20:00、ほかは30分ずつ後ろへ）・公開の時刻を過ぎてから見つけたものは入れない・時刻を変えたら次の同期で直る、はショートと同じ
- **LinkedIn にも YouTube のリンクで自動で出す**（2026-10-30 エイイチの指示「LinkedIn で動画を自分でアップロードするのが面倒。YouTube の投稿に統一」。それまでは LinkedIn に動画を直接上げて手で出していた）。`posts.linkedin_template`（本文）と `posts.linkedin_comment_template`（最初のコメント）を置くと、同期が YouTube の公開と同じ時刻に `VIDEO_{名前}-linkedin` を予定表に入れ、`{url}` を `https://youtu.be/{id}` にして `posts.linkedin`・`posts.linkedin_comment` を作る。LinkedIn では YouTube のサムネイルつきのリンクのカード（題は YouTube のタイトル）で出る。同じ時刻に LinkedIn の予約があれば30分ずつ後ろへ。文は日本語だけ（投稿文_X_LinkedIn.txt の LinkedIn の日本語の部分）
- 動画を LinkedIn に直接上げるより、外のリンクの投稿は LinkedIn で広がりにくいと言われている（公式の発表ではない）。反応は sns_analytics の書き出しで見比べる
- **ここぞという回は、エイイチが LinkedIn に動画を直接上げる**（2026-10-30 エイイチ「ポイントポイントで直接あげる」）。その回は json の meta に `"linkedin_manual": true` を書く。同期が自動の LinkedIn の予約を入れず、もう入っていれば（まだ出ていなければ）外す
````



## 作業ログ ― まだやっていないこと

- 擬音（ジリリリ・バチン など、絵に直接描いた音の字）は、日本語のまま残しています
- 名札・小窓の吹き出しの英語は、絵の中の場所が小さく、読めない大きさのものがあります
- 第126話のエレベーターの板は、元の黒ではなく灰色の地に黒い字になっています
- 横長の話（第1〜72話）の画像の説明は、絵ではなく解説文から書いています。作業役が、解説文から場面が分からなかった話（第1・2・4・10・12・14・17・62・65・72話など）を挙げているので、絵と合っているかを見直します
- いいねやリポストが増えたら、X_LINKS を 1 にしてリンク付きに戻すかを決めます
