seo_geo 0.1.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 +7 -0
- data/AGENTS.md +244 -0
- data/CHANGELOG.md +40 -0
- data/LICENSE.txt +21 -0
- data/README.md +428 -0
- data/app/controllers/seo_geo/index_now_controller.rb +13 -0
- data/app/controllers/seo_geo/llms_controller.rb +29 -0
- data/app/controllers/seo_geo/og_images_controller.rb +67 -0
- data/app/controllers/seo_geo/robots_controller.rb +14 -0
- data/app/controllers/seo_geo/sitemaps_controller.rb +16 -0
- data/app/jobs/seo_geo/index_now_job.rb +21 -0
- data/app/views/seo_geo/fallback/_body.html.erb +21 -0
- data/app/views/seo_geo/fallback/_faq.html.erb +12 -0
- data/config/routes.rb +17 -0
- data/lib/generators/seo_geo/install/install_generator.rb +138 -0
- data/lib/generators/seo_geo/install/templates/home.yml.tt +14 -0
- data/lib/generators/seo_geo/install/templates/initializer.rb.tt +52 -0
- data/lib/seo_geo/audit/checks.rb +107 -0
- data/lib/seo_geo/audit/html_document.rb +132 -0
- data/lib/seo_geo/audit/report.rb +85 -0
- data/lib/seo_geo/audit/runner.rb +327 -0
- data/lib/seo_geo/audit/static_scan.rb +60 -0
- data/lib/seo_geo/audit.rb +14 -0
- data/lib/seo_geo/bot_verifier.rb +278 -0
- data/lib/seo_geo/configuration.rb +832 -0
- data/lib/seo_geo/controller.rb +72 -0
- data/lib/seo_geo/crawler_fallback.rb +45 -0
- data/lib/seo_geo/crawlers.rb +79 -0
- data/lib/seo_geo/engine.rb +123 -0
- data/lib/seo_geo/index_now.rb +187 -0
- data/lib/seo_geo/json_ld.rb +259 -0
- data/lib/seo_geo/llms_txt.rb +183 -0
- data/lib/seo_geo/markdown/document.rb +163 -0
- data/lib/seo_geo/markdown/negotiation.rb +49 -0
- data/lib/seo_geo/markdown/path_suffix.rb +72 -0
- data/lib/seo_geo/markdownable.rb +141 -0
- data/lib/seo_geo/meta.rb +382 -0
- data/lib/seo_geo/model.rb +95 -0
- data/lib/seo_geo/og_image/card.html.erb +22 -0
- data/lib/seo_geo/og_image/ferrum_backend.rb +72 -0
- data/lib/seo_geo/og_image/renderer.rb +163 -0
- data/lib/seo_geo/page.rb +201 -0
- data/lib/seo_geo/path_match.rb +36 -0
- data/lib/seo_geo/publication.rb +68 -0
- data/lib/seo_geo/robots/evaluator.rb +85 -0
- data/lib/seo_geo/robots.rb +105 -0
- data/lib/seo_geo/robots_directives.rb +170 -0
- data/lib/seo_geo/robots_path.rb +125 -0
- data/lib/seo_geo/rspec.rb +197 -0
- data/lib/seo_geo/safe_regexp.rb +50 -0
- data/lib/seo_geo/sitemap/registry.rb +239 -0
- data/lib/seo_geo/sitemap/xml.rb +138 -0
- data/lib/seo_geo/testing/environment.rb +31 -0
- data/lib/seo_geo/testing.rb +111 -0
- data/lib/seo_geo/undeclared_host.rb +40 -0
- data/lib/seo_geo/version.rb +5 -0
- data/lib/seo_geo.rb +80 -0
- data/lib/tasks/seo_geo.rake +23 -0
- data/llms.txt +27 -0
- metadata +224 -0
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
|
|
5
|
+
module SeoGeo
|
|
6
|
+
module Generators
|
|
7
|
+
# +bin/rails g seo_geo:install [CANONICAL_ORIGIN]+: initializer, sample page copy, engine
|
|
8
|
+
# mount, layout instructions, and a warning for static files that would shadow the engine routes.
|
|
9
|
+
class InstallGenerator < ::Rails::Generators::Base
|
|
10
|
+
source_root File.expand_path("templates", __dir__)
|
|
11
|
+
|
|
12
|
+
SHADOWED_FILES = %w[robots.txt sitemap.xml llms.txt llms-full.txt].freeze
|
|
13
|
+
MOUNT = 'mount SeoGeo::Engine, at: "/"'
|
|
14
|
+
# A catch-all route (+get "*path"+, +match "/*slug"+, ...).
|
|
15
|
+
CATCH_ALL = /\A[ \t]*(?:get|match|any|post)\b[ \t(]*["']\/?\*/
|
|
16
|
+
DRAW = /\.routes\.draw\s+do\b/
|
|
17
|
+
# Lines that open a block closed by +end+: a trailing +do+ (optionally +do |args|+) or a leading keyword.
|
|
18
|
+
OPENS_BLOCK = /(?:\bdo(?:\s*\|[^|]*\|)?\s*(?:#.*)?\z)|\A\s*(?:if|unless|case|begin|while|until|def|class|module)\b/
|
|
19
|
+
CLOSES_BLOCK = /\A\s*end\b/
|
|
20
|
+
|
|
21
|
+
# @param lines [Array<String>] config/routes.rb
|
|
22
|
+
# @return [Integer, nil] line index for the mount: directly inside +routes.draw+ (never inside a scope or
|
|
23
|
+
# namespace), before its first top-level catch-all, else before the +end+ closing the draw block
|
|
24
|
+
def self.mount_index(lines)
|
|
25
|
+
start = lines.index { |line| line.match?(DRAW) }
|
|
26
|
+
return nil if start.nil?
|
|
27
|
+
|
|
28
|
+
depth = 1
|
|
29
|
+
lines.each_with_index.drop(start + 1).each do |line, index|
|
|
30
|
+
return index if depth == 1 && line.match?(CATCH_ALL)
|
|
31
|
+
|
|
32
|
+
if line.match?(CLOSES_BLOCK)
|
|
33
|
+
depth -= 1
|
|
34
|
+
return index if depth.zero?
|
|
35
|
+
elsif line.match?(OPENS_BLOCK)
|
|
36
|
+
depth += 1
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
nil
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
argument :canonical_origin, type: :string, default: "https://www.example.com",
|
|
43
|
+
banner: "https://www.example.com"
|
|
44
|
+
|
|
45
|
+
class_option :site_name, type: :string, default: nil, desc: "site.name (defaults to the application name)"
|
|
46
|
+
|
|
47
|
+
def validate_origin
|
|
48
|
+
return if canonical_origin.match?(%r{\Ahttps?://[^/\s]+/?\z})
|
|
49
|
+
|
|
50
|
+
raise Thor::Error, "CANONICAL_ORIGIN must look like https://www.example.com (got #{canonical_origin.inspect})"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def create_initializer
|
|
54
|
+
template "initializer.rb.tt", "config/initializers/seo_geo.rb"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def create_home_page
|
|
58
|
+
template "home.yml.tt", "config/seo_geo/pages/home.yml"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# The mount goes last (before a top-level catch-all +get "*path"+ if there is one), not at the top where Rails'
|
|
62
|
+
# +route+ puts it: routes match in order, so the app's own routes (e.g. +/og/:username.png+) stay first. One
|
|
63
|
+
# insertion point, directly inside +routes.draw+; an existing mount is left alone.
|
|
64
|
+
def mount_engine
|
|
65
|
+
routes = File.join(destination_root, "config/routes.rb")
|
|
66
|
+
return route(MOUNT) unless File.exist?(routes)
|
|
67
|
+
|
|
68
|
+
content = File.read(routes)
|
|
69
|
+
if content.include?("SeoGeo::Engine")
|
|
70
|
+
say_status :identical, "config/routes.rb (SeoGeo::Engine already mounted)", :blue
|
|
71
|
+
return
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
lines = content.lines
|
|
75
|
+
index = self.class.mount_index(lines.map(&:chomp))
|
|
76
|
+
return route(MOUNT) if index.nil?
|
|
77
|
+
|
|
78
|
+
catch_all = lines[index].match?(CATCH_ALL)
|
|
79
|
+
indent = lines[index][/\A[ \t]*/]
|
|
80
|
+
indent = "#{indent} " unless catch_all
|
|
81
|
+
lines.insert(index, "#{indent}#{MOUNT}\n")
|
|
82
|
+
say_status :insert, "config/routes.rb", :green
|
|
83
|
+
File.write(routes, lines.join) unless options[:pretend]
|
|
84
|
+
say_status :note, "mounted SeoGeo::Engine after your routes#{catch_all ? " (before the catch-all)" : ""}: " \
|
|
85
|
+
"app routes win where paths overlap, and /robots.txt, /sitemap.xml, /llms.txt still reach " \
|
|
86
|
+
"the engine. Keep any catch-all route below the mount.", :blue
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def warn_about_static_files
|
|
90
|
+
SHADOWED_FILES.each do |name|
|
|
91
|
+
next unless File.exist?(File.join(destination_root, "public", name))
|
|
92
|
+
|
|
93
|
+
say_status :warning, "public/#{name} is served before the seo_geo route — delete it " \
|
|
94
|
+
"(rake seo_geo:audit reports it as an error)", :yellow
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def show_layout_instructions
|
|
99
|
+
say <<~TEXT
|
|
100
|
+
|
|
101
|
+
seo_geo is installed. In app/views/layouts/application.html.erb:
|
|
102
|
+
|
|
103
|
+
<html lang="<%= seo_geo_html_lang %>">
|
|
104
|
+
<head>
|
|
105
|
+
<%= seo_geo_head %> <!-- replaces your <title>, meta description, canonical, JSON-LD -->
|
|
106
|
+
</head>
|
|
107
|
+
|
|
108
|
+
Remove any other <title> tag (seo_geo_head emits exactly one).
|
|
109
|
+
|
|
110
|
+
Client-rendered pages (Inertia/React/Vue): render the copy for crawlers that do not run JavaScript
|
|
111
|
+
inside <body>, and call window.SeoGeo.mounted() once the app has mounted:
|
|
112
|
+
|
|
113
|
+
<%= seo_geo_fallback_body(:home) %>
|
|
114
|
+
|
|
115
|
+
In controllers: seo title: "...", description: "..." or seo_geo_page(:home).
|
|
116
|
+
Then run: bin/rails seo_geo:audit
|
|
117
|
+
TEXT
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
private
|
|
121
|
+
|
|
122
|
+
def origin
|
|
123
|
+
canonical_origin.chomp("/")
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def site_name
|
|
127
|
+
options[:site_name] || default_site_name
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def default_site_name
|
|
131
|
+
name = defined?(::Rails.application) && ::Rails.application ? ::Rails.application.class.module_parent_name : nil
|
|
132
|
+
name ? name.underscore.humanize : "My site"
|
|
133
|
+
rescue StandardError
|
|
134
|
+
"My site"
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# Page copy for SeoGeo.page(:home): title/description feed the head, faq feeds both the visible FAQ
|
|
2
|
+
# (seo_geo_faq / seo_geo_fallback_body) and the FAQPage JSON-LD, so they always match.
|
|
3
|
+
# Keyed by locale; a flat file (no locale keys) also works.
|
|
4
|
+
en:
|
|
5
|
+
title: <%= site_name.to_json %>
|
|
6
|
+
description: Replace this with a 70–160 character summary of the home page for search results.
|
|
7
|
+
heading: <%= site_name.to_json %>
|
|
8
|
+
intro: One or two sentences that say what the site does and for whom.
|
|
9
|
+
features:
|
|
10
|
+
- title: First feature
|
|
11
|
+
description: What it does for the reader.
|
|
12
|
+
faq:
|
|
13
|
+
- question: <%= "What is #{site_name}?".to_json %>
|
|
14
|
+
answer: A short, factual answer that is also shown on the page.
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# seo_geo: SEO and GEO (AI search visibility). Every option is documented in docs/DESIGN.md of the gem.
|
|
4
|
+
SeoGeo.configure do |seo|
|
|
5
|
+
seo.site.name = <%= site_name.inspect %>
|
|
6
|
+
seo.site.description = "One sentence that describes the site."
|
|
7
|
+
# seo.site.logo_url = "<%= origin %>/logo.png"
|
|
8
|
+
# seo.site.default_locale = :en # <html lang>, inLanguage and og:locale (:ko => ko_KR) use this
|
|
9
|
+
# seo.site.same_as = ["https://github.com/your-org"]
|
|
10
|
+
|
|
11
|
+
# One canonical origin; alias hosts are folded onto it, undeclared hosts get Disallow: /.
|
|
12
|
+
seo.canonical_origin = <%= origin.inspect %>
|
|
13
|
+
# seo.canonical_aliases = ["example.com"] # aliases inherit the canonical host's disallow/allow
|
|
14
|
+
# seo.host "www.example.com", disallow: ["/admin", "/oauth/"]
|
|
15
|
+
# seo.share_prefixes = ["/s/"] # open for link-preview bots
|
|
16
|
+
# seo.noindex_paths = ["/waitlist", %r{\A/drafts/}] # robots meta, sitemap.xml and llms.txt agree
|
|
17
|
+
|
|
18
|
+
# Crawlers by purpose (:allow or :deny): training, search, user-triggered fetches.
|
|
19
|
+
# seo.crawlers.training = :deny
|
|
20
|
+
# seo.crawlers.override "Bytespider", :deny
|
|
21
|
+
# seo.crawlers.override "Mediapartners-Google", :full_access # Allow: / — ignores host disallows (opt-in)
|
|
22
|
+
|
|
23
|
+
# Search-console verification tokens.
|
|
24
|
+
# seo.verification.google = "..."
|
|
25
|
+
# seo.verification.bing = "..."
|
|
26
|
+
# seo.verification.naver = "..."
|
|
27
|
+
|
|
28
|
+
# seo.locales = %i[en ko] # only then does I18n.locale pick the page language (else default_locale)
|
|
29
|
+
# seo.title_template = "%{title} | %{site}"
|
|
30
|
+
# seo.default_noindex = false
|
|
31
|
+
|
|
32
|
+
# GET /og/:key.png is routed only once a backend (or template) is set, and only for known keys
|
|
33
|
+
# (page keys, og_image.keys, og_image.key_resolver); other /og/... URLs reach your own routes.
|
|
34
|
+
# seo.og_image.backend = ->(key, page, params) { MyOgRenderer.png(key, page) }
|
|
35
|
+
# seo.og_image.fallback_url = "<%= origin %>/og-default.png"
|
|
36
|
+
# seo.og_image.fallback_width = 1200 # og:image:width/height for the fallback only when both are set
|
|
37
|
+
# seo.og_image.fallback_height = 630
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Sitemap sources. Never pass Time.now as lastmod; use real date columns.
|
|
41
|
+
# Collections: pass a lambda so no model is loaded at boot and dev reloads never keep a stale class;
|
|
42
|
+
# it is called per sitemap request. Records are read with find_each (select must include :id, order is
|
|
43
|
+
# ignored); add batch: false to keep the scope's order.
|
|
44
|
+
SeoGeo.sitemap.draw do
|
|
45
|
+
add "/", priority: 1.0
|
|
46
|
+
# add -> { Post.published }, path: ->(post) { "/posts/#{post.slug}" }, lastmod: :updated_at
|
|
47
|
+
# Under default_noindex: add ..., index: true, match: %r{\A/posts/[^/]+\z} # head of each post indexable
|
|
48
|
+
# exclude %r{\A/s/}
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# llms.txt: list the pages agents should read first (page copy comes from config/seo_geo/pages/*.yml).
|
|
52
|
+
SeoGeo.config.llms_txt.page :home, path: "/"
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
require "nokogiri"
|
|
5
|
+
|
|
6
|
+
module SeoGeo
|
|
7
|
+
module Audit
|
|
8
|
+
# Pure checks over response bodies. Shared by {Runner}, {SeoGeo::Testing::Assertions} and the
|
|
9
|
+
# RSpec matchers, so a test and the audit can never disagree. Each returns problem messages
|
|
10
|
+
# (empty when fine).
|
|
11
|
+
module Checks
|
|
12
|
+
TITLE_MAX = 60
|
|
13
|
+
DESCRIPTION_RANGE = (70..160)
|
|
14
|
+
SITEMAP_NS = "http://www.sitemaps.org/schemas/sitemap/0.9"
|
|
15
|
+
|
|
16
|
+
module_function
|
|
17
|
+
|
|
18
|
+
# @param doc [HtmlDocument]
|
|
19
|
+
# @return [Array<String>] problems with +<title>+ count (errors)
|
|
20
|
+
def title_count(doc)
|
|
21
|
+
count = doc.titles.size
|
|
22
|
+
return [] if count == 1
|
|
23
|
+
|
|
24
|
+
[count.zero? ? "no <title>" : "#{count} <title> elements (exactly one expected)"]
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# @param doc [HtmlDocument]
|
|
28
|
+
# @return [Array<String>] FAQPage questions or answers missing from the visible text
|
|
29
|
+
def faq_visible(doc)
|
|
30
|
+
text = doc.visible_text
|
|
31
|
+
doc.faq_items.flat_map do |item|
|
|
32
|
+
problems = []
|
|
33
|
+
problems << "FAQ question not in visible text: #{item[:question].inspect}" unless text.include?(item[:question])
|
|
34
|
+
problems << "FAQ answer not in visible text: #{item[:answer].inspect}" unless text.include?(item[:answer])
|
|
35
|
+
problems
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# @param doc [HtmlDocument]
|
|
40
|
+
# @return [Array<String>] unparsable blocks and nodes missing properties {JsonLd::REQUIRED} lists
|
|
41
|
+
def json_ld(doc)
|
|
42
|
+
nodes, problems = doc.json_ld
|
|
43
|
+
nodes.each do |node|
|
|
44
|
+
doc.types(node).each do |type|
|
|
45
|
+
missing = JsonLd::REQUIRED.fetch(type, []).select { |key| blank?(node[key]) }
|
|
46
|
+
problems << [type, node["@id"], "is missing #{missing.join(", ")}"].compact.join(" ") unless missing.empty?
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
problems
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# @param xml [String] sitemap.xml body
|
|
53
|
+
# @param origin [String, nil] every +<loc>+ must start with it (e.g. "https://www.example.com/")
|
|
54
|
+
# @return [Array(Array<String>, Array<String>, Symbol)] problems, +<loc>+ values, :urlset/:sitemapindex
|
|
55
|
+
def sitemap(xml, origin: nil)
|
|
56
|
+
doc = Nokogiri::XML(xml.to_s) { |options| options.strict.nonet }
|
|
57
|
+
root = doc.root
|
|
58
|
+
kind = root&.name&.to_sym
|
|
59
|
+
unless %i[urlset sitemapindex].include?(kind) && root.namespace&.href == SITEMAP_NS
|
|
60
|
+
return [["root element must be <urlset> or <sitemapindex> in #{SITEMAP_NS}"], [], kind]
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
locs = doc.xpath("//s:loc", "s" => SITEMAP_NS).map { |node| node.text.strip }
|
|
64
|
+
problems = []
|
|
65
|
+
problems << "sitemap lists no URL" if locs.empty?
|
|
66
|
+
problems << "#{locs.size} URLs in one file (limit 50,000)" if locs.size > 50_000
|
|
67
|
+
locs.each do |loc|
|
|
68
|
+
problems << "<loc> #{loc} is not absolute" unless loc.match?(%r{\Ahttps?://}i)
|
|
69
|
+
problems << "<loc> #{loc} is not under #{origin}" if origin && !same_origin?(loc, origin)
|
|
70
|
+
end
|
|
71
|
+
[problems, locs, kind]
|
|
72
|
+
rescue Nokogiri::XML::SyntaxError => e
|
|
73
|
+
[["sitemap is not well-formed XML: #{e.message.strip}"], [], nil]
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# @param canonical [String, nil] canonical href
|
|
77
|
+
# @param expected_origin [String] e.g. "https://www.example.com"
|
|
78
|
+
# @return [Array<String>]
|
|
79
|
+
def canonical_origin(canonical, expected_origin)
|
|
80
|
+
return ["no <link rel=\"canonical\">"] if canonical.nil? || canonical.strip.empty?
|
|
81
|
+
|
|
82
|
+
uri = URI.parse(canonical)
|
|
83
|
+
actual = uri.host && "#{uri.scheme}://#{uri.host}#{":#{uri.port}" unless [80, 443].include?(uri.port)}"
|
|
84
|
+
actual&.casecmp?(expected_origin.chomp("/")) ? [] : ["canonical #{canonical} is not on #{expected_origin}"]
|
|
85
|
+
rescue URI::InvalidURIError
|
|
86
|
+
["canonical #{canonical.inspect} is not a valid URL"]
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# @param url [String]
|
|
90
|
+
# @param origin [String]
|
|
91
|
+
# @return [Boolean] same scheme, host and effective port
|
|
92
|
+
def same_origin?(url, origin)
|
|
93
|
+
a = URI.parse(url)
|
|
94
|
+
b = URI.parse(origin)
|
|
95
|
+
a.host && b.host && a.scheme&.downcase == b.scheme&.downcase && a.host.casecmp?(b.host) && a.port == b.port
|
|
96
|
+
rescue URI::InvalidURIError
|
|
97
|
+
false
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# @param value [Object]
|
|
101
|
+
# @return [Boolean]
|
|
102
|
+
def blank?(value)
|
|
103
|
+
value.nil? || (value.respond_to?(:empty?) && value.empty?) || (value.is_a?(String) && value.strip.empty?)
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "nokogiri"
|
|
5
|
+
|
|
6
|
+
module SeoGeo
|
|
7
|
+
module Audit
|
|
8
|
+
# Read-only view of one HTML response: the values crawlers take from it.
|
|
9
|
+
class HtmlDocument
|
|
10
|
+
NON_VISIBLE = "script, style, template, head"
|
|
11
|
+
|
|
12
|
+
# @param html [String]
|
|
13
|
+
def initialize(html)
|
|
14
|
+
@doc = Nokogiri::HTML(html.to_s)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# @return [Array<String>] text of every +<title>+ in the head (SVG titles ignored)
|
|
18
|
+
def titles
|
|
19
|
+
@doc.xpath("//head/title").map { |node| squish(node.text) }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# @return [String, nil] the first title
|
|
23
|
+
def title
|
|
24
|
+
titles.first
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# @return [String, nil]
|
|
28
|
+
def description
|
|
29
|
+
meta_content("description")
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# @return [String, nil] href of +<link rel="canonical">+
|
|
33
|
+
def canonical
|
|
34
|
+
@doc.css("link[rel~=canonical]").first&.[]("href")
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# @return [String, nil] first robots meta content (lower case); {#noindex?} reads all of them
|
|
38
|
+
def robots
|
|
39
|
+
meta_content("robots")&.downcase
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# @return [RobotsDirectives] every robots and crawler-specific meta ({RobotsDirectives.from_html}, the
|
|
43
|
+
# parser the Markdown response uses)
|
|
44
|
+
def robots_directives
|
|
45
|
+
@robots_directives ||= RobotsDirectives.from_html(@doc)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# @param header [String, Array<String>, nil] the response's +X-Robots-Tag+
|
|
49
|
+
# @return [Boolean] true when any meta or the header tells any crawler noindex (+none+ included)
|
|
50
|
+
def noindex?(header = nil)
|
|
51
|
+
robots_directives.merge(RobotsDirectives.parse_header(header)).noindex_for_any?
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# @return [String, nil] +<html lang>+
|
|
55
|
+
def html_lang
|
|
56
|
+
value = @doc.at_xpath("/html")&.[]("lang")
|
|
57
|
+
value.nil? || value.strip.empty? ? nil : value
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# @return [String, nil] href of +<link rel="alternate" type="text/markdown">+
|
|
61
|
+
def markdown_alternate
|
|
62
|
+
@doc.css("link[rel~=alternate]").find { |node| node["type"].to_s.casecmp?("text/markdown") }&.[]("href")
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# @param name [String] meta name, e.g. "google-site-verification"
|
|
66
|
+
# @return [String, nil]
|
|
67
|
+
def meta_content(name)
|
|
68
|
+
node = @doc.css("meta[name]").find { |item| item["name"].casecmp?(name) }
|
|
69
|
+
node && node["content"]
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# @return [Array<String>] raw bodies of +<script type="application/ld+json">+
|
|
73
|
+
def json_ld_sources
|
|
74
|
+
@doc.css("script").select { |node| node["type"].to_s.strip.casecmp?("application/ld+json") }.map(&:text)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# @return [Array(Array<Hash>, Array<String>)] parsed nodes (@graph flattened) and parse error messages
|
|
78
|
+
def json_ld
|
|
79
|
+
nodes = []
|
|
80
|
+
errors = []
|
|
81
|
+
json_ld_sources.each_with_index do |source, index|
|
|
82
|
+
nodes.concat(flatten(JSON.parse(source)))
|
|
83
|
+
rescue JSON::ParserError => e
|
|
84
|
+
errors << "JSON-LD block #{index + 1} does not parse: #{e.message.lines.first.to_s.strip[0, 120]}"
|
|
85
|
+
end
|
|
86
|
+
[nodes, errors]
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# @return [Array<Hash{Symbol => String}>] +{question:, answer:}+ from every FAQPage node
|
|
90
|
+
def faq_items
|
|
91
|
+
json_ld.first.select { |node| types(node).include?("FAQPage") }.flat_map do |node|
|
|
92
|
+
Array(node["mainEntity"]).filter_map do |question|
|
|
93
|
+
next unless question.is_a?(Hash)
|
|
94
|
+
|
|
95
|
+
answer = question["acceptedAnswer"]
|
|
96
|
+
answer = answer.first if answer.is_a?(Array)
|
|
97
|
+
{ question: squish(question["name"].to_s), answer: squish(answer.is_a?(Hash) ? answer["text"].to_s : "") }
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# @return [String] text a crawler that does not run JavaScript reads (scripts, styles, head removed)
|
|
103
|
+
def visible_text
|
|
104
|
+
@visible_text ||= begin
|
|
105
|
+
copy = @doc.dup
|
|
106
|
+
copy.css(NON_VISIBLE).each(&:remove)
|
|
107
|
+
squish(copy.text)
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# @param node [Hash]
|
|
112
|
+
# @return [Array<String>]
|
|
113
|
+
def types(node)
|
|
114
|
+
Array(node["@type"]).map(&:to_s)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
private
|
|
118
|
+
|
|
119
|
+
def flatten(value)
|
|
120
|
+
case value
|
|
121
|
+
when Array then value.flat_map { |item| flatten(item) }
|
|
122
|
+
when Hash then value.key?("@graph") ? flatten(value["@graph"]) : [value]
|
|
123
|
+
else []
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def squish(text)
|
|
128
|
+
text.to_s.gsub(/[[:space:]]+/, " ").strip
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module SeoGeo
|
|
6
|
+
module Audit
|
|
7
|
+
SEVERITIES = %i[error warning].freeze
|
|
8
|
+
|
|
9
|
+
# One problem.
|
|
10
|
+
#
|
|
11
|
+
# @!attribute severity [Symbol] :error (exit non-zero) or :warning
|
|
12
|
+
# @!attribute check [String] check id, e.g. "title.count"
|
|
13
|
+
# @!attribute host [String, nil]
|
|
14
|
+
# @!attribute url [String, nil] URL or file:line
|
|
15
|
+
# @!attribute message [String]
|
|
16
|
+
Finding = Data.define(:severity, :check, :host, :url, :message) do
|
|
17
|
+
# @return [Hash{String => String}]
|
|
18
|
+
def to_h
|
|
19
|
+
{ "severity" => severity.to_s, "check" => check, "host" => host, "url" => url, "message" => message }
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Collected findings plus counters. +to_s+ for people, +to_json+ for CI.
|
|
24
|
+
class Report
|
|
25
|
+
# @return [Array<Finding>]
|
|
26
|
+
attr_reader :findings
|
|
27
|
+
# @return [Hash{String => Integer}] e.g. "pages" => 12
|
|
28
|
+
attr_reader :stats
|
|
29
|
+
|
|
30
|
+
def initialize
|
|
31
|
+
@findings = []
|
|
32
|
+
@stats = Hash.new(0)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# @param severity [Symbol] :error or :warning
|
|
36
|
+
# @param check [String]
|
|
37
|
+
# @param message [String]
|
|
38
|
+
# @param host [String, nil]
|
|
39
|
+
# @param url [String, nil]
|
|
40
|
+
# @return [Finding]
|
|
41
|
+
def add(severity, check, message, host: nil, url: nil)
|
|
42
|
+
raise ArgumentError, "unknown severity #{severity.inspect}" unless SEVERITIES.include?(severity)
|
|
43
|
+
|
|
44
|
+
Finding.new(severity: severity, check: check, host: host, url: url, message: message).tap { |item| @findings << item }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# @return [Array<Finding>]
|
|
48
|
+
def errors
|
|
49
|
+
findings.select { |item| item.severity == :error }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# @return [Array<Finding>]
|
|
53
|
+
def warnings
|
|
54
|
+
findings.select { |item| item.severity == :warning }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# @return [Boolean]
|
|
58
|
+
def errors?
|
|
59
|
+
!errors.empty?
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# @return [Hash]
|
|
63
|
+
def to_h
|
|
64
|
+
{ "errors" => errors.size, "warnings" => warnings.size, "stats" => stats.dup,
|
|
65
|
+
"findings" => findings.map(&:to_h) }
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# @return [String]
|
|
69
|
+
def to_json(*args)
|
|
70
|
+
JSON.pretty_generate(to_h, *args)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# @return [String] one line per finding, then a summary line
|
|
74
|
+
def to_s
|
|
75
|
+
lines = findings.sort_by { |item| [SEVERITIES.index(item.severity), item.host.to_s, item.check] }.map do |item|
|
|
76
|
+
where = [item.host, item.url].compact.join(" ")
|
|
77
|
+
"#{item.severity.to_s.upcase.ljust(7)} #{item.check.ljust(22)} #{where}#{where.empty? ? "" : " — "}#{item.message}"
|
|
78
|
+
end
|
|
79
|
+
counted = stats.map { |name, count| "#{count} #{name}" }.join(", ")
|
|
80
|
+
lines << "seo_geo audit: #{errors.size} error(s), #{warnings.size} warning(s)#{counted.empty? ? "" : " (#{counted})"}"
|
|
81
|
+
"#{lines.join("\n")}\n"
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|