バージョン
前提
- 今回は化粧品関連の商品を取得したかったので、アマゾンの「ビューティー」カテゴリーの下のカテゴリー全ての商品を取得します
- 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に読み込むファイルを設置します
- amazonのcsvではカテゴリーの名前が「/」区切りで親から子まで表示されています
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