Python応用

USPSの追跡データ制限強化を受けて、マルチキャリア追跡APIを自作する方法

「複数の配送会社のトラッキング情報をまとめて取得したいのに、APIが使いにくくて困っている…」

そんな悩みを抱えるエンジニアが、最近じわじわと増えています。特にUSPS(アメリカ郵便公社)が追跡データへのアクセスを厳格化したことで、マルチキャリア対応のトラッキングAPIを自分で組む必要性が高まってきました。

今回はこのトレンドを技術的に掘り下げながら、Pythonで実際に動くシンプルなマルチキャリア追跡APIの基本設計を一緒に見ていきましょう! 🚚

🔍 そもそも何が変わったの?

package tracking api
package tracking api / Photo by Kampus Production via Pexels

USPSはここ数年で、追跡データへのアクセスに関していくつかの変更を加えています。ざっくりまとめるとこんな感じです。

  • APIキーの発行条件が厳しくなった(事業規模・用途の審査が強化)
  • リクエスト制限(レートリミット)が厳格化された
  • Web Toolsという従来APIが将来的に廃止方向で、新APIへの移行を求められている

つまり、「ちょっとトラッキング番号を叩いて情報を取ってくる」という気軽な使い方がどんどんしにくくなっているんですよね。個人開発者や小規模なECサイト運営者にとっては、なかなか頭の痛い問題です。

🛠 マルチキャリア追跡APIの設計思想

こういった状況に対応するアプローチとして注目されているのが、複数キャリアを一元管理するラッパーAPIを自分で作るという方法です。イメージとしては、キャリアごとのAPIを裏側で呼び出しつつ、フロントに統一されたインターフェースを用意する「窓口の一本化」ですね。

対応キャリアの例としては:

  • USPS(審査申請が必要)
  • FedEx(Developer Portalから取得可)
  • UPS(無料の開発者アカウントで取得可)
  • DHL(APIキー発行可)

それぞれのキャリアAPIを直接フロントから叩くのではなく、自作のラッパーAPIを経由させることで、以下のメリットが得られます。

  • APIキーをサーバーサイドで安全に管理できる
  • キャリアが変わってもフロントのコードを変更しなくていい
  • レスポンスの形式を統一できて扱いやすい
  • キャッシュやレートリミット対策を一か所にまとめられる

💻 Pythonで書くシンプルなマルチキャリア追跡APIの例

ポイントをまとめるとこんな感じです。

  • トラッキング番号のパターンでキャリアを自動判別する
  • キャリアごとに異なるAPIを呼び分ける
  • レスポンスを統一フォーマットに整形して返す

まずはコードの全体像を見てみましょう。

① キャリア自動判別ロジック

トラッキング番号の形式は、キャリアごとに異なります。正規表現でパターンマッチングして、自動的にキャリアを判別します。

# multi_carrier_tracker.py
import re
import requests
from typing import Optional

# ── キャリア判別(トラッキング番号の形式から推測)──
def detect_carrier(tracking_number: str) -> str:
    patterns = {
        "USPS": [
            r"^(94|93|92|94|95)[0-9]{20}$",   # Priority Mail等
            r"^[0-9]{20}$",                     # 20桁の数字
            r"^[A-Z]{2}[0-9]{9}US$",           # 国際郵便
        ],
        "FedEx": [
            r"^[0-9]{12}$",                     # 12桁
            r"^[0-9]{15}$",                     # 15桁
            r"^[0-9]{20}$",                     # 20桁(Ground)
        ],
        "UPS": [
            r"^1Z[A-Z0-9]{16}$",               # 標準UPS番号
        ],
        "DHL": [
            r"^[0-9]{10}$",                     # 10桁
            r"^[0-9]{39}$",                     # 39桁
        ],
    }

    tn = tracking_number.strip().upper()

    for carrier, carrier_patterns in patterns.items():
        for pattern in carrier_patterns:
            if re.match(pattern, tn):
                return carrier

    return "UNKNOWN"

⚠️ 注意:トラッキング番号の形式はキャリアのアップデートで変わることがあります。実運用では最新の仕様を確認してください。

② 各キャリアのAPI呼び出し関数

次に、キャリアごとのAPI呼び出し部分です。ここではダミーのAPIキーを使っているので、実際には各キャリアの開発者ポータルで取得したキーに差し替えてください。

# ── 各キャリアのAPI呼び出し ──

USPS_API_URL = "https://secure.shippingapis.com/ShippingAPI.dll"
FEDEX_API_URL = "https://apis.fedex.com/track/v1/trackingnumbers"
UPS_API_URL = "https://onlinetools.ups.com/api/track/v1/details"
DHL_API_URL = "https://api-eu.dhl.com/track/shipments"

def fetch_usps(tracking_number: str, api_key: str) -> dict:
    """USPS Web Tools API(旧式のXMLベース)"""
    xml_payload = f"""<TrackRequest USERID=\"{api_key}\">
        <TrackID ID=\"{tracking_number}\"/>
    </TrackRequest>"""

    try:
        response = requests.get(
            USPS_API_URL,
            params={"API": "TrackV2", "XML": xml_payload},
            timeout=10
        )
        response.raise_for_status()
        # 実際にはXMLをパースする処理が必要
        return {"raw": response.text, "carrier": "USPS"}
    except requests.RequestException as e:
        return {"error": str(e), "carrier": "USPS"}


def fetch_fedex(tracking_number: str, api_key: str) -> dict:
    """FedEx Tracking API v1"""
    headers = {
        "Authorization": f"Bearer {api_key}",
        "Content-Type": "application/json",
    }
    payload = {
        "trackingInfo": [
            {"trackingNumberInfo": {"trackingNumber": tracking_number}}
        ],
        "includeDetailedScans": True
    }

    try:
        response = requests.post(FEDEX_API_URL, json=payload, headers=headers, timeout=10)
        response.raise_for_status()
        return response.json()
    except requests.RequestException as e:
        return {"error": str(e), "carrier": "FedEx"}


def fetch_ups(tracking_number: str, api_key: str) -> dict:
    """UPS Tracking API v1"""
    headers = {
        "Authorization": f"Bearer {api_key}",
        "Content-Type": "application/json",
    }

    try:
        url = f"{UPS_API_URL}/{tracking_number}"
        response = requests.get(url, headers=headers, timeout=10)
        response.raise_for_status()
        return response.json()
    except requests.RequestException as e:
        return {"error": str(e), "carrier": "UPS"}


def fetch_dhl(tracking_number: str, api_key: str) -> dict:
    """DHL Shipment Tracking API"""
    headers = {
        "DHL-API-Key": api_key,
        "Content-Type": "application/json",
    }

    try:
        response = requests.get(
            DHL_API_URL,
            params={"trackingNumber": tracking_number},
            headers=headers,
            timeout=10
        )
        response.raise_for_status()
        return response.json()
    except requests.RequestException as e:
        return {"error": str(e), "carrier": "DHL"}

③ レスポンスの統一フォーマット化

各キャリアのAPIレスポンスは形式がバラバラです。これを統一フォーマットに整形することで、フロント側の実装がシンプルになります。

# ── レスポンスを統一フォーマットに整形 ──

def normalize_response(carrier: str, raw_response: dict) -> dict:
    """
    キャリアに関わらず同じ形式で返す統一レスポンス
    {
        "carrier": "FedEx",
        "tracking_number": "123456789012",
        "status": "In Transit",
        "location": "Memphis, TN",
        "estimated_delivery": "2025-07-10",
        "events": [...],
        "error": null
    }
    """

    # エラーレスポンスの処理
    if "error" in raw_response:
        return {
            "carrier": carrier,
            "status": "ERROR",
            "error": raw_response["error"],
            "events": []
        }

    # キャリアごとにパースロジックを切り替え
    if carrier == "FedEx":
        return _parse_fedex(raw_response)
    elif carrier == "UPS":
        return _parse_ups(raw_response)
    elif carrier == "DHL":
        return _parse_dhl(raw_response)
    elif carrier == "USPS":
        return _parse_usps(raw_response)
    else:
        return {"carrier": carrier, "status": "UNSUPPORTED", "events": []}


def _parse_fedex(data: dict) -> dict:
    """FedExレスポンスを統一フォーマットに変換"""
    try:
        track_info = data["output"]["completeTrackResults"][0]["trackResults"][0]
        status = track_info["latestStatusDetail"]["description"]
        location_data = track_info.get("lastUpdateTime", "")
        events = [
            {
                "timestamp": e.get("date", ""),
                "description": e.get("eventDescription", ""),
                "location": e.get("scanLocation", {}).get("city", "")
            }
            for e in track_info.get("dateAndTimes", [])
        ]
        return {
            "carrier": "FedEx",
            "status": status,
            "location": location_data,
            "events": events,
            "error": None
        }
    except (KeyError, IndexError) as e:
        return {"carrier": "FedEx", "status": "PARSE_ERROR", "error": str(e), "events": []}


def _parse_ups(data: dict) -> dict:
    """UPSレスポンスを統一フォーマットに変換"""
    try:
        shipment = data["trackResponse"]["shipment"][0]
        package = shipment["package"][0]
        status = package["activity"][0]["status"]["description"]
        events = [
            {
                "timestamp": a["date"] + " " + a["time"],
                "description": a["status"]["description"],
                "location": a.get("location", {}).get("address", {}).get("city", "")
            }
            for a in package.get("activity", [])
        ]
        return {
            "carrier": "UPS",
            "status": status,
            "events": events,
            "error": None
        }
    except (KeyError, IndexError) as e:
        return {"carrier": "UPS", "status": "PARSE_ERROR", "error": str(e), "events": []}


def _parse_dhl(data: dict) -> dict:
    """DHLレスポンスを統一フォーマットに変換"""
    try:
        shipment = data["shipments"][0]
        status = shipment["status"]["description"]
        events = [
            {
                "timestamp": e["timestamp"],
                "description": e["description"],
                "location": e.get("location", {}).get("address", {}).get("addressLocality", "")
            }
            for e in shipment.get("events", [])
        ]
        return {
            "carrier": "DHL",
            "status": status,
            "events": events,
            "error": None
        }
    except (KeyError, IndexError) as e:
        return {"carrier": "DHL", "status": "PARSE_ERROR", "error": str(e), "events": []}


def _parse_usps(data: dict) -> dict:
    """USPS(XMLをパースした後の辞書を想定)"""
    # USPS Web Tools APIはXML形式のため、実際にはxml.etree.ElementTreeで変換が必要
    return {
        "carrier": "USPS",
        "status": "要XMLパース実装",
        "events": [],
        "error": None
    }

④ メインの追跡関数をまとめる

ここまでのパーツを組み合わせて、「トラッキング番号を渡すだけで結果が返ってくる」メインの関数を作ります。

# ── APIキーの設定(実際は環境変数で管理すること)──
API_KEYS = {
    "USPS": "your_usps_api_key",
    "FedEx": "your_fedex_bearer_token",
    "UPS": "your_ups_bearer_token",
    "DHL": "your_dhl_api_key",
}

# ── メイン追跡関数 ──
def track(tracking_number: str) -> dict:
    carrier = detect_carrier(tracking_number)

    if carrier == "UNKNOWN":
        return {
            "carrier": "UNKNOWN",
            "status": "ERROR",
            "error": "キャリアを特定できませんでした。トラッキング番号を確認してください。",
            "events": []
        }

    api_key = API_KEYS.get(carrier, "")

    fetch_functions = {
        "USPS": fetch_usps,
        "FedEx": fetch_fedex,
        "UPS": fetch_ups,
        "DHL": fetch_dhl,
    }

    fetch_fn = fetch_functions.get(carrier)
    if not fetch_fn:
        return {"carrier": carrier, "status": "ERROR", "error": "対応していないキャリアです", "events": []}

    raw = fetch_fn(tracking_number, api_key)
    return normalize_response(carrier, raw)


# ── 動作確認 ──
if __name__ == "__main__":
    import json

    test_numbers = [
        "1Z999AA10123456784",   # UPS形式
        "9400111899223045401898", # USPS形式
        "123456789012",          # FedEx形式
    ]

    for tn in test_numbers:
        print(f"\n--- {tn} ---")
        result = track(tn)
        print(json.dumps(result, ensure_ascii=False, indent=2))

🌐 FastAPIでWeb API化する


上の関数をそのままFlaskやFastAPIでラップすれば、HTTP経由で叩けるAPIに変えられます。FastAPIを使った簡単な例も見てみましょう。

# api_server.py
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel

# multi_carrier_tracker.py から track関数をインポートする想定
# from multi_carrier_tracker import track

app = FastAPI(title="Multi Carrier Tracker API")

class TrackResponse(BaseModel):
    carrier: str
    status: str
    error: str | None = None
    events: list

@app.get("/track/{tracking_number}", response_model=TrackResponse)
async def track_shipment(tracking_number: str):
    result = track(tracking_number)
    if result.get("status") == "ERROR":
        raise HTTPException(status_code=400, detail=result.get("error"))
    return result

これで GET /track/1Z999AA10123456784 のようにリクエストするだけで、統一フォーマットのJSONが返ってくるAPIのできあがりです。

🔐 本番運用で気をつけること


このAPIを実際に運用する際に注意しておきたいポイントをまとめておきます。

APIキーの管理

コードにAPIキーを直書きするのは絶対に避けてください。.env ファイルと python-dotenv ライブラリを使って環境変数で管理しましょう。

# .env ファイル
USPS_API_KEY=your_actual_key
FEDEX_API_KEY=your_actual_key
UPS_API_KEY=your_actual_key
DHL_API_KEY=your_actual_key
from dotenv import load_dotenv
import os

load_dotenv()

API_KEYS = {
    "USPS": os.getenv("USPS_API_KEY"),
    "FedEx": os.getenv("FEDEX_API_KEY"),
    "UPS": os.getenv("UPS_API_KEY"),
    "DHL": os.getenv("DHL_API_KEY"),
}

レートリミット対策

各キャリアのAPIには1日・1時間あたりのリクエスト上限があります。同じトラッキング番号の結果をキャッシュすることで、無駄なリクエストを減らせます。

import functools
import time

# シンプルなインメモリキャッシュ(TTL付き)
_cache = {}
CACHE_TTL = 300  # 5分間キャッシュ

def track_with_cache(tracking_number: str) -> dict:
    now = time.time()
    if tracking_number in _cache:
        cached_at, data = _cache[tracking_number]
        if now - cached_at < CACHE_TTL:
            return data  # キャッシュから返す

    result = track(tracking_number)
    _cache[tracking_number] = (now, result)
    return result

本番環境ではRedisを使ったキャッシュが定番です。

エラーハンドリングとリトライ

ネットワークエラーやAPIの一時的な障害に備えて、tenacityライブラリを使ったリトライ処理を加えると安心です。

from tenacity import retry, stop_after_attempt, wait_exponential

@retry(stop=stop_after_attempt(3), wait=wait_exponential(min=1, max=10))
def fetch_fedex_with_retry(tracking_number: str, api_key: str) -> dict:
    return fetch_fedex(tracking_number, api_key)

📦 必要なライブラリのインストール

今回のコードで使うライブラリをまとめてインストールするには:

pip install requests fastapi uvicorn python-dotenv tenacity

FastAPIのサーバーを起動する場合は:

uvicorn api_server:app --reload

🎯 まとめ

今回の内容を振り返ってみましょう。

  • ✅ USPSのAPI制限強化により、マルチキャリア対応の自作ラッパーAPIの需要が増えている
  • ✅ トラッキング番号の正規表現パターンでキャリアを自動判別できる
  • ✅ 各キャリアのAPI呼び出し関数を作り、レスポンスを統一フォーマットに整形する
  • ✅ FastAPIを使えばHTTP APIとして公開するのも簡単
  • ✅ 本番運用ではAPIキー管理・キャッシュ・リトライ処理が重要

最初はシンプルな構成から始めて、必要に応じてキャリアを追加していくのがおすすめです。「自分たちのシステムに合ったトラッキングAPI」を育てていく感覚で取り組んでみてください 🛠✨

📚 関連商品・おすすめ書籍

スッキリわかるPython入門 第2版 (スッキリわかる入門シリーズ)

もしも

スッキリわかるPython入門 第2版 (スッキリわかる入門シリーズ)

初心者に定番のPython入門書

Amazonで見る

徹底攻略! 電子工作&プログラミング Arduinoで学ぶ電子工作完全ガイド

もしも

徹底攻略! 電子工作&プログラミング Arduinoで学ぶ電子工作完全ガイド

電子工作とプログラミングを同時に学べる

Amazonで見る

実践Claude Code入門―現場で活用するためのAIコーディングの思考法

もしも

実践Claude Code入門―現場で活用するためのAIコーディングの思考法

AIコーディングの現場活用法を学ぶ一冊

Amazonで見る

※本記事にはアフィリエイトリンクが含まれます。

ABOUT ME
やまちゃん
これまで学生と社会人を合わせて5000人以上にプログラミング学習を指導。 ゼロからイチをわかりやすく解説する専門家として活動しており、本業ではArduinoを用いたIoT開発とロボットプログラミングが専門。 Pythonを用いたアプリ開発、ウェブアプリケーションの開発で業務の効率化をサポートしています。

COMMENT

メールアドレスが公開されることはありません。 が付いている欄は必須項目です