metainspector 5.18.1 → 5.20.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/.gitignore +1 -0
- data/CHANGELOG.md +15 -0
- data/README.md +125 -6
- data/lib/meta_inspector/document.rb +25 -5
- data/lib/meta_inspector/http_session.rb +65 -0
- data/lib/meta_inspector/image_downloader.rb +53 -0
- data/lib/meta_inspector/parser.rb +4 -2
- data/lib/meta_inspector/parsers/images.rb +19 -14
- data/lib/meta_inspector/parsers/links.rb +2 -1
- data/lib/meta_inspector/parsers/texts.rb +13 -4
- data/lib/meta_inspector/request.rb +43 -42
- data/lib/meta_inspector/url_policy.rb +58 -0
- data/lib/meta_inspector/version.rb +1 -1
- data/spec/document_spec.rb +20 -0
- data/spec/image_download_spec.rb +114 -0
- data/spec/meta_inspector/head_first_spec.rb +195 -0
- data/spec/meta_inspector/images_spec.rb +233 -0
- data/spec/meta_inspector/links_spec.rb +22 -0
- data/spec/meta_inspector/meta_inspector_spec.rb +58 -7
- data/spec/meta_inspector/redirections_spec.rb +20 -6
- data/spec/meta_inspector/texts_spec.rb +115 -0
- data/spec/request_spec.rb +29 -0
- data/spec/url_filter_spec.rb +206 -0
- metadata +7 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ba31ea21114c1932ca2dde179a2812f24d429b7420807fec232de65ea1ba824e
|
|
4
|
+
data.tar.gz: 99188cbec093cfd1d22cdfd3d31bf181cf193e937f6c7b23a61cb6ed39ba1ca2
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: bc39e09f57cadd29f60e6a41556878d807a629e99de37b6b64b3ebf090d32aeca63d49b38af436fa16e5fff29e4cf5e927d99df5b37f35385ea30165a8de55e3
|
|
7
|
+
data.tar.gz: d4a4777631f8c63d238236960148330a3dc7ba0f4ae2ec0b497cc3e03f33a5ca8daca05141dbaaa20a99a54fd933d5a5c9cb7056dd4b84b12c1a515119935574
|
data/.gitignore
CHANGED
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
# MetaInspector Changelog
|
|
2
2
|
|
|
3
|
+
## [Changes in 5.20.0](https://github.com/jaimeiniesta/metainspector/compare/v5.19.0...v5.20.0)
|
|
4
|
+
|
|
5
|
+
* Security: route image size downloads through Faraday so they respect configured network and redirect controls. Add optional `url_filter:` to validate the initial page URL, HEAD requests, image URLs and every redirect destination before requesting them. Returning `false` or `nil` blocks the request with `MetaInspector::RequestError`; filter exceptions propagate. Only HTTP(S) requests are supported ([GHSA-6vgc-f9v5-8939](https://github.com/jaimeiniesta/metainspector/security/advisories/GHSA-6vgc-f9v5-8939)).
|
|
6
|
+
* Accept `faraday_http_cache` inside `faraday_options`, which used to raise `NameError`. The top-level `faraday_http_cache` option still works but is deprecated: it prints a warning once per process. Passing both raises `ArgumentError` (#373).
|
|
7
|
+
|
|
8
|
+
## [Changes in 5.19.0](https://github.com/jaimeiniesta/metainspector/compare/v5.18.1...v5.19.0)
|
|
9
|
+
|
|
10
|
+
* Security: stop modifying the `faraday_options` hash passed to MetaInspector. When the same hash was reused, its `redirect` options were deleted after the first request, so a redirect `callback` used to block private hosts only ran once and later requests followed any redirect ([GHSA-mm2p-6q2v-7ph5](https://github.com/jaimeiniesta/metainspector/security/advisories/GHSA-mm2p-6q2v-7ph5)). Frozen option hashes no longer raise `FrozenError`.
|
|
11
|
+
* Add `language` to expose the root HTML `lang` attribute, trimming surrounding whitespace and returning `nil` for missing or blank values. Include it in `to_hash` (#121).
|
|
12
|
+
* Add opt-in `max_image_downloads` to limit image size lookup attempts while preserving dimensions already available in HTML (#242).
|
|
13
|
+
* Add `parsely-title`, `parsely-author`, and `parsely-image-url` fallbacks to `best_title`, `best_author`, and `images.owner_suggested` / `images.best`, preserving existing preferred metadata sources (#214).
|
|
14
|
+
* Resolve relative base hrefs against the full document URL, preserving nested paths and non-default ports (#240).
|
|
15
|
+
* Skip invalid image URLs in `images.with_size`, preventing `images.best` from failing when FastImage receives a nil URL ([#521](https://github.com/jaimeiniesta/metainspector/issues/521), follow-up to [#359](https://github.com/jaimeiniesta/metainspector/issues/359)).
|
|
16
|
+
* Add opt-in `head_first: true` to check content type with HEAD and reject non-HTML files before downloading them (#221).
|
|
17
|
+
|
|
3
18
|
## [Changes in 5.18.1](https://github.com/jaimeiniesta/metainspector/compare/v5.18.0...v5.18.1)
|
|
4
19
|
|
|
5
20
|
* Require nokogiri 1.19.5, which fixes three low-severity security advisories.
|
data/README.md
CHANGED
|
@@ -84,6 +84,7 @@ page.feeds # Get rss or atom links in meta data fields as array of
|
|
|
84
84
|
### Texts
|
|
85
85
|
|
|
86
86
|
```ruby
|
|
87
|
+
page.language # declared language from the root <html lang="..."> attribute
|
|
87
88
|
page.title # title of the page from the head section, as string
|
|
88
89
|
page.best_title # best title of the page, from a selection of candidates
|
|
89
90
|
page.author # author of the page from the meta author tag
|
|
@@ -98,6 +99,20 @@ page.h5 # returns h5 text array
|
|
|
98
99
|
page.h6 # returns h6 text array
|
|
99
100
|
```
|
|
100
101
|
|
|
102
|
+
`language` trims surrounding whitespace and preserves the declared value, including
|
|
103
|
+
capitalization and regional tags such as `en-US`. It returns `nil` when the `lang`
|
|
104
|
+
attribute is missing, empty, or whitespace-only. It does not infer language from
|
|
105
|
+
the page content or other attributes. The value is also included in `to_hash`
|
|
106
|
+
under the `"language"` key.
|
|
107
|
+
|
|
108
|
+
`best_title` selects the first non-empty value from the standard `title` meta tag,
|
|
109
|
+
`og:title`, the head `<title>`, a body `<title>`, the first `<h1>`, and finally
|
|
110
|
+
`parsely-title`.
|
|
111
|
+
|
|
112
|
+
`best_author` selects the first non-empty value from the standard `author` meta tag,
|
|
113
|
+
the first link with `rel="author"`, the first `<address>`, `twitter:creator`, and
|
|
114
|
+
finally `parsely-author`. Both methods trim and collapse whitespace in their results.
|
|
115
|
+
|
|
101
116
|
### Links
|
|
102
117
|
|
|
103
118
|
```ruby
|
|
@@ -114,10 +129,15 @@ page.links.external # every external link found on the page as an absolute
|
|
|
114
129
|
```ruby
|
|
115
130
|
page.images # enumerable collection, with every img found on the page as an absolute URL
|
|
116
131
|
page.images.with_size # a sorted array (by descending area) of [image_url, width, height]
|
|
117
|
-
page.images.best # Most relevant image
|
|
132
|
+
page.images.best # Most relevant image from og:image, twitter:image, or parsely-image-url; falls back to page.images.largest
|
|
118
133
|
page.images.favicon # absolute URL to the favicon
|
|
119
134
|
```
|
|
120
135
|
|
|
136
|
+
`images.owner_suggested` tries `og:image`, `twitter:image`, and `parsely-image-url`
|
|
137
|
+
in that order, skipping empty or whitespace-only values. Relative image URLs are
|
|
138
|
+
resolved against the document's base URL. `images.best` uses this suggested image
|
|
139
|
+
before falling back to the largest suitable image on the page.
|
|
140
|
+
|
|
121
141
|
### Meta tags
|
|
122
142
|
|
|
123
143
|
When it comes to meta tags, you have several options:
|
|
@@ -321,8 +341,7 @@ And even customize what to do in between each redirect:
|
|
|
321
341
|
|
|
322
342
|
```ruby
|
|
323
343
|
callback = proc do |previous_response, next_request|
|
|
324
|
-
|
|
325
|
-
raise 'Invalid address' if IPAddr.new(ip_address).private?
|
|
344
|
+
puts "Following redirect to #{next_request.url}"
|
|
326
345
|
end
|
|
327
346
|
|
|
328
347
|
page = MetaInspector.new(url, faraday_options: { redirect: { callback: callback } })
|
|
@@ -332,6 +351,63 @@ page = MetaInspector.new(url, faraday_options: { redirect: { callback: callback
|
|
|
332
351
|
The `faraday_options[:redirect]` hash is passed to the `FollowRedirects` middleware used by `Faraday`, so that we can use all available options.
|
|
333
352
|
Check them [here](https://github.com/lostisland/faraday_middleware/blob/main/lib/faraday_middleware/response/follow_redirects.rb#L44).
|
|
334
353
|
|
|
354
|
+
### Filtering request URLs
|
|
355
|
+
|
|
356
|
+
Use `url_filter:` to decide which destinations MetaInspector may request. The filter receives a Ruby `URI` and runs before the initial page request, the optional HEAD request, image size downloads, and every redirect destination. Only HTTP and HTTPS requests are supported.
|
|
357
|
+
|
|
358
|
+
For example, allow HTTPS requests to two known hosts:
|
|
359
|
+
|
|
360
|
+
```ruby
|
|
361
|
+
allowed_hosts = %w[example.com images.example.com]
|
|
362
|
+
|
|
363
|
+
filter = proc do |uri|
|
|
364
|
+
uri.scheme == 'https' && allowed_hosts.include?(uri.hostname)
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
page = MetaInspector.new('https://example.com', url_filter: filter)
|
|
368
|
+
page.images.largest # Image requests and their redirects use the same filter.
|
|
369
|
+
```
|
|
370
|
+
|
|
371
|
+
A truthy return value permits the request. Returning `false` or `nil` raises `MetaInspector::RequestError` before contacting the rejected destination. This also applies when an image request is rejected:
|
|
372
|
+
|
|
373
|
+
```ruby
|
|
374
|
+
begin
|
|
375
|
+
page = MetaInspector.new(url, url_filter: filter)
|
|
376
|
+
page.images.largest
|
|
377
|
+
rescue MetaInspector::RequestError => error
|
|
378
|
+
warn error.message
|
|
379
|
+
end
|
|
380
|
+
```
|
|
381
|
+
|
|
382
|
+
Exceptions raised by your filter propagate to the caller. The filter is optional: MetaInspector does not block private IP addresses by default. It applies to outgoing requests, including image downloads from HTML passed with `document:`. Reading image URLs with `images.to_a` or `images.owner_suggested` does not download them or run the filter.
|
|
383
|
+
|
|
384
|
+
If your application accepts arbitrary hostnames, this example rejects addresses in private, loopback, link-local and unspecified ranges, including IPv4-mapped IPv6 addresses:
|
|
385
|
+
|
|
386
|
+
```ruby
|
|
387
|
+
require 'ipaddr'
|
|
388
|
+
require 'resolv'
|
|
389
|
+
|
|
390
|
+
zero_network = IPAddr.new('0.0.0.0/8')
|
|
391
|
+
|
|
392
|
+
filter = proc do |uri|
|
|
393
|
+
addresses = Resolv.getaddresses(uri.hostname)
|
|
394
|
+
|
|
395
|
+
!addresses.empty? && addresses.all? do |address|
|
|
396
|
+
ip = IPAddr.new(address)
|
|
397
|
+
ip = ip.native if ip.ipv4_mapped?
|
|
398
|
+
|
|
399
|
+
!(ip.private? || ip.loopback? || ip.link_local? ||
|
|
400
|
+
ip.to_i.zero? || zero_network.include?(ip))
|
|
401
|
+
end
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
page = MetaInspector.new(url, url_filter: filter)
|
|
405
|
+
```
|
|
406
|
+
|
|
407
|
+
DNS resolution in this example happens separately from the connection, so it cannot prevent DNS rebinding. Use network egress restrictions as well when processing untrusted URLs. The example covers the listed ranges; applications may need additional address or port restrictions.
|
|
408
|
+
|
|
409
|
+
The existing redirect `callback` keeps its two arguments and runs only on redirects. Move destination validation from that callback to `url_filter:` to cover initial URLs as well as redirects, for both pages and images.
|
|
410
|
+
|
|
335
411
|
### Headers
|
|
336
412
|
|
|
337
413
|
By default, the following headers are set:
|
|
@@ -385,6 +461,28 @@ page.content_type # "image/png"
|
|
|
385
461
|
page.description # will return a garbled string
|
|
386
462
|
```
|
|
387
463
|
|
|
464
|
+
### Check content type before downloading
|
|
465
|
+
|
|
466
|
+
To avoid downloading large non-HTML files, enable `head_first`:
|
|
467
|
+
|
|
468
|
+
```ruby
|
|
469
|
+
page = MetaInspector.new('http://example.com/page', head_first: true)
|
|
470
|
+
```
|
|
471
|
+
|
|
472
|
+
This sends an HTTP HEAD request before GET. If a successful HEAD response declares
|
|
473
|
+
a content type other than `text/html`, MetaInspector raises `MetaInspector::NonHtmlError`
|
|
474
|
+
without downloading the body. Content-type parameters such as `charset` are ignored.
|
|
475
|
+
With `allow_non_html_content: true`, the download proceeds regardless of content type.
|
|
476
|
+
|
|
477
|
+
If HEAD omits the content type or returns an unsuccessful status (including 405 or
|
|
478
|
+
501 for servers that do not support HEAD), MetaInspector continues with GET and its
|
|
479
|
+
usual content-type check. HEAD timeouts and connection errors use the same exception
|
|
480
|
+
handling as GET. The final page data, response headers and status come from GET.
|
|
481
|
+
|
|
482
|
+
This option is off by default because it adds a request and not every server
|
|
483
|
+
supports HEAD. Both requests use the configured headers, timeouts, retries and
|
|
484
|
+
redirect settings. Passing a `document` directly makes no HTTP requests.
|
|
485
|
+
|
|
388
486
|
### URL Normalization
|
|
389
487
|
|
|
390
488
|
By default, URLs are normalized using the Addressable gem. For example:
|
|
@@ -405,7 +503,13 @@ You can disable URL normalization by passing the `normalize_url: false` option.
|
|
|
405
503
|
|
|
406
504
|
### Image downloading
|
|
407
505
|
|
|
408
|
-
|
|
506
|
+
Image downloading is enabled by default. `page.images.with_size` and `page.images.largest` fetch images with missing HTML dimensions; `page.images.best` also does this when there is no preferred metadata image. The page controls these image URLs, which may point to other hosts.
|
|
507
|
+
|
|
508
|
+
Image requests use Faraday with the page's proxy, TLS, headers and redirect settings, and respect [`url_filter:`](#filtering-request-urls). Downloaded bytes are passed to [fastimage](https://github.com/sdsykes/fastimage) to determine dimensions. Dimensions already present in HTML are preserved without downloading the image.
|
|
509
|
+
|
|
510
|
+
Custom page headers are preserved for images on the same origin (scheme, host and port). Requests to other origins, including image redirects, send only standard image request headers such as `User-Agent` and `Accept`; they do not receive page credentials or arbitrary custom headers.
|
|
511
|
+
|
|
512
|
+
Each size lookup stops at a 256 KiB response body budget or a two-second overall timeout, including redirects. If the dimensions cannot be determined within these limits, they remain `[0, 0]`. Streamed image responses are not stored in the HTTP response cache, and failed image requests are not retried.
|
|
409
513
|
|
|
410
514
|
If you want to disable this, you can specify it like this:
|
|
411
515
|
|
|
@@ -413,15 +517,30 @@ If you want to disable this, you can specify it like this:
|
|
|
413
517
|
page = MetaInspector.new('http://example.com', download_images: false)
|
|
414
518
|
```
|
|
415
519
|
|
|
520
|
+
To limit how many images are downloaded to determine their dimensions, use `max_image_downloads`:
|
|
521
|
+
|
|
522
|
+
```ruby
|
|
523
|
+
page = MetaInspector.new('http://example.com', max_image_downloads: 10)
|
|
524
|
+
page.images.with_size
|
|
525
|
+
```
|
|
526
|
+
|
|
527
|
+
The default is `nil` (unlimited). The option accepts a non-negative integer or `nil`; `0` disables image downloads. `download_images: false` always disables downloads, regardless of the limit.
|
|
528
|
+
|
|
529
|
+
The limit counts image size lookup attempts in HTML order, after excluding unparseable URLs and duplicates. Images with both HTML dimensions already present do not consume the limit, and those dimensions are preserved even after the limit is reached. Failed lookups count toward the limit. Images with incomplete dimensions that are not downloaded remain in `images.with_size` with dimensions `[0, 0]`, matching the behavior when downloads are disabled.
|
|
530
|
+
|
|
531
|
+
The limit leaves `images.to_a` unchanged, but can affect `images.largest` and the largest-image fallback in `images.best`, since images with unknown dimensions cannot be ranked by size. Size lookup results are cached per document, so repeated calls reuse the same results. This limits size lookup attempts, not elapsed time or HTTP requests made while following image redirects.
|
|
532
|
+
|
|
416
533
|
### Caching responses
|
|
417
534
|
|
|
418
|
-
MetaInspector can be configured to use [Faraday::HttpCache](https://github.com/plataformatec/faraday-http-cache) to cache page responses. For that you should pass the `faraday_http_cache` option with at least the `:store` key, for example:
|
|
535
|
+
MetaInspector can be configured to use [Faraday::HttpCache](https://github.com/plataformatec/faraday-http-cache) to cache page responses. For that you should pass the `faraday_http_cache` option inside `faraday_options`, with at least the `:store` key, for example:
|
|
419
536
|
|
|
420
537
|
```ruby
|
|
421
538
|
cache = ActiveSupport::Cache.lookup_store(:file_store, '/tmp/cache')
|
|
422
|
-
page = MetaInspector.new('http://example.com', faraday_http_cache: { store: cache })
|
|
539
|
+
page = MetaInspector.new('http://example.com', faraday_options: { faraday_http_cache: { store: cache } })
|
|
423
540
|
```
|
|
424
541
|
|
|
542
|
+
The top-level `faraday_http_cache` option still works, but it is deprecated and prints a warning, once per process. Passing it in both places raises an `ArgumentError`.
|
|
543
|
+
|
|
425
544
|
## Exception Handling
|
|
426
545
|
|
|
427
546
|
Web page scraping is tricky, you can expect to find different exceptions during the request of the page or the parsing of its contents. MetaInspector will encapsulate these exceptions on these main errors:
|
|
@@ -9,12 +9,23 @@ module MetaInspector
|
|
|
9
9
|
# * read_timeout: defaults to 20 seconds
|
|
10
10
|
# * retries: defaults to 3 times
|
|
11
11
|
# * allow_redirections: when true, follow HTTP redirects. Defaults to true
|
|
12
|
+
# * head_first: check content type with HEAD before GET. Defaults to false
|
|
12
13
|
# * document: the html of the url as a string
|
|
13
14
|
# * headers: object containing custom headers for the request
|
|
14
15
|
# * normalize_url: true by default
|
|
15
|
-
# *
|
|
16
|
+
# * download_images: fetch missing image dimensions. Defaults to true
|
|
17
|
+
# * max_image_downloads: maximum image size lookups, or nil for unlimited
|
|
18
|
+
# * url_filter: callable receiving a URI before each request; false/nil rejects it
|
|
19
|
+
# * faraday_options: an optional hash of options to pass to Faraday on the request.
|
|
20
|
+
# Its faraday_http_cache key configures response caching with Faraday::HttpCache
|
|
21
|
+
# * faraday_http_cache: deprecated, use faraday_options: { faraday_http_cache: ... }
|
|
16
22
|
def initialize(initial_url, options = {})
|
|
17
23
|
options = defaults.merge(options)
|
|
24
|
+
@max_image_downloads = options[:max_image_downloads]
|
|
25
|
+
unless @max_image_downloads.nil? || (@max_image_downloads.is_a?(Integer) && @max_image_downloads >= 0)
|
|
26
|
+
raise ArgumentError, 'max_image_downloads must be a non-negative Integer or nil'
|
|
27
|
+
end
|
|
28
|
+
|
|
18
29
|
@connection_timeout = options[:connection_timeout]
|
|
19
30
|
@read_timeout = options[:read_timeout]
|
|
20
31
|
@retries = options[:retries]
|
|
@@ -29,8 +40,12 @@ module MetaInspector
|
|
|
29
40
|
@normalize_url = options[:normalize_url]
|
|
30
41
|
@faraday_options = options[:faraday_options]
|
|
31
42
|
@faraday_http_cache = options[:faraday_http_cache]
|
|
43
|
+
@http_session = MetaInspector::HttpSession.new(options)
|
|
32
44
|
@url = MetaInspector::URL.new(initial_url, normalize: @normalize_url)
|
|
33
|
-
@request = MetaInspector::Request.new(@url,
|
|
45
|
+
@request = MetaInspector::Request.new(@url, http_session: @http_session,
|
|
46
|
+
allow_redirections: @allow_redirections,
|
|
47
|
+
head_first: options[:head_first],
|
|
48
|
+
allow_non_html_content: @allow_non_html_content,
|
|
34
49
|
connection_timeout: @connection_timeout,
|
|
35
50
|
read_timeout: @read_timeout,
|
|
36
51
|
retries: @retries,
|
|
@@ -38,7 +53,9 @@ module MetaInspector
|
|
|
38
53
|
headers: @headers,
|
|
39
54
|
faraday_options: @faraday_options,
|
|
40
55
|
faraday_http_cache: @faraday_http_cache) unless @document
|
|
41
|
-
@parser = MetaInspector::Parser.new(self, download_images: @download_images
|
|
56
|
+
@parser = MetaInspector::Parser.new(self, download_images: @download_images,
|
|
57
|
+
max_image_downloads: @max_image_downloads,
|
|
58
|
+
image_downloader: MetaInspector::ImageDownloader.new(@http_session, @url, options))
|
|
42
59
|
end
|
|
43
60
|
|
|
44
61
|
extend Forwardable
|
|
@@ -47,7 +64,7 @@ module MetaInspector
|
|
|
47
64
|
|
|
48
65
|
delegate [:content_type, :response] => :@request
|
|
49
66
|
|
|
50
|
-
delegate [:parsed, :title, :best_title, :author, :best_author,
|
|
67
|
+
delegate [:parsed, :language, :title, :best_title, :author, :best_author,
|
|
51
68
|
:h1, :h2, :h3, :h4, :h5, :h6, :description, :best_description, :links,
|
|
52
69
|
:images, :feeds, :feed, :charset, :meta_tags,
|
|
53
70
|
:meta_tag, :meta, :favicon,
|
|
@@ -60,6 +77,7 @@ module MetaInspector
|
|
|
60
77
|
'scheme' => scheme,
|
|
61
78
|
'host' => host,
|
|
62
79
|
'root_url' => root_url,
|
|
80
|
+
'language' => language,
|
|
63
81
|
'title' => title,
|
|
64
82
|
'best_title' => best_title,
|
|
65
83
|
'author' => author,
|
|
@@ -101,8 +119,10 @@ module MetaInspector
|
|
|
101
119
|
},
|
|
102
120
|
:allow_redirections => true,
|
|
103
121
|
:allow_non_html_content => false,
|
|
122
|
+
:head_first => false,
|
|
104
123
|
:normalize_url => true,
|
|
105
|
-
:download_images => true
|
|
124
|
+
:download_images => true,
|
|
125
|
+
:max_image_downloads => nil }
|
|
106
126
|
end
|
|
107
127
|
|
|
108
128
|
def default_user_agent
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
require 'http/cookie'
|
|
2
|
+
require 'faraday'
|
|
3
|
+
require 'faraday-cookie_jar'
|
|
4
|
+
require 'faraday-http-cache'
|
|
5
|
+
require 'faraday/encoding'
|
|
6
|
+
require 'faraday/follow_redirects'
|
|
7
|
+
require 'faraday/gzip'
|
|
8
|
+
require 'faraday/retry'
|
|
9
|
+
require_relative 'url_policy'
|
|
10
|
+
|
|
11
|
+
module MetaInspector
|
|
12
|
+
# Builds page and binary image transports from the same caller configuration.
|
|
13
|
+
class HttpSession
|
|
14
|
+
def initialize(options = {})
|
|
15
|
+
@allow_redirections = options[:allow_redirections]
|
|
16
|
+
@retries = options[:retries]
|
|
17
|
+
@headers = options[:headers]
|
|
18
|
+
@url_filter = options[:url_filter]
|
|
19
|
+
if !@url_filter.nil? && !@url_filter.respond_to?(:call)
|
|
20
|
+
raise ArgumentError, 'url_filter must respond to call'
|
|
21
|
+
end
|
|
22
|
+
@cache = cache_option(options)
|
|
23
|
+
@faraday_options = (options[:faraday_options] || {}).except(:faraday_http_cache)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def build(url, image: false, origin: nil)
|
|
27
|
+
URLPolicy.validate!(url)
|
|
28
|
+
faraday_options = @faraday_options.merge(url: url)
|
|
29
|
+
redirects = (faraday_options.delete(:redirect) || {}).dup
|
|
30
|
+
Faraday.new(faraday_options) do |faraday|
|
|
31
|
+
unless image
|
|
32
|
+
faraday.request :retry, max: @retries
|
|
33
|
+
faraday.request :gzip
|
|
34
|
+
end
|
|
35
|
+
if @allow_redirections
|
|
36
|
+
redirects[:limit] ||= 10
|
|
37
|
+
faraday.use Faraday::FollowRedirects::Middleware, **redirects
|
|
38
|
+
faraday.use :cookie_jar
|
|
39
|
+
end
|
|
40
|
+
faraday.use URLPolicy, @url_filter, image ? origin : nil
|
|
41
|
+
if !image && @cache.is_a?(Hash)
|
|
42
|
+
cache_options = @cache.dup
|
|
43
|
+
cache_options[:serializer] ||= Marshal
|
|
44
|
+
faraday.use Faraday::HttpCache, **cache_options
|
|
45
|
+
end
|
|
46
|
+
faraday.headers.merge!(@headers || {})
|
|
47
|
+
faraday.response :encoding unless image
|
|
48
|
+
faraday.adapter :net_http
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def cache_option(options)
|
|
55
|
+
nested = (options[:faraday_options] || {})[:faraday_http_cache]
|
|
56
|
+
top_level = options[:faraday_http_cache]
|
|
57
|
+
return nested if top_level.nil?
|
|
58
|
+
unless nested.nil?
|
|
59
|
+
raise ArgumentError, 'Pass faraday_http_cache inside faraday_options or as a top-level option, not both'
|
|
60
|
+
end
|
|
61
|
+
Request.warn_faraday_http_cache_deprecation
|
|
62
|
+
top_level
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
require 'stringio'
|
|
2
|
+
require 'fastimage'
|
|
3
|
+
require 'timeout'
|
|
4
|
+
|
|
5
|
+
module MetaInspector
|
|
6
|
+
class ImageDownloader
|
|
7
|
+
MAX_BYTES = 256 * 1024
|
|
8
|
+
TIMEOUT = 2
|
|
9
|
+
|
|
10
|
+
def initialize(session, origin, options = {})
|
|
11
|
+
@session = session
|
|
12
|
+
@origin = origin
|
|
13
|
+
@connection_timeout = [options[:connection_timeout] || TIMEOUT, TIMEOUT].min
|
|
14
|
+
@read_timeout = [options[:read_timeout] || TIMEOUT, TIMEOUT].min
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def size(url)
|
|
18
|
+
bytes = ''.b
|
|
19
|
+
received = 0
|
|
20
|
+
status = nil
|
|
21
|
+
stop = Object.new
|
|
22
|
+
Timeout.timeout(TIMEOUT) do
|
|
23
|
+
session = @session.build(url, image: true, origin: @origin.url)
|
|
24
|
+
catch(stop) do
|
|
25
|
+
session.get do |request|
|
|
26
|
+
request.headers['Range'] = "bytes=0-#{MAX_BYTES - 1}"
|
|
27
|
+
request.headers['Accept-Encoding'] = 'identity'
|
|
28
|
+
request.options.open_timeout = @connection_timeout
|
|
29
|
+
request.options.timeout = @read_timeout
|
|
30
|
+
request.options.on_data = proc do |chunk, _total, env|
|
|
31
|
+
status = env.status
|
|
32
|
+
remaining = MAX_BYTES - received
|
|
33
|
+
received += chunk.bytesize
|
|
34
|
+
# Count redirect bodies too, across the entire lookup.
|
|
35
|
+
if (200..299).cover?(status)
|
|
36
|
+
bytes << chunk.b.byteslice(0, remaining)
|
|
37
|
+
elsif !(300..399).cover?(status)
|
|
38
|
+
throw stop
|
|
39
|
+
end
|
|
40
|
+
throw stop if received >= MAX_BYTES
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
FastImage.size(StringIO.new(bytes)) if status && (200..299).cover?(status)
|
|
46
|
+
rescue URLPolicy::Failure => failure
|
|
47
|
+
raise failure.original
|
|
48
|
+
rescue Faraday::TimeoutError, Faraday::ConnectionFailed, Faraday::SSLError,
|
|
49
|
+
Faraday::FollowRedirects::RedirectLimitReached, URI::InvalidURIError, Timeout::Error
|
|
50
|
+
nil
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -14,7 +14,9 @@ module MetaInspector
|
|
|
14
14
|
@meta_tag_parser = MetaInspector::Parsers::MetaTagsParser.new(self)
|
|
15
15
|
@links_parser = MetaInspector::Parsers::LinksParser.new(self)
|
|
16
16
|
@download_images = options[:download_images]
|
|
17
|
-
@images_parser = MetaInspector::Parsers::ImagesParser.new(self, download_images: @download_images
|
|
17
|
+
@images_parser = MetaInspector::Parsers::ImagesParser.new(self, download_images: @download_images,
|
|
18
|
+
max_image_downloads: options[:max_image_downloads],
|
|
19
|
+
image_downloader: options[:image_downloader])
|
|
18
20
|
@texts_parser = MetaInspector::Parsers::TextsParser.new(self)
|
|
19
21
|
|
|
20
22
|
parsed # parse early so we can fail early
|
|
@@ -26,7 +28,7 @@ module MetaInspector
|
|
|
26
28
|
delegate [:head_links, :stylesheets, :canonicals, :feeds, :feed] => :@head_links_parser
|
|
27
29
|
delegate [:links, :base_url] => :@links_parser
|
|
28
30
|
delegate :images => :@images_parser
|
|
29
|
-
delegate [:title, :best_title, :author, :best_author, :description, :best_description,
|
|
31
|
+
delegate [:language, :title, :best_title, :author, :best_author, :description, :best_description,
|
|
30
32
|
:h1, :h2, :h3, :h4, :h5, :h6] => :@texts_parser
|
|
31
33
|
|
|
32
34
|
# Returns the whole parsed document
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
|
|
1
|
+
require_relative '../image_downloader'
|
|
2
2
|
|
|
3
3
|
module MetaInspector
|
|
4
4
|
module Parsers
|
|
@@ -9,7 +9,9 @@ module MetaInspector
|
|
|
9
9
|
include Enumerable
|
|
10
10
|
|
|
11
11
|
def initialize(main_parser, options = {})
|
|
12
|
+
@image_downloader = options[:image_downloader]
|
|
12
13
|
@download_images = options[:download_images]
|
|
14
|
+
@max_image_downloads = options[:max_image_downloads]
|
|
13
15
|
super(main_parser)
|
|
14
16
|
end
|
|
15
17
|
|
|
@@ -17,7 +19,7 @@ module MetaInspector
|
|
|
17
19
|
self
|
|
18
20
|
end
|
|
19
21
|
|
|
20
|
-
# Returns
|
|
22
|
+
# Returns the Facebook Open Graph image, Twitter image, Parsely image or
|
|
21
23
|
# the largest image in the image collection
|
|
22
24
|
def best
|
|
23
25
|
owner_suggested || largest
|
|
@@ -26,9 +28,10 @@ module MetaInspector
|
|
|
26
28
|
# Returns the parsed image from Facebook's open graph property tags
|
|
27
29
|
# Most major websites now define this property and is usually relevant
|
|
28
30
|
# See doc at http://developers.facebook.com/docs/opengraph/
|
|
29
|
-
# If none found, tries with Twitter image
|
|
31
|
+
# If none found, tries with Twitter image, then Parsely image
|
|
30
32
|
def owner_suggested
|
|
31
|
-
suggested_img = content_of(meta['og:image']) || content_of(meta['twitter:image'])
|
|
33
|
+
suggested_img = content_of(meta['og:image']) || content_of(meta['twitter:image']) ||
|
|
34
|
+
content_of(meta['parsely-image-url'])
|
|
32
35
|
URL.absolutify(suggested_img, base_url, normalize: false) if suggested_img
|
|
33
36
|
end
|
|
34
37
|
|
|
@@ -39,17 +42,19 @@ module MetaInspector
|
|
|
39
42
|
imgs_with_size = img_nodes.map do |img_node|
|
|
40
43
|
[URL.absolutify(img_node['src'], base_url, normalize: false), img_node['width'], img_node['height']]
|
|
41
44
|
end
|
|
45
|
+
imgs_with_size.reject! { |url, width, height| url.nil? || url.empty? }
|
|
42
46
|
imgs_with_size.uniq! { |url, width, height| url }
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
47
|
+
download_attempts = 0
|
|
48
|
+
imgs_with_size.map! do |url, width, height|
|
|
49
|
+
if width.nil? || height.nil?
|
|
50
|
+
if @download_images && (@max_image_downloads.nil? || download_attempts < @max_image_downloads)
|
|
51
|
+
download_attempts += 1
|
|
52
|
+
width, height = @image_downloader.size(url)
|
|
53
|
+
else
|
|
54
|
+
width, height = [0, 0]
|
|
55
|
+
end
|
|
52
56
|
end
|
|
57
|
+
[url, width.to_i, height.to_i]
|
|
53
58
|
end
|
|
54
59
|
imgs_with_size.sort_by { |url, width, height| -(width.to_i * height.to_i) }
|
|
55
60
|
end
|
|
@@ -93,7 +98,7 @@ module MetaInspector
|
|
|
93
98
|
end
|
|
94
99
|
|
|
95
100
|
def content_of(content)
|
|
96
|
-
return nil if content.nil? || content.empty?
|
|
101
|
+
return nil if content.nil? || content.strip.empty?
|
|
97
102
|
content
|
|
98
103
|
end
|
|
99
104
|
end
|
|
@@ -46,8 +46,9 @@ module MetaInspector
|
|
|
46
46
|
# Returns the base url to absolutify relative links.
|
|
47
47
|
# This can be the one set on a <base> tag,
|
|
48
48
|
# or the url of the document if no <base> tag was found.
|
|
49
|
+
# Relative base hrefs are resolved against the document URL.
|
|
49
50
|
def base_url
|
|
50
|
-
current_base_href = base_href.to_s.strip.empty? ? nil : URL.absolutify(base_href,
|
|
51
|
+
current_base_href = base_href.to_s.strip.empty? ? nil : URL.absolutify(base_href, url)
|
|
51
52
|
current_base_href || url
|
|
52
53
|
end
|
|
53
54
|
|
|
@@ -3,6 +3,12 @@ module MetaInspector
|
|
|
3
3
|
class TextsParser < Base
|
|
4
4
|
delegate [:parsed, :meta] => :@main_parser
|
|
5
5
|
|
|
6
|
+
# Returns the declared HTML language, or nil when missing or blank
|
|
7
|
+
def language
|
|
8
|
+
value = parsed.at_css('html')&.[]('lang')&.strip
|
|
9
|
+
value unless value.to_s.empty?
|
|
10
|
+
end
|
|
11
|
+
|
|
6
12
|
# Returns the parsed document title, from the content of the <title> tag
|
|
7
13
|
# within the <head> section.
|
|
8
14
|
def title
|
|
@@ -42,12 +48,13 @@ module MetaInspector
|
|
|
42
48
|
@author ||= meta['author']
|
|
43
49
|
end
|
|
44
50
|
|
|
45
|
-
# An author getter that returns the first non-
|
|
51
|
+
# An author getter that returns the first non-empty author
|
|
46
52
|
# from the following candidates:
|
|
47
|
-
# - the standard meta
|
|
53
|
+
# - the standard meta author
|
|
48
54
|
# - a link with the relational attribute "author"
|
|
49
55
|
# - address tag which may contain the author
|
|
50
56
|
# - the twitter:creator meta tag for the username
|
|
57
|
+
# - the parsely-author meta tag
|
|
51
58
|
def best_author
|
|
52
59
|
@best_author ||= find_best_author
|
|
53
60
|
end
|
|
@@ -80,7 +87,8 @@ module MetaInspector
|
|
|
80
87
|
meta['og:title'],
|
|
81
88
|
parsed.css('head title'),
|
|
82
89
|
parsed.css('body title'),
|
|
83
|
-
parsed.css('h1').first
|
|
90
|
+
parsed.css('h1').first,
|
|
91
|
+
meta['parsely-title']
|
|
84
92
|
]
|
|
85
93
|
candidates.flatten!
|
|
86
94
|
candidates.compact!
|
|
@@ -94,7 +102,8 @@ module MetaInspector
|
|
|
94
102
|
meta['author'],
|
|
95
103
|
parsed.css('a[rel="author"]').first,
|
|
96
104
|
parsed.css('address').first,
|
|
97
|
-
meta['twitter:creator']
|
|
105
|
+
meta['twitter:creator'],
|
|
106
|
+
meta['parsely-author']
|
|
98
107
|
]
|
|
99
108
|
candidates.flatten!
|
|
100
109
|
candidates.compact!
|