OCR 不是终点:从图片和 PDF 到可核验结构化 JSON 的 Python 实践

很多文档处理项目的第一版都停在“把字识别出来”。图片交给 OCR,PDF 转成纯文本,然后把结果直接写进数据库或向量库。这个流程能跑,却很难回答三个真正影响生产质量的问题:字段来自哪里、缺失时如何发现、后续规则变化时怎样重放。

更稳妥的做法,是把文档处理拆成两个边界清楚的阶段:先恢复文本,再按明确的 JSON Schema 抽取字段。前者解决“读出来”,后者解决“按业务结构交付”。

本文用 OCR 文字识别 APIPDF 转文本 API 统一图片与 PDF 的输入,再把规范化文本交给 文档字段抽取 API。示例使用 Python 标准库,不依赖第三方 HTTP 包。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

先把管线拆对

一条可维护的文档数据管线可以表示为:

图片 URL ──> OCR ─────────────┐
                              ├─> 规范化文本 ─> Schema 字段抽取 ─> 校验 ─> 人工复核/入库
PDF 文件 ──> PDF 转文本 ──────┘

这样拆分有四个直接好处:

  1. 输入层和业务字段解耦。OCR 或 PDF 解析方式变化,不需要重写入库模型。
  2. Schema 可以版本化。新增字段时,可以用保留的原始文本重放,不必重新上传文档。
  3. 失败位置明确。是文本恢复失败、字段抽取失败,还是业务校验失败,可以分别处理。
  4. 结果可核验。字段值之外还能保留请求 ID、失败字段、告警和证据,方便复查。

三个接口的职责不同

阶段 输入 输出 适合做什么
OCR 图片 URL 或图片 Base64 文本行数组 扫描件、截图、照片
PDF 转文本 PDF 文件 连续文本 可解析 PDF、报告、说明书
字段抽取 文本或文件 + JSON Schema 结构化字段与状态 合同、采购文件、表单入库

不要把 OCR 的文本行数组直接当成最终业务对象。识别文本里通常仍有页眉、换行、顺序和噪声;而“项目名称”“预算金额”“截止时间”这类字段需要明确类型、必填规则和失败处理。

用 JSON Schema 定义交付格式

下面以采购文档为例,只定义四个字段:

{
  "type": "object",
  "properties": {
    "projectName": {"type": "string", "description": "项目名称"},
    "projectCode": {"type": "string", "description": "项目编号"},
    "budgetAmount": {"type": "number", "description": "预算金额"},
    "bidDeadline": {"type": "string", "description": "投标截止时间"}
  },
  "required": ["projectName", "projectCode"]
}

Schema 的价值不只是告诉模型“抽哪些字段”。它还是调用方与下游系统之间的契约:类型不符、必填字段缺失、字段名变化,都可以在入库前被发现。

Python 调用要点

先把 AppKey 放进环境变量,不要把真实密钥写进代码:

export GUGUDATA_APPKEY='your-app-key'

OCR 请求使用表单编码。下面的代码同时检查 HTTP 响应和业务状态:

import json
import os
from urllib.parse import urlencode
from urllib.request import Request, urlopen


def recognize_image(image_url: str) -> list[str]:
    app_key = os.environ["GUGUDATA_APPKEY"]
    query = urlencode({"appkey": app_key})
    body = urlencode({"imageurl": image_url}).encode("utf-8")
    request = Request(
        f"https://api.gugudata.com/imagerecognition/ocr?{query}",
        data=body,
        headers={"Content-Type": "application/x-www-form-urlencoded"},
        method="POST"
    )
    with urlopen(request, timeout=30) as response:
        payload = json.load(response)

    status = payload.get("DataStatus", {})
    if int(status.get("StatusCode", 0)) != 100:
        raise RuntimeError(status.get("StatusDescription", "OCR failed"))
    return payload.get("Data", {}).get("ResultText", [])

PDF 转文本和字段抽取使用 multipart 请求。无论使用哪种输入,都不要把 AppKey、原始敏感文档或完整响应写入公共日志。

不能只检查 HTTP 200

HTTP 200 只表示网关完成了响应,不等于业务处理成功。调用方还应检查返回对象中的 DataStatus.StatusCode,只有状态值为 100 时才进入下一步。

lines = recognize_image(image_url)
if not lines:
    raise ValueError("OCR returned no text lines")

推荐把失败分成三类:

  • 传输失败:网络超时、非 2xx HTTP 状态,按退避策略重试。
  • 业务失败:业务状态不为 100,记录状态信息后进入失败队列。
  • 内容失败:请求成功但文本为空、字段缺失或类型不符,进入人工复核。

重试应由调用方设置上限,并为同一文档保存稳定的业务 ID。不要在未知原因下无限重试,也不要把 AppKey、原始敏感文档或完整响应写入公共日志。

抽取结果还要过一道业务校验

字段抽取成功,不代表可以直接入库。至少应做以下检查:

def validate_values(values: dict) -> list[str]:
    errors = []
    if not values.get("projectName"):
        errors.append("projectName is required")
    if "budgetAmount" in values and values["budgetAmount"] < 0:
        errors.append("budgetAmount must be non-negative")
    return errors

对于金额、日期、证件号等字段,建议在 Schema 类型校验之外再做领域规则校验。低置信、无证据、必填字段失败或警告不为空的记录,应进入人工复核,而不是静默写入正式表。
在这里插入图片描述

保存哪些追踪信息

为了支持排错和重放,建议为每次处理保存:

  • 内部文档 ID 和内容哈希;
  • API 返回的请求 ID 与处理模式;
  • Schema 名称、版本和哈希;
  • 文本恢复阶段与字段抽取阶段的状态;
  • 成功字段、失败字段、告警和人工复核结果;
  • 创建时间、完成时间和有限次重试记录。

原文件与全文是否长期保存,应由数据分级和合规要求决定。能只保存哈希和必要字段时,不要额外复制敏感文档。

一次公开 Demo 能证明什么

在本文核验时,三个公开 Demo 都返回了 HTTP 200 和业务状态 100;字段抽取 Demo 返回 7 个成功字段、0 个失败字段。这能证明示例返回结构与管线设计相符,但不能代表你的文档准确率、账号吞吐量或生产 SLA。

上线前仍应使用脱敏、具有代表性的自有样本建立测试集,分别统计文本恢复成功率、关键字段完整率、人工复核率和单文档成本。只有这些指标稳定,OCR 才真正从“能识别文字”变成一条可运营的结构化数据管线。

更多推荐