2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

楽天・Yahoo・AmazonのAPIを横断して同一商品の最安値を見つける実装の話

2
Posted at

楽天・Yahoo・AmazonのAPIを横断して同一商品の最安値を見つける実装の話

はじめに

バイクポータルサイト「MotoHub」を個人開発しています。

MotoHubにはバイクパーツの価格比較機能があります。楽天市場・Yahoo!ショッピング・Amazonの3サイトから同じパーツを横断検索して、最安値を一目で比較できる機能です。

image.png

3サイトの価格を並べて比較できる

「3サイトから検索して並べるだけでしょ?」と思うかもしれませんが、実際に作ってみると 「同じ商品」を特定するのが異常に難しい ということがわかりました。

この記事では、EC横断価格比較の実装で直面した課題と、それをどう解決したかを書きます。


アーキテクチャ全体像

ユーザー入力(キーワード + 車種名)
    ↓
┌─────────────────────────────┐
│   PartsSearchController     │
│   ・バリデーション           │
│   ・各API並列呼び出し        │
└──────┬──────┬──────┬────────┘
       ↓      ↓      ↓
   Rakuten   Yahoo   Amazon
    API       API     API
       ↓      ↓      ↓
┌─────────────────────────────┐
│   PartsCodeExtractor        │
│   ・JANコード抽出            │
│   ・メーカー品番抽出          │
└──────┬──────────────────────┘
       ↓
┌─────────────────────────────┐
│   同一商品マッチング          │
│   ・JAN一致 → 確実に同一      │
│   ・品番一致 → ほぼ同一       │
│   ・商品名類似 → フォールバック │
└──────┬──────────────────────┘
       ↓
   価格比較カード表示

ポイントは中間にある PartsCodeExtractor です。各ECサイトから取得した商品データからJANコードとメーカー品番を抽出し、同一商品をマッチングするのがこの仕組みのキモになっています。


各ECサイトAPIの特徴と罠

楽天市場 API

楽天のIchiba Item Search APIは、3サイトの中では最も扱いやすいです。

// 楽天APIのリクエスト例
$response = Http::get('https://app.rakuten.co.jp/services/api/IchibaItem/Search/20220601', [
    'applicationId' => config('services.rakuten.app_id'),
    'affiliateId'   => config('services.rakuten.affiliate_id'),
    'keyword'        => $keyword,
    'hits'           => 30,
    'sort'           => '-updateTimestamp',
]);

良い点

  • レスポンスが安定している
  • itemCaption(商品説明文)が充実しており、JAN・品番情報が含まれていることが多い

  • JANコードの専用フィールドがない
  • itemCaption の中にJANが埋まっているが、フォーマットがバラバラ
  • 同一商品が複数店舗から出品されており、重複が大量に出る

JANコードは itemCaption からの正規表現抽出が必要です(後述)。

Yahoo!ショッピング API

Yahoo!ショッピングのAPIには、楽天にはない強力な武器があります。

$response = Http::get('https://shopping.yahooapis.jp/ShoppingWebService/V3/itemSearch', [
    'appid'    => config('services.yahoo.app_id'),
    'query'    => $keyword,
    'results'  => 30,
    'sort'     => '-score',
]);

良い点

  • janCode フィールドがネイティブで存在する(これが最高に便利)
  • レスポンスにJANが含まれている商品は、ほぼ100%の精度でマッチングできる

  • バイクパーツの取扱店舗数が楽天より少ない
  • アフィリエイト連携にValueCommerceの設定が必要

Amazon PA-API

Amazon Product Advertising APIは、3サイトの中で最もハードルが高いです。

罠(というか壁)

  • 過去30日間に3件以上の適格売上がないとAPIアクセスできない
  • つまり、サイト立ち上げ初期はそもそもAPIが使えない
  • リクエスト制限も厳しい(1秒1リクエスト〜)

MotoHubでは現時点でPA-APIの利用条件を満たしていないため、Amazon枠は 検索リンクへのフォールバック で対応しています。

// Amazonは検索URLにリダイレクト
$amazonSearchUrl = 'https://www.amazon.co.jp/s?' . http_build_query([
    'k'   => $keyword,
    'tag' => config('services.amazon.associate_tag'),
]);

将来的にPA-APIが使えるようになったら、JANコードでの精密マッチングに切り替える予定です。


最大の課題:「同じ商品」をどう特定するか

ECサイト横断比較で最も難しいのは、楽天で見つけた商品とYahoo!で見つけた商品が本当に同じものかを判定することです。

なぜ難しいのか

同じ「モリワキ レブル250用マフラー」でも、各サイトの商品名はこうなります。

楽天: MORIWAKI ENGINEERING モリワキエンジニアリング スリップオンマフラー NEO CLASSIC[ネオ クラシック] REBEL250...
Yahoo: MORIWAKI ENGINEERING モリワキエンジニアリング スリップオンマフラー NEO CLASSIC ブラック スリップオンマフラー レブル250(2017-2022) 01810-HG1P6-10

商品名の文字列比較では、表記揺れが多すぎてマッチングできません。

解決策:JAN・品番によるマッチング

そこで作ったのが PartsCodeExtractor です。商品名と商品説明文から JANコードメーカー品番 を正規表現で抽出します。

class PartsCodeExtractor
{
    /**
     * JANコード抽出(13桁、日本のプレフィックス 45/49)
     */
    public static function extractJan(string $text): ?string
    {
        // 明示的ラベル付き
        if (preg_match('/JAN[コード::\s]*(\d{13})/u', $text, $m)) {
            return $m[1];
        }
        // 45xxxxx or 49xxxxx で始まる13桁
        if (preg_match('/\b(4[59]\d{11})\b/', $text, $m)) {
            return $m[1];
        }
        return null;
    }
}

マッチングの優先度

JAN一致     → 確実に同一商品(精度ほぼ100%)
品番一致    → ほぼ同一商品(精度95%以上)
商品名類似  → フォールバック(精度はまちまち)

JAN(Japanese Article Number) は商品パッケージのバーコードに印刷されている13桁の番号です。世界共通の商品識別コードなので、これが一致すれば間違いなく同じ商品です。

メーカー品番(例:01810-HG1P6-10)はメーカーが付与する型番で、JANほど確実ではありませんが、バイクパーツの世界ではほぼ一意に商品を特定できます。


JANコード抽出の精度

各サイトでのJAN抽出率を調べてみました。

サイト JAN取得方法 抽出率
楽天 itemCaption から正規表現で抽出 約60%
Yahoo janCode フィールド(ネイティブ) ほぼ100%
Amazon PA-API未使用のため対象外

楽天の60%は一見低いように見えますが、バイクパーツの場合、JANが取れなくても 品番が取れるケースが多い ので、実際のマッチング成功率はもっと高くなります。

JAN + 品番での検索精度

JANや品番が取得できた商品を使って相手サイトを検索すると、マッチング精度が劇的に向上します。

// JANで検索 → ほぼ確実に同一商品がヒット
$yahooResults = $this->searchYahoo($janCode);

// 品番で検索 → こちらもかなりの精度
$rakutenResults = $this->searchRakuten($partNumber);

キーワード「マフラー レブル250」で検索すると数百件ヒットしますが、JAN 4527350152006 で検索すると ピンポイントで同一商品だけ がヒットします。


品番抽出で踏んだ罠:PHP 8.3 + PCRE2 + Unicode

品番抽出のロジックで、なかなか気づけないバグに遭遇しました。

症状

BEAMSマフラーの品番 G1016-24-001 を抽出する際に、 G1016-24-001重量 のように後続の日本語テキストが混入する。

この品番でYahoo!ショッピングを検索すると、全く関係ない商品(スパチュラナイフ等)がヒットしてしまう。

原因

品番抽出の正規表現で \w を使い、u(Unicode)フラグを付けていたことが原因でした。

// NG: u フラグ付きの \w は漢字もマッチする
'/(?:品番|型番)[::\s]*([A-Za-z0-9][\w-]{4,})/u'

PHP 8.3が使用するPCRE2では、u フラグを付けると \w がUnicodeプロパティ対応になります。つまり CJK漢字(Lo カテゴリ)も「単語文字」として扱われる のです。

結果として [\w-]{4,}G1016-24-001重量 まで貪欲にマッチしてしまいます。

修正

\w を全て 明示的なASCII文字クラス に置き換えました。

// OK: ASCII文字のみを明示指定
private const ALNUM    = '[A-Za-z0-9]';
private const ALNUM_HU = '[A-Za-z0-9_-]';

// パターン1: 明示的ラベル付き品番
'/(?:品番|型番|商品コード|Part ?No\.?)[::\s]*('
    . self::ALNUM . self::ALNUM_HU . '{4,})/u'

境界判定も \b から 負の先読み/後読み に変更しました。

// NG: \b もUTF-8バイト列と噛み合わないリスク
'/\b([A-Za-z]{1,5}\d[\w]*(?:-[\w]+){1,4})\b/'

// OK: 前後がASCII英数字でないことを明示
'/(?<![A-Za-z0-9_-])([A-Za-z]{1,5}\d[A-Za-z0-9]*(?:-[A-Za-z0-9]+){1,4})(?![A-Za-z0-9_-])/u'

さらに安全弁として、抽出後に非ASCII文字を除去する sanitize() を追加しました。

private static function sanitize(string $code): string
{
    // 末尾の非ASCII文字を除去(重量, 適合, 用 等の混入対策)
    $code = preg_replace('/[^\x20-\x7E]+$/u', '', $code);
    // 先頭の非ASCII文字を除去
    $code = preg_replace('/^[^\x20-\x7E]+/u', '', $code);
    return trim($code);
}

教訓

PHP + u フラグ + \w の組み合わせは危険。日本語テキストを扱う正規表現では、\w ではなく [A-Za-z0-9_] を明示的に書くのが安全です。

特にPHP 8.x系ではPCRE2への移行により、以前のバージョンとは \w の挙動が変わっている場合があるので注意が必要です。


UIの設計:3カラム比較カード

価格比較の結果は、楽天・Yahoo!・Amazonの 3カラム比較カード で表示しています。

image.png
3サイトの価格を横並びで比較

設計のポイント

最安値のハイライト: 3サイト中で最安値の価格を強調表示。一目でどこが安いかわかる。

「他のショップ」の折りたたみ: 同一商品が複数店舗から出品されている場合、最安値の店舗を表示し、残りは折りたたみで格納。画面が埋まらないようにしつつ、比較したい人は展開できる。

車種での絞り込み: 「マフラー」だけだと汎用品が大量にヒットするので、車種名を入力すると適合パーツだけに絞り込めるようにした。既存の bike_models テーブルと /bikes/suggest APIを活用したサジェスト付き。


アフィリエイト設計

EC横断比較はアフィリエイト収益にも直結します。各サイトの設定方法をメモしておきます。

楽天

楽天アフィリエイトIDをAPIリクエストの affiliateId パラメータに含めるだけ。レスポンスの affiliateUrl にアフィリエイトリンクが自動生成されます。

Yahoo!ショッピング

ValueCommerceとの連携が必要です。ValueCommerceに登録し、Yahoo!ショッピングの広告主と提携。アフィリエイトIDを取得してリンクに埋め込みます。

Amazon

PA-APIの AssociateTag パラメータでアソシエイトIDを指定。PA-API未使用の場合は、検索URLに tag パラメータを付与します。

// 検索リンクにアソシエイトIDを付与
$url = 'https://www.amazon.co.jp/s?k=' . urlencode($keyword) 
     . '&tag=' . config('services.amazon.associate_tag');

今後の展望

対応サイトの拡大

現在は楽天・Yahoo!・Amazonの3サイトですが、バイクパーツに特化したECサイトへの対応も検討中です。

  • Webike: バイクパーツ専門通販の最大手。公開APIがないため、ビジネス提携ルートを検討
  • MonotaRO: 工具・消耗品系に強い。こちらもAPI連携の可能性を模索中

Amazon PA-APIの本格対応

適格売上の条件を満たし次第、PA-APIに切り替えて楽天・Yahooと同等のJAN/品番マッチングを実現予定です。

ポイント還元の計算

「楽天の方が本体は高いけど、SPUでポイント10%還元だから実質最安」というケースは多いです。ポイント還元率を加味した 実質価格 での比較機能も検討しています。


まとめ

EC横断の価格比較、実装してみると「同一商品の特定」が最大の壁でした。

  • JANコード が取れれば確実にマッチングできる(Yahoo!はネイティブ対応、楽天は正規表現抽出)
  • メーカー品番 でのマッチングはJANの補完として有効
  • PHP 8.3 + u フラグでの \w は漢字にもマッチするので要注意
  • Amazon PA-APIは参入障壁が高いので、まずは検索リンクでフォールバック

バイクパーツに限らず、EC横断比較を作りたい人の参考になれば幸いです。

MotoHubのパーツ価格比較を使ってみる

2
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?