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,236 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Nokodown
|
|
4
|
+
# Removes site furniture (navigation, headers, footers, sidebars) and noise
|
|
5
|
+
# widgets (ads, cookie banners, share bars) from the whole page.
|
|
6
|
+
#
|
|
7
|
+
# No rule may remove a content landmark, an ancestor of one, or an element
|
|
8
|
+
# holding most of the page's prose: class names such as "has-sidebar" and
|
|
9
|
+
# ad-targeting attributes routinely sit on the wrappers around the content.
|
|
10
|
+
class Boilerplate
|
|
11
|
+
LANDMARKS = "main, [role=main], [itemprop~=articleBody]"
|
|
12
|
+
CONTENT_ANCESTORS = %w[article main].freeze
|
|
13
|
+
SECTIONING = %w[article aside main nav section].freeze
|
|
14
|
+
PHRASING = Set.new(%w[a abbr b bdi bdo cite code data dfn em i kbd mark q s samp small span strong sub sup time u var]).freeze
|
|
15
|
+
AD_ATTRIBUTES = %w[data-ad-slot data-ad-unit data-adunit data-ad-client data-google-query-id data-ad-format].freeze
|
|
16
|
+
DRAWER = /(?<![a-z])drawer(?![a-z])/.freeze
|
|
17
|
+
MIN_CARD_RUN = 3
|
|
18
|
+
CARD_RUN_SHARE = 0.2
|
|
19
|
+
|
|
20
|
+
def initialize(body)
|
|
21
|
+
@body = body
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def call
|
|
25
|
+
@stats = Stats.new(@body)
|
|
26
|
+
@total = @stats.text_chars(@body)
|
|
27
|
+
@shielded = shielded_keys
|
|
28
|
+
# Card grids repeat the same class list hundreds of times.
|
|
29
|
+
@furniture = Hash.new { |cache, signature| cache[signature] = signature.match?(Patterns::PAGE_FURNITURE) }
|
|
30
|
+
@prose_blocks = Set.new
|
|
31
|
+
@card_classes = card_classes
|
|
32
|
+
@card_bases = @card_classes.map { |classes| base_classes(classes) }
|
|
33
|
+
walk(@body)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def shielded_keys
|
|
39
|
+
landmarks = @body.css(LANDMARKS).to_a
|
|
40
|
+
articles = @body.css("article")
|
|
41
|
+
landmarks << articles.first if articles.size == 1
|
|
42
|
+
landmarks.flat_map { |node| [node] + node.ancestors.to_a }.map { |node| Dom.key(node) }.compact.to_set
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Code is never furniture, whatever classes its highlighter gives tokens.
|
|
46
|
+
def walk(node)
|
|
47
|
+
node.element_children.each do |child|
|
|
48
|
+
next if Dom::CODE.include?(child.name)
|
|
49
|
+
|
|
50
|
+
if remove?(child)
|
|
51
|
+
child.remove
|
|
52
|
+
else
|
|
53
|
+
walk(child)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Semantic chrome (nav, a page-level footer) goes even when it outweighs
|
|
59
|
+
# the content, as legal footnotes often do; class-name guesses do not.
|
|
60
|
+
def remove?(node)
|
|
61
|
+
return false if @shielded.include?(Dom.key(node))
|
|
62
|
+
# Side notes inside an article are content whatever their class says.
|
|
63
|
+
return false if node.name == "aside" && !aside_noise?(node)
|
|
64
|
+
return true if chrome_element?(node) || chrome_role?(node)
|
|
65
|
+
return false if dominant?(node)
|
|
66
|
+
return false unless advertising?(node) || chrome_label?(node) || class_noise?(node)
|
|
67
|
+
|
|
68
|
+
!inside_sentence?(node)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# A link or span in the middle of running text is part of that text,
|
|
72
|
+
# whatever its class says ("recommendation-inline-link", "tooltip-term").
|
|
73
|
+
def inside_sentence?(node)
|
|
74
|
+
return false unless PHRASING.include?(node.name) && node.parent
|
|
75
|
+
|
|
76
|
+
@stats.chars(node.parent) - @stats.chars(node) >= 20
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def chrome_element?(node)
|
|
80
|
+
case node.name
|
|
81
|
+
when "nav" then true
|
|
82
|
+
when "footer" then !inside_sectioning?(node)
|
|
83
|
+
when "header" then site_header?(node)
|
|
84
|
+
when "aside" then aside_noise?(node)
|
|
85
|
+
else false
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def chrome_role?(node)
|
|
90
|
+
role = node["role"]
|
|
91
|
+
return false unless role && Patterns::CHROME_ROLES.include?(role)
|
|
92
|
+
|
|
93
|
+
case role
|
|
94
|
+
when "complementary" then aside_noise?(node)
|
|
95
|
+
when "banner" then site_header?(node)
|
|
96
|
+
when "alert", "status" then Dom.char_count(node.content) < 300
|
|
97
|
+
when "tablist" then tab_strip?(node)
|
|
98
|
+
else true
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Sites also put role=tablist on the wrapper around the tabs and their
|
|
103
|
+
# panels, or on an FAQ accordion; only a strip of short labels or links
|
|
104
|
+
# is chrome.
|
|
105
|
+
def tab_strip?(node)
|
|
106
|
+
return false if node.at_css("[role=tabpanel]")
|
|
107
|
+
|
|
108
|
+
@stats.text_chars(node) < 200 || @stats.link_density(node) > 0.5
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def advertising?(node)
|
|
112
|
+
return true if node.name == "ins" && node["class"].to_s.include?("adsbygoogle")
|
|
113
|
+
return true if node["id"].to_s.start_with?("div-gpt-ad", "google_ads")
|
|
114
|
+
|
|
115
|
+
AD_ATTRIBUTES.any? { |name| node.key?(name) }
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def chrome_label?(node)
|
|
119
|
+
return true if node["itemtype"].to_s.match?(Patterns::CHROME_ITEMTYPES)
|
|
120
|
+
|
|
121
|
+
node["aria-label"].to_s.match?(Patterns::ARIA_LABEL) && !%w[main article].include?(node.name)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def class_noise?(node)
|
|
125
|
+
return false if !@prose_blocks.empty? && prose_part?(node)
|
|
126
|
+
|
|
127
|
+
furniture_class?(node) && (@card_bases.empty? || !card?(node))
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def card?(node)
|
|
131
|
+
classes = base_classes(node["class"])
|
|
132
|
+
@card_bases.any? { |base| (base - classes).empty? } && card_candidate?(node)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def base_classes(classes)
|
|
136
|
+
classes.to_s.split.reject { |token| token.include?("--") }
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def prose_part?(node)
|
|
140
|
+
node["class"].to_s.split.any? { |token| @prose_blocks.any? { |block| token.start_with?("#{block}_", "#{block}-") } }
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def furniture_class?(node)
|
|
144
|
+
signature = Dom.signature(node)
|
|
145
|
+
return false if signature.empty?
|
|
146
|
+
|
|
147
|
+
chrome = @furniture[signature]
|
|
148
|
+
return site_header?(node) if !chrome && signature.match?(Patterns::HEADER)
|
|
149
|
+
return @stats.link_density(node) > 0.5 if !chrome && signature.match?(Patterns::MENU)
|
|
150
|
+
return false unless chrome
|
|
151
|
+
return false if prose_section?(node, signature)
|
|
152
|
+
|
|
153
|
+
!(signature.match?(Patterns::POSITIVE) && @stats.text_chars(node) >= 200)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Sections named after the TOC entry or skip link that points to them
|
|
157
|
+
# ("toc-para", "skip-item") and accordions named "drawer" like slide-out
|
|
158
|
+
# panels are content when they hold prose. Furniture named for its place
|
|
159
|
+
# on the page or for an overlay, ad or share bar is not.
|
|
160
|
+
def prose_section?(node, signature)
|
|
161
|
+
return false if @stats.text_chars(node) < 400 || @stats.link_density(node) >= 0.2
|
|
162
|
+
return false if signature.match?(Patterns::PLACE) || signature.gsub(DRAWER, " ").match?(Patterns::NOISE)
|
|
163
|
+
return false if node.css("p").size < 2
|
|
164
|
+
|
|
165
|
+
@prose_blocks.merge(node["class"].to_s.split.select { |token| token.downcase.match?(Patterns::PAGE_FURNITURE) })
|
|
166
|
+
true
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# A hub page often splits its content into modules named after their role
|
|
170
|
+
# elsewhere ("recirc-list", "hide-read-more-ad"). None holds enough of the
|
|
171
|
+
# page to be dominant, so a run of them is judged as a whole. Card text
|
|
172
|
+
# is link text, so all characters count.
|
|
173
|
+
def card_classes
|
|
174
|
+
min_chars = @stats.chars(@body) * CARD_RUN_SHARE
|
|
175
|
+
runs = @body.css("[class]").group_by { |node| node["class"] }
|
|
176
|
+
runs.each_with_object(Set.new) do |(classes, nodes), cards|
|
|
177
|
+
cards << classes if card_run?(classes, nodes, min_chars)
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def card_run?(classes, nodes, min_chars)
|
|
182
|
+
# Most class lists fail on size or name alone, before the slower checks below.
|
|
183
|
+
return false if nodes.size < MIN_CARD_RUN || !(furniture_name?(classes) || nodes.any? { |node| node.key?("id") })
|
|
184
|
+
return false if nodes.sum { |node| @stats.chars(node) } < min_chars
|
|
185
|
+
|
|
186
|
+
cards = nodes.select { |node| card_candidate?(node) }
|
|
187
|
+
# Wrappers repeating their child's class would count its text twice.
|
|
188
|
+
cards.reject! { |node| node.ancestors.any? { |ancestor| ancestor.element? && ancestor["class"] == classes } }
|
|
189
|
+
cards.size >= MIN_CARD_RUN && cards.sum { |node| @stats.chars(node) } >= min_chars
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def furniture_name?(classes)
|
|
193
|
+
signature = Dom.signature_of("#{classes} ")
|
|
194
|
+
@furniture[signature] || signature.match?(Patterns::HEADER) || signature.match?(Patterns::MENU)
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# Menus repeat their items too, so a run named after a place is never
|
|
198
|
+
# content, and neither is a run of cookie categories or dialogs.
|
|
199
|
+
def card_candidate?(node)
|
|
200
|
+
return false if @shielded.include?(Dom.key(node)) || !furniture_class?(node)
|
|
201
|
+
return false if Dom.signature(node).match?(Patterns::PLACE) || Dom.signature(node).match?(Patterns::OVERLAY)
|
|
202
|
+
|
|
203
|
+
!chrome_element?(node) && !chrome_role?(node)
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# Per the HTML spec a header inside sectioning content belongs to that
|
|
207
|
+
# section; outside, one holding the page title (h1, few links, no
|
|
208
|
+
# navigation) is still content.
|
|
209
|
+
def site_header?(node)
|
|
210
|
+
return false if inside_sectioning?(node) || dominant?(node)
|
|
211
|
+
|
|
212
|
+
title_block = node.at_css("h1") && @stats.link_density(node) < 0.5 && !node.at_css("nav, [role=navigation], [role=search]")
|
|
213
|
+
!title_block
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def aside_noise?(node)
|
|
217
|
+
!(inside_content?(node) && @stats.link_density(node) < 0.5)
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def inside_sectioning?(node)
|
|
221
|
+
node.ancestors.any? { |ancestor| ancestor.element? && (SECTIONING.include?(ancestor.name) || ancestor["role"] == "main") }
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def inside_content?(node)
|
|
225
|
+
node.ancestors.any? do |ancestor|
|
|
226
|
+
ancestor.element? && (CONTENT_ANCESTORS.include?(ancestor.name) || ancestor["role"] == "main" ||
|
|
227
|
+
ancestor["itemprop"].to_s.include?("articleBody"))
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def dominant?(node)
|
|
232
|
+
text = @stats.text_chars(node)
|
|
233
|
+
text >= 200 && text >= @total * 0.5
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Nokodown
|
|
4
|
+
# Lexbor reads bytes as UTF-8 and ignores <meta charset>, so HTML is
|
|
5
|
+
# transcoded first. Invalid bytes are dropped: they would make every regex
|
|
6
|
+
# downstream raise.
|
|
7
|
+
module Charset
|
|
8
|
+
META_CHARSET = /<meta[^>]+charset\s*=\s*["']?\s*([\w-]+)/i.freeze
|
|
9
|
+
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
def utf8(html)
|
|
13
|
+
html = html.dup.force_encoding(declared(html) || Encoding::UTF_8) if html.encoding == Encoding::BINARY
|
|
14
|
+
html = html.encode(Encoding::UTF_8, invalid: :replace, undef: :replace, replace: "") if html.encoding != Encoding::UTF_8
|
|
15
|
+
html.valid_encoding? ? html : html.scrub("")
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def declared(html)
|
|
19
|
+
charset = html.byteslice(0, 4096)[META_CHARSET, 1]
|
|
20
|
+
return unless charset
|
|
21
|
+
|
|
22
|
+
Encoding.find(charset.sub(/\Autf8\z/i, "UTF-8"))
|
|
23
|
+
rescue ArgumentError
|
|
24
|
+
nil
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Nokodown
|
|
4
|
+
# Readability's conditional cleaning for one container: what it holds (link
|
|
5
|
+
# density, images per paragraph, inputs, embeds) decides whether it is prose
|
|
6
|
+
# or page furniture.
|
|
7
|
+
class ContainerCheck
|
|
8
|
+
HEADINGS = "h1, h2, h3, h4, h5, h6"
|
|
9
|
+
TEXTISH = "span, li, td, blockquote, dl, div, img, ol, p, pre, table, ul"
|
|
10
|
+
AD_WORDS = /\A(?:ad(?:vertising|vertisement)?|pub(?:licité)?|werb(?:ung)?|广告|Реклама|Anuncio)\z/i.freeze
|
|
11
|
+
LOADING_WORDS = /\A(?:(?:loading|正在加载|Загрузка|chargement|cargando)(?:…|\.\.\.)?)\z/i.freeze
|
|
12
|
+
POST_BODY_SHARE = 0.8
|
|
13
|
+
|
|
14
|
+
def initialize(node, content_length, paragraph_keys, memo = {})
|
|
15
|
+
@node = node
|
|
16
|
+
@text = Dom.text(node)
|
|
17
|
+
@content_length = content_length
|
|
18
|
+
@paragraph_keys = paragraph_keys
|
|
19
|
+
@memo = memo
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def self.forget(memo, node)
|
|
23
|
+
memo.delete(:tables) if node.name == "table" || node.at_css("table") || Dom.ancestors_named(node, "table").any?
|
|
24
|
+
memo.delete(:posts) if Dom.thread?(node)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def remove?
|
|
28
|
+
return false if majority? || forum_post?
|
|
29
|
+
|
|
30
|
+
tables, code = Dom.ancestors_named(@node, "table", "code", "pre").partition { |ancestor| ancestor.name == "table" }
|
|
31
|
+
return false if code.any? || data_table_involved?(tables)
|
|
32
|
+
return true if weight.negative?
|
|
33
|
+
return false if @text.count(",") >= 10 || video_embed?
|
|
34
|
+
return true if @text.match?(AD_WORDS) || @text.match?(LOADING_WORDS)
|
|
35
|
+
|
|
36
|
+
suspicious? && !image_gallery?
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
# Page builders (Elementor, Divi) wrap whole articles in "widget" and
|
|
42
|
+
# "module" containers; class weight alone must not delete the article.
|
|
43
|
+
def majority?
|
|
44
|
+
@text.length > @content_length * 0.5
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def weight
|
|
48
|
+
@weight ||= Scorer.cleaning_weight(@node)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def data_table_involved?(outer_tables)
|
|
52
|
+
return true if (@node.name == "table" && data_table?(@node)) || holders_of_data_tables.include?(Dom.key(@node))
|
|
53
|
+
|
|
54
|
+
outer_tables.any? { |table| data_table?(table) }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def holders_of_data_tables
|
|
58
|
+
tables = (@memo[:tables] ||= {})
|
|
59
|
+
tables[:holders] ||= @memo.fetch(:root, @node).css("table").each_with_object(Set.new) do |table, keys|
|
|
60
|
+
next unless data_table?(table)
|
|
61
|
+
|
|
62
|
+
keys << Dom.key(table)
|
|
63
|
+
table.ancestors.each { |ancestor| keys << Dom.key(ancestor) if ancestor.element? }
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def data_table?(table)
|
|
68
|
+
verdicts = ((@memo[:tables] ||= {})[:verdicts] ||= {})
|
|
69
|
+
key = Dom.key(table)
|
|
70
|
+
verdicts.key?(key) ? verdicts[key] : (verdicts[key] = Tables.data_table?(table))
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def post_length(post)
|
|
74
|
+
lengths = (@memo[:posts] ||= {})
|
|
75
|
+
lengths[Dom.key(post)] ||= Dom.text(post).length
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def forum_post?
|
|
79
|
+
return false unless Dom.thread?(@node)
|
|
80
|
+
return true if Dom.thread_post?(@node)
|
|
81
|
+
|
|
82
|
+
post = @node.ancestors.find { |ancestor| ancestor.element? && Dom.thread_post?(ancestor) }
|
|
83
|
+
!post.nil? && @text.length >= post_length(post) * POST_BODY_SHARE
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def video_embed?
|
|
87
|
+
@node.css("object, embed, iframe").any? do |embed|
|
|
88
|
+
embed.attribute_nodes.any? { |attribute| attribute.value.to_s.match?(Preprocessor::VIDEO_HOSTS) }
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def suspicious?
|
|
93
|
+
too_many_images? || too_many_list_items? || too_many_inputs? || short_with_links? ||
|
|
94
|
+
too_linky? || suspicious_embeds? || no_text_containers?
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def too_many_images?
|
|
98
|
+
!figure_child? && images > 1 && paragraphs.fdiv(images) < 0.5
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def too_many_list_items?
|
|
102
|
+
!list? && @node.css("li").size - 100 > paragraphs
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def too_many_inputs?
|
|
106
|
+
@node.css("input").size > paragraphs / 3
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def short_with_links?
|
|
110
|
+
!list? && !figure_child? && density(HEADINGS) < 0.9 && @text.length < 25 &&
|
|
111
|
+
(images.zero? || images > 2) && link_density.positive?
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def too_linky?
|
|
115
|
+
(!list? && weight < 25 && link_density > 0.2) || (weight >= 25 && link_density > 0.5)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def suspicious_embeds?
|
|
119
|
+
embeds = @node.css("object, embed, iframe").size
|
|
120
|
+
(embeds == 1 && @text.length < 75) || embeds > 1
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def no_text_containers?
|
|
124
|
+
images.zero? && density(TEXTISH, own_text_length).zero?
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Galleries survive as long as every list item is a single image.
|
|
128
|
+
def image_gallery?
|
|
129
|
+
list? && @node.element_children.none? { |item| item.element_children.size > 1 } && images == @node.css("li").size
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def paragraphs
|
|
133
|
+
@paragraphs ||= @node.css("p").size + @node.css("div").count { |div| @paragraph_keys.include?(Dom.key(div)) }
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def images
|
|
137
|
+
@images ||= @node.css("img").size
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def list?
|
|
141
|
+
return @list if defined?(@list)
|
|
142
|
+
|
|
143
|
+
@list = %w[ul ol].include?(@node.name) || density("ul, ol") > 0.9
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def figure_child?
|
|
147
|
+
return @figure_child if defined?(@figure_child)
|
|
148
|
+
|
|
149
|
+
@figure_child = Dom.ancestors_named(@node, "figure").any?
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def link_density
|
|
153
|
+
@link_density ||= begin
|
|
154
|
+
linked = @node.css("a").sum do |anchor|
|
|
155
|
+
weight = anchor["href"].to_s.start_with?("#") ? Stats::FRAGMENT_LINK_WEIGHT : 1.0
|
|
156
|
+
Dom.text(anchor).length * weight
|
|
157
|
+
end
|
|
158
|
+
@text.empty? ? 0.0 : linked / @text.length
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def density(selector, extra = 0)
|
|
163
|
+
return 0.0 if @text.empty?
|
|
164
|
+
|
|
165
|
+
(@node.css(selector).sum { |child| Dom.text(child).length } + extra).fdiv(@text.length)
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# Readability wraps a container's loose text in <p>, which then counts as text.
|
|
169
|
+
def own_text_length
|
|
170
|
+
loose = @node.children.reject { |child| child.element? && Dom::PARAGRAPH_BREAKERS.include?(child.name) }
|
|
171
|
+
Dom.squish(loose.map(&:content).join).length
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Nokodown
|
|
4
|
+
# Decides what part of the cleaned page is the content.
|
|
5
|
+
#
|
|
6
|
+
# Pages with one dominant body of prose (articles, posts, documentation)
|
|
7
|
+
# are narrowed to that body using Readability's scoring. Everything else
|
|
8
|
+
# (home pages, listings, product pages, pricing tables) keeps the whole main
|
|
9
|
+
# region, because there the content is spread across many small blocks and
|
|
10
|
+
# picking a single winner would throw most of it away.
|
|
11
|
+
class ContentLocator
|
|
12
|
+
Selection = Struct.new(:nodes, :mode)
|
|
13
|
+
|
|
14
|
+
MIN_ARTICLE_CHARS = 250
|
|
15
|
+
|
|
16
|
+
# Share of the page's prose the article candidate must hold, by the page
|
|
17
|
+
# type its metadata declares. A declared article only needs a real body.
|
|
18
|
+
REQUIRED_SHARE = { article: 0.15, product: 0.9 }.freeze
|
|
19
|
+
DEFAULT_SHARE = 0.7
|
|
20
|
+
|
|
21
|
+
# Page types whose replies are content: forum threads and Q&A pages.
|
|
22
|
+
DISCUSSIONS = %i[forum qa].freeze
|
|
23
|
+
|
|
24
|
+
# Share of a forum page that one comment-named container, or a run of
|
|
25
|
+
# same-named ones, must hold to be the thread itself.
|
|
26
|
+
THREAD_SHARE = 0.25
|
|
27
|
+
THREAD_RUN_SHARE = 0.5
|
|
28
|
+
|
|
29
|
+
def initialize(body, page_type: nil)
|
|
30
|
+
@body = body
|
|
31
|
+
@page_type = page_type
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def call
|
|
35
|
+
stats = Stats.new(@body)
|
|
36
|
+
root = main_region(stats) || @body
|
|
37
|
+
mark_thread(root, stats) if @page_type == :forum
|
|
38
|
+
result = Scorer.new(root, stats).call
|
|
39
|
+
return Selection.new(result.nodes, :article) if result && article?(result, root, stats)
|
|
40
|
+
|
|
41
|
+
# A thread's posts score alike, so no candidate holds it, and the replies'
|
|
42
|
+
# container alone would leave out the question. Cleaning the whole region
|
|
43
|
+
# as an article still drops per-answer comments, badges and chrome.
|
|
44
|
+
return Selection.new([root], :article) if DISCUSSIONS.include?(@page_type)
|
|
45
|
+
|
|
46
|
+
Selection.new([root], :page)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
# The outermost <main> holding a fair share of the page, if any.
|
|
52
|
+
def main_region(stats)
|
|
53
|
+
regions = @body.css("main, [role=main]").reject do |node|
|
|
54
|
+
node.ancestors.any? { |ancestor| ancestor.element? && (ancestor.name == "main" || ancestor["role"] == "main") }
|
|
55
|
+
end
|
|
56
|
+
best = regions.max_by { |node| stats.text_chars(node) }
|
|
57
|
+
best if best && stats.text_chars(best) >= stats.text_chars(@body) * 0.4
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def article?(result, root, stats)
|
|
61
|
+
article_text = result.nodes.sum { |node| stats.text_chars(node) }
|
|
62
|
+
return false if article_text < MIN_ARTICLE_CHARS
|
|
63
|
+
|
|
64
|
+
page_text = stats.text_chars(root) - secondary_text(root, result.nodes, stats)
|
|
65
|
+
return true if page_text <= 0
|
|
66
|
+
|
|
67
|
+
article_text / page_text >= REQUIRED_SHARE.fetch(@page_type, DEFAULT_SHARE)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Comment threads and "most popular" rails beside an article do not count
|
|
71
|
+
# against it. A forum's own thread is not beside it.
|
|
72
|
+
def secondary_text(root, article_nodes, stats)
|
|
73
|
+
article_keys = article_nodes.map { |node| Dom.key(node) }
|
|
74
|
+
article_ancestor_keys = article_nodes.flat_map { |node| node.ancestors.map { |ancestor| Dom.key(ancestor) } }
|
|
75
|
+
total = 0.0
|
|
76
|
+
stack = root.element_children.to_a
|
|
77
|
+
until stack.empty?
|
|
78
|
+
node = stack.pop
|
|
79
|
+
key = Dom.key(node)
|
|
80
|
+
next if article_keys.include?(key) || Dom.thread?(node)
|
|
81
|
+
|
|
82
|
+
if Dom.signature(node).match?(Patterns::SECONDARY) && !article_ancestor_keys.include?(key)
|
|
83
|
+
total += stats.text_chars(node)
|
|
84
|
+
else
|
|
85
|
+
stack.concat(node.element_children.to_a)
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
total
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# A forum's replies sit in containers named like a comment section: one
|
|
92
|
+
# holding a fair share of the page, or a run of them, one per post. Not on
|
|
93
|
+
# Q&A pages, where the comments under a short question pass the share.
|
|
94
|
+
def mark_thread(root, stats)
|
|
95
|
+
total = stats.text_chars(root)
|
|
96
|
+
regions = outermost_secondary(root)
|
|
97
|
+
thread = regions.select { |node| stats.text_chars(node) >= total * THREAD_SHARE }
|
|
98
|
+
runs = regions.group_by { |node| "#{node.name} #{Dom.signature(node).delete("0-9")}" }.values +
|
|
99
|
+
regions.group_by { |node| "#{node.name} #{Dom.key(node.parent)}" }.values
|
|
100
|
+
runs.each { |run| thread.concat(run) if run.sum { |node| stats.text_chars(node) } >= total * THREAD_RUN_SHARE }
|
|
101
|
+
thread.each { |node| Dom.mark_as_thread(node) }
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def outermost_secondary(root)
|
|
105
|
+
found = []
|
|
106
|
+
stack = root.element_children.to_a
|
|
107
|
+
until stack.empty?
|
|
108
|
+
node = stack.pop
|
|
109
|
+
if Dom.signature(node).match?(Patterns::SECONDARY)
|
|
110
|
+
found << node
|
|
111
|
+
else
|
|
112
|
+
stack.concat(node.element_children.to_a)
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
found
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
data/lib/nokodown/dom.rb
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "set"
|
|
4
|
+
|
|
5
|
+
module Nokodown
|
|
6
|
+
module Dom
|
|
7
|
+
BLOCK = Set.new(%w[
|
|
8
|
+
address article aside blockquote body caption center dd details dialog dir div dl dt
|
|
9
|
+
fieldset figcaption figure footer form h1 h2 h3 h4 h5 h6 header hgroup hr html iframe
|
|
10
|
+
legend li main menu nav ol p pre section summary table tbody td tfoot th thead tr ul
|
|
11
|
+
]).freeze
|
|
12
|
+
|
|
13
|
+
HEADINGS = Set.new(%w[h1 h2 h3 h4 h5 h6]).freeze
|
|
14
|
+
|
|
15
|
+
# Elements that stop Readability from treating a div as a paragraph.
|
|
16
|
+
PARAGRAPH_BREAKERS = Set.new(%w[blockquote dl div img ol p pre table ul]).freeze
|
|
17
|
+
|
|
18
|
+
CODE = %w[pre code].freeze
|
|
19
|
+
|
|
20
|
+
NBSP = "\u00A0"
|
|
21
|
+
WHITESPACE = " \t\n\r\f\u00A0"
|
|
22
|
+
|
|
23
|
+
# Every character [[:space:]] matches. tr/squeeze is several times faster
|
|
24
|
+
# than a regex gsub on large subtrees.
|
|
25
|
+
UNICODE_SPACES = "\t\n\v\f\r\u0085\u00A0\u1680\u2000-\u200A\u2028\u2029\u202F\u205F\u3000"
|
|
26
|
+
|
|
27
|
+
# Nokolexbor returns a new Ruby object every time a node is read, so nodes
|
|
28
|
+
# cannot be hash keys. Elements get a numbered attribute to key on instead.
|
|
29
|
+
KEY = "data-nokodown-key"
|
|
30
|
+
|
|
31
|
+
UUID = /\h{8}-\h{4}-\h{4}-\h{4}-\h{12}/.freeze
|
|
32
|
+
CSS_MODULE_HASH = /__(?=[\w-]{0,4}[A-Z\d])[\w-]{5}(?![\w-])/.freeze
|
|
33
|
+
|
|
34
|
+
# On a forum, the replies sit in containers named like a comment section.
|
|
35
|
+
# ContentLocator marks them and everything inside them, so the mark
|
|
36
|
+
# survives the copy that article cleaning works on.
|
|
37
|
+
THREAD = "data-nokodown-thread"
|
|
38
|
+
|
|
39
|
+
module_function
|
|
40
|
+
|
|
41
|
+
def assign_keys(root)
|
|
42
|
+
root[KEY] = "0"
|
|
43
|
+
root.css("*").each_with_index { |node, index| node[KEY] = (index + 1).to_s }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def key(node)
|
|
47
|
+
node[KEY]
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def mark_as_thread(node)
|
|
51
|
+
node[THREAD] = "1"
|
|
52
|
+
node.css("*").each { |descendant| descendant[THREAD] = "1" }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def thread?(node)
|
|
56
|
+
!node[THREAD].nil?
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def thread_post?(node)
|
|
60
|
+
thread?(node) && signature(node).match?(Patterns::SECONDARY)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Keys of the <p>s and of the divs Readability would rewrite into <p>
|
|
64
|
+
# (no block-level descendants), found in one bottom-up pass.
|
|
65
|
+
def paragraph_keys(root)
|
|
66
|
+
keys = Set.new
|
|
67
|
+
breaks = {}
|
|
68
|
+
root.traverse do |node|
|
|
69
|
+
next unless node.element?
|
|
70
|
+
|
|
71
|
+
key = key(node)
|
|
72
|
+
breaks[key] = node.element_children.any? { |child| PARAGRAPH_BREAKERS.include?(child.name) || breaks[key(child)] }
|
|
73
|
+
keys << key if node.name == "p" || (node.name == "div" && !breaks[key])
|
|
74
|
+
end
|
|
75
|
+
keys
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Nokolexbor answers ancestors(selector) by running CSS matching per
|
|
79
|
+
# ancestor, which dominated extraction time; compare tag names instead.
|
|
80
|
+
def ancestors_named(node, *names)
|
|
81
|
+
node.ancestors.select { |ancestor| ancestor.element? && names.include?(ancestor.name) }
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Syntax highlighters wrap tokens in spans whose class ("token comment",
|
|
85
|
+
# "tag") or whole text ("print", "email") reads like page furniture.
|
|
86
|
+
def in_code?(node)
|
|
87
|
+
CODE.include?(node.name) || ancestors_named(node, *CODE).any?
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def block?(node)
|
|
91
|
+
node.element? && BLOCK.include?(node.name)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def heading?(node)
|
|
95
|
+
node.element? && HEADINGS.include?(node.name)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def char_count(string)
|
|
99
|
+
string.length - string.count(WHITESPACE)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def text(node)
|
|
103
|
+
squish(node.content)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def squish(string)
|
|
107
|
+
string.tr(UNICODE_SPACES, " ").squeeze(" ").strip
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def signature(node)
|
|
111
|
+
classes = node["class"]
|
|
112
|
+
id = node["id"]
|
|
113
|
+
return "" if classes.nil? && id.nil?
|
|
114
|
+
|
|
115
|
+
signature_of("#{classes} #{id}")
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def signature_of(value)
|
|
119
|
+
# Generated ids (Webflow "w-node-<uuid>") would otherwise read as words.
|
|
120
|
+
value = value.gsub(UUID, " ") if value.match?(UUID)
|
|
121
|
+
value = value.gsub(CSS_MODULE_HASH, "") if value.include?("__")
|
|
122
|
+
value = value.gsub(/([a-z\d])([A-Z])/, '\1-\2') if value.match?(/[a-z\d][A-Z]/)
|
|
123
|
+
value.downcase
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Readability's text similarity: share of B's distinct tokens that also occur in A.
|
|
127
|
+
def similarity(a, b)
|
|
128
|
+
tokens_a = tokenize(a)
|
|
129
|
+
tokens_b = tokenize(b)
|
|
130
|
+
return 0.0 if tokens_a.empty? || tokens_b.empty?
|
|
131
|
+
|
|
132
|
+
unique_b = tokens_b - tokens_a
|
|
133
|
+
1.0 - (unique_b.join(" ").length.to_f / tokens_b.join(" ").length)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def tokenize(string)
|
|
137
|
+
string.to_s.downcase.split(/[^\p{L}\p{N}]+/).reject(&:empty?)
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|