crawlscope 0.3.0 → 0.5.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: b49aaaa6fdb5f7d5bd4dc63713d8c0090411e7063363645a900d8f59d803aaaa
4
- data.tar.gz: 5dfcc35d60745c25db6faf3acaa4344e29e438c758740613d6216e2f47aeac6e
3
+ metadata.gz: 7d9e56ae9a55e3c4bb6b079585b8a302edcc1bfad9110938c9421c5224bf27f9
4
+ data.tar.gz: ab1908aa4a1bef4c15f055800ca9862ba973c9257f39020309de1f5554923cca
5
5
  SHA512:
6
- metadata.gz: 9f66627274ce2ea969b5bb9b53a339215718c37baf47393c75bcf3a528c5c73658c6a71903fdbbf9e53796aaf3680be5f99ab4151b834efbf9450e05abbab83b
7
- data.tar.gz: 3cf2e2c7f251a6af7b931f00da63436eaa7e09f078d73de112852a10665cf16eefb561c7d61d6bc8b0c3c014ca0db2df217d31c00b9f0ed321565ed554574261
6
+ metadata.gz: 8981de1e7bc19737df3048b1e19f28d585f22eed8f2b32ea4eea473ba377d3a261e08df8165114c8d967b7ec7d14a48c47a6b83cfe14261f6c83b56b39134766
7
+ data.tar.gz: 3df1e21bf74c12e994c0a932f9c581e4a6e12b55dd14975e43c5eca073bcc8eb4a49337d3c3c2e52137c4436fb5ddf52accd6fc11954171d77e75ce8f75e69a5
data/CHANGELOG.md CHANGED
@@ -5,6 +5,35 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.5.0] - 2026-05-31
9
+
10
+
11
+ ### Added
12
+
13
+ - expand SEO audit checks
14
+
15
+
16
+
17
+ ## [0.4.0] - 2026-05-21
18
+
19
+
20
+ ### Added
21
+
22
+ - add indexability and content quality checks
23
+
24
+
25
+
26
+
27
+ ### Fixed
28
+
29
+ - preserve release changelog history
30
+
31
+ - scope content ratio to main content
32
+
33
+ - harden indexability and uniqueness rules
34
+
35
+
36
+
8
37
  ## [0.3.0] - 2026-04-28
9
38
 
10
39
 
@@ -28,3 +57,47 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
28
57
 
29
58
 
30
59
 
60
+ ## [0.2.0] - 2026-04-24
61
+
62
+
63
+ ### Changed
64
+
65
+ - simplify crawl and structured data boundaries
66
+
67
+ - harden validation boundaries
68
+
69
+
70
+
71
+
72
+ ### Fixed
73
+
74
+ - handle child sitemaps
75
+
76
+ - use URL for sitemap validation
77
+
78
+
79
+
80
+ ## [0.1.0] - 2026-04-23
81
+
82
+
83
+ ### Added
84
+
85
+ - add crawlkit release-ready audit gem
86
+
87
+ - add standalone validation commands
88
+
89
+ - move default schema rules into crawlkit
90
+
91
+
92
+
93
+
94
+ ### Changed
95
+
96
+ - strengthen public API coverage
97
+
98
+ - load shared test dependencies
99
+
100
+ - rename crawlkit to crawlscope
101
+
102
+
103
+
data/README.md CHANGED
@@ -23,9 +23,11 @@ It works in three modes:
23
23
 
24
24
  The default rule set includes:
25
25
 
26
+ - indexability blockers
26
27
  - metadata validation
27
28
  - structured-data validation
28
29
  - uniqueness checks
30
+ - content-quality checks
29
31
  - internal-link checks
30
32
 
31
33
  ## Installation
@@ -146,9 +148,11 @@ Available tasks:
146
148
 
147
149
  ```bash
148
150
  bin/rails crawlscope:validate
151
+ bin/rails crawlscope:validate:indexability
149
152
  bin/rails crawlscope:validate:metadata
150
153
  bin/rails crawlscope:validate:structured_data
151
154
  bin/rails crawlscope:validate:uniqueness
155
+ bin/rails crawlscope:validate:content_quality
152
156
  bin/rails crawlscope:validate:links
153
157
  bin/rails crawlscope:validate:ldjson
154
158
  ```
@@ -157,11 +161,17 @@ The same validation surface is also available in the gem repository itself throu
157
161
 
158
162
  ```bash
159
163
  bundle exec rake crawlscope:validate URL=https://example.com
164
+ bundle exec rake 'crawlscope:validate[https://example.com]'
160
165
  bundle exec rake crawlscope:validate:metadata URL=https://example.com
166
+ bundle exec rake 'crawlscope:validate:metadata[https://example.com]'
161
167
  bundle exec rake crawlscope:validate:ldjson URL=https://example.com/article
168
+ bundle exec rake 'crawlscope:validate:ldjson[https://example.com/article]'
162
169
  ```
163
170
 
164
- `crawlscope:validate` runs all default sitemap rules: metadata, structured data, uniqueness, and links. `URL` is the site base. Without `SITEMAP`, Crawlscope uses `/sitemap.xml`. With `SITEMAP`, Crawlscope uses `URL` as the site base and validates URLs from that sitemap. `SITEMAP` may be a full URL or a local file path.
171
+ `crawlscope:validate` runs all default sitemap rules: indexability, metadata, structured data, uniqueness, content quality, and links. `URL` is the site base. Without `SITEMAP`, Crawlscope uses `/sitemap.xml`. With `SITEMAP`, Crawlscope uses `URL` as the site base and validates URLs from that sitemap. `SITEMAP` may be a full URL or a local file path.
172
+
173
+ Plain `rake` does not pass `--url` style flags to tasks. Use `URL=...` or the
174
+ task-argument form above instead.
165
175
 
166
176
  `crawlscope:validate:ldjson` is separate because it directly checks the URL or semicolon-separated URLs in `URL`; it does not crawl the sitemap. Without `URL`, it checks the configured base URL, falling back to `http://localhost:3000`.
167
177
 
@@ -186,11 +196,20 @@ Optional flags:
186
196
 
187
197
  Built-in rules:
188
198
 
199
+ - `indexability`
189
200
  - `metadata`
190
201
  - `structured_data`
191
202
  - `uniqueness`
203
+ - `content_quality`
192
204
  - `links`
193
205
 
206
+ ### Indexability
207
+
208
+ Checks:
209
+
210
+ - page-level meta robots `noindex`
211
+ - `X-Robots-Tag: noindex`
212
+
194
213
  ### Metadata
195
214
 
196
215
  Checks:
@@ -220,6 +239,19 @@ Checks:
220
239
  - duplicate titles
221
240
  - duplicate meta descriptions
222
241
  - duplicate content fingerprints
242
+ - near-duplicate visible content for up to 250 HTML pages
243
+
244
+ For larger crawls, exact duplicate checks still run and Crawlscope reports
245
+ `near_duplicate_scan_skipped`. Configure `Rules::Uniqueness` with
246
+ `max_near_duplicate_pages:` in a custom rule registry to change the limit.
247
+
248
+ ### Content Quality
249
+
250
+ Checks:
251
+
252
+ - thin visible text
253
+ - low visible-text-to-HTML ratio
254
+ - low unique-token ratio
223
255
 
224
256
  ### Links
225
257
 
@@ -268,7 +300,12 @@ bundle exec rake
268
300
 
269
301
  ### Git hooks
270
302
 
271
- We use [lefthook](https://lefthook.dev/) with the Ruby [commitlint](https://github.com/arandilopez/commitlint) gem to enforce Conventional Commits on every commit. We also use [Standard Ruby](https://standardrb.com/) to keep code style consistent. CI validates commit messages, Standard Ruby, tests, and git-cliff changelog generation on pull requests and pushes to main/master.
303
+ We use [lefthook](https://lefthook.dev/) with the Ruby
304
+ [commitlint](https://github.com/arandilopez/commitlint) gem to enforce
305
+ Conventional Commits on every commit. We also use
306
+ [Standard Ruby](https://standardrb.com/) to keep code style consistent. CI
307
+ validates commit messages, Standard Ruby, tests, and git-cliff changelog
308
+ generation on pull requests and pushes to main/master.
272
309
 
273
310
  Run the hook installer once per clone:
274
311
 
@@ -284,11 +321,16 @@ rake install
284
321
 
285
322
  ## Release
286
323
 
287
- Releases are tag-driven and published by GitHub Actions to RubyGems. Local release commands never publish directly.
324
+ Releases are tag-driven and published by GitHub Actions to RubyGems.
325
+ Local release commands never publish directly.
288
326
 
289
- Install [git-cliff](https://git-cliff.org/) locally before preparing a release. The release task regenerates `CHANGELOG.md` from Conventional Commits.
327
+ Install [git-cliff](https://git-cliff.org/) locally before preparing a
328
+ release. The release task prepends the next `CHANGELOG.md` section from
329
+ Conventional Commits.
290
330
 
291
- Before preparing a release, make sure you are on `main` or `master` with a clean worktree.
331
+ Before preparing a release, make sure you are on `main` or `master` with a
332
+ clean worktree. If the release contains a breaking public-contract change,
333
+ update `UPGRADE.md` with the host-app migration steps first.
292
334
 
293
335
  Then run one of:
294
336
 
@@ -301,12 +343,13 @@ bundle exec rake 'release:prepare[0.1.0]'
301
343
 
302
344
  The task will:
303
345
 
304
- 1. Regenerate `CHANGELOG.md` with `git-cliff`.
346
+ 1. Prepend the next `CHANGELOG.md` section with `git-cliff`.
305
347
  1. Update `lib/crawlscope/version.rb`.
306
348
  1. Commit the release changes.
307
349
  1. Create and push the `vX.Y.Z` tag.
308
350
 
309
- The `Release` workflow then runs tests, publishes the gem to RubyGems, and creates the GitHub release from the changelog entry.
351
+ The `Release` workflow then runs tests, publishes the gem to RubyGems,
352
+ and creates the GitHub release from the changelog entry.
310
353
 
311
354
  ## Contributing
312
355
 
@@ -37,11 +37,14 @@ module Crawlscope
37
37
  @err.puts(general_usage)
38
38
  1
39
39
  end
40
- rescue OptionParser::InvalidOption, OptionParser::MissingArgument, ConfigurationError, ValidationError, ArgumentError => error
40
+ rescue OptionParser::InvalidOption, OptionParser::MissingArgument, ConfigurationError, ArgumentError => error
41
41
  @err.puts(error.message)
42
42
  @err.puts("")
43
43
  @err.puts(general_usage)
44
44
  1
45
+ rescue ValidationError => error
46
+ @err.puts(error.message)
47
+ 1
45
48
  end
46
49
 
47
50
  private
@@ -83,6 +83,7 @@ module Crawlscope
83
83
  issues.add(code: :unexpected_status, severity: :error, category: :crawl, url: page.url, message: "HTTP #{page.status}", details: {status: page.status})
84
84
  elsif redirected?(page)
85
85
  issues.add(code: :redirected_page, severity: :warning, category: :crawl, url: page.url, message: "redirects to #{page.final_url}", details: {final_url: page.final_url, status: page.status})
86
+ issues.add(code: :sitemap_redirect_url, severity: :warning, category: :sitemaps, url: page.url, message: "sitemap URL redirects to #{page.final_url}", details: {final_url: page.final_url, status: page.status})
86
87
  end
87
88
  end
88
89
  end
@@ -127,6 +128,7 @@ module Crawlscope
127
128
  crawled: crawled,
128
129
  error: page.error,
129
130
  final_url: page.normalized_final_url || normalized_url,
131
+ html: page.html?,
130
132
  status: page.status
131
133
  }
132
134
  end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Crawlscope
4
+ module DocumentText
5
+ REMOVED_SELECTORS = "script, style, noscript, template, svg"
6
+ TOKEN_PATTERN = /[[:alnum:]]+/
7
+
8
+ module_function
9
+
10
+ def body_text(doc)
11
+ text_for(doc, selector: nil)
12
+ end
13
+
14
+ def html_for(doc, selector: "main")
15
+ root_for(doc, selector: selector)&.to_html.to_s
16
+ end
17
+
18
+ def text_for(doc, selector: "main")
19
+ normalize(root_for(doc, selector: selector)&.text)
20
+ end
21
+
22
+ def tokens(text)
23
+ normalize(text).downcase.scan(TOKEN_PATTERN).reject { |token| token.length < 2 }
24
+ end
25
+
26
+ def normalize(text)
27
+ text.to_s.gsub(/\s+/, " ").strip
28
+ end
29
+
30
+ def root_for(doc, selector:)
31
+ return unless doc
32
+
33
+ copy = doc.dup
34
+ copy.css(REMOVED_SELECTORS).remove
35
+
36
+ root = selector.to_s.empty? ? nil : copy.at_css(selector)
37
+ root || copy.at_css("body") || copy
38
+ end
39
+ end
40
+ end
@@ -4,25 +4,40 @@ module Crawlscope
4
4
  module RakeTasks
5
5
  module_function
6
6
 
7
- def validate
8
- run("validate")
7
+ def validate(url: nil, sitemap_path: nil, rule_names: nil)
8
+ run("validate", argv: validate_argv(url: url, sitemap_path: sitemap_path, rule_names: rule_names))
9
9
  end
10
10
 
11
- def ldjson
12
- run("ldjson")
11
+ def ldjson(urls: nil)
12
+ run("ldjson", argv: ldjson_argv(urls: urls))
13
13
  end
14
14
 
15
- def validate_rule(rule)
16
- original_rules = ENV["RULES"]
17
- ENV["RULES"] = rule
18
- validate
19
- ensure
20
- ENV["RULES"] = original_rules
15
+ def validate_rule(rule, url: nil, sitemap_path: nil)
16
+ validate(url: url, sitemap_path: sitemap_path, rule_names: rule)
21
17
  end
22
18
 
23
- def run(command)
24
- status = Cli.start([command], out: $stdout, err: $stderr)
19
+ def run(command, argv: [])
20
+ status = Cli.start([command, *argv], out: $stdout, err: $stderr)
25
21
  exit(status) unless status.zero?
26
22
  end
23
+
24
+ def validate_argv(url:, sitemap_path:, rule_names:)
25
+ [
26
+ option_pair("--url", url),
27
+ option_pair("--sitemap", sitemap_path),
28
+ option_pair("--rules", rule_names)
29
+ ].compact.flatten
30
+ end
31
+
32
+ def ldjson_argv(urls:)
33
+ Array(urls).flat_map { |url| option_pair("--url", url) }.compact
34
+ end
35
+
36
+ def option_pair(name, value)
37
+ value = value.to_s.strip
38
+ return if value.empty?
39
+
40
+ [name, value]
41
+ end
27
42
  end
28
43
  end
@@ -20,14 +20,29 @@ module Crawlscope
20
20
 
21
21
  @io.puts("Status: FAILED")
22
22
  @io.puts("Issues: #{result.issues.size}")
23
+ @io.puts("")
23
24
 
24
- result.issues.by_severity.sort_by { |severity, _issues| severity.to_s }.each do |severity, issues|
25
- @io.puts("#{severity}: #{issues.size}")
26
- end
25
+ report_grouped_issues("Severity", result.issues.by_severity)
26
+ @io.puts("")
27
+ report_grouped_issues("Category", result.issues.by_category)
28
+ end
29
+
30
+ private
27
31
 
28
- result.issues.each do |issue|
29
- @io.puts("- [#{issue.severity}] #{issue.url} #{issue.message}")
32
+ def report_grouped_issues(title, grouped_issues)
33
+ @io.puts("#{title}:")
34
+
35
+ grouped_issues.sort_by { |name, _issues| name.to_s }.each do |name, issues|
36
+ @io.puts("#{name}: #{issues.size}")
37
+ issues.each do |issue|
38
+ @io.puts(" - #{offense(issue)}")
39
+ end
30
40
  end
31
41
  end
42
+
43
+ def offense(issue)
44
+ parts = ["[#{issue.severity}]", issue.code, issue.url, issue.message]
45
+ parts.compact.join(" ")
46
+ end
32
47
  end
33
48
  end
@@ -12,12 +12,14 @@ module Crawlscope
12
12
  def self.default(site_name: nil)
13
13
  new(
14
14
  rules: [
15
+ Rules::Indexability.new,
15
16
  Rules::Metadata.new(site_name: site_name),
16
17
  Rules::StructuredData.new,
17
18
  Rules::Uniqueness.new,
19
+ Rules::ContentQuality.new,
18
20
  Rules::Links.new
19
21
  ],
20
- default_codes: %i[metadata structured_data uniqueness links]
22
+ default_codes: %i[indexability metadata structured_data uniqueness content_quality links]
21
23
  )
22
24
  end
23
25
 
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Crawlscope
4
+ module Rules
5
+ class ContentQuality
6
+ MIN_VISIBLE_TEXT_RATIO = 0.08
7
+ MIN_VISIBLE_WORDS = 250
8
+ MIN_UNIQUE_TOKEN_RATIO = 0.25
9
+
10
+ attr_reader :code
11
+
12
+ def initialize(
13
+ min_visible_text_ratio: MIN_VISIBLE_TEXT_RATIO,
14
+ min_visible_words: MIN_VISIBLE_WORDS,
15
+ min_unique_token_ratio: MIN_UNIQUE_TOKEN_RATIO
16
+ )
17
+ @code = :content_quality
18
+ @min_visible_text_ratio = min_visible_text_ratio
19
+ @min_visible_words = min_visible_words
20
+ @min_unique_token_ratio = min_unique_token_ratio
21
+ end
22
+
23
+ def call(urls:, pages:, issues:, context: nil)
24
+ pages.each do |page|
25
+ next unless page.html?
26
+
27
+ validate_visible_words(page, issues)
28
+ validate_visible_text_ratio(page, issues)
29
+ validate_unique_token_ratio(page, issues)
30
+ end
31
+ end
32
+
33
+ private
34
+
35
+ def validate_unique_token_ratio(page, issues)
36
+ tokens = DocumentText.tokens(DocumentText.text_for(page.doc))
37
+ return if tokens.size < @min_visible_words
38
+
39
+ ratio = tokens.uniq.size.to_f / tokens.size
40
+ return if ratio >= @min_unique_token_ratio
41
+
42
+ issues.add(
43
+ code: :low_unique_token_ratio,
44
+ severity: :warning,
45
+ category: :content_quality,
46
+ url: page.url,
47
+ message: "visible text has low token variety (#{format_ratio(ratio)})",
48
+ details: {
49
+ ratio: ratio.round(3),
50
+ threshold: @min_unique_token_ratio,
51
+ token_count: tokens.size,
52
+ unique_token_count: tokens.uniq.size
53
+ }
54
+ )
55
+ end
56
+
57
+ def validate_visible_text_ratio(page, issues)
58
+ html_bytes = DocumentText.html_for(page.doc).bytesize
59
+ return if html_bytes.zero?
60
+
61
+ visible_text = DocumentText.text_for(page.doc)
62
+ ratio = visible_text.bytesize.to_f / html_bytes
63
+ return if ratio >= @min_visible_text_ratio
64
+
65
+ issues.add(
66
+ code: :low_visible_text_ratio,
67
+ severity: :warning,
68
+ category: :content_quality,
69
+ url: page.url,
70
+ message: "low visible text to HTML ratio (#{format_ratio(ratio)})",
71
+ details: {
72
+ html_bytes: html_bytes,
73
+ ratio: ratio.round(3),
74
+ threshold: @min_visible_text_ratio,
75
+ visible_text_bytes: visible_text.bytesize
76
+ }
77
+ )
78
+ end
79
+
80
+ def validate_visible_words(page, issues)
81
+ word_count = DocumentText.tokens(DocumentText.text_for(page.doc)).size
82
+ return if word_count >= @min_visible_words
83
+
84
+ issues.add(
85
+ code: :thin_visible_text,
86
+ severity: :warning,
87
+ category: :content_quality,
88
+ url: page.url,
89
+ message: "thin visible text (#{word_count} words)",
90
+ details: {word_count: word_count, minimum: @min_visible_words}
91
+ )
92
+ end
93
+
94
+ def format_ratio(value)
95
+ format("%.2f", value)
96
+ end
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,179 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Crawlscope
4
+ module Rules
5
+ class Indexability
6
+ ROBOTS_META_SELECTOR = 'meta[name="robots"], meta[name="googlebot"]'
7
+ X_ROBOTS_TAG_HEADER = "x-robots-tag"
8
+
9
+ attr_reader :code
10
+
11
+ def initialize
12
+ @code = :indexability
13
+ end
14
+
15
+ def call(urls:, pages:, issues:, context: nil)
16
+ sitemap_urls = normalized_sitemap_urls(urls)
17
+
18
+ pages.each do |page|
19
+ validate_meta_robots(page, issues, sitemap_urls) if page.html?
20
+ validate_x_robots_tag(page, issues, sitemap_urls)
21
+ end
22
+ end
23
+
24
+ private
25
+
26
+ def normalized_sitemap_urls(urls)
27
+ urls.map { |url| Url.normalize(url, base_url: url) }.compact
28
+ end
29
+
30
+ def header_value(page, name)
31
+ page.headers.find { |key, _value| key.to_s.casecmp?(name) }&.last.to_s
32
+ end
33
+
34
+ def directives(value)
35
+ value
36
+ .split(",")
37
+ .map { |directive| directive.split(":", 2).last.to_s.strip }
38
+ .reject(&:empty?)
39
+ end
40
+
41
+ def noindex?(value)
42
+ directives(value).any? { |directive| directive.casecmp?("noindex") || directive.casecmp?("none") }
43
+ end
44
+
45
+ def follow?(value)
46
+ directives(value).any? { |directive| directive.casecmp?("follow") }
47
+ end
48
+
49
+ def nofollow?(value)
50
+ directives(value).any? { |directive| directive.casecmp?("nofollow") || directive.casecmp?("none") }
51
+ end
52
+
53
+ def validate_meta_robots(page, issues, sitemap_urls)
54
+ page.doc.css(ROBOTS_META_SELECTOR).each do |tag|
55
+ content = tag["content"].to_s
56
+
57
+ report_noindex_meta(page, issues, content, tag["name"].to_s, sitemap_urls) if noindex?(content)
58
+ report_nofollow_meta(page, issues, content, tag["name"].to_s) if nofollow?(content)
59
+ report_noindex_follow_meta(page, issues, content, tag["name"].to_s) if noindex?(content) && follow?(content)
60
+ report_noindex_nofollow_meta(page, issues, content, tag["name"].to_s) if noindex?(content) && nofollow?(content)
61
+ end
62
+ end
63
+
64
+ def validate_x_robots_tag(page, issues, sitemap_urls)
65
+ content = header_value(page, X_ROBOTS_TAG_HEADER)
66
+ return if content.empty?
67
+
68
+ report_noindex_header(page, issues, content, sitemap_urls) if noindex?(content)
69
+ report_nofollow_header(page, issues, content) if nofollow?(content)
70
+ report_noindex_follow_header(page, issues, content) if noindex?(content) && follow?(content)
71
+ report_noindex_nofollow_header(page, issues, content) if noindex?(content) && nofollow?(content)
72
+ end
73
+
74
+ def report_noindex_meta(page, issues, content, name, sitemap_urls)
75
+ issues.add(
76
+ code: :noindex_meta,
77
+ severity: :error,
78
+ category: :indexability,
79
+ url: page.url,
80
+ message: "robots meta tag prevents indexing",
81
+ details: {content: content, name: name}
82
+ )
83
+ report_sitemap_noindex_url(page, issues, content, source: "meta", sitemap_urls: sitemap_urls)
84
+ end
85
+
86
+ def report_nofollow_meta(page, issues, content, name)
87
+ issues.add(
88
+ code: :nofollow_meta,
89
+ severity: :warning,
90
+ category: :indexability,
91
+ url: page.url,
92
+ message: "robots meta tag prevents following links",
93
+ details: {content: content, name: name}
94
+ )
95
+ end
96
+
97
+ def report_noindex_follow_meta(page, issues, content, name)
98
+ issues.add(
99
+ code: :noindex_follow_meta,
100
+ severity: :warning,
101
+ category: :indexability,
102
+ url: page.url,
103
+ message: "robots meta tag prevents indexing but allows following links",
104
+ details: {content: content, name: name}
105
+ )
106
+ end
107
+
108
+ def report_noindex_nofollow_meta(page, issues, content, name)
109
+ issues.add(
110
+ code: :noindex_nofollow_meta,
111
+ severity: :error,
112
+ category: :indexability,
113
+ url: page.url,
114
+ message: "robots meta tag prevents indexing and following links",
115
+ details: {content: content, name: name}
116
+ )
117
+ end
118
+
119
+ def report_noindex_header(page, issues, content, sitemap_urls)
120
+ issues.add(
121
+ code: :noindex_header,
122
+ severity: :error,
123
+ category: :indexability,
124
+ url: page.url,
125
+ message: "X-Robots-Tag header prevents indexing",
126
+ details: {content: content}
127
+ )
128
+ report_sitemap_noindex_url(page, issues, content, source: "header", sitemap_urls: sitemap_urls)
129
+ end
130
+
131
+ def report_nofollow_header(page, issues, content)
132
+ issues.add(
133
+ code: :nofollow_header,
134
+ severity: :warning,
135
+ category: :indexability,
136
+ url: page.url,
137
+ message: "X-Robots-Tag header prevents following links",
138
+ details: {content: content}
139
+ )
140
+ end
141
+
142
+ def report_noindex_follow_header(page, issues, content)
143
+ issues.add(
144
+ code: :noindex_follow_header,
145
+ severity: :warning,
146
+ category: :indexability,
147
+ url: page.url,
148
+ message: "X-Robots-Tag header prevents indexing but allows following links",
149
+ details: {content: content}
150
+ )
151
+ end
152
+
153
+ def report_noindex_nofollow_header(page, issues, content)
154
+ issues.add(
155
+ code: :noindex_nofollow_header,
156
+ severity: :error,
157
+ category: :indexability,
158
+ url: page.url,
159
+ message: "X-Robots-Tag header prevents indexing and following links",
160
+ details: {content: content}
161
+ )
162
+ end
163
+
164
+ def report_sitemap_noindex_url(page, issues, content, source:, sitemap_urls:)
165
+ normalized_url = Url.normalize(page.url, base_url: page.url)
166
+ return unless sitemap_urls.include?(normalized_url)
167
+
168
+ issues.add(
169
+ code: :sitemap_noindex_url,
170
+ severity: :error,
171
+ category: :sitemaps,
172
+ url: page.url,
173
+ message: "sitemap URL is noindex",
174
+ details: {content: content, source: source}
175
+ )
176
+ end
177
+ end
178
+ end
179
+ end