diff --git a/client/image_xml/737116531267_home.xml b/client/image_xml/737116531267_home.xml new file mode 100644 index 0000000..01d874e --- /dev/null +++ b/client/image_xml/737116531267_home.xml @@ -0,0 +1,423 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/image_xml/737116531267_size.xml b/client/image_xml/737116531267_size.xml new file mode 100644 index 0000000..6d16930 --- /dev/null +++ b/client/image_xml/737116531267_size.xml @@ -0,0 +1,348 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/src/collect_task_service.py b/client/src/collect_task_service.py new file mode 100644 index 0000000..6987de9 --- /dev/null +++ b/client/src/collect_task_service.py @@ -0,0 +1,229 @@ +"""一条采集任务的应用层流程:领取、采集、落库、提交。""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Callable, Optional + +from .admin_gateway import ( + AdminGateway, + AdminGatewayError, + AndroidDeviceInfo, + ClaimCapabilities, + ClientInfo, +) +from .pdd_collect_service import PddCollectError, PddCollectService +from .pdd_device_service import PddDeviceService +from .db import data_dir +from .task_models import OutboxEventRecord, OutboxEventType, TaskStatus, TaskType +from .task_models import NewClaimedTask +from .task_repository import DuplicateTaskError, TaskRepository + + +@dataclass(frozen=True) +class CollectTaskOutcome: + """工作线程返回给界面的简短结果。""" + + kind: str + message: str + task_id: str = "" + + +CollectServiceFactory = Callable[ + [str, str, Callable[[], bool]], PddCollectService +] + + +class CollectTaskService: + """一次调用只处理一条本地工作或一个待提交事件。""" + + def __init__( + self, + gateway: AdminGateway, + repository: TaskRepository, + client: ClientInfo, + device_address: str, + *, + cancelled: Callable[[], bool] = lambda: False, + collect_service_factory: Optional[CollectServiceFactory] = None, + ) -> None: + self._gateway = gateway + self._repository = repository + self._client = client + self._device_address = device_address + self._cancelled = cancelled + self._factory = collect_service_factory or self._default_factory + + def execute_one(self) -> CollectTaskOutcome: + """先补交已有结果,再执行最早的本地任务,最后才领取新任务。""" + + pending = self._repository.next_pending_outbox() + if pending is not None: + return self._submit(pending) + + if not self._device_address.strip(): + raise ValueError("请先在设置页选择并保存 Android 设备") + + task = self._repository.next_collect_task() + if task is None: + remote = self._gateway.claim_next( + self._client, + ClaimCapabilities( + device=AndroidDeviceInfo(self._device_address), + supported_types=(TaskType.COLLECT,), + purchase_mode="dry_run", + schema_versions=(1,), + ), + ) + if remote is None: + return CollectTaskOutcome("no_task", "暂无可领取的采集任务") + try: + self._repository.add_claimed_task( + NewClaimedTask( + remote_task_id=remote.task_id, + task_type=remote.task_type, + goods_url=str(remote.payload.get("goods_url") or ""), + goods_id=( + str(remote.payload["goods_id"]) + if remote.payload.get("goods_id") is not None + else None + ), + priority=remote.priority, + version=remote.version, + admin_payload={ + "id": remote.task_id, + "type": remote.task_type.value, + "version": remote.version, + "priority": remote.priority, + "payload": dict(remote.payload), + "created_at": remote.created_at, + "updated_at": remote.updated_at, + }, + ) + ) + except DuplicateTaskError: + pass + except Exception as exc: + raise RuntimeError( + f"任务 {remote.task_id} 已领取,但本地保存失败:{exc}" + ) from exc + task = self._repository.get_task(remote.task_id) + if task is None: + raise RuntimeError(f"任务 {remote.task_id} 未能保存到本地") + + if self._cancelled(): + return CollectTaskOutcome("cancelled", "本次采集已取消", task.remote_task_id) + + started = self._repository.start_collect_run( + task.remote_task_id, self._device_address + ) + collector = self._factory( + self._device_address, self._client.client_id, self._cancelled + ) + try: + result = collector.collect(started.task) + event = self._repository.save_collect_result( + task.remote_task_id, started.attempt_id, result.to_pdd_data() + ) + except PddCollectError as exc: + status, retryable = self._classify_error(exc.code) + report_code = self._report_error_code(exc.code) + event = self._repository.save_collect_failure( + task.remote_task_id, + started.attempt_id, + status, + report_code, + exc.message, + retryable, + exc.diagnostics, + ) + return self._submit(event) + + def _submit(self, event: OutboxEventRecord) -> CollectTaskOutcome: + task_id = self._repository.outbox_task_id(event.id) + self._repository.mark_outbox_sending(event.id) + try: + if event.event_type is OutboxEventType.TASK_FAILURE: + receipt = self._gateway.submit_failure( + task_id, event.idempotency_key, event.payload_json + ) + else: + receipt = self._gateway.submit_result( + task_id, event.idempotency_key, event.payload_json + ) + if not receipt.accepted: + raise AdminGatewayError( + "ADMIN_RESULT_NOT_ACCEPTED", "Admin 未确认接收结果", False + ) + except AdminGatewayError as exc: + message = str(exc) + if exc.retryable: + self._repository.mark_outbox_retry(event.id, message) + return CollectTaskOutcome( + "result_pending", + f"任务 {task_id} 数据已保存在本地,等待重新提交 Admin:{message}", + task_id, + ) + self._repository.mark_outbox_failed(event.id, message) + return CollectTaskOutcome( + "manual_review", + f"任务 {task_id} 提交被 Admin 拒绝:{message}", + task_id, + ) + + self._repository.mark_outbox_sent(event.id) + if event.event_type is OutboxEventType.TASK_FAILURE: + return CollectTaskOutcome( + "failed", + f"任务 {task_id} 采集未完成,失败信息已提交 Admin", + task_id, + ) + return CollectTaskOutcome( + "succeeded", f"任务 {task_id} 采集完成并已提交 Admin", task_id + ) + + @staticmethod + def _classify_error(code: str) -> tuple[TaskStatus, bool]: + if code == "PDD_CANCELLED": + return TaskStatus.CANCELLED, False + if code in { + "PDD_PAGE_LOGIN_REQUIRED", + "PDD_PAGE_CAPTCHA", + "PDD_DATA_SPEC_INCOMPLETE", + "PDD_DATA_TITLE_MISSING", + "PDD_DATA_PRICE_MISSING", + "PDD_DATA_SKU_NAME_TRUNCATED", + }: + return TaskStatus.MANUAL_REVIEW, False + if code.startswith("PDD_DATA_GOODS_"): + return TaskStatus.FAILED, False + return TaskStatus.RETRY_WAIT, True + + @staticmethod + def _report_error_code(code: str) -> str: + """把内部解析代码映射为 Admin 可枚举的稳定代码。""" + + return { + "PDD_DATA_SKU_NAME_TRUNCATED": "SKU_NAME_TRUNCATED", + "PDD_PAGE_SPEC_ENTRY_MISSING": "SKU_PANEL_NOT_FOUND", + "PDD_DATA_TITLE_MISSING": "TITLE_TOO_SHORT", + "PDD_PAGE_OVERALL_TIMEOUT": "COLLECT_TIMEOUT", + "DEVICE_OFFLINE": "DEVICE_DISCONNECTED", + "DEVICE_CONNECT_FAILED": "DEVICE_DISCONNECTED", + "DEVICE_DISCONNECTED": "DEVICE_DISCONNECTED", + }.get(code, code) + + @staticmethod + def _default_factory( + device_address: str, + client_id: str, + cancelled: Callable[[], bool], + ) -> PddCollectService: + return PddCollectService( + PddDeviceService(), + device_address, + client_id, + cancelled=cancelled, + overall_timeout=600.0, + artifact_directory=data_dir() / "artifacts", + ) diff --git a/client/src/http_admin_gateway.py b/client/src/http_admin_gateway.py index 3ba01e4..122d502 100644 --- a/client/src/http_admin_gateway.py +++ b/client/src/http_admin_gateway.py @@ -3,20 +3,20 @@ import json import socket from http.client import RemoteDisconnected -from typing import Callable, Mapping, Optional +from typing import Any, Callable, Mapping, Optional from urllib.error import HTTPError, URLError from urllib.parse import urlparse from urllib.request import ProxyHandler, Request, build_opener from uuid import uuid4 from .admin_gateway import ( + AdminGateway, AdminTask, AdminGatewayError, ClaimCapabilities, ClientInfo, - ClientRegistrationGateway, RegistrationReceipt, - TaskClaimGateway, + SubmissionReceipt, ) from .task_models import TaskType @@ -24,8 +24,8 @@ from .task_models import TaskType DEFAULT_ADMIN_BASE_URL = "http://127.0.0.1:8080" -class HttpAdminGateway(ClientRegistrationGateway, TaskClaimGateway): - """通过 HTTP 登记 Client 和领取采集任务;令牌只保存在内存。""" +class HttpAdminGateway(AdminGateway): + """通过 HTTP 登记、领取和提交任务;令牌只保存在内存。""" def __init__( self, @@ -33,6 +33,7 @@ class HttpAdminGateway(ClientRegistrationGateway, TaskClaimGateway): token: str = "", timeout_seconds: float = 3.0, opener: Optional[Callable] = None, + client_id: str = "", ): normalized_url = base_url.strip().rstrip("/") parsed = urlparse(normalized_url) @@ -44,6 +45,7 @@ class HttpAdminGateway(ClientRegistrationGateway, TaskClaimGateway): self._base_url = normalized_url self._token = token.strip() self._timeout_seconds = timeout_seconds + self._client_id = client_id.strip() # Admin 通常运行在本机或局域网。明确禁用环境代理,避免 # HTTP_PROXY 把 127.0.0.1 请求错误转发到代理服务器。 self._opener = opener or build_opener(ProxyHandler({})).open @@ -54,6 +56,7 @@ class HttpAdminGateway(ClientRegistrationGateway, TaskClaimGateway): """调用独立登记接口,不领取或修改任务。""" request_id = str(uuid4()) + self._client_id = client.client_id.strip() payload = { "client": {"name": client.name.strip()}, "supported_types": [ @@ -146,6 +149,7 @@ class HttpAdminGateway(ClientRegistrationGateway, TaskClaimGateway): """领取一个采集任务;Admin 返回 204 时返回 ``None``。""" request_id = str(uuid4()) + self._client_id = client.client_id.strip() payload = { "client": {"name": client.name.strip()}, # #30 只允许领取采集任务。采购能力必须由安全门禁工单开启。 @@ -219,6 +223,104 @@ class HttpAdminGateway(ClientRegistrationGateway, TaskClaimGateway): ) return self._parse_claim_response(body, request_id) + def submit_result( + self, + task_id: str, + idempotency_key: str, + result: Mapping[str, Any], + ) -> SubmissionReceipt: + """幂等提交成功结果。""" + + return self._submit(task_id, idempotency_key, result, "result") + + def submit_failure( + self, + task_id: str, + idempotency_key: str, + failure: Mapping[str, Any], + ) -> SubmissionReceipt: + """幂等提交失败或人工处理结果。""" + + return self._submit(task_id, idempotency_key, failure, "failure") + + def _submit( + self, + task_id: str, + idempotency_key: str, + payload: Mapping[str, Any], + endpoint: str, + ) -> SubmissionReceipt: + if not task_id.strip(): + raise ValueError("task_id 不能为空") + if not idempotency_key.strip(): + raise ValueError("Idempotency-Key 不能为空") + if not self._client_id: + raise AdminGatewayError( + "CLIENT_ID_MISSING", + "提交结果前必须先配置 Client 设备号", + False, + ) + request_id = str(uuid4()) + headers = { + "Content-Type": "application/json; charset=utf-8", + "Accept": "application/json", + "Idempotency-Key": idempotency_key.strip(), + "X-Request-Id": request_id, + "X-Client-Id": self._client_id, + } + if self._token: + headers["Authorization"] = f"Bearer {self._token}" + request = Request( + f"{self._base_url}/api/v1/client/tasks/{task_id.strip()}/{endpoint}", + data=json.dumps(payload, ensure_ascii=False).encode("utf-8"), + headers=headers, + method="POST", + ) + try: + with self._opener(request, timeout=self._timeout_seconds) as response: + status = getattr(response, "status", None) + if status is None: + status = response.getcode() + body = response.read() + except HTTPError as exc: + self._raise_http_error(exc, request_id, "提交任务") + except ( + URLError, + RemoteDisconnected, + ConnectionError, + socket.timeout, + TimeoutError, + ) as exc: + reason = getattr(exc, "reason", exc) + code = "ADMIN_TIMEOUT" if isinstance(reason, (socket.timeout, TimeoutError)) else "ADMIN_UNAVAILABLE" + message = "Admin 提交请求超时,请稍后重试" if code == "ADMIN_TIMEOUT" else "无法连接 Admin,结果已保存在本地" + raise AdminGatewayError(code, message, True, request_id) from exc + if status not in (200, 201): + raise AdminGatewayError( + "ADMIN_UNEXPECTED_RESPONSE", + f"Admin 返回了未预期的状态码 {status}", + status >= 500, + request_id, + ) + data = self._decode_json(body, request_id) + accepted = data.get("accepted") + result_id = data.get("result_id") + accepted_at = data.get("accepted_at") + if ( + accepted is not True + or not isinstance(result_id, str) + or not result_id.strip() + or not isinstance(accepted_at, str) + or not accepted_at.strip() + ): + raise AdminGatewayError( + "ADMIN_INVALID_RESPONSE", + "Admin 提交响应字段不完整", + False, + request_id, + ) + return SubmissionReceipt(True, result_id, accepted_at) + @classmethod def _parse_claim_response( cls, diff --git a/client/src/pdd_collect_service.py b/client/src/pdd_collect_service.py index 3123e4e..35ffecd 100644 --- a/client/src/pdd_collect_service.py +++ b/client/src/pdd_collect_service.py @@ -14,6 +14,7 @@ import xml.etree.ElementTree as ET from dataclasses import dataclass from datetime import datetime, timezone from decimal import Decimal, InvalidOperation, ROUND_HALF_UP +from pathlib import Path from typing import Any, Callable, Iterable, Mapping, Optional, Sequence from urllib.parse import parse_qs, urlparse @@ -31,13 +32,32 @@ _CAPTCHA_MARKERS = ("请完成验证", "拖动滑块", "安全验证", "点击 _READY_MARKERS = ("发起拼单", "立即购买", "单独购买", "免拼购买", "快要抢光") +def _is_device_disconnect(error: BaseException) -> bool: + details = str(error).lower() + return any( + marker in details + for marker in ( + "device not found", + "device offline", + "disconnected", + "closed transport", + ) + ) + + class PddCollectError(RuntimeError): """采集失败,并携带稳定错误码。""" - def __init__(self, code: str, message: str) -> None: + def __init__( + self, + code: str, + message: str, + diagnostics: Optional[Mapping[str, Any]] = None, + ) -> None: super().__init__(message) self.code = code self.message = message + self.diagnostics = dict(diagnostics or {}) @dataclass(frozen=True) @@ -85,11 +105,15 @@ class SkuResult: price_cent: Optional[int] available: bool raw_price: Optional[str] + price_observed_at: Mapping[str, str] + list_price_cent: Optional[int] = None def to_dict(self) -> dict[str, Any]: return { "options": dict(self.options), "price_cent": self.price_cent, + "list_price_cent": self.list_price_cent, + "price_observed_at": dict(self.price_observed_at), "currency": "CNY", "available": self.available, "raw_price": self.raw_price, @@ -110,6 +134,7 @@ class SpecSnapshot: selected_text: Optional[str] price_cent: Optional[int] raw_price: Optional[str] + list_price_cent: Optional[int] @dataclass(frozen=True) @@ -127,16 +152,16 @@ class CollectResult: captured_at: str client_id: str device_address: str + artifacts: tuple[Mapping[str, Any], ...] = () def to_pdd_data(self) -> dict[str, Any]: return { "schema_version": 1, - "goods": { - "goods_id": self.goods_id, - "url": self.goods_url, - "title": self.title, - }, - "shop": {"name": self.shop_name}, + "goods_id": self.goods_id, + "goods_url": self.goods_url, + "title": self.title, + "shop_name": self.shop_name, + "price_granularity": "color", "metrics": { "sales": self.sales.to_dict(), "reviews": self.reviews.to_dict(), @@ -150,7 +175,7 @@ class CollectResult: "device_address": self.device_address, "pdd_package": PDD_PACKAGE_NAME, }, - "artifacts": [], + "artifacts": [dict(item) for item in self.artifacts], } @@ -182,6 +207,14 @@ def _node_label(node: ET.Element) -> str: ) +def _preferred_node_label(node: ET.Element) -> str: + """规格名被 text 截断时,优先采用完整的无障碍描述。""" + + text = (node.get("text") or "").strip() + description = (node.get("content-desc") or "").strip() + return description or text + + def _own_or_descendant_label(node: ET.Element) -> str: own = _node_label(node) if own: @@ -193,6 +226,17 @@ def _own_or_descendant_label(node: ET.Element) -> str: return "" +def _preferred_or_descendant_label(node: ET.Element) -> str: + own = _preferred_node_label(node) + if own: + return own + for child in node.iter("node"): + label = _preferred_node_label(child) + if label and label != "打开大图": + return label + return "" + + def _all_labels(root: ET.Element) -> list[str]: return [label for node in root.iter("node") if (label := _node_label(node))] @@ -250,9 +294,21 @@ def parse_goods_page(xml_data: str | bytes) -> GoodsSnapshot: title = candidate break if title is None: + line_parts: dict[int, list[tuple[int, str]]] = {} + for node in root.iter("node"): + label = _preferred_node_label(node) + bounds = _parse_bounds(node.get("bounds", "")) + if not label or bounds is None: + continue + line_key = bounds[1] // 24 + line_parts.setdefault(line_key, []).append((bounds[0], label)) + joined_lines = [ + "".join(text for _, text in sorted(parts)) + for parts in line_parts.values() + ] candidates = [ label - for label in labels + for label in labels + joined_lines if len(label) >= 12 and not any(word in label for word in ("通知", "支付", "已拼", "评价")) ] @@ -347,7 +403,7 @@ def _top_level_clickable_options( def _price_from_nodes( nodes: Iterable[ET.Element], spec_top: int, -) -> tuple[Optional[int], Optional[str]]: +) -> tuple[Optional[int], Optional[str], Optional[int]]: candidates: list[tuple[int, int, str]] = [] for node in nodes: label = _node_label(node) @@ -366,9 +422,12 @@ def _price_from_nodes( continue candidates.append((bounds[1], cents, match.group(0).replace(" ", ""))) if not candidates: - return None, None - _, cents, raw = min(candidates, key=lambda item: (item[0], item[1])) - return cents, raw + return None, None, None + first_top = min(item[0] for item in candidates) + first_row = [item for item in candidates if abs(item[0] - first_top) <= 12] + _, cents, raw = min(first_row, key=lambda item: item[1]) + list_prices = [item[1] for item in first_row if item[1] > cents] + return cents, raw, max(list_prices, default=None) def parse_spec_panel(xml_data: str | bytes) -> SpecSnapshot: @@ -446,7 +505,12 @@ def parse_spec_panel(xml_data: str | bytes) -> SpecSnapshot: values: list[DimensionValue] = [] seen: set[str] = set() for node in option_nodes: - text = _own_or_descendant_label(node).strip() + text = _preferred_or_descendant_label(node).strip() + if text.endswith(("…", "...")): + raise PddCollectError( + "PDD_DATA_SKU_NAME_TRUNCATED", + f"规格名称被截断,无法安全采集:{text}", + ) if not text or text in seen: continue seen.add(text) @@ -458,8 +522,12 @@ def parse_spec_panel(xml_data: str | bytes) -> SpecSnapshot: dimensions.append(SpecDimension(key, name, tuple(values))) selected_text = next((label for label in labels if label.startswith("已选")), None) - price_cent, raw_price = _price_from_nodes(root.iter("node"), outer_bounds[1]) - return SpecSnapshot(tuple(dimensions), selected_text, price_cent, raw_price) + price_cent, raw_price, list_price_cent = _price_from_nodes( + root.iter("node"), outer_bounds[1] + ) + return SpecSnapshot( + tuple(dimensions), selected_text, price_cent, raw_price, list_price_cent + ) def _ancestors( @@ -561,9 +629,11 @@ class PddCollectService: now: Callable[[], datetime] = lambda: datetime.now(timezone.utc), cancelled: Callable[[], bool] = lambda: False, page_timeout: float = 30.0, + overall_timeout: float = 600.0, max_page_swipes: int = 12, max_spec_swipes: int = 12, max_sku_count: int = 200, + artifact_directory: Optional[Path] = None, ) -> None: self._device_service = device_service self._device_address = device_address @@ -573,9 +643,15 @@ class PddCollectService: self._now = now self._cancelled = cancelled self._page_timeout = page_timeout + self._overall_timeout = overall_timeout + self._overall_deadline: Optional[float] = None self._max_page_swipes = max_page_swipes self._max_spec_swipes = max_spec_swipes self._max_sku_count = max_sku_count + self._artifact_directory = artifact_directory + self._last_goods_xml: Optional[str] = None + self._goods_screens_checked = 0 + self._artifacts: list[Mapping[str, Any]] = [] def collect(self, task: Any) -> CollectResult: """执行采集;``task`` 至少提供 ``goods_url`` 和可选 ``goods_id``。""" @@ -585,6 +661,7 @@ class PddCollectService: raise PddCollectError("PDD_DATA_GOODS_URL_MISSING", "采集任务缺少商品链接") goods_id = str(getattr(task, "goods_id", "") or "").strip() goods_id = _validate_goods_url(goods_url, goods_id) + self._overall_deadline = self._monotonic() + self._overall_timeout self._check_cancelled() try: @@ -593,12 +670,14 @@ class PddCollectService: goods = self._collect_goods_details(device) if not goods.title: raise PddCollectError("PDD_DATA_TITLE_MISSING", "商品页没有可识别的标题") - if not goods.shop_name: - raise PddCollectError("PDD_DATA_SHOP_MISSING", "商品页没有采集到店铺名称") if not goods.sales.raw: raise PddCollectError("PDD_DATA_SALES_MISSING", "商品页没有采集到已拼数量") if not goods.reviews.raw: raise PddCollectError("PDD_DATA_REVIEWS_MISSING", "商品页没有采集到评价数量") + if not goods.shop_name and self._last_goods_xml: + artifact = self._save_xml("shop-not-found", self._last_goods_xml) + if artifact: + self._artifacts.append(artifact) home_xml = device.dump_hierarchy() coordinate = get_size_panel_coord(home_xml) @@ -623,11 +702,23 @@ class PddCollectService: ) if not skus or not has_available_price: raise PddCollectError("PDD_DATA_PRICE_MISSING", "没有采集到可用 SKU 的价格") - except PddCollectError: + except PddCollectError as exc: + if not exc.diagnostics and self._last_goods_xml: + artifact = self._save_xml("collect-failed", self._last_goods_xml) + if artifact: + exc.diagnostics = { + "artifacts": [artifact], + "goods_screens_checked": self._goods_screens_checked, + } raise except PddDeviceError as exc: raise PddCollectError(exc.code, exc.message) from exc except Exception as exc: + if _is_device_disconnect(exc): + raise PddCollectError( + "DEVICE_DISCONNECTED", + f"Android 设备在采集过程中断开:{exc}", + ) from exc raise PddCollectError("PDD_PAGE_UNKNOWN", f"PDD 采集过程中发生未知错误:{exc}") from exc return CollectResult( @@ -642,11 +733,17 @@ class PddCollectService: captured_at=self._now().astimezone(timezone.utc).isoformat().replace("+00:00", "Z"), client_id=self._client_id, device_address=self._device_address, + artifacts=tuple(self._artifacts), ) def _check_cancelled(self) -> None: if self._cancelled(): raise PddCollectError("PDD_CANCELLED", "采集任务已安全取消") + if ( + self._overall_deadline is not None + and self._monotonic() >= self._overall_deadline + ): + raise PddCollectError("PDD_PAGE_OVERALL_TIMEOUT", "PDD 采集超过 10 分钟") def _open_goods(self, device: Any, goods_url: str) -> None: try: @@ -659,6 +756,11 @@ class PddCollectService: except PddCollectError: raise except Exception as exc: + if _is_device_disconnect(exc): + raise PddCollectError( + "DEVICE_DISCONNECTED", + f"Android 设备在打开商品页时断开:{exc}", + ) from exc raise PddCollectError("DEVICE_APP_START_FAILED", f"无法打开 PDD 商品链接:{exc}") from exc deadline = self._monotonic() + self._page_timeout @@ -686,13 +788,14 @@ class PddCollectService: for _ in range(self._max_page_swipes + 1): self._check_cancelled() xml_data = device.dump_hierarchy() + self._last_goods_xml = str(xml_data) + self._goods_screens_checked += 1 root = _parse_xml(xml_data) labels = tuple(_all_labels(root)) snapshots.append(parse_goods_page(xml_data)) combined = _combine_goods_snapshots(snapshots) is_complete = ( combined.title - and combined.shop_name and combined.sales.raw and combined.reviews.raw ) @@ -720,6 +823,27 @@ class PddCollectService: self._sleep(0.35) return _combine_goods_snapshots(snapshots) + def _save_xml(self, label: str, xml_data: str) -> Optional[Mapping[str, Any]]: + """保存本地诊断 XML;测试未提供目录时不写文件。""" + + if self._artifact_directory is None: + return None + try: + digest = hashlib.sha256(xml_data.encode("utf-8")).hexdigest() + directory = self._artifact_directory / self._client_id + directory.mkdir(parents=True, exist_ok=True) + path = directory / f"{label}-{digest[:12]}.xml" + if not path.exists(): + path.write_text(xml_data, encoding="utf-8") + return { + "kind": "accessibility_xml", + "path": str(path.resolve()), + "sha256": digest, + "screens_checked": self._goods_screens_checked, + } + except OSError: + return None + def _discover_dimensions(self, device: Any) -> list[SpecSnapshot]: snapshots: list[SpecSnapshot] = [] @@ -768,28 +892,66 @@ class PddCollectService: f"规格组合共 {len(combinations)} 个,超过安全上限 {self._max_sku_count}", ) - results: list[SkuResult] = [] - for values in combinations: + color_index = next( + (index for index, item in enumerate(dimensions) if item.key == "color"), + None, + ) + if color_index is None: + raise PddCollectError( + "PDD_DATA_SPEC_INCOMPLETE", "规格面板没有可识别的颜色分类" + ) + + samples: dict[ + str, + tuple[Optional[int], Optional[str], Optional[int], dict[str, str]], + ] = {} + color_dimension = dimensions[color_index] + for color in color_dimension.values: self._check_cancelled() - options = {dimension.key: value.text for dimension, value in zip(dimensions, values)} - pairs = list(zip(dimensions, values)) - selected = self._select_combination(device, pairs) - if not selected and len(pairs) > 1: - # 某个选项可能只是在当前搭配下禁用。反向选择一次,可以先改变 - # 依赖维度,再重新判断目标组合是否真的缺货。 - selected = self._select_combination(device, list(reversed(pairs))) + if not color.available: + samples[color.text] = (None, None, None, {"color": color.text}) + continue + selected = self._select_option(device, color.text, color_dimension.key) if not selected: - results.append(SkuResult(options, None, False, None)) + samples[color.text] = (None, None, None, {"color": color.text}) continue snapshot = parse_spec_panel(device.dump_hierarchy()) summary = snapshot.selected_text or "" - confirmed = all(value.text in summary for value in values) + observed: dict[str, str] = {} + for dimension in dimensions: + match = next( + (value.text for value in dimension.values if value.text in summary), + None, + ) + if match: + observed[dimension.key] = match + confirmed = ( + observed.get(color_dimension.key) == color.text + and len(observed) == len(dimensions) + ) + samples[color.text] = ( + snapshot.price_cent if confirmed else None, + snapshot.raw_price if confirmed else None, + snapshot.list_price_cent if confirmed else None, + observed, + ) + + results: list[SkuResult] = [] + for values in combinations: + options = { + dimension.key: value.text + for dimension, value in zip(dimensions, values) + } + price, raw_price, list_price, observed = samples[values[color_index].text] + available = all(value.available for value in values) and price is not None results.append( SkuResult( options, - snapshot.price_cent if confirmed else None, - confirmed and snapshot.price_cent is not None, - snapshot.raw_price if confirmed else None, + price if available else None, + available, + raw_price if available else None, + observed, + list_price if available else None, ) ) return tuple(results) @@ -864,7 +1026,7 @@ class PddCollectService: ] screen_right = max(right_edges, default=0) for node in root.iter("node"): - if _own_or_descendant_label(node).strip() != target: + if _preferred_or_descendant_label(node).strip() != target: continue bounds = _parse_bounds(node.get("bounds", "")) if node.get("clickable") != "true" or bounds is None: diff --git a/client/src/pdd_ui_event.py b/client/src/pdd_ui_event.py index 1c31606..1899658 100644 --- a/client/src/pdd_ui_event.py +++ b/client/src/pdd_ui_event.py @@ -13,7 +13,7 @@ - 窗口关闭时要断开信号并置标志位,否则迟到的后台结果会访问 已经销毁的控件、直接崩溃。做法见同文档 §5.2。 - 数据库读写走 Repository,**不要在这里拼业务 SQL**。 -- “获取任务”会真的去操作手机、可能下单;“搜索”只读本地数据库。 +- “获取任务”会真的去操作手机采集商品;“搜索”只读本地数据库。 两者必须分开,不得共用入口。 - 普通成功不弹窗,更新界面即可;可恢复错误用 `InfoBar` (模板见 `docs/client/05-ui-specification.md` §9.1); @@ -28,11 +28,10 @@ from qfluentwidgets import InfoBar, InfoBarPosition from .admin_gateway import ( AdminGatewayError, AdminTask, - AndroidDeviceInfo, - ClaimCapabilities, ClientInfo, - TaskClaimGateway, + AdminGateway, ) +from .collect_task_service import CollectServiceFactory, CollectTaskService from .current_client_service import CurrentClientService from .http_admin_gateway import DEFAULT_ADMIN_BASE_URL, HttpAdminGateway from .pdd_ui import PDDTaskPage, TaskRow @@ -45,7 +44,7 @@ from .task_models import ( TaskSummary, TaskType, ) -from .task_repository import DuplicateTaskError, TaskRepository +from .task_repository import TaskRepository TASK_TYPE_BY_TEXT = { @@ -116,21 +115,23 @@ def admin_task_to_new_claimed_task(task: AdminTask) -> NewClaimedTask: class ClaimTaskWorker(QObject): - """在后台领取至多一个采集任务,并先写入本地数据库。""" + """在后台补交或执行至多一条采集任务。""" noTask = pyqtSignal() taskSaved = pyqtSignal(str) duplicateTask = pyqtSignal(str) localSaveFailed = pyqtSignal(str, str) failed = pyqtSignal(str) + outcome = pyqtSignal(str, str, str) completed = pyqtSignal() def __init__( self, - gateway: TaskClaimGateway, + gateway: AdminGateway, task_repository: TaskRepository, client_service: CurrentClientService, android_device_service: SelectedAndroidDeviceService, + collect_service_factory: Optional[CollectServiceFactory] = None, ) -> None: super().__init__() self._gateway = gateway @@ -138,6 +139,7 @@ class ClaimTaskWorker(QObject): self._client_service = client_service self._android_device_service = android_device_service self._cancelled = False + self._collect_service_factory = collect_service_factory def cancel(self) -> None: """阻止尚未开始的领取;已领取的任务仍必须保存到本地。""" @@ -146,7 +148,6 @@ class ClaimTaskWorker(QObject): @pyqtSlot() def run(self) -> None: - claimed_task: Optional[AdminTask] = None try: if self._cancelled: return @@ -155,42 +156,20 @@ class ClaimTaskWorker(QObject): self.failed.emit("请先在设置页保存当前设备号和设备名") return android_serial = self._android_device_service.load() - if not android_serial: - self.failed.emit("请先在设置页选择并保存 Android 设备") - return - capabilities = ClaimCapabilities( - device=AndroidDeviceInfo(android_serial), - supported_types=(TaskType.COLLECT,), - purchase_mode="dry_run", - schema_versions=(1,), - ) - claimed_task = self._gateway.claim_next( + result = CollectTaskService( + self._gateway, + self._task_repository, ClientInfo( client_settings.client_id, client_settings.client_name, ), - capabilities, - ) - if claimed_task is None: - if not self._cancelled: - self.noTask.emit() - return - - local_task = admin_task_to_new_claimed_task(claimed_task) - try: - self._task_repository.add_claimed_task(local_task) - except DuplicateTaskError: - if not self._cancelled: - self.duplicateTask.emit(claimed_task.task_id) - return - except Exception as exc: - if not self._cancelled: - self.localSaveFailed.emit(claimed_task.task_id, str(exc)) - return - - if not self._cancelled: - self.taskSaved.emit(claimed_task.task_id) + android_serial or "", + cancelled=lambda: self._cancelled, + collect_service_factory=self._collect_service_factory, + ).execute_one() + if not self._cancelled or result.kind == "cancelled": + self.outcome.emit(result.kind, result.message, result.task_id) except AdminGatewayError as exc: if not self._cancelled: request_hint = ( @@ -199,10 +178,7 @@ class ClaimTaskWorker(QObject): self.failed.emit(f"{exc}{request_hint}") except Exception as exc: if not self._cancelled: - if claimed_task is not None: - self.localSaveFailed.emit(claimed_task.task_id, str(exc)) - else: - self.failed.emit(f"领取任务失败:{exc}") + self.failed.emit(f"执行采集任务失败:{exc}") finally: self.completed.emit() @@ -215,8 +191,9 @@ class PDDTaskPageEvent(QObject): page: PDDTaskPage, repository: Optional[TaskRepository] = None, parent=None, - claim_gateway: Optional[TaskClaimGateway] = None, + claim_gateway: Optional[AdminGateway] = None, settings_repository: Optional[SettingsRepository] = None, + collect_service_factory: Optional[CollectServiceFactory] = None, ): super().__init__(parent or page) self._page = page @@ -226,6 +203,13 @@ class PDDTaskPageEvent(QObject): self._claim_busy = False self._claim_thread: Optional[QThread] = None self._claim_worker: Optional[ClaimTaskWorker] = None + self._collect_service_factory = collect_service_factory + + try: + self._repository.recover_interrupted_work() + except AttributeError: + # 测试用的只读 Repository 可以不实现恢复接口。 + pass settings = settings_repository or SettingsRepository() self._client_service = CurrentClientService(settings) @@ -242,6 +226,7 @@ class PDDTaskPageEvent(QObject): self._claim_gateway = HttpAdminGateway( base_url if isinstance(base_url, str) else "", timeout_seconds=timeout_seconds, + client_id=self._client_service.load().client_id, ) except (TypeError, ValueError) as exc: self._claim_gateway_error = str(exc) @@ -290,7 +275,7 @@ class PDDTaskPageEvent(QObject): self._claim_busy = True self._page.autoFetchButton.setEnabled(False) - self._page.set_engine_status("正在领取一个采集任务,请稍候…") + self._page.set_engine_status("正在处理一条采集任务,请稍候…") thread = QThread(self) worker = ClaimTaskWorker( @@ -298,6 +283,7 @@ class PDDTaskPageEvent(QObject): self._repository, self._client_service, self._selected_android_device_service, + self._collect_service_factory, ) worker.moveToThread(thread) thread.started.connect(worker.run) @@ -306,6 +292,7 @@ class PDDTaskPageEvent(QObject): worker.duplicateTask.connect(self._on_duplicate_claimed_task) worker.localSaveFailed.connect(self._on_claimed_task_save_failed) worker.failed.connect(self._on_claim_failed) + worker.outcome.connect(self._on_collect_outcome) worker.completed.connect(thread.quit) worker.completed.connect(worker.deleteLater) thread.finished.connect(thread.deleteLater) @@ -352,6 +339,15 @@ class PDDTaskPageEvent(QObject): self._page.set_engine_status(content) self._show_claim_error("领取任务失败", content) + @pyqtSlot(str, str, str) + def _on_collect_outcome(self, kind: str, message: str, _task_id: str) -> None: + if self._closing: + return + self._page.set_engine_status(message) + self._reload() + if kind in {"result_pending", "manual_review", "failed"}: + self._show_claim_error("采集任务需要处理", message) + def _show_claim_error(self, title: str, content: str) -> None: """显示不会自动消失的可恢复错误,同时保留底部状态文字。""" @@ -413,6 +409,7 @@ class PDDTaskPageEvent(QObject): self._on_claimed_task_save_failed, ), (worker.failed, self._on_claim_failed), + (worker.outcome, self._on_collect_outcome), ) except RuntimeError: signal_slots = () @@ -424,7 +421,10 @@ class PDDTaskPageEvent(QObject): if thread is not None and thread.isRunning(): thread.quit() - thread.wait(10_000) + # uiautomator2/ADB 的单次调用可能需要数秒才返回。先通过 + # cancelled 标志让采集在下一个安全点退出,再等待工作线程收尾, + # 避免窗口销毁时出现 "QThread destroyed while running"。 + thread.wait(60_000) def summary_to_row(summary: TaskSummary) -> TaskRow: diff --git a/client/src/task_models.py b/client/src/task_models.py index f7ed055..ab55768 100644 --- a/client/src/task_models.py +++ b/client/src/task_models.py @@ -184,6 +184,15 @@ class OutboxEventRecord: sent_at: Optional[str] +@dataclass(frozen=True) +class StartedTaskRun: + """已进入执行状态的一次采集尝试。""" + + task: TaskDetail + attempt_id: str + attempt_no: int + + @dataclass(frozen=True) class AppSettingRecord: """一条非敏感应用设置。""" diff --git a/client/src/task_repository.py b/client/src/task_repository.py index 6c469db..c66ac6b 100644 --- a/client/src/task_repository.py +++ b/client/src/task_repository.py @@ -8,10 +8,16 @@ import sqlite3 from datetime import datetime, timezone from pathlib import Path from typing import Dict, List, Optional, Tuple, Union +from uuid import uuid4 from .db import initialize_database, open_database from .task_models import ( NewClaimedTask, + OutboxEventRecord, + OutboxEventType, + OutboxStatus, + RunStatus, + StartedTaskRun, TaskDetail, TaskFilters, TaskStatus, @@ -145,6 +151,353 @@ class TaskRepository: connection.close() return self._to_detail(row) if row is not None else None + def recover_interrupted_work(self) -> None: + """恢复上次异常退出留下的可重试状态。""" + + now = utc_now_iso() + connection = open_database(self._db_path) + try: + with connection: + connection.execute( + "UPDATE outbox_events SET status = 'pending', updated_at = ?" + " WHERE status = 'sending'", + (now,), + ) + connection.execute( + "UPDATE pdd_tasks SET status = 'retry_wait'," + " current_step = 'interrupted', retry_count = retry_count + 1," + " last_error_code = 'CLIENT_INTERRUPTED'," + " last_error_message = '客户端上次执行期间退出', updated_at = ?" + " WHERE status = 'running' AND task_type = 'collect'", + (now,), + ) + connection.execute( + "UPDATE task_runs SET run_status = 'failed'," + " error_code = 'CLIENT_INTERRUPTED'," + " error_message = '客户端上次执行期间退出'," + " finished_at = ?, updated_at = ?" + " WHERE run_status = 'running' AND irreversible_action_at IS NULL" + " AND task_id IN (SELECT id FROM pdd_tasks WHERE task_type = 'collect')", + (now, now), + ) + finally: + connection.close() + + def next_collect_task(self) -> Optional[TaskDetail]: + """返回最早的本地待执行采集任务。""" + + connection = open_database(self._db_path) + try: + row = connection.execute( + "SELECT * FROM pdd_tasks" + " WHERE task_type = 'collect' AND status IN ('claimed', 'retry_wait')" + " ORDER BY received_at ASC, id ASC LIMIT 1" + ).fetchone() + finally: + connection.close() + return self._to_detail(row) if row is not None else None + + def start_collect_run( + self, remote_task_id: str, device_address: str + ) -> StartedTaskRun: + """原子地把待执行任务改为执行中,并创建一次运行记录。""" + + now = utc_now_iso() + attempt_id = str(uuid4()) + connection = open_database(self._db_path) + try: + with connection: + row = connection.execute( + "SELECT * FROM pdd_tasks WHERE remote_task_id = ?", + (remote_task_id,), + ).fetchone() + if row is None: + raise ValueError(f"任务 {remote_task_id} 不存在") + if row["task_type"] != TaskType.COLLECT.value: + raise ValueError("当前只能执行采集任务") + if row["status"] not in { + TaskStatus.CLAIMED.value, + TaskStatus.RETRY_WAIT.value, + }: + raise ValueError(f"任务状态 {row['status']} 不能开始采集") + attempt_no = int( + connection.execute( + "SELECT COALESCE(MAX(attempt_no), 0) + 1" + " FROM task_runs WHERE task_id = ?", + (row["id"],), + ).fetchone()[0] + ) + connection.execute( + "UPDATE pdd_tasks SET status = 'running'," + " current_step = 'collecting', started_at = COALESCE(started_at, ?)," + " last_error_code = NULL, last_error_message = NULL, updated_at = ?" + " WHERE id = ?", + (now, now, row["id"]), + ) + connection.execute( + "INSERT INTO task_runs (task_id, attempt_id, attempt_no," + " device_address, run_status, current_step, started_at," + " created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", + ( + row["id"], attempt_id, attempt_no, device_address, + RunStatus.RUNNING.value, "collecting", now, now, now, + ), + ) + task = self.get_task(remote_task_id) + assert task is not None + return StartedTaskRun(task, attempt_id, attempt_no) + finally: + connection.close() + + def save_collect_result( + self, + remote_task_id: str, + attempt_id: str, + pdd_data: Dict[str, object], + ) -> OutboxEventRecord: + """在一个事务中保存采集结果并创建待提交事件。""" + + now = utc_now_iso() + connection = open_database(self._db_path) + try: + with connection: + task = connection.execute( + "SELECT id, version FROM pdd_tasks WHERE remote_task_id = ?", + (remote_task_id,), + ).fetchone() + if task is None: + raise ValueError(f"任务 {remote_task_id} 不存在") + payload = { + "task_version": task["version"], + "attempt_id": attempt_id, + "result_type": "collect", + "completed_at": now, + "pdd_data": pdd_data, + } + idempotency_key = f"{remote_task_id}:{attempt_id}:result-v1" + connection.execute( + "UPDATE pdd_tasks SET status = 'result_pending'," + " current_step = 'submit_result', pdd_data = ?, goods_id = ?," + " title = ?, price_cent = ?, finished_at = ?, updated_at = ?" + " WHERE id = ?", + ( + json.dumps(pdd_data, ensure_ascii=False), + pdd_data.get("goods_id"), pdd_data.get("title"), + self._summary_price(pdd_data), now, now, task["id"], + ), + ) + connection.execute( + "UPDATE task_runs SET run_status = 'succeeded'," + " current_step = 'submit_result', finished_at = ?, updated_at = ?" + " WHERE attempt_id = ?", + (now, now, attempt_id), + ) + cursor = connection.execute( + "INSERT INTO outbox_events (task_id, event_type, idempotency_key," + " payload_json, status, created_at, updated_at)" + " VALUES (?, 'collect_result', ?, ?, 'pending', ?, ?)", + ( + task["id"], idempotency_key, + json.dumps(payload, ensure_ascii=False), now, now, + ), + ) + event_id = int(cursor.lastrowid) + event = self.get_outbox_event(event_id) + assert event is not None + return event + finally: + connection.close() + + def save_collect_failure( + self, + remote_task_id: str, + attempt_id: str, + status: TaskStatus, + error_code: str, + error_message: str, + retryable: bool, + diagnostics: Optional[Dict[str, object]] = None, + ) -> OutboxEventRecord: + """保存结构化失败,并可靠排队提交 Admin。""" + + if status not in { + TaskStatus.RETRY_WAIT, TaskStatus.MANUAL_REVIEW, + TaskStatus.FAILED, TaskStatus.CANCELLED, + }: + raise ValueError("失败状态无效") + now = utc_now_iso() + connection = open_database(self._db_path) + try: + with connection: + task = connection.execute( + "SELECT id, version FROM pdd_tasks WHERE remote_task_id = ?", + (remote_task_id,), + ).fetchone() + if task is None: + raise ValueError(f"任务 {remote_task_id} 不存在") + payload = { + "task_version": task["version"], + "attempt_id": attempt_id, + "status": status.value, + "error": { + "code": error_code, + "message": error_message, + "retryable": retryable, + "step": "collecting", + }, + "diagnostics": diagnostics or {"artifacts": []}, + "reported_at": now, + } + idempotency_key = f"{remote_task_id}:{attempt_id}:failure-v1" + connection.execute( + "UPDATE pdd_tasks SET status = ?, current_step = 'failed'," + " retry_count = retry_count + ?, last_error_code = ?," + " last_error_message = ?, finished_at = ?, updated_at = ?" + " WHERE id = ?", + ( + status.value, 1 if status is TaskStatus.RETRY_WAIT else 0, + error_code, error_message, now, now, task["id"], + ), + ) + run_status = { + TaskStatus.CANCELLED: RunStatus.CANCELLED, + TaskStatus.MANUAL_REVIEW: RunStatus.MANUAL_REVIEW, + }.get(status, RunStatus.FAILED) + connection.execute( + "UPDATE task_runs SET run_status = ?, current_step = 'failed'," + " error_code = ?, error_message = ?, finished_at = ?, updated_at = ?" + " WHERE attempt_id = ?", + (run_status.value, error_code, error_message, now, now, attempt_id), + ) + cursor = connection.execute( + "INSERT INTO outbox_events (task_id, event_type, idempotency_key," + " payload_json, status, created_at, updated_at)" + " VALUES (?, 'task_failure', ?, ?, 'pending', ?, ?)", + ( + task["id"], idempotency_key, + json.dumps(payload, ensure_ascii=False), now, now, + ), + ) + event_id = int(cursor.lastrowid) + event = self.get_outbox_event(event_id) + assert event is not None + return event + finally: + connection.close() + + def next_pending_outbox(self) -> Optional[OutboxEventRecord]: + connection = open_database(self._db_path) + try: + row = connection.execute( + "SELECT o.* FROM outbox_events o JOIN pdd_tasks t ON t.id = o.task_id" + " WHERE o.status = 'pending'" + " AND (o.next_retry_at IS NULL OR o.next_retry_at <= ?)" + " ORDER BY o.id ASC LIMIT 1", + (utc_now_iso(),), + ).fetchone() + finally: + connection.close() + return self._to_outbox(row) if row is not None else None + + def get_outbox_event(self, event_id: int) -> Optional[OutboxEventRecord]: + connection = open_database(self._db_path) + try: + row = connection.execute( + "SELECT * FROM outbox_events WHERE id = ?", (event_id,) + ).fetchone() + finally: + connection.close() + return self._to_outbox(row) if row is not None else None + + def outbox_task_id(self, event_id: int) -> str: + connection = open_database(self._db_path) + try: + row = connection.execute( + "SELECT t.remote_task_id FROM outbox_events o" + " JOIN pdd_tasks t ON t.id = o.task_id WHERE o.id = ?", + (event_id,), + ).fetchone() + finally: + connection.close() + if row is None: + raise ValueError(f"Outbox {event_id} 不存在") + return str(row[0]) + + def mark_outbox_sending(self, event_id: int) -> None: + self._update_outbox(event_id, "sending", None) + + def mark_outbox_retry(self, event_id: int, message: str) -> None: + self._update_outbox(event_id, "pending", message, increment=True) + + def mark_outbox_failed(self, event_id: int, message: str) -> None: + self._update_outbox(event_id, "failed", message, increment=True) + + def mark_outbox_sent(self, event_id: int) -> None: + now = utc_now_iso() + connection = open_database(self._db_path) + try: + with connection: + row = connection.execute( + "SELECT task_id, event_type FROM outbox_events WHERE id = ?", + (event_id,), + ).fetchone() + if row is None: + raise ValueError(f"Outbox {event_id} 不存在") + connection.execute( + "UPDATE outbox_events SET status = 'sent', sent_at = ?," + " updated_at = ?, attempt_count = attempt_count + 1" + " WHERE id = ?", + (now, now, event_id), + ) + if row["event_type"] == OutboxEventType.COLLECT_RESULT.value: + connection.execute( + "UPDATE pdd_tasks SET status = 'succeeded'," + " current_step = 'completed', updated_at = ? WHERE id = ?", + (now, row["task_id"]), + ) + finally: + connection.close() + + def _update_outbox( + self, event_id: int, status: str, message: Optional[str], increment: bool = False + ) -> None: + connection = open_database(self._db_path) + try: + with connection: + cursor = connection.execute( + "UPDATE outbox_events SET status = ?, last_error = ?, updated_at = ?," + f" attempt_count = attempt_count + {1 if increment else 0} WHERE id = ?", + (status, message, utc_now_iso(), event_id), + ) + if cursor.rowcount != 1: + raise ValueError(f"Outbox {event_id} 不存在") + finally: + connection.close() + + @staticmethod + def _summary_price(pdd_data: Dict[str, object]) -> Optional[int]: + skus = pdd_data.get("skus") + if not isinstance(skus, list): + return None + prices = [ + item.get("price_cent") for item in skus + if isinstance(item, dict) and isinstance(item.get("price_cent"), int) + ] + return min(prices) if prices else None + + @staticmethod + def _to_outbox(row: sqlite3.Row) -> OutboxEventRecord: + return OutboxEventRecord( + id=row["id"], task_id=row["task_id"], + event_type=OutboxEventType(row["event_type"]), + idempotency_key=row["idempotency_key"], + payload_json=TaskRepository._load_json_object(row["payload_json"]), + status=OutboxStatus(row["status"]), attempt_count=row["attempt_count"], + next_retry_at=row["next_retry_at"], last_error=row["last_error"], + created_at=row["created_at"], updated_at=row["updated_at"], + sent_at=row["sent_at"], + ) + @staticmethod def _validate_page(limit: int, offset: int) -> None: if not 1 <= limit <= MAX_PAGE_SIZE: diff --git a/client/test/test_collect_task_service.py b/client/test/test_collect_task_service.py new file mode 100644 index 0000000..8355da0 --- /dev/null +++ b/client/test/test_collect_task_service.py @@ -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() diff --git a/client/test/test_http_admin_gateway.py b/client/test/test_http_admin_gateway.py index 383d6e0..9ccc0d8 100644 --- a/client/test/test_http_admin_gateway.py +++ b/client/test/test_http_admin_gateway.py @@ -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( diff --git a/client/test/test_pdd_collect_service.py b/client/test/test_pdd_collect_service.py index 26275d4..c55d1f5 100644 --- a/client/test/test_pdd_collect_service.py +++ b/client/test/test_pdd_collect_service.py @@ -62,6 +62,18 @@ class LoadingDevice(FakeCollectDevice): return '' +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('') @@ -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( '