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,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Yuno
|
|
4
|
+
module Proofreader
|
|
5
|
+
class Error < StandardError; end
|
|
6
|
+
|
|
7
|
+
class ConfigError < Error; end
|
|
8
|
+
|
|
9
|
+
class SitemapError < Error; end
|
|
10
|
+
|
|
11
|
+
class FetchError < Error; end
|
|
12
|
+
|
|
13
|
+
class StoreError < Error; end
|
|
14
|
+
|
|
15
|
+
class LlmError < Error; end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Yuno
|
|
4
|
+
module Proofreader
|
|
5
|
+
module Findings
|
|
6
|
+
# One suggested change, as the model reports it: where it is, what it quotes and what it suggests.
|
|
7
|
+
class Finding < Data.define(:language, :category, :effort, :original, :suggested, :reason)
|
|
8
|
+
CATEGORIES = %w[accent typo grammar repetition html alt_text parity seo stale style other].freeze
|
|
9
|
+
MAX_QUOTE = 2_000
|
|
10
|
+
MAX_SUGGESTION = 2_000
|
|
11
|
+
MAX_REASON = 500
|
|
12
|
+
|
|
13
|
+
# findings: the usable ones. discarded: a line for each item that was not usable, saying why.
|
|
14
|
+
Parsed = Data.define(:findings, :discarded)
|
|
15
|
+
|
|
16
|
+
# items: the list of findings from a model answer. An item that cannot be used is discarded with a
|
|
17
|
+
# reason, so one bad item never costs the others. categories: the ones allowed for this list.
|
|
18
|
+
# fallback: the one used for anything else.
|
|
19
|
+
def self.parse_all(items, languages:, categories: CATEGORIES, fallback: "other")
|
|
20
|
+
return Parsed.new(findings: [], discarded: []) if items.nil?
|
|
21
|
+
return Parsed.new(findings: [], discarded: ["the findings are not a list"]) unless items.is_a?(Array)
|
|
22
|
+
|
|
23
|
+
findings = []
|
|
24
|
+
discarded = []
|
|
25
|
+
items.each_with_index do |item, index|
|
|
26
|
+
finding, problem = build(item, languages:, categories:, fallback:)
|
|
27
|
+
if finding
|
|
28
|
+
findings << finding
|
|
29
|
+
else
|
|
30
|
+
discarded << "finding #{index + 1} #{problem}"
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
Parsed.new(findings:, discarded:)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Returns [finding, nil], or [nil, what is wrong with the item].
|
|
37
|
+
def self.build(item, languages:, categories:, fallback:)
|
|
38
|
+
return [nil, "is not an object"] unless item.is_a?(Hash)
|
|
39
|
+
|
|
40
|
+
raw = item.transform_keys(&:to_s)
|
|
41
|
+
language = language_for(raw["language"], languages)
|
|
42
|
+
problem = problem_with(raw, language)
|
|
43
|
+
return [nil, problem] if problem
|
|
44
|
+
|
|
45
|
+
category = category_for(raw["category"], categories, fallback)
|
|
46
|
+
[new(language:, category:, effort: effort_for(raw["effort"], category), original: clean(raw["original"]),
|
|
47
|
+
suggested: cut(clean(raw["suggested"]), MAX_SUGGESTION), reason: cut(clean(raw["reason"]), MAX_REASON)),
|
|
48
|
+
nil]
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def self.problem_with(raw, language)
|
|
52
|
+
original = clean(raw["original"])
|
|
53
|
+
suggested = clean(raw["suggested"])
|
|
54
|
+
return "has an unknown language #{raw['language'].inspect}" unless language
|
|
55
|
+
return "quotes no text" if original.empty?
|
|
56
|
+
return "quotes more than #{MAX_QUOTE} characters" if original.length > MAX_QUOTE
|
|
57
|
+
return "suggests nothing" if suggested.empty?
|
|
58
|
+
|
|
59
|
+
"suggests the text it quotes" if suggested == original
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# An exact code wins. Otherwise a regional code (de-AT) counts as its language (de).
|
|
63
|
+
def self.language_for(value, languages)
|
|
64
|
+
code = clean(value).downcase
|
|
65
|
+
languages.find { |language| language == code } ||
|
|
66
|
+
languages.sort_by { |language| -language.length }.find { |language| code.start_with?("#{language}-") }
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def self.category_for(value, categories, fallback)
|
|
70
|
+
category = clean(value).downcase
|
|
71
|
+
categories.include?(category) ? category : fallback
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Quick means a fix that needs no thinking. Anything unclear is bigger, and alt text is always bigger.
|
|
75
|
+
def self.effort_for(value, category)
|
|
76
|
+
return "bigger" if category == "alt_text"
|
|
77
|
+
|
|
78
|
+
clean(value).downcase == "quick" ? "quick" : "bigger"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Text and numbers become trimmed text. Anything else (a nested structure, nothing) becomes empty.
|
|
82
|
+
def self.clean(value)
|
|
83
|
+
case value
|
|
84
|
+
when String, Numeric then value.to_s.strip
|
|
85
|
+
else ""
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def self.cut(text, max) = text[0, max]
|
|
90
|
+
|
|
91
|
+
private_class_method :build, :problem_with, :language_for, :category_for, :effort_for, :clean, :cut
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Yuno
|
|
4
|
+
module Proofreader
|
|
5
|
+
module Findings
|
|
6
|
+
# When two findings, or a finding and a suggestion in the sheet, are about the same place.
|
|
7
|
+
module Matching
|
|
8
|
+
MIN_OVERLAP = 6
|
|
9
|
+
FAMILIES = { "accent" => "spelling", "typo" => "spelling", "grammar" => "wording", "style" => "wording" }.freeze
|
|
10
|
+
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
# Spelling and wording issues match across their categories, because the model files one issue under either.
|
|
14
|
+
def family(category) = FAMILIES.fetch(category.to_s, category.to_s)
|
|
15
|
+
|
|
16
|
+
# Same language, and one quoted text contains the other (ignoring spaces, capitals and entities).
|
|
17
|
+
# A short quote has to match exactly, so a word like "the" does not match half the page.
|
|
18
|
+
def same_spot?(first, second)
|
|
19
|
+
return false unless first.language == second.language
|
|
20
|
+
|
|
21
|
+
one = key(first.original)
|
|
22
|
+
other = key(second.original)
|
|
23
|
+
return false if one.empty? || other.empty?
|
|
24
|
+
return true if one == other
|
|
25
|
+
|
|
26
|
+
shorter, longer = [one, other].sort_by(&:length)
|
|
27
|
+
shorter.length >= MIN_OVERLAP && longer.include?(shorter)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def key(text) = Text.squash(text).downcase
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Yuno
|
|
4
|
+
module Proofreader
|
|
5
|
+
module Findings
|
|
6
|
+
# Combines the findings of the audit and of the independent second review. A finding both found counts as
|
|
7
|
+
# confirmed. Where both found it, the audit's wording is kept, because its categories are the more reliable.
|
|
8
|
+
class Merger
|
|
9
|
+
Merged = Data.define(:finding, :found_by)
|
|
10
|
+
|
|
11
|
+
BOTH = "both"
|
|
12
|
+
AUDIT = "audit"
|
|
13
|
+
REVIEW = "review"
|
|
14
|
+
UNREVIEWED = "audit (unreviewed)"
|
|
15
|
+
|
|
16
|
+
# REVIEW: is nil when the second review failed. An empty list means it ran and found nothing.
|
|
17
|
+
def self.call(audit, review) = new(audit, review).call
|
|
18
|
+
|
|
19
|
+
def initialize(audit, review)
|
|
20
|
+
@audit = audit
|
|
21
|
+
@review = review
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def call
|
|
25
|
+
return @audit.map { |finding| Merged.new(finding:, found_by: UNREVIEWED) } if @review.nil?
|
|
26
|
+
|
|
27
|
+
confirmed = Array.new(@review.size, false)
|
|
28
|
+
merged = @audit.map { |finding| compare(finding, confirmed) }
|
|
29
|
+
merged + @review.each_index.reject { |index| confirmed[index] }
|
|
30
|
+
.map { |index| Merged.new(finding: @review[index], found_by: REVIEW) }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def compare(finding, confirmed)
|
|
36
|
+
hits = @review.each_index.select { |index| Matching.same_spot?(finding, @review[index]) }
|
|
37
|
+
hits.each { |index| confirmed[index] = true }
|
|
38
|
+
Merged.new(finding:, found_by: hits.empty? ? AUDIT : BOTH)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Yuno
|
|
4
|
+
module Proofreader
|
|
5
|
+
module Findings
|
|
6
|
+
# The structure of a page's HTML: its header, navigation and footer areas, and its headings.
|
|
7
|
+
# It answers one question: which headings is a position in the page under?
|
|
8
|
+
# It reads the markup with regular expressions, so it is made for the cleaned HTML the tool works with.
|
|
9
|
+
class Outline
|
|
10
|
+
Region = Data.define(:name, :start, :finish)
|
|
11
|
+
Heading = Data.define(:level, :start, :inner, :region)
|
|
12
|
+
|
|
13
|
+
REGION = %r{<(head|header|nav|footer)\b.*?</\1\s*>}mi
|
|
14
|
+
HEADING = %r{<h([1-6])\b[^>]*>(.*?)</h\1\s*>}mi
|
|
15
|
+
MAX_LABEL = 60
|
|
16
|
+
|
|
17
|
+
def initialize(html)
|
|
18
|
+
@regions = matches(REGION, html).map do |match|
|
|
19
|
+
Region.new(name: match[1].downcase, start: match.begin(0), finish: match.end(0))
|
|
20
|
+
end
|
|
21
|
+
@headings = matches(HEADING, html).map { |match| build_heading(match) }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# The headings as [level, text, region name or nil], for looking at the structure.
|
|
25
|
+
def headings = @headings.map { |heading| [heading.level, label(heading), heading.region&.name] }
|
|
26
|
+
|
|
27
|
+
# "header", "footer" or "head" for those areas. Anywhere else the headings above the position, from the
|
|
28
|
+
# first h2 down, such as "What we offer > Tours". The h1 is left out, because everything is under it.
|
|
29
|
+
def path_at(position)
|
|
30
|
+
region = region_at(position)
|
|
31
|
+
return "head" if region&.name == "head"
|
|
32
|
+
|
|
33
|
+
parts = chain_at(position, region).select { |heading| heading.level > 1 }.map { |heading| label(heading) }
|
|
34
|
+
path = [region&.name, *parts].compact.join(" > ")
|
|
35
|
+
path.empty? ? "(before first h2)" : path
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def matches(regexp, html)
|
|
41
|
+
html.to_enum(:scan, regexp).map { Regexp.last_match }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def build_heading(match)
|
|
45
|
+
start = match.begin(0)
|
|
46
|
+
Heading.new(level: match[1].to_i, start:, inner: match[2], region: region_at(start))
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def region_at(position)
|
|
50
|
+
@regions.find { |region| position >= region.start && position < region.finish }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# The headings that are open at the position: a new heading closes every heading of the same or a lower
|
|
54
|
+
# level. Headings in other areas (a footer, say) do not count.
|
|
55
|
+
def chain_at(position, region)
|
|
56
|
+
@headings.each_with_object([]) do |heading, chain|
|
|
57
|
+
next if heading.start > position || heading.region != region
|
|
58
|
+
|
|
59
|
+
chain.pop while chain.any? && chain.last.level >= heading.level
|
|
60
|
+
chain << heading
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def label(heading)
|
|
65
|
+
text = Text.collapse(Text.decode(Text.strip_tags(heading.inner)))
|
|
66
|
+
return "(empty h#{heading.level})" if text.empty?
|
|
67
|
+
|
|
68
|
+
text.length > MAX_LABEL ? "#{text[0, MAX_LABEL - 1]}…" : text
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Yuno
|
|
4
|
+
module Proofreader
|
|
5
|
+
module Findings
|
|
6
|
+
# One thing the reconciliation decided. kind is :added, :reappeared, :seen_again, :suppressed, :duplicate,
|
|
7
|
+
# :ignored, :closed or :close_skipped.
|
|
8
|
+
Decision = Data.define(:kind, :summary, :reason)
|
|
9
|
+
|
|
10
|
+
# What to write: the rows to add, the changes to rows that exist (row number => fields), and why.
|
|
11
|
+
Plan = Data.define(:additions, :updates, :decisions) do
|
|
12
|
+
def apply(store)
|
|
13
|
+
store.update_suggestions(updates)
|
|
14
|
+
store.add_suggestions(additions)
|
|
15
|
+
self
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def counts = decisions.map(&:kind).tally
|
|
19
|
+
|
|
20
|
+
def empty? = additions.empty? && updates.empty?
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Yuno
|
|
4
|
+
module Proofreader
|
|
5
|
+
module Findings
|
|
6
|
+
# Decides what one page's findings mean for the suggestions already in the sheet. It changes nothing: it
|
|
7
|
+
# returns a Plan with every change and the reason for it.
|
|
8
|
+
class Reconciler
|
|
9
|
+
ACTIVE = ["open", "on staging"].freeze
|
|
10
|
+
PRIORITY = { active: 0, held: 1, done: 2 }.freeze
|
|
11
|
+
RANK = { "code" => 0, "both" => 1 }.freeze
|
|
12
|
+
REAPPEARED = "[Reappeared after being marked done] "
|
|
13
|
+
HTTP_ADVICE = "Check why this URL does not load"
|
|
14
|
+
MAX_REASON = 500
|
|
15
|
+
MAX_SUMMARY = 50
|
|
16
|
+
|
|
17
|
+
# A suggestion as matching and closing see it. row is nil for one added in this run.
|
|
18
|
+
Entry = Data.define(:row, :language, :category, :original, :status, :verified, :reason, :seen)
|
|
19
|
+
Addition = Data.define(:record, :rank)
|
|
20
|
+
Check = Data.define(:presence, :place) do
|
|
21
|
+
def verified = { exact: "yes", loose: "loose", none: "no" }.fetch(presence)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# page: the fetched page. findings: Merger::Merged items. existing: the page's rows in the sheet.
|
|
25
|
+
def self.call(**inputs) = new(**inputs).call
|
|
26
|
+
|
|
27
|
+
def initialize(page:, findings:, existing: [], ignore_terms: [], now: Time.now.utc)
|
|
28
|
+
@page = page
|
|
29
|
+
@findings = findings
|
|
30
|
+
@now = now
|
|
31
|
+
@ignored = ignore_terms.map { |term| Text.norm(term).downcase }.reject(&:empty?)
|
|
32
|
+
@snapshots = page.snapshots.to_h { |snapshot| [snapshot.language, snapshot] }
|
|
33
|
+
@sources = page.snapshots.select(&:ok?).to_h do |snapshot|
|
|
34
|
+
[snapshot.language, Source.from_snapshot(snapshot)]
|
|
35
|
+
end
|
|
36
|
+
@entries = existing.map { |record| entry_of(record) }
|
|
37
|
+
@claimed = {}
|
|
38
|
+
@skipped = {}
|
|
39
|
+
@additions = []
|
|
40
|
+
@updates = {}
|
|
41
|
+
@decisions = []
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def call
|
|
45
|
+
(code_findings + @findings).each { |merged| consider(merged) }
|
|
46
|
+
close_resolved
|
|
47
|
+
Plan.new(additions: ranked, updates: @updates, decisions: @decisions)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
# ---- findings ----
|
|
53
|
+
|
|
54
|
+
def consider(merged)
|
|
55
|
+
finding = merged.finding
|
|
56
|
+
return note(:ignored, finding, "its quoted text is on the ignore list") if ignored?(finding)
|
|
57
|
+
|
|
58
|
+
match = best_match(finding)
|
|
59
|
+
return add(merged) unless match
|
|
60
|
+
|
|
61
|
+
case kind_of(match)
|
|
62
|
+
when :active then repeat(match, finding)
|
|
63
|
+
when :done then add(merged, reappeared: true)
|
|
64
|
+
else note(:suppressed, finding, "an earlier suggestion for it is marked #{match.status.inspect}")
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def repeat(match, finding)
|
|
69
|
+
if match.row.nil? || @claimed[match.row]
|
|
70
|
+
return note(:duplicate, finding, "it repeats another finding of this run")
|
|
71
|
+
end
|
|
72
|
+
if check(finding).presence == :none
|
|
73
|
+
return note(:ignored, finding, "its quoted text is not on the page, so it does not count as seen again")
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
@claimed[match.row] = true
|
|
77
|
+
@updates[match.row] = { last_seen: @now, amount_seen: match.seen + 1 }
|
|
78
|
+
note(:seen_again, finding, "row #{match.row} was seen again")
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def add(merged, reappeared: false)
|
|
82
|
+
finding = merged.finding
|
|
83
|
+
found = check(finding)
|
|
84
|
+
reason = reappeared ? cut("#{REAPPEARED}#{finding.reason}") : finding.reason
|
|
85
|
+
@entries << new_entry(finding, found, reason)
|
|
86
|
+
@additions << Addition.new(record: record_for(merged, found, reason), rank: rank(merged))
|
|
87
|
+
note(reappeared ? :reappeared : :added, finding,
|
|
88
|
+
reappeared ? "its text came back after the suggestion was marked done" : "a new finding")
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def record_for(merged, found, reason)
|
|
92
|
+
finding = merged.finding
|
|
93
|
+
Store::SuggestionRecord.build(
|
|
94
|
+
timestamp: @now, page: @page.name, language: finding.language, category: finding.category,
|
|
95
|
+
effort: finding.effort, approximate_place: found.place, original: finding.original,
|
|
96
|
+
suggested: finding.suggested, reason:, verified: found.verified, status: "open",
|
|
97
|
+
found_by: merged.found_by, last_seen: @now, amount_seen: 1
|
|
98
|
+
)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def new_entry(finding, found, reason)
|
|
102
|
+
Entry.new(row: nil, language: finding.language, category: finding.category, original: finding.original,
|
|
103
|
+
status: "open", verified: found.verified, reason:, seen: 1)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Findings made by code: a version that answered with an HTTP error. A block by robots.txt or a network
|
|
107
|
+
# error has no status and is not a finding about the site.
|
|
108
|
+
def code_findings
|
|
109
|
+
@snapshots.values.filter_map do |snapshot|
|
|
110
|
+
next unless snapshot.status && snapshot.status != 200
|
|
111
|
+
|
|
112
|
+
finding = Finding.new(language: snapshot.language, category: "http", effort: "bigger",
|
|
113
|
+
original: snapshot.url, suggested: HTTP_ADVICE, reason: snapshot.problem.to_s)
|
|
114
|
+
Merger::Merged.new(finding:, found_by: "code")
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def ignored?(finding) = @ignored.include?(Text.norm(finding.original).downcase)
|
|
119
|
+
|
|
120
|
+
# ---- matching ----
|
|
121
|
+
|
|
122
|
+
def best_match(finding)
|
|
123
|
+
hits = @entries.each_with_index.select { |entry, _index| matches?(entry, finding) }
|
|
124
|
+
hits.min_by { |entry, index| [PRIORITY.fetch(kind_of(entry)), index] }&.first
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def matches?(entry, finding)
|
|
128
|
+
Matching.family(entry.category) == Matching.family(finding.category) && Matching.same_spot?(entry, finding)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# :active (open or on staging), :done, or :held (rejected, or a status the tool does not know).
|
|
132
|
+
def kind_of(entry)
|
|
133
|
+
status = entry.status.empty? ? "open" : entry.status
|
|
134
|
+
return :active if ACTIVE.include?(status)
|
|
135
|
+
|
|
136
|
+
status == "done" ? :done : :held
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def entry_of(record)
|
|
140
|
+
language = record.language.to_s.strip.downcase
|
|
141
|
+
status = record.status.to_s.strip.downcase
|
|
142
|
+
verified = record.verified.to_s.strip.downcase
|
|
143
|
+
Entry.new(row: record.row, language:, category: record.category.to_s.strip, original: record.original.to_s,
|
|
144
|
+
status:, verified:, reason: record.reason.to_s, seen: record.amount_seen || 1)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# ---- what the page says ----
|
|
148
|
+
|
|
149
|
+
def check(finding)
|
|
150
|
+
return Check.new(presence: :exact, place: "n/a") if finding.category == "http"
|
|
151
|
+
|
|
152
|
+
verification = @sources[finding.language]&.verify(finding.original)
|
|
153
|
+
return Check.new(presence: :none, place: "") unless verification
|
|
154
|
+
|
|
155
|
+
Check.new(presence: verification.presence, place: verification.place.to_s)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# ---- closing ----
|
|
159
|
+
|
|
160
|
+
def close_resolved
|
|
161
|
+
@entries.each do |entry|
|
|
162
|
+
next unless entry.row && kind_of(entry) == :active && !@claimed[entry.row]
|
|
163
|
+
|
|
164
|
+
why = resolved_because(entry)
|
|
165
|
+
close(entry, why) if why
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def resolved_because(entry)
|
|
170
|
+
snapshot = @snapshots[entry.language]
|
|
171
|
+
return unless snapshot&.ok?
|
|
172
|
+
return "the page loads again" if entry.category == "http"
|
|
173
|
+
return unless entry.verified == "yes"
|
|
174
|
+
return skip_close(entry) unless snapshot.complete?
|
|
175
|
+
|
|
176
|
+
"its quoted text is no longer on the page" unless @sources[entry.language].presence(entry.original) == :exact
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def skip_close(entry)
|
|
180
|
+
return if @skipped[entry.language]
|
|
181
|
+
|
|
182
|
+
@skipped[entry.language] = true
|
|
183
|
+
@decisions << Decision.new(kind: :close_skipped, summary: "#{entry.language} version",
|
|
184
|
+
reason: "the page looks incomplete, so open suggestions in this language " \
|
|
185
|
+
"were not closed")
|
|
186
|
+
nil
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def close(entry, why)
|
|
190
|
+
reason = cut("[Auto-closed #{@now.strftime('%Y-%m-%d')}: #{why}] #{entry.reason}")
|
|
191
|
+
@updates[entry.row] = { status: "done", reason:, last_seen: @now }
|
|
192
|
+
@decisions << Decision.new(kind: :closed, summary: describe(entry), reason: why)
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# ---- the plan ----
|
|
196
|
+
|
|
197
|
+
def rank(merged) = [RANK.fetch(merged.found_by, 2), merged.finding.effort == "quick" ? 0 : 1]
|
|
198
|
+
|
|
199
|
+
def ranked
|
|
200
|
+
ordered = @additions.each_with_index.sort_by { |addition, index| [addition.rank, index] }
|
|
201
|
+
ordered.map { |addition, _index| addition.record }
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def note(kind, item, reason)
|
|
205
|
+
@decisions << Decision.new(kind:, summary: describe(item), reason:)
|
|
206
|
+
nil
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def describe(item)
|
|
210
|
+
text = item.original.to_s
|
|
211
|
+
text = "#{text[0, MAX_SUMMARY - 1]}…" if text.length > MAX_SUMMARY
|
|
212
|
+
"#{item.category} (#{item.language}): #{text}"
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def cut(text) = text[0, MAX_REASON]
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Yuno
|
|
4
|
+
module Proofreader
|
|
5
|
+
module Findings
|
|
6
|
+
# One language version of a page, prepared for searching. It answers two questions about a quoted text:
|
|
7
|
+
# is it really on the page, and under which headings does it sit?
|
|
8
|
+
class Source
|
|
9
|
+
# presence is :exact, :loose or :none. place is the heading path, or nil when it is not known.
|
|
10
|
+
Verification = Data.define(:presence, :place) do
|
|
11
|
+
def exact? = presence == :exact
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
attr_reader :html
|
|
15
|
+
|
|
16
|
+
def self.from_snapshot(snapshot) = new(snapshot.html)
|
|
17
|
+
|
|
18
|
+
def initialize(html)
|
|
19
|
+
@html = Text.collapse(html)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def outline = @outline ||= Outline.new(@html)
|
|
23
|
+
|
|
24
|
+
# :exact when the text is on the page as it stands, apart from runs of whitespace and the way entities
|
|
25
|
+
# are written. :loose when it only matches with every space ignored, for example after a spacing-only
|
|
26
|
+
# change. :none when it is not there. Capital letters count.
|
|
27
|
+
def presence(quote)
|
|
28
|
+
text = quote.to_s.strip
|
|
29
|
+
return :none if text.empty?
|
|
30
|
+
return :exact if exact?(text)
|
|
31
|
+
|
|
32
|
+
loose?(text) ? :loose : :none
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# The headings the first occurrence of the text is under, or nil. The page is searched as sent, so a
|
|
36
|
+
# quote that only matches once entities such as é are decoded has no place.
|
|
37
|
+
def place(quote)
|
|
38
|
+
index = position(quote)
|
|
39
|
+
index && outline.path_at(index)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def verify(quote)
|
|
43
|
+
found = presence(quote)
|
|
44
|
+
Verification.new(presence: found, place: found == :none ? nil : place(quote))
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def position(quote)
|
|
50
|
+
text = Text.collapse(quote)
|
|
51
|
+
return if text.empty?
|
|
52
|
+
|
|
53
|
+
[text, Text.escape(text)].uniq.filter_map { |candidate| @html.index(candidate) }.min
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def exact?(text)
|
|
57
|
+
plain = Text.collapse(text)
|
|
58
|
+
decoded = Text.norm(text)
|
|
59
|
+
@html.include?(plain) || decoded_html.include?(decoded) || visible.include?(decoded)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def loose?(text)
|
|
63
|
+
squashed = Text.norm(text).delete(" ")
|
|
64
|
+
return false if squashed.empty?
|
|
65
|
+
|
|
66
|
+
loose_html.include?(squashed) || loose_visible.include?(squashed)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def decoded_html = @decoded_html ||= Text.collapse(Text.decode(@html))
|
|
70
|
+
|
|
71
|
+
def visible = @visible ||= Text.collapse(Text.decode(Text.strip_tags(@html)))
|
|
72
|
+
|
|
73
|
+
def loose_html = @loose_html ||= decoded_html.delete(" ")
|
|
74
|
+
|
|
75
|
+
def loose_visible = @loose_visible ||= visible.delete(" ")
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "nokogiri"
|
|
4
|
+
|
|
5
|
+
module Yuno
|
|
6
|
+
module Proofreader
|
|
7
|
+
module Findings
|
|
8
|
+
# Text helpers for comparing a quoted text with a page: whitespace, HTML entities and tags.
|
|
9
|
+
module Text
|
|
10
|
+
SPACE = /[[:space:]]+/
|
|
11
|
+
ESCAPES = { "&" => "&", "<" => "<", ">" => ">", '"' => """, "'" => "'" }.freeze
|
|
12
|
+
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
# Runs of whitespace (non-breaking spaces included) become one space, and the ends are trimmed.
|
|
16
|
+
def collapse(string) = string.to_s.gsub(SPACE, " ").strip
|
|
17
|
+
|
|
18
|
+
# Turns HTML entities into the characters they stand for. Tags are left exactly as they are.
|
|
19
|
+
def decode(string)
|
|
20
|
+
escaped = string.to_s.gsub("<", "<").gsub(">", ">")
|
|
21
|
+
Nokogiri::HTML4.fragment("<div>#{escaped}</div>").text
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Entities decoded and whitespace collapsed: the form in which two texts are compared.
|
|
25
|
+
def norm(string) = collapse(decode(string))
|
|
26
|
+
|
|
27
|
+
# Entities decoded and all whitespace removed: the loosest form of comparing.
|
|
28
|
+
def squash(string) = decode(string).gsub(SPACE, "")
|
|
29
|
+
|
|
30
|
+
def strip_tags(html) = html.to_s.gsub(/<[^>]*>/m, "")
|
|
31
|
+
|
|
32
|
+
# The way a server writes a text into HTML, for finding a quote in the markup.
|
|
33
|
+
def escape(string) = string.to_s.gsub(/[&<>"']/, ESCAPES)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|