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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 291c528925e5f6990b8223762c00a996cf8435325b52048c1c0338fc873d1a42
4
- data.tar.gz: 5c845e2c6b19adea7742bbad3a031fe0ad5f09a3a0f8ceff248970cc2bb74fcb
3
+ metadata.gz: cd0bd19dd98e5f26e52826a24963add84de95abda7150c126ea8cb95aae1c531
4
+ data.tar.gz: dd36224b062f925a82317b8487647b058cafb373d0049c7341f199840fde588e
5
5
  SHA512:
6
- metadata.gz: 77d65c60e1541c1ab1473b64bd7c451e3d57199fa19885bcf4e182d295321d0cdd36121cc673a9285a7113b482ec9143607d17a4017632d1d6826b5c5ad48b7e
7
- data.tar.gz: 2a0e9bbe6de79e9ba6e7afed4d422de6b72639d91b508a921dd4bba2cf0b8e878a3de6846fca87de25e20047594b153efcf70ca3b91d373190bf976605e0bb2d
6
+ metadata.gz: bb6e940657405be517a1864cde6d6448924e7ccb608927b7e6ca34fb8ca3ab7937792422ee12e546ca96aef4472e7351121f1ad9dd0a883e27fc041b692bbede
7
+ data.tar.gz: 80bc743960b10f132ede881f84d81e1852b20c250f5fdc9f8cd21998b20c5aa388f3e14db71eb287a3f537186ffb077d8f6fa40ed3e4c9e65b3b5f34993d6067
data/CHANGELOG.md ADDED
@@ -0,0 +1,70 @@
1
+ # Changelog
2
+
3
+ ## 0.3.0 (unreleased)
4
+
5
+ The first release since 2021, the internals were rewritten along the way.
6
+
7
+ ### Breaking
8
+
9
+ * Middleware is a class with a `call(hash, fields)` method now and is declared by
10
+ its name, `middleware "Debug", "Save"`. The `Middleware.build` chain of the
11
+ previous release is gone.
12
+ * `timeout` and `ferrum` settings are replaced by `driver`, e.g.
13
+ `driver :ferrum, timeout: 30`.
14
+ * `intercept` is replaced by `blacklist` and `whitelist`.
15
+ * Urls are visited only once by default, pass `once: false` to a request to
16
+ visit one again.
17
+
18
+ ### Added
19
+
20
+ * Pluggable drivers, `:ferrum` (default, real Chrome) and `:mechanize` (plain
21
+ HTTP, no browser), plus a `Vessel::Driver` base class for your own.
22
+ * Fields api, `field :name, value: ...` with blocks, `service: true`, `rename:`
23
+ and `Vessel::Cargo::FieldType` for per name normalization.
24
+ * Requests carry `data`, and accept per request `handler`, `headers`, `cookies`,
25
+ `delay` and `once`.
26
+ * `start_urls` accepts a hash of `url => handler`.
27
+ * Cookies, `cookie`/`cookies` to set them and `allow_cookies` to control whether
28
+ the ones a page sends back are kept for the next requests.
29
+ * Proxy rotation, `Vessel::RoundRobinProxy` and `Vessel::ShuffledProxy`.
30
+ * Retries of the requests that fail with a network error,
31
+ `network_error_attempts`.
32
+ * Callbacks, `before_start`, `before`, `after_change`, `info`, `after`,
33
+ `before_stop` and `on_error`.
34
+ * `Vessel.page_snapshot` to run the selectors against a Nokogiri document
35
+ instead of the live page.
36
+ * CLI and a project skeleton, `vessel new`, `generate`, `list`, `settings`,
37
+ `start`, `parse` and `version`.
38
+ * `Vessel::Cargo` is the crawler base class, `Vessel::Crawler` is kept as an
39
+ alias.
40
+
41
+ ### Fixed
42
+
43
+ * The stats timer task is shut down when a run finishes, it used to keep logging
44
+ and hold a thread for the life of the process ([#36](https://github.com/rubycdp/vessel/issues/36)).
45
+ * An url is registered before its page is opened, threads racing for the same
46
+ url no longer visit it in parallel.
47
+ * The skeleton of a new project is packaged completely, `vessel new` used to
48
+ generate a project without `crawlers`, `log`, `config/fields` and
49
+ `lib/helpers` when Vessel was installed as a gem
50
+ ([#37](https://github.com/rubycdp/vessel/issues/37)).
51
+ * The middleware and the settings of a generated project, `Debug` took one
52
+ argument where the pipeline passes two and the prod environment called
53
+ `thread` instead of `threads`.
54
+ * The templator closes the file it writes a crawler to.
55
+ * Settings are deep cloned into the subclasses of a crawler.
56
+ * The engine is idle only when every scheduled request and item is done.
57
+ * Ferrum 0.15 and up with a proxy.
58
+
59
+ ### Changed
60
+
61
+ * Minimum Ruby is 3.1, the same as Ferrum.
62
+ * Dependencies are up to date, Ferrum 0.18, Mechanize 2.14, Nokogiri 1.18 and
63
+ Thor 1.5. Addressable, concurrent-ruby and logger are declared explicitly,
64
+ they are required directly and used to come in through other gems. Logger is
65
+ no longer a default gem on Ruby 4.0, without the dependency requiring Vessel
66
+ raised a LoadError there.
67
+
68
+ ## 0.2.0 (2021-03-09)
69
+
70
+ * Initial public releases, see the git history for the details.
data/README.md CHANGED
@@ -2,14 +2,13 @@
2
2
 
3
3
  #### Fast as Chrome, dead simple and yet extendable.
4
4
 
5
- It is Ruby high-level web crawling framework based on
6
- [Ferrum](https://github.com/rubycdp/ferrum) for extracting the data you need
7
- from websites. It can be used in a wide range of scenarios, like data mining,
8
- monitoring or historical archival. For automated testing we recommend
5
+ Vessel is a Ruby high-level web crawling framework. It can be used in a wide range of scenarios, like data
6
+ mining, monitoring or historical archival. Pages are fetched by a pluggable
7
+ driver: [Ferrum](https://github.com/rubycdp/ferrum) (a real Chrome, the default)
8
+ or [Mechanize](https://github.com/sparklemotion/mechanize) (plain HTTP, no
9
+ browser). For automated testing we recommend
9
10
  [Cuprite](https://github.com/rubycdp/cuprite).
10
11
 
11
- Thanks to Evrone [design team](https://evrone.com/design?utm_source=github&utm_campaign=vessel). Read about [Vessel](https://evrone.com/vessel-framework?utm_source=github&utm_campaign=vessel) & other projects supported by Evrone [here](https://evrone.com/cases?utm_source=github&utm_campaign=vessel#open-source).
12
-
13
12
 
14
13
  ## Install
15
14
 
@@ -19,11 +18,13 @@ Add this to your Gemfile:
19
18
  gem "vessel"
20
19
  ```
21
20
 
21
+ The default `:ferrum` driver needs Chrome or Chromium installed and available in `PATH`.
22
+
22
23
 
23
24
  ## A look around
24
25
 
25
26
  In order to show you how Vessel works we are going to crawl together
26
- [famous quotes website](http://quotes.toscrape.com):
27
+ [famous quotes website](https://quotes.toscrape.com):
27
28
 
28
29
  ```ruby
29
30
  require "json"
@@ -31,7 +32,7 @@ require "vessel"
31
32
 
32
33
  class QuotesToScrapeCom < Vessel::Cargo
33
34
  domain "quotes.toscrape.com"
34
- start_urls "http://quotes.toscrape.com/tag/humor/"
35
+ start_urls "https://quotes.toscrape.com/tag/humor/"
35
36
 
36
37
  def parse
37
38
  css("div.quote").each do |quote|
@@ -41,10 +42,10 @@ class QuotesToScrapeCom < Vessel::Cargo
41
42
  })
42
43
  end
43
44
 
44
- if next_page = at_xpath("//li[@class='next']/a[@href]")
45
- url = absolute_url(next_page.attribute(:href))
46
- yield request(url: url, method: :parse)
47
- end
45
+ next_page = at_xpath("//li[@class='next']/a[@href]")
46
+ return unless next_page
47
+
48
+ yield request(url: absolute_url(next_page[:href]), handler: :parse)
48
49
  end
49
50
  end
50
51
 
@@ -55,44 +56,427 @@ puts JSON.generate(quotes)
55
56
 
56
57
  Save this to `quotes.rb` file and run `bundle exec ruby quotes.rb > quotes.json`.
57
58
  When this finishes you will have a list of the quotes in JSON format in the
58
- `quotes.json` file.
59
+ `quotes.json` file. Vessel logs to `$stdout` at the debug level by default, so
60
+ either send the log somewhere else (see [Logging](#logging)) or write your items
61
+ to a file instead of `$stdout`.
59
62
 
60
- How it all works? First Vessel using Ferrum spawns Chrome which goes to one or
61
- more urls in `start_urls`, in our case it's only one. After Chrome reports back
62
- that page is loaded with all the resources it needs the first default callback
63
- `parse` is invoked. In the parse callback, we loop through the quote elements
64
- using a CSS Selector, yield a Hash with the extracted quote text and author and
65
- look for a link to the next page and schedule another request using the same
66
- parse method as callback.
63
+ How it all works? First Vessel using the driver goes to one or more urls in
64
+ `start_urls`, in our case it's only one. After the page is loaded with all the
65
+ resources it needs the handler for that url is invoked, `parse` by default. In
66
+ the parse handler, we loop through the quote elements using a CSS Selector,
67
+ yield a Hash with the extracted quote text and author and look for a link to the
68
+ next page and schedule another request using the same parse method as a handler.
67
69
 
68
- Notice that all requests are scheduled and handled concurrently. We use thread
70
+ Notice that all requests are scheduled and handled concurrently. We use a thread
69
71
  pool to work with all your requests with one page per core by default or add
70
72
  `threads max: n` to a class. If you yield more than one request Ruby will send
71
- them to Chrome which will load pages in parallel. Thus crawler is lightweight
72
- and speedy.
73
+ them to the driver which will load pages in parallel. Thus crawler is
74
+ lightweight and speedy.
75
+
76
+ `Vessel::Crawler` is an alias for `Vessel::Cargo`, both names work.
73
77
 
74
78
 
75
79
  ## Settings
76
80
 
77
- * domain
78
- * start_urls
79
- * delay
80
- * timeout
81
- * threads
82
- * middleware
81
+ Settings are declared in the class body and are inherited by (and deep-copied
82
+ into) subclasses, which is what makes a shared `ApplicationCrawler` base class
83
+ useful:
84
+
85
+ | Setting | Description |
86
+ | --- | --- |
87
+ | [`domain`](#domain) | Domain name of the crawler, also registers it in the loader |
88
+ | [`start_urls`](#start_urls) | Urls to start with, optionally mapped to handlers |
89
+ | [`driver`](#driver) | Driver to fetch pages with and its options |
90
+ | [`delay`](#delay) | Seconds (or a range) to wait between requests |
91
+ | [`headers`](#headers) | Request headers |
92
+ | [`cookie` / `cookies`](#cookies) | Cookies to set before a request |
93
+ | [`allow_cookies`](#cookies) | Whether cookies received from a page are kept for the next requests |
94
+ | [`threads`](#threads) | Min and max size of the thread pools |
95
+ | [`middleware`](#middleware) | Pipeline the yielded items go through |
96
+ | [`proxy`](#proxy) | Proxy rotation class |
97
+ | [`blacklist` / `whitelist`](#blacklist-and-whitelist) | Patterns of the resources to block or to allow |
98
+ | [`network_error_attempts`](#network_error_attempts) | How many times a request is retried on a network error |
99
+
100
+ Every setting can also be passed at runtime to `.run`, which merges it on top of
101
+ the class-level ones:
102
+
103
+ ```ruby
104
+ QuotesToScrapeCom.run(delay: 2, max_threads: 1)
105
+ ```
106
+
107
+ ### domain
108
+
109
+ ```ruby
110
+ class MyScraper < Vessel::Cargo
111
+ domain "example.com"
112
+ end
113
+ ```
114
+
115
+ The domain is used as the crawler name for the CLI and as the default domain for
116
+ [cookies](#cookies).
117
+
118
+ ### start_urls
119
+
120
+ Accepts strings, an array of strings or a hash of `url => handler`, so different
121
+ entry points can be parsed by different methods. Without a handler `:parse` is
122
+ used:
123
+
124
+ ```ruby
125
+ class MyScraper < Vessel::Cargo
126
+ start_urls "https://example.com/one", "https://example.com/two"
127
+ # or
128
+ start_urls "https://example.com/products" => :parse_products,
129
+ "https://example.com/news" => :parse_news
130
+ end
131
+ ```
132
+
133
+ If `start_urls` is empty the crawler is still started once with a stub response,
134
+ which is handy when the first urls are computed in `parse` itself.
135
+
136
+ ### driver
137
+
138
+ ```ruby
139
+ class MyScraper < Vessel::Cargo
140
+ driver :ferrum, headless: true, timeout: 30
141
+ end
142
+ ```
143
+
144
+ Two drivers ship with Vessel:
145
+
146
+ * `:ferrum` (default) - a real Chrome driven by
147
+ [Ferrum](https://github.com/rubycdp/ferrum), options are passed to
148
+ `Ferrum::Browser.new`. Vessel defaults to `timeout: 60`, `js_errors: false`,
149
+ `process_timeout: 30`, `pending_connection_errors: false` and ignoring
150
+ certificate errors.
151
+ * `:mechanize` - plain HTTP requests via
152
+ [Mechanize](https://github.com/sparklemotion/mechanize), no browser and no
153
+ JavaScript, which makes it much faster and lighter. `blacklist` and
154
+ `whitelist` are not supported by this driver, and an error status is raised
155
+ rather than parsed, so a `404` or a `500` is retried and ends up in
156
+ [`on_error`](#callbacks) instead of reaching your handler with
157
+ `response.status` set.
158
+
159
+ You can disable headless mode by passing driver options at runtime:
160
+
161
+ ```ruby
162
+ MyScraper.run(driver_options: { headless: false })
163
+ ```
164
+
165
+ Custom drivers are supported, subclass `Vessel::Driver`, implement `start`,
166
+ `stop` and `create_page` along with a `Vessel::Driver::Page` subclass, and
167
+ register it with `driver_name :my_driver`.
168
+
169
+ ### delay
170
+
171
+ ```ruby
172
+ class MyScraper < Vessel::Cargo
173
+ delay 4..6
174
+ threads max: 1
175
+ end
176
+ ```
177
+
178
+ Number of seconds to keep between two consecutive requests, a range is sampled
179
+ randomly per request. The delay is only applied when the crawler is single
180
+ threaded, with a pool of more than one thread it doesn't make sense.
181
+
182
+ ### headers
183
+
184
+ ```ruby
185
+ class MyScraper < Vessel::Cargo
186
+ headers "Content-Type" => "text/plain",
187
+ "Referer" => "https://example.com"
188
+ end
189
+ ```
190
+
191
+ ### cookies
192
+
193
+ `cookie` sets one cookie, `cookies` takes an array of them. `domain` defaults to
194
+ the crawler's `domain`:
195
+
196
+ ```ruby
197
+ class MyScraper < Vessel::Cargo
198
+ domain "example.com"
199
+
200
+ cookie name: "lang", value: "en", path: "/"
201
+ cookies [
202
+ { name: "session", value: "abc", domain: "example.com", path: "/" },
203
+ { name: "consent", value: "1", domain: "example.com", path: "/", secure: true }
204
+ ]
205
+ end
206
+ ```
207
+
208
+ Supported keys are `name`, `value`, `domain`, `path`, `httponly`, `secure` and
209
+ `expires`. By default cookies collected from a response are stored and sent with
210
+ the following requests, `allow_cookies false` turns that off.
211
+
212
+ ### threads
213
+
214
+ ```ruby
215
+ class MyScraper < Vessel::Cargo
216
+ threads min: 1, max: 5
217
+ end
218
+ ```
219
+
220
+ `max` defaults to the number of the processors, the same pool size is used both
221
+ for fetching pages and for running the middleware.
222
+
223
+ ### proxy
224
+
225
+ Subclass `Vessel::RoundRobinProxy` (or `Vessel::ShuffledProxy` to shuffle the
226
+ list on start) and define the `PROXIES` constant, the driver takes the next
227
+ proxy for every page it creates:
228
+
229
+ ```ruby
230
+ class MyProxy < Vessel::ShuffledProxy
231
+ PROXIES = [
232
+ { host: "127.0.0.1", port: 8080, user: "user1", password: "password1" },
233
+ { host: "127.0.0.1", port: 8081, user: "user2", password: "password2" }
234
+ ].freeze
235
+ end
236
+
237
+ class MyScraper < Vessel::Cargo
238
+ proxy MyProxy
239
+ end
240
+ ```
241
+
242
+ For a fully custom rotation subclass `Vessel::Proxy` and implement `prepare`,
243
+ which is called once and fills in `@proxies`.
244
+
245
+ ### blacklist and whitelist
246
+
247
+ Patterns of the resources Chrome is allowed to load, useful to skip images,
248
+ fonts or trackers and speed the crawler up. Ferrum driver only:
249
+
250
+ ```ruby
251
+ class MyScraper < Vessel::Cargo
252
+ blacklist [/\.png$/, /googletagmanager/]
253
+ # or
254
+ whitelist [/example.com/]
255
+ end
256
+ ```
257
+
258
+ ### network_error_attempts
259
+
260
+ ```ruby
261
+ class MyScraper < Vessel::Cargo
262
+ network_error_attempts 5
263
+ end
264
+ ```
265
+
266
+ How many times a url is retried when the driver raises a network error, e.g. a
267
+ timeout, a socket error or a bad status. The browser is restarted between the
268
+ attempts. Defaults to `5`. When the attempts are exhausted the error is passed
269
+ to the [`on_error`](#callbacks) callback.
270
+
271
+
272
+ ## Requests
273
+
274
+ Inside a handler `request` builds a new request relative to the current page,
275
+ yield it and the engine schedules it:
276
+
277
+ ```ruby
278
+ def parse
279
+ yield request(url: "/page/2/", handler: :parse_page, data: { category: "humor" })
280
+ end
281
+
282
+ def parse_page
283
+ puts response.data[:category] # => "humor"
284
+ end
285
+ ```
286
+
287
+ | Option | Description |
288
+ | --- | --- |
289
+ | `url` | Absolute or relative url, resolved against the current page |
290
+ | `handler` | Method to call with the response, `:parse` by default, `callback` is an alias |
291
+ | `data` | Arbitrary hash carried over to the response, deep-copied |
292
+ | `delay` | Overrides the crawler `delay` for this request |
293
+ | `headers` | Overrides the crawler `headers` for this request |
294
+ | `cookies` | Overrides the crawler `cookies` for this request |
295
+ | `once` | `true` by default, the same url is not visited twice, pass `false` to allow it |
296
+ | `encode` | `true` by default, the url is decoded and encoded again before it's joined |
297
+
298
+ You can yield a single request, an array of them, or a hash of the extracted
299
+ fields, but not both in one `yield`.
83
300
 
84
301
 
85
302
  ## Selectors
86
303
 
87
- * at_css
88
- * css
89
- * at_xpath
90
- * xpath
304
+ The response is delegated to the crawler, so inside a handler you can call
305
+ directly:
306
+
307
+ * `at_css`, `css`, `at_xpath`, `xpath` - search the page
308
+ * `url`, `data`, `attempt`, `size`, `body`, `raw` - the current url, the data
309
+ attached to the request, which attempt it is, the page size in bytes, the
310
+ Nokogiri document and the raw html
311
+ * `absolute_url`, `join_url`, `url_encode`, `url_decode` (aliased as
312
+ `uri_encode`, `uri_decode`) - url helpers
313
+
314
+ `response` and `page` are available as well, `page` being the driver's native
315
+ object, `Ferrum::Page` or `Mechanize::Page`. `response.status` and
316
+ `response.headers` give you the response status and headers,
317
+ `response.sync_body` re-reads the html after a pause when the page needs a
318
+ moment to render.
319
+
320
+ By default the selectors run against the live page, which for the Ferrum driver
321
+ means Chrome does the querying. Setting `Vessel.page_snapshot = true` makes them
322
+ run against the Nokogiri document of the page's html instead, which is much
323
+ faster when you extract a lot of nodes and don't need to interact with the page.
324
+
325
+ Keep in mind that the nodes you get back come from the driver, `Ferrum::Node` or
326
+ `Nokogiri::XML::Node`, and the two don't have the same api. Read the attributes
327
+ with `node[:href]`, that one works everywhere, `node.attribute(:href)` only works
328
+ on the live Chrome page.
329
+
330
+
331
+ ## Fields
332
+
333
+ Instead of building a hash by hand a handler can declare fields and yield the
334
+ collected `fields` object:
335
+
336
+ ```ruby
337
+ class MyScraper < Vessel::Cargo
338
+ def parse
339
+ field :author, value: at_xpath("span/small").text
340
+ field :text, value: at_css("span.text").text
341
+ field :html, value: nil, service: true do
342
+ raw
343
+ end
344
+
345
+ yield fields
346
+ end
347
+ end
348
+ ```
349
+
350
+ * a block is called instead of `value` when it's given
351
+ * `service: true` keeps the field out of the resulting hash, it's only available
352
+ as `fields.service[:name]` in the middleware
353
+ * `rename: { "author" => :writer }` stores the field under another name
354
+
355
+ Fields can be normalized in one place by their name, put such declarations into
356
+ `config/fields` of a generated project:
357
+
358
+ ```ruby
359
+ Vessel::Cargo::FieldType.add(:price) { |value| value.to_s.gsub(/[^\d.]/, "").to_f }
360
+ ```
361
+
362
+ Every `field :price` is then passed through that block, `typing: false` skips it.
91
363
 
92
364
 
93
365
  ## Middleware
94
366
 
95
- To be continued
367
+ Everything a handler yields that is not a request goes through the middleware
368
+ pipeline, which runs in its own thread pool. A middleware is a class with a
369
+ `call(hash, fields)` method, it gets the hash returned by the previous
370
+ middleware plus the original fields object, and returns the hash for the next
371
+ one:
372
+
373
+ ```ruby
374
+ class Sanitize < Vessel::Middleware
375
+ def call(hash, fields)
376
+ hash.transform_values { |v| v.is_a?(String) ? v.strip : v }
377
+ end
378
+ end
379
+
380
+ class Save < Vessel::Middleware
381
+ def call(hash, fields)
382
+ raise Vessel::Middleware::InvalidItemError if hash[:text].to_s.empty?
383
+
384
+ DB[:quotes].insert(hash)
385
+ hash
386
+ end
387
+ end
388
+
389
+ class MyScraper < Vessel::Cargo
390
+ middleware "Sanitize", "Save"
391
+ end
392
+ ```
393
+
394
+ Middleware is declared by name so that classes can be defined anywhere in the
395
+ project, the crawler settings are available as `settings`. Raising
396
+ `Vessel::Middleware::InvalidItemError` silently rejects the item, any other
397
+ error is logged and counted as a rejection too.
398
+
399
+ A block passed to `.run` replaces the whole pipeline, which is the shortest way
400
+ to collect the items in a script:
401
+
402
+ ```ruby
403
+ QuotesToScrapeCom.run { |item| quotes << item }
404
+ ```
405
+
406
+
407
+ ## Callbacks
408
+
409
+ Override any of these in the crawler to hook into the run:
410
+
411
+ ```ruby
412
+ class MyScraper < Vessel::Cargo
413
+ def before_start; end # before anything is scheduled
414
+ def before(stats); end # with the initial stats
415
+ def after_change(counter, stats) # on every stats counter change
416
+ end
417
+ def info(stats); end # every 4 seconds, logs the stats by default
418
+ def after(stats); end # when the engine is done
419
+ def before_stop; end # at exit
420
+ def on_error(request, error); end # a request failed, re-raises by default
421
+ end
422
+ ```
423
+
424
+ The stats hash holds `req_enqueued`, `res_dequeued`, `res_handled`,
425
+ `item_pipelined`, `item_processed`, `item_sent`, `item_rejected` and `idling`.
426
+
427
+
428
+ ## Logging
429
+
430
+ Vessel logs to `$stdout` at the debug level. Pass your own logger to change
431
+ that:
432
+
433
+ ```ruby
434
+ Vessel::Logger.instance = Logger.new("log/vessel.log", level: Logger::INFO)
435
+ ```
436
+
437
+
438
+ ## Projects and CLI
439
+
440
+ A single-file script is enough for a small crawler, for a bigger one Vessel can
441
+ generate a project:
442
+
443
+ ```
444
+ $ vessel new myproject
445
+ $ cd myproject
446
+ $ bundle install
447
+ $ vessel generate example.com
448
+ ```
449
+
450
+ ```
451
+ myproject
452
+ ├── Gemfile
453
+ ├── config
454
+ │ ├── boot.rb # loads everything below
455
+ │ ├── environments
456
+ │ │ ├── dev/dev.rb # ApplicationCrawler for VESSEL_ENV=dev
457
+ │ │ └── prod/prod.rb # ApplicationCrawler for VESSEL_ENV=prod
458
+ │ ├── fields # FieldType declarations
459
+ │ └── middleware # middleware classes
460
+ ├── crawlers # one crawler per site
461
+ ├── lib
462
+ │ ├── helpers
463
+ │ └── loader.rb
464
+ └── log
465
+ ```
466
+
467
+ Crawlers inherit from `ApplicationCrawler`, which is defined per environment, so
468
+ the settings can differ between development and production, `VESSEL_ENV`
469
+ selects the environment and defaults to `dev`.
470
+
471
+ | Command | Description |
472
+ | --- | --- |
473
+ | `vessel new NAME` | Generate a new project |
474
+ | `vessel generate DOMAIN` | Generate `crawlers/DOMAIN.rb` |
475
+ | `vessel list` | List the crawlers of the project |
476
+ | `vessel settings DOMAIN` | Show the settings of a crawler |
477
+ | `vessel start DOMAIN` | Run a crawler |
478
+ | `vessel parse DOMAIN URL HANDLER --data=key:value` | Fetch one url and call one handler, for debugging |
479
+ | `vessel version` | Print the version |
96
480
 
97
481
 
98
482
  ## License
data/bin/console ADDED
@@ -0,0 +1,16 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ # frozen_string_literal: true
4
+
5
+ require "bundler/setup"
6
+ require "vessel"
7
+
8
+ # You can add fixtures and/or initialization code here to make experimenting
9
+ # with your gem easier. You can also use a different console, if you like.
10
+
11
+ # (If you use this, don't forget to add pry to your Gemfile!)
12
+ # require "pry"
13
+ # Pry.start
14
+
15
+ require "irb"
16
+ IRB.start(__FILE__)
data/bin/vessel ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ # frozen_string_literal: true
4
+
5
+ require "bundler/setup"
6
+ require "vessel"
7
+
8
+ Vessel::CLI.start(ARGV)
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Vessel
4
+ class Cargo
5
+ module Callbacks
6
+ INFO_INTERVAL = 4
7
+
8
+ def before_start; end
9
+
10
+ def before(_stats); end
11
+
12
+ def after_change(_counter, _stats); end
13
+
14
+ def info(stats)
15
+ Vessel::Logger.info "Cargo: #{stats.map { |k, v| "#{k}=#{v}" }.join(', ')}"
16
+ end
17
+
18
+ def after(_stats); end
19
+
20
+ def before_stop; end
21
+
22
+ def on_error(_request, error)
23
+ raise error
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Vessel
4
+ class Cargo
5
+ class Field
6
+ attr_reader :name, :value, :block, :typing
7
+
8
+ def initialize(name:, value:, context:, rename: nil, typing: true, &block)
9
+ @name = rename(name, rename)
10
+ @value = value
11
+ @block = block
12
+ @context = context
13
+ @typing = FieldType.types[@name] if typing
14
+ end
15
+
16
+ def apply
17
+ apply_block
18
+ apply_typing
19
+ value
20
+ end
21
+
22
+ private
23
+
24
+ def apply_block
25
+ return unless @block
26
+
27
+ @value = @block.call
28
+ end
29
+
30
+ def apply_typing
31
+ return unless typing
32
+
33
+ @value = @context.instance_exec(value, &typing)
34
+ end
35
+
36
+ def rename(name, aliases)
37
+ Hash(aliases).fetch(name.to_s, name).to_sym
38
+ end
39
+ end
40
+ end
41
+ end