apress-yandex_market 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.drone.yml +30 -0
- data/.gitignore +14 -0
- data/.rspec +3 -0
- data/CHANGELOG.md +9 -0
- data/Gemfile +3 -0
- data/README.md +3 -0
- data/Rakefile +7 -0
- data/apress-yandexmarket.gemspec +36 -0
- data/dip.yml +41 -0
- data/docker-compose.development.yml +13 -0
- data/docker-compose.drone.yml +7 -0
- data/docker-compose.yml +9 -0
- data/lib/apress/yandex_market/api/client.rb +79 -0
- data/lib/apress/yandex_market/api/error.rb +19 -0
- data/lib/apress/yandex_market/api/page_error.rb +9 -0
- data/lib/apress/yandex_market/api.rb +9 -0
- data/lib/apress/yandex_market/presenters/base.rb +49 -0
- data/lib/apress/yandex_market/presenters/model.rb +19 -0
- data/lib/apress/yandex_market/presenters.rb +8 -0
- data/lib/apress/yandex_market/readers/base.rb +54 -0
- data/lib/apress/yandex_market/readers/category.rb +117 -0
- data/lib/apress/yandex_market/readers/model_by_category.rb +145 -0
- data/lib/apress/yandex_market/readers.rb +8 -0
- data/lib/apress/yandex_market/version.rb +5 -0
- data/lib/apress/yandex_market.rb +9 -0
- data/spec/apress/yandex_market/api/client_spec.rb +46 -0
- data/spec/apress/yandex_market/readers/category_spec.rb +46 -0
- data/spec/apress/yandex_market/readers/model_by_category_spec.rb +29 -0
- data/spec/fixtures/get_category_with_invalid_id.yml +81 -0
- data/spec/fixtures/get_root_categories.yml +82 -0
- data/spec/fixtures/read_models_from_category.yml +29960 -0
- data/spec/fixtures/read_two_categories.yml +3600 -0
- data/spec/fixtures/read_two_categories_with_page_error.yml +1983 -0
- data/spec/spec_helper.rb +27 -0
- metadata +240 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA1:
|
3
|
+
metadata.gz: 4b62a9a0c7db6767fe9559e3e07cfefd0b1bed4f
|
4
|
+
data.tar.gz: 712a1629589d5f6db999c62fb6f3b547e49cd705
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 4a0840fceb8743f97872f44240717414b55cf0dc3632dd24c6bc22511375cb98bf267e97cff3bbf2b38157c7feceb515d5cdb8aaad1f966d181b1a7a6d9fa19d
|
7
|
+
data.tar.gz: 4b605819f184624087a2ff58bf156560ebf84af744df6977250d0589daf5d4c7ab049f9b4c8b9d36445ced72749b9897b7700b290d0c88dfb96ba0c31302473d
|
data/.drone.yml
ADDED
@@ -0,0 +1,30 @@
|
|
1
|
+
build:
|
2
|
+
test:
|
3
|
+
image: abakpress/dind-testing
|
4
|
+
pull: true
|
5
|
+
privileged: true
|
6
|
+
volumes:
|
7
|
+
- /home/data/drone/images:/images
|
8
|
+
- /home/data/drone/gems:/bundle
|
9
|
+
environment:
|
10
|
+
- COMPOSE_FILE_EXT=drone
|
11
|
+
- RUBY_IMAGE_TAG=2.2-latest
|
12
|
+
commands:
|
13
|
+
- prepare-build
|
14
|
+
|
15
|
+
- fetch-images
|
16
|
+
--image abakpress/ruby-app:$RUBY_IMAGE_TAG
|
17
|
+
|
18
|
+
- dip provision
|
19
|
+
- dip rspec
|
20
|
+
|
21
|
+
release:
|
22
|
+
image: abakpress/gem-publication:latest
|
23
|
+
pull: true
|
24
|
+
when:
|
25
|
+
event: push
|
26
|
+
branch: master
|
27
|
+
volumes:
|
28
|
+
- /home/data/drone/rubygems:/root/.gem
|
29
|
+
commands:
|
30
|
+
- release-gem --public
|
data/.gitignore
ADDED
data/.rspec
ADDED
data/CHANGELOG.md
ADDED
@@ -0,0 +1,9 @@
|
|
1
|
+
# v0.1.0
|
2
|
+
|
3
|
+
* 2018-12-24 [b5adb2b](../../commit/b5adb2b) - __(Mikhail Nelaev)__ fix: получаем все корневые категории
|
4
|
+
https://jira.railsc.ru/browse/GOODS-1525
|
5
|
+
|
6
|
+
* 2018-11-12 [e660187](../../commit/e660187) - __(Mikhail Nelaev)__ feature: обновление по api Яндекс.Маркета
|
7
|
+
https://jira.railsc.ru/browse/GOODS-1525
|
8
|
+
|
9
|
+
* 2018-11-08 [113a81b](../../commit/113a81b) - __(Mamedaliev Kirill)__ Initial commit
|
data/Gemfile
ADDED
data/README.md
ADDED
data/Rakefile
ADDED
@@ -0,0 +1,36 @@
|
|
1
|
+
lib = File.expand_path('../lib', __FILE__)
|
2
|
+
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include? lib
|
3
|
+
|
4
|
+
require 'apress/yandex_market/version'
|
5
|
+
|
6
|
+
Gem::Specification.new do |gem|
|
7
|
+
gem.name = 'apress-yandex_market'
|
8
|
+
gem.version = Apress::YandexMarket::VERSION
|
9
|
+
gem.authors = ['Mikhail Nelaev']
|
10
|
+
gem.email = %w(spyderdfx@gmail.com)
|
11
|
+
gem.summary = 'Tools for synchronization with Yandex.Market'
|
12
|
+
gem.homepage = 'https://github.com/abak-press/apress-yandex_market'
|
13
|
+
|
14
|
+
gem.files = `git ls-files -z`.split("\x0")
|
15
|
+
gem.test_files = gem.files.grep(%r{^(test|spec|features)/})
|
16
|
+
gem.require_path = "lib"
|
17
|
+
|
18
|
+
gem.add_runtime_dependency 'oj'
|
19
|
+
gem.add_runtime_dependency 'facets'
|
20
|
+
|
21
|
+
gem.add_development_dependency 'bundler', '~> 1.6'
|
22
|
+
gem.add_development_dependency 'rake', '< 11.0' # https://github.com/lsegal/yard/issues/947
|
23
|
+
|
24
|
+
# test
|
25
|
+
gem.add_development_dependency 'rspec', '>= 3.5'
|
26
|
+
gem.add_development_dependency 'rspec-its'
|
27
|
+
gem.add_development_dependency 'rspec-collection_matchers'
|
28
|
+
gem.add_development_dependency 'webmock', '< 2.3'
|
29
|
+
gem.add_development_dependency 'vcr'
|
30
|
+
|
31
|
+
# test coverage tools
|
32
|
+
gem.add_development_dependency 'simplecov', '~> 0.10.0'
|
33
|
+
|
34
|
+
# debug
|
35
|
+
gem.add_development_dependency 'pry-byebug'
|
36
|
+
end
|
data/dip.yml
ADDED
@@ -0,0 +1,41 @@
|
|
1
|
+
version: '2'
|
2
|
+
|
3
|
+
environment:
|
4
|
+
DOCKER_RUBY_VERSION: 2.2
|
5
|
+
RUBY_IMAGE_TAG: 2.2-latest
|
6
|
+
COMPOSE_FILE_EXT: development
|
7
|
+
RAILS_ENV: test
|
8
|
+
|
9
|
+
compose:
|
10
|
+
files:
|
11
|
+
- docker-compose.yml
|
12
|
+
- docker-compose.${COMPOSE_FILE_EXT}.yml
|
13
|
+
|
14
|
+
interaction:
|
15
|
+
sh:
|
16
|
+
service: app
|
17
|
+
|
18
|
+
irb:
|
19
|
+
service: app
|
20
|
+
command: irb
|
21
|
+
|
22
|
+
bundle:
|
23
|
+
service: app
|
24
|
+
command: bundle
|
25
|
+
|
26
|
+
rake:
|
27
|
+
service: app
|
28
|
+
command: bundle exec rake
|
29
|
+
|
30
|
+
rspec:
|
31
|
+
service: app
|
32
|
+
command: bundle exec rspec
|
33
|
+
|
34
|
+
clean:
|
35
|
+
service: app
|
36
|
+
command: rm -f Gemfile.lock gemfiles/*.gemfile.*
|
37
|
+
|
38
|
+
provision:
|
39
|
+
- docker volume create --name bundler_data
|
40
|
+
- dip clean
|
41
|
+
- dip bundle install
|
data/docker-compose.yml
ADDED
@@ -0,0 +1,79 @@
|
|
1
|
+
require 'net/http'
|
2
|
+
require 'openssl'
|
3
|
+
require 'uri'
|
4
|
+
require 'oj'
|
5
|
+
|
6
|
+
module Apress
|
7
|
+
module YandexMarket
|
8
|
+
module Api
|
9
|
+
# API клиент Яндекс.Маркета
|
10
|
+
class Client
|
11
|
+
TIMEOUT = 60
|
12
|
+
HOST = 'api.content.market.yandex.ru'.freeze
|
13
|
+
ACCEPT = '*/*'.freeze
|
14
|
+
VERSION = 'v2'.freeze
|
15
|
+
|
16
|
+
# Public: инициализация клиента
|
17
|
+
#
|
18
|
+
# auth_token - String, токен для доступа к API
|
19
|
+
#
|
20
|
+
# Returns an instance of Api::Client
|
21
|
+
def initialize(auth_token)
|
22
|
+
@auth_token = auth_token
|
23
|
+
end
|
24
|
+
|
25
|
+
# Public: выполняет GET-запрос и парсит ответ в формате JSON
|
26
|
+
#
|
27
|
+
# path - String, ресурс API
|
28
|
+
# params - Hash, дополнительные get-параметры запроса (default: {})
|
29
|
+
#
|
30
|
+
# Returns Hash
|
31
|
+
def get(path, params = {})
|
32
|
+
uri = api_uri(path)
|
33
|
+
uri.query = URI.encode_www_form(params) unless params.empty?
|
34
|
+
|
35
|
+
req = Net::HTTP::Get.new(uri)
|
36
|
+
req['Host'] = HOST
|
37
|
+
req['Accept'] = ACCEPT
|
38
|
+
req['Authorization'] = @auth_token
|
39
|
+
|
40
|
+
res = Net::HTTP.start(uri.hostname, uri.port, http_options) do |http|
|
41
|
+
http.request(req)
|
42
|
+
end
|
43
|
+
|
44
|
+
if res['Content-Type'] && res['Content-Type'].start_with?('application/json')
|
45
|
+
parse_response(res)
|
46
|
+
else
|
47
|
+
raise Api::Error.new(res.msg, res.code)
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
private
|
52
|
+
|
53
|
+
def api_uri(path)
|
54
|
+
URI "https://#{HOST}/#{VERSION}/#{path}"
|
55
|
+
end
|
56
|
+
|
57
|
+
def parse_response(res)
|
58
|
+
Oj.load(res.body, symbol_keys: true, mode: :compat).tap do |data|
|
59
|
+
if data[:status] == 'ERROR'
|
60
|
+
err = data[:errors].first
|
61
|
+
|
62
|
+
raise Api::Error.new(err[:message], res.code)
|
63
|
+
end
|
64
|
+
|
65
|
+
raise Api::Error.new(res.msg, res.code) unless res.is_a? Net::HTTPOK
|
66
|
+
end
|
67
|
+
end
|
68
|
+
|
69
|
+
def http_options
|
70
|
+
{
|
71
|
+
open_timeout: TIMEOUT,
|
72
|
+
read_timeout: TIMEOUT,
|
73
|
+
use_ssl: true
|
74
|
+
}
|
75
|
+
end
|
76
|
+
end
|
77
|
+
end
|
78
|
+
end
|
79
|
+
end
|
@@ -0,0 +1,19 @@
|
|
1
|
+
module Apress
|
2
|
+
module YandexMarket
|
3
|
+
module Api
|
4
|
+
class Error < StandardError
|
5
|
+
attr_reader :code
|
6
|
+
|
7
|
+
def initialize(msg, code = nil)
|
8
|
+
@code = code.to_i
|
9
|
+
|
10
|
+
message = code ? "#{code} - #{msg}" : msg
|
11
|
+
|
12
|
+
raise PageError.new(message) if msg.start_with? Api::PageError::MSG
|
13
|
+
|
14
|
+
super message.force_encoding('UTF-8')
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
@@ -0,0 +1,49 @@
|
|
1
|
+
module Apress
|
2
|
+
module YandexMarket
|
3
|
+
module Presenters
|
4
|
+
class Base
|
5
|
+
class << self
|
6
|
+
attr_accessor :attributes
|
7
|
+
|
8
|
+
attributes = []
|
9
|
+
end
|
10
|
+
|
11
|
+
def initialize
|
12
|
+
@counter = 0
|
13
|
+
end
|
14
|
+
|
15
|
+
def expose(row)
|
16
|
+
record = {
|
17
|
+
__line__: @counter += 1,
|
18
|
+
__column__: 1
|
19
|
+
}
|
20
|
+
|
21
|
+
record.merge! filter(row)
|
22
|
+
|
23
|
+
record
|
24
|
+
end
|
25
|
+
|
26
|
+
private
|
27
|
+
|
28
|
+
def filter(row, attrs = self.class.attributes)
|
29
|
+
attrs.each_with_object({}) do |key, result|
|
30
|
+
if key.is_a? Hash
|
31
|
+
key.each do |hash_key, hash_attrs|
|
32
|
+
next unless row.key? hash_key
|
33
|
+
|
34
|
+
result[hash_key] =
|
35
|
+
if row[hash_key].is_a? Array
|
36
|
+
row[hash_key].map { |item| filter(item, hash_attrs) }
|
37
|
+
else
|
38
|
+
filter(row[hash_key], hash_attrs)
|
39
|
+
end
|
40
|
+
end
|
41
|
+
elsif row.key? key
|
42
|
+
result[key] = row[key]
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
@@ -0,0 +1,19 @@
|
|
1
|
+
module Apress
|
2
|
+
module YandexMarket
|
3
|
+
module Presenters
|
4
|
+
class Model < Base
|
5
|
+
self.attributes = [
|
6
|
+
:id,
|
7
|
+
:name,
|
8
|
+
:link,
|
9
|
+
:description,
|
10
|
+
photo: [:url].freeze,
|
11
|
+
photos: [:url].freeze,
|
12
|
+
price: [:min].freeze,
|
13
|
+
vendor: [:name].freeze,
|
14
|
+
specification: [features: [:name, :value].freeze].freeze
|
15
|
+
].freeze
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
@@ -0,0 +1,54 @@
|
|
1
|
+
require 'apress/yandex_market/api'
|
2
|
+
|
3
|
+
module Apress
|
4
|
+
module YandexMarket
|
5
|
+
module Readers
|
6
|
+
# Базовый класс ридеров
|
7
|
+
class Base
|
8
|
+
DEFAULT_REGION_ID = 225 # Россия
|
9
|
+
PAGE_SIZE = 30 # максимум сущностей на одной странице в API Яндекс.Маркета
|
10
|
+
|
11
|
+
SLEEP_TIME = 0.5 # no more than 2 rps
|
12
|
+
|
13
|
+
RETRY_ATTEMPTS_SLEEP_TIME = [60, 60, 30, 15, 1].freeze
|
14
|
+
RETRY_ATTEMPTS = RETRY_ATTEMPTS_SLEEP_TIME.size
|
15
|
+
RETRY_CODES = [401, 403, 404, 500, 502, 503, 504].freeze
|
16
|
+
|
17
|
+
attr_reader :client
|
18
|
+
|
19
|
+
class << self
|
20
|
+
def allowed_options
|
21
|
+
%i(token region_id)
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
# Public: инициализация ридера
|
26
|
+
#
|
27
|
+
# options - Hash, параметры ридера
|
28
|
+
# :region_id - идентификатор региона (необязательный)
|
29
|
+
# :token - токен для доступа к API Яндекс.Маркета
|
30
|
+
#
|
31
|
+
# Returns an instance of Readers::Base
|
32
|
+
def initialize(options)
|
33
|
+
@region_id = options.fetch(:region_id, DEFAULT_REGION_ID)
|
34
|
+
@client = Api::Client.new(options.fetch(:token))
|
35
|
+
end
|
36
|
+
|
37
|
+
private
|
38
|
+
|
39
|
+
def with_rescue_temporary_errors(attempts = RETRY_ATTEMPTS)
|
40
|
+
yield
|
41
|
+
rescue Api::Error, Timeout::Error => err
|
42
|
+
raise if err.is_a?(Api::Error) && !RETRY_CODES.include?(err.code)
|
43
|
+
|
44
|
+
if (attempts -= 1) > 0
|
45
|
+
sleep RETRY_ATTEMPTS_SLEEP_TIME[attempts]
|
46
|
+
retry
|
47
|
+
else
|
48
|
+
raise
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|
53
|
+
end
|
54
|
+
end
|
@@ -0,0 +1,117 @@
|
|
1
|
+
require 'apress/yandex_market/readers/base'
|
2
|
+
require 'facets/string/squish'
|
3
|
+
|
4
|
+
module Apress
|
5
|
+
module YandexMarket
|
6
|
+
module Readers
|
7
|
+
# Ридер категорий. Читает из API Яндекс.Маркета переданные категории и их подкатегории
|
8
|
+
#
|
9
|
+
# Examples
|
10
|
+
#
|
11
|
+
# reader = Apress::YandexMarket::Readers::Category.new(region_id: 225,
|
12
|
+
# token: 'qwerty',
|
13
|
+
# categories: 'Авто; Дом и дача')
|
14
|
+
# # => #<Apress::YandexMarket::Readers::Category ...>
|
15
|
+
#
|
16
|
+
# reader.each_row |category|
|
17
|
+
# puts category
|
18
|
+
# end
|
19
|
+
# # => {
|
20
|
+
# :id=>90402,
|
21
|
+
# :name=>"Авто",
|
22
|
+
# :fullName=>"Товары для авто- и мототехники",
|
23
|
+
# :link=>
|
24
|
+
# "https://market.yandex.ru/catalog/90402/list?hid=90402&onstock=1&pp=1001&clid=2326601&distr_type=7",
|
25
|
+
# :childCount=>12,
|
26
|
+
# :advertisingModel=>"HYBRID",
|
27
|
+
# :viewType=>"LIST"
|
28
|
+
# }
|
29
|
+
# {
|
30
|
+
# :id=>90403,
|
31
|
+
# ...
|
32
|
+
# }
|
33
|
+
class Category < Base
|
34
|
+
FIELDS = %w(PARENT).join(',').freeze
|
35
|
+
|
36
|
+
class << self
|
37
|
+
def allowed_options
|
38
|
+
super + %i(categories)
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
# Public: инициализация ридера
|
43
|
+
#
|
44
|
+
# options - Hash, параметры ридера
|
45
|
+
# :region_id - идентификатор региона (необязательный)
|
46
|
+
# :token - токен для доступа к API Яндекс.Маркета
|
47
|
+
# :categories - список категорий для загрузки моделей Яндекс.Маркета (разделитель - ';')
|
48
|
+
#
|
49
|
+
# Returns an instance of Readers::Category
|
50
|
+
def initialize(options)
|
51
|
+
super
|
52
|
+
@categories = options.fetch(:categories).split(';').map(&:squish)
|
53
|
+
end
|
54
|
+
|
55
|
+
# Public: читаем категории из API и для каждой выполняем блок
|
56
|
+
#
|
57
|
+
# Returns nothing
|
58
|
+
def each_row
|
59
|
+
root_categories.each do |root_category|
|
60
|
+
yield root_category
|
61
|
+
|
62
|
+
get_children_categories(root_category[:id]) do |category|
|
63
|
+
yield category
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|
67
|
+
|
68
|
+
private
|
69
|
+
|
70
|
+
def get_children_categories(parent_id, &block)
|
71
|
+
page = 1
|
72
|
+
|
73
|
+
loop do
|
74
|
+
sleep(SLEEP_TIME)
|
75
|
+
categories =
|
76
|
+
get_categories("categories/#{parent_id}/children", page)
|
77
|
+
|
78
|
+
page += 1
|
79
|
+
|
80
|
+
categories.each do |category|
|
81
|
+
yield category
|
82
|
+
|
83
|
+
next if category[:childCount].zero?
|
84
|
+
|
85
|
+
get_children_categories(category[:id], &block)
|
86
|
+
end
|
87
|
+
|
88
|
+
break if categories.empty? || categories.count < PAGE_SIZE
|
89
|
+
end
|
90
|
+
end
|
91
|
+
|
92
|
+
def root_categories(page = 1)
|
93
|
+
categories = get_categories('categories', page)
|
94
|
+
categories += root_categories(page + 1) unless categories.size < PAGE_SIZE
|
95
|
+
|
96
|
+
categories.select! { |category| @categories.include?(category.fetch(:name)) }
|
97
|
+
end
|
98
|
+
|
99
|
+
def get_categories(resource, page)
|
100
|
+
with_rescue_temporary_errors do
|
101
|
+
client.
|
102
|
+
get(
|
103
|
+
resource,
|
104
|
+
geo_id: @region_id,
|
105
|
+
sort: 'BY_NAME'.freeze,
|
106
|
+
count: PAGE_SIZE,
|
107
|
+
page: page
|
108
|
+
).
|
109
|
+
fetch(:categories)
|
110
|
+
end
|
111
|
+
rescue Api::PageError
|
112
|
+
[]
|
113
|
+
end
|
114
|
+
end
|
115
|
+
end
|
116
|
+
end
|
117
|
+
end
|