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,243 @@
|
||||
"""
|
||||
豆包浏览器驱动 v7.1 —— 稳定等待法
|
||||
|
||||
改进:
|
||||
1. 豆包保持独立标签页,不关闭,不 rebind
|
||||
2. 抖音用另一个标签页,互不干扰
|
||||
3. 稳定等待法:触发增长后等连续 3 轮无变化才算完成,防止提前截断
|
||||
4. 改用 fill + click #flow-end-msg-send 替代 KeyboardEvent(React兼容)
|
||||
"""
|
||||
|
||||
import re, json, time, os, subprocess
|
||||
from pathlib import Path
|
||||
|
||||
OPENCLI = os.path.expanduser("~/.workbuddy/binaries/node/versions/22.12.0/bin/opencli")
|
||||
|
||||
ANALYSIS_PROMPT = """请从这段视频内容中提取所有知识性内容,按以下格式完整记录,不要概括,不要总结:
|
||||
|
||||
【完整字幕】
|
||||
逐句记录视频中的每一句话,一字不差
|
||||
|
||||
【提示词/指令原文】
|
||||
如果视频中出现了AI提示词、模板、指令,请完整抄录,一字不改
|
||||
|
||||
【操作步骤】
|
||||
视频中演示的具体操作流程,分步骤详细记录
|
||||
|
||||
【关键参数】
|
||||
视频中提到的工具名称、参数设置、具体数字等
|
||||
|
||||
【核心技巧】
|
||||
视频中传授的具体方法、技巧、经验
|
||||
|
||||
记住:只提取不总结,不要分析结构,不要分析背景音乐。"""
|
||||
|
||||
|
||||
def _oc(args, timeout=30):
|
||||
env = {**os.environ, "NODE_OPTIONS": ""}
|
||||
for k in ["HTTP_PROXY", "HTTPS_PROXY", "http_proxy", "https_proxy"]:
|
||||
env.pop(k, None)
|
||||
try:
|
||||
r = subprocess.run([OPENCLI] + args, capture_output=True, text=True, timeout=timeout, env=env)
|
||||
return r.stdout
|
||||
except:
|
||||
return ""
|
||||
|
||||
|
||||
def _parse_sections(raw):
|
||||
"""
|
||||
按标记提取各段落。
|
||||
支持两种格式:
|
||||
- 【】格式(旧)
|
||||
- === 标题 === 格式(新,豆包AI常用)
|
||||
"""
|
||||
sections = {}
|
||||
|
||||
# 先找 AI 回复的起始位置(跳过用户消息)
|
||||
lines = raw.split('\n')
|
||||
ai_start = -1
|
||||
for i, line in enumerate(lines):
|
||||
stripped = line.strip()
|
||||
if (stripped.startswith('===') and
|
||||
any(k in stripped for k in ['完整字幕', '提示词', '操作步骤', '关键参数', '核心技巧'])):
|
||||
next_chunk = '\n'.join(lines[i:i+4])
|
||||
if '逐句记录' not in next_chunk and '如果视频' not in next_chunk:
|
||||
ai_start = i
|
||||
break
|
||||
|
||||
ai_raw = '\n'.join(lines[ai_start:]) if ai_start >= 0 else raw
|
||||
|
||||
# 格式1:=== 标题 === 内容
|
||||
pattern = r'===\s*([^=]+?)\s*===\s*([\s\S]*?)(?====\s*[^=]|$)'
|
||||
for m in re.finditer(pattern, ai_raw):
|
||||
title = m.group(1).strip()
|
||||
content = m.group(2).strip()
|
||||
skip = ['逐句记录', '视频中演示', '视频中提到', '如果视频', '记住:', '完整抄录']
|
||||
if content and len(content) > 5 and not any(s in content[:40] for s in skip):
|
||||
sections[title] = content
|
||||
|
||||
# 格式2:旧格式 【】兜底
|
||||
if not sections:
|
||||
markers = ["完整字幕", "提示词/指令原文", "提示词", "指令原文", "操作步骤", "关键参数", "核心技巧"]
|
||||
for marker in markers:
|
||||
m = re.search(rf'【{re.escape(marker)}】\s*([\s\S]*?)(?=【|\Z)', raw)
|
||||
if m:
|
||||
content = m.group(1).strip()
|
||||
content = re.sub(r'逐句记录.*|如果视频中.*|视频中演示.*|视频中提到.*|记住:.*', '', content)
|
||||
sections[marker] = content
|
||||
|
||||
return sections
|
||||
|
||||
|
||||
def _wait_for_response(session="doubao", max_wait=120, min_growth=500, stable_checks=3):
|
||||
"""
|
||||
稳定等待法:检测页面文本显著增长后,再等连续 stable_checks 轮无变化才算完成。
|
||||
防止 AI 还在生成就被提前截断的老 bug。
|
||||
"""
|
||||
before = len(_oc(["browser", session, "eval", "document.body.innerText"], timeout=10) or "")
|
||||
prev_len = before
|
||||
stable_count = 0
|
||||
triggered = False
|
||||
|
||||
for i in range(max_wait // 5):
|
||||
time.sleep(5)
|
||||
body = _oc(["browser", session, "eval", "document.body.innerText"], timeout=10) or ""
|
||||
current_len = len(body)
|
||||
growth = current_len - prev_len
|
||||
total_growth = current_len - before
|
||||
|
||||
has_sections = '===完整字幕' in body or '===操作步骤' in body or '===核心技巧' in body
|
||||
|
||||
if total_growth > min_growth or has_sections:
|
||||
triggered = True
|
||||
|
||||
if triggered:
|
||||
if growth == 0:
|
||||
stable_count += 1
|
||||
else:
|
||||
stable_count = 0 # 还在增长,重置
|
||||
|
||||
if i > 0 and i % 2 == 0:
|
||||
status = f" ⏳ {(i+1)*5}s...总增长{total_growth}字"
|
||||
if triggered:
|
||||
status += f" (稳定{stable_count}/{stable_checks})" if growth == 0 else " (增长中)"
|
||||
if has_sections:
|
||||
status += " ✅含结构"
|
||||
print(status, flush=True)
|
||||
|
||||
if triggered and stable_count >= stable_checks:
|
||||
print(f" ✅ 回复稳定,确认完成", flush=True)
|
||||
return body
|
||||
|
||||
print(f" ⏰ 超时({max_wait}s),返回当前内容", flush=True)
|
||||
return _oc(["browser", session, "eval", "document.body.innerText"], timeout=10)
|
||||
|
||||
|
||||
class DoubaoDriver:
|
||||
"""豆包分析驱动 v7.1(稳定等待法)"""
|
||||
|
||||
def __init__(self):
|
||||
self.doubao_ready = False
|
||||
self.douyin_session = "douyin"
|
||||
self.doubao_session = "doubao"
|
||||
|
||||
def start_session(self):
|
||||
"""启动豆包会话(只一次,标签页保持打开)"""
|
||||
# 先开一个空白标签给 douyin session
|
||||
_oc(["browser", self.douyin_session, "bind"], timeout=5)
|
||||
time.sleep(0.5)
|
||||
# 开新标签给 doubao session(在新标签页打开豆包)
|
||||
_oc(["browser", self.doubao_session, "tab", "new", "https://www.doubao.com/chat"], timeout=15)
|
||||
time.sleep(5)
|
||||
self.doubao_ready = True
|
||||
print(" ✅ 豆包会话已启动(独立标签页)")
|
||||
|
||||
def analyze_video(self, url):
|
||||
"""分析单个视频"""
|
||||
t0 = time.time()
|
||||
try:
|
||||
# 1. 抖音标签页:打开视频提取文字
|
||||
_oc(["browser", self.douyin_session, "bind"], timeout=5)
|
||||
time.sleep(0.5)
|
||||
_oc(["browser", self.douyin_session, "open", url], timeout=15)
|
||||
time.sleep(6)
|
||||
page_text = _oc(["browser", self.douyin_session, "eval", "document.body.innerText"], timeout=10)
|
||||
page_text = re.sub(r'^\{[^}]*"result"[^}]*"|^"[}]$', '', page_text).strip()
|
||||
|
||||
if not page_text or len(page_text) < 50:
|
||||
return {"success": False, "error": "无法提取视频页内容", "url": url}
|
||||
|
||||
# 清理
|
||||
skip_kw = ['开启读屏', '读屏标签', '精选', '推荐', '搜索',
|
||||
'关注', '朋友', '我的', '直播', '放映厅', '壁纸',
|
||||
'通知', '私信', '投稿', '登录', '倍速', '智能',
|
||||
'清屏', '连播', '点击加载更多', '展开', '条回复',
|
||||
'评论', '广告投放', '用户服务']
|
||||
useful = [l.strip() for l in page_text.split('\n')
|
||||
if len(l.strip()) > 5 and not any(k in l for k in skip_kw)]
|
||||
|
||||
msg = f"视频 {url}\n\n视频内容:\n{chr(10).join(useful[:30])}\n\n{ANALYSIS_PROMPT}"
|
||||
|
||||
# 2. 豆包标签页(已存在,用 fill 填入 + click 发送按钮)
|
||||
_oc(["browser", self.doubao_session, "bind"], timeout=5)
|
||||
time.sleep(0.5)
|
||||
_oc(["browser", self.doubao_session, "fill", "--nth", "0", "textarea", msg], timeout=15)
|
||||
time.sleep(1)
|
||||
# 触发发送(React 页面无法通过 dispatchEvent 捕获 KeyboardEvent)
|
||||
# 优先使用显式发送按钮 click,这个按钮有 id=flow-end-msg-send
|
||||
_oc(["browser", self.doubao_session, "click", "#flow-end-msg-send"], timeout=5)
|
||||
|
||||
# 3. 智能等待回复
|
||||
raw = _wait_for_response(session=self.doubao_session)
|
||||
sections = _parse_sections(raw or "")
|
||||
|
||||
elapsed = round(time.time() - t0, 1)
|
||||
return {
|
||||
"success": True,
|
||||
"url": url,
|
||||
"elapsed": elapsed,
|
||||
"raw_response": raw or "",
|
||||
"sections": sections,
|
||||
}
|
||||
except Exception as e:
|
||||
return {"success": False, "error": str(e), "url": url}
|
||||
|
||||
def analyze_profile(self, profile_url, max_videos=5):
|
||||
"""分析主播主页"""
|
||||
_oc(["browser", "profile", "bind"], timeout=5)
|
||||
_oc(["browser", "profile", "open", profile_url], timeout=15)
|
||||
time.sleep(8)
|
||||
for _ in range(2):
|
||||
_oc(["browser", "profile", "scroll", "down"])
|
||||
time.sleep(3)
|
||||
|
||||
links_raw = _oc(["browser", "profile", "eval",
|
||||
"JSON.stringify(Array.from(document.querySelectorAll('a[href*=\"/video/\"]'))"
|
||||
f".slice(0,{max_videos}).map(a=>({{url:a.href,title:(a.textContent||'').trim().slice(0,40)}})))"],
|
||||
timeout=10)
|
||||
try:
|
||||
videos = json.loads(links_raw)
|
||||
except:
|
||||
videos = []
|
||||
|
||||
if not videos:
|
||||
return [{"success": False, "error": "无法获取视频列表", "url": profile_url}]
|
||||
|
||||
self.start_session()
|
||||
print(f" ✅ 获取到 {len(videos)} 个视频")
|
||||
|
||||
results = []
|
||||
for i, v in enumerate(videos):
|
||||
print(f"\n [{i+1}/{len(videos)}] {v.get('title','')[:30]}...")
|
||||
r = self.analyze_video(v["url"])
|
||||
r["title"] = v.get("title", "")
|
||||
results.append(r)
|
||||
time.sleep(2)
|
||||
return results
|
||||
|
||||
|
||||
def analyze_video(url):
|
||||
return DoubaoDriver().analyze_video(url)
|
||||
|
||||
def analyze_profile(url, max_n=5):
|
||||
return DoubaoDriver().analyze_profile(url, max_n)
|
||||
Reference in New Issue
Block a user