yuehua-ziniao-webdriver 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- yuehua_ziniao_webdriver/__init__.py +149 -0
- yuehua_ziniao_webdriver/browser.py +258 -0
- yuehua_ziniao_webdriver/client.py +435 -0
- yuehua_ziniao_webdriver/config.py +265 -0
- yuehua_ziniao_webdriver/exceptions.py +237 -0
- yuehua_ziniao_webdriver/http_client.py +214 -0
- yuehua_ziniao_webdriver/process.py +270 -0
- yuehua_ziniao_webdriver/store.py +429 -0
- yuehua_ziniao_webdriver/types.py +172 -0
- yuehua_ziniao_webdriver/utils.py +310 -0
- yuehua_ziniao_webdriver-0.1.0.dist-info/METADATA +438 -0
- yuehua_ziniao_webdriver-0.1.0.dist-info/RECORD +15 -0
- yuehua_ziniao_webdriver-0.1.0.dist-info/WHEEL +5 -0
- yuehua_ziniao_webdriver-0.1.0.dist-info/licenses/LICENSE +21 -0
- yuehua_ziniao_webdriver-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
"""进程管理模块
|
|
2
|
+
|
|
3
|
+
提供紫鸟客户端进程的启动、关闭和管理功能。
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import os
|
|
7
|
+
import time
|
|
8
|
+
import subprocess
|
|
9
|
+
import logging
|
|
10
|
+
from typing import List, Optional
|
|
11
|
+
|
|
12
|
+
from .types import VersionType
|
|
13
|
+
from .utils import is_windows, is_mac, is_linux
|
|
14
|
+
from .exceptions import BrowserStartError, ProcessError
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ProcessManager:
|
|
20
|
+
"""进程管理器
|
|
21
|
+
|
|
22
|
+
负责紫鸟客户端进程的启动和关闭。
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
client_path: str,
|
|
28
|
+
socket_port: int,
|
|
29
|
+
version: VersionType = "v6"
|
|
30
|
+
) -> None:
|
|
31
|
+
"""初始化进程管理器
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
client_path: 客户端可执行文件路径
|
|
35
|
+
socket_port: 通信端口
|
|
36
|
+
version: 客户端版本
|
|
37
|
+
"""
|
|
38
|
+
self.client_path = client_path
|
|
39
|
+
self.socket_port = socket_port
|
|
40
|
+
self.version = version
|
|
41
|
+
self.process: Optional[subprocess.Popen] = None
|
|
42
|
+
|
|
43
|
+
logger.debug(
|
|
44
|
+
f"初始化进程管理器:client_path={client_path}, "
|
|
45
|
+
f"socket_port={socket_port}, version={version}"
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
def kill_existing_process(self, force: bool = False) -> bool:
|
|
49
|
+
"""关闭已存在的紫鸟客户端进程
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
force: 是否强制关闭(不询问用户),默认 False
|
|
53
|
+
|
|
54
|
+
Returns:
|
|
55
|
+
bool: 成功关闭返回 True,用户取消返回 False
|
|
56
|
+
|
|
57
|
+
Raises:
|
|
58
|
+
ProcessError: 进程关闭失败
|
|
59
|
+
"""
|
|
60
|
+
# 确认是否继续
|
|
61
|
+
if not force:
|
|
62
|
+
try:
|
|
63
|
+
confirmation = input(
|
|
64
|
+
"在启动之前,需要先关闭紫鸟浏览器的主进程,"
|
|
65
|
+
"确定要终止进程吗?(y/n): "
|
|
66
|
+
)
|
|
67
|
+
if confirmation.lower() != 'y':
|
|
68
|
+
logger.info("用户取消关闭进程")
|
|
69
|
+
return False
|
|
70
|
+
except (EOFError, KeyboardInterrupt):
|
|
71
|
+
logger.info("用户取消关闭进程")
|
|
72
|
+
return False
|
|
73
|
+
|
|
74
|
+
try:
|
|
75
|
+
if is_windows():
|
|
76
|
+
process_name = self._get_windows_process_name()
|
|
77
|
+
logger.info(f"关闭 Windows 进程:{process_name}")
|
|
78
|
+
result = os.system(f'taskkill /f /t /im {process_name}')
|
|
79
|
+
time.sleep(3)
|
|
80
|
+
|
|
81
|
+
if result != 0:
|
|
82
|
+
logger.warning(f"关闭进程返回非零状态码:{result}")
|
|
83
|
+
|
|
84
|
+
elif is_mac():
|
|
85
|
+
logger.info("关闭 macOS 进程:ziniao")
|
|
86
|
+
os.system('killall ziniao')
|
|
87
|
+
time.sleep(3)
|
|
88
|
+
|
|
89
|
+
elif is_linux():
|
|
90
|
+
logger.info("关闭 Linux 进程:ziniaobrowser")
|
|
91
|
+
os.system('killall ziniaobrowser')
|
|
92
|
+
time.sleep(3)
|
|
93
|
+
|
|
94
|
+
else:
|
|
95
|
+
raise ProcessError("不支持的操作系统平台")
|
|
96
|
+
|
|
97
|
+
logger.info("成功关闭已存在的进程")
|
|
98
|
+
return True
|
|
99
|
+
|
|
100
|
+
except Exception as e:
|
|
101
|
+
error_msg = f"关闭进程失败:{e}"
|
|
102
|
+
logger.error(error_msg)
|
|
103
|
+
raise ProcessError(error_msg, {"error": str(e)})
|
|
104
|
+
|
|
105
|
+
def _get_windows_process_name(self) -> str:
|
|
106
|
+
"""获取 Windows 平台的进程名称
|
|
107
|
+
|
|
108
|
+
Returns:
|
|
109
|
+
str: 进程名称
|
|
110
|
+
"""
|
|
111
|
+
if self.version == "v5":
|
|
112
|
+
return "SuperBrowser.exe"
|
|
113
|
+
else:
|
|
114
|
+
return "ziniao.exe"
|
|
115
|
+
|
|
116
|
+
def start_browser(self, wait_time: int = 5) -> None:
|
|
117
|
+
"""启动紫鸟客户端
|
|
118
|
+
|
|
119
|
+
Args:
|
|
120
|
+
wait_time: 启动后等待时间(秒),默认 5 秒
|
|
121
|
+
|
|
122
|
+
Raises:
|
|
123
|
+
BrowserStartError: 启动失败
|
|
124
|
+
"""
|
|
125
|
+
try:
|
|
126
|
+
cmd = self._build_start_command()
|
|
127
|
+
|
|
128
|
+
logger.info(f"启动客户端:{' '.join(cmd)}")
|
|
129
|
+
|
|
130
|
+
# 启动进程
|
|
131
|
+
self.process = subprocess.Popen(
|
|
132
|
+
cmd,
|
|
133
|
+
stdout=subprocess.PIPE,
|
|
134
|
+
stderr=subprocess.PIPE
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
logger.info(f"客户端进程已启动,PID: {self.process.pid}")
|
|
138
|
+
|
|
139
|
+
# 等待客户端启动完成
|
|
140
|
+
logger.debug(f"等待 {wait_time} 秒让客户端完成启动...")
|
|
141
|
+
time.sleep(wait_time)
|
|
142
|
+
|
|
143
|
+
# 检查进程是否仍在运行
|
|
144
|
+
if self.process.poll() is not None:
|
|
145
|
+
# 进程已退出
|
|
146
|
+
stdout, stderr = self.process.communicate()
|
|
147
|
+
error_msg = (
|
|
148
|
+
f"客户端启动后立即退出,退出码:{self.process.returncode}\n"
|
|
149
|
+
f"stdout: {stdout.decode('utf-8', errors='ignore')}\n"
|
|
150
|
+
f"stderr: {stderr.decode('utf-8', errors='ignore')}"
|
|
151
|
+
)
|
|
152
|
+
logger.error(error_msg)
|
|
153
|
+
raise BrowserStartError(error_msg)
|
|
154
|
+
|
|
155
|
+
logger.info("客户端启动成功")
|
|
156
|
+
|
|
157
|
+
except BrowserStartError:
|
|
158
|
+
raise
|
|
159
|
+
except Exception as e:
|
|
160
|
+
error_msg = f"启动客户端失败:{e}"
|
|
161
|
+
logger.error(error_msg)
|
|
162
|
+
raise BrowserStartError(error_msg, {"error": str(e)})
|
|
163
|
+
|
|
164
|
+
def _build_start_command(self) -> List[str]:
|
|
165
|
+
"""构建启动命令
|
|
166
|
+
|
|
167
|
+
Returns:
|
|
168
|
+
List[str]: 命令参数列表
|
|
169
|
+
|
|
170
|
+
Raises:
|
|
171
|
+
ProcessError: 不支持的平台
|
|
172
|
+
"""
|
|
173
|
+
port_str = str(self.socket_port)
|
|
174
|
+
|
|
175
|
+
if is_windows():
|
|
176
|
+
return [
|
|
177
|
+
self.client_path,
|
|
178
|
+
'--run_type=web_driver',
|
|
179
|
+
'--ipc_type=http',
|
|
180
|
+
f'--port={port_str}'
|
|
181
|
+
]
|
|
182
|
+
|
|
183
|
+
elif is_mac():
|
|
184
|
+
return [
|
|
185
|
+
'open',
|
|
186
|
+
'-a',
|
|
187
|
+
self.client_path,
|
|
188
|
+
'--args',
|
|
189
|
+
'--run_type=web_driver',
|
|
190
|
+
'--ipc_type=http',
|
|
191
|
+
f'--port={port_str}'
|
|
192
|
+
]
|
|
193
|
+
|
|
194
|
+
elif is_linux():
|
|
195
|
+
return [
|
|
196
|
+
self.client_path,
|
|
197
|
+
'--no-sandbox',
|
|
198
|
+
'--run_type=web_driver',
|
|
199
|
+
'--ipc_type=http',
|
|
200
|
+
f'--port={port_str}'
|
|
201
|
+
]
|
|
202
|
+
|
|
203
|
+
else:
|
|
204
|
+
raise ProcessError("不支持的操作系统平台")
|
|
205
|
+
|
|
206
|
+
def is_running(self) -> bool:
|
|
207
|
+
"""检查客户端进程是否正在运行
|
|
208
|
+
|
|
209
|
+
Returns:
|
|
210
|
+
bool: 运行中返回 True
|
|
211
|
+
"""
|
|
212
|
+
if self.process is None:
|
|
213
|
+
return False
|
|
214
|
+
|
|
215
|
+
return self.process.poll() is None
|
|
216
|
+
|
|
217
|
+
def get_pid(self) -> Optional[int]:
|
|
218
|
+
"""获取进程 PID
|
|
219
|
+
|
|
220
|
+
Returns:
|
|
221
|
+
Optional[int]: PID,如果进程未启动返回 None
|
|
222
|
+
"""
|
|
223
|
+
if self.process is None:
|
|
224
|
+
return None
|
|
225
|
+
return self.process.pid
|
|
226
|
+
|
|
227
|
+
def terminate(self, wait_timeout: int = 10) -> bool:
|
|
228
|
+
"""终止客户端进程
|
|
229
|
+
|
|
230
|
+
Args:
|
|
231
|
+
wait_timeout: 等待进程终止的超时时间(秒)
|
|
232
|
+
|
|
233
|
+
Returns:
|
|
234
|
+
bool: 成功终止返回 True
|
|
235
|
+
"""
|
|
236
|
+
if self.process is None:
|
|
237
|
+
logger.debug("进程未启动,无需终止")
|
|
238
|
+
return True
|
|
239
|
+
|
|
240
|
+
if not self.is_running():
|
|
241
|
+
logger.debug("进程已退出,无需终止")
|
|
242
|
+
return True
|
|
243
|
+
|
|
244
|
+
try:
|
|
245
|
+
logger.info(f"终止进程 PID: {self.process.pid}")
|
|
246
|
+
self.process.terminate()
|
|
247
|
+
|
|
248
|
+
# 等待进程退出
|
|
249
|
+
try:
|
|
250
|
+
self.process.wait(timeout=wait_timeout)
|
|
251
|
+
logger.info("进程已正常终止")
|
|
252
|
+
return True
|
|
253
|
+
except subprocess.TimeoutExpired:
|
|
254
|
+
# 强制杀死
|
|
255
|
+
logger.warning(f"进程 {wait_timeout} 秒内未退出,强制杀死")
|
|
256
|
+
self.process.kill()
|
|
257
|
+
self.process.wait()
|
|
258
|
+
logger.info("进程已强制杀死")
|
|
259
|
+
return True
|
|
260
|
+
|
|
261
|
+
except Exception as e:
|
|
262
|
+
logger.error(f"终止进程失败:{e}")
|
|
263
|
+
return False
|
|
264
|
+
|
|
265
|
+
def __repr__(self) -> str:
|
|
266
|
+
return (
|
|
267
|
+
f"ProcessManager(client_path='{self.client_path}', "
|
|
268
|
+
f"socket_port={self.socket_port}, version='{self.version}', "
|
|
269
|
+
f"running={self.is_running()})"
|
|
270
|
+
)
|
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
"""店铺管理模块
|
|
2
|
+
|
|
3
|
+
提供店铺的打开、关闭、列表获取和搜索功能。
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
import uuid
|
|
9
|
+
from typing import List, Optional, Dict, Any
|
|
10
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
11
|
+
|
|
12
|
+
from .types import Store, StoreOpenOptions, BrowserStartResult
|
|
13
|
+
from .http_client import HttpClient
|
|
14
|
+
from .browser import BrowserSession
|
|
15
|
+
from .utils import fuzzy_match, exact_match
|
|
16
|
+
from .exceptions import (
|
|
17
|
+
StoreNotFoundError,
|
|
18
|
+
MultipleStoresFoundError,
|
|
19
|
+
StoreOperationError,
|
|
20
|
+
UnsupportedVersionError
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class StoreManager:
|
|
27
|
+
"""店铺管理器
|
|
28
|
+
|
|
29
|
+
负责店铺的各种操作。
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(
|
|
33
|
+
self,
|
|
34
|
+
http_client: HttpClient,
|
|
35
|
+
user_info: Dict[str, str]
|
|
36
|
+
) -> None:
|
|
37
|
+
"""初始化店铺管理器
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
http_client: HTTP 客户端
|
|
41
|
+
user_info: 用户信息字典(company, username, password)
|
|
42
|
+
"""
|
|
43
|
+
self.http_client = http_client
|
|
44
|
+
self.user_info = user_info
|
|
45
|
+
self._store_list_cache: Optional[List[Store]] = None
|
|
46
|
+
|
|
47
|
+
logger.debug("初始化店铺管理器")
|
|
48
|
+
|
|
49
|
+
def get_store_list(self, use_cache: bool = False) -> List[Store]:
|
|
50
|
+
"""获取店铺列表
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
use_cache: 是否使用缓存,默认 False
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
List[Store]: 店铺列表
|
|
57
|
+
|
|
58
|
+
Raises:
|
|
59
|
+
StoreOperationError: 获取失败
|
|
60
|
+
"""
|
|
61
|
+
# 如果使用缓存且缓存存在
|
|
62
|
+
if use_cache and self._store_list_cache is not None:
|
|
63
|
+
logger.debug("使用缓存的店铺列表")
|
|
64
|
+
return self._store_list_cache
|
|
65
|
+
|
|
66
|
+
request_id = str(uuid.uuid4())
|
|
67
|
+
data = {
|
|
68
|
+
"action": "getBrowserList",
|
|
69
|
+
"requestId": request_id
|
|
70
|
+
}
|
|
71
|
+
data.update(self.user_info)
|
|
72
|
+
|
|
73
|
+
logger.info("获取店铺列表...")
|
|
74
|
+
|
|
75
|
+
result = self.http_client.send_request(data)
|
|
76
|
+
|
|
77
|
+
if result is None:
|
|
78
|
+
raise StoreOperationError(
|
|
79
|
+
"获取",
|
|
80
|
+
"all",
|
|
81
|
+
message="HTTP 请求返回 None"
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
status_code = result.get("statusCode")
|
|
85
|
+
|
|
86
|
+
if status_code == 0:
|
|
87
|
+
browser_list = result.get("browserList", [])
|
|
88
|
+
logger.info(f"成功获取店铺列表,共 {len(browser_list)} 个店铺")
|
|
89
|
+
|
|
90
|
+
# 更新缓存
|
|
91
|
+
self._store_list_cache = browser_list
|
|
92
|
+
|
|
93
|
+
return browser_list
|
|
94
|
+
else:
|
|
95
|
+
error_msg = result.get("message", "未知错误")
|
|
96
|
+
logger.error(f"获取店铺列表失败:statusCode={status_code}, message={error_msg}")
|
|
97
|
+
raise StoreOperationError(
|
|
98
|
+
"获取列表",
|
|
99
|
+
"all",
|
|
100
|
+
status_code=status_code,
|
|
101
|
+
message=error_msg
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
def find_stores_by_name(
|
|
105
|
+
self,
|
|
106
|
+
name: str,
|
|
107
|
+
exact_match_mode: bool = False,
|
|
108
|
+
use_cache: bool = True
|
|
109
|
+
) -> List[Store]:
|
|
110
|
+
"""通过名称搜索店铺
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
name: 店铺名称
|
|
114
|
+
exact_match_mode: 是否精确匹配,False 则模糊匹配
|
|
115
|
+
use_cache: 是否使用缓存的店铺列表,默认 True
|
|
116
|
+
|
|
117
|
+
Returns:
|
|
118
|
+
List[Store]: 匹配的店铺列表
|
|
119
|
+
"""
|
|
120
|
+
logger.debug(
|
|
121
|
+
f"搜索店铺:name='{name}', exact_match={exact_match_mode}"
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
# 获取店铺列表
|
|
125
|
+
store_list = self.get_store_list(use_cache=use_cache)
|
|
126
|
+
|
|
127
|
+
# 搜索匹配的店铺
|
|
128
|
+
matched_stores: List[Store] = []
|
|
129
|
+
|
|
130
|
+
for store in store_list:
|
|
131
|
+
store_name = store.get("browserName", "")
|
|
132
|
+
|
|
133
|
+
if exact_match_mode:
|
|
134
|
+
if exact_match(store_name, name):
|
|
135
|
+
matched_stores.append(store)
|
|
136
|
+
else:
|
|
137
|
+
if fuzzy_match(store_name, name):
|
|
138
|
+
matched_stores.append(store)
|
|
139
|
+
|
|
140
|
+
logger.debug(f"找到 {len(matched_stores)} 个匹配的店铺")
|
|
141
|
+
|
|
142
|
+
return matched_stores
|
|
143
|
+
|
|
144
|
+
def open_store(
|
|
145
|
+
self,
|
|
146
|
+
store_identifier: str,
|
|
147
|
+
**options
|
|
148
|
+
) -> BrowserSession:
|
|
149
|
+
"""打开店铺
|
|
150
|
+
|
|
151
|
+
Args:
|
|
152
|
+
store_identifier: 店铺标识(browserOauth 或 browserId)
|
|
153
|
+
**options: 打开店铺的选项(参见 StoreOpenOptions)
|
|
154
|
+
|
|
155
|
+
Returns:
|
|
156
|
+
BrowserSession: 浏览器会话对象
|
|
157
|
+
|
|
158
|
+
Raises:
|
|
159
|
+
StoreOperationError: 打开失败
|
|
160
|
+
"""
|
|
161
|
+
request_id = str(uuid.uuid4())
|
|
162
|
+
|
|
163
|
+
# 构建请求数据
|
|
164
|
+
data: Dict[str, Any] = {
|
|
165
|
+
"action": "startBrowser",
|
|
166
|
+
"isWaitPluginUpdate": options.get("isWaitPluginUpdate", 0),
|
|
167
|
+
"isHeadless": options.get("isHeadless", 0),
|
|
168
|
+
"requestId": request_id,
|
|
169
|
+
"isWebDriverReadOnlyMode": options.get("isWebDriverReadOnlyMode", 0),
|
|
170
|
+
"cookieTypeLoad": options.get("cookieTypeLoad", 0),
|
|
171
|
+
"cookieTypeSave": options.get("cookieTypeSave", 0),
|
|
172
|
+
"runMode": options.get("runMode", "1"),
|
|
173
|
+
"isLoadUserPlugin": options.get("isLoadUserPlugin", False),
|
|
174
|
+
"pluginIdType": options.get("pluginIdType", 1),
|
|
175
|
+
"privacyMode": options.get("privacyMode", 0),
|
|
176
|
+
}
|
|
177
|
+
data.update(self.user_info)
|
|
178
|
+
|
|
179
|
+
# 确定使用 browserId 还是 browserOauth
|
|
180
|
+
if store_identifier.isdigit():
|
|
181
|
+
data["browserId"] = store_identifier
|
|
182
|
+
else:
|
|
183
|
+
data["browserOauth"] = store_identifier
|
|
184
|
+
|
|
185
|
+
# 注入 JS 信息(如果提供)
|
|
186
|
+
js_info = options.get("jsInfo", "")
|
|
187
|
+
if len(str(js_info)) > 2:
|
|
188
|
+
data["injectJsInfo"] = json.dumps(js_info)
|
|
189
|
+
|
|
190
|
+
logger.info(f"打开店铺:{store_identifier}")
|
|
191
|
+
|
|
192
|
+
# 发送请求
|
|
193
|
+
result = self.http_client.send_request(data)
|
|
194
|
+
|
|
195
|
+
if result is None:
|
|
196
|
+
raise StoreOperationError(
|
|
197
|
+
"打开",
|
|
198
|
+
store_identifier,
|
|
199
|
+
message="HTTP 请求返回 None"
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
status_code = result.get("statusCode")
|
|
203
|
+
|
|
204
|
+
if status_code == 0:
|
|
205
|
+
# 打开成功
|
|
206
|
+
debugging_port = result.get("debuggingPort")
|
|
207
|
+
browser_oauth = result.get("browserOauth", store_identifier)
|
|
208
|
+
ip_check_url = result.get("ipDetectionPage")
|
|
209
|
+
launcher_page = result.get("launcherPage")
|
|
210
|
+
|
|
211
|
+
# 获取店铺名称(尝试从缓存的店铺列表中查找)
|
|
212
|
+
store_name = self._get_store_name(browser_oauth)
|
|
213
|
+
|
|
214
|
+
logger.info(
|
|
215
|
+
f"店铺打开成功:{store_name} (OAuth: {browser_oauth}, "
|
|
216
|
+
f"Port: {debugging_port})"
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
# 创建浏览器会话
|
|
220
|
+
session = BrowserSession(
|
|
221
|
+
port=debugging_port,
|
|
222
|
+
store_id=browser_oauth,
|
|
223
|
+
store_name=store_name,
|
|
224
|
+
ip_check_url=ip_check_url,
|
|
225
|
+
launcher_page=launcher_page,
|
|
226
|
+
close_callback=lambda sid: self.close_store(sid)
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
return session
|
|
230
|
+
|
|
231
|
+
else:
|
|
232
|
+
error_msg = result.get("message", "未知错误")
|
|
233
|
+
logger.error(
|
|
234
|
+
f"打开店铺失败:{store_identifier}, "
|
|
235
|
+
f"statusCode={status_code}, message={error_msg}"
|
|
236
|
+
)
|
|
237
|
+
raise StoreOperationError(
|
|
238
|
+
"打开",
|
|
239
|
+
store_identifier,
|
|
240
|
+
status_code=status_code,
|
|
241
|
+
message=error_msg
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
def open_store_by_name(
|
|
245
|
+
self,
|
|
246
|
+
store_name: str,
|
|
247
|
+
exact_match_mode: bool = False,
|
|
248
|
+
**options
|
|
249
|
+
) -> BrowserSession:
|
|
250
|
+
"""通过店铺名称打开店铺
|
|
251
|
+
|
|
252
|
+
Args:
|
|
253
|
+
store_name: 店铺名称
|
|
254
|
+
exact_match_mode: 是否精确匹配,默认 False(模糊匹配)
|
|
255
|
+
**options: 打开店铺的选项
|
|
256
|
+
|
|
257
|
+
Returns:
|
|
258
|
+
BrowserSession: 浏览器会话对象
|
|
259
|
+
|
|
260
|
+
Raises:
|
|
261
|
+
StoreNotFoundError: 未找到匹配的店铺
|
|
262
|
+
MultipleStoresFoundError: 找到多个匹配的店铺
|
|
263
|
+
StoreOperationError: 打开失败
|
|
264
|
+
"""
|
|
265
|
+
logger.info(f"通过名称打开店铺:'{store_name}'")
|
|
266
|
+
|
|
267
|
+
# 搜索店铺
|
|
268
|
+
matched_stores = self.find_stores_by_name(
|
|
269
|
+
store_name,
|
|
270
|
+
exact_match_mode=exact_match_mode
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
# 检查结果
|
|
274
|
+
if len(matched_stores) == 0:
|
|
275
|
+
raise StoreNotFoundError(store_name, "名称")
|
|
276
|
+
|
|
277
|
+
if len(matched_stores) > 1:
|
|
278
|
+
store_names = [s.get("browserName", "") for s in matched_stores]
|
|
279
|
+
raise MultipleStoresFoundError(
|
|
280
|
+
store_name,
|
|
281
|
+
len(matched_stores),
|
|
282
|
+
store_names
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
# 打开店铺
|
|
286
|
+
store = matched_stores[0]
|
|
287
|
+
store_oauth = store.get("browserOauth")
|
|
288
|
+
|
|
289
|
+
if not store_oauth:
|
|
290
|
+
raise StoreOperationError(
|
|
291
|
+
"打开",
|
|
292
|
+
store_name,
|
|
293
|
+
message="店铺 OAuth 标识为空"
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
return self.open_store(store_oauth, **options)
|
|
297
|
+
|
|
298
|
+
def open_stores_by_names(
|
|
299
|
+
self,
|
|
300
|
+
store_names: List[str],
|
|
301
|
+
max_workers: int = 3,
|
|
302
|
+
exact_match_mode: bool = False,
|
|
303
|
+
**options
|
|
304
|
+
) -> Dict[str, BrowserSession]:
|
|
305
|
+
"""并发打开多个店铺(通过店铺名称)
|
|
306
|
+
|
|
307
|
+
Args:
|
|
308
|
+
store_names: 店铺名称列表
|
|
309
|
+
max_workers: 最大并发数,默认 3
|
|
310
|
+
exact_match_mode: 是否精确匹配,默认 False
|
|
311
|
+
**options: 打开店铺的选项
|
|
312
|
+
|
|
313
|
+
Returns:
|
|
314
|
+
Dict[str, BrowserSession]: 店铺名称到浏览器会话的映射
|
|
315
|
+
|
|
316
|
+
Note:
|
|
317
|
+
如果某个店铺打开失败,会记录错误但不会中断其他店铺的打开。
|
|
318
|
+
返回的字典中只包含成功打开的店铺。
|
|
319
|
+
"""
|
|
320
|
+
logger.info(f"并发打开 {len(store_names)} 个店铺,最大并发数:{max_workers}")
|
|
321
|
+
|
|
322
|
+
sessions: Dict[str, BrowserSession] = {}
|
|
323
|
+
|
|
324
|
+
def open_single_store(name: str) -> tuple[str, Optional[BrowserSession]]:
|
|
325
|
+
"""打开单个店铺的辅助函数"""
|
|
326
|
+
try:
|
|
327
|
+
session = self.open_store_by_name(
|
|
328
|
+
name,
|
|
329
|
+
exact_match_mode=exact_match_mode,
|
|
330
|
+
**options
|
|
331
|
+
)
|
|
332
|
+
return (name, session)
|
|
333
|
+
except Exception as e:
|
|
334
|
+
logger.error(f"打开店铺失败:{name}, 错误:{e}")
|
|
335
|
+
return (name, None)
|
|
336
|
+
|
|
337
|
+
# 使用线程池并发打开
|
|
338
|
+
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
|
339
|
+
# 提交所有任务
|
|
340
|
+
futures = {
|
|
341
|
+
executor.submit(open_single_store, name): name
|
|
342
|
+
for name in store_names
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
# 收集结果
|
|
346
|
+
for future in as_completed(futures):
|
|
347
|
+
name, session = future.result()
|
|
348
|
+
if session is not None:
|
|
349
|
+
sessions[name] = session
|
|
350
|
+
logger.info(f"店铺打开成功:{name}")
|
|
351
|
+
else:
|
|
352
|
+
logger.warning(f"店铺打开失败:{name}")
|
|
353
|
+
|
|
354
|
+
logger.info(
|
|
355
|
+
f"并发打开完成:成功 {len(sessions)}/{len(store_names)} 个店铺"
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
return sessions
|
|
359
|
+
|
|
360
|
+
def close_store(self, store_id: str) -> None:
|
|
361
|
+
"""关闭店铺
|
|
362
|
+
|
|
363
|
+
Args:
|
|
364
|
+
store_id: 店铺 ID/OAuth
|
|
365
|
+
|
|
366
|
+
Raises:
|
|
367
|
+
StoreOperationError: 关闭失败
|
|
368
|
+
"""
|
|
369
|
+
request_id = str(uuid.uuid4())
|
|
370
|
+
data = {
|
|
371
|
+
"action": "stopBrowser",
|
|
372
|
+
"requestId": request_id,
|
|
373
|
+
"duplicate": 0,
|
|
374
|
+
"browserOauth": store_id
|
|
375
|
+
}
|
|
376
|
+
data.update(self.user_info)
|
|
377
|
+
|
|
378
|
+
logger.info(f"关闭店铺:{store_id}")
|
|
379
|
+
|
|
380
|
+
result = self.http_client.send_request(data)
|
|
381
|
+
|
|
382
|
+
if result is None:
|
|
383
|
+
raise StoreOperationError(
|
|
384
|
+
"关闭",
|
|
385
|
+
store_id,
|
|
386
|
+
message="HTTP 请求返回 None"
|
|
387
|
+
)
|
|
388
|
+
|
|
389
|
+
status_code = result.get("statusCode")
|
|
390
|
+
|
|
391
|
+
if status_code == 0:
|
|
392
|
+
logger.info(f"店铺关闭成功:{store_id}")
|
|
393
|
+
else:
|
|
394
|
+
error_msg = result.get("message", "未知错误")
|
|
395
|
+
logger.error(
|
|
396
|
+
f"关闭店铺失败:{store_id}, "
|
|
397
|
+
f"statusCode={status_code}, message={error_msg}"
|
|
398
|
+
)
|
|
399
|
+
raise StoreOperationError(
|
|
400
|
+
"关闭",
|
|
401
|
+
store_id,
|
|
402
|
+
status_code=status_code,
|
|
403
|
+
message=error_msg
|
|
404
|
+
)
|
|
405
|
+
|
|
406
|
+
def _get_store_name(self, store_oauth: str) -> str:
|
|
407
|
+
"""从缓存中获取店铺名称
|
|
408
|
+
|
|
409
|
+
Args:
|
|
410
|
+
store_oauth: 店铺 OAuth 标识
|
|
411
|
+
|
|
412
|
+
Returns:
|
|
413
|
+
str: 店铺名称,未找到返回 OAuth
|
|
414
|
+
"""
|
|
415
|
+
if self._store_list_cache:
|
|
416
|
+
for store in self._store_list_cache:
|
|
417
|
+
if store.get("browserOauth") == store_oauth:
|
|
418
|
+
return store.get("browserName", store_oauth)
|
|
419
|
+
|
|
420
|
+
return store_oauth
|
|
421
|
+
|
|
422
|
+
def clear_cache(self) -> None:
|
|
423
|
+
"""清除店铺列表缓存"""
|
|
424
|
+
logger.debug("清除店铺列表缓存")
|
|
425
|
+
self._store_list_cache = None
|
|
426
|
+
|
|
427
|
+
def __repr__(self) -> str:
|
|
428
|
+
cache_size = len(self._store_list_cache) if self._store_list_cache else 0
|
|
429
|
+
return f"StoreManager(cached_stores={cache_size})"
|