Files
crawler-plugin/app/amazon/base.py
2026-04-25 17:14:23 +08:00

581 lines
23 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import json
import logging
import os
import subprocess
import time
import traceback
import uuid
from typing import Literal
import requests
from DrissionPage import Chromium
from DrissionPage._pages.chromium_tab import ChromiumTab
from DrissionPage.common import By
try:
import winreg
except ImportError:
winreg = None
logger = logging.getLogger(__name__)
def kill_process(version: Literal["v5", "v6"]):
"""杀紫鸟客户端进程(独立函数版本)"""
logger.info("准备杀紫鸟客户端进程version=%s", version)
driver = ZiniaoDriver({})
driver.kill_process(version)
class ZiniaoDriver:
"""紫鸟浏览器自动化驱动类"""
def __init__(self, user_info: dict, socket_port: int = 19890):
"""
初始化紫鸟浏览器驱动
Args:
user_info: 用户信息字典,包含 company, username, password
socket_port: 客户端通信端口,默认 19890
"""
self.user_info = user_info
self.socket_port = socket_port
self.client_path = None
self.browser = None
self.tab: ChromiumTab = None
self.store_id = None
def get_zinaio_exe(self, protocol_name: str = "superbrowser"):
"""
获取紫鸟安装目录
Args:
protocol_name: 协议名称,默认 "superbrowser"
Returns:
exe_path: 紫鸟浏览器可执行文件路径
"""
if winreg is None:
logger.error("当前系统不支持 winreg无法从注册表获取紫鸟客户端路径")
return None
key_path = rf"SOFTWARE\Classes\{protocol_name}\shell\open\command"
for root_key, root_name in (
(winreg.HKEY_CURRENT_USER, "HKEY_CURRENT_USER"),
(winreg.HKEY_LOCAL_MACHINE, "HKEY_LOCAL_MACHINE"),
):
try:
logger.info("正在从注册表读取紫鸟客户端路径:%s\\%s", root_name, key_path)
key = winreg.OpenKey(root_key, key_path)
command, _ = winreg.QueryValueEx(key, "")
winreg.CloseKey(key)
if isinstance(command, str):
sub = "ziniao.exe"
exe_path = command[0: command.find(sub) + len(sub) + 1]
else:
exe_path = command[0]
logger.info("已获取紫鸟客户端路径:%s", exe_path)
return exe_path
except FileNotFoundError:
logger.warning("注册表中未找到紫鸟客户端路径:%s\\%s", root_name, key_path)
logger.error("未能获取紫鸟客户端路径protocol_name=%s", protocol_name)
return None
def update_core(self):
"""
下载所有内核打开店铺前调用需客户端版本5.285.7以上
因为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("开始更新紫鸟浏览器内核")
while True:
response = requests.post(url, json.dumps(data).encode("utf-8"), timeout=120)
result = response.json()
logger.info("更新内核返回:%s", result)
if result is None:
logger.info("等待客户端启动后继续更新内核")
time.sleep(2)
continue
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))
time.sleep(2)
def kill_process(self, version: Literal["v5", "v6"]):
"""
杀紫鸟客户端进程
Args:
version: 客户端版本
"""
if version == "v5":
process_name = "SuperBrowser.exe"
logger.info("准备结束紫鸟 v5 starter.exe")
os.system("taskkill /f /t /im starter.exe")
else:
process_name = "ziniao.exe"
logger.info("准备结束紫鸟客户端进程:%s", process_name)
os.system("taskkill /f /t /im " + process_name)
time.sleep(3)
def get_browser_list(self) -> list:
"""
获取浏览器列表
Returns:
list: 浏览器列表
"""
request_id = str(uuid.uuid4())
data = {
"action": "getBrowserList",
"requestId": request_id,
}
data.update(self.user_info)
logger.info("开始获取紫鸟浏览器列表requestId=%s", request_id)
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":
browser_list = result.get("browserList")
logger.info("获取紫鸟浏览器列表成功,数量=%s", len(browser_list or []))
return browser_list
if str(result.get("statusCode")) == "-10003":
logger.error("获取紫鸟浏览器列表登录失败:%s", json.dumps(result, ensure_ascii=False))
return None
logger.error("获取紫鸟浏览器列表失败:%s", json.dumps(result, ensure_ascii=False))
return None
def open_store(
self,
store_info,
isWebDriverReadOnlyMode=0,
isprivacy=0,
isHeadless=0,
cookieTypeSave=0,
jsInfo="",
):
"""
打开店铺
Args:
store_info: 店铺信息browserId 或 browserOauth
isWebDriverReadOnlyMode: 是否只读模式,默认 0
isprivacy: 隐私模式,默认 0
isHeadless: 无头模式,默认 0
cookieTypeSave: cookie保存类型默认 0
jsInfo: 注入的JS信息默认 ""
Returns:
dict: 返回结果
"""
request_id = str(uuid.uuid4())
data = {
"action": "startBrowser",
"isWaitPluginUpdate": 0,
"isHeadless": isHeadless,
"requestId": request_id,
"isWebDriverReadOnlyMode": isWebDriverReadOnlyMode,
"cookieTypeLoad": 0,
"cookieTypeSave": cookieTypeSave,
"runMode": "1",
"isLoadUserPlugin": True,
"pluginIdType": 1,
"privacyMode": isprivacy,
}
data.update(self.user_info)
if store_info.isdigit():
data["browserId"] = store_info
else:
data["browserOauth"] = store_info
if len(str(jsInfo)) > 2:
data["injectJsInfo"] = json.dumps(jsInfo)
logger.info("开始打开紫鸟店铺store_info=%srequestId=%s", store_info, request_id)
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:
"""
获取浏览器实例
Args:
port: 调试端口
Returns:
Chromium: DrissionPage浏览器实例
"""
logger.info("开始连接 DrissionPage 浏览器port=%s", port)
browser = Chromium(port)
logger.info("DrissionPage 浏览器连接完成port=%s", port)
return browser
def start_client(self):
"""启动紫鸟客户端"""
url = f"http://127.0.0.1:{self.socket_port}"
try:
requests.get(url, timeout=2)
logger.info("端口 %s 已启动,跳过启动紫鸟客户端", self.socket_port)
return
except (requests.exceptions.ConnectionError, requests.exceptions.Timeout):
logger.info("端口 %s 未启动,开始启动紫鸟客户端", self.socket_port)
self.kill_process("v6")
time.sleep(5)
client_path = self.get_zinaio_exe("superbrowserv6")
if not client_path:
raise RuntimeError("未找到紫鸟客户端路径,无法启动客户端")
self.client_path = client_path.strip('"')
cmd = [
self.client_path,
"--run_type=web_driver",
"--ipc_type=http",
"--port=" + str(self.socket_port),
]
logger.info("紫鸟客户端启动命令:%s", " ".join(cmd))
max_retries = 3
for retry_count in range(max_retries):
logger.info("%s/%s 次尝试启动紫鸟客户端", retry_count + 1, max_retries)
subprocess.Popen(cmd)
start_check_time = time.time()
client_started = False
while time.time() - start_check_time < 10:
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()
return
logger.warning("%s 次尝试启动失败10秒内未检测到客户端启动", retry_count + 1)
logger.error("紫鸟客户端启动失败,已重试 %s", max_retries)
raise RuntimeError(f"客户端启动失败:重试 {max_retries} 次后仍未成功启动")
def open_shop(self, shop_name: str):
"""
打开指定店铺
Args:
shop_name: 店铺名称
Returns:
Chromium or str: 成功返回浏览器实例,失败返回错误信息
"""
logger.info("开始打开店铺:%s", shop_name)
self.start_client()
shop_ls = self.get_browser_list()
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:
logger.warning("店铺不存在:%s", shop_name)
return "店铺不存在"
ret_json = self.open_store(self.store_id)
self.store_id = ret_json.get("browserOauth")
if self.store_id is None:
self.store_id = ret_json.get("browserId")
self.browser = self.get_browser(ret_json.get("debuggingPort"))
ip_check_url = ret_json.get("ipDetectionPage")
if not ip_check_url:
logger.error("ip检测页地址为空准备关闭店铺%s", shop_name)
self.close_store(self.store_id)
raise RuntimeError("没有IP检测地址为了店铺安全不打开店铺")
ip_usable = self.open_ip_check(self.browser, ip_check_url)
if ip_usable:
logger.info("IP检测通过打开店铺平台主页%s", shop_name)
self.open_launcher_page(ret_json.get("launcherPage"), self.browser)
else:
logger.error("IP检测不通过停止打开店铺%s", shop_name)
raise RuntimeError("IP检测不通过可能是因为网络环境变化导致的为了店铺安全不打开店铺")
return self.browser
def close_store(self, browser_oauth=None):
"""
关闭店铺
Args:
browser_oauth: 店铺OAuth标识如果不提供则使用当前打开的店铺
Returns:
dict: 返回结果
"""
if browser_oauth is None:
browser_oauth = self.store_id
request_id = str(uuid.uuid4())
data = {
"action": "stopBrowser",
"requestId": request_id,
"duplicate": 0,
"browserOauth": browser_oauth,
}
data.update(self.user_info)
logger.info("开始关闭紫鸟店铺browserOauth=%srequestId=%s", browser_oauth, request_id)
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("紫鸟店铺关闭成功browserOauth=%s", browser_oauth)
return result
if str(result.get("statusCode")) == "-10003":
logger.error("关闭紫鸟店铺登录失败:%s", json.dumps(result, ensure_ascii=False))
raise RuntimeError(f"【close_store】登录失败 {json.dumps(result, ensure_ascii=False)}")
logger.error("关闭紫鸟店铺失败:%s", json.dumps(result, ensure_ascii=False))
raise RuntimeError(f"【close_store】失败 {json.dumps(result, ensure_ascii=False)} ")
def open_launcher_page(self, launcher_page: str, browser: Chromium = None):
"""
打开启动页面
Args:
launcher_page: 要打开的页面URL
browser: 浏览器实例,如果不提供则使用当前浏览器实例
"""
if browser is None:
browser = self.browser
logger.info("打开店铺平台启动页:%s", launcher_page)
tab = browser.new_tab(url=launcher_page)
self.tab = tab
return tab
def open_ip_check(self, browser: Chromium, ip_check_url: str):
"""
打开ip检测页检测ip是否正常
:param browser: drissionpage浏览器会话
:param ip_check_url ip检测页地址
:return 检测结果
"""
try:
logger.info("开始打开IP检测页%s", ip_check_url)
tab = browser.latest_tab
tab.get(ip_check_url)
success_button = tab.ele(
(By.XPATH, '//button[contains(@class, "styles_btn--success")]'),
timeout=60,
)
if success_button:
logger.info("IP检测成功")
return True
logger.warning("IP检测超时或未找到成功按钮")
return False
except Exception:
logger.exception("IP检测异常")
return False
class AmamzonBase(ZiniaoDriver):
"""亚马逊操作基类,包含一些通用方法"""
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:
country_name: 目标国家名称
Returns:
bool: 切换成功返回True失败返回False
"""
try:
if self.browser is None:
logger.warning("浏览器实例不存在,请先打开店铺")
return False
tab = self.tab
tab.wait.doc_loaded(timeout=120, raise_err=False)
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("无法获取当前国家信息")
return False
logger.info("正在打开国家切换下拉框")
dropdown_header = tab.ele('xpath://div[@class="dropdown-account-switcher-header-label"]', timeout=10)
if not dropdown_header:
logger.warning("找不到国家切换下拉框")
return False
dropdown_header.click()
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
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("等待国家切换后页面加载")
self.tab.wait.doc_loaded()
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:
logger.exception("切换国家时发生异常")
return False
def need_login(self):
"""
判断是否需要登录,部分国家可能需要登录后才能切换国家
"""
time.sleep(3)
self.tab.wait.doc_loaded(timeout=30, raise_err=False)
need_login_ele = self.tab.eles(
'xpath://h1[@class="a-spacing-small"]|//span[contains(text(),"登录")]',
timeout=5,
)
if len(need_login_ele) > 0:
logger.info("检测到需要登录元素")
return True
logger.info("未检测到登录元素")
return False
def login(self, password, username=""):
try:
self.tab.wait.doc_loaded(timeout=30, raise_err=False)
for _ in range(4):
pwd_input = self.tab.eles('xpath://input[@type="password"]', timeout=5)
if len(pwd_input) > 0:
logger.info("检测到密码输入框,准备输入密码")
pwd_input[0].input(password, clear=True)
submit_btn = self.tab.eles('xpath://input[@id="signInSubmit"]', timeout=5)
if len(submit_btn) > 0:
logger.info("点击登录提交按钮")
submit_btn[0].click()
self.tab.wait.doc_loaded(timeout=30, raise_err=False)
send_code = self.tab.eles(
'xpath://span[@id="auth-send-code" and contains(string(.),"发送一次性密码")]',
timeout=5,
)
logger.info("发送一次性密码元素数量:%s", len(send_code))
if len(send_code) > 0:
logger.info("检测到发送一次性密码按钮,准备点击")
send_code[0].click()
time.sleep(1)
self.tab.wait.doc_loaded(timeout=30, raise_err=False)
else:
for _ in range(5):
opt_code_input = self.tab.eles('xpath://input[@name="otpCode"]', timeout=30)
logger.info("验证码输入框数量:%s", len(opt_code_input))
if len(opt_code_input) > 0:
opt_code_input[0].wait.displayed(timeout=10, raise_err=False)
for _ in range(30):
if opt_code_input[0].value is not None and opt_code_input[0].value.strip() != "":
logger.info("检测到验证码输入完成")
submit_btn = self.tab.eles('xpath://input[@id="auth-signin-button"]', timeout=10)
if len(submit_btn) > 0:
submit_btn[0].click()
self.tab.wait.doc_loaded(timeout=20, raise_err=False)
error_mes = self.tab.eles(
'xpath://div[@id="auth-error-message-box"]',
timeout=10,
)
if len(error_mes) > 0:
logger.warning("验证码输入错误:%s", error_mes[0].text)
self.tab.refresh()
else:
logger.info("登录成功")
return True
time.sleep(1)
else:
break
submit_btn = self.tab.eles('xpath://input[@id="auth-signin-button"]', timeout=10)
if len(submit_btn) > 0:
logger.info("点击二次登录提交按钮")
submit_btn[0].click()
except Exception:
logger.exception("登录过程中发生异常:%s", traceback.format_exc())
return False