diff --git a/api/monitor/service.py b/api/monitor/service.py index 1ef65bd..8b5fb7b 100644 --- a/api/monitor/service.py +++ b/api/monitor/service.py @@ -431,30 +431,31 @@ async def set_note_alias( async def _latest_creator_stats( session: AsyncSession, - task_ids: Sequence[int], - creator_hashes: Sequence[str], + task_ids: Optional[Sequence[int]] = None, + creator_hashes: Optional[Sequence[str]] = None, ) -> Dict[tuple, "MonitorCreatorStat"]: """``(task_id, creator_hash) -> 最近一条``账号级快照。 + 两个参数都是**可选过滤**:传 None 就是不限。``list_notes`` 两个都给(只要手里 + 这批作品涉及的博主),``list_creators`` 只给任务(要的是全部博主,包括一条作品 + 都没有的那些)。 + 按 run_id 而不是 captured_at 取「最近」:和作品指标用的是同一个口径,两者放一起 看才不会出现「作品数据来自第 8 轮、粉丝数来自第 9 轮」这种对不上的情况。 """ - if not task_ids or not creator_hashes: + if task_ids is not None and not task_ids: + return {} + if creator_hashes is not None and not creator_hashes: return {} - rows = ( - await session.scalars( - select(MonitorCreatorStat) - .where( - MonitorCreatorStat.task_id.in_(list(task_ids)), - MonitorCreatorStat.creator_hash.in_(list(creator_hashes)), - ) - .order_by(MonitorCreatorStat.run_id.desc()) - ) - ).all() + query = select(MonitorCreatorStat).order_by(MonitorCreatorStat.run_id.desc()) + if task_ids is not None: + query = query.where(MonitorCreatorStat.task_id.in_(list(task_ids))) + if creator_hashes is not None: + query = query.where(MonitorCreatorStat.creator_hash.in_(list(creator_hashes))) latest: Dict[tuple, MonitorCreatorStat] = {} - for row in rows: + for row in (await session.scalars(query)).all(): latest.setdefault((row.task_id, row.creator_hash), row) # 已按 run_id 倒序 return latest @@ -604,6 +605,107 @@ async def list_notes( return result +async def list_creators( + session: AsyncSession, + task_id: Optional[int] = None, + platform: Optional[str] = None, +) -> List[Dict[str, Any]]: + """作品栏里要显示的**博主** —— **包括一条作品都没有的**。 + + 分组原先是从作品推出来的(按作品的 creator_hash 归组),于是没有作品的博主根本 + 不会出现在列表里:目标加了、资料也采到了、粉丝数就躺在库里,界面上什么都看不见。 + 而「这个号在涨粉、只是最近没发作品」恰恰是最该看见的一种情况 —— 藏起来正好藏反了。 + + 所以来源换成 **账号快照 ∪ 作品**: + + * 有快照没作品 → 一个 0 篇的组,粉丝数照常显示; + * 有作品没快照 → 一个没有账号指标的组(小红书那条路不产生快照,就是这种情况)。 + + ``creator_alias`` 从作品备注那张表来;``note_count`` / ``last_activity_at`` 用来 + 排序,让最近还在动的博主排在前面。 + """ + scope: Optional[List[int]] = None + if task_id is not None: + scope = [task_id] + elif platform is not None: + scope = await platform_task_ids(session, platform) + if not scope: + return [] + + # 作品一侧:谁有作品、有几篇、最后一次是什么时候。 + work_query = ( + select( + MonitorNote.task_id, + MonitorNote.creator_hash, + func.count().label("note_count"), + func.max(MonitorNote.last_seen_at).label("last_seen_at"), + func.max(MonitorNote.creator_name).label("creator_name"), + ) + .where(MonitorNote.creator_hash != "") + .group_by(MonitorNote.task_id, MonitorNote.creator_hash) + ) + if scope is not None: + work_query = work_query.where(MonitorNote.task_id.in_(scope)) + + work: Dict[tuple, Dict[str, Any]] = {} + for row in (await session.execute(work_query)).all(): + work[(row.task_id, row.creator_hash)] = { + "note_count": row.note_count, + "last_seen_at": row.last_seen_at, + "creator_name": row.creator_name or "", + } + + stats = await _latest_creator_stats(session, task_ids=scope) + if not work and not stats: + return [] + + # 备注是按 (platform, creator_hash) 存的,所以要知道每个博主属于哪个平台。 + involved = {key[0] for key in set(work) | set(stats)} + task_platform = { + row.id: row.platform + for row in ( + await session.execute( + select(MonitorTask.id, MonitorTask.platform).where( + MonitorTask.id.in_(list(involved)) + ) + ) + ).all() + } + aliases = await _creator_alias_map(session) + + result: List[Dict[str, Any]] = [] + for key in set(work) | set(stats): + row_task, creator_hash = key + work_row = work.get(key) + stat = stats.get(key) + result.append( + { + "task_id": row_task, + "creator_hash": creator_hash, + # 昵称优先取作品的(那是界面上本来就在用的),快照的兜底 —— 没有作品 + # 的博主只剩快照这一个来源。 + "creator_name": (work_row or {}).get("creator_name") + or (stat.nickname if stat else ""), + "creator_alias": aliases.get( + (task_platform.get(row_task, ""), creator_hash), "" + ), + "note_count": (work_row or {}).get("note_count", 0), + "creator_fans": stat.fans if stat else None, + "creator_total_favorited": stat.total_favorited if stat else None, + "creator_works": stat.works_count if stat else None, + "creator_stats_at": stat.captured_at if stat else None, + # 排序用:作品最近出现的时间,或者账号指标的采集时间,取晚的那个。 + "last_activity_at": max( + (work_row or {}).get("last_seen_at") or 0, + stat.captured_at if stat else 0, + ), + } + ) + + result.sort(key=lambda row: row["last_activity_at"], reverse=True) + return result + + async def note_series(session: AsyncSession, note_id: str, task_id: Optional[int] = None) -> List[Dict[str, Any]]: """Metric time series for one note.""" query = ( diff --git a/api/routers/monitor.py b/api/routers/monitor.py index 7ccafb0..574e983 100644 --- a/api/routers/monitor.py +++ b/api/routers/monitor.py @@ -133,7 +133,10 @@ async def list_notes( ): async with get_session() as session: return { - "notes": await service.list_notes(session, task_id, only_new, limit, platform) + "notes": await service.list_notes(session, task_id, only_new, limit, platform), + # 博主**单独给一份**,而不是让前端从作品里推。作品推不出「一条作品都没有的 + # 博主」—— 那正是最该显示的一类(还在涨粉,只是最近没发)。 + "creators": await service.list_creators(session, task_id, platform), } diff --git a/tests/test_monitor_creators.py b/tests/test_monitor_creators.py index 05bbd9a..411b37d 100644 --- a/tests/test_monitor_creators.py +++ b/tests/test_monitor_creators.py @@ -218,3 +218,105 @@ class TestCreatorStats: assert note["creator_works"] is None # 快照本身是有的(有采集时间),只是值不知道 —— 前端要能分开这两件事。 assert note["creator_stats_at"] is not None + + +async def _seed_task(platform: str = "xhs", task_name: str = "空任务") -> int: + """只有任务,**一条作品都没有**。""" + async with monitor_db.get_session() as session: + task = MonitorTask( + name=task_name, platform=platform, mode=MODE_CREATOR, enabled=True, + interval_minutes=60, max_notes_count=20, enable_comments=False, + max_comments_count=50, run_timeout_seconds=3600, + notify_enabled=False, created_at=0, updated_at=0, + ) + session.add(task) + await session.flush() + return task.id + + +async def _creators(client, platform: str = "xhs"): + return (await client.get("/api/monitor/notes", params={"platform": platform})).json()["creators"] + + +class TestCreatorList: + """作品栏要显示谁 —— **包括一条作品都没有的博主**。 + + 组原先是从作品推出来的,于是「目标加了、资料采到了、粉丝数在库里,界面上什么都 + 没有」。而这类博主恰恰最该看见:还在涨粉,只是最近没发东西。 + """ + + @pytest.mark.asyncio + async def test_a_creator_with_no_works_still_shows_up(self, client): + """**这条就是这个改动的全部理由。**""" + task_id = await _seed_task() + await _add_stat(task_id, run_id=1, fans=12000) + + creators = await _creators(client) + + assert len(creators) == 1 + assert creators[0]["creator_hash"] == CREATOR_HASH + assert creators[0]["creator_name"] == NICKNAME # 只剩快照这一个来源 + assert creators[0]["note_count"] == 0 + assert creators[0]["creator_fans"] == 12000 + + @pytest.mark.asyncio + async def test_a_creator_with_works_carries_their_count(self, client): + task_id = await _seed() + await _add_stat(task_id, run_id=1, fans=12000) + + creators = await _creators(client) + + assert len(creators) == 1 + assert creators[0]["note_count"] == 1 + assert creators[0]["creator_name"] == NICKNAME + assert creators[0]["creator_fans"] == 12000 + + @pytest.mark.asyncio + async def test_a_creator_with_neither_works_nor_a_snapshot_is_absent(self, client): + """两个来源都没有 = 我们对他一无所知,不该凭空造一个组出来。""" + await _seed_task() + + assert await _creators(client) == [] + + @pytest.mark.asyncio + async def test_a_work_without_a_snapshot_still_lists_its_creator(self, client): + """小红书那条路不产生账号快照 —— 那边只能靠作品认出人来。""" + await _seed() + + creators = await _creators(client) + + assert len(creators) == 1 + assert creators[0]["note_count"] == 1 + assert creators[0]["creator_fans"] is None + + @pytest.mark.asyncio + async def test_the_creator_remark_comes_along(self, client): + """没有作品的博主也要能起备注 —— 否则「这是谁」在最需要的时候认不出来。""" + task_id = await _seed_task() + await _add_stat(task_id, run_id=1, fans=12000) + + await _set_alias(client, "竞品A") + + assert (await _creators(client))[0]["creator_alias"] == "竞品A" + + @pytest.mark.asyncio + async def test_platforms_stay_apart(self, client): + dy_task = await _seed_task(platform="dy") + await _add_stat(dy_task, run_id=1, fans=999) + + assert await _creators(client, "xhs") == [] + assert (await _creators(client, "dy"))[0]["creator_fans"] == 999 + + @pytest.mark.asyncio + async def test_the_same_creator_under_two_tasks_is_two_rows_the_ui_merges(self, client): + """服务端按 任务×博主 给(快照就是那么存的),合并交给界面 —— 因为备注跨任务 + 是同一条,合并之后的组才是人眼里的「一个博主」。""" + first = await _seed(task_name="任务甲") + second = await _seed(task_name="任务乙") + await _add_stat(first, run_id=1, fans=12000) + + creators = await _creators(client) + + assert len(creators) == 2 + assert {row["creator_hash"] for row in creators} == {CREATOR_HASH} + assert sum(row["note_count"] for row in creators) == 2 diff --git a/webui/src/components/monitor/NotesTable.tsx b/webui/src/components/monitor/NotesTable.tsx index 8f54892..7b8de09 100644 --- a/webui/src/components/monitor/NotesTable.tsx +++ b/webui/src/components/monitor/NotesTable.tsx @@ -10,7 +10,7 @@ import { formatDelta, formatRelative, } from '@/lib/monitorFormat' -import type { MonitorNote, NoteMetrics } from '@/types/monitor' +import type { MonitorCreator, MonitorNote, NoteMetrics } from '@/types/monitor' import { NoteCover } from './NoteCover' import { NoteTrendChart } from './NoteTrendChart' @@ -83,6 +83,17 @@ function sumMetrics(notes: MonitorNote[]) { // 多出来的那几列:展开箭头、作品、发布日期、首次发现、跳转链接;分组表头会跨掉整行。 const COLUMN_COUNT = METRIC_COLUMNS.length + 5 +/** + * 账号级指标那几个字段。 + * + * 作品的 payload 和博主的 payload 都带这一组,值也一样(同一次采集写的同一行), + * 所以组件只认字段、不认来源。 + */ +type AccountStats = Pick< + MonitorCreator, + 'creator_fans' | 'creator_total_favorited' | 'creator_works' | 'creator_stats_at' +> + /** * 博主的**账号级**指标:粉丝 / 总获赞 / 作品数。 * @@ -90,11 +101,11 @@ const COLUMN_COUNT = METRIC_COLUMNS.length + 5 * 账号在涨还是在掉」。**一个都没采到时整块不画**:画成「粉丝 0」比不画糟得多,那是 * 一句假话。 */ -function CreatorStats({ note }: { note: MonitorNote }) { +function CreatorStats({ stats }: { stats: AccountStats }) { const parts = [ - note.creator_fans !== null && `粉丝 ${formatCount(note.creator_fans)}`, - note.creator_total_favorited !== null && `获赞 ${formatCount(note.creator_total_favorited)}`, - note.creator_works !== null && `${formatCount(note.creator_works)} 作品`, + stats.creator_fans !== null && `粉丝 ${formatCount(stats.creator_fans)}`, + stats.creator_total_favorited !== null && `获赞 ${formatCount(stats.creator_total_favorited)}`, + stats.creator_works !== null && `${formatCount(stats.creator_works)} 作品`, ].filter(Boolean) as string[] if (parts.length === 0) return null @@ -102,7 +113,7 @@ function CreatorStats({ note }: { note: MonitorNote }) { return ( {parts.join(' · ')} @@ -117,7 +128,9 @@ function CreatorStats({ note }: { note: MonitorNote }) { * 显示名是已脱敏的昵称(张***三)。两者都认不出来时才退回"未知博主"。 */ export function NotesTable({ taskId, onlyNew }: NotesTableProps) { - const { data: notes, isLoading } = useMonitorNotes(taskId, onlyNew) + const { data, isLoading } = useMonitorNotes(taskId, onlyNew) + const notes = data?.notes + const creators = data?.creators const [expandedNote, setExpandedNote] = useState(null) // 折叠状态按博主记。默认全展开 —— 藏起来的数据比多滚两屏更糟。 const [collapsed, setCollapsed] = useState>(new Set()) @@ -155,32 +168,74 @@ export function NotesTable({ taskId, onlyNew }: NotesTableProps) { } const groups = useMemo(() => { - const byCreator = new Map< - string, - { key: string; name: string; alias: string; notes: MonitorNote[] } - >() + type Group = { + key: string + name: string + alias: string + /** 服务端说的这条博主名下有多少作品 —— 列表被 `onlyNew` 滤过时和 `notes.length` 不等。 */ + totalNotes: number + stats: MonitorCreator | null + notes: MonitorNote[] + } + const byCreator = new Map() + + // **先放博主,不是先放作品。** 「一条作品都没有的博主」在作品里根本推不出来 —— + // 目标加了、资料也采到了、粉丝数就躺在库里,可界面上什么都看不见。而他恰恰是最该 + // 看见的一个:还在涨粉,只是最近没发东西。 + for (const creator of creators ?? []) { + const key = creator.creator_hash || '__unknown__' + const existing = byCreator.get(key) + if (existing) { + // 同一个博主可能挂在多个任务下(服务端按 任务×博主 给),合并成一组。 + existing.totalNotes += creator.note_count + if (!existing.stats?.creator_stats_at && creator.creator_stats_at) { + existing.stats = creator + } + existing.name ||= creator.creator_name + // 备注按 (platform, creator_hash) 存,所以跨任务就是同一条,取到即可。 + existing.alias ||= creator.creator_alias + continue + } + byCreator.set(key, { + key, + name: creator.creator_name || '', + alias: creator.creator_alias || '', + totalNotes: creator.note_count, + stats: creator, + notes: [], + }) + } + for (const note of notes ?? []) { // 作品模式下每条作品都会带 creator_hash;真丢了也要有个兜底分组, // 否则那些作品会凭空消失。 const key = note.creator_hash || '__unknown__' - if (!byCreator.has(key)) { - byCreator.set(key, { + let group = byCreator.get(key) + if (!group) { + group = { key, name: note.creator_name || '', alias: note.creator_alias || '', + totalNotes: 0, + stats: null, notes: [], - }) + } + byCreator.set(key, group) } - byCreator.get(key)!.notes.push(note) + group.notes.push(note) + group.name ||= note.creator_name || '' + group.alias ||= note.creator_alias || '' } + return [...byCreator.values()] - }, [notes]) + }, [notes, creators]) if (isLoading) { return

加载中…

} - if (!notes || notes.length === 0) { + // 博主有、作品没有也是**要画**的:那正是「这个号还没被删,只是没发东西」。 + if (groups.length === 0) { return (

{taskId === null @@ -266,17 +321,18 @@ export function NotesTable({ taskId, onlyNew }: NotesTableProps) { {group.name} )} - {group.notes.length} 篇 + {/* 被 onlyNew 滤过时,把「显示了几篇 / 一共几篇」都说出来, + 否则「1 篇」会让人以为这个号只发过一条。 */} + {group.notes.length} + {group.totalNotes > group.notes.length && `/${group.totalNotes}`} 篇 - {/* 账号级指标是按 (任务, 博主) 存的,而分组只按博主 —— 同一个博主 - 被两个任务监控时,组里可能只有一部分作品带指标。优先挑带指标的那条, - 否则「第一个任务还没采过」会让整组看起来没有指标。 */} - note.creator_stats_at !== null) ?? - group.notes[0] - } - /> + {group.stats && } + {/* 博主在、作品一条都没有 —— 说清楚,别让人以为列表挂了。 */} + {group.notes.length === 0 && ( + + 暂无作品 + + )}