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,230 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Nokodown
|
|
4
|
+
# Readability's paragraph scoring, run without rewriting the DOM: paragraphs
|
|
5
|
+
# push score up to their ancestors, the best ancestor wins, and siblings that
|
|
6
|
+
# look like part of the same article are merged back in.
|
|
7
|
+
class Scorer
|
|
8
|
+
Result = Struct.new(:top, :nodes, :score)
|
|
9
|
+
|
|
10
|
+
SCORED = Set.new(%w[p td pre section h2 h3 h4 h5 h6]).freeze
|
|
11
|
+
PARAGRAPH_MIN = 25
|
|
12
|
+
TOP_CANDIDATES = 5
|
|
13
|
+
COMMAS = /[,\u060C\uFE50\uFE10\uFE11\u2E41\u2E34\u2E32\uFF0C]/.freeze
|
|
14
|
+
|
|
15
|
+
def self.class_weight(node)
|
|
16
|
+
[node["class"], node["id"]].sum { |value| words_weight(value.to_s.downcase) }
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Conditional cleaning deletes whatever weighs negative, so it ignores CSS
|
|
20
|
+
# utility tokens. Candidate scoring keeps Readability's weight, which some
|
|
21
|
+
# pages need to pick the right candidate.
|
|
22
|
+
def self.cleaning_weight(node)
|
|
23
|
+
[node["class"], node["id"]].sum do |value|
|
|
24
|
+
tokens = value.to_s.downcase.split.reject { |token| utility_class?(token) }
|
|
25
|
+
words_weight(tokens.join(" "))
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# "lg:overflow-hidden" is judged without its variant prefix.
|
|
30
|
+
def self.utility_class?(token)
|
|
31
|
+
token.rpartition(":").last.match?(Patterns::UTILITY_CLASS)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def self.words_weight(value)
|
|
35
|
+
return 0 if value.empty?
|
|
36
|
+
|
|
37
|
+
(value.match?(Patterns::POSITIVE) ? 25 : 0) - (value.match?(Patterns::NEGATIVE) ? 25 : 0)
|
|
38
|
+
end
|
|
39
|
+
private_class_method :utility_class?, :words_weight
|
|
40
|
+
|
|
41
|
+
def initialize(root, stats)
|
|
42
|
+
@root = root
|
|
43
|
+
@root_key = Dom.key(root)
|
|
44
|
+
@stats = stats
|
|
45
|
+
@scores = {}
|
|
46
|
+
@nodes = {}
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def call
|
|
50
|
+
@paragraph_keys = Dom.paragraph_keys(@root)
|
|
51
|
+
score_paragraphs
|
|
52
|
+
return if @scores.empty?
|
|
53
|
+
|
|
54
|
+
@scores.each_key { |key| @scores[key] *= 1 - @stats.link_density(@nodes[key]) }
|
|
55
|
+
ranked = @scores.keys.max_by(TOP_CANDIDATES) { |key| @scores[key] }.map { |key| @nodes[key] }
|
|
56
|
+
top = climb(shared_ancestor(ranked) || ranked.first)
|
|
57
|
+
Result.new(top, merge_siblings(top), score(top) || 0.0)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
def paragraph?(node)
|
|
63
|
+
@paragraph_keys.include?(Dom.key(node))
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def score(node)
|
|
67
|
+
@scores[Dom.key(node)]
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def score_paragraphs
|
|
71
|
+
ignored = secondary_keys
|
|
72
|
+
@root.traverse do |node|
|
|
73
|
+
next unless node.element?
|
|
74
|
+
next if ignored.include?(Dom.key(node))
|
|
75
|
+
|
|
76
|
+
if SCORED.include?(node.name) || paragraph?(node)
|
|
77
|
+
add_score(Dom.text(node), scoring_parent(node))
|
|
78
|
+
elsif node.name == "div"
|
|
79
|
+
phrasing_runs(node).each { |text| add_score(text, node) }
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Comment threads are prose too, and on news sites they often outscore the
|
|
85
|
+
# article. A thread holding most of the page (a forum) still counts.
|
|
86
|
+
def secondary_keys
|
|
87
|
+
limit = @stats.text_chars(@root) * 0.5
|
|
88
|
+
regions = @root.css("*").select do |node|
|
|
89
|
+
Dom.signature(node).match?(Patterns::SECONDARY) && @stats.text_chars(node) < limit
|
|
90
|
+
end
|
|
91
|
+
regions.flat_map { |region| [region] + region.css("*").to_a }.map { |node| Dom.key(node) }.to_set
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# A div wrapping a single paragraph is that paragraph, as far as scoring goes.
|
|
95
|
+
def scoring_parent(node)
|
|
96
|
+
parent = node.parent
|
|
97
|
+
return parent unless node.name == "p" && parent&.name == "div" && parent.element_children.size == 1
|
|
98
|
+
return parent unless parent.children.all? { |child| child.element? || child.content.strip.empty? }
|
|
99
|
+
|
|
100
|
+
parent.parent
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Text sitting directly in a div between block children counts as paragraphs.
|
|
104
|
+
def phrasing_runs(div)
|
|
105
|
+
runs = [+""]
|
|
106
|
+
div.children.each do |child|
|
|
107
|
+
if child.element? && (Dom::PARAGRAPH_BREAKERS.include?(child.name) || Dom.block?(child))
|
|
108
|
+
runs << +""
|
|
109
|
+
else
|
|
110
|
+
runs.last << child.content
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
runs.map { |run| Dom.squish(run) }.reject(&:empty?)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def add_score(text, parent)
|
|
117
|
+
return if text.length < PARAGRAPH_MIN || parent.nil? || !within_root?(parent)
|
|
118
|
+
|
|
119
|
+
points = 1 + text.split(COMMAS, -1).size + [text.length / 100, 3].min
|
|
120
|
+
ancestors_from(parent).each_with_index do |ancestor, level|
|
|
121
|
+
key = Dom.key(ancestor)
|
|
122
|
+
@nodes[key] ||= ancestor
|
|
123
|
+
@scores[key] ||= initial_score(ancestor)
|
|
124
|
+
@scores[key] += points.to_f / divider(level)
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def divider(level)
|
|
129
|
+
case level
|
|
130
|
+
when 0 then 1
|
|
131
|
+
when 1 then 2
|
|
132
|
+
else level * 3
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def ancestors_from(parent)
|
|
137
|
+
list = []
|
|
138
|
+
current = parent
|
|
139
|
+
while current&.element? && list.size < 5
|
|
140
|
+
list << current
|
|
141
|
+
break if Dom.key(current) == @root_key
|
|
142
|
+
|
|
143
|
+
current = current.parent
|
|
144
|
+
end
|
|
145
|
+
list
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def within_root?(node)
|
|
149
|
+
Dom.key(node) == @root_key || node.ancestors.any? { |ancestor| ancestor.element? && Dom.key(ancestor) == @root_key }
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def initial_score(node)
|
|
153
|
+
self.class.class_weight(node) +
|
|
154
|
+
case node.name
|
|
155
|
+
when "div" then 5
|
|
156
|
+
when "pre", "td", "blockquote" then 3
|
|
157
|
+
when "address", "ol", "ul", "dl", "dd", "dt", "li", "form" then -3
|
|
158
|
+
when "h1", "h2", "h3", "h4", "h5", "h6", "th" then -5
|
|
159
|
+
else 0
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# When several near-equal candidates share an ancestor, the content is
|
|
164
|
+
# split across them: a list of posts, or an article broken up by ads.
|
|
165
|
+
def shared_ancestor(ranked)
|
|
166
|
+
top_score = score(ranked.first)
|
|
167
|
+
return if top_score <= 0
|
|
168
|
+
|
|
169
|
+
peers = ranked.drop(1).select { |node| score(node) / top_score >= 0.75 }
|
|
170
|
+
return if peers.size < 3
|
|
171
|
+
|
|
172
|
+
peer_ancestor_keys = peers.map { |node| node.ancestors.map { |ancestor| Dom.key(ancestor) } }
|
|
173
|
+
climbable_ancestors(ranked.first).find do |ancestor|
|
|
174
|
+
key = Dom.key(ancestor)
|
|
175
|
+
peer_ancestor_keys.count { |keys| keys.include?(key) } >= 3
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def climbable_ancestors(node)
|
|
180
|
+
node.ancestors.take_while { |ancestor| ancestor.element? && ancestor.name != "body" && within_root?(ancestor) }
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Readability walks up while the parent scores higher than its child: the
|
|
184
|
+
# content probably continues in the parent's other children.
|
|
185
|
+
def climb(top)
|
|
186
|
+
last = score(top) || 0.0
|
|
187
|
+
threshold = last / 3
|
|
188
|
+
climbable_ancestors(top).each do |parent|
|
|
189
|
+
parent_score = score(parent)
|
|
190
|
+
next unless parent_score
|
|
191
|
+
break if parent_score < threshold
|
|
192
|
+
|
|
193
|
+
return single_child_ancestor(parent) if parent_score > last
|
|
194
|
+
|
|
195
|
+
last = parent_score
|
|
196
|
+
end
|
|
197
|
+
single_child_ancestor(top)
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def single_child_ancestor(node)
|
|
201
|
+
climbable_ancestors(node).each do |parent|
|
|
202
|
+
break unless parent.element_children.size == 1
|
|
203
|
+
|
|
204
|
+
node = parent
|
|
205
|
+
end
|
|
206
|
+
node
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def merge_siblings(top)
|
|
210
|
+
return [top] if Dom.key(top) == @root_key || top.parent.nil?
|
|
211
|
+
|
|
212
|
+
top_score = score(top) || 0.0
|
|
213
|
+
threshold = [10, top_score * 0.2].max
|
|
214
|
+
top.parent.element_children.select do |sibling|
|
|
215
|
+
sibling == top || related_sibling?(sibling, top, top_score, threshold)
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def related_sibling?(sibling, top, top_score, threshold)
|
|
220
|
+
same_class = !top["class"].to_s.empty? && sibling["class"] == top["class"]
|
|
221
|
+
sibling_score = score(sibling)
|
|
222
|
+
return true if sibling_score && sibling_score + (same_class ? top_score * 0.2 : 0) >= threshold
|
|
223
|
+
return false unless paragraph?(sibling)
|
|
224
|
+
|
|
225
|
+
text = Dom.text(sibling)
|
|
226
|
+
density = @stats.link_density(sibling)
|
|
227
|
+
(text.length > 80 && density < 0.25) || (text.length.between?(1, 80) && density.zero? && text.match?(/\.( |\z)/))
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Nokodown
|
|
4
|
+
# Character and link counts for every element under a root, computed in one
|
|
5
|
+
# pass. Counts are non-whitespace characters, so indentation and line breaks
|
|
6
|
+
# in the source do not skew them.
|
|
7
|
+
class Stats
|
|
8
|
+
FRAGMENT_LINK_WEIGHT = 0.3
|
|
9
|
+
|
|
10
|
+
def initialize(root)
|
|
11
|
+
@chars = {}
|
|
12
|
+
@link_chars = {}
|
|
13
|
+
measure(root, 0.0)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def chars(node)
|
|
17
|
+
lookup(@chars, node)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def link_chars(node)
|
|
21
|
+
lookup(@link_chars, node)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def text_chars(node)
|
|
25
|
+
chars(node) - link_chars(node)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def link_density(node)
|
|
29
|
+
total = chars(node)
|
|
30
|
+
total.zero? ? 0.0 : link_chars(node) / total
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def lookup(table, node)
|
|
36
|
+
key = Dom.key(node)
|
|
37
|
+
measure(node, inherited_link_weight(node)) unless table.key?(key)
|
|
38
|
+
table[key]
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def measure(node, link_weight)
|
|
42
|
+
chars = 0.0
|
|
43
|
+
link_chars = 0.0
|
|
44
|
+
node.children.each do |child|
|
|
45
|
+
if child.text? || child.cdata?
|
|
46
|
+
count = Dom.char_count(child.content)
|
|
47
|
+
chars += count
|
|
48
|
+
link_chars += count * link_weight
|
|
49
|
+
elsif child.element?
|
|
50
|
+
child_chars, child_link_chars = measure(child, link_weight_for(child, link_weight))
|
|
51
|
+
chars += child_chars
|
|
52
|
+
link_chars += child_link_chars
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
store(node, chars, link_chars)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def store(node, chars, link_chars)
|
|
59
|
+
key = Dom.key(node)
|
|
60
|
+
if key
|
|
61
|
+
@chars[key] = chars
|
|
62
|
+
@link_chars[key] = link_chars
|
|
63
|
+
end
|
|
64
|
+
[chars, link_chars]
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def link_weight_for(node, inherited)
|
|
68
|
+
href = node.name == "a" && node["href"]
|
|
69
|
+
return inherited unless href
|
|
70
|
+
|
|
71
|
+
href.start_with?("#") ? FRAGMENT_LINK_WEIGHT : 1.0
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def inherited_link_weight(node)
|
|
75
|
+
anchor = node.ancestors.find { |ancestor| ancestor.element? && ancestor.name == "a" && ancestor["href"] }
|
|
76
|
+
anchor ? link_weight_for(anchor, 0.0) : 0.0
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Nokodown
|
|
4
|
+
module Tables
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
# Data vs. layout tables, following the checks Firefox uses for
|
|
8
|
+
# accessibility (and Readability copies).
|
|
9
|
+
def data_table?(table)
|
|
10
|
+
return false if table["role"] == "presentation" || table["datatable"] == "0"
|
|
11
|
+
return true if table["summary"].to_s.strip != ""
|
|
12
|
+
|
|
13
|
+
caption = table.at_css("caption")
|
|
14
|
+
return true if caption && !caption.content.strip.empty?
|
|
15
|
+
return true if table.at_css("col, colgroup, tfoot, thead, th")
|
|
16
|
+
return false if table.at_css("table")
|
|
17
|
+
|
|
18
|
+
rows, columns = size(table)
|
|
19
|
+
return false if rows == 1 || columns == 1 || sparse?(table)
|
|
20
|
+
return true if rows >= 10 || columns > 4
|
|
21
|
+
|
|
22
|
+
rows * columns > 10
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Headerless grids with many blank cells (spacers, indentation) are layout,
|
|
26
|
+
# however many rows they have. Hacker News is the canonical example.
|
|
27
|
+
def sparse?(table)
|
|
28
|
+
cells = table.css("td")
|
|
29
|
+
blank = cells.count { |cell| cell.content.strip.empty? && cell.at_css("img").nil? }
|
|
30
|
+
blank > cells.size * 0.3
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def size(table)
|
|
34
|
+
rows = 0
|
|
35
|
+
columns = 0
|
|
36
|
+
table.css("tr").each do |tr|
|
|
37
|
+
rowspan = tr["rowspan"].to_i
|
|
38
|
+
rows += rowspan.positive? ? rowspan : 1
|
|
39
|
+
cells = tr.css("td").sum do |td|
|
|
40
|
+
colspan = td["colspan"].to_i
|
|
41
|
+
colspan.positive? ? colspan : 1
|
|
42
|
+
end
|
|
43
|
+
columns = cells if cells > columns
|
|
44
|
+
end
|
|
45
|
+
[rows, columns]
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def rows(table)
|
|
49
|
+
table.element_children.flat_map do |child|
|
|
50
|
+
case child.name
|
|
51
|
+
when "tr" then [child]
|
|
52
|
+
when "thead", "tbody", "tfoot" then child.element_children.select { |row| row.name == "tr" }
|
|
53
|
+
else []
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def cells(row)
|
|
59
|
+
row.element_children.select { |cell| cell.name == "td" || cell.name == "th" }
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
module Nokodown
|
|
6
|
+
# Resolves hrefs against the page URL (and <base>), tolerating the malformed
|
|
7
|
+
# URLs real pages contain, and shortens them to the address they point at:
|
|
8
|
+
# tracking parameters, click redirectors and image resize options cost
|
|
9
|
+
# tokens and tell a reader nothing.
|
|
10
|
+
class UrlResolver
|
|
11
|
+
# From Ruby 3.4 URI::DEFAULT_PARSER is the RFC 3986 parser, whose unescape is obsolete.
|
|
12
|
+
URI_PARSER = defined?(URI::RFC2396_PARSER) ? URI::RFC2396_PARSER : URI::DEFAULT_PARSER
|
|
13
|
+
TRACKING_PARAM = /\A(?:utm_[a-z_]+|fbclid|gclid|gclsrc|dclid|gbraid|wbraid|msclkid|yclid|igshid|mc_cid|mc_eid|_hsenc|_hsmi|mkt_tok|__s|
|
|
14
|
+
_gl|_ga|srsltid|trk|icid|cmpid|hsCtaTracking|ref_|pd_rd_[a-z]+|pf_rd_[a-z]+)\z/ix.freeze
|
|
15
|
+
# Amazon session, search-rank and affiliate parameters; on other sites
|
|
16
|
+
# these names can matter (Microsoft fwlinks need linkid).
|
|
17
|
+
AMAZON_PARAM = /\A(?:ref|qid|sr|th|smid|psc|crid|dib|dib_tag|spLa|sprefix|linkCode|linkId|ascsubtag|camp|creative)\z/.freeze
|
|
18
|
+
AMAZON = /\A(?:www\.|smile\.)?amazon\.(?:com?\.)?[a-z]{2,3}\z/i.freeze
|
|
19
|
+
AMAZON_PRODUCT = %r{\A.*?/(?:dp|gp/product|gp/aw/d)/[A-Z0-9]{10}(?![A-Za-z0-9])}.freeze
|
|
20
|
+
# Bestseller links put the session id after the ref segment.
|
|
21
|
+
AMAZON_REF = %r{/ref=[^/]*(?:/\d{3}-\d{7}-\d{7})?\z}.freeze
|
|
22
|
+
REDIRECTOR = %r{
|
|
23
|
+
\A(?:www\.)?google\.(?:com?\.)?[a-z]{2,3}/url\z |
|
|
24
|
+
\A(?:www\.)?youtube\.com/redirect\z |
|
|
25
|
+
\Alm?\.facebook\.com/l\.php\z |
|
|
26
|
+
\.safelinks\.protection\.outlook\.com/?\z |
|
|
27
|
+
\Ago\.(?:skimresources|redirectingat)\.com/?\z |
|
|
28
|
+
\Aredirect\.viglink\.com/?\z |
|
|
29
|
+
\Aclick\.linksynergy\.com/ |
|
|
30
|
+
\A(?:www\.)?avantlink\.com/click\.php\z |
|
|
31
|
+
\A[\w-]+\.(?:pxf|sjv)\.io/c/ |
|
|
32
|
+
\A(?:www\.)?(?:anrdoezrs\.net|dpbolvw\.net|jdoqocy\.com|kqzyfj\.com|tkqlhce\.com)/click- |
|
|
33
|
+
\Asteamcommunity\.com/linkfilter/?\z
|
|
34
|
+
}xi.freeze
|
|
35
|
+
DESTINATION_PARAM = /\A(?:url|u|murl|q)\z/.freeze
|
|
36
|
+
MAX_REDIRECTS = 3
|
|
37
|
+
# Image CDN size, crop and format options; the default rendition shows the
|
|
38
|
+
# same picture.
|
|
39
|
+
PRESENTATION_PARAM = /\A(?:fit|crop|auto|dpr|fm|fmt|resize|resMode|op_usm|ixlib|impolicy)\z/.freeze
|
|
40
|
+
# Only a number is a size: Google and Bing thumbnails name the image in q.
|
|
41
|
+
SIZE_PARAM = /\A(?:w|h|width|height|q|quality|wid|hei|qlt|imwidth|imheight)\z/.freeze
|
|
42
|
+
NUMBER = /\A\d+(?:[.,]\d+)?\z/.freeze
|
|
43
|
+
EXTENSION = /\.[A-Za-z][A-Za-z0-9]{1,4}\z/.freeze
|
|
44
|
+
# Signed URLs stop working once any parameter changes.
|
|
45
|
+
SIGNATURE_PARAM = /\A(?:s|sig|sign|signature|token|__token__|hdnts|expires|x-amz-[a-z-]+|x-goog-[a-z-]+|key-pair-id|
|
|
46
|
+
policy|hmac|hash|jwt|itok)\z/ix.freeze
|
|
47
|
+
NEXT_IMAGE = %r{/_(?:next|vercel)/image/?\z}.freeze
|
|
48
|
+
# Cloudinary (on any domain), Forem, Cloudflare and ShortPixel append the
|
|
49
|
+
# original to their path. Screenshot services and web archives do too, but
|
|
50
|
+
# they do not serve the image at that URL.
|
|
51
|
+
FETCH_PROXY = %r{
|
|
52
|
+
\A[^/]*(?:/[\w-]+)?/image/fetch/ |
|
|
53
|
+
\A[^/]*/(?:dynamic|cdn-cgi)/image/ |
|
|
54
|
+
\Asp-ao\.shortpixel\.ai/client/
|
|
55
|
+
}ix.freeze
|
|
56
|
+
EMBEDDED_URL = %r{/(https?(?::|%3A)(?:/|%2F){2}.+)\z}i.freeze
|
|
57
|
+
CDN_CGI_IMAGE = %r{\A/cdn-cgi/image/[^/]+(/.+)\z}.freeze
|
|
58
|
+
PRIVATE_BUCKET = /\Abucketeer-/i.freeze
|
|
59
|
+
WEB_URL = %r{\Ahttps?://}i.freeze
|
|
60
|
+
UNSAFE = %r{[^A-Za-z0-9\-._~:/?#@!$&'()*+,;=%]}.freeze
|
|
61
|
+
|
|
62
|
+
def initialize(url, base_href = nil)
|
|
63
|
+
@base = parse(url)
|
|
64
|
+
href = base_href.to_s.strip
|
|
65
|
+
@base = join(@base, href) || @base if !href.empty? && !href.start_with?("javascript:")
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def resolve(href)
|
|
69
|
+
absolute(href) { |uri| clean_link(uri) }
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def resolve_image(src)
|
|
73
|
+
absolute(src) { |uri| clean_image(uri) }
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def same_page?(url)
|
|
77
|
+
return false unless @base
|
|
78
|
+
|
|
79
|
+
uri = parse(url)
|
|
80
|
+
return false unless uri
|
|
81
|
+
|
|
82
|
+
@page ||= strip_fragment(clean_link(@base))
|
|
83
|
+
uri.fragment && strip_fragment(uri) == @page
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
private
|
|
87
|
+
|
|
88
|
+
def absolute(href)
|
|
89
|
+
href = href.to_s.strip.delete("\t\n\r")
|
|
90
|
+
return if href.empty?
|
|
91
|
+
return href if href.match?(/\A(?:mailto|tel|data):/i)
|
|
92
|
+
|
|
93
|
+
uri = @base ? join(@base, href) : parse(href)
|
|
94
|
+
uri ? yield(uri).to_s : href
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def parse(url)
|
|
98
|
+
return if url.nil? || url.to_s.strip.empty?
|
|
99
|
+
|
|
100
|
+
URI.parse(encode(url.to_s.strip))
|
|
101
|
+
rescue URI::Error, ArgumentError
|
|
102
|
+
nil
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def join(base, href)
|
|
106
|
+
return parse(href) unless base
|
|
107
|
+
|
|
108
|
+
base.merge(encode(href))
|
|
109
|
+
rescue URI::Error, ArgumentError
|
|
110
|
+
nil
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def encode(string)
|
|
114
|
+
string.gsub(UNSAFE) { |char| char.bytes.map { |byte| format("%%%02X", byte) }.join }
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def clean_link(uri, redirects = 0)
|
|
118
|
+
target = redirect_target(uri) if redirects < MAX_REDIRECTS
|
|
119
|
+
return clean_link(target, redirects + 1) if target
|
|
120
|
+
return clean_amazon(uri) if uri.host.to_s.match?(AMAZON)
|
|
121
|
+
|
|
122
|
+
strip_tracking(uri)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def strip_tracking(uri)
|
|
126
|
+
reject_params(uri) { |key, _| key.match?(TRACKING_PARAM) }
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def redirect_target(uri)
|
|
130
|
+
return unless "#{uri.host}#{uri.path}".match?(REDIRECTOR)
|
|
131
|
+
|
|
132
|
+
param_values(uri, DESTINATION_PARAM).map { |value| parse(value) }.find { |target| web?(target) }
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def web?(uri)
|
|
136
|
+
uri.is_a?(URI::HTTP) && !uri.host.to_s.empty?
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def clean_amazon(uri)
|
|
140
|
+
product = uri.path[AMAZON_PRODUCT]
|
|
141
|
+
uri = uri.dup
|
|
142
|
+
uri.path = product || uri.path.sub(AMAZON_REF, "")
|
|
143
|
+
return reject_params(uri) { |key, _| key != "tag" } if product
|
|
144
|
+
|
|
145
|
+
reject_params(uri) { |key, _| key.match?(TRACKING_PARAM) || key.match?(AMAZON_PARAM) }
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def clean_image(uri)
|
|
149
|
+
return uri if signed_image?(uri)
|
|
150
|
+
|
|
151
|
+
embedded = embedded_image(uri)
|
|
152
|
+
return original_image(uri, embedded) if embedded
|
|
153
|
+
|
|
154
|
+
proxy?(uri) ? strip_tracking(uri) : strip_presentation(uri)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def proxy?(uri)
|
|
158
|
+
uri.query.to_s.split("&").any? { |pair| decode(param(pair).last).to_s.match?(WEB_URL) }
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# A proxy needs its options, so it stays whole when its original is unusable
|
|
162
|
+
# or is the proxy itself. The original is not unwrapped in turn, because
|
|
163
|
+
# proxies can point at each other.
|
|
164
|
+
def original_image(proxy, embedded)
|
|
165
|
+
original = join(proxy, embedded) unless embedded.empty?
|
|
166
|
+
return strip_tracking(proxy) unless web?(original) && !same_resource?(original, proxy) && !original.host.match?(PRIVATE_BUCKET)
|
|
167
|
+
|
|
168
|
+
signed_image?(original) ? original : strip_presentation(original)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def same_resource?(uri, other)
|
|
172
|
+
uri.host == other.host && uri.path == other.path
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def strip_presentation(uri)
|
|
176
|
+
extension = uri.path.to_s.match?(EXTENSION)
|
|
177
|
+
reject_params(uri) { |key, value| key.match?(TRACKING_PARAM) || presentation?(key, value, extension) }
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Twitter media has no file extension and is a 404 without its format.
|
|
181
|
+
def presentation?(key, value, extension)
|
|
182
|
+
return value.match?(NUMBER) if key.match?(SIZE_PARAM)
|
|
183
|
+
return extension if key == "format"
|
|
184
|
+
|
|
185
|
+
key.match?(PRESENTATION_PARAM) || (key == "v" && value.match?(/\A\d+\z/))
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def signed_image?(uri)
|
|
189
|
+
uri.query.to_s.split("&").any? { |pair| param(pair).first.match?(SIGNATURE_PARAM) }
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def embedded_image(uri)
|
|
193
|
+
path = uri.path.to_s
|
|
194
|
+
return param_values(uri, /\Aurl\z/).first.to_s if path.match?(NEXT_IMAGE)
|
|
195
|
+
return fetched_image(uri) if "#{uri.host}#{path}".match?(FETCH_PROXY) && path.match?(EMBEDDED_URL)
|
|
196
|
+
|
|
197
|
+
original = path[CDN_CGI_IMAGE, 1]
|
|
198
|
+
original && uri.query ? "#{original}?#{uri.query}" : original
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# An encoded original ends where the proxy's query starts. A plain one was
|
|
202
|
+
# split in two by the parser, so that query is the original's own.
|
|
203
|
+
def fetched_image(uri)
|
|
204
|
+
embedded = uri.path[EMBEDDED_URL, 1]
|
|
205
|
+
return unescape(embedded).to_s if embedded.match?(/\Ahttps?%3A/i)
|
|
206
|
+
|
|
207
|
+
uri.query ? "#{embedded}?#{uri.query}" : embedded
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def param_values(uri, key)
|
|
211
|
+
params = uri.query.to_s.split("&").map { |pair| param(pair) }
|
|
212
|
+
params.select { |name, _| name.match?(key) }.map { |_, value| decode(value) }.compact
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def param(pair)
|
|
216
|
+
key, value = pair.split("=", 2)
|
|
217
|
+
[key.to_s, value.to_s]
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def decode(value)
|
|
221
|
+
decoded = URI.decode_www_form_component(value)
|
|
222
|
+
decoded if decoded.valid_encoding?
|
|
223
|
+
rescue ArgumentError
|
|
224
|
+
nil
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def unescape(value)
|
|
228
|
+
decoded = URI_PARSER.unescape(value)
|
|
229
|
+
decoded if decoded.valid_encoding?
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def reject_params(uri)
|
|
233
|
+
return uri unless uri.query
|
|
234
|
+
|
|
235
|
+
pairs = uri.query.split("&")
|
|
236
|
+
kept = pairs.reject { |pair| yield(*param(pair)) }
|
|
237
|
+
return uri if kept.size == pairs.size
|
|
238
|
+
|
|
239
|
+
kept -= [""]
|
|
240
|
+
uri = uri.dup
|
|
241
|
+
uri.query = kept.empty? ? nil : kept.join("&")
|
|
242
|
+
uri
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def strip_fragment(uri)
|
|
246
|
+
uri.to_s.sub(/#.*\z/, "")
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
end
|
data/lib/nokodown.rb
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "nokolexbor"
|
|
4
|
+
|
|
5
|
+
require_relative "nokodown/version"
|
|
6
|
+
require_relative "nokodown/charset"
|
|
7
|
+
require_relative "nokodown/dom"
|
|
8
|
+
require_relative "nokodown/stats"
|
|
9
|
+
require_relative "nokodown/patterns"
|
|
10
|
+
require_relative "nokodown/tables"
|
|
11
|
+
require_relative "nokodown/url_resolver"
|
|
12
|
+
require_relative "nokodown/metadata"
|
|
13
|
+
require_relative "nokodown/preprocessor"
|
|
14
|
+
require_relative "nokodown/boilerplate"
|
|
15
|
+
require_relative "nokodown/product_reviews"
|
|
16
|
+
require_relative "nokodown/scorer"
|
|
17
|
+
require_relative "nokodown/content_locator"
|
|
18
|
+
require_relative "nokodown/container_check"
|
|
19
|
+
require_relative "nokodown/pruner"
|
|
20
|
+
require_relative "nokodown/markdown/text"
|
|
21
|
+
require_relative "nokodown/markdown/image"
|
|
22
|
+
require_relative "nokodown/markdown/code_block"
|
|
23
|
+
require_relative "nokodown/markdown/inline"
|
|
24
|
+
require_relative "nokodown/markdown/redundant_images"
|
|
25
|
+
require_relative "nokodown/markdown/table"
|
|
26
|
+
require_relative "nokodown/markdown/renderer"
|
|
27
|
+
require_relative "nokodown/markdown/cleanup"
|
|
28
|
+
require_relative "nokodown/markdown/image_cleanup"
|
|
29
|
+
require_relative "nokodown/result"
|
|
30
|
+
require_relative "nokodown/extractor"
|
|
31
|
+
|
|
32
|
+
module Nokodown
|
|
33
|
+
# Extracts the content of an HTML page as Markdown, plus page metadata.
|
|
34
|
+
#
|
|
35
|
+
# result = Nokodown.extract(html, url: "https://example.com/post")
|
|
36
|
+
# result.markdown
|
|
37
|
+
# result.title
|
|
38
|
+
#
|
|
39
|
+
# Options:
|
|
40
|
+
# url: page URL, used to resolve relative links and images
|
|
41
|
+
# main_content: true (default) keeps only the main content; false converts
|
|
42
|
+
# the whole page, removing only invisible elements and scripts
|
|
43
|
+
# title: prepend the page title as an H1 when the content has none;
|
|
44
|
+
# a page with no content gives empty Markdown, not the title
|
|
45
|
+
# images: include images
|
|
46
|
+
# links: keep links; false renders link text only
|
|
47
|
+
def self.extract(html, **options)
|
|
48
|
+
Extractor.new(html, **options).call
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def self.to_markdown(html, **options)
|
|
52
|
+
extract(html, **options).markdown
|
|
53
|
+
end
|
|
54
|
+
end
|