Files
gyxx-flow/tests/modules/content_marketing/test_collection_completeness.py
T

1447 lines
45 KiB
Python

import argparse
from pathlib import Path
import pytest
from gyxx_flow.modules.content_marketing import bilibili_scraper as bili
from gyxx_flow.modules.content_marketing import collection_completeness as cc
from gyxx_flow.modules.content_marketing import pgy_xhs_scraper_v2 as pgy
from gyxx_flow.modules.content_marketing import run_all
from gyxx_flow.modules.content_marketing import self_douyin_scraper as self_dy
from gyxx_flow.modules.content_marketing import xingtu_scraper_v2 as xingtu
from gyxx_flow.modules.content_marketing.serialized_page_action import (
is_transient_browser_error,
run_serialized_page_action,
)
def test_normalize_title_handles_nfkc_case_and_zero_width():
assert cc.normalize_title("MacBook\u200b 通勤包!") == "macbook通勤包"
def test_coerce_url_unwraps_feishu_markdown_links():
value = "[查看笔记](https://www.xiaohongshu.com/discovery/item/abc123?x=1)"
assert cc.coerce_url(value) == "https://www.xiaohongshu.com/discovery/item/abc123?x=1"
def test_match_tasks_prefers_note_id_over_changed_title():
tasks = [{
"record_id": "r1",
"target_title": "飞书中的旧标题",
"note_url": "https://www.xiaohongshu.com/explore/abc123",
"note_id": "abc123",
}]
cards = [
{"title": "完全不同的新标题", "href": "https://www.xiaohongshu.com/explore/abc123", "note_id": "abc123"},
{"title": "飞书中的旧标题", "href": "https://www.xiaohongshu.com/explore/other", "note_id": "other"},
]
matched = cc.match_tasks_to_cards(tasks, cards, platform="xhs")
assert matched["r1"]["note_id"] == "abc123"
assert matched["r1"]["match_method"] == "content_id"
def test_match_tasks_accepts_unique_truncated_title_but_rejects_ambiguous_title():
task = {"record_id": "r1", "target_title": "男生长期主义通勤双肩包分享"}
unique = [
{"title": "男生长期主义通勤双肩包"},
{"title": "夏日轻量斜挎包"},
]
ambiguous = [
{"title": "男生长期主义通勤双肩包"},
{"title": "男生长期主义通勤双肩包"},
]
assert cc.match_tasks_to_cards([task], unique, platform="douyin")["r1"]
assert "r1" not in cc.match_tasks_to_cards([task], ambiguous, platform="douyin")
def test_title_similarity_ignores_episode_prefix_and_hashtag_suffix():
target = "第20集:Pocket4拍旋焦,无后期也能出片?"
platform_title = (
"Pocket4拍旋焦,无后期也能出片? #旋焦 #pocket4 #摄影装备 "
"无论Pocket3还是Pocket4都可以轻松拍出旋焦效果!"
)
assert cc.title_similarity(platform_title, target) >= 0.98
def test_title_similarity_rejects_different_episode_numbers():
assert cc.title_similarity(
"第19集:Pocket4拍旋焦,无后期也能出片?",
"第20集:Pocket4拍旋焦,无后期也能出片?",
) == 0.0
def test_match_tasks_rejects_duplicate_exact_titles_with_different_ids():
task = {"record_id": "r1", "target_title": "完全相同的目标标题"}
cards = [
{"title": "完全相同的目标标题", "note_id": "video-1"},
{"title": "完全相同的目标标题", "note_id": "video-2"},
]
assert "r1" not in cc.match_tasks_to_cards([task], cards, platform="douyin")
def test_one_card_is_not_reused_for_two_different_creator_tasks():
tasks = [
{
"record_id": "loose",
"target_title": "commuter backpack review today",
"note_url": "https://v.douyin.com/loose-source/",
},
{
"record_id": "exact",
"target_title": "commuter backpack review",
"note_url": "https://v.douyin.com/exact-source/",
},
]
cards = [{"title": "commuter backpack review", "note_id": "observed-1"}]
matched = cc.match_tasks_to_cards(tasks, cards, platform="douyin")
assert set(matched) == {"exact"}
def test_same_source_url_can_share_one_card_across_styles():
tasks = [
{
"record_id": "style-1",
"target_title": "同一篇跨款式笔记",
"note_url": "https://v.douyin.com/same-short-link/",
},
{
"record_id": "style-2",
"target_title": "同一篇跨款式笔记",
"note_url": "https://v.douyin.com/same-short-link/",
},
]
cards = [{"title": "同一篇跨款式笔记", "note_id": "observed-1"}]
assert set(cc.match_tasks_to_cards(tasks, cards, platform="douyin")) == {
"style-1", "style-2",
}
def test_known_mismatched_content_ids_cannot_fall_back_to_same_title():
task = {
"record_id": "r1",
"target_title": "完全相同标题",
"note_id": "video-a",
}
card = {"title": "完全相同标题", "note_id": "video-b"}
assert cc.match_tasks_to_cards([task], [card], platform="douyin") == {}
def test_xingtu_can_use_strong_title_when_share_id_is_canonicalized():
task = {
"record_id": "r1",
"target_title": "这家包颜值和实用性真挺高",
"note_id": "7665297310758210854",
}
card = {
"title": "这家包颜值和实用性真挺高 #潮流运动 #光影行星",
"note_id": "canonical- Xingtu-item-id",
}
matched = cc.match_tasks_to_cards(
[task],
[card],
platform="douyin",
allow_mismatched_content_id_title=True,
)
assert matched["r1"]["match_method"] == "title"
assert matched["r1"]["match_score"] >= 0.9
def test_xingtu_api_and_dom_duplicate_are_merged_before_matching():
cards = [
{
"title": "同一条视频",
"play_count": 19481,
"note_id": "video-1",
"source": "show_items_api",
},
{
"title": "同一条视频",
"play_count": "1.9w",
"note_id": "",
"page": 1,
},
]
deduped = cc.deduplicate_observed_cards(cards)
matched = cc.match_tasks_to_cards(
[{"record_id": "r1", "target_title": "同一条视频"}],
deduped,
platform="douyin",
)
assert len(deduped) == 1
assert matched["r1"]["note_id"] == "video-1"
def test_merge_partial_summary_keeps_existing_success_rows():
existing = {
"style": "款式A", "index": 1, "total": 2, "matched": 1, "filled": 1,
"results": [
{"record_id": "ok", "status": "success", "matched": True, "write_ok": True},
{"record_id": "retry", "status": "retryable_failure", "matched": False},
],
}
partial = {
"style": "款式A", "index": 1, "total": 1, "matched": 1, "filled": 1,
"results": [
{"record_id": "retry", "status": "success", "matched": True, "write_ok": True},
],
}
merged = cc.merge_style_summary(existing, partial)
assert merged["total"] == 2
assert {row["record_id"] for row in merged["results"]} == {"ok", "retry"}
assert merged["matched"] == 2
assert merged["filled"] == 2
assert merged["complete"] is True
def test_summary_contract_detects_missing_or_write_failure():
summary = {
"total": 2,
"results": [
{"record_id": "ok", "status": "success", "matched": True, "write_ok": True},
{"record_id": "bad", "status": "write_failure", "matched": True, "write_ok": False},
],
}
finalized = cc.finalize_summary(summary, dry_run=False)
assert finalized["complete"] is False
assert finalized["unresolved"] == 1
missing = cc.finalize_summary({"total": 2, "results": summary["results"][:1]}, dry_run=False)
assert missing["complete"] is False
assert missing["missing_results"] == 1
def _style_for_platform(platform: str):
return {
"index": 1,
"name": "款式A",
"base_token": "base",
"table_id": "table",
"field_map": {
"creator_name": {"field_id": "name"},
"creator_id": {"field_id": "creator_id"},
"note_title": {"field_id": "title"},
"publish_time": {"field_id": "pub"},
"note_url": {"field_id": "url"},
"platform": {"field_id": "platform"},
"read_count_7d": {"field_id": "read7"},
},
}
def test_extractors_preserve_note_url_and_content_id(monkeypatch):
xhs_row = {
"record_id": "xhs-r", "name": "达人", "creator_id": "xhs-id",
"title": "标题", "pub": "2026-07-20", "platform": "小红书",
"url": "https://www.xiaohongshu.com/explore/abc123", "read7": None,
}
dy_row = {
"record_id": "dy-r", "name": "达人", "creator_id": "dy-id",
"title": "标题", "pub": "2026-07-20", "platform": "抖音",
"url": "https://www.douyin.com/video/7654321", "read7": None,
}
monkeypatch.setattr(pgy, "list_records_by_table", lambda *_: [xhs_row])
monkeypatch.setattr(xingtu, "list_records_by_table", lambda *_: [dy_row])
xhs_task = pgy.extract_target_tasks(_style_for_platform("小红书"))[0]
dy_task = xingtu.extract_target_tasks(_style_for_platform("抖音"))[0]
assert xhs_task["note_url"].endswith("/abc123")
assert xhs_task["note_id"] == "abc123"
assert dy_task["note_url"].endswith("/7654321")
assert dy_task["note_id"] == "7654321"
@pytest.mark.parametrize(
("module", "platform", "filled_url"),
[
(pgy, "小红书", "https://www.douyin.com/video/7654321"),
(xingtu, "抖音", "http://8.99 分享文案 https://v.douyin.com/abc123/"),
],
)
def test_filled_publish_link_triggers_collection_without_platform_validation(
monkeypatch, module, platform, filled_url,
):
row = {
"record_id": "r1",
"name": "达人",
"creator_id": "creator-id",
"title": "已发布标题",
"pub": "2026-07-20",
"platform": platform,
"url": filled_url,
"read7": None,
}
monkeypatch.setattr(module, "list_records_by_table", lambda *_: [row])
task = module.extract_target_tasks(_style_for_platform(platform))[0]
assert task["input_error"] is None
def test_extractors_can_collect_missing_title_when_direct_url_has_content_id(monkeypatch):
xhs_style = _style_for_platform("小红书")
dy_style = _style_for_platform("抖音")
xhs_row = {
"record_id": "xhs-r", "name": "达人", "creator_id": "xhs-id",
"title": None, "pub": "2026-07-20", "platform": "小红书",
"url": "https://www.xiaohongshu.com/explore/abc123", "read7": None,
}
dy_row = {
"record_id": "dy-r", "name": "达人", "creator_id": "dy-id",
"title": None, "pub": "2026-07-20", "platform": "抖音",
"url": "https://www.douyin.com/video/7654321", "read7": None,
}
monkeypatch.setattr(pgy, "list_records_by_table", lambda *_: [xhs_row])
monkeypatch.setattr(xingtu, "list_records_by_table", lambda *_: [dy_row])
assert pgy.extract_target_tasks(xhs_style)[0]["note_id"] == "abc123"
assert xingtu.extract_target_tasks(dy_style)[0]["note_id"] == "7654321"
assert xhs_style["_extract_stats"]["skipped_incomplete"] == 0
assert dy_style["_extract_stats"]["skipped_incomplete"] == 0
def test_missing_critical_mapping_is_an_incomplete_summary(monkeypatch):
style = _style_for_platform("抖音")
del style["field_map"]["note_url"]
monkeypatch.setattr(
xingtu,
"list_records_by_table",
lambda *_: (_ for _ in ()).throw(AssertionError("mapping should fail first")),
)
_, summaries = xingtu.collect_tasks_across_styles([style], None)
finalized = cc.finalize_summary(summaries[1], dry_run=True)
assert "note_url" in finalized["error"]
assert finalized["complete"] is False
assert finalized["unresolved"] >= 1
class _FakeInput:
def __init__(self):
self.filled = []
def click(self):
return None
def fill(self, value):
self.filled.append(value)
def press(self, _key):
return None
class _FakeLocator:
def __init__(self, input_):
self.first = input_
def filter(self, **_kwargs):
return self
class _FakeSearchPage:
def __init__(self):
self.input = _FakeInput()
self.has_result_calls = 0
def wait_for_timeout(self, _ms):
return None
def evaluate(self, script, arg=None):
if "const rows" in script:
self.has_result_calls += 1
return self.has_result_calls >= 2
if "const inputs" in script:
return ""
raise AssertionError(script)
def locator(self, _selector):
return _FakeLocator(self.input)
def wait_for_url(self, *_args, **_kwargs):
return None
def test_pgy_search_retry_keeps_creator_id_query():
page = _FakeSearchPage()
found = pgy._ensure_search_results(
page, query="xhs-id-123", result_name="达人昵称", max_retries=2
)
assert found is True
assert page.input.filled == ["xhs-id-123"]
def test_apply_matched_task_reports_write_failure(monkeypatch):
task = {
"record_id": "r1", "publish_time": object(),
"style_context": {
"index": 1, "base_token": "base", "table_id": "table", "field_map": {},
},
}
summary = {"matched": 0, "filled": 0, "skipped_no_pubtime": 0, "results": []}
monkeypatch.setattr(pgy, "pick_read_field", lambda *_: ("read_count_7d", "fid", "7天曝光量"))
monkeypatch.setattr(pgy, "write_back", lambda *_: False)
outcome = pgy.apply_matched_task(task, {"read_count": "123", "title": "标题"}, summary, dry_run=False)
assert outcome["status"] == "write_failure"
assert outcome["write_ok"] is False
def test_pgy_default_creator_batch_rotates_browser_before_memory_accumulates():
assert pgy.DEFAULT_BATCH_SIZE == 4
assert xingtu.DEFAULT_BATCH_SIZE == 0
@pytest.mark.parametrize("message", [
"Page crashed: Out of Memory",
"Target page, context or browser has been closed: not enough memory",
])
def test_pgy_treats_chromium_memory_exhaustion_as_a_recoverable_session_loss(message):
assert pgy.is_browser_session_lost(RuntimeError(message)) is True
def test_repeatable_style_argument_and_selection_keep_every_requested_style():
parser = argparse.ArgumentParser()
cc.add_repeatable_style_argument(parser, "styles")
args = parser.parse_args(["--style", "1", "--style", "3"])
styles = [{"index": 1}, {"index": 2}, {"index": 3}]
assert args.style == [1, 3]
assert cc.select_requested_styles(styles, args.style) == [styles[0], styles[2]]
with pytest.raises(ValueError, match="99"):
cc.select_requested_styles(styles, [1, 99])
def test_pgy_stable_cards_retry_an_initial_empty_render(monkeypatch):
sequence = [[], [{"title": "标题", "read_count": "1"}], [{"title": "标题", "read_count": "1"}]]
monkeypatch.setattr(pgy, "parse_cards_on_page", lambda _page: sequence.pop(0))
class Page:
waits = 0
def wait_for_timeout(self, _ms):
self.waits += 1
page = Page()
assert pgy.parse_cards_stable(page) == [{"title": "标题", "read_count": "1"}]
assert page.waits == 2
def test_pgy_stable_cards_waits_through_a_slow_initial_render(monkeypatch):
card = {"title": "标题", "read_count": "1"}
sequence = [[], [], [], [], [], [card], [card]]
monkeypatch.setattr(pgy, "parse_cards_on_page", lambda _page: sequence.pop(0))
class Page:
waits = 0
def wait_for_timeout(self, _ms):
self.waits += 1
page = Page()
assert pgy.parse_cards_stable(page) == [card]
assert page.waits == 6
def test_pgy_card_parser_recovers_note_id_from_internal_card_link():
note_id = "0123456789abcdef01234567"
class Page:
def evaluate(self, _script):
return [{
"title": "标题",
"read_count": "1",
"href": "",
"note_id": "",
"_href_candidates": [
f"https://pgy.xiaohongshu.com/internal?source_note_id={note_id}"
],
"_data_note_ids": [],
}]
cards = pgy.parse_cards_on_page(Page())
assert cards == [{
"title": "标题",
"read_count": "1",
"href": "",
"note_id": note_id,
}]
def test_pgy_card_parser_prefers_strict_note_detail_id_over_generic_segment():
note_id = "0123456789abcdef01234567"
internal_href = f"https://example.invalid/note/detail/{note_id}"
class Page:
def evaluate(self, _script):
return [{
"title": "标题",
"read_count": "1",
"href": internal_href,
# This is what the old generic /note/<segment> parser produced.
"note_id": "detail",
"_href_candidates": [internal_href],
"_data_note_ids": [],
}]
cards = pgy.parse_cards_on_page(Page())
assert cards[0]["note_id"] == note_id
def test_pgy_dom_parser_reads_root_data_id_and_rejects_arbitrary_href_fallback():
captured = {}
class Page:
def evaluate(self, script):
captured["script"] = script
return []
assert pgy.parse_cards_on_page(Page()) == []
assert "const dataNoteNodes = [card, ...card.querySelectorAll" in captured["script"]
assert "const href = link?.href || '';" in captured["script"]
assert "hrefCandidates[0]" not in captured["script"]
def test_pgy_arbitrary_shared_anchor_does_not_collapse_distinct_cards():
shared_anchor = "https://example.invalid/creator/profile"
class Page:
def evaluate(self, _script):
return [
{
"title": "第一篇笔记",
"read_count": "1",
"like_count": "1",
"collect_count": "1",
"publish_time": "2026-08-01",
"href": "",
"note_id": "",
"_href_candidates": [shared_anchor],
"_data_note_ids": [],
},
{
"title": "第二篇笔记",
"read_count": "2",
"like_count": "2",
"collect_count": "2",
"publish_time": "2026-08-02",
"href": "",
"note_id": "",
"_href_candidates": [shared_anchor],
"_data_note_ids": [],
},
]
cards = pgy.parse_cards_on_page(Page())
assert [card["href"] for card in cards] == ["", ""]
assert pgy._card_observation_key(cards[0]) != pgy._card_observation_key(cards[1])
def test_pgy_deduplicates_one_card_repeated_across_pages_before_matching(monkeypatch):
card = {
"title": "同一篇笔记",
"read_count": "100",
"like_count": "10",
"collect_count": "5",
"publish_time": "2026-08-01",
"href": "",
"note_id": "",
}
pages = [[dict(card)], [dict(card)], [dict(card)]]
monkeypatch.setattr(pgy, "parse_cards_stable", lambda _page: pages.pop(0))
monkeypatch.setattr(pgy, "go_next_page", lambda _page: bool(pages))
found, candidates, page_limit_hit = pgy.find_notes_for_tasks(
object(),
[{"record_id": "r1", "target_title": "同一篇笔记"}],
max_pages=4,
)
assert found["r1"]["read_count"] == "100"
assert candidates == ["同一篇笔记"]
assert page_limit_hit is False
def test_pgy_missing_creator_is_blocked_but_unmatched_note_stays_retryable():
no_creator = pgy._missing_detail_outcome(saw_search_result=False)
no_unique_note = pgy._unmatched_note_outcome(
candidates=["平台上的其他笔记"],
page_limit_hit=False,
)
completed = cc.finalize_summary({
"total": 2,
"results": [
{"record_id": "creator", **no_creator},
{"record_id": "note", **no_unique_note},
],
})
assert completed["blocked_input"] == 1
assert completed["retryable_failures"] == 1
assert completed["unresolved"] == 1
assert completed["complete"] is False
assert pgy._missing_detail_outcome(saw_search_result=True)["status"] == "retryable_failure"
assert pgy._unmatched_note_outcome(
candidates=[], page_limit_hit=False
)["reason"] == "empty_detail_page"
assert pgy._unmatched_note_outcome(
candidates=["候选"], page_limit_hit=True
)["status"] == "retryable_failure"
@pytest.mark.parametrize(
"state",
[
{
"nextFound": True,
"nextDisabled": True,
"activePage": None,
"lastPage": None,
"numericPageCount": 0,
},
{
"nextFound": True,
"nextDisabled": False,
"activePage": 7,
"lastPage": 7,
"numericPageCount": 7,
},
],
)
def test_pgy_known_last_page_stops_without_clicking(state):
class Page:
def evaluate(self, script):
if "pgy-pagination-state" in script:
return state
if "pgy-pagination-click" in script:
pytest.fail("terminal paginator must not be clicked")
return "last-page-card-signature"
assert pgy.go_next_page(Page()) is False
def test_pgy_clicked_pagination_without_card_change_uses_source_end_semantics():
class Page:
def evaluate(self, script):
if "pgy-pagination-state" in script:
return {
"nextFound": True,
"nextDisabled": False,
"activePage": 2,
"lastPage": 7,
"numericPageCount": 7,
}
if "pgy-pagination-click" in script:
return {"clicked": True, "text": "next"}
return "same-card-signature"
def wait_for_timeout(self, _ms):
return None
assert pgy.go_next_page(Page()) is False
def test_xingtu_paginates_before_falling_back_to_search(monkeypatch):
pages = [
[{"title": "无关视频", "play_count": "10"}],
[{"title": "目标视频标题", "play_count": "20"}],
]
calls = {"next": 0}
monkeypatch.setattr(xingtu, "is_blocked", lambda _page: False)
monkeypatch.setattr(xingtu, "parse_videos_stable", lambda _page: pages.pop(0))
def next_page(_page):
calls["next"] += 1
return True
monkeypatch.setattr(xingtu, "go_next_page", next_page)
monkeypatch.setattr(xingtu, "submit_video_search", lambda *_: False)
found, _, _ = xingtu.find_videos_for_tasks(
object(), [{"record_id": "r1", "target_title": "目标视频标题"}], max_pages=2,
)
assert found["r1"]["play_count"] == "20"
assert calls["next"] == 1
def test_xingtu_video_search_uses_visible_input_and_source_keyboard_events():
events = []
class Locator:
first = None
def __init__(self):
self.first = self
def count(self):
return 1
def wait_for(self, **kwargs):
events.append(("wait", kwargs))
def click(self):
events.append(("click",))
def press(self, key):
events.append(("locator_press", key))
def fill(self, _value):
pytest.fail("Xingtu search must preserve source key-by-key input")
class Keyboard:
def type(self, value, **kwargs):
events.append(("type", value, kwargs))
def press(self, key):
events.append(("keyboard_press", key))
class Page:
keyboard = Keyboard()
def __init__(self):
self.selector = None
self.input = Locator()
def locator(self, selector):
self.selector = selector
return self.input
page = Page()
assert xingtu.submit_video_search(page, "目标标题") is True
assert ":visible" in page.selector
assert events == [
("wait", {"state": "visible", "timeout": 5000}),
("click",),
("locator_press", "Control+A"),
("locator_press", "Backspace"),
("type", "目标标题", {"delay": 20}),
("keyboard_press", "Enter"),
]
def test_xingtu_only_confirmed_creator_miss_is_blocked():
no_creator = xingtu._missing_detail_outcome(saw_search_result=False)
no_unique_video = xingtu._unmatched_video_outcome(
candidates=["平台上的其他视频"],
page_limit_hit=False,
)
completed = cc.finalize_summary({
"total": 2,
"results": [
{"record_id": "creator", **no_creator},
{"record_id": "video", **no_unique_video},
],
})
assert completed["blocked_input"] == 1
assert completed["retryable_failures"] == 1
assert completed["unresolved"] == 1
assert completed["complete"] is False
assert no_unique_video["status"] == "retryable_failure"
assert xingtu._missing_detail_outcome(
saw_search_result=True
)["status"] == "retryable_failure"
assert xingtu._unmatched_video_outcome(
candidates=[], page_limit_hit=False
)["reason"] == "empty_detail_page"
assert xingtu._unmatched_video_outcome(
candidates=["候选"], page_limit_hit=True
)["status"] == "retryable_failure"
def test_xingtu_missing_creator_result_has_distinct_business_exception():
class Context:
pages = []
class Page:
context = Context()
def evaluate(self, _script, argument=None):
if argument is not None:
return {"found": False}
return False
def wait_for_timeout(self, _milliseconds):
return None
with pytest.raises(xingtu.CreatorNotFound, match="未找到精确达人"):
xingtu.open_creator_detail(Page(), "不存在的达人", "missing-id")
def test_xingtu_clicked_pagination_without_card_change_is_retryable_timeout():
class Page:
def evaluate(self, script):
if "const all" in script:
return {"clicked": True, "picked": "pagination"}
return "same-card-signature"
def wait_for_timeout(self, _milliseconds):
return None
with pytest.raises(xingtu.BrowserTimeoutError, match="did not change"):
xingtu.go_next_page(Page())
def test_xingtu_normalizes_show_items_api_video():
card = xingtu.normalize_xingtu_api_item({
"item_id": 7654321,
"item_title": "目标视频标题",
"play": 168000,
"like": 1200,
"comment": 34,
"share": 56,
"url": "https://www.douyin.com/video/7654321",
})
assert card == {
"title": "目标视频标题",
"play_count": 168000,
"like_count": 1200,
"comment_count": 34,
"share_count": 56,
"href": "https://www.douyin.com/video/7654321",
"note_id": "7654321",
"source": "show_items_api",
}
def test_xingtu_show_items_capture_collects_and_deduplicates_cards():
import gc
import weakref
class Response:
url = "https://www.xingtu.cn/gw/api/author/get_author_show_items_v2"
def json(self):
return {
"latest_item_info": [
{"item_id": "1", "title": "视频一", "play": 10},
{"item_id": "2", "item_title": "视频二", "play": 20},
],
"latest_star_item_info": [
{"item_id": "2", "item_title": "视频二", "play": 20},
],
}
class Page:
def on(self, event, callback):
assert event == "response"
self.callback = callback
page = Page()
cards = xingtu.setup_show_items_capture(page)
response = Response()
response_ref = weakref.ref(response)
page.callback(response)
del response
gc.collect()
assert [card["note_id"] for card in cards] == ["1", "2"]
assert response_ref() is None
def test_xingtu_matches_api_cards_without_dom_pagination(monkeypatch):
monkeypatch.setattr(
xingtu,
"parse_videos_stable",
lambda _page: (_ for _ in ()).throw(AssertionError("DOM should not be needed")),
)
monkeypatch.setattr(
xingtu,
"is_blocked",
lambda _page: (_ for _ in ()).throw(AssertionError("page should not be needed")),
)
tasks = [{
"record_id": "r1",
"target_title": "飞书旧标题",
"note_url": "https://www.douyin.com/video/7654321",
"note_id": "7654321",
}]
api_cards = [{
"title": "平台上的新标题",
"play_count": 168000,
"note_id": "7654321",
"source": "show_items_api",
}]
found, _, page_limit_hit = xingtu.find_videos_for_tasks(
object(), tasks, max_pages=5, api_cards=api_cards,
)
assert found["r1"]["play_count"] == 168000
assert found["r1"]["match_method"] == "content_id"
assert page_limit_hit is False
def test_xingtu_api_fuzzy_candidate_does_not_preempt_later_exact_dom(monkeypatch):
monkeypatch.setattr(xingtu, "is_blocked", lambda _page: False)
monkeypatch.setattr(
xingtu,
"parse_videos_stable",
lambda _page: [{"title": "commuter backpack review", "note_id": "exact-card"}],
)
monkeypatch.setattr(xingtu, "go_next_page", lambda _page: False)
monkeypatch.setattr(xingtu, "submit_video_search", lambda *_: False)
task = {"record_id": "r1", "target_title": "commuter backpack review"}
api_cards = [{
"title": "commuter backpack review today",
"note_id": "fuzzy-card",
"source": "show_items_api",
}]
found, _, _ = xingtu.find_videos_for_tasks(
object(), [task], max_pages=2, api_cards=api_cards,
)
assert found["r1"]["note_id"] == "exact-card"
def test_browser_session_loss_detection_is_specific():
closed = RuntimeError("Target page, context or browser has been closed")
assert pgy.is_browser_session_lost(closed)
assert xingtu.is_browser_session_lost(closed)
assert pgy.is_browser_session_lost(RuntimeError("Page crashed"))
assert xingtu.is_browser_session_lost(RuntimeError("Page crashed"))
assert pgy.is_browser_session_lost(
RuntimeError("Execution context was destroyed, most likely because of a navigation.")
)
assert not pgy.is_browser_session_lost(RuntimeError("ordinary parse failure"))
def test_transient_browser_error_excludes_business_timeout():
assert is_transient_browser_error(
pgy.BrowserTimeoutError("Page.goto: Timeout 60000ms exceeded")
)
assert is_transient_browser_error(
RuntimeError("Page.goto: net::ERR_CONNECTION_RESET")
)
assert not is_transient_browser_error(
TimeoutError("Login timed out after 300 seconds.")
)
def _stub_creator_session_failure(monkeypatch, tmp_path, module, error_factory):
style = {"index": 1, "name": "style"}
task = {
"record_id": "record-1",
"creator_name": "creator",
"creator_id": None,
"target_title": "target",
"style_context": {"index": 1},
}
summary = {
"index": 1,
"name": "style",
"total": 1,
"filled": 0,
"results": [],
}
group = {
"creator_name": "creator",
"creator_id": None,
"tasks": [task],
}
sessions = []
class Session:
def __init__(self, **_kwargs):
sessions.append(self)
def __enter__(self):
return self
def __exit__(self, *_args):
return False
def fail_page_action(*_args, **_kwargs):
raise error_factory()
monkeypatch.setattr(
module,
"collect_tasks_across_styles",
lambda *_args, **_kwargs: ([task], {1: summary}),
)
monkeypatch.setattr(module, "group_tasks_by_creator", lambda _tasks: [group])
monkeypatch.setattr(module, "chunk_creator_groups", lambda groups, _size: [groups])
monkeypatch.setattr(module, "DynamicSession", Session)
monkeypatch.setattr(module, "run_serialized_page_action", fail_page_action)
monkeypatch.setattr(module, "_force_cleanup_session", lambda _session: None)
monkeypatch.setattr(module, "PROFILE_DIR", tmp_path / module.__name__.split(".")[-1])
return style, sessions
@pytest.mark.parametrize("module", [pgy, xingtu])
def test_creator_collectors_retry_browser_timeout_in_three_fresh_sessions(
monkeypatch, tmp_path, module
):
style, sessions = _stub_creator_session_failure(
monkeypatch,
tmp_path,
module,
lambda: module.BrowserTimeoutError("Page.goto: Timeout 60000ms exceeded"),
)
summaries = module.scrape_styles([style], 1, True, None, True)
assert len(sessions) == 3
assert summaries[0]["complete"] is False
assert summaries[0]["retryable_failures"] == 1
@pytest.mark.parametrize("module", [pgy, xingtu])
def test_creator_collectors_do_not_retry_acceptance_terminal(
monkeypatch, tmp_path, module
):
style, sessions = _stub_creator_session_failure(
monkeypatch,
tmp_path,
module,
lambda: module.AcceptanceCookieSkip("cookie unavailable in acceptance"),
)
with pytest.raises(module.AcceptanceCookieSkip):
module.scrape_styles([style], 1, True, None, True)
assert len(sessions) == 1
@pytest.mark.parametrize("module", [pgy, xingtu])
def test_creator_collectors_do_not_retry_login_timeout_terminal(
monkeypatch, tmp_path, module
):
style, sessions = _stub_creator_session_failure(
monkeypatch,
tmp_path,
module,
lambda: TimeoutError("Login timed out after 300 seconds."),
)
with pytest.raises(TimeoutError, match="Login timed out"):
module.scrape_styles([style], 1, True, None, True)
assert len(sessions) == 1
@pytest.mark.parametrize("error_type", [xingtu.SearchQuotaExhausted, xingtu.CaptchaBlocked])
def test_xingtu_does_not_retry_explicit_business_terminal(
monkeypatch, tmp_path, error_type
):
style, sessions = _stub_creator_session_failure(
monkeypatch,
tmp_path,
xingtu,
lambda: error_type("explicit platform terminal"),
)
with pytest.raises(error_type):
xingtu.scrape_styles([style], 1, True, None, True)
assert len(sessions) == 1
@pytest.mark.parametrize("module", [pgy, xingtu])
def test_creator_collectors_with_no_tasks_do_not_open_browser(
monkeypatch, module
):
style = {"index": 1, "name": "style"}
summary = {"index": 1, "name": "style", "total": 0, "results": []}
monkeypatch.setattr(
module,
"collect_tasks_across_styles",
lambda *_args, **_kwargs: ([], {1: summary}),
)
monkeypatch.setattr(
module,
"DynamicSession",
lambda **_kwargs: pytest.fail("no browser session expected"),
)
assert module.scrape_styles([style], 1, True, None, True) == [summary]
def test_long_page_action_does_not_retain_navigation_response():
import gc
import weakref
response_ref = None
class Response:
url = "https://example.test/final"
status = 200
headers = {"content-type": "text/html"}
class Page:
closed = False
def set_default_timeout(self, _timeout):
pass
def set_default_navigation_timeout(self, _timeout):
pass
def goto(self, *_args, **_kwargs):
nonlocal response_ref
response = Response()
response_ref = weakref.ref(response)
return response
def is_closed(self):
return self.closed
def close(self):
self.closed = True
page = Page()
class Context:
def new_page(self):
return page
class Session:
context = Context()
def action(_page):
gc.collect()
assert response_ref is not None
assert response_ref() is None
navigation = run_serialized_page_action(Session(), "https://example.test", action)
assert navigation == {
"url": "https://example.test/final",
"status": 200,
"headers": {"content-type": "text/html"},
}
assert page.closed is True
def test_xingtu_nested_session_loss_is_reraised():
closed = RuntimeError("Target page, context or browser has been closed")
with pytest.raises(RuntimeError, match="has been closed"):
xingtu.reraise_if_browser_session_lost(closed)
xingtu.reraise_if_browser_session_lost(RuntimeError("ordinary lookup miss"))
def test_xingtu_detail_and_video_search_propagate_closed_session():
closed = RuntimeError("Target page, context or browser has been closed")
class Detail:
def goto(self, *_args, **_kwargs):
raise closed
class Context:
pages = []
def new_page(self):
return Detail()
class SearchPage:
context = Context()
def evaluate(self, _script, _arg=None):
return {"found": True, "url": "https://www.xingtu.cn/ad/creator/author-homepage/douyin-video/1"}
with pytest.raises(RuntimeError, match="has been closed"):
xingtu.open_creator_detail(SearchPage(), "达人", "creator-id")
class Locator:
first = None
def __init__(self):
self.first = self
def count(self):
return 1
def wait_for(self, **_kwargs):
raise closed
class VideoPage:
def locator(self, _selector):
return Locator()
with pytest.raises(RuntimeError, match="has been closed"):
xingtu.submit_video_search(VideoPage(), "标题")
def test_pgy_fallback_detail_click_propagates_closed_session():
closed = RuntimeError("Target page, context or browser has been closed")
class Clicker:
first = None
def __init__(self):
self.first = self
def click(self, **_kwargs):
raise closed
class Context:
pages = []
class Page:
context = Context()
def evaluate(self, *_args, **_kwargs):
return {"ok": False, "reason": "no-row"}
def get_by_text(self, *_args, **_kwargs):
return Clicker()
with pytest.raises(RuntimeError, match="has been closed"):
pgy.open_blogger_detail(Page(), "达人", None)
def test_bilibili_triggered_blocked_inputs_are_visible_terminal_rows():
summary = bili.finalize_bili_summary({
"style": "款式A", "index": 1, "total_b_records": 1,
"details": [{
"record_id": "r1", "status": "blocked_input",
"matched": False, "reason": "publish_time_missing", "ok": False,
}],
})
assert summary["blocked_input"] == 1
assert summary["unresolved"] == 0
assert summary["complete"] is True
def test_bilibili_missing_url_does_not_trigger_collection(monkeypatch):
style = {
"style": "款式A",
"name": "款式A",
"index": 1,
"base_token": "base",
"table_id": "table",
"field_map": {
"platform": {"field_id": "platform"},
"note_url": {"field_id": "url"},
"creator_name": {"field_id": "creator"},
"publish_time": {"field_id": "pub"},
"read_count_7d": {"field_id": "s7"},
"read_count_14d": {"field_id": "s14"},
"read_count_21d": {"field_id": "s21"},
"read_count_28d": {"field_id": "s28"},
"month_end": {"field_id": "sm"},
},
}
rows = [
{
"record_id": "no-link",
"platform": "B站",
"creator": "未发布达人",
"url": "",
"pub": "2026-07-20",
},
{
"record_id": "published",
"platform": "B站",
"creator": "已发布达人",
"url": "https://www.bilibili.com/video/BV1234567890",
"pub": "2026-07-20",
},
]
monkeypatch.setattr(bili, "list_all_records", lambda *_args: rows)
monkeypatch.setattr(bili, "fetch_play_count", lambda *_args: 321)
monkeypatch.setattr(bili, "write_record", lambda *_args, **_kwargs: True)
summary = bili.process_style(
style,
only_record_ids=None,
dry_run=True,
delay=0,
session=object(),
state={},
force_today=bili.date(2026, 7, 23),
first_run=False,
)
assert summary["source_b_records"] == 2
assert summary["skipped_no_url"] == 1
assert summary["total_b_records"] == 1
assert summary["success"] == 1
assert summary["blocked_input"] == 0
assert [row["record_id"] for row in summary["details"]] == ["published"]
@pytest.mark.parametrize(
("raw", "expected"),
[
("2026-07-20T00:00:00.000+08:00", bili.date(2026, 7, 20)),
("2026-07-20T00:00:00+08:00", bili.date(2026, 7, 20)),
("2026-07-19T16:00:00.000Z", bili.date(2026, 7, 19)),
("2026-07-20", bili.date(2026, 7, 20)),
],
)
def test_bilibili_parse_pub_date_accepts_feishu_iso_values(raw, expected):
assert bili.parse_pub_date(raw) == expected
def test_bilibili_partial_retry_preserves_old_success():
existing = {
"style": "款式A", "index": 1, "total_b_records": 2,
"details": [
{"record_id": "ok", "status": "success", "ok": True},
{"record_id": "retry", "status": "retryable_failure", "ok": False},
],
}
partial = {
"style": "款式A", "index": 1, "total_b_records": 1,
"details": [{"record_id": "retry", "status": "success", "ok": True}],
}
merged = bili.merge_bili_summary(existing, partial)
assert merged["total_b_records"] == 2
assert {row["record_id"] for row in merged["details"]} == {"ok", "retry"}
assert merged["complete"] is True
def test_bilibili_top_level_error_cannot_finalize_as_complete():
finalized = bili.finalize_bili_summary({
"style": "款式A",
"index": 1,
"total_b_records": 0,
"details": [],
"error": "missing platform/url field_id",
"unresolved": 1,
"retryable_failures": 1,
"complete": False,
})
assert finalized["complete"] is False
assert finalized["unresolved"] >= 1
def test_bilibili_missing_slot_mapping_is_system_error_not_blocked_input():
summary = bili.process_style(
{
"style": "款式A",
"name": "款式A",
"index": 1,
"base_token": "base",
"table_id": "table",
"field_map": {
"platform": {"field_id": "platform"},
"note_url": {"field_id": "url"},
},
},
only_record_ids=None,
dry_run=True,
delay=0,
session=object(),
state={},
force_today=None,
first_run=False,
)
finalized = bili.finalize_bili_summary(summary, dry_run=True)
assert "publish_time" in finalized["error"]
assert "read_count_7d" in finalized["error"]
assert finalized["complete"] is False
def test_self_douyin_missing_mapping_and_write_failure_are_not_complete(monkeypatch):
missing = self_dy.scrape_one_style(
{"name": "款式A", "index": 1, "base_token": "base", "table_id": "table", "field_map": {}},
login_timeout=1,
headless=True,
only_record_ids=None,
dry_run=False,
)
assert cc.finalize_summary(missing)["complete"] is False
style = _style_for_platform("抖音")
row = {
"record_id": "r1",
"platform": "抖音",
"name": "自营达人",
"title": "目标作品",
"url": "https://www.douyin.com/video/1",
"pub": "2026-07-20",
}
class Page:
def goto(self, *_args, **_kwargs):
return None
def wait_for_timeout(self, _ms):
return None
class Session:
def __init__(self, **_kwargs):
pass
def __enter__(self):
return self
def __exit__(self, *_args):
return None
def fetch(self, _url, page_action, wait):
page_action(Page())
monkeypatch.setattr(self_dy, "DynamicSession", Session)
monkeypatch.setattr(self_dy, "list_records_by_table", lambda *_: [row])
monkeypatch.setattr(self_dy, "dismiss_popups", lambda *_: None)
monkeypatch.setattr(self_dy, "restore_cookies", lambda *_: None)
monkeypatch.setattr(self_dy, "maybe_wait_for_login", lambda *_: None)
monkeypatch.setattr(self_dy, "save_state", lambda *_: None)
monkeypatch.setattr(
self_dy,
"scroll_and_collect_posts",
lambda *_args, **_kwargs: [{"title": "目标作品", "play_count": 123}],
)
monkeypatch.setattr(self_dy, "pick_read_field", lambda *_: ("read_count_7d", "read7", "7天曝光量"))
monkeypatch.setattr(self_dy, "write_back", lambda *_: False)
failed = cc.finalize_summary(
self_dy.scrape_one_style(
style,
login_timeout=1,
headless=True,
only_record_ids=None,
dry_run=False,
)
)
assert failed["results"][0]["status"] == "write_failure"
assert failed["complete"] is False
def test_run_all_rejects_stale_or_incomplete_summary(tmp_path: Path):
path = tmp_path / "summary.json"
path.write_text("[]", encoding="utf-8")
started_at = path.stat().st_mtime + 1
assert run_all.validate_summary_payload([], {1}, path, started_at)[0] is False
path.write_text('[{"index": 1, "total": 2, "results": [{"record_id": "a", "status": "success"}]}]', encoding="utf-8")
assert run_all.validate_summary_payload(
[{"index": 1, "total": 2, "results": [{"record_id": "a", "status": "success"}]}],
{1}, path, path.stat().st_mtime - 1,
)[0] is False