feat: 实现 PDD 商品采集基础服务 (#28)
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user