feat(monitor): 抖音 Web 接口客户端 —— 绕开爬虫子进程,直接发 HTTP
移植自 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:
@@ -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()
|
||||
Reference in New Issue
Block a user