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,226 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Nokodown
|
|
4
|
+
# Removes everything that can never be content: scripts, invisible elements,
|
|
5
|
+
# form controls, embeds. Lazy-loaded images are restored first so their
|
|
6
|
+
# placeholders are not mistaken for decoration.
|
|
7
|
+
class Preprocessor
|
|
8
|
+
JUNK = %w[
|
|
9
|
+
script style noscript template link meta base object embed applet param canvas map area svg
|
|
10
|
+
source track audio video dialog select option optgroup datalist textarea output progress meter
|
|
11
|
+
keygen frame frameset portal slot
|
|
12
|
+
].join(",").freeze
|
|
13
|
+
|
|
14
|
+
HIDDEN_CLASSES = Set.new(%w[
|
|
15
|
+
hidden hide d-none is-hidden u-hidden visually-hidden visuallyhidden sr-only screen-reader-text
|
|
16
|
+
screen-reader-only screenreader-only a11y-hidden offscreen invisible element-invisible
|
|
17
|
+
print-only visible-print d-print-block show-for-sr
|
|
18
|
+
]).freeze
|
|
19
|
+
|
|
20
|
+
# Found by Lexbor's selector engine, so the checks below only run on
|
|
21
|
+
# elements that could be hidden.
|
|
22
|
+
HIDDEN_CANDIDATES = (["[hidden]", "[aria-hidden=true]", "[style]"] + HIDDEN_CLASSES.map { |name| ".#{name}" }).join(", ").freeze
|
|
23
|
+
|
|
24
|
+
# Utility classes that make a "hidden" element visible again at some breakpoint.
|
|
25
|
+
RESPONSIVE_SHOW = /\A(?:(?:sm|md|lg|xl|2xl):(?:block|flex|grid|inline|inline-block|inline-flex|table|contents)|d-(?:sm|md|lg|xl|xxl)-(?:block|flex|grid|inline|inline-block|inline-flex|table))\z/.freeze
|
|
26
|
+
|
|
27
|
+
HIDDEN_STYLE = /(?:\A|;)\s*(?:display\s*:\s*none|visibility\s*:\s*hidden)/i.freeze
|
|
28
|
+
|
|
29
|
+
LINE_NUMBERS = /(?<![a-z])(?:line-?numbers?(?:-rows)?|linenos?|lineno|gutter|hljs-ln-numbers|ln-num)(?![a-z])/.freeze
|
|
30
|
+
|
|
31
|
+
VIDEO_HOSTS = %r{//(?:www\.)?(?:(?:dailymotion|youtube|youtube-nocookie|player\.vimeo|v\.qq|bilibili|live\.bilibili)\.com|(?:archive|upload\.wikimedia)\.org|player\.twitch\.tv|fast\.wistia\.(?:net|com)|play\.vidyard\.com|loom\.com/embed)}i.freeze
|
|
32
|
+
|
|
33
|
+
IMAGE_URL = /\.(?:jpe?g|png|webp|gif|avif)(?:[?#]\S*)?\s*\z/i.freeze
|
|
34
|
+
IMAGE_SRCSET = /\.(?:jpe?g|png|webp|gif|avif)(?:\?\S*)?\s+\d/i.freeze
|
|
35
|
+
LAZY_SRC = %w[
|
|
36
|
+
data-src data-lazy-src data-original data-lazy data-url data-hi-res-src data-full-src
|
|
37
|
+
data-large-src data-orig-src data-actualsrc data-echo data-img-src data-image
|
|
38
|
+
].freeze
|
|
39
|
+
LAZY_SRCSET = %w[data-srcset data-lazy-srcset data-original-set].freeze
|
|
40
|
+
|
|
41
|
+
def initialize(body)
|
|
42
|
+
@body = body
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def call
|
|
46
|
+
@body.xpath(".//comment()").each(&:remove)
|
|
47
|
+
unwrap_noscript
|
|
48
|
+
@body.css("img").each { |img| restore_lazy_image(img) }
|
|
49
|
+
restore_picture_sources
|
|
50
|
+
@body.css(JUNK).each(&:remove)
|
|
51
|
+
@body.css("iframe").each { |iframe| iframe.remove unless iframe["src"].to_s.match?(VIDEO_HOSTS) }
|
|
52
|
+
@body.css("pre [class], table[class] td[class]").each { |node| node.remove if node["class"].downcase.match?(LINE_NUMBERS) }
|
|
53
|
+
remove_hidden
|
|
54
|
+
remove_form_controls
|
|
55
|
+
Dom.assign_keys(@body)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
# Lexbor keeps <noscript> content as text. Lazy loaders put the real <img>
|
|
61
|
+
# there, and some apps (Discourse) put the whole page there for crawlers.
|
|
62
|
+
def unwrap_noscript
|
|
63
|
+
@body.css("noscript").each do |noscript|
|
|
64
|
+
fragment = @body.document.fragment(noscript.content)
|
|
65
|
+
images = fragment.css("img")
|
|
66
|
+
if images.size == 1 && Dom.text(fragment).empty?
|
|
67
|
+
(lazy_copy(noscript, images.first) || noscript).replace(images.first)
|
|
68
|
+
elsif Dom.char_count(fragment.content) >= 300 && empty_without_noscript?
|
|
69
|
+
noscript.replace(fragment.children)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def empty_without_noscript?
|
|
75
|
+
@empty_without_noscript = visible_char_count < 200 if @empty_without_noscript.nil?
|
|
76
|
+
@empty_without_noscript
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def visible_char_count
|
|
80
|
+
invisible = @body.css("script, style, noscript, template").sum { |node| Dom.char_count(node.content) }
|
|
81
|
+
Dom.char_count(@body.content) - invisible
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# The lazy copy sits just before the <noscript>, or just after it when it
|
|
85
|
+
# loads the same file. Otherwise both copies would be rendered.
|
|
86
|
+
def lazy_copy(noscript, image)
|
|
87
|
+
following = lone_image(noscript.next_element)
|
|
88
|
+
return following if following && same_source?(following, image)
|
|
89
|
+
|
|
90
|
+
lone_image(noscript.previous_element)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def lone_image(node)
|
|
94
|
+
return unless node
|
|
95
|
+
return node if node.name == "img"
|
|
96
|
+
|
|
97
|
+
images = node.css("img")
|
|
98
|
+
images.first if images.size == 1 && Dom.text(node).empty?
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def same_source?(img, image)
|
|
102
|
+
src = image["src"].to_s.strip
|
|
103
|
+
!src.empty? && ["src", *LAZY_SRC].any? { |name| img[name].to_s.strip == src }
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def restore_lazy_image(img)
|
|
107
|
+
img.remove_attribute("src") if placeholder_src?(img)
|
|
108
|
+
return if img["src"] && !img["class"].to_s.downcase.include?("lazy")
|
|
109
|
+
|
|
110
|
+
copy_first_attribute(img, LAZY_SRC, "src")
|
|
111
|
+
copy_first_attribute(img, LAZY_SRCSET, "srcset")
|
|
112
|
+
restore_from_any_attribute(img) unless img["src"] || img["srcset"]
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def copy_first_attribute(img, names, target)
|
|
116
|
+
value = names.map { |name| img[name].to_s.strip }.find { |v| !v.empty? && !v.start_with?("data:") }
|
|
117
|
+
img[target] = value if value
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def restore_from_any_attribute(img)
|
|
121
|
+
img.attribute_nodes.each do |attribute|
|
|
122
|
+
next if %w[src srcset alt].include?(attribute.name)
|
|
123
|
+
|
|
124
|
+
value = attribute.value.to_s
|
|
125
|
+
if value.match?(IMAGE_SRCSET)
|
|
126
|
+
img["srcset"] = value
|
|
127
|
+
elsif value.match?(IMAGE_URL) && !value.include?(" ")
|
|
128
|
+
img["src"] = value
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Tiny base64 GIF/PNG stand-ins that a lazy loader swaps out.
|
|
134
|
+
def placeholder_src?(img)
|
|
135
|
+
src = img["src"].to_s.strip
|
|
136
|
+
return false unless src.start_with?("data:")
|
|
137
|
+
return false if src.start_with?("data:image/svg+xml") && src.length > 200
|
|
138
|
+
|
|
139
|
+
src.length < 300 || img.attribute_nodes.any? { |a| a.name != "src" && a.value.to_s.match?(IMAGE_URL) }
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def restore_picture_sources
|
|
143
|
+
@body.css("picture").each do |picture|
|
|
144
|
+
img = picture.at_css("img")
|
|
145
|
+
next if img.nil? || img["src"] || img["srcset"]
|
|
146
|
+
|
|
147
|
+
source = picture.css("source").find { |s| s["srcset"] || s["data-srcset"] }
|
|
148
|
+
img["srcset"] = source["srcset"] || source["data-srcset"] if source
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def remove_hidden
|
|
153
|
+
total = Dom.char_count(@body.content)
|
|
154
|
+
@panel_ids = @body.css("[aria-controls]").flat_map { |control| control["aria-controls"].split }.to_set
|
|
155
|
+
@body.css(HIDDEN_CANDIDATES).each do |node|
|
|
156
|
+
next unless hidden?(node)
|
|
157
|
+
# A hidden wrapper around most of the page is an anti-flicker snippet
|
|
158
|
+
# or an open modal in the snapshot, not something readers cannot see.
|
|
159
|
+
next if total > 500 && Dom.char_count(node.content) > total * 0.3
|
|
160
|
+
|
|
161
|
+
node.remove
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def hidden?(node)
|
|
166
|
+
return !disclosure?(node) if node.key?("hidden") && node["hidden"] != "until-found"
|
|
167
|
+
return !node["class"].to_s.include?("fallback-image") && !closed_panel?(node) if node["aria-hidden"] == "true"
|
|
168
|
+
return !disclosure?(node) if node["style"].to_s.match?(HIDDEN_STYLE)
|
|
169
|
+
|
|
170
|
+
hidden_by_class?(node) && !disclosure?(node)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def hidden_by_class?(node)
|
|
174
|
+
classes = node["class"].to_s.split
|
|
175
|
+
classes.any? { |c| HIDDEN_CLASSES.include?(c) } && classes.none? { |c| c.match?(RESPONSIVE_SHOW) }
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def disclosure?(node)
|
|
179
|
+
node["role"] == "tabpanel" || Dom.signature(node).match?(Patterns::DISCLOSURE)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# Accordions and tabs mark closed panels aria-hidden, but icons and the
|
|
183
|
+
# full text behind "read more" are aria-hidden too, so a panel only counts
|
|
184
|
+
# when a control on the page opens it or a tab labels it.
|
|
185
|
+
def closed_panel?(node)
|
|
186
|
+
disclosure?(node) && (node.key?("aria-labelledby") || @panel_ids.include?(node["id"]))
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def remove_form_controls
|
|
190
|
+
@body.css("form").each { |form| form.remove if utility_form?(form) }
|
|
191
|
+
@body.css("input").each { |input| input.remove unless task_checkbox?(input) }
|
|
192
|
+
@body.css("button").each { |button| disclosure_button?(button) ? button.replace(button.children) : button.remove }
|
|
193
|
+
@body.css("label").each { |label| label.remove if label["for"] || Dom.ancestors_named(label, "form").any? }
|
|
194
|
+
@body.css("form").each { |form| form.remove if leftover_form?(form) }
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# Only a prompt such as "Search:" is left once the controls are gone.
|
|
198
|
+
def leftover_form?(form)
|
|
199
|
+
text = Dom.text(form)
|
|
200
|
+
text.length < 30 && !text.match?(/\d/) && form.at_css("img").nil?
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def task_checkbox?(input)
|
|
204
|
+
input["type"].to_s.casecmp?("checkbox") && input.parent&.name == "li"
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# Search boxes, sign-ups and logins. Forms that wrap real content (ASP.NET
|
|
208
|
+
# pages, product option pickers) are left alone.
|
|
209
|
+
def utility_form?(form)
|
|
210
|
+
return false if Dom.char_count(form.content) >= 300
|
|
211
|
+
|
|
212
|
+
form["role"] == "search" ||
|
|
213
|
+
form.css("input").any? { |i| %w[search email password].include?(i["type"].to_s.downcase) } ||
|
|
214
|
+
form["action"].to_s.match?(/search|subscribe|newsletter|login|sign-?in|sign-?up/i) ||
|
|
215
|
+
Dom.signature(form).match?(Patterns::NOISE)
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# Accordion triggers usually carry the question text of an FAQ.
|
|
219
|
+
def disclosure_button?(button)
|
|
220
|
+
parent = button.parent
|
|
221
|
+
return false unless button.key?("aria-expanded") || button.key?("aria-controls") || (parent && Dom.heading?(parent))
|
|
222
|
+
|
|
223
|
+
Dom.text(button).split.size >= 3
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
end
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Nokodown
|
|
4
|
+
# Removes customer reviews, ratings and Q&A widgets from product pages.
|
|
5
|
+
# Shoppers' prose easily outweighs a product description, so it would win
|
|
6
|
+
# the content scoring or, in page mode, bury the description. On other
|
|
7
|
+
# pages a reviews section is usually the content itself (a review article,
|
|
8
|
+
# a service's testimonials), so only product pages are cleaned.
|
|
9
|
+
class ProductReviews
|
|
10
|
+
# Shops that declare no page type in JSON-LD or Open Graph. Form controls
|
|
11
|
+
# are gone once the body is cleaned, so this is checked on the raw
|
|
12
|
+
# document.
|
|
13
|
+
PRODUCT_MARKUP = "[itemtype*='schema.org/Product'], form[action*='/cart/add']"
|
|
14
|
+
|
|
15
|
+
# The product's title and description stay even when a wrapper around
|
|
16
|
+
# them is named after its reviews.
|
|
17
|
+
PROTECTED = "h1, [itemprop~=description]"
|
|
18
|
+
|
|
19
|
+
# Testimonials and a book's editorial reviews are the seller's own words.
|
|
20
|
+
SELLER_CONTENT = /testimonial|editorial/.freeze
|
|
21
|
+
|
|
22
|
+
ITEMPROP = /\Areviews?\z/i.freeze
|
|
23
|
+
|
|
24
|
+
# With less text than this left, the reviews are what the page is about.
|
|
25
|
+
MIN_REMAINING_CHARS = 200
|
|
26
|
+
|
|
27
|
+
# Review articles embed affiliate product boxes and quick-add forms too,
|
|
28
|
+
# so the markup only counts on a page that declares no type.
|
|
29
|
+
def self.product_page?(doc, page_type)
|
|
30
|
+
return page_type == :product unless page_type.nil?
|
|
31
|
+
|
|
32
|
+
!doc.at_css(PRODUCT_MARKUP).nil?
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def initialize(body)
|
|
36
|
+
@body = body
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def call
|
|
40
|
+
@protected = protected_keys
|
|
41
|
+
# Card grids repeat the same class list hundreds of times.
|
|
42
|
+
@review_names = Hash.new { |cache, signature| cache[signature] = signature.match?(Patterns::REVIEWS) }
|
|
43
|
+
widgets = outermost_widgets(@body)
|
|
44
|
+
return if widgets.empty?
|
|
45
|
+
|
|
46
|
+
remaining = Dom.char_count(@body.content) - widgets.sum { |node| Dom.char_count(node.content) }
|
|
47
|
+
widgets.each(&:remove) if remaining >= MIN_REMAINING_CHARS
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
# A review's own description (schema.org Review) must not shield the
|
|
53
|
+
# list it sits in.
|
|
54
|
+
def protected_keys
|
|
55
|
+
kept = @body.css(PROTECTED).reject { |node| inside_review_item?(node) }
|
|
56
|
+
kept.flat_map { |node| [node] + node.ancestors.to_a }.map { |node| Dom.key(node) }.compact.to_set
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def inside_review_item?(node)
|
|
60
|
+
node.ancestors.any? { |ancestor| ancestor.element? && ancestor["itemprop"].to_s.match?(ITEMPROP) }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def outermost_widgets(node, found = [])
|
|
64
|
+
node.element_children.each do |child|
|
|
65
|
+
signature = Dom.signature(child)
|
|
66
|
+
next if signature.match?(SELLER_CONTENT)
|
|
67
|
+
|
|
68
|
+
if widget?(child, signature)
|
|
69
|
+
found << child
|
|
70
|
+
else
|
|
71
|
+
outermost_widgets(child, found)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
found
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def widget?(node, signature)
|
|
78
|
+
return false if kept?(node, signature)
|
|
79
|
+
|
|
80
|
+
@review_names[signature] || node["itemprop"].to_s.match?(ITEMPROP)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# A rating badge in a line of text ("4.7 stars, 212 reviews") sums the
|
|
84
|
+
# reviews up like a price does. A section named after the description too
|
|
85
|
+
# holds the seller's words, such as critics' quotes on a game page.
|
|
86
|
+
def kept?(node, signature)
|
|
87
|
+
@protected.include?(Dom.key(node)) || Boilerplate::PHRASING.include?(node.name) || signature.include?("description")
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Nokodown
|
|
4
|
+
# Final cleanup inside the selected content.
|
|
5
|
+
class Pruner
|
|
6
|
+
CLUSTER_CONTAINERS = "ul, ol, menu, dl, div, section, p, td, li, span, table, header, footer"
|
|
7
|
+
TEXT_BLOCKS = "p, div, span, li, h1, h2, h3, h4, h5, h6, small, section, aside, figcaption, b, strong, em, i"
|
|
8
|
+
BYLINE = /byline|author|dateline|writtenby|p-author/.freeze
|
|
9
|
+
SHARE = /(?<![a-z])(?:share|sharedaddy)(?![a-z])/.freeze
|
|
10
|
+
# A class names a button when that is its last word, before a modifier,
|
|
11
|
+
# a size or a rank: "btn", "c-button", "hero-cta", "btn--ghost",
|
|
12
|
+
# "btn_medium", "button-primary". "button-text" and
|
|
13
|
+
# "btn-view-author-options" name something else, and Bootstrap's
|
|
14
|
+
# "btn-link" is a button that looks like a link.
|
|
15
|
+
BUTTON_CLASS = /
|
|
16
|
+
(?:\A|[-_])(?:btn|btns|button|buttons|cta|ctas)\d*
|
|
17
|
+
(?:--[\w-]+|[-_](?:primary|secondary|tertiary|default|small|medium|large|sm|md|lg))?\z
|
|
18
|
+
/x.freeze
|
|
19
|
+
BUTTON_WORD = /btn|button|cta/.freeze
|
|
20
|
+
LINK_STYLED_BUTTON = /\A(?:btn|button)-link\z/.freeze
|
|
21
|
+
CALL_TO_ACTION_PARTS = "img, picture, h1, h2, h3, h4, h5, h6, table, ul, ol, p"
|
|
22
|
+
# Inside these a button is a title, a term or a footnote marker.
|
|
23
|
+
LABEL_HOLDERS = %w[h1 h2 h3 h4 h5 h6 dt th sup label summary].freeze
|
|
24
|
+
MIN_INDEX_LENGTH = 80
|
|
25
|
+
DOWNLOAD = /\.(?:pdf|zip|docx?|xlsx?|pptx?|csv|epub|mp3|mp4|dmg|exe|msi|apk|tar|gz)\z/i.freeze
|
|
26
|
+
|
|
27
|
+
def initialize(nodes, mode, conditional: true)
|
|
28
|
+
@nodes = nodes
|
|
29
|
+
@mode = mode
|
|
30
|
+
@conditional = conditional
|
|
31
|
+
@conditional_seen = 0
|
|
32
|
+
@conditional_kept = 0
|
|
33
|
+
@conditional_removed_links = 0
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def call
|
|
37
|
+
@nodes.each do |root|
|
|
38
|
+
@mode == :article ? prune_article(root) : remove_link_clusters(root)
|
|
39
|
+
remove_boilerplate_text(root)
|
|
40
|
+
remove_calls_to_action(root)
|
|
41
|
+
end
|
|
42
|
+
self
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Share of the text conditional cleaning was given that it kept.
|
|
46
|
+
def conditional_share
|
|
47
|
+
@conditional_seen.zero? ? 1.0 : @conditional_kept.fdiv(@conditional_seen)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Share of the text conditional cleaning removed that was link text.
|
|
51
|
+
def removed_link_share
|
|
52
|
+
removed = @conditional_seen - @conditional_kept
|
|
53
|
+
removed.zero? ? 0.0 : @conditional_removed_links.fdiv(removed)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def prune_article(root)
|
|
59
|
+
content_length = Dom.text(root).length
|
|
60
|
+
remove_secondary(root, content_length)
|
|
61
|
+
remove_bylines(root)
|
|
62
|
+
remove_article_furniture(root, content_length)
|
|
63
|
+
remove_related_links(root)
|
|
64
|
+
remove_stray_lines(root)
|
|
65
|
+
clean_conditionally(root, content_length)
|
|
66
|
+
root.css("h1, h2").each { |heading| remove(heading) if Scorer.cleaning_weight(heading).negative? }
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Boxes of links to other stories placed inside the body: "READ MORE:
|
|
70
|
+
# <link>", a "More:" label over a list of headlines, a paragraph of links.
|
|
71
|
+
def remove_related_links(root)
|
|
72
|
+
root.css("p, li, h2, h3, h4, h5, h6, strong, b").each do |node|
|
|
73
|
+
text = Dom.text(node)
|
|
74
|
+
if related_label?(text)
|
|
75
|
+
remove_with_link_box(node)
|
|
76
|
+
elsif related_box?(node, text)
|
|
77
|
+
remove(node)
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# "More:" or "DON'T MISS" on its own.
|
|
83
|
+
def related_label?(text)
|
|
84
|
+
text.length <= 30 && text.match?(Patterns::RELATED_LABEL) && text.sub(Patterns::RELATED_LABEL, "").empty?
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# The label may sit in a wrapper of its own (Wikipedia's div.mw-heading),
|
|
88
|
+
# so the box of links follows the wrapper.
|
|
89
|
+
def remove_with_link_box(label)
|
|
90
|
+
text = Dom.text(label)
|
|
91
|
+
holder = label
|
|
92
|
+
holder = holder.parent while holder.next_element.nil? && holder.parent&.element? && Dom.text(holder.parent) == text
|
|
93
|
+
following = holder.next_element
|
|
94
|
+
remove(following) if following && link_density(following) >= 0.6
|
|
95
|
+
remove(holder)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# "READ MORE: <headline>", or a paragraph made of several headlines.
|
|
99
|
+
def related_box?(node, text)
|
|
100
|
+
return link_density(node) >= 0.5 if text.match?(Patterns::RELATED_LABEL)
|
|
101
|
+
|
|
102
|
+
node.name == "p" && node.css("a[href]").size >= 2 && link_density(node) >= 0.8
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Datelines and "follow us" lines.
|
|
106
|
+
def remove_stray_lines(root)
|
|
107
|
+
root.css("p, div, span, li, small, time").each do |node|
|
|
108
|
+
next if Dom.ancestors_named(node, "table").any?
|
|
109
|
+
|
|
110
|
+
text = Dom.text(node)
|
|
111
|
+
remove(node) if text.length <= 120 && text.match?(Patterns::STRAY_LINE)
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def link_density(node)
|
|
116
|
+
text_length = Dom.text(node).length
|
|
117
|
+
return 0.0 if text_length.zero?
|
|
118
|
+
|
|
119
|
+
link_text_length(node).fdiv(text_length)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def link_text_length(node)
|
|
123
|
+
node.css("a[href]").sum { |link| Dom.text(link).length }
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Readability drops footers, asides and share widgets inside the article;
|
|
127
|
+
# asides holding real prose (side notes) stay.
|
|
128
|
+
def remove_article_furniture(root, content_length)
|
|
129
|
+
root.css("footer").each { |node| remove(node) if Dom.text(node).length <= content_length * 0.5 }
|
|
130
|
+
root.css("aside").each do |node|
|
|
131
|
+
remove(node) if Dom.text(node).length < 200 || ContainerCheck.new(node, content_length, Set.new).remove?
|
|
132
|
+
end
|
|
133
|
+
root.css("[class], [id]").each do |node|
|
|
134
|
+
remove(node) if Dom.signature(node).match?(SHARE) && Dom.text(node).length < 500
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Turned off, it still drops unlabeled navigation, as page pruning does.
|
|
139
|
+
def clean_conditionally(root, content_length)
|
|
140
|
+
return remove_link_clusters(root) unless @conditional
|
|
141
|
+
|
|
142
|
+
text_before = Dom.text(root).length
|
|
143
|
+
links_before = link_text_length(root)
|
|
144
|
+
remove_suspicious_containers(root, content_length)
|
|
145
|
+
@conditional_seen += text_before
|
|
146
|
+
@conditional_kept += Dom.text(root).length
|
|
147
|
+
@conditional_removed_links += links_before - link_text_length(root)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Innermost containers first, so an outer one is judged on what is left.
|
|
151
|
+
def remove_suspicious_containers(root, content_length)
|
|
152
|
+
paragraph_keys = Dom.paragraph_keys(root)
|
|
153
|
+
memo = { root: root }
|
|
154
|
+
["form, fieldset", "table", "ul", "div"].each do |selector|
|
|
155
|
+
root.css(selector).reverse_each do |node|
|
|
156
|
+
next if paragraph_keys.include?(Dom.key(node)) || !ContainerCheck.new(node, content_length, paragraph_keys, memo).remove?
|
|
157
|
+
|
|
158
|
+
ContainerCheck.forget(memo, node)
|
|
159
|
+
remove(node)
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# A forum's thread is content.
|
|
165
|
+
def remove_secondary(root, total)
|
|
166
|
+
root.element_children.each do |node|
|
|
167
|
+
next if Dom.thread?(node)
|
|
168
|
+
|
|
169
|
+
if Dom.signature(node).match?(Patterns::SECONDARY) && Dom.text(node).length <= total * 0.5
|
|
170
|
+
remove(node)
|
|
171
|
+
else
|
|
172
|
+
remove_secondary(node, total)
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# The author line is returned as metadata, like Readability does. Many of
|
|
178
|
+
# them mean a thread or a list of posts, where the authors are content.
|
|
179
|
+
def remove_bylines(root)
|
|
180
|
+
bylines = root.css("*").select do |node|
|
|
181
|
+
byline = node["rel"] == "author" || node["itemprop"].to_s.include?("author") || Dom.signature(node).match?(BYLINE)
|
|
182
|
+
byline && Dom.text(node).length.between?(1, 99)
|
|
183
|
+
end
|
|
184
|
+
bylines.each { |node| remove(node) } if bylines.size <= 2
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# Navigation that survived as unlabeled markup: several links, almost no
|
|
188
|
+
# other text, and no link long enough to be a headline.
|
|
189
|
+
def remove_link_clusters(root)
|
|
190
|
+
clusters = root.css(CLUSTER_CONTAINERS).select { |node| link_cluster?(node) && !in_data_table?(node) }
|
|
191
|
+
# Removal can only start at one of these, so none means nothing to remove.
|
|
192
|
+
return if clusters.empty? || clusters_dominate?(root, clusters)
|
|
193
|
+
|
|
194
|
+
root.css(CLUSTER_CONTAINERS).reverse_each do |node|
|
|
195
|
+
remove(node) if link_cluster?(node) && !in_data_table?(node)
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# On an index page (an A-Z list, a category grid) the clusters are the
|
|
200
|
+
# content, not navigation around it. A footer sitemap, or a few skip and
|
|
201
|
+
# account links left in a root that missed the content, is no index.
|
|
202
|
+
def clusters_dominate?(root, clusters)
|
|
203
|
+
index = outermost(clusters).reject do |node|
|
|
204
|
+
%w[header footer].include?(node.name) || Dom.ancestors_named(node, "header", "footer").any?
|
|
205
|
+
end
|
|
206
|
+
length = index.sum { |node| Dom.text(node).length }
|
|
207
|
+
length >= MIN_INDEX_LENGTH && length >= Dom.text(root).length * 0.5
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def outermost(nodes)
|
|
211
|
+
keys = nodes.map { |node| Dom.key(node) }.to_set
|
|
212
|
+
nodes.reject do |node|
|
|
213
|
+
node.ancestors.any? { |ancestor| ancestor.element? && keys.include?(Dom.key(ancestor)) }
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def link_cluster?(node)
|
|
218
|
+
links = node.css("a[href]")
|
|
219
|
+
return false if links.size < 3
|
|
220
|
+
|
|
221
|
+
text_length = Dom.text(node).length
|
|
222
|
+
lengths = links.map { |link| Dom.text(link).length }
|
|
223
|
+
text_length.positive? && lengths.max < 30 && lengths.sum.fdiv(text_length) >= 0.7
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def in_data_table?(node)
|
|
227
|
+
tables = node.name == "table" ? [node] : []
|
|
228
|
+
(tables + Dom.ancestors_named(node, "table")).any? { |table| Tables.data_table?(table) }
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def remove_boilerplate_text(root)
|
|
232
|
+
root.css(TEXT_BLOCKS).each do |node|
|
|
233
|
+
text = Dom.text(node)
|
|
234
|
+
next if text.empty? || text.length > 200
|
|
235
|
+
|
|
236
|
+
boilerplate = text.length <= 80 && text.match?(Patterns::BOILERPLATE_TEXT)
|
|
237
|
+
copyright = text.match?(Patterns::COPYRIGHT_TEXT) && node.at_css("img").nil?
|
|
238
|
+
remove(node) if boilerplate || copyright
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# Links and boxes styled as buttons ("Add to cart", "Book a demo") are
|
|
243
|
+
# controls, not content. Cards (an image, a heading, a list) are content.
|
|
244
|
+
# All are judged before any is removed, so document order does not matter.
|
|
245
|
+
def remove_calls_to_action(root)
|
|
246
|
+
@running_text = {}
|
|
247
|
+
root.css("a, span, div, p").select { |node| call_to_action?(node) }.each { |node| remove(node) }
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def destination(link)
|
|
251
|
+
href = link["href"].to_s.strip
|
|
252
|
+
href unless href.empty? || href.start_with?("#") || href.match?(/\Ajavascript:/i)
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def links_of(node)
|
|
256
|
+
(node.name == "a" ? [node] : node.css("a[href]").to_a).select { |link| destination(link) }
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
def download?(node)
|
|
260
|
+
links_of(node).any? { |link| destination(link).sub(/[?#].*/m, "").match?(DOWNLOAD) }
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def only_link_in_card?(node, card)
|
|
264
|
+
own = links_of(node).map { |link| Dom.key(link) }
|
|
265
|
+
return false if own.empty? || card.nil?
|
|
266
|
+
|
|
267
|
+
card.css("a[href]").none? { |link| destination(link) && !own.include?(Dom.key(link)) }
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def call_to_action?(node)
|
|
271
|
+
return false unless button?(node)
|
|
272
|
+
|
|
273
|
+
text = Dom.text(node)
|
|
274
|
+
return false if text.length > 40 || text.split.size > 6 || text.match?(/\d/) || node.at_css(CALL_TO_ACTION_PARTS)
|
|
275
|
+
return false if Dom.ancestors_named(node, *LABEL_HOLDERS).any?
|
|
276
|
+
|
|
277
|
+
# FAQ questions and section titles that open a disclosure; the toggle
|
|
278
|
+
# may be a wrapper around the styled label.
|
|
279
|
+
wrappers = wrappers(node, text)
|
|
280
|
+
return false if text.end_with?("?") || wrappers.any? { |wrapper| toggle?(wrapper) }
|
|
281
|
+
return false if download?(node) || only_link_in_card?(node, wrappers.last.parent)
|
|
282
|
+
|
|
283
|
+
# In a data table the button is a cell value, like a plan's "Order" column.
|
|
284
|
+
!inside_sentence?(wrappers) && !in_data_table?(node)
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def button?(node)
|
|
288
|
+
return true if node["role"] == "button"
|
|
289
|
+
return false unless "#{node["class"]} #{node["id"]}".downcase.match?(BUTTON_WORD)
|
|
290
|
+
|
|
291
|
+
tokens = Dom.signature(node).split
|
|
292
|
+
tokens.any? { |token| token.match?(BUTTON_CLASS) } && tokens.none? { |token| token.match?(LINK_STYLED_BUTTON) }
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
# The node and the elements around it that hold nothing else, such as the
|
|
296
|
+
# link of an accordion title around a styled label.
|
|
297
|
+
def wrappers(node, text)
|
|
298
|
+
chain = [node]
|
|
299
|
+
parent = node.parent
|
|
300
|
+
while parent&.element? && Dom.text(parent) == text
|
|
301
|
+
chain << parent
|
|
302
|
+
parent = parent.parent
|
|
303
|
+
end
|
|
304
|
+
chain
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
def toggle?(node)
|
|
308
|
+
node.key?("aria-expanded") || node.key?("aria-controls") || Dom.signature(node).match?(Patterns::DISCLOSURE)
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
# A button-styled link in running text ("<a class=btn>Download</a> the
|
|
312
|
+
# installer") is part of the sentence. Blocks and other buttons beside it
|
|
313
|
+
# are not running text, so a button after a heading or in a group is not.
|
|
314
|
+
def inside_sentence?(wrappers)
|
|
315
|
+
control = wrappers.last
|
|
316
|
+
return false if control.parent.nil? || wrappers.any? { |wrapper| Dom.block?(wrapper) }
|
|
317
|
+
|
|
318
|
+
running_text(control.parent) - running_text_length(control) >= 20
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
def running_text(parent)
|
|
322
|
+
@running_text[Dom.key(parent)] ||= parent.children.sum { |child| running_text_length(child) }
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
def running_text_length(child)
|
|
326
|
+
return Dom.text(child).length if child.text?
|
|
327
|
+
return 0 if !child.element? || Dom.block?(child) || button?(child)
|
|
328
|
+
|
|
329
|
+
Dom.text(child).length
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
# Every rule removes through here: nothing inside code is furniture.
|
|
333
|
+
def remove(node)
|
|
334
|
+
node.remove unless Dom.in_code?(node)
|
|
335
|
+
end
|
|
336
|
+
end
|
|
337
|
+
end
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Nokodown
|
|
4
|
+
# mode is :article when the page was narrowed to one body of prose, :page
|
|
5
|
+
# when the whole main region was kept, :full when main_content was off and
|
|
6
|
+
# :none when the document had no body.
|
|
7
|
+
Result = Struct.new(:markdown, :title, :byline, :excerpt, :site_name, :published_time, :lang, :mode, keyword_init: true)
|
|
8
|
+
end
|