html2rss 0.32.4 → 0.34.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +4 -2
- data/lib/html2rss/article/deduplicator.rb +5 -2
- data/lib/html2rss/article/enclosure.rb +185 -25
- data/lib/html2rss/article/title.rb +203 -0
- data/lib/html2rss/article.rb +79 -13
- data/lib/html2rss/auto_source/README.md +16 -8
- data/lib/html2rss/auto_source/cleanup.rb +127 -53
- data/lib/html2rss/auto_source/corroboration.rb +167 -0
- data/lib/html2rss/auto_source/page_links/anchor_claims.rb +82 -0
- data/lib/html2rss/auto_source/page_links/card_units.rb +77 -0
- data/lib/html2rss/auto_source/page_links/label_binding.rb +79 -0
- data/lib/html2rss/auto_source/page_links/sponsored_cards.rb +147 -0
- data/lib/html2rss/auto_source/page_links.rb +164 -0
- data/lib/html2rss/auto_source/scraper/html.rb +11 -45
- data/lib/html2rss/auto_source/scraper/json_document.rb +3 -6
- data/lib/html2rss/auto_source/scraper/json_state/article_normalizer.rb +202 -125
- data/lib/html2rss/auto_source/scraper/json_state/candidate_detector.rb +27 -38
- data/lib/html2rss/auto_source/scraper/json_state/document_scanner.rb +123 -123
- data/lib/html2rss/auto_source/scraper/json_state/value_finder.rb +27 -35
- data/lib/html2rss/auto_source/scraper/json_state.rb +4 -9
- data/lib/html2rss/auto_source/scraper/meta_oembed.rb +27 -5
- data/lib/html2rss/auto_source/scraper/microdata.rb +233 -248
- data/lib/html2rss/auto_source/scraper/microformats2.rb +10 -4
- data/lib/html2rss/auto_source/scraper/native_feed.rb +28 -18
- data/lib/html2rss/auto_source/scraper/schema/category_extractor.rb +7 -2
- data/lib/html2rss/auto_source/scraper/schema/item_list.rb +5 -3
- data/lib/html2rss/auto_source/scraper/schema/thing.rb +140 -22
- data/lib/html2rss/auto_source/scraper/schema.rb +52 -8
- data/lib/html2rss/auto_source/scraper/semantic_html/entry_deduplicator.rb +3 -9
- data/lib/html2rss/auto_source/scraper/semantic_html.rb +12 -20
- data/lib/html2rss/auto_source/scraper/sitemap/parser.rb +1 -4
- data/lib/html2rss/auto_source/scraper/sitemap.rb +7 -14
- data/lib/html2rss/auto_source/scraper/wordpress_api/page_scope.rb +2 -2
- data/lib/html2rss/auto_source/scraper/wordpress_api/posts_endpoint.rb +1 -3
- data/lib/html2rss/auto_source/scraper/wordpress_api.rb +7 -7
- data/lib/html2rss/auto_source/scraper/xhr_articles.rb +2 -4
- data/lib/html2rss/auto_source/scraper.rb +10 -13
- data/lib/html2rss/auto_source/segment.rb +19 -5
- data/lib/html2rss/auto_source/segmenter/cluster.rb +18 -22
- data/lib/html2rss/auto_source/segmenter/list.rb +35 -59
- data/lib/html2rss/auto_source/segmenter/primary_link.rb +29 -4
- data/lib/html2rss/auto_source/segmenter/semantic.rb +23 -19
- data/lib/html2rss/auto_source/segmenter.rb +4 -8
- data/lib/html2rss/auto_source.rb +81 -26
- data/lib/html2rss/batch.rb +24 -32
- data/lib/html2rss/capture/README.md +6 -3
- data/lib/html2rss/capture.rb +51 -59
- data/lib/html2rss/channel.rb +30 -12
- data/lib/html2rss/cli/render.rb +97 -96
- data/lib/html2rss/cli/validate.rb +2 -4
- data/lib/html2rss/cli.rb +55 -38
- data/lib/html2rss/config/auto_source_contract.rb +52 -45
- data/lib/html2rss/config/issue_mapper.rb +4 -1
- data/lib/html2rss/config/request_controls.rb +16 -5
- data/lib/html2rss/config/schema.rb +23 -15
- data/lib/html2rss/config/selectors_validator.rb +3 -2
- data/lib/html2rss/config/validation_issue.rb +4 -1
- data/lib/html2rss/config/validator.rb +14 -3
- data/lib/html2rss/config.rb +54 -28
- data/lib/html2rss/contract_error.rb +9 -0
- data/lib/html2rss/defaults.rb +25 -38
- data/lib/html2rss/doctor/botasaurus.rb +18 -22
- data/lib/html2rss/error.rb +17 -3
- data/lib/html2rss/error_kind.rb +39 -0
- data/lib/html2rss/feed_builder/item_presentation.rb +22 -9
- data/lib/html2rss/feed_builder/json_feed/item.rb +2 -1
- data/lib/html2rss/feed_builder/json_feed.rb +2 -2
- data/lib/html2rss/feed_builder/rss/stylesheet.rb +3 -3
- data/lib/html2rss/feed_builder/rss.rb +50 -7
- data/lib/html2rss/feed_builder.rb +2 -1
- data/lib/html2rss/feed_pipeline/README.md +1 -1
- data/lib/html2rss/feed_pipeline/auto_fallback.rb +16 -34
- data/lib/html2rss/feed_pipeline/strategy_plan.rb +3 -3
- data/lib/html2rss/feed_pipeline.rb +40 -27
- data/lib/html2rss/feed_resolution/README.md +1 -1
- data/lib/html2rss/feed_resolution/candidate_generator.rb +6 -8
- data/lib/html2rss/feed_resolution/options.rb +3 -7
- data/lib/html2rss/feed_resolution/policy.rb +29 -33
- data/lib/html2rss/feed_resolution/probe.rb +6 -4
- data/lib/html2rss/feed_resolution/scorer.rb +29 -29
- data/lib/html2rss/feed_resolution.rb +16 -21
- data/lib/html2rss/feed_result.rb +11 -6
- data/lib/html2rss/hash_util.rb +91 -90
- data/lib/html2rss/html/article_extractor/category_extractor.rb +7 -10
- data/lib/html2rss/html/article_extractor/date_extractor.rb +1 -1
- data/lib/html2rss/html/article_extractor/enclosure_extractor.rb +12 -33
- data/lib/html2rss/html/article_extractor/heading_extractor.rb +2 -3
- data/lib/html2rss/html/article_extractor/id_generator.rb +7 -10
- data/lib/html2rss/html/article_extractor/image_extractor.rb +9 -1
- data/lib/html2rss/html/article_extractor.rb +19 -47
- data/lib/html2rss/html/article_rules/category.rb +3 -3
- data/lib/html2rss/html/article_rules/description.rb +6 -15
- data/lib/html2rss/html/article_rules/enclosure.rb +70 -42
- data/lib/html2rss/html/article_rules/image.rb +30 -16
- data/lib/html2rss/html/feed_link.rb +7 -9
- data/lib/html2rss/html/navigator/text_extractor.rb +26 -16
- data/lib/html2rss/html/navigator.rb +39 -9
- data/lib/html2rss/html/probe.rb +55 -51
- data/lib/html2rss/html/rendering/description_builder.rb +46 -32
- data/lib/html2rss/html/rendering.rb +2 -9
- data/lib/html2rss/html/sst_article_extractor.rb +55 -145
- data/lib/html2rss/html/title_pieces/kicker.rb +147 -0
- data/lib/html2rss/html/title_pieces/nodes.rb +107 -0
- data/lib/html2rss/html/title_pieces/nokogiri_nodes.rb +70 -0
- data/lib/html2rss/html/title_pieces/sst_nodes.rb +71 -0
- data/lib/html2rss/html/title_pieces.rb +169 -0
- data/lib/html2rss/html/visibility.rb +56 -0
- data/lib/html2rss/html.rb +2 -0
- data/lib/html2rss/link_destination/destination_facts.rb +17 -1
- data/lib/html2rss/link_destination/noise_policy.rb +11 -10
- data/lib/html2rss/link_destination/path_classifier.rb +111 -42
- data/lib/html2rss/link_destination/sponsorship/label_text.rb +82 -0
- data/lib/html2rss/link_destination/sponsorship.rb +184 -0
- data/lib/html2rss/link_destination/text_classifier.rb +104 -8
- data/lib/html2rss/mcp/README.md +13 -1
- data/lib/html2rss/mcp/config_argument.rb +14 -4
- data/lib/html2rss/mcp/contract.rb +20 -9
- data/lib/html2rss/mcp/outcome.rb +18 -17
- data/lib/html2rss/mcp/runtime.rb +53 -23
- data/lib/html2rss/mcp/server/tools.rb +9 -9
- data/lib/html2rss/mcp/server.rb +13 -4
- data/lib/html2rss/option.rb +48 -0
- data/lib/html2rss/page_recon/README.md +1 -1
- data/lib/html2rss/page_recon/diagnostics.rb +9 -9
- data/lib/html2rss/page_recon.rb +0 -4
- data/lib/html2rss/recon.rb +19 -23
- data/lib/html2rss/request_service/blocked_surface.rb +5 -8
- data/lib/html2rss/request_service/botasaurus_contract.rb +25 -3
- data/lib/html2rss/request_service/botasaurus_strategy.rb +44 -12
- data/lib/html2rss/request_service/budget.rb +3 -24
- data/lib/html2rss/request_service/compressed_body.rb +36 -48
- data/lib/html2rss/request_service/context.rb +16 -17
- data/lib/html2rss/request_service/cookie_jar.rb +89 -0
- data/lib/html2rss/request_service/cookie_store.rb +79 -0
- data/lib/html2rss/request_service/feed_advertisement.rb +72 -0
- data/lib/html2rss/request_service/httpx_strategy.rb +7 -3
- data/lib/html2rss/request_service/local_file_strategy.rb +2 -3
- data/lib/html2rss/request_service/network_guard.rb +3 -2
- data/lib/html2rss/request_service/policy.rb +55 -36
- data/lib/html2rss/request_service/replay_breaker.rb +56 -0
- data/lib/html2rss/request_service/response.rb +33 -19
- data/lib/html2rss/request_service/response_cache.rb +109 -0
- data/lib/html2rss/request_service/response_caching.rb +95 -0
- data/lib/html2rss/request_service/strategy.rb +5 -11
- data/lib/html2rss/request_service.rb +71 -12
- data/lib/html2rss/request_session/pager/base.rb +3 -10
- data/lib/html2rss/request_session/pager/custom_selector.rb +4 -4
- data/lib/html2rss/request_session/pager/offset.rb +1 -7
- data/lib/html2rss/request_session/pager/rel_next.rb +1 -1
- data/lib/html2rss/request_session/pager/url_template.rb +1 -7
- data/lib/html2rss/request_session/pager.rb +1 -1
- data/lib/html2rss/request_session.rb +6 -2
- data/lib/html2rss/scoring/cluster_scorer.rb +1 -1
- data/lib/html2rss/scoring/container_assessor.rb +12 -13
- data/lib/html2rss/scoring/engine.rb +10 -38
- data/lib/html2rss/scoring/link_resolver.rb +2 -7
- data/lib/html2rss/scoring/observation.rb +2 -2
- data/lib/html2rss/selector_candidates.rb +19 -8
- data/lib/html2rss/selectors/README.md +1 -1
- data/lib/html2rss/selectors/extractors/href.rb +2 -4
- data/lib/html2rss/selectors/json_xml.rb +33 -34
- data/lib/html2rss/selectors/option_spec.rb +4 -1
- data/lib/html2rss/selectors/post_processors/base.rb +0 -8
- data/lib/html2rss/selectors/post_processors/gsub.rb +9 -10
- data/lib/html2rss/selectors/post_processors/html_transformers/keep_playable_media.rb +50 -0
- data/lib/html2rss/selectors/post_processors/html_transformers/transform_urls_to_absolute_ones.rb +1 -3
- data/lib/html2rss/selectors/post_processors/parse_time.rb +5 -6
- data/lib/html2rss/selectors/post_processors/parse_uri.rb +1 -1
- data/lib/html2rss/selectors/post_processors/sanitize_html.rb +18 -5
- data/lib/html2rss/selectors/post_processors/substring.rb +1 -1
- data/lib/html2rss/selectors/schema_export.rb +60 -60
- data/lib/html2rss/selectors.rb +67 -30
- data/lib/html2rss/sst/attrs.rb +2 -2
- data/lib/html2rss/sst/document.rb +3 -3
- data/lib/html2rss/sst/index.rb +30 -11
- data/lib/html2rss/sst/node.rb +42 -29
- data/lib/html2rss/sst/normalizer.rb +47 -34
- data/lib/html2rss/sst/tags.rb +113 -2
- data/lib/html2rss/sst/text.rb +94 -58
- data/lib/html2rss/sst.rb +2 -0
- data/lib/html2rss/status.rb +27 -31
- data/lib/html2rss/surface_category.rb +1 -1
- data/lib/html2rss/syndication/README.md +12 -6
- data/lib/html2rss/syndication/alternate_links.rb +39 -0
- data/lib/html2rss/syndication/channel_facts.rb +87 -0
- data/lib/html2rss/syndication/discovery.rb +171 -187
- data/lib/html2rss/syndication/feed.rb +20 -0
- data/lib/html2rss/syndication/item_media.rb +103 -0
- data/lib/html2rss/syndication/parser.rb +117 -116
- data/lib/html2rss/test/enhance_audit.rb +15 -17
- data/lib/html2rss/test/item_sample.rb +43 -0
- data/lib/html2rss/test/policy.rb +9 -11
- data/lib/html2rss/test.rb +66 -57
- data/lib/html2rss/url.rb +163 -62
- data/lib/html2rss/version.rb +1 -1
- data/lib/html2rss/word_count.rb +53 -0
- data/lib/html2rss.rb +19 -9
- data/schema/html2rss-config.schema.json +56 -41
- metadata +33 -4
- data/lib/html2rss/html/rendering/media_renderer.rb +0 -39
- data/lib/html2rss/html/rendering/pdf_renderer.rb +0 -30
|
@@ -2,8 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
module Html2rss
|
|
4
4
|
module LinkDestination
|
|
5
|
-
# Classifies visible anchor text for utility and
|
|
6
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
data/lib/html2rss/mcp/README.md
CHANGED
|
@@ -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 [
|
|
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
|
|
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 =
|
|
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 <
|
|
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,
|
data/lib/html2rss/mcp/outcome.rb
CHANGED
|
@@ -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
|
-
#
|
|
12
|
-
|
|
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
|
|
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
|
|
35
|
-
raise
|
|
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
|
|
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:
|
|
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
|
|
227
|
-
suggested_channel_url:
|
|
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
|
data/lib/html2rss/mcp/runtime.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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.
|
|
39
|
+
Outcome.inspect_report(
|
|
40
40
|
report: PageRecon::Diagnostics.call(
|
|
41
|
-
url:, strategy:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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] =
|
|
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:
|
|
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 =
|
|
192
|
+
plan = Option.strategy(strategy)
|
|
193
193
|
result = Html2rss::Capture.build(
|
|
194
194
|
url,
|
|
195
195
|
strategy: plan,
|
data/lib/html2rss/mcp/server.rb
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|