diff --git a/api/monitor/ingest.py b/api/monitor/ingest.py index 3e1358d..93f64a2 100644 --- a/api/monitor/ingest.py +++ b/api/monitor/ingest.py @@ -40,7 +40,7 @@ from dataclasses import dataclass, field from pathlib import Path from typing import Any, Dict, List, Optional, Sequence -from sqlalchemy import func, select +from sqlalchemy import func, select, update from sqlalchemy.ext.asyncio import AsyncSession from tools.time_util import get_current_timestamp @@ -359,6 +359,10 @@ async def _ingest_notes( # 同一件作品在一轮里进来两次会让第二次插入直接撞键、整个 run 崩掉 —— 产物里重复并不 # 罕见(多个目标指向同一个人、或退化路径重复刷新)。 seen_in_run: set = set() + # 这一轮看到的 (博主哈希 -> 昵称)。**昵称是博主级的属性,而作品是逐条刷的** —— + # 一个博主只有落在采集窗口里的那几条会被重刷,掉出窗口的那些会一直留着旧名字。 + # 所以收完这一轮之后,再拿它把该博主在本任务下的作品统一改一遍(见循环之后)。 + fresh_names: Dict[str, str] = {} for record in records: note_id = adapter.note_field(record, "note_id") @@ -377,6 +381,9 @@ async def _ingest_notes( title = (adapter.note_field(record, "title") or "")[:500] cover = adapter.cover(record) + fresh_name = adapter.note_field(record, "creator_name") or "" + if fresh_name: + fresh_names[adapter.note_field(record, "creator_hash") or ""] = fresh_name if note is None: note = MonitorNote( @@ -430,6 +437,23 @@ async def _ingest_notes( await _snapshot_metrics(session, run, note_id, record, now, is_baseline) + # 把这一轮的昵称铺到该博主的**全部**作品上,不只这一轮采到的那几条。 + # 不做的话,博主改名之后库里会一半是新名字、一半是旧名字:界面按作品名分组时取的是 + # 其中任意一个(max),导出和 SQL 更是直接看到旧名字。带上 `!= name` 是为了让没变化 + # 的那些不产生写入。 + for creator_hash, name in fresh_names.items(): + if not creator_hash: + continue + await session.execute( + update(MonitorNote) + .where( + MonitorNote.task_id == run.task_id, + MonitorNote.creator_hash == creator_hash, + MonitorNote.creator_name != name, + ) + .values(creator_name=name) + ) + return new_count diff --git a/api/monitor/service.py b/api/monitor/service.py index 4905034..c395aa4 100644 --- a/api/monitor/service.py +++ b/api/monitor/service.py @@ -945,10 +945,13 @@ async def list_creators( { "task_id": row_task, "creator_hash": creator_hash, - # 昵称优先取作品的(那是界面上本来就在用的),快照的兜底 —— 没有作品 - # 的博主只剩快照这一个来源。 - "creator_name": (work_row or {}).get("creator_name") - or (stat.nickname if stat else ""), + # 昵称**优先取账号快照**里的。快照每次采集都从资料接口重写一遍,是这一行 + # 最新的一份;作品上的名字只会跟着「这一轮采到的那几条」更新,掉出采集 + # 窗口的老作品可能还留着旧名字。博主改名之后取作品的会显示旧名。 + # 没有快照的(小红书那条路不产生快照)才退回作品上的名字。 + "creator_name": (stat.nickname if stat else "") + or (work_row or {}).get("creator_name") + or "", "creator_alias": aliases.get( (task_platform.get(row_task, ""), creator_hash), "" ), diff --git a/docs/queries/douyin-ads-last-7-days.sql b/docs/queries/douyin-ads-last-7-days.sql index 11370c1..1b9b66c 100644 --- a/docs/queries/douyin-ads-last-7-days.sql +++ b/docs/queries/douyin-ads-last-7-days.sql @@ -20,7 +20,7 @@ SELECT acct.creator_hash AS 博主哈希, ca.alias AS 博主备注, - COALESCE(n.creator_name, st.nickname, '') AS 博主昵称, + COALESCE(st.nickname, n.creator_name, '') AS 博主昵称, st.fans AS 粉丝数, st.total_favorited AS 总获赞, st.works_count AS 主页作品数, diff --git a/docs/queries/douyin-latest-video.sql b/docs/queries/douyin-latest-video.sql index a37f441..865120c 100644 --- a/docs/queries/douyin-latest-video.sql +++ b/docs/queries/douyin-latest-video.sql @@ -23,7 +23,7 @@ SELECT acct.creator_hash AS 博主哈希, ca.alias AS 博主备注, - COALESCE(n.creator_name, st.nickname, '') AS 博主昵称, + COALESCE(st.nickname, n.creator_name, '') AS 博主昵称, st.fans AS 粉丝数, st.total_favorited AS 总获赞, st.works_count AS 主页作品数, diff --git a/tests/test_monitor_creators.py b/tests/test_monitor_creators.py index 0592059..3ab7bac 100644 --- a/tests/test_monitor_creators.py +++ b/tests/test_monitor_creators.py @@ -140,6 +140,7 @@ async def _add_stat( creator_hash: str = CREATOR_HASH, captured_at: int = 1, creator_id: str = "", + nickname: str = NICKNAME, ) -> None: async with monitor_db.get_session() as session: session.add( @@ -147,7 +148,7 @@ async def _add_stat( task_id=task_id, run_id=run_id, creator_hash=creator_hash, - nickname=NICKNAME, + nickname=nickname, creator_id=creator_id, fans=fans, total_favorited=total_favorited, @@ -367,3 +368,26 @@ class TestCreatorProfileLink: assert creators[0]["note_count"] == 0 assert creators[0]["creator_url"].endswith("/user/MS4wLjABAAAAquiet") + + +class TestNicknameSource: + """博主改名之后,组头显示哪个名字。 + + **账号快照里的昵称优先**:那一行每次采集都从资料接口重写一遍,是这一行最新的一份。 + 作品上的名字只会跟着「这一轮采到的那几条」更新 —— 掉出采集窗口的老作品可能还留着 + 旧名字,而作品栏取的是该博主名下作品的 max(name),恰好可能取到旧的。 + """ + + @pytest.mark.asyncio + async def test_the_snapshot_name_wins_over_a_stale_work_name(self, client): + task_id = await _seed(platform="dy") # 作品上的名字还是 NICKNAME + await _add_stat(task_id, run_id=1, fans=1, nickname="改过的名字") + + assert (await _creators(client, "dy"))[0]["creator_name"] == "改过的名字" + + @pytest.mark.asyncio + async def test_without_a_snapshot_it_falls_back_to_the_work_name(self, client): + """小红书那条路不产生账号快照 —— 只能靠作品上的名字。""" + await _seed(platform="xhs") + + assert (await _creators(client, "xhs"))[0]["creator_name"] == NICKNAME diff --git a/tests/test_monitor_ingest.py b/tests/test_monitor_ingest.py index 7492ebc..413eff0 100644 --- a/tests/test_monitor_ingest.py +++ b/tests/test_monitor_ingest.py @@ -1010,3 +1010,82 @@ class TestCreatorIdIsStored: await ingest_run(db, run, task, tmp_path) assert (await db.scalar(select(MonitorCreatorStat))).creator_id == "" + + +class TestNicknameRefresh: + """博主改名之后,库里不能一半是新名字、一半是旧名字。 + + 昵称是**博主级**的属性,而作品是逐条刷的 —— 一个博主只有落在采集窗口里的那几条会被 + 重刷,掉出窗口的老作品会一直留着旧名字。界面按作品名分组时取的是其中任意一个, + 导出和 SQL 更是直接看到旧名字。 + """ + + @pytest.mark.asyncio + async def test_a_rename_reaches_the_works_outside_this_runs_window(self, db, tmp_path): + task = await _make_task(db, platform="dy") + + # 第一轮:两条作品,都叫「旧名字」。 + first = await _make_run(db, task, started_at=1) + _write_run_dir( + tmp_path, + [_dy_note("111", nickname="旧名字"), _dy_note("222", nickname="旧名字")], + comments=[], + subdir="douyin", + ) + await ingest_run(db, first, task, tmp_path) + + # 第二轮:只采到 111(222 掉出了采集窗口),而博主已经改名。 + second = await _make_run(db, task, started_at=2000) + _write_run_dir( + tmp_path, + [_dy_note("111", nickname="新名字")], + comments=[], + subdir="douyin", + ) + await ingest_run(db, second, task, tmp_path) + + names = { + row.note_id: row.creator_name + for row in (await db.scalars(select(MonitorNote))).all() + } + assert names == {"111": "新名字", "222": "新名字"} + + @pytest.mark.asyncio + async def test_the_rename_does_not_leak_into_another_task(self, db, tmp_path): + """博主级不等于全局 —— 另一个任务里的同一个人是另一条跟踪线,别顺手改它。""" + mine = await _make_task(db, name="我的", platform="dy") + other = await _make_task(db, name="别人的", platform="dy") + + run = await _make_run(db, other, started_at=1) + _write_run_dir(tmp_path, [_dy_note("999", nickname="别人的名字")], comments=[], subdir="douyin") + await ingest_run(db, run, other, tmp_path) + + run = await _make_run(db, mine, started_at=1) + _write_run_dir(tmp_path, [_dy_note("111", nickname="旧名字")], comments=[], subdir="douyin") + await ingest_run(db, run, mine, tmp_path) + + run = await _make_run(db, mine, started_at=2000) + _write_run_dir(tmp_path, [_dy_note("111", nickname="新名字")], comments=[], subdir="douyin") + await ingest_run(db, run, mine, tmp_path) + + by_task = { + row.note_id: row.creator_name + for row in (await db.scalars(select(MonitorNote))).all() + } + assert by_task["999"] == "别人的名字" + assert by_task["111"] == "新名字" + + @pytest.mark.asyncio + async def test_an_empty_name_does_not_wipe_the_stored_one(self, db, tmp_path): + """平台这一轮没给昵称时,不要把已有的抹掉。""" + task = await _make_task(db, platform="dy") + + run = await _make_run(db, task, started_at=1) + _write_run_dir(tmp_path, [_dy_note("111", nickname="有名字")], comments=[], subdir="douyin") + await ingest_run(db, run, task, tmp_path) + + run = await _make_run(db, task, started_at=2000) + _write_run_dir(tmp_path, [_dy_note("111", nickname="")], comments=[], subdir="douyin") + await ingest_run(db, run, task, tmp_path) + + assert (await db.scalar(select(MonitorNote))).creator_name == "有名字"