feat(monitor): 抖音接入博主监控
上游爬虫本身不缺抖音能力(三模式、四项指标、二级评论都与小红书对等、指标还是同名同列),
缺的全在监控层的适配。这次把「平台之间不一样」的管子集中到一个新模块,再把散落的
xhs 硬编码接上去。
* 新增 api/monitor/adapters.py:产物目录名、jsonl 字段别名、目标链接形态与正则、
通知链接模板。不放进 platforms.py 是因为那个模块被 describe_all() 整个序列化进
/api/config/platforms 交给前端,塞进正则和目录名会让爬虫内部细节漏进 API 载荷。
代价是两个注册表可能漂移,用一条测试钉住「声明接通就必须有适配器」。
* 两个必须知道的坑,都在这版里处理掉了:
1) 抖音的平台 id 是 dy,而 store 把产物写在 douyin/ 下(store/douyin/_store_impl.py:47)。
不改就是 ingest 一个文件都读不到 —— 不报错,只是 0 条,然后被冒充成「疑似登录失效」。
2) 抖音的作品没有 note_id(叫 aweme_id)、评论也用 aweme_id 指作品。ingest 第一步是
`if not note_id: continue`,不映射就逐条全丢。
另外抖音顶层评论的 parent_comment_id 是字符串 "0",归一成空串,免得前端多出悬空的父节点。
* 顺带把「东西抓到了、只是没落在期望目录里」单独识别出来。这类故障的现象和登录失效
一模一样,按登录失效报会把人指去查完全错误的方向。
* 修两个既有 bug(今天只有小红书所以无害,加抖音就踩响):
- service.py update_task 换目标时漏传 task.platform,回落到默认小红书
- scheduler.py 取 cookie 没传 platform,抖音任务会读着小红书那份 cookie 不动
* 行为变更(已与用户确认):cookie 闸门改成「没 cookie 且没开 CDP」才跳过。
CDP 模式下登录态来自被接管的浏览器,粘不粘 cookie 由不得它决定;不放行的话,
选了「接管已有 Chrome」却没粘 cookie 的用户会看到任务永远不触发,而且不报错。
副作用是开启了 CDP 的小红书任务也不再被该闸门拦住 —— 语义上是对的。
* 目标输入框的示例链接与措辞改由能力矩阵提供(notes_label 抖音说「作品」、小红书说
「笔记」;「建议只填纯 ID」是小红书专属劝告,抖音链接不带令牌,不再显示)。
测试 +22 条(858 通过),其中最关键的是「抖音作品/评论不被静默丢弃」与「产物目录名
不等于平台 id」两条 —— 都是把最难查的失败模式钉死在回归网里。
注意:抖音这条路的**端到端尚未验证**,需要一份可用的抖音登录态(CDP 那台 Chrome 里
登录,或导出一份 cookie)。单测覆盖的是解析与入库,真实抓取还没跑过。
This commit is contained in:
@@ -0,0 +1,233 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright (c) 2025 [email protected]
|
||||
#
|
||||
# This file is part of MediaCrawler project.
|
||||
# Repository: https://github.com/NanmiCoder/MediaCrawler/blob/main/api/monitor/adapters.py
|
||||
# GitHub: https://github.com/NanmiCoder
|
||||
# Licensed under NON-COMMERCIAL LEARNING LICENSE 1.1
|
||||
#
|
||||
# 声明:本代码仅供学习和研究目的使用。使用者应遵守以下原则:
|
||||
# 1. 不得用于任何商业用途。
|
||||
# 2. 使用时应遵守对应平台的使用条款和robots.txt规则。
|
||||
# 3. 不得进行大规模爬取或对平台造成运营干扰。
|
||||
# 4. 应合理控制请求频率,避免给目标平台带来不必要的负担。
|
||||
# 5. 不得用于任何非法或不当的用途。
|
||||
#
|
||||
# 详细许可条款请参阅项目根目录下的LICENSE文件。
|
||||
# 使用本代码即表示您同意遵守上述原则和LICENSE中的所有条款。
|
||||
|
||||
"""平台适配:两个平台之间**不一样**的那些管子。
|
||||
|
||||
监控层的大部分是平台中立的 —— 调度、入库、差分、报表、封面缓存、通知发送都与平台无关。
|
||||
真正随平台变化的只有四样东西:
|
||||
|
||||
1. 爬虫把产物**落在哪个目录**(这里有个坑,见 ``artifact_dir``)
|
||||
2. jsonl 里**字段叫什么**(抖音的作品没有 ``note_id``,叫 ``aweme_id``)
|
||||
3. **目标链接**长什么样(怎么拼、怎么从链接里抠出 id)
|
||||
4. 通知里的作品链接怎么拼
|
||||
|
||||
集中在这里,是为了让「加一个平台」变成在一处补一份数据,而不是去五个文件里找硬编码。
|
||||
|
||||
**为什么不放进 platforms.py**:那个模块被 ``describe_all()`` 整个序列化进
|
||||
``GET /api/config/platforms`` 交给前端(连 ``**capability`` 一起),把正则、目录名、字段别名
|
||||
塞进去会让爬虫的内部细节漏进 API 载荷,也会让「改适配」有动到接口形状的风险。
|
||||
分工与既有的 schedule.py(算术)↔ scheduler.py(循环)一致。
|
||||
"""
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, Mapping, Pattern, Tuple
|
||||
|
||||
from .platforms import PLATFORM_XHS
|
||||
|
||||
PLATFORM_DY = "dy"
|
||||
|
||||
|
||||
def _first_cover(record: Dict[str, Any], fields: Tuple[str, ...]) -> str:
|
||||
"""封面地址:取第一个非空字段,再取逗号分隔的第一段。
|
||||
|
||||
一条规则同时适配两边,所以不需要 per-platform 的函数:
|
||||
小红书的 ``image_list`` 是 ``"url1,url2,..."``(要切第一段),
|
||||
抖音的 ``cover_url`` 本身就是单个地址(切了等于没切)。
|
||||
"""
|
||||
for name in fields:
|
||||
raw = record.get(name)
|
||||
if raw:
|
||||
return str(raw).split(",")[0].strip()
|
||||
return ""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PlatformAdapter:
|
||||
"""一个平台的全部「管子」。
|
||||
|
||||
字段别名的方向是**规范名 -> 该平台 jsonl 里的键**,读作「我们的列 ← 他们的键」。
|
||||
"""
|
||||
|
||||
# 爬虫落盘用的目录名。**不等于平台 id**:抖音的平台 id 是 ``dy`` 而目录是 ``douyin``。
|
||||
# 这不是笔误,是上游 store 里写死的(store/douyin/_store_impl.py:47)。改错这里的
|
||||
# 后果是 ingest 一个文件都找不到 —— 它不会报错,只会落进「没抓到数据」分支,
|
||||
# 然后被误报成「疑似登录失效」。
|
||||
artifact_dir: str
|
||||
|
||||
web_base: str
|
||||
creator_path: str
|
||||
note_path: str
|
||||
|
||||
# 从链接里抠 id。是元组而不是单个正则,因为同一个平台可能有多种链接形态
|
||||
# (抖音的作品链接还带 ?modal_id= 那种),按顺序试,第一个匹配的胜出。
|
||||
# 每个正则必须恰好有一个捕获组。
|
||||
creator_url_res: Tuple[Pattern, ...]
|
||||
note_url_res: Tuple[Pattern, ...]
|
||||
# 也允许直接粘贴裸 id —— 但两边的 id 形状不同,所以分开。
|
||||
creator_bare_re: Pattern
|
||||
note_bare_re: Pattern
|
||||
# 短链(v.douyin.com 这种)无法在不发请求的情况下还原出 id,解析时单独报错,
|
||||
# 好过存一个聚不出目标的值进去。
|
||||
short_link_hosts: Tuple[str, ...]
|
||||
|
||||
note_fields: Mapping[str, str]
|
||||
comment_fields: Mapping[str, str]
|
||||
cover_fields: Tuple[str, ...]
|
||||
|
||||
def note_url(self, note_id: str) -> str:
|
||||
"""作品的可点击链接。拼法与监控目标的链接是同一个形状 —— 通知里给的就是
|
||||
人能直接点开看的那一个。"""
|
||||
return f"{self.web_base}{self.note_path}/{note_id}"
|
||||
|
||||
def note_field(self, record: Dict[str, Any], name: str) -> Any:
|
||||
"""按规范名读作品记录里的原始值(没有就是 None)。"""
|
||||
return record.get(self.note_fields.get(name, name))
|
||||
|
||||
def comment_field(self, record: Dict[str, Any], name: str) -> Any:
|
||||
return record.get(self.comment_fields.get(name, name))
|
||||
|
||||
def cover(self, record: Dict[str, Any]) -> str:
|
||||
return _first_cover(record, self.cover_fields)
|
||||
|
||||
def parent_comment_id(self, record: Dict[str, Any]) -> str:
|
||||
"""父评论 id,顶层评论一律归一成空串。
|
||||
|
||||
抖音顶层评论的 ``reply_id`` 是字符串 ``"0"``,小红书是 ``""`` —— 把 "0" 原样
|
||||
存进去,前端就会多出一堆指向不存在的父评论的边。
|
||||
"""
|
||||
raw = self.comment_field(record, "parent_comment_id")
|
||||
if raw is None:
|
||||
return ""
|
||||
raw = str(raw).strip()
|
||||
return "" if raw in ("", "0") else raw
|
||||
|
||||
|
||||
# 小红书 id 是 24 位 hex,允许稍宽一点,让格式变化退化成「仍然接受」而不是「拒绝」。
|
||||
_XHS_BARE_RE = re.compile(r"^[A-Za-z0-9_-]{8,64}$")
|
||||
|
||||
XHS = PlatformAdapter(
|
||||
artifact_dir="xhs",
|
||||
web_base="https://www.xiaohongshu.com",
|
||||
creator_path="/user/profile",
|
||||
note_path="/explore",
|
||||
creator_url_res=(re.compile(r"xiaohongshu\.com/user/profile/([A-Za-z0-9_-]+)"),),
|
||||
note_url_res=(
|
||||
re.compile(r"xiaohongshu\.com/(?:explore|discovery/item)/([A-Za-z0-9_-]+)"),
|
||||
),
|
||||
creator_bare_re=_XHS_BARE_RE,
|
||||
note_bare_re=_XHS_BARE_RE,
|
||||
short_link_hosts=(),
|
||||
note_fields={
|
||||
"note_id": "note_id",
|
||||
"title": "title",
|
||||
"note_url": "note_url",
|
||||
"creator_hash": "creator_hash",
|
||||
"creator_name": "nickname",
|
||||
"source_kind": "type",
|
||||
"published_at": "time",
|
||||
},
|
||||
comment_fields={
|
||||
"comment_id": "comment_id",
|
||||
"note_id": "note_id",
|
||||
"content": "content",
|
||||
"creator_hash": "creator_hash",
|
||||
"creator_name": "nickname",
|
||||
"create_time": "create_time",
|
||||
"like_count": "like_count",
|
||||
"sub_comment_count": "sub_comment_count",
|
||||
"parent_comment_id": "parent_comment_id",
|
||||
},
|
||||
cover_fields=("image_list",),
|
||||
)
|
||||
|
||||
# 抖音的 id 形状与小红书完全不同(见 media_platform/douyin/help.py:101-164):
|
||||
# 作品 aweme_id 纯数字,如 7525082444551310602
|
||||
# 博主 sec_user_id 形如 MS4wLjABAAAA...,含 - 和 _,**变长**(实测样本 55 字符,更长的也常见),
|
||||
# 而小红书那条裸 id 规则封顶 64 —— 所以两条规则必须分开,否则长一点的 sec_uid
|
||||
# 会被拒,表现为「粘贴了一个完全正确的链接却说无法识别」。
|
||||
# 另外抖音**不需要 xsec_token**,裸链接就能用,比小红书简单。
|
||||
DY = PlatformAdapter(
|
||||
artifact_dir="douyin",
|
||||
web_base="https://www.douyin.com",
|
||||
creator_path="/user",
|
||||
note_path="/video",
|
||||
creator_url_res=(re.compile(r"douyin\.com/user/([A-Za-z0-9_-]+)"),),
|
||||
note_url_res=(
|
||||
re.compile(r"douyin\.com/video/(\d+)"),
|
||||
# 带 modal_id 的链接:在别人主页或搜索结果里点开视频就是这个形态。
|
||||
re.compile(r"[?&]modal_id=(\d+)"),
|
||||
),
|
||||
# 用长度而不是前缀来区分两者:sec_uid 是 20 字符以上的变长串,作品 id 是 19 位数字。
|
||||
# 用前缀(MS4wLjABAAAA)更精确,但上游的 parse_creator_info_from_url 对裸 id 一律
|
||||
# 照单全收,万一有别的前缀就会被我这里挡掉 —— 门槛设在长度上,两边都放得进,
|
||||
# 又不会把 19 位的作品号误当成博主。
|
||||
creator_bare_re=re.compile(r"^[A-Za-z0-9_-]{20,128}$"),
|
||||
note_bare_re=re.compile(r"^\d{8,25}$"),
|
||||
short_link_hosts=("v.douyin.com",),
|
||||
note_fields={
|
||||
"note_id": "aweme_id",
|
||||
"title": "title",
|
||||
"note_url": "aweme_url",
|
||||
"creator_hash": "creator_hash",
|
||||
"creator_name": "nickname",
|
||||
"source_kind": "aweme_type",
|
||||
"published_at": "create_time",
|
||||
},
|
||||
comment_fields={
|
||||
"comment_id": "comment_id",
|
||||
"note_id": "aweme_id",
|
||||
"content": "content",
|
||||
"creator_hash": "creator_hash",
|
||||
"creator_name": "nickname",
|
||||
"create_time": "create_time",
|
||||
"like_count": "like_count",
|
||||
"sub_comment_count": "sub_comment_count",
|
||||
"parent_comment_id": "parent_comment_id",
|
||||
},
|
||||
cover_fields=("cover_url",),
|
||||
)
|
||||
|
||||
ADAPTERS: Dict[str, PlatformAdapter] = {
|
||||
PLATFORM_XHS: XHS,
|
||||
PLATFORM_DY: DY,
|
||||
}
|
||||
|
||||
|
||||
class UnknownPlatformError(ValueError):
|
||||
"""平台还没有适配器。"""
|
||||
|
||||
|
||||
def adapter(platform: str) -> PlatformAdapter:
|
||||
try:
|
||||
return ADAPTERS[platform]
|
||||
except KeyError as exc:
|
||||
raise UnknownPlatformError(f"平台 {platform} 还没有适配器") from exc
|
||||
|
||||
|
||||
def has_adapter(platform: str) -> bool:
|
||||
return platform in ADAPTERS
|
||||
|
||||
|
||||
def artifact_dir(platform: str) -> str:
|
||||
"""该平台的爬虫会把 jsonl 落在哪个子目录下。
|
||||
|
||||
``runner`` 用它判断产物是否真的出现过,``ingest`` 用它定位文件 —— 两处必须用
|
||||
同一个值,否则会出现「文件在,但两边找的目录不是同一个」这种最难查的错。
|
||||
"""
|
||||
return adapter(platform).artifact_dir
|
||||
Reference in New Issue
Block a user