metainspector 5.18.1 → 5.19.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: 5bc16dfdcdd22661ba7bb196d4db8750a5fa4d48d054197982768fa7fac44835
4
+ data.tar.gz: ce356b1f0f2511069ff96329e775e3581282d68d395c4dada93a9275e5dd4aa6
5
5
  SHA512:
6
- metadata.gz: 2f0d12a22b1288aae2ca671ca5aa0516dad4aa3590b32be9337a626f5741d2c07d8050c26f013fa7a6478f83733e372425b0ce0ad450ee7d8f2d8a64f4efdbd9
7
- data.tar.gz: 4a3662b35d9e4b5485dc00cd8101d216a086e70c08b152a0dc1c89045f7f179031b8597ccf92b106792f604b340aa49907de8dc6ca9758c6755f32b55fd6a109
6
+ metadata.gz: 6a669057baeb8107c8ae6940f4323f60664982fa3c8c74ce4f029b434ebfe227436c743a6b6041acbcddfdc2ea7fb38704a6126d3204b18479a8ca81b2b63a0e
7
+ data.tar.gz: 123f1cbb41fd5492307d96e316b17c91fc4f17d523a12f9fd865be49a3de31d8b932ceae6365bc7e1f6f49fd9721f8626eb9a8b5bbcbf9d86b2e972aedaa1544
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,15 @@
1
1
  # MetaInspector Changelog
2
2
 
3
+ ## [Changes in 5.19.0](https://github.com/jaimeiniesta/metainspector/compare/v5.18.1...v5.19.0)
4
+
5
+ * 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`.
6
+ * 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).
7
+ * Add opt-in `max_image_downloads` to limit image size lookup attempts while preserving dimensions already available in HTML (#242).
8
+ * 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).
9
+ * Resolve relative base hrefs against the full document URL, preserving nested paths and non-default ports (#240).
10
+ * 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)).
11
+ * Add opt-in `head_first: true` to check content type with HEAD and reject non-HTML files before downloading them (#221).
12
+
3
13
  ## [Changes in 5.18.1](https://github.com/jaimeiniesta/metainspector/compare/v5.18.0...v5.18.1)
4
14
 
5
15
  * 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:
@@ -385,6 +405,28 @@ page.content_type # "image/png"
385
405
  page.description # will return a garbled string
386
406
  ```
387
407
 
408
+ ### Check content type before downloading
409
+
410
+ To avoid downloading large non-HTML files, enable `head_first`:
411
+
412
+ ```ruby
413
+ page = MetaInspector.new('http://example.com/page', head_first: true)
414
+ ```
415
+
416
+ This sends an HTTP HEAD request before GET. If a successful HEAD response declares
417
+ a content type other than `text/html`, MetaInspector raises `MetaInspector::NonHtmlError`
418
+ without downloading the body. Content-type parameters such as `charset` are ignored.
419
+ With `allow_non_html_content: true`, the download proceeds regardless of content type.
420
+
421
+ If HEAD omits the content type or returns an unsuccessful status (including 405 or
422
+ 501 for servers that do not support HEAD), MetaInspector continues with GET and its
423
+ usual content-type check. HEAD timeouts and connection errors use the same exception
424
+ handling as GET. The final page data, response headers and status come from GET.
425
+
426
+ This option is off by default because it adds a request and not every server
427
+ supports HEAD. Both requests use the configured headers, timeouts, retries and
428
+ redirect settings. Passing a `document` directly makes no HTTP requests.
429
+
388
430
  ### URL Normalization
389
431
 
390
432
  By default, URLs are normalized using the Addressable gem. For example:
@@ -413,6 +455,19 @@ If you want to disable this, you can specify it like this:
413
455
  page = MetaInspector.new('http://example.com', download_images: false)
414
456
  ```
415
457
 
458
+ To limit how many images are downloaded to determine their dimensions, use `max_image_downloads`:
459
+
460
+ ```ruby
461
+ page = MetaInspector.new('http://example.com', max_image_downloads: 10)
462
+ page.images.with_size
463
+ ```
464
+
465
+ 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.
466
+
467
+ 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.
468
+
469
+ 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.
470
+
416
471
  ### Caching responses
417
472
 
418
473
  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:
@@ -9,12 +9,20 @@ 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
16
+ # * download_images: fetch missing image dimensions. Defaults to true
17
+ # * max_image_downloads: maximum image size lookups, or nil for unlimited
15
18
  # * faraday_options: an optional hash of options to pass to Faraday on the request
16
19
  def initialize(initial_url, options = {})
17
20
  options = defaults.merge(options)
21
+ @max_image_downloads = options[:max_image_downloads]
22
+ unless @max_image_downloads.nil? || (@max_image_downloads.is_a?(Integer) && @max_image_downloads >= 0)
23
+ raise ArgumentError, 'max_image_downloads must be a non-negative Integer or nil'
24
+ end
25
+
18
26
  @connection_timeout = options[:connection_timeout]
19
27
  @read_timeout = options[:read_timeout]
20
28
  @retries = options[:retries]
@@ -31,6 +39,8 @@ module MetaInspector
31
39
  @faraday_http_cache = options[:faraday_http_cache]
32
40
  @url = MetaInspector::URL.new(initial_url, normalize: @normalize_url)
33
41
  @request = MetaInspector::Request.new(@url, allow_redirections: @allow_redirections,
42
+ head_first: options[:head_first],
43
+ allow_non_html_content: @allow_non_html_content,
34
44
  connection_timeout: @connection_timeout,
35
45
  read_timeout: @read_timeout,
36
46
  retries: @retries,
@@ -38,7 +48,8 @@ module MetaInspector
38
48
  headers: @headers,
39
49
  faraday_options: @faraday_options,
40
50
  faraday_http_cache: @faraday_http_cache) unless @document
41
- @parser = MetaInspector::Parser.new(self, download_images: @download_images)
51
+ @parser = MetaInspector::Parser.new(self, download_images: @download_images,
52
+ max_image_downloads: @max_image_downloads)
42
53
  end
43
54
 
44
55
  extend Forwardable
@@ -47,7 +58,7 @@ module MetaInspector
47
58
 
48
59
  delegate [:content_type, :response] => :@request
49
60
 
50
- delegate [:parsed, :title, :best_title, :author, :best_author,
61
+ delegate [:parsed, :language, :title, :best_title, :author, :best_author,
51
62
  :h1, :h2, :h3, :h4, :h5, :h6, :description, :best_description, :links,
52
63
  :images, :feeds, :feed, :charset, :meta_tags,
53
64
  :meta_tag, :meta, :favicon,
@@ -60,6 +71,7 @@ module MetaInspector
60
71
  'scheme' => scheme,
61
72
  'host' => host,
62
73
  'root_url' => root_url,
74
+ 'language' => language,
63
75
  'title' => title,
64
76
  'best_title' => best_title,
65
77
  'author' => author,
@@ -101,8 +113,10 @@ module MetaInspector
101
113
  },
102
114
  :allow_redirections => true,
103
115
  :allow_non_html_content => false,
116
+ :head_first => false,
104
117
  :normalize_url => true,
105
- :download_images => true }
118
+ :download_images => true,
119
+ :max_image_downloads => nil }
106
120
  end
107
121
 
108
122
  def default_user_agent
@@ -14,7 +14,8 @@ 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])
18
19
  @texts_parser = MetaInspector::Parsers::TextsParser.new(self)
19
20
 
20
21
  parsed # parse early so we can fail early
@@ -26,7 +27,7 @@ module MetaInspector
26
27
  delegate [:head_links, :stylesheets, :canonicals, :feeds, :feed] => :@head_links_parser
27
28
  delegate [:links, :base_url] => :@links_parser
28
29
  delegate :images => :@images_parser
29
- delegate [:title, :best_title, :author, :best_author, :description, :best_description,
30
+ delegate [:language, :title, :best_title, :author, :best_author, :description, :best_description,
30
31
  :h1, :h2, :h3, :h4, :h5, :h6] => :@texts_parser
31
32
 
32
33
  # Returns the whole parsed document
@@ -10,6 +10,7 @@ module MetaInspector
10
10
 
11
11
  def initialize(main_parser, options = {})
12
12
  @download_images = options[:download_images]
13
+ @max_image_downloads = options[:max_image_downloads]
13
14
  super(main_parser)
14
15
  end
15
16
 
@@ -17,7 +18,7 @@ module MetaInspector
17
18
  self
18
19
  end
19
20
 
20
- # Returns either the Facebook Open Graph image, twitter suggested image or
21
+ # Returns the Facebook Open Graph image, Twitter image, Parsely image or
21
22
  # the largest image in the image collection
22
23
  def best
23
24
  owner_suggested || largest
@@ -26,9 +27,10 @@ module MetaInspector
26
27
  # Returns the parsed image from Facebook's open graph property tags
27
28
  # Most major websites now define this property and is usually relevant
28
29
  # See doc at http://developers.facebook.com/docs/opengraph/
29
- # If none found, tries with Twitter image
30
+ # If none found, tries with Twitter image, then Parsely image
30
31
  def owner_suggested
31
- suggested_img = content_of(meta['og:image']) || content_of(meta['twitter:image'])
32
+ suggested_img = content_of(meta['og:image']) || content_of(meta['twitter:image']) ||
33
+ content_of(meta['parsely-image-url'])
32
34
  URL.absolutify(suggested_img, base_url, normalize: false) if suggested_img
33
35
  end
34
36
 
@@ -39,17 +41,19 @@ module MetaInspector
39
41
  imgs_with_size = img_nodes.map do |img_node|
40
42
  [URL.absolutify(img_node['src'], base_url, normalize: false), img_node['width'], img_node['height']]
41
43
  end
44
+ imgs_with_size.reject! { |url, width, height| url.nil? || url.empty? }
42
45
  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]
46
+ download_attempts = 0
47
+ imgs_with_size.map! do |url, width, height|
48
+ if width.nil? || height.nil?
49
+ if @download_images && (@max_image_downloads.nil? || download_attempts < @max_image_downloads)
50
+ download_attempts += 1
51
+ width, height = FastImage.size(url)
52
+ else
53
+ width, height = [0, 0]
54
+ end
52
55
  end
56
+ [url, width.to_i, height.to_i]
53
57
  end
54
58
  imgs_with_size.sort_by { |url, width, height| -(width.to_i * height.to_i) }
55
59
  end
@@ -93,7 +97,7 @@ module MetaInspector
93
97
  end
94
98
 
95
99
  def content_of(content)
96
- return nil if content.nil? || content.empty?
100
+ return nil if content.nil? || content.strip.empty?
97
101
  content
98
102
  end
99
103
  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!
@@ -18,6 +18,8 @@ module MetaInspector
18
18
  fail MetaInspector::RequestError.new('URL must be HTTP') unless @url.url =~ /http[s]?:\/\//i
19
19
 
20
20
  @allow_redirections = options[:allow_redirections]
21
+ @head_first = options[:head_first]
22
+ @allow_non_html_content = options[:allow_non_html_content]
21
23
  @connection_timeout = options[:connection_timeout]
22
24
  @read_timeout = options[:read_timeout]
23
25
  @retries = options[:retries]
@@ -42,8 +44,7 @@ module MetaInspector
42
44
  end
43
45
 
44
46
  def content_type
45
- return nil if response.headers['content-type'].nil?
46
- response.headers['content-type'].split(';')[0] if response
47
+ response_content_type(response)
47
48
  end
48
49
 
49
50
  def response
@@ -58,10 +59,12 @@ module MetaInspector
58
59
 
59
60
  def fetch
60
61
  Timeout::timeout(fatal_timeout) do
61
- @faraday_options.merge!(:url => url)
62
- follow_redirects_options = @faraday_options.delete(:redirect) || {}
62
+ # Work on copies: the caller may reuse or freeze the hashes it passed in, and
63
+ # losing :redirect would drop a callback that blocks redirects to private hosts
64
+ faraday_options = @faraday_options.merge(:url => url)
65
+ follow_redirects_options = (faraday_options.delete(:redirect) || {}).dup
63
66
 
64
- session = Faraday.new(@faraday_options) do |faraday|
67
+ session = Faraday.new(faraday_options) do |faraday|
65
68
  faraday.request :retry, max: @retries
66
69
 
67
70
  faraday.request :gzip
@@ -73,8 +76,9 @@ module MetaInspector
73
76
  end
74
77
 
75
78
  if @faraday_http_cache.is_a?(Hash)
76
- @faraday_http_cache[:serializer] ||= Marshal
77
- faraday.use Faraday::HttpCache, **@faraday_http_cache
79
+ cache_options = @faraday_http_cache.dup
80
+ cache_options[:serializer] ||= Marshal
81
+ faraday.use Faraday::HttpCache, **cache_options
78
82
  end
79
83
 
80
84
  faraday.headers.merge!(@headers || {})
@@ -82,10 +86,8 @@ module MetaInspector
82
86
  faraday.adapter :net_http
83
87
  end
84
88
 
85
- response = session.get do |req|
86
- req.options.timeout = @connection_timeout
87
- req.options.open_timeout = @read_timeout
88
- end
89
+ check_head_content_type(session) if @head_first
90
+ response = perform_request(session, :get)
89
91
 
90
92
  if @allow_redirections
91
93
  @url.url = response.env.url.to_s
@@ -97,6 +99,27 @@ module MetaInspector
97
99
  raise MetaInspector::TimeoutError.new(e)
98
100
  end
99
101
 
102
+ def check_head_content_type(session)
103
+ head_response = perform_request(session, :head)
104
+ return unless head_response.success?
105
+
106
+ type = response_content_type(head_response)
107
+ if !@allow_non_html_content && !type.nil? && type != 'text/html'
108
+ fail MetaInspector::NonHtmlError.new "The url provided contains #{type} content instead of text/html content"
109
+ end
110
+ end
111
+
112
+ def response_content_type(response)
113
+ response.headers['content-type']&.split(';')&.first
114
+ end
115
+
116
+ def perform_request(session, method)
117
+ session.public_send(method) do |req|
118
+ req.options.timeout = @connection_timeout
119
+ req.options.open_timeout = @read_timeout
120
+ end
121
+ end
122
+
100
123
  # Timeouts when connecting / reading a request are handled by Faraday, but in the
101
124
  # case of URLs that respond with streaming, Faraday will never return. In that case,
102
125
  # we'll resort to our own timeout
@@ -1,3 +1,3 @@
1
1
  module MetaInspector
2
- VERSION = '5.18.1'
2
+ VERSION = '5.19.0'
3
3
  end
@@ -1,6 +1,18 @@
1
1
  require 'spec_helper'
2
2
 
3
3
  describe MetaInspector::Document do
4
+ describe 'max_image_downloads option' do
5
+ [-1, 1.5, '1', true, false].each do |limit|
6
+ it "should reject #{limit.inspect} before fetching the document" do
7
+ expect do
8
+ MetaInspector::Document.new('http://example.com/', max_image_downloads: limit)
9
+ end.to raise_error(ArgumentError, 'max_image_downloads must be a non-negative Integer or nil')
10
+
11
+ expect(WebMock).not_to have_requested(:get, 'http://example.com/')
12
+ end
13
+ end
14
+ end
15
+
4
16
  describe 'passing the contents of the document as html' do
5
17
  let(:doc) { MetaInspector::Document.new('http://cnn.com/', :document => "<html><head><title>Hello From Passed Html</title><a href='/hello'>Hello link</a></head><body></body></html>") }
6
18
 
@@ -17,6 +29,13 @@ describe MetaInspector::Document do
17
29
  expect(MetaInspector::Document.new('http://pagerankalert.com').to_s.class).to eq(String)
18
30
  end
19
31
 
32
+ it 'should include a nil language in the hash when the document has no declared language' do
33
+ stub_request(:get, 'http://example.com/').to_return(body: '<html><body></body></html>', headers: { 'Content-Type' => 'text/html' })
34
+ doc = MetaInspector::Document.new('http://example.com/')
35
+
36
+ expect(doc.to_hash).to include('language' => nil)
37
+ end
38
+
20
39
  it "should return a Hash with all the values set" do
21
40
  doc = MetaInspector::Document.new('http://pagerankalert.com')
22
41
  expect(doc.to_hash).to eq({
@@ -24,6 +43,7 @@ describe MetaInspector::Document do
24
43
  "scheme" => "http",
25
44
  "host" => "pagerankalert.com",
26
45
  "root_url" => "http://pagerankalert.com/",
46
+ "language" => "en",
27
47
  "title" => "PageRankAlert.com :: Track your PageRank changes & receive alerts",
28
48
  "best_title" => "PageRankAlert.com :: Track your PageRank changes & receive alerts",
29
49
  "author" => nil,
@@ -0,0 +1,195 @@
1
+ require 'spec_helper'
2
+
3
+ describe MetaInspector, 'head_first option' do
4
+ let(:page_url) { 'http://example.com/head-first' }
5
+ let(:html) { '<html><head><title>Downloaded page</title></head></html>' }
6
+ let!(:get_request) do
7
+ stub_request(:get, page_url).to_return(body: html, headers: { 'Content-Type' => 'text/html' })
8
+ end
9
+
10
+ it 'does not send HEAD by default' do
11
+ expect(MetaInspector.new(page_url).title).to eq('Downloaded page')
12
+
13
+ expect(a_request(:head, page_url)).not_to have_been_made
14
+ expect(get_request).to have_been_requested.once
15
+ end
16
+
17
+ it 'does not send HEAD when explicitly disabled' do
18
+ expect(MetaInspector.new(page_url, head_first: false).title).to eq('Downloaded page')
19
+
20
+ expect(a_request(:head, page_url)).not_to have_been_made
21
+ end
22
+
23
+ it 'sends HEAD before GET for HTML and exposes the GET response' do
24
+ requests = []
25
+ head_request = stub_request(:head, page_url).to_return do
26
+ requests << :head
27
+ { headers: { 'Content-Type' => 'text/html; charset=UTF-8', 'X-Response' => 'head' } }
28
+ end
29
+ stub_request(:get, page_url).to_return do
30
+ requests << :get
31
+ { body: html, headers: { 'Content-Type' => 'text/html', 'X-Response' => 'get' } }
32
+ end
33
+
34
+ page = MetaInspector.new(page_url, head_first: true)
35
+
36
+ expect(page.title).to eq('Downloaded page')
37
+ expect(page.content_type).to eq('text/html')
38
+ expect(page.response.headers['x-response']).to eq('get')
39
+ expect(page.to_s).to eq(html)
40
+ expect(head_request).to have_been_requested.once
41
+ expect(requests).to eq([:head, :get])
42
+ end
43
+
44
+ it 'rejects non-HTML content without sending GET' do
45
+ head_request = stub_request(:head, page_url)
46
+ .to_return(headers: { 'Content-Type' => 'application/pdf; version=1.7' })
47
+
48
+ expect do
49
+ MetaInspector.new(page_url, head_first: true)
50
+ end.to raise_error(MetaInspector::NonHtmlError,
51
+ 'The url provided contains application/pdf content instead of text/html content')
52
+
53
+ expect(head_request).to have_been_requested.once
54
+ expect(get_request).not_to have_been_requested
55
+ end
56
+
57
+ it 'downloads non-HTML content when explicitly allowed' do
58
+ head_request = stub_request(:head, page_url).to_return(headers: { 'Content-Type' => 'text/plain' })
59
+ stub_request(:get, page_url).to_return(body: 'Plain text', headers: { 'Content-Type' => 'text/plain' })
60
+
61
+ page = MetaInspector.new(page_url, head_first: true, allow_non_html_content: true)
62
+
63
+ expect(page.to_s).to eq('Plain text')
64
+ expect(page.content_type).to eq('text/plain')
65
+ expect(head_request).to have_been_requested.once
66
+ expect(get_request).to have_been_requested.once
67
+ end
68
+
69
+ it 'continues to GET when HEAD has no content type' do
70
+ head_request = stub_request(:head, page_url).to_return(status: 200)
71
+
72
+ expect(MetaInspector.new(page_url, head_first: true).title).to eq('Downloaded page')
73
+
74
+ expect(head_request).to have_been_requested.once
75
+ expect(get_request).to have_been_requested.once
76
+ end
77
+
78
+ [403, 404, 405, 501].each do |status|
79
+ it "continues to GET when HEAD returns #{status}" do
80
+ head_request = stub_request(:head, page_url)
81
+ .to_return(status: status, headers: { 'Content-Type' => 'text/plain' })
82
+
83
+ expect(MetaInspector.new(page_url, head_first: true).title).to eq('Downloaded page')
84
+
85
+ expect(head_request).to have_been_requested.once
86
+ expect(get_request).to have_been_requested.once
87
+ end
88
+ end
89
+
90
+ it 'still rejects a non-HTML GET response when HEAD reports HTML' do
91
+ head_request = stub_request(:head, page_url).to_return(headers: { 'Content-Type' => 'text/html' })
92
+ stub_request(:get, page_url).to_return(headers: { 'Content-Type' => 'application/pdf' })
93
+
94
+ expect do
95
+ MetaInspector.new(page_url, head_first: true)
96
+ end.to raise_error(MetaInspector::NonHtmlError)
97
+
98
+ expect(head_request).to have_been_requested.once
99
+ expect(get_request).to have_been_requested.once
100
+ end
101
+
102
+ it 'does not send requests for a supplied document' do
103
+ expect(MetaInspector.new(page_url, head_first: true, document: html).title).to eq('Downloaded page')
104
+
105
+ expect(a_request(:head, page_url)).not_to have_been_made
106
+ expect(get_request).not_to have_been_requested
107
+ end
108
+
109
+ it 'sends custom headers on both requests' do
110
+ headers = { 'User-Agent' => 'Custom agent', 'Authorization' => 'Bearer test-token' }
111
+ head_request = stub_request(:head, page_url).with(headers: headers)
112
+ .to_return(headers: { 'Content-Type' => 'text/html' })
113
+ get_with_headers = stub_request(:get, page_url).with(headers: headers).to_return(body: html)
114
+
115
+ expect(MetaInspector.new(page_url, head_first: true, headers: headers).title).to eq('Downloaded page')
116
+
117
+ expect(head_request).to have_been_requested.once
118
+ expect(get_with_headers).to have_been_requested.once
119
+ end
120
+
121
+ it 'follows HEAD redirects to reject non-HTML content before GET' do
122
+ target = 'http://example.com/large.pdf'
123
+ stub_request(:head, page_url).to_return(status: 302, headers: { 'Location' => target })
124
+ target_request = stub_request(:head, target).to_return(headers: { 'Content-Type' => 'application/pdf' })
125
+
126
+ expect do
127
+ MetaInspector.new(page_url, head_first: true)
128
+ end.to raise_error(MetaInspector::NonHtmlError)
129
+
130
+ expect(target_request).to have_been_requested.once
131
+ expect(a_request(:get, /example.com/)).not_to have_been_made
132
+ end
133
+
134
+ it 'follows GET redirects independently of HEAD and exposes the final GET URL' do
135
+ head_target = 'http://example.com/head-target'
136
+ get_target = 'http://example.com/get-target'
137
+ head_request = stub_request(:head, page_url).to_return(status: 302, headers: { 'Location' => head_target })
138
+ stub_request(:head, head_target).to_return(headers: { 'Content-Type' => 'text/html' })
139
+ stub_request(:get, page_url).to_return(status: 302, headers: { 'Location' => get_target })
140
+ final_request = stub_request(:get, get_target).to_return(body: html, headers: { 'Content-Type' => 'text/html' })
141
+
142
+ page = MetaInspector.new(page_url, head_first: true)
143
+
144
+ expect(page.title).to eq('Downloaded page')
145
+ expect(page.url).to eq(get_target)
146
+ expect(head_request).to have_been_requested.once
147
+ expect(final_request).to have_been_requested.once
148
+ expect(a_request(:get, head_target)).not_to have_been_made
149
+ end
150
+
151
+ it 'does not follow HEAD redirects when redirections are disabled' do
152
+ head_request = stub_request(:head, page_url)
153
+ .to_return(status: 302, headers: { 'Location' => 'http://example.com/target', 'Content-Type' => 'text/plain' })
154
+
155
+ page = MetaInspector.new(page_url, head_first: true, allow_redirections: false)
156
+
157
+ expect(page.title).to eq('Downloaded page')
158
+ expect(page.url).to eq(page_url)
159
+ expect(head_request).to have_been_requested.once
160
+ expect(a_request(:head, 'http://example.com/target')).not_to have_been_made
161
+ end
162
+
163
+ it 'keeps cookies from HEAD for the GET request' do
164
+ head_request = stub_request(:head, page_url)
165
+ .to_return(headers: { 'Content-Type' => 'text/html', 'Set-Cookie' => 'session=checked; path=/' })
166
+ get_with_cookie = stub_request(:get, page_url).with(headers: { 'Cookie' => 'session=checked' }).to_return(body: html)
167
+
168
+ expect(MetaInspector.new(page_url, head_first: true).title).to eq('Downloaded page')
169
+
170
+ expect(head_request).to have_been_requested.once
171
+ expect(get_with_cookie).to have_been_requested.once
172
+ end
173
+
174
+ it 'wraps HEAD timeouts without sending GET' do
175
+ head_request = stub_request(:head, page_url).to_raise(Net::ReadTimeout)
176
+
177
+ expect do
178
+ MetaInspector.new(page_url, head_first: true, retries: 0)
179
+ end.to raise_error(MetaInspector::TimeoutError)
180
+
181
+ expect(head_request).to have_been_requested.once
182
+ expect(get_request).not_to have_been_requested
183
+ end
184
+
185
+ it 'wraps HEAD connection failures without sending GET' do
186
+ head_request = stub_request(:head, page_url).to_raise(Faraday::ConnectionFailed.new('connection failed'))
187
+
188
+ expect do
189
+ MetaInspector.new(page_url, head_first: true, retries: 0)
190
+ end.to raise_error(MetaInspector::RequestError)
191
+
192
+ expect(head_request).to have_been_requested.once
193
+ expect(get_request).not_to have_been_requested
194
+ end
195
+ end
@@ -70,6 +70,31 @@ describe MetaInspector do
70
70
  end
71
71
 
72
72
  describe "images.best" do
73
+ it 'should prefer the Parsely image over the largest image' do
74
+ html = '<meta name="parsely-image-url" content="/parsely.jpg"><img src="/largest.jpg" width="100" height="100">'
75
+ page = MetaInspector.new('http://example.com/', document: html)
76
+
77
+ expect(page.images.best).to eq('http://example.com/parsely.jpg')
78
+ end
79
+
80
+ ['', ' &#10; '].each do |value|
81
+ it "should fall back to the largest image when the Parsely image is empty (#{value.inspect})" do
82
+ html = %(<meta name="parsely-image-url" content="#{value}"><img src="/largest.jpg" width="100" height="100">)
83
+ page = MetaInspector.new('http://example.com/', document: html)
84
+
85
+ expect(page.images.best).to eq('http://example.com/largest.jpg')
86
+ end
87
+ end
88
+
89
+ it "should find a valid image when another image URL cannot be parsed" do
90
+ html = '<html><body><img src="http://exa mple.com/x.png"><img src="/100x100"></body></html>'
91
+ page = MetaInspector.new('http://example.com/', document: html)
92
+
93
+ allow(FastImage).to receive(:size).and_call_original
94
+ expect(FastImage).not_to receive(:size).with(nil)
95
+ expect(page.images.best).to eq("http://example.com/100x100")
96
+ end
97
+
73
98
  it "should find the og image" do
74
99
  page = MetaInspector.new('http://www.theonion.com/articles/apple-claims-new-iphone-only-visible-to-most-loyal,2772/')
75
100
 
@@ -103,6 +128,44 @@ describe MetaInspector do
103
128
  end
104
129
 
105
130
  describe "images.owner_suggested" do
131
+ it 'should find the Parsely image when no existing metadata is present' do
132
+ html = '<meta name="parsely-image-url" content="https://images.example.com/parsely.jpg?b=2&amp;a=1">'
133
+ page = MetaInspector.new('http://example.com/', document: html)
134
+
135
+ expect(page.images.owner_suggested).to eq('https://images.example.com/parsely.jpg?b=2&a=1')
136
+ end
137
+
138
+ it 'should resolve a relative Parsely image against the document base URL' do
139
+ html = '<base href="/assets/"><meta name="parsely-image-url" content="parsely.jpg">'
140
+ page = MetaInspector.new('http://example.com/articles/page', document: html)
141
+
142
+ expect(page.images.owner_suggested).to eq('http://example.com/assets/parsely.jpg')
143
+ end
144
+
145
+ it 'should use the Parsely image when existing metadata is empty' do
146
+ html = '<meta property="og:image" content=""><meta name="twitter:image" content=""><meta name="parsely-image-url" content="/parsely.jpg">'
147
+ page = MetaInspector.new('http://example.com/', document: html)
148
+
149
+ expect(page.images.owner_suggested).to eq('http://example.com/parsely.jpg')
150
+ end
151
+
152
+ ['og:image', 'twitter:image'].each do |candidate|
153
+ it "should prefer #{candidate} over the Parsely image" do
154
+ html = %(<meta name="parsely-image-url" content="/parsely.jpg"><meta property="#{candidate}" content="/existing.jpg">)
155
+ page = MetaInspector.new('http://example.com/', document: html)
156
+
157
+ expect(page.images.owner_suggested).to eq('http://example.com/existing.jpg')
158
+ end
159
+ end
160
+
161
+ ['', ' &#10; '].each do |value|
162
+ it "should ignore an empty Parsely image (#{value.inspect})" do
163
+ page = MetaInspector.new('http://example.com/', document: %(<meta name="parsely-image-url" content="#{value}">))
164
+
165
+ expect(page.images.owner_suggested).to be_nil
166
+ end
167
+ end
168
+
106
169
  it "should find the og image" do
107
170
  page = MetaInspector.new('http://www.theonion.com/articles/apple-claims-new-iphone-only-visible-to-most-loyal,2772/')
108
171
 
@@ -135,6 +198,176 @@ describe MetaInspector do
135
198
  end
136
199
 
137
200
  describe "images.with_size" do
201
+ describe "max_image_downloads" do
202
+ let(:html) do
203
+ '<img src="/10x10"><img src="/100x100"><img src="/remaining.jpg">'
204
+ end
205
+
206
+ before do
207
+ stub_request(:get, 'http://example.com/remaining.jpg').to_return(status: 404)
208
+ end
209
+
210
+ it "should download only the first images needing dimensions in document order" do
211
+ page = MetaInspector.new('http://example.com/', document: html, max_image_downloads: 2)
212
+
213
+ expect(page.images.with_size).to eq([
214
+ ['http://example.com/100x100', 100, 100],
215
+ ['http://example.com/10x10', 10, 10],
216
+ ['http://example.com/remaining.jpg', 0, 0]
217
+ ])
218
+ expect(WebMock).to have_requested(:get, 'http://example.com/10x10').once
219
+ expect(WebMock).to have_requested(:get, 'http://example.com/100x100').once
220
+ expect(WebMock).not_to have_requested(:get, 'http://example.com/remaining.jpg')
221
+ end
222
+
223
+ it "should preserve HTML dimensions without consuming the download budget" do
224
+ html = '<img src="/known.jpg" width="200" height="200"><img src="/10x10"><img src="/100x100"><img src="/later.jpg" width="300" height="300">'
225
+ page = MetaInspector.new('http://example.com/', document: html, max_image_downloads: 1)
226
+
227
+ expect(page.images.with_size).to eq([
228
+ ['http://example.com/later.jpg', 300, 300],
229
+ ['http://example.com/known.jpg', 200, 200],
230
+ ['http://example.com/10x10', 10, 10],
231
+ ['http://example.com/100x100', 0, 0]
232
+ ])
233
+ expect(WebMock).to have_requested(:get, 'http://example.com/10x10').once
234
+ expect(WebMock).not_to have_requested(:get, 'http://example.com/100x100')
235
+ end
236
+
237
+ it "should exclude invalid and duplicate URLs before consuming the budget" do
238
+ html = '<img src="http://exa mple.com/broken.jpg"><img src="/10x10"><img src="http://example.com/10x10"><img src="/100x100"><img src="/remaining.jpg">'
239
+ page = MetaInspector.new('http://example.com/', document: html, max_image_downloads: 2)
240
+
241
+ expect(page.images.with_size).to eq([
242
+ ['http://example.com/100x100', 100, 100],
243
+ ['http://example.com/10x10', 10, 10],
244
+ ['http://example.com/remaining.jpg', 0, 0]
245
+ ])
246
+ expect(WebMock).to have_requested(:get, 'http://example.com/10x10').once
247
+ expect(WebMock).to have_requested(:get, 'http://example.com/100x100').once
248
+ expect(WebMock).not_to have_requested(:get, 'http://example.com/remaining.jpg')
249
+ end
250
+
251
+ it "should treat an image missing one HTML dimension as needing a download" do
252
+ html = '<img src="/10x10" width="500"><img src="/100x100" height="500">'
253
+ page = MetaInspector.new('http://example.com/', document: html, max_image_downloads: 1)
254
+
255
+ expect(page.images.with_size).to eq([
256
+ ['http://example.com/10x10', 10, 10],
257
+ ['http://example.com/100x100', 0, 0]
258
+ ])
259
+ expect(WebMock).to have_requested(:get, 'http://example.com/10x10').once
260
+ expect(WebMock).not_to have_requested(:get, 'http://example.com/100x100')
261
+ end
262
+
263
+ it "should disable downloads with a zero limit while preserving HTML dimensions" do
264
+ html = '<img src="/10x10"><img src="/known.jpg" width="200" height="100">'
265
+ page = MetaInspector.new('http://example.com/', document: html, max_image_downloads: 0)
266
+
267
+ expect(page.images.with_size).to eq([
268
+ ['http://example.com/known.jpg', 200, 100],
269
+ ['http://example.com/10x10', 0, 0]
270
+ ])
271
+ expect(WebMock).not_to have_requested(:get, 'http://example.com/10x10')
272
+ end
273
+
274
+ it "should respect download_images: false even with a positive limit" do
275
+ page = MetaInspector.new('http://example.com/', document: html, download_images: false, max_image_downloads: 2)
276
+
277
+ expect(page.images.with_size).to eq([
278
+ ['http://example.com/10x10', 0, 0],
279
+ ['http://example.com/100x100', 0, 0],
280
+ ['http://example.com/remaining.jpg', 0, 0]
281
+ ])
282
+ expect(WebMock).not_to have_requested(:get, %r{http://example.com/})
283
+ end
284
+
285
+ [nil, 10].each do |limit|
286
+ it "should download every candidate when the limit is #{limit.inspect}" do
287
+ page = MetaInspector.new('http://example.com/', document: html, max_image_downloads: limit)
288
+
289
+ expect(page.images.with_size).to eq([
290
+ ['http://example.com/100x100', 100, 100],
291
+ ['http://example.com/10x10', 10, 10],
292
+ ['http://example.com/remaining.jpg', 0, 0]
293
+ ])
294
+ expect(WebMock).to have_requested(:get, 'http://example.com/10x10').once
295
+ expect(WebMock).to have_requested(:get, 'http://example.com/100x100').once
296
+ expect(WebMock).to have_requested(:get, 'http://example.com/remaining.jpg').once
297
+ end
298
+ end
299
+
300
+ [:not_found, :invalid_image, :timeout].each do |failure|
301
+ it "should count a #{failure} image attempt toward the limit" do
302
+ request = stub_request(:get, 'http://example.com/broken.jpg')
303
+ case failure
304
+ when :not_found then request.to_return(status: 404)
305
+ when :invalid_image then request.to_return(body: 'not an image')
306
+ when :timeout then request.to_timeout
307
+ end
308
+ html = '<img src="/broken.jpg"><img src="/100x100">'
309
+ page = MetaInspector.new('http://example.com/', document: html, max_image_downloads: 1)
310
+
311
+ expect(page.images.with_size).to eq([
312
+ ['http://example.com/broken.jpg', 0, 0],
313
+ ['http://example.com/100x100', 0, 0]
314
+ ])
315
+ expect(request).to have_been_requested.once
316
+ expect(WebMock).not_to have_requested(:get, 'http://example.com/100x100')
317
+ end
318
+ end
319
+
320
+ it "should reuse the limited results for repeated size and best-image queries" do
321
+ page = MetaInspector.new('http://example.com/', document: html, max_image_downloads: 1)
322
+
323
+ 2.times do
324
+ expect(page.images.with_size.first).to eq(['http://example.com/10x10', 10, 10])
325
+ expect(page.images.largest).to eq('http://example.com/10x10')
326
+ expect(page.images.best).to eq('http://example.com/10x10')
327
+ end
328
+ expect(WebMock).to have_requested(:get, 'http://example.com/10x10').once
329
+ expect(WebMock).not_to have_requested(:get, 'http://example.com/100x100')
330
+ end
331
+
332
+ it "should keep the budget local to each document" do
333
+ 2.times do
334
+ page = MetaInspector.new('http://example.com/', document: html, max_image_downloads: 1)
335
+
336
+ expect(page.images.with_size.first).to eq(['http://example.com/10x10', 10, 10])
337
+ end
338
+ expect(WebMock).to have_requested(:get, 'http://example.com/10x10').twice
339
+ expect(WebMock).not_to have_requested(:get, 'http://example.com/100x100')
340
+ end
341
+
342
+ it "should keep all image URLs without downloading them when enumerating images" do
343
+ page = MetaInspector.new('http://example.com/', document: html, max_image_downloads: 1)
344
+
345
+ expect(page.images.to_a).to eq([
346
+ 'http://example.com/10x10',
347
+ 'http://example.com/100x100',
348
+ 'http://example.com/remaining.jpg'
349
+ ])
350
+ expect(WebMock).not_to have_requested(:get, %r{http://example.com/})
351
+ end
352
+ end
353
+
354
+ [true, false].each do |download_images|
355
+ it "should ignore unparseable image URLs with download_images: #{download_images}" do
356
+ html = '<html><body><img src="http://exa mple.com/x.png"><img src="/valid.png" width="100" height="100"></body></html>'
357
+ page = MetaInspector.new('https://example.com/', document: html, download_images: download_images)
358
+
359
+ expect(FastImage).not_to receive(:size).with(nil)
360
+ expect(page.images.with_size).to eq([["https://example.com/valid.png", 100, 100]])
361
+ end
362
+ end
363
+
364
+ it "should ignore unparseable image URLs even when HTML sizes are present" do
365
+ html = '<html><body><img src="http://exa mple.com/x.png" width="100" height="100"></body></html>'
366
+ page = MetaInspector.new('https://example.com/', document: html)
367
+
368
+ expect(page.images.with_size).to eq([])
369
+ end
370
+
138
371
  it "should return sorted by area array of [img_url, width, height] using html sizes" do
139
372
  page = MetaInspector.new('http://example.com/largest_image_in_html')
140
373
 
@@ -174,6 +174,28 @@ describe MetaInspector do
174
174
  end
175
175
 
176
176
  describe 'Relative links with relative base' do
177
+ [
178
+ ['assets/', 'https://example.com/posts/article', 'https://example.com/posts/assets/next.html'],
179
+ ['../assets/', 'https://example.com/posts/2026/article', 'https://example.com/posts/assets/next.html'],
180
+ ['assets/base.html', 'https://example.com/posts/article', 'https://example.com/posts/assets/next.html'],
181
+ ['assets/', 'https://example.com/posts/article/', 'https://example.com/posts/article/assets/next.html'],
182
+ ['/assets/', 'https://example.com:8443/posts/article', 'https://example.com:8443/assets/next.html']
183
+ ].each do |base_href, url, expected_link|
184
+ it "resolves base #{base_href.inspect} against #{url}" do
185
+ html = %(<html><head><base href="#{base_href}"></head><body><a href="next.html">Next</a></body></html>)
186
+ m = MetaInspector.new(url, document: html)
187
+
188
+ expect(m.links.all).to eq([expected_link])
189
+ end
190
+ end
191
+
192
+ it 'resolves image URLs against a document-relative base in a nested directory' do
193
+ html = '<html><head><base href="assets/"></head><body><img src="image.jpg"></body></html>'
194
+ m = MetaInspector.new('https://example.com/posts/article', document: html)
195
+
196
+ expect(m.images.to_a).to eq(['https://example.com/posts/assets/image.jpg'])
197
+ end
198
+
177
199
  it 'should get the relative links with relative directory base' do
178
200
  m = MetaInspector.new('http://relativewithrelativebase.com/relativedir')
179
201
  expect(m.links.all).to eq(['http://relativewithrelativebase.com/other/about',
@@ -52,22 +52,36 @@ describe MetaInspector do
52
52
  end
53
53
 
54
54
  context "when there is a callback to be ran between redirects that blocks redirections to private IP addresses" do
55
- it "raises an exception" do
56
- stub_request(:get, "https://www.facebook.com/")
57
- .to_return(:status => 302,
58
- :headers => { "Location" => "http://10.0.0.0/" })
59
-
60
- redirect_options = {
55
+ let(:redirect_options) do
56
+ {
61
57
  callback: proc do |_previous_response, next_request|
62
58
  ip_address = Resolv.getaddress(next_request.url.host)
63
59
  raise PrivateIPAddressError if IPAddr.new(ip_address).private?
64
60
  end
65
61
  }
62
+ end
63
+
64
+ before do
65
+ stub_request(:get, "https://www.facebook.com/")
66
+ .to_return(:status => 302,
67
+ :headers => { "Location" => "http://10.0.0.0/" })
68
+ end
66
69
 
70
+ it "raises an exception" do
67
71
  expect {
68
72
  MetaInspector.new("https://www.facebook.com/", faraday_options: { redirect: redirect_options })
69
73
  }.to raise_error PrivateIPAddressError
70
74
  end
75
+
76
+ it "keeps running the callback when the same faraday_options are reused" do
77
+ faraday_options = { redirect: redirect_options }
78
+
79
+ 2.times do
80
+ expect {
81
+ MetaInspector.new("https://www.facebook.com/", faraday_options: faraday_options)
82
+ }.to raise_error PrivateIPAddressError
83
+ end
84
+ end
71
85
  end
72
86
  end
73
87
 
@@ -1,6 +1,50 @@
1
1
  require 'spec_helper'
2
2
 
3
3
  describe MetaInspector do
4
+ describe '#language' do
5
+ ['en', 'en-US', 'zh-Hant-TW'].each do |language|
6
+ it "should return the declared language #{language.inspect}" do
7
+ page = MetaInspector.new('http://example.com/', document: %(<html lang="#{language}"><body></body></html>))
8
+
9
+ expect(page.language).to eq(language)
10
+ end
11
+ end
12
+
13
+ it 'should strip surrounding whitespace from the declared language' do
14
+ page = MetaInspector.new('http://example.com/', document: "<html lang=\" \t en-US \n \"><body></body></html>")
15
+
16
+ expect(page.language).to eq('en-US')
17
+ end
18
+
19
+ [nil, '', " \t\n "].each do |value|
20
+ it "should return nil for a missing or blank language (#{value.inspect})" do
21
+ attribute = value.nil? ? '' : %( lang="#{value}")
22
+ page = MetaInspector.new('http://example.com/', document: "<html#{attribute}><body></body></html>")
23
+
24
+ expect(page.language).to be_nil
25
+ end
26
+ end
27
+
28
+ it 'should read the root language rather than a descendant language' do
29
+ page = MetaInspector.new('http://example.com/', document: '<html lang="en"><body lang="es"></body></html>')
30
+
31
+ expect(page.language).to eq('en')
32
+ end
33
+
34
+ it 'should not infer language from xml:lang, metadata, or descendants' do
35
+ html = '<html xml:lang="fr"><head><meta http-equiv="content-language" content="de"></head><body lang="es">Hello</body></html>'
36
+ page = MetaInspector.new('http://example.com/', document: html)
37
+
38
+ expect(page.language).to be_nil
39
+ end
40
+
41
+ it 'should return the declared language from a fetched document' do
42
+ page = MetaInspector.new('http://pagerankalert.com/')
43
+
44
+ expect(page.language).to eq('en')
45
+ end
46
+ end
47
+
4
48
  it "should get the title from the head section" do
5
49
  page = MetaInspector.new('http://example.com')
6
50
  expect(page.title).to eq('An example page')
@@ -49,6 +93,42 @@ describe MetaInspector do
49
93
  end
50
94
 
51
95
  describe '#best_title' do
96
+ it 'should use the Parsely title when no existing candidate is present' do
97
+ page = MetaInspector.new('http://example.com/', document: '<meta name="parsely-title" content=" A Parsely&#10; title ">')
98
+
99
+ expect(page.best_title).to eq('A Parsely title')
100
+ end
101
+
102
+ it 'should use the Parsely title when existing candidates are empty' do
103
+ html = '<html><head><meta name="title" content=" "><meta property="og:title" content=""><title> </title><meta name="parsely-title" content="Parsely title"></head><body><h1> </h1></body></html>'
104
+ page = MetaInspector.new('http://example.com/', document: html)
105
+
106
+ expect(page.best_title).to eq('Parsely title')
107
+ end
108
+
109
+ [
110
+ ['<meta name="title" content="Existing title">', ''],
111
+ ['<meta property="og:title" content="Existing title">', ''],
112
+ ['<title>Existing title</title>', ''],
113
+ ['', '<title>Existing title</title>'],
114
+ ['', '<h1>Existing title</h1>']
115
+ ].each do |head, body|
116
+ it "should prefer #{head + body} over the Parsely title" do
117
+ html = %(<html><head><meta name="parsely-title" content="Parsely title">#{head}</head><body>#{body}</body></html>)
118
+ page = MetaInspector.new('http://example.com/', document: html)
119
+
120
+ expect(page.best_title).to eq('Existing title')
121
+ end
122
+ end
123
+
124
+ ['', ' &#10; '].each do |value|
125
+ it "should ignore an empty Parsely title (#{value.inspect})" do
126
+ page = MetaInspector.new('http://example.com/', document: %(<meta name="parsely-title" content="#{value}">))
127
+
128
+ expect(page.best_title).to be_nil
129
+ end
130
+ end
131
+
52
132
  it "should find 'head title' when that's the only thing" do
53
133
  page = MetaInspector.new('http://example.com/title_in_head')
54
134
  expect(page.best_title).to eq('This title came from the head')
@@ -105,6 +185,41 @@ describe MetaInspector do
105
185
  end
106
186
 
107
187
  describe "#best_author" do
188
+ it 'should use the Parsely author when no existing candidate is present' do
189
+ page = MetaInspector.new('http://example.com/', document: '<meta name="parsely-author" content=" A Parsely&#10; author ">')
190
+
191
+ expect(page.best_author).to eq('A Parsely author')
192
+ end
193
+
194
+ it 'should use the Parsely author when existing candidates are empty' do
195
+ html = '<meta name="author" content=" "><meta name="twitter:creator" content=""><meta name="parsely-author" content="Parsely author"><a rel="author"> </a><address> </address>'
196
+ page = MetaInspector.new('http://example.com/', document: html)
197
+
198
+ expect(page.best_author).to eq('Parsely author')
199
+ end
200
+
201
+ [
202
+ '<meta name="author" content="Existing author">',
203
+ '<a rel="author">Existing author</a>',
204
+ '<address>Existing author</address>',
205
+ '<meta name="twitter:creator" content="Existing author">'
206
+ ].each do |candidate|
207
+ it "should prefer #{candidate} over the Parsely author" do
208
+ html = '<meta name="parsely-author" content="Parsely author">' + candidate
209
+ page = MetaInspector.new('http://example.com/', document: html)
210
+
211
+ expect(page.best_author).to eq('Existing author')
212
+ end
213
+ end
214
+
215
+ ['', ' &#10; '].each do |value|
216
+ it "should ignore an empty Parsely author (#{value.inspect})" do
217
+ page = MetaInspector.new('http://example.com/', document: %(<meta name="parsely-author" content="#{value}">))
218
+
219
+ expect(page.best_author).to be_nil
220
+ end
221
+ end
222
+
108
223
  it "should return the author meta tag content if present" do
109
224
  page = MetaInspector.new('http://example.com/author_in_meta')
110
225
 
data/spec/request_spec.rb CHANGED
@@ -68,6 +68,35 @@ describe MetaInspector::Request do
68
68
  end
69
69
  end
70
70
 
71
+ describe "options it is given" do
72
+ it "accepts a frozen faraday_options hash" do
73
+ expect do
74
+ MetaInspector::Request.new(url('http://example.com'), faraday_options: { ssl: { verify: true } }.freeze)
75
+ end.not_to raise_error
76
+ end
77
+
78
+ it "leaves the faraday_options hash unchanged" do
79
+ callback = proc {}
80
+ faraday_options = { redirect: { callback: callback } }
81
+
82
+ MetaInspector::Request.new(url('http://example.com'), allow_redirections: true,
83
+ faraday_options: faraday_options)
84
+
85
+ expect(faraday_options).to eq({ redirect: { callback: callback } })
86
+ end
87
+
88
+ it "leaves the faraday_http_cache hash unchanged" do
89
+ cache = Hash.new
90
+ def cache.read(k) self[k]; end
91
+ def cache.write(k, v) self[k] = v; end
92
+ cache_options = { store: cache }
93
+
94
+ MetaInspector::Request.new(url('http://example.com'), faraday_http_cache: cache_options)
95
+
96
+ expect(cache_options).to eq({ store: cache })
97
+ end
98
+ end
99
+
71
100
  describe 'exception handling' do
72
101
  before(:each) do
73
102
  WebMock.allow_net_connect!
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: metainspector
3
3
  version: !ruby/object:Gem::Version
4
- version: 5.18.1
4
+ version: 5.19.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jaime Iniesta
@@ -406,6 +406,7 @@ files:
406
406
  - spec/fixtures/wordpress_site.response
407
407
  - spec/fixtures/youtube.response
408
408
  - spec/fixtures/youtube_short_title.response
409
+ - spec/meta_inspector/head_first_spec.rb
409
410
  - spec/meta_inspector/head_links_spec.rb
410
411
  - spec/meta_inspector/images_spec.rb
411
412
  - spec/meta_inspector/links_spec.rb