From a42f8e8b2f8a1201ebdd838aafaf2e9465a373cb Mon Sep 17 00:00:00 2001 From: butubb <1422726308@qq.com> Date: Sun, 11 Oct 2026 07:54:26 +0800 Subject: [PATCH] =?UTF-8?q?feat(monitor):=20=E4=BD=9C=E5=93=81=E5=AF=BC?= =?UTF-8?q?=E5=87=BA=E9=87=8C=EF=BC=8C=E6=B2=A1=E6=9C=89=E4=BD=9C=E5=93=81?= =?UTF-8?q?=E7=9A=84=E5=8D=9A=E4=B8=BB=E4=B9=9F=E5=8D=A0=E4=B8=80=E8=A1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 导出原先是从作品摊出来的,于是「这个号这段时间一条作品都没发」直接从表里消失 —— 而在表里,**消失和「一切正常」长得一模一样**。这类博主恰恰是名单上最该被看见的: 还在涨粉、只是没动静,或者号出了问题。 来源换成 博主 ∪ 作品(和作品栏同一份 list_creators),没有作品的那一行只填博主, 作品那几列留空 —— **空,不是 0**。 顺带把账号级指标加进作品导出(粉丝数 / 总获赞 / 主页作品数):没有作品的博主那一行 就只剩它们,不加的话那一行等于空行。「主页作品数」是平台上的总数,和下面几列(我们 监控到的作品)不是一回事,所以名字分得开。 排序改成按「这是谁」,同一个博主的行挨在一起 —— 表格是拿去筛和做透视的。 --- api/routers/monitor.py | 62 +++++++++++++++++++++++++- tests/test_monitor_comments.py | 79 ++++++++++++++++++++++++++++++++++ 2 files changed, 140 insertions(+), 1 deletion(-) diff --git a/api/routers/monitor.py b/api/routers/monitor.py index 7d87558..f935712 100644 --- a/api/routers/monitor.py +++ b/api/routers/monitor.py @@ -439,7 +439,10 @@ async def export_data( if kind == "notes": single_task = scoped[0] if scoped and len(scoped) == 1 else None - rows = await service.list_notes(session, single_task, False, 5000, platform) + rows = _notes_rows( + await service.list_creators(session, single_task, platform), + await service.list_notes(session, single_task, False, 5000, platform), + ) elif kind == "comments": single_task = scoped[0] if scoped and len(scoped) == 1 else None rows = await service.list_comments(session, single_task, note_id, 5000, platform) @@ -482,6 +485,57 @@ async def export_data( ) +def _notes_rows( + creators: List[Dict[str, Any]], notes: List[Dict[str, Any]] +) -> List[Dict[str, Any]]: + """博主 ∪ 作品,摊成一行行 —— **没有作品的博主也要占一行**,作品那几列留空。 + + 导出是拿去比对名单的,而「这个号这段时间一条作品都没发」恰恰是名单上最该被看见的 + 一行:还在涨粉、只是没动静,或者号出了问题。按作品导的话他直接消失 —— 而在表里, + **消失和「一切正常」长得一模一样**。 + + 和作品栏用的是同一个来源(`list_creators` 也是 账号快照 ∪ 作品),所以导出来的 + 名单和屏幕上看到的博主是同一批。 + """ + works_by_creator: Dict[str, List[Dict[str, Any]]] = {} + for note in notes: + works_by_creator.setdefault(note["creator_hash"], []).append(note) + + rows: List[Dict[str, Any]] = [] + listed: set = set() + for creator in creators: + creator_hash = creator["creator_hash"] + listed.add(creator_hash) + works = works_by_creator.get(creator_hash, []) + if works: + # 作品行自己就带着博主那几列(list_notes 里已经拼好了),直接用。 + rows.extend(works) + continue + # 一行只填博主:作品列全是空串(`_cell` 把缺失的键变成空)。 + rows.append( + { + "creator_alias": creator["creator_alias"], + "creator_name": creator["creator_name"], + "creator_fans": creator["creator_fans"], + "creator_total_favorited": creator["creator_total_favorited"], + "creator_works": creator["creator_works"], + } + ) + + # 兜底:作品里的博主没出现在博主名单里就不该丢。正常情况下不会发生(名单是 + # 账号快照 ∪ 作品),但「悄悄少几行」是这份导出最不该犯的错。 + rows.extend(note for note in notes if note["creator_hash"] not in listed) + + # 同一个博主的行挨在一起,按「这是谁」排 —— 表格是拿去筛和做透视的。 + rows.sort( + key=lambda row: ( + row.get("creator_alias") or row.get("creator_name") or "", + -(row.get("published_at") or 0), + ) + ) + return rows + + def _export_columns(kind: str) -> List[tuple[str, str]]: """(key, header) pairs per export kind.""" if kind == "notes": @@ -490,6 +544,12 @@ def _export_columns(kind: str) -> List[tuple[str, str]]: # 备注优先 —— 昵称常常认不出是谁(见 notes 表那一层的说明)。 ("creator_alias", "博主备注"), ("creator_name", "博主昵称"), + # 账号级指标 —— 没有作品的博主那一行**只剩它们**,没有的话那一行等于空行。 + # 「主页作品数」是他在平台上的总数,和下面那几列(我们监控到的作品)不是 + # 一回事,所以名字要分得开。 + ("creator_fans", "粉丝数"), + ("creator_total_favorited", "总获赞"), + ("creator_works", "主页作品数"), ("note_alias", "作品备注"), ("title", "标题"), ("note_id", "作品ID"), diff --git a/tests/test_monitor_comments.py b/tests/test_monitor_comments.py index 9a689e8..1f27898 100644 --- a/tests/test_monitor_comments.py +++ b/tests/test_monitor_comments.py @@ -370,3 +370,82 @@ class TestNotesExportColumns: assert re.fullmatch(r"\d{4}-\d{2}-\d{2} \d{2}:\d{2}", by_id["note-a"]["首次发现"]) # 而且不能是原始毫秒。 assert by_id["note-a"]["发布时间"] != str(PUBLISHED_A) + + +class TestNotesExportWithoutWorks: + """**一条作品都没有的博主,也要在导出里占一行**,作品那几列留空。 + + 导出是拿去比对名单的,「这个号这段时间一条作品都没发」恰恰是名单上最该被看见的 + 一行 —— 还在涨粉、只是没动静,或者号出了问题。按作品导的话他直接消失,而在表里, + **消失和「一切正常」长得一模一样**。 + """ + + @staticmethod + async def _add_creator_without_works(**overrides) -> None: + from sqlalchemy import select + + from api.monitor.models import MonitorCreatorStat + + fields = dict( + run_id=1, creator_hash="hash-c", nickname="博主丙", fans=8800, + total_favorited=4321, works_count=7, following=2, + captured_at=1_700_000_000_000, + ) + fields.update(overrides) + + async with monitor_db.get_session() as session: + task_id = await session.scalar(select(MonitorTask.id)) + session.add(MonitorCreatorStat(task_id=task_id, **fields)) + + @staticmethod + async def _rows(client): + response = await client.get( + "/api/monitor/export", params={"kind": "notes", "format": "csv"} + ) + assert response.status_code == 200 + return list(csv.DictReader(io.StringIO(response.content.decode("utf-8-sig")))) + + @pytest.mark.asyncio + async def test_a_creator_without_works_gets_a_row(self, client): + await self._add_creator_without_works() + + rows = await self._rows(client) + only = [row for row in rows if row["博主昵称"] == "博主丙"] + + assert len(only) == 1, "没有作品的博主从导出里消失了" + + @pytest.mark.asyncio + async def test_that_row_keeps_the_creator_info_and_leaves_works_empty(self, client): + await self._add_creator_without_works() + + row = next(r for r in await self._rows(client) if r["博主昵称"] == "博主丙") + + # 博主那几列要有 —— 否则这一行就是个空行。 + assert row["粉丝数"] == "8800" + assert row["总获赞"] == "4321" + assert row["主页作品数"] == "7" + # 作品那几列留空。**空,不是 0** —— 0 会读成「这条作品零互动」。 + for column in ("作品ID", "标题", "链接", "发布时间", "点赞", "评论", "收藏", "分享"): + assert row[column] == "", f"{column} 应该是空的" + + @pytest.mark.asyncio + async def test_the_works_are_still_all_there(self, client): + """加了这一类行之后,原来那些作品一行都不能少。""" + await self._add_creator_without_works() + + rows = await self._rows(client) + + assert {row["作品ID"] for row in rows if row["作品ID"]} == {"note-a", "note-b"} + + @pytest.mark.asyncio + async def test_a_creator_whose_metrics_are_unknown_leaves_them_empty(self, client): + """采不到账号指标时留空,不写 0 —— 和界面一个口径。""" + await self._add_creator_without_works( + fans=None, total_favorited=None, works_count=None + ) + + row = next(r for r in await self._rows(client) if r["博主昵称"] == "博主丙") + + assert row["粉丝数"] == "" + assert row["总获赞"] == "" + assert row["主页作品数"] == ""