yuno-proofreader 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/CHANGELOG.md +13 -0
- data/LICENSE.txt +21 -0
- data/README.md +435 -0
- data/exe/yuno-proofreader +16 -0
- data/lib/yuno/proofreader/analysis/adjustments.rb +45 -0
- data/lib/yuno/proofreader/analysis/crawler.rb +95 -0
- data/lib/yuno/proofreader/analysis/entries.rb +73 -0
- data/lib/yuno/proofreader/analysis/extractor.rb +209 -0
- data/lib/yuno/proofreader/analysis/graph.rb +115 -0
- data/lib/yuno/proofreader/analysis/judge.rb +103 -0
- data/lib/yuno/proofreader/analysis/judge.yml +27 -0
- data/lib/yuno/proofreader/analysis/judge_prompt.rb +72 -0
- data/lib/yuno/proofreader/analysis/judgments.rb +70 -0
- data/lib/yuno/proofreader/analysis/links.rb +54 -0
- data/lib/yuno/proofreader/analysis/list_order.rb +33 -0
- data/lib/yuno/proofreader/analysis/reasons.rb +49 -0
- data/lib/yuno/proofreader/analysis/report.rb +110 -0
- data/lib/yuno/proofreader/analysis/runner.rb +171 -0
- data/lib/yuno/proofreader/analysis/scorer.rb +128 -0
- data/lib/yuno/proofreader/analysis/settings.rb +90 -0
- data/lib/yuno/proofreader/analysis/signals.rb +102 -0
- data/lib/yuno/proofreader/analysis/structured_data.rb +57 -0
- data/lib/yuno/proofreader/analysis/url_tree.rb +49 -0
- data/lib/yuno/proofreader/analysis/weights.rb +77 -0
- data/lib/yuno/proofreader/analysis/weights.yml +39 -0
- data/lib/yuno/proofreader/audit/agenda.rb +86 -0
- data/lib/yuno/proofreader/audit/agenda_report.rb +65 -0
- data/lib/yuno/proofreader/audit/answers.rb +74 -0
- data/lib/yuno/proofreader/audit/attempt.rb +69 -0
- data/lib/yuno/proofreader/audit/batch.rb +64 -0
- data/lib/yuno/proofreader/audit/batch_report.rb +87 -0
- data/lib/yuno/proofreader/audit/cadence.rb +67 -0
- data/lib/yuno/proofreader/audit/instructions.rb +30 -0
- data/lib/yuno/proofreader/audit/log_fields.rb +28 -0
- data/lib/yuno/proofreader/audit/prompt.rb +157 -0
- data/lib/yuno/proofreader/audit/prompts/instructions.yml +46 -0
- data/lib/yuno/proofreader/audit/prompts/steps.yml +67 -0
- data/lib/yuno/proofreader/audit/report.rb +153 -0
- data/lib/yuno/proofreader/audit/runner.rb +144 -0
- data/lib/yuno/proofreader/audit/steps.rb +56 -0
- data/lib/yuno/proofreader/cli/analysis_commands.rb +43 -0
- data/lib/yuno/proofreader/cli/audit_commands.rb +92 -0
- data/lib/yuno/proofreader/cli/command_log.rb +42 -0
- data/lib/yuno/proofreader/cli/flags.rb +48 -0
- data/lib/yuno/proofreader/cli/init_command.rb +116 -0
- data/lib/yuno/proofreader/cli/model_commands.rb +34 -0
- data/lib/yuno/proofreader/cli/queue_commands.rb +89 -0
- data/lib/yuno/proofreader/cli/sheet_commands.rb +57 -0
- data/lib/yuno/proofreader/cli.rb +218 -0
- data/lib/yuno/proofreader/config.rb +25 -0
- data/lib/yuno/proofreader/context.rb +55 -0
- data/lib/yuno/proofreader/env_file.rb +76 -0
- data/lib/yuno/proofreader/errors.rb +17 -0
- data/lib/yuno/proofreader/findings/finding.rb +95 -0
- data/lib/yuno/proofreader/findings/matching.rb +34 -0
- data/lib/yuno/proofreader/findings/merger.rb +43 -0
- data/lib/yuno/proofreader/findings/outline.rb +73 -0
- data/lib/yuno/proofreader/findings/plan.rb +24 -0
- data/lib/yuno/proofreader/findings/reconciler.rb +219 -0
- data/lib/yuno/proofreader/findings/source.rb +79 -0
- data/lib/yuno/proofreader/findings/text.rb +37 -0
- data/lib/yuno/proofreader/http/client.rb +131 -0
- data/lib/yuno/proofreader/http/net_http_transport.rb +55 -0
- data/lib/yuno/proofreader/http/robots.rb +72 -0
- data/lib/yuno/proofreader/init/ask.rb +67 -0
- data/lib/yuno/proofreader/init/detector.rb +100 -0
- data/lib/yuno/proofreader/init/interview.rb +210 -0
- data/lib/yuno/proofreader/init/parsing.rb +47 -0
- data/lib/yuno/proofreader/init/web.rb +33 -0
- data/lib/yuno/proofreader/llm/client.rb +140 -0
- data/lib/yuno/proofreader/llm/defaults.rb +22 -0
- data/lib/yuno/proofreader/llm/defaults.yml +6 -0
- data/lib/yuno/proofreader/llm/errors.rb +26 -0
- data/lib/yuno/proofreader/llm/factory.rb +45 -0
- data/lib/yuno/proofreader/llm/http_transport.rb +41 -0
- data/lib/yuno/proofreader/llm/json_text.rb +23 -0
- data/lib/yuno/proofreader/llm/model_commands.rb +34 -0
- data/lib/yuno/proofreader/llm/provider.rb +58 -0
- data/lib/yuno/proofreader/llm/providers/gemini.rb +146 -0
- data/lib/yuno/proofreader/llm/providers.rb +38 -0
- data/lib/yuno/proofreader/llm/types.rb +13 -0
- data/lib/yuno/proofreader/llm/usage.rb +17 -0
- data/lib/yuno/proofreader/log.rb +129 -0
- data/lib/yuno/proofreader/pages/fetcher.rb +75 -0
- data/lib/yuno/proofreader/pages/report.rb +45 -0
- data/lib/yuno/proofreader/pages/snapshot.rb +48 -0
- data/lib/yuno/proofreader/pages/trimmer.rb +36 -0
- data/lib/yuno/proofreader/settings_file.rb +51 -0
- data/lib/yuno/proofreader/site.rb +74 -0
- data/lib/yuno/proofreader/site_reader.rb +131 -0
- data/lib/yuno/proofreader/sitemap/aligner.rb +78 -0
- data/lib/yuno/proofreader/sitemap/importer.rb +31 -0
- data/lib/yuno/proofreader/sitemap/loader.rb +127 -0
- data/lib/yuno/proofreader/sitemap/mismatch.rb +76 -0
- data/lib/yuno/proofreader/sitemap/pairer.rb +223 -0
- data/lib/yuno/proofreader/sitemap/parser.rb +73 -0
- data/lib/yuno/proofreader/sitemap/report.rb +80 -0
- data/lib/yuno/proofreader/store/cells.rb +67 -0
- data/lib/yuno/proofreader/store/google_gateway.rb +198 -0
- data/lib/yuno/proofreader/store/memory.rb +90 -0
- data/lib/yuno/proofreader/store/records.rb +28 -0
- data/lib/yuno/proofreader/store/schema.rb +109 -0
- data/lib/yuno/proofreader/store/sheets.rb +183 -0
- data/lib/yuno/proofreader/store/tab_setup.rb +74 -0
- data/lib/yuno/proofreader/version.rb +7 -0
- data/lib/yuno/proofreader.rb +101 -0
- data/proofreader.example.yml +134 -0
- metadata +194 -0
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Yuno
|
|
4
|
+
module Proofreader
|
|
5
|
+
module Sitemap
|
|
6
|
+
# Adds the pages of a sitemap pairing to the page queue. Pages that are already there are left alone,
|
|
7
|
+
# so it is safe to run again. Pages that left the sitemap are counted but never removed.
|
|
8
|
+
class Importer
|
|
9
|
+
Result = Data.define(:added, :existing, :gone)
|
|
10
|
+
|
|
11
|
+
def self.call(pairing, store, site) = new(pairing, store, site).call
|
|
12
|
+
|
|
13
|
+
def initialize(pairing, store, site)
|
|
14
|
+
@pairing = pairing
|
|
15
|
+
@store = store
|
|
16
|
+
@language = site.default_language
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def call
|
|
20
|
+
raise SitemapError, "The pages could not be paired, so nothing was imported." unless @pairing.resolved?
|
|
21
|
+
|
|
22
|
+
known = @store.pages.map { |record| record.key(@language) }
|
|
23
|
+
fresh = @pairing.pages.reject { |page| known.include?(page.key(@language)) }
|
|
24
|
+
@store.add_pages(fresh)
|
|
25
|
+
listed = @pairing.pages.map { |page| page.key(@language) }
|
|
26
|
+
Result.new(added: fresh.size, existing: @pairing.pages.size - fresh.size, gone: (known - listed).size)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
module Yuno
|
|
6
|
+
module Proofreader
|
|
7
|
+
module Sitemap
|
|
8
|
+
# Finds a site's sitemap, reads it (following a sitemap index) and pairs its pages by language.
|
|
9
|
+
# Discovery order: sitemap_url from the settings, then the Sitemap lines of robots.txt, then /sitemap.xml.
|
|
10
|
+
class Loader
|
|
11
|
+
MAX_DEPTH = 2
|
|
12
|
+
MAX_SITEMAPS = 25
|
|
13
|
+
MAX_ENTRIES = 50_000
|
|
14
|
+
|
|
15
|
+
Result = Data.define(:pairing, :entries, :sources, :warnings)
|
|
16
|
+
|
|
17
|
+
def self.call(site, client) = new(site, client).call
|
|
18
|
+
|
|
19
|
+
def initialize(site, client)
|
|
20
|
+
@site = site
|
|
21
|
+
@client = client
|
|
22
|
+
@entries = []
|
|
23
|
+
@sources = []
|
|
24
|
+
@warnings = []
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def call
|
|
28
|
+
discover.each { |url| read(url, 0) }
|
|
29
|
+
raise SitemapError, unreadable_message if @sources.empty?
|
|
30
|
+
|
|
31
|
+
entries = unique(@entries)
|
|
32
|
+
pairing = Pairer.call(entries, @site)
|
|
33
|
+
Result.new(pairing:, entries:, sources: @sources, warnings: @warnings + pairing.warnings)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def unreadable_message
|
|
39
|
+
reasons = @warnings.select { |warning| warning.start_with?("Could not read the sitemap") }
|
|
40
|
+
"#{reasons.join('. ')}. If it lives somewhere else, set sitemap_url in your site settings."
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def discover
|
|
44
|
+
return [@site.sitemap_url] if @site.sitemap_url
|
|
45
|
+
|
|
46
|
+
declared = robots_sitemaps
|
|
47
|
+
declared.empty? ? ["#{@site.website}/sitemap.xml"] : declared
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def robots_sitemaps
|
|
51
|
+
response = @client.get("#{@site.website}/robots.txt")
|
|
52
|
+
response.success? ? declared_in(response.body) : []
|
|
53
|
+
rescue FetchError => e
|
|
54
|
+
@warnings << "Could not read robots.txt (#{e.message}). Using the default sitemap address."
|
|
55
|
+
[]
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def declared_in(text)
|
|
59
|
+
on_site_only(text.lines.filter_map { |line| line.sub(/#.*/, "")[/\A\s*sitemap:\s*(\S+)/i, 1] })
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def read(url, depth)
|
|
63
|
+
return if @sources.include?(url)
|
|
64
|
+
|
|
65
|
+
if @sources.size >= MAX_SITEMAPS
|
|
66
|
+
@warnings << "More than #{MAX_SITEMAPS} sitemaps. Skipped #{url}"
|
|
67
|
+
return
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
result, error = load_one(url)
|
|
71
|
+
return @warnings << "Could not read the sitemap #{url}: #{error}" if error
|
|
72
|
+
|
|
73
|
+
@sources << url
|
|
74
|
+
@entries.concat(result.entries)
|
|
75
|
+
@warnings.concat(result.warnings)
|
|
76
|
+
raise SitemapError, "The sitemaps list more than #{MAX_ENTRIES} pages" if @entries.size > MAX_ENTRIES
|
|
77
|
+
|
|
78
|
+
follow(result.children, depth)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Returns [parsed sitemap, nil], or [nil, reason] when it could not be read.
|
|
82
|
+
def load_one(url)
|
|
83
|
+
response = @client.get(url)
|
|
84
|
+
return [nil, "HTTP #{response.status}"] unless response.success?
|
|
85
|
+
|
|
86
|
+
[Parser.parse(response.body), nil]
|
|
87
|
+
rescue FetchError, SitemapError => e
|
|
88
|
+
[nil, e.message]
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def follow(children, depth)
|
|
92
|
+
return if children.empty?
|
|
93
|
+
|
|
94
|
+
if depth >= MAX_DEPTH
|
|
95
|
+
@warnings << "Sitemap indexes are nested more than #{MAX_DEPTH} levels deep. " \
|
|
96
|
+
"Skipped #{children.size} sitemap(s)"
|
|
97
|
+
return
|
|
98
|
+
end
|
|
99
|
+
on_site_only(children).each { |child| read(child, depth + 1) }
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def on_site_only(urls)
|
|
103
|
+
kept, skipped = urls.uniq.partition { |url| on_site?(url) }
|
|
104
|
+
@warnings << "Ignored sitemap(s) on another host: #{skipped.join(', ')}" unless skipped.empty?
|
|
105
|
+
kept
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def on_site?(url)
|
|
109
|
+
host_of(url) == host_of(@site.website)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def host_of(url)
|
|
113
|
+
URI.parse(url).host.to_s.downcase.delete_prefix("www.")
|
|
114
|
+
rescue URI::InvalidURIError
|
|
115
|
+
""
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def unique(entries)
|
|
119
|
+
kept = entries.uniq(&:url)
|
|
120
|
+
dropped = entries.size - kept.size
|
|
121
|
+
@warnings << "#{dropped} repeated sitemap entries were ignored" if dropped.positive?
|
|
122
|
+
kept
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Yuno
|
|
4
|
+
module Proofreader
|
|
5
|
+
module Sitemap
|
|
6
|
+
# When the language lists differ in length, names the pages that most likely have no counterpart, so the
|
|
7
|
+
# person knows what to put under default_language_only_paths or skip_paths.
|
|
8
|
+
class Mismatch
|
|
9
|
+
SHOWN = 10
|
|
10
|
+
MIN_WORD = 3
|
|
11
|
+
|
|
12
|
+
# groups: the entries of each language, in sitemap order. bare: turns a path into the path without its
|
|
13
|
+
# language prefix.
|
|
14
|
+
def self.lines(groups, default, bare) = new(groups, default, bare).lines
|
|
15
|
+
|
|
16
|
+
def initialize(groups, default, bare)
|
|
17
|
+
@groups = groups
|
|
18
|
+
@default = default
|
|
19
|
+
@bare = bare
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def lines
|
|
23
|
+
base = @groups.fetch(@default)
|
|
24
|
+
others = @groups.reject { |language, _list| language == @default }
|
|
25
|
+
gaps = others.to_h { |language, list| [language, Aligner.call(shape(base), shape(list))] }
|
|
26
|
+
lonely = gaps.values.map(&:first).reduce { |common, found| common & found } || []
|
|
27
|
+
[*only_default(base, lonely), *missing(base, gaps, lonely), *only_other(others, gaps)]
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def only_default(base, lonely)
|
|
33
|
+
return [] if lonely.empty?
|
|
34
|
+
|
|
35
|
+
["Probably only in #{@default}, with no counterpart in any other language: #{listed(base, lonely)} " \
|
|
36
|
+
"(add them to default_language_only_paths)"]
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def missing(base, gaps, lonely)
|
|
40
|
+
gaps.filter_map do |language, (left, _right)|
|
|
41
|
+
rest = left - lonely
|
|
42
|
+
unless rest.empty?
|
|
43
|
+
"Probably missing in #{language}, so these #{@default} pages have no partner there: " \
|
|
44
|
+
"#{listed(base, rest)}"
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def only_other(others, gaps)
|
|
50
|
+
others.filter_map do |language, list|
|
|
51
|
+
right = gaps.fetch(language).last
|
|
52
|
+
"Probably only in #{language}: #{listed(list, right)} (add them to skip_paths)" unless right.empty?
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def listed(entries, indices)
|
|
57
|
+
paths = indices.map { |index| entries[index].path }
|
|
58
|
+
shown = paths.first(SHOWN).join(", ")
|
|
59
|
+
paths.size > SHOWN ? "#{shown} and #{paths.size - SHOWN} more" : shown
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def shape(entries)
|
|
63
|
+
entries.map do |entry|
|
|
64
|
+
segments = @bare.call(entry.path).split("/").reject(&:empty?)
|
|
65
|
+
[segments.size, words(segments.last.to_s)]
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def words(segment)
|
|
70
|
+
plain = segment.unicode_normalize(:nfd).gsub(/\p{Mn}/, "").downcase
|
|
71
|
+
plain.split(/[^a-z0-9]+/).select { |word| word.length >= MIN_WORD }
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
module Yuno
|
|
6
|
+
module Proofreader
|
|
7
|
+
module Sitemap
|
|
8
|
+
Page = Data.define(:name, :urls, :lastmod, :notes) do
|
|
9
|
+
# The address that identifies the page: its default-language address, or the first one it has.
|
|
10
|
+
def key(default_language) = urls[default_language] || urls.values.first
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# The outcome of pairing. resolved is false when the person has to fix the settings first.
|
|
14
|
+
Pairing = Data.define(:strategy, :pages, :warnings, :resolved) do
|
|
15
|
+
def resolved? = resolved
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Works out which sitemap addresses are language versions of the same page.
|
|
19
|
+
# Strategies: hreflang alternates inside the sitemap, otherwise language prefixes plus position
|
|
20
|
+
# (all pages of one language, then the next, in the same order).
|
|
21
|
+
class Pairer
|
|
22
|
+
HREFLANG_SHARE = 0.5
|
|
23
|
+
CHECK = "CHECK pairing"
|
|
24
|
+
|
|
25
|
+
def self.call(entries, site) = new(entries, site).call
|
|
26
|
+
|
|
27
|
+
def initialize(entries, site)
|
|
28
|
+
@site = site
|
|
29
|
+
@languages = site.languages
|
|
30
|
+
# Longest prefix first, so the empty prefix (a language without one) only matches as a fallback.
|
|
31
|
+
@by_prefix = site.prefixes.sort_by { |_language, prefix| -prefix.length }
|
|
32
|
+
wanted = entries.reject { |entry| listed?(entry.path, site.skip_paths) }
|
|
33
|
+
@unassigned, @entries = wanted.partition { |entry| language_of(entry.path).nil? }
|
|
34
|
+
@lastmods = @entries.to_h { |entry| [entry.url, entry.lastmod] }
|
|
35
|
+
@order = @entries.each_with_index.to_h { |entry, index| [entry.url, index] }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def call
|
|
39
|
+
if @entries.empty?
|
|
40
|
+
return pairing(:none, [], ["The sitemap lists no pages that belong to a configured language."],
|
|
41
|
+
resolved: false)
|
|
42
|
+
end
|
|
43
|
+
return by_single_language if @languages.one?
|
|
44
|
+
|
|
45
|
+
hreflang_share >= HREFLANG_SHARE ? by_hreflang : by_position
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def pairing(strategy, pages, warnings, resolved: true)
|
|
51
|
+
Pairing.new(strategy:, pages: sort(pages), warnings: unassigned_warning + warnings, resolved:)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def unassigned_warning
|
|
55
|
+
return [] if @unassigned.empty?
|
|
56
|
+
|
|
57
|
+
count = @unassigned.size
|
|
58
|
+
sample = @unassigned.first(3).map(&:path).join(", ")
|
|
59
|
+
["Ignored #{count} sitemap address#{'es' unless count == 1} that match no language prefix " \
|
|
60
|
+
"(e.g. #{sample}). Add them to skip_paths to silence this."]
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def by_single_language
|
|
64
|
+
pages = @entries.map { |entry| page_from({ language_of(entry.path) => entry.url }) }
|
|
65
|
+
pairing(:single, pages, [])
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# ---- strategy: hreflang alternates in the sitemap ----
|
|
69
|
+
|
|
70
|
+
def hreflang_share
|
|
71
|
+
@entries.count { |entry| configured_alternates(entry).size >= 2 }.fdiv(@entries.size)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def by_hreflang
|
|
75
|
+
warnings = []
|
|
76
|
+
owner = {}
|
|
77
|
+
pages = hreflang_groups(owner, warnings) + hreflang_leftovers(owner)
|
|
78
|
+
pairing(:hreflang, pages, warnings.uniq)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def hreflang_groups(owner, warnings)
|
|
82
|
+
@entries.filter_map { |entry| group_for(entry, owner, warnings) }
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def group_for(entry, owner, warnings)
|
|
86
|
+
urls = configured_alternates(entry)
|
|
87
|
+
return if urls.size < 2
|
|
88
|
+
|
|
89
|
+
warnings << "#{entry.url} is not part of its own hreflang set" unless urls.value?(entry.url)
|
|
90
|
+
page_from(urls) if claim(urls, owner, warnings)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Records which set owns each address. Returns nil when another set already owns one of them.
|
|
94
|
+
def claim(urls, owner, warnings)
|
|
95
|
+
clash = urls.values.find { |url| owner[url] && owner[url] != urls }
|
|
96
|
+
if clash
|
|
97
|
+
warnings << "Conflicting hreflang sets around #{clash}"
|
|
98
|
+
return
|
|
99
|
+
end
|
|
100
|
+
return if urls.values.all? { |url| owner[url] }
|
|
101
|
+
|
|
102
|
+
urls.each_value { |url| owner[url] = urls }
|
|
103
|
+
urls
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def hreflang_leftovers(owner)
|
|
107
|
+
@entries.reject { |entry| owner[entry.url] }.map do |entry|
|
|
108
|
+
page_from({ language_of(entry.path) => entry.url }, notes: ["no hreflang alternates"])
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def configured_alternates(entry)
|
|
113
|
+
entry.alternates.each_with_object({}) do |(code, href), found|
|
|
114
|
+
lang = language_for(code)
|
|
115
|
+
found[lang] ||= href if lang
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def language_for(code)
|
|
120
|
+
@languages.find { |lang| code == lang || code.start_with?("#{lang}-") }
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# ---- strategy: language prefix plus position ----
|
|
124
|
+
|
|
125
|
+
def by_position
|
|
126
|
+
groups = @languages.to_h { |lang| [lang, []] }
|
|
127
|
+
default_only = []
|
|
128
|
+
@entries.each do |entry|
|
|
129
|
+
lang = language_of(entry.path)
|
|
130
|
+
if lang == @site.default_language && listed?(entry.path, @site.default_language_only_paths)
|
|
131
|
+
default_only << entry
|
|
132
|
+
else
|
|
133
|
+
groups[lang] << entry
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
paired(groups, default_only)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def paired(groups, default_only)
|
|
140
|
+
sizes = groups.transform_values(&:size)
|
|
141
|
+
return length_mismatch(groups) unless sizes.values.uniq.one?
|
|
142
|
+
|
|
143
|
+
pages = groups[@site.default_language].each_index.map { |index| page_at(groups, index) }
|
|
144
|
+
pages += default_only.map do |entry|
|
|
145
|
+
page_from({ @site.default_language => entry.url }, notes: ["default language only"])
|
|
146
|
+
end
|
|
147
|
+
pairing(:positional, pages, depth_warnings(pages))
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def depth_warnings(pages)
|
|
151
|
+
flagged = pages.count { |page| page.notes.include?(CHECK) }
|
|
152
|
+
return [] if flagged.zero?
|
|
153
|
+
|
|
154
|
+
["#{flagged} pages have a different depth across languages. Check the pairing."]
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def page_at(groups, index)
|
|
158
|
+
members = groups.transform_values { |list| list[index] }
|
|
159
|
+
notes = members.values.map { |entry| depth(entry.path) }.uniq.one? ? [] : [CHECK]
|
|
160
|
+
page_from(members.transform_values(&:url), notes:)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def length_mismatch(groups)
|
|
164
|
+
counts = groups.map { |lang, list| "#{lang}=#{list.size}" }.join(", ")
|
|
165
|
+
warning = "Language lists differ in length (#{counts}). Put pages that exist in only one language " \
|
|
166
|
+
"under default_language_only_paths or skip_paths."
|
|
167
|
+
guesses = Mismatch.lines(groups, @site.default_language, method(:bare_path))
|
|
168
|
+
note = "These are guesses from the order and the addresses of the pages. Check them before you change " \
|
|
169
|
+
"the settings."
|
|
170
|
+
pairing(:positional, [], [warning, *guesses, *(guesses.empty? ? [] : [note])], resolved: false)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# ---- shared helpers ----
|
|
174
|
+
|
|
175
|
+
def listed?(path, list)
|
|
176
|
+
list.any? { |item| path == item || path.start_with?("#{item}/") }
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# The language whose URL prefix the path starts with, or nil when none matches.
|
|
180
|
+
def language_of(path)
|
|
181
|
+
lowered = path.downcase
|
|
182
|
+
@by_prefix.each do |lang, prefix|
|
|
183
|
+
prefix = prefix.downcase
|
|
184
|
+
return lang if prefix.empty? || lowered == prefix || lowered.start_with?("#{prefix}/")
|
|
185
|
+
end
|
|
186
|
+
nil
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def bare_path(path)
|
|
190
|
+
lang = language_of(path)
|
|
191
|
+
prefix = lang ? @site.prefixes[lang] : ""
|
|
192
|
+
"/#{path[prefix.length..].to_s.split('/').reject(&:empty?).join('/')}"
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def depth(path)
|
|
196
|
+
bare_path(path).split("/").reject(&:empty?).size
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def page_from(urls, notes: [])
|
|
200
|
+
ordered = @languages.filter_map { |lang| [lang, urls[lang]] if urls[lang] }.to_h
|
|
201
|
+
lastmod = ordered.values.filter_map { |url| @lastmods[url] }.reject(&:empty?).max.to_s
|
|
202
|
+
Page.new(name: name_for(ordered), urls: ordered, lastmod:, notes:)
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def name_for(urls)
|
|
206
|
+
url = urls[@site.default_language] || urls.values.first
|
|
207
|
+
bare = bare_path(URI.parse(url).path.to_s)
|
|
208
|
+
bare == "/" ? "home" : bare.delete_prefix("/")
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def sort(pages)
|
|
212
|
+
pages.each_with_index
|
|
213
|
+
.sort_by do |page, index|
|
|
214
|
+
[page.urls.values.filter_map do |url|
|
|
215
|
+
@order[url]
|
|
216
|
+
end.min || @entries.size, index]
|
|
217
|
+
end
|
|
218
|
+
.map(&:first)
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "nokogiri"
|
|
4
|
+
require "uri"
|
|
5
|
+
|
|
6
|
+
module Yuno
|
|
7
|
+
module Proofreader
|
|
8
|
+
module Sitemap
|
|
9
|
+
# One <url> of a sitemap. alternates maps an hreflang code (lowercase) to its address.
|
|
10
|
+
Entry = Data.define(:url, :path, :lastmod, :alternates)
|
|
11
|
+
|
|
12
|
+
# Reads sitemap XML. It only parses text and never fetches anything.
|
|
13
|
+
class Parser
|
|
14
|
+
# children: addresses of child sitemaps, when the document is a sitemap index.
|
|
15
|
+
Result = Data.define(:entries, :children, :warnings)
|
|
16
|
+
|
|
17
|
+
def self.parse(xml) = new.parse(xml)
|
|
18
|
+
|
|
19
|
+
def parse(xml)
|
|
20
|
+
doc = read(xml)
|
|
21
|
+
warnings = []
|
|
22
|
+
children = doc.css("sitemapindex > sitemap > loc").map { |node| node.text.strip }.reject(&:empty?)
|
|
23
|
+
entries = doc.css("urlset > url").filter_map { |node| entry_from(node, warnings) }
|
|
24
|
+
Result.new(entries:, children:, warnings:)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def read(xml)
|
|
30
|
+
doc = Nokogiri::XML(xml.to_s) { |config| config.strict.nonet }
|
|
31
|
+
doc.remove_namespaces!
|
|
32
|
+
name = doc.root&.name
|
|
33
|
+
unless %w[urlset sitemapindex].include?(name)
|
|
34
|
+
raise SitemapError, "This is not a sitemap (root element: #{name.inspect})"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
doc
|
|
38
|
+
rescue Nokogiri::XML::SyntaxError => e
|
|
39
|
+
raise SitemapError, "The sitemap is not valid XML: #{e.message}"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def entry_from(node, warnings)
|
|
43
|
+
url = node.at_xpath("loc")&.text.to_s.strip
|
|
44
|
+
uri = web_uri(url)
|
|
45
|
+
unless uri
|
|
46
|
+
warnings << "Skipped an entry with a missing or invalid address: #{url.inspect}"
|
|
47
|
+
return nil
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
Entry.new(
|
|
51
|
+
url:,
|
|
52
|
+
path: uri.path.to_s.empty? ? "/" : uri.path,
|
|
53
|
+
lastmod: node.at_xpath("lastmod")&.text.to_s.strip,
|
|
54
|
+
alternates: alternates_of(node)
|
|
55
|
+
)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def web_uri(url)
|
|
59
|
+
uri = URI.parse(url)
|
|
60
|
+
%w[http https].include?(uri.scheme) && !uri.host.to_s.empty? ? uri : nil
|
|
61
|
+
rescue URI::InvalidURIError
|
|
62
|
+
nil
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def alternates_of(node)
|
|
66
|
+
node.xpath("link[@rel='alternate'][@hreflang][@href]").to_h do |link|
|
|
67
|
+
[link["hreflang"].strip.downcase, link["href"].strip]
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Yuno
|
|
4
|
+
module Proofreader
|
|
5
|
+
module Sitemap
|
|
6
|
+
# Turns a loaded sitemap into the lines the sitemap command prints.
|
|
7
|
+
class Report
|
|
8
|
+
PREVIEW = 3
|
|
9
|
+
NOTED_LIMIT = 10
|
|
10
|
+
|
|
11
|
+
def initialize(site, result)
|
|
12
|
+
@site = site
|
|
13
|
+
@result = result
|
|
14
|
+
@pairing = result.pairing
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def lines(all: false)
|
|
18
|
+
[*summary, *warning_lines, *page_lines(all), *verdict]
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def pages
|
|
24
|
+
@pairing.pages
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def noted
|
|
28
|
+
@noted ||= pages.reject { |page| page.notes.empty? }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def summary
|
|
32
|
+
complete = pages.count { |page| page.urls.size == @site.languages.size }
|
|
33
|
+
[
|
|
34
|
+
"Website: #{@site.website}",
|
|
35
|
+
"Sitemaps read: #{@result.sources.join(', ')}",
|
|
36
|
+
"Entries in the sitemaps: #{@result.entries.size}",
|
|
37
|
+
"Strategy: #{@pairing.strategy}, resolved: #{@pairing.resolved? ? 'yes' : 'no'}",
|
|
38
|
+
"Pages: #{pages.size} (#{complete} in all #{@site.languages.size} languages, #{noted.size} with notes)"
|
|
39
|
+
]
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def warning_lines
|
|
43
|
+
return [] if @result.warnings.empty?
|
|
44
|
+
|
|
45
|
+
["", "Warnings:", *@result.warnings.map { |warning| " - #{warning}" }]
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def page_lines(all)
|
|
49
|
+
return [] if pages.empty?
|
|
50
|
+
return listing("Pages:", pages) if all
|
|
51
|
+
|
|
52
|
+
[*listing("First pages:", pages.first(PREVIEW)), *noted_lines]
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def noted_lines
|
|
56
|
+
return [] if noted.empty?
|
|
57
|
+
|
|
58
|
+
shown = noted.first(NOTED_LIMIT)
|
|
59
|
+
more = noted.size - shown.size
|
|
60
|
+
[*listing("Pages with notes:", shown), *(more.positive? ? [" ... and #{more} more (use --all)"] : [])]
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def listing(title, list)
|
|
64
|
+
["", title, *list.flat_map { |page| page_block(page) }]
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def page_block(page)
|
|
68
|
+
note = page.notes.empty? ? "" : " (#{page.notes.join(', ')})"
|
|
69
|
+
[" #{page.name}#{note}", *page.urls.map { |lang, url| " #{lang}: #{url}" }]
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def verdict
|
|
73
|
+
return [] if @pairing.resolved?
|
|
74
|
+
|
|
75
|
+
["", "The pages could not be paired. Fix the settings or the sitemap as described above."]
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Yuno
|
|
4
|
+
module Proofreader
|
|
5
|
+
module Store
|
|
6
|
+
# Converts values to and from what is stored in a sheet cell.
|
|
7
|
+
module Cells
|
|
8
|
+
EPOCH = Time.utc(1899, 12, 30)
|
|
9
|
+
DATETIME = /\A(\d{4})-(\d{2})-(\d{2})(?:[ T](\d{2}):(\d{2})(?::(\d{2}))?)?\z/
|
|
10
|
+
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
def to_cell(value, type)
|
|
14
|
+
case type
|
|
15
|
+
when :datetime then value.nil? ? "" : serial(value)
|
|
16
|
+
when :integer then value.nil? ? "" : Integer(value)
|
|
17
|
+
when :number then value.nil? ? "" : Float(value).round(4)
|
|
18
|
+
else value.to_s
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def from_cell(text, type)
|
|
23
|
+
case type
|
|
24
|
+
when :datetime then parse_time(text)
|
|
25
|
+
when :integer then integer(text)
|
|
26
|
+
when :number then number(text)
|
|
27
|
+
else text.to_s
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# A time as a sheet date: days since 1899-12-30, counted in UTC.
|
|
32
|
+
def serial(time) = (time.getutc - EPOCH) / 86_400.0
|
|
33
|
+
|
|
34
|
+
# Reads "2026-10-24 14:30" (seconds optional, date alone allowed) as UTC. Anything else gives nil.
|
|
35
|
+
def parse_time(text)
|
|
36
|
+
match = DATETIME.match(text.to_s.strip)
|
|
37
|
+
return unless match
|
|
38
|
+
|
|
39
|
+
year, month, day, hour, minute, second = match.captures.map(&:to_i)
|
|
40
|
+
Time.utc(year, month, day, hour, minute, second)
|
|
41
|
+
rescue ArgumentError
|
|
42
|
+
nil
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def integer(text)
|
|
46
|
+
Integer(text.to_s.strip, 10)
|
|
47
|
+
rescue ArgumentError
|
|
48
|
+
nil
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# A decimal number. A decimal comma, as a sheet in a German locale shows it, is read too. Anything else
|
|
52
|
+
# gives nil.
|
|
53
|
+
def number(text) = Float(text.to_s.strip.tr(",", "."), exception: false)
|
|
54
|
+
|
|
55
|
+
# The A1 name of a cell: column 1 is A, 27 is AA. Row and column start at 1.
|
|
56
|
+
def a1(row, column)
|
|
57
|
+
letters = +""
|
|
58
|
+
while column.positive?
|
|
59
|
+
column, remainder = (column - 1).divmod(26)
|
|
60
|
+
letters.prepend((65 + remainder).chr)
|
|
61
|
+
end
|
|
62
|
+
"#{letters}#{row}"
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|