nokodown 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 +12 -0
- data/LICENSE +202 -0
- data/NOTICE +10 -0
- data/README.md +246 -0
- data/lib/nokodown/boilerplate.rb +236 -0
- data/lib/nokodown/charset.rb +27 -0
- data/lib/nokodown/container_check.rb +174 -0
- data/lib/nokodown/content_locator.rb +118 -0
- data/lib/nokodown/dom.rb +140 -0
- data/lib/nokodown/extractor.rb +137 -0
- data/lib/nokodown/markdown/cleanup.rb +111 -0
- data/lib/nokodown/markdown/code_block.rb +71 -0
- data/lib/nokodown/markdown/image.rb +63 -0
- data/lib/nokodown/markdown/image_cleanup.rb +166 -0
- data/lib/nokodown/markdown/inline.rb +340 -0
- data/lib/nokodown/markdown/redundant_images.rb +142 -0
- data/lib/nokodown/markdown/renderer.rb +339 -0
- data/lib/nokodown/markdown/table.rb +93 -0
- data/lib/nokodown/markdown/text.rb +79 -0
- data/lib/nokodown/metadata.rb +316 -0
- data/lib/nokodown/patterns.rb +152 -0
- data/lib/nokodown/preprocessor.rb +226 -0
- data/lib/nokodown/product_reviews.rb +90 -0
- data/lib/nokodown/pruner.rb +337 -0
- data/lib/nokodown/result.rb +8 -0
- data/lib/nokodown/scorer.rb +230 -0
- data/lib/nokodown/stats.rb +79 -0
- data/lib/nokodown/tables.rb +62 -0
- data/lib/nokodown/url_resolver.rb +249 -0
- data/lib/nokodown/version.rb +5 -0
- data/lib/nokodown.rb +54 -0
- metadata +93 -0
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Nokodown
|
|
4
|
+
module Markdown
|
|
5
|
+
RenderedImage = Struct.new(:url, :redundant, :targets)
|
|
6
|
+
|
|
7
|
+
# Renders a run of inline nodes (text, emphasis, code, links, images) to
|
|
8
|
+
# one string. Line breaks from <br> are kept as "\n".
|
|
9
|
+
class Inline
|
|
10
|
+
EMPHASIS = { "strong" => "**", "b" => "**", "em" => "*", "i" => "*", "del" => "~~", "s" => "~~", "strike" => "~~" }.freeze
|
|
11
|
+
CODE = Set.new(%w[code kbd samp tt]).freeze
|
|
12
|
+
UNSAFE_SCHEME = /\A\s*(?:javascript|vbscript|data|tel|sms):/i.freeze
|
|
13
|
+
FOOTNOTE_MARKER = /\A\s*(?:\[[^\]]{1,8}\]|[*†‡§]+)\s*\z/.freeze
|
|
14
|
+
# Exponents are often written with the minus sign "−", not "-".
|
|
15
|
+
SCRIPT_TEXT = /\A[0-9+\-−=()]+(?:,[0-9+\-−=()]+)*\z/.freeze
|
|
16
|
+
SCRIPT_SOURCE = "0123456789+−=()-"
|
|
17
|
+
SUPERSCRIPTS = "⁰¹²³⁴⁵⁶⁷⁸⁹⁺⁻⁼⁽⁾⁻"
|
|
18
|
+
SUBSCRIPTS = "₀₁₂₃₄₅₆₇₈₉₊₋₌₍₎₋"
|
|
19
|
+
# A point or a comma only between digits or letters: 10^(2.5), a_(i,j).
|
|
20
|
+
SCRIPT_WORD = %r{\A(?:[\p{L}\p{N}+\-−=/()]|(?<=[\p{L}\p{N}])[.,](?=[\p{L}\p{N}]))+\z}.freeze
|
|
21
|
+
SCRIPT_OPERATOR = %r{[+\-−=/]}.freeze
|
|
22
|
+
GROUPED = /\A(\((?:[^()]|\g<1>)*\))\z/.freeze
|
|
23
|
+
# These have characters of their own (1º, nº, Acme™); a raised o before
|
|
24
|
+
# C or F is a degree (30°C).
|
|
25
|
+
RAISED_LETTERS = { "o" => "º", "TM" => "™", "SM" => "℠" }.freeze
|
|
26
|
+
DEGREE_UNIT = /\A\s?[CF]\b/.freeze
|
|
27
|
+
# English and French endings: 21st, 1er, 2nde, XIXe.
|
|
28
|
+
ORDINAL_SUFFIX = /\A(?:st|nd|rd|th|er|ers|re|res|e|es|ème|eme|nde|a|as|os|ª|do|da|ra|ro|ta|to)\z/i.freeze
|
|
29
|
+
NUMBER_END = /(?:\d|\b[IVXLCDM]+)\z/.freeze
|
|
30
|
+
# French titles: Mme, Mlle, Mgr.
|
|
31
|
+
TITLE_SUFFIX = /\A(?:me|mes|lle|lles|gr)\z/.freeze
|
|
32
|
+
PRICE_END = /(?:\p{Sc}|\b[A-Z]{3})\s?\d[\d,.]*\z/.freeze
|
|
33
|
+
DOT_GROUPED = /\d\.\d{3}\z/.freeze
|
|
34
|
+
MATH_BASE = /(?:\d|(?<![\p{L}\p{N}])\p{L})\z/.freeze
|
|
35
|
+
PRICE_START = /\A\s?\p{Sc}/.freeze
|
|
36
|
+
CENTS = /\A\d\d\z/.freeze
|
|
37
|
+
ATTACHED = /[\p{L}\p{N})\]]\z/.freeze
|
|
38
|
+
WORD_START = /\A[\p{L}\p{N}]/.freeze
|
|
39
|
+
WORD_END = /[\p{L}\p{N}]\z/.freeze
|
|
40
|
+
|
|
41
|
+
# Absolute URL worth linking to, or nil for in-page anchors, scripts and
|
|
42
|
+
# phone numbers.
|
|
43
|
+
def self.link_url(href, urls)
|
|
44
|
+
href = href.strip
|
|
45
|
+
return if href.empty? || href.start_with?("#") || href.match?(UNSAFE_SCHEME)
|
|
46
|
+
|
|
47
|
+
url = urls.resolve(href)
|
|
48
|
+
url unless url.nil? || urls.same_page?(url)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# redundant_images: RedundantImages#call.
|
|
52
|
+
def initialize(urls, images:, links:, redundant_images: {})
|
|
53
|
+
@urls = urls
|
|
54
|
+
@images = images
|
|
55
|
+
@links = links
|
|
56
|
+
@redundant_images = redundant_images
|
|
57
|
+
@rendered_images = {}
|
|
58
|
+
@emphasis = Hash.new(0)
|
|
59
|
+
@in_heading = false
|
|
60
|
+
@in_link = false
|
|
61
|
+
@link_images = nil
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Markup of every image written, for Cleanup to drop redundant images
|
|
65
|
+
# and repeats.
|
|
66
|
+
attr_reader :rendered_images
|
|
67
|
+
|
|
68
|
+
def render(nodes, heading: false)
|
|
69
|
+
outer = @in_heading
|
|
70
|
+
@in_heading ||= heading
|
|
71
|
+
nodes.map { |node| node_text(node) }.join
|
|
72
|
+
ensure
|
|
73
|
+
@in_heading = outer
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def link_url(href)
|
|
77
|
+
Inline.link_url(href, @urls)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
def node_text(node)
|
|
83
|
+
if node.text? || node.cdata?
|
|
84
|
+
Text.escape(Text.collapse(node.content))
|
|
85
|
+
elsif node.element?
|
|
86
|
+
element_text(node)
|
|
87
|
+
else
|
|
88
|
+
""
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def element_text(node)
|
|
93
|
+
name = node.name
|
|
94
|
+
return emphasis(node, EMPHASIS[name]) if EMPHASIS.key?(name)
|
|
95
|
+
return code_span(node) if CODE.include?(name)
|
|
96
|
+
|
|
97
|
+
case name
|
|
98
|
+
when "br" then "\n"
|
|
99
|
+
when "wbr" then ""
|
|
100
|
+
when "a" then link(node)
|
|
101
|
+
when "img" then image(node)
|
|
102
|
+
when "q" then "\"#{render(node.children)}\""
|
|
103
|
+
when "math" then math(node)
|
|
104
|
+
when "sup", "sub" then script(node)
|
|
105
|
+
when "input" then node.key?("checked") ? "[x] " : "[ ] "
|
|
106
|
+
else
|
|
107
|
+
text = render(node.children)
|
|
108
|
+
Dom::BLOCK.include?(name) ? " #{text} " : text
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Headings are bold already; nested emphasis of the same kind is dropped.
|
|
113
|
+
def emphasis(node, marker)
|
|
114
|
+
return render(node.children) if @emphasis[marker].positive? || (@in_heading && marker == "**")
|
|
115
|
+
|
|
116
|
+
@emphasis[marker] += 1
|
|
117
|
+
content = render(node.children)
|
|
118
|
+
@emphasis[marker] -= 1
|
|
119
|
+
core = content.strip
|
|
120
|
+
return content if core.empty? || core.match?(/\A[^\p{L}\p{N}]*\z/)
|
|
121
|
+
|
|
122
|
+
"#{content[/\A\s*/]}#{marker}#{core}#{marker}#{content[/\s*\z/]}"
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def code_span(node)
|
|
126
|
+
text = Text.collapse(node.content)
|
|
127
|
+
core = text.strip
|
|
128
|
+
return text.empty? ? "" : " " if core.empty?
|
|
129
|
+
|
|
130
|
+
"#{text[/\A\s*/]}#{Text.code_span(core)}#{text[/\s*\z/]}"
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Images inside a link are written before it rather than inside its text.
|
|
134
|
+
def link(node)
|
|
135
|
+
outer_images = @link_images
|
|
136
|
+
outer_link = @in_link
|
|
137
|
+
@link_images = []
|
|
138
|
+
@in_link = true
|
|
139
|
+
content = render(node.children)
|
|
140
|
+
images = @link_images
|
|
141
|
+
@link_images = outer_images
|
|
142
|
+
@in_link = outer_link
|
|
143
|
+
|
|
144
|
+
parts = images + [link_markup(Text.single_line(content), node["href"].to_s)]
|
|
145
|
+
surround_like(content, parts.reject(&:empty?).join(" "))
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def link_markup(text, href)
|
|
149
|
+
return "" if text.empty? || text.match?(Patterns::LINK_NOISE_TEXT)
|
|
150
|
+
return text if !@links || @in_link
|
|
151
|
+
|
|
152
|
+
url = link_url(href)
|
|
153
|
+
return text unless url
|
|
154
|
+
return url if same_as_url?(text, url)
|
|
155
|
+
|
|
156
|
+
"[#{Text.link_text(text)}](#{Text.destination(url)})"
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def same_as_url?(text, url)
|
|
160
|
+
bare(text.delete("\\")) == bare(url)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def bare(url)
|
|
164
|
+
url.sub(%r{\Ahttps?://}i, "").sub(/\Awww\./i, "").sub(/\Amailto:/i, "").chomp("/")
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def surround_like(original, rendered)
|
|
168
|
+
return rendered if rendered.empty?
|
|
169
|
+
|
|
170
|
+
leading = original.match?(/\A\s/) ? " " : ""
|
|
171
|
+
trailing = original.match?(/\s\z/) ? " " : ""
|
|
172
|
+
"#{leading}#{rendered}#{trailing}"
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def image(node)
|
|
176
|
+
alt = Dom.squish(node["alt"].to_s)
|
|
177
|
+
return Image.tex(alt) if node["class"].to_s.include?("mwe-math-fallback-image")
|
|
178
|
+
return alt if Image.emoji?(node, alt)
|
|
179
|
+
return "" if !@images || @in_heading
|
|
180
|
+
|
|
181
|
+
src = Image.source(node, @urls)
|
|
182
|
+
return "" if src.nil? || Image.decorative?(node, src, alt)
|
|
183
|
+
|
|
184
|
+
markup = "})"
|
|
185
|
+
record(markup, src, node)
|
|
186
|
+
return markup unless @link_images
|
|
187
|
+
|
|
188
|
+
@link_images << markup
|
|
189
|
+
""
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# An image is redundant only if every copy of it is. Targets are the
|
|
193
|
+
# pages whose text links make its thumbnail copies redundant.
|
|
194
|
+
def record(markup, src, node)
|
|
195
|
+
image = (@rendered_images[markup] ||= RenderedImage.new(src, true, Set.new))
|
|
196
|
+
key = Dom.key(node)
|
|
197
|
+
image.redundant &&= @redundant_images.key?(key)
|
|
198
|
+
target = @redundant_images[key]
|
|
199
|
+
image.targets << target if target
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# Markdown has no superscript, and "2attempt" is wrong. Digits and signs
|
|
203
|
+
# have Unicode forms (10⁶, H₂O); other raised and lowered words get a
|
|
204
|
+
# caret or an underscore (x^n, a_n). Footnote markers such as [1],
|
|
205
|
+
# symbols such as ® and small print read fine as plain text. The plain
|
|
206
|
+
# text decides, so 2<sup><i>p</i></sup> is 2^p like 2<sup>p</sup>.
|
|
207
|
+
def script(node)
|
|
208
|
+
text = render(node.children)
|
|
209
|
+
core = Text.collapse(node.content).strip
|
|
210
|
+
return text if core.empty? || core.match?(FOOTNOTE_MARKER) || node.at_css("a, img, math")
|
|
211
|
+
|
|
212
|
+
before = text_before(node) + text[/\A\s*/]
|
|
213
|
+
after = text[/\s*\z/] + adjacent(node, :next_sibling)&.content.to_s
|
|
214
|
+
return nested_script(node, text, core, before, after) if node.at_css("sup, sub")
|
|
215
|
+
|
|
216
|
+
marked = node.name == "sup" ? superscript(core, before, after, text_before(node, skip_blank: true)) : subscript(node, core, before, after)
|
|
217
|
+
marked ? surround_like(text, marked) : unmarked(text, core, before, after)
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def nested_script(node, text, core, before, after)
|
|
221
|
+
return unmarked(text, core, before, after) unless before.match?(MATH_BASE)
|
|
222
|
+
|
|
223
|
+
surround_like(text, "#{node.name == 'sup' ? '^' : subscript_mark(node, before)}(#{text.strip})")
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# Glued on, a raised or lowered word would merge with the words around
|
|
227
|
+
# it: Smitha, b for Smith<sup>a, b</sup>, aNote for <sup>a</sup>Note.
|
|
228
|
+
def unmarked(text, core, before, after)
|
|
229
|
+
leading = before.match?(ATTACHED) && core.match?(WORD_START) ? " " : ""
|
|
230
|
+
trailing = core.match?(WORD_END) && after.match?(WORD_START) ? " " : ""
|
|
231
|
+
"#{leading}#{text}#{trailing}"
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# Text of the node before, with the one before that when it is a bare
|
|
235
|
+
# amount (<sup>$</sup>549<sup>99</sup>).
|
|
236
|
+
def text_before(node, skip_blank: false)
|
|
237
|
+
previous = adjacent(node, :previous_sibling)
|
|
238
|
+
previous = filled(previous) if skip_blank
|
|
239
|
+
before = previous&.content.to_s
|
|
240
|
+
return before unless before.match?(/\A[\d,.]+\z/)
|
|
241
|
+
|
|
242
|
+
earlier = previous.previous_sibling
|
|
243
|
+
earlier = filled(earlier) if skip_blank
|
|
244
|
+
"#{earlier&.content}#{before}"
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def filled(node)
|
|
248
|
+
node = node.previous_sibling while node&.text? && node.content.strip.empty? && node.previous_sibling
|
|
249
|
+
node
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
# The node right before or after this one, looking past inline wrappers
|
|
253
|
+
# that it opens or closes (21<span><sup>st</sup></span>).
|
|
254
|
+
def adjacent(node, direction)
|
|
255
|
+
node = node.parent while node.public_send(direction).nil? && inline_parent?(node)
|
|
256
|
+
node.public_send(direction)
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
def inline_parent?(node)
|
|
260
|
+
parent = node.parent
|
|
261
|
+
parent&.element? && !Dom::BLOCK.include?(parent.name)
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
# Cents and ordinals are raised only for looks: $549.99, 21st, XIXe. A
|
|
265
|
+
# badge after a space (Feature <sup>NEW</sup>) is not an exponent.
|
|
266
|
+
def superscript(core, before, after, amount)
|
|
267
|
+
return cents(core, amount) if core.match?(CENTS) && price?(amount, after)
|
|
268
|
+
return core.tr(SCRIPT_SOURCE, SUPERSCRIPTS) if core.match?(SCRIPT_TEXT)
|
|
269
|
+
return core if plain_suffix?(core, before)
|
|
270
|
+
|
|
271
|
+
raised_letter(core, after) || ("^#{grouped(core, after)}" if expression?(core) && math_base?(core, before))
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def math_base?(core, before)
|
|
275
|
+
before.match?(MATH_BASE) || (before.match?(/\)\z/) && core.match?(/[\d+\-−=\/]/))
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
# A lowered word is an index only when it hangs off the word before it.
|
|
279
|
+
def subscript(node, core, before, after)
|
|
280
|
+
return core.tr(SCRIPT_SOURCE, SUBSCRIPTS) if core.match?(SCRIPT_TEXT)
|
|
281
|
+
|
|
282
|
+
return unless expression?(core) && before.match?(ATTACHED)
|
|
283
|
+
|
|
284
|
+
"#{subscript_mark(node, before)}#{grouped(core, after)}"
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def subscript_mark(node, before)
|
|
288
|
+
previous = adjacent(node, :previous_sibling)
|
|
289
|
+
marked = previous&.element? && (EMPHASIS.key?(previous.name) || CODE.include?(previous.name))
|
|
290
|
+
before.match?(WORD_END) && !marked ? "_" : "\\_"
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
# $549<sup>99</sup>, <sup>$</sup>549<sup>99</sup> and 549<sup>99</sup> €.
|
|
294
|
+
def price?(before, after)
|
|
295
|
+
before.match?(PRICE_END) || (before.match?(/\d,?\z/) && after.match?(PRICE_START))
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
# €12,<sup>99</sup> has its decimal comma already.
|
|
299
|
+
def cents(core, before)
|
|
300
|
+
return core if before.end_with?(",")
|
|
301
|
+
|
|
302
|
+
before.match?(DOT_GROUPED) ? ",#{core}" : ".#{core}"
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
def plain_suffix?(core, before)
|
|
306
|
+
(core.match?(ORDINAL_SUFFIX) && before.match?(NUMBER_END)) || (core.match?(TITLE_SUFFIX) && before.match?(/\bM\z/))
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
def raised_letter(core, after)
|
|
310
|
+
return "°" if core == "o" && after.match?(DEGREE_UNIT)
|
|
311
|
+
|
|
312
|
+
RAISED_LETTERS[core] || RAISED_LETTERS[core.upcase]
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
# Spaces count only around operators (n - 1), so small print is not an
|
|
316
|
+
# expression.
|
|
317
|
+
def expression?(core)
|
|
318
|
+
compact = core.delete(" ")
|
|
319
|
+
compact.match?(SCRIPT_WORD) && (compact == core || compact.match?(SCRIPT_OPERATOR))
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
# 2^n-1 would read as 2^n minus 1, 2^2n as 2^2 times n, e^(a)(b) as
|
|
323
|
+
# e^(a) times (b), and x_iy_i as x with the index iy_i.
|
|
324
|
+
def grouped(core, after)
|
|
325
|
+
return core if core.match?(GROUPED)
|
|
326
|
+
return core if (core.length == 1 || core.match?(/\A\p{L}+\z/)) && !after.match?(WORD_START)
|
|
327
|
+
|
|
328
|
+
"(#{core})"
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
def math(node)
|
|
332
|
+
annotation = node.css("annotation").find { |a| a["encoding"].to_s.include?("tex") }
|
|
333
|
+
tex = (annotation&.content || node["alttext"]).to_s.strip
|
|
334
|
+
return Text.escape(Text.collapse(node.content).strip) if tex.empty?
|
|
335
|
+
|
|
336
|
+
node["display"] == "block" ? "$$#{tex}$$" : "$#{tex}$"
|
|
337
|
+
end
|
|
338
|
+
end
|
|
339
|
+
end
|
|
340
|
+
end
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Nokodown
|
|
4
|
+
module Markdown
|
|
5
|
+
# Finds images that cost tokens without telling the reader anything: card
|
|
6
|
+
# thumbnails beside a text link to the same page, and images the page
|
|
7
|
+
# marks as decorative. They are still rendered, and Cleanup removes them
|
|
8
|
+
# last, so a heading whose section held only an image keeps its place.
|
|
9
|
+
class RedundantImages
|
|
10
|
+
MONTHS = "jan(?:uary)?|feb(?:ruary)?|mar(?:ch)?|apr(?:il)?|may|june?|july?|" \
|
|
11
|
+
"aug(?:ust)?|sep(?:t(?:ember)?)?|oct(?:ober)?|nov(?:ember)?|dec(?:ember)?"
|
|
12
|
+
# A squashed label that is only a date: "Oct 1, 2026", "1 October".
|
|
13
|
+
DATE = /\A(?:\d+(?:#{MONTHS})\d*|(?:#{MONTHS})\d+)\z/.freeze
|
|
14
|
+
CONTENT_SIZE = 300
|
|
15
|
+
LABEL_SHARE = 0.3
|
|
16
|
+
|
|
17
|
+
# Where a link destination points, ignoring the fragment and a trailing
|
|
18
|
+
# slash. ImageCleanup reads the links left in the Markdown this way.
|
|
19
|
+
def self.page(destination)
|
|
20
|
+
destination.sub(/#.*\z/m, "").chomp("/")
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def initialize(nodes, mode, urls)
|
|
24
|
+
@nodes = nodes
|
|
25
|
+
@mode = mode
|
|
26
|
+
@urls = urls
|
|
27
|
+
@text_free = {}
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Dom keys of the images to drop. A thumbnail maps to the page whose
|
|
31
|
+
# text link names it, a decorative image to nil.
|
|
32
|
+
def call
|
|
33
|
+
links = elements("a").map { |anchor| [anchor, target(anchor)] }.select { |_, page| page }
|
|
34
|
+
labels = link_labels(links)
|
|
35
|
+
redundant = {}
|
|
36
|
+
links.each do |anchor, page|
|
|
37
|
+
thumbnails(anchor, labels[page]).each { |img| redundant[Dom.key(img)] = page }
|
|
38
|
+
end
|
|
39
|
+
elements("img").each { |img| redundant[Dom.key(img)] = nil if decorative?(img) }
|
|
40
|
+
redundant.reject { |key, _| key.nil? }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def elements(name)
|
|
46
|
+
@nodes.flat_map { |node| (node.name == name ? [node] : []) + node.css(name).to_a }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Link texts by page, squashed for comparison with alt text. "Read
|
|
50
|
+
# more" is not written to the Markdown, and "3 comments" names nothing.
|
|
51
|
+
def link_labels(links)
|
|
52
|
+
links.each_with_object({}) do |(anchor, page), labels|
|
|
53
|
+
text = Dom.text(anchor)
|
|
54
|
+
next if text.match?(Patterns::LINK_NOISE_TEXT) || text.match?(Patterns::BOILERPLATE_TEXT)
|
|
55
|
+
|
|
56
|
+
label = squash(text)
|
|
57
|
+
(labels[page] ||= []) << label unless label.empty?
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# A link with its own text, or another link to the same page with text,
|
|
62
|
+
# names the destination already. A link to an image file is a zoom, not
|
|
63
|
+
# a card.
|
|
64
|
+
def thumbnails(anchor, labels)
|
|
65
|
+
return [] if labels.nil? || image_link?(anchor)
|
|
66
|
+
return [] if @mode != :page && Dom.ancestors_named(anchor, "figure").any?
|
|
67
|
+
|
|
68
|
+
anchor.css("img").select { |img| adds_nothing?(Dom.squish(img["alt"].to_s), labels) }
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# An image without alt text adds nothing to any label. One with an alt
|
|
72
|
+
# needs a label in words, since a price or a date does not say what the
|
|
73
|
+
# page is; page mode then drops it, article mode only if the alt
|
|
74
|
+
# repeats the label.
|
|
75
|
+
def adds_nothing?(alt, labels)
|
|
76
|
+
return true if alt.empty?
|
|
77
|
+
|
|
78
|
+
names = labels.select { |label| label.match?(/\p{L}/) && !label.match?(DATE) }
|
|
79
|
+
!names.empty? && (@mode == :page || repeats?(alt, names))
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# An alt of a word or two ("Photo", "Boot") adds nothing either.
|
|
83
|
+
def repeats?(alt, names)
|
|
84
|
+
return true if alt.split.size <= 2
|
|
85
|
+
|
|
86
|
+
alt = squash(alt)
|
|
87
|
+
names.any? { |name| name.include?(alt) || (alt.include?(name) && name.length >= alt.length * LABEL_SHARE) }
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# The page a link opens, written as the Markdown link would be.
|
|
91
|
+
def target(anchor)
|
|
92
|
+
url = Inline.link_url(anchor["href"].to_s, @urls)
|
|
93
|
+
RedundantImages.page(Text.destination(url)) if url
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# alt="" and role="presentation" are how HTML marks an image as
|
|
97
|
+
# decorative, but many sites write alt="" on every image. Articles
|
|
98
|
+
# carry their photos in any wrapper, so there only the role counts; on
|
|
99
|
+
# other pages, where an image sits can still make it content. A
|
|
100
|
+
# missing alt says nothing.
|
|
101
|
+
def decorative?(img)
|
|
102
|
+
presentation = %w[presentation none].include?(img["role"].to_s.strip.downcase)
|
|
103
|
+
return false unless presentation || (@mode == :page && img.key?("alt") && Dom.squish(img["alt"]).empty?)
|
|
104
|
+
|
|
105
|
+
!content_sized?(img) && !placed_as_content?(img)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def content_sized?(img)
|
|
109
|
+
[img["width"], img["height"]].any? { |value| Image.pixels(value).to_i >= CONTENT_SIZE }
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# In a figure, linked to its full-size file, or in a paragraph or table
|
|
113
|
+
# cell without text (a tick in a comparison table).
|
|
114
|
+
def placed_as_content?(img)
|
|
115
|
+
ancestors = Dom.ancestors_named(img, "figure", "a", "p", "td", "th")
|
|
116
|
+
return true if ancestors.any? { |node| node.name == "figure" || image_link?(node) }
|
|
117
|
+
|
|
118
|
+
holder = ancestors.find { |node| node.name != "a" }
|
|
119
|
+
!holder.nil? && text_free?(holder)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Memoized: on a table layout one cell holds the whole page and all of
|
|
123
|
+
# its images.
|
|
124
|
+
def text_free?(node)
|
|
125
|
+
key = Dom.key(node)
|
|
126
|
+
return @text_free[key] if key && @text_free.key?(key)
|
|
127
|
+
|
|
128
|
+
free = Dom.text(node).empty?
|
|
129
|
+
@text_free[key] = free if key
|
|
130
|
+
free
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def image_link?(node)
|
|
134
|
+
node.name == "a" && node["href"].to_s.match?(Preprocessor::IMAGE_URL)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def squash(text)
|
|
138
|
+
text.downcase.gsub(/[^\p{L}\p{N}]+/, "")
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|