用 WorkBuddy+ Playwright 实现自动化下载中国国际贸易单一窗口出口报关单

用 WorkBuddy+ Playwright 实现自动化下载中国国际贸易单一窗口出口报关单

用 WorkBuddy+ Playwright 实现自动化下载中国国际贸易单一窗口出口报关单-寻找资源网
用 WorkBuddy+ Playwright 实现自动化下载中国国际贸易单一窗口出口报关单
此内容为付费阅读,请付费后查看
5
立即购买
您当前未登录!建议登陆后购买,可保存购买订单
seekresource@163.com
1919588043
QQ1919588043
寻找资源网
微信小店:寻网百货
付费阅读
商城已上线,快去看看吧!

本文其实承接了之前的文章(用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 为空)。

我尝试了以下方案,全部失败:

方案
操作
失败原因
A
fill("C-报关收发货人")
keydown 不触发 → autocomplete 未激活 → hidden input 值不变
B
fill() + press("Enter")
Enter 被 layui 输入框事件拦截,下拉仍未弹出
C
type("C-报关收发货人", delay=50)
全量 type 触发 keydown,但窗口报错”往右拉”(输入框预填了默认值,叠加输入超出)
D
click() + press("Backspace") + click(option)
Backspace 只删一个字符,残留值过滤了下拉选项

解决方法

核心思路:必须模拟”值被清空 → 键盘输入 → 弹出下拉 → 匹配选项”的完整人工操作流程。

关键改动两处:

序号
改什么
为什么
1
清空用 press("Backspace"),不用 fill("")
fill("") 不触发 keydown/keyup,下拉不展开。Backspace 逐键删除 → 组件检测到键盘事件 → 下拉出现
2
搜索下拉选项的作用域从主页面(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_allextract_custom_numbersfind_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

关键设计决策

决策
原因
在 iframe 内搜索
下拉的完整路径是 goods_page > iframe01 > div.autocompleter > ul > li,主页面找不到

force=True

 点击

li.autocompleter-item

 可能被 pointer-events: none 遮罩层遮挡,正常 click 会抛异常
不校验 is_visible
autocomplete 选项可能因 CSS 障眼法被误判为不可见,但实际可点击
不手动关闭下拉
选中选项后下拉自动消失,多余操作可能干扰后续流程
双域搜索(iframe → page)
下拉有时渲染在 iframe 内,有时在外,双域保证兼容

四、问题 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-transformvisibility)影响,在 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-iframe3layui-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

frame_locator().last

 选错 iframe;goods_page.frames 返回实际加载的 Frame 对象,可直接操作
过滤 layui-layer-iframe
跳过 iframe01、iframe99 等无关 frame,从检查 5 个缩减到 1 个,耗时从 4s 降到 ~1s
不硬编码 frame 索引
frame 列表顺序不固定,硬编码会导致偶发失败
先 wait_for_timeout(1500)
弹窗 iframe 是动态创建的,需要等待加载完成再遍历

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:如何关闭”一键打印”弹窗?

我设计的

每个报关单打印完毕后,代码应自动关闭”一键打印”弹窗,回到主界面的查询结果表格页面,然后开始处理下一条报关单。

问题

尝试了三种方式都无法关闭弹窗:

方案
操作
结果
1
goods_page.keyboard.press("Escape")
无效,layui 弹窗不响应 Escape 键
2
goods_page.locator("a.layui-layer-close1")
找不到元素(超时),因为按钮在 iframe 内
3
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 弹窗外壳的一部分。

  1. 在主页面搜(尝试 2):找不到,因为按钮在 iframe01 内
  2. 在弹窗 iframe 内搜(尝试 3):也找不到,因为按钮在弹窗外壳上,不在弹窗内容 iframe 里
  3. 正确答案:进入 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']")
layui 弹窗内的内容按钮
遍历 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-iframe3layui-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   # 指定月份

九、全部代码

看完不过瘾,那就自己发一篇吧!
© 版权声明
THE END
喜欢就支持一下吧
点赞12 分享
相关推荐
评论 抢沙发

请登录后发表评论

    暂无评论内容