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 +4 -4
- data/CHANGELOG.md +73 -0
- data/README.md +50 -7
- data/lib/crawlscope/cli.rb +4 -1
- data/lib/crawlscope/crawl.rb +2 -0
- data/lib/crawlscope/document_text.rb +40 -0
- data/lib/crawlscope/rake_tasks.rb +27 -12
- data/lib/crawlscope/reporter.rb +20 -5
- data/lib/crawlscope/rule_registry.rb +3 -1
- data/lib/crawlscope/rules/content_quality.rb +99 -0
- data/lib/crawlscope/rules/indexability.rb +179 -0
- data/lib/crawlscope/rules/links.rb +312 -9
- data/lib/crawlscope/rules/metadata.rb +61 -6
- data/lib/crawlscope/rules/structured_data.rb +31 -0
- data/lib/crawlscope/rules/uniqueness.rb +98 -4
- data/lib/crawlscope/sitemap.rb +9 -1
- data/lib/crawlscope/version.rb +1 -1
- data/lib/tasks/crawlscope_tasks.rake +28 -18
- data/test/crawlscope/cli_test.rb +1 -0
- data/test/crawlscope/configuration_test.rb +8 -1
- data/test/crawlscope/content_quality_rule_test.rb +68 -0
- data/test/crawlscope/crawl_test.rb +37 -1
- data/test/crawlscope/indexability_rule_test.rb +129 -0
- data/test/crawlscope/links_rule_test.rb +148 -3
- data/test/crawlscope/metadata_rule_test.rb +36 -0
- data/test/crawlscope/rake_tasks_test.rb +70 -0
- data/test/crawlscope/reporter_test.rb +7 -3
- data/test/crawlscope/sitemap_test.rb +24 -0
- data/test/crawlscope/structured_data_rule_test.rb +56 -0
- data/test/crawlscope/uniqueness_rule_test.rb +59 -3
- data/test/release_task_test.rb +86 -0
- metadata +9 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7d9e56ae9a55e3c4bb6b079585b8a302edcc1bfad9110938c9421c5224bf27f9
|
|
4
|
+
data.tar.gz: ab1908aa4a1bef4c15f055800ca9862ba973c9257f39020309de1f5554923cca
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
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.
|
|
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,
|
|
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
|
|
data/lib/crawlscope/cli.rb
CHANGED
|
@@ -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,
|
|
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
|
data/lib/crawlscope/crawl.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
data/lib/crawlscope/reporter.rb
CHANGED
|
@@ -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
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
29
|
-
|
|
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
|