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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ec8b0f1b5e2f745e00c47a2d2f9a15e2eda6ca9d0357948feca1a22548c6ec25
4
- data.tar.gz: 8eb32a70fd40779db958d83a6927229c7b09ba156e630472cb4325759cdbec91
3
+ metadata.gz: bcf366096f21a5775c68799a9d2daf6a221d228ce0537c032050993e6703ea52
4
+ data.tar.gz: 9917d75bca5dae4ddf96f9d99567361d5fb7c66a0ec6048faabdf5ae1e24964f
5
5
  SHA512:
6
- metadata.gz: 01636e3e7a699db78db0765973b16652cd6130c2ed1aea1c463632c3b8b55cd1576bdbedfcbf20c0f0f95139ee64c9579a735d3129bc7af1815960a94881389d
7
- data.tar.gz: 33f4593adfa66ddebdf586bdc21473749eb60df802d242da5c2b8be2fc983a48af5338da4ab55f65b46272768070e02cafeb870cf3c896888fd30120d4ac7c63
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
- ## Unreleased
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.1.0, **not yet released** to RubyGems. Until then, use
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
@@ -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
- def initialize(app:, config: SeoGeo.config, sitemap: SeoGeo.sitemap, root: nil, hosts: nil, max_pages: 200)
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 = "#{@config.origin_for(host)}/"
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 = "#{@config.origin_for(host)}/"
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 = [@config.origin_for(host), @config.canonical_origin].uniq
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
- origin = URI.parse(@config.origin_for(host))
236
- scheme = origin.scheme || "https"
237
- # Rails HostAuthorization (config.hosts) reads the Host header, not SERVER_NAME: send both, with the port
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"
@@ -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
  {
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SeoGeo
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.0"
5
5
  end
@@ -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: %i[audit_flag environment] do
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.1.0 is not yet on RubyGems; until it is, use `gem "seo_geo", github: "seunghan91/seo_geo"`.
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.1.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-14 00:00:00.000000000 Z
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