🔄 卡若AI 同步 2026-03-09 22:16 | 更新:水桥平台对接、水溪整理归档、卡木、运营中枢工作台 | 排除 >20MB: 11 个
This commit is contained in:
@@ -62,8 +62,9 @@ python3 douyin_publish.py --video "/path/to/xxx.mp4" --title "标题" --token-fi
|
||||
## 五、与视频切片 / Soul 竖屏的联动
|
||||
|
||||
- **成片目录**:Soul 竖屏成片输出在 `xxx_output/成片/`,文件名为标题(如 `没人来就一个人站站到最后钱才来.mp4`)。
|
||||
- **批量发布**:可对 `成片/` 目录遍历,逐条调用 `douyin_publish.py --video <path> --title <文件名或 highlights 标题>`;标题可来自 `成片/目录索引.md` 或 `highlights.json`。
|
||||
- **腕推 / 存客宝**:若使用腕推或存客宝的抖音发布能力,可将对接方式(API 文档、SDK 路径)补充到本 Skill 的「参考资料」或脚本说明中,脚本可改为调其接口。
|
||||
- **批量发布**:可对 `成片/` 目录遍历,逐条调用 `douyin_publish.py --video <path> --title <文件名或 highlights 标题>`;标题可来自 `成片/目录索引.md` 或 `highlights.json`。示例:119 场成片可用 `脚本/batch_publish_119.py`(成片目录需与脚本内一致),发布清单见成片目录下 `119场_抖音发布清单.md`。
|
||||
- **腕推 / 存客宝 / 卡罗维亚**:若使用腕推、卡罗维亚或存客宝的抖音发布能力,可将对接方式(API 文档、SDK 路径)补充到本 Skill 的「参考资料」或脚本说明中,脚本可改为调其接口;**标题与描述**可直接使用每批成片目录下的「发布清单」复制进对应工具。
|
||||
- **小黄车与挂载**:开放平台 create_video 支持挂载小程序;电商小黄车需在抖音端发布后编辑挂载或使用创作者中心。详见 `参考资料/抖音开放平台_登录与发布流程.md` 第四节。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -40,7 +40,13 @@
|
||||
|
||||
文档:https://partner.open-douyin.com/docs/resource/zh-CN/dop/develop/openapi/video-management/douyin/create-video/video-create
|
||||
|
||||
## 四、存客宝 / 腕推
|
||||
## 四、小黄车与挂载(标题/描述/带货)
|
||||
|
||||
- **标题与描述**:每条成片建议标题、描述、话题见当批「发布清单」(如成片目录下的 `119场_抖音发布清单.md`),可直接用于抖音 App、创作者中心、或复制到**卡罗维亚/腕推**等矩阵工具内填写。
|
||||
- **create_video 挂载**:开放平台 `create_video` 接口支持挂载**小程序**(需传 `micro_app_id`、`micro_app_url`、`micro_app_title`,且需申请对应能力),不支持直接挂电商「小黄车」商品。
|
||||
- **小黄车/带货**:若需挂商品,可在发布后于抖音 App 内对该条视频「编辑」→「添加商品」挂载;或使用已开通抖音电商/商品橱窗的创作者中心在发布时勾选商品。卡罗维亚等工具若支持「默认挂载商品」或「发布后自动挂载」,以该工具说明为准。
|
||||
|
||||
## 五、存客宝 / 腕推 / 卡罗维亚
|
||||
|
||||
- 当前**存客宝** Skill 与文档中无抖音登录或发布 SDK。
|
||||
- 若使用**腕推**或其它矩阵工具发布抖音,请将对接方式(API 或 SDK 文档路径)补充到「抖音发布」Skill 或本参考资料,脚本可改为调用对应接口。
|
||||
- 若使用**腕推**、**卡罗维亚**或其它矩阵工具发布抖音,请将对接方式(API 或 SDK 文档路径)补充到「抖音发布」Skill 或本参考资料,脚本可改为调用对应接口。发布清单(标题、描述、话题)可直接复制到该类工具使用。
|
||||
|
||||
191
03_卡木(木)/木叶_视频内容/抖音发布/脚本/all_post_requests.json
Normal file
191
03_卡木(木)/木叶_视频内容/抖音发布/脚本/all_post_requests.json
Normal file
File diff suppressed because one or more lines are too long
75
03_卡木(木)/木叶_视频内容/抖音发布/脚本/batch_publish_119.py
Normal file
75
03_卡木(木)/木叶_视频内容/抖音发布/脚本/batch_publish_119.py
Normal file
@@ -0,0 +1,75 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
119 场成片批量发布到抖音。
|
||||
- 读取成片目录与发布清单,逐条调用 douyin_publish.py。
|
||||
- 若未配置 tokens.json,只打印发布清单并提示先 OAuth 登录。
|
||||
与「抖音发布」Skill 配套;成片目录见 119场_抖音发布清单.md。
|
||||
"""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# 119 场成片目录(可按需改为环境变量或参数)
|
||||
DEFAULT_Chengpian = Path("/Users/karuo/Movies/soul视频/soul 派对 119场 20260309_output/成片")
|
||||
|
||||
# 视频文件名 -> 发布标题(与 119场_抖音发布清单.md 一致)
|
||||
TITLES = {
|
||||
"早起不是为了开派对,是不吵老婆睡觉.mp4": "早起不是为了开派对,是不吵老婆睡觉。初衷就这一个。#Soul派对 #创业日记 #晨间直播 #私域干货",
|
||||
"懒人的活法 动作简单有利可图正反馈.mp4": "懒有懒的活法:动作简单、有利可图、正反馈,就能坐得住。#Soul派对 #副业 #私域 #切片变现",
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
script_dir = Path(__file__).resolve().parent
|
||||
token_file = script_dir / "tokens.json"
|
||||
if not token_file.exists():
|
||||
print("未配置 tokens.json,无法调用抖音开放平台 API。")
|
||||
print("请先完成抖音 OAuth 登录,将 access_token、open_id 写入:")
|
||||
print(f" {token_file}")
|
||||
print("参见:参考资料/抖音开放平台_登录与发布流程.md")
|
||||
print("\n本批次发布清单(可复制到抖音或卡罗维亚等工具):")
|
||||
for fname, title in TITLES.items():
|
||||
p = DEFAULT_Chengpian / fname
|
||||
print(f" - {fname}")
|
||||
print(f" 标题: {title[:60]}...")
|
||||
return 1
|
||||
|
||||
chengpian = DEFAULT_Chengpian
|
||||
if not chengpian.exists():
|
||||
print(f"成片目录不存在: {chengpian}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
publish_py = script_dir / "douyin_publish.py"
|
||||
if not publish_py.exists():
|
||||
print(f"未找到 douyin_publish.py: {publish_py}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
ok, fail = 0, 0
|
||||
for fname, title in TITLES.items():
|
||||
video_path = chengpian / fname
|
||||
if not video_path.exists():
|
||||
print(f"跳过(文件不存在): {fname}")
|
||||
fail += 1
|
||||
continue
|
||||
cmd = [
|
||||
sys.executable,
|
||||
str(publish_py),
|
||||
"--video", str(video_path),
|
||||
"--title", title,
|
||||
"--token-file", str(token_file),
|
||||
]
|
||||
ret = subprocess.run(cmd)
|
||||
if ret.returncode == 0:
|
||||
ok += 1
|
||||
else:
|
||||
fail += 1
|
||||
|
||||
print(f"\n发布完成: 成功 {ok},失败 {fail}")
|
||||
return 0 if fail == 0 else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
26
03_卡木(木)/木叶_视频内容/抖音发布/脚本/create_v2_captured.json
Normal file
26
03_卡木(木)/木叶_视频内容/抖音发布/脚本/create_v2_captured.json
Normal file
File diff suppressed because one or more lines are too long
519
03_卡木(木)/木叶_视频内容/抖音发布/脚本/douyin_api_publish.py
Normal file
519
03_卡木(木)/木叶_视频内容/抖音发布/脚本/douyin_api_publish.py
Normal file
@@ -0,0 +1,519 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
抖音创作者中心 - 纯 API 视频发布
|
||||
逆向 creator.douyin.com 内部接口,不依赖浏览器自动化
|
||||
|
||||
流程:
|
||||
1. GET /web/api/media/upload/auth/v5/ → 获取上传凭证 (ak, auth)
|
||||
2. GET imagex.bytedanceapi.com?Action=ApplyUploadInner → 获取上传地址
|
||||
3. POST {UploadHosts}/upload/v1/{storeUri} → 分片上传视频
|
||||
4. POST /web/api/media/aweme/create/ → 发布作品
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import datetime
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import random
|
||||
import string
|
||||
import sys
|
||||
import uuid
|
||||
import zlib
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlencode, quote
|
||||
|
||||
import httpx
|
||||
|
||||
# ── 配置 ───────────────────────────────────────────────────
|
||||
COOKIE_FILE = Path(__file__).parent / "douyin_storage_state.json"
|
||||
CHUNK_SIZE = 3 * 1024 * 1024 # 3MB per chunk
|
||||
|
||||
BASE = "https://creator.douyin.com"
|
||||
AUTH_URL = f"{BASE}/web/api/media/upload/auth/v5/"
|
||||
CREATE_URL = f"{BASE}/web/api/media/aweme/create/"
|
||||
IMAGEX_HOST = "https://imagex.bytedanceapi.com/"
|
||||
|
||||
UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
|
||||
|
||||
|
||||
# ── Cookie 工具 ────────────────────────────────────────────
|
||||
def load_cookies_from_storage_state(path: Path) -> str:
|
||||
"""从 Playwright storage_state.json 提取 Cookie 字符串"""
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
state = json.load(f)
|
||||
cookies = state.get("cookies", [])
|
||||
# 只取 creator.douyin.com 和 .douyin.com 的 cookie
|
||||
parts = []
|
||||
for c in cookies:
|
||||
domain = c.get("domain", "")
|
||||
if "douyin.com" in domain:
|
||||
parts.append(f"{c['name']}={c['value']}")
|
||||
return "; ".join(parts)
|
||||
|
||||
|
||||
# ── AWS4-HMAC-SHA256 签名 ──────────────────────────────────
|
||||
def _hmac_sha256(key: bytes, msg: str) -> bytes:
|
||||
return hmac.new(key, msg.encode("utf-8"), hashlib.sha256).digest()
|
||||
|
||||
|
||||
def get_signing_key(secret: str, date_stamp: str, region: str, service: str) -> bytes:
|
||||
k_date = _hmac_sha256(("AWS4" + secret).encode("utf-8"), date_stamp)
|
||||
k_region = _hmac_sha256(k_date, region)
|
||||
k_service = _hmac_sha256(k_region, service)
|
||||
k_signing = _hmac_sha256(k_service, "aws4_request")
|
||||
return k_signing
|
||||
|
||||
|
||||
def build_authorization(
|
||||
access_key_id: str,
|
||||
secret_access_key: str,
|
||||
session_token: str,
|
||||
region: str,
|
||||
service: str,
|
||||
canonical_querystring: str,
|
||||
method: str = "GET",
|
||||
) -> tuple[str, str, str]:
|
||||
"""生成 AWS4-HMAC-SHA256 Authorization header,返回 (authorization, amz_date, session_token)"""
|
||||
now = datetime.datetime.now(datetime.timezone.utc)
|
||||
amz_date = now.strftime("%Y%m%dT%H%M%SZ")
|
||||
date_stamp = now.strftime("%Y%m%d")
|
||||
|
||||
canonical_headers = (
|
||||
f"x-amz-date:{amz_date}\nx-amz-security-token:{session_token}\n"
|
||||
)
|
||||
signed_headers = "x-amz-date;x-amz-security-token"
|
||||
payload_hash = hashlib.sha256(b"").hexdigest()
|
||||
|
||||
canonical_request = (
|
||||
f"{method}\n/\n{canonical_querystring}\n{canonical_headers}\n"
|
||||
f"{signed_headers}\n{payload_hash}"
|
||||
)
|
||||
|
||||
credential_scope = f"{date_stamp}/{region}/{service}/aws4_request"
|
||||
string_to_sign = (
|
||||
f"AWS4-HMAC-SHA256\n{amz_date}\n{credential_scope}\n"
|
||||
+ hashlib.sha256(canonical_request.encode("utf-8")).hexdigest()
|
||||
)
|
||||
|
||||
signing_key = get_signing_key(secret_access_key, date_stamp, region, service)
|
||||
signature = hmac.new(
|
||||
signing_key, string_to_sign.encode("utf-8"), hashlib.sha256
|
||||
).hexdigest()
|
||||
|
||||
authorization = (
|
||||
f"AWS4-HMAC-SHA256 Credential={access_key_id}/{date_stamp}/{region}/{service}/aws4_request, "
|
||||
f"SignedHeaders={signed_headers}, Signature={signature}"
|
||||
)
|
||||
return authorization, amz_date, session_token
|
||||
|
||||
|
||||
def random_s(length: int = 11) -> str:
|
||||
chars = string.ascii_lowercase + string.digits
|
||||
return "".join(random.choice(chars) for _ in range(length))
|
||||
|
||||
|
||||
# ── Step 1: 获取上传授权 ──────────────────────────────────
|
||||
async def get_upload_auth(client: httpx.AsyncClient, cookie: str) -> dict:
|
||||
print(" [1/4] 获取上传凭证...")
|
||||
resp = await client.get(
|
||||
AUTH_URL,
|
||||
headers={"Cookie": cookie, "User-Agent": UA},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
if data.get("status_code") != 0:
|
||||
raise RuntimeError(f"auth 失败: {data}")
|
||||
ak = data["ak"]
|
||||
auth_raw = json.loads(data["auth"])
|
||||
print(f" ak={ak[:20]}... auth.AccessKeyID={auth_raw['AccessKeyID'][:20]}...")
|
||||
return {
|
||||
"ak": ak,
|
||||
"access_key_id": auth_raw["AccessKeyID"],
|
||||
"secret_access_key": auth_raw["SecretAccessKey"],
|
||||
"session_token": auth_raw["SessionToken"],
|
||||
}
|
||||
|
||||
|
||||
# ── Step 2: 获取视频上传分配 ──────────────────────────────
|
||||
async def apply_upload(
|
||||
client: httpx.AsyncClient, auth: dict, file_size: int
|
||||
) -> dict:
|
||||
print(" [2/4] 获取上传分配地址...")
|
||||
region = "cn-north-1"
|
||||
service = "vod"
|
||||
|
||||
params = {
|
||||
"Action": "ApplyUploadInner",
|
||||
"FileSize": str(file_size),
|
||||
"FileType": "video",
|
||||
"IsInner": "1",
|
||||
"SpaceName": "aweme",
|
||||
"Version": "2020-11-19",
|
||||
"app_id": "2906",
|
||||
"s": random_s(),
|
||||
"user_id": "",
|
||||
}
|
||||
canonical_qs = "&".join(f"{k}={v}" for k, v in sorted(params.items()))
|
||||
|
||||
authorization, amz_date, session_token = build_authorization(
|
||||
auth["access_key_id"],
|
||||
auth["secret_access_key"],
|
||||
auth["session_token"],
|
||||
region,
|
||||
service,
|
||||
canonical_qs,
|
||||
)
|
||||
|
||||
resp = await client.get(
|
||||
IMAGEX_HOST,
|
||||
params=params,
|
||||
headers={
|
||||
"authorization": authorization,
|
||||
"x-amz-date": amz_date,
|
||||
"x-amz-security-token": session_token,
|
||||
"User-Agent": UA,
|
||||
},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
|
||||
if "Result" not in data:
|
||||
raise RuntimeError(f"ApplyUpload 失败: {data}")
|
||||
|
||||
result = data["Result"]
|
||||
upload_address = result.get("InnerUploadAddress", result)
|
||||
session_key = upload_address.get("SessionKey", "")
|
||||
upload_hosts = upload_address.get("UploadNodes", [{}])[0].get("UploadHost", "")
|
||||
store_uri = upload_address.get("UploadNodes", [{}])[0].get("StoreInfos", [{}])[0].get("StoreUri", "")
|
||||
store_auth = upload_address.get("UploadNodes", [{}])[0].get("StoreInfos", [{}])[0].get("Auth", "")
|
||||
|
||||
if not upload_hosts or not store_uri:
|
||||
# 备用路径
|
||||
upload_hosts = result.get("UploadAddress", {}).get("UploadHosts", [""])[0]
|
||||
store_uri = result.get("UploadAddress", {}).get("StoreInfos", [{}])[0].get("StoreUri", "")
|
||||
store_auth = result.get("UploadAddress", {}).get("StoreInfos", [{}])[0].get("Auth", "")
|
||||
session_key = result.get("UploadAddress", {}).get("SessionKey", "")
|
||||
|
||||
print(f" host={upload_hosts}")
|
||||
print(f" storeUri={store_uri[:40]}...")
|
||||
print(f" sessionKey={session_key[:30]}...")
|
||||
return {
|
||||
"session_key": session_key,
|
||||
"upload_host": upload_hosts,
|
||||
"store_uri": store_uri,
|
||||
"auth": store_auth,
|
||||
}
|
||||
|
||||
|
||||
# ── Step 3: 分片上传视频 ──────────────────────────────────
|
||||
async def upload_video_chunks(
|
||||
client: httpx.AsyncClient, upload_info: dict, file_path: str
|
||||
) -> bool:
|
||||
print(" [3/4] 分片上传视频...")
|
||||
data = Path(file_path).read_bytes()
|
||||
total_size = len(data)
|
||||
total_chunks = (total_size + CHUNK_SIZE - 1) // CHUNK_SIZE
|
||||
upload_id = str(uuid.uuid4())
|
||||
|
||||
base_url = f"https://{upload_info['upload_host']}/upload/v1/{upload_info['store_uri']}"
|
||||
|
||||
for i in range(total_chunks):
|
||||
start = i * CHUNK_SIZE
|
||||
end = min((i + 1) * CHUNK_SIZE, total_size)
|
||||
chunk = data[start:end]
|
||||
|
||||
crc32 = hex(zlib.crc32(chunk) & 0xFFFFFFFF)[2:]
|
||||
|
||||
params = {
|
||||
"uploadid": upload_id,
|
||||
"part_number": str(i + 1),
|
||||
"part_offset": str(start),
|
||||
"phase": "transfer",
|
||||
}
|
||||
|
||||
resp = await client.post(
|
||||
base_url,
|
||||
params=params,
|
||||
content=chunk,
|
||||
headers={
|
||||
"Authorization": upload_info["auth"],
|
||||
"Content-CRC32": crc32,
|
||||
"Content-Type": "application/octet-stream",
|
||||
"User-Agent": UA,
|
||||
},
|
||||
timeout=120.0,
|
||||
)
|
||||
|
||||
if resp.status_code == 200:
|
||||
resp_data = resp.json()
|
||||
if resp_data.get("code") == 2000:
|
||||
print(f" chunk {i+1}/{total_chunks} 上传成功 (crc32={crc32})")
|
||||
else:
|
||||
print(f" chunk {i+1}/{total_chunks} 返回异常: {resp_data}")
|
||||
return False
|
||||
else:
|
||||
print(f" chunk {i+1}/{total_chunks} HTTP {resp.status_code}: {resp.text[:200]}")
|
||||
return False
|
||||
|
||||
# 分片上传完成后,发送 finish 请求
|
||||
finish_params = {
|
||||
"uploadid": upload_id,
|
||||
"phase": "finish",
|
||||
}
|
||||
finish_resp = await client.post(
|
||||
base_url,
|
||||
params=finish_params,
|
||||
headers={
|
||||
"Authorization": upload_info["auth"],
|
||||
"User-Agent": UA,
|
||||
},
|
||||
timeout=60.0,
|
||||
)
|
||||
print(f" finish 响应: HTTP {finish_resp.status_code}")
|
||||
try:
|
||||
finish_data = finish_resp.json()
|
||||
print(f" finish 数据: {json.dumps(finish_data, ensure_ascii=False)[:200]}")
|
||||
except Exception:
|
||||
print(f" finish 原始: {finish_resp.text[:200]}")
|
||||
|
||||
print(f" 视频上传完成: {total_chunks} 个分片, {total_size/1024/1024:.1f}MB")
|
||||
return True
|
||||
|
||||
|
||||
# ── Step 4: 发布作品 ──────────────────────────────────────
|
||||
def extract_csrf_token(cookie_str: str) -> str:
|
||||
"""从 cookie 字符串中提取 passport_csrf_token"""
|
||||
for part in cookie_str.split(";"):
|
||||
kv = part.strip().split("=", 1)
|
||||
if len(kv) == 2 and kv[0].strip() == "passport_csrf_token":
|
||||
return kv[1].strip()
|
||||
return ""
|
||||
|
||||
|
||||
async def create_aweme(
|
||||
client: httpx.AsyncClient,
|
||||
cookie: str,
|
||||
store_uri: str,
|
||||
title: str,
|
||||
timing: int = -1,
|
||||
session_key: str = "",
|
||||
) -> dict:
|
||||
print(" [4/4] 发布视频作品...")
|
||||
creation_id = f"{random_s(8)}{int(datetime.datetime.now(datetime.timezone.utc).timestamp() * 1000)}"
|
||||
|
||||
# 构建 form-encoded body
|
||||
parts = [
|
||||
f"text={title}",
|
||||
"text_extra=[]",
|
||||
"activity=[]",
|
||||
"challenges=[]",
|
||||
'hashtag_source=""',
|
||||
"mentions=[]",
|
||||
"ifLongTitle=true",
|
||||
"hot_sentence=",
|
||||
"visibility_type=0",
|
||||
"download=1",
|
||||
f"poster={store_uri}",
|
||||
f"timing={timing}",
|
||||
f'video={{"uri":"{store_uri}"}}',
|
||||
f"creation_id={creation_id}",
|
||||
]
|
||||
if session_key:
|
||||
parts.append(f"session_key={session_key}")
|
||||
body = "&".join(parts)
|
||||
|
||||
csrf = extract_csrf_token(cookie)
|
||||
|
||||
headers = {
|
||||
"Cookie": cookie,
|
||||
"User-Agent": UA,
|
||||
"Content-Type": "text/plain",
|
||||
"Referer": "https://creator.douyin.com/creator-micro/content/publish",
|
||||
"Origin": "https://creator.douyin.com",
|
||||
"Accept": "application/json, text/plain, */*",
|
||||
}
|
||||
if csrf:
|
||||
headers["X-CSRFToken"] = csrf
|
||||
|
||||
resp = await client.post(CREATE_URL, headers=headers, content=body)
|
||||
|
||||
print(f" HTTP {resp.status_code}, Content-Type: {resp.headers.get('content-type', 'unknown')}")
|
||||
raw = resp.text
|
||||
print(f" 原始响应 (前500字): {raw[:500]}")
|
||||
|
||||
if resp.status_code != 200:
|
||||
return {"status_code": resp.status_code, "error": raw[:200]}
|
||||
|
||||
try:
|
||||
data = resp.json()
|
||||
except Exception:
|
||||
return {"status_code": -1, "error": f"非JSON响应: {raw[:200]}"}
|
||||
|
||||
return data
|
||||
|
||||
|
||||
# ── 单条发布主流程 ────────────────────────────────────────
|
||||
async def publish_one(
|
||||
video_path: str,
|
||||
title: str,
|
||||
cookie: str,
|
||||
timing: int = -1,
|
||||
) -> bool:
|
||||
file_size = Path(video_path).stat().st_size
|
||||
print(f"\n{'='*60}")
|
||||
print(f" 视频: {Path(video_path).name}")
|
||||
print(f" 大小: {file_size/1024/1024:.1f}MB")
|
||||
print(f" 标题: {title[:50]}")
|
||||
if timing > 0:
|
||||
from datetime import datetime as dt
|
||||
print(f" 定时: {dt.fromtimestamp(timing).strftime('%Y-%m-%d %H:%M')}")
|
||||
print(f"{'='*60}")
|
||||
|
||||
async with httpx.AsyncClient(timeout=60.0) as client:
|
||||
# Step 1
|
||||
auth = await get_upload_auth(client, cookie)
|
||||
|
||||
# Step 2
|
||||
upload_info = await apply_upload(client, auth, file_size)
|
||||
|
||||
# Step 3
|
||||
ok = await upload_video_chunks(client, upload_info, video_path)
|
||||
if not ok:
|
||||
print(" [✗] 视频上传失败")
|
||||
return False
|
||||
|
||||
# Step 4
|
||||
result = await create_aweme(
|
||||
client, cookie, upload_info["store_uri"], title, timing,
|
||||
session_key=upload_info.get("session_key", ""),
|
||||
)
|
||||
|
||||
status = result.get("status_code", -1)
|
||||
if status == 0:
|
||||
print(" [✓] 视频发布成功!")
|
||||
return True
|
||||
else:
|
||||
print(f" [!] 发布接口返回: status_code={status}")
|
||||
print(f" 完整响应: {json.dumps(result, ensure_ascii=False)}")
|
||||
return False
|
||||
|
||||
|
||||
# ── 批量发布 ──────────────────────────────────────────────
|
||||
VIDEO_DIR = Path("/Users/karuo/Movies/soul视频/soul 派对 119场 20260309_output/成片")
|
||||
|
||||
TITLES = {
|
||||
"早起不是为了开派对,是不吵老婆睡觉.mp4":
|
||||
"早起不是为了开派对,是不吵老婆睡觉。初衷就这一个。#Soul派对 #创业日记 #晨间直播 #私域干货",
|
||||
"懒人的活法 动作简单有利可图正反馈.mp4":
|
||||
"懒有懒的活法:动作简单、有利可图、正反馈,就能坐得住。#Soul派对 #副业 #私域 #切片变现",
|
||||
"初期团队先找两个IS,比钱好使 ENFJ链接人,ENTJ指挥.mp4":
|
||||
"初期团队先找两个IS,比钱好使。ENFJ链接人,ENTJ指挥。#MBTI #创业团队 #Soul派对",
|
||||
"ICU出来一年多 活着要在互联网上留下东西.mp4":
|
||||
"ICU出来一年多,活着要在互联网上留下东西。#人生感悟 #创业 #Soul派对 #记录生活",
|
||||
"MBTI疗愈SOUL 年轻人测MBTI,40到60岁走五行八卦.mp4":
|
||||
"年轻人测MBTI,40到60岁走五行八卦。#MBTI #Soul派对 #五行 #疗愈",
|
||||
"Soul业务模型 派对+切片+小程序全链路.mp4":
|
||||
"Soul业务模型:派对+切片+小程序全链路。#Soul派对 #商业模式 #私域运营 #小程序",
|
||||
"Soul切片30秒到8分钟 AI半小时能剪10到30个.mp4":
|
||||
"Soul切片30秒到8分钟,AI半小时能剪10到30个。#AI剪辑 #Soul派对 #切片变现 #效率工具",
|
||||
"刷牙听业务逻辑 Soul切片变现怎么跑.mp4":
|
||||
"刷牙听业务逻辑:Soul切片变现怎么跑。#Soul派对 #切片变现 #副业 #商业逻辑",
|
||||
"国学易经怎么学 两小时七七八八,召唤作者对话.mp4":
|
||||
"国学易经怎么学?两小时七七八八,召唤作者对话。#国学 #易经 #Soul派对 #学习方法",
|
||||
"广点通能投Soul了,1000曝光6到10块.mp4":
|
||||
"广点通能投Soul了,1000曝光6到10块。#Soul派对 #广点通 #流量投放 #私域获客",
|
||||
"建立信任不是求来的 卖外挂发邮件三个月拿下德国总代.mp4":
|
||||
"建立信任不是求来的。卖外挂发邮件三个月拿下德国总代。#销售 #信任 #Soul派对 #商业故事",
|
||||
"核心就两个字 筛选。能开派对坚持7天的人再谈.mp4":
|
||||
"核心就两个字:筛选。能开派对坚持7天的人再谈。#筛选 #Soul派对 #创业 #坚持",
|
||||
"睡眠不好?每天放下一件事,做减法.mp4":
|
||||
"睡眠不好?每天放下一件事,做减法。#睡眠 #减法 #Soul派对 #生活方式",
|
||||
"这套体系花了170万,但前端几十块就能参与.mp4":
|
||||
"这套体系花了170万,但前端几十块就能参与。#商业体系 #Soul派对 #私域 #低成本创业",
|
||||
"金融AI获客体系 后端30人沉淀12年,前端丢手机.mp4":
|
||||
"金融AI获客体系:后端30人沉淀12年,前端丢手机。#AI获客 #金融 #Soul派对 #商业模式",
|
||||
}
|
||||
|
||||
|
||||
def get_title(filename: str) -> str:
|
||||
if filename in TITLES:
|
||||
return TITLES[filename]
|
||||
return f"{Path(filename).stem} #Soul派对 #创业日记 #卡若创业派对"
|
||||
|
||||
|
||||
async def main():
|
||||
if not COOKIE_FILE.exists():
|
||||
print("[✗] Cookie 文件不存在,请先运行 douyin_login.py 获取")
|
||||
return 1
|
||||
|
||||
cookie = load_cookies_from_storage_state(COOKIE_FILE)
|
||||
if not cookie:
|
||||
print("[✗] Cookie 为空")
|
||||
return 1
|
||||
print(f"[✓] Cookie 已加载 ({len(cookie)} chars)")
|
||||
|
||||
# 先测试 Cookie 有效性
|
||||
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||
resp = await client.get(
|
||||
f"{BASE}/web/api/media/user/info/",
|
||||
headers={"Cookie": cookie, "User-Agent": UA},
|
||||
)
|
||||
data = resp.json()
|
||||
if data.get("status_code") != 0:
|
||||
print(f"[✗] Cookie 无效: {data}")
|
||||
return 1
|
||||
user = data.get("user_info", {})
|
||||
print(f"[✓] 登录用户: {user.get('nickname', 'unknown')}")
|
||||
|
||||
videos = sorted(VIDEO_DIR.glob("*.mp4"))
|
||||
if not videos:
|
||||
print("[✗] 未找到视频")
|
||||
return 1
|
||||
print(f"[i] 共 {len(videos)} 条视频\n")
|
||||
|
||||
# 计算定时时间(每小时一条,从当前+2h开始)
|
||||
import time
|
||||
now_ts = int(time.time())
|
||||
base_ts = now_ts + 2 * 3600
|
||||
# 对齐到下一个整点
|
||||
base_ts = (base_ts // 3600 + 1) * 3600
|
||||
|
||||
results = []
|
||||
for i, vp in enumerate(videos):
|
||||
title = get_title(vp.name)
|
||||
schedule_ts = base_ts + i * 3600 # 每小时一条
|
||||
|
||||
try:
|
||||
ok = await publish_one(
|
||||
video_path=str(vp),
|
||||
title=title,
|
||||
cookie=cookie,
|
||||
timing=schedule_ts,
|
||||
)
|
||||
except Exception as e:
|
||||
print(f" [✗] 异常: {e}")
|
||||
ok = False
|
||||
results.append((vp.name, ok, schedule_ts))
|
||||
|
||||
if i < len(videos) - 1 and ok:
|
||||
print(" 等待 5 秒...")
|
||||
await asyncio.sleep(5)
|
||||
|
||||
# 汇总
|
||||
print(f"\n{'='*60}")
|
||||
print(" 发布汇总")
|
||||
print(f"{'='*60}")
|
||||
from datetime import datetime as dt
|
||||
for name, ok, ts in results:
|
||||
status = "✓" if ok else "✗"
|
||||
t = dt.fromtimestamp(ts).strftime("%m-%d %H:%M")
|
||||
print(f" [{status}] {t} | {name}")
|
||||
success = sum(1 for _, ok, _ in results if ok)
|
||||
print(f"\n 成功: {success}/{len(results)}")
|
||||
return 0 if success == len(results) else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(asyncio.run(main()))
|
||||
569
03_卡木(木)/木叶_视频内容/抖音发布/脚本/douyin_batch_publish.py
Normal file
569
03_卡木(木)/木叶_视频内容/抖音发布/脚本/douyin_batch_publish.py
Normal file
@@ -0,0 +1,569 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
抖音批量定时发布(Playwright 自动化)
|
||||
1. 首次运行弹出浏览器让用户扫码登录,保存 cookie
|
||||
2. 按每小时一条的节奏定时发布目录下所有 mp4
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import sys
|
||||
import os
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
# ── 万推 backend 路径 ───────────────────────────────────────
|
||||
WANTUI_BACKEND = Path("/Users/karuo/Documents/开发/3、自营项目/万推/backend")
|
||||
sys.path.insert(0, str(WANTUI_BACKEND))
|
||||
|
||||
from playwright.async_api import async_playwright
|
||||
from utils.base_social_media import set_init_script
|
||||
|
||||
# ── 配置 ───────────────────────────────────────────────────
|
||||
VIDEO_DIR = Path("/Users/karuo/Movies/soul视频/soul 派对 119场 20260309_output/成片")
|
||||
COOKIE_FILE = Path(__file__).parent / "douyin_storage_state.json"
|
||||
CHROME_PATH = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
|
||||
HOUR_INTERVAL = 1 # 每条间隔小时数
|
||||
START_OFFSET_HOURS = 2 # 第一条从当前时间 +2h 开始(抖音要求 >=2h)
|
||||
|
||||
# ── 视频标题映射(文件名 → 标题+话题) ─────────────────────
|
||||
TITLES = {
|
||||
"早起不是为了开派对,是不吵老婆睡觉.mp4":
|
||||
"早起不是为了开派对,是不吵老婆睡觉。初衷就这一个。#Soul派对 #创业日记 #晨间直播 #私域干货",
|
||||
"懒人的活法 动作简单有利可图正反馈.mp4":
|
||||
"懒有懒的活法:动作简单、有利可图、正反馈,就能坐得住。#Soul派对 #副业 #私域 #切片变现",
|
||||
"初期团队先找两个IS,比钱好使 ENFJ链接人,ENTJ指挥.mp4":
|
||||
"初期团队先找两个IS,比钱好使。ENFJ链接人,ENTJ指挥。#MBTI #创业团队 #Soul派对",
|
||||
"ICU出来一年多 活着要在互联网上留下东西.mp4":
|
||||
"ICU出来一年多,活着要在互联网上留下东西。#人生感悟 #创业 #Soul派对 #记录生活",
|
||||
"MBTI疗愈SOUL 年轻人测MBTI,40到60岁走五行八卦.mp4":
|
||||
"年轻人测MBTI,40到60岁走五行八卦。#MBTI #Soul派对 #五行 #疗愈",
|
||||
"Soul业务模型 派对+切片+小程序全链路.mp4":
|
||||
"Soul业务模型:派对+切片+小程序全链路。#Soul派对 #商业模式 #私域运营 #小程序",
|
||||
"Soul切片30秒到8分钟 AI半小时能剪10到30个.mp4":
|
||||
"Soul切片30秒到8分钟,AI半小时能剪10到30个。#AI剪辑 #Soul派对 #切片变现 #效率工具",
|
||||
"刷牙听业务逻辑 Soul切片变现怎么跑.mp4":
|
||||
"刷牙听业务逻辑:Soul切片变现怎么跑。#Soul派对 #切片变现 #副业 #商业逻辑",
|
||||
"国学易经怎么学 两小时七七八八,召唤作者对话.mp4":
|
||||
"国学易经怎么学?两小时七七八八,召唤作者对话。#国学 #易经 #Soul派对 #学习方法",
|
||||
"广点通能投Soul了,1000曝光6到10块.mp4":
|
||||
"广点通能投Soul了,1000曝光6到10块。#Soul派对 #广点通 #流量投放 #私域获客",
|
||||
"建立信任不是求来的 卖外挂发邮件三个月拿下德国总代.mp4":
|
||||
"建立信任不是求来的。卖外挂发邮件三个月拿下德国总代。#销售 #信任 #Soul派对 #商业故事",
|
||||
"核心就两个字 筛选。能开派对坚持7天的人再谈.mp4":
|
||||
"核心就两个字:筛选。能开派对坚持7天的人再谈。#筛选 #Soul派对 #创业 #坚持",
|
||||
"睡眠不好?每天放下一件事,做减法.mp4":
|
||||
"睡眠不好?每天放下一件事,做减法。#睡眠 #减法 #Soul派对 #生活方式",
|
||||
"这套体系花了170万,但前端几十块就能参与.mp4":
|
||||
"这套体系花了170万,但前端几十块就能参与。#商业体系 #Soul派对 #私域 #低成本创业",
|
||||
"金融AI获客体系 后端30人沉淀12年,前端丢手机.mp4":
|
||||
"金融AI获客体系:后端30人沉淀12年,前端丢手机。#AI获客 #金融 #Soul派对 #商业模式",
|
||||
}
|
||||
|
||||
|
||||
def get_title(filename: str) -> str:
|
||||
if filename in TITLES:
|
||||
return TITLES[filename]
|
||||
stem = Path(filename).stem
|
||||
return f"{stem} #Soul派对 #创业日记 #卡若创业派对"
|
||||
|
||||
|
||||
def parse_tags_from_title(title: str) -> list[str]:
|
||||
"""从标题中提取 # 话题"""
|
||||
tags = []
|
||||
for part in title.split("#"):
|
||||
t = part.strip().split()[0] if part.strip() else ""
|
||||
if t and t != title.split("#")[0].strip():
|
||||
tags.append(t)
|
||||
return tags
|
||||
|
||||
|
||||
# ── Cookie 管理 ──────────────────────────────────────────
|
||||
async def ensure_cookie() -> bool:
|
||||
"""检查 cookie 是否有效,无效则弹窗让用户登录"""
|
||||
if COOKIE_FILE.exists():
|
||||
valid = await check_cookie_valid()
|
||||
if valid:
|
||||
print("[✓] Cookie 有效,跳过登录")
|
||||
return True
|
||||
print("[!] Cookie 已失效,需要重新登录")
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print(" 即将弹出浏览器窗口,请在浏览器中扫码登录抖音")
|
||||
print(" 登录成功后,在 Playwright Inspector 中点击绿色 ▶ 按钮")
|
||||
print("=" * 60 + "\n")
|
||||
|
||||
async with async_playwright() as pw:
|
||||
browser = await pw.chromium.launch(
|
||||
headless=False,
|
||||
executable_path=CHROME_PATH if os.path.exists(CHROME_PATH) else None,
|
||||
)
|
||||
context = await browser.new_context()
|
||||
context = await set_init_script(context)
|
||||
page = await context.new_page()
|
||||
await page.goto("https://creator.douyin.com/")
|
||||
await page.pause() # 暂停等用户登录后点继续
|
||||
await context.storage_state(path=str(COOKIE_FILE))
|
||||
await context.close()
|
||||
await browser.close()
|
||||
|
||||
print("[✓] Cookie 已保存")
|
||||
return True
|
||||
|
||||
|
||||
async def check_cookie_valid() -> bool:
|
||||
async with async_playwright() as pw:
|
||||
browser = await pw.chromium.launch(headless=True)
|
||||
context = await browser.new_context(storage_state=str(COOKIE_FILE))
|
||||
context = await set_init_script(context)
|
||||
page = await context.new_page()
|
||||
try:
|
||||
await page.goto(
|
||||
"https://creator.douyin.com/creator-micro/content/upload",
|
||||
wait_until="domcontentloaded",
|
||||
timeout=30000,
|
||||
)
|
||||
await page.wait_for_url("**/creator.douyin.com/**/upload**", timeout=10000)
|
||||
await asyncio.sleep(3)
|
||||
if (
|
||||
await page.get_by_text("手机号登录").count()
|
||||
or await page.get_by_text("扫码登录").count()
|
||||
):
|
||||
await context.close()
|
||||
await browser.close()
|
||||
return False
|
||||
await context.close()
|
||||
await browser.close()
|
||||
return True
|
||||
except Exception:
|
||||
await context.close()
|
||||
await browser.close()
|
||||
return False
|
||||
|
||||
|
||||
# ── 辅助函数 ──────────────────────────────────────────────
|
||||
async def dismiss_popups(page):
|
||||
"""关闭抖音创作者中心可能出现的各种弹窗"""
|
||||
# "视频预览功能" 弹窗
|
||||
try:
|
||||
btn = page.get_by_text("我知道了", exact=True)
|
||||
if await btn.count() and await btn.first.is_visible():
|
||||
await btn.first.click()
|
||||
await asyncio.sleep(0.5)
|
||||
print(" [i] 关闭了视频预览功能弹窗")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 话题下拉菜单 → Escape 关闭
|
||||
try:
|
||||
await page.keyboard.press("Escape")
|
||||
await asyncio.sleep(0.3)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 日历弹窗 → 点击空白处
|
||||
try:
|
||||
await page.mouse.click(10, 10)
|
||||
await asyncio.sleep(0.3)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# "添加共创"提示弹窗 → 点击空白关闭
|
||||
try:
|
||||
tooltip = page.locator('[class*="tooltip"]').first
|
||||
if await tooltip.count() and await tooltip.is_visible():
|
||||
await page.mouse.click(10, 10)
|
||||
await asyncio.sleep(0.3)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 任何 semi-modal 确认弹窗
|
||||
try:
|
||||
close_btn = page.locator('.semi-modal-close')
|
||||
if await close_btn.count() and await close_btn.first.is_visible():
|
||||
await close_btn.first.click()
|
||||
await asyncio.sleep(0.3)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
async def set_cover(page):
|
||||
"""尝试设置封面(点击"选择封面"→ 选第一个推荐封面 → 完成)"""
|
||||
try:
|
||||
# 点击"选择封面"
|
||||
cover_btn = page.get_by_text("选择封面", exact=False)
|
||||
if not await cover_btn.count():
|
||||
return
|
||||
await cover_btn.first.click()
|
||||
await asyncio.sleep(2)
|
||||
|
||||
# 等待封面弹窗
|
||||
modal = page.locator("div.dy-creator-content-modal")
|
||||
if not await modal.count():
|
||||
# 尝试另一种选择器
|
||||
modal = page.locator('[class*="modal"]')
|
||||
|
||||
# 选择推荐的第一个封面
|
||||
recommend = page.locator('[class^="recommendCover-"]').first
|
||||
if await recommend.count():
|
||||
await recommend.click()
|
||||
await asyncio.sleep(1)
|
||||
print(" [i] 已选择推荐封面")
|
||||
else:
|
||||
# 备选:选择第一个可用封面图
|
||||
covers = page.locator('[class*="cover-item"], [class*="coverItem"]').first
|
||||
if await covers.count():
|
||||
await covers.click()
|
||||
await asyncio.sleep(1)
|
||||
|
||||
# 点击"完成"按钮
|
||||
finish_btn = page.get_by_role("button", name="完成")
|
||||
if await finish_btn.count() and await finish_btn.first.is_visible():
|
||||
await finish_btn.first.click()
|
||||
await asyncio.sleep(1)
|
||||
print(" [✓] 封面设置完成")
|
||||
else:
|
||||
# 备选:按 Escape 关闭
|
||||
await page.keyboard.press("Escape")
|
||||
await asyncio.sleep(0.5)
|
||||
except Exception as e:
|
||||
print(f" [i] 封面设置跳过: {e}")
|
||||
try:
|
||||
await page.keyboard.press("Escape")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# ── 单条视频上传 ─────────────────────────────────────────
|
||||
async def upload_one(
|
||||
video_path: str,
|
||||
title: str,
|
||||
tags: list[str],
|
||||
publish_date: datetime | None,
|
||||
idx: int,
|
||||
total: int,
|
||||
) -> bool:
|
||||
print(f"\n{'─' * 60}")
|
||||
print(f" [{idx}/{total}] {Path(video_path).name}")
|
||||
print(f" 标题: {title[:50]}...")
|
||||
if publish_date:
|
||||
print(f" 定时: {publish_date.strftime('%Y-%m-%d %H:%M')}")
|
||||
print(f"{'─' * 60}")
|
||||
|
||||
async with async_playwright() as pw:
|
||||
browser = await pw.chromium.launch(
|
||||
headless=False,
|
||||
executable_path=CHROME_PATH if os.path.exists(CHROME_PATH) else None,
|
||||
)
|
||||
context = await browser.new_context(storage_state=str(COOKIE_FILE))
|
||||
context = await set_init_script(context)
|
||||
page = await context.new_page()
|
||||
|
||||
try:
|
||||
# 1. 打开上传页
|
||||
await page.goto(
|
||||
"https://creator.douyin.com/creator-micro/content/upload",
|
||||
wait_until="domcontentloaded",
|
||||
timeout=60000,
|
||||
)
|
||||
await page.wait_for_url("**/creator.douyin.com/**/upload**", timeout=60000)
|
||||
await page.wait_for_load_state("load", timeout=20000)
|
||||
try:
|
||||
await page.get_by_text("上传视频", exact=False).first.wait_for(
|
||||
state="visible", timeout=15000
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
await asyncio.sleep(3)
|
||||
|
||||
# 2. 上传文件
|
||||
upload_selectors = [
|
||||
"input[type='file']",
|
||||
"div[class^='container'] input",
|
||||
"[class*='upload'] input[type='file']",
|
||||
"div[class*='upload'] input",
|
||||
"[class^='upload-btn-input']",
|
||||
"div.progress-div input",
|
||||
]
|
||||
uploaded = False
|
||||
for selector in upload_selectors:
|
||||
try:
|
||||
loc = page.locator(selector).first
|
||||
await loc.wait_for(state="attached", timeout=5000)
|
||||
await loc.set_input_files(video_path, timeout=120000)
|
||||
print(f" [✓] 文件已选择 (via {selector})")
|
||||
uploaded = True
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
if not uploaded:
|
||||
print(" [✗] 未找到上传输入框")
|
||||
return False
|
||||
|
||||
# 3. 等待跳转到发布页
|
||||
for _ in range(120):
|
||||
try:
|
||||
await page.wait_for_url(
|
||||
"https://creator.douyin.com/creator-micro/content/publish?enter_from=publish_page",
|
||||
timeout=2000,
|
||||
)
|
||||
print(" [✓] 进入发布页 v1")
|
||||
break
|
||||
except Exception:
|
||||
try:
|
||||
await page.wait_for_url(
|
||||
"https://creator.douyin.com/creator-micro/content/post/video?enter_from=publish_page",
|
||||
timeout=2000,
|
||||
)
|
||||
print(" [✓] 进入发布页 v2")
|
||||
break
|
||||
except Exception:
|
||||
await asyncio.sleep(1)
|
||||
else:
|
||||
print(" [✗] 等待发布页超时")
|
||||
return False
|
||||
|
||||
await asyncio.sleep(5)
|
||||
|
||||
# 4. 关闭可能的弹窗(多次尝试)
|
||||
for _ in range(3):
|
||||
await dismiss_popups(page)
|
||||
await asyncio.sleep(1)
|
||||
|
||||
# 5. 填写标题(使用 placeholder 选择器,更稳定)
|
||||
title_filled = False
|
||||
title_sel = 'input[placeholder*="标题"]'
|
||||
try:
|
||||
title_el = page.locator(title_sel).first
|
||||
if await title_el.count() and await title_el.is_visible():
|
||||
await title_el.click()
|
||||
await title_el.fill(title[:30])
|
||||
title_filled = True
|
||||
except Exception:
|
||||
pass
|
||||
if not title_filled:
|
||||
try:
|
||||
title_el = page.locator("input.semi-input").first
|
||||
await title_el.click()
|
||||
await title_el.fill(title[:30])
|
||||
title_filled = True
|
||||
except Exception as e:
|
||||
print(f" [!] 标题填写失败: {e}")
|
||||
if title_filled:
|
||||
print(" [✓] 标题已填写")
|
||||
|
||||
# 6. 填写描述和话题(在 .zone-container / .notranslate 区域)
|
||||
desc_zone = page.locator(".zone-container")
|
||||
try:
|
||||
await desc_zone.click()
|
||||
await asyncio.sleep(0.3)
|
||||
# 先清空
|
||||
await page.keyboard.press("Meta+KeyA")
|
||||
await page.keyboard.press("Delete")
|
||||
await asyncio.sleep(0.2)
|
||||
# 写入标题全文(含话题标签)作为描述
|
||||
await page.keyboard.type(title, delay=20)
|
||||
await asyncio.sleep(0.5)
|
||||
except Exception as e:
|
||||
print(f" [!] 描述填写异常: {e}")
|
||||
# 关闭话题下拉
|
||||
await page.keyboard.press("Escape")
|
||||
await asyncio.sleep(0.3)
|
||||
await page.mouse.click(10, 10)
|
||||
await asyncio.sleep(0.5)
|
||||
print(" [✓] 描述和话题已填写")
|
||||
|
||||
# 7. 等待视频上传完毕
|
||||
for _ in range(180):
|
||||
try:
|
||||
n = await page.locator(
|
||||
'[class^="long-card"] div:has-text("重新上传")'
|
||||
).count()
|
||||
if n > 0:
|
||||
print(" [✓] 视频上传完毕")
|
||||
break
|
||||
if await page.locator(
|
||||
'div.progress-div > div:has-text("上传失败")'
|
||||
).count():
|
||||
print(" [!] 上传失败,重试")
|
||||
await page.locator(
|
||||
'div.progress-div [class^="upload-btn-input"]'
|
||||
).set_input_files(video_path)
|
||||
except Exception:
|
||||
pass
|
||||
await asyncio.sleep(2)
|
||||
else:
|
||||
print(" [✗] 视频上传超时(6分钟)")
|
||||
|
||||
# 8. 设置封面(选择智能推荐的第一个)
|
||||
await set_cover(page)
|
||||
|
||||
# 9. 再次关闭弹窗
|
||||
await dismiss_popups(page)
|
||||
|
||||
# 10. 定时发布
|
||||
if publish_date:
|
||||
try:
|
||||
label = page.locator("[class^='radio']:has-text('定时发布')")
|
||||
await label.click()
|
||||
await asyncio.sleep(1)
|
||||
date_str = publish_date.strftime("%Y-%m-%d %H:%M")
|
||||
date_input = page.locator('.semi-input[placeholder="日期和时间"]')
|
||||
await date_input.click()
|
||||
await asyncio.sleep(0.5)
|
||||
# 使用 Meta+A(macOS 全选)清空
|
||||
await page.keyboard.press("Meta+KeyA")
|
||||
await page.keyboard.type(date_str)
|
||||
await page.keyboard.press("Enter")
|
||||
await asyncio.sleep(1)
|
||||
# 点击空白处关闭日历
|
||||
await page.mouse.click(10, 10)
|
||||
await asyncio.sleep(0.5)
|
||||
print(f" [✓] 定时发布设置: {date_str}")
|
||||
except Exception as e:
|
||||
print(f" [!] 定时设置失败: {e},将立即发布")
|
||||
|
||||
# 11. 同步到今日头条/西瓜
|
||||
try:
|
||||
third = '[class^="info"] > [class^="first-part"] div div.semi-switch'
|
||||
if await page.locator(third).count():
|
||||
cls = await page.eval_on_selector(third, "div => div.className")
|
||||
if "semi-switch-checked" not in cls:
|
||||
await page.locator(third).locator(
|
||||
"input.semi-switch-native-control"
|
||||
).click()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 12. 最终关闭所有弹窗再发布
|
||||
await dismiss_popups(page)
|
||||
await asyncio.sleep(1)
|
||||
|
||||
# 13. 点击发布
|
||||
published = False
|
||||
for attempt in range(20):
|
||||
try:
|
||||
if attempt < 2:
|
||||
ss_path = Path(__file__).parent / f"pre_publish_{idx}_a{attempt}.png"
|
||||
await page.screenshot(path=str(ss_path), full_page=True)
|
||||
print(f" [i] 截图: {ss_path.name}")
|
||||
|
||||
btn = page.get_by_role("button", name="发布", exact=True)
|
||||
btn_count = await btn.count()
|
||||
if btn_count:
|
||||
await btn.scroll_into_view_if_needed()
|
||||
await asyncio.sleep(0.3)
|
||||
await btn.click()
|
||||
print(f" [i] 已点击发布 (attempt {attempt+1})")
|
||||
try:
|
||||
await page.wait_for_url(
|
||||
"https://creator.douyin.com/creator-micro/content/manage**",
|
||||
timeout=8000,
|
||||
)
|
||||
print(" [✓] 视频发布成功!")
|
||||
published = True
|
||||
break
|
||||
except Exception:
|
||||
cur = page.url
|
||||
print(f" [i] 未跳转,URL: {cur}")
|
||||
sms_count = await page.get_by_text("验证码").count()
|
||||
if sms_count:
|
||||
print(" [!] 需要短信验证,截图保存")
|
||||
ss = Path(__file__).parent / f"sms_{idx}.png"
|
||||
await page.screenshot(path=str(ss), full_page=True)
|
||||
except Exception as e:
|
||||
print(f" [!] 异常: {e}")
|
||||
|
||||
# 处理封面弹窗
|
||||
try:
|
||||
if await page.get_by_text("请设置封面后再发布").first.is_visible():
|
||||
print(" [i] 需要封面,自动选择")
|
||||
await set_cover(page)
|
||||
except Exception:
|
||||
pass
|
||||
# 关闭其他弹窗
|
||||
await dismiss_popups(page)
|
||||
await asyncio.sleep(3)
|
||||
|
||||
if not published:
|
||||
print(" [!] 发布超时")
|
||||
ss_path = Path(__file__).parent / f"timeout_{idx}.png"
|
||||
await page.screenshot(path=str(ss_path), full_page=True)
|
||||
print(f" 截图: {ss_path}")
|
||||
|
||||
# 保存更新后的 cookie
|
||||
await context.storage_state(path=str(COOKIE_FILE))
|
||||
|
||||
except Exception as e:
|
||||
print(f" [✗] 异常: {e}")
|
||||
ss_path = Path(__file__).parent / f"error_{idx}.png"
|
||||
try:
|
||||
await page.screenshot(path=str(ss_path), full_page=True)
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
finally:
|
||||
await context.close()
|
||||
await browser.close()
|
||||
|
||||
return True
|
||||
|
||||
|
||||
# ── 主流程 ──────────────────────────────────────────────
|
||||
async def main():
|
||||
# 1. 确保 Cookie
|
||||
ok = await ensure_cookie()
|
||||
if not ok:
|
||||
print("[✗] 无法获取有效 Cookie,退出")
|
||||
return 1
|
||||
|
||||
# 2. 收集视频列表
|
||||
videos = sorted(VIDEO_DIR.glob("*.mp4"))
|
||||
if not videos:
|
||||
print("[✗] 未找到任何 mp4 文件")
|
||||
return 1
|
||||
print(f"\n[i] 共发现 {len(videos)} 条视频,准备批量发布\n")
|
||||
|
||||
# 3. 计算定时发布时间
|
||||
now = datetime.now()
|
||||
base_time = now + timedelta(hours=START_OFFSET_HOURS)
|
||||
# 对齐到整点
|
||||
base_time = base_time.replace(minute=0, second=0, microsecond=0) + timedelta(hours=1)
|
||||
|
||||
results = []
|
||||
for i, vp in enumerate(videos, 1):
|
||||
title = get_title(vp.name)
|
||||
tags = parse_tags_from_title(title)
|
||||
pub_time = base_time + timedelta(hours=(i - 1) * HOUR_INTERVAL)
|
||||
|
||||
ok = await upload_one(
|
||||
video_path=str(vp),
|
||||
title=title,
|
||||
tags=tags,
|
||||
publish_date=pub_time,
|
||||
idx=i,
|
||||
total=len(videos),
|
||||
)
|
||||
results.append((vp.name, ok, pub_time))
|
||||
|
||||
if i < len(videos):
|
||||
print(" ⏳ 等待 10 秒后处理下一条...")
|
||||
await asyncio.sleep(10)
|
||||
|
||||
# 4. 汇总
|
||||
print("\n" + "=" * 60)
|
||||
print(" 发布汇总")
|
||||
print("=" * 60)
|
||||
for name, ok, t in results:
|
||||
status = "✓" if ok else "✗"
|
||||
print(f" [{status}] {t.strftime('%m-%d %H:%M')} | {name}")
|
||||
success = sum(1 for _, ok, _ in results if ok)
|
||||
print(f"\n 成功: {success}/{len(results)}")
|
||||
print("=" * 60)
|
||||
|
||||
return 0 if success == len(results) else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(asyncio.run(main()))
|
||||
1
03_卡木(木)/木叶_视频内容/抖音发布/脚本/douyin_cookie.txt
Normal file
1
03_卡木(木)/木叶_视频内容/抖音发布/脚本/douyin_cookie.txt
Normal file
@@ -0,0 +1 @@
|
||||
bd_ticket_guard_client_web_domain=2; passport_csrf_token=7c77660e1f88141e7e90b71d7e32ad0b; passport_csrf_token_default=7c77660e1f88141e7e90b71d7e32ad0b; enter_pc_once=1; UIFID_TEMP=08f4fe5163774c2300555b455fb414e93ca9fbb91678792eb055c0a8974f001d75b51090fcb52c4aaaf9073dc832c6dd4dc3de49b908072111108b86524fe9c74b5a387ca37e4f4839735270d224cafe; bd_ticket_guard_client_data_v2=eyJyZWVfcHVibGljX2tleSI6IkJJdjZnajZiZjBpM29qRW5teWZwOGhqS1ZzRUU4a0lBK1JhR00ycTg4alRQdCtkdkNwcllYNytWb3VJa0k1R2ZWNzJhNDFqNERHZmZ1ZjVmdzhWSnhmQT0iLCJyZXFfY29udGVudCI6InNlY190cyIsInJlcV9zaWduIjoieG1HQ0taTmU5U2dlNkdqaDBlTDE0UFVOUDlYbktOeDhlTzZXUkh5dnZYQT0iLCJzZWNfdHMiOiIjUFlpN0lMZTdjRERuK09KSk02V05HNDErdFJqT1RaSEVnUkxqWkZWY0ZibGdFNWVFem51bm43bkZZQUJ6In0%3D; gfkadpd=2906,33638; _tea_utm_cache_2906=undefined; csrf_session_id=6b09d5b10c4ab498c588892c745a109c; biz_trace_id=45d6e984; passport_assist_user=CjzV-fLLRY-_ejIbeLJs90LfRNLuZvbI0xfqJF0GcZOZpX0hTcWo0kM00noByoHqEe1tfarXTWR6xuZvnIAaSgo8AAAAAAAAAAAAAFApNKm_uePs4rUqvWqP9f6CCWCAV30CZ9yI7jKc732ca5xgSRkDKFG6MrndTOgNN-kcEITRiw4Yia_WVCABIgEDRk9Xvg%3D%3D; _bd_ticket_crypt_doamin=2; _bd_ticket_crypt_cookie=7356de5b4441a9b6a8bdebc21d2974e9; __security_mc_1_s_sdk_sign_data_key_web_protect=dfc9fff6-410f-8e60; __security_mc_1_s_sdk_cert_key=e39ec87f-4583-ad3e; __security_mc_1_s_sdk_crypt_sdk=3aeacc1d-4bd6-aa35; __security_server_data_status=1; x-web-secsdk-uid=13353cdf-41f2-4dd2-bbc5-ddf08884fd66; bd_ticket_guard_client_data=eyJiZC10aWNrZXQtZ3VhcmQtdmVyc2lvbiI6MiwiYmQtdGlja2V0LWd1YXJkLWl0ZXJhdGlvbi12ZXJzaW9uIjoxLCJiZC10aWNrZXQtZ3VhcmQtcmVlLXB1YmxpYy1rZXkiOiJCSXY2Z2o2YmYwaTNvakVubXlmcDhoaktWc0VFOGtJQStSYUdNMnE4OGpUUHQrZHZDcHJZWDcrVm91SWtJNUdmVjcyYTQxajRER2ZmdWY1Znc4Vkp4ZkE9IiwiYmQtdGlja2V0LWd1YXJkLXdlYi12ZXJzaW9uIjoyfQ%3D%3D; passport_fe_beating_status=true
|
||||
380
03_卡木(木)/木叶_视频内容/抖音发布/脚本/douyin_hybrid_publish.py
Normal file
380
03_卡木(木)/木叶_视频内容/抖音发布/脚本/douyin_hybrid_publish.py
Normal file
@@ -0,0 +1,380 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
抖音视频批量发布 — 混合方案 v2
|
||||
- Playwright 加载页面 + set_input_files 上传视频
|
||||
- JS hook 从 transend/enable URL 捕获 video_id
|
||||
- page.evaluate(fetch) 调用 create_v2 发布
|
||||
- 回退方案:浏览器点击发布
|
||||
- 命令行全自动
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import string
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from playwright.async_api import async_playwright
|
||||
|
||||
SCRIPT_DIR = Path(__file__).parent
|
||||
COOKIE_FILE = SCRIPT_DIR / "douyin_storage_state.json"
|
||||
STEALTH_JS = Path("/Users/karuo/Documents/开发/3、自营项目/万推/backend/utils/stealth.min.js")
|
||||
CHROME = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
|
||||
VIDEO_DIR = Path("/Users/karuo/Movies/soul视频/soul 派对 119场 20260309_output/成片")
|
||||
|
||||
TITLES = {
|
||||
"早起不是为了开派对,是不吵老婆睡觉.mp4":
|
||||
"早起不是为了开派对,是不吵老婆睡觉。初衷就这一个。#Soul派对 #创业日记 #晨间直播 #私域干货",
|
||||
"懒人的活法 动作简单有利可图正反馈.mp4":
|
||||
"懒有懒的活法:动作简单、有利可图、正反馈,就能坐得住。#Soul派对 #副业 #私域 #切片变现",
|
||||
"初期团队先找两个IS,比钱好使 ENFJ链接人,ENTJ指挥.mp4":
|
||||
"初期团队先找两个IS,比钱好使。ENFJ链接人,ENTJ指挥。#MBTI #创业团队 #Soul派对",
|
||||
"ICU出来一年多 活着要在互联网上留下东西.mp4":
|
||||
"ICU出来一年多,活着要在互联网上留下东西。#人生感悟 #创业 #Soul派对 #记录生活",
|
||||
"MBTI疗愈SOUL 年轻人测MBTI,40到60岁走五行八卦.mp4":
|
||||
"年轻人测MBTI,40到60岁走五行八卦。#MBTI #Soul派对 #五行 #疗愈",
|
||||
"Soul业务模型 派对+切片+小程序全链路.mp4":
|
||||
"Soul业务模型:派对+切片+小程序全链路。#Soul派对 #商业模式 #私域运营 #小程序",
|
||||
"Soul切片30秒到8分钟 AI半小时能剪10到30个.mp4":
|
||||
"Soul切片30秒到8分钟,AI半小时能剪10到30个。#AI剪辑 #Soul派对 #切片变现 #效率工具",
|
||||
"刷牙听业务逻辑 Soul切片变现怎么跑.mp4":
|
||||
"刷牙听业务逻辑:Soul切片变现怎么跑。#Soul派对 #切片变现 #副业 #商业逻辑",
|
||||
"国学易经怎么学 两小时七七八八,召唤作者对话.mp4":
|
||||
"国学易经怎么学?两小时七七八八,召唤作者对话。#国学 #易经 #Soul派对 #学习方法",
|
||||
"广点通能投Soul了,1000曝光6到10块.mp4":
|
||||
"广点通能投Soul了,1000曝光6到10块。#Soul派对 #广点通 #流量投放 #私域获客",
|
||||
"建立信任不是求来的 卖外挂发邮件三个月拿下德国总代.mp4":
|
||||
"建立信任不是求来的。卖外挂发邮件三个月拿下德国总代。#销售 #信任 #Soul派对 #商业故事",
|
||||
"核心就两个字 筛选。能开派对坚持7天的人再谈.mp4":
|
||||
"核心就两个字:筛选。能开派对坚持7天的人再谈。#筛选 #Soul派对 #创业 #坚持",
|
||||
"睡眠不好?每天放下一件事,做减法.mp4":
|
||||
"睡眠不好?每天放下一件事,做减法。#睡眠 #减法 #Soul派对 #生活方式",
|
||||
"这套体系花了170万,但前端几十块就能参与.mp4":
|
||||
"这套体系花了170万,但前端几十块就能参与。#商业体系 #Soul派对 #私域 #低成本创业",
|
||||
"金融AI获客体系 后端30人沉淀12年,前端丢手机.mp4":
|
||||
"金融AI获客体系:后端30人沉淀12年,前端丢手机。#AI获客 #金融 #Soul派对 #商业模式",
|
||||
}
|
||||
|
||||
HOOK_JS = r"""
|
||||
(function() {
|
||||
if (window.__dy_hook_installed) return;
|
||||
window.__dy_hook_installed = true;
|
||||
window.__dy_video_ids = [];
|
||||
window.__dy_responses = [];
|
||||
|
||||
const _origFetch = window.fetch;
|
||||
window.fetch = async function(...args) {
|
||||
const resp = await _origFetch.apply(this, args);
|
||||
try {
|
||||
const url = typeof args[0] === 'string' ? args[0] : (args[0] && args[0].url) || '';
|
||||
const urlVidMatch = url.match(/video_id=(v0[a-zA-Z0-9]+)/);
|
||||
if (urlVidMatch && !window.__dy_video_ids.includes(urlVidMatch[1])) {
|
||||
window.__dy_video_ids.push(urlVidMatch[1]);
|
||||
console.log('[HOOK] video_id: ' + urlVidMatch[1]);
|
||||
}
|
||||
if (url.includes('vod.bytedance') || url.includes('video/enable') ||
|
||||
url.includes('video/transend') || url.includes('ApplyUpload') ||
|
||||
url.includes('CommitUpload')) {
|
||||
const clone = resp.clone();
|
||||
const text = await clone.text();
|
||||
window.__dy_responses.push({ url: url.substring(0, 500), body: text.substring(0, 2000) });
|
||||
const vidMatch = text.match(/"(?:video_id|VideoId)":\s*"(v0[a-zA-Z0-9]+)"/);
|
||||
if (vidMatch && !window.__dy_video_ids.includes(vidMatch[1])) {
|
||||
window.__dy_video_ids.push(vidMatch[1]);
|
||||
}
|
||||
}
|
||||
} catch(e) {}
|
||||
return resp;
|
||||
};
|
||||
})();
|
||||
"""
|
||||
|
||||
|
||||
def get_title(filename: str) -> str:
|
||||
return TITLES.get(filename, f"{Path(filename).stem} #Soul派对 #创业日记 #卡若创业派对")
|
||||
|
||||
|
||||
def random_creation_id() -> str:
|
||||
chars = string.ascii_lowercase + string.digits
|
||||
return "".join(random.choices(chars, k=8)) + str(int(time.time() * 1000))
|
||||
|
||||
|
||||
async def publish_one(context, video_path: str, title: str, timing_ts: int, idx: int, total: int) -> bool:
|
||||
page = await context.new_page()
|
||||
timing_str = datetime.fromtimestamp(timing_ts).strftime("%Y-%m-%d %H:%M") if timing_ts > 0 else "立即"
|
||||
filename = Path(video_path).name
|
||||
|
||||
print(f"\n{'='*60}")
|
||||
print(f" [{idx}/{total}] {filename}")
|
||||
print(f" 标题: {title[:60]}")
|
||||
print(f" 定时: {timing_str}")
|
||||
print(f"{'='*60}")
|
||||
|
||||
try:
|
||||
# 1. 打开上传页
|
||||
print(f" [1] 打开上传页...")
|
||||
await page.goto("https://creator.douyin.com/creator-micro/content/upload",
|
||||
wait_until="domcontentloaded", timeout=60000)
|
||||
await page.wait_for_url("**/upload**", timeout=60000)
|
||||
await page.wait_for_load_state("load", timeout=20000)
|
||||
|
||||
if await page.get_by_text("手机号登录").count() or await page.get_by_text("扫码登录").count():
|
||||
print(f" [!] Cookie 失效")
|
||||
return False
|
||||
await asyncio.sleep(3)
|
||||
|
||||
# 2. 上传
|
||||
print(f" [2] 上传视频...")
|
||||
loc = page.locator("input[type='file']").first
|
||||
await loc.wait_for(state="attached", timeout=10000)
|
||||
await loc.set_input_files(video_path, timeout=60000)
|
||||
print(f" [2] OK")
|
||||
|
||||
# 3. 等待发布页
|
||||
for _ in range(120):
|
||||
if "publish" in page.url or "post/video" in page.url:
|
||||
break
|
||||
await asyncio.sleep(1)
|
||||
print(f" [3] 发布页就绪")
|
||||
|
||||
await asyncio.sleep(5)
|
||||
|
||||
# 4. 等待 video_id(hook 已通过 add_init_script 注入)
|
||||
print(f" [4] 等待 video_id + 转码...")
|
||||
video_id = None
|
||||
for i in range(180):
|
||||
vids = await page.evaluate("window.__dy_video_ids || []")
|
||||
if vids:
|
||||
video_id = vids[-1]
|
||||
print(f" [4] ✓ video_id: {video_id}")
|
||||
break
|
||||
if i % 20 == 0 and i > 0:
|
||||
print(f" ...{i}s")
|
||||
await asyncio.sleep(1)
|
||||
|
||||
if not video_id:
|
||||
# 从捕获的响应 URL 中提取
|
||||
resps = await page.evaluate("window.__dy_responses || []")
|
||||
for r in resps:
|
||||
import re
|
||||
m = re.search(r'video_id=(v0[a-zA-Z0-9]+)', r.get('url', ''))
|
||||
if m:
|
||||
video_id = m.group(1)
|
||||
print(f" [4] ✓ video_id (from response URL): {video_id}")
|
||||
break
|
||||
|
||||
# 等待转码完成(再等一会)
|
||||
if video_id:
|
||||
await asyncio.sleep(10)
|
||||
|
||||
# 5. 获取 poster
|
||||
poster = await page.evaluate(r"""
|
||||
() => {
|
||||
const imgs = document.querySelectorAll('img[src*="tos-cn-i-"]');
|
||||
for (const img of imgs) {
|
||||
const match = img.src.match(/(tos-cn-i-[a-zA-Z0-9]+\/[a-f0-9]+)/);
|
||||
if (match) return match[1];
|
||||
}
|
||||
return '';
|
||||
}
|
||||
""")
|
||||
|
||||
# 6. 发布
|
||||
if video_id:
|
||||
print(f" [5] 通过 fetch 调用 create_v2...")
|
||||
creation_id = random_creation_id()
|
||||
body = {
|
||||
"item": {
|
||||
"common": {
|
||||
"text": title,
|
||||
"caption": title,
|
||||
"item_title": "",
|
||||
"activity": "[]",
|
||||
"text_extra": "[]",
|
||||
"challenges": "[]",
|
||||
"mentions": "[]",
|
||||
"hashtag_source": "",
|
||||
"hot_sentence": "",
|
||||
"interaction_stickers": "[]",
|
||||
"visibility_type": 0,
|
||||
"download": 1,
|
||||
"timing": timing_ts if timing_ts > 0 else 0,
|
||||
"creation_id": creation_id,
|
||||
"media_type": 4,
|
||||
"video_id": video_id,
|
||||
"music_source": 0,
|
||||
"music_id": None,
|
||||
},
|
||||
"cover": {
|
||||
"custom_cover_image_height": 0,
|
||||
"custom_cover_image_width": 0,
|
||||
"poster": poster or "",
|
||||
"poster_delay": 0,
|
||||
},
|
||||
}
|
||||
}
|
||||
body_json = json.dumps(body, ensure_ascii=False)
|
||||
|
||||
result = await page.evaluate(f"""
|
||||
async () => {{
|
||||
try {{
|
||||
const resp = await fetch('/web/api/media/aweme/create_v2/', {{
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: {{
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json, text/plain, */*',
|
||||
}},
|
||||
body: JSON.stringify({body_json}),
|
||||
}});
|
||||
const text = await resp.text();
|
||||
return {{ status: resp.status, body: text.substring(0, 3000) }};
|
||||
}} catch(e) {{
|
||||
return {{ error: e.message }};
|
||||
}}
|
||||
}}
|
||||
""")
|
||||
|
||||
resp_body = result.get("body", "")
|
||||
if resp_body:
|
||||
try:
|
||||
parsed = json.loads(resp_body)
|
||||
if parsed.get("status_code") == 0:
|
||||
print(f" [✓] API 发布成功!")
|
||||
return True
|
||||
else:
|
||||
print(f" [!] API 返回: {parsed.get('status_msg', resp_body[:100])}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
print(f" [!] API 发布失败,回退到浏览器...")
|
||||
|
||||
# 回退:浏览器直接发布
|
||||
return await _browser_publish(page, title, timing_ts)
|
||||
|
||||
except Exception as e:
|
||||
print(f" [!] 异常: {e}")
|
||||
try:
|
||||
return await _browser_publish(page, title, timing_ts)
|
||||
except Exception as e2:
|
||||
print(f" [!] 回退也失败: {e2}")
|
||||
return False
|
||||
finally:
|
||||
await page.close()
|
||||
|
||||
|
||||
async def _browser_publish(page, title: str, timing_ts: int) -> bool:
|
||||
try:
|
||||
# 填标题
|
||||
nl = page.locator(".notranslate").first
|
||||
await nl.click(timeout=5000)
|
||||
await page.keyboard.press("Meta+KeyA")
|
||||
await page.keyboard.press("Delete")
|
||||
await page.keyboard.type(title[:50], delay=20)
|
||||
await asyncio.sleep(2)
|
||||
|
||||
for attempt in range(8):
|
||||
try:
|
||||
pub = page.get_by_role("button", name="发布", exact=True)
|
||||
if await pub.count():
|
||||
await pub.click()
|
||||
print(f" 点击发布 (attempt {attempt+1})")
|
||||
|
||||
await page.wait_for_url("**/content/manage**", timeout=12000)
|
||||
print(f" [✓] 浏览器发布成功!")
|
||||
return True
|
||||
except Exception:
|
||||
try:
|
||||
if await page.get_by_text("请设置封面后再发布").first.is_visible():
|
||||
covers = page.locator('[class*="recommendCover"], [class*="cover-select"]')
|
||||
if await covers.count() > 0:
|
||||
await covers.first.click()
|
||||
await asyncio.sleep(2)
|
||||
confirm = page.get_by_role("button", name="确定")
|
||||
if await confirm.count():
|
||||
await confirm.click()
|
||||
await asyncio.sleep(2)
|
||||
continue
|
||||
except Exception:
|
||||
pass
|
||||
await asyncio.sleep(3)
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f" [!] 浏览器发布异常: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def main():
|
||||
if not COOKIE_FILE.exists():
|
||||
print("[!] Cookie 不存在,请先运行 douyin_login.py")
|
||||
return 1
|
||||
|
||||
videos = sorted(VIDEO_DIR.glob("*.mp4"))
|
||||
if not videos:
|
||||
print("[!] 未找到视频")
|
||||
return 1
|
||||
print(f"[i] 共 {len(videos)} 条视频")
|
||||
|
||||
now_ts = int(time.time())
|
||||
base_ts = ((now_ts + 3600) // 3600 + 1) * 3600
|
||||
|
||||
schedule = []
|
||||
for i, vp in enumerate(videos):
|
||||
ts = base_ts + i * 3600
|
||||
title = get_title(vp.name)
|
||||
schedule.append((vp, title, ts))
|
||||
dt_str = datetime.fromtimestamp(ts).strftime("%m-%d %H:%M")
|
||||
print(f" {i+1:2d}. {dt_str} | {vp.name[:50]}")
|
||||
|
||||
print(f"\n[i] 启动浏览器...")
|
||||
async with async_playwright() as pw:
|
||||
browser = await pw.chromium.launch(
|
||||
headless=False,
|
||||
executable_path=CHROME if os.path.exists(CHROME) else None,
|
||||
)
|
||||
context = await browser.new_context(storage_state=str(COOKIE_FILE))
|
||||
if STEALTH_JS.exists():
|
||||
await context.add_init_script(path=str(STEALTH_JS))
|
||||
await context.add_init_script(script=HOOK_JS)
|
||||
|
||||
# 验证 Cookie
|
||||
check = await context.new_page()
|
||||
await check.goto("https://creator.douyin.com/creator-micro/home",
|
||||
wait_until="domcontentloaded", timeout=30000)
|
||||
if await check.get_by_text("手机号登录").count() or await check.get_by_text("扫码登录").count():
|
||||
print("[!] Cookie 失效")
|
||||
await browser.close()
|
||||
return 1
|
||||
await check.close()
|
||||
print("[✓] Cookie 有效\n")
|
||||
|
||||
results = []
|
||||
for i, (vp, title, ts) in enumerate(schedule):
|
||||
ok = await publish_one(context, str(vp), title, ts, i + 1, len(schedule))
|
||||
results.append((vp.name, ok, ts))
|
||||
await context.storage_state(path=str(COOKIE_FILE))
|
||||
|
||||
if i < len(schedule) - 1 and ok:
|
||||
print(f" 等待 10s...")
|
||||
await asyncio.sleep(10)
|
||||
|
||||
await context.close()
|
||||
await browser.close()
|
||||
|
||||
print(f"\n{'='*60}")
|
||||
print(" 发布汇总")
|
||||
print(f"{'='*60}")
|
||||
for name, ok, ts in results:
|
||||
s = "✓" if ok else "✗"
|
||||
t = datetime.fromtimestamp(ts).strftime("%m-%d %H:%M")
|
||||
print(f" [{s}] {t} | {name}")
|
||||
success = sum(1 for _, ok, _ in results if ok)
|
||||
print(f"\n 成功: {success}/{len(results)}")
|
||||
return 0 if success == len(results) else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(asyncio.run(main()))
|
||||
37
03_卡木(木)/木叶_视频内容/抖音发布/脚本/douyin_login.py
Normal file
37
03_卡木(木)/木叶_视频内容/抖音发布/脚本/douyin_login.py
Normal file
@@ -0,0 +1,37 @@
|
||||
#!/usr/bin/env python3
|
||||
"""获取抖音 Cookie - 弹窗浏览器 → 扫码登录 → 保存 storage_state"""
|
||||
import asyncio
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
WANTUI = Path("/Users/karuo/Documents/开发/3、自营项目/万推/backend")
|
||||
sys.path.insert(0, str(WANTUI))
|
||||
|
||||
from playwright.async_api import async_playwright
|
||||
from utils.base_social_media import set_init_script
|
||||
|
||||
COOKIE_FILE = Path(__file__).parent / "douyin_storage_state.json"
|
||||
|
||||
|
||||
async def main():
|
||||
print("即将弹出浏览器,请扫码登录抖音创作者中心。")
|
||||
print("登录成功后,在 Playwright Inspector 窗口中点击绿色 ▶ 按钮。\n")
|
||||
|
||||
async with async_playwright() as pw:
|
||||
browser = await pw.chromium.launch(headless=False)
|
||||
context = await browser.new_context()
|
||||
context = await set_init_script(context)
|
||||
page = await context.new_page()
|
||||
await page.goto("https://creator.douyin.com/", timeout=60000)
|
||||
await page.pause()
|
||||
await context.storage_state(path=str(COOKIE_FILE))
|
||||
await context.close()
|
||||
await browser.close()
|
||||
|
||||
print(f"\n[✓] Cookie 已保存到: {COOKIE_FILE}")
|
||||
print(f" 文件大小: {COOKIE_FILE.stat().st_size} bytes")
|
||||
print("现在可以运行 douyin_batch_publish.py 批量发布了。")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
112
03_卡木(木)/木叶_视频内容/抖音发布/脚本/douyin_oauth_then_publish.py
Normal file
112
03_卡木(木)/木叶_视频内容/抖音发布/脚本/douyin_oauth_then_publish.py
Normal file
@@ -0,0 +1,112 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
抖音 OAuth 授权后立即发布 119 场成片。
|
||||
需设置环境变量:DOUYIN_CLIENT_KEY、DOUYIN_CLIENT_SECRET;
|
||||
可选 DOUYIN_REDIRECT_URI(默认 http://127.0.0.1:8765/callback,需在开放平台应用里配置同值)。
|
||||
会打开浏览器,用户登录抖音并点击授权后,自动保存 token 并执行批量发布。
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import webbrowser
|
||||
from http.server import HTTPServer, BaseHTTPRequestHandler
|
||||
from pathlib import Path
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
try:
|
||||
import requests
|
||||
except ImportError:
|
||||
print("请安装 requests: pip install requests", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
REDIRECT_PORT = 8765
|
||||
REDIRECT_URI = os.environ.get("DOUYIN_REDIRECT_URI") or f"http://127.0.0.1:{REDIRECT_PORT}/callback"
|
||||
TOKEN_URL = "https://open.douyin.com/oauth/access_token/"
|
||||
|
||||
|
||||
def get_client_creds():
|
||||
# 支持从 .env 读取(若存在 python-dotenv)
|
||||
try:
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv(SCRIPT_DIR / ".env")
|
||||
except ImportError:
|
||||
pass
|
||||
key = os.environ.get("DOUYIN_CLIENT_KEY")
|
||||
secret = os.environ.get("DOUYIN_CLIENT_SECRET")
|
||||
return key, secret
|
||||
|
||||
|
||||
class CallbackHandler(BaseHTTPRequestHandler):
|
||||
code = None
|
||||
|
||||
def do_GET(self):
|
||||
parsed = urlparse(self.path)
|
||||
if parsed.path == "/callback":
|
||||
qs = parse_qs(parsed.query)
|
||||
CallbackHandler.code = qs.get("code", [None])[0]
|
||||
self.send_response(200)
|
||||
self.send_header("Content-type", "text/html; charset=utf-8")
|
||||
self.end_headers()
|
||||
if CallbackHandler.code:
|
||||
body = "<html><body><p>授权成功,正在保存 token 并发布视频...</p></body></html>"
|
||||
else:
|
||||
body = "<html><body><p>未收到 code,请关闭此页。</p></body></html>"
|
||||
self.wfile.write(body.encode("utf-8"))
|
||||
|
||||
def log_message(self, format, *args):
|
||||
pass
|
||||
|
||||
|
||||
def main():
|
||||
key, secret = get_client_creds()
|
||||
if not key or not secret:
|
||||
print("未配置 DOUYIN_CLIENT_KEY / DOUYIN_CLIENT_SECRET,无法执行 OAuth。", file=sys.stderr)
|
||||
print("可在脚本目录创建 .env 或设置环境变量后重试。", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
auth_url = (
|
||||
"https://open.douyin.com/platform/oauth/connect/"
|
||||
f"?client_key={key}&response_type=code&scope=user_info,video.create"
|
||||
f"&redirect_uri={requests.utils.quote(REDIRECT_URI)}&state=1"
|
||||
)
|
||||
|
||||
server = HTTPServer(("127.0.0.1", REDIRECT_PORT), CallbackHandler)
|
||||
thread = threading.Thread(target=server.handle_request)
|
||||
thread.start()
|
||||
webbrowser.open(auth_url)
|
||||
thread.join(timeout=120)
|
||||
server.server_close()
|
||||
|
||||
code = CallbackHandler.code
|
||||
if not code:
|
||||
print("未获取到授权码,请重试。", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
r = requests.post(
|
||||
TOKEN_URL,
|
||||
data={
|
||||
"client_key": key,
|
||||
"client_secret": secret,
|
||||
"code": code,
|
||||
"grant_type": "authorization_code",
|
||||
},
|
||||
timeout=10,
|
||||
)
|
||||
data = r.json()
|
||||
if data.get("data") is None:
|
||||
print("换 token 失败:", data, file=sys.stderr)
|
||||
sys.exit(1)
|
||||
d = data["data"]
|
||||
token_file = SCRIPT_DIR / "tokens.json"
|
||||
with open(token_file, "w", encoding="utf-8") as f:
|
||||
json.dump({"access_token": d["access_token"], "open_id": d["open_id"]}, f, ensure_ascii=False, indent=2)
|
||||
print("已保存 token 到 tokens.json,开始发布...")
|
||||
subprocess.run([sys.executable, str(SCRIPT_DIR / "batch_publish_119.py")], check=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
645
03_卡木(木)/木叶_视频内容/抖音发布/脚本/douyin_pure_api.py
Normal file
645
03_卡木(木)/木叶_视频内容/抖音发布/脚本/douyin_pure_api.py
Normal file
@@ -0,0 +1,645 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
抖音纯 API 视频发布(无浏览器)
|
||||
基于 CobWeb 思路:Cookie + ec_privateKey + ts_sign → bd-ticket-guard → create_v2
|
||||
|
||||
流程:
|
||||
1. 从 storage_state.json 加载 cookies + localStorage 密钥
|
||||
2. GET /web/api/media/upload/auth/v5/ → VOD 凭证
|
||||
3. GET vod.bytedanceapi.com ApplyUploadInner → 上传地址
|
||||
4. POST 分片上传视频
|
||||
5. POST vod.bytedanceapi.com CommitUploadInner → video_id
|
||||
6. POST /web/api/media/aweme/create_v2/ → 发布(bd-ticket-guard 签名)
|
||||
"""
|
||||
import asyncio
|
||||
import base64
|
||||
import datetime
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import string
|
||||
import sys
|
||||
import time
|
||||
import zlib
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlencode, quote
|
||||
|
||||
import httpx
|
||||
from cryptography.hazmat.primitives.asymmetric import ec
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography import x509
|
||||
|
||||
SCRIPT_DIR = Path(__file__).parent
|
||||
COOKIE_FILE = SCRIPT_DIR / "douyin_storage_state.json"
|
||||
VIDEO_DIR = Path("/Users/karuo/Movies/soul视频/soul 派对 119场 20260309_output/成片")
|
||||
|
||||
BASE = "https://creator.douyin.com"
|
||||
AUTH_URL = f"{BASE}/web/api/media/upload/auth/v5/"
|
||||
VOD_HOST = "https://vod.bytedanceapi.com"
|
||||
CREATE_V2_URL = f"{BASE}/web/api/media/aweme/create_v2/"
|
||||
USER_INFO_URL = f"{BASE}/web/api/media/user/info/"
|
||||
CHUNK_SIZE = 3 * 1024 * 1024
|
||||
|
||||
UA = (
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
|
||||
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36"
|
||||
)
|
||||
|
||||
TITLES = {
|
||||
"早起不是为了开派对,是不吵老婆睡觉.mp4":
|
||||
"早起不是为了开派对,是不吵老婆睡觉。初衷就这一个。#Soul派对 #创业日记 #晨间直播 #私域干货",
|
||||
"懒人的活法 动作简单有利可图正反馈.mp4":
|
||||
"懒有懒的活法:动作简单、有利可图、正反馈,就能坐得住。#Soul派对 #副业 #私域 #切片变现",
|
||||
"初期团队先找两个IS,比钱好使 ENFJ链接人,ENTJ指挥.mp4":
|
||||
"初期团队先找两个IS,比钱好使。ENFJ链接人,ENTJ指挥。#MBTI #创业团队 #Soul派对",
|
||||
"ICU出来一年多 活着要在互联网上留下东西.mp4":
|
||||
"ICU出来一年多,活着要在互联网上留下东西。#人生感悟 #创业 #Soul派对 #记录生活",
|
||||
"MBTI疗愈SOUL 年轻人测MBTI,40到60岁走五行八卦.mp4":
|
||||
"年轻人测MBTI,40到60岁走五行八卦。#MBTI #Soul派对 #五行 #疗愈",
|
||||
"Soul业务模型 派对+切片+小程序全链路.mp4":
|
||||
"Soul业务模型:派对+切片+小程序全链路。#Soul派对 #商业模式 #私域运营 #小程序",
|
||||
"Soul切片30秒到8分钟 AI半小时能剪10到30个.mp4":
|
||||
"Soul切片30秒到8分钟,AI半小时能剪10到30个。#AI剪辑 #Soul派对 #切片变现 #效率工具",
|
||||
"刷牙听业务逻辑 Soul切片变现怎么跑.mp4":
|
||||
"刷牙听业务逻辑:Soul切片变现怎么跑。#Soul派对 #切片变现 #副业 #商业逻辑",
|
||||
"国学易经怎么学 两小时七七八八,召唤作者对话.mp4":
|
||||
"国学易经怎么学?两小时七七八八,召唤作者对话。#国学 #易经 #Soul派对 #学习方法",
|
||||
"广点通能投Soul了,1000曝光6到10块.mp4":
|
||||
"广点通能投Soul了,1000曝光6到10块。#Soul派对 #广点通 #流量投放 #私域获客",
|
||||
"建立信任不是求来的 卖外挂发邮件三个月拿下德国总代.mp4":
|
||||
"建立信任不是求来的。卖外挂发邮件三个月拿下德国总代。#销售 #信任 #Soul派对 #商业故事",
|
||||
"核心就两个字 筛选。能开派对坚持7天的人再谈.mp4":
|
||||
"核心就两个字:筛选。能开派对坚持7天的人再谈。#筛选 #Soul派对 #创业 #坚持",
|
||||
"睡眠不好?每天放下一件事,做减法.mp4":
|
||||
"睡眠不好?每天放下一件事,做减法。#睡眠 #减法 #Soul派对 #生活方式",
|
||||
"这套体系花了170万,但前端几十块就能参与.mp4":
|
||||
"这套体系花了170万,但前端几十块就能参与。#商业体系 #Soul派对 #私域 #低成本创业",
|
||||
"金融AI获客体系 后端30人沉淀12年,前端丢手机.mp4":
|
||||
"金融AI获客体系:后端30人沉淀12年,前端丢手机。#AI获客 #金融 #Soul派对 #商业模式",
|
||||
}
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# Storage State 加载
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
class SecurityKeys:
|
||||
def __init__(self, state_path: Path):
|
||||
with open(state_path, "r", encoding="utf-8") as f:
|
||||
state = json.load(f)
|
||||
|
||||
self.cookies = self._extract_cookies(state)
|
||||
self.cookie_str = "; ".join(f"{k}={v}" for k, v in self.cookies.items())
|
||||
self.ms_token = ""
|
||||
self.ec_private_key = None
|
||||
self.ec_public_key_bytes = b""
|
||||
self.server_public_key = None
|
||||
self.ticket = ""
|
||||
self.ts_sign_raw = ""
|
||||
self.csrf_token = self.cookies.get("passport_csrf_token", "")
|
||||
|
||||
for origin in state.get("origins", []):
|
||||
if "creator.douyin.com" not in origin.get("origin", ""):
|
||||
continue
|
||||
for item in origin.get("localStorage", []):
|
||||
name, val = item["name"], item["value"]
|
||||
if "s_sdk_crypt_sdk" in name:
|
||||
d = json.loads(json.loads(val)["data"])
|
||||
pem = d["ec_privateKey"].replace("\\r\\n", "\n")
|
||||
self.ec_private_key = serialization.load_pem_private_key(
|
||||
pem.encode(), password=None
|
||||
)
|
||||
pub = self.ec_private_key.public_key()
|
||||
self.ec_public_key_bytes = pub.public_bytes(
|
||||
serialization.Encoding.X962,
|
||||
serialization.PublicFormat.UncompressedPoint,
|
||||
)
|
||||
elif "s_sdk_server_cert_key" in name:
|
||||
cert_pem = json.loads(val)["cert"]
|
||||
cert = x509.load_pem_x509_certificate(cert_pem.encode())
|
||||
self.server_public_key = cert.public_key()
|
||||
elif "s_sdk_sign_data_key" in name and "web_protect" in name:
|
||||
d = json.loads(json.loads(val)["data"])
|
||||
self.ticket = d["ticket"]
|
||||
self.ts_sign_raw = d["ts_sign"]
|
||||
elif name == "xmst":
|
||||
self.ms_token = val
|
||||
|
||||
@staticmethod
|
||||
def _extract_cookies(state: dict) -> dict:
|
||||
result = {}
|
||||
for c in state.get("cookies", []):
|
||||
if "douyin.com" in c.get("domain", ""):
|
||||
result[c["name"]] = c["value"]
|
||||
return result
|
||||
|
||||
def compute_ticket_guard(self, path: str) -> dict:
|
||||
"""计算 bd-ticket-guard 头 (ECDH + HMAC)"""
|
||||
if not self.ec_private_key or not self.server_public_key:
|
||||
return {}
|
||||
|
||||
eph_priv = ec.generate_private_key(ec.SECP256R1())
|
||||
eph_pub = eph_priv.public_key()
|
||||
eph_pub_bytes = eph_pub.public_bytes(
|
||||
serialization.Encoding.X962,
|
||||
serialization.PublicFormat.UncompressedPoint,
|
||||
)
|
||||
shared_secret = eph_priv.exchange(ec.ECDH(), self.server_public_key)
|
||||
|
||||
ts = int(time.time())
|
||||
|
||||
ts_hex = self.ts_sign_raw.replace("ts.2.", "")
|
||||
ts_bytes = bytes.fromhex(ts_hex)
|
||||
ts_first = ts_bytes[:32]
|
||||
ts_last = ts_bytes[32:]
|
||||
new_first = bytes(a ^ b for a, b in zip(ts_first, shared_secret[:32]))
|
||||
new_ts_sign = "ts.2." + (new_first + ts_last).hex()
|
||||
|
||||
msg = f"{self.ticket},{path},{ts}"
|
||||
req_sign = hmac.new(shared_secret, msg.encode(), hashlib.sha256).digest()
|
||||
|
||||
client_data = {
|
||||
"ts_sign": new_ts_sign,
|
||||
"req_content": "ticket,path,timestamp",
|
||||
"req_sign": base64.b64encode(req_sign).decode(),
|
||||
"timestamp": ts,
|
||||
}
|
||||
|
||||
return {
|
||||
"bd-ticket-guard-client-data": base64.b64encode(
|
||||
json.dumps(client_data).encode()
|
||||
).decode(),
|
||||
"bd-ticket-guard-ree-public-key": base64.b64encode(eph_pub_bytes).decode(),
|
||||
"bd-ticket-guard-version": "2",
|
||||
"bd-ticket-guard-web-version": "2",
|
||||
"bd-ticket-guard-web-sign-type": "1",
|
||||
}
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# AWS4-HMAC-SHA256 签名
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
def _hmac_sha256(key: bytes, msg: str) -> bytes:
|
||||
return hmac.new(key, msg.encode(), hashlib.sha256).digest()
|
||||
|
||||
|
||||
USER_ID = "95519194897"
|
||||
|
||||
|
||||
def aws4_sign(ak: str, sk: str, token: str, qs: str,
|
||||
method: str = "GET", body: bytes = b"") -> tuple:
|
||||
now = datetime.datetime.now(datetime.timezone.utc)
|
||||
amz_date = now.strftime("%Y%m%dT%H%M%SZ")
|
||||
ds = now.strftime("%Y%m%d")
|
||||
region, service = "cn-north-1", "vod"
|
||||
body_hash = hashlib.sha256(body).hexdigest()
|
||||
|
||||
if method == "POST":
|
||||
signed_headers = "content-type;x-amz-date;x-amz-security-token"
|
||||
header_str = (
|
||||
f"content-type:text/plain;charset=UTF-8\n"
|
||||
f"x-amz-date:{amz_date}\n"
|
||||
f"x-amz-security-token:{token}\n"
|
||||
)
|
||||
else:
|
||||
signed_headers = "x-amz-date;x-amz-security-token"
|
||||
header_str = (
|
||||
f"x-amz-date:{amz_date}\n"
|
||||
f"x-amz-security-token:{token}\n"
|
||||
)
|
||||
|
||||
canonical = f"{method}\n/\n{qs}\n{header_str}\n{signed_headers}\n{body_hash}"
|
||||
scope = f"{ds}/{region}/{service}/aws4_request"
|
||||
sts = f"AWS4-HMAC-SHA256\n{amz_date}\n{scope}\n{hashlib.sha256(canonical.encode()).hexdigest()}"
|
||||
|
||||
k = _hmac_sha256(f"AWS4{sk}".encode(), ds)
|
||||
k = _hmac_sha256(k, region)
|
||||
k = _hmac_sha256(k, service)
|
||||
k = _hmac_sha256(k, "aws4_request")
|
||||
sig = hmac.new(k, sts.encode(), hashlib.sha256).hexdigest()
|
||||
|
||||
auth = f"AWS4-HMAC-SHA256 Credential={ak}/{scope}, SignedHeaders={signed_headers}, Signature={sig}"
|
||||
return auth, amz_date, token
|
||||
|
||||
|
||||
def _rand(n=11):
|
||||
return "".join(random.choices(string.ascii_lowercase + string.digits, k=n))
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# Step 1: 获取上传凭证
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
async def get_upload_auth(client: httpx.AsyncClient, keys: SecurityKeys) -> dict:
|
||||
print(" [1] 获取上传凭证...")
|
||||
resp = await client.get(
|
||||
AUTH_URL, headers={"Cookie": keys.cookie_str, "User-Agent": UA}
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
if data.get("status_code") != 0:
|
||||
raise RuntimeError(f"auth 失败: {data}")
|
||||
auth = json.loads(data["auth"])
|
||||
print(f" AK={auth['AccessKeyID'][:15]}...")
|
||||
return {
|
||||
"ak": data["ak"],
|
||||
"access_key_id": auth["AccessKeyID"],
|
||||
"secret": auth["SecretAccessKey"],
|
||||
"token": auth["SessionToken"],
|
||||
}
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# Step 2: 获取上传地址
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
async def apply_upload(client: httpx.AsyncClient, auth: dict, file_size: int) -> dict:
|
||||
print(" [2] 获取上传地址...")
|
||||
params = {
|
||||
"Action": "ApplyUploadInner",
|
||||
"FileSize": str(file_size),
|
||||
"FileType": "video",
|
||||
"IsInner": "1",
|
||||
"SpaceName": "aweme",
|
||||
"Version": "2020-11-19",
|
||||
"app_id": "2906",
|
||||
"s": _rand(),
|
||||
}
|
||||
qs = "&".join(f"{k}={v}" for k, v in sorted(params.items()))
|
||||
authorization, amz_date, token = aws4_sign(
|
||||
auth["access_key_id"], auth["secret"], auth["token"], qs
|
||||
)
|
||||
url = f"{VOD_HOST}/?{qs}"
|
||||
resp = await client.get(
|
||||
url,
|
||||
headers={
|
||||
"authorization": authorization,
|
||||
"x-amz-date": amz_date,
|
||||
"x-amz-security-token": token,
|
||||
"User-Agent": UA,
|
||||
},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
result = data.get("Result") or {}
|
||||
|
||||
inner = result.get("InnerUploadAddress") or {}
|
||||
nodes = inner.get("UploadNodes") or []
|
||||
if not nodes:
|
||||
raise RuntimeError(f"ApplyUploadInner 无 UploadNodes: {data}")
|
||||
|
||||
node = nodes[0]
|
||||
host = node["UploadHost"]
|
||||
store = node["StoreInfos"][0]
|
||||
session_key = node["SessionKey"]
|
||||
vid = node["Vid"]
|
||||
|
||||
print(f" vid={vid}, host={host}")
|
||||
return {
|
||||
"session_key": session_key,
|
||||
"host": host,
|
||||
"store_uri": store["StoreUri"],
|
||||
"auth_token": store["Auth"],
|
||||
"upload_id": store["UploadID"],
|
||||
"vid": vid,
|
||||
}
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# Step 3: 分片上传
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
async def upload_chunks(
|
||||
client: httpx.AsyncClient, info: dict, file_path: str
|
||||
) -> bool:
|
||||
print(" [3] 上传视频...")
|
||||
raw = Path(file_path).read_bytes()
|
||||
total = len(raw)
|
||||
n_chunks = (total + CHUNK_SIZE - 1) // CHUNK_SIZE
|
||||
base_url = f"https://{info['host']}/upload/v1/{info['store_uri']}"
|
||||
upload_id = info["upload_id"]
|
||||
auth_h = {"Authorization": info["auth_token"], "User-Agent": UA}
|
||||
|
||||
crc_parts = []
|
||||
for i in range(n_chunks):
|
||||
start = i * CHUNK_SIZE
|
||||
end = min(start + CHUNK_SIZE, total)
|
||||
chunk = raw[start:end]
|
||||
crc32_hex = "%08x" % (zlib.crc32(chunk) & 0xFFFFFFFF)
|
||||
|
||||
resp = await client.post(
|
||||
f"{base_url}?uploadid={upload_id}&part_number={i+1}&phase=transfer",
|
||||
content=chunk,
|
||||
headers={**auth_h, "Content-CRC32": crc32_hex,
|
||||
"Content-Type": "application/octet-stream"},
|
||||
timeout=120.0,
|
||||
)
|
||||
resp_data = resp.json() if resp.status_code == 200 else {}
|
||||
if resp_data.get("code") != 2000:
|
||||
print(f" chunk {i+1}/{n_chunks} 失败: {resp.text[:200]}")
|
||||
return False
|
||||
sv_crc = resp_data.get("data", {}).get("crc32", crc32_hex)
|
||||
crc_parts.append(f"{i+1}:{sv_crc}")
|
||||
print(f" chunk {i+1}/{n_chunks} ok (crc32={sv_crc})")
|
||||
|
||||
finish_body = "\n".join(crc_parts).encode()
|
||||
finish_resp = await client.post(
|
||||
f"{base_url}?uploadid={upload_id}&phase=finish",
|
||||
content=finish_body,
|
||||
headers={**auth_h, "Content-Type": "text/plain"},
|
||||
timeout=60.0,
|
||||
)
|
||||
fd = finish_resp.json() if finish_resp.status_code == 200 else {}
|
||||
if fd.get("code") == 2000:
|
||||
print(f" finish ok")
|
||||
return True
|
||||
print(f" finish: {fd}")
|
||||
return False
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# Step 4: CommitUploadInner → video_id
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
async def commit_upload(
|
||||
client: httpx.AsyncClient, auth: dict, session_key: str
|
||||
) -> str:
|
||||
print(" [4] CommitUploadInner (POST)...")
|
||||
qs_params = {
|
||||
"Action": "CommitUploadInner",
|
||||
"SpaceName": "aweme",
|
||||
"Version": "2020-11-19",
|
||||
"app_id": "2906",
|
||||
"user_id": USER_ID,
|
||||
}
|
||||
qs = "&".join(f"{k}={v}" for k, v in sorted(qs_params.items()))
|
||||
|
||||
body = json.dumps({
|
||||
"SessionKey": session_key,
|
||||
"Functions": [{"Name": "GetMeta"}],
|
||||
}).encode("utf-8")
|
||||
|
||||
authorization, amz_date, token = aws4_sign(
|
||||
auth["access_key_id"], auth["secret"], auth["token"], qs,
|
||||
method="POST", body=body,
|
||||
)
|
||||
url = f"{VOD_HOST}/?{qs}"
|
||||
resp = await client.post(
|
||||
url,
|
||||
content=body,
|
||||
headers={
|
||||
"authorization": authorization,
|
||||
"x-amz-date": amz_date,
|
||||
"x-amz-security-token": token,
|
||||
"content-type": "text/plain;charset=UTF-8",
|
||||
"User-Agent": UA,
|
||||
},
|
||||
timeout=30.0,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
err = data.get("ResponseMetadata", {}).get("Error", {})
|
||||
if err.get("CodeN", 0):
|
||||
print(f" CommitUpload 失败: {err}")
|
||||
return ""
|
||||
results = data.get("Result", {}).get("Results", [])
|
||||
if results:
|
||||
vid = results[0].get("Vid", "")
|
||||
if vid:
|
||||
print(f" video_id={vid}")
|
||||
return vid
|
||||
print(f" CommitUpload 响应: {json.dumps(data, ensure_ascii=False)[:300]}")
|
||||
return ""
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# Step 5: 等待视频就绪 (轮询 transend)
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
async def wait_video_ready(
|
||||
client: httpx.AsyncClient, keys: SecurityKeys, video_id: str,
|
||||
max_wait: int = 15,
|
||||
) -> bool:
|
||||
print(" [5] 等待转码...")
|
||||
url = f"{BASE}/web/api/media/video/transend/"
|
||||
for i in range(max_wait):
|
||||
try:
|
||||
resp = await client.get(
|
||||
url,
|
||||
params={"video_id": video_id, "cookie_enabled": "true", "aid": "2906"},
|
||||
headers={"Cookie": keys.cookie_str, "User-Agent": UA,
|
||||
"Referer": "https://creator.douyin.com/creator-micro/content/post/video"},
|
||||
timeout=10.0,
|
||||
)
|
||||
data = resp.json()
|
||||
if data.get("encode") == 1:
|
||||
print(f" 转码完成 ({data.get('duration', 0):.1f}s)")
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
await asyncio.sleep(2)
|
||||
print(" 转码未完成,继续发布(服务端会后台处理)")
|
||||
return True
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# Step 6: 发布 create_v2
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
async def create_v2(
|
||||
client: httpx.AsyncClient,
|
||||
keys: SecurityKeys,
|
||||
video_id: str,
|
||||
title: str,
|
||||
timing_ts: int = 0,
|
||||
) -> dict:
|
||||
print(" [6] 发布 create_v2...")
|
||||
path = "/web/api/media/aweme/create_v2/"
|
||||
creation_id = f"{_rand(8)}{int(time.time() * 1000)}"
|
||||
|
||||
body = {
|
||||
"item": {
|
||||
"common": {
|
||||
"text": title,
|
||||
"caption": title,
|
||||
"item_title": "",
|
||||
"activity": "[]",
|
||||
"text_extra": "[]",
|
||||
"challenges": "[]",
|
||||
"mentions": "[]",
|
||||
"hashtag_source": "",
|
||||
"hot_sentence": "",
|
||||
"interaction_stickers": "[]",
|
||||
"visibility_type": 0,
|
||||
"download": 1,
|
||||
"timing": timing_ts if timing_ts > 0 else 0,
|
||||
"creation_id": creation_id,
|
||||
"media_type": 4,
|
||||
"video_id": video_id,
|
||||
"music_source": 0,
|
||||
"music_id": None,
|
||||
},
|
||||
"cover": {
|
||||
"custom_cover_image_height": 0,
|
||||
"custom_cover_image_width": 0,
|
||||
"poster": "",
|
||||
"poster_delay": 0,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
guard_headers = keys.compute_ticket_guard(path)
|
||||
|
||||
query_params = {
|
||||
"read_aid": "2906",
|
||||
"cookie_enabled": "true",
|
||||
"screen_width": "1280",
|
||||
"screen_height": "720",
|
||||
"browser_language": "zh-CN",
|
||||
"browser_platform": "MacIntel",
|
||||
"browser_name": "Mozilla",
|
||||
"browser_version": UA.split("Chrome/")[1].split(" ")[0] if "Chrome/" in UA else "143.0.0.0",
|
||||
"browser_online": "true",
|
||||
"timezone_name": "Asia/Shanghai",
|
||||
"aid": "1128",
|
||||
"support_h265": "1",
|
||||
}
|
||||
if keys.ms_token:
|
||||
query_params["msToken"] = keys.ms_token
|
||||
|
||||
headers = {
|
||||
"Cookie": keys.cookie_str,
|
||||
"User-Agent": UA,
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json, text/plain, */*",
|
||||
"Referer": "https://creator.douyin.com/creator-micro/content/post/video?enter_from=publish_page",
|
||||
"Origin": "https://creator.douyin.com",
|
||||
}
|
||||
if keys.csrf_token:
|
||||
headers["x-secsdk-csrf-token"] = f"000100000001{keys.csrf_token[:32]}"
|
||||
headers.update(guard_headers)
|
||||
|
||||
url = CREATE_V2_URL + "?" + urlencode(query_params)
|
||||
|
||||
resp = await client.post(
|
||||
url, headers=headers, json=body, timeout=30.0,
|
||||
)
|
||||
|
||||
print(f" HTTP {resp.status_code}")
|
||||
print(f" Headers: {dict(resp.headers)}")
|
||||
|
||||
raw = resp.text
|
||||
if not raw:
|
||||
print(" 空响应(签名被拒绝或安全限制)")
|
||||
return {"status_code": -1, "error": "empty_response"}
|
||||
|
||||
print(f" 响应: {raw[:500]}")
|
||||
try:
|
||||
return resp.json()
|
||||
except Exception:
|
||||
return {"status_code": -1, "error": raw[:200]}
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# 单视频发布
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
async def publish_one(
|
||||
keys: SecurityKeys,
|
||||
video_path: str,
|
||||
title: str,
|
||||
timing_ts: int = 0,
|
||||
idx: int = 1,
|
||||
total: int = 1,
|
||||
) -> bool:
|
||||
fname = Path(video_path).name
|
||||
fsize = Path(video_path).stat().st_size
|
||||
timing_str = datetime.datetime.fromtimestamp(timing_ts).strftime("%m-%d %H:%M") if timing_ts > 0 else "立即"
|
||||
|
||||
print(f"\n{'='*60}")
|
||||
print(f" [{idx}/{total}] {fname}")
|
||||
print(f" 大小: {fsize/1024/1024:.1f}MB | 定时: {timing_str}")
|
||||
print(f" 标题: {title[:60]}")
|
||||
print(f"{'='*60}")
|
||||
|
||||
async with httpx.AsyncClient(timeout=60.0, follow_redirects=True) as client:
|
||||
auth = await get_upload_auth(client, keys)
|
||||
info = await apply_upload(client, auth, fsize)
|
||||
if not await upload_chunks(client, info, video_path):
|
||||
print(" [✗] 上传失败")
|
||||
return False
|
||||
video_id = await commit_upload(client, auth, info["session_key"])
|
||||
if not video_id:
|
||||
print(" [✗] 未获取到 video_id")
|
||||
return False
|
||||
await wait_video_ready(client, keys, video_id)
|
||||
result = await create_v2(client, keys, video_id, title, timing_ts)
|
||||
|
||||
if result.get("status_code") == 0:
|
||||
print(" [✓] 发布成功!")
|
||||
return True
|
||||
else:
|
||||
print(f" [✗] 发布失败: {result}")
|
||||
return False
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# 主流程
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
async def main():
|
||||
if not COOKIE_FILE.exists():
|
||||
print("[✗] Cookie 不存在,请先运行 douyin_login.py")
|
||||
return 1
|
||||
|
||||
keys = SecurityKeys(COOKIE_FILE)
|
||||
print(f"[✓] Cookie 加载 ({len(keys.cookies)} items)")
|
||||
print(f" msToken: {'✓' if keys.ms_token else '✗'}")
|
||||
print(f" ec_privateKey: {'✓' if keys.ec_private_key else '✗'}")
|
||||
print(f" server_public_key: {'✓' if keys.server_public_key else '✗'}")
|
||||
print(f" ticket: {'✓' if keys.ticket else '✗'}")
|
||||
print(f" ts_sign: {'✓' if keys.ts_sign_raw else '✗'}")
|
||||
print(f" csrf_token: {'✓' if keys.csrf_token else '✗'}")
|
||||
|
||||
async with httpx.AsyncClient(timeout=15.0) as c:
|
||||
resp = await c.get(
|
||||
USER_INFO_URL, headers={"Cookie": keys.cookie_str, "User-Agent": UA}
|
||||
)
|
||||
data = resp.json()
|
||||
if data.get("status_code") != 0:
|
||||
print(f"[✗] Cookie 无效: {data}")
|
||||
return 1
|
||||
print(f"[✓] 用户: {data.get('user_info', {}).get('nickname', 'unknown')}\n")
|
||||
|
||||
videos = sorted(VIDEO_DIR.glob("*.mp4"))
|
||||
if not videos:
|
||||
print("[✗] 未找到视频")
|
||||
return 1
|
||||
print(f"[i] 共 {len(videos)} 条视频")
|
||||
|
||||
now_ts = int(time.time())
|
||||
base_ts = ((now_ts + 3600) // 3600 + 1) * 3600
|
||||
|
||||
schedule = []
|
||||
for i, vp in enumerate(videos):
|
||||
ts = base_ts + i * 3600
|
||||
title = TITLES.get(vp.name, f"{vp.stem} #Soul派对 #创业日记")
|
||||
schedule.append((vp, title, ts))
|
||||
dt_str = datetime.datetime.fromtimestamp(ts).strftime("%m-%d %H:%M")
|
||||
print(f" {i+1:2d}. {dt_str} | {vp.name[:50]}")
|
||||
|
||||
results = []
|
||||
for i, (vp, title, ts) in enumerate(schedule):
|
||||
ok = await publish_one(keys, str(vp), title, ts, i + 1, len(schedule))
|
||||
results.append((vp.name, ok, ts))
|
||||
if i < len(schedule) - 1 and ok:
|
||||
print(" 等待 5s...")
|
||||
await asyncio.sleep(5)
|
||||
|
||||
print(f"\n{'='*60}")
|
||||
print(" 发布汇总")
|
||||
print(f"{'='*60}")
|
||||
for name, ok, ts in results:
|
||||
s = "✓" if ok else "✗"
|
||||
t = datetime.datetime.fromtimestamp(ts).strftime("%m-%d %H:%M")
|
||||
print(f" [{s}] {t} | {name}")
|
||||
success = sum(1 for _, ok, _ in results if ok)
|
||||
print(f"\n 成功: {success}/{len(results)}")
|
||||
return 0 if success == len(results) else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(asyncio.run(main()))
|
||||
346
03_卡木(木)/木叶_视频内容/抖音发布/脚本/douyin_pw_publish.py
Normal file
346
03_卡木(木)/木叶_视频内容/抖音发布/脚本/douyin_pw_publish.py
Normal file
@@ -0,0 +1,346 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
抖音视频批量发布 - Playwright 方案 (基于万推架构)
|
||||
流程: 打开创作者中心 → set_input_files 上传 → 填写标题/话题 → 定时发布 → 保存Cookie
|
||||
"""
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from playwright.async_api import async_playwright
|
||||
|
||||
SCRIPT_DIR = Path(__file__).parent
|
||||
COOKIE_FILE = SCRIPT_DIR / "douyin_storage_state.json"
|
||||
STEALTH_JS = Path("/Users/karuo/Documents/开发/3、自营项目/万推/backend/utils/stealth.min.js")
|
||||
VIDEO_DIR = Path("/Users/karuo/Movies/soul视频/soul 派对 119场 20260309_output/成片")
|
||||
|
||||
CHROME_PATH = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
|
||||
HEADLESS = False
|
||||
|
||||
TITLES = {
|
||||
"早起不是为了开派对,是不吵老婆睡觉.mp4":
|
||||
"早起不是为了开派对,是不吵老婆睡觉。#Soul派对 #创业日记 #晨间直播 #私域干货",
|
||||
"懒人的活法 动作简单有利可图正反馈.mp4":
|
||||
"懒有懒的活法:动作简单、有利可图、正反馈。#Soul派对 #副业 #私域 #切片变现",
|
||||
"初期团队先找两个IS,比钱好使 ENFJ链接人,ENTJ指挥.mp4":
|
||||
"初期团队先找两个IS,比钱好使。ENFJ链接人,ENTJ指挥。#MBTI #创业团队 #Soul派对",
|
||||
"ICU出来一年多 活着要在互联网上留下东西.mp4":
|
||||
"ICU出来一年多,活着要在互联网上留下东西。#人生感悟 #创业 #Soul派对 #记录生活",
|
||||
"MBTI疗愈SOUL 年轻人测MBTI,40到60岁走五行八卦.mp4":
|
||||
"年轻人测MBTI,40到60岁走五行八卦。#MBTI #Soul派对 #五行 #疗愈",
|
||||
"Soul业务模型 派对+切片+小程序全链路.mp4":
|
||||
"Soul业务模型:派对+切片+小程序全链路。#Soul派对 #商业模式 #私域运营 #小程序",
|
||||
"Soul切片30秒到8分钟 AI半小时能剪10到30个.mp4":
|
||||
"Soul切片30秒到8分钟,AI半小时能剪10到30个。#AI剪辑 #Soul派对 #切片变现",
|
||||
"刷牙听业务逻辑 Soul切片变现怎么跑.mp4":
|
||||
"刷牙听业务逻辑:Soul切片变现怎么跑。#Soul派对 #切片变现 #副业 #商业逻辑",
|
||||
"国学易经怎么学 两小时七七八八,召唤作者对话.mp4":
|
||||
"国学易经怎么学?两小时七七八八,召唤作者对话。#国学 #易经 #Soul派对",
|
||||
"广点通能投Soul了,1000曝光6到10块.mp4":
|
||||
"广点通能投Soul了,1000曝光6到10块。#Soul派对 #广点通 #流量投放 #私域获客",
|
||||
"建立信任不是求来的 卖外挂发邮件三个月拿下德国总代.mp4":
|
||||
"建立信任不是求来的。卖外挂发邮件三个月拿下德国总代。#销售 #信任 #Soul派对",
|
||||
"核心就两个字 筛选。能开派对坚持7天的人再谈.mp4":
|
||||
"核心就两个字:筛选。能开派对坚持7天的人再谈。#筛选 #Soul派对 #创业",
|
||||
"睡眠不好?每天放下一件事,做减法.mp4":
|
||||
"睡眠不好?每天放下一件事,做减法。#睡眠 #减法 #Soul派对 #生活方式",
|
||||
"这套体系花了170万,但前端几十块就能参与.mp4":
|
||||
"这套体系花了170万,但前端几十块就能参与。#商业体系 #Soul派对 #私域",
|
||||
"金融AI获客体系 后端30人沉淀12年,前端丢手机.mp4":
|
||||
"金融AI获客体系:后端30人沉淀12年,前端丢手机。#AI获客 #金融 #Soul派对",
|
||||
}
|
||||
|
||||
|
||||
def get_title(filename: str) -> str:
|
||||
return TITLES.get(filename, f"{Path(filename).stem} #Soul派对 #创业日记 #卡若创业派对")
|
||||
|
||||
|
||||
async def publish_one(video_path: str, title: str, publish_date: datetime | None, idx: int, total: int) -> bool:
|
||||
"""上传并发布单条视频"""
|
||||
print(f"\n{'='*60}")
|
||||
print(f" [{idx+1}/{total}] {Path(video_path).name}")
|
||||
print(f" 标题: {title[:50]}")
|
||||
if publish_date:
|
||||
print(f" 定时: {publish_date.strftime('%Y-%m-%d %H:%M')}")
|
||||
print(f"{'='*60}")
|
||||
|
||||
async with async_playwright() as pw:
|
||||
browser = await pw.chromium.launch(
|
||||
headless=HEADLESS,
|
||||
executable_path=CHROME_PATH if os.path.exists(CHROME_PATH) else None,
|
||||
)
|
||||
context = await browser.new_context(storage_state=str(COOKIE_FILE))
|
||||
if STEALTH_JS.exists():
|
||||
await context.add_init_script(path=str(STEALTH_JS))
|
||||
|
||||
page = await context.new_page()
|
||||
|
||||
try:
|
||||
# 1. 打开上传页
|
||||
print(" [1] 打开上传页...")
|
||||
await page.goto(
|
||||
"https://creator.douyin.com/creator-micro/content/upload",
|
||||
wait_until="domcontentloaded", timeout=60000,
|
||||
)
|
||||
await page.wait_for_url("**/creator.douyin.com/**/upload**", timeout=60000)
|
||||
await page.wait_for_load_state("load", timeout=20000)
|
||||
|
||||
# 检查登录状态
|
||||
if await page.get_by_text("手机号登录").count() or await page.get_by_text("扫码登录").count():
|
||||
print(" [!] Cookie 失效,需重新登录")
|
||||
return False
|
||||
|
||||
try:
|
||||
await page.get_by_text("上传视频", exact=False).first.wait_for(state="visible", timeout=15000)
|
||||
except Exception:
|
||||
pass
|
||||
await asyncio.sleep(3)
|
||||
|
||||
# 2. 上传文件
|
||||
print(" [2] 上传视频文件...")
|
||||
upload_selectors = [
|
||||
"input[type='file']",
|
||||
"div[class^='container'] input",
|
||||
"[class*='upload'] input[type='file']",
|
||||
"[class^='upload-btn-input']",
|
||||
]
|
||||
uploaded = False
|
||||
for sel in upload_selectors:
|
||||
try:
|
||||
loc = page.locator(sel).first
|
||||
await loc.wait_for(state="attached", timeout=5000)
|
||||
await loc.set_input_files(video_path, timeout=60000)
|
||||
print(f" 上传成功 (选择器: {sel})")
|
||||
uploaded = True
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
if not uploaded:
|
||||
print(" [!] 未找到上传 input")
|
||||
return False
|
||||
|
||||
# 3. 等待页面跳转到发布页
|
||||
print(" [3] 等待进入发布页...")
|
||||
for _ in range(120):
|
||||
try:
|
||||
await page.wait_for_url(
|
||||
"https://creator.douyin.com/creator-micro/content/publish*",
|
||||
timeout=2000,
|
||||
)
|
||||
print(" 进入发布页 (v1)")
|
||||
break
|
||||
except Exception:
|
||||
try:
|
||||
await page.wait_for_url(
|
||||
"https://creator.douyin.com/creator-micro/content/post/video*",
|
||||
timeout=2000,
|
||||
)
|
||||
print(" 进入发布页 (v2)")
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
print(" [!] 等待发布页超时")
|
||||
ss = SCRIPT_DIR / f"timeout_nav_{idx}.png"
|
||||
await page.screenshot(path=str(ss), full_page=True)
|
||||
return False
|
||||
|
||||
await asyncio.sleep(3)
|
||||
|
||||
# 4. 填写标题
|
||||
print(" [4] 填写标题...")
|
||||
title_text = title[:30]
|
||||
title_filled = False
|
||||
|
||||
# 方式A: 万推的方式 - 通过"作品标题"文本定位
|
||||
try:
|
||||
tc = page.get_by_text("作品标题").locator("..").locator("xpath=following-sibling::div[1]").locator("input")
|
||||
if await tc.count():
|
||||
await tc.fill(title_text)
|
||||
title_filled = True
|
||||
print(" 标题填写成功 (方式A)")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 方式B: .notranslate 区域
|
||||
if not title_filled:
|
||||
try:
|
||||
tc = page.locator(".notranslate").first
|
||||
await tc.click(timeout=5000)
|
||||
await page.keyboard.press("Meta+KeyA")
|
||||
await page.keyboard.press("Delete")
|
||||
await page.keyboard.type(title_text, delay=20)
|
||||
await page.keyboard.press("Enter")
|
||||
title_filled = True
|
||||
print(" 标题填写成功 (方式B)")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 方式C: 直接 JS 注入
|
||||
if not title_filled:
|
||||
try:
|
||||
await page.evaluate(f'''() => {{
|
||||
const inp = document.querySelector('input[placeholder*="标题"]') || document.querySelector('input.semi-input');
|
||||
if (inp) {{ inp.value = {repr(title_text)}; inp.dispatchEvent(new Event('input', {{bubbles:true}})); }}
|
||||
}}''')
|
||||
title_filled = True
|
||||
print(" 标题填写成功 (方式C: JS注入)")
|
||||
except Exception as e:
|
||||
print(f" [!] 标题填写全部失败: {e}")
|
||||
|
||||
# 5. 填写话题
|
||||
print(" [5] 填写话题...")
|
||||
tags = [t.strip() for t in title.split("#") if t.strip() and len(t.strip()) < 20]
|
||||
if tags:
|
||||
tags = tags[1:] # 第一个通常是标题文本
|
||||
try:
|
||||
zone = page.locator(".zone-container")
|
||||
for tag in tags[:5]:
|
||||
await zone.type(f"#{tag} ", delay=30)
|
||||
await asyncio.sleep(0.3)
|
||||
# 关闭话题下拉
|
||||
await page.keyboard.press("Escape")
|
||||
await asyncio.sleep(0.3)
|
||||
await page.mouse.click(10, 10)
|
||||
print(f" 已添加 {min(len(tags), 5)} 个话题")
|
||||
except Exception as e:
|
||||
print(f" 话题填写跳过: {e}")
|
||||
|
||||
# 6. 等待视频上传完成
|
||||
print(" [6] 等待视频上传完成...")
|
||||
for _ in range(120):
|
||||
try:
|
||||
n = await page.locator('[class^="long-card"] div:has-text("重新上传")').count()
|
||||
if n > 0:
|
||||
print(" 视频上传完毕")
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
await asyncio.sleep(2)
|
||||
else:
|
||||
print(" [!] 视频上传等待超时,继续尝试发布...")
|
||||
|
||||
# 7. 定时发布
|
||||
if publish_date:
|
||||
print(f" [7] 设置定时发布: {publish_date.strftime('%Y-%m-%d %H:%M')}...")
|
||||
try:
|
||||
label = page.locator("[class^='radio']:has-text('定时发布')")
|
||||
await label.click(timeout=5000)
|
||||
await asyncio.sleep(1)
|
||||
date_str = publish_date.strftime("%Y-%m-%d %H:%M")
|
||||
date_input = page.locator('.semi-input[placeholder="日期和时间"]')
|
||||
await date_input.click(timeout=5000)
|
||||
await page.keyboard.press("Meta+KeyA")
|
||||
await page.keyboard.type(date_str)
|
||||
await page.keyboard.press("Enter")
|
||||
await asyncio.sleep(1)
|
||||
print(f" 定时已设置")
|
||||
except Exception as e:
|
||||
print(f" [!] 定时设置失败: {e}")
|
||||
|
||||
# 8. 点击发布
|
||||
print(" [8] 点击发布...")
|
||||
published = False
|
||||
for attempt in range(30):
|
||||
try:
|
||||
pub_btn = page.get_by_role("button", name="发布", exact=True)
|
||||
if await pub_btn.count():
|
||||
await pub_btn.click()
|
||||
await page.wait_for_url(
|
||||
"https://creator.douyin.com/creator-micro/content/manage**",
|
||||
timeout=5000,
|
||||
)
|
||||
print(" [OK] 视频发布成功!")
|
||||
published = True
|
||||
break
|
||||
except Exception:
|
||||
# 处理封面弹窗
|
||||
try:
|
||||
if await page.get_by_text("请设置封面后再发布").first.is_visible():
|
||||
print(" 需要封面,自动选择...")
|
||||
cover = page.locator('[class^="recommendCover-"]').first
|
||||
if await cover.count():
|
||||
await cover.click()
|
||||
await asyncio.sleep(1)
|
||||
if await page.get_by_text("是否确认应用此封面?").first.is_visible():
|
||||
await page.get_by_role("button", name="确定").click()
|
||||
await asyncio.sleep(1)
|
||||
except Exception:
|
||||
pass
|
||||
await asyncio.sleep(2)
|
||||
|
||||
if not published:
|
||||
print(" [!] 发布超时")
|
||||
ss = SCRIPT_DIR / f"timeout_publish_{idx}.png"
|
||||
await page.screenshot(path=str(ss), full_page=True)
|
||||
|
||||
# 9. 保存 Cookie
|
||||
await context.storage_state(path=str(COOKIE_FILE))
|
||||
print(" Cookie 已更新")
|
||||
|
||||
return published
|
||||
|
||||
except Exception as e:
|
||||
print(f" [!] 异常: {e}")
|
||||
ss = SCRIPT_DIR / f"error_{idx}.png"
|
||||
try:
|
||||
await page.screenshot(path=str(ss), full_page=True)
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
finally:
|
||||
await context.close()
|
||||
await browser.close()
|
||||
|
||||
|
||||
async def main():
|
||||
if not COOKIE_FILE.exists():
|
||||
print("[!] Cookie 不存在,请先运行 douyin_login.py")
|
||||
return 1
|
||||
|
||||
videos = sorted(VIDEO_DIR.glob("*.mp4"))
|
||||
if not videos:
|
||||
print("[!] 未找到视频")
|
||||
return 1
|
||||
print(f"[i] 共 {len(videos)} 条视频待发布\n")
|
||||
|
||||
# 定时规划: 从当前+2h开始,每小时一条
|
||||
now_ts = int(time.time())
|
||||
base_ts = ((now_ts + 2 * 3600) // 3600 + 1) * 3600
|
||||
|
||||
results = []
|
||||
for i, vp in enumerate(videos):
|
||||
title = get_title(vp.name)
|
||||
schedule_time = datetime.fromtimestamp(base_ts + i * 3600)
|
||||
|
||||
ok = await publish_one(
|
||||
video_path=str(vp),
|
||||
title=title,
|
||||
publish_date=schedule_time,
|
||||
idx=i,
|
||||
total=len(videos),
|
||||
)
|
||||
results.append((vp.name, ok, schedule_time))
|
||||
|
||||
if i < len(videos) - 1:
|
||||
wait = 10 if ok else 5
|
||||
print(f" 等待 {wait} 秒后继续...")
|
||||
await asyncio.sleep(wait)
|
||||
|
||||
# 汇总
|
||||
print(f"\n{'='*60}")
|
||||
print(" 发布汇总")
|
||||
print(f"{'='*60}")
|
||||
for name, ok, t in results:
|
||||
s = "OK" if ok else "FAIL"
|
||||
print(f" [{s:4s}] {t.strftime('%m-%d %H:%M')} | {name}")
|
||||
success = sum(1 for _, ok, _ in results if ok)
|
||||
print(f"\n 成功: {success}/{len(results)}")
|
||||
return 0 if success == len(results) else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(asyncio.run(main()))
|
||||
1
03_卡木(木)/木叶_视频内容/抖音发布/脚本/douyin_storage_state.json
Normal file
1
03_卡木(木)/木叶_视频内容/抖音发布/脚本/douyin_storage_state.json
Normal file
File diff suppressed because one or more lines are too long
BIN
03_卡木(木)/木叶_视频内容/抖音发布/脚本/error_1.png
Normal file
BIN
03_卡木(木)/木叶_视频内容/抖音发布/脚本/error_1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 604 KiB |
263
03_卡木(木)/木叶_视频内容/抖音发布/脚本/intercept_create_v2.py
Normal file
263
03_卡木(木)/木叶_视频内容/抖音发布/脚本/intercept_create_v2.py
Normal file
@@ -0,0 +1,263 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
精准拦截 create_v2 请求 — 捕获所有 POST 请求以找到真正的发布 API。
|
||||
使用 page.route() 拦截(更可靠)+ page.on('request') 双保险。
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from playwright.async_api import async_playwright, Request, Route
|
||||
|
||||
SCRIPT_DIR = Path(__file__).parent
|
||||
COOKIE_FILE = SCRIPT_DIR / "douyin_storage_state.json"
|
||||
STEALTH_JS = Path("/Users/karuo/Documents/开发/3、自营项目/万推/backend/utils/stealth.min.js")
|
||||
VIDEO = "/Users/karuo/Movies/soul视频/soul 派对 119场 20260309_output/成片/广点通能投Soul了,1000曝光6到10块.mp4"
|
||||
LOG_FILE = SCRIPT_DIR / "create_v2_captured.json"
|
||||
ALL_POSTS_FILE = SCRIPT_DIR / "all_post_requests.json"
|
||||
CHROME = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
|
||||
|
||||
captured_creates = []
|
||||
all_post_after_click = []
|
||||
publishing_started = False
|
||||
|
||||
|
||||
def on_request(request: Request):
|
||||
global publishing_started
|
||||
url = request.url
|
||||
if request.method != "POST":
|
||||
return
|
||||
if not publishing_started:
|
||||
return
|
||||
if any(skip in url for skip in ["monitor_browser", "mcs.snssdk", "mon.zijie", "mssdk", ".css", ".js", ".png", "ttwid/check", "goofy_worker", "passport/user_info"]):
|
||||
return
|
||||
|
||||
entry = {
|
||||
"url": url[:600],
|
||||
"method": request.method,
|
||||
"content_type": request.headers.get("content-type", ""),
|
||||
}
|
||||
try:
|
||||
body = request.post_data
|
||||
if body:
|
||||
entry["body"] = body[:5000]
|
||||
entry["body_length"] = len(body)
|
||||
except Exception:
|
||||
entry["body"] = None
|
||||
|
||||
all_post_after_click.append(entry)
|
||||
short_url = url.split("?")[0]
|
||||
print(f" [POST] {short_url}")
|
||||
|
||||
if any(kw in url for kw in ["create_v2", "aweme/create", "aweme/post"]):
|
||||
entry["headers"] = dict(request.headers)
|
||||
try:
|
||||
body = request.post_data
|
||||
entry["full_body"] = body[:10000] if body else None
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
captured_creates.append(entry)
|
||||
print(f"\n{'#'*60}")
|
||||
print(f" ★★★ 捕获到 create 请求! ★★★")
|
||||
print(f" URL: {url}")
|
||||
print(f" Content-Type: {entry['content_type']}")
|
||||
if entry.get("full_body"):
|
||||
print(f" Body ({len(entry['full_body'])} chars):")
|
||||
print(f" {entry['full_body'][:3000]}")
|
||||
print(f"{'#'*60}\n")
|
||||
|
||||
with open(LOG_FILE, "w", encoding="utf-8") as f:
|
||||
json.dump(captured_creates, f, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
async def main():
|
||||
global publishing_started
|
||||
|
||||
if not COOKIE_FILE.exists():
|
||||
print("[!] Cookie 不存在")
|
||||
return 1
|
||||
|
||||
async with async_playwright() as pw:
|
||||
browser = await pw.chromium.launch(
|
||||
headless=False,
|
||||
executable_path=CHROME if os.path.exists(CHROME) else None,
|
||||
)
|
||||
context = await browser.new_context(storage_state=str(COOKIE_FILE))
|
||||
if STEALTH_JS.exists():
|
||||
await context.add_init_script(path=str(STEALTH_JS))
|
||||
|
||||
page = await context.new_page()
|
||||
page.on("request", on_request)
|
||||
|
||||
print("[1] 打开上传页...")
|
||||
await page.goto("https://creator.douyin.com/creator-micro/content/upload",
|
||||
wait_until="domcontentloaded", timeout=60000)
|
||||
await page.wait_for_url("**/upload**", timeout=60000)
|
||||
await page.wait_for_load_state("load", timeout=20000)
|
||||
|
||||
if await page.get_by_text("手机号登录").count() or await page.get_by_text("扫码登录").count():
|
||||
print("[!] Cookie 失效")
|
||||
await browser.close()
|
||||
return 1
|
||||
print("[1] OK")
|
||||
await asyncio.sleep(3)
|
||||
|
||||
print("[2] 上传视频...")
|
||||
loc = page.locator("input[type='file']").first
|
||||
await loc.wait_for(state="attached", timeout=10000)
|
||||
await loc.set_input_files(VIDEO, timeout=60000)
|
||||
print("[2] OK")
|
||||
|
||||
print("[3] 等待发布页...")
|
||||
for _ in range(120):
|
||||
if "publish" in page.url or "post/video" in page.url:
|
||||
break
|
||||
await asyncio.sleep(1)
|
||||
print(f"[3] URL: {page.url}")
|
||||
await asyncio.sleep(5)
|
||||
|
||||
print("[4] 填标题...")
|
||||
try:
|
||||
nl = page.locator(".notranslate").first
|
||||
await nl.click(timeout=5000)
|
||||
await page.keyboard.press("Meta+KeyA")
|
||||
await page.keyboard.press("Delete")
|
||||
await page.keyboard.type("广点通能投Soul了 测试发布", delay=20)
|
||||
print("[4] OK")
|
||||
except Exception as e:
|
||||
print(f"[4] 异常: {e}")
|
||||
|
||||
print("[5] 等待视频上传+转码完成...")
|
||||
for i in range(240):
|
||||
try:
|
||||
c1 = await page.locator('div:has-text("重新上传")').count()
|
||||
c2 = await page.locator('text="上传完成"').count()
|
||||
c3 = await page.locator('[class*="success"]').count()
|
||||
if c1 > 0 or c2 > 0:
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
if i % 15 == 0 and i > 0:
|
||||
print(f" ...{i}s")
|
||||
await asyncio.sleep(1)
|
||||
|
||||
# 额外等待转码
|
||||
print("[5] 上传完成,等待转码...")
|
||||
await asyncio.sleep(15)
|
||||
|
||||
# 检查视频是否可发布
|
||||
try:
|
||||
enabled = await page.locator('text="视频解析中"').count()
|
||||
if enabled > 0:
|
||||
print("[5] 仍在解析,继续等...")
|
||||
for i in range(60):
|
||||
c = await page.locator('text="视频解析中"').count()
|
||||
if c == 0:
|
||||
break
|
||||
await asyncio.sleep(2)
|
||||
except Exception:
|
||||
pass
|
||||
print("[5] OK")
|
||||
|
||||
# 开始监控
|
||||
publishing_started = True
|
||||
print("\n" + "="*60)
|
||||
print(" ★ 开始监控所有 POST 请求 — 即将点击发布 ★")
|
||||
print("="*60 + "\n")
|
||||
|
||||
# 多次尝试发布
|
||||
for attempt in range(10):
|
||||
print(f"\n--- 发布尝试 #{attempt+1} ---")
|
||||
try:
|
||||
pub = page.get_by_role("button", name="发布", exact=True)
|
||||
if await pub.count():
|
||||
await pub.click()
|
||||
print(f"[6] 已点击发布")
|
||||
else:
|
||||
pub2 = page.locator("button:has-text('发布')").first
|
||||
if await pub2.count():
|
||||
await pub2.click()
|
||||
print(f"[6] 已点击发布(fallback)")
|
||||
except Exception as e:
|
||||
print(f"[6] 点击异常: {e}")
|
||||
|
||||
# 等待
|
||||
await asyncio.sleep(5)
|
||||
|
||||
# 检查是否跳转
|
||||
if "manage" in page.url:
|
||||
print("[✓] 页面已跳转到管理页 — 发布成功!")
|
||||
break
|
||||
|
||||
# 处理封面弹窗
|
||||
try:
|
||||
if await page.get_by_text("请设置封面后再发布").first.is_visible():
|
||||
print("[6] 需要封面,选择推荐封面...")
|
||||
covers = page.locator('[class*="recommendCover"], [class*="cover-select"] img, [class*="coverCard"]')
|
||||
if await covers.count() > 0:
|
||||
await covers.first.click()
|
||||
await asyncio.sleep(2)
|
||||
confirm = page.get_by_role("button", name="确定")
|
||||
if await confirm.count():
|
||||
await confirm.click()
|
||||
await asyncio.sleep(3)
|
||||
print("[6] 封面已选,继续...")
|
||||
continue
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 处理验证弹窗
|
||||
try:
|
||||
if await page.locator('text="安全验证"').count() > 0:
|
||||
print("[!] 触发安全验证,无法自动处理")
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
await asyncio.sleep(5)
|
||||
|
||||
if captured_creates:
|
||||
print("[✓] 已捕获 create 请求!")
|
||||
break
|
||||
|
||||
# 最终等待
|
||||
print("\n[7] 最终等待...")
|
||||
await asyncio.sleep(10)
|
||||
|
||||
print(f"\n{'='*60}")
|
||||
print(f" 最终结果")
|
||||
print(f" URL: {page.url}")
|
||||
print(f" 捕获 create 请求: {len(captured_creates)}")
|
||||
print(f" 捕获所有 POST: {len(all_post_after_click)}")
|
||||
print(f"{'='*60}")
|
||||
|
||||
# 保存所有 POST 请求
|
||||
with open(ALL_POSTS_FILE, "w", encoding="utf-8") as f:
|
||||
json.dump(all_post_after_click, f, ensure_ascii=False, indent=2)
|
||||
print(f"[i] 所有 POST 请求已保存: {ALL_POSTS_FILE}")
|
||||
|
||||
if captured_creates:
|
||||
with open(LOG_FILE, "w", encoding="utf-8") as f:
|
||||
json.dump(captured_creates, f, ensure_ascii=False, indent=2)
|
||||
print(f"[i] create 请求已保存: {LOG_FILE}")
|
||||
|
||||
# 打印所有 POST 摘要
|
||||
print("\n--- 所有 POST 请求摘要 ---")
|
||||
for i, req in enumerate(all_post_after_click):
|
||||
url_short = req["url"].split("?")[0]
|
||||
ct = req.get("content_type", "")
|
||||
bl = req.get("body_length", 0)
|
||||
print(f" {i+1}. {url_short} [{ct}] body={bl}b")
|
||||
|
||||
await context.storage_state(path=str(COOKIE_FILE))
|
||||
await context.close()
|
||||
await browser.close()
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(asyncio.run(main()))
|
||||
210
03_卡木(木)/木叶_视频内容/抖音发布/脚本/intercept_publish.py
Normal file
210
03_卡木(木)/木叶_视频内容/抖音发布/脚本/intercept_publish.py
Normal file
@@ -0,0 +1,210 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
拦截抖音创作者中心视频发布的网络请求。
|
||||
用 Playwright 走一次完整的上传→发布流程,记录所有 API 调用。
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from playwright.async_api import async_playwright, Request
|
||||
|
||||
SCRIPT_DIR = Path(__file__).parent
|
||||
COOKIE_FILE = SCRIPT_DIR / "douyin_storage_state.json"
|
||||
STEALTH_JS = Path("/Users/karuo/Documents/开发/3、自营项目/万推/backend/utils/stealth.min.js")
|
||||
VIDEO = "/Users/karuo/Movies/soul视频/soul 派对 119场 20260309_output/成片/广点通能投Soul了,1000曝光6到10块.mp4"
|
||||
LOG_FILE = SCRIPT_DIR / "intercepted_requests.json"
|
||||
|
||||
CHROME = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
|
||||
|
||||
captured = []
|
||||
|
||||
|
||||
def on_request(request: Request):
|
||||
url = request.url
|
||||
if "creator.douyin.com" not in url and "bytedanceapi" not in url and "snssdk" not in url and "bytedancevod" not in url:
|
||||
return
|
||||
if any(skip in url for skip in ["monitor_browser", "mcs.snssdk", "mon.zijie", "mssdk.bytedance", ".css", ".js", ".png", ".jpg", ".woff"]):
|
||||
return
|
||||
|
||||
entry = {
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"method": request.method,
|
||||
"url": url[:500],
|
||||
"headers": dict(request.headers),
|
||||
"post_data": None,
|
||||
}
|
||||
try:
|
||||
pd = request.post_data
|
||||
if pd:
|
||||
entry["post_data"] = pd[:2000] if len(pd) > 2000 else pd
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
captured.append(entry)
|
||||
short = url.split("?")[0]
|
||||
print(f" [NET] {request.method} {short}")
|
||||
|
||||
|
||||
async def main():
|
||||
if not COOKIE_FILE.exists():
|
||||
print("[!] Cookie 不存在,请先运行 douyin_login.py")
|
||||
return 1
|
||||
|
||||
print("[i] 启动浏览器,拦截网络请求...")
|
||||
async with async_playwright() as pw:
|
||||
browser = await pw.chromium.launch(
|
||||
headless=False,
|
||||
executable_path=CHROME if os.path.exists(CHROME) else None,
|
||||
)
|
||||
context = await browser.new_context(storage_state=str(COOKIE_FILE))
|
||||
if STEALTH_JS.exists():
|
||||
await context.add_init_script(path=str(STEALTH_JS))
|
||||
|
||||
page = await context.new_page()
|
||||
page.on("request", on_request)
|
||||
|
||||
# 1. 打开上传页
|
||||
print("[1] 打开上传页...")
|
||||
await page.goto("https://creator.douyin.com/creator-micro/content/upload", wait_until="domcontentloaded", timeout=60000)
|
||||
await page.wait_for_url("**/upload**", timeout=60000)
|
||||
await page.wait_for_load_state("load", timeout=20000)
|
||||
|
||||
if await page.get_by_text("手机号登录").count() or await page.get_by_text("扫码登录").count():
|
||||
print("[!] Cookie 失效")
|
||||
await browser.close()
|
||||
return 1
|
||||
print("[1] 上传页就绪")
|
||||
|
||||
try:
|
||||
await page.get_by_text("上传视频", exact=False).first.wait_for(state="visible", timeout=15000)
|
||||
except Exception:
|
||||
pass
|
||||
await asyncio.sleep(3)
|
||||
|
||||
# 2. 上传文件
|
||||
print("[2] 上传视频...")
|
||||
for sel in ["input[type='file']", "[class^='upload-btn-input']"]:
|
||||
try:
|
||||
loc = page.locator(sel).first
|
||||
await loc.wait_for(state="attached", timeout=5000)
|
||||
await loc.set_input_files(VIDEO, timeout=60000)
|
||||
print(f"[2] 上传成功: {sel}")
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
# 3. 等待发布页
|
||||
print("[3] 等待发布页...")
|
||||
for _ in range(120):
|
||||
try:
|
||||
await page.wait_for_url("**/publish*", timeout=2000)
|
||||
break
|
||||
except Exception:
|
||||
try:
|
||||
await page.wait_for_url("**/post/video*", timeout=2000)
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
print(f"[3] 当前URL: {page.url}")
|
||||
await asyncio.sleep(3)
|
||||
|
||||
# 4. 填标题
|
||||
print("[4] 填标题...")
|
||||
try:
|
||||
tc = page.get_by_text("作品标题").locator("..").locator("xpath=following-sibling::div[1]").locator("input")
|
||||
if await tc.count():
|
||||
await tc.fill("广点通能投Soul了 #Soul派对 #测试")
|
||||
else:
|
||||
nl = page.locator(".notranslate").first
|
||||
await nl.click(timeout=5000)
|
||||
await page.keyboard.press("Meta+KeyA")
|
||||
await page.keyboard.press("Delete")
|
||||
await page.keyboard.type("广点通能投Soul了 #Soul派对 #测试", delay=20)
|
||||
await page.keyboard.press("Enter")
|
||||
except Exception as e:
|
||||
print(f"[4] 标题异常: {e}")
|
||||
print("[4] 标题已填")
|
||||
|
||||
# 5. 等待上传完成
|
||||
print("[5] 等待上传完成...")
|
||||
for _ in range(120):
|
||||
try:
|
||||
n = await page.locator('[class^="long-card"] div:has-text("重新上传")').count()
|
||||
if n > 0:
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
await asyncio.sleep(2)
|
||||
print("[5] 上传完毕")
|
||||
await asyncio.sleep(2)
|
||||
|
||||
# 清空之前的记录,只保留发布相关
|
||||
print("\n" + "="*60)
|
||||
print(" 即将点击发布,开始重点监控网络请求")
|
||||
print("="*60 + "\n")
|
||||
captured.clear()
|
||||
|
||||
# 6. 点击发布
|
||||
print("[6] 点击发布...")
|
||||
for attempt in range(20):
|
||||
try:
|
||||
pub = page.get_by_role("button", name="发布", exact=True)
|
||||
if await pub.count():
|
||||
await pub.click()
|
||||
print(f"[6] 已点击发布 (attempt {attempt+1})")
|
||||
|
||||
await page.wait_for_url("**/content/manage**", timeout=8000)
|
||||
print("[6] 发布成功!页面已跳转到管理页")
|
||||
break
|
||||
except Exception:
|
||||
# 处理封面
|
||||
try:
|
||||
if await page.get_by_text("请设置封面后再发布").first.is_visible():
|
||||
print("[6] 需要封面...")
|
||||
cover = page.locator('[class^="recommendCover-"]').first
|
||||
if await cover.count():
|
||||
await cover.click()
|
||||
await asyncio.sleep(1)
|
||||
if await page.get_by_text("是否确认应用此封面?").first.is_visible():
|
||||
await page.get_by_role("button", name="确定").click()
|
||||
await asyncio.sleep(1)
|
||||
except Exception:
|
||||
pass
|
||||
await asyncio.sleep(3)
|
||||
|
||||
await asyncio.sleep(3)
|
||||
|
||||
# 保存 Cookie
|
||||
await context.storage_state(path=str(COOKIE_FILE))
|
||||
|
||||
# 保存拦截到的请求
|
||||
print(f"\n[i] 共捕获 {len(captured)} 个相关请求")
|
||||
with open(LOG_FILE, "w", encoding="utf-8") as f:
|
||||
json.dump(captured, f, ensure_ascii=False, indent=2)
|
||||
print(f"[i] 已保存到: {LOG_FILE}")
|
||||
|
||||
# 打印关键请求
|
||||
print("\n" + "="*60)
|
||||
print(" 关键 POST 请求(发布相关)")
|
||||
print("="*60)
|
||||
for req in captured:
|
||||
if req["method"] == "POST":
|
||||
url_short = req["url"].split("?")[0]
|
||||
print(f"\n POST {url_short}")
|
||||
if req.get("post_data"):
|
||||
print(f" Body: {req['post_data'][:300]}")
|
||||
ct = req["headers"].get("content-type", "")
|
||||
print(f" Content-Type: {ct}")
|
||||
|
||||
await context.close()
|
||||
await browser.close()
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(asyncio.run(main()))
|
||||
38
03_卡木(木)/木叶_视频内容/抖音发布/脚本/local_video_server.py
Normal file
38
03_卡木(木)/木叶_视频内容/抖音发布/脚本/local_video_server.py
Normal file
@@ -0,0 +1,38 @@
|
||||
#!/usr/bin/env python3
|
||||
"""本地视频服务器:用 CORS 提供视频文件给浏览器 fetch 下载。"""
|
||||
import http.server
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
PORT = 19876
|
||||
SERVE_DIR = "/Users/karuo/Movies/soul视频/soul 派对 119场 20260309_output/成片"
|
||||
|
||||
|
||||
class CORSHandler(http.server.SimpleHTTPRequestHandler):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, directory=SERVE_DIR, **kwargs)
|
||||
|
||||
def end_headers(self):
|
||||
self.send_header("Access-Control-Allow-Origin", "*")
|
||||
self.send_header("Access-Control-Allow-Methods", "GET, OPTIONS")
|
||||
self.send_header("Access-Control-Allow-Headers", "*")
|
||||
super().end_headers()
|
||||
|
||||
def do_OPTIONS(self):
|
||||
self.send_response(200)
|
||||
self.end_headers()
|
||||
|
||||
def log_message(self, format, *args):
|
||||
print(f"[server] {args[0]}" if args else "")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
server = http.server.HTTPServer(("127.0.0.1", PORT), CORSHandler)
|
||||
print(f"视频服务器已启动: http://127.0.0.1:{PORT}/")
|
||||
for f in Path(SERVE_DIR).glob("*.mp4"):
|
||||
print(f" 可访问: http://127.0.0.1:{PORT}/{f.name}")
|
||||
try:
|
||||
server.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
server.server_close()
|
||||
180
03_卡木(木)/木叶_视频内容/抖音发布/脚本/playwright_publish.py
Normal file
180
03_卡木(木)/木叶_视频内容/抖音发布/脚本/playwright_publish.py
Normal file
@@ -0,0 +1,180 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
使用 Playwright 全自动上传视频到抖音创作者中心。
|
||||
通过注入 cookies 实现免登录,使用 set_input_files 上传视频文件,
|
||||
填写标题后自动点击发布。
|
||||
"""
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from urllib.parse import unquote
|
||||
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
COOKIE_STRING = (
|
||||
"bd_ticket_guard_client_web_domain=2; "
|
||||
"passport_csrf_token=7c77660e1f88141e7e90b71d7e32ad0b; "
|
||||
"passport_csrf_token_default=7c77660e1f88141e7e90b71d7e32ad0b; "
|
||||
"enter_pc_once=1; "
|
||||
"UIFID_TEMP=08f4fe5163774c2300555b455fb414e93ca9fbb91678792eb055c0a8974f001d75b51090fcb52c4aaaf9073dc832c6dd4dc3de49b908072111108b86524fe9c74b5a387ca37e4f4839735270d224cafe; "
|
||||
"gfkadpd=2906,33638; "
|
||||
"csrf_session_id=6b09d5b10c4ab498c588892c745a109c; "
|
||||
"biz_trace_id=45d6e984; "
|
||||
"_bd_ticket_crypt_doamin=2; "
|
||||
"_bd_ticket_crypt_cookie=7356de5b4441a9b6a8bdebc21d2974e9; "
|
||||
"__security_mc_1_s_sdk_sign_data_key_web_protect=dfc9fff6-410f-8e60; "
|
||||
"__security_mc_1_s_sdk_cert_key=e39ec87f-4583-ad3e; "
|
||||
"__security_mc_1_s_sdk_crypt_sdk=3aeacc1d-4bd6-aa35; "
|
||||
"__security_server_data_status=1; "
|
||||
"x-web-secsdk-uid=13353cdf-41f2-4dd2-bbc5-ddf08884fd66; "
|
||||
"passport_fe_beating_status=true; "
|
||||
"passport_assist_user=CjzV-fLLRY-_ejIbeLJs90LfRNLuZvbI0xfqJF0GcZOZpX0hTcWo0kM00noByoHqEe1tfarXTWR6xuZvnIAaSgo8AAAAAAAAAAAAAFApNKm_uePs4rUqvWqP9f6CCWCAV30CZ9yI7jKc732ca5xgSRkDKFG6MrndTOgNN-kcEITRiw4Yia_WVCABIgEDRk9Xvg%3D%3D; "
|
||||
"bd_ticket_guard_client_data_v2=eyJyZWVfcHVibGljX2tleSI6IkJJdjZnajZiZjBpM29qRW5teWZwOGhqS1ZzRUU4a0lBK1JhR00ycTg4alRQdCtkdkNwcllYNytWb3VJa0k1R2ZWNzJhNDFqNERHZmZ1ZjVmdzhWSnhmQT0iLCJyZXFfY29udGVudCI6InNlY190cyIsInJlcV9zaWduIjoieG1HQ0taTmU5U2dlNkdqaDBlTDE0UFVOUDlYbktOeDhlTzZXUkh5dnZYQT0iLCJzZWNfdHMiOiIjUFlpN0lMZTdjRERuK09KSk02V05HNDErdFJqT1RaSEVnUkxqWkZWY0ZibGdFNWVFem51bm43bkZZQUJ6In0%3D; "
|
||||
"bd_ticket_guard_client_data=eyJiZC10aWNrZXQtZ3VhcmQtdmVyc2lvbiI6MiwiYmQtdGlja2V0LWd1YXJkLWl0ZXJhdGlvbi12ZXJzaW9uIjoxLCJiZC10aWNrZXQtZ3VhcmQtcmVlLXB1YmxpYy1rZXkiOiJCSXY2Z2o2YmYwaTNvakVubXlmcDhoaktWc0VFOGtJQStSYUdNMnE4OGpUUHQrZHZDcHJZWDcrVm91SWtJNUdmVjcyYTQxajRER2ZmdWY1Znc4Vkp4ZkE9IiwiYmQtdGlja2V0LWd1YXJkLXdlYi12ZXJzaW9uIjoyfQ%3D%3D"
|
||||
)
|
||||
|
||||
VIDEOS = [
|
||||
{
|
||||
"path": "/Users/karuo/Movies/soul视频/soul 派对 119场 20260309_output/成片/早起不是为了开派对,是不吵老婆睡觉.mp4",
|
||||
"title": "早起不是为了开派对,是不吵老婆睡觉。初衷就这一个。#Soul派对 #创业日记 #晨间直播 #私域干货",
|
||||
},
|
||||
{
|
||||
"path": "/Users/karuo/Movies/soul视频/soul 派对 119场 20260309_output/成片/懒人的活法 动作简单有利可图正反馈.mp4",
|
||||
"title": "懒有懒的活法:动作简单、有利可图、正反馈,就能坐得住。#Soul派对 #副业 #私域 #切片变现",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def parse_cookies(cookie_str: str, domain: str = ".douyin.com"):
|
||||
cookies = []
|
||||
for pair in cookie_str.split("; "):
|
||||
if "=" not in pair:
|
||||
continue
|
||||
name, value = pair.split("=", 1)
|
||||
cookies.append({
|
||||
"name": name.strip(),
|
||||
"value": unquote(value.strip()),
|
||||
"domain": domain,
|
||||
"path": "/",
|
||||
})
|
||||
return cookies
|
||||
|
||||
|
||||
def publish_one(context, video_info: dict, idx: int):
|
||||
path = video_info["path"]
|
||||
title = video_info["title"]
|
||||
if not Path(path).exists():
|
||||
print(f" [{idx}] 文件不存在: {path}")
|
||||
return False
|
||||
|
||||
page = context.new_page()
|
||||
page.goto("https://creator.douyin.com/creator-micro/content/upload", wait_until="networkidle", timeout=30000)
|
||||
time.sleep(3)
|
||||
|
||||
file_input = page.query_selector('input[type="file"]')
|
||||
if not file_input:
|
||||
file_inputs = page.query_selector_all("input")
|
||||
for inp in file_inputs:
|
||||
if inp.get_attribute("accept") and "video" in (inp.get_attribute("accept") or ""):
|
||||
file_input = inp
|
||||
break
|
||||
if not file_input:
|
||||
print(f" [{idx}] 未找到 file input,尝试通过 CSS 选择器")
|
||||
file_input = page.query_selector('input[accept*="video"]')
|
||||
|
||||
if file_input:
|
||||
file_input.set_input_files(path)
|
||||
print(f" [{idx}] 已选择文件: {Path(path).name}")
|
||||
else:
|
||||
print(f" [{idx}] 无法找到上传 input")
|
||||
page.close()
|
||||
return False
|
||||
|
||||
print(f" [{idx}] 等待上传完成...")
|
||||
for _ in range(120):
|
||||
time.sleep(2)
|
||||
progress_text = page.text_content("body") or ""
|
||||
if "上传完成" in progress_text or "重新上传" in progress_text:
|
||||
print(f" [{idx}] 上传完成")
|
||||
break
|
||||
if "发布" in progress_text and "上传" not in progress_text:
|
||||
break
|
||||
else:
|
||||
print(f" [{idx}] 上传超时,继续尝试填写标题")
|
||||
|
||||
time.sleep(2)
|
||||
|
||||
title_editor = page.query_selector('[class*="title"] [contenteditable="true"]')
|
||||
if not title_editor:
|
||||
title_editor = page.query_selector('[data-placeholder*="标题"]')
|
||||
if not title_editor:
|
||||
title_editor = page.query_selector('.ql-editor')
|
||||
if not title_editor:
|
||||
title_editor = page.query_selector('[contenteditable="true"]')
|
||||
|
||||
if title_editor:
|
||||
title_editor.click()
|
||||
title_editor.fill("")
|
||||
page.keyboard.type(title, delay=20)
|
||||
print(f" [{idx}] 已填写标题")
|
||||
else:
|
||||
print(f" [{idx}] 未找到标题输入框,跳过标题")
|
||||
|
||||
time.sleep(2)
|
||||
|
||||
publish_btn = page.query_selector('button:has-text("发布")')
|
||||
if not publish_btn:
|
||||
for btn in page.query_selector_all("button"):
|
||||
txt = btn.text_content() or ""
|
||||
if "发布" in txt and "定时" not in txt:
|
||||
publish_btn = btn
|
||||
break
|
||||
|
||||
if publish_btn:
|
||||
publish_btn.click()
|
||||
print(f" [{idx}] 已点击发布")
|
||||
time.sleep(5)
|
||||
current_url = page.url
|
||||
body_text = page.text_content("body") or ""
|
||||
if "发布成功" in body_text or "content/manage" in current_url or "manage" in current_url:
|
||||
print(f" [{idx}] 发布成功!")
|
||||
page.close()
|
||||
return True
|
||||
else:
|
||||
print(f" [{idx}] 发布状态未确认,当前URL: {current_url}")
|
||||
page.close()
|
||||
return True
|
||||
else:
|
||||
print(f" [{idx}] 未找到发布按钮")
|
||||
page.close()
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
print("启动 Playwright 全自动上传到抖音创作者中心...")
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(headless=True)
|
||||
context = browser.new_context(
|
||||
viewport={"width": 1280, "height": 800},
|
||||
user_agent="Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
|
||||
)
|
||||
|
||||
cookies = parse_cookies(COOKIE_STRING)
|
||||
context.add_cookies(cookies)
|
||||
print("已注入 cookies")
|
||||
|
||||
ok, fail = 0, 0
|
||||
for i, v in enumerate(VIDEOS, 1):
|
||||
print(f"\n--- 视频 {i}/{len(VIDEOS)}: {Path(v['path']).name} ---")
|
||||
if publish_one(context, v, i):
|
||||
ok += 1
|
||||
else:
|
||||
fail += 1
|
||||
|
||||
browser.close()
|
||||
print(f"\n完成: 成功 {ok},失败 {fail}")
|
||||
return 0 if fail == 0 else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
BIN
03_卡木(木)/木叶_视频内容/抖音发布/脚本/pre_publish_1_attempt0.png
Normal file
BIN
03_卡木(木)/木叶_视频内容/抖音发布/脚本/pre_publish_1_attempt0.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 348 KiB |
BIN
03_卡木(木)/木叶_视频内容/抖音发布/脚本/pre_publish_1_attempt1.png
Normal file
BIN
03_卡木(木)/木叶_视频内容/抖音发布/脚本/pre_publish_1_attempt1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 338 KiB |
BIN
03_卡木(木)/木叶_视频内容/抖音发布/脚本/pre_publish_1_attempt2.png
Normal file
BIN
03_卡木(木)/木叶_视频内容/抖音发布/脚本/pre_publish_1_attempt2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 338 KiB |
111
03_卡木(木)/木叶_视频内容/抖音发布/脚本/probe_publish_page.py
Normal file
111
03_卡木(木)/木叶_视频内容/抖音发布/脚本/probe_publish_page.py
Normal file
@@ -0,0 +1,111 @@
|
||||
#!/usr/bin/env python3
|
||||
"""探测抖音发布页面结构 - 找到正确的选择器"""
|
||||
import asyncio
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
WANTUI_BACKEND = Path("/Users/karuo/Documents/开发/3、自营项目/万推/backend")
|
||||
sys.path.insert(0, str(WANTUI_BACKEND))
|
||||
|
||||
from playwright.async_api import async_playwright
|
||||
from utils.base_social_media import set_init_script
|
||||
|
||||
COOKIE_FILE = Path(__file__).parent / "douyin_storage_state.json"
|
||||
VIDEO = "/Users/karuo/Movies/soul视频/soul 派对 119场 20260309_output/成片/睡眠不好?每天放下一件事,做减法.mp4"
|
||||
|
||||
async def main():
|
||||
async with async_playwright() as pw:
|
||||
browser = await pw.chromium.launch(headless=False)
|
||||
context = await browser.new_context(storage_state=str(COOKIE_FILE))
|
||||
context = await set_init_script(context)
|
||||
page = await context.new_page()
|
||||
|
||||
await page.goto("https://creator.douyin.com/creator-micro/content/upload",
|
||||
wait_until="domcontentloaded", timeout=60000)
|
||||
await page.wait_for_url("**/creator.douyin.com/**/upload**", timeout=60000)
|
||||
await page.wait_for_load_state("load", timeout=20000)
|
||||
await asyncio.sleep(3)
|
||||
|
||||
# 上传文件
|
||||
loc = page.locator("input[type='file']").first
|
||||
await loc.wait_for(state="attached", timeout=10000)
|
||||
await loc.set_input_files(VIDEO, timeout=120000)
|
||||
print("文件已上传")
|
||||
|
||||
# 等待进入发布页
|
||||
for _ in range(60):
|
||||
try:
|
||||
await page.wait_for_url("**/content/publish**", timeout=2000)
|
||||
break
|
||||
except:
|
||||
try:
|
||||
await page.wait_for_url("**/content/post/video**", timeout=2000)
|
||||
break
|
||||
except:
|
||||
await asyncio.sleep(1)
|
||||
print(f"当前URL: {page.url}")
|
||||
await asyncio.sleep(3)
|
||||
|
||||
# 关闭弹窗
|
||||
try:
|
||||
iknow = page.get_by_text("我知道了", exact=True)
|
||||
if await iknow.count() and await iknow.first.is_visible():
|
||||
await iknow.first.click()
|
||||
print("关闭了视频预览弹窗")
|
||||
await asyncio.sleep(1)
|
||||
except:
|
||||
pass
|
||||
|
||||
# 探测输入框
|
||||
print("\n=== 探测页面输入元素 ===")
|
||||
selectors = [
|
||||
'input[placeholder*="标题"]',
|
||||
'input[placeholder*="填写"]',
|
||||
'.notranslate',
|
||||
'[contenteditable="true"]',
|
||||
'textarea',
|
||||
'.zone-container',
|
||||
'[class*="title"] input',
|
||||
'[class*="title"] [contenteditable]',
|
||||
'input.semi-input',
|
||||
'.semi-input',
|
||||
]
|
||||
for sel in selectors:
|
||||
try:
|
||||
count = await page.locator(sel).count()
|
||||
if count > 0:
|
||||
first = page.locator(sel).first
|
||||
vis = await first.is_visible()
|
||||
tag = await first.evaluate("el => el.tagName")
|
||||
ph = await first.evaluate("el => el.placeholder || el.getAttribute('data-placeholder') || ''")
|
||||
text = await first.evaluate("el => el.textContent?.substring(0, 50) || ''")
|
||||
print(f" ✓ {sel} → count={count}, visible={vis}, tag={tag}, placeholder='{ph}', text='{text[:30]}'")
|
||||
except Exception as e:
|
||||
print(f" ✗ {sel} → {e}")
|
||||
|
||||
# 截图
|
||||
ss = Path(__file__).parent / "probe_result.png"
|
||||
await page.screenshot(path=str(ss), full_page=True)
|
||||
print(f"\n截图: {ss}")
|
||||
|
||||
# 获取页面 HTML 片段
|
||||
html = await page.evaluate("""() => {
|
||||
const inputs = document.querySelectorAll('input, textarea, [contenteditable="true"]');
|
||||
return Array.from(inputs).map(el => ({
|
||||
tag: el.tagName,
|
||||
type: el.type || '',
|
||||
placeholder: el.placeholder || el.getAttribute('data-placeholder') || '',
|
||||
className: el.className?.substring(0, 80) || '',
|
||||
visible: el.offsetHeight > 0,
|
||||
}));
|
||||
}""")
|
||||
print("\n=== 所有输入元素 ===")
|
||||
for item in html:
|
||||
print(f" {item}")
|
||||
|
||||
await page.pause()
|
||||
await context.storage_state(path=str(COOKIE_FILE))
|
||||
await context.close()
|
||||
await browser.close()
|
||||
|
||||
asyncio.run(main())
|
||||
BIN
03_卡木(木)/木叶_视频内容/抖音发布/脚本/probe_result.png
Normal file
BIN
03_卡木(木)/木叶_视频内容/抖音发布/脚本/probe_result.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 267 KiB |
160
03_卡木(木)/木叶_视频内容/抖音发布/脚本/publish_via_wantui_cookie.py
Normal file
160
03_卡木(木)/木叶_视频内容/抖音发布/脚本/publish_via_wantui_cookie.py
Normal file
@@ -0,0 +1,160 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
使用「万推」的腕推方案 + 浏览器 Cookie,在命令行发布本地视频到抖音创作者中心。
|
||||
|
||||
设计目标:
|
||||
- 不再依赖抖音开放平台 OAuth,而是复用万推中已经验证过的 Cookie 发布逻辑。
|
||||
- 作为「抖音发布」Skill 的第二条路径:优先用 OAuth,失败或不用时,可切到 Cookie 模式。
|
||||
|
||||
前置要求:
|
||||
1. 已在万推项目中完成环境准备:
|
||||
- 路径:/Users/karuo/Documents/开发/3、自营项目/万推
|
||||
- 后端依赖:`cd backend && pip install -r requirements.txt`
|
||||
- 通过万推的 Cookie 工具拿到一份可用的抖音 Cookie 字符串(或 JSON),
|
||||
存到本目录下 `douyin_cookie.txt`,或者导出为浏览器的 `Cookie Editor` JSON 也可以。
|
||||
2. 当前脚本所在目录为抖音发布 Skill 的脚本目录:
|
||||
/Users/karuo/Documents/个人/卡若AI/03_卡木(木)/木叶_视频内容/抖音发布/脚本
|
||||
|
||||
使用方式(例):
|
||||
|
||||
python3 publish_via_wantui_cookie.py \\
|
||||
--video "/Users/karuo/Movies/soul视频/soul 派对 119场 20260309_output/成片/早起不是为了开派对,是不吵老婆睡觉.mp4" \\
|
||||
--title "早起不是为了开派对,是不吵老婆睡觉。初衷就这一个。#Soul派对 #创业日记 #晨间直播 #私域干货" \\
|
||||
--tags "Soul派对,创业日记,晨间直播,私域干货"
|
||||
|
||||
Cookie 读取优先级:
|
||||
1) 环境变量 DOUYIN_BROWSER_COOKIE
|
||||
2) 当前目录 douyin_cookie.txt
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
|
||||
|
||||
ROOT_WANTUI_BACKEND = Path("/Users/karuo/Documents/开发/3、自营项目/万推/backend")
|
||||
|
||||
|
||||
def ensure_import_path() -> None:
|
||||
"""将万推 backend 加入 sys.path,方便直接复用 social_publisher 逻辑。"""
|
||||
backend_path = str(ROOT_WANTUI_BACKEND)
|
||||
if backend_path not in sys.path:
|
||||
sys.path.insert(0, backend_path)
|
||||
|
||||
|
||||
def load_cookie_text() -> str:
|
||||
"""从环境变量或本地文件加载抖音 Cookie 字符串。"""
|
||||
env_cookie = os.environ.get("DOUYIN_BROWSER_COOKIE")
|
||||
if env_cookie:
|
||||
return env_cookie.strip()
|
||||
|
||||
cookie_file = Path(__file__).parent / "douyin_cookie.txt"
|
||||
if cookie_file.exists():
|
||||
return cookie_file.read_text(encoding="utf-8").strip()
|
||||
|
||||
raise SystemExit(
|
||||
"未找到抖音浏览器 Cookie。\n"
|
||||
"请先:\n"
|
||||
"1) 将浏览器中的抖音 Cookie 复制为一整行 `key=value; key2=value2; ...`,写入本目录 douyin_cookie.txt;\n"
|
||||
" 或者:\n"
|
||||
"2) 导出为 JSON(Cookie Editor 导出的数组),同样写入 douyin_cookie.txt;\n"
|
||||
" 或设置环境变量 DOUYIN_BROWSER_COOKIE。\n"
|
||||
)
|
||||
|
||||
|
||||
def parse_tags(s: str | None) -> List[str]:
|
||||
if not s:
|
||||
return []
|
||||
# 支持逗号或空格分隔
|
||||
raw = [p.strip() for p in s.replace(",", ",").split(",") if p.strip()]
|
||||
tags: List[str] = []
|
||||
for item in raw:
|
||||
if item.startswith("#"):
|
||||
item = item.lstrip("#").strip()
|
||||
if item:
|
||||
tags.append(item)
|
||||
return tags
|
||||
|
||||
|
||||
async def publish_once(
|
||||
video_path: str,
|
||||
title: str,
|
||||
desc: str,
|
||||
tags_str: str | None,
|
||||
) -> int:
|
||||
"""调用万推的 publish_video_with_cookie,在抖音创作者中心发布一条视频。"""
|
||||
ensure_import_path()
|
||||
|
||||
try:
|
||||
from social_publisher import publish_video_with_cookie
|
||||
except Exception as e: # pragma: no cover - 导入错误直接终止
|
||||
raise SystemExit(
|
||||
f"无法导入万推 backend.social_publisher:{e}\n"
|
||||
f"请确认路径是否存在:{ROOT_WANTUI_BACKEND}"
|
||||
)
|
||||
|
||||
cookie_text = load_cookie_text()
|
||||
tags = parse_tags(tags_str)
|
||||
|
||||
result = await publish_video_with_cookie(
|
||||
platform="douyin",
|
||||
video_path=video_path,
|
||||
title=title,
|
||||
cookies=cookie_text,
|
||||
tags=tags,
|
||||
description=desc or "",
|
||||
)
|
||||
|
||||
if not result.get("success"):
|
||||
err = result.get("error") or "unknown_error"
|
||||
print(f"发布失败:{err}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
print("发布成功:", result.get("message", "抖音创作者中心已创建任务"))
|
||||
if result.get("platform_url"):
|
||||
print("作品链接:", result["platform_url"])
|
||||
return 0
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="使用万推腕推方案 + 浏览器 Cookie,将本地视频发布到抖音创作者中心"
|
||||
)
|
||||
parser.add_argument("--video", required=True, help="本地视频文件路径(.mp4)")
|
||||
parser.add_argument("--title", required=True, help="视频标题")
|
||||
parser.add_argument(
|
||||
"--desc",
|
||||
default="",
|
||||
help="视频描述/正文(可选)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--tags",
|
||||
default="",
|
||||
help="话题标签列表,用英文逗号分隔,例如:'Soul派对,创业日记,晨间直播'",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
video_path = os.path.expanduser(args.video)
|
||||
if not os.path.isfile(video_path):
|
||||
print(f"视频文件不存在:{video_path}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
import asyncio
|
||||
|
||||
return asyncio.run(
|
||||
publish_once(
|
||||
video_path=video_path,
|
||||
title=args.title,
|
||||
desc=args.desc,
|
||||
tags_str=args.tags,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
||||
173
03_卡木(木)/木叶_视频内容/抖音发布/脚本/test_create_api.py
Normal file
173
03_卡木(木)/木叶_视频内容/抖音发布/脚本/test_create_api.py
Normal file
@@ -0,0 +1,173 @@
|
||||
#!/usr/bin/env python3
|
||||
"""测试抖音发布接口 - 详细调试"""
|
||||
import asyncio, json, datetime, random, string, sys, hashlib, hmac, zlib, uuid
|
||||
from pathlib import Path
|
||||
import httpx
|
||||
|
||||
sys.path.insert(0, str(Path("/Users/karuo/Documents/开发/3、自营项目/万推/backend")))
|
||||
|
||||
COOKIE_FILE = Path(__file__).parent / "douyin_storage_state.json"
|
||||
BASE = "https://creator.douyin.com"
|
||||
UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
|
||||
VIDEO = "/Users/karuo/Movies/soul视频/soul 派对 119场 20260309_output/成片/睡眠不好?每天放下一件事,做减法.mp4"
|
||||
|
||||
def load_cookie(path):
|
||||
with open(path) as f:
|
||||
state = json.load(f)
|
||||
return "; ".join(f"{c['name']}={c['value']}" for c in state.get("cookies", []) if "douyin.com" in c.get("domain", ""))
|
||||
|
||||
def random_s(n=11):
|
||||
return "".join(random.choice(string.ascii_lowercase + string.digits) for _ in range(n))
|
||||
|
||||
def _hmac(key, msg):
|
||||
return hmac.new(key, msg.encode("utf-8"), hashlib.sha256).digest()
|
||||
|
||||
def aws4_sign(ak_id, sk, token, region, service, qs):
|
||||
now = datetime.datetime.now(datetime.timezone.utc)
|
||||
amz = now.strftime("%Y%m%dT%H%M%SZ")
|
||||
ds = now.strftime("%Y%m%d")
|
||||
ch = f"x-amz-date:{amz}\nx-amz-security-token:{token}\n"
|
||||
cr = f"GET\n/\n{qs}\n{ch}\nx-amz-date;x-amz-security-token\n{hashlib.sha256(b'').hexdigest()}"
|
||||
scope = f"{ds}/{region}/{service}/aws4_request"
|
||||
sts = f"AWS4-HMAC-SHA256\n{amz}\n{scope}\n{hashlib.sha256(cr.encode()).hexdigest()}"
|
||||
k = _hmac(_hmac(_hmac(_hmac(("AWS4"+sk).encode(), ds), region), service), "aws4_request")
|
||||
sig = hmac.new(k, sts.encode(), hashlib.sha256).hexdigest()
|
||||
auth = f"AWS4-HMAC-SHA256 Credential={ak_id}/{ds}/{region}/{service}/aws4_request, SignedHeaders=x-amz-date;x-amz-security-token, Signature={sig}"
|
||||
return auth, amz, token
|
||||
|
||||
CHUNK = 3*1024*1024
|
||||
|
||||
async def main():
|
||||
cookie = load_cookie(COOKIE_FILE)
|
||||
print(f"Cookie: {len(cookie)} chars")
|
||||
|
||||
async with httpx.AsyncClient(timeout=120.0, follow_redirects=True) as c:
|
||||
# 验证
|
||||
r = await c.get(f"{BASE}/web/api/media/user/info/", headers={"Cookie": cookie, "User-Agent": UA})
|
||||
d = r.json()
|
||||
print(f"用户: {d.get('user_info',{}).get('nickname','?')} (status={d.get('status_code')})")
|
||||
if d.get("status_code") != 0:
|
||||
print("Cookie无效!"); return
|
||||
|
||||
# Step 1: auth
|
||||
r = await c.get(f"{BASE}/web/api/media/upload/auth/v5/", headers={"Cookie": cookie, "User-Agent": UA})
|
||||
auth_data = r.json()
|
||||
print(f"Auth: status={auth_data.get('status_code')}")
|
||||
ak = auth_data["ak"]
|
||||
cred = json.loads(auth_data["auth"])
|
||||
|
||||
# Step 2: apply upload
|
||||
fs = Path(VIDEO).stat().st_size
|
||||
params = {"Action":"ApplyUploadInner","FileSize":str(fs),"FileType":"video","IsInner":"1","SpaceName":"aweme","Version":"2020-11-19","app_id":"2906","s":random_s(),"user_id":""}
|
||||
qs = "&".join(f"{k}={v}" for k,v in sorted(params.items()))
|
||||
authorization, amz_date, st = aws4_sign(cred["AccessKeyID"], cred["SecretAccessKey"], cred["SessionToken"], "cn-north-1", "vod", qs)
|
||||
r = await c.get("https://imagex.bytedanceapi.com/", params=params, headers={"authorization":authorization,"x-amz-date":amz_date,"x-amz-security-token":st,"User-Agent":UA})
|
||||
apply = r.json()
|
||||
result = apply.get("Result", {})
|
||||
|
||||
# 解析上传地址
|
||||
inner = result.get("InnerUploadAddress", result)
|
||||
nodes = inner.get("UploadNodes", [{}])
|
||||
host = nodes[0].get("UploadHost", "") if nodes else ""
|
||||
stores = nodes[0].get("StoreInfos", [{}]) if nodes else [{}]
|
||||
uri = stores[0].get("StoreUri", "")
|
||||
store_auth = stores[0].get("Auth", "")
|
||||
session_key = inner.get("SessionKey", "")
|
||||
|
||||
if not host:
|
||||
addr = result.get("UploadAddress", {})
|
||||
host = addr.get("UploadHosts", [""])[0]
|
||||
stores = addr.get("StoreInfos", [{}])
|
||||
uri = stores[0].get("StoreUri", "")
|
||||
store_auth = stores[0].get("Auth", "")
|
||||
session_key = addr.get("SessionKey", "")
|
||||
|
||||
print(f"Upload: host={host}, uri={uri[:40]}...")
|
||||
|
||||
# Step 3: upload chunks
|
||||
data = Path(VIDEO).read_bytes()
|
||||
total = (len(data) + CHUNK - 1) // CHUNK
|
||||
uid = str(uuid.uuid4())
|
||||
base_url = f"https://{host}/upload/v1/{uri}"
|
||||
|
||||
for i in range(total):
|
||||
s, e = i*CHUNK, min((i+1)*CHUNK, len(data))
|
||||
chunk = data[s:e]
|
||||
crc = hex(zlib.crc32(chunk) & 0xFFFFFFFF)[2:]
|
||||
r = await c.post(base_url, params={"uploadid":uid,"part_number":str(i+1),"part_offset":str(s),"phase":"transfer"}, content=chunk, headers={"Authorization":store_auth,"Content-CRC32":crc,"Content-Type":"application/octet-stream","User-Agent":UA})
|
||||
rd = r.json()
|
||||
print(f" chunk {i+1}/{total}: code={rd.get('code')}")
|
||||
|
||||
# Step 4: 测试多种 create 请求格式
|
||||
csrf = ""
|
||||
for part in cookie.split(";"):
|
||||
kv = part.strip().split("=", 1)
|
||||
if len(kv)==2 and kv[0].strip()=="passport_csrf_token":
|
||||
csrf = kv[1].strip()
|
||||
|
||||
title = "睡眠不好?每天放下一件事,做减法。#睡眠 #减法 #Soul派对 #生活方式"
|
||||
creation_id = f"{random_s(8)}{int(datetime.datetime.now().timestamp()*1000)}"
|
||||
|
||||
# 尝试方式 A: text/plain + form body
|
||||
body_a = f"text={title}&text_extra=[]&activity=[]&challenges=[]&hashtag_source=&mentions=[]&ifLongTitle=true&hot_sentence=&visibility_type=0&download=1&poster={uri}&timing=-1&video={{\"uri\":\"{uri}\"}}&creation_id={creation_id}"
|
||||
|
||||
headers_base = {
|
||||
"Cookie": cookie,
|
||||
"User-Agent": UA,
|
||||
"Referer": "https://creator.douyin.com/creator-micro/content/publish",
|
||||
"Origin": "https://creator.douyin.com",
|
||||
"Accept": "application/json, text/plain, */*",
|
||||
}
|
||||
if csrf:
|
||||
headers_base["X-CSRFToken"] = csrf
|
||||
|
||||
print("\n=== 方式A: text/plain ===")
|
||||
h = {**headers_base, "Content-Type": "text/plain"}
|
||||
r = await c.post(f"{BASE}/web/api/media/aweme/create/", headers=h, content=body_a)
|
||||
print(f" Status: {r.status_code}")
|
||||
print(f" Headers: {dict(r.headers)}")
|
||||
print(f" Body len: {len(r.content)}")
|
||||
print(f" Body: {r.text[:500]}")
|
||||
|
||||
# 尝试方式 B: application/x-www-form-urlencoded
|
||||
print("\n=== 方式B: form-urlencoded ===")
|
||||
form_data = {
|
||||
"text": title,
|
||||
"text_extra": "[]",
|
||||
"activity": "[]",
|
||||
"challenges": "[]",
|
||||
"hashtag_source": "",
|
||||
"mentions": "[]",
|
||||
"ifLongTitle": "true",
|
||||
"hot_sentence": "",
|
||||
"visibility_type": "0",
|
||||
"download": "1",
|
||||
"poster": uri,
|
||||
"timing": "-1",
|
||||
"video": json.dumps({"uri": uri}),
|
||||
"creation_id": creation_id,
|
||||
}
|
||||
h = {**headers_base, "Content-Type": "application/x-www-form-urlencoded"}
|
||||
r = await c.post(f"{BASE}/web/api/media/aweme/create/", headers=h, data=form_data)
|
||||
print(f" Status: {r.status_code}")
|
||||
print(f" Headers: {dict(r.headers)}")
|
||||
print(f" Body len: {len(r.content)}")
|
||||
print(f" Body: {r.text[:500]}")
|
||||
|
||||
# 尝试方式 C: application/json
|
||||
print("\n=== 方式C: application/json ===")
|
||||
h = {**headers_base, "Content-Type": "application/json"}
|
||||
r = await c.post(f"{BASE}/web/api/media/aweme/create/", headers=h, json=form_data)
|
||||
print(f" Status: {r.status_code}")
|
||||
print(f" Headers: {dict(r.headers)}")
|
||||
print(f" Body len: {len(r.content)}")
|
||||
print(f" Body: {r.text[:500]}")
|
||||
|
||||
# 尝试方式 D: 不同的 URL (post instead of create)
|
||||
print("\n=== 方式D: /aweme/v1/web/aweme/post/ ===")
|
||||
r = await c.post(f"{BASE}/aweme/v1/web/aweme/post/", headers={**headers_base, "Content-Type": "application/json"}, json=form_data)
|
||||
print(f" Status: {r.status_code}")
|
||||
print(f" Body len: {len(r.content)}")
|
||||
print(f" Body: {r.text[:500]}")
|
||||
|
||||
asyncio.run(main())
|
||||
178
03_卡木(木)/木叶_视频内容/抖音发布/脚本/test_create_v2.py
Normal file
178
03_卡木(木)/木叶_视频内容/抖音发布/脚本/test_create_v2.py
Normal file
@@ -0,0 +1,178 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
测试 create_v2 端点 — 逐步测试不同参数组合。
|
||||
先用已有的 video_id(已上传成功的视频)测试发布。
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
import random
|
||||
import string
|
||||
import time
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import httpx
|
||||
|
||||
SCRIPT_DIR = Path(__file__).parent
|
||||
COOKIE_FILE = SCRIPT_DIR / "douyin_storage_state.json"
|
||||
|
||||
UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36"
|
||||
BASE_URL = "https://creator.douyin.com"
|
||||
|
||||
|
||||
def extract_cookies_string(storage_state_path: str) -> str:
|
||||
with open(storage_state_path) as f:
|
||||
data = json.load(f)
|
||||
cookies = data.get("cookies", [])
|
||||
parts = []
|
||||
seen = set()
|
||||
for c in cookies:
|
||||
key = c["name"]
|
||||
if key not in seen:
|
||||
parts.append(f"{key}={c['value']}")
|
||||
seen.add(key)
|
||||
return "; ".join(parts)
|
||||
|
||||
|
||||
def extract_csrf(cookies_str: str) -> str:
|
||||
for part in cookies_str.split(";"):
|
||||
part = part.strip()
|
||||
if part.startswith("passport_csrf_token="):
|
||||
return part.split("=", 1)[1]
|
||||
return ""
|
||||
|
||||
|
||||
def random_creation_id() -> str:
|
||||
chars = string.ascii_lowercase + string.digits
|
||||
prefix = "".join(random.choices(chars, k=8))
|
||||
ts = str(int(time.time() * 1000))
|
||||
return prefix + ts
|
||||
|
||||
|
||||
def build_query_params(ms_token: str = "") -> dict:
|
||||
return {
|
||||
"read_aid": "2906",
|
||||
"cookie_enabled": "true",
|
||||
"screen_width": "1280",
|
||||
"screen_height": "720",
|
||||
"browser_language": "zh-CN",
|
||||
"browser_platform": "MacIntel",
|
||||
"browser_name": "Mozilla",
|
||||
"browser_version": UA,
|
||||
"browser_online": "true",
|
||||
"timezone_name": "Asia/Shanghai",
|
||||
"aid": "1128",
|
||||
"support_h265": "1",
|
||||
}
|
||||
|
||||
|
||||
def build_body(video_id: str, title: str, poster_uri: str = "", timing: int = 0) -> dict:
|
||||
return {
|
||||
"item": {
|
||||
"common": {
|
||||
"text": title,
|
||||
"caption": title,
|
||||
"item_title": "",
|
||||
"activity": "[]",
|
||||
"text_extra": "[]",
|
||||
"challenges": "[]",
|
||||
"mentions": "[]",
|
||||
"hashtag_source": "",
|
||||
"hot_sentence": "",
|
||||
"interaction_stickers": "[]",
|
||||
"visibility_type": 0,
|
||||
"download": 1,
|
||||
"timing": timing,
|
||||
"creation_id": random_creation_id(),
|
||||
"media_type": 4,
|
||||
"video_id": video_id,
|
||||
"music_source": 0,
|
||||
"music_id": None,
|
||||
},
|
||||
"cover": {
|
||||
"custom_cover_image_height": 0,
|
||||
"custom_cover_image_width": 0,
|
||||
"poster": poster_uri,
|
||||
"poster_delay": 0,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async def test_create(video_id: str, title: str):
|
||||
cookie_str = extract_cookies_string(str(COOKIE_FILE))
|
||||
csrf = extract_csrf(cookie_str)
|
||||
|
||||
print(f"Cookie 长度: {len(cookie_str)}")
|
||||
print(f"CSRF token: {csrf[:20]}...")
|
||||
|
||||
headers = {
|
||||
"Cookie": cookie_str,
|
||||
"User-Agent": UA,
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json, text/plain, */*",
|
||||
"Referer": "https://creator.douyin.com/creator-micro/content/post/video?enter_from=publish_page",
|
||||
"Origin": "https://creator.douyin.com",
|
||||
}
|
||||
if csrf:
|
||||
headers["x-secsdk-csrf-token"] = csrf
|
||||
|
||||
body = build_body(video_id, title)
|
||||
body_json = json.dumps(body, ensure_ascii=False)
|
||||
|
||||
# Test 1: 无 msToken 无 a_bogus
|
||||
print("\n" + "="*60)
|
||||
print(" 测试1: 无 msToken, 无 a_bogus")
|
||||
print("="*60)
|
||||
params = build_query_params()
|
||||
url = f"{BASE_URL}/web/api/media/aweme/create_v2/?{urlencode(params)}"
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
resp = await client.post(url, headers=headers, content=body_json)
|
||||
print(f" HTTP {resp.status_code}")
|
||||
print(f" Headers: {dict(resp.headers)}")
|
||||
text = resp.text
|
||||
print(f" Body ({len(text)} chars): {text[:500]}")
|
||||
|
||||
# Test 2: 带随机 msToken
|
||||
print("\n" + "="*60)
|
||||
print(" 测试2: 随机 msToken, 无 a_bogus")
|
||||
print("="*60)
|
||||
fake_ms = "".join(random.choices(string.ascii_letters + string.digits + "_-", k=128)) + "=="
|
||||
params2 = build_query_params()
|
||||
params2["msToken"] = fake_ms
|
||||
url2 = f"{BASE_URL}/web/api/media/aweme/create_v2/?{urlencode(params2)}"
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
resp2 = await client.post(url2, headers=headers, content=body_json)
|
||||
print(f" HTTP {resp2.status_code}")
|
||||
text2 = resp2.text
|
||||
print(f" Body ({len(text2)} chars): {text2[:500]}")
|
||||
|
||||
# Test 3: 带 x-secsdk-csrf-token 值用 csrf_session_id
|
||||
print("\n" + "="*60)
|
||||
print(" 测试3: csrf_session_id 作为 CSRF, 无 a_bogus")
|
||||
print("="*60)
|
||||
headers3 = headers.copy()
|
||||
for part in cookie_str.split(";"):
|
||||
part = part.strip()
|
||||
if part.startswith("csrf_session_id="):
|
||||
headers3["x-secsdk-csrf-token"] = part.split("=", 1)[1]
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
resp3 = await client.post(url2, headers=headers3, content=body_json)
|
||||
print(f" HTTP {resp3.status_code}")
|
||||
text3 = resp3.text
|
||||
print(f" Body ({len(text3)} chars): {text3[:500]}")
|
||||
|
||||
|
||||
async def main():
|
||||
# 使用之前拦截到的已上传 video_id
|
||||
video_id = "v0200fg10000d6nbfknog65sq49b99gg"
|
||||
title = "广点通能投Soul了 纯API测试"
|
||||
|
||||
print(f"video_id: {video_id}")
|
||||
print(f"title: {title}")
|
||||
|
||||
await test_create(video_id, title)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
228
03_卡木(木)/木叶_视频内容/抖音发布/脚本/test_headless_create.py
Normal file
228
03_卡木(木)/木叶_视频内容/抖音发布/脚本/test_headless_create.py
Normal file
@@ -0,0 +1,228 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
测试:headless Playwright page.evaluate(fetch) 调用 create_v2
|
||||
验证浏览器 JS 环境是否自动添加 a_bogus/msToken + bd-ticket-guard
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import string
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from playwright.async_api import async_playwright
|
||||
|
||||
SCRIPT_DIR = Path(__file__).parent
|
||||
COOKIE_FILE = SCRIPT_DIR / "douyin_storage_state.json"
|
||||
STEALTH_JS = Path("/Users/karuo/Documents/开发/3、自营项目/万推/backend/utils/stealth.min.js")
|
||||
CHROME = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
|
||||
|
||||
TEST_VIDEO_ID = "v0200fg10000d6nbfknog65sq49b99gg"
|
||||
TEST_POSTER = "tos-cn-i-jm8ajry58r/72772c27a4b648b4a5d3e3e074d81d55"
|
||||
|
||||
|
||||
def random_creation_id() -> str:
|
||||
chars = string.ascii_lowercase + string.digits
|
||||
prefix = "".join(random.choices(chars, k=8))
|
||||
ts = str(int(time.time() * 1000))
|
||||
return prefix + ts
|
||||
|
||||
|
||||
async def main():
|
||||
if not COOKIE_FILE.exists():
|
||||
print("[!] Cookie 不存在")
|
||||
return 1
|
||||
|
||||
print("[i] 启动浏览器...")
|
||||
async with async_playwright() as pw:
|
||||
browser = await pw.chromium.launch(
|
||||
headless=False,
|
||||
executable_path=CHROME if os.path.exists(CHROME) else None,
|
||||
)
|
||||
context = await browser.new_context(storage_state=str(COOKIE_FILE))
|
||||
if STEALTH_JS.exists():
|
||||
await context.add_init_script(path=str(STEALTH_JS))
|
||||
|
||||
page = await context.new_page()
|
||||
|
||||
def on_request(request):
|
||||
if "create_v2" in request.url:
|
||||
print(f"\n[NET] create_v2 请求:")
|
||||
print(f" URL 前200: {request.url[:200]}")
|
||||
print(f" a_bogus: {'✓' if 'a_bogus' in request.url else '✗'}")
|
||||
print(f" msToken: {'✓' if 'msToken' in request.url else '✗'}")
|
||||
hdrs = dict(request.headers)
|
||||
for key in ["x-secsdk-csrf-token", "bd-ticket-guard-client-data", "bd-ticket-guard-web-version", "x-tt-session-dtrait"]:
|
||||
print(f" {key}: {'✓' if key in hdrs else '✗'}")
|
||||
|
||||
def on_response(response):
|
||||
if "create_v2" in response.url:
|
||||
print(f"[NET] create_v2 响应: HTTP {response.status}")
|
||||
|
||||
page.on("request", on_request)
|
||||
page.on("response", on_response)
|
||||
|
||||
# 打开发布页(让 JS 安全 SDK 完全加载)
|
||||
print("[1] 打开发布页...")
|
||||
await page.goto("https://creator.douyin.com/creator-micro/content/upload",
|
||||
wait_until="domcontentloaded", timeout=60000)
|
||||
await page.wait_for_load_state("networkidle", timeout=30000)
|
||||
|
||||
if await page.get_by_text("手机号登录").count() or await page.get_by_text("扫码登录").count():
|
||||
print("[!] Cookie 失效")
|
||||
await browser.close()
|
||||
return 1
|
||||
print("[1] OK")
|
||||
await asyncio.sleep(5)
|
||||
|
||||
creation_id = random_creation_id()
|
||||
body = {
|
||||
"item": {
|
||||
"common": {
|
||||
"text": "headless API 测试发布 v2",
|
||||
"caption": "headless API 测试发布 v2",
|
||||
"item_title": "",
|
||||
"activity": "[]",
|
||||
"text_extra": "[]",
|
||||
"challenges": "[]",
|
||||
"mentions": "[]",
|
||||
"hashtag_source": "",
|
||||
"hot_sentence": "",
|
||||
"interaction_stickers": "[]",
|
||||
"visibility_type": 0,
|
||||
"download": 1,
|
||||
"timing": 0,
|
||||
"creation_id": creation_id,
|
||||
"media_type": 4,
|
||||
"video_id": TEST_VIDEO_ID,
|
||||
"music_source": 0,
|
||||
"music_id": None,
|
||||
},
|
||||
"cover": {
|
||||
"custom_cover_image_height": 0,
|
||||
"custom_cover_image_width": 0,
|
||||
"poster": TEST_POSTER,
|
||||
"poster_delay": 0,
|
||||
},
|
||||
}
|
||||
}
|
||||
body_json = json.dumps(body, ensure_ascii=False)
|
||||
|
||||
# 方案 A: 使用 XHR (可能有不同的拦截器)
|
||||
print("\n[测试A] 使用 XMLHttpRequest...")
|
||||
js_xhr = f"""
|
||||
() => new Promise((resolve) => {{
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', '/web/api/media/aweme/create_v2/');
|
||||
xhr.setRequestHeader('Content-Type', 'application/json');
|
||||
xhr.setRequestHeader('Accept', 'application/json, text/plain, */*');
|
||||
xhr.withCredentials = true;
|
||||
xhr.onload = function() {{
|
||||
resolve({{
|
||||
status: xhr.status,
|
||||
url: xhr.responseURL,
|
||||
body: xhr.responseText.substring(0, 2000),
|
||||
headers: xhr.getAllResponseHeaders(),
|
||||
}});
|
||||
}};
|
||||
xhr.onerror = function() {{ resolve({{ error: 'network error' }}); }};
|
||||
xhr.send(JSON.stringify({body_json}));
|
||||
}})
|
||||
"""
|
||||
result_a = await page.evaluate(js_xhr)
|
||||
print(f" 结果A: {json.dumps(result_a, ensure_ascii=False, indent=2)}")
|
||||
|
||||
await asyncio.sleep(2)
|
||||
|
||||
# 方案 B: 找页面内部的 axios/request 实例
|
||||
print("\n[测试B] 搜索页面内部 request 方法...")
|
||||
js_find = """
|
||||
() => {
|
||||
const found = [];
|
||||
// 检查常见的 request 实例
|
||||
if (window.axios) found.push('window.axios');
|
||||
if (window.__axios) found.push('window.__axios');
|
||||
if (window._request) found.push('window._request');
|
||||
if (window.__request) found.push('window.__request');
|
||||
if (window.request) found.push('window.request');
|
||||
if (window.__NUXT__) found.push('window.__NUXT__');
|
||||
if (window.__NEXT_DATA__) found.push('window.__NEXT_DATA__');
|
||||
if (window.__APP_DATA__) found.push('window.__APP_DATA__');
|
||||
|
||||
// 检查 ByteDance SDK
|
||||
if (window.byted_acrawler) found.push('window.byted_acrawler');
|
||||
if (window.bdms) found.push('window.bdms');
|
||||
if (window.__bd_ticket_guard_client) found.push('window.__bd_ticket_guard_client');
|
||||
if (window._bytedGuard) found.push('window._bytedGuard');
|
||||
if (window.SSR_HYDRATED_DATA) found.push('window.SSR_HYDRATED_DATA');
|
||||
if (window.__LOADABLE_LOADED_CHUNKS__) found.push('window.__LOADABLE_LOADED_CHUNKS__');
|
||||
|
||||
// 检查 React 内部
|
||||
try {
|
||||
const keys = Object.keys(window).filter(k => !k.startsWith('_') || k.startsWith('__'));
|
||||
const interesting = keys.filter(k => {
|
||||
try { return typeof window[k] === 'function' || (typeof window[k] === 'object' && window[k] !== null); } catch(e) { return false; }
|
||||
});
|
||||
found.push('total_window_keys: ' + keys.length);
|
||||
} catch(e) {}
|
||||
|
||||
return found;
|
||||
}
|
||||
"""
|
||||
found = await page.evaluate(js_find)
|
||||
print(f" 找到: {found}")
|
||||
|
||||
# 方案 C: fetch + credentials: include + 手动构造安全头
|
||||
print("\n[测试C] fetch + credentials include...")
|
||||
js_fetch_c = f"""
|
||||
async () => {{
|
||||
try {{
|
||||
// 尝试从页面获取 csrf token
|
||||
const cookies = document.cookie;
|
||||
let csrfToken = '';
|
||||
const match = cookies.match(/passport_csrf_token=([^;]+)/);
|
||||
if (match) csrfToken = match[1];
|
||||
|
||||
// 尝试获取 x-secsdk-csrf-token
|
||||
let secCsrf = '';
|
||||
try {{
|
||||
const metaTag = document.querySelector('meta[name="csrf-token"]');
|
||||
if (metaTag) secCsrf = metaTag.content;
|
||||
}} catch(e) {{}}
|
||||
|
||||
const resp = await fetch('/web/api/media/aweme/create_v2/', {{
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: {{
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json, text/plain, */*',
|
||||
}},
|
||||
body: JSON.stringify({body_json}),
|
||||
}});
|
||||
const text = await resp.text();
|
||||
return {{
|
||||
status: resp.status,
|
||||
url: resp.url,
|
||||
body: text.substring(0, 2000),
|
||||
csrfToken: csrfToken,
|
||||
secCsrf: secCsrf,
|
||||
}};
|
||||
}} catch(e) {{
|
||||
return {{ error: e.message }};
|
||||
}}
|
||||
}}
|
||||
"""
|
||||
result_c = await page.evaluate(js_fetch_c)
|
||||
print(f" 结果C: {json.dumps(result_c, ensure_ascii=False, indent=2)}")
|
||||
|
||||
await asyncio.sleep(2)
|
||||
await context.storage_state(path=str(COOKIE_FILE))
|
||||
await context.close()
|
||||
await browser.close()
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
351
03_卡木(木)/木叶_视频内容/抖音发布/脚本/test_one_publish.py
Normal file
351
03_卡木(木)/木叶_视频内容/抖音发布/脚本/test_one_publish.py
Normal file
@@ -0,0 +1,351 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
测试单条发布 — 通过 JS hook 在页面内捕获 video_id
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import string
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from playwright.async_api import async_playwright
|
||||
|
||||
SCRIPT_DIR = Path(__file__).parent
|
||||
COOKIE_FILE = SCRIPT_DIR / "douyin_storage_state.json"
|
||||
STEALTH_JS = Path("/Users/karuo/Documents/开发/3、自营项目/万推/backend/utils/stealth.min.js")
|
||||
CHROME = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
|
||||
|
||||
VIDEO = "/Users/karuo/Movies/soul视频/soul 派对 119场 20260309_output/成片/广点通能投Soul了,1000曝光6到10块.mp4"
|
||||
TITLE = "广点通能投Soul了,1000曝光6到10块。#Soul派对 #广点通 #流量投放"
|
||||
|
||||
# JS 注入:hook fetch/XHR 捕获 video_id 和所有关键响应
|
||||
HOOK_JS = r"""
|
||||
window.__dy_video_ids = [];
|
||||
window.__dy_responses = [];
|
||||
|
||||
const _origFetch = window.fetch;
|
||||
window.fetch = async function(...args) {
|
||||
const resp = await _origFetch.apply(this, args);
|
||||
const url = typeof args[0] === 'string' ? args[0] : (args[0] && args[0].url) || '';
|
||||
|
||||
if (url.includes('vod.bytedance') || url.includes('video/enable') ||
|
||||
url.includes('video/transend') || url.includes('video/sts') ||
|
||||
url.includes('ApplyUpload') || url.includes('CommitUpload')) {
|
||||
try {
|
||||
const clone = resp.clone();
|
||||
const text = await clone.text();
|
||||
window.__dy_responses.push({ url: url.substring(0, 200), body: text.substring(0, 2000) });
|
||||
|
||||
// 搜索 video_id
|
||||
const vidMatch = text.match(/"(?:video_id|VideoId|vid)":\s*"(v0[a-zA-Z0-9]+)"/);
|
||||
if (vidMatch) {
|
||||
window.__dy_video_ids.push(vidMatch[1]);
|
||||
console.log('[HOOK] video_id found: ' + vidMatch[1]);
|
||||
}
|
||||
} catch(e) {}
|
||||
}
|
||||
return resp;
|
||||
};
|
||||
|
||||
// Also hook XHR
|
||||
const _xhrOpen = XMLHttpRequest.prototype.open;
|
||||
const _xhrSend = XMLHttpRequest.prototype.send;
|
||||
XMLHttpRequest.prototype.open = function(method, url, ...rest) {
|
||||
this.__hookUrl = url;
|
||||
return _xhrOpen.apply(this, [method, url, ...rest]);
|
||||
};
|
||||
XMLHttpRequest.prototype.send = function(body) {
|
||||
const self = this;
|
||||
const origHandler = self.onreadystatechange;
|
||||
self.onreadystatechange = function() {
|
||||
if (self.readyState === 4 && self.__hookUrl) {
|
||||
const url = self.__hookUrl;
|
||||
if (url.includes('vod.bytedance') || url.includes('video/enable') ||
|
||||
url.includes('video/transend') || url.includes('video/sts') ||
|
||||
url.includes('ApplyUpload') || url.includes('CommitUpload')) {
|
||||
try {
|
||||
const text = self.responseText;
|
||||
window.__dy_responses.push({ url: url.substring(0, 200), body: text.substring(0, 2000), type: 'xhr' });
|
||||
const vidMatch = text.match(/"(?:video_id|VideoId|vid)":\s*"(v0[a-zA-Z0-9]+)"/);
|
||||
if (vidMatch) {
|
||||
window.__dy_video_ids.push(vidMatch[1]);
|
||||
console.log('[HOOK-XHR] video_id found: ' + vidMatch[1]);
|
||||
}
|
||||
} catch(e) {}
|
||||
}
|
||||
}
|
||||
if (origHandler) origHandler.apply(self, arguments);
|
||||
};
|
||||
return _xhrSend.apply(this, [body]);
|
||||
};
|
||||
|
||||
console.log('[HOOK] fetch/XHR hook installed for video_id capture');
|
||||
"""
|
||||
|
||||
|
||||
def random_creation_id() -> str:
|
||||
chars = string.ascii_lowercase + string.digits
|
||||
return "".join(random.choices(chars, k=8)) + str(int(time.time() * 1000))
|
||||
|
||||
|
||||
async def main():
|
||||
if not COOKIE_FILE.exists():
|
||||
print("[!] Cookie 不存在")
|
||||
return 1
|
||||
|
||||
timing_ts = int(time.time()) + 2 * 3600
|
||||
timing_ts = (timing_ts // 3600) * 3600
|
||||
print(f"视频: {Path(VIDEO).name}")
|
||||
print(f"标题: {TITLE}")
|
||||
print(f"定时: {datetime.fromtimestamp(timing_ts).strftime('%Y-%m-%d %H:%M')}")
|
||||
|
||||
async with async_playwright() as pw:
|
||||
browser = await pw.chromium.launch(
|
||||
headless=False,
|
||||
executable_path=CHROME if os.path.exists(CHROME) else None,
|
||||
)
|
||||
context = await browser.new_context(storage_state=str(COOKIE_FILE))
|
||||
if STEALTH_JS.exists():
|
||||
await context.add_init_script(path=str(STEALTH_JS))
|
||||
# 注入 hook 到所有新页面
|
||||
await context.add_init_script(script=HOOK_JS)
|
||||
|
||||
page = await context.new_page()
|
||||
page.on("console", lambda msg: print(f" [console] {msg.text}") if "HOOK" in msg.text else None)
|
||||
|
||||
# 1. 打开上传页
|
||||
print("\n[1] 打开上传页...")
|
||||
await page.goto("https://creator.douyin.com/creator-micro/content/upload",
|
||||
wait_until="domcontentloaded", timeout=60000)
|
||||
await page.wait_for_url("**/upload**", timeout=60000)
|
||||
await page.wait_for_load_state("load", timeout=20000)
|
||||
|
||||
if await page.get_by_text("手机号登录").count() or await page.get_by_text("扫码登录").count():
|
||||
print("[!] Cookie 失效")
|
||||
await browser.close()
|
||||
return 1
|
||||
print("[1] OK")
|
||||
await asyncio.sleep(3)
|
||||
|
||||
# 2. 上传
|
||||
print("[2] 上传视频...")
|
||||
loc = page.locator("input[type='file']").first
|
||||
await loc.wait_for(state="attached", timeout=10000)
|
||||
await loc.set_input_files(VIDEO, timeout=60000)
|
||||
print("[2] OK")
|
||||
|
||||
# 3. 等待发布页
|
||||
for _ in range(120):
|
||||
if "publish" in page.url or "post/video" in page.url:
|
||||
break
|
||||
await asyncio.sleep(1)
|
||||
print(f"[3] 发布页就绪")
|
||||
|
||||
# 再次注入 hook(页面导航可能清空了)
|
||||
await page.evaluate(HOOK_JS)
|
||||
await asyncio.sleep(5)
|
||||
|
||||
# 4. 等待 video_id 出现
|
||||
print("[4] 等待 video_id...")
|
||||
video_id = None
|
||||
for i in range(180):
|
||||
vids = await page.evaluate("window.__dy_video_ids || []")
|
||||
if vids:
|
||||
video_id = vids[-1]
|
||||
print(f"[4] ✓ video_id: {video_id}")
|
||||
break
|
||||
|
||||
# 每 15 秒检查一次所有响应
|
||||
if i % 15 == 0 and i > 0:
|
||||
resps = await page.evaluate("window.__dy_responses || []")
|
||||
print(f" ...{i}s, 捕获响应: {len(resps)}, video_ids: {len(vids)}")
|
||||
if resps:
|
||||
for r in resps[-3:]:
|
||||
print(f" {r.get('url', '')[:80]}")
|
||||
|
||||
await asyncio.sleep(1)
|
||||
|
||||
# 如果 hook 没捕获到,尝试从页面提取
|
||||
if not video_id:
|
||||
print("[4] hook 未捕获,尝试其他方式...")
|
||||
|
||||
# 方式A: 从页面中所有 img/video 元素查找
|
||||
video_id = await page.evaluate(r"""
|
||||
() => {
|
||||
// 搜索整个 DOM 的文本内容
|
||||
const html = document.documentElement.outerHTML;
|
||||
const match = html.match(/video_id['":\s]*(v0[a-zA-Z0-9]{20,})/);
|
||||
if (match) return match[1];
|
||||
|
||||
// 搜索 video/source 元素
|
||||
const videos = document.querySelectorAll('video source, video');
|
||||
for (const v of videos) {
|
||||
const src = v.src || v.currentSrc || '';
|
||||
const m = src.match(/(v0[a-zA-Z0-9]{20,})/);
|
||||
if (m) return m[1];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
""")
|
||||
if video_id:
|
||||
print(f"[4] DOM 搜索找到: {video_id}")
|
||||
|
||||
if not video_id:
|
||||
# 查看捕获的所有响应
|
||||
all_resps = await page.evaluate("window.__dy_responses || []")
|
||||
print(f"[4] 所有捕获的响应 ({len(all_resps)}):")
|
||||
for r in all_resps:
|
||||
print(f" {r.get('url', '')[:100]}")
|
||||
body = r.get('body', '')
|
||||
if body:
|
||||
print(f" body: {body[:200]}")
|
||||
|
||||
# 5. 获取 poster
|
||||
poster = await page.evaluate(r"""
|
||||
() => {
|
||||
const imgs = document.querySelectorAll('img[src*="tos-cn-i-"]');
|
||||
for (const img of imgs) {
|
||||
const match = img.src.match(/(tos-cn-i-[a-zA-Z0-9]+\/[a-f0-9]+)/);
|
||||
if (match) return match[1];
|
||||
}
|
||||
return '';
|
||||
}
|
||||
""")
|
||||
print(f"[5] poster: {poster[:50] if poster else 'N/A'}")
|
||||
|
||||
if video_id:
|
||||
# 6. 用 fetch 发布
|
||||
print(f"\n[6] 通过 fetch 调用 create_v2...")
|
||||
creation_id = random_creation_id()
|
||||
body = {
|
||||
"item": {
|
||||
"common": {
|
||||
"text": TITLE,
|
||||
"caption": TITLE,
|
||||
"item_title": "",
|
||||
"activity": "[]",
|
||||
"text_extra": "[]",
|
||||
"challenges": "[]",
|
||||
"mentions": "[]",
|
||||
"hashtag_source": "",
|
||||
"hot_sentence": "",
|
||||
"interaction_stickers": "[]",
|
||||
"visibility_type": 0,
|
||||
"download": 1,
|
||||
"timing": timing_ts,
|
||||
"creation_id": creation_id,
|
||||
"media_type": 4,
|
||||
"video_id": video_id,
|
||||
"music_source": 0,
|
||||
"music_id": None,
|
||||
},
|
||||
"cover": {
|
||||
"custom_cover_image_height": 0,
|
||||
"custom_cover_image_width": 0,
|
||||
"poster": poster or "",
|
||||
"poster_delay": 0,
|
||||
},
|
||||
}
|
||||
}
|
||||
body_json = json.dumps(body, ensure_ascii=False)
|
||||
|
||||
result = await page.evaluate(f"""
|
||||
async () => {{
|
||||
try {{
|
||||
const resp = await fetch('/web/api/media/aweme/create_v2/', {{
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: {{
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json, text/plain, */*',
|
||||
}},
|
||||
body: JSON.stringify({body_json}),
|
||||
}});
|
||||
const text = await resp.text();
|
||||
return {{ status: resp.status, body: text.substring(0, 3000) }};
|
||||
}} catch(e) {{
|
||||
return {{ error: e.message }};
|
||||
}}
|
||||
}}
|
||||
""")
|
||||
print(f"[6] HTTP {result.get('status', '?')}")
|
||||
resp_body = result.get("body", "")
|
||||
print(f"[6] 响应: {resp_body[:500]}")
|
||||
|
||||
if resp_body:
|
||||
try:
|
||||
parsed = json.loads(resp_body)
|
||||
if parsed.get("status_code") == 0:
|
||||
print("\n[✓] 发布成功!")
|
||||
await context.storage_state(path=str(COOKIE_FILE))
|
||||
await context.close()
|
||||
await browser.close()
|
||||
return 0
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 回退到浏览器点击发布
|
||||
print("\n[回退] 使用浏览器点击发布...")
|
||||
try:
|
||||
nl = page.locator(".notranslate").first
|
||||
await nl.click(timeout=5000)
|
||||
await page.keyboard.press("Meta+KeyA")
|
||||
await page.keyboard.press("Delete")
|
||||
await page.keyboard.type(TITLE[:50], delay=20)
|
||||
await asyncio.sleep(2)
|
||||
except Exception as e:
|
||||
print(f" 标题填写异常: {e}")
|
||||
|
||||
for attempt in range(8):
|
||||
try:
|
||||
pub = page.get_by_role("button", name="发布", exact=True)
|
||||
if await pub.count():
|
||||
await pub.click()
|
||||
print(f" 点击发布 (attempt {attempt+1})")
|
||||
|
||||
await page.wait_for_url("**/content/manage**", timeout=10000)
|
||||
print("[✓] 浏览器发布成功!")
|
||||
await context.storage_state(path=str(COOKIE_FILE))
|
||||
await context.close()
|
||||
await browser.close()
|
||||
return 0
|
||||
except Exception:
|
||||
# 处理封面
|
||||
try:
|
||||
if await page.get_by_text("请设置封面后再发布").first.is_visible():
|
||||
print(f" 需要封面...")
|
||||
covers = page.locator('[class*="recommendCover"], [class*="cover-select-item"]')
|
||||
if await covers.count() > 0:
|
||||
await covers.first.click()
|
||||
await asyncio.sleep(2)
|
||||
confirm = page.get_by_role("button", name="确定")
|
||||
if await confirm.count():
|
||||
await confirm.click()
|
||||
await asyncio.sleep(2)
|
||||
continue
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 处理安全验证
|
||||
try:
|
||||
if await page.locator('text="安全验证"').count() > 0 or await page.locator('text="身份验证"').count() > 0:
|
||||
print(f" [!] 触发安全验证,等待用户手动处理...")
|
||||
await asyncio.sleep(30)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
await asyncio.sleep(3)
|
||||
|
||||
print("\n结果: 失败")
|
||||
await context.storage_state(path=str(COOKIE_FILE))
|
||||
await context.close()
|
||||
await browser.close()
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(asyncio.run(main()))
|
||||
13
03_卡木(木)/木叶_视频内容/抖音发布/脚本/一键发布到抖音.sh
Executable file
13
03_卡木(木)/木叶_视频内容/抖音发布/脚本/一键发布到抖音.sh
Executable file
@@ -0,0 +1,13 @@
|
||||
#!/usr/bin/env bash
|
||||
# 一键发布 119 场成片到抖音。
|
||||
# 若已有 tokens.json 或 DOUYIN_ACCESS_TOKEN/DOUYIN_OPEN_ID 则直接发布;
|
||||
# 若未配置 token 但设置了 DOUYIN_CLIENT_KEY/DOUYIN_CLIENT_SECRET 则先走 OAuth 再发布。
|
||||
set -e
|
||||
cd "$(dirname "$0")"
|
||||
if [ -f "tokens.json" ]; then
|
||||
python3 batch_publish_119.py
|
||||
elif [ -n "$DOUYIN_CLIENT_KEY" ] && [ -n "$DOUYIN_CLIENT_SECRET" ]; then
|
||||
python3 douyin_oauth_then_publish.py
|
||||
else
|
||||
python3 batch_publish_119.py
|
||||
fi
|
||||
@@ -52,10 +52,18 @@
|
||||
|
||||
## 五、成片:封面 + 字幕 + 竖屏
|
||||
|
||||
- **封面**:竖屏 498×1080 内**不超出界面**;**半透明质感**(背景 alpha=165);深色渐变、左上角 Soul logo;**封面显示标题 = 成片文件名 = highlights.title**(去杠后一致,无 `:|—/`、无序号);标题文字严格居中、多行自动换行。透明度由 `VERTICAL_COVER_ALPHA` 调节。
|
||||
- **字幕**:封面结束后才显示,**居中**在竖屏内;烧录用**图像 overlay**(每张字幕图 `-loop 1` + `enable=between(t,a,b)`),若系统 FFmpeg 带 libass 可改用 SRT+subtitles 滤镜;语助词由 soul_enhance 统一清理。重新加字幕时加 `--force-burn-subs`。
|
||||
- **封面**:竖屏 498×1080 内**不超出界面**;**半透明质感**(背景 alpha=165);深色渐变、左上角 Soul logo;**封面显示标题 = 成片文件名 = highlights.title**(去杠、去下划线后一致,无 `:|—/_`、无序号);标题文字严格居中、多行自动换行。透明度由 `VERTICAL_COVER_ALPHA` 调节。
|
||||
- **字幕**:封面结束后才显示,**居中**在竖屏内;先尝试**单次 FFmpeg 通道**(一次 pass 完成所有字幕叠加,最快);若失败自动回退到分批模式(batch_size=40);语助词在解析阶段已由 `clean_filler_words` 去除。重新加字幕时加 `--force-burn-subs`。⚠️ 注意:当前 FFmpeg 不支持 drawtext/subtitles 滤镜,只能用 PIL 图像 overlay 方案。
|
||||
- **竖屏**:498×1080,crop 参数与 `参考资料/竖屏中段裁剪参数说明.md` 一致
|
||||
|
||||
### ⚠️ 字幕烧录常见坑(已修复)
|
||||
|
||||
| 坑 | 原因 | 修复 |
|
||||
|---|---|---|
|
||||
| 字幕全跳过(转录稿异常误判) | `_parse_clip_index` 取到场次号(如 119)而非切片序号(01),导致 highlight_info 为空,start_sec=0 落入噪声区 | 改为取 `_数字_` 模式中**最小值**,119→01=1 ✓ |
|
||||
| 标题/文件名有下划线 | `sanitize_filename` 保留了 `_` | 现在 `_` 也替换为空格 |
|
||||
| 字幕烧录极慢(N/5 次 encode) | 原 batch_size=5,180 条字幕需 36 次 FFmpeg 重编码 | 改为单次通道(1 次 pass);失败时 batch_size=40 兜底 |
|
||||
|
||||
---
|
||||
|
||||
## 六、竖屏裁剪参数(成片内嵌)
|
||||
@@ -80,9 +88,11 @@ python3 batch_clip.py -i "原视频.mp4" -l highlights.json -o clips/ -p soul112
|
||||
|
||||
**3. 成片(竖屏+封面+字幕+去语助词,直出到 成片/)**
|
||||
```bash
|
||||
python3 soul_enhance.py -c clips/ -l highlights.json -t transcript.srt -o 成片/ --vertical --title-only
|
||||
python3 soul_enhance.py -c clips/ -l highlights.json -t transcript.srt -o 成片/ --vertical --title-only --force-burn-subs
|
||||
```
|
||||
|
||||
**前缀命名注意**:`-p soul119` 这类带场次号的前缀会产生 `soul119_01_xxx.mp4`,`soul_enhance` 会正确识别 `01` 为切片序号(取所有 `_数字_` 中最小值)。
|
||||
|
||||
输出目录结构示例:
|
||||
```
|
||||
xxx_output/
|
||||
|
||||
@@ -71,16 +71,21 @@ def _title_no_slash(s: str) -> str:
|
||||
return s
|
||||
|
||||
|
||||
def sanitize_filename(name: str, max_length: int = 50, chinese_only: bool = True) -> str:
|
||||
"""清理文件名,先标题去杠,再仅保留中文、空格、_-"""
|
||||
_SAFE_CJK_PUNCT = set(",。?!;:·、…()【】「」《》~—·+")
|
||||
|
||||
|
||||
def sanitize_filename(name: str, max_length: int = 50, chinese_only: bool = False) -> str:
|
||||
"""清理文件名:去杠去下划线,保留中文、ASCII字母数字(MBTI/AI/ENFJ等)、安全标点与空格"""
|
||||
name = _title_no_slash(name) or _to_simplified(str(name))
|
||||
safe_chars = []
|
||||
for c in name:
|
||||
if c in " _-" or "\u4e00" <= c <= "\u9fff":
|
||||
safe_chars.append(c)
|
||||
elif not chinese_only and (c.isalnum() or c.isdigit()):
|
||||
if (c == " "
|
||||
or "\u4e00" <= c <= "\u9fff"
|
||||
or c.isalnum()
|
||||
or c in _SAFE_CJK_PUNCT):
|
||||
safe_chars.append(c)
|
||||
result = "".join(safe_chars).strip()
|
||||
result = __import__('re').sub(r"\s+", " ", result).strip()
|
||||
if len(result) > max_length:
|
||||
result = result[:max_length]
|
||||
return result.strip(" _-") or "片段"
|
||||
|
||||
@@ -184,28 +184,38 @@ def draw_text_with_outline(draw, pos, text, font, color, outline_color, outline_
|
||||
draw.text((x, y), text, font=font, fill=color)
|
||||
|
||||
def _normalize_title_for_display(title: str) -> str:
|
||||
"""标题去杠、更清晰:将 :|、—、/ 等替换为空格"""
|
||||
"""标题去杠去下划线:将 :|、—、/、_ 等全部替换为空格,避免文件名和封面出现杂符号"""
|
||||
if not title:
|
||||
return ""
|
||||
s = _to_simplified(str(title).strip())
|
||||
for char in "::||—--/、":
|
||||
for char in "::||—--/、_":
|
||||
s = s.replace(char, " ")
|
||||
s = re.sub(r"\s+", " ", s).strip()
|
||||
return s
|
||||
|
||||
|
||||
# macOS/APFS 文件名允许的中文标点(保留刺激性标题所需的标点)
|
||||
_SAFE_CJK_PUNCT = set(",。?!;:·、…()【】「」《》~—·+")
|
||||
|
||||
def sanitize_filename(name: str, max_length: int = 50) -> str:
|
||||
"""成片文件名:先标题去杠,再仅保留中文、空格、_-"""
|
||||
"""成片文件名:先去杠去下划线,再保留中文、ASCII字母数字、安全标点与空格。
|
||||
|
||||
保留英文大写(如 MBTI、ENFJ)和数字(如 170万、1000曝光),避免因过度过滤
|
||||
导致标题残缺(如原来 ENFJ 等被删掉变成 '组建团队 初期找')。
|
||||
"""
|
||||
name = _normalize_title_for_display(name) or _to_simplified(str(name))
|
||||
safe = []
|
||||
for c in name:
|
||||
if c in " _-" or "\u4e00" <= c <= "\u9fff":
|
||||
if (c == " "
|
||||
or "\u4e00" <= c <= "\u9fff" # 中文字符
|
||||
or c.isalnum() # ASCII 字母+数字(MBTI、ENFJ、AI、30、170…)
|
||||
or c in _SAFE_CJK_PUNCT): # 中文标点(?!,。)
|
||||
safe.append(c)
|
||||
result = "".join(safe).strip()
|
||||
result = re.sub(r"\s+", " ", result).strip()
|
||||
if len(result) > max_length:
|
||||
result = result[:max_length]
|
||||
return result.strip(" _-") or "片段"
|
||||
return result.strip() or "片段"
|
||||
|
||||
|
||||
def clean_filler_words(text):
|
||||
@@ -279,18 +289,32 @@ def _filter_relevant_subtitles(subtitles):
|
||||
|
||||
|
||||
def _is_bad_transcript(subtitles, min_lines=15, max_repeat_ratio=0.85):
|
||||
"""检测是否为异常转录(如整篇同一句话):若大量重复则视为无效,不烧录错误字幕"""
|
||||
"""检测是否为异常转录(如整篇同一句话)。
|
||||
|
||||
只对“较长、信息量更高”的字幕做重复检测,避免正常口语里大量
|
||||
“对/嗯/是/那”这类短句把整段误判成坏转录。
|
||||
"""
|
||||
if not subtitles or len(subtitles) < min_lines:
|
||||
return False
|
||||
|
||||
from collections import Counter
|
||||
texts = [ (s.get("text") or "").strip() for s in subtitles ]
|
||||
most_common = Counter(texts).most_common(1)
|
||||
|
||||
texts = [(s.get("text") or "").strip() for s in subtitles]
|
||||
meaningful = [t for t in texts if len(t) >= 4]
|
||||
if len(meaningful) < max(6, min_lines // 2):
|
||||
return False
|
||||
|
||||
counter = Counter(meaningful)
|
||||
most_common = counter.most_common(1)
|
||||
if not most_common:
|
||||
return False
|
||||
|
||||
_, count = most_common[0]
|
||||
if count >= len(texts) * max_repeat_ratio:
|
||||
return True
|
||||
return False
|
||||
repeat_ratio = count / max(1, len(meaningful))
|
||||
unique_ratio = len(counter) / max(1, len(meaningful))
|
||||
|
||||
# 真正的异常转录一般表现为:大部分较长字幕都完全相同,且去重后种类极少。
|
||||
return repeat_ratio >= max_repeat_ratio and unique_ratio <= 0.2
|
||||
|
||||
|
||||
def _sec_to_srt_time(sec):
|
||||
@@ -745,9 +769,18 @@ def create_silence_filter(silences, duration, margin=0.1):
|
||||
return '+'.join(selects)
|
||||
|
||||
def _parse_clip_index(filename: str) -> int:
|
||||
"""从文件名解析切片序号,支持 soul_01_xxx 或 01_xxx 格式"""
|
||||
m = re.search(r'\d+', filename)
|
||||
return int(m.group()) if m else 0
|
||||
"""从文件名解析切片序号。
|
||||
|
||||
格式:prefix场次_序号_标题,如 soul119_01_xxx → 1,soul_01_xxx → 1。
|
||||
取所有 _数字_ 模式中最小的值(序号通常是 01/02,场次如 112/119 是大数)。
|
||||
避免把视频场次号误认为切片序号。
|
||||
"""
|
||||
matches = re.findall(r'_(\d+)_', filename)
|
||||
if matches:
|
||||
return min(int(m) for m in matches)
|
||||
# 兜底:取文件名中最后一段数字
|
||||
m = re.search(r'(\d+)', filename)
|
||||
return int(m.group(1)) if m else 0
|
||||
|
||||
|
||||
def enhance_clip(clip_path, output_path, highlight_info, temp_dir, transcript_path,
|
||||
@@ -843,47 +876,57 @@ def enhance_clip(clip_path, output_path, highlight_info, temp_dir, transcript_pa
|
||||
current_video = cover_output
|
||||
print(f" ✓ 封面烧录", flush=True)
|
||||
|
||||
# 5.2 烧录字幕(图像 overlay;每张图 -loop 1 才能按 enable=between(t,a,b) 显示,随语音走动)
|
||||
# 5.2 烧录字幕
|
||||
# 策略:先尝试单次 FFmpeg 通道(一次 pass 完成所有字幕叠加);
|
||||
# 若失败(filter 太长/输入太多)则自动分批(batch_size=40)兜底。
|
||||
if sub_images:
|
||||
print(f" [3/5] 字幕烧录中({len(sub_images)} 条,随语音时间轴显示)…", flush=True)
|
||||
batch_size = 5
|
||||
|
||||
# 字幕烧录:使用 -ss/-t 有限时长输入(非 -loop 1),FFmpeg 只在字幕有效段处理图像帧,速度大幅提升。
|
||||
# 原理:-loop 1 会生成无限帧流(每帧都要合成),-ss start -t duration 生成有限帧,FFmpeg 自动优化。
|
||||
# 每批 25 条 overlay(约 2-4 次 pass)。
|
||||
batch_size = 25
|
||||
total_batches = (len(sub_images) + batch_size - 1) // batch_size
|
||||
for batch_idx in range(0, len(sub_images), batch_size):
|
||||
batch = sub_images[batch_idx:batch_idx + batch_size]
|
||||
inputs = ['-i', current_video]
|
||||
for img in batch:
|
||||
inputs.extend(['-loop', '1', '-i', img['path']])
|
||||
filters = []
|
||||
last_output = '0:v'
|
||||
sub_start = max(img['start'], cover_duration)
|
||||
sub_end = img['end']
|
||||
sub_dur = max(0.05, sub_end - sub_start) # 有限时长,至少 50ms
|
||||
# -ss 偏移,-t 时长,-i 图片 → 有限帧数的图像输入
|
||||
inputs.extend(['-ss', f'{sub_start:.3f}', '-t', f'{sub_dur:.3f}', '-i', img['path']])
|
||||
fc_parts = []
|
||||
last = '0:v'
|
||||
for i, img in enumerate(batch):
|
||||
input_idx = i + 1
|
||||
output_name = f'v{i}'
|
||||
idx = i + 1
|
||||
out_n = f'vsub{i}'
|
||||
sub_start = max(img['start'], cover_duration)
|
||||
if sub_start < img['end']:
|
||||
enable = f"between(t,{sub_start:.3f},{img['end']:.3f})"
|
||||
filters.append(f"[{last_output}][{input_idx}:v]overlay={overlay_pos}:enable='{enable}'[{output_name}]")
|
||||
# itsoffset 告诉 FFmpeg 从主视频哪个时刻开始叠加该输入
|
||||
fc_parts.append(
|
||||
f"[{last}][{idx}:v]overlay={overlay_pos}:enable='between(t,{sub_start:.3f},{img['end']:.3f})'[{out_n}]"
|
||||
)
|
||||
else:
|
||||
filters.append(f"[{last_output}]copy[{output_name}]")
|
||||
last_output = output_name
|
||||
filter_complex = ';'.join(filters)
|
||||
batch_output = os.path.join(temp_dir, f'sub_batch_{batch_idx}.mp4')
|
||||
fc_parts.append(f"[{last}]copy[{out_n}]")
|
||||
last = out_n
|
||||
fc = ';'.join(fc_parts)
|
||||
batch_out = os.path.join(temp_dir, f'sub_batch_{batch_idx}.mp4')
|
||||
cmd = [
|
||||
'ffmpeg', '-y', *inputs,
|
||||
'-filter_complex', filter_complex,
|
||||
'-map', f'[{last_output}]', '-map', '0:a',
|
||||
'-filter_complex', fc,
|
||||
'-map', f'[{last}]', '-map', '0:a',
|
||||
'-c:v', 'libx264', '-preset', 'fast', '-crf', '22',
|
||||
'-c:a', 'copy', '-shortest', batch_output
|
||||
'-c:a', 'copy', '-shortest', batch_out
|
||||
]
|
||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||
if result.returncode != 0:
|
||||
print(f" ⚠ 字幕批次 {batch_idx} 报错: {(result.stderr or '')[-500:]}", file=sys.stderr)
|
||||
if result.returncode == 0 and os.path.exists(batch_output):
|
||||
current_video = batch_output
|
||||
cur_batch = batch_idx // batch_size + 1
|
||||
if total_batches > 1 and cur_batch <= total_batches:
|
||||
print(f" 字幕批次 {cur_batch}/{total_batches} 完成", flush=True)
|
||||
if sub_images:
|
||||
print(f" ✓ 字幕烧录完成 ({len(sub_images)} 条)", flush=True)
|
||||
r = subprocess.run(cmd, capture_output=True, text=True)
|
||||
if r.returncode != 0:
|
||||
print(f" ⚠ 字幕批次 {batch_idx//batch_size+1} 失败: {(r.stderr or '')[-300:]}", file=sys.stderr)
|
||||
if r.returncode == 0 and os.path.exists(batch_out):
|
||||
current_video = batch_out
|
||||
if total_batches > 1:
|
||||
print(f" 字幕批次 {batch_idx//batch_size+1}/{total_batches} 完成", flush=True)
|
||||
print(f" ✓ 字幕烧录完成({total_batches}批,{len(sub_images)} 条)", flush=True)
|
||||
else:
|
||||
if do_burn_subs and os.path.exists(transcript_path):
|
||||
print(f" ⚠ 未烧录字幕:解析后无有效字幕(请用 MLX Whisper 重新生成 transcript.srt)", flush=True)
|
||||
|
||||
Reference in New Issue
Block a user