上一个提交把作品发布日期透到界面之后,抖音那一行显示成 1970-01-22。查原始产物: 小红书 time = 1790923011000 → 毫秒 → 2026-10-02 ✓ 抖音 create_time = 1790574515 → 秒 → 2026-09-28 ✗(被当毫秒 → 1970-01-22) 抖音给的是**秒**。而且这条路径不只影响新增的发布日期 —— **评论的 create_time 走的是 同一条路**,所以抖音评论的时间一直是错的,只是之前界面上没显示出来,没人发现。 时间单位的换算正是 adapters 该管的事,所以加在那边: * `PlatformAdapter.time_scale`(小红书 1、抖音 1000)+ `to_ms()`,解析不出来返回 None 而不是伪造 0。 * ingest 用它换算作品的 published_at 和评论的 create_time。落库统一毫秒,展示层不必 关心来源。 * 已入库的数据要能自愈:published_at 和评论 create_time 原先都是**只写一次**的,换算 改对了老数据也修不回来。现在它们会在重采时跟着刷新(昵称早就是这么做的)。 测试 +1:抖音记录落库后 published_at 是 1790574515 * 1000,且年份是 2026 不是 1970。 dy 的 fixture 也改成用真实的秒值(原来写的是毫秒形态,所以测不出这个 bug)。 注意:库里那条抖音记录**仍带着错的值**,要等抖音下一次成功采集才会被修回来 —— 而它 现在正被平台风控挡着(account blocked),见下一条说明。
250 lines
10 KiB
Python
250 lines
10 KiB
Python
# -*- 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, Optional, 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, ...]
|
||
|
||
# 时间戳换算成毫秒要乘的数。**小红书给毫秒、抖音给秒**,差 1000 倍;不换算的话
|
||
# 2026 年的作品会显示成 1970 年(实测踩到过:抖音作品发布日期显示 1970-01-22,
|
||
# 抖音评论的时间同理)。库里统一存毫秒,展示层才不用关心来源。
|
||
time_scale: int
|
||
|
||
def to_ms(self, value: Any) -> Optional[int]:
|
||
"""把平台的时间戳换算成毫秒;解析不出来返回 None(不伪造 0)。"""
|
||
try:
|
||
return int(value) * self.time_scale
|
||
except (TypeError, ValueError):
|
||
return None
|
||
|
||
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",),
|
||
# 小红书的时间戳本来就是毫秒(实测 time=1790923011000)。
|
||
time_scale=1,
|
||
)
|
||
|
||
# 抖音的 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",),
|
||
# 抖音给的是**秒**(实测 create_time=1790574515,即 2026-09-28)。
|
||
time_scale=1000,
|
||
)
|
||
|
||
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
|