# 用WorkBuddy实现一键提取出口报关单
## 一、背景
外贸财务经常需要通过每份报关单的关键信息,收货人、合同号,以及每一项商品的型号、数量、单价、总价与发票进行逐一匹配,并将发票一一匹配上出口报关单的海关编号。
本文运用WorkBuddy,只告知了需要提取报关单对应内容的需求,很快就写好了 Python 脚本,主要使用PyPDF2+正则表达式,只要200 行代码即可自动完成全部提取工作。
本文内容较长,只需要代码的可以直接划到末尾~~
---
## 二、PDF 结构分析
拿到 PDF 之后,第一步不是写代码,而是搞清楚文档内部到底是什么,页面构成、字段分布、排版陷阱。
### 2.1 一份报关单 PDF 里到底有什么?
我给的报关单 PDF 并非只有一张报关单表格,而是由多份不同性质的文件合并而成。以最常见的 **5 页**结构为例:
```
第 1 页 → 出口货物报关单(1/2)——含头部信息 + 部分商品项目
第 2 页 → 出口货物报关单(2/2)——续接商品项目,直到完税信息
第 3 页 → 通关无纸化出口放行通知书 ← 不需要
第 4 页 → 委托报关协议 ← 不需要
第 5 页 → 委托报关协议通用条款 ← 不需要
```
还有 **4 页**的版本(报关单 1 页 + 放行通知书 + 委托协议 + 通用条款)。
> **关键**:不同报关代理拼的 PDF,页数可能不同(4 或 5 页),但规律是稳定的——**只有前 1~2 页是报关单本体,后面全是无关附件**。
### 2.2 报关单页面的文本布局
用 PyPDF2 把第 1 页文本提取出来(原始顺序,未整理),核心结构如下:
```
*5304XXXXXXXXXXXXXX* ← 海关编号
10000个 ← 商品 6 的数量(倒序排列!)
1.2千克 ← 商品 6 的净重
10000个25000个 ← 商品 5/6 的数量
...
155000个1100个 ← 商品 2/1 的数量
1.9千克 ← 商品 1 的净重
1100个征免 境内货源地 ... ← 数量表尾部 + 表头行
中华人民共和国海关出口货物报关单 ← 正式标题
页码/页数: 1/2 (深圳湾关)
海关编号: 预录入编号: 5304XXXXXXXXXXXXXX ... ← 编号区
...
出境关别(5345)
XXXX公司境内发货人 ← 发货人
...
XX Limited境外收货人 ← 收货人(换行截断!)
...
合同协议号 ← 头部结束
8542319099 控制器用集成电路 ← 商品 1 起
...XX牌|XX型非量产3.3820
3720.20美元中国台湾(TWN)...
...
8542319099 控制器用集成电路 ← 商品 2
...牌|XX型|量产|中4.2030
651465.00美元...
```
### 2.3 PDF 中问题
**文本顺序颠倒。** PDF 文本提取的顺序是从页面底部到顶部:商品数量、重量在最前面,而商品详情(型号、单价、总价)反而在后面。不能按行号顺序直接解析。
**属性分散在页面不同位置。** 每一件商品的信息并不聚在同一个"块"里:数量和重量被集中在页面顶部的数量表区域(按照 `N个 → M千克` 的配对从底向上排列),而型号、单价、总价则散落在页面下半部分的商品明细区。
### 2.4 总结 PDF 结构
> 报关单 PDF文件包含: 1~2 页报关单本体(含头部 + 商品) + 1 页放行通知书 + 2 页委托协议。
> 商品信息被拆成两部分分散在两页的不同区域,数量/重量要反向配对,型号/单价/总价要兼容多种格式。
---
## 三、输出样式
在动笔写代码之前,需要先定义清楚"我要输出什么",我只需要以下几个关键内容。
Excel 格式:**头部信息 + 商品明细合并在同一行,每个商品占一行**。
| 海关编号 | 境外收货人 | 出口日期 | 申报日期 | 合同协议号 | 项号 | 商品编号 | 型号 | 是否量产 | 数量 | 单位(重量) | 单价 | 总价(美元) |
| -------- | ---------- | -------- | -------- | ---------- | ---- | -------- | ---- | -------- | ---- | ---------- | ---- | ---------- |
脚本处理完 Customs 目录下所有 PDF 后,所有数据合并到一个 Excel 中,无需手动复制粘贴。
---
## 四、代码设计
整个脚本分为 **6 个模块**,每个解决一个独立问题。
下面是逐一拆解,说明为什么这样设计。
### 4.1 环境兼容性:`sys.stdout` 编码
```python
if hasattr(sys.stdout, 'buffer'):
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
```
PDF 中的中文(如"控制器用集成电路""香港")在某些 IDE(IDLE、Spyder)中打印时可能乱码。这行代码让命令行环境下强制 UTF-8 输出,同时用 `hasattr` 做防御——在 IDE 环境跳过,避免 `'StdOutputFile' object has no attribute 'buffer'` 报错。
### 4.2 模块一:页面识别与文本提取
**设计思路**:不是机械地取前两页,而是**按内容特征判断每页的角色**。
```python
def is_customs_page(text):
"""判断是否为报关单页面"""
if not text or not text.strip():
return False
# 排除放行通知书——关键字"放行通知书""兹申明"
if '放行通知书' in text or '兹申明' in text or '(签印)兹申明' in text:
return False
# 排除委托报关协议——关键字"委托报关协议""通用条款"
if '委托报关协议' in text or '中国报关协会监制' in text or '通用条款' in text:
return False
return True
```
**为什么用关键字而不是硬编码页码?** 因为不同代理拼的 PDF,报关单可能在第 1 页、第 1+2 页。枚举关键字比猜页码更稳。
```python
def extract_customs_text(pdf_path):
"""提取所有报关单页→合并文本 + 逐页文本列表"""
reader = PyPDF2.PdfReader(pdf_path)
pages = []
for page in reader.pages:
t = page.extract_text() or ''
if is_customs_page(t):
pages.append(t)
return '\n'.join(pages), pages
# 返回两个值:合并文本(给正则解析用) + 逐页文本(给数量/重量提取用)
```
**为什么返回两个值?** 商品型号、单价、总价是全文正则匹配,不需要分页;但数量/重量是逐页按行扫描提取的(详见 4.3),必须保留原文的行结构,不能合并。
### 4.3 模块二:头部信息解析
**设计思路**:把多行文本拍平成一个字符串,然后用正则按"锚点"定位每个字段。
```python
def parse_header(text):
flat = ' '.join(text.split()) # 把所有空白字符替换为单个空格
h = {}
# 海关编号 = 预录入编号 = 18位数字
m = re.search(r'预录入编号[::\s]*(\d{18})', flat)
h['海关编号'] = m.group(1) if m else ''
# 境外收货人 = "运输方式(N)"之后、"境外收货人"之前的文本
m = re.search(r'运输方式\(\d+\)\s*(.+?)境外收货人', flat)
h['境外收货人'] = m.group(1).strip() if m else ''
# 出口日期 = 紧贴"出口日期"之前的8位数字
m = re.search(r'(\d{8})出口日期', flat)
h['出口日期'] = m.group(1) if m else ''
# 申报日期 = 紧贴"申报日期"之前的8位数字
m = re.search(r'(\d{8})申报日期', flat)
h['申报日期'] = m.group(1) if m else ''
# 合同协议号 = "贸易国(地区)(XXX)"之后、"合同协议号"之前的文本
m = re.search(r'贸易国(地区)\s*\(\w+\)\s*(.+?)合同协议号', flat)
h['合同协议号'] = m.group(1).strip() if m else ''
return h
```
**核心技巧**:不去解析 PDF 的二维表格坐标,用**固定标签("境外收货人""出口日期"等)作为"锚点"**,通过它前后的已知文本做边界定位。例如"境外收货人"前面一定有"运输方式(N)",这段文字本身就是收货人的名字。它在 PDF 文本中就是一个连续的 span,只是被换行打断了。
> `.+?`(非贪婪匹配),保证只取到"境外收货人"之前。
### 4.4 模块三:数量/重量提取
这是整个脚本中逻辑最复杂的模块,因为它要处理 PDF 文本顺序和实际表格顺序的颠倒。
#### 原始文本长什么样(以第 1 页为例)
```
10000个 ← 按页面底部→顶部的顺序排列!
1.2千克 ← 商品 6 的净重
10000个25000个
4.4千克 ← 商品 5 的净重
...
155000个1100个
1.9千克 ← 商品 1 的净重
1100个征免 ... ← 尾部 + 表头
```
规律:
1. 每个商品的"数量(个)"和"净重(千克)"是**一对一配对**的
2. PDF 从页面底部向顶部提取,所以排列是**倒序**的(商品 1 在最下面)
3. 同一行可能有多个数量值(`10000个25000个` 表示两项商品的数量),取最后一个
#### 解析逻辑
```python
def extract_qty_weights(page_text):
lines = page_text.split('\n')
raw = []
for line in lines:
s = line.strip()
# ① 匹配纯重量行: 如 "1.9千克"
kg_m = re.match(r'^([\d.]+)\s*千克$', s)
if kg_m:
raw.append(('w', kg_m.group(1)))
continue
# ② 匹配数量行: 如 "155000个1100个" → 取最后一个 "1100"
if re.match(r'^[\d]+\s*个', s) and '千克' not in s \
and '征免' not in s and '境内' not in s:
nums = re.findall(r'(\d+)\s*个', s)
if nums:
raw.append(('q', nums[-1]))
continue
# ③ 一对一配对(数量 + 重量)
pairs = []
pending = None
for typ, val in raw:
if typ == 'q':
pending = val
elif typ == 'w' and pending:
pairs.append((pending, val))
pending = None
# ④ 反转:PDF是倒序,翻转回正向
return list(reversed(pairs))
```
### 4.5 模块四:商品项目解析:正则兼容 6 种格式变体
商品明细的写法因代理而异(见 2.3 节的格式对比表)。
#### 第一步:定位每个商品的起始位置
```python
# 找所有 "项号 + 商品编号" 起始块
starts = list(re.finditer(r'(?
评论(0)

暂无数据