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.
@@ -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
@@ -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