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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c74590fe15ada0cba938c3a9c4a0d167fcb10bd6159615751652e6376dec7110
4
- data.tar.gz: d92a0ba92d9d7c6b1fa3404cdebc001a49080cbeba419766f6d60a2b96e1b582
3
+ metadata.gz: ba31ea21114c1932ca2dde179a2812f24d429b7420807fec232de65ea1ba824e
4
+ data.tar.gz: 99188cbec093cfd1d22cdfd3d31bf181cf193e937f6c7b23a61cb6ed39ba1ca2
5
5
  SHA512:
6
- metadata.gz: 2f0d12a22b1288aae2ca671ca5aa0516dad4aa3590b32be9337a626f5741d2c07d8050c26f013fa7a6478f83733e372425b0ce0ad450ee7d8f2d8a64f4efdbd9
7
- data.tar.gz: 4a3662b35d9e4b5485dc00cd8101d216a086e70c08b152a0dc1c89045f7f179031b8597ccf92b106792f604b340aa49907de8dc6ca9758c6755f32b55fd6a109
6
+ metadata.gz: bc39e09f57cadd29f60e6a41556878d807a629e99de37b6b64b3ebf090d32aeca63d49b38af436fa16e5fff29e4cf5e927d99df5b37f35385ea30165a8de55e3
7
+ data.tar.gz: d4a4777631f8c63d238236960148330a3dc7ba0f4ae2ec0b497cc3e03f33a5ca8daca05141dbaaa20a99a54fd933d5a5c9cb7056dd4b84b12c1a515119935574
data/.gitignore CHANGED
@@ -9,3 +9,4 @@ pkg/*
9
9
  .rubocop_todo.yml
10
10
  .rubocop.yml
11
11
  .tool-versions
12
+ docs/
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, if defined with the og:image or twitter:image metatags. Fallback to the first page.images array element
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
-   ip_address = Resolv.getaddress(next_request.url.host)
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
- When you ask for the largest image on the page with `page.images.largest`, it will be determined by its height and width attributes on the HTML markup, and also by downloading a small portion of each image using the [fastimage](https://github.com/sdsykes/fastimage) gem. This is really fast as it doesn't download the entire images, normally just the headers of the image files.
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
- # * faraday_options: an optional hash of options to pass to Faraday on the request
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, allow_redirections: @allow_redirections,
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
- require 'fastimage'
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 either the Facebook Open Graph image, twitter suggested image or
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
- if @download_images
44
- imgs_with_size.map! do |url, width, height|
45
- width, height = FastImage.size(url) if width.nil? || height.nil?
46
- [url, width.to_i, height.to_i]
47
- end
48
- else
49
- imgs_with_size.map! do |url, width, height|
50
- width, height = [0, 0] if width.nil? || height.nil?
51
- [url, width.to_i, height.to_i]
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, URL.new(url).root_url)
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-nil description
51
+ # An author getter that returns the first non-empty author
46
52
  # from the following candidates:
47
- # - the standard meta description
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!