html2rss 0.32.4 → 0.34.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 +4 -4
- data/README.md +4 -2
- data/lib/html2rss/article/deduplicator.rb +5 -2
- data/lib/html2rss/article/enclosure.rb +185 -25
- data/lib/html2rss/article/title.rb +203 -0
- data/lib/html2rss/article.rb +79 -13
- data/lib/html2rss/auto_source/README.md +16 -8
- data/lib/html2rss/auto_source/cleanup.rb +127 -53
- data/lib/html2rss/auto_source/corroboration.rb +167 -0
- data/lib/html2rss/auto_source/page_links/anchor_claims.rb +82 -0
- data/lib/html2rss/auto_source/page_links/card_units.rb +77 -0
- data/lib/html2rss/auto_source/page_links/label_binding.rb +79 -0
- data/lib/html2rss/auto_source/page_links/sponsored_cards.rb +147 -0
- data/lib/html2rss/auto_source/page_links.rb +164 -0
- data/lib/html2rss/auto_source/scraper/html.rb +11 -45
- data/lib/html2rss/auto_source/scraper/json_document.rb +3 -6
- data/lib/html2rss/auto_source/scraper/json_state/article_normalizer.rb +202 -125
- data/lib/html2rss/auto_source/scraper/json_state/candidate_detector.rb +27 -38
- data/lib/html2rss/auto_source/scraper/json_state/document_scanner.rb +123 -123
- data/lib/html2rss/auto_source/scraper/json_state/value_finder.rb +27 -35
- data/lib/html2rss/auto_source/scraper/json_state.rb +4 -9
- data/lib/html2rss/auto_source/scraper/meta_oembed.rb +27 -5
- data/lib/html2rss/auto_source/scraper/microdata.rb +233 -248
- data/lib/html2rss/auto_source/scraper/microformats2.rb +10 -4
- data/lib/html2rss/auto_source/scraper/native_feed.rb +28 -18
- data/lib/html2rss/auto_source/scraper/schema/category_extractor.rb +7 -2
- data/lib/html2rss/auto_source/scraper/schema/item_list.rb +5 -3
- data/lib/html2rss/auto_source/scraper/schema/thing.rb +140 -22
- data/lib/html2rss/auto_source/scraper/schema.rb +52 -8
- data/lib/html2rss/auto_source/scraper/semantic_html/entry_deduplicator.rb +3 -9
- data/lib/html2rss/auto_source/scraper/semantic_html.rb +12 -20
- data/lib/html2rss/auto_source/scraper/sitemap/parser.rb +1 -4
- data/lib/html2rss/auto_source/scraper/sitemap.rb +7 -14
- data/lib/html2rss/auto_source/scraper/wordpress_api/page_scope.rb +2 -2
- data/lib/html2rss/auto_source/scraper/wordpress_api/posts_endpoint.rb +1 -3
- data/lib/html2rss/auto_source/scraper/wordpress_api.rb +7 -7
- data/lib/html2rss/auto_source/scraper/xhr_articles.rb +2 -4
- data/lib/html2rss/auto_source/scraper.rb +10 -13
- data/lib/html2rss/auto_source/segment.rb +19 -5
- data/lib/html2rss/auto_source/segmenter/cluster.rb +18 -22
- data/lib/html2rss/auto_source/segmenter/list.rb +35 -59
- data/lib/html2rss/auto_source/segmenter/primary_link.rb +29 -4
- data/lib/html2rss/auto_source/segmenter/semantic.rb +23 -19
- data/lib/html2rss/auto_source/segmenter.rb +4 -8
- data/lib/html2rss/auto_source.rb +81 -26
- data/lib/html2rss/batch.rb +24 -32
- data/lib/html2rss/capture/README.md +6 -3
- data/lib/html2rss/capture.rb +51 -59
- data/lib/html2rss/channel.rb +30 -12
- data/lib/html2rss/cli/render.rb +97 -96
- data/lib/html2rss/cli/validate.rb +2 -4
- data/lib/html2rss/cli.rb +55 -38
- data/lib/html2rss/config/auto_source_contract.rb +52 -45
- data/lib/html2rss/config/issue_mapper.rb +4 -1
- data/lib/html2rss/config/request_controls.rb +16 -5
- data/lib/html2rss/config/schema.rb +23 -15
- data/lib/html2rss/config/selectors_validator.rb +3 -2
- data/lib/html2rss/config/validation_issue.rb +4 -1
- data/lib/html2rss/config/validator.rb +14 -3
- data/lib/html2rss/config.rb +54 -28
- data/lib/html2rss/contract_error.rb +9 -0
- data/lib/html2rss/defaults.rb +25 -38
- data/lib/html2rss/doctor/botasaurus.rb +18 -22
- data/lib/html2rss/error.rb +17 -3
- data/lib/html2rss/error_kind.rb +39 -0
- data/lib/html2rss/feed_builder/item_presentation.rb +22 -9
- data/lib/html2rss/feed_builder/json_feed/item.rb +2 -1
- data/lib/html2rss/feed_builder/json_feed.rb +2 -2
- data/lib/html2rss/feed_builder/rss/stylesheet.rb +3 -3
- data/lib/html2rss/feed_builder/rss.rb +50 -7
- data/lib/html2rss/feed_builder.rb +2 -1
- data/lib/html2rss/feed_pipeline/README.md +1 -1
- data/lib/html2rss/feed_pipeline/auto_fallback.rb +16 -34
- data/lib/html2rss/feed_pipeline/strategy_plan.rb +3 -3
- data/lib/html2rss/feed_pipeline.rb +40 -27
- data/lib/html2rss/feed_resolution/README.md +1 -1
- data/lib/html2rss/feed_resolution/candidate_generator.rb +6 -8
- data/lib/html2rss/feed_resolution/options.rb +3 -7
- data/lib/html2rss/feed_resolution/policy.rb +29 -33
- data/lib/html2rss/feed_resolution/probe.rb +6 -4
- data/lib/html2rss/feed_resolution/scorer.rb +29 -29
- data/lib/html2rss/feed_resolution.rb +16 -21
- data/lib/html2rss/feed_result.rb +11 -6
- data/lib/html2rss/hash_util.rb +91 -90
- data/lib/html2rss/html/article_extractor/category_extractor.rb +7 -10
- data/lib/html2rss/html/article_extractor/date_extractor.rb +1 -1
- data/lib/html2rss/html/article_extractor/enclosure_extractor.rb +12 -33
- data/lib/html2rss/html/article_extractor/heading_extractor.rb +2 -3
- data/lib/html2rss/html/article_extractor/id_generator.rb +7 -10
- data/lib/html2rss/html/article_extractor/image_extractor.rb +9 -1
- data/lib/html2rss/html/article_extractor.rb +19 -47
- data/lib/html2rss/html/article_rules/category.rb +3 -3
- data/lib/html2rss/html/article_rules/description.rb +6 -15
- data/lib/html2rss/html/article_rules/enclosure.rb +70 -42
- data/lib/html2rss/html/article_rules/image.rb +30 -16
- data/lib/html2rss/html/feed_link.rb +7 -9
- data/lib/html2rss/html/navigator/text_extractor.rb +26 -16
- data/lib/html2rss/html/navigator.rb +39 -9
- data/lib/html2rss/html/probe.rb +55 -51
- data/lib/html2rss/html/rendering/description_builder.rb +46 -32
- data/lib/html2rss/html/rendering.rb +2 -9
- data/lib/html2rss/html/sst_article_extractor.rb +55 -145
- data/lib/html2rss/html/title_pieces/kicker.rb +147 -0
- data/lib/html2rss/html/title_pieces/nodes.rb +107 -0
- data/lib/html2rss/html/title_pieces/nokogiri_nodes.rb +70 -0
- data/lib/html2rss/html/title_pieces/sst_nodes.rb +71 -0
- data/lib/html2rss/html/title_pieces.rb +169 -0
- data/lib/html2rss/html/visibility.rb +56 -0
- data/lib/html2rss/html.rb +2 -0
- data/lib/html2rss/link_destination/destination_facts.rb +17 -1
- data/lib/html2rss/link_destination/noise_policy.rb +11 -10
- data/lib/html2rss/link_destination/path_classifier.rb +111 -42
- data/lib/html2rss/link_destination/sponsorship/label_text.rb +82 -0
- data/lib/html2rss/link_destination/sponsorship.rb +184 -0
- data/lib/html2rss/link_destination/text_classifier.rb +104 -8
- data/lib/html2rss/mcp/README.md +13 -1
- data/lib/html2rss/mcp/config_argument.rb +14 -4
- data/lib/html2rss/mcp/contract.rb +20 -9
- data/lib/html2rss/mcp/outcome.rb +18 -17
- data/lib/html2rss/mcp/runtime.rb +53 -23
- data/lib/html2rss/mcp/server/tools.rb +9 -9
- data/lib/html2rss/mcp/server.rb +13 -4
- data/lib/html2rss/option.rb +48 -0
- data/lib/html2rss/page_recon/README.md +1 -1
- data/lib/html2rss/page_recon/diagnostics.rb +9 -9
- data/lib/html2rss/page_recon.rb +0 -4
- data/lib/html2rss/recon.rb +19 -23
- data/lib/html2rss/request_service/blocked_surface.rb +5 -8
- data/lib/html2rss/request_service/botasaurus_contract.rb +25 -3
- data/lib/html2rss/request_service/botasaurus_strategy.rb +44 -12
- data/lib/html2rss/request_service/budget.rb +3 -24
- data/lib/html2rss/request_service/compressed_body.rb +36 -48
- data/lib/html2rss/request_service/context.rb +16 -17
- data/lib/html2rss/request_service/cookie_jar.rb +89 -0
- data/lib/html2rss/request_service/cookie_store.rb +79 -0
- data/lib/html2rss/request_service/feed_advertisement.rb +72 -0
- data/lib/html2rss/request_service/httpx_strategy.rb +7 -3
- data/lib/html2rss/request_service/local_file_strategy.rb +2 -3
- data/lib/html2rss/request_service/network_guard.rb +3 -2
- data/lib/html2rss/request_service/policy.rb +55 -36
- data/lib/html2rss/request_service/replay_breaker.rb +56 -0
- data/lib/html2rss/request_service/response.rb +33 -19
- data/lib/html2rss/request_service/response_cache.rb +109 -0
- data/lib/html2rss/request_service/response_caching.rb +95 -0
- data/lib/html2rss/request_service/strategy.rb +5 -11
- data/lib/html2rss/request_service.rb +71 -12
- data/lib/html2rss/request_session/pager/base.rb +3 -10
- data/lib/html2rss/request_session/pager/custom_selector.rb +4 -4
- data/lib/html2rss/request_session/pager/offset.rb +1 -7
- data/lib/html2rss/request_session/pager/rel_next.rb +1 -1
- data/lib/html2rss/request_session/pager/url_template.rb +1 -7
- data/lib/html2rss/request_session/pager.rb +1 -1
- data/lib/html2rss/request_session.rb +6 -2
- data/lib/html2rss/scoring/cluster_scorer.rb +1 -1
- data/lib/html2rss/scoring/container_assessor.rb +12 -13
- data/lib/html2rss/scoring/engine.rb +10 -38
- data/lib/html2rss/scoring/link_resolver.rb +2 -7
- data/lib/html2rss/scoring/observation.rb +2 -2
- data/lib/html2rss/selector_candidates.rb +19 -8
- data/lib/html2rss/selectors/README.md +1 -1
- data/lib/html2rss/selectors/extractors/href.rb +2 -4
- data/lib/html2rss/selectors/json_xml.rb +33 -34
- data/lib/html2rss/selectors/option_spec.rb +4 -1
- data/lib/html2rss/selectors/post_processors/base.rb +0 -8
- data/lib/html2rss/selectors/post_processors/gsub.rb +9 -10
- data/lib/html2rss/selectors/post_processors/html_transformers/keep_playable_media.rb +50 -0
- data/lib/html2rss/selectors/post_processors/html_transformers/transform_urls_to_absolute_ones.rb +1 -3
- data/lib/html2rss/selectors/post_processors/parse_time.rb +5 -6
- data/lib/html2rss/selectors/post_processors/parse_uri.rb +1 -1
- data/lib/html2rss/selectors/post_processors/sanitize_html.rb +18 -5
- data/lib/html2rss/selectors/post_processors/substring.rb +1 -1
- data/lib/html2rss/selectors/schema_export.rb +60 -60
- data/lib/html2rss/selectors.rb +67 -30
- data/lib/html2rss/sst/attrs.rb +2 -2
- data/lib/html2rss/sst/document.rb +3 -3
- data/lib/html2rss/sst/index.rb +30 -11
- data/lib/html2rss/sst/node.rb +42 -29
- data/lib/html2rss/sst/normalizer.rb +47 -34
- data/lib/html2rss/sst/tags.rb +113 -2
- data/lib/html2rss/sst/text.rb +94 -58
- data/lib/html2rss/sst.rb +2 -0
- data/lib/html2rss/status.rb +27 -31
- data/lib/html2rss/surface_category.rb +1 -1
- data/lib/html2rss/syndication/README.md +12 -6
- data/lib/html2rss/syndication/alternate_links.rb +39 -0
- data/lib/html2rss/syndication/channel_facts.rb +87 -0
- data/lib/html2rss/syndication/discovery.rb +171 -187
- data/lib/html2rss/syndication/feed.rb +20 -0
- data/lib/html2rss/syndication/item_media.rb +103 -0
- data/lib/html2rss/syndication/parser.rb +117 -116
- data/lib/html2rss/test/enhance_audit.rb +15 -17
- data/lib/html2rss/test/item_sample.rb +43 -0
- data/lib/html2rss/test/policy.rb +9 -11
- data/lib/html2rss/test.rb +66 -57
- data/lib/html2rss/url.rb +163 -62
- data/lib/html2rss/version.rb +1 -1
- data/lib/html2rss/word_count.rb +53 -0
- data/lib/html2rss.rb +19 -9
- data/schema/html2rss-config.schema.json +56 -41
- metadata +33 -4
- data/lib/html2rss/html/rendering/media_renderer.rb +0 -39
- data/lib/html2rss/html/rendering/pdf_renderer.rb +0 -30
|
@@ -33,6 +33,7 @@ module Html2rss
|
|
|
33
33
|
(@link_resolver.utility_text?(text) && destination_facts.vanity_path) ||
|
|
34
34
|
utility_text_chrome?(text, destination_facts, heading_anchor:) ||
|
|
35
35
|
icon_only_anchor?(anchor, text) ||
|
|
36
|
+
concealed_anchor?(anchor) ||
|
|
36
37
|
utility_landmark_ancestor
|
|
37
38
|
end
|
|
38
39
|
|
|
@@ -42,16 +43,7 @@ module Html2rss
|
|
|
42
43
|
destination_facts.utility_path &&
|
|
43
44
|
!destination_facts.content_path &&
|
|
44
45
|
!destination_facts.strong_post_suffix &&
|
|
45
|
-
|
|
46
|
-
end
|
|
47
|
-
|
|
48
|
-
def word_count_at_most?(text, max)
|
|
49
|
-
count = 0
|
|
50
|
-
text.to_s.scan(/\p{Alnum}+/) do
|
|
51
|
-
count += 1
|
|
52
|
-
return false if count > max
|
|
53
|
-
end
|
|
54
|
-
true
|
|
46
|
+
!WordCount.at_least?(text, 4)
|
|
55
47
|
end
|
|
56
48
|
|
|
57
49
|
def recommended_chrome?(text, destination_facts, heading_anchor:)
|
|
@@ -65,6 +57,15 @@ module Html2rss
|
|
|
65
57
|
!heading_anchor && !destination_facts.strong_post_suffix
|
|
66
58
|
end
|
|
67
59
|
|
|
60
|
+
# An anchor a sighted reader does not see, such as the off-screen accessibility twin of a teaser
|
|
61
|
+
# link ({Html::Visibility.concealed?}), is never the item.
|
|
62
|
+
def concealed_anchor?(anchor)
|
|
63
|
+
return false unless anchor
|
|
64
|
+
|
|
65
|
+
attrs = anchor.attrs
|
|
66
|
+
Html::Visibility.concealed?(attrs.class_names, style: attrs.style, hidden: attrs.raw.key?('hidden'))
|
|
67
|
+
end
|
|
68
|
+
|
|
68
69
|
def icon_only_anchor?(anchor, text)
|
|
69
70
|
return false unless anchor
|
|
70
71
|
return false if text.to_s.match?(/\p{Alnum}/)
|
|
@@ -15,13 +15,48 @@ module Html2rss
|
|
|
15
15
|
# Segment groups used to classify article, taxonomy, utility, and vanity routes.
|
|
16
16
|
# Utility = taxonomy ∪ chrome ∪ soft utility (do not re-list taxonomy tokens).
|
|
17
17
|
SEGMENT_SETS = begin
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
'
|
|
21
|
-
'
|
|
22
|
-
'
|
|
23
|
-
'
|
|
24
|
-
'
|
|
18
|
+
# Listing words: a path ending in one is a section index, not an article.
|
|
19
|
+
section = Set[
|
|
20
|
+
'articles', 'blog', 'blogs', 'changelogs', 'insights', 'launches', 'news', 'posts', 'releases',
|
|
21
|
+
'stories', 'updates', 'nachrichten', 'neuigkeiten', 'aktuelles', 'beitraege', 'articulos', 'noticias',
|
|
22
|
+
'entradas', 'publicaciones', 'actualites', 'nouvelles', 'teasers', 'cards',
|
|
23
|
+
'aktualnosci', 'aktualności', 'wiadomosci', 'wiadomości', 'artykuly', 'artykuły', 'wpisy', 'notizie',
|
|
24
|
+
'articoli', 'novita', 'novità', 'aggiornamenti', 'nieuws', 'nieuwsberichten', 'artikelen', 'berichten',
|
|
25
|
+
'nyheter', 'artiklar', 'ニュース', '記事', 'お知らせ', 'ブログ'
|
|
26
|
+
].freeze
|
|
27
|
+
content = (section | Set[
|
|
28
|
+
'article', 'changelog', 'insight', 'launch', 'post', 'release', 'story', 'update', 'artikel', 'beitrag',
|
|
29
|
+
'articulo', 'noticia', 'entrada', 'publicacion', 'actualite', 'nouvelle', 'teaser', 'card',
|
|
30
|
+
'artykul', 'artykuł', 'wpis', 'notizia', 'articolo', 'bericht', 'nyhet', 'inlagg', 'inlägg'
|
|
31
|
+
]).freeze
|
|
32
|
+
# People pages. `team` and `people` are left out on purpose: they name sports teams and
|
|
33
|
+
# newsmakers in article paths. The words below are unambiguous byline/staff words.
|
|
34
|
+
profile = Set[
|
|
35
|
+
'author', 'authors', 'staff', 'journalist', 'journalists', 'players',
|
|
36
|
+
'autor', 'autoren', 'redakteure', 'autores', 'periodista',
|
|
37
|
+
'periodistas', 'auteur', 'auteurs', 'journaliste', 'journalistes',
|
|
38
|
+
'autore', 'autori', 'giornalista', 'giornalisti', 'journalisten',
|
|
39
|
+
'autorzy', 'dziennikarz', 'dziennikarze', 'skribent', 'skribenter', 'forfattare',
|
|
40
|
+
'författare', '著者', '執筆者', '記者', 'ライター'
|
|
41
|
+
].freeze
|
|
42
|
+
# Editorial-office and contributor words: their child pages are often editorial notes with a
|
|
43
|
+
# three-word slug, so only a one- or two-word name slug marks a profile.
|
|
44
|
+
office = Set[
|
|
45
|
+
'contributor', 'contributors', 'redaktion', 'redaccion', 'redacción', 'redaction', 'rédaction',
|
|
46
|
+
'redazione', 'redactie', 'redakcja', '編集部'
|
|
47
|
+
].freeze
|
|
48
|
+
# Destinations that are never articles: games, horoscopes, quizzes, obituaries, livestreams, players.
|
|
49
|
+
non_article = Set[
|
|
50
|
+
'games', 'spiele', 'juegos', 'jeux', 'giochi', 'spellen', 'spel', 'gry', 'ゲーム',
|
|
51
|
+
'sudoku', 'crossword', 'crosswords', 'kreuzwortraetsel', 'kreuzworträtsel', 'crucigrama', 'crucigramas',
|
|
52
|
+
'quiz', 'quizzes', 'quizze', 'quizzen', 'kviz', 'クイズ',
|
|
53
|
+
'horoscope', 'horoscopes', 'horoskop', 'horoskope', 'horoscopo', 'horoscopos', 'oroscopo', 'oroscopi',
|
|
54
|
+
'horoscoop', 'horoskopy', '星座占い', '占い',
|
|
55
|
+
'obituary', 'obituaries', 'nachruf', 'nachrufe', 'traueranzeigen', 'todesanzeigen', 'necrologie',
|
|
56
|
+
'nécrologie', 'necrologio', 'necrologi', 'esquelas', 'obituarios', 'overlijdensberichten', 'nekrolog',
|
|
57
|
+
'nekrologi', 'dodsannonser', 'dödsannonser', '訃報', 'おくやみ',
|
|
58
|
+
'livestream', 'livestreams', 'endirect', 'envivo', 'indiretta', 'nazywo', 'ライブ配信',
|
|
59
|
+
'player'
|
|
25
60
|
].freeze
|
|
26
61
|
taxonomy = Set[
|
|
27
62
|
'category', 'categories', 'tag', 'tags', 'topic', 'topics',
|
|
@@ -52,15 +87,20 @@ module Html2rss
|
|
|
52
87
|
'career', 'careers', 'deals', 'deal', 'shopping', 'shop', 'trading', 'broker',
|
|
53
88
|
'versicherung', 'tierversicherung', 'insurance', 'vergleich', 'comparison',
|
|
54
89
|
'partnerboerse', 'singleboerse', 'krypto', 'crypto', 'casinos', 'casino',
|
|
55
|
-
'kreditkarten', 'kreditkarte', 'kredit', 'echtgeld', 'vpn', '
|
|
90
|
+
'kreditkarten', 'kreditkarte', 'kredit', 'echtgeld', 'vpn', 'kaufberater',
|
|
56
91
|
'leasing'
|
|
57
92
|
].freeze
|
|
58
93
|
utility = (taxonomy | chrome | SOFT_UTILITY).freeze
|
|
59
94
|
{
|
|
60
95
|
content:,
|
|
96
|
+
section:,
|
|
61
97
|
utility:,
|
|
62
98
|
high_confidence_junk: (utility - SOFT_UTILITY).freeze,
|
|
63
99
|
taxonomy:,
|
|
100
|
+
non_article:,
|
|
101
|
+
profile:,
|
|
102
|
+
office:,
|
|
103
|
+
people_index: (profile | office).freeze,
|
|
64
104
|
vanity:,
|
|
65
105
|
deep_post_context: Set['press', 'newsroom', 'presse', 'pressemitteilungen', 'prensa'].freeze
|
|
66
106
|
}.freeze
|
|
@@ -75,9 +115,23 @@ module Html2rss
|
|
|
75
115
|
# Common file-extension suffixes excluded from host-shaped junk matching.
|
|
76
116
|
FILE_EXTENSION_SEGMENT = /\.(?:pdf|jpe?g|png|gif|svg|webp|css|js|mjs|html?|xml|json|mp4|webm|zip|gz)\z/i
|
|
77
117
|
|
|
118
|
+
# A single person-name slug: letters only (dated and id slugs carry digits), up to three words.
|
|
119
|
+
NAME_SLUG = /\A\p{L}+(?:-\p{L}+){0,2}\z/
|
|
120
|
+
# Name slug of at most two words, for office/contributor sections whose notes carry longer slugs.
|
|
121
|
+
SHORT_NAME_SLUG = /\A\p{L}+(?:-\p{L}+)?\z/
|
|
122
|
+
# Name-slug shape that marks a profile page, by the word set of its parent segment.
|
|
123
|
+
NAME_SLUG_BY_OWNER = { profile: NAME_SLUG, office: SHORT_NAME_SLUG }.freeze
|
|
124
|
+
|
|
125
|
+
# Pop-up / embedded player segments such as `popup-audio-player` (hyphens removed before matching).
|
|
126
|
+
PLAYER_SEGMENT = /\A(?:(?:pop(?:up|out)|embed|audio|video|media)\w*)?player\z/
|
|
127
|
+
|
|
78
128
|
# @param segments [Array<String>] normalized URL path segments
|
|
79
|
-
|
|
129
|
+
# @param host_label [String, nil] first host label (e.g. an obituary subdomain)
|
|
130
|
+
# @param query [Boolean] whether the URL carries a query string (`/blog?p=12` selects one post)
|
|
131
|
+
def initialize(segments, host_label: nil, query: false)
|
|
80
132
|
@segments = segments
|
|
133
|
+
@query = query
|
|
134
|
+
@host_label = host_label.to_s.downcase
|
|
81
135
|
@lexicon = segments.map { ::Html2rss::Html::Probe.fold(_1) }.freeze
|
|
82
136
|
end
|
|
83
137
|
|
|
@@ -106,13 +160,13 @@ module Html2rss
|
|
|
106
160
|
def shallow?
|
|
107
161
|
segment_count = segments.size
|
|
108
162
|
|
|
109
|
-
segment_count <= 1 || (segment_count == 2 && high_confidence_junk_at?(
|
|
163
|
+
segment_count <= 1 || (segment_count == 2 && high_confidence_junk_at?(1))
|
|
110
164
|
end
|
|
111
165
|
|
|
112
166
|
# @return [Boolean] true when the final path segment looks like a post slug
|
|
113
167
|
def strong_post_suffix?
|
|
114
168
|
@strong_post_suffix ||= segments.any? &&
|
|
115
|
-
|
|
169
|
+
slug_last_segment? &&
|
|
116
170
|
trusted_post_context?(segments.size - 1)
|
|
117
171
|
end
|
|
118
172
|
|
|
@@ -121,13 +175,9 @@ module Html2rss
|
|
|
121
175
|
(0...segments.size).all? { |i| high_confidence_junk_at?(i) }
|
|
122
176
|
end
|
|
123
177
|
|
|
124
|
-
# @return [Boolean] true when the route is shallow and
|
|
125
|
-
def
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
shallow? && (0...segments.size).any? do |i|
|
|
129
|
-
high_confidence_junk_at?(i) || vanity_segments.include?(@lexicon[i])
|
|
130
|
-
end
|
|
178
|
+
# @return [Boolean] true when the route is shallow and names a soft vanity segment
|
|
179
|
+
def shallow_vanity_route?
|
|
180
|
+
shallow? && @lexicon.any? { |folded| SEGMENT_SETS.fetch(:vanity).include?(folded) }
|
|
131
181
|
end
|
|
132
182
|
|
|
133
183
|
# @return [Boolean] true when the leading segments are all utility chrome
|
|
@@ -135,19 +185,25 @@ module Html2rss
|
|
|
135
185
|
all_junk?(segments.size - 1)
|
|
136
186
|
end
|
|
137
187
|
|
|
188
|
+
# @return [Boolean] true when the host or a path segment names a game, horoscope, quiz,
|
|
189
|
+
# obituary, livestream or player destination, or the path is a bare section index
|
|
190
|
+
def non_article_path?
|
|
191
|
+
return @non_article_path if defined?(@non_article_path)
|
|
192
|
+
|
|
193
|
+
@non_article_path = section_index? || profile_route? || (!strong_post_suffix? && non_article_word_present?)
|
|
194
|
+
end
|
|
195
|
+
|
|
138
196
|
# @return [Boolean] true when the route is shallow and contains high-confidence noise
|
|
139
197
|
def junk_path?
|
|
198
|
+
return true if non_article_path?
|
|
140
199
|
return false if excluded_content_route?
|
|
141
200
|
|
|
142
|
-
any_high_confidence_junk_segment? ||
|
|
143
|
-
taxonomy_path? ||
|
|
144
|
-
utility_only_route? ||
|
|
145
|
-
deep_utility_context_route? ||
|
|
146
|
-
shallow_high_confidence_route?
|
|
201
|
+
any_high_confidence_junk_segment? || shallow_vanity_route?
|
|
147
202
|
end
|
|
148
203
|
|
|
149
204
|
# @return [Boolean] true when the route points at conversion or account chrome
|
|
150
205
|
def utility_destination?
|
|
206
|
+
return true if non_article_path?
|
|
151
207
|
return false if excluded_content_route?
|
|
152
208
|
|
|
153
209
|
vanity_path? || utility_route?
|
|
@@ -155,6 +211,33 @@ module Html2rss
|
|
|
155
211
|
|
|
156
212
|
private
|
|
157
213
|
|
|
214
|
+
def non_article_word_present?
|
|
215
|
+
([@host_label] + @lexicon).any? { non_article_word?(_1) }
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def non_article_word?(word)
|
|
219
|
+
joined = word.delete('-_')
|
|
220
|
+
SEGMENT_SETS[:non_article].include?(joined) || joined.match?(PLAYER_SEGMENT)
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# A people word that ends the path (an index) or is followed by one name slug (a profile page).
|
|
224
|
+
def profile_route?
|
|
225
|
+
return false if segments.empty?
|
|
226
|
+
|
|
227
|
+
SEGMENT_SETS[:people_index].include?(@lexicon.last) || profile_page?
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def profile_page?
|
|
231
|
+
return false if segments.size < 2
|
|
232
|
+
|
|
233
|
+
NAME_SLUG_BY_OWNER.any? { |set, slug| SEGMENT_SETS[set].include?(@lexicon[-2]) && segments.last.match?(slug) }
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# A path ending in a section word (no slug) lists articles instead of being one.
|
|
237
|
+
def section_index?
|
|
238
|
+
!@query && segments.any? && SEGMENT_SETS[:section].include?(@lexicon.last)
|
|
239
|
+
end
|
|
240
|
+
|
|
158
241
|
def leading_high_confidence_junk?
|
|
159
242
|
segments.any? && high_confidence_junk_at?(0)
|
|
160
243
|
end
|
|
@@ -164,8 +247,7 @@ module Html2rss
|
|
|
164
247
|
end
|
|
165
248
|
|
|
166
249
|
def yearish_content_context?
|
|
167
|
-
segments.any? { |segment| segment.match?(YEARISH_SEGMENT) } &&
|
|
168
|
-
(strong_post_suffix? || trusted_post_context?(segments.size - 1))
|
|
250
|
+
segments.any? { |segment| segment.match?(YEARISH_SEGMENT) } && trusted_post_context?(segments.size - 1)
|
|
169
251
|
end
|
|
170
252
|
|
|
171
253
|
def excluded_content_route?
|
|
@@ -199,28 +281,15 @@ module Html2rss
|
|
|
199
281
|
end
|
|
200
282
|
|
|
201
283
|
def trusted_post_context?(limit)
|
|
202
|
-
return false if limit <= 0
|
|
203
284
|
return false if leading_high_confidence_junk?
|
|
204
285
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
content_segments.include?(@lexicon[i]) ||
|
|
210
|
-
segments[i].match?(PathClassifier::YEARISH_SEGMENT) ||
|
|
211
|
-
context_segments.include?(@lexicon[i])
|
|
286
|
+
content = SEGMENT_SETS.fetch(:content)
|
|
287
|
+
context = SEGMENT_SETS.fetch(:deep_post_context)
|
|
288
|
+
segments.zip(@lexicon).first(limit.clamp(0..)).any? do |segment, folded|
|
|
289
|
+
content.include?(folded) || segment.match?(YEARISH_SEGMENT) || context.include?(folded)
|
|
212
290
|
end
|
|
213
291
|
end
|
|
214
292
|
|
|
215
|
-
def included_last_segment?
|
|
216
|
-
!excluded_last_segment? && slug_last_segment?
|
|
217
|
-
end
|
|
218
|
-
|
|
219
|
-
def excluded_last_segment?
|
|
220
|
-
last_i = segments.size - 1
|
|
221
|
-
high_confidence_junk_at?(last_i) || SEGMENT_SETS[:vanity].include?(@lexicon[last_i])
|
|
222
|
-
end
|
|
223
|
-
|
|
224
293
|
def slug_last_segment?
|
|
225
294
|
last = segments.last
|
|
226
295
|
last.match?(YEARISH_SEGMENT) || last.match?(POST_SLUG_SEGMENT)
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Html2rss
|
|
4
|
+
module LinkDestination
|
|
5
|
+
module Sponsorship
|
|
6
|
+
##
|
|
7
|
+
# The closed multilingual vocabulary of sponsorship label text (en, de, fr, es, it, nl, pl, sv, ja), in
|
|
8
|
+
# two kinds. A paid phrase states paid content; an ad word names an ad and also something editorial
|
|
9
|
+
# (the advertising trade; "Promoted" a team or a person moving up). "Provided by" attribution ("提供")
|
|
10
|
+
# is in neither: it also credits wire and content partners. Only whole texts match; {Sponsorship}
|
|
11
|
+
# decides where a text is read.
|
|
12
|
+
module LabelText
|
|
13
|
+
# Letters with an accented variant are alternations, not classes: with +/i+, Ruby on Linux does not
|
|
14
|
+
# fold an uppercase accented letter against a class that also holds its ASCII base. Texts are read in
|
|
15
|
+
# NFC, so a decomposed accent matches too.
|
|
16
|
+
#
|
|
17
|
+
# A whole label, optionally wrapped in punctuation or separators ("Anzeige |", "[Sponsored]",
|
|
18
|
+
# "Publicité:", "【広告】"), in two groups. +paid+ states paid content; +ad+ is an ad word, which also
|
|
19
|
+
# names the advertising trade. Paid attribution ("Sponsored by …") may name the sponsor in up to five
|
|
20
|
+
# words.
|
|
21
|
+
LABEL_PATTERN = /
|
|
22
|
+
\A[\s\u00A0\p{P}|]*(?:(?<paid>
|
|
23
|
+
# English
|
|
24
|
+
sponsored(?:\s+(?:content|post|story))?|advertorial|(?:paid|partner)\s+content|paid\s+post|
|
|
25
|
+
sponsored\s+by(?:\s+\S+){0,5}|
|
|
26
|
+
# German
|
|
27
|
+
gesponsert(?:\s+von(?:\s+\S+){0,5})?|
|
|
28
|
+
# French
|
|
29
|
+
(?:contenu\s+)?sponsoris(?:e|é)(?:\s+par(?:\s+\S+){0,5})?|publireportage|contenus?\s+partenaires?|
|
|
30
|
+
# Spanish
|
|
31
|
+
(?:contenido\s+)?patrocinado(?:\s+por(?:\s+\S+){0,5})?|publirreportaje|
|
|
32
|
+
# Italian
|
|
33
|
+
(?:contenuto\s+)?sponsorizzat[oa](?:\s+(?:da|a\s+cura\s+di)(?:\s+\S+){0,5})?|
|
|
34
|
+
informazione\s+pubblicitaria|pubbliredazionale|
|
|
35
|
+
# Dutch
|
|
36
|
+
gesponsord(?:e\s+(?:inhoud|content))?(?:\s+door(?:\s+\S+){0,5})?|
|
|
37
|
+
# Polish
|
|
38
|
+
sponsorowane|materia(?:ł|l)\s+(?:sponsorowany|partnera|partnerski|promocyjny|reklamowy)|
|
|
39
|
+
materia(?:ł|l)y\s+(?:sponsorowane|partnera|partnerskie|promocyjne|reklamowe)|artyku(?:ł|l)\s+sponsorowany|
|
|
40
|
+
# Swedish
|
|
41
|
+
sponsrad|sponsrat(?:\s+inneh(?:å|a)ll)?|annonssamarbete|
|
|
42
|
+
# Japanese
|
|
43
|
+
タイアップ(?:広告|企画)?|スポンサー(?:リンク|コンテンツ)|広告企画|PR企画
|
|
44
|
+
)|(?<ad>
|
|
45
|
+
ad|advertisement|promoted|
|
|
46
|
+
anzeige|werbung|publicit(?:e|é)|publicidad|pubblicit(?:à|a)|advertentie|reclame|reklama|annons|
|
|
47
|
+
reklam|広告|スポンサー
|
|
48
|
+
))[\s\u00A0\p{P}|]*\z
|
|
49
|
+
/ix
|
|
50
|
+
# "PR" as a whole uppercase label element (a Japanese paid-content badge). Only element text counts:
|
|
51
|
+
# never an ARIA label, a heading or a topic chip, and never "Pr" or "pr".
|
|
52
|
+
ELEMENT_LABEL = /\A[\s\u00A0\p{P}|]*PR[\s\u00A0\p{P}|]*\z/
|
|
53
|
+
|
|
54
|
+
class << self
|
|
55
|
+
##
|
|
56
|
+
# @param text [String, nil] a whole text
|
|
57
|
+
# @param element [Boolean] whether +text+ is an element's text, the only place "PR" counts
|
|
58
|
+
# @return [Symbol, nil] +:paid_label+ for a paid phrase, +:label+ for an ad word, else nil
|
|
59
|
+
def kind(text, element: true)
|
|
60
|
+
return if text.nil?
|
|
61
|
+
|
|
62
|
+
text = text.unicode_normalize
|
|
63
|
+
return :label if element && text.match?(ELEMENT_LABEL)
|
|
64
|
+
|
|
65
|
+
match = LABEL_PATTERN.match(text)
|
|
66
|
+
match && (match[:paid] ? :paid_label : :label)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
##
|
|
70
|
+
# Cheap filter for the direct text of every node: whether it may hold a label at all.
|
|
71
|
+
#
|
|
72
|
+
# @param text [String]
|
|
73
|
+
# @return [Boolean]
|
|
74
|
+
def candidate?(text)
|
|
75
|
+
text = text.unicode_normalize
|
|
76
|
+
text.match?(LABEL_PATTERN) || text.include?('PR')
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Html2rss
|
|
4
|
+
module LinkDestination
|
|
5
|
+
##
|
|
6
|
+
# Explicit sponsorship markers on SST nodes, in three kinds:
|
|
7
|
+
#
|
|
8
|
+
# - +:container+: a +rel+ containing +sponsored+, a truthy +data-sponsored+ or
|
|
9
|
+
# +data-advertorial+, a sponsored, advertorial or native-advertising class token (BEM variants
|
|
10
|
+
# included), or an ARIA label that is an ad label. The publisher states that the element is paid content.
|
|
11
|
+
# - +:label+: an ad-label class token (+ad-label+, +ad-badge__item+), or a standalone element whose whole
|
|
12
|
+
# text is an ad word ({LabelText} ad words: "Anzeige", "Publicité", "Promoted", "Reklama", "広告", or
|
|
13
|
+
# "PR" as an uppercase element text). An ad word also names something editorial (the advertising trade,
|
|
14
|
+
# a promotion), so it counts only on a plain element: never on a heading or a topic chip (category, tag
|
|
15
|
+
# or kicker class), and it never heads a section.
|
|
16
|
+
# - +:paid_label+: a standalone element whose whole text states paid content ({LabelText} paid
|
|
17
|
+
# phrases: "Sponsored", "Contenus partenaires", "Materiał partnera", "タイアップ"; paid attribution may
|
|
18
|
+
# name the sponsor in up to five words). It also counts on a heading or a topic chip, and
|
|
19
|
+
# {AutoSource::PageLinks} lets it head a section.
|
|
20
|
+
#
|
|
21
|
+
# Both text vocabularies are one closed multilingual set (en, de, fr, es, it, nl, pl, sv, ja).
|
|
22
|
+
# Attribution alone ("Presented by", "In Kooperation mit", "proposé par", "In collaborazione con", "提供")
|
|
23
|
+
# states no payment and is no label, nor is "Partners" (an editorial word).
|
|
24
|
+
#
|
|
25
|
+
# A class or text label counts only on a rendered element: neither it nor an ancestor inside its
|
|
26
|
+
# card ({CARD_NAMES}) is unrendered ({Html::Visibility.unrendered?}). +aria-hidden="true"+ and
|
|
27
|
+
# screen-reader-only classes still count: one is a visual label, the other a spoken one. A class
|
|
28
|
+
# marker also needs content (an empty template slot is not a label). A text label never counts
|
|
29
|
+
# inside running text: anchor text is never read, nor are words inside a heading or an inline element
|
|
30
|
+
# of text-bearing prose (or of an inline topic chip, for an ad word). A label inside an anchor counts
|
|
31
|
+
# (a linked label); a headline about advertising is not a marker.
|
|
32
|
+
#
|
|
33
|
+
# Markers only; {AutoSource::PageLinks} scopes a marker to the card that holds it.
|
|
34
|
+
module Sponsorship
|
|
35
|
+
# +rel+ token a publisher sets on paid links.
|
|
36
|
+
REL_TOKEN = 'sponsored'
|
|
37
|
+
# Attribute names that mark a sponsored element when set to a value in {ON_VALUES}.
|
|
38
|
+
ATTRIBUTE_NAMES = %w[data-sponsored data-advertorial].freeze
|
|
39
|
+
# Attribute values (case-insensitive) that switch a marker attribute on. Anything else is
|
|
40
|
+
# off: an empty value (template output or a bare attribute; HTML parsing cannot tell them
|
|
41
|
+
# apart), +no+, +null+, +false+, +0+, +off+.
|
|
42
|
+
ON_VALUES = Set['true', '1', 'yes'].freeze
|
|
43
|
+
# Sponsored class token: whole, behind a BEM block (+card--sponsored+, +teaser__advertorial+)
|
|
44
|
+
# or an +is-+ state prefix, with an optional content suffix (+sponsored-content+).
|
|
45
|
+
# +unsponsored+, +non-sponsored+ and +has-sponsored+ (a wrapper that contains one) do not match.
|
|
46
|
+
SPONSORED_CLASS = /
|
|
47
|
+
\A(?:[a-z0-9_-]*(?:--|__))?(?:is-)?(?:sponsored|advertorial)
|
|
48
|
+
(?:[-_](?:content|post|story|article|card|item|teaser))?\z
|
|
49
|
+
/x
|
|
50
|
+
# Native-advertising class token: a whole +native+, behind a BEM block (+teaser--native+,
|
|
51
|
+
# +card__native+), or a native-ad block (+native-ad+, +adv-native+, +ad-native+) with optional suffix
|
|
52
|
+
# words (+adv-native-strip+). +native-input+, +nativeApp+, +native-teaser+, +bt-langnative+ and a
|
|
53
|
+
# blockless +--native+ modifier do not match.
|
|
54
|
+
NATIVE_AD_CLASS = /
|
|
55
|
+
\A(?:[a-z0-9][a-z0-9_-]*?(?:--|__))?
|
|
56
|
+
(?:native|(?:native-ads?|adv-native|ad-native)(?:-[a-z0-9]+)*)\z
|
|
57
|
+
/x
|
|
58
|
+
# Ad-label class token family, with an optional BEM element or modifier: +ad-label+,
|
|
59
|
+
# +ad-labels__item+, +ad-badge+, +ad-marker--small+. Ad slots (+ad-container+) are not labels.
|
|
60
|
+
AD_LABEL_CLASS = /\Aad-(?:labels?|badge|marker)(?:(?:__|--)[a-z0-9-]+)?\z/
|
|
61
|
+
# ARIA attributes read as labels.
|
|
62
|
+
ARIA_NAMES = %w[aria-label aria-roledescription].freeze
|
|
63
|
+
# Elements that delimit one card (+main+ is a page region, never a card). A hidden ancestor hides
|
|
64
|
+
# a marker only inside its card: a hidden tab panel or responsive wrapper around whole cards
|
|
65
|
+
# hides none of their labels.
|
|
66
|
+
CARD_NAMES = Set[:article, :section, :aside, :li].freeze
|
|
67
|
+
# Class-token words (split on +-+/+_+) of topic chips, whose text names a topic, not a sale.
|
|
68
|
+
CHIP_WORDS = Set['category', 'categories', 'tag', 'tags', 'kicker', 'topic', 'topics', 'rubric', 'rubrik',
|
|
69
|
+
'eyebrow'].freeze
|
|
70
|
+
# Inline elements: a label inside one belongs to the surrounding text.
|
|
71
|
+
PHRASING_NAMES = Set[:span, :em, :strong, :b, :i, :q, :small, :mark, :u, :s, :cite, :abbr, :time, :sup,
|
|
72
|
+
:sub, :label, :font].freeze
|
|
73
|
+
class << self
|
|
74
|
+
##
|
|
75
|
+
# @param node [SST::Node]
|
|
76
|
+
# @param ancestors [Array<SST::Node>] +node+'s ancestors, root first
|
|
77
|
+
# @return [Symbol, nil] +:container+, +:label+ or +:paid_label+ when +node+ itself carries an
|
|
78
|
+
# explicit sponsorship marker, else nil
|
|
79
|
+
def marker(node, ancestors)
|
|
80
|
+
return :container if raw_marker?(node.attrs.raw)
|
|
81
|
+
|
|
82
|
+
kind = class_marker(node.attrs.class_names)
|
|
83
|
+
return (kind if visible?(node, ancestors)) if kind
|
|
84
|
+
|
|
85
|
+
text_label(node, ancestors)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
##
|
|
89
|
+
# Whether +text+, as a whole, states paid content ({LabelText}). For readers
|
|
90
|
+
# of plain text such as title pieces; an ad word is left out, as it also names a topic.
|
|
91
|
+
#
|
|
92
|
+
# @param text [String, nil]
|
|
93
|
+
# @return [Boolean]
|
|
94
|
+
def paid_text?(text) = LabelText.kind(text) == :paid_label
|
|
95
|
+
|
|
96
|
+
private
|
|
97
|
+
|
|
98
|
+
# Runs on every node of the page: literal checks gate each regex.
|
|
99
|
+
def class_marker(class_names)
|
|
100
|
+
kind = nil
|
|
101
|
+
class_names.each do |token|
|
|
102
|
+
return :container if sponsored_class?(token) || native_ad_class?(token)
|
|
103
|
+
|
|
104
|
+
kind ||= :label if token.start_with?('ad-') && token.match?(AD_LABEL_CLASS)
|
|
105
|
+
end
|
|
106
|
+
kind
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def sponsored_class?(token)
|
|
110
|
+
(token.include?('sponsored') || token.include?('advertorial')) && token.match?(SPONSORED_CLASS)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def native_ad_class?(token) = token.include?('native') && token.match?(NATIVE_AD_CLASS)
|
|
114
|
+
|
|
115
|
+
def visible?(node, ancestors)
|
|
116
|
+
(node.children.any? || node.own_text.match?(/\S/)) && !unrendered?(node, ancestors)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Whether +node+, or an ancestor up to and including its card, is hidden.
|
|
120
|
+
def unrendered?(node, ancestors)
|
|
121
|
+
return true if hidden?(node)
|
|
122
|
+
|
|
123
|
+
ancestors.reverse_each do |ancestor|
|
|
124
|
+
return true if hidden?(ancestor)
|
|
125
|
+
return false if CARD_NAMES.include?(ancestor.name)
|
|
126
|
+
end
|
|
127
|
+
false
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def hidden?(node)
|
|
131
|
+
attrs = node.attrs
|
|
132
|
+
Html::Visibility.unrendered?(attrs.class_names, style: attrs.style, hidden: attrs.raw.key?('hidden'))
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Most nodes carry no raw attributes, so the empty check skips every lookup.
|
|
136
|
+
def raw_marker?(raw)
|
|
137
|
+
return false if raw.empty?
|
|
138
|
+
|
|
139
|
+
sponsored_rel?(raw['rel']) ||
|
|
140
|
+
ATTRIBUTE_NAMES.any? { ON_VALUES.include?(raw[_1]&.strip&.downcase) } ||
|
|
141
|
+
ARIA_NAMES.any? { LabelText.kind(raw[_1], element: false) }
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def sponsored_rel?(rel)
|
|
145
|
+
return false unless rel
|
|
146
|
+
|
|
147
|
+
rel.downcase.split.include?(REL_TOKEN)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# The own-text check runs first: it is the cheap filter for every node of the page.
|
|
151
|
+
def text_label(node, ancestors)
|
|
152
|
+
return unless LabelText.candidate?(node.own_text)
|
|
153
|
+
|
|
154
|
+
kind = LabelText.kind(node.visible_text)
|
|
155
|
+
kind if kind && readable?(node, ancestors, paid: kind == :paid_label)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Anchor text is never read; an ad word is read only on a plain element.
|
|
159
|
+
def readable?(node, ancestors, paid:)
|
|
160
|
+
node.name != :a && (paid || plain?(node)) && ancestors.none?(&:heading?) &&
|
|
161
|
+
!in_running_text?(ancestors, paid:) && !unrendered?(node, ancestors)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def plain?(node) = !node.heading? && !chip?(node)
|
|
165
|
+
|
|
166
|
+
def chip?(node)
|
|
167
|
+
node.attrs.class_names.any? { |token| token.downcase.split(/[-_]+/).any? { CHIP_WORDS.include?(_1) } }
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Climbs inline ancestors up to the first block one: prose in any of them, or (for an ad word) a
|
|
171
|
+
# chip class on an inline one, makes the label part of the surrounding text. Block classes are not
|
|
172
|
+
# read (post classes such as +category-news+ sit on whole cards).
|
|
173
|
+
def in_running_text?(ancestors, paid:)
|
|
174
|
+
ancestors.reverse_each do |ancestor|
|
|
175
|
+
return true if ancestor.own_text.match?(/\p{Alnum}/)
|
|
176
|
+
return false unless PHRASING_NAMES.include?(ancestor.name)
|
|
177
|
+
return true if !paid && chip?(ancestor)
|
|
178
|
+
end
|
|
179
|
+
false
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
end
|