eBay Inventory Mapping API + Inventory API:AI推奨結果をInventory APIに渡して高品質な出品を自動作成する

前回の記事はこちら

【連載#20】eBay Inventory Mapping API + Inventory API:AI推奨結果をInventory APIに渡して高品質な出品を自動作成する

はじめに

本記事は、全42回にわたる「eBay API 実践ガイド」の第20回です。

前回(#19)では、Inventory Mapping APIにサブミットしたタスクをポーリングで監視し、タスク完了後にListingPreviewオブジェクト群(AI推奨のカテゴリ・タイトル・アスペクト・説明文・SKU・mappingReferenceId)を取得しました。これでAIの推奨結果が手元に揃った状態です。

本記事では、その「プレビュー結果を眺めるだけ」で終わらせず、Inventory APIの createOrReplaceInventoryItem へ実際に渡して出品アイテムレコードを自動作成する——Inventory Mapping API三部作の「仕上げ」を実装します。

この記事で得られること:

  • ListingPreview(GraphQL形式)のaspectsをInventory API REST形式の辞書へ変換するデータマッピング層の設計と実装。
  • mappingReferenceIdをSKUと紐付けてDBに永続化し、後日AI推薦効果を定量評価できるトレーサビリティ設計。
  • COMPLETED_WITH_ERROR商品の混入を防ぐ入力バリデーションと、bulkCreateOrReplaceInventoryItemを使ったスケーラブルなE2Eパイプラインの実装。

背景・なぜこれが重要か (Motivation)

「推奨結果をコピペして手動で出品すればいいのでは?」

Inventory Mapping APIを初めて使う開発者が必ず抱く疑問です。数件であれば確かに手動コピーも現実的ですが、数百・数千件のカタログを毎日処理するECオペレーションでは、すぐに壁に当たります。

プログラム化が必要な理由は3つあります。「スケーラビリティ」——Mapping APIは1タスクで最大500件をバッチ処理できますが、その結果を手動で出品しては処理能力を活かせません。「データ一貫性」——コピペによるタイプミスや項目の抜けが出品エラーや検索品質低下に直結します。「トレーサビリティ」——mappingReferenceIdをどのSKUに紐付けたか記録しなければ、AI推薦がビジネスにどれだけ貢献したかを後から検証できません。

この3課題を同時解決するのが、本記事で実装する「AI推奨→Inventory API自動連携パイプライン」です。

基本的な使い方(ベースライン):ListingPreviewからcreateOrReplaceInventoryItemへ

まず最小限の動作確認コードから始めます。Inventory APIの createOrReplaceInventoryItem はPUT /sell/inventory/v1/inventory_item/{sku} として呼び出します。リクエストボディの主要フィールドを確認しましょう。

  • product.title: 商品タイトル(最大80文字)。
  • product.description: 商品説明文(基本的なHTMLタグ可)。
  • product.aspects: Item Specificsの辞書形式。キー=アスペクト名(最大40文字)、値=文字列の配列(各値最大50文字)。
  • product.imageUrls: 商品画像URLの配列(HTTPS必須、offer公開前に最低1枚必要)。
  • condition: 商品コンディションの列挙値(例: NEW, USED_EXCELLENT など)。
  • availability.shipToLocationAvailability.quantity: 利用可能在庫数。

なお、createOrReplaceInventoryItemはPUTリクエストで、新規作成・更新ともにHTTP 204(No Content)を返します。このAPIだけでは出品は完了せず、その後createOrReplaceOffer → publishOfferが必要です(Offer APIシリーズは第21回以降で解説予定)。

以下は最小限の実装例です。

# baseline_inventory_upload.py
import requests
from typing import Any


def _convert_aspects_to_rest(gql_aspects: list[dict]) -> dict[str, list[str]]:
    """
    GraphQL ListingPreviewProductAspect配列をInventory API REST形式の辞書に変換する。

    GraphQL形式(第19回で取得した構造):
      [{"localizedAspectName": "Brand", "value": [{"localizedValue": "Nike"}]}, ...]
    REST形式(createOrReplaceInventoryItemが期待する構造):
      {"Brand": ["Nike"], "Color": ["Black"], ...}
    """
    result: dict[str, list[str]] = {}
    for aspect in (gql_aspects or []):
        name = aspect.get("localizedAspectName") or aspect.get("name", "")
        if not name:
            continue
        raw_values = aspect.get("value") or aspect.get("values") or []
        if raw_values and isinstance(raw_values[0], dict):
            values = [v.get("localizedValue", "") for v in raw_values if v.get("localizedValue")]
        else:
            values = [str(v) for v in raw_values if v]
        if values:
            result[name] = values
    return result


def create_inventory_item_from_preview(
    preview: dict[str, Any],
    access_token: str,
    condition: str = "NEW",
    quantity: int = 10,
) -> dict:
    """ListingPreviewデータをInventory APIに渡して在庫アイテムを作成する(最小実装)。"""
    sku = preview["sku"]
    images = preview.get("images", [])
    image_urls = [img if isinstance(img, str) else img.get("value", "") for img in images]
    body = {
        "product": {
            "title": preview.get("title", ""),
            "description": preview.get("description", ""),
            "aspects": _convert_aspects_to_rest(preview.get("aspects", [])),
            "imageUrls": image_urls,
        },
        "condition": condition,
        "availability": {
            "shipToLocationAvailability": {"quantity": quantity}
        },
    }
    url = f"https://api.ebay.com/sell/inventory/v1/inventory_item/{sku}"
    headers = {
        "Authorization": f"Bearer {access_token}",
        "Content-Type": "application/json",
        "Content-Language": "en-US",
    }
    resp = requests.put(url, json=body, headers=headers, timeout=30)
    resp.raise_for_status()
    return {"sku": sku, "http_status": resp.status_code}
補足: conditionはListingPreviewに含まれない

createOrReplaceInventoryItemのconditionフィールドはoffer公開前に必須ですが、Inventory Mapping APIのListingPreviewには含まれていません。「商品コンディションはセラー自身が判断する情報」という設計思想によるものです。パイプラインでは外部(商品マスターDB等)からSKUに対応するconditionを注入する設計にしてください。

実務で躓く場面・深いポイント (Core)

ベースラインコードは動きますが、本番に乗せると必ず遭遇する3つの落とし穴を解説します。

1. GraphQLのaspects形式とRESTのaspects形式——データ構造の違いと安全な変換

第19回で取得したListingPreview.aspectsはGraphQLの「配列形式」です。一方、Inventory API REST側が期待するproduct.aspectsは「辞書(オブジェクト)形式」です。この変換を誤ると全商品でHTTP 400が返り続けます。

GraphQL形式(ListingPreviewProductAspect配列)の例:

# GraphQL レスポンス(第19回で取得済み)
gql_aspects = [
    {"localizedAspectName": "Brand", "value": [{"localizedValue": "Nike"}]},
    {"localizedAspectName": "Color",
     "value": [{"localizedValue": "Black"}, {"localizedValue": "White"}]},
    {"localizedAspectName": "Size",  "value": [{"localizedValue": "M"}]},
]

REST API形式(createOrReplaceInventoryItemが受け付ける形式)の例:

# Inventory API REST リクエストボディ内の product.aspects
rest_aspects = {
    "Brand": ["Nike"],
    "Color": ["Black", "White"],
    "Size": ["M"],
}

変換自体は単純ですが、見落としやすい制限が2点あります。アスペクト名の最大長は40文字、値の最大長は1つにつき50文字です。Mapping APIの推奨値がこの制限を超えることは稀ですが、超えた場合はInventory APIがerrorId: 25044または25045を返します。また、localizedValueが空文字のエントリをそのまま送るとバリデーションエラーになるため、変換時に除外処理を入れておくことが重要です。

2. mappingReferenceIdのトレーサビリティ管理を怠ると後でAI推薦効果を検証できなくなる

eBay公式ドキュメントは「Inventory Mapping APIの推奨結果を使って出品を作成・更新する場合、mappingReferenceIdをその出品に含めること」と明記しています。これはトレーサビリティとAI推薦精度の効果測定のためです。

本記事執筆時点(Inventory API v1.18.5)において、createOrReplaceInventoryItemのリクエストボディにmappingReferenceId専用のフィールドは確認できませんでした。そのため、SKUとmappingReferenceIdの対応を内部DB(SQLite等)に永続化する設計を推奨します。憶測で存在しないフィールド名を指定すると、APIが400エラーを返す原因になるため注意してください。

# sku_mapping_store.py — mappingReferenceId を SQLite で管理する
import sqlite3
from datetime import datetime


def init_mapping_store(db_path: str = "ebay_mapping.db") -> None:
    conn = sqlite3.connect(db_path)
    conn.execute("""
        CREATE TABLE IF NOT EXISTS sku_mapping_log (
            id               INTEGER PRIMARY KEY AUTOINCREMENT,
            sku              TEXT NOT NULL,
            mapping_ref_id   TEXT NOT NULL,
            category_id      TEXT,
            created_at       TEXT NOT NULL,
            UNIQUE(sku, mapping_ref_id)
        )
    """)
    conn.commit()
    conn.close()


def save_mapping_reference(
    sku: str, mapping_ref_id: str,
    category_id: str | None = None,
    db_path: str = "ebay_mapping.db",
) -> None:
    """SKUとmappingReferenceIdの対応をDBに保存する。"""
    conn = sqlite3.connect(db_path)
    conn.execute(
        "INSERT OR IGNORE INTO sku_mapping_log"
        " (sku, mapping_ref_id, category_id, created_at)"
        " VALUES (?, ?, ?, ?)",
        (sku, mapping_ref_id, category_id, datetime.utcnow().isoformat()),
    )
    conn.commit()
    conn.close()

このテーブルを持つことで、後日「AI推薦カテゴリで出品したSKUとそうでないSKUの売上CVRを比較する」といった分析が可能になります。mappingReferenceIdはListingPreview1件ごとに発行される個別IDなので、SKUと1対1で紐付けて管理するのが正しい使い方です。

補足: 将来のAPI変更への備え

eBayがInventory APIにmappingReferenceId専用フィールドを追加する可能性もあります。その際は、既存のSQLiteテーブルを参照しながら移行できるため、DBへの永続化は将来の変更にも柔軟に対応できる設計です。

3. COMPLETED_WITH_ERRORの商品を誤って自動出品してしまうリスク

第19回で解説した通り、タスク結果にはCOMPLETED(全成功)とCOMPLETED_WITH_ERROR(一部エラーあり)の2ステータスがあります。COMPLETED_WITH_ERRORのタスクには成功した商品と失敗した商品が混在しています。

よくある実装ミスは、タスクステータスを確認せずlistingPreviewsが空でなければ全件をInventory APIに渡してしまうパターンです。エラーとなった商品のListingPreviewは推奨データが不完全な状態(titleが空・aspectsが0件など)になっている場合があり、品質の低い出品が自動生成されるリスクがあります。

安全策は各ListingPreviewに対して「最低品質チェック」を実施してからAPIを呼ぶことです。チェック項目: title が非空か、aspects が1件以上あるか、imageUrls が1件以上あるか、mappingReferenceId が存在するか。不備があればスキップして警告ログを残し、人手確認に回します。

注意: conditionの列挙値はカテゴリによって異なる

conditionフィールドに渡す値はConditionEnumの文字列(NEW、USED_EXCELLENT等)ですが、有効な値はeBayサイトとカテゴリによって異なります。例えばあるカテゴリではNEWのみ有効で、USED_EXCELLENTを渡すとerrorId: 25013が返ります。

事前にMetadata APIのgetItemConditionPoliciesを呼び出し、対象カテゴリで有効なconditionIDを取得してからConditionEnumへマッピングすることを推奨します。特にeBay JPとeBay USでは同じカテゴリでもサポートされるconditionが異なる場合があります。

頻出エラーコード早見表

createOrReplaceInventoryItemで遭遇しやすいエラーと対処法のまとめです。

# ──────────────────────────────────────────────────
# エラーコード早見表 (createOrReplaceInventoryItem)
# ──────────────────────────────────────────────────

# HTTP 400 | errorId: 25002
# 内容: SKU値が無効(使用禁止文字を含む、または長すぎる)
# 対処: SKUは英数字・ハイフン・アンダースコアのみ。最大50文字に収める。

# HTTP 400 | errorId: 25013
# 内容: conditionの値が対象カテゴリで無効
# 対処: Metadata API getItemConditionPoliciesで
#        カテゴリ別の有効なcondition一覧を事前取得して照合する。

# HTTP 400 | errorId: 25044 / 25045
# 内容: アスペクト名が40文字超 / アスペクト値が50文字超
# 対処: _convert_aspects内でname[:40]・value[:50]に切り詰めるか、
#        超過したアスペクトをスキップしてログに残す。

# HTTP 400 | errorId: 25709
# 内容: カテゴリで必須のItem Specificが欠落している
# 対処: Taxonomy API getItemAspectsForCategoryで必須アスペクトを確認し、
#        Mapping API推奨結果に含まれない場合はデフォルト値で補完する。

# HTTP 401 | errorId: 1001
# 内容: アクセストークンが無効または期限切れ
# 対処: OAuthリフレッシュロジックを実装し、401時に自動リフレッシュ&リトライする。

堅牢な実装:E2Eパイプライン(ポーリング結果取得→変換→Inventory API登録→トレーサビリティ保存)

ここまでの知識を統合した、本番運用に耐えるE2Eパイプラインを示します。型アノテーション・docstring・例外処理・入力バリデーションをすべて含みます。

# inventory_mapping_pipeline.py
# Inventory Mapping API 三部作(#18-#20)の仕上げ実装
import logging
import requests
from dataclasses import dataclass, field
from typing import Any

from sku_mapping_store import init_mapping_store, save_mapping_reference

logger = logging.getLogger(__name__)


@dataclass
class ListingPreviewValidationError(Exception):
    """ListingPreviewの品質チェック失敗を表す例外。"""
    sku: str
    reasons: list[str] = field(default_factory=list)

    def __str__(self) -> str:
        return f"SKU={self.sku} バリデーション失敗: {self.reasons}"


class InventoryMappingPipeline:
    """
    Inventory Mapping APIの推奨結果をInventory APIに投入するE2Eパイプライン。

    Attributes:
        access_token: eBay OAuthアクセストークン。
        default_condition: conditionのデフォルト値。SKUごとに上書き可能。
        default_quantity: 初回在庫数のデフォルト値。
        db_path: mappingReferenceId管理用SQLiteのパス。
    """

    BASE_URL = "https://api.ebay.com/sell/inventory/v1"

    def __init__(
        self,
        access_token: str,
        default_condition: str = "NEW",
        default_quantity: int = 10,
        db_path: str = "ebay_mapping.db",
    ) -> None:
        self.access_token = access_token
        self.default_condition = default_condition
        self.default_quantity = default_quantity
        self.db_path = db_path
        init_mapping_store(db_path)

    # ----------------------------------------------------------------
    # パブリックメソッド
    # ----------------------------------------------------------------

    def process_listing_previews(
        self,
        previews: list[dict[str, Any]],
        condition_map: dict[str, str] | None = None,
        quantity_map: dict[str, int] | None = None,
    ) -> dict[str, list[str]]:
        """
        ListingPreviewのリストを受け取り、一括でInventory APIに登録する。

        Args:
            previews: 第19回ポーリング結果のListingPreview配列。
            condition_map: SKU→conditionの上書き辞書。
            quantity_map: SKU→在庫数の上書き辞書。

        Returns:
            {"succeeded": [sku,...], "failed": [sku,...], "skipped": [sku,...]}
        """
        results: dict[str, list[str]] = {"succeeded": [], "failed": [], "skipped": []}
        for preview in previews:
            sku = preview.get("sku", "UNKNOWN_SKU")
            try:
                self._validate_preview(preview)
                condition = (condition_map or {}).get(sku, self.default_condition)
                quantity = (quantity_map or {}).get(sku, self.default_quantity)
                self._upload_inventory_item(preview, condition, quantity)
                self._save_traceability(preview)
                results["succeeded"].append(sku)
                logger.info("Inventory item created: sku=%s", sku)
            except ListingPreviewValidationError as e:
                logger.warning("品質チェック不合格のためスキップ: %s", e)
                results["skipped"].append(sku)
            except requests.HTTPError as e:
                logger.error("HTTP error sku=%s: %s", sku, e.response.text)
                results["failed"].append(sku)
            except Exception as e:
                logger.exception("予期しないエラー sku=%s: %s", sku, e)
                results["failed"].append(sku)
        return results

    # ----------------------------------------------------------------
    # プライベートメソッド
    # ----------------------------------------------------------------

    def _validate_preview(self, preview: dict[str, Any]) -> None:
        """COMPLETED_WITH_ERROR商品の混入を防ぐ最低品質チェック。"""
        sku = preview.get("sku", "UNKNOWN_SKU")
        reasons: list[str] = []
        if not preview.get("title", "").strip():
            reasons.append("title が空")
        if not preview.get("aspects"):
            reasons.append("aspects が空(Item Specificsなし)")
        if not preview.get("images"):
            reasons.append("images が空(画像URLなし)")
        if not preview.get("mappingReferenceId"):
            reasons.append("mappingReferenceId が欠落")
        if reasons:
            raise ListingPreviewValidationError(sku=sku, reasons=reasons)

    def _convert_aspects(self, gql_aspects: list[dict]) -> dict[str, list[str]]:
        """GraphQL aspects配列 → REST辞書形式。名前40文字・値50文字上限を強制。"""
        result: dict[str, list[str]] = {}
        for aspect in (gql_aspects or []):
            name = (aspect.get("localizedAspectName") or aspect.get("name", "")).strip()
            if not name:
                continue
            if len(name) > 40:
                logger.warning("アスペクト名が40文字超のためスキップ: %r", name)
                continue
            raw_values = aspect.get("value") or aspect.get("values") or []
            if raw_values and isinstance(raw_values[0], dict):
                values = [
                    v.get("localizedValue", "")[:50]
                    for v in raw_values if v.get("localizedValue")
                ]
            else:
                values = [str(v)[:50] for v in raw_values if v]
            if values:
                result[name] = values
        return result

    def _upload_inventory_item(
        self, preview: dict[str, Any], condition: str, quantity: int
    ) -> None:
        """createOrReplaceInventoryItemを呼び出す。"""
        sku = preview["sku"]
        images = preview.get("images", [])
        image_urls = [
            img if isinstance(img, str) else img.get("value", "")
            for img in images if img
        ]
        body = {
            "product": {
                "title": preview.get("title", ""),
                "description": preview.get("description", ""),
                "aspects": self._convert_aspects(preview.get("aspects", [])),
                "imageUrls": image_urls,
            },
            "condition": condition,
            "availability": {
                "shipToLocationAvailability": {"quantity": quantity}
            },
        }
        url = f"{self.BASE_URL}/inventory_item/{sku}"
        headers = {
            "Authorization": f"Bearer {self.access_token}",
            "Content-Type": "application/json",
            "Content-Language": "en-US",
        }
        resp = requests.put(url, json=body, headers=headers, timeout=30)
        resp.raise_for_status()

    def _save_traceability(self, preview: dict[str, Any]) -> None:
        """SKUとmappingReferenceIdの対応をDBに永続化する。"""
        cat = preview.get("category") or {}
        category_id = cat.get("categoryId") or cat.get("id") if isinstance(cat, dict) else None
        save_mapping_reference(
            sku=preview.get("sku", ""),
            mapping_ref_id=preview.get("mappingReferenceId", ""),
            category_id=category_id,
            db_path=self.db_path,
        )


# ====================================================
# 使用例
# ====================================================
if __name__ == "__main__":
    import json
    # 第19回ポーリングスクリプトが出力したJSONを読み込む
    with open("listing_previews.json") as f:
        previews = json.load(f)

    pipeline = InventoryMappingPipeline(
        access_token="v^1.1#i^1#...",  # OAuthトークン
        default_condition="NEW",
        default_quantity=5,
    )
    result = pipeline.process_listing_previews(
        previews=previews,
        condition_map={"SKU-12345": "USED_EXCELLENT"},  # SKUごとの上書き
    )
    print(f"成功: {len(result['succeeded'])}件")
    print(f"スキップ: {len(result['skipped'])}件")
    print(f"失敗: {len(result['failed'])}件")

このパイプラインは3つの責務を明確に分離しています。_validate_preview(品質チェック)、_convert_aspects(データ変換)、_save_traceability(トレーサビリティ)。各メソッドをPytestで個別にユニットテストできるため、CI組み込みも容易です。

timeoutを必ず設定する

requests.put()にtimeout=30を設定しています。大量バッチ処理中にeBay側の応答が遅延した場合、timeoutなしだとスレッドが無限にブロックされます。SandboxとProductionでそれぞれ適切な値を設定してください。

パフォーマンス・スケーリング視点 (深度)

数百〜数千件の商品を定期バッチで処理する場合、1件ずつcreateOrReplaceInventoryItemを呼ぶアーキテクチャではすぐに限界が来ます。eBayが提供する一括投入エンドポイントと再処理キューを組み合わせて対処します。

bulkCreateOrReplaceInventoryItemで最大25件を一括投入する

Inventory APIには POST /sell/inventory/v1/bulk_create_or_replace_inventory_item という一括エンドポイントがあり、1リクエストで最大25件を処理できます。単発PUTと比較してAPI呼び出し回数を最大1/25に削減でき、スループットが大幅に向上します。

# bulk_upload.py — 25件単位のバッチ投入
import requests
from itertools import islice

BULK_URL = "https://api.ebay.com/sell/inventory/v1/bulk_create_or_replace_inventory_item"
CHUNK_SIZE = 25


def _chunk(lst: list, size: int):
    it = iter(lst)
    while chunk := list(islice(it, size)):
        yield chunk


def bulk_create_inventory_items(
    inventory_bodies: list[dict],
    access_token: str,
) -> list[dict]:
    """
    skuキーを含む各SKU分のリクエストボディを25件ずつ一括投入する。

    Returns:
        各チャンクのAPIレスポンスのリスト。
        responses[i]["responses"][j]["statusCode"] で個別の成否を確認すること。
    """
    headers = {
        "Authorization": f"Bearer {access_token}",
        "Content-Type": "application/json",
        "Content-Language": "en-US",
    }
    all_responses = []
    for chunk in _chunk(inventory_bodies, CHUNK_SIZE):
        resp = requests.post(
            BULK_URL, json={"requests": chunk},
            headers=headers, timeout=60,
        )
        resp.raise_for_status()
        all_responses.append(resp.json())
    return all_responses
補足: bulkレスポンスのHTTP 207(Multi-Status)に注意

25件中一部が成功・一部が失敗した場合でも、HTTPレスポンス自体は200系になります。resp.raise_for_status()だけではエラーを検知できないため、responses[i]["responses"][j]["statusCode"]を個別に確認し、204以外(400系等)のエントリを抽出して再処理キューに積む実装が必要です。

失敗SKUの再処理キュー設計

バッチ処理中の部分失敗に対して、「その場でリトライ」よりも「失敗キューに積んで後で処理する」アーキテクチャが堅牢です。以下はJSONLファイルベースの軽量なキュー実装例です。

# retry_queue.py — 失敗SKUをJSONLファイルで管理する
import json
from pathlib import Path
from datetime import datetime

RETRY_QUEUE = Path("retry_queue.jsonl")


def enqueue_failed(sku: str, error_msg: str, body: dict) -> None:
    """失敗したSKUを再処理キューに追記する。"""
    entry = {
        "sku": sku,
        "error": error_msg,
        "body": body,
        "queued_at": datetime.utcnow().isoformat(),
        "retry_count": 0,
    }
    with RETRY_QUEUE.open("a", encoding="utf-8") as f:
        f.write(json.dumps(entry, ensure_ascii=False) + "\n")


def drain_retry_queue(access_token: str, max_retries: int = 3) -> None:
    """キューの失敗SKUを順次再処理し、成功分はキューから削除する。"""
    if not RETRY_QUEUE.exists():
        return
    remaining = []
    for line in RETRY_QUEUE.read_text(encoding="utf-8").splitlines():
        if not line.strip():
            continue
        entry = json.loads(line)
        if entry["retry_count"] >= max_retries:
            # 上限超過 → 人手確認リストに転記(実装省略)
            continue
        # ここで createOrReplaceInventoryItem を再呼び出し(実装省略)
        # 失敗した場合のみ remaining に積む
        entry["retry_count"] += 1
        remaining.append(entry)
    RETRY_QUEUE.write_text(
        "\n".join(json.dumps(e, ensure_ascii=False) for e in remaining),
        encoding="utf-8",
    )

JSONLファイルは行ごとに1件のJSONを格納する形式で、追記が容易かつ部分読み込みが可能なため、ジョブキューの簡易実装に適しています。スループット要件が高まった際はこのキューをAWS SQSやRedisに差し替えるだけで、パイプラインの主要ロジックを変えずにスケールアップできます。

並列処理の観点では、concurrent.futures.ThreadPoolExecutorを使って複数SKUのAPI呼び出しを同時実行することも有効です。ただし、eBay APIには1セラーあたりのレートリミットがあるため、max_workers=5程度から始めて429(Too Many Requests)が出ないか確認しながら調整してください。

まとめ

本記事では、Inventory Mapping APIが出力したAI推奨結果(ListingPreview)をInventory APIのcreateOrReplaceInventoryItemに連携するE2Eパイプラインを実装しました。

  • ベースライン: ListingPreviewのGraphQL aspects配列([{localizedAspectName, value: [...]}])をInventory API REST形式の辞書({"Brand": ["Nike"]})に変換する_convert_aspects関数と、title・description・imageUrlsのシンプルなフィールドマッピングを確立しました。
  • 深いポイント: ①mappingReferenceIdはREST API専用フィールドがないため内部SQLiteでSKUと1対1に永続化するトレーサビリティ設計を採用。②COMPLETED_WITH_ERRORの商品が品質不足のまま出品されるリスクを_validate_previewの事前チェックで排除。③conditionはInventory Mapping APIが推奨しないため外部から注入する設計が必須。
  • スケーリング: bulkCreateOrReplaceInventoryItemで25件単位の一括投入を実現し、部分失敗はJSONLキューで管理して後回し再処理することで、大量カタログにも対応できるアーキテクチャを構築しました。

これでInventory Mapping API三部作(第18-20回)が完結しました。第18回のタスク生成・第19回のポーリングと結果取得・第20回の実出品連携——この3ステップを繋ぐことで、商品メタデータを入力するだけでAIがカテゴリ・アスペクトを決定し、Inventory APIへ自動登録される完全自動化パイプラインが完成しました。

次のステップ

次回(#21)からは新しいAPIカテゴリ「Message API」に入ります。

「Message API入門:getConversationsとgetConversationでバイヤーとのメッセージを取得する」をテーマに、ECオペレーションで欠かせないカスタマーコミュニケーション自動化の第一歩を解説します。お楽しみに!

次の記事はこちら

トップに戻る