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,361 @@
"""
scrape_engine.py — 抓取系统核心引擎 v1
职责:
1. URL 解析 → 标准化目标
2. 适配器路由(按平台选择适配器)
3. 工具降级执行
4. 结果入库(SQLite,去重)
5. 多机分发(通过 command_bus)
6. 异步任务管理(大量目标用轮询)
"""
import asyncio, json, logging, os, time, uuid
from datetime import datetime
from pathlib import Path
from typing import Optional
from services.scrape_db import ScrapeDB
from services.adapters.douyin_scrape import DouyinScrapeAdapter
from services.adapters.xhs_scrape import XhsScrapeAdapter
from services.adapters.bilibili_scrape import BilibiliScrapeAdapter
from services.adapters.zhihu_scrape import ZhihuScrapeAdapter
from services.adapters.web_scrape import WebScrapeAdapter
logger = logging.getLogger("dashboard.scrape.engine")
_THIS_DIR = Path(__file__).resolve().parent
class ScrapeEngine:
"""抓取引擎"""
def __init__(self):
self.db = ScrapeDB()
self._adapters = {}
self._tasks = {} # 异步任务缓存 {run_id: status}
def _get_adapter(self, platform: str):
"""获取/缓存适配器实例"""
if platform not in self._adapters:
if platform == "douyin":
self._adapters[platform] = DouyinScrapeAdapter()
elif platform in ("xiaohongshu", "xhs"):
self._adapters[platform] = XhsScrapeAdapter()
elif platform == "bilibili":
self._adapters[platform] = BilibiliScrapeAdapter()
elif platform == "zhihu":
self._adapters[platform] = ZhihuScrapeAdapter()
elif platform == "web":
self._adapters[platform] = WebScrapeAdapter()
else:
raise ValueError(f"不支持的平台: {platform}")
return self._adapters[platform]
# ── URL 解析 ──
def resolve_target(self, raw: str) -> dict:
"""解析单个目标 → 标准化格式"""
raw = raw.strip()
import re
# ── 抖音 ──
# 抖音短链: v.douyin.com/xxx
if "v.douyin.com" in raw:
return {"input": raw, "platform": "douyin", "type": "shortlink",
"status": "pending"}
# 抖音视频页: douyin.com/video/{id}
if "douyin.com/video/" in raw:
m = re.search(r'/video/(\d+)', raw)
return {"input": raw, "platform": "douyin", "type": "video",
"target_id": m.group(1) if m else raw, "status": "resolved"}
# 抖音用户主页: douyin.com/user/{sec_uid}
if "douyin.com/user/" in raw:
sec_uid = raw.split("/user/")[-1].split("?")[0]
return {"input": raw, "platform": "douyin", "type": "user",
"target_id": sec_uid, "status": "resolved"}
# ── 小红书 ──
if "xiaohongshu.com" in raw:
m = re.search(r'/explore/([a-f0-9]+)', raw)
if m:
return {"input": raw, "platform": "xiaohongshu", "type": "note",
"target_id": m.group(1), "status": "resolved"}
# ── B站 ──
if "bilibili.com" in raw:
# bilibili.com/video/BVxxx
m = re.search(r'/video/([a-zA-Z0-9]+)', raw)
if m:
return {"input": raw, "platform": "bilibili", "type": "video",
"target_id": m.group(1), "status": "resolved"}
# bilibili.com/space/{mid}
m = re.search(r'/space/(\d+)', raw)
if m:
return {"input": raw, "platform": "bilibili", "type": "user",
"target_id": m.group(1), "status": "resolved"}
# ── 知乎 ──
if "zhihu.com" in raw:
# zhihu.com/answer/{id}
m = re.search(r'/answer/(\d+)', raw)
if m:
return {"input": raw, "platform": "zhihu", "type": "item",
"target_id": m.group(1), "status": "resolved"}
# zhihu.com/question/{id}
m = re.search(r'/question/(\d+)', raw)
if m:
return {"input": raw, "platform": "zhihu", "type": "item",
"target_id": m.group(1), "status": "resolved"}
# ── 识别 URL(通用网页)──
if raw.startswith("http://") or raw.startswith("https://"):
return {"input": raw, "platform": "web", "type": "page",
"target_id": raw, "status": "resolved"}
# ── 纯 sec_uid(抖音)──
if raw.startswith("MS4w") or len(raw) > 20:
return {"input": raw, "platform": "douyin", "type": "user",
"target_id": raw, "status": "resolved"}
# 纯数字(可能是 aweme_id)
if raw.isdigit() and len(raw) >= 15:
return {"input": raw, "platform": "douyin", "type": "video",
"target_id": raw, "status": "resolved"}
# 纯数字(知乎 answer_id)
if raw.isdigit():
return {"input": raw, "platform": "zhihu", "type": "item",
"target_id": raw, "status": "resolved"}
return {"input": raw, "platform": "unknown", "type": "unknown",
"status": "unresolved"}
def resolve_urls(self, targets: list) -> list[dict]:
"""批量解析 URL"""
return [self.resolve_target(t) for t in targets]
# ── URL 短链解析 ──
def _resolve_shortlink(self, url: str) -> Optional[str]:
"""解析抖音短链 → aweme_id"""
import subprocess, re
try:
result = subprocess.run(
["curl", "-sI", url],
capture_output=True, text=True, timeout=10
)
loc = ""
for line in result.stdout.split("\n"):
if line.lower().startswith("location:"):
loc = line.split(":", 1)[1].strip()
break
if loc:
m = re.search(r'/video/(\d+)', loc)
if m:
return m.group(1)
# iesdouyin.com 格式
m = re.search(r'/share/video/(\d+)', loc)
if m:
return m.group(1)
return None
except:
return None
# ── 核心执行 ──
async def run(self, request: dict) -> dict:
"""
执行抓取任务
request:
targets: [str] — 目标列表
platform: str — 自动识别时留空
depth: "light" — "light"/"deep"
tool_level: int — 1/2/3
machine: str — 指定机器(空=本机)
multi_machine: bool — 是否多机协同
async_mode: bool — 是否异步(大量目标用)
"""
t0 = time.time()
targets = request.get("targets", [])
platform = request.get("platform", "auto")
depth = request.get("depth", "light")
tool_level = request.get("tool_level", 2)
machine = request.get("machine", "")
multi_machine = request.get("multi_machine", False)
async_mode = request.get("async_mode", False) or len(targets) > 50
# 1. 解析目标
resolved = self.resolve_urls(targets)
unresolved = [r for r in resolved if r["status"] == "unresolved"]
shortlinks = [r for r in resolved if r["type"] == "shortlink"]
# 2. 解析短链
for sl in shortlinks:
aweme_id = self._resolve_shortlink(sl["input"])
if aweme_id:
sl["type"] = "video"
sl["target_id"] = aweme_id
sl["platform"] = "douyin"
sl["status"] = "resolved"
else:
sl["status"] = "unresolved"
ready = [r for r in resolved if r["status"] == "resolved"]
# 3. 异步模式 → 创建后台任务
if async_mode:
run_id = str(uuid.uuid4())[:8]
self._tasks[run_id] = {
"id": run_id, "status": "running",
"total": len(ready), "completed": 0,
"results": [], "errors": [],
"created_at": datetime.now().isoformat(),
}
asyncio.create_task(self._run_async(run_id, ready, platform,
depth, tool_level))
return {
"status": "async",
"run_id": run_id,
"total": len(ready),
"message": f"异步任务已创建({len(ready)} 个目标)",
}
# 4. 同步模式(目标少,直接执行)
task_id = self.db.create_task(
"single", platform or "auto",
json.dumps([t["input"] for t in ready], ensure_ascii=False),
depth, tool_level, machine, len(ready)
)
results = []
errors = []
for target in ready:
try:
result = await self._scrape_one(
target, platform or target["platform"],
depth, tool_level
)
if result is None:
continue
# result 可能是 dict(单条)或 list(用户的所有内容)
items = result if isinstance(result, list) else [result]
for item in items:
self._save_item(task_id, item)
item.pop("comments", None)
results.append(item)
except Exception as e:
errors.append({"target": target["input"], "error": str(e)})
self.db.update_task_status(task_id, "completed" if not errors else "completed",
summary=json.dumps({"success": len(results),
"errors": len(errors)},
ensure_ascii=False))
return {
"status": "completed",
"task_id": task_id,
"duration": round(time.time() - t0, 1),
"total": len(ready),
"success": len(results),
"errors": errors,
"data": results,
}
async def _scrape_one(self, target: dict, platform: str,
depth: str, tool_level: int):
"""
抓取单个目标。
返回:
- dict → 单条内容(video/note)
- list → 多条内容(user 类型,用户的所有视频/笔记)
- None → 抓取失败
"""
try:
adapter = self._get_adapter(platform)
except ValueError:
logger.warning(f" ⚠️ 不支持的平台: {platform}")
return None
try:
if target["type"] == "user":
# 用户抓取:返回该用户的所有内容(列表)
return await adapter.collect_user(target["target_id"])
elif target["type"] == "video":
return await adapter.collect_item(target["target_id"],
depth, tool_level)
elif target["type"] == "note":
return await adapter.collect_item(target["target_id"],
depth, tool_level)
else:
return None
except Exception as e:
logger.error(f" ❌ 抓取失败 [{target['input']}]: {e}")
return None
def _save_item(self, task_id: int, item: dict) -> int:
"""将单条抓取结果写入数据库,返回 db_id"""
db_id = self.db.insert_item(
task_id=task_id,
platform=item.get("platform", ""),
item_id=item.get("item_id", ""),
url=item.get("url", ""),
title=item.get("title", ""),
author_name=item.get("author_name", ""),
author_id=item.get("author_id", ""),
published_at=item.get("published_at", ""),
text_content=item.get("text_content", ""),
tags=item.get("tags", []),
stats=item.get("stats", {}),
extra=item.get("extra", {}),
media=item.get("media", []),
)
# 评论独立入库
comments = item.get("comments", [])
if comments and db_id:
self.db.insert_comments(db_id, comments)
return db_id
async def _run_async(self, run_id: str, targets: list, platform: str,
depth: str, tool_level: int):
"""异步执行大量目标(结果写入 SQLite 持久化,不依赖内存)"""
task_data = self._tasks.get(run_id, {})
# 异步模式也创建持久化任务记录
all_targets_json = json.dumps([t["input"] for t in targets], ensure_ascii=False)
task_id = self.db.create_task("batch", platform or "auto",
all_targets_json, depth, tool_level,
"async", len(targets))
for target in targets:
try:
result = await self._scrape_one(
target, platform or target["platform"],
depth, tool_level
)
if result:
items = result if isinstance(result, list) else [result]
for item in items:
self._save_item(task_id, item)
item.pop("comments", None)
task_data["results"].append(item)
except Exception as e:
err = {"target": target["input"], "error": str(e)}
task_data["errors"].append(err)
task_data["completed"] += 1
task_data["status"] = "completed"
# 更新任务状态
self.db.update_task_status(task_id, "completed",
summary=json.dumps({
"success": len(task_data["results"]),
"errors": len(task_data["errors"]),
}, ensure_ascii=False))
def get_async_result(self, run_id: str) -> Optional[dict]:
"""查询异步任务结果"""
return self._tasks.get(run_id)
# ── 统计 ──
def get_stats(self) -> dict:
return {
"total": sum(self.db.count_by_platform().values()),
"today": self.db.count_today(),
"sources": self.db.sources_count(),
"by_platform": self.db.count_by_platform(),
"tasks": self.db.task_stats(),
}