feat: complete production workflow migration

This commit is contained in:
2026-08-06 14:29:57 +08:00
parent 7f215e79c4
commit 8df5266abb
448 changed files with 56937 additions and 14619 deletions
@@ -0,0 +1,57 @@
import sys
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from gyxx_flow.modules.content_marketing.data.tools import (
analyze_note, # noqa: E402
)
class AnalyzeNoteWithoutCommentsTest(unittest.TestCase):
def test_no_comments_still_writes_metrics_report(self):
info = {
"title": "无评论测试笔记",
"platform": "xiaohongshu",
"source_url": "https://example.com/note",
"scraped_at": "",
"comment_count_total": 0,
"like_count": 12,
"favorite_count": 3,
"comment_count_metric": 0,
"share_count": 1,
"view_count": 1000,
"collect_count_yxyy": 0,
"style_name": "测试款",
"creator_name": "测试达人",
}
with tempfile.TemporaryDirectory() as tmpdir:
output = Path(tmpdir) / "note.txt"
argv = [
"analyze_note.py",
"--note-id",
"1",
"--no-send",
"--brand",
"光影行星",
"--output",
str(output),
]
with patch.object(
analyze_note, "load_note_from_db", return_value=(info, [])
), patch.object(
analyze_note, "resolve_layer_output", return_value=output
), patch.object(sys, "argv", argv):
result = analyze_note.main()
self.assertEqual(result, 0)
self.assertTrue(output.exists())
report = output.read_text(encoding="utf-8-sig")
self.assertIn("无有效评论可分析", report)
self.assertIn("1,000", report)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,11 @@
from gyxx_flow.modules.content_marketing.data.tools import (
batch_rescrape_douyin as batch,
)
def test_result_logged_in_reads_nested_scraper_stats():
result = {"stats": {"logged_in": True}}
assert batch.result_logged_in(result) is True
assert batch.result_logged_in({"stats": {"logged_in": False}}) is False
assert batch.result_logged_in({}) is False
@@ -0,0 +1,253 @@
import pytest
from gyxx_flow.core.exit_codes import COOKIE_SKIP_EXIT_CODE
from gyxx_flow.modules.content_marketing import chanmama_scraper as chanmama
def test_exposure_state_clicks_text_and_zero_but_keeps_positive_values():
for value in ("更新数据", " 更新中 ", "暂无数据", "待更新", "0", "0.0", 0):
assert chanmama._classify_exposure_text(value) == "refresh"
for value in (23000, "23,000", "10w+", "8.8万+", ""):
assert chanmama._classify_exposure_text(value) == "ready"
def test_refresh_then_export_waits_once_after_all_accounts(monkeypatch):
events = []
urls = ["account-1", "account-2"]
monkeypatch.setattr(chanmama, "navigate_to_target", lambda _driver, url: events.append(("navigate", url)))
results = iter([
{"clicked": 2, "pending": 0, "zero_without_button": 0, "pages": 2},
{"clicked": 0, "pending": 1, "zero_without_button": 0, "pages": 1},
])
monkeypatch.setattr(chanmama, "refresh_zero_exposure_videos", lambda _driver: next(results))
monkeypatch.setattr(chanmama.time, "sleep", lambda seconds: events.append(("sleep", seconds)))
monkeypatch.setattr(chanmama, "_max_excel_mtime", lambda: 123.0)
monkeypatch.setattr(chanmama, "export_video_data", lambda _driver, mtime: events.append(("export", mtime)) or "x.xlsx")
monkeypatch.setattr(chanmama, "parse_chanmama_excel", lambda _path: ([{"title": "video"}], None))
records, summaries = chanmama.refresh_then_export_accounts(object(), urls, wait_seconds=600)
assert len(records) == 2
assert [item["clicked"] for item in summaries] == [2, 0]
assert events == [
("navigate", "account-1"), ("navigate", "account-2"), ("sleep", 600),
("navigate", "account-1"), ("export", 123.0),
("navigate", "account-2"), ("export", 123.0),
]
def test_refresh_then_export_skips_wait_when_nothing_needs_refresh(monkeypatch):
sleeps = []
monkeypatch.setattr(chanmama, "navigate_to_target", lambda *_args: None)
monkeypatch.setattr(chanmama, "refresh_zero_exposure_videos", lambda _driver: {
"clicked": 0, "pending": 0, "zero_without_button": 0, "pages": 1,
})
monkeypatch.setattr(chanmama.time, "sleep", sleeps.append)
monkeypatch.setattr(chanmama, "_max_excel_mtime", lambda: 0.0)
monkeypatch.setattr(chanmama, "export_video_data", lambda *_args: None)
records, _ = chanmama.refresh_then_export_accounts(object(), ["account-1"], wait_seconds=600)
assert records == []
assert sleeps == []
class _RefreshElement:
def __init__(self, text="", css_class="", element_id="node"):
self.text = text
self.css_class = css_class
self.id = element_id
def is_displayed(self):
return True
def get_attribute(self, name):
return self.css_class if name == "class" else None
def click(self):
return None
class _RefreshDriver:
def __init__(self, nodes=None):
self.nodes = list(nodes or [])
def find_elements(self, _by, selector):
return self.nodes if selector == "td .cursor-pointer" else []
def test_next_page_button_keeps_the_source_xpath_fallback():
expected = "//span[normalize-space()='下一页']/ancestor::button[1]"
button = _RefreshElement("下一页", element_id="next")
class Driver:
def find_elements(self, _by, selector):
return [button] if selector == expected else []
assert chanmama._find_next_page_button(Driver()) is button
def test_find_get_data_targets_selects_actual_cursor_pointer_only():
outer = _RefreshElement("获取数据", "detail-text", "outer")
clickable = _RefreshElement("获取数据", "cp cursor-pointer", "inner")
other = _RefreshElement("查看详情", "cursor-pointer", "other")
assert chanmama._find_get_data_targets(_RefreshDriver([outer, clickable, other])) == [clickable]
def test_refresh_zero_exposure_videos_walks_all_pages(monkeypatch):
class Button:
def __init__(self, disabled):
self.disabled = disabled
class Driver:
page = 0
def execute_script(self, _script, _element):
self.page += 1
driver = Driver()
page_results = [
{"clicked": 2, "pending": 1, "zero_without_button": 0},
{"clicked": 1, "pending": 0, "zero_without_button": 1},
]
monkeypatch.setattr(
chanmama, "_page_signature", lambda current: f"page-{current.page}"
)
monkeypatch.setattr(
chanmama,
"_refresh_current_page",
lambda current: page_results[current.page],
)
monkeypatch.setattr(
chanmama,
"_find_next_page_button",
lambda current: Button(disabled=current.page == 1),
)
monkeypatch.setattr(
chanmama,
"_pagination_element_disabled",
lambda button: button.disabled,
)
monkeypatch.setattr(chanmama.time, "sleep", lambda _seconds: None)
assert chanmama.refresh_zero_exposure_videos(driver) == {
"clicked": 3,
"pending": 1,
"zero_without_button": 1,
"unconfirmed": 0,
"pages": 2,
}
def test_missing_get_data_button_does_not_trigger_refresh_wait(monkeypatch):
sleeps = []
monkeypatch.setattr(chanmama, "navigate_to_target", lambda *_args: None)
monkeypatch.setattr(chanmama, "refresh_zero_exposure_videos", lambda _driver: {
"clicked": 0, "pending": 0, "zero_without_button": 1, "pages": 1,
})
monkeypatch.setattr(chanmama.time, "sleep", sleeps.append)
monkeypatch.setattr(chanmama, "_max_excel_mtime", lambda: 0.0)
monkeypatch.setattr(chanmama, "export_video_data", lambda *_args: None)
chanmama.refresh_then_export_accounts(object(), ["account-1"], wait_seconds=600)
assert sleeps == []
def test_main_reuses_profile_before_cookie_file_or_credentials(monkeypatch):
events = []
class Driver:
def quit(self):
events.append("quit")
monkeypatch.setattr(chanmama, "HAS_SELENIUM", True)
monkeypatch.setattr(chanmama.sys, "argv", ["chanmama_scraper.py"])
monkeypatch.setattr(chanmama, "create_driver", lambda: Driver())
monkeypatch.setattr(chanmama, "verify_login", lambda _driver: True)
monkeypatch.setattr(chanmama, "save_cookies", lambda _driver: events.append("saved"))
monkeypatch.setattr(
chanmama,
"load_cookies",
lambda _driver: (_ for _ in ()).throw(AssertionError("profile must win")),
)
monkeypatch.setattr(
chanmama,
"login",
lambda _driver: (_ for _ in ()).throw(AssertionError("profile must win")),
)
monkeypatch.setattr(
chanmama,
"refresh_then_export_accounts",
lambda *_args, **_kwargs: ([], []),
)
assert chanmama.main() == 0
assert events == ["saved", "quit"]
def test_acceptance_skips_when_profile_cookie_and_credentials_fail(
monkeypatch,
tmp_path,
):
evidence = tmp_path / "evidence.jsonl"
class Driver:
def quit(self):
pass
monkeypatch.setenv("GYXX_WORKFLOW_ACCEPTANCE", "1")
monkeypatch.setenv("GYXX_ACCEPTANCE_EVIDENCE_FILE", str(evidence))
monkeypatch.setattr(chanmama, "HAS_SELENIUM", True)
monkeypatch.setattr(chanmama.sys, "argv", ["chanmama_scraper.py"])
monkeypatch.setattr(chanmama, "ACCOUNT", "")
monkeypatch.setattr(chanmama, "PASSWORD", "")
monkeypatch.setattr(chanmama, "create_driver", lambda: Driver())
monkeypatch.setattr(chanmama, "verify_login", lambda _driver: False)
monkeypatch.setattr(chanmama, "load_cookies", lambda _driver: False)
monkeypatch.setattr(
chanmama,
"login",
lambda _driver: (_ for _ in ()).throw(AssertionError("must not login")),
)
monkeypatch.setattr(
chanmama,
"refresh_then_export_accounts",
lambda *_args, **_kwargs: (_ for _ in ()).throw(
AssertionError("must not scrape")
),
)
assert chanmama.main() == COOKIE_SKIP_EXIT_CODE
assert "profile/cookie session is invalid" in evidence.read_text(encoding="utf-8")
def test_acceptance_captcha_is_non_interactive(monkeypatch, tmp_path):
evidence = tmp_path / "evidence.jsonl"
class Element:
def is_displayed(self):
return True
class Driver:
current_url = "https://www.chanmama.com/login.html"
page_source = ""
def find_element(self, *_args):
return Element()
monkeypatch.setenv("GYXX_WORKFLOW_ACCEPTANCE", "1")
monkeypatch.setenv("GYXX_ACCEPTANCE_EVIDENCE_FILE", str(evidence))
monkeypatch.setattr(chanmama.time, "sleep", lambda _seconds: None)
assert chanmama.handle_captcha(Driver()) is False
assert "captcha or interactive verification" in evidence.read_text(
encoding="utf-8"
)
def test_cli_preserves_cookie_skip_exit_code(monkeypatch):
monkeypatch.setattr(chanmama, "main", lambda: COOKIE_SKIP_EXIT_CODE)
with pytest.raises(SystemExit) as raised:
chanmama.cli()
assert raised.value.code == COOKIE_SKIP_EXIT_CODE
@@ -0,0 +1,840 @@
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
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_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()
pgy._ensure_search_results(page, query="xhs-id-123", result_name="达人昵称", max_retries=2)
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_default_creator_batch_has_no_forced_hour_pause():
assert pgy.DEFAULT_BATCH_SIZE == 0
assert xingtu.DEFAULT_BATCH_SIZE == 0
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_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_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():
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)
page.callback(Response())
assert [card["note_id"] for card in cards] == ["1", "2"]
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 not pgy.is_browser_session_lost(RuntimeError("ordinary parse failure"))
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"]
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
@@ -0,0 +1,157 @@
from __future__ import annotations
import json
from pathlib import Path
from types import SimpleNamespace
from gyxx_flow.adapters import WORKFLOW_ACCEPTANCE_RECIPIENT_OPEN_ID
from gyxx_flow.modules.content_marketing import chanmama_scraper, weekly_summary_all
from gyxx_flow.modules.content_marketing.data.tools import (
analyze_comments,
analyze_note,
daily_report_card,
friday_relogin_parallel,
)
def _enable_acceptance(monkeypatch, tmp_path: Path) -> Path:
evidence = tmp_path / "content-evidence.jsonl"
monkeypatch.setenv("GYXX_WORKFLOW_ACCEPTANCE", "1")
monkeypatch.setenv("GYXX_ACCEPTANCE_EVIDENCE_FILE", str(evidence))
monkeypatch.delenv("GYXX_NOTIFICATION_RECIPIENT_OPEN_ID", raising=False)
return evidence
def test_content_write_back_skips_before_lark_cli(
monkeypatch,
tmp_path: Path,
) -> None:
evidence = _enable_acceptance(monkeypatch, tmp_path)
def fail_if_called(*args, **kwargs):
raise AssertionError("lark-cli wrapper must not be called")
monkeypatch.setattr(chanmama_scraper, "_call_lark_json", fail_if_called)
assert chanmama_scraper._write_back(
"base_secret", "tbl_test", "rec_test", "fld_test", 123
)
payload = json.loads(evidence.read_text(encoding="utf-8"))
assert payload["operation"].endswith("record-upsert")
def test_weekly_summary_skips_table_upsert_but_reports_success(
monkeypatch,
tmp_path: Path,
) -> None:
_enable_acceptance(monkeypatch, tmp_path)
monkeypatch.setattr(
weekly_summary_all,
"PATHS",
SimpleNamespace(tmp_root=tmp_path),
)
def fail_if_called(*args, **kwargs):
raise AssertionError("weekly lark-cli wrapper must not be called")
monkeypatch.setattr(weekly_summary_all, "call_lark_json", fail_if_called)
assert weekly_summary_all.write_to_target_table(
"base_secret",
"tbl_test",
"fld_time",
"fld_summary",
"style-test",
"https://example.test/doc",
False,
)
def test_daily_card_sends_only_to_wang_yunlong(
monkeypatch,
tmp_path: Path,
) -> None:
_enable_acceptance(monkeypatch, tmp_path)
commands = []
def fake_run(command, **kwargs):
commands.append(command)
return SimpleNamespace(
returncode=0,
stdout=json.dumps({"message_id": "om_test"}),
stderr="",
)
monkeypatch.setattr(daily_report_card.subprocess, "run", fake_run)
results = daily_report_card.send_card_to_recipients(
{"schema": "2.0", "body": {"elements": []}},
("ou_other_a", "ou_other_b"),
"2026-08-01",
)
assert len(results) == 1
assert len(commands) == 1
command = commands[0]
assert command[command.index("--user-id") + 1] == WORKFLOW_ACCEPTANCE_RECIPIENT_OPEN_ID
def test_comment_summary_sends_only_to_wang_yunlong(
monkeypatch,
tmp_path: Path,
) -> None:
_enable_acceptance(monkeypatch, tmp_path)
commands = []
def fake_run(command, **kwargs):
commands.append(command)
return SimpleNamespace(returncode=0, stdout="{}", stderr="")
monkeypatch.setattr(analyze_comments.subprocess, "run", fake_run)
analyze_comments.send_feishu_summary("summary", "ou_other")
command = commands[0]
assert command[command.index("--user-id") + 1] == WORKFLOW_ACCEPTANCE_RECIPIENT_OPEN_ID
def test_note_report_sends_only_to_wang_yunlong(
monkeypatch,
tmp_path: Path,
) -> None:
_enable_acceptance(monkeypatch, tmp_path)
commands = []
monkeypatch.setattr(analyze_note.sys, "platform", "linux")
monkeypatch.setattr(
analyze_note,
"build_card_payload",
lambda *args, **kwargs: {"schema": "2.0"},
)
def fake_run(command, **kwargs):
commands.append(command)
return SimpleNamespace(returncode=0, stdout="{}", stderr="")
monkeypatch.setattr(analyze_note.subprocess, "run", fake_run)
analyze_note.send_feishu_report(
{},
{},
{},
[],
[],
"analysis",
"ou_other",
tmp_path / "report.md",
)
command = commands[0]
assert command[command.index("--user-id") + 1] == WORKFLOW_ACCEPTANCE_RECIPIENT_OPEN_ID
def test_relogin_notifications_resolve_only_to_wang_yunlong(
monkeypatch,
tmp_path: Path,
) -> None:
_enable_acceptance(monkeypatch, tmp_path)
assert (
friday_relogin_parallel.recipient_for_platform("pgy", "ou_other")
== WORKFLOW_ACCEPTANCE_RECIPIENT_OPEN_ID
)
@@ -0,0 +1,43 @@
from __future__ import annotations
from pathlib import Path
from gyxx_flow.modules.content_marketing import run_all
def test_run_all_rebinds_browser_environment_to_child_entry(monkeypatch) -> None:
observed: dict[str, object] = {}
def fake_rebind(target, base_environment):
observed["target"] = Path(target)
observed["base"] = dict(base_environment)
return {
**base_environment,
"GYXX_SCRIPT_ID": "content_marketing:pgy_xhs_scraper_v2.py",
"GYXX_BROWSER_CDP_PORT": "child-port",
"GYXX_BROWSER_PROFILE_DIR": "child-profile",
"GYXX_BROWSER_COOKIE_FILE": "child-cookie",
"GYXX_BROWSER_STORAGE_STATE_FILE": "child-storage",
}
monkeypatch.setattr(run_all, "environment_for_child_script", fake_rebind)
target = run_all.BASE_DIR / "pgy_xhs_scraper_v2.py"
environment = run_all.build_child_environment(
target,
{
"KEEP": "yes",
"GYXX_BROWSER_CDP_PORT": "parent-port",
"GYXX_BROWSER_PROFILE_DIR": "parent-profile",
},
)
assert observed["target"] == target
assert observed["base"]["GYXX_BROWSER_CDP_PORT"] == "parent-port"
assert environment["KEEP"] == "yes"
assert environment["GYXX_BROWSER_CDP_PORT"] == "child-port"
assert environment["GYXX_BROWSER_PROFILE_DIR"] == "child-profile"
assert environment["GYXX_BROWSER_COOKIE_FILE"] == "child-cookie"
assert environment["GYXX_BROWSER_STORAGE_STATE_FILE"] == "child-storage"
assert environment["PYTHONIOENCODING"] == "utf-8"
assert environment["PYTHONUNBUFFERED"] == "1"
@@ -0,0 +1,100 @@
from argparse import Namespace
from datetime import date
import pytest
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 xingtu_scraper_v2 as xingtu
from gyxx_flow.modules.content_marketing.daily_creator_exposure_scope import (
DEFAULT_MAX_AGE_DAYS,
DEFAULT_PUBLISHED_FROM,
EXCLUDED_STYLE_NAMES,
classify_publish_scope,
select_daily_styles,
)
def test_daily_scope_excludes_requested_styles():
styles = [
{"index": 1, "name": "保留款"},
{"index": 2, "name": "盖亚微单"},
{"index": 3, "name": "逐星GT"},
{"index": 4, "name": "晨星2"},
{"index": 5, "name": "觅光"},
]
assert [style["name"] for style in select_daily_styles(styles)] == ["保留款"]
assert EXCLUDED_STYLE_NAMES == frozenset({"盖亚微单", "逐星GT", "晨星2", "觅光"})
def test_publish_scope_starts_on_july_first_and_stops_at_30_days():
collected_on = date(2026, 7, 31)
assert classify_publish_scope(
date(2026, 6, 30),
collected_on,
published_from=DEFAULT_PUBLISHED_FROM,
max_age_days=DEFAULT_MAX_AGE_DAYS,
) == "before_publish_cutoff"
assert classify_publish_scope(
date(2026, 7, 1),
collected_on,
published_from=DEFAULT_PUBLISHED_FROM,
max_age_days=DEFAULT_MAX_AGE_DAYS,
) == "collection_window_complete"
assert classify_publish_scope(
date(2026, 7, 2),
collected_on,
published_from=DEFAULT_PUBLISHED_FROM,
max_age_days=DEFAULT_MAX_AGE_DAYS,
) is None
def test_run_all_daily_scope_passes_filters_and_no_retry_to_children():
args = Namespace(dry_run=False, style=[1, 2], daily_scope=True, no_retry=False)
command = run_all.build_process_command("pgy", args)
assert "--skip-field-prepare" in command
assert command[command.index("--published-from") + 1] == "2026-07-01"
assert command[command.index("--max-age-days") + 1] == "30"
assert "--no-retry" in command
@pytest.mark.parametrize(
("module", "platform", "url"),
[
(pgy, "小红书", "https://www.xiaohongshu.com/explore/eligible"),
(xingtu, "抖音", "https://www.douyin.com/video/eligible"),
],
)
def test_platform_task_extraction_applies_daily_publish_window(
module, platform, url, monkeypatch
):
style = {
"index": 1,
"name": "保留款",
"base_token": "base",
"table_id": "table",
"field_map": {
"creator_name": {"field_id": "creator"},
"note_title": {"field_id": "title"},
"publish_time": {"field_id": "published"},
"note_url": {"field_id": "url"},
"platform": {"field_id": "platform"},
"daily_exposure": {"field_id": "daily", "field_name": "2026-07-29曝光量"},
},
}
base = {"creator": "达人", "title": "标题", "url": url, "platform": platform}
records = [
{"record_id": "old", **base, "published": "2026-06-30"},
{"record_id": "eligible", **base, "published": "2026-07-02"},
{"record_id": "missing", **base, "published": None},
]
monkeypatch.setattr(module, "list_records_by_table", lambda *_args: records)
tasks = module.extract_target_tasks(
style,
published_from=date(2026, 7, 1),
max_age_days=30,
collection_date=date(2026, 7, 29),
)
assert [task["record_id"] for task in tasks] == ["eligible"]
assert style["_extract_stats"]["skipped_publish_scope"] == 1
assert style["_extract_stats"]["skipped_incomplete"] == 1
@@ -0,0 +1,105 @@
import unittest
from datetime import date
from gyxx_flow.modules.content_marketing.data.tools.daily_dashboard_analytics import (
build_dashboard_facts,
)
class DailyDashboardAnalyticsTests(unittest.TestCase):
def setUp(self):
self.tracking = [
{"style_name": "大流量", "visitors": 1000, "cart_users": 100, "sales": 20, "refund_orders": 2, "light": "🟡"},
{"style_name": "高转化", "visitors": 100, "cart_users": 30, "sales": 20, "refund_orders": 8, "light": "🔴"},
{"style_name": "极小样本", "visitors": 10, "cart_users": 5, "sales": 10, "refund_orders": 0, "light": "🔴"},
{"style_name": "缺数据", "visitors": None, "cart_users": None, "sales": None, "refund_orders": None, "light": "无判断"},
]
self.platform = [
{"style_name": "大流量", "platform": "天猫", "visitors": 900, "cart_users": 90, "sales": 18, "refund_orders": 2},
{"style_name": "高转化", "platform": "天猫", "visitors": 100, "cart_users": 30, "sales": 20, "refund_orders": 8},
{"style_name": "极小样本", "platform": "天猫", "visitors": 10, "cart_users": 5, "sales": 10, "refund_orders": 0},
]
self.personas = [
{
"style_name": "大流量", "platform": "天猫", "data_date": date(2026, 7, 15),
"gender_top": "女性 60%", "age_top": "26-35岁 45%",
"city_top": "一线城市 35%", "buying_power_top": "高级白领 40%",
}
]
def test_all_styles_receive_same_metrics_and_diagnosis(self):
facts = build_dashboard_facts(
"2026-07-15", self.tracking, self.platform, self.personas,
{"大流量": {"creator_count": 3, "directions": ["通勤"]}},
)
self.assertEqual(len(facts["styles"]), 4)
high = next(row for row in facts["styles"] if row["style_name"] == "高转化")
self.assertEqual(high["conversion_rate"], 0.2)
self.assertEqual(high["cart_rate"], 0.3)
self.assertEqual(high["refund_rate"], 0.4)
missing = next(row for row in facts["styles"] if row["style_name"] == "缺数据")
self.assertEqual(missing["diagnosis"], "无数据")
def test_top_conversion_excludes_tiny_visitor_base(self):
facts = build_dashboard_facts("2026-07-15", self.tracking, self.platform, self.personas, {})
self.assertEqual(facts["rankings"]["conversion"][0]["style_name"], "高转化")
self.assertEqual(facts["rankings"]["visitors"][0]["style_name"], "大流量")
self.assertEqual(facts["rankings"]["refund_rate"][0]["style_name"], "高转化")
def test_platform_winners_keep_missing_persona_explicit(self):
facts = build_dashboard_facts("2026-07-15", self.tracking, self.platform, self.personas, {})
card = facts["platform_personas"][0]
self.assertEqual(card["platform"], "天猫")
self.assertEqual(card["top_conversion"]["style_name"], "高转化")
self.assertEqual(card["top_conversion"]["persona_text"], "无数据")
self.assertEqual(card["top_visitors"]["style_name"], "大流量")
self.assertIn("推测", card["top_visitors"]["interest_hypothesis"])
self.assertIn("提转化", card["top_visitors"]["optimization"])
def test_daily_new_notes_overview_replaces_view_delta_and_creator_ranking(self):
notes = [{"note_id": 7, "style_name": "大流量", "creator": "达人A", "platform": "小红书", "view_count": 500}]
facts = build_dashboard_facts(
"2026-07-15", self.tracking, self.platform, self.personas, {}, daily_notes=notes
)
self.assertEqual(facts["daily_new_notes"], notes)
self.assertNotIn("creator_connections", facts["rankings"])
self.assertNotIn("daily_views", facts["rankings"])
def test_daily_view_ranking_and_platform_cards_receive_stored_images(self):
images = {
("高转化", "天猫"): {"local_image_path": "same.jpg", "image_platform": "天猫"},
("大流量", "天猫"): {"local_image_path": "visitor.jpg", "image_platform": "天猫"},
}
facts = build_dashboard_facts(
"2026-07-15", self.tracking, self.platform, self.personas, {},
style_images=images,
)
card = facts["platform_personas"][0]
self.assertEqual(card["top_conversion"]["local_image_path"], "same.jpg")
self.assertEqual(card["top_visitors"]["local_image_path"], "visitor.jpg")
def test_matrix_groups_by_style_category_without_total_exposure(self):
signals = {
"高转化": {"persona": True, "note_count": 3, "recent_note_exposure": 43000, "comment_count": 80,
"review_count": 10, "review_negative_rate": 0.3, "creative_ctr": 0.08},
}
facts = build_dashboard_facts(
"2026-07-15", self.tracking, self.platform, self.personas, {},
style_categories={
"大流量": "都市机能", "高转化": "智性通勤",
"极小样本": "智性通勤", "缺数据": "都市机能",
},
style_signals=signals,
)
self.assertEqual([group["style_category"] for group in facts["style_groups"]], ["都市机能", "智性通勤"])
self.assertEqual([row["style_name"] for row in facts["style_groups"][0]["styles"]], ["大流量", "缺数据"])
high = next(row for row in facts["styles"] if row["style_name"] == "高转化")
self.assertNotIn("total_note_exposure", high)
self.assertEqual(high["recent_note_exposure"], 43000)
self.assertNotIn("total_note_exposure", facts["definitions"])
self.assertIn("评价风险", high["diagnosis"])
self.assertIn("画像", high["evidence_summary"])
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,127 @@
import copy
import json
from datetime import date, datetime
from pathlib import Path
from gyxx_flow.modules.content_marketing import bilibili_scraper as bili
from gyxx_flow.modules.content_marketing import feishu_mapping
from gyxx_flow.modules.content_marketing import pgy_xhs_scraper_v2 as pgy
from gyxx_flow.modules.content_marketing import xingtu_scraper_v2 as xingtu
TARGET_DATE = date(2026, 7, 28)
TARGET_NAME = "2026-07-28曝光量"
def _mapping():
return {"tables": [{"index": 1, "name": "款式A", "base_token": "base-a", "table_id": "table-a", "field_map": {}, "all_field_names": []}]}
def test_daily_exposure_field_name_uses_collection_date():
assert feishu_mapping.daily_exposure_field_name(TARGET_DATE) == TARGET_NAME
def test_build_field_map_only_maps_exact_requested_daily_field():
fmap, names = feishu_mapping.build_field_map([
{"id": "old", "name": "2026-07-27曝光量"}, {"id": "today", "name": TARGET_NAME},
], target_date=TARGET_DATE)
assert fmap["daily_exposure"] == {"field_id": "today", "field_name": TARGET_NAME}
assert names == ["2026-07-27曝光量", TARGET_NAME]
def test_ensure_daily_field_reuses_existing_field(monkeypatch):
mapping = _mapping()
monkeypatch.setattr(feishu_mapping, "_field_list", lambda *_: [{"id": "field-today", "name": TARGET_NAME}])
calls = []
monkeypatch.setattr(feishu_mapping, "call_lark_json", lambda args: calls.append(args) or {"ok": True})
result = feishu_mapping.ensure_daily_exposure_fields(mapping, target_date=TARGET_DATE, write=False)
assert result["ok"] is True and result["created"] == [] and calls == []
assert mapping["tables"][0]["field_map"]["daily_exposure"]["field_id"] == "field-today"
def test_ensure_daily_field_creates_number_field_then_refreshes(monkeypatch):
mapping = _mapping()
fields = iter([[], [{"id": "new-field", "name": TARGET_NAME}]])
monkeypatch.setattr(feishu_mapping, "_field_list", lambda *_: next(fields))
calls = []
monkeypatch.setattr(feishu_mapping, "call_lark_json", lambda args: calls.append(args) or {"ok": True})
result = feishu_mapping.ensure_daily_exposure_fields(mapping, target_date=TARGET_DATE, write=False)
assert result["ok"] is True and result["created"] == ["款式A"]
payload = calls[0][calls[0].index("--json") + 1]
assert TARGET_NAME in payload and '"type": "number"' in payload
def test_ensure_daily_field_dry_run_does_not_call_feishu(monkeypatch):
mapping = _mapping()
monkeypatch.setattr(feishu_mapping, "_field_list", lambda *_: (_ for _ in ()).throw(AssertionError("must not list")))
monkeypatch.setattr(feishu_mapping, "call_lark_json", lambda *_: (_ for _ in ()).throw(AssertionError("must not create")))
result = feishu_mapping.ensure_daily_exposure_fields(mapping, target_date=TARGET_DATE, dry_run=True, write=False)
assert result["ok"] is True
assert mapping["tables"][0]["field_map"]["daily_exposure"]["field_id"].startswith("dryrun:")
def test_acceptance_skips_field_create_and_keeps_sentinel_in_memory(
monkeypatch,
tmp_path: Path,
):
evidence = tmp_path / "evidence.jsonl"
monkeypatch.setenv("GYXX_WORKFLOW_ACCEPTANCE", "1")
monkeypatch.setenv("GYXX_ACCEPTANCE_EVIDENCE_FILE", str(evidence))
mapping = _mapping()
monkeypatch.setattr(feishu_mapping, "_field_list", lambda *_: [])
monkeypatch.setattr(
feishu_mapping,
"call_lark_json",
lambda *_: (_ for _ in ()).throw(AssertionError("must not create")),
)
result = feishu_mapping.ensure_daily_exposure_fields(
mapping,
target_date=TARGET_DATE,
write=False,
)
assert result["ok"] is True
assert result["created"] == []
assert result["skipped_creations"] == ["款式A"]
assert mapping["tables"][0]["field_map"]["daily_exposure"][
"field_id"
].startswith("acceptance-skipped:")
payload = json.loads(evidence.read_text(encoding="utf-8"))
assert payload["operation"] == "content.mapping.daily-exposure-field-create"
def test_acceptance_hydrates_cached_mapping_without_persisting_sentinel(
monkeypatch,
tmp_path: Path,
):
monkeypatch.setenv("GYXX_WORKFLOW_ACCEPTANCE", "1")
cache = tmp_path / "mapping.json"
cache.write_text(json.dumps(_mapping(), ensure_ascii=False), encoding="utf-8")
hydrated = feishu_mapping._hydrate_acceptance_daily_field(
json.loads(cache.read_text(encoding="utf-8")),
self_operated=False,
)
assert hydrated["tables"][0]["field_map"]["daily_exposure"][
"field_id"
].startswith("acceptance-skipped:")
persisted = json.loads(cache.read_text(encoding="utf-8"))
assert "daily_exposure" not in persisted["tables"][0]["field_map"]
def test_collaboration_slot_pickers_use_daily_field_without_publish_time():
fmap = {"daily_exposure": {"field_id": "today-field", "field_name": TARGET_NAME}}
expected = ("daily_exposure", "today-field", TARGET_NAME)
assert pgy.pick_read_field(fmap, None, datetime(2026, 7, 28)) == expected
assert xingtu.pick_read_field(fmap, None, datetime(2026, 7, 28)) == expected
def test_self_operated_mapping_removes_daily_field(monkeypatch):
source = {"tables": [{"field_map": {
"daily_exposure": {"field_id": "daily", "field_name": TARGET_NAME},
"read_count_7d": {"field_id": "s7", "field_name": "7天曝光量"},
}}]}
monkeypatch.setattr(feishu_mapping, "load_mapping", lambda *_args, **_kwargs: copy.deepcopy(source))
for mapping in (pgy.load_mapping(True), xingtu.load_mapping(True), bili.load_mapping(bili.DEFAULT_DATA_DIR, True)):
assert "daily_exposure" not in mapping["tables"][0]["field_map"]
@@ -0,0 +1,442 @@
import unittest
from datetime import date
from unittest.mock import patch
from gyxx_flow.modules.content_marketing import daily_marketing_report as daily
from gyxx_flow.modules.content_marketing.data.tools import analyze_comments
class DailyMarketingReportTests(unittest.TestCase):
def test_hermes_url_accepts_api_base_or_full_completion_path(self):
full_url = "http://127.0.0.1:8642/v1/chat/completions"
self.assertEqual(
analyze_comments._chat_completions_url("http://127.0.0.1:8642/v1"),
full_url,
)
self.assertEqual(
analyze_comments._chat_completions_url(full_url),
full_url,
)
def test_sales_forecast_uses_seven_day_total_and_recent_three_day_velocity(self):
forecast = daily.estimate_sales_forecast([10, 12, 14, 16, 18, 20, 22])
self.assertEqual(forecast["sales_7d_total"], 112)
self.assertEqual(forecast["sales_forecast_7d"], 140)
self.assertAlmostEqual(forecast["sales_forecast_change"], 7 / 13, places=4)
def test_sales_forecast_requires_complete_seven_day_history(self):
self.assertEqual(
daily.estimate_sales_forecast([10, 12, 14]),
{
"sales_7d_total": None,
"sales_forecast_7d": None,
"sales_forecast_change": None,
},
)
def test_default_recipients_include_all_four_daily_report_owners(self):
self.assertEqual(
daily.DEFAULT_RECIPIENT_OPEN_IDS,
(
"ou_7ad5fc8012e2f741afc5346e05ffd447",
"ou_fa8d81a16527ad06352dbecc575285b8",
"ou_2eda5eec112109ae6d19a1f6813eadcb",
"ou_89bcff110ccbb09a23548dc0fb3d880c",
),
)
def test_hermes_transport_error_is_not_accepted_as_report(self):
with self.assertRaisesRegex(RuntimeError, "Hermes 分析失败"):
daily.require_valid_hermes_report("API call failed after 3 retries: Connection error.")
self.assertEqual(daily.require_valid_hermes_report("整体经营分析\n销量保持稳定"), "整体经营分析\n销量保持稳定")
def test_light_uses_highest_absolute_visitor_or_cart_change(self):
self.assertEqual(daily.classify_light(0.05, -0.08), "🟢")
self.assertEqual(daily.classify_light(0.12, 0.02), "🟡")
self.assertEqual(daily.classify_light(-0.31, 0.01), "🔴")
self.assertEqual(daily.classify_light(None, None), "无判断")
def test_comment_signal_requires_minimum_sample(self):
comments = ["光影行星宙斯怎么买"] * 49
self.assertIsNone(daily.calculate_comment_signal("宙斯", comments))
comments.append("好看")
signal = daily.calculate_comment_signal("宙斯", comments)
self.assertEqual(signal["sample_size"], 50)
self.assertEqual(signal["brand_mention_rate"], 0.98)
self.assertEqual(signal["purchase_intent_rate"], 0.98)
def test_prompt_uses_only_the_fresh_user_instruction_and_database_facts(self):
prompt = daily.build_daily_prompt(
report_date="2026-07-15",
today_notes=[],
tracking_rows=[],
next_day_rows=[],
comment_rows=[],
)
self.assertIn("以下为数据库查询结果", prompt)
self.assertIn('"报告日期": "2026-07-15"', prompt)
self.assertIn("增长型SKU", daily.SYSTEM_PROMPT)
self.assertIn("流量机会型SKU", daily.SYSTEM_PROMPT)
self.assertIn("潜力型SKU", daily.SYSTEM_PROMPT)
self.assertIn("风险型SKU", daily.SYSTEM_PROMPT)
self.assertNotIn("A类:爆款增长SKU", daily.SYSTEM_PROMPT)
def test_prompt_uses_fashion_bag_data_analyst_role(self):
self.assertIn("专业的时尚包袋品牌电商运营数据分析师", daily.SYSTEM_PROMPT)
self.assertIn("销售、电商运营及达人营销数据", daily.SYSTEM_PROMPT)
self.assertIn("发现增长机会和风险", daily.SYSTEM_PROMPT)
self.assertIn("SKU营销运营日报", daily.SYSTEM_PROMPT)
self.assertIn("展示关键数据", daily.SYSTEM_PROMPT)
def test_fresh_prompt_contains_the_complete_requested_analysis_structure(self):
prompt = daily.SYSTEM_PROMPT
for phrase in (
"今日整体经营判断",
"SKU经营表现分析",
"达人营销效果分析",
"产品节奏判断",
"蓄水期",
"放量期",
"稳定期",
"衰退期",
"增长SKU TOP3",
"风险SKU TOP3",
"比较昨日、七日基准",
"用图表展示",
"曝光量",
"建议动作",
"对销量影响",
"所有建议必须具体到SKU",
):
self.assertIn(phrase, prompt)
for retired in ("A类:爆款增长SKU", "B类:潜力增长SKU", "D类:需要优化SKU"):
self.assertNotIn(retired, prompt)
def test_database_payload_keeps_all_available_sources(self):
prompt = daily.build_daily_prompt("2026-07-15", [], [], [], [], {"reviews": [{"sample_size": 3}]})
for phrase in ("所有SKU电商运营数据", "达人营销内容数据", "评论与补充经营数据", "reviews"):
self.assertIn(phrase, prompt)
def test_note_label_ignores_placeholder_creator_name(self):
note = {"id": 7, "creator_name": "/", "title": "瑞白通勤包实测"}
self.assertEqual(daily.note_label(note), "瑞白通勤包实测")
def test_cooperation_metrics_hide_unverifiable_engagement(self):
invalid = daily._sanitize_cooperation_row({
"publish_time": None,
"exposure_count": 38000,
"engagement_count_num": 21430000,
})
self.assertIsNone(invalid["engagement_count_num"])
self.assertEqual(invalid["publish_status"], "发布时间无数据")
self.assertIn("口径异常", invalid["engagement_data_status"])
valid = daily._sanitize_cooperation_row({
"publish_time": "2026-07-14",
"exposure_count": 38000,
"engagement_count_num": 2143,
})
self.assertEqual(valid["engagement_count_num"], 2143)
self.assertEqual(valid["publish_status"], "已记录发布时间")
def test_load_report_facts_tracks_all_styles_for_report_date(self):
metrics = {
"宙斯": {"light": "🟢"},
}
with patch.object(daily, "_note_rows", return_value=[]), \
patch.object(daily, "_all_style_names", return_value=["宙斯", "蓝鹊"]) as all_styles, \
patch.object(daily, "_style_metrics", return_value=metrics) as style_metrics:
with patch.object(daily, "_load_enrichment", return_value={"platform_metrics": []}), \
patch.object(daily, "_platform_metrics", return_value=[]), \
patch.object(daily, "_persona_context", return_value=[]), \
patch.object(daily, "load_creator_connections", return_value={}), \
patch.object(daily, "load_style_images", return_value={}), \
patch.object(daily, "load_style_categories", return_value={}), \
patch.object(daily, "_cooperation_context", return_value=[]), \
patch.object(daily, "_style_comment_context", return_value=[]), \
patch.object(daily, "_review_context", return_value=[]), \
patch.object(daily, "_creative_context", return_value=[]), \
patch.object(daily, "build_dashboard_facts", return_value={"styles": ["宙斯", "蓝鹊"]}):
_, tracking, _, _, enrichment = daily.load_report_facts(date(2026, 7, 15))
all_styles.assert_called_once_with(date(2026, 7, 15))
self.assertEqual([row["style_name"] for row in tracking], ["宙斯", "蓝鹊"])
self.assertEqual(tracking[1]["light"], "无判断")
self.assertEqual(style_metrics.call_args_list[0].args[1], ["宙斯", "蓝鹊"])
self.assertEqual(enrichment["platform_metrics"], [])
self.assertEqual(enrichment["candidate_styles"], [])
self.assertEqual(enrichment["dashboard"]["styles"], ["宙斯", "蓝鹊"])
def test_report_date_uses_yesterday_notes_for_daily_new_notes(self):
notes = [
{"id": 1, "publish_date": "2026-07-15", "style_name": "宙斯", "creator_name": "达人甲", "platform": "小红书", "publish_time": "2026-07-15 10:00", "title": "今天", "view_count": 10, "engagement_rate": None},
{"id": 2, "publish_date": "2026-07-14", "style_name": "宙斯", "creator_name": "达人乙", "platform": "抖音", "publish_time": "2026-07-14 11:00", "title": "昨天", "view_count": 20, "engagement_rate": None},
]
with patch.object(daily, "_note_rows", return_value=notes), \
patch.object(daily, "_all_style_names", return_value=["宙斯"]), \
patch.object(daily, "_style_metrics", return_value={}), \
patch.object(daily, "_load_enrichment", return_value={}), \
patch.object(daily, "_platform_metrics", return_value=[]), \
patch.object(daily, "_persona_context", return_value=[]), \
patch.object(daily, "load_creator_connections", return_value={}), \
patch.object(daily, "load_style_images", return_value={}), \
patch.object(daily, "load_style_categories", return_value={}), \
patch.object(daily, "_cooperation_context", return_value=[]), \
patch.object(daily, "_style_comment_context", return_value=[]), \
patch.object(daily, "_review_context", return_value=[]), \
patch.object(daily, "_creative_context", return_value=[]), \
patch.object(daily, "_comment_rows", return_value=[]), \
patch.object(daily, "build_dashboard_facts", return_value={}) as dashboard:
daily_notes, _, _, _, _ = daily.load_report_facts(date(2026, 7, 15))
self.assertEqual([row["note_id"] for row in daily_notes], [2])
self.assertEqual([row["note_id"] for row in dashboard.call_args.kwargs["daily_notes"]], [2])
def test_prompt_requires_all_styles_without_top_ten_truncation(self):
prompt = daily.build_daily_prompt("2026-07-15", [], [], [], [])
self.assertIn("所有SKU", prompt)
self.assertNotIn("只保留异常程度最高的10个", prompt)
self.assertIn("SKU经营表现分析", daily.SYSTEM_PROMPT)
self.assertIn("加购率", daily.SYSTEM_PROMPT)
def test_prompt_uses_the_new_daily_output_contract(self):
self.assertIn("电商运营数据", daily.SYSTEM_PROMPT)
self.assertIn("达人营销数据", daily.SYSTEM_PROMPT)
self.assertNotIn("TOP20", daily.SYSTEM_PROMPT)
self.assertIn("一页A4", daily.SYSTEM_PROMPT)
self.assertIn("↑↓展示趋势", daily.SYSTEM_PROMPT)
self.assertNotIn("→稳定", daily.SYSTEM_PROMPT)
self.assertIn("电商销量表现 + 访客数 + 转化率 + 达人笔记曝光 + 用户反馈", daily.SYSTEM_PROMPT)
def test_prompt_payload_keeps_yesterday_and_seven_day_comparisons(self):
rows = [{
"style_name": "蓝鹊",
"sales": 10,
"sales_yesterday": 8,
"avg_sales_7d": 7.5,
"sales_change_yesterday": 0.25,
"sales_change": 0.3333,
}]
prompt = daily.build_daily_prompt("2026-07-16", [], rows, [], [])
self.assertIn('"sales_yesterday": 8', prompt)
self.assertIn('"avg_sales_7d": 7.5', prompt)
self.assertIn('"sales_change_yesterday": 0.25', prompt)
def test_style_metrics_calculates_yesterday_and_seven_day_changes(self):
class FakeCursor:
def __enter__(self):
return self
def __exit__(self, *args):
return False
def execute(self, query, params):
self.query = query
self.params = params
def fetchall(self):
return [(
"蓝鹊",
120, 24, 10, 2,
100, 20, 8, 1,
90, 18, 7.5, 1.2,
[4, 5, 6, 7, 8, 9, 10],
)]
class FakeConnection:
def __enter__(self):
return self
def __exit__(self, *args):
return False
def cursor(self):
return FakeCursor()
with patch.object(daily, "get_conn", return_value=FakeConnection()):
row = daily._style_metrics(date(2026, 7, 16), ["蓝鹊"])["蓝鹊"]
self.assertEqual(row["sales_yesterday"], 8)
self.assertEqual(row["sales_change_yesterday"], 0.25)
self.assertEqual(row["sales_change"], 0.3333)
self.assertEqual(row["visitor_change_yesterday"], 0.2)
self.assertEqual(row["conversion_rate"], round(10 / 120, 4))
self.assertEqual(row["conversion_rate_yesterday"], round(8 / 100, 4))
self.assertEqual(row["sales_7d_total"], 49)
self.assertEqual(row["sales_forecast_7d"], 63)
def test_prompt_does_not_reuse_retired_dashboard_sections(self):
prompt = daily.build_daily_prompt("2026-07-15", [], [], [], [], {"dashboard": {
"rankings": {"sales": []},
"platform_personas": [{"platform": "天猫"}],
"daily_new_notes": [],
"styles": [{"style_name": "蓝鹊", "total_note_exposure": 1000, "evidence_summary": "经营、画像、营销笔记"}],
"definitions": {"conversion_rate": "销量/访客"},
}})
self.assertNotIn("按风格分组的综合经营矩阵", prompt)
self.assertNotIn("平台高转化与高访客画像", prompt)
self.assertNotIn("全款式经营看板摘要", prompt)
def test_report_normalization_and_all_style_validation(self):
report = daily.normalize_report(
"健康度:偏弱。36 个款式中\n蓝鹊同比增长79%\n宙斯环比下降16%",
35,
)
self.assertNotIn("同比", report)
self.assertIn("环比下降16%", report)
self.assertIn("35个款式中", report)
self.assertEqual(daily.normalize_report("销量 0 / 退款 0(无数据)"), "销量 0 / 退款 0(当日均为0")
cleaned = daily.normalize_report("投放尚未发布,带动效应快速衰减;待发布后核对")
self.assertNotIn("未发布", cleaned)
self.assertNotIn("带动效应", cleaned)
self.assertIn("发布时间无数据", cleaned)
self.assertNotIn("驱动访客", daily.normalize_report("该内容未驱动访客回升"))
daily.validate_all_styles(report, [{"style_name": "蓝鹊"}, {"style_name": "宙斯"}])
with self.assertRaisesRegex(RuntimeError, "缺少款式"):
daily.validate_all_styles(report, [{"style_name": "白鹭"}])
def test_report_normalization_removes_database_and_code_style_language(self):
raw = """数据已收到,我先用脚本做一遍聚合。
# 品牌SKU电商运营日报
• 最佳渠道:无法判定。今日 `昨日新发布内容` 为空,cooperations 仅1条,publish_time=null、engagement_data_status=无数据。
5. 数据治理与内容补位:(a)对齐 publish_time;(b)修复互动数据;(c)补齐数据。
报告字数提示:如需压缩请告知。
完整数据索引:如需导出 CSV 请告诉我。
"""
report = daily.normalize_report(raw)
self.assertTrue(report.startswith("# 品牌SKU电商运营日报"))
for forbidden in ("我先用脚本", "cooperations", "publish_time", "engagement_data_status", "null", "`", "a", "报告字数提示", "请告诉我"):
self.assertNotIn(forbidden, report)
self.assertIn("达人合作记录", report)
self.assertIn("发布时间无数据", report)
self.assertIn("互动数据无数据", report)
self.assertIn("1", report)
def test_system_prompt_forbids_database_fields_and_conversational_tail(self):
for phrase in (
"禁止输出数据库表名、字段名、JSON键名",
"禁止出现反引号、null",
"禁止描述生成过程",
"禁止在结尾追问",
):
self.assertIn(phrase, daily.SYSTEM_PROMPT)
def test_status_groups_are_replaced_with_complete_deterministic_lists(self):
report = "二、种草追踪池\n\nB. 全量状态(固定4行)\n🔴(1):蓝鹊\n\n三、昨日笔记次日追踪"
rows = [
{"style_name": "蓝鹊", "light": "🔴"},
{"style_name": "宙斯", "light": "🟡"},
{"style_name": "白鹭", "light": "🟢"},
{"style_name": "云卷2", "light": "无判断"},
]
result = daily.replace_status_groups(report, rows)
self.assertIn("B. 全量状态(4款)", result)
self.assertIn("🔴(1):蓝鹊", result)
self.assertIn("🟡(1):宙斯", result)
self.assertIn("🟢(1):白鹭", result)
self.assertIn("无判断(1):云卷2", result)
self.assertIn("三、昨日笔记次日追踪", result)
def test_status_groups_are_inserted_when_model_omits_marker(self):
report = "一、核心经营结论\n正常\n\n三、达人投放与电商同期关联\n无数据"
rows = [{"style_name": "蓝鹊", "light": "🔴"}, {"style_name": "白鹭", "light": "🟢"}]
result = daily.replace_status_groups(report, rows, "C. 补充经营信号\n- 店铺大盘:无数据")
self.assertIn("B. 全量状态(2款)", result)
self.assertIn("🔴(1):蓝鹊", result)
self.assertIn("🟢(1):白鹭", result)
self.assertLess(result.index("B. 全量状态"), result.index("三、达人投放"))
def test_prompt_passes_available_enrichment_without_restoring_old_sections(self):
enrichment = {
"platform_metrics": [{"style_name": "蓝鹊", "platform": "天猫", "sales": 14}],
"cooperations": [{"style_name": "蓝鹊", "content_direction": "通勤"}],
"personas": [{"style_name": "蓝鹊", "gender_top": "女性用户 60%"}],
"creatives": [{"style_name": "蓝鹊", "ctr": 4.2}],
"reviews": [{"style_name": "蓝鹊", "sample_size": 6}],
"shop_overview": [{"platform": "天猫", "week_end": "2026-07-12"}],
}
prompt = daily.build_daily_prompt("2026-07-15", [], [], [], [], enrichment)
for source_key in ("platform_metrics", "cooperations", "personas", "creatives", "reviews", "shop_overview"):
self.assertIn(source_key, prompt)
self.assertIn("评论与补充经营数据", prompt)
self.assertNotIn("补充经营信号", prompt)
self.assertNotIn("全量状态", prompt)
def test_enrichment_section_validation_requires_available_categories(self):
enrichment = {"personas": [{}], "creatives": [{}], "reviews": [{}], "shop_overview": [{}]}
complete = "条件信号:人物画像;主图;商品评价;店铺大盘"
daily.validate_enrichment_sections(complete, enrichment)
with self.assertRaisesRegex(RuntimeError, "主图"):
daily.validate_enrichment_sections("人物画像 商品评价 店铺大盘", enrichment)
def test_build_condition_signals_covers_all_available_sources(self):
enrichment = {
"candidate_styles": ["蓝鹊"],
"personas": [{"style_name": "蓝鹊", "platform": "天猫", "data_date": "2026-07-15", "gender_top": "女性 60%"}],
"creatives": [{"style_name": "蓝鹊", "platform": "天猫", "data_date": "2026-07-12", "impressions": 1000, "clicks": 50, "ctr": 5.0}],
"reviews": [{"style_name": "蓝鹊", "sample_size": 6, "negative_count": 1, "negative_rate": 0.1667, "window_start": "2026-07-09", "window_end": "2026-07-15"}],
"shop_overview": [{"platform": "天猫", "week_end": "2026-07-12", "visitors": 10000, "deal_amount": 20000}],
}
text = daily.build_condition_signals(enrichment)
for phrase in ("C. 补充经营信号", "人物画像", "主图", "商品评价", "店铺大盘", "2026-07-12"):
self.assertIn(phrase, text)
def test_persona_top_value_ignores_invalid_percentages(self):
payload = {"buying_power": [{"name": "异常", "value": 133}, {"name": "L4", "value": 42}]}
self.assertEqual(daily._top_persona_value(payload, "buying_power"), "L4 42.0%")
def test_enriched_report_rejects_missing_platform_claim_and_invented_new_product(self):
daily.validate_enriched_report("平台拆分完整,均为同期合作")
with self.assertRaisesRegex(RuntimeError, "平台拆分"):
daily.validate_enriched_report("无平台拆分数据")
with self.assertRaisesRegex(RuntimeError, "新品"):
daily.validate_enriched_report("同期出现多个新品合作")
def test_platform_persona_section_uses_database_winners_not_model_text(self):
report = ("### 三、平台人群与营销机会\n天猫高转化款为错误款式\n\n"
"### 四、明日业务动作\n复查数据\n\n"
"三、平台人群与营销机会\n重复错误款式")
enrichment = {"dashboard": {
"daily_views_status": "无数据:未保存每日增量快照",
"platform_personas": [{
"platform": "天猫",
"top_conversion": {
"style_name": "布谷", "conversion_rate": 0.0259, "visitors": 617,
"persona_text": "无数据", "interest_hypothesis": "兴趣推测:无数据",
"optimization": "先补采人群画像",
},
"top_visitors": {
"style_name": "星云2", "conversion_rate": 0.0114, "visitors": 3504,
"persona_text": "男性用户 62.7%", "interest_hypothesis": "兴趣推测:数码3C",
"optimization": "优化详情页",
},
}],
}}
result = daily.replace_platform_persona_section(report, enrichment)
self.assertIn("转化最高为布谷(转化率2.6%", result)
self.assertIn("访客最高为星云2(访客3,504", result)
self.assertIn("兴趣推测:数码3C", result)
self.assertNotIn("错误款式", result)
self.assertNotIn("重复错误款式", result)
self.assertIn("四、明日业务动作", result)
self.assertEqual(result.count("三、平台人群与营销机会"), 1)
def test_platform_persona_section_supports_new_daily_report_boundary(self):
report = ("### 三、平台人群与营销机会\n模型内容\n\n"
"### 四、今日重点SKU分析\n重点款内容\n")
result = daily.replace_platform_persona_section(report, {"dashboard": {}})
self.assertIn("三、平台人群与营销机会", result)
self.assertIn("四、今日重点SKU分析", result)
self.assertNotIn("模型内容", result)
self.assertEqual(result.count("三、平台人群与营销机会"), 1)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,26 @@
import unittest
from pathlib import Path
from gyxx_flow.catalog import WorkflowCatalog
PROJECT_ROOT = Path(__file__).resolve().parents[3]
class DailyMarketingReportScheduleTests(unittest.TestCase):
def test_python_scheduler_runs_latest_daily_report(self):
catalog = WorkflowCatalog.load(PROJECT_ROOT / "config")
workflow = next(
item
for item in catalog.workflows
if item.workflow_id == "content.marketing_report.daily"
)
schedule = catalog.schedule_for(workflow.workflow_id)
self.assertEqual(workflow.steps[0].entry, "daily_marketing_report.py")
self.assertEqual(workflow.steps[0].args, ("--send",))
self.assertEqual(schedule.kind, "daily")
self.assertEqual(schedule.at, "10:00")
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,202 @@
import json
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from PIL import Image
from gyxx_flow.modules.content_marketing.data.tools.daily_report_card import (
build_analysis_card,
build_daily_report_card,
build_dashboard_card,
prepare_feishu_image,
send_card_to_recipients,
send_daily_report_cards,
upload_dashboard_image,
)
class DailyReportCardTests(unittest.TestCase):
def test_dashboard_and_analysis_are_separate_cards_with_full_text(self):
report = """![全域种草数据看板](charts/dashboard.png)
第一部分 整体经营分析
总销量下降15%
整体趋势判断:下滑状态。
第二部分 SKU分析排序
1. 蓝鹊:销量第一。
2. 星云2:转化领先。
3. 宙斯:访客增长。
4. 南斯:退款偏高。
5. 凌云3:需要优化。
6. 波塞冬:继续观察。
第三部分 关键预警与建议
退款订单比上升,48小时内排查。
"""
rows = [{"style_name": "蓝鹊", "light": "🔴"}]
dashboard = build_dashboard_card("2026-07-17", rows, "img_v3_test")
analysis = build_analysis_card("2026-07-17", report)
dashboard_text = json.dumps(dashboard, ensure_ascii=False)
analysis_text = json.dumps(analysis, ensure_ascii=False)
self.assertIn("img_v3_test", dashboard_text)
self.assertNotIn("总销量下降15%", dashboard_text)
self.assertNotIn("img_v3_test", analysis_text)
self.assertNotIn("![全域种草数据看板]", analysis_text)
self.assertIn("波塞冬:继续观察", analysis_text)
self.assertIn("退款订单比上升", analysis_text)
def test_build_card_uses_card_2_schema_and_dashboard(self):
report = """一、核心经营结论
1. 波塞冬edge访客明显下滑。
2. 蓝鹊销量同期上行。
二、产品经营分析
四、明日业务动作
1. 复查波塞冬edge天猫流量。
2. 跟进蓝鹊投放承接。
"""
rows = [
{"style_name": "波塞冬edge", "light": "🔴"},
{"style_name": "宙斯", "light": "🟡"},
{"style_name": "白鹭", "light": "🟢"},
]
card = build_daily_report_card("2026-07-15", report, rows, "img_v3_test")
self.assertEqual(card["schema"], "2.0")
self.assertEqual(card["config"]["width_mode"], "fill")
self.assertIn("Hermes", card["header"]["subtitle"]["content"])
elements = card["body"]["elements"]
self.assertEqual([item["tag"] for item in elements], ["column_set", "img", "markdown", "markdown"])
self.assertEqual(elements[1]["img_key"], "img_v3_test")
metrics_text = json.dumps(elements[0], ensure_ascii=False)
self.assertIn("红灯款式", metrics_text)
self.assertIn("1", metrics_text)
self.assertNotIn("二、产品经营分析", json.dumps(card, ensure_ascii=False))
def test_prepare_feishu_image_resizes_to_supported_width(self):
with tempfile.TemporaryDirectory() as tmp:
source = Path(tmp) / "source.png"
target = Path(tmp) / "target.png"
Image.new("RGB", (1600, 1000), "white").save(source)
prepare_feishu_image(source, target)
with Image.open(target) as image:
self.assertEqual(image.width, 1500)
self.assertLessEqual(image.height / image.width, 16 / 9)
def test_build_card_reads_new_management_report_headings(self):
report = """## 1. 今日经营概览
销售:↑12%,蓝鹊贡献主要增量。
整体判断:增长状态。
## 2. SKU健康排名 TOP20
## 6. 今日运营建议
1. 商品动作:复核蓝鹊库存。
2. 电商动作:优化宙斯详情页。
"""
card = build_daily_report_card("2026-07-17", report, [], "img_v3_test")
card_text = json.dumps(card, ensure_ascii=False)
self.assertIn("蓝鹊贡献主要增量", card_text)
self.assertIn("复核蓝鹊库存", card_text)
def test_build_card_reads_fresh_partial_prompt_report_headings(self):
report = """第一部分 整体经营分析
总销量下降15%
整体趋势判断:下滑状态。
第二部分 SKU分析排序
第三部分 关键预警与建议
退款订单比上升,48小时内排查。
追加增长型SKU预算。
"""
card = build_daily_report_card("2026-07-17", report, [], "img_v3_test")
card_text = json.dumps(card, ensure_ascii=False)
self.assertIn("总销量下降15%", card_text)
self.assertIn("48小时内排查", card_text)
@patch(
"gyxx_flow.modules.content_marketing.data.tools."
"daily_report_card.subprocess.run"
)
def test_upload_dashboard_uses_named_image_file_field(self, run):
run.return_value = type("Result", (), {
"returncode": 0, "stdout": json.dumps({"image_key": "img_v3_test"}), "stderr": ""
})()
key = upload_dashboard_image(Path("charts/dashboard.png"))
self.assertEqual(key, "img_v3_test")
command = run.call_args.args[0]
self.assertEqual(command[command.index("--file") + 1], "image=./dashboard.png")
@patch(
"gyxx_flow.modules.content_marketing.data.tools."
"daily_report_card.subprocess.run"
)
def test_send_card_to_all_recipients(self, run):
run.side_effect = [
type("Result", (), {"returncode": 0, "stdout": json.dumps({"message_id": f"om_{i}"}), "stderr": ""})()
for i in range(3)
]
recipients = ["ou_one", "ou_two", "ou_three"]
results = send_card_to_recipients({"schema": "2.0"}, recipients, "2026-07-15")
self.assertEqual([item["message_id"] for item in results], ["om_0", "om_1", "om_2"])
self.assertEqual(run.call_count, 3)
commands = [call.args[0] for call in run.call_args_list]
self.assertEqual([cmd[cmd.index("--user-id") + 1] for cmd in commands], recipients)
self.assertTrue(all("--msg-type" in cmd and "interactive" in cmd for cmd in commands))
keys = [cmd[cmd.index("--idempotency-key") + 1] for cmd in commands]
self.assertTrue(all(key.startswith("daily-2026-07-15-") for key in keys))
self.assertTrue(all(len(key) <= 50 for key in keys))
@patch(
"gyxx_flow.modules.content_marketing.data.tools.daily_report_card.send_card_to_recipients"
)
@patch(
"gyxx_flow.modules.content_marketing.data.tools.daily_report_card.upload_dashboard_image",
return_value="img_v3_test",
)
@patch(
"gyxx_flow.modules.content_marketing.data.tools.daily_report_card.prepare_feishu_image"
)
def test_daily_report_sends_dashboard_then_analysis_as_two_messages_per_recipient(
self, prepare, upload, send
):
send.side_effect = [
[{"message_id": "om_board_1"}, {"message_id": "om_board_2"}],
[{"message_id": "om_text_1"}, {"message_id": "om_text_2"}],
]
recipients = ["ou_one", "ou_two"]
results = send_daily_report_cards(
"2026-07-17",
"第一部分 整体经营分析\n完整分析内容",
[{"light": "🟢"}],
Path("charts/dashboard.png"),
recipients,
)
self.assertEqual(
[item["message_id"] for item in results],
["om_board_1", "om_board_2", "om_text_1", "om_text_2"],
)
self.assertEqual(send.call_count, 2)
first_card = send.call_args_list[0].args[0]
second_card = send.call_args_list[1].args[0]
self.assertIn("img_v3_test", json.dumps(first_card, ensure_ascii=False))
self.assertIn("完整分析内容", json.dumps(second_card, ensure_ascii=False))
self.assertEqual(send.call_args_list[0].kwargs["message_kind"], "dashboard")
self.assertEqual(send.call_args_list[1].kwargs["message_kind"], "analysis")
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,104 @@
import tempfile
import unittest
from pathlib import Path
from PIL import Image
from gyxx_flow.modules.content_marketing.data.tools.daily_report_charts import (
_split_style_groups,
build_overview_cards,
classify_style_action,
generate_dashboard,
select_chart_styles,
)
class DailyReportChartTests(unittest.TestCase):
def setUp(self):
self.rows = [
{"style_name": "大盘下滑", "light": "🔴", "visitors": 1000, "avg_visitors_7d": 2000, "cart_users": 80, "avg_cart_users_7d": 100, "visitor_change": -0.5, "cart_change": -0.2, "sales_change": -0.4, "refund_change": 0.1},
{"style_name": "正向放量", "light": "🔴", "visitors": 1500, "avg_visitors_7d": 1000, "cart_users": 180, "avg_cart_users_7d": 100, "visitor_change": 0.5, "cart_change": 0.8, "sales_change": 0.3, "refund_change": -0.1},
{"style_name": "小基数", "light": "🔴", "visitors": 20, "avg_visitors_7d": 100, "cart_users": 1, "avg_cart_users_7d": 2, "visitor_change": -0.8, "cart_change": -0.5, "sales_change": None, "refund_change": None},
{"style_name": "黄灯A", "light": "🟡", "visitors": 800, "avg_visitors_7d": 1000, "cart_users": 70, "avg_cart_users_7d": 80, "visitor_change": -0.2, "cart_change": -0.125, "sales_change": -0.1, "refund_change": 0.2},
{"style_name": "绿灯", "light": "🟢", "visitors": 500, "avg_visitors_7d": 510, "cart_users": 40, "avg_cart_users_7d": 41, "visitor_change": -0.02, "cart_change": -0.02, "sales_change": 0.0, "refund_change": 0.0},
]
def test_selection_covers_negative_positive_and_small_base(self):
names = [row["style_name"] for row in select_chart_styles(self.rows, limit=4)]
self.assertIn("大盘下滑", names)
self.assertIn("正向放量", names)
self.assertIn("小基数", names)
def test_generate_dashboard_writes_png(self):
enrichment = {"platform_metrics": [
{"style_name": "大盘下滑", "platform": "天猫", "visitors": 700},
{"style_name": "大盘下滑", "platform": "京东", "visitors": 300},
]}
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "dashboard.png"
generate_dashboard("2026-07-15", self.rows, enrichment, path)
self.assertTrue(path.exists())
self.assertGreater(path.stat().st_size, 10_000)
with Image.open(path) as image:
self.assertEqual(image.size, (1600, 2500))
def test_style_groups_are_split_into_balanced_category_columns(self):
groups = [
{"style_category": "都市机能", "styles": [{}] * 12},
{"style_category": "户外机能", "styles": [{}] * 3},
{"style_category": "都市新贵", "styles": [{}] * 3},
{"style_category": "智性通勤", "styles": [{}] * 14},
{"style_category": "都市新旅", "styles": [{}]},
{"style_category": "都市运动", "styles": [{}] * 3},
]
left, right = _split_style_groups(groups)
self.assertEqual([group["style_category"] for group in left], ["都市机能", "户外机能", "都市新贵"])
self.assertEqual([group["style_category"] for group in right], ["智性通勤", "都市新旅", "都市运动"])
groups.append({"style_category": "未分类", "styles": [{}] * 2})
left, right = _split_style_groups(groups)
self.assertEqual(sum(len(group["styles"]) for group in left), 18)
self.assertEqual(sum(len(group["styles"]) for group in right), 20)
def test_overview_compares_today_yesterday_and_seven_day_average(self):
rows = [
{
"style_name": "蓝鹊", "sales": 12, "sales_yesterday": 10, "avg_sales_7d": 8,
"visitors": 120, "visitors_yesterday": 100, "avg_visitors_7d": 90,
},
{
"style_name": "宙斯", "sales": 18, "sales_yesterday": 20, "avg_sales_7d": 15,
"visitors": 180, "visitors_yesterday": 200, "avg_visitors_7d": 150,
},
]
cards = build_overview_cards(rows, {"daily_new_notes": [{"view_count": 515}]})
sales = next(card for card in cards if card["key"] == "sales")
conversion = next(card for card in cards if card["key"] == "conversion")
content = next(card for card in cards if card["key"] == "content")
self.assertEqual((sales["today"], sales["yesterday"], sales["baseline_7d"]), (30, 30, 23))
self.assertEqual(conversion["today"], 0.1)
self.assertEqual(content["today"], 515)
self.assertIsNone(content["baseline_7d"])
def test_style_action_matches_prompt_growth_opportunity_and_risk_rules(self):
growth = classify_style_action({
"sales_change_yesterday": 0.2, "visitor_change_yesterday": 0.1,
"conversion_change_yesterday": 0.001, "refund_change_yesterday": -0.1,
})
opportunity = classify_style_action({
"sales_change_yesterday": -0.1, "visitor_change_yesterday": 0.2,
"conversion_change_yesterday": -0.01, "refund_change_yesterday": 0,
})
risk = classify_style_action({
"sales_change_yesterday": -0.2, "visitor_change_yesterday": -0.1,
"conversion_change_yesterday": -0.01, "refund_change_yesterday": 0.4,
})
self.assertEqual(growth, ("增长型", "加达人/备货"))
self.assertEqual(opportunity, ("流量机会", "优化详情页"))
self.assertEqual(risk, ("风险型", "查退款/控投放"))
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,137 @@
from gyxx_flow.modules.content_marketing.data.tools import (
friday_relogin_parallel as relogin,
)
from gyxx_flow.modules.content_marketing.data.tools import relogin_transaction
def test_five_expected_platforms_are_configured():
assert set(relogin.PLATFORMS) == {
"bilibili", "douyin", "pgy", "xingtu", "xiaohongshu"
}
assert relogin.PLATFORMS["xingtu"].required_cookie == "sessionid"
assert relogin.PLATFORMS["douyin"].required_cookie == "sessionid"
def test_manual_relogin_can_select_only_xingtu():
assert relogin.resolve_platform_names(["xingtu"]) == ["xingtu"]
assert relogin.resolve_platform_names(None) == list(relogin.PLATFORMS)
def test_default_screenshot_recipients_are_routed_by_platform():
assert relogin.DEFAULT_RECIPIENTS == {
"pgy": "ou_24cc944d6e43c69c59d6560ad4e2ae6e",
"xingtu": "ou_24cc944d6e43c69c59d6560ad4e2ae6e",
"douyin": "ou_7ad5fc8012e2f741afc5346e05ffd447",
"bilibili": "ou_7ad5fc8012e2f741afc5346e05ffd447",
"xiaohongshu": "ou_7ad5fc8012e2f741afc5346e05ffd447",
}
assert relogin.recipient_for_platform("pgy") == "ou_24cc944d6e43c69c59d6560ad4e2ae6e"
assert relogin.recipient_for_platform("douyin") == "ou_7ad5fc8012e2f741afc5346e05ffd447"
assert relogin.recipient_for_platform("bilibili") == "ou_7ad5fc8012e2f741afc5346e05ffd447"
assert relogin.recipient_for_platform("pgy", "ou_override") == "ou_override"
def test_failed_platforms_are_grouped_by_their_responsible_recipient():
grouped = relogin.group_platforms_by_recipient(
["pgy", "douyin", "bilibili", "xingtu", "xiaohongshu"]
)
assert grouped == {
"ou_24cc944d6e43c69c59d6560ad4e2ae6e": ["pgy", "xingtu"],
"ou_7ad5fc8012e2f741afc5346e05ffd447": [
"douyin", "bilibili", "xiaohongshu"
],
}
def test_login_windows_are_classified_by_title():
assert relogin._platform_from_window_title("账号登录 - Google Chrome") == "bilibili"
assert relogin._platform_from_window_title("小红书蒲公英 - Google Chrome") == "pgy"
assert relogin._platform_from_window_title("抖音授权 - Google Chrome") == "xingtu"
assert relogin._platform_from_window_title("抖音 - 记录美好生活") == "douyin"
assert relogin._platform_from_window_title("小红书 - 你的生活兴趣社区") == "xiaohongshu"
assert relogin._platform_from_window_title("Codex") is None
def test_retry_only_failed_platforms_for_at_most_three_rounds():
calls = []
def fake_round(names, attempt):
calls.append((tuple(names), attempt))
if attempt == 1:
return {name: name != "xingtu" for name in names}
if attempt == 2:
return {name: False for name in names}
return {name: True for name in names}
result = relogin.run_with_retries(
["bilibili", "pgy", "xingtu", "xiaohongshu"],
max_attempts=3,
run_round=fake_round,
)
assert calls == [
(("bilibili", "pgy", "xingtu", "xiaohongshu"), 1),
(("xingtu",), 2),
(("xingtu",), 3),
]
assert all(result.values())
def test_stops_after_third_failed_round():
calls = []
def always_fail(names, attempt):
calls.append(attempt)
return {name: False for name in names}
result = relogin.run_with_retries(
["pgy"], max_attempts=3, run_round=always_fail
)
assert calls == [1, 2, 3]
assert result == {"pgy": False}
def test_timeout_terminates_the_complete_process_tree(monkeypatch):
calls = []
class Process:
pid = 1234
def terminate(self):
calls.append("fallback-terminate")
def wait(self, timeout):
calls.append(("wait", timeout))
monkeypatch.setattr(
relogin.subprocess,
"run",
lambda command, **kwargs: calls.append((command, kwargs)),
)
relogin._terminate_process_tree(Process())
assert calls[0][0] == ["taskkill", "/PID", "1234", "/T", "/F"]
assert calls[1] == ("wait", 10)
def test_transaction_rolls_back_files_and_profile(tmp_path):
cookie = tmp_path / "cookies.json"
storage = tmp_path / "storage.json"
profile = tmp_path / "profile"
cookie.write_text("old-cookie", encoding="utf-8")
storage.write_text("old-storage", encoding="utf-8")
profile.mkdir()
(profile / "old.txt").write_text("old-profile", encoding="utf-8")
state = relogin_transaction.begin((cookie, storage), profile)
cookie.write_text("partial", encoding="utf-8")
profile.mkdir()
(profile / "partial.txt").write_text("partial", encoding="utf-8")
relogin_transaction.rollback(state)
assert cookie.read_text(encoding="utf-8") == "old-cookie"
assert storage.read_text(encoding="utf-8") == "old-storage"
assert (profile / "old.txt").read_text(encoding="utf-8") == "old-profile"
assert not (profile / "partial.txt").exists()
@@ -0,0 +1,367 @@
import json
from pathlib import Path
import pytest
from gyxx_flow.catalog import WorkflowCatalog
from gyxx_flow.modules.content_marketing import pgy_xhs_scraper_v2 as pgy
from gyxx_flow.modules.content_marketing import xingtu_scraper_v2 as xingtu
from gyxx_flow.modules.content_marketing.creator_task_grouping import (
chunk_creator_groups,
group_tasks_by_creator,
)
PROJECT_ROOT = Path(__file__).resolve().parents[3]
def _workflow(workflow_id):
catalog = WorkflowCatalog.load(PROJECT_ROOT / "config")
return next(item for item in catalog.workflows if item.workflow_id == workflow_id)
def test_group_tasks_uses_creator_id_and_merges_missing_id_by_unique_name():
tasks = [
{"creator_name": "同一达人", "creator_id": " 123 ", "record_id": "a"},
{"creator_name": "同一达人 ", "creator_id": None, "record_id": "b"},
{"creator_name": "另一个达人", "creator_id": None, "record_id": "c"},
]
groups = group_tasks_by_creator(tasks)
assert len(groups) == 2
assert groups[0]["creator_id"] == "123"
assert [task["record_id"] for task in groups[0]["tasks"]] == ["a", "b"]
def test_group_tasks_does_not_guess_when_same_name_has_conflicting_ids():
tasks = [
{"creator_name": "重名达人", "creator_id": "1", "record_id": "a"},
{"creator_name": "重名达人", "creator_id": "2", "record_id": "b"},
{"creator_name": "重名达人", "creator_id": None, "record_id": "c"},
]
groups = group_tasks_by_creator(tasks)
assert len(groups) == 3
def test_zero_batch_size_means_one_unlimited_batch():
groups = [{"creator_name": str(i), "tasks": []} for i in range(3)]
assert chunk_creator_groups(groups, 0) == [groups]
def _assert_cross_style_collection(module, monkeypatch):
styles = [
{"index": 1, "name": "款式A", "base_token": "base-a", "table_id": "table-a", "field_map": {"a": 1}},
{"index": 2, "name": "款式B", "base_token": "base-b", "table_id": "table-b", "field_map": {"b": 2}},
]
def fake_extract(style, only_record_ids):
return [{
"record_id": f"record-{style['index']}",
"creator_name": "同一达人",
"creator_id": "creator-1",
"target_title": f"标题{style['index']}",
}]
monkeypatch.setattr(module, "extract_target_tasks", fake_extract)
tasks, summaries = module.collect_tasks_across_styles(styles, None)
assert len(tasks) == 2
assert len(group_tasks_by_creator(tasks)) == 1
assert tasks[0]["style_context"]["table_id"] == "table-a"
assert tasks[1]["style_context"]["table_id"] == "table-b"
assert summaries[1]["total"] == 1
assert summaries[2]["total"] == 1
def test_pgy_collects_tasks_across_styles(monkeypatch):
_assert_cross_style_collection(pgy, monkeypatch)
def test_xingtu_collects_tasks_across_styles(monkeypatch):
_assert_cross_style_collection(xingtu, monkeypatch)
def test_xingtu_record_scope_is_applied_per_style(monkeypatch):
styles = [
{"index": 1, "name": "款式A", "base_token": "a", "table_id": "a", "field_map": {}},
{"index": 2, "name": "款式B", "base_token": "b", "table_id": "b", "field_map": {}},
]
observed = {}
def fake_extract(style, only_record_ids):
observed[style["index"]] = only_record_ids
return []
monkeypatch.setattr(xingtu, "extract_target_tasks", fake_extract)
xingtu.collect_tasks_across_styles(styles, {1: {"same-id"}, 2: {"other-id"}})
assert observed == {1: {"same-id"}, 2: {"other-id"}}
def test_xingtu_load_retry_scope_keeps_style_and_record_pair(tmp_path):
summary_path = tmp_path / "summary.json"
summary_path.write_text(json.dumps([
{"index": 1, "results": [
{"record_id": "same-id", "reason": "creator_search_circuit_open"},
]},
{"index": 2, "results": [
{"record_id": "same-id", "reason": "title_unmatched"},
{"record_id": "other-id", "reason": "creator_search_circuit_open"},
]},
]), encoding="utf-8")
scope = xingtu.load_retry_scope(summary_path, "creator_search_circuit_open")
assert scope == {1: {"same-id"}, 2: {"other-id"}}
def _assert_matched_task_uses_own_writeback_context(module, metric_key, monkeypatch):
calls = []
task = {
"record_id": "record-b",
"publish_time": object(),
"style_context": {
"index": 2,
"base_token": "base-b",
"table_id": "table-b",
"field_map": {"slot": "style-b-slot"},
},
}
summary = {"matched": 0, "filled": 0, "skipped_no_pubtime": 0, "results": []}
monkeypatch.setattr(module, "pick_read_field", lambda fmap, publish_time: ("read_count_7d", "field-b", "7天曝光量"))
monkeypatch.setattr(module, "write_back", lambda *args: calls.append(args) or True)
module.apply_matched_task(task, {metric_key: "1.2万", "title": "命中标题"}, summary, dry_run=False)
assert calls == [("base-b", "table-b", "record-b", "field-b", 12000)]
assert summary["matched"] == 1
assert summary["filled"] == 1
def test_pgy_match_writes_to_task_style_context(monkeypatch):
_assert_matched_task_uses_own_writeback_context(pgy, "read_count", monkeypatch)
def test_xingtu_match_writes_to_task_style_context(monkeypatch):
_assert_matched_task_uses_own_writeback_context(xingtu, "play_count", monkeypatch)
def test_xingtu_search_and_open_creator_submits_only_one_search(monkeypatch):
calls = []
def fail_search(_page, creator_name, creator_id):
calls.append((creator_name, creator_id))
raise TimeoutError("no result")
monkeypatch.setattr(xingtu, "search_creator", fail_search)
monkeypatch.setattr(
xingtu,
"open_creator_detail",
lambda *_args, **_kwargs: pytest.fail("detail must not open after search failure"),
)
with pytest.raises(TimeoutError, match="no result"):
xingtu.search_and_open_creator_once(object(), "同一达人", "creator-1")
assert calls == [("同一达人", "creator-1")]
def test_xingtu_search_budget_blocks_duplicate_without_failure_circuit():
budget = xingtu.CreatorSearchBudget(max_consecutive_failures=3)
assert budget.begin(("id", "1")) is True
assert budget.begin(("id", "1")) is False
budget.failed("first")
assert budget.begin(("id", "2")) is True
budget.failed("second")
assert budget.begin(("id", "3")) is True
assert budget.failed("third") is False
assert budget.begin(("id", "4")) is True
assert budget.calls == 4
assert budget.circuit_reason is None
budget.exhaust("search_quota_exhausted")
assert budget.begin(("id", "5")) is False
def test_xingtu_wait_for_results_detects_search_quota_exhausted():
class Page:
def evaluate(self, *_args):
return "quota"
def wait_for_timeout(self, _milliseconds):
pytest.fail("quota detection must return immediately")
with pytest.raises(xingtu.SearchQuotaExhausted):
xingtu.wait_for_visible_creator_result(Page(), "达人", timeout_ms=1000)
def test_xingtu_missing_creation_tab_is_not_treated_as_global_block():
class Page:
def evaluate(self, _script):
return "no_creation_tab"
assert xingtu.is_blocked(Page()) is False
@pytest.mark.parametrize("reason", ["verify_text", "verify_iframe", "verify_div"])
def test_xingtu_explicit_verification_still_opens_global_block(reason):
class Page:
def evaluate(self, _script):
return reason
assert xingtu.is_blocked(Page()) is True
def test_xingtu_missing_creation_tab_falls_back_to_current_creator_page():
class Locator:
@property
def first(self):
return self
def click(self, **_kwargs):
raise TimeoutError("missing tab")
class Page:
def get_by_text(self, *_args, **_kwargs):
return Locator()
def evaluate(self, _script):
return False
def wait_for_timeout(self, _milliseconds):
pass
assert xingtu.open_creation_ability(Page()) is False
def test_xingtu_current_creator_page_cards_are_used_as_fallback(monkeypatch):
class Page:
def wait_for_timeout(self, _milliseconds):
pass
monkeypatch.setattr(xingtu, "is_blocked", lambda _page: False)
monkeypatch.setattr(
xingtu,
"parse_videos_stable",
lambda _page: [{
"title": "MacBook党必看!提升幸福感的开工好物",
"play_count": "1.2万",
"note_id": "",
}],
)
monkeypatch.setattr(xingtu, "go_next_page", lambda _page: False)
monkeypatch.setattr(xingtu, "submit_video_search", lambda _page, _title: False)
found, _candidates, page_limit_hit = xingtu.find_videos_for_tasks(
Page(),
[{
"record_id": "record-1",
"target_title": "MacBook党必看!提升幸福感的开工好物",
}],
)
assert found["record-1"]["play_count"] == "1.2万"
assert found["record-1"]["match_method"] == "title"
assert page_limit_hit is False
@pytest.mark.parametrize(
"message",
[
"BrowserContext.new_page: Protocol error (Target.createTarget): Failed to open a new tab",
"Protocol error (Target.createTarget)",
],
)
def test_xingtu_new_tab_creation_failure_rebuilds_browser_session(message):
assert xingtu.is_browser_session_lost(RuntimeError(message)) is True
def test_scheduled_collection_has_no_automatic_retry_and_syncs_once():
steps = _workflow("content.metrics.daily").steps
entries = [step.entry for step in steps]
assert "data/tools/retry_failed.py" not in entries
assert entries.count("data/tools/sync_metrics_to_cmt_notes.py") == 1
assert entries.index("run_all.py") < entries.index(
"data/tools/sync_metrics_to_cmt_notes.py"
)
def test_daily_collection_uses_dedicated_self_operated_pipeline():
steps = _workflow("content.metrics.daily").steps
entries = [step.entry for step in steps]
run_step = next(step for step in steps if step.entry == "run_all.py")
assert "--daily-scope" in run_step.args
assert "--include-self-operated" not in run_step.args
assert "data/tools/refresh_self_mapping.py" in entries
assert "self_bilibili_scraper.py" in entries
assert "chanmama_scraper.py" in entries
refresh_index = entries.index("data/tools/refresh_self_mapping.py")
bili_index = entries.index("self_bilibili_scraper.py")
douyin_index = entries.index("chanmama_scraper.py")
assert refresh_index < bili_index < douyin_index
@pytest.mark.parametrize(
("module", "entry_url", "error_type", "operation"),
[
(pgy, pgy.HOME_URL, pgy.AcceptanceCookieSkip, "content.pugongying.login"),
(
xingtu,
xingtu.MARKET_URL,
xingtu.AcceptanceCookieSkip,
"content.xingtu.login",
),
],
)
def test_acceptance_invalid_session_skips_before_qr_login(
module,
entry_url,
error_type,
operation,
monkeypatch,
tmp_path,
):
evidence_file = tmp_path / "evidence.jsonl"
monkeypatch.setenv("GYXX_WORKFLOW_ACCEPTANCE", "1")
monkeypatch.setenv("GYXX_FEISHU_TABLE_WRITE_DISABLED", "1")
monkeypatch.setenv("GYXX_COOKIE_INVALID_SKIP", "1")
monkeypatch.setenv(
"GYXX_NOTIFICATION_RECIPIENT_OPEN_ID",
"ou_8ee224968aa26a74c7d30ba27fed5eeb",
)
monkeypatch.setenv("GYXX_ACCEPTANCE_EVIDENCE_FILE", str(evidence_file))
monkeypatch.setattr(module, "is_logged_in", lambda _page: False)
monkeypatch.setattr(
module,
"wait_for_scan_login",
lambda *_args, **_kwargs: pytest.fail("QR login must remain disabled"),
)
class Page:
def __init__(self):
self.urls = []
def goto(self, url, **_kwargs):
self.urls.append(url)
def wait_for_timeout(self, _milliseconds):
pass
page = Page()
with pytest.raises(error_type):
module.ensure_login(page, login_timeout=30)
assert page.urls == [entry_url]
records = [
json.loads(line)
for line in evidence_file.read_text(encoding="utf-8").splitlines()
]
assert records[-1]["event"] == "cookie_skipped"
assert records[-1]["operation"] == operation
assert records[-1]["details"]["status"] == "SKIPPED_COOKIE"
@@ -0,0 +1,367 @@
import pytest
from gyxx_flow.modules.content_marketing import (
pgy_xhs_scraper_v2,
xingtu_scraper_v2,
)
from gyxx_flow.modules.content_marketing.data.tools import (
relogin_douyin,
relogin_pgy,
relogin_xingtu,
)
@pytest.mark.parametrize("module", [relogin_pgy, relogin_xingtu])
def test_failed_relogin_restores_cookie_and_profile(tmp_path, monkeypatch, module):
cookie = tmp_path / "cookies.json"
profile = tmp_path / "profile"
cookie.write_text("old-cookie", encoding="utf-8")
profile.mkdir()
(profile / "state.txt").write_text("old-profile", encoding="utf-8")
monkeypatch.setattr(module, "COOKIE_FILE", cookie)
monkeypatch.setattr(module, "PROFILE_DIR", profile)
cookie_backup, profile_backup = module.reset_state()
assert not cookie.exists()
assert not profile.exists()
cookie.write_text("partial-new-cookie", encoding="utf-8")
profile.mkdir()
(profile / "partial.txt").write_text("partial", encoding="utf-8")
module.restore_previous_state(cookie_backup, profile_backup)
assert cookie.read_text(encoding="utf-8") == "old-cookie"
assert (profile / "state.txt").read_text(encoding="utf-8") == "old-profile"
assert not (profile / "partial.txt").exists()
@pytest.mark.parametrize("module", [relogin_pgy, relogin_xingtu])
def test_successful_relogin_keeps_new_cookie(tmp_path, monkeypatch, module):
cookie = tmp_path / "cookies.json"
profile = tmp_path / "profile"
cookie.write_text("old-cookie", encoding="utf-8")
profile.mkdir()
monkeypatch.setattr(module, "COOKIE_FILE", cookie)
monkeypatch.setattr(module, "PROFILE_DIR", profile)
cookie_backup, profile_backup = module.reset_state()
cookie.write_text("new-cookie", encoding="utf-8")
profile.mkdir()
module.finish_successful_relogin(profile_backup)
assert cookie.read_text(encoding="utf-8") == "new-cookie"
assert cookie_backup.exists()
assert not profile_backup.exists()
def test_xingtu_scan_login_opens_customer_sso_and_clicks_douyin():
class FakeLocator:
def __init__(self):
self.first = self
self.clicked = False
self.evaluated = False
def count(self):
return 1
def is_visible(self):
return True
def click(self, timeout):
self.clicked = True
def evaluate(self, script):
assert script == "e => e.click()"
self.evaluated = True
class FakePage:
url = "https://www.xingtu.cn/?redirect_uri=/ad/creator/market"
def __init__(self):
self.goto_url = None
self.selectors = []
self.douyin = FakeLocator()
def goto(self, url, **kwargs):
self.goto_url = url
self.url = url
def wait_for_timeout(self, _milliseconds):
pass
def locator(self, selector):
self.selectors.append(selector)
return self.douyin
def get_by_text(self, _pattern):
return FakeLocator()
page = FakePage()
xingtu_scraper_v2.open_scan_login(page)
assert page.goto_url == xingtu_scraper_v2.CUSTOMER_LOGIN_URL
assert 'img[src*="aweme.png"]' in page.selectors
assert page.douyin.clicked
def test_xingtu_index_page_is_recognized_as_logged_in():
class Locator:
@property
def first(self):
return self
def wait_for(self, timeout):
assert timeout == 1200
class Page:
url = "https://www.xingtu.cn/ad/creator/index"
def get_by_text(self, _text, exact=False):
return Locator()
assert xingtu_scraper_v2.is_logged_in(Page()) is True
def test_xingtu_public_homepage_is_not_logged_in_even_with_stale_cookie():
class Context:
def cookies(self):
return [{"name": "sessionid", "value": "stale"}]
class Page:
url = "https://www.xingtu.cn/?redirect_uri=/ad/creator/market"
context = Context()
def get_by_text(self, *_args, **_kwargs):
raise AssertionError("public homepage must be rejected before marker checks")
assert xingtu_scraper_v2.is_logged_in(Page()) is False
def test_xingtu_market_page_without_business_ui_rejects_stale_cookie():
class Locator:
@property
def first(self):
return self
def wait_for(self, **_kwargs):
raise xingtu_scraper_v2.PlaywrightTimeoutError("missing")
def count(self):
return 0
class Context:
def cookies(self):
return [{"name": "sessionid", "value": "stale"}]
class Page:
url = "https://www.xingtu.cn/ad/creator/market"
context = Context()
def get_by_text(self, *_args, **_kwargs):
return Locator()
def locator(self, *_args, **_kwargs):
return Locator()
assert xingtu_scraper_v2.is_logged_in(Page()) is False
def test_xingtu_cookie_validation_requires_real_session_cookie(tmp_path):
cookie = tmp_path / "xingtu.json"
cookie.write_text('[{"name":"other","value":"1"}]', encoding="utf-8")
assert xingtu_scraper_v2.has_valid_login_cookie_file(cookie) is False
cookie.write_text('[{"name":"sessionid","value":"fresh"}]', encoding="utf-8")
assert xingtu_scraper_v2.has_valid_login_cookie_file(cookie) is True
def test_xingtu_login_only_does_not_report_success_when_page_action_was_swallowed(
tmp_path, monkeypatch
):
cookie = tmp_path / "xingtu.json"
cookie.write_text('[{"name":"sessionid","value":"old"}]', encoding="utf-8")
class Session:
def __enter__(self):
return self
def __exit__(self, *_args):
return False
def fetch(self, *_args, **_kwargs):
# Scrapling can log a page_action error and still return a response.
return object()
monkeypatch.setattr(xingtu_scraper_v2, "COOKIE_FILE", cookie)
monkeypatch.setattr(xingtu_scraper_v2, "DynamicSession", lambda **_kwargs: Session())
monkeypatch.setattr(xingtu_scraper_v2, "_force_cleanup_session", lambda _session: None)
assert xingtu_scraper_v2.login_only(1) == 1
def test_xingtu_login_only_verifies_persisted_profile_after_oauth_context_closes(
tmp_path, monkeypatch
):
cookie = tmp_path / "xingtu.json"
class Session:
def __enter__(self):
return self
def __exit__(self, *_args):
return False
def fetch(self, *_args, **_kwargs):
raise RuntimeError("Target page, context or browser has been closed")
monkeypatch.setattr(xingtu_scraper_v2, "COOKIE_FILE", cookie)
monkeypatch.setattr(xingtu_scraper_v2, "DynamicSession", lambda **_kwargs: Session())
monkeypatch.setattr(xingtu_scraper_v2, "_force_cleanup_session", lambda _session: None)
def verify():
cookie.write_text('[{"name":"sessionid","value":"fresh"}]', encoding="utf-8")
return True
monkeypatch.setattr(xingtu_scraper_v2, "verify_persisted_profile_login", verify)
assert xingtu_scraper_v2.login_only(1) == 0
def test_xingtu_scan_login_follows_replacement_page_after_oauth_tab_closes(monkeypatch):
class Context:
def __init__(self):
self.pages = []
def new_page(self):
raise AssertionError("a surviving replacement page should be reused")
class Page:
def __init__(self, context, url, *, closes_on_wait=False):
self.context = context
self.url = url
self.closed = False
self.closes_on_wait = closes_on_wait
def is_closed(self):
return self.closed
def wait_for_timeout(self, _milliseconds):
if self.closes_on_wait:
self.closed = True
def goto(self, url, **_kwargs):
self.url = url
context = Context()
qr_page = Page(
context,
"https://open.douyin.com/platform/oauth/pc/auth",
closes_on_wait=True,
)
market_page = Page(context, xingtu_scraper_v2.MARKET_URL)
context.pages = [qr_page, market_page]
saved = []
monkeypatch.setattr(xingtu_scraper_v2, "open_scan_login", lambda _page: None)
monkeypatch.setattr(
xingtu_scraper_v2, "is_scan_qr_page", lambda page: page is qr_page
)
monkeypatch.setattr(
xingtu_scraper_v2, "is_logged_in", lambda page: page is market_page
)
monkeypatch.setattr(xingtu_scraper_v2, "save_state", lambda page: saved.append(page))
result = xingtu_scraper_v2.wait_for_scan_login(qr_page, 2)
assert result is market_page
assert saved == [market_page]
def test_xingtu_interrupted_transaction_is_recovered(tmp_path, monkeypatch):
cookie = tmp_path / "xingtu_cookies.json"
profile = tmp_path / "profile"
cookie.write_text("old-cookie", encoding="utf-8")
profile.mkdir()
(profile / "old.txt").write_text("old-profile", encoding="utf-8")
monkeypatch.setattr(relogin_xingtu, "COOKIE_FILE", cookie)
monkeypatch.setattr(relogin_xingtu, "PROFILE_DIR", profile)
cookie_backup, profile_backup = relogin_xingtu.reset_state()
profile.mkdir()
(profile / "partial.txt").write_text("partial", encoding="utf-8")
assert relogin_xingtu.recover_interrupted_state() is True
assert cookie.read_text(encoding="utf-8") == "old-cookie"
assert (profile / "old.txt").read_text(encoding="utf-8") == "old-profile"
assert not (profile / "partial.txt").exists()
assert cookie_backup is not None
assert profile_backup is not None
def test_pgy_scan_login_clicks_qr_switch_image():
class FakeLocator:
def __init__(self):
self.first = self
self.clicked = False
self.evaluated = False
def count(self):
return 1
def is_visible(self):
return True
def click(self, timeout):
self.clicked = True
def evaluate(self, script):
assert script == "e => e.click()"
self.evaluated = True
class FakePage:
def __init__(self):
self.selectors = []
self.qr_switch = FakeLocator()
def wait_for_timeout(self, _milliseconds):
pass
def get_by_text(self, _pattern):
return FakeLocator()
def locator(self, selector):
self.selectors.append(selector)
return self.qr_switch
page = FakePage()
pgy_xhs_scraper_v2.open_scan_login(page)
assert 'img[src*="qr_code"]' in page.selectors
assert page.qr_switch.evaluated
def test_failed_douyin_relogin_restores_cookie_storage_and_profile(tmp_path, monkeypatch):
cookie = tmp_path / "douyin_cookies.json"
storage = tmp_path / "douyin_storage_state.json"
profile = tmp_path / "profile"
cookie.write_text('[{"name":"sessionid","value":"old"}]', encoding="utf-8")
storage.write_text("old-storage", encoding="utf-8")
profile.mkdir()
(profile / "old.txt").write_text("old-profile", encoding="utf-8")
monkeypatch.setattr(relogin_douyin, "COOKIE_FILE", cookie)
monkeypatch.setattr(relogin_douyin, "STATE_FILE", storage)
monkeypatch.setattr(relogin_douyin, "PROFILE_DIR", profile)
monkeypatch.setattr(relogin_douyin.subprocess, "call", lambda *_args, **_kwargs: 1)
monkeypatch.setattr(relogin_douyin.sys, "argv", ["relogin_douyin.py"])
assert relogin_douyin.main() == 1
assert cookie.read_text(encoding="utf-8") == '[{"name":"sessionid","value":"old"}]'
assert storage.read_text(encoding="utf-8") == "old-storage"
assert (profile / "old.txt").read_text(encoding="utf-8") == "old-profile"
@@ -0,0 +1,84 @@
import io
import unittest
from pathlib import Path
from gyxx_flow.adapters import RuntimeServicePolicy
from gyxx_flow.modules.content_marketing.data.tools import db
from gyxx_flow.modules.content_marketing.data.tools import (
sync_metrics_to_cmt_notes as sync_metrics,
)
ROOT = (
Path(__file__).resolve().parents[3]
/ "src"
/ "gyxx_flow"
/ "modules"
/ "content_marketing"
)
class LocalDatabaseConfigTests(unittest.TestCase):
def test_config_requires_every_final_connection_value(self):
complete = {
"PG_HOST": "127.0.0.1",
"PG_PORT": "5432",
"PG_DB": "gyxx_super_data",
"PG_USER": "gyxx_flow",
"PG_PASSWORD": "secret",
}
self.assertEqual(
db.database_config_from_env(complete),
{
"host": "127.0.0.1",
"port": 5432,
"dbname": "gyxx_super_data",
"user": "gyxx_flow",
"password": "secret",
},
)
for missing in complete:
values = {key: value for key, value in complete.items() if key != missing}
with self.subTest(missing=missing), self.assertRaisesRegex(RuntimeError, missing):
db.database_config_from_env(values)
def test_runtime_policy_uses_explicit_cloud_postgres(self):
environment = RuntimeServicePolicy().apply(
{
"PG_HOST": "db.example.com",
"PG_PORT": "5432",
"PG_DB": "data_hub",
"PG_USER": "data_hub",
"GYXX_POSTGRES_PASSWORD": "secret",
}
)
config = db.database_config_from_env(environment)
self.assertEqual(config["host"], "db.example.com")
self.assertEqual(config["dbname"], "data_hub")
self.assertEqual(config["user"], "data_hub")
def test_database_scripts_do_not_define_remote_connection_defaults(self):
paths = (
ROOT / "data" / "tools" / "db.py",
ROOT / "data" / "tools" / "sync_metrics_to_cmt_notes.py",
ROOT / "data" / "tools" / "sync_cooperations.py",
ROOT / "data" / "tools" / "generate_creator_report.py",
)
for path in paths:
source = path.read_text(encoding="utf-8-sig")
with self.subTest(path=path.name):
self.assertNotIn("8.148.185.119", source)
self.assertNotIn("data_hub", source)
def test_sync_console_replaces_characters_unsupported_by_gbk(self):
raw = io.BytesIO()
stream = io.TextIOWrapper(raw, encoding="gbk", errors="strict")
sync_metrics.configure_console_output(stream, None)
stream.write("𝑻")
stream.flush()
self.assertTrue(raw.getvalue())
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,398 @@
import json
import os
import time
from pathlib import Path
from types import SimpleNamespace
import gyxx_flow.modules.content_marketing.data.tools.retry_failed as retry
from gyxx_flow.modules.content_marketing import run_all
def test_requested_records_must_be_successful_even_when_process_exits_zero():
data = {
"results": [
{"record_id": "a", "status": "success", "matched": True},
{"record_id": "b", "status": "retryable_failure", "matched": False},
]
}
ok, failed = retry.requested_records_succeeded(data, ["a", "b"])
assert ok is False
assert failed == ["b"]
def test_success_status_cannot_hide_write_failure_and_blocked_input_is_terminal():
contradictory = {
"results": [{
"record_id": "a", "status": "success", "matched": True,
"write_ok": False,
}]
}
assert retry.requested_records_succeeded(contradictory, ["a"]) == (False, ["a"])
blocked = {
"total": 1,
"details": [{
"record_id": "b", "status": "blocked_input", "ok": False,
"reason": "url_missing",
}],
}
assert retry.extract_failed_records(blocked, "bili") == []
def test_canonical_filename_requires_current_style_name():
style = {"index": 2, "name": "极星pro"}
assert retry.canonical_result_filename(style, "pgy", False) == "02-极星pro_v2.json"
assert retry.canonical_result_filename(style, "xt", False) == "02-极星pro_xingtu_v2.json"
assert retry.canonical_result_filename(style, "bili", True) == "02-极星pro_self_bilibili_v2.json"
def _write_json(path: Path, payload: dict | list) -> None:
path.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
def test_find_failed_uses_exact_current_style_file_and_ignores_legacy_name(tmp_path):
style = {"index": 2, "name": "极星pro"}
canonical = tmp_path / "02-极星pro_v2.json"
legacy = tmp_path / "02-盖亚微单_v2.json"
payload = {
"index": 2,
"total": 1,
"matched": 1,
"filled": 1,
"results": [{"record_id": "ok", "status": "success", "matched": True}],
}
_write_json(canonical, payload)
_write_json(legacy, {"index": 2, "total": 1, "matched": 0, "results": []})
old = time.time() - 48 * 3600
os.utime(legacy, (old, old))
failed = retry.find_failed(
"pgy", stale_hours=4, include_half=True,
styles=[style], v2_dir=tmp_path,
)
assert failed == {}
def test_find_failed_treats_missing_result_rows_as_incomplete(tmp_path):
style = {"index": 1, "name": "款式A"}
path = tmp_path / retry.canonical_result_filename(style, "xt", False)
_write_json(path, {"index": 1, "total": 2, "matched": 0, "filled": 0, "results": []})
failed = retry.find_failed(
"xt", stale_hours=4, include_half=True,
styles=[style], v2_dir=tmp_path,
)
assert failed[1]["reason"] == "ALL_FAILED"
assert failed[1]["failed_rids"] == []
def test_run_one_style_rechecks_payload_and_retries_exit_zero(monkeypatch, tmp_path):
style = {"index": 1, "name": "款式A"}
result_path = tmp_path / retry.canonical_result_filename(style, "pgy", False)
calls = []
def fake_call(_cmd, cwd, env=None):
del env
calls.append(cwd)
_write_json(result_path, {
"index": 1,
"total": 1,
"matched": 0,
"filled": 0,
"results": [{
"record_id": "bad",
"status": "retryable_failure",
"matched": False,
}],
})
return 0
monkeypatch.setattr(retry.subprocess, "call", fake_call)
monkeypatch.setattr(retry, "environment_for_child_script", lambda *_a, **_k: {})
monkeypatch.setattr(retry.time, "sleep", lambda _seconds: None)
ok = retry.run_one_style(
"pgy", 1, max_attempts=2, record_ids=["bad"],
style=style, v2_dir=tmp_path,
)
assert ok is False
assert len(calls) == 2
def test_run_one_style_accepts_exit_zero_only_after_requested_record_succeeds(monkeypatch, tmp_path):
style = {"index": 1, "name": "款式A"}
result_path = tmp_path / retry.canonical_result_filename(style, "pgy", False)
def fake_call(_cmd, cwd, env=None):
del env
_write_json(result_path, {
"index": 1,
"total": 1,
"matched": 1,
"filled": 1,
"results": [{
"record_id": "ok",
"status": "success",
"matched": True,
"write_ok": True,
}],
})
return 0
monkeypatch.setattr(retry.subprocess, "call", fake_call)
monkeypatch.setattr(retry, "environment_for_child_script", lambda *_a, **_k: {})
assert retry.run_one_style(
"pgy", 1, max_attempts=1, record_ids=["ok"],
style=style, v2_dir=tmp_path,
) is True
def test_self_operated_xingtu_retry_uses_self_douyin_scraper(monkeypatch, tmp_path):
style = {"index": 1, "name": "款式A"}
result_path = tmp_path / retry.canonical_result_filename(style, "xt", True)
observed = {}
def fake_call(cmd, cwd, env=None):
del env
observed["cmd"] = cmd
_write_json(result_path, {
"index": 1,
"total": 1,
"results": [{"record_id": "ok", "status": "success", "matched": True}],
})
return 0
monkeypatch.setattr(retry.subprocess, "call", fake_call)
monkeypatch.setattr(retry, "environment_for_child_script", lambda *_a, **_k: {})
assert retry.run_one_style(
"xt", 1, max_attempts=1, self_operated=True,
record_ids=["ok"], style=style, v2_dir=tmp_path,
) is True
assert observed["cmd"][1].endswith("self_douyin_scraper.py")
assert "--self-operated" not in observed["cmd"]
def test_full_summary_declared_failure_cannot_be_treated_as_complete():
complete, reason = retry._summary_completed({
"total": 1,
"results": [{
"record_id": "ok",
"status": "success",
"matched": True,
"write_ok": True,
}],
"write_failures": 1,
})
assert complete is False
assert reason == "summary declares unresolved failures"
def test_retry_main_returns_nonzero_when_verified_retry_still_fails(monkeypatch):
style = {"index": 1, "name": "款式A"}
monkeypatch.setattr(retry, "load_current_styles", lambda self_operated=False: [style])
monkeypatch.setattr(
retry, "find_failed",
lambda *_args, **_kwargs: {1: {"reason": "HALF", "failed_rids": ["bad"]}},
)
monkeypatch.setattr(retry, "run_one_style", lambda *_args, **_kwargs: False)
monkeypatch.setattr(retry.sys, "argv", ["retry_failed.py", "--platform", "pgy"])
assert retry.main() == 1
def test_validate_summary_payload_rejects_stale_missing_duplicate_and_unresolved(tmp_path):
path = tmp_path / "summary.json"
valid = [{
"index": 1,
"run_started_at": time.time() + 10,
"total": 2,
"matched": 2,
"filled": 2,
"results": [
{"record_id": "a", "status": "success", "matched": True},
{"record_id": "b", "status": "success", "matched": True},
],
}]
_write_json(path, valid)
assert run_all.validate_summary_payload(
valid, {1}, path, path.stat().st_mtime - 1,
)[0] is True
assert run_all.validate_summary_payload(
valid, {1}, path, path.stat().st_mtime + 1,
)[0] is False
assert run_all.validate_summary_payload(valid, {1, 2}, path, 0)[0] is False
duplicate = [{
"index": 1, "total": 2,
"results": [
{"record_id": "a", "status": "success"},
{"record_id": "a", "status": "success"},
],
}]
assert run_all.validate_summary_payload(duplicate, {1}, path, 0)[0] is False
unresolved = [{
"index": 1, "total": 1,
"results": [{
"record_id": "a", "status": "retryable_failure", "matched": False,
}],
}]
assert run_all.validate_summary_payload(unresolved, {1}, path, 0)[0] is False
def test_validate_selected_style_allows_unrelated_rows_in_merged_global_summary(tmp_path):
path = tmp_path / "summary.json"
payload = [
{
"index": 1,
"run_started_at": time.time() + 10,
"total": 1,
"results": [{"record_id": "ok", "status": "success"}],
},
{
"index": 2,
"total": 1,
"results": [{
"record_id": "old-failure",
"status": "retryable_failure",
}],
},
]
_write_json(path, payload)
valid, reason = run_all.validate_summary_payload(
payload, {1}, path, path.stat().st_mtime - 1,
)
assert valid is True, reason
def test_validate_summary_rejects_expected_style_left_over_from_old_run(tmp_path):
path = tmp_path / "summary.json"
started_at = 100.0
payload = [
{
"index": 1,
"run_started_at": 101.0,
"total": 1,
"results": [{"record_id": "new", "status": "success"}],
},
{
"index": 2,
"run_started_at": 90.0,
"total": 1,
"results": [{"record_id": "old", "status": "success"}],
},
]
_write_json(path, payload)
valid, reason = run_all.validate_summary_payload(
payload, {1, 2}, path, started_at,
)
assert valid is False
assert "style 2" in reason
assert "current run" in reason
def test_aggregate_counts_all_unresolved_rows_as_errors():
aggregate = run_all.aggregate({
"pgy": [{
"index": 1,
"total": 3,
"matched": 1,
"filled": 1,
"results": [
{"record_id": "ok", "status": "success"},
{"record_id": "miss", "matched": False},
{"record_id": "detail", "reason": "no_detail_page"},
],
}],
})
assert aggregate["platforms"]["pgy"]["unresolved"] == 2
assert aggregate["platforms"]["pgy"]["errors"] == 2
def test_aggregate_sums_declared_failure_categories_when_unresolved_is_absent():
aggregate = run_all.aggregate({
"xt": [{
"index": 1,
"total": 2,
"matched": 2,
"results": [
{"record_id": "a", "status": "success"},
{"record_id": "b", "status": "success"},
],
"retryable_failures": 1,
"write_failures": 1,
}],
})
assert aggregate["platforms"]["xt"]["unresolved"] == 2
def test_aggregate_keeps_partial_counts_when_validation_marks_payload_invalid():
aggregate = run_all.aggregate({
"pgy": {
"error": "invalid summary: unresolved",
"payload": [{
"index": 1,
"total": 1,
"matched": 0,
"results": [{
"record_id": "bad", "status": "retryable_failure",
}],
}],
},
})
platform = aggregate["platforms"]["pgy"]
assert platform["loaded"] is True
assert platform["unresolved"] == 1
assert platform["validation_error"] == "invalid summary: unresolved"
def test_run_round_marks_exit_zero_platform_failed_when_summary_is_invalid(monkeypatch, tmp_path):
args = SimpleNamespace(dry_run=True, style=[1])
stdout = tmp_path / "stdout.log"
stderr = tmp_path / "stderr.log"
observed = {}
monkeypatch.setattr(
run_all, "start_process",
lambda *_args, **_kwargs: (object(), stdout, stderr),
)
monkeypatch.setattr(run_all, "wait_all", lambda _procs: {"pgy": 0})
def fake_load(key, self_operated=False, **kwargs):
observed.update(kwargs)
return {"error": "invalid summary: stale"}
monkeypatch.setattr(run_all, "load_summary", fake_load)
monkeypatch.setattr(run_all, "print_report", lambda *_args: None)
monkeypatch.setattr(run_all, "_atomic_write_json", lambda *_args: None)
result = run_all.run_round(args, ["pgy"])
assert result["exit_codes"] == {"pgy": 0}
assert result["_non_zero_platforms"] == ["pgy"]
assert observed["expected_indices"] == {1}
assert isinstance(observed["started_at"], float)
def test_run_all_preserves_cookie_skip_exit_code():
assert run_all.aggregate_exit_code(True, [75, 75]) == 75
assert run_all.aggregate_exit_code(True, [75, 1]) == 1
assert run_all.aggregate_exit_code(True, []) == 1
assert run_all.aggregate_exit_code(False, []) == 0
@@ -0,0 +1,10 @@
from gyxx_flow.modules.content_marketing import self_douyin_scraper
def test_self_douyin_accepts_feishu_select_and_multiselect_values():
assert self_douyin_scraper.is_douyin_platform("抖音") is True
assert self_douyin_scraper.is_douyin_platform(["抖音"]) is True
assert self_douyin_scraper.is_douyin_platform("Douyin") is True
assert self_douyin_scraper.is_douyin_platform(["小红书", "抖音"]) is True
assert self_douyin_scraper.is_douyin_platform("小红书") is False
assert self_douyin_scraper.is_douyin_platform(None) is False
@@ -0,0 +1,58 @@
import unittest
from gyxx_flow.modules.content_marketing.data.tools.sync_style_categories import (
extract_style_mapping,
normalize_style_name,
)
class SyncStyleCategoriesTests(unittest.TestCase):
def test_normalizes_feishu_lifecycle_names_to_database_names(self):
cases = {
"盖亚微单Pro生命进程(新)": "盖亚微单",
"星迹&星迹2生命进程(新)": "星迹2",
"宙斯双肩包生命进程(新)": "宙斯",
"极星Pro生命进程(新)": "极星pro",
"瑞白双肩包生命进程(新)": "瑞白",
"逐星gt生命进程(新)": "逐星GT",
"拾影相机包生命进程 (新)": "拾影斜挎相机包",
"极星托特生命进程(新)": "极星托特",
}
for source, expected in cases.items():
with self.subTest(source=source):
self.assertEqual(normalize_style_name(source), expected)
def test_extracts_mapping_and_ignores_blank_rows(self):
payload = {
"data": {
"data": [
[
"[盖亚斜挎生命进程(新)](https://example.feishu.cn/base/TokenA)",
["都市机能"],
],
[None, ["都市运动"]],
["[极星托特生命进程(新)](https://example.feishu.cn/base/TokenB)", ["智性通勤"]],
]
}
}
self.assertEqual(
extract_style_mapping(payload),
{"盖亚斜挎": "都市机能", "极星托特": "智性通勤"},
)
def test_rejects_conflicting_categories_for_same_style(self):
payload = {
"data": {
"data": [
["[布谷生命进程(新)](https://example/a)", ["都市运动"]],
["[布谷生命进程(新)](https://example/b)", ["都市机能"]],
]
}
}
with self.assertRaisesRegex(ValueError, "布谷"):
extract_style_mapping(payload)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,70 @@
import unittest
from unittest.mock import patch
from gyxx_flow.modules.content_marketing import weekly_summary_all as weekly
class WeeklyTmallPersonaTests(unittest.TestCase):
def test_missing_persona_forbids_invented_percentages(self):
text = weekly.format_tmall_persona(None)
self.assertIn("不得虚构具体比例", text)
def test_format_tmall_persona_includes_date_and_dimensions(self):
persona = {
"collect_date": "2026-07-15",
"item_id": "900176117674",
"payload": {
"gender": [
{"name": "男性用户", "value": 66.97},
{"name": "女性用户", "value": 28.26},
],
"age": [{"name": "25-29岁", "value": 26.97}],
"city_tier": [{"name": "准一线城市", "value": 31.14}],
"buying_power": [{"name": "购买力L5", "value": 30.65}],
},
}
text = weekly.format_tmall_persona(persona)
self.assertIn("2026-07-15", text)
self.assertIn("男性用户 66.97%", text)
self.assertIn("25-29岁 26.97%", text)
self.assertIn("准一线城市 31.14%", text)
self.assertIn("购买力L5 30.65%", text)
def test_generate_summary_injects_latest_tmall_persona_into_single_note_prompt(self):
persona = {
"collect_date": "2026-07-15",
"item_id": "900176117674",
"payload": {
"gender": [{"name": "男性用户", "value": 66.97}],
"age": [{"name": "25-29岁", "value": 26.97}],
"city_tier": [],
"buying_power": [],
},
}
notes = [{
"title": "测试笔记",
"platform": "douyin",
"creator_name": "达人甲",
"view_count": 100,
"like_count": 10,
"comment_count": 2,
"favorite_count": 3,
"share_count": 1,
"report_path": None,
}]
with patch.object(weekly, "get_latest_tmall_persona", return_value=persona) as get_persona, \
patch.object(weekly, "call_hermes_analyzer", side_effect=["单篇分析", "## 三、周度总结"]) as call:
weekly.generate_summary("星云2", notes)
get_persona.assert_called_once_with("星云2")
single_prompt = call.call_args_list[0].args[1]
self.assertIn("天猫该款最新人物画像", single_prompt)
self.assertIn("男性用户 66.97%", single_prompt)
self.assertIn("内容触达人群", single_prompt)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,18 @@
"""Test bootstrap for the flattened product-commerce module."""
from __future__ import annotations
import sys
from pathlib import Path
MODULE_ROOT = (
Path(__file__).resolve().parents[3]
/ "src"
/ "gyxx_flow"
/ "modules"
/ "product_commerce"
)
# The migrated scripts intentionally retain their original sibling imports.
if str(MODULE_ROOT) not in sys.path:
sys.path.insert(0, str(MODULE_ROOT))
@@ -0,0 +1,74 @@
from __future__ import annotations
import importlib.util
import sys
from pathlib import Path
from types import ModuleType
import pytest
from gyxx_flow.adapters import WORKFLOW_ACCEPTANCE_RECIPIENT_OPEN_ID
PROJECT_ROOT = Path(__file__).resolve().parents[3]
COMMAND_PATH = (
PROJECT_ROOT
/ "src"
/ "gyxx_flow"
/ "modules"
/ "product_commerce"
/ "commands"
/ "run_alerts.py"
)
def _load_command(
monkeypatch: pytest.MonkeyPatch,
observed: dict[str, object],
) -> ModuleType:
legacy = ModuleType("run_alerts_with_retry")
def fake_main() -> int:
observed["argv"] = tuple(sys.argv)
return 0
legacy.main = fake_main # type: ignore[attr-defined]
monkeypatch.setitem(sys.modules, "run_alerts_with_retry", legacy)
spec = importlib.util.spec_from_file_location("test_run_alerts_command", COMMAND_PATH)
assert spec is not None and spec.loader is not None
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def test_alert_command_uses_canonical_acceptance_recipient(
monkeypatch: pytest.MonkeyPatch,
) -> None:
observed: dict[str, object] = {}
monkeypatch.setenv("GYXX_BUSINESS_DATE", "2026-08-01")
monkeypatch.setenv("GYXX_WORKFLOW_ACCEPTANCE", "1")
monkeypatch.delenv("GYXX_NOTIFICATION_RECIPIENT_OPEN_ID", raising=False)
monkeypatch.delenv("GYXX_ALERT_RECIPIENT_OPEN_ID", raising=False)
module = _load_command(monkeypatch, observed)
assert module.main() == 0
assert observed["argv"] == (
sys.argv[0],
"--end-date",
"2026-08-01",
"--openid",
WORKFLOW_ACCEPTANCE_RECIPIENT_OPEN_ID,
)
def test_alert_command_accepts_canonical_production_recipient(
monkeypatch: pytest.MonkeyPatch,
) -> None:
observed: dict[str, object] = {}
monkeypatch.setenv("GYXX_BUSINESS_DATE", "2026-08-01")
monkeypatch.delenv("GYXX_WORKFLOW_ACCEPTANCE", raising=False)
monkeypatch.setenv("GYXX_NOTIFICATION_RECIPIENT_OPEN_ID", "ou_configured")
monkeypatch.setenv("GYXX_ALERT_RECIPIENT_OPEN_ID", "ou_legacy")
module = _load_command(monkeypatch, observed)
assert module.main() == 0
assert observed["argv"][-1] == "ou_configured"
@@ -0,0 +1,362 @@
from __future__ import annotations
import subprocess
import sys
from datetime import date
from pathlib import Path
from types import ModuleType
import check_nine_day_decline as decline
import pytest
import run_alerts_with_retry as retry
PAIR = ("tm", "测试款")
def _segment(sales: int, *, days: int = 3) -> dict:
return {
"sales": sales,
"present_days": days,
"erp_style_codes": ["STYLE-1"],
}
def _event() -> dict:
return {
"style_name": "测试款",
"platform": "tm",
"erp_style_codes": ["STYLE-1"],
"window_start": "2026-07-24",
"window_end": "2026-08-01",
"seg1_sales": 300,
"seg2_sales": 210,
"seg3_sales": 90,
"drop_pct_1to2": 30.0,
"drop_pct_2to3": 57.1,
"drop_pct_1to3": 70.0,
}
def _prepare_main(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
monkeypatch.setattr(
sys,
"argv",
[
"check_nine_day_decline.py",
"--end-date",
"2026-08-01",
"--openid",
"ou_owner",
"--data-root",
str(tmp_path),
],
)
monkeypatch.setattr(decline, "managed_data_path", lambda value: Path(value))
monkeypatch.setattr(decline, "append_log", lambda line: None)
monkeypatch.delenv("GYXX_WORKFLOW_ACCEPTANCE", raising=False)
def test_detect_decline_uses_three_inclusive_postgres_sum_windows(
monkeypatch: pytest.MonkeyPatch,
) -> None:
observed: list[list[tuple[date, date]]] = []
def fake_load(windows):
observed.append(windows)
return [
{PAIR: _segment(300)},
{PAIR: _segment(210)},
{PAIR: _segment(90)},
]
monkeypatch.setattr(decline, "_load_segment_snapshots", fake_load)
events = decline.detect_decline("2026-08-01", 9)
assert observed == [
[
(date(2026, 7, 24), date(2026, 7, 26)),
(date(2026, 7, 27), date(2026, 7, 29)),
(date(2026, 7, 30), date(2026, 8, 1)),
]
]
assert len(events) == 1
assert events[0]["window_start"] == "2026-07-24"
assert events[0]["window_end"] == "2026-08-01"
assert [events[0][f"seg{index}_sales"] for index in range(1, 4)] == [
300,
210,
90,
]
assert events[0]["segment_windows"] == [
{"start": "2026-07-24", "end": "2026-07-26", "days": 3},
{"start": "2026-07-27", "end": "2026-07-29", "days": 3},
{"start": "2026-07-30", "end": "2026-08-01", "days": 3},
]
def test_detect_decline_rejects_incomplete_three_day_segment(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(
decline,
"_load_segment_snapshots",
lambda windows: [
{PAIR: _segment(300)},
{PAIR: _segment(210, days=2)},
{PAIR: _segment(90)},
],
)
assert decline.detect_decline("2026-08-01", 9) == []
def test_segment_loader_uses_postgres_range_sum_on_one_connection(
monkeypatch: pytest.MonkeyPatch,
) -> None:
fake_db = ModuleType("db")
connection = object()
observed: list[tuple[object, str, str]] = []
class ConnectionContext:
def __enter__(self):
return connection
def __exit__(self, *args):
return False
def fake_fetch(conn, start, end):
observed.append((conn, start, end))
return {}
fake_db.get_conn = ConnectionContext # type: ignore[attr-defined]
fake_db.fetch_daily_for_range = fake_fetch # type: ignore[attr-defined]
monkeypatch.setitem(sys.modules, "db", fake_db)
windows = decline._segment_windows("2026-08-01", 9)
assert decline._load_segment_snapshots(windows) == [{}, {}, {}]
assert observed == [
(connection, "2026-07-24", "2026-07-26"),
(connection, "2026-07-27", "2026-07-29"),
(connection, "2026-07-30", "2026-08-01"),
]
@pytest.mark.parametrize(
("response", "expected"),
[
({"id": "chatcmpl-not-a-receipt"}, None),
({"message_id": "om_direct123"}, "om_direct123"),
({"data": {"message_id": "om_nested123"}}, "om_nested123"),
(
{"choices": [{"message": {"content": "飞书回执 om_content123"}}]},
"om_content123",
),
(
{"choices": [{"message": {"content": "发送成功,但没有回执"}}]},
None,
),
],
)
def test_extract_feishu_message_id_requires_real_receipt_shape(
response: dict,
expected: str | None,
) -> None:
assert decline.extract_feishu_message_id(response) == expected
def test_main_fails_when_postgres_detection_read_fails(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
_prepare_main(monkeypatch, tmp_path)
monkeypatch.setattr(
decline,
"detect_decline",
lambda *args: (_ for _ in ()).throw(RuntimeError("postgres unavailable")),
)
assert decline.main() == 1
def test_main_zero_events_has_explicit_postcondition(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
capsys: pytest.CaptureFixture[str],
) -> None:
_prepare_main(monkeypatch, tmp_path)
monkeypatch.setattr(decline, "detect_decline", lambda *args: [])
assert decline.main() == 0
output = capsys.readouterr().out
assert "detection_source=postgresql events=0" in output
assert "notification=not_required persistence=not_required" in output
def test_main_dedup_query_failure_is_fail_closed(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
_prepare_main(monkeypatch, tmp_path)
monkeypatch.setattr(decline, "detect_decline", lambda *args: [_event()])
monkeypatch.setattr(
decline,
"_already_notified",
lambda *args: (_ for _ in ()).throw(RuntimeError("db timeout")),
)
notify = pytest.fail
monkeypatch.setattr(decline, "notify_hermes", notify)
assert decline.main() == 5
def test_main_does_not_accept_arbitrary_non_error_hermes_text(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
_prepare_main(monkeypatch, tmp_path)
monkeypatch.setattr(decline, "detect_decline", lambda *args: [_event()])
monkeypatch.setattr(decline, "_already_notified", lambda *args: False)
monkeypatch.setattr(
decline,
"notify_hermes",
lambda *args, **kwargs: {
"id": "chatcmpl-123",
"choices": [{"message": {"content": "发送成功"}}],
},
)
persist = pytest.fail
monkeypatch.setattr(decline, "_persist_events", persist)
assert decline.main() == 6
def test_main_returns_nonzero_when_receipt_persistence_fails(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
_prepare_main(monkeypatch, tmp_path)
monkeypatch.setattr(decline, "detect_decline", lambda *args: [_event()])
monkeypatch.setattr(decline, "_already_notified", lambda *args: False)
monkeypatch.setattr(
decline,
"notify_hermes",
lambda *args, **kwargs: {"message_id": "om_real123"},
)
monkeypatch.setattr(
decline,
"_persist_events",
lambda *args, **kwargs: (_ for _ in ()).throw(RuntimeError("write failed")),
)
assert decline.main() == 4
def test_main_success_requires_and_persists_feishu_receipt(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
capsys: pytest.CaptureFixture[str],
) -> None:
_prepare_main(monkeypatch, tmp_path)
observed: dict[str, object] = {}
monkeypatch.setattr(decline, "detect_decline", lambda *args: [_event()])
monkeypatch.setattr(decline, "_already_notified", lambda *args: False)
monkeypatch.setattr(
decline,
"notify_hermes",
lambda *args, **kwargs: {"message_id": "om_real123"},
)
def fake_persist(*args, **kwargs):
observed.update(kwargs)
return 1
monkeypatch.setattr(decline, "_persist_events", fake_persist)
assert decline.main() == 0
assert observed["message_id"] == "om_real123"
assert observed["openid"] == "ou_owner"
output = capsys.readouterr().out
assert "notification=sent feishu_message_id=om_real123" in output
assert "persistence=committed history_rows=1" in output
class _FailedCollector:
def __init__(self, *args):
self.pages: list[tuple] = []
def add_page(self, *args, **kwargs):
self.pages.append((args, kwargs))
def write(self):
return None
def summary_line(self):
return "failure"
def test_retry_wrapper_does_not_repeat_after_possible_success(
monkeypatch: pytest.MonkeyPatch,
) -> None:
calls: list[list[str]] = []
monkeypatch.setattr(
sys,
"argv",
[
"run_alerts_with_retry.py",
"--end-date",
"2026-08-01",
"--openid",
"ou_owner",
],
)
monkeypatch.setattr(retry, "FailedCollector", _FailedCollector)
monkeypatch.setattr(retry, "consolidate_failed_logs", lambda value: None)
monkeypatch.setattr(retry, "build_child_environment", lambda *args: {})
monkeypatch.setattr(
retry.time,
"sleep",
lambda seconds: pytest.fail("non-retryable result must not sleep"),
)
def fake_run(command, **kwargs):
calls.append(command)
return subprocess.CompletedProcess(command, 4)
monkeypatch.setattr(retry.subprocess, "run", fake_run)
assert retry.main() == 4
assert len(calls) == 1
def test_retry_wrapper_can_retry_fail_closed_dedup_read(
monkeypatch: pytest.MonkeyPatch,
) -> None:
return_codes = iter((5, 0))
sleeps: list[int] = []
monkeypatch.setattr(
sys,
"argv",
[
"run_alerts_with_retry.py",
"--end-date",
"2026-08-01",
"--openid",
"ou_owner",
],
)
monkeypatch.setattr(retry, "FailedCollector", _FailedCollector)
monkeypatch.setattr(retry, "consolidate_failed_logs", lambda value: None)
monkeypatch.setattr(retry, "build_child_environment", lambda *args: {})
monkeypatch.setattr(retry.time, "sleep", sleeps.append)
monkeypatch.setattr(
retry.subprocess,
"run",
lambda command, **kwargs: subprocess.CompletedProcess(
command,
next(return_codes),
),
)
assert retry.main() == 0
assert sleeps == [retry.RUN_SLEEP_SECONDS]
@@ -0,0 +1,210 @@
from __future__ import annotations
import backfill_poseidon_sales as backfill
import pandas as pd
def test_backfill_uses_layered_runtime_roots():
assert backfill.DOWNLOAD_ROOT == backfill.RAW_DATA_ROOT
assert backfill.PLATFORM_OUTPUT_ROOT == backfill.NORMALIZED_DATA_ROOT
assert backfill.FINAL_OUTPUT_ROOT == backfill.CURATED_DATA_ROOT
assert backfill.REPORT_OUTPUT_ROOT == backfill.EXPORTS_DATA_ROOT
def test_extract_jd_uses_existing_pipeline_metric_definitions():
frame = pd.DataFrame(
[
{
"时间": "2026-07-01",
"商品ID": "10035289085755",
"访客数": "1,234",
"加购人数": "23",
"成交单量": "7",
"成交商品件数": "9",
"取消及售后退款单量": "2",
}
]
)
result = backfill.extract_metrics("jd", frame, ["10035289085755"], "2026-07-01")
assert result["matched_ids"] == ["10035289085755"]
assert result["metrics"] == {
"sales": 7,
"visitors": 1234,
"cart_users": 23,
"refund_orders": 2,
}
def test_extract_dy_ignores_weekly_period_and_uses_deal_people():
frame = pd.DataFrame(
[
{
"统计周期": "2026/07/01-2026/07/07",
"商品编码": "3830202762700915010",
"商品点击人数": "999",
"加购人数": "99",
"成交人数": "88",
"成交订单数": "100",
"退款订单数(支付时间)": "9",
},
{
"统计周期": "2026/07/07",
"商品编码": "3830202762700915010",
"商品点击人数": "42",
"加购人数": "10",
"成交人数": "3",
"成交订单数": "4",
"退款订单数(支付时间)": "1",
},
]
)
result = backfill.extract_metrics("dy", frame, ["3830202762700915010"], "2026-07-07")
assert result["matched_row_count"] == 1
assert result["metrics"] == {
"sales": 3,
"visitors": 42,
"cart_users": 10,
"refund_orders": 1,
}
def test_extract_tm_sums_configured_ids_and_uses_paid_items():
frame = pd.DataFrame(
[
{
"统计日期": "2026-07-25",
"商品ID": "1061408417997",
"商品访客数": "395",
"商品加购人数": "17",
"支付买家数": "3",
"支付件数": "4",
},
{
"统计日期": "2026-07-25",
"商品ID": "1063521443835",
"商品访客数": "5",
"商品加购人数": "1",
"支付买家数": "1",
"支付件数": "2",
},
]
)
result = backfill.extract_metrics(
"tm",
frame,
["1060420778652", "1061408417997", "1063521443835"],
"2026-07-25",
)
assert result["matched_ids"] == ["1061408417997", "1063521443835"]
assert result["missing_ids"] == ["1060420778652"]
assert result["metrics"] == {
"sales": 6,
"visitors": 400,
"cart_users": 18,
"refund_orders": 0,
}
def test_build_final_payload_preserves_excel_lineage():
parsed = {
"jd": {
"metrics": {"sales": 1, "visitors": 10, "cart_users": 2, "refund_orders": 0},
"matched_ids": ["jd-1"],
"missing_ids": [],
"matched_row_count": 1,
"source_file": "jd.xlsx",
},
"dy": {
"metrics": {"sales": 2, "visitors": 20, "cart_users": 3, "refund_orders": 1},
"matched_ids": ["dy-1"],
"missing_ids": [],
"matched_row_count": 1,
"source_file": "dy.xlsx",
},
"tm": {
"metrics": {"sales": 3, "visitors": 30, "cart_users": 4, "refund_orders": 0},
"matched_ids": ["tm-1"],
"missing_ids": [],
"matched_row_count": 1,
"source_file": "tm.xls",
},
}
payload = backfill.build_final_payload("2026-07-01", parsed, ["10515"])
assert payload["style_name"] == backfill.STYLE_NAME
assert payload["erp"]["style_codes"] == ["10515"]
assert payload["jd"]["sales"] == 1
assert payload["dy"]["source"] == "dy.xlsx"
assert payload["tm"]["matched_product_ids"] == ["tm-1"]
def test_render_final_markdown_contains_all_three_platforms():
parsed = {
"jd": {
"metrics": {"sales": 1, "visitors": 10, "cart_users": 2, "refund_orders": 0},
"matched_ids": ["jd-1"], "missing_ids": [], "matched_row_count": 1, "source_file": "jd.xlsx",
},
"dy": {
"metrics": {"sales": 2, "visitors": 20, "cart_users": 3, "refund_orders": 1},
"matched_ids": ["dy-1"], "missing_ids": [], "matched_row_count": 1, "source_file": "dy.xlsx",
},
"tm": {
"metrics": {"sales": 3, "visitors": 30, "cart_users": 4, "refund_orders": 0},
"matched_ids": ["tm-1"], "missing_ids": [], "matched_row_count": 1, "source_file": "tm.xls",
},
}
payload = backfill.build_final_payload("2026-07-16", parsed, ["10515"])
markdown = backfill.render_final_markdown(payload)
assert "| jd | 1 | 0 | 10 | 2 |" in markdown
assert "| dy | 2 | 1 | 20 | 3 |" in markdown
assert "| tm | 3 | 0 | 30 | 4 |" in markdown
def test_write_final_summary_updates_json_and_markdown(tmp_path, monkeypatch):
monkeypatch.setattr(backfill, "FINAL_OUTPUT_ROOT", tmp_path)
parsed = {
platform: {
"metrics": {"sales": 1, "visitors": 10, "cart_users": 2, "refund_orders": 0},
"matched_ids": [platform], "missing_ids": [], "matched_row_count": 1, "source_file": f"{platform}.xlsx",
}
for platform in ("jd", "dy", "tm")
}
payload = backfill.build_final_payload("2026-07-16", parsed, ["10515"])
json_path = backfill.write_final_summary(payload)
markdown_path = json_path.with_suffix(".md")
assert json_path.exists()
assert markdown_path.exists()
assert "| dy | 1 | 0 | 10 | 2 |" in markdown_path.read_text(encoding="utf-8-sig")
def test_build_db_rows_expands_every_platform():
parsed = {
platform: {
"metrics": {"sales": index, "visitors": 10 * index, "cart_users": index, "refund_orders": 0},
"matched_ids": [platform], "missing_ids": [], "matched_row_count": 1, "source_file": f"{platform}.xlsx",
}
for index, platform in enumerate(("jd", "dy", "tm"), start=1)
}
payload = backfill.build_final_payload("2026-07-16", parsed, ["10515"])
rows = backfill.build_db_rows([payload])
assert len(rows) == 3
assert {(row["metric_date"], row["platform"]) for row in rows} == {
("2026-07-16", "jd"),
("2026-07-16", "dy"),
("2026-07-16", "tm"),
}
assert next(row for row in rows if row["platform"] == "tm")["sales"] == 3
@@ -0,0 +1,207 @@
from __future__ import annotations
import inspect
from datetime import date
from pathlib import Path
from types import SimpleNamespace
import collect_erp_yesterday_metrics as erp
import collect_jd_persona_to_bitable as jd_persona
from runtime_paths import playwright_launch_options
class _Chromium:
def __init__(self) -> None:
self.kwargs = None
def launch_persistent_context(self, **kwargs):
self.kwargs = kwargs
return object()
def test_jd_persona_launches_the_managed_persistent_profile(
monkeypatch,
tmp_path: Path,
) -> None:
profile = tmp_path / "jd-profile"
monkeypatch.setenv("GYXX_BROWSER_PROFILE_DIR", str(profile))
chromium = _Chromium()
context = jd_persona._launch_jd_context(chromium, headless=True)
assert context is not None
assert chromium.kwargs["user_data_dir"] == str(profile.resolve())
assert chromium.kwargs["headless"] is True
def test_jd_persona_reuses_logged_in_page_without_credentials() -> None:
calls: list[tuple[str, str]] = []
class Page:
url = "https://shop.jd.com/jdm/home"
def goto(self, *_args, **_kwargs):
return None
def wait_for_timeout(self, _milliseconds):
return None
def locator(self, _selector):
return SimpleNamespace(
inner_text=lambda **_kwargs: "京东商家后台 商品明细",
)
jd_persona._ensure_jd_session(
Page(),
shop="",
password="",
step_login=lambda _page, shop, password: calls.append((shop, password)),
)
assert calls == []
def test_erp_uses_managed_profile_when_bound_cdp_is_not_running(
monkeypatch,
tmp_path: Path,
) -> None:
profile = tmp_path / "erp-profile"
monkeypatch.setattr(erp, "_cdp_is_listening", lambda _url: False)
cdp_url, user_data_dir = erp.resolve_browser_session(
{"cdp_url": "http://127.0.0.1:9222"},
{
"GYXX_BROWSER_CDP_URL": "http://127.0.0.1:22063",
"GYXX_BROWSER_PROFILE_DIR": str(profile),
},
)
assert cdp_url is None
assert user_data_dir == str(profile)
def test_erp_attaches_only_to_the_live_managed_cdp(monkeypatch) -> None:
monkeypatch.setattr(erp, "_cdp_is_listening", lambda _url: True)
cdp_url, user_data_dir = erp.resolve_browser_session(
{"cdp_url": "http://127.0.0.1:9222", "user_data_dir": "legacy"},
{
"GYXX_BROWSER_CDP_URL": "http://127.0.0.1:22063",
"GYXX_BROWSER_PROFILE_DIR": "managed",
},
)
assert cdp_url == "http://127.0.0.1:22063"
assert user_data_dir is None
def test_erp_main_fails_before_browser_when_no_style_is_collectable(monkeypatch) -> None:
args = SimpleNamespace(
config="unused.json",
target_date=date(2026, 8, 3),
mode="daily",
refresh_config=False,
input="unused-styles.json",
)
monkeypatch.setattr(erp, "parse_args", lambda: args)
monkeypatch.setattr(
erp,
"load_config",
lambda _path: {"login": {"url": "https://erp.test", "username": "placeholder", "password": "placeholder"}},
)
monkeypatch.setattr(
erp,
"load_erp_styles",
lambda *_args: ([], {"source": "lark", "collectable": [], "skipped": []}),
)
monkeypatch.setattr(erp, "print_skip_summary", lambda _report: None)
monkeypatch.setattr(erp, "write_erp_skip_report", lambda *_args: None)
monkeypatch.setattr(
erp.DynamicFetcher,
"fetch",
lambda *_args, **_kwargs: (_ for _ in ()).throw(
AssertionError("browser must not start")
),
)
assert erp.main() == 1
def test_erp_main_fails_when_collector_summary_contains_failures(
monkeypatch,
tmp_path: Path,
) -> None:
args = SimpleNamespace(
config="unused.json",
target_date=date(2026, 8, 3),
mode="daily",
refresh_config=False,
input="unused-styles.json",
data_dir=str(tmp_path),
platform="all",
no_cdp=False,
timeout_ms=1000,
limit=0,
report_timeout=1,
report_attempts=1,
include_slow_codes=False,
hold_seconds=0,
headless=True,
)
styles = [{"style_name": "style-a", "erp_style_codes": ["erp-a"], "brand": erp.DEFAULT_BRAND}]
monkeypatch.setattr(erp, "parse_args", lambda: args)
monkeypatch.setattr(
erp,
"load_config",
lambda _path: {"login": {"url": "https://erp.test", "username": "placeholder", "password": "placeholder"}},
)
monkeypatch.setattr(
erp,
"load_erp_styles",
lambda *_args: (styles, {"source": "lark", "collectable": ["style-a"], "skipped": []}),
)
monkeypatch.setattr(erp, "print_skip_summary", lambda _report: None)
monkeypatch.setattr(erp, "write_erp_skip_report", lambda *_args: None)
monkeypatch.setattr(erp, "managed_data_path", lambda _path: tmp_path)
monkeypatch.setattr(erp, "resolve_browser_session", lambda _config: (None, None))
def fake_login_action(**kwargs):
state = kwargs["state"]
def action(_page):
state["ok"] = True
return action
monkeypatch.setattr(erp, "make_page_action", fake_login_action)
monkeypatch.setattr(
erp,
"collect_in_page",
lambda *_args, **_kwargs: {
"reports": 1,
"valid_codes": 1,
"has_failures": True,
},
)
monkeypatch.setattr(
erp.DynamicFetcher,
"fetch",
lambda _url, **kwargs: kwargs["page_action"](object()),
)
assert erp.main() == 1
def test_erp_date_inputs_are_required_and_read_back() -> None:
source = inspect.getsource(erp.set_platform_and_code)
assert "const begin = await waitFor('#order_date_begin')" in source
assert "const end = await waitFor('#order_date_end')" in source
assert "date range did not persist" in source
def test_playwright_channel_is_optional_and_environment_driven() -> None:
assert playwright_launch_options({}) == {}
assert playwright_launch_options({"GYXX_PLAYWRIGHT_CHANNEL": "chrome"}) == {
"channel": "chrome"
}
@@ -0,0 +1,81 @@
from __future__ import annotations
from types import SimpleNamespace
import insert_bitable_records as insert
import pytest
def test_record_id_parser_accepts_nested_upsert_receipt() -> None:
envelope = {
"ok": True,
"data": {"record": {"record_id_list": ["rec-confirmed"]}},
}
assert insert._record_id_from_envelope(envelope) == "rec-confirmed"
def test_lark_upsert_rejects_success_without_record_id(monkeypatch) -> None:
monkeypatch.delenv("GYXX_WORKFLOW_ACCEPTANCE", raising=False)
monkeypatch.setattr(insert.shutil, "which", lambda _name: "lark-cli.cmd")
monkeypatch.setattr(
insert.subprocess,
"run",
lambda *_args, **_kwargs: SimpleNamespace(
returncode=0,
stdout='{"ok": true, "data": {}}',
stderr="",
),
)
with pytest.raises(RuntimeError, match="without a confirmed record_id"):
insert.run_lark_upsert("base", "table", {"field": "value"})
def _daily_record(style: str, source_id: str) -> dict:
return {
"数据日期": "2026-08-03",
"平台": "京东",
"平台代码": "jd",
"款式": style,
"SourceID": source_id,
"metric": 1,
}
def test_any_feishu_insert_failure_makes_whole_command_fail(monkeypatch) -> None:
style_map = {
style: {
"base_token": "base",
"table_id": "table",
"enabled": True,
"field_map": {"metric": "metric", "SourceID": "SourceID"},
}
for style in ("style-a", "style-b")
}
monkeypatch.setattr(insert, "get_bitable_style_map", lambda: style_map)
monkeypatch.setattr(
insert,
"load_records",
lambda *_args, **_kwargs: [
_daily_record("style-a", "source-a"),
_daily_record("style-b", "source-b"),
],
)
monkeypatch.setattr(insert, "_find_record_id_by_source_id", lambda *_args, **_kwargs: None)
def upsert(_base, _table, fields, **_kwargs):
if fields["SourceID"] == "source-b":
raise RuntimeError("write failed")
return "rec-a"
monkeypatch.setattr(insert, "run_lark_upsert", upsert)
assert insert.main(["--date", "2026-08-03"]) == 1
def test_empty_feishu_export_is_not_success(monkeypatch) -> None:
monkeypatch.setattr(insert, "get_bitable_style_map", lambda: {"style-a": {}})
monkeypatch.setattr(insert, "load_records", lambda *_args, **_kwargs: [])
assert insert.main(["--date", "2026-08-03"]) == 1
@@ -0,0 +1,13 @@
from __future__ import annotations
import aggregate_daily_final as aggregate
import export_bitable_records as export
def test_daily_aggregate_and_export_share_curated_summary_root() -> None:
assert export.SUMMARY_ROOT == aggregate.CURATED_DATA_ROOT
def test_daily_export_keeps_platform_details_in_raw_root() -> None:
assert export.PLATFORM_ROOT == export.DATA_ROOT
assert export.PLATFORM_ROOT != export.SUMMARY_ROOT
@@ -0,0 +1,428 @@
from __future__ import annotations
import json
import time
from datetime import datetime, timezone
from pathlib import Path
from types import SimpleNamespace
import aggregate_daily_final as aggregate
import db as product_db
import import_product_daily as product_import
import orchestrate_daily_collection as daily
import pytest
from commands import import_daily as import_daily_command
class _Context:
def __init__(self, value):
self.value = value
def __enter__(self):
return self.value
def __exit__(self, *_args):
return False
class _Cursor:
def __init__(self, rows):
self.rows = rows
self.execute_args = None
def __enter__(self):
return self
def __exit__(self, *_args):
return False
def execute(self, *args):
self.execute_args = args
def fetchone(self):
return self.rows[0] if self.rows else None
def fetchall(self):
return list(self.rows)
class _Connection:
def __init__(self, rows):
self.cursor_instance = _Cursor(rows)
def cursor(self):
return self.cursor_instance
def _record() -> dict:
return {
"platform": "dy",
"stat_date": datetime(2026, 8, 3).date(),
"product_id": "product-1",
"source_file": "report.xlsx",
}
def _patch_valid_import(monkeypatch, *, upserted: int = 1) -> None:
target_dir = Path("2026-08-03")
monkeypatch.setattr(product_import, "list_date_dirs", lambda _platform: [target_dir])
monkeypatch.setattr(product_import, "find_target_files", lambda _dir, _platform: [Path("report.xlsx")])
monkeypatch.setattr(product_import, "parse_records_for_date", lambda _platform, _dir: [_record()])
monkeypatch.setattr(product_import, "get_conn", lambda: _Context(object()))
monkeypatch.setattr(
product_import,
"upsert_product_daily_metrics",
lambda _conn, _records: upserted,
)
def test_selected_platform_collector_failure_stops_daily_aggregation(monkeypatch) -> None:
results = iter(
[
{"name": "jd", "code": 0},
{"name": "dy", "code": 1, "error": "collector failed"},
]
)
monkeypatch.setattr(daily, "run_step_with_retry", lambda *_args, **_kwargs: next(results))
monkeypatch.setattr(daily, "verify_platform_artifacts", lambda *_args: 1)
with pytest.raises(RuntimeError, match="required platform collector.*dy"):
daily.run_platforms(
parallel=False,
platforms=["jd", "dy"],
target_date="2026-08-03",
)
def test_platform_selection_keeps_omitted_collectors_optional(monkeypatch) -> None:
calls: list[str] = []
def fake_run(name, *_args, **_kwargs):
calls.append(name)
return {"name": name, "code": 0}
monkeypatch.setattr(daily, "run_step_with_retry", fake_run)
monkeypatch.setattr(daily, "verify_platform_artifacts", lambda *_args: 1)
results = daily.run_platforms(
parallel=False,
platforms=["jd"],
target_date="2026-08-03",
)
assert calls == ["jd"]
assert results == [{"name": "jd", "code": 0}]
def test_successful_collector_exit_still_requires_fresh_artifact(monkeypatch) -> None:
monkeypatch.setattr(
daily,
"run_step_with_retry",
lambda *_args, **_kwargs: {"name": "jd", "code": 0},
)
monkeypatch.setattr(
daily,
"verify_platform_artifacts",
lambda *_args: (_ for _ in ()).throw(RuntimeError("missing-or-stale")),
)
with pytest.raises(RuntimeError, match="missing-or-stale"):
daily.run_platforms(
parallel=False,
platforms=["jd"],
target_date="2026-08-03",
)
@pytest.mark.parametrize(
("platform", "payload"),
[
(
"jd",
{
"采集日期": "2026-08-03",
"matched_row_count": 0,
"download_file": "jd.xlsx",
},
),
(
"dy",
{
"date_range": "2026-08-03 ~ 2026-08-03",
"matched_row_count": 0,
"download_file": "dy.xlsx",
},
),
(
"tm",
{
"date": "2026-08-03",
"source_file": "tm.xlsx",
"style": {"匹配商品ID数": 0},
},
),
],
)
def test_zero_matched_product_is_not_a_valid_artifact(platform, payload) -> None:
assert daily._artifact_has_valid_product(platform, payload, "2026-08-03") is False
def test_platform_artifact_verifier_rejects_missing_configured_style(
monkeypatch,
tmp_path,
) -> None:
class Loader:
def get_daily_styles(self):
return {"jd": {"collectable": ["style-a", "style-b"], "skipped": []}}
monkeypatch.setattr(daily, "StyleConfigLoader", Loader)
monkeypatch.setattr(daily, "DATA_ROOT", tmp_path)
marker_ns = time.time_ns()
path = daily._platform_artifact_path("jd", "style-a", "2026-08-03")
path.parent.mkdir(parents=True)
path.write_text(
json.dumps({
"采集日期": "2026-08-03",
"matched_row_count": 1,
"download_file": "_downloads/2026-08-03/report.xlsx",
}),
encoding="utf-8",
)
with pytest.raises(RuntimeError, match="style-b:missing-or-stale"):
daily.verify_platform_artifacts("jd", "2026-08-03", marker_ns)
def test_platform_artifact_verifier_accepts_fresh_valid_product(
monkeypatch,
tmp_path,
) -> None:
class Loader:
def get_daily_styles(self):
return {"jd": {"collectable": ["style-a"], "skipped": []}}
monkeypatch.setattr(daily, "StyleConfigLoader", Loader)
monkeypatch.setattr(daily, "DATA_ROOT", tmp_path)
marker_ns = time.time_ns()
path = daily._platform_artifact_path("jd", "style-a", "2026-08-03")
path.parent.mkdir(parents=True)
path.write_text(
json.dumps({
"采集日期": "2026-08-03",
"matched_row_count": 2,
"download_file": "_downloads/2026-08-03/report.xlsx",
}),
encoding="utf-8",
)
assert daily.verify_platform_artifacts("jd", "2026-08-03", marker_ns) == 1
def test_review_import_remains_explicitly_noncritical(monkeypatch) -> None:
args = SimpleNamespace(
skip_erp=True,
skip_platforms=False,
serial_platforms=False,
dry_run=False,
skip_reviews_import=False,
)
monkeypatch.setattr(daily, "run_platforms", lambda **_kwargs: [])
calls = []
monkeypatch.setattr(
daily,
"run_step_with_retry",
lambda *args, **kwargs: calls.append((args, kwargs)) or {"name": args[0], "code": 1},
)
daily.run_collect(args, "2026-08-03", ["jd"], failed=None)
assert len(calls) == 1
assert calls[0][0][0] == "import_product_reviews"
assert calls[0][1]["critical"] is False
def test_formal_aggregation_fails_when_postgres_write_is_not_fresh(monkeypatch) -> None:
marker = datetime(2026, 8, 4, 3, 0, tzinfo=timezone.utc)
args = SimpleNamespace(dry_run=False)
monkeypatch.setattr(daily, "database_write_marker", lambda: marker)
monkeypatch.setattr(
daily,
"run_step_with_retry",
lambda *_args, **_kwargs: {"name": "aggregate_daily_final", "code": 0},
)
monkeypatch.setattr(
daily,
"verify_daily_metrics_write",
lambda *_args, **_kwargs: (_ for _ in ()).throw(
RuntimeError("daily aggregation produced no fresh PostgreSQL rows")
),
)
with pytest.raises(RuntimeError, match="no fresh PostgreSQL rows"):
daily.run_analyze(args, "2026-08-03", failed=None)
def test_dry_run_aggregation_does_not_require_postgres(monkeypatch) -> None:
args = SimpleNamespace(dry_run=True)
monkeypatch.setattr(
daily,
"database_write_marker",
lambda: pytest.fail("dry-run must not connect to PostgreSQL"),
)
monkeypatch.setattr(
daily,
"verify_daily_metrics_write",
lambda *_args: pytest.fail("dry-run must not verify PostgreSQL"),
)
calls = []
monkeypatch.setattr(
daily,
"run_step_with_retry",
lambda *args, **kwargs: calls.append((args, kwargs)) or {"name": args[0], "code": 0},
)
daily.run_analyze(args, "2026-08-03", failed=None)
assert calls[0][1]["dry_run"] is True
def test_aggregate_command_rejects_zero_style_outputs(monkeypatch, tmp_path) -> None:
monkeypatch.setattr(aggregate, "collect_style_names", lambda *_args: [])
monkeypatch.setattr(aggregate, "managed_data_path", lambda _path: tmp_path)
assert aggregate.main([
"--date",
"2026-08-03",
"--data-root",
str(tmp_path),
]) == 1
def test_aggregate_command_rejects_partial_postgres_write(monkeypatch, tmp_path) -> None:
payload = {
"date": "2026-08-03",
"style_name": "style-a",
"erp": {"style_codes": ["erp-a"]},
"jd": {
"sales": 1,
"refund_orders": 0,
"visitors": 2,
"cart_users": 1,
"source": "jd.json",
},
"dy": {},
"tm": {},
}
monkeypatch.setattr(aggregate, "collect_style_names", lambda *_args: ["style-a"])
monkeypatch.setattr(aggregate, "managed_data_path", lambda _path: tmp_path)
monkeypatch.setattr(aggregate, "build_payload", lambda *_args: payload)
monkeypatch.setattr(aggregate, "write_outputs", lambda *_args: (tmp_path / "a", tmp_path / "b"))
monkeypatch.setattr(product_db, "get_conn", lambda: _Context(object()))
monkeypatch.setattr(product_db, "upsert_daily_metrics", lambda *_args: 0)
assert aggregate.main([
"--date",
"2026-08-03",
"--data-root",
str(tmp_path),
]) == 1
def test_postgres_verifier_rejects_stale_or_missing_rows(monkeypatch) -> None:
connection = _Connection([])
monkeypatch.setattr(daily, "get_conn", lambda: _Context(connection))
marker = datetime(2026, 8, 4, 3, 0, tzinfo=timezone.utc)
with pytest.raises(RuntimeError, match="missing fresh PostgreSQL rows"):
daily.verify_daily_metrics_write(
"2026-08-03",
marker,
required_platforms=["jd"],
expected_pairs={("jd", "style-a")},
)
assert connection.cursor_instance.execute_args[1] == (
"2026-08-03",
marker,
["jd"],
)
def test_postgres_verifier_requires_every_expected_platform_style(monkeypatch) -> None:
connection = _Connection([("jd", "style-a"), ("dy", "style-a")])
monkeypatch.setattr(daily, "get_conn", lambda: _Context(connection))
marker = datetime(2026, 8, 4, 3, 0, tzinfo=timezone.utc)
with pytest.raises(RuntimeError, match="tm/style-a"):
daily.verify_daily_metrics_write(
"2026-08-03",
marker,
required_platforms=["jd", "dy", "tm"],
expected_pairs={
("jd", "style-a"),
("dy", "style-a"),
("tm", "style-a"),
},
)
def test_import_fails_when_selected_platform_has_no_target_directory(monkeypatch, capsys) -> None:
monkeypatch.setattr(product_import, "list_date_dirs", lambda _platform: [])
code = product_import.main(["--date", "2026-08-03", "--platforms", "dy"])
assert code == 2
assert "dy: 没有匹配所选日期范围的报表目录" in capsys.readouterr().err
def test_import_fails_when_target_directory_has_no_input_file(monkeypatch, capsys) -> None:
target_dir = Path("2026-08-03")
monkeypatch.setattr(product_import, "list_date_dirs", lambda _platform: [target_dir])
monkeypatch.setattr(product_import, "find_target_files", lambda _dir, _platform: [])
monkeypatch.setattr(product_import, "parse_records_for_date", lambda _platform, _dir: [])
code = product_import.main(["--date", "2026-08-03", "--platforms", "dy"])
assert code == 2
assert "目标目录中没有可导入的报表文件" in capsys.readouterr().err
def test_import_fails_when_input_has_no_valid_rows(monkeypatch, capsys) -> None:
target_dir = Path("2026-08-03")
monkeypatch.setattr(product_import, "list_date_dirs", lambda _platform: [target_dir])
monkeypatch.setattr(product_import, "find_target_files", lambda _dir, _platform: [Path("report.xlsx")])
monkeypatch.setattr(product_import, "parse_records_for_date", lambda _platform, _dir: [])
code = product_import.main(["--date", "2026-08-03", "--platforms", "dy"])
assert code == 2
assert "报表中没有有效商品明细行" in capsys.readouterr().err
def test_import_fails_when_postgres_reports_zero_written_rows(monkeypatch, capsys) -> None:
_patch_valid_import(monkeypatch, upserted=0)
code = product_import.main(["--date", "2026-08-03", "--platforms", "dy"])
assert code == 2
assert "PostgreSQL 未写入任何商品明细行" in capsys.readouterr().err
def test_valid_selected_platform_input_still_imports_successfully(monkeypatch) -> None:
_patch_valid_import(monkeypatch, upserted=1)
code = product_import.main(["--date", "2026-08-03", "--platforms", "dy"])
assert code == 0
def test_import_command_passes_target_date_and_propagates_failure(monkeypatch) -> None:
monkeypatch.setenv("GYXX_BUSINESS_DATE", "2026-08-04")
invoked = []
monkeypatch.setattr(
import_daily_command,
"import_main",
lambda argv: invoked.append(argv) or 2,
)
assert import_daily_command.main() == 2
assert invoked == [["--date", "2026-08-03"]]
@@ -0,0 +1,42 @@
import unittest
from db import build_pg_config
class DatabaseConfigTests(unittest.TestCase):
def test_complete_config_is_parsed(self):
config = build_pg_config(
{
"PG_HOST": "db.example.test",
"PG_PORT": "5544",
"PG_DB": "warehouse",
"PG_USER": "collector",
"PG_PASSWORD": "secret",
}
)
self.assertEqual(
config,
{
"host": "db.example.test",
"port": 5544,
"dbname": "warehouse",
"user": "collector",
"password": "secret",
},
)
def test_missing_final_config_is_rejected(self):
with self.assertRaisesRegex(RuntimeError, "PG_HOST"):
build_pg_config(
{
"PG_PORT": "5432",
"PG_DB": "warehouse",
"PG_USER": "collector",
"PG_PASSWORD": "secret",
}
)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,286 @@
import unittest
from unittest.mock import MagicMock, patch
from collect_dy_market_rank import (
TARGET_DY_MARKET_CATEGORIES,
append_feishu_category_table,
build_dy_category_table_xml,
build_dy_feishu_batch_xml,
build_dy_feishu_intro_xml,
is_excluded_product_title,
navigate_to_product_rank,
parse_market_product_rank_html,
parse_money_range_upper,
payment_metric_header_index,
require_payment_metric_header,
)
from collect_sycm_market_rank import build_feishu_batch_xml
SAMPLE_HTML = """
<table>
<thead>
<tr>
<th>排名</th><th>商品</th><th>店铺名称</th>
<th>用户支付金额</th><th>点击次数</th><th>成交件数</th><th>操作</th>
</tr>
</thead>
<tbody>
<tr aria-hidden="true"><td>排名</td><td>商品</td><td></td><td></td></tr>
<tr class="aurora-table-row" data-row-key="3826298175627592082_1">
<td><div class="tagItem">首次上榜</div></td>
<td>
<img src="https://p9-aio.ecombdimg.com/product.jpg"/>
<div class="name-Gh2R04">测试旅行拉杆箱</div>
<div class="price-HH6TP1">价格带 <span>¥1889-¥2289</span></div>
</td>
<td>测试旗舰店</td>
<td><div>¥2,500万</div>-<div>¥5,000万</div></td>
<td>250万-500万</td><td>10万-25万</td><td>查看详情</td>
</tr>
<tr class="aurora-table-row" data-row-key="3826298175627592083_3">
<td><div>↑4</div></td>
<td>
<img src="https://p9-aio.ecombdimg.com/product-3.jpg"/>
<div elementtiming="pccp_element">第三名旅行箱</div>
<div>价格带 <span>¥999</span></div>
</td>
<td>第三名旗舰店</td>
<td><div>¥750万</div>-<div>¥1,000万</div></td>
<td>10万</td><td>1万</td><td>查看详情</td>
</tr>
</tbody>
</table>
"""
class DyMarketRankTests(unittest.TestCase):
def test_title_blacklist_filters_children_primary_and_middle_school(self):
self.assertTrue(is_excluded_product_title("儿童轻便双肩包"))
self.assertTrue(is_excluded_product_title("小学生护脊书包"))
self.assertTrue(is_excluded_product_title("初中生大容量背包"))
self.assertTrue(
is_excluded_product_title(
"TigerFamily【老爸抽检】学生生日礼物1-3年级男女护脊减负折叠书包"
)
)
self.assertTrue(
is_excluded_product_title(
"【官方旗舰】AIRJORDAN耐克青少年运动休闲男女大号双肩书包9300"
)
)
self.assertTrue(is_excluded_product_title("老人轻便防水随身包"))
self.assertTrue(is_excluded_product_title("高中生大容量双肩包"))
self.assertFalse(is_excluded_product_title("成人通勤双肩包"))
def test_default_categories_match_requested_eight_filters(self):
specs = {item["name"]: item for item in TARGET_DY_MARKET_CATEGORIES}
self.assertEqual(
set(specs),
{"运动包", "旅行箱", "托特包", "双肩包", "斜挎包", "胸包", "手机包", "电脑包"},
)
self.assertEqual((specs["运动包"]["min_price"], specs["运动包"]["max_price"]), (200, 1000))
self.assertEqual((specs["旅行箱"]["min_price"], specs["旅行箱"]["max_price"]), (400, 2000))
self.assertEqual((specs["托特包"]["min_price"], specs["托特包"]["max_price"]), (200, 3000))
self.assertEqual(specs["托特包"]["search_keyword"], "托特包")
self.assertEqual((specs["手机包"]["min_price"], specs["手机包"]["max_price"]), (100, 1000))
self.assertEqual(specs["手机包"]["paths"][0][-1], "耳机包")
self.assertEqual((specs["电脑包"]["min_price"], specs["电脑包"]["max_price"]), (200, 1000))
self.assertEqual(specs["电脑包"]["search_keyword"], "电脑包")
self.assertEqual(specs["电脑包"]["paths"][0][-2:], ("功能箱包", "全部"))
def test_parse_money_range_upper(self):
self.assertEqual(parse_money_range_upper("¥2,500万-¥5,000万"), 50_000_000)
self.assertEqual(parse_money_range_upper("¥750万"), 7_500_000)
def test_payment_metric_header_uses_exact_supported_aliases(self):
self.assertEqual(
payment_metric_header_index(["排名", "支付金额(元)", "成交人数"]),
1,
)
self.assertEqual(
payment_metric_header_index(["排名", "成交金额", "成交人数"]),
1,
)
self.assertIsNone(
payment_metric_header_index(["排名", "用户支付金额同比", "成交人数"])
)
def test_missing_payment_metric_reports_current_headers(self):
page = MagicMock()
page.locator.return_value.all_inner_texts.return_value = [
"排名",
"商品",
"成交人数",
]
with self.assertRaisesRegex(
RuntimeError,
"允许的精确候选.*当前表头.*成交人数",
):
require_payment_metric_header(page)
def test_login_probe_can_open_rank_page_without_waiting_for_metrics(self):
page = MagicMock()
with patch("collect_dy_market_rank.wait_for_product_rank_ready") as ready:
navigate_to_product_rank(page, wait_until_ready=False)
page.goto.assert_called_once()
ready.assert_not_called()
def test_parser_accepts_supported_payment_metric_alias(self):
rows = parse_market_product_rank_html(
SAMPLE_HTML.replace("用户支付金额", "支付金额(元)"),
category_name="旅行箱",
category_path="鞋靴箱包 > 箱包 > 功能箱包 > 旅行箱/拉杆箱",
)
self.assertEqual(len(rows), 2)
def test_parse_rank_html_extracts_requested_fields(self):
rows = parse_market_product_rank_html(
SAMPLE_HTML,
category_name="旅行箱",
category_path="鞋靴箱包 > 箱包 > 功能箱包 > 旅行箱/拉杆箱",
)
self.assertEqual(len(rows), 2)
row = rows[0]
self.assertEqual(row["rank"], 1)
self.assertEqual(row["product_id"], "3826298175627592082")
self.assertEqual(row["name"], "测试旅行拉杆箱")
self.assertEqual(row["image_url"], "https://p9-aio.ecombdimg.com/product.jpg")
self.assertEqual(row["price_range"], "¥1889-¥2289")
self.assertEqual(row["sales_amount_range"], "¥2,500万-¥5,000万")
self.assertEqual(row["sales_amount_upper"], 50_000_000)
self.assertEqual(
row["product_url"],
"https://haohuo.jinritemai.com/views/product/item2?id=3826298175627592082",
)
self.assertEqual(rows[1]["rank"], 3)
def test_parse_rank_html_drops_blacklisted_title(self):
rows = parse_market_product_rank_html(
SAMPLE_HTML.replace("测试旅行拉杆箱", "小学生测试旅行拉杆箱"),
category_name="旅行箱",
category_path="鞋靴箱包 > 箱包 > 功能箱包 > 旅行箱/拉杆箱",
)
self.assertEqual([row["name"] for row in rows], ["第三名旅行箱"])
def test_dy_feishu_xml_contains_sales_image_and_link(self):
product = parse_market_product_rank_html(
SAMPLE_HTML,
category_name="旅行箱",
category_path="鞋靴箱包 > 箱包 > 功能箱包 > 旅行箱/拉杆箱",
)[0]
xml = build_dy_feishu_batch_xml([product], start_rank=1)
self.assertIn(">销售额</th>", xml)
self.assertIn("¥2,500万-¥5,000万", xml)
self.assertIn('<img href="https://p9-aio.ecombdimg.com/product.jpg"', xml)
self.assertIn("https://haohuo.jinritemai.com/views/product/item2?id=3826298175627592082", xml)
def test_category_output_is_one_table_per_category(self):
product = parse_market_product_rank_html(
SAMPLE_HTML,
category_name="旅行箱",
category_path="鞋靴箱包 > 箱包 > 功能箱包 > 旅行箱/拉杆箱",
)[0]
xml = build_dy_category_table_xml("旅行箱", [product, dict(product, rank=2)])
self.assertEqual(xml.count("<table>"), 1)
self.assertEqual(xml.count("<th "), 7)
self.assertIn("<h2>旅行箱(2条)</h2>", xml)
self.assertIn("<td>1</td>", xml)
self.assertIn("<td>2</td>", xml)
def test_category_upload_timeout_is_verified_before_continuing(self):
product = parse_market_product_rank_html(
SAMPLE_HTML,
category_name="旅行箱",
category_path="鞋靴箱包 > 箱包 > 功能箱包 > 旅行箱/拉杆箱",
)[0]
with (
patch(
"collect_dy_market_rank.append_feishu_doc",
side_effect=RuntimeError("server time out error"),
),
patch(
"collect_dy_market_rank._feishu_doc_contains_keyword",
side_effect=[False, True, True],
) as verify,
):
append_feishu_category_table(
"https://example.feishu.cn/docx/test",
"旅行箱",
[product],
2,
)
self.assertEqual(verify.call_count, 3)
def test_category_upload_skips_complete_existing_table(self):
product = parse_market_product_rank_html(
SAMPLE_HTML,
category_name="旅行箱",
category_path="鞋靴箱包 > 箱包 > 功能箱包 > 旅行箱/拉杆箱",
)[0]
with (
patch("collect_dy_market_rank.append_feishu_doc") as append,
patch(
"collect_dy_market_rank._feishu_doc_contains_keyword",
return_value=True,
),
):
append_feishu_category_table(
"https://example.feishu.cn/docx/test",
"旅行箱",
[product],
2,
)
append.assert_not_called()
def test_dy_feishu_intro_records_price_and_search_filters(self):
xml = build_dy_feishu_intro_xml(
title="抖音市场榜单",
period="近30天",
products=[
{
"category_name": "托特包",
"filter_min_price": 200,
"filter_max_price": 3000,
"search_keyword": "托特包",
}
],
)
self.assertIn("托特包 ¥200-¥3000(搜索:托特包)", xml)
self.assertIn("标题过滤", xml)
for keyword in ("儿童", "学生", "青少年", "老人", "中学", "书包"):
self.assertIn(keyword, xml)
def test_tmall_feishu_table_also_has_sales_column(self):
xml = build_feishu_batch_xml(
[{
"category_name": "旅行箱",
"rank": 1,
"name": "测试商品",
"buyer_range": "2500~5000",
"buyer_max": 5000,
"price": "¥418",
"product_url": "https://item.taobao.com/item.htm?id=1",
"image_url": "https://img.alicdn.com/test.jpg",
}],
start_rank=1,
)
self.assertIn(">销售额</th>", xml)
self.assertIn(">未获取</td>", xml)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,89 @@
import json
import sys
from pathlib import Path
VENDOR_ROOT = (
Path(__file__).resolve().parents[3]
/ "src"
/ "gyxx_flow"
/ "modules"
/ "product_commerce"
/ "vendors"
/ "dy-data-flow"
)
sys.path.insert(0, str(VENDOR_ROOT))
from dy_store_competitor_store_scraping import ( # noqa: E402
_check_login_status,
_choose_session_root,
_looks_like_login_url,
)
def _write_cookies(root: Path, domains: list[str]) -> None:
root.mkdir(parents=True, exist_ok=True)
cookies = [{"name": f"c{index}", "value": "x", "domain": domain, "path": "/"}
for index, domain in enumerate(domains)]
(root / "compass_cookies.json").write_text(
json.dumps(cookies), encoding="utf-8"
)
def test_choose_session_root_prefers_complete_sso_bundle(tmp_path):
project = tmp_path / "project"
legacy = tmp_path / "legacy"
_write_cookies(project, [".compass.jinritemai.com"])
_write_cookies(
legacy,
[
".compass.jinritemai.com",
".doudian-sso.jinritemai.com",
".fxg.jinritemai.com",
],
)
assert _choose_session_root([project, legacy]) == legacy
def test_choose_session_root_honors_explicit_root(tmp_path):
project = tmp_path / "project"
explicit = tmp_path / "explicit"
_write_cookies(project, [".compass.jinritemai.com", ".fxg.jinritemai.com"])
assert _choose_session_root([project], explicit=explicit) == explicit
def test_login_redirect_url_detection():
assert _looks_like_login_url("https://compass.jinritemai.com/login?redirect=x")
assert _looks_like_login_url("https://doudian-sso.jinritemai.com/login")
assert _looks_like_login_url("https://fxg.jinritemai.com/passport/sso")
assert not _looks_like_login_url(
"https://compass.jinritemai.com/shop/commodity/product-list"
)
class _EmptyLocator:
def count(self):
return 0
class _FakeContext:
pages = []
class _FakeShopPage:
url = "https://compass.jinritemai.com/shop/commodity/product-list"
context = _FakeContext()
def __init__(self):
self.context.pages = [self]
def is_closed(self):
return False
def locator(self, _selector):
return _EmptyLocator()
def test_shop_url_without_authenticated_dom_is_not_logged_in():
assert not _check_login_status(_FakeShopPage())
@@ -0,0 +1,92 @@
import json
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
import erp_metric_overrides as overrides
class ErpMetricOverridesTests(unittest.TestCase):
def setUp(self):
self.temp_dir = tempfile.TemporaryDirectory()
self.data_root = Path(self.temp_dir.name)
def tearDown(self):
self.temp_dir.cleanup()
def _write_erp(self, code_results):
data_dir = self.data_root / "dy" / "apollo_2026-07-22"
data_dir.mkdir(parents=True)
(data_dir / "data.json").write_text(
json.dumps(
{
"style_name": "阿波罗x1",
"erp_style_codes": ["10388"],
"date_start": "2026-07-22",
"yesterday_sales": 0,
"yesterday_returns": 0,
"code_results": code_results,
},
ensure_ascii=False,
),
encoding="utf-8",
)
def _apply(self, platform_sales, platform_refunds):
with patch.object(overrides, "DATA_ROOT", self.data_root), patch.dict(
overrides.PLATFORM_DIRS, {"dy": "dy"}
):
return overrides.apply_erp_override(
"dy", "阿波罗x1", 394, 46,
target_date="2026-07-22",
platform_sales=platform_sales,
platform_refunds=platform_refunds,
)
def test_error_makes_ostensibly_ok_result_unavailable(self):
self._write_erp([{
"erp_style_code": "10388",
"ok": True,
"reason": "no_data",
"error": "timeout waiting for report result: 10388 | allsku iframe not found",
"yesterday_sales": 0,
"yesterday_returns": 0,
}])
result = self._apply(platform_sales=8, platform_refunds=2)
self.assertTrue(result["erp_unavailable"])
self.assertEqual(result["销量"], 8)
self.assertEqual(result["退款订单数"], 2)
def test_any_failed_code_disables_partial_erp_override(self):
self._write_erp([
{"erp_style_code": "1", "ok": True, "yesterday_sales": 3, "yesterday_returns": 1},
{"erp_style_code": "2", "ok": False, "error": "timeout", "yesterday_sales": 0, "yesterday_returns": 0},
])
result = self._apply(platform_sales=7, platform_refunds=2)
self.assertTrue(result["erp_unavailable"])
self.assertEqual(result["销量"], 7)
self.assertEqual(result["退款订单数"], 2)
def test_clean_no_data_is_a_reliable_zero(self):
self._write_erp([{
"erp_style_code": "10388",
"ok": True,
"reason": "no_data",
"yesterday_sales": 0,
"yesterday_returns": 0,
}])
result = self._apply(platform_sales=8, platform_refunds=2)
self.assertFalse(result["erp_unavailable"])
self.assertEqual(result["销量"], 0)
self.assertEqual(result["退款订单数"], 0)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,42 @@
import unittest
from collect_erp_yesterday_metrics import _no_data_report_is_fresh
class ErpNoDataFreshnessTest(unittest.TestCase):
def test_rejects_unchanged_no_data_page_from_previous_query(self):
self.assertFalse(
_no_data_report_is_fresh(
previous_url="https://bi.erp321.com/allsku.aspx?r=old",
current_url="https://bi.erp321.com/allsku.aspx?r=old",
previous_body="NO_DATA",
current_body="NO_DATA",
reloaded=False,
)
)
def test_accepts_no_data_after_forced_reload(self):
self.assertTrue(
_no_data_report_is_fresh(
previous_url="https://bi.erp321.com/allsku.aspx?r=old",
current_url="https://bi.erp321.com/allsku.aspx?r=new",
previous_body="NO_DATA",
current_body="NO_DATA",
reloaded=True,
)
)
def test_accepts_no_data_when_report_body_changed(self):
self.assertTrue(
_no_data_report_is_fresh(
previous_url="https://bi.erp321.com/allsku.aspx?r=same",
current_url="https://bi.erp321.com/allsku.aspx?r=same",
previous_body="OLD REPORT",
current_body="NO_DATA",
reloaded=False,
)
)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,38 @@
import ast
import unittest
from pathlib import Path
PROJECT_ROOT = (
Path(__file__).resolve().parents[3]
/ "src"
/ "gyxx_flow"
/ "modules"
/ "product_commerce"
)
ERP_COLLECTOR = PROJECT_ROOT / "collect_erp_yesterday_metrics.py"
def load_slow_skip_codes() -> set[str]:
tree = ast.parse(ERP_COLLECTOR.read_text(encoding="utf-8"))
for node in tree.body:
if isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name):
if node.target.id == "ERP_SLOW_SKIP_CODES":
if (
isinstance(node.value, ast.Call)
and isinstance(node.value.func, ast.Name)
and node.value.func.id == "set"
and not node.value.args
and not node.value.keywords
):
return set()
return set(ast.literal_eval(node.value))
raise AssertionError("ERP_SLOW_SKIP_CODES not found")
class ErpSlowSkipCodesTest(unittest.TestCase):
def test_no_styles_are_permanently_skipped(self):
self.assertEqual(load_slow_skip_codes(), set())
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,52 @@
import pytest
from feishu_doc_native import (
FeishuNativeDocClient,
FeishuNativeDocError,
append_native_category_table,
parse_category_table_xml,
)
def test_parse_category_table_xml_keeps_text_link_and_image():
table = parse_category_table_xml(
'<h2>旅行箱(1条)</h2><table><colgroup><col width="80"/>'
'<col width="120"/></colgroup><thead><tr><th>商品图</th><th>商品</th></tr>'
'<tbody><tr><td><img href="https://img.test/a.jpg"/></td>'
'<td><a href="https://item.test/1">打开商品</a></td></tr></tbody></table>'
)
assert table.heading == "旅行箱(1条)"
assert table.widths == [80, 120]
assert table.rows[1][0].image_url == "https://img.test/a.jpg"
assert table.rows[1][1].link_url == "https://item.test/1"
def test_batch_update_splits_image_resources_into_twenty(monkeypatch):
client = FeishuNativeDocClient("test-token")
calls = []
def fake_request(method, path, **kwargs):
calls.append(kwargs["json_body"]["requests"])
return {}
monkeypatch.setattr(client, "request", fake_request)
requests_ = [
{"block_id": f"img-{index}", "replace_image": {"token": f"t-{index}"}}
for index in range(45)
]
client.batch_update("doc", requests_)
assert [len(batch) for batch in calls] == [20, 20, 5]
def test_rejects_table_over_feishu_2000_cell_limit_before_api_call():
rows = "".join("<tr>" + "<td>x</td>" * 8 + "</tr>" for _ in range(251))
xml = "<h2>超大类目</h2><table><tbody>" + rows + "</tbody></table>"
with pytest.raises(FeishuNativeDocError, match="超过飞书单表 2000"):
append_native_category_table(
"https://example.feishu.cn/docx/test",
xml,
client=object(),
)
@@ -0,0 +1,469 @@
import unittest
from pathlib import Path
from tempfile import TemporaryDirectory
from unittest.mock import Mock, patch
from upload_video_to_guanghe import (
GuangHeUploader,
_match_tm_item_ids_from_product_titles,
download_videos,
)
class GuangHeMetadataTests(unittest.TestCase):
def test_downloads_with_same_attachment_name_are_isolated_by_record(self):
attachment = {"file_token": "file-token", "name": "same-name.mp4"}
with TemporaryDirectory() as tmp_dir, patch(
"upload_video_to_guanghe.DOWNLOAD_DIR", Path(tmp_dir)
), patch("upload_video_to_guanghe.run_lark") as run_lark_mock:
def fake_download(args):
output_dir = Path(args[args.index("--output") + 1])
output_dir.mkdir(parents=True, exist_ok=True)
(output_dir / attachment["name"]).write_bytes(b"video")
return 0, "", ""
run_lark_mock.side_effect = fake_download
first = download_videos("record-one", [attachment])
second = download_videos("record-two", [attachment])
self.assertEqual(first[0].parent.name, "record-one")
self.assertEqual(second[0].parent.name, "record-two")
self.assertNotEqual(first[0], second[0])
def test_unresolved_style_uses_exact_database_product_title_match(self):
result = _match_tm_item_ids_from_product_titles(
["宙斯3", "未建档款"],
[
(
"986141740958",
"GYXX/光影行星宙斯3双肩包男士背包机能大容量",
),
("655191144133", "GYXX光影行星宙斯双肩包男士电脑包"),
],
)
self.assertEqual(result, {"宙斯3": ["986141740958"]})
def test_product_search_term_removes_series_suffix(self):
self.assertEqual(
GuangHeUploader._normalize_product_search_term("宙斯系列"),
"宙斯",
)
self.assertEqual(
GuangHeUploader._normalize_product_search_term(" 宙斯 系列款 "),
"宙斯",
)
self.assertEqual(
GuangHeUploader._normalize_product_search_term("星迹2"),
"星迹2",
)
def test_cover_generation_pending_marker_is_not_upload_done(self):
self.assertTrue(GuangHeUploader._cover_generation_is_pending("封面生成中"))
self.assertFalse(GuangHeUploader._cover_generation_is_pending("智能封面图"))
def test_content_label_option_allows_browse_count_but_rejects_longer_label(self):
self.assertTrue(
GuangHeUploader._content_label_text_matches("# 斜挎包 41673031次浏览", "斜挎包")
)
self.assertTrue(
GuangHeUploader._content_label_text_matches("商品展示", "商品展示")
)
self.assertTrue(
GuangHeUploader._content_label_text_matches("#斜挎包", "斜挎包")
)
self.assertFalse(
GuangHeUploader._content_label_text_matches("# 单肩斜挎包 32871826次浏览", "斜挎包")
)
def test_topic_result_card_requires_topic_and_activity_statistics(self):
self.assertTrue(
GuangHeUploader._topic_result_card_text_matches(
"# 在淘宝种草一夏 小二推荐 作品数469.5万 参与数16.2万",
"在淘宝种草一夏",
)
)
self.assertFalse(
GuangHeUploader._topic_result_card_text_matches(
"在淘宝种草一夏",
"在淘宝种草一夏",
)
)
self.assertTrue(GuangHeUploader._topic_card_box_is_clickable(620, 120))
self.assertFalse(GuangHeUploader._topic_card_box_is_clickable(620, 45))
self.assertFalse(GuangHeUploader._topic_card_box_is_clickable(720, 600))
def test_poseidon_edge_uses_authoritative_tote_category(self):
self.assertEqual(
GuangHeUploader._resolve_title_category(
"波塞冬edge",
"双肩包、电脑包、托特包、大容量",
),
"托特包",
)
self.assertEqual(
GuangHeUploader._resolve_title_category("星迹2", ""),
"斜挎包",
)
def test_invalid_online_info_is_rejected_when_main_category_conflicts(self):
result = GuangHeUploader._validate_online_product_info(
"波塞冬edge",
"主包型:双肩包;尺寸:39cm;容量/适配:16寸;材质:尼龙;卖点:分区",
)
self.assertEqual(result, "")
def test_verified_fallback_info_contains_all_product_fields(self):
info = GuangHeUploader._verified_product_info("星迹2")
for field in ["主包型:", "尺寸:", "容量/适配:", "材质:", "卖点:"]:
self.assertIn(field, info)
self.assertIn("斜挎包", info)
def test_title_category_uses_name_then_search_features(self):
self.assertEqual(
GuangHeUploader._resolve_title_category("盖亚斜挎", "轻量、大容量"),
"斜挎包",
)
self.assertEqual(
GuangHeUploader._resolve_title_category("宙斯", "双肩包、大容量、笔电仓"),
"双肩包",
)
def test_title_normalization_keeps_search_structure_and_uses_computer_bag_term(self):
title = GuangHeUploader._normalize_generated_title(
"宙斯笔电大容量通勤真能装",
["宙斯"],
"双肩包",
)
self.assertTrue(title.startswith("宙斯双肩包,"))
self.assertTrue(title.endswith("!"))
self.assertIn("电脑包", title)
self.assertNotIn("笔电", title)
self.assertNotIn("笔记本电脑", title)
self.assertNotIn("电脑包仓", title)
self.assertGreaterEqual(len(title), 26)
self.assertLessEqual(len(title), 30)
def test_title_normalization_removes_compact_duplicate_model_and_broken_tail(self):
title = GuangHeUploader._normalize_generated_title(
"光影行星宙斯zair双肩包,宙斯zair,大容量分区收纳,从容应",
["宙斯z air"],
"双肩包",
)
self.assertTrue(title.startswith("宙斯z air双肩包,"))
self.assertEqual(title.replace(" ", "").count("宙斯zair"), 1)
self.assertNotIn("从容应!", title)
self.assertGreaterEqual(len(title), 26)
self.assertLessEqual(len(title), 30)
def test_same_style_multiple_videos_get_distinct_titles_when_hermes_repeats(self):
uploader = object.__new__(GuangHeUploader)
repeated = "光影行星宙斯双肩包科学分区收纳轻量通勤出行"
with (
patch.object(uploader, "_search_product_features", return_value=""),
patch("upload_video_to_guanghe.HAS_HERMES", True),
patch("upload_video_to_guanghe._call_hermes", return_value=(repeated, {})),
):
first = uploader._generate_title(["宙斯"], video_index=1, video_total=2)
second = uploader._generate_title(["宙斯"], video_index=2, video_total=2)
self.assertNotEqual(first, second)
self.assertTrue(first.startswith("宙斯双肩包,"))
self.assertTrue(second.startswith("宙斯双肩包,"))
def test_same_style_repeated_hermes_copy_gets_low_similarity_bodies(self):
uploader = object.__new__(GuangHeUploader)
repeated = "光影行星宙斯双肩包,科学分区收纳,轻量通勤出行!"
with (
patch.object(uploader, "_search_product_features", return_value=""),
patch("upload_video_to_guanghe.HAS_HERMES", True),
patch("upload_video_to_guanghe._call_hermes", return_value=(repeated, {})),
):
titles = [
uploader._generate_title(["宙斯"], video_index=index, video_total=4)
for index in range(1, 5)
]
bodies = [GuangHeUploader._title_body(title) for title in titles]
self.assertEqual(len(set(bodies)), 4)
for index, body in enumerate(bodies):
for other in bodies[index + 1:]:
self.assertLess(
GuangHeUploader._title_similarity(body, other),
GuangHeUploader.TITLE_SIMILARITY_LIMIT,
)
def test_different_styles_cannot_reuse_same_title_skeleton(self):
uploader = object.__new__(GuangHeUploader)
repeated = "科学分区收纳,轻装通勤出行"
with (
patch.object(uploader, "_search_product_features", return_value=""),
patch("upload_video_to_guanghe.HAS_HERMES", True),
patch("upload_video_to_guanghe._call_hermes", return_value=(repeated, {})),
):
first = uploader._generate_title(["宙斯"], video_index=1)
second = uploader._generate_title(["星云2"], video_index=1)
first_body = GuangHeUploader._title_body(first)
second_body = GuangHeUploader._title_body(second)
self.assertLess(
GuangHeUploader._title_similarity(first_body, second_body),
GuangHeUploader.TITLE_SIMILARITY_LIMIT,
)
def test_title_similarity_ignores_product_prefix(self):
first = "宙斯双肩包,科学分区收纳,轻装通勤出行!"
second = "星云2斜挎包,科学分区收纳,轻装通勤出行!"
self.assertEqual(GuangHeUploader._title_similarity(first, second), 1.0)
def test_full_batch_can_claim_48_globally_distinct_title_bodies(self):
uploader = object.__new__(GuangHeUploader)
titles = []
for index in range(48):
product_names = [f"款式{index % 12 + 1}"]
video_index = index // 12 + 1
candidate = uploader._fallback_title(
product_names,
"双肩包",
variant_index=video_index,
)
titles.append(
uploader._claim_unique_title(
candidate,
product_names,
"双肩包",
variant_index=video_index,
)
)
self.assertEqual(len(set(titles)), 48)
self.assertTrue(all(26 <= len(title) <= 30 for title in titles))
for index, title in enumerate(titles):
for other in titles[index + 1:]:
self.assertLess(
GuangHeUploader._title_similarity(title, other),
GuangHeUploader.TITLE_SIMILARITY_LIMIT,
)
def test_two_store_uploaders_can_share_one_global_title_registry(self):
shared_global = []
shared_styles = {}
shared_usage = {}
first_uploader = object.__new__(GuangHeUploader)
second_uploader = object.__new__(GuangHeUploader)
for uploader in (first_uploader, second_uploader):
uploader._used_titles_global = shared_global
uploader._used_titles_by_products = shared_styles
uploader._title_variant_usage = shared_usage
first = first_uploader._claim_unique_title(
first_uploader._fallback_title(["宙斯"], "双肩包", 1),
["宙斯"],
"双肩包",
1,
)
second = second_uploader._claim_unique_title(
second_uploader._fallback_title(["星云2"], "斜挎包", 1),
["星云2"],
"斜挎包",
1,
)
self.assertLess(
GuangHeUploader._title_similarity(first, second),
GuangHeUploader.TITLE_SIMILARITY_LIMIT,
)
def test_product_search_specs_prefer_tm_item_ids_and_fallback_to_keyword(self):
specs = GuangHeUploader._build_product_search_specs(
["星云2", "未建档系列款"],
{"星云2": ["900176117674", "865283623976"]},
)
self.assertEqual(
specs,
[
{
"product_name": "星云2",
"search_term": "900176117674",
"exact_id": True,
},
{
"product_name": "未建档系列款",
"search_term": "未建档",
"exact_id": False,
},
{
"product_name": "星云2",
"search_term": "865283623976",
"exact_id": True,
},
],
)
def test_exact_id_searches_get_one_store_keyword_fallback(self):
specs = GuangHeUploader._build_product_search_specs(
["极星双肩", "未建档款"],
{"极星双肩": ["972285625931", "966753757238"]},
)
self.assertEqual(
GuangHeUploader._build_exact_search_fallback_specs(specs),
[
{
"product_name": "极星双肩",
"search_term": "极星双肩",
"exact_id": False,
}
],
)
def test_title_fallback_contains_product_category_solution_and_result(self):
title = GuangHeUploader._fallback_title(["盖亚斜挎"], "斜挎包")
self.assertTrue(title.startswith("盖亚斜挎包,"))
self.assertIn(",", title)
self.assertTrue(title.endswith("!"))
self.assertIn("钥匙卡包各归其位", title)
self.assertIn("早高峰取物", title)
self.assertGreaterEqual(title.count(","), 2)
self.assertGreaterEqual(len(title), 26)
self.assertLessEqual(len(title), 30)
def test_generated_title_replaces_generic_category_with_model_category(self):
uploader = object.__new__(GuangHeUploader)
with (
patch.object(uploader, "_search_product_features", return_value=""),
patch("upload_video_to_guanghe.HAS_HERMES", True),
patch(
"upload_video_to_guanghe._call_hermes",
return_value=("光影行星宙斯双肩包大容量笔记本电脑轻量通勤", {}),
),
):
title = uploader._generate_title(["宙斯"])
self.assertTrue(title.startswith("宙斯双肩包,"))
self.assertNotIn("包袋双肩包", title)
def test_generated_poseidon_title_cannot_be_changed_to_backpack_by_hermes(self):
uploader = object.__new__(GuangHeUploader)
with (
patch.object(
uploader,
"_search_product_features",
return_value="双肩包、电脑包、托特包、大容量",
),
patch("upload_video_to_guanghe.HAS_HERMES", True),
patch(
"upload_video_to_guanghe._call_hermes",
return_value=(
"光影行星波塞冬edge双肩包暴雨通勤防泼水电脑仓从容出行",
{},
),
),
):
title = uploader._generate_title(["波塞冬edge"])
self.assertTrue(title.startswith("波塞冬edge托特包,"))
self.assertNotIn("双肩包", title)
self.assertTrue(title.endswith("!"))
self.assertGreaterEqual(len(title), 26)
self.assertLessEqual(len(title), 30)
def test_primary_content_label_uses_first_product_bag_type(self):
self.assertEqual(
GuangHeUploader._content_label_for_products(["盖亚斜挎", "极星双肩"]),
"斜挎包",
)
self.assertEqual(
GuangHeUploader._content_label_for_products(["盖亚微单"]),
"相机包",
)
self.assertEqual(
GuangHeUploader._content_label_for_products(["波塞冬edge"]),
"托特包",
)
self.assertEqual(
GuangHeUploader._content_label_for_products(["波塞冬手提电脑包"]),
"电脑包",
)
def test_catalog_product_titles_supply_style_category_and_verified_info(self):
try:
GuangHeUploader.configure_catalog_product_titles(
{
"星云2": [
"GYXX光影行星星云2斜挎包男士单肩包平板胸包",
"光影行星星云斜挎包男款邮差单肩包",
],
"宙斯": ["GYXX光影行星宙斯双肩包男大容量通勤背包"],
"宙斯z air": ["光影行星宙斯zair双肩包电脑包通勤背包"],
"黑曜石托特": ["光影行星黑曜石双肩托特多用包"],
}
)
self.assertEqual(
GuangHeUploader._content_label_for_products(["星云2"]),
"斜挎包",
)
self.assertEqual(
GuangHeUploader._resolve_title_category("宙斯"),
"双肩包",
)
self.assertIn(
"主包型:斜挎包",
GuangHeUploader._verified_product_info("星云2"),
)
self.assertEqual(
GuangHeUploader._content_label_for_products(["宙斯z air"]),
"双肩包",
)
self.assertEqual(
GuangHeUploader._content_label_for_products(["黑曜石托特"]),
"托特包",
)
finally:
GuangHeUploader.configure_catalog_product_titles({})
def test_metadata_selects_style_category_and_product_showcase_before_topic(self):
cases = [
(["盖亚斜挎"], ["斜挎包", "商品展示"]),
(["极星双肩"], ["双肩包", "商品展示"]),
]
for product_names, expected_labels in cases:
with self.subTest(product_names=product_names):
uploader = object.__new__(GuangHeUploader)
calls = []
uploader._pick_content_labels = Mock(
side_effect=lambda labels: calls.append(("labels", list(labels)))
)
uploader._pick_topic = Mock(
side_effect=lambda topic: calls.append(("topic", topic))
)
uploader._snapshot = Mock()
uploader._prepare_publish_metadata(product_names)
self.assertEqual(
calls,
[
("labels", expected_labels),
("topic", "在淘宝种草一夏"),
],
)
def test_fixed_content_labels_only_keep_product_showcase(self):
self.assertEqual(GuangHeUploader.FIXED_CONTENT_LABELS, ("商品展示",))
self.assertNotIn("户外", GuangHeUploader.FIXED_CONTENT_LABELS)
def test_fixed_topic_is_not_a_recommended_first_result(self):
self.assertEqual(GuangHeUploader.FIXED_TOPIC, "在淘宝种草一夏")
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,118 @@
import unittest
from upload_video_to_guanghe import (
STORE_FLAGSHIP,
STORE_LUGGAGE,
TM_UPLOAD_FIELD,
select_pending_records,
)
def record(
*,
brand="光影行星",
gender="",
uploaded=False,
attachments=True,
date=None,
):
return {
"品牌": [brand] if brand is not None else None,
"性别": [gender] if gender is not None else None,
TM_UPLOAD_FIELD: uploaded,
"代拍附件": [{"name": "video.mp4"}] if attachments else [],
"日期": date,
}
class GuangHeStoreRoutingTests(unittest.TestCase):
def test_tm_upload_field_matches_current_feishu_schema(self):
self.assertEqual(TM_UPLOAD_FIELD, "天猫上传")
def test_flagship_requires_brand_gender_attachment_and_unchecked_status(self):
records = [
record(gender=""),
record(gender=""),
record(gender="", uploaded=True),
record(brand="OZKO"),
record(brand=None),
record(gender=None),
record(attachments=False),
]
selected = select_pending_records(records, STORE_FLAGSHIP)
self.assertEqual(selected, records[:2])
def test_luggage_uploads_female_brand_records_regardless_of_tm_status(self):
unchecked = record(gender="", uploaded=False)
checked = record(gender="", uploaded=True)
unchecked["代拍产品"] = "极星双肩"
checked["代拍产品"] = "极星双肩"
records = [
unchecked,
checked,
record(gender=""),
record(brand="OZKO", gender=""),
record(gender=None),
record(gender="", attachments=False),
]
selected = select_pending_records(
records,
STORE_LUGGAGE,
luggage_style_whitelist={"极星双肩", "觅光"},
)
self.assertEqual(selected, [unchecked, checked])
def test_luggage_rejects_styles_outside_whitelist_and_trims_names(self):
allowed = record()
allowed["代拍产品"] = " 黑曜石电脑包 "
blocked = record()
blocked["代拍产品"] = "宙斯z air"
multi_with_blocked_style = record()
multi_with_blocked_style["代拍产品"] = "极星双肩 / 宙斯z air"
selected = select_pending_records(
[allowed, blocked, multi_with_blocked_style],
STORE_LUGGAGE,
luggage_style_whitelist={"黑曜石电脑包", "极星双肩"},
)
self.assertEqual(selected, [allowed])
def test_luggage_requires_a_loaded_whitelist(self):
with self.assertRaisesRegex(ValueError, "款式白名单"):
select_pending_records([record()], STORE_LUGGAGE)
def test_flagship_republish_from_date_includes_uploaded_records_in_range(self):
before = record(uploaded=True, date="2026-06-02 00:00:00")
first = record(uploaded=True, date="2026-06-03 00:00:00")
later = record(uploaded=True, date="2026-07-26 00:00:00")
missing_date = record(uploaded=True, date=None)
selected = select_pending_records(
[before, first, later, missing_date],
STORE_FLAGSHIP,
republish_from_date="2026-06-03",
)
self.assertEqual(selected, [first, later])
def test_republish_from_date_is_rejected_for_luggage_store(self):
with self.assertRaisesRegex(ValueError, "旗舰店"):
select_pending_records(
[record(date="2026-06-03 00:00:00")],
STORE_LUGGAGE,
luggage_style_whitelist={"极星双肩"},
republish_from_date="2026-06-03",
)
def test_unknown_store_is_rejected(self):
with self.assertRaisesRegex(ValueError, "未知光合店铺"):
select_pending_records([record()], "unknown")
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,110 @@
import unittest
from decimal import Decimal
from pathlib import Path
from unittest.mock import patch
import import_product_daily
TM_31_HEADERS = [
"统计日期", "商品ID", "商品名称", "货号", "商品状态", "商品访客数",
"商品浏览量", "平均停留时长", "商品详情页跳出率", "商品收藏人数",
"商品加购件数", "商品加购人数", "下单买家数", "下单件数", "下单金额",
"下单转化率", "支付买家数", "支付件数", "支付金额", "商品支付转化率",
"支付新买家数", "支付老买家数", "老买家支付金额", "聚划算支付金额",
"访客平均价值", "成功退款金额", "搜索引导支付转化率", "搜索引导访客数",
"搜索引导支付买家数", "结构化详情引导转化率", "结构化详情引导成交占比",
]
TM_38_HEADERS = [
"统计日期", "商品ID", "商品名称", "主商品ID", "商品类型", "货号", "商品状态",
"商品标签", "商品访客数", "商品浏览量", "平均停留时长",
"商品详情页跳出率", "商品收藏人数", "商品加购件数", "商品加购人数",
"下单买家数", "下单件数", "下单金额", "下单转化率", "支付买家数",
"支付件数", "支付金额", "商品支付转化率", "支付新买家数", "支付老买家数",
"老买家支付金额", "聚划算支付金额", "访客平均价值", "成功退款金额",
"竞争力评分", "年累计支付金额", "月累计支付金额", "月累计支付件数",
"搜索引导支付转化率", "搜索引导访客数", "搜索引导支付买家数",
"结构化详情引导转化率", "结构化详情引导成交占比",
]
class _FakeSheet:
def __init__(self, headers, row):
self.nrows = 6
self._headers = headers
self._row = row
def row_values(self, row_index):
if row_index == 4:
return self._headers
if row_index == 5:
return self._row
return []
class _FakeWorkbook:
def __init__(self, headers, row):
self._sheet = _FakeSheet(headers, row)
def sheet_by_index(self, _sheet_index):
return self._sheet
def _tm_row(headers):
values = {
"统计日期": "2026-07-03",
"商品ID": "123456",
"商品名称": "测试商品",
"商品类型": "普通商品",
"货号": "SKU-001",
"商品访客数": "100",
"商品浏览量": "200",
"支付买家数": "8",
"支付件数": "10",
"支付金额": "1234.56",
"商品支付转化率": "8%",
"访客平均价值": "12.35",
"成功退款金额": "23.45",
"竞争力评分": "95",
"年累计支付金额": "9999.99",
"月累计支付金额": "5678.90",
"结构化详情引导成交占比": "45%",
}
row = [values.get(header, "") for header in headers]
return row
class TmallProductDailyImportTests(unittest.TestCase):
def test_parse_31_column_export(self):
row = _tm_row(TM_31_HEADERS)
with patch.object(
import_product_daily.xlrd,
"open_workbook",
return_value=_FakeWorkbook(TM_31_HEADERS, row),
):
records = list(import_product_daily.parse_tm_xls(Path("31-columns.xls")))
self.assertEqual(len(records), 1)
self.assertEqual(records[0]["product_no"], "SKU-001")
self.assertEqual(records[0]["visitors"], 100)
self.assertEqual(records[0]["paid_qty"], 10)
self.assertEqual(records[0]["paid_amount"], Decimal("1234.56"))
self.assertEqual(records[0]["refund_amount"], Decimal("23.45"))
self.assertNotIn("月累计支付金额", records[0]["raw_data"])
def test_parse_38_column_export_keeps_extended_fields(self):
row = _tm_row(TM_38_HEADERS)
with patch.object(
import_product_daily.xlrd,
"open_workbook",
return_value=_FakeWorkbook(TM_38_HEADERS, row),
):
records = list(import_product_daily.parse_tm_xls(Path("38-columns.xls")))
self.assertEqual(len(records), 1)
self.assertEqual(records[0]["raw_data"]["月累计支付金额"], "5678.90")
self.assertEqual(records[0]["raw_data"]["结构化详情引导成交占比"], "45%")
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,319 @@
import importlib.util
import sys
import unittest
from pathlib import Path
from unittest.mock import patch
PROJECT_ROOT = (
Path(__file__).resolve().parents[3]
/ "src"
/ "gyxx_flow"
/ "modules"
/ "product_commerce"
)
JD_VENDOR_DIR = PROJECT_ROOT / "vendors" / "jd-data-flow"
MODULE_PATH = JD_VENDOR_DIR / "jd_data_collector.py"
sys.path.insert(0, str(JD_VENDOR_DIR))
try:
spec = importlib.util.spec_from_file_location("jd_data_collector_under_test", MODULE_PATH)
jd = importlib.util.module_from_spec(spec)
assert spec.loader is not None
spec.loader.exec_module(jd)
finally:
sys.path.remove(str(JD_VENDOR_DIR))
class FakeKeyboard:
def __init__(self, page):
self.page = page
self.presses = []
def press(self, key):
self.presses.append(key)
class FakeLocator:
def __init__(self, visible=False, on_click=None):
self.visible = visible
self.on_click = on_click
self.clicks = 0
@property
def first(self):
return self
def is_visible(self, timeout=None):
return self.visible
def click(self, timeout=None):
self.clicks += 1
if self.on_click:
self.on_click()
class FakeOverlayPage:
def __init__(self, close_button_works, overlay_visible=True, blocks_entry=True):
self.overlay_visible = overlay_visible
self.close_button_works = close_button_works
self.blocks_entry = blocks_entry
self.keyboard = FakeKeyboard(self)
self.evaluate_calls = []
self.waits = []
def locator(self, selector, **kwargs):
if selector == "div.all-dialog:visible":
return FakeLocator(visible=self.overlay_visible)
if self.close_button_works and "close-btn" in selector:
return FakeLocator(visible=True, on_click=self._close_overlay)
return FakeLocator(visible=False)
def _close_overlay(self):
self.overlay_visible = False
def evaluate(self, script, *args):
self.evaluate_calls.append(script)
if "elementFromPoint" in script and "setProperty('display'" in script:
if self.overlay_visible and self.blocks_entry:
self.overlay_visible = False
return 1
return 0
if "elementFromPoint" in script:
return self.overlay_visible and self.blocks_entry
if "all-dialog" in script and self.blocks_entry:
self.overlay_visible = False
return 1
if "all-dialog" in script:
# The old broad fallback would hide this non-blocking business dialog.
if "elementFromPoint" not in script:
self.overlay_visible = False
return 1
return 0
def wait_for_timeout(self, milliseconds):
self.waits.append(milliseconds)
class FakePage:
def __init__(
self,
url="https://shop.jd.com/jdm/home",
click_actions=None,
wait_actions=None,
):
self.url = url
self.closed = False
self.click_actions = list(click_actions or [])
self.entry_clicks = 0
self.keyboard = FakeKeyboard(self)
self.locator_calls = []
self.wait_actions = list(wait_actions or [])
def is_closed(self):
return self.closed
def locator(self, selector, **kwargs):
self.locator_calls.append((selector, kwargs))
def click_entry():
self.entry_clicks += 1
if self.click_actions:
action = self.click_actions.pop(0)
action()
is_precise_shangzhi_entry = (
selector == '.shop-menu-navigate__menu-name:has-text("商智"):visible'
)
return FakeLocator(visible=is_precise_shangzhi_entry, on_click=click_entry)
def wait_for_timeout(self, milliseconds):
if self.wait_actions:
self.wait_actions.pop(0)()
return None
def wait_for_load_state(self, *args, **kwargs):
return None
def goto(self, url, **kwargs):
self.url = url
class FakeContext:
def __init__(self, page):
self.pages = [page]
def new_page(self):
page = FakePage()
self.pages.append(page)
return page
class JdEnterShangzhiTests(unittest.TestCase):
def test_dismiss_clicks_real_close_button_before_dom_fallback(self):
page = FakeOverlayPage(close_button_works=True)
self.assertTrue(jd.dismiss_shop_home_overlays(page))
self.assertFalse(page.overlay_visible)
self.assertFalse(
any("setProperty('display'" in script for script in page.evaluate_calls)
)
def test_dismiss_hides_stuck_pointer_blocking_overlay(self):
page = FakeOverlayPage(close_button_works=False)
self.assertTrue(jd.dismiss_shop_home_overlays(page))
self.assertFalse(page.overlay_visible)
self.assertTrue(page.keyboard.presses)
self.assertTrue(any("all-dialog" in script for script in page.evaluate_calls))
def test_dismiss_does_not_touch_dom_when_no_overlay_is_visible(self):
page = FakeOverlayPage(close_button_works=False, overlay_visible=False)
self.assertTrue(jd.dismiss_shop_home_overlays(page))
self.assertEqual(page.keyboard.presses, [])
self.assertEqual(page.evaluate_calls, [])
def test_dismiss_does_not_hide_nonblocking_business_dialog(self):
page = FakeOverlayPage(
close_button_works=False,
overlay_visible=True,
blocks_entry=False,
)
self.assertTrue(jd.dismiss_shop_home_overlays(page))
self.assertTrue(page.overlay_visible)
self.assertFalse(any("setProperty('display'" in script for script in page.evaluate_calls))
def test_enter_retries_after_first_click_is_intercepted(self):
source = FakePage()
context = FakeContext(source)
destination = FakePage(url="https://sz.jd.com/szweb/index.html")
def fail_first_click():
raise RuntimeError("all-dialog intercepts pointer events")
def open_destination():
context.pages.append(destination)
source.click_actions = [fail_first_click, open_destination]
dismiss_calls = []
with (
patch.object(
jd,
"dismiss_shop_home_overlays",
side_effect=lambda page: dismiss_calls.append(page) or True,
),
patch.object(jd, "close_popup_if_present", return_value=None),
):
result = jd.step_enter_shangzhi(context, source)
self.assertIs(result, destination)
self.assertEqual(source.entry_clicks, 2)
self.assertGreaterEqual(len(dismiss_calls), 2)
self.assertFalse(any(selector == "text=商智" for selector, _ in source.locator_calls))
def test_enter_accepts_current_page_navigation(self):
source = FakePage()
context = FakeContext(source)
source.click_actions = [
lambda: setattr(source, "url", "https://sz.jd.com/szweb/index.html")
]
with (
patch.object(jd, "dismiss_shop_home_overlays", return_value=True),
patch.object(jd, "close_popup_if_present", return_value=None),
):
result = jd.step_enter_shangzhi(context, source)
self.assertIs(result, source)
self.assertEqual(source.entry_clicks, 1)
def test_enter_rejects_unrelated_new_page_and_retries(self):
source = FakePage()
context = FakeContext(source)
advertisement = FakePage(url="https://pro.jd.com/promotion.html")
destination = FakePage(url="https://sz.jd.com/szweb/index.html")
source.click_actions = [
lambda: context.pages.append(advertisement),
lambda: context.pages.append(destination),
]
with (
patch.object(jd, "dismiss_shop_home_overlays", return_value=True),
patch.object(jd, "close_popup_if_present", return_value=None),
):
result = jd.step_enter_shangzhi(context, source)
self.assertIs(result, destination)
self.assertEqual(source.entry_clicks, 2)
def test_enter_rejects_login_page_whose_return_url_mentions_shangzhi(self):
source = FakePage()
context = FakeContext(source)
login_page = FakePage(
url=(
"https://passport.jd.com/login?returnUrl="
"https%3A%2F%2Fsz.jd.com%2Fszweb%2Findex.html"
)
)
destination = FakePage(url="https://sz.jd.com/szweb/index.html")
source.click_actions = [
lambda: context.pages.append(login_page),
lambda: context.pages.append(destination),
]
with (
patch.object(jd, "dismiss_shop_home_overlays", return_value=True),
patch.object(jd, "close_popup_if_present", return_value=None),
):
result = jd.step_enter_shangzhi(context, source)
self.assertIs(result, destination)
self.assertEqual(source.entry_clicks, 2)
def test_shangzhi_url_match_uses_hostname_not_query_or_similar_domain(self):
self.assertTrue(jd._is_shangzhi_url("https://sz.jd.com/szweb/index.html"))
self.assertTrue(jd._is_shangzhi_url("https://sub.sz.jd.com/path"))
self.assertTrue(
jd._is_shangzhi_url(
"https://jdsz.jd.com/szweb/view/index/home.html"
)
)
self.assertFalse(
jd._is_shangzhi_url(
"https://passport.jd.com/login?returnUrl=https://sz.jd.com/szweb/"
)
)
self.assertFalse(jd._is_shangzhi_url("https://evil-sz.jd.com/szweb/"))
self.assertFalse(jd._is_shangzhi_url("https://evil-jdsz.jd.com/szweb/"))
def test_enter_waits_for_about_blank_popup_to_navigate_to_shangzhi(self):
source = FakePage()
context = FakeContext(source)
destination = FakePage(url="about:blank")
source.click_actions = [lambda: context.pages.append(destination)]
source.wait_actions = [
lambda: None,
lambda: setattr(
destination,
"url",
"https://jdsz.jd.com/szweb/view/index/home.html",
),
]
with (
patch.object(jd, "dismiss_shop_home_overlays", return_value=True),
patch.object(jd, "close_popup_if_present", return_value=None),
):
result = jd.step_enter_shangzhi(context, source)
self.assertIs(result, destination)
self.assertEqual(
result.url,
"https://jdsz.jd.com/szweb/view/index/home.html",
)
self.assertEqual(source.entry_clicks, 1)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,295 @@
import unittest
from unittest.mock import MagicMock, patch
from collect_jd_market_rank import (
NEXT_PAGE_SELECTORS,
TARGET_JD_MARKET_CATEGORIES,
build_jd_feishu_batch_xml,
build_jd_feishu_category_xml,
build_jd_feishu_intro_xml,
category_selection_matches,
exact_category_label_pattern,
group_products_by_category,
high_resolution_jd_image_url,
inspect_feishu_table_images,
parse_jd_market_rank_html,
parse_money_range_upper,
resize_feishu_table_xml,
select_category_paths,
)
SAMPLE_HTML = """
<table class="jmtd-table-main">
<thead>
<tr>
<th>排名</th>
<th>商品信息</th>
<th>成交金额</th>
<th>成交单量</th>
<th>访客数</th>
<th>搜索点击次数</th>
<th>关注人数</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr>
<td><span class="jmtd-table-formatter-rank-icon-1"></span><span>↑3名</span></td>
<td>
<div class="prod-info">
<a class="prod-name" href="//item.jd.com/10012345678901.html?pcdk=tracking"
title="测试高颜值拉杆箱">测试高颜值拉杆箱</a>
<a class="shop-name">测试旗舰店</a>
<img data-src="//img10.360buyimg.com/n1/jfs/test.jpg"/>
</div>
</td>
<td>¥ 50万 ¥ 75万</td>
<td>4,000~6,000</td>
<td>4万~6万</td>
<td>4万~6万</td>
<td>357</td>
<td>详情</td>
</tr>
<tr>
<td><span class="jmtd-table-formatter-rank-number">4</span></td>
<td>
<div class="prod-info">
<a class="prod-name" href="https://item.jd.com/10012345678902.html">
测试电脑包
</a>
<a class="shop-name">电脑包旗舰店</a>
<img src="https://img11.360buyimg.com/n1/jfs/test2.jpg"/>
</div>
</td>
<td>¥10万~¥25万</td>
<td>50~100</td>
<td>4,000~6,000</td>
<td>1,000~2,000</td>
<td>20</td>
<td>详情</td>
</tr>
</tbody>
</table>
"""
class JdMarketRankTests(unittest.TestCase):
def test_default_categories_match_requested_seven_filters(self):
specs = {item["name"]: item for item in TARGET_JD_MARKET_CATEGORIES}
self.assertEqual(
set(specs),
{"旅行箱", "运动包", "双肩包", "斜挎包", "胸包", "手机包", "电脑包"},
)
self.assertEqual(specs["旅行箱"]["paths"], (("功能箱包", "行李箱"),))
self.assertEqual(specs["运动包"]["paths"], (("功能箱包", "休闲运动包"),))
self.assertEqual(
specs["双肩包"]["paths"],
(("男包", "双肩包"), ("男包", "男士双肩包")),
)
self.assertEqual(specs["手机包"]["paths"], (("男包", "男士手机包"),))
self.assertEqual(specs["电脑包"]["paths"], (("功能箱包", "电脑包"),))
def test_category_label_matching_is_exact_not_contains(self):
pattern = exact_category_label_pattern("双肩包")
self.assertIsNotNone(pattern.fullmatch(" 双肩包 "))
self.assertIsNone(pattern.fullmatch("男士双肩包"))
self.assertIsNone(pattern.fullmatch("双肩包配件"))
def test_selected_category_verification_accepts_only_exact_display_forms(self):
path = ("男包", "双肩包")
self.assertTrue(category_selection_matches(("双肩包",), path))
self.assertTrue(category_selection_matches(("男包 > 双肩包",), path))
self.assertFalse(category_selection_matches(("男士双肩包",), path))
self.assertFalse(category_selection_matches(("双肩包配件",), path))
def test_category_candidates_fall_back_only_after_explicit_failure(self):
page = MagicMock()
paths = (("男包", "双肩包"), ("男包", "男士双肩包"))
with patch(
"collect_jd_market_rank.select_category_path",
side_effect=[RuntimeError("当前页面无此精确类目"), paths[1]],
) as select_one:
selected = select_category_paths(page, paths)
self.assertEqual(selected, paths[1])
self.assertEqual(
[call.args[1] for call in select_one.call_args_list],
list(paths),
)
def test_all_missing_category_candidates_fail_with_diagnostics(self):
page = MagicMock()
paths = (("男包", "双肩包"), ("男包", "男士双肩包"))
with (
patch(
"collect_jd_market_rank.select_category_path",
side_effect=[RuntimeError("无双肩包"), RuntimeError("无男士双肩包")],
),
self.assertRaisesRegex(RuntimeError, "候选类目路径.*无双肩包.*无男士双肩包"),
):
select_category_paths(page, paths)
def test_parse_money_range_upper(self):
self.assertEqual(parse_money_range_upper("¥50万~¥75万"), 750_000)
self.assertEqual(parse_money_range_upper("¥ 1亿以上"), 100_000_000)
def test_jd_thumbnail_is_upgraded_to_n0_original(self):
self.assertEqual(
high_resolution_jd_image_url(
"https://img10.360buyimg.com/n5/jfs/t1/test/image.jpg"
),
"https://img10.360buyimg.com/n0/jfs/t1/test/image.jpg",
)
def test_real_jd_next_page_selector_is_supported(self):
self.assertIn(".jmtd-pagination-item-next", NEXT_PAGE_SELECTORS)
def test_inspect_feishu_table_images_finds_incomplete_table(self):
content = """
<title>测试</title>
<table id="table-1">
<thead><tr><th>商品图</th></tr></thead>
<tbody>
<tr><td><img src="token-1"/></td></tr>
<tr><td></td></tr>
</tbody>
</table>
<table id="table-2">
<thead><tr><th>商品图</th></tr></thead>
<tbody><tr><td><img src="token-2"/></td></tr></tbody>
</table>
"""
stats = inspect_feishu_table_images(content)
self.assertEqual(
stats,
[
{"block_id": "table-1", "rows": 2, "images": 1},
{"block_id": "table-2", "rows": 1, "images": 1},
],
)
def test_resize_feishu_table_xml_reuses_media_token_at_compact_size(self):
table_xml = """
<table id="table-1">
<tbody>
<tr id="row-1">
<td>
<img id="image-1" src="image-token" href="https://internal/image"
width="220" height="800"/>
</td>
<td><a href="https://item.jd.com/1.html">打开商品</a></td>
</tr>
</tbody>
</table>
"""
resized = resize_feishu_table_xml(table_xml, image_size=160)
self.assertNotIn('id="table-1"', resized)
self.assertNotIn('id="image-1"', resized)
self.assertIn(
'<img src="image-token" width="160" height="160"/>',
resized,
)
self.assertIn(
'<a href="https://item.jd.com/1.html">打开商品</a>',
resized,
)
def test_parse_html_extracts_image_name_link_and_sales(self):
rows = parse_jd_market_rank_html(
SAMPLE_HTML,
category_name="旅行箱",
category_path="功能箱包 > 行李箱",
)
self.assertEqual(len(rows), 2)
first = rows[0]
self.assertEqual(first["platform"], "jd")
self.assertEqual(first["rank"], 1)
self.assertEqual(first["product_id"], "10012345678901")
self.assertEqual(first["name"], "测试高颜值拉杆箱")
self.assertEqual(
first["image_url"],
"https://img10.360buyimg.com/n0/jfs/test.jpg",
)
self.assertEqual(
first["product_url"],
"https://item.jd.com/10012345678901.html",
)
self.assertEqual(first["sales_amount_range"], "¥50万~¥75万")
self.assertEqual(first["sales_amount_upper"], 750_000)
self.assertEqual(rows[1]["rank"], 4)
def test_feishu_xml_adds_sales_column_and_product_assets(self):
product = parse_jd_market_rank_html(
SAMPLE_HTML,
category_name="旅行箱",
category_path="功能箱包 > 行李箱",
)[0]
xml = build_jd_feishu_batch_xml([product], start_rank=1)
self.assertIn(">销售额</th>", xml)
self.assertIn("<b>¥50万~¥75万</b>", xml)
self.assertIn(
'<img href="https://img10.360buyimg.com/n0/jfs/test.jpg" '
'width="160" height="160"',
xml,
)
self.assertIn(
'<a href="https://item.jd.com/10012345678901.html">打开商品</a>',
xml,
)
def test_category_xml_uses_one_named_table_for_the_category(self):
products = parse_jd_market_rank_html(
SAMPLE_HTML,
category_name="旅行箱",
category_path="功能箱包 > 行李箱",
)
xml = build_jd_feishu_category_xml("旅行箱", products)
self.assertIn("<h2>旅行箱(2条)</h2>", xml)
self.assertEqual(xml.count("<table>"), 1)
self.assertEqual(xml.count("<tbody>"), 1)
self.assertEqual(xml.count("<tr>"), 3)
def test_group_products_by_category_preserves_category_order(self):
grouped = group_products_by_category(
[
{"category_name": "旅行箱", "rank": 1},
{"category_name": "运动包", "rank": 1},
{"category_name": "旅行箱", "rank": 2},
]
)
self.assertEqual(
[(name, len(items)) for name, items in grouped],
[("旅行箱", 2), ("运动包", 1)],
)
def test_intro_records_period_and_category_path(self):
xml = build_jd_feishu_intro_xml(
title="京东市场榜单",
period="近30天",
products=[
{
"category_name": "运动包",
"category_path": "功能箱包 > 休闲运动包",
"search_keyword": "",
}
],
)
self.assertIn("<h1>京东</h1>", xml)
self.assertIn("近30天", xml)
self.assertIn("运动包(功能箱包 &gt; 休闲运动包)", xml)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,449 @@
import io
import json
import tempfile
import unittest
from contextlib import redirect_stdout
from pathlib import Path
from unittest.mock import patch
import db as product_db
import run_weekly_jd_main_image as jd_runner
import run_weekly_main_image as tmall_runner
from main_image_db import persist_main_image_records, validate_main_image_schema
from main_image_paths import collect_images_dir, resolve_images_dir
from scripts import insert_jd_main_image_records as jd_insert
from scripts import insert_main_image_records as tmall_insert
from taobao_wanxiang_ai_creative_report import cleanup_images_after_visual_merge
class MainImagePathTests(unittest.TestCase):
def test_collectors_use_platform_owned_directories(self):
style_dir = Path("style")
self.assertEqual(collect_images_dir(style_dir, "tm"), style_dir / "tm_images")
self.assertEqual(collect_images_dir(style_dir, "jd"), style_dir / "jd_images")
def test_insert_prefers_platform_directory_and_supports_legacy_data(self):
with tempfile.TemporaryDirectory() as tmp:
style_dir = Path(tmp)
legacy = style_dir / "images"
legacy.mkdir()
self.assertEqual(resolve_images_dir(style_dir, "jd"), legacy)
current = style_dir / "jd_images"
current.mkdir()
self.assertEqual(resolve_images_dir(style_dir, "jd"), current)
class MainImageWorkflowPreflightTests(unittest.TestCase):
def test_zero_valid_styles_is_a_formal_failure(self):
for runner in (tmall_runner, jd_runner):
with self.subTest(runner=runner.__name__):
with self.assertRaisesRegex(RuntimeError, "拒绝按成功退出"):
runner.require_valid_output(0, "2026-08-04")
def test_dynamic_style_read_failure_is_not_silently_ignored(self):
with patch.object(
tmall_runner,
"get_main_image_styles",
side_effect=RuntimeError("Feishu unavailable"),
):
with self.assertRaisesRegex(RuntimeError, "款式读取失败"):
tmall_runner.load_dynamic_styles()
with patch.object(
jd_runner,
"get_jd_spu_groups",
side_effect=RuntimeError("Feishu unavailable"),
):
with self.assertRaisesRegex(RuntimeError, "款式读取失败"):
jd_runner.load_dynamic_styles()
def test_empty_dynamic_style_set_is_a_formal_failure(self):
with patch.object(tmall_runner, "get_main_image_styles", return_value=[]):
with self.assertRaisesRegex(RuntimeError, "未返回任何天猫"):
tmall_runner.load_dynamic_styles()
with patch.object(jd_runner, "get_jd_spu_groups", return_value=[]):
with self.assertRaisesRegex(RuntimeError, "未返回任何京东"):
jd_runner.load_dynamic_styles()
class TmallCleanupTests(unittest.TestCase):
def test_locked_duplicate_is_retried_without_console_encoding_failure(self):
with tempfile.TemporaryDirectory() as tmp:
images_dir = Path(tmp)
keep = images_dir / "keep.png"
locked = images_dir / "locked.png"
keep.write_bytes(b"keep")
locked.write_bytes(b"duplicate")
creatives = [{"local_image_path": str(keep), "local_image_paths": [str(keep), str(locked)]}]
original_unlink = Path.unlink
attempts = 0
def flaky_unlink(path, *args, **kwargs):
nonlocal attempts
if path == locked:
attempts += 1
if attempts < 3:
raise PermissionError("temporarily locked")
return original_unlink(path, *args, **kwargs)
raw = io.BytesIO()
strict_gbk_stdout = io.TextIOWrapper(raw, encoding="gbk", errors="strict")
with patch.object(Path, "unlink", new=flaky_unlink):
with redirect_stdout(strict_gbk_stdout):
cleanup_images_after_visual_merge(
images_dir,
creatives,
retry_attempts=3,
retry_delay=0,
)
strict_gbk_stdout.flush()
self.assertEqual(attempts, 3)
self.assertFalse(locked.exists())
self.assertTrue(keep.exists())
class MainImageDatabaseTests(unittest.TestCase):
@staticmethod
def _schema_connection(columns):
class Cursor:
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
def execute(self, _sql):
return None
def fetchone(self):
return (list(columns),)
class Connection:
@staticmethod
def cursor():
return Cursor()
class ConnectionContext:
def __enter__(self):
return Connection()
def __exit__(self, exc_type, exc, tb):
return False
return ConnectionContext
def test_schema_preflight_accepts_platform_aware_primary_key(self):
columns = ("collect_date", "style_name", "platform", "image_key")
self.assertEqual(
validate_main_image_schema(get_conn_fn=self._schema_connection(columns)),
columns,
)
def test_schema_preflight_rejects_legacy_primary_key_before_external_writes(self):
legacy = ("collect_date", "style_name", "image_key")
with self.assertRaisesRegex(RuntimeError, "primary key mismatch"):
validate_main_image_schema(get_conn_fn=self._schema_connection(legacy))
def test_empty_batch_does_not_open_database_connection(self):
def unexpected_connection():
raise AssertionError("database should not be opened")
self.assertEqual(
persist_main_image_records([], get_conn_fn=unexpected_connection),
0,
)
def test_database_connection_is_opened_only_when_records_are_persisted(self):
events = []
connection = object()
class ConnectionContext:
def __enter__(self):
events.append("connect")
return connection
def __exit__(self, exc_type, exc, tb):
events.append("close")
def upsert(conn, records):
self.assertIs(conn, connection)
events.append(("upsert", len(records)))
return len(records)
self.assertEqual(
persist_main_image_records(
[{"image_key": "a"}, {"image_key": "b"}],
get_conn_fn=ConnectionContext,
upsert_fn=upsert,
),
2,
)
self.assertEqual(events, ["connect", ("upsert", 2), "close"])
def test_upsert_identity_contains_platform_and_preserves_both_platform_rows(self):
class CursorContext:
def __enter__(self):
return object()
def __exit__(self, exc_type, exc, tb):
return False
class Connection:
@staticmethod
def cursor():
return CursorContext()
records = [
{
"collect_date": "2026-08-02",
"style_name": "同款",
"platform": platform,
"image_key": "main.jpg",
}
for platform in ("jd", "tm")
]
with patch.object(product_db, "execute_values") as execute_values:
count = product_db.upsert_main_image_creatives(Connection(), records)
self.assertEqual(count, 2)
sql = execute_values.call_args.args[1]
rows = execute_values.call_args.args[2]
self.assertIn(
"ON CONFLICT (collect_date, style_name, platform, image_key)",
sql,
)
self.assertEqual([row[2] for row in rows], ["jd", "tm"])
def test_schema_migrates_existing_primary_key_to_platform_identity(self):
schema = (Path(product_db.__file__).with_name("schema.sql")).read_text(
encoding="utf-8"
)
expected = "PRIMARY KEY (collect_date, style_name, platform, image_key)"
self.assertGreaterEqual(schema.count(expected), 2)
self.assertIn("current_pk_columns IS DISTINCT FROM", schema)
self.assertIn("DROP CONSTRAINT", schema)
class MainImageSinkIsolationTests(unittest.TestCase):
@staticmethod
def _write_source(
root: Path,
*,
platform: str,
date_str: str = "2026-08-02",
style: str = "同款",
) -> tuple[Path, Path]:
style_dir = root / date_str / style
images_dir = style_dir / f"{platform}_images"
images_dir.mkdir(parents=True)
image_path = images_dir / "main.jpg"
image_path.write_bytes(b"image")
if platform == "tm":
payload = {
"creatives": [
{
"image_key": image_path.name,
"impressions": 10,
"clicks": 2,
}
]
}
filename = "wanxiang_creative_main_images.json"
else:
payload = {
"records": [
{
"image_key": image_path.name,
"impressions": 20,
"clicks": 3,
}
]
}
filename = "jd_main_images.json"
(style_dir / filename).write_text(
json.dumps(payload, ensure_ascii=False), encoding="utf-8"
)
return style_dir, image_path
def test_tmall_pg_payload_survives_feishu_discovery_failure(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
self._write_source(root, platform="tm")
pg_records = []
with (
patch.object(
tmall_insert,
"artifact_relative_path",
return_value="raw/product_commerce/main.jpg",
),
patch.object(
tmall_insert,
"discover_fields",
side_effect=RuntimeError("Feishu unavailable"),
),
):
result = tmall_insert.process_product(
"同款",
{"base_token": "base", "table_id": "table"},
"2026-08-02",
root,
False,
pg_records,
)
self.assertTrue(result["errors"])
self.assertEqual(len(pg_records), 1)
self.assertEqual(pg_records[0]["platform"], "tm")
self.assertFalse(pg_records[0]["uploaded_to_bitable"])
def test_jd_pg_payload_survives_feishu_discovery_failure(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
self._write_source(root, platform="jd")
pg_records = []
with (
patch.object(
jd_insert,
"artifact_relative_path",
return_value="raw/product_commerce/main.jpg",
),
patch.object(
jd_insert,
"discover_fields",
side_effect=RuntimeError("Feishu unavailable"),
),
):
result = jd_insert.process_style(
"同款",
{"base_token": "base", "table_id": "table"},
"2026-08-02",
root,
False,
pg_records,
)
self.assertTrue(result["errors"])
self.assertEqual(len(pg_records), 1)
self.assertEqual(pg_records[0]["platform"], "jd")
self.assertFalse(pg_records[0]["uploaded_to_bitable"])
def test_tmall_pg_payload_survives_feishu_record_lookup_failure(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
self._write_source(root, platform="tm")
pg_records = []
fields = {
"时间": "time",
"平台": "platform",
"曝光量": "impressions",
"点击量": "clicks",
"主图": "image",
}
with (
patch.object(
tmall_insert,
"artifact_relative_path",
return_value="raw/product_commerce/main.jpg",
),
patch.object(tmall_insert, "discover_fields", return_value=fields),
patch.object(
tmall_insert,
"list_existing_records",
side_effect=RuntimeError("Feishu record-list unavailable"),
),
):
result = tmall_insert.process_product(
"同款",
{"base_token": "base", "table_id": "table"},
"2026-08-02",
root,
False,
pg_records,
)
self.assertIn("record-list 失败", result["errors"][0])
self.assertEqual(len(pg_records), 1)
self.assertEqual(pg_records[0]["platform"], "tm")
def test_jd_pg_payload_survives_feishu_record_lookup_failure(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
self._write_source(root, platform="jd")
pg_records = []
fields = {
"时间": "time",
"平台": "platform",
"曝光量": "impressions",
"点击量": "clicks",
"主图": "image",
}
with (
patch.object(
jd_insert,
"artifact_relative_path",
return_value="raw/product_commerce/main.jpg",
),
patch.object(jd_insert, "discover_fields", return_value=fields),
patch.object(
jd_insert,
"list_existing_records",
side_effect=RuntimeError("Feishu record-list unavailable"),
),
):
result = jd_insert.process_style(
"同款",
{"base_token": "base", "table_id": "table"},
"2026-08-02",
root,
False,
pg_records,
)
self.assertIn("record-list 失败", result["errors"][0])
self.assertEqual(len(pg_records), 1)
self.assertEqual(pg_records[0]["platform"], "jd")
def test_feishu_existing_record_lookup_is_platform_scoped(self):
fields = {
"时间": "time",
"平台": "platform",
"曝光量": "impressions",
"点击量": "clicks",
"主图": "image",
}
payload = {
"data": {
"field_id_list": list(fields.values()),
"record_id_list": ["tm-record", "jd-record"],
"data": [
["本周", ["天猫"], 10, 2, [{"name": "main.jpg"}]],
["本周", ["京东"], 20, 3, [{"name": "main.jpg"}]],
],
}
}
with patch.object(
tmall_insert,
"run_lark",
return_value=(0, json.dumps(payload, ensure_ascii=False), ""),
) as run_lark:
by_image, metric_tuples, duplicates = (
tmall_insert.list_existing_records(
"base", "table", fields, "本周", "京东"
)
)
command = run_lark.call_args.args[0]
filter_json = json.loads(command[command.index("--filter-json") + 1])
self.assertIn(["平台", "==", "京东"], filter_json["conditions"])
self.assertEqual(by_image["main.jpg"]["record_id"], "jd-record")
self.assertEqual(metric_tuples, {(20, 3)})
self.assertEqual(duplicates, {})
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,214 @@
from __future__ import annotations
import json
from datetime import date, datetime
from unittest.mock import MagicMock, patch
import market_rank_hermes_notification as notification
REPORTS = [
{
"platform": "tm",
"feishu_doc_url": "https://example.com/tm",
"product_count": 1178,
"report_title": "天猫九品类市场排行",
},
{
"platform": "jd",
"feishu_doc_url": "https://example.com/jd",
"product_count": 1050,
"report_title": "京东市场排行",
},
{
"platform": "dy",
"feishu_doc_url": "https://example.com/dy",
"product_count": 1285,
"report_title": "抖音市场排行",
},
]
def test_build_notification_message_contains_three_plain_links():
message = notification.build_notification_message(date(2026, 7, 26), REPORTS)
assert "三平台市场排行" in message
assert "天猫:https://example.com/tm" in message
assert "京东:https://example.com/jd" in message
assert "抖音:https://example.com/dy" in message
assert "1178" in message
def test_build_notification_message_supports_partial_platform_success():
message = notification.build_notification_message(
date(2026, 7, 26),
[REPORTS[1]],
)
assert "京东:https://example.com/jd" in message
assert "天猫:https://" not in message
assert "抖音:https://" not in message
assert "当前成功平台 1 个" in message
def test_build_hermes_payload_requires_direct_message_to_recipient():
payload = notification.build_hermes_payload(
recipient_open_id="ou_wangyunlong",
message="三个链接",
)
encoded = json.dumps(payload, ensure_ascii=False)
assert "ou_wangyunlong" in encoded
assert "飞书私聊" in encoded
assert "三个链接" in encoded
def test_extracts_real_feishu_message_id_from_hermes_completion():
response = {
"id": "api-response-id",
"choices": [
{
"message": {
"content": "发送成功,消息ID om_x100b696f1169f0a0b1f9c311e8dc323"
}
}
],
}
assert (
notification.extract_feishu_message_id(response)
== "om_x100b696f1169f0a0b1f9c311e8dc323"
)
assert notification.hermes_send_succeeded(response) is True
def test_hermes_success_text_without_real_message_id_is_not_success():
response = {
"choices": [{"message": {"content": "发送成功,但没有返回消息回执"}}]
}
assert notification.extract_feishu_message_id(response) is None
assert notification.hermes_send_succeeded(response) is False
def test_extracts_nested_feishu_message_id_and_accepts_safe_suffixes():
response = {"data": {"message_id": "om_real-id_123"}}
assert notification.extract_feishu_message_id(response) == "om_real-id_123"
assert notification.hermes_send_succeeded(response) is True
def test_notify_skips_when_same_day_recipient_was_already_sent():
conn = MagicMock()
with (
patch.object(notification, "get_conn") as get_conn,
patch.object(notification, "ensure_market_rank_notification_table"),
patch.object(notification, "get_market_rank_reports_for_date", return_value=REPORTS),
patch.object(notification, "market_rank_notification_was_sent", return_value=True),
patch.object(notification, "post_hermes") as post_hermes,
):
get_conn.return_value.__enter__.return_value = conn
result = notification.notify_market_rank_reports(
report_date=date(2026, 7, 26),
recipient_open_id="ou_wangyunlong",
)
assert result["status"] == "skipped"
assert result["reason"] == "already_sent"
post_hermes.assert_not_called()
def test_notify_sends_only_after_all_three_links_exist_and_persists_success():
conn = MagicMock()
response = {
"id": "hermes-response-id",
"choices": [
{
"message": {
"content": "发送成功,消息ID om_x100b696f1169f0a0b1f9c311e8dc323"
}
}
],
}
with (
patch.object(notification, "get_conn") as get_conn,
patch.object(notification, "ensure_market_rank_notification_table"),
patch.object(notification, "market_rank_notification_was_sent", return_value=False),
patch.object(
notification,
"get_market_rank_reports_for_date",
return_value=REPORTS,
) as get_reports,
patch.object(notification, "post_hermes", return_value=response) as post_hermes,
patch.object(notification, "upsert_market_rank_notification") as upsert,
):
get_conn.return_value.__enter__.return_value = conn
cutoff = datetime(2026, 7, 26, 10, 0, 0)
result = notification.notify_market_rank_reports(
report_date=date(2026, 7, 26),
recipient_open_id="ou_wangyunlong",
generated_after=cutoff,
)
assert result["status"] == "sent"
get_reports.assert_called_once_with(
conn,
date(2026, 7, 26),
notification.PLATFORM_ORDER,
updated_after=cutoff,
)
post_hermes.assert_called_once()
saved = upsert.call_args.args[1]
assert saved["status"] == "sent"
assert saved["platform_links"] == {
"tm": "https://example.com/tm",
"jd": "https://example.com/jd",
"dy": "https://example.com/dy",
}
def test_notify_sends_available_links_when_other_platforms_are_missing():
conn = MagicMock()
response = {
"choices": [{"message": {"content": "发送成功,消息ID om_partial123"}}]
}
with (
patch.object(notification, "get_conn") as get_conn,
patch.object(notification, "ensure_market_rank_notification_table"),
patch.object(notification, "market_rank_notification_was_sent", return_value=False),
patch.object(notification, "get_market_rank_reports_for_date", return_value=REPORTS[:2]),
patch.object(notification, "post_hermes", return_value=response) as post_hermes,
patch.object(notification, "upsert_market_rank_notification") as upsert,
):
get_conn.return_value.__enter__.return_value = conn
result = notification.notify_market_rank_reports(
report_date=date(2026, 7, 26),
recipient_open_id="ou_wangyunlong",
)
assert result["status"] == "sent"
assert result["missing_platforms"] == ["dy"]
post_hermes.assert_called_once()
assert upsert.call_args.args[1]["status"] == "sent"
def test_notify_skips_only_when_current_run_has_no_links():
conn = MagicMock()
with (
patch.object(notification, "get_conn") as get_conn,
patch.object(notification, "ensure_market_rank_notification_table"),
patch.object(notification, "get_market_rank_reports_for_date", return_value=[]),
patch.object(notification, "market_rank_notification_was_sent") as was_sent,
patch.object(notification, "post_hermes") as post_hermes,
patch.object(notification, "upsert_market_rank_notification") as upsert,
):
get_conn.return_value.__enter__.return_value = conn
result = notification.notify_market_rank_reports(
report_date=date(2026, 7, 26),
recipient_open_id="ou_wangyunlong",
)
assert result["status"] == "skipped"
assert result["reason"] == "no_platform_reports"
was_sent.assert_not_called()
post_hermes.assert_not_called()
assert upsert.call_args.args[1]["status"] == "incomplete"
@@ -0,0 +1,61 @@
import pytest
from gyxx_flow.modules.product_commerce.market_rank_limits import (
category_rank_limit,
remaining_category_rank_limit,
require_valid_market_rank_products,
)
def test_tmall_and_douyin_collect_top_200_per_category():
assert category_rank_limit("tm", "旅行箱") == 200
assert category_rank_limit("tm", "双肩包") == 200
assert category_rank_limit("dy", "旅行箱") == 200
assert category_rank_limit("dy", "电脑包") == 200
def test_jd_collects_top_150_for_luggage_and_top_100_for_other_categories():
assert category_rank_limit("jd", "旅行箱") == 150
assert category_rank_limit("jd", "运动包") == 100
assert category_rank_limit("jd", "双肩包") == 100
assert category_rank_limit("jd", "电脑包") == 100
def test_remaining_limit_is_per_category_and_respects_optional_global_limit():
assert remaining_category_rank_limit("tm", "双肩包", category_count=80) == 120
assert remaining_category_rank_limit("jd", "旅行箱", category_count=120) == 30
assert remaining_category_rank_limit("jd", "电脑包", category_count=100) == 0
assert remaining_category_rank_limit(
"dy",
"双肩包",
category_count=20,
total_count=490,
global_limit=500,
) == 10
@pytest.mark.parametrize(
("platform", "rows"),
[
("tm", ["not-a-product-row"]),
("jd", [{"name": "", "rank": 1, "sales_amount_upper": 10}]),
("dy", [{"name": "商品", "rank": 1, "sales_amount_upper": 0}]),
],
)
def test_zero_valid_platform_results_are_never_success(platform, rows):
with pytest.raises(RuntimeError, match="0 条有效商品.*拒绝标记成功"):
require_valid_market_rank_products(platform, rows)
@pytest.mark.parametrize(
("platform", "metric_name"),
[
("tm", "buyer_max"),
("jd", "sales_amount_upper"),
("dy", "sales_amount_upper"),
],
)
def test_valid_platform_result_passes_gate(platform, metric_name):
row = {"name": "有效商品", "rank": 1, metric_name: 100}
assert require_valid_market_rank_products(platform, [row]) == 1
@@ -0,0 +1,123 @@
from __future__ import annotations
from decimal import Decimal
from pathlib import Path
from gyxx_flow.modules.product_commerce.market_rank_product_import import (
build_product_snapshot,
extract_document_labels,
normalize_scene_name,
normalize_style_name,
)
RUNTIME_ROOT = (
Path(__file__).resolve().parents[3]
/ "src"
/ "gyxx_flow"
/ "modules"
/ "product_commerce"
)
def test_known_label_aliases_are_normalized_without_guessing_unknown_styles():
assert normalize_style_name("都是新旅") == "都市新旅"
assert normalize_style_name("智性通勤") == "智性通勤"
assert normalize_style_name("都市运动") == "都市运动"
assert normalize_style_name("都市通勤") is None
assert normalize_scene_name("上班通勤") == "通勤上班"
assert normalize_scene_name("摄影爱好") == "摄影爱好"
def test_tmall_snapshot_uses_buyer_upper_times_cached_unit_price():
snapshot = build_product_snapshot(
"tm",
{
"rank": 1,
"name": "旅行箱",
"buyer_range": "100 ~ 250",
"buyer_max": 250,
"product_url": "https://detail.tmall.com/item.htm?id=123",
"category_name": "旅行箱",
"category_path": "箱包 > 旅行箱",
},
price_cache={"123": "¥418"},
)
assert snapshot["platform_product_id"] == "123"
assert snapshot["unit_price_min"] == Decimal("418")
assert snapshot["unit_price_max"] == Decimal("418")
assert snapshot["estimated_gmv"] == Decimal("104500")
assert snapshot["estimate_method"] == "buyer_upper_x_unit_price"
assert snapshot["quality_status"] == "complete"
def test_douyin_and_jd_snapshots_use_reported_sales_upper():
for platform in ("dy", "jd"):
snapshot = build_product_snapshot(
platform,
{
"product_id": "9988",
"name": "商品",
"sales_amount_range": "¥100万-¥250万",
"sales_amount_upper": 2_500_000,
"price_range": "¥398-¥617",
"product_url": "https://example.com/9988",
"category_name": "双肩包",
},
)
assert snapshot["estimated_gmv"] == Decimal("2500000")
assert snapshot["estimate_method"] == "reported_sales_upper"
assert snapshot["quality_status"] == "complete"
def test_missing_tmall_price_keeps_product_but_excludes_it_from_amount():
snapshot = build_product_snapshot(
"tm",
{
"buyer_max": 100,
"name": "没有价格的商品",
"product_url": "https://detail.tmall.com/item.htm?id=456",
"category_name": "相机包",
},
price_cache={},
)
assert snapshot["estimated_gmv"] is None
assert snapshot["quality_status"] == "missing_price"
def test_feishu_document_labels_keep_raw_values_and_normalized_values():
content = """
<title>京东榜单</title>
<h2>旅行箱(1条)</h2>
<table>
<thead><tr>
<th>风格(运营手动区分)</th><th>场景用途(运营手动区分)</th>
<th>品类</th><th>排名</th><th>商品图</th><th>商品名称</th>
<th>销售额</th><th>商品价格</th><th>商品链接</th>
</tr></thead>
<tbody><tr>
<td><b>都是新旅</b></td><td>上班通勤</td><td>旅行箱</td><td>1</td>
<td></td><td>测试商品</td><td>¥50万~¥75万</td><td>¥399</td>
<td><a href="https://item.jd.com/123456.html">打开商品</a></td>
</tr></tbody>
</table>
"""
labels = extract_document_labels(content, "jd")
assert labels["123456"]["raw_style_name"] == "都是新旅"
assert labels["123456"]["style_name"] == "都市新旅"
assert labels["123456"]["raw_scene_name"] == "上班通勤"
assert labels["123456"]["scene_name"] == "通勤上班"
assert labels["123456"]["unit_price_text"] == "¥399"
def test_collector_schema_contains_idempotent_market_snapshot_tables():
schema = (RUNTIME_ROOT / "db" / "schema.sql").read_text(encoding="utf-8")
assert "CREATE TABLE IF NOT EXISTS market_product" in schema
assert "CREATE TABLE IF NOT EXISTS fact_market_product_snapshot" in schema
assert "CREATE TABLE IF NOT EXISTS market_product_classification" in schema
assert "UNIQUE (report_id, market_product_id, category_name)" in schema
@@ -0,0 +1,115 @@
from __future__ import annotations
from datetime import date
from unittest.mock import patch
from db import upsert_market_rank_report
from market_rank_report_archive import (
archive_market_rank_report,
extract_feishu_doc_id,
normalize_market_rank_platform,
)
class FakeCursor:
def __init__(self):
self.sql = ""
self.params = ()
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
def execute(self, sql, params):
self.sql = sql
self.params = params
def fetchone(self):
return (37,)
class FakeConnection:
def __init__(self):
self.cursor_instance = FakeCursor()
def cursor(self):
return self.cursor_instance
def test_extract_feishu_doc_id_from_docx_url():
assert (
extract_feishu_doc_id(
"https://bu0zgpibak.feishu.cn/docx/ZPCYdLtvfoksGmxy5zcc8E7anzg"
)
== "ZPCYdLtvfoksGmxy5zcc8E7anzg"
)
def test_platform_names_are_normalized_to_database_codes():
assert normalize_market_rank_platform("天猫") == "tm"
assert normalize_market_rank_platform("jd") == "jd"
assert normalize_market_rank_platform("抖音") == "dy"
def test_upsert_market_rank_report_is_idempotent_per_platform_and_day():
conn = FakeConnection()
report_id = upsert_market_rank_report(
conn,
{
"platform": "tm",
"report_date": date(2026, 7, 24),
"period": "30天",
"category_scope": "九品类",
"category_names": ["旅行箱", "运动包"],
"report_title": "天猫九品类市场排行 30天 2026-07-24",
"feishu_doc_id": "doc-token",
"feishu_doc_url": "https://example.feishu.cn/docx/doc-token",
"product_count": 146,
"source_json_path": "data/sycm_market_rank/result.json",
"run_metadata": {"min_price": 400, "max_price": 2000},
},
)
assert report_id == 37
assert "ON CONFLICT (platform, report_date)" in conn.cursor_instance.sql
assert "ON CONFLICT (platform, report_date, period, category_scope)" not in conn.cursor_instance.sql
assert conn.cursor_instance.params[0] == "tm"
assert conn.cursor_instance.params[4] == ["旅行箱", "运动包"]
assert conn.cursor_instance.params[7] == "https://example.feishu.cn/docx/doc-token"
assert conn.cursor_instance.params[8] == 146
def test_archive_market_rank_report_builds_and_saves_normalized_record():
with (
patch("market_rank_report_archive.get_conn") as get_conn,
patch(
"market_rank_report_archive.upsert_market_rank_report",
return_value=88,
) as upsert,
):
conn = object()
get_conn.return_value.__enter__.return_value = conn
report_id = archive_market_rank_report(
platform="京东",
report_date=date(2026, 7, 24),
period="近30天",
category_scope="七品类",
category_names=["旅行箱", "双肩包"],
report_title="京东七品类市场商品榜单 近30天 2026-07-24",
feishu_doc_url="https://example.feishu.cn/docx/jd-doc-token",
product_count=70,
source_json_path="data/jd_market_rank/result.json",
run_metadata={"max_pages": 20},
)
assert report_id == 88
record = upsert.call_args.args[1]
assert upsert.call_args.args[0] is conn
assert record["platform"] == "jd"
assert record["feishu_doc_id"] == "jd-doc-token"
assert record["category_names"] == ["旅行箱", "双肩包"]
assert record["run_metadata"] == {"max_pages": 20}
@@ -0,0 +1,298 @@
from __future__ import annotations
import json
import sys
from datetime import date, timedelta
from pathlib import Path
from threading import Barrier
import orchestrate_market_rank_collection as workflow
import pytest
REPOSITORY_ROOT = Path(__file__).resolve().parents[3]
PROJECT_ROOT = (
REPOSITORY_ROOT / "src" / "gyxx_flow" / "modules" / "product_commerce"
)
HISTORY_ROOT = REPOSITORY_ROOT / "docs" / "history" / "product-commerce"
def test_default_platform_order_and_commands():
assert workflow.DEFAULT_PLATFORMS == ("tm", "jd", "dy")
report_date = date(2026, 8, 4)
tm_command = workflow.build_platform_command(
"tm", report_date=report_date, python_executable=sys.executable
)
jd_command = workflow.build_platform_command(
"jd", report_date=report_date, python_executable=sys.executable
)
dy_command = workflow.build_platform_command(
"dy", report_date=report_date, python_executable=sys.executable
)
assert tm_command[0] == sys.executable
assert Path(tm_command[1]).name == "collect_sycm_market_rank.py"
assert tm_command[-2:] == ["--risk-mode", "manual"]
assert jd_command[0] == sys.executable
assert Path(jd_command[1]).name == "collect_jd_market_rank.py"
assert dy_command[0] == sys.executable
assert Path(dy_command[1]).name == "collect_dy_market_rank.py"
for command in (tm_command, jd_command, dy_command):
assert command[command.index("--report-date") + 1] == "2026-08-04"
def test_historical_live_snapshot_date_is_rejected():
today = date(2026, 8, 4)
with pytest.raises(ValueError, match="拒绝错标"):
workflow.validate_live_snapshot_date(
today - timedelta(days=1),
current_date=today,
)
def test_notification_recipient_comes_only_from_runtime_environment():
assert workflow.notification_recipient_from_env({}) == ("", "")
assert workflow.notification_recipient_from_env(
{
"GYXX_NOTIFICATION_RECIPIENT_OPEN_ID": "ou_runtime",
"GYXX_NOTIFICATION_RECIPIENT_NAME": "runtime-owner",
}
) == ("ou_runtime", "runtime-owner")
assert workflow.notification_recipient_from_env(
{
"MARKET_RANK_NOTIFY_OPEN_ID": "ou_market",
"MARKET_RANK_NOTIFY_NAME": "market-owner",
"GYXX_NOTIFICATION_RECIPIENT_OPEN_ID": "ou_generic",
}
) == ("ou_market", "market-owner")
def test_child_environment_forces_utf8_unbuffered_output():
env = workflow.build_subprocess_env({"EXISTING": "kept"})
assert env["EXISTING"] == "kept"
assert env["PYTHONIOENCODING"] == "utf-8"
assert env["PYTHONUNBUFFERED"] == "1"
def test_console_text_replaces_characters_not_supported_by_gbk():
assert workflow.console_safe_text("prefix \ufffd suffix", "gbk") == "prefix ? suffix"
def test_workflow_continues_after_one_platform_fails(tmp_path):
calls: list[str] = []
def fake_executor(*, platform, command, log_path, dry_run):
calls.append(platform)
return {
"platform": platform,
"command": command,
"log_path": str(log_path),
"started_at": "2026-07-26T10:00:00",
"finished_at": "2026-07-26T10:01:00",
"duration_seconds": 60.0,
"exit_code": 1 if platform == "tm" else 0,
"status": "failed" if platform == "tm" else "success",
}
summary = workflow.execute_workflow(
workflow.DEFAULT_PLATFORMS,
run_id="test-run",
log_dir=tmp_path,
executor=fake_executor,
)
assert set(calls) == {"tm", "jd", "dy"}
assert [item["platform"] for item in summary["steps"]] == ["tm", "jd", "dy"]
assert summary["exit_code"] == 1
assert [item["status"] for item in summary["steps"]] == [
"failed", "success", "success"
]
saved = json.loads((tmp_path / "test-run_summary.json").read_text(encoding="utf-8"))
assert saved["run_id"] == "test-run"
assert saved["exit_code"] == 1
def test_workflow_continues_when_executor_raises(tmp_path):
calls: list[str] = []
def raising_executor(*, platform, command, log_path, dry_run):
calls.append(platform)
if platform == "tm":
raise RuntimeError("browser startup failed")
return {
"platform": platform,
"command": command,
"log_path": str(log_path),
"started_at": "2026-07-26T10:00:00",
"finished_at": "2026-07-26T10:01:00",
"duration_seconds": 60.0,
"exit_code": 0,
"status": "success",
}
summary = workflow.execute_workflow(
workflow.DEFAULT_PLATFORMS,
run_id="raised-run",
log_dir=tmp_path,
executor=raising_executor,
)
assert set(calls) == {"tm", "jd", "dy"}
assert summary["exit_code"] == 1
assert summary["steps"][0]["status"] == "failed"
assert "browser startup failed" in summary["steps"][0]["error"]
def test_three_platforms_start_in_parallel(tmp_path):
all_started = Barrier(3)
def synchronized_executor(*, platform, command, log_path, dry_run):
all_started.wait(timeout=1)
return {
"platform": platform,
"command": command,
"log_path": str(log_path),
"started_at": "2026-07-26T10:00:00",
"finished_at": "2026-07-26T10:01:00",
"duration_seconds": 60.0,
"exit_code": 0,
"status": "success",
}
summary = workflow.execute_workflow(
workflow.DEFAULT_PLATFORMS,
run_id="parallel-run",
log_dir=tmp_path,
executor=synchronized_executor,
)
assert summary["exit_code"] == 0
assert [item["platform"] for item in summary["steps"]] == ["tm", "jd", "dy"]
def test_workflow_notifies_after_all_parallel_steps_finish(tmp_path):
all_started = Barrier(3)
notifications: list[dict] = []
report_date = date.today()
def synchronized_executor(*, platform, command, log_path, dry_run):
all_started.wait(timeout=1)
return {
"platform": platform,
"label": workflow.PLATFORM_LABELS[platform],
"command": command,
"log_path": str(log_path),
"started_at": "2026-07-26T10:00:00",
"finished_at": "2026-07-26T10:01:00",
"duration_seconds": 60.0,
"exit_code": 0,
"status": "success",
}
def fake_notifier(**kwargs):
notifications.append(kwargs)
return {"status": "sent", "recipient_open_id": kwargs["recipient_open_id"]}
summary = workflow.execute_workflow(
workflow.DEFAULT_PLATFORMS,
run_id="notify-run",
log_dir=tmp_path,
executor=synchronized_executor,
report_date=report_date,
notify_open_id="ou_wangyunlong",
notify_name="runtime-owner",
notifier=fake_notifier,
)
assert len(notifications) == 1
assert notifications[0]["report_date"] == report_date
assert notifications[0]["recipient_open_id"] == "ou_wangyunlong"
assert notifications[0]["recipient_name"] == "runtime-owner"
assert notifications[0]["generated_after"] is not None
for step in summary["steps"]:
command = step["command"]
assert command[command.index("--report-date") + 1] == report_date.isoformat()
assert summary["notification"]["status"] == "sent"
saved = json.loads((tmp_path / "notify-run_summary.json").read_text(encoding="utf-8"))
assert saved["notification"]["status"] == "sent"
def test_partial_current_run_links_can_still_send_successfully(tmp_path):
def successful_executor(*, platform, command, log_path, dry_run):
return {
"platform": platform,
"command": command,
"log_path": str(log_path),
"started_at": "2026-07-26T10:00:00",
"finished_at": "2026-07-26T10:01:00",
"duration_seconds": 60.0,
"exit_code": 0,
"status": "success",
}
summary = workflow.execute_workflow(
workflow.DEFAULT_PLATFORMS,
run_id="missing-link-run",
log_dir=tmp_path,
executor=successful_executor,
notify_open_id="ou_wangyunlong",
notifier=lambda **kwargs: {
"status": "sent",
"platform_links": {"jd": "https://example.com/jd"},
"missing_platforms": ["tm", "dy"],
},
)
assert summary["exit_code"] == 0
assert summary["status"] == "success"
def test_platform_parser_deduplicates_while_preserving_order():
assert workflow.parse_platforms("dy,tm,dy,jd") == ("dy", "tm", "jd")
def test_main_requires_runtime_recipient_unless_notification_disabled(monkeypatch):
monkeypatch.setattr(workflow, "DEFAULT_NOTIFY_OPEN_ID", "")
monkeypatch.setattr(sys, "argv", ["orchestrate_market_rank_collection.py", "--dry-run"])
with pytest.raises(SystemExit) as exc_info:
workflow.main()
assert exc_info.value.code == 2
def test_main_allows_explicit_no_notify_without_recipient(monkeypatch, tmp_path):
monkeypatch.setattr(workflow, "DEFAULT_NOTIFY_OPEN_ID", "")
observed = {}
def fake_execute(*args, **kwargs):
observed.update(kwargs)
return {"exit_code": 0}
monkeypatch.setattr(workflow, "execute_workflow", fake_execute)
monkeypatch.setattr(workflow, "managed_data_path", Path)
monkeypatch.setattr(
sys,
"argv",
[
"orchestrate_market_rank_collection.py",
"--dry-run",
"--no-notify",
"--log-dir",
str(tmp_path),
],
)
assert workflow.main() == 0
assert observed["notify_open_id"] is None
def test_weekly_launcher_uses_market_rank_orchestrator():
launcher = (
HISTORY_ROOT / "launchers_reference" / "run_weekly_market_rank.bat"
).read_text(encoding="utf-8")
assert ".venv\\Scripts\\python.exe" in launcher
assert "orchestrate_market_rank_collection.py" in launcher
@@ -0,0 +1,319 @@
from __future__ import annotations
import json
import sys
from datetime import date
from pathlib import Path
from types import SimpleNamespace
import collect_dy_persona_to_bitable as dy_worker
import collect_jd_persona_to_bitable as jd_worker
import collect_persona_to_bitable as tm_worker
import dy_audience_profile_collect as dy_collector
import pytest
import taobao_dmp_item_crowd_insight_screenshots as dmp
from gyxx_flow.adapters import acceptance_policy
WORKERS = (tm_worker, dy_worker, jd_worker)
BUSINESS_DATE = date(2026, 8, 4)
@pytest.mark.parametrize("worker", WORKERS)
def test_platform_collector_failure_forces_nonzero(worker) -> None:
assert worker.combined_result_exit_code(
[{"status": "ok"}],
collection_ok=False,
) == 2
@pytest.mark.parametrize("worker", WORKERS)
def test_formal_base_upsert_requires_response_record_id(monkeypatch, worker) -> None:
monkeypatch.setattr(acceptance_policy, "skip_feishu_table_write", lambda *_a, **_k: False)
monkeypatch.setattr(
worker.subprocess,
"run",
lambda *_a, **_k: SimpleNamespace(
returncode=0,
stdout=json.dumps({"ok": True, "data": {}}),
stderr="",
),
)
ok, message = worker.upsert_record(
"bas_test",
"tbl_test",
{"时间": "8.4", "男性比例": 0.5},
)
assert ok is False
assert "record_id" in message
@pytest.mark.parametrize("worker", WORKERS)
def test_formal_base_upsert_accepts_verified_response_record_id(monkeypatch, worker) -> None:
monkeypatch.setattr(acceptance_policy, "skip_feishu_table_write", lambda *_a, **_k: False)
monkeypatch.setattr(
worker.subprocess,
"run",
lambda *_a, **_k: SimpleNamespace(
returncode=0,
stdout=json.dumps(
{
"ok": True,
"data": {"record": {"record_id_list": ["rec12345678"]}},
}
),
stderr="",
),
)
ok, message = worker.upsert_record(
"bas_test",
"tbl_test",
{"时间": "8.4", "男性比例": 0.5},
)
assert ok is True
assert message == "record_id=rec12345678"
@pytest.mark.parametrize("worker", WORKERS)
def test_acceptance_skip_is_not_formal_success(monkeypatch, worker) -> None:
monkeypatch.setattr(acceptance_policy, "skip_feishu_table_write", lambda *_a, **_k: True)
monkeypatch.setattr(
worker.subprocess,
"run",
lambda *_a, **_k: (_ for _ in ()).throw(
AssertionError("lark subprocess must not run after acceptance skip")
),
)
ok, message = worker.upsert_record(
"bas_test",
"tbl_test",
{"时间": "8.4", "男性比例": 0.5},
)
assert ok is False
assert "acceptance-skipped" in message
def test_tm_payload_must_match_requested_business_date(monkeypatch, tmp_path: Path) -> None:
style = "款A"
style_dir = tmp_path / BUSINESS_DATE.isoformat() / style
style_dir.mkdir(parents=True)
(style_dir / f"{style}_123_chart_values.json").write_text(
json.dumps(
{
"style_name": style,
"item_id": "123",
"business_date": "2026-08-03",
"chart_values": {"用户性别": [{"category": "男性用户", "分析人群占比": 50}]},
},
ensure_ascii=False,
),
encoding="utf-8",
)
monkeypatch.setattr(tm_worker, "DMP_OUTPUT_ROOT", tmp_path)
assert tm_worker.load_latest_chart_values(style, BUSINESS_DATE) is None
@pytest.mark.parametrize("worker", (dy_worker, jd_worker))
def test_profile_payload_must_match_requested_business_date(
monkeypatch,
tmp_path: Path,
worker,
) -> None:
style = "款A"
style_dir = tmp_path / BUSINESS_DATE.isoformat() / style
style_dir.mkdir(parents=True)
(style_dir / f"{style}_123_profile.json").write_text(
json.dumps(
{
"style_name": style,
"product_id": "123",
"business_date": "2026-08-03",
"profile": {"gender_distribution": [{"name": "", "value": 50}]},
},
ensure_ascii=False,
),
encoding="utf-8",
)
monkeypatch.setattr(worker, "OUTPUT_ROOT", tmp_path)
assert worker.load_latest_profile(style, BUSINESS_DATE) is None
@pytest.mark.parametrize("worker", (tm_worker, dy_worker))
def test_current_run_does_not_reuse_old_artifact(monkeypatch, tmp_path: Path, worker) -> None:
style = "款A"
style_dir = tmp_path / BUSINESS_DATE.isoformat() / style
style_dir.mkdir(parents=True)
if worker is tm_worker:
path = style_dir / f"{style}_123_chart_values.json"
payload = {
"style_name": style,
"item_id": "123",
"business_date": BUSINESS_DATE.isoformat(),
"chart_values": {"用户性别": [{"category": "男性用户", "分析人群占比": 50}]},
}
monkeypatch.setattr(worker, "DMP_OUTPUT_ROOT", tmp_path)
load = worker.load_latest_chart_values
else:
path = style_dir / f"{style}_123_profile.json"
payload = {
"style_name": style,
"product_id": "123",
"business_date": BUSINESS_DATE.isoformat(),
"profile": {"gender_distribution": [{"name": "", "value": 50}]},
}
monkeypatch.setattr(worker, "OUTPUT_ROOT", tmp_path)
load = worker.load_latest_profile
path.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
assert load(
style,
BUSINESS_DATE,
artifact_not_before=path.stat().st_mtime + 10,
) is None
@pytest.mark.parametrize("worker", WORKERS)
def test_database_failure_stops_before_base_write(monkeypatch, worker) -> None:
if worker is tm_worker:
monkeypatch.setattr(
worker,
"load_latest_chart_values",
lambda *_a, **_k: {
"style_name": "款A",
"item_id": "123",
"business_date": BUSINESS_DATE.isoformat(),
"chart_values": {"用户性别": [{"category": "男性用户", "分析人群占比": 50}]},
},
)
monkeypatch.setattr(
worker,
"chart_values_to_fields",
lambda *_a, **_k: {"时间": "8.4", "男性比例": 0.5},
)
normalize_name = "normalize_tm_payload"
elif worker is dy_worker:
monkeypatch.setattr(
worker,
"load_latest_profile",
lambda *_a, **_k: {
"style_name": "款A",
"product_id": "123",
"business_date": BUSINESS_DATE.isoformat(),
"profile": {"gender_distribution": [{"name": "", "value": 50}]},
},
)
monkeypatch.setattr(worker, "profile_has_positive_distribution", lambda _profile: True)
monkeypatch.setattr(worker, "get_subtable_fields", lambda *_a, **_k: [])
monkeypatch.setattr(worker, "profile_to_fields", lambda *_a, **_k: {"男性比例": 0.5})
normalize_name = "normalize_dy_payload"
else:
monkeypatch.setattr(
worker,
"load_latest_profile",
lambda *_a, **_k: {
"style_name": "款A",
"product_id": "123",
"business_date": BUSINESS_DATE.isoformat(),
"profile": {"性别": [{"name": "", "value": 50}]},
},
)
monkeypatch.setattr(worker, "get_subtable_fields", lambda *_a, **_k: [])
monkeypatch.setattr(worker, "map_profile_to_fields", lambda *_a, **_k: {"男性比例": 0.5})
normalize_name = "normalize_jd_payload"
fake_db = SimpleNamespace(
get_conn=lambda: (_ for _ in ()).throw(RuntimeError("database unavailable")),
upsert_persona_metrics=lambda *_a, **_k: 1,
**{normalize_name: lambda _payload: {"valid": True}},
)
monkeypatch.setitem(sys.modules, "db", fake_db)
monkeypatch.setattr(
worker,
"upsert_record",
lambda *_a, **_k: (_ for _ in ()).throw(
AssertionError("Base write must not run after DB failure")
),
)
result = worker.upsert_one_style(
"款A",
{"base_token": "bas_test", "table_id": "tbl_test"},
"8.4",
"user",
False,
BUSINESS_DATE,
)
assert result["status"] == "db_failed"
def test_zero_distributions_are_not_valid_persona() -> None:
assert dy_worker.profile_has_positive_distribution(
{
"gender_distribution": [{"name": "", "value": 0}],
"age_distribution": [],
"strategy_crowd_distribution": [{"name": "Z世代", "value": "0%"}],
}
) is False
def test_dmp_skips_are_not_valid_style_results() -> None:
styles = {"款A": ["123"]}
assert dmp.records_exit_code(
styles,
[{"style_name": "款A", "status": "skipped", "skip_reason": "无图表"}],
) == 2
assert dmp.records_exit_code(
styles,
[
{
"style_name": "款A",
"status": "ok",
"chart_values": {
"用户性别": [{"category": "男性用户", "分析人群占比": 50}]
},
}
],
) == 0
@pytest.mark.parametrize(
("detector", "url", "body", "expected"),
(
(dmp.blocking_page_reason, "https://dmp.taobao.com/", "暂无权限", "权限"),
(dy_collector.blocking_page_reason, "https://compass.test/", "请登录", "登录"),
(jd_worker.blocking_page_reason, "https://passport.jd.com/login", "", "登录"),
),
)
def test_login_and_permission_pages_have_explicit_diagnostics(
detector,
url: str,
body: str,
expected: str,
) -> None:
assert expected in detector(url, body)
def test_persona_search_selectors_are_finite_and_evidence_based() -> None:
assert 1 <= len(dy_collector.PRODUCT_SEARCH_INPUT_SELECTORS) <= 6
assert 1 <= len(jd_worker.JD_SEARCH_INPUT_SELECTORS) <= 6
assert all("input" in selector for selector in dy_collector.PRODUCT_SEARCH_INPUT_SELECTORS)
assert all("input" in selector for selector in jd_worker.JD_SEARCH_INPUT_SELECTORS)
def test_tm_collector_command_redacts_credentials() -> None:
rendered = tm_worker.redacted_command(
["python", "collector.py", "--account", "secret-user", "--password", "secret-pass"]
)
assert "secret-user" not in rendered
assert "secret-pass" not in rendered
assert rendered.count("***") == 2
@@ -0,0 +1,57 @@
import unittest
from datetime import date
from pathlib import Path
import collect_dy_persona_to_bitable as dy_worker
import collect_jd_persona_to_bitable as jd_worker
import collect_persona_to_bitable as tm_worker
import run_daily_persona
class PersonaLauncherTests(unittest.TestCase):
def test_any_platform_failure_makes_launcher_fail(self):
self.assertEqual(run_daily_persona.combined_exit_code({"tm": 0, "dy": 2, "jd": 0}), 2)
def test_all_platforms_success_makes_launcher_success(self):
self.assertEqual(run_daily_persona.combined_exit_code({"tm": 0, "dy": 0, "jd": 0}), 0)
def test_launcher_passes_business_date_to_each_worker(self):
command = run_daily_persona.build_child_command(
Path("collect_persona_to_bitable.py"),
date(2026, 8, 4),
)
self.assertEqual(command[-2:], ["--date", "2026-08-04"])
def test_workers_fail_when_permission_is_skipped(self):
results = [{"status": "ok"}, {"status": "permission_skipped"}]
for worker in (tm_worker, dy_worker, jd_worker):
with self.subTest(worker=worker.__name__):
self.assertEqual(worker.combined_result_exit_code(results), 2)
def test_workers_fail_when_no_styles_were_processed(self):
for worker in (tm_worker, dy_worker, jd_worker):
with self.subTest(worker=worker.__name__):
self.assertEqual(worker.combined_result_exit_code([]), 2)
def test_workers_succeed_only_when_all_styles_are_successful(self):
results = [{"status": "ok"}, {"status": "dry-run"}]
for worker in (tm_worker, dy_worker, jd_worker):
with self.subTest(worker=worker.__name__):
self.assertEqual(worker.combined_result_exit_code(results), 0)
def test_workers_fail_when_collector_itself_failed(self):
results = [{"status": "ok"}]
for worker in (tm_worker, dy_worker, jd_worker):
with self.subTest(worker=worker.__name__):
self.assertEqual(
worker.combined_result_exit_code(results, collection_ok=False),
2,
)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,204 @@
from __future__ import annotations
import io
import json
from pathlib import Path
from types import SimpleNamespace
import check_nine_day_decline as decline
import insert_bitable_records as insert_records
import lark_cli_runtime
import market_rank_hermes_notification as market_notification
import pytest
from gyxx_flow.adapters import WORKFLOW_ACCEPTANCE_RECIPIENT_OPEN_ID
def _enable_acceptance(monkeypatch, tmp_path: Path) -> Path:
evidence = tmp_path / "product-evidence.jsonl"
monkeypatch.setenv("GYXX_WORKFLOW_ACCEPTANCE", "1")
monkeypatch.setenv("GYXX_ACCEPTANCE_EVIDENCE_FILE", str(evidence))
monkeypatch.delenv("GYXX_NOTIFICATION_RECIPIENT_OPEN_ID", raising=False)
return evidence
@pytest.mark.parametrize(
"arguments",
[
["base", "+record-upsert"],
["base", "+record-delete"],
["sheets", "+values-batch-update"],
],
)
def test_product_shared_lark_cli_skips_table_mutations(
monkeypatch,
tmp_path: Path,
arguments: list[str],
) -> None:
_enable_acceptance(monkeypatch, tmp_path)
def fail_if_spawned(*args, **kwargs):
raise AssertionError("lark-cli subprocess must not start")
monkeypatch.setattr(lark_cli_runtime.subprocess, "run", fail_if_spawned)
result = lark_cli_runtime.run_lark_cli(arguments)
assert result["acceptance_skipped"] is True
def test_legacy_product_upsert_bypass_is_physically_skipped(
monkeypatch,
tmp_path: Path,
) -> None:
evidence = _enable_acceptance(monkeypatch, tmp_path)
def fail_if_spawned(*args, **kwargs):
raise AssertionError("legacy lark-cli subprocess must not start")
monkeypatch.setattr(insert_records.subprocess, "run", fail_if_spawned)
insert_records.run_lark_upsert("base_secret", "tbl_test", {"field": 1})
payload = json.loads(evidence.read_text(encoding="utf-8"))
assert payload["operation"].endswith("record-upsert")
assert payload["details"]["base_token"] == "<redacted>"
def test_product_shared_lark_cli_keeps_production_write_behavior(monkeypatch) -> None:
monkeypatch.delenv("GYXX_WORKFLOW_ACCEPTANCE", raising=False)
calls = []
def fake_run(command, **kwargs):
calls.append(command)
return SimpleNamespace(returncode=0, stdout='{"ok": true}', stderr="")
monkeypatch.setattr(lark_cli_runtime, "_node_cli_entry", lambda: ("node", "run.js"))
monkeypatch.setattr(lark_cli_runtime.subprocess, "run", fake_run)
result = lark_cli_runtime.run_lark_cli(["base", "+record-upsert"])
assert result == {"ok": True}
assert len(calls) == 1
assert calls[0][:4] == ["node", "run.js", "--profile", "hermes-analyzer"]
def test_product_shared_lark_cli_allows_safe_profile_override(monkeypatch) -> None:
monkeypatch.delenv("GYXX_WORKFLOW_ACCEPTANCE", raising=False)
calls = []
def fake_run(command, **kwargs):
calls.append(command)
return SimpleNamespace(returncode=0, stdout='{"ok": true}', stderr="")
monkeypatch.setattr(lark_cli_runtime, "_node_cli_entry", lambda: ("node", "run.js"))
monkeypatch.setattr(lark_cli_runtime.subprocess, "run", fake_run)
lark_cli_runtime.run_lark_cli(
["auth", "status", "--json"],
env={"GYXX_LARK_CLI_PROFILE": "product-analyzer.test"},
)
assert calls[0][:4] == [
"node",
"run.js",
"--profile",
"product-analyzer.test",
]
def test_decline_hermes_prompt_forces_wang_yunlong(
monkeypatch,
tmp_path: Path,
) -> None:
_enable_acceptance(monkeypatch, tmp_path)
monkeypatch.setattr(decline, "HERMES_API_KEY", "test-only")
captured = {}
class Response:
def __enter__(self):
return self
def __exit__(self, *args):
return False
def read(self):
return json.dumps({"choices": [{"message": {"content": "ok"}}]}).encode()
def fake_urlopen(request, timeout):
captured["body"] = json.load(io.BytesIO(request.data))
return Response()
monkeypatch.setattr(decline.urllib.request, "urlopen", fake_urlopen)
decline.notify_hermes(
[{"style_name": "test", "platform": "tm"}],
"2026-08-01",
9,
openid="ou_someone_else",
)
encoded = json.dumps(captured["body"], ensure_ascii=False)
assert WORKFLOW_ACCEPTANCE_RECIPIENT_OPEN_ID in encoded
assert "ou_someone_else" not in encoded
def test_market_rank_notification_forces_wang_yunlong(
monkeypatch,
tmp_path: Path,
) -> None:
_enable_acceptance(monkeypatch, tmp_path)
captured = {}
class ConnectionContext:
def __enter__(self):
return object()
def __exit__(self, *args):
return False
monkeypatch.setattr(market_notification, "get_conn", ConnectionContext)
monkeypatch.setattr(
market_notification,
"ensure_market_rank_notification_table",
lambda conn: None,
)
monkeypatch.setattr(
market_notification,
"get_market_rank_reports_for_date",
lambda *args, **kwargs: [
{
"platform": "tm",
"feishu_doc_url": "https://example.test/tm",
"product_count": 1,
}
],
)
monkeypatch.setattr(
market_notification,
"market_rank_notification_was_sent",
lambda *args, **kwargs: False,
)
def fake_post(payload):
captured["payload"] = payload
return {"message_id": "om_test"}
def fake_archive(conn, record):
captured["record"] = record
monkeypatch.setattr(market_notification, "post_hermes", fake_post)
monkeypatch.setattr(
market_notification,
"upsert_market_rank_notification",
fake_archive,
)
result = market_notification.notify_market_rank_reports(
report_date=decline.date(2026, 8, 1),
recipient_open_id="ou_someone_else",
)
encoded = json.dumps(captured["payload"], ensure_ascii=False)
assert WORKFLOW_ACCEPTANCE_RECIPIENT_OPEN_ID in encoded
assert "ou_someone_else" not in encoded
assert result["recipient_open_id"] == WORKFLOW_ACCEPTANCE_RECIPIENT_OPEN_ID
assert (
captured["record"]["recipient_open_id"]
== WORKFLOW_ACCEPTANCE_RECIPIENT_OPEN_ID
)
@@ -0,0 +1,232 @@
from __future__ import annotations
import subprocess
from datetime import date
from pathlib import Path
import backfill_collect
import backfill_one_day
import collect_dy_persona_to_bitable as dy_persona_worker
import collect_persona_to_bitable as tm_persona_worker
import orchestrate_daily_collection as daily
import orchestrate_market_rank_collection as market_rank
import orchestrate_review_collection as reviews
import run_alerts_with_retry as alerts
import run_daily_persona as persona
import run_weekly_jd_main_image as jd_main_image
import run_weekly_main_image as tm_main_image
def _fake_rebind(observed: dict[str, object]):
def rebind(target, base_environment):
observed["target"] = Path(target)
observed["base"] = dict(base_environment)
return {
**base_environment,
"GYXX_SCRIPT_ID": f"product_commerce:{Path(target).name}",
"GYXX_BROWSER_CDP_PORT": "child-port",
"GYXX_BROWSER_PROFILE_DIR": "child-profile",
"GYXX_BROWSER_COOKIE_FILE": "child-cookie",
"GYXX_BROWSER_STORAGE_STATE_FILE": "child-storage",
}
return rebind
def _assert_child_binding(environment: dict[str, str]) -> None:
assert environment["GYXX_BROWSER_CDP_PORT"] == "child-port"
assert environment["GYXX_BROWSER_PROFILE_DIR"] == "child-profile"
assert environment["GYXX_BROWSER_COOKIE_FILE"] == "child-cookie"
assert environment["GYXX_BROWSER_STORAGE_STATE_FILE"] == "child-storage"
def test_persona_builds_one_target_bound_environment_per_collector(monkeypatch) -> None:
observed: dict[str, object] = {}
monkeypatch.setattr(persona, "environment_for_child_script", _fake_rebind(observed))
target = persona.PROJECT_ROOT / "collect_persona_to_bitable.py"
environment = persona.build_child_environment(target, {"KEEP": "yes"})
assert observed["target"] == target
assert environment["KEEP"] == "yes"
_assert_child_binding(environment)
def test_persona_workers_rebind_their_nested_browser_collectors(monkeypatch) -> None:
cases = (
(
tm_persona_worker,
tm_persona_worker.DMP_SCRIPT,
lambda: tm_persona_worker.run_dmp_collect_all(
"parent-profile",
False,
"",
"",
date(2026, 8, 4),
),
),
(
dy_persona_worker,
dy_persona_worker.COLLECT_SCRIPT,
lambda: dy_persona_worker.run_collect_all(
"parent-profile",
False,
date(2026, 8, 4),
),
),
)
for worker, target, invoke in cases:
observed: dict[str, object] = {}
monkeypatch.setattr(
worker,
"environment_for_child_script",
_fake_rebind(observed),
)
def fake_run(command, **kwargs):
observed["command"] = command
observed["environment"] = kwargs["env"]
return subprocess.CompletedProcess(command, 0)
monkeypatch.setattr(worker.subprocess, "run", fake_run)
assert invoke() is True
assert observed["target"] == target
command = observed["command"]
assert command[command.index("--date") + 1] == "2026-08-04"
_assert_child_binding(observed["environment"])
def test_daily_run_step_rebinds_to_command_target(monkeypatch) -> None:
observed: dict[str, object] = {}
monkeypatch.setattr(daily, "environment_for_child_script", _fake_rebind(observed))
def fake_run(command, **kwargs):
observed["environment"] = kwargs["env"]
return subprocess.CompletedProcess(command, 0)
monkeypatch.setattr(daily.subprocess, "run", fake_run)
target = daily.PROJECT_ROOT / "dy_product_scraping.py"
result = daily.run_step("dy", ["python", str(target)], critical=True)
assert result["code"] == 0
assert observed["target"] == target
_assert_child_binding(observed["environment"])
def test_market_rank_rebinds_to_each_platform_target(monkeypatch) -> None:
observed: dict[str, object] = {}
monkeypatch.setattr(
market_rank,
"environment_for_child_script",
_fake_rebind(observed),
)
target = market_rank.PROJECT_ROOT / "collect_jd_market_rank.py"
environment = market_rank.build_subprocess_env(
{"KEEP": "yes"},
target=target,
)
assert observed["target"] == target
assert environment["KEEP"] == "yes"
assert environment["PYTHONIOENCODING"] == "utf-8"
assert environment["PYTHONUNBUFFERED"] == "1"
_assert_child_binding(environment)
def test_review_run_step_rebinds_to_command_target(monkeypatch) -> None:
observed: dict[str, object] = {}
monkeypatch.setattr(reviews, "environment_for_child_script", _fake_rebind(observed))
def fake_run(command, **kwargs):
observed["environment"] = kwargs["env"]
return subprocess.CompletedProcess(command, 0)
monkeypatch.setattr(reviews.subprocess, "run", fake_run)
target = reviews.PROJECT_ROOT / "dy_product_scraping.py"
result = reviews.run_step("dy", ["python", str(target)], critical=True)
assert result["code"] == 0
assert observed["target"] == target
_assert_child_binding(observed["environment"])
def test_main_image_launchers_rebind_to_command_target(monkeypatch) -> None:
for launcher in (tm_main_image, jd_main_image):
observed: dict[str, object] = {}
monkeypatch.setattr(
launcher,
"environment_for_child_script",
_fake_rebind(observed),
)
def fake_run(command, **kwargs):
observed["environment"] = kwargs["env"]
return subprocess.CompletedProcess(command, 0)
monkeypatch.setattr(launcher.subprocess, "run", fake_run)
target = launcher.PROJECT_ROOT / "collector.py"
result = launcher.run_step("collect", ["python", str(target)])
assert result["code"] == 0
assert observed["target"] == target
_assert_child_binding(observed["environment"])
def test_alert_wrapper_exposes_target_bound_environment(monkeypatch) -> None:
observed: dict[str, object] = {}
monkeypatch.setattr(alerts, "environment_for_child_script", _fake_rebind(observed))
target = alerts.PROJECT_ROOT / "check_nine_day_decline.py"
environment = alerts.build_child_environment(target, {"KEEP": "yes"})
assert observed["target"] == target
assert environment["KEEP"] == "yes"
_assert_child_binding(environment)
def test_backfill_range_rebinds_daily_orchestrator_after_overrides(monkeypatch) -> None:
observed: dict[str, object] = {}
monkeypatch.setattr(
backfill_collect,
"environment_for_child_script",
_fake_rebind(observed),
)
target = backfill_collect.PROJECT_ROOT / "orchestrate_daily_collection.py"
environment = backfill_collect._subprocess_env(target)
assert observed["target"] == target
assert observed["base"]["KEEP_BROWSER_OPEN"] == "0"
assert environment["KEEP_BROWSER_OPEN"] == "0"
_assert_child_binding(environment)
def test_backfill_day_rebinds_each_stage_target(monkeypatch) -> None:
observed: dict[str, object] = {}
monkeypatch.setattr(
backfill_one_day,
"environment_for_child_script",
_fake_rebind(observed),
)
def fake_run(command, **kwargs):
observed["environment"] = kwargs["env"]
return subprocess.CompletedProcess(command, 0)
monkeypatch.setattr(backfill_one_day.subprocess, "run", fake_run)
target = backfill_one_day.PROJECT_ROOT / "dy_product_scraping.py"
code, _elapsed = backfill_one_day.run_subprocess(
"dy",
["python", str(target)],
"2026-07-31",
)
assert code == 0
assert observed["target"] == target
_assert_child_binding(observed["environment"])
@@ -0,0 +1,35 @@
from __future__ import annotations
import json
from pathlib import Path
REPOSITORY_ROOT = Path(__file__).resolve().parents[3]
def _workflow(workflow_id: str) -> dict:
catalog = json.loads(
(REPOSITORY_ROOT / "config" / "workflows.json").read_text(encoding="utf-8")
)
return next(item for item in catalog["workflows"] if item["id"] == workflow_id)
def test_product_workflows_forward_the_selected_business_date() -> None:
persona = _workflow("product.persona.daily")["execution"]["steps"][0]
style = _workflow("product.style_analysis.interval")["execution"]["steps"][0]
main_image = _workflow("product.main_image.weekly")["execution"]["steps"]
market_rank = _workflow("product.market_rank")["execution"]["steps"][0]
monthly_sales = _workflow("product.sales_sheet.daily")["execution"]["steps"][0]
assert persona["args"] == ["--date", "{business_date}"]
assert style["args"][-2:] == ["--end-date", "{business_date}"]
assert all(
step["args"][:2] == ["--date", "{business_date}"]
for step in main_image
)
assert market_rank["args"] == ["--report-date", "{business_date}"]
assert monthly_sales["args"] == [
"--month",
"{business_date}",
"--execute",
"--allow-missing-erp-as-zero",
]
@@ -0,0 +1,695 @@
import json
import os
import tempfile
import unittest
from datetime import date
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch
import analyze_style_with_hermes as analysis
import orchestrate_daily_collection as orchestrator
from config.style_config_loader import StyleConfigLoader
REPOSITORY_ROOT = Path(__file__).resolve().parents[3]
HISTORY_ROOT = REPOSITORY_ROOT / "docs" / "history" / "product-commerce"
class StyleAnalysisConfigTests(unittest.TestCase):
def test_platform_analysis_url_is_aggregated_by_style(self):
loader = StyleConfigLoader()
payload = loader._aggregate([
{
"platform": "天猫",
"style": "盖亚斜挎",
"style_analysis_bitable_url": (
"[分析表](https://example.feishu.cn/base/base_token"
"?table=table_id&view=view_id)"
),
}
])
self.assertEqual(
payload["styles"]["盖亚斜挎"]["style_analysis_bitable"],
{"base_token": "base_token", "table_id": "table_id", "view_id": "view_id"},
)
class StyleAnalysisWindowTests(unittest.TestCase):
def test_main_image_analysis_remains_tmall_scoped_after_jd_merge(self):
class Cursor:
sql = ""
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
def execute(self, sql, params):
self.sql = sql
self.params = params
@staticmethod
def fetchall():
return []
class Connection:
def __init__(self, cursor):
self._cursor = cursor
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
def cursor(self):
return self._cursor
cursor = Cursor()
with patch.object(analysis, "get_conn", return_value=Connection(cursor)):
rows = analysis.pull_main_image(
"同款", date(2026, 7, 1), date(2026, 7, 3)
)
self.assertEqual(rows, [])
self.assertIn("platform='tm'", cursor.sql)
def test_three_day_label_uses_compact_end_date(self):
self.assertEqual(
analysis.analysis_period_label(date(2026, 7, 13), date(2026, 7, 15)),
"2026-07-13~07-15",
)
def test_due_styles_wait_three_full_days(self):
styles = ["A", "B", "C"]
latest = {"A": date(2026, 7, 15), "B": date(2026, 7, 12)}
due = analysis.select_due_styles(styles, latest, date(2026, 7, 15), 3)
self.assertEqual(due, ["B", "C"])
def test_prompt_keeps_all_dimensions_inside_current_three_day_window(self):
payload = {
"total": {"sales": 30, "visitors": 1000, "cart": 80},
"platforms": {"tm": {"sales": 20}, "jd": {"sales": 10}},
"daily": [{"date": "2026-07-14", "plat": "tm", "sales": 10}],
"persona": [{"date": "2026-07-14", "plat": "tm", "gender": []}],
"product_reviews": {"tm|negative": {"n": 2, "samples": ["肩带偏硬"]}},
"main_image": [{"date": "2026-07-14", "key": "hero", "ctr": "2.1"}],
"marketing": {
"notes_by_platform_all": [{"platform": "xiaohongshu", "views": 1000}],
"note_comments_sample": [{"content": "求尺寸"}],
},
}
prompt = analysis.build_user_prompt(
"盖亚斜挎", date(2026, 7, 14), date(2026, 7, 16), payload,
)
self.assertIn("2026-07-14 ~ 2026-07-16", prompt)
self.assertNotIn("对比基期", prompt)
self.assertNotIn("紧邻上期", prompt)
self.assertNotIn("最近 6 周", prompt)
self.assertNotIn("近 60d", prompt)
self.assertIn("逐日经营数据", prompt)
self.assertIn("人物画像", prompt)
self.assertIn("商品评价", prompt)
self.assertIn("主图表现", prompt)
self.assertIn("营销笔记、曝光互动与评论", prompt)
self.assertIn("肩带偏硬", prompt)
self.assertIn("求尺寸", prompt)
self.assertIn('"sales": 30', prompt)
def test_system_prompt_requires_each_business_dimension(self):
for dimension in ("访客", "加购", "人物画像", "营销笔记", "笔记评论", "商品评价", "主图"):
self.assertIn(dimension, analysis.SYSTEM)
def test_system_prompt_requires_fixed_seven_section_report(self):
headings = [
"## 一、访客分析",
"## 二、加购率分析",
"## 三、点击率分析",
"## 四、转化率分析",
"## 五、退货率分析",
"## 六、销量分析",
"## 七、总评",
]
for heading in headings:
self.assertIn(heading, analysis.SYSTEM)
def test_plain_seven_section_titles_are_normalized_to_markdown(self):
report = """盖亚斜挎 单款式多维度分析报告 (2026-07-11~2026-07-13)
一、访客分析
内容
二、加购率分析
内容
三、点击率分析
内容
四、转化率分析
内容
五、退货率分析
内容
六、销量分析
内容
七、总评
内容
"""
normalized = analysis.normalize_report_markdown(report)
self.assertTrue(normalized.startswith("# 盖亚斜挎"))
self.assertTrue(analysis.has_exact_report_headings(normalized))
def test_report_gets_clear_subheadings_and_key_emphasis(self):
blocks = ["盖亚斜挎 单款式多维度分析报告 (2026-07-11~2026-07-13)"]
for heading in analysis.REPORT_HEADINGS[:-1]:
blocks.extend([
heading.removeprefix("## "),
"数据快照:三日访客2692,转化率0.71%。其余数据。",
"问题判断:天猫访客高但转化偏低。需要重点处理。",
"可执行建议:P0,24小时内完成核查,目标转化率1.00%",
])
blocks.extend([
"七、总评",
"核心结论:当前核心问题是高访客、低转化。需要优先修复。",
"链路表现为:曝光到访客再到成交。",
"P0,48小时内完成详情页整改,目标转化率1.00%",
])
normalized = analysis.normalize_report_markdown("\n\n".join(blocks))
self.assertTrue(analysis.has_clear_report_hierarchy(normalized))
self.assertTrue(analysis.has_key_emphasis(normalized))
self.assertIn("### 数据快照", normalized)
self.assertIn("### 核心判断", normalized)
self.assertIn("### 优先级行动", normalized)
self.assertIn("**P0**", normalized)
self.assertIn("**24小时内**", normalized)
def test_extra_helpful_h3_does_not_reject_complete_required_hierarchy(self):
required = "\n".join(analysis.REPORT_SUBHEADINGS)
report = required.replace(
"### 核心判断\n",
"### 平台明细\n补充内容\n### 核心判断\n",
1,
)
self.assertTrue(analysis.has_clear_report_hierarchy(report))
def test_missing_calendar_day_is_reported(self):
rows = [
{"date": "2026-07-14", "plat": "tm"},
{"date": "2026-07-16", "plat": "jd"},
]
missing = analysis.missing_window_dates(
rows, date(2026, 7, 14), date(2026, 7, 16),
)
self.assertEqual(missing, [date(2026, 7, 15)])
class LarkEnvironmentTests(unittest.TestCase):
def test_legacy_lark_config_is_used_when_default_is_missing(self):
with tempfile.TemporaryDirectory() as temp_dir:
home = Path(temp_dir)
legacy = home / ".lark-cli" / "hermes"
legacy.mkdir(parents=True)
(legacy / "config.json").write_text("{}", encoding="utf-8")
with patch.dict(os.environ, {}, clear=True), patch.object(Path, "home", return_value=home):
env = analysis.lark_cli_env()
self.assertEqual(env["LARKSUITE_CLI_CONFIG_DIR"], str(legacy))
def test_root_config_with_analyzer_profile_wins_over_legacy_config(self):
with tempfile.TemporaryDirectory() as temp_dir:
home = Path(temp_dir)
config_root = home / ".lark-cli"
legacy = config_root / "hermes"
legacy.mkdir(parents=True)
(config_root / "config.json").write_text(
json.dumps({"apps": [{"name": "hermes-analyzer"}]}),
encoding="utf-8",
)
(legacy / "config.json").write_text("{}", encoding="utf-8")
with patch.dict(os.environ, {}, clear=True), patch.object(Path, "home", return_value=home):
env = analysis.lark_cli_env()
self.assertEqual(env["LARKSUITE_CLI_CONFIG_DIR"], str(config_root))
def test_empty_root_config_falls_back_to_legacy_config(self):
with tempfile.TemporaryDirectory() as temp_dir:
home = Path(temp_dir)
config_root = home / ".lark-cli"
legacy = config_root / "hermes"
legacy.mkdir(parents=True)
(config_root / "config.json").write_text("{}", encoding="utf-8")
(legacy / "config.json").write_text("{}", encoding="utf-8")
env = analysis.lark_cli_env(base_env={}, home=home)
self.assertEqual(env["LARKSUITE_CLI_CONFIG_DIR"], str(legacy))
def test_explicit_config_directory_is_preserved(self):
with tempfile.TemporaryDirectory() as temp_dir:
home = Path(temp_dir)
explicit = home / "explicit-lark-config"
config_root = home / ".lark-cli"
config_root.mkdir(parents=True)
(config_root / "config.json").write_text(
json.dumps({"profiles": {"hermes-analyzer": {}}}),
encoding="utf-8",
)
env = analysis.lark_cli_env(
base_env={"LARKSUITE_CLI_CONFIG_DIR": str(explicit)},
home=home,
)
self.assertEqual(env["LARKSUITE_CLI_CONFIG_DIR"], str(explicit))
class FeishuDocumentWriteTests(unittest.TestCase):
def test_preflight_refreshes_and_requires_both_identities(self):
response = {
"verified": True,
"identities": {
"bot": {"available": True, "verified": True},
"user": {"available": True, "verified": True},
},
}
with patch.object(analysis, "run_lark_cli", return_value=response) as run:
analysis.ensure_lark_cli_ready()
run.assert_called_once_with(["auth", "status", "--json", "--verify"])
def test_preflight_rejects_missing_user_identity(self):
response = {
"verified": True,
"identities": {
"bot": {"available": True, "verified": True},
"user": {"available": False, "verified": False},
},
}
with patch.object(analysis, "run_lark_cli", return_value=response):
with self.assertRaisesRegex(RuntimeError, "user"):
analysis.ensure_lark_cli_ready()
def test_bot_created_document_is_granted_to_current_user(self):
responses = [
{"data": {"document": {
"url": "https://example.feishu.cn/docx/doc_token",
"document_id": "doc_token",
}}},
{"identities": {"user": {"openId": "ou_current_user"}}},
{"ok": True, "data": {"perm": "full_access"}},
]
with patch.object(analysis, "run_lark_cli", side_effect=responses) as run:
url, doc_id = analysis.write_feishu_doc("测试分析", "# 内容")
self.assertEqual(url, "https://example.feishu.cn/docx/doc_token")
self.assertEqual(doc_id, "doc_token")
create_args = run.call_args_list[0].args[0]
self.assertEqual(create_args[create_args.index("--as") + 1], "bot")
content_arg = create_args[create_args.index("--content") + 1]
self.assertFalse(Path(content_arg.removeprefix("@")).is_absolute())
self.assertEqual(
run.call_args_list[0].kwargs["cwd"],
analysis.report_cache_path("测试分析").parent,
)
grant_args = run.call_args_list[2].args[0]
self.assertIn("+member-add", grant_args)
self.assertIn("ou_current_user", grant_args)
self.assertIn("full_access", grant_args)
self.assertIn("--yes", grant_args)
def test_confirmed_automatic_permission_grant_skips_duplicate_member_add(self):
response = {
"data": {"document": {
"url": "https://example.feishu.cn/docx/doc_token",
"document_id": "doc_token",
}},
"permission_grant": {"status": "granted", "perm": "full_access"},
}
with patch.object(analysis, "run_lark_cli", return_value=response) as run:
url, doc_id = analysis.write_feishu_doc("测试分析", "# 内容")
self.assertEqual(url, "https://example.feishu.cn/docx/doc_token")
self.assertEqual(doc_id, "doc_token")
run.assert_called_once()
def test_missing_current_user_identity_stops_before_base_write(self):
responses = [
{"data": {"document": {
"url": "https://example.feishu.cn/docx/doc_token",
"document_id": "doc_token",
}}},
{"identities": {"user": {"ready": False}}},
]
with patch.object(analysis, "run_lark_cli", side_effect=responses):
with self.assertRaisesRegex(RuntimeError, "open_id"):
analysis.write_feishu_doc("测试分析", "# 内容")
class LocalReportRecoveryTests(unittest.TestCase):
def test_only_complete_exact_window_report_is_reused(self):
with tempfile.TemporaryDirectory() as tmp:
with patch.object(analysis, "CURATED_DATA_ROOT", Path(tmp)):
title = "测试款 单款式多维度分析 2026-08-01~2026-08-03"
report_lines: list[str] = []
for index, heading in enumerate(analysis.REPORT_HEADINGS):
report_lines.append(heading)
for subheading in analysis.REPORT_SUBHEADINGS[index * 3:(index + 1) * 3]:
report_lines.extend([subheading, "**重点**"])
report = "\n".join(report_lines)
cache = analysis.report_cache_path(title)
cache.parent.mkdir(parents=True, exist_ok=True)
cache.write_text(report, encoding="utf-8")
self.assertEqual(
analysis.load_validated_cached_report(title),
analysis.normalize_report_markdown(report),
)
cache.write_text("## 一、访客分析\n### 数据快照\n**重点**", encoding="utf-8")
self.assertIsNone(analysis.load_validated_cached_report(title))
class ExternalEffectCheckpointTests(unittest.TestCase):
@staticmethod
def checkpoint_spec(report: str = "# 内容"):
return analysis.StyleAnalysisCheckpointSpec.for_report(
"测试款",
date(2026, 8, 1),
date(2026, 8, 3),
3,
"测试款 单款式多维度分析 2026-08-01~2026-08-03",
report,
)
@staticmethod
def complete_report() -> str:
lines: list[str] = []
for index, heading in enumerate(analysis.REPORT_HEADINGS):
lines.append(heading)
for subheading in analysis.REPORT_SUBHEADINGS[index * 3:(index + 1) * 3]:
lines.extend([subheading, "**重点**"])
return analysis.normalize_report_markdown("\n".join(lines))
def test_permission_failure_resumes_same_created_document(self):
created = {
"data": {"document": {
"url": "https://example.feishu.cn/docx/doc_token",
"document_id": "doc_token",
}},
"permission_grant": {"status": "failed"},
}
auth = {"identities": {"user": {"openId": "ou_current_user"}}}
with tempfile.TemporaryDirectory() as tmp:
with patch.object(analysis, "CURATED_DATA_ROOT", Path(tmp)):
spec = self.checkpoint_spec()
with patch.object(
analysis,
"run_lark_cli",
side_effect=[created, auth, RuntimeError("grant failed")],
):
with self.assertRaisesRegex(RuntimeError, "grant failed"):
analysis.write_feishu_doc(spec.title, "# 内容", spec)
state = analysis.load_style_analysis_checkpoint(spec)
self.assertEqual(state["phase"], "doc_created")
self.assertEqual(state["document"]["id"], "doc_token")
with patch.object(
analysis,
"run_lark_cli",
side_effect=[auth, {"ok": True}],
) as resumed:
url, doc_id = analysis.write_feishu_doc(spec.title, "# 内容", spec)
self.assertEqual(url, "https://example.feishu.cn/docx/doc_token")
self.assertEqual(doc_id, "doc_token")
self.assertFalse(any(
call.args[0][:2] == ["docs", "+create"]
for call in resumed.call_args_list
))
self.assertEqual(
analysis.load_style_analysis_checkpoint(spec)["phase"],
"permission_granted",
)
def test_crash_after_automatic_grant_resumes_without_member_add(self):
with tempfile.TemporaryDirectory() as tmp:
with patch.object(analysis, "CURATED_DATA_ROOT", Path(tmp)):
spec = self.checkpoint_spec()
analysis.claim_style_analysis_checkpoint(spec)
analysis.advance_style_analysis_checkpoint(
spec,
"doc_created",
document={
"id": "doc_token",
"url": "https://example.feishu.cn/docx/doc_token",
},
automatic_permission_status="granted",
)
with patch.object(analysis, "run_lark_cli") as run:
url, doc_id = analysis.write_feishu_doc(spec.title, "# 内容", spec)
self.assertEqual(url, "https://example.feishu.cn/docx/doc_token")
self.assertEqual(doc_id, "doc_token")
run.assert_not_called()
self.assertEqual(
analysis.load_style_analysis_checkpoint(spec)["phase"],
"permission_granted",
)
def test_ambiguous_and_corrupt_checkpoints_fail_closed(self):
with tempfile.TemporaryDirectory() as tmp:
with patch.object(analysis, "CURATED_DATA_ROOT", Path(tmp)):
ambiguous = self.checkpoint_spec()
state, owns_creation = analysis.claim_style_analysis_checkpoint(ambiguous)
self.assertTrue(owns_creation)
self.assertEqual(state["phase"], "doc_creating")
with patch.object(analysis, "run_lark_cli") as run:
with self.assertRaisesRegex(
analysis.StyleAnalysisCheckpointError,
"doc_creating",
):
analysis.write_feishu_doc(ambiguous.title, "# 内容", ambiguous)
run.assert_not_called()
with patch.object(analysis, "CURATED_DATA_ROOT", Path(tmp) / "corrupt"):
corrupt = self.checkpoint_spec()
corrupt.path().parent.mkdir(parents=True, exist_ok=True)
corrupt.path().write_text("{", encoding="utf-8")
with patch.object(analysis, "run_lark_cli") as run:
with self.assertRaisesRegex(
analysis.StyleAnalysisCheckpointError,
"损坏",
):
analysis.write_feishu_doc(corrupt.title, "# 内容", corrupt)
run.assert_not_called()
def test_report_hash_mismatch_fails_closed(self):
with tempfile.TemporaryDirectory() as tmp:
with patch.object(analysis, "CURATED_DATA_ROOT", Path(tmp)):
original = self.checkpoint_spec("# 原报告")
analysis.claim_style_analysis_checkpoint(original)
changed = self.checkpoint_spec("# 新报告")
with patch.object(analysis, "run_lark_cli") as run:
with self.assertRaisesRegex(
analysis.StyleAnalysisCheckpointError,
"哈希不匹配",
):
analysis.write_feishu_doc(changed.title, "# 新报告", changed)
run.assert_not_called()
def test_base_and_postgres_phases_resume_without_recreating_document(self):
class Connection:
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
report = self.complete_report()
daily = [
{"date": "2026-08-01"},
{"date": "2026-08-02"},
{"date": "2026-08-03"},
]
created = {
"data": {"document": {
"url": "https://example.feishu.cn/docx/doc_token",
"document_id": "doc_token",
}},
"permission_grant": {"status": "granted", "perm": "full_access"},
}
target = {"base_token": "base", "table_id": "table"}
with tempfile.TemporaryDirectory() as tmp:
with (
patch.object(analysis, "CURATED_DATA_ROOT", Path(tmp)),
patch.object(analysis, "pull_daily", return_value=daily),
patch.object(analysis, "aggregate_period", return_value={"tm": {"sales": 1}}),
patch.object(analysis, "aggregate_style_total", return_value={"sales": 1}),
patch.object(analysis, "pull_persona", return_value=[]),
patch.object(analysis, "pull_reviews", return_value={}),
patch.object(analysis, "pull_main_image", return_value=[]),
patch.object(analysis, "pull_seeding", return_value={}),
patch.object(analysis, "build_user_prompt", return_value="prompt"),
patch.object(analysis, "run_lark_cli", return_value=created) as run,
patch.object(
analysis,
"write_analysis_link_to_base",
return_value="rec_1",
) as write_base,
patch.object(analysis, "get_conn", return_value=Connection()),
patch.object(
analysis,
"upsert_style_analysis_report",
side_effect=[RuntimeError("pg unavailable"), 42],
) as write_pg,
):
title = "测试款 单款式多维度分析 2026-08-01~2026-08-03"
cache = analysis.report_cache_path(title)
cache.parent.mkdir(parents=True, exist_ok=True)
cache.write_text(report, encoding="utf-8")
spec = self.checkpoint_spec(report)
with self.assertRaisesRegex(RuntimeError, "pg unavailable"):
analysis.analyze_one_style(
"测试款",
date(2026, 8, 1),
date(2026, 8, 3),
3,
False,
target,
)
base_state = analysis.load_style_analysis_checkpoint(spec)
self.assertEqual(base_state["phase"], "base_written")
self.assertEqual(base_state["base_record_id"], "rec_1")
ok, _ = analysis.analyze_one_style(
"测试款",
date(2026, 8, 1),
date(2026, 8, 3),
3,
False,
target,
)
self.assertTrue(ok)
self.assertEqual(run.call_count, 1)
write_base.assert_called_once()
self.assertEqual(write_pg.call_count, 2)
final_state = analysis.load_style_analysis_checkpoint(spec)
self.assertEqual(final_state["phase"], "pg_written")
self.assertEqual(final_state["pg_row_id"], 42)
class TargetBaseWriteTests(unittest.TestCase):
def test_existing_period_record_is_updated(self):
responses = [
{"data": {"fields": [
{"name": "时间", "type": "select"},
{"name": "笔记分析汇总", "type": "text"},
]}},
{"data": {"field": {
"id": "fld_time", "name": "时间", "type": "select",
"multiple": False, "options": [{"name": "2026-07-13~07-15"}],
}}},
{"data": {
"data": [[['2026-07-13~07-15'], "old link"]],
"fields": ["时间", "笔记分析汇总"],
"record_id_list": ["rec_existing"],
}},
{"ok": True, "data": {"record_id": "rec_existing"}},
]
with patch.object(analysis, "run_lark_cli", side_effect=responses) as run:
record_id = analysis.write_analysis_link_to_base(
{"base_token": "base", "table_id": "table"},
"2026-07-13~07-15",
"https://example.feishu.cn/docx/doc",
"盖亚斜挎",
)
self.assertEqual(record_id, "rec_existing")
upsert_args = run.call_args_list[3].args[0]
self.assertIn("--record-id", upsert_args)
self.assertIn("rec_existing", upsert_args)
payload = json.loads(upsert_args[upsert_args.index("--json") + 1])
self.assertIn("笔记分析汇总", payload)
def test_missing_period_is_appended_without_dropping_existing_options(self):
responses = [
{"data": {"field": {
"id": "fld_time", "name": "时间", "type": "select",
"multiple": False, "options": [{"name": "7月第二周", "hue": "Blue"}],
}}},
{"ok": True},
]
with patch.object(analysis, "run_lark_cli", side_effect=responses) as run:
analysis.ensure_select_option(
{"base_token": "base", "table_id": "table"},
"时间", "2026-07-13~07-15", "盖亚斜挎",
)
update_args = run.call_args_list[1].args[0]
definition = json.loads(update_args[update_args.index("--json") + 1])
self.assertEqual(
[option["name"] for option in definition["options"]],
["7月第二周", "2026-07-13~07-15"],
)
self.assertIn("--yes", update_args)
class OrchestratorTests(unittest.TestCase):
def test_style_analysis_stage_runs_three_day_batch(self):
args = SimpleNamespace(dry_run=False)
with patch.object(orchestrator, "run_step_with_retry") as run:
orchestrator.run_style_analysis(args, "2026-07-15", failed=None)
command = run.call_args.args[1]
self.assertIn("analyze_style_with_hermes.py", [Path(item).name for item in command])
self.assertIn("--all-styles", command)
self.assertEqual(command[command.index("--days") + 1], "3")
self.assertEqual(command[command.index("--min-interval-days") + 1], "3")
self.assertFalse(run.call_args.kwargs["critical"])
class LauncherScheduleTests(unittest.TestCase):
def test_daily_launcher_does_not_run_style_analysis(self):
launcher = (
HISTORY_ROOT / "launchers_reference" / "run_daily_collect.bat"
).read_text(encoding="utf-8")
command_lines = [line for line in launcher.splitlines() if "orchestrate_daily_collection.py" in line]
self.assertEqual(len(command_lines), 1)
self.assertNotIn("style_analysis", command_lines[0])
def test_three_day_launcher_runs_only_style_analysis(self):
launcher = (
HISTORY_ROOT / "launchers_reference" / "run_style_analysis_3d.bat"
).read_text(encoding="utf-8")
self.assertIn("analyze_style_with_hermes.py", launcher)
self.assertIn("--all-styles", launcher)
self.assertIn("--days 3", launcher)
self.assertIn("--skip-existing", launcher)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,63 @@
from __future__ import annotations
import io
from contextlib import redirect_stdout
from config.style_config_loader import StyleConfigLoader
def test_erp_codes_use_tm_record_even_when_jd_record_comes_first() -> None:
loader = StyleConfigLoader()
output = io.StringIO()
with redirect_stdout(output):
payload = loader._aggregate(
[
{"platform": "京东旗舰店", "style": "极星托特", "erp_codes": "10513001"},
{"platform": "天猫", "style": "极星托特", "erp_codes": "10513"},
]
)
assert payload["styles"]["极星托特"]["erp_codes"] == ["10513"]
assert "ERP 编码冲突" in output.getvalue()
assert "10513001" in output.getvalue()
def test_non_tm_erp_code_is_not_used_when_tm_code_is_missing() -> None:
loader = StyleConfigLoader()
output = io.StringIO()
with redirect_stdout(output):
payload = loader._aggregate(
[
{"platform": "京东旗舰店", "style": "极星托特", "erp_codes": "10513001"},
{"platform": "天猫", "style": "极星托特", "erp_codes": ""},
]
)
assert payload["styles"]["极星托特"]["erp_codes"] == []
assert "天猫分组未填写" in output.getvalue()
def test_exact_style_field_wins_over_style_content_prefix(monkeypatch) -> None:
loader = StyleConfigLoader()
monkeypatch.setattr(
loader,
"_run_lark",
lambda _args: {
"data": {
"fields": ["款式内容", "款式", "ERP款式编码", "平台"],
"data": [["营销文案", "盖世m1", "10416,10455", ["天猫"]]],
}
},
)
records = loader._load_records_from_lark()
assert records == [
{
"style": "盖世m1",
"erp_codes": "10416,10455",
"platform": "天猫",
}
]
@@ -0,0 +1,564 @@
import inspect
import unittest
from unittest.mock import MagicMock, patch
from collect_sycm_market_rank import (
TARGET_MARKET_CATEGORIES,
_feishu_append_xml_path,
_original_taobao_image_url,
_product_item_id,
_risk_control_reason,
build_feishu_category_xml,
build_feishu_doc_xml,
close_market_rank_browser,
collect_all_rank_pages,
create_feishu_doc,
detect_risk_control_text,
ensure_market_rank_login,
group_products_by_category,
inspect_feishu_batch_tables,
inspect_feishu_category_tables,
parse_market_rank_html,
parse_range_upper,
prepare_market_rank_filters,
prepare_market_rank_period,
)
SAMPLE_HTML = """
<table>
<thead>
<tr>
<th>排名</th><th>商品</th><th>商品关键词</th>
<th>店铺</th><th>支付买家数</th><th>访客数</th>
</tr>
</thead>
<tbody>
<tr>
<td>1</td>
<td>
<a href="//detail.tmall.com/item.htm?id=123">
<img data-src="//img.alicdn.com/bao/uploaded/demo.jpg"/>
【新品】CECE大容量旅行箱
</a>
</td>
<td>旅行</td><td>cece旗舰店</td><td>2500 ~ 5000</td><td>10万 ~ 25万</td>
</tr>
</tbody>
</table>
"""
class SycmMarketRankTests(unittest.TestCase):
def test_expired_login_without_credentials_fails_clearly(self):
with (
patch("collect_sycm_market_rank.sycm.ACCOUNT", ""),
patch("collect_sycm_market_rank.sycm.PASSWORD", ""),
patch(
"collect_sycm_market_rank.sycm.ensure_logged_in",
return_value=None,
),
self.assertRaisesRegex(
RuntimeError,
"登录态失效.*SYCM_ACCOUNT/SYCM_PASSWORD",
),
):
ensure_market_rank_login(MagicMock(), MagicMock())
def test_expired_login_does_not_swallow_login_exception(self):
with (
patch("collect_sycm_market_rank.sycm.ACCOUNT", "configured"),
patch("collect_sycm_market_rank.sycm.PASSWORD", "configured"),
patch(
"collect_sycm_market_rank.sycm.ensure_logged_in",
side_effect=RuntimeError("login rejected"),
),
self.assertRaisesRegex(RuntimeError, "自动登录失败: RuntimeError"),
):
ensure_market_rank_login(MagicMock(), MagicMock())
def test_parallel_documents_use_isolated_append_xml_files(self):
first = _feishu_append_xml_path("https://example.feishu.cn/docx/first", 1)
second = _feishu_append_xml_path("https://example.feishu.cn/docx/second", 1)
self.assertNotEqual(first, second)
self.assertEqual(first.parent, second.parent)
def test_title_with_question_mark_is_rejected_before_creating_document(self):
with self.assertRaisesRegex(ValueError, "编码损坏"):
create_feishu_doc("??九品类市场排行", "<p>test</p>")
def test_category_groups_are_one_table_each(self):
products = [
{"category_name": "旅行箱", "rank": 1, "name": "A"},
{"category_name": "双肩包", "rank": 1, "name": "B"},
{"category_name": "旅行箱", "rank": 2, "name": "C"},
]
groups = group_products_by_category(products)
self.assertEqual([name for name, _ in groups], ["旅行箱", "双肩包"])
self.assertEqual([len(items) for _, items in groups], [2, 1])
category_xml = build_feishu_category_xml(*groups[0])
self.assertEqual(category_xml.count("<table>"), 1)
self.assertEqual(category_xml.count("<th "), 7)
self.assertNotIn(">品类</th>", category_xml)
def test_category_table_inspection_keeps_heading_and_row_count(self):
content = """
<h2>旅行箱(2条)</h2><table id="t1"><tbody>
<tr><td><img src="1"/></td></tr><tr><td><img src="2"/></td></tr>
</tbody></table>
<h2>双肩包(1条)</h2><table id="t2"><tbody><tr><td>缺图</td></tr></tbody></table>
"""
self.assertEqual(
inspect_feishu_category_tables(content),
[
{"block_id": "t1", "category": "旅行箱", "declared_rows": 2, "rows": 2, "images": 2},
{"block_id": "t2", "category": "双肩包", "declared_rows": 1, "rows": 1, "images": 0},
],
)
def test_detect_risk_control_text_matches_actual_sycm_popup(self):
popup_text = """
请稍等片刻再点击刷新哦
操作太频繁了,请稍后再试
反馈码:a777b03e7183a9564215ff465acad489
"""
self.assertEqual(
detect_risk_control_text(popup_text),
"操作太频繁了,请稍后再试",
)
def test_detect_risk_control_text_ignores_normal_rank_page(self):
self.assertEqual(
detect_risk_control_text("市场排行 支付买家数 访客数 下一页"),
"",
)
def test_detect_risk_control_text_matches_baxia_mask(self):
self.assertEqual(
detect_risk_control_text('<div class="baxia-dialog-mask"></div>'),
"生意参谋 Baxia 风控遮罩",
)
def test_hidden_baxia_dialog_is_not_treated_as_active_captcha(self):
page = MagicMock()
dialog = MagicMock()
dialog.count.return_value = 1
dialog.nth.return_value.is_visible.return_value = False
body = MagicMock()
body.inner_text.return_value = "市场排行 支付买家数"
page.locator.side_effect = lambda selector: (
dialog if selector == ".baxia-dialog" else body
)
page.content.return_value = (
'<div style="display:none" class="baxia-dialog">X</div>'
)
self.assertEqual(_risk_control_reason(page), "")
def test_owned_tmall_browser_has_profile_scoped_fallback_cleanup(self):
session = MagicMock()
with (
patch("collect_sycm_market_rank.sys.platform", "win32"),
patch("collect_sycm_market_rank.time.sleep"),
patch("collect_sycm_market_rank.subprocess.run") as run,
):
close_market_rank_browser(
session,
user_data_dir="portable-tm-profile",
cdp_url=None,
)
session.close.assert_called_once_with()
run.assert_called_once()
self.assertEqual(
run.call_args.kwargs["env"]["TM_PROFILE_TO_CLOSE"],
"portable-tm-profile",
)
def test_cdp_browser_is_not_force_closed(self):
session = MagicMock()
with patch("collect_sycm_market_rank.subprocess.run") as run:
close_market_rank_browser(
session,
user_data_dir="portable-tm-profile",
cdp_url="http://127.0.0.1:9222",
)
session.close.assert_called_once_with()
run.assert_not_called()
def test_prepare_market_period_waits_in_same_session_before_clicking_period(self):
context = MagicMock()
original_page = object()
with (
patch(
"collect_sycm_market_rank._risk_control_reason",
side_effect=["生意参谋 Baxia 风控遮罩", ""],
),
patch(
"collect_sycm_market_rank.wait_for_manual_risk_clearance"
) as wait_for_clearance,
patch("collect_sycm_market_rank.select_market_period") as select_period,
):
result = prepare_market_rank_period(
context,
original_page,
period="30天",
risk_mode="manual",
cooldown_seconds=0,
max_retries=2,
)
self.assertIs(result, original_page)
context.clear_cookies.assert_not_called()
wait_for_clearance.assert_called_once_with(
original_page,
"生意参谋 Baxia 风控遮罩",
)
select_period.assert_called_once_with(original_page, "30天")
def test_prepare_market_filters_retries_same_step_after_manual_captcha(self):
page = object()
with (
patch(
"collect_sycm_market_rank.select_market_period",
) as select_period,
patch(
"collect_sycm_market_rank.select_market_category_path",
side_effect=[RuntimeError("mask intercepts pointer events"), None],
) as select_category,
patch(
"collect_sycm_market_rank._risk_control_reason",
side_effect=["", "生意参谋 Baxia 风控遮罩", ""],
),
patch(
"collect_sycm_market_rank.wait_for_manual_risk_clearance"
) as wait_for_clearance,
patch("collect_sycm_market_rank.apply_custom_price") as apply_price,
):
result = prepare_market_rank_filters(
page,
period="30天",
category_path=("箱包皮具/热销女包/男包", "旅行箱"),
min_price=400,
max_price=2000,
risk_mode="manual",
cooldown_seconds=0,
max_retries=2,
)
self.assertIs(result, page)
self.assertEqual(select_period.call_count, 2)
self.assertEqual(select_category.call_count, 2)
apply_price.assert_called_once_with(page, 400, 2000)
wait_for_clearance.assert_called_once_with(
page,
"生意参谋 Baxia 风控遮罩",
)
def test_manual_captcha_reapplies_filters_before_parsing_current_page(self):
page = MagicMock()
page.content.side_effect = [
'<div class="baxia-dialog">captcha</div>',
SAMPLE_HTML,
]
recover = MagicMock(return_value=page)
with (
patch(
"collect_sycm_market_rank._risk_control_reason",
side_effect=["生意参谋 Baxia 风控遮罩", ""],
),
patch(
"collect_sycm_market_rank.wait_for_manual_risk_clearance"
) as wait_for_clearance,
patch("collect_sycm_market_rank._save_checkpoint"),
):
rows = collect_all_rank_pages(
page,
max_pages=1,
category_name="旅行箱",
category_path="箱包 > 旅行箱",
recover_from_risk=recover,
manual_risk_wait=True,
)
self.assertEqual(len(rows), 1)
wait_for_clearance.assert_called_once_with(
page,
"生意参谋 Baxia 风控遮罩",
)
recover.assert_called_once_with(
page,
1,
1,
"生意参谋 Baxia 风控遮罩",
)
def test_target_market_categories_contains_the_nine_requested_paths(self):
self.assertEqual(len(TARGET_MARKET_CATEGORIES), 9)
self.assertEqual(
[
(
item["name"],
item["min_price"],
item["max_price"],
tuple(tuple(path) for path in item["paths"]),
)
for item in TARGET_MARKET_CATEGORIES
],
[
(
"旅行箱",
400,
2000,
(("箱包皮具/热销女包/男包", "旅行箱"),),
),
(
"运动包",
200,
1000,
(("箱包皮具/热销女包/男包", "旅行袋"),),
),
(
"托特包",
200,
3000,
(("箱包皮具/热销女包/男包", "女士包袋新", "托特包"),),
),
(
"双肩包",
200,
2000,
(("箱包皮具/热销女包/男包", "双肩背包"),),
),
(
"斜挎包",
200,
2000,
(
("箱包皮具/热销女包/男包", "男士包袋"),
(
"箱包皮具/热销女包/男包",
"女士包袋新",
"通用款女包",
),
),
),
(
"胸包",
200,
1000,
(("箱包皮具/热销女包/男包", "胸包"),),
),
(
"手机包",
100,
1000,
(("箱包皮具/热销女包/男包", "功能小包", "手机包"),),
),
(
"电脑包",
200,
1000,
(("3C数码配件", "笔记本电脑配件", "笔记本电脑包"),),
),
(
"相机包",
200,
2000,
(("3C数码配件", "数码相机配件", "数码相机包"),),
),
],
)
def test_original_taobao_image_url_removes_rank_thumbnail_suffix(self):
thumbnail = (
"https://img.alicdn.com/bao/uploaded/demo-item_pic.jpg_36x36.jpg"
)
self.assertEqual(
_original_taobao_image_url(thumbnail),
"https://img.alicdn.com/bao/uploaded/demo-item_pic.jpg",
)
def test_product_identity_ignores_changing_mi_id(self):
first = _product_item_id(
"https://detail.tmall.com/item.htm?id=123&mi_id=first"
)
second = _product_item_id(
"https://detail.tmall.com/item.htm?mi_id=second&id=123"
)
self.assertEqual(first, "123")
self.assertEqual(first, second)
def test_parse_range_upper_supports_plain_and_wan_units(self):
self.assertEqual(parse_range_upper("2500 ~ 5000"), 5000)
self.assertEqual(parse_range_upper("7.5万~10万"), 100000)
self.assertEqual(parse_range_upper("500"), 500)
def test_parse_market_rank_html_extracts_name_image_link_and_buyer_max(self):
rows = parse_market_rank_html(SAMPLE_HTML)
self.assertEqual(len(rows), 1)
self.assertEqual(rows[0]["rank"], 1)
self.assertEqual(rows[0]["name"], "【新品】CECE大容量旅行箱")
self.assertEqual(rows[0]["buyer_range"], "2500 ~ 5000")
self.assertEqual(rows[0]["buyer_max"], 5000)
self.assertEqual(
rows[0]["image_url"],
"https://img.alicdn.com/bao/uploaded/demo.jpg",
)
self.assertEqual(
rows[0]["product_url"],
"https://detail.tmall.com/item.htm?id=123",
)
def test_parse_market_rank_html_skips_image_only_link_for_product_name(self):
source = """
<table>
<thead><tr>
<th>排名</th><th>商品</th><th>支付买家数</th>
</tr></thead>
<tbody><tr>
<td>9 降1名</td>
<td>
<a href="//detail.tmall.com/item.htm?id=639">
<img src="//img.alicdn.com/demo.jpg"/>
</a>
<p class="goodsName">
<a title="MOFT电脑支架收纳包"
href="//detail.tmall.com/item.htm?id=639">MOFT电脑支架收纳包</a>
</p>
</td>
<td>50 ~ 100</td>
</tr></tbody>
</table>
"""
rows = parse_market_rank_html(source)
self.assertEqual(len(rows), 1)
self.assertEqual(rows[0]["name"], "MOFT电脑支架收纳包")
self.assertEqual(rows[0]["rank"], 9)
self.assertEqual(rows[0]["buyer_max"], 100)
def test_rank_page_collector_has_no_detail_page_or_price_interface(self):
parameters = inspect.signature(collect_all_rank_pages).parameters
source = inspect.getsource(collect_all_rank_pages)
self.assertNotIn("context", parameters)
self.assertNotIn("collect_prices", parameters)
self.assertNotIn("price_cache", parameters)
self.assertNotIn("_collect_price_from_rank_link", source)
self.assertNotIn("expect_page", source)
def test_inspect_feishu_batch_tables_reports_start_rows_and_images(self):
content = """
<h2 id="heading-1">榜单记录 12</h2>
<table id="table-1">
<tbody>
<tr><td><img src="img-1"/></td></tr>
<tr><td>缺图</td></tr>
</tbody>
</table>
<h2 id="heading-2">榜单记录 33</h2>
<table id="table-2">
<tbody><tr><td><img src="img-2"/></td></tr></tbody>
</table>
"""
self.assertEqual(
inspect_feishu_batch_tables(content),
[
{
"block_id": "table-1",
"start_rank": 1,
"rows": 2,
"images": 1,
},
{
"block_id": "table-2",
"start_rank": 3,
"rows": 1,
"images": 1,
},
],
)
def test_build_doc_contains_image_metrics_and_link_without_product_price(self):
xml = build_feishu_doc_xml(
[
{
"rank": 1,
"category_name": "旅行箱",
"name": "CECE旅行箱",
"buyer_range": "2500 ~ 5000",
"buyer_max": 5000,
"image_url": "https://img.alicdn.com/demo.jpg",
"product_url": "https://detail.tmall.com/item.htm?id=123",
}
],
min_price=400,
max_price=2000,
title="市场排行测试",
)
self.assertIn("<title>市场排行测试</title>", xml)
self.assertIn('img href="https://img.alicdn.com/demo.jpg"', xml)
self.assertIn("5000", xml)
self.assertIn("https://detail.tmall.com/item.htm?id=123", xml)
self.assertIn("商品链接", xml)
self.assertNotIn("商品价格", xml)
self.assertNotIn("¥418", xml)
self.assertIn("风格(运营手动区分)", xml)
self.assertIn("场景用途(运营手动区分)", xml)
self.assertIn(">品类</th>", xml)
self.assertIn("<tr><td></td><td></td><td>旅行箱</td>", xml)
self.assertIn(
'<a href="https://detail.tmall.com/item.htm?id=123">打开商品</a>',
xml,
)
self.assertNotIn(
'<a href="https://detail.tmall.com/item.htm?id=123">CECE旅行箱</a>',
xml,
)
self.assertEqual(
xml.count('href="https://detail.tmall.com/item.htm?id=123"'),
1,
)
self.assertIn('width="160"', xml)
self.assertIn('height="160"', xml)
self.assertIn('<col width="180"/>', xml)
def test_build_doc_uses_original_image_instead_of_thumbnail(self):
xml = build_feishu_doc_xml(
[
{
"rank": 1,
"name": "CECE旅行箱",
"buyer_range": "2500 ~ 5000",
"buyer_max": 5000,
"image_url": (
"https://img.alicdn.com/demo.jpg_36x36.jpg"
),
"product_url": "https://detail.tmall.com/item.htm?id=123",
}
],
min_price=400,
max_price=2000,
title="大图测试",
)
self.assertIn('href="https://img.alicdn.com/demo.jpg"', xml)
self.assertNotIn("_36x36.jpg", xml)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,343 @@
from __future__ import annotations
from datetime import date
import pytest
from gyxx_flow.modules.product_commerce import (
collect_erp_monthly_metrics as erp_monthly,
)
from gyxx_flow.modules.product_commerce.collect_erp_monthly_metrics import (
monthly_report_interval,
set_monthly_filters,
)
from gyxx_flow.modules.product_commerce.sync_monthly_sales_sheet import (
ErpStylePlan,
MonthlyMetric,
MonthlySalesConfig,
ProductTarget,
SheetInfo,
_write_rows,
build_parser,
choose_template,
fetch_product_targets,
match_metrics,
merge_product_catalog,
month_bounds,
month_option,
month_sheet_title,
normalized_name,
parse_month,
plan_erp_styles,
product_text,
require_complete_erp_mappings,
)
def test_month_contract_uses_natural_month_and_existing_title_convention() -> None:
month = parse_month("2026-08-19")
assert month == date(2026, 8, 1)
assert month_bounds(month) == (date(2026, 8, 1), date(2026, 8, 31))
assert month_option(month) == "2026.08"
assert month_sheet_title(month) == "26年8月"
def test_erp_month_interval_uses_inclusive_natural_month_for_day_input() -> None:
assert monthly_report_interval(parse_month("2026-07-02")) == (
"2026-07-01",
"2026-07-31",
)
assert monthly_report_interval(parse_month("2026-12-31")) == (
"2026-12-01",
"2026-12-31",
)
assert monthly_report_interval(parse_month("2024-02-15")) == (
"2024-02-01",
"2024-02-29",
)
def test_selected_month_is_the_only_month_argument() -> None:
arguments = build_parser().parse_args(["--month", "2026-07"])
assert parse_month(arguments.month) == date(2026, 7, 1)
with pytest.raises(SystemExit):
build_parser().parse_args(
["--month", "2026-07", "--budget-month", "2026-08"]
)
def test_missing_erp_zero_override_is_explicit() -> None:
default = build_parser().parse_args(["--month", "2026-07"])
override = build_parser().parse_args(
["--month", "2026-07", "--allow-missing-erp-as-zero"]
)
assert default.allow_missing_erp_as_zero is False
assert override.allow_missing_erp_as_zero is True
def test_product_targets_are_read_without_a_month_filter() -> None:
config = MonthlySalesConfig(
spreadsheet_url="https://example.test/sheets/token",
base_url="https://example.test/base/token",
base_token="base",
table_id="table",
view_id="view",
product_field="品名",
target_field="目标销量",
month_field="月份",
product_aliases={},
)
calls: list[list[str]] = []
def lark(args: list[str]) -> dict:
calls.append(args)
return {
"data": {
"fields": ["品名", "目标销量"],
"data": [["布谷", 500], ["白鹭", 350], ["蓝鹊", 400]],
"has_more": False,
}
}
result = fetch_product_targets(
config,
lark=lark,
)
assert result == [
ProductTarget("布谷", 500),
ProductTarget("白鹭", 350),
ProductTarget("蓝鹊", 400),
]
assert "--filter-json" not in calls[0]
def test_full_catalog_unions_style_base_and_targets_and_sums_duplicates() -> None:
targets = [
ProductTarget("布谷", 500),
ProductTarget("星迹&星迹2", 1000),
ProductTarget("星迹2", 500),
ProductTarget("预算孤儿", 200),
]
styles = {
"布谷": {"erp_codes": ["10352", "10496"]},
"星迹2": {"erp_codes": ["10504"]},
"白鹭": {"erp_codes": ["10310"]},
"蓝鹊": {"erp_codes": ["10279"]},
}
result = merge_product_catalog(
targets,
styles,
{"星迹&星迹2": "星迹2"},
)
assert result == [
ProductTarget("布谷", 500),
ProductTarget("星迹&星迹2", 1500),
ProductTarget("预算孤儿", 200),
ProductTarget("白鹭", 0),
ProductTarget("蓝鹊", 0),
]
def test_product_text_turns_base_markdown_links_into_plain_product_names() -> None:
assert (
product_text(
"[盖亚微单Pro生命进程(新)](https://example.feishu.cn/base/token)"
)
== "盖亚微单Pro"
)
assert product_text("阿波罗X1生命进程(新)") == "阿波罗X1"
assert product_text("拾影相机包(新) 副本") == "拾影相机包"
def test_metric_matching_uses_explicit_aliases_and_casefolded_exact_names() -> None:
targets = [
ProductTarget("盖世M1", 300),
ProductTarget("星迹&星迹2", 1500),
ProductTarget("尚未入库", 200),
]
metrics = {
"盖世m1": MonthlyMetric("盖世m1", 271, 36, 12),
"星迹2": MonthlyMetric("星迹2", 1858, 451, 12),
}
rows, unmatched = match_metrics(
targets,
metrics,
{"星迹&星迹2": "星迹2"},
)
assert normalized_name("星迹&星迹2") == normalized_name("星迹+星迹2")
assert rows[0]["sales"] == 271
assert rows[1]["returns"] == 451
assert rows[2]["sales"] == 0
assert unmatched == ["尚未入库"]
def test_template_is_latest_strictly_earlier_year_month_sheet() -> None:
sheets = [
SheetInfo("a", "26年6月", 0, 198),
SheetInfo("b", "26年7月", 1, 198),
SheetInfo("c", "7月销量", 2, 200),
]
assert choose_template(sheets, date(2026, 8, 1)).sheet_id == "b"
def test_erp_style_plan_uses_style_base_codes_and_aliases() -> None:
targets = [
ProductTarget("盖世M1", 300),
ProductTarget("星迹&星迹2", 1500),
ProductTarget("云栖相机双肩包", 200),
]
styles = {
"盖世m1": {"erp_codes": ["10416", "10455", "10416"]},
"星迹2": {"erp_codes": ["10504", "10394"]},
"云栖": {"erp_codes": []},
}
plans, missing = plan_erp_styles(
targets,
styles,
{
"星迹&星迹2": "星迹2",
"云栖相机双肩包": "云栖",
},
)
assert plans == [
ErpStylePlan("盖世m1", ("10416", "10455")),
ErpStylePlan("星迹2", ("10504", "10394")),
]
assert missing == ["云栖相机双肩包"]
def test_execute_guard_rejects_incomplete_erp_mapping_before_sheet_write() -> None:
with pytest.raises(RuntimeError, match="目标表未清空"):
require_complete_erp_mappings(["云栖相机双肩包", "轻风双肩包"])
require_complete_erp_mappings([])
def test_monthly_filter_sets_natural_month_exact_shop_labels_and_erp_code() -> None:
class Frame:
def __init__(self) -> None:
self.script = ""
self.args = {}
def evaluate(self, script, args):
self.script = script
self.args = args
return {"selected_shop_ids": ["shop_1"], "selected_shop_labels": ["甲店"]}
frame = Frame()
result = set_monthly_filters(
frame,
erp_code="10439",
start_date="2026-07-01",
end_date="2026-07-31",
shop_labels=["甲店"],
)
assert frame.args == {
"erpCode": "10439",
"startDate": "2026-07-01",
"endDate": "2026-07-31",
"shopLabels": ["甲店"],
}
assert 'input[name="shop_id"]' in frame.script
assert "#i_id" in frame.script
assert result["selected_shop_labels"] == ["甲店"]
def test_monthly_collector_sums_all_erp_codes_for_one_style(monkeypatch) -> None:
monkeypatch.setattr(erp_monthly.erp, "find_report_frames", lambda _page: (object(), object()))
observed_ranges: list[tuple[str, str, str]] = []
def run_one_code(_page, _frame, code, start_date, end_date, *_args):
observed_ranges.append((code, start_date, end_date))
return {
"ok": True,
"yesterday_sales": {"10416": 1185, "10455": 904}[code],
"yesterday_returns": {"10416": 279, "10455": 159}[code],
}
monkeypatch.setattr(
erp_monthly,
"_run_one_code",
run_one_code,
)
result = erp_monthly.collect_monthly_in_page(
object(),
[{"style_name": "盖世m1", "erp_style_codes": ["10416", "10455"]}],
date(2026, 7, 1),
shop_labels=["甲店"],
)
assert result["盖世m1"].sales == 2089
assert result["盖世m1"].returns == 438
assert observed_ranges == [
("10416", "2026-07-01", "2026-07-31"),
("10455", "2026-07-01", "2026-07-31"),
]
def test_monthly_collector_rejects_partial_erp_code_failure(monkeypatch) -> None:
monkeypatch.setattr(erp_monthly.erp, "find_report_frames", lambda _page: (object(), object()))
monkeypatch.setattr(
erp_monthly,
"_run_one_code",
lambda _page, _frame, code, *_args: {
"ok": code == "10416",
"yesterday_sales": 10 if code == "10416" else 0,
"yesterday_returns": 1 if code == "10416" else 0,
"error": "timeout" if code != "10416" else "",
},
)
with pytest.raises(RuntimeError, match="10455"):
erp_monthly.collect_monthly_in_page(
object(),
[{"style_name": "盖世m1", "erp_style_codes": ["10416", "10455"]}],
date(2026, 7, 1),
shop_labels=["甲店"],
)
def test_write_rows_repeats_first_data_row_format_without_overwriting_values() -> None:
calls: list[list[str]] = []
config = MonthlySalesConfig(
spreadsheet_url="https://example.test/sheets/token",
base_url="https://example.test/base/token",
base_token="base",
table_id="table",
view_id="view",
product_field="品名",
target_field="目标销量",
month_field="月份",
product_aliases={},
)
rows = [
{"product": "", "target_sales": 10, "sales": 8, "returns": 1},
{"product": "", "target_sales": 20, "sales": 9, "returns": 2},
]
_write_rows(
config,
SheetInfo("sheet", "26年7月", 0, 198),
rows,
lark=lambda args: calls.append(args) or {},
)
format_call = next(args for args in calls if "+range-copy" in args)
assert format_call[format_call.index("--source-range") + 1] == "A2:G2"
assert format_call[format_call.index("--target-range") + 1] == "A3:G3"
assert format_call[format_call.index("--paste-type") + 1] == "formats"
@@ -0,0 +1,78 @@
from __future__ import annotations
from pathlib import Path
import taobao_sycm_products as tm
class _Session:
def __init__(self) -> None:
self.context = object()
self.closed = False
def close(self) -> None:
self.closed = True
def test_tm_main_fails_when_browser_connection_is_missing(monkeypatch) -> None:
monkeypatch.setattr(tm, "connect_browser", lambda **_kwargs: (None, None))
assert tm.main(["--target-date", "2026-08-03"]) == 1
def test_tm_main_fails_when_login_does_not_complete(monkeypatch) -> None:
session = _Session()
page = object()
monkeypatch.setattr(tm, "connect_browser", lambda **_kwargs: (session, page))
monkeypatch.setattr(tm, "ensure_logged_in", lambda *_args: None)
monkeypatch.setattr(tm, "_kill_port_listeners", lambda _port: 0)
assert tm.main(["--target-date", "2026-08-03"]) == 1
assert session.closed is True
def test_tm_main_fails_when_target_date_cannot_be_selected(monkeypatch) -> None:
session = _Session()
page = object()
monkeypatch.setattr(tm, "connect_browser", lambda **_kwargs: (session, page))
monkeypatch.setattr(tm, "ensure_logged_in", lambda *_args: page)
monkeypatch.setattr(tm, "navigate_to_products", lambda _page: None)
monkeypatch.setattr(tm, "select_yesterday", lambda _page: False)
monkeypatch.setattr(tm, "_kill_port_listeners", lambda _port: 0)
assert tm.main(["--target-date", "2026-08-03"]) == 1
assert session.closed is True
def test_tm_main_fails_when_download_is_missing(monkeypatch) -> None:
session = _Session()
page = object()
monkeypatch.setattr(tm, "connect_browser", lambda **_kwargs: (session, page))
monkeypatch.setattr(tm, "ensure_logged_in", lambda *_args: page)
monkeypatch.setattr(tm, "navigate_to_products", lambda _page: None)
monkeypatch.setattr(tm, "select_yesterday", lambda _page: True)
monkeypatch.setattr(tm, "click_download", lambda _page: None)
monkeypatch.setattr(tm, "_kill_port_listeners", lambda _port: 0)
assert tm.main(["--target-date", "2026-08-03"]) == 1
assert session.closed is True
def test_tm_main_fails_when_download_has_no_valid_style(
monkeypatch,
tmp_path: Path,
) -> None:
session = _Session()
page = object()
report = tmp_path / "report.xlsx"
monkeypatch.setattr(tm, "connect_browser", lambda **_kwargs: (session, page))
monkeypatch.setattr(tm, "ensure_logged_in", lambda *_args: page)
monkeypatch.setattr(tm, "navigate_to_products", lambda _page: None)
monkeypatch.setattr(tm, "select_yesterday", lambda _page: True)
monkeypatch.setattr(tm, "click_download", lambda _page: report)
monkeypatch.setattr(tm, "export_excel_to_json_and_md", lambda _path: (None, None))
monkeypatch.setattr(tm, "summarize_styles_from_excel", lambda _path: [])
monkeypatch.setattr(tm, "_kill_port_listeners", lambda _port: 0)
assert tm.main(["--target-date", "2026-08-03"]) == 1
assert session.closed is True
@@ -0,0 +1,69 @@
import unittest
from pathlib import Path
from unittest.mock import Mock
import taobao_dmp_item_crowd_insight_screenshots as dmp
class TmPersonaRecoveryTests(unittest.TestCase):
def test_zero_chart_skip_is_not_platform_success(self):
self.assertEqual(
dmp.records_exit_code(
{"款A": ["123"]},
[{"style_name": "款A", "status": "skipped"}],
),
2,
)
def test_switch_drawer_failure_retries_page_then_rebuilds_session(self):
failed = {
"status": "failed",
"error": "切换分析单品弹窗未加载(未出现搜索框)",
}
succeeded = {"status": "ok", "chart_values": {"用户性别": []}}
collect = Mock(side_effect=[failed, failed, succeeded])
goto = Mock()
reopen = Mock(return_value=("new-session", "new-page"))
session, page, record = dmp.collect_item_with_recovery(
"old-session",
"old-page",
"盖亚斜挎",
"921336775006",
Path("data/tm人物画像"),
collect_fn=collect,
goto_fn=goto,
reopen_session=reopen,
)
self.assertEqual(record["status"], "ok")
self.assertEqual((session, page), ("new-session", "new-page"))
self.assertEqual(collect.call_count, 3)
goto.assert_called_once_with("old-page")
reopen.assert_called_once_with("old-session", "old-page")
def test_non_navigation_failure_is_not_retried(self):
skipped = {"status": "skipped", "skip_reason": "分析人群规模过小"}
collect = Mock(return_value=skipped)
goto = Mock()
reopen = Mock()
_, _, record = dmp.collect_item_with_recovery(
"session",
"page",
"盖亚斜挎",
"921336775006",
Path("data/tm人物画像"),
collect_fn=collect,
goto_fn=goto,
reopen_session=reopen,
)
self.assertEqual(record, skipped)
self.assertEqual(collect.call_count, 1)
goto.assert_not_called()
reopen.assert_not_called()
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,152 @@
import unittest
from argparse import Namespace
from pathlib import Path
from unittest.mock import Mock, patch
import taobao_wanxiang_ai_creative_report as wanxiang
from taobao_wanxiang_ai_creative_report import select_report_template
class WanxiangReportTemplateTests(unittest.TestCase):
def test_authenticated_account_chooser_on_login_route_is_valid_session(self):
page = Mock()
page.url = "https://one.alimama.com/index.html#!/login/index"
page.locator.return_value.inner_text.return_value = (
"欢迎登录\nHi,测试店铺\n进入后台\n退出账户"
)
self.assertTrue(wanxiang.is_logged_in(page))
def test_credential_form_on_login_route_is_not_valid_session(self):
page = Mock()
page.url = "https://one.alimama.com/index.html#!/login/index"
page.locator.return_value.inner_text.return_value = "账号名\n请输入登录密码"
self.assertFalse(wanxiang.is_logged_in(page))
def test_supports_actual_wanxiang_template_label_spelling(self):
self.assertIn("报表模板", wanxiang.REPORT_TEMPLATE_LABELS)
self.assertIn("报表模版", wanxiang.REPORT_TEMPLATE_LABELS)
def test_already_selected_keyword_template_does_not_click(self):
page = Mock()
page.evaluate.return_value = {
"found": True,
"current": "关键词推广",
}
select_report_template(page, verify_attempts=1)
self.assertEqual(page.evaluate.call_count, 1)
page.wait_for_timeout.assert_not_called()
def test_waits_for_async_template_filter_render(self):
page = Mock()
page.evaluate.side_effect = [
{"found": False, "current": ""},
{"found": True, "current": "关键词推广"},
]
select_report_template(page, find_attempts=2, verify_attempts=1)
self.assertEqual(page.evaluate.call_count, 2)
page.wait_for_timeout.assert_called_once_with(1000)
def test_missing_template_filter_saves_debug_and_stops(self):
page = Mock()
page.evaluate.return_value = {"found": False, "current": ""}
with (
patch.object(wanxiang, "dump_report_template_debug") as dump,
self.assertRaisesRegex(RuntimeError, "报表模板"),
):
select_report_template(page, find_attempts=2, verify_attempts=1)
dump.assert_called_once_with(page, "filter_not_found")
def test_switches_from_all_plans_to_keyword_template(self):
page = Mock()
page.evaluate.side_effect = [
{"found": True, "current": "全部计划"},
True,
True,
{"found": True, "current": "关键词推广"},
]
with patch.object(wanxiang, "wait_table_change") as wait_table:
select_report_template(page, verify_attempts=1)
self.assertEqual(page.evaluate.call_count, 4)
self.assertGreaterEqual(page.wait_for_timeout.call_count, 2)
wait_table.assert_called_once_with(page, seconds=2.0)
def test_raises_when_keyword_template_cannot_be_verified(self):
page = Mock()
page.evaluate.side_effect = [
{"found": True, "current": "全部计划"},
True,
True,
{"found": True, "current": "全部计划"},
{"found": True, "current": "全部计划"},
]
with (
patch.object(wanxiang, "dump_report_template_debug"),
self.assertRaisesRegex(RuntimeError, "关键词推广"),
):
select_report_template(page, verify_attempts=2)
def test_run_selects_keyword_template_before_collecting_styles(self):
events = []
page = Mock()
session = Mock()
args = Namespace(
output=str(Path("data") / "test_wanxiang_keyword"),
styles="星云2",
date="2026-07-26",
headless=True,
user_data_dir="test-profile",
account="account",
password="placeholder",
max_pages=1,
report_template="关键词推广",
keep_open=False,
)
with (
patch.object(wanxiang, "start_stealthy_session", return_value=(session, page)),
patch.object(wanxiang, "ensure_logged_in"),
patch.object(wanxiang, "goto_wanxiang_from_seller"),
patch.object(wanxiang, "goto_creative_report"),
patch.object(
wanxiang,
"select_report_template",
side_effect=lambda *_args, **_kwargs: events.append("select"),
) as select,
patch.object(
wanxiang,
"collect_style",
side_effect=lambda *_args, **_kwargs: (
events.append("collect") or {"style_name": "星云2"}
),
),
patch.object(
wanxiang,
"write_style_outputs",
return_value=(Path("result.json"), Path("result.md")),
),
patch.object(
wanxiang,
"write_overall_outputs",
return_value=Path("overall.json"),
),
):
wanxiang.run(args)
select.assert_called_once_with(page, "关键词推广")
self.assertEqual(events, ["select", "collect"])
session.close.assert_called_once()
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,106 @@
from __future__ import annotations
from types import SimpleNamespace
from gyxx_flow.modules.shop_intelligence.browser_session import (
install_jd_dom_compatibility,
reuse_or_run_jd_login,
)
class _AccountInput:
def __init__(self, *, visible: bool) -> None:
self._visible = visible
@property
def first(self):
return self
def count(self) -> int:
return int(self._visible)
def is_visible(self, *, timeout: int) -> bool:
assert timeout == 1_000
return self._visible
class _Context:
def __init__(self) -> None:
self.scripts: list[str] = []
def add_init_script(self, *, script: str) -> None:
self.scripts.append(script)
def test_jd_session_adapter_reuses_authenticated_profile() -> None:
inputs = {"shop": "account", "credential": "credential"}
page = SimpleNamespace(
url="https://shop.jd.com/jdm/home",
goto=lambda *_args, **_kwargs: None,
wait_for_timeout=lambda *_args: None,
locator=lambda _selector: _AccountInput(visible=False),
)
result = reuse_or_run_jd_login(
page,
lambda *_args: (_ for _ in ()).throw(
AssertionError("authenticated profile must not repeat source login")
),
shop=inputs["shop"],
password=inputs["credential"],
)
assert result is None
def test_jd_session_adapter_invokes_source_login_when_form_is_visible() -> None:
calls: list[tuple[object, str, str]] = []
inputs = {"shop": "account", "credential": "credential"}
page = SimpleNamespace(
url="https://shop.jd.com/jdm/home",
goto=lambda *_args, **_kwargs: None,
wait_for_timeout=lambda *_args: None,
locator=lambda _selector: _AccountInput(visible=True),
)
reuse_or_run_jd_login(
page,
lambda current_page, shop, password: calls.append(
(current_page, shop, password)
),
shop=inputs["shop"],
password=inputs["credential"],
)
assert calls == [(page, "account", "credential")]
def test_jd_dom_compatibility_aliases_current_and_future_pages() -> None:
context = _Context()
evaluated: list[str] = []
page = SimpleNamespace(
context=context,
evaluate=lambda script: evaluated.append(script),
)
install_jd_dom_compatibility(page)
assert len(context.scripts) == 1
assert evaluated == context.scripts
assert "jmtd-date-picker-combo" in context.scripts[0]
assert "jmt-combo-date-picker" in context.scripts[0]
assert "jmt-date-picker" in context.scripts[0]
assert "classList.remove('jmt-date-picker')" in context.scripts[0]
assert "element.dataset.eventContent === 'realtime'" in context.scripts[0]
assert "gyxx-disabled-week-number" in context.scripts[0]
assert "if (!document.documentElement)" in context.scripts[0]
assert "setTimeout(installObserver, 0)" in context.scripts[0]
assert "childList: true" in context.scripts[0]
assert "attributes: true" not in context.scripts[0]
assert 'data-event-content="week"' in context.scripts[0]
assert "currentWeekTag.click()" in context.scripts[0]
assert "__gyxxJdWeekActivationAt" in context.scripts[0]
assert "gyxxWeekActivationAttempted" in context.scripts[0]
assert ").find(visible)" in context.scripts[0]
assert "gyxxWeekAdapterInstalled" in context.scripts[0]
assert "addEventListener('mouseenter'" in context.scripts[0]
@@ -0,0 +1,386 @@
from __future__ import annotations
import json
from types import SimpleNamespace
import pytest
from gyxx_flow.modules.shop_intelligence.collectors import (
dy_store_competitor_store_scraping as dy_collector,
)
from gyxx_flow.modules.shop_intelligence.collectors import (
jd_data_collector,
jd_peer_store_data_collector,
)
class _PlaywrightContext:
def __init__(self, page: object) -> None:
self.page = page
def __enter__(self):
context = SimpleNamespace(
new_page=lambda: self.page,
close=lambda: None,
)
browser = SimpleNamespace(
new_context=lambda **kwargs: context,
close=lambda: None,
)
chromium = SimpleNamespace(launch=lambda **kwargs: browser)
return SimpleNamespace(chromium=chromium)
def __exit__(self, exc_type, exc, traceback) -> None:
return None
def test_jd_shop_collector_returns_nonzero_when_collection_fails(
monkeypatch,
) -> None:
monkeypatch.setattr(
jd_data_collector,
"sync_playwright",
lambda: _PlaywrightContext(object()),
)
monkeypatch.setattr(
jd_data_collector,
"step_login",
lambda *args: (_ for _ in ()).throw(RuntimeError("page changed")),
)
assert jd_data_collector.main([]) == 1
def test_jd_shop_login_preserves_source_account_password_flow() -> None:
events: list[tuple[str, object]] = []
class Field:
def fill(self, value: str) -> None:
events.append(("fill", value))
def click(self) -> None:
events.append(("click", "login"))
fields = {
'input[placeholder="请输入账号名/邮箱"]': Field(),
'input[placeholder="请输入登录密码"]': Field(),
'button:has-text("立即登录")': Field(),
}
page = SimpleNamespace(
goto=lambda url, **kwargs: events.append(("goto", (url, kwargs))),
wait_for_timeout=lambda timeout: events.append(("wait", timeout)),
wait_for_load_state=lambda *_args, **_kwargs: None,
locator=lambda selector: fields[selector],
evaluate=lambda script: events.append(("evaluate", script)),
keyboard=SimpleNamespace(
press=lambda key: events.append(("key", key)),
),
)
assert jd_data_collector.step_login(page, "account", "credential") is None
assert ("fill", "account") in events
assert ("fill", "credential") in events
assert ("click", "login") in events
def test_jd_peer_collector_returns_nonzero_when_collection_fails(
monkeypatch,
) -> None:
monkeypatch.setattr(
jd_peer_store_data_collector,
"sync_playwright",
lambda: _PlaywrightContext(object()),
)
monkeypatch.setattr(
jd_peer_store_data_collector,
"step_login",
lambda *args: (_ for _ in ()).throw(RuntimeError("page changed")),
)
assert jd_peer_store_data_collector.main([]) == 1
def test_dy_callback_preserves_failure_for_main_process(monkeypatch) -> None:
failure = RuntimeError("date control changed")
persisted = []
monkeypatch.setattr(
dy_collector,
"_page_automation_impl",
lambda page: (_ for _ in ()).throw(failure),
)
monkeypatch.setattr(
dy_collector,
"_save_browser_state",
lambda *args: persisted.append(args),
)
with pytest.raises(RuntimeError, match="date control changed"):
dy_collector._page_automation(object())
assert dy_collector._automation_error is failure
assert persisted == []
def test_dy_outer_callback_persists_final_cross_domain_state(monkeypatch) -> None:
events = []
page = SimpleNamespace(
url="https://fxg.jinritemai.com/ffa/eco/experience-score"
)
monkeypatch.setattr(
dy_collector,
"current_acceptance_policy",
lambda: SimpleNamespace(enabled=False),
)
monkeypatch.setattr(
dy_collector,
"_page_automation_impl",
lambda actual_page: events.append(("source", actual_page)),
)
monkeypatch.setattr(
dy_collector,
"_save_browser_state",
lambda actual_page, stage: events.append(("persist", actual_page, stage)),
)
dy_collector._page_automation(page)
assert events == [
("source", page),
("persist", page, "after cross-domain stages"),
]
assert dy_collector._automation_error is None
def test_dy_final_state_snapshot_keeps_all_cross_domain_cookies(
monkeypatch,
tmp_path,
) -> None:
cookie_file = tmp_path / "cookies.json"
storage_file = tmp_path / "storage_state.json"
cookies = [
{"name": "compass", "value": "one", "domain": ".jinritemai.com"},
{"name": "qianchuan", "value": "two", "domain": ".oceanengine.com"},
{"name": "fxg", "value": "three", "domain": "fxg.jinritemai.com"},
]
class Context:
def cookies(self):
return cookies
def storage_state(self, *, path: str) -> None:
with open(path, "w", encoding="utf-8") as stream:
json.dump({"cookies": cookies, "origins": []}, stream)
monkeypatch.setattr(dy_collector, "COOKIES_FILE", str(cookie_file))
monkeypatch.setattr(dy_collector, "STORAGE_STATE_FILE", str(storage_file))
dy_collector._save_browser_state(
SimpleNamespace(context=Context()),
"after cross-domain stages",
)
saved_cookies = json.loads(cookie_file.read_text(encoding="utf-8"))
saved_storage = json.loads(storage_file.read_text(encoding="utf-8"))
assert {item["domain"] for item in saved_cookies} == {
".jinritemai.com",
".oceanengine.com",
"fxg.jinritemai.com",
}
assert saved_storage["cookies"] == cookies
def test_dy_main_returns_nonzero_when_fetcher_swallows_callback_failure(
monkeypatch,
tmp_path,
) -> None:
monkeypatch.setattr(
dy_collector,
"current_acceptance_policy",
lambda: SimpleNamespace(enabled=True),
)
monkeypatch.setattr(dy_collector, "OUTPUT_DIR", str(tmp_path / "module"))
monkeypatch.setattr(dy_collector, "DATA_DIR", str(tmp_path / "data"))
monkeypatch.setattr(dy_collector, "STORE_DATA_DIR", str(tmp_path / "store"))
monkeypatch.setattr(
dy_collector,
"COMPETITOR_DATA_DIR",
str(tmp_path / "competitor"),
)
monkeypatch.setattr(dy_collector, "USER_DATA_DIR", str(tmp_path / "profile"))
monkeypatch.setattr(dy_collector, "_load_cookies", lambda: [])
monkeypatch.setattr(dy_collector, "enable_adaptive_fetchers", lambda: None)
monkeypatch.setattr(
dy_collector,
"_page_automation_impl",
lambda page: (_ for _ in ()).throw(RuntimeError("date control changed")),
)
monkeypatch.setattr(
dy_collector,
"save_results",
lambda data: (_ for _ in ()).throw(
AssertionError("failed automation must not save fallback data")
),
)
def fake_fetch(url, *, page_action, **kwargs):
try:
page_action(object())
except RuntimeError:
pass
return SimpleNamespace(url=url, status=200)
monkeypatch.setattr(dy_collector.DynamicFetcher, "fetch", fake_fetch)
assert dy_collector.main() == 1
def test_dy_main_treats_final_login_response_as_cookie_skip(
monkeypatch,
tmp_path,
) -> None:
monkeypatch.setattr(
dy_collector,
"current_acceptance_policy",
lambda: SimpleNamespace(enabled=True),
)
monkeypatch.setattr(dy_collector, "OUTPUT_DIR", str(tmp_path / "module"))
monkeypatch.setattr(dy_collector, "DATA_DIR", str(tmp_path / "data"))
monkeypatch.setattr(dy_collector, "STORE_DATA_DIR", str(tmp_path / "store"))
monkeypatch.setattr(
dy_collector,
"COMPETITOR_DATA_DIR",
str(tmp_path / "competitor"),
)
monkeypatch.setattr(dy_collector, "USER_DATA_DIR", str(tmp_path / "profile"))
monkeypatch.setattr(dy_collector, "_load_cookies", lambda: [])
monkeypatch.setattr(dy_collector, "enable_adaptive_fetchers", lambda: None)
monkeypatch.setattr(dy_collector, "_session_expired_exit_code", lambda: 75)
monkeypatch.setattr(
dy_collector,
"_page_automation_impl",
lambda page: (_ for _ in ()).throw(RuntimeError("navigation aborted")),
)
monkeypatch.setattr(
dy_collector,
"save_results",
lambda data: (_ for _ in ()).throw(
AssertionError("expired session must not save fallback data")
),
)
def fake_fetch(url, *, page_action, **kwargs):
try:
page_action(object())
except RuntimeError:
pass
return SimpleNamespace(
url="https://compass.jinritemai.com/login?roleType=shop",
status=200,
)
monkeypatch.setattr(dy_collector.DynamicFetcher, "fetch", fake_fetch)
assert dy_collector.main() == 75
assert isinstance(dy_collector._automation_error, RuntimeError)
def test_dy_production_keeps_source_success_after_optional_page_redirect(
monkeypatch,
tmp_path,
) -> None:
monkeypatch.setattr(
dy_collector,
"current_acceptance_policy",
lambda: SimpleNamespace(enabled=False),
)
monkeypatch.setattr(dy_collector, "OUTPUT_DIR", str(tmp_path / "module"))
monkeypatch.setattr(dy_collector, "DATA_DIR", str(tmp_path / "data"))
monkeypatch.setattr(dy_collector, "STORE_DATA_DIR", str(tmp_path / "store"))
monkeypatch.setattr(
dy_collector,
"COMPETITOR_DATA_DIR",
str(tmp_path / "competitor"),
)
monkeypatch.setattr(dy_collector, "USER_DATA_DIR", str(tmp_path / "profile"))
monkeypatch.setattr(dy_collector, "_load_cookies", lambda: [])
monkeypatch.setattr(dy_collector, "enable_adaptive_fetchers", lambda: None)
monkeypatch.setattr(dy_collector, "_results_saved", True)
monkeypatch.setattr(dy_collector, "_automation_error", None)
monkeypatch.setattr(dy_collector, "_page_automation_impl", lambda page: None)
def fake_fetch(url, *, page_action, **kwargs):
page_action(SimpleNamespace(url=url))
return SimpleNamespace(
url="https://fxg.jinritemai.com/login/common",
status=200,
)
monkeypatch.setattr(dy_collector.DynamicFetcher, "fetch", fake_fetch)
assert dy_collector.main() == 0
def test_dy_login_redirect_is_not_treated_as_business_page() -> None:
assert dy_collector._is_login_page_url(
"https://fxg.jinritemai.com/login/common"
)
assert not dy_collector._is_login_page_url(
"https://compass.jinritemai.com/shop/business-part"
)
def test_dy_login_form_is_detected_when_spa_url_does_not_change() -> None:
counts = {
'input[name="mobile"]': 1,
'input[name="mobilecaptcha"]': 1,
".account-center-action-button": 1,
}
page = SimpleNamespace(
locator=lambda selector: SimpleNamespace(count=lambda: counts[selector])
)
assert dy_collector._has_interactive_login_form(page)
def test_dy_acceptance_login_redirect_returns_cookie_skip(monkeypatch) -> None:
recorded = []
class Policy:
enabled = True
def record(self, event, **payload):
recorded.append((event, payload))
monkeypatch.setattr(dy_collector, "current_acceptance_policy", lambda: Policy())
assert dy_collector._session_expired_exit_code() == 75
assert recorded[0][0] == "cookie_skipped"
assert recorded[0][1]["details"]["status"] == "SKIPPED_COOKIE"
def test_dy_session_expiry_is_classified_by_outer_callback(monkeypatch) -> None:
class Policy:
enabled = True
monkeypatch.setattr(
dy_collector,
"current_acceptance_policy",
lambda: Policy(),
)
page = SimpleNamespace(
url="https://compass.jinritemai.com/login?roleType=shop",
)
monkeypatch.setattr(dy_collector, "_save_browser_debug", lambda *args: None)
monkeypatch.setattr(
dy_collector,
"_page_automation_impl",
lambda *_args: (_ for _ in ()).throw(
AssertionError("expired session must not enter source business logic")
),
)
with pytest.raises(
dy_collector.BrowserSessionExpiredError,
match="redirected to an interactive login page",
):
dy_collector._page_automation(page)
@@ -0,0 +1,64 @@
from __future__ import annotations
from gyxx_flow.modules.shop_intelligence.db import db as shop_db
class _Cursor:
rowcount = 1
def __enter__(self):
return self
def __exit__(self, exc_type, exc, traceback) -> None:
return None
class _Connection:
def __init__(self) -> None:
self.cursor_instance = _Cursor()
def cursor(self) -> _Cursor:
return self.cursor_instance
def test_dynamic_batch_upsert_reports_all_input_records(monkeypatch) -> None:
records = [
{"week_start": "2026-07-27", "brand": "A", "rank": 1},
{"week_start": "2026-07-27", "brand": "B", "rank": 2},
{"week_start": "2026-07-27", "brand": "C", "rank": 3},
]
executed = {}
monkeypatch.setattr(
shop_db,
"_get_table_columns",
lambda conn, table: {"week_start", "brand", "rank"},
)
def fake_execute_batch(cursor, sql, data, *, page_size):
executed.update(
cursor=cursor,
sql=sql,
data=data,
page_size=page_size,
)
monkeypatch.setattr(shop_db.psycopg2.extras, "execute_batch", fake_execute_batch)
connection = _Connection()
persisted = shop_db._dynamic_batch_upsert(
connection,
"jd_peer_store_ranking",
records,
("week_start", "brand"),
)
assert connection.cursor_instance.rowcount == 1
assert persisted == len(records)
assert executed["cursor"] is connection.cursor_instance
assert executed["data"] == [
["2026-07-27", "A", 1],
["2026-07-27", "B", 2],
["2026-07-27", "C", 3],
]
assert executed["page_size"] == 200
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,196 @@
from __future__ import annotations
from datetime import date
from types import SimpleNamespace
import pytest
from gyxx_flow.modules.shop_intelligence.collectors import (
dy_store_competitor_store_scraping as collector,
)
class _Locator:
def __init__(self, *, count: int = 1) -> None:
self._count = count
self.clicked = False
self.waited = False
@property
def first(self):
return self
def count(self) -> int:
return self._count
def click(self, **_kwargs) -> None:
self.clicked = True
def wait_for(self, **_kwargs) -> None:
self.waited = True
def test_market_category_controls_are_bound_to_visible_cascader() -> None:
selectors: list[str] = []
closed_menu = _Locator(count=0)
trigger = _Locator()
opened_menu = _Locator()
class Page:
def locator(self, selector: str):
selectors.append(selector)
if selector == ".aurora-cascader-menu:visible":
return closed_menu if selectors.count(selector) == 1 else opened_menu
if selector == ".aurora-form-item-control .aurora-cascader:visible":
return trigger
raise AssertionError(selector)
assert collector._click_market_category_dropdown(Page())
assert trigger.clicked
assert opened_menu.waited
def test_market_category_option_uses_visible_titled_menu_item() -> None:
located: list[str] = []
option = _Locator()
page = SimpleNamespace(
locator=lambda selector: located.append(selector) or option,
)
assert collector._click_market_category_option(page, "双肩包")
assert located == [
'.aurora-cascader-menu '
'li.aurora-cascader-menu-item[role="menuitemcheckbox"]'
'[title="双肩包"]:visible'
]
assert option.waited
assert option.clicked
def test_select_market_category_requires_path_and_fresh_table(monkeypatch) -> None:
expected = collector._expected_market_category_path("双肩包")
paths = iter([collector._expected_market_category_path("腰包/胸包"), expected])
signatures = iter(["old rows", "new rows"])
clicked: list[str] = []
refresh_checks: list[tuple[str, str]] = []
page = SimpleNamespace(
evaluate=lambda *_args, **_kwargs: None,
wait_for_timeout=lambda *_args, **_kwargs: None,
)
monkeypatch.setattr(
collector,
"_selected_market_category_path",
lambda _page: next(paths),
)
monkeypatch.setattr(
collector,
"_market_table_signature",
lambda _page: next(signatures),
)
monkeypatch.setattr(
collector,
"_click_market_category_dropdown",
lambda _page: True,
)
monkeypatch.setattr(
collector,
"_click_market_category_option",
lambda _page, label: clicked.append(label) or True,
)
monkeypatch.setattr(
collector,
"_wait_for_market_category_refresh",
lambda _page, *, expected_path, previous_signature: refresh_checks.append(
(expected_path, previous_signature)
),
)
monkeypatch.setattr(collector, "_wait_after_action", lambda *_args, **_kwargs: None)
collector._select_market_category(page, "双肩包")
assert clicked == ["鞋靴箱包", "箱包", "时尚箱包", "双肩包"]
assert refresh_checks == [(expected, "old rows")]
def test_select_market_category_rejects_stale_table(monkeypatch) -> None:
expected = collector._expected_market_category_path("双肩包")
paths = iter([collector._expected_market_category_path("腰包/胸包"), expected])
page = SimpleNamespace(
evaluate=lambda *_args, **_kwargs: None,
wait_for_timeout=lambda *_args, **_kwargs: None,
)
monkeypatch.setattr(
collector,
"_selected_market_category_path",
lambda _page: next(paths),
)
monkeypatch.setattr(
collector,
"_market_table_signature",
lambda _page: "same rows",
)
monkeypatch.setattr(
collector,
"_click_market_category_dropdown",
lambda _page: True,
)
monkeypatch.setattr(
collector,
"_click_market_category_option",
lambda *_args, **_kwargs: True,
)
monkeypatch.setattr(
collector,
"_wait_for_market_category_refresh",
lambda *_args, **_kwargs: None,
)
monkeypatch.setattr(collector, "_wait_after_action", lambda *_args, **_kwargs: None)
with pytest.raises(
collector.MarketCategorySelectionError,
match="still contains stale rows",
):
collector._select_market_category(page, "双肩包")
def test_market_ranking_collection_never_extracts_after_selection_failure(
monkeypatch,
) -> None:
extracted: list[str] = []
monkeypatch.setattr(
collector,
"_previous_week_range",
lambda: (date(2026, 7, 27), date(2026, 8, 2)),
)
monkeypatch.setattr(
collector,
"_open_market_rank_shop_page",
lambda *_args, **_kwargs: None,
)
monkeypatch.setattr(
collector,
"_select_market_previous_week",
lambda *_args, **_kwargs: None,
)
monkeypatch.setattr(
collector,
"_select_market_category",
lambda *_args, **_kwargs: (_ for _ in ()).throw(
collector.MarketCategorySelectionError("category did not change")
),
)
monkeypatch.setattr(
collector,
"_extract_market_ranking",
lambda _page, category: extracted.append(category),
)
with pytest.raises(
collector.MarketCategorySelectionError,
match="category did not change",
):
collector._collect_market_rankings(object())
assert extracted == []
@@ -0,0 +1,359 @@
from __future__ import annotations
import json
import pytest
from gyxx_flow.modules.shop_intelligence import lark_cli
from gyxx_flow.modules.shop_intelligence.config import (
DY_PEER_BASE_FIELDS,
DY_PEER_CAT_MAP,
JD_PEER_BASE_FIELDS,
JD_PEER_BRAND_MAP,
JD_PEER_CATEGORY_MAP,
)
from gyxx_flow.modules.shop_intelligence.writers import peer_store_writer
def test_shop_idempotency_reads_all_pages_and_normalizes_month(
monkeypatch: pytest.MonkeyPatch,
) -> None:
offsets: list[int] = []
def fake_run(_node, _script, args, **_kwargs):
payload = json.loads(args[-1])
offsets.append(payload["offset"])
if payload["offset"] == 0:
return {
"data": {
"fields": ["time", "platform", "month"],
"data": [["7月第5周(7.27-8.02", ["JD"], ["2026.7月"]]],
"record_id_list": ["other-platform"],
"has_more": True,
}
}
return {
"data": {
"fields": ["time", "platform", "month"],
"data": [["7月第5周(7.27-8.02", ["DY"], ["2026.7月"]]],
"record_id_list": ["target"],
"has_more": False,
}
}
monkeypatch.setattr(lark_cli, "run_lark_cli", fake_run)
record_id = lark_cli.find_existing_record(
"node",
"script",
"base",
"table",
{"时间": "time", "平台": "platform", "月份": "month"},
"7月第5周(7.27-8.2",
"DY",
month_str="2026.07月",
)
assert record_id == "target"
assert offsets == [0, 1]
def test_shop_idempotency_fails_closed_when_identity_index_is_missing(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(
lark_cli,
"run_lark_cli",
lambda *_args, **_kwargs: {
"data": {
"fields": ["time", "month"],
"data": [],
"record_id_list": [],
"has_more": False,
}
},
)
with pytest.raises(RuntimeError, match="平台"):
lark_cli.find_existing_record(
"node",
"script",
"base",
"table",
{"时间": "time", "平台": "platform", "月份": "month"},
"7月第5周(7.27-8.2",
"DY",
month_str="2026.07月",
)
def test_record_search_pagination_has_a_hard_page_limit(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(lark_cli, "_MAX_RECORD_SEARCH_PAGES", 1)
monkeypatch.setattr(
lark_cli,
"run_lark_cli",
lambda *_args, **_kwargs: {
"data": {
"fields": ["time"],
"data": [["7月第5周(7.27-8.2"]],
"record_id_list": ["record-1"],
"has_more": True,
}
},
)
with pytest.raises(RuntimeError, match="安全上限"):
lark_cli.search_record_pages(
"node",
"script",
"base",
"table",
{
"keyword": "7月第5周",
"search_fields": ["time"],
"select_fields": ["time"],
"limit": 1,
},
)
def test_peer_idempotency_reads_all_pages_and_requires_identity_indexes(
monkeypatch: pytest.MonkeyPatch,
) -> None:
offsets: list[int] = []
def fake_run(_node, _script, args, **_kwargs):
payload = json.loads(args[-1])
offsets.append(payload["offset"])
if payload["offset"] == 0:
return {
"data": {
"fields": ["time", "brand", "month"],
"data": [["7月第5周(7.27-8.02", ["other"], ["2026.7月"]]],
"record_id_list": ["other-brand"],
"has_more": True,
}
}
return {
"data": {
"fields": ["time", "brand", "month"],
"data": [["7月第5周(7.27-8.02", ["NIID"], ["2026.7月"]]],
"record_id_list": ["target"],
"has_more": False,
}
}
monkeypatch.setattr(lark_cli, "run_lark_cli", fake_run)
record_id = peer_store_writer._find_existing_peer_record(
"node",
"script",
"table",
{"时间": "time", "竞店名称": "brand", "月份": "month"},
"7月第5周(7.27-8.2",
"NIID",
"2026.07月",
)
assert record_id == "target"
assert offsets == [0, 1]
monkeypatch.setattr(
lark_cli,
"run_lark_cli",
lambda *_args, **_kwargs: {
"data": {
"fields": ["time", "month"],
"data": [],
"record_id_list": [],
"has_more": False,
}
},
)
with pytest.raises(RuntimeError, match="竞店名称"):
peer_store_writer._find_existing_peer_record(
"node",
"script",
"table",
{"时间": "time", "竞店名称": "brand", "月份": "month"},
"7月第5周(7.27-8.2",
"NIID",
"2026.07月",
)
def test_jd_rerun_explicitly_clears_only_na_category(
monkeypatch: pytest.MonkeyPatch,
tmp_path,
) -> None:
brand_json_name = next(iter(JD_PEER_BRAND_MAP))
category_names = list(JD_PEER_CATEGORY_MAP)
artifact = tmp_path / "jd-peer.json"
artifact.write_text(
json.dumps(
{
"数据周期": "2026-07-27 ~ 2026-08-02",
"数据": {
brand_json_name: {
category_names[0]: {"rank": "N/A"},
category_names[1]: {"rank": "7"},
category_names[2]: {"rank": "3"},
}
},
},
ensure_ascii=False,
),
encoding="utf-8",
)
writes: list[dict] = []
monkeypatch.setattr(peer_store_writer, "validate_peer_payload", lambda *_args: (True, ""))
monkeypatch.setattr(peer_store_writer, "resolve_lark_cli", lambda: ("node", "script"))
monkeypatch.setattr(
peer_store_writer,
"_build_month_options_cache",
lambda *_args: {"2026.07月": "2026.07月"},
)
monkeypatch.setattr(peer_store_writer, "_find_existing_peer_record", lambda *_args: "existing")
monkeypatch.setattr(
peer_store_writer,
"_upsert_peer_record",
lambda *_args: writes.append(_args[-1]) or {"data": {"record_id_list": ["existing"]}},
)
assert peer_store_writer._write_jd_peer_store(str(artifact)) is True
assert len(writes) == 1
assert writes[0][JD_PEER_CATEGORY_MAP[category_names[0]]] is None
assert writes[0][JD_PEER_CATEGORY_MAP[category_names[1]]] == 7
assert writes[0][JD_PEER_CATEGORY_MAP[category_names[2]]] == 3
def test_dy_brand_iteration_uses_both_category_brand_sets(
monkeypatch: pytest.MonkeyPatch,
tmp_path,
) -> None:
artifact = tmp_path / "dy-peer.json"
artifact.write_text(
json.dumps(
{
"date_range": "2026/07/27 - 2026/08/02",
"results": {
DY_PEER_CAT_MAP["斜挎包"]: {"alpha": {"rank": "11"}},
DY_PEER_CAT_MAP["双肩包"]: {"beta": {"rank": "22"}},
},
},
ensure_ascii=False,
),
encoding="utf-8",
)
writes: list[dict] = []
monkeypatch.setattr(peer_store_writer, "validate_peer_payload", lambda *_args: (True, ""))
monkeypatch.setattr(peer_store_writer, "resolve_lark_cli", lambda: ("node", "script"))
monkeypatch.setattr(
peer_store_writer,
"_build_brand_options_cache",
lambda *_args: {"alpha": "Alpha", "beta": "Beta"},
)
monkeypatch.setattr(
peer_store_writer,
"_build_month_options_cache",
lambda *_args: {"2026.07月": "2026.07月"},
)
monkeypatch.setattr(peer_store_writer, "_find_existing_peer_record", lambda *_args: "existing")
monkeypatch.setattr(
peer_store_writer,
"_upsert_peer_record",
lambda *_args: writes.append(_args[-1]) or {"data": {"record_id_list": ["existing"]}},
)
assert peer_store_writer._write_dy_peer_store(str(artifact)) is True
assert [row[DY_PEER_BASE_FIELDS["竞店名称"]] for row in writes] == [
"Alpha",
"Beta",
]
def test_dy_local_shop_validation_failure_propagates_false(
monkeypatch: pytest.MonkeyPatch,
tmp_path,
) -> None:
peer_artifact = tmp_path / "dy-peer.json"
peer_artifact.write_text(
json.dumps(
{
"date_range": "2026/07/27 - 2026/08/02",
"results": {
DY_PEER_CAT_MAP["斜挎包"]: {"alpha": {"rank": "11"}},
DY_PEER_CAT_MAP["双肩包"]: {"alpha": {"rank": "22"}},
},
},
ensure_ascii=False,
),
encoding="utf-8",
)
shop_artifact = tmp_path / "dy-shop.json"
shop_artifact.write_text("{}", encoding="utf-8")
monkeypatch.setattr(peer_store_writer, "validate_peer_payload", lambda *_args: (True, ""))
monkeypatch.setattr(peer_store_writer, "resolve_lark_cli", lambda: ("node", "script"))
monkeypatch.setattr(
peer_store_writer,
"_build_brand_options_cache",
lambda *_args: {"alpha": "Alpha"},
)
monkeypatch.setattr(
peer_store_writer,
"_build_month_options_cache",
lambda *_args: {"2026.07月": "2026.07月"},
)
monkeypatch.setattr(peer_store_writer, "_find_existing_peer_record", lambda *_args: "existing")
monkeypatch.setattr(
peer_store_writer,
"_upsert_peer_record",
lambda *_args: {"data": {"record_id_list": ["existing"]}},
)
monkeypatch.setattr(peer_store_writer, "_write_dy_local_shop_row", lambda *_args: False)
assert (
peer_store_writer._write_dy_peer_store(
str(peer_artifact),
str(shop_artifact),
)
is False
)
def test_invalid_dy_local_shop_rankings_return_false_without_upsert(
monkeypatch: pytest.MonkeyPatch,
tmp_path,
) -> None:
artifact = tmp_path / "shop.json"
artifact.write_text(json.dumps({"market_rankings": {}}), encoding="utf-8")
monkeypatch.setattr(
peer_store_writer,
"validate_market_rankings",
lambda *_args: (False, "missing rankings"),
)
monkeypatch.setattr(
peer_store_writer,
"_upsert_peer_record",
lambda *_args: pytest.fail("invalid local rankings must not be written"),
)
assert (
peer_store_writer._write_dy_local_shop_row(
"node",
"script",
"7月第5周(7.27-8.2",
"2026.07月",
str(artifact),
)
is False
)
def test_configured_peer_identity_fields_are_non_empty() -> None:
for fields in (JD_PEER_BASE_FIELDS, DY_PEER_BASE_FIELDS):
assert fields["时间"]
assert fields["月份"]
assert fields["竞店名称"]
@@ -0,0 +1,257 @@
from __future__ import annotations
from types import SimpleNamespace
from gyxx_flow.modules.shop_intelligence.collectors import (
jd_peer_store_data_collector as collector,
)
class _HiddenMenuLocator:
def count(self) -> int:
return 0
class _Page:
def __init__(self) -> None:
self.keyboard = SimpleNamespace(press=lambda _key: None)
def locator(self, _selector: str) -> _HiddenMenuLocator:
return _HiddenMenuLocator()
def wait_for_timeout(self, _timeout_ms: int) -> None:
return None
def test_category_path_normalization_is_exact() -> None:
assert collector._normalize_category_path(" 男包 → 男士双肩包 ") == (
"男包 > 男士双肩包"
)
assert collector._expected_category_path("男士双肩包", "男包") == (
"男包 > 男士双肩包"
)
assert collector._expected_category_path("男包", None) == "男包"
def test_category_menu_closes_stale_portals_before_opening_selector() -> None:
class Menus:
def __init__(self, page) -> None:
self.page = page
self.first = self
def count(self) -> int:
return self.page.menu_count
def wait_for(self, **_kwargs) -> None:
return None
class Trigger:
def __init__(self, page) -> None:
self.page = page
def click(self, **_kwargs) -> None:
self.page.menu_count = 1
class Selector:
def __init__(self, page) -> None:
self.page = page
def locator(self, _selector: str) -> Trigger:
return Trigger(self.page)
class Page:
def __init__(self) -> None:
self.menu_count = 2
self.escapes = 0
self.keyboard = SimpleNamespace(press=self.press)
def press(self, key: str) -> None:
assert key == "Escape"
self.escapes += 1
self.menu_count = 0
def locator(self, _selector: str) -> Menus:
return Menus(self)
def wait_for_timeout(self, _timeout_ms: int) -> None:
return None
page = Page()
menu = collector._visible_category_menu(page, Selector(page))
assert menu.count() == 1
assert page.escapes == 1
def test_category_menu_targets_only_outer_dropdown_panel() -> None:
source = __import__("inspect").getsource(collector._visible_category_menu)
assert ".jmtd-dropdown-panel.jmtd-dropdown-lists:visible" in source
def test_successful_category_switch_closes_its_dropdown_portal(
monkeypatch,
) -> None:
class Menus:
def __init__(self, page) -> None:
self.page = page
def count(self) -> int:
return self.page.menu_count
class Trigger:
def __init__(self, page) -> None:
self.page = page
def click(self, **_kwargs) -> None:
self.page.menu_count = 0
class Selector:
def __init__(self, page) -> None:
self.page = page
def locator(self, _selector: str) -> Trigger:
return Trigger(self.page)
class Page:
def __init__(self) -> None:
self.menu_count = 1
self.keyboard = SimpleNamespace(press=lambda _key: None)
def locator(self, _selector: str) -> Menus:
return Menus(self)
def wait_for_timeout(self, _timeout_ms: int) -> None:
return None
page = Page()
selector = Selector(page)
monkeypatch.setattr(collector, "_visible_category_selector", lambda _page: selector)
monkeypatch.setattr(collector, "_ranking_table_fingerprint", lambda _page: "before")
monkeypatch.setattr(
collector, "_selected_category_path", lambda _selector: "男包 > 男士双肩包"
)
assert collector.switch_category(page, "男士双肩包", "男包") is True
assert page.menu_count == 0
def test_ranking_refresh_accepts_only_explicit_empty_state(monkeypatch) -> None:
monkeypatch.setattr(collector, "_ranking_table_fingerprint", lambda _page: "")
monkeypatch.setattr(
collector, "_ranking_table_is_explicitly_empty", lambda _page: True
)
assert collector._wait_for_ranking_table_refresh(
object(), "previous", timeout_ms=1
) is True
def test_ranking_refresh_rejects_unverified_empty_state(monkeypatch) -> None:
monkeypatch.setattr(collector, "_ranking_table_fingerprint", lambda _page: "")
monkeypatch.setattr(
collector, "_ranking_table_is_explicitly_empty", lambda _page: False
)
monkeypatch.setattr(collector.time, "sleep", lambda _seconds: None)
assert collector._wait_for_ranking_table_refresh(
object(), "previous", timeout_ms=0
) is False
def test_switch_category_requires_selected_state_and_table_refresh(
monkeypatch,
) -> None:
page = _Page()
selector = object()
menu = object()
observed: list[object] = []
monkeypatch.setattr(
collector,
"_visible_category_selector",
lambda actual_page: selector if actual_page is page else None,
)
monkeypatch.setattr(collector, "_ranking_table_fingerprint", lambda _page: "before")
monkeypatch.setattr(collector, "_selected_category_path", lambda _selector: "男包")
monkeypatch.setattr(
collector,
"_visible_category_menu",
lambda actual_page, actual_selector: (
menu if (actual_page, actual_selector) == (page, selector) else None
),
)
monkeypatch.setattr(
collector,
"_select_category_option",
lambda actual_page, actual_menu, category, parent: observed.append(
(actual_page, actual_menu, category, parent)
),
)
monkeypatch.setattr(
collector,
"_wait_for_selected_category",
lambda actual_selector, expected: (
observed.append((actual_selector, expected, "selected")) or True
),
)
monkeypatch.setattr(
collector,
"_wait_for_ranking_table_refresh",
lambda actual_page, previous: (
observed.append((actual_page, previous, "refreshed")) or True
),
)
assert collector.switch_category(page, "男士双肩包", "男包") is True
assert observed == [
(page, menu, "男士双肩包", "男包"),
(selector, "男包 > 男士双肩包", "selected"),
(page, "before", "refreshed"),
]
def test_failed_category_switch_never_extracts_previous_table(
monkeypatch,
) -> None:
page = _Page()
extracted: list[str] = []
monkeypatch.setattr(collector, "SHOPS", ["peer-shop"])
monkeypatch.setattr(
collector,
"CATEGORIES",
[("男士双肩包", "男包"), ("男士单肩/斜挎包", "男包")],
)
monkeypatch.setattr(collector, "select_last_week_any_day", lambda _page: None)
monkeypatch.setattr(collector, "search_shop", lambda *_args: None)
monkeypatch.setattr(
collector,
"switch_category",
lambda _page, category, _parent: category == "男士单肩/斜挎包",
)
def extract_rows(_page):
extracted.append("called")
return [
{
"rank": "7",
"shop": "peer-shop",
"amount": "¥1万~¥2万",
"orders": "10~50",
"followers": "3",
"visitors": "1,000~2,000",
"searchClicks": "600~800",
}
]
monkeypatch.setattr(collector, "extract_ranking_table", extract_rows)
result = collector.step_collect_competitor_data(page)["peer-shop"]
assert result["男士双肩包"]["rank"] == "N/A"
assert "禁止复用" not in result["男士双肩包"]["error"]
assert "类目切换或榜单刷新失败" in result["男士双肩包"]["error"]
assert result["男士单肩/斜挎包"]["rank"] == "7"
assert extracted == ["called"]
@@ -0,0 +1,225 @@
import unittest
from datetime import date
from decimal import Decimal
from types import SimpleNamespace
from gyxx_flow.adapters import COOKIE_SKIP_EXIT_CODE
from gyxx_flow.modules.shop_intelligence.collectors import (
jd_self_operated_brand_daily as brand_daily,
)
from gyxx_flow.modules.shop_intelligence.collectors.jd_self_operated_brand_daily import (
browser_runtime_options,
build_db_records,
is_authenticated_shop_url,
iter_dates,
parse_decimal,
parse_percent,
resolve_account_identifier,
)
def test_browser_runtime_defaults_to_bundled_chromium(monkeypatch):
monkeypatch.delenv("GYXX_BROWSER_CHANNEL", raising=False)
assert browser_runtime_options() == {}
def test_browser_runtime_can_use_installed_chrome(monkeypatch):
monkeypatch.setenv("GYXX_BROWSER_CHANNEL", "chrome")
assert browser_runtime_options() == {"channel": "chrome"}
class DateRangeTests(unittest.TestCase):
def test_iter_dates_is_inclusive(self):
self.assertEqual(
list(iter_dates(date(2026, 6, 29), date(2026, 7, 2))),
[
date(2026, 6, 29),
date(2026, 6, 30),
date(2026, 7, 1),
date(2026, 7, 2),
],
)
def test_iter_dates_rejects_reverse_range(self):
with self.assertRaisesRegex(ValueError, "start_date"):
list(iter_dates(date(2026, 7, 2), date(2026, 7, 1)))
class LoginStateTests(unittest.TestCase):
def test_shop_page_is_authenticated(self):
self.assertTrue(
is_authenticated_shop_url(
"https://shop.jd.com/jdm/vc/data-management/performance/brandPerformance"
)
)
def test_passport_and_security_pages_are_not_authenticated(self):
self.assertFalse(
is_authenticated_shop_url(
"https://passport.shop.jd.com/login/index.action/jdm"
)
)
self.assertFalse(
is_authenticated_shop_url("https://aq.jd.com/certified/index?p=abc")
)
class _FakeLocator:
def __init__(self, value=None):
self.value = value
self.first = self
def count(self):
return int(self.value is not None)
def get_attribute(self, name):
assert name == "title"
return self.value
def inner_text(self):
return self.value or ""
class _FakePage:
def __init__(self, values, cookies=None):
self.values = values
self.context = SimpleNamespace(cookies=lambda: cookies or [])
def locator(self, selector):
return _FakeLocator(self.values.get(selector))
class AccountIdentifierTests(unittest.TestCase):
def test_configured_account_takes_priority(self):
self.assertEqual(
resolve_account_identifier(_FakePage({}), " gyxx2022 "),
"gyxx2022",
)
def test_cookie_session_uses_signed_in_pin(self):
page = _FakePage(
{
".shop-menu-accountV1__account-dropdown .content-pin[title]": (
"gyxx2022"
),
".shop-menu-accountV1__right-account-top-name[title]": (
"惠州鑫华达科技有限公司"
),
}
)
self.assertEqual(resolve_account_identifier(page, ""), "gyxx2022")
def test_cookie_session_falls_back_to_company_name(self):
page = _FakePage(
{
".shop-menu-accountV1__right-account-top-name[title]": (
"惠州鑫华达科技有限公司"
)
}
)
self.assertEqual(
resolve_account_identifier(page, ""),
"惠州鑫华达科技有限公司",
)
def test_cookie_session_uses_jd_pin_cookie_without_account_menu(self):
page = _FakePage(
{},
cookies=[
{
"name": "pin",
"value": "gyxx%32%30%32%32",
"domain": ".jd.com",
}
],
)
self.assertEqual(resolve_account_identifier(page, ""), "gyxx2022")
def test_cookie_session_requires_stable_identifier(self):
with self.assertRaisesRegex(RuntimeError, "无法识别当前京麦账号"):
resolve_account_identifier(_FakePage({}), "")
class ValueParsingTests(unittest.TestCase):
def test_parse_decimal_handles_commas_and_currency(self):
self.assertEqual(parse_decimal("¥34,605.80"), Decimal("34605.80"))
self.assertEqual(parse_decimal("122.00"), Decimal("122.00"))
def test_parse_decimal_preserves_negative_values(self):
self.assertEqual(parse_decimal("-2,173.20"), Decimal("-2173.20"))
def test_parse_decimal_returns_none_for_placeholder(self):
for value in (None, "", "-", "--", "", "N/A"):
self.assertIsNone(parse_decimal(value))
def test_parse_percent_converts_display_percent_to_ratio(self):
self.assertEqual(parse_percent("6.25%"), Decimal("0.0625"))
self.assertEqual(parse_percent("0.06"), Decimal("0.06"))
class RecordBuildingTests(unittest.TestCase):
def test_build_db_records_maps_all_columns(self):
rows = [
{
"品牌": "光影行星(GYXX",
"财务销量": "122.00",
"收入": "34,605.80",
"毛利": "2,173.20",
"毛利率": "6.00%",
"库存金额": "586,149.83",
}
]
records = build_db_records(
account="gyxx2022",
data_date=date(2026, 7, 29),
rows=rows,
source_url="https://shop.jd.com/example",
)
self.assertEqual(len(records), 1)
record = records[0]
self.assertEqual(record["account"], "gyxx2022")
self.assertEqual(record["data_date"], date(2026, 7, 29))
self.assertEqual(record["brand"], "光影行星(GYXX")
self.assertEqual(record["financial_sales"], Decimal("122.00"))
self.assertEqual(record["revenue"], Decimal("34605.80"))
self.assertEqual(record["gross_profit"], Decimal("2173.20"))
self.assertEqual(record["gross_margin"], Decimal("0.06"))
self.assertEqual(record["inventory_amount"], Decimal("586149.83"))
self.assertEqual(record["raw_row"]["收入"], "34,605.80")
def test_build_db_records_skips_empty_placeholder_row(self):
records = build_db_records(
account="gyxx2022",
data_date=date(2026, 7, 29),
rows=[{"品牌": "-", "收入": "-"}],
source_url="https://shop.jd.com/example",
)
self.assertEqual(records, [])
def test_acceptance_preflight_skips_before_argument_parsing(monkeypatch):
class FakePolicy:
enabled = True
def preflight_cookie(self, binding, *, environment):
return SimpleNamespace(should_skip=True, reason="browser state is missing")
monkeypatch.setattr(brand_daily, "current_acceptance_policy", lambda: FakePolicy())
monkeypatch.setattr(brand_daily, "binding_from_environment", lambda: "binding")
monkeypatch.setattr(
brand_daily,
"parse_args",
lambda argv: (_ for _ in ()).throw(
AssertionError("arguments must not be parsed after a cookie skip")
),
)
assert brand_daily.main([]) == COOKIE_SKIP_EXIT_CODE
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,108 @@
import unittest
from datetime import date
from decimal import Decimal
from gyxx_flow.adapters import COOKIE_SKIP_EXIT_CODE
from gyxx_flow.modules.shop_intelligence.collectors import (
jd_self_operated_product_daily as product_daily,
)
from gyxx_flow.modules.shop_intelligence.collectors.jd_self_operated_product_daily import (
build_product_db_records,
parse_total_count,
validate_collected_rows,
)
class TotalCountTests(unittest.TestCase):
def test_parse_total_count(self):
self.assertEqual(parse_total_count("共50条"), 50)
self.assertEqual(parse_total_count("共 1,234 条"), 1234)
def test_parse_total_count_rejects_unknown_text(self):
with self.assertRaisesRegex(ValueError, "总条数"):
parse_total_count("暂无数据")
class ProductRecordTests(unittest.TestCase):
def test_maps_all_product_columns(self):
rows = [
{
"商品编码": "100232520894",
"商品名称": "光影行星测试商品",
"财务销量": "1.00",
"收入": "145.14",
"成本": "196.60",
"毛利": "-51.46",
"优惠券抵减数": "50.68",
"积分抵减数": "0",
"满返满减额": "0",
"库存周转天数": "70.55",
}
]
records = build_product_db_records(
account="gyxx2022",
data_date=date(2026, 7, 30),
rows=rows,
source_url="https://shop.jd.com/example",
)
self.assertEqual(len(records), 1)
record = records[0]
self.assertEqual(record["product_code"], "100232520894")
self.assertEqual(record["product_name"], "光影行星测试商品")
self.assertEqual(record["financial_sales"], Decimal("1.00"))
self.assertEqual(record["revenue"], Decimal("145.14"))
self.assertEqual(record["cost"], Decimal("196.60"))
self.assertEqual(record["gross_profit"], Decimal("-51.46"))
self.assertEqual(record["coupon_deduction"], Decimal("50.68"))
self.assertEqual(record["points_deduction"], Decimal("0"))
self.assertEqual(record["promotion_deduction"], Decimal("0"))
self.assertEqual(record["inventory_turnover_days"], Decimal("70.55"))
def test_skips_row_without_product_code(self):
self.assertEqual(
build_product_db_records(
"gyxx2022",
date(2026, 7, 30),
[{"商品编码": "-", "商品名称": "占位行"}],
"https://shop.jd.com/example",
),
[],
)
class PaginationValidationTests(unittest.TestCase):
def test_accepts_complete_unique_rows(self):
rows = [{"商品编码": "1"}, {"商品编码": "2"}]
self.assertEqual(validate_collected_rows(rows, 2), rows)
def test_rejects_duplicate_product_codes(self):
rows = [{"商品编码": "1"}, {"商品编码": "1"}]
with self.assertRaisesRegex(RuntimeError, "重复"):
validate_collected_rows(rows, 2)
def test_rejects_missing_rows(self):
with self.assertRaisesRegex(RuntimeError, "总条数"):
validate_collected_rows([{"商品编码": "1"}], 2)
def test_acceptance_preflight_skips_before_argument_parsing(monkeypatch):
monkeypatch.setattr(
product_daily,
"preflight_acceptance_login",
lambda: COOKIE_SKIP_EXIT_CODE,
)
monkeypatch.setattr(
product_daily,
"parse_args",
lambda argv: (_ for _ in ()).throw(
AssertionError("arguments must not be parsed after a cookie skip")
),
)
assert product_daily.main([]) == COOKIE_SKIP_EXIT_CODE
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,517 @@
from __future__ import annotations
import json
import sys
from copy import deepcopy
from importlib import import_module
from pathlib import Path
import pytest
from gyxx_flow.catalog import WorkflowCatalog
from gyxx_flow.core.exit_codes import COOKIE_SKIP_EXIT_CODE
from gyxx_flow.modules.shop_intelligence import ShopIntelligenceModule
from gyxx_flow.modules.shop_intelligence.config import DY_PLATFORM, JD_PLATFORM
from gyxx_flow.modules.shop_intelligence.db import db as shop_db
from gyxx_flow.modules.shop_intelligence.quality import validate_peer_payload
run_peer_store = import_module(
"gyxx_flow.modules.shop_intelligence.runners.run_peer_store"
)
PROJECT_ROOT = Path(__file__).resolve().parents[3]
JD_BRANDS = (
"Bellroy官方旗舰店",
"tomtoc官方旗舰店",
"探迹者TAJEZZO官方旗舰店",
"BAGSMART旗舰店",
"GROTTO个乐官方旗舰店",
"NIID箱包旗舰店",
"光影行星 GYXX官方旗舰店",
)
JD_CATEGORIES = (
"男士包袋",
"男士双肩包",
"男士单肩/斜挎包",
"电脑包",
"男士腰包/胸包",
"旅行包",
"休闲运动包",
)
DY_BRANDS = (
"Bellroy",
"tomtoc",
"BAGSMART",
"GROTTO",
"NIID",
"reeyee",
"VAOPER",
"探迹者",
)
DY_CATEGORIES = ("单肩包/斜挎包", "双肩包", "腰包/胸包")
def _jd_ranked_entry(brand: str, seed: int) -> dict:
return {
"rank": str(seed + 1),
"shop": brand,
"amount": f"{seed + 10}万~¥{seed + 20}",
"orders": f"{seed + 20}~{seed + 30}",
"followers": str(seed + 40),
"visitors": f"{seed + 50}~{seed + 60}",
"searchClicks": str(seed + 70),
}
def _jd_unranked_entry(brand: str) -> dict:
return {
"rank": "N/A",
"amount": "N/A",
"orders": "N/A",
"followers": "N/A",
"visitors": "N/A",
"searchClicks": "N/A",
"error": f"当前类目榜单未找到店铺: {brand}",
}
def _jd_payload() -> dict:
return {
"数据": {
brand: {
category: _jd_ranked_entry(brand, brand_index * 20 + category_index)
for category_index, category in enumerate(JD_CATEGORIES)
}
for brand_index, brand in enumerate(JD_BRANDS)
}
}
def _dy_range(seed: int) -> dict:
return {
"min": float(seed),
"max": float(seed + 1),
"open_ended": False,
"raw_values": [str(seed)],
}
def _dy_entry(
brand: str,
category: str,
seed: int,
*,
ranked: bool = True,
matched: bool = True,
) -> dict:
rank = str(seed + 1) if ranked else ""
stores = (
[
{
"shop_name": f"{brand}旗舰店",
"rank": rank,
"row_text": f"{brand}-{seed}",
}
]
if matched
else []
)
return {
"brand": brand,
"category": category,
"matched_shop_count": len(stores),
"rank": rank,
"rank_display": rank or "未进入前200名",
"payment_amount": _dy_range(seed + 10),
"order_count": _dy_range(seed + 20),
"product_click_users": _dy_range(seed + 30),
"stores": stores,
"captured_at": "2026-08-04T10:00:00",
}
def _dy_payload() -> dict:
return {
"categories": list(DY_CATEGORIES),
"brands": list(DY_BRANDS),
"results": {
category: {
brand: _dy_entry(brand, category, category_index * 100 + brand_index)
for brand_index, brand in enumerate(DY_BRANDS)
}
for category_index, category in enumerate(DY_CATEGORIES)
},
}
def test_runner_rejects_success_without_fresh_peer_artifact(monkeypatch) -> None:
marker = ("D:/old-peer.json", 1, 10)
monkeypatch.setattr(run_peer_store, "_artifact_marker", lambda _platform: marker)
result = run_peer_store._run_with_fresh_artifact(
JD_PLATFORM,
lambda: (JD_PLATFORM, 0, "collector output"),
)
assert result[1] == 1
assert result[3] == ""
assert "no fresh peer-store file" in result[2]
def test_runner_passes_only_the_fresh_peer_artifact(
monkeypatch,
tmp_path: Path,
) -> None:
artifact = tmp_path / "new-peer.json"
artifact.write_text(
json.dumps(_jd_payload(), ensure_ascii=False),
encoding="utf-8",
)
marker = (str(artifact), artifact.stat().st_mtime_ns, artifact.stat().st_size)
markers = iter((None, marker))
monkeypatch.setattr(
run_peer_store,
"_artifact_marker",
lambda _platform: next(markers),
)
result = run_peer_store._run_with_fresh_artifact(
JD_PLATFORM,
lambda: (JD_PLATFORM, 0, "ok"),
)
assert result == (JD_PLATFORM, 0, "ok", str(artifact))
def test_runner_rejects_identical_jd_payload_copied_across_categories(
monkeypatch,
tmp_path: Path,
) -> None:
artifact = tmp_path / "bad-peer.json"
payload = _jd_payload()
repeated = _jd_ranked_entry(JD_BRANDS[0], 22)
for category in JD_CATEGORIES:
payload["数据"][JD_BRANDS[0]][category] = repeated
artifact.write_text(
json.dumps(payload, ensure_ascii=False),
encoding="utf-8",
)
marker = (str(artifact), artifact.stat().st_mtime_ns, artifact.stat().st_size)
markers = iter((None, marker))
monkeypatch.setattr(
run_peer_store,
"_artifact_marker",
lambda _platform: next(markers),
)
result = run_peer_store._run_with_fresh_artifact(
JD_PLATFORM,
lambda: (JD_PLATFORM, 0, "ok"),
)
assert result[1] == 1
assert result[3] == ""
assert "identical payload copied across categories" in result[2]
def test_quality_gate_allows_one_unranked_brand_when_other_brands_differ() -> None:
payload = _jd_payload()
brand = JD_BRANDS[0]
payload["数据"][brand] = {
category: _jd_unranked_entry(brand) for category in JD_CATEGORIES
}
assert validate_peer_payload(JD_PLATFORM, payload) == (True, "")
def test_quality_gate_rejects_explicit_category_switch_error() -> None:
payload = _jd_payload()
payload["数据"][JD_BRANDS[0]][JD_CATEGORIES[1]] = {
**_jd_unranked_entry(JD_BRANDS[0]),
"error": "类目切换或榜单刷新失败",
}
valid, reason = validate_peer_payload(JD_PLATFORM, payload)
assert valid is False
assert "类目切换或榜单刷新失败" in reason
def test_direct_jd_peer_persistence_rejects_repeated_payload_before_connecting(
monkeypatch,
tmp_path: Path,
) -> None:
artifact = tmp_path / "bad-peer-direct.json"
payload = _jd_payload()
repeated = _jd_ranked_entry(JD_BRANDS[-2], 22)
for category in JD_CATEGORIES:
payload["数据"][JD_BRANDS[-2]][category] = repeated
artifact.write_text(
json.dumps(payload, ensure_ascii=False),
encoding="utf-8",
)
monkeypatch.setattr(shop_db, "DB_ENABLED", True)
monkeypatch.setattr(
shop_db,
"get_conn",
lambda: pytest.fail("invalid payload must not connect to PostgreSQL"),
)
with pytest.raises(ValueError, match="JD peer-store data quality check failed"):
shop_db.persist_jd_peer_store(str(artifact))
def test_quality_gate_requires_exact_jd_brand_and_category_sets() -> None:
missing_brand = _jd_payload()
missing_brand["数据"].pop(JD_BRANDS[-1])
valid, reason = validate_peer_payload(JD_PLATFORM, missing_brand)
assert valid is False
assert "brand set mismatch" in reason
missing_category = _jd_payload()
missing_category["数据"][JD_BRANDS[0]].pop(JD_CATEGORIES[-1])
valid, reason = validate_peer_payload(JD_PLATFORM, missing_category)
assert valid is False
assert "category set mismatch" in reason
def test_quality_gate_rejects_malformed_jd_ranking_entry() -> None:
payload = _jd_payload()
payload["数据"][JD_BRANDS[0]][JD_CATEGORIES[0]] = "23"
valid, reason = validate_peer_payload(JD_PLATFORM, payload)
assert valid is False
assert "ranking entry is not an object" in reason
def test_quality_gate_allows_verified_dy_unranked_results() -> None:
payload = _dy_payload()
for category in DY_CATEGORIES:
for brand_index, brand in enumerate(DY_BRANDS):
payload["results"][category][brand] = _dy_entry(
brand,
category,
brand_index,
ranked=False,
matched=False,
)
assert validate_peer_payload(DY_PLATFORM, payload) == (True, "")
def test_quality_gate_rejects_identical_dy_data_in_two_categories() -> None:
payload = _dy_payload()
first_category, second_category = DY_CATEGORIES[:2]
for brand in DY_BRANDS:
repeated = deepcopy(payload["results"][first_category][brand])
repeated["category"] = second_category
payload["results"][second_category][brand] = repeated
valid, reason = validate_peer_payload(DY_PLATFORM, payload)
assert valid is False
assert "identical payload copied across categories" in reason
def test_quality_gate_requires_exact_dy_sets_and_entry_structure() -> None:
payload = _dy_payload()
payload["results"][DY_CATEGORIES[0]].pop(DY_BRANDS[-1])
valid, reason = validate_peer_payload(DY_PLATFORM, payload)
assert valid is False
assert "brand set mismatch" in reason
payload = _dy_payload()
payload["results"][DY_CATEGORIES[0]][DY_BRANDS[0]].pop("stores")
valid, reason = validate_peer_payload(DY_PLATFORM, payload)
assert valid is False
assert "ranking fields missing stores" in reason
def test_runner_propagates_cookie_skip_without_writes(monkeypatch) -> None:
monkeypatch.setattr(
sys,
"argv",
["run_peer_store", "--platform", "dy", "--skip-feishu"],
)
monkeypatch.setattr(
run_peer_store,
"setup_runner_logging",
lambda _name: "test.log",
)
monkeypatch.setattr(
run_peer_store,
"skip_feishu_for_acceptance",
lambda *_args, **_kwargs: True,
)
monkeypatch.setattr(
run_peer_store,
"_run_with_fresh_artifact",
lambda platform, _collector: (
platform,
COOKIE_SKIP_EXIT_CODE,
"expired",
"",
),
)
monkeypatch.setattr(
run_peer_store,
"_persist_and_write",
lambda *_args, **_kwargs: pytest.fail("skipped platform must not write"),
)
assert run_peer_store.main() == COOKIE_SKIP_EXIT_CODE
def test_persist_zero_is_reported_as_failure_and_feishu_can_be_skipped(
monkeypatch,
) -> None:
monkeypatch.setattr(run_peer_store, "persist_jd_peer_store", lambda _path: 0)
monkeypatch.setattr(
run_peer_store,
"write_peer_store",
lambda *_args, **_kwargs: pytest.fail("Feishu must remain skipped"),
)
errors = run_peer_store._persist_and_write(
JD_PLATFORM,
True,
"D:/fresh-peer.json",
skip_feishu=True,
)
assert errors == [f"{JD_PLATFORM} DB 持久化失败: 未写入任何记录"]
def test_persist_zero_stops_peer_feishu_write(monkeypatch) -> None:
monkeypatch.setattr(run_peer_store, "persist_jd_peer_store", lambda _path: 0)
monkeypatch.setattr(
run_peer_store,
"write_peer_store",
lambda *_args, **_kwargs: pytest.fail("Feishu must not run after DB failure"),
)
errors = run_peer_store._persist_and_write(
JD_PLATFORM,
True,
"D:/fresh-peer.json",
)
assert errors == [f"{JD_PLATFORM} DB 持久化失败: 未写入任何记录"]
def test_dy_peer_rejects_shop_json_from_another_week_before_writes(
monkeypatch,
tmp_path: Path,
) -> None:
peer_json = tmp_path / "peer.json"
peer_json.write_text(
json.dumps({"date_range": "2026/07/27 - 2026/08/02"}),
encoding="utf-8",
)
shop_json = tmp_path / "shop.json"
shop_json.write_text(
json.dumps({"date_range": "2026/07/20 - 2026/07/26"}),
encoding="utf-8",
)
monkeypatch.setattr(
run_peer_store,
"_latest_shop_json_for_dy",
lambda: str(shop_json),
)
monkeypatch.setattr(
run_peer_store,
"persist_dy_peer_store",
lambda _path: pytest.fail("mismatched snapshots must fail before PG"),
)
monkeypatch.setattr(
run_peer_store,
"write_peer_store",
lambda *_args, **_kwargs: pytest.fail("mismatched snapshots must not write Feishu"),
)
errors = run_peer_store._persist_and_write(
DY_PLATFORM,
True,
str(peer_json),
)
assert len(errors) == 1
assert "周区间不一致" in errors[0]
def test_dy_peer_passes_matching_shop_json_to_writer(
monkeypatch,
tmp_path: Path,
) -> None:
peer_json = tmp_path / "peer.json"
peer_json.write_text(
json.dumps({"date_range": "2026/07/27 - 2026/08/02"}),
encoding="utf-8",
)
shop_json = tmp_path / "shop.json"
shop_json.write_text(
json.dumps({"date_range": "2026-07-27 ~ 2026-08-02"}),
encoding="utf-8",
)
observed = {}
monkeypatch.setattr(
run_peer_store,
"_latest_shop_json_for_dy",
lambda: str(shop_json),
)
monkeypatch.setattr(run_peer_store, "persist_dy_peer_store", lambda _path: 3)
def fake_write(platform, actual_peer_json, actual_shop_json):
observed.update(
platform=platform,
peer_json=actual_peer_json,
shop_json=actual_shop_json,
)
return True
monkeypatch.setattr(run_peer_store, "write_peer_store", fake_write)
errors = run_peer_store._persist_and_write(
DY_PLATFORM,
True,
str(peer_json),
)
assert errors == []
assert observed == {
"platform": DY_PLATFORM,
"peer_json": str(peer_json),
"shop_json": str(shop_json),
}
def test_false_feishu_writer_result_is_reported(monkeypatch) -> None:
monkeypatch.setattr(run_peer_store, "persist_jd_peer_store", lambda _path: 3)
monkeypatch.setattr(run_peer_store, "write_peer_store", lambda *_args: False)
errors = run_peer_store._persist_and_write(
JD_PLATFORM,
True,
"D:/fresh-peer.json",
)
assert errors == [f"{JD_PLATFORM} 竞店飞书写入失败: writer 返回 False"]
def test_competitor_platforms_are_independent_graph_steps() -> None:
module = ShopIntelligenceModule.from_catalog(
WorkflowCatalog.load(PROJECT_ROOT / "config")
)
competitor = next(
definition
for definition in module.workflow_definitions()
if definition.workflow_id == "shop.competitor.weekly"
)
assert tuple(step.step_id for step in competitor.steps) == ("jd", "dy")
assert all(not step.depends_on for step in competitor.steps)
assert tuple(step.action.command_args for step in competitor.steps) == (
("--platform", "jd"),
("--platform", "dy"),
)
@@ -0,0 +1,39 @@
import unittest
from datetime import date
from gyxx_flow.modules.shop_intelligence.runners.run_jd_self_operated_daily import (
build_collector_args,
previous_day,
)
class TargetDateTests(unittest.TestCase):
def test_previous_day_handles_month_boundary(self):
self.assertEqual(previous_day(date(2026, 8, 1)), date(2026, 7, 31))
def test_previous_day_handles_year_boundary(self):
self.assertEqual(previous_day(date(2027, 1, 1)), date(2026, 12, 31))
class CollectorArgsTests(unittest.TestCase):
def test_builds_single_day_headless_args(self):
self.assertEqual(
build_collector_args(date(2026, 7, 30), headless=True),
[
"--start-date",
"2026-07-30",
"--end-date",
"2026-07-30",
"--headless",
],
)
def test_headful_args_omit_headless_flag(self):
self.assertNotIn(
"--headless",
build_collector_args(date(2026, 7, 30), headless=False),
)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,56 @@
from __future__ import annotations
import json
from pathlib import Path
from types import SimpleNamespace
from gyxx_flow.modules.shop_intelligence import lark_cli
def _enable_acceptance(monkeypatch, tmp_path: Path) -> Path:
evidence = tmp_path / "shop-evidence.jsonl"
monkeypatch.setenv("GYXX_WORKFLOW_ACCEPTANCE", "1")
monkeypatch.setenv("GYXX_ACCEPTANCE_EVIDENCE_FILE", str(evidence))
monkeypatch.delenv("GYXX_NOTIFICATION_RECIPIENT_OPEN_ID", raising=False)
return evidence
def test_shop_shared_lark_cli_physically_skips_bitable_write(
monkeypatch,
tmp_path: Path,
) -> None:
evidence = _enable_acceptance(monkeypatch, tmp_path)
def fail_if_spawned(*args, **kwargs):
raise AssertionError("lark-cli subprocess must not start")
monkeypatch.setattr(lark_cli.subprocess, "run", fail_if_spawned)
result = lark_cli.run_lark_cli(
"node",
"run.js",
["base", "+record-batch-update", "--table-id", "tbl_test"],
)
assert result["acceptance_skipped"] is True
payload = json.loads(evidence.read_text(encoding="utf-8"))
assert payload["event"] == "feishu_write_skipped"
assert payload["operation"].endswith("record-batch-update")
def test_shop_shared_lark_cli_keeps_production_write_behavior(monkeypatch) -> None:
monkeypatch.delenv("GYXX_WORKFLOW_ACCEPTANCE", raising=False)
calls = []
def fake_run(command, **kwargs):
calls.append(command)
return SimpleNamespace(returncode=0, stdout='{"ok": true}', stderr="")
monkeypatch.setattr(lark_cli.subprocess, "run", fake_run)
result = lark_cli.run_lark_cli(
"node",
"run.js",
["base", "+record-batch-create", "--table-id", "tbl_test"],
)
assert result == {"ok": True}
assert len(calls) == 1
@@ -0,0 +1,916 @@
from __future__ import annotations
import ast
import inspect
import json
import sys
from datetime import date
from importlib import import_module
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
from gyxx_flow.core.exit_codes import COOKIE_SKIP_EXIT_CODE
from gyxx_flow.modules.shop_intelligence import lark_cli as shop_lark_cli
from gyxx_flow.modules.shop_intelligence.collectors import (
jd_data_collector,
jd_peer_store_data_collector,
jd_self_operated_brand_daily,
taobao_sycm,
)
from gyxx_flow.modules.shop_intelligence.db import db as shop_db
from gyxx_flow.modules.shop_intelligence.lark_cli import (
format_week,
normalize_week_label,
week_search_prefix,
)
from gyxx_flow.modules.shop_intelligence.quality import validate_shop_payload
from gyxx_flow.modules.shop_intelligence.writers import shop_base_writer
run_shop = import_module("gyxx_flow.modules.shop_intelligence.runners.run_shop")
def test_jd_playwright_wait_for_function_passes_argument_by_keyword() -> None:
tree = ast.parse(
Path(jd_data_collector.__file__).read_text(encoding="utf-8-sig")
)
calls = [
node
for node in ast.walk(tree)
if isinstance(node, ast.Call)
and isinstance(node.func, ast.Attribute)
and node.func.attr == "wait_for_function"
]
assert calls
assert all(len(call.args) == 1 for call in calls)
def test_jd_cross_month_week_uses_visible_iso_week_row() -> None:
source = inspect.getsource(jd_data_collector.select_last_week_any_day)
assert "target_year = last_sunday.year" in source
assert "jmtd-date-picker-header-btn-prev-month" in source
assert "jmtd-date-picker-week-number-cell" in source
assert "clicked_iso_week_row_" in source
assert "jmtd-date-picker-combo-tag-echo" in source
def test_jd_navigation_retries_transient_failures() -> None:
class Page:
def __init__(self) -> None:
self.attempts = 0
self.waits: list[int] = []
def goto(self, *_args, **_kwargs) -> None:
self.attempts += 1
if self.attempts < 3:
raise RuntimeError("net::ERR_PROXY_CONNECTION_FAILED")
def wait_for_timeout(self, timeout_ms: int) -> None:
self.waits.append(timeout_ms)
page = Page()
jd_data_collector.goto_with_retry(page, "https://example.invalid")
assert page.attempts == 3
assert page.waits == [3000, 6000]
def test_jd_waits_for_summary_card_hydration_after_week_switch() -> None:
source = inspect.getsource(jd_data_collector.step_shop_star_and_trade)
assert "page.wait_for_timeout(8000)" in source
assert ".sz-summary-item" in source
assert ".sz-summary-item-index [data-value]" in source
assert "real-time-module-right-shop-level-content-my-level-data-value" in source
def test_jd_star_accepts_current_bare_score_value() -> None:
assert jd_data_collector._extract_jd_star("5.0") == "5.0星"
assert jd_data_collector._extract_jd_star("5.0详情") == "5.0星"
assert jd_data_collector._extract_jd_star("未获取到") == "未获取到"
def test_week_label_is_canonical_and_historical_padding_is_equivalent() -> None:
week_label, month_label, *_ = format_week("2026-07-27", "2026-08-02")
assert week_label == "7月第5周(7.27-8.2"
assert month_label == "2026.07月"
assert normalize_week_label("7月第5周(7.27-8.02") == normalize_week_label(
week_label
)
assert normalize_week_label(" 7月第5周 ( 7.27 - 8.02 ) ") == normalize_week_label(
week_label
)
assert week_search_prefix("7月第5周(7.27-8.02") == "7月第5周"
def test_find_existing_record_matches_padding_but_not_another_year(
monkeypatch,
) -> None:
observed = {}
def fake_run(_node, _script, args, **_kwargs):
observed["payload"] = json.loads(args[-1])
return {
"data": {
"fields": ["time", "platform", "month"],
"data": [
["7月第5周(7.27-8.02", ["DY"], ["2025.07月"]],
["7月第5周(7.27-8.02", ["DY"], ["2026.07月"]],
],
"record_id_list": ["old-year", "same-week"],
}
}
monkeypatch.setattr(shop_lark_cli, "run_lark_cli", fake_run)
fields = {
"时间": "time",
"平台": "platform",
"月份": "month",
}
record_id = shop_lark_cli.find_existing_record(
"node",
"script",
"base",
"table",
fields,
"7月第5周(7.27-8.2",
"DY",
month_str="2026.07月",
)
assert record_id == "same-week"
assert observed["payload"]["keyword"] == "7月第5周"
def test_dy_shop_feishu_sales_uses_confirmed_net_sales_amount() -> None:
extracted = shop_base_writer._extract_dy_metrics(
{
"date_range": "2026/07/27 - 2026/08/02",
"metrics": {
"成交金额": "¥413,988.46",
"成交退款金额": "¥119,095.48",
},
"derived_metrics": {"raw": {"销售额": 294892.98}},
"experience_score": {"score": "95"},
}
)
assert extracted["sales_amount"] == 294892.98
assert extracted["star_rating"] == 95.0
@pytest.mark.parametrize(
"invalid_value",
["加载失败", "未采集到", "¥", "abc", float("nan"), -0.01, "5星"],
)
def test_jd_shop_quality_rejects_invalid_or_negative_sales(invalid_value) -> None:
valid, reason = validate_shop_payload(
run_shop.JD_PLATFORM,
{
"transaction": {"成交金额": {"value": invalid_value}},
"star_rating_raw": "5.0星",
},
)
assert valid is False
assert "成交金额" in reason
@pytest.mark.parametrize(
"invalid_value",
["加载失败", "未采集到", "¥", "abc", float("nan"), -0.01, 5.01, "¥4.9"],
)
def test_jd_shop_quality_rejects_invalid_or_out_of_range_score(
invalid_value,
) -> None:
valid, reason = validate_shop_payload(
run_shop.JD_PLATFORM,
{
"transaction": {"成交金额": {"value": "¥311,671.16"}},
"star_rating_raw": invalid_value,
},
)
assert valid is False
assert "店铺评分" in reason
@pytest.mark.parametrize(
("field", "invalid_value", "reason_text"),
[
("gross", "加载失败", "成交金额"),
("gross", "¥", "成交金额"),
("gross", float("nan"), "成交金额"),
("gross", -1, "成交金额"),
("net", "abc", "净销售额"),
("net", float("nan"), "净销售额"),
("net", -1, "净销售额"),
("score", "未采集到", "店铺评分"),
("score", float("nan"), "店铺评分"),
("score", -1, "店铺评分"),
("score", 100.01, "店铺评分"),
],
)
def test_dy_shop_quality_rejects_invalid_metrics(
field,
invalid_value,
reason_text,
) -> None:
gross = "¥413,988.46"
net = 294892.98
score = "95分"
if field == "gross":
gross = invalid_value
elif field == "net":
net = invalid_value
else:
score = invalid_value
valid, reason = validate_shop_payload(
run_shop.DY_PLATFORM,
{
"metrics": {"成交金额": gross},
"derived_metrics": {"raw": {"销售额": net}},
"experience_score": {"score": score},
},
)
assert valid is False
assert reason_text in reason
def test_shop_quality_accepts_strict_real_metric_formats() -> None:
assert validate_shop_payload(
run_shop.JD_PLATFORM,
{
"transaction": {"成交金额": {"value": "¥311,671.16"}},
"star_rating_raw": "5.0星",
},
) == (True, "")
assert validate_shop_payload(
run_shop.JD_PLATFORM,
{
"transaction": {"成交金额": {"value": "¥264,650.81"}},
"star_rating_raw": (
"店铺星级星级运营店铺星级我的店铺星级4.7详情较昨日持平"
),
},
) == (True, "")
assert validate_shop_payload(
run_shop.DY_PLATFORM,
{
"metrics": {"成交金额": "¥413,988.46"},
"derived_metrics": {"raw": {"销售额": 294892.98}},
"experience_score": {"score": "95分"},
},
) == (True, "")
def test_jd_shop_feishu_rejects_missing_metrics_instead_of_writing_zero() -> None:
with pytest.raises(ValueError, match="缺少有效成交金额、店铺评分"):
shop_base_writer._extract_jd_metrics(
{
"week_start": "2026-07-27",
"week_end": "2026-08-02",
"star_rating_raw": "未获取到",
"transaction": {"成交金额": {"value": "N/A"}},
}
)
def test_jd_current_dom_metric_text_and_star_are_normalized() -> None:
assert jd_data_collector._normalize_jd_metric_text(
"\n3\n1\n1\n,\n6\n7\n1\n.\n1\n6"
) == "¥311,671.16"
assert jd_data_collector._extract_jd_star(
"店铺星级\n星级运营\n我的店铺星级\n5.0详情\n较昨日持平"
) == "5.0星"
def test_jd_week_event_suffix_uses_iso_week() -> None:
assert jd_data_collector._week_event_suffix(
jd_data_collector.datetime(2026, 7, 27)
) == "2026-31"
def test_runner_rejects_success_without_fresh_artifact(monkeypatch) -> None:
marker = ("D:/old.json", 1, 10)
monkeypatch.setattr(run_shop, "_artifact_marker", lambda _platform: marker)
result = run_shop._run_with_fresh_artifact(
"JD",
lambda: ("JD", 0, "collector output"),
)
assert result[1] == 1
assert result[3] == ""
assert "no fresh metrics file" in result[2]
def test_runner_passes_only_the_new_artifact(monkeypatch) -> None:
markers = iter((None, ("D:/new.json", 2, 20)))
monkeypatch.setattr(run_shop, "_artifact_marker", lambda _platform: next(markers))
monkeypatch.setattr(
run_shop,
"_validate_metrics_artifact",
lambda _platform, _path: (True, ""),
)
result = run_shop._run_with_fresh_artifact(
"JD",
lambda: ("JD", 0, "ok"),
)
assert result == ("JD", 0, "ok", "D:/new.json")
def test_runner_rejects_fresh_but_empty_metrics_artifact(
monkeypatch,
tmp_path: Path,
) -> None:
artifact = tmp_path / "dy.json"
artifact.write_text('{"metrics": {}}', encoding="utf-8")
markers = iter((None, (str(artifact), 2, artifact.stat().st_size)))
monkeypatch.setattr(run_shop, "_artifact_marker", lambda _platform: next(markers))
result = run_shop._run_with_fresh_artifact(
run_shop.DY_PLATFORM,
lambda: (run_shop.DY_PLATFORM, 0, "collector output"),
)
assert result[1] == 1
assert result[3] == ""
assert "shop metrics are empty" in result[2]
def test_runner_rejects_jd_placeholder_metrics_before_external_writes(
monkeypatch,
tmp_path: Path,
) -> None:
artifact = tmp_path / "jd.json"
artifact.write_text(
json.dumps(
{
"star_rating_raw": "未获取到",
"transaction": {"成交金额": {"value": "N/A"}},
},
ensure_ascii=False,
),
encoding="utf-8",
)
marker = (str(artifact), artifact.stat().st_mtime_ns, artifact.stat().st_size)
markers = iter((None, marker))
monkeypatch.setattr(run_shop, "_artifact_marker", lambda _platform: next(markers))
result = run_shop._run_with_fresh_artifact(
run_shop.JD_PLATFORM,
lambda: (run_shop.JD_PLATFORM, 0, "collector output"),
)
assert result[1] == 1
assert result[3] == ""
assert "missing valid 成交金额/店铺评分" in result[2]
def test_direct_jd_persistence_rejects_placeholder_metrics_before_connecting(
monkeypatch,
tmp_path: Path,
) -> None:
artifact = tmp_path / "jd-invalid.json"
artifact.write_text(
json.dumps(
{
"star_rating_raw": "未获取到",
"transaction": {"成交金额": {"value": "N/A"}},
},
ensure_ascii=False,
),
encoding="utf-8",
)
monkeypatch.setattr(shop_db, "DB_ENABLED", True)
monkeypatch.setattr(
shop_db,
"get_conn",
lambda: pytest.fail("invalid payload must not connect to PostgreSQL"),
)
with pytest.raises(ValueError, match="JD shop data quality check failed"):
shop_db.persist_jd_shop(str(artifact))
def test_jd_pg_transaction_value_does_not_depend_on_item_key_order() -> None:
metrics = {
"shop": "test-shop",
"week_start": "2026-07-27",
"week_end": "2026-08-02",
"star_rating_raw": "5.0",
"transaction": {
"成交金额": {
"环比": "-19.8%",
"同行同级均值": "¥999.00",
"value": "¥311,671.16",
}
},
}
row = shop_db._build_jd_shop_row(metrics)
assert row is not None
assert row["deal_amount"] == 311671.16
assert row["deal_amount_qoq"] == "-19.8%"
assert row["deal_amount_peer_avg"] == "¥999.00"
@pytest.mark.parametrize(
("platform", "persist_name", "platform_code"),
[
(run_shop.JD_PLATFORM, "persist_jd_shop", "JD"),
(run_shop.DY_PLATFORM, "persist_dy_shop", "DY"),
],
)
@pytest.mark.parametrize("persisted_count", [0, -1])
def test_shop_nonpositive_persistence_stops_feishu_write(
monkeypatch,
platform: str,
persist_name: str,
platform_code: str,
persisted_count: int,
) -> None:
monkeypatch.setattr(run_shop, "ensure_ai_analysis", lambda *_args: "")
monkeypatch.setattr(run_shop, persist_name, lambda _path: persisted_count)
monkeypatch.setattr(
run_shop,
"write_shop",
lambda *_args: pytest.fail("Feishu must not run after DB failure"),
)
errors = run_shop._persist_and_write(platform, True, "D:/fresh-shop.json")
assert errors == [f"{platform_code} DB 持久化失败: 未写入任何记录"]
@pytest.mark.parametrize(
("platform", "persist_name", "platform_code"),
[
(run_shop.JD_PLATFORM, "persist_jd_shop", "JD"),
(run_shop.DY_PLATFORM, "persist_dy_shop", "DY"),
],
)
def test_shop_false_writer_result_is_reported(
monkeypatch,
platform: str,
persist_name: str,
platform_code: str,
) -> None:
monkeypatch.setattr(run_shop, "ensure_ai_analysis", lambda *_args: "")
monkeypatch.setattr(run_shop, persist_name, lambda _path: 1)
monkeypatch.setattr(run_shop, "write_shop", lambda *_args: False)
errors = run_shop._persist_and_write(platform, True, "D:/fresh-shop.json")
assert errors == [f"{platform_code} 飞书写入失败: writer 返回 False"]
def test_runner_propagates_cookie_skip_without_writes(monkeypatch) -> None:
monkeypatch.setattr(
sys,
"argv",
["run_shop", "--platform", "dy", "--skip-feishu"],
)
monkeypatch.setattr(run_shop, "setup_runner_logging", lambda _name: "test.log")
monkeypatch.setattr(
run_shop,
"skip_feishu_for_acceptance",
lambda *_args, **_kwargs: True,
)
monkeypatch.setattr(
run_shop,
"_run_with_fresh_artifact",
lambda platform, _collector: (
platform,
COOKIE_SKIP_EXIT_CODE,
"expired",
"",
),
)
monkeypatch.setattr(
run_shop,
"_persist_and_write",
lambda *_args, **_kwargs: pytest.fail("skipped platform must not write"),
)
assert run_shop.main() == COOKIE_SKIP_EXIT_CODE
def test_shop_analysis_uses_local_analyzer_hermes(monkeypatch) -> None:
observed = {}
class Response:
def raise_for_status(self) -> None:
return None
def json(self) -> dict:
return {"choices": [{"message": {"content": "analysis"}}]}
class Session:
trust_env = True
def post(self, url, **kwargs):
observed["session"] = self
observed["url"] = url
observed.update(kwargs)
return Response()
monkeypatch.setenv(
"HERMES_ANALYZER_URL",
"http://127.0.0.1:8642/v1/chat/completions",
)
monkeypatch.setenv("HERMES_ANALYZER_TOKEN", "test-token")
monkeypatch.setattr(shop_base_writer.requests, "Session", Session)
assert shop_base_writer._call_hermes("test prompt") == "analysis"
assert observed["url"].startswith("http://127.0.0.1:8642/")
assert observed["session"].trust_env is False
assert observed["headers"]["Authorization"] == "Bearer test-token"
def test_shop_analysis_falls_back_to_local_cli_when_gateway_requires_auth(
monkeypatch,
) -> None:
class Response:
status_code = 401
def raise_for_status(self) -> None:
error = shop_base_writer.requests.HTTPError("unauthorized")
error.response = self
raise error
class Session:
trust_env = True
def post(self, *_args, **_kwargs):
return Response()
monkeypatch.setattr(shop_base_writer.requests, "Session", Session)
monkeypatch.setattr(
shop_base_writer,
"_call_hermes_cli",
lambda prompt, *, timeout: f"cli:{prompt}:{timeout}",
)
assert shop_base_writer._call_hermes("prompt", timeout=12) == "cli:prompt:12"
def test_shop_analysis_reads_analyzer_profile_gateway_key(
monkeypatch,
tmp_path: Path,
) -> None:
profile = tmp_path / "profiles" / "data-analyzer"
profile.mkdir(parents=True)
(profile / ".env").write_text(
"API_SERVER_KEY=profile-key\n",
encoding="utf-8",
)
observed = {}
class Response:
def raise_for_status(self) -> None:
return None
def json(self) -> dict:
return {"choices": [{"message": {"content": "analysis"}}]}
class Session:
trust_env = True
def post(self, _url, **kwargs):
observed.update(kwargs)
return Response()
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
monkeypatch.delenv("HERMES_ANALYZER_TOKEN", raising=False)
monkeypatch.delenv("GYXX_HERMES_API_KEY", raising=False)
monkeypatch.delenv("HERMES_API_KEY", raising=False)
monkeypatch.setattr(shop_base_writer.requests, "Session", Session)
assert shop_base_writer._call_hermes("prompt") == "analysis"
assert observed["headers"]["Authorization"] == "Bearer profile-key"
def test_jd_shop_uses_bound_profile_port_and_persists_state(
monkeypatch,
tmp_path: Path,
) -> None:
page = object()
observed = {}
class Context:
pages = [page]
closed = False
def cookies(self):
return [{"name": "session", "value": "saved"}]
def storage_state(self, *, path: str) -> None:
Path(path).write_text('{"cookies": []}', encoding="utf-8")
def close(self) -> None:
self.closed = True
context = Context()
class Chromium:
def launch_persistent_context(self, **kwargs):
observed.update(kwargs)
return context
class Playwright:
chromium = Chromium()
def __enter__(self):
return self
def __exit__(self, *_args) -> None:
return None
cookie_file = tmp_path / "state" / "cookies.json"
storage_file = tmp_path / "state" / "storage.json"
profile = tmp_path / "profile"
monkeypatch.setenv("GYXX_BROWSER_PROFILE_DIR", str(profile))
monkeypatch.setenv("GYXX_BROWSER_CDP_PORT", "22105")
monkeypatch.setenv("GYXX_BROWSER_COOKIE_FILE", str(cookie_file))
monkeypatch.setenv("GYXX_BROWSER_STORAGE_STATE_FILE", str(storage_file))
monkeypatch.setattr(jd_data_collector, "sync_playwright", Playwright)
monkeypatch.setattr(jd_data_collector, "step_login", lambda *_args: None)
monkeypatch.setattr(
jd_data_collector,
"step_enter_shangzhi",
lambda *_args: page,
)
monkeypatch.setattr(
jd_data_collector,
"step_shop_star_and_trade",
lambda *_args: ({}, {}),
)
monkeypatch.setattr(jd_data_collector, "step_deal_summary", lambda *_args: {})
monkeypatch.setattr(jd_data_collector, "step_merchant_ranking", lambda *_args: {})
monkeypatch.setattr(jd_data_collector, "step_jzt_non_full_site", lambda *_args: {})
monkeypatch.setattr(jd_data_collector, "step_jzt_full_site", lambda *_args: {})
monkeypatch.setattr(
jd_data_collector,
"generate_report",
lambda *_args: str(tmp_path / "report.json"),
)
assert jd_data_collector.main(["--password", "test-only"]) == 0
assert observed["user_data_dir"] == str(profile)
assert "--remote-debugging-port=22105" in observed["args"]
assert "--no-proxy-server" in observed["args"]
assert json.loads(cookie_file.read_text(encoding="utf-8"))[0]["name"] == "session"
assert storage_file.is_file()
assert context.closed is True
def test_jd_peer_uses_bound_profile_port_and_persists_state(
monkeypatch,
tmp_path: Path,
) -> None:
page = object()
observed = {}
class Context:
pages = [page]
closed = False
def cookies(self):
return [{"name": "peer-session", "value": "saved"}]
def storage_state(self, *, path: str) -> None:
Path(path).write_text('{"cookies": []}', encoding="utf-8")
def close(self) -> None:
self.closed = True
context = Context()
class Chromium:
def launch_persistent_context(self, **kwargs):
observed.update(kwargs)
return context
class Playwright:
chromium = Chromium()
def __enter__(self):
return self
def __exit__(self, *_args) -> None:
return None
cookie_file = tmp_path / "state" / "cookies.json"
storage_file = tmp_path / "state" / "storage.json"
profile = tmp_path / "profile"
monkeypatch.setenv("GYXX_BROWSER_PROFILE_DIR", str(profile))
monkeypatch.setenv("GYXX_BROWSER_CDP_PORT", "22106")
monkeypatch.setenv("GYXX_BROWSER_COOKIE_FILE", str(cookie_file))
monkeypatch.setenv("GYXX_BROWSER_STORAGE_STATE_FILE", str(storage_file))
monkeypatch.setattr(jd_peer_store_data_collector, "sync_playwright", Playwright)
monkeypatch.setattr(jd_peer_store_data_collector, "step_login", lambda *_args: None)
monkeypatch.setattr(
jd_peer_store_data_collector,
"step_enter_shangzhi",
lambda *_args: page,
)
monkeypatch.setattr(
jd_peer_store_data_collector,
"step_return_old_version",
lambda *_args: None,
)
monkeypatch.setattr(
jd_peer_store_data_collector,
"step_goto_vender_ranks",
lambda *_args: None,
)
monkeypatch.setattr(
jd_peer_store_data_collector,
"step_collect_competitor_data",
lambda *_args: {},
)
monkeypatch.setattr(
jd_peer_store_data_collector,
"save_results",
lambda *_args: str(tmp_path / "peer.json"),
)
monkeypatch.setattr(
jd_peer_store_data_collector,
"step_goto_product_ranks",
lambda *_args: None,
)
monkeypatch.setattr(
jd_peer_store_data_collector,
"collect_new_products",
lambda *_args: {},
)
monkeypatch.setattr(
jd_peer_store_data_collector,
"save_new_product_results",
lambda *_args: None,
)
assert jd_peer_store_data_collector.main(["--password", "test-only"]) == 0
assert observed["user_data_dir"] == str(profile)
assert "--remote-debugging-port=22106" in observed["args"]
assert json.loads(cookie_file.read_text(encoding="utf-8"))[0]["name"] == "peer-session"
assert storage_file.is_file()
assert context.closed is True
def test_jd_peer_selects_week_once_before_category_loop(monkeypatch) -> None:
calls = []
monkeypatch.setattr(
jd_peer_store_data_collector,
"SHOPS",
["peer-shop"],
)
monkeypatch.setattr(
jd_peer_store_data_collector,
"CATEGORIES",
[("男士双肩包", "男包")],
)
monkeypatch.setattr(
jd_peer_store_data_collector,
"select_last_week_any_day",
lambda _page: calls.append("initial-week"),
)
monkeypatch.setattr(
jd_peer_store_data_collector,
"search_shop",
lambda *_args: calls.append("search"),
)
monkeypatch.setattr(
jd_peer_store_data_collector,
"switch_category",
lambda *_args: calls.append("category"),
)
monkeypatch.setattr(
jd_peer_store_data_collector,
"reselect_last_week",
lambda _page: calls.append("reselect-week"),
)
monkeypatch.setattr(
jd_peer_store_data_collector,
"extract_ranking_table",
lambda _page: [],
)
page = SimpleNamespace(wait_for_timeout=lambda *_args: None)
jd_peer_store_data_collector.step_collect_competitor_data(page)
assert calls == ["initial-week", "search", "category"]
def test_jd_peer_product_rank_navigation_does_not_wait_for_network_idle() -> None:
observed: dict[str, object] = {}
class Page:
def goto(self, url: str, **kwargs) -> None:
observed["url"] = url
observed.update(kwargs)
def wait_for_timeout(self, timeout_ms: int) -> None:
observed["settle_ms"] = timeout_ms
jd_peer_store_data_collector.step_goto_product_ranks(Page())
assert observed == {
"url": "https://sz.jd.com/szweb/sz/view/industryMarket/productRanks.html",
"wait_until": "domcontentloaded",
"timeout": 60000,
"settle_ms": 8000,
}
def test_jd_self_operated_reports_date_not_yet_available() -> None:
page = MagicMock()
mode = MagicMock()
mode.count.return_value = 0
date_picker = MagicMock()
dropdown = MagicMock()
enabled_target = MagicMock()
disabled_target = MagicMock()
selectable = MagicMock()
enabled_target.count.return_value = 0
disabled_target.count.return_value = 1
selectable.evaluate_all.return_value = [
"2026-08-01",
"2026-08-02",
None,
]
def locate(selector: str):
if selector == ".jd-select-selection-item":
return SimpleNamespace(first=mode)
if selector == ".jd-picker-range":
return SimpleNamespace(first=date_picker)
if selector == ".jd-picker-dropdown:visible":
return SimpleNamespace(first=dropdown)
raise AssertionError(selector)
def locate_dropdown(selector: str):
if selector == (
"td[title].jd-picker-cell-in-view:not(.jd-picker-cell-disabled)"
):
return selectable
if selector.endswith(":not(.jd-picker-cell-disabled)"):
return enabled_target
if selector.endswith(".jd-picker-cell-disabled"):
return disabled_target
raise AssertionError(selector)
page.locator.side_effect = locate
dropdown.locator.side_effect = locate_dropdown
with pytest.raises(RuntimeError, match="当前最晚可选日期为 2026-08-02"):
jd_self_operated_brand_daily.set_single_day(
page,
date(2026, 8, 3),
)
def test_tmall_returns_failure_when_browser_cannot_start(monkeypatch) -> None:
monkeypatch.setattr(taobao_sycm, "sync_cookies_from_daily_profile", lambda *_args: 0)
monkeypatch.setattr(taobao_sycm, "connect_browser", lambda: (None, None))
assert taobao_sycm.main() == 1
def test_tmall_profile_seed_preserves_cookie_encryption_state(tmp_path: Path) -> None:
source = tmp_path / "source"
target = tmp_path / "target"
(source / "Default" / "Network").mkdir(parents=True)
(source / "Local State").write_text("encryption-state", encoding="utf-8")
(source / "Default" / "Network" / "Cookies").write_bytes(b"cookie-db")
copied = taobao_sycm.sync_cookies_from_daily_profile(source, target)
assert copied >= 2
assert (target / "Local State").read_text(encoding="utf-8") == "encryption-state"
assert (target / "Default" / "Network" / "Cookies").read_bytes() == b"cookie-db"
def test_tmall_close_always_releases_bound_cdp(monkeypatch) -> None:
closed = []
killed = []
monkeypatch.setattr(taobao_sycm, "DEBUG_PORT", 22107)
monkeypatch.setattr(taobao_sycm, "_kill_port_listeners", killed.append)
taobao_sycm._close_session(SimpleNamespace(close=lambda: closed.append(True)))
assert closed == [True]
assert killed == [22107]
@@ -0,0 +1,208 @@
from __future__ import annotations
import json
import os
import subprocess
import time
from pathlib import Path
from types import SimpleNamespace
import psutil
import pytest
from gyxx_flow.adapters import COOKIE_SKIP_EXIT_CODE
from gyxx_flow.modules.shop_intelligence.runners import utils
def test_runner_rebinds_browser_environment_after_caller_overrides(monkeypatch) -> None:
observed: dict[str, object] = {}
def fake_rebind(target, base_environment):
observed["target"] = Path(target)
observed["base"] = dict(base_environment)
return {
**base_environment,
"GYXX_SCRIPT_ID": "shop_intelligence:collectors/example.py",
"GYXX_BROWSER_CDP_PORT": "child-port",
"GYXX_BROWSER_PROFILE_DIR": "child-profile",
"GYXX_BROWSER_COOKIE_FILE": "child-cookie",
"GYXX_BROWSER_STORAGE_STATE_FILE": "child-storage",
}
def fake_run(command, *, environment, timeout):
observed["command"] = command
observed["environment"] = environment
observed["timeout"] = timeout
return subprocess.CompletedProcess(command, 0, stdout=b"ok", stderr=b"")
monkeypatch.setattr(utils, "environment_for_child_script", fake_rebind)
monkeypatch.setattr(utils, "_run_managed_command", fake_run)
target = str(Path(utils.PROJECT_ROOT) / "collectors" / "example.py")
code, output = utils.run_collector(
"example",
target,
[],
env={"KEEP": "yes", "GYXX_BROWSER_CDP_PORT": "caller-port"},
)
environment = observed["environment"]
assert code == 0
assert output == "ok"
assert observed["target"] == Path(target)
assert observed["base"]["GYXX_BROWSER_CDP_PORT"] == "caller-port"
assert environment["KEEP"] == "yes"
assert environment["GYXX_BROWSER_CDP_PORT"] == "child-port"
assert environment["GYXX_BROWSER_PROFILE_DIR"] == "child-profile"
assert environment["GYXX_BROWSER_COOKIE_FILE"] == "child-cookie"
assert environment["GYXX_BROWSER_STORAGE_STATE_FILE"] == "child-storage"
assert observed["timeout"] == 3600
def test_runner_skips_invalid_cookie_before_starting_child(monkeypatch) -> None:
observed: dict[str, object] = {}
def fake_rebind(target, base_environment):
return {
**base_environment,
"GYXX_WORKFLOW_ACCEPTANCE": "1",
"GYXX_SCRIPT_ID": "shop_intelligence:collectors/example.py",
}
class FakePolicy:
enabled = True
def preflight_cookie(self, binding, *, environment):
observed["binding"] = binding
observed["environment"] = environment
return SimpleNamespace(should_skip=True, reason="browser state is missing")
monkeypatch.setattr(utils, "environment_for_child_script", fake_rebind)
monkeypatch.setattr(utils, "binding_from_environment", lambda env: "binding")
monkeypatch.setattr(utils, "current_acceptance_policy", lambda env: FakePolicy())
monkeypatch.setattr(
utils,
"_run_managed_command",
lambda *args, **kwargs: (_ for _ in ()).throw(
AssertionError("child process must not start")
),
)
code, output = utils.run_collector(
"example",
str(Path(utils.PROJECT_ROOT) / "collectors" / "example.py"),
[],
)
assert code == COOKIE_SKIP_EXIT_CODE
assert output == "browser state is missing"
assert observed["binding"] == "binding"
assert observed["environment"]["GYXX_WORKFLOW_ACCEPTANCE"] == "1"
def test_runner_timeout_terminates_collector_grandchild_before_return(
monkeypatch,
tmp_path: Path,
) -> None:
identity_file = tmp_path / "grandchild.json"
marker_file = tmp_path / "grandchild-finished.txt"
grandchild_code = (
"import pathlib,time; "
"time.sleep(2.8); "
f"pathlib.Path({str(marker_file)!r}).write_text('alive', encoding='utf-8')"
)
collector = tmp_path / "collector.py"
collector.write_text(
"\n".join(
(
"import json, pathlib, psutil, subprocess, sys, time",
f"child = subprocess.Popen([sys.executable, '-c', {grandchild_code!r}], close_fds=False)",
f"pathlib.Path({str(identity_file)!r}).write_text(json.dumps({{'pid': child.pid, 'created': psutil.Process(child.pid).create_time()}}), encoding='utf-8')",
"time.sleep(10)",
)
),
encoding="utf-8",
)
monkeypatch.setattr(
utils,
"environment_for_child_script",
lambda _target, environment: environment,
)
monkeypatch.setattr(
utils,
"current_acceptance_policy",
lambda _environment: SimpleNamespace(enabled=False),
)
code, output = utils.run_collector(
"timeout-probe",
str(collector),
[],
env=dict(os.environ),
timeout=2,
)
assert code == 1
assert "timeout-probe" in output
assert identity_file.is_file()
identity = json.loads(identity_file.read_text(encoding="utf-8"))
try:
descendant = psutil.Process(identity["pid"])
same_process_is_live = (
descendant.create_time()
== pytest.approx(identity["created"], abs=0.01)
and descendant.is_running()
and descendant.status() != psutil.STATUS_ZOMBIE
)
except psutil.Error:
same_process_is_live = False
assert same_process_is_live is False
time.sleep(1.0)
assert marker_file.exists() is False
def test_runner_redacts_sensitive_command_arguments() -> None:
command = [
"python",
"collector.py",
"--password",
"placeholder",
"--api-key=placeholder",
"--output",
"safe-path",
]
assert utils._redact_command(command) == [
"python",
"collector.py",
"--password",
"[REDACTED]",
"--api-key=[REDACTED]",
"--output",
"safe-path",
]
def test_acceptance_skips_entire_feishu_writer_path(monkeypatch) -> None:
observed: list[str] = []
class FakePolicy:
def skip_feishu_write(self, operation):
observed.append(operation)
return True
monkeypatch.setattr(utils, "current_acceptance_policy", lambda: FakePolicy())
assert utils.skip_feishu_for_acceptance("shop.writer", requested=False) is True
assert observed == ["shop.writer"]
def test_explicit_feishu_skip_does_not_need_policy(monkeypatch) -> None:
monkeypatch.setattr(
utils,
"current_acceptance_policy",
lambda: (_ for _ in ()).throw(AssertionError("policy must not be loaded")),
)
assert utils.skip_feishu_for_acceptance("shop.writer", requested=True) is True
@@ -0,0 +1,316 @@
from __future__ import annotations
import ast
import hashlib
import os
from pathlib import Path
import pytest
PROJECT_ROOT = Path(__file__).resolve().parents[3]
TARGET_COLLECTOR_ROOT = (
PROJECT_ROOT
/ "src"
/ "gyxx_flow"
/ "modules"
/ "shop_intelligence"
/ "collectors"
)
SOURCE_COLLECTOR_ROOT = Path(
os.getenv("GYXX_SHOP_SOURCE_ROOT", r"D:\shop-data-flow")
) / "collectors"
# These functions implement page navigation, date/category selection, extraction,
# and iteration order. Portability adaptations belong in imports, configuration,
# persistence, browser-session setup, and main/runner boundaries instead.
CORE_BUSINESS_FUNCTIONS = {
"jd_data_collector.py": (
"select_last_week_any_day",
"select_last_week_in_old_datepicker",
"step_enter_shangzhi",
"step_jzt_full_site",
"step_jzt_non_full_site",
"step_merchant_ranking",
"step_shop_star_and_trade",
"switch_category",
),
"jd_peer_store_data_collector.py": (
"clear_and_search_brand",
"collect_new_products",
"reselect_last_week",
"search_product_by_name",
"select_last_week_any_day",
"select_product_category",
"step_enter_shangzhi",
),
"dy_store_competitor_store_scraping.py": (
"_check_fxg_login",
"_collect_competitor_rankings",
"_collect_experience_score",
"_ensure_fxg_login",
"_extract_experience_score",
"_open_market_rank_shop_page",
"_select_menu_and_date",
),
}
# SHA-256 of ast.dump(function, include_attributes=False) from the reviewed
# D:\shop-data-flow source. A source change must be reviewed before updating this
# snapshot; copying a new hash merely to make the test pass defeats the guard.
SOURCE_AST_SHA256 = {
("jd_data_collector.py", "select_last_week_any_day"): (
"0c85d64e6d47b08a5971caec3afe391271fd6bc94d86bab65f6ebae9655b8a6f"
),
("jd_data_collector.py", "select_last_week_in_old_datepicker"): (
"ff542b968c43b92304ccb15a2282acf3d64ab7258dee020bae51f1c7f5f5f973"
),
("jd_data_collector.py", "step_enter_shangzhi"): (
"6ba9e2df11ecbf0aae88fb2ed3cab8abe5157121418a0426384f8c43368b9362"
),
("jd_data_collector.py", "step_jzt_full_site"): (
"0cf4276039e96650879ba6fff8822322245dc581ad3020785ed6cb8a84e11e8f"
),
("jd_data_collector.py", "step_jzt_non_full_site"): (
"2a911597995c4d9a2cc47b106d6a8c5a811932eea0d83e928b0dcafcdb6864a5"
),
("jd_data_collector.py", "step_merchant_ranking"): (
"027626587724aa224fca6146dae61ea505374e78ca36603c96769b36725dfac7"
),
("jd_data_collector.py", "step_shop_star_and_trade"): (
"2e54324b6091e961eb6fbc704aca2867a91ce829709c2824e8a6ee8fa2260790"
),
("jd_data_collector.py", "switch_category"): (
"f6dfdc7025958cbb85476c31b4930f2b57a8f67316c3000a8e38f5d49acb6aef"
),
("jd_peer_store_data_collector.py", "clear_and_search_brand"): (
"32327c849483a44aa8c2b8cab8210f8e80767d3e2b86fd6789d855669062dc00"
),
("jd_peer_store_data_collector.py", "collect_new_products"): (
"c2cbf6528a756d4fb26f5b8794177cbcb37790584f85660eff0a851ecffc8f58"
),
("jd_peer_store_data_collector.py", "reselect_last_week"): (
"5897bd8afb8bf0604daace2a13628ea002f753afd8de7956246a4ee18e89cbd9"
),
("jd_peer_store_data_collector.py", "search_product_by_name"): (
"2fd758156e716d2c666b8c1c1d9edb0c72cad83c56c4c44b09900bd7eb0bd940"
),
("jd_peer_store_data_collector.py", "select_last_week_any_day"): (
"0a9b5fad4ea111e7b2afe39241508b5d036b84006091188b95ac85b652e7e3f9"
),
("jd_peer_store_data_collector.py", "select_product_category"): (
"ff947df3e6e455176d723c93fb2af06ab92a72ac9174f4a5ae2db306201d5182"
),
("jd_peer_store_data_collector.py", "step_enter_shangzhi"): (
"d6bfb283c00c842a129d8f81f0da61cae752e421e2dafaffdb87fd20603f0f2d"
),
("dy_store_competitor_store_scraping.py", "_collect_competitor_rankings"): (
"de55dded8a9341b955e000e4fafb8b4914533b25e06dc2aca930535836cfc0b5"
),
("dy_store_competitor_store_scraping.py", "_check_fxg_login"): (
"1928b38f6c167971139b147565b51e727e65a74e49ba21fe40d47711a7641e6a"
),
("dy_store_competitor_store_scraping.py", "_collect_experience_score"): (
"4609852591005c5b3ba8f9704b4db49918173f846176d9e50da6d4c8ee07914a"
),
("dy_store_competitor_store_scraping.py", "_ensure_fxg_login"): (
"ae69c5c58563c6cdb97fd6af380a24e2d275ec6623038a58b5e15a7bda2777f4"
),
("dy_store_competitor_store_scraping.py", "_extract_experience_score"): (
"5a28dbc7fa627fea384dae3d5f9b85fb0542b585c7312918ef355810209089df"
),
("dy_store_competitor_store_scraping.py", "_open_market_rank_shop_page"): (
"b60acaeaf3b1073144f3f570eb539ad87945fd92f2577cf338b3b496ec542d35"
),
("dy_store_competitor_store_scraping.py", "_select_menu_and_date"): (
"4ffb5e89e7a7af3d0af126adf420399a0017fba8e49b9185266baf7307e3e549"
),
}
# Deliberately reviewed target-only compatibility fixes. The source project stays
# read-only; these hashes ensure the migrated collector cannot drift beyond the
# current JD DOM adaptation without another explicit review.
TARGET_AST_SHA256_OVERRIDES = {
("jd_data_collector.py", "select_last_week_any_day"): (
"8f6aed11b39d8e19ec4146e9b32eee96f5977fdbef694311534346bacec5d847"
),
("jd_data_collector.py", "step_shop_star_and_trade"): (
"77aa873543861f1fad63d0bd1ace60528ab20592b0bffef3d46ad192f2103161"
),
("jd_data_collector.py", "step_merchant_ranking"): (
"187cd3cfffcb97a5bf9f1ed4dde0d117ee65f82c1cf431df4c7c9c9ee2a7b282"
),
("jd_data_collector.py", "step_jzt_non_full_site"): (
"1bd53eba5d0123e63d2e628d9772c0efa57fc8d2f87d79a4f3c41a311132952d"
),
}
# These two source functions were reviewed after a production run proved that
# a failed category click silently copied the previous category's table. They
# intentionally diverge from D:\shop-data-flow and retain their own reviewed
# target snapshots so the defect cannot be reintroduced by a future sync.
REVIEWED_DEFECT_FIX_AST_SHA256 = {
("jd_peer_store_data_collector.py", "step_collect_competitor_data"): (
"bf6836516dc3e70d00c145acbdfa00b6ddeee480f9927d7236e82ba8b1430ff2"
),
("jd_peer_store_data_collector.py", "switch_category"): (
"bb4dc034447666682e19dc8cce05903c92d53852669ac93cc6810b18ad310d13"
),
}
FUNCTION_CASES = tuple(
(filename, function_name)
for filename, function_names in CORE_BUSINESS_FUNCTIONS.items()
for function_name in function_names
)
def _call_name(node: ast.AST) -> str | None:
if not isinstance(node, ast.Call):
return None
if isinstance(node.func, ast.Name):
return node.func.id
if isinstance(node.func, ast.Attribute):
return node.func.attr
return None
def _is_session_expiry_guard(node: ast.stmt) -> bool:
if not isinstance(node, ast.If) or node.orelse:
return False
detector_names = {"_is_login_page_url", "_has_interactive_login_form"}
detectors = {
name
for child in ast.walk(node.test)
if (name := _call_name(child)) is not None
}
raises_session_expired = any(
isinstance(child, ast.Raise)
and child.exc is not None
and _call_name(child.exc) == "BrowserSessionExpiredError"
for statement in node.body
for child in ast.walk(statement)
)
return bool(detectors & detector_names) and raises_session_expired
def _is_navigation_session_wrapper(node: ast.Try) -> bool:
if len(node.body) != 1 or node.orelse or node.finalbody or len(node.handlers) != 1:
return False
navigation = node.body[0]
if not (
isinstance(navigation, ast.Expr)
and isinstance(navigation.value, ast.Call)
and _call_name(navigation.value) == "goto"
):
return False
handler_body = node.handlers[0].body
return (
len(handler_body) == 2
and _is_session_expiry_guard(handler_body[0])
and isinstance(handler_body[1], ast.Raise)
and handler_body[1].exc is None
)
class _ApprovedBoundaryNormalizer(ast.NodeTransformer):
"""Remove only the approved cookie/session-expiry adapter statements."""
def visit_If(self, node: ast.If) -> ast.AST | None: # noqa: N802
if _is_session_expiry_guard(node):
return None
return self.generic_visit(node)
def visit_Try(self, node: ast.Try) -> ast.AST | list[ast.AST]: # noqa: N802
if _is_navigation_session_wrapper(node):
return [self.visit(statement) for statement in node.body]
return self.generic_visit(node)
def _function_ast(path: Path, function_name: str) -> str:
tree = ast.parse(path.read_text(encoding="utf-8-sig"), filename=str(path))
matches = [
node
for node in tree.body
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
and node.name == function_name
]
assert len(matches) == 1, f"expected exactly one top-level {function_name} in {path}"
normalized = _ApprovedBoundaryNormalizer().visit(matches[0])
assert isinstance(normalized, (ast.FunctionDef, ast.AsyncFunctionDef))
return ast.dump(normalized, annotate_fields=True, include_attributes=False)
def _ast_sha256(function_ast: str) -> str:
return hashlib.sha256(function_ast.encode("utf-8")).hexdigest()
def test_ast_snapshot_covers_exactly_the_reviewed_business_functions() -> None:
assert set(SOURCE_AST_SHA256) == set(FUNCTION_CASES)
assert set(SOURCE_AST_SHA256).isdisjoint(REVIEWED_DEFECT_FIX_AST_SHA256)
@pytest.mark.parametrize(
("filename", "function_name"),
REVIEWED_DEFECT_FIX_AST_SHA256,
ids=lambda value: value,
)
def test_reviewed_local_defect_fixes_match_target_snapshot(
filename: str,
function_name: str,
) -> None:
target_ast = _function_ast(TARGET_COLLECTOR_ROOT / filename, function_name)
assert _ast_sha256(target_ast) == REVIEWED_DEFECT_FIX_AST_SHA256[
(filename, function_name)
]
@pytest.mark.parametrize(
("filename", "function_name"),
FUNCTION_CASES,
ids=lambda value: value,
)
def test_migrated_core_business_logic_matches_reviewed_ast_snapshot(
filename: str,
function_name: str,
) -> None:
target_ast = _function_ast(TARGET_COLLECTOR_ROOT / filename, function_name)
expected = TARGET_AST_SHA256_OVERRIDES.get(
(filename, function_name),
SOURCE_AST_SHA256[(filename, function_name)],
)
assert _ast_sha256(target_ast) == expected, (
f"{filename}:{function_name} changed the reviewed source business logic; "
"only explicitly reviewed target compatibility adapters may differ"
)
@pytest.mark.skipif(
not SOURCE_COLLECTOR_ROOT.is_dir(),
reason="local D:\\shop-data-flow source project is unavailable",
)
@pytest.mark.parametrize(
("filename", "function_name"),
FUNCTION_CASES,
ids=lambda value: value,
)
def test_reviewed_snapshot_and_migration_match_current_local_source(
filename: str,
function_name: str,
) -> None:
source_ast = _function_ast(SOURCE_COLLECTOR_ROOT / filename, function_name)
target_ast = _function_ast(TARGET_COLLECTOR_ROOT / filename, function_name)
expected = SOURCE_AST_SHA256[(filename, function_name)]
assert _ast_sha256(source_ast) == expected, (
f"{filename}:{function_name} changed in D:\\shop-data-flow; review the "
"source change before deliberately refreshing this snapshot"
)
override = TARGET_AST_SHA256_OVERRIDES.get((filename, function_name))
if override:
assert _ast_sha256(target_ast) == override
else:
assert target_ast == source_ast, (
f"{filename}:{function_name} no longer matches the current source project"
)