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 +4 -4
- data/.gitignore +1 -0
- data/CHANGELOG.md +10 -0
- data/README.md +56 -1
- data/lib/meta_inspector/document.rb +17 -3
- data/lib/meta_inspector/parser.rb +3 -2
- data/lib/meta_inspector/parsers/images.rb +17 -13
- data/lib/meta_inspector/parsers/links.rb +2 -1
- data/lib/meta_inspector/parsers/texts.rb +13 -4
- data/lib/meta_inspector/request.rb +34 -11
- data/lib/meta_inspector/version.rb +1 -1
- data/spec/document_spec.rb +20 -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/redirections_spec.rb +20 -6
- data/spec/meta_inspector/texts_spec.rb +115 -0
- data/spec/request_spec.rb +29 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5bc16dfdcdd22661ba7bb196d4db8750a5fa4d48d054197982768fa7fac44835
|
|
4
|
+
data.tar.gz: ce356b1f0f2511069ff96329e775e3581282d68d395c4dada93a9275e5dd4aa6
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 6a669057baeb8107c8ae6940f4323f60664982fa3c8c74ce4f029b434ebfe227436c743a6b6041acbcddfdc2ea7fb38704a6126d3204b18479a8ca81b2b63a0e
|
|
7
|
+
data.tar.gz: 123f1cbb41fd5492307d96e316b17c91fc4f17d523a12f9fd865be49a3de31d8b932ceae6365bc7e1f6f49fd9721f8626eb9a8b5bbcbf9d86b2e972aedaa1544
|
data/.gitignore
CHANGED
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
|
|
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
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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,
|
|
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!
|
|
@@ -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
|
-
|
|
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
|
-
|
|
62
|
-
|
|
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(
|
|
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
|
|
77
|
-
|
|
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
|
-
|
|
86
|
-
|
|
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
|
data/spec/document_spec.rb
CHANGED
|
@@ -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
|
+
['', ' '].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&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
|
+
['', ' '].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
|
-
|
|
56
|
-
|
|
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 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
|
+
['', ' '].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 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
|
+
['', ' '].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.
|
|
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
|