本文其实承接了之前的文章(用WorkBuddy 实现一键提取出口报关单),实现了从网站批量下载报关单到提取报关单明细的全流程,完全不需要你再一个个自己点击下载哦。
本文内容较长,只需要代码的可以直接划到末尾~~
提前说在前面,代码内容设置的是直接从【我的应用】这里点击进入,所以要不像我一样这么设置,要么修改代码中的点击元素位置才能跑。除此之外,还需要修改代码中的口令卡密码及PDF下载路径。
一、项目背景
在外贸财务工作中,每月至少需要处理几十至上百张出口报关单,原因主要为:
① 发票数据核对
每张报关单包含商品名称、数量、金额、币制等信息,需要与财务系统中的发票逐笔匹配。
② 报关单电子归档
根据企业内控及审计要求,出口报关单留存备查,传统做法是:
-
逐张登录”中国国际贸易单一窗口” → 输入查询条件 → 点击查询 -
在结果列表中逐条点击”一键打印” → “预览打印” → 浏览器打印对话框 → 选择保存路径 -
按报关单号命名文件 → 存入归档文件夹
每月重复数十上百次,非常枯燥,并且极易遗漏或重复,因此这篇文章旨在自动化登录网页,批量提取报关单。
二、整体设计
2.1 流程概览
整个自动化脚本从打开浏览器到 PDF 保存,分为三大阶段:
┌─────────────────────────────────────────────────────────────┐
│ 阶段一:登录(整个运行过程仅执行一次) │
├─────────────────────────────────────────────────────────────┤
│ ① 打开 https://www.singlewindow.cn │
│ ② 点击「登录」→ 切换「卡介质登录」→ 输入卡口令 → 登录 │
│ ③ 点击「业务应用」→「口岸执法申报」→「货物申报」 │
│ ④ 关闭提示弹窗 → 进入「综合查询」→「报关数据查询」 │├─────────────────────────────────────────────────────────────┤
│ 阶段二:查询(每批次循环) │
├─────────────────────────────────────────────────────────────┤
│ ⑤ 填写 3 个下拉框:企业类别 / 进出口标志 / 是否结关 │
│ ⑥ 填写日期范围(按 7 天一批) │
│ ⑦ 点击「查询」按钮 │
│ ⑧ 滚动表格容器加载全部结果行 │
│ ⑨ 解析表格,提取所有 18 位海关编号 │
├─────────────────────────────────────────────────────────────┤
│ 阶段三:打印(逐条循环) │
├─────────────────────────────────────────────────────────────┤
│ ⑩ 逐条:勾选目标行 → 一键打印 → 预览打印 │
│ → Ctrl+P 打印 → 保存 PDF → 关闭弹窗 → 处理下一条 │
└─────────────────────────────────────────────────────────────┘
2.2 月度多批次循环架构
登录只执行一次,然后按 7 天为一个批次遍历全月,主要是考虑系统时间跨度最大为7天。
登录(仅一次,以7月举例)
│
├── 批次 1: 7/1 ~ 6/7 填日期 → 查询 → 全部报关单逐个打印
├── 批次 2: 7/8 ~ 7/14 填日期 → 查询 → 全部报关单逐个打印
├── 批次 3: 7/15 ~ 7/21 填日期 → 查询 → 全部报关单逐个打印
├── 批次 4: 7/22 ~ 7/28 填日期 → 查询 → 全部报关单逐个打印
├── 批次 5: 7/29 ~ 7/31 填日期 → 查询 → 全部报关单逐个打印
│
└── 全月统计:成功 X 条 / 失败 Y 条
2.3 核心配置
模拟手动填写查询内容,默认设置导出出口报关单,如需导出进口报关单,QUERY_IMPORT_EXPORT_FLAG 可以改为 = “I-进口”
QUERY_ENTERPRISE_TYPE = "C-报关收发货人" # 企业类别
QUERY_IMPORT_EXPORT_FLAG = "E-出口" # 进出口标志
QUERY_IS_CLOSED = "1-是" # 是否结关
DOWNLOAD_DIR = r"填写路径" #需要改为自己下载路径
MAX_DATE_RANGE_DAYS = 7
三、问题及解决方案
其实目前的代码设计和实际运行还是会有时差问题,即能正常实现下载报关单的要求,但是每一步设定了等待机制,运行速度还是有所欠缺,并且在保存PDF的时候会占用鼠标键盘,后续有时间会完善下代码,到时候再分享。
以下是遇到的问题和解决方案。
问题1:填入查询条件后点击查询没反应
我设计的
我想用 Playwright 自动完成以下操作:通过自动填入”企业类别=报关收发货人”、”进出口标志=出口”、”是否结关=是”,填写日期范围,然后点击查询按钮获取报关单列表,避免下拉框选择。
但最后还是用下拉框选择的方式解决了问题。
问题
问题 1.1:Playwright codegen 录制时,所有点击都无效
现象
用 Playwright 自带的 codegen 录制工具打开单一窗口页面时,所有交互操作,下拉框、按钮点击全部无反应,codegen 捕捉不到任何点击或输入事件的代码。
原因
了解Playwright codegen 的工作原理和查看该网页源码时发现,Playwright codegen 的工作原理是在主页面注入事件监听器(mousedown / click / keydown 等),捕捉用户操作后生成对应代码。
单一窗口的所有表单、表格、按钮都在 <iframe name="iframe01"> 内部。
事件在 iframe 内触发,主页面的监听器接收不到跨框架的事件,所以 codegen 根本看不到任何操作。
codegen 事件监听器 → 在主页面
单一窗口交互元素 → 在 iframe01 内
事件无法穿透 iframe 边界 → codegen 什么都录不到
关键认知:对于有 iframe 嵌套的老式 Web 应用(如使用 layui 框架的单一窗口),codegen 基本无用。必须先搞清楚 iframe 层级,再手写 frame_locator。
问题 1.2:fill() 填入文字后查询无数据
现象
代码执行 fill("C-报关收发货人")等,直接用fill()填入信息,输入框确实显示了文字,但点击查询后返回 0 条数据。
根因一:jQuery Autocomplete 只监听键盘事件
单一窗口的下拉框基于 jQuery Autocomplete 插件。它的核心逻辑是:
// autocomplete 监听的是 keydown 事件
$input.on('keydown', function() {
var value = this.value; // 获取当前输入值
$.ajax({ url: '/search', data: { q: value } }) // 用这个值发起AJAX
.done(function(data) {
renderDropdown(data); // 渲染下拉列表
});
});
而 Playwright 的 fill() 内部实现是:
// fill() 直接设置 value,不触发 keydown
element.value = "C-报关收发货人";
element.dispatchEvent(new Event('input'));
element.dispatchEvent(new Event('change'));
它跳过了 keydown / keyup 事件链,jQuery Autocomplete 的监听回调根本没有被触发。所以:
-
下拉列表没有弹出 -
AJAX 搜索没有发起 -
组件内部的”已选择”状态没有更新
根因二:hidden input 的值没有变化(更隐蔽)
单一窗口的 autocomplete 使用了双 input 模式:
<!-- 可见输入框:对用户展示 -->
<input id="etpsCategory1Name" type="text" autocomplete="off">
<!-- 隐藏 input:后端读取的是这个字段的值! -->
<input id="etpsCategory1" name="etpsCategory1" type="hidden" value="">
fill() 只改变了可见输入框 #etpsCategory1Name 的 value,隐藏 input #etpsCategory1 的值仍然是空字符串。当点击查询时,服务器提交表单读取的是 #etpsCategory1,始终为空,自然查不到数据。
这解释了日志中的矛盾:日志显示”企业类别=报关收发货人”(可见 input 有值),但查询结果始终是 0 条(隐藏 input 为空)。
我尝试了以下方案,全部失败:
|
|
|
|
|
|
fill("C-报关收发货人") |
|
|
|
fill() + press("Enter") |
|
|
|
type("C-报关收发货人", delay=50) |
|
|
|
click() + press("Backspace") + click(option) |
|
解决方法
核心思路:必须模拟”值被清空 → 键盘输入 → 弹出下拉 → 匹配选项”的完整人工操作流程。
关键改动两处:
|
|
|
|
|
|
press("Backspace"),不用 fill("") |
fill("") 不触发 keydown/keyup,下拉不展开。Backspace 逐键删除 → 组件检测到键盘事件 → 下拉出现 |
|
|
page_obj)改为 iframe(frame_loc) |
iframe01 > div.autocompleter > ul > li.autocompleter-item,在主页面搜永远找不到 |
同时删除以下不必要的冗余操作:
-
删除 Escape 关闭下拉的逻辑:autocomplete 选中选项后下拉自动消失,无需手动管理 -
删除 is_visible检查:下拉选项可能被pointer-events: none等 CSS 限制,is_visible检查会误判,改用force=True直接点击
最终代码:
# ✅ 正确流程:click(全选,考虑到点击窗口就是默认全选) → Backspace → type(前缀) → iframe 内搜选项 → force click
inp.click()page_obj.wait_for_timeout(200)
inp.press("Control+a") # 确保全选
page_obj.wait_for_timeout(200)inp.press("Backspace") # 删除 → 触发 keydown → autocomplete 激活
page_obj.wait_for_timeout(400)
inp.type("C-", delay=80) # type() 走完整键盘事件链 → AJAX 请求 → 下拉渲染
page_obj.wait_for_timeout(1000)
# 在 iframe 内搜索 li.autocompleter-item
clicked = _click_autocompleter_item(frame_loc, "C-报关收发货人")
if not clicked:
clicked = _click_autocompleter_item(page_obj, "C-报关收发货人") # 双域兜底
问题 1.3:click() 不全选,旧值残留过滤下拉列表
现象
执行 click() → press("Backspace") → type("C-") 后,下拉列表仍然只显示 A 开头的选项(如”A-报关申报单位”),目标”C-报关收发货人”被隐藏。
原因
F12 检查输入框发现:
<input id="etpsCategory1Name" value="A-报关申报单位" ...>
click() 后 value 仍然是完整文本 A-报关申报单位,Backspace 只删了最后一个字符变为 A-报关申报单。下拉列表基于这个残留值做过滤,自然找不到 C 开头的选项。
浏览器的 click() 行为在不同上下文中不同:
-
被 tab/JS 提前聚焦过的输入框: click()= 光标定位(不会全选) -
首次加载的输入框: click()可能全选,也可能不全选
单一窗口的输入框在页面加载后通过 JS 预设了默认值,”A-报关申报单位”,click() 只是把光标放过去,不会全选。
解决方法
强制 Ctrl+A 全选后再 Backspace,确保彻底清空:
inp.click()
page_obj.wait_for_timeout(200)
inp.press("Control+a") # 强制全选已有内容
page_obj.wait_for_timeout(200)
inp.press("Backspace") # 一键删除
page_obj.wait_for_timeout(400)
问题 1.4:Strict Mode —— 9 个隐藏 Tab 直接崩溃
现象
执行 query_frame.locator("div.fixed-table-body table tbody tr") 时报错
Error: strict mode violation: locator("div.fixed-table-body table tbody tr")
resolved to 9 elements
原因
F12 检查页面 DOM,发现查询结果区域实际上有 9 个 Tab 页签:
Tab: 报关单 | 回执 | 转关回执 | 商品信息 | 集装箱 | 随附单证 | 运输工具 | 保费 | 杂费
每个 Tab 都有一个独立完整的<div class="fixed-table-body"> 和 <table> 结构。当前只激活”报关单”Tab,其余 8 个的 DOM 以 display: none 隐藏——但 DOM 本身是存在的。
Playwright 的 locator() 在 DOM 层级匹配,不管 CSS 是否可见。div.fixed-table-body table tbody tr 在 9 个表格中都存在,匹配到 9 个元素。
Playwright 默认开启 Strict Mode——当 locator 返回多个元素时,直接抛异常,防止”你以为在操作第 1 个但实际可能操作了第 9 个”的隐蔽 bug。
这就是前面说的”hidden 模式”——不是 Playwright 的一种运行模式,而是页面通过 display: none 隐藏了 8 组重复 DOM 结构,在 Strict Mode 下触发了多元素匹配异常。
解决方法
用 .first 限定只取第一个容器(当前激活的”报关单”Tab),然后在该容器内定位表格:
# ✅ 正确:.first 只加在容器上
rows = query_frame.locator("div.fixed-table-body").first.locator("table tbody tr")
row_count = rows.count()
问题 1.5:.first 位置放错了 —— 只查到 1 条记录
现象
解决了 Strict Mode 后,明明查询结果有 8 条报关单,但脚本只提取了 1 条海关编号。
原因
某次修复中把 .first 写错了位置:
# ❌ 错误:.first 放在了整行选择器的末尾
rows = query_frame.locator("div.fixed-table-body table tbody tr").first
row_count = rows.count() # 永远 = 1
Playwright 的 .first 会裁剪 Locator 匹配的元素集合,只保留第一个。当 .first 放在整个选择器链末尾时:
locator("... tr").first
→ 匹配到所有表格中的所有 tr 行
→ .first 裁剪为只保留第 1 行
→ 结果是一个只包含 1 个元素的 Locator
→ .count() 永远返回 1
解决方法
.first 只加在容器上,不加在行选择器上:
# ✅ 正确:.first 限定容器,再在该容器内定位全部行
# 三层选择器结构
# 第一层(容器限定) 第二层(表格) 第三层(行)
rows = query_frame.locator("div.fixed-table-body").first.locator("table tbody tr")
# ↑ .first 在这里 ↑ tr 不加 .first
row_count = rows.count() # 返回真实行数,如 8
这个错误横跨三个函数(scroll_to_load_all、extract_custom_numbers、find_row_by_custom_number),全部需要统一修复。
问题 1 的最终完整代码
def select_dropdown(frame_loc, page_obj, input_id: str, option_text: str):
"""
下拉框选择 — 完整流程:
① Ctrl+A 全选 → Backspace 清空 → 触发 keydown 事件链
② type() 输入前缀 → 触发 AJAX → 弹出过滤后的下拉列表
③ 在 iframe 内搜索 li.autocompleter-item → force click
④ 外层 page 兜底搜索(双域保险)
"""
inp = frame_loc.locator(f"#{input_id}")
inp.wait_for(state="visible", timeout=10000)
# ① Ctrl+A 强制全选 → Backspace 彻底清空
inp.click() page_obj.wait_for_timeout(200)
inp.press("Control+a")
page_obj.wait_for_timeout(200)
inp.press("Backspace")
page_obj.wait_for_timeout(400)
# ② type() 带 delay,触发完整键盘事件链
prefix = option_text.split("-")[0] + "-" # 如 "C-"
inp.type(prefix, delay=80)
page_obj.wait_for_timeout(1000)
# ③ 优先在 iframe 内搜索(下拉渲染在 iframe01 中)
clicked = _click_autocompleter_item(frame_loc, option_text)
if not clicked:
clicked = _click_autocompleter_item(page_obj, option_text) # 双域兜底
def _click_autocompleter_item(scope, option_text):
"""在指定范围内搜索 autocomplete 下拉选项 → force click"""
selectors = [
"li.autocompleter-item",
"ul.autocompleter-list li",
"ul.ui-autocomplete li",
]
for sel in selectors:
items = scope.locator(sel)
count = items.count()
if count == 0:
continue
# 打印所有选项(INFO 级别日志)
visible_texts = []
for i in range(min(count, 20)):
try:
text = items.nth(i).text_content(timeout=200).strip()
if text: visible_texts.append(text)
except Exception: pass
log.info(f" 下拉选项({count}): {', '.join(visible_texts[:8])}")
# 遍历匹配目标选项
for i in range(count):
item = items.nth(i)
text = (item.text_content(timeout=200) or "").strip()
label = item.get_attribute("data-label", timeout=200) or ""
if text == option_text or label == option_text or option_text in text:
log.info(f" 点击: [{text}] (force=True)")
item.click(force=True) # 跳过 pointer-events 限制
return True
return False
关键设计决策:
|
|
|
|
|
goods_page > iframe01 > div.autocompleter > ul > li,主页面找不到 |
|
|
pointer-events: none 遮罩层遮挡,正常 click 会抛异常 |
is_visible |
|
|
|
|
|
|
|
四、问题 2:日期循环如何设计?
我设计的
我想让脚本能自动遍历整个月份的报关单。由于单一窗口单次查询数据量有限(实践中超过 7 天容易超时),需要将月份切分为多个 7 天批次,每个批次独立查询并打印。
登录 + 导航仅一次,每批只改日期重新查询。
但这里有个技术问题:在逐条打印过程中,页面会频繁弹出和关闭弹窗,DOM 结构会持续变化。传统的静态元素引用会在这过程中失效。
解决方法
核心设计:使用 Playwright 的 FrameLocator(不是静态引用),每次调用时自动重新解析 iframe 的 DOM 位置,弹窗开关不影响定位。
登录(一次)→ 导航到查询页面(一次)→ 获取 FrameLocator(一次)
↓
批次1: 改日期 → 查询 → 逐个打印 → 关闭弹窗
批次2: 改日期 → 查询 → 逐个打印 → 关闭弹窗
批次3:
......
↓
全月统计
批次生成算法:
def generate_batches_for_month(run_date=None):
"""
将指定月份切分为 7 天一批的列表,最后一批准许不满 7 天
例:2026年7月 → [(7/1,7/7), (7/8,7/14), (7/15,7/21), (7/22,7/28), (7/29,7/31)]
"""
if run_date is None:
run_date = datetime.date.today()
year, month = run_date.year, run_date.month
last_day = calendar.monthrange(year, month)[1]
batches = []
day = 1 while day <= last_day:
start = datetime.date(year, month, day)
end_day = min(day + MAX_DATE_RANGE_DAYS - 1, last_day)
end = datetime.date(year, month, end_day)
batches.append((start, end))
day = end_day + 1
return batches
边界处理(min 自动覆盖所有情况):
-
大月(31 天):5 批(7+7+7+7+3) -
小月(30 天):5 批(7+7+7+7+2) -
平年 2 月(28 天):4 批(7+7+7+7) -
闰年 2 月(29 天):5 批(7+7+7+7+1)
完整的月度循环主函数:
def run(playwright, run_date=None):
batches = generate_batches_for_month(run_date)
# ====== 登录 + 导航(仅一次)======
context = playwright.chromium.launch_persistent_context(
user_data_dir=user_data_dir,
headless=False,
accept_downloads=True,
)
page = context.new_page()
do_login(page)
goods_page = do_navigate_to_query(page)
query_frame = do_enter_query_page(goods_page) # FrameLocator,自动重新解析
total_success = 0
total_fail = 0
# ====== 固定下拉框(仅执行一次,所有批次共用)======
do_fill_dropdowns(query_frame, goods_page)
# ====== 逐批次循环 ======
for batch_idx, (start_date, end_date) in enumerate(batches):
log.info(f"[批次 {batch_idx+1}/{len(batches)}] {start_date} ~ {end_date}")
# 每批次只需重填日期范围,下拉框保持上次的值
do_fill_dates(query_frame, goods_page, start_date, end_date)
custom_numbers = do_query_and_extract(query_frame, goods_page)
if not custom_numbers:
continue
for idx, custom_num in enumerate(custom_numbers):
try:
if do_print_single(query_frame, goods_page, context,
custom_num, idx, len(custom_numbers)):
total_success += 1
else:
total_fail += 1
except Exception as e:
log.error(f"异常: {e}")
total_fail += 1
goods_page.keyboard.press("Escape")
log.info(f"全月完成: 成功 {total_success} / 失败 {total_fail}")
为什么 FrameLocator 不会失效? FrameLocator 不是静态引用,每次 query_frame.locator(...) 调用时,Playwright 都会重新查找 iframe[name='iframe01']。即使中间的弹窗开关、DOM 增删,只要 iframe 仍然存在(name=iframe01),就能正确定位。
五、问题 3:如何实现一个个报关单打印?
我设计的
每条报关单的打印流程是:在结果表格中找到目标行 → 勾选 → 点击”一键打印” → 在弹出的预览窗口中点击”预览打印” → 在新打开的 PDF 预览标签页中保存 PDF → 关闭弹窗 → 处理下一条。
勾选目标行 → 一键打印 → 预览打印 → 新标签页捕获 → 保存 PDF → 关闭弹窗 → 下一条
问题
这个流程中有四个关键难题:
难题 1:如何正确匹配行?不能假设行的位置(data-index 等属性不可靠),因为查询结果每次顺序可能不同。
难题 2:如何在动态弹窗 iframe 中定位”预览打印”按钮?点击”一键打印”后,layui 框架动态创建弹窗,”预览打印”按钮(#previewBtn)在弹窗 iframe 内,而 iframe 的 name 后缀每次变化,定位困难。
难题 3:如何捕获新打开的 PDF 预览标签页?点击”预览打印”后,Chrome 会在新标签页中打开 PDF 内容。
难题 4:如何保存 PDF?Chrome 的 PDF 预览器有内置的”打印”按钮,点击后会弹出 Windows “另存为”对话框,Playwright 的 CDP 合成事件无法操作系统级对话框。
解决方法
5.1 按海关编号精确匹配行
放弃 data-index 等固定属性,改用遍历表格每一行的每一个单元格,用文本匹配 18 位海关编号:
def find_row_by_custom_number(query_frame, custom_num):
"""遍历所有行,在所有 td 中匹配 18 位海关编号"""
rows = query_frame.locator("div.fixed-table-body").first.locator("table tbody tr")
for i in range(rows.count()):
row = rows.nth(i)
tds = row.locator("td")
for j in range(tds.count()):
td_text = tds.nth(j).text_content().strip()
if td_text == custom_num: # 精确匹配
return row
return None
选择 text_content() 而不是 inner_text() 的原因:inner_text() 受 CSS 样式(text-transform、visibility)影响,在 layui 复杂样式下可能返回不一致的结果,text_content() 返回 DOM 中的原始文本,更可靠。
5.2 在弹窗 iframe 中定位”预览打印”按钮
点击”一键打印”后,layui 框架会动态创建一个弹窗,弹窗内容在一个新的 iframe 中。”预览打印”按钮(#previewBtn)就在这个 iframe 里。但这个 iframe 的定位是整个打印流程中最棘手的问题之一。
问题 5.2.1:frame_locator().last 选错了 iframe
现象
最初用 frame_locator('iframe[name*="layui-layer-iframe"]').last 定位弹窗 iframe,等待 #previewBtn 可见时超时失败,浏览器表现为”不断往右拉”——在错误的 iframe 中空转,不执行下一步。
原因
layui 弹窗的 iframe name 带有数字后缀,如 layui-layer-iframe3、layui-layer-iframe4,这个后缀每次创建弹窗时都会变化。.last 假设最新的弹窗是最后一个匹配的 iframe,但数字后缀与创建顺序不一定对应,导致选错了 iframe。
iframe 列表(每次运行可能不同):
frame[0]: name=(无name) ← 无关
frame[1]: name=iframe0 ← 无关
frame[2]: name=iframe99 ← 无关
frame[3]: name=iframe01 ← 主内容区
frame[4]: name=layui-layer-iframe3 ← 弹窗内容(#previewBtn 在这里)
frame_locator(...).last → 可能选中错误的后缀 → #previewBtn 不存在 → 超时空转
问题 5.2.2:遍历所有 frame 太慢
现象
改为遍历 goods_page.frames 逐个查找 #previewBtn(策略B),可以找到按钮,但耗时约 4 秒。
实测日志:
ounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(line
--- 策略A开始: frame_locator('iframe[name*="layui-layer-iframe"]').last ---
<<< 策略A 失败(超时 1.00s)
--- 策略B开始: 遍历 goods_page.frames 查找 #previewBtn ---
策略B: 检查 frame[0] (name=(无name))... 未找到
策略B: 检查 frame[1] (name=iframe0)... 未找到
策略B: 检查 frame[2] (name=iframe99)... 未找到
策略B: 检查 frame[3] (name=iframe01)... 未找到
策略B: 检查 frame[4] (name=layui-layer-iframe3)... >>> 成功!耗时 4.05s
策略A失败、策略B成功,但策略B遍历了 5 个 frame 才找到目标,前 4 个都不包含 #previewBtn,每个都要等 1 秒超时才知道没有,白白浪费 4 秒。如果有 100 条报关单,就是 400 秒的无效等待。
解决方法
遍历 goods_page.frames,但只检查 name 含 layui-layer-iframe 的 frame(跳过无关 frame),找到第一个包含可见 #previewBtn 的即返回:
# ✅ 正确:遍历 frames,过滤 layui-layer-iframe,精确查找 #previewBtn
preview_btn = None
# 先等弹窗 iframe 加载(弹窗是点击"一键打印"后动态创建的)
goods_page.wait_for_timeout(1500)
for frame in goods_page.frames:
frame_name = frame.name or ""
# 跳过无关 frame,只检查 layui-layer-iframe
if "layui-layer-iframe" not in frame_name:
continue
try:
btn = frame.locator("#previewBtn")
btn.wait_for(state="visible", timeout=2000)
preview_btn = btn
break
except PlaywrightTimeout:
continue
if preview_btn is None:
log.error("未找到[预览打印]按钮,跳过")
goods_page.keyboard.press("Escape")
return False
关键设计决策:
|
|
|
goods_page.frames 而非 frame_locator |
goods_page.frames 返回实际加载的 Frame 对象,可直接操作 |
layui-layer-iframe |
|
|
|
|
wait_for_timeout(1500) |
|
5.3 新标签页捕获
必须在点击”预览打印”之前记录当前页面集合,点击后用差集找出新页面:
# ★ 关键:点击前捕获当前页面集合
pages_before = {p for p in context.pages if not p.is_closed()}
preview_btn.click()
goods_page.wait_for_timeout(3000) # 等待新标签页打开
# 差集运算找到新页面
pages_after = {p for p in context.pages if not p.is_closed()}new_pages = pages_after - pages_before
# 取第一个新页面(PDF 预览器)
pdf_preview_page = None
for np in new_pages:
try:
np.wait_for_load_state("networkidle", timeout=15000)
pdf_preview_page = np
break
except Exception:
continue
为什么不能用 expect_page()?expect_page() 有超时限制,如果新页面打开速度受网络影响,可能在超时后丢失。而”点击前捕获 + 差集”没有超时问题——只要新页面确实出现在 context.pages 中,就一定能找到。
易错点:我之前把 pages_before = {...} 写在了点击之后,此时新页面已经在集合中,差集为空,永远找不到新页面。必须先捕获再点击。
5.4 PDF 保存——pyautogui 替代 Playwright CDP
为什么不能用 page.pdf()?
Playwright 的 page.pdf() 是把当前页面的 DOM + CSS 渲染为 PDF。但单一窗口的报关单 PDF 是服务端生成的。点击”预览打印”时,服务器返回的是已经渲染好的 PDF 文件。page.pdf() 得到的只是页面本身的快照,不是报关单 PDF。
为什么不能用 keyboard.press("Control+p")?
Playwright 的 keyboard.press() 走的是 CDP(Chrome DevTools Protocol)合成事件,只在浏览器处理层生效,不会传递到操作系统层。而 Windows 的”另存为”对话框是操作系统级窗口,CDP 事件对它完全无效。
pyautogui 的 hotkey() 走的是操作系统输入队列,和真人按键完全相同的路径:
pyautogui: 键盘驱动 → 操作系统内核 → 窗口消息队列 → 应用程序消息循环
CDP合成: Playwright → CDP → 浏览器处理层(止步于此)
完整保存流程:
# ① 确保目标窗口在前台
pdf_preview_page.bring_to_front()
time.sleep(2)
# ② Ctrl+P 打开 Chrome 打印对话框
pyautogui.hotkey('ctrl', 'p')
time.sleep(3)
# ③ Enter 确认(已通过 Preferences 预设"另存为 PDF"为默认)
pyautogui.press('enter')
time.sleep(2.5)
# ④ Windows "另存为"对话框:粘贴路径
pyautogui.hotkey('ctrl', 'a') # 全选默认文件名
time.sleep(0.2)
subprocess.run(['clip'], input=pdf_path.encode(), check=True) # 写入剪贴板
time.sleep(0.3)pyautogui.hotkey('ctrl', 'v') # 粘贴目标路径
time.sleep(0.3)pyautogui.press('enter') # 确认保存
time.sleep(3)
为什么用 subprocess.run(['clip'], ...) 而不是 pyautogui.write()?
pyautogui.write() 是逐字符模拟键盘输入。对于路径 E:\Python\Automation\Customs\XXXXX.pdf(超过 70 个字符),需要模拟 70+ 次按键,既慢又容易被其他窗口打断。而 subprocess + clip 一次 Ctrl+V 即可完成粘贴。
5.5 保存后三层验证机制
保存操作可能因时序问题失败(对话框未就绪、剪贴板未更新等),必须做三层保障:
save_success = False
# 第一层:直接验证目标路径
if os.path.exists(pdf_path) and os.path.getsize(pdf_path) > 1000:
save_success = True
log.info(f"PDF已保存: {pdf_path}")
# 第二层:搜索 Downloads 备用路径(对话框可能默认保存到了下载目录)
if not save_success:
download_dir = os.path.join(os.path.expanduser("~"), "Downloads")
alt_candidates = [
os.path.join(download_dir, os.path.basename(pdf_path)),
os.path.join(download_dir, "print.pdf"),
os.path.join(download_dir, f"{custom_num}.pdf"),
]
for alt_path in alt_candidates:
if os.path.exists(alt_path) and os.path.getsize(alt_path) > 1000:
shutil.move(alt_path, pdf_path)
save_success = True
break
# 第三层:兜底重试(二次粘贴 + Enter)
if not save_success:
time.sleep(2)
pyautogui.hotkey('ctrl', 'a')
subprocess.run(['clip'], input=pdf_path.encode(), check=True)
time.sleep(0.3)
pyautogui.hotkey('ctrl', 'v')
time.sleep(0.3)
pyautogui.press('enter')
time.sleep(3)
if os.path.exists(pdf_path) and os.path.getsize(pdf_path) > 1000:
save_success = True
5.6 打印预设:Chrome 默认目标设为”另存为 PDF”
为确保 Ctrl+P 后默认选中的是”另存为 PDF”(而非 “Microsoft Print to PDF”,该模式生成的 PDF 无法复制编辑),需在启动浏览器前写入 Chrome 配置。
def setup_print_profile():
"""创建临时 Chrome 配置,将打印默认目标设为"另存为 PDF" """
user_data_dir = tempfile.mkdtemp(prefix="chrome_print_profile_")
default_dir = os.path.join(user_data_dir, "Default")
os.makedirs(default_dir, exist_ok=True)
# ★ appState 的值必须是 JSON 字符串,不能是 Python 字典
app_state = json.dumps({
"recentDestinations": [{
"id": "Save as PDF",
"origin": "local",
"isDefault": True
}],
"selectedDestinationId": "Save as PDF",
"version": 2
})
prefs = {
"printing": {
"print_preview_sticky_settings": {
"appState": app_state
}
}
}
prefs_path = os.path.join(default_dir, "Preferences")
with open(prefs_path, "w", encoding="utf-8") as f:
json.dump(prefs, f)
return user_data_dir
# 使用时配合 launch_persistent_context
context = playwright.chromium.launch_persistent_context(
user_data_dir=user_data_dir,
headless=False,
accept_downloads=True,
)
六、问题 4:如何关闭”一键打印”弹窗?
我设计的
每个报关单打印完毕后,代码应自动关闭”一键打印”弹窗,回到主界面的查询结果表格页面,然后开始处理下一条报关单。
问题
尝试了三种方式都无法关闭弹窗:
|
|
|
|
|
|
goods_page.keyboard.press("Escape") |
|
|
|
goods_page.locator("a.layui-layer-close1") |
|
|
|
goods_page.frame_locator('iframe[name*="layui-layer-iframe"]').last.locator("a.layui-layer-close1") |
|
原因
需要先搞清楚完整的 DOM 嵌套结构。F12 审查元素后:
goods_page(主页面 / 货物申报窗口)
│├── iframe[name="iframe01"] ← 主内容区
│ ├── <form> 查询条件表单
│ ├── <div class="fixed-table-body"> 结果表格
│ │
│ └── <div class="layui-layer-shade"> ← 遮罩层
│ └── <div class="layui-layer"> ← layui 弹窗容器
│ ├── 标题栏: "一键打印"
│ ├── <iframe class="layui-layer-iframe"> ← 弹窗内容区(又一层 iframe!)
│ │ └── "预览打印"按钮在这里
│ └── <span class="layui-layer-setwin">
│ └── <a class="layui-layer-ico layui-layer-close layui-layer-close1">
│ ← ★ 关闭按钮(X)在这里!
关键发现:关闭按钮 a.layui-layer-close1 不在弹窗内部的 iframe 里,也不在最外层。它嵌入在 iframe01 内,是 layui 弹窗外壳的一部分。
-
在主页面搜(尝试 2):找不到,因为按钮在 iframe01内 -
在弹窗 iframe 内搜(尝试 3):也找不到,因为按钮在弹窗外壳上,不在弹窗内容 iframe 里 -
正确答案:进入 iframe01,在其中搜弹窗关闭按钮
解决方法
# ✅ 正确:进入 iframe01,在其中查找弹窗关闭按钮
close_popped = False
# 双域搜索:iframe01 优先,主页面兜底
for scope_desc, scope in [
("iframe01", goods_page.frame_locator("iframe[name='iframe01']")),
("主页面", goods_page),]:
if close_popped:
break
try:
close_btn = scope.locator("a.layui-layer-close1")
if close_btn.count() > 0:
close_btn.first.wait_for(state="visible", timeout=3000)
close_btn.first.click()
goods_page.wait_for_timeout(800)
close_popped = True
except PlaywrightTimeout:
continue
except Exception:
continue
# 兜底:Escape
if not close_popped:
goods_page.keyboard.press("Escape")
goods_page.wait_for_timeout(800)
项目中 iframe 操作的完整规则
|
|
|
|
|
goods_page.frame_locator("iframe[name='iframe01']") |
|
|
goods_page.frames,过滤 name 含 layui-layer-iframe 的 frame(详见 5.2 节) |
|
|
goods_page.frame_locator("iframe[name='iframe01']").locator("a.layui-layer-close1") |
|
|
page 或 goods_page,不进 iframe |
为什么弹窗内的按钮不能用 .last? layui 弹窗 iframe 的 name 带数字后缀(如 layui-layer-iframe3、layui-layer-iframe4),每次创建弹窗时后缀变化,.last 选中的不一定是当前激活的弹窗,实测会导致定位错误。正确做法是遍历 goods_page.frames,过滤 name 含 layui-layer-iframe 的 frame,在其中精确查找目标元素。
七、窗口自适应大小
这个问题我也一直好奇,为什么WorkBuddy和其他的AI工具一直推荐使用1920*1680的格式,实际上固定窗口大小,如果切换到显示屏或者不同电脑,体验感有点差,还是自适应窗口会比较舒适,可以直接铺满全屏。
context = playwright.chromium.launch_persistent_context(user_data_dir=user_data_dir,headless=False,args=['--start-maximized'],
# ← 启动时最大化no_viewport=True,
# ← 不强制固定视口accept_downloads=True,
# ← 注意:不能再加 viewport= 参数,no_viewport=True 时已隐含 viewport=None)
八、完整代码架构
extract_customs.py
│
├── [配置区] QUERY_ENTERPRISE_TYPE / QUERY_IMPORT_EXPORT_FLAG / QUERY_IS_CLOSED ...
│
├── [工具函数]
│ ├── setup_logging() 日志双输出
│ ├── generate_batches_for_month() 月份批次切分
│ ├── setup_print_profile() Chrome 打印预设
│ ├── select_dropdown() 下拉框选择
│ ├── _click_autocompleter_item() 下拉选项点击
│ ├── find_row_by_custom_number() 按编号匹配行
│ ├── extract_custom_numbers() 批量提取编号
│ └── scroll_to_load_all() 滚动加载
│├── [流程函数]
│ ├── do_login() 登录(步骤 1~5)
│ ├── do_navigate_to_query() 导航到查询页(步骤 6~8)
│ ├── do_enter_query_page() 进入报关数据查询(步骤 9)
│ ├── do_fill_dropdowns() 填写固定下拉框(步骤 10,仅执行一次)
│ ├── do_fill_dates() 填写日期范围(步骤 10,每批次执行)
│ ├── do_query_and_extract() 查询并提取(步骤 11~12)
│ └── do_print_single() 单条打印(步骤 13)
│└── [主入口]
├── run() 月度多批次主流程
└── __main__
运行方法:运行前需确保已经下载Python各个库,以下为在终端cmd下运行的方法。
pip install playwright pyautogui
playwright install chromium
python extract_customs.py # 当月
python extract_customs.py 2026-07 # 指定月份








![表情[nanguo]-寻找资源网](http://www.seekresource.com/wp-content/themes/zibll/img/smilies/nanguo.gif)

![表情[haobang]-寻找资源网](http://www.seekresource.com/wp-content/themes/zibll/img/smilies/haobang.gif)
![表情[shuai]-寻找资源网](http://www.seekresource.com/wp-content/themes/zibll/img/smilies/shuai.gif)
![表情[deyi]-寻找资源网](http://www.seekresource.com/wp-content/themes/zibll/img/smilies/deyi.gif)
![表情[chi]-寻找资源网](http://www.seekresource.com/wp-content/themes/zibll/img/smilies/chi.gif)



暂无评论内容