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

用 WorkBuddy+ Playwright 实现自动化下载中国国际贸易单一窗口出口报关单 用 WorkBuddy+ Playwright 实现自动化下载中国国际贸易单一窗口出口报关单本文其实承接了之前的文章(https://mp.weixin.qq.com/s/LMNAjI6JE5PTpKTB_gQvrQ?token=145596125lang=zh_CN),实现了从网站批量下载报关单到提取报关单明细的全流程。本文内容较长,只需要代码的可以直接划到末尾~~提前说在前面,代码内容设置的是直接从【我的应用】这里点击进入,所以要不像我一样这么设置,要么修改代码中的点击元素位置才能跑。除此之外,还需要修改代码中的口令卡密码及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 核心配置模拟手动填写查询内容,如需导出进口报关单,以下可以改为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内部。事件在 iframe 内触发,主页面的监听器接收不到跨框架的事件,所以 codegen 根本看不到任何操作。codegen 事件监听器 → 在主页面 单一窗口交互元素 → 在 iframe01 内 事件无法穿透 iframe 边界 → codegen 什么都录不到解决办法放弃 codegen,用 Playwright 的FrameLocator切入 iframe:# 先在 iframe01 内找元素,再操作query_frame=goods_page.frame_locator("iframe[name='iframe01']")# 之后所有查询、表单操作都通过 query_framequery_frame.locator("#decQuery").click()关键认知:对于有 iframe 嵌套的老式 Web 应用(如使用 layui 框架的单一窗口),codegen 基本无用。必须先搞清楚 iframe 层级,再手写frame_locator。问题 1.2:fill() 填入文字后查询无数据现象代码执行fill("C-报关收发货人")等,直接用fill()填入信息,输入框确实显示了文字,但点击查询后返回0 条数据。根因一:jQuery Autocomplete 只监听键盘事件单一窗口的下拉框基于 jQuery Autocomplete 插件。它的核心逻辑是:// autocomplete 监听的是 keydown 事件$input.on('keydown',function(){varvalue=this.value;// 获取当前输入值$.ajax({url:'/search',data:{q:value}})// 用这个值发起 AJAX.done(function(data){renderDropdown(data);// 渲染下拉列表});});而 Playwright 的fill()内部实现是:// fill() 直接设置 value,不触发 keydownelement.value="C-报关收发货人";element.dispatchEvent(newEvent('input'));element.dispatchEvent(newEvent('change'));它跳过了keydown/keyup事件链,jQuery Autocomplete 的监听回调根本没有被触发。所以:下拉列表没有弹出AJAX 搜索没有发起组件内部的"已选择"状态没有更新根因二:hidden input 的值没有变化(更隐蔽)单一窗口的 autocomplete 使用了双 input 模式:!-- 可见输入框:对用户展示 --inputid="etpsCategory1Name"type="text"autocomplete="off"!-- 隐藏 input:后端读取的是这个字段的值! --inputid="etpsCategory1"name="etpsCategory1"type="hidden"value=""fill()只改变了可见输入框#etpsCategory1Name的 value,隐藏 input#etpsCategory1的值仍然是空字符串。当点击查询时,服务器提交表单读取的是#etpsCategory1,始终为空,自然查不到数据。这解释了日志中的矛盾:日志显示"企业类别=报关收发货人"(可见 input 有值),但查询结果始终是 0 条(隐藏 input 为空)。我尝试了以下方案,全部失败:方案操作失败原因Afill("C-报关收发货人")keydown 不触发 → autocomplete 未激活 → hidden input 值不变Bfill() + press("Enter")Enter 被 layui 输入框事件拦截,下拉仍未弹出Ctype("C-报关收发货人", delay=50)全量 type 触发 keydown,但窗口报错"往右拉"(输入框预填了默认值,叠加输入超出)Dclick() + 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 clickinp.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-itemclicked=_click_autocompleter_item(frame_loc,"C-报关收发货人")ifnotclicked:clicked=_click_autocompleter_item(page_obj,"C-报关收发货人")# 双域兜底问题 1.3:click() 不全选,旧值残留过滤下拉列表现象执行click() → press("Backspace") → type("C-")后,下拉列表仍然只显示 A 开头的选项(如"A-报关申报单位"),目标"C-报关收发货人"被隐藏。原因F12 检查输入框发现:input 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和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()# 永远 = 1Playwright 的.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 不加 .firstrow_count=rows.count()# 返回真实行数,如 8这个错误横跨三个函数(scroll_to_load_all、extract_custom_numbers、find_row_by_custom_number),全部需要统一修复。问题 1 的最终完整代码defselect_dropdown(frame_loc,page_obj,input_id:str,option_text:str):""" 下拉框选择 — 完整流程