commit e6dc8d1290833157be50a881502ef5fe1974573a Author: Claude 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 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 投稿")