📄

Upstage Document Parse API を Python から叩いてみる

に公開

はじめに

前回は、Upstage Studio の GUI から Document Parse を試しました。

https://zenn.dev/ytkdm/articles/upstage-document-parse-agent

GUI で触ると、請求書や職務経歴シートのような表がある文書でも、ただのテキストではなく HTML や Markdown に近い形で見られました。ざっくり相性を見るには GUI が楽です。

GUI でだいたいの手応えは見えたので、次は API から使ってみます。画面で見えていた HTML や Markdown の結果が API ではどう返ってくるのか、使い勝手を確認してみましょう。

今回は、前回と同じサンプルの一部を使って、Document Parse API を Python から呼び、返ってきた JSON をそのまま眺めてみます。

GUI から納品書写真を処理した結果

API キーを用意する

API から使うには API キーが必要です。Upstage の Web Console にログインして、API Keys から作成できます。

試した時点では、初回 10 USD 分のクレジットや無料枠があるようで、クレジットカード登録なしで試せました。

Upstage Console の API Keys 画面

画面を見ると Studio API KeySecret key がありました。前回 Studio を触ったときに作られたのかもしれませんし、Studio 側からも作れるのかもしれません。このあたりは少し曖昧です。

作成したキーは、今回は環境変数 UPSTAGE_API_KEY に入れて使います。

export UPSTAGE_API_KEY="..."

まずは curl で叩く

Document parsing API reference を見ると、Document Parse API は https://api.upstage.ai/v1/document-digitizationmultipart/form-data でファイルを投げる形になっています。API キーは Authorization: Bearer ... で渡します。

まずはドキュメントの内容に従って、curl でアクセスしてみます。

model には alias か、特定の snapshot model を指定できます。2026-04-12 に Document Parse のモデルページ を見た時点では、document-parsedocument-parse-260128 を指していました。

名前 メモ
document-parse alias。現時点では document-parse-260128
document-parse-260128 2026-01-28 リリース。前回 GUI で選んだモデル
document-parse-251217 2025-12-17 リリース
document-parse-250618 2025-06-18 リリース
document-parse-250508 2025-05-08 リリース
document-parse-250404 2025-04-04 リリース
document-parse-250116 2025-01-16 リリース
document-parse-240910 2024-09-10 リリース

前回 GUI で選んだモデルも document-parse-260128 でした。alias の document-parse が現時点では同じモデルを指しているので、今回は document-parse を指定します。

# sample_input/納品書.jpeg は実際のファイルパスに置き換える
curl -X POST https://api.upstage.ai/v1/document-digitization \
  -H "Authorization: Bearer ${UPSTAGE_API_KEY}" \
  -F "document=@sample_input/納品書.jpeg" \
  -F "model=document-parse" \
  -F "mode=standard" \
  -F "ocr=force" \
  -F "output_formats=['html','markdown']" \
  -F "coordinates=true"

ocrautoforce があります。画像ファイルだけなら auto でも OCR が走りますが、スキャン PDF なども混ざる前提なら、最初は force にして挙動をそろえておくほうが見やすいです。

output_formats は、今回は htmlmarkdown を返すようにしました。前回 GUI で見ていた結果に近いものを、API からも確認したいためです。

curl で取得したレスポンス JSON

レスポンスを見ると、指定した htmlmarkdown が返ってきています。GUI で見ていた出力も、API ではこの JSON の中から取り出せばよさそうです。

Python から呼ぶ

次に Python から呼びます。

今回は、input/ フォルダに置いた PDF や画像を読み、output/ フォルダに Markdown と HTML を書き出すスクリプトにしました。API キーは .env から読みます。

.env
input/
  納品書.jpeg
output/
  納品書.md
  納品書.html

.env はこうしておきます。

UPSTAGE_API_KEY=...

スクリプトは parse_documents.py という1ファイルにしました。python-dotenv は使わず、.env をそのまま読んでいます。今回は uv で実行しました。

uv init --no-package --vcs none --no-readme --no-pin-python
uv add requests
from __future__ import annotations

import argparse
import os
from pathlib import Path
from typing import Any

import requests


ENDPOINT = "https://api.upstage.ai/v1/document-digitization"
BASE_DIR = Path(__file__).resolve().parent
SUPPORTED_SUFFIXES = {
    ".bmp",
    ".docx",
    ".heic",
    ".jpeg",
    ".jpg",
    ".pdf",
    ".png",
    ".pptx",
    ".tif",
    ".tiff",
    ".xlsx",
}


def load_api_key(env_file: Path) -> str:
    if env_file.exists():
        for line in env_file.read_text(encoding="utf-8").splitlines():
            line = line.strip()
            if not line or line.startswith("#") or "=" not in line:
                continue

            key, value = line.split("=", 1)
            if key.strip() == "UPSTAGE_API_KEY":
                return value.strip().strip("'\"")

    api_key = os.environ.get("UPSTAGE_API_KEY")
    if api_key:
        return api_key

    raise SystemExit("UPSTAGE_API_KEY is not set in .env")


def iter_inputs(input_dir: Path) -> list[Path]:
    if not input_dir.is_dir():
        raise FileNotFoundError(input_dir)

    return sorted(
        child
        for child in input_dir.iterdir()
        if child.is_file() and child.suffix.lower() in SUPPORTED_SUFFIXES
    )


def parse_document(
    path: Path,
    *,
    api_key: str,
) -> dict[str, Any]:
    headers = {"Authorization": f"Bearer {api_key}"}
    data = {
        "model": "document-parse",
        "mode": "standard",
        "ocr": "force",
        "output_formats": "['html','markdown']",
        "coordinates": "true",
    }

    with path.open("rb") as fp:
        files = {"document": (path.name, fp)}
        response = requests.post(
            ENDPOINT,
            headers=headers,
            data=data,
            files=files,
            timeout=180,
        )

    response.raise_for_status()
    return response.json()


def write_outputs(result: dict[str, Any], output_dir: Path, stem: str) -> None:
    content = result.get("content")
    if not isinstance(content, dict):
        return

    output_dir.mkdir(parents=True, exist_ok=True)

    markdown = content.get("markdown")
    if isinstance(markdown, str) and markdown.strip():
        (output_dir / f"{stem}.md").write_text(markdown, encoding="utf-8")

    html = content.get("html")
    if isinstance(html, str) and html.strip():
        (output_dir / f"{stem}.html").write_text(html, encoding="utf-8")


def main() -> None:
    parser = argparse.ArgumentParser()
    parser.add_argument("--env-file", type=Path, default=BASE_DIR / ".env")
    parser.add_argument("--input-dir", type=Path, default=BASE_DIR / "input")
    parser.add_argument("--output-dir", type=Path, default=BASE_DIR / "output")
    args = parser.parse_args()

    api_key = load_api_key(args.env_file)
    inputs = iter_inputs(args.input_dir)
    if not inputs:
        raise SystemExit(f"No supported files found: {args.input_dir}")

    for path in inputs:
        print(f"parse: {path}")
        result = parse_document(path, api_key=api_key)
        write_outputs(result, args.output_dir, path.stem)


if __name__ == "__main__":
    main()

全文は parse_documents.py に置いています。

今回は input/納品書.jpeg を置いてから実行しました。

uv run parse_documents.py

実行後、output/納品書.mdoutput/納品書.html ができました。

出力された Markdown

納品書の明細と金額の部分が、Markdown のテーブルとして残っています。

# 納品書

ご注文日 2026年4月7日 ご注文番号

発行日 2026年4月7日

| 数量商品名 | 種類 | 金額 (税込) |
| --- | --- | --- |
| 1 UGREEN LANケーブルCAT6A メッシュLAN ケーブルカテゴリー6Aコネクタ RJ45 高速 10Gbps/500MHzCAT6準拠 イーサネットケーブル イーサネットケーブル 爪折れ防止PVC素材 モデム ルータ PS3 PS4Xbox等に対応 20M BOF8MZ31KS,6941876251643,BOF8MZ31KS | その他 | ¥2,228 |

| 小計 | ¥2,228 |
| --- | --- |
| 配送料・手数料 | ¥200 |
| 合計 | ¥2,428 |

output/納品書.html をブラウザで開くと、前回 GUI で見ていたように表の形で確認できます。

出力された HTML をブラウザで表示した結果

同期 API と非同期 API

今回のサンプルでは同期 API を使いました。公式ドキュメントを見ると、同期 API は1リクエストあたり最大100ページまでで、100ページを超える文書では分割するか非同期 API を使う案内になっています。

今回使った職務経歴シートのような短い PDF なら、同期 API でそのまま結果を確認できました。

RAG 前処理に使うなら

RAG の前処理として見るなら、まず Markdown をそのままチャンク分割したくなります。

ただ、テーブルがある文書では、単純に文字数で切ると行や列のまとまりが崩れます。前回の職務経歴シートのように、列境界で一部の文字が隣に吸われるケースもあります。そういう文書を扱うなら、Markdown だけを見てすぐベクトルDBへ入れるより、HTML も見て、表をどうチャンク化するかを決めたほうがよさそうです。

今回は、まず次のような使い分けで考えています。

保存物 使いどころ
HTML テーブルや見出し構造を目で確認する
Markdown LLM に渡す本文候補にする

このあたりは「Document Parse の出力が正しいか」だけではなく、後段で何をしたいかによって変わりそうです。ざっくり内容検索をしたいのか、請求書の明細を1行ずつ DB に入れたいのかで、見ておくべき出力も変わりそうですね。

まとめ

前回 GUI で見ていた Document Parse の結果を、今回は curl と Python のコードから取得しました。GUI で見えていた HTML や Markdown が、API のレスポンス JSON からそのまま取り出せます。

コードから呼べるようになると、ファイルをまとめて投げたり、返ってきた HTML / Markdown を保存したりするところを自分の処理に組み込めます。外部 API として呼び出すところまでは、かなり簡単に使えそうです。

今後は、実際の処理に組み込んだときにどうなるかも見てみたいです。たとえば RAG のチャンクに使ったり、既存の RAG 検索システムに PDF をそのまま入れるのではなく、Document Parse で処理した HTML や Markdown を入れることで精度が変わるのか、というあたりです。

参考

Discussion