html2rss 0.33.0 → 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 (177) 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 -21
  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 +126 -50
  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 -39
  16. data/lib/html2rss/auto_source/scraper/json_document.rb +1 -3
  17. data/lib/html2rss/auto_source/scraper/json_state/article_normalizer.rb +119 -42
  18. data/lib/html2rss/auto_source/scraper/json_state/candidate_detector.rb +5 -16
  19. data/lib/html2rss/auto_source/scraper/json_state/value_finder.rb +2 -10
  20. data/lib/html2rss/auto_source/scraper/json_state.rb +2 -3
  21. data/lib/html2rss/auto_source/scraper/meta_oembed.rb +6 -14
  22. data/lib/html2rss/auto_source/scraper/microdata.rb +6 -4
  23. data/lib/html2rss/auto_source/scraper/microformats2.rb +1 -3
  24. data/lib/html2rss/auto_source/scraper/native_feed.rb +27 -17
  25. data/lib/html2rss/auto_source/scraper/schema/item_list.rb +5 -3
  26. data/lib/html2rss/auto_source/scraper/schema/thing.rb +68 -27
  27. data/lib/html2rss/auto_source/scraper/schema.rb +5 -8
  28. data/lib/html2rss/auto_source/scraper/semantic_html/entry_deduplicator.rb +3 -9
  29. data/lib/html2rss/auto_source/scraper/semantic_html.rb +1 -5
  30. data/lib/html2rss/auto_source/scraper/sitemap.rb +4 -3
  31. data/lib/html2rss/auto_source/scraper/wordpress_api.rb +3 -3
  32. data/lib/html2rss/auto_source/scraper/xhr_articles.rb +1 -3
  33. data/lib/html2rss/auto_source/scraper.rb +10 -2
  34. data/lib/html2rss/auto_source/segment.rb +19 -5
  35. data/lib/html2rss/auto_source/segmenter/cluster.rb +13 -11
  36. data/lib/html2rss/auto_source/segmenter/list.rb +13 -5
  37. data/lib/html2rss/auto_source/segmenter/primary_link.rb +29 -4
  38. data/lib/html2rss/auto_source/segmenter/semantic.rb +17 -6
  39. data/lib/html2rss/auto_source/segmenter.rb +4 -8
  40. data/lib/html2rss/auto_source.rb +81 -26
  41. data/lib/html2rss/batch.rb +2 -0
  42. data/lib/html2rss/capture/README.md +5 -2
  43. data/lib/html2rss/capture.rb +44 -32
  44. data/lib/html2rss/channel.rb +30 -12
  45. data/lib/html2rss/cli.rb +22 -11
  46. data/lib/html2rss/config/auto_source_contract.rb +52 -45
  47. data/lib/html2rss/config/issue_mapper.rb +4 -1
  48. data/lib/html2rss/config/request_controls.rb +6 -3
  49. data/lib/html2rss/config/schema.rb +15 -5
  50. data/lib/html2rss/config/selectors_validator.rb +2 -2
  51. data/lib/html2rss/config/validation_issue.rb +4 -1
  52. data/lib/html2rss/config/validator.rb +11 -0
  53. data/lib/html2rss/config.rb +53 -27
  54. data/lib/html2rss/contract_error.rb +9 -0
  55. data/lib/html2rss/defaults.rb +21 -23
  56. data/lib/html2rss/doctor/botasaurus.rb +7 -3
  57. data/lib/html2rss/error.rb +10 -0
  58. data/lib/html2rss/error_kind.rb +39 -0
  59. data/lib/html2rss/feed_builder/item_presentation.rb +22 -9
  60. data/lib/html2rss/feed_builder/json_feed/item.rb +2 -1
  61. data/lib/html2rss/feed_builder/json_feed.rb +2 -2
  62. data/lib/html2rss/feed_builder/rss/stylesheet.rb +3 -3
  63. data/lib/html2rss/feed_builder/rss.rb +49 -6
  64. data/lib/html2rss/feed_builder.rb +2 -1
  65. data/lib/html2rss/feed_pipeline/README.md +1 -1
  66. data/lib/html2rss/feed_pipeline/auto_fallback.rb +13 -31
  67. data/lib/html2rss/feed_pipeline/strategy_plan.rb +3 -3
  68. data/lib/html2rss/feed_pipeline.rb +40 -27
  69. data/lib/html2rss/feed_resolution/README.md +1 -1
  70. data/lib/html2rss/feed_resolution/candidate_generator.rb +3 -4
  71. data/lib/html2rss/feed_resolution/policy.rb +1 -1
  72. data/lib/html2rss/feed_resolution/probe.rb +3 -1
  73. data/lib/html2rss/feed_resolution.rb +11 -12
  74. data/lib/html2rss/feed_result.rb +9 -5
  75. data/lib/html2rss/hash_util.rb +3 -3
  76. data/lib/html2rss/html/article_extractor/category_extractor.rb +1 -1
  77. data/lib/html2rss/html/article_extractor/enclosure_extractor.rb +12 -31
  78. data/lib/html2rss/html/article_extractor/heading_extractor.rb +2 -3
  79. data/lib/html2rss/html/article_extractor/id_generator.rb +6 -9
  80. data/lib/html2rss/html/article_extractor/image_extractor.rb +9 -1
  81. data/lib/html2rss/html/article_extractor.rb +19 -47
  82. data/lib/html2rss/html/article_rules/category.rb +3 -3
  83. data/lib/html2rss/html/article_rules/description.rb +6 -15
  84. data/lib/html2rss/html/article_rules/enclosure.rb +70 -42
  85. data/lib/html2rss/html/article_rules/image.rb +30 -16
  86. data/lib/html2rss/html/feed_link.rb +7 -9
  87. data/lib/html2rss/html/navigator/text_extractor.rb +26 -16
  88. data/lib/html2rss/html/navigator.rb +39 -9
  89. data/lib/html2rss/html/probe.rb +4 -0
  90. data/lib/html2rss/html/rendering/description_builder.rb +46 -32
  91. data/lib/html2rss/html/rendering.rb +2 -9
  92. data/lib/html2rss/html/sst_article_extractor.rb +55 -145
  93. data/lib/html2rss/html/title_pieces/kicker.rb +147 -0
  94. data/lib/html2rss/html/title_pieces/nodes.rb +107 -0
  95. data/lib/html2rss/html/title_pieces/nokogiri_nodes.rb +70 -0
  96. data/lib/html2rss/html/title_pieces/sst_nodes.rb +71 -0
  97. data/lib/html2rss/html/title_pieces.rb +169 -0
  98. data/lib/html2rss/html/visibility.rb +56 -0
  99. data/lib/html2rss/html.rb +2 -0
  100. data/lib/html2rss/link_destination/destination_facts.rb +17 -1
  101. data/lib/html2rss/link_destination/noise_policy.rb +11 -10
  102. data/lib/html2rss/link_destination/path_classifier.rb +100 -9
  103. data/lib/html2rss/link_destination/sponsorship/label_text.rb +82 -0
  104. data/lib/html2rss/link_destination/sponsorship.rb +184 -0
  105. data/lib/html2rss/link_destination/text_classifier.rb +104 -8
  106. data/lib/html2rss/mcp/README.md +13 -1
  107. data/lib/html2rss/mcp/config_argument.rb +5 -2
  108. data/lib/html2rss/mcp/contract.rb +13 -4
  109. data/lib/html2rss/mcp/outcome.rb +14 -12
  110. data/lib/html2rss/mcp/runtime.rb +39 -2
  111. data/lib/html2rss/mcp/server.rb +13 -4
  112. data/lib/html2rss/page_recon/README.md +1 -1
  113. data/lib/html2rss/page_recon/diagnostics.rb +2 -0
  114. data/lib/html2rss/recon.rb +10 -12
  115. data/lib/html2rss/request_service/blocked_surface.rb +2 -1
  116. data/lib/html2rss/request_service/botasaurus_contract.rb +25 -3
  117. data/lib/html2rss/request_service/botasaurus_strategy.rb +42 -10
  118. data/lib/html2rss/request_service/context.rb +15 -14
  119. data/lib/html2rss/request_service/cookie_jar.rb +89 -0
  120. data/lib/html2rss/request_service/cookie_store.rb +79 -0
  121. data/lib/html2rss/request_service/feed_advertisement.rb +72 -0
  122. data/lib/html2rss/request_service/httpx_strategy.rb +7 -3
  123. data/lib/html2rss/request_service/local_file_strategy.rb +2 -2
  124. data/lib/html2rss/request_service/policy.rb +48 -11
  125. data/lib/html2rss/request_service/replay_breaker.rb +56 -0
  126. data/lib/html2rss/request_service/response.rb +30 -13
  127. data/lib/html2rss/request_service/response_cache.rb +109 -0
  128. data/lib/html2rss/request_service/response_caching.rb +95 -0
  129. data/lib/html2rss/request_service/strategy.rb +5 -11
  130. data/lib/html2rss/request_service.rb +71 -12
  131. data/lib/html2rss/request_session/pager/base.rb +0 -7
  132. data/lib/html2rss/request_session/pager.rb +1 -1
  133. data/lib/html2rss/request_session.rb +6 -2
  134. data/lib/html2rss/scoring/cluster_scorer.rb +1 -1
  135. data/lib/html2rss/scoring/container_assessor.rb +12 -13
  136. data/lib/html2rss/scoring/engine.rb +6 -6
  137. data/lib/html2rss/scoring/observation.rb +2 -2
  138. data/lib/html2rss/selector_candidates.rb +19 -8
  139. data/lib/html2rss/selectors/README.md +1 -1
  140. data/lib/html2rss/selectors/option_spec.rb +4 -1
  141. data/lib/html2rss/selectors/post_processors/base.rb +0 -8
  142. data/lib/html2rss/selectors/post_processors/gsub.rb +7 -7
  143. data/lib/html2rss/selectors/post_processors/html_transformers/keep_playable_media.rb +50 -0
  144. data/lib/html2rss/selectors/post_processors/html_transformers/transform_urls_to_absolute_ones.rb +1 -3
  145. data/lib/html2rss/selectors/post_processors/parse_time.rb +3 -3
  146. data/lib/html2rss/selectors/post_processors/parse_uri.rb +1 -1
  147. data/lib/html2rss/selectors/post_processors/sanitize_html.rb +18 -5
  148. data/lib/html2rss/selectors/post_processors/substring.rb +1 -1
  149. data/lib/html2rss/selectors.rb +53 -12
  150. data/lib/html2rss/sst/attrs.rb +2 -2
  151. data/lib/html2rss/sst/document.rb +3 -3
  152. data/lib/html2rss/sst/index.rb +30 -11
  153. data/lib/html2rss/sst/node.rb +33 -26
  154. data/lib/html2rss/sst/normalizer.rb +31 -19
  155. data/lib/html2rss/sst/tags.rb +113 -2
  156. data/lib/html2rss/sst/text.rb +69 -29
  157. data/lib/html2rss/sst.rb +2 -0
  158. data/lib/html2rss/status.rb +6 -3
  159. data/lib/html2rss/surface_category.rb +1 -1
  160. data/lib/html2rss/syndication/README.md +12 -6
  161. data/lib/html2rss/syndication/alternate_links.rb +1 -1
  162. data/lib/html2rss/syndication/channel_facts.rb +87 -0
  163. data/lib/html2rss/syndication/discovery.rb +82 -28
  164. data/lib/html2rss/syndication/feed.rb +20 -0
  165. data/lib/html2rss/syndication/item_media.rb +103 -0
  166. data/lib/html2rss/syndication/parser.rb +75 -51
  167. data/lib/html2rss/test/enhance_audit.rb +2 -2
  168. data/lib/html2rss/test/item_sample.rb +43 -0
  169. data/lib/html2rss/test.rb +42 -14
  170. data/lib/html2rss/url.rb +115 -14
  171. data/lib/html2rss/version.rb +1 -1
  172. data/lib/html2rss/word_count.rb +53 -0
  173. data/lib/html2rss.rb +17 -8
  174. data/schema/html2rss-config.schema.json +56 -41
  175. metadata +31 -4
  176. data/lib/html2rss/html/rendering/media_renderer.rb +0 -39
  177. 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
 
@@ -131,8 +185,17 @@ module Html2rss
131
185
  all_junk?(segments.size - 1)
132
186
  end
133
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
+
134
196
  # @return [Boolean] true when the route is shallow and contains high-confidence noise
135
197
  def junk_path?
198
+ return true if non_article_path?
136
199
  return false if excluded_content_route?
137
200
 
138
201
  any_high_confidence_junk_segment? || shallow_vanity_route?
@@ -140,6 +203,7 @@ module Html2rss
140
203
 
141
204
  # @return [Boolean] true when the route points at conversion or account chrome
142
205
  def utility_destination?
206
+ return true if non_article_path?
143
207
  return false if excluded_content_route?
144
208
 
145
209
  vanity_path? || utility_route?
@@ -147,6 +211,33 @@ module Html2rss
147
211
 
148
212
  private
149
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
+
150
241
  def leading_high_confidence_junk?
151
242
  segments.any? && high_confidence_junk_at?(0)
152
243
  end
@@ -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
@@ -2,8 +2,10 @@
2
2
 
3
3
  module Html2rss
4
4
  module LinkDestination
5
- # Classifies visible anchor text for utility and recommendation chrome.
6
- class TextClassifier
5
+ # Classifies visible anchor text for utility, recommendation and read-more chrome in en, de, fr, es, it,
6
+ # nl, pl, sv and ja. Letters with an accented variant are alternations, not classes: with +/i+, Ruby on
7
+ # Linux does not fold an uppercase accented letter against a class that also holds its ASCII base.
8
+ class TextClassifier # rubocop:disable Metrics/ClassLength -- nine-language vocabulary tables
7
9
  # Prefix labels that usually identify navigation or subscription links.
8
10
  UTILITY_PREFIX_PATTERN = /
9
11
  \A\s*(
@@ -14,7 +16,17 @@ module Html2rss
14
16
  # Spanish
15
17
  ver\s+todos|ver\s+todo|todas\s+las\s+noticias|suscribirse|bolet(i|í)n|comentarios\s+feed|unirse|
16
18
  # French
17
- voir\s+tout|voir\s+tous|toutes\s+les\s+nouvelles|s['’]abonner|flux\s+de\s+commentaires|rejoindre
19
+ voir\s+tout|voir\s+tous|toutes\s+les\s+nouvelles|s['’]abonner|flux\s+de\s+commentaires|rejoindre|
20
+ # Italian
21
+ vedi\s+tutto|vedi\s+tutti|tutte\s+le\s+notizie|abbonati|newsletter|iscriviti|
22
+ # Dutch
23
+ bekijk\s+alles|alles\s+bekijken|al\s+het\s+nieuws|abonneren|nieuwsbrief|lid\s+worden|
24
+ # Polish
25
+ zobacz\s+wszystko|wszystkie\s+wiadomo(ś|s)ci|subskrybuj|newsletter|do(łą|la)cz|
26
+ # Swedish
27
+ visa\s+alla|se\s+alla|alla\s+nyheter|prenumerera|nyhetsbrev|bli\s+medlem|
28
+ # Japanese
29
+ すべて見る|全て見る|ニュースレター|メルマガ|会員登録|購読
18
30
  )\b
19
31
  /ix
20
32
  # Short labels that usually identify non-article navigation links.
@@ -35,7 +47,22 @@ module Html2rss
35
47
  # French
36
48
  (a|à)\s+propos|(a|à)propos|contact|commentaires?|rejoindre|
37
49
  se\s+connecter|login|membre|abonnement|recommand(e|é)(\s+pour\s+vous)?|
38
- voir\s+tout|partager|s['’]inscrire|s['’]abonner|newsletter
50
+ voir\s+tout|partager|s['’]inscrire|s['’]abonner|newsletter|
51
+ # Italian
52
+ chi\s+siamo|contatti|commenti|accedi|iscriviti|condividi|abbonati|registrati|vedi\s+tutto|
53
+ consigliato(\s+per\s+te)?|abbonamento|
54
+ # Dutch
55
+ over\s+ons|contact|reacties|inloggen|aanmelden|delen|abonneren|registreren|bekijk\s+alles|
56
+ aanbevolen(\s+voor\s+jou)?|nieuwsbrief|lid\s+worden|lidmaatschap|
57
+ # Polish
58
+ o\s+nas|kontakt|komentarze|zaloguj\s+si(ę|e)|zarejestruj\s+si(ę|e)|udost(ę|e)pnij|subskrybuj|
59
+ zobacz\s+wszystko|polecane(\s+dla\s+ciebie)?|do(łą|la)cz|
60
+ # Swedish
61
+ om\s+oss|kontakt|kommentarer|logga\s+in|bli\s+medlem|dela|prenumerera|registrera\s+dig|visa\s+alla|
62
+ se\s+alla|rekommenderat(\s+f(ö|o)r\s+dig)?|nyhetsbrev|medlemskap|
63
+ # Japanese
64
+ 会社概要|お問い合わせ|コメント|ログイン|会員登録|新規登録|シェア|共有|購読|すべて見る|全て見る|
65
+ もっと見る|おすすめ|ニュースレター|メルマガ
39
66
  )\b
40
67
  /ix
41
68
  # Labels for recommendation chrome rather than source articles.
@@ -44,21 +71,90 @@ module Html2rss
44
71
  recommended(\s+for\s+you)?|
45
72
  empfohlen(\s+f(ue|ü)r\s+dich)?|
46
73
  recomendado(\s+para\s+ti)?|
47
- recommand(e|é)(\s+pour\s+vous)?
74
+ recommand(e|é)(\s+pour\s+vous)?|
75
+ consigliato(\s+per\s+te)?|
76
+ aanbevolen(\s+voor\s+jou)?|
77
+ polecane(\s+dla\s+ciebie)?|
78
+ rekommenderat(\s+f(ö|o)r\s+dig)?|
79
+ おすすめ
48
80
  )\b
49
81
  /ix
50
82
 
83
+ # Whole read-more labels that stand alone ("Continue reading", "Lire la suite", "続きを読む").
84
+ READ_MORE_PHRASE = /
85
+ read\s+more|read\s+on|continue\s+reading|keep\s+reading|learn\s+more|find\s+out\s+more|
86
+ read\s+(?:the\s+)?(?:full\s+|whole\s+)?(?:story|article)|read\s+the\s+rest|
87
+ weiterlesen|weiter\s+lesen|mehr\s+lesen|mehr\s+erfahren|mehr\s+dazu|mehr\s+zum\s+thema|
88
+ lesen\s+sie\s+(?:mehr|weiter)|(?:den\s+)?(?:ganzen|gesamten|vollst(?:ä|a)ndigen)\s+artikel\s+lesen|
89
+ lire\s+la\s+suite|lire\s+plus|lire\s+l['’]article(?:\s+complet)?|en\s+savoir\s+plus|
90
+ pour\s+en\s+savoir\s+plus|plus\s+d['’]infos?|plus\s+d['’]informations?|
91
+ leer\s+m(?:á|a)s|seguir\s+leyendo|continuar\s+leyendo|saber\s+m(?:á|a)s|
92
+ m(?:á|a)s\s+informaci(?:ó|o)n|leer\s+(?:todo|el\s+art(?:í|i)culo\s+completo)|
93
+ leggi\s+tutto|leggi\s+di\s+pi(?:ù|u)|leggi\s+l['’]articolo|continua\s+a\s+leggere|scopri\s+di\s+pi(?:ù|u)|
94
+ maggiori\s+informazioni|ulteriori\s+informazioni|per\s+saperne\s+di\s+pi(?:ù|u)|approfondisci|
95
+ lees\s+meer|lees\s+verder|verder\s+lezen|meer\s+lezen|meer\s+informatie|meer\s+weten|
96
+ lees\s+(?:het\s+)?(?:hele|volledige)\s+artikel|
97
+ czytaj\s+wi(?:ę|e)cej|czytaj\s+dalej|dowiedz\s+si(?:ę|e)\s+wi(?:ę|e)cej|wi(?:ę|e)cej\s+informacji|
98
+ czytaj\s+ca(?:ł|l)o(?:ś|s)(?:ć|c)|zobacz\s+wi(?:ę|e)cej|
99
+ l(?:ä|a)s\s+mer|l(?:ä|a)s\s+vidare|forts(?:ä|a)tt\s+l(?:ä|a)sa|mer\s+information|
100
+ l(?:ä|a)s\s+hela\s+artikeln|
101
+ 続きを読む|もっと読む|続きはこちら|詳しくはこちら|詳細を見る|詳細はこちら|全文を読む|続きを見る
102
+ /ix
103
+ # "More about X" lead-ins: a read-more phrase or a bare "more", then a topic preposition.
104
+ READ_MORE_TOPIC_LEAD = /
105
+ (?:#{READ_MORE_PHRASE.source}|more|mehr|plus\s+d['’]infos?|m(?:á|a)s|altro|di\s+pi(?:ù|u)|meer|wi(?:ę|e)cej|mer)
106
+ \s+(?:about|on|from|zu|zum\s+thema|zum|zur|(?:ü|ue)ber|von|sur|
107
+ (?:à|a)\s+propos\s+de|de|sobre|su|riguardo|over|van|
108
+ o|na\s+temat|om|av)
109
+ /ix
110
+ # A read-more label as a whole text, optionally wrapped in punctuation or arrows. The topic after a
111
+ # lead-in counts only when quoted ("More about «X»", „X“, "X", 'X', 「X」), so a headline that merely
112
+ # opens with one ("Mehr über die Zukunft der Rente") stays a headline; Japanese topic forms put the
113
+ # topic first.
114
+ READ_MORE_PATTERN = /
115
+ \A[\s\p{P}\p{S}]*(?:
116
+ (?:#{READ_MORE_PHRASE.source})|
117
+ (?:#{READ_MORE_TOPIC_LEAD.source})\s*[«„"“”'‘’‹「].+[»"“”'’›」]|
118
+ \S{1,40}(?:の続きを読む|をもっと読む|について詳しく(?:はこちら)?|の詳細(?:を見る|はこちら))
119
+ )[\s\p{P}\p{S}]*\z
120
+ /ix
121
+
122
+ # Visible new-window notice that trails a link label, in parentheses or brackets.
123
+ NEW_WINDOW_NOTICE = /
124
+ \s*[(\[(【]\s*(?:
125
+ opens\s+in\s+(?:a\s+)?new\s+(?:window|tab)|
126
+ (?:ö|oe)ffnet\s+(?:sich\s+)?in\s+(?:einem\s+neuen|neuem)\s+(?:fenster|tab)|
127
+ ouvre\s+dans\s+(?:une\s+)?nouvel(?:le)?\s+(?:fen(?:ê|e)tre|onglet)|
128
+ se\s+abre\s+en\s+(?:una\s+)?nueva\s+(?:ventana|pesta(?:ñ|n)a)|
129
+ si\s+apre\s+in\s+(?:una\s+)?nuova\s+(?:finestra|scheda)|
130
+ opent\s+in\s+(?:een\s+)?nieuw(?:e)?\s+(?:venster|tabblad)|
131
+ otwiera\s+si(?:ę|e)\s+w\s+nowym\s+(?:oknie|karcie)|
132
+ (?:ö|o)ppnas\s+i\s+(?:ett\s+)?nytt\s+(?:f(?:ö|o)nster|flik)|
133
+ 新しい(?:ウィンドウ|ウインドウ|タブ)で開く
134
+ )\s*[)\])】]\s*\z
135
+ /ix
136
+
51
137
  # @param text [String, #to_s] visible anchor text
52
138
  # @return [Boolean] true when text matches a utility label
53
- def utility?(text) = text.to_s.match?(UTILITY_PATTERN)
139
+ def utility?(text) = normalize(text).match?(UTILITY_PATTERN)
54
140
 
55
141
  # @param text [String, #to_s] visible anchor text
56
142
  # @return [Boolean] true when text begins with a utility label
57
- def utility_prefix?(text) = text.to_s.match?(UTILITY_PREFIX_PATTERN)
143
+ def utility_prefix?(text) = normalize(text).match?(UTILITY_PREFIX_PATTERN)
58
144
 
59
145
  # @param text [String, #to_s] visible anchor text
60
146
  # @return [Boolean] true when text identifies recommendation chrome
61
- def recommended?(text) = text.to_s.match?(RECOMMENDED_PATTERN)
147
+ def recommended?(text) = normalize(text).match?(RECOMMENDED_PATTERN)
148
+
149
+ # @param text [String, #to_s] visible anchor text or extracted title
150
+ # @return [Boolean] true when the whole text is a read-more label, bare or with its topic
151
+ def read_more?(text) = normalize(text).match?(READ_MORE_PATTERN)
152
+
153
+ private
154
+
155
+ # Every label is read in NFC, so a decomposed accent matches the precomposed vocabulary, and without
156
+ # its trailing new-window notice ("(opens in new tab)").
157
+ def normalize(text) = text.to_s.unicode_normalize(:nfc).sub(NEW_WINDOW_NOTICE, '')
62
158
  end
63
159
  end
64
160
  end
@@ -39,6 +39,7 @@ Every tool result is one JSON object (text body and `structuredContent`):
39
39
  | `next_step` | Bare verb name or `done` / `read_runtime` |
40
40
  | `guidance` | Agent instruction (from `Outcome::Playbook`) |
41
41
  | `payload` | Tool-specific data (`yaml`, `rss`, `items`, recon fields, …) |
42
+ | `cache` | Optional: `{ hits, max_age_seconds }` when cached pages fed the result |
42
43
 
43
44
  `Contract.response` builds the wire body; `Outcome` owns `next_step` policy.
44
45
 
@@ -64,7 +65,7 @@ Do not duplicate playbook prose in `server.rb`.
64
65
  | `html2rss://schema` | Full JSON Schema for feed configurations |
65
66
  | `html2rss://extractors` | Registered extractor names |
66
67
  | `html2rss://strategies` | Published MCP strategies (`auto`, `default`, `httpx`, `botasaurus`) |
67
- | `html2rss://runtime` | `version`, `mcp_contract_version`, `catalog_fingerprint`, `tools`, `botasaurus_configured` (never the scraper URL). Fingerprint covers tool names, required keys, and `oneOf` branches; bump `mcp_contract_version` for envelope semantics. |
68
+ | `html2rss://runtime` | `version`, `mcp_contract_version`, `catalog_fingerprint`, `tools`, `botasaurus_configured`, `response_cache_ttl` (never the scraper URL). Fingerprint covers tool names, required keys, and `oneOf` branches; bump `mcp_contract_version` for envelope semantics. |
68
69
 
69
70
  ## Prompts
70
71
 
@@ -73,6 +74,17 @@ Do not duplicate playbook prose in `server.rb`.
73
74
  | `scrape-webpage` | One `scrape` call; `inspect` only if weak or recon needed |
74
75
  | `capture-feed-config` | Capture YAML → test → apply; catalog rewrite; strive enhance |
75
76
 
77
+ ## Response cache
78
+
79
+ The MCP process is long-lived, so an agent iterating `inspect → capture → validate → test` on one URL should not refetch it. `RequestService::ResponseCache` is installed at server start, **on by default with a 300s TTL**. Set `HTML2RSS_RESPONSE_CACHE_TTL=<seconds>` to change it, or `0` / `off` / `false` to disable; an unusable value logs a warning and uses the default.
80
+
81
+ - Only initial 2xx fetches are cached, keyed by a digest of URL, strategy, headers, request options, and policy limits. Follow-ups and block pages always hit the network.
82
+ - Memory is bounded: at most 32 entries and 32 MiB of bodies, oldest evicted first.
83
+ - When a result was built from cached pages, the envelope carries `cache: { hits, max_age_seconds }`; absent means everything was fetched live. Wait out the TTL or restart with the cache off to force a refetch.
84
+ - `html2rss://runtime` publishes `response_cache_ttl` (`null` when off).
85
+
86
+ The one-shot CLI has no use for it; use `capture` or `--input -` for offline iteration.
87
+
76
88
  ## Strategy note
77
89
 
78
90
  `scrape` / `capture` with `strategy: "auto"` run the full AutoFallback chain. `inspect` maps `auto` to default (HTTPX) for cheap diagnostics; pin `botasaurus` when you need browser rendering for inspect.