apress-yandex_market 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (36) hide show
  1. checksums.yaml +7 -0
  2. data/.drone.yml +30 -0
  3. data/.gitignore +14 -0
  4. data/.rspec +3 -0
  5. data/CHANGELOG.md +9 -0
  6. data/Gemfile +3 -0
  7. data/README.md +3 -0
  8. data/Rakefile +7 -0
  9. data/apress-yandexmarket.gemspec +36 -0
  10. data/dip.yml +41 -0
  11. data/docker-compose.development.yml +13 -0
  12. data/docker-compose.drone.yml +7 -0
  13. data/docker-compose.yml +9 -0
  14. data/lib/apress/yandex_market/api/client.rb +79 -0
  15. data/lib/apress/yandex_market/api/error.rb +19 -0
  16. data/lib/apress/yandex_market/api/page_error.rb +9 -0
  17. data/lib/apress/yandex_market/api.rb +9 -0
  18. data/lib/apress/yandex_market/presenters/base.rb +49 -0
  19. data/lib/apress/yandex_market/presenters/model.rb +19 -0
  20. data/lib/apress/yandex_market/presenters.rb +8 -0
  21. data/lib/apress/yandex_market/readers/base.rb +54 -0
  22. data/lib/apress/yandex_market/readers/category.rb +117 -0
  23. data/lib/apress/yandex_market/readers/model_by_category.rb +145 -0
  24. data/lib/apress/yandex_market/readers.rb +8 -0
  25. data/lib/apress/yandex_market/version.rb +5 -0
  26. data/lib/apress/yandex_market.rb +9 -0
  27. data/spec/apress/yandex_market/api/client_spec.rb +46 -0
  28. data/spec/apress/yandex_market/readers/category_spec.rb +46 -0
  29. data/spec/apress/yandex_market/readers/model_by_category_spec.rb +29 -0
  30. data/spec/fixtures/get_category_with_invalid_id.yml +81 -0
  31. data/spec/fixtures/get_root_categories.yml +82 -0
  32. data/spec/fixtures/read_models_from_category.yml +29960 -0
  33. data/spec/fixtures/read_two_categories.yml +3600 -0
  34. data/spec/fixtures/read_two_categories_with_page_error.yml +1983 -0
  35. data/spec/spec_helper.rb +27 -0
  36. 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
@@ -0,0 +1,14 @@
1
+ /.bundle/
2
+ /Gemfile.lock
3
+ /coverage/
4
+ /doc/
5
+ /pkg/
6
+ /tmp/
7
+ *.bundle
8
+ *.so
9
+ *.o
10
+ *.a
11
+ mkmf.log
12
+ /.rvmrc
13
+ /.idea/
14
+ gemfiles/
data/.rspec ADDED
@@ -0,0 +1,3 @@
1
+ --color
2
+ --format progress
3
+ --order random
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
@@ -0,0 +1,3 @@
1
+ source 'https://rubygems.org'
2
+
3
+ gemspec
data/README.md ADDED
@@ -0,0 +1,3 @@
1
+ # Apress::YandexMarket
2
+
3
+ Библиотека для работы с Яндекс.Маркет
data/Rakefile ADDED
@@ -0,0 +1,7 @@
1
+ require 'rubygems'
2
+ require 'bundler/setup'
3
+
4
+ require 'rspec/core/rake_task'
5
+
6
+ # setup `spec` task
7
+ RSpec::Core::RakeTask.new(:spec)
@@ -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
@@ -0,0 +1,13 @@
1
+ version: '2'
2
+
3
+ services:
4
+ app:
5
+ volumes:
6
+ - .:/app
7
+ - ../:/localgems
8
+ - bundler-data:/bundle
9
+
10
+ volumes:
11
+ bundler-data:
12
+ external:
13
+ name: bundler_data
@@ -0,0 +1,7 @@
1
+ version: '2'
2
+
3
+ services:
4
+ app:
5
+ volumes:
6
+ - .:/app
7
+ - /bundle:/bundle
@@ -0,0 +1,9 @@
1
+ version: '2'
2
+
3
+ services:
4
+ app:
5
+ image: abakpress/ruby-app:$RUBY_IMAGE_TAG
6
+ environment:
7
+ - BUNDLE_PATH=/bundle/$DOCKER_RUBY_VERSION
8
+ - BUNDLE_CONFIG=/app/.bundle/config
9
+ command: bash
@@ -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,9 @@
1
+ module Apress
2
+ module YandexMarket
3
+ module Api
4
+ class PageError < StandardError
5
+ MSG = "Parameter 'page' has invalid value. Parameter does not fit range constraint".freeze
6
+ end
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,9 @@
1
+ module Apress
2
+ module YandexMarket
3
+ module Api
4
+ autoload :Client, 'apress/yandex_market/api/client'
5
+ autoload :Error, 'apress/yandex_market/api/error'
6
+ autoload :PageError, 'apress/yandex_market/api/page_error'
7
+ end
8
+ end
9
+ 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,8 @@
1
+ module Apress
2
+ module YandexMarket
3
+ module Presenters
4
+ autoload :Base, 'apress/yandex_market/presenters/base'
5
+ autoload :Model, 'apress/yandex_market/presenters/model'
6
+ end
7
+ end
8
+ 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