#!/usr/bin/env python3 """sync_youtube_shorts.py — YouTube で公開予約したショートの X 投稿を、公開の時刻に合わせて予定表に入れる。 元になるもの: sns/SHORT_ep{N}_sns_posts.json ショートの X の文(posts.x_template。{url} がショートの URL に替わる) youtube_videos.json エイイチの Mac が1時間ごとに書き出す自分のチャンネルの一覧 (_制作ベース/youtube_schedule.py。説明文の manga/ep{N} から manga_ep を拾う) やること: 1. manga_ep(なければタイトル)で、ショートの json と YouTube の動画を結びつける 2. 公開予約(または公開済み)の時刻を X の予約の時刻にする。同じ時刻に別の X の予約があれば、 19:30 なら 20:00、それ以外は30分ずつ後ろへずらす(2026-10-24 エイイチの指示「19:30が重なったら動画は20:00」) 3. まだ投稿していない予約は、YouTube 側で時刻を変えていたら合わせて直す 4. 公開の時刻を過ぎていて、まだ予定表にないものは入れない(古いものを今さら出さない) LinkedIn には入れない(動画は LinkedIn に直接アップロードする決まり。video_rules.md の 0節の22)。 使い方: python3 tools/sync_youtube_shorts.py --videos youtube_videos.json [--dry-run] """ import argparse, glob, json, os, re, sys from datetime import datetime, timedelta, timezone ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) JST = timezone(timedelta(hours=9)) SCHEDULE = os.path.join(ROOT, "sns", "schedule.json") POSTED = os.path.join(ROOT, "sns", "posted.json") def norm(t): return re.sub(r"\s+", "", re.split(r"\s#", t or "")[0]) def load(p, d): try: return json.load(open(p, encoding="utf-8")) except FileNotFoundError: return d def main(): ap = argparse.ArgumentParser() 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")] 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"))): 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"))] 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']}" 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"] 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") # 同じ時刻の X の予約を避ける taken = {e["post_at"] for e in sched["posts"] if e["platform"] == "x" and e["id"] != sid} t = when.astimezone(JST) while t.isoformat() in taken: t = t.replace(hour=20, minute=0) if (t.hour, t.minute) == (19, 30) else t + timedelta(minutes=30) post_at = t.isoformat() if entry is None: sched["posts"].append({"id": sid, "source": os.path.relpath(path, ROOT), "platform": "x", "post_at": post_at}) changes.append(f"足した {sid} {post_at} {v['title'][:30]}") elif entry["post_at"] != post_at: changes.append(f"時刻を直した {sid} {entry['post_at']} → {post_at}") entry["post_at"] = post_at sched["posts"].sort(key=lambda e: (e["post_at"], e["id"])) if changes and not a.dry_run: with open(SCHEDULE, "w", encoding="utf-8") as f: json.dump(sched, f, ensure_ascii=False, indent=2); f.write("\n") print("\n".join(changes) or "変わったものはありません") return 0 if __name__ == "__main__": sys.exit(main())