PlayWright 工具(二)

🍰 Playwright用于跨浏览器测试,支持Chrome、Firefox等,提供一个简单而强大的API模拟用户在浏览器中的交互操作。

1 方法汇总

  • 方法汇总
    • Playwright中的测试层级:Browser > Context > Page > Locator > ElementHandle。
    • Page对象:直接在整个页面范围内进行元素查找和判断。
      • page.is_hidden(selector: str)·····判断指定选择器对应的元素是否隐藏
      • page.is_visible(selector: str)····判断指定选择器对应的元素是否可见
      • page.is_enabled(selector: str)····判断指定选择器对应的元素是否可用
      • page.is_editable(selector: str)···判断指定选择器对应的元素是否可编辑
      • page.is_disabled(selector: str)···判断指定选择器对应的元素是否不可用
      • page.is_checked(selector: str)····判断指定选择器对应的单选Radio或复选Checkbox,元素是否被选中
    • Locator对象:通过Page页面对象的定位方法,例如使用page.locator(selector)来获取ElementHandle元素句柄。
      • locator.is_hidden()···············判断该定位器对象对应的元素是否隐藏
      • locator.is_visible()··············判断该定位器对象对应的元素是否可见
      • locator.is_enabled()··············判断该定位器对象对应的元素是否可用
      • locator.is_editable()·············判断该定位器对象对应的元素是否可编辑
      • locator.is_disabled()·············判断该定位器对象对应的元素是否不可用
      • locator.is_checked()··············判断该定位器对象对应的单选Radio或复选Checkbox,元素是否被选中
    • ElementHandle对象:通过使用page.query_selector()方法,来调用返回的ElementHandle元素句柄,一般不常用。
      • element_handle.is_hidden()········判断该元素句柄对象对应的元素是否隐藏
      • element_handle.is_visible()·······判断该元素句柄对象对应的元素是否可见
      • element_handle.is_enabled()·······判断该元素句柄对象对应的元素是否可用
      • element_handle.is_editable()······判断该元素句柄对象对应的元素是否可编辑
      • element_handle.is_disabled()······判断该元素句柄对象对应的元素是否不可用
      • element_handle.is_checked()·······判断该元素句柄对象对应的单选Radio或复选Checkbox,元素是否被选中
    • Expect常用的断言方法
      • expect(api_response).to_be_ok()·········断言API响应是否成功
      • Page页面断言
        • expect(page).to_have_url()··········断言页面的URL是否符合预期
        • expect(page).not_to_have_url()······断言页面的URL是否不存在
        • expect(page).to_have_title()········断言页面的标题是否符合预期
        • expect(page).not_to_have_title()····断言页面的标题是否不存在
      • expect(locator).to_be_empty()···········断言locator对应的元素是否为空
      • expect(locator).to_be_hidden()··········断言locator对应的元素是否隐藏
      • expect(locator).to_be_visible()·········断言locator对应的元素是否可见
      • expect(locator).to_be_enabled()·········断言locator对应的元素是否可用
      • expect(locator).to_be_editable()········断言locator对应的元素是否可编辑
      • expect(locator).to_be_disabled()········断言locator对应的元素是否不可用
      • expect(locator).to_be_focused()·········断言locator对应的元素是否处于焦点状态
      • expect(locator).to_be_checked()·········断言locator对应的复选框或单选框元素是否被选中
      • expect(locator).to_have_id()············断言locator对应的元素是否具有特定的id
      • expect(locator).to_have_count()·········断言locator对应的元素数量是否符合预期
      • expect(locator).to_have_value()·········断言locator对应的元素的值是否符合预期
      • expect(locator).to_have_text()··········断言locator对应的元素是否具有特定的文本
      • expect(locator).to_have_class()·········断言locator对应的元素是否具有特定的类名
      • expect(locator).to_have_css()···········断言locator对应的元素是否具有特定的CSS样式
      • expect(locator).to_have_values()········断言locator对应的元素的值集合是否符合预期
      • expect(locator).to_have_attribute()·····断言locator对应的元素是否具有特定的属性
      • expect(locator).to_have_js_property()···断言locator对应的元素是否具有特定的JavaScript属性
      • expect(locator).to_contain_text()·······断言locator对应的元素是否包含特定的文本

1-1 页面导航*

  • 页面导航
    • 异步导航(Async navigation)
      • 指等待页面完成导航的操作,页面完成导航后再执行后续的操作。
      • 可使用page.goto()page.wait_for_navigation()方法来等待页面导航完成。
      • 异步导航通常用于单一的页面导航,等待页面跳转,或加载完毕后再进行后续操作。
      • 触发的导航:指代导航到新的URL、在同一网页中刷新、后退或前进,这四种情况。
    • 多重导航(Multiple navigations)
      • 多重导航指连续进行多次页面导航的操作,每次导航都需要等待页面完成跳转或加载。
      • 可使用page.goto()page.expect_navigation()方法来明确等待特定的导航完成。
      • 通常用于需进行连续多次页面跳转的情况,如应用程序中执行多步骤操作,或测试期间模拟用户浏览。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import asyncio
from playwright.async_api import async_playwright


async def example():
async with async_playwright() as p:
browser = await p.chromium.launch(headless=False)
page = await browser.new_page()

async def on_navigation(page, **kwargs): # 监听页面导航事件
print("页面导航完成")
page.on("navigation", on_navigation) # 添加导航事件监听器
await page.goto("https://www.baidu.com/") # 导航到指定URL
try:
async with page.expect_navigation(): # 等待页面导航完成
await page.click("//*[@id='s-top-left']/a[1]")
await page.reload() # 为啥不加这行会报错???
except TimeoutError:
print("页面导航超时")
print("页面导航完成,新页面加载成功")

await browser.close() # 关闭浏览器

asyncio.run(example()) # 运行代码

1-2 高级模式

  • 高级模式
    • page.wait_for_function():用于等待指定的JavaScript函数返回true或非空结果。
    • 可用于检查页面上的某些条件是否满足,例如:等待特定元素出现或特定文本被更新。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import asyncio
from playwright.async_api import async_playwright


async def wait_for_example():
async with async_playwright() as p:
browser = await p.chromium.launch(headless=False)
page = await browser.new_page()

await page.goto("https://www.baidu.com/") # 打开页面
await page.wait_for_function( # 等待页面上的标题元素出现
"() => document.querySelector('title') !== null"
)
title_text = await page.evaluate( # 检查标题文本是否符合预期
"document.querySelector('title').textContent"
)
if title_text == "百度一下,你就知道":
print("标题文本符合预期")
else:
print("标题文本不符合预期")

await browser.close() # 关闭浏览器对象

asyncio.run(wait_for_example())

1-3 svg元素操作

  • svg元素操作
    • SVG即Scalable Vector Graphics,由W3C制定,用于描述二维矢量图形的XML(可扩展标记语言)文件格式。
    • 无法用普通的标签定位到,如$x('//svg'),只能通过name()函数定位,如$x("//*[name()='svg']")
    • 如果页面上有多个svg元素,//*[name()="svg"]将定位出全部的svg元素。
      • 可以通过父元素来进行区分,例如://*[@id="box1"]//*[name()="svg"]
      • 可使用and组合其他属性,例如://*[name()="svg" and @width="500"]
    • 定位svg上的子元素,仍然可以通过name()函数定位,如$x("//*[name()='svg']/*[name()='path']")
    • svg元素下的circle是可拖动的,例如:往右拖动100个像素,那么cx值就由原先的cx="100"变为cx=200
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
from playwright.sync_api import sync_playwright

with sync_playwright() as p:
browser = p.chromium.launch(headless=False)
page = browser.new_page()
page.goto("https://www.w3school.com.cn/svg/circle1.svg")

circle = page.locator(
"//*[name()='svg']/*[name()='circle']"
) # svg元素定位
print(circle.bounding_box())
box = circle.bounding_box()

circle.evaluate( # 添加事件监听
"node => node.addEventListener('mousedown', function()"
"{console.log('目标元素被鼠标down了');});"
)

page.mouse.move( # svg元素拖拽
x=box["x"] + box["width"] / 2,
y=box["y"] + box["height"] / 2
)
page.mouse.down()
page.mouse.move(
x=box["x"] + box["width"] / 2 + 100,
y=box["y"] + box["height"] / 2
)
page.mouse.up(button="middle")
page.pause() # F12的console查看

browser.close() # 关闭浏览器对象

1-4 日期控件输入

  • 日期控件输入
    • 输入框是日期控件时,先看能否直接输入日期,可以直接输入的情况下就不用点开了。
    • 若有readonly属性就不能直接输入,这种情况下可以使用JavaScript去掉属性再输入。

(1) 直接输入

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
from playwright.sync_api import sync_playwright

with sync_playwright() as p:
browser = p.chromium.launch(headless=False)
context = browser.new_context()
page = context.new_page()
page.goto(
r"http://wyeditor.com/layui_demo/%E6%97%A5%E6%9C%9F"
r"%E5%92%8C%E6%97%B6%E9%97%B4%E7%BB%84%E4%BB%B6.html"
)
page.locator(
"//*[@id='LayEdit_267858']"
).fill("2023-09-01") # 日期控件直接输入
page.pause()

browser.close() # 关闭浏览器对象

(2) readonly

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
from playwright.sync_api import sync_playwright

with sync_playwright() as p:
browser = p.chromium.launch(headless=False)
context = browser.new_context()
page = context.new_page()
page.goto(r"...\html\readonly.html")
page.locator("//*[@id='ymd01']").hover() # 定位悬停到具体位置
page.wait_for_timeout(1000)
js1 = "document.getElementById('ymd01').removeAttribute('readonly');"
page.evaluate(js1) # 去掉readonly属性
js2 = "document.getElementById('ymd01').value='2023-09-01 15:15:15';"
page.evaluate(js2)
page.pause()

browser.close() # 关闭浏览器对象

(3) readonly.html

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=yes">
<title>只读日期控件</title>
<style>
/* 全局重置与居中布局 */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}

body {
background: linear-gradient(145deg, #f5f7fa 0%, #e9edf5 100%);
font-family: "Segoe UI", Roboto, "Helvetica Neue", system-ui, -apple-system, sans-serif;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
margin: 0;
padding: 1.5rem;
}

/* 卡片式居中容器 */
.demo-card {
background: rgba(255, 255, 255, 0.9);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
background: #ffffff;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25), 0 4px 12px rgba(0, 0, 0, 0.08);
border-radius: 2.5rem;
padding: 2.5rem 2.8rem;
width: 100%;
max-width: 580px;
text-align: center;
transition: all 0.2s ease;
border: 1px solid rgba(255, 255, 255, 0.5);
}

.demo-card:hover {
box-shadow: 0 30px 60px -15px rgba(0, 20, 60, 0.3);
}

h2 {
font-weight: 500;
font-size: 1.8rem;
color: #1e293b;
margin-bottom: 0.35rem;
letter-spacing: -0.3px;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
}

h2 span {
background: #eef2ff;
padding: 0.15rem 0.8rem;
border-radius: 2rem;
font-size: 0.9rem;
font-weight: 600;
color: #4f46e5;
letter-spacing: 0.3px;
}

.subtitle {
color: #64748b;
font-size: 0.95rem;
margin-bottom: 2.2rem;
border-bottom: 1px dashed #d9e2ef;
padding-bottom: 1.3rem;
font-weight: 400;
display: flex;
align-items: center;
justify-content: center;
gap: 0.35rem;
}

.subtitle i {
font-style: normal;
font-size: 1.1rem;
}

/* 输入框组 - 突出只读日期控件 */
.input-wrapper {
background: #f8fafc;
border-radius: 1.5rem;
padding: 1.8rem 1.5rem;
margin: 1.2rem 0 1.8rem;
border: 1px solid #e2e8f0;
background: #ffffff;
box-shadow: inset 0 2px 6px rgba(0, 0, 0, 0.02);
}

.date-label {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
font-weight: 500;
color: #334155;
margin-bottom: 1.2rem;
font-size: 1rem;
text-transform: uppercase;
letter-spacing: 0.8px;
}

.date-label i {
font-size: 1.2rem;
color: #3b82f6;
}

/* 核心:只读日期输入框样式 */
#ymd01 {
width: 100%;
max-width: 320px;
margin: 0 auto;
display: block;
padding: 0.95rem 1.5rem;
font-size: 1.2rem;
font-weight: 500;
text-align: center;
background: #ffffff;
border: 2px solid #cbd5e1;
border-radius: 3rem;
color: #0f172a;
letter-spacing: 0.4px;
font-family: "Fira Code", "JetBrains Mono", "Courier New", monospace;
transition: all 0.2s;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
background: #f1f5f9;
cursor: default;
/* readonly 状态通常有默认样式,我们自定义以保持清晰 */
}

/* 针对只读状态的视觉提示 */
#ymd01:read-only {
background: #f8fafc;
border-color: #94a3b8;
color: #1e293b;
box-shadow: inset 0 1px 5px rgba(0, 0, 0, 0.05);
/* 保留明显的边框,并加上小锁图标感觉 */
background-image: url("data:image/svg+xml;utf8,<svg xmlns=" http://www.w3.org/2000/svg " width=" 18 " height=" 18 " viewBox=" 0 0 24 24 " fill=" none " stroke=" %23475569 " stroke-width=" 2 " stroke-linecap=" round " stroke-linejoin=" round "><rect x=" 3 " y=" 11 " width=" 18 " height=" 11 " rx=" 2 " ry=" 2 "/><path d=" M7 11V7a5 5 0 0 1 10 0v4 "/></svg>");
background-repeat: no-repeat;
background-position: right 16px center;
background-size: 18px;
padding-right: 2.8rem;
}

/* 焦点时也保持风格,但不允许编辑 */
#ymd01:focus {
outline: none;
border-color: #6366f1;
box-shadow: 0 0 0 4px rgba(99, 102, 241, 0.2);
background: #ffffff;
}

/* 说明文字区域 */
.attribute-hint {
background: #f1f4f9;
border-radius: 2rem;
padding: 0.8rem 1.2rem;
margin: 1.8rem 0 0.8rem;
font-size: 0.9rem;
color: #475569;
display: flex;
align-items: center;
justify-content: center;
gap: 0.4rem;
flex-wrap: wrap;
background: #eef2ff;
border: 1px solid #d9e2ef;
font-weight: 450;
}

.badge {
background: #4f46e5;
color: white;
padding: 0.2rem 0.9rem;
border-radius: 2rem;
font-size: 0.8rem;
font-weight: 600;
letter-spacing: 0.3px;
margin: 0 0.2rem;
}

.playwright-note {
margin-top: 1.5rem;
font-size: 0.85rem;
color: #64748b;
background: #f1f5f9;
border-radius: 1.2rem;
padding: 0.75rem 1.2rem;
text-align: center;
border: 1px solid #e2e8f0;
word-break: break-word;
}

code {
background: #1e293b;
color: #f1f5f9;
padding: 0.2rem 0.6rem;
border-radius: 0.5rem;
font-size: 0.8rem;
font-family: "Fira Code", monospace;
}

.small-icon {
font-size: 1.1rem;
}
</style>
</head>
<body>
<div class="demo-card">
<h2>
📅 只读日期
<span>readonly</span>
</h2>
<div class="subtitle">
<i>🔒</i> 属性保护 · 不可直接编辑 <i>🔒</i>
</div>

<!-- 日期输入框居中核心区域 -->
<div class="input-wrapper">
<div class="date-label">
<i>⏱️</i> 选择日期时间 <i>⏱️</i>
</div>
<!--
重点:input 拥有 id="ymd01" 且带有 readonly 属性
完全对应 Playwright 脚本中的定位与操作
-->
<input
type="text"
id="ymd01"
readonly
placeholder="YYYY-MM-DD HH:MM:SS"
value=""
aria-label="只读日期时间输入框"
title="此输入框为只读状态 (readonly),通过脚本修改值"
/>
</div>

<!-- 属性状态展示 -->
<div class="attribute-hint">
<span class="badge">readonly</span> 属性已应用
<span style="margin-left:0.3rem;"></span>
<span style="font-weight:500;">不可手动输入</span>
</div>

<div class="playwright-note">
💡 对应 Playwright 脚本 <br>
<code>#ymd01</code> · 移除 readonly · 设置值 <code>2023-09-01 15:15:15</code><br>
<span style="font-size:0.8rem; opacity:0.8;">(脚本执行后日期将动态更新)</span>
</div>

<!-- 微小的装饰说明:可显示当前只读状态 -->
<div style="margin-top: 1.2rem; font-size: 0.8rem; color: #94a3b8; display: flex; align-items:center; justify-content:center; gap:0.3rem;">
<span>🔐</span> 输入框处于只读模式 <span>🔐</span>
</div>
</div>

<!-- 附加说明:完全匹配 playwright 代码中的 id 和结构 -->
</body>
</html>

1-5 Trace Viewer

  • Trace Viewer
    • 执行自动化用例的过程中,出现一些不稳定偶然性的Bug,需复现还原Bug出现的过程。
    • Playwright Trace Viewer可探索记录Playwright的测试跟踪,直观查看操作期间的情况。
    • 查看
      • 使用Playwright CLI在terminal中输入playwright show-trace trace.zip,打开跟踪。
      • 通过单击每个操作,或使用时间轴悬停来查看测试的痕迹,并查看操作前后页面的状态。
      • 在每个步骤中检查日志、源和网络,跟踪查看器创建一个DOM快照,可与其完全交互,打开devtools等。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
from playwright.sync_api import sync_playwright

with sync_playwright() as p:
browser = p.chromium.launch(headless=False)
context = browser.new_context()

context.tracing.start( # 记录跟踪
screenshots=True, snapshots=True, sources=True
)
page = context.new_page()
page.goto("https://www.baidu.com/")
page.locator("//*[@id='s-hotsearch-wrapper']/div/a[1]/div").click()
context.tracing.stop(path="./file/trace.zip") # 保存为.zip文件

browser.close() # 关闭浏览器对象

1-6 table表格标签

  • table表格标签
    • table表格的常用标签:table(一个表格)、tr(一行)、th(表头单元格)、td(内容单元格)。
    • 使用xpath定位table表格数据,例如/html/body/div[1]/table[1]/tbody/tr[1]/td[2]

(1) 表格定位

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
from playwright.sync_api import sync_playwright

with sync_playwright() as p:
browser = p.chromium.launch(headless=False)
context = browser.new_context()
page = context.new_page()
page.goto(r"...\html\index.html")
with context.expect_page() as new_page_info:
page.get_by_text("用户数据管理表").click()
page.wait_for_timeout(1000)
new_page = new_page_info.value # 切换到预览table页
print(new_page.title())

new_page.wait_for_load_state("networkidle") # 等待页面加载完毕
n1 = new_page.locator("//table/tbody/tr")
print(n1.count()) # 获取内容单元格总行数

n2 = new_page.locator("//*[@id='one-part-th']")
print(n2.inner_text()) # 获取表头单元格的第二行数据

n3 = new_page.locator("//table[2]/tbody/tr/td[4]") # 不同tr提取同一td[4]
for td in n3.all():
print(td.inner_text()) # 获取内容单元格第4列的数据

browser.close() # 关闭浏览器对象

(2) 数据获取

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
from playwright.sync_api import sync_playwright

with sync_playwright() as p:
browser = p.chromium.launch(headless=False)
context = browser.new_context()
page = context.new_page()
page.goto(r"...\html\index.html")
with context.expect_page() as new_page_info:
page.get_by_text("项目进度跟踪表").click()
page.wait_for_timeout(1000)
new_page = new_page_info.value # 切换到预览table页
print(new_page.title())

new_page.wait_for_load_state("networkidle") # 等待页面加载完毕
col_name = new_page.locator("//*[@id='table-0']/div/div[1]")
col_text = [item.inner_text() for item in col_name.all()]
print(col_text) # 列表形式单列输出

tit_name = new_page.locator("//*[@id='table-0']/div[1]")
tit_list = [item.inner_text() for item in tit_name.all()]
print("标题:", [item.strip() for item in tit_list[0].split("\n")])

num = new_page.locator("//*[@id='table-0']/div").count() # 获取总行数
print("行数:", num)

for n in range(2, num+1):
element = r"//*[@id='table-0']/" + "div[" + f"{n}" + "]"
row_name = new_page.locator(element)
row_text = [item.inner_text() for item in row_name.all()]
row_list = [item.strip() for item in row_text[0].split("\n")]
if "" in row_list: # 处理第11行之后的数据
row_list = [item.strip() for item in row_list if item.strip()]
print(row_list) # 打印所有的数据行

browser.close() # 关闭浏览器对象

(3) index.html

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>多表格预览系统</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}

body {
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}

.preview-container {
background: white;
border-radius: 20px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
padding: 40px;
width: 100%;
max-width: 1200px;
text-align: center;
}

h1 {
color: #333;
margin-bottom: 10px;
font-size: 2.5em;
}

.subtitle {
color: #666;
margin-bottom: 40px;
font-size: 1.2em;
}

/* 表格导航列表 */
.table-nav {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 25px;
}

.nav-item {
background: #f8f9fa;
border: 2px solid #e0e0e0;
border-radius: 15px;
padding: 30px;
transition: all 0.3s ease;
text-decoration: none;
color: inherit;
display: block;
}

.nav-item:hover {
background: #f0f0ff;
border-color: #667eea;
transform: translateY(-5px);
box-shadow: 0 15px 40px rgba(102, 126, 234, 0.3);
}

.nav-icon {
font-size: 4em;
margin-bottom: 15px;
}

.nav-title {
font-size: 1.5em;
font-weight: bold;
color: #333;
margin-bottom: 10px;
}

.nav-description {
color: #666;
font-size: 1em;
margin-bottom: 15px;
line-height: 1.6;
}

.nav-arrow {
display: inline-block;
padding: 8px 20px;
background: #667eea;
color: white;
border-radius: 25px;
font-size: 0.9em;
transition: all 0.3s ease;
}

.nav-item:hover .nav-arrow {
background: #764ba2;
transform: scale(1.1);
}

.nav-item:nth-child(2) .nav-arrow {
background: #43e97b;
color: #333;
}

.nav-item:nth-child(3) .nav-arrow {
background: #f5576c;
}

.nav-item:nth-child(4) .nav-arrow {
background: #f39c12;
}

/* 底部信息 */
.footer-info {
margin-top: 40px;
padding-top: 20px;
border-top: 1px solid #e0e0e0;
color: #999;
font-size: 0.9em;
}

@media (max-width: 768px) {
.preview-container {
padding: 20px;
}

.table-nav {
grid-template-columns: 1fr;
}

h1 {
font-size: 2em;
}
}
</style>
</head>
<body>
<div class="preview-container">
<h1>📊 表格数据预览系统</h1>
<p class="subtitle">选择要查看的表格数据</p>

<!-- 表格导航 - 点击直接跳转 -->
<div class="table-nav">
<!-- 用户数据表 -->
<a href="table1.html" class="nav-item" target="_blank">
<div class="nav-icon">📋</div>
<div class="nav-title">用户数据管理表</div>
<div class="nav-description">
员工信息、部门分布、薪资统计等完整人力资源管理数据
</div>
<span class="nav-arrow">查看详情 →</span>
</a>

<!-- 项目进度表 -->
<a href="table2.html" class="nav-item" target="_blank">
<div class="nav-icon">📊</div>
<div class="nav-title">项目进度跟踪表</div>
<div class="nav-description">
项目名称、负责人、进度状态和时间规划完整信息
</div>
<span class="nav-arrow">查看详情 →</span>
</a>
</div>

<div class="footer-info">
💡 点击任意卡片将在新标签页中打开对应的表格数据
</div>
</div>
</body>
</html>

(4) table1.html

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>表格数据展示</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}

body {
font-family: "Microsoft YaHei", "PingFang SC", sans-serif;
background: #f5f7fa;
padding: 20px;
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
}

.container {
width: 100%;
max-width: 1100px;
margin: 0 auto;
background: white;
border-radius: 12px;
box-shadow: 0 5px 20px rgba(0, 0, 0, 0.1);
overflow: hidden;
}

/* 表格样式 */
table {
width: 100%;
border-collapse: collapse;
text-align: center;
color: #333;
margin: 0 auto;
}

/* 表头样式 */
thead th {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 15px 12px;
font-weight: 600;
font-size: 14px;
border: 1px solid #ddd;
text-align: center;
white-space: nowrap;
}

/* 表格主体样式 */
tbody td {
padding: 12px;
border: 1px solid #e0e0e0;
font-size: 13px;
color: #555;
text-align: center;
}

tbody tr:nth-child(even) {
background-color: #f8f9ff;
}

tbody tr:hover {
background-color: #eef1ff;
transition: background-color 0.3s ease;
}

/* 第二行表头特殊样式 */
#one-part-th {
background: #6c5ce7;
color: white;
font-weight: 500;
text-align: center;
}

/* 表格标题居中 */
.table-title {
padding: 20px;
background: #f8f9ff;
border-bottom: 2px solid #667eea;
text-align: center;
}

.table-title h2 {
color: #333;
font-size: 1.5em;
margin: 0;
text-align: center;
}
</style>
</head>
<body>
<div class="container">
<div class="table-title">
<h2>📋 用户数据管理表</h2>
</div>

<!-- 第一个表格:对应 table[2] -->
<table>
<thead>
<!-- 第一行表头 -->
<tr>
<th colspan="8" style="background: #5a67d8; font-size: 16px; text-align: center;">
员工信息汇总表 - 2024年度
</th>
</tr>
<!-- 第二行表头(包含 id="one-part-th") -->
<tr id="one-part-th">
<th>序号</th>
<th>姓名</th>
<th>部门</th>
<th>职位</th>
<th>入职日期</th>
<th>薪资(元)</th>
<th>绩效评级</th>
<th>状态</th>
</tr>
</thead>
<tbody>
<!-- 数据行 1-10 -->
<tr>
<td>1</td>
<td>张三</td>
<td>技术部</td>
<td>高级工程师</td>
<td>2022-03-15</td>
<td>25,000</td>
<td>A</td>
<td>在职</td>
</tr>
<tr>
<td>2</td>
<td>李四</td>
<td>产品部</td>
<td>产品经理</td>
<td>2021-07-20</td>
<td>22,000</td>
<td>A+</td>
<td>在职</td>
</tr>
<tr>
<td>3</td>
<td>王五</td>
<td>设计部</td>
<td>UI设计师</td>
<td>2023-01-10</td>
<td>18,000</td>
<td>B+</td>
<td>在职</td>
</tr>
<tr>
<td>4</td>
<td>赵六</td>
<td>市场部</td>
<td>市场总监</td>
<td>2020-05-08</td>
<td>30,000</td>
<td>A</td>
<td>在职</td>
</tr>
<tr>
<td>5</td>
<td>孙七</td>
<td>财务部</td>
<td>会计主管</td>
<td>2019-11-25</td>
<td>20,000</td>
<td>B</td>
<td>在职</td>
</tr>
<tr>
<td>6</td>
<td>周八</td>
<td>人事部</td>
<td>HR经理</td>
<td>2021-09-12</td>
<td>19,000</td>
<td>B+</td>
<td>在职</td>
</tr>
<tr>
<td>7</td>
<td>吴九</td>
<td>技术部</td>
<td>前端开发</td>
<td>2023-06-18</td>
<td>16,000</td>
<td>B</td>
<td>在职</td>
</tr>
<tr>
<td>8</td>
<td>郑十</td>
<td>运维部</td>
<td>系统管理员</td>
<td>2022-08-30</td>
<td>17,000</td>
<td>A-</td>
<td>在职</td>
</tr>
</tbody>
</table>
</div>
</body>
</html>

(5) table2.html

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>结构表格展示</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}

body {
font-family: "Microsoft YaHei", "PingFang SC", sans-serif;
background: #f0f2f5;
padding: 20px;
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
}

.wrapper {
width: 100%;
max-width: 1200px;
margin: 0 auto;
background: white;
border-radius: 15px;
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.12);
overflow: hidden;
}

.header {
background: linear-gradient(135deg, #43e97b 0%, #38f9d7 100%);
padding: 20px;
text-align: center;
color: white;
font-size: 1.8em;
font-weight: bold;
}

/* 表格容器 */
#table-0 {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
}

/* 标题行 */
#table-0 .header-row {
display: grid;
grid-template-columns: 80px 1fr 1fr 1fr 1fr 1fr 100px;
background: #2d3436;
color: white;
font-weight: 600;
width: 100%;
text-align: center;
}

/* 列标题 */
#table-0 .header-row .col {
padding: 15px 10px;
border-right: 1px solid #636e72;
text-align: center;
font-size: 14px;
display: flex;
align-items: center;
justify-content: center;
}

#table-0 .header-row .col:last-child {
border-right: none;
}

/* 数据行 */
#table-0 .data-row {
display: grid;
grid-template-columns: 80px 1fr 1fr 1fr 1fr 1fr 100px;
border-bottom: 1px solid #e0e0e0;
transition: background-color 0.3s ease;
width: 100%;
text-align: center;
}

#table-0 .data-row:hover {
background-color: #f0f9ff;
}

#table-0 .data-row:nth-child(even) {
background-color: #fafafa;
}

#table-0 .data-row:nth-child(even):hover {
background-color: #f0f9ff;
}

#table-0 .data-row .col {
padding: 12px 10px;
border-right: 1px solid #e8e8e8;
text-align: center;
font-size: 13px;
color: #555;
display: flex;
align-items: center;
justify-content: center;
}

#table-0 .data-row .col:last-child {
border-right: none;
}

/* 状态标签 */
.status-active {
background: #00b894;
color: white;
padding: 4px 12px;
border-radius: 20px;
font-size: 12px;
font-weight: 500;
text-align: center;
}

.status-inactive {
background: #d63031;
color: white;
padding: 4px 12px;
border-radius: 20px;
font-size: 12px;
font-weight: 500;
text-align: center;
}

.status-probation {
background: #fdcb6e;
color: #2d3436;
padding: 4px 12px;
border-radius: 20px;
font-size: 12px;
font-weight: 500;
text-align: center;
}
</style>
</head>
<body>
<div class="wrapper">
<div class="header">
📊 项目进度跟踪表
</div>

<!-- 主表格容器 id="table-0" -->
<div id="table-0">
<!-- 标题行 (div[1]) -->
<div class="header-row">
<div class="col">序号</div>
<div class="col">项目名称</div>
<div class="col">负责人</div>
<div class="col">开始日期</div>
<div class="col">结束日期</div>
<div class="col">进度</div>
<div class="col">状态</div>
</div>

<!-- 数据行 1 (div[2]) -->
<div class="data-row">
<div class="col">1</div>
<div class="col">官网重构</div>
<div class="col">张三</div>
<div class="col">2024-01-15</div>
<div class="col">2024-03-20</div>
<div class="col">85%</div>
<div class="col"><span class="status-active">进行中</span></div>
</div>

<!-- 数据行 2 (div[3]) -->
<div class="data-row">
<div class="col">2</div>
<div class="col">移动端APP开发</div>
<div class="col">李四</div>
<div class="col">2024-02-01</div>
<div class="col">2024-06-30</div>
<div class="col">60%</div>
<div class="col"><span class="status-active">进行中</span></div>
</div>

<!-- 数据行 3 (div[4]) -->
<div class="data-row">
<div class="col">3</div>
<div class="col">数据分析平台</div>
<div class="col">王五</div>
<div class="col">2023-11-01</div>
<div class="col">2024-02-28</div>
<div class="col">100%</div>
<div class="col"><span class="status-active">已完成</span></div>
</div>

<!-- 数据行 4 (div[5]) -->
<div class="data-row">
<div class="col">4</div>
<div class="col">客户管理系统</div>
<div class="col">赵六</div>
<div class="col">2024-03-01</div>
<div class="col">2024-08-15</div>
<div class="col">45%</div>
<div class="col"><span class="status-active">进行中</span></div>
</div>

<!-- 数据行 5 (div[6]) -->
<div class="data-row">
<div class="col">5</div>
<div class="col">内部培训系统</div>
<div class="col">孙七</div>
<div class="col">2024-04-10</div>
<div class="col">2024-07-20</div>
<div class="col">30%</div>
<div class="col"><span class="status-probation">待审核</span></div>
</div>

<!-- 数据行 6 (div[7]) -->
<div class="data-row">
<div class="col">6</div>
<div class="col">云存储迁移</div>
<div class="col">周八</div>
<div class="col">2024-01-20</div>
<div class="col">2024-05-10</div>
<div class="col">70%</div>
<div class="col"><span class="status-active">进行中</span></div>
</div>

<!-- 数据行 7 (div[8]) -->
<div class="data-row">
<div class="col">7</div>
<div class="col">安全审计系统</div>
<div class="col">吴九</div>
<div class="col">2024-02-15</div>
<div class="col">2024-09-30</div>
<div class="col">25%</div>
<div class="col"><span class="status-inactive">暂停</span></div>
</div>

<!-- 数据行 8 (div[9]) -->
<div class="data-row">
<div class="col">8</div>
<div class="col">AI推荐引擎</div>
<div class="col">郑十</div>
<div class="col">2024-05-01</div>
<div class="col">2024-12-31</div>
<div class="col">15%</div>
<div class="col"><span class="status-active">进行中</span></div>
</div>
</div>
</div>
</body>
</html>

1-7 图片相似度对比

  • 图片相似度对比
    • pip命令安装airtest和aircv:pip install airtestpip install aircv
    • Python安装目录中,找到Lib\site-packages\airtest\aircv\cal_confidence.py。
    • 相似度对比时需用到cal_confidence.py中的函数cal_ccoeff_confidence()
    • 对比图时需要将图片的大小设为一致,可以使用cv2.resize()方法进行处理。
    • cv2.resize(src, dsize, dst=None, fx=None, fy=None, interpolation=None)
      • src源图像、dsize图像的大小、fxwidth方向的缩放比例。
      • fyheight方向的缩放比例、interpolation指定插值的方式。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import cv2
from PIL import Image
from playwright.sync_api import sync_playwright
import airtest.aircv.cal_confidence as cal_confidence # 引用函数


with sync_playwright() as playwright:
browser = playwright.chromium.launch(headless=False)
page = browser.new_page()
page.goto(
"https://img.zcool.cn/community/012e9a5ecfd"
"26aa80120662183278c.jpg@1280w_1l_2o_100sh.jpg"
)
screenshot_path = "pictures/image1.png"
page.screenshot(path=screenshot_path) # 截图保存到本地
browser.close() # 关闭浏览器对象

image = Image.open(screenshot_path) # 将保存下来的图片裁剪成两半
half1 = image.crop((0, 0, image.width // 2, image.height))
half2 = image.crop((image.width // 2, 0, image.width, image.height))
half1.save("pictures/image2.png") # 左图
half2.save("pictures/image3.png") # 右图

im1 = cv2.resize(cv2.imread("pictures/image2.png"), (100, 100))
im2 = cv2.resize(cv2.imread("pictures/image3.png"), (100, 100))
res = cal_confidence.cal_ccoeff_confidence(im1, im2) # 相似度对比
print(res)

2 插件编写用例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import re                                                       # test_plugin.py
from playwright.sync_api import Page, expect


def test_homepage(page: Page): # 网页测试
page.goto("https://playwright.dev/") # 访问指定的URL
expect(page).to_have_title(re.compile("Playwright")) # 判断页面标题是否包含"Playwright"
get_started = page.get_by_role("link", name="Get started") # 获取元素role为link,name为Get started
expect(get_started).to_have_attribute("href", "/docs/intro")
get_started.click() # 点击获取到的元素
expect(page).to_have_url(re.compile(".*intro")) # 正则判断页面的URL是否包含"intro"

# pytest test_plugin.py # 无头模式运行,测试结果和日志在终端显示
# pytest test_plugin.py --headed # 浏览器UI运行

2-1 CLI参数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
pytest                                                          # 默认在chromium上运行测试
pytest ./test_*/ # 运行一组测试文件
pytest test_plugin.py # 运行单个测试文件
pytest test_plugin.py -k "test_homepage" # 使用函数名运行测试
pytest test_plugin.py --headed # 有头模式运行测试(默认无头)
pytest test_plugin.py --browser webkit # 特定浏览器运行测试
pytest test_plugin.py --browser webkit --browser firefox # 多个浏览器运行测试
pytest test_plugin.py --browser-channel chrome # 指定浏览器通道运行测试
pytest test_plugin.py --slowmo 100 # 指定延迟不超过100ms来减缓测试执行速度
pytest test_plugin.py --device="iPhone 12" # 模拟iPhone 12设备运行测试
pytest test_plugin.py --output="my_results" --video=on # 测试生成的工作目录(默认test-results)
pytest test_plugin.py --tracing=on # 为每个测试记录轨迹
pytest test_plugin.py --video=on # 为每次测试录制视频
pytest test_plugin.py --screenshot=on # 截图(on、默认off、retain-on-failure)

pip install pytest-xdist
pytest --numprocesses auto # 多进程运行测试

2-2 用例编写

  • 用例编写
    • 浏览器上下文使得页面在测试之间被隔离,相当于一个全新的浏览器配置文件,每个测试都会获得一个新环境。
    • 可以使用各种fixture在测试之前或者之后执行代码,并在它们之间共享对象,例如:beforeEach、afterEach等。
    • 内置fixture
      • Function scope:这些固定装置在测试功能中请求时创建,并在测试结束时销毁。
        • page:用于测试的新浏览器页面。
        • context:用于测试的新浏览器上下文。
      • Session scope:这些固定装置在测试函数中请求时创建,并在所有测试结束时销毁。
        • browser_name:浏览器名称作为字符串。
        • browser:由Playwright启动的浏览器实例。
        • browser_channel:浏览器通道作为字符串。
        • browser_type:当前浏览器的BrowserType实例。
        • is_chromium,is_webkit,is_firefox:相应浏览器类型的布尔值。
      • 自定义fixture选项:对于browser和context ,使用以下fixture来自定义启动选项。
        • browser_context_args:覆盖browser.new_context()的选项,返回一个字典。
        • browser_type_launch_args:覆盖browser_type.launch()的启动参数,返回一个字典。
1
2
3
4
5
6
7
8
from playwright.sync_api import Page                             # test_example.py


def test_baidu(page: Page): # 充当测试用例
page.goto("https://www.baidu.com/") # 结合conftest.py使用
page.wait_for_timeout(3000)

page.close()

(1) 跳过浏览器

1
2
3
4
5
6
7
8
import pytest                                                    # test_skip.py


@pytest.mark.skip_browser("firefox")
def test_example(page):
page.goto("https://playwright.dev/")

# pytest test_skip.py --browser firefox # 执行用例查看效果

(2) 指定浏览器

1
2
3
4
5
6
7
8
9
10
import pytest                                                   # test_browser.py


@pytest.mark.only_browser("firefox") # 【方法一】指定浏览器
def test_example(page):
page.goto("https://playwright.dev/")

# pytest test_browser.py --headed # 默认chromium执行
# pytest test_browser.py --browser firefox # 【方法二】
# pytest test_browser.py --browser-channel chromium # 命令行参数指定浏览器

(3) 配置base-url

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
def test_example(page):                                         # test_base_url.py
page.goto("/Login.aspx") # 用到了pytest-base-url插件


"""
# pytest test_base_url.py --base-url https://mall.cnki.net # 【方法一】命令行参数访问

# 另外:PlayWright框架(一)的上下文代码中,base_url参数是在new_context()新建上下文时使用的
# 即browser.new_context(base_url="https://www.baidu.com") # 【方法二】新建上下文使用
# 后续page.goto("/")使用时,只需要填入相对地址"/"就可以访问了

[pytest] # 【方法三】pytest.ini中配置
base_url=https://mall.cnki.net
# pytest test_base_url.py --headed
"""

(4) 设置手机设备

1
2
3
4
5
6
7
8
9
10
11
12
import pytest                                                   # conftest.py


@pytest.fixture(scope="session")
def browser_context_args(browser_context_args, playwright):
iphone_11 = playwright.devices["iPhone 11 Pro"] # 指定手机型号运行用例
return {
**browser_context_args,
**iphone_11,
}

# pytest test_example.py --headed # 执行用例查看效果

(5) 忽略https错误

1
2
3
4
5
6
7
8
9
10
11
import pytest                                                   # conftest.py


@pytest.fixture(scope="session")
def browser_context_args(browser_context_args):
return {
**browser_context_args,
"ignore_https_errors": True
}

# pytest test_example.py --headed # 执行用例查看效果

(6) 持久的context

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import pytest                                                   # conftest.py
from typing import Dict
from playwright.sync_api import BrowserType


@pytest.fixture(scope="session")
def context( # 测试中的所有页面
browser_type: BrowserType, # 都是从持久上下文创建的
browser_type_launch_args: Dict,
browser_context_args: Dict):
context = browser_type.launch_persistent_context("./foobar", **{
**browser_type_launch_args,
**browser_context_args,
"locale": "de-DE",
})
yield context
context.close()

# pytest test_example.py --headed # 执行用例查看效果

(7) 浏览器窗口大小

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import pytest                                                   # conftest.py


@pytest.fixture(scope="session")
def browser_context_args(browser_context_args):
return {
**browser_context_args,
"viewport": {
"width": 1920,
"height": 1080,
}
}

# pytest test_example.py --headed # 执行用例查看效果

2-3 多进程执行

  • 多进程执行
    • 原则
      • 用例之间相互独立,没有依赖,每个用例都可独立运行。
      • 用例执行之间没有顺序要求,随机顺序都可以正常执行。
      • 每个用例都能重复执行,运行结果不会影响到其他用例。
    • 使用PIP包管理工具安装pytest-xdist插件:pip install pytest-xdist
    • 线程(Thread)是进程(Process)内的实际执行单位,一进程可包含多个线程。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
url = "https://www.baidu.com/"                                  # test_process.py


def test_case1():
print("case1------")
assert url == "https://www.baidu.com/"


def test_case2():
print("case2------")
assert url == "https://www.baidu.com/"


def test_case3():
print("case3------")
assert url == "https://www.baidu.com/"

# pytest test_process.py -n 2 # 并行执行用例
# pytest test_process.py -n auto # 自动获取CPU核数,执行速度慢

2-4 命令行选项

  • 命令行选项
    • 测试分布算法配置:指在分布式测试执行期间使用的算法和相关设置。
      • 通过测试分布,可以将测试用例并行在多个浏览器、设备或环境中执行,以加快测试执行速度。
      • 常见的测试分布算法配置选项
        • 轮询:将待执行的测试用例依次轮流分发给可用的执行器,以均匀地分配测试负载。
        • 随机:随机选择可用的执行器来运行测试用例,从而可能在某些情况下提高测试效率。
        • 基于负载:根据执行器的负载情况动态分发测试用例,以确保执行器的负载大致相等。
    • 在Playwright中,dist命令行选项用于配置测试分布算法。
      • --repeat-each 3:每个测试用例将被执行3次。
      • --workers 3:使用3个工作进程来执行测试用例。
      • --shard 1/3:使用3个分片中的第1个分片来执行测试用例。
      • --max-failures 3:如果测试用例失败的次数达到3次,则测试执行将停止。
      • --dist loadfile:使用基于负载的分布算法,并根据测试文件来分发测试用例。
      • --dist loadgroup:使用基于负载的分布算法,并根据测试分组来分发测试用例。
        • 使用@pytest.mark.xdist_group(name=" ")装饰器为测试用例指定不同的分组名称。
        • 当执行测试时,pytest-xdist插件可以并行地执行同一分组的测试用例,提高执行效率。
      • --dist loadscope:使用基于负载的分布算法,并按作用域来动态分配测试用例。
      • --dist load(默认方式):使用基于负载的分布算法,根据执行器的负载情况来动态地分配测试用例。
      • --dist no:禁用测试分布,即不进行分布式测试执行,而是在单个执行器上依次执行所有测试用例。
      • --dist worksteal:使用worksteal算法分发用例,算法允许空闲的执行器从繁忙的执行器那里窃取用例以平衡负载。
      • --max-sched-chunk=3:限制并发执行的最大数量为3,从而在多线程进行测试时可以限制同时执行的测试用例数量。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import pytest                                                   # test_xdist_group.py

url = "https://www.baidu.com/"


@pytest.mark.xdist_group(name="group1") # 装饰器指定分组名称group1
def test_case1(): # 并行执行group1分组的用例
print("case1------")
assert url == "https://www.baidu.com/"


class TestA:
@pytest.mark.xdist_group("group1")
def test_case2(self):
print("case2------")
assert url == "https://www.baidu.com/"


class TestB:
def test_case3(self):
print("case3------")
assert url == "https://www.baidu.com/"

# pytest -n auto test_xdist_group.py

2-5 插件结合使用

1
2
3
4
5
6
7
8
9
10
11
12
13
def test_case1(base_url):                                       # test_xdist_url.py
print(base_url) # pytest-xdist与pytest-base-url结合使用
assert base_url == "https://www.baidu.com/" # 会遇到xdist不适配的情况,可以参看解决方案


def test_case2(base_url):
print(base_url)
assert base_url == "https://www.baidu.com/"


def test_case3(base_url):
print(base_url)
assert base_url == "https://www.baidu.com/"

(1) pytest.ini

1
2
3
4
5
6
7
[pytest]                                                         # pytest.ini

base_url=https://www.baidu.com/

# pytest test_xdist_url.py # 执行正常
# pytest test_xdist_url.py -n 2 # 执行报错,会获取不到base_url
# don't run configure on xdist worker nodes # 源码文件注释,说明不适配xdist

(2) 解决方案一

1
2
3
4
5
6
7
8
9
10
11
12
@pytest.fixture(scope="session")                                # PyCharm中,Ctrl+右键点击base_url
def base_url(request): # 加载plugin.py文件,找到左侧代码段
"""Return a base URL"""
config = request.config
# base_url = config.getoption("base_url") # 修改该行代码为下一行代码内容,保存
base_url = config.getoption("base_url") or config.getini("base_url")
if base_url is not None:
return base_url

# 再次执行命令pytest test_xdist_url.py -n 2,就不会再报错了,随后执行pytest命令时都出现以下问题
# UnicodeDecodeError: 'gbk' codec can't decode byte 0xa1 in position 185: illegal multibyte sequence
# 将pytest.ini文件的编码格式由UTF-8改为GBK即可解决(不清楚是不是由于修改源文件内容引起的,不推荐使用该解决方案)

(3) 解决方案二

1
2
3
[pytest]                                                         # pytest.ini

addopts = --base-url=https://www.baidu.com/ # 不配置ini参数,配置命令行参数

3 页面对象模型

  • 页面对象模型
    • POM是一种软件测试设计模式,用于在自动化测试中组织和管理网页的元素和操作。
    • 该模式将每个网页视为一个独立的对象,并且使用对象来表示该页面的元素和行为。
    • 基本原则是将页面结构和行为与测试代码分离开来,若结构发生变化,只需更新页面对象的代码。
    • 以测试注册页面为例
      • 根据输入框的内容,可以编写多个有效等价和无效等价的测试用例。
      • 用例操作是在页面元素上点点点,将元素定位和操作封装成一个类。
    • 搭建JForum论坛
      • xampp-windows-x64-5.6.40-1-VC11-installer.exe:下载,傻瓜式安装。
        • MySQL将my.ini文件中的端口3306修改为3311,Apache将httpd.conf文件中的端口80修改为81。
        • /Xampp/phpMyAdmin/config.inc.php文件添加内容$cfg['Servers'][$i]['port'] = '3311';
        • 启动Apache、Tomcat、MySQL,下载“jforum-2.7.0.war”剪贴到Xampp/tomcat/webapps目录。
      • 若浏览器正常访问“http://localhost:81/phpmyadmin/”,说明文件配置正确。
        • phpMyAdmin中新建一个名为jforum,编码为utf8_general_ci的数据库。
        • phpMyAdmin界面,点击账户,将用户名为root的权限密码全部改为123456。
        • /Xampp/phpMyAdmin/config.inc.php文件,改['password'] = '123456';
      • 浏览器访问“http://127.0.0.1:8080/jforum-2.7.0/install.jsp”,部署本地论坛。
      • 数据库端口3311,数据库用户账号为root,密码123456,系统管理员密码123456。
      • 部署完成后访问论坛首页:http://127.0.0.1:8080/jforum-2.7.0/forums/list.page
      • 注意:访问论坛时只需启动Tomcat和MySQL,访问数据库则要开启Apache和MySQL。
1
2
3
4
5
6
7
8
cases:                                                           # 用于放置测试用例
- test_register.py
- __init__.py
models: # 用于放置封装好的类文件
- register_page.py # conftest.py的配置优先于pytest.ini
- __init.py # 但仍然受到pytest.ini命令行选项的影响
- conftest.py # 用于定义共享的fixtures和钩子函数hooks
- pytest.ini # 配置文件,用于设置全局的测试选项和插件

3-1 封装成类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
from playwright.sync_api import Page                            # register_page.py


class RegisterPage:
def __init__(self, page: Page):
self.page = page
self.pop_message = None
self.locator_username = page.locator(
"//*[@id='formregister']/table[2]/tbody/tr[3]/td[2]/input"
) # 定位会员名称
self.locator_email = page.locator(
"//*[@id='formregister']/table[2]/tbody/tr[4]/td[2]/input"
) # 定位电子邮箱
self.locator_password = page.locator(
"//*[@id='password']"
) # 定位密码
self.locator_password_confirm = page.locator(
"//*[@id='formregister']/table[2]/tbody/tr[6]/td[2]/input"
) # 定位确认密码
# self.locator_register_btn = page.locator(
# "//*[@id="formregister"]/table[2]/tbody/tr[7]/td/input[1]"
# ) # 定位确定按钮
self.locator_register_btn = page.get_by_role("button", name="确定")

def navigate(self): # 导航到会员注册页面
self.page.goto("http://127.0.0.1:8080/jforum-2.7.0/user/insert.page")
# self.page.click(
# "/html/body/table/tbody/tr[2]/td/table/tbody/tr[3]/td/input[1]"
# ) # 同意协议,没定位到
self.page.get_by_role("button", name="我同意以上条款").click()

def handle_dialog(self):
def on_dialog(dialog):
print("弹窗提示内容:", dialog.message)
self.pop_message = dialog.message
dialog.dismiss()
self.page.on("dialog", on_dialog)

def fill_username(self, username): # 输入会员名称
self.locator_username.fill(username)

def fill_email(self, email): # 输入电子邮箱
self.locator_email.fill(email)

def fill_password(self, password): # 输入密码
self.locator_password.fill(password)

def fill_password_confirm(self, password_confirm): # 输入确认密码
self.locator_password_confirm.fill(password_confirm)

def click_register_button(self): # 点击确定按钮
self.locator_register_btn.wait_for(state="visible")
self.locator_register_btn.click()

3-2 conftest.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import pytest                                                   # conftest.py
from playwright.sync_api import sync_playwright


@pytest.fixture(scope="session")
def context_chrome():
p = sync_playwright().start() # 前置操作代码
browser = p.chromium.launch(headless=False, slow_mo=1000)
context = browser.new_context()
yield context

context.close() # 实现用例后置
browser.close() # 后置操作代码
p.stop()

3-3 测试用例部分

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
import pytest                                                   # test_register.py
from playwright.sync_api import expect
from models.register_page import RegisterPage


class TestRegister:
@pytest.fixture(autouse=True) # True为作用域内的用例自动调用
def start_for_each(self, context_chrome):
print("访问注册页")
self.page = context_chrome.new_page()
self.register = RegisterPage(self.page)
self.register.navigate()
yield # 将一个函数变成一个生成器
print("关闭注册页")
self.page.close() # 这个生成器用于测试注册页面

def test_register_a(self): # 会员名称为空
self.register.fill_username("") # 字段长度超过直接截断
self.register.fill_email("Amelia@gmail.com") # 这里不做验证
self.register.fill_password("Aa.123456")
self.register.fill_password_confirm("Aa.123456")
self.register.handle_dialog()
self.register.click_register_button()
assert self.register.pop_message == "请填写会员名称输入框"
# expect( # expect断言获取不到内容
# self.register.pop_message
# ).to_contain_text("请填写会员名称输入框")

def test_register_b(self): # 电子邮箱为空
self.register.fill_username("Blima")
self.register.fill_email("")
self.register.fill_password("Aa.123456")
self.register.fill_password_confirm("Aa.123456")
self.register.handle_dialog()
self.register.click_register_button()
assert self.register.pop_message == "请正确的填写电子邮件输入框"

def test_register_c(self): # 密码为空
self.register.fill_username("Cing")
self.register.fill_email("Cing@gmail.com")
self.register.fill_password("")
self.register.fill_password_confirm("Aa.123456")
self.register.handle_dialog()
self.register.click_register_button()
assert self.register.pop_message == "请填写密码输入框"

def test_register_d(self): # 密码与确认密码不一致
self.register.fill_username("Daijon")
self.register.fill_email("Daijon@gmail.com")
self.register.fill_password("Aa.123456")
self.register.fill_password_confirm("123456")
self.register.handle_dialog()
self.register.click_register_button()
assert self.register.pop_message == "两次输入的密码不合"

def test_register_e(self): # 成功注册
self.register.fill_username("Elianis")
self.register.fill_email("Elianis@gmail.com")
self.register.fill_password("Aa.123456")
self.register.fill_password_confirm("Aa.123456")
self.register.handle_dialog()
self.register.click_register_button()
expect(self.register.page).to_have_title("My Forum - your board description")


if __name__ == "__main__":
pytest.main()

4 登录相关操作

4-1 table3.html

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=yes">
<title>登录页滑动解锁</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}

body {
background: linear-gradient(145deg, #1e2b3c 0%, #0f1a26 100%);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
font-family: "Segoe UI", "Inter", system-ui, -apple-system, sans-serif;
padding: 20px;
margin: 0;
}

.login-card {
background: rgba(255, 255, 255, 0.08);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border-radius: 36px;
box-shadow: 0 30px 50px rgba(0, 0, 0, 0.6), inset 0 1px 1px rgba(255, 255, 255, 0.1);
padding: 40px 32px;
width: 100%;
max-width: 440px;
border: 1px solid rgba(255, 255, 255, 0.15);
transition: all 0.2s ease;
}

h2 {
color: #f0f4fa;
font-weight: 500;
font-size: 2rem;
letter-spacing: 2px;
text-align: center;
margin-bottom: 12px;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
}

.subhead {
text-align: center;
color: #9aa9bb;
margin-bottom: 36px;
font-size: 0.95rem;
border-bottom: 1px dashed rgba(255, 255, 255, 0.2);
padding-bottom: 20px;
}

.input-group {
margin-bottom: 28px;
}

label {
display: block;
color: #bccbd8;
margin-bottom: 8px;
font-size: 0.9rem;
font-weight: 500;
letter-spacing: 0.5px;
}

.input-field {
width: 100%;
background: rgba(10, 20, 30, 0.7);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 18px;
padding: 14px 18px;
font-size: 1rem;
color: #ffffff;
outline: none;
transition: all 0.25s;
backdrop-filter: blur(5px);
}

.input-field:focus {
border-color: #5f9eff;
box-shadow: 0 0 12px rgba(95, 158, 255, 0.4);
background: rgba(20, 30, 44, 0.8);
}

.input-field::placeholder {
color: #5d6f82;
font-weight: 300;
}

/* 滑块验证区域 */
.slider-container {
margin: 32px 0 18px;
position: relative;
}

.slider-label {
display: flex;
justify-content: space-between;
color: #adb9c9;
font-size: 0.85rem;
margin-bottom: 12px;
}

.slider-track {
background: #1e2e3f;
height: 54px;
border-radius: 40px;
position: relative;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.25);
box-shadow: inset 0 2px 8px rgba(0, 0, 0, 0.5);
user-select: none;
cursor: pointer;
display: flex;
align-items: center;
}

.track-text {
position: absolute;
left: 50%;
transform: translateX(-50%);
color: #7e8c9e;
font-weight: 500;
font-size: 1rem;
letter-spacing: 1px;
white-space: nowrap;
pointer-events: none;
z-index: 2;
transition: color 0.2s;
}

.progress-fill {
width: 0%;
height: 100%;
background: linear-gradient(90deg, #2b5a3b, #3f8a4c);
border-radius: 40px;
position: absolute;
left: 0;
top: 0;
z-index: 0;
transition: width 0.05s linear;
box-shadow: inset 0 0 10px rgba(0, 0, 0, 0.3);
}

.slider-thumb {
position: absolute;
left: 4px;
top: 4px;
width: 46px;
height: 46px;
background: #f0f4ff;
border-radius: 50%;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.6), 0 0 0 2px rgba(255, 255, 255, 0.3);
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
font-weight: bold;
color: #1e3b4f;
z-index: 10;
cursor: grab;
transition: background 0.1s, box-shadow 0.2s;
user-select: none;
}

.slider-thumb:active {
cursor: grabbing;
background: #ffffff;
box-shadow: 0 0 18px #5f9eff;
}

.slider-thumb.dragging {
background: #e6f0ff;
box-shadow: 0 0 22px #3c7eff;
}

.login-btn {
width: 100%;
background: #2f455e;
border: none;
border-radius: 30px;
padding: 16px 20px;
font-size: 1.15rem;
font-weight: 600;
color: #cfdbe8;
background: linear-gradient(135deg, #2a3f54, #17212b);
letter-spacing: 2px;
cursor: pointer;
transition: all 0.3s;
margin-top: 12px;
border: 1px solid rgba(255, 255, 255, 0.2);
box-shadow: 0 8px 18px rgba(0, 0, 0, 0.4);
opacity: 0.7;
pointer-events: none;
}

.login-btn.active {
opacity: 1;
pointer-events: auto;
background: linear-gradient(135deg, #4f7aa6, #1f3b4f);
color: white;
border-color: rgba(255, 255, 255, 0.5);
box-shadow: 0 0 22px #3f7bc0;
}

.login-btn.active:hover {
background: linear-gradient(135deg, #5f8cc0, #25475a);
}

.status-message {
text-align: center;
margin-top: 16px;
color: #b0c7da;
font-size: 0.9rem;
min-height: 24px;
font-weight: 400;
}

.unlock-hint {
color: #f0b27a;
}

.success-highlight {
color: #8fd894;
}
</style>
</head>
<body>
<div class="login-card">
<h2>
<span>🔐</span> 安全登录
</h2>
<div class="subhead">请先滑动解锁验证</div>

<!-- 模拟登录表单 -->
<div class="input-group">
<label>👤 账号</label>
<input class="input-field" type="text" placeholder="admin@example.com" value="user@playwright">
</div>
<div class="input-group">
<label>🔑 密码</label>
<input class="input-field" type="password" placeholder="••••••••" value="123456">
</div>

<!-- 滑动解锁模块 —— 与Playwright脚本匹配 -->
<div class="slider-container">
<div class="slider-label">
<span>🔒 安全验证</span>
<span id="sliderStatus" class="unlock-hint">← 向右滑动</span>
</div>

<div class="slider-track" id="sliderTrack">
<div class="progress-fill" id="progressFill"></div>
<span class="track-text" id="trackText">→ 向右滑动</span>
<div class="slider-thumb" id="sliderThumb"></div>
</div>
</div>

<button class="login-btn" id="loginButton" disabled>登 录</button>
<div class="status-message" id="statusMessage"></div>
<div style="color:#5b6f83; font-size:0.7rem; text-align:center; margin-top:16px;">
拖动滑块至最右侧完成验证
</div>
</div>

<script>
(function () {
// --- DOM 元素 ---
const sliderTrack = document.getElementById("sliderTrack");
const sliderThumb = document.getElementById("sliderThumb");
const progressFill = document.getElementById("progressFill");
const trackText = document.getElementById("trackText");
const sliderStatus = document.getElementById("sliderStatus");
const loginButton = document.getElementById("loginButton");
const statusMessage = document.getElementById("statusMessage");

// --- 状态变量 ---
let isDragging = false;
let startMouseX = 0;
let thumbStartLeft = 0;
let currentLeft = 4; // 滑块初始left像素 (与CSS一致)
const minLeft = 4;
let maxLeft = 0; // 动态计算:轨道宽度 - 滑块宽度 - 4px padding
let unlocked = false;

// 滑块尺寸常量
const THUMB_WIDTH = 46;
const TRACK_PADDING = 4;

// 获取元素引用方便计算
function updateMaxLeft() {
const trackWidth = sliderTrack.clientWidth;
if (trackWidth > 0) {
maxLeft = trackWidth - THUMB_WIDTH - TRACK_PADDING;
// 确保maxLeft不小于minLeft
if (maxLeft < minLeft) maxLeft = minLeft;
} else {
maxLeft = minLeft;
}
}

// 根据left值更新UI (滑块位置、进度条、文字)
function updateUI(leftValue) {
// 边界限制
if (leftValue < minLeft) leftValue = minLeft;
if (leftValue > maxLeft) leftValue = maxLeft;

currentLeft = leftValue;
sliderThumb.style.left = leftValue + "px";

// 进度百分比 (基于可滑动范围)
const range = maxLeft - minLeft;
let percent = 0;
if (range > 0) {
percent = ((leftValue - minLeft) / range) * 100;
}
progressFill.style.width = percent + "%";

// 接近完成时改变文字提示
if (percent >= 99.5) {
trackText.textContent = "✓ 验证通过";
trackText.style.color = "#c3e6cb";
sliderStatus.textContent = "✓ 解锁成功";
sliderStatus.className = "success-highlight";
if (!unlocked) {
unlocked = true;
onUnlockSuccess();
}
} else {
trackText.textContent = "→ 向右滑动";
trackText.style.color = "#7e8c9e";
sliderStatus.textContent = "← 向右滑动";
sliderStatus.className = "unlock-hint";
if (unlocked) {
unlocked = false;
onUnlockReset();
}
}
}

// 解锁成功时启用登录按钮
function onUnlockSuccess() {
loginButton.disabled = false;
loginButton.classList.add("active");
statusMessage.textContent = "✅ 验证完成,可以登录";
sliderThumb.style.background = "#d4edda";
}

// 重置解锁状态 (未滑到最右)
function onUnlockReset() {
loginButton.disabled = true;
loginButton.classList.remove("active");
statusMessage.textContent = "";
sliderThumb.style.background = "#f0f4ff";
}

// 将滑块移动到指定位置 (主要用于程序化调用,如Playwright)
function setSliderPosition(leftValue) {
updateMaxLeft(); // 确保maxLeft最新
updateUI(leftValue);
}

// 重置滑块到起始位置
function resetSlider() {
updateMaxLeft();
updateUI(minLeft);
unlocked = false;
onUnlockReset();
trackText.textContent = "→ 向右滑动";
trackText.style.color = "#7e8c9e";
}

// --- 鼠标/触摸事件处理 (模拟真实拖动) ---
function handleDragStart(e) {
// 如果已经解锁,仍允许拖动但重置状态 (符合一般逻辑:拖动滑块即重置)
if (unlocked) {
// 可以选择重置:一旦开始拖动,解锁状态取消
unlocked = false;
onUnlockReset();
updateUI(minLeft); // 立刻回到起点
// 继续拖动处理
}

e.preventDefault();
isDragging = true;
sliderThumb.classList.add("dragging");

// 记录起始鼠标X坐标 (兼容触摸和鼠标)
const clientX = e.touches ? e.touches[0].clientX : e.clientX;
startMouseX = clientX;
thumbStartLeft = currentLeft;

// 更新maxLeft以防窗口大小变化
updateMaxLeft();

// 添加全局监听
window.addEventListener("mousemove", handleDragMove);
window.addEventListener("mouseup", handleDragEnd);
window.addEventListener("touchmove", handleDragMove, {passive: false});
window.addEventListener("touchend", handleDragEnd);
window.addEventListener("touchcancel", handleDragEnd);
}

function handleDragMove(e) {
if (!isDragging) return;
e.preventDefault();

const clientX = e.touches ? e.touches[0].clientX : e.clientX;
const deltaX = clientX - startMouseX;
let newLeft = thumbStartLeft + deltaX;

// 边界限制
if (newLeft < minLeft) newLeft = minLeft;
if (newLeft > maxLeft) newLeft = maxLeft;

updateUI(newLeft);
}

function handleDragEnd(e) {
if (!isDragging) return;
isDragging = false;
sliderThumb.classList.remove("dragging");

// 移除全局事件
window.removeEventListener("mousemove", handleDragMove);
window.removeEventListener("mouseup", handleDragEnd);
window.removeEventListener("touchmove", handleDragMove);
window.removeEventListener("touchend", handleDragEnd);
window.removeEventListener("touchcancel", handleDragEnd);

// 如果滑块位置已经达到最右侧,确保解锁状态
if (currentLeft >= maxLeft - 0.5) {
// 强制对齐到最右侧
updateUI(maxLeft);
if (!unlocked) {
unlocked = true;
onUnlockSuccess();
}
} else {
// 未滑到最右,自动弹回起点 (增加体验)
// 但也可以保留位置。这里为了更符合滑动解锁习惯,未完成自动回弹。
if (!unlocked) {
// 回弹动画
sliderThumb.style.transition = "left 0.25s ease-out";
progressFill.style.transition = "width 0.25s ease-out";
updateUI(minLeft);
setTimeout(() => {
sliderThumb.style.transition = "";
progressFill.style.transition = "";
}, 260);
}
}
}

// 绑定滑块事件
sliderThumb.addEventListener("mousedown", handleDragStart);
sliderThumb.addEventListener("touchstart", handleDragStart, {passive: false});

// 防止轨道上点击干扰 (可选:点击轨道也可移动)
sliderTrack.addEventListener("click", (e) => {
// 如果点击的是滑块本身,不处理 (由滑块事件处理)
if (e.target === sliderThumb || sliderThumb.contains(e.target)) return;
// 点击轨道其他位置:不做自动跳转,保持手动拖动逻辑。
});

// 窗口大小变化重新计算边界
window.addEventListener("resize", () => {
updateMaxLeft();
// 如果当前滑块位置超出新的maxLeft,修正
if (currentLeft > maxLeft) {
updateUI(maxLeft);
// 检查是否因此解锁
if (maxLeft > 0 && currentLeft >= maxLeft - 0.5) {
if (!unlocked) {
unlocked = true;
onUnlockSuccess();
}
}
} else if (currentLeft < minLeft) {
updateUI(minLeft);
}
// 如果已解锁但滑块不在最右(极少情况),保持解锁状态不变
});

// 登录按钮点击演示
loginButton.addEventListener("click", () => {
if (!unlocked || loginButton.disabled) return;
statusMessage.textContent = "🎉 登录成功 (模拟)";
alert("滑动验证通过,登录成功!(演示)");
});

// 初始化
updateMaxLeft();
// 确保滑块起始位置
updateUI(minLeft);
unlocked = false;
onUnlockReset();

// 额外暴露全局方法以便Playwright直接通过JS操作(如果需要)
window.sliderAPI = {
reset: resetSlider,
setPosition: setSliderPosition,
getStatus: () => unlocked,
getCurrentLeft: () => currentLeft,
getMaxLeft: () => maxLeft,
forceUnlock: () => {
updateMaxLeft();
updateUI(maxLeft);
unlocked = true;
onUnlockSuccess();
}
};

console.log("滑动解锁模块已启动。使用鼠标拖动滑块“→”完成验证。");
})();
</script>
</body>
</html>

4-2 table4.html

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=yes">
<title>滑块拼图验证码</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}

body {
background: linear-gradient(135deg, #0f172a 0%, #1e293b 100%);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
font-family: "Segoe UI", "Inter", system-ui, -apple-system, sans-serif;
padding: 20px;
}

.login-container {
background: rgba(255, 255, 255, 0.05);
backdrop-filter: blur(25px);
-webkit-backdrop-filter: blur(25px);
border-radius: 28px;
box-shadow: 0 25px 60px rgba(0, 0, 0, 0.5), inset 0 1px 1px rgba(255, 255, 255, 0.1);
padding: 36px 30px;
width: 100%;
max-width: 440px;
border: 1px solid rgba(255, 255, 255, 0.12);
}

h2 {
color: #e2e8f0;
font-weight: 600;
font-size: 1.9rem;
text-align: center;
margin-bottom: 28px;
letter-spacing: 1px;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
}

.form-group {
margin-bottom: 20px;
}

label {
display: block;
color: #94a3b8;
margin-bottom: 6px;
font-size: 0.9rem;
font-weight: 500;
}

input {
width: 100%;
padding: 13px 16px;
background: rgba(15, 23, 42, 0.8);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 14px;
font-size: 0.95rem;
color: #f1f5f9;
outline: none;
transition: all 0.25s;
}

input:focus {
border-color: #60a5fa;
box-shadow: 0 0 14px rgba(96, 165, 250, 0.35);
}

input::placeholder {
color: #64748b;
font-weight: 300;
}

.captcha-area {
margin: 28px 0 18px;
}

.captcha-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
color: #b9c7dd;
font-size: 0.85rem;
}

.refresh-btn {
background: transparent;
border: 1px solid rgba(255, 255, 255, 0.25);
color: #cbd5e1;
padding: 5px 14px;
border-radius: 20px;
cursor: pointer;
font-size: 0.8rem;
transition: 0.2s;
}

.refresh-btn:hover {
background: rgba(255, 255, 255, 0.1);
border-color: #60a5fa;
}

/* 拼图容器 */
.img-ver-container {
position: relative;
background: #1e293b;
border-radius: 16px;
overflow: hidden;
margin-bottom: 18px;
border: 1px solid rgba(255, 255, 255, 0.2);
user-select: none;
}

#imgVer {
position: relative;
width: 100%;
height: 200px;
overflow: hidden;
}

.bg-image {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
filter: brightness(0.9);
}

/* 缺口裁剪层 - 模拟拼图缺口 */
.clip-layer {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
}

.puzzle-slot {
position: absolute;
top: 0;
height: 100%;
width: 50px;
background: rgba(0, 0, 0, 0.35);
box-shadow: inset 0 0 0 2px rgba(255, 255, 255, 0.7);
border-radius: 4px;
pointer-events: none;
}

/* 滑块拼图小块 (浮动) */
.puzzle-piece {
position: absolute;
top: 0;
height: 100%;
width: 50px;
background-size: auto 100%;
background-repeat: no-repeat;
border: 2px solid #fff;
border-radius: 4px;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.6);
z-index: 20;
pointer-events: none;
transition: left 0.0s linear;
}

/* 滑块轨道 */
.slider-wrapper {
position: relative;
margin-top: 6px;
}

.slider-track {
background: #1e2a3a;
height: 48px;
border-radius: 40px;
position: relative;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.25);
box-shadow: inset 0 2px 8px rgba(0, 0, 0, 0.5);
}

.track-text {
position: absolute;
left: 50%;
transform: translateX(-50%);
color: #7c8ba0;
font-weight: 500;
font-size: 0.9rem;
letter-spacing: 0.8px;
top: 50%;
transform: translate(-50%, -50%);
white-space: nowrap;
pointer-events: none;
}

.progress-fill {
width: 0%;
height: 100%;
background: linear-gradient(90deg, #2563eb, #3b82f6);
border-radius: 40px;
position: absolute;
left: 0;
top: 0;
transition: width 0.02s linear;
}

.slider-btn {
position: absolute;
left: 4px;
top: 4px;
width: 40px;
height: 40px;
background: #f8fafc;
border-radius: 50%;
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.7), 0 0 0 2px rgba(255, 255, 255, 0.4);
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
color: #1e3a5f;
z-index: 15;
cursor: grab;
user-select: none;
transition: background 0.1s;
}

.slider-btn:active {
cursor: grabbing;
background: #ffffff;
box-shadow: 0 0 18px #3b82f6;
}

.login-btn {
width: 100%;
padding: 14px;
background: #334155;
border: none;
border-radius: 30px;
color: #cbd5e1;
font-weight: 600;
font-size: 1.1rem;
letter-spacing: 1.5px;
cursor: pointer;
transition: 0.3s;
margin-top: 18px;
opacity: 0.7;
pointer-events: none;
border: 1px solid rgba(255, 255, 255, 0.2);
}

.login-btn.active {
opacity: 1;
pointer-events: auto;
background: linear-gradient(135deg, #2563eb, #1e40af);
color: white;
box-shadow: 0 8px 20px rgba(37, 99, 235, 0.5);
}

.message {
text-align: center;
margin-top: 12px;
color: #94a3b8;
font-size: 0.85rem;
min-height: 22px;
}
</style>
</head>
<body>
<div class="login-container">
<h2>🧩 拼图验证</h2>

<div class="form-group">
<label>👤 用户名</label>
<input type="text" id="username" placeholder="用户名" value="admin">
</div>
<div class="form-group">
<label>🔑 密码</label>
<input type="password" id="password" placeholder="密码" value="123456">
</div>

<!-- 拼图验证码区域 -->
<div class="captcha-area">
<div class="captcha-header">
<span>🔐 安全验证</span>
<button class="refresh-btn" id="refreshCaptcha">↻ 刷新</button>
</div>

<div class="img-ver-container" id="imgVer">
<div style="position: relative; width: 100%; height: 200px;">
<!-- 使用picsum.photos随机图片,支持跨域 -->
<img id="scream" class="bg-image" src="https://picsum.photos/id/1015/400/200" alt="背景图"
crossorigin="anonymous">
<!-- 缺口位置 (动态设置left) -->
<div class="puzzle-slot" id="puzzleSlot" style="left: 140px;"></div>
<!-- 浮动拼图小块 -->
<div class="puzzle-piece" id="puzzlePiece"
style="left: 4px; background-image: url('https://picsum.photos/id/1015/400/200');"></div>
</div>
</div>

<!-- 滑块控制 -->
<div class="slider-wrapper">
<div class="slider-track" id="sliderTrack">
<div class="progress-fill" id="progressFill"></div>
<span class="track-text" id="trackText">→ 滑动拼图到缺口位置</span>
<div class="slider-btn" id="sliderBtn"></div>
</div>
</div>
</div>

<button class="login-btn" id="loginButton" disabled>登 录</button>
<div class="message" id="messageBox"></div>
</div>

<script>
(function () {
// ----- 使用Lorem Picsum图片库 (稳定、支持跨域) -----
const imageIds = [1015, 1016, 1018, 1020, 1025, 1028, 1035, 1040, 1043, 1049];

// 获取随机图片URL
function getRandomImageUrl() {
const randomId = imageIds[Math.floor(Math.random() * imageIds.length)];
return `https://picsum.photos/id/${randomId}/400/200`;
}

// 当前使用的图片URL
let currentImageUrl = getRandomImageUrl();

// DOM 元素
const bgImage = document.getElementById("scream");
const puzzleSlot = document.getElementById("puzzleSlot");
const puzzlePiece = document.getElementById("puzzlePiece");
const sliderTrack = document.getElementById("sliderTrack");
const sliderBtn = document.getElementById("sliderBtn");
const progressFill = document.getElementById("progressFill");
const trackText = document.getElementById("trackText");
const loginButton = document.getElementById("loginButton");
const messageBox = document.getElementById("messageBox");
const refreshBtn = document.getElementById("refreshCaptcha");

// 滑块相关变量
let isDragging = false;
let startMouseX = 0;
let btnStartLeft = 0;
let currentBtnLeft = 4;
const minLeft = 4;
let maxLeft = 0;
const BTN_WIDTH = 40;
const TRACK_PADDING = 4;

// 缺口位置 (随机生成,范围: 40px ~ 轨道可移动最大范围-10)
let slotLeft = 140; // 默认缺口left值 (px)

// 更新maxLeft根据轨道宽度
function updateMaxLeft() {
const trackWidth = sliderTrack.clientWidth;
if (trackWidth > 0) {
maxLeft = trackWidth - BTN_WIDTH - TRACK_PADDING;
if (maxLeft < minLeft) maxLeft = minLeft;
} else {
maxLeft = minLeft;
}
}

// 生成随机缺口位置 (基于图片容器宽度与滑块范围)
function generateRandomSlot() {
const containerWidth = document.querySelector("#imgVer > div").clientWidth;
if (!containerWidth) return 120;
// 缺口宽度50px,确保缺口不超出图片右侧
const maxSlot = containerWidth - 55;
const minSlot = 30;
return Math.floor(Math.random() * (maxSlot - minSlot + 1)) + minSlot;
}

// 应用缺口位置到UI
function applySlotPosition(leftPx) {
puzzleSlot.style.left = leftPx + "px";
// 浮动拼图初始位置在滑块对应起点
puzzlePiece.style.left = minLeft + "px";
// 更新浮动拼图的背景位置
updatePieceBackground(leftPx);
}

// 更新浮动拼图块的背景定位
function updatePieceBackground(slotLeftValue) {
const containerWidth = document.querySelector("#imgVer > div").clientWidth;
if (!containerWidth) return;

// 图片实际显示宽度,计算缩放比例
const displayWidth = containerWidth;
// 假设图片原始宽度为400px (picsum图片)
const imgNaturalWidth = 400;
const ratio = imgNaturalWidth / displayWidth;

// 计算背景偏移,使浮动块显示图片对应缺口区域
const bgX = -slotLeftValue * ratio;

puzzlePiece.style.backgroundImage = `url("${bgImage.src}")`;
puzzlePiece.style.backgroundSize = `${displayWidth}px 100%`;
puzzlePiece.style.backgroundPosition = `${bgX}px 0px`;
}

// 刷新验证码:更换图片,重新随机缺口
function refreshCaptcha() {
currentImageUrl = getRandomImageUrl();
bgImage.src = currentImageUrl;
puzzlePiece.style.backgroundImage = `url("${currentImageUrl}")`;

// 图片加载后重新设置缺口与拼图
bgImage.onload = function () {
slotLeft = generateRandomSlot();
applySlotPosition(slotLeft);
resetSlider();
messageBox.textContent = "";
loginButton.classList.remove("active");
loginButton.disabled = true;
};

// 如果图片已缓存直接触发
if (bgImage.complete) {
slotLeft = generateRandomSlot();
applySlotPosition(slotLeft);
resetSlider();
messageBox.textContent = "";
loginButton.classList.remove("active");
loginButton.disabled = true;
}
}

// 重置滑块到起始位置
function resetSlider() {
updateMaxLeft();
currentBtnLeft = minLeft;
sliderBtn.style.left = minLeft + "px";
progressFill.style.width = "0%";
puzzlePiece.style.left = minLeft + "px";
trackText.textContent = "→ 滑动拼图到缺口位置";
trackText.style.color = "#7c8ba0";
loginButton.classList.remove("active");
loginButton.disabled = true;
}

// 根据滑块位置更新UI及拼图位置
function updateSliderAndPiece(leftValue) {
if (leftValue < minLeft) leftValue = minLeft;
if (leftValue > maxLeft) leftValue = maxLeft;

currentBtnLeft = leftValue;
sliderBtn.style.left = leftValue + "px";

// 进度条
const range = maxLeft - minLeft;
let percent = 0;
if (range > 0) percent = ((leftValue - minLeft) / range) * 100;
progressFill.style.width = percent + "%";

// 移动浮动拼图块 (与滑块同步)
puzzlePiece.style.left = leftValue + "px";

// 判断是否对齐缺口 (容差5px)
const tolerance = 5;
if (Math.abs(leftValue - slotLeft) <= tolerance) {
// 验证成功
trackText.textContent = "✓ 验证通过";
trackText.style.color = "#86efac";
loginButton.disabled = false;
loginButton.classList.add("active");
messageBox.textContent = "✅ 拼图位置正确";
} else {
trackText.textContent = "→ 滑动拼图到缺口位置";
trackText.style.color = "#7c8ba0";
loginButton.disabled = true;
loginButton.classList.remove("active");
messageBox.textContent = "";
}
}

// --- 拖动事件处理 ---
function handleDragStart(e) {
e.preventDefault();
isDragging = true;
sliderBtn.style.cursor = "grabbing";
const clientX = e.touches ? e.touches[0].clientX : e.clientX;
startMouseX = clientX;
btnStartLeft = currentBtnLeft;
updateMaxLeft();

window.addEventListener("mousemove", handleDragMove);
window.addEventListener("mouseup", handleDragEnd);
window.addEventListener("touchmove", handleDragMove, {passive: false});
window.addEventListener("touchend", handleDragEnd);
window.addEventListener("touchcancel", handleDragEnd);
}

function handleDragMove(e) {
if (!isDragging) return;
e.preventDefault();
const clientX = e.touches ? e.touches[0].clientX : e.clientX;
const deltaX = clientX - startMouseX;
let newLeft = btnStartLeft + deltaX;
updateSliderAndPiece(newLeft);
}

function handleDragEnd() {
if (!isDragging) return;
isDragging = false;
sliderBtn.style.cursor = "grab";
window.removeEventListener("mousemove", handleDragMove);
window.removeEventListener("mouseup", handleDragEnd);
window.removeEventListener("touchmove", handleDragMove);
window.removeEventListener("touchend", handleDragEnd);
window.removeEventListener("touchcancel", handleDragEnd);

// 如果未对齐,自动回弹
const tolerance = 5;
if (Math.abs(currentBtnLeft - slotLeft) > tolerance) {
sliderBtn.style.transition = "left 0.25s ease-out";
puzzlePiece.style.transition = "left 0.25s ease-out";
progressFill.style.transition = "width 0.25s ease-out";
updateSliderAndPiece(minLeft);
setTimeout(() => {
sliderBtn.style.transition = "";
puzzlePiece.style.transition = "";
progressFill.style.transition = "";
}, 260);
}
}

// 绑定滑块事件
sliderBtn.addEventListener("mousedown", handleDragStart);
sliderBtn.addEventListener("touchstart", handleDragStart, {passive: false});

// 刷新按钮
refreshBtn.addEventListener("click", refreshCaptcha);

// 登录按钮
loginButton.addEventListener("click", () => {
if (loginButton.disabled) return;
alert("🎉 拼图验证通过,登录成功!(演示)");
messageBox.textContent = "登录成功";
});

// 窗口resize重新计算
window.addEventListener("resize", () => {
updateMaxLeft();
if (currentBtnLeft > maxLeft) {
updateSliderAndPiece(maxLeft);
}
updatePieceBackground(slotLeft);
});

// 初始化
function init() {
updateMaxLeft();
// 使用新图片URL
currentImageUrl = getRandomImageUrl();
bgImage.src = currentImageUrl;
puzzlePiece.style.backgroundImage = `url("${currentImageUrl}")`;

slotLeft = generateRandomSlot();
applySlotPosition(slotLeft);
resetSlider();

// 确保图片加载完成后更新背景
if (bgImage.complete) {
updatePieceBackground(slotLeft);
} else {
bgImage.onload = () => updatePieceBackground(slotLeft);
}
}

init();

// 暴露全局方法以便Playwright操作
window.captchaAPI = {
getSlotLeft: () => slotLeft,
getCurrentLeft: () => currentBtnLeft,
refresh: refreshCaptcha,
reset: resetSlider,
};

console.log("滑块拼图验证码已启动。缺口位置(left):", slotLeft);
})();
</script>
</body>
</html>

4-3 table5.html

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=yes">
<title>登录验证码识别</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}

body {
background: linear-gradient(135deg, #0f172a 0%, #1e293b 100%);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
font-family: "Segoe UI", "Inter", system-ui, -apple-system, sans-serif;
padding: 20px;
}

.login-container {
background: rgba(255, 255, 255, 0.05);
backdrop-filter: blur(25px);
-webkit-backdrop-filter: blur(25px);
border-radius: 28px;
box-shadow: 0 25px 60px rgba(0, 0, 0, 0.5), inset 0 1px 1px rgba(255, 255, 255, 0.1);
padding: 36px 30px;
width: 100%;
max-width: 420px;
border: 1px solid rgba(255, 255, 255, 0.12);
}

h2 {
color: #e2e8f0;
font-weight: 600;
font-size: 1.8rem;
text-align: center;
margin-bottom: 28px;
letter-spacing: 1px;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
}

.form-group {
margin-bottom: 20px;
}

label {
display: block;
color: #94a3b8;
margin-bottom: 6px;
font-size: 0.9rem;
font-weight: 500;
}

input {
width: 100%;
padding: 13px 16px;
background: rgba(15, 23, 42, 0.8);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 14px;
font-size: 0.95rem;
color: #f1f5f9;
outline: none;
transition: all 0.25s;
}

input:focus {
border-color: #60a5fa;
box-shadow: 0 0 14px rgba(96, 165, 250, 0.35);
}

input::placeholder {
color: #64748b;
font-weight: 300;
}

.captcha-group {
margin-bottom: 24px;
}

.captcha-box {
display: flex;
align-items: center;
gap: 12px;
}

.captcha-input {
flex: 1;
}

.captcha-image {
position: relative;
cursor: pointer;
border-radius: 10px;
overflow: hidden;
border: 2px solid rgba(255, 255, 255, 0.2);
transition: all 0.3s;
flex-shrink: 0;
}

.captcha-image:hover {
border-color: #60a5fa;
box-shadow: 0 0 15px rgba(96, 165, 250, 0.3);
}

.captcha-image:active {
transform: scale(0.95);
}

#canvas {
display: block;
width: 120px;
height: 48px;
background: #f8fafc;
border-radius: 8px;
}

.refresh-text {
position: absolute;
bottom: 2px;
right: 6px;
font-size: 0.65rem;
color: #64748b;
background: rgba(0, 0, 0, 0.5);
padding: 1px 5px;
border-radius: 3px;
pointer-events: none;
}

.submit-btn {
width: 100%;
padding: 14px;
background: linear-gradient(135deg, #3b82f6, #1d4ed8);
border: none;
border-radius: 30px;
color: white;
font-weight: 600;
font-size: 1.1rem;
letter-spacing: 1.5px;
cursor: pointer;
transition: all 0.3s;
margin-top: 8px;
border: 1px solid rgba(255, 255, 255, 0.2);
box-shadow: 0 8px 20px rgba(37, 99, 235, 0.4);
}

.submit-btn:hover {
background: linear-gradient(135deg, #4f8df7, #2563eb);
box-shadow: 0 10px 25px rgba(37, 99, 235, 0.6);
transform: translateY(-1px);
}

.submit-btn:active {
transform: translateY(0);
}

.message {
text-align: center;
margin-top: 14px;
color: #94a3b8;
font-size: 0.85rem;
min-height: 22px;
}

.message.success {
color: #4ade80;
}

.message.error {
color: #f87171;
}

.divider {
border-top: 1px solid rgba(255, 255, 255, 0.1);
margin: 20px 0;
}
</style>
</head>
<body>
<div class="login-container">
<h2>🔐 验证码登录</h2>

<div class="form-group">
<label>👤 用户名</label>
<input type="text" id="username" placeholder="请输入用户名" value="admin">
</div>

<div class="form-group">
<label>🔑 密码</label>
<input type="password" id="password" placeholder="请输入密码" value="123456">
</div>

<div class="captcha-group">
<label>🔒 验证码</label>
<div class="captcha-box">
<div class="captcha-input">
<input type="text" id="captchaInput" placeholder="请输入验证码(不区分大小写)" maxlength="4"
autocomplete="off">
</div>
<div class="captcha-image" id="captchaImage" title="点击刷新验证码">
<canvas id="canvas" width="120" height="48"></canvas>
<span class="refresh-text">↻ 刷新</span>
</div>
</div>
</div>

<button class="submit-btn" id="submitBtn">提 交</button>
<div class="message" id="messageBox"></div>

<div class="divider"></div>
<div style="text-align:center; color:#64748b; font-size:0.75rem;">
💡 点击验证码图片可刷新 | 不区分大小写
</div>
</div>

<script>
(function () {
// ============ 验证码生成逻辑 ============
const canvas = document.getElementById("canvas");
const ctx = canvas.getContext("2d");
const captchaImage = document.getElementById("captchaImage");
const captchaInput = document.getElementById("captchaInput");
const submitBtn = document.getElementById("submitBtn");
const messageBox = document.getElementById("messageBox");
const usernameInput = document.getElementById("username");
const passwordInput = document.getElementById("password");

// 存储当前验证码
let currentCaptcha = "";

// 生成随机验证码(4位,字母数字混合)
function generateCaptchaText() {
const chars = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789";
let code = "";
for (let i = 0; i < 4; i++) {
code += chars.charAt(Math.floor(Math.random() * chars.length));
}
return code;
}

// 生成随机颜色
function randomColor(min = 0, max = 255) {
const r = Math.floor(Math.random() * (max - min + 1)) + min;
const g = Math.floor(Math.random() * (max - min + 1)) + min;
const b = Math.floor(Math.random() * (max - min + 1)) + min;
return `rgb(${r}, ${g}, ${b})`;
}

// 绘制验证码到Canvas
function drawCaptcha(text) {
// 清空画布
ctx.clearRect(0, 0, canvas.width, canvas.height);

// 背景色
ctx.fillStyle = "#f0f4ff";
ctx.fillRect(0, 0, canvas.width, canvas.height);

// 添加干扰线(多条)
for (let i = 0; i < 6; i++) {
ctx.strokeStyle = randomColor(100, 200);
ctx.lineWidth = Math.random() * 2 + 0.5;
ctx.beginPath();
ctx.moveTo(Math.random() * canvas.width, Math.random() * canvas.height);
ctx.lineTo(Math.random() * canvas.width, Math.random() * canvas.height);
ctx.stroke();
}

// 添加干扰点
for (let i = 0; i < 30; i++) {
ctx.fillStyle = randomColor(100, 200);
ctx.beginPath();
ctx.arc(
Math.random() * canvas.width,
Math.random() * canvas.height,
Math.random() * 1.5,
0,
Math.PI * 2
);
ctx.fill();
}

// 绘制文字(每个字符独立绘制,增加旋转和偏移)
const fontSize = 28;
ctx.font = `bold ${fontSize}px "Segoe UI", Arial, sans-serif`;
ctx.textBaseline = "middle";

for (let i = 0; i < text.length; i++) {
const char = text[i];
const x = 15 + i * 25 + Math.random() * 4;
const y = canvas.height / 2 + (Math.random() - 0.5) * 10;

// 随机旋转角度
const angle = (Math.random() - 0.5) * 0.4;
ctx.save();
ctx.translate(x, y);
ctx.rotate(angle);

// 随机颜色(深色确保可读性)
ctx.fillStyle = randomColor(0, 100);
ctx.fillText(char, 0, 0);
ctx.restore();
}

// 添加边框
ctx.strokeStyle = "rgba(0,0,0,0.1)";
ctx.lineWidth = 1;
ctx.strokeRect(0, 0, canvas.width, canvas.height);
}

// 刷新验证码
function refreshCaptcha() {
currentCaptcha = generateCaptchaText();
drawCaptcha(currentCaptcha);
captchaInput.value = "";
messageBox.textContent = "";
messageBox.className = "message";
console.log("当前验证码:", currentCaptcha); // 方便调试
}

// 验证用户输入
function validateCaptcha(input) {
return input.toUpperCase() === currentCaptcha.toUpperCase();
}

// 提交处理
function handleSubmit() {
const username = usernameInput.value.trim();
const password = passwordInput.value.trim();
const captchaValue = captchaInput.value.trim();

// 基础验证
if (!username) {
showMessage("请输入用户名", "error");
return;
}
if (!password) {
showMessage("请输入密码", "error");
return;
}
if (!captchaValue) {
showMessage("请输入验证码", "error");
return;
}

// 验证码校验
if (validateCaptcha(captchaValue)) {
// 验证成功,弹出提示
showMessage("✅ 验证成功", "success");
setTimeout(() => {
alert("提交成功!");
}, 300);

// 可选:刷新验证码
setTimeout(() => {
refreshCaptcha();
}, 1000);
} else {
// 验证失败
showMessage("❌ 验证码错误,请重新输入", "error");
refreshCaptcha();
}
}

// 显示消息
function showMessage(text, type) {
messageBox.textContent = text;
messageBox.className = `message ${type}`;
}

// ============ 事件监听 ============

// 点击验证码图片刷新
captchaImage.addEventListener("click", refreshCaptcha);

// 提交按钮点击
submitBtn.addEventListener("click", handleSubmit);

// 回车键提交
captchaInput.addEventListener("keypress", (e) => {
if (e.key === "Enter") {
handleSubmit();
}
});

// 自动转大写(可选)
captchaInput.addEventListener("input", (e) => {
// 保持原样,不自动转换,验证时统一转大写比较
});

// ============ 初始化 ============
function init() {
refreshCaptcha();
// 聚焦到用户名输入框
usernameInput.focus();
}

init();

// 暴露API供外部调用(如Playwright)
window.captchaAPI = {
getCurrentCaptcha: () => currentCaptcha,
refresh: refreshCaptcha,
validate: validateCaptcha,
};

console.log("验证码页面已就绪。当前验证码:", currentCaptcha);
})();
</script>
</body>
</html>

4-4 身份验证方式

  • 身份验证方式:以“页面对象模型”中搭建的“JForum论坛”为例进行说明。
    • 保存登录cookie:先登录,将cookie保存到本地,通过加载cookie的方式解决重复登录的问题。
    • Web应用程序使用基于cookie或基于令牌的身份验证,其中经过身份验证的状态存储为cookie或本地存储。
    • Playwright提供方法用于从经过身份验证的上下文中检索存储状态,然后创建具有预填充状态的新上下文。
    • 会话存储:会话存储很少用于存储与登录状态相关的信息,常用于特定域,并且不会跨页面加载持续存在。
    • 保存登录的cookie或基于本地存储状态的身份验证,可以跨不同的浏览器使用,取决于应用程序的身份验证模型。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
from playwright.sync_api import sync_playwright


with sync_playwright() as p:
browser = p.chromium.launch(headless=False)
context = browser.new_context()
page = context.new_page()
page.goto("http://127.0.0.1:8080/jforum-2.7.0/user/login.page")

page.locator(
"//*[@id='loginform']/table[2]/tbody/tr[2]/td/table/tbody/tr[2]/td[2]/input"
).fill("admin")
page.locator(
"//*[@id='loginform']/table[2]/tbody/tr[2]/td/table/tbody/tr[3]/td[2]/input"
).fill("123456")
page.locator(
"//*[@id='loginform']/table[2]/tbody/tr[2]/td/table/tbody/tr[5]/td/input[2]"
).click() # 需要事先创建auth目录,否则报错

storage = context.storage_state(path="auth/state.json") # 保存storageState到指定的文件

context.close() # 关闭上下文
browser.close() # 关闭浏览器对象

(1) 生成state.json

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
{
"cookies": [
{
"name": "JSESSIONID",
"value": "B2BAB0E2E765073EA45904A266F0F1C0",
"domain": "127.0.0.1",
"path": "/jforum-2.7.0",
"expires": -1,
"httpOnly": true,
"secure": false,
"sameSite": "Lax"
},
{
"name": "jforumUserId",
"value": "2",
"domain": "127.0.0.1",
"path": "/jforum-2.7.0",
"expires": 1732842342.156105,
"httpOnly": true,
"secure": false,
"sameSite": "Lax"
}
],
"origins": []
}

(2) 验证是否免登

1
2
3
4
5
6
7
8
9
10
11
12
13
14
from playwright.sync_api import sync_playwright

with sync_playwright() as p:
browser = p.chromium.launch(headless=False)
context = browser.new_context(
storage_state="auth/state.json"
) # 加载本地cookies,实现免登
page = context.new_page()
page.wait_for_timeout(1000)
page.goto("http://127.0.0.1:8080/jforum-2.7.0/forums/list.page")
page.pause() # 打断点查看是否免登

context.close() # 关闭上下文
browser.close() # 关闭浏览器对象

(3) 本地会话存储

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import os
from playwright.sync_api import sync_playwright

with sync_playwright() as p:
browser = p.chromium.launch(headless=False)
context = browser.new_context()
page = context.new_page() # 导航到特定页面
page.goto("http://127.0.0.1:8080/jforum-2.7.0/user/login.page")
session_storage = page.evaluate( # 获取sessionStorage
"() => JSON.stringify(sessionStorage)"
)
os.environ["SESSION_STORAGE"] = session_storage # 将其存储为环境变量
session_storage = os.environ["SESSION_STORAGE"] # 在新的上下文中设置sessionStorage
context.add_init_script("""(storage => {
if (window.location.hostname === "example.com") {
const entries = JSON.parse(storage)
for (const [key, value] of Object.entries(entries)) {
window.sessionStorage.setItem(key, value)
}
}
})('""" + session_storage + "')")

session_storage = os.getenv("SESSION_STORAGE") # 检查环境变量是否设置了SESSION_STORAGE
if session_storage:
print("环境变量SESSION_STORAGE已经设置")
else:
print("环境变量SESSION_STORAGE尚未设置")

context.close() # 关闭上下文
browser.close() # 关闭浏览器对象

4-5 绕过登录验证码

  • 绕过登录验证码:以“页面对象模型”中搭建的“JForum论坛”为例进行说明。
    • 浏览器手工登录,再使用Playwright接管页面,绕过登录验证码操作。
    • 找到Chrome属性,复制Chrome的起始位置,添加到Path环境变量下。
    • 参数说明
      • --start-maximized窗口最大化。
      • --incognito隐私模式打开、--new-window直接打开网址。
      • --remote-debugging-port指定运行端口,需确保没被占用。
      • --user-data-dir指定运行浏览器的运行数据,新建目录,不影响系统原数据。
    • chrome.exe --remote-debugging-port=12345 --incognito --start-maximized --user-data-dir=
    • 'D:\Project\Python\demo' --new-window http://127.0.0.1:8080/jforum-2.7.0/user/login.page
    • CMD窗口输入命令启动浏览器,并打开JForum论坛,手动输入账号密码登录后,执行以下Playwright代码。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
from playwright.sync_api import sync_playwright

with sync_playwright() as p:
browser = p.chromium.connect_over_cdp(
"http://localhost:12345/" # 确保chrome没有执行其他任务
) # 连接现有的Chromium实例
page = browser.contexts[0].pages[0] # 获取page对象
print(page.title())
print(page.url)
page.get_by_role("link", name="进入后台管理").click()

# connect_over_cdp()方法将现有的Chromium浏览器实例与Playwright代码进行绑定
# 而非在Playwright中创建一个新的浏览器实例,不需要再显示地关闭已连接的浏览器实例
# browser.close()

4-6 登录页滑动解锁

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
from playwright.sync_api import sync_playwright

with sync_playwright() as p:
browser = p.chromium.launch(headless=False, slow_mo=1000)
context = browser.new_context() # 创建上下文
page = context.new_page() # 创建新页面
page.goto(r"...\html\index.html")

with context.expect_page() as new_page_info: # 切换到滑块页面
page.get_by_role("link", name="登录页滑动解锁").click()
new_page = new_page_info.value
new_page.wait_for_load_state() # 等待页面加载到指定状态

slider = new_page.get_by_text("→", exact=True).bounding_box()
slider_x = slider["x"] + slider["width"] // 2 # 滑块中心点的x坐标
slider_y = slider["y"] + slider["height"] // 2 # 滑块中心点的y坐标
target = new_page.get_by_text("→ 向右滑动").bounding_box()
target_x = target["width"] # 滑块通道总长
print("x坐标:", slider_x, "\ny坐标:", slider_y, "\n最右侧x坐标:", slider_x + target_x)

new_page.mouse.move(slider_x, slider_y)
new_page.mouse.down() # 按住鼠标左键
for _ in range(10): # 循环执行代码10次
slider_x += 50 # 逐步移动鼠标至滑块右侧
new_page.mouse.move(slider_x, slider_y)
print("x坐标+50:", slider_x)
new_page.mouse.up() # 释放鼠标左键

browser.close() # 关闭浏览器对象

4-7 滑块拼图验证码

  • 滑块拼图验证码
    • 首先需要得到滑块背景图,然后计算缺口位置,最后可能还要绕过防爬虫机制,计算滑动轨迹。
    • 滑块背景图可以获取到一个图片地址,缺口可能获取到图片地址,可能由Canvas标签绘制而成。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
import re
from playwright.sync_api import sync_playwright

with sync_playwright() as p:
browser = p.chromium.launch(headless=False, slow_mo=1000)
context = browser.new_context() # 创建上下文
page = context.new_page() # 创建新页面
page.goto(r"...\html\index.html")

with context.expect_page() as new_page_info: # 切换到滑块拼图页
page.get_by_role("link", name="滑块拼图验证码").click()
new_page = new_page_info.value
new_page.wait_for_load_state() # 等待页面加载到指定状态

new_page.get_by_placeholder("用户名").fill("admin") # 填写表单
new_page.get_by_placeholder("密码").fill("123456")

src = new_page.locator("//*[@id='scream']").get_attribute("src")
print(f"背景图地址:{src}")

canvas_style = new_page.locator("#puzzleSlot").get_attribute("style")
left_value = re.search(r"left:\s*(-?\d+)px", canvas_style).group(1)
print(f"缺口需移动的距离:{left_value}")

canvas = new_page.locator("#imgVer").bounding_box()
canvas_x = canvas["x"] # 左边距
canvas_y = canvas["y"] # 上边距
print(f"canvas位置:左边距={canvas_x}, 上边距={canvas_y}")

slider = new_page.locator(".slider-btn").bounding_box()
print(f"滑块位置:{slider}") # 返回滑块的x,y,滑块大小

slider_center_x = slider["x"] + slider["width"] / 2 # 计算滑块中心点
slider_center_y = slider["y"] + slider["height"] / 2

new_page.mouse.move(slider_center_x, slider_center_y) # 模拟拖动滑块到缺口位置
new_page.mouse.down()
new_page.wait_for_timeout(500)

target_x = slider_center_x + int(left_value) # 逐步移动滑块到目标位置
steps = 20
step_distance = int(left_value) / steps

for i in range(steps):
current_x = slider_center_x + step_distance * (i + 1)
new_page.mouse.move(current_x, slider_center_y)
new_page.wait_for_timeout(50) # 模拟人类拖动速度

new_page.mouse.up()
new_page.wait_for_timeout(1000)

success_text = new_page.locator("#trackText").text_content()
print(f"验证结果:{success_text}") # 验证是否成功

if "验证通过" in success_text:
new_page.get_by_role("button", name="登 录").click()
new_page.wait_for_timeout(2000)
print("登录成功!")
else:
print("验证失败,尝试其他方式!")

new_page.wait_for_timeout(3000)
browser.close() # 关闭浏览器对象

4-8 登录验证码识别

  • 登录验证码识别
    • 思路:先获取到验证码图片,定位元素,对元素截图即可,再使用ddddocr库快速识别。
    • 命令安装ddddocr库:pip install ddddocr -i https://mirrors.aliyun.com/pypi/simple/
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
import ddddocr
from playwright.sync_api import sync_playwright


def handle_dialog(dialog): # 触发弹出窗口的操作未知时使用
print(dialog.message) # 获取对话框中显示的消息
assert dialog.message == "提交成功!"
dialog.dismiss() # 关闭对话框


with sync_playwright() as p:
browser = p.chromium.launch(headless=False, slow_mo=1000)
context = browser.new_context() # 创建上下文
page = context.new_page() # 创建新页面
page.goto(r"...\html\index.html")

with context.expect_page() as new_page_info: # 切换到验证码页
page.get_by_role("link", name="登录验证码识别").click()
new_page = new_page_info.value
new_page.wait_for_load_state() # 等待页面加载到指定状态

new_page.locator(
"#canvas"
).screenshot(path="pictures/code.png") # 保存验证码

ocr = ddddocr.DdddOcr(show_ad=False) # 识别验证码,实例化
with open("pictures/code.png", "rb") as f: # 打开图片
img_bytes = f.read() # 读取图片
code = ocr.classification(img_bytes) # 识别验证码
print(f"验证码:{code}")

new_page.get_by_placeholder("请输入验证码(不区分大小写)").fill(code)
# iframe.get_by_role("button", name="提 交").click()

# 点击提交按钮前,先注册弹窗处理函数,确保弹窗出现时被正确处理
new_page.on("dialog", handle_dialog)
new_page.get_by_role("button", name="提 交").click()
page.wait_for_timeout(5000)

browser.close() # 关闭浏览器对象

4-9 多线程登录账号

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
from threading import Thread
from playwright.sync_api import sync_playwright


def do_some_thing(username, password):
p = sync_playwright().start()
browser = p.chromium.launch(headless=False, slow_mo=1000)
context = browser.new_context()
page = context.new_page()
page.goto("http://127.0.0.1:8080/jforum-2.7.0/user/login.page")

page.locator("input[name=\"username\"]").fill(username)
page.locator("input[name=\"password\"]").fill(password)
page.get_by_role("button", name="登入").click()

page.pause() # 暂停查看效果
context.close()
browser.close() # 关闭浏览器对象


users = [["Admin", "123456"], ["Elara", "Aa.123456"], ["Celeste", "Aa.123456"]]
for user in users: # 多线程,3个账号同时操作
thread = Thread(target=do_some_thing, args=user)
thread.start()

5 账号切换操作

  • 账号切换操作
    • 以“页面对象模型”中搭建的“JForum论坛”为例进行说明。
      • 用户的发帖与回帖功能,需使用Admin管理员账号,在后台管理中将验证码关闭。
      • 双账号切换操作:代码实现Admin账号登录进行发帖,Elianis账号登录进行回帖。
      • 这里的conftest.py文件在根目录,会被“页面对象模型”的conftest.py文件取代。
    • conftest.py文件的优先级问题
      • 测试文件所在目录中的conftest.py文件,具有最高的优先级。
      • 测试文件所在父目录的conftest.py文件其次,依次向上查找。
      • 项目根目录中的conftest.py文件优先级最低。
1
2
3
4
5
6
7
8
cases:                                                           # 用于放置测试用例
- test_login.py
- __init__.py # 空
- conftest.py # 用于定义共享的fixtures和钩子函数hooks
models: # 用于放置封装好的类文件
- post_page.py
- login_page.py
- __init__.py # 空

5-1 conftest.py

1
2
3
4
5
6
7
8
9
10
11
12
import pytest                                                   # conftest.py
from playwright.sync_api import sync_playwright


@pytest.fixture(scope="session")
def context_chrome():
p = sync_playwright().start() # 前置操作代码
browser = p.chromium.launch(headless=False, slow_mo=1000)
yield browser

browser.close() # 后置操作代码
p.stop()

5-2 post_page.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
from playwright.sync_api import Page                            # post_page.py


class PostPage:
def __init__(self, page: Page):
self.page = page

self.locator_section = page.locator(
"body > table > tbody > tr:nth-child(2) > td > table:nth-child(5) > tbody > tr > td "
"> table.forumline.forumlist > tbody > tr:nth-child(3) > td:nth-child(2) > span.forumlink > a"
) # 定位每日一言版块
self.locator_topic = page.locator(
"body > table > tbody > tr:nth-child(2) > td > table:nth-child(6) "
"> tbody > tr > td > table:nth-child(2) > tbody > tr > td:nth-child(1)"
) # 定位发表主题按钮

self.locator_post = page.get_by_role("link", name="每日英语")
self.locator_english = page.locator(
".bodyline > table:nth-child(2) > tbody > tr > td"
).first

self.locator_subject = page.locator("input[name=\"subject\"]")
self.locator_message = page.locator("textarea[name=\"message\"]")
self.locator_sending = page.get_by_role("button", name="发送")

def navigate_daily_word(self):
self.locator_section.click()
self.locator_topic.click()

def posted(self, subject, message): # 发帖
self.locator_subject.fill(subject)
self.locator_message.fill(message)
self.locator_sending.click()

def navigate_daily_english(self):
self.locator_section.click()
self.locator_post.click()
self.locator_english.click()

def reply(self, message): # 回帖
self.locator_message.fill(message)
self.locator_sending.click()

5-3 login_page.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
from playwright.sync_api import Page                            # login_page.py


class LoginPage:
def __init__(self, page: Page):
self.page = page
self.locator_username = page.locator("input[name=\"username\"]")
self.locator_password = page.locator("input[name=\"password\"]")
self.locator_login_btn = page.get_by_role("button", name="登入")

def navigate(self): # 导航到登录页面
self.page.goto("http://127.0.0.1:8080/jforum-2.7.0/user/login.page")

def login(self, username, password): # 登录
self.locator_username.fill(username)
self.locator_password.fill(password)
self.locator_login_btn.click()

5-4 test_login.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
import pytest                                                   # test_login.py
import requests
from models.post_page import PostPage
from models.login_page import LoginPage


def message_content():
response = requests.get("https://api.vvhan.com/api/dailyEnglish?type=sj")
if response.status_code == 200:
data = response.json() # 解析JSON响应
zh_content = data["data"]["zh"] # 获取zh和en内容
en_content = data["data"]["en"]
print(f"{en_content}") # 打印内容
print(f"{zh_content}")
return f"{en_content}" + "/n" + f"{zh_content}"
else:
print("API获取每日英语失败。")
return "API获取每日英语失败。"


class TestMoreAccounts: # 多账号切换操作
"""
测试流程:
step--Admin登录,发帖
step--Elianis登录,回帖
:return:
"""
@pytest.fixture(autouse=True) # True为作用域内的用例自动调用
def start_for_each(self, context_chrome):
print("访问登录页")

admin_context = context_chrome.new_context() # 管理员登录
admin_page = admin_context.new_page()
self.user_admin_login = LoginPage(admin_page)
self.user_admin_login.navigate() # 每个账户操作
self.user_admin_post = PostPage(admin_page) # 都将产生两个浏览器???

general_context = context_chrome.new_context() # 普通用户登录
general_page = general_context.new_page()
self.uesr_general_login = LoginPage(general_page)
self.uesr_general_login.navigate()
self.user_general_reply = PostPage(general_page)

yield # 将一个函数变成一个生成器
print("关闭登录页")
admin_context.close()
general_context.close() # 关闭上下文

def test_admin(self):
self.user_admin_login.login("Admin", "123456")
self.user_admin_post.navigate_daily_word()
self.user_admin_post.posted("每日英语", message_content())

def test_general(self):
self.uesr_general_login.login("Elianis", "Aa.123456")
self.user_general_reply.navigate_daily_english()
self.user_general_reply.reply(message_content())


if __name__ == "__main__":
pytest.main()

6 Mock接口返回

  • Mock接口返回
    • Playwright提供API,用来模拟和修改网络请求,包括HTTP和HTTPS。
    • 页面执行的任何请求包括XHR和获取请求,都可以被跟踪、修改和模拟。
    • 使用page.route()方法创建Route对象,指定要拦截的请求URL,或使用正则表达式进行匹配。
    • 创建Route对象后,通过调用route.abort()route.fulfill()等来控制请求的进一步处理。

6-1 方法

  • 方法
    • route.abort(),中止请求,可以选择指定的错误代码。
    • route.fetch(),执行请求并在不满足的情况下获取结果,以修改完成响应。
    • route.fulfill(),用于模拟完成请求,即手动提供响应数据,并结束请求。
    • route.fallback(),用于指定当请求未匹配到任何拦截规则时候的回退行为。
    • route.continue_(),继续请求,使其按照正常的流程继续发送,并接收响应。

(1) abort()*

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
from playwright.sync_api import sync_playwright


def intercept_request(route, request):
if request.url.startswith("https://www.amap.com"):
print(f"Intercepted request to: {request.url}")
route.abort() # 中止请求


with sync_playwright() as p:
browser = p.chromium.launch(headless=False, slow_mo=1000)
page = browser.new_page()
page.route( # 监听请求并拦截
"**/*", lambda route, request: intercept_request(route, request)
)
page.goto("https://www.amap.com/smog") # 报错,无法访问网站???
browser.close() # 关闭浏览器对象

(2) fetch()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
from playwright.sync_api import sync_playwright


def handle(route):
response = route.fetch() # 获取网络请求的响应
json = response.json() # 将响应转换成Json格式
json["result"]["big_red_dog"] = [] # 添加响应数据big_red_dog,属性值设为空
route.fulfill(response=response, json=json)


with sync_playwright() as p:
browser = p.chromium.launch(headless=False, slow_mo=1000)
page = browser.new_page()
page.route("https://api.oioweb.cn/api/SoulWords", handle) # 暂停后,在当前页面访问接口地址
page.pause() # F12查看响应数据的添加情况
browser.close() # 关闭浏览器对象

(3) fulfill()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
from playwright.sync_api import sync_playwright


def intercept_request(route, request):
if request.url.startswith("https://api.oioweb.cn/api/SoulWords"):
print(f"Intercepted request to: {request.url}")
route.fulfill(
status=200, # 状态码
body="{'message': 'Hello, World!'}", # 响应体
headers={"Content-Type": "application/json"} # 响应头
) # 模拟完成请求
else:
route.continue_() # 条件不符则允许请求正常进行


with sync_playwright() as p:
browser = p.chromium.launch(headless=False, slow_mo=1000)
page = browser.new_page()
page.route(
"**/*", lambda route, request: intercept_request(route, request)
) # 监听请求并拦截
page.goto("https://api.oioweb.cn/api/SoulWords")
page.pause()
browser.close() # 关闭浏览器对象

(4) fallback()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
from playwright.sync_api import sync_playwright


def intercept_request(route, request):
if request.url.startswith("https://www.amap.com/smog"):
print(f"Intercepted request to: {request.url}")
route.abort() # 中止请求
else:
print(f"Fallback: {request.url}") # 打印回退消息
route.fallback() # 触发回退行为,允许请求正常继续


with sync_playwright() as p:
browser = p.chromium.launch(headless=False, slow_mo=1000)
page = browser.new_page()
page.route( # 监听请求并拦截
"**/*", lambda route, request: intercept_request(route, request)
)
page.goto("https://www.amap.com")
browser.close() # 关闭浏览器对象

(5) continue_()*

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
from playwright.sync_api import sync_playwright


def intercept_request(route, request):
if request.url.startswith("https://www.amap.com"):
print(f"Intercepted request to: {request.url}")
route.continue_() # 继续请求,报错???


with sync_playwright() as p:
browser = p.chromium.launch(headless=False, slow_mo=1000)
page = browser.new_page()
page.route( # 监听请求并拦截
"**/*", lambda route, request: intercept_request(route, request)
)
page.goto("https://www.amap.com/smog")
browser.close() # 关闭浏览器对象

6-2 属性

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
from playwright.sync_api import sync_playwright


def intercept_request(route, request):
if request.url.startswith("https://api.oioweb.cn/api/SoulWords"):
print(f"Intercepted request to: {request.url}")
route.fulfill(
status=200, # 状态码
body="{'message': 'Hello, World!'}", # 响应体
headers={"Content-Type": "application/json"} # 响应头
) # 模拟完成请求
print(route.request) # 获取当前请求的相关信息或属性
else:
route.continue_() # 条件不符则允许请求正常进行


with sync_playwright() as p:
browser = p.chromium.launch(headless=False, slow_mo=1000)
page = browser.new_page()
page.route(
"**/*", lambda route, request: intercept_request(route, request)
) # 监听请求并拦截
page.goto("https://api.oioweb.cn/api/SoulWords")
browser.close() # 关闭浏览器对象

7 JavaScript脚本

  • JavaScript脚本
    • 页面对象执行JavaScript脚本
      • Playwright使用page.evaluate()执行JavaScript代码并返回调用执行的结果。
      • 使用page.evaluate_handle()执行JavaScript代码,并返回执行结果的句柄。
    • 定位元素执行JavaScript脚本
      • 通过locator.evaluate()方法,对定位到的元素执行JavaScript代码。
      • 使用locator.evaluate_all()对定位的所有元素执行JavaScript代码。

7-1 页面Js*

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
from playwright.sync_api import sync_playwright

with sync_playwright() as p:
browser = p.chromium.launch(headless=False, slow_mo=1000)
page = browser.new_page()

print(page.evaluate("1 + 2")) # 3
x = 10
print(page.evaluate(f"1 + {x}")) # 11

res = page.evaluate("() => 'Hello World!'", ) # 执行一个函数
print(res) # Hello World!
res = page.evaluate(
"([a, b]) => a+b+'World!'", ["Hello", " "] # 执行的函数可以带参数
)
print(res) # hello world

page.goto("https://www.baidu.com/")
title = page.evaluate("document.title") # 获取页面的title
print(title)

page = browser.new_page() # 需要开启JForum论坛服务
page.goto("http://127.0.0.1:8080/jforum-2.7.0/user/login.page")
js = """
document.querySelector("input[name='username']").value="admin";
document.querySelector("input[name='password']").value="123456";
document.querySelector("input[name='login']").click();
"""
page.evaluate(js)

a_handle = page.evaluate_handle("document.body") # 返回句柄JSHandle,报错了???
result_handle = page.evaluate_handle("body => body.innerHTML", a_handle)
print(result_handle.json_value())
result_handle.dispose() # 释放result_handle对象,避免内存泄漏

browser.close() # 关闭浏览器对象

7-2 元素Js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
from playwright.sync_api import sync_playwright

with sync_playwright() as p:
browser = p.chromium.launch(headless=False, slow_mo=1000)
page = browser.new_page()
page.goto("http://127.0.0.1:8080/jforum-2.7.0/user/login.page")
username = page.locator(
"//*[@id='loginform']/table[2]/tbody/tr[2]/td/table/tbody/tr[2]/td[2]/input"
)
username.evaluate("node => node.value='admin'") # 输入框输入内容
input_value = username.evaluate("node => node.value")
print(input_value) # 获取输入框内容

page.goto("https://www.baidu.com/")
links = page.locator("#s-top-left>a")
res = links.evaluate_all("nodes => nodes.length") # 定位全部元素
print(res) # nodes.length获取元素个数

browser.close() # 关闭浏览器对象

8 Pyinstaller打包

  • Pyinstaller打包
    • Playwright与Pyinstaller三方插件结合使用,可用来创建独立的可执行文件。
    • 例如:要打包一个files_pack.py文件。
      • 先在当前文件夹中设置环境变量:set PLAYWRIGHT_BROWSERS_PATH=0
      • 然后命令安装文件中使用到的浏览器:playwright install chromium
      • 最后使用命令查看浏览器的安装路径:playwright install --dry-run
      • 可以看到chromium-1048和ffmpeg-1008这两个文件,后缀数字是版本号。
    • 打包files_pack.py文件命令如下,打包后的可执行文件可在当前目录的dist文件夹中找到。
      • 依赖文件较多,首次打包需加载好一会:pyinstaller -F files_pack.py
      • 打包时自定义ico图标:pyinstaller -F files_pack.py -i favicon.ico
      • ico图标制作可以使用地址“在线制作ico图标”,提供图片线上转换即可。

8-1 files_pack.py

1
2
3
4
5
6
7
8
from playwright.sync_api import sync_playwright                  # files_pack.py

with sync_playwright() as p:
browser = p.chromium.launch(headless=False, slow_mo=1000)
page = browser.new_page()
page.goto("http://www.baidu.com")
page.screenshot(path="pictures/install.png")
browser.close() # 关闭浏览器对象

8-2 依赖环境配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
(base) D:\Program\...> set PLAYWRIGHT_BROWSERS_PATH=0
(base) D:\Program\...> playwright install chromium
(base) D:\Program\...> playwright install --dry-run
browser: chromium version 111.0.5563.19
Install location: C:\Users\...\chromium-1048
Download url: https://playwright.azureedge.net/builds/chromium/1048/chromium-win64.zip
Download fallback 1: https://playwright-akamai.azureedge.net/builds/chromium/1048/chromium-win64.zip
Download fallback 2: https://playwright-verizon.azureedge.net/builds/chromium/1048/chromium-win64.zip

browser: firefox version 109.0
Install location: C:\Users\...\firefox-1378
Download url: https://playwright.azureedge.net/builds/firefox/1378/firefox-win64.zip
Download fallback 1: https://playwright-akamai.azureedge.net/builds/firefox/1378/firefox-win64.zip
Download fallback 2: https://playwright-verizon.azureedge.net/builds/firefox/1378/firefox-win64.zip

browser: webkit version 16.4
Install location: C:\Users\...\webkit-1792
Download url: https://playwright.azureedge.net/builds/webkit/1792/webkit-win64.zip
Download fallback 1: https://playwright-akamai.azureedge.net/builds/webkit/1792/webkit-win64.zip
Download fallback 2: https://playwright-verizon.azureedge.net/builds/webkit/1792/webkit-win64.zip

browser: ffmpeg
Install location: C:\Users\...\ffmpeg-1008
Download url: https://playwright.azureedge.net/builds/ffmpeg/1008/ffmpeg-win64.zip
Download fallback 1: https://playwright-akamai.azureedge.net/builds/ffmpeg/1008/ffmpeg-win64.zip
Download fallback 2: https://playwright-verizon.azureedge.net/builds/ffmpeg/1008/ffmpeg-win64.zip

(base) D:\Program\...> pyinstaller -F files_pack.py
(base) D:\Program\...> pyinstaller -F files_pack.py -i pictures/favicon.ico

8-3 打包路径问题*

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
Traceback (most recent call last):
File "files_pack.py", line 4, in <module>
File "playwright\sync_api\_generated.py", line 14155, in launch
File "playwright\_impl\_sync_base.py", line 115, in _sync
File "playwright\_impl\_browser_type.py", line 94, in launch
File "playwright\_impl\_connection.py", line 59, in send
File "playwright\_impl\_connection.py", line 514, in wrap_api_call
playwright._impl._errors.Error: BrowserType.launch: Executable doesn't exist at
C:\Users\...\playwright\driver\package\.local-browsers\chromium-1048\chrome-win\chrome.exe
╔════════════════════════════════════════════════════════════╗
║ Looks like Playwright was just installed or updated. ║
║ Please run the following command to download new browsers: ║
║ ║
║ playwright install ║
║ ║
║ <3 Playwright Team ║
╚════════════════════════════════════════════════════════════╝
[9672] Failed to execute script 'files_pack' due to unhandled exception!
  • 说明Playwright浏览器驱动没有被正确打包到exe文件中。
    • 【方法一】手动添加浏览器驱动到打包目录:执行仍然报同个错误,不知道参数设置是否有问题?
    • pyinstaller --add-data="C:\Users\...\chromium-1048;playwright/driver" files_pack.py
    • 【方法二】使用--hidden-import参数,指定需要打包的隐藏模块,将其强制打包到exe文件中。
    • pyinstaller --hidden-import=playwright._impl._browser_type files_pack.py:执行会闪退。
    • 【方法三】使用--onefile参数,将所有的文件都打包到可执行文件exe中,就可避免路径问题了。
    • pyinstaller --onefile files_pack.pyexe文件执行还是会报错。
    • 【方法四】代码中使用Playwright的install()函数来安装浏览器驱动。
    • 不知道是否环境问题,在其他电脑上打包后,可执行文件能成功被执行。
1
2
3
4
5
6
7
8
9
from playwright.sync_api import sync_playwright                  # files_pack.py

with sync_playwright() as p:
p.install() # 安装浏览器驱动
browser = p.chromium.launch(headless=False, slow_mo=1000)
page = browser.new_page()
page.goto("http://www.baidu.com")
page.screenshot(path="pictures/install.png")
browser.close() # 关闭浏览器对象

PlayWright 工具(二)
https://stitch-top.github.io/2023/11/02/zi-dong-hua/at05-playwright/at02-playwright-gong-ju-er/
作者
Dr.626
发布于
2023年11月2日 21:10:10
许可协议