fix(monitor): 抖音的时间戳是秒、小红书是毫秒,不换算会把 2026 年显示成 1970 年
Deploy VitePress site to Pages / build (push) Canceled after 0s
Deploy VitePress site to Pages / Deploy (push) Canceled after 0s

上一个提交把作品发布日期透到界面之后,抖音那一行显示成 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),见下一条说明。
This commit is contained in:
2026-10-10 16:08:42 +08:00
parent 3486c7f524
commit 1118d466be
3 changed files with 51 additions and 5 deletions
+17 -1
View File
@@ -36,7 +36,7 @@
import re import re
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any, Dict, Mapping, Pattern, Tuple from typing import Any, Dict, Mapping, Optional, Pattern, Tuple
from .platforms import PLATFORM_XHS from .platforms import PLATFORM_XHS
@@ -90,6 +90,18 @@ class PlatformAdapter:
comment_fields: Mapping[str, str] comment_fields: Mapping[str, str]
cover_fields: Tuple[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: def note_url(self, note_id: str) -> str:
"""作品的可点击链接。拼法与监控目标的链接是同一个形状 —— 通知里给的就是 """作品的可点击链接。拼法与监控目标的链接是同一个形状 —— 通知里给的就是
人能直接点开看的那一个。""" 人能直接点开看的那一个。"""
@@ -154,6 +166,8 @@ XHS = PlatformAdapter(
"parent_comment_id": "parent_comment_id", "parent_comment_id": "parent_comment_id",
}, },
cover_fields=("image_list",), cover_fields=("image_list",),
# 小红书的时间戳本来就是毫秒(实测 time=1790923011000)。
time_scale=1,
) )
# 抖音的 id 形状与小红书完全不同(见 media_platform/douyin/help.py:101-164): # 抖音的 id 形状与小红书完全不同(见 media_platform/douyin/help.py:101-164):
@@ -201,6 +215,8 @@ DY = PlatformAdapter(
"parent_comment_id": "parent_comment_id", "parent_comment_id": "parent_comment_id",
}, },
cover_fields=("cover_url",), cover_fields=("cover_url",),
# 抖音给的是**秒**(实测 create_time=1790574515,即 2026-09-28)。
time_scale=1000,
) )
ADAPTERS: Dict[str, PlatformAdapter] = { ADAPTERS: Dict[str, PlatformAdapter] = {
+12 -2
View File
@@ -334,7 +334,8 @@ async def _ingest_notes(
creator_hash=adapter.note_field(record, "creator_hash") or "", creator_hash=adapter.note_field(record, "creator_hash") or "",
creator_name=adapter.note_field(record, "creator_name") or "", creator_name=adapter.note_field(record, "creator_name") or "",
source_kind=adapter.note_field(record, "source_kind") or "", source_kind=adapter.note_field(record, "source_kind") or "",
published_at=_as_int(adapter.note_field(record, "published_at")), # 经 to_ms 换算:小红书给毫秒、抖音给秒,差 1000 倍。
published_at=adapter.to_ms(adapter.note_field(record, "published_at")),
first_seen_run_id=run.id, first_seen_run_id=run.id,
first_seen_at=now, first_seen_at=now,
last_seen_run_id=run.id, last_seen_run_id=run.id,
@@ -365,6 +366,11 @@ async def _ingest_notes(
creator_name = adapter.note_field(record, "creator_name") creator_name = adapter.note_field(record, "creator_name")
if creator_name: if creator_name:
note.creator_name = creator_name note.creator_name = creator_name
# 发布时间也刷。正常情况下它不会变,但**换算单位改过之后**(抖音是秒、
# 小红书是毫秒),已经入库的那批只能靠重采修回来。
published = adapter.to_ms(adapter.note_field(record, "published_at"))
if published is not None:
note.published_at = published
note.last_seen_run_id = run.id note.last_seen_run_id = run.id
note.last_seen_at = now note.last_seen_at = now
@@ -491,9 +497,13 @@ async def _ingest_comments(
refreshed = adapter.comment_field(record, "creator_name") refreshed = adapter.comment_field(record, "creator_name")
if refreshed: if refreshed:
existing.nickname = refreshed existing.nickname = refreshed
# 时间同理:单位换算修好之后,老数据要重采才能纠正。
created = adapter.to_ms(adapter.comment_field(record, "create_time"))
if created is not None:
existing.create_time = created
continue continue
create_time = _as_int(adapter.comment_field(record, "create_time")) create_time = adapter.to_ms(adapter.comment_field(record, "create_time"))
session.add( session.add(
MonitorComment( MonitorComment(
task_id=run.task_id, task_id=run.task_id,
+22 -2
View File
@@ -24,6 +24,7 @@ posted/seen comment split, idempotency, and the silent-cookie-failure signal.
""" """
import json import json
from datetime import date
from pathlib import Path from pathlib import Path
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
@@ -188,7 +189,9 @@ def _dy_note(aweme_id: str, liked: Any = "10", **extra) -> Dict[str, Any]:
"aweme_type": "0", "aweme_type": "0",
"title": f"title-{aweme_id}", "title": f"title-{aweme_id}",
"desc": f"title-{aweme_id}", "desc": f"title-{aweme_id}",
"create_time": 1700000000000, # 抖音给的是**秒**(实测 1790574515 = 2026-09-28),小红书给毫秒。落库统一
# 换算成毫秒,这个 fixture 必须照真实形态写,否则测不出单位问题。
"create_time": 1790574515,
"creator_hash": "hash", "creator_hash": "hash",
"nickname": "u***r", "nickname": "u***r",
"liked_count": liked, "liked_count": liked,
@@ -611,9 +614,26 @@ class TestDouyinIngest:
assert note.note_url == f"https://www.douyin.com/video/{aweme_id}" assert note.note_url == f"https://www.douyin.com/video/{aweme_id}"
assert note.cover == "https://img/cover.jpg" assert note.cover == "https://img/cover.jpg"
assert note.source_kind == "0" assert note.source_kind == "0"
assert note.published_at == 1700000000000 # 秒 → 毫秒,换算过才对。
assert note.published_at == 1790574515 * 1000
assert result.notes_fetched == 1 assert result.notes_fetched == 1
@pytest.mark.asyncio
async def test_timestamps_are_normalised_to_milliseconds(self, db, tmp_path):
"""抖音的时间戳是**秒**,小红书是毫秒 —— 差 1000 倍,必须换算。
不换算的话,2026 年的作品会显示成 1970 年。这是实测踩到的:抖音作品的
「发布日期」列显示成 1970-01-22(1790574515 被当成毫秒就是 21 天后)。
"""
aweme_id = "7525082444551310602"
await self._ingest(db, tmp_path, [_dy_note(aweme_id)])
note = await db.scalar(select(MonitorNote))
assert note.published_at == 1790574515 * 1000
# 落库的是毫秒,展示层才不用关心来源;乘完应该在 2026 年,不是 1970。
assert date.fromtimestamp(note.published_at / 1000).year == 2026
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_the_artifact_directory_is_not_the_platform_id(self, db, tmp_path): async def test_the_artifact_directory_is_not_the_platform_id(self, db, tmp_path):
"""目录名与平台 id 不一致,是这套适配里最反直觉的一条。 """目录名与平台 id 不一致,是这套适配里最反直觉的一条。