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,435 @@
1
+ """主客户端模块
2
+
3
+ 提供 ZiniaoClient 主类,是 SDK 的核心接口。
4
+ """
5
+
6
+ import json
7
+ import logging
8
+ import time
9
+ import uuid
10
+ from typing import List, Dict, Optional, Union
11
+
12
+ from .config import ZiniaoConfig
13
+ from .types import Store, ConfigSource
14
+ from .http_client import HttpClient
15
+ from .process import ProcessManager
16
+ from .store import StoreManager
17
+ from .browser import BrowserSession
18
+ from .exceptions import (
19
+ ClientNotStartedError,
20
+ UnsupportedVersionError,
21
+ CoreUpdateError,
22
+ ConfigurationError
23
+ )
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+
28
+ class ZiniaoClient:
29
+ """紫鸟浏览器客户端
30
+
31
+ 这是 SDK 的主要入口类,提供所有核心功能。
32
+
33
+ 使用示例:
34
+ ```python
35
+ from yuehua_ziniao_webdriver import ZiniaoClient, ZiniaoConfig
36
+
37
+ config = ZiniaoConfig(
38
+ client_path=r"D:\ziniao\ziniao.exe",
39
+ company="企业名",
40
+ username="用户名",
41
+ password="密码"
42
+ )
43
+
44
+ client = ZiniaoClient(config)
45
+ client.start()
46
+
47
+ # 通过名称打开店铺
48
+ session = client.open_store_by_name("我的店铺")
49
+
50
+ client.stop()
51
+ ```
52
+ """
53
+
54
+ def __init__(self, config: ConfigSource) -> None:
55
+ """初始化紫鸟客户端
56
+
57
+ Args:
58
+ config: 配置对象、配置字典或配置文件路径
59
+
60
+ Raises:
61
+ ConfigurationError: 配置无效
62
+ """
63
+ # 解析配置
64
+ self.config = self._parse_config(config)
65
+
66
+ # 初始化组件
67
+ self.http_client = HttpClient(
68
+ port=self.config.socket_port,
69
+ timeout=self.config.request_timeout,
70
+ max_retries=self.config.max_retries,
71
+ retry_delay=self.config.retry_delay
72
+ )
73
+
74
+ self.process_manager = ProcessManager(
75
+ client_path=self.config.client_path,
76
+ socket_port=self.config.socket_port,
77
+ version=self.config.version
78
+ )
79
+
80
+ self.store_manager = StoreManager(
81
+ http_client=self.http_client,
82
+ user_info=self.config.get_user_info()
83
+ )
84
+
85
+ self._started = False
86
+
87
+ logger.info(f"紫鸟客户端已初始化:{self.config}")
88
+
89
+ def _parse_config(self, config: ConfigSource) -> ZiniaoConfig:
90
+ """解析配置
91
+
92
+ Args:
93
+ config: 配置源
94
+
95
+ Returns:
96
+ ZiniaoConfig: 解析后的配置对象
97
+
98
+ Raises:
99
+ ConfigurationError: 配置解析失败
100
+ """
101
+ if isinstance(config, ZiniaoConfig):
102
+ return config
103
+
104
+ elif isinstance(config, dict):
105
+ return ZiniaoConfig.from_dict(config) # type: ignore
106
+
107
+ elif isinstance(config, str):
108
+ # 假设是文件路径
109
+ return ZiniaoConfig.from_json_file(config)
110
+
111
+ else:
112
+ raise ConfigurationError(
113
+ f"不支持的配置类型:{type(config)}",
114
+ {"type": str(type(config))}
115
+ )
116
+
117
+ def start(
118
+ self,
119
+ kill_existing: bool = False,
120
+ update_core: bool = False,
121
+ wait_time: int = 5
122
+ ) -> None:
123
+ """启动紫鸟客户端
124
+
125
+ Args:
126
+ kill_existing: 是否自动关闭已存在的进程,默认 False(会询问用户)
127
+ update_core: 是否在启动后更新内核,默认 False
128
+ wait_time: 启动后等待时间(秒),默认 5
129
+
130
+ Raises:
131
+ BrowserStartError: 启动失败
132
+ """
133
+ if self._started:
134
+ logger.warning("客户端已启动,跳过重复启动")
135
+ return
136
+
137
+ logger.info("=== 启动紫鸟客户端 ===")
138
+
139
+ # 关闭已存在的进程
140
+ if not self.process_manager.kill_existing_process(force=kill_existing):
141
+ logger.info("用户取消启动")
142
+ return
143
+
144
+ # 启动客户端
145
+ self.process_manager.start_browser(wait_time=wait_time)
146
+
147
+ self._started = True
148
+
149
+ # 更新内核(如果需要)
150
+ if update_core:
151
+ logger.info("=== 更新浏览器内核 ===")
152
+ self.update_core()
153
+
154
+ def stop(self) -> None:
155
+ """关闭紫鸟客户端
156
+
157
+ 会先关闭客户端进程,然后发送退出命令。
158
+ """
159
+ if not self._started:
160
+ logger.warning("客户端未启动,无需关闭")
161
+ return
162
+
163
+ logger.info("=== 关闭紫鸟客户端 ===")
164
+
165
+ try:
166
+ # 发送退出命令
167
+ self._send_exit()
168
+
169
+ # 等待一下让客户端处理
170
+ time.sleep(2)
171
+
172
+ # 终止进程(如果还在运行)
173
+ self.process_manager.terminate()
174
+
175
+ except Exception as e:
176
+ logger.error(f"关闭客户端时出错:{e}")
177
+
178
+ finally:
179
+ self._started = False
180
+ logger.info("客户端已关闭")
181
+
182
+ def update_core(self, max_wait_time: int = 300) -> None:
183
+ """更新浏览器内核
184
+
185
+ 需要客户端版本 5.285.7 以上。
186
+ 会循环调用直到更新完成或超时。
187
+
188
+ Args:
189
+ max_wait_time: 最大等待时间(秒),默认 300(5分钟)
190
+
191
+ Raises:
192
+ ClientNotStartedError: 客户端未启动
193
+ UnsupportedVersionError: 版本不支持
194
+ CoreUpdateError: 更新失败
195
+ """
196
+ if not self._started:
197
+ raise ClientNotStartedError()
198
+
199
+ logger.info("开始更新内核...")
200
+
201
+ start_time = time.time()
202
+
203
+ while True:
204
+ # 检查超时
205
+ if time.time() - start_time > max_wait_time:
206
+ raise CoreUpdateError(
207
+ f"更新内核超时({max_wait_time} 秒)",
208
+ {"max_wait_time": max_wait_time}
209
+ )
210
+
211
+ # 发送更新请求
212
+ data = {
213
+ "action": "updateCore",
214
+ "requestId": str(uuid.uuid4()),
215
+ }
216
+ data.update(self.config.get_user_info())
217
+
218
+ result = self.http_client.send_request(data, retry_on_none=True)
219
+
220
+ if result is None:
221
+ logger.info("等待客户端启动...")
222
+ time.sleep(2)
223
+ continue
224
+
225
+ status_code = result.get("statusCode")
226
+
227
+ if status_code is None or status_code == -10003:
228
+ raise UnsupportedVersionError(
229
+ "updateCore",
230
+ required_version="5.285.7"
231
+ )
232
+
233
+ elif status_code == 0:
234
+ logger.info("内核更新完成")
235
+ return
236
+
237
+ else:
238
+ logger.info(
239
+ f"等待更新内核:{json.dumps(result, ensure_ascii=False)}"
240
+ )
241
+ time.sleep(2)
242
+
243
+ def get_store_list(self, use_cache: bool = False) -> List[Store]:
244
+ """获取店铺列表
245
+
246
+ Args:
247
+ use_cache: 是否使用缓存,默认 False
248
+
249
+ Returns:
250
+ List[Store]: 店铺列表
251
+
252
+ Raises:
253
+ ClientNotStartedError: 客户端未启动
254
+ StoreOperationError: 获取失败
255
+ """
256
+ if not self._started:
257
+ raise ClientNotStartedError()
258
+
259
+ return self.store_manager.get_store_list(use_cache=use_cache)
260
+
261
+ def find_stores_by_name(
262
+ self,
263
+ name: str,
264
+ exact_match: bool = False
265
+ ) -> List[Store]:
266
+ """通过名称搜索店铺
267
+
268
+ Args:
269
+ name: 店铺名称
270
+ exact_match: 是否精确匹配,False 则模糊匹配
271
+
272
+ Returns:
273
+ List[Store]: 匹配的店铺列表
274
+
275
+ Raises:
276
+ ClientNotStartedError: 客户端未启动
277
+ """
278
+ if not self._started:
279
+ raise ClientNotStartedError()
280
+
281
+ return self.store_manager.find_stores_by_name(
282
+ name,
283
+ exact_match_mode=exact_match
284
+ )
285
+
286
+ def open_store(
287
+ self,
288
+ store_id: str,
289
+ **options
290
+ ) -> BrowserSession:
291
+ """通过店铺 ID 打开店铺
292
+
293
+ Args:
294
+ store_id: 店铺 ID 或 OAuth 标识
295
+ **options: 打开店铺的选项
296
+
297
+ Returns:
298
+ BrowserSession: 浏览器会话对象
299
+
300
+ Raises:
301
+ ClientNotStartedError: 客户端未启动
302
+ StoreOperationError: 打开失败
303
+ """
304
+ if not self._started:
305
+ raise ClientNotStartedError()
306
+
307
+ return self.store_manager.open_store(store_id, **options)
308
+
309
+ def open_store_by_name(
310
+ self,
311
+ store_name: str,
312
+ exact_match: bool = False,
313
+ **options
314
+ ) -> BrowserSession:
315
+ """通过店铺名称打开店铺
316
+
317
+ Args:
318
+ store_name: 店铺名称(支持模糊匹配)
319
+ exact_match: 是否精确匹配,默认 False(模糊匹配)
320
+ **options: 打开店铺的选项
321
+
322
+ Returns:
323
+ BrowserSession: 浏览器会话对象
324
+
325
+ Raises:
326
+ ClientNotStartedError: 客户端未启动
327
+ StoreNotFoundError: 未找到匹配的店铺
328
+ MultipleStoresFoundError: 找到多个匹配的店铺
329
+ StoreOperationError: 打开失败
330
+ """
331
+ if not self._started:
332
+ raise ClientNotStartedError()
333
+
334
+ return self.store_manager.open_store_by_name(
335
+ store_name,
336
+ exact_match_mode=exact_match,
337
+ **options
338
+ )
339
+
340
+ def open_stores_by_names(
341
+ self,
342
+ store_names: List[str],
343
+ max_workers: int = 3,
344
+ exact_match: bool = False,
345
+ **options
346
+ ) -> Dict[str, BrowserSession]:
347
+ """并发打开多个店铺(通过店铺名称)
348
+
349
+ Args:
350
+ store_names: 店铺名称列表
351
+ max_workers: 最大并发数,默认 3
352
+ exact_match: 是否精确匹配,默认 False
353
+ **options: 打开店铺的选项
354
+
355
+ Returns:
356
+ Dict[str, BrowserSession]: 店铺名称到浏览器会话的映射
357
+
358
+ Note:
359
+ 如果某个店铺打开失败,会记录错误但不会中断其他店铺的打开。
360
+ 返回的字典中只包含成功打开的店铺。
361
+
362
+ Raises:
363
+ ClientNotStartedError: 客户端未启动
364
+ """
365
+ if not self._started:
366
+ raise ClientNotStartedError()
367
+
368
+ return self.store_manager.open_stores_by_names(
369
+ store_names,
370
+ max_workers=max_workers,
371
+ exact_match_mode=exact_match,
372
+ **options
373
+ )
374
+
375
+ def close_store(self, store_id: str) -> None:
376
+ """关闭店铺
377
+
378
+ Args:
379
+ store_id: 店铺 ID/OAuth
380
+
381
+ Raises:
382
+ ClientNotStartedError: 客户端未启动
383
+ StoreOperationError: 关闭失败
384
+ """
385
+ if not self._started:
386
+ raise ClientNotStartedError()
387
+
388
+ self.store_manager.close_store(store_id)
389
+
390
+ def _send_exit(self) -> None:
391
+ """发送退出命令到客户端"""
392
+ data = {
393
+ "action": "exit",
394
+ "requestId": str(uuid.uuid4())
395
+ }
396
+ data.update(self.config.get_user_info())
397
+
398
+ logger.debug("发送退出命令...")
399
+
400
+ try:
401
+ self.http_client.send_request(data)
402
+ except Exception as e:
403
+ logger.warning(f"发送退出命令失败:{e}")
404
+
405
+ def is_started(self) -> bool:
406
+ """检查客户端是否已启动
407
+
408
+ Returns:
409
+ bool: 已启动返回 True
410
+ """
411
+ return self._started
412
+
413
+ def is_process_running(self) -> bool:
414
+ """检查客户端进程是否正在运行
415
+
416
+ Returns:
417
+ bool: 运行中返回 True
418
+ """
419
+ return self.process_manager.is_running()
420
+
421
+ def __enter__(self) -> "ZiniaoClient":
422
+ """上下文管理器入口,自动启动客户端"""
423
+ if not self._started:
424
+ self.start()
425
+ return self
426
+
427
+ def __exit__(self, exc_type, exc_val, exc_tb) -> None:
428
+ """上下文管理器退出,自动关闭客户端"""
429
+ self.stop()
430
+
431
+ def __repr__(self) -> str:
432
+ return (
433
+ f"ZiniaoClient(version='{self.config.version}', "
434
+ f"port={self.config.socket_port}, started={self._started})"
435
+ )
@@ -0,0 +1,265 @@
1
+ """配置管理模块
2
+
3
+ 提供配置类和配置加载功能。
4
+ """
5
+
6
+ import json
7
+ import os
8
+ from dataclasses import dataclass, field, asdict
9
+ from pathlib import Path
10
+ from typing import Optional, Dict, Any
11
+
12
+ from .types import VersionType, ConfigDict
13
+ from .exceptions import ConfigurationError
14
+
15
+
16
+ @dataclass
17
+ class ZiniaoConfig:
18
+ """紫鸟浏览器客户端配置类
19
+
20
+ 使用 dataclass 提供类型提示和默认值。
21
+
22
+ Attributes:
23
+ client_path: 紫鸟客户端可执行文件路径(必需)
24
+ socket_port: 客户端通信端口,默认 16851
25
+ company: 企业名称(用于登录)
26
+ username: 用户名(用于登录)
27
+ password: 密码(用于登录)
28
+ version: 客户端版本,"v5" 或 "v6",默认 "v6"
29
+ request_timeout: HTTP 请求超时时间(秒),默认 120
30
+ max_retries: 失败重试次数,默认 3
31
+ retry_delay: 重试延迟时间(秒),默认 2.0
32
+ """
33
+
34
+ client_path: str
35
+ socket_port: int = 16851
36
+ company: str = ""
37
+ username: str = ""
38
+ password: str = ""
39
+ version: VersionType = "v6"
40
+ request_timeout: int = 120
41
+ max_retries: int = 3
42
+ retry_delay: float = 2.0
43
+
44
+ def __post_init__(self) -> None:
45
+ """初始化后的验证"""
46
+ self.validate()
47
+
48
+ def validate(self) -> None:
49
+ """验证配置有效性
50
+
51
+ Raises:
52
+ ConfigurationError: 当配置无效时
53
+ """
54
+ # 验证必需字段
55
+ if not self.client_path:
56
+ raise ConfigurationError("client_path 不能为空")
57
+
58
+ # 验证客户端路径是否存在
59
+ if not os.path.exists(self.client_path):
60
+ raise ConfigurationError(
61
+ f"客户端路径不存在:{self.client_path}",
62
+ {"path": self.client_path}
63
+ )
64
+
65
+ # 验证端口范围
66
+ if not (1024 <= self.socket_port <= 65535):
67
+ raise ConfigurationError(
68
+ f"端口号必须在 1024-65535 之间,当前值:{self.socket_port}",
69
+ {"port": self.socket_port}
70
+ )
71
+
72
+ # 验证版本
73
+ if self.version not in ("v5", "v6"):
74
+ raise ConfigurationError(
75
+ f"version 必须是 'v5' 或 'v6',当前值:{self.version}",
76
+ {"version": self.version}
77
+ )
78
+
79
+ # 验证超时时间
80
+ if self.request_timeout <= 0:
81
+ raise ConfigurationError(
82
+ f"request_timeout 必须大于 0,当前值:{self.request_timeout}",
83
+ {"timeout": self.request_timeout}
84
+ )
85
+
86
+ # 验证重试次数
87
+ if self.max_retries < 0:
88
+ raise ConfigurationError(
89
+ f"max_retries 不能为负数,当前值:{self.max_retries}",
90
+ {"retries": self.max_retries}
91
+ )
92
+
93
+ # 验证重试延迟
94
+ if self.retry_delay < 0:
95
+ raise ConfigurationError(
96
+ f"retry_delay 不能为负数,当前值:{self.retry_delay}",
97
+ {"delay": self.retry_delay}
98
+ )
99
+
100
+ @classmethod
101
+ def from_dict(cls, config_dict: ConfigDict) -> "ZiniaoConfig":
102
+ """从字典创建配置对象
103
+
104
+ Args:
105
+ config_dict: 配置字典
106
+
107
+ Returns:
108
+ ZiniaoConfig: 配置对象
109
+
110
+ Raises:
111
+ ConfigurationError: 当配置无效时
112
+ """
113
+ try:
114
+ return cls(**config_dict) # type: ignore
115
+ except TypeError as e:
116
+ raise ConfigurationError(f"配置字典格式错误:{e}", {"dict": config_dict})
117
+
118
+ @classmethod
119
+ def from_json_file(cls, file_path: str) -> "ZiniaoConfig":
120
+ """从 JSON 文件加载配置
121
+
122
+ Args:
123
+ file_path: JSON 配置文件路径
124
+
125
+ Returns:
126
+ ZiniaoConfig: 配置对象
127
+
128
+ Raises:
129
+ ConfigurationError: 当文件不存在或格式错误时
130
+ """
131
+ path = Path(file_path)
132
+
133
+ if not path.exists():
134
+ raise ConfigurationError(
135
+ f"配置文件不存在:{file_path}",
136
+ {"path": file_path}
137
+ )
138
+
139
+ try:
140
+ with open(path, "r", encoding="utf-8") as f:
141
+ config_dict = json.load(f)
142
+ return cls.from_dict(config_dict)
143
+ except json.JSONDecodeError as e:
144
+ raise ConfigurationError(
145
+ f"JSON 文件格式错误:{e}",
146
+ {"path": file_path, "error": str(e)}
147
+ )
148
+ except Exception as e:
149
+ raise ConfigurationError(
150
+ f"加载配置文件失败:{e}",
151
+ {"path": file_path, "error": str(e)}
152
+ )
153
+
154
+ @classmethod
155
+ def from_env(cls, prefix: str = "ZINIAO_") -> "ZiniaoConfig":
156
+ """从环境变量加载配置
157
+
158
+ 环境变量命名规则:前缀 + 大写字段名
159
+ 例如:ZINIAO_CLIENT_PATH, ZINIAO_SOCKET_PORT
160
+
161
+ Args:
162
+ prefix: 环境变量前缀,默认 "ZINIAO_"
163
+
164
+ Returns:
165
+ ZiniaoConfig: 配置对象
166
+
167
+ Raises:
168
+ ConfigurationError: 当必需的环境变量不存在时
169
+ """
170
+ config_dict: Dict[str, Any] = {}
171
+
172
+ # 字段映射:Python 字段名 -> 环境变量名
173
+ field_mapping = {
174
+ "client_path": f"{prefix}CLIENT_PATH",
175
+ "socket_port": f"{prefix}SOCKET_PORT",
176
+ "company": f"{prefix}COMPANY",
177
+ "username": f"{prefix}USERNAME",
178
+ "password": f"{prefix}PASSWORD",
179
+ "version": f"{prefix}VERSION",
180
+ "request_timeout": f"{prefix}REQUEST_TIMEOUT",
181
+ "max_retries": f"{prefix}MAX_RETRIES",
182
+ "retry_delay": f"{prefix}RETRY_DELAY",
183
+ }
184
+
185
+ # 从环境变量读取
186
+ for field_name, env_name in field_mapping.items():
187
+ env_value = os.getenv(env_name)
188
+ if env_value is not None:
189
+ # 类型转换
190
+ if field_name == "socket_port":
191
+ config_dict[field_name] = int(env_value)
192
+ elif field_name == "request_timeout":
193
+ config_dict[field_name] = int(env_value)
194
+ elif field_name == "max_retries":
195
+ config_dict[field_name] = int(env_value)
196
+ elif field_name == "retry_delay":
197
+ config_dict[field_name] = float(env_value)
198
+ else:
199
+ config_dict[field_name] = env_value
200
+
201
+ # 检查必需字段
202
+ if "client_path" not in config_dict:
203
+ raise ConfigurationError(
204
+ f"环境变量 {prefix}CLIENT_PATH 未设置",
205
+ {"required_env": f"{prefix}CLIENT_PATH"}
206
+ )
207
+
208
+ return cls.from_dict(config_dict) # type: ignore
209
+
210
+ def to_dict(self) -> ConfigDict:
211
+ """将配置转换为字典
212
+
213
+ Returns:
214
+ ConfigDict: 配置字典
215
+ """
216
+ return asdict(self) # type: ignore
217
+
218
+ def to_json_file(self, file_path: str, indent: int = 2) -> None:
219
+ """将配置保存到 JSON 文件
220
+
221
+ Args:
222
+ file_path: 目标文件路径
223
+ indent: JSON 缩进空格数,默认 2
224
+
225
+ Raises:
226
+ ConfigurationError: 当保存失败时
227
+ """
228
+ try:
229
+ path = Path(file_path)
230
+ path.parent.mkdir(parents=True, exist_ok=True)
231
+
232
+ with open(path, "w", encoding="utf-8") as f:
233
+ json.dump(self.to_dict(), f, ensure_ascii=False, indent=indent)
234
+ except Exception as e:
235
+ raise ConfigurationError(
236
+ f"保存配置文件失败:{e}",
237
+ {"path": file_path, "error": str(e)}
238
+ )
239
+
240
+ def get_user_info(self) -> Dict[str, str]:
241
+ """获取用户登录信息字典
242
+
243
+ 用于 HTTP 请求。
244
+
245
+ Returns:
246
+ Dict[str, str]: 包含 company, username, password 的字典
247
+ """
248
+ return {
249
+ "company": self.company,
250
+ "username": self.username,
251
+ "password": self.password
252
+ }
253
+
254
+ def __repr__(self) -> str:
255
+ """安全的字符串表示(隐藏密码)"""
256
+ return (
257
+ f"ZiniaoConfig("
258
+ f"client_path='{self.client_path}', "
259
+ f"socket_port={self.socket_port}, "
260
+ f"company='{self.company}', "
261
+ f"username='{self.username}', "
262
+ f"password='***', "
263
+ f"version='{self.version}'"
264
+ f")"
265
+ )