seo_geo 0.1.0 → 0.2.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/AGENTS.md +42 -0
- data/CHANGELOG.md +7 -1
- data/README.md +45 -3
- data/lib/seo_geo/audit/http_fetcher.rb +99 -0
- data/lib/seo_geo/audit/rack_fetcher.rb +34 -0
- data/lib/seo_geo/audit/remote.rb +114 -0
- data/lib/seo_geo/audit/runner.rb +41 -19
- data/lib/seo_geo/audit.rb +3 -0
- data/lib/seo_geo/controller.rb +2 -0
- data/lib/seo_geo/meta.rb +18 -1
- data/lib/seo_geo/version.rb +1 -1
- data/lib/tasks/seo_geo.rake +13 -2
- data/llms.txt +4 -2
- metadata +5 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: bcf366096f21a5775c68799a9d2daf6a221d228ce0537c032050993e6703ea52
|
|
4
|
+
data.tar.gz: 9917d75bca5dae4ddf96f9d99567361d5fb7c66a0ec6048faabdf5ae1e24964f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: '09ce397eac4ae10fbabde19a7ef53335a7a09e9e9e1756c56f7d4e57834c5ca9947cc315966c5bbe11d2b5c70db2810afa262d0c2973ab9541e17f56fa1d4272'
|
|
7
|
+
data.tar.gz: 7f49703ed03915d08bdee4c5155c115aba8e8c9038312a61773bd15a77374da1d0f788a617123b46418c046e1641f1d18f291c6762d922ff02b0a815c24676f2
|
data/AGENTS.md
CHANGED
|
@@ -162,6 +162,29 @@ end
|
|
|
162
162
|
RSpec: `require "seo_geo/rspec"` gives `have_seo_geo_canonical`, `have_seo_geo_single_title`,
|
|
163
163
|
`have_seo_geo_faq_matching_visible_text`, `be_seo_geo_valid_sitemap(origin:)` and `seo_geo_robots_allow(ua, path)`.
|
|
164
164
|
|
|
165
|
+
For article pages, pass `type: "article"`, `published_time:` and `modified_time:` to `seo`.
|
|
166
|
+
Use the article's real stored dates: `Time`, `Date` and ISO 8601 strings are accepted and output as ISO 8601.
|
|
167
|
+
Date-only values remain date-only; invalid values raise `ArgumentError` when rendering an article.
|
|
168
|
+
These tags are omitted for other OG types and when no time is supplied.
|
|
169
|
+
|
|
170
|
+
<!-- example: quickstart/app/controllers/articles_controller.rb -->
|
|
171
|
+
```ruby
|
|
172
|
+
# app/controllers/articles_controller.rb
|
|
173
|
+
class ArticlesController < ApplicationController
|
|
174
|
+
def show
|
|
175
|
+
seo title: "Example launch", type: "article",
|
|
176
|
+
published_time: Time.iso8601("2026-10-01T09:00:00+09:00"),
|
|
177
|
+
modified_time: "2026-10-02T12:00:00+09:00"
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
<!-- output: quickstart GET /articles/launch Host=www.example.com -->
|
|
183
|
+
```html
|
|
184
|
+
<meta property="article:published_time" content="2026-10-01T09:00:00+09:00">
|
|
185
|
+
<meta property="article:modified_time" content="2026-10-02T12:00:00+09:00">
|
|
186
|
+
```
|
|
187
|
+
|
|
165
188
|
## Invariants — do not violate
|
|
166
189
|
|
|
167
190
|
1. **Hosts fail closed.** Every hostname that serves the app is either `canonical_origin`'s host, listed in
|
|
@@ -197,6 +220,25 @@ The rake task audits as production even in development: it sets `SEO_GEO_AUDIT=1
|
|
|
197
220
|
the configured hosts to `config.hosts` for that run (`seo_geo audit: temporarily allowing ... in config.hosts`).
|
|
198
221
|
A runner needs the variable before boot. Production `config.hosts` is never widened.
|
|
199
222
|
|
|
223
|
+
Audit deployed HTTP responses without booting the application with `URL` or comma-separated `URLS`:
|
|
224
|
+
|
|
225
|
+
```sh
|
|
226
|
+
bundle exec rake seo_geo:audit URL=https://www.example.com
|
|
227
|
+
bundle exec rake seo_geo:audit URLS=https://www.example.com,https://www.example.com/pricing FORMAT=json
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
Remote requests run serially with a 0.5-second pause between requests, 10-second connect/read/write timeouts
|
|
231
|
+
and `User-Agent: seo_geo-audit/<VERSION>`. The initial response remains the audit input; redirect destinations
|
|
232
|
+
are explicitly probed and recorded up to five hops. More than one hop warns (`page.redirect_chain`), while loops,
|
|
233
|
+
unfinished chains and `X-Robots-Tag: noindex` (including scoped `none`) are errors. `robots.mismatch` warns only
|
|
234
|
+
when an already available configuration declares the host and its rendered robots.txt differs from the response.
|
|
235
|
+
No Rails initializer is loaded for remote mode, so pass an existing configuration via
|
|
236
|
+
`SeoGeo::Audit::Remote.run(urls, io: $stdout, config: config)` when a comparison is needed.
|
|
237
|
+
Without configuration, canonical URLs are checked against the requested origin and robots blocking against `*`;
|
|
238
|
+
intentional crawler-specific denials cannot be inferred. Both audit modes probe a random missing path per host
|
|
239
|
+
(`page.soft_404` on 2xx) and warn about duplicate descriptions on indexable pages (`description.duplicate`).
|
|
240
|
+
Remote audits judge the live site as production would: like the in-process audit they switch the config to an indexable environment for the run and restore it afterwards. They do not widen `config.hosts` or scan local source files.
|
|
241
|
+
|
|
200
242
|
With the app running (`bin/rails s`), check what crawlers and agents receive. robots.txt and sitemap.xml only
|
|
201
243
|
open up in `indexable_environments` (default production):
|
|
202
244
|
|
data/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
##
|
|
3
|
+
## 0.2.0 - 2026-10-20
|
|
4
|
+
|
|
5
|
+
- Add standalone remote audits (`URL` / `URLS`, `Audit::Remote.run`) with shared checks, Rack/HTTP fetchers,
|
|
6
|
+
serial paced requests, bounded redirect tracing, X-Robots-Tag errors and configured robots comparison.
|
|
7
|
+
- Detect soft-404 responses with a random missing path on each audited host, in-process and remotely.
|
|
8
|
+
- Warn when indexable pages on the same host share a meta description.
|
|
9
|
+
- Support `seo published_time:` / `modified_time:` as ISO 8601 article Open Graph tags, with executable examples.
|
|
4
10
|
|
|
5
11
|
## 0.1.0 - 2026-10-14
|
|
6
12
|
|
data/README.md
CHANGED
|
@@ -61,8 +61,7 @@ gem "seo_geo"
|
|
|
61
61
|
|
|
62
62
|
## Status
|
|
63
63
|
|
|
64
|
-
- Version 0.
|
|
65
|
-
`gem "seo_geo", github: "seunghan91/seo_geo"`.
|
|
64
|
+
- Version 0.2.0, released to [RubyGems](https://rubygems.org/gems/seo_geo) on 2026-10-20.
|
|
66
65
|
- CI runs Ruby 3.2, 3.3 and 3.4 × Rails 7.2, 8.0 and 8.1 (every cell), each against an ERB app and an
|
|
67
66
|
Inertia-style app.
|
|
68
67
|
- Runtime dependencies: `railties`, `actionpack`, `meta-tags ~> 2.24` and `reverse_markdown ~> 3.0`.
|
|
@@ -109,6 +108,7 @@ SeoGeo.config.llms_txt.page :home, path: "/"
|
|
|
109
108
|
Rails.application.routes.draw do
|
|
110
109
|
root "pages#home"
|
|
111
110
|
get "/pricing", to: "pages#pricing"
|
|
111
|
+
get "/articles/launch", to: "articles#show"
|
|
112
112
|
|
|
113
113
|
mount SeoGeo::Engine, at: "/" # last, so your own routes win where paths overlap
|
|
114
114
|
end
|
|
@@ -276,6 +276,48 @@ On a real app warnings appear in the same line, for example
|
|
|
276
276
|
line per finding. Calling `SeoGeo::Audit::Runner` from `bin/rails runner` needs `SEO_GEO_AUDIT=1` set before
|
|
277
277
|
boot for the same host allowance.
|
|
278
278
|
|
|
279
|
+
Audit deployed HTTP responses without booting the application with `URL` or comma-separated `URLS`:
|
|
280
|
+
|
|
281
|
+
```sh
|
|
282
|
+
bundle exec rake seo_geo:audit URL=https://www.example.com
|
|
283
|
+
bundle exec rake seo_geo:audit URLS=https://www.example.com,https://www.example.com/pricing FORMAT=json
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
Remote requests run serially with a 0.5-second pause between requests, 10-second connect/read/write timeouts
|
|
287
|
+
and `User-Agent: seo_geo-audit/<VERSION>`. The initial response remains the audit input; redirect destinations
|
|
288
|
+
are explicitly probed and recorded up to five hops. More than one hop warns (`page.redirect_chain`), while loops,
|
|
289
|
+
unfinished chains and `X-Robots-Tag: noindex` (including scoped `none`) are errors. `robots.mismatch` warns only
|
|
290
|
+
when an already available configuration declares the host and its rendered robots.txt differs from the response.
|
|
291
|
+
No Rails initializer is loaded for remote mode, so pass an existing configuration via
|
|
292
|
+
`SeoGeo::Audit::Remote.run(urls, io: $stdout, config: config)` when a comparison is needed.
|
|
293
|
+
Without configuration, canonical URLs are checked against the requested origin and robots blocking against `*`;
|
|
294
|
+
intentional crawler-specific denials cannot be inferred. Both audit modes probe a random missing path per host
|
|
295
|
+
(`page.soft_404` on 2xx) and warn about duplicate descriptions on indexable pages (`description.duplicate`).
|
|
296
|
+
Remote audits judge the live site as production would: like the in-process audit they switch the config to an indexable environment for the run and restore it afterwards. They do not widen `config.hosts` or scan local source files.
|
|
297
|
+
|
|
298
|
+
For article pages, pass `type: "article"`, `published_time:` and `modified_time:` to `seo`.
|
|
299
|
+
Use the article's real stored dates: `Time`, `Date` and ISO 8601 strings are accepted and output as ISO 8601.
|
|
300
|
+
Date-only values remain date-only; invalid values raise `ArgumentError` when rendering an article.
|
|
301
|
+
These tags are omitted for other OG types and when no time is supplied.
|
|
302
|
+
|
|
303
|
+
<!-- example: quickstart/app/controllers/articles_controller.rb -->
|
|
304
|
+
```ruby
|
|
305
|
+
# app/controllers/articles_controller.rb
|
|
306
|
+
class ArticlesController < ApplicationController
|
|
307
|
+
def show
|
|
308
|
+
seo title: "Example launch", type: "article",
|
|
309
|
+
published_time: Time.iso8601("2026-10-01T09:00:00+09:00"),
|
|
310
|
+
modified_time: "2026-10-02T12:00:00+09:00"
|
|
311
|
+
end
|
|
312
|
+
end
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
<!-- output: quickstart GET /articles/launch Host=www.example.com -->
|
|
316
|
+
```html
|
|
317
|
+
<meta property="article:published_time" content="2026-10-01T09:00:00+09:00">
|
|
318
|
+
<meta property="article:modified_time" content="2026-10-02T12:00:00+09:00">
|
|
319
|
+
```
|
|
320
|
+
|
|
279
321
|
## Inertia (React, Vue, Svelte)
|
|
280
322
|
|
|
281
323
|
Inertia sends an empty `<div id="app">`, and crawlers that do not run JavaScript (GPTBot, ClaudeBot,
|
|
@@ -396,7 +438,7 @@ are read with `find_each` (`select` must include `id`, `order` is ignored); `bat
|
|
|
396
438
|
| `title_template must contain %{title}` | A template such as `"%{site}"` | Include `%{title}`, or use `"%{title}"` to drop the suffix |
|
|
397
439
|
| `CANONICAL_ORIGIN must look like https://www.example.com (got "example.com")` | Generator argument without a scheme | `bin/rails g seo_geo:install https://www.example.com` |
|
|
398
440
|
| `SeoGeo::JsonLd::InvalidNode: Product "Widget" needs offers, review or aggregate_rating` | A typed JSON-LD node lacks a property Google requires | Pass the property, or leave the node out; never invent one |
|
|
399
|
-
| `unknown seo option(s): titel (known: title, description, image, noindex, index, canonical, page, type, markdown, json_ld)` | Typo in a controller `seo(...)` call | Use one of the listed keys |
|
|
441
|
+
| `unknown seo option(s): titel (known: title, description, image, noindex, index, canonical, page, type, markdown, json_ld, published_time, modified_time)` | Typo in a controller `seo(...)` call | Use one of the listed keys |
|
|
400
442
|
| `KeyError: seo_geo page :pricing is not defined` | No `config/seo_geo/pages/pricing.yml`, I18n scope or `SeoGeo.pages.register` | Add the page copy, or check the key and the YAML locale keys |
|
|
401
443
|
| `collections need path: (a callable or a record method name)` | `sitemap.add -> { Post.published }` without `path:` | `add -> { Post.published }, path: ->(post) { "/posts/#{post.slug}" }` |
|
|
402
444
|
| `sitemap path "pricing" must start with /` | Relative sitemap path | `add "/pricing"` |
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "uri"
|
|
5
|
+
require "thread"
|
|
6
|
+
require "rack/mock"
|
|
7
|
+
|
|
8
|
+
module SeoGeo
|
|
9
|
+
module Audit
|
|
10
|
+
# Serial HTTP transport. The initial response is preserved; redirect targets are separate probes.
|
|
11
|
+
class HttpFetcher
|
|
12
|
+
REDIRECTS = [301, 302, 303, 307, 308].freeze
|
|
13
|
+
MAX_HOPS = 5
|
|
14
|
+
# @return [Array<Hash>] initial response and each explicitly requested redirect target
|
|
15
|
+
attr_reader :chain
|
|
16
|
+
# @return [String, nil] why the redirect trace could not finish
|
|
17
|
+
attr_reader :redirect_error
|
|
18
|
+
|
|
19
|
+
# @param origin [String] HTTP(S) origin to request
|
|
20
|
+
# @param delay [Numeric] minimum pause between requests, including redirect probes
|
|
21
|
+
# @param timeout [Numeric] connect/read/write timeout in seconds
|
|
22
|
+
def initialize(origin:, delay: 0.5, timeout: 10)
|
|
23
|
+
@origin = self.class.http_uri(origin).to_s
|
|
24
|
+
raise ArgumentError, "delay must be nonnegative and timeout positive" unless delay >= 0 && timeout > 0
|
|
25
|
+
|
|
26
|
+
@delay = delay
|
|
27
|
+
@timeout = timeout
|
|
28
|
+
@mutex = Mutex.new
|
|
29
|
+
@chain = []
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Validates caller URLs and redirect locations before opening a connection.
|
|
33
|
+
# @return [URI::HTTP]
|
|
34
|
+
def self.http_uri(url)
|
|
35
|
+
uri = URI.parse(url.to_s)
|
|
36
|
+
unless uri.is_a?(URI::HTTP) && uri.host && !uri.userinfo
|
|
37
|
+
raise ArgumentError, "audit URL must be HTTP(S), with a host and without credentials"
|
|
38
|
+
end
|
|
39
|
+
uri.path = "/" if uri.path.empty?
|
|
40
|
+
uri.fragment = nil
|
|
41
|
+
uri
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# @return [Rack::MockResponse] first response (never replaced by a redirect target)
|
|
45
|
+
def fetch(_host, path, headers = {})
|
|
46
|
+
@mutex.synchronize do
|
|
47
|
+
@chain = []
|
|
48
|
+
@redirect_error = nil
|
|
49
|
+
uri = self.class.http_uri(URI.join(@origin, path).to_s)
|
|
50
|
+
first = nil
|
|
51
|
+
loop do
|
|
52
|
+
response = request(uri, headers)
|
|
53
|
+
first ||= response
|
|
54
|
+
@chain << { url: uri.to_s, status: response.status, headers: response.headers }
|
|
55
|
+
break unless REDIRECTS.include?(response.status) && response.headers["location"]
|
|
56
|
+
if @chain.size > MAX_HOPS
|
|
57
|
+
@redirect_error = "redirect chain exceeds #{MAX_HOPS} hops"
|
|
58
|
+
break
|
|
59
|
+
end
|
|
60
|
+
target = self.class.http_uri(URI.join(uri.to_s, response.headers["location"]).to_s)
|
|
61
|
+
if @chain.any? { |hop| hop[:url] == target.to_s }
|
|
62
|
+
@redirect_error = "redirect loop to #{target}"
|
|
63
|
+
break
|
|
64
|
+
end
|
|
65
|
+
uri = target
|
|
66
|
+
end
|
|
67
|
+
first
|
|
68
|
+
rescue StandardError => e
|
|
69
|
+
raise unless first
|
|
70
|
+
|
|
71
|
+
@redirect_error = "#{e.class}: #{e.message.lines.first.to_s.strip}"
|
|
72
|
+
first
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
private
|
|
77
|
+
|
|
78
|
+
def request(uri, headers)
|
|
79
|
+
if @last_request
|
|
80
|
+
remaining = @delay - (Process.clock_gettime(Process::CLOCK_MONOTONIC) - @last_request)
|
|
81
|
+
sleep(remaining) if remaining.positive?
|
|
82
|
+
end
|
|
83
|
+
request = Net::HTTP::Get.new(uri.request_uri)
|
|
84
|
+
request["Accept"] = "text/html"
|
|
85
|
+
headers.each { |name, value| request[name.delete_prefix("HTTP_").tr("_", "-")] = value }
|
|
86
|
+
request["User-Agent"] = "seo_geo-audit/#{SeoGeo::VERSION}"
|
|
87
|
+
# Ignore ambient proxy credentials/configuration; audit the requested origin directly.
|
|
88
|
+
http = Net::HTTP.new(uri.hostname, uri.port, nil)
|
|
89
|
+
http.use_ssl = uri.scheme == "https"
|
|
90
|
+
http.open_timeout = http.read_timeout = http.write_timeout = @timeout
|
|
91
|
+
http.max_retries = 0
|
|
92
|
+
response = http.start { |connection| connection.request(request) }
|
|
93
|
+
Rack::MockResponse.new(response.code.to_i, response.to_hash.transform_values { |values| values.join("\n") }, response.body.to_s)
|
|
94
|
+
ensure
|
|
95
|
+
@last_request = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rack"
|
|
4
|
+
require "rack/mock"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
module SeoGeo
|
|
8
|
+
module Audit
|
|
9
|
+
# The audit's original in-process Rack transport.
|
|
10
|
+
class RackFetcher
|
|
11
|
+
# @param app [#call] Rack application
|
|
12
|
+
# @param config [Configuration] origins for the audited hosts
|
|
13
|
+
def initialize(app:, config: SeoGeo.config)
|
|
14
|
+
@app = app
|
|
15
|
+
@config = config
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# @return [Rack::MockResponse] original response, without following redirects
|
|
19
|
+
def fetch(host, path, headers = {})
|
|
20
|
+
origin = URI.parse(@config.origin_for(host))
|
|
21
|
+
scheme = origin.scheme || "https"
|
|
22
|
+
# Rails HostAuthorization (config.hosts) reads the Host header, not SERVER_NAME: send both, with the port
|
|
23
|
+
# only when it is not the scheme's default.
|
|
24
|
+
authority = origin.port && origin.port != origin.default_port ? "#{host}:#{origin.port}" : host
|
|
25
|
+
env = Rack::MockRequest.env_for("#{scheme}://#{authority}#{path}",
|
|
26
|
+
{ "REMOTE_ADDR" => "127.0.0.1", "HTTP_ACCEPT" => "text/html" }.merge(headers))
|
|
27
|
+
env["HTTP_HOST"] = authority
|
|
28
|
+
env["HTTPS"] = "on" if scheme == "https"
|
|
29
|
+
status, response_headers, body = @app.call(env)
|
|
30
|
+
Rack::MockResponse.new(status, response_headers, body)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SeoGeo
|
|
4
|
+
module Audit
|
|
5
|
+
# Audits deployed responses without booting Rails or changing the application's configuration.
|
|
6
|
+
class Remote < Runner
|
|
7
|
+
# @param urls [Array<String>, String] explicit pages/origins; each origin is audited once
|
|
8
|
+
# @param io [#puts, nil] report destination; nil suppresses output
|
|
9
|
+
# @param config [Configuration, nil] optional comparison policy, used only for known hosts
|
|
10
|
+
# @param max_pages [Integer] sitemap pages per origin
|
|
11
|
+
# @param delay [Numeric] HTTP request spacing in seconds
|
|
12
|
+
# @param timeout [Numeric] HTTP timeouts in seconds
|
|
13
|
+
# @param format [Symbol, String] text or json
|
|
14
|
+
# @return [Report]
|
|
15
|
+
def self.run(urls, io: $stdout, config: SeoGeo.config, max_pages: 200, delay: 0.5, timeout: 10, format: :text)
|
|
16
|
+
targets = Array(urls).map { |url| HttpFetcher.http_uri(url) }
|
|
17
|
+
raise ArgumentError, "at least one audit URL is required" if targets.empty?
|
|
18
|
+
|
|
19
|
+
transport = HttpFetcher.new(origin: targets.first.to_s, delay: delay, timeout: timeout)
|
|
20
|
+
report = Report.new
|
|
21
|
+
targets.group_by { |uri| "#{uri.scheme}://#{uri.authority}" }.each do |origin, pages|
|
|
22
|
+
result = new(origin: origin, urls: pages.map(&:to_s), config: config,
|
|
23
|
+
max_pages: max_pages, delay: delay, timeout: timeout, fetcher: transport).run
|
|
24
|
+
report.findings.concat(result.findings)
|
|
25
|
+
result.stats.each { |name, count| report.stats[name] += count }
|
|
26
|
+
end
|
|
27
|
+
io.puts(format.to_s.casecmp?("json") ? report.to_json : report.to_s) if io
|
|
28
|
+
report
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# @param origin [String] one HTTP(S) origin
|
|
32
|
+
# @param urls [Array<String>] explicitly requested pages on that origin
|
|
33
|
+
# @param config [Configuration, nil] optional existing configuration
|
|
34
|
+
# @param max_pages [Integer] maximum sitemap pages
|
|
35
|
+
# @param delay [Numeric] delay between HTTP requests
|
|
36
|
+
# @param timeout [Numeric] HTTP timeouts
|
|
37
|
+
# @param fetcher [HttpFetcher, nil] shared serial transport across origins
|
|
38
|
+
def initialize(origin:, urls:, config: nil, max_pages: 200, delay: 0.5, timeout: 10, fetcher: nil)
|
|
39
|
+
@origin = origin
|
|
40
|
+
@urls = urls.uniq
|
|
41
|
+
host = URI.parse(origin).host.downcase
|
|
42
|
+
@comparison_config = config if config&.known_hosts&.include?(host)
|
|
43
|
+
observed = @comparison_config || Configuration.new.tap do |settings|
|
|
44
|
+
settings.canonical_origin = origin
|
|
45
|
+
settings.environment = "production"
|
|
46
|
+
end
|
|
47
|
+
@audited_pages = []
|
|
48
|
+
@remote_findings = {}
|
|
49
|
+
super(app: nil, config: observed, sitemap: Sitemap::Registry.new, hosts: [host], max_pages: max_pages,
|
|
50
|
+
fetcher: fetcher || HttpFetcher.new(origin: origin, delay: delay, timeout: timeout))
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def origin_for(_host)
|
|
56
|
+
@origin
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def audit_host(host)
|
|
60
|
+
super
|
|
61
|
+
@urls.each do |url|
|
|
62
|
+
audit_page(host, url, from_sitemap: false) unless @audited_pages.include?(url)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def audit_page(host, url, **options)
|
|
67
|
+
@audited_pages << url
|
|
68
|
+
super
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def sitemap_urls(host)
|
|
72
|
+
super.select { |url| Checks.same_origin?(url, @origin) }
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def check_robots_allows(host, url, evaluator)
|
|
76
|
+
return super if @comparison_config
|
|
77
|
+
return if evaluator.allowed?("*", request_path(url))
|
|
78
|
+
|
|
79
|
+
error(host, url, "sitemap.robots_blocked", "listed in sitemap.xml but robots.txt disallows it for *")
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def fetch(host, path, headers = {}, **options)
|
|
83
|
+
response = super(host, "#{@origin}#{path}", headers, **options)
|
|
84
|
+
@fetcher.chain.each do |hop|
|
|
85
|
+
directives = RobotsDirectives.parse_header(hop[:headers]["x-robots-tag"])
|
|
86
|
+
if directives.noindex? || directives.scoped.keys.any? { |bot| directives.noindex?(bot) }
|
|
87
|
+
remote_finding(:error, host, hop[:url], "page.x_robots_noindex", "X-Robots-Tag contains noindex")
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
hops = @fetcher.chain.size - 1
|
|
91
|
+
if hops > 1
|
|
92
|
+
trace = @fetcher.chain.map { |hop| "#{hop[:status]} #{hop[:url]}" }.join(" -> ")
|
|
93
|
+
remote_finding(:warning, host, path, "page.redirect_chain", "#{hops} redirect hops: #{trace}")
|
|
94
|
+
end
|
|
95
|
+
if @fetcher.redirect_error
|
|
96
|
+
remote_finding(:error, host, path, "page.redirect", @fetcher.redirect_error)
|
|
97
|
+
end
|
|
98
|
+
if path == "/robots.txt" && @comparison_config && response.status == 200 &&
|
|
99
|
+
response.body != Robots.new(@comparison_config, host).to_s
|
|
100
|
+
remote_finding(:warning, host, path, "robots.mismatch", "robots.txt differs from the configured rendering for this host")
|
|
101
|
+
end
|
|
102
|
+
response
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def remote_finding(severity, host, url, check, message)
|
|
106
|
+
key = [host, url, check]
|
|
107
|
+
return if @remote_findings[key]
|
|
108
|
+
|
|
109
|
+
@remote_findings[key] = true
|
|
110
|
+
@report.add(severity, check, message, host: host, url: url)
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
data/lib/seo_geo/audit/runner.rb
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require "uri"
|
|
4
4
|
require "rack"
|
|
5
5
|
require "rack/mock"
|
|
6
|
+
require "securerandom"
|
|
6
7
|
|
|
7
8
|
module SeoGeo
|
|
8
9
|
module Audit
|
|
@@ -29,14 +30,17 @@ module SeoGeo
|
|
|
29
30
|
# @param root [String, Pathname, nil] application root for {StaticScan}; nil skips it
|
|
30
31
|
# @param hosts [Array<String>, nil] hosts to audit; nil => {Configuration#known_hosts}
|
|
31
32
|
# @param max_pages [Integer] sitemap URLs fetched per host
|
|
32
|
-
|
|
33
|
+
# @param fetcher [#fetch, nil] transport; nil uses {RackFetcher}
|
|
34
|
+
def initialize(app:, config: SeoGeo.config, sitemap: SeoGeo.sitemap, root: nil, hosts: nil, max_pages: 200, fetcher: nil)
|
|
33
35
|
@app = app
|
|
36
|
+
@fetcher = fetcher || RackFetcher.new(app: app, config: config)
|
|
34
37
|
@config = config
|
|
35
38
|
@sitemap = sitemap
|
|
36
39
|
@root = root
|
|
37
40
|
@hosts = hosts
|
|
38
41
|
@max_pages = max_pages
|
|
39
42
|
@report = Report.new
|
|
43
|
+
@descriptions = Hash.new { |hash, key| hash[key] = [] }
|
|
40
44
|
@titles = Hash.new { |hash, key| hash[key] = [] }
|
|
41
45
|
end
|
|
42
46
|
|
|
@@ -48,12 +52,14 @@ module SeoGeo
|
|
|
48
52
|
(@hosts || @config.known_hosts).each { |host| audit_host(host.to_s.downcase) }
|
|
49
53
|
end
|
|
50
54
|
duplicate_titles
|
|
55
|
+
duplicate_descriptions
|
|
51
56
|
@report
|
|
52
57
|
end
|
|
53
58
|
|
|
54
59
|
private
|
|
55
60
|
|
|
56
61
|
def audit_host(host)
|
|
62
|
+
check_soft_404(host)
|
|
57
63
|
policy = @config.host_policy(host)
|
|
58
64
|
robots = fetch(host, "/robots.txt")
|
|
59
65
|
evaluator = nil
|
|
@@ -70,7 +76,7 @@ module SeoGeo
|
|
|
70
76
|
check_robots_allows(host, url, evaluator) if evaluator
|
|
71
77
|
audit_page(host, url, from_sitemap: true)
|
|
72
78
|
end
|
|
73
|
-
home = "#{
|
|
79
|
+
home = "#{origin_for(host)}/"
|
|
74
80
|
unless pages.any? { |url| same_page?(url, home) }
|
|
75
81
|
audit_page(host, home, from_sitemap: false, full: policy.sitemap)
|
|
76
82
|
end
|
|
@@ -84,7 +90,7 @@ module SeoGeo
|
|
|
84
90
|
return []
|
|
85
91
|
end
|
|
86
92
|
|
|
87
|
-
origin = "#{
|
|
93
|
+
origin = "#{origin_for(host)}/"
|
|
88
94
|
problems, locs, kind = Checks.sitemap(response.body, origin: origin)
|
|
89
95
|
problems.each { |message| error(host, "/sitemap.xml", "sitemap.valid", message) }
|
|
90
96
|
return locs unless kind == :sitemapindex
|
|
@@ -155,6 +161,7 @@ module SeoGeo
|
|
|
155
161
|
@titles[[host, title]] << url unless noindex
|
|
156
162
|
end
|
|
157
163
|
description = doc.description.to_s.strip
|
|
164
|
+
@descriptions[[host, description]] << url unless noindex || description.empty?
|
|
158
165
|
if description.empty?
|
|
159
166
|
warning(host, url, "description.missing", "no meta description")
|
|
160
167
|
elsif !Checks::DESCRIPTION_RANGE.cover?(description.length)
|
|
@@ -183,7 +190,7 @@ module SeoGeo
|
|
|
183
190
|
error(host, markdown_url, "markdown.url", "advertised Markdown URL returned #{dot_md.status} #{dot_md.content_type}")
|
|
184
191
|
end
|
|
185
192
|
|
|
186
|
-
negotiated = fetch(host, request_path(url), "HTTP_ACCEPT" => "text/markdown")
|
|
193
|
+
negotiated = fetch(host, request_path(url), { "HTTP_ACCEPT" => "text/markdown" })
|
|
187
194
|
unless negotiated.status == 200 && negotiated.content_type.to_s.match?(MARKDOWN_TYPE)
|
|
188
195
|
error(host, url, "markdown.accept", "page advertises Markdown but Accept: text/markdown returned " \
|
|
189
196
|
"#{negotiated.status} #{negotiated.content_type} (include SeoGeo::Markdownable " \
|
|
@@ -201,7 +208,7 @@ module SeoGeo
|
|
|
201
208
|
return
|
|
202
209
|
end
|
|
203
210
|
|
|
204
|
-
origins = [
|
|
211
|
+
origins = [origin_for(host), @config.canonical_origin].uniq
|
|
205
212
|
response.body.scan(/\]\((https?:\/\/[^)\s]+)\)/).flatten.uniq.each do |link|
|
|
206
213
|
next unless origins.any? { |origin| link.start_with?("#{origin}/") || link == origin }
|
|
207
214
|
|
|
@@ -230,27 +237,42 @@ module SeoGeo
|
|
|
230
237
|
end
|
|
231
238
|
end
|
|
232
239
|
|
|
240
|
+
def duplicate_descriptions
|
|
241
|
+
@descriptions.each do |(host, description), urls|
|
|
242
|
+
next if urls.uniq.size < 2
|
|
243
|
+
|
|
244
|
+
warning(host, urls.first, "description.duplicate",
|
|
245
|
+
"description #{description.inspect} is shared by #{urls.uniq.size} URLs: #{urls.uniq.first(5).join(', ')}")
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def check_soft_404(host)
|
|
250
|
+
path = "/seo-geo-audit-#{SecureRandom.hex(8)}"
|
|
251
|
+
response = fetch(host, path, expected_missing: true)
|
|
252
|
+
error(host, path, "page.soft_404", "unknown path returned #{response.status} (expected a missing-page response)") if (200..299).cover?(response.status)
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def origin_for(host)
|
|
256
|
+
@config.origin_for(host)
|
|
257
|
+
end
|
|
258
|
+
|
|
233
259
|
# @return [Rack::MockResponse]
|
|
234
|
-
def fetch(host, path, headers = {})
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
# only when it is not the scheme's default.
|
|
239
|
-
authority = origin.port && origin.port != origin.default_port ? "#{host}:#{origin.port}" : host
|
|
240
|
-
env = Rack::MockRequest.env_for("#{scheme}://#{authority}#{path}",
|
|
241
|
-
{ "REMOTE_ADDR" => "127.0.0.1", "HTTP_ACCEPT" => "text/html" }.merge(headers))
|
|
242
|
-
env["HTTP_HOST"] = authority
|
|
243
|
-
env["HTTPS"] = "on" if scheme == "https"
|
|
244
|
-
status, response_headers, body = @app.call(env)
|
|
245
|
-
response = Rack::MockResponse.new(status, response_headers, body)
|
|
246
|
-
if status == 403 && host_blocked?(host, response)
|
|
247
|
-
error(host, path, "host_authorization", "Rails HostAuthorization blocked Host: #{authority}; run it through " \
|
|
260
|
+
def fetch(host, path, headers = {}, expected_missing: false)
|
|
261
|
+
response = @fetcher.fetch(host, path, headers)
|
|
262
|
+
if response.status == 403 && host_blocked?(host, response)
|
|
263
|
+
error(host, path, "host_authorization", "Rails HostAuthorization blocked Host: #{URI.parse(origin_for(host)).authority}; run it through " \
|
|
248
264
|
"`bin/rails seo_geo:audit` (or set SEO_GEO_AUDIT=1 before boot, e.g. for " \
|
|
249
265
|
"bin/rails runner) so seo_geo allows its hosts outside production, or " \
|
|
250
266
|
"add #{host.inspect} to config.hosts")
|
|
251
267
|
end
|
|
252
268
|
response
|
|
253
269
|
rescue StandardError => e
|
|
270
|
+
# Test/development apps can raise their configured 404 exceptions instead of rendering a response.
|
|
271
|
+
if expected_missing && defined?(ActionDispatch::ExceptionWrapper) &&
|
|
272
|
+
ActionDispatch::ExceptionWrapper.status_code_for_exception(e.class.name) == 404
|
|
273
|
+
return Rack::MockResponse.new(404, {}, [])
|
|
274
|
+
end
|
|
275
|
+
|
|
254
276
|
error(host, path, "exception", "#{e.class}: #{e.message.lines.first.to_s.strip}")
|
|
255
277
|
Rack::MockResponse.new(500, {}, [])
|
|
256
278
|
end
|
data/lib/seo_geo/audit.rb
CHANGED
|
@@ -11,4 +11,7 @@ require_relative "audit/report"
|
|
|
11
11
|
require_relative "audit/html_document"
|
|
12
12
|
require_relative "audit/checks"
|
|
13
13
|
require_relative "audit/static_scan"
|
|
14
|
+
require_relative "audit/rack_fetcher"
|
|
15
|
+
require_relative "audit/http_fetcher"
|
|
14
16
|
require_relative "audit/runner"
|
|
17
|
+
require_relative "audit/remote"
|
data/lib/seo_geo/controller.rb
CHANGED
|
@@ -33,6 +33,8 @@ module SeoGeo
|
|
|
33
33
|
# @param canonical [String, false] path or URL to canonicalize; false omits the canonical link and hreflang
|
|
34
34
|
# @param page [Symbol, Page] page copy (title/description/FAQ JSON-LD defaults)
|
|
35
35
|
# @param type [String] og:type (default "website")
|
|
36
|
+
# @param published_time [Time, Date, String] ISO 8601 article publication time (type: "article" only)
|
|
37
|
+
# @param modified_time [Time, Date, String] ISO 8601 article modification time (type: "article" only)
|
|
36
38
|
# @param markdown [Boolean] false hides the Markdown alternate link
|
|
37
39
|
# @param json_ld [Hash, JsonLd, Array] extra JSON-LD nodes
|
|
38
40
|
# @return [Meta::State]
|
data/lib/seo_geo/meta.rb
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
require "uri"
|
|
4
4
|
require "erb"
|
|
5
5
|
require "json"
|
|
6
|
+
require "time"
|
|
7
|
+
require "date"
|
|
6
8
|
require "meta-tags"
|
|
7
9
|
|
|
8
10
|
module SeoGeo
|
|
@@ -21,7 +23,7 @@ module SeoGeo
|
|
|
21
23
|
class Meta
|
|
22
24
|
# Per-request values set by the controller ({SeoGeo::Controller#seo}) or views.
|
|
23
25
|
State = Struct.new(:title, :description, :image, :noindex, :index, :canonical, :page, :type,
|
|
24
|
-
:markdown, :json_ld, keyword_init: true) do
|
|
26
|
+
:markdown, :json_ld, :published_time, :modified_time, keyword_init: true) do
|
|
25
27
|
def initialize(**)
|
|
26
28
|
super
|
|
27
29
|
self.json_ld ||= []
|
|
@@ -168,6 +170,10 @@ module SeoGeo
|
|
|
168
170
|
end
|
|
169
171
|
tags[:og] = open_graph
|
|
170
172
|
tags[:twitter] = twitter
|
|
173
|
+
if state.type.to_s == "article"
|
|
174
|
+
article = { published_time: article_time(state.published_time), modified_time: article_time(state.modified_time) }.compact
|
|
175
|
+
tags[:article] = article unless article.empty?
|
|
176
|
+
end
|
|
171
177
|
tags.merge(config.verification.meta_tags.transform_keys(&:to_sym)).compact
|
|
172
178
|
end
|
|
173
179
|
|
|
@@ -222,6 +228,17 @@ module SeoGeo
|
|
|
222
228
|
links.empty? ? nil : links
|
|
223
229
|
end
|
|
224
230
|
|
|
231
|
+
def article_time(value)
|
|
232
|
+
case value
|
|
233
|
+
when nil then nil
|
|
234
|
+
when Time, Date then value.iso8601
|
|
235
|
+
when String
|
|
236
|
+
value.match?(/\A\d{4}-\d{2}-\d{2}\z/) ? Date.iso8601(value).iso8601 : Time.iso8601(value).iso8601
|
|
237
|
+
else
|
|
238
|
+
raise ArgumentError, "article time must be a Time, Date or ISO 8601 string"
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
|
|
225
242
|
def open_graph
|
|
226
243
|
image = image_url
|
|
227
244
|
{
|
data/lib/seo_geo/version.rb
CHANGED
data/lib/tasks/seo_geo.rake
CHANGED
|
@@ -9,8 +9,19 @@ namespace :seo_geo do
|
|
|
9
9
|
end
|
|
10
10
|
|
|
11
11
|
desc "Audit robots.txt, sitemap.xml, llms.txt and every sitemap page in-process " \
|
|
12
|
-
"(FORMAT=json, HOSTS=a.com,b.com, MAX_PAGES=200); exits 1 when there are errors"
|
|
13
|
-
task audit
|
|
12
|
+
"(URL=https://example.com or URLS=a,b for remote; FORMAT=json, HOSTS=a.com,b.com, MAX_PAGES=200); exits 1 when there are errors"
|
|
13
|
+
task :audit do
|
|
14
|
+
urls = (ENV["URLS"] || ENV["URL"]).to_s.split(",").map(&:strip).reject(&:empty?)
|
|
15
|
+
unless urls.empty?
|
|
16
|
+
require "seo_geo"
|
|
17
|
+
report = SeoGeo::Audit::Remote.run(urls, max_pages: Integer(ENV.fetch("MAX_PAGES", "200"), 10),
|
|
18
|
+
format: ENV.fetch("FORMAT", "text"))
|
|
19
|
+
exit(1) if report.errors?
|
|
20
|
+
next
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
Rake::Task["seo_geo:audit_flag"].invoke
|
|
24
|
+
Rake::Task["environment"].invoke
|
|
14
25
|
report = SeoGeo::Audit::Runner.new(
|
|
15
26
|
app: Rails.application,
|
|
16
27
|
root: Rails.root,
|
data/llms.txt
CHANGED
|
@@ -2,9 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
> Rails engine for SEO and GEO (Generative Engine Optimization: visibility in AI search and assistants), not a geolocation gem. One initializer and one layout line give meta tags with canonical folding, JSON-LD from one page-copy source, crawler fallback bodies for Inertia/SPA pages, purpose-based AI crawler policy (training / search / user), per-host robots.txt, sitemap.xml, llms.txt, Markdown negotiation, IndexNow, OG images and an audit task.
|
|
4
4
|
|
|
5
|
-
- Install: `gem "seo_geo"` in the Gemfile, then `bin/rails g seo_geo:install https://www.example.com`. Version 0.
|
|
5
|
+
- Install: `gem "seo_geo"` in the Gemfile, then `bin/rails g seo_geo:install https://www.example.com`. Version 0.2.0 is on RubyGems.
|
|
6
6
|
- Requirements: Ruby >= 3.2, Rails 7.2 / 8.0 / 8.1 (CI covers every cell). Runtime dependencies: railties, actionpack, meta-tags ~> 2.24, reverse_markdown ~> 3.0. Optional: ferrum (OG cards), ActiveJob (IndexNow job).
|
|
7
|
-
- Public API: `SeoGeo.configure`, `SeoGeo.config`, `SeoGeo.sitemap.draw { add ... }`, `SeoGeo.page(:key)`, `SeoGeo.pages.register`; view helpers `seo_geo_head`, `seo_geo_html_lang`, `seo_geo_fallback_body(:key)`, `seo_geo_faq(:key)`; controller `seo(title:, description:, image:, noindex:, index:, canonical:, type:, json_ld:)`, `seo_geo_page(:key)`; `include SeoGeo::Markdownable` + `seo_geo_markdown only:, page:`; `SeoGeo::JsonLd`; `SeoGeo::IndexNow.submit(urls)`, model macro `seo_geo_indexnow on: :publish, url:`; `rake seo_geo:audit`; `rails g seo_geo:install`; `require "seo_geo/testing"` (Minitest) and `require "seo_geo/rspec"`; `SeoGeo::BotVerifier::Middleware`; JS `window.SeoGeo.mounted()`.
|
|
7
|
+
- Public API: `SeoGeo.configure`, `SeoGeo.config`, `SeoGeo.sitemap.draw { add ... }`, `SeoGeo.page(:key)`, `SeoGeo.pages.register`; view helpers `seo_geo_head`, `seo_geo_html_lang`, `seo_geo_fallback_body(:key)`, `seo_geo_faq(:key)`; controller `seo(title:, description:, image:, noindex:, index:, canonical:, type:, json_ld:, published_time:, modified_time:)`, `seo_geo_page(:key)`; `include SeoGeo::Markdownable` + `seo_geo_markdown only:, page:`; `SeoGeo::JsonLd`; `SeoGeo::IndexNow.submit(urls)`, model macro `seo_geo_indexnow on: :publish, url:`; `rake seo_geo:audit`; `rails g seo_geo:install`; `require "seo_geo/testing"` (Minitest) and `require "seo_geo/rspec"`; `SeoGeo::BotVerifier::Middleware`; JS `window.SeoGeo.mounted()`.
|
|
8
|
+
- Article times: `seo type: "article", published_time:, modified_time:` accepts Time, Date or ISO 8601 strings and emits ISO 8601 `article:published_time` / `article:modified_time`; omitted for other types. Use real stored dates.
|
|
9
|
+
- Remote audit: `bundle exec rake seo_geo:audit URL=https://example.com` or `URLS=a,b` (FORMAT=json supported), no app boot; `SeoGeo::Audit::Remote.run(urls, io:)`. Serial Net::HTTP, 0.5s spacing, 10s timeouts, original redirect responses retained with at most five traced hops. Errors for header noindex and incomplete chains; warnings for >1 redirect hop and robots differences when matching config is available. Both modes check soft-404 and duplicate descriptions.
|
|
8
10
|
- Routes served when mounted at "/": `GET /robots.txt`, `/sitemap.xml`, `/llms.txt`, `/llms-full.txt`, `/<indexnow-key>.txt`, `/og/:key.png`, and `.md` for every Markdownable action.
|
|
9
11
|
- Invariants: undeclared hosts fail closed; one copy source per page; the User-Agent is never used for access decisions; no invented lastmod.
|
|
10
12
|
- License: MIT.
|
metadata
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: seo_geo
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- seunghan Kim
|
|
8
8
|
bindir: bin
|
|
9
9
|
cert_chain: []
|
|
10
|
-
date: 2026-10-
|
|
10
|
+
date: 2026-10-20 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
12
|
- !ruby/object:Gem::Dependency
|
|
13
13
|
name: railties
|
|
@@ -156,6 +156,9 @@ files:
|
|
|
156
156
|
- lib/seo_geo/audit.rb
|
|
157
157
|
- lib/seo_geo/audit/checks.rb
|
|
158
158
|
- lib/seo_geo/audit/html_document.rb
|
|
159
|
+
- lib/seo_geo/audit/http_fetcher.rb
|
|
160
|
+
- lib/seo_geo/audit/rack_fetcher.rb
|
|
161
|
+
- lib/seo_geo/audit/remote.rb
|
|
159
162
|
- lib/seo_geo/audit/report.rb
|
|
160
163
|
- lib/seo_geo/audit/runner.rb
|
|
161
164
|
- lib/seo_geo/audit/static_scan.rb
|