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,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Vessel
4
+ class Driver
5
+ class Page
6
+ def close
7
+ raise NotImplementedError
8
+ end
9
+
10
+ def go_to(_)
11
+ raise NotImplementedError
12
+ end
13
+
14
+ def headers=(_)
15
+ raise NotImplementedError
16
+ end
17
+
18
+ def headers
19
+ raise NotImplementedError
20
+ end
21
+
22
+ def cookies=(_)
23
+ raise NotImplementedError
24
+ end
25
+
26
+ def cookies
27
+ raise NotImplementedError
28
+ end
29
+
30
+ def status
31
+ raise NotImplementedError
32
+ end
33
+
34
+ def body
35
+ raise NotImplementedError
36
+ end
37
+
38
+ def size
39
+ raise NotImplementedError
40
+ end
41
+
42
+ def current_url
43
+ raise NotImplementedError
44
+ end
45
+
46
+ def blacklist=(_)
47
+ raise NotImplementedError
48
+ end
49
+
50
+ def whitelist=(_)
51
+ raise NotImplementedError
52
+ end
53
+
54
+ def full_response
55
+ [status, headers, body]
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "singleton"
4
+
5
+ module Vessel
6
+ class Driver
7
+ class Registry
8
+ include Singleton
9
+
10
+ attr_reader :drivers
11
+
12
+ def initialize
13
+ @drivers = {}
14
+ end
15
+
16
+ def register(name, klass)
17
+ @drivers[name.to_sym] = klass
18
+ end
19
+
20
+ def build(settings = nil)
21
+ name = settings[:driver_name].to_sym
22
+ @drivers.fetch(name.to_sym).new(settings)
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "vessel/driver/page"
4
+ require "vessel/driver/registry"
5
+
6
+ module Vessel
7
+ class Driver
8
+ def self.driver_name(name)
9
+ Registry.instance.register(name, self)
10
+ end
11
+
12
+ def self.direct_network_errors
13
+ [
14
+ ::Net::HTTP::Persistent::Error,
15
+ ::Net::HTTPFatalError,
16
+ ::Net::ReadTimeout,
17
+ ::Net::OpenTimeout,
18
+ ::SocketError,
19
+ ::Errno::ECONNRESET
20
+ ].freeze
21
+ end
22
+
23
+ def self.indirect_network_errors
24
+ [].freeze
25
+ end
26
+
27
+ attr_reader :browser, :settings, :proxy
28
+
29
+ def initialize(settings)
30
+ @last_request = nil
31
+ @settings = settings
32
+ @visited_urls = Concurrent::Map.new { 0 }
33
+ @proxy = settings[:proxy]&.new
34
+ start
35
+ at_exit { stop }
36
+ end
37
+
38
+ def start
39
+ raise NotImplementedError
40
+ end
41
+
42
+ def stop
43
+ raise NotImplementedError
44
+ end
45
+
46
+ def create_page(proxy: nil)
47
+ raise NotImplementedError
48
+ end
49
+
50
+ def restart
51
+ stop
52
+ start
53
+ end
54
+
55
+ def go_to(request)
56
+ url = request.url.to_s
57
+ response = Response.new(**request.to_handler)
58
+ return [response, request] if request.stub?
59
+
60
+ # The url is registered before the page is created, otherwise threads
61
+ # racing for the same url all pass the check and visit it in parallel.
62
+ attempt = visit(url)
63
+ if request.once && attempt > 1
64
+ Logger.info("Driver: rejecting #{url} visited #{attempt - 1} time(s)")
65
+ response.rejected = true
66
+ return [response, request]
67
+ end
68
+
69
+ begin
70
+ page = prepare_page(request)
71
+ delay(request.delay)
72
+ Logger.info("Driver: visiting #{url}, last_request = #{@last_request.to_i}")
73
+ page.go_to(url)
74
+ @last_request = Time.now.to_i
75
+ response = Response.new(page: page, attempt: attempt, **request.to_handler)
76
+ [response, request]
77
+ rescue StandardError => e
78
+ if network_error?(e)
79
+ Logger.error("Driver: network issue for #{url}, attempt ##{attempt}")
80
+ Logger.error("Driver: #{e.class}: #{e.message}")
81
+ if attempt < settings[:network_error_attempts]
82
+ attempt = visit(url)
83
+ restart
84
+ retry
85
+ end
86
+ end
87
+
88
+ [response, request, e]
89
+ end
90
+ end
91
+
92
+ private
93
+
94
+ def visit(url)
95
+ @visited_urls.compute(url) { |v| v.to_i + 1 }
96
+ end
97
+
98
+ def proxy_options
99
+ options = proxy&.next
100
+ Logger.debug("Driver: setting proxy #{options[:host]}:#{options[:port]}") if options
101
+ options
102
+ end
103
+
104
+ def delay(delay)
105
+ # Delay is set between requests when we don't want to bombard server with them.
106
+ # So it requires a crawler to be single threaded. Otherwise it doesn't make sense.
107
+ return if settings[:max_threads] > 1
108
+ return unless @last_request
109
+
110
+ begin
111
+ interval = @last_request + delay - Time.now.to_i
112
+ raise if interval.positive?
113
+ rescue RuntimeError
114
+ sleep(interval)
115
+ retry
116
+ end
117
+ end
118
+
119
+ def prepare_page(request)
120
+ page = create_page(proxy: proxy_options)
121
+ page.blacklist = settings[:blacklist] if settings[:blacklist]
122
+ page.whitelist = settings[:whitelist] if settings[:whitelist]
123
+ page.headers = request.headers if request.headers
124
+ page.cookies = request.cookies if request.cookies
125
+ page
126
+ end
127
+
128
+ def network_error?(error)
129
+ self.class.direct_network_errors.include?(error.class) ||
130
+ self.class.indirect_network_errors.include?(error.cause.class)
131
+ end
132
+ end
133
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Vessel
4
+ class Engine
5
+ module Stats
6
+ private
7
+
8
+ def stats
9
+ {
10
+ req_enqueued: req_enqueued.value, res_dequeued: res_dequeued.value,
11
+ res_handled: res_handled.value, item_pipelined: item_pipelined.value,
12
+ item_processed: item_processed.value, item_sent: item_sent.value,
13
+ item_rejected: item_rejected.value, idling: idle?
14
+ }
15
+ end
16
+
17
+ def increase(name)
18
+ send(name).increment
19
+ @crawler_class.new.after_change(name, stats)
20
+ end
21
+
22
+ def req_enqueued
23
+ @req_enqueued ||= Concurrent::AtomicFixnum.new
24
+ end
25
+
26
+ def res_dequeued
27
+ @res_dequeued ||= Concurrent::AtomicFixnum.new
28
+ end
29
+
30
+ def res_handled
31
+ @res_handled ||= Concurrent::AtomicFixnum.new
32
+ end
33
+
34
+ def item_pipelined
35
+ @item_pipelined ||= Concurrent::AtomicFixnum.new
36
+ end
37
+
38
+ def item_processed
39
+ @item_processed ||= Concurrent::AtomicFixnum.new
40
+ end
41
+
42
+ def item_sent
43
+ @item_sent ||= Concurrent::AtomicFixnum.new
44
+ end
45
+
46
+ def item_rejected
47
+ @item_rejected ||= Concurrent::AtomicFixnum.new
48
+ end
49
+ end
50
+ end
51
+ end
data/lib/vessel/engine.rb CHANGED
@@ -1,63 +1,142 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "vessel/engine/stats"
4
+
3
5
  module Vessel
4
6
  class Engine
5
- def self.run(*args, &block)
6
- new(*args, &block).tap(&:run)
7
+ include Stats
8
+
9
+ def self.run(...)
10
+ new(...).tap(&:run)
7
11
  end
8
12
 
9
- attr_reader :crawler_class, :settings, :scheduler, :middleware
13
+ attr_reader :crawler_class, :settings, :scheduler, :middleware_scheduler
10
14
 
11
- def initialize(klass, &block)
15
+ def initialize(klass, &)
12
16
  @crawler_class = klass
13
17
  @settings = klass.settings
14
- @middleware = block || Middleware.build(*settings[:middleware])
15
18
  @queue = SizedQueue.new(settings[:max_threads])
16
19
  @scheduler = Scheduler.new(@queue, settings)
20
+ @middleware_scheduler = MiddlewareScheduler.new(settings, &)
17
21
  end
18
22
 
19
23
  def run
20
- scheduler.post(*start_requests)
21
-
22
- until @queue.closed?
23
- message = @queue.pop
24
-
25
- raise(message) if message.is_a?(Exception)
26
-
27
- page, request = message
28
- args = [request.method, request.data].compact
29
- handle(page, args)
24
+ at_exit do
25
+ crawler_class.new.info(stats)
26
+ crawler_class.new.before_stop
27
+ end
30
28
 
31
- @queue.close if idle?
29
+ trap_sigint
30
+ crawler_class.new.before_start
31
+ crawler_class.new.before(stats)
32
+ @info_task = Concurrent::TimerTask.execute(execution_interval: crawler_class::INFO_INTERVAL) do
33
+ crawler_class.new.info(stats)
32
34
  end
35
+ schedule(crawler_class.start_requests)
36
+ event_loop
37
+ ensure
38
+ @info_task&.shutdown
39
+ scheduler.stop
40
+ middleware_scheduler.stop
41
+ crawler_class.new.after(stats)
42
+ end
33
43
 
44
+ def parse(url, handler, data = nil)
45
+ trap_sigint
46
+ request = crawler_class.build_request(url: url, handler: handler, data: data)
47
+ schedule(request)
48
+ event_loop
34
49
  ensure
35
50
  scheduler.stop
51
+ middleware_scheduler.stop
36
52
  end
37
53
 
38
- def handle(page, args)
39
- crawler = @crawler_class.new(page)
54
+ def handle(response, request, error)
55
+ increase(:res_dequeued)
56
+ return if response.rejected
57
+
58
+ crawler = crawler_class.new(response)
59
+ crawler_class.store_cookies(response.cookies) if settings[:allow_cookies]
60
+ args = error ? [:on_error, request, error] : [request.handler].compact
61
+ Logger.debug("Engine: crawler starts processing #{response.url} with :#{response.handler}")
40
62
  crawler.send(*args) do |*result|
41
- if result.flatten.all? { |i| i.is_a?(Request) }
42
- scheduler.post(*result.flatten)
63
+ result = result.flatten
64
+ if result.all?(Request)
65
+ Logger.debug("Engine: :#{response.handler} enqueued requests #{result.map { |r| r.url.to_s }}")
66
+ schedule(result)
43
67
  else
44
- @middleware&.call(*result)
68
+ result.each { |fields| process(fields) }
45
69
  end
46
70
  end
71
+ Logger.debug("Engine: crawler stopped processing #{response.url}")
72
+ rescue StandardError => e
73
+ Logger.error("Engine: there was an issue while processing #{response.url}")
74
+ Logger.error("Engine: `#{e.class}: #{e.message}`\n#{e.backtrace.join("\n")}")
47
75
  ensure
48
- page.close if page
76
+ increase(:res_handled)
77
+ response.close
49
78
  end
50
79
 
51
80
  private
52
81
 
53
- def start_requests
54
- Request.build(*settings[:start_urls])
82
+ def engine_idle?
83
+ @queue.empty? &&
84
+ req_enqueued.value == res_dequeued.value &&
85
+ res_dequeued.value == res_handled.value &&
86
+ item_pipelined.value == item_processed.value &&
87
+ item_processed.value == (item_sent.value + item_rejected.value)
88
+ end
89
+
90
+ def engine_and_scheduler_idle?
91
+ engine_idle? && @scheduler.idle?
55
92
  end
56
93
 
57
94
  def idle?
58
- @queue.empty? &&
59
- @scheduler.queue_length.zero? &&
60
- @scheduler.scheduled_task_count == @scheduler.completed_task_count
95
+ engine_and_scheduler_idle? && middleware_scheduler.idle?
96
+ end
97
+
98
+ def schedule(*requests)
99
+ requests.flatten.size.times { increase(:req_enqueued) }
100
+ scheduler.post(*requests)
101
+ end
102
+
103
+ def process(fields)
104
+ increase(:item_pipelined)
105
+ middleware_scheduler&.call(fields)&.add_observer(self, :on_item_processed)
106
+ end
107
+
108
+ def on_item_processed(_time, value, error)
109
+ increase(:item_processed)
110
+ increase(:item_sent) if value && !error
111
+ increase(:item_rejected) if error
112
+
113
+ if error && !error.is_a?(Middleware::InvalidItemError)
114
+ Logger.error("Engine: issue `#{error.class}: #{error.message}` raised in the middleware")
115
+ Logger.error("Engine: \n#{error.backtrace.join("\n")}")
116
+ end
117
+
118
+ @queue.close if engine_and_scheduler_idle? && middleware_scheduler.idle?(after: true)
119
+ end
120
+
121
+ def trap_sigint
122
+ trap "SIGINT" do
123
+ puts "\n\nEngine: terminating...\n\n"
124
+ @queue.clear
125
+ @queue.close
126
+ scheduler.force_kill
127
+ middleware_scheduler.force_kill
128
+ end
129
+ end
130
+
131
+ def event_loop
132
+ until @queue.closed?
133
+ message = @queue.pop
134
+ break unless message
135
+
136
+ response, request, error = message
137
+ handle(response, request, error)
138
+ @queue.close if idle?
139
+ end
61
140
  end
62
141
  end
63
142
  end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Vessel
4
+ class Loader
5
+ attr_reader :crawlers
6
+
7
+ def initialize
8
+ @crawlers = {}
9
+ end
10
+
11
+ def templator
12
+ Templator
13
+ end
14
+
15
+ def load_crawlers
16
+ Dir["crawlers/**/*.rb"].each { |file| require(file) }
17
+ end
18
+
19
+ def load_crawler(name)
20
+ path = File.expand_path("crawlers/#{name}.rb")
21
+ require(path) if File.exist?(path)
22
+ end
23
+
24
+ def register(name, klass)
25
+ @crawlers.merge!(name => klass)
26
+ end
27
+
28
+ def find_crawler(name)
29
+ @crawlers[name]
30
+ end
31
+
32
+ def list_crawlers
33
+ @crawlers.keys.sort
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "logger"
4
+ require "forwardable"
5
+
6
+ module Vessel
7
+ class Logger
8
+ class << self
9
+ extend Forwardable
10
+
11
+ delegate %i[debug info warn error] => :instance
12
+
13
+ attr_writer :instance
14
+
15
+ def instance
16
+ @instance ||= begin
17
+ $stdout.sync = true
18
+ instance = ::Logger.new($stdout)
19
+ instance.level = ::Logger::DEBUG
20
+ instance
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -2,22 +2,20 @@
2
2
 
3
3
  module Vessel
4
4
  class Middleware
5
- attr_reader :middleware
5
+ class InvalidItemError < RuntimeError; end
6
6
 
7
- def self.build(*classes)
8
- classes.inject { |base, klass| base.new(klass.new) }
7
+ attr_reader :settings
8
+
9
+ def initialize(settings)
10
+ @settings = settings
9
11
  end
10
12
 
11
- def initialize(middleware = nil)
12
- @middleware = middleware
13
+ def call
14
+ raise NotImplementedError
13
15
  end
14
16
 
15
17
  def ==(other)
16
18
  self.class == other.class
17
19
  end
18
-
19
- def call
20
- raise NotImplementedError
21
- end
22
20
  end
23
21
  end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "concurrent"
4
+ require "forwardable"
5
+
6
+ module Vessel
7
+ class MiddlewareScheduler
8
+ extend Forwardable
9
+
10
+ delegate %i[scheduled_task_count completed_task_count queue_length] => :pool
11
+
12
+ attr_reader :settings, :middlewares
13
+
14
+ def initialize(settings, &block)
15
+ @settings = settings
16
+ @middlewares = if block_given?
17
+ [block]
18
+ else
19
+ settings[:middleware].map { |m| Object.const_get(m).new(settings) }
20
+ end
21
+ end
22
+
23
+ def call(fields)
24
+ Concurrent::Future.execute(executor: pool) do
25
+ middlewares.inject(fields.to_h) { |hash, middleware| middleware.call(hash, fields) }
26
+ end
27
+ end
28
+
29
+ def pool
30
+ @pool ||= Concurrent::ThreadPoolExecutor.new(
31
+ min_threads: settings[:min_threads],
32
+ max_threads: settings[:max_threads],
33
+ max_queue: 0
34
+ )
35
+ end
36
+
37
+ def idle?(after: false)
38
+ completed_tasks = completed_task_count + (after ? 1 : 0)
39
+ queue_length.zero? && scheduled_task_count == completed_tasks
40
+ end
41
+
42
+ def stop
43
+ pool.shutdown
44
+ pool.kill unless pool.wait_for_termination(30)
45
+ end
46
+
47
+ def force_kill
48
+ pool.send(:ns_kill_execution)
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Vessel
4
+ class Proxy
5
+ attr_reader :proxies
6
+
7
+ def next
8
+ @index ||= 0
9
+ @prepared ||= prepare
10
+ return if proxies.nil? || proxies.empty?
11
+
12
+ settings = proxies[@index % proxies.size]
13
+ @index += 1
14
+ settings
15
+ end
16
+
17
+ def prepare
18
+ raise NotImplementedError
19
+ end
20
+ end
21
+
22
+ class RoundRobinProxy < Proxy
23
+ PROXIES = [].freeze
24
+
25
+ def initialize
26
+ @proxies = self.class::PROXIES
27
+ super
28
+ end
29
+
30
+ def prepare
31
+ true
32
+ end
33
+ end
34
+
35
+ class ShuffledProxy < RoundRobinProxy
36
+ def prepare
37
+ @proxies = @proxies.shuffle
38
+ true
39
+ end
40
+ end
41
+ end
@@ -1,27 +1,44 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "addressable/uri"
4
+ require "vessel/util"
4
5
 
5
6
  module Vessel
6
7
  class Request
7
- attr_reader :url, :uri, :method, :data
8
+ include Vessel::Util
8
9
 
9
- def self.build(*urls)
10
- urls.empty? ? [new] : urls.map { |url| new(url: url) }
11
- end
10
+ DEFAULT_HANDLER = :parse
12
11
 
13
- def initialize(url: nil, method: :parse, data: nil)
14
- if url
15
- @url = url.to_s
16
- @uri = Addressable::URI.parse(@url)
17
- end
12
+ attr_reader :url, :handler, :data, :delay, :cookies, :headers, :once
18
13
 
19
- @method = method
20
- @data = data.freeze if data
14
+ def initialize(url: nil, callback: nil, handler: nil, delay: 0, cookies: nil, headers: nil, data: nil, once: true)
15
+ @url = Addressable::URI.parse(url) if url
16
+ @delay = delay.is_a?(Range) ? rand(delay) : delay
17
+ @cookies = cookies
18
+ @headers = headers
19
+ @handler = handler || callback || DEFAULT_HANDLER
20
+ @data = deep_clone(data) || {}
21
+ @once = once
21
22
  end
22
23
 
24
+ alias callback handler
25
+
23
26
  def stub?
24
27
  !url
25
28
  end
29
+
30
+ def to_handler
31
+ { url: url, data: data, handler: handler }
32
+ end
33
+
34
+ def ==(other)
35
+ url == other.url &&
36
+ data == other.data &&
37
+ handler == other.handler &&
38
+ delay == other.delay &&
39
+ cookies == other.cookies &&
40
+ headers == other.headers &&
41
+ once == other.once
42
+ end
26
43
  end
27
44
  end