feat: update amazon driver

This commit is contained in:
koko
2026-04-25 17:58:28 +08:00
parent 5c9671df44
commit c352c34501
5 changed files with 523 additions and 329 deletions

13
app/.rtk/filters.toml Normal file
View File

@@ -0,0 +1,13 @@
# Project-local RTK filters — commit this file with your repo.
# Filters here override user-global and built-in filters.
# Docs: https://github.com/rtk-ai/rtk#custom-filters
schema_version = 1
# Example: suppress build noise from a custom tool
# [filters.my-tool]
# description = "Compact my-tool output"
# match_command = "^my-tool\\s+build"
# strip_ansi = true
# strip_lines_matching = ["^\\s*$", "^Downloading", "^Installing"]
# max_lines = 30
# on_empty = "my-tool: ok"

View File

@@ -3,9 +3,8 @@ import logging
import os import os
import subprocess import subprocess
import time import time
import traceback
import uuid import uuid
from typing import Literal from typing import Any, Dict, List, Literal, Optional, TypedDict
import requests import requests
from DrissionPage import Chromium from DrissionPage import Chromium
@@ -20,44 +19,150 @@ except ImportError:
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
STATUS_OK = "0"
STATUS_LOGIN_FAILED = "-10003"
DEFAULT_SOCKET_PORT = 19890
CLIENT_API_TIMEOUT = 120
PORT_CHECK_TIMEOUT = 2
UPDATE_CORE_RETRY_DELAY = 2
CLIENT_RESTART_DELAY = 5
CLIENT_START_RETRIES = 3
CLIENT_READY_TIMEOUT = 10
CLIENT_READY_INTERVAL = 0.5
CLIENT_POST_START_DELAY = 5
PROCESS_KILL_DELAY = 3
DOC_LOAD_TIMEOUT = 30
COUNTRY_INITIAL_LOAD_TIMEOUT = 120
COUNTRY_LOOKUP_TIMEOUT = 20
COUNTRY_CLICK_TIMEOUT = 10
COUNTRY_DROPDOWN_DELAY = 1
IP_CHECK_TIMEOUT = 60
LOGIN_ATTEMPTS = 4
PASSWORD_INPUT_TIMEOUT = 5
PASSWORD_SUBMIT_TIMEOUT = 5
OTP_SEND_TIMEOUT = 5
OTP_INPUT_LOOKUP_ATTEMPTS = 5
OTP_INPUT_TIMEOUT = 30
OTP_INPUT_DISPLAY_TIMEOUT = 10
OTP_CODE_WAIT_SECONDS = 30
OTP_SUBMIT_TIMEOUT = 10
OTP_RESULT_LOAD_TIMEOUT = 20
OTP_ERROR_TIMEOUT = 10
OTP_SEND_DELAY = 1
LOGIN_FINAL_SUBMIT_TIMEOUT = 10
NEED_LOGIN_DELAY = 3
NEED_LOGIN_TIMEOUT = 5
COUNTRY_LABEL_XPATH = 'xpath://div[@class="dropdown-account-switcher-header-label"]/span[last()]'
COUNTRY_DROPDOWN_XPATH = 'xpath://div[@class="dropdown-account-switcher-header-label"]'
COUNTRY_LIST_ITEM_XPATH = 'xpath://div[@class="dropdown-account-switcher-list-item"]'
NEED_LOGIN_XPATH = 'xpath://h1[@class="a-spacing-small"]|//span[contains(text(),"登录")]'
PASSWORD_INPUT_XPATH = 'xpath://input[@type="password"]'
PASSWORD_SUBMIT_XPATH = 'xpath://input[@id="signInSubmit"]'
OTP_SEND_XPATH = 'xpath://span[@id="auth-send-code" and contains(string(.),"发送一次性密码")]'
OTP_INPUT_XPATH = 'xpath://input[@name="otpCode"]'
OTP_SUBMIT_XPATH = 'xpath://input[@id="auth-signin-button"]'
OTP_ERROR_XPATH = 'xpath://div[@id="auth-error-message-box"]'
class UserInfo(TypedDict):
username: str
password: str
company: str
def kill_process(version: Literal["v5", "v6"]): def kill_process(version: Literal["v5", "v6"]):
"""杀紫鸟客户端进程(独立函数版本)""" """结束指定版本的紫鸟客户端进程."""
logger.info("准备杀紫鸟客户端进程version=%s", version) logger.info("准备杀紫鸟客户端进程,version=%s", version)
driver = ZiniaoDriver({}) driver = ZiniaoDriver({})
driver.kill_process(version) driver.kill_process(version)
class ZiniaoDriver: class ZiniaoDriver:
"""紫鸟浏览器自动化驱动类""" """封装紫鸟客户端启动,店铺浏览器生命周期和客户端 API 调用."""
def __init__(self, user_info: dict, socket_port: int = 19890): def __init__(self, user_info: UserInfo, socket_port: int = DEFAULT_SOCKET_PORT):
""" """初始化紫鸟浏览器驱动.
初始化紫鸟浏览器驱动
Args: Args:
user_info: 用户信息字典,包含 company, username, password user_info: 紫鸟账号信息,包含 company, username, password.
socket_port: 客户端通信端口,默认 19890 socket_port: 客户端 HTTP 通信端口.
""" """
self.user_info = user_info self.user_info = user_info
self.socket_port = socket_port self.socket_port = socket_port
self.client_path = None self.client_path = None
self.browser = None self.browser = None
self.tab: ChromiumTab = None self.tab: Optional[ChromiumTab] = None
self.store_id = None self.store_id = None
@property
def client_url(self) -> str:
return f"http://127.0.0.1:{self.socket_port}"
def _build_payload(self, action: str, extra: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
payload = {
"action": action,
"requestId": str(uuid.uuid4()),
}
if extra:
payload.update(extra)
payload.update(self.user_info)
return payload
def _post_client(self, payload: Dict[str, Any]) -> Dict[str, Any]:
response = requests.post(
self.client_url,
json.dumps(payload).encode("utf-8"),
timeout=CLIENT_API_TIMEOUT,
)
return response.json()
@staticmethod
def _status_code(result: Optional[Dict[str, Any]]) -> Optional[str]:
if result is None:
return None
return str(result.get("statusCode"))
@staticmethod
def _result_text(result: Dict[str, Any]) -> str:
return json.dumps(result, ensure_ascii=False)
@staticmethod
def _find_store_oauth(shop_list: Optional[List[Dict[str, Any]]], shop_name: str):
for shop in shop_list or []:
if shop.get("browserName") == shop_name:
return shop.get("browserOauth")
return None
def _is_client_ready(self) -> bool:
try:
requests.get(self.client_url, timeout=PORT_CHECK_TIMEOUT)
return True
except (requests.exceptions.ConnectionError, requests.exceptions.Timeout):
return False
def _wait_until_client_ready(self, timeout: float) -> bool:
start_check_time = time.time()
while time.time() - start_check_time < timeout:
if self._is_client_ready():
return True
time.sleep(CLIENT_READY_INTERVAL)
return False
def get_zinaio_exe(self, protocol_name: str = "superbrowser"): def get_zinaio_exe(self, protocol_name: str = "superbrowser"):
""" """从 Windows 注册表读取紫鸟客户端可执行文件路径.
获取紫鸟安装目录
Args: Args:
protocol_name: 协议名称,默认 "superbrowser" protocol_name: 注册表协议名称.
Returns: Returns:
exe_path: 紫鸟浏览器可执行文件路径 紫鸟浏览器可执行文件路径. 未找到时返回 None.
""" """
if winreg is None: if winreg is None:
logger.error("当前系统不支持 winreg无法从注册表获取紫鸟客户端路径") logger.error("当前系统不支持 winreg,无法从注册表获取紫鸟客户端路径")
return None return None
key_path = rf"SOFTWARE\Classes\{protocol_name}\shell\open\command" key_path = rf"SOFTWARE\Classes\{protocol_name}\shell\open\command"
@@ -66,60 +171,63 @@ class ZiniaoDriver:
(winreg.HKEY_LOCAL_MACHINE, "HKEY_LOCAL_MACHINE"), (winreg.HKEY_LOCAL_MACHINE, "HKEY_LOCAL_MACHINE"),
): ):
try: try:
logger.info("正在从注册表读取紫鸟客户端路径%s\\%s", root_name, key_path) logger.info("正在从注册表读取紫鸟客户端路径:%s\\%s", root_name, key_path)
key = winreg.OpenKey(root_key, key_path) key = winreg.OpenKey(root_key, key_path)
command, _ = winreg.QueryValueEx(key, "") command, _ = winreg.QueryValueEx(key, "")
winreg.CloseKey(key) winreg.CloseKey(key)
if isinstance(command, str): if isinstance(command, str):
sub = "ziniao.exe" sub = "ziniao.exe"
exe_path = command[0: command.find(sub) + len(sub) + 1] exe_path = command[0 : command.find(sub) + len(sub) + 1]
else: else:
exe_path = command[0] exe_path = command[0]
logger.info("已获取紫鸟客户端路径%s", exe_path) logger.info("已获取紫鸟客户端路径:%s", exe_path)
return exe_path return exe_path
except FileNotFoundError: except FileNotFoundError:
logger.warning("注册表中未找到紫鸟客户端路径%s\\%s", root_name, key_path) logger.warning("注册表中未找到紫鸟客户端路径:%s\\%s", root_name, key_path)
logger.error("未能获取紫鸟客户端路径protocol_name=%s", protocol_name) logger.error("未能获取紫鸟客户端路径,protocol_name=%s", protocol_name)
return None return None
def update_core(self): def update_core(self):
"""更新紫鸟浏览器内核.
打开店铺前调用,要求客户端版本 5.285.7 以上. 接口可能因 HTTP 超时
返回未完成状态,因此会循环调用直到成功或判定客户端不支持.
""" """
下载所有内核打开店铺前调用需客户端版本5.285.7以上 payload = self._build_payload("updateCore")
因为http有超时时间所以这个action适合循环调用直到返回成功
"""
data = {
"action": "updateCore",
"requestId": str(uuid.uuid4()),
}
data.update(self.user_info)
url = f"http://127.0.0.1:{self.socket_port}"
logger.info("开始更新紫鸟浏览器内核") logger.info("开始更新紫鸟浏览器内核")
while True: while True:
response = requests.post(url, json.dumps(data).encode("utf-8"), timeout=120) result = self._post_client(payload)
result = response.json() logger.info("更新内核返回:%s", result)
logger.info("更新内核返回:%s", result) if self._handle_update_core_result(result):
return
if result is None:
continue
logger.info("等待更新内核完成:%s", self._result_text(result))
time.sleep(UPDATE_CORE_RETRY_DELAY)
def _handle_update_core_result(self, result: Optional[Dict[str, Any]]) -> bool:
if result is None: if result is None:
logger.info("等待客户端启动后继续更新内核") logger.info("等待客户端启动后继续更新内核")
time.sleep(2) time.sleep(UPDATE_CORE_RETRY_DELAY)
continue return False
if result.get("statusCode") is None or result.get("statusCode") == -10003:
logger.error("当前紫鸟客户端版本不支持更新内核接口,请升级客户端")
return
if result.get("statusCode") == 0:
logger.info("紫鸟浏览器内核更新完成")
return
logger.info("等待更新内核完成:%s", json.dumps(result, ensure_ascii=False)) status_code = self._status_code(result)
time.sleep(2) if status_code is None or status_code == STATUS_LOGIN_FAILED:
logger.error("当前紫鸟客户端版本不支持更新内核接口,请升级客户端")
return True
if status_code == STATUS_OK:
logger.info("紫鸟浏览器内核更新完成")
return True
return False
def kill_process(self, version: Literal["v5", "v6"]): def kill_process(self, version: Literal["v5", "v6"]):
""" """结束指定版本的紫鸟客户端进程.
杀紫鸟客户端进程
Args: Args:
version: 客户端版本 version: 客户端版本.
""" """
if version == "v5": if version == "v5":
process_name = "SuperBrowser.exe" process_name = "SuperBrowser.exe"
@@ -128,37 +236,31 @@ class ZiniaoDriver:
else: else:
process_name = "ziniao.exe" process_name = "ziniao.exe"
logger.info("准备结束紫鸟客户端进程%s", process_name) logger.info("准备结束紫鸟客户端进程:%s", process_name)
os.system("taskkill /f /t /im " + process_name) os.system("taskkill /f /t /im " + process_name)
time.sleep(3) time.sleep(PROCESS_KILL_DELAY)
def get_browser_list(self) -> list: def get_browser_list(self) -> Optional[list[Dict[str, Any]]]:
""" """获取紫鸟浏览器店铺列表.
获取浏览器列表
Returns: Returns:
list: 浏览器列表 浏览器店铺列表.登录失败或请求失败时返回 None.
""" """
request_id = str(uuid.uuid4()) payload = self._build_payload("getBrowserList")
data = { request_id = payload["requestId"]
"action": "getBrowserList",
"requestId": request_id,
}
data.update(self.user_info)
logger.info("开始获取紫鸟浏览器列表requestId=%s", request_id) logger.info("开始获取紫鸟浏览器列表,requestId=%s", request_id)
url = f"http://127.0.0.1:{self.socket_port}" result = self._post_client(payload)
response = requests.post(url, json.dumps(data).encode("utf-8"), timeout=120) status_code = self._status_code(result)
result = response.json() if status_code == STATUS_OK:
if str(result.get("statusCode")) == "0":
browser_list = result.get("browserList") browser_list = result.get("browserList")
logger.info("获取紫鸟浏览器列表成功数量=%s", len(browser_list or [])) logger.info("获取紫鸟浏览器列表成功,数量=%s", len(browser_list or []))
return browser_list return browser_list
if str(result.get("statusCode")) == "-10003": if status_code == STATUS_LOGIN_FAILED:
logger.error("获取紫鸟浏览器列表登录失败%s", json.dumps(result, ensure_ascii=False)) logger.error("获取紫鸟浏览器列表登录失败:%s", self._result_text(result))
return None return None
logger.error("获取紫鸟浏览器列表失败%s", json.dumps(result, ensure_ascii=False)) logger.error("获取紫鸟浏览器列表失败:%s", self._result_text(result))
return None return None
def open_store( def open_store(
@@ -170,89 +272,103 @@ class ZiniaoDriver:
cookieTypeSave=0, cookieTypeSave=0,
jsInfo="", jsInfo="",
): ):
""" """启动紫鸟店铺浏览器.
打开店铺
Args: Args:
store_info: 店铺信息(browserId 或 browserOauth store_info: 店铺 browserId 或 browserOauth.
isWebDriverReadOnlyMode: 是否只读模式,默认 0 isWebDriverReadOnlyMode: WebDriver 只读模式开关.
isprivacy: 隐私模式,默认 0 isprivacy: 隐私模式开关.
isHeadless: 无头模式,默认 0 isHeadless: 无头模式开关.
cookieTypeSave: cookie保存类型,默认 0 cookieTypeSave: Cookie 保存类型.
jsInfo: 注入的JS信息,默认 "" jsInfo: 注入的 JS 信息.
Returns: Returns:
dict: 返回结果 紫鸟客户端返回的启动结果.
""" """
request_id = str(uuid.uuid4()) payload = self._build_start_browser_payload(
data = { store_info=store_info,
"action": "startBrowser", is_web_driver_read_only_mode=isWebDriverReadOnlyMode,
is_privacy=isprivacy,
is_headless=isHeadless,
cookie_type_save=cookieTypeSave,
js_info=jsInfo,
)
request_id = payload["requestId"]
logger.info("开始打开紫鸟店铺,store_info=%s,requestId=%s", store_info, request_id)
result = self._post_client(payload)
status_code = self._status_code(result)
if status_code == STATUS_OK:
logger.info("紫鸟店铺打开成功,debuggingPort=%s", result.get("debuggingPort"))
return result
if status_code == STATUS_LOGIN_FAILED:
logger.error("打开紫鸟店铺登录失败:%s", self._result_text(result))
raise RuntimeError(f"[open_store]登录失败 {self._result_text(result)}")
logger.error("打开紫鸟店铺失败:%s", self._result_text(result))
raise RuntimeError(f"[open_store]失败 {self._result_text(result)} ")
def _build_start_browser_payload(
self,
*,
store_info,
is_web_driver_read_only_mode=0,
is_privacy=0,
is_headless=0,
cookie_type_save=0,
js_info="",
) -> Dict[str, Any]:
payload = self._build_payload(
"startBrowser",
{
"isWaitPluginUpdate": 0, "isWaitPluginUpdate": 0,
"isHeadless": isHeadless, "isHeadless": is_headless,
"requestId": request_id, "isWebDriverReadOnlyMode": is_web_driver_read_only_mode,
"isWebDriverReadOnlyMode": isWebDriverReadOnlyMode,
"cookieTypeLoad": 0, "cookieTypeLoad": 0,
"cookieTypeSave": cookieTypeSave, "cookieTypeSave": cookie_type_save,
"runMode": "1", "runMode": "1",
"isLoadUserPlugin": True, "isLoadUserPlugin": True,
"pluginIdType": 1, "pluginIdType": 1,
"privacyMode": isprivacy, "privacyMode": is_privacy,
} },
data.update(self.user_info) )
if store_info.isdigit(): if store_info.isdigit():
data["browserId"] = store_info payload["browserId"] = store_info
else: else:
data["browserOauth"] = store_info payload["browserOauth"] = store_info
if len(str(jsInfo)) > 2: if len(str(js_info)) > 2:
data["injectJsInfo"] = json.dumps(jsInfo) payload["injectJsInfo"] = json.dumps(js_info)
logger.info("开始打开紫鸟店铺store_info=%srequestId=%s", store_info, request_id) return payload
url = f"http://127.0.0.1:{self.socket_port}"
response = requests.post(url, json.dumps(data).encode("utf-8"), timeout=120)
result = response.json()
if str(result.get("statusCode")) == "0":
logger.info("紫鸟店铺打开成功debuggingPort=%s", result.get("debuggingPort"))
return result
if str(result.get("statusCode")) == "-10003":
logger.error("打开紫鸟店铺登录失败:%s", json.dumps(result, ensure_ascii=False))
raise RuntimeError(f"【open_store】登录失败 {json.dumps(result, ensure_ascii=False)}")
logger.error("打开紫鸟店铺失败:%s", json.dumps(result, ensure_ascii=False))
raise RuntimeError(f"【open_store】失败 {json.dumps(result, ensure_ascii=False)} ")
def get_browser(self, port) -> Chromium: def get_browser(self, port) -> Chromium:
""" """连接 DrissionPage 浏览器实例.
获取浏览器实例
Args: Args:
port: 调试端口 port: Chromium 调试端口.
Returns: Returns:
Chromium: DrissionPage浏览器实例 DrissionPage Chromium 实例.
""" """
logger.info("开始连接 DrissionPage 浏览器port=%s", port) logger.info("开始连接 DrissionPage 浏览器,port=%s", port)
browser = Chromium(port) browser = Chromium(port)
logger.info("DrissionPage 浏览器连接完成port=%s", port) logger.info("DrissionPage 浏览器连接完成,port=%s", port)
return browser return browser
def start_client(self): def start_client(self):
"""启动紫鸟客户端""" """启动紫鸟客户端并更新浏览器内核."""
url = f"http://127.0.0.1:{self.socket_port}" if self._is_client_ready():
logger.info("端口 %s 已启动,跳过启动紫鸟客户端", self.socket_port)
try:
requests.get(url, timeout=2)
logger.info("端口 %s 已启动,跳过启动紫鸟客户端", self.socket_port)
return return
except (requests.exceptions.ConnectionError, requests.exceptions.Timeout):
logger.info("端口 %s 未启动,开始启动紫鸟客户端", self.socket_port)
logger.info("端口 %s 未启动,开始启动紫鸟客户端", self.socket_port)
self.kill_process("v6") self.kill_process("v6")
time.sleep(5) time.sleep(CLIENT_RESTART_DELAY)
client_path = self.get_zinaio_exe("superbrowserv6") client_path = self.get_zinaio_exe("superbrowserv6")
if not client_path: if not client_path:
raise RuntimeError("未找到紫鸟客户端路径无法启动客户端") raise RuntimeError("未找到紫鸟客户端路径,无法启动客户端")
self.client_path = client_path.strip('"') self.client_path = client_path.strip('"')
cmd = [ cmd = [
@@ -261,56 +377,38 @@ class ZiniaoDriver:
"--ipc_type=http", "--ipc_type=http",
"--port=" + str(self.socket_port), "--port=" + str(self.socket_port),
] ]
logger.info("紫鸟客户端启动命令%s", " ".join(cmd)) logger.info("紫鸟客户端启动命令:%s", " ".join(cmd))
max_retries = 3 for retry_count in range(CLIENT_START_RETRIES):
for retry_count in range(max_retries): logger.info("%s/%s 次尝试启动紫鸟客户端", retry_count + 1, CLIENT_START_RETRIES)
logger.info("%s/%s 次尝试启动紫鸟客户端", retry_count + 1, max_retries)
subprocess.Popen(cmd) subprocess.Popen(cmd)
start_check_time = time.time() if self._wait_until_client_ready(CLIENT_READY_TIMEOUT):
client_started = False logger.info("紫鸟客户端启动成功,第 %s 次尝试", retry_count + 1)
while time.time() - start_check_time < 10: time.sleep(CLIENT_POST_START_DELAY)
try:
requests.get(url, timeout=2)
logger.info("紫鸟客户端启动成功,第 %s 次尝试", retry_count + 1)
client_started = True
break
except (requests.exceptions.ConnectionError, requests.exceptions.Timeout):
time.sleep(0.5)
if client_started:
time.sleep(5)
self.update_core() self.update_core()
return return
logger.warning("%s 次尝试启动失败10秒内未检测到客户端启动", retry_count + 1) logger.warning("%s 次尝试启动失败,10秒内未检测到客户端启动", retry_count + 1)
logger.error("紫鸟客户端启动失败已重试 %s", max_retries) logger.error("紫鸟客户端启动失败,已重试 %s", CLIENT_START_RETRIES)
raise RuntimeError(f"客户端启动失败重试 {max_retries} 次后仍未成功启动") raise RuntimeError(f"客户端启动失败:重试 {CLIENT_START_RETRIES} 次后仍未成功启动")
def open_shop(self, shop_name: str): def open_shop(self, shop_name: str):
""" """打开指定店铺并完成 IP 检测.
打开指定店铺
Args: Args:
shop_name: 店铺名称 shop_name: 店铺名称.
Returns: Returns:
Chromium or str: 成功返回浏览器实例,失败返回错误信息 成功返回浏览器实例.店铺不存在时返回错误信息.
""" """
logger.info("开始打开店铺%s", shop_name) logger.info("开始打开店铺:%s", shop_name)
self.start_client() self.start_client()
shop_ls = self.get_browser_list() self.store_id = self._find_store_oauth(self.get_browser_list(), shop_name)
self.store_id = None
for shop in shop_ls or []:
if shop.get("browserName") == shop_name:
self.store_id = shop.get("browserOauth")
break
if not self.store_id: if not self.store_id:
logger.warning("店铺不存在%s", shop_name) logger.warning("店铺不存在:%s", shop_name)
return "店铺不存在" return "店铺不存在"
ret_json = self.open_store(self.store_id) ret_json = self.open_store(self.store_id)
@@ -319,88 +417,86 @@ class ZiniaoDriver:
self.store_id = ret_json.get("browserId") self.store_id = ret_json.get("browserId")
self.browser = self.get_browser(ret_json.get("debuggingPort")) self.browser = self.get_browser(ret_json.get("debuggingPort"))
ip_check_url = ret_json.get("ipDetectionPage") ip_check_url = ret_json.get("ipDetectionPage")
if not ip_check_url: if not ip_check_url:
logger.error("ip检测页地址为空准备关闭店铺%s", shop_name) logger.error("ip检测页地址为空,准备关闭店铺:%s", shop_name)
self.close_store(self.store_id) self.close_store(self.store_id)
raise RuntimeError("没有IP检测地址为了店铺安全不打开店铺") raise RuntimeError("没有IP检测地址,为了店铺安全不打开店铺")
ip_usable = self.open_ip_check(self.browser, ip_check_url) if self.open_ip_check(self.browser, ip_check_url):
if ip_usable: logger.info("IP检测通过,打开店铺平台主页:%s", shop_name)
logger.info("IP检测通过打开店铺平台主页%s", shop_name)
self.open_launcher_page(ret_json.get("launcherPage"), self.browser) self.open_launcher_page(ret_json.get("launcherPage"), self.browser)
else:
logger.error("IP检测不通过停止打开店铺%s", shop_name)
raise RuntimeError("IP检测不通过可能是因为网络环境变化导致的为了店铺安全不打开店铺")
return self.browser return self.browser
logger.error("IP检测不通过,停止打开店铺:%s", shop_name)
raise RuntimeError("IP检测不通过,可能是因为网络环境变化导致的,为了店铺安全不打开店铺")
def close_store(self, browser_oauth=None): def close_store(self, browser_oauth=None):
""" """关闭紫鸟店铺浏览器.
关闭店铺
Args: Args:
browser_oauth: 店铺OAuth标识,如果不提供使用当前打开的店铺 browser_oauth: 店铺 OAuth 标识. 未提供使用当前打开的店铺.
Returns: Returns:
dict: 返回结果 紫鸟客户端返回的关闭结果.
""" """
if browser_oauth is None: if browser_oauth is None:
browser_oauth = self.store_id browser_oauth = self.store_id
request_id = str(uuid.uuid4()) payload = self._build_payload(
data = { "stopBrowser",
"action": "stopBrowser", {
"requestId": request_id,
"duplicate": 0, "duplicate": 0,
"browserOauth": browser_oauth, "browserOauth": browser_oauth,
} },
data.update(self.user_info) )
request_id = payload["requestId"]
logger.info("开始关闭紫鸟店铺browserOauth=%srequestId=%s", browser_oauth, request_id) logger.info("开始关闭紫鸟店铺,browserOauth=%s,requestId=%s", browser_oauth, request_id)
url = f"http://127.0.0.1:{self.socket_port}" result = self._post_client(payload)
response = requests.post(url, json.dumps(data).encode("utf-8"), timeout=120) status_code = self._status_code(result)
result = response.json() if status_code == STATUS_OK:
if str(result.get("statusCode")) == "0": logger.info("紫鸟店铺关闭成功,browserOauth=%s", browser_oauth)
logger.info("紫鸟店铺关闭成功browserOauth=%s", browser_oauth)
return result return result
if str(result.get("statusCode")) == "-10003": if status_code == STATUS_LOGIN_FAILED:
logger.error("关闭紫鸟店铺登录失败%s", json.dumps(result, ensure_ascii=False)) logger.error("关闭紫鸟店铺登录失败:%s", self._result_text(result))
raise RuntimeError(f"close_store登录失败 {json.dumps(result, ensure_ascii=False)}") raise RuntimeError(f"[close_store]登录失败 {self._result_text(result)}")
logger.error("关闭紫鸟店铺失败%s", json.dumps(result, ensure_ascii=False)) logger.error("关闭紫鸟店铺失败:%s", self._result_text(result))
raise RuntimeError(f"close_store失败 {json.dumps(result, ensure_ascii=False)} ") raise RuntimeError(f"[close_store]失败: {self._result_text(result)} ")
def open_launcher_page(self, launcher_page: str, browser: Chromium = None): def open_launcher_page(self, launcher_page: str, browser: Chromium = None):
""" """打开店铺平台启动页.
打开启动页面
Args: Args:
launcher_page: 要打开的页面URL launcher_page: 要打开的启动页 URL.
browser: 浏览器实例,如果不提供使用当前浏览器实例 browser: 浏览器实例. 未提供使用当前浏览器实例.
""" """
if browser is None: if browser is None:
browser = self.browser browser = self.browser
logger.info("打开店铺平台启动页%s", launcher_page) logger.info("打开店铺平台启动页:%s", launcher_page)
tab = browser.new_tab(url=launcher_page) tab = browser.new_tab(url=launcher_page)
self.tab = tab self.tab = tab
return tab return tab
def open_ip_check(self, browser: Chromium, ip_check_url: str): def open_ip_check(self, browser: Chromium, ip_check_url: str):
""" """打开 IP 检测页并判断网络环境是否通过.
打开ip检测页检测ip是否正常
:param browser: drissionpage浏览器会话 Args:
:param ip_check_url ip检测页地址 browser: DrissionPage 浏览器会话.
:return 检测结果 ip_check_url: IP 检测页地址.
Returns:
IP 检测通过时返回 True,否则返回 False.
""" """
try: try:
logger.info("开始打开IP检测页%s", ip_check_url) logger.info("开始打开IP检测页:%s", ip_check_url)
tab = browser.latest_tab tab = browser.latest_tab
tab.get(ip_check_url) tab.get(ip_check_url)
success_button = tab.ele( success_button = tab.ele(
(By.XPATH, '//button[contains(@class, "styles_btn--success")]'), (By.XPATH, '//button[contains(@class, "styles_btn--success")]'),
timeout=60, timeout=IP_CHECK_TIMEOUT,
) )
if success_button: if success_button:
logger.info("IP检测成功") logger.info("IP检测成功")
@@ -414,105 +510,108 @@ class ZiniaoDriver:
class AmamzonBase(ZiniaoDriver): class AmamzonBase(ZiniaoDriver):
"""亚马逊操作基类,包含一些通用方法""" """亚马逊页面操作基类."""
def SwitchingCountries(self, country_name: str): def SwitchingCountries(self, country_name: str):
""" """切换亚马逊账号国家并验证结果.
切换国家
操作: 流程包括读取当前国家,打开国家下拉框,选择目标国家并等待页面加载.
1、//div[@class="dropdown-account-switcher-header-label"]/span[last()] 获取此元素文本,判断当前国家,如果与目标国家相同则不操作,否则执行下一步
2、点击 //div[@class="dropdown-account-switcher-header-label"] 打开下拉框
3、点击 //div[@class="dropdown-account-switcher-list-item"] 第一个展开国家列表
4、点击 //div[@class="dropdown-account-switcher-list-item dropdown-account-switcher-list-item-indented" and @title="国家名"] 切换到目标国家
5、等待页面加载完成判断国家是否切换成功成功则返回True否则返回False
Args: Args:
country_name: 目标国家名称 country_name: 目标国家名称.
Returns: Returns:
bool: 切换成功返回True,失败返回False 切换成功返回 True,否则返回 False.
""" """
try: try:
if self.browser is None: if self.browser is None:
logger.warning("浏览器实例不存在请先打开店铺") logger.warning("浏览器实例不存在,请先打开店铺")
return False return False
tab = self.tab self.tab.wait.doc_loaded(timeout=COUNTRY_INITIAL_LOAD_TIMEOUT, raise_err=False)
tab.wait.doc_loaded(timeout=120, raise_err=False) current_country = self._get_current_country(timeout=COUNTRY_LOOKUP_TIMEOUT)
if current_country is None:
logger.info("正在检查当前国家")
current_country_ele = tab.ele(
'xpath://div[@class="dropdown-account-switcher-header-label"]/span[last()]',
timeout=20,
)
if current_country_ele:
current_country = current_country_ele.text.strip()
logger.info("当前国家:%s", current_country)
if current_country == country_name:
logger.info("当前已经是目标国家 %s,无需切换", country_name)
return True
else:
logger.warning("无法获取当前国家信息") logger.warning("无法获取当前国家信息")
return False return False
if current_country == country_name:
logger.info("当前已经是目标国家 %s,无需切换", country_name)
return True
logger.info("正在打开国家切换下拉框") if not self._open_country_dropdown():
dropdown_header = tab.ele('xpath://div[@class="dropdown-account-switcher-header-label"]', timeout=10)
if not dropdown_header:
logger.warning("找不到国家切换下拉框")
return False return False
dropdown_header.click() if not self._select_country(country_name):
time.sleep(1)
logger.info("正在展开国家列表")
first_item = tab.ele('xpath://div[@class="dropdown-account-switcher-list-item"]', timeout=10)
if not first_item:
logger.warning("找不到国家列表项")
return False return False
first_item.click()
time.sleep(1)
logger.info("正在切换到国家:%s", country_name)
target_country_xpath = f'//div[@class="dropdown-account-switcher-list-item dropdown-account-switcher-list-item-indented" and @title="{country_name}"]'
target_country = tab.ele(f"xpath:{target_country_xpath}", timeout=10)
if not target_country:
logger.warning("找不到目标国家:%s", country_name)
return False
target_country.click()
logger.info("等待国家切换后页面加载") logger.info("等待国家切换后页面加载")
self.tab.wait.doc_loaded() self.tab.wait.doc_loaded()
return self._verify_country(country_name)
new_country_ele = tab.ele(
'xpath://div[@class="dropdown-account-switcher-header-label"]/span[last()]',
timeout=10,
)
if new_country_ele:
new_country = new_country_ele.text.strip()
if new_country == country_name:
logger.info("国家切换成功:%s", new_country)
return True
logger.warning("国家切换失败,当前国家:%s,目标国家:%s", new_country, country_name)
return False
logger.warning("无法验证国家切换结果")
return False
except Exception: except Exception:
logger.exception("切换国家时发生异常") logger.exception("切换国家时发生异常")
return False return False
def need_login(self): def _get_current_country(self, *, timeout: int) -> Optional[str]:
""" logger.info("正在检查当前国家")
判断是否需要登录,部分国家可能需要登录后才能切换国家 current_country_ele = self.tab.ele(COUNTRY_LABEL_XPATH, timeout=timeout)
""" if not current_country_ele:
time.sleep(3) return None
self.tab.wait.doc_loaded(timeout=30, raise_err=False)
need_login_ele = self.tab.eles( current_country = current_country_ele.text.strip()
'xpath://h1[@class="a-spacing-small"]|//span[contains(text(),"登录")]', logger.info("当前国家:%s", current_country)
timeout=5, return current_country
def _open_country_dropdown(self) -> bool:
logger.info("正在打开国家切换下拉框")
dropdown_header = self.tab.ele(COUNTRY_DROPDOWN_XPATH, timeout=COUNTRY_CLICK_TIMEOUT)
if not dropdown_header:
logger.warning("找不到国家切换下拉框")
return False
dropdown_header.click()
time.sleep(COUNTRY_DROPDOWN_DELAY)
logger.info("正在展开国家列表")
first_item = self.tab.ele(COUNTRY_LIST_ITEM_XPATH, timeout=COUNTRY_CLICK_TIMEOUT)
if not first_item:
logger.warning("找不到国家列表项")
return False
first_item.click()
time.sleep(COUNTRY_DROPDOWN_DELAY)
return True
def _select_country(self, country_name: str) -> bool:
logger.info("正在切换到国家:%s", country_name)
target_country = self.tab.ele(self._country_option_xpath(country_name), timeout=COUNTRY_CLICK_TIMEOUT)
if not target_country:
logger.warning("找不到目标国家:%s", country_name)
return False
target_country.click()
return True
@staticmethod
def _country_option_xpath(country_name: str) -> str:
return (
"xpath://div[@class="
'"dropdown-account-switcher-list-item dropdown-account-switcher-list-item-indented" '
f'and @title="{country_name}"]'
) )
def _verify_country(self, country_name: str) -> bool:
new_country = self._get_current_country(timeout=COUNTRY_CLICK_TIMEOUT)
if new_country is None:
logger.warning("无法验证国家切换结果")
return False
if new_country == country_name:
logger.info("国家切换成功:%s", new_country)
return True
logger.warning("国家切换失败,当前国家:%s,目标国家:%s", new_country, country_name)
return False
def need_login(self):
"""判断当前页面是否需要登录."""
time.sleep(NEED_LOGIN_DELAY)
self.tab.wait.doc_loaded(timeout=DOC_LOAD_TIMEOUT, raise_err=False)
need_login_ele = self.tab.eles(NEED_LOGIN_XPATH, timeout=NEED_LOGIN_TIMEOUT)
if len(need_login_ele) > 0: if len(need_login_ele) > 0:
logger.info("检测到需要登录元素") logger.info("检测到需要登录元素")
return True return True
@@ -520,61 +619,102 @@ class AmamzonBase(ZiniaoDriver):
return False return False
def login(self, password, username=""): def login(self, password, username=""):
"""登录当前页面,必要时等待并提交一次性验证码.
Args:
password: 登录密码.
username: 兼容保留参数,当前未使用.
Returns:
登录成功返回 True,失败返回 False.
"""
try: try:
self.tab.wait.doc_loaded(timeout=30, raise_err=False) self.tab.wait.doc_loaded(timeout=DOC_LOAD_TIMEOUT, raise_err=False)
for _ in range(4): for _ in range(LOGIN_ATTEMPTS):
pwd_input = self.tab.eles('xpath://input[@type="password"]', timeout=5) self._submit_password_if_present(password)
self.tab.wait.doc_loaded(timeout=DOC_LOAD_TIMEOUT, raise_err=False)
if self._send_otp_if_present():
continue
if self._submit_ready_otp():
return True
self._click_final_login_submit_if_present()
except Exception:
logger.exception("登录过程中发生异常")
return False
def _submit_password_if_present(self, password) -> None:
pwd_input = self.tab.eles(PASSWORD_INPUT_XPATH, timeout=PASSWORD_INPUT_TIMEOUT)
if len(pwd_input) > 0: if len(pwd_input) > 0:
logger.info("检测到密码输入框准备输入密码") logger.info("检测到密码输入框,准备输入密码")
pwd_input[0].input(password, clear=True) pwd_input[0].input(password, clear=True)
submit_btn = self.tab.eles('xpath://input[@id="signInSubmit"]', timeout=5)
submit_btn = self.tab.eles(PASSWORD_SUBMIT_XPATH, timeout=PASSWORD_SUBMIT_TIMEOUT)
if len(submit_btn) > 0: if len(submit_btn) > 0:
logger.info("点击登录提交按钮") logger.info("点击登录提交按钮")
submit_btn[0].click() submit_btn[0].click()
self.tab.wait.doc_loaded(timeout=30, raise_err=False)
send_code = self.tab.eles( def _send_otp_if_present(self) -> bool:
'xpath://span[@id="auth-send-code" and contains(string(.),"发送一次性密码")]', send_code = self.tab.eles(OTP_SEND_XPATH, timeout=OTP_SEND_TIMEOUT)
timeout=5, logger.info("发送一次性密码元素数量:%s", len(send_code))
) if len(send_code) == 0:
logger.info("发送一次性密码元素数量:%s", len(send_code)) return False
if len(send_code) > 0:
logger.info("检测到发送一次性密码按钮准备点击") logger.info("检测到发送一次性密码按钮,准备点击")
send_code[0].click() send_code[0].click()
time.sleep(1) time.sleep(OTP_SEND_DELAY)
self.tab.wait.doc_loaded(timeout=30, raise_err=False) self.tab.wait.doc_loaded(timeout=DOC_LOAD_TIMEOUT, raise_err=False)
else: return True
for _ in range(5):
opt_code_input = self.tab.eles('xpath://input[@name="otpCode"]', timeout=30) def _submit_ready_otp(self) -> bool:
logger.info("验证码输入框数量:%s", len(opt_code_input)) for _ in range(OTP_INPUT_LOOKUP_ATTEMPTS):
if len(opt_code_input) > 0: otp_input = self._find_otp_input()
opt_code_input[0].wait.displayed(timeout=10, raise_err=False) if otp_input is None:
for _ in range(30): return False
if opt_code_input[0].value is not None and opt_code_input[0].value.strip() != "": if self._wait_for_otp_value(otp_input) and self._submit_otp_if_ready():
return True
return False
def _find_otp_input(self):
otp_code_input = self.tab.eles(OTP_INPUT_XPATH, timeout=OTP_INPUT_TIMEOUT)
logger.info("验证码输入框数量:%s", len(otp_code_input))
if len(otp_code_input) == 0:
return None
otp_code_input[0].wait.displayed(timeout=OTP_INPUT_DISPLAY_TIMEOUT, raise_err=False)
return otp_code_input[0]
@staticmethod
def _has_otp_value(otp_input) -> bool:
return otp_input.value is not None and otp_input.value.strip() != ""
def _wait_for_otp_value(self, otp_input) -> bool:
for _ in range(OTP_CODE_WAIT_SECONDS):
if self._has_otp_value(otp_input):
logger.info("检测到验证码输入完成") logger.info("检测到验证码输入完成")
submit_btn = self.tab.eles('xpath://input[@id="auth-signin-button"]', timeout=10) return True
if len(submit_btn) > 0: time.sleep(1)
return False
def _submit_otp_if_ready(self) -> bool:
submit_btn = self.tab.eles(OTP_SUBMIT_XPATH, timeout=OTP_SUBMIT_TIMEOUT)
if len(submit_btn) == 0:
return False
submit_btn[0].click() submit_btn[0].click()
self.tab.wait.doc_loaded(timeout=20, raise_err=False) self.tab.wait.doc_loaded(timeout=OTP_RESULT_LOAD_TIMEOUT, raise_err=False)
error_mes = self.tab.eles( error_mes = self.tab.eles(OTP_ERROR_XPATH, timeout=OTP_ERROR_TIMEOUT)
'xpath://div[@id="auth-error-message-box"]',
timeout=10,
)
if len(error_mes) > 0: if len(error_mes) > 0:
logger.warning("验证码输入错误%s", error_mes[0].text) logger.warning("验证码输入错误:%s", error_mes[0].text)
self.tab.refresh() self.tab.refresh()
else: return False
logger.info("登录成功") logger.info("登录成功")
return True return True
time.sleep(1) def _click_final_login_submit_if_present(self) -> None:
else: submit_btn = self.tab.eles(OTP_SUBMIT_XPATH, timeout=LOGIN_FINAL_SUBMIT_TIMEOUT)
break
submit_btn = self.tab.eles('xpath://input[@id="auth-signin-button"]', timeout=10)
if len(submit_btn) > 0: if len(submit_btn) > 0:
logger.info("点击二次登录提交按钮") logger.info("点击二次登录提交按钮")
submit_btn[0].click() submit_btn[0].click()
except Exception:
logger.exception("登录过程中发生异常:%s", traceback.format_exc())
return False

11
app/amazon/product.py Normal file
View File

@@ -0,0 +1,11 @@
from .base import AmamzonBase, UserInfo
if __name__ == "__main__":
user_info: UserInfo = {"company": "rongchuang123", "username": "自动化_Robot", "password": "#20zsg25"}
driver = AmamzonBase(user_info)
shop_name = "郭亚芳"
country = "德国"
driver.open_shop(shop_name)
driver.SwitchingCountries(country)

View File

@@ -91,6 +91,9 @@ dependencies = [
[tool.uv] [tool.uv]
package = false package = false
[tool.pytest.ini_options]
pythonpath = ["."]
[dependency-groups] [dependency-groups]
dev = [ dev = [
"pytest>=8.4.2", "pytest>=8.4.2",

View File

@@ -154,6 +154,33 @@ def test_get_browser_list_status_handling(monkeypatch, payload, expected):
assert base.ZiniaoDriver({}).get_browser_list() == expected assert base.ZiniaoDriver({}).get_browser_list() == expected
def test_update_core_retries_none_then_returns_on_success(monkeypatch):
calls = []
responses = [FakeResponse(None), FakeResponse({"statusCode": 0})]
def fake_post(url, data, timeout):
calls.append(json.loads(data.decode("utf-8")))
return responses.pop(0)
monkeypatch.setattr(base.requests, "post", fake_post)
monkeypatch.setattr(base.time, "sleep", lambda seconds: None)
base.ZiniaoDriver({}).update_core()
assert [payload["action"] for payload in calls] == ["updateCore", "updateCore"]
def test_wait_until_client_ready_polls_until_available(monkeypatch):
readiness = [False, False, True]
sleeps = []
driver = base.ZiniaoDriver({})
monkeypatch.setattr(driver, "_is_client_ready", lambda: readiness.pop(0))
monkeypatch.setattr(base.time, "sleep", lambda seconds: sleeps.append(seconds))
assert driver._wait_until_client_ready(timeout=10) is True
assert sleeps == [base.CLIENT_READY_INTERVAL, base.CLIENT_READY_INTERVAL]
def test_close_store_success_uses_current_store(monkeypatch): def test_close_store_success_uses_current_store(monkeypatch):
payloads = [] payloads = []