feat(monitor): 博主的粉丝数,以及给作品起备注
两件都是「作品栏里把这东西认出来」的延伸: * **账号级指标**:作品列表只会说「这条涨了多少赞」,说不了「这个人整个 账号的粉丝在涨还是在掉」。抖音的资料接口本来就有粉丝数/总获赞/作品数, 每轮顺手记一条快照(`monitor_creator_stat`,粒度 = 任务×博主×轮次, 和作品指标同形)。组头显示最近一条。 快照在「一条作品都没采到」的早退**之前**落:作品列表被风控挡住的那一轮, 正是「粉丝还在涨、但新作品没在发现」最该被看见的时刻。 * **作品备注**:博主备注回答「这个账号是谁」,这条回答「这条我要盯着」。 一个博主底下常常只有一两件值得盯的作品,所以不能合并成一条。键取 (platform, note_id),跨任务共用一份。 两边都守住同一条口径:**不知道就是 null,不写成 0** —— 0 在趋势图上是一条 砸到底的线,和「还没采到」是两回事。
This commit is contained in:
@@ -13,6 +13,35 @@ import pytest
|
||||
from api.monitor import douyin_api, douyin_fetch
|
||||
|
||||
|
||||
def _profile(**overrides) -> dict:
|
||||
profile = {
|
||||
"creator_hash": "hash",
|
||||
"nickname": "博主",
|
||||
"unique_id": "abc",
|
||||
"fans": 12000,
|
||||
"total_favorited": 83000,
|
||||
"works": 42,
|
||||
"following": 7,
|
||||
}
|
||||
profile.update(overrides)
|
||||
return profile
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def fake_profile(monkeypatch):
|
||||
"""每个用例都挡住「问博主资料」这一跳。
|
||||
|
||||
它是附加信息,不在任何一条编排路径上,但真发出去就会去连 9222 那个浏览器 ——
|
||||
于是所有 creator 用例都会多出一次连接失败、并把 ``errors`` 弄脏。想验它自己的
|
||||
用例再单独覆盖这个 fixture。
|
||||
"""
|
||||
|
||||
async def _profile_call(sec_user_id, *, cookie=""):
|
||||
return _profile()
|
||||
|
||||
monkeypatch.setattr(douyin_api, "author_profile", _profile_call)
|
||||
|
||||
|
||||
def _video(aweme_id: str, likes: str = "1") -> dict:
|
||||
return {
|
||||
"aweme_id": aweme_id,
|
||||
@@ -272,3 +301,111 @@ class TestDegradation:
|
||||
# 评论拿不到是小事,作品不能跟着丢。
|
||||
assert result["notes"] == 1
|
||||
assert any("评论失败" in error for error in result["errors"])
|
||||
|
||||
|
||||
class TestCreatorProfile:
|
||||
"""博主的**账号级**指标 —— 粉丝 / 总获赞 / 作品数。
|
||||
|
||||
作品列表给不了这个东西:它说的是一件作品涨了多少赞,不是这个人整个账号的粉丝
|
||||
在涨还是在掉。单独问一次资料接口。
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def _profiles(tmp_path):
|
||||
files = list((tmp_path / "douyin" / "jsonl").glob("*_profile_*.jsonl"))
|
||||
assert len(files) == 1
|
||||
return _read(files[0])
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_the_profile_lands_in_the_run_dir(self, monkeypatch, tmp_path):
|
||||
async def fake_videos(sec_user_id, count=20, *, cookie=""):
|
||||
return [_video("111")]
|
||||
|
||||
monkeypatch.setattr(douyin_api, "author_videos", fake_videos)
|
||||
|
||||
await _collect(tmp_path, want_comments=False)
|
||||
|
||||
assert self._profiles(tmp_path) == [_profile()]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_the_profile_is_keyed_by_the_same_hash_as_the_works(
|
||||
self, monkeypatch, tmp_path
|
||||
):
|
||||
"""**这条是关键。** 快照表的唯一键是 (任务, creator_hash, 轮次),而界面上是按
|
||||
作品的 creator_hash 归组去查它的。两边只要差一个字符,粉丝数就永远查不出来 ——
|
||||
而且是静默的:表里有数据,界面上什么都没有。
|
||||
"""
|
||||
|
||||
async def fake_videos(sec_user_id, count=20, *, cookie=""):
|
||||
return [_video("111")] # 作品带的哈希是 "hash"
|
||||
|
||||
# 资料接口自己算出来的是另一个值(比如它那边 uid 缺字段、只能拿 sec_uid 算)。
|
||||
async def off_hash_profile(sec_user_id, *, cookie=""):
|
||||
return _profile(creator_hash="另一个哈希")
|
||||
|
||||
monkeypatch.setattr(douyin_api, "author_videos", fake_videos)
|
||||
monkeypatch.setattr(douyin_api, "author_profile", off_hash_profile)
|
||||
|
||||
await _collect(tmp_path, want_comments=False)
|
||||
|
||||
# 以作品为准:作品才是界面上的行,快照必须挂在能查到它的那个键上。
|
||||
assert self._profiles(tmp_path)[0]["creator_hash"] == "hash"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_profile_without_an_identity_is_dropped(self, monkeypatch, tmp_path):
|
||||
"""哈希都算不出来的快照,落下去只会是一条谁也查不到的垃圾。"""
|
||||
|
||||
async def fake_videos(sec_user_id, count=20, *, cookie=""):
|
||||
return []
|
||||
|
||||
async def anonymous_profile(sec_user_id, *, cookie=""):
|
||||
return _profile(creator_hash="")
|
||||
|
||||
monkeypatch.setattr(douyin_api, "author_videos", fake_videos)
|
||||
monkeypatch.setattr(douyin_api, "author_profile", anonymous_profile)
|
||||
|
||||
result = await _collect(tmp_path, want_comments=False)
|
||||
|
||||
assert self._profiles(tmp_path) == []
|
||||
assert any("身份标识" in error for error in result["errors"])
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_failing_profile_does_not_lose_the_works(self, monkeypatch, tmp_path):
|
||||
"""附加信息拿不到,这一轮采到的作品不能跟着判成失败。"""
|
||||
|
||||
async def fake_videos(sec_user_id, count=20, *, cookie=""):
|
||||
return [_video("111")]
|
||||
|
||||
async def broken_profile(sec_user_id, *, cookie=""):
|
||||
raise douyin_api.DouyinApiError("资料接口抽风")
|
||||
|
||||
monkeypatch.setattr(douyin_api, "author_videos", fake_videos)
|
||||
monkeypatch.setattr(douyin_api, "author_profile", broken_profile)
|
||||
|
||||
result = await _collect(tmp_path, want_comments=False)
|
||||
|
||||
assert result["notes"] == 1
|
||||
assert self._profiles(tmp_path) == []
|
||||
assert any("资料失败" in error for error in result["errors"])
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_work_target_never_asks_for_a_profile(self, monkeypatch, tmp_path):
|
||||
"""作品模式的目标是一件作品,没有「这个博主是谁」可问 —— 不该白发一个请求。"""
|
||||
|
||||
asked = []
|
||||
|
||||
async def fake_detail(aweme_id, *, cookie=""):
|
||||
return _video(aweme_id)
|
||||
|
||||
async def recording_profile(sec_user_id, *, cookie=""):
|
||||
asked.append(sec_user_id)
|
||||
return _profile()
|
||||
|
||||
monkeypatch.setattr(douyin_api, "video_detail", fake_detail)
|
||||
monkeypatch.setattr(douyin_api, "author_profile", recording_profile)
|
||||
|
||||
await _collect(tmp_path, mode="note", want_comments=False)
|
||||
|
||||
assert asked == []
|
||||
# 文件仍然建出来(空的):事后翻 run 目录能看出「这次根本没问过」。
|
||||
assert self._profiles(tmp_path) == []
|
||||
|
||||
Reference in New Issue
Block a user