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,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\)|&copy;)|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