vessel 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +70 -0
  3. data/README.md +418 -34
  4. data/bin/console +16 -0
  5. data/bin/vessel +8 -0
  6. data/lib/vessel/cargo/callbacks.rb +27 -0
  7. data/lib/vessel/cargo/field.rb +41 -0
  8. data/lib/vessel/cargo/field_type.rb +17 -0
  9. data/lib/vessel/cargo/fieldable.rb +27 -0
  10. data/lib/vessel/cargo/fields.rb +54 -0
  11. data/lib/vessel/cargo/settings.rb +113 -0
  12. data/lib/vessel/cargo.rb +38 -78
  13. data/lib/vessel/cli.rb +61 -2
  14. data/lib/vessel/driver/ferrum/driver.rb +43 -0
  15. data/lib/vessel/driver/ferrum/page.rb +64 -0
  16. data/lib/vessel/driver/mechanize/driver.rb +35 -0
  17. data/lib/vessel/driver/mechanize/page.rb +79 -0
  18. data/lib/vessel/driver/page.rb +59 -0
  19. data/lib/vessel/driver/registry.rb +26 -0
  20. data/lib/vessel/driver.rb +133 -0
  21. data/lib/vessel/engine/stats.rb +51 -0
  22. data/lib/vessel/engine.rb +106 -27
  23. data/lib/vessel/loader.rb +36 -0
  24. data/lib/vessel/logger.rb +25 -0
  25. data/lib/vessel/middleware.rb +7 -9
  26. data/lib/vessel/middleware_scheduler.rb +51 -0
  27. data/lib/vessel/proxy.rb +41 -0
  28. data/lib/vessel/request.rb +28 -11
  29. data/lib/vessel/response.rb +106 -0
  30. data/lib/vessel/scheduler.rb +19 -34
  31. data/lib/vessel/skeleton/Gemfile +5 -0
  32. data/lib/vessel/skeleton/config/boot.rb +17 -0
  33. data/lib/vessel/skeleton/config/environments/dev/dev.rb +5 -0
  34. data/lib/vessel/skeleton/config/environments/prod/prod.rb +7 -0
  35. data/lib/vessel/skeleton/config/fields/.keep +0 -0
  36. data/lib/vessel/skeleton/config/middleware/debug.rb +15 -0
  37. data/lib/vessel/skeleton/crawlers/.keep +0 -0
  38. data/lib/vessel/skeleton/lib/helpers/.keep +0 -0
  39. data/lib/vessel/skeleton/lib/loader.rb +4 -0
  40. data/lib/vessel/skeleton/log/.keep +0 -0
  41. data/lib/vessel/templates/template.erb +9 -0
  42. data/lib/vessel/templator.rb +32 -0
  43. data/lib/vessel/util.rb +16 -0
  44. data/lib/vessel/version.rb +1 -1
  45. data/lib/vessel.rb +35 -5
  46. metadata +96 -29
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Vessel
4
+ class Cargo
5
+ class FieldType
6
+ class << self
7
+ def add(*names, &block)
8
+ names.each { |n| types.merge!(n.to_sym => block) }
9
+ end
10
+
11
+ def types
12
+ @types ||= {}
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "vessel/cargo/field"
4
+ require "vessel/cargo/field_type"
5
+ require "vessel/cargo/fields"
6
+
7
+ module Vessel
8
+ class Cargo
9
+ module Fieldable
10
+ def fields
11
+ @fields ||= Fields.new
12
+ end
13
+
14
+ def field(name, *rest, &)
15
+ options = rest.last.is_a?(Hash) ? rest.pop : {}
16
+ service = options.delete(:service)
17
+ field = Field.new(name: name, value: options[:value], context: self, **options, &)
18
+ value = field.apply
19
+ if service
20
+ fields.service[name.to_sym] = value
21
+ else
22
+ fields[field.name] = value
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Vessel
4
+ class Cargo
5
+ class Fields
6
+ attr_reader :service
7
+
8
+ def initialize(data = nil)
9
+ @service = {}
10
+ @fields = data.to_h
11
+ end
12
+
13
+ def []=(key, value)
14
+ @fields[key.to_sym] = value
15
+ end
16
+
17
+ def [](key)
18
+ @fields[key.to_sym]
19
+ end
20
+
21
+ def values_at(*args)
22
+ @fields.values_at(*args)
23
+ end
24
+
25
+ def count
26
+ @fields.count
27
+ end
28
+
29
+ def select(&)
30
+ @fields.send(:select, &)
31
+ end
32
+
33
+ def zip(value)
34
+ @fields.zip(value)
35
+ end
36
+
37
+ def key?(value)
38
+ @fields.key?(value)
39
+ end
40
+
41
+ def keys
42
+ @fields.keys
43
+ end
44
+
45
+ def to_h
46
+ @fields
47
+ end
48
+
49
+ def each(&)
50
+ @fields.each(&)
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "vessel/proxy"
4
+ require "vessel/util"
5
+
6
+ module Vessel
7
+ class Cargo
8
+ module Settings
9
+ include Vessel::Util
10
+
11
+ DELAY = 0
12
+ START_URLS = {}.freeze
13
+ MIDDLEWARE = [].freeze
14
+ MIN_THREADS = 1
15
+ MAX_THREADS = Concurrent.processor_count
16
+ NETWORK_ERROR_ATTEMPTS = 5
17
+
18
+ def name
19
+ settings[:domain]
20
+ end
21
+
22
+ def domain(value)
23
+ settings[:domain] = value
24
+ Vessel.loader.register(name, self)
25
+ end
26
+
27
+ def start_urls(*url_handlers)
28
+ settings[:start_urls] = url_handlers.compact.flatten.inject({}) do |options, url_handler|
29
+ options.merge(url_handler.is_a?(Hash) ? url_handler : { url_handler => Request::DEFAULT_HANDLER })
30
+ end
31
+ end
32
+
33
+ def driver(name, **options)
34
+ settings[:driver_name] = name.to_sym
35
+ settings[:driver_options] = options
36
+ end
37
+
38
+ def delay(value)
39
+ settings[:delay] = value
40
+ end
41
+
42
+ def headers(value)
43
+ settings[:headers] = value
44
+ end
45
+
46
+ def cookie(name:, value:, domain: nil, path: nil, httponly: nil, secure: nil, expires: nil)
47
+ settings[:cookies] ||= []
48
+ domain ||= settings[:domain]
49
+ cookie = { name: name, value: value, domain: domain, path: path,
50
+ httponly: httponly, secure: secure, expires: expires }.compact
51
+ settings[:cookies] << cookie
52
+ end
53
+
54
+ def cookies(*value)
55
+ value.flatten.each { |c| cookie(**c) }
56
+ end
57
+
58
+ def threads(min: MIN_THREADS, max: MAX_THREADS)
59
+ settings[:min_threads] = min
60
+ settings[:max_threads] = max
61
+ end
62
+
63
+ def middleware(*classes)
64
+ settings[:middleware] = classes
65
+ end
66
+
67
+ def proxy(klass)
68
+ settings[:proxy] = klass
69
+ end
70
+
71
+ def blacklist(patterns)
72
+ settings[:blacklist] = patterns
73
+ end
74
+
75
+ def whitelist(patterns)
76
+ settings[:whitelist] = patterns
77
+ end
78
+
79
+ def network_error_attempts(value)
80
+ settings[:network_error_attempts] = value.to_i
81
+ end
82
+
83
+ def allow_cookies(value)
84
+ settings[:allow_cookies] = value
85
+ end
86
+
87
+ def settings
88
+ @settings ||= if superclass.respond_to?(:settings)
89
+ deep_clone(superclass.settings)
90
+ else
91
+ {
92
+ delay: DELAY, start_urls: START_URLS, middleware: MIDDLEWARE,
93
+ min_threads: MIN_THREADS, max_threads: MAX_THREADS,
94
+ driver_name: :ferrum, driver_options: {}, headers: nil,
95
+ cookies: nil, allow_cookies: true, proxy: nil,
96
+ blacklist: nil, whitelist: nil, domain: nil,
97
+ network_error_attempts: NETWORK_ERROR_ATTEMPTS
98
+ }
99
+ end
100
+ end
101
+
102
+ def to_domain_name(klass)
103
+ # name&.split("::")&.last&.downcase
104
+ klass.name.gsub(/[a-zA-Z](?=[A-Z])/, '\0 ').downcase.split.map do |value|
105
+ next "." if value == "dot"
106
+ next "-" if value == "dash"
107
+
108
+ value
109
+ end.join
110
+ end
111
+ end
112
+ end
113
+ end
data/lib/vessel/cargo.rb CHANGED
@@ -1,87 +1,52 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "ferrum"
4
3
  require "forwardable"
4
+ require "vessel/cargo/fieldable"
5
+ require "vessel/cargo/settings"
6
+ require "vessel/cargo/callbacks"
5
7
 
6
8
  module Vessel
7
9
  class Cargo
8
- DELAY = 0
9
- START_URLS = [].freeze
10
- MIDDLEWARE = [].freeze
11
- MIN_THREADS = 1
12
- MAX_THREADS = Concurrent.processor_count
13
-
14
- class << self
15
- attr_reader :settings
16
-
17
- def run(settings = nil, &block)
18
- self.settings.merge!(Hash(settings))
19
- Engine.run(self, &block)
20
- end
21
-
22
- def domain(name)
23
- settings[:domain] = name
24
- end
25
-
26
- def start_urls(*urls)
27
- settings[:start_urls] = urls.flatten
28
- end
29
-
30
- def delay(value)
31
- settings[:delay] = value
32
- end
33
-
34
- def timeout(value)
35
- settings[:timeout] = value
36
- end
37
-
38
- def headers(value)
39
- settings[:headers] = value
40
- end
41
-
42
- def threads(min: MIN_THREADS, max: MAX_THREADS)
43
- settings[:min_threads] = min
44
- settings[:max_threads] = max
45
- end
10
+ def self.run(settings = nil, &)
11
+ self.settings.merge!(Hash(settings))
12
+ Engine.run(self, &)
13
+ end
46
14
 
47
- def middleware(*classes)
48
- settings[:middleware] = classes
49
- end
15
+ def self.build_request(url:, **options)
16
+ Request.new(url: url,
17
+ delay: settings[:delay],
18
+ cookies: settings[:cookies],
19
+ headers: settings[:headers],
20
+ **options)
21
+ end
50
22
 
51
- def ferrum(**options)
52
- settings[:ferrum] = options
53
- end
23
+ def self.start_requests
24
+ return build_request(url: nil) if settings[:start_urls].empty?
54
25
 
55
- def intercept(&block)
56
- settings[:intercept] = block
57
- end
26
+ settings[:start_urls].map { |u, h| build_request(url: u, handler: h) }
27
+ end
58
28
 
59
- def settings
60
- @settings ||= {
61
- delay: DELAY,
62
- middleware: MIDDLEWARE,
63
- start_urls: START_URLS,
64
- min_threads: MIN_THREADS,
65
- max_threads: MAX_THREADS,
66
- ferrum: Hash.new,
67
- intercept: nil,
68
- headers: nil,
69
- domain: name&.split('::')&.last&.downcase
70
- }
71
- end
29
+ def self.store_cookies(cookies)
30
+ settings[:cookies] = cookies
72
31
  end
73
32
 
33
+ include Callbacks
34
+ include Fieldable
35
+ extend Settings
74
36
  extend Forwardable
75
- delegate %i[at_css css at_xpath xpath] => :page
76
37
 
77
- attr_reader :page
38
+ delegate %i[at_css css at_xpath xpath absolute_url join_url
39
+ url url_encode url_decode uri_encode uri_decode
40
+ data attempt body raw] => :response
78
41
 
79
- def initialize(page = nil)
80
- @page = page
81
- end
42
+ attr_reader :settings, :domain, :response, :page, :size
82
43
 
83
- def domain
84
- self.class.settings[:domain]
44
+ def initialize(response = nil)
45
+ @settings = self.class.settings
46
+ @domain = settings[:domain]
47
+ @response = response
48
+ @page = response&.page
49
+ @size = response&.size
85
50
  end
86
51
 
87
52
  def parse
@@ -90,16 +55,11 @@ module Vessel
90
55
 
91
56
  private
92
57
 
93
- def request(**options)
94
- Request.new(**options)
95
- end
96
-
97
- def absolute_url(relative)
98
- Addressable::URI.join(page.current_url, relative).to_s
99
- end
100
-
101
- def current_url
102
- Addressable::URI.parse(page.current_url)
58
+ def request(url:, encode: true, **options)
59
+ url = absolute_url(url, encode: encode)
60
+ self.class.build_request(url: url, **options)
103
61
  end
104
62
  end
63
+
64
+ Crawler = Cargo
105
65
  end
data/lib/vessel/cli.rb CHANGED
@@ -1,3 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "pp"
1
5
  require "thor"
2
6
 
3
7
  module Vessel
@@ -7,9 +11,64 @@ module Vessel
7
11
  puts Vessel::VERSION
8
12
  end
9
13
 
10
- desc "start NAME", "Run given crawler."
14
+ desc "list", "List all available crawlers of current project."
15
+ def list
16
+ Vessel.boot
17
+ Vessel.loader.load_crawlers
18
+ puts Vessel.loader.list_crawlers
19
+ end
20
+
21
+ desc "settings NAME", "Get the settings for given name."
22
+ def settings(name)
23
+ crawler = find_crawler(name)
24
+ settings = crawler.settings
25
+ puts "Delay: #{settings[:delay]}"
26
+ puts "Allow cookies: #{settings[:allow_cookies]}"
27
+ puts "Cookies: #{settings[:cookies]}"
28
+ puts "Middleware: #{settings[:middleware]}"
29
+ puts "Start urls:\n"
30
+ PP.pp(settings[:start_urls])
31
+ end
32
+
33
+ desc "start NAME", "Start crawling given name."
11
34
  def start(name)
12
- raise NotImplementedError
35
+ crawler = find_crawler(name)
36
+ engine = Engine.new(crawler)
37
+ engine.run
38
+ end
39
+
40
+ desc "parse NAME URL CALLBACK --data=key:value", "Parse given url and call a callback with response."
41
+ method_option :data, type: :hash, default: {}, required: false
42
+ def parse(name, url, callback)
43
+ crawler = find_crawler(name)
44
+ engine = Engine.new(crawler)
45
+ data = options[:data].transform_keys(&:to_sym)
46
+ engine.parse(url, callback, data)
47
+ end
48
+
49
+ desc "new NAME", "Generate new project."
50
+ def new(name)
51
+ templates = File.expand_path("skeleton", __dir__)
52
+ FileUtils.cp_r(templates, "./#{name}")
53
+ puts "Project #{name} generated"
54
+ end
55
+
56
+ desc "generate NAME OPTIONS", "Generate crawler skeleton with given name and parameters."
57
+ def generate(name, *options)
58
+ require "erb"
59
+ Vessel.boot
60
+ templator = Vessel.loader.templator
61
+ templator.new(name, options).build
62
+ end
63
+
64
+ private
65
+
66
+ def find_crawler(name)
67
+ Vessel.boot
68
+ Vessel.loader.load_crawler(name)
69
+ crawler = Vessel.loader.find_crawler(name)
70
+ abort "Crawler not found" unless crawler
71
+ crawler
13
72
  end
14
73
  end
15
74
  end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ferrum"
4
+ require "vessel/driver/ferrum/page"
5
+
6
+ module Vessel
7
+ class Driver
8
+ module Ferrum
9
+ class Driver < ::Vessel::Driver
10
+ DEFAULT_OPTIONS = {
11
+ timeout: 60,
12
+ js_errors: false,
13
+ process_timeout: 30,
14
+ pending_connection_errors: false,
15
+ browser_options: { "ignore-certificate-errors" => nil }
16
+ }.freeze
17
+
18
+ def self.direct_network_errors
19
+ @direct_network_errors ||= (super + [::Ferrum::TimeoutError, ::Ferrum::StatusError]).freeze
20
+ end
21
+
22
+ driver_name :ferrum
23
+
24
+ def start
25
+ driver_options = settings.fetch(:driver_options, {})
26
+ @browser = ::Ferrum::Browser.new(**DEFAULT_OPTIONS, **driver_options)
27
+ end
28
+
29
+ def stop
30
+ browser&.quit
31
+ @browser = nil
32
+ end
33
+
34
+ def create_page(proxy: nil)
35
+ options = {}
36
+ options.merge!(proxy: proxy) if proxy
37
+ page = browser.create_page(**options)
38
+ Page.new(page, context: browser.contexts[page.context_id])
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "forwardable"
4
+
5
+ module Vessel
6
+ class Driver
7
+ module Ferrum
8
+ class Page < Page
9
+ extend Forwardable
10
+
11
+ delegate %i[css at_css xpath at_xpath body current_url go_to] => :page
12
+
13
+ attr_reader :page
14
+
15
+ def initialize(page, context:)
16
+ super()
17
+ @page = page
18
+ @context = context
19
+ end
20
+
21
+ def close
22
+ @context.dispose if page.use_proxy?
23
+ page.close
24
+ end
25
+
26
+ def headers=(headers)
27
+ return unless headers
28
+
29
+ page.headers.set(headers)
30
+ end
31
+
32
+ def headers
33
+ page.headers.get
34
+ end
35
+
36
+ def cookies=(cookies)
37
+ cookies.each { |c| page.cookies.set(c) }
38
+ end
39
+
40
+ def cookies
41
+ page.cookies.all.values
42
+ end
43
+
44
+ def status
45
+ page.network.status
46
+ end
47
+
48
+ def size
49
+ page.network.traffic.map do |req|
50
+ [req.response&.body_size, req.response&.headers_size]
51
+ end.flatten.compact.inject(&:+)
52
+ end
53
+
54
+ def blacklist=(patterns)
55
+ page.network.blacklist = patterns
56
+ end
57
+
58
+ def whitelist=(patterns)
59
+ page.network.whitelist = patterns
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "mechanize"
4
+ require "vessel/driver/mechanize/page"
5
+
6
+ module Vessel
7
+ class Driver
8
+ module Mechanize
9
+ class Driver < ::Vessel::Driver
10
+ def self.indirect_network_errors
11
+ @indirect_network_errors ||= (super + [::Mechanize::ResponseCodeError]).freeze
12
+ end
13
+
14
+ driver_name :mechanize
15
+
16
+ def start
17
+ # Nothing to start here
18
+ end
19
+
20
+ def stop
21
+ # Nothing to stop here
22
+ end
23
+
24
+ def create_page(proxy: nil)
25
+ mechanize = ::Mechanize.new do |m|
26
+ m.user_agent = settings.dig(:headers, "User-Agent")
27
+ m.set_proxy(proxy[:host], proxy[:port], proxy[:user], proxy[:password]) if proxy
28
+ end
29
+
30
+ Page.new(mechanize)
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "forwardable"
4
+
5
+ module Vessel
6
+ class Driver
7
+ module Mechanize
8
+ class Page < Page
9
+ extend Forwardable
10
+
11
+ delegate %i[css at_css xpath at_xpath body] => :page
12
+
13
+ attr_reader :mechanize
14
+
15
+ def initialize(mechanize)
16
+ super()
17
+ @mechanize = mechanize
18
+ end
19
+
20
+ def close
21
+ mechanize.shutdown
22
+ @mechanize = nil
23
+ end
24
+
25
+ def go_to(url)
26
+ mechanize.get(url)
27
+ end
28
+
29
+ def headers=(headers)
30
+ mechanize.request_headers = headers
31
+ end
32
+
33
+ def headers
34
+ mechanize.request_headers
35
+ end
36
+
37
+ def cookies=(cookies)
38
+ cookies.each { |c| mechanize.cookie_jar << ::Mechanize::Cookie.new(c) }
39
+ end
40
+
41
+ def cookies
42
+ mechanize.cookies
43
+ end
44
+
45
+ def status
46
+ page.code.to_i
47
+ end
48
+
49
+ def size
50
+ page.content.bytesize
51
+ end
52
+
53
+ def current_url
54
+ page&.uri.to_s
55
+ end
56
+
57
+ # rubocop:disable all
58
+ def blacklist=(*)
59
+ @@_blacklist_warning ||= begin
60
+ warn "blacklist is not supported by mechanize driver"
61
+ true
62
+ end
63
+ end
64
+
65
+ def whitelist=(*)
66
+ @@_whitelist_warning ||= begin
67
+ warn "whitelist is not supported by mechanize driver"
68
+ true
69
+ end
70
+ end
71
+ # rubocop:enable all
72
+
73
+ def page
74
+ mechanize.current_page
75
+ end
76
+ end
77
+ end
78
+ end
79
+ end