diff --git a/api/monitor/service.py b/api/monitor/service.py
index eb8c774..4043ad2 100644
--- a/api/monitor/service.py
+++ b/api/monitor/service.py
@@ -404,8 +404,14 @@ async def list_notes(
"note_id": note.note_id,
"title": note.title,
"note_url": note.note_url,
+ # 作品的发布时间(爬虫侧:小红书叫 time、抖音叫 create_time)。
+ # 和 first_seen_at 不是一回事 —— 那是**我们第一次看到它**的时间;把一个
+ # 早就存在的作品加进监控时,两者能差好几个月。可能为 null(平台没给,
+ # 或者值解析不出来),所以前端要能显示成「—」。
+ "published_at": note.published_at,
# 按博主分组用。creator_hash 是唯一稳定的创作者标识(原始 user_id
- # 被爬虫刻意匿名化了),creator_name 是已脱敏的昵称。
+ # 被爬虫刻意匿名化了),creator_name 是昵称本身 —— 本仓库关掉了脱敏
+ # (见 config.MASK_NICKNAME),所以就是原文。
"creator_hash": note.creator_hash,
"creator_name": note.creator_name,
# 优先给本地缓存地址:远程地址带签名、会过期(实测隔天即 403),
@@ -488,6 +494,7 @@ async def _note_meta_map(
"note_title": row.title,
"note_cover": covers.cover_url(row.note_id, row.cover),
"note_url": row.note_url,
+ "published_at": row.published_at,
"task_id": row.task_id,
# 博主维度也带上,评论流才能按 博主 -> 作品 -> 评论 三级展开。
"creator_hash": row.creator_hash,
@@ -539,6 +546,7 @@ async def list_comments(
"note_url": meta.get(row.note_id, {}).get("note_url", ""),
"note_creator_hash": meta.get(row.note_id, {}).get("creator_hash", ""),
"note_creator_name": meta.get(row.note_id, {}).get("creator_name", ""),
+ "note_published_at": meta.get(row.note_id, {}).get("published_at"),
}
for row in comments
]
diff --git a/api/routers/monitor.py b/api/routers/monitor.py
index fb40058..491bcf3 100644
--- a/api/routers/monitor.py
+++ b/api/routers/monitor.py
@@ -178,6 +178,9 @@ async def list_comments(
# 同一个桶里的评论必然同属一个作品,所以取哪一条都一样。
"creator_hash": comment["note_creator_hash"],
"creator_name": comment["note_creator_name"],
+ # 作品的发布时间。评论流按 博主 → 作品 → 评论 展开时,作品那一层
+ # 光有标题不够 —— 同名作品不少,日期能帮着认。
+ "published_at": comment["note_published_at"],
"comments": [],
},
)
diff --git a/tests/test_monitor_comments.py b/tests/test_monitor_comments.py
index 60cbd89..6fc83ee 100644
--- a/tests/test_monitor_comments.py
+++ b/tests/test_monitor_comments.py
@@ -36,6 +36,11 @@ from api.monitor.models import (
TASK_NAME = "评论归属测试"
+# 作品的发布时间。和 first_seen_at(我们第一次看到它)刻意取不同的值 —— 两者混成
+# 一个概念是最容易犯的错。
+PUBLISHED_A = 1_699_000_000_000
+PUBLISHED_B = 1_699_100_000_000
+
async def _seed():
"""Two works; three comments on the first, one on the second."""
@@ -50,9 +55,9 @@ async def _seed():
await session.flush()
# 两个作品**属于不同的博主** —— 评论流最外层按创作者分组,同一个人就没得测了。
- for note_id, title, creator_hash, creator_name in (
- ("note-a", "作品甲", "hash-a", "博主甲"),
- ("note-b", "作品乙", "hash-b", "博主乙"),
+ for note_id, title, creator_hash, creator_name, published_at in (
+ ("note-a", "作品甲", "hash-a", "博主甲", PUBLISHED_A),
+ ("note-b", "作品乙", "hash-b", "博主乙", PUBLISHED_B),
):
session.add(
MonitorNote(
@@ -60,7 +65,7 @@ async def _seed():
note_url=f"https://www.xiaohongshu.com/explore/{note_id}",
cover=f"https://img/{note_id}.jpg", creator_hash=creator_hash,
creator_name=creator_name,
- source_kind="video", published_at=None,
+ source_kind="video", published_at=published_at,
first_seen_run_id=1, first_seen_at=1_700_000_000_000,
last_seen_run_id=1, last_seen_at=1_700_000_000_000,
)
@@ -165,6 +170,37 @@ class TestGroupByNote:
# 两个作品的创作者必须真的不同,否则界面上照样分不出来。
assert groups["note-a"]["creator_hash"] != groups["note-b"]["creator_hash"]
+ @pytest.mark.asyncio
+ async def test_each_bucket_carries_the_publish_date(self, client):
+ """作品那一层要带发布日期:同名作品不少,日期能帮着认。
+
+ 注意它和 first_seen_at 是两个概念 —— 前者是作者发布的那天,后者是我们第一次
+ 看到它的那天。把老作品加进监控时两者能差好几个月。
+ """
+ groups = {
+ group["note_id"]: group
+ for group in (
+ await client.get("/api/monitor/comments", params={"group_by": "note"})
+ ).json()["groups"]
+ }
+
+ assert groups["note-a"]["published_at"] == PUBLISHED_A
+ assert groups["note-b"]["published_at"] == PUBLISHED_B
+ assert groups["note-a"]["published_at"] != groups["note-b"]["published_at"]
+
+ @pytest.mark.asyncio
+ async def test_the_notes_endpoint_exposes_the_publish_date(self, client):
+ """作品列表也要带上它 —— 作品栏就是靠这个显示「发布日期」列的。"""
+ notes = {
+ note["note_id"]: note
+ for note in (await client.get("/api/monitor/notes")).json()["notes"]
+ }
+
+ assert notes["note-a"]["published_at"] == PUBLISHED_A
+ assert notes["note-b"]["published_at"] == PUBLISHED_B
+ # 和「首次发现」不是同一个值 —— 两者混了的话这个断言会抓到。
+ assert notes["note-a"]["first_seen_at"] != notes["note-a"]["published_at"]
+
@pytest.mark.asyncio
async def test_flat_shape_is_unchanged_without_the_flag(self, client):
body = (await client.get("/api/monitor/comments")).json()
diff --git a/webui/src/components/monitor/CommentsFeed.tsx b/webui/src/components/monitor/CommentsFeed.tsx
index 14ce38d..c870888 100644
--- a/webui/src/components/monitor/CommentsFeed.tsx
+++ b/webui/src/components/monitor/CommentsFeed.tsx
@@ -25,7 +25,7 @@ import {
useMonitorCommentsGrouped,
} from '@/hooks/useMonitor'
import { monitorApi } from '@/lib/api'
-import { formatRelative } from '@/lib/monitorFormat'
+import { formatDate, formatRelative } from '@/lib/monitorFormat'
import type { CommentBucket, MonitorComment } from '@/types/monitor'
import { NoteCover } from './NoteCover'
@@ -137,6 +137,15 @@ function CollapsibleGroup({
noteId={bucket.note_id}
/>
+ {/* 作品那一层光有标题不够 —— 同名的作品不少,发布日期能帮着认。 */}
+ {bucket.published_at && (
+
+ {formatDate(bucket.published_at)}
+
+ )}