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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +70 -0
- data/README.md +418 -34
- data/bin/console +16 -0
- data/bin/vessel +8 -0
- data/lib/vessel/cargo/callbacks.rb +27 -0
- data/lib/vessel/cargo/field.rb +41 -0
- data/lib/vessel/cargo/field_type.rb +17 -0
- data/lib/vessel/cargo/fieldable.rb +27 -0
- data/lib/vessel/cargo/fields.rb +54 -0
- data/lib/vessel/cargo/settings.rb +113 -0
- data/lib/vessel/cargo.rb +38 -78
- data/lib/vessel/cli.rb +61 -2
- data/lib/vessel/driver/ferrum/driver.rb +43 -0
- data/lib/vessel/driver/ferrum/page.rb +64 -0
- data/lib/vessel/driver/mechanize/driver.rb +35 -0
- data/lib/vessel/driver/mechanize/page.rb +79 -0
- data/lib/vessel/driver/page.rb +59 -0
- data/lib/vessel/driver/registry.rb +26 -0
- data/lib/vessel/driver.rb +133 -0
- data/lib/vessel/engine/stats.rb +51 -0
- data/lib/vessel/engine.rb +106 -27
- data/lib/vessel/loader.rb +36 -0
- data/lib/vessel/logger.rb +25 -0
- data/lib/vessel/middleware.rb +7 -9
- data/lib/vessel/middleware_scheduler.rb +51 -0
- data/lib/vessel/proxy.rb +41 -0
- data/lib/vessel/request.rb +28 -11
- data/lib/vessel/response.rb +106 -0
- data/lib/vessel/scheduler.rb +19 -34
- data/lib/vessel/skeleton/Gemfile +5 -0
- data/lib/vessel/skeleton/config/boot.rb +17 -0
- data/lib/vessel/skeleton/config/environments/dev/dev.rb +5 -0
- data/lib/vessel/skeleton/config/environments/prod/prod.rb +7 -0
- data/lib/vessel/skeleton/config/fields/.keep +0 -0
- data/lib/vessel/skeleton/config/middleware/debug.rb +15 -0
- data/lib/vessel/skeleton/crawlers/.keep +0 -0
- data/lib/vessel/skeleton/lib/helpers/.keep +0 -0
- data/lib/vessel/skeleton/lib/loader.rb +4 -0
- data/lib/vessel/skeleton/log/.keep +0 -0
- data/lib/vessel/templates/template.erb +9 -0
- data/lib/vessel/templator.rb +32 -0
- data/lib/vessel/util.rb +16 -0
- data/lib/vessel/version.rb +1 -1
- data/lib/vessel.rb +35 -5
- 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
|
-
|
|
6
|
-
|
|
7
|
+
include Stats
|
|
8
|
+
|
|
9
|
+
def self.run(...)
|
|
10
|
+
new(...).tap(&:run)
|
|
7
11
|
end
|
|
8
12
|
|
|
9
|
-
attr_reader :crawler_class, :settings, :scheduler, :
|
|
13
|
+
attr_reader :crawler_class, :settings, :scheduler, :middleware_scheduler
|
|
10
14
|
|
|
11
|
-
def initialize(klass, &
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
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(
|
|
39
|
-
|
|
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
|
-
|
|
42
|
-
|
|
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
|
-
|
|
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
|
-
|
|
76
|
+
increase(:res_handled)
|
|
77
|
+
response.close
|
|
49
78
|
end
|
|
50
79
|
|
|
51
80
|
private
|
|
52
81
|
|
|
53
|
-
def
|
|
54
|
-
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
data/lib/vessel/middleware.rb
CHANGED
|
@@ -2,22 +2,20 @@
|
|
|
2
2
|
|
|
3
3
|
module Vessel
|
|
4
4
|
class Middleware
|
|
5
|
-
|
|
5
|
+
class InvalidItemError < RuntimeError; end
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
7
|
+
attr_reader :settings
|
|
8
|
+
|
|
9
|
+
def initialize(settings)
|
|
10
|
+
@settings = settings
|
|
9
11
|
end
|
|
10
12
|
|
|
11
|
-
def
|
|
12
|
-
|
|
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
|
data/lib/vessel/proxy.rb
ADDED
|
@@ -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
|
data/lib/vessel/request.rb
CHANGED
|
@@ -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
|
-
|
|
8
|
+
include Vessel::Util
|
|
8
9
|
|
|
9
|
-
|
|
10
|
-
urls.empty? ? [new] : urls.map { |url| new(url: url) }
|
|
11
|
-
end
|
|
10
|
+
DEFAULT_HANDLER = :parse
|
|
12
11
|
|
|
13
|
-
|
|
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
|
-
|
|
20
|
-
@
|
|
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
|