feat: 正式采购下单前更新地址标记 (#173)
This commit is contained in:
@@ -78,7 +78,11 @@ class PddPurchaseAdapter(ABC):
|
||||
|
||||
|
||||
class PddLivePurchaseAdapter(PddPurchaseAdapter):
|
||||
"""受控真实采购接口;只增加一次性提交,不提供付款或取消。"""
|
||||
"""受控真实采购接口;增加地址标记和一次性提交,不提供付款或取消。"""
|
||||
|
||||
@abstractmethod
|
||||
def update_shipping_address(self, purchase_number: str) -> None:
|
||||
"""下单前更新地址末尾采购编号;失败时必须保持在可逆阶段。"""
|
||||
|
||||
@abstractmethod
|
||||
def submit_order_once(self) -> None:
|
||||
|
||||
@@ -55,6 +55,11 @@ from .util.select_color_size import select_color, select_size
|
||||
Bounds = tuple[int, int, int, int]
|
||||
_BOUNDS_PATTERN = re.compile(r"^\[(\d+),(\d+)\]\[(\d+),(\d+)\]$")
|
||||
_PRICE_PATTERN = re.compile(r"[¥¥]\s*(\d+(?:\.\d{1,2})?)")
|
||||
_MASKED_PHONE_PATTERN = re.compile(r"(?<!\d)\d{3}\*{4}\d{4}(?!\d)")
|
||||
_PROGRAM_ADDRESS_SUFFIX_PATTERN = re.compile(
|
||||
r"(?:[-_](?:cg\d+|(?:am|pm)?[0-9a-f]{4,12}))$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_READY_MARKERS = ("发起拼单", "立即购买", "单独购买", "免拼购买", "快要抢光")
|
||||
_LOGIN_MARKERS = ("手机号登录", "登录后继续", "验证码登录", "账号登录")
|
||||
_CAPTCHA_MARKERS = ("请完成验证", "拖动滑块", "安全验证", "点击图中")
|
||||
@@ -150,6 +155,107 @@ def _parse_bounds(value: str) -> Optional[Bounds]:
|
||||
return left, top, right, bottom
|
||||
|
||||
|
||||
def _parent_nodes(root: ET.Element) -> dict[ET.Element, ET.Element]:
|
||||
return {
|
||||
child: parent
|
||||
for parent in root.iter()
|
||||
for child in parent
|
||||
if child.tag == "node"
|
||||
}
|
||||
|
||||
|
||||
def _nearest_clickable_bounds(
|
||||
node: ET.Element, parents: Mapping[ET.Element, ET.Element]
|
||||
) -> Optional[Bounds]:
|
||||
"""从文字节点向上寻找最近的可点击、可见且启用容器。"""
|
||||
|
||||
current: Optional[ET.Element] = node
|
||||
while current is not None:
|
||||
bounds = _parse_bounds(current.get("bounds", ""))
|
||||
if (
|
||||
bounds is not None
|
||||
and current.get("clickable") == "true"
|
||||
and current.get("enabled", "true") != "false"
|
||||
and current.get("visible-to-user", "true") != "false"
|
||||
):
|
||||
return bounds
|
||||
current = parents.get(current)
|
||||
return None
|
||||
|
||||
|
||||
def _clickable_text_targets(root: ET.Element, exact_text: str) -> list[Bounds]:
|
||||
"""按明确文字返回最近的唯一点击容器候选。"""
|
||||
|
||||
parents = _parent_nodes(root)
|
||||
targets = {
|
||||
bounds
|
||||
for node in root.iter("node")
|
||||
if exact_text
|
||||
in {
|
||||
node.get("text", "").strip(),
|
||||
node.get("content-desc", "").strip(),
|
||||
}
|
||||
if (bounds := _nearest_clickable_bounds(node, parents)) is not None
|
||||
}
|
||||
return sorted(targets)
|
||||
|
||||
|
||||
def _address_entry_targets(root: ET.Element) -> list[Bounds]:
|
||||
"""通过脱敏手机号结构定位规格面板顶部的收货地址卡片。"""
|
||||
|
||||
parents = _parent_nodes(root)
|
||||
targets = {
|
||||
bounds
|
||||
for node in root.iter("node")
|
||||
if _MASKED_PHONE_PATTERN.search(_label(node))
|
||||
if (bounds := _nearest_clickable_bounds(node, parents)) is not None
|
||||
}
|
||||
return sorted(targets)
|
||||
|
||||
|
||||
def _shipping_address_editor(root: ET.Element) -> Optional[tuple[str, Bounds]]:
|
||||
"""在修改页中寻找与“详细地址”标签同一行的唯一输入框。"""
|
||||
|
||||
address_labels = [
|
||||
bounds
|
||||
for node in root.iter("node")
|
||||
if "详细地址" in _label(node).replace(" ", "")
|
||||
if (bounds := _parse_bounds(node.get("bounds", ""))) is not None
|
||||
]
|
||||
editors = [
|
||||
(node.get("text", ""), bounds)
|
||||
for node in root.iter("node")
|
||||
if node.get("class") == "android.widget.EditText"
|
||||
if node.get("enabled", "true") != "false"
|
||||
if node.get("visible-to-user", "true") != "false"
|
||||
if (bounds := _parse_bounds(node.get("bounds", ""))) is not None
|
||||
]
|
||||
matches = [
|
||||
editor
|
||||
for editor in editors
|
||||
for label_bounds in address_labels
|
||||
if editor[1][1] <= label_bounds[3]
|
||||
and editor[1][3] >= label_bounds[1]
|
||||
and editor[1][0] >= label_bounds[0]
|
||||
]
|
||||
return matches[0] if len(matches) == 1 and matches[0][0].strip() else None
|
||||
|
||||
|
||||
def _tagged_shipping_address(address: str, purchase_number: str) -> str:
|
||||
"""只替换程序生成的末尾标记,保留完整真实地址主体。"""
|
||||
|
||||
checked_address = str(address or "").strip()
|
||||
checked_number = str(purchase_number or "").strip()
|
||||
if not checked_address or not checked_number:
|
||||
raise ValueError("地址和采购编号不能为空")
|
||||
if any(character in checked_number for character in "\r\n\t"):
|
||||
raise ValueError("采购编号不能包含控制字符")
|
||||
address_body = _PROGRAM_ADDRESS_SUFFIX_PATTERN.sub("", checked_address)
|
||||
if not address_body:
|
||||
raise ValueError("地址主体不能为空")
|
||||
return f"{address_body}_{checked_number}"
|
||||
|
||||
|
||||
def _goods_id_from_url(goods_url: str) -> str:
|
||||
parsed = urlparse(goods_url)
|
||||
host = (parsed.hostname or "").lower()
|
||||
@@ -1090,6 +1196,156 @@ def create_u2_purchase_adapter(
|
||||
class U2PddLivePurchaseAdapter(U2PddPurchaseAdapter, PddLivePurchaseAdapter):
|
||||
"""只允许一次最终提交点击的真机 Adapter,不包含付款路径。"""
|
||||
|
||||
def update_shipping_address(self, purchase_number: str) -> None:
|
||||
"""更新正式采购地址末尾标记,并返回原规格确认面板。"""
|
||||
|
||||
self._check_cancelled("purchase_update_shipping_address")
|
||||
device = self._require_device()
|
||||
try:
|
||||
checked_number = str(purchase_number or "").strip()
|
||||
if not checked_number or any(
|
||||
character in checked_number for character in "\r\n\t"
|
||||
):
|
||||
raise PddPurchaseError(
|
||||
"PURCHASE_NUMBER_INVALID",
|
||||
"采购编号为空或包含无效字符,未修改收货地址",
|
||||
step="purchase_update_shipping_address",
|
||||
)
|
||||
|
||||
root = _parse_xml(self._dump_hierarchy())
|
||||
entry_targets = _address_entry_targets(root)
|
||||
self._click_unique_address_target(
|
||||
entry_targets,
|
||||
"PURCHASE_ADDRESS_ENTRY_AMBIGUOUS",
|
||||
"规格面板没有唯一可靠的收货地址入口",
|
||||
)
|
||||
|
||||
panel_root = self._wait_for_address_page("panel")
|
||||
modify_targets = _clickable_text_targets(panel_root, "修改")
|
||||
self._click_unique_address_target(
|
||||
modify_targets,
|
||||
"PURCHASE_ADDRESS_EDIT_AMBIGUOUS",
|
||||
"收货地址页没有唯一可靠的修改按钮",
|
||||
)
|
||||
|
||||
edit_root = self._wait_for_address_page("edit")
|
||||
editor_data = _shipping_address_editor(edit_root)
|
||||
if editor_data is None:
|
||||
raise PddPurchaseError(
|
||||
"PURCHASE_ADDRESS_EDITOR_MISSING",
|
||||
"修改页没有唯一可靠的详细地址输入框",
|
||||
step="purchase_update_shipping_address",
|
||||
)
|
||||
current_address, _editor_bounds = editor_data
|
||||
try:
|
||||
new_address = _tagged_shipping_address(
|
||||
current_address, checked_number
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise PddPurchaseError(
|
||||
"PURCHASE_ADDRESS_VALUE_INVALID",
|
||||
"当前详细地址或采购编号不符合更新要求",
|
||||
step="purchase_update_shipping_address",
|
||||
) from exc
|
||||
|
||||
editor = device(
|
||||
className="android.widget.EditText", text=current_address
|
||||
)
|
||||
if int(getattr(editor, "count", 0)) != 1:
|
||||
raise PddPurchaseError(
|
||||
"PURCHASE_ADDRESS_EDITOR_AMBIGUOUS",
|
||||
"无法唯一选中详细地址输入框",
|
||||
step="purchase_update_shipping_address",
|
||||
)
|
||||
editor.set_text(new_address)
|
||||
self._sleep(0.2)
|
||||
|
||||
latest_edit_root = _parse_xml(self._dump_hierarchy())
|
||||
latest_editor = _shipping_address_editor(latest_edit_root)
|
||||
if latest_editor is None or latest_editor[0] != new_address:
|
||||
raise PddPurchaseError(
|
||||
"PURCHASE_ADDRESS_INPUT_MISMATCH",
|
||||
"详细地址输入后回读不一致,未保存也未下单",
|
||||
step="purchase_update_shipping_address",
|
||||
)
|
||||
save_targets = _clickable_text_targets(latest_edit_root, "保存")
|
||||
self._click_unique_address_target(
|
||||
save_targets,
|
||||
"PURCHASE_ADDRESS_SAVE_AMBIGUOUS",
|
||||
"修改页没有唯一可靠的保存按钮",
|
||||
)
|
||||
|
||||
saved_root = self._wait_for_address_page(
|
||||
"saved", expected_address=new_address
|
||||
)
|
||||
back_targets = _clickable_text_targets(saved_root, "返回")
|
||||
self._click_unique_address_target(
|
||||
back_targets,
|
||||
"PURCHASE_ADDRESS_BACK_AMBIGUOUS",
|
||||
"收货地址页没有唯一可靠的返回按钮",
|
||||
)
|
||||
self._wait_for_address_page("confirmation")
|
||||
except PddPurchaseError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
self._raise_device_or_page_error(
|
||||
exc, "purchase_update_shipping_address"
|
||||
)
|
||||
|
||||
def _click_unique_address_target(
|
||||
self, targets: list[Bounds], code: str, message: str
|
||||
) -> None:
|
||||
"""地址流程只允许点击唯一候选,避免误触其他资料。"""
|
||||
|
||||
if len(targets) != 1:
|
||||
raise PddPurchaseError(
|
||||
code,
|
||||
message,
|
||||
step="purchase_update_shipping_address",
|
||||
diagnostics={"candidate_count": len(targets)},
|
||||
)
|
||||
left, top, right, bottom = targets[0]
|
||||
self._require_device().click(
|
||||
(left + right) // 2, (top + bottom) // 2
|
||||
)
|
||||
|
||||
def _wait_for_address_page(
|
||||
self, kind: str, *, expected_address: str = ""
|
||||
) -> ET.Element:
|
||||
"""等待地址页面切换;诊断信息不包含任何地址文字。"""
|
||||
|
||||
deadline = self._monotonic() + self._panel_timeout
|
||||
while self._monotonic() < deadline:
|
||||
self._check_cancelled("purchase_update_shipping_address")
|
||||
root = _parse_xml(self._dump_hierarchy())
|
||||
labels = _labels(root)
|
||||
if kind == "panel" and "收货地址" in labels:
|
||||
return root
|
||||
if kind == "edit" and any(
|
||||
"修改收货地址" in label.replace(" ", "")
|
||||
for label in labels
|
||||
):
|
||||
return root
|
||||
if (
|
||||
kind == "saved"
|
||||
and "收货地址" in labels
|
||||
and expected_address
|
||||
and any(expected_address in label for label in labels)
|
||||
):
|
||||
return root
|
||||
if (
|
||||
kind == "confirmation"
|
||||
and _page_kind(root, "") == "order_confirmation"
|
||||
):
|
||||
return root
|
||||
self._sleep(0.2)
|
||||
raise PddPurchaseError(
|
||||
"PURCHASE_ADDRESS_PAGE_TIMEOUT",
|
||||
"收货地址页面切换或保存确认超时,未进入不可逆下单阶段",
|
||||
step="purchase_update_shipping_address",
|
||||
diagnostics={"expected_page": kind},
|
||||
)
|
||||
|
||||
def submit_order_once(self) -> None:
|
||||
if self._submit_attempted:
|
||||
raise PddPurchaseError(
|
||||
|
||||
@@ -166,6 +166,16 @@ class PurchaseTaskService:
|
||||
state = adapter.read_state()
|
||||
self._validate_checkout_values(state, target)
|
||||
|
||||
if execution_mode == "live":
|
||||
assert isinstance(adapter, PddLivePurchaseAdapter)
|
||||
step = "purchase_update_shipping_address"
|
||||
self._enter_step(remote_task_id, started.attempt_id, step)
|
||||
adapter.update_shipping_address(remote_task_id)
|
||||
|
||||
step = "purchase_verify_after_address"
|
||||
self._enter_step(remote_task_id, started.attempt_id, step)
|
||||
self._validate_checkout_values(adapter.read_state(), target)
|
||||
|
||||
step = "purchase_enter_confirmation"
|
||||
self._enter_step(remote_task_id, started.attempt_id, step)
|
||||
self._validate_checkout_values(adapter.read_state(), target)
|
||||
|
||||
Reference in New Issue
Block a user