Files
MediaCrawler/tests/test_douyin_fetch.py
T
butubb 9f70cd0924
Deploy VitePress site to Pages / build (push) Canceled after 0s
Deploy VitePress site to Pages / Deploy (push) Canceled after 0s
fix(monitor): 抖音「作品」模式的目标被当成博主去查,白废一条本来能用的路
两种模式的**目标是不同的东西**,原来的 fetcher 却一条路走到底:

* 「作品」模式(粘贴作品链接)—— 目标本身就是作品 id,直接取详情即可。**这个接口没被
  那道真校验挡,今天就能用。**
* 「博主」模式 —— 目标是主页 sec_uid,要先拉作品列表;那个接口被挡,退化成刷新已知作品。

原来两种都去调 author_videos(它要的是博主 sec_uid),于是「作品」模式的监控拿作品号当
sec_uid 去查,必然失败 —— 而且失败原因说得很难懂(接口回你「未登录/不是浏览器」)。
结果就是:**新建「作品」模式的抖音监控永远抓不到东西**,而那本来是现有条件下唯一能用的。

现在按 mode 分岔。测试 +2:作品模式必须走 detail 且**不得**去调列表接口(走错了会
直接抛断言);一件作品坏掉不连累其他作品。

顺带记一条排查结论:博主主页的 HTML 里**没有**作品列表(RENDER_DATA 解出来只有
{isLogin, statusCode, isSpider}),所以「走页面 HTML 免接口」那条路也是死的。
2026-10-10 17:30:28 +08:00

275 lines
9.7 KiB
Python

# -*- coding: utf-8 -*-
"""抖音采集编排 —— 不碰网络,把 douyin_api 整个换掉。
验的是编排本身:产物落在正确的目录、文件名是 store 那套、以及**作品列表被挡时的退化**
(用已知 aweme_id 逐条刷新)—— 那条退化路径决定了今天这个功能是「完全没用」还是
「已知作品还能看」。
"""
import json
import pytest
from api.monitor import douyin_api, douyin_fetch
def _video(aweme_id: str, likes: str = "1") -> dict:
return {
"aweme_id": aweme_id,
"title": f"title-{aweme_id}",
"aweme_url": f"https://www.douyin.com/video/{aweme_id}",
"cover_url": "",
"aweme_type": "0",
"create_time": 1790574515,
"creator_hash": "hash",
"nickname": "博主",
"liked_count": likes,
"comment_count": "0",
"collected_count": "0",
"share_count": "0",
}
def _comment(aweme_id: str, index: int) -> dict:
return {
"comment_id": f"c{index}",
"aweme_id": aweme_id,
"content": f"评论{index}",
"nickname": "路人",
"creator_hash": "h2",
"create_time": 1790574600,
"like_count": "0",
"sub_comment_count": "0",
"parent_comment_id": "0",
}
class _Target:
def __init__(self, external_id: str) -> None:
self.external_id = external_id
def _read(path):
return [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines() if line]
async def _collect(tmp_path, **overrides):
kwargs = dict(
platform="dy",
mode="creator",
limit=20,
want_comments=True,
comment_limit=20,
targets=[_Target("MS4w-sec")],
cookie="sessionid=x",
)
kwargs.update(overrides)
return await douyin_fetch.collect(tmp_path, **kwargs)
class TestHappyPath:
@pytest.mark.asyncio
async def test_writes_the_layout_ingest_expects(self, monkeypatch, tmp_path):
async def fake_videos(sec_user_id, count=20, *, cookie=""):
return [_video("111"), _video("222")]
async def fake_comments(aweme_id, count=20, *, cookie=""):
return [_comment(aweme_id, 1)]
monkeypatch.setattr(douyin_api, "author_videos", fake_videos)
monkeypatch.setattr(douyin_api, "video_comments", fake_comments)
result = await _collect(tmp_path)
assert result["notes"] == 2
assert result["comments"] == 2
assert result["errors"] == []
# 目录名必须是 douyin(不是平台 id dy)—— ingest 找文件用的是同一个来源。
jsonl_dir = tmp_path / "douyin" / "jsonl"
assert jsonl_dir.is_dir()
contents = list(jsonl_dir.glob("*_contents_*.jsonl"))
comments = list(jsonl_dir.glob("*_comments_*.jsonl"))
assert len(contents) == 1
assert len(comments) == 1
notes = _read(contents[0])
assert [n["aweme_id"] for n in notes] == ["111", "222"]
# 键名照抄 store/douyin —— ingest 靠这个读出来。
assert notes[0]["aweme_url"] == "https://www.douyin.com/video/111"
# 秒,不是毫秒;换算交给 adapters。
assert notes[0]["create_time"] == 1790574515
assert _read(comments[0])[0]["aweme_id"] == "111"
@pytest.mark.asyncio
async def test_the_comment_file_exists_even_without_comments(self, monkeypatch, tmp_path):
"""评论文件必须建出来。
ingest 靠「文件在不在」区分「这一轮没评论」和「这一轮什么都没抓到」——
两种情况的含义完全不同。
"""
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 len(list((tmp_path / "douyin" / "jsonl").glob("*_comments_*.jsonl"))) == 1
@pytest.mark.asyncio
async def test_duplicate_works_are_written_once(self, monkeypatch, tmp_path):
async def fake_videos(sec_user_id, count=20, *, cookie=""):
return [_video("111"), _video("111")]
monkeypatch.setattr(douyin_api, "author_videos", fake_videos)
result = await _collect(tmp_path, want_comments=False)
assert result["notes"] == 1
class TestNoteMode:
@pytest.mark.asyncio
async def test_a_work_target_is_fetched_by_detail_not_by_creator_list(
self, monkeypatch, tmp_path
):
"""作品模式的目标**本身就是作品 id**,不能拿它当博主的 sec_uid 去查列表。
走错了会必然失败,而且失败原因很难看懂(接口说你没登录/不是浏览器)——
「粘贴作品链接的监控」今天本来是能用的,别让它因为这一处走错而废掉。
"""
async def must_not_be_called(*args, **kwargs):
raise AssertionError("作品模式不该去拉博主的作品列表")
async def fake_detail(aweme_id, *, cookie=""):
return _video(aweme_id)
monkeypatch.setattr(douyin_api, "author_videos", must_not_be_called)
monkeypatch.setattr(douyin_api, "video_detail", fake_detail)
result = await _collect(
tmp_path,
mode="note",
want_comments=False,
targets=[_Target("111"), _Target("222")],
)
assert result["notes"] == 2
assert result["errors"] == []
notes = _read(list((tmp_path / "douyin" / "jsonl").glob("*_contents_*.jsonl"))[0])
assert [n["aweme_id"] for n in notes] == ["111", "222"]
@pytest.mark.asyncio
async def test_a_broken_work_does_not_lose_the_others(self, monkeypatch, tmp_path):
async def flaky(aweme_id, *, cookie=""):
if aweme_id == "222":
raise douyin_api.DouyinApiError("作品已被删除")
return _video(aweme_id)
monkeypatch.setattr(douyin_api, "video_detail", flaky)
result = await _collect(
tmp_path,
mode="note",
want_comments=False,
targets=[_Target("111"), _Target("222")],
)
assert result["notes"] == 1
assert any("222" in error for error in result["errors"])
class TestDegradation:
"""作品列表被挡时的行为 —— 决定了这个功能今天有没有用。"""
@pytest.mark.asyncio
async def test_falls_back_to_refreshing_known_works(self, monkeypatch, tmp_path):
async def blocked(sec_user_id, count=20, *, cookie=""):
raise douyin_api.DouyinApiError("接口返回了空内容")
refreshed = []
async def fake_detail(aweme_id, *, cookie=""):
refreshed.append(aweme_id)
return _video(aweme_id, likes="9")
monkeypatch.setattr(douyin_api, "author_videos", blocked)
monkeypatch.setattr(douyin_api, "video_detail", fake_detail)
result = await _collect(
tmp_path, want_comments=False, known_aweme_ids=["999", "888"]
)
assert refreshed == ["999", "888"]
assert result["notes"] == 2
# 但错误照样报出来 —— 这一轮是「部分可用」,不是「一切正常」,别粉饰。
assert any("作品列表失败" in error for error in result["errors"])
@pytest.mark.asyncio
async def test_known_works_are_not_refetched_for_every_target(self, monkeypatch, tmp_path):
"""退化路径不能每个目标都把同一批已知作品再刷一遍。
任务有多个目标时那会让同一件作品在一轮里出现两次,而指标快照的唯一键是
(task_id, note_id, run_id) —— 第二次插入直接撞键,整个 run 崩掉(踩过:
Duplicate entry for key 'uq_note_metric')。
"""
async def blocked(sec_user_id, count=20, *, cookie=""):
raise douyin_api.DouyinApiError("接口返回了空内容")
calls = []
async def fake_detail(aweme_id, *, cookie=""):
calls.append(aweme_id)
return _video(aweme_id)
monkeypatch.setattr(douyin_api, "author_videos", blocked)
monkeypatch.setattr(douyin_api, "video_detail", fake_detail)
result = await _collect(
tmp_path,
want_comments=False,
targets=[_Target("sec-a"), _Target("sec-b")],
known_aweme_ids=["999"],
)
assert calls == ["999"], "同一件已知作品只该刷一次,而不是每个目标一次"
assert result["notes"] == 1
@pytest.mark.asyncio
async def test_nothing_at_all_still_reports_the_reason(self, monkeypatch, tmp_path):
async def blocked(sec_user_id, count=20, *, cookie=""):
raise douyin_api.DouyinApiError("接口返回了空内容")
monkeypatch.setattr(douyin_api, "author_videos", blocked)
result = await _collect(tmp_path, want_comments=False)
assert result["notes"] == 0
assert result["errors"]
# 产物仍然写出来(空的),让调用方去判断这是失败而不是「这个博主没作品」。
assert (tmp_path / "douyin" / "jsonl").is_dir()
@pytest.mark.asyncio
async def test_a_failing_comment_fetch_does_not_lose_the_work(self, monkeypatch, tmp_path):
async def fake_videos(sec_user_id, count=20, *, cookie=""):
return [_video("111")]
async def broken_comments(aweme_id, count=20, *, cookie=""):
raise douyin_api.DouyinApiError("评论接口抽风")
monkeypatch.setattr(douyin_api, "author_videos", fake_videos)
monkeypatch.setattr(douyin_api, "video_comments", broken_comments)
result = await _collect(tmp_path)
# 评论拿不到是小事,作品不能跟着丢。
assert result["notes"] == 1
assert any("评论失败" in error for error in result["errors"])