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,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(),
|
||||
}
|
||||
Reference in New Issue
Block a user