feat: 实现 PDD 商品采集基础服务 (#28)

This commit is contained in:
chengma
2026-08-07 16:12:39 +08:00
parent d2decc7a29
commit 4aff22c009
6 changed files with 1365 additions and 0 deletions
+139
View File
@@ -0,0 +1,139 @@
"""uiautomator2 设备连接边界。
每次自动化任务通过 ``connect`` 获取一个会话。会话只能在创建它的线程中
使用,退出 ``with`` 后立即释放,避免多个任务同时控制同一台手机。
"""
from __future__ import annotations
import threading
from contextlib import AbstractContextManager
from typing import Any, Callable, Optional
PDD_PACKAGE_NAME = "com.xunmeng.pinduoduo"
class PddDeviceError(RuntimeError):
"""设备连接失败,并携带供任务状态使用的稳定错误码。"""
def __init__(self, code: str, message: str) -> None:
super().__init__(message)
self.code = code
self.message = message
class PddDeviceSession(AbstractContextManager[Any]):
"""一个只能由创建线程使用的 uiautomator2 Device 会话。"""
def __init__(
self,
device: Any,
serial: str,
release: Callable[[], None],
) -> None:
self._device = device
self.serial = serial
self._release = release
self._owner_thread_id = threading.get_ident()
self._closed = False
@property
def device(self) -> Any:
"""返回 Device;跨线程或会话关闭后访问会明确失败。"""
if self._closed:
raise PddDeviceError("DEVICE_SESSION_CLOSED", "设备会话已经关闭")
if threading.get_ident() != self._owner_thread_id:
raise PddDeviceError(
"DEVICE_THREAD_VIOLATION",
"uiautomator2 Device 只能在创建会话的工作线程中使用",
)
return self._device
def __enter__(self) -> Any:
return self.device
def __exit__(self, exc_type: Any, exc: Any, traceback: Any) -> None:
if not self._closed:
self._closed = True
self._release()
return None
class PddDeviceService:
"""连接并校验一台已保存的 Android 设备。"""
def __init__(
self,
connector: Optional[Callable[[str], Any]] = None,
) -> None:
self._connector = connector or self._default_connector
self._state_lock = threading.Lock()
self._active_serial: Optional[str] = None
@staticmethod
def _default_connector(serial: str) -> Any:
try:
import uiautomator2 as u2
except ImportError as exc:
raise PddDeviceError(
"DEVICE_U2_MISSING",
"未安装 uiautomator2,请先安装 client/requirements.txt",
) from exc
return u2.connect(serial)
@staticmethod
def _validate_serial(serial: str) -> str:
value = str(serial or "").strip()
if not value or any(character.isspace() for character in value):
raise PddDeviceError("DEVICE_ADDRESS_INVALID", "Android 设备号无效")
return value
def connect(self, serial: str) -> PddDeviceSession:
"""连接设备并返回单线程独占会话。
调用方必须使用 ``with service.connect(serial) as device``。同一个服务
已有活动会话时会拒绝第二次连接。
"""
checked_serial = self._validate_serial(serial)
with self._state_lock:
if self._active_serial is not None:
raise PddDeviceError(
"DEVICE_IN_USE",
f"设备 {self._active_serial} 正在执行其他自动化任务",
)
self._active_serial = checked_serial
try:
device = self._connector(checked_serial)
current = device.app_current()
if not isinstance(current, dict):
raise RuntimeError("uiautomator2 未返回有效的设备状态")
except PddDeviceError:
self._release(checked_serial)
raise
except Exception as exc:
self._release(checked_serial)
details = str(exc).lower()
code = (
"DEVICE_OFFLINE"
if any(word in details for word in ("offline", "not found", "disconnected"))
else "DEVICE_CONNECT_FAILED"
)
raise PddDeviceError(
code,
f"无法连接 Android 设备 {checked_serial}:{exc}",
) from exc
return PddDeviceSession(
device,
checked_serial,
lambda: self._release(checked_serial),
)
def _release(self, serial: str) -> None:
with self._state_lock:
if self._active_serial == serial:
self._active_serial = None