chore: [workflow] 提交工作区代码
This commit is contained in:
1 parent
2ff588a65d
commit
01f3d7aaf7
131 files changed
+24306
-1320
No files matched your search
@@ -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
|
||||
|
||||
Reference in new issue
Block a user