feat(monitor): 抖音 Web 接口客户端 —— 绕开爬虫子进程,直接发 HTTP
Deploy VitePress site to Pages / build (push) Canceled after 0s
Deploy VitePress site to Pages / Deploy (push) Canceled after 0s

移植自 mac-agent-os 的 mediacrawler_adapter:不起子进程、不开页面,用浏览器里那份
登录态直接调抖音 Web 接口。产物键名照抄 store/douyin,所以 ingest 那条链路一个字不用改。

**目前能用的(真环境实测,非推断)**:

    profile/other : 200, 7075 字节  —— 博主主页指标(粉丝/获赞/作品数/昵称)
    aweme/detail  : 200, 45425 字节 —— 单条作品详情(含点赞/评论/收藏/分享)

**目前不能用的:作品列表 `aweme/post`。** 两个互相独立的原因:
  1. 这个接口被抖音单独升级成了真校验:不带 x-tt-argus 回 403「Uifid Not Found」,
     带上 dummy 值回 200 + **空 body**。也就是说「头在不在」骗得过,「真校验」过不了。
     同一套头打 profile/other 和 aweme/detail 都是通的 —— 抖音是挑着接口加保护的,
     挑中的恰好是「批量拉作品列表」这个最敏感的动作。
  2. 改走页面截获也不行:CDP 浏览器打开博主主页会落到「验证码中间页」(当天大量探测的
     代价,过几小时要重测)。

  所以现在的边界是:**已知作品的指标刷新能做,自动发现新作品做不了**。

**排查中控住变量后得到的两条事实**(都写进注释了):
  · `Accept` / `Accept-Language` / `Referer` 才是主页接口能返回真数据的原因 —— 只有
    UA+client hints+Cookie 时是 200 但仅 121 字节的空壳,补上这三个头变 7074 字节。
    (我先前猜的 sec-ch-ua 不是关键。)
  · 因此 UA 与 client hints 必须**成套地取自同一个浏览器**,所以 BrowserIdentity 一次
    从 CDP 取齐 cookie + UA + hints,而不是各自写死。

「200 + 空 body 必须当场报错」也是刻意写死的:放过去它会在下游变成「这个博主没作品」,
把一次失败伪装成一条正常结果 —— 爬虫那条路正是这么栽的,还被翻译成「账号被封」。

测试 +11:cookie 解析、请求头成套性(含 uifid 缺失/回退)、产物键名与 store 对齐、
以及 _get 的三条失败路径(空 body / 403 带网关原话 / 正常返回)。
This commit is contained in:
2026-10-10 17:12:25 +08:00
parent 21ff01b894
commit 2112c1a870
1601 changed files with 367425 additions and 0 deletions
@@ -0,0 +1,334 @@
#!/usr/bin/env python3
"""
视频/图文发布脚本 — 通过 Camoufox 浏览器自动化上传
用法:
python publish_video.py douyin --account my_name --file video.mp4 --title "标题" --desc "描述"
python publish_video.py xiaohongshu --account my_name --file image.jpg --title "标题"
# 也可调用 social-auto-upload CLI (需先安装)
python publish_video.py douyin --account my_name --file video.mp4 --title "标题" --sau
"""
import sys, os, json, asyncio, argparse, logging, subprocess
from pathlib import Path
# 路径
from matrix_mgmt import AGENT_SYNC, AGENT_LOCAL
SCRIPTS_DIR = AGENT_SYNC / "05_tools" / "07_matrix" / "scripts"
IDENTITIES_ROOT = AGENT_LOCAL / "identities"
sys.path.insert(0, str(SCRIPTS_DIR))
from cdp_connector import CDPConnector
logging.basicConfig(level=logging.INFO, format="%(message)s")
log = logging.getLogger("publish")
# ── 平台上传页 URL ──
UPLOAD_URLS = {
"douyin": "https://www.douyin.com/upload/",
"xiaohongshu": "https://www.xiaohongshu.com/upload/",
}
# ── 默认等待时间 ──
WAIT_SHORT = 3
WAIT_MEDIUM = 5
WAIT_LONG = 10
def gaussian_jitter(base, min_val=None, max_val=None):
"""高斯抖动: base 秒 ±30% 随机"""
import random
jitter = base * random.uniform(-0.3, 0.3)
result = base + jitter
if min_val is not None:
result = max(result, min_val)
if max_val is not None:
result = min(result, max_val)
return max(0.5, result) # 至少 0.5s
async def publish_douyin(conn, file_path, title, desc):
"""在已打开的 Camoufox 中上传抖音视频"""
page = conn.page
log.info(f" 📤 导航到上传页: {UPLOAD_URLS['douyin']}")
await page.goto(UPLOAD_URLS["douyin"], timeout=30000)
await asyncio.sleep(gaussian_jitter(WAIT_MEDIUM))
# 等待上传页面加载
try:
await page.wait_for_selector("input[type=file]", timeout=15000)
except:
log.warning(" ⚠️ 未找到文件上传 input,尝试备用选择器")
try:
await page.wait_for_selector(".upload-btn, .upload-file, [class*=upload]", timeout=10000)
except:
log.error(" ❌ 找不到上传按钮,请检查页面")
return {"status": "error", "message": "找不到上传入口"}
# 选择文件
file_abs = str(Path(file_path).resolve())
if not os.path.exists(file_abs):
return {"status": "error", "message": f"文件不存在: {file_abs}"}
log.info(f" 📎 选择文件: {file_abs}")
try:
file_input = await page.query_selector("input[type=file]")
if file_input:
await file_input.set_input_files(file_abs)
else:
# 有些平台的 file input 是隐藏的,需要通过 JS 触发
await page.evaluate("""(path) => {
const input = document.querySelector('input[type=file]');
if (input) {
const dt = new DataTransfer();
input.files = dt.files;
}
}""", file_abs)
log.warning(" ⚠️ 直接 set_input_files 可能不生效")
except Exception as e:
return {"status": "error", "message": f"文件选择失败: {e}"}
# 等待上传
await asyncio.sleep(gaussian_jitter(WAIT_LONG, min_val=5))
# 填写标题
if title:
try:
title_input = await page.query_selector(
"[placeholder*=标题], [placeholder*=title], .title-input, input[name*=title]"
)
if title_input:
await title_input.click()
await asyncio.sleep(gaussian_jitter(1))
await title_input.fill(title)
log.info(f" ✏️ 标题已填写")
except Exception as e:
log.warning(f" ⚠️ 填写标题失败: {e}")
# 填写描述
if desc:
try:
desc_input = await page.query_selector(
"[placeholder*=描述], [placeholder*=简介], .desc-input, textarea"
)
if desc_input:
await desc_input.click()
await asyncio.sleep(gaussian_jitter(1))
await desc_input.fill(desc)
log.info(f" ✏️ 描述已填写")
except Exception as e:
log.warning(f" ⚠️ 填写描述失败: {e}")
# 等待手动操作或自动发布
log.info(f" ⏳ 等待上传完成 (30s)...")
await asyncio.sleep(gaussian_jitter(30, min_val=10))
# 尝试点击发布按钮
publish_btn_selectors = [
"button:has-text('发布')", "button:has-text('上传')",
".publish-btn", "[class*=publish]", "[class*=submit]",
"//button[contains(text(), '发布')]"
]
for selector in publish_btn_selectors:
try:
btn = await page.query_selector(selector)
if btn:
await btn.click()
log.info(f" 🚀 点击发布按钮")
await asynleep(gaussian_jitter(5))
break
except:
continue
return {"status": "ok", "message": "发布流程已启动,请在浏览器中确认最终结果"}
async def publish_xiaohongshu(conn, file_path, title, desc):
"""小红书发布笔记"""
page = conn.page
log.info(f" 📤 导航到发布页: {UPLOAD_URLS['xiaohongshu']}")
await page.goto(UPLOAD_URLS["xiaohongshu"], timeout=30000)
await asyncio.sleep(gaussian_jitter(WAIT_MEDIUM))
# 上传图片
file_abs = str(Path(file_path).resolve())
if not os.path.exists(file_abs):
return {"status": "error", "message": f"文件不存在: {file_abs}"}
try:
file_input = await page.query_selector("input[type=file]")
if file_input:
await file_input.set_input_files(file_abs)
log.info(f" 📎 已选择文件")
except Exception as e:
return {"status": "error", "message": f"文件选择失败: {e}"}
await asyncio.sleep(gaussian_jitter(WAIT_LONG, min_val=5))
# 填写标题
if title:
try:
title_input = await page.query_selector(
"[placeholder*=标题], .title-input, input[name*=title]"
)
if title_input:
await title_input.fill(title)
except: pass
# 填写正文
if desc:
try:
desc_input = await page.query_selector(
"[placeholder*=正文], .note-text, .ql-editor, [contenteditable=true]"
)
if desc_input:
await desc_input.fill(desc)
except: pass
await asyncio.sleep(gaussian_jitter(WAIT_MEDIUM))
return {"status": "ok", "message": "发布流程已启动,请在浏览器中确认"}
# ═══════════════════════════════════════════════════════════════
# social-auto-upload 集成 (sau CLI 调用)
# ═══════════════════════════════════════════════════════════════
def _find_sau():
"""查找 sau 可执行文件"""
# 1. 本地 path
import shutil
sau = shutil.which("sau") or shutil.which("sau_cli")
if sau:
return sau
# 2. pip 安装的脚本
for p in [Path(sys.prefix) / "bin" / "sau",
Path(sys.prefix) / "Scripts" / "sau.exe",
Path.home() / ".local" / "bin" / "sau"]:
if p.exists():
return str(p)
return None
async def publish_via_sau(args):
"""通过 social-auto-upload CLI 发布"""
sau_path = _find_sau()
if not sau_path:
return {"status": "error", "message": "social-auto-upload 未安装 (需要: cd social-auto-upload && pip install -e .)"}
# 构建命令
platform_map = {"douyin": "douyin", "xiaohongshu": "xiaohongshu"}
action_map = {".mp4": "upload-video", ".avi": "upload-video", ".mov": "upload-video",
".jpg": "upload-note", ".jpeg": "upload-note", ".png": "upload-note", ".gif": "upload-note"}
ext = Path(args.file).suffix.lower()
action = action_map.get(ext, "upload-video")
plat = platform_map.get(args.platform, args.platform)
cmd = [sys.executable, sau_path, plat, action,
"--account", args.account,
"--file", args.file if action == "upload-video" else None,
"--images", args.file if action == "upload-note" else None,
"--title", args.title or "无标题"]
# 清理 None 参数
cmd = [c for c in cmd if c is not None]
if args.desc:
cmd += ["--note" if action == "upload-note" else "--desc", args.desc]
log.info(f" 🚀 通过 social-auto-upload 发布 ({plat} {action})")
log.info(f" command: {' '.join(str(c) for c in cmd[:8])}...")
try:
p = subprocess.run(cmd, capture_output=True, text=True, timeout=600)
out = p.stdout[-1000:] if p.stdout else ""
err = p.stderr[-500:] if p.stderr else ""
if p.returncode == 0:
return {"status": "ok", "output": out}
return {"status": "error", "message": err or out or f"退出码 {p.returncode}"}
except subprocess.TimeoutExpired:
return {"status": "error", "message": "发布超时 (600s)"}
except Exception as e:
return {"status": "error", "message": str(e)}
async def main():
parser = argparse.ArgumentParser(description="视频/图文发布")
parser.add_argument("platform", choices=["douyin", "xiaohongshu"], help="平台")
parser.add_argument("--account", required=True, help="账号身份目录名")
parser.add_argument("--file", required=True, help="要上传的文件路径")
parser.add_argument("--title", default="", help="标题")
parser.add_argument("--desc", default="", help="描述/正文")
parser.add_argument("--json", action="store_true", help="JSON 输出")
parser.add_argument("--sau", action="store_true", help="使用 social-auto-upload 引擎 (代替 Camoufox)")
args = parser.parse_args()
# 如果启用 social-auto-upload,调用 sau CLI
if args.sau:
return await publish_via_sau(args)
identity_dir = IDENTITIES_ROOT / args.account
if not identity_dir.exists():
# 尝试从账号注册表查找
try:
sys.path.insert(0, str(SCRIPTS_DIR))
from matrix_mgmt import MatrixManager
mgr = MatrixManager()
for a in mgr.list_accounts():
if a.get("phone") == args.account or a["id"] == args.account:
idir = a.get("identity_dir", "")
if idir:
identity_dir = IDENTITIES_ROOT / idir.replace("identities/", "")
break
except: pass
if not identity_dir.exists():
result = {"status": "error", "message": f"身份目录不存在: {identity_dir}"}
if args.json:
print(json.dumps(result, ensure_ascii=False))
else:
print(f"❌ {result['message']}")
return
log.info(f"\n🚀 发布到 {args.platform}")
log.info(f" 账号: {args.account}")
log.info(f" 文件: {args.file}")
log.info(f" 标题: {args.title[:30]}...")
conn = None
try:
conn = CDPConnector(identity_dir=str(identity_dir), headless=False,
window=(802, 783), locale=["zh-CN"])
await conn.connect()
await asyncio.sleep(gaussian_jitter(WAIT_SHORT))
if args.platform == "douyin":
result = await publish_douyin(conn, args.file, args.title, args.desc)
else:
result = await publish_xiaohongshu(conn, args.file, args.title, args.desc)
if args.json:
print(json.dumps(result, ensure_ascii=False))
else:
s = result.get("status")
icon = "✅" if s == "ok" else "❌"
print(f"\n{icon} {result.get('message', '')}")
except Exception as e:
result = {"status": "error", "message": str(e)}
if args.json:
print(json.dumps(result, ensure_ascii=False))
else:
print(f"\n❌ 发布失败: {e}")
finally:
if conn:
try:
await conn.close()
except: pass
if __name__ == "__main__":
asyncio.run(main())