Product Advertising API(PAAPI)を使って50万商品を取得する

バージョン

前提

  • 今回は化粧品関連の商品を取得したかったので、アマゾンの「ビューティー」カテゴリーの下のカテゴリー全ての商品を取得します
  • amazonのbrowse node idを使用します
  • amazonのカテゴリーはamazonのサイトからダウンロードできたのですが場所がわからなくってしまったので見つけ次第貼りますm( )m
  • 商品とカテゴリーを多対多にします(一番下の子カテゴリーから一番上の親カテゴリーを紐づけるため)

準備

itemモデルとmigration

  • amazonでは商品ごとにユニークな asin があるので、それをこちらでも取得しユニーク制約をつけることで商品の重複を防ぎます
class CreateItems < ActiveRecord::Migration[5.1]
  def change
    create_table :items do |t|
      t.string :name, null: false
      t.references :brand, index: true
      t.string :asin
      t.string :image_url
    end

    add_index :items, :asin, unique: true
  end
end
class Item < ApplicationRecord
  has_many :item_category_relations
  has_many :categories, through: :item_category_relations
  validates :name, length: {maximum: 255}, presence: true
  validates :asin, uniqueness: true, presence: true
end

categoryモデルとmigration

  • ancestryカラムはgemの ancestry で使用します
class CreateCategories < ActiveRecord::Migration[5.1]
  def change
    create_table :categories do |t|
      t.string :name, null: false
      t.string :browse_node_id
      t.string :ancestry, index: true
      t.integer :acquire_status, default: 0, null: false
    end
  end
end
class Category < ApplicationRecord
  has_ancestry
  has_many :item_category_relations
  has_many :items, through: :item_category_relations
  validates :name, length: {maximum: 255}, presence: true
  enum acquire_status: {unacquired: 0, acquired: 1}
end

item_category_relationモデルとmigration

class CreateItemCategoryRelations < ActiveRecord::Migration[5.1]
  def change
    create_table :item_category_relations do |t|
      t.references :item, index: true, null: false
      t.references :category, index: true, null: false
    end
  end
end
class ItemCategoryRelation < ApplicationRecord
  belongs_to :item
  belongs_to :category
end

categoryの一括登録seed

  • db/fixtures/categories.csvに読み込むファイルを設置します
  • amazoncsvではカテゴリーの名前が「/」区切りで親から子まで表示されています
    • 例:ビューティー/オーラルケ
CSV.foreach(Rails.root.join('db', 'fixtures', "categories.csv")) do |row|
  ActiveRecord::Base.transaction do
    browse_node_id = row.first
    names = row.last.split('/')
    category = Category.create! name: names.last, browse_node_id: browse_node_id
    if names.count > 1 #  親カテゴリーが存在する時
      category.parent = Category.find_by name: names.last(2).first
      category.save!
    end
  end
end

実際に使ったコード

  • 非常に汚いコードです(恥ずかしい
  • 価格とカテゴリーで検索をしています
  • ひたすら再帰して商品を取得させます
  • 商品数が多いとリクエスト回数が増えて再帰する回数も増えて stack level too deep になってしまう(3カテゴリーほど全ての商品を取得できなかった)
  • 実装の際に意識したこと
    • 1つの検索クエリで10ページ(10件/ページ)までしか取得できないので検索結果が100件以下になるように検索をする
    • 検索結果が多い時にその時の価格を半分にして再度検索をする
    • 逆に検索結果が少ない時(10件以下の時)は価格を倍にして検索をする
    • サーバーでひたすら走らせるので適宜 puts を入れてログを残す
    • 取得に途中で失敗しタスクが中断されると最初からまた取得し直すのは辛いので acquire_status をカテゴリーに持たせ、どこまで習得したかを管理してました
  • @last_item_count != item_count の条件分岐をしているについて
    • 価格差1円で検索しても100件以上あるケースがあり、それは頑張っても取得できなさそうなので前回検索した時の商品数と今回検索した商品数が同じ時は商品の取得を開始するようになっています
namespace :amazon_api do
  task fetch_items: :environment do
    def amazon_api(browse_node_id, max, min, item_page)
      Amazon::Ecs.item_search(
        '',
        country: 'jp', # 自分が登録しているamazonの国
        browse_node: browse_node_id,
        minimum_price: min,
        maximum_price: max,
        search_index: 'Beauty',
        response_group: 'Images,ItemAttributes',
        item_page: item_page
      )
    end

    def blank_upside?(browse_node_id, max, min)
      result = amazon_api(browse_node_id, max, min, 1).doc.at('TotalResults').text == '0'
      return result
    rescue Amazon::RequestError
      sleep(10)
      blank_upside?(browse_node_id, max, min)
    end

    def create_item(row, c)
      name = row.at('Title')&.text
      return if name.blank?
      brand = row.at('Manufacturer').blank? ? nil : Brand.find_or_create_by(name: row.at('Manufacturer').text)
      asin  = row.at('ASIN').text
      image_url = (row.at('LargeImage') || row.at('MediumImage') || row.at('SmallImage'))&.at('URL')&.text || nil
      Item.create name: name, asin: asin, image_url: image_url, brand: brand, categories: c.path
    end

    def fetch(c, price, diff, page)
      res = amazon_api(c.browse_node_id, (price + diff), price, page)
      item_count = res.doc.at('TotalResults').text.to_i
      # 最後に取得した時の件数と異なる時かつ
      # 取得した商品の数が1〜10かつ
      # pageが1ページ目のとき
      if @last_item_count != item_count && item_count.in?(1..10) && page == 1
        @last_item_count = item_count
        fetch(c, price, ((diff.zero? ? 10 : diff)*2), 1)
        return
      end
      if diff != 1 && res.doc.at('TotalResults').text.to_i > 100 && page == 1
        fetch(c, price, ((diff.zero? ? 10 : diff)/2), 1)
        return
      end
      if res.doc.at('TotalResults').text == '0'
        # 現在の指定された価格で商品が見つからない時は上限の上を検索して見つからなければ次のカテゴリーに進める
        return if blank_upside?(c.browse_node_id, (price + 10000), price+diff)
        below(c, price, ((diff.zero? ? 10 : diff)*2), page, res)
        return
      end
      # 正常パターン
      res.doc.at('Items').search('Item').each {|row| create_item row, c}
      below(c, price, (diff.zero? ? 10 : diff), page, res)
    rescue Amazon::RequestError
      sleep(10)
      fetch(c, price, (diff.zero? ? 10 : diff), page)
    end

    def below(c, price, diff, page, res)
      if page == 10
        fetch(c, (price + diff), diff, 1)
        return
      end

      if res.doc.at('TotalPages').text.to_i > page
        fetch(c, price, diff, page + 1)
        return
      end

      fetch(c, (price + diff), diff, 1)
    end

    most_child_categories = Category.unacquired.where.select{|c| c.children.blank?}
    most_child_categories.each do |category|
      fetch(category, 0, 100, 1)
      category.acquired!
    end
  end
end