pytest-dsl-ui 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.
- pytest_dsl_ui/__init__.py +39 -0
- pytest_dsl_ui/__main__.py +106 -0
- pytest_dsl_ui/core/__init__.py +1 -0
- pytest_dsl_ui/core/auth_manager.py +248 -0
- pytest_dsl_ui/core/browser_manager.py +261 -0
- pytest_dsl_ui/core/element_locator.py +844 -0
- pytest_dsl_ui/core/element_locator_improved.py +619 -0
- pytest_dsl_ui/core/page_context.py +225 -0
- pytest_dsl_ui/examples/test_compound_locator.py +99 -0
- pytest_dsl_ui/examples/test_converted_locators.py +126 -0
- pytest_dsl_ui/examples/test_locator.py +33 -0
- pytest_dsl_ui/keywords/__init__.py +27 -0
- pytest_dsl_ui/keywords/assertion_keywords.py +1558 -0
- pytest_dsl_ui/keywords/auth_keywords.py +695 -0
- pytest_dsl_ui/keywords/browser_keywords.py +417 -0
- pytest_dsl_ui/keywords/captcha_keywords.py +245 -0
- pytest_dsl_ui/keywords/capture_keywords.py +419 -0
- pytest_dsl_ui/keywords/element_keywords.py +364 -0
- pytest_dsl_ui/keywords/element_keywords_extended.py +396 -0
- pytest_dsl_ui/keywords/navigation_keywords.py +351 -0
- pytest_dsl_ui/keywords/network_keywords.py +813 -0
- pytest_dsl_ui/utils/__init__.py +1 -0
- pytest_dsl_ui/utils/helpers.py +312 -0
- pytest_dsl_ui/utils/playwright_converter.py +546 -0
- pytest_dsl_ui-0.1.0.dist-info/METADATA +220 -0
- pytest_dsl_ui-0.1.0.dist-info/RECORD +29 -0
- pytest_dsl_ui-0.1.0.dist-info/WHEEL +5 -0
- pytest_dsl_ui-0.1.0.dist-info/entry_points.txt +6 -0
- pytest_dsl_ui-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""pytest-dsl-ui: Playwright-based UI automation keywords for pytest-dsl
|
|
2
|
+
|
|
3
|
+
这个包为pytest-dsl框架提供基于Playwright的UI自动化测试关键字。
|
|
4
|
+
通过entry_points机制自动集成到pytest-dsl中。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
__version__ = "0.1.0"
|
|
8
|
+
__author__ = "Chen Shuanglin"
|
|
9
|
+
|
|
10
|
+
from pytest_dsl.core.keyword_manager import keyword_manager
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def register_keywords(keyword_manager_instance=None):
|
|
14
|
+
"""注册所有UI关键字到关键字管理器
|
|
15
|
+
|
|
16
|
+
这个函数会被pytest-dsl的插件发现机制自动调用。
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
keyword_manager_instance: 关键字管理器实例,如果为None则使用全局实例
|
|
20
|
+
"""
|
|
21
|
+
if keyword_manager_instance is None:
|
|
22
|
+
keyword_manager_instance = keyword_manager
|
|
23
|
+
|
|
24
|
+
# 导入所有关键字模块,触发关键字注册
|
|
25
|
+
try:
|
|
26
|
+
from . import keywords
|
|
27
|
+
print("pytest-dsl-ui: 已成功加载UI自动化关键字")
|
|
28
|
+
except ImportError as e:
|
|
29
|
+
print(f"pytest-dsl-ui: 加载关键字时出错: {e}")
|
|
30
|
+
raise
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# 当模块被直接导入时,自动注册关键字
|
|
34
|
+
# 这确保了即使没有调用register_keywords函数,关键字也会被注册
|
|
35
|
+
try:
|
|
36
|
+
from . import keywords
|
|
37
|
+
except ImportError:
|
|
38
|
+
# 在某些情况下(如安装时),可能无法导入依赖
|
|
39
|
+
pass
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
pytest-dsl-ui 包的主入口点
|
|
4
|
+
|
|
5
|
+
支持通过 python -m pytest_dsl_ui 运行
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import sys
|
|
9
|
+
import argparse
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def show_help():
|
|
14
|
+
"""显示帮助信息"""
|
|
15
|
+
help_text = """
|
|
16
|
+
pytest-dsl-ui 工具集
|
|
17
|
+
|
|
18
|
+
用法:
|
|
19
|
+
python -m pytest_dsl_ui <command> [options]
|
|
20
|
+
|
|
21
|
+
命令:
|
|
22
|
+
convert - 转换Playwright脚本为DSL格式
|
|
23
|
+
help - 显示帮助信息
|
|
24
|
+
|
|
25
|
+
示例:
|
|
26
|
+
# 转换Playwright脚本
|
|
27
|
+
python -m pytest_dsl_ui convert script.py output.dsl
|
|
28
|
+
|
|
29
|
+
# 显示帮助
|
|
30
|
+
python -m pytest_dsl_ui help
|
|
31
|
+
"""
|
|
32
|
+
print(help_text.strip())
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def convert_command(args):
|
|
36
|
+
"""处理转换命令"""
|
|
37
|
+
if len(args) < 1:
|
|
38
|
+
print("错误: convert命令需要输入文件")
|
|
39
|
+
print("用法: python -m pytest_dsl_ui convert <input_file> [output_file]")
|
|
40
|
+
return 1
|
|
41
|
+
|
|
42
|
+
# 导入转换器
|
|
43
|
+
try:
|
|
44
|
+
from .utils.playwright_converter import PlaywrightToDSLConverter
|
|
45
|
+
except ImportError:
|
|
46
|
+
print("错误: 无法导入playwright_converter模块")
|
|
47
|
+
return 1
|
|
48
|
+
|
|
49
|
+
input_file = args[0]
|
|
50
|
+
output_file = args[1] if len(args) > 1 else None
|
|
51
|
+
|
|
52
|
+
# 检查输入文件
|
|
53
|
+
input_path = Path(input_file)
|
|
54
|
+
if not input_path.exists():
|
|
55
|
+
print(f"错误: 输入文件 {input_path} 不存在")
|
|
56
|
+
return 1
|
|
57
|
+
|
|
58
|
+
try:
|
|
59
|
+
# 读取文件
|
|
60
|
+
with open(input_path, 'r', encoding='utf-8') as f:
|
|
61
|
+
script_content = f.read()
|
|
62
|
+
|
|
63
|
+
# 转换
|
|
64
|
+
converter = PlaywrightToDSLConverter()
|
|
65
|
+
dsl_content = converter.convert_script(script_content)
|
|
66
|
+
|
|
67
|
+
# 输出结果
|
|
68
|
+
if output_file:
|
|
69
|
+
output_path = Path(output_file)
|
|
70
|
+
with open(output_path, 'w', encoding='utf-8') as f:
|
|
71
|
+
f.write(dsl_content)
|
|
72
|
+
print(f"转换完成! DSL文件已保存到: {output_path}")
|
|
73
|
+
else:
|
|
74
|
+
print("转换结果:")
|
|
75
|
+
print("=" * 50)
|
|
76
|
+
print(dsl_content)
|
|
77
|
+
|
|
78
|
+
return 0
|
|
79
|
+
|
|
80
|
+
except Exception as e:
|
|
81
|
+
print(f"错误: {e}")
|
|
82
|
+
return 1
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def main():
|
|
86
|
+
"""主函数"""
|
|
87
|
+
if len(sys.argv) < 2:
|
|
88
|
+
show_help()
|
|
89
|
+
return 0
|
|
90
|
+
|
|
91
|
+
command = sys.argv[1]
|
|
92
|
+
args = sys.argv[2:]
|
|
93
|
+
|
|
94
|
+
if command == 'convert':
|
|
95
|
+
return convert_command(args)
|
|
96
|
+
elif command == 'help':
|
|
97
|
+
show_help()
|
|
98
|
+
return 0
|
|
99
|
+
else:
|
|
100
|
+
print(f"错误: 未知命令 '{command}'")
|
|
101
|
+
show_help()
|
|
102
|
+
return 1
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
if __name__ == '__main__':
|
|
106
|
+
sys.exit(main())
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# Core modules for pytest-dsl-ui
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
"""认证状态管理器
|
|
2
|
+
|
|
3
|
+
基于Playwright的storage_state功能,提供登录状态的保存和重用机制。
|
|
4
|
+
参考: https://playwright.net.cn/python/docs/auth
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import logging
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Dict, Any, Optional
|
|
11
|
+
from playwright.sync_api import BrowserContext
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class AuthManager:
|
|
17
|
+
"""认证状态管理器
|
|
18
|
+
|
|
19
|
+
提供登录状态的保存、加载和重用功能。
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def __init__(self, auth_dir: str = "playwright/.auth"):
|
|
23
|
+
"""初始化认证管理器
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
auth_dir: 认证状态文件存储目录
|
|
27
|
+
"""
|
|
28
|
+
self.auth_dir = Path(auth_dir)
|
|
29
|
+
self.auth_dir.mkdir(parents=True, exist_ok=True)
|
|
30
|
+
|
|
31
|
+
# 确保.gitignore包含认证目录
|
|
32
|
+
self._ensure_gitignore()
|
|
33
|
+
|
|
34
|
+
def _ensure_gitignore(self):
|
|
35
|
+
"""确保.gitignore包含认证目录"""
|
|
36
|
+
gitignore_path = Path(".gitignore")
|
|
37
|
+
auth_pattern = str(self.auth_dir)
|
|
38
|
+
|
|
39
|
+
if gitignore_path.exists():
|
|
40
|
+
with open(gitignore_path, 'r', encoding='utf-8') as f:
|
|
41
|
+
content = f.read()
|
|
42
|
+
|
|
43
|
+
if auth_pattern not in content:
|
|
44
|
+
with open(gitignore_path, 'a', encoding='utf-8') as f:
|
|
45
|
+
f.write(f"\n# Playwright认证状态文件\n{auth_pattern}\n")
|
|
46
|
+
logger.info(f"已将 {auth_pattern} 添加到 .gitignore")
|
|
47
|
+
else:
|
|
48
|
+
with open(gitignore_path, 'w', encoding='utf-8') as f:
|
|
49
|
+
f.write(f"# Playwright认证状态文件\n{auth_pattern}\n")
|
|
50
|
+
logger.info(f"已创建 .gitignore 并添加 {auth_pattern}")
|
|
51
|
+
|
|
52
|
+
def save_auth_state(self, context: BrowserContext,
|
|
53
|
+
state_name: str,
|
|
54
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
55
|
+
include_indexed_db: bool = True) -> str:
|
|
56
|
+
"""保存认证状态
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
context: 已认证的浏览器上下文
|
|
60
|
+
state_name: 状态名称(用于标识不同的认证状态)
|
|
61
|
+
metadata: 元数据(如用户名、网站信息等)
|
|
62
|
+
include_indexed_db: 是否包含IndexedDB数据(Playwright 1.20+支持)
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
str: 状态文件路径
|
|
66
|
+
"""
|
|
67
|
+
try:
|
|
68
|
+
# 生成状态文件路径
|
|
69
|
+
state_file = self.auth_dir / f"{state_name}.json"
|
|
70
|
+
|
|
71
|
+
# 保存存储状态 - 根据Playwright版本支持IndexedDB
|
|
72
|
+
try:
|
|
73
|
+
if include_indexed_db:
|
|
74
|
+
# 尝试使用IndexedDB选项(需要Playwright 1.20+)
|
|
75
|
+
storage_state = context.storage_state(indexed_db=True)
|
|
76
|
+
logger.info("使用IndexedDB支持保存认证状态")
|
|
77
|
+
else:
|
|
78
|
+
storage_state = context.storage_state()
|
|
79
|
+
logger.info("使用标准方式保存认证状态")
|
|
80
|
+
except TypeError as te:
|
|
81
|
+
# 旧版本Playwright不支持indexed_db参数
|
|
82
|
+
logger.warning(f"当前Playwright版本不支持IndexedDB选项: {str(te)}")
|
|
83
|
+
storage_state = context.storage_state()
|
|
84
|
+
except Exception as e:
|
|
85
|
+
logger.error(f"获取存储状态失败: {str(e)}")
|
|
86
|
+
raise
|
|
87
|
+
|
|
88
|
+
# 添加元数据
|
|
89
|
+
if metadata:
|
|
90
|
+
storage_state["metadata"] = metadata
|
|
91
|
+
|
|
92
|
+
# 确保保存时间戳
|
|
93
|
+
storage_state["saved_at"] = self._get_timestamp()
|
|
94
|
+
|
|
95
|
+
# 添加IndexedDB标识
|
|
96
|
+
storage_state["include_indexed_db"] = include_indexed_db
|
|
97
|
+
|
|
98
|
+
# 添加统计信息到元数据
|
|
99
|
+
stats = {
|
|
100
|
+
"cookies_count": len(storage_state.get("cookies", [])),
|
|
101
|
+
"origins_count": len(storage_state.get("origins", [])),
|
|
102
|
+
"file_path": str(state_file)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if "metadata" not in storage_state:
|
|
106
|
+
storage_state["metadata"] = {}
|
|
107
|
+
storage_state["metadata"].update(stats)
|
|
108
|
+
|
|
109
|
+
# 写入文件
|
|
110
|
+
with open(state_file, 'w', encoding='utf-8') as f:
|
|
111
|
+
json.dump(storage_state, f, indent=2, ensure_ascii=False)
|
|
112
|
+
|
|
113
|
+
logger.info(f"认证状态已保存: {state_file} (cookies: {stats['cookies_count']}, origins: {stats['origins_count']})")
|
|
114
|
+
return str(state_file)
|
|
115
|
+
|
|
116
|
+
except Exception as e:
|
|
117
|
+
logger.error(f"保存认证状态失败: {str(e)}")
|
|
118
|
+
raise
|
|
119
|
+
|
|
120
|
+
def load_auth_state(self, state_name: str) -> Optional[Dict[str, Any]]:
|
|
121
|
+
"""加载认证状态
|
|
122
|
+
|
|
123
|
+
Args:
|
|
124
|
+
state_name: 状态名称
|
|
125
|
+
|
|
126
|
+
Returns:
|
|
127
|
+
Optional[Dict[str, Any]]: 认证状态数据,如果不存在则返回None
|
|
128
|
+
"""
|
|
129
|
+
try:
|
|
130
|
+
state_file = self.auth_dir / f"{state_name}.json"
|
|
131
|
+
|
|
132
|
+
if not state_file.exists():
|
|
133
|
+
logger.warning(f"认证状态文件不存在: {state_file}")
|
|
134
|
+
return None
|
|
135
|
+
|
|
136
|
+
with open(state_file, 'r', encoding='utf-8') as f:
|
|
137
|
+
storage_state = json.load(f)
|
|
138
|
+
|
|
139
|
+
logger.info(f"认证状态已加载: {state_file}")
|
|
140
|
+
return storage_state
|
|
141
|
+
|
|
142
|
+
except Exception as e:
|
|
143
|
+
logger.error(f"加载认证状态失败: {str(e)}")
|
|
144
|
+
return None
|
|
145
|
+
|
|
146
|
+
def has_auth_state(self, state_name: str) -> bool:
|
|
147
|
+
"""检查认证状态是否存在
|
|
148
|
+
|
|
149
|
+
Args:
|
|
150
|
+
state_name: 状态名称
|
|
151
|
+
|
|
152
|
+
Returns:
|
|
153
|
+
bool: 是否存在
|
|
154
|
+
"""
|
|
155
|
+
state_file = self.auth_dir / f"{state_name}.json"
|
|
156
|
+
return state_file.exists()
|
|
157
|
+
|
|
158
|
+
def delete_auth_state(self, state_name: str) -> bool:
|
|
159
|
+
"""删除认证状态
|
|
160
|
+
|
|
161
|
+
Args:
|
|
162
|
+
state_name: 状态名称
|
|
163
|
+
|
|
164
|
+
Returns:
|
|
165
|
+
bool: 删除是否成功
|
|
166
|
+
"""
|
|
167
|
+
try:
|
|
168
|
+
state_file = self.auth_dir / f"{state_name}.json"
|
|
169
|
+
|
|
170
|
+
if state_file.exists():
|
|
171
|
+
state_file.unlink()
|
|
172
|
+
logger.info(f"认证状态已删除: {state_file}")
|
|
173
|
+
return True
|
|
174
|
+
else:
|
|
175
|
+
logger.warning(f"认证状态文件不存在: {state_file}")
|
|
176
|
+
return False
|
|
177
|
+
|
|
178
|
+
except Exception as e:
|
|
179
|
+
logger.error(f"删除认证状态失败: {str(e)}")
|
|
180
|
+
return False
|
|
181
|
+
|
|
182
|
+
def list_auth_states(self) -> Dict[str, Dict[str, Any]]:
|
|
183
|
+
"""列出所有认证状态
|
|
184
|
+
|
|
185
|
+
Returns:
|
|
186
|
+
Dict[str, Dict[str, Any]]: 状态名称到元数据的映射
|
|
187
|
+
"""
|
|
188
|
+
states = {}
|
|
189
|
+
|
|
190
|
+
try:
|
|
191
|
+
for state_file in self.auth_dir.glob("*.json"):
|
|
192
|
+
state_name = state_file.stem
|
|
193
|
+
|
|
194
|
+
try:
|
|
195
|
+
with open(state_file, 'r', encoding='utf-8') as f:
|
|
196
|
+
data = json.load(f)
|
|
197
|
+
|
|
198
|
+
metadata = data.get("metadata", {})
|
|
199
|
+
metadata["file_path"] = str(state_file)
|
|
200
|
+
metadata["file_size"] = state_file.stat().st_size
|
|
201
|
+
|
|
202
|
+
states[state_name] = metadata
|
|
203
|
+
|
|
204
|
+
except Exception as e:
|
|
205
|
+
logger.warning(f"无法读取状态文件 {state_file}: {str(e)}")
|
|
206
|
+
|
|
207
|
+
except Exception as e:
|
|
208
|
+
logger.error(f"列出认证状态失败: {str(e)}")
|
|
209
|
+
|
|
210
|
+
return states
|
|
211
|
+
|
|
212
|
+
def cleanup_expired_states(self, max_age_days: int = 30):
|
|
213
|
+
"""清理过期的认证状态
|
|
214
|
+
|
|
215
|
+
Args:
|
|
216
|
+
max_age_days: 最大保存天数
|
|
217
|
+
"""
|
|
218
|
+
import time
|
|
219
|
+
|
|
220
|
+
try:
|
|
221
|
+
current_time = time.time()
|
|
222
|
+
cutoff_time = current_time - (max_age_days * 24 * 60 * 60)
|
|
223
|
+
|
|
224
|
+
deleted_count = 0
|
|
225
|
+
for state_file in self.auth_dir.glob("*.json"):
|
|
226
|
+
file_mtime = state_file.stat().st_mtime
|
|
227
|
+
|
|
228
|
+
if file_mtime < cutoff_time:
|
|
229
|
+
state_file.unlink()
|
|
230
|
+
deleted_count += 1
|
|
231
|
+
logger.info(f"已删除过期认证状态: {state_file}")
|
|
232
|
+
|
|
233
|
+
if deleted_count > 0:
|
|
234
|
+
logger.info(f"清理完成,删除了 {deleted_count} 个过期认证状态")
|
|
235
|
+
else:
|
|
236
|
+
logger.info("没有过期的认证状态需要清理")
|
|
237
|
+
|
|
238
|
+
except Exception as e:
|
|
239
|
+
logger.error(f"清理过期认证状态失败: {str(e)}")
|
|
240
|
+
|
|
241
|
+
def _get_timestamp(self) -> str:
|
|
242
|
+
"""获取当前时间戳"""
|
|
243
|
+
import datetime
|
|
244
|
+
return datetime.datetime.now().isoformat()
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
# 全局认证管理器实例
|
|
248
|
+
auth_manager = AuthManager()
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
"""浏览器管理器
|
|
2
|
+
|
|
3
|
+
负责管理Playwright浏览器实例的生命周期,包括启动、关闭和配置。
|
|
4
|
+
支持多浏览器、多页面的管理。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
from typing import Dict, Optional
|
|
9
|
+
from playwright.sync_api import (
|
|
10
|
+
sync_playwright, Browser, BrowserContext, Page, Playwright
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class BrowserManager:
|
|
17
|
+
"""浏览器管理器
|
|
18
|
+
|
|
19
|
+
管理Playwright浏览器实例,支持多浏览器类型和多页面。
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def __init__(self):
|
|
23
|
+
"""初始化浏览器管理器"""
|
|
24
|
+
self.playwright: Optional[Playwright] = None
|
|
25
|
+
self.browsers: Dict[str, Browser] = {}
|
|
26
|
+
self.contexts: Dict[str, BrowserContext] = {}
|
|
27
|
+
self.pages: Dict[str, Page] = {}
|
|
28
|
+
self.current_browser: Optional[str] = None
|
|
29
|
+
self.current_context: Optional[str] = None
|
|
30
|
+
self.current_page: Optional[str] = None
|
|
31
|
+
|
|
32
|
+
def _ensure_playwright(self):
|
|
33
|
+
"""确保Playwright实例已启动"""
|
|
34
|
+
if self.playwright is None:
|
|
35
|
+
self.playwright = sync_playwright().start()
|
|
36
|
+
|
|
37
|
+
def launch_browser(self, browser_type: str = "chromium", **config) -> str:
|
|
38
|
+
"""启动浏览器
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
browser_type: 浏览器类型 (chromium, firefox, webkit)
|
|
42
|
+
**config: 浏览器启动配置
|
|
43
|
+
|
|
44
|
+
Returns:
|
|
45
|
+
str: 浏览器ID
|
|
46
|
+
"""
|
|
47
|
+
self._ensure_playwright()
|
|
48
|
+
|
|
49
|
+
# 获取浏览器类型
|
|
50
|
+
if browser_type.lower() == "chromium":
|
|
51
|
+
browser_launcher = self.playwright.chromium
|
|
52
|
+
elif browser_type.lower() == "firefox":
|
|
53
|
+
browser_launcher = self.playwright.firefox
|
|
54
|
+
elif browser_type.lower() == "webkit":
|
|
55
|
+
browser_launcher = self.playwright.webkit
|
|
56
|
+
else:
|
|
57
|
+
raise ValueError(f"不支持的浏览器类型: {browser_type}")
|
|
58
|
+
|
|
59
|
+
# 处理配置参数
|
|
60
|
+
launch_config = {
|
|
61
|
+
"headless": config.get("headless", True),
|
|
62
|
+
"slow_mo": config.get("slow_mo", 0),
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
# 添加启动参数
|
|
66
|
+
if "args" in config:
|
|
67
|
+
launch_config["args"] = config["args"]
|
|
68
|
+
|
|
69
|
+
# 添加可执行文件路径
|
|
70
|
+
if "executable_path" in config:
|
|
71
|
+
launch_config["executable_path"] = config["executable_path"]
|
|
72
|
+
|
|
73
|
+
# 启动浏览器
|
|
74
|
+
browser = browser_launcher.launch(**launch_config)
|
|
75
|
+
|
|
76
|
+
# 生成浏览器ID
|
|
77
|
+
browser_id = f"{browser_type}_{len(self.browsers)}"
|
|
78
|
+
self.browsers[browser_id] = browser
|
|
79
|
+
self.current_browser = browser_id
|
|
80
|
+
|
|
81
|
+
logger.info(f"已启动浏览器: {browser_id}")
|
|
82
|
+
return browser_id
|
|
83
|
+
|
|
84
|
+
def create_context(self, browser_id: Optional[str] = None, **config) -> str:
|
|
85
|
+
"""创建浏览器上下文
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
browser_id: 浏览器ID,如果为None则使用当前浏览器
|
|
89
|
+
**config: 上下文配置,支持storage_state参数加载认证状态
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
str: 上下文ID
|
|
93
|
+
"""
|
|
94
|
+
if browser_id is None:
|
|
95
|
+
browser_id = self.current_browser
|
|
96
|
+
|
|
97
|
+
if browser_id is None:
|
|
98
|
+
raise ValueError("没有可用的浏览器实例")
|
|
99
|
+
|
|
100
|
+
if browser_id not in self.browsers:
|
|
101
|
+
raise ValueError(f"浏览器 {browser_id} 不存在")
|
|
102
|
+
|
|
103
|
+
browser = self.browsers[browser_id]
|
|
104
|
+
|
|
105
|
+
# 处理配置参数
|
|
106
|
+
context_config = {}
|
|
107
|
+
|
|
108
|
+
# 视口配置
|
|
109
|
+
if "viewport" in config:
|
|
110
|
+
context_config["viewport"] = config["viewport"]
|
|
111
|
+
elif "width" in config and "height" in config:
|
|
112
|
+
context_config["viewport"] = {
|
|
113
|
+
"width": config["width"],
|
|
114
|
+
"height": config["height"]
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
# 用户代理
|
|
118
|
+
if "user_agent" in config:
|
|
119
|
+
context_config["user_agent"] = config["user_agent"]
|
|
120
|
+
|
|
121
|
+
# 地理位置
|
|
122
|
+
if "geolocation" in config:
|
|
123
|
+
context_config["geolocation"] = config["geolocation"]
|
|
124
|
+
|
|
125
|
+
# 权限
|
|
126
|
+
if "permissions" in config:
|
|
127
|
+
context_config["permissions"] = config["permissions"]
|
|
128
|
+
|
|
129
|
+
# SSL证书忽略配置
|
|
130
|
+
ignore_https_errors = config.get("ignore_https_errors", False)
|
|
131
|
+
if ignore_https_errors:
|
|
132
|
+
context_config["ignore_https_errors"] = True
|
|
133
|
+
|
|
134
|
+
# 认证状态配置
|
|
135
|
+
if "storage_state" in config:
|
|
136
|
+
context_config["storage_state"] = config["storage_state"]
|
|
137
|
+
logger.info("将使用认证状态创建浏览器上下文")
|
|
138
|
+
|
|
139
|
+
context = browser.new_context(**context_config)
|
|
140
|
+
|
|
141
|
+
# 生成上下文ID
|
|
142
|
+
context_id = f"{browser_id}_ctx_{len(self.contexts)}"
|
|
143
|
+
self.contexts[context_id] = context
|
|
144
|
+
self.current_context = context_id
|
|
145
|
+
|
|
146
|
+
# 标记上下文是否支持HTTPS证书错误忽略
|
|
147
|
+
if context_config.get('ignore_https_errors', False):
|
|
148
|
+
setattr(context, '_ignore_https_errors', True)
|
|
149
|
+
|
|
150
|
+
logger.info(f"已创建浏览器上下文: {context_id}")
|
|
151
|
+
return context_id
|
|
152
|
+
|
|
153
|
+
def create_page(self, context_id: Optional[str] = None) -> str:
|
|
154
|
+
"""创建页面
|
|
155
|
+
|
|
156
|
+
Args:
|
|
157
|
+
context_id: 上下文ID,如果为None则使用当前上下文
|
|
158
|
+
|
|
159
|
+
Returns:
|
|
160
|
+
str: 页面ID
|
|
161
|
+
"""
|
|
162
|
+
if context_id is None:
|
|
163
|
+
context_id = self.current_context
|
|
164
|
+
|
|
165
|
+
if context_id is None:
|
|
166
|
+
raise ValueError("没有可用的浏览器上下文")
|
|
167
|
+
|
|
168
|
+
if context_id not in self.contexts:
|
|
169
|
+
raise ValueError(f"浏览器上下文 {context_id} 不存在")
|
|
170
|
+
|
|
171
|
+
context = self.contexts[context_id]
|
|
172
|
+
page = context.new_page()
|
|
173
|
+
|
|
174
|
+
# 生成页面ID
|
|
175
|
+
page_id = f"{context_id}_page_{len(self.pages)}"
|
|
176
|
+
self.pages[page_id] = page
|
|
177
|
+
self.current_page = page_id
|
|
178
|
+
|
|
179
|
+
logger.info(f"已创建页面: {page_id}")
|
|
180
|
+
return page_id
|
|
181
|
+
|
|
182
|
+
def get_current_page(self) -> Page:
|
|
183
|
+
"""获取当前页面实例"""
|
|
184
|
+
if self.current_page is None or self.current_page not in self.pages:
|
|
185
|
+
raise ValueError("没有可用的页面实例")
|
|
186
|
+
return self.pages[self.current_page]
|
|
187
|
+
|
|
188
|
+
def get_page(self, page_id: str) -> Page:
|
|
189
|
+
"""获取指定页面实例"""
|
|
190
|
+
if page_id not in self.pages:
|
|
191
|
+
raise ValueError(f"页面 {page_id} 不存在")
|
|
192
|
+
return self.pages[page_id]
|
|
193
|
+
|
|
194
|
+
def switch_page(self, page_id: str):
|
|
195
|
+
"""切换到指定页面"""
|
|
196
|
+
if page_id not in self.pages:
|
|
197
|
+
raise ValueError(f"页面 {page_id} 不存在")
|
|
198
|
+
self.current_page = page_id
|
|
199
|
+
logger.info(f"已切换到页面: {page_id}")
|
|
200
|
+
|
|
201
|
+
def close_browser(self, browser_id: Optional[str] = None):
|
|
202
|
+
"""关闭浏览器
|
|
203
|
+
|
|
204
|
+
Args:
|
|
205
|
+
browser_id: 浏览器ID,如果为None则关闭当前浏览器
|
|
206
|
+
"""
|
|
207
|
+
if browser_id is None:
|
|
208
|
+
browser_id = self.current_browser
|
|
209
|
+
|
|
210
|
+
if browser_id is None:
|
|
211
|
+
logger.warning("没有可关闭的浏览器实例")
|
|
212
|
+
return
|
|
213
|
+
|
|
214
|
+
if browser_id in self.browsers:
|
|
215
|
+
browser = self.browsers[browser_id]
|
|
216
|
+
browser.close()
|
|
217
|
+
del self.browsers[browser_id]
|
|
218
|
+
|
|
219
|
+
# 清理相关的上下文和页面
|
|
220
|
+
contexts_to_remove = [
|
|
221
|
+
ctx_id for ctx_id in self.contexts.keys()
|
|
222
|
+
if ctx_id.startswith(browser_id)
|
|
223
|
+
]
|
|
224
|
+
for ctx_id in contexts_to_remove:
|
|
225
|
+
del self.contexts[ctx_id]
|
|
226
|
+
|
|
227
|
+
pages_to_remove = [
|
|
228
|
+
page_id for page_id in self.pages.keys()
|
|
229
|
+
if page_id.startswith(browser_id)
|
|
230
|
+
]
|
|
231
|
+
for page_id in pages_to_remove:
|
|
232
|
+
del self.pages[page_id]
|
|
233
|
+
|
|
234
|
+
# 更新当前引用
|
|
235
|
+
if self.current_browser == browser_id:
|
|
236
|
+
self.current_browser = None
|
|
237
|
+
self.current_context = None
|
|
238
|
+
self.current_page = None
|
|
239
|
+
|
|
240
|
+
logger.info(f"已关闭浏览器: {browser_id}")
|
|
241
|
+
|
|
242
|
+
def close_all(self):
|
|
243
|
+
"""关闭所有浏览器实例"""
|
|
244
|
+
for browser in self.browsers.values():
|
|
245
|
+
browser.close()
|
|
246
|
+
|
|
247
|
+
if self.playwright:
|
|
248
|
+
self.playwright.stop()
|
|
249
|
+
|
|
250
|
+
self.browsers.clear()
|
|
251
|
+
self.contexts.clear()
|
|
252
|
+
self.pages.clear()
|
|
253
|
+
self.playwright = None
|
|
254
|
+
self.current_browser = None
|
|
255
|
+
self.current_context = None
|
|
256
|
+
self.current_page = None
|
|
257
|
+
logger.info("已关闭所有浏览器实例")
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
# 全局浏览器管理器实例
|
|
261
|
+
browser_manager = BrowserManager()
|