AddFixedPriceItemで単一商品(Single-SKU)を安全に出品する

前回の記事はこちら

eBay Trading API:AddFixedPriceItemで単一商品(Single-SKU)を安全に出品する

はじめに

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

これまでの連載で、OAuth 認証基盤、Sandbox 環境、配送などのメタデータ、そして画像(EPS)のアップロード機能が整いました。今回はこれらをすべて結合し、ついに eBay に商品を出品(Listing) します。

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

  • AddFixedPriceItem を用いた固定価格商品(即決)の XML ペイロード構造の理解。
  • ネットワークエラーによる「二重出品」を防ぐ、UUIDの永続化と冪等性(Idempotency)の確保。
  • 実務で致命傷になる XML Injection の防止と、適切なデータ型バリデーション手法。

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

eBay に商品を出品する際、現在は Inventory API(REST)というモダンな選択肢もあります。しかし、越境 EC の実務において、依然として Trading API の AddFixedPriceItem が広く使われているのには理由があります

それは 「圧倒的な即時性と柔軟性」 です。REST API が内部的に非同期処理を多用するのに対し、Trading API はリクエストを送った瞬間に ItemID が発行され、即座にサイトに反映されます。

ただし、AddFixedPriceItem の XML は巨大かつ複雑です。必須項目が1つでも欠ければエラー弾きに遭うため、「どのブロックが何のために必要なのか」をアーキテクチャの視点から理解することが、堅牢な出品システム構築の鍵となります。

基本的な使い方(ベースライン):出品XMLの全体像

AddFixedPriceItem のリクエストは、大きく以下のブロックに分かれています。

  • 基本情報: タイトル、カテゴリ、価格、コンディション、数量。
  • ポリシー情報: 返品・支払い・配送のポリシー(現在主流の Business Policies を使用するか、レガシーな個別指定を行うか)、発送までの日数。
  • 画像情報: 前回取得した EPS の URL。
  • 商品詳細設定 (Item Specifics): ブランドやサイズなどの必須スペック情報。

これらを愚直に XML で組むと以下のようになります(一部省略)。

<?xml version="1.0" encoding="utf-8"?>
<AddFixedPriceItemRequest xmlns="urn:ebay:apis:eBLBaseComponents">
    <ErrorLanguage>en_US</ErrorLanguage>
    <WarningLevel>High</WarningLevel>
    <Item>
        <Title>Sample Product Title</Title>
        <Description><![CDATA[Detailed item description goes here.]]></Description>
        <PrimaryCategory>
            <CategoryID>12345</CategoryID>
        </PrimaryCategory>
        <StartPrice currencyID="USD">99.99</StartPrice>
        <ConditionID>1000</ConditionID>
        <Country>JP</Country>
        <Currency>USD</Currency>
        <DispatchTimeMax>3</DispatchTimeMax>
        <ListingDuration>GTC</ListingDuration>
    </Item>
</AddFixedPriceItemRequest>

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

ここからは、実稼働するシステムを組む上で絶対に避けては通れない、実務レベルの落とし穴とその解決策を解説します。

1. 二重出品を防ぐ魔法のキー「UUID」とその「永続化」

出品 API は処理が重いため、eBay 側のサーバー都合やネットワークの瞬断でタイムアウトが発生することがあります。この時、プログラム側で「失敗した」と判定してリトライ(再送)をかけると、実は eBay 側では最初の処理が成功しており、同じ商品が二重に出品されてしまう 悲劇が起こります。

これを防ぐのが <UUID> タグです。しかし、「リクエストの直前で UUID を生成する」コードは絶対に書いてはいけません。 その直後にクラッシュした場合、UUID はメモリから消失し、再送時に新しい UUID が生成されて冪等性が失われるからです。

【ベストプラクティス】:
API をコールする前に、必ず UUID(32桁の16進数)を生成し、自社のデータベース(DB)に出品予定データと一緒に 「保存(永続化)」 してください。リトライ時は常にその DB の UUID を読み込んで送信します。

2. XML Injection 防止と数値バリデーションの使い分け

Title の値に &< が含まれている(例: "Canon AE-1 & AE-1P")と、そのまま f-string で埋め込んだ瞬間に XML が壊れます。プレーンテキストは html.escape() で必ずエスケープし、HTML タグを含む Description は <![CDATA[ ... ]]> で囲む必要があります。

一方で、価格や数量といった数値フィールドに対してエスケープ処理を行うのはアンチパターンです(フォーマットエラーの原因になります)。数値フィールドや各種プロファイル ID は事前に Python 側で厳格な型チェック(バリデーション)を行うのが正解です。

3. Business Policies (ビジネスポリシー) の必須化

多くのアカウントでは現在、支払い・返品・発送の設定をまとめた Business Policies の利用が強制されています。これらが強制されているアカウントで古い形式の <ReturnPolicy> などを送ると、Error 21919187 で弾かれます。代わりに <SellerProfiles> を使用します。

【メモ】: 自分のアカウントが Business Policies 有効か確認するには?
eBay の Web サイト(Seller Hub)から確認するか、GetUser API を叩くことで判定可能です。現在新規作成されたセラーアカウントの多くはデフォルトで有効化されています。

堅牢な実装:UUID と エスケープ処理を組み込んだ出品スクリプト

上記の実務的な課題をすべてクリアした安全な出品関数を実装します。

# add_fixed_price_item.py
import requests
import xml.etree.ElementTree as ET
import uuid
import html
from config import eBayConfig
from ebay_token_manager import eBayTokenManager

# 【注意】: idempotency_key (UUID) は必ずAPI呼び出し前にDBへ保存済みのものを渡してください。
# Noneを渡すとクラッシュ時に冪等性が失われます(テスト用途のみ許容)。
def list_single_item(config: eBayConfig, token: str, item_data: dict, eps_urls: list, idempotency_key: str = None) -> str:
    """
    堅牢な単一商品出品処理。
    """
    if idempotency_key is None:
        idempotency_key = uuid.uuid4().hex

    # 【ポイント①】: 数値フィールドとIDの事前バリデーション(エスケープの代わり)
    try:
        price = float(item_data['price'])
        quantity = int(item_data['quantity'])
        category_id = int(item_data['category_id'])
        condition_id = int(item_data['condition_id'])
        dispatch_time = int(item_data.get('dispatch_time', 3))
        shipping_profile_id = int(item_data['shipping_profile_id'])
        return_profile_id = int(item_data['return_profile_id'])
        payment_profile_id = int(item_data['payment_profile_id'])
    except (ValueError, TypeError, KeyError) as e:
        raise ValueError(f"Invalid or missing data in item_data: {e}")

    headers = {
        "X-EBAY-API-CALL-NAME": "AddFixedPriceItem",
        "X-EBAY-API-SITEID": "0", 
        "X-EBAY-API-COMPATIBILITY-LEVEL": "1323",
        "X-EBAY-API-IAF-TOKEN": token,
        "Content-Type": "text/xml"
    }
    
    # 画像ブロックの動的生成
    picture_tags = "".join([f"<PictureURL>{html.escape(url)}</PictureURL>" for url in eps_urls])
    
    # 【ポイント②】: テキストフィールドの XML Injection 防止 (html.escape)
    specifics_tags = ""
    for name, value in item_data.get("item_specifics", {}).items():
        specifics_tags += f"""
        <NameValueList>
            <Name>{html.escape(str(name))}</Name>
            <Value>{html.escape(str(value))}</Value>
        </NameValueList>
        """

    # XML ペイロードの組み立て
    xml_payload = f"""<?xml version="1.0" encoding="utf-8"?>
    <AddFixedPriceItemRequest xmlns="urn:ebay:apis:eBLBaseComponents">
        <ErrorLanguage>en_US</ErrorLanguage>
        <WarningLevel>High</WarningLevel>
        <Item>
            <Title>{html.escape(item_data['title'])}</Title>
            <Description><![CDATA[{item_data['description']}]]></Description>
            <PrimaryCategory>
                <CategoryID>{category_id}</CategoryID>
            </PrimaryCategory>
            <StartPrice currencyID="USD">{price}</StartPrice>
            <Quantity>{quantity}</Quantity>
            <ConditionID>{condition_id}</ConditionID>
            <Country>JP</Country>
            <Currency>USD</Currency>
            <DispatchTimeMax>{dispatch_time}</DispatchTimeMax>
            <ListingDuration>GTC</ListingDuration>
            
            <UUID>{idempotency_key}</UUID>

            <PictureDetails>
                {picture_tags}
            </PictureDetails>

            <ItemSpecifics>
                {specifics_tags}
            </ItemSpecifics>
            
            <SellerProfiles>
                <SellerShippingProfile>
                    <ShippingProfileID>{shipping_profile_id}</ShippingProfileID>
                </SellerShippingProfile>
                <SellerReturnProfile>
                    <ReturnProfileID>{return_profile_id}</ReturnProfileID>
                </SellerReturnProfile>
                <SellerPaymentProfile>
                    <PaymentProfileID>{payment_profile_id}</PaymentProfileID>
                </SellerPaymentProfile>
            </SellerProfiles>
        </Item>
    </AddFixedPriceItemRequest>
    """

    # 【ポイント③】: 無限待機を防ぐため timeout を明示。タイムアウト時はDBのUUIDで安全にリトライする
    response = requests.post(
        config.trading_api_url, 
        headers=headers, 
        data=xml_payload.encode('utf-8'),
        timeout=30
    )
    response.raise_for_status()

    # レスポンス解析
    namespace = {'ns': 'urn:ebay:apis:eBLBaseComponents'}
    root = ET.fromstring(response.text)
    
    ack_node = root.find('ns:Ack', namespace)
    ack = ack_node.text if ack_node is not None else ""
    
    # 【ポイント④】: Warningログの記録と、複数エラーの網羅的キャッチ
    if ack == 'Warning':
        warnings = [
            e.find('ns:LongMessage', namespace).text if e.find('ns:LongMessage', namespace) is not None else ""
            for e in root.findall('ns:Errors', namespace)
            if e.find('ns:SeverityCode', namespace) is not None and e.find('ns:SeverityCode', namespace).text == 'Warning'
        ]
        print(f"[WARNING] Listing succeeded with warnings: {warnings}") # 本番ではloggingモジュールを使用
        
    if ack not in ['Success', 'Warning']:
        error_nodes = root.findall('ns:Errors', namespace)
        errors = [
            {
                "code": e.find('ns:ErrorCode', namespace).text if e.find('ns:ErrorCode', namespace) is not None else "",
                "severity": e.find('ns:SeverityCode', namespace).text if e.find('ns:SeverityCode', namespace) is not None else "",
                "message": e.find('ns:LongMessage', namespace).text if e.find('ns:LongMessage', namespace) is not None else "",
            }
            for e in error_nodes
            if e.find('ns:SeverityCode', namespace) is not None and e.find('ns:SeverityCode', namespace).text == 'Error'
        ]
        if errors:
            raise Exception(f"Listing Failed with {len(errors)} errors: {errors}")

    # 成功時の ItemID を抽出
    item_id_node = root.find('ns:ItemID', namespace)
    if item_id_node is None:
        raise Exception("Success returned but ItemID is missing.")
        
    return item_id_node.text

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

実務において、毎月数千〜数万件の出品を行う場合、f-string での巨大な XML 構築はメンテナンス性が著しく低下します。

【テンプレートエンジン(Jinja2)の導入】

スケーリングの第一歩として、XML の雛形を外部ファイルに切り離し、Jinja2 を使って Python コードから変数を流し込むアーキテクチャに移行することを強く推奨します。autoescape=True を使えば、XML Injection の心配も無くなります。

<Title>{{ title | e }}</Title>
<Description><![CDATA[{{ description | safe }}]]></Description>
<UUID>{{ idempotency_key }}</UUID>
【セキュリティ注意】: safe フィルタは CDATA ブロック内でのみ使用してください。autoescape の保護を無効化するため、プレーンテキストのフィールド(Title等)に使用すると XML Injection の脆弱性を生みます。
# Python側の呼び出し例
from jinja2 import Environment, FileSystemLoader

env = Environment(loader=FileSystemLoader('templates'), autoescape=True)
template = env.get_template('listing.xml.j2')

# item_data辞書を展開してXMLを生成
xml_payload = template.render(**item_data, idempotency_key=db_saved_uuid)

これにより、「新しいポリシー ID に一斉に変更したい」「説明文の HTML デザインを一新したい」といったビジネス要求に対し、Python のロジックに触れることなく、テンプレートファイルの修正だけで安全に対応できるようになります。

まとめ

本記事では、eBay 開発における第一の到達点である「商品の出品」を実装しました。

  • ベースライン: AddFixedPriceItem で要求される複雑な XML ペイロードの基本構造。
  • 深いポイント: DB永続化を前提とした UUID と timeout による冪等性の確保。XML Injection 対策と複数エラー/警告のハンドリング。
  • スケーリング: f-string から Jinja2 テンプレートエンジン移行への具体的なアプローチ。

これであなたは、プログラム経由で eBay に安全かつ堅牢に商品カタログを展開できるようになりました。

次のステップ

単一商品の出品に成功したら、次なる壁はアパレルや靴などで必須となる 「バリエーション出品 (Multi-SKU)」 です。

次回(#6)は、親商品(Parent)と子商品(Child/Variation)を一つの AddFixedPriceItem リクエストにまとめ、サイズや色ごとに異なる在庫と価格を管理する高度な XML の構築方法を解説します。お楽しみに!

技術的なサポートやご質問について

APIの実装や仕様に関してご不明な点がございましたら、以下のeBay Japan 技術サポート窓口までお気軽にお問い合わせください:
ebayjapan-techsupport@ebay.com

トップに戻る