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,316 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "cgi/escape"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module Nokodown
|
|
7
|
+
# Title, byline, excerpt, site name, publish date and language, from JSON-LD,
|
|
8
|
+
# <meta> tags and the document, in Readability's order of preference. Runs
|
|
9
|
+
# before the document is cleaned.
|
|
10
|
+
class Metadata
|
|
11
|
+
ARTICLE_TYPES = /\A(?:Article|AdvertiserContentArticle|NewsArticle|AnalysisNewsArticle|AskPublicNewsArticle|BackgroundNewsArticle|OpinionNewsArticle|ReportageNewsArticle|ReviewNewsArticle|Report|SatiricalArticle|ScholarlyArticle|MedicalScholarlyArticle|SocialMediaPosting|BlogPosting|LiveBlogPosting|DiscussionForumPosting|TechArticle|APIReference)\z/.freeze
|
|
12
|
+
PRODUCT_TYPES = /\A(?:Product|ProductGroup|IndividualProduct|Offer|SoftwareApplication|MobileApplication|Book|Vehicle)\z/.freeze
|
|
13
|
+
MICRODATA_FORUM = "[itemtype*='schema.org/DiscussionForumPosting']:not([itemprop]), " \
|
|
14
|
+
"[itemtype*='schema.org/DiscussionForumPosting'][itemprop~=mainEntity]"
|
|
15
|
+
FORUM_GENERATOR = /\A\s*(?:Discourse|XenForo|vBulletin|phpBB|Invision|Vanilla|NodeBB|Flarum|MyBB)/i.freeze
|
|
16
|
+
|
|
17
|
+
PROPERTY = /\s*(article|dc|dcterm|og|twitter)\s*:\s*(author|creator|description|published_time|title|site_name|type)\s*/i.freeze
|
|
18
|
+
NAME = /\A\s*(?:(dc|dcterm|og|twitter|parsely|weibo:(?:article|webpage))\s*[-.:]\s*)?(author|creator|pub-date|description|title|site_name|application-name)\s*\z/i.freeze
|
|
19
|
+
|
|
20
|
+
TITLE_SEPARATORS = '\|\-–—\\\/>»'
|
|
21
|
+
SEPARATOR = /\s[#{TITLE_SEPARATORS}]\s/.freeze
|
|
22
|
+
HIERARCHY_SEPARATOR = %r{\s[\\/>»]\s}.freeze
|
|
23
|
+
SITE_NAME_SEPARATOR = "[#{TITLE_SEPARATORS}:·•]"
|
|
24
|
+
WORD = /[\p{L}\p{N}]+/.freeze
|
|
25
|
+
HEADING_END = /\A([?!.)\]"'”’]*)(?:\s[-–—]\s|\s*[|:·•,])/.freeze
|
|
26
|
+
HOME_LINK = %r{\A(?:(?:https?:)?//[^/?#]+)?/?(?:index\.[a-z]+|[a-z]{2}(?:[-_][a-z]{2})?/?)?(?:[?#].*)?\z}i.freeze
|
|
27
|
+
LOGO_NAME = /logo|brand|site[-_]?(?:title|name)/i.freeze
|
|
28
|
+
|
|
29
|
+
TITLE_KEYS = %w[dc:title dcterm:title og:title weibo:article:title weibo:webpage:title title twitter:title parsely-title].freeze
|
|
30
|
+
BYLINE_KEYS = %w[dc:creator dcterm:creator author parsely-author].freeze
|
|
31
|
+
EXCERPT_KEYS = %w[dc:description dcterm:description og:description weibo:article:description weibo:webpage:description description twitter:description].freeze
|
|
32
|
+
|
|
33
|
+
def initialize(doc)
|
|
34
|
+
@doc = doc
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def to_h
|
|
38
|
+
documents = json_ld_documents
|
|
39
|
+
json = json_ld(documents)
|
|
40
|
+
meta = meta_values
|
|
41
|
+
site_name = clean(json[:site_name] || meta["og:site_name"] || meta["application-name"])
|
|
42
|
+
title = strip_site_name(clean(json[:title] || first_value(meta, TITLE_KEYS)) || document_title, site_name)
|
|
43
|
+
{
|
|
44
|
+
title: title && heading_title(title),
|
|
45
|
+
byline: clean(json[:byline] || first_value(meta, BYLINE_KEYS) || article_author(meta)) || document_byline,
|
|
46
|
+
excerpt: clean(json[:excerpt] || first_value(meta, EXCERPT_KEYS)),
|
|
47
|
+
site_name: site_name,
|
|
48
|
+
published_time: clean(json[:published_time] || meta["article:published_time"] || meta["parsely-pub-date"] || document_published_time),
|
|
49
|
+
lang: clean(@doc.root&.[]("lang")) || clean(@doc.at_css("meta[http-equiv='content-language'], meta[http-equiv='Content-Language']")&.[]("content")),
|
|
50
|
+
page_type: discussion_type(documents) || json[:type] || og_type(meta)
|
|
51
|
+
}
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def first_value(meta, keys)
|
|
57
|
+
keys.map { |key| meta[key] }.compact.first
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def json_ld_documents
|
|
61
|
+
@doc.css("script[type='application/ld+json']").map { |script| parse_json(script.content) }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def json_ld(documents)
|
|
65
|
+
items = documents.flat_map { |document| flatten(document) }
|
|
66
|
+
article = items.find { |item| types(item).any? { |type| type.match?(ARTICLE_TYPES) } }
|
|
67
|
+
return article_fields(article).merge(type: :article) if article
|
|
68
|
+
|
|
69
|
+
product = items.find { |item| types(item).any? { |type| type.match?(PRODUCT_TYPES) } }
|
|
70
|
+
product ? { title: string(product["name"]), type: :product } : {}
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def parse_json(content)
|
|
74
|
+
JSON.parse(content.sub(/\A\s*<!\[CDATA\[/, "").sub(/\]\]>\s*\z/, ""))
|
|
75
|
+
rescue JSON::ParserError
|
|
76
|
+
nil
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def flatten(node)
|
|
80
|
+
case node
|
|
81
|
+
when Array then node.flat_map { |item| flatten(item) }
|
|
82
|
+
when Hash then [node] + (node["@graph"].is_a?(Array) ? flatten(node["@graph"]) : [])
|
|
83
|
+
else []
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def types(item)
|
|
88
|
+
Array(item["@type"]).grep(String)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Threads and Q&A pages, whose replies are content. Takes precedence over
|
|
92
|
+
# the article types, since a thread declares its opening post as a
|
|
93
|
+
# DiscussionForumPosting, often under mainEntity. A posting nested deeper,
|
|
94
|
+
# such as an article's reader comments, does not make the page a thread.
|
|
95
|
+
def discussion_type(documents)
|
|
96
|
+
items = documents.flat_map { |document| flatten(document) }
|
|
97
|
+
declared = (items + items.flat_map { |item| flatten(item["mainEntity"]) }).flat_map { |item| types(item) }
|
|
98
|
+
return :qa if declared.include?("QAPage") || @doc.at_css("[itemtype*='schema.org/QAPage']")
|
|
99
|
+
|
|
100
|
+
forum = declared.include?("DiscussionForumPosting") || @doc.at_css(MICRODATA_FORUM)
|
|
101
|
+
:forum if forum || forum_generator?
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def forum_generator?
|
|
105
|
+
@doc.css("meta[name=generator]").any? { |meta| meta["content"].to_s.match?(FORUM_GENERATOR) }
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def article_fields(item)
|
|
109
|
+
{
|
|
110
|
+
title: article_title(string(item["name"]), string(item["headline"])),
|
|
111
|
+
byline: authors(item["author"]),
|
|
112
|
+
excerpt: string(item["description"]),
|
|
113
|
+
site_name: item["publisher"].is_a?(Hash) ? string(item["publisher"]["name"]) : nil,
|
|
114
|
+
published_time: string(item["datePublished"])
|
|
115
|
+
}
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Some sites put their own name in "name" and the title in "headline":
|
|
119
|
+
# prefer whichever matches the <title>.
|
|
120
|
+
def article_title(name, headline)
|
|
121
|
+
return name || headline unless name && headline && name != headline
|
|
122
|
+
|
|
123
|
+
title = document_title.to_s
|
|
124
|
+
Dom.similarity(headline, title) > 0.75 && Dom.similarity(name, title) <= 0.75 ? headline : name
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def authors(value)
|
|
128
|
+
value = [value] if value.is_a?(Hash)
|
|
129
|
+
names = Array(value).map { |author| author.is_a?(Hash) ? string(author["name"]) : string(author) }.compact
|
|
130
|
+
names.reject! { |name| name.match?(%r{\Ahttps?://}) }
|
|
131
|
+
names.empty? ? nil : names.join(", ")
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def string(value)
|
|
135
|
+
value = value.first if value.is_a?(Array)
|
|
136
|
+
value.is_a?(String) && !value.strip.empty? ? value.strip : nil
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def meta_values
|
|
140
|
+
@doc.css("meta").each_with_object({}) do |meta, values|
|
|
141
|
+
content = meta["content"].to_s.strip
|
|
142
|
+
next if content.empty?
|
|
143
|
+
|
|
144
|
+
if (property = meta["property"].to_s[PROPERTY])
|
|
145
|
+
values[property.downcase.delete(" \t\n")] = content
|
|
146
|
+
elsif (name = meta["name"].to_s).match?(NAME)
|
|
147
|
+
values[name.downcase.delete(" \t\n").tr(".", ":")] = content
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def article_author(meta)
|
|
153
|
+
author = meta["article:author"]
|
|
154
|
+
author unless author.to_s.match?(%r{\Ahttps?://})
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def og_type(meta)
|
|
158
|
+
type = meta["og:type"].to_s.downcase
|
|
159
|
+
return :article if type.start_with?("article")
|
|
160
|
+
|
|
161
|
+
:product if type.include?("product")
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Readability's <title> heuristics: drop the site name around a separator
|
|
165
|
+
# unless that leaves too few words.
|
|
166
|
+
def document_title
|
|
167
|
+
original = Dom.squish((@doc.at_css("head title") || @doc.at_css("title"))&.content.to_s)
|
|
168
|
+
title = Dom.squish(shortened_title(original))
|
|
169
|
+
keep_original = words(title) <= 4 &&
|
|
170
|
+
(!original.match?(HIERARCHY_SEPARATOR) || words(title) != words(original.gsub(SEPARATOR, "")) - 1)
|
|
171
|
+
title = original if keep_original
|
|
172
|
+
title.empty? ? nil : title
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def shortened_title(original)
|
|
176
|
+
if original.match?(SEPARATOR)
|
|
177
|
+
without_last_part(original)
|
|
178
|
+
elsif original.include?(": ")
|
|
179
|
+
after_colon(original)
|
|
180
|
+
elsif original.length > 150 || original.length < 15
|
|
181
|
+
h1s = @doc.css("h1")
|
|
182
|
+
h1s.size == 1 ? Dom.text(h1s.first) : original
|
|
183
|
+
else
|
|
184
|
+
original
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def without_last_part(original)
|
|
189
|
+
last_separator = original.enum_for(:scan, SEPARATOR).map { Regexp.last_match.begin(0) }.last
|
|
190
|
+
title = original[0...last_separator]
|
|
191
|
+
words(title) < 3 ? original.sub(/\A[^#{TITLE_SEPARATORS}]*[#{TITLE_SEPARATORS}]/, "") : title
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def after_colon(original)
|
|
195
|
+
return original if @doc.css("h1, h2").any? { |heading| Dom.text(heading) == original }
|
|
196
|
+
|
|
197
|
+
title = original[(original.rindex(":") + 1)..]
|
|
198
|
+
return original[(original.index(":") + 1)..] if words(title) < 3
|
|
199
|
+
return original if words(original[0...original.index(":")]) > 5
|
|
200
|
+
|
|
201
|
+
title
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def words(string)
|
|
205
|
+
string.split(/\s+/).size
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def strip_site_name(title, site_name)
|
|
209
|
+
return title if title.nil? || site_name.nil? || title.casecmp?(site_name)
|
|
210
|
+
|
|
211
|
+
escaped = Regexp.escape(site_name)
|
|
212
|
+
stripped = title.sub(/\s*#{SITE_NAME_SEPARATOR}\s*#{escaped}\s*\z/i, "").sub(/\A\s*#{escaped}\s*#{SITE_NAME_SEPARATOR}\s*/i, "")
|
|
213
|
+
stripped.empty? ? title : stripped
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# SEO titles wrap the page's name in sections, site names and keywords
|
|
217
|
+
# ("Using the Fetch API - Web APIs | MDN"); the H1s tell which part it is.
|
|
218
|
+
def heading_title(title)
|
|
219
|
+
h1s = @doc.css("body h1")
|
|
220
|
+
title_from_h1(title, h1s) || title_without_site_h1(title, h1s) || title
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# Cut only at a separator: an H1 that is just the first words of a longer
|
|
224
|
+
# name ("Trail Running" in "Trail Running Shoes for Women") says less than
|
|
225
|
+
# the title. A logo H1 names the site, not the page.
|
|
226
|
+
def title_from_h1(title, h1s)
|
|
227
|
+
matches = title.enum_for(:scan, WORD).map { Regexp.last_match }
|
|
228
|
+
title_words = matches.map { |match| match[0].downcase }
|
|
229
|
+
h1s.each do |h1|
|
|
230
|
+
h1_words = tokens(h1.content)
|
|
231
|
+
next if h1_words.empty? || h1_words.size >= title_words.size || title_words.first(h1_words.size) != h1_words
|
|
232
|
+
next if logo?(h1)
|
|
233
|
+
|
|
234
|
+
cut = matches[h1_words.size - 1].end(0)
|
|
235
|
+
rest = title[cut..].match(HEADING_END)
|
|
236
|
+
return title[0, cut] + rest[1] if rest
|
|
237
|
+
end
|
|
238
|
+
nil
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# mdBook and similar generators end every page's title with the book's
|
|
242
|
+
# name, which their menu bar shows as an H1. "Site | Page" titles end
|
|
243
|
+
# with the page's H1 too, so the part kept must be a heading of the page,
|
|
244
|
+
# not of the site, and of at least 3 words, as in without_last_part.
|
|
245
|
+
def title_without_site_h1(title, h1s)
|
|
246
|
+
separator = title.enum_for(:scan, SEPARATOR).map { Regexp.last_match }.last
|
|
247
|
+
return if separator.nil?
|
|
248
|
+
|
|
249
|
+
page = title[0, separator.begin(0)]
|
|
250
|
+
site_words = tokens(title[separator.end(0)..])
|
|
251
|
+
return if words(page) < 3 || site_words.empty? || h1s.none? { |h1| tokens(h1.content) == site_words }
|
|
252
|
+
|
|
253
|
+
page if heading_text?(page)
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
# H1s are left out: title_from_h1 has already cut at a page H1 that starts
|
|
257
|
+
# the title, so an H1 that matches here is most often the site's logo.
|
|
258
|
+
def heading_text?(text)
|
|
259
|
+
text_words = tokens(text)
|
|
260
|
+
@doc.css("body h2, body h3").any? do |heading|
|
|
261
|
+
tokens(heading.content) == text_words && !logo?(heading) && !site_chrome?(heading)
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
# A <header> or <footer> names the site, unless it is the header of the
|
|
266
|
+
# page's <main> or <article>.
|
|
267
|
+
def site_chrome?(node)
|
|
268
|
+
names = Dom.ancestors_named(node, "header", "footer", "main", "article").map(&:name)
|
|
269
|
+
(names & %w[header footer]).any? && (names & %w[main article]).empty?
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
# A logo heading links to the site's root or names itself a logo, brand or
|
|
273
|
+
# site title. A <header> around an H1, or its container's classes, say too
|
|
274
|
+
# little: most such H1s are the page's own title.
|
|
275
|
+
def logo?(heading)
|
|
276
|
+
nodes = [heading] + heading.css("[class], [id]").to_a
|
|
277
|
+
return true if nodes.any? { |node| "#{node["class"]} #{node["id"]}".match?(LOGO_NAME) }
|
|
278
|
+
|
|
279
|
+
heading.css("a[href]").any? { |link| home_link?(link["href"].strip) }
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
# An empty href, or one that starts with a fragment or query, links to
|
|
283
|
+
# the page itself, as a permalink does.
|
|
284
|
+
def home_link?(href)
|
|
285
|
+
!href.empty? && !href.start_with?("#", "?") && href.match?(HOME_LINK)
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
# Split before downcasing, like the title's words, which are scanned first
|
|
289
|
+
# for their offsets: downcasing "İ" adds a combining dot that splits words.
|
|
290
|
+
def tokens(text)
|
|
291
|
+
text.scan(WORD).map(&:downcase)
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
def document_byline
|
|
295
|
+
node = @doc.at_css("[itemprop~=author] [itemprop~=name], [rel=author], [itemprop~=author], .byline, .author")
|
|
296
|
+
return unless node
|
|
297
|
+
|
|
298
|
+
text = Dom.text(node).sub(/\A(?:by|von|par|por)\s+/i, "")
|
|
299
|
+
text if text.length.between?(1, 100)
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
def document_published_time
|
|
303
|
+
node = @doc.at_css("[itemprop~=datePublished][datetime], [itemprop~=datePublished][content], time[pubdate][datetime]")
|
|
304
|
+
node && (node["datetime"] || node["content"])
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
def clean(value)
|
|
308
|
+
return if value.nil?
|
|
309
|
+
|
|
310
|
+
value = value.to_s
|
|
311
|
+
value = CGI.unescapeHTML(value) if value.match?(/&(?:#\d+|#x\h+|[a-z]+);/i)
|
|
312
|
+
value = Dom.squish(value)
|
|
313
|
+
value.empty? ? nil : value
|
|
314
|
+
end
|
|
315
|
+
end
|
|
316
|
+
end
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Nokodown
|
|
4
|
+
# Class/id/role vocabularies. Matched against Dom.signature, which lowercases
|
|
5
|
+
# and splits camelCase so "adContainer" reads as "ad-container".
|
|
6
|
+
module Patterns
|
|
7
|
+
# Word boundaries where "_" and digits separate words too, unlike \b.
|
|
8
|
+
WORD_START = "(?<![a-z])"
|
|
9
|
+
WORD_END = "(?![a-z])"
|
|
10
|
+
|
|
11
|
+
# Site furniture that surrounds the content on any kind of page.
|
|
12
|
+
CHROME = /
|
|
13
|
+
#{WORD_START}(?:nav|navbar|nav-?bar|navigation|nav-?menu|nav-?links|main-?nav|site-?nav|global-?nav|
|
|
14
|
+
menubar|mega-?menu|top-?bar|toolbar|masthead|crumbs|sidenav|side-?nav|pager|
|
|
15
|
+
skip|skip-?link|skip-?links|skip-?to-?content|noprint|no-?print|d-print-none|toc|
|
|
16
|
+
table-?of-?contents|colophon|copyright|left-?rail|right-?rail)#{WORD_END}|
|
|
17
|
+
sidebar|breadcrumb|pagination|
|
|
18
|
+
(?<![\w-])(?:(?:site|global|main|page|app|l|c|o|g)-?)?footer(?:-(?:wrapper|container|inner|main|top|bottom|links|nav|menu))?(?![\w-])
|
|
19
|
+
/x.freeze
|
|
20
|
+
|
|
21
|
+
# Overlays, ads, prompts and widgets that are never part of the content.
|
|
22
|
+
# "ad" may carry a size ("ad300x250") but must not touch other digits:
|
|
23
|
+
# generated hashes such as "elementor-element-38ad5ea" contain it.
|
|
24
|
+
NOISE = /
|
|
25
|
+
cookie|consent|gdpr|#{WORD_START}ccpa#{WORD_END}|onetrust|didomi|cookiebot|usercentrics|
|
|
26
|
+
newsletter|subscribe|#{WORD_START}sign-?up#{WORD_END}|#{WORD_START}opt-?in#{WORD_END}|#{WORD_START}log-?in#{WORD_END}|#{WORD_START}sign-?in#{WORD_END}|
|
|
27
|
+
advert|adsbygoogle|adsense|#{WORD_START}ad-?(?:slot|unit|container|wrapper|wrap|box|banner|block|label|zone|space)s?#{WORD_END}|
|
|
28
|
+
(?<![a-z\d])(?:ad|ads|dfp|gpt-ad)(?:\d+(?:x\d+)?)?(?![a-z\d])|doubleclick|taboola|outbrain|revcontent|zergnet|
|
|
29
|
+
sponsor|#{WORD_START}promoted#{WORD_END}|
|
|
30
|
+
#{WORD_START}(?:share|shares|sharing|share-?bar|share-?buttons?|share-?tools|sharedaddy|addthis|sharethis)#{WORD_END}|
|
|
31
|
+
social|#{WORD_START}follow-?us#{WORD_END}|
|
|
32
|
+
popup|pop-up|#{WORD_START}modal#{WORD_END}|interstitial|offcanvas|off-canvas|flyout|snackbar|tooltip|popover|
|
|
33
|
+
#{WORD_START}toast#{WORD_END}|#{WORD_START}drawer#{WORD_END}|
|
|
34
|
+
editsection|#{WORD_START}feedback#{WORD_END}|was-?this-?helpful|intercom|livechat|chat-?widget|
|
|
35
|
+
related|recommend|recirc|read-?next|more-?stories|you-?may-?like|also-?like|
|
|
36
|
+
#{WORD_START}(?:search-?form|search-?box|search-?bar)#{WORD_END}
|
|
37
|
+
/x.freeze
|
|
38
|
+
|
|
39
|
+
# Secondary content: noise next to an article, content on a hub page.
|
|
40
|
+
# WordPress puts "tag-<slug>" and "comments-open" on the post itself, so
|
|
41
|
+
# those forms must not match.
|
|
42
|
+
SECONDARY = /
|
|
43
|
+
comment(?!s?[-_]?(?:open|closed|enabled|disabled))|disqus|#{WORD_START}(?:respond|replies|reply)#{WORD_END}|
|
|
44
|
+
popular|trending|most-?read|most-?viewed|author-?bio|about-?the-?author|#{WORD_START}rss#{WORD_END}|
|
|
45
|
+
(?<![\w-])(?:post-|entry-|article-)?tags?(?:-?(?:cloud|list|links))?(?![\w-])
|
|
46
|
+
/x.freeze
|
|
47
|
+
|
|
48
|
+
# Customer reviews, ratings and Q&A on a product page, by name or by the
|
|
49
|
+
# class prefixes of review widget vendors. Modifiers ("has-reviews",
|
|
50
|
+
# "reviews-enabled") and WordPress terms ("category-reviews") sit on the
|
|
51
|
+
# wrapper that has reviews, often around the description, so they must
|
|
52
|
+
# not match.
|
|
53
|
+
REVIEWS = /
|
|
54
|
+
#{WORD_START}(?<!has-|no-|with-|category-|tag-)(?:reviews|(?:customer|product|user|individual|pdp|seo|all)-?reviews?|
|
|
55
|
+
review-?(?:lists?|section|container|wrapper|widget|summary|snapshot|stars|histogram|filters?|cards?|items?|content-list)|
|
|
56
|
+
ratings?-?(?:histogram|summary|snapshot|breakdown)|questions?-?(?:and|&|n)?-?answers?|qna|q-?and-?a|
|
|
57
|
+
customer-?questions|ask-?(?:a-)?question)(?!-(?:enabled|disabled))#{WORD_END}|
|
|
58
|
+
yotpo|bazaarvoice|#{WORD_START}bv-|jdgm|judgeme|okendo|#{WORD_START}stamped-|loox|trustpilot|powerreviews|
|
|
59
|
+
#{WORD_START}pr-(?:review|rd|snippet)|reviews-?io|#{WORD_START}junip-|spr-review|rvw-|#{WORD_START}ruk_
|
|
60
|
+
/x.freeze
|
|
61
|
+
|
|
62
|
+
# Cheap first pass: most elements match none of the patterns below.
|
|
63
|
+
PAGE_FURNITURE = Regexp.union(CHROME, NOISE)
|
|
64
|
+
|
|
65
|
+
# Furniture named after where it sits. A section may be named after the
|
|
66
|
+
# table-of-contents entry or skip link that points to it ("toc-para",
|
|
67
|
+
# "skip-item"), but never after a place on the page.
|
|
68
|
+
PLACE = /sidebar|rail|footer|nav|menu|masthead|breadcrumb|crumbs|pager|pagination|copyright|colophon|toolbar|top-?bar/.freeze
|
|
69
|
+
|
|
70
|
+
OVERLAY = /cookie|consent|gdpr|onetrust|didomi|cookiebot|usercentrics|modal|popup|pop-up|popover|dialog|flyout|off-?canvas|interstitial|snackbar|toast|tooltip/.freeze
|
|
71
|
+
|
|
72
|
+
MENU = /#{WORD_START}(?:menu|menus|menu-?items?)#{WORD_END}/.freeze
|
|
73
|
+
|
|
74
|
+
# Site headers only; "card-header" or "plan-header" hold content.
|
|
75
|
+
HEADER = /(?<![\w-])(?:(?:site|global|main|top|page|app|l|c|o|g)-?)?header(?:-(?:wrapper|container|inner|main|top|bar|nav))?(?![\w-])|masthead/.freeze
|
|
76
|
+
|
|
77
|
+
# Collapsed regions that still hold content: accordions, tab panels, FAQs.
|
|
78
|
+
DISCLOSURE = /accordion|collaps|tab-?pane|tab-?panel|tab-?content|faq|answer|expand|disclosure/.freeze
|
|
79
|
+
|
|
80
|
+
# Readability's class weighting regexes.
|
|
81
|
+
POSITIVE = /article|body|content|entry|hentry|h-entry|main|page|pagination|post|text|blog|story/.freeze
|
|
82
|
+
NEGATIVE = /
|
|
83
|
+
-ad-|hidden|^hid$|\shid$|\shid\s|^hid\s|banner|combx|comment|com-|contact|footer|gdpr|masthead|
|
|
84
|
+
media|meta|outbrain|promo|related|scroll|share|shoutbox|sidebar|skyscraper|sponsor|shopping|tags|widget
|
|
85
|
+
/x.freeze
|
|
86
|
+
|
|
87
|
+
# Class tokens where "hidden" or "scroll" names a CSS value or a widget
|
|
88
|
+
# state, not page furniture: overflow-hidden, scroll-mt-20, quick-add-hidden,
|
|
89
|
+
# field--label-hidden. Elements that are really hidden are gone by the time
|
|
90
|
+
# this is used. Bare "hidden", as in "hidden md:block", is not one: it marks
|
|
91
|
+
# a responsive duplicate.
|
|
92
|
+
UTILITY_CLASS = /
|
|
93
|
+
\A-?(?:overflow|overscroll|scroll|snap)(?:-[\w.\[\]]+)*\z|.-+hidden\z|
|
|
94
|
+
scroll-(?:m[trblxy]?|p[trblxy]?|margin|padding)(?![a-z])
|
|
95
|
+
/x.freeze
|
|
96
|
+
|
|
97
|
+
CHROME_ROLES = Set.new(%w[
|
|
98
|
+
banner navigation contentinfo complementary search menu menubar toolbar tablist
|
|
99
|
+
dialog alertdialog tooltip alert status marquee timer
|
|
100
|
+
]).freeze
|
|
101
|
+
|
|
102
|
+
CHROME_ITEMTYPES = %r{schema\.org/(?:WPHeader|WPFooter|WPSideBar|WPAdBlock|SiteNavigationElement)}.freeze
|
|
103
|
+
|
|
104
|
+
ARIA_LABEL = /breadcrumb|pagination|share|social|advert|cookie|newsletter|navigation|\bmenu\b|footer|related/i.freeze
|
|
105
|
+
|
|
106
|
+
# Whole-text matches for short blocks and links that carry no content.
|
|
107
|
+
BOILERPLATE_TEXT = /
|
|
108
|
+
\A(?:
|
|
109
|
+
advertisement|advertising|advert|ad|ads|sponsored(?:\scontent)?|paid\scontent|anzeige|werbung|
|
|
110
|
+
publicit[eé]|publicidad|pubblicit[aà]|реклама|广告|
|
|
111
|
+
skip\sto\s(?:main\s)?(?:content|navigation)|jump\sto\s(?:main\s)?content|
|
|
112
|
+
share(?:\sthis)?(?:\s(?:article|story|post|page))?|share\son\s\w+|tweet(?:\sthis)?|pin\sit|
|
|
113
|
+
email(?:\sthis)?|print(?:\sthis)?(?:\s(?:article|page))?|
|
|
114
|
+
loading|back\sto\stop|scroll\sto\stop|copy\slink|link\scopied|copied|
|
|
115
|
+
(?:read|view|see|show|load)\s(?:more|less)|continue\sreading|click\sto\sexpand|
|
|
116
|
+
(?:hide|show|toggle)\scaption|enlarge\sthis\simage|
|
|
117
|
+
(?:image|photo|picture|slide)\s\d+\s(?:of|\/)\s\d+|\d*\s?comments?|
|
|
118
|
+
(?:this\s\w+\srequires|please\senable|you\sneed\sto\senable)\sjavascript.*|
|
|
119
|
+
permalink|link\sto\sthis\s(?:heading|section)|direct\slink\sto\sthis\s(?:heading|section)|
|
|
120
|
+
add\sto\s(?:cart|bag|basket|wish\s?list|favou?rites|compare)|quick\s(?:shop|view|add)|
|
|
121
|
+
(?:choose|select|more|view)\soptions|compare\sproducts?(?:\s\(\d+\))?|filter\s(?:&|and)\ssort|sort\sby
|
|
122
|
+
)[\s.:!…»›→>]*\z
|
|
123
|
+
/xi.freeze
|
|
124
|
+
|
|
125
|
+
# Labels that introduce a box of links to other stories.
|
|
126
|
+
RELATED_LABEL = /
|
|
127
|
+
\A(?:read\smore|more|related(?:\s(?:stories|articles|posts|content|coverage|reading))?|see\salso|
|
|
128
|
+
also\sread|don'?t\smiss|recommended|must\sread|more\son\sthis|watch|trending|popular)\b[\s:|]*
|
|
129
|
+
/xi.freeze
|
|
130
|
+
|
|
131
|
+
# Short lines that are page furniture inside an article body.
|
|
132
|
+
STRAY_LINE = /
|
|
133
|
+
\A(?:published|updated|posted|last\supdated|first\spublished)\b.{0,80}\d|
|
|
134
|
+
\bfollow\s(?:us|him|her|them|me)\b.{0,60}\b(?:twitter|facebook|instagram|linkedin|threads|bluesky|x)\b
|
|
135
|
+
/xi.freeze
|
|
136
|
+
|
|
137
|
+
LINK_NOISE_TEXT = /
|
|
138
|
+
\A(?:(?:read|view|see|show|load)\s(?:the\s)?(?:full\s)?(?:more|less|article|story|post)|continue\sreading|
|
|
139
|
+
permalink|link\sto\sthis\s(?:heading|section)|copy\slink|edit(?:\sthis\s(?:page|section))?|
|
|
140
|
+
¶|§|\#|🔗)[\s.:!…»›→>]*\z
|
|
141
|
+
/xi.freeze
|
|
142
|
+
|
|
143
|
+
COPYRIGHT_TEXT = /\A(?:copyright\s*)?(?:©|\(c\)|©)|all\srights\sreserved/i.freeze
|
|
144
|
+
|
|
145
|
+
NOISE_HEADING = /
|
|
146
|
+
\A(?:related(?:\s\w+)?|share(?:\sthis)?(?:\s\w+)?|comments?|leave\sa\s(?:comment|reply)|
|
|
147
|
+
advertisement|newsletter|follow\sus|subscribe(?:\s\w+)?|popular(?:\s\w+)?|trending(?:\s\w+)?|
|
|
148
|
+
recommended(?:\s\w+)?|more\sfrom\s.+|you\smay\salso\slike|you\smight\salso\slike|read\snext|
|
|
149
|
+
read\smore|tags|categories|about\sthe\sauthor|sign\sup.*|most\s(?:read|popular|viewed))[\s:]*\z
|
|
150
|
+
/xi.freeze
|
|
151
|
+
end
|
|
152
|
+
end
|