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,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