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,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 <录制包路径>")
|
||||
Reference in New Issue
Block a user