Files
MediaCrawler/tests/test_monitor_creators.py
T
butubb 44277600e4
Deploy VitePress site to Pages / build (push) Canceled after 0s
Deploy VitePress site to Pages / Deploy (push) Canceled after 0s
feat(monitor): 组头上加一个跳到博主主页的按钮
`creator_hash` 是单向的,拼不出主页地址 —— 所以这个 id 必须在采集时单独存下来。
抖音的资料接口本来就返回 sec_uid,只是之前没落库。

* `monitor_creator_stat` 加 `creator_id` 列(老数据默认空,下一次采集补上);
* 链接在 `adapters.creator_url()` 里拼,**空串是有意义的返回值**:平台不认识、
  或者没拿到 id,就返回空,界面据此不画按钮 —— 画一个点开 404 的按钮比不画糟;
* 没有作品的博主照样有链接:他恰恰是你想点进去看的那个人。

已知缺口:小红书那条路(爬虫子进程)根本不落创作者资料,所以那边没有链接。
2026-10-11 08:52:51 +08:00

370 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""作品栏里的**博主**:备注(他到底是谁)与账号级指标(他现在多大)。
按 creator_hash 分组、显示 creator_name,两样都认不出人:一个是哈希,一个是平台昵称。
备注是人自己起的名字。账号级指标则是作品列表给不了的东西 —— 作品说的是"这条涨了多少赞",
粉丝数说的是"这个人整个账号在涨还是在掉"。
"""
import httpx
import pytest
import pytest_asyncio
from api.main import app
from api.monitor import db as monitor_db
from api.monitor.models import (
MODE_CREATOR,
MonitorCreatorStat,
MonitorNote,
MonitorTask,
)
CREATOR_HASH = "hash-a"
NICKNAME = "张三"
@pytest_asyncio.fixture
async def client(tmp_path):
monitor_db.set_sqlite_path(tmp_path / "monitor.db")
await monitor_db.init_db()
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as http_client:
yield http_client
await monitor_db.dispose_engine()
async def _seed(platform: str = "xhs", task_name: str = "任务") -> int:
"""一个任务 + 一条作品,博主固定用 CREATOR_HASH / NICKNAME。"""
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()
session.add(
MonitorNote(
task_id=task.id, note_id=f"{platform}-n1", title="作品",
note_url="", cover="", creator_hash=CREATOR_HASH,
creator_name=NICKNAME, source_kind="", published_at=None,
first_seen_run_id=1, first_seen_at=0,
last_seen_run_id=1, last_seen_at=0,
)
)
return task.id
async def _notes(client, platform: str = "xhs"):
return (await client.get("/api/monitor/notes", params={"platform": platform})).json()["notes"]
async def _set_alias(client, alias: str, platform: str = "xhs", creator_hash=CREATOR_HASH):
return await client.put(
f"/api/monitor/creators/{creator_hash}",
params={"platform": platform},
json={"alias": alias},
)
class TestCreatorAlias:
@pytest.mark.asyncio
async def test_notes_start_without_an_alias(self, client):
await _seed()
assert (await _notes(client))[0]["creator_alias"] == ""
@pytest.mark.asyncio
async def test_an_alias_comes_back_with_the_notes(self, client):
await _seed()
response = await _set_alias(client, "竞品A")
assert response.status_code == 200
note = (await _notes(client))[0]
assert note["creator_alias"] == "竞品A"
# 备注是**叠加**在昵称之上的,不是替换 —— 昵称仍然是有用的对照。
assert note["creator_name"] == NICKNAME
@pytest.mark.asyncio
async def test_an_alias_is_shared_across_tasks(self, client):
"""同一个博主出现在两个任务里,备注只该填一次。
creator_hash 对同一个 uid 是稳定的,所以键取 (platform, creator_hash) 而不是
按任务存 —— 否则每加一个任务都要重新认一遍人。
"""
await _seed(task_name="任务甲")
await _seed(task_name="任务乙")
await _set_alias(client, "竞品A")
for note in await _notes(client):
assert note["creator_alias"] == "竞品A"
@pytest.mark.asyncio
async def test_the_alias_does_not_leak_to_another_platform(self, client):
"""同一个哈希在另一个平台上是另一个(或同一个)人 —— 别串味。"""
await _seed(platform="xhs")
await _seed(platform="dy")
await _set_alias(client, "小红书那边的", platform="xhs")
assert (await _notes(client, "xhs"))[0]["creator_alias"] == "小红书那边的"
assert (await _notes(client, "dy"))[0]["creator_alias"] == ""
@pytest.mark.asyncio
async def test_an_empty_alias_clears_it(self, client):
await _seed()
await _set_alias(client, "竞品A")
await _set_alias(client, "")
assert (await _notes(client))[0]["creator_alias"] == ""
@pytest.mark.asyncio
async def test_an_alias_is_trimmed(self, client):
await _seed()
await _set_alias(client, " 竞品A ")
assert (await _notes(client))[0]["creator_alias"] == "竞品A"
async def _add_stat(
task_id: int,
run_id: int,
fans: int | None,
*,
total_favorited: int | None = 83000,
works: int | None = 42,
creator_hash: str = CREATOR_HASH,
captured_at: int = 1,
creator_id: str = "",
) -> None:
async with monitor_db.get_session() as session:
session.add(
MonitorCreatorStat(
task_id=task_id,
run_id=run_id,
creator_hash=creator_hash,
nickname=NICKNAME,
creator_id=creator_id,
fans=fans,
total_favorited=total_favorited,
works_count=works,
following=7,
captured_at=captured_at,
)
)
class TestCreatorStats:
"""账号级指标跟着作品一起返回 —— 界面上是按博主归组的,为了一个组头再发一轮请求
没道理。"""
@pytest.mark.asyncio
async def test_without_snapshots_the_fields_are_null(self, client):
"""null 而不是 0:0 会显示成「粉丝 0」,而事实是"还没采到"。"""
await _seed()
note = (await _notes(client))[0]
assert note["creator_fans"] is None
assert note["creator_total_favorited"] is None
assert note["creator_works"] is None
@pytest.mark.asyncio
async def test_a_snapshot_shows_up_on_every_work_of_that_creator(self, client):
task_id = await _seed()
await _add_stat(task_id, run_id=1, fans=12000)
for note in await _notes(client):
assert note["creator_fans"] == 12000
assert note["creator_total_favorited"] == 83000
assert note["creator_works"] == 42
@pytest.mark.asyncio
async def test_the_latest_run_wins(self, client):
"""一轮一条,所以总会有好几条 —— 给界面的必须是最近那条。"""
task_id = await _seed()
await _add_stat(task_id, run_id=1, fans=12000)
await _add_stat(task_id, run_id=2, fans=12300)
assert (await _notes(client))[0]["creator_fans"] == 12300
@pytest.mark.asyncio
async def test_another_platforms_snapshot_does_not_leak(self, client):
"""两个平台上恰好同名同哈希的博主是两个人 —— 快照挂在任务上,不该串。"""
await _seed(platform="xhs")
dy_task_id = await _seed(platform="dy")
await _add_stat(dy_task_id, run_id=1, fans=999)
assert (await _notes(client, "xhs"))[0]["creator_fans"] is None
assert (await _notes(client, "dy"))[0]["creator_fans"] == 999
@pytest.mark.asyncio
async def test_an_unparsed_count_stays_null(self, client):
"""快照在,但某一项没解析出来 —— 那一项必须是 null,不能变成 0。
和上一条的区别:那条是"根本没有快照",这条是"有快照、其中一项平台没给"。
界面上两种都该是「—」。
"""
task_id = await _seed()
await _add_stat(
task_id, run_id=1, fans=None, total_favorited=None, works=None
)
note = (await _notes(client))[0]
assert note["creator_fans"] is None
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
class TestCreatorProfileLink:
"""组头上那个「跳到他主页」的按钮。
它要的是**平台上的主页 id**,不是 ``creator_hash``(哈希单向,拼不出地址)。所以
这个 id 是采集资料时单独存下来的;拿不到就返回空串让界面不画按钮。
"""
@pytest.mark.asyncio
async def test_the_link_is_built_from_the_stored_id(self, client):
task_id = await _seed_task(platform="dy")
await _add_stat(task_id, run_id=1, fans=1, creator_id="MS4wLjABAAAAsomebody")
creators = await _creators(client, "dy")
assert creators[0]["creator_url"] == (
"https://www.douyin.com/user/MS4wLjABAAAAsomebody"
)
@pytest.mark.asyncio
async def test_no_id_means_no_link(self, client):
"""没采到主页 id 就不给链接 —— 画一个点开 404 的按钮比不画糟。"""
task_id = await _seed_task(platform="dy")
await _add_stat(task_id, run_id=1, fans=1, creator_id="")
assert (await _creators(client, "dy"))[0]["creator_url"] == ""
@pytest.mark.asyncio
async def test_a_creator_with_only_works_has_no_link_either(self, client):
"""小红书那条路根本不落创作者资料,所以那边永远是空的 —— 这是已知的、可接受的。"""
await _seed(platform="xhs")
assert (await _creators(client, "xhs"))[0]["creator_url"] == ""
@pytest.mark.asyncio
async def test_a_creator_with_no_works_still_gets_the_link(self, client):
"""**没有作品的博主照样能跳过去** —— 他恰恰是你想点进去看看发的什么的那个人。"""
task_id = await _seed_task(platform="dy")
await _add_stat(task_id, run_id=1, fans=1, creator_id="MS4wLjABAAAAquiet")
creators = await _creators(client, "dy")
assert creators[0]["note_count"] == 0
assert creators[0]["creator_url"].endswith("/user/MS4wLjABAAAAquiet")