"""予約投稿と 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()