space ocr
指南文章價格文件
developer

用 Python 解析發票:一份 REST API 指南

使用 Python 從發票中提取結構化資料。本指南將說明如何呼叫一個簡單的 REST API,從任何發票影像中取得 JSON 欄位、條列項目以及可供驗證的座標。

7 分鐘閱讀· 2026-08-31

從發票中提取結構化資料是個常見卻惱人的任務。您可能有一堆掃描的收據或廠商發票,格式是 JPEG 或 PNG,而您需要從中取出發票號碼、總金額和每個條列項目,以利會計或分析之用。大多數 OCR 工具只會輸出一大堆文字,讓您得用脆弱的正規表示式(regular expressions)辛苦地重組資料結構。

其實有個更直接的方法:透過一次 REST API 呼叫,就能回傳您需要的結構化 JSON,而且每個數值都能追溯到它在頁面上的確切位置。下方的互動式範例展示了最終成果。點擊左側任何欄位,即可看到它在發票上對應的位置反白顯示。

Invoice with extracted-field bounding boxes
Verified fields
Invoice

Each value with a box carries a verified on-page location — in data.cells[path], that is box + 4-point quad + evidence.match_ratio — on a 0–1000 normalized grid (0,0 top-left → 1000,1000 bottom-right), the same shape the live API returns. Hover a field to trace it back to the pixels it came from.

An example invoice for OCR processing
一張發票影像——這就是 /ocr/fields 的輸入內容。

space-ocr 不是一個需要安裝的函式庫,而是一個單純的 HTTP 服務。流程很簡單:準備一張發票影像,將它傳送到 POST /ocr/fields 端點,並定義您希望回傳的資料綱要(schema)。您可以指定像是 invoice_number、total_due 等欄位,以及一個包含品項描述和價格欄位的 line_items 表格。API 會把您宣告的綱要原樣放在 data.values 回傳,座標與驗證結果則分別放在同層的其他鍵裡。

每個擷取出的數值都與其在原始影像上的邊界框(bounding box)連結,提供了一條可供驗證的稽核軌跡。

回應會把資料和驗證結果分開存放。data.values 就是您宣告的綱要本身,invoice_number 的 "20250430-001" 也在您定義的位置上。data.cells 是一個以路徑為鍵的扁平對映,cells["total_due"] 裡放著該值在頁面上的 box 與 quad,以及一個 verified 判定。data.review.flagged 則是一份簡短清單,列出值得再看一眼的路徑和對應的 reasons。下方的綱要宣告了日期與數字,因此 data.normalized 會一併回傳解析後的值,而 values 保留讀取到的原始寫法。下方的 Python 腳本會依序取出這些內容。

parse_invoice.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
import os
import base64
import json
import requests

# Get your API key from an environment variable
API_KEY = os.environ.get("SPACE_OCR_API_KEY")
IMAGE_PATH = "./path/to/your/invoice.jpg" # Path to your invoice image
API_URL = "https://api.space-ocr.com/ocr/fields"

if not API_KEY:
    raise ValueError("API key not found. Set the SPACE_OCR_API_KEY environment variable.")

# Declare the schema you want back.
# 'type' adds a deterministic parse under data.normalized; 'required' and 'pattern'
# are checked after extraction and show up in the review list when they fail.
# Declarations never reach the model: they do not change the value, only the signals.
fields_schema = [
    {"name": "supplier", "type": "string", "description": "The name of the company that issued the invoice."},
    {"name": "invoice_number", "type": "string", "required": True, "pattern": "^[0-9]{8}-[0-9]{3}$"},
    {"name": "issue_date", "type": "date", "required": True},
    {"name": "total_due", "type": "number", "required": True},
    {
        "name": "line_items",
        "type": "array",
        "description": "All items listed in the invoice table.",
        "children": [
            {"name": "item_description", "type": "string"},
            {"name": "unit_price", "type": "number"},
            {"name": "quantity", "type": "integer"},
            {"name": "line_total", "type": "number"}
        ]
    }
]

# Read the image file and encode it in base64
with open(IMAGE_PATH, "rb") as image_file:
    base64_image = base64.b64encode(image_file.read()).decode("utf-8")

headers = {
    "Authorization": f"Bearer {API_KEY}",
    "Content-Type": "application/json"
}

payload = {
    "image": base64_image,
    "imageType": "base64",
    "fields": fields_schema
}

print(f"Sending request for {IMAGE_PATH}...")
response = requests.post(API_URL, headers=headers, json=payload)

if response.status_code != 200:
    # Errors use one envelope: {"error": {"code", "message", "requestId"}, "details": {...}}
    try:
        err = response.json()["error"]
        print(f"HTTP {response.status_code} {err['code']}: {err['message']} (requestId {err['requestId']})")
    except (ValueError, KeyError):
        # A body over 32MiB is cut off before the app sees it and comes back as HTML
        print(f"HTTP {response.status_code}: {response.text[:200]}")
    if response.status_code == 429:
        print(f"Rate limited - Retry-After: {response.headers.get('Retry-After')} seconds")
    # 400 invalid_image is not charged and a retry will not help; 502 is refunded and worth
    # retrying; 504 means the 180s sync ceiling was hit, so send that page on its own.
    raise SystemExit(1)

data = response.json()["data"]
values = data["values"]                  # your schema, and nothing else
cells = data["cells"]                    # flat map: path -> box / quad / verified / review / evidence
review = data["review"]                  # what is worth a second look
normalized = data.get("normalized", {})  # deterministic parse of the types you declared
image = data["image"]                    # {"width", "height"} - the frame the coordinates use

print("\n--- Extracted Invoice Data ---")
print(json.dumps(values, ensure_ascii=False, indent=2))

# 'values' keeps the text as it was read; the parsed form lives here.
# A null leaf means the parse failed - the reason sits in cells[path]["normalized"]["error"].
print("\nissue_date parsed:", normalized.get("issue_date"))
print("total_due parsed :", normalized.get("total_due"))

# The review queue: every entry carries a path and a rank-ordered reasons array.
print(f"\n{len(review['flagged'])} of {review['declared']} declared fields were flagged:")
for item in review["flagged"]:
    path = item["path"]
    reasons = item["reasons"]      # reasons[0] is the primary one
    cell = cells.get(path)         # 'missing' and 'nobox' have no cell to point at
    box = cell["box"] if cell else None
    print(f"  {path}: {reasons[0]} ({', '.join(reasons)}) box={box}")

# Where a single value came from
total = cells.get("total_due")
if total:
    print("\ntotal_due")
    print("  box     :", total["box"])       # {"xmin", "ymin", "xmax", "ymax"}, 0-1000
    print("  quad    :", total["quad"])      # four {"x", "y"} points, following the page tilt
    print("  verified:", total["verified"])  # False if flagged, True if a check ran clean, None if there was nothing to check
    print("  evidence:", total["evidence"])  # text_match, source, match_ratio, printed_text ...
    left = total["box"]["xmin"] / 1000 * image["width"]
    top = total["box"]["ymin"] / 1000 * image["height"]
    print(f"  pixels  : ({left:.0f}, {top:.0f}) on a {image['width']}x{image['height']} page")

# Line items: values holds the rows, cells is keyed by the indexed path
print("\nline_items")
for i, row in enumerate(values.get("line_items") or []):
    cell = cells.get(f"line_items[{i}].line_total")
    verified = cell["verified"] if cell else None
    print(f"  row {i}: {row.get('item_description')} x{row.get('quantity')} "
          f"= {row.get('line_total')} (verified={verified})")
✓ Verified

為了確保準確性,space-ocr 並不全然相信語言模型的輸出。模型會回傳文字值以及它在頁面上使用了哪些詞彙的提示。接著,引擎會對照頁面上實際的 OCR 符號,對擷取出的數值進行逐字元比對。這個過程會產生 match_ratio 信賴度分數(≥ 0.85 即為高信賴度比對)和座標,兩者都放在 data.cells[path] 裡:box 是 xmin、ymin、xmax、ymax 四個整數鍵,quad 則是隨頁面傾斜的四個點。所有座標都標準化到 0-1000,因此與原始影像的解析度無關;要換算成像素時,請用 data.image 提供的頁面尺寸。比對本身的結果記在 evidence.text_match,而 verified 是該單元的整體判定 —— 只要有任何指摘成立,包含您自行宣告的規則,它就會變成 false。

API 的計價方式是按次計費,而非根據欄位數量或頁面複雜度。每張影像 $0.05(含稅),若請求未能成功產生結果則不收費。新註冊帳戶每月可獲得 100 次免費掃描額度。

  1. 取得您的 API 金鑰
    註冊一個免費的 space-ocr 帳戶,並在儀表板設定中找到您的 API 金鑰。金鑰會以「spocr_」開頭。
  2. 準備您的發票影像
    將您的發票儲存為常見的影像檔,例如 JPEG 或 PNG。記下檔案路徑以供腳本使用。
  3. 定義您的資料綱要
    在您的 Python 腳本中建立一個 JSON 陣列,用來定義您想擷取的欄位名稱和類型,包含任何條列項目表格。
  4. 撰寫 Python 腳本
    使用 requests 函式庫,撰寫一個腳本來讀取影像、將其轉換為 base64,並將 API 金鑰放在 Authorization 標頭中,向 https://api.space-ocr.com/ocr/fields 發送 POST 請求。
  5. 執行並處理 JSON
    執行您的腳本。把 `data.values` 載入資料庫、會計軟體或分析工具,並先核對 `data.review.flagged` 中列出的那幾個值。
我需要安裝 SDK 或函式庫嗎?
不需要,space-ocr 是一個標準的 HTTP REST 服務。您可以使用任何能夠發送 HTTP 請求的語言或工具來呼叫它,例如 cURL 或 Python 的 requests 函式庫。
我可以處理 PDF 格式的發票嗎?
API 端點接受點陣圖影像格式,如 JPEG、PNG 或 WebP。若要處理 PDF 檔案,您需要先在自己的程式碼中將每個頁面渲染成影像,然後再傳送給 API。space-ocr 的網頁應用程式會自動為您處理這個轉換過程。
回應中的座標代表什麼?
API 回傳的是 0-1000 的標準化座標,其中 (0,0) 是左上角,(1000,1000) 是右下角。`data.cells` 中的每一筆都同時帶有 `box`(整數鍵 `xmin`、`ymin`、`xmax`、`ymax`)與 `quad`(隨頁面傾斜的四個點)。要換算成像素時,請以 `data.image` 提供的 width / height 為基準,那是讀取當下的頁面尺寸。
我要如何處理條列項目或表格?
在您的 `fields` 綱要中,定義一個 `type` 為 `array` 的欄位。然後,在巢狀的 `children` 屬性中,指定您想為每一列擷取的欄位。API 將會為該欄位回傳一個物件陣列。
如果我事先不知道發票的版面配置該怎麼辦?
若無法事先確定欄位定義,可以在請求主體中設定 `autoFields: true`,而不提供 `fields` 陣列。服務會偵測文件的結構並回傳它找到的欄位。看到回傳的欄位名稱之後,再把它們寫進 `fields` 明確宣告,後續的呼叫就會穩定下來。
我要怎麼知道哪些值需要人工複核?
請看 `data.review.flagged`。每一筆都帶有 `path` 和一個依排名排序的 `reasons` 陣列,第一個是主要原因,`missing`、`text_mismatch`、`pattern_mismatch` 這類代碼說明了被標記的理由。數量就是 `flagged` 的長度,沒有另外的計數器。要逐值查看時,`cells[path].verified` 會用布林值給出同一個判定,字元層級的細節則在 `cells[path].evidence` 裡(`match_ratio` 介於 0.0 到 1.0,0.85 及以上視為高信賴度的比對)。兩個引擎若碰巧讀錯到同一處,這類值仍可能通過,所以這份清單是用來縮小人工複核範圍,而不是取消它。

幾分鐘內即可開始解析發票。

立即取得您的 API 金鑰及每月 100 次免費掃描額度,不需提供信用卡。

相關文章