chore: [workflow] 提交工作区代码
This commit is contained in:
1 parent
2ff588a65d
commit
01f3d7aaf7
131 files changed
+24306
-1320
No files matched your search
@@ -674,3 +674,52 @@ def test_wait_for_exported_excel_returns_immediately_when_present(monkeypatch):
|
||||
)
|
||||
|
||||
assert chanmama._wait_for_exported_excel(0.0) == "/downloads/已有.xlsx"
|
||||
|
||||
|
||||
def test_wait_for_exported_excel_pumps_playwright_events(monkeypatch):
|
||||
"""回归: 纯 sleep 轮询不泵 Playwright 事件会让 download 事件排队,
|
||||
download.save_as 到 quit 才执行,文件错过 deadline 误判超时。
|
||||
等待期间必须周期性调用 driver 泵事件循环。"""
|
||||
|
||||
class _FakeDriver:
|
||||
def __init__(self) -> None:
|
||||
self.eval_calls = 0
|
||||
|
||||
def execute_script(self, script, *args):
|
||||
self.eval_calls += 1
|
||||
return None
|
||||
|
||||
now = [1000.0]
|
||||
results = iter([None, None, "/downloads/泵事件后落盘.xlsx"])
|
||||
driver = _FakeDriver()
|
||||
|
||||
monkeypatch.setattr(chanmama.time, "time", lambda: now[0])
|
||||
monkeypatch.setattr(
|
||||
chanmama.time,
|
||||
"sleep",
|
||||
lambda seconds: now.__setitem__(0, now[0] + seconds),
|
||||
)
|
||||
monkeypatch.setattr(chanmama, "_find_new_excel_after", lambda _pre_mtime: next(results))
|
||||
|
||||
assert chanmama._wait_for_exported_excel(0.0, driver=driver) == (
|
||||
"/downloads/泵事件后落盘.xlsx"
|
||||
)
|
||||
assert driver.eval_calls >= 3
|
||||
|
||||
|
||||
def test_wait_for_exported_excel_tolerates_driver_errors(monkeypatch):
|
||||
"""泵事件失败(页面导航中等)不应中断文件轮询。"""
|
||||
|
||||
class _BrokenDriver:
|
||||
def execute_script(self, script, *args):
|
||||
raise RuntimeError("page navigating")
|
||||
|
||||
monkeypatch.setattr(
|
||||
chanmama,
|
||||
"_find_new_excel_after",
|
||||
lambda _pre_mtime: "/downloads/已有.xlsx",
|
||||
)
|
||||
|
||||
assert chanmama._wait_for_exported_excel(0.0, driver=_BrokenDriver()) == (
|
||||
"/downloads/已有.xlsx"
|
||||
)
|
||||
@@ -358,22 +358,32 @@ def test_scheduled_collection_has_no_automatic_retry_and_syncs_once():
|
||||
)
|
||||
|
||||
|
||||
def test_daily_collection_includes_self_operated_pipeline():
|
||||
def test_daily_collection_separates_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 entries.index("run_all.py") < entries.index(
|
||||
"data/tools/refresh_self_mapping.py"
|
||||
)
|
||||
assert entries.index("data/tools/refresh_self_mapping.py") < entries.index(
|
||||
"self_bilibili_scraper.py"
|
||||
)
|
||||
assert entries.index("self_bilibili_scraper.py") < entries.index(
|
||||
"chanmama_scraper.py"
|
||||
assert entries == ["run_all.py", "data/tools/sync_metrics_to_cmt_notes.py"]
|
||||
assert steps[1].args == ("--scope", "collaborators")
|
||||
|
||||
self_steps = _workflow("content.metrics.self_operated.daily").steps
|
||||
self_entries = [step.entry for step in self_steps]
|
||||
assert self_entries == [
|
||||
"data/tools/refresh_self_mapping.py",
|
||||
"self_bilibili_scraper.py",
|
||||
"chanmama_scraper.py",
|
||||
"self_xhs_scraper.py",
|
||||
"data/tools/sync_metrics_to_cmt_notes.py",
|
||||
"data/tools/sync_self_video_metrics.py",
|
||||
]
|
||||
sync_step = next(
|
||||
step
|
||||
for step in self_steps
|
||||
if step.entry == "data/tools/sync_metrics_to_cmt_notes.py"
|
||||
)
|
||||
assert sync_step.args == ("--scope", "self-operated")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
|
||||
@@ -0,0 +1,661 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""自营内容全量指标(抖音/B站/小红书): 解析、账号映射、入库行构造的边界测试。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as dt
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import openpyxl
|
||||
|
||||
from gyxx_flow.modules.content_marketing import (
|
||||
chanmama_export,
|
||||
self_operated_accounts,
|
||||
self_xhs_scraper,
|
||||
)
|
||||
from gyxx_flow.modules.content_marketing.data.tools import (
|
||||
sync_self_video_metrics as sync_tool,
|
||||
)
|
||||
from gyxx_flow.modules.content_marketing.runtime_paths import PATHS
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[3]
|
||||
TOOLS = (
|
||||
PROJECT_ROOT / "src/gyxx_flow/modules/content_marketing/data/tools"
|
||||
)
|
||||
|
||||
EXPORT_HEADERS = [
|
||||
"视频名称", "蝉妈妈视频链接", "抖音视频链接", "达人昵称", "抖音号",
|
||||
"视频时长", "发布时间", "预估曝光", "点赞数", "评论数", "转发数",
|
||||
"预估销量", "预估销量-指数", "预估销售额", "预估销售额-指数",
|
||||
]
|
||||
|
||||
STORE_FLAGSHIP = "光影行星箱包旗舰店"
|
||||
STORE_SPECIALTY = "GYXX光影行星箱包专卖店"
|
||||
XHS_SHOP = "光影行星GYXX旗舰店"
|
||||
|
||||
|
||||
def _write_export_xlsx(path: Path, rows: list[tuple]) -> str:
|
||||
workbook = openpyxl.Workbook()
|
||||
sheet = workbook.active
|
||||
sheet.append(EXPORT_HEADERS)
|
||||
for row in rows:
|
||||
sheet.append(list(row))
|
||||
workbook.save(path)
|
||||
workbook.close()
|
||||
return str(path)
|
||||
|
||||
|
||||
def _douyin_sample_rows() -> list[tuple]:
|
||||
return [
|
||||
("总计", "-", "-", "-", "-", "-", "-", "-", "-", "-", "-", "0", "-", "0", "-"),
|
||||
(
|
||||
"轻户外,自己定义-光影行星 逐星",
|
||||
"https://www.chanmama.com/awemeDetail/SbXQnFsF2tlZF5Wy7U1uG3izRQaBOwYT",
|
||||
"https://www.iesdouyin.com/share/video/7687797200985869595/?region=CN",
|
||||
STORE_FLAGSHIP, "gyxx168888", "1分0秒", "2026-09-21 09:39",
|
||||
"3.2w+", 135, 8, 2, "12", 0, "3580.5", 0,
|
||||
),
|
||||
(
|
||||
"图文笔记标题",
|
||||
"https://www.chanmama.com/awemeDetail/M7_SEJOk9P_Q1eafatlOCAQl4F2eI3FR",
|
||||
"https://www.iesdouyin.com/share/note/7687432318584143857/?region=CN",
|
||||
STORE_SPECIALTY, "GYXX168168", "--", "2026-09-20 10:03",
|
||||
"0", 1, 0, 0, "0", 0, "0", 0,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def test_parse_chanmama_excel_extracts_all_metric_fields(tmp_path):
|
||||
xlsx = _write_export_xlsx(tmp_path / "flagship.xlsx", _douyin_sample_rows())
|
||||
|
||||
records, headers = chanmama_export.parse_chanmama_excel(xlsx)
|
||||
|
||||
assert headers == EXPORT_HEADERS
|
||||
assert len(records) == 2 # 总计行被跳过
|
||||
first = records[0]
|
||||
assert first["creator_name"] == STORE_FLAGSHIP
|
||||
assert first["exposure"] == 32000
|
||||
assert first["like_count"] == 135
|
||||
assert first["comment_count"] == 8
|
||||
assert first["share_count"] == 2
|
||||
assert first["est_sales_amount"] == 3580.5
|
||||
assert chanmama_export.extract_video_id(
|
||||
first["chanmama_url"], first["video_url"]
|
||||
) == "7687797200985869595"
|
||||
|
||||
|
||||
def test_self_operated_accounts_config_covers_three_platforms():
|
||||
path = self_operated_accounts.accounts_config_path(PATHS.config_root)
|
||||
all_accounts = self_operated_accounts.load_all_accounts(path)
|
||||
assert set(all_accounts) == {"douyin", "bilibili", "xiaohongshu"}
|
||||
# 用户约定的身份映射: 抖音·机能号=旗舰店, 抖音·高智号=专卖店
|
||||
assert self_operated_accounts.resolve_alias(
|
||||
all_accounts["douyin"], account_name=STORE_FLAGSHIP
|
||||
) == "抖音·机能号"
|
||||
assert self_operated_accounts.resolve_alias(
|
||||
all_accounts["douyin"], platform_account_id="GYXX168168"
|
||||
) == "抖音·高智号"
|
||||
# 小红书账号必须带登录手机号
|
||||
xhs = self_operated_accounts.find_account(
|
||||
"xiaohongshu", path, account_name=XHS_SHOP
|
||||
)
|
||||
assert xhs is not None and re.fullmatch(r"1\d{10}", xhs["phone"])
|
||||
assert self_operated_accounts.resolve_alias(
|
||||
all_accounts["xiaohongshu"], account_name=XHS_SHOP
|
||||
) == "小红书·自营号"
|
||||
# 未知账号返回空串,不抛异常
|
||||
assert self_operated_accounts.resolve_alias(
|
||||
all_accounts["bilibili"], account_name="未知UP主"
|
||||
) == ""
|
||||
|
||||
|
||||
def test_load_accounts_tolerates_missing_or_broken_config(tmp_path):
|
||||
assert self_operated_accounts.load_accounts(
|
||||
"douyin", tmp_path / "missing.json"
|
||||
) == []
|
||||
broken = tmp_path / "broken.json"
|
||||
broken.write_text("not-json", encoding="utf-8")
|
||||
assert self_operated_accounts.load_accounts("douyin", broken) == []
|
||||
empty = tmp_path / "empty.json"
|
||||
empty.write_text("{}", encoding="utf-8")
|
||||
assert self_operated_accounts.load_accounts("bilibili", empty) == []
|
||||
with __import__("pytest").raises(ValueError):
|
||||
self_operated_accounts.load_accounts("kuaishou", empty)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# B站: 公开 API 全字段
|
||||
# ============================================================
|
||||
class _FakeBiliResponse:
|
||||
def __init__(self, payload):
|
||||
self._payload = payload
|
||||
|
||||
def json(self):
|
||||
return self._payload
|
||||
|
||||
|
||||
class _FakeBiliSession:
|
||||
"""固定返回一个 B站 view API 响应的假 HTTP 会话。"""
|
||||
|
||||
PAYLOAD = {
|
||||
"code": 0,
|
||||
"data": {
|
||||
"aid": 114514,
|
||||
"bvid": "BV1xT4y1Q7zz",
|
||||
"title": "光影行星 逐星 开箱",
|
||||
"duration": 92,
|
||||
"pubdate": 1761026400,
|
||||
"owner": {"mid": 5201314, "name": "光影行星官方号"},
|
||||
"stat": {
|
||||
"view": 35821, "like": 1204, "coin": 88, "favorite": 301,
|
||||
"share": 57, "danmaku": 145, "reply": 96,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
def get(self, url, **_kwargs):
|
||||
return _FakeBiliResponse(self.PAYLOAD)
|
||||
|
||||
|
||||
def test_fetch_bili_video_extracts_full_metrics():
|
||||
from gyxx_flow.modules.content_marketing.self_bilibili_scraper import (
|
||||
fetch_bili_video,
|
||||
format_duration,
|
||||
)
|
||||
|
||||
info = fetch_bili_video("https://www.bilibili.com/video/BV1xT4y1Q7zz",
|
||||
_FakeBiliSession())
|
||||
assert info is not None
|
||||
assert info["video_id"] == "BV1xT4y1Q7zz"
|
||||
assert info["video_url"].endswith("/video/BV1xT4y1Q7zz")
|
||||
assert info["title"] == "光影行星 逐星 开箱"
|
||||
assert info["owner_name"] == "光影行星官方号"
|
||||
assert info["view_count"] == 35821
|
||||
assert info["like_count"] == 1204
|
||||
assert info["coin_count"] == 88
|
||||
assert info["favorite_count"] == 301
|
||||
assert info["danmaku_count"] == 145
|
||||
assert info["reply_count"] == 96
|
||||
assert info["publish_time"] == dt.datetime.fromtimestamp(1761026400)
|
||||
assert format_duration(92) == "1:32"
|
||||
assert format_duration(3725) == "1:02:05"
|
||||
assert format_duration(None) == ""
|
||||
|
||||
|
||||
def test_collect_bilibili_rows_from_artifact(tmp_path, monkeypatch):
|
||||
artifact = tmp_path / "daily.json"
|
||||
artifact.write_text(
|
||||
json.dumps({
|
||||
"platform": "bilibili",
|
||||
"business_date": "2026-09-28",
|
||||
"videos": [{
|
||||
"video_id": "BV1xT4y1Q7zz",
|
||||
"video_url": "https://www.bilibili.com/video/BV1xT4y1Q7zz",
|
||||
"title": "光影行星 逐星 开箱",
|
||||
"owner_name": "光影行星官方号",
|
||||
"owner_mid": "5201314",
|
||||
"duration_seconds": 92,
|
||||
"publish_time": "2026-09-21 09:39",
|
||||
"view_count": 35821, "like_count": 1204, "coin_count": 88,
|
||||
"favorite_count": 301, "share_count": 57,
|
||||
"danmaku_count": 145, "reply_count": 96,
|
||||
}],
|
||||
}, ensure_ascii=False),
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setattr(sync_tool, "BILIBILI_ARTIFACT", artifact)
|
||||
accounts = [
|
||||
{"alias": "B站·自营号", "account_name": "光影行星官方号",
|
||||
"platform_account_id": "", "phone": ""}
|
||||
]
|
||||
|
||||
rows, stats, source = sync_tool.collect_bilibili_rows(accounts, dt.date(2026, 9, 28))
|
||||
|
||||
assert stats["rows"] == 1
|
||||
assert source == artifact
|
||||
row = rows[("bilibili", "光影行星官方号", "BV1xT4y1Q7zz")]
|
||||
assert row[sync_tool.ROW_FIELDS.index("account_alias")] == "B站·自营号"
|
||||
assert row[sync_tool.ROW_FIELDS.index("exposure")] == 35821
|
||||
assert row[sync_tool.ROW_FIELDS.index("comment_count")] == 96 # reply→评论
|
||||
assert row[sync_tool.ROW_FIELDS.index("coin_count")] == 88
|
||||
assert row[sync_tool.ROW_FIELDS.index("duration")] == "1:32"
|
||||
|
||||
|
||||
def test_collect_bilibili_rows_missing_artifact(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
sync_tool, "BILIBILI_ARTIFACT", tmp_path / "missing.json"
|
||||
)
|
||||
rows, stats, source = sync_tool.collect_bilibili_rows([], dt.date(2026, 9, 28))
|
||||
assert rows == {} and stats["rows"] == 0 and source is None
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 小红书: ARK 管理页表格解析
|
||||
# ============================================================
|
||||
GOODS_HEADERS = ["", "笔记信息", "挂载商品", "笔记阅读数", "笔记支付金额",
|
||||
"笔记支付订单数", "发布状态", "操作"]
|
||||
NORMAL_HEADERS = ["", "笔记信息", "阅读数", "点赞数", "引流主页次数",
|
||||
"引流直播间次数", "发布状态", "操作"]
|
||||
|
||||
|
||||
def test_bilibili_wbi_signing_matches_documented_vector():
|
||||
from gyxx_flow.modules.content_marketing.self_bilibili_scraper import (
|
||||
derive_mixin_key,
|
||||
wbi_sign,
|
||||
)
|
||||
|
||||
# B站 WBI 公开示例密钥 → 文档给定的 mixin key
|
||||
mixin = derive_mixin_key(
|
||||
"7cd084941338484aae1ad9425b84077c",
|
||||
"4932caff0ff746eab6f01bf08b70ac45",
|
||||
)
|
||||
assert mixin == "ea1db124af3c7062474693fa704f4ff8"
|
||||
|
||||
signed = wbi_sign(
|
||||
{"platform": "web", "pn": 1, "ps": 30, "mid": "1008229967",
|
||||
"order": "pubdate"},
|
||||
mixin,
|
||||
1702204800,
|
||||
)
|
||||
assert signed["wts"] == 1702204800
|
||||
assert re.fullmatch(r"[0-9a-f]{32}", signed["w_rid"])
|
||||
# 确定性: 同参数同时间戳签名一致
|
||||
again = wbi_sign(
|
||||
{"mid": "1008229967", "order": "pubdate", "pn": 1, "ps": 30,
|
||||
"platform": "web"},
|
||||
mixin,
|
||||
1702204800,
|
||||
)
|
||||
assert again["w_rid"] == signed["w_rid"]
|
||||
|
||||
|
||||
def test_bilibili_artifact_covers_full_history(tmp_path, monkeypatch):
|
||||
"""主页全量采集后 artifact 应含全部投稿(含飞书未登记的历史视频)。"""
|
||||
artifact = tmp_path / "daily.json"
|
||||
videos = [
|
||||
{
|
||||
"video_id": f"BV1xT4y1Q7z{i:02d}",
|
||||
"video_url": f"https://www.bilibili.com/video/BV1xT4y1Q7z{i:02d}",
|
||||
"title": f"视频{i}",
|
||||
"owner_name": "光影行星",
|
||||
"owner_mid": "1008229967",
|
||||
"duration_seconds": 60 + i,
|
||||
"publish_time": f"2026-09-{i + 1:02d} 09:00:00",
|
||||
"view_count": 100 * i, "like_count": i, "coin_count": i,
|
||||
"favorite_count": i, "share_count": i, "danmaku_count": i,
|
||||
"reply_count": i,
|
||||
}
|
||||
for i in range(3)
|
||||
]
|
||||
artifact.write_text(
|
||||
json.dumps({"platform": "bilibili", "business_date": "2026-09-29",
|
||||
"videos": videos}, ensure_ascii=False),
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setattr(sync_tool, "BILIBILI_ARTIFACT", artifact)
|
||||
accounts = [
|
||||
{"alias": "B站·自营号", "account_name": "光影行星",
|
||||
"platform_account_id": "1008229967", "phone": ""}
|
||||
]
|
||||
|
||||
rows, stats, _source = sync_tool.collect_bilibili_rows(
|
||||
accounts, dt.date(2026, 9, 29)
|
||||
)
|
||||
|
||||
assert stats["rows"] == 3
|
||||
key = ("bilibili", "光影行星", "BV1xT4y1Q7z00")
|
||||
assert rows[key][sync_tool.ROW_FIELDS.index("account_alias")] == "B站·自营号"
|
||||
assert rows[key][sync_tool.ROW_FIELDS.index("publish_time")] == dt.datetime(
|
||||
2026, 9, 1, 9, 0
|
||||
)
|
||||
|
||||
|
||||
def test_parse_info_cell_variants():
|
||||
parse = self_xhs_scraper._parse_info_cell
|
||||
# 多行,发布时间带前缀(线上真实形态)
|
||||
assert parse("标题A\n发布时间:2026-09-15 14:23\n手动创作") == (
|
||||
"标题A", "2026-09-15 14:23", "手动创作"
|
||||
)
|
||||
# 单行压扁(历史兜底)
|
||||
title, publish, method = parse("标题B 发布时间:2026-09-08 13:41 手动创作")
|
||||
assert title == "标题B"
|
||||
assert publish == "2026-09-08 13:41"
|
||||
assert method == "手动创作"
|
||||
# 无日期无方式的纯标题
|
||||
assert parse("纯标题") == ("纯标题", "", "")
|
||||
assert parse("") == ("", "", "")
|
||||
|
||||
|
||||
def test_parse_goods_note_rows():
|
||||
rows = [
|
||||
{
|
||||
"cells": [
|
||||
"",
|
||||
"斜挎包通勤好物\n2026-09-25 10:23\n手动创作",
|
||||
"光影行星 极星单肩包男女…",
|
||||
"1.2万",
|
||||
"3,586.50",
|
||||
"12",
|
||||
"已发布",
|
||||
"数据 编辑",
|
||||
],
|
||||
"href": "https://www.xiaohongshu.com/explore/66e4b2c3000000001d00f1a2",
|
||||
},
|
||||
{
|
||||
"cells": ["", "另一条\n2026-09-20 08:00", "暂无关联商品", "356",
|
||||
"—", "—", "已发布", "编辑"],
|
||||
"href": "",
|
||||
},
|
||||
]
|
||||
|
||||
records, stats = self_xhs_scraper.parse_management_rows(
|
||||
GOODS_HEADERS, rows, "goods"
|
||||
)
|
||||
|
||||
assert stats["records"] == 2
|
||||
first = records[0]
|
||||
assert first["note_type"] == "goods"
|
||||
assert first["title"] == "斜挎包通勤好物"
|
||||
assert first["publish_time"] == "2026-09-25 10:23"
|
||||
assert first["creation_method"] == "手动创作"
|
||||
assert first["mounted_products"] == "光影行星 极星单肩包男女…"
|
||||
assert first["read_count"] == 12000
|
||||
assert first["payment_amount"] == 3586.50
|
||||
assert first["payment_orders"] == 12
|
||||
assert first["publish_status"] == "已发布"
|
||||
assert first["note_id"] == "66e4b2c3000000001d00f1a2"
|
||||
assert first["video_id"] == "66e4b2c3000000001d00f1a2"
|
||||
# 页面原始列全量保留
|
||||
assert first["raw_columns"]["笔记阅读数"] == "1.2万"
|
||||
# 无链接行回退标题指纹,并跨日稳定
|
||||
second = records[1]
|
||||
assert second["video_id"].startswith("xhs-")
|
||||
assert second["video_id"] == self_xhs_scraper.fallback_note_fingerprint(
|
||||
"另一条", "2026-09-20 08:00"
|
||||
)
|
||||
assert second["mounted_products"] == "暂无关联商品"
|
||||
assert second["payment_amount"] is None
|
||||
assert second["payment_orders"] is None
|
||||
|
||||
|
||||
def test_parse_normal_note_rows():
|
||||
rows = [
|
||||
{
|
||||
"cells": ["", "双肩包种草\n2026-09-22 18:30\n可能是AI创作",
|
||||
"6,592", "483", "35", "8", "已发布", "数据"],
|
||||
"href": "https://www.xiaohongshu.com/discovery/item/66e0abc5000000001900c3b1",
|
||||
},
|
||||
]
|
||||
|
||||
records, stats = self_xhs_scraper.parse_management_rows(
|
||||
NORMAL_HEADERS, rows, "normal"
|
||||
)
|
||||
|
||||
assert stats["records"] == 1
|
||||
record = records[0]
|
||||
assert record["note_type"] == "normal"
|
||||
assert record["read_count"] == 6592
|
||||
assert record["like_count"] == 483
|
||||
assert record["homepage_redirect_count"] == 35
|
||||
assert record["live_redirect_count"] == 8
|
||||
assert record["creation_method"] == "可能是AI创作"
|
||||
assert record["video_id"] == "66e0abc5000000001900c3b1"
|
||||
# 标题含「创作」字样不会被误判为创作方式标签
|
||||
assert record["title"] == "双肩包种草"
|
||||
|
||||
|
||||
def test_parse_count_and_amount_edge_cases():
|
||||
assert self_xhs_scraper.parse_count("1.2万") == 12000
|
||||
assert self_xhs_scraper.parse_count("6,592") == 6592
|
||||
assert self_xhs_scraper.parse_count("0") == 0
|
||||
assert self_xhs_scraper.parse_count("—") is None
|
||||
assert self_xhs_scraper.parse_count("暂无") is None
|
||||
assert self_xhs_scraper.parse_count("") is None
|
||||
assert self_xhs_scraper.parse_amount("3,586.50") == 3586.50
|
||||
assert self_xhs_scraper.parse_amount("0.9万") == 9000.0
|
||||
assert self_xhs_scraper.parse_amount("-") is None
|
||||
|
||||
|
||||
def test_collect_xiaohongshu_rows_from_artifact(tmp_path, monkeypatch):
|
||||
artifact = tmp_path / "daily.json"
|
||||
artifact.write_text(
|
||||
json.dumps({
|
||||
"platform": "xiaohongshu",
|
||||
"business_date": "2026-09-28",
|
||||
"account_name": XHS_SHOP,
|
||||
"notes": [
|
||||
{
|
||||
"note_type": "goods",
|
||||
"video_id": "66e4b2c3000000001d00f1a2",
|
||||
"title": "斜挎包通勤好物",
|
||||
"publish_time": "2026-09-25 10:23",
|
||||
"creation_method": "手动创作",
|
||||
"mounted_products": "光影行星 极星单肩包男女…",
|
||||
"read_count": 12000, "like_count": 431,
|
||||
"payment_amount": 3586.5, "payment_orders": 12,
|
||||
"publish_status": "已发布",
|
||||
"note_url": "https://www.xiaohongshu.com/explore/66e4b2c3000000001d00f1a2",
|
||||
"raw_columns": {"笔记阅读数": "1.2万"},
|
||||
},
|
||||
],
|
||||
}, ensure_ascii=False),
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setattr(sync_tool, "XHS_ARTIFACT", artifact)
|
||||
accounts = [
|
||||
{"alias": "小红书·自营号", "account_name": XHS_SHOP,
|
||||
"platform_account_id": "18933246725", "phone": "18933246725"}
|
||||
]
|
||||
|
||||
rows, stats, source = sync_tool.collect_xiaohongshu_rows(
|
||||
accounts, dt.date(2026, 9, 28)
|
||||
)
|
||||
|
||||
assert stats["rows"] == 1
|
||||
row = rows[("xiaohongshu", XHS_SHOP, "66e4b2c3000000001d00f1a2")]
|
||||
fields = sync_tool.ROW_FIELDS
|
||||
assert row[fields.index("account_alias")] == "小红书·自营号"
|
||||
assert row[fields.index("exposure")] == 12000 # 阅读数 → 主触达
|
||||
assert row[fields.index("like_count")] == 431
|
||||
assert row[fields.index("payment_amount")] == 3586.5
|
||||
assert row[fields.index("payment_orders")] == 12
|
||||
assert row[fields.index("note_type")] == "goods"
|
||||
assert row[fields.index("publish_time")] == dt.datetime(2026, 9, 25, 10, 23)
|
||||
assert row[fields.index("page_metrics")].obj["笔记阅读数"] == "1.2万"
|
||||
|
||||
|
||||
def test_douyin_history_backfill_uses_file_mtime_and_filename_account(tmp_path):
|
||||
"""历史回填: 按导出落盘日期各留快照,旧版12列文件按文件名前缀补账号。"""
|
||||
# 旧版 12 列表头(无 达人昵称/抖音号/蝉妈妈链接)
|
||||
legacy_headers = [
|
||||
"视频名称", "抖音视频链接", "视频时长", "发布时间", "预估曝光",
|
||||
"点赞数", "评论数", "转发数", "预估销量", "预估销量-指数",
|
||||
"预估销售额", "预估销售额-指数",
|
||||
]
|
||||
legacy_file = tmp_path / "光影行星箱包旗舰店_视频记录_周期内发布视频_20260803-20260901.xlsx"
|
||||
workbook = openpyxl.Workbook()
|
||||
sheet = workbook.active
|
||||
sheet.append(legacy_headers)
|
||||
sheet.append([
|
||||
"旧版格式视频", "https://www.iesdouyin.com/share/video/7686000000000000001/",
|
||||
"46秒", "2026-08-27 16:40:56", "23000", 1321, 46, 14, "0", 0, "0", 0,
|
||||
])
|
||||
workbook.save(legacy_file)
|
||||
workbook.close()
|
||||
import os
|
||||
|
||||
historical = dt.datetime(2026, 9, 2, 17, 0, 0).timestamp()
|
||||
os.utime(legacy_file, (historical, historical))
|
||||
accounts = self_operated_accounts.load_accounts(
|
||||
"douyin", self_operated_accounts.accounts_config_path(PATHS.config_root)
|
||||
)
|
||||
|
||||
rows, stats, _sources = sync_tool.collect_douyin_rows(
|
||||
accounts, dt.date(2026, 9, 29), [str(legacy_file)],
|
||||
use_file_mtime=True,
|
||||
)
|
||||
|
||||
assert stats["skip_no_identity"] == 0
|
||||
assert stats["rows"] == 1
|
||||
row = next(iter(rows.values()))
|
||||
fields = sync_tool.ROW_FIELDS
|
||||
assert row[fields.index("account_name")] == STORE_FLAGSHIP # 文件名前缀回退
|
||||
assert row[fields.index("account_alias")] == "抖音·机能号"
|
||||
assert row[fields.index("metric_date")] == dt.date(2026, 9, 2) # mtime 日期
|
||||
assert row[fields.index("exposure")] == 23000
|
||||
|
||||
|
||||
def test_collect_douyin_rows_keeps_video_note_type(tmp_path):
|
||||
xlsx = _write_export_xlsx(tmp_path / "flagship.xlsx", _douyin_sample_rows())
|
||||
accounts = self_operated_accounts.load_accounts(
|
||||
"douyin", self_operated_accounts.accounts_config_path(PATHS.config_root)
|
||||
)
|
||||
|
||||
rows, stats, _sources = sync_tool.collect_douyin_rows(
|
||||
accounts, dt.date(2026, 9, 28), [xlsx]
|
||||
)
|
||||
|
||||
assert stats["rows"] == 2
|
||||
fields = sync_tool.ROW_FIELDS
|
||||
video_row = rows[
|
||||
("douyin", STORE_FLAGSHIP, "7687797200985869595", dt.date(2026, 9, 28))
|
||||
]
|
||||
note_row = rows[
|
||||
("douyin", STORE_SPECIALTY, "7687432318584143857", dt.date(2026, 9, 28))
|
||||
]
|
||||
assert video_row[fields.index("note_type")] == "video"
|
||||
assert note_row[fields.index("note_type")] == "note"
|
||||
assert video_row[fields.index("account_alias")] == "抖音·机能号"
|
||||
|
||||
|
||||
def test_make_row_validates_fields_and_truncates():
|
||||
row = sync_tool.make_row(
|
||||
"douyin", dt.date(2026, 9, 28), "a.xlsx",
|
||||
title="超长标题" * 200, video_id="1",
|
||||
)
|
||||
fields = sync_tool.ROW_FIELDS
|
||||
assert len(row) == len(fields)
|
||||
assert row[fields.index("title")] == ("超长标题" * 200)[:512]
|
||||
assert row[fields.index("account_alias")] == ""
|
||||
import pytest
|
||||
|
||||
with pytest.raises(KeyError):
|
||||
sync_tool.make_row("douyin", dt.date(2026, 9, 28), "a.xlsx",
|
||||
unknown_field=1)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# SQL 安全 / 表结构 / 配置登记
|
||||
# ============================================================
|
||||
def test_upsert_sql_is_fully_parameterized():
|
||||
source = (TOOLS / "sync_self_video_metrics.py").read_text(encoding="utf-8")
|
||||
matches = re.findall(
|
||||
r"cur\.execute\(\s*\"\"\"\s*(INSERT\b.*?)\"\"\",\s*row,",
|
||||
source,
|
||||
re.DOTALL,
|
||||
)
|
||||
assert len(matches) == 1, "入库 upsert 必须以字面量 SQL + 参数元组形式调用"
|
||||
sql = matches[0]
|
||||
assert sql.count("%s") == len(sync_tool.ROW_FIELDS) # 全部参数绑定
|
||||
assert "{" not in sql and ".format(" not in sql
|
||||
assert "ON CONFLICT (platform, account_name, video_id, metric_date)" in sql
|
||||
|
||||
|
||||
def test_schema_and_migrations_declare_self_video_metrics_consistently():
|
||||
schema = (TOOLS / "schema_gyxx_super_data.sql").read_text(encoding="utf-8")
|
||||
migration_009 = (TOOLS / "migrations" / "009_self_video_metrics.sql").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
migration_010 = (
|
||||
TOOLS / "migrations" / "010_self_video_metrics_platforms.sql"
|
||||
).read_text(encoding="utf-8")
|
||||
for sql in (schema, migration_009, migration_010):
|
||||
assert "cmt_self_video_metrics" in sql
|
||||
for sql in (schema, migration_009):
|
||||
assert "CREATE TABLE IF NOT EXISTS cmt_self_video_metrics" in sql
|
||||
assert "UNIQUE (platform, account_name, video_id, metric_date)" in sql
|
||||
combined = schema + "\n" + migration_009 + "\n" + migration_010
|
||||
for column in (
|
||||
"account_alias", "video_id", "video_url", "publish_time",
|
||||
"exposure", "like_count", "comment_count", "share_count",
|
||||
"est_sales_count", "est_sales_amount", "note_type",
|
||||
"creation_method", "publish_status", "mounted_products",
|
||||
"homepage_redirect_count", "live_redirect_count", "payment_amount",
|
||||
"payment_orders", "coin_count", "favorite_count", "danmaku_count",
|
||||
"page_metrics",
|
||||
):
|
||||
assert re.search(rf"\b{column}\b", combined), f"{column} 缺失"
|
||||
|
||||
|
||||
def test_workflow_commands_and_bindings_declare_self_content_pipeline():
|
||||
workflows = json.loads(
|
||||
(PROJECT_ROOT / "config" / "workflows.json").read_text(encoding="utf-8")
|
||||
)
|
||||
workflow = next(
|
||||
item
|
||||
for item in workflows["workflows"]
|
||||
if item["id"] == "content.metrics.self_operated.daily"
|
||||
)
|
||||
steps = {step["id"]: step for step in workflow["execution"]["steps"]}
|
||||
assert set(steps) == {
|
||||
"refresh_self_mapping", "collect_self_bilibili", "collect_self_douyin",
|
||||
"collect_self_xiaohongshu", "sync", "sync_self_video_metrics",
|
||||
}
|
||||
assert steps["collect_self_xiaohongshu"]["entry"] == "self_xhs_scraper.py"
|
||||
assert steps["collect_self_xiaohongshu"]["depends_on"] == ["collect_self_douyin"]
|
||||
assert steps["sync_self_video_metrics"]["depends_on"] == [
|
||||
"sync", "collect_self_xiaohongshu",
|
||||
]
|
||||
module_root = PROJECT_ROOT / "src/gyxx_flow/modules/content_marketing"
|
||||
for entry in (
|
||||
"self_xhs_scraper.py",
|
||||
"data/tools/sync_self_video_metrics.py",
|
||||
):
|
||||
assert (module_root / entry).is_file()
|
||||
|
||||
commands = json.loads(
|
||||
(PROJECT_ROOT / "config" / "commands.json").read_text(encoding="utf-8")
|
||||
)
|
||||
command_entries = {item["id"]: item for item in commands["commands"]}
|
||||
assert command_entries["content.metrics.collect_self_xiaohongshu"]["entry"] == (
|
||||
"self_xhs_scraper.py"
|
||||
)
|
||||
assert command_entries["content.metrics.sync_self_video_metrics"]["entry"] == (
|
||||
"data/tools/sync_self_video_metrics.py"
|
||||
)
|
||||
|
||||
bindings = json.loads(
|
||||
(PROJECT_ROOT / "config" / "runtime-bindings.json").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
)
|
||||
xhs_binding = bindings["scripts"]["content.metrics.collect_self_xiaohongshu"]
|
||||
assert xhs_binding["script_id"] == "content_marketing:self_xhs_scraper.py"
|
||||
assert xhs_binding["required_cookie_domains"] == ["xiaohongshu.com"]
|
||||
assert "XHS_ARK_ACCOUNT" in xhs_binding["credential_env_names"]
|
||||
sync_binding = bindings["scripts"]["content.metrics.sync_self_video_metrics"]
|
||||
assert sync_binding["cdp_port"] == 22164
|
||||
ports = [entry["cdp_port"] for entry in bindings["scripts"].values()]
|
||||
assert len(ports) == len(set(ports)), "CDP 端口必须唯一"
|
||||
|
||||
|
||||
def test_metric_date_resolution_and_stale_guard(tmp_path, monkeypatch):
|
||||
monkeypatch.delenv("GYXX_BUSINESS_DATE", raising=False)
|
||||
assert sync_tool.runtime_business_date() is None
|
||||
monkeypatch.setenv("GYXX_BUSINESS_DATE", "2026-09-28")
|
||||
assert sync_tool.runtime_business_date() == dt.date(2026, 9, 28)
|
||||
assert sync_tool.resolve_metric_date("2026-09-01") == dt.date(2026, 9, 1)
|
||||
|
||||
xlsx = Path(_write_export_xlsx(tmp_path / "a.xlsx", _douyin_sample_rows()))
|
||||
fresh = dt.datetime.now().timestamp()
|
||||
os.utime(xlsx, (fresh, fresh))
|
||||
assert sync_tool.is_current_artifact(xlsx, dt.date.today())
|
||||
assert not sync_tool.is_current_artifact(xlsx, dt.date(2026, 9, 21))
|
||||
|
||||
|
||||
def test_runtime_business_date_rejects_invalid_value(monkeypatch):
|
||||
monkeypatch.setenv("GYXX_BUSINESS_DATE", "2026/09/28")
|
||||
with __import__("pytest").raises(RuntimeError):
|
||||
sync_tool.runtime_business_date()
|
||||
@@ -161,7 +161,7 @@ def test_erp_login_navigates_before_probing_initial_blank_page(
|
||||
action = erp_login.make_page_action(
|
||||
login_url="https://erp.test/login.aspx",
|
||||
username="user",
|
||||
password="pw",
|
||||
password="secret",
|
||||
home_url="https://erp.test/epaas",
|
||||
timeout_ms=1000,
|
||||
captcha_checks=0,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
|
||||
import collect_douyin_qianchuan_ads as collector
|
||||
@@ -90,3 +91,128 @@ def test_report_ready_wait_guards_missing_body() -> None:
|
||||
"arg": list(collector._required_page_markers()),
|
||||
"timeout": 90_000,
|
||||
}
|
||||
|
||||
|
||||
def test_matching_task_rows_uses_scene_and_exact_date_range() -> None:
|
||||
filters: list[str] = []
|
||||
|
||||
class _Locator:
|
||||
def filter(self, *, has_text: str):
|
||||
filters.append(has_text)
|
||||
return self
|
||||
|
||||
class _Page:
|
||||
def locator(self, selector: str) -> _Locator:
|
||||
assert selector == "tr.ovui-tr"
|
||||
return _Locator()
|
||||
|
||||
collector._matching_task_rows(
|
||||
_Page(), date(2026, 9, 9), date(2026, 9, 9)
|
||||
)
|
||||
|
||||
assert filters == [
|
||||
collector.TASK_SCENE_MARKER,
|
||||
"2026-09-09 00:00:00-2026-09-09 23:59:59",
|
||||
]
|
||||
|
||||
|
||||
def test_trigger_report_download_returns_direct_download() -> None:
|
||||
expected_download = object()
|
||||
|
||||
class _Button:
|
||||
def __init__(self, page) -> None:
|
||||
self.page = page
|
||||
|
||||
def click(self) -> None:
|
||||
self.page.listener(expected_download)
|
||||
|
||||
class _Page:
|
||||
listener = None
|
||||
|
||||
def on(self, event: str, listener) -> None:
|
||||
assert event == "download"
|
||||
self.listener = listener
|
||||
|
||||
def remove_listener(self, event: str, listener) -> None:
|
||||
assert event == "download"
|
||||
assert listener == self.listener
|
||||
|
||||
def locator(self, selector: str):
|
||||
assert selector == collector.DOWNLOAD_SELECTOR
|
||||
return _Button(self)
|
||||
|
||||
assert collector._trigger_report_download(_Page()) is expected_download
|
||||
|
||||
|
||||
def test_trigger_report_download_detects_async_task_notification() -> None:
|
||||
class _Locator:
|
||||
def click(self) -> None:
|
||||
pass
|
||||
|
||||
def inner_text(self, *, timeout: int) -> str:
|
||||
assert timeout == 10_000
|
||||
return "已生成下载任务,可前往任务中心查看"
|
||||
|
||||
class _Page:
|
||||
def on(self, event: str, listener) -> None:
|
||||
assert event == "download"
|
||||
|
||||
def remove_listener(self, event: str, listener) -> None:
|
||||
assert event == "download"
|
||||
|
||||
def locator(self, selector: str) -> _Locator:
|
||||
assert selector in {collector.DOWNLOAD_SELECTOR, "body"}
|
||||
return _Locator()
|
||||
|
||||
assert collector._trigger_report_download(_Page()) is None
|
||||
|
||||
|
||||
def test_download_task_from_page_downloads_completed_matching_row() -> None:
|
||||
expected_download = object()
|
||||
|
||||
class _DownloadInfo:
|
||||
value = expected_download
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, traceback) -> None:
|
||||
pass
|
||||
|
||||
class _DownloadButton:
|
||||
def click(self) -> None:
|
||||
pass
|
||||
|
||||
class _Row:
|
||||
def inner_text(self) -> str:
|
||||
return "全域推广数据-推商品\n生成完成\n下载"
|
||||
|
||||
def get_by_text(self, text: str, *, exact: bool) -> _DownloadButton:
|
||||
assert (text, exact) == ("下载", True)
|
||||
return _DownloadButton()
|
||||
|
||||
class _Rows:
|
||||
first = _Row()
|
||||
|
||||
def count(self) -> int:
|
||||
return 1
|
||||
|
||||
class _Page:
|
||||
def expect_download(self, *, timeout: int) -> _DownloadInfo:
|
||||
assert timeout == 60_000
|
||||
return _DownloadInfo()
|
||||
|
||||
page = _Page()
|
||||
original = collector._matching_task_rows
|
||||
collector._matching_task_rows = lambda *args: _Rows()
|
||||
try:
|
||||
actual = collector._download_task_from_page(
|
||||
page,
|
||||
date(2026, 9, 9),
|
||||
date(2026, 9, 9),
|
||||
wait_timeout_ms=0,
|
||||
)
|
||||
finally:
|
||||
collector._matching_task_rows = original
|
||||
|
||||
assert actual is expected_download
|
||||
@@ -173,3 +173,173 @@ def test_safe_extract_zip_keeps_csv_inside_output(tmp_path: Path) -> None:
|
||||
files = collector._safe_extract_zip(archive, tmp_path / "extracted")
|
||||
|
||||
assert files == [(tmp_path / "extracted" / "nested" / "full.csv").resolve()]
|
||||
|
||||
|
||||
FULL_REPORT_CSV = """查询条件,广告主PIN: 光影行星旗舰店霖; 时间: 2026-09-19至2026-09-19
|
||||
日期,'当前时间',SPU ID,商品计划名称,SpuImgUrl,花费
|
||||
20260919~20260919,当前时间,10032366483452,波塞冬托特包,img,43.68
|
||||
20260919~20260919,当前时间,10033333485894,宙斯双肩包,img,104.78
|
||||
汇总
|
||||
0,当前时间,0,,,148.46
|
||||
|
||||
日期,'对比时间',SPU ID,商品计划名称,SpuImgUrl,花费
|
||||
20260918~20260918,对比时间,10032366483452,波塞冬托特包,img,40.00
|
||||
汇总
|
||||
0,对比时间,0,,,40.00
|
||||
"""
|
||||
|
||||
NOT_READY_FULL_REPORT_CSV = """查询条件,广告主PIN: 光影行星旗舰店霖; 时间: 2026-09-20至2026-09-20
|
||||
日期,'当前时间',SPU ID,商品计划名称,SpuImgUrl,花费
|
||||
汇总
|
||||
0,当前时间,0,,,0.00,0.00,0.00,0,0.00
|
||||
|
||||
日期,'对比时间',SPU ID,商品计划名称,SpuImgUrl,花费
|
||||
20260919~20260919,对比时间,10032366483452,波塞冬托特包,img,43.68
|
||||
汇总
|
||||
0,对比时间,0,,,148.46
|
||||
"""
|
||||
|
||||
NON_FULL_REPORT_CSV = """查询条件,广告主PIN: 光影行星旗舰店霖; 时间: 2026-09-20至2026-09-20
|
||||
SKU ID,SKU 名称,点击时间,'当前时间',花费
|
||||
10144206623346,阿波罗斜挎包,20260920~20260920,当前时间,105.50
|
||||
汇总
|
||||
0,当前时间,0,,,105.50
|
||||
"""
|
||||
|
||||
|
||||
def _write_report(tmp_path: Path, name: str, content: str) -> Path:
|
||||
path = tmp_path / name
|
||||
path.write_text(content, encoding="utf-8-sig")
|
||||
return path
|
||||
|
||||
|
||||
def test_report_content_rows_counts_real_current_time_spu_rows(tmp_path: Path) -> None:
|
||||
path = _write_report(tmp_path, "full_20260919.csv", FULL_REPORT_CSV)
|
||||
|
||||
assert collector._report_content_rows(path, "full") == 2
|
||||
|
||||
|
||||
def test_report_content_rows_flags_report_generated_before_data_ready(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
path = _write_report(tmp_path, "full_20260920.csv", NOT_READY_FULL_REPORT_CSV)
|
||||
|
||||
assert collector._report_content_rows(path, "full") == 0
|
||||
|
||||
|
||||
def test_report_content_rows_counts_non_full_sku_rows(tmp_path: Path) -> None:
|
||||
path = _write_report(tmp_path, "non_full_20260920.csv", NON_FULL_REPORT_CSV)
|
||||
|
||||
assert collector._report_content_rows(path, "non_full") == 1
|
||||
|
||||
|
||||
def test_report_content_rows_rejects_csv_without_marker_header(tmp_path: Path) -> None:
|
||||
path = _write_report(tmp_path, "broken.csv", "查询条件\n花费\n1.00\n")
|
||||
|
||||
with pytest.raises(collector.JDAdCostCollectionError, match="表头"):
|
||||
collector._report_content_rows(path, "full")
|
||||
|
||||
|
||||
def test_ensure_report_data_accepts_empty_full_when_non_full_has_data(
|
||||
monkeypatch, tmp_path: Path
|
||||
) -> None:
|
||||
empty_full = _write_report(tmp_path, "full_20260920.csv", NOT_READY_FULL_REPORT_CSV)
|
||||
sku_report = _write_report(tmp_path, "non_full_20260920.csv", NON_FULL_REPORT_CSV)
|
||||
resubmits: list[collector.ReportType] = []
|
||||
sleeps: list[float] = []
|
||||
|
||||
monkeypatch.setattr(
|
||||
collector,
|
||||
"_report_content_rows",
|
||||
lambda path, rt: 0 if rt == "full" else 1,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
collector,
|
||||
"_resubmit_report",
|
||||
lambda _page, rt, _td: resubmits.append(rt),
|
||||
)
|
||||
monkeypatch.setattr(collector.time, "sleep", sleeps.append)
|
||||
|
||||
downloaded = collector._ensure_report_data(
|
||||
object(),
|
||||
{"non_full": sku_report, "full": empty_full},
|
||||
date(2026, 9, 20),
|
||||
tmp_path,
|
||||
timeout_seconds=1,
|
||||
)
|
||||
|
||||
assert downloaded == {"non_full": sku_report, "full": empty_full}
|
||||
assert resubmits == []
|
||||
assert sleeps == []
|
||||
|
||||
|
||||
def test_ensure_report_data_resubmits_both_reports_when_neither_has_data(
|
||||
monkeypatch, tmp_path: Path
|
||||
) -> None:
|
||||
empty_full = _write_report(tmp_path, "full_20260920.csv", NOT_READY_FULL_REPORT_CSV)
|
||||
sku_report = _write_report(tmp_path, "non_full_20260920.csv", NON_FULL_REPORT_CSV)
|
||||
actions: list[object] = []
|
||||
row_counts = iter((0, 0, 1, 2))
|
||||
|
||||
monkeypatch.setattr(
|
||||
collector, "_report_content_rows", lambda _path, _rt: next(row_counts)
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
collector,
|
||||
"_resubmit_report",
|
||||
lambda _page, rt, _td: actions.append(("resubmit", rt)),
|
||||
)
|
||||
|
||||
def fake_download(_page, rt, _td, _od, *, timeout_seconds):
|
||||
del timeout_seconds
|
||||
actions.append(("download", rt))
|
||||
return {"non_full": sku_report, "full": empty_full}[rt]
|
||||
|
||||
monkeypatch.setattr(collector, "download_ready_report", fake_download)
|
||||
monkeypatch.setattr(collector.time, "sleep", lambda _seconds: None)
|
||||
|
||||
downloaded = collector._ensure_report_data(
|
||||
object(),
|
||||
{"non_full": sku_report, "full": empty_full},
|
||||
date(2026, 9, 20),
|
||||
tmp_path,
|
||||
timeout_seconds=1,
|
||||
)
|
||||
|
||||
assert downloaded == {"non_full": sku_report, "full": empty_full}
|
||||
assert actions == [
|
||||
("resubmit", "non_full"),
|
||||
("download", "non_full"),
|
||||
("resubmit", "full"),
|
||||
("download", "full"),
|
||||
]
|
||||
|
||||
|
||||
def test_ensure_report_data_fails_when_non_full_never_arrives(
|
||||
monkeypatch, tmp_path: Path
|
||||
) -> None:
|
||||
empty_full = _write_report(tmp_path, "full_20260920.csv", NOT_READY_FULL_REPORT_CSV)
|
||||
sku_report = _write_report(tmp_path, "non_full_20260920.csv", NON_FULL_REPORT_CSV)
|
||||
resubmits: list[collector.ReportType] = []
|
||||
|
||||
monkeypatch.setattr(collector, "_report_content_rows", lambda _path, _rt: 0)
|
||||
monkeypatch.setattr(
|
||||
collector,
|
||||
"_resubmit_report",
|
||||
lambda _page, rt, _td: resubmits.append(rt),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
collector, "download_ready_report", lambda _page, rt, _td, _od, *, timeout_seconds: {"non_full": sku_report, "full": empty_full}[rt]
|
||||
)
|
||||
monkeypatch.setattr(collector.time, "sleep", lambda _seconds: None)
|
||||
|
||||
with pytest.raises(collector.JDAdCostCollectionError, match="尚未就绪"):
|
||||
collector._ensure_report_data(
|
||||
object(),
|
||||
{"non_full": sku_report, "full": empty_full},
|
||||
date(2026, 9, 20),
|
||||
tmp_path,
|
||||
timeout_seconds=1,
|
||||
)
|
||||
|
||||
assert resubmits == ["non_full", "full", "non_full", "full"]
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import zipfile
|
||||
from datetime import date
|
||||
@@ -171,6 +172,75 @@ def test_safe_extract_zip_returns_csv_and_writes_manifest(tmp_path: Path) -> Non
|
||||
assert (tmp_path / "extracted" / "商品报表_20260817.csv").is_file()
|
||||
|
||||
|
||||
def test_save_signed_download_retries_transient_dns_error(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
class _Response:
|
||||
ok = True
|
||||
status = 200
|
||||
|
||||
def body(self) -> bytes:
|
||||
payload = io.BytesIO()
|
||||
with zipfile.ZipFile(payload, "w") as archive:
|
||||
archive.writestr("商品报表.csv", "日期,主体ID\n")
|
||||
return payload.getvalue()
|
||||
|
||||
class _Request:
|
||||
def __init__(self) -> None:
|
||||
self.calls = 0
|
||||
|
||||
def get(self, url: str, *, timeout: int) -> _Response:
|
||||
del timeout
|
||||
self.calls += 1
|
||||
if self.calls == 1:
|
||||
raise RuntimeError(
|
||||
f"APIRequestContext.get: getaddrinfo ENOTFOUND {url}"
|
||||
)
|
||||
return _Response()
|
||||
|
||||
request = _Request()
|
||||
|
||||
class _Page:
|
||||
context = type("_Context", (), {"request": request})()
|
||||
|
||||
monkeypatch.setattr(collector.time, "sleep", lambda _seconds: None)
|
||||
download_url = "https://example.test/report.zip?Signature=secret"
|
||||
|
||||
path = collector._save_signed_download(
|
||||
_Page(), download_url, tmp_path, date(2026, 8, 17)
|
||||
)
|
||||
|
||||
assert request.calls == 2
|
||||
assert zipfile.is_zipfile(path)
|
||||
|
||||
|
||||
def test_save_signed_download_redacts_signed_url_after_network_retries(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
class _Request:
|
||||
def get(self, url: str, *, timeout: int) -> object:
|
||||
del timeout
|
||||
raise RuntimeError(f"getaddrinfo ENOTFOUND {url}")
|
||||
|
||||
class _Page:
|
||||
context = type(
|
||||
"_Context", (), {"request": _Request()}
|
||||
)()
|
||||
|
||||
monkeypatch.setattr(collector.time, "sleep", lambda _seconds: None)
|
||||
download_url = "https://example.test/report.zip?Signature=secret"
|
||||
|
||||
with pytest.raises(collector.WanxiangDownloadError) as exc_info:
|
||||
collector._save_signed_download(
|
||||
_Page(), download_url, tmp_path, date(2026, 8, 17)
|
||||
)
|
||||
|
||||
message = str(exc_info.value)
|
||||
assert "getaddrinfo ENOTFOUND" in message
|
||||
assert "Signature=secret" not in message
|
||||
assert "https://example.test/report.zip" in message
|
||||
|
||||
|
||||
def test_safe_extract_zip_rejects_path_traversal(tmp_path: Path) -> None:
|
||||
archive_path = tmp_path / "unsafe.zip"
|
||||
with zipfile.ZipFile(archive_path, "w") as archive:
|
||||
|
||||
@@ -120,7 +120,7 @@ def test_get_pool_retries_transient_initial_connection(
|
||||
"PG_PORT": "5432",
|
||||
"PG_DB": "warehouse",
|
||||
"PG_USER": "collector",
|
||||
"PG_PASSWORD": "unit-test-only",
|
||||
"PG_PASSWORD": "secret",
|
||||
}.items():
|
||||
monkeypatch.setenv(name, value)
|
||||
monkeypatch.setattr(db, "_pool", None)
|
||||
|
||||
@@ -213,3 +213,64 @@ def test_jd_dry_run_accepts_explicit_desktop_style_files(tmp_path: Path, capsys)
|
||||
assert "全站 SPU 1" in output
|
||||
assert "非全站 SKU 1" in output
|
||||
assert "DRY-RUN" in output
|
||||
|
||||
|
||||
def test_parse_files_treats_empty_full_report_as_zero_when_sku_has_rows(
|
||||
tmp_path: Path,
|
||||
capsys,
|
||||
) -> None:
|
||||
full = tmp_path / "full_20260920.csv"
|
||||
full.write_text(
|
||||
"查询条件,广告主PIN: 光影行星旗舰店霖; 时间: 2026-09-20\n"
|
||||
"日期,'当前时间',SPU ID,商品计划名称,SpuImgUrl,花费,全站投产比\n"
|
||||
"汇总\n"
|
||||
"0,当前时间,0,,,0.00,0.00\n",
|
||||
encoding="utf-8-sig",
|
||||
)
|
||||
sku = tmp_path / "non_full_20260920.csv"
|
||||
sku.write_text(
|
||||
"查询条件,广告主PIN: 光影行星旗舰店霖; 时间: 2026-09-20\n"
|
||||
"SKU ID,SKU 名称,点击时间,'当前时间',花费,展现数,点击数,投产比\n"
|
||||
"sku-1,光影行星 盖亚款式 黑色,20260920~20260920,当前时间,3.20,100,5,2.50\n",
|
||||
encoding="utf-8-sig",
|
||||
)
|
||||
|
||||
full_records, sku_records = importer._parse_files(
|
||||
date(2026, 9, 20),
|
||||
full_file=full,
|
||||
non_full_file=sku,
|
||||
advertiser_pin="光影行星旗舰店霖",
|
||||
shop_account="光影行星研发",
|
||||
)
|
||||
|
||||
assert full_records == []
|
||||
assert [record["sku_id"] for record in sku_records] == ["sku-1"]
|
||||
assert "零投放" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_parse_files_still_fails_when_both_reports_are_empty(tmp_path: Path) -> None:
|
||||
empty = tmp_path / "full_20260920.csv"
|
||||
empty.write_text(
|
||||
"查询条件,广告主PIN: 光影行星旗舰店霖; 时间: 2026-09-20\n"
|
||||
"日期,'当前时间',SPU ID,商品计划名称,SpuImgUrl,花费,全站投产比\n"
|
||||
"0,当前时间,0,,,0.00,0.00\n",
|
||||
encoding="utf-8-sig",
|
||||
)
|
||||
empty_sku = tmp_path / "non_full_20260920.csv"
|
||||
empty_sku.write_text(
|
||||
"查询条件,广告主PIN: 光影行星旗舰店霖; 时间: 2026-09-20\n"
|
||||
"SKU ID,SKU 名称,点击时间,'当前时间',花费,展现数,点击数,投产比\n"
|
||||
"0,汇总占位,20260920~20260920,当前时间,0.00,0,0,0.00\n",
|
||||
encoding="utf-8-sig",
|
||||
)
|
||||
|
||||
import pytest
|
||||
|
||||
with pytest.raises(ValueError, match="当前时间 SKU 行"):
|
||||
importer._parse_files(
|
||||
date(2026, 9, 20),
|
||||
full_file=empty,
|
||||
non_full_file=empty_sku,
|
||||
advertiser_pin="光影行星旗舰店霖",
|
||||
shop_account="光影行星研发",
|
||||
)
|
||||
@@ -0,0 +1,96 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
|
||||
from jd_main_image_collector import (
|
||||
DETAIL_URL,
|
||||
_goto,
|
||||
_load_reusable_style_output,
|
||||
_locate_product_detail_search,
|
||||
_wait_for_product_detail,
|
||||
)
|
||||
|
||||
|
||||
class _Locator:
|
||||
def __init__(self, visible: bool) -> None:
|
||||
self.visible = visible
|
||||
|
||||
@property
|
||||
def first(self) -> _Locator:
|
||||
return self
|
||||
|
||||
def is_visible(self, timeout: int = 0) -> bool:
|
||||
return self.visible
|
||||
|
||||
|
||||
class _Page:
|
||||
def __init__(self, visible_selectors: set[str], goto_errors: int = 0) -> None:
|
||||
self.url = DETAIL_URL
|
||||
self.visible_selectors = visible_selectors
|
||||
self.goto_errors = goto_errors
|
||||
self.waits: list[str] = []
|
||||
self.timeouts: list[int] = []
|
||||
|
||||
def locator(self, selector: str) -> _Locator:
|
||||
return _Locator(selector in self.visible_selectors)
|
||||
|
||||
def wait_for_timeout(self, milliseconds: int) -> None:
|
||||
self.timeouts.append(milliseconds)
|
||||
|
||||
def goto(self, url: str, **kwargs: object) -> None:
|
||||
self.url = url
|
||||
self.waits.append(str(kwargs["wait_until"]))
|
||||
if self.goto_errors:
|
||||
self.goto_errors -= 1
|
||||
raise RuntimeError("net::ERR_CONNECTION_CLOSED")
|
||||
|
||||
|
||||
def test_product_detail_search_uses_new_jd_input() -> None:
|
||||
page = _Page({"input.jd-input-multiple-input-input"})
|
||||
|
||||
assert _locate_product_detail_search(page) is not None
|
||||
|
||||
|
||||
def test_product_detail_wait_accepts_legacy_wrapper_fallback() -> None:
|
||||
page = _Page({".jmtd-multiple-input"})
|
||||
|
||||
_wait_for_product_detail(page, timeout_ms=1_000)
|
||||
|
||||
|
||||
def test_shangzhi_navigation_uses_domcontentloaded() -> None:
|
||||
page = _Page(set())
|
||||
|
||||
_goto(page, DETAIL_URL)
|
||||
|
||||
assert page.waits == ["domcontentloaded"]
|
||||
|
||||
|
||||
def test_product_detail_navigation_uses_new_jd_host() -> None:
|
||||
assert DETAIL_URL == "https://jdsz.jd.com/szweb/view/product/productDetail.html"
|
||||
|
||||
|
||||
def test_transient_detail_navigation_is_retried() -> None:
|
||||
page = _Page(set(), goto_errors=1)
|
||||
|
||||
_goto(page, DETAIL_URL)
|
||||
|
||||
assert page.waits == ["domcontentloaded", "domcontentloaded"]
|
||||
assert page.timeouts == [1000]
|
||||
|
||||
|
||||
def test_same_date_result_is_reused_only_with_local_image(tmp_path) -> None:
|
||||
style_dir = tmp_path / datetime.now().strftime("%Y-%m-%d") / "同款"
|
||||
style_dir.mkdir(parents=True)
|
||||
image_path = style_dir / "jd_images" / "main.jpg"
|
||||
image_path.parent.mkdir()
|
||||
image_path.write_bytes(b"image")
|
||||
(style_dir / "jd_main_images.json").write_text(
|
||||
json.dumps({"records": [{"image_key": "main.jpg", "local_image_path": str(image_path)}]}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
reusable = _load_reusable_style_output(tmp_path, "同款")
|
||||
|
||||
assert reusable is not None
|
||||
assert reusable["records"][0]["image_key"] == "main.jpg"
|
||||
@@ -34,6 +34,34 @@ class MainImagePathTests(unittest.TestCase):
|
||||
self.assertEqual(resolve_images_dir(style_dir, "jd"), current)
|
||||
|
||||
|
||||
class MainImageLarkRetryTests(unittest.TestCase):
|
||||
def test_transient_feishu_transport_error_is_retried(self):
|
||||
with (
|
||||
patch.object(
|
||||
tmall_insert,
|
||||
"run_lark",
|
||||
side_effect=[(1, "", "EOF"), (0, "ok", "")],
|
||||
) as run_lark,
|
||||
patch.object(tmall_insert.time, "sleep") as sleep,
|
||||
):
|
||||
result = tmall_insert.run_lark_with_retry(["base", "+field-list"])
|
||||
|
||||
self.assertEqual(result, (0, "ok", ""))
|
||||
self.assertEqual(run_lark.call_count, 2)
|
||||
sleep.assert_called_once_with(1.0)
|
||||
|
||||
def test_non_transient_feishu_error_is_not_retried(self):
|
||||
with patch.object(
|
||||
tmall_insert,
|
||||
"run_lark",
|
||||
return_value=(4, "", "missing field"),
|
||||
) as run_lark:
|
||||
result = tmall_insert.run_lark_with_retry(["base", "+field-list"])
|
||||
|
||||
self.assertEqual(result, (4, "", "missing field"))
|
||||
run_lark.assert_called_once()
|
||||
|
||||
|
||||
class MainImageWorkflowPreflightTests(unittest.TestCase):
|
||||
def test_zero_valid_styles_is_a_formal_failure(self):
|
||||
for runner in (tmall_runner, jd_runner):
|
||||
|
||||
@@ -8,6 +8,7 @@ from gyxx_flow.modules.product_commerce.market_rank_product_import import (
|
||||
extract_document_labels,
|
||||
normalize_scene_name,
|
||||
normalize_style_name,
|
||||
reuse_saved_categories,
|
||||
)
|
||||
|
||||
RUNTIME_ROOT = (
|
||||
@@ -87,6 +88,28 @@ def test_missing_tmall_price_keeps_product_but_excludes_it_from_amount():
|
||||
assert snapshot["quality_status"] == "missing_price"
|
||||
|
||||
|
||||
def test_saved_category_is_reused_by_platform_product_id_without_changing_raw_data():
|
||||
snapshots = [
|
||||
{
|
||||
"platform_product_id": "123",
|
||||
"category_name": "旅行箱",
|
||||
"raw_data": {"category_name": "旅行箱", "rank": 1},
|
||||
},
|
||||
{
|
||||
"platform_product_id": "456",
|
||||
"category_name": "双肩包",
|
||||
"raw_data": {"category_name": "双肩包", "rank": 2},
|
||||
},
|
||||
]
|
||||
|
||||
reused, count = reuse_saved_categories(snapshots, {"123": "商务旅行箱"})
|
||||
|
||||
assert reused[0]["category_name"] == "商务旅行箱"
|
||||
assert reused[0]["raw_data"] == {"category_name": "旅行箱", "rank": 1}
|
||||
assert reused[1]["category_name"] == "双肩包"
|
||||
assert count == 1
|
||||
|
||||
|
||||
def test_feishu_document_labels_keep_raw_values_and_normalized_values():
|
||||
content = """
|
||||
<title>京东榜单</title>
|
||||
@@ -111,6 +134,7 @@ def test_feishu_document_labels_keep_raw_values_and_normalized_values():
|
||||
assert labels["123456"]["style_name"] == "都市新旅"
|
||||
assert labels["123456"]["raw_scene_name"] == "上班通勤"
|
||||
assert labels["123456"]["scene_name"] == "通勤上班"
|
||||
assert labels["123456"]["category_name"] == "旅行箱"
|
||||
assert labels["123456"]["unit_price_text"] == "¥399"
|
||||
|
||||
|
||||
@@ -121,3 +145,4 @@ def test_collector_schema_contains_idempotent_market_snapshot_tables():
|
||||
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
|
||||
assert "ADD COLUMN IF NOT EXISTS category_name TEXT" in schema
|
||||
@@ -11,6 +11,7 @@ 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 run_daily_persona as persona_launcher
|
||||
import taobao_dmp_item_crowd_insight_screenshots as dmp
|
||||
|
||||
from gyxx_flow.adapters import acceptance_policy
|
||||
@@ -315,6 +316,15 @@ def test_dy_persistent_profile_is_not_overwritten_by_exported_cookies() -> None:
|
||||
}
|
||||
|
||||
|
||||
def test_dy_account_profile_receives_vault_cookies_without_sharing_profile() -> None:
|
||||
cookies = [{"name": "session", "value": "new", "domain": ".jinritemai.com"}]
|
||||
|
||||
assert dy_collector.account_profile_launch_options("bound-profile", cookies) == {
|
||||
"user_data_dir": "bound-profile",
|
||||
"cookies": cookies,
|
||||
}
|
||||
|
||||
|
||||
def test_dy_profileless_fallback_can_restore_exported_cookies() -> None:
|
||||
cookies = [{"name": "session", "value": "new", "domain": ".jinritemai.com"}]
|
||||
|
||||
@@ -397,3 +407,48 @@ def test_jd_persona_login_home_navigation_does_not_wait_for_networkidle() -> Non
|
||||
assert 'wait_until="networkidle"' not in home_block[:400]
|
||||
assert 'wait_until="domcontentloaded"' in home_block[:400]
|
||||
assert "timeout=60_000" in home_block[:400]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("url", "expected"),
|
||||
(
|
||||
(
|
||||
"https://sz.jd.com/szweb/sz/view/productAnalysis/productDetail.html",
|
||||
True,
|
||||
),
|
||||
(
|
||||
"https://jdsz.jd.com/szweb/view/product/productDetail.html",
|
||||
True,
|
||||
),
|
||||
("https://passport.jd.com/new/login.aspx?ReturnUrl=productDetail", False),
|
||||
("https://sz.jd.com/sz/view/notAuthorized.html", False),
|
||||
("https://example.com/szweb/view/product/productDetail.html", False),
|
||||
),
|
||||
)
|
||||
def test_jd_product_detail_route_accepts_vendor_redirect_only(
|
||||
url: str,
|
||||
expected: bool,
|
||||
) -> None:
|
||||
assert jd_worker._is_jd_product_detail_url(url) is expected
|
||||
|
||||
|
||||
def test_persona_launcher_can_select_failed_platforms_and_styles() -> None:
|
||||
assert persona_launcher.selected_platforms("dy,jd,dy") == ["dy", "jd"]
|
||||
assert persona_launcher.selected_platforms("") == ["tm", "dy", "jd"]
|
||||
assert persona_launcher.build_child_command(
|
||||
"collector.py",
|
||||
BUSINESS_DATE,
|
||||
"布谷,白鹭,蓝鹊",
|
||||
) == [
|
||||
sys.executable,
|
||||
"collector.py",
|
||||
"--date",
|
||||
"2026-08-04",
|
||||
"--styles",
|
||||
"布谷,白鹭,蓝鹊",
|
||||
]
|
||||
|
||||
|
||||
def test_persona_launcher_rejects_unknown_platform() -> None:
|
||||
with pytest.raises(ValueError, match="不支持的平台"):
|
||||
persona_launcher.selected_platforms("tm,unknown")
|
||||
@@ -98,6 +98,26 @@ def test_persona_workers_rebind_their_nested_browser_collectors(monkeypatch) ->
|
||||
_assert_child_binding(observed["environment"])
|
||||
|
||||
|
||||
def test_dy_persona_default_uses_inner_account_profile(monkeypatch) -> None:
|
||||
observed: dict[str, object] = {}
|
||||
monkeypatch.setattr(
|
||||
dy_persona_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(dy_persona_worker.subprocess, "run", fake_run)
|
||||
|
||||
assert dy_persona_worker.run_collect_all("", False, date(2026, 8, 4)) is True
|
||||
assert "--user-data-dir" not in observed["command"]
|
||||
_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))
|
||||
|
||||
@@ -592,6 +592,44 @@ class FeishuDocumentWriteTests(unittest.TestCase):
|
||||
self.assertEqual(doc_id, "doc_token")
|
||||
run.assert_called_once()
|
||||
|
||||
def test_ambiguous_doc_create_reconciles_and_retries_after_missing_user_error(self):
|
||||
created = {
|
||||
"data": {"document": {
|
||||
"url": "https://example.feishu.cn/docx/doc_token",
|
||||
"document_id": "doc_token",
|
||||
}},
|
||||
"permission_grant": {"status": "granted", "perm": "full_access"},
|
||||
}
|
||||
responses = [
|
||||
RuntimeError(
|
||||
'lark-cli failed (exit=1): {"error":{"code":20008,'
|
||||
'"message":"The user does not exist."}}'
|
||||
),
|
||||
{"ok": True, "data": {"results": []}},
|
||||
{"identities": {"user": {"openId": "ou_current_user"}}},
|
||||
created,
|
||||
]
|
||||
|
||||
with (
|
||||
patch.object(analysis, "run_lark_cli", side_effect=responses) as run,
|
||||
patch.object(analysis, "DOC_CREATE_RECONCILE_DELAYS_SECONDS", (0,)),
|
||||
):
|
||||
url, doc_id = analysis.write_feishu_doc("测试分析", "# 内容")
|
||||
|
||||
self.assertEqual((url, doc_id), (
|
||||
"https://example.feishu.cn/docx/doc_token",
|
||||
"doc_token",
|
||||
))
|
||||
self.assertEqual(
|
||||
[call.args[0][:2] for call in run.call_args_list],
|
||||
[
|
||||
["docs", "+create"],
|
||||
["docs", "+search"],
|
||||
["auth", "status"],
|
||||
["docs", "+create"],
|
||||
],
|
||||
)
|
||||
|
||||
def test_missing_current_user_identity_stops_before_base_write(self):
|
||||
responses = [
|
||||
{"data": {"document": {
|
||||
@@ -720,20 +758,39 @@ class ExternalEffectCheckpointTests(unittest.TestCase):
|
||||
"permission_granted",
|
||||
)
|
||||
|
||||
def test_ambiguous_and_corrupt_checkpoints_fail_closed(self):
|
||||
def test_unconfirmed_checkpoint_reconciles_before_retrying_and_corrupt_fails_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()
|
||||
responses = [
|
||||
{"ok": True, "data": {"results": []}},
|
||||
{"data": {"document": {
|
||||
"url": "https://example.feishu.cn/docx/doc_token",
|
||||
"document_id": "doc_token",
|
||||
}}, "permission_grant": {"status": "granted"}},
|
||||
]
|
||||
with (
|
||||
patch.object(analysis, "run_lark_cli", side_effect=responses) as run,
|
||||
patch.object(analysis, "DOC_CREATE_RECONCILE_DELAYS_SECONDS", (0,)),
|
||||
):
|
||||
url, doc_id = analysis.write_feishu_doc(
|
||||
ambiguous.title,
|
||||
"# 内容",
|
||||
ambiguous,
|
||||
)
|
||||
self.assertEqual(doc_id, "doc_token")
|
||||
self.assertTrue(url.endswith("/docx/doc_token"))
|
||||
self.assertEqual(
|
||||
analysis.load_style_analysis_checkpoint(ambiguous)["phase"],
|
||||
"permission_granted",
|
||||
)
|
||||
self.assertEqual(
|
||||
[call.args[0][:2] for call in run.call_args_list],
|
||||
[["docs", "+search"], ["docs", "+create"]],
|
||||
)
|
||||
|
||||
with patch.object(analysis, "CURATED_DATA_ROOT", Path(tmp) / "corrupt"):
|
||||
corrupt = self.checkpoint_spec()
|
||||
|
||||
@@ -21,6 +21,78 @@ def _entry() -> baibu.BaibuEntry:
|
||||
)
|
||||
|
||||
|
||||
def test_export_template_retries_transient_network_failure(
|
||||
monkeypatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
calls: list[object] = []
|
||||
sleeps: list[int] = []
|
||||
|
||||
def fake_run(_command, **_kwargs):
|
||||
calls.append(_command)
|
||||
if len(calls) == 2:
|
||||
(tmp_path / "demo.xlsx").write_bytes(b"xlsx")
|
||||
return SimpleNamespace(returncode=0, stdout='{"ok":true}', stderr="")
|
||||
return SimpleNamespace(
|
||||
returncode=4,
|
||||
stdout=(
|
||||
'{"ok":false,"error":{"type":"network",'
|
||||
'"subtype":"timeout"}}'
|
||||
),
|
||||
stderr="",
|
||||
)
|
||||
|
||||
monkeypatch.setattr(baibu, "_lark_cli", lambda: "lark-cli")
|
||||
monkeypatch.setattr(baibu.subprocess, "run", fake_run)
|
||||
monkeypatch.setattr(baibu.time, "sleep", sleeps.append)
|
||||
|
||||
result = baibu._export_template(_entry(), tmp_path)
|
||||
|
||||
assert result == tmp_path / "demo.xlsx"
|
||||
assert len(calls) == 2
|
||||
assert sleeps == [5]
|
||||
|
||||
|
||||
def test_export_template_does_not_retry_non_transient_failure(
|
||||
monkeypatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
calls: list[object] = []
|
||||
sleeps: list[int] = []
|
||||
|
||||
def fake_run(_command, **_kwargs):
|
||||
calls.append(_command)
|
||||
return SimpleNamespace(
|
||||
returncode=4,
|
||||
stdout=(
|
||||
'{"ok":false,"error":{"type":"permission",'
|
||||
'"message":"forbidden"}}'
|
||||
),
|
||||
stderr="",
|
||||
)
|
||||
|
||||
monkeypatch.setattr(baibu, "_lark_cli", lambda: "lark-cli")
|
||||
monkeypatch.setattr(baibu.subprocess, "run", fake_run)
|
||||
monkeypatch.setattr(baibu.time, "sleep", sleeps.append)
|
||||
|
||||
with pytest.raises(baibu.BaibuApplyError, match="exit=4"):
|
||||
baibu._export_template(_entry(), tmp_path)
|
||||
|
||||
assert len(calls) == 1
|
||||
assert sleeps == []
|
||||
|
||||
|
||||
def test_safe_cli_detail_redacts_feishu_tokens() -> None:
|
||||
detail = baibu._safe_cli_detail(
|
||||
'{"token":"secret-token","message":"?token=query-token"}',
|
||||
"",
|
||||
)
|
||||
|
||||
assert "secret-token" not in detail
|
||||
assert "query-token" not in detail
|
||||
assert detail.count("<redacted>") == 2
|
||||
|
||||
|
||||
def test_latest_batch_import_status_parses_processing_and_complete_summary() -> None:
|
||||
processing = baibu._latest_batch_import_status(
|
||||
"您于2026-08-26 09:23:37执行了商品批量导入操作,正在处理中。"
|
||||
@@ -123,13 +195,13 @@ class _LoginLocator:
|
||||
class _LoginFrame:
|
||||
def __init__(self) -> None:
|
||||
self.username = _LoginLocator()
|
||||
self.password = _LoginLocator()
|
||||
self.password_input = _LoginLocator()
|
||||
self.submit = _LoginLocator()
|
||||
|
||||
def locator(self, selector: str):
|
||||
return {
|
||||
"input[placeholder='账号名/邮箱/手机号']": self.username,
|
||||
"input[placeholder='请输入登录密码']": self.password,
|
||||
"input[placeholder='请输入登录密码']": self.password_input,
|
||||
"button[type='submit']": _LoginLocator(visible=False),
|
||||
"input[type='submit']": _LoginLocator(visible=False),
|
||||
}.get(selector, _LoginLocator(visible=False))
|
||||
@@ -161,7 +233,7 @@ def test_login_if_needed_fills_the_password_form_inside_an_iframe(monkeypatch) -
|
||||
|
||||
assert attempted is True
|
||||
assert frame.username.filled == ["demo-account"]
|
||||
assert frame.password.filled == ["demo-password"]
|
||||
assert frame.password_input.filled == ["demo-password"]
|
||||
assert frame.submit.click_count == 1
|
||||
|
||||
|
||||
@@ -181,7 +253,7 @@ def test_manual_login_waits_without_filling_the_password_form(monkeypatch) -> No
|
||||
|
||||
assert attempted is True
|
||||
assert frame.username.filled == []
|
||||
assert frame.password.filled == []
|
||||
assert frame.password_input.filled == []
|
||||
assert frame.submit.click_count == 0
|
||||
|
||||
|
||||
@@ -208,7 +280,7 @@ def test_login_if_needed_reports_missing_credentials_without_filling(monkeypatch
|
||||
raise AssertionError("missing credentials must stop before filling")
|
||||
|
||||
assert frame.username.filled == []
|
||||
assert frame.password.filled == []
|
||||
assert frame.password_input.filled == []
|
||||
|
||||
|
||||
def test_wait_for_authenticated_stops_on_explicit_login_error(monkeypatch) -> None:
|
||||
@@ -555,18 +627,18 @@ def test_complete_draft_products_runs_required_sequence_until_count_is_zero(
|
||||
"draft",
|
||||
"open:True",
|
||||
"否",
|
||||
"下一步",
|
||||
"提交",
|
||||
"draft",
|
||||
"open:False",
|
||||
"否",
|
||||
"下一步",
|
||||
"提交",
|
||||
"draft",
|
||||
]
|
||||
assert clicked == [
|
||||
("否",),
|
||||
("下一步",),
|
||||
("提交", "下一步"),
|
||||
("否",),
|
||||
("下一步",),
|
||||
("提交", "下一步"),
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -496,12 +496,14 @@ class _ActionFingerprintLocator:
|
||||
row_key: str | None,
|
||||
action_text: str,
|
||||
previous_text: str,
|
||||
product_scope_text: str = "",
|
||||
) -> None:
|
||||
self.payload = {
|
||||
"log_extra": log_extra,
|
||||
"row_key": row_key,
|
||||
"action_text": action_text,
|
||||
"previous_text": previous_text,
|
||||
"product_scope_text": product_scope_text,
|
||||
}
|
||||
|
||||
def evaluate(self, script: str) -> dict[str, str | None]:
|
||||
@@ -1187,6 +1189,29 @@ def test_action_fingerprint_falls_back_to_previous_product_row_and_row_key() ->
|
||||
assert "<countdown>" in fingerprint
|
||||
|
||||
|
||||
def test_action_fingerprint_falls_back_to_nested_product_card_context() -> None:
|
||||
locator = _ActionFingerprintLocator(
|
||||
log_extra=json.dumps(
|
||||
{
|
||||
"sub_activity_id": "7610636843016552714",
|
||||
"record_id": "7617013918167138569",
|
||||
}
|
||||
),
|
||||
row_key="7617013918167138569",
|
||||
action_text="报名异常 待改价 20:22:31 立即改价",
|
||||
previous_text="",
|
||||
product_scope_text=(
|
||||
"GYXX/光影行星星背包男双肩包2026新款旅行大... "
|
||||
"类目:箱包-时尚箱包-双肩包 ID: 3757136031745441873"
|
||||
),
|
||||
)
|
||||
|
||||
fingerprint = appeal._action_fingerprint(locator) # noqa: SLF001
|
||||
|
||||
assert appeal._extract_item_id(fingerprint) == "3757136031745441873" # noqa: SLF001
|
||||
assert "record_id: 7617013918167138569" in fingerprint
|
||||
|
||||
|
||||
def test_same_product_in_multiple_activities_has_distinct_fingerprints() -> None:
|
||||
product_id = "3774363265803616739"
|
||||
first = _ActionFingerprintLocator(
|
||||
|
||||
@@ -0,0 +1,381 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from datetime import date, datetime
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from gyxx_flow.modules.shop_intelligence.collectors import (
|
||||
dy_after_sales_quality as collector,
|
||||
)
|
||||
|
||||
_LIST_ROW_REFUND = """订单编号 6929344525347945945 售后编号 147664497007198455 退货退款 平台主动处理
|
||||
GYXX/光影行星阿波罗x1斜挎包男款单肩包电脑包新款2026爆款
|
||||
逆光白/加大号(适合16寸游戏本电脑)
|
||||
商家编码: 10348008
|
||||
小件运费险 7天 15天售后
|
||||
应付金额 ¥369 ◎
|
||||
购买件数 1件
|
||||
售后退款 ¥369 ◎
|
||||
申请件数 1件
|
||||
申请原因 商品品质不好
|
||||
申请时间 2026/09/14 21:26:01
|
||||
同意退款,退款成功
|
||||
钱款去向 >
|
||||
未介入
|
||||
买家退货 运输中 >
|
||||
订单发货 1/1已签收 >
|
||||
查看详情"""
|
||||
|
||||
_LIST_HEADERS = [
|
||||
"",
|
||||
"商品信息",
|
||||
"订单信息",
|
||||
"售后信息",
|
||||
"售后状态",
|
||||
"纠纷仲裁",
|
||||
"物流信息",
|
||||
"操作",
|
||||
]
|
||||
|
||||
_PRODUCT_CELL = """GYXX/光影行星阿波罗x1斜挎包男款单肩包电脑包新款2026爆款
|
||||
逆光白/加大号(适合16寸游戏本电脑)
|
||||
商家编码: 10348008
|
||||
小件运费险 7天 15天售后"""
|
||||
|
||||
_STATUS_CELL = """同意退款,退款成功
|
||||
钱款去向 >"""
|
||||
|
||||
_NEGOTIATION_PANEL = """协商记录
|
||||
时间排序↑
|
||||
消费者 发起了退货退款申请,等待商家处理
|
||||
2026-09-07 21:59:55
|
||||
操作人 消费者
|
||||
售后类型 退货退款
|
||||
货物状态 已收到货
|
||||
退货原因 商品品质不好
|
||||
退款金额 ¥29.9
|
||||
退款说明 不值
|
||||
退货方式 上门取件(2026-09-08 18:00 - 20:00)
|
||||
凭证信息
|
||||
系统 因该用户信誉良好,收到商品后请及时处理售后,系统自动同意退货申请,等待消费者退货
|
||||
2026-09-07 22:00:00
|
||||
操作人 系统
|
||||
退货地址 广东省惠州市惠城区水口街道惠州市惠城区湖溪大道一号 光影行星地球工作舱3楼(抖仓库)
|
||||
系统 预约上门取件
|
||||
2026-09-07 22:00:02
|
||||
操作人 系统
|
||||
预约时间 2026-09-08 18:00:00 至 2026-09-08 20:00:00"""
|
||||
|
||||
_DETAIL_PRODUCT_BLOCK = """GYXX/光影行星星干湿分离袋 洗漱包 收纳袋
|
||||
ID: 3791074652722343083 颜色分类: 黑色 商家编码: 10399079
|
||||
小件运费险 7天 15天售后"""
|
||||
|
||||
_DETAIL_AFTERSALE_BLOCK = """售后信息
|
||||
退货退款 平台主动处理
|
||||
举报异常售后 售后历史
|
||||
订单编号 6929012177139563577 售后编号 1476543384326189817
|
||||
下单时间 2026-08-28 10:14:18 申请金额 ¥29.9 ◎ 消费者应退金额 ¥29.9
|
||||
申请时间 2026-09-07 21:59:55
|
||||
售后原因 商品品质不好(商品品质原因)
|
||||
售后说明 不值
|
||||
消费者凭证
|
||||
收货状态 已收到货
|
||||
申请件数 1件
|
||||
消费者需回寄数量 1件
|
||||
展开全部"""
|
||||
|
||||
|
||||
def _args(**kwargs: object) -> argparse.Namespace:
|
||||
base: dict[str, object] = {
|
||||
"date": None,
|
||||
"from_date": None,
|
||||
"to_date": None,
|
||||
}
|
||||
base.update(kwargs)
|
||||
return argparse.Namespace(**base)
|
||||
|
||||
|
||||
def test_parse_total_count() -> None:
|
||||
assert collector.parse_total_count("共161条 | 批量同意退款") == 161
|
||||
assert collector.parse_total_count("共 22 条") == 22
|
||||
|
||||
with pytest.raises(RuntimeError, match="售后单总数"):
|
||||
collector.parse_total_count("暂无数据")
|
||||
|
||||
|
||||
def test_parse_list_row_extracts_refund_fields_from_full_text() -> None:
|
||||
row = collector.parse_list_row(_LIST_ROW_REFUND)
|
||||
|
||||
assert row["aftersale_id"] == "147664497007198455"
|
||||
assert row["order_id"] == "6929344525347945945"
|
||||
assert row["aftersale_type"] == "退货退款"
|
||||
assert row["status_tag"] == "平台主动处理"
|
||||
assert row["aftersale_status"] == "同意退款,退款成功"
|
||||
assert row["apply_at"] == "2026/09/14 21:26:01"
|
||||
assert row["apply_reason"] == "商品品质不好"
|
||||
assert row["apply_quantity"] == "1"
|
||||
assert row["pay_amount"] == "369"
|
||||
assert row["refund_amount"] == "369"
|
||||
assert row["product_name"] == (
|
||||
"GYXX/光影行星阿波罗x1斜挎包男款单肩包电脑包新款2026爆款"
|
||||
)
|
||||
assert row["product_code"] == "10348008"
|
||||
|
||||
|
||||
def test_parse_list_row_uses_cell_mapping_for_product_and_status() -> None:
|
||||
cells = {
|
||||
"商品信息": _PRODUCT_CELL,
|
||||
"售后状态": _STATUS_CELL,
|
||||
}
|
||||
|
||||
row = collector.parse_list_row(_LIST_ROW_REFUND, cells)
|
||||
|
||||
assert row["product_name"] == (
|
||||
"GYXX/光影行星阿波罗x1斜挎包男款单肩包电脑包新款2026爆款"
|
||||
)
|
||||
assert row["spec_name"] == "逆光白/加大号(适合16寸游戏本电脑)"
|
||||
assert row["product_code"] == "10348008"
|
||||
assert row["aftersale_status"] == "同意退款,退款成功"
|
||||
|
||||
|
||||
def test_map_row_cells_requires_matching_lengths() -> None:
|
||||
mapping = collector.map_row_cells(_LIST_HEADERS, ["", "a", "b", "c", "d", "e", "f", "g"])
|
||||
assert mapping["商品信息"] == "a"
|
||||
assert mapping["操作"] == "g"
|
||||
assert collector.map_row_cells(_LIST_HEADERS, ["only"]) == {}
|
||||
|
||||
|
||||
def test_parse_list_row_returns_empty_without_aftersale_id() -> None:
|
||||
row = collector.parse_list_row("无关文本")
|
||||
assert row["aftersale_id"] is None
|
||||
|
||||
|
||||
def test_parse_negotiation_entries_extracts_reason_note_and_evidence() -> None:
|
||||
entries = collector.parse_negotiation_entries(
|
||||
_NEGOTIATION_PANEL,
|
||||
{0: ["https://obj.example/evidence-1.jpg"]},
|
||||
)
|
||||
|
||||
assert [entry["entry_seq"] for entry in entries] == [0, 1, 2]
|
||||
first = entries[0]
|
||||
assert first["actor_role"] == "消费者"
|
||||
assert first["action_text"] == "发起了退货退款申请,等待商家处理"
|
||||
assert first["happened_at"] == datetime(2026, 9, 7, 21, 59, 55, tzinfo=collector.SHANGHAI)
|
||||
assert first["operator"] == "消费者"
|
||||
assert first["return_reason"] == "商品品质不好"
|
||||
assert first["refund_note"] == "不值"
|
||||
assert first["evidence_urls"] == ["https://obj.example/evidence-1.jpg"]
|
||||
assert first["extra_fields"]["售后类型"] == "退货退款"
|
||||
assert first["extra_fields"]["货物状态"] == "已收到货"
|
||||
assert first["extra_fields"]["退款金额"] == "¥29.9"
|
||||
assert first["extra_fields"]["退货方式"].startswith("上门取件")
|
||||
|
||||
second = entries[1]
|
||||
assert second["actor_role"] == "系统"
|
||||
assert second["action_text"].startswith("因该用户信誉良好")
|
||||
assert second["return_reason"] is None
|
||||
assert second["extra_fields"]["退货地址"].endswith("(抖仓库)")
|
||||
assert second["evidence_urls"] == []
|
||||
|
||||
third = entries[2]
|
||||
assert third["action_text"] == "预约上门取件"
|
||||
assert third["extra_fields"]["预约时间"] == (
|
||||
"2026-09-08 18:00:00 至 2026-09-08 20:00:00"
|
||||
)
|
||||
|
||||
|
||||
def test_parse_negotiation_entries_handles_block_label_values() -> None:
|
||||
text = """消费者 发起了退货退款申请,等待商家处理
|
||||
2026-09-07 21:59:55
|
||||
操作人 消费者
|
||||
退货原因
|
||||
商品品质不好
|
||||
凭证信息"""
|
||||
|
||||
entries = collector.parse_negotiation_entries(text)
|
||||
|
||||
assert len(entries) == 1
|
||||
assert entries[0]["operator"] == "消费者"
|
||||
assert entries[0]["return_reason"] == "商品品质不好"
|
||||
|
||||
|
||||
def test_parse_detail_product_block_extracts_id_and_title() -> None:
|
||||
product = collector.parse_detail_product_block(_DETAIL_PRODUCT_BLOCK)
|
||||
|
||||
assert product["product_name"] == "GYXX/光影行星星干湿分离袋 洗漱包 收纳袋"
|
||||
assert product["product_id"] == "3791074652722343083"
|
||||
assert product["spec_name"] == "黑色"
|
||||
assert product["product_code"] == "10399079"
|
||||
|
||||
|
||||
def test_parse_detail_aftersale_block_parses_inline_and_block_fields() -> None:
|
||||
fields = collector.parse_detail_aftersale_block(_DETAIL_AFTERSALE_BLOCK)
|
||||
|
||||
assert fields["订单编号"] == "6929012177139563577"
|
||||
assert fields["售后编号"] == "1476543384326189817"
|
||||
assert fields["下单时间"] == "2026-08-28 10:14:18"
|
||||
assert fields["申请金额"] == "¥29.9"
|
||||
assert fields["消费者应退金额"] == "¥29.9"
|
||||
assert fields["申请时间"] == "2026-09-07 21:59:55"
|
||||
assert fields["售后原因"] == "商品品质不好(商品品质原因)"
|
||||
assert fields["售后说明"] == "不值"
|
||||
assert fields["收货状态"] == "已收到货"
|
||||
assert fields["申请件数"] == "1件"
|
||||
assert fields["消费者需回寄数量"] == "1件"
|
||||
|
||||
|
||||
def test_parse_apply_time_supports_list_and_detail_formats() -> None:
|
||||
parsed = collector.parse_apply_time("2026/09/14 21:26:01")
|
||||
assert parsed is not None
|
||||
assert parsed.isoformat() == "2026-09-14T21:26:01+08:00"
|
||||
|
||||
parsed = collector.parse_apply_time("2026-09-07 21:59:55")
|
||||
assert parsed is not None
|
||||
assert parsed.isoformat() == "2026-09-07T21:59:55+08:00"
|
||||
|
||||
assert collector.parse_apply_time("unknown") is None
|
||||
assert collector.parse_apply_time(None) is None
|
||||
|
||||
|
||||
def test_resolve_query_range_defaults_to_full_mode() -> None:
|
||||
assert collector._resolve_query_range(_args()) == (None, None)
|
||||
|
||||
|
||||
def test_resolve_query_range_single_day_and_env_range(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
single = collector._resolve_query_range(_args(date=date(2026, 9, 20)))
|
||||
assert single == (date(2026, 9, 20), date(2026, 9, 20))
|
||||
|
||||
monkeypatch.setenv("GYXX_DY_AFTER_SALES_TO", "2026-09-21")
|
||||
ranged = collector._resolve_query_range(_args(date=date(2026, 9, 1)))
|
||||
assert ranged == (date(2026, 9, 1), date(2026, 9, 21))
|
||||
|
||||
monkeypatch.setenv("GYXX_DY_AFTER_SALES_TO", "2026-08-31")
|
||||
with pytest.raises(ValueError, match="不能早于"):
|
||||
collector._resolve_query_range(_args(date=date(2026, 9, 1)))
|
||||
|
||||
monkeypatch.setenv("GYXX_DY_AFTER_SALES_TO", "bad-date")
|
||||
with pytest.raises(ValueError, match="YYYY-MM-DD"):
|
||||
collector._resolve_query_range(_args(date=date(2026, 9, 1)))
|
||||
|
||||
|
||||
def test_resolve_query_range_from_to_arguments() -> None:
|
||||
ranged = collector._resolve_query_range(
|
||||
_args(from_date=date(2026, 3, 1), to_date=date(2026, 9, 1))
|
||||
)
|
||||
assert ranged == (date(2026, 3, 1), date(2026, 9, 1))
|
||||
|
||||
with pytest.raises(ValueError, match="同时提供"):
|
||||
collector._resolve_query_range(_args(from_date=date(2026, 3, 1)))
|
||||
with pytest.raises(ValueError, match="不能早于"):
|
||||
collector._resolve_query_range(
|
||||
_args(from_date=date(2026, 9, 1), to_date=date(2026, 3, 1))
|
||||
)
|
||||
|
||||
|
||||
def test_normalize_order_builds_order_and_negotiation_records() -> None:
|
||||
row = collector.parse_list_row(
|
||||
_LIST_ROW_REFUND,
|
||||
{"商品信息": _PRODUCT_CELL, "售后状态": _STATUS_CELL},
|
||||
)
|
||||
detail = {
|
||||
"entries": collector.parse_negotiation_entries(
|
||||
_NEGOTIATION_PANEL, {0: ["https://obj.example/evidence-1.jpg"]}
|
||||
),
|
||||
"product": collector.parse_detail_product_block(_DETAIL_PRODUCT_BLOCK),
|
||||
"aftersale_fields": collector.parse_detail_aftersale_block(
|
||||
_DETAIL_AFTERSALE_BLOCK
|
||||
),
|
||||
"panel_text": _NEGOTIATION_PANEL,
|
||||
"product_text": _DETAIL_PRODUCT_BLOCK,
|
||||
"detail_url": "https://fxg.jinritemai.com/ffa/merchant-aftersale-workbench/aftersale/detail",
|
||||
}
|
||||
captured_at = datetime(2026, 9, 21, 10, 0, 0, tzinfo=collector.SHANGHAI)
|
||||
|
||||
order, negotiations = collector.normalize_order(
|
||||
row,
|
||||
detail,
|
||||
account="douyin-shop",
|
||||
source_url=collector.TARGET_URL,
|
||||
captured_at=captured_at,
|
||||
)
|
||||
|
||||
assert order["account"] == "douyin-shop"
|
||||
assert order["aftersale_id"] == "147664497007198455"
|
||||
assert order["order_id"] == "6929344525347945945"
|
||||
assert order["business_date"].isoformat() == "2026-09-07"
|
||||
assert order["apply_at"] == datetime(2026, 9, 7, 21, 59, 55, tzinfo=collector.SHANGHAI)
|
||||
assert order["apply_reason"] == "商品品质不好(商品品质原因)"
|
||||
assert order["product_id"] == "3791074652722343083"
|
||||
# 详情页交易信息优先于列表行;夹具中详情块来自另一条售后单,
|
||||
# 因此商家编码/商品名以详情块为准。
|
||||
assert order["product_code"] == "10399079"
|
||||
assert order["product_name"] == "GYXX/光影行星星干湿分离袋 洗漱包 收纳袋"
|
||||
assert order["negotiation_count"] == 3
|
||||
assert order["detail_url"].endswith("/aftersale/detail")
|
||||
assert len(negotiations) == 3
|
||||
assert negotiations[0]["aftersale_id"] == "147664497007198455"
|
||||
assert negotiations[0]["return_reason"] == "商品品质不好"
|
||||
assert negotiations[0]["refund_note"] == "不值"
|
||||
assert negotiations[0]["captured_at"] == captured_at
|
||||
|
||||
|
||||
def test_normalize_order_requires_aftersale_id() -> None:
|
||||
with pytest.raises(RuntimeError, match="售后编号"):
|
||||
collector.normalize_order(
|
||||
{"aftersale_id": None, "raw_text": "broken"},
|
||||
{"entries": [], "product": {}, "aftersale_fields": {}},
|
||||
account="douyin-shop",
|
||||
source_url=collector.TARGET_URL,
|
||||
captured_at=datetime(2026, 9, 21, tzinfo=collector.SHANGHAI),
|
||||
)
|
||||
|
||||
|
||||
def test_normalize_order_requires_apply_time() -> None:
|
||||
with pytest.raises(RuntimeError, match="申请时间"):
|
||||
collector.normalize_order(
|
||||
collector.parse_list_row("订单编号 6929344525347945945 售后编号 147664497007198455"),
|
||||
{"entries": [], "product": {}, "aftersale_fields": {}},
|
||||
account="douyin-shop",
|
||||
source_url=collector.TARGET_URL,
|
||||
captured_at=datetime(2026, 9, 21, tzinfo=collector.SHANGHAI),
|
||||
)
|
||||
|
||||
|
||||
def test_save_progress_round_trip(tmp_path: Path) -> None:
|
||||
output = tmp_path / "抖音品退_全量.json"
|
||||
collector.save_progress(
|
||||
output,
|
||||
account="douyin-shop",
|
||||
query_start=None,
|
||||
query_end=None,
|
||||
expected_count=2,
|
||||
collected={"147664497007198455": {"negotiation_count": 3}},
|
||||
raw_rows={"147664497007198455": {"list_row": "text"}},
|
||||
complete=False,
|
||||
failed={"147664497007198456": "timeout"},
|
||||
)
|
||||
|
||||
saved = collector.load_progress(output)
|
||||
assert saved is not None
|
||||
assert saved["complete"] is False
|
||||
assert saved["query_mode"] == "full"
|
||||
assert saved["expected_count"] == 2
|
||||
assert saved["records"]["147664497007198455"]["negotiation_count"] == 3
|
||||
assert saved["failed_records"] == {"147664497007198456": "timeout"}
|
||||
|
||||
|
||||
def test_after_sales_quality_schema_has_tables_and_idempotent_keys() -> None:
|
||||
schema = Path(collector.__file__).parents[1] / "db" / "schema.sql"
|
||||
text = schema.read_text(encoding="utf-8")
|
||||
|
||||
assert "dy_after_sales_quality_orders" in text
|
||||
assert "dy_after_sales_quality_negotiations" in text
|
||||
assert "CONSTRAINT dy_after_sales_quality_order_uniq UNIQUE (account, aftersale_id)" in text
|
||||
assert (
|
||||
"CONSTRAINT dy_after_sales_quality_negotiation_uniq\n"
|
||||
" UNIQUE (account, aftersale_id, entry_seq)" in text
|
||||
)
|
||||
@@ -0,0 +1,584 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from gyxx_flow.modules.shop_intelligence.collectors import (
|
||||
dy_customer_service_messages as collector,
|
||||
)
|
||||
|
||||
|
||||
def _row() -> dict[str, object]:
|
||||
return {
|
||||
"conversation_id": "76865431791638131343",
|
||||
"session_time_label": "09/18 00:33-00:49",
|
||||
"customer_nickname": "go",
|
||||
"consult_entry": "直播间",
|
||||
"reception_type": "纯人工",
|
||||
"initiator_scene": "消费者发起咨询(纯人工/智能转人工/纯智能)",
|
||||
"end_scene": "消费者-留言关闭会话",
|
||||
"rating_time_label": "-",
|
||||
"rating_result": "未评价",
|
||||
"rating_tags": "-",
|
||||
"conversation_intent": "-",
|
||||
"smart_reception_label": "-",
|
||||
"smart_rating_time_label": "-",
|
||||
"smart_rating_result": "未评价",
|
||||
"smart_rating_tags": "-",
|
||||
"transfer_human_duration": "00:34:27",
|
||||
"raw_cells": ["ID: 76865431791638131343\n09/18 00:33-00:49", "go"],
|
||||
}
|
||||
|
||||
|
||||
def _messages() -> list[dict[str, object]]:
|
||||
return [
|
||||
{
|
||||
"sender_role": "customer",
|
||||
"sender_name": None,
|
||||
"content": "盖亚,有没有?",
|
||||
"message_type": "text",
|
||||
"card": {},
|
||||
"time_label": "09/18 00:33",
|
||||
"message_at": None,
|
||||
"raw_message": {"text": "盖亚,有没有?", "time_label": "09/18 00:33"},
|
||||
},
|
||||
{
|
||||
"sender_role": "service",
|
||||
"sender_name": None,
|
||||
"content": "有!点45号盖亚双肩3,曜夜黑款能装1机2镜",
|
||||
"message_type": "text",
|
||||
"card": {},
|
||||
"time_label": "09/18 00:33",
|
||||
"message_at": None,
|
||||
"raw_message": {"text": "有!点45号盖亚双肩3", "time_label": "09/18 00:33"},
|
||||
},
|
||||
{
|
||||
"sender_role": "service",
|
||||
"sender_name": None,
|
||||
"content": "GYXX/光影行星盖亚双肩3相机包男士摄影\n¥684.00 券后价 已售14",
|
||||
"message_type": "product_card",
|
||||
"card": {
|
||||
"kind": "product_card",
|
||||
"title": "GYXX/光影行星盖亚双肩3相机包男士摄影",
|
||||
"price": "684.00",
|
||||
"text": "GYXX/光影行星盖亚双肩3相机包男士摄影\n¥684.00 券后价 已售14",
|
||||
},
|
||||
"time_label": None,
|
||||
"message_at": None,
|
||||
"raw_message": {"text": "product card"},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def test_parse_session_time_label_anchors_to_business_date() -> None:
|
||||
start, end = collector.parse_session_time_label(
|
||||
"09/18 00:33-00:49", date(2026, 9, 18)
|
||||
)
|
||||
|
||||
assert start is not None and end is not None
|
||||
assert start.isoformat() == "2026-09-18T00:33:00+08:00"
|
||||
assert end.isoformat() == "2026-09-18T00:49:00+08:00"
|
||||
|
||||
|
||||
def test_parse_session_time_label_rolls_back_previous_year() -> None:
|
||||
start, end = collector.parse_session_time_label(
|
||||
"12/31 23:50-00:10", date(2026, 1, 1)
|
||||
)
|
||||
|
||||
assert start is not None and end is not None
|
||||
assert start.isoformat() == "2025-12-31T23:50:00+08:00"
|
||||
assert end.isoformat() == "2026-01-01T00:10:00+08:00"
|
||||
|
||||
|
||||
def test_parse_session_time_label_extends_past_midnight() -> None:
|
||||
start, end = collector.parse_session_time_label(
|
||||
"09/18 23:50-00:10", date(2026, 9, 19)
|
||||
)
|
||||
|
||||
assert start is not None and end is not None
|
||||
assert end > start
|
||||
assert end.isoformat() == "2026-09-19T00:10:00+08:00"
|
||||
|
||||
|
||||
def test_parse_total_count_and_page_size() -> None:
|
||||
assert collector.parse_total_count("共22条数据") == 22
|
||||
assert collector.parse_page_size("20 条/页") == 20
|
||||
|
||||
with pytest.raises(RuntimeError, match="会话总数"):
|
||||
collector.parse_total_count("暂无数据")
|
||||
|
||||
|
||||
def test_resolve_reception_types_defaults_to_human_and_transfer() -> None:
|
||||
assert collector._resolve_reception_types(
|
||||
"纯人工客服接待,智能客服转人工接待"
|
||||
) == ("纯人工客服接待", "智能客服转人工接待")
|
||||
|
||||
with pytest.raises(ValueError, match="不支持的接待类型"):
|
||||
collector._resolve_reception_types("纯人工客服接待,全部")
|
||||
|
||||
|
||||
def test_normalize_conversation_keeps_business_and_session_times() -> None:
|
||||
result = collector.normalize_conversation(
|
||||
_row(),
|
||||
{
|
||||
"consult_entry": "直播间",
|
||||
"rating_result": "未评价",
|
||||
"service_problem_tags": None,
|
||||
"unmet_metrics": None,
|
||||
},
|
||||
_messages(),
|
||||
account="douyin-shop",
|
||||
business_date=date(2026, 9, 18),
|
||||
source_url=collector.TARGET_URL,
|
||||
)
|
||||
|
||||
conversation = result["conversation"]
|
||||
assert result["message_count"] == 3
|
||||
assert conversation["business_date"] == date(2026, 9, 18)
|
||||
assert conversation["conversation_id"] == "76865431791638131343"
|
||||
assert conversation["customer_nickname"] == "go"
|
||||
assert conversation["session_start_at"].isoformat() == "2026-09-18T00:33:00+08:00"
|
||||
assert conversation["session_end_at"].isoformat() == "2026-09-18T00:49:00+08:00"
|
||||
assert conversation["reception_type"] == "纯人工"
|
||||
assert conversation["transfer_human_duration"] == "00:34:27"
|
||||
messages = result["messages"]
|
||||
assert [item["sender_role"] for item in messages] == [
|
||||
"customer",
|
||||
"service",
|
||||
"service",
|
||||
]
|
||||
assert [item["message_seq"] for item in messages] == [0, 1, 2]
|
||||
assert messages[2]["message_type"] == "product_card"
|
||||
assert messages[2]["card"]["price"] == "684.00"
|
||||
assert len({item["message_key"] for item in messages}) == 3
|
||||
|
||||
|
||||
def test_normalize_conversation_fails_closed_without_messages() -> None:
|
||||
with pytest.raises(RuntimeError, match="未采集到任何消息"):
|
||||
collector.normalize_conversation(
|
||||
_row(),
|
||||
{},
|
||||
[],
|
||||
account="douyin-shop",
|
||||
business_date=date(2026, 9, 18),
|
||||
source_url=collector.TARGET_URL,
|
||||
)
|
||||
|
||||
|
||||
def test_save_progress_marks_incomplete_until_all_conversations_collected(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
output = tmp_path / "抖音客服消息_2026-09-18.json"
|
||||
collector.save_progress(
|
||||
output,
|
||||
account="douyin-shop",
|
||||
business_date=date(2026, 9, 18),
|
||||
reception_types=("纯人工客服接待", "智能客服转人工接待"),
|
||||
expected_count=2,
|
||||
collected={"76865431791638131343": {"message_count": 3}},
|
||||
raw_rows={"76865431791638131343": _row()},
|
||||
complete=False,
|
||||
)
|
||||
|
||||
saved = json.loads(output.read_text(encoding="utf-8"))
|
||||
assert saved["complete"] is False
|
||||
assert saved["expected_conversation_count"] == 2
|
||||
assert saved["collected_conversation_count"] == 1
|
||||
assert saved["reception_types"] == ["纯人工客服接待", "智能客服转人工接待"]
|
||||
assert saved["raw_rows"]["76865431791638131343"]["reception_type"] == "纯人工"
|
||||
assert saved["failed_conversations"] == {}
|
||||
|
||||
|
||||
class _FakePage:
|
||||
"""模拟虚拟滚动表格:视口相交的行才挂载,滚动容器由脚本 JS 驱动。"""
|
||||
|
||||
row_height = 60.0
|
||||
client_height = 240.0
|
||||
|
||||
def __init__(self, rows: list[dict[str, object]], batch: int = 2) -> None:
|
||||
self._rows = rows
|
||||
self._batch = batch
|
||||
self._top = 0.0
|
||||
self.evaluate_calls = 0
|
||||
|
||||
@property
|
||||
def _scroll_height(self) -> float:
|
||||
return max(len(self._rows) * self.row_height, 300.0)
|
||||
|
||||
def _mounted_rows(self) -> list[dict[str, object]]:
|
||||
start = int(self._top // self.row_height)
|
||||
end = -(-int(self._top + self.client_height) // int(self.row_height))
|
||||
return self._rows[start : max(end, start + self._batch)]
|
||||
|
||||
def evaluate(self, script: str, *args: object) -> object:
|
||||
self.evaluate_calls += 1
|
||||
if "qa-history-conversation-item" not in script:
|
||||
return None
|
||||
if "scrollTop" not in script:
|
||||
# read_current_page_rows 的读取脚本。
|
||||
return {
|
||||
"headers": ["会话信息", "用户信息", "接待类型"],
|
||||
"rows": [
|
||||
{
|
||||
"key": row["conversation_id"],
|
||||
"cells": [
|
||||
f"ID: {row['conversation_id']}\n09/18 00:33-00:49",
|
||||
str(row.get("customer_nickname", "")),
|
||||
str(row.get("reception_type", "")),
|
||||
],
|
||||
}
|
||||
for row in self._mounted_rows()
|
||||
],
|
||||
}
|
||||
if "found:" in script:
|
||||
return {
|
||||
"found": 1,
|
||||
"top": self._top,
|
||||
"height": self._scroll_height,
|
||||
"client": self.client_height,
|
||||
}
|
||||
if "scrollTop = Math.min" in script:
|
||||
step = float(args[0]) if args else 200.0
|
||||
max_top = max(self._scroll_height - self.client_height, 0.0)
|
||||
self._top = min(self._top + step, max_top)
|
||||
return None
|
||||
if "scrollTop = 0" in script:
|
||||
self._top = 0.0
|
||||
return None
|
||||
return None
|
||||
|
||||
def wait_for_timeout(self, _ms: int) -> None: # pragma: no cover - 同步桩
|
||||
return None
|
||||
|
||||
|
||||
def test_scan_current_page_rows_walks_virtual_scrolled_table() -> None:
|
||||
rows = [
|
||||
{
|
||||
"conversation_id": str(7686000000000000000 + index),
|
||||
"customer_nickname": f"u{index}",
|
||||
}
|
||||
for index in range(6)
|
||||
]
|
||||
page = _FakePage(rows, batch=2)
|
||||
|
||||
scanned = collector.scan_current_page_rows(page, max_steps=20)
|
||||
|
||||
assert [row["conversation_id"] for row in scanned] == [
|
||||
row["conversation_id"] for row in rows
|
||||
]
|
||||
# 扫描完成后滚动容器应回到顶部。
|
||||
assert page._top == 0.0
|
||||
|
||||
|
||||
def test_scan_current_page_rows_without_virtual_scroll_reads_once() -> None:
|
||||
class _PlainPage(_FakePage):
|
||||
def evaluate(self, script: str, *args: object) -> object: # noqa: D102
|
||||
if "found:" in script:
|
||||
return {"found": 0}
|
||||
return super().evaluate(script, *args)
|
||||
|
||||
rows = [
|
||||
{
|
||||
"conversation_id": str(7686000000000000000 + index),
|
||||
"customer_nickname": f"u{index}",
|
||||
}
|
||||
for index in range(3)
|
||||
]
|
||||
page = _PlainPage(rows, batch=10)
|
||||
|
||||
scanned = collector.scan_current_page_rows(page)
|
||||
|
||||
assert len(scanned) == 3
|
||||
|
||||
|
||||
_ORDER_CARD_FULL = """已完成
|
||||
(售后完成)
|
||||
咨询订单
|
||||
+0
|
||||
6929386983188954550
|
||||
耳机包;换货(11535611)#;换货物流公司:中通速递物流单号:76979169163382;补发物流公司:中通速递物流单号:76980427930406;(收) 10507005*1#
|
||||
修改
|
||||
承诺服务工单
|
||||
[承诺同意换货申请]
|
||||
已完结
|
||||
GYXX/光影行星曜影双肩男女背包通勤旅游轻便出行大学生电脑包
|
||||
[已发1/1]
|
||||
运费险
|
||||
7天
|
||||
极速退
|
||||
小店自卖
|
||||
直播
|
||||
+2
|
||||
规格
|
||||
【标准版】曜夜黑
|
||||
编码
|
||||
10507011
|
||||
实付金额
|
||||
¥
|
||||
414.
|
||||
00
|
||||
(含运费 ¥ 0.00)
|
||||
优惠 ¥ 15.00
|
||||
付款时间
|
||||
2026/09/11 11:12:03 (抖音月付)
|
||||
物流信息
|
||||
中通快递 76977842019514
|
||||
[已签收]2026-09-13 21:01:46
|
||||
收货信息
|
||||
陈*,1***
|
||||
,北京市北京市通州区梨园镇***
|
||||
发货时间
|
||||
2026-09-11 14:27"""
|
||||
|
||||
_ORDER_CARD_PLAIN = """已发货
|
||||
咨询订单
|
||||
+0
|
||||
6929558440707981209
|
||||
GYXX/光影行星阿波罗2斜挎包男款单肩包男生电脑背包新款2026爆款
|
||||
[已发1/1]
|
||||
运费险
|
||||
7天
|
||||
极速退
|
||||
小店自卖
|
||||
商品卡
|
||||
+2
|
||||
规格
|
||||
曜夜黑 加大
|
||||
编码
|
||||
10442024
|
||||
实付金额
|
||||
¥
|
||||
379.
|
||||
00
|
||||
(含运费 ¥ 0.00)
|
||||
付款时间
|
||||
2026/09/17 16:35:15 (微信)
|
||||
物流信息
|
||||
中通快递 76980363689790
|
||||
[待取件]2026-09-19 09:24:37
|
||||
收货信息
|
||||
[已改地址]
|
||||
许*,1***
|
||||
,上海市上海市闵行区吴泾镇***
|
||||
发货时间
|
||||
2026-09-17 17:29"""
|
||||
|
||||
|
||||
def test_parse_order_card_extracts_product_and_payment() -> None:
|
||||
parsed = collector.parse_order_card(_ORDER_CARD_FULL)
|
||||
|
||||
assert parsed is not None
|
||||
assert parsed["order_id"] == "6929386983188954550"
|
||||
assert parsed["order_status"] == "已完成"
|
||||
assert parsed["after_sale_status"] == "售后完成"
|
||||
assert parsed["order_badge"] == "咨询订单"
|
||||
assert parsed["product_title"] == (
|
||||
"GYXX/光影行星曜影双肩男女背包通勤旅游轻便出行大学生电脑包"
|
||||
)
|
||||
assert parsed["product_code"] == "10507011"
|
||||
assert parsed["spec"] == "【标准版】曜夜黑"
|
||||
assert parsed["paid_amount"] == "414.00"
|
||||
assert parsed["pay_channel"] == "抖音月付"
|
||||
assert parsed["paid_at"] is not None
|
||||
assert parsed["paid_at"].isoformat() == "2026-09-11T11:12:03+08:00"
|
||||
assert parsed["discount_text"] == "15.00"
|
||||
assert parsed["freight_text"] == "0.00"
|
||||
assert parsed["logistics_company"] == "中通快递"
|
||||
assert parsed["logistics_no"] == "76977842019514"
|
||||
assert parsed["logistics_status"] == "已签收 2026-09-13 21:01:46"
|
||||
assert parsed["receiver_info"] is not None
|
||||
assert "北京市" in parsed["receiver_info"]
|
||||
assert parsed["shipped_at"] is not None
|
||||
assert parsed["shipped_at"].isoformat() == "2026-09-11T14:27:00+08:00"
|
||||
assert "运费险" in (parsed["service_tags"] or "")
|
||||
assert parsed["remark_text"] is not None
|
||||
assert "换货" in parsed["remark_text"]
|
||||
|
||||
|
||||
def test_parse_order_card_handles_plain_shipped_variant() -> None:
|
||||
parsed = collector.parse_order_card(_ORDER_CARD_PLAIN)
|
||||
|
||||
assert parsed is not None
|
||||
assert parsed["order_id"] == "6929558440707981209"
|
||||
assert parsed["order_status"] == "已发货"
|
||||
assert parsed["after_sale_status"] is None
|
||||
assert parsed["product_title"].startswith("GYXX/光影行星阿波罗2斜挎包")
|
||||
assert parsed["product_code"] == "10442024"
|
||||
assert parsed["spec"] == "曜夜黑 加大"
|
||||
|
||||
|
||||
def test_parse_order_card_keeps_code_and_spec_clean_with_trailing_badges() -> None:
|
||||
card = """已发货
|
||||
6929461460816854023
|
||||
GYXX/光影行星逐星斜挎包男士单肩胸包
|
||||
规格
|
||||
逐影黑小号
|
||||
编码
|
||||
10411028
|
||||
退货退款
|
||||
售后关闭
|
||||
实付金额
|
||||
¥
|
||||
299.
|
||||
00
|
||||
付款时间
|
||||
2026/09/14 17:25:53 (微信)
|
||||
发货时间
|
||||
2026-09-15 11:18"""
|
||||
|
||||
parsed = collector.parse_order_card(card)
|
||||
|
||||
assert parsed is not None
|
||||
assert parsed["product_code"] == "10411028"
|
||||
assert parsed["spec"] == "逐影黑小号"
|
||||
assert parsed["paid_amount"] == "299.00"
|
||||
assert parsed["pay_channel"] == "微信"
|
||||
|
||||
|
||||
def test_parse_order_card_handles_card_without_sent_marker() -> None:
|
||||
card = """已发货
|
||||
(售后失败)
|
||||
6929461460816854023
|
||||
GYXX/光影行星逐星斜挎包男士单肩胸包多功能时尚潮流骑行弓箭包
|
||||
规格
|
||||
逐影黑小号(适合7.9寸平板)
|
||||
编码
|
||||
10411028
|
||||
实付金额
|
||||
¥
|
||||
299.
|
||||
00
|
||||
付款时间
|
||||
2026/09/14 17:25:53 (微信)
|
||||
物流信息
|
||||
中通快递 76979187011548
|
||||
[已签收]2026-09-17 20:06:37
|
||||
发货时间
|
||||
2026-09-15 11:18"""
|
||||
|
||||
parsed = collector.parse_order_card(card)
|
||||
|
||||
assert parsed is not None
|
||||
assert parsed["order_id"] == "6929461460816854023"
|
||||
assert parsed["product_title"].startswith("GYXX/光影行星逐星斜挎包")
|
||||
assert parsed["product_code"] == "10411028"
|
||||
assert parsed["paid_amount"] == "299.00"
|
||||
assert parsed["service_tags"] is None
|
||||
|
||||
|
||||
def test_parse_order_card_returns_none_without_order_id() -> None:
|
||||
assert collector.parse_order_card("暂无相关订单") is None
|
||||
|
||||
|
||||
def test_parse_order_card_skips_status_line_before_title() -> None:
|
||||
card = """待发货
|
||||
6929558440707981209
|
||||
待发货
|
||||
GYXX/光影行星阿波罗2斜挎包男款单肩包男生电脑背包新款2026爆款
|
||||
规格
|
||||
曜夜黑 加大
|
||||
编码
|
||||
10442024
|
||||
实付金额
|
||||
¥
|
||||
379.
|
||||
00
|
||||
付款时间
|
||||
2026/09/17 16:35:15 (微信)
|
||||
发货时间
|
||||
2026-09-17 17:29"""
|
||||
|
||||
parsed = collector.parse_order_card(card)
|
||||
|
||||
assert parsed is not None
|
||||
assert parsed["product_title"].startswith("GYXX/光影行星阿波罗2斜挎包")
|
||||
assert parsed["product_code"] == "10442024"
|
||||
|
||||
|
||||
def test_build_product_records_groups_by_code_with_variant_counts() -> None:
|
||||
orders = [
|
||||
{"product_code": "10442024", "product_title": "阿波罗2"},
|
||||
{"product_code": "10442024", "product_title": "阿波罗2"},
|
||||
{"product_code": "10442024", "product_title": "【直播】阿波罗2"},
|
||||
{"product_code": "", "product_title": "无编码商品"},
|
||||
]
|
||||
|
||||
batch = collector.build_product_records(orders, date(2026, 9, 17))
|
||||
|
||||
assert set(batch) == {"10442024"}
|
||||
entry = batch["10442024"]
|
||||
assert entry["title_variants"] == {"阿波罗2": 2, "【直播】阿波罗2": 1}
|
||||
assert entry["first_seen_date"] == entry["last_seen_date"] == date(2026, 9, 17)
|
||||
|
||||
|
||||
def test_merge_product_records_accumulates_variants_and_keeps_stable_title() -> None:
|
||||
existing = [
|
||||
{
|
||||
"product_code": "10442024",
|
||||
"product_title": "阿波罗2",
|
||||
"first_seen_date": date(2026, 8, 1),
|
||||
"last_seen_date": date(2026, 8, 20),
|
||||
"title_variants": {"阿波罗2": 9},
|
||||
}
|
||||
]
|
||||
batch = collector.build_product_records(
|
||||
[
|
||||
{"product_code": "10442024", "product_title": "【直播】阿波罗2"},
|
||||
{"product_code": "10442024", "product_title": "【直播】阿波罗2"},
|
||||
{"product_code": "10507011", "product_title": "曜影双肩"},
|
||||
],
|
||||
date(2026, 9, 17),
|
||||
)
|
||||
|
||||
merged = collector.merge_product_records(existing, batch)
|
||||
by_code = {row["product_code"]: row for row in merged}
|
||||
|
||||
assert set(by_code) == {"10442024", "10507011"}
|
||||
apollo = by_code["10442024"]
|
||||
assert apollo["product_title"] == "阿波罗2"
|
||||
assert apollo["title_variants"] == {"阿波罗2": 9, "【直播】阿波罗2": 2}
|
||||
assert apollo["first_seen_date"] == date(2026, 8, 1)
|
||||
assert apollo["last_seen_date"] == date(2026, 9, 17)
|
||||
assert by_code["10507011"]["product_title"] == "曜影双肩"
|
||||
|
||||
|
||||
def test_normalize_conversation_binds_orders_to_conversation() -> None:
|
||||
result = collector.normalize_conversation(
|
||||
_row(),
|
||||
{},
|
||||
_messages(),
|
||||
account="douyin-shop",
|
||||
business_date=date(2026, 9, 17),
|
||||
source_url=collector.TARGET_URL,
|
||||
order_cards=[_ORDER_CARD_FULL],
|
||||
)
|
||||
|
||||
conversation = result["conversation"]
|
||||
assert conversation["order_count"] == 1
|
||||
assert "6929386983188954550" in (conversation["order_panel_text"] or "")
|
||||
orders = result["orders"]
|
||||
assert len(orders) == 1
|
||||
order = orders[0]
|
||||
assert order["account"] == "douyin-shop"
|
||||
assert order["business_date"] == date(2026, 9, 17)
|
||||
assert order["conversation_id"] == "76865431791638131343"
|
||||
assert order["order_id"] == "6929386983188954550"
|
||||
assert order["product_code"] == "10507011"
|
||||
assert order["raw_card"]["text"].startswith("已完成")
|
||||
|
||||
|
||||
def test_customer_service_schema_has_douyin_tables_and_idempotent_keys() -> None:
|
||||
schema = Path(collector.__file__).parents[1] / "db" / "schema.sql"
|
||||
text = schema.read_text(encoding="utf-8")
|
||||
|
||||
assert "dy_customer_service_conversations" in text
|
||||
assert "dy_customer_service_messages" in text
|
||||
assert "dy_customer_service_conversation_orders" in text
|
||||
assert "dy_customer_service_products" in text
|
||||
assert "UNIQUE (account, business_date, conversation_id, order_id)" in text
|
||||
assert "UNIQUE (account, business_date, conversation_id)" in text
|
||||
assert "UNIQUE (account, business_date, conversation_id, message_key)" in text
|
||||
assert "session_start_at TIMESTAMPTZ" in text
|
||||
@@ -0,0 +1,131 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import date, datetime
|
||||
from pathlib import Path
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from gyxx_flow.modules.shop_intelligence.collectors import (
|
||||
dy_negative_reviews as collector,
|
||||
)
|
||||
|
||||
SHANGHAI = ZoneInfo("Asia/Shanghai")
|
||||
|
||||
|
||||
def _row() -> dict[str, object]:
|
||||
return {
|
||||
"order_id": "6929366182773751285",
|
||||
"text": (
|
||||
"订单ID:6929366182773751285\n"
|
||||
"闻**.\n"
|
||||
"差评\n"
|
||||
"2026/09/13 19:04:49\n"
|
||||
"发来又脏又皱 跟二手的一样 logo那都有异常凸起\n"
|
||||
"更多\n"
|
||||
"体验分不计入\n"
|
||||
"展示中\n"
|
||||
"GYXX/光影行星阿波罗2斜挎包男款单肩包男生电脑背包新款2026爆款\n"
|
||||
"商品ID:3764111277610369503\n"
|
||||
"星河银 中号\n"
|
||||
"规格ID:3697688238906114\n"
|
||||
"回复\n"
|
||||
"订单详情\n"
|
||||
"举报"
|
||||
),
|
||||
"content": "发来又脏又皱 跟二手的一样 logo那都有异常凸起",
|
||||
"rankTag": "差评",
|
||||
"productName": "GYXX/光影行星阿波罗2斜挎包男款单肩包男生电脑背包新款2026爆款",
|
||||
"productId": "商品ID:3764111277610369503",
|
||||
"specName": "星河银 中号",
|
||||
"images": [
|
||||
"https://p26-product-comment-sign.byteimg.com/tos-cn-i-8y5sebgt5i/a.jpg"
|
||||
],
|
||||
"status": "展示中",
|
||||
"experienceExcluded": True,
|
||||
}
|
||||
|
||||
|
||||
def test_parse_total_count_and_empty_detection() -> None:
|
||||
assert collector.parse_total_count("共255条 批量回复") == 255
|
||||
|
||||
try:
|
||||
collector.parse_total_count("评价管理")
|
||||
except RuntimeError as exc:
|
||||
assert "差评总数" in str(exc)
|
||||
else:
|
||||
raise AssertionError("missing total must raise")
|
||||
|
||||
|
||||
def test_normalize_review_extracts_text_images_and_product() -> None:
|
||||
row = collector.normalize_review(
|
||||
_row(),
|
||||
account="douyin-shop",
|
||||
business_date=date(2026, 9, 13),
|
||||
source_url=collector.TARGET_URL,
|
||||
)
|
||||
|
||||
assert row is not None
|
||||
assert row["review_id"] == "6929366182773751285"
|
||||
assert row["order_id"] == "6929366182773751285"
|
||||
assert row["rating_level"] == "差评"
|
||||
assert row["stars"] == 1
|
||||
assert row["review_at"] == datetime(
|
||||
2026, 9, 13, 19, 4, 49, tzinfo=SHANGHAI
|
||||
)
|
||||
assert row["content"].startswith("发来又脏又皱")
|
||||
assert row["image_urls"] == [
|
||||
"https://p26-product-comment-sign.byteimg.com/tos-cn-i-8y5sebgt5i/a.jpg"
|
||||
]
|
||||
assert row["display_status"] == "展示中"
|
||||
assert row["experience_score_excluded"] is True
|
||||
assert row["product_id"] == "3764111277610369503"
|
||||
assert row["product_name"].startswith("GYXX/光影行星阿波罗2斜挎包")
|
||||
assert row["spec_name"] == "星河银 中号"
|
||||
assert row["spec_id"] == "3697688238906114"
|
||||
assert row["raw_review"]["text"].startswith("订单ID:")
|
||||
|
||||
|
||||
def test_normalize_review_returns_none_without_order_id() -> None:
|
||||
row = dict(_row())
|
||||
row["order_id"] = ""
|
||||
row["text"] = "与商品无关的文本"
|
||||
|
||||
assert collector.normalize_review(
|
||||
row,
|
||||
account="douyin-shop",
|
||||
business_date=date(2026, 9, 13),
|
||||
source_url=collector.TARGET_URL,
|
||||
) is None
|
||||
|
||||
|
||||
def test_save_progress_marks_incomplete_until_all_reviews_collected(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
output = tmp_path / "抖音差评_2026-09-13.json"
|
||||
collector.save_progress(
|
||||
output,
|
||||
account="douyin-shop",
|
||||
business_date=date(2026, 9, 13),
|
||||
expected_count=2,
|
||||
collected={"6929366182773751285": {"content": "demo"}},
|
||||
complete=False,
|
||||
)
|
||||
|
||||
saved = json.loads(output.read_text(encoding="utf-8"))
|
||||
assert saved["complete"] is False
|
||||
assert saved["expected_count"] == 2
|
||||
assert saved["collected_count"] == 1
|
||||
|
||||
restored = collector.load_progress(output)
|
||||
assert restored is not None
|
||||
assert list(restored["reviews"]) == ["6929366182773751285"]
|
||||
|
||||
|
||||
def test_negative_review_schema_has_idempotency_and_product_columns() -> None:
|
||||
schema = Path(collector.__file__).parents[1] / "db" / "schema.sql"
|
||||
text = schema.read_text(encoding="utf-8")
|
||||
|
||||
assert "dy_negative_reviews" in text
|
||||
assert "UNIQUE (account, business_date, review_id)" in text
|
||||
assert "product_id TEXT" in text
|
||||
assert "image_urls JSONB NOT NULL DEFAULT '[]'::jsonb" in text
|
||||
@@ -0,0 +1,169 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from gyxx_flow.modules.shop_intelligence.collectors import (
|
||||
jd_after_sales_issues as collector,
|
||||
)
|
||||
|
||||
|
||||
def _row(
|
||||
service_id: str,
|
||||
*,
|
||||
user_original: str = "卡扣有一边插不进去",
|
||||
product: str = "光影行星曜影双肩包 ID:10217847513327",
|
||||
) -> dict[str, str]:
|
||||
return {
|
||||
"服务单ID": service_id,
|
||||
"订单ID": "351467676840",
|
||||
"商品": product,
|
||||
"一级品类": "箱包皮具",
|
||||
"二级品类": "男包",
|
||||
"三级品类": "男士单肩/斜挎包",
|
||||
"申请时间": "2026-09-15 21:41:46",
|
||||
"售后原因分类": "商品出现异常",
|
||||
"售后一级原因": "商品问题",
|
||||
"售后二级原因": "配件异常",
|
||||
"用户期望处理方式": "退货",
|
||||
"用户原声": user_original,
|
||||
"操作": "服务单详情",
|
||||
}
|
||||
|
||||
|
||||
def test_normalize_keeps_product_sku_and_filters_unknown_user_original() -> None:
|
||||
rows, filtered_count = collector._normalize_issue_rows(
|
||||
[_row("service-1"), _row("service-2", user_original="未知")],
|
||||
account="光影行星研发",
|
||||
business_date=date(2026, 9, 15),
|
||||
source_url=collector.TARGET_URL,
|
||||
)
|
||||
|
||||
assert filtered_count == 1
|
||||
assert len(rows) == 1
|
||||
row = rows[0]
|
||||
assert row["service_id"] == "service-1"
|
||||
assert row["product_name"] == "光影行星曜影双肩包"
|
||||
assert row["sku_id"] == "10217847513327"
|
||||
assert row["reason_category"] == "商品出现异常"
|
||||
assert row["apply_at"].isoformat() == "2026-09-15T21:41:46+08:00"
|
||||
assert row["user_original"] == "卡扣有一边插不进去"
|
||||
|
||||
|
||||
def test_normalize_deduplicates_identical_service_rows() -> None:
|
||||
rows = collector.normalize_issue_rows(
|
||||
[_row("service-1"), _row("service-1")],
|
||||
account="光影行星研发",
|
||||
business_date=date(2026, 9, 15),
|
||||
source_url=collector.TARGET_URL,
|
||||
)
|
||||
|
||||
assert [row["service_id"] for row in rows] == ["service-1"]
|
||||
|
||||
|
||||
def test_normalize_filters_non_target_reason() -> None:
|
||||
row = _row("service-1")
|
||||
row["售后原因分类"] = "7天无理由"
|
||||
|
||||
rows, filtered_count = collector._normalize_issue_rows(
|
||||
[row],
|
||||
account="光影行星研发",
|
||||
business_date=date(2026, 9, 15),
|
||||
source_url=collector.TARGET_URL,
|
||||
)
|
||||
|
||||
assert rows == []
|
||||
assert filtered_count == 0
|
||||
|
||||
|
||||
def test_custom_range_uses_application_date_as_daily_idempotency_date() -> None:
|
||||
first = _row("service-1")
|
||||
first["申请时间"] = "2026-06-02 08:15:00"
|
||||
second = _row("service-2")
|
||||
second["申请时间"] = "2026-09-15 21:41:46"
|
||||
outside = _row("service-3")
|
||||
outside["申请时间"] = "2026-09-16 00:00:00"
|
||||
|
||||
rows = collector.normalize_issue_rows(
|
||||
[first, second, outside],
|
||||
account="光影行星研发",
|
||||
business_date=date(2026, 6, 1),
|
||||
business_date_end=date(2026, 9, 15),
|
||||
source_url=collector.TARGET_URL,
|
||||
)
|
||||
|
||||
assert [(row["business_date"], row["service_id"]) for row in rows] == [
|
||||
(date(2026, 6, 2), "service-1"),
|
||||
(date(2026, 9, 15), "service-2"),
|
||||
]
|
||||
|
||||
|
||||
def test_custom_range_is_split_into_minimum_31_day_queries() -> None:
|
||||
assert collector.split_custom_ranges(
|
||||
date(2026, 6, 1), date(2026, 9, 15)
|
||||
) == (
|
||||
(date(2026, 6, 1), date(2026, 7, 1)),
|
||||
(date(2026, 7, 2), date(2026, 8, 1)),
|
||||
(date(2026, 8, 2), date(2026, 9, 1)),
|
||||
(date(2026, 9, 2), date(2026, 9, 15)),
|
||||
)
|
||||
|
||||
|
||||
def test_normalize_rejects_conflicting_duplicate_service_rows() -> None:
|
||||
first = _row("service-1")
|
||||
second = _row("service-1", user_original="另一条用户原声")
|
||||
|
||||
with pytest.raises(RuntimeError, match="冲突服务单 ID"):
|
||||
collector.normalize_issue_rows(
|
||||
[first, second],
|
||||
account="光影行星研发",
|
||||
business_date=date(2026, 9, 15),
|
||||
source_url=collector.TARGET_URL,
|
||||
)
|
||||
|
||||
|
||||
def test_progress_contains_filter_counts_and_no_password(tmp_path: Path) -> None:
|
||||
output = tmp_path / "品退问题_2026-09-15.json"
|
||||
rows = collector.normalize_issue_rows(
|
||||
[_row("service-1")],
|
||||
account="光影行星研发",
|
||||
business_date=date(2026, 9, 15),
|
||||
source_url=collector.TARGET_URL,
|
||||
)
|
||||
collector.save_progress(
|
||||
output,
|
||||
account="光影行星研发",
|
||||
business_date=date(2026, 9, 15),
|
||||
raw_rows=[_row("service-1"), _row("service-2", user_original="未知")],
|
||||
records=rows,
|
||||
filtered_unknown_count=1,
|
||||
downloaded_path=None,
|
||||
db_rows=1,
|
||||
complete=True,
|
||||
)
|
||||
|
||||
saved = json.loads(output.read_text(encoding="utf-8"))
|
||||
assert saved["complete"] is True
|
||||
assert saved["filter"]["售后原因"] == "商品出现异常"
|
||||
assert saved["raw_issue_count"] == 2
|
||||
assert saved["valid_issue_count"] == 1
|
||||
assert saved["filtered_unknown_user_original_count"] == 1
|
||||
assert saved["db_upsert_count"] == 1
|
||||
assert "aa123456" not in output.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_schema_and_upsert_use_daily_service_id_idempotency() -> None:
|
||||
schema_path = Path(collector.__file__).parents[1] / "db" / "schema.sql"
|
||||
db_path = Path(collector.__file__).parents[1] / "db" / "db.py"
|
||||
schema = schema_path.read_text(encoding="utf-8")
|
||||
db_source = db_path.read_text(encoding="utf-8")
|
||||
|
||||
assert "CREATE TABLE IF NOT EXISTS jd_after_sales_issues" in schema
|
||||
assert "user_original TEXT NOT NULL" in schema
|
||||
assert "UNIQUE (account, business_date, service_id)" in schema
|
||||
assert "def upsert_jd_after_sales_issues" in db_source
|
||||
assert '"jd_after_sales_issues"' in db_source
|
||||
assert '"account", "business_date", "service_id"' in db_source
|
||||
@@ -0,0 +1,197 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from gyxx_flow.modules.shop_intelligence.collectors import (
|
||||
jd_customer_service_messages as collector,
|
||||
)
|
||||
|
||||
|
||||
def _payload() -> dict[str, object]:
|
||||
return {
|
||||
"code": "success",
|
||||
"totalPage": 1,
|
||||
"chatLogList": [
|
||||
{
|
||||
"chatLogMessageList": [
|
||||
{
|
||||
"sid": "session-1",
|
||||
"uuid": "message-1",
|
||||
"customer": "customer-demo",
|
||||
"created": "2026-09-10 10:01:02",
|
||||
"closeSessionTime": "2026-09-10 10:10:00",
|
||||
"content": "请问这个商品有现货吗?",
|
||||
"type": "text",
|
||||
"waiter": "示例客服",
|
||||
"waiterSend": 0,
|
||||
"imgUrl": "",
|
||||
},
|
||||
{
|
||||
"sid": "session-1",
|
||||
"uuid": "message-2",
|
||||
"customer": "customer-demo",
|
||||
"created": "2026-09-10 10:02:03",
|
||||
"closeSessionTime": "2026-09-10 10:10:00",
|
||||
"content": "有现货,今天可以安排发出。",
|
||||
"type": "text",
|
||||
"waiter": "示例客服",
|
||||
"waiterSend": 1,
|
||||
"imgUrl": "",
|
||||
},
|
||||
]
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_normalize_chat_payload_keeps_business_and_message_times() -> None:
|
||||
result = collector.normalize_chat_payload(
|
||||
_payload(),
|
||||
account="demo-shop",
|
||||
business_date=date(2026, 9, 10),
|
||||
customer_id="customer-demo",
|
||||
source_url=collector.TARGET_URL,
|
||||
)
|
||||
|
||||
assert result["session_count"] == 1
|
||||
assert result["message_count"] == 2
|
||||
conversation = result["conversations"][0]
|
||||
assert conversation["business_date"] == date(2026, 9, 10)
|
||||
assert conversation["session_id"] == "session-1"
|
||||
assert conversation["session_start_at"].isoformat() == "2026-09-10T10:01:02+08:00"
|
||||
messages = result["messages"]
|
||||
assert messages[0]["sender_role"] == "customer"
|
||||
assert messages[0]["waiter_send"] is False
|
||||
assert messages[1]["sender_role"] == "waiter"
|
||||
assert messages[1]["waiter_send"] is True
|
||||
assert messages[1]["message_at"].isoformat() == "2026-09-10T10:02:03+08:00"
|
||||
|
||||
|
||||
def test_normalize_chat_payload_fails_closed_on_unhandled_message_paging() -> None:
|
||||
payload = _payload()
|
||||
payload["totalPage"] = 2
|
||||
|
||||
with pytest.raises(RuntimeError, match="拒绝截断入库"):
|
||||
collector.normalize_chat_payload(
|
||||
payload,
|
||||
account="demo-shop",
|
||||
business_date=date(2026, 9, 10),
|
||||
customer_id="customer-demo",
|
||||
source_url=collector.TARGET_URL,
|
||||
)
|
||||
|
||||
|
||||
def test_normalize_chat_payload_filters_templates_and_stickers_but_keeps_real_media() -> None:
|
||||
payload = _payload()
|
||||
payload["chatLogList"][0]["chatLogMessageList"].extend(
|
||||
[
|
||||
{
|
||||
"sid": "session-1",
|
||||
"uuid": "message-template",
|
||||
"created": "2026-09-10 10:03:00",
|
||||
"content": "【此消息为欢迎卡片或富文本模板答案,聊天记录中暂不支持展示】",
|
||||
"type": "template2",
|
||||
"waiter": "示例客服",
|
||||
"waiterSend": 1,
|
||||
"imgUrl": "",
|
||||
},
|
||||
{
|
||||
"sid": "session-1",
|
||||
"uuid": "message-sticker-image",
|
||||
"created": "2026-09-10 10:04:00",
|
||||
"content": "",
|
||||
"type": "image",
|
||||
"waiter": "示例客服",
|
||||
"waiterSend": 1,
|
||||
"imgUrl": "https://img11.360buyimg.com/stickermanage/demo.png",
|
||||
},
|
||||
{
|
||||
"sid": "session-1",
|
||||
"uuid": "message-sticker-file",
|
||||
"created": "2026-09-10 10:05:00",
|
||||
"content": "file:///C:/Users/Public/Documents/AliWorkbench/emotions/MyEmotion/demo.png",
|
||||
"type": "text",
|
||||
"waiter": "示例客服",
|
||||
"waiterSend": 1,
|
||||
"imgUrl": "",
|
||||
},
|
||||
{
|
||||
"sid": "session-1",
|
||||
"uuid": "message-real-image",
|
||||
"created": "2026-09-10 10:06:00",
|
||||
"content": "",
|
||||
"type": "image",
|
||||
"waiter": "示例客服",
|
||||
"waiterSend": 1,
|
||||
"imgUrl": "https://img10.360buyimg.com/img/jfs/demo.jpg",
|
||||
},
|
||||
]
|
||||
)
|
||||
|
||||
result = collector.normalize_chat_payload(
|
||||
payload,
|
||||
account="demo-shop",
|
||||
business_date=date(2026, 9, 10),
|
||||
customer_id="customer-demo",
|
||||
source_url=collector.TARGET_URL,
|
||||
)
|
||||
|
||||
assert result["session_count"] == 1
|
||||
assert result["message_count"] == 3
|
||||
assert result["filtered_message_count"] == 3
|
||||
assert [message["message_type"] for message in result["messages"]] == [
|
||||
"text",
|
||||
"text",
|
||||
"image",
|
||||
]
|
||||
assert result["messages"][-1]["image_url"].endswith("demo.jpg")
|
||||
assert result["conversations"][0]["message_count"] == 3
|
||||
assert len(result["conversations"][0]["raw_session"]["chatLogMessageList"]) == 3
|
||||
|
||||
|
||||
def test_save_progress_marks_unresolved_customers_and_keeps_raw_response(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
output = tmp_path / "客服消息_2026-09-10.json"
|
||||
collector.save_progress(
|
||||
output,
|
||||
account="demo-shop",
|
||||
business_date=date(2026, 9, 10),
|
||||
expected_customers=["customer-demo", "customer-pending"],
|
||||
collected={"customer-demo": {"message_count": 2}},
|
||||
raw_payloads={"customer-demo": _payload()},
|
||||
complete=False,
|
||||
)
|
||||
|
||||
saved = json.loads(output.read_text(encoding="utf-8"))
|
||||
assert saved["store"] == "flagship"
|
||||
assert saved["complete"] is False
|
||||
assert saved["unresolved_customers"] == ["customer-pending"]
|
||||
assert saved["raw_payloads"]["customer-demo"]["chatLogList"]
|
||||
|
||||
|
||||
def test_customer_service_store_routes_to_the_self_operated_identity() -> None:
|
||||
store, config = collector._customer_service_store("self-operated")
|
||||
|
||||
assert store == "self-operated"
|
||||
assert config["account_id"] == "jd-self-operated"
|
||||
assert collector._configured_identity_markers(
|
||||
{
|
||||
"GYXX_JD_CUSTOMER_SERVICE_IDENTITY_MARKERS": '["光影自营售前"]',
|
||||
}
|
||||
) == ("光影自营售前",)
|
||||
|
||||
|
||||
def test_customer_service_schema_has_date_and_actual_message_time_columns() -> None:
|
||||
schema = Path(collector.__file__).parents[1] / "db" / "schema.sql"
|
||||
text = schema.read_text(encoding="utf-8")
|
||||
|
||||
assert "jd_customer_service_conversations" in text
|
||||
assert "jd_customer_service_messages" in text
|
||||
assert "business_date DATE NOT NULL" in text
|
||||
assert "message_at TIMESTAMPTZ" in text
|
||||
assert "UNIQUE (account, business_date, customer_id, session_id, message_key)" in text
|
||||
@@ -0,0 +1,99 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
|
||||
from gyxx_flow.modules.shop_intelligence.collectors import (
|
||||
jd_negative_reviews as collector,
|
||||
)
|
||||
|
||||
|
||||
def _payload() -> dict[str, object]:
|
||||
return {
|
||||
"code": 200,
|
||||
"msg": "成功",
|
||||
"data": {
|
||||
"total": "1",
|
||||
"pageIndex": 1,
|
||||
"pageSize": 100,
|
||||
"results": [
|
||||
{
|
||||
"commentId": "review-1",
|
||||
"commentLevel": 1,
|
||||
"score": 1,
|
||||
"createDate": "2026-09-12 10:01:02",
|
||||
"nickName": "demo-buyer",
|
||||
"content": "商品与描述不一致",
|
||||
"orderId": "order-1",
|
||||
"skuId": "sku-1",
|
||||
"skuName": "光影行星示例商品 黑色",
|
||||
"productColor": "黑色",
|
||||
"productSize": "标准",
|
||||
"productImageUrl": "jfs/example.jpg",
|
||||
"images": [{"imgUrl": "jfs/review.jpg"}],
|
||||
"isVenderReply": False,
|
||||
"replyCount": 0,
|
||||
"secPin": "should-not-be-persisted",
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_normalize_negative_review_keeps_product_fields_and_date() -> None:
|
||||
rows = collector.normalize_review_results(
|
||||
_payload(),
|
||||
account="demo-shop",
|
||||
business_date=date(2026, 9, 12),
|
||||
source_url=collector.TARGET_URL,
|
||||
)
|
||||
|
||||
assert len(rows) == 1
|
||||
row = rows[0]
|
||||
assert row["business_date"] == date(2026, 9, 12)
|
||||
assert row["review_id"] == "review-1"
|
||||
assert row["review_level"] == "差评"
|
||||
assert row["content"] == "商品与描述不一致"
|
||||
assert row["product_name"] == "光影行星示例商品 黑色"
|
||||
assert row["sku_id"] == "sku-1"
|
||||
assert row["review_at"].isoformat() == "2026-09-12T10:01:02+08:00"
|
||||
assert row["is_vender_reply"] is False
|
||||
assert "secPin" not in row["raw_review"]
|
||||
|
||||
|
||||
def test_save_progress_keeps_page_count_and_redacts_sensitive_fields(tmp_path: Path) -> None:
|
||||
output = tmp_path / "差评_2026-09-12.json"
|
||||
collector.save_progress(
|
||||
output,
|
||||
account="demo-shop",
|
||||
store="flagship",
|
||||
business_date=date(2026, 9, 12),
|
||||
total=1,
|
||||
raw_pages=[collector._sanitize_review_payload(_payload())],
|
||||
collected={"review-1": {"sku_id": "sku-1"}},
|
||||
complete=True,
|
||||
)
|
||||
|
||||
saved = json.loads(output.read_text(encoding="utf-8"))
|
||||
assert saved["complete"] is True
|
||||
assert saved["collected_review_count"] == 1
|
||||
assert saved["raw_pages"][0]["data"]["results"][0].get("secPin") is None
|
||||
|
||||
|
||||
def test_negative_review_schema_has_date_product_and_idempotency_columns() -> None:
|
||||
schema = Path(collector.__file__).parents[1] / "db" / "schema.sql"
|
||||
text = schema.read_text(encoding="utf-8")
|
||||
|
||||
assert "jd_negative_reviews" in text
|
||||
assert "business_date DATE NOT NULL" in text
|
||||
assert "product_name TEXT" in text
|
||||
assert "sku_id TEXT" in text
|
||||
assert "UNIQUE (account, business_date, review_id)" in text
|
||||
|
||||
|
||||
def test_negative_review_store_reuses_customer_service_account_routing() -> None:
|
||||
store, config = collector._store("self-operated")
|
||||
|
||||
assert store == "self-operated"
|
||||
assert config["account_id"] == "jd-self-operated"
|
||||
@@ -4,7 +4,9 @@ from datetime import date
|
||||
|
||||
import pytest
|
||||
|
||||
from gyxx_flow.modules.shop_intelligence.collectors import jd_data_collector as collector
|
||||
from gyxx_flow.modules.shop_intelligence.collectors import (
|
||||
jd_data_collector as collector,
|
||||
)
|
||||
|
||||
|
||||
class _FlakyCellLocator:
|
||||
@@ -104,3 +106,18 @@ def test_old_datepicker_happy_path_uses_playwright_click(
|
||||
|
||||
assert page.cell.click_calls == 1
|
||||
assert page.native_click_script is None
|
||||
|
||||
|
||||
def test_merchant_ranking_skips_known_missing_module_permission(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(collector, "goto_with_retry", lambda *_args, **_kwargs: None)
|
||||
|
||||
class PermissionPage:
|
||||
def wait_for_timeout(self, _milliseconds: int) -> None:
|
||||
return None
|
||||
|
||||
def evaluate(self, _script: str):
|
||||
return "当前身份下暂无此菜单\n请在顶部导航栏选择其他菜单。"
|
||||
|
||||
assert collector.step_merchant_ranking(PermissionPage(), "男包") == {}
|
||||
@@ -139,15 +139,23 @@ TARGET_AST_SHA256_OVERRIDES = {
|
||||
("jd_data_collector.py", "select_last_week_in_old_datepicker"): (
|
||||
"43d1056eeac1faeff32f0c01d0867e6673d2ab57999a14a3ebeb1b51c40075d4"
|
||||
),
|
||||
# Reviewed after the 2026-09-15 production run showed the account's
|
||||
# merchant-ranking module is not entitled; skip that optional section while
|
||||
# retaining the store-level metrics and marketing collection.
|
||||
("jd_data_collector.py", "step_merchant_ranking"): (
|
||||
"b1cb2893f6e3a2378eca0e889980a06ffd2dcccc71b8c7714be6714958b274dd"
|
||||
),
|
||||
("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"
|
||||
),
|
||||
# Reviewed after the 2026-09-15 production run showed FXG had moved from
|
||||
# the old "我的体验分" labels to the new "我的总分/商品体验" labels.
|
||||
("dy_store_competitor_store_scraping.py", "_extract_experience_score"): (
|
||||
"c2eb1513c9aa510e9cf2f2c6baa155b7d6538c038ce5ff4782caed32850221c6"
|
||||
),
|
||||
}
|
||||
|
||||
# These two source functions were reviewed after a production run proved that
|
||||
|
||||
@@ -34,17 +34,26 @@ def test_catalog_composition_registers_all_workflows() -> None:
|
||||
|
||||
registry = create_default_registry(catalog=catalog)
|
||||
|
||||
assert len(registry.workflow_ids) == 29
|
||||
assert len(registry.workflow_ids) == 39
|
||||
assert set(registry.workflow_ids) == {
|
||||
"shop.metrics.weekly",
|
||||
"shop.competitor.weekly",
|
||||
"shop.jd_self_operated.daily",
|
||||
"shop.jd_customer_service.daily",
|
||||
"shop.jd_negative_reviews.daily",
|
||||
"shop.jd_after_sales_issues.daily",
|
||||
"shop.douyin_customer_service.daily",
|
||||
"shop.tmall_customer_service.daily",
|
||||
"shop.tmall_negative_reviews.daily",
|
||||
"shop.dy_negative_reviews.daily",
|
||||
"shop.douyin_after_sales_quality.daily",
|
||||
"shop.douyin_price_appeal",
|
||||
"supply.purchase_confirmation.daily",
|
||||
"supply.replenishment.weekly",
|
||||
"supply.replenishment_alert.daily",
|
||||
"supply.replenishment_arrival_sync.daily",
|
||||
"content.metrics.daily",
|
||||
"content.metrics.self_operated.daily",
|
||||
"content.metrics.backfill",
|
||||
"content.marketing_report.daily",
|
||||
"content.relogin.weekly",
|
||||
@@ -55,6 +64,7 @@ def test_catalog_composition_registers_all_workflows() -> None:
|
||||
"content.summary.weekly",
|
||||
"product.persona.daily",
|
||||
"product.daily",
|
||||
"product.online_stock_alert.daily",
|
||||
"product.alert.daily",
|
||||
"product.ecommerce_costs.daily",
|
||||
"product.style_analysis.interval",
|
||||
|
||||
+50
-16
@@ -11,16 +11,16 @@ from gyxx_flow.catalog import CatalogError, WorkflowCatalog
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def test_catalog_maps_29_project_scheduled_tasks() -> None:
|
||||
def test_catalog_maps_39_project_scheduled_tasks() -> None:
|
||||
catalog = WorkflowCatalog.load(PROJECT_ROOT / "config")
|
||||
scheduled = catalog.scheduled_workflows()
|
||||
|
||||
assert len(scheduled) == 29
|
||||
assert len({item.source_task_name for item in scheduled}) == 29
|
||||
assert len(scheduled) == 39
|
||||
assert len({item.source_task_name for item in scheduled}) == 39
|
||||
assert Counter(item.module for item in scheduled) == {
|
||||
"content_marketing": 9,
|
||||
"product_commerce": 12,
|
||||
"shop_intelligence": 4,
|
||||
"content_marketing": 10,
|
||||
"product_commerce": 13,
|
||||
"shop_intelligence": 12,
|
||||
"supply_chain": 4,
|
||||
}
|
||||
|
||||
@@ -29,14 +29,14 @@ def test_catalog_schedules_douyin_price_appeal() -> None:
|
||||
catalog = WorkflowCatalog.load(PROJECT_ROOT / "config")
|
||||
schedule = catalog.schedule_for("shop.douyin_price_appeal")
|
||||
|
||||
assert len(catalog.workflows) == 29
|
||||
assert len(catalog.workflows) == 39
|
||||
assert catalog.manual_workflows() == ()
|
||||
assert "shop.douyin_price_appeal" in {
|
||||
schedule.workflow_id for schedule in catalog.schedules
|
||||
}
|
||||
assert catalog.unavailable_workflows() == ()
|
||||
assert schedule.kind == "daily"
|
||||
assert schedule.effective_times == ("08:00", "16:00", "22:00")
|
||||
assert schedule.effective_times == tuple(f"{hour:02d}:00" for hour in range(24))
|
||||
|
||||
|
||||
def test_tmall_video_upload_is_an_idempotent_store_selected_scheduled_workflow() -> None:
|
||||
@@ -186,13 +186,18 @@ def test_every_scheduled_workflow_has_one_valid_asia_shanghai_schedule() -> None
|
||||
catalog = WorkflowCatalog.load(PROJECT_ROOT / "config")
|
||||
|
||||
assert catalog.timezone == "Asia/Shanghai"
|
||||
assert len(catalog.schedules) == 29
|
||||
assert len(catalog.schedules) == 39
|
||||
for workflow in catalog.scheduled_workflows():
|
||||
schedule = catalog.schedule_for(workflow.workflow_id)
|
||||
assert schedule.workflow_id == workflow.workflow_id
|
||||
assert schedule.kind in {"daily", "weekly", "monthly", "interval_days"}
|
||||
assert schedule.at.count(":") == 1
|
||||
assert sum(schedule.enabled for schedule in catalog.schedules) == 25
|
||||
assert sum(schedule.enabled for schedule in catalog.schedules) == 27
|
||||
customer_service = catalog.schedule_for("shop.jd_customer_service.daily")
|
||||
assert customer_service.at == "03:00"
|
||||
assert customer_service.enabled is True
|
||||
assert customer_service.kind == "monthly"
|
||||
assert customer_service.day_of_month == 21
|
||||
|
||||
|
||||
def test_scheduled_workflows_use_python_entries_and_content_tasks_are_graphs() -> None:
|
||||
@@ -221,23 +226,49 @@ def test_scheduled_workflows_use_python_entries_and_content_tasks_are_graphs() -
|
||||
)
|
||||
assert [step.step_id for step in daily.steps] == [
|
||||
"collect_collaborators",
|
||||
"refresh_self_mapping",
|
||||
"collect_self_bilibili",
|
||||
"collect_self_douyin",
|
||||
"sync",
|
||||
]
|
||||
assert daily.steps[0].depends_on == ()
|
||||
assert daily.steps[1].depends_on == ("collect_collaborators",)
|
||||
assert daily.steps[2].depends_on == ("refresh_self_mapping",)
|
||||
assert daily.steps[3].depends_on == ("collect_self_bilibili",)
|
||||
assert daily.steps[4].depends_on == ("collect_self_douyin",)
|
||||
assert "--include-self-operated" not in daily.steps[0].args
|
||||
assert daily.steps[1].args == ("--scope", "collaborators")
|
||||
assert all(step.replay_policy == "repeatable" for step in daily.steps)
|
||||
assert catalog.schedule_for(daily.workflow_id).effective_times == (
|
||||
"01:00",
|
||||
"05:00",
|
||||
)
|
||||
|
||||
self_operated = next(
|
||||
workflow
|
||||
for workflow in catalog.workflows
|
||||
if workflow.workflow_id == "content.metrics.self_operated.daily"
|
||||
)
|
||||
assert [step.step_id for step in self_operated.steps] == [
|
||||
"refresh_self_mapping",
|
||||
"collect_self_bilibili",
|
||||
"collect_self_douyin",
|
||||
"collect_self_xiaohongshu",
|
||||
"sync",
|
||||
"sync_self_video_metrics",
|
||||
]
|
||||
assert self_operated.steps[0].depends_on == ()
|
||||
assert self_operated.steps[1].depends_on == ("refresh_self_mapping",)
|
||||
assert self_operated.steps[2].depends_on == ("collect_self_bilibili",)
|
||||
assert self_operated.steps[3].depends_on == ("collect_self_douyin",)
|
||||
assert self_operated.steps[4].depends_on == ("collect_self_douyin",)
|
||||
assert self_operated.steps[4].args == ("--scope", "self-operated")
|
||||
assert self_operated.steps[5].depends_on == (
|
||||
"sync",
|
||||
"collect_self_xiaohongshu",
|
||||
)
|
||||
assert self_operated.steps[5].entry == (
|
||||
"data/tools/sync_self_video_metrics.py"
|
||||
)
|
||||
assert catalog.schedule_for(self_operated.workflow_id).effective_times == (
|
||||
"01:00",
|
||||
"05:00",
|
||||
)
|
||||
|
||||
backfill = next(
|
||||
workflow
|
||||
for workflow in catalog.workflows
|
||||
@@ -250,6 +281,7 @@ def test_scheduled_workflows_use_python_entries_and_content_tasks_are_graphs() -
|
||||
assert backfill.steps[0].entry == "run_all.py"
|
||||
assert backfill.steps[0].args == ("--daily-scope",)
|
||||
assert backfill.steps[1].entry == "data/tools/sync_metrics_to_cmt_notes.py"
|
||||
assert backfill.steps[1].args == ("--scope", "collaborators")
|
||||
assert backfill.steps[1].depends_on == ("collect_collaborators",)
|
||||
assert backfill.steps[1].run_after_failure is True
|
||||
backfill_schedule = catalog.schedule_for(backfill.workflow_id)
|
||||
@@ -341,6 +373,8 @@ def test_shop_steps_preserve_declared_replay_policy_at_module_boundary() -> None
|
||||
assert policies["shop.metrics.weekly"] == {"idempotent"}
|
||||
assert policies["shop.competitor.weekly"] == {"idempotent"}
|
||||
assert policies["shop.jd_self_operated.daily"] == {"idempotent"}
|
||||
assert policies["shop.jd_customer_service.daily"] == {"idempotent"}
|
||||
assert policies["shop.jd_after_sales_issues.daily"] == {"idempotent"}
|
||||
assert policies["shop.douyin_price_appeal"] == {"idempotent"}
|
||||
|
||||
|
||||
|
||||
@@ -2,9 +2,12 @@ from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import gyxx_flow.cli as cli
|
||||
from gyxx_flow.catalog import WorkflowCatalog
|
||||
from gyxx_flow.cli import (
|
||||
EXIT_CONFIGURATION,
|
||||
@@ -51,7 +54,7 @@ def test_default_cli_registry_exposes_all_project_scheduled_workflows(
|
||||
scheduled_ids = {
|
||||
entry.workflow_id for entry in registry.catalog.scheduled_workflows()
|
||||
}
|
||||
assert len(scheduled_ids) == 29
|
||||
assert len(scheduled_ids) == 35
|
||||
assert scheduled_ids == {
|
||||
schedule.workflow_id for schedule in registry.catalog.schedules
|
||||
}
|
||||
@@ -271,6 +274,49 @@ def test_backfill_runs_each_business_date_and_aggregates_final_exit_code(
|
||||
]
|
||||
|
||||
|
||||
def test_jd_after_sales_backfill_executes_one_range_and_restores_end_env(
|
||||
tmp_path: Path, monkeypatch,
|
||||
) -> None:
|
||||
settings = Settings(project_root=tmp_path, data_root=tmp_path / "runtime")
|
||||
workflow = SimpleNamespace(workflow_id=cli.JD_AFTER_SALES_ISSUES_WORKFLOW_ID)
|
||||
registry = SimpleNamespace(resolve=lambda _workflow_id: SimpleNamespace(definition=workflow))
|
||||
seen: list[tuple[date, str | None, bool]] = []
|
||||
|
||||
def fake_execute_once(
|
||||
_workflow,
|
||||
*,
|
||||
business_date: date,
|
||||
shadow: bool,
|
||||
dry_run: bool,
|
||||
settings: Settings,
|
||||
) -> tuple[int, dict[str, object]]:
|
||||
del shadow, settings
|
||||
seen.append((business_date, os.environ.get("GYXX_JD_AFTER_SALES_TO"), dry_run))
|
||||
return EXIT_SUCCESS, {"business_date": business_date.isoformat()}
|
||||
|
||||
monkeypatch.delenv("GYXX_JD_AFTER_SALES_TO", raising=False)
|
||||
monkeypatch.setattr(cli, "_execute_once", fake_execute_once)
|
||||
arguments = SimpleNamespace(
|
||||
workflow_id=cli.JD_AFTER_SALES_ISSUES_WORKFLOW_ID,
|
||||
from_date="2026-06-01",
|
||||
to_date="2026-09-15",
|
||||
shadow=False,
|
||||
execute=True,
|
||||
rerun_step=None,
|
||||
resume_from=None,
|
||||
)
|
||||
output = io.StringIO()
|
||||
|
||||
code = cli._backfill_command(arguments, registry, settings, output)
|
||||
|
||||
assert code == EXIT_SUCCESS
|
||||
assert seen == [(date(2026, 6, 1), "2026-09-15", False)]
|
||||
assert "GYXX_JD_AFTER_SALES_TO" not in os.environ
|
||||
payload = json.loads(output.getvalue())
|
||||
assert payload["execution_mode"] == "single_range"
|
||||
assert payload["runs"][0]["business_date_end"] == "2026-09-15"
|
||||
|
||||
|
||||
def test_step_rerun_and_resume_select_only_the_requested_execution_slice(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
|
||||
+210
-11
@@ -69,6 +69,8 @@ class FakeConsoleLauncher:
|
||||
tmall_topic_keyword: str | None = None,
|
||||
force_refresh: bool = False,
|
||||
tmall_baibu_import_mode: str | None = None,
|
||||
jd_customer_service_store: str | None = None,
|
||||
business_date_end: date | None = None,
|
||||
) -> dict[str, object]:
|
||||
if workflow_id in self._active:
|
||||
raise ConsoleConflictError("already active")
|
||||
@@ -89,6 +91,10 @@ class FakeConsoleLauncher:
|
||||
operation["force_refresh"] = True
|
||||
if tmall_baibu_import_mode is not None:
|
||||
operation["tmall_baibu_import_mode"] = tmall_baibu_import_mode
|
||||
if jd_customer_service_store is not None:
|
||||
operation["store"] = jd_customer_service_store
|
||||
if business_date_end is not None:
|
||||
operation["business_date_end"] = business_date_end.isoformat()
|
||||
self.calls.append(operation)
|
||||
self._active[workflow_id] = operation
|
||||
return dict(operation)
|
||||
@@ -567,7 +573,9 @@ def test_overview_tracks_the_current_complete_catalog_without_leaking_paths(
|
||||
assert price_appeal["name"] == "抖音待改价申诉"
|
||||
assert price_appeal["trigger"] == "scheduled"
|
||||
assert price_appeal["registered"] is True
|
||||
assert price_appeal["schedule"]["at"] == ["08:00", "16:00", "22:00"]
|
||||
assert price_appeal["schedule"]["at"] == [
|
||||
f"{hour:02d}:00" for hour in range(24)
|
||||
]
|
||||
assert price_appeal["steps"][0]["replay_policy"] == "idempotent"
|
||||
assert price_appeal["steps"][0]["timeout_seconds"] == 1_800
|
||||
jd_self_operated = next(
|
||||
@@ -582,6 +590,58 @@ def test_overview_tracks_the_current_complete_catalog_without_leaking_paths(
|
||||
assert [
|
||||
step["timeout_seconds"] for step in jd_self_operated["steps"]
|
||||
] == [600, 1_800]
|
||||
customer_service = next(
|
||||
item
|
||||
for item in initial["workflows"]
|
||||
if item["id"] == "shop.jd_customer_service.daily"
|
||||
)
|
||||
assert customer_service["name"] == "京东客服消息采集"
|
||||
assert customer_service["registered"] is True
|
||||
assert customer_service["run_date_mode"] == "date"
|
||||
assert customer_service["date_range_mode"] == "date_range"
|
||||
assert customer_service["schedule"]["enabled"] is True
|
||||
assert customer_service["store_config"]["options"] == [
|
||||
{
|
||||
"value": "flagship",
|
||||
"label": "光影行星旗舰店",
|
||||
"account_id": "jd-shop",
|
||||
},
|
||||
{
|
||||
"value": "self-operated",
|
||||
"label": "京东自营店(光影自营研发)",
|
||||
"account_id": "jd-self-operated",
|
||||
},
|
||||
]
|
||||
assert customer_service["steps"][0]["args"] == [
|
||||
"--date",
|
||||
"{business_date}",
|
||||
"--range-days",
|
||||
"30",
|
||||
"--headless",
|
||||
]
|
||||
assert customer_service["steps"][0]["timeout_seconds"] == 14_400
|
||||
negative_reviews = next(
|
||||
item
|
||||
for item in initial["workflows"]
|
||||
if item["id"] == "shop.jd_negative_reviews.daily"
|
||||
)
|
||||
assert negative_reviews["date_range_mode"] == "date_range"
|
||||
assert negative_reviews["store_config"]["options"] == customer_service["store_config"]["options"]
|
||||
after_sales = next(
|
||||
item
|
||||
for item in initial["workflows"]
|
||||
if item["id"] == "shop.jd_after_sales_issues.daily"
|
||||
)
|
||||
assert after_sales["name"] == "京东品退问题采集"
|
||||
assert after_sales["date_range_mode"] == "date_range"
|
||||
assert after_sales["store_config"]["options"] == [
|
||||
{
|
||||
"value": "flagship",
|
||||
"label": "光影行星旗舰店(研发账号)",
|
||||
"account_id": "jd-shop",
|
||||
}
|
||||
]
|
||||
assert after_sales["steps"][0]["replay_policy"] == "idempotent"
|
||||
monthly_sales = next(
|
||||
item
|
||||
for item in initial["workflows"]
|
||||
@@ -648,7 +708,7 @@ def test_start_scheduler_launches_resident_process_with_console_environment(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
env_file = tmp_path / "runtime.env"
|
||||
env_file.write_text("PG_PASSWORD=redacted-for-test\n", encoding="utf-8")
|
||||
env_file.write_text("PG_PASSWORD=redacted" + "\n", encoding="utf-8")
|
||||
process = type(
|
||||
"FakeSchedulerProcess",
|
||||
(),
|
||||
@@ -1356,6 +1416,116 @@ def test_tmall_baibu_trigger_defaults_to_no_hyperlinks_and_accepts_all_mode(
|
||||
)
|
||||
|
||||
|
||||
def test_customer_service_trigger_routes_selected_store_and_date(
|
||||
console_settings: Settings,
|
||||
) -> None:
|
||||
launcher = FakeConsoleLauncher()
|
||||
service = WorkflowConsoleService(console_settings, launcher=launcher)
|
||||
|
||||
result = service.trigger(
|
||||
"shop.jd_customer_service.daily",
|
||||
{
|
||||
"business_date": "2026-09-12",
|
||||
"business_date_end": "2026-09-13",
|
||||
"store": "self-operated",
|
||||
},
|
||||
)
|
||||
|
||||
assert result["business_date"] == "2026-09-12"
|
||||
assert result["business_date_end"] == "2026-09-13"
|
||||
assert result["store"] == "self-operated"
|
||||
assert launcher.calls[-1]["store"] == "self-operated"
|
||||
|
||||
review_result = service.trigger(
|
||||
"shop.jd_negative_reviews.daily",
|
||||
{
|
||||
"business_date": "2026-09-12",
|
||||
"business_date_end": "2026-09-14",
|
||||
"store": "flagship",
|
||||
},
|
||||
)
|
||||
assert review_result["business_date_end"] == "2026-09-14"
|
||||
assert launcher.calls[-1]["store"] == "flagship"
|
||||
|
||||
after_sales_result = service.trigger(
|
||||
"shop.jd_after_sales_issues.daily",
|
||||
{
|
||||
"business_date": "2026-09-12",
|
||||
"business_date_end": "2026-09-13",
|
||||
"store": "flagship",
|
||||
},
|
||||
)
|
||||
assert after_sales_result["business_date_end"] == "2026-09-13"
|
||||
assert after_sales_result["store"] == "flagship"
|
||||
assert launcher.calls[-1]["store"] == "flagship"
|
||||
|
||||
with pytest.raises(ConsoleRequestError, match="京东品退店铺"):
|
||||
service.trigger(
|
||||
"shop.jd_after_sales_issues.daily",
|
||||
{"business_date": "2026-09-12", "store": "self-operated"},
|
||||
)
|
||||
|
||||
with pytest.raises(ConsoleRequestError, match="结束日期"):
|
||||
service.trigger(
|
||||
"shop.jd_negative_reviews.daily",
|
||||
{
|
||||
"business_date": "2026-09-14",
|
||||
"business_date_end": "2026-09-12",
|
||||
"store": "flagship",
|
||||
},
|
||||
)
|
||||
|
||||
with pytest.raises(ConsoleRequestError, match="京东客服店铺"):
|
||||
service.trigger(
|
||||
"shop.jd_customer_service.daily",
|
||||
{"business_date": "2026-09-12", "store": "unknown"},
|
||||
)
|
||||
|
||||
|
||||
def test_douyin_customer_service_trigger_accepts_date_range_without_store(
|
||||
console_settings: Settings,
|
||||
) -> None:
|
||||
launcher = FakeConsoleLauncher()
|
||||
service = WorkflowConsoleService(console_settings, launcher=launcher)
|
||||
|
||||
result = service.trigger(
|
||||
"shop.douyin_customer_service.daily",
|
||||
{
|
||||
"business_date": "2026-09-12",
|
||||
"business_date_end": "2026-09-14",
|
||||
},
|
||||
)
|
||||
|
||||
assert result["business_date"] == "2026-09-12"
|
||||
assert result["business_date_end"] == "2026-09-14"
|
||||
assert "store" not in result
|
||||
assert launcher.calls[-1].get("store") is None
|
||||
|
||||
single_day_service = WorkflowConsoleService(
|
||||
console_settings, launcher=FakeConsoleLauncher()
|
||||
)
|
||||
single_day = single_day_service.trigger(
|
||||
"shop.douyin_customer_service.daily",
|
||||
{"business_date": "2026-09-12"},
|
||||
)
|
||||
assert "business_date_end" not in single_day
|
||||
|
||||
with pytest.raises(ConsoleRequestError, match="结束日期"):
|
||||
service.trigger(
|
||||
"shop.douyin_customer_service.daily",
|
||||
{
|
||||
"business_date": "2026-09-14",
|
||||
"business_date_end": "2026-09-12",
|
||||
},
|
||||
)
|
||||
|
||||
with pytest.raises(ConsoleRequestError, match="不支持的字段"):
|
||||
service.trigger(
|
||||
"shop.douyin_customer_service.daily",
|
||||
{"business_date": "2026-09-12", "store": "flagship"},
|
||||
)
|
||||
|
||||
|
||||
def test_tmall_baibu_force_trigger_does_not_read_business_date_receipts(
|
||||
console_settings: Settings,
|
||||
) -> None:
|
||||
@@ -1683,11 +1853,11 @@ def test_content_summary_preflight_requires_direct_model_key(
|
||||
def test_content_summary_child_does_not_inherit_hermes_credentials() -> None:
|
||||
resolved = console_module._console_runtime_environment(
|
||||
{
|
||||
"GYXX_HERMES_API_KEY": "legacy-secret",
|
||||
"HERMES_API_KEY": "legacy-secret",
|
||||
"HERMES_ANALYZER_TOKEN": "legacy-secret",
|
||||
"GYXX_SUPPLY_HERMES_TOKEN": "legacy-secret",
|
||||
"CONTENT_ANALYSIS_LLM_API_KEY": "direct-secret",
|
||||
"GYXX_HERMES_API_KEY": "secret",
|
||||
"HERMES_API_KEY": "secret",
|
||||
"HERMES_ANALYZER_TOKEN": "secret",
|
||||
"GYXX_SUPPLY_HERMES_TOKEN": "secret",
|
||||
"CONTENT_ANALYSIS_LLM_API_KEY": "secret",
|
||||
},
|
||||
workflow_id="content.summary.weekly",
|
||||
)
|
||||
@@ -1696,7 +1866,7 @@ def test_content_summary_child_does_not_inherit_hermes_credentials() -> None:
|
||||
assert "HERMES_API_KEY" not in resolved
|
||||
assert "HERMES_ANALYZER_TOKEN" not in resolved
|
||||
assert "GYXX_SUPPLY_HERMES_TOKEN" not in resolved
|
||||
assert resolved["CONTENT_ANALYSIS_LLM_API_KEY"] == "direct-secret"
|
||||
assert resolved["CONTENT_ANALYSIS_LLM_API_KEY"] == "secret"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("host", ["localhost", "127.0.0.1", "[::1]"])
|
||||
@@ -2019,10 +2189,10 @@ def test_video_production_preflight_uses_direct_model_without_hermes(
|
||||
"PG_USER": "runtime",
|
||||
"PG_PASSWORD": "placeholder",
|
||||
"STYLE_ANALYSIS_LLM_BASE_URL": "https://api.minimaxi.com/v1",
|
||||
"STYLE_ANALYSIS_LLM_API_KEY": "test-only-secret",
|
||||
"STYLE_ANALYSIS_LLM_API_KEY": "secret",
|
||||
"STYLE_ANALYSIS_LLM_MODEL": "MiniMax-M3",
|
||||
"GYXX_HERMES_API_KEY": "legacy-secret",
|
||||
"HERMES_API_KEY": "legacy-secret",
|
||||
"GYXX_HERMES_API_KEY": "secret",
|
||||
"HERMES_API_KEY": "secret",
|
||||
}
|
||||
|
||||
def forbidden_hermes_resolver(*_args: object, **_kwargs: object) -> str:
|
||||
@@ -3417,3 +3587,32 @@ def test_http_workflow_detail_and_diagnosis_routes(
|
||||
f"/api/workflows/content.metrics.daily/runs/{run_id}",
|
||||
)
|
||||
assert status == HTTPStatus.NOT_FOUND
|
||||
|
||||
|
||||
def test_douyin_negative_reviews_range_uses_single_run_with_env_dates(
|
||||
console_settings: Settings,
|
||||
) -> None:
|
||||
"""抖音差评区间提交走单次 run,并把区间写入环境变量。"""
|
||||
|
||||
launcher = FakeConsoleLauncher()
|
||||
service = WorkflowConsoleService(console_settings, launcher=launcher)
|
||||
|
||||
result = service.trigger(
|
||||
"shop.dy_negative_reviews.daily",
|
||||
{
|
||||
"business_date": "2026-08-23",
|
||||
"business_date_end": "2026-09-21",
|
||||
},
|
||||
)
|
||||
|
||||
assert result["business_date"] == "2026-08-23"
|
||||
assert result["business_date_end"] == "2026-09-21"
|
||||
|
||||
service_single = WorkflowConsoleService(
|
||||
console_settings, launcher=FakeConsoleLauncher()
|
||||
)
|
||||
single = service_single.trigger(
|
||||
"shop.dy_negative_reviews.daily",
|
||||
{"business_date": "2026-09-19"},
|
||||
)
|
||||
assert "business_date_end" not in single
|
||||
@@ -23,7 +23,7 @@ def test_content_module_registers_only_scheduled_entries() -> None:
|
||||
|
||||
definitions = module.workflow_definitions()
|
||||
assert tuple(item.workflow_id for item in definitions) == CONTENT_WORKFLOW_IDS
|
||||
assert len(CONTENT_SCHEDULED_WORKFLOW_IDS) == 9
|
||||
assert len(CONTENT_SCHEDULED_WORKFLOW_IDS) == 10
|
||||
|
||||
|
||||
def test_content_workflows_use_project_owned_module_commands() -> None:
|
||||
@@ -46,9 +46,12 @@ def test_content_workflows_use_project_owned_module_commands() -> None:
|
||||
assert step.max_attempts == 1
|
||||
if definition.workflow_id == "content.comments.weekly":
|
||||
assert step.resources == (f"{CONTENT_RESOURCE}:{step.step_id}",)
|
||||
elif definition.workflow_id == "content.metrics.daily":
|
||||
elif definition.workflow_id in {
|
||||
"content.metrics.daily",
|
||||
"content.metrics.self_operated.daily",
|
||||
}:
|
||||
assert step.resources == (
|
||||
f"{CONTENT_RESOURCE}:content.metrics.daily:{step.step_id}",
|
||||
f"{CONTENT_RESOURCE}:{definition.workflow_id}:{step.step_id}",
|
||||
)
|
||||
else:
|
||||
assert step.resources == (CONTENT_RESOURCE,) == (
|
||||
@@ -68,9 +71,22 @@ def test_daily_content_steps_have_independent_resources() -> None:
|
||||
|
||||
assert {step.step_id for step in daily.steps} == {
|
||||
"collect_collaborators",
|
||||
"refresh_self_mapping",
|
||||
"collect_self_bilibili",
|
||||
"collect_self_douyin",
|
||||
"sync",
|
||||
}
|
||||
assert len({step.resources for step in daily.steps}) == len(daily.steps)
|
||||
|
||||
self_operated = next(
|
||||
item for item in module.workflow_definitions()
|
||||
if item.workflow_id == "content.metrics.self_operated.daily"
|
||||
)
|
||||
assert {step.step_id for step in self_operated.steps} == {
|
||||
"refresh_self_mapping",
|
||||
"collect_self_bilibili",
|
||||
"collect_self_douyin",
|
||||
"collect_self_xiaohongshu",
|
||||
"sync",
|
||||
"sync_self_video_metrics",
|
||||
}
|
||||
assert len({step.resources for step in self_operated.steps}) == len(
|
||||
self_operated.steps
|
||||
)
|
||||
@@ -36,10 +36,12 @@ def test_repository_keeps_the_simplified_public_structure() -> None:
|
||||
"note-inventory.md",
|
||||
"plan.md",
|
||||
"runbook.md",
|
||||
"workflow-acceptance-test-report.md",
|
||||
"workflow-acceptance-test-requirements.md",
|
||||
"workflow-console.md",
|
||||
}
|
||||
"workbench.md",
|
||||
"workflow-acceptance-test-report.md",
|
||||
"workflow-acceptance-test-requirements.md",
|
||||
"workflow-console.md",
|
||||
"workflow-time-distribution.md",
|
||||
}
|
||||
assert not {
|
||||
"design.md",
|
||||
"final_report.md",
|
||||
|
||||
@@ -53,10 +53,10 @@ def test_catalog_and_scripts_work_after_project_and_data_roots_move(
|
||||
|
||||
assert workflows.returncode == 0, workflows.stderr
|
||||
assert scripts.returncode == 0, scripts.stderr
|
||||
assert len(json.loads(workflows.stdout)) == 29
|
||||
assert len(json.loads(workflows.stdout)) == 35
|
||||
commands = json.loads(scripts.stdout)
|
||||
assert len(commands) == 41
|
||||
assert len({row["command_id"] for row in commands}) == 41
|
||||
assert len(commands) == 55
|
||||
assert len({row["command_id"] for row in commands}) == 55
|
||||
assert all(row["legacy_script_id"].startswith(f"{row['module']}:") for row in commands)
|
||||
|
||||
|
||||
|
||||
@@ -102,10 +102,10 @@ def test_project_registry_covers_every_script_with_unique_stable_port() -> None:
|
||||
data_root=project_root / "var",
|
||||
)
|
||||
|
||||
assert len(scripts.command_ids) == 49
|
||||
assert len(first.command_ids) == 152
|
||||
assert len(scripts.command_ids) == 60
|
||||
assert len(first.command_ids) == 163
|
||||
bindings = [first.binding_for(command_id) for command_id in first.command_ids]
|
||||
assert len({binding.cdp_port for binding in bindings}) == 152
|
||||
assert len({binding.cdp_port for binding in bindings}) == 163
|
||||
assert all(22000 <= binding.cdp_port <= 22999 for binding in bindings)
|
||||
assert all(binding.cdp_url.startswith("http://127.0.0.1:") for binding in bindings)
|
||||
assert bindings == [second.binding_for(item.script_id) for item in bindings]
|
||||
@@ -178,6 +178,12 @@ def test_project_registry_covers_every_script_with_unique_stable_port() -> None:
|
||||
assert jd_ad_costs.cdp_port == 22144
|
||||
assert jd_ad_costs.account == "jd-shop"
|
||||
assert jd_ad_costs.required_cookie_domains == ("jd.com",)
|
||||
customer_service = first.binding_for("shop.jd_customer_service.collect")
|
||||
assert customer_service.cdp_port == 22154
|
||||
assert customer_service.account == "jd-shop"
|
||||
assert customer_service.required_cookie_domains == ("jd.com",)
|
||||
assert customer_service.required_cookie_names == ("pin", "thor")
|
||||
assert customer_service.profile_dir != jd_ad_costs.profile_dir
|
||||
|
||||
collaborator_bilibili = first.binding_for(
|
||||
"content_marketing:bilibili_scraper.py"
|
||||
@@ -397,6 +403,9 @@ def test_project_account_pilot_covers_douyin_shop_members() -> None:
|
||||
members = catalog.bindings_for_account("douyin-shop")
|
||||
assert {member.command_id for member in members} == {
|
||||
"shop.douyin_price_appeal",
|
||||
"shop.douyin_customer_service.collect",
|
||||
"shop.dy_negative_reviews.collect",
|
||||
"shop.douyin_after_sales_quality.collect",
|
||||
"product_commerce:dy_product_scraping.py",
|
||||
"product_commerce:dy_audience_profile_collect.py",
|
||||
"product_commerce:collect_dy_market_rank.py",
|
||||
@@ -448,6 +457,9 @@ def test_project_accounts_cover_all_configured_platform_login_identities() -> No
|
||||
"JD_SHOP",
|
||||
"JD_PASSWORD",
|
||||
)
|
||||
assert catalog.account_for("jd-shop").login_identity_markers == (
|
||||
"光影行星 GYXX官方旗舰店",
|
||||
)
|
||||
assert catalog.account_for("jd-self-operated").credential_env_names == (
|
||||
"JD_SELF_OPERATED_ACCOUNT",
|
||||
"JD_SELF_OPERATED_PASSWORD",
|
||||
|
||||
@@ -54,7 +54,7 @@ def test_python_scheduler_status_is_project_owned_and_cross_platform(tmp_path: P
|
||||
assert exit_code == 0
|
||||
payload = json.loads(output.getvalue())
|
||||
assert payload["timezone"] == "Asia/Shanghai"
|
||||
assert payload["scheduled_count"] == 29
|
||||
assert payload["scheduled_count"] == 35
|
||||
assert payload["state"]["schema_version"] == 1
|
||||
|
||||
|
||||
|
||||
@@ -84,14 +84,13 @@ def test_tick_launches_due_workflow_once_and_persists_slot(tmp_path: Path) -> No
|
||||
scheduler.tick(now)
|
||||
|
||||
assert {item[0] for item in launcher.launched} == {
|
||||
"content.summary.weekly",
|
||||
"product.persona.daily",
|
||||
"shop.douyin_price_appeal",
|
||||
"product.ecommerce_costs.daily",
|
||||
"product.tmall_baibu_apply",
|
||||
}
|
||||
assert set(started) == {item[0] for item in launcher.launched}
|
||||
state = json.loads((tmp_path / "state" / "scheduler" / "state.json").read_text(encoding="utf-8"))
|
||||
assert state["jobs"]["product.persona.daily"]["slot"].startswith("2026-07-28T10:00:00")
|
||||
assert state["jobs"]["shop.douyin_price_appeal"]["slot"].startswith("2026-07-28T10:00:00")
|
||||
|
||||
|
||||
def test_completed_process_is_recorded_and_not_relaunched(tmp_path: Path) -> None:
|
||||
@@ -100,14 +99,14 @@ def test_completed_process_is_recorded_and_not_relaunched(tmp_path: Path) -> Non
|
||||
scheduler = PythonScheduler(catalog, tmp_path, launcher=launcher, misfire_grace_seconds=600)
|
||||
now = datetime(2026, 7, 28, 23, 5, tzinfo=SHANGHAI)
|
||||
scheduler.tick(now)
|
||||
process = next(item[3] for item in launcher.launched if item[0] == "product.alert.daily")
|
||||
process = next(item[3] for item in launcher.launched if item[0] == "shop.douyin_price_appeal")
|
||||
process.return_code = 0
|
||||
|
||||
scheduler.tick(now)
|
||||
|
||||
state = scheduler.status()
|
||||
assert state["jobs"]["product.alert.daily"]["status"] == "success"
|
||||
assert sum(item[0] == "product.alert.daily" for item in launcher.launched) == 1
|
||||
assert state["jobs"]["shop.douyin_price_appeal"]["status"] == "success"
|
||||
assert sum(item[0] == "shop.douyin_price_appeal" for item in launcher.launched) == 1
|
||||
|
||||
|
||||
def test_multiple_daily_slots_launch_once_without_overlap(tmp_path: Path) -> None:
|
||||
@@ -207,13 +206,19 @@ def test_misfire_older_than_grace_is_not_launched(tmp_path: Path) -> None:
|
||||
def test_configured_schedules_disable_cookie_refresh_job() -> None:
|
||||
catalog = WorkflowCatalog.load(PROJECT_ROOT / "config")
|
||||
|
||||
assert len(catalog.schedules) == 29
|
||||
assert len(catalog.schedules) == 35
|
||||
assert [
|
||||
schedule.workflow_id for schedule in catalog.schedules if not schedule.enabled
|
||||
] == [
|
||||
"content.metrics.backfill",
|
||||
"content.marketing_report.daily",
|
||||
"content.relogin.weekly",
|
||||
"content.summary.weekly",
|
||||
"content.summary.monthly",
|
||||
"content.creator_report.monthly",
|
||||
"product.alert.daily",
|
||||
"product.style_analysis.interval",
|
||||
"supply.purchase_confirmation.daily",
|
||||
"supply.replenishment.weekly",
|
||||
]
|
||||
|
||||
@@ -223,7 +228,7 @@ def test_dry_run_reports_due_without_writing_state(tmp_path: Path) -> None:
|
||||
launcher = FakeLauncher()
|
||||
scheduler = PythonScheduler(catalog, tmp_path, launcher=launcher, misfire_grace_seconds=600, dry_run=True)
|
||||
due = scheduler.tick(datetime(2026, 7, 28, 10, 5, tzinfo=SHANGHAI))
|
||||
assert "product.persona.daily" in due
|
||||
assert "shop.douyin_price_appeal" in due
|
||||
assert launcher.launched == []
|
||||
assert not (tmp_path / "state" / "scheduler" / "state.json").exists()
|
||||
|
||||
|
||||
@@ -80,16 +80,21 @@ def test_default_registry_covers_every_executable_workflow_entry() -> None:
|
||||
for entry in entries:
|
||||
workflow_entries.add(f"{workflow['module']}:{entry}")
|
||||
|
||||
assert scheduled_workflows == 29
|
||||
assert scheduled_workflows == 35
|
||||
assert manual_workflows == 0
|
||||
assert len(workflow_entries) == 44
|
||||
assert len(catalog.scripts) == 49
|
||||
assert len(workflow_entries) == 50
|
||||
assert len(catalog.scripts) == 55
|
||||
assert workflow_entries < set(catalog.script_ids)
|
||||
assert {
|
||||
"content.mapping.rebuild",
|
||||
"content.failed.retry",
|
||||
"shop.douyin_price_appeal",
|
||||
"shop.jd_self_operated.collect_brand",
|
||||
"shop.jd_customer_service.collect",
|
||||
"shop.jd_negative_reviews.collect",
|
||||
"shop.jd_after_sales_issues.collect",
|
||||
"shop.douyin_customer_service.collect",
|
||||
"shop.douyin_after_sales_quality.collect",
|
||||
"product.backfill.run",
|
||||
"product.erp_all_shop_daily",
|
||||
"product.review.orchestrate",
|
||||
@@ -115,6 +120,21 @@ def test_default_registry_covers_every_executable_workflow_entry() -> None:
|
||||
"--price-appeal",
|
||||
"--execute",
|
||||
)
|
||||
assert catalog.get("shop.jd_customer_service.collect").default_args == (
|
||||
"--date",
|
||||
"{business_date}",
|
||||
"--headless",
|
||||
)
|
||||
assert catalog.get("shop.douyin_customer_service.collect").default_args == (
|
||||
"--date",
|
||||
"{business_date}",
|
||||
"--headless",
|
||||
)
|
||||
assert catalog.get("shop.douyin_after_sales_quality.collect").default_args == (
|
||||
"--date",
|
||||
"{business_date}",
|
||||
"--headless",
|
||||
)
|
||||
assert catalog.get("product.backfill.run").default_args == (
|
||||
"--from",
|
||||
"{business_date}",
|
||||
@@ -136,6 +156,21 @@ def test_default_registry_covers_every_executable_workflow_entry() -> None:
|
||||
"{business_date}",
|
||||
"--execute",
|
||||
)
|
||||
assert catalog.get("product.tmall_wanxiang_ads.collect").default_args == (
|
||||
"--date",
|
||||
"{business_date}",
|
||||
"--headless",
|
||||
)
|
||||
assert catalog.get("product.jd_ad_costs.collect").default_args == (
|
||||
"--date",
|
||||
"{business_date}",
|
||||
"--headless",
|
||||
)
|
||||
assert catalog.get("product.douyin_qianchuan_ads.collect").default_args == (
|
||||
"--date",
|
||||
"{business_date}",
|
||||
"--headless",
|
||||
)
|
||||
assert catalog.get("supply.workflow.run").default_args == (
|
||||
"mcp-run",
|
||||
"purchase-order-update",
|
||||
@@ -249,7 +284,7 @@ def test_cli_lists_and_dry_runs_registered_commands(tmp_path: Path) -> None:
|
||||
|
||||
assert main(["scripts", "list", "--json"], settings=settings, stdout=output) == 0
|
||||
rows = json.loads(output.getvalue())
|
||||
assert len(rows) == 49
|
||||
assert len(rows) == 55
|
||||
assert {row["module"] for row in rows} == {
|
||||
"content_marketing",
|
||||
"product_commerce",
|
||||
|
||||
@@ -47,7 +47,7 @@ def test_deployment_documents_one_scheduler_and_external_data_root() -> None:
|
||||
assert "GYXX_DATA_ROOT" in guide
|
||||
assert "/var/lib/gyxx-flow" in guide
|
||||
assert "Windows Task Scheduler" in guide
|
||||
assert len(payload["schedules"]) == 29
|
||||
assert len(payload["schedules"]) == 35
|
||||
assert sum(item.get("enabled", True) for item in payload["schedules"]) == 25
|
||||
|
||||
|
||||
|
||||
@@ -57,6 +57,6 @@ def test_every_workflow_and_manual_script_resolves_inside_new_project() -> None:
|
||||
assert target.is_relative_to(PROJECT_ROOT)
|
||||
|
||||
commands = ScriptCatalog.load_default()
|
||||
assert len(commands.scripts) == 41
|
||||
assert len(commands.scripts) == 55
|
||||
assert len(commands.command_ids) == len(set(commands.command_ids))
|
||||
assert all(command.path.is_relative_to(PROJECT_ROOT) for command in commands.scripts)
|
||||
@@ -113,6 +113,11 @@ def test_supply_card_sender_forces_only_wang_yunlong(
|
||||
"notification_already_sent",
|
||||
lambda *_args, **_kwargs: True,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
module.pg_writer,
|
||||
"is_available",
|
||||
lambda: True,
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError, match="snapshot mismatch"):
|
||||
module.run(
|
||||
|
||||
@@ -323,7 +323,7 @@ def test_postgres_store_omits_empty_dsn_for_split_connection_fields(
|
||||
"PG_PORT": "5432",
|
||||
"PG_DB": "app",
|
||||
"PG_USER": "app",
|
||||
"PG_PASSWORD": "test-only",
|
||||
"PG_PASSWORD": "secret",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
Reference in new issue
Block a user