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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: cd0bd19dd98e5f26e52826a24963add84de95abda7150c126ea8cb95aae1c531
|
|
4
|
+
data.tar.gz: dd36224b062f925a82317b8487647b058cafb373d0049c7341f199840fde588e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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](
|
|
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 "
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
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,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
|