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
@@ -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.
@@ -8,22 +8,26 @@ module Html2rss
8
8
  #
9
9
  # Config does not know MCP. YAML parsing stays on {Html2rss::Config.from_yaml}.
10
10
  class ConfigArgument
11
+ # Raised unless exactly one of +config+ or +yaml+ is present.
12
+ class ExclusiveInputError < Html2rss::InvalidOption; end
13
+
11
14
  class << self
12
15
  ##
13
16
  # @param config [Hash, nil] feed configuration hash
14
17
  # @param yaml [String, nil] feed configuration YAML
15
18
  # @return [ConfigArgument]
16
- # @raise [ArgumentError] unless exactly one of +config+ or +yaml+ is present
19
+ # @raise [ExclusiveInputError] unless exactly one of +config+ or +yaml+ is present
20
+ # @raise [Html2rss::InvalidOption] when +config+ is not a Hash or +yaml+ is not a String
17
21
  # @raise [Contract::UnpublishedRequestError] when the parsed config uses
18
22
  # an unpublished MCP strategy or +request.local_file_path+
19
23
  def parse(config: nil, yaml: nil)
20
24
  parsed = case [present?(config), present?(yaml)]
21
25
  in [true, false]
22
- HashUtil.deep_symbolize_keys(config, context: 'config')
26
+ HashUtil.deep_symbolize_keys(require_type!(config, Hash, 'config'), context: 'config')
23
27
  in [false, true]
24
- Config.from_yaml(yaml)
28
+ Config.from_yaml(require_type!(yaml, String, 'yaml'))
25
29
  else
26
- raise ArgumentError, 'Provide exactly one of config or yaml'
30
+ raise ExclusiveInputError, 'Provide exactly one of config or yaml'
27
31
  end
28
32
  Contract.assert_published_request!(parsed)
29
33
  new(config: parsed)
@@ -31,6 +35,12 @@ module Html2rss
31
35
 
32
36
  private
33
37
 
38
+ def require_type!(value, type, name)
39
+ return value if value.is_a?(type)
40
+
41
+ raise Html2rss::InvalidOption, "#{name} must be a #{type}, got #{value.class}"
42
+ end
43
+
34
44
  def present?(value)
35
45
  case value
36
46
  in nil then false
@@ -16,11 +16,11 @@ module Html2rss
16
16
  ALL_ACCEPTED_STRATEGIES = (STRATEGIES + MIGRATION_STRATEGIES).freeze
17
17
 
18
18
  # Bump when tool names, required inputs, or envelope semantics change (independent of gem +VERSION+).
19
- MCP_CONTRACT_VERSION = 3
19
+ MCP_CONTRACT_VERSION = 4
20
20
  public_constant :MCP_CONTRACT_VERSION
21
21
 
22
22
  # Raised when apply/validate config uses an unpublished MCP request adapter.
23
- class UnpublishedRequestError < ArgumentError; end
23
+ class UnpublishedRequestError < Html2rss::InvalidOption; end
24
24
 
25
25
  # JSON Schema property for a source page URL.
26
26
  URL_PROPERTY = {
@@ -103,7 +103,7 @@ module Html2rss
103
103
  properties: {
104
104
  url: URL_PROPERTY,
105
105
  strategy: STRATEGY_PROPERTY,
106
- limit: { type: 'integer', description: 'Max articles to keep (default 25)', default: 25 },
106
+ limit: { type: 'integer', minimum: 1, description: 'Max articles to keep (default 25)', default: 25 },
107
107
  items_selector: { type: 'string', description: 'Optional CSS selector hint for items' }
108
108
  }.freeze,
109
109
  required: %w[url]
@@ -135,9 +135,9 @@ module Html2rss
135
135
  title: { type: 'string', description: 'Channel title override' },
136
136
  summary: { type: 'string', description: 'Directory summary override' },
137
137
  enhance: { type: 'boolean', description: 'Force enhance on or off' },
138
- limit: { type: 'integer', description: 'Max articles to keep' },
139
- max_redirects: { type: 'integer', description: 'Optional redirect limit override' },
140
- max_requests: { type: 'integer', description: 'Optional request budget override' }
138
+ limit: { type: 'integer', minimum: 1, description: 'Max articles to keep' },
139
+ max_redirects: { type: 'integer', minimum: 0, description: 'Optional redirect limit override' },
140
+ max_requests: { type: 'integer', minimum: 1, description: 'Optional request budget override' }
141
141
  }.freeze,
142
142
  required: %w[url]
143
143
  }.freeze
@@ -163,6 +163,8 @@ module Html2rss
163
163
  strategy: strategy_property,
164
164
  concurrency: {
165
165
  type: 'integer',
166
+ minimum: 1,
167
+ maximum: 10,
166
168
  description: 'Max parallel worker threads (1..10, default: 5)',
167
169
  default: 5
168
170
  },
@@ -177,7 +179,7 @@ module Html2rss
177
179
  urls_description: 'List of page URLs to scrape (1..25)',
178
180
  strategy_property: STRATEGY_PROPERTY,
179
181
  extra_properties: {
180
- limit: { type: 'integer', description: 'Max articles per URL to keep (default 10)', default: 10 }
182
+ limit: { type: 'integer', minimum: 1, description: 'Max articles per URL to keep (default 10)', default: 10 }
181
183
  }
182
184
  ).freeze
183
185
 
@@ -242,7 +244,14 @@ module Html2rss
242
244
  ok: { type: 'boolean' },
243
245
  next_step: { type: 'string', enum: Outcome::NextStep::ALL.keys.map(&:to_s) },
244
246
  guidance: { type: 'string' },
245
- payload: { type: 'object' }
247
+ payload: { type: 'object' },
248
+ cache: {
249
+ type: 'object',
250
+ description: 'Present when part of this result came from the response cache instead of the live page',
251
+ additionalProperties: false,
252
+ required: %w[hits max_age_seconds],
253
+ properties: { hits: { type: 'integer' }, max_age_seconds: { type: 'integer' } }
254
+ }
246
255
  }
247
256
  }
248
257
  end
@@ -251,9 +260,11 @@ module Html2rss
251
260
  # One envelope Hash, one compact JSON body, no +_meta+.
252
261
  #
253
262
  # @param outcome [Outcome]
263
+ # @param cache_ages [Array<Integer>] ages in seconds of cached page fetches behind this result
254
264
  # @return [::MCP::Tool::Response]
255
- def response(outcome)
265
+ def response(outcome, cache_ages: [])
256
266
  wire = outcome.to_h
267
+ wire[:cache] = { hits: cache_ages.size, max_age_seconds: cache_ages.max } unless cache_ages.empty?
257
268
  ::MCP::Tool::Response.new(
258
269
  [{ type: 'text', text: JSON.generate(wire) }],
259
270
  error: !outcome.ok,
@@ -8,8 +8,9 @@ module Html2rss
8
8
  # Typed MCP tool result. Owns next-step policy; guidance copy lives in
9
9
  # {Playbook}.
10
10
  class Outcome # rubocop:disable Metrics/ClassLength -- next-step policy + factories stay co-located
11
- # Matches {ConfigArgument} XOR +ArgumentError+ messages.
12
- XOR_ERROR = /exactly one of config or yaml/
11
+ # Raised when an outcome is built with a wrong-typed next step or payload.
12
+ class InvalidError < Html2rss::ContractError; end
13
+
13
14
  NextStep = Data.define(:name, :guidance)
14
15
 
15
16
  ##
@@ -22,7 +23,7 @@ module Html2rss
22
23
  ##
23
24
  # @param name [Symbol, String]
24
25
  # @return [NextStep]
25
- def self.[](name) = ALL.fetch(name.to_sym) { raise ArgumentError, "unknown next_step: #{name.inspect}" }
26
+ def self.[](name) = ALL.fetch(name.to_sym) { raise InvalidOption, "unknown next_step: #{name.inspect}" }
26
27
  end
27
28
 
28
29
  ##
@@ -31,8 +32,8 @@ module Html2rss
31
32
  # @param guidance [String]
32
33
  # @param payload [Hash]
33
34
  def initialize(ok:, next_step:, guidance:, payload:) # rubocop:disable Naming/MethodParameterName -- +ok+ is the envelope field
34
- raise ArgumentError, 'next_step must be a NextStep' unless next_step.is_a?(NextStep)
35
- raise ArgumentError, 'payload must be a Hash' unless payload.is_a?(Hash)
35
+ raise InvalidError, 'next_step must be a NextStep' unless next_step.is_a?(NextStep)
36
+ raise InvalidError, 'payload must be a Hash' unless payload.is_a?(Hash)
36
37
 
37
38
  super(ok: !!ok, next_step:, guidance: guidance.to_s.freeze, payload: payload.dup.freeze)
38
39
  end
@@ -60,7 +61,7 @@ module Html2rss
60
61
  ##
61
62
  # @param report [PageRecon::Diagnostics::Report]
62
63
  # @return [Outcome]
63
- def inspect(report:)
64
+ def inspect_report(report:)
64
65
  next_step = inspect_next_step(report)
65
66
  guidance = Playbook.inspect_guidance(report)
66
67
  new(ok: true, next_step:, guidance:, payload: report.to_wire_h)
@@ -194,7 +195,7 @@ module Html2rss
194
195
  NextStep[:scrape]
195
196
  end
196
197
 
197
- def capture_next_step(articles_count:, has_selectors:, native_feed: nil)
198
+ def capture_next_step(articles_count:, has_selectors:, native_feed:)
198
199
  return NextStep[:done] if native_feed
199
200
 
200
201
  articles_count.positive? && has_selectors ? NextStep[:test] : NextStep[:inspect]
@@ -205,7 +206,6 @@ module Html2rss
205
206
 
206
207
  kind = test_result.failure_kind
207
208
  return NextStep[:validate] if kind&.schema?
208
- return NextStep[:capture] if kind&.execution? || kind&.min_items? || kind&.quality?
209
209
 
210
210
  NextStep[:capture]
211
211
  end
@@ -219,12 +219,18 @@ module Html2rss
219
219
  warnings = test_result.quality_report&.warnings
220
220
  return base if warnings.nil? || warnings.empty?
221
221
 
222
- "#{base} Review payload.quality_report warnings: #{warnings.join(', ')}."
222
+ "#{base} Review payload.quality_report warnings: #{warnings.join(', ')}.#{items_without_url_hint(warnings)}"
223
+ end
224
+
225
+ def items_without_url_hint(warnings)
226
+ return '' unless warnings.include?(:items_without_url)
227
+
228
+ ' Items were dropped for having no URL: fix the selectors.url selector (it must match a link on each item).'
223
229
  end
224
230
 
225
231
  def capture_payload(yaml:, articles_count:, has_selectors:, channel_title:, requested_strategy:, # rubocop:disable Metrics/ParameterLists
226
- segment_strategy:, selected_strategy:, admission_drops:, native_feed: nil,
227
- suggested_channel_url: nil)
232
+ segment_strategy:, selected_strategy:, admission_drops:, native_feed:,
233
+ suggested_channel_url:)
228
234
  {
229
235
  yaml:, articles_count:, has_selectors:, channel_title:, requested_strategy: requested_strategy.to_s,
230
236
  **(native_feed ? { native_feed: native_feed.to_s } : {}),
@@ -238,15 +244,10 @@ module Html2rss
238
244
  def next_step_for_error(error)
239
245
  case error
240
246
  when RequestService::BotasaurusConfigurationError then NextStep[:read_runtime]
241
- when Contract::UnpublishedRequestError then NextStep[:validate]
242
- when ArgumentError then argument_error_next_step(error)
247
+ when Contract::UnpublishedRequestError, ConfigArgument::ExclusiveInputError then NextStep[:validate]
243
248
  else NextStep[:inspect]
244
249
  end
245
250
  end
246
-
247
- def argument_error_next_step(error)
248
- XOR_ERROR.match?(error.message) ? NextStep[:validate] : NextStep[:inspect]
249
- end
250
251
  end
251
252
  end
252
253
  end
@@ -11,34 +11,64 @@ module Html2rss
11
11
  :mcp_contract_version,
12
12
  :catalog_fingerprint,
13
13
  :tools,
14
- :botasaurus_configured
14
+ :botasaurus_configured,
15
+ :response_cache_ttl
15
16
  )
16
17
 
17
- module_function
18
+ # Seconds an initial page fetch is reused when +HTML2RSS_RESPONSE_CACHE_TTL+ is unset.
19
+ DEFAULT_RESPONSE_CACHE_TTL = 300
20
+ # Values of +HTML2RSS_RESPONSE_CACHE_TTL+ that disable the cache.
21
+ CACHE_OFF_VALUES = %w[0 off false].freeze
22
+ private_constant :CACHE_OFF_VALUES
18
23
 
19
- ##
20
- # @return [Boolean] whether Botasaurus transport is configured in this process
21
- def botasaurus_configured?
22
- !ENV['BOTASAURUS_SCRAPER_URL'].to_s.strip.empty?
23
- end
24
+ class << self
25
+ ##
26
+ # @return [Boolean] whether Botasaurus transport is configured in this process
27
+ def botasaurus_configured?
28
+ !ENV['BOTASAURUS_SCRAPER_URL'].to_s.strip.empty?
29
+ end
24
30
 
25
- ##
26
- # @return [Snapshot] runtime capabilities and catalog identity for MCP clients
27
- def snapshot
28
- Snapshot.new(
29
- version: Html2rss::VERSION,
30
- mcp_contract_version: Contract::MCP_CONTRACT_VERSION,
31
- catalog_fingerprint: Contract.catalog_fingerprint,
32
- tools: Contract.catalog_tools,
33
- botasaurus_configured: botasaurus_configured?
34
- )
35
- end
31
+ ##
32
+ # Installs the initial-fetch cache for the long-lived MCP process, so an agent iterating
33
+ # +inspect -> capture -> validate -> test+ on one URL fetches it once per TTL window.
34
+ # +HTML2RSS_RESPONSE_CACHE_TTL+ sets the seconds ({DEFAULT_RESPONSE_CACHE_TTL} when unset);
35
+ # +0+, +off+ or +false+ disables caching. An unusable value logs a warning and uses the default.
36
+ #
37
+ # @return [void]
38
+ def install_response_cache!
39
+ ttl = response_cache_ttl
40
+ RequestService.response_cache = ttl && RequestService::ResponseCache.new(ttl:)
41
+ end
42
+
43
+ ##
44
+ # @return [Snapshot] runtime capabilities and catalog identity for MCP clients
45
+ def snapshot
46
+ Snapshot.new(
47
+ version: Html2rss::VERSION,
48
+ mcp_contract_version: Contract::MCP_CONTRACT_VERSION,
49
+ catalog_fingerprint: Contract.catalog_fingerprint,
50
+ tools: Contract.catalog_tools,
51
+ botasaurus_configured: botasaurus_configured?,
52
+ response_cache_ttl: RequestService.response_cache&.ttl
53
+ )
54
+ end
55
+
56
+ private
57
+
58
+ def response_cache_ttl
59
+ raw = ENV['HTML2RSS_RESPONSE_CACHE_TTL'].to_s.strip.downcase
60
+ return DEFAULT_RESPONSE_CACHE_TTL if raw.empty?
61
+ return if CACHE_OFF_VALUES.include?(raw)
62
+
63
+ ttl = Integer(raw, 10, exception: false)
64
+ ttl&.positive? ? ttl : invalid_cache_ttl(raw)
65
+ end
36
66
 
37
- ##
38
- # @param strategy [String, Symbol, nil]
39
- # @return [Symbol]
40
- def coerce_strategy(strategy)
41
- (strategy || :auto).to_sym
67
+ def invalid_cache_ttl(raw)
68
+ Log.warn("HTML2RSS_RESPONSE_CACHE_TTL=#{raw.inspect} is not a positive integer; " \
69
+ "using #{DEFAULT_RESPONSE_CACHE_TTL}s")
70
+ DEFAULT_RESPONSE_CACHE_TTL
71
+ end
42
72
  end
43
73
  end
44
74
  end
@@ -36,9 +36,9 @@ module Html2rss
36
36
  input_schema: Contract::INSPECT_INPUT_SCHEMA,
37
37
  annotations: Contract::ANNOTATIONS_OPEN_WORLD,
38
38
  call: lambda { |url:, strategy: 'auto', **|
39
- Outcome.inspect(
39
+ Outcome.inspect_report(
40
40
  report: PageRecon::Diagnostics.call(
41
- url:, strategy: Runtime.coerce_strategy(strategy), deep: false
41
+ url:, strategy: Option.strategy(strategy), deep: false
42
42
  )
43
43
  )
44
44
  }
@@ -51,7 +51,7 @@ module Html2rss
51
51
  input_schema: Contract::RECON_INPUT_SCHEMA,
52
52
  annotations: Contract::ANNOTATIONS_OPEN_WORLD,
53
53
  call: lambda { |url:, strategy: 'auto', **|
54
- Outcome.recon(result: Html2rss.recon(url, strategy: Runtime.coerce_strategy(strategy)))
54
+ Outcome.recon(result: Html2rss.recon(url, strategy: Option.strategy(strategy)))
55
55
  }
56
56
  ),
57
57
  Tool.new(
@@ -63,7 +63,7 @@ module Html2rss
63
63
  annotations: Contract::ANNOTATIONS_OPEN_WORLD,
64
64
  call: lambda { |urls:, strategy: 'auto', concurrency: Batch::DEFAULT_CONCURRENCY, limit: 10, **|
65
65
  Outcome.batch_scrape(
66
- Batch.batch_scrape(urls:, strategy: Runtime.coerce_strategy(strategy), concurrency:, limit:)
66
+ Batch.batch_scrape(urls:, strategy: Option.strategy(strategy), concurrency:, limit:)
67
67
  )
68
68
  }
69
69
  ),
@@ -76,7 +76,7 @@ module Html2rss
76
76
  annotations: Contract::ANNOTATIONS_OPEN_WORLD,
77
77
  call: lambda { |urls:, strategy: 'auto', concurrency: Batch::DEFAULT_CONCURRENCY, **|
78
78
  Outcome.batch_inspect(
79
- Batch.batch_inspect(urls:, strategy: Runtime.coerce_strategy(strategy), concurrency:)
79
+ Batch.batch_inspect(urls:, strategy: Option.strategy(strategy), concurrency:)
80
80
  )
81
81
  }
82
82
  ),
@@ -89,7 +89,7 @@ module Html2rss
89
89
  annotations: Contract::ANNOTATIONS_OPEN_WORLD,
90
90
  call: lambda { |urls:, strategy: 'auto', concurrency: Batch::DEFAULT_CONCURRENCY, **|
91
91
  Outcome.batch_recon(
92
- Batch.batch_recon(urls:, strategy: Runtime.coerce_strategy(strategy), concurrency:)
92
+ Batch.batch_recon(urls:, strategy: Option.strategy(strategy), concurrency:)
93
93
  )
94
94
  }
95
95
  ),
@@ -136,7 +136,7 @@ module Html2rss
136
136
  compare_enhance: false, **kwargs|
137
137
  feed_config = ConfigArgument.parse(config:, yaml:).config
138
138
  test_args = { min_items:, strict_quality:, compare_enhance: }
139
- test_args[:strategy] = Runtime.coerce_strategy(kwargs[:strategy]) if kwargs.key?(:strategy)
139
+ test_args[:strategy] = Option.strategy(kwargs[:strategy]) if kwargs.key?(:strategy)
140
140
  Outcome.test(Html2rss.test(feed_config, **test_args))
141
141
  }
142
142
  ),
@@ -163,7 +163,7 @@ module Html2rss
163
163
  # @param items_selector [String, nil]
164
164
  # @return [Outcome]
165
165
  def scrape_outcome(url:, strategy: 'auto', limit: 25, items_selector: nil)
166
- wire = Batch.scrape_wire(url:, strategy: Runtime.coerce_strategy(strategy), limit:, items_selector:)
166
+ wire = Batch.scrape_wire(url:, strategy: Option.strategy(strategy), limit:, items_selector:)
167
167
  Outcome.scrape(
168
168
  items: wire[:items],
169
169
  requested_strategy: wire[:strategy],
@@ -189,7 +189,7 @@ module Html2rss
189
189
  # @return [Outcome]
190
190
  def capture_outcome(url:, strategy: 'auto', items_selector: nil, force: false, topics: nil, title: nil, # rubocop:disable Metrics/MethodLength, Metrics/ParameterLists
191
191
  summary: nil, enhance: nil, limit: nil, max_redirects: nil, max_requests: nil)
192
- plan = Runtime.coerce_strategy(strategy)
192
+ plan = Option.strategy(strategy)
193
193
  result = Html2rss::Capture.build(
194
194
  url,
195
195
  strategy: plan,
@@ -23,6 +23,9 @@ module Html2rss
23
23
  # Supported transport names.
24
24
  TRANSPORTS = Set[:stdio, :http].freeze
25
25
 
26
+ # Raised when the HTTP transport's optional gems (+rackup+, +webrick+) cannot be loaded.
27
+ class HttpTransportUnavailable < Html2rss::Error; end
28
+
26
29
  # Declarative MCP resource registrations consumed by {register_resources}.
27
30
  RESOURCES = [
28
31
  {
@@ -95,10 +98,13 @@ module Html2rss
95
98
  #
96
99
  # @param transport [Symbol] +:stdio+ or +:http+
97
100
  # @param port [Integer] port for HTTP transport
101
+ # @raise [InvalidOption] for an unknown transport
102
+ # @raise [HttpTransportUnavailable] when +:http+ is requested without the rackup and webrick gems
98
103
  def start(transport: :stdio, port: 8080)
99
- raise ArgumentError, "Unknown transport: #{transport.inspect}" unless TRANSPORTS.include?(transport)
104
+ raise InvalidOption, "Unknown transport: #{transport.inspect}" unless TRANSPORTS.include?(transport)
100
105
 
101
106
  configure_daemon_logging!
107
+ Runtime.install_response_cache!
102
108
  app = build
103
109
  Log.info(start_banner(transport:, port:))
104
110
  return start_http(app, port:) if transport == :http
@@ -204,7 +210,7 @@ module Html2rss
204
210
  Logger: Html2rss.logger
205
211
  )
206
212
  rescue LoadError => error
207
- raise LoadError,
213
+ raise HttpTransportUnavailable,
208
214
  'HTTP transport requires the rackup and webrick gems ' \
209
215
  "(#{error.message}). Install them or use --transport stdio."
210
216
  end
@@ -218,8 +224,11 @@ module Html2rss
218
224
  Contract.response(Outcome.from_error(error))
219
225
  end
220
226
 
221
- def handle_tool_call
222
- Contract.response(yield)
227
+ def handle_tool_call(&)
228
+ outcome, cache_ages = RequestService.observe_cache_hits(&)
229
+ Contract.response(outcome, cache_ages:)
230
+ rescue Html2rss::ContractError
231
+ raise
223
232
  rescue StandardError => error
224
233
  tool_error_response(error)
225
234
  end