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.
@@ -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})"