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,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 &eacute; 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 = { "&" => "&amp;", "<" => "&lt;", ">" => "&gt;", '"' => "&quot;", "'" => "&#39;" }.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("<", "&lt;").gsub(">", "&gt;")
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