feat: 执行并提交 PDD 采集任务 (#32)

This commit is contained in:
chengma
2026-08-07 17:38:29 +08:00
parent 34b4b54b63
commit 2d1fdbea3f
17 changed files with 2156 additions and 108 deletions
+164
View File
@@ -0,0 +1,164 @@
"""采集任务应用流程测试,不连接真机和网络。"""
import tempfile
import unittest
from pathlib import Path
from src.admin_gateway import AdminTask, ClaimCapabilities, ClientInfo
from src.collect_task_service import CollectTaskService
from src.mock_admin_gateway import MockAdminGateway
from src.pdd_collect_service import PddCollectError
from src.task_models import NewClaimedTask, TaskStatus, TaskType
from src.task_repository import TaskRepository
class FakeResult:
def to_pdd_data(self):
return {
"schema_version": 1,
"goods_id": "737116531267",
"title": "测试商品",
"shop_name": "测试店铺",
"price_granularity": "color",
"dimensions": [
{"key": "color", "name": "颜色分类"},
{"key": "size", "name": "尺码"},
],
"skus": [
{
"options": {"color": "黑色", "size": "M"},
"price_cent": 990,
"price_observed_at": {"color": "黑色", "size": "M"},
"available": True,
}
],
}
class FakeCollector:
def __init__(self, calls, error=None):
self.calls = calls
self.error = error
def collect(self, task):
self.calls.append(task.remote_task_id)
if self.error is not None:
raise self.error
return FakeResult()
class CollectTaskServiceTest(unittest.TestCase):
def setUp(self):
self.temporary = tempfile.TemporaryDirectory()
self.repository = TaskRepository(Path(self.temporary.name) / "client.db")
self.gateway = MockAdminGateway()
self.client = ClientInfo("CLIENT-001", "测试电脑")
self.task = AdminTask(
task_id="COL-001",
task_type=TaskType.COLLECT,
version=1,
priority=0,
payload={
"goods_id": "737116531267",
"goods_url": "https://mobile.yangkeduo.com/goods.html?goods_id=737116531267",
},
created_at="2026-08-07T08:00:00Z",
updated_at="2026-08-07T08:00:00Z",
)
self.gateway.enqueue_task(self.task, self.client.client_id)
def tearDown(self):
self.temporary.cleanup()
def _service(self, calls, error=None):
return CollectTaskService(
self.gateway,
self.repository,
self.client,
"USB-001",
collect_service_factory=lambda *_args: FakeCollector(calls, error),
)
def test_claim_collect_persist_submit_completes_one_task(self):
calls = []
outcome = self._service(calls).execute_one()
self.assertEqual(outcome.kind, "succeeded")
self.assertEqual(calls, ["COL-001"])
detail = self.repository.get_task("COL-001")
self.assertEqual(detail.status, TaskStatus.SUCCEEDED)
self.assertEqual(detail.pdd_data["price_granularity"], "color")
self.assertEqual(self.gateway.submission_count, 1)
def test_submit_timeout_retries_stored_outbox_without_recollecting(self):
remote = self.gateway.claim_next(
self.client,
ClaimCapabilities(supported_types=(TaskType.COLLECT,)),
)
self.repository.add_claimed_task(
NewClaimedTask(
remote_task_id=remote.task_id,
task_type=remote.task_type,
goods_url=remote.payload["goods_url"],
goods_id=remote.payload["goods_id"],
version=remote.version,
admin_payload={"payload": dict(remote.payload)},
)
)
calls = []
self.gateway.timeout_next_call()
first = self._service(calls).execute_one()
second = self._service(calls).execute_one()
self.assertEqual(first.kind, "result_pending")
self.assertEqual(second.kind, "succeeded")
self.assertEqual(calls, ["COL-001"])
self.assertEqual(self.gateway.submission_count, 1)
def test_pending_outbox_can_submit_without_android_device(self):
remote = self.gateway.claim_next(
self.client,
ClaimCapabilities(supported_types=(TaskType.COLLECT,)),
)
self.repository.add_claimed_task(
NewClaimedTask(
remote_task_id=remote.task_id,
task_type=remote.task_type,
goods_url=remote.payload["goods_url"],
goods_id=remote.payload["goods_id"],
version=remote.version,
)
)
started = self.repository.start_collect_run(remote.task_id, "USB-001")
self.repository.save_collect_result(
remote.task_id, started.attempt_id, FakeResult().to_pdd_data()
)
outcome = CollectTaskService(
self.gateway, self.repository, self.client, ""
).execute_one()
self.assertEqual(outcome.kind, "succeeded")
self.assertEqual(
self.repository.get_task(remote.task_id).status,
TaskStatus.SUCCEEDED,
)
def test_captcha_becomes_manual_review_and_is_reported(self):
calls = []
outcome = self._service(
calls, PddCollectError("PDD_PAGE_CAPTCHA", "需要验证")
).execute_one()
self.assertEqual(outcome.kind, "failed")
self.assertEqual(
self.repository.get_task("COL-001").status,
TaskStatus.MANUAL_REVIEW,
)
self.assertEqual(self.gateway.submission_count, 1)
if __name__ == "__main__":
unittest.main()
+66 -1
View File
@@ -104,7 +104,7 @@ class HttpAdminGatewayTest(unittest.TestCase):
},
)
)
gateway = HttpAdminGateway(opener=opener)
gateway = HttpAdminGateway(opener=opener, client_id="CLIENT-001")
gateway.register_client(
ClientInfo("CLIENT-001"), self._capabilities(False)
@@ -116,6 +116,71 @@ class HttpAdminGatewayTest(unittest.TestCase):
key.lower(): value for key, value in opener.request.header_items()
})
def test_submit_result_sends_idempotency_key_and_parses_receipt(self):
opener = RecordingOpener(
FakeResponse(
200,
{
"accepted": True,
"result_id": "RESULT-001",
"accepted_at": "2026-08-07T08:00:01Z",
},
)
)
gateway = HttpAdminGateway(opener=opener, client_id="CLIENT-001")
payload = {
"task_version": 1,
"attempt_id": "ATTEMPT-001",
"result_type": "collect",
"completed_at": "2026-08-07T08:00:00Z",
"pdd_data": {"goods_id": "123"},
}
receipt = gateway.submit_result(
"COL-001", "COL-001:ATTEMPT-001:result-v1", payload
)
self.assertTrue(receipt.accepted)
self.assertEqual(
opener.request.full_url,
"http://127.0.0.1:8080/api/v1/client/tasks/COL-001/result",
)
headers = {
key.lower(): value for key, value in opener.request.header_items()
}
self.assertEqual(
headers["idempotency-key"], "COL-001:ATTEMPT-001:result-v1"
)
self.assertEqual(headers["x-client-id"], "CLIENT-001")
self.assertEqual(json.loads(opener.request.data), payload)
def test_submit_failure_uses_failure_endpoint(self):
opener = RecordingOpener(
FakeResponse(
201,
{
"accepted": True,
"result_id": "FAILURE-001",
"accepted_at": "2026-08-07T08:00:01Z",
},
)
)
gateway = HttpAdminGateway(opener=opener, client_id="CLIENT-001")
gateway.submit_failure(
"COL-001",
"COL-001:ATTEMPT-001:failure-v1",
{
"task_version": 1,
"attempt_id": "ATTEMPT-001",
"status": "manual_review",
"error": {"code": "PDD_PAGE_CAPTCHA"},
"reported_at": "2026-08-07T08:00:00Z",
},
)
self.assertTrue(opener.request.full_url.endswith("/COL-001/failure"))
def test_claim_maps_real_admin_payload_and_only_reports_collect(self):
opener = RecordingOpener(
FakeResponse(
+78 -8
View File
@@ -62,6 +62,18 @@ class LoadingDevice(FakeCollectDevice):
return '<hierarchy><node text="正在加载" /></hierarchy>'
class DisconnectingDevice(FakeCollectDevice):
def __init__(self, home_xml, spec_xml):
super().__init__(home_xml, spec_xml)
self.current_calls = 0
def app_current(self):
self.current_calls += 1
if self.current_calls > 1:
raise RuntimeError("device not found")
return super().app_current()
def keep_only_one_sku(xml_data: str) -> str:
"""从脱敏固件中删除蓝色和 L,只保留一个组合。"""
@@ -114,6 +126,19 @@ class PddCollectParserTest(unittest.TestCase):
["M", "L"],
)
def test_truncated_spec_name_prefers_full_content_description(self):
xml_data = self.spec_xml.replace(
'content-desc="红色" clickable="true"',
'text="红…" content-desc="红色" clickable="true"',
)
result = parse_spec_panel(xml_data)
self.assertEqual(result.dimensions[0].values[0].text, "红色")
without_description = xml_data.replace(' content-desc="红色"', "")
with self.assertRaises(PddCollectError) as raised:
parse_spec_panel(without_description)
self.assertEqual(raised.exception.code, "PDD_DATA_SKU_NAME_TRUNCATED")
def test_login_and_invalid_xml_have_different_error_codes(self):
with self.assertRaises(PddCollectError) as login:
parse_goods_page('<hierarchy><node text="手机号登录" /></hierarchy>')
@@ -150,14 +175,46 @@ class PddCollectParserTest(unittest.TestCase):
data = result.to_pdd_data()
self.assertEqual(data["schema_version"], 1)
self.assertEqual(data["goods"]["goods_id"], "123")
self.assertEqual(data["goods_id"], "123")
self.assertEqual(data["title"], "测试纯棉短袖商品")
self.assertEqual(data["shop_name"], "测试服饰旗舰店")
self.assertEqual(data["price_granularity"], "color")
self.assertEqual(data["metrics"]["sales"]["value"], 12000)
self.assertEqual(data["dimensions"][0]["key"], "color")
self.assertEqual(data["skus"][0]["options"], {"color": "红色", "size": "M"})
self.assertEqual(data["skus"][0]["price_cent"], 1000)
self.assertEqual(
data["skus"][0]["price_observed_at"],
{"color": "红色", "size": "M"},
)
self.assertEqual(data["source"]["device_address"], "USB-001")
self.assertIsNone(data["purchase"])
def test_price_is_sampled_once_per_available_color(self):
device = FakeCollectDevice(self.home_xml, self.spec_xml)
service = PddCollectService(
PddDeviceService(lambda _serial: device),
"USB-001",
"client-001",
sleeper=lambda _seconds: None,
max_page_swipes=0,
max_spec_swipes=0,
)
data = service.collect(
FakeTask("https://mobile.yangkeduo.com/goods.html?goods_id=123")
).to_pdd_data()
self.assertEqual(len(data["skus"]), 4)
red_skus = [
item for item in data["skus"] if item["options"]["color"] == "红色"
]
self.assertEqual(
{item["price_observed_at"]["size"] for item in red_skus}, {"M"}
)
# 1 次打开规格面板,另有红色点 1 次;尺码只读不点击。
self.assertEqual(len(device.clicks), 2)
def test_missing_goods_id_is_structured_error(self):
service = PddCollectService(
PddDeviceService(lambda _serial: object()),
@@ -195,7 +252,7 @@ class PddCollectParserTest(unittest.TestCase):
def test_page_timeout_has_specific_error_code(self):
device = LoadingDevice(self.home_xml, self.spec_xml)
ticks = iter((0.0, 2.0))
ticks = iter((0.0, 0.0, 0.0, 2.0))
service = PddCollectService(
PddDeviceService(lambda _serial: device),
"USB-001",
@@ -211,7 +268,21 @@ class PddCollectParserTest(unittest.TestCase):
)
self.assertEqual(raised.exception.code, "PDD_PAGE_TIMEOUT")
def test_incomplete_goods_data_does_not_return_partial_success(self):
def test_runtime_device_disconnect_has_stable_error_code(self):
service = PddCollectService(
PddDeviceService(
lambda _serial: DisconnectingDevice(self.home_xml, self.spec_xml)
),
"USB-001",
"client-001",
)
with self.assertRaises(PddCollectError) as raised:
service.collect(
FakeTask("https://mobile.yangkeduo.com/goods.html?goods_id=123")
)
self.assertEqual(raised.exception.code, "DEVICE_DISCONNECTED")
def test_missing_shop_name_is_allowed(self):
incomplete_home = self.home_xml.replace(
'<node class="android.widget.TextView" text="店铺:测试服饰旗舰店" '
'bounds="[30,1050][600,1110]" visible-to-user="true" '
@@ -227,11 +298,10 @@ class PddCollectParserTest(unittest.TestCase):
max_page_swipes=0,
)
with self.assertRaises(PddCollectError) as raised:
service.collect(
FakeTask("https://mobile.yangkeduo.com/goods.html?goods_id=123")
)
self.assertEqual(raised.exception.code, "PDD_DATA_SHOP_MISSING")
result = service.collect(
FakeTask("https://mobile.yangkeduo.com/goods.html?goods_id=123")
)
self.assertIsNone(result.shop_name)
if __name__ == "__main__":
+35
View File
@@ -0,0 +1,35 @@
"""用真机导出的控件树验证真实页面结构。"""
from pathlib import Path
import unittest
from src.pdd_collect_service import parse_goods_page, parse_spec_panel
REAL_XML = Path(__file__).parents[1] / "image_xml"
class PddRealXmlFixtureTest(unittest.TestCase):
def test_split_title_is_joined_from_real_goods_page(self):
result = parse_goods_page(
(REAL_XML / "737116531267_home.xml").read_text(encoding="utf-8")
)
self.assertEqual(
result.title,
"【店铺热销】冰丝吊带裙女夏季薄款内搭连衣裙宽松a字裙中长款打底裙衬裙纯色",
)
def test_real_spec_panel_has_all_colors_and_two_prices(self):
result = parse_spec_panel(
(REAL_XML / "737116531267_size.xml").read_text(encoding="utf-8")
)
colors = next(item for item in result.dimensions if item.key == "color")
self.assertEqual(len(colors.values), 8)
self.assertIn("浅蓝色中长款", [item.text for item in colors.values])
self.assertEqual(result.price_cent, 470)
self.assertEqual(result.list_price_cent, 1990)
if __name__ == "__main__":
unittest.main()
+43 -2
View File
@@ -12,7 +12,7 @@ os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PyQt5.QtWidgets import QApplication
from src.pdd_ui import PDDTaskPage
from src.admin_gateway import AdminTask
from src.admin_gateway import AdminTask, SubmissionReceipt
from src.pdd_ui_event import (
PDDTaskPageEvent,
admin_task_to_new_claimed_task,
@@ -38,6 +38,12 @@ class BrokenSaveRepository(BrokenRepository):
def add_claimed_task(self, _task):
raise RuntimeError("disk is full")
def next_pending_outbox(self):
return None
def next_collect_task(self):
return None
class RecordingClaimGateway:
"""记录领取参数并返回预设结果。"""
@@ -52,6 +58,12 @@ class RecordingClaimGateway:
self.thread_ids.append(threading.get_ident())
return self.response
def submit_result(self, task_id, idempotency_key, result):
return SubmissionReceipt(True, "RESULT-001", "2026-08-07T08:00:00Z")
def submit_failure(self, task_id, idempotency_key, failure):
return SubmissionReceipt(True, "FAILURE-001", "2026-08-07T08:00:00Z")
class SlowClaimGateway(RecordingClaimGateway):
"""让关闭测试能稳定发生在 HTTP 返回之前。"""
@@ -66,6 +78,28 @@ class SlowClaimGateway(RecordingClaimGateway):
return super().claim_next(client, capabilities)
class FakeCollectResult:
def to_pdd_data(self):
return {
"schema_version": 1,
"goods_id": "737116531267",
"title": "测试商品",
"shop_name": "测试店铺",
"price_granularity": "color",
"dimensions": [],
"skus": [{"price_cent": 990}],
}
class FakeCollector:
def collect(self, _task):
return FakeCollectResult()
def fake_collect_factory(*_args):
return FakeCollector()
def collect_admin_task(task_id="COL-001"):
return AdminTask(
task_id=task_id,
@@ -246,6 +280,7 @@ class PDDTaskPageEventTest(unittest.TestCase):
self.repository,
claim_gateway=gateway,
settings_repository=self._saved_settings(),
collect_service_factory=fake_collect_factory,
)
page.autoFetchRequested.emit()
@@ -276,6 +311,7 @@ class PDDTaskPageEventTest(unittest.TestCase):
self.repository,
claim_gateway=gateway,
settings_repository=self._saved_settings(),
collect_service_factory=fake_collect_factory,
)
page.autoFetchRequested.emit()
@@ -295,6 +331,7 @@ class PDDTaskPageEventTest(unittest.TestCase):
self.repository,
claim_gateway=gateway,
settings_repository=self._saved_settings(),
collect_service_factory=fake_collect_factory,
)
page.autoFetchRequested.emit()
@@ -316,12 +353,14 @@ class PDDTaskPageEventTest(unittest.TestCase):
self.repository,
claim_gateway=gateway,
settings_repository=self._saved_settings(),
collect_service_factory=fake_collect_factory,
)
page.autoFetchRequested.emit()
self.assertTrue(wait_until(self.app, lambda: not events._claim_busy))
self.assertIn("本地已有", page.statusLabel.text())
self.assertIn("采集完成", page.statusLabel.text())
self.assertEqual(gateway.calls, [])
self.assertEqual(self.repository.count_tasks(), 1)
events.shutdown()
page.deleteLater()
@@ -334,6 +373,7 @@ class PDDTaskPageEventTest(unittest.TestCase):
BrokenSaveRepository(),
claim_gateway=gateway,
settings_repository=self._saved_settings(),
collect_service_factory=fake_collect_factory,
)
page.autoFetchRequested.emit()
@@ -371,6 +411,7 @@ class PDDTaskPageEventTest(unittest.TestCase):
self.repository,
claim_gateway=gateway,
settings_repository=self._saved_settings(),
collect_service_factory=fake_collect_factory,
)
page.autoFetchRequested.emit()
self.assertTrue(wait_until(self.app, gateway.started.is_set))
+47
View File
@@ -183,6 +183,53 @@ class TaskRepositoryTests(unittest.TestCase):
with self.assertRaisesRegex(ValueError, "offset"):
self.repository.list_tasks(offset=-1)
def test_result_and_outbox_are_saved_together_then_marked_succeeded(self):
self.repository.add_claimed_task(self._task("TASK-RUN"))
started = self.repository.start_collect_run("TASK-RUN", "USB-001")
event = self.repository.save_collect_result(
"TASK-RUN",
started.attempt_id,
{
"goods_id": "10001",
"title": "测试商品",
"price_granularity": "color",
"dimensions": [],
"skus": [{"price_cent": 990}],
},
)
self.assertEqual(
self.repository.get_task("TASK-RUN").status,
TaskStatus.RESULT_PENDING,
)
self.assertEqual(event.status, OutboxStatus.PENDING)
self.repository.mark_outbox_sent(event.id)
self.assertEqual(
self.repository.get_task("TASK-RUN").status,
TaskStatus.SUCCEEDED,
)
def test_recovery_restores_sending_and_interrupted_running(self):
self.repository.add_claimed_task(self._task("TASK-RECOVER"))
started = self.repository.start_collect_run("TASK-RECOVER", "USB-001")
self.repository.save_collect_failure(
"TASK-RECOVER",
started.attempt_id,
TaskStatus.RETRY_WAIT,
"DEVICE_OFFLINE",
"设备离线",
True,
)
event = self.repository.next_pending_outbox()
self.repository.mark_outbox_sending(event.id)
self.repository.recover_interrupted_work()
self.assertEqual(
self.repository.get_outbox_event(event.id).status,
OutboxStatus.PENDING,
)
if __name__ == "__main__":
unittest.main()