GetMemberMessagesでバイヤーへの自動返信を実装する
前回の記事はこちら
【連載#14】eBay Trading API:GetMemberMessages / AddMemberMessageAAQToPartnerでバイヤーへの自動返信を実装する
はじめに
本記事は、全42回にわたる「eBay API 実践ガイド」の第14回です。
前回(#13)は CompleteSale API を使い、発送済みマークと追跡番号をプログラムから一括登録する方法を解説しました。荷物が旅立ったその瞬間から、今度はバイヤーからのメッセージが届き始めます。「荷物はどこ?」「これ返品できる?」「このアイテムの使い方は?」――CS(カスタマーサポート)対応は、越境 EC における最も労働集約的な業務の一つです。
本記事では、Trading API の GetMemberMessages と AddMemberMessageAAQToPartner を組み合わせ、未返信メッセージを自動取得・分類し、テンプレートで返信する「CSボット」を Python で実装します。
この記事で得られること:
- GetMemberMessages のパラメータ体系と、未返信メッセージのみを効率的に絞り込むフィルタリング手法。
- AddMemberMessageAAQToPartner の必須三点セット(ItemID・RecipientID・ParentMessageID)と、その一つでも欠けると発生するエラーの回避策。
- 日英混在メッセージを「発送・返品・質問」に分類し、テンプレートで自動返信するキーワードベースの分類ロジックの実装と、その限界・注意事項。
背景・なぜこれが重要か (Motivation)
「メッセージなんて、受け取ったら手動でその都度返せばいい。」
これは初学者が抱く最も自然な感想ですが、eBay のビジネスルールとセラー評価制度を知ると、その認識がいかに危険かがわかります。
eBay は各セラーに「Seller Level」を付与し、その評価指標の一つに Response Rate(返信率)と Response Time(返信時間)があります。eBay 公式の基準によれば、バイヤーからの問い合わせに 24時間以内に返信できなかった場合、Response Rate がカウントされます。この数字が一定水準を下回ると、出品が Best Match(eBay の検索ランキングアルゴリズム)で不利な扱いを受け、さらには「Below Standard」セラーへと格下げされるリスクがあります。
出品数が数十件のうちは手動対応でも回せます。しかし SKU 数が数百・数千件に増え、時差のある海外バイヤーから深夜にメッセージが届くようになると、手動対応は物理的に破綻します。API による自動化は、スケールする越境 EC ビジネスにとって避けられないステップです。
また、自動返信は「とりあえず受け取ったことを知らせる」だけでも大きな効果があります。バイヤーは返信があると安心し、ネガティブフィードバック(Negative Feedback)を残す確率が著しく下がります。CS 品質の向上は、Defect Rate(欠陥率)の改善にも直結します。
基本的な使い方(ベースライン):GetMemberMessagesで未返信メッセージを取得する
まず zeep を使った最小限の実装で、直近 24 時間の未返信 ASQ メッセージ(Ask Seller Question: 商品ページの「Contact seller」から届くメッセージ)を取得してみます。
# cs_bot_baseline.py import zeep from datetime import datetime, timedelta, timezone EBAY_WSDL = "https://developer.ebay.com/webservices/latest/eBaySvc.wsdl" def get_member_messages(user_token: str) -> dict: """未返信のバイヤーメッセージを取得する(直近24時間分)。""" transport = zeep.Transport() client = zeep.Client(wsdl=EBAY_WSDL, transport=transport) now = datetime.now(timezone.utc) start = now - timedelta(hours=24) response = client.service.GetMemberMessages( _soapheaders={ 'RequesterCredentials': {'eBayAuthToken': user_token} }, ErrorLanguage='ja_JP', WarningLevel='High', MailMessageType='AskSellerQuestion', MessageStatus='Unanswered', StartCreationTime=start.strftime('%Y-%m-%dT%H:%M:%S.000Z'), EndCreationTime=now.strftime('%Y-%m-%dT%H:%M:%S.000Z'), Pagination={'EntriesPerPage': 25, 'PageNumber': 1}, ) return response # --- 動作確認 --- if __name__ == "__main__": import os TOKEN = os.environ["EBAY_USER_TOKEN"] resp = get_member_messages(TOKEN) print(f"Ack: {resp.Ack}") exchanges = ( getattr(resp.MemberMessage, "MemberMessageExchange", []) or [] ) print(f"未返信メッセージ数: {len(exchanges)}") for ex in exchanges: q = ex.Question print(f" MessageID={q.MessageID}, Sender={q.SenderID}") print(f" QuestionType={q.QuestionType}") print(f" Subject: {q.Subject}")
MailMessageType には AskSellerQuestion(ASQ)、All(全種)などを指定できます。本記事では ASQ のみを対象とします。AllMessages(セラーが送信したメッセージを含む全スレッド)と混同しないよう注意してください。
MessageStatus には Unanswered(未返信)と Answered(返信済み)の2値を指定できます。Unanswered を指定することで、返信が必要なメッセージだけを効率的に取得できます。
レスポンスの MemberMessageExchange は「1往復の会話スレッド」を表すオブジェクトです。その中の Question がバイヤーからの問い合わせ本体であり、MessageID、SenderID、Body、ItemID(商品ページから問い合わせた場合)などが含まれます。QuestionType フィールドには General(一般的な質問)、Shipping(配送関連)、CustomizedOrder(カスタム注文)などが自動付与されることがありますが、常にセットされるとは限りません。そのため、本記事では本文のキーワード分類を主な判定ロジックとして採用します。
実務で躓く場面・深いポイント (Core)
GetMemberMessages で取得まではできた。では返信 API を呼ぶ――ここで多くのエンジニアが数時間を溶かします。AddMemberMessageAAQToPartner は一見シンプルに見えて、非常に厳格なパラメータ検証を行います。
1. AddMemberMessageAAQToPartner の「必須三点セット」
AddMemberMessageAAQToPartner の呼び出しには、以下の3つのパラメータが必ず揃っていなければなりません。
- ItemID: 元の問い合わせが紐付いている商品の ItemID。GetMemberMessages の MemberMessageExchange.Item.ItemID から取得します。商品ページを経由しない一般的なメッセージ(たとえばセラーページの Contact ボタンから来たもの)は ItemID が空の場合があり、その場合は AddMemberMessageAAQToPartner ではなく AddMemberMessageRTQ を使う必要があります。
- MemberMessage.RecipientID: 返信先のユーザー ID(Question.SenderID)。eBay アカウントの UserID を文字列で指定します。大文字小文字が一致しないと Error 21915815 が発生します。
- MemberMessage.ParentMessageID: 元の問い合わせの MessageID。返信がどのスレッドに属するかを eBay が紐付けるためのキーです。これを誤ると、全く別の会話に返信が付く、あるいは Error 21915814 で弾かれます。
最も踏みやすい落とし穴は、GetMemberMessages の結果から ItemID を取得する際に None チェックを怠ることです。Item オブジェクト自体が None の場合があり、そのまま .ItemID を参照すると AttributeError がスローされます。必ず以下のように安全にアクセスしてください。
# 安全な ItemID 取得パターン item_id = getattr(getattr(exchange, "Item", None), "ItemID", None) if not item_id: # ItemID がない = RTQ または一般メッセージ → 手動対応キューへ logger.warning("ItemID なし: message_id=%s", message_id) continue
2. 日英混在メッセージのキーワード分類の落とし穴
グローバルな eBay では、日本のバイヤーが日本語で書いてきたり、英語で書いてきたりします。さらに「トラッキングナンバーはいつ届く?」のように日英混在のメッセージも珍しくありません。
キーワードリストを英語だけで組むと、日本語メッセージを UNKNOWN(分類不能)と判定してしまい、手動対応キューが溢れます。逆に日本語キーワードだけを使うと英語バイヤーへの対応が漏れます。実務では、分類ロジックに両言語のキーワードセットを含めることが必須です。
また、単純な部分文字列マッチングでは誤判定が起きやすい点にも注意が必要です。たとえば「return address(返送先住所)」は配送関連であって返品関連ではありませんが、"return" というキーワードに引っかかります。ビジネスの規模が大きくなったら、形態素解析(fugashi / MeCab)や軽量な分類モデル(scikit-learn の TF-IDF + ロジスティック回帰など)への移行を検討してください。
3. 自動返信してはいけない場面と重複送信ループ防止
- クレーム・不正申告(SNAD: Significantly Not As Described / Item Not Received): これらは eBay Money Back Guarantee(eBay バイヤー保護)の対象ケースです。機械的な返信テンプレートはバイヤーの怒りを増幅させます。必ず人間が対応してください。QuestionType が "INR" や "SNAD" の場合は即座に手動対応フラグを立てます。
- 詐欺疑いメッセージ(フィッシング・外部決済誘導): "PayPal only", "pay outside eBay" などの文言が含まれるメッセージへの自動返信は、詐欺行為への加担とみなされるリスクがあります。
- 連続自動返信ループ: バイヤーが自動返信に再返信し、再度 Unanswered として取得されてしまう場合、ループが発生します。ParentMessageID で既に自分が返信したスレッドを追跡し、重複返信を防ぐ制御が必要です。実装例として、返信済み MessageID を Redis や DB に保存し、run_once() の冒頭でチェックするパターンが一般的です。
頻出エラーコード早見表
| エラーコード | 説明と対処 |
|---|---|
Error 21915814
|
ParentMessageID に対応するメッセージが存在しない、または既に別のメッセージが返信として紐付き済み。MessageID の取得元(GetMemberMessages の最新呼び出し)が古くなっていないか確認すること。 |
Error 21915815
|
RecipientID に指定した UserID が eBay に存在しない、または大文字小文字が一致していない。SenderID をそのまま流用し、独自加工(toLowerCase 等)をかけないこと。 |
Error 21915414
|
ItemID が eBay に存在しない、または出品が終了・削除されている。商品ページから来た問い合わせでも、出品が終了後に届いたメッセージは ItemID が無効化されていることがある。このエラーは自動返信をスキップして手動対応キューへ回す。 |
Error 55012
|
OAuth トークンの有効期限切れ、またはスコープ不足。eBay Trading API は User Token(Require Token)スコープが必須。Application Token のみでは GetMemberMessages・AddMemberMessageAAQToPartner は呼べない。トークンのリフレッシュ処理を実装すること。 |
堅牢な実装:キーワード分類とテンプレート自動返信 CSボット
上記の実務的な課題をすべて踏まえた、型アノテーション・docstring・例外処理つきの完全実装を示します。未返信メッセージの取得、キーワードによる3カテゴリ分類、テンプレート返信の一連のフローを CSBot クラスにまとめます。
# cs_bot_production.py import zeep import logging from datetime import datetime, timedelta, timezone from dataclasses import dataclass from typing import Optional from enum import Enum, auto logger = logging.getLogger(__name__) class MessageCategory(Enum): """メッセージの分類カテゴリ。""" SHIPPING = auto() # 発送・配送関連 RETURN = auto() # 返品・返金関連 QUESTION = auto() # 商品に関する一般質問 UNKNOWN = auto() # 分類不能 -> 手動対応キューへ KEYWORDS: dict[MessageCategory, list[str]] = { MessageCategory.SHIPPING: [ 'ship', 'tracking', 'deliver', 'delivery', 'dispatch', 'sent', 'transit', '発送', '追跡', '配送', '届', '送り', 'トラッキング', '輸送', ], MessageCategory.RETURN: [ 'return', 'refund', 'broken', 'damaged', 'wrong item', 'cancel', '返品', '返金', '壊れ', '破損', '違う', '間違い', 'キャンセル', ], MessageCategory.QUESTION: [ 'question', 'condition', 'compatible', 'include', 'color', 'size', '質問', '状態', '対応', '含ま', '色', 'サイズ', '詳細', '仙5様', ], } TEMPLATES: dict[MessageCategory, str] = { MessageCategory.SHIPPING: ( "お問い合わせありがとうございます。\n" "ご注文の商品は発送済みです。\n" "追跡番号: {tracking_number}\n" "お届けまでしばらくお待ちください。" ), MessageCategory.RETURN: ( "ご不便をおかけして申し訳ございません。\n" "返品・返金については eBay のリターンプロセスをご利用ください。\n" "ご不明な点があればお気軽にご連絡ください。" ), MessageCategory.QUESTION: ( "お問い合わせありがとうございます。\n" "ご質問の件について確認し、改めてご返答いたします。\n" "しばらくお時間をいただけますと幸いです。" ), } @dataclass class EbayMessage: """GetMemberMessages から取得した1件のメッセージを表すデータクラス。""" message_id: str item_id: str # 空文字列の場合は ItemID 未紐付けメッセージ sender_id: str subject: str body: str question_type: str message_status: str class CSBot: """eBay CS 自動返信ボット。""" EBAY_WSDL = "https://developer.ebay.com/webservices/latest/eBaySvc.wsdl" def __init__(self, user_token: str) -> None: self.user_token = user_token transport = zeep.Transport() self.client = zeep.Client(wsdl=self.EBAY_WSDL, transport=transport) self._headers = { 'RequesterCredentials': {'eBayAuthToken': self.user_token} } # ---------------------------------------------------------- # Step 1: 未返信メッセージ取得 # ---------------------------------------------------------- def get_unanswered_messages( self, hours_back: int = 24, page: int = 1 ) -> list[EbayMessage]: """ 直近 hours_back 時間内の未返信 ASQ メッセージを取得する。 Args: hours_back: 遡る時間数(デフォルト 24 時間)。 page: ページ番号(デフォルト 1、1ページ最大 25 件)。 Returns: EbayMessage のリスト。 Raises: RuntimeError: eBay がエラーを返した場合。 """ now = datetime.now(timezone.utc) start = now - timedelta(hours=hours_back) try: resp = self.client.service.GetMemberMessages( _soapheaders=self._headers, ErrorLanguage='ja_JP', WarningLevel='High', MailMessageType='AskSellerQuestion', MessageStatus='Unanswered', StartCreationTime=start.strftime('%Y-%m-%dT%H:%M:%S.000Z'), EndCreationTime=now.strftime('%Y-%m-%dT%H:%M:%S.000Z'), Pagination={'EntriesPerPage': 25, 'PageNumber': page}, ) except zeep.exceptions.Fault as exc: logger.error("GetMemberMessages SOAP Fault: %s", exc) raise if resp.Ack not in ('Success', 'Warning'): errors = resp.Errors or [] codes = [f"{e.ErrorCode}: {e.ShortMessage}" for e in errors] raise RuntimeError(f"GetMemberMessages failed: {codes}") exchanges = ( getattr(resp.MemberMessage, "MemberMessageExchange", []) or [] ) result: list[EbayMessage] = [] for ex in exchanges: msg = ex.Question # ItemID は None の場合があるため安全に取得する item_id = ( getattr(getattr(ex, "Item", None), "ItemID", None) or "" ) result.append(EbayMessage( message_id=msg.MessageID, item_id=item_id, sender_id=msg.SenderID, subject=msg.Subject or "", body=msg.Body or "", question_type=msg.QuestionType or "", message_status=ex.MessageStatus or "", )) logger.info("取得メッセージ数: %d (page=%d)", len(result), page) return result # ---------------------------------------------------------- # Step 2: キーワード分類 # ---------------------------------------------------------- def classify_message(self, message: EbayMessage) -> MessageCategory: """ 件名 + 本文をキーワードで分類する。 優先順位: RETURN > SHIPPING > QUESTION > UNKNOWN。 Args: message: 分類対象の EbayMessage。 Returns: MessageCategory。 """ text = (message.subject + " " + message.body).lower() # 返品・クレーム系を最優先で判定 for kw in KEYWORDS[MessageCategory.RETURN]: if kw.lower() in text: return MessageCategory.RETURN for kw in KEYWORDS[MessageCategory.SHIPPING]: if kw.lower() in text: return MessageCategory.SHIPPING for kw in KEYWORDS[MessageCategory.QUESTION]: if kw.lower() in text: return MessageCategory.QUESTION return MessageCategory.UNKNOWN # ---------------------------------------------------------- # Step 3: テンプレート自動返信 # ---------------------------------------------------------- def reply_with_template( self, message: EbayMessage, category: MessageCategory, template_vars: Optional[dict] = None, ) -> bool: """ 分類結果に応じたテンプレートで AddMemberMessageAAQToPartner を呼び出す。 UNKNOWN / ItemID なし の場合はスキップして False を返す。 Args: message: 返信対象の EbayMessage。 category: classify_message が返した MessageCategory。 template_vars: テンプレート埋め込み変数(例: tracking_number)。 Returns: True = 送信成功、False = スキップ(手動対応要)。 Raises: RuntimeError: API 呼び出し失敗時。 """ if category == MessageCategory.UNKNOWN: logger.warning( "分類不能 -> 手動対応キューへ: message_id=%s, sender=%s", message.message_id, message.sender_id, ) return False if not message.item_id: # ItemID なし = RTQ または一般メッセージ。API が対応不可。 logger.warning( "ItemID なし -> スキップ: message_id=%s", message.message_id ) return False body = TEMPLATES[category].format(**(template_vars or {})) try: resp = self.client.service.AddMemberMessageAAQToPartner( _soapheaders=self._headers, ErrorLanguage="ja_JP", ItemID=message.item_id, # 必須: 商品 ItemID MemberMessage={ 'Body': body, 'RecipientID': {'value': [message.sender_id]}, 'ParentMessageID': message.message_id, }, ) except zeep.exceptions.Fault as exc: logger.error("AddMemberMessageAAQToPartner Fault: %s", exc) raise if resp.Ack not in ('Success', 'Warning'): errors = resp.Errors or [] codes = [f"{e.ErrorCode}: {e.ShortMessage}" for e in errors] raise RuntimeError( f"Reply failed for {message.message_id}: {codes}" ) logger.info( "返信成功: message_id=%s, category=%s", message.message_id, category.name, ) return True # ---------------------------------------------------------- # 1サイクル実行 # ---------------------------------------------------------- def run_once(self, template_vars: Optional[dict] = None) -> dict: """ 未返信メッセージ取得 -> 分類 -> 返信 を1サイクル実行する。 Returns: {'replied': int, 'skipped': int, 'errors': int} """ stats = {'replied': 0, 'skipped': 0, 'errors': 0} messages = self.get_unanswered_messages(hours_back=24) for msg in messages: category = self.classify_message(msg) try: sent = self.reply_with_template(msg, category, template_vars) stats['replied' if sent else 'skipped'] += 1 except RuntimeError as exc: logger.error("返信エラー: %s", exc) stats['errors'] += 1 logger.info("CSBot run 完了: %s", stats) return stats # ---- エントリポイント ---- if __name__ == "__main__": import os logging.basicConfig(level=logging.INFO) bot = CSBot(user_token=os.environ["EBAY_USER_TOKEN"]) result = bot.run_once( template_vars={"tracking_number": "JPN123456789"} ) print(result)
このクラス設計において特に注意が必要なのは、RecipientID の渡し方です。zeep の型マッピングによっては RecipientID を StringArray 型としてリスト形式でラップする必要があります。zeep が生成した型定義(client.get_type)を事前に確認し、StringArray 型であれば value キーでリストを包む形式で渡してください。もし単純な文字列で渡してしまうと、zeep の内部で型変換が失敗しランタイムエラーとなります。
また、TEMPLATES 内の返信文を実際の eBay メッセージとして送信すると、文字数制限(概ね 2,000 文字以内)があります。テンプレートを長くしすぎると Error 21915104(Body が長すぎる)が発生します。各テンプレートは 500 文字以内に収めることを推奨します。
パフォーマンス・スケーリング視点 (深度)
ポーリング設計と大量メッセージのページネーション
CSBot を定期実行するには、run_once() を cron ジョブや APScheduler・Celery Beat などのタスクスケジューラから呼び出すのが基本パターンです。ポーリング間隔については、30分〜1時間ごとが現実的です。それより短い間隔で GetMemberMessages を高頻度に呼び出すと、eBay の API コールリミット(1日あたりのコール数上限)を消費するリスクがあります。特に Sandbox 環境では上限が本番より低いため、テスト中でも過負荷にならないよう注意してください。
出品数が多く大量のメッセージが届くセラーの場合、1ページ(25件)では取りきれないことがあります。GetMemberMessages のレスポンスには PaginationResult.TotalNumberOfPages が含まれるため、これを使ってすべてのページを取得する全件ループを実装します。
def get_all_unanswered_messages( self, hours_back: int = 24 ) -> list[EbayMessage]: """全ページを走査して未返信メッセージを全件取得する。""" all_messages: list[EbayMessage] = [] page = 1 while True: batch = self.get_unanswered_messages( hours_back=hours_back, page=page ) all_messages.extend(batch) if len(batch) < 25: break # 最終ページ(25件未満)に到達 page += 1 logger.info("全件取得完了: 計 %d 件", len(all_messages)) return all_messages
REST版 Messaging API への将来的な移行と Webhook アーキテクチャ
本記事で解説した GetMemberMessages / AddMemberMessageAAQToPartner は Trading API(SOAP)の機能です。eBay はモダン化の一環として、REST ベースの各種 API の整備を進めており、メッセージング機能についても将来的に REST 版の充実が見込まれます。
REST 版では、JSON ペイロードによるシンプルな HTTP リクエストで同等の機能を実現でき、zeep のような SOAP クライアントライブラリへの依存がなくなります。また、OAuth 2.0 のアプリケーショントークンとユーザートークンの使い分けも REST 版ではより明確に整理されています。
現時点(2026年)では Trading API の GetMemberMessages が実稼働環境での信頼性が高く、本番システムの主力として問題ありません。ただし、将来の移行を見越して実装を CSBot クラスに閉じ込め(カプセル化)、外部から SOAP / REST の実装の違いを意識させないアーキテクチャにしておくことを強く推奨します。インターフェース(get_unanswered_messages / reply_with_template)を共通化しておけば、実装を差し替える際の変更影響範囲を最小化できます。
次回(#15)で解説する SetNotificationPreferences を活用すると、eBay のイベント(新着メッセージ通知など)を Push 型でサーバーに受信することができ、ポーリング方式から Webhook 型アーキテクチャへの移行が可能になります。これにより、API コール数を大幅に削減しつつリアルタイム性を向上させることができます。「メッセージが届いた瞬間にボットが動く」という理想的な CS 自動化が実現します。
まとめ
本記事では、eBay CS 対応を自動化する核心となる2つの Trading API メソッドを実装しました。
- ベースライン: GetMemberMessages で未返信の ASQ メッセージを取得し、MessageID・SenderID・ItemID の三点情報を正しく抽出する手法。
- 深いポイント: AddMemberMessageAAQToPartner の必須三点セット(ItemID・RecipientID・ParentMessageID)を揃えることの重要性と、日英混在キーワード分類の設計、および UNKNOWN・ItemID なし・クレーム系メッセージへの自動返信禁止ルール。
- スケーリング: ページネーションによる全件取得ループの実装と、将来的な REST 版 Messaging API への移行を見越したカプセル化設計のアプローチ。
CS の自動化は「完全自動」を目指すのではなく、「確実に返信すべきものを自動化し、人間の判断が必要なケースを漏れなく手動キューに回す」という設計思想が成功の鍵です。UNKNOWN 分類と ItemID なし・クレーム系の手動フォールバックを必ず実装してください。
次のステップ
CSボットをポーリング(定期実行)から Webhook(Push 通知)型に進化させるには、eBay が送信するイベント通知の受信基盤が必要です。
次回(#15)は「SetNotificationPreferences で eBay イベント通知を設定して Python サーバーで受信する」をテーマに、通知エンドポイントの登録から Python(FastAPI)での受信・署名検証まで、エンドツーエンドの実装を解説します。お楽しみに!
次の記事はこちら