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,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "securerandom"
5
+ require "time"
6
+
7
+ module Yuno
8
+ module Proofreader
9
+ # Writes one line per event: what happened, when, and the facts about it, such as the page and the tokens
10
+ # used. A line is "key=value" pairs, or with LOG_FORMAT=json one JSON object that Google Cloud Logging reads
11
+ # as a structured entry (its severity and message fields become the entry's severity and text). Which lines
12
+ # are written depends on LOG_LEVEL.
13
+ class Log
14
+ LEVELS = Config::LOG_LEVELS
15
+ FORMATS = %w[text json].freeze
16
+ RANK = LEVELS.each_with_index.to_h { |name, index| [name.to_sym, index] }.freeze
17
+ SEVERITIES = { debug: "DEBUG", info: "INFO", warn: "WARNING", error: "ERROR" }.freeze
18
+ DEFAULT_LEVEL = :warn
19
+ MAX_VALUE = 500
20
+ SECRET = /api_?key|secret|password|credential|authorization|\A(access_|auth_|bearer_)?token\z/i
21
+ PLAIN = %r{\A[\w.:/@+,%-]+\z}
22
+
23
+ # A log that writes nothing, for code that was not given one.
24
+ def self.null = new(io: nil)
25
+
26
+ # Reads LOG_LEVEL and LOG_FORMAT. Every line carries a short id of the run, so the lines of one run can be
27
+ # picked out of a shared log.
28
+ def self.from_env(env = ENV, io = $stderr, **)
29
+ new(io:, level: level_from(env["LOG_LEVEL"]), format: format_from(env["LOG_FORMAT"]),
30
+ fields: { run: SecureRandom.hex(4) }, **)
31
+ end
32
+
33
+ def self.level_from(value)
34
+ level = value.to_s.strip.downcase
35
+ return DEFAULT_LEVEL if level.empty?
36
+ return level.to_sym if LEVELS.include?(level)
37
+
38
+ raise ConfigError, "LOG_LEVEL must be one of #{LEVELS.join(', ')} (got #{value.inspect})"
39
+ end
40
+
41
+ def self.format_from(value)
42
+ kind = value.to_s.strip.downcase
43
+ return "text" if kind.empty?
44
+ return kind if FORMATS.include?(kind)
45
+
46
+ raise ConfigError, "LOG_FORMAT must be one of #{FORMATS.join(', ')} (got #{value.inspect})"
47
+ end
48
+
49
+ def initialize(io:, level: DEFAULT_LEVEL, format: "text", fields: {}, clock: -> { Time.now.utc })
50
+ @io = io
51
+ @level = level
52
+ @format = format
53
+ @fields = fields
54
+ @clock = clock
55
+ end
56
+
57
+ # The same log with more fields on every line it writes, such as the page being worked on.
58
+ def with(**fields)
59
+ self.class.new(io: @io, level: @level, format: @format, fields: @fields.merge(fields), clock: @clock)
60
+ end
61
+
62
+ def enabled?(level) = !@io.nil? && RANK.fetch(level) >= RANK.fetch(@level)
63
+
64
+ def debug(message, **fields) = write(:debug, message, fields)
65
+
66
+ def info(message, **fields) = write(:info, message, fields)
67
+
68
+ def warn(message, **fields) = write(:warn, message, fields)
69
+
70
+ def error(message, **fields) = write(:error, message, fields)
71
+
72
+ private
73
+
74
+ # One write per line, so lines from different threads or processes do not mix. A log that cannot be written
75
+ # (a closed pipe) never stops the work.
76
+ def write(level, message, fields)
77
+ return unless enabled?(level)
78
+
79
+ @io.write("#{line(level, shorten(message.to_s), clean(@fields.merge(fields)))}\n")
80
+ rescue IOError, SystemCallError
81
+ nil
82
+ end
83
+
84
+ def line(level, message, data)
85
+ time = @clock.call.utc.iso8601
86
+ @format == "json" ? json_line(level, message, time, data) : text_line(level, message, time, data)
87
+ end
88
+
89
+ def text_line(level, message, time, data)
90
+ head = { "time" => time, "level" => level.to_s, "msg" => message }
91
+ head.merge(data.except(*head.keys)).map { |key, value| "#{key}=#{text_value(value)}" }.join(" ")
92
+ end
93
+
94
+ def json_line(level, message, time, data)
95
+ head = { "severity" => SEVERITIES.fetch(level), "message" => message, "time" => time }
96
+ JSON.generate(head.merge(data.except(*head.keys)))
97
+ end
98
+
99
+ def text_value(value)
100
+ text = value.to_s
101
+ text.match?(PLAIN) ? text : JSON.generate(text)
102
+ end
103
+
104
+ # Fields as the log writes them: empty ones left out, secrets hidden, values on one line and not too long.
105
+ def clean(fields)
106
+ fields.each_with_object({}) do |(key, value), found|
107
+ next if value.nil?
108
+
109
+ found[key.to_s] = SECRET.match?(key.to_s) ? "[redacted]" : normalize(value)
110
+ end
111
+ end
112
+
113
+ def normalize(value)
114
+ case value
115
+ when true, false, Integer then value
116
+ when Float then value.round(2)
117
+ when Time then value.utc.iso8601
118
+ when Array then shorten(value.join(","))
119
+ else shorten(value.to_s)
120
+ end
121
+ end
122
+
123
+ def shorten(text)
124
+ flat = text.gsub(/\s+/, " ").strip
125
+ flat.length > MAX_VALUE ? "#{flat[0, MAX_VALUE - 1]}…" : flat
126
+ end
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yuno
4
+ module Proofreader
5
+ module Pages
6
+ # Downloads every language version of a page politely: it reads robots.txt first, goes through the
7
+ # client's pacing and size limits, and reports a problem per version instead of failing the whole page.
8
+ class Fetcher
9
+ MAX_HTML_CHARS = 200_000
10
+
11
+ def initialize(site, client)
12
+ @site = site
13
+ @client = client
14
+ end
15
+
16
+ # Raises FetchError when robots.txt cannot be read, because then nothing may be fetched.
17
+ def fetch(page)
18
+ rules = robots
19
+ FetchedPage.new(name: page.name,
20
+ snapshots: page.urls.map { |language, url| snapshot(rules, language, url) })
21
+ end
22
+
23
+ private
24
+
25
+ def robots
26
+ @robots ||= load_robots
27
+ end
28
+
29
+ def load_robots
30
+ response = @client.get("#{@site.website}/robots.txt")
31
+ case response.status
32
+ when 200..299 then Http::Robots.parse(response.body, agent: Http::PRODUCT_TOKEN)
33
+ when 400..499 then Http::Robots.allow_all
34
+ else raise FetchError,
35
+ "robots.txt answered HTTP #{response.status}, so no pages are fetched. Try again later."
36
+ end
37
+ end
38
+
39
+ def snapshot(rules, language, url)
40
+ return Snapshot.failed(language, url, "blocked by robots.txt") unless rules.allowed?(url)
41
+
42
+ build(language, url, @client.get(url))
43
+ rescue FetchError => e
44
+ Snapshot.failed(language, url, "could not be fetched (#{e.message})")
45
+ end
46
+
47
+ def build(language, url, response)
48
+ problem = problem_with(response)
49
+ return Snapshot.failed(language, url, problem, status: response.status) if problem
50
+
51
+ trimmed = Trimmer.call(response.body)
52
+ cut = trimmed.length > MAX_HTML_CHARS
53
+ Snapshot.new(language:, url:, final_url: response.url, status: response.status,
54
+ content_type: response.content_type, raw: response.body,
55
+ html: cut ? trimmed[0, MAX_HTML_CHARS] : trimmed,
56
+ fingerprint: Trimmer.fingerprint(trimmed), truncated: cut, problem: nil)
57
+ end
58
+
59
+ def problem_with(response)
60
+ return "HTTP #{response.status}" unless response.status == 200
61
+ return if html?(response)
62
+
63
+ "not an HTML page (content type #{response.content_type.inspect})"
64
+ end
65
+
66
+ def html?(response)
67
+ type = response.content_type.to_s
68
+ return type.match?(/html/i) unless type.empty?
69
+
70
+ response.body.match?(/<html|<!doctype html/i)
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yuno
4
+ module Proofreader
5
+ module Pages
6
+ # Turns a fetched page into the lines the fetch command prints.
7
+ class Report
8
+ def initialize(fetched)
9
+ @fetched = fetched
10
+ end
11
+
12
+ def lines
13
+ ["Page: #{@fetched.name}", *@fetched.snapshots.flat_map { |snapshot| block(snapshot) }, "", fingerprint_line]
14
+ end
15
+
16
+ private
17
+
18
+ def block(snapshot)
19
+ status = snapshot.ok? ? "ok " : "PROBLEM"
20
+ [" #{snapshot.language.ljust(3)} #{status} #{snapshot.url}", *details(snapshot)]
21
+ end
22
+
23
+ def details(snapshot)
24
+ return [" - #{snapshot.problem}"] unless snapshot.ok?
25
+
26
+ facts = [
27
+ "raw #{snapshot.raw.bytesize} bytes", "trimmed #{snapshot.html.length} chars",
28
+ "complete: #{snapshot.complete? ? 'yes' : 'no'}", "fingerprint #{snapshot.fingerprint[0, 12]}"
29
+ ]
30
+ notes = []
31
+ notes << "redirected to #{snapshot.final_url}" if snapshot.redirected?
32
+ notes << "cut at #{Fetcher::MAX_HTML_CHARS} characters" if snapshot.truncated
33
+ [" #{facts.join(', ')}", *notes.map { |note| " - #{note}" }]
34
+ end
35
+
36
+ def fingerprint_line
37
+ fingerprint = @fetched.fingerprint
38
+ return "Combined fingerprint: none (not every version could be fetched)" unless fingerprint
39
+
40
+ "Combined fingerprint: #{fingerprint[0, 12]}"
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module Yuno
6
+ module Proofreader
7
+ module Pages
8
+ # A trimmed page this short, or without a closing </html>, is treated as incomplete.
9
+ MIN_COMPLETE_CHARS = 3_000
10
+
11
+ # One language version of a page as fetched. problem is nil when the page could be used.
12
+ # raw is the HTML exactly as received, html the trimmed (and possibly cut) text for the model.
13
+ Snapshot = Data.define(:language, :url, :final_url, :status, :content_type, :raw, :html,
14
+ :fingerprint, :truncated, :problem) do
15
+ def self.failed(language, url, problem, status: nil)
16
+ new(language:, url:, final_url: url, status:, content_type: nil, raw: "", html: "",
17
+ fingerprint: nil, truncated: false, problem:)
18
+ end
19
+
20
+ def ok? = problem.nil?
21
+
22
+ def redirected? = ok? && final_url != url
23
+
24
+ def complete?
25
+ ok? && !truncated && html.length >= MIN_COMPLETE_CHARS && raw.match?(%r{</html\s*>}i)
26
+ end
27
+ end
28
+
29
+ # All language versions of one page.
30
+ FetchedPage = Data.define(:name, :snapshots) do
31
+ def snapshot(language) = snapshots.find { |snapshot| snapshot.language == language }
32
+
33
+ def ok? = snapshots.all?(&:ok?)
34
+
35
+ def complete? = snapshots.all?(&:complete?)
36
+
37
+ # One fingerprint for the whole page, or nil when a version is missing (then nothing can be called unchanged).
38
+ def fingerprint
39
+ return unless ok?
40
+
41
+ Digest::SHA256.hexdigest(snapshots.map do |snapshot|
42
+ "#{snapshot.language}:#{snapshot.fingerprint}"
43
+ end.join("\n"))
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module Yuno
6
+ module Proofreader
7
+ module Pages
8
+ # Cleans a page's HTML for the model without rewriting it. What stays is exactly what the server sent
9
+ # (only runs of whitespace become one space), so a text the model quotes can still be found in the page.
10
+ module Trimmer
11
+ LONG_DATA_URI = /data:[^"')\s]{50,}/
12
+ # Values that change on every request. They are ignored when deciding whether a page changed.
13
+ VOLATILE = [
14
+ /<meta\b[^>]*\bname=["']?(?:csrf-token|csrf-param|csp-nonce)["']?[^>]*>/i,
15
+ /<input\b[^>]*\bname=["']authenticity_token["'][^>]*>/i,
16
+ /\snonce=(?:"[^"]*"|'[^']*')/i
17
+ ].freeze
18
+
19
+ module_function
20
+
21
+ def call(html)
22
+ html.to_s
23
+ .gsub(%r{<(script|style|svg)\b.*?</\1\s*>}mi, "")
24
+ .gsub(/<!--.*?-->/m, "")
25
+ .gsub(LONG_DATA_URI, "data:...")
26
+ .gsub(/\s+/, " ")
27
+ .strip
28
+ end
29
+
30
+ def fingerprint(trimmed)
31
+ Digest::SHA256.hexdigest(VOLATILE.reduce(trimmed) { |text, pattern| text.gsub(pattern, "") })
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Yuno
6
+ module Proofreader
7
+ # Writes settings as YAML in the style of proofreader.example.yml: text in double quotes, lists with the dash
8
+ # indented, and the settings in a fixed order. Settings with nothing in them are left out.
9
+ module SettingsFile
10
+ ORDER = %w[website languages sitemap_url default_language_only_paths skip_paths provider models review_models
11
+ business audience tone rules ignore_terms intentional_differences analysis cadence].freeze
12
+ PLAIN_KEY = %r{\A[\w/.-]+\z}
13
+ INDENT = " "
14
+
15
+ module_function
16
+
17
+ # settings: a hash with string keys. header: comment lines for the top of the file.
18
+ def render(settings, header: [])
19
+ present = settings.select { |_key, value| filled?(value) }
20
+ keys = ORDER.select { |key| present.key?(key) } + (present.keys - ORDER)
21
+ body = keys.map { |key| emit(key, present[key], 0).join("\n") }.join("\n\n")
22
+ comments = header.map { |line| "# #{line}".rstrip }.join("\n")
23
+ "#{comments}#{"\n\n" unless comments.empty?}#{body}\n"
24
+ end
25
+
26
+ def filled?(value) = !(value.nil? || (value.respond_to?(:empty?) && value.empty?))
27
+
28
+ # The lines of one setting. level is how deep it is nested.
29
+ def emit(key, value, level)
30
+ pad = INDENT * level
31
+ case value
32
+ when Hash then ["#{pad}#{name(key)}:", *value.flat_map { |inner, item| emit(inner, item, level + 1) }]
33
+ when Array then ["#{pad}#{name(key)}:", *value.flat_map { |item| list_item(item, level + 1) }]
34
+ else ["#{pad}#{name(key)}: #{scalar(value)}"]
35
+ end
36
+ end
37
+
38
+ def list_item(item, level)
39
+ pad = INDENT * level
40
+ return ["#{pad}- #{scalar(item)}"] unless item.is_a?(Hash)
41
+
42
+ lines = item.flat_map { |key, value| emit(key, value, level + 1) }
43
+ ["#{pad}- #{lines.first.lstrip}", *lines.drop(1)]
44
+ end
45
+
46
+ def name(key) = key.to_s.match?(PLAIN_KEY) ? key.to_s : JSON.generate(key.to_s)
47
+
48
+ def scalar(value) = value.is_a?(String) ? JSON.generate(value) : value.to_s
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+ require "yaml"
5
+
6
+ module Yuno
7
+ module Proofreader
8
+ # What you tell the tool about your website. Read from a YAML file.
9
+ Site = Data.define(:website, :prefixes, :language_notes, :sitemap_url, :default_language_only_paths,
10
+ :skip_paths, :provider, :models, :review_models, :context, :analysis, :cadence) do
11
+ def self.load(path)
12
+ raise ConfigError, "Site file not found: #{path}" unless File.file?(path)
13
+
14
+ from_hash(YAML.safe_load_file(path))
15
+ rescue Psych::Exception => e
16
+ raise ConfigError, "Site file #{path} could not be read as YAML: #{e.message}"
17
+ end
18
+
19
+ def self.from_hash(hash) = SiteReader.call(hash)
20
+
21
+ def languages = prefixes.keys
22
+
23
+ def default_language = languages.first
24
+
25
+ # How each setting that describes the site stands: :set, :empty or :placeholder.
26
+ def states
27
+ notes = language_notes.to_h { |code, note| ["languages: #{code} notes", Context.state_of(note)] }
28
+ context.states.merge(notes)
29
+ end
30
+
31
+ # What stops an audit from starting: no business description, or example text that was never replaced.
32
+ def audit_problems
33
+ problems = []
34
+ problems << "business is not set. Describe the site in one or two sentences." if context.business.empty?
35
+ unfinished = states.select { |_name, state| state == :placeholder }.keys
36
+ unless unfinished.empty?
37
+ problems << "These settings still hold the [FILL IN] example text: #{unfinished.join(', ')}. " \
38
+ "Replace it with your own text, or delete the setting."
39
+ end
40
+ problems
41
+ end
42
+
43
+ def ready_for_audit!
44
+ problems = audit_problems
45
+ raise ConfigError, "Site settings are not ready for an audit. #{problems.join(' ')}" unless problems.empty?
46
+
47
+ self
48
+ end
49
+
50
+ def validate!
51
+ unless http_url?(website)
52
+ raise ConfigError,
53
+ "Site: website must be a full address, e.g. https://example.com"
54
+ end
55
+ unless prefixes.values.uniq.size == prefixes.size
56
+ raise ConfigError,
57
+ "Site: two languages share the same URL prefix"
58
+ end
59
+ raise ConfigError, "Site: sitemap_url must be a full address" if sitemap_url && !http_url?(sitemap_url)
60
+
61
+ self
62
+ end
63
+
64
+ private
65
+
66
+ def http_url?(value)
67
+ uri = URI.parse(value)
68
+ %w[http https].include?(uri.scheme) && !uri.host.to_s.empty?
69
+ rescue URI::InvalidURIError
70
+ false
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yuno
4
+ module Proofreader
5
+ # Turns the parsed YAML of a settings file into a Site, with a clear error for anything that is wrong.
6
+ class SiteReader
7
+ SITE_KEYS = %w[website languages sitemap_url default_language_only_paths skip_paths provider models
8
+ review_models analysis cadence].freeze
9
+ PROVIDER_ID = /\A[a-z][a-z0-9_-]{0,39}\z/
10
+ MODEL_NAME = %r{\A[A-Za-z0-9][A-Za-z0-9._:/-]{0,99}\z}
11
+ MAX_MODELS = 8
12
+ KEYS = (SITE_KEYS + Context::KEYS).freeze
13
+ LANGUAGE_KEYS = %w[code prefix notes].freeze
14
+ LANGUAGE_CODE = /\A[a-z]{2,3}(-[a-z0-9]+)*\z/
15
+
16
+ def self.call(hash) = new(hash).call
17
+
18
+ def initialize(hash)
19
+ raise ConfigError, "Site settings must be a list of key: value pairs" unless hash.nil? || hash.is_a?(Hash)
20
+
21
+ @raw = (hash || {}).transform_keys(&:to_s)
22
+ end
23
+
24
+ def call
25
+ check_keys(@raw.keys, KEYS, "Site")
26
+ prefixes, notes = languages
27
+ Site.new(
28
+ website: @raw["website"].to_s.strip.chomp("/"), prefixes:, language_notes: notes,
29
+ sitemap_url: sitemap_url, default_language_only_paths: paths("default_language_only_paths"),
30
+ skip_paths: paths("skip_paths"), provider: provider_name, models: model_list("models"),
31
+ review_models: model_list("review_models"), context: Context.from_hash(@raw),
32
+ analysis: Analysis::Settings.from_hash(@raw["analysis"]), cadence: Audit::Cadence.from_hash(@raw["cadence"])
33
+ ).validate!
34
+ end
35
+
36
+ private
37
+
38
+ def check_keys(found, allowed, where)
39
+ unknown = found - allowed
40
+ return if unknown.empty?
41
+
42
+ raise ConfigError, "#{where}: unknown setting(s): #{unknown.join(', ')}. Allowed: #{allowed.join(', ')}"
43
+ end
44
+
45
+ def sitemap_url
46
+ value = @raw["sitemap_url"].to_s.strip
47
+ value.empty? ? nil : value
48
+ end
49
+
50
+ # Returns the URL prefix and the notes of each language, both keyed by language code in the order given.
51
+ def languages
52
+ value = @raw["languages"]
53
+ unless value.is_a?(Array) && !value.empty? && value.all?(Hash)
54
+ raise ConfigError, "Site: languages must be a list with one entry per language, " \
55
+ "each with a code and a prefix (see proofreader.example.yml)"
56
+ end
57
+
58
+ prefixes = {}
59
+ notes = {}
60
+ value.each_with_index do |item, index|
61
+ code, prefix, note = language(item, index + 1)
62
+ raise ConfigError, "Site: language #{code} is listed twice" if prefixes.key?(code)
63
+
64
+ prefixes[code] = prefix
65
+ notes[code] = note
66
+ end
67
+ [prefixes, notes]
68
+ end
69
+
70
+ def language(item, number)
71
+ entry = item.transform_keys(&:to_s)
72
+ check_keys(entry.keys, LANGUAGE_KEYS, "Site: language ##{number}")
73
+ [language_code(entry["code"], number), normalize_prefix(entry["prefix"]),
74
+ Context.text(entry["notes"], "language ##{number} notes", Context::MAX_ITEM)]
75
+ end
76
+
77
+ def language_code(value, number)
78
+ if [true, false].include?(value)
79
+ raise ConfigError, "Site: language ##{number}: YAML read the code as #{value}. " \
80
+ "Put quotes around language codes, for example code: \"no\""
81
+ end
82
+
83
+ code = value.to_s.strip.downcase
84
+ return code if code.match?(LANGUAGE_CODE)
85
+
86
+ raise ConfigError, "Site: language ##{number} needs a code such as \"de\" or \"pt-br\" (got #{value.inspect})"
87
+ end
88
+
89
+ def normalize_prefix(value)
90
+ prefix = value.to_s.strip
91
+ return "" if prefix.empty? || prefix == "/"
92
+
93
+ prefix = "/#{prefix}" unless prefix.start_with?("/")
94
+ prefix.chomp("/")
95
+ end
96
+
97
+ def paths(key)
98
+ items = Array(@raw[key])
99
+ raise ConfigError, "Site: #{key} must be a list of paths, written as text" unless items.all?(String)
100
+
101
+ items.filter_map do |item|
102
+ path = item.strip
103
+ next if path.empty?
104
+
105
+ path = "/#{path}" unless path.start_with?("/")
106
+ path = path.chomp("/")
107
+ path unless path.empty?
108
+ end
109
+ end
110
+
111
+ def provider_name
112
+ name = Context.text(@raw["provider"], "provider", 40).downcase
113
+ return name if name.empty? || name.match?(PROVIDER_ID)
114
+
115
+ raise ConfigError, "Site: provider must be a short lowercase name (got #{@raw['provider'].inspect})"
116
+ end
117
+
118
+ def model_list(key)
119
+ items = Array(@raw[key])
120
+ raise ConfigError, "Site: #{key} must be a list of model names, written as text" unless items.all?(String)
121
+
122
+ names = items.map(&:strip).reject(&:empty?).uniq
123
+ bad = names.find { |name| !name.match?(MODEL_NAME) }
124
+ raise ConfigError, "Site: #{key} has an entry that is not a model name: #{bad.inspect}" if bad
125
+ raise ConfigError, "Site: #{key} has more than #{MAX_MODELS} entries" if names.size > MAX_MODELS
126
+
127
+ names
128
+ end
129
+ end
130
+ end
131
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yuno
4
+ module Proofreader
5
+ module Sitemap
6
+ # Lines up two lists of pages so that as many pages as possible get a partner, and returns the ones that did
7
+ # not. Pages match better when their addresses have the same depth, and better still when their last segment
8
+ # shares a word. It is a guess for lists whose order is mostly the same.
9
+ module Aligner
10
+ GAP = -1
11
+ SAME_DEPTH = 2
12
+ OTHER_DEPTH = -1
13
+ SHARED_WORD = 1
14
+
15
+ module_function
16
+
17
+ # left, right: [depth, words] for each page. Returns [indices of left pages without a partner,
18
+ # indices of right pages without a partner].
19
+ def call(left, right)
20
+ table = fill(left, right)
21
+ trace(table, left, right)
22
+ end
23
+
24
+ def fill(left, right)
25
+ table = Array.new(left.size + 1) { |row| Array.new(right.size + 1) { |col| (row + col) * GAP } }
26
+ (1..left.size).each do |row|
27
+ (1..right.size).each { |col| table[row][col] = best(table, left, right, row, col) }
28
+ end
29
+ table
30
+ end
31
+
32
+ def best(table, left, right, row, col)
33
+ [table[row - 1][col - 1] + similarity(left[row - 1], right[col - 1]),
34
+ table[row - 1][col] + GAP, table[row][col - 1] + GAP].max
35
+ end
36
+
37
+ def trace(table, left, right)
38
+ row = left.size
39
+ col = right.size
40
+ alone = [[], []]
41
+ while row.positive? || col.positive?
42
+ move = step(table, row, col)
43
+ note_alone(alone, move, row, col)
44
+ row, col = after(move, row, col)
45
+ end
46
+ alone.map(&:reverse)
47
+ end
48
+
49
+ # A page passed on its own, without the other side, has no partner.
50
+ def note_alone(alone, move, row, col)
51
+ alone[0] << (row - 1) if move == :left
52
+ alone[1] << (col - 1) if move == :right
53
+ end
54
+
55
+ # A pair moves past a page on both sides, a gap past the page on its own side only.
56
+ def after(move, row, col) = [move == :right ? row : row - 1, move == :left ? col : col - 1]
57
+
58
+ # On a tie the later page is left alone, because a page without a partner is most often one that was added
59
+ # at the end of a list, such as the pages of a second sitemap. Any move this allows still gives the best
60
+ # alignment, so the tie only decides which of two equally good guesses is shown.
61
+ def step(table, row, col)
62
+ return :right if row.zero?
63
+ return :left if col.zero?
64
+ return :left if table[row][col] == table[row - 1][col] + GAP
65
+ return :right if table[row][col] == table[row][col - 1] + GAP
66
+
67
+ :both
68
+ end
69
+
70
+ def similarity(first, second)
71
+ score = first[0] == second[0] ? SAME_DEPTH : OTHER_DEPTH
72
+ first[1].intersect?(second[1]) ? score + SHARED_WORD : score
73
+ end
74
+ private_class_method :fill, :best, :trace, :step, :similarity
75
+ end
76
+ end
77
+ end
78
+ end