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.
Files changed (202) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +4 -2
  3. data/lib/html2rss/article/deduplicator.rb +5 -2
  4. data/lib/html2rss/article/enclosure.rb +185 -25
  5. data/lib/html2rss/article/title.rb +203 -0
  6. data/lib/html2rss/article.rb +79 -13
  7. data/lib/html2rss/auto_source/README.md +16 -8
  8. data/lib/html2rss/auto_source/cleanup.rb +127 -53
  9. data/lib/html2rss/auto_source/corroboration.rb +167 -0
  10. data/lib/html2rss/auto_source/page_links/anchor_claims.rb +82 -0
  11. data/lib/html2rss/auto_source/page_links/card_units.rb +77 -0
  12. data/lib/html2rss/auto_source/page_links/label_binding.rb +79 -0
  13. data/lib/html2rss/auto_source/page_links/sponsored_cards.rb +147 -0
  14. data/lib/html2rss/auto_source/page_links.rb +164 -0
  15. data/lib/html2rss/auto_source/scraper/html.rb +11 -45
  16. data/lib/html2rss/auto_source/scraper/json_document.rb +3 -6
  17. data/lib/html2rss/auto_source/scraper/json_state/article_normalizer.rb +202 -125
  18. data/lib/html2rss/auto_source/scraper/json_state/candidate_detector.rb +27 -38
  19. data/lib/html2rss/auto_source/scraper/json_state/document_scanner.rb +123 -123
  20. data/lib/html2rss/auto_source/scraper/json_state/value_finder.rb +27 -35
  21. data/lib/html2rss/auto_source/scraper/json_state.rb +4 -9
  22. data/lib/html2rss/auto_source/scraper/meta_oembed.rb +27 -5
  23. data/lib/html2rss/auto_source/scraper/microdata.rb +233 -248
  24. data/lib/html2rss/auto_source/scraper/microformats2.rb +10 -4
  25. data/lib/html2rss/auto_source/scraper/native_feed.rb +28 -18
  26. data/lib/html2rss/auto_source/scraper/schema/category_extractor.rb +7 -2
  27. data/lib/html2rss/auto_source/scraper/schema/item_list.rb +5 -3
  28. data/lib/html2rss/auto_source/scraper/schema/thing.rb +140 -22
  29. data/lib/html2rss/auto_source/scraper/schema.rb +52 -8
  30. data/lib/html2rss/auto_source/scraper/semantic_html/entry_deduplicator.rb +3 -9
  31. data/lib/html2rss/auto_source/scraper/semantic_html.rb +12 -20
  32. data/lib/html2rss/auto_source/scraper/sitemap/parser.rb +1 -4
  33. data/lib/html2rss/auto_source/scraper/sitemap.rb +7 -14
  34. data/lib/html2rss/auto_source/scraper/wordpress_api/page_scope.rb +2 -2
  35. data/lib/html2rss/auto_source/scraper/wordpress_api/posts_endpoint.rb +1 -3
  36. data/lib/html2rss/auto_source/scraper/wordpress_api.rb +7 -7
  37. data/lib/html2rss/auto_source/scraper/xhr_articles.rb +2 -4
  38. data/lib/html2rss/auto_source/scraper.rb +10 -13
  39. data/lib/html2rss/auto_source/segment.rb +19 -5
  40. data/lib/html2rss/auto_source/segmenter/cluster.rb +18 -22
  41. data/lib/html2rss/auto_source/segmenter/list.rb +35 -59
  42. data/lib/html2rss/auto_source/segmenter/primary_link.rb +29 -4
  43. data/lib/html2rss/auto_source/segmenter/semantic.rb +23 -19
  44. data/lib/html2rss/auto_source/segmenter.rb +4 -8
  45. data/lib/html2rss/auto_source.rb +81 -26
  46. data/lib/html2rss/batch.rb +24 -32
  47. data/lib/html2rss/capture/README.md +6 -3
  48. data/lib/html2rss/capture.rb +51 -59
  49. data/lib/html2rss/channel.rb +30 -12
  50. data/lib/html2rss/cli/render.rb +97 -96
  51. data/lib/html2rss/cli/validate.rb +2 -4
  52. data/lib/html2rss/cli.rb +55 -38
  53. data/lib/html2rss/config/auto_source_contract.rb +52 -45
  54. data/lib/html2rss/config/issue_mapper.rb +4 -1
  55. data/lib/html2rss/config/request_controls.rb +16 -5
  56. data/lib/html2rss/config/schema.rb +23 -15
  57. data/lib/html2rss/config/selectors_validator.rb +3 -2
  58. data/lib/html2rss/config/validation_issue.rb +4 -1
  59. data/lib/html2rss/config/validator.rb +14 -3
  60. data/lib/html2rss/config.rb +54 -28
  61. data/lib/html2rss/contract_error.rb +9 -0
  62. data/lib/html2rss/defaults.rb +25 -38
  63. data/lib/html2rss/doctor/botasaurus.rb +18 -22
  64. data/lib/html2rss/error.rb +17 -3
  65. data/lib/html2rss/error_kind.rb +39 -0
  66. data/lib/html2rss/feed_builder/item_presentation.rb +22 -9
  67. data/lib/html2rss/feed_builder/json_feed/item.rb +2 -1
  68. data/lib/html2rss/feed_builder/json_feed.rb +2 -2
  69. data/lib/html2rss/feed_builder/rss/stylesheet.rb +3 -3
  70. data/lib/html2rss/feed_builder/rss.rb +50 -7
  71. data/lib/html2rss/feed_builder.rb +2 -1
  72. data/lib/html2rss/feed_pipeline/README.md +1 -1
  73. data/lib/html2rss/feed_pipeline/auto_fallback.rb +16 -34
  74. data/lib/html2rss/feed_pipeline/strategy_plan.rb +3 -3
  75. data/lib/html2rss/feed_pipeline.rb +40 -27
  76. data/lib/html2rss/feed_resolution/README.md +1 -1
  77. data/lib/html2rss/feed_resolution/candidate_generator.rb +6 -8
  78. data/lib/html2rss/feed_resolution/options.rb +3 -7
  79. data/lib/html2rss/feed_resolution/policy.rb +29 -33
  80. data/lib/html2rss/feed_resolution/probe.rb +6 -4
  81. data/lib/html2rss/feed_resolution/scorer.rb +29 -29
  82. data/lib/html2rss/feed_resolution.rb +16 -21
  83. data/lib/html2rss/feed_result.rb +11 -6
  84. data/lib/html2rss/hash_util.rb +91 -90
  85. data/lib/html2rss/html/article_extractor/category_extractor.rb +7 -10
  86. data/lib/html2rss/html/article_extractor/date_extractor.rb +1 -1
  87. data/lib/html2rss/html/article_extractor/enclosure_extractor.rb +12 -33
  88. data/lib/html2rss/html/article_extractor/heading_extractor.rb +2 -3
  89. data/lib/html2rss/html/article_extractor/id_generator.rb +7 -10
  90. data/lib/html2rss/html/article_extractor/image_extractor.rb +9 -1
  91. data/lib/html2rss/html/article_extractor.rb +19 -47
  92. data/lib/html2rss/html/article_rules/category.rb +3 -3
  93. data/lib/html2rss/html/article_rules/description.rb +6 -15
  94. data/lib/html2rss/html/article_rules/enclosure.rb +70 -42
  95. data/lib/html2rss/html/article_rules/image.rb +30 -16
  96. data/lib/html2rss/html/feed_link.rb +7 -9
  97. data/lib/html2rss/html/navigator/text_extractor.rb +26 -16
  98. data/lib/html2rss/html/navigator.rb +39 -9
  99. data/lib/html2rss/html/probe.rb +55 -51
  100. data/lib/html2rss/html/rendering/description_builder.rb +46 -32
  101. data/lib/html2rss/html/rendering.rb +2 -9
  102. data/lib/html2rss/html/sst_article_extractor.rb +55 -145
  103. data/lib/html2rss/html/title_pieces/kicker.rb +147 -0
  104. data/lib/html2rss/html/title_pieces/nodes.rb +107 -0
  105. data/lib/html2rss/html/title_pieces/nokogiri_nodes.rb +70 -0
  106. data/lib/html2rss/html/title_pieces/sst_nodes.rb +71 -0
  107. data/lib/html2rss/html/title_pieces.rb +169 -0
  108. data/lib/html2rss/html/visibility.rb +56 -0
  109. data/lib/html2rss/html.rb +2 -0
  110. data/lib/html2rss/link_destination/destination_facts.rb +17 -1
  111. data/lib/html2rss/link_destination/noise_policy.rb +11 -10
  112. data/lib/html2rss/link_destination/path_classifier.rb +111 -42
  113. data/lib/html2rss/link_destination/sponsorship/label_text.rb +82 -0
  114. data/lib/html2rss/link_destination/sponsorship.rb +184 -0
  115. data/lib/html2rss/link_destination/text_classifier.rb +104 -8
  116. data/lib/html2rss/mcp/README.md +13 -1
  117. data/lib/html2rss/mcp/config_argument.rb +14 -4
  118. data/lib/html2rss/mcp/contract.rb +20 -9
  119. data/lib/html2rss/mcp/outcome.rb +18 -17
  120. data/lib/html2rss/mcp/runtime.rb +53 -23
  121. data/lib/html2rss/mcp/server/tools.rb +9 -9
  122. data/lib/html2rss/mcp/server.rb +13 -4
  123. data/lib/html2rss/option.rb +48 -0
  124. data/lib/html2rss/page_recon/README.md +1 -1
  125. data/lib/html2rss/page_recon/diagnostics.rb +9 -9
  126. data/lib/html2rss/page_recon.rb +0 -4
  127. data/lib/html2rss/recon.rb +19 -23
  128. data/lib/html2rss/request_service/blocked_surface.rb +5 -8
  129. data/lib/html2rss/request_service/botasaurus_contract.rb +25 -3
  130. data/lib/html2rss/request_service/botasaurus_strategy.rb +44 -12
  131. data/lib/html2rss/request_service/budget.rb +3 -24
  132. data/lib/html2rss/request_service/compressed_body.rb +36 -48
  133. data/lib/html2rss/request_service/context.rb +16 -17
  134. data/lib/html2rss/request_service/cookie_jar.rb +89 -0
  135. data/lib/html2rss/request_service/cookie_store.rb +79 -0
  136. data/lib/html2rss/request_service/feed_advertisement.rb +72 -0
  137. data/lib/html2rss/request_service/httpx_strategy.rb +7 -3
  138. data/lib/html2rss/request_service/local_file_strategy.rb +2 -3
  139. data/lib/html2rss/request_service/network_guard.rb +3 -2
  140. data/lib/html2rss/request_service/policy.rb +55 -36
  141. data/lib/html2rss/request_service/replay_breaker.rb +56 -0
  142. data/lib/html2rss/request_service/response.rb +33 -19
  143. data/lib/html2rss/request_service/response_cache.rb +109 -0
  144. data/lib/html2rss/request_service/response_caching.rb +95 -0
  145. data/lib/html2rss/request_service/strategy.rb +5 -11
  146. data/lib/html2rss/request_service.rb +71 -12
  147. data/lib/html2rss/request_session/pager/base.rb +3 -10
  148. data/lib/html2rss/request_session/pager/custom_selector.rb +4 -4
  149. data/lib/html2rss/request_session/pager/offset.rb +1 -7
  150. data/lib/html2rss/request_session/pager/rel_next.rb +1 -1
  151. data/lib/html2rss/request_session/pager/url_template.rb +1 -7
  152. data/lib/html2rss/request_session/pager.rb +1 -1
  153. data/lib/html2rss/request_session.rb +6 -2
  154. data/lib/html2rss/scoring/cluster_scorer.rb +1 -1
  155. data/lib/html2rss/scoring/container_assessor.rb +12 -13
  156. data/lib/html2rss/scoring/engine.rb +10 -38
  157. data/lib/html2rss/scoring/link_resolver.rb +2 -7
  158. data/lib/html2rss/scoring/observation.rb +2 -2
  159. data/lib/html2rss/selector_candidates.rb +19 -8
  160. data/lib/html2rss/selectors/README.md +1 -1
  161. data/lib/html2rss/selectors/extractors/href.rb +2 -4
  162. data/lib/html2rss/selectors/json_xml.rb +33 -34
  163. data/lib/html2rss/selectors/option_spec.rb +4 -1
  164. data/lib/html2rss/selectors/post_processors/base.rb +0 -8
  165. data/lib/html2rss/selectors/post_processors/gsub.rb +9 -10
  166. data/lib/html2rss/selectors/post_processors/html_transformers/keep_playable_media.rb +50 -0
  167. data/lib/html2rss/selectors/post_processors/html_transformers/transform_urls_to_absolute_ones.rb +1 -3
  168. data/lib/html2rss/selectors/post_processors/parse_time.rb +5 -6
  169. data/lib/html2rss/selectors/post_processors/parse_uri.rb +1 -1
  170. data/lib/html2rss/selectors/post_processors/sanitize_html.rb +18 -5
  171. data/lib/html2rss/selectors/post_processors/substring.rb +1 -1
  172. data/lib/html2rss/selectors/schema_export.rb +60 -60
  173. data/lib/html2rss/selectors.rb +67 -30
  174. data/lib/html2rss/sst/attrs.rb +2 -2
  175. data/lib/html2rss/sst/document.rb +3 -3
  176. data/lib/html2rss/sst/index.rb +30 -11
  177. data/lib/html2rss/sst/node.rb +42 -29
  178. data/lib/html2rss/sst/normalizer.rb +47 -34
  179. data/lib/html2rss/sst/tags.rb +113 -2
  180. data/lib/html2rss/sst/text.rb +94 -58
  181. data/lib/html2rss/sst.rb +2 -0
  182. data/lib/html2rss/status.rb +27 -31
  183. data/lib/html2rss/surface_category.rb +1 -1
  184. data/lib/html2rss/syndication/README.md +12 -6
  185. data/lib/html2rss/syndication/alternate_links.rb +39 -0
  186. data/lib/html2rss/syndication/channel_facts.rb +87 -0
  187. data/lib/html2rss/syndication/discovery.rb +171 -187
  188. data/lib/html2rss/syndication/feed.rb +20 -0
  189. data/lib/html2rss/syndication/item_media.rb +103 -0
  190. data/lib/html2rss/syndication/parser.rb +117 -116
  191. data/lib/html2rss/test/enhance_audit.rb +15 -17
  192. data/lib/html2rss/test/item_sample.rb +43 -0
  193. data/lib/html2rss/test/policy.rb +9 -11
  194. data/lib/html2rss/test.rb +66 -57
  195. data/lib/html2rss/url.rb +163 -62
  196. data/lib/html2rss/version.rb +1 -1
  197. data/lib/html2rss/word_count.rb +53 -0
  198. data/lib/html2rss.rb +19 -9
  199. data/schema/html2rss-config.schema.json +56 -41
  200. metadata +33 -4
  201. data/lib/html2rss/html/rendering/media_renderer.rb +0 -39
  202. 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
- word_count_at_most?(text, 3)
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
- content = Set[
19
- 'article', 'articles', 'blog', 'blogs', 'changelog', 'changelogs', 'insight', 'insights',
20
- 'launch', 'launches', 'news', 'post', 'posts', 'release', 'releases', 'story', 'stories',
21
- 'update', 'updates', 'artikel', 'beitrag', 'beitraege', 'nachrichten', 'neuigkeiten',
22
- 'aktuelles', 'articulo', 'articulos', 'noticia', 'noticias', 'entrada', 'entradas',
23
- 'publicacion', 'publicaciones', 'actualite', 'actualites', 'nouvelle', 'nouvelles',
24
- 'teaser', 'teasers', 'card', 'cards'
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', 'games', 'kaufberater',
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
- def initialize(segments)
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?(segment_count - 1))
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
- included_last_segment? &&
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 contains high-confidence noise
125
- def shallow_high_confidence_route?
126
- vanity_segments = SEGMENT_SETS.fetch(:vanity)
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
- content_segments = SEGMENT_SETS.fetch(:content)
206
- context_segments = SEGMENT_SETS.fetch(:deep_post_context)
207
-
208
- (0...limit).any? do |i|
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