EndItemで出品を終了する / 複数商品の一括取り下げ(bulk)自動化

前回の記事はこちら

【連載#10】eBay Trading API:EndItemで出品を終了する / 複数商品の一括取り下げ(bulk)自動化

はじめに

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

前回(#9)は、eBay 上のアクティブな出品一覧を Pull してローカル DB と同期する処理を構築しました。今回は、商品のライフサイクルの最終ステージである「出品の取り下げ(早期終了:Early Termination)」を扱います。

2026年現在、eBay は従来の Trading API から新世代の REST API への移行を急速に推し進めています(直近でも 6 月の GetCategoryFeatures の廃止、9 月の UploadSiteHostedPictures の廃止などが控えています)。本記事では、現行の Trading API を用いた堅牢な一括下架システムの実装方法を解説するとともに、将来のシステム刷新を見据えた REST API への移行パスについても明示します。

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

  • EndItem API を用いた出品終了処理の基本構造と、アカウントを守る理由の選択。
  • プラットフォームのポリシー変更(ファッションカテゴリのサイズ規則変更など)に伴う緊急下架シナリオへの対応。
  • 途中でクラッシュしても再開できる「断点回復(レジューム機能)」と「ファイルロギング」を備えた、本番環境仕様の一括下架スクリプト。

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

EC 運用において、商品ページを即座に削除・取り下げなければならない局面は突発的に発生します。

  • 自社倉庫や併売先で商品の破損・紛失が発覚した。
  • 知的財産権保護(VeRO プログラム)の警告を受け、即時下架が必要になった。

さらに直近の具体例として、「プラットフォーム側の急なポリシー変更による被動的下架」 も挙げられます。例えば、ファッションカテゴリにおける「サイズ表記のグローバルロック(世界統一規格化)」のような大変革が起きる際、旧来の非標準サイズで出品されていた大量の Listing は、システム上「修正(Revise)」を受け付けなくなります。このような場合、セラーツールは「対象商品を一括で EndItem(終了)させ、標準規格に準拠したデータで新規に出品し直す」という緊急バッチ処理を走らせる必要があります。

手動で数十~数百件の対応をしていては手遅れになります。自動化された強固な下架パイプラインを構築しておくことは、アカウントの健全性を死守するための強力な防衛策です。

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

Trading API の EndItem は 1 リクエストにつき 1 商品しか処理できません。最もシンプルなリクエスト形式は以下の通りです。

<?xml version="1.0" encoding="utf-8"?>
<EndItemRequest xmlns="urn:ebay:apis:eBLBaseComponents">
    <ErrorLanguage>en_US</ErrorLanguage>
    <WarningLevel>High</WarningLevel>
    <ItemID>110022334455</ItemID>
    <EndingReason>Incorrect</EndingReason>
</EndItemRequest>

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

1. EndingReason(終了理由)の選択ミスによるアカウント降格の罠

EndItem のリクエストには、取り下げの理由を示す <EndingReason> が必須です。

  • Incorrect(出品内容の誤り)
  • LostOrBroken(商品の紛失・破損)
  • OtherListingError(その他のエラー)

ここで LostOrBroken を不用意に選択してはいけません。 公式ドキュメントには明記されていませんが、eBay の内部アルゴリズムは LostOrBroken による早期終了が頻発するアカウントを「在庫管理能力が著しく低いセラー」と判定します。これが累積すると、Best Match(検索結果)の表示順位が落とされる(SEOペナルティ)実害が発生します。

実務上、社内システムやタイムアウト起因の取り下げであれば、原則として Incorrect または OtherListingError を選択するのが安全です。

2. 特異なエッジケース:アクティブな取引(注文)がある場合の挙動

バイヤーが購入手続きを完了したが、未発送の状態や、支払い保留(ON_HOLD)、あるいは未着トラブル(INR)の保護期間内にある商品(ItemID)を EndItem で終了させた場合、システム的にどうなるでしょうか?

  • 注文データは消失しない: 出品を終了させても、既存の注文履歴や進行中のトランザクションは消えません。セラーは引き続き GetOrders 等でデータを取得し、発送処理や返金、ディスピュート(紛失・未着手続き)に対応する義務があります。
  • 新たな購入のブロック: あくまで「これ以上の新規購入・入札」を即座に防ぐ処理として機能します。

3. EndItem と「在庫数 0 更新(OutOfStockControl)」の境界線

  • EndItem: その ItemID は完全に終了し、蓄積された「販売履歴(Sales History)」やウォッチ数は消滅します。廃盤商品や VeRO 警告など、二度と同じページを使わない場合のみ使用します。
  • 在庫数 0 更新: 出品状態(SEOパワー)を維持したまま検索結果から一時的に非表示にします。再入荷の可能性がある場合は必ずこちら(第8回参照)を選択してください。

堅牢な実装:断点回復とログ保存を備えた一括下架エンジン

Trading API の EndItem は 1 リクエストにつき 1 商品しか処理できません。数百件をマルチスレッドで高速に処理しつつ、途中でシステムがクラッシュしても「どこまで処理したか」を記憶して再開できる(断点回復)本番仕様のスクリプトを実装します。

# bulk_end_items.py
import requests
import xml.etree.ElementTree as ET
import time
import random
import threading
import logging
import json
import os
import concurrent.futures
from typing import List, Dict, Any
from config import eBayConfig
from ebay_token_manager import eBayTokenManager

# 深いポイント①: 監査追跡のため、ログを標準出力ではなくファイルへ永続化
logging.basicConfig(
    level=logging.INFO,
    format='%(asctime)s [%(levelname)s] %(message)s',
    handlers=[
        logging.FileHandler("bulk_end_operations.log", encoding="utf-8"),
        logging.StreamHandler()
    ]
)

PROGRESS_FILE = "bulk_end_progress.json"

def _get_text(node: ET.Element, tag: str, ns: dict) -> str:
    if node is None: return ""
    el = node.find(tag, ns)
    return el.text if el is not None and el.text is not None else ""

def load_progress() -> Dict[str, str]:
    """断点回復用:過去の成功/失敗ステータスをロード"""
    if os.path.exists(PROGRESS_FILE):
        with open(PROGRESS_FILE, 'r', encoding='utf-8') as f:
            return json.load(f)
    return {}

def save_progress(item_id: str, status: str):
    """断点回復用:処理結果を即座にファイルへ同期保存
    
    スケーリング注意点:
    数千件規模にスケールする場合、スレッドごとに毎回全量JSONファイルを読み書きすると
    深刻なI/Oボトルネックになります。大量データを扱う実稼働環境では、
    SQLiteやMySQL等の外部DBへの個別レコード書き込み(UPDATE文)に置き換えてください。
    """
    progress = load_progress()
    progress[item_id] = status
    with open(PROGRESS_FILE, 'w', encoding='utf-8') as f:
        json.dump(progress, f, ensure_ascii=False, indent=2)

def api_call_with_backoff(fn, max_retries=3):
    for attempt in range(max_retries):
        try:
            return fn()
        except requests.exceptions.HTTPError as e:
            if e.response is not None and e.response.status_code == 429:
                wait = (2 ** attempt) + random.uniform(0, 1)
                logging.warning(f"Rate limited (429). Retrying in {wait:.2f}s...")
                time.sleep(wait)
            else:
                raise
    raise Exception("Max retries exceeded after HTTP errors")

def end_single_item(config: eBayConfig, token: str, item_id: str, reason: str = "Incorrect") -> str:
    headers = {
        "X-EBAY-API-CALL-NAME": "EndItem",
        "X-EBAY-API-SITEID": "0",
        "X-EBAY-API-COMPATIBILITY-LEVEL": "1323",
        "X-EBAY-API-IAF-TOKEN": token,
        "Content-Type": "text/xml"
    }

    xml_payload = f"""<?xml version="1.0" encoding="utf-8"?>
    <EndItemRequest xmlns="urn:ebay:apis:eBLBaseComponents">
        <ErrorLanguage>en_US</ErrorLanguage>
        <WarningLevel>High</WarningLevel>
        <ItemID>{item_id}</ItemID>
        <EndingReason>{reason}</EndingReason>
    </EndItemRequest>
    """

    def execute():
        res = requests.post(config.trading_api_url, headers=headers, data=xml_payload.encode('utf-8'), timeout=30)
        res.raise_for_status()
        return res

    response = api_call_with_backoff(execute)
    namespace = {'ns': 'urn:ebay:apis:eBLBaseComponents'}
    root = ET.fromstring(response.text)

    ack = _get_text(root, 'ns:Ack', namespace)
    if ack not in ['Success', 'Warning']:
        errors = [{"code": _get_text(e, 'ns:ErrorCode', namespace), "msg": _get_text(e, 'ns:LongMessage', namespace)} 
                  for e in root.findall('ns:Errors', namespace)]
        raise Exception(f"EndItem Failed: {errors}")

    return _get_text(root, 'ns:EndTime', namespace)

def run_concurrent_bulk_end(config: eBayConfig, manager: eBayTokenManager, item_list: List[str], reason: str = "Incorrect"):
    # 防御的プログラミング: メモリ快照の不整合や二重Endを防ぐため、順序を維持したまま重複を除去
    item_list = list(dict.fromkeys(item_list))
    
    logging.info(f"[System] Starting concurrent bulk end for {len(item_list)} items...")
    
    # 拡張性の注意: 同時実行数(Semaphore)と最小ディレイ(sleep)は、eBayアカウントのTier(登録クラス)により
    # 割り当てられる日次上限「Call Limits」が異なるため、本番環境ではトラフィック制限に応じて数値を増減させてください。
    semaphore = threading.Semaphore(5)  
    progress_map = load_progress()
    stats_lock = threading.Lock()

    def _worker_concurrent(item_id: str):
        # 深いポイント②: 断点回復チェック(既に過去の実行で成功している場合は二重Endを防ぐためスキップ)
        if progress_map.get(item_id) == "SUCCESS":
            logging.info(f"[Skipped] (Already Ended in previous run): {item_id}")
            return

        with semaphore:
            time.sleep(0.1)  # スパイク電流的な過負荷を防ぐためのインターバル
            try:
                token = manager.get_token()
                end_time = end_single_item(config, token, item_id, reason)
                logging.info(f"[Success]: {item_id} at {end_time}")
                with stats_lock:
                    save_progress(item_id, "SUCCESS")
            except Exception as e:
                logging.error(f"[Failed] to end {item_id}: {e}")
                with stats_lock:
                    save_progress(item_id, f"FAILED: {str(e)}")

    with concurrent.futures.ThreadPoolExecutor(max_workers=5) as executor:
        futures = [executor.submit(_worker_concurrent, item_id) for item_id in item_list]
        concurrent.futures.wait(futures)

    logging.info("--- Bulk End Process Complete ---")

if __name__ == "__main__":
    config = eBayConfig()
    manager = eBayTokenManager(config.client_id, config.client_secret, config.refresh_token, config.env)
    
    # テスト対象のItemIDリスト
    items_to_end = ["110022334455", "110022334456"]
    run_concurrent_bulk_end(config, manager, items_to_end, reason="Incorrect")

構造的リスクの回避:最新 REST API への移行パス

本記事で紹介した Trading API の EndItem(XML 形式)は、eBay が段階的に廃止を進めているレガシーな通信プロトコルです。将来にわたり安定したシステムを運用するためには、最新の REST API(Inventory API) へのリプレイスを視野に入れる必要があります。

新規開発時やシステム刷新時は、以下の REST エンドポイントへの移行設計を行ってください。

対応する REST API メソッド:
DASHBOARD / INVENTORY 領域の Inventory API > withdrawOffer

REST でのエンドポイント (POST):
https://api.ebay.com/sell/inventory/v1/offer/{offerId}/withdraw

アーキテクチャの違い:
Trading API では「商品(ItemID)」そのものを直接終了させますが、REST API(Inventory API)では、商品情報マスタ(Inventory Item)から市場へ公開している売り枠(Offer)を「取り下げる(withdraw)」という洗練されたリソース設計に変わっています。

重要な前提条件と注意点:
上記の withdrawOffer は、最新の Inventory API 経由で作成された出品(Offer)にのみ適用可能です。本連載の第5回・第6回で解説した Trading API(AddItem 等)で登録済みのレガシー出品には OfferID が存在しないため、直接この REST API を呼び出すことはできません。既存の古い出品を REST 管理へと移行させるには、別途マイグレーション手順(既存出品の Inventory API モデルへのコンバート)が必要です。これについては後続回で詳しく取り上げます。

まとめ

本記事では、商品の販売を安全かつ迅速に終了させるための EndItem API について解説しました。

  • ベースライン: ItemID と EndingReason を組み合わせた下架処理の標準プロトコル。
  • 深いポイント: アカウントの SEO ペナルティを回避する EndingReason の選択。GTC(長期無期限出品)商品における規約変更時の緊急下架シナリオの想定。
  • 堅牢化実装: 障害時に未処理データから安全に再開できる JSON 進行状況保存(断点回復)と、監査可能なファイルロギング。
  • 移行パス: 将来の完全 REST 化を見据えた Inventory API(withdrawOffer)への設計アプローチと制約。

これで、商品の「出品」「更新」「取得」「下架」という、商品管理サイクル(CRUD)の全 API パズルが完成しました。

次のステップ

システムが自動で出品のライフサイクルを回せるようになると、次に必要になるのが「その出品データが本当に eBay の推奨する品質を満たしているか?」の自動監査です。

次回(#11)は、「GetItem / GetItemsで商品詳細を取得してデータ品質チェックツールを作る」 について解説します。単一・複数商品のデータを一括で取得し、タイトルの文字数や画像の枚数、Item Specifics の充足率を自動判定する QA スクリプトの組み方を深掘りします。お楽しみに!

トップに戻る