---
title: "寝ている間も回る仕事を作る ― X と LinkedIn の予約投稿を、ひと晩で組んだ"
slug: sns-autopost-x-linkedin
date: 2026-10-24
day: 376
meta_description: "夜もAIに仕事を回すには、人が見なくても良し悪しを判定できる仕事を増やすこと。配膳ロボットの試算を広げる条件を整理し、XとLinkedInの予約投稿をGitHub Actionsで組んで22本を予約した記録。"
tags: [SNS予約投稿, X API, LinkedIn API, GitHub Actions, Cloudflare Workers, 自動化, サービスロボット導入シミュレーション, Unity, Claude]
series: "ひとり研究所の裏側"
ai_consult: true
---

「寝ている間も働く、AIのチーム」。そんな見出しの動画を見て、Claude に聞いてみました。うちの研究室で、寝ている間も AI を動かせるくらいの仕事を生み出すには、何を作ればいいのか。

返ってきた答えは、思っていたものと違いました。**夜に回せる仕事の量は、AI の性能ではなく、人が見なくても良し悪しを判定できる仕事がどれだけ用意できているかで決まる。** 正解かどうかを機械が判定できない仕事は、朝に人が全部見直すことになり、結局人の時間を食います。夜の AI を増やしたいなら、先に増やすべきなのは「判定できる問いの在庫」でした。

この研究室でいちばん夜に向いているのは、配膳ロボットなどのサービスロボットを店に入れたらどうなるかを試算する、サービスロボット導入シミュレーションです。数字で結果が出るので、AI が自分で良し悪しを判定できます。ただ、種類を広げて夜通し回すには、ケースを部品の組み合わせで書ける型、結果を自動で振り分ける判定、次に回す条件を選ぶ仕組み、画面を出さずに回す環境、の4つがまだ足りません。その夜は Unity で18回のシミュレーションを回している最中だったので、回し終わるまで作りは変えず、途中の結果を読んで「何を決めればいいか」だけを整理しました。

読んでいる間に、もう1つ話が出ました。X と LinkedIn に毎日投稿しているのに、投稿のたびに手で開いて貼っている。これは今夜のうちに自動にできます。そこで、GitHub に置いた予定表を15分ごとに読み、時刻が来た投稿を X と LinkedIn に出す仕組みを組みました。費用は X が1投稿あたり約0.015ドル（リンク付きは約0.2ドル）、LinkedIn の個人の投稿は無料です。鍵の発行で2回ほど違うボタンを押しかけましたが、夜のうちに両方の鍵がそろい、まだ出していない記録11本を X と LinkedIn に1本ずつ、合わせて22本を予約しました。翌朝、GitHub の定期実行が何時間も遅れて動いていたと分かったので、このサイトを配信している Cloudflare から5分ごとに起こす形に替えました。

やったことの全体はこうです。

| やったこと | なぜ | 結果 |
|---|---|---|
| 夜に AI へ渡せる仕事を考えた | 夜の AI を増やしたい | 判定できる問いを増やすのが先。いちばん向くのはサービスロボットの試算 |
| 試算を横に広げるのに足りないものを整理した | 種類を増やして夜通し回したい | 型・自動の判定・次の条件を選ぶ仕組み・画面なしの実行、の4つ。作る前に、決めることを先に決める |
| Unity の段階B（18回）の途中の結果を読んだ | 判定の線を、実際のデータで決めたい | 料理待ちは試算と合った。入店待ちは乱数の種で大きく揺れる。台帳に空の列が2つあった |
| X と LinkedIn の予約投稿を作った | 投稿を手で貼る手間をなくしたい | 予定表を GitHub Actions が15分ごとに読んで投稿する（翌朝、起こし役を Cloudflare に替えた）。投稿の前に記録を残して二重投稿を防ぐ |
| 鍵を発行して GitHub に預けた | 自分の代わりに投稿させる | 投稿せずに確かめる仕組みで、X・LinkedIn とも OK |
| 出していない記録を選んで予約した | すぐ回し始めたい | X と LinkedIn に11本ずつ、22本を予約。日時と本文の一覧を自動で作る |
| 投稿の言語の決まりを変えた | 記事と動画は日本語だけで作っている | 記事と YouTube の投稿は日本語だけ。英語はマンガだけにした |
| 起こし役を Cloudflare に替えた | GitHub の定期実行が2〜3時間おきにしか動かなかった | Cloudflare の Worker が5分ごとに予定表を見て、時刻が来ていたら Actions を起こす |
| 予約をカレンダーに並べた | いつ何が出るかをひと目で見たい | X は黒・LinkedIn は青・YouTube は赤で、93件を Google カレンダーに入れた。本編の動画10本の X も予約した |

効果の書き方について先に断っておきます。この記録で「見込める効果」と書いたものは、まだ測っていない見込みです。試験で確かめた所は、その出力を載せています。予約した投稿が実際に出たかどうかは、10月25日の朝8時の1本目で確かめます。


## 作業ログ ― 寝ている間に AI へ渡せる仕事を考える

### 判定できる問いの在庫が、夜に回せる仕事の量を決める

最初の問いはこうでした。

> うちの研究室が寝ている間もAIを動かすくらい仕事を生み出すためには何をつくるのがいいですか？

Claude の答えの中心は、「夜のあいだに AI が自分で回して、自分で結果を判定できる仕事の在庫を作る」でした。候補として挙がったのは3つです。

| 夜に回す仕事 | 判定のしかた | 朝に残るもの |
|---|---|---|
| サービスロボット導入シミュレーションの条件を少しずつ変えて大量に回す | 待ち時間・回る／回らないの数字 | 「何台・どの工程なら効果が出るか」のレポートと Unity 用のデータ |
| π0.5（Physical Intelligence のロボット用 AI モデル）のクラウド GPU での学習 | 学習の loss・予測とお手本の差 | 学習済みのモデル。朝に実機で比べる |
| その日の作業ログから、記事の下書きとショートの台本を作る | 文字数・決まりに沿っているか | 下書き。人が読んで直す |

逆に、実機を動かす作業と、見た目の確認は夜に任せないことにしました。安全を確かめてから任せる、目で見る確認は AI に渡さない、という前からの決まりがそのまま当てはまります。夜はシミュレーション・学習・文章、昼は実機と判断、という分け方です。

### サービスロボット導入シミュレーションを横に広げるのに足りない4つ

次の問いは、いま回しているサービスロボット導入シミュレーションを、もっと多くの種類でずっと回すには何が要るか、でした（夜に回すことは、もうしています）。

答えは、計算の速さより、人が毎回決めている部分を型にすることでした。新しいケースを足すたびに、店の形・回し方・ずれの見方を人が判断しているので、夜に回せるのは決めた分だけになっています。

| # | 足りないもの | 中身 |
|---|---|---|
| 1 | ケースを部品の組み合わせで書ける型 | 店の形 × ロボットの機種 × 運用の決まり × 来客の波を別々の部品にする。機種は速さ・積める量・通れる幅の表、運用は受け渡しのしかたと下げ膳の3通りを選べるようにする。広告パネル付きのロボットや宴会場も、新しく作らず組み合わせで書ける |
| 2 | 夜の結果を自動で振り分ける判定 | 立ち往生・向かい合っての停止・試算とのずれを自動で見つけ、「正常」「Unity の作りの問題」「試算を見直す候補」に分けて台帳に入れる |
| 3 | 次に回す条件を決める仕組み | 全部の組み合わせを回さず、「回る／回らない」が切り替わる境目の近くを重点的に回す。ケースごとに「何がわかったら終わりか」を先に決める |
| 4 | 画面なしで回せる実行環境 | Unity を画面を出さずに起動し、条件と乱数の種を外から渡せるようにする |

注意点も1つ付いていました。型を増やしても、数字の確からしさは増えません。種類を広げるほど、実際の店の観察データ1件の値打ちが上がります。

### 1〜4を作る順番と、段階Bを回し切るまで区切らない理由

続けて、「1〜4を作るには何が要るか」「回しているシミュレーションは、どこかで区切る必要があるか」を聞きました。

区切りは必要で、場所は「いまの Unity の作りのまま段階B・Cを回し切ったところ」です。途中で作りを変えると前後の結果が比べられなくなるのと、その結果が自動の判定（2）の答え合わせの材料になるからです。区切ったら Unity の版を固定し、結果を「基準データ v1」として残します。

1〜4は、人が決めることと、作ることに分かれます。

| # | 決めること（人） | 作ること |
|---|---|---|
| 1 | 運用の決まりを言葉にする（受け渡しのしかた・下げ膳の3通り・誰が何を優先するか） | 試算の側で部品ごとのファイルの形を作り、いまの値と店の形を分ける。機種の公表値の表を調べて作る |
| 2 | 何秒止まったら立ち往生とするか、ずれがどれだけなら試算を見直すか | Unity から止まりや詰まりの記録を書き出す仕組みと、台帳の列の定義 |
| 3 | ケースごとの「何がわかったら終わりか」 | 境目を探す処理。探すのは速い SimPy の試算で行い、Unity は見つかった境目の条件だけを回す |
| 4 | — | Unity を画面なしで起動し、条件と乱数の種を外から渡す。Mac が夜にスリープしない設定 |

作る順番は「区切り → 2 → 4 → 1 → 3」にしました。判定がないまま回す量を増やすと、確かめる手間だけが増えるからです。

### 決めることのたたき台：立ち往生30秒・向かい合い60秒・2倍・種3つ

Unity が回っている間に、最初に決める「2. 自動の判定」のたたき台を出しました。

| 決めること | たたき台 |
|---|---|
| 同じ場所で何秒動かなければ立ち往生とするか | 30秒 |
| ロボットどうしが向かい合って止まるのを問題とする長さ | 60秒のまま |
| 試算を見直す線 | 向きが逆、または大きさが2倍以上ずれたら |
| 1条件あたり何回回すか | 乱数の種3つのまま |

このあと Mac をつないで実際のデータを読み、このたたき台のうち3つを直すことになります。


## 作業ログ ― 段階Bの18回の結果で、たたき台を直す

### Unity の台帳 unity_ledger_v1.csv を読む

Mac をつなぐと、KIOXIA の `service_sim/unity/` に1回ごとのフォルダと、全部の回を1行ずつ並べた台帳 `unity_ledger_v1.csv` がありました。この夜の段階B（Unity の作りは cs:143）は、2つの店（③効率重視・入口が角＝fam_p1、⑦その通路を広げた店＝fam_p5）× ロボット0・1・2台 × 乱数の種3つの18回です。読み始めたときは12回が終わっていて、記録をまとめる時点で18回すべてがそろいました。

台帳を読んだコマンドと、18回そろった時点の出力です。どの数も「Unity の3回の値 / 試算の値」の形で、待ち時間の単位は分です。

```bash
cd $HOME/mnt/KIOXIA/r2r2r-data/service_sim/unity/ && date && grep -c "stageB_cs143" unity_ledger_v1.csv && python3 - <<'EOF'
import csv
rows=[r for r in csv.DictReader(open('unity_ledger_v1.csv')) if r['unity_cs']=='cs:143' and 'stageB' in r['run_folder']]
from collections import defaultdict
g=defaultdict(list)
for r in rows: g[(r['store'],r['robots'])].append(r)
f=lambda x: float(x) if x else float('nan')
print('店|台数|回数|入店待ち(分) Unity/サイト|スタッフ待ち|料理待ち|ロボットが運んだ割合|60秒以上止まった回数|時間切れ|客を元の場所へ戻した回数|往復')
for k in sorted(g):
  rs=sorted(g[k],key=lambda r:r['seed'])
  out=[k[0],k[1],str(len(rs))]
  for m in ['entry_wait','staff_wait','dish_wait']:
    u=[f(r[m+'_mean']) for r in rs]; s=f(rs[0]['site_'+m+'_mean'])
    out.append('%s / %.1f'%(','.join('%.1f'%x for x in u),s))
  out.append('%s / %.2f'%(','.join('%.2f'%f(r['robot_share']) for r in rs), f(rs[0]['site_robot_share'])))
  for m in ['stuck_count','timeouts','warped','trips']: out.append(','.join(r[m] for r in rs))
  print('|'.join(out))
print('pause_rate空欄', sum(1 for r in rows if not r['pause_rate']), 'stuck_rate空欄', sum(1 for r in rows if not r['stuck_rate']), '/', len(rows))
EOF
```

```text
Sat Oct 24 14:25:33 UTC 2026
18
店|台数|回数|入店待ち(分) Unity/サイト|スタッフ待ち|料理待ち|ロボットが運んだ割合|60秒以上止まった回数|時間切れ|客を元の場所へ戻した回数|往復
fam_p1|0|3|1.5,1.7,7.2 / 4.0|0.3,0.6,4.2 / 3.4|0.8,0.9,1.0 / 0.6|0.00,0.00,0.00 / 0.00|0,0,0|0,0,0|6,13,12|0,0,0
fam_p1|1|3|1.4,1.7,7.5 / 1.8|0.2,0.4,3.5 / 1.2|2.2,2.5,2.5 / 2.0|0.82,0.69,0.66 / 0.73|5,4,4|0,1,0|34,31,50|65,64,75
fam_p1|2|3|1.6,2.0,7.4 / 1.1|0.3,0.7,3.4 / 0.7|1.2,1.5,1.8 / 1.0|0.97,0.90,0.82 / 0.99|24,23,42|2,3,2|73,51,64|120,124,135
fam_p5|0|3|1.8,1.9,8.1 / 4.8|0.6,0.7,4.5 / 3.9|1.1,1.1,1.2 / 0.7|0.00,0.00,0.00 / 0.00|0,0,0|0,0,0|37,28,38|0,0,0
fam_p5|1|3|1.7,1.8,8.5 / 2.1|0.4,0.5,4.6 / 1.4|2.1,2.7,2.8 / 2.2|0.81,0.63,0.63 / 0.71|5,7,9|0,0,0|67,48,81|61,61,69
fam_p5|2|3|1.6,2.0,7.8 / 1.1|0.3,0.7,4.4 / 0.8|1.0,1.5,1.6 / 1.0|0.97,0.92,0.94 / 0.99|25,13,26|2,2,2|83,84,108|128,129,152
pause_rate空欄 18 stuck_rate空欄 18 / 18
```

### 乱数の種2の回だけ、入店待ちが7〜8分になる

一番目立ったのは、乱数の種2の回です。ロボットの台数に関係なく、入店待ちが7.2〜8.5分になっています。種0と種1は1.4〜2.0分です。スタッフ待ちも同じで、種2だけ3.4〜4.6分、ほかは0.2〜0.7分でした。

これでは、1条件3回では、入店待ちとスタッフ待ちについて「ロボットを入れた効果」と「たまたま」の差が見分けられません。たたき台の「種3つのまま」を直し、判定には、ばらつきの小さい料理待ちとロボットが運んだ割合を使い、入店待ちとスタッフ待ちは種を5つ以上にするまで参考扱いにしました。

最初に12回の時点で読んだときは、⑤のロボット0台のスタッフ待ちが、試算の3.9分に対して Unity は0.6〜0.7分で、「ロボットがいなくてもずれているので、人の動かし方の問題」と読みました。18回そろうと、種2の回は4.5分で試算に近く、ずれの大きさも種で揺れていたことになります。それでも、ロボット0台の回は「人の動かし方」を確かめる基準になるので、0台の回がずれていたらロボットの回は比べずに分ける、という順番は残しました。

### 料理待ちは、試算と向きも大きさも合った

料理待ち（料理ができてから卓に届くまで）は、どの条件でも3回の値が近く、試算とも合っていました。向きは、ロボット0台が一番短く、1台が一番長く、2台がその間で、試算と同じです。大きさも、試算1.0分に対して Unity は1.0〜1.8分です。

前の版の Unity の作り（cs:140）では、同じ③のロボット2台・種0で料理待ちが4.2分あり、試算の1.0分の約4倍でした。cs:143 では1.2分まで縮んでいます。「ロボットを入れたときだけずれる」と困っていた所が、作りの直しでほぼ解けたことになります。

### 立ち往生の線は、30秒ではなく60秒のまま

回したときの設定（`conditions.json`）を見ると、Unity はすでに60秒で立ち往生を数えていました。

```text
settings.robot_spec.replanAfterStoppedSec = 10.0
settings.robot_spec.stuckAfterStoppedSec = 60.0
settings.robot_spec.stuckRecoveryWaitSec = 15.0
settings.robot_spec.headOnStuckSec = 10.0
settings.people_spec.guestGiveUpSec = 60.0
settings.people_spec.guestWarpHomeSec = 60.0
settings.people_spec.staffGiveUpSec = 60.0
```

ここで30秒に変えると、これまでの結果と比べられなくなります。たたき台の30秒をやめ、60秒のままにしました。

### 台帳の pause_rate と stuck_rate が、18回とも空いている

自動の判定に使うつもりだった「止まった割合（pause_rate）」と「動けなくなった割合（stuck_rate）」の列が、18回とも空でした。また、客が60秒止まったまま元の場所に戻された回数（warped）が、ロボット0台の回でも6〜38回あります。これは「Unity の作りの問題」として数える指標になります。

列を埋める直しは、区切りのあとに Unity の作りを触る最初の直しとして出します。区切る前に作りを変えないという決まりどおり、ここでは読むだけにしました。

- ねらい：自動の判定の線を、想像の数字ではなく、実際の18回の結果で決める
- 変わったこと：たたき台4つのうち3つを直した（種3つでは入店待ちを判定に使わない／立ち往生は60秒のまま／0台の回を先に見る）。料理待ちとロボットが運んだ割合を判定の主な数にした
- 見込める効果：夜に回した結果のうち、乱数のたまたまで「試算を見直す候補」に入る回が減る（見込み。区切りのあと、判定を入れてから数で確かめる）


## 作業ログ ― X と LinkedIn の予約投稿を作る

### X は使った分だけ払う方式、LinkedIn の個人の投稿は無料

途中で、「X と LinkedIn の自動投稿って可能ですか？」と聞きました。どちらも公式の API で投稿できますが、条件が違います。調べた内容をまとめます。

| | X | LinkedIn（個人のアカウント） |
|---|---|---|
| 料金 | 使った分だけ払う。1投稿 約0.015ドル、リンク付きは約0.2ドル。無料の枠は2026年2月に新規の受け付けが終わっている | 公開されている料金はない（無料） |
| 先に要るもの | クレジットを先に買う。買うまでは投稿が失敗する | 開発者のアプリ。アプリには会社ページを1つ紐づける必要がある |
| 審査 | 開発者の登録で使い道を書く | 個人の投稿（Share on LinkedIn）は審査なし。会社ページへの投稿は審査が要る |
| 鍵の期限 | なし | 60日で切れる。作り直しが要る |

出典は、X の料金が X の開発者ドキュメントの料金のページ、無料の枠の終わりは OpenTweet の解説、LinkedIn の条件は OpenTweet の解説と LinkedIn のヘルプ・Microsoft Learn の版の説明です。OpenTweet は X への投稿の道具を売る会社なので、料金の数字は X の公式のページで確かめ直しました。記事のリンク付きで月20本出しても約4ドルなので、費用はほとんど気になりません。

Facebook は、個人のアカウントへの API での投稿ができないので、これまでどおり手で出します。

### 予定表 sns/schedule.json を、GitHub Actions が15分ごとに読む

SNS の投稿文は、もともと記事ごとに GitHub の `r2r2r-content` の `sns/LOG{番号}_sns_posts.json` に置いています。そこで、同じ場所に「いつ・どの投稿を・どこに出すか」の予定表を足し、GitHub Actions（GitHub の上で決まった時刻にプログラムを動かす仕組み）に15分ごとに読ませることにしました（翌朝、15分ごとの定期実行が2〜3時間おきにしか動いていないと分かり、起こし役を Cloudflare の Worker に替えました。下の「定期実行が遅れたので、Cloudflare から5分ごとに起こす」）。

予定表の1行はこの形です。

```json
{"id": "LOG375-x", "source": "sns/LOG375_sns_posts.json", "platform": "x", "post_at": "2026-10-25T08:00:00+09:00"}
```

流れは次のとおりです。

1. 投稿文を作り、出すかどうかと時刻を人が決める
2. Claude が予定表に1行足して push する
3. 15分ごとに Actions が動き、時刻が来た投稿を出して、結果を `sns/posted.json` に残す

予定表を push したときは、Actions がすぐに中身だけを確かめます（文字数・ファイルがあるか・時刻の書き方）。問題があると Actions が赤くなり、その予定表のままでは投稿しません。X の文字数は X の数え方（日本語は1字を2、URL は23として280まで）で数えます。予定から12時間以上遅れたもの（Actions が止まっていたなど）は出さずに見送ります。

LinkedIn には、記事のリンクを表紙画像付きのカードで付けます。LinkedIn の新しい投稿の仕組み（Posts API）は、リンクを貼っても表紙画像を自動では取ってこないので、記事のページから `og:image` を読み、画像を LinkedIn に上げてからカードに付けています。LinkedIn 向けの補足の文（`linkedin_comment`）は、投稿のすぐあとに自分のコメントとして付けます。

### 投稿の前に「投稿中」を push して、二重投稿を防ぐ

外に出た投稿は取り消しにくいので、同じ投稿が2回出ることだけは避けたいと考えました。そこで、**投稿の前に `posted.json` に「投稿中」と書いて GitHub に push し、push できなかったら投稿しない作りにしました。**投稿のあとに、結果（投稿済み・失敗など）で書き直してもう一度 push します。

失敗の種類も分けました。相手から断られた（HTTP のエラーが返った・鍵がない）なら、投稿は出ていないので「失敗（failed）」。通信が途中で切れたなどで出たか分からないときは「要確認（unknown）」。どちらも自動ではやり直さず、人が確かめてから `posted.json` の行を消すと、次の回でやり直します。

【図解】LOG376_autopost_flow.html

- ねらい：Actions が同時に2回動いたり、途中で止まったりしても、同じ投稿を2回出さない
- 変わったこと：投稿の前に「投稿中」の記録を push し、push できなければ投稿しない。結果は posted / failed / unknown / skipped_stale に分けて残す
- 見込める効果：ダミーのサーバーでの試験では、記録の push を断らせた回はサーバーへの送信が0回で、投稿は出なかった。同じ予定表で2回続けて動かしても、2回目は「時刻が来ている投稿はありません」で終わった（下の試験の出力）

### tools/sns_autopost.py（全文）

Python の標準のライブラリだけで書きました。X の鍵の署名（OAuth 1.0a の HMAC-SHA1）も自分で書いています。

```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 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":
        return {"text": posts.get("x" + sfx, "").strip()}
    return {
        "text": posts.get("linkedin" + sfx, "").strip(),
        "comment": posts.get("linkedin_comment" + sfx, "").strip(),
        "url": meta.get("url" + sfx) or meta.get("url", ""),
        "title": 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}）")
    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 post_x(t):
    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)}")
    url = os.environ.get("X_API_BASE", "https://api.x.com") + "/2/tweets"
    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"}, {"text": t["text"]}, "X 投稿")
    pid = json.loads(raw)["data"]["id"]
    return {"post_id": pid, "url": f"https://x.com/i/web/status/{pid}"}


# ---------- 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:
        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"]
        http("PUT", v["uploadUrl"], {"Authorization": f"Bearer {token}"}, 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="")
            _, h2, _ = http("POST", f"{li_base()}/rest/socialActions/{enc}/comments", li_headers(token),
                            {"actor": person, "object": urn, "message": {"text": t["comment"]}}, "LinkedIn コメント")
            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}"
        lines += [info, "", md_quote(tx["text"]), ""]
    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:
            print(f"[{e['id']}] {e['platform']} に投稿します（試し・送らない）\n----\n{t['text']}\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"):
                print(f"[{e['id']}] コメントは失敗しました: {result['comment_error']}")
                failed = True
        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']}）")
    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（全文）

```yaml
name: SNS自動投稿

on:
  schedule:
    - cron: "*/15 * * * *"   # 15分ごと（GitHub の都合で数分〜十数分遅れることがある）
  workflow_dispatch:
    inputs:
      dry_run:
        description: "試し（投稿しない）"
        type: boolean
        default: true
      verify:
        description: "鍵が使えるかだけ確かめる（投稿しない）"
        type: boolean
        default: false
  push:
    branches: [main]
    paths:
      - "sns/schedule.json"
      - "tools/sns_autopost.py"

permissions:
  contents: write

concurrency:
  group: sns-autopost
  cancel-in-progress: false

jobs:
  # 予定表を push したら、中身だけ確かめる（投稿はしない）
  check:
    if: github.event_name == 'push'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.12"
      - run: python tools/sns_autopost.py check
        env:
          X_LONG_POSTS: ${{ vars.X_LONG_POSTS }}
      # 日時と本文の一覧（sns/schedule_preview.md）を作り直し、変わっていれば push する
      - name: 一覧を更新
        run: |
          python tools/sns_autopost.py preview
          git config user.name "sns-autopost"
          git config user.email "sns-autopost@users.noreply.github.com"
          git add sns/schedule_preview.md
          if ! git diff --cached --quiet; then
            git commit -m "SNS予約：一覧を更新"
            git pull --rebase origin main && git push origin HEAD:main
          fi

  post:
    if: github.event_name != 'push'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.12"
      - name: 投稿
        env:
          AUTOPOST_GIT: "1"
          X_LONG_POSTS: ${{ vars.X_LONG_POSTS }}
          LINKEDIN_VERSION: ${{ vars.LINKEDIN_VERSION }}
          X_API_KEY: ${{ secrets.X_API_KEY }}
          X_API_SECRET: ${{ secrets.X_API_SECRET }}
          X_ACCESS_TOKEN: ${{ secrets.X_ACCESS_TOKEN }}
          X_ACCESS_SECRET: ${{ secrets.X_ACCESS_SECRET }}
          LINKEDIN_ACCESS_TOKEN: ${{ secrets.LINKEDIN_ACCESS_TOKEN }}
          LINKEDIN_PERSON_URN: ${{ secrets.LINKEDIN_PERSON_URN }}
        run: |
          git config user.name "sns-autopost"
          git config user.email "sns-autopost@users.noreply.github.com"
          if [ "${{ github.event_name }}" = "workflow_dispatch" ] && [ "${{ inputs.verify }}" = "true" ]; then
            python tools/sns_autopost.py verify
          elif [ "${{ github.event_name }}" = "workflow_dispatch" ] && [ "${{ inputs.dry_run }}" = "true" ]; then
            python tools/sns_autopost.py run --dry-run
          else
            python tools/sns_autopost.py run
          fi
```

### ダミーのサーバーで試した：投稿・二重投稿・断られたとき・記録の push に失敗したとき

本物の X と LinkedIn に出す前に、手元で X と LinkedIn の代わりをするダミーのサーバーを立て、GitHub の代わりの置き場（bare リポジトリ）を作って試しました。

### mock.py（X と LinkedIn の代わりをするダミーのサーバー）

```python
import json, http.server, sys
LOG=[]
class H(http.server.BaseHTTPRequestHandler):
    def log_message(self,*a): pass
    def _body(self):
        n=int(self.headers.get('Content-Length',0)); return self.rfile.read(n)
    def do_GET(self):
        if self.path.startswith('/v2/userinfo'):
            self._send(200,{'sub':'abc123'})
        elif self.path.startswith('/article'):
            b=b'<html><head><meta property="og:image" content="/img.png"></head></html>'
            self.send_response(200); self.end_headers(); self.wfile.write(b)
        elif self.path.startswith('/img.png'):
            self.send_response(200); self.end_headers(); self.wfile.write(b'PNGDATA')
        else: self._send(404,{})
    def do_PUT(self):
        b=self._body(); open('log.txt','a').write(f'PUT {self.path} {len(b)}\n'); self.send_response(201); self.end_headers()
    def do_POST(self):
        b=self._body(); open('log.txt','a').write(f'POST {self.path} auth={self.headers.get("Authorization","")[:12]} ver={self.headers.get("LinkedIn-Version")} {b.decode()[:3000]}\n')
        if self.path=='/2/tweets':
            if 'FAIL' in b.decode(): return self._send(403,{'detail':'forbidden'})
            self._send(201,{'data':{'id':'1111','text':'x'}})
        elif self.path.startswith('/rest/images'):
            self._send(200,{'value':{'uploadUrl':f'http://127.0.0.1:{PORT}/upload','image':'urn:li:image:IMG1'}})
        elif self.path=='/rest/posts':
            self.send_response(201); self.send_header('x-restli-id','urn:li:share:999'); self.end_headers()
        elif '/comments' in self.path:
            self.send_response(201); self.send_header('x-restli-id','urn:li:comment:(urn:li:share:999,5)'); self.end_headers()
        else: self._send(404,{})
    def _send(self,c,o):
        b=json.dumps(o).encode(); self.send_response(c); self.send_header('Content-Type','application/json'); self.end_headers(); self.wfile.write(b)
PORT=int(sys.argv[1])
http.server.HTTPServer(('127.0.0.1',PORT),H).serve_forever()
```

試した予定表は、時刻が来た X と LinkedIn・1日以上遅れたもの・まだ先のもの、の4本です。続けて、X に断られたとき（403）・記録の push を断らせたとき・予定表の書き方が間違っているときも試しました。記事にまとめるときに、同じ試験をもう一度通して回し、その出力をそのまま載せています。試験の手順のスクリプトです。

### run_tests.sh（試験の手順）

```bash
#!/bin/bash
# LOG376 の試験：ダミーの X・LinkedIn（mock.py）と、GitHub の代わりの bare リポジトリで、sns_autopost.py を動かす
set -u
S=$(cd "$(dirname "$0")" && pwd); cd "$S"
python3 mock.py 8765 & MOCK=$!; sleep 1
rm -rf t remote.git log.txt; git clone -q --bare /home/claude/r2r2r-content remote.git; git clone -q remote.git t
python3 - <<'PY'
import json
src=json.load(open('t/sns/LOG375_sns_posts.json')); src['meta']['url']='http://127.0.0.1:8765/article'
json.dump(src,open('t/sns/TEST_sns_posts.json','w'),ensure_ascii=False)
json.dump({'posts':[
 {'id':'T-x','source':'sns/TEST_sns_posts.json','platform':'x','post_at':'2026-10-24T21:00:00+09:00'},
 {'id':'T-li','source':'sns/TEST_sns_posts.json','platform':'linkedin','post_at':'2026-10-24T21:05:00+09:00'},
 {'id':'T-old','source':'sns/TEST_sns_posts.json','platform':'x','post_at':'2026-10-23T05:00:00+09:00'},
 {'id':'T-future','source':'sns/TEST_sns_posts.json','platform':'x','post_at':'2026-10-26T08:00:00+09:00'}]},open('t/sns/schedule.json','w'),ensure_ascii=False)
json.dump({},open('t/sns/posted.json','w'))
PY
cd t; git -c user.name=t -c user.email=t@t add sns; git -c user.name=t -c user.email=t@t commit -qm test; git push -q origin HEAD
export X_API_BASE=http://127.0.0.1:8765 LINKEDIN_API_BASE=http://127.0.0.1:8765 X_API_KEY=k X_API_SECRET=s X_ACCESS_TOKEN=t X_ACCESS_SECRET=ts LINKEDIN_ACCESS_TOKEN=lt AUTOPOST_GIT=1 GIT_AUTHOR_NAME=a GIT_AUTHOR_EMAIL=a@a GIT_COMMITTER_NAME=a GIT_COMMITTER_EMAIL=a@a
export AUTOPOST_NOW=2026-10-24T21:40:00+09:00
echo "== 1. 試し（送らない）"; python3 tools/sns_autopost.py run --dry-run | grep '^\['
echo "== 2. 投稿する"; python3 tools/sns_autopost.py run; echo "exit=$?"
echo "== 3. もう一度動かす（二重投稿しないか）"; python3 tools/sns_autopost.py run; echo "exit=$?"
echo "== posted.json"; cat sns/posted.json
echo "== GitHub の代わりの置き場に残った記録"; git --git-dir=../remote.git log --format='%s' | head -7
echo "== ダミーのサーバーが受け取ったもの"; cat ../log.txt
export AUTOPOST_NOW=2026-10-26T08:10:00+09:00
python3 - <<'PY'
import json;p='sns/TEST_sns_posts.json';d=json.load(open(p));d['posts']['x']='FAIL test';json.dump(d,open(p,'w'),ensure_ascii=False)
PY
echo "== 4. X に断られたとき（403）"; python3 tools/sns_autopost.py run; echo "exit=$?"; python3 -c "import json;print(json.load(open('sns/posted.json'))['T-future'])"
python3 - <<'PY'
import json;p='sns/schedule.json';d=json.load(open(p));d['posts'].append({'id':'T-lock','source':'sns/TEST_sns_posts.json','platform':'x','post_at':'2026-10-26T08:00:00+09:00'});json.dump(d,open(p,'w'),ensure_ascii=False)
PY
printf '#!/bin/sh\nexit 1\n' > ../remote.git/hooks/pre-receive; chmod +x ../remote.git/hooks/pre-receive; : > ../log.txt
echo "== 5. 記録の push が断られたとき"; python3 tools/sns_autopost.py run; echo "exit=$?"; echo "ダミーのサーバーへの送信: $(wc -l < ../log.txt) 回"
rm ../remote.git/hooks/pre-receive
python3 - <<'PY'
import json;p='sns/schedule.json';d=json.load(open(p));d['posts'].append({'id':'T-bad','source':'sns/nope.json','platform':'fb','post_at':'2026-10-26 08:00'});json.dump(d,open(p,'w'),ensure_ascii=False)
PY
echo "== 6. 予定表の書き方が間違っているとき"; python3 tools/sns_autopost.py check; echo "exit=$?"
kill $MOCK
```

### test_output.txt（run_tests.sh の出力の全量）

ダミーのサーバーは、受け取った中身を1件3,000字まで記録しています。

```text
== 1. 試し（送らない）
[T-old] 予定から 1 day, 16:40:00 遅れているので見送ります
[T-x] x に投稿します（試し・送らない）
[T-li] linkedin に投稿します（試し・送らない）
== 2. 投稿する
[T-old] 予定から 1 day, 16:40:00 遅れているので見送ります
[T-x] 投稿しました https://x.com/i/web/status/1111
[T-li] 投稿しました https://www.linkedin.com/feed/update/urn:li:share:999/
exit=0
== 3. もう一度動かす（二重投稿しないか）
時刻が来ている投稿はありません。
exit=0
== posted.json
{
  "T-old": {
    "status": "skipped_stale",
    "at": "2026-10-24T21:40:00+09:00",
    "post_at": "2026-10-23T05:00:00+09:00"
  },
  "T-x": {
    "status": "posted",
    "at": "2026-10-24T21:40:00+09:00",
    "platform": "x",
    "post_id": "1111",
    "url": "https://x.com/i/web/status/1111"
  },
  "T-li": {
    "status": "posted",
    "at": "2026-10-24T21:40:00+09:00",
    "platform": "linkedin",
    "post_id": "urn:li:share:999",
    "url": "https://www.linkedin.com/feed/update/urn:li:share:999/",
    "comment_id": "urn:li:comment:(urn:li:share:999,5)"
  }
}
== GitHub の代わりの置き場に残った記録
SNS自動投稿：T-li の結果（posted）
SNS自動投稿：T-li を投稿中
SNS自動投稿：T-x の結果（posted）
SNS自動投稿：T-x を投稿中
SNS自動投稿：T-old を見送り（予定から12時間以上遅れ）
test
SNS：LinkedIn のトークン設定済み。マンガの LinkedIn も予約に入れる
== ダミーのサーバーが受け取ったもの
POST /2/tweets auth=OAuth oauth_ ver=None {"text": "協働ロボットは70万円台から買える。\nでも仕事で使うとなると、値段より先に決めることだらけだった。\n\n病院の搬送ロボットだと、導入の数か月の大半は部署ごとのOK待ち。薬剤部、看護部、施設、IT、防火…\n\n技術より調整なんだ、と正直驚いた！\n#協働ロボット #ロボット導入\nhttps://pixelstomotors.com/record/cobot-deployment-law-and-coordination"}
POST /rest/images?action=initializeUpload auth=Bearer lt ver=202605 {"initializeUploadRequest": {"owner": "urn:li:person:abc123"}}
PUT /upload 7
POST /rest/posts auth=Bearer lt ver=202605 {"author": "urn:li:person:abc123", "commentary": "A collaborative robot arm now costs about ¥770,000 in Japan. Buying one is easy. Using it for client work is not.\n\nI started from a video by PyBrain AI, a Korean robotics startup. Their mobile picking robot runs on a cheap mini PC and sends vision inference to a GPU machine elsewhere in the building, with a round trip of about 0.65 seconds. Their arm is a FAIRINO FR5, the same family that MicroFactory uses.\n\nThat made me want the arm as an option for my own work, so I looked into what it takes to deploy one in Japan. A few things stood out:\n\n- Most legal duties sit with the user, not the supplier: the risk assessment for fenceless use, special safety training for anyone who teaches the robot, written work rules\n- The supplier's job is to hand over what the user needs to meet those duties: a list of remaining hazards, instructions, settings, and a plan for re-checking safety when a learned model or gripper changes\n- ISO 10218 \\(2025\\) now evaluates the application, not the robot. Change the gripper, and you re-evaluate\n- In hospitals, a transport robot needs no license, but drug-handling rules, fire doors, elevators, and radio policy all apply. Most of the months it takes to deploy go into getting each department to sign off\n\nI also made a mistake on the way: I compared FR3 and FR5 prices from different countries. From the same store, the gap is about ¥50,000. The only real difference is size.\n\nPhysical AI keeps getting more capable. But the last step into a real site seems to be people deciding who decides what.\n\nWhere do you see deployments slow down most: the technology, or the sign-offs?\n\n{hashtag|\\#|Cobot} {hashtag|\\#|RobotDeployment} {hashtag|\\#|PhysicalAI} {hashtag|\\#|HospitalRobotics} {hashtag|\\#|協働ロボット} {hashtag|\\#|ロボット導入}\n\n協働ロボットのアームは、日本でいま約77万円で買える。買うのは簡単。でも、それで仕事を請けるのは簡単じゃなかった。\n\nきっかけは、韓国のスタートアップ PyBrain AI の動画。移動するピッキングロボットが、安いミニPCだけで動いていて、画像認識は建物の中の別の GPU マシンに頼み、往復約0.65秒で返してもらっている。アームは FAIRINO の FR5 で、MicroFactory が使っているのと同じシリーズだった。\n\n自分の仕事でも候補に入れたくなって、日本で使うときに何が要るかを調べた。目立ったのはこのあたり。\n\n・法律上の義務の多くは、納める側ではなく使う側にある。柵なしで使うための危険の評価、ロボットに動きを教える人の特別教育、作業の決まりを文書にすること\n・納める側の仕事は、その義務を果たせる材料を渡すこと。残った危険の一覧、説明書、設定値、そして学習したモデルや手先を変えたときに安全を確かめ直す手順\n・ISO 10218 の2025年の改訂では、ロボット単体ではなく使い方の単位で評価する。手先を変えたら評価し直し\n・病院の搬送ロボットは資格は要らないけれど、薬の管理、防火戸、エレベーター、電波の管理の決まりがかかる。導入の数か月の大半は、部署ごとのOKをもらう時間だった\n\n途中で失敗もした。FR3 と FR5 の価格を、違う国の値段のまま比べていた。同じ店で比べ直すと差は約5万円で、違いは大きさだけだった。\n\nフィジカルAIはどんどんできることが増えている。でも現場に入る最後の一歩は、誰が何を決めるかを人が決めることなのかもしれない。\n\n導入が遅れるのは、技術のせいと、調整のせい、どちらが多いと感じますか？", "visibility": "PUBLIC", "distribution": {"feedDistribution": "MAIN_FEED", "targetEntities": [], "thirdPartyDistributionChannels": []}, "content": {"article": {"source": "http://127.0.0.1:8765/article", "title": "協働ロボットを現場に入れる前に ― 法律・資格・院内の調整を調べた", "description": "韓国PyBrainのロボットが使うFAIRINOのアームから、協働ロボットで事業をするときの法律・資格・責任を、事故事例と制度で調べた。病院の搬送ロボットでは、薬・防火・電波と部署ごとの調整が壁になる。", "thumbnail": "urn:li:image:IMG1"}}, "lifecycleSt
POST /rest/socialActions/urn%3Ali%3Ashare%3A999/comments auth=Bearer lt ver=202605 {"actor": "urn:li:person:abc123", "object": "urn:li:share:999", "message": {"text": "A note on sources: the safety-law points come from Japan's Ministry of Health, Labour and Welfare (the 2013 notice on fenceless robots and the rule on passing hazard information to buyers), the Product Liability Act Q&A, and public reports from hospitals that deployed transport robots (Panasonic HOSPI, Gunma University Hospital). I am not a lawyer, so treat this as a map, not advice. The full dialogue and all links are in the article.\n\n出典について補足。安全の決まりは、厚生労働省の資料（柵なしで使う条件の2013年の通達、機械を譲る人が危険の情報を伝える決まり）、製造物責任法の Q&A、搬送ロボットを入れた病院の公開資料（パナソニックの HOSPI、群馬大学病院）から整理した。弁護士ではないので、判断の材料の地図として読んでほしい。対話の全量とリンクは記事に載せている。"}}
== 4. X に断られたとき（403）
[T-future] 投稿に失敗しました（failed）: X 投稿: HTTP 403 {"detail": "forbidden"}
exit=1
{'status': 'failed', 'at': '2026-10-26T08:10:00+09:00', 'platform': 'x', 'error': 'X 投稿: HTTP 403 {"detail": "forbidden"}'}
== 5. 記録の push が断られたとき
[T-lock] 記録を push できなかったので投稿しません
exit=1
ダミーのサーバーへの送信: 0 回
== 6. 予定表の書き方が間違っているとき
  - posts[5] T-bad: post_at にタイムゾーンがありません（例 2026-10-25T08:00:00+09:00）: 2026-10-26 08:00
  - posts[5] T-bad: platform は x か linkedin です
2 件の問題があります。
exit=1
```

2回目に動かしたときは「時刻が来ている投稿はありません」で終わり、二重投稿はありませんでした。記録の push を断らせた回は、ダミーのサーバーへの送信が0回で、投稿の前に止まっています。LinkedIn に送った本文では、`(2025)` のような記号がエスケープされ、ハッシュタグが `{hashtag|\#|Cobot}` の形になっていることも確かめました（LinkedIn の本文の書き方では、記号をそのままにすると本文が途中で切れることがあるため）。


## 作業ログ ― 鍵を発行して GitHub に預ける

### X の開発者コンソールで、OAuth 1.0 の鍵を4つ作る

X の開発者コンソールで開発者の登録をすると、使い道を書く欄がありました。書いたのは、自分のアカウントからの投稿だけに使い、X のデータを読む・集める・分析する・再販することはしない、という内容です。登録が終わるとアプリが1つ自動でできていたので、そのアプリの「ユーザー認証の設定」で権限を「読み取りと書き込み」にしました。

ここで2回、違うボタンを押しかけました。X の鍵には、昔からの「OAuth 1.0 キー」と新しい「OAuth 2.0 キー」が並んでいて、今回の仕組みが使うのは OAuth 1.0 の方（コンシューマーキーとアクセストークン）です。設定を保存したときに出た OAuth 2.0 のクライアントシークレットの画面と、OAuth 2.0 のアクセストークンを作る画面を、どちらも閉じて戻りました。OAuth 2.0 のクライアントシークレットはチャットに貼った画面写真に写ってしまったので、使っていない鍵ですが、あとで作り直します。

アクセストークンは、権限を「読み取りと書き込み」にしたあとに作る必要があります。前に作ったトークンは読むだけの権限のままになるからです。画面の表示が「読み取りと書き込み」に変わったのを確かめてから作りました。

鍵は GitHub の Secrets（暗号にして預け、あとから誰にも見えない置き場）に4つ入れました。最初に「記録しました」と伝えたとき、GitHub にはまだ1つも入っていなかったので、名前を1つずつ入れ直しています。

【画像】LOG376_01_github_secrets.png GitHub の Secrets の画面。X の鍵4つ（X_ACCESS_SECRET・X_ACCESS_TOKEN・X_API_KEY・X_API_SECRET）が並んでいる

最後にクレジットを10ドル買いました。初めて買うと同じ額が付く特典があります。足りなくなったら自動で足す設定は使わず、月の支出の上限を決めておくことにしました。

### LinkedIn は、会社ページ「Pixels to Motors」を作ってアプリを紐づけた

LinkedIn の開発者のアプリは、会社ページを1つ紐づけないと作れません。個人の開発者向けの「デフォルトの会社ページ」もあるのですが、名前が画面のどこにも出ていなかったので、迷わない方として「Pixels to Motors」の会社ページを作り、それを紐づけました。投稿先は個人のアカウントのままです。

アプリには、審査なしで使える2つ（Share on LinkedIn と、Sign In with LinkedIn using OpenID Connect）を足しました。Auth のタブで、トークンを作る道具の戻り先の URL を登録し、OAuth のトークンを作る道具で、`openid`・`profile`・`w_member_social` の3つの権限を付けたトークンを作りました。

途中で1回、ログインの画面で止まりました。パスワードだけが自動で入っていて、メールアドレスの欄が空だったためです。入れ直すと通りました。

トークンは60日で切れます。12月23日ごろなので、その1週間前に作り直しを知らせるように予約しました。

### 鍵が使えるかを、投稿せずに確かめる

10月25日の朝8時の1本目を待たずに、鍵が正しいかを確かめたいと考えました。そこで、投稿せずに X では自分の名前を読み、LinkedIn では本人の確認だけを行う `verify` を足しました（上の全文の `verify()`）。X の名前の読み出しも少し費用がかかりますが、1回なのでほとんどかかりません。

Actions の手動の実行は、Claude の側からはボタンを押せない設定だったので、Actions の画面から「鍵が使えるかだけ確かめる」に印を付けて1回動かしてもらいました。

【画像】LOG376_02_actions_verify.png GitHub Actions の「SNS自動投稿」の手動の実行。投稿の役目（post）が8秒で成功している

ログに出た結果です。

```text
post	投稿	2026-10-24T13:53:05.7928233Z [36;1m  python tools/sns_autopost.py verify[0m
post	投稿	2026-10-24T13:53:06.3714153Z X: OK（@adaptdesignjp）
post	投稿	2026-10-24T13:53:06.3725006Z LinkedIn: OK（Eiichi Shimazu 島津 栄一）
```


## 作業ログ ― 予約する投稿を選ぶ

### X のプロフィールの画面と照らして、出していない11本を選んだ

時刻の指定は「10月25日の朝8時と17時」と「平日19:30に毎日日替わり」、どれを出すかは「投稿していないのを探して選んで」でした。

X のプロフィールのページを画面ごと保存した PDF をもらい、10月16日以降に出した投稿と、`sns/` にある LOG341〜375 の投稿文を照らしました。同じ記事の投稿がすでにある LOG356・357・367・370・373 は外し、π0.5 の話とサービスロボットの話が交互になるように並べています。11月3日は祝日なので空けました。

| 日時 | 投稿 |
|---|---|
| 10/25（日）8:00 | LOG375 協働ロボットは70万円台から買える |
| 10/25（日）17:00 | LOG361 π0.5 で初めてブロックが箱に（肩の2°） |
| 10/26（月）19:30 | LOG366 2台借りても47条件で人は減らない |
| 10/27（火）19:30 | LOG363 置き場所を変えても箱に入った |
| 10/28（水）19:30 | LOG364 36分で店が止まった |
| 10/29（木）19:30 | LOG365 PHP の穴の週、別の穴が4つ |
| 10/30（金）19:30 | LOG358 お手本の4割が待ち時間だった |
| 11/2（月）19:30 | LOG359 満席待ちと、手が回らない待ち |
| 11/4（水）19:30 | LOG369 ひと晩で65回公開して LP を詰めた |
| 11/5（木）19:30 | LOG360 記憶のファミレスを図面に |
| 11/6（金）19:30 | LOG372 Mac の空き59GB のはずが14GB |

X の文字数（X の数え方）は、11本とも280以内でした（LOG375 は279、LOG363 は276）。リンク付きなので、11本で約2.2ドルです。

### LinkedIn は、日本語の部分だけを取り出した版を11本作った

LinkedIn も X と同じ11本・同じ時刻にしました。ただ、このあと決まりを変えて（次の節）、LinkedIn の投稿は日本語だけにしたので、公開済みの LOG の LinkedIn の文（英語の本文→日本語の訳）から日本語の部分だけを取り出した版を、別のファイル `sns/LOG{番号}_linkedin_ja.json` に作りました。元のファイルは変えていません。日本語のタグがなかった回は、記事のタグから3つ付けています。取り出したスクリプトです。

```python
import json,re
JP=re.compile(r'[぀-ヿ一-鿿]')
def isjp(p): 
    s=p.strip(); return len(JP.findall(s))>len(s)*0.25 and not s.startswith('#') and not s.startswith('http')
def ja_part(li):
    if isinstance(li,dict): return li.get('ja','')
    paras=li.split('\n\n')
    idx=next(i for i,p in enumerate(paras) if isjp(p))
    out=[p for p in paras[idx:] if p.strip() not in ('（日本語）',)]
    out=[re.sub(r'^（日本語）\n','',p) for p in out]
    # drop english-only tag lines, keep JP tags
    res=[]
    for p in out:
        if p.strip().startswith('#'):
            jt=[t for t in p.split() if JP.search(t)]
            if jt: res.append(' '.join(jt))
        else: res.append(p)
    return '\n\n'.join(res).strip()
def ja_comment(c):
    if isinstance(c,dict): return c.get('ja','')
    return '\n\n'.join(p for p in c.split('\n\n') if isjp(p)).strip()
for n in [375,361,366,363,364,365,358,359,369,360,372]:
    d=json.load(open(f'sns/LOG{n}_sns_posts.json'))
    t=ja_part(d['posts']['linkedin']); c=ja_comment(d['posts'].get('linkedin_comment',''))
    has_tags=any(l.strip().startswith('#') for l in t.split('\n'))
    if not has_tags:
        tags=[x for x in d['meta'].get('tags',[]) if JP.search(x)][:3]
        t+= '\n\n' + ' '.join('#'+re.sub(r'\s+','',x) for x in tags)
    out={"meta":d['meta'],"posts":{"linkedin":t,"linkedin_comment":c},
         "note":f"LOG{n}_sns_posts.json の LinkedIn 文の日本語の部分だけを取り出した版（2026-10-24 から LinkedIn は日本語だけ）。元のファイルは変えていない"}
    json.dump(out,open(f'sns/LOG{n}_linkedin_ja.json','w'),ensure_ascii=False,indent=2)
    print(f'===== LOG{n} ({len(t)}字)\n{t}\n--- comment ---\n{c}\n')
```

取り出した11本は、すべて目で読んで確かめました。LOG358 の補足だけ、先頭に「（日本語）」の見出しが残っていたので消しています。LOG360 は LinkedIn の文が `{"en": …, "ja": …}` の形で入っていたので、`ja` をそのまま使いました。

### 予約の日時と本文の一覧 sns/schedule_preview.md を、自動で作る

予約した中身をどこで確かめるのか、と聞かれました。投稿は GitHub が時刻に出すので、X のアプリの「予約済み」には出てきません。予定表は日時とファイル名だけで、本文は別のファイルにあるので見にくい状態でした。

そこで、予定表を変えたときと投稿したときに、「日時・どこに出すか・本文・文字数・リンク先・結果」を1枚にまとめた `sns/schedule_preview.md` を自動で作り直すようにしました（全文の `write_preview()`）。

- ねらい：何がいつ出るかを、GitHub の1ページで確かめられるようにする
- 変わったこと：予定表を push すると、Actions が一覧を作り直して push する。投稿したら、結果のリンクとエラーも一覧に出る
- 見込める効果：出る前に中身を直す機会が増え、出たあとに「どれが出たか」を探す手間が減る（見込み）

これからの予約は、Claude に「LOG376 を予約して」のように頼めば入ります。時刻を言わなければ、空いている次の平日19:30です。別の会話の Claude にも伝わるように、記事の決まり（article_rules.md）の SNS の節に「SNS の文を作ったら、予約投稿するかを確かめる」を足しました。


## 作業ログ ― 投稿の言語と、マンガ・YouTube の決まり

### 記事と YouTube の投稿は日本語だけ、英語はマンガだけ（平日22:30）

これまでは、LinkedIn だけ英語の本文と日本語の訳を並べていました。でも、記事も YouTube も日本語だけで作っています。英語版があるのは4コママンガ『ハコブくん』だけです。そこで決まりを変えました。

> Xとlinkdeinは日本語と英語の投稿文章作ってもらっているけど、記事、Youtubeなどは日本語だけなので、マンガだけ英語で投稿するようにしたい。英語の投稿は平日22:30で投稿するようにセットできますか？

| 投稿のもと | X・LinkedIn | 予約の時刻（指定がないとき） |
|---|---|---|
| 記事（LOG） | 日本語だけ | 平日 19:30 |
| YouTube | 日本語だけ | 平日 19:30 |
| マンガ | 日本語の投稿と英語の投稿を1本ずつ | 日本語は平日 19:30・英語は平日 22:30 |

仕組みの側では、予定表の行に `"lang": "en"` を付けると、英語の文（`posts.x_en`・`linkedin_en`）と英語のページへのリンク（`meta.url_en`。英語版の4コマのページ `/english-4-koma/ep{N}`）で投稿するようにしました。予約の一覧にも「X（日本語）」「X（英語）」と分けて出ます。試しのマンガのファイルで、日本語は19:30、英語は22:30に分かれて出ることを確かめました。

決まりは、article_rules.md の SNS の節に「投稿の言語」として足し、動画の作り方の決まり（video_rules.md）の投稿文を作る手順も、日本語だけに直しました。公開済みの LOG の英語入りの LinkedIn の文は、そのまま残しています。

### マンガの投稿の決まりは、ハコブくんの会話で足した

マンガの投稿文は、別のプロジェクト（ハコブくん4コママンガ）の会話で作っています。こちらで決めたファイルの形と置き場所を、そのまま貼れる文にして渡しました。その会話の Claude が、GitHub の同じ article_rules.md に「マンガ（ハコブくん）の投稿文の決まり」を足しています（3回のコミット）。

- タグは付けない（X・LinkedIn・Facebook とも、日本語・英語とも）。一度「タグあり」で伝えたあと、付けない方を選び直した
- 文体は自分の話し方（〜だよ／だね／かも）。英語は、話の流れとハコブくんの性格を楽しめる文
- LinkedIn は3〜4段落のエッセイ（日本語500〜700字・英語150〜220 words）。最後は読み手への問いで締める
- X の文字数は URL 込みで数える。日本語は本文が実質約128字、英語は約256字まで
- 予約は確かめずに、既定の時刻で入れる。予約したことと日時は、その会話で伝える。止めたいときは言う
- 予約の前に、リンク先のマンガのページ（日本語・英語）が開けるかを確かめる。開けなければ予約しない
- Facebook（日本語）も同じ会話で作り、json の `facebook` に入れる。自動投稿の対象外なので手で出す
- LinkedIn も予定表に入れる（トークンを入れたあとに足した）

記事の投稿は「出すかを確かめてから予約」、マンガは「確かめずに既定の時刻で予約」と、決まりが分かれています。マンガは英語版も含めて決まった形で毎回出すもので、記事は中身ごとに出す時刻を考えたいからです。

### YouTube のショートの投稿は、公開の時刻と URL を Mac の記録から読む

YouTube は予約して公開しているので、SNS も動画の公開に合わせて出す必要があり、URL を毎回伝えないといけないのか、と聞きました。

伝える必要はありません。Mac が1時間ごとに、YouTube で予約した動画の公開時刻と URL を `youtube_videos.json` に書き出しています（動画の進み具合を判定する仕組みのために、前から動いていたもの）。読んだ時点で、予約中の動画は21本あり、どれにも公開時刻と URL が入っていました。動画の投稿文を作るときに Claude がここを読み、公開の直後に X へ出るように予定表に入れられます。

決めたのは次の3つです。

- ショートも X に出す（ショートは平日の7:30と12:30にも出してよい。動画のストック次第）
- 動画の公開が19:30で、記事の投稿と重なるときは、動画の投稿を20:00にする
- YouTube で公開時刻を変えたときは伝える（X の予約は自動ではずれない）

LinkedIn に動画を出すときは、動画を LinkedIn に直接上げる決まり（10月14日に決めたもの）があるので、そこは手作業のままです。リンク付きの投稿に変えれば自動にできますが、直接上げた動画の方が LinkedIn では見られやすいので、決めるのはあとにしました。

この記録をまとめている間に、動画を作っている別の会話の Claude が、ショートの分を先に作っていました（GitHub の同じリポジトリへのコミット）。中身はこうです。

- ショート31本（予約済み11本・これから上げる20本）の X の文を、`sns/SHORT_ep{N}_sns_posts.json` に置いた。本文の中の `{url}` が、ショートの URL に替わる
- `tools/sync_youtube_shorts.py` が Mac の `youtube_videos.json` を読み、説明文にあるマンガの話の番号で投稿文と動画を結びつけて、公開の時刻で予定表に入れる。19:30 が重なったら 20:00、それ以外は30分ずつ後ろへずらす。YouTube で公開の時刻を変えたら、まだ出していない予約の時刻も次の同期で直る
- 同期は1日2回（7:47・17:47）の予定のタスクが動かす。Mac が眠っていると、その回は何もしない
- 予約済みのショート11本（10月25日〜11月6日）が予定表に入り、予定表は全部で33本になった

本編の動画の投稿を予定表に入れる仕組みは、この時点ではまだありませんでした（そのあと別の会話で、本編も同じ同期で入る形になりました。本編10本の文は、下の「予約をカレンダーに並べ、本編の動画の X を足す」で作っています）。

### 記事の投稿の既定の時刻に、平日12:30と土日12:30を足した

予約が平日19:30だけに並ぶと、出していない記録が溜まるほど先の日付になります。10月25日0時の時点で、平日19:30の予約は11月11日まで埋まっていて、LOG374 は11月10日（火）19:30 に入っていました。

> 投稿時間を平日夜だけではなく、土日12:30、平日の昼間12:30も追加してください。ルール化

時刻の指定がないときの決まりを、次のように変えました（r2r2r-content の `methodology/article_rules.md` と `docs/sns_autopost.md`、プロジェクトの article_rules.md）。

- 日本語の投稿は、平日12:30・平日19:30・土日12:30 のうち、いちばん早く空いている枠に入れる
- 平日は月〜金（祝日も平日として扱う）
- 「空いている」は、予定表にその時刻の投稿がまだないこと。ショートと本編の動画の X も数える。同じ記事の X と LinkedIn は同じ時刻に入れる
- 英語（マンガ）は、空いている次の平日22:30のまま
- ショートと本編の動画は YouTube の公開の時刻で入る。同じ時刻に X の予約があれば、19:30 なら 20:00、12:30 なら 13:00 にずらす（`tools/sync_youtube_shorts.py` の「それ以外は30分ずつ後ろへ」で、12:30 は 13:00 になる。コードは変えず、説明の行だけ足した）

新しい決まりで、LOG374 の X と LinkedIn を、いちばん早く空いていた10月25日（日）12:30 に移しました。予定表を push したときの確かめ（GitHub Actions）は通りました。

```
db845ca SNS予約の既定の時刻に平日12:30と土日12:30を足す・LOG374を10/25 12:30へ
completed	success	SNS予約の既定の時刻に平日12:30と土日12:30を足す・LOG374を10/25 12:30へ	SNS自動投稿	main	push	40105066893	24s	2026-10-24T15:01:12Z
```

- ねらい：出していない記録が溜まっても、予約が何週間も先にならないようにする
- 変わったこと：既定の枠が週5つ（平日19:30）から週12（平日12:30・平日19:30を5日ずつ、土日12:30を2日）になった。LOG374 は11月10日19:30 から10月25日12:30 へ、16日早まった
- 見込める効果：出していない記録を、今までの約2.4倍の速さで出せる。昼の投稿で見る人が増えるかは見込みで、X のアナリティクスで時間帯ごとの表示回数を比べて確かめる

ほかの会話で平日19:30 に入れた11本（LOG358〜LOG377 のうちの11本）は、まだ元の日時のままです。


## 作業ログ ― 定期実行が遅れたので、Cloudflare から5分ごとに起こす

### 15分ごとのはずの定期実行が、3時間半たってから初めて動いた

記録をまとめたあと、朝5時すぎに Actions の動いた記録を見直しました。定期実行（schedule）を入れたのは10月24日21時38分です。15分ごとなら朝5時半までに30回ほど動くはずが、動いたのは2回だけでした。1回目は25日1時13分（入れてから3時間35分後）、2回目は3時47分（その2時間34分後）です。

`gh api` で取った、Actions の動いた記録の全量です（番号・起こされ方・日時（UTC）・結果）。`schedule` が定期実行、`push` が予定表を push したとき、`workflow_dispatch` が手動の実行です。

```text
16	push	2026-10-24T20:39:46Z	success
15	schedule	2026-10-24T18:47:02Z	success
14	schedule	2026-10-24T16:13:32Z	success
13	push	2026-10-24T15:01:12Z	success
12	push	2026-10-24T14:55:12Z	success
11	push	2026-10-24T14:51:49Z	success
10	push	2026-10-24T14:46:16Z	success
9	push	2026-10-24T14:42:15Z	success
8	push	2026-10-24T14:28:14Z	success
7	push	2026-10-24T13:55:52Z	success
6	workflow_dispatch	2026-10-24T13:52:58Z	success
5	push	2026-10-24T13:49:48Z	success
4	push	2026-10-24T13:13:29Z	success
3	push	2026-10-24T13:03:52Z	success
2	push	2026-10-24T13:00:12Z	success
1	push	2026-10-24T12:38:15Z	success
```

GitHub のドキュメントには、定期実行は Actions が混んでいると遅れることがあり、混み方がひどいと待っている実行が落とされることもある、と書かれています。いちばん短い間隔は5分です。8時の1本目が数時間遅れる見込みが高く、12時間以上遅れると「見送り」になる作りなので、出ないまま終わることもありえます。

もう1つ、Actions の分数の問題も見つかりました。このリポジトリは公開していないので、Actions を使える時間は無料の枠で月2,000分までです。GitHub は1回の実行（ジョブ）ごとに、使った時間を1分単位に切り上げて数えます。15分ごとだと1日96回、月に約2,880分になり、時刻が来た投稿がなくても枠を超えます。

### 先に入れた保険：予定表を push したときも、時刻が来た投稿を出す

まず、すぐにできる保険を入れました（10月24日23時42分）。予定表を push したとき、中身を確かめたあとに、時刻が来た投稿も出すようにしました。`sns/tick.txt` を変えて push すると、投稿の役目だけを動かせます。出したものは `posted.json` にあるので、何度動いても同じ投稿は出ません。

ただ、これは誰かが push しないと動きません。寝ている間に時刻どおりに出すには、別の起こし役が要ります。

### Cloudflare の Worker が5分ごとに予定表を見て、時刻が来ていたら Actions を起こす

このサイト（pixelstomotors.com）は、もともと Cloudflare の Worker（Cloudflare の上で動く小さなプログラム）`p2m-site` で配信しています。Worker には Cron Triggers（決まった時刻に Worker を動かす仕組み）があるので、これを起こし役にしました。

1. Worker が5分ごとに、GitHub の `sns/schedule.json` と `sns/posted.json` を読む
2. 時刻が来ていて、まだ出していない投稿がなければ、何もせずに終わる（Actions の分数を使わない）
3. あれば、GitHub の API で「SNS自動投稿」を手動の実行と同じ形で起こす。すでに動いている回があれば、重ねて起こさない
4. 投稿と二重投稿の防止は、これまでどおり `sns_autopost.py` が行う

GitHub の定期実行は、15分ごとから3時間ごとに減らし、Worker が止まったときの保険だけにしました。分数は、3時間ごとで月に約240分です。これに、投稿がある回（1日に数回）が足されます。

### worker/sns_cron.js（全文）

```javascript
// SNS の予約投稿を、時刻どおりに起こす（2026-10-25）。
// GitHub の定期実行（schedule）は混むと何時間も遅れるため、Cloudflare の Cron Triggers（wrangler.jsonc の triggers）で
// 5分ごとにここを動かす。r2r2r-content の sns/schedule.json と sns/posted.json を読み、時刻が来ていてまだ出していない
// 投稿があるときだけ、GitHub Actions の「SNS自動投稿」（sns-autopost.yml）を起動する。出すものがなければ何もしない
// （GitHub Actions の分数を使わないため）。
// 鍵は Cloudflare の秘密の値 GH_SNS_TOKEN（GitHub の fine-grained token。r2r2r-content だけ・Actions 読み書き・Contents 読み取り）。
// 鍵がなければ何もしない。投稿そのものと二重投稿の防止は sns_autopost.py が行う。
const REPO = 'PixelstoMotors/r2r2r-content';
const WORKFLOW = 'sns-autopost.yml';
const STALE_MS = 12 * 3600 * 1000; // sns_autopost.py の STALE_HOURS と同じ。これより古いものは起こさない

async function gh(env, path, init = {}) {
  return fetch(`https://api.github.com/repos/${REPO}${path}`, {
    ...init,
    headers: {
      Authorization: `Bearer ${env.GH_SNS_TOKEN}`,
      Accept: init.accept || 'application/vnd.github.raw+json',
      'X-GitHub-Api-Version': '2022-11-28',
      'User-Agent': 'p2m-sns-cron',
      ...(init.body ? { 'Content-Type': 'application/json' } : {}),
    },
  });
}

async function readJson(env, file) {
  const r = await gh(env, `/contents/${file}?ref=main`);
  if (!r.ok) throw new Error(`${file}: HTTP ${r.status}`);
  return r.json();
}

export async function snsCron(env, now = Date.now()) {
  if (!env.GH_SNS_TOKEN) return 'no token';
  const [schedule, posted] = await Promise.all([readJson(env, 'sns/schedule.json'), readJson(env, 'sns/posted.json')]);
  const due = (schedule.posts || []).filter((e) => {
    if (!e.id || !e.post_at || posted[e.id]) return false;
    const t = Date.parse(e.post_at);
    return Number.isFinite(t) && t <= now && now - t <= STALE_MS;
  });
  if (!due.length) return 'nothing due';
  // すでに動いている・待っている回があれば、重ねて起こさない
  const runs = await gh(env, `/actions/workflows/${WORKFLOW}/runs?per_page=5`, { accept: 'application/vnd.github+json' });
  if (runs.ok) {
    const busy = ((await runs.json()).workflow_runs || []).some((r) => r.status === 'queued' || r.status === 'in_progress');
    if (busy) return `busy (${due.length} due)`;
  }
  const r = await gh(env, `/actions/workflows/${WORKFLOW}/dispatches`, {
    method: 'POST', accept: 'application/vnd.github+json',
    body: JSON.stringify({ ref: 'main', inputs: { dry_run: 'false', verify: 'false' } }),
  });
  if (!r.ok) throw new Error(`dispatch: HTTP ${r.status} ${(await r.text()).slice(0, 200)}`);
  return `dispatched (${due.map((e) => e.id).join(', ')})`;
}
```

### worker/index.js と wrangler.jsonc の変更（差分）

Worker の入口に、5分ごとに呼ばれる `scheduled` を足し、設定に5分ごとの Cron Triggers を足しました。

```diff
commit cecffc0a2973a268073f2a0f8d5405ab7b27c891
Author: Claude <noreply@anthropic.com>
Date:   Sun Oct 25 05:41:33 2026 +0900

    Worker：5分ごとに SNS の予約投稿の時刻を見て、時刻が来ていたら GitHub Actions を起こす
    
    GitHub の定期実行が何時間も遅れるため。鍵（GH_SNS_TOKEN）がなければ何もしない。
    
    Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01QsSduqU6ifMpxXFw3oEsGo

diff --git a/worker/index.js b/worker/index.js
index 61704f6..2230ef8 100644
--- a/worker/index.js
+++ b/worker/index.js
@@ -7,6 +7,7 @@
 // - /api/hk-stats        その記録を日ごと・更新ごとに数えて見せる（数だけ。誰かは分からない）
 // - /api/ev              サイトの使われ方（1回のページ表示ごとの、読んだ深さ・押したもの・迷い）を R2（p2m-messages の ev/）に残す
 // - /api/ev-stats        その記録を週ごと・ページごとに数えて見せる（数だけ。誰かは分からない）
+// - 5分ごとの scheduled  SNS の予約投稿の時刻が来ていたら GitHub Actions を起こす（worker/sns_cron.js）
 //
 // 守り（2026-10-23）
 // - 数の表（/api/hk-stats・/api/ev-stats）は、鍵（Cloudflare の秘密の値 STATS_KEY）を付けたときだけ見られる。鍵がなければ 404
@@ -15,6 +16,7 @@
 // - フォームには人には見えない欄（website）を置き、そこが埋まった送信は保存せずに受け取ったふりをする（自動投稿の対策）
 // - このファイルが返す応答には、内容の種類を推測させない指定（nosniff）を付ける。ページ側の指定は public/_headers
 import { EmailMessage } from 'cloudflare:email';
+import { snsCron } from './sns_cron.js';
 
 export default {
   async fetch(request, env) {
@@ -24,6 +26,10 @@ export default {
     }
     return env.ASSETS.fetch(request);
   },
+  // 5分ごと（wrangler.jsonc の triggers）：SNS の予約投稿の時刻が来ていたら、GitHub Actions を起こす（worker/sns_cron.js）
+  async scheduled(event, env, ctx) {
+    ctx.waitUntil(snsCron(env, event.scheduledTime).then((m) => console.log('sns-cron:', m), (e) => console.error('sns-cron:', e.message)));
+  },
 };
 
 async function route(request, env, url) {
diff --git a/wrangler.jsonc b/wrangler.jsonc
index a9510e7..c2b733b 100644
--- a/wrangler.jsonc
+++ b/wrangler.jsonc
@@ -22,6 +22,8 @@
   "observability": {
     "logs": { "enabled": true, "head_sampling_rate": 1, "invocation_logs": true, "persist": true }
   },
+  // 5分ごとに worker/index.js の scheduled を動かす（SNS の予約投稿を時刻どおりに起こす。worker/sns_cron.js）
+  "triggers": { "crons": ["*/5 * * * *"] },
   // 送信の口の流量制限（同じ送信元から60秒あたりの回数）。worker/index.js の guarded() が使う。
   // namespace_id はこのアカウントの中で他と重ならない番号なら何でもよい
   "ratelimits": [
```

### .github/workflows/sns-autopost.yml の変更（差分）

上の全文は、記録をまとめた時点（10月24日23時39分）のものです。そのあとの変更は2回です。1回目が push のときも出す保険、2回目が定期実行を3時間ごとに減らした変更です。

```diff
commit 694cfbda8602b74648dfafe13e1983b8261257d9
Author: Claude <noreply@anthropic.com>
Date:   Sat Oct 24 23:42:12 2026 +0900

    SNS自動投稿：push のときも時刻が来た投稿を出す（定期実行が遅れたときの保険）
    
    sns/tick.txt を変えて push すると、投稿の役目だけを動かせる。
    出したものは posted.json にあるので、何度動いても同じ投稿は出ない。
    
    Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01QsSduqU6ifMpxXFw3oEsGo

diff --git a/.github/workflows/sns-autopost.yml b/.github/workflows/sns-autopost.yml
index e6aff47..c1193ae 100644
--- a/.github/workflows/sns-autopost.yml
+++ b/.github/workflows/sns-autopost.yml
@@ -17,6 +17,7 @@ on:
     branches: [main]
     paths:
       - "sns/schedule.json"
+      - "sns/tick.txt"
       - "tools/sns_autopost.py"
 
 permissions:
@@ -51,8 +52,11 @@ jobs:
             git pull --rebase origin main && git push origin HEAD:main
           fi
 
+  # 定期実行に加えて、push のときも時刻が来た投稿を出す（定期実行が遅れたときの保険。
+  # 出したものは posted.json にあるので、何度動いても同じ投稿は出ない）。sns/tick.txt を変えて push すると、これだけを動かせる
   post:
-    if: github.event_name != 'push'
+    needs: [check]
+    if: always() && (github.event_name != 'push' || needs.check.result == 'success')
     runs-on: ubuntu-latest
     steps:
       - uses: actions/checkout@v4
commit 485ab229fd8fa4693460553d64132b7399d10db9
Author: Claude <noreply@anthropic.com>
Date:   Sun Oct 25 05:41:48 2026 +0900

    SNS自動投稿：起こすのは Cloudflare の Worker（5分ごと）に。GitHub の定期実行は3時間ごとの保険だけ
    
    Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01QsSduqU6ifMpxXFw3oEsGo

diff --git a/.github/workflows/sns-autopost.yml b/.github/workflows/sns-autopost.yml
index c1193ae..8a425d0 100644
--- a/.github/workflows/sns-autopost.yml
+++ b/.github/workflows/sns-autopost.yml
@@ -2,7 +2,9 @@ name: SNS自動投稿
 
 on:
   schedule:
-    - cron: "*/15 * * * *"   # 15分ごと（GitHub の都合で数分〜十数分遅れることがある）
+    # ふだんは Cloudflare の Worker（p2m-site の worker/sns_cron.js）が5分ごとに時刻を見て、出すものがあるときだけ起こす。
+    # GitHub の定期実行は混むと何時間も遅れ、15分ごとだと Actions の分数（非公開のリポジトリは月2,000分まで）を超えるので、3時間ごとの保険だけにした
+    - cron: "23 */3 * * *"
   workflow_dispatch:
     inputs:
       dry_run:
```

### ダミーの GitHub で試した：時刻の前・時刻のあと・出したあと・動いている回があるとき・鍵がないとき

本物の GitHub を起こす前に、GitHub の代わりに決まった答えを返すダミーで試しました。予定表は本物の `sns/schedule.json` を使い、時刻だけを変えています。

### crontest.mjs（試験の手順）

```javascript
import { snsCron } from '/home/claude/p2m-site/worker/sns_cron.js';
const sched = JSON.parse(await (await import('fs')).promises.readFile('/home/claude/r2r2r-content/sns/schedule.json','utf8'));
let calls=[];
function mock(posted, runs){ globalThis.fetch = async (url, init={}) => { calls.push([init.method||'GET', url.replace('https://api.github.com/repos/PixelstoMotors/r2r2r-content','')]);
  if (url.includes('schedule.json')) return new Response(JSON.stringify(sched));
  if (url.includes('posted.json')) return new Response(JSON.stringify(posted));
  if (url.includes('/runs')) return new Response(JSON.stringify({workflow_runs: runs}));
  if (url.includes('/dispatches')) return new Response(null,{status:204});
  return new Response('x',{status:404}); }; }
const env={GH_SNS_TOKEN:'t'};
const at = s => Date.parse(s);
mock({}, []); console.log('7:59', await snsCron(env, at('2026-10-25T07:59:00+09:00')));
calls=[]; mock({}, []); console.log('8:03', await snsCron(env, at('2026-10-25T08:03:00+09:00'))); console.log(calls.map(c=>c.join(' ')).join('\n'));
mock({'LOG375-x':{},'LOG375-linkedin':{}}, []); console.log('8:08 posted', await snsCron(env, at('2026-10-25T08:08:00+09:00')));
mock({}, [{status:'in_progress'}]); console.log('8:03 busy', await snsCron(env, at('2026-10-25T08:03:00+09:00')));
console.log('no token', await snsCron({}, Date.now()));
```

### crontest_out.txt（出力の全量）

```text
7:59 nothing due
8:03 dispatched (LOG375-linkedin, LOG375-x)
GET /contents/sns/schedule.json?ref=main
GET /contents/sns/posted.json?ref=main
GET /actions/workflows/sns-autopost.yml/runs?per_page=5
POST /actions/workflows/sns-autopost.yml/dispatches
8:08 posted nothing due
8:03 busy busy (2 due)
no token no token
```

7時59分は「出すものなし」、8時3分は LOG375 の X と LinkedIn の2本で Actions を起こしました。そのとき GitHub に送った問い合わせは、予定表・結果・動いている回・起動の4つです。出したあと（`posted.json` にある）は「出すものなし」、動いている回があるときは「重ねて起こさない」、鍵がないときは何もしない、になりました。

### 鍵：GitHub の fine-grained token を、Cloudflare の秘密の値に入れる

Worker が GitHub を読んで起こすには、GitHub の鍵が要ります。fine-grained token（使えるリポジトリと権限を細かく絞れる鍵）を作ってもらい、できることを最小にしました。

| 項目 | 設定 | なぜ |
|---|---|---|
| 名前 | p2m-sns-cron | どこで使う鍵か分かるように |
| 期限 | 2027年10月25日 | 1年。期限が来たら同じ権限で作り直す |
| リポジトリ | r2r2r-content だけ | ほかのリポジトリには触れない |
| Actions | 読み書き | 動いている回を見る・起こす |
| Contents | 読み取りだけ | 予定表と結果を読む（書き込みは Actions の側で行う） |
| Metadata | 読み取りだけ | 必ず付く |

【画像】LOG376_03_github_token.png GitHub の fine-grained token を作る画面。リポジトリは r2r2r-content だけ、権限は Actions が読み書き、Contents と Metadata が読み取りだけ

鍵は Cloudflare の `p2m-site` の Settings → Variables and Secrets に、`GH_SNS_TOKEN` という名前で入れました。種類は「Secret」にして、値を画面に出さないようにしています。Cloudflare の画面が新しくなっていて、Workers & Pages が左のメニューの Compute の中に入っていたので、探すのに少しかかりました。

【画像】LOG376_04_cloudflare_secret.png Cloudflare の Worker「p2m-site」に、秘密の値 GH_SNS_TOKEN を足す画面（値を入れる前）

鍵を入れる前の Worker のログです。5時50分の回で `*/5 * * * *`（5分ごとの起動）と `sns-cron: no token`（鍵がないので何もしない）が出ていて、5分ごとに動いていることが分かりました。

【画像】LOG376_05_worker_logs.png Cloudflare の Worker「p2m-site」のログ。5時50分の回に「*/5 * * * *」と「sns-cron: no token」が出ている

鍵を入れたあとの6時5分の回では `sns-cron: nothing due` に変わりました。鍵で GitHub の予定表と結果を読めていて、この時刻に出す投稿はない、という意味です。

【画像】LOG376_06_worker_nothing_due.png 鍵を入れたあとの Worker のログ。6時5分の回に「*/5 * * * *」と「sns-cron: nothing due」が出ている

### 8時の1本目：Worker が起こして、8時0分24秒に出た

10月25日8時0分の回で Worker が Actions を起こし（`workflow_dispatch`）、LOG375 の X と LinkedIn が8時0分24秒に出ました。`sns/posted.json` に残った結果です。

```json
"LOG375-x": {"status": "posted", "at": "2026-10-25T08:00:24+09:00", "platform": "x", "post_id": "2114129941537206305", "url": "https://x.com/i/web/status/2114129941537206305"}
"LOG375-linkedin": {"status": "posted", "at": "2026-10-25T08:00:24+09:00", "platform": "linkedin", "post_id": "urn:li:share:7519895619071766528", "url": "https://www.linkedin.com/feed/update/urn:li:share:7519895619071766528/", "comment_error": "LinkedIn コメント: HTTP 403 {\"status\":403,\"serviceErrorCode\":100,\"code\":\"ACCESS_DENIED\",\"message\":\"Not enough permissions to access: partnerApiSocialActions.CREATE.20260501\"}"}
```

本文はどちらも出ましたが、LinkedIn で2つ失敗していました。

- 表紙画像の送信が HTTP 400。Python の `urllib` は、中身の種類（Content-Type）を書かずにデータを送ると、フォームの形として送ります。画像として受け取ってもらえず、表紙なしのカードになりました。送るときに `image/jpeg` か `image/png` を付けるように直しました
- 補足のコメントが HTTP 403。LinkedIn の版つきの口（`rest/socialActions`）は、いまの権限（`w_member_social`）だけでは書き込めませんでした。断られたら版なしの口（`v2/socialActions`）で1回だけやり直すように直しました。それでも付かなければ、本文は出ているので Actions は赤くせず、コメントは手で付けます

ダミーのサーバーでの試験（上の `run_tests.sh`）は、直したあとも全部通りました。LOG375 のコメントは手で付けます。直した版は、12時30分の LOG374 で確かめました。12時30分19秒に X と LinkedIn が出て、ログに「表紙画像は付けずに」の行はなく、LinkedIn には表紙画像つきのカードで出ました。コメントも版なしの口で付きました。

```json
"LOG374-linkedin": {"status": "posted", "at": "2026-10-25T12:30:19+09:00", "platform": "linkedin", "post_id": "urn:li:share:7519963541307604992", "url": "https://www.linkedin.com/feed/update/urn:li:share:7519963541307604992/", "comment_id": "7519963544944197632"}
```

- ねらい：GitHub の混み方に左右されず、予約した時刻から5分以内に投稿する。Actions の分数を無料の枠に収める
- 変わったこと：起こし役が GitHub の定期実行（15分ごと・実際は2〜3時間おき）から、Cloudflare の Worker（5分ごと）に替わった。GitHub の定期実行は3時間ごとの保険だけにした
- 見込める効果：ダミーの GitHub での試験では、時刻の3分後の回で起こし、出したあとと動いている回があるときは起こさなかった。本物では、10月25日8時の LOG375 が8時0分24秒に出た。分数は月に約2,880分の見込みだったものが、約240分と投稿の回の分になる見込み


## 作業ログ ― 予約をカレンダーに並べ、本編の動画の X を足す

### 予約の日時を Google カレンダーに入れ、媒体で色を分けた

予約が X・LinkedIn・YouTube で50本を超えると、予定表（`sns/schedule.json`）や一覧（`schedule_preview.md`）を開かないと、いつ何が出るのか分からなくなります。そこで、ふだん見ている Google カレンダーに並べてほしいと頼みました。

> 投稿タイミングをカレンダーに追加してほしいけどできる？

> youtubeもね

> ラベル色分けして　X黒系　linkdeinブルー系　Yutube赤系

Claude は、Google カレンダーにつないだ道具で、予定表と YouTube の公開予約（Mac が1時間ごとに書き出す `youtube_videos.json`）を読み、1本ずつ予定にしました。予定は15分の長さで、「空き時間」として入れているので、ほかの予定とぶつかりません。通知は付けていません。予定の説明には、題名・URL・X の本文（LinkedIn は本文の冒頭）を入れています。

| 色 | 中身 | 件数 |
|---|---|---|
| 黒（グラファイト） | X の投稿（記事・ショート・本編） | 36 |
| 青（ブルーベリー） | LinkedIn の記事の投稿 | 15 |
| 青（ブルーベリー） | 「LinkedIn 動画を上げる（手で）」 | 21 |
| 赤（トマト） | YouTube の公開 | 21 |

最初は、同じ記事の X と LinkedIn を「（X・LinkedIn）」の1件にまとめていました。色を媒体で分けることになったので、X と LinkedIn の2件に分け直しています。

動画は LinkedIn に直接アップロードする決まり（10月14日に決めたもの）なので、自動では出ません。そこで、YouTube の公開と同じ時刻に「LinkedIn 動画を上げる（手で）」も入れました。

> 「LinkedIn に動画を上げる」を、YouTube の公開と同じ時刻にカレンダーへ入れておく

- ねらい：いつ・どこに・何が出るかを、ふだんのカレンダーでひと目で分かるようにする。手で出す LinkedIn の動画を忘れないようにする
- 変わったこと：予定表と YouTube の公開予約が、色分けした93件の予定としてカレンダーに並んだ
- 見込める効果：同じ時刻に投稿が重なっていないか、手で出す作業がどの日に来るかを、ファイルを開かずに確かめられる見込み。ただしカレンダーは予定表から自動ではそろわないので、予約を足したり時刻を変えたりしたら、カレンダーとずれる。そこで、毎日そろえる予定のタスクを作った（下の節）

### 本編の動画10本の X の文を作り、公開の時刻で予約した

カレンダーを作っている途中で、YouTube の本編10本だけ、X の予約が予定表に入っていないと分かりました。ショートは `sns/SHORT_ep{N}_sns_posts.json` に文があって自動で予約されますが、本編は動画ごとの文（`sns/VIDEO_{名前}_sns_posts.json`）がまだ1本分しかなかったためです。

> 本編の動画10本は、X の投稿がまだ予約に入っていません。動画ごとの X の文（`VIDEO_…_sns_posts.json`）がまだないため　作成

10本それぞれの作業フォルダにある `投稿文_X_LinkedIn.txt` の X の文をもとに、記事の決まり（`article_rules.md` の「SNS の文章の質の決まり」）に合わせて書き直しました。1行目で手が止まる一文から入る、小さな感想を入れる、タグは自分の名前（#R2R2R #PixelsToMotors）ではなく話題のタグを2つ、です。字数はどれも X の数え方で280以内（URL は23として数える）で、いちばん長いものが279でした。

作った文の1本です（`sns/VIDEO_robot-deployment-cost_sns_posts.json`。`url` と `posts.x` は、予約を入れるときに自動で入ったもの）。

```json
{
  "meta": {
    "kind": "youtube_video",
    "title": "導入はいくら",
    "youtube_title": "ロボット導入、本当はいくらかかる？｜本体300万円、総額980万円の内訳",
    "title_match": [
      "ロボット導入",
      "980万円"
    ],
    "seconds": 781,
    "url": "https://youtu.be/KxopHCbMYFY",
    "youtube_id": "KxopHCbMYFY"
  },
  "posts": {
    "x_template": "ロボット本体300万円。使える形にしたら総額980万円。\n\n業界団体の小冊子の想定例だけど、いちばん大きいのは組み上げの仕事で半分以上。見積もりの外側の費用を並べてみた。\n自分も研究の機材は、買う前に借りて測ってるよ。\n#ロボット導入 #製造業\n{url}",
    "x": "ロボット本体300万円。使える形にしたら総額980万円。\n\n業界団体の小冊子の想定例だけど、いちばん大きいのは組み上げの仕事で半分以上。見積もりの外側の費用を並べてみた。\n自分も研究の機材は、買う前に借りて測ってるよ。\n#ロボット導入 #製造業\nhttps://youtu.be/KxopHCbMYFY"
  },
  "note": "YouTube の本編の X 投稿。url と posts.x は tools/sync_youtube_shorts.py が YouTube の公開予約を見て入れる。LinkedIn は動画を直接アップロードする決まりなので入れない（文は iCloud の 動画/導入はいくら/投稿文_X_LinkedIn.txt）"
}
```

予定表への入れ方は、ショートと同じ `tools/sync_youtube_shorts.py` です。YouTube の題名で文と動画を結びつけ、YouTube の公開の時刻で入れます。同じ時刻に X の予約があれば、19:30 なら 20:00、それ以外は30分ずつ後ろへずらします。先に試し（`--dry-run`）で動かした出力です。

```text
$ python3 tools/sync_youtube_shorts.py --videos youtube_videos.json --dry-run
足した VIDEO_ai-reviews-ai-x 2026-11-01T19:30:00+09:00 AIの間違いは、誰が見つけるのか ― 別のAIに疑わせたら、
足した VIDEO_ghost-cyborg-body-x 2026-11-12T19:15:00+09:00 攻殻機動隊の義体は、いまの技術でどこまで作れるのか ― 体は
足した VIDEO_ghost-remote-body-x 2026-11-10T19:00:00+09:00 攻殻機動隊を思わせる遠隔ボディは、もう作れるのか ― 中身に
足した VIDEO_robot-accident-liability-x 2026-10-29T20:30:00+09:00 ロボットが事故を起こしたら、誰の責任になるのか ― 実際の事
足した VIDEO_robot-deployment-cost-x 2026-10-28T20:30:00+09:00 ロボット導入、本当はいくらかかる？｜本体300万円、総額98
足した VIDEO_robot-motion-fear-x 2026-10-26T18:30:00+09:00 ロボットの動きはなぜ怖い？｜気味が悪いのか、危ないのか
足した VIDEO_solo-ai-team-x 2026-10-31T19:30:00+09:00 ひとりとAIで、研究と発信を続けるには ― AIに任せる仕事
足した VIDEO_votoms-at-x 2026-11-02T19:15:00+09:00 ボトムズのATは、いまの技術で作れるのか ― 乗れる4メートル
足した VIDEO_washing-machine-robot-x 2026-10-27T20:30:00+09:00 洗濯機を分解して組み立て直すロボットは作れるか｜ブルーカラー
足した VIDEO_world-model-sim-to-real-x 2026-10-25T18:30:00+09:00 仮想では届くのに、本物では届かない ― 「3Dの世界を作るA
```

10月27日〜29日の3本は、19:30 に記事の X、20:00 にショートの X がすでに入っていたので、20:30 になりました。そのあと本番で動かし、予定表の確かめ（`sns_autopost.py check`）が「問題ありません」になったので push しました。予定表は全部で51本、そのうち本編が10本です。

- ねらい：YouTube の本編も、ショートと同じように公開の時刻に X に出す
- 変わったこと：本編10本の X の文ができ、公開の時刻（10月25日〜11月12日）で予約に入った
- 見込める効果：本編の公開を手で X に知らせる作業がなくなる。動画への流れが増えるかは、YouTube の流入元で X からの再生を見て確かめる


### カレンダーを、毎日18時10分に予定表とそろえる

カレンダーの予定は、そのときの予定表を見て1件ずつ写したものです。あとで予約を足したり、YouTube で公開の時刻を変えたりすると、予定表は自動で直りますが、カレンダーは古いままになります。そろえ方を2つ出しました。予約を入れるたびに Claude がカレンダーも直すか、1日1回、予定表を見てそろえる予定のタスクを作るか。

> 1日1回、予定表を見てカレンダーをそろえる予定のタスクを作る（おすすめ）　18:10）

18時10分にしたのは、YouTube の公開予約を予定表に入れる同期（17時47分）のすぐあとにするためです。

仕組みは2つに分けました。あるべき予定の一覧は、リポジトリの中のスクリプト（`tools/calendar_plan.py`）が出します。カレンダーへの書き込みは、予定のタスクの Claude が Google カレンダーの道具で行います。予定ごとに、説明の最後の行に `p2m-key: x:LOG375-x` のような印を入れ、その印で一覧と見比べて、足りない予定を足し、時刻のずれを直し、なくなった予定を消します。消すのは、まだ来ていない予定で、印のあるものだけです。

YouTube の公開の時刻は、これまで Mac の `youtube_videos.json` にしかなく、クラウドで動く予定のタスクからは読めませんでした。そこで、同期のスクリプトが、ショートと本編の文のファイルに公開の時刻（`meta.publish_at`）も書き残すようにしました。

### tools/calendar_plan.py（全文）

```python
"""予約投稿と YouTube の公開を、Google カレンダーに並べるための「あるべき予定の一覧」を出す（2026-10-25）。

カレンダーへの書き込みはしない。予定のタスク「SNS予約をカレンダーにそろえる」（毎日 18:10）の Claude が、
この一覧と Google カレンダーの予定を見比べて、足りない予定を足し・時刻のずれを直し・なくなった予定を消す。

使い方: python3 tools/calendar_plan.py [--days 60]   → 標準出力に JSON（予定の配列）

読むもの
  sns/schedule.json                 予定表（まだ出していない・時刻がまだ来ていない X / LinkedIn）
  sns/posted.json                   出したもの（ここにある id は並べない）
  sns/SHORT_ep*_sns_posts.json      ショートの YouTube の公開の時刻（meta.publish_at。tools/sync_youtube_shorts.py が入れる）
  sns/VIDEO_*_sns_posts.json        本編の YouTube の公開の時刻（同上）

予定の色（Google カレンダーの colorId。2026-10-25 エイイチの指示「X黒系・LinkedInブルー系・YouTube赤系」）
  X = 8（グラファイト） / LinkedIn = 9（ブルーベリー） / YouTube = 11（トマト）

予定の見分け方（key）。予定の説明の最後の行に「p2m-key: {key}」を入れる
  x:{id} / linkedin:{id}            予定表の1行（id は schedule.json の id）
  yt:{youtube_id}                   YouTube の公開
  li-video:{youtube_id}             LinkedIn に動画を手で上げる（動画は LinkedIn に直接アップロードする決まりのため）
"""
import argparse, glob, json, os, re
from datetime import datetime, timedelta, timezone

ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
JST = timezone(timedelta(hours=9))
COLOR = {"x": "8", "linkedin": "9", "yt": "11", "li-video": "9"}


def load(p, d):
    try:
        return json.load(open(os.path.join(ROOT, p), encoding="utf-8"))
    except FileNotFoundError:
        return d


def short_title(t):
    """YouTube の題名の「 ― 」「｜」より前（カレンダーの予定の名前に使う）"""
    return re.split(r"\s*[―｜|]\s*", t or "")[0].strip()


def label(src, path):
    m = src.get("meta", {})
    base = os.path.basename(path)
    if base.startswith("SHORT_ep"):
        return f"ショート 第{m.get('episode')}話", "short"
    if base.startswith("VIDEO_"):
        return f"本編「{short_title(m.get('youtube_title') or m.get('title'))}」", "video"
    name = re.sub(r"(_linkedin_ja)?_sns_posts\.json$|_linkedin_ja\.json$", "", base)
    return name, "article"


def ev(key, summary, start, desc):
    s = start.astimezone(JST)
    return {"key": key, "summary": summary, "colorId": COLOR[key.split(":")[0]],
            "startTime": s.strftime("%Y-%m-%dT%H:%M:%S"), "endTime": (s + timedelta(minutes=15)).strftime("%Y-%m-%dT%H:%M:%S"),
            "description": desc.rstrip() + f"\n\np2m-key: {key}"}


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--days", type=int, default=60)
    a = ap.parse_args()
    now = datetime.now(timezone.utc)
    until = now + timedelta(days=a.days)
    sched = load("sns/schedule.json", {"posts": []})
    posted = load("sns/posted.json", {})
    out = []
    for e in sched.get("posts", []):
        t = datetime.fromisoformat(e["post_at"])
        if e["id"] in posted or not (now < t <= until):
            continue
        src = load(e["source"], {})
        m, p = src.get("meta", {}), src.get("posts", {})
        name, _ = label(src, e["source"])
        en = e.get("lang") == "en"
        if e["platform"] == "x":
            body = p.get("x_en" if en else "x", "")
            desc = f"{m.get('title_en' if en else 'title', '') or m.get('youtube_title', '')}\n{m.get('url_en' if en else 'url', '')}\n\nX の本文:\n{body}"
            out.append(ev(f"x:{e['id']}", f"SNS予約 {name}（X{'・英語' if en else ''}）", t, desc))
        else:
            body = p.get("linkedin_en" if en else "linkedin", "")
            head = body[:120] + ("…" if len(body) > 120 else "")
            desc = f"{m.get('title_en' if en else 'title', '')}\n{m.get('url_en' if en else 'url', '')}\n\nLinkedIn の本文（冒頭）:\n{head}"
            out.append(ev(f"linkedin:{e['id']}", f"SNS予約 {name}（LinkedIn{'・英語' if en else ''}）", t, desc))
    paths = sorted(glob.glob(os.path.join(ROOT, "sns", "SHORT_ep*_sns_posts.json"))) + \
        sorted(glob.glob(os.path.join(ROOT, "sns", "VIDEO_*_sns_posts.json")))
    for path in paths:
        src = json.load(open(path, encoding="utf-8"))
        m = src.get("meta", {})
        if not m.get("publish_at") or not m.get("youtube_id"):
            continue
        t = datetime.fromisoformat(m["publish_at"])
        if not (now < t <= until):
            continue
        name, kind = label(src, path)
        yid = m["youtube_id"]
        watch = f"https://www.youtube.com/watch?v={yid}"
        title = m.get("youtube_title") or m.get("title", "")
        out.append(ev(f"yt:{yid}", f"YouTube公開 {name}", t, f"{title}\n{watch}"))
        note = "YouTube の公開と同じ時刻。LinkedIn には動画を直接アップロードする決まり（自動では出ない）。"
        if kind == "video":
            note += f"文は iCloud の 動画/{m.get('title', '')}/投稿文_X_LinkedIn.txt"
        out.append(ev(f"li-video:{yid}", f"LinkedIn 動画を上げる（手で）{name}", t, f"{note}\nYouTube: {watch}"))
    out.sort(key=lambda x: (x["startTime"], x["key"]))
    print(json.dumps(out, ensure_ascii=False, indent=1))


if __name__ == "__main__":
    main()
```

### tools/sync_youtube_shorts.py の変更（差分）

```diff
commit 5aaccf09425fdbadfcf86966ec115c8a8159ebb4
Author: Claude <noreply@anthropic.com>
Date:   Sun Oct 25 07:22:47 2026 +0900

    カレンダーをそろえる：予定の一覧を出す calendar_plan.py・YouTube の公開の時刻を meta.publish_at に残す
    
    Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01QsSduqU6ifMpxXFw3oEsGo

diff --git a/tools/sync_youtube_shorts.py b/tools/sync_youtube_shorts.py
index 469e708..ae536f9 100644
--- a/tools/sync_youtube_shorts.py
+++ b/tools/sync_youtube_shorts.py
@@ -82,8 +82,9 @@ def main():
             continue
         url = f"https://youtu.be/{v['id']}" if is_main else f"https://youtube.com/shorts/{v['id']}"
         text = posts["x_template"].replace("{url}", url)
-        if posts.get("x") != text or meta.get("url") != url or meta.get("youtube_id") != v["id"]:
-            posts["x"], meta["url"], meta["youtube_id"] = text, url, v["id"]
+        pub = when.astimezone(JST).isoformat()  # YouTube の公開の時刻（カレンダーをそろえる予定のタスクが読む。2026-10-25）
+        if posts.get("x") != text or meta.get("url") != url or meta.get("youtube_id") != v["id"] or meta.get("publish_at") != pub:
+            posts["x"], meta["url"], meta["youtube_id"], meta["publish_at"] = text, url, v["id"], pub
             if not a.dry_run:
                 with open(path, "w", encoding="utf-8") as f:
                     json.dump(src, f, ensure_ascii=False, indent=2); f.write("\n")
```

試しに動かすと、一覧は93件でした（X 36・LinkedIn 15・YouTube 21・LinkedIn に動画を上げる 21）。手で入れた予定と同じ数です。

- ねらい：予約を誰がどう変えても、カレンダーが1日以内に予定表と同じになる
- 変わったこと：毎日18時10分に、予定のタスク「SNS予約をカレンダーにそろえる」が動く。リポジトリには書き込まず、カレンダーだけを直す
- 見込める効果：カレンダーを手で直す作業がなくなる見込み。1回目は10月25日18時10分で、手で入れた93件に印が付くはず。その結果を確かめる


## 作業ログ ― 残っていること

- Cloudflare の Worker から起こす形で、10月25日8時の LOG375（X・LinkedIn）が時刻どおりに出るかを確かめる。出なければ `sns/tick.txt` を push して出す
- GitHub の鍵（p2m-sns-cron）を2027年10月25日までに作り直す
- 段階Bは18回そろった。区切り（Unity の版の固定・基準データ v1）と、自動の判定の線を決める作業が次
- Unity の台帳の pause_rate と stuck_rate の列を埋める直しは、区切りのあとに出す
- 「LinkedIn 動画を上げる」に通知を付けるかを決める
- 10月25日18時10分の「SNS予約をカレンダーにそろえる」の1回目で、既存の93件に p2m-key の印が付き、予定が増えたり消えたりしていないかを確かめる
- 使っていない OAuth 2.0 のクライアントシークレットを作り直す
- LinkedIn のトークンを12月23日ごろまでに作り直す（1週間前に知らせが来る）

予約した22本の日時と本文は、GitHub の `PixelstoMotors/r2r2r-content` の `sns/schedule_preview.md` で見られます（このリポジトリは公開していません）。
