commit e6dc8d1290833157be50a881502ef5fe1974573a
Author: Claude <noreply@anthropic.com>
Date:   Mon Nov 2 18:47:16 2026 +0900

    SNS予約：ショート AIあるある 01〜04 を X に動画で予約（11/3〜6 22:00）・X の投稿に動画を付けられるようにした
    
    - tools/sns_autopost.py: posts.x_video（動画1本）を v2 の分けて送る形で上げてから投稿する
    - docs/sns_autopost.md: X に動画を付ける節を追加
    
    Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01LevEXUphAJgmqJRkW8ek9S

diff --git a/tools/sns_autopost.py b/tools/sns_autopost.py
index a5c11ea..a2933c9 100644
--- a/tools/sns_autopost.py
+++ b/tools/sns_autopost.py
@@ -104,7 +104,9 @@ def texts_for(entry, src):
             # リンクを外す（2026-10-31 から。リンク入りの投稿は1本0.20ドル、リンクなしは0.015ドル）
             lines = [URL_RE.sub("", l).rstrip() for l in text.split("\n") if URL_RE.sub("", l).strip() or not URL_RE.search(l)]
             text = re.sub(r"\n{3,}", "\n\n", "\n".join(lines)).strip()
+        vid = posts.get("x" + sfx + "_video") or ""   # 動画（1本）。画像と一緒には付けられない
         return {"text": text,
+                "video": (vid if vid.startswith("http") else base + vid) if vid else "",
                 "images": [u if u.startswith("http") else base + u for u in imgs],
                 "alts": posts.get("x" + sfx + "_alts") or [],
                 "reply": (posts.get("x" + sfx + "_reply") or "").strip()}
@@ -145,6 +147,8 @@ def validate(entry, src):
         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}）")
+        if t.get("video") and t["images"]:
+            errs.append("X は動画と画像を一緒に付けられません（x_video か x_images のどちらか）")
         if len(t["images"]) > 4:
             errs.append(f"X の画像は4枚までです（{len(t['images'])}枚）")
         if t["alts"] and len(t["alts"]) != len(t["images"]):
@@ -216,7 +220,8 @@ def http(method, url, headers=None, body=None, where=""):
 
 # ---------- X ----------
 
-def oauth1_header(method, url, ck, cs, tk, ts):
+def oauth1_header(method, url, ck, cs, tk, ts, query=None):
+    """query: URL の ? の後ろの値（署名に入れる。GET の STATUS で使う。url には ? を含めない）"""
     q = lambda s: urllib.parse.quote(str(s), safe="~")
     params = {
         "oauth_consumer_key": ck,
@@ -226,7 +231,7 @@ def oauth1_header(method, url, ck, cs, tk, ts):
         "oauth_token": tk,
         "oauth_version": "1.0",
     }
-    base_params = "&".join(f"{q(k)}={q(v)}" for k, v in sorted(params.items()))
+    base_params = "&".join(f"{q(k)}={q(v)}" for k, v in sorted({**params, **(query or {})}.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()
@@ -270,11 +275,53 @@ def x_upload_image(img_url, alt=""):
     return mid
 
 
+def x_upload_video(video_url):
+    """動画をサイトから取って X に上げ、media の id を返す（2026-11-02 ショート「AIあるある」を X に先に出すため）。
+    v2 の分けて送る形: initialize（JSON）→ append（multipart・4MB ずつ）→ finalize → 処理が終わるまで STATUS を見る。"""
+    req = urllib.request.Request(video_url, headers={"User-Agent": UA})
+    with urllib.request.urlopen(req, timeout=120) as r:
+        data = r.read()
+    if len(data) > 512 * 1024 * 1024:
+        raise RuntimeError(f"動画が大きすぎます: {video_url}")
+    env = x_env()
+    base = os.environ.get("X_API_BASE", "https://api.x.com")
+    res = x_call("/2/media/upload/initialize",
+                 {"media_type": "video/mp4", "total_bytes": len(data), "media_category": "tweet_video"}, "X 動画の準備")
+    mid = str((res.get("data") or {}).get("id") or "")
+    if not mid:
+        raise RuntimeError(f"X 動画の準備の返事に id がありません: {str(res)[:300]}")
+    step = 4 * 1024 * 1024
+    for k, i in enumerate(range(0, len(data), step)):
+        boundary = "----p2m" + secrets.token_hex(12)
+        body = (f"--{boundary}\r\nContent-Disposition: form-data; name=\"segment_index\"\r\n\r\n{k}\r\n"
+                f"--{boundary}\r\nContent-Disposition: form-data; name=\"media\"; filename=\"chunk{k}.mp4\"\r\n"
+                f"Content-Type: application/octet-stream\r\n\r\n").encode() + data[i:i + step] + f"\r\n--{boundary}--\r\n".encode()
+        url = f"{base}/2/media/upload/{mid}/append"
+        auth = oauth1_header("POST", url, env["X_API_KEY"], env["X_API_SECRET"], env["X_ACCESS_TOKEN"], env["X_ACCESS_SECRET"])
+        http("POST", url, {"Authorization": auth, "Content-Type": f"multipart/form-data; boundary={boundary}"}, body, f"X 動画の送信 {k}")
+    res = x_call(f"/2/media/upload/{mid}/finalize", b"", "X 動画の仕上げ")
+    info = (res.get("data") or {}).get("processing_info")
+    for _ in range(60):
+        if not info or info.get("state") == "succeeded":
+            return mid
+        if info.get("state") == "failed":
+            raise RuntimeError(f"X 動画の処理に失敗しました: {str(info)[:300]}")
+        time.sleep(max(1, int(info.get("check_after_secs", 3))))
+        url = f"{base}/2/media/upload"
+        qs = {"command": "STATUS", "media_id": mid}
+        auth = oauth1_header("GET", url, env["X_API_KEY"], env["X_API_SECRET"], env["X_ACCESS_TOKEN"], env["X_ACCESS_SECRET"], qs)
+        _, _, raw = http("GET", url + "?" + urllib.parse.urlencode(qs), {"Authorization": auth}, where="X 動画の処理の確認")
+        info = (json.loads(raw or b"{}").get("data") or {}).get("processing_info")
+    raise RuntimeError("X 動画の処理が3分たっても終わりませんでした")
+
+
 def post_x(t):
     x_env()
     body = {"text": t["text"]}
     imgs = t.get("images") or []
-    if imgs:
+    if t.get("video"):
+        body["media"] = {"media_ids": [x_upload_video(t["video"])]}
+    elif imgs:
         alts = t.get("alts") or [""] * len(imgs)
         body["media"] = {"media_ids": [x_upload_image(u, a) for u, a in zip(imgs, alts)]}
     res = x_call("/2/tweets", body, "X 投稿")
