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,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
|