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,253 @@
#!/usr/bin/env python3
"""
comment_sender.py v2 — 状态感知的评论发送器
流程(锚点驱动):
1. 检测评论区是否打开 → 否则 KeyX
2. 检测输入框是否已激活 → 否则 JS focus
3. 检测是否有内容 → 否则键盘输入
4. Alt+Enter 发送
5. 验证码检测 → 手动输入
"""
import asyncio, os, random, sys, json
script_dir = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.dirname(script_dir))
from cdp_connector import CDPConnector
LOG_FILE = '/tmp/comment_sender.log'
def log(m):
with open(LOG_FILE, 'a') as f: f.write(m + '\n')
print(m, flush=True)
async def check_state(page) -> dict:
"""检测评论区相关状态"""
return await page.evaluate("""() => {
const ae = document.activeElement;
// 评论区面板
const panel = document.querySelector('[class*="comment-header"]')
|| document.querySelector('[class*="comment"]')
|| document.querySelector('[class*="Comment"]');
// Draft.js 编辑器
const editor = document.querySelector('[contenteditable="true"][role="combobox"]')
|| document.querySelector('[class*="DraftEditor"] [contenteditable]');
// 输入框是否激活(activeElement 是编辑器或其子元素)
const editorActive = ae && (ae.isContentEditable
|| ae.getAttribute('contenteditable') === 'true'
|| ae.closest('[contenteditable]'));
return {
has_comment_area: !!panel,
has_editor: !!editor,
editor_active: editorActive,
active_tag: ae ? ae.tagName : 'none',
active_cls: ae ? (ae.className||'').slice(0,50) : '',
panel_rect: panel ? Object.values(panel.getBoundingClientRect()).map(Math.round) : null
};
}""")
async def focus_editor(page) -> bool:
"""尝试聚焦 Draft.js 编辑器,返回是否成功"""
ok = await page.evaluate("""() => {
// 直接找 contenteditable 且有 role 的
let ed = document.querySelector('[contenteditable="true"][role="combobox"]');
if (!ed) ed = document.querySelector('[class*="DraftEditor"] [contenteditable]');
if (!ed) ed = document.querySelector('[contenteditable="true"]');
if (ed) { ed.focus(); ed.click(); return true; }
return false;
}""")
await asyncio.sleep(0.5)
return ok
async def send_comment(page, comment_text: str):
"""发送一条评论(状态感知版)"""
# ── Step 1: 检测并确保评论区打开 ──
state = await check_state(page)
log(f'📊 初始状态: comment={state["has_comment_area"]} editor={state["has_editor"]} active={state["editor_active"]}')
if not state['has_comment_area']:
log('⌨️ KeyX 打开评论区')
await page.keyboard.press('KeyX')
await asyncio.sleep(2)
else:
log('✅ 评论区已打开,跳过KeyX')
# ── Step 2: 用真实鼠标点击激活输入框 ──
state = await check_state(page)
if state['editor_active']:
log('✅ 输入框已激活')
else:
# 找到编辑器位置 → 用 Playwright 真实鼠标点击
rect = await page.evaluate("""() => {
let el = document.querySelector('[contenteditable="true"][role="combobox"]');
if (!el) el = document.querySelector('[class*="DraftEditor"] [contenteditable]');
if (!el) el = document.querySelector('[contenteditable="true"]');
if (!el) return null;
const r = el.getBoundingClientRect();
return {x: r.x, y: r.y, w: r.width, h: r.height};
}""")
if rect and rect['w'] > 0 and rect['h'] > 0:
# 用 Playwright 生成真实鼠标事件链
x = rect['x'] + rect['w'] // 2
y = rect['y'] + rect['h'] // 2
log(f'🖱️ 真实鼠标点击编辑器: ({x:.0f}, {y:.0f})')
await page.mouse.click(x, y)
await asyncio.sleep(1)
# 再次确认
state = await check_state(page)
if state['editor_active']:
log('✅ 鼠标点击后输入框已激活')
else:
log('⚠️ 鼠标点击后仍未激活')
else:
log('⚠️ 找不到编辑器位置')
return 'failed'
await asyncio.sleep(0.5)
# ── Step 3: JS 设置内容(不用键盘,不依赖焦点)──
log(f'💬 JS设置内容: "{comment_text}"')
result = await page.evaluate(f'''() => {{
// 找 Draft.js 编辑器
let ed = document.querySelector('[contenteditable="true"][role="combobox"]');
if (!ed) ed = document.querySelector('[class*="DraftEditor"] [contenteditable]');
if (!ed) ed = document.querySelector('[contenteditable="true"]');
if (!ed) return {{ok: false, reason: 'no_editor'}};
// focus + 清空 + 设置内容
ed.focus();
ed.textContent = '';
ed.textContent = "{comment_text}";
ed.dispatchEvent(new Event('input', {{bubbles: true}}));
ed.dispatchEvent(new Event('change', {{bubbles: true}}));
const finalText = ed.textContent;
return {{ok: finalText.length > 0, text: finalText, tag: ed.tagName}};
}}''')
log(f' 结果: ok={result["ok"]} text="{result.get("text","")}" tag={result.get("tag","")}')
if not result.get('ok'):
log('⚠️ JS设置内容失败')
return 'failed'
await asyncio.sleep(0.5)
# ── Step 4: JS 触发发送(先找发送按钮,没有则 Alt+Enter)──
log('⌨️ JS触发发送...')
send_result = await page.evaluate('''() => {
// 找发送按钮
const btns = [...document.querySelectorAll('button')];
const sendBtn = btns.find(b =>
b.textContent.includes('发送') || b.textContent.includes('发布')
);
if (sendBtn) {
sendBtn.click();
return 'click_send_btn';
}
// 没按钮 → 触发 Alt+Enter
const ed = document.querySelector('[contenteditable="true"][role="combobox"]')
|| document.querySelector('[contenteditable="true"]');
if (ed) {
ed.dispatchEvent(new KeyboardEvent('keydown', {
key: 'Enter', altKey: true, bubbles: true
}));
return 'alt_enter_event';
}
return 'failed';
}''')
log(f' 发送方式: {send_result}')
await asyncio.sleep(2)
# ── Step 5: 验证码 ──
has_code = await page.evaluate(
"() => !!document.querySelector('input[placeholder*=\"\\u9a8c\\u8bc1\\u7801\"]')"
)
if has_code:
log('⚠️ 验证码弹窗!')
return 'verify_code_required'
# ── Step 6: 验证发送结果 ──
found = await page.evaluate(
f'() => document.body.innerText.includes("{comment_text}")'
)
if found:
log('✅ 评论已发送')
return 'success'
else:
log('❌ 未在页面找到评论')
return 'failed'
async def main():
import argparse
parser = argparse.ArgumentParser()
parser.add_argument('--account', '-a', default='douyin_01')
parser.add_argument('--comment', '-c', default='好内容,已三连')
parser.add_argument('--count', '-n', type=int, default=2)
args = parser.parse_args()
ID_DIR = os.path.expanduser(
f'~/workbuddy-agent-os/agent-local/tools/matrix/identities/{args.account}_camo'
)
if not os.path.exists(ID_DIR):
print(f'❌ 账号目录不存在: {ID_DIR}')
return
print(f'🦊 启动: {args.account}')
conn = CDPConnector(browser_type='camoufox', identity_dir=ID_DIR,
headless=False, window=(702,783), window_position=(652,0))
await conn.connect()
page = conn.page
await conn.init_anti_detection()
await page.goto('https://www.douyin.com/', timeout=30000)
await asyncio.sleep(3)
print('\n📌 请操作:')
print(' 1. 进入视频播放页')
print(' 2. 打开评论区(按 X)')
print(' 3. 手动点击激活评论输入框')
print(' 4. 回到终端按回车\n')
input()
# 检查是否在播放页
v = await page.evaluate('document.querySelectorAll("video").length')
if v < 2:
print('❌ 未检测到播放页')
return
print(f'✅ 播放页确认 (video={v})')
for i in range(args.count):
print(f'\n——— 第{i+1}/{args.count}次评论 ———')
result = await send_comment(page, args.comment)
if result == 'verify_code_required':
code = input('\n📱 验证码(6位): ').strip()
if code and len(code) == 6:
await page.evaluate(f'''() => {{
const inp = document.querySelector('input[placeholder*="验证码"]');
if (inp) {{
inp.value = "{code}";
inp.dispatchEvent(new Event('input', {{bubbles: true}}));
}}
const btn = [...document.querySelectorAll('button')]
.find(b => b.textContent.includes('确认') || b.textContent.includes('提交'));
if (btn) btn.click();
}}''')
print('✅ 验证码已提交')
await asyncio.sleep(2)
if i < args.count - 1:
print('⏳ 评论区保持打开,5秒后下一次...')
await asyncio.sleep(5)
print('\n✅ 完成,浏览器保持打开')
print('按 Ctrl+C 退出')
while True:
await asyncio.sleep(10)
if __name__ == '__main__':
try:
asyncio.run(main())
except KeyboardInterrupt:
print('\n👋 退出')
except Exception as e:
import traceback; traceback.print_exc()