html2rss 0.27.2 → 0.29.1

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 (94) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +82 -25
  3. data/html2rss.gemspec +0 -1
  4. data/lib/html2rss/article.rb +1 -2
  5. data/lib/html2rss/auto_source/README.md +12 -9
  6. data/lib/html2rss/auto_source/cleanup.rb +92 -1
  7. data/lib/html2rss/auto_source/scraper/json_state/document_scanner.rb +3 -5
  8. data/lib/html2rss/auto_source/scraper/meta_oembed.rb +15 -5
  9. data/lib/html2rss/auto_source/scraper/microdata.rb +8 -6
  10. data/lib/html2rss/auto_source/scraper/native_feed.rb +93 -0
  11. data/lib/html2rss/auto_source/scraper/schema.rb +39 -21
  12. data/lib/html2rss/auto_source/scraper.rb +6 -4
  13. data/lib/html2rss/auto_source/segmenter/cluster.rb +2 -3
  14. data/lib/html2rss/auto_source/segmenter/primary_link.rb +1 -2
  15. data/lib/html2rss/auto_source/segmenter.rb +1 -2
  16. data/lib/html2rss/auto_source.rb +32 -11
  17. data/lib/html2rss/batch.rb +169 -0
  18. data/lib/html2rss/capture/README.md +15 -6
  19. data/lib/html2rss/capture.rb +158 -15
  20. data/lib/html2rss/channel.rb +1 -2
  21. data/lib/html2rss/cli/probe_view.rb +66 -0
  22. data/lib/html2rss/cli/render.rb +124 -0
  23. data/lib/html2rss/cli/validate.rb +91 -0
  24. data/lib/html2rss/cli.rb +318 -193
  25. data/lib/html2rss/config/auto_source_contract.rb +7 -0
  26. data/lib/html2rss/config/request_headers.rb +4 -14
  27. data/lib/html2rss/config/schema.rb +7 -5
  28. data/lib/html2rss/config/validator.rb +1 -0
  29. data/lib/html2rss/config.rb +64 -18
  30. data/lib/html2rss/doctor/botasaurus.rb +130 -0
  31. data/lib/html2rss/feed_builder/rss.rb +1 -2
  32. data/lib/html2rss/feed_pipeline/README.md +7 -5
  33. data/lib/html2rss/feed_pipeline/auto_fallback.rb +78 -19
  34. data/lib/html2rss/feed_pipeline/runtime_policy.rb +1 -0
  35. data/lib/html2rss/feed_pipeline.rb +48 -21
  36. data/lib/html2rss/feed_resolution/README.md +52 -0
  37. data/lib/html2rss/feed_resolution/candidate_generator.rb +139 -0
  38. data/lib/html2rss/feed_resolution/diag.rb +32 -0
  39. data/lib/html2rss/feed_resolution/options.rb +31 -0
  40. data/lib/html2rss/feed_resolution/policy.rb +47 -0
  41. data/lib/html2rss/feed_resolution/probe.rb +59 -0
  42. data/lib/html2rss/feed_resolution/scorer.rb +49 -0
  43. data/lib/html2rss/feed_resolution.rb +268 -0
  44. data/lib/html2rss/html/article_extractor/heading_extractor.rb +24 -14
  45. data/lib/html2rss/html/article_extractor.rb +4 -9
  46. data/lib/html2rss/html/feed_link.rb +4 -2
  47. data/lib/html2rss/html/navigator.rb +4 -39
  48. data/lib/html2rss/html/probe.rb +82 -0
  49. data/lib/html2rss/html/sst_article_extractor.rb +1 -2
  50. data/lib/html2rss/link_destination/noise_policy.rb +1 -2
  51. data/lib/html2rss/link_destination/path_classifier.rb +20 -19
  52. data/lib/html2rss/mcp/README.md +80 -0
  53. data/lib/html2rss/mcp/contract.rb +135 -13
  54. data/lib/html2rss/mcp/outcome/playbook.rb +122 -0
  55. data/lib/html2rss/mcp/outcome.rb +112 -40
  56. data/lib/html2rss/mcp/runtime.rb +45 -0
  57. data/lib/html2rss/mcp/server/tools.rb +277 -0
  58. data/lib/html2rss/mcp/server.rb +91 -232
  59. data/lib/html2rss/mcp.rb +3 -0
  60. data/lib/html2rss/page_recon/README.md +69 -0
  61. data/lib/html2rss/page_recon/diagnostics.rb +211 -0
  62. data/lib/html2rss/page_recon.rb +297 -0
  63. data/lib/html2rss/recon.rb +252 -0
  64. data/lib/html2rss/request_service/blocked_surface.rb +1 -0
  65. data/lib/html2rss/request_service/botasaurus_contract.rb +8 -1
  66. data/lib/html2rss/request_service/botasaurus_strategy.rb +1 -1
  67. data/lib/html2rss/request_service/faraday_strategy.rb +11 -7
  68. data/lib/html2rss/request_service/policy.rb +1 -2
  69. data/lib/html2rss/request_service/response.rb +27 -4
  70. data/lib/html2rss/request_service/strategy.rb +1 -2
  71. data/lib/html2rss/request_service.rb +15 -1
  72. data/lib/html2rss/request_session.rb +4 -2
  73. data/lib/html2rss/scrape_target.rb +24 -0
  74. data/lib/html2rss/selectors/post_processors/sanitize_html.rb +2 -4
  75. data/lib/html2rss/selectors/post_processors.rb +0 -1
  76. data/lib/html2rss/selectors.rb +33 -10
  77. data/lib/html2rss/sst/attrs.rb +1 -2
  78. data/lib/html2rss/sst/normalizer.rb +49 -11
  79. data/lib/html2rss/status.rb +48 -24
  80. data/lib/html2rss/surface_category.rb +63 -0
  81. data/lib/html2rss/syndication/README.md +21 -0
  82. data/lib/html2rss/syndication/candidate_catalog.rb +26 -0
  83. data/lib/html2rss/syndication/discovery.rb +215 -0
  84. data/lib/html2rss/syndication/parser.rb +137 -0
  85. data/lib/html2rss/syndication.rb +10 -0
  86. data/lib/html2rss/test/enhance_audit.rb +192 -0
  87. data/lib/html2rss/test.rb +458 -0
  88. data/lib/html2rss/url.rb +23 -2
  89. data/lib/html2rss/version.rb +1 -1
  90. data/lib/html2rss.rb +157 -35
  91. data/schema/html2rss-config.schema.json +47 -29
  92. metadata +34 -18
  93. data/lib/html2rss/mcp/inspect.rb +0 -241
  94. data/lib/html2rss/selectors/post_processors/html_to_markdown.rb +0 -59
@@ -71,34 +71,35 @@ module Html2rss
71
71
  # @param segments [Array<String>] normalized URL path segments
72
72
  def initialize(segments)
73
73
  @segments = segments
74
+ @lexicon = segments.map { ::Html2rss::Html::Probe.fold(_1) }.freeze
74
75
  end
75
76
 
76
77
  # @return [Boolean] true when the route has article-like path evidence
77
78
  def content_path?
78
79
  @content_path ||= !leading_high_confidence_junk? &&
79
- (SEGMENT_SETS[:content].intersect?(segments) || yearish_content_context?)
80
+ (SEGMENT_SETS[:content].intersect?(@lexicon) || yearish_content_context?)
80
81
  end
81
82
 
82
83
  # @return [Boolean] true when the route includes utility/navigation evidence
83
84
  def utility_path?
84
- @utility_path ||= SEGMENT_SETS[:utility].intersect?(segments)
85
+ @utility_path ||= SEGMENT_SETS[:utility].intersect?(@lexicon)
85
86
  end
86
87
 
87
88
  # @return [Boolean] true when the route points at conversion or account chrome
88
89
  def vanity_path?
89
- @vanity_path ||= SEGMENT_SETS[:vanity].intersect?(segments)
90
+ @vanity_path ||= SEGMENT_SETS[:vanity].intersect?(@lexicon)
90
91
  end
91
92
 
92
93
  # @return [Boolean] true when the route points at taxonomy/listing chrome
93
94
  def taxonomy_path?
94
- @taxonomy_path ||= SEGMENT_SETS[:taxonomy].intersect?(segments)
95
+ @taxonomy_path ||= SEGMENT_SETS[:taxonomy].intersect?(@lexicon)
95
96
  end
96
97
 
97
98
  # @return [Boolean] true when the route is too shallow to strongly indicate an article
98
99
  def shallow?
99
100
  segment_count = segments.size
100
101
 
101
- segment_count <= 1 || (segment_count == 2 && high_confidence_junk_segment?(segments.last))
102
+ segment_count <= 1 || (segment_count == 2 && high_confidence_junk_at?(segment_count - 1))
102
103
  end
103
104
 
104
105
  # @return [Boolean] true when the final path segment looks like a post slug
@@ -110,15 +111,15 @@ module Html2rss
110
111
 
111
112
  # @return [Boolean] true when every path segment is utility chrome
112
113
  def utility_only_route?
113
- segments.all? { |segment| high_confidence_junk_segment?(segment) }
114
+ (0...segments.size).all? { |i| high_confidence_junk_at?(i) }
114
115
  end
115
116
 
116
117
  # @return [Boolean] true when the route is shallow and contains high-confidence noise
117
118
  def shallow_high_confidence_route?
118
119
  vanity_segments = SEGMENT_SETS.fetch(:vanity)
119
120
 
120
- shallow? && segments.any? do |segment|
121
- high_confidence_junk_segment?(segment) || vanity_segments.include?(segment)
121
+ shallow? && (0...segments.size).any? do |i|
122
+ high_confidence_junk_at?(i) || vanity_segments.include?(@lexicon[i])
122
123
  end
123
124
  end
124
125
 
@@ -148,11 +149,11 @@ module Html2rss
148
149
  private
149
150
 
150
151
  def leading_high_confidence_junk?
151
- segments.any? && high_confidence_junk_segment?(segments.first)
152
+ segments.any? && high_confidence_junk_at?(0)
152
153
  end
153
154
 
154
155
  def any_high_confidence_junk_segment?
155
- segments.any? { |segment| high_confidence_junk_segment?(segment) }
156
+ (0...segments.size).any? { |i| high_confidence_junk_at?(i) }
156
157
  end
157
158
 
158
159
  def yearish_content_context?
@@ -178,11 +179,12 @@ module Html2rss
178
179
  def all_junk?(limit)
179
180
  return false if limit <= 0
180
181
 
181
- (0...limit).all? { |i| high_confidence_junk_segment?(segments[i]) }
182
+ (0...limit).all? { |i| high_confidence_junk_at?(i) }
182
183
  end
183
184
 
184
- def high_confidence_junk_segment?(segment)
185
- SEGMENT_SETS.fetch(:high_confidence_junk).include?(segment) || host_shaped_segment?(segment)
185
+ def high_confidence_junk_at?(index)
186
+ SEGMENT_SETS.fetch(:high_confidence_junk).include?(@lexicon[index]) ||
187
+ host_shaped_segment?(segments[index])
186
188
  end
187
189
 
188
190
  def host_shaped_segment?(segment)
@@ -197,10 +199,9 @@ module Html2rss
197
199
  context_segments = SEGMENT_SETS.fetch(:deep_post_context)
198
200
 
199
201
  (0...limit).any? do |i|
200
- segment = segments[i]
201
- content_segments.include?(segment) ||
202
- segment.match?(PathClassifier::YEARISH_SEGMENT) ||
203
- context_segments.include?(segment)
202
+ content_segments.include?(@lexicon[i]) ||
203
+ segments[i].match?(PathClassifier::YEARISH_SEGMENT) ||
204
+ context_segments.include?(@lexicon[i])
204
205
  end
205
206
  end
206
207
 
@@ -209,8 +210,8 @@ module Html2rss
209
210
  end
210
211
 
211
212
  def excluded_last_segment?
212
- last = segments.last
213
- high_confidence_junk_segment?(last) || SEGMENT_SETS[:vanity].include?(last)
213
+ last_i = segments.size - 1
214
+ high_confidence_junk_at?(last_i) || SEGMENT_SETS[:vanity].include?(@lexicon[last_i])
214
215
  end
215
216
 
216
217
  def slug_last_segment?
@@ -0,0 +1,80 @@
1
+ # MCP — curation wire surface
2
+
3
+ MCP exposes the same seven user-facing verbs as the CLI and gem facades. Full contract and ownership: `CONTEXT.md` § Frozen contract. Contributor law: `AGENTS.md` § Curation CLI / MCP.
4
+
5
+ ## Verb table
6
+
7
+ | Verb | MCP tool | Job |
8
+ | -------- | ---------- | ---------------------------------------------------- |
9
+ | inspect | `inspect` | Diagnostics (final URL, status, alternates, surface) |
10
+ | recon | `recon` | Verdict + native_feed preference |
11
+ | capture | `capture` | YAML draft config |
12
+ | validate | `validate` | Schema only |
13
+ | test | `test` | Schema + live extraction |
14
+ | apply | `apply` | Ship RSS from config |
15
+ | scrape | `scrape` | Articles now (one-shot) |
16
+
17
+ Batch: `batch_inspect`, `batch_recon`, `batch_scrape`.
18
+
19
+ **Golden path:** optional inspect → recon → capture → test → apply. Side door: validate. One-shot: scrape.
20
+
21
+ ## Decision tree
22
+
23
+ 1. **Articles now (no saved config)?** → `scrape` (or `batch_scrape` for multiple URLs). `strategy: "auto"` runs Faraday → Botasaurus; do not retry with explicit `faraday` after `auto`.
24
+ 2. **Reusable feed YAML?** → `capture` → `test` → `apply`. `capture` returns YAML in `payload.yaml`. Strive `enhance: true` (false only when chrome leaks). `test` runs schema + live extraction; optional `compare_enhance` compares enhance off vs on. `apply` is the ship gate (`isError` on zero items). Both `test` and `apply` may include `quality_report.enhance_gains` when `selectors.items.enhance` is true.
25
+ 3. **Weak scrape/capture or recon?** → `inspect` (or `batch_inspect`). When alternates warrant it, follow `next_step` to `recon`.
26
+ 4. **Config already in hand?** → `validate` (schema only) → `test` → `apply`.
27
+
28
+ Prefer `capture` for durable configs; `scrape` / `batch_scrape` for one-shot extraction. Follow envelope `next_step` and `guidance`; do not parse scrape text as a raw item array.
29
+
30
+ ## Envelope
31
+
32
+ Every tool result is one JSON object (text body and `structuredContent`):
33
+
34
+ | Field | Role |
35
+ | ----------- | ------------------------------------------------------------ |
36
+ | `ok` | Success vs schema/ship failure |
37
+ | `next_step` | Bare verb name or `done` / `read_runtime` |
38
+ | `guidance` | Agent instruction (from `Outcome::Playbook`) |
39
+ | `payload` | Tool-specific data (`yaml`, `rss`, `items`, recon fields, …) |
40
+
41
+ `Contract.response` builds the wire body; `Outcome` owns `next_step` policy.
42
+
43
+ ## Ownership
44
+
45
+ | Concern | Owner |
46
+ | --------------------------------------------- | ------------------------------------------------ |
47
+ | Tool schemas, titles, strategy enum | `MCP::Contract` |
48
+ | Catalog fingerprint + `mcp_contract_version` | `MCP::Contract` (+ `Runtime.snapshot` wire) |
49
+ | Envelope factories, `next_step` routing | `MCP::Outcome` |
50
+ | Runtime instructions, guidance, prompt bodies | `Outcome::Playbook` (SSOT — `Server` delegates) |
51
+ | Diagnostic fetch + assess | `PageRecon::Diagnostics` |
52
+ | Curation verdict | `Recon` |
53
+ | Capture YAML product | `Capture::CaptureResult#yaml` |
54
+ | Batch concurrency | `Batch.map` (Thread pool; preserves input order) |
55
+
56
+ Do not duplicate playbook prose in `server.rb`.
57
+
58
+ ## Resources
59
+
60
+ | URI | Description |
61
+ | ----------------------- | ---------------------------------------------------------- |
62
+ | `html2rss://schema` | Full JSON Schema for feed configurations |
63
+ | `html2rss://extractors` | Registered extractor names |
64
+ | `html2rss://strategies` | Published MCP strategies (`auto`, `faraday`, `botasaurus`) |
65
+ | `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. |
66
+
67
+ ## Prompts
68
+
69
+ | Name | Description |
70
+ | --------------------- | ------------------------------------------------------------ |
71
+ | `scrape-webpage` | One `scrape` call; `inspect` only if weak or recon needed |
72
+ | `capture-feed-config` | Capture YAML → test → apply; catalog rewrite; strive enhance |
73
+
74
+ ## Strategy note
75
+
76
+ `scrape` / `capture` with `strategy: "auto"` run the full AutoFallback chain. `inspect` maps `auto` to Faraday for cheap diagnostics; pin `botasaurus` when you need browser rendering for inspect.
77
+
78
+ ## Inspect redirects
79
+
80
+ `payload.final_url` is the post-redirect landing URL. When it differs from the URL you passed and `status` is 4xx, inspect still followed redirects — retry on `final_url` or pass the site's canonical hostname (often `www`). Cross-host redirects set `Host` per hop; html2rss does not pin the entry hostname. See [`page_recon/README.md`](../page_recon/README.md).
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'digest'
4
+
3
5
  module Html2rss
4
6
  module MCP
5
7
  ##
@@ -9,6 +11,10 @@ module Html2rss
9
11
  # Published MCP request strategies (excludes +local_file+).
10
12
  STRATEGIES = %w[auto faraday botasaurus].freeze
11
13
 
14
+ # Bump when tool names, required inputs, or envelope semantics change (independent of gem +VERSION+).
15
+ MCP_CONTRACT_VERSION = 2
16
+ public_constant :MCP_CONTRACT_VERSION
17
+
12
18
  # Raised when apply/validate config uses an unpublished MCP request adapter.
13
19
  class UnpublishedRequestError < ArgumentError; end
14
20
 
@@ -51,14 +57,14 @@ module Html2rss
51
57
  }.freeze
52
58
  }.freeze
53
59
 
54
- # Input schema for +validate_config+ (config XOR yaml).
60
+ # Input schema for +validate+ (config XOR yaml).
55
61
  CONFIG_XOR_SCHEMA = {
56
62
  type: 'object',
57
63
  properties: CONFIG_XOR_PROPERTIES,
58
64
  oneOf: XOR_ONE_OF
59
65
  }.freeze
60
66
 
61
- # Input schema for +apply_config+ (required URL plus config XOR yaml).
67
+ # Input schema for +apply+ (required URL plus config XOR yaml).
62
68
  APPLY_INPUT_SCHEMA = {
63
69
  type: 'object',
64
70
  properties: { url: URL_PROPERTY, **CONFIG_XOR_PROPERTIES }.freeze,
@@ -66,7 +72,28 @@ module Html2rss
66
72
  oneOf: XOR_ONE_OF
67
73
  }.freeze
68
74
 
69
- # Input schema for +scrape_url+.
75
+ # Input schema for +test+.
76
+ TEST_INPUT_SCHEMA = {
77
+ type: 'object',
78
+ properties: {
79
+ **CONFIG_XOR_PROPERTIES,
80
+ min_items: { type: 'integer', description: 'Minimum required items (default: 1)', default: 1 },
81
+ strict_quality: {
82
+ type: 'boolean',
83
+ description: 'Fail when ship-quality audit thresholds are exceeded (default: false)',
84
+ default: false
85
+ },
86
+ compare_enhance: {
87
+ type: 'boolean',
88
+ description: 'Diagnostic: compare extraction with enhance on vs off on cached HTML (default: false)',
89
+ default: false
90
+ },
91
+ strategy: STRATEGY_PROPERTY
92
+ }.freeze,
93
+ oneOf: XOR_ONE_OF
94
+ }.freeze
95
+
96
+ # Input schema for +scrape+.
70
97
  SCRAPE_INPUT_SCHEMA = {
71
98
  type: 'object',
72
99
  properties: {
@@ -78,24 +105,87 @@ module Html2rss
78
105
  required: %w[url]
79
106
  }.freeze
80
107
 
81
- # Input schema for +inspect_url+.
108
+ # Input schema for +inspect+.
82
109
  INSPECT_INPUT_SCHEMA = {
83
110
  type: 'object',
84
111
  properties: { url: URL_PROPERTY, strategy: INSPECT_STRATEGY_PROPERTY }.freeze,
85
112
  required: %w[url]
86
113
  }.freeze
87
114
 
88
- # Input schema for +capture_config+.
115
+ # Input schema for +recon+.
116
+ RECON_INPUT_SCHEMA = INSPECT_INPUT_SCHEMA
117
+
118
+ # Input schema for +capture+.
89
119
  CAPTURE_INPUT_SCHEMA = {
90
120
  type: 'object',
91
121
  properties: {
92
122
  url: URL_PROPERTY,
93
123
  strategy: STRATEGY_PROPERTY,
94
- items_selector: { type: 'string', description: 'Optional CSS selector hint for items' }
124
+ items_selector: { type: 'string', description: 'Optional CSS selector hint for items' },
125
+ force: { type: 'boolean', description: 'Bypass native feed check', default: false },
126
+ topics: {
127
+ type: 'array',
128
+ items: { type: 'string' },
129
+ description: 'Directory topics override'
130
+ }.freeze,
131
+ title: { type: 'string', description: 'Channel title override' },
132
+ summary: { type: 'string', description: 'Directory summary override' },
133
+ enhance: { type: 'boolean', description: 'Force enhance on or off' },
134
+ limit: { type: 'integer', description: 'Max articles to keep' },
135
+ max_redirects: { type: 'integer', description: 'Optional redirect limit override' },
136
+ max_requests: { type: 'integer', description: 'Optional request budget override' }
95
137
  }.freeze,
96
138
  required: %w[url]
97
139
  }.freeze
98
140
 
141
+ ##
142
+ # Shared JSON Schema for batch URL tools (+batch_scrape+, +batch_inspect+, +batch_recon+).
143
+ #
144
+ # @param urls_description [String] description for the +urls+ array property
145
+ # @param strategy_property [Hash] strategy JSON Schema property
146
+ # @param extra_properties [Hash] additional tool-specific properties (e.g. +limit+ on scrape)
147
+ # @return [Hash]
148
+ def self.batch_urls_input_schema(urls_description:, strategy_property:, extra_properties: {}) # rubocop:disable Metrics/MethodLength
149
+ {
150
+ type: 'object',
151
+ properties: {
152
+ urls: {
153
+ type: 'array',
154
+ items: URL_PROPERTY,
155
+ minItems: 1,
156
+ maxItems: 25,
157
+ description: urls_description
158
+ }.freeze,
159
+ strategy: strategy_property,
160
+ concurrency: {
161
+ type: 'integer',
162
+ description: 'Max parallel worker threads (1..10, default: 5)',
163
+ default: 5
164
+ },
165
+ **extra_properties
166
+ }.freeze,
167
+ required: %w[urls]
168
+ }
169
+ end
170
+
171
+ # Input schema for +batch_scrape+.
172
+ BATCH_SCRAPE_INPUT_SCHEMA = batch_urls_input_schema(
173
+ urls_description: 'List of page URLs to scrape (1..25)',
174
+ strategy_property: STRATEGY_PROPERTY,
175
+ extra_properties: {
176
+ limit: { type: 'integer', description: 'Max articles per URL to keep (default 10)', default: 10 }
177
+ }
178
+ ).freeze
179
+
180
+ # Input schema for +batch_inspect+.
181
+ BATCH_INSPECT_INPUT_SCHEMA = batch_urls_input_schema(
182
+ urls_description: 'List of page URLs to inspect (1..25)',
183
+ strategy_property: INSPECT_STRATEGY_PROPERTY
184
+ ).freeze
185
+
186
+ # Input schema for +batch_recon+.
187
+ BATCH_RECON_INPUT_SCHEMA = BATCH_INSPECT_INPUT_SCHEMA
188
+
99
189
  # Tool annotations for open-world read-only tools.
100
190
  ANNOTATIONS_OPEN_WORLD = {
101
191
  read_only_hint: true,
@@ -104,19 +194,51 @@ module Html2rss
104
194
  open_world_hint: true
105
195
  }.freeze
106
196
 
107
- # Tool annotations for +validate_config+ (closed world).
197
+ # Tool annotations for +validate+ (closed world).
108
198
  ANNOTATIONS_VALIDATE = ANNOTATIONS_OPEN_WORLD.merge(open_world_hint: false).freeze
109
199
 
110
200
  # Human titles for +tools/list+.
111
201
  TITLES = {
112
- scrape_url: 'Scrape URL',
113
- inspect_url: 'Inspect URL',
114
- capture_config: 'Capture feed config',
115
- validate_config: 'Validate feed config',
116
- apply_config: 'Apply feed config'
202
+ scrape: 'Scrape',
203
+ inspect: 'Inspect',
204
+ recon: 'Recon',
205
+ batch_scrape: 'Batch scrape',
206
+ batch_inspect: 'Batch inspect',
207
+ batch_recon: 'Batch recon',
208
+ capture: 'Capture',
209
+ validate: 'Validate',
210
+ apply: 'Apply',
211
+ test: 'Test'
117
212
  }.freeze
118
213
 
214
+ # @api private
215
+ CATALOG_ENTRY_LINE = lambda do |entry|
216
+ schema = entry.fetch(:input_schema)
217
+ required = Array(schema[:required]).sort.join(',')
218
+ one_of = Array(schema[:oneOf]).map { |branch| Array(branch[:required]).sort.join('+') }.sort.join('|')
219
+ [entry.fetch(:name), required, one_of].reject(&:empty?).join(':')
220
+ end.freeze
221
+
119
222
  class << self
223
+ ##
224
+ # Canonical MCP tool names in alphabetical order (same set as +tools/list+).
225
+ #
226
+ # @return [Array<String>]
227
+ def catalog_tools
228
+ Server::Tools::TOOLS.map { |entry| entry.fetch(:name) }.sort
229
+ end
230
+
231
+ ##
232
+ # Stable fingerprint of published tools, required keys, and +oneOf+ branches.
233
+ # Clients compare against a cached +tools/list+ to detect stale catalogs.
234
+ # Bump {MCP_CONTRACT_VERSION} for envelope or breaking wire semantics only.
235
+ #
236
+ # @return [String] 16-char hex digest prefix
237
+ def catalog_fingerprint
238
+ lines = Server::Tools::TOOLS.sort_by { |entry| entry.fetch(:name) }.map(&CATALOG_ENTRY_LINE)
239
+ Digest::SHA256.hexdigest(lines.join("\n")).slice(0, 16)
240
+ end
241
+
120
242
  ##
121
243
  # Envelope JSON Schema. Built lazily so Zeitwerk can load Contract before Outcome.
122
244
  #
@@ -151,7 +273,7 @@ module Html2rss
151
273
 
152
274
  ##
153
275
  # Rejects unpublished MCP request adapters so apply/validate cannot
154
- # {File.read} arbitrary paths. CLI and Config still allow +local_file+.
276
+ # +File.read+ arbitrary paths. CLI and Config still allow +local_file+.
155
277
  #
156
278
  # @param config [Hash]
157
279
  # @return [void]
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ module MCP
5
+ class Outcome
6
+ ##
7
+ # Single source of truth for MCP agent instructions, next-step guidance,
8
+ # and prompt bodies. {Server} delegates here — do not duplicate prose in
9
+ # +server.rb+.
10
+ class Playbook
11
+ # Default guidance copy keyed by {Outcome::NextStep::NAMES}.
12
+ GUIDANCE = {
13
+ done: 'Done. Read payload for the result.',
14
+ inspect: 'Call inspect next. Read payload for diagnostics (final_url, status, ' \
15
+ 'scheme_downgrade, alternate_feeds, likely_js_shell, redirect_summary).',
16
+ recon: 'Call recon next. Read payload for verdict and native_feed preference.',
17
+ validate: 'Call validate with payload.yaml or a config hash (XOR, not both).',
18
+ apply: 'Call apply next. Confirm payload.item_count before shipping.',
19
+ scrape: 'Call scrape for articles now. strategy auto already runs Faraday then Botasaurus ' \
20
+ 'and promotes native RSS/Atom when present.',
21
+ capture: 'Call capture for a reusable YAML draft, then follow next_step.',
22
+ read_runtime: 'Read html2rss://runtime. Compare mcp_contract_version and catalog_fingerprint ' \
23
+ 'to your cached tools/list before retrying unknown tools. ' \
24
+ 'Set BOTASAURUS_SCRAPER_URL on the MCP process if botasaurus_configured is false.',
25
+ test: 'Call test next (schema + live extraction). Confirm payload.item_count, ' \
26
+ 'failure_kind, and payload.quality_report warnings before shipping.'
27
+ }.freeze
28
+
29
+ class << self
30
+ ##
31
+ # Published MCP server instructions (decision tree for agents).
32
+ #
33
+ # @return [String]
34
+ def instructions # rubocop:disable Metrics/MethodLength -- agent decision tree is the published contract
35
+ <<~TEXT.strip
36
+ html2rss MCP — decide which tool to call:
37
+
38
+ 1. Need articles now (no saved config)? → scrape (or batch_scrape for multiple)
39
+ - strategy "auto" runs Faraday → Botasaurus AutoFallback. Do not retry with explicit faraday after auto.
40
+ - Empty scrape is still success (articles-now). Follow next_step / guidance (read_runtime if Botasaurus unset).
41
+ 2. Need a reusable feed YAML? → capture → test → apply
42
+ - capture returns YAML inside payload.yaml. Draft only: if destination is html2rss-configs, rewrite for directory.topics and explicit channel title/url. Default enhance follows capture evidence (false when admission_drops show chrome); override only when needed.
43
+ - test runs schema + live extraction (min items). apply is the ship gate (isError on zero items). Confirm payload.item_count and payload.quality_report warnings (including enhance_gains when selectors.items.enhance is true). Use compare_enhance on test for enhance on/off diagnostics.
44
+ - validate alone is for schema-only checks; on success next_step is test.
45
+ 3. Weak scrape/capture or recon (final URL, status, https→http, rel=alternate feeds)? → inspect (or batch_inspect). Read likely_js_shell vs blocked_surface when articles_count is 0. When alternates warrant it, inspect next_step is recon.
46
+ 4. Have a config already? → validate (must succeed) → test → apply
47
+ 5. Schema / extractors / strategies / runtime → resources html2rss://schema|extractors|strategies|runtime
48
+ - runtime publishes version, mcp_contract_version, catalog_fingerprint, tools, botasaurus_configured.
49
+ - Refresh tools/list when catalog_fingerprint differs from your cache.
50
+
51
+ Prefer capture for durable config; scrape / batch_scrape for one-shot extraction.
52
+ Follow envelope next_step and guidance. Botasaurus needs BOTASAURUS_SCRAPER_URL in this process env (read html2rss://runtime; the URL is never returned).
53
+ TEXT
54
+ end
55
+
56
+ ##
57
+ # @param url [String]
58
+ # @return [String]
59
+ def scrape_webpage_prompt(url)
60
+ <<~MSG.strip
61
+ Scrape #{url} with scrape (strategy auto). One call is enough — auto already runs Faraday then Botasaurus.
62
+ Follow envelope next_step and guidance. Call inspect only if articles are empty/weak or you need diagnostics (final_url, status, scheme_downgrade, alternate_feeds). When inspect finds alternates, follow next_step to recon.
63
+ Do not retry scrape with explicit faraday after auto. Read html2rss://runtime if next_step is read_runtime.
64
+ Return payload.items (not a raw JSON array).
65
+ MSG
66
+ end
67
+
68
+ ##
69
+ # @param url [String]
70
+ # @return [String]
71
+ def capture_feed_config_prompt(url)
72
+ <<~MSG.strip
73
+ Build a reusable html2rss feed config for #{url}:
74
+ 1) capture — YAML is payload.yaml. Check payload.articles_count, payload.has_selectors, and payload.suggested_channel_url. enhance defaults from admission evidence (false when chrome drops are high). When payload.native_feed is set, follow next_step (done — use the native feed).
75
+ 2) Follow next_step. If weak or you need recon, inspect then recon when alternates warrant it. Auto already hops to Botasaurus; do not retry capture with botasaurus unless Faraday was blocked.
76
+ 3) test with yaml (or config hash) — schema + live extraction. On :schema failure, validate; on :execution/:min_items, recapture. Read payload.quality_report.enhance_gains when enhance is on; optional compare_enhance compares enhance off vs on without changing shipped RSS.
77
+ 4) apply — isError if zero items. Confirm payload.item_count and payload.quality_report (including enhance_gains) before shipping.
78
+ If the destination is html2rss-configs, rewrite the draft for directory.topics and explicit channel title/url. Return YAML.
79
+ MSG
80
+ end
81
+
82
+ ##
83
+ # @param report [PageRecon::Diagnostics::Report]
84
+ # @return [String]
85
+ def inspect_guidance(report)
86
+ return GUIDANCE.fetch(:inspect) unless report.articles_count.zero?
87
+
88
+ empty_extract_guidance(report.data)
89
+ end
90
+
91
+ ##
92
+ # @param data [Hash{Symbol => Object}]
93
+ # @return [String]
94
+ def empty_extract_guidance(data) # rubocop:disable Metrics/MethodLength
95
+ if data[:blocked_surface] || data[:surface_category].to_s == 'blocked_surface'
96
+ return 'Blocked or anti-bot interstitial likely. Retry scrape with strategy botasaurus once ' \
97
+ '(or CLI inspect --deep when BOTASAURUS_SCRAPER_URL is set). ' \
98
+ 'Do not retry explicit faraday after auto.'
99
+ end
100
+ if data[:likely_js_shell]
101
+ return 'JS-rendered shell likely (html_present, zero articles). Use strategy auto or botasaurus; ' \
102
+ 'CLI inspect --deep for one Botasaurus diagnostic hop.'
103
+ end
104
+
105
+ 'Empty extract on a static-looking page. Verify redirect_summary.final_url and surface; ' \
106
+ 'capture may need selector hints.'
107
+ end
108
+
109
+ ##
110
+ # @param result [Html2rss::Recon::Result]
111
+ # @param next_step [Outcome::NextStep]
112
+ # @return [String]
113
+ def recon_guidance(result, next_step)
114
+ return next_step.guidance unless result.scheme_downgrade
115
+
116
+ "#{next_step.guidance} HTTPS→HTTP downgrade detected: try one Botasaurus scrape before DROP."
117
+ end
118
+ end
119
+ end
120
+ end
121
+ end
122
+ end