diff --git a/tools/calendar_plan.py b/tools/calendar_plan.py index 66b6e2a..d79753a 100644 --- a/tools/calendar_plan.py +++ b/tools/calendar_plan.py @@ -17,7 +17,7 @@ 予定の見分け方(key)。予定の説明の最後の行に「p2m-key: {key}」を入れる x:{id} / linkedin:{id} 予定表の1行(id は schedule.json の id) yt:{youtube_id} YouTube の公開 - li-video:{youtube_id} LinkedIn に動画を手で上げる(動画は LinkedIn に直接アップロードする決まりのため) + li-video:{youtube_id} (2026-10-30 まで)LinkedIn に動画を手で上げる。今は本編も自動のリンク投稿なので作らない """ import argparse, glob, json, os, re from datetime import datetime, timedelta, timezone @@ -98,10 +98,7 @@ def main(): 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}")) + # 本編の LinkedIn は 2026-10-30 から YouTube のリンクのカードで自動(予定表の linkedin:{id} で出る)。手で上げる予定は入れない out.sort(key=lambda x: (x["startTime"], x["key"])) print(json.dumps(out, ensure_ascii=False, indent=1)) diff --git a/tools/sns_autopost.py b/tools/sns_autopost.py index 65ac978..8d31b59 100644 --- a/tools/sns_autopost.py +++ b/tools/sns_autopost.py @@ -94,7 +94,9 @@ def texts_for(entry, src): "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", ""), + # YouTube の本編(2026-10-30 から LinkedIn にもリンクで出す)はカードの題を YouTube のタイトルにする + "title": (meta.get("youtube_title") if meta.get("kind") == "youtube_video" else None) + or meta.get("title" + sfx) or meta.get("title", ""), "description": meta.get("meta_description" + sfx) or meta.get("meta_description", ""), } @@ -258,7 +260,17 @@ def og_image(page_url): def li_thumbnail(token, person, page_url): """記事の表紙画像を LinkedIn に上げて image の URN を返す。失敗したら None(表紙なしで投稿する)。""" try: - img = og_image(page_url) + m = re.search(r"(?:youtu\.be/|youtube\.com/(?:watch\?v=|shorts/))([\w-]{6,})", page_url) + if m: # YouTube の動画は、ページを読まずにサムネイルを直接取る(同意の画面などで og:image が取れないことがあるため) + img = f"https://i.ytimg.com/vi/{m.group(1)}/hqdefault.jpg" + for size in ("maxresdefault", "hqdefault"): + try: + http("GET", f"https://i.ytimg.com/vi/{m.group(1)}/{size}.jpg", where="YouTube のサムネイル") + img = f"https://i.ytimg.com/vi/{m.group(1)}/{size}.jpg"; break + except Exception: + continue + else: + img = og_image(page_url) if not img: return None _, _, data = http("GET", img, where="表紙画像") diff --git a/tools/sync_youtube_shorts.py b/tools/sync_youtube_shorts.py index ae536f9..79d98aa 100644 --- a/tools/sync_youtube_shorts.py +++ b/tools/sync_youtube_shorts.py @@ -17,7 +17,9 @@ 12:30 の枠(2026-10-25 に足した)は「それ以外」に入り、13:00 になる 3. まだ投稿していない予約は、YouTube 側で時刻を変えていたら合わせて直す 4. 公開の時刻を過ぎていて、まだ予定表にないものは入れない(古いものを今さら出さない) -LinkedIn には入れない(動画は LinkedIn に直接アップロードする決まり。video_rules.md の 0節の22)。 +本編は LinkedIn にも入れる(2026-10-30 エイイチの指示「LinkedIn は YouTube の投稿に統一」)。posts.linkedin_template があれば、 +YouTube の公開の時刻に LinkedIn の予約(id は VIDEO_{名前}-linkedin)を入れ、{url} を動画の URL にして posts.linkedin と +posts.linkedin_comment を作る。LinkedIn では動画の URL のリンクのカード(YouTube のサムネイルつき)で出る。ショートは LinkedIn に出さない。 使い方: python3 tools/sync_youtube_shorts.py --videos youtube_videos.json [--dry-run] """ @@ -100,6 +102,31 @@ def main(): elif entry["post_at"] != post_at: changes.append(f"時刻を直した {sid} {entry['post_at']} → {post_at}") entry["post_at"] = post_at + # 本編の LinkedIn(2026-10-30 から): YouTube の公開と同じ時刻。同じ時刻の LinkedIn の予約があれば30分ずつ後ろへ + if is_main and posts.get("linkedin_template"): + lid = sid[:-len("-x")] + "-linkedin" + lentry = next((e for e in sched["posts"] if e["id"] == lid), None) + if not (lentry and lid in done_ids) and (lentry or when >= now): + ltext = posts["linkedin_template"].replace("{url}", url) + lcom = posts.get("linkedin_comment_template", "").replace("{url}", url) + if posts.get("linkedin") != ltext or posts.get("linkedin_comment", "") != lcom: + posts["linkedin"] = ltext + if lcom: + posts["linkedin_comment"] = lcom + 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") + ltaken = {e["post_at"] for e in sched["posts"] if e["platform"] == "linkedin" and e["id"] != lid} + lt = when.astimezone(JST) + while lt.isoformat() in ltaken: + lt = lt + timedelta(minutes=30) + lpost = lt.isoformat() + if lentry is None: + sched["posts"].append({"id": lid, "source": os.path.relpath(path, ROOT), "platform": "linkedin", "post_at": lpost}) + changes.append(f"足した {lid} {lpost} {v['title'][:30]}") + elif lentry["post_at"] != lpost: + changes.append(f"時刻を直した {lid} {lentry['post_at']} → {lpost}") + lentry["post_at"] = lpost 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: