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,131 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "openssl"
|
|
5
|
+
require "timeout"
|
|
6
|
+
require "uri"
|
|
7
|
+
require "zlib"
|
|
8
|
+
|
|
9
|
+
require_relative "net_http_transport"
|
|
10
|
+
|
|
11
|
+
module Yuno
|
|
12
|
+
module Proofreader
|
|
13
|
+
module Http
|
|
14
|
+
PRODUCT_TOKEN = "yuno-proofreader"
|
|
15
|
+
DEFAULT_USER_AGENT = "#{PRODUCT_TOKEN}/#{VERSION} (+https://gitlab.com/alsen-ca/yuno-proofreader)".freeze
|
|
16
|
+
|
|
17
|
+
Settings = Data.define(:user_agent, :delay, :max_redirects, :timeout, :max_bytes) do
|
|
18
|
+
def initialize(user_agent: DEFAULT_USER_AGENT, delay: 0.5, max_redirects: 3,
|
|
19
|
+
timeout: 30, max_bytes: 5_000_000)
|
|
20
|
+
super
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
Response = Data.define(:url, :status, :body, :content_type) do
|
|
25
|
+
def success? = status.between?(200, 299)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Fetches web pages politely: it says who it is, waits between requests, follows a few redirects and
|
|
29
|
+
# never reads more than max_bytes. It returns the answer for any HTTP status, error statuses included,
|
|
30
|
+
# and raises FetchError only when no answer could be obtained.
|
|
31
|
+
class Client
|
|
32
|
+
REDIRECTS = [301, 302, 303, 307, 308].freeze
|
|
33
|
+
ACCEPT = "text/html,application/xhtml+xml,application/xml;q=0.9,text/plain;q=0.8,*/*;q=0.5"
|
|
34
|
+
NETWORK_ERRORS = [
|
|
35
|
+
SocketError, SystemCallError, IOError, Timeout::Error, OpenSSL::SSL::SSLError,
|
|
36
|
+
Net::ProtocolError, Net::HTTPBadResponse, Zlib::Error
|
|
37
|
+
].freeze
|
|
38
|
+
REAL_SLEEP = ->(seconds) { sleep(seconds) }
|
|
39
|
+
REAL_CLOCK = -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
|
|
40
|
+
|
|
41
|
+
def initialize(settings: Settings.new, transport: NetHttpTransport.new,
|
|
42
|
+
sleeper: REAL_SLEEP, clock: REAL_CLOCK)
|
|
43
|
+
@settings = settings
|
|
44
|
+
@transport = transport
|
|
45
|
+
@sleeper = sleeper
|
|
46
|
+
@clock = clock
|
|
47
|
+
@last_request_at = nil
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def get(url)
|
|
51
|
+
uri = parse(url)
|
|
52
|
+
(@settings.max_redirects + 1).times do
|
|
53
|
+
pace
|
|
54
|
+
raw = request(uri)
|
|
55
|
+
target = redirect_target(uri, raw)
|
|
56
|
+
return response_from(uri, raw) unless target
|
|
57
|
+
|
|
58
|
+
uri = target
|
|
59
|
+
end
|
|
60
|
+
raise FetchError, "Too many redirects for #{url}"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
def parse(url)
|
|
66
|
+
uri = URI.parse(url.to_s.strip)
|
|
67
|
+
return uri if %w[http https].include?(uri.scheme) && !uri.host.to_s.empty?
|
|
68
|
+
|
|
69
|
+
raise FetchError, "Not a web address: #{url.inspect}"
|
|
70
|
+
rescue URI::InvalidURIError
|
|
71
|
+
raise FetchError, "Not a valid web address: #{url.inspect}"
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Waits so that at least settings.delay seconds pass between two requests.
|
|
75
|
+
def pace
|
|
76
|
+
now = @clock.call
|
|
77
|
+
if @last_request_at
|
|
78
|
+
wait = @settings.delay - (now - @last_request_at)
|
|
79
|
+
if wait.positive?
|
|
80
|
+
@sleeper.call(wait)
|
|
81
|
+
now += wait
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
@last_request_at = now
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def request(uri)
|
|
88
|
+
raw = @transport.call(uri, headers: request_headers, **limits)
|
|
89
|
+
raw.with(headers: raw.headers.transform_keys { |name| name.to_s.downcase })
|
|
90
|
+
rescue *NETWORK_ERRORS => e
|
|
91
|
+
raise FetchError, "Could not fetch #{uri}: #{e.message}"
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def request_headers
|
|
95
|
+
{ "User-Agent" => @settings.user_agent, "Accept" => ACCEPT }
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def limits
|
|
99
|
+
{ timeout: @settings.timeout, max_bytes: @settings.max_bytes }
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def redirect_target(uri, raw)
|
|
103
|
+
return unless REDIRECTS.include?(raw.status)
|
|
104
|
+
|
|
105
|
+
location = raw.headers["location"].to_s.strip
|
|
106
|
+
raise FetchError, "#{uri} redirects without saying where to" if location.empty?
|
|
107
|
+
|
|
108
|
+
parse(URI.join(uri.to_s, location).to_s)
|
|
109
|
+
rescue URI::Error
|
|
110
|
+
raise FetchError, "#{uri} redirects to an invalid address"
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def response_from(uri, raw)
|
|
114
|
+
type = raw.headers["content-type"].to_s
|
|
115
|
+
Response.new(url: uri.to_s, status: raw.status, body: decode(raw.body, type), content_type: type)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# The body as clean UTF-8: the charset from the Content-Type header if there is one, otherwise UTF-8.
|
|
119
|
+
def decode(body, content_type)
|
|
120
|
+
charset = content_type[/charset=["']?([\w-]+)/i, 1]
|
|
121
|
+
text = body.dup
|
|
122
|
+
return text.force_encoding(Encoding::UTF_8).scrub if charset.nil? || charset.casecmp?("utf-8")
|
|
123
|
+
|
|
124
|
+
text.force_encoding(charset).encode(Encoding::UTF_8, invalid: :replace, undef: :replace)
|
|
125
|
+
rescue ArgumentError, EncodingError
|
|
126
|
+
body.dup.force_encoding(Encoding::UTF_8).scrub
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "uri"
|
|
5
|
+
|
|
6
|
+
module Yuno
|
|
7
|
+
module Proofreader
|
|
8
|
+
module Http
|
|
9
|
+
# What a transport returns: the raw status, the headers (lowercase names) and the body.
|
|
10
|
+
RawResponse = Data.define(:status, :headers, :body)
|
|
11
|
+
|
|
12
|
+
# Sends one GET request with Net::HTTP and reads at most max_bytes of the answer.
|
|
13
|
+
# It follows no redirects and repairs no encodings. The client does both.
|
|
14
|
+
class NetHttpTransport
|
|
15
|
+
def call(uri, headers:, timeout:, max_bytes:)
|
|
16
|
+
raw = nil
|
|
17
|
+
http(uri, timeout).request(Net::HTTP::Get.new(uri, headers)) do |response|
|
|
18
|
+
raw = RawResponse.new(status: response.code.to_i, headers: headers_of(response),
|
|
19
|
+
body: read_limited(response, max_bytes))
|
|
20
|
+
end
|
|
21
|
+
raw
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def http(uri, timeout)
|
|
27
|
+
Net::HTTP.new(uri.host, uri.port).tap do |http|
|
|
28
|
+
http.use_ssl = uri.scheme == "https"
|
|
29
|
+
http.open_timeout = timeout
|
|
30
|
+
http.read_timeout = timeout
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def headers_of(response)
|
|
35
|
+
response.to_hash.transform_values { |values| values.join(", ") }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def read_limited(response, max_bytes)
|
|
39
|
+
raise too_large(max_bytes) if response["content-length"].to_i > max_bytes
|
|
40
|
+
|
|
41
|
+
buffer = String.new(encoding: Encoding::BINARY)
|
|
42
|
+
response.read_body do |chunk|
|
|
43
|
+
buffer << chunk
|
|
44
|
+
raise too_large(max_bytes) if buffer.bytesize > max_bytes
|
|
45
|
+
end
|
|
46
|
+
buffer
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def too_large(max_bytes)
|
|
50
|
+
FetchError.new("The answer is larger than #{max_bytes} bytes")
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
module Yuno
|
|
6
|
+
module Proofreader
|
|
7
|
+
module Http
|
|
8
|
+
# The rules of a robots.txt for one crawler name. Rules are matched the way search engines do:
|
|
9
|
+
# the longest matching pattern wins and Allow wins a tie. Percent-encoding is not normalized.
|
|
10
|
+
class Robots
|
|
11
|
+
Rule = Data.define(:allow, :pattern, :regexp)
|
|
12
|
+
|
|
13
|
+
def self.allow_all = new([])
|
|
14
|
+
|
|
15
|
+
# Uses the group that names this crawler, or else the group for all crawlers (*).
|
|
16
|
+
def self.parse(text, agent:)
|
|
17
|
+
groups = groups_in(text.to_s)
|
|
18
|
+
name = agent.to_s.downcase
|
|
19
|
+
chosen = groups.select { |group| group[:agents].include?(name) }
|
|
20
|
+
chosen = groups.select { |group| group[:agents].include?("*") } if chosen.empty?
|
|
21
|
+
new(chosen.flat_map { |group| group[:rules] }.filter_map { |allow, value| rule(allow, value) })
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def self.groups_in(text)
|
|
25
|
+
groups = []
|
|
26
|
+
after_agent = false
|
|
27
|
+
fields_in(text).each do |field, value|
|
|
28
|
+
if field == "user-agent"
|
|
29
|
+
groups << { agents: [], rules: [] } unless after_agent
|
|
30
|
+
groups.last[:agents] << value.downcase
|
|
31
|
+
after_agent = true
|
|
32
|
+
elsif %w[allow disallow].include?(field)
|
|
33
|
+
groups.last[:rules] << [field == "allow", value] unless groups.empty?
|
|
34
|
+
after_agent = false
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
groups
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# The "field: value" pairs of the file, without comments and blank or malformed lines.
|
|
41
|
+
def self.fields_in(text)
|
|
42
|
+
text.each_line.filter_map do |line|
|
|
43
|
+
field, value = line.sub(/#.*/, "").strip.split(/\s*:\s*/, 2)
|
|
44
|
+
[field.downcase, value] if field && value
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def self.rule(allow, value)
|
|
49
|
+
return if value.empty?
|
|
50
|
+
|
|
51
|
+
source = value.chomp("$").split("*", -1).map { |part| Regexp.escape(part) }.join(".*")
|
|
52
|
+
anchor = value.end_with?("$") ? "\\z" : ""
|
|
53
|
+
Rule.new(allow:, pattern: value, regexp: Regexp.new("\\A#{source}#{anchor}"))
|
|
54
|
+
end
|
|
55
|
+
private_class_method :groups_in, :fields_in, :rule
|
|
56
|
+
|
|
57
|
+
def initialize(rules)
|
|
58
|
+
@rules = rules
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def allowed?(url)
|
|
62
|
+
uri = URI.parse(url.to_s)
|
|
63
|
+
target = uri.path.to_s.empty? ? "/" : uri.path
|
|
64
|
+
target += "?#{uri.query}" if uri.query
|
|
65
|
+
best = @rules.select { |rule| rule.regexp.match?(target) }
|
|
66
|
+
.max_by { |rule| [rule.pattern.length, rule.allow ? 1 : 0] }
|
|
67
|
+
best.nil? || best.allow
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Yuno
|
|
4
|
+
module Proofreader
|
|
5
|
+
module Init
|
|
6
|
+
# Asks questions on a terminal and reads the answers. Without a terminal it must not be used to ask:
|
|
7
|
+
# interactive? says which it is.
|
|
8
|
+
class Ask
|
|
9
|
+
def initialize(input:, out:)
|
|
10
|
+
@input = input
|
|
11
|
+
@out = out
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def interactive? = @input.respond_to?(:tty?) && @input.tty?
|
|
15
|
+
|
|
16
|
+
# One line of text. An empty answer gives the default, which may be nil.
|
|
17
|
+
def line(question, default: nil)
|
|
18
|
+
@out.print("#{question}#{" [#{default}]" if default}: ")
|
|
19
|
+
answer = read
|
|
20
|
+
answer.empty? ? default : answer
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Asks until something is typed.
|
|
24
|
+
def required(question, default: nil)
|
|
25
|
+
loop do
|
|
26
|
+
answer = line(question, default:)
|
|
27
|
+
return answer unless answer.nil? || answer.empty?
|
|
28
|
+
|
|
29
|
+
@out.puts "An answer is needed here."
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def yes?(question, default: true)
|
|
34
|
+
loop do
|
|
35
|
+
answer = line("#{question} #{default ? '[Y/n]' : '[y/N]'}").to_s.downcase
|
|
36
|
+
return default if answer.empty?
|
|
37
|
+
return answer.start_with?("y") if %w[y yes n no].include?(answer)
|
|
38
|
+
|
|
39
|
+
@out.puts "Please answer y or n."
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Lines until an empty one. An empty list means the question was skipped.
|
|
44
|
+
def list(question)
|
|
45
|
+
@out.puts "#{question} One per line, an empty line to finish."
|
|
46
|
+
lines = []
|
|
47
|
+
loop do
|
|
48
|
+
@out.print(" > ")
|
|
49
|
+
answer = read
|
|
50
|
+
return lines if answer.empty?
|
|
51
|
+
|
|
52
|
+
lines << answer
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def read
|
|
59
|
+
text = @input.gets
|
|
60
|
+
raise Error, "The input ended before all the questions were answered." if text.nil?
|
|
61
|
+
|
|
62
|
+
text.strip
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "nokogiri"
|
|
4
|
+
|
|
5
|
+
module Yuno
|
|
6
|
+
module Proofreader
|
|
7
|
+
module Init
|
|
8
|
+
# What a homepage says about the languages of its site. languages is a list of { "code", "prefix" } with the
|
|
9
|
+
# default language first. origin is where the page was found (scheme and host), description its meta
|
|
10
|
+
# description when it is short enough to suggest, and problems say what could not be used.
|
|
11
|
+
Detection = Data.define(:languages, :origin, :description, :problems)
|
|
12
|
+
|
|
13
|
+
# Reads the language versions a homepage announces with its hreflang tags. The tool tells languages apart by
|
|
14
|
+
# the start of the address (/es/...), so a version on another host, or deeper than one path segment, is
|
|
15
|
+
# reported and left out.
|
|
16
|
+
class Detector
|
|
17
|
+
CODE = /\A[a-z]{2,3}(-[a-z0-9]{2,8})*\z/
|
|
18
|
+
ALTERNATES = 'link[rel~="alternate"][hreflang]'
|
|
19
|
+
MAX_DESCRIPTION = 300
|
|
20
|
+
|
|
21
|
+
def self.call(html, url:, website:) = new(html, url, website).call
|
|
22
|
+
|
|
23
|
+
def initialize(html, url, website)
|
|
24
|
+
@doc = Nokogiri::HTML(html.to_s)
|
|
25
|
+
@url = url
|
|
26
|
+
@website = website
|
|
27
|
+
@problems = []
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def call
|
|
31
|
+
languages = ordered(distinct(versions))
|
|
32
|
+
Detection.new(languages:, origin: Parsing.origin(@url), description:, problems: @problems)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def alternates
|
|
38
|
+
@doc.css(ALTERNATES).filter_map do |link|
|
|
39
|
+
code = link["hreflang"].to_s.strip.downcase
|
|
40
|
+
href = Analysis::Links.absolute(@url, link["href"])
|
|
41
|
+
[code, href] if !code.empty? && href
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def versions
|
|
46
|
+
found = alternates.reject { |pair| pair.first == "x-default" }.uniq(&:first)
|
|
47
|
+
found.filter_map { |code, href| version(code, href) }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# { "code", "prefix" } for a version, or nil with the reason noted.
|
|
51
|
+
def version(code, href)
|
|
52
|
+
return note("#{code}: not a language code the tool can use") unless code.match?(CODE)
|
|
53
|
+
return note("#{code}: its address #{href} is on another host") unless Analysis::Links.same_site?(href,
|
|
54
|
+
@website)
|
|
55
|
+
|
|
56
|
+
parts = Analysis::Links.segments(href)
|
|
57
|
+
return note("#{code}: its address #{href} is deeper than a language prefix") if parts.size > 1
|
|
58
|
+
|
|
59
|
+
{ "code" => code, "prefix" => parts.empty? ? "" : "/#{parts.first}" }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def note(message)
|
|
63
|
+
@problems << message
|
|
64
|
+
nil
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Two versions cannot share a prefix: the first one is kept.
|
|
68
|
+
def distinct(list)
|
|
69
|
+
kept = {}
|
|
70
|
+
list.each do |entry|
|
|
71
|
+
first = kept[entry["prefix"]]
|
|
72
|
+
if first
|
|
73
|
+
note("#{entry['code']}: shares its address prefix with #{first['code']}")
|
|
74
|
+
else
|
|
75
|
+
kept[entry["prefix"]] =
|
|
76
|
+
entry
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
kept.values
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# The default language first: the one without a prefix, else the one x-default points to, else the first.
|
|
83
|
+
def ordered(list)
|
|
84
|
+
default = list.find { |entry| entry["prefix"].empty? } || x_default(list) || list.first
|
|
85
|
+
default ? [default, *(list - [default])] : []
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def x_default(list)
|
|
89
|
+
target = alternates.find { |code, _href| code == "x-default" }&.last
|
|
90
|
+
target && list.find { |entry| Analysis::Links.key(target) == Analysis::Links.key("#{@website}#{entry['prefix']}") }
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def description
|
|
94
|
+
text = Findings::Text.collapse(@doc.at_css('meta[name="description"]')&.[]("content"))
|
|
95
|
+
text unless text.empty? || text.length > MAX_DESCRIPTION
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Yuno
|
|
4
|
+
module Proofreader
|
|
5
|
+
module Init
|
|
6
|
+
# Works out the settings of a new site: from the command's options, from what the homepage says about itself
|
|
7
|
+
# and, on a terminal, by asking. Only the address and the languages are needed to write a file, the business
|
|
8
|
+
# description is needed before an audit, and everything else is optional.
|
|
9
|
+
class Interview
|
|
10
|
+
LISTS = {
|
|
11
|
+
"skip_paths" => ["skip-path",
|
|
12
|
+
"Pages you do not want audited, written as in the address (for example /thank-you)."],
|
|
13
|
+
"rules" => ["rule", "Writing rules, such as how prices or dates are written."],
|
|
14
|
+
"ignore_terms" => ["ignore-term", "Words that are correct as written and must never be flagged: brand " \
|
|
15
|
+
"names, place names, local words."],
|
|
16
|
+
"intentional_differences" => ["difference", "Differences between language versions that are intended, " \
|
|
17
|
+
"such as a section that exists in one language only."]
|
|
18
|
+
}.freeze
|
|
19
|
+
LANGUAGE_QUESTION = "Languages, the default first: a code, or code:/prefix (for example de, es:/es, en:/en)"
|
|
20
|
+
|
|
21
|
+
# flags: the options (Flags::Parsed). web: looks things up. offline: make no request. yes: accept what was
|
|
22
|
+
# detected, and ask nothing.
|
|
23
|
+
def initialize(flags:, ask:, web:, out:, offline: false, yes: false)
|
|
24
|
+
@flags = flags
|
|
25
|
+
@ask = ask
|
|
26
|
+
@web = web
|
|
27
|
+
@out = out
|
|
28
|
+
@offline = offline
|
|
29
|
+
@yes = yes
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# The settings as a hash with string keys, in the shape of the settings file.
|
|
33
|
+
def call
|
|
34
|
+
intro
|
|
35
|
+
origin = website
|
|
36
|
+
detection = read_homepage(origin)
|
|
37
|
+
origin = better_origin(origin, detection)
|
|
38
|
+
settings = { "website" => origin, "languages" => languages(detection), **about(detection), **lists }
|
|
39
|
+
settings["sitemap_url"] = @flags.values["sitemap"] if @flags.values["sitemap"]
|
|
40
|
+
check_sitemap(settings)
|
|
41
|
+
settings
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def asking? = @ask.interactive? && !@yes
|
|
47
|
+
|
|
48
|
+
def intro
|
|
49
|
+
return unless asking?
|
|
50
|
+
|
|
51
|
+
@out.puts "A few questions to write your settings file. An empty answer skips a question that is optional."
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def accept?(question) = asking? ? @ask.yes?(question) : true
|
|
55
|
+
|
|
56
|
+
# ---- the address ----
|
|
57
|
+
|
|
58
|
+
def website
|
|
59
|
+
given = @flags.values["website"]
|
|
60
|
+
if given
|
|
61
|
+
return Parsing.origin(given) || raise(ConfigError,
|
|
62
|
+
"--website is not a web address (got #{given.inspect})")
|
|
63
|
+
end
|
|
64
|
+
raise ConfigError, "Say which site to set up: --website ADDRESS" unless @ask.interactive?
|
|
65
|
+
|
|
66
|
+
loop do
|
|
67
|
+
origin = Parsing.origin(@ask.required("Address of the site (for example example.com)"))
|
|
68
|
+
return origin if origin
|
|
69
|
+
|
|
70
|
+
@out.puts "That is not a web address."
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def read_homepage(origin)
|
|
75
|
+
return if @offline
|
|
76
|
+
|
|
77
|
+
html, url = @web.homepage(origin)
|
|
78
|
+
Detector.call(html, url:, website: origin)
|
|
79
|
+
rescue Error => e
|
|
80
|
+
@out.puts "The homepage could not be read (#{e.message}), so nothing could be detected from it."
|
|
81
|
+
nil
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def better_origin(origin, detection)
|
|
85
|
+
found = detection&.origin
|
|
86
|
+
return origin if found.nil? || found == origin
|
|
87
|
+
|
|
88
|
+
accept?("#{origin} leads to #{found}. Use #{found} as the address of the site?") ? found : origin
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# ---- the languages ----
|
|
92
|
+
|
|
93
|
+
def languages(detection)
|
|
94
|
+
given = @flags.lists["language"]
|
|
95
|
+
return with_notes(checked(given.map { |item| Parsing.language(item) })) unless given.empty?
|
|
96
|
+
|
|
97
|
+
detected = Array(detection&.languages)
|
|
98
|
+
return with_notes(detected) if !detected.empty? && announce(detection) && accept?("Use these languages?")
|
|
99
|
+
|
|
100
|
+
with_notes(typed_languages)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def checked(list)
|
|
104
|
+
problem = Parsing.language_problem(list)
|
|
105
|
+
raise ConfigError, "--language: #{problem}" if problem
|
|
106
|
+
|
|
107
|
+
list
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def announce(detection)
|
|
111
|
+
@out.puts "The homepage announces these languages (the first is the default):"
|
|
112
|
+
detection.languages.each do |entry|
|
|
113
|
+
@out.puts " #{entry['code']} #{entry['prefix'].empty? ? '(no prefix)' : entry['prefix']}"
|
|
114
|
+
end
|
|
115
|
+
detection.problems.each { |problem| @out.puts " Left out: #{problem}" }
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def typed_languages
|
|
119
|
+
unless @ask.interactive?
|
|
120
|
+
raise ConfigError, "Say which languages the site has: --language CODE[:PREFIX], once for each, the " \
|
|
121
|
+
"default first"
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
loop do
|
|
125
|
+
list = Parsing.languages(@ask.required(LANGUAGE_QUESTION))
|
|
126
|
+
problem = Parsing.language_problem(list)
|
|
127
|
+
return list unless problem
|
|
128
|
+
|
|
129
|
+
@out.puts "Not accepted: #{problem}"
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def with_notes(list)
|
|
134
|
+
given = @flags.lists["note"].to_h do |item|
|
|
135
|
+
item.split("=", 2).then do |code, text|
|
|
136
|
+
[code.to_s.strip.downcase, text.to_s.strip]
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
list.map do |language|
|
|
140
|
+
note = given.fetch(language["code"]) { ask_note(language["code"]) }
|
|
141
|
+
note.to_s.empty? ? language : language.merge("notes" => note)
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def ask_note(code)
|
|
146
|
+
@ask.line("Notes for #{code}: its variant and form of address (Enter to skip)") if asking?
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# ---- the site ----
|
|
150
|
+
|
|
151
|
+
def about(detection)
|
|
152
|
+
{ "business" => text("business", "What the site is and what it offers, in a sentence or two",
|
|
153
|
+
required: true, suggestion: detection&.description),
|
|
154
|
+
"audience" => text("audience", "Who reads the site (Enter to skip)"),
|
|
155
|
+
"tone" => text("tone", "How the text should sound, such as formal or warm (Enter to skip)") }
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def text(name, question, required: false, suggestion: nil)
|
|
159
|
+
given = @flags.values[name]
|
|
160
|
+
return given.strip if given
|
|
161
|
+
return (suggestion if @yes) unless asking?
|
|
162
|
+
|
|
163
|
+
required ? @ask.required(question, default: suggestion) : @ask.line(question)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def lists
|
|
167
|
+
LISTS.to_h { |setting, (flag, question)| [setting, list(flag, question)] }
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def list(flag, question)
|
|
171
|
+
given = @flags.lists[flag]
|
|
172
|
+
return given.map(&:strip).reject(&:empty?) unless given.empty?
|
|
173
|
+
|
|
174
|
+
asking? ? @ask.list(question) : []
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# ---- the sitemap ----
|
|
178
|
+
|
|
179
|
+
# Looks for the sitemap. When none is found and there is someone to ask, asks for its address once.
|
|
180
|
+
def check_sitemap(settings)
|
|
181
|
+
return if @offline
|
|
182
|
+
|
|
183
|
+
2.times do
|
|
184
|
+
return if sitemap_found?(settings) || !asking?
|
|
185
|
+
|
|
186
|
+
address = @ask.line("Address of your sitemap (Enter to skip)")
|
|
187
|
+
return if address.nil? || address.empty?
|
|
188
|
+
|
|
189
|
+
settings["sitemap_url"] = address
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def sitemap_found?(settings)
|
|
194
|
+
count, paired = @web.sitemap(settings)
|
|
195
|
+
@out.puts sitemap_line(count, paired)
|
|
196
|
+
true
|
|
197
|
+
rescue Error => e
|
|
198
|
+
@out.puts "No usable sitemap was found (#{e.message})."
|
|
199
|
+
false
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def sitemap_line(count, paired)
|
|
203
|
+
return "Found a sitemap: #{count} pages, paired by language." if paired
|
|
204
|
+
|
|
205
|
+
"Found a sitemap, but its pages could not be paired by language. Run: yuno-proofreader sitemap"
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
module Yuno
|
|
6
|
+
module Proofreader
|
|
7
|
+
module Init
|
|
8
|
+
# Turns what a person typed, or gave as an option, into settings.
|
|
9
|
+
module Parsing
|
|
10
|
+
SCHEME = %r{\A[a-z][a-z0-9+.-]*://}i
|
|
11
|
+
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
# "example.com" or "https://example.com/en" give "https://example.com". Anything that is not a web address
|
|
15
|
+
# gives nil.
|
|
16
|
+
def origin(text)
|
|
17
|
+
value = text.to_s.strip
|
|
18
|
+
return if value.empty?
|
|
19
|
+
|
|
20
|
+
value = "https://#{value}" unless value.match?(SCHEME)
|
|
21
|
+
uri = URI.parse(value)
|
|
22
|
+
return unless %w[http https].include?(uri.scheme) && !uri.host.to_s.empty?
|
|
23
|
+
|
|
24
|
+
"#{uri.scheme}://#{uri.host}#{":#{uri.port}" unless uri.port == uri.default_port}"
|
|
25
|
+
rescue URI::Error
|
|
26
|
+
nil
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# "es:/es" gives code es with the prefix /es, and "de" gives code de with no prefix.
|
|
30
|
+
def language(item)
|
|
31
|
+
code, prefix = item.to_s.split(":", 2)
|
|
32
|
+
{ "code" => code.to_s.strip.downcase, "prefix" => prefix.to_s.strip }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def languages(text) = text.to_s.split(",").map(&:strip).reject(&:empty?).map { |item| language(item) }
|
|
36
|
+
|
|
37
|
+
# What is wrong with a list of languages, in the words of the settings check, or nil when it is fine.
|
|
38
|
+
def language_problem(list)
|
|
39
|
+
Site.from_hash("website" => "https://example.com", "languages" => list)
|
|
40
|
+
nil
|
|
41
|
+
rescue ConfigError => e
|
|
42
|
+
e.message
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|