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,462 @@
"""
analyzer.py — 录制步骤差异分析引擎 (v1.0)
三阶段管线 Phase 2:
读取录制包 → 对比相邻步骤的 URL/DOM/事件 →
推断操作类型 → 生成结构化分析结果
用法:
from mc.analyzer import analyze_recording
result = analyze_recording(package)
"""
import json
import re
from pathlib import Path
from typing import Optional
# ── 抖音快捷键映射 ──
DY_HOTKEYS = {
'z': 'like', 'x': 'comment', 'c': 'comment',
'g': 'follow',
'k': 'loop', 'm': 'mute', 'j': 'clear',
'p': 'loop', 'h': 'fullscreen',
'ArrowDown': 'next_video', 'ArrowUp': 'prev_video',
' ': 'play_pause',
}
# ── 点击坐标区域定义 ──
# (x_min, y_min, x_max, y_max, label)
KNOWN_REGIONS = {
'dy_like_area': (330, 400, 500, 600, '抖音点赞区域'),
'dy_comment_area': (350, 680, 500, 720, '抖音评论区触发'),
'dy_search_bar': (50, 50, 350, 90, '抖音搜索框'),
'xhs_bottom_nav': (0, 700, 400, 780, 'XHS底部导航'),
'xhs_me_tab': (300, 700, 400, 780, 'XHS"我"标签'),
}
def analyze_recording(package: dict) -> dict:
"""分析录制包,生成可导出的原子操作定义"""
steps = package.get("steps", [])
if len(steps) < 2:
return {"error": "至少需要2个步骤才能分析", "steps_analyzed": 0}
result = {
"meta": package.get("meta", {}),
"steps_analyzed": len(steps),
"actions": [],
"blueprint_suggestions": [],
}
# 分析相邻步骤对
for i in range(len(steps) - 1):
before = steps[i]
after = steps[i+1]
analysis = _analyze_step_pair(before, after)
result["actions"].append(analysis)
# 生成蓝图建议
result["blueprint_suggestions"] = _suggest_blueprint(result["actions"])
return result
def _analyze_step_pair(before: dict, after: dict) -> dict:
"""分析一对相邻步骤的变化"""
bp = before.get("page", {})
ap = after.get("page", {})
events = before.get("events", [])
# before_state 对比(三段式记录的状态指纹)
bbs = before.get("before_state", {}) or {}
abs = after.get("before_state", {}) or {}
analysis = {
"step_before": before["step"],
"step_after": after["step"],
"event_count": len(events),
"page_url": ap.get("url", ""),
"page_mode": ap.get("page_mode", "?"),
"interactable_count": ap.get("interactable_count", 0),
}
# 0. before_state 选择器变化分析(优先级最高)
sel_before = bbs.get("selectors", {})
sel_after = abs.get("selectors", {})
if sel_before and sel_after:
sel_changes = {}
all_keys = set(sel_before) | set(sel_after)
for k in all_keys:
v1 = sel_before.get(k, False)
v2 = sel_after.get(k, False)
if v1 != v2:
sel_changes[k] = f"{'✅' if v1 else '❌'}→{'✅' if v2 else '❌'}"
if sel_changes:
analysis["selector_changes"] = sel_changes
# 基于选择器变化推断操作
op = _infer_op_from_selectors(sel_before, sel_after, bbs, abs)
if op:
analysis["action_type"] = op["type"]
analysis["action_desc"] = op["desc"]
analysis["confidence"] = op["confidence"]
# 1. URL 变化分析
url_before = bp.get("url", "")
url_after = ap.get("url", "")
if url_before != url_after:
analysis["url_changed"] = True
analysis["url_from"] = url_before[:80]
analysis["url_to"] = url_after[:80]
# URL 变化类型推断
if "/video/" in url_after and "/video/" not in url_before:
analysis["action_type"] = "navigate_video"
analysis["action_desc"] = "进入视频播放页"
elif "/user/profile/" in url_after:
analysis["action_type"] = "navigate_profile"
analysis["action_desc"] = "进入个人主页"
elif "/search/" in url_after or "?keyword=" in url_after:
analysis["action_type"] = "search"
analysis["action_desc"] = "搜索"
elif "/user/self" in url_after:
analysis["action_type"] = "navigate_profile"
analysis["action_desc"] = "进入抖音主页"
else:
analysis["action_type"] = "navigate"
analysis["action_desc"] = "页面导航"
else:
analysis["url_changed"] = False
# 2. 页面模式变化
mode_before = bp.get("page_mode", "?")
mode_after = ap.get("page_mode", "?")
if mode_before != mode_after:
analysis["mode_changed"] = f"{mode_before} → {mode_after}"
if mode_before == "grid" and mode_after == "player":
analysis["action_type"] = analysis.get("action_type", "enter_video")
analysis["action_desc"] = analysis.get("action_desc", "进入视频播放")
elif mode_before == "player" and mode_after == "grid":
analysis["action_type"] = analysis.get("action_type", "exit_video")
analysis["action_desc"] = analysis.get("action_desc", "退出视频")
elif mode_after == "profile":
analysis["action_type"] = analysis.get("action_type", "view_profile")
analysis["action_desc"] = analysis.get("action_desc", "查看主页")
# 3. 事件序列分析(从用户操作反推操作类型)
if events:
action = _infer_action_from_events(events)
if action:
analysis["inferred_action"] = action
# 事件推断的优先级高于 URL 推断
if action.get("type") and not analysis.get("action_type"):
analysis["action_type"] = action["type"]
analysis["action_desc"] = action.get("desc", "")
# 4. 文本差异分析
text_before = bp.get("text_snippet", "")
text_after = ap.get("text_snippet", "")
if text_before and text_after and text_before != text_after:
analysis["text_changed"] = True
# 检测文本中的关键变化
added = _find_added_text(text_before, text_after)
if added:
analysis["text_added"] = added[:100]
# 5. 标题变化
title_before = bp.get("title", "")
title_after = ap.get("title", "")
if title_before != title_after:
analysis["title_changed"] = f"{title_before[:30]} → {title_after[:30]}"
# 6. 交互元素变化
ia_before = set(e.get("e2e", "") or e.get("cls", "") for e in bp.get("interactables", []))
ia_after = set(e.get("e2e", "") or e.get("cls", "") for e in ap.get("interactables", []))
new_elements = ia_after - ia_before
if new_elements:
analysis["new_elements"] = list(new_elements)[:5]
# 7. 生成特征码(可复现的自动化选择器建议)
analysis["features"] = _suggest_features(bp, ap, events, analysis)
# 8. 检测空步骤(连续两次 · 无操作)
if not analysis.get("action_type"):
no_events = len(events) <= 1 # 只有 · 键事件
no_url_change = not analysis.get("url_changed", True)
no_sel_change = not analysis.get("selector_changes")
no_mode_change = not analysis.get("mode_changed")
no_clicks = len([e for e in events if e.get('t') == 'click']) == 0
no_scrolls = len([e for e in events if e.get('t') == 'scroll']) == 0
if no_events and no_url_change and no_sel_change and no_mode_change:
analysis["action_type"] = "skip"
analysis["action_desc"] = "无有效操作(空步骤)"
analysis["skip_reason"] = "no_change"
# 9. 生成操作摘要
if not analysis.get("action_type"):
analysis["action_type"] = "unknown"
analysis["action_desc"] = f"页面内容变化 ({len(events)} events)"
return analysis
def _infer_op_from_selectors(sel_before: dict, sel_after: dict,
bbs: dict, abs: dict) -> Optional[dict]:
"""基于 before_state 的选择器变化推断具体操作"""
# 抖音 like 按钮: [data-e2e="video-player-digg"]
# 抖音 collect: [data-e2e="video-player-collect"]
# 小红书 note: section.note-item
digg_key = '[data-e2e="video-player-digg"]'
collect_key = '[data-e2e="video-player-collect"]'
search_key = '[data-e2e="searchbar-input"]'
video_key = 'video'
note_key = 'section.note-item'
p_before = bbs.get("page_mode", "")
p_after = abs.get("page_mode", "")
# 检测抖音点赞操作
digg_before = sel_before.get(digg_key, False)
digg_after = sel_after.get(digg_key, False)
if digg_before and not digg_after and p_before == "player":
return {"type": "like", "desc": "点赞", "confidence": "high"}
# 检测抖音取消点赞
if not digg_before and digg_after and p_before == "player":
liked_text_before = bbs.get("markers", {}).get("liked", "")
liked_text_after = abs.get("markers", {}).get("liked", "")
return {"type": "like", "desc": "点赞(按钮重显)", "confidence": "medium"}
# 检测抖音收藏操作
collect_before = sel_before.get(collect_key, False)
collect_after = sel_after.get(collect_key, False)
if collect_before and not collect_after and p_before == "player":
return {"type": "collect", "desc": "收藏", "confidence": "high"}
# 检测搜索操作
search_before = sel_before.get(search_key, False)
search_after = sel_after.get(search_key, False)
if not search_before and search_after:
return {"type": "search", "desc": "打开搜索", "confidence": "high"}
if search_before and not search_after:
return {"type": "go_back", "desc": "关闭搜索", "confidence": "medium"}
# 检测进入小红书笔记
note_before = sel_before.get(note_key, False)
note_after = sel_after.get(note_key, False)
if note_before and not note_after and p_before == "grid":
return {"type": "xhs_click_note", "desc": "点击笔记", "confidence": "high"}
if not note_before and note_after:
return {"type": "go_back", "desc": "返回笔记列表", "confidence": "medium"}
# 检测视频播放状态变化
video_before = sel_before.get(video_key, False)
video_after = sel_after.get(video_key, False)
if not video_before and video_after:
return {"type": "enter_video", "desc": "进入视频页", "confidence": "medium"}
if video_before and not video_after:
return {"type": "exit_video", "desc": "退出视频", "confidence": "medium"}
return None
def _infer_action_from_events(events: list) -> Optional[dict]:
"""从 CDP 事件序列推断操作类型"""
# 取关键事件:键盘 + 点击
key_events = [e for e in events if e.get('t') == 'key']
click_events = [e for e in events if e.get('t') == 'click']
scroll_events = [e for e in events if e.get('t') == 'scroll']
# 抖音快捷键识别
for ke in key_events:
k = ke.get('k', '').lower()
if k in DY_HOTKEYS:
action = DY_HOTKEYS[k]
desc_map = {
'like': '点赞', 'comment': '打开评论', 'follow': '关注',
'loop': '连播', 'mute': '静音', 'clear': '清屏',
'fullscreen': '全屏', 'next_video': '下一个视频',
'prev_video': '上一个视频', 'play_pause': '播放/暂停',
}
return {"type": action, "desc": desc_map.get(action, action),
"trigger": f"key:{k}", "confidence": "high"}
# 字母键识别
for ke in key_events:
k = ke.get('k', '')
if len(k) == 1 and k.isalpha():
return {"type": f"keypress_{k}", "desc": f"按键 {k}",
"trigger": f"key:{k}", "confidence": "medium"}
# 点击事件分析
if click_events:
last_click = click_events[-1]
x, y = last_click.get('x', 0), last_click.get('y', 0)
target = last_click.get('target', '')
# 检测区域
for region_name, (x1, y1, x2, y2, label) in KNOWN_REGIONS.items():
if x1 <= x <= x2 and y1 <= y <= y2:
return {"type": f"click_{region_name}", "desc": label,
"trigger": f"click({x},{y})", "confidence": "medium",
"coords": {"x": x, "y": y}}
return {"type": "click", "desc": f"点击 ({x},{y}) <{target}>",
"trigger": f"click({x},{y})", "confidence": "medium",
"coords": {"x": x, "y": y}}
# 滚动事件
if scroll_events and len(scroll_events) >= 2:
total_dy = sum(e.get('dy', 0) for e in scroll_events)
if total_dy > 200:
return {"type": "scroll", "desc": f"下滚 (delta={total_dy})",
"trigger": "scroll", "confidence": "medium"}
return None
def _find_added_text(old_text: str, new_text: str) -> str:
"""简单的文本差异检测——找出出现在新文本中但不在旧文本中的部分"""
# 行级别 diff
old_lines = set(old_text.split('\n'))
new_lines = new_text.split('\n')
added = [l.strip() for l in new_lines if l.strip() and l.strip() not in old_lines
and len(l.strip()) > 3]
return '\n'.join(added[:10]) if added else ''
def _suggest_features(before_page: dict, after_page: dict,
events: list, analysis: dict) -> list:
"""从事件和页面状态生成自动化特征码建议"""
features = []
key_events = [e for e in events if e.get('t') == 'key']
click_events = [e for e in events if e.get('t') == 'click']
# 来自推断操作的特征
inferred = analysis.get("inferred_action", {})
if inferred:
trigger = inferred.get("trigger", "")
if trigger.startswith("key:"):
k = trigger.split(":")[1]
features.append({
"type": "keyboard",
"selector": None,
"description": f"按键 {k}",
"code": f'await page.keyboard.press("{k}")',
"confidence": inferred.get("confidence", "medium"),
})
elif "coords" in inferred:
coords = inferred["coords"]
# 尝试从 after_page 的 interactables 中找到最近的可点击元素
closest = None
for el in after_page.get("interactables", []):
rect_str = el.get("rect", "")
if rect_str and ',' in rect_str:
parts = rect_str.split()
if len(parts) >= 1:
xy = parts[0].split(',')
if len(xy) == 2:
try:
ex, ey = int(xy[0]), int(xy[1])
dist = (coords['x'] - ex) ** 2 + (coords['y'] - ey) ** 2
if dist < 5000 and (closest is None or dist < closest[0]):
closest = (dist, el)
except:
pass
if closest:
el = closest[1]
selector = el.get("e2e") or el.get("cls", "").split()[0] if el.get("cls") else None
tag = el.get("tag", "")
text = el.get("text", "")
features.append({
"type": "click",
"selector": f'[data-e2e="{selector}"]' if selector else (f'{tag}:has-text("{text}")' if text else None),
"description": f'点击 <{tag}> "{text[:20]}"' if text else f'点击 <{tag}>',
"code": f'await page.locator("{selector or tag}").click()',
"confidence": "high" if selector else "medium",
"near_coords": f'({coords["x"]},{coords["y"]})',
})
else:
features.append({
"type": "click_coords",
"selector": None,
"description": f"点击坐标 ({coords['x']},{coords['y']})",
"code": f'await page.mouse.click({coords["x"]}, {coords["y"]})',
"confidence": "medium",
})
# URL 变化特征
if analysis.get("url_changed"):
url_to = analysis.get("url_to", "")
if "/video/" in url_to:
features.append({
"type": "navigation",
"selector": 'a[href*="/video/"]',
"description": "进入视频播放页",
"code": 'await page.goto(video_url)',
"confidence": "high",
})
elif "/user/profile/" in url_to:
features.append({
"type": "navigation",
"selector": 'a[href*="/user/profile/"]',
"description": "进入个人主页",
"code": 'await page.goto(profile_url)',
"confidence": "high",
})
# 页面模式变化的特征
if analysis.get("mode_changed"):
features.append({
"type": "page_mode",
"selector": None,
"description": f"页面模式: {analysis['mode_changed']}",
"code": None,
"confidence": "high",
})
return features
def _suggest_blueprint(actions: list) -> list:
"""从分析结果生成蓝图建议"""
suggestions = []
for i, action in enumerate(actions):
step_num = i + 1
at = action.get("action_type", "unknown")
# 操作名 → 建议的原子操作op名
op_map = {
"like": "like", "comment": "open_comment", "follow": "follow",
"navigate_video": "open_video", "navigate_profile": "goto_profile",
"scroll": "scroll_feed", "next_video": "next_video",
"click_like_area": "like", "click_comment_area": "open_comment",
"search": "search", "enter_video": "enter_video",
}
op = op_map.get(at, f"custom_{at}")
suggestions.append({
"step": step_num,
"suggested_op": op,
"action_type": at,
"desc": action.get("action_desc", ""),
"confidence": action.get("inferred_action", {}).get("confidence", "medium"),
})
return suggestions
# ── 快捷入口 ──
def analyze_recording_file(path: str) -> dict:
"""从文件路径加载并分析录制包"""
from mc.recorder import RecordingSession
pkg = RecordingSession.load_recording(path)
return analyze_recording(pkg)
if __name__ == "__main__":
import sys
path = sys.argv[1] if len(sys.argv) > 1 else ""
if path:
result = analyze_recording_file(path)
print(json.dumps(result, ensure_ascii=False, indent=2))
else:
print("用法: python -m mc.analyzer <录制包路径>")