From 1118d466bebc42632d2c382ba92b47591bc8efd0 Mon Sep 17 00:00:00 2001 From: butubb <1422726308@qq.com> Date: Sat, 10 Oct 2026 16:08:42 +0800 Subject: [PATCH] =?UTF-8?q?fix(monitor):=20=E6=8A=96=E9=9F=B3=E7=9A=84?= =?UTF-8?q?=E6=97=B6=E9=97=B4=E6=88=B3=E6=98=AF=E7=A7=92=E3=80=81=E5=B0=8F?= =?UTF-8?q?=E7=BA=A2=E4=B9=A6=E6=98=AF=E6=AF=AB=E7=A7=92=EF=BC=8C=E4=B8=8D?= =?UTF-8?q?=E6=8D=A2=E7=AE=97=E4=BC=9A=E6=8A=8A=202026=20=E5=B9=B4?= =?UTF-8?q?=E6=98=BE=E7=A4=BA=E6=88=90=201970=20=E5=B9=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 上一个提交把作品发布日期透到界面之后,抖音那一行显示成 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),见下一条说明。 --- api/monitor/adapters.py | 18 +++++++++++++++++- api/monitor/ingest.py | 14 ++++++++++++-- tests/test_monitor_ingest.py | 24 ++++++++++++++++++++++-- 3 files changed, 51 insertions(+), 5 deletions(-) diff --git a/api/monitor/adapters.py b/api/monitor/adapters.py index 3c04e19..c977376 100644 --- a/api/monitor/adapters.py +++ b/api/monitor/adapters.py @@ -36,7 +36,7 @@ import re 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 @@ -90,6 +90,18 @@ class PlatformAdapter: 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: """作品的可点击链接。拼法与监控目标的链接是同一个形状 —— 通知里给的就是 人能直接点开看的那一个。""" @@ -154,6 +166,8 @@ XHS = PlatformAdapter( "parent_comment_id": "parent_comment_id", }, cover_fields=("image_list",), + # 小红书的时间戳本来就是毫秒(实测 time=1790923011000)。 + time_scale=1, ) # 抖音的 id 形状与小红书完全不同(见 media_platform/douyin/help.py:101-164): @@ -201,6 +215,8 @@ DY = PlatformAdapter( "parent_comment_id": "parent_comment_id", }, cover_fields=("cover_url",), + # 抖音给的是**秒**(实测 create_time=1790574515,即 2026-09-28)。 + time_scale=1000, ) ADAPTERS: Dict[str, PlatformAdapter] = { diff --git a/api/monitor/ingest.py b/api/monitor/ingest.py index 91db70f..d110404 100644 --- a/api/monitor/ingest.py +++ b/api/monitor/ingest.py @@ -334,7 +334,8 @@ async def _ingest_notes( creator_hash=adapter.note_field(record, "creator_hash") or "", creator_name=adapter.note_field(record, "creator_name") 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_at=now, last_seen_run_id=run.id, @@ -365,6 +366,11 @@ async def _ingest_notes( creator_name = adapter.note_field(record, "creator_name") if 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_at = now @@ -491,9 +497,13 @@ async def _ingest_comments( refreshed = adapter.comment_field(record, "creator_name") if refreshed: existing.nickname = refreshed + # 时间同理:单位换算修好之后,老数据要重采才能纠正。 + created = adapter.to_ms(adapter.comment_field(record, "create_time")) + if created is not None: + existing.create_time = created continue - create_time = _as_int(adapter.comment_field(record, "create_time")) + create_time = adapter.to_ms(adapter.comment_field(record, "create_time")) session.add( MonitorComment( task_id=run.task_id, diff --git a/tests/test_monitor_ingest.py b/tests/test_monitor_ingest.py index 52ef451..7905969 100644 --- a/tests/test_monitor_ingest.py +++ b/tests/test_monitor_ingest.py @@ -24,6 +24,7 @@ posted/seen comment split, idempotency, and the silent-cookie-failure signal. """ import json +from datetime import date from pathlib import Path 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", "title": f"title-{aweme_id}", "desc": f"title-{aweme_id}", - "create_time": 1700000000000, + # 抖音给的是**秒**(实测 1790574515 = 2026-09-28),小红书给毫秒。落库统一 + # 换算成毫秒,这个 fixture 必须照真实形态写,否则测不出单位问题。 + "create_time": 1790574515, "creator_hash": "hash", "nickname": "u***r", "liked_count": liked, @@ -611,9 +614,26 @@ class TestDouyinIngest: assert note.note_url == f"https://www.douyin.com/video/{aweme_id}" assert note.cover == "https://img/cover.jpg" assert note.source_kind == "0" - assert note.published_at == 1700000000000 + # 秒 → 毫秒,换算过才对。 + assert note.published_at == 1790574515 * 1000 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 async def test_the_artifact_directory_is_not_the_platform_id(self, db, tmp_path): """目录名与平台 id 不一致,是这套适配里最反直觉的一条。