commit c13916168e50c83107f094cbd2ba6243c62b846c
Author: Claude <noreply@anthropic.com>
Date:   Sat Oct 24 23:57:52 2026 +0900

    SNS予約：YouTube の本編の X 投稿も、公開予約に合わせて予定表に入れる
    
    - tools/sync_youtube_shorts.py が sns/VIDEO_*_sns_posts.json も読む（タイトル、なければ語と長さで結びつけ、URL は youtu.be）
    - 1本目：何台入れたら店は変わる（配膳ロボット導入シミュレーション 第1回）
    - 試し：いまの youtube_videos.json では変わるものなし（前と同じ）。タイトルを直して上げた想定の偽の行では、19:30・20:00 の重なりを避けて 20:30 に入り、check も通った
    
    Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01CF5RbAfS7xaH2fgqEUe7xG

diff --git a/docs/sns_autopost.md b/docs/sns_autopost.md
index dc9ef32..b7f70a9 100644
--- a/docs/sns_autopost.md
+++ b/docs/sns_autopost.md
@@ -64,9 +64,25 @@ Facebook は個人アカウントへの API 投稿ができないので、これ
 - 同じ時刻に X の予約があれば、19:30 なら 20:00、それ以外は30分ずつ後ろへずらす
 - YouTube で公開の時刻を変えたら、まだ投稿していない予約の時刻も次の同期で直る。公開の時刻を過ぎてから見つけたものは入れない
 - LinkedIn には入れない（動画は LinkedIn に直接アップロードする決まりのため、手で出す）
-- 同期は予定のタスク「ショートのX予約をYouTubeに合わせる」が1日2回（7:47・17:47）動かす。Mac が眠っていると、その回は何もしない
+- 同期は予定のタスク「ショートと本編の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{url}\n#R2R2R #PixelsToMotors", "x": "（自動）"}}
+```
+
+- 文は動画を作った会話で、投稿文_X_LinkedIn.txt の X の文から作って置く（`{url}` が `https://youtu.be/{id}` に替わる。URL込み140字）。ファイル名の `{名前}` は英数字とハイフン
+- 動画との結びつけ: YouTube のタイトルが `youtube_title` と同じもの。なければ、`title_match` の語をすべて含み、長さ（秒）が `seconds` と3秒以内のもの（上げるときにタイトルを直しても結びつく）
+- 時刻の決まり（19:30 が重なったら 20:00、ほかは30分ずつ後ろへ）・公開の時刻を過ぎてから見つけたものは入れない・時刻を変えたら次の同期で直る、はショートと同じ
+- LinkedIn には入れない（動画は LinkedIn に直接アップロードする決まり）
+
 ## 結果（sns/posted.json）
 
 | status | 意味 | どうするか |
diff --git a/tools/sync_youtube_shorts.py b/tools/sync_youtube_shorts.py
index 558c08c..b6fcf83 100644
--- a/tools/sync_youtube_shorts.py
+++ b/tools/sync_youtube_shorts.py
@@ -1,13 +1,17 @@
 #!/usr/bin/env python3
-"""sync_youtube_shorts.py — YouTube で公開予約したショートの X 投稿を、公開の時刻に合わせて予定表に入れる。
+"""sync_youtube_shorts.py — YouTube で公開予約したショートと本編の X 投稿を、公開の時刻に合わせて予定表に入れる。
 
 元になるもの:
   sns/SHORT_ep{N}_sns_posts.json   ショートの X の文（posts.x_template。{url} がショートの URL に替わる）
+  sns/VIDEO_{名前}_sns_posts.json  本編の X の文（2026-10-24 から。{url} が https://youtu.be/{id} に替わる）。
+                                   動画との結びつけは、タイトルが meta.youtube_title と同じもの、
+                                   なければ meta.title_match の語をすべて含み、長さが meta.seconds と3秒以内のもの
+                                   （YouTube に上げるときにタイトルを直しても結びつくように）
   youtube_videos.json              エイイチの Mac が1時間ごとに書き出す自分のチャンネルの一覧
                                    （_制作ベース/youtube_schedule.py。説明文の manga/ep{N} から manga_ep を拾う）
 
 やること:
-  1. manga_ep（なければタイトル）で、ショートの json と YouTube の動画を結びつける
+  1. ショートは manga_ep（なければタイトル）、本編はタイトル（なければ語と長さ）で、json と YouTube の動画を結びつける
   2. 公開予約（または公開済み）の時刻を X の予約の時刻にする。同じ時刻に別の X の予約があれば、
      19:30 なら 20:00、それ以外は30分ずつ後ろへずらす（2026-10-24 エイイチの指示「19:30が重なったら動画は20:00」）
   3. まだ投稿していない予約は、YouTube 側で時刻を変えていたら合わせて直す
@@ -41,29 +45,41 @@ def main():
     ap.add_argument("--videos", required=True)
     ap.add_argument("--dry-run", action="store_true")
     a = ap.parse_args()
-    vids = [v for v in load(a.videos, {}).get("動画", []) if v.get("is_short")]
+    allv = load(a.videos, {}).get("動画", [])
+    vids = [v for v in allv if v.get("is_short")]
+    mains = [v for v in allv if not v.get("is_short")]
     sched = load(SCHEDULE, {"posts": []})
     posted = load(POSTED, {})
     done_ids = set(posted.keys()) if isinstance(posted, dict) else {p.get("id") for p in posted.get("posts", posted) if isinstance(p, dict)}
     now = datetime.now(timezone.utc)
     changes = []
-    for path in sorted(glob.glob(os.path.join(ROOT, "sns", "SHORT_ep*_sns_posts.json"))):
+    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"))
         meta, posts = src["meta"], src["posts"]
-        ep = meta["episode"]
-        cand = [v for v in vids if v.get("manga_ep") == ep] or [v for v in vids if norm(v["title"]) == norm(meta.get("youtube_title"))]
+        is_main = os.path.basename(path).startswith("VIDEO_")
+        if is_main:
+            words = meta.get("title_match") or []
+            cand = [v for v in mains if norm(v["title"]) == norm(meta.get("youtube_title"))] or \
+                [v for v in mains if words and all(w in v["title"] for w in words)
+                 and meta.get("seconds") and v.get("seconds") and abs(v["seconds"] - meta["seconds"]) <= 3]
+            sid = os.path.basename(path)[:-len("_sns_posts.json")] + "-x"
+        else:
+            ep = meta["episode"]
+            cand = [v for v in vids if v.get("manga_ep") == ep] or [v for v in vids if norm(v["title"]) == norm(meta.get("youtube_title"))]
+            sid = f"SHORT_ep{ep}-x"
         cand = [v for v in cand if v.get("publishAt") or v.get("published")]
         if not cand:
             continue
         v = sorted(cand, key=lambda v: v.get("publishAt") or v.get("published"))[-1]
         when = datetime.fromisoformat((v.get("publishAt") or v.get("published")).replace("Z", "+00:00"))
-        sid = f"SHORT_ep{ep}-x"
         entry = next((e for e in sched["posts"] if e["id"] == sid), None)
         if entry and sid in done_ids:
             continue
         if not entry and when < now:
             continue
-        url = f"https://youtube.com/shorts/{v['id']}"
+        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"]
