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.
Files changed (109) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +13 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +435 -0
  5. data/exe/yuno-proofreader +16 -0
  6. data/lib/yuno/proofreader/analysis/adjustments.rb +45 -0
  7. data/lib/yuno/proofreader/analysis/crawler.rb +95 -0
  8. data/lib/yuno/proofreader/analysis/entries.rb +73 -0
  9. data/lib/yuno/proofreader/analysis/extractor.rb +209 -0
  10. data/lib/yuno/proofreader/analysis/graph.rb +115 -0
  11. data/lib/yuno/proofreader/analysis/judge.rb +103 -0
  12. data/lib/yuno/proofreader/analysis/judge.yml +27 -0
  13. data/lib/yuno/proofreader/analysis/judge_prompt.rb +72 -0
  14. data/lib/yuno/proofreader/analysis/judgments.rb +70 -0
  15. data/lib/yuno/proofreader/analysis/links.rb +54 -0
  16. data/lib/yuno/proofreader/analysis/list_order.rb +33 -0
  17. data/lib/yuno/proofreader/analysis/reasons.rb +49 -0
  18. data/lib/yuno/proofreader/analysis/report.rb +110 -0
  19. data/lib/yuno/proofreader/analysis/runner.rb +171 -0
  20. data/lib/yuno/proofreader/analysis/scorer.rb +128 -0
  21. data/lib/yuno/proofreader/analysis/settings.rb +90 -0
  22. data/lib/yuno/proofreader/analysis/signals.rb +102 -0
  23. data/lib/yuno/proofreader/analysis/structured_data.rb +57 -0
  24. data/lib/yuno/proofreader/analysis/url_tree.rb +49 -0
  25. data/lib/yuno/proofreader/analysis/weights.rb +77 -0
  26. data/lib/yuno/proofreader/analysis/weights.yml +39 -0
  27. data/lib/yuno/proofreader/audit/agenda.rb +86 -0
  28. data/lib/yuno/proofreader/audit/agenda_report.rb +65 -0
  29. data/lib/yuno/proofreader/audit/answers.rb +74 -0
  30. data/lib/yuno/proofreader/audit/attempt.rb +69 -0
  31. data/lib/yuno/proofreader/audit/batch.rb +64 -0
  32. data/lib/yuno/proofreader/audit/batch_report.rb +87 -0
  33. data/lib/yuno/proofreader/audit/cadence.rb +67 -0
  34. data/lib/yuno/proofreader/audit/instructions.rb +30 -0
  35. data/lib/yuno/proofreader/audit/log_fields.rb +28 -0
  36. data/lib/yuno/proofreader/audit/prompt.rb +157 -0
  37. data/lib/yuno/proofreader/audit/prompts/instructions.yml +46 -0
  38. data/lib/yuno/proofreader/audit/prompts/steps.yml +67 -0
  39. data/lib/yuno/proofreader/audit/report.rb +153 -0
  40. data/lib/yuno/proofreader/audit/runner.rb +144 -0
  41. data/lib/yuno/proofreader/audit/steps.rb +56 -0
  42. data/lib/yuno/proofreader/cli/analysis_commands.rb +43 -0
  43. data/lib/yuno/proofreader/cli/audit_commands.rb +92 -0
  44. data/lib/yuno/proofreader/cli/command_log.rb +42 -0
  45. data/lib/yuno/proofreader/cli/flags.rb +48 -0
  46. data/lib/yuno/proofreader/cli/init_command.rb +116 -0
  47. data/lib/yuno/proofreader/cli/model_commands.rb +34 -0
  48. data/lib/yuno/proofreader/cli/queue_commands.rb +89 -0
  49. data/lib/yuno/proofreader/cli/sheet_commands.rb +57 -0
  50. data/lib/yuno/proofreader/cli.rb +218 -0
  51. data/lib/yuno/proofreader/config.rb +25 -0
  52. data/lib/yuno/proofreader/context.rb +55 -0
  53. data/lib/yuno/proofreader/env_file.rb +76 -0
  54. data/lib/yuno/proofreader/errors.rb +17 -0
  55. data/lib/yuno/proofreader/findings/finding.rb +95 -0
  56. data/lib/yuno/proofreader/findings/matching.rb +34 -0
  57. data/lib/yuno/proofreader/findings/merger.rb +43 -0
  58. data/lib/yuno/proofreader/findings/outline.rb +73 -0
  59. data/lib/yuno/proofreader/findings/plan.rb +24 -0
  60. data/lib/yuno/proofreader/findings/reconciler.rb +219 -0
  61. data/lib/yuno/proofreader/findings/source.rb +79 -0
  62. data/lib/yuno/proofreader/findings/text.rb +37 -0
  63. data/lib/yuno/proofreader/http/client.rb +131 -0
  64. data/lib/yuno/proofreader/http/net_http_transport.rb +55 -0
  65. data/lib/yuno/proofreader/http/robots.rb +72 -0
  66. data/lib/yuno/proofreader/init/ask.rb +67 -0
  67. data/lib/yuno/proofreader/init/detector.rb +100 -0
  68. data/lib/yuno/proofreader/init/interview.rb +210 -0
  69. data/lib/yuno/proofreader/init/parsing.rb +47 -0
  70. data/lib/yuno/proofreader/init/web.rb +33 -0
  71. data/lib/yuno/proofreader/llm/client.rb +140 -0
  72. data/lib/yuno/proofreader/llm/defaults.rb +22 -0
  73. data/lib/yuno/proofreader/llm/defaults.yml +6 -0
  74. data/lib/yuno/proofreader/llm/errors.rb +26 -0
  75. data/lib/yuno/proofreader/llm/factory.rb +45 -0
  76. data/lib/yuno/proofreader/llm/http_transport.rb +41 -0
  77. data/lib/yuno/proofreader/llm/json_text.rb +23 -0
  78. data/lib/yuno/proofreader/llm/model_commands.rb +34 -0
  79. data/lib/yuno/proofreader/llm/provider.rb +58 -0
  80. data/lib/yuno/proofreader/llm/providers/gemini.rb +146 -0
  81. data/lib/yuno/proofreader/llm/providers.rb +38 -0
  82. data/lib/yuno/proofreader/llm/types.rb +13 -0
  83. data/lib/yuno/proofreader/llm/usage.rb +17 -0
  84. data/lib/yuno/proofreader/log.rb +129 -0
  85. data/lib/yuno/proofreader/pages/fetcher.rb +75 -0
  86. data/lib/yuno/proofreader/pages/report.rb +45 -0
  87. data/lib/yuno/proofreader/pages/snapshot.rb +48 -0
  88. data/lib/yuno/proofreader/pages/trimmer.rb +36 -0
  89. data/lib/yuno/proofreader/settings_file.rb +51 -0
  90. data/lib/yuno/proofreader/site.rb +74 -0
  91. data/lib/yuno/proofreader/site_reader.rb +131 -0
  92. data/lib/yuno/proofreader/sitemap/aligner.rb +78 -0
  93. data/lib/yuno/proofreader/sitemap/importer.rb +31 -0
  94. data/lib/yuno/proofreader/sitemap/loader.rb +127 -0
  95. data/lib/yuno/proofreader/sitemap/mismatch.rb +76 -0
  96. data/lib/yuno/proofreader/sitemap/pairer.rb +223 -0
  97. data/lib/yuno/proofreader/sitemap/parser.rb +73 -0
  98. data/lib/yuno/proofreader/sitemap/report.rb +80 -0
  99. data/lib/yuno/proofreader/store/cells.rb +67 -0
  100. data/lib/yuno/proofreader/store/google_gateway.rb +198 -0
  101. data/lib/yuno/proofreader/store/memory.rb +90 -0
  102. data/lib/yuno/proofreader/store/records.rb +28 -0
  103. data/lib/yuno/proofreader/store/schema.rb +109 -0
  104. data/lib/yuno/proofreader/store/sheets.rb +183 -0
  105. data/lib/yuno/proofreader/store/tab_setup.rb +74 -0
  106. data/lib/yuno/proofreader/version.rb +7 -0
  107. data/lib/yuno/proofreader.rb +101 -0
  108. data/proofreader.example.yml +134 -0
  109. 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