🔄 卡若AI 同步 2026-03-09 22:16 | 更新:水桥平台对接、水溪整理归档、卡木、运营中枢工作台 | 排除 >20MB: 11 个

This commit is contained in:
2026-03-09 22:16:22 +08:00
parent 1345fa0a6b
commit c35c03aa5d
41 changed files with 5080 additions and 67 deletions

View File

@@ -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` 第四节。
---

View File

@@ -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 或本参考资料,脚本可改为调用对应接口。发布清单(标题、描述、话题)可直接复制到该类工具使用。

File diff suppressed because one or more lines are too long

View 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())

File diff suppressed because one or more lines are too long

View 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 年轻人测MBTI40到60岁走五行八卦.mp4":
"年轻人测MBTI40到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()))

View 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 年轻人测MBTI40到60岁走五行八卦.mp4":
"年轻人测MBTI40到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+AmacOS 全选)清空
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()))

View 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

View 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 年轻人测MBTI40到60岁走五行八卦.mp4":
"年轻人测MBTI40到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_idhook 已通过 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()))

View 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())

View 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()

View 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 年轻人测MBTI40到60岁走五行八卦.mp4":
"年轻人测MBTI40到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()))

View 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 年轻人测MBTI40到60岁走五行八卦.mp4":
"年轻人测MBTI40到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()))

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 604 KiB

View 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()))

View 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()))

View 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()

View 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())

Binary file not shown.

After

Width:  |  Height:  |  Size: 348 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 338 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 338 KiB

View 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())

Binary file not shown.

After

Width:  |  Height:  |  Size: 267 KiB

View 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) 导出为 JSONCookie 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())

View 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())

View 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())

View 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())

View 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()))

View 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

View File

@@ -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×1080crop 参数与 `参考资料/竖屏中段裁剪参数说明.md` 一致
### ⚠️ 字幕烧录常见坑(已修复)
| 坑 | 原因 | 修复 |
|---|---|---|
| 字幕全跳过(转录稿异常误判) | `_parse_clip_index` 取到场次号(如 119而非切片序号01导致 highlight_info 为空start_sec=0 落入噪声区 | 改为取 `_数字_` 模式中**最小值**119→01=1 ✓ |
| 标题/文件名有下划线 | `sanitize_filename` 保留了 `_` | 现在 `_` 也替换为空格 |
| 字幕烧录极慢N/5 次 encode | 原 batch_size=5180 条字幕需 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/

View File

@@ -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 "片段"

View File

@@ -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 → 1soul_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 1FFmpeg 只在字幕有效段处理图像帧,速度大幅提升。
# 原理:-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)