html2rss 0.30.0 → 0.31.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 (126) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +2 -2
  3. data/lib/html2rss/article/deduplicator.rb +2 -4
  4. data/lib/html2rss/article.rb +16 -39
  5. data/lib/html2rss/auto_source/README.md +1 -1
  6. data/lib/html2rss/auto_source/cleanup.rb +15 -6
  7. data/lib/html2rss/auto_source/scraper/html.rb +5 -0
  8. data/lib/html2rss/auto_source/scraper/json_state.rb +5 -0
  9. data/lib/html2rss/auto_source/scraper/meta_oembed.rb +8 -1
  10. data/lib/html2rss/auto_source/scraper/microdata.rb +8 -0
  11. data/lib/html2rss/auto_source/scraper/microformats2.rb +8 -0
  12. data/lib/html2rss/auto_source/scraper/native_feed.rb +3 -0
  13. data/lib/html2rss/auto_source/scraper/schema/thing.rb +4 -5
  14. data/lib/html2rss/auto_source/scraper/schema.rb +8 -0
  15. data/lib/html2rss/auto_source/scraper/semantic_html.rb +5 -0
  16. data/lib/html2rss/auto_source/scraper/sitemap.rb +3 -0
  17. data/lib/html2rss/auto_source/scraper/wordpress_api.rb +3 -0
  18. data/lib/html2rss/auto_source/scraper.rb +4 -14
  19. data/lib/html2rss/auto_source/segment.rb +7 -4
  20. data/lib/html2rss/auto_source/segmenter/cluster.rb +22 -23
  21. data/lib/html2rss/auto_source/segmenter/list.rb +54 -31
  22. data/lib/html2rss/auto_source/segmenter.rb +5 -5
  23. data/lib/html2rss/auto_source.rb +2 -2
  24. data/lib/html2rss/batch.rb +3 -1
  25. data/lib/html2rss/capture/README.md +6 -4
  26. data/lib/html2rss/capture.rb +67 -35
  27. data/lib/html2rss/channel.rb +3 -3
  28. data/lib/html2rss/cli/probe_view.rb +5 -3
  29. data/lib/html2rss/cli/render.rb +6 -5
  30. data/lib/html2rss/cli/validate.rb +28 -27
  31. data/lib/html2rss/cli.rb +1 -1
  32. data/lib/html2rss/config/issue_mapper.rb +108 -0
  33. data/lib/html2rss/config/request_headers.rb +2 -3
  34. data/lib/html2rss/config/schema.rb +16 -1
  35. data/lib/html2rss/config/selectors_validator.rb +92 -70
  36. data/lib/html2rss/config/validation_issue.rb +60 -0
  37. data/lib/html2rss/config/validation_report.rb +55 -0
  38. data/lib/html2rss/config/validator.rb +10 -6
  39. data/lib/html2rss/config.rb +35 -113
  40. data/lib/html2rss/defaults.rb +42 -30
  41. data/lib/html2rss/doctor/botasaurus.rb +6 -4
  42. data/lib/html2rss/doctor.rb +8 -0
  43. data/lib/html2rss/error.rb +1 -1
  44. data/lib/html2rss/feed_builder/rss.rb +9 -14
  45. data/lib/html2rss/feed_pipeline/runtime_policy.rb +14 -10
  46. data/lib/html2rss/feed_pipeline/strategy_plan.rb +6 -2
  47. data/lib/html2rss/feed_resolution/candidate_generator.rb +1 -4
  48. data/lib/html2rss/feed_resolution/diag.rb +3 -1
  49. data/lib/html2rss/feed_resolution/options.rb +3 -1
  50. data/lib/html2rss/feed_resolution/policy.rb +2 -2
  51. data/lib/html2rss/html/article_extractor/date_extractor.rb +1 -1
  52. data/lib/html2rss/html/article_extractor.rb +2 -4
  53. data/lib/html2rss/html/feed_link.rb +3 -1
  54. data/lib/html2rss/html/navigator/text_extractor.rb +1 -1
  55. data/lib/html2rss/html/navigator.rb +10 -10
  56. data/lib/html2rss/html/rendering/description_builder.rb +1 -1
  57. data/lib/html2rss/html/sst_article_extractor.rb +8 -5
  58. data/lib/html2rss/link_destination/destination_facts.rb +4 -2
  59. data/lib/html2rss/link_destination/noise_policy.rb +10 -1
  60. data/lib/html2rss/link_destination/path_classifier.rb +45 -38
  61. data/lib/html2rss/mcp/README.md +2 -0
  62. data/lib/html2rss/mcp/config_argument.rb +3 -1
  63. data/lib/html2rss/mcp/contract.rb +6 -20
  64. data/lib/html2rss/mcp/outcome/playbook.rb +2 -2
  65. data/lib/html2rss/mcp/outcome.rb +36 -43
  66. data/lib/html2rss/mcp/server/tools.rb +111 -131
  67. data/lib/html2rss/mcp/server.rb +26 -18
  68. data/lib/html2rss/page_recon/README.md +5 -5
  69. data/lib/html2rss/page_recon/diagnostics.rb +71 -81
  70. data/lib/html2rss/page_recon.rb +9 -5
  71. data/lib/html2rss/recon.rb +19 -30
  72. data/lib/html2rss/request_service/botasaurus_contract.rb +11 -12
  73. data/lib/html2rss/request_service/botasaurus_strategy.rb +1 -1
  74. data/lib/html2rss/request_service/compressed_body.rb +4 -1
  75. data/lib/html2rss/request_service/httpx_strategy.rb +39 -28
  76. data/lib/html2rss/request_service/network_guard.rb +7 -5
  77. data/lib/html2rss/request_service/response.rb +1 -2
  78. data/lib/html2rss/request_service/strategy.rb +3 -8
  79. data/lib/html2rss/scoring/container_assessor.rb +3 -1
  80. data/lib/html2rss/scoring/engine.rb +8 -8
  81. data/lib/html2rss/scoring/observation.rb +5 -3
  82. data/lib/html2rss/scoring/ranked_segment.rb +3 -1
  83. data/lib/html2rss/scrape_target.rb +3 -1
  84. data/lib/html2rss/selector_candidates.rb +234 -0
  85. data/lib/html2rss/selectors/README.md +64 -0
  86. data/lib/html2rss/selectors/extractors/attribute.rb +13 -15
  87. data/lib/html2rss/selectors/extractors/href.rb +15 -16
  88. data/lib/html2rss/selectors/extractors/html.rb +8 -9
  89. data/lib/html2rss/selectors/extractors/static.rb +12 -14
  90. data/lib/html2rss/selectors/extractors/text.rb +8 -9
  91. data/lib/html2rss/selectors/extractors.rb +33 -20
  92. data/lib/html2rss/selectors/item_env.rb +31 -0
  93. data/lib/html2rss/selectors/{object_to_xml_converter.rb → json_xml.rb} +11 -12
  94. data/lib/html2rss/selectors/option_spec.rb +140 -0
  95. data/lib/html2rss/selectors/post_processors/base.rb +79 -31
  96. data/lib/html2rss/selectors/post_processors/gsub.rb +103 -39
  97. data/lib/html2rss/selectors/post_processors/html_transformers/transform_urls_to_absolute_ones.rb +4 -4
  98. data/lib/html2rss/selectors/post_processors/markdown_to_html.rb +10 -11
  99. data/lib/html2rss/selectors/post_processors/parse_time.rb +50 -26
  100. data/lib/html2rss/selectors/post_processors/parse_uri.rb +11 -7
  101. data/lib/html2rss/selectors/post_processors/sanitize_html.rb +46 -32
  102. data/lib/html2rss/selectors/post_processors/substring.rb +14 -25
  103. data/lib/html2rss/selectors/post_processors/template.rb +23 -19
  104. data/lib/html2rss/selectors/post_processors.rb +6 -5
  105. data/lib/html2rss/selectors/{schema_doc.rb → schema_export.rb} +12 -31
  106. data/lib/html2rss/selectors/step_env.rb +24 -0
  107. data/lib/html2rss/selectors.rb +231 -142
  108. data/lib/html2rss/sst/attrs.rb +5 -3
  109. data/lib/html2rss/sst/document.rb +3 -1
  110. data/lib/html2rss/sst/index.rb +3 -1
  111. data/lib/html2rss/sst/node.rb +29 -10
  112. data/lib/html2rss/sst/normalizer.rb +12 -16
  113. data/lib/html2rss/sst/tags.rb +7 -7
  114. data/lib/html2rss/status.rb +13 -8
  115. data/lib/html2rss/surface_category.rb +14 -32
  116. data/lib/html2rss/syndication/parser.rb +2 -2
  117. data/lib/html2rss/test/enhance_audit.rb +1 -3
  118. data/lib/html2rss/test/policy.rb +106 -0
  119. data/lib/html2rss/test.rb +126 -173
  120. data/lib/html2rss/url.rb +25 -4
  121. data/lib/html2rss/version.rb +1 -1
  122. data/lib/html2rss.rb +21 -13
  123. data/lib/tasks/config_schema.rake +2 -2
  124. data/schema/html2rss-config.schema.json +11 -8
  125. metadata +15 -6
  126. data/lib/html2rss/selectors/item_scope.rb +0 -30
@@ -4,25 +4,37 @@ module Html2rss
4
4
  module MCP
5
5
  module Server
6
6
  ##
7
- # MCP tool registration and outcome mapping over public html2rss APIs.
7
+ # MCP tool registry: typed {Tool} values over public html2rss APIs.
8
8
  module Tools # rubocop:disable Metrics/ModuleLength -- declarative registry + substantive handlers
9
- # Declarative MCP tool registrations consumed by {register_all}.
9
+ Tool = Data.define(:name, :title, :description, :input_schema, :annotations, :call)
10
+
11
+ ##
12
+ # One published MCP tool. +call+ is a lambda with explicit keyword defaults.
13
+ # +annotations+ is {Contract::ANNOTATIONS_OPEN_WORLD} unless a tool overrides it.
14
+ class Tool # rubocop:disable Lint/EmptyClass -- Data members only; handlers are +call+ lambdas
15
+ end
16
+
17
+ # Published MCP tools consumed by {Server.register_tools}.
10
18
  TOOLS = [
11
- {
19
+ Tool.new(
12
20
  name: 'scrape',
13
- kind: :url,
21
+ title: 'Scrape',
14
22
  description: 'One-shot article extraction as JSON Feed items. ' \
15
23
  'Use when you need articles now without a saved config. ' \
16
24
  'strategy "auto" triggers fallback chain (default → botasaurus) for JS-rendered sites.',
17
25
  input_schema: Contract::SCRAPE_INPUT_SCHEMA,
18
- handler: :scrape_outcome
19
- },
20
- {
26
+ annotations: Contract::ANNOTATIONS_OPEN_WORLD,
27
+ call: lambda { |url:, strategy: 'auto', limit: 25, items_selector: nil, **|
28
+ scrape_outcome(url:, strategy:, limit:, items_selector:)
29
+ }
30
+ ),
31
+ Tool.new(
21
32
  name: 'inspect',
22
- kind: :url,
33
+ title: 'Inspect',
23
34
  description: 'Diagnostic page analysis (scrapers, SST, segments, final URL, status, ' \
24
35
  'rel=alternate feeds). Use recon for BUILD/DEFER/DROP verdict and native_feed preference.',
25
36
  input_schema: Contract::INSPECT_INPUT_SCHEMA,
37
+ annotations: Contract::ANNOTATIONS_OPEN_WORLD,
26
38
  call: lambda { |url:, strategy: 'auto', **|
27
39
  Outcome.inspect(
28
40
  report: PageRecon::Diagnostics.call(
@@ -30,45 +42,60 @@ module Html2rss
30
42
  )
31
43
  )
32
44
  }
33
- },
34
- {
45
+ ),
46
+ Tool.new(
35
47
  name: 'recon',
36
- kind: :url,
48
+ title: 'Recon',
37
49
  description: 'Curation verdict and native_feed preference for a URL. ' \
38
50
  'Use after inspect when alternates warrant deeper recon, or when you need BUILD/DEFER/DROP.',
39
51
  input_schema: Contract::RECON_INPUT_SCHEMA,
52
+ annotations: Contract::ANNOTATIONS_OPEN_WORLD,
40
53
  call: lambda { |url:, strategy: 'auto', **|
41
54
  Outcome.recon(result: Html2rss.recon(url, strategy: Runtime.coerce_strategy(strategy)))
42
55
  }
43
- },
44
- {
56
+ ),
57
+ Tool.new(
45
58
  name: 'batch_scrape',
46
- kind: :batch,
47
- batch_method: :batch_scrape,
48
- limit_default: 10,
59
+ title: 'Batch scrape',
49
60
  description: 'Scrape multiple URLs in parallel with per-URL error isolation. ' \
50
61
  'Returns structured JSON Feed items and extraction counts.',
51
- input_schema: Contract::BATCH_SCRAPE_INPUT_SCHEMA
52
- },
53
- {
62
+ input_schema: Contract::BATCH_SCRAPE_INPUT_SCHEMA,
63
+ annotations: Contract::ANNOTATIONS_OPEN_WORLD,
64
+ call: lambda { |urls:, strategy: 'auto', concurrency: Batch::DEFAULT_CONCURRENCY, limit: 10, **|
65
+ Outcome.batch_scrape(
66
+ Batch.batch_scrape(urls:, strategy: Runtime.coerce_strategy(strategy), concurrency:, limit:)
67
+ )
68
+ }
69
+ ),
70
+ Tool.new(
54
71
  name: 'batch_inspect',
55
- kind: :batch,
56
- batch_method: :batch_inspect,
72
+ title: 'Batch inspect',
57
73
  description: 'Inspect multiple URLs in parallel with per-URL error isolation. ' \
58
74
  'Returns final redirected URLs, status codes, and rel="alternate" feeds.',
59
- input_schema: Contract::BATCH_INSPECT_INPUT_SCHEMA
60
- },
61
- {
75
+ input_schema: Contract::BATCH_INSPECT_INPUT_SCHEMA,
76
+ annotations: Contract::ANNOTATIONS_OPEN_WORLD,
77
+ call: lambda { |urls:, strategy: 'auto', concurrency: Batch::DEFAULT_CONCURRENCY, **|
78
+ Outcome.batch_inspect(
79
+ Batch.batch_inspect(urls:, strategy: Runtime.coerce_strategy(strategy), concurrency:)
80
+ )
81
+ }
82
+ ),
83
+ Tool.new(
62
84
  name: 'batch_recon',
63
- kind: :batch,
64
- batch_method: :batch_recon,
85
+ title: 'Batch recon',
65
86
  description: 'Run recon across multiple URLs in parallel with per-URL error isolation. ' \
66
87
  'Returns verdict, native_feed, and surface classification per URL.',
67
- input_schema: Contract::BATCH_RECON_INPUT_SCHEMA
68
- },
69
- {
88
+ input_schema: Contract::BATCH_RECON_INPUT_SCHEMA,
89
+ annotations: Contract::ANNOTATIONS_OPEN_WORLD,
90
+ call: lambda { |urls:, strategy: 'auto', concurrency: Batch::DEFAULT_CONCURRENCY, **|
91
+ Outcome.batch_recon(
92
+ Batch.batch_recon(urls:, strategy: Runtime.coerce_strategy(strategy), concurrency:)
93
+ )
94
+ }
95
+ ),
96
+ Tool.new(
70
97
  name: 'capture',
71
- kind: :capture,
98
+ title: 'Capture',
72
99
  description: 'Derive a reusable html2rss feed config from a URL. ' \
73
100
  'Use when the goal is a durable YAML (then test → apply). ' \
74
101
  'Returns YAML inside payload.yaml (same serializer as CLI capture). ' \
@@ -76,134 +103,65 @@ module Html2rss
76
103
  'enhance defaults from admission evidence (false when chrome drops are high). ' \
77
104
  'Full schema options live in resource html2rss://schema.',
78
105
  input_schema: Contract::CAPTURE_INPUT_SCHEMA,
79
- handler: :capture_outcome
80
- },
81
- {
106
+ annotations: Contract::ANNOTATIONS_OPEN_WORLD,
107
+ call: lambda { |url:, strategy: 'auto', items_selector: nil, force: false, topics: nil, title: nil,
108
+ summary: nil, enhance: nil, limit: nil, max_redirects: nil, max_requests: nil, **|
109
+ capture_outcome(url:, strategy:, items_selector:, force:, topics:, title:, summary:,
110
+ enhance:, limit:, max_redirects:, max_requests:)
111
+ }
112
+ ),
113
+ Tool.new(
82
114
  name: 'validate',
83
- kind: :config_xor,
115
+ title: 'Validate',
84
116
  description: 'Validate a feed config hash XOR yaml string against the html2rss JSON schema. ' \
85
- 'Call before test. Failures return isError with payload.errors. ' \
117
+ 'Call before test. Failures return isError with payload.issues. ' \
86
118
  'Full schema lives in resource html2rss://schema.',
87
119
  input_schema: Contract::CONFIG_XOR_SCHEMA,
88
120
  annotations: Contract::ANNOTATIONS_VALIDATE,
89
121
  call: lambda { |config: nil, yaml: nil, **|
90
- validation = Html2rss::Config.validate(ConfigArgument.parse(config:, yaml:).config)
91
- Outcome.validate(errors: validation.success? ? nil : validation.errors.to_h)
122
+ Outcome.validate(report: Html2rss::Config.validate(ConfigArgument.parse(config:, yaml:).config))
92
123
  }
93
- },
94
- {
124
+ ),
125
+ Tool.new(
95
126
  name: 'test',
96
- kind: :config_xor,
127
+ title: 'Test',
97
128
  description: 'Validate schema and execute live extraction (asserting >= min_items items). ' \
98
129
  'Call after capture or validate; on success next_step is apply. ' \
99
130
  'Returns test summary in payload with sample items, timing, failure_kind, ' \
100
131
  'and quality_report (warnings for duplicate URLs, junk titles, native feed). ' \
101
132
  'Set strict_quality to fail on duplicate URLs, >50% junk titles, or short titles.',
102
133
  input_schema: Contract::TEST_INPUT_SCHEMA,
134
+ annotations: Contract::ANNOTATIONS_OPEN_WORLD,
103
135
  call: lambda { |config: nil, yaml: nil, min_items: 1, strict_quality: false,
104
136
  compare_enhance: false, **kwargs|
105
137
  feed_config = ConfigArgument.parse(config:, yaml:).config
106
138
  test_args = { min_items:, strict_quality:, compare_enhance: }
107
139
  test_args[:strategy] = Runtime.coerce_strategy(kwargs[:strategy]) if kwargs.key?(:strategy)
108
- test_result = Html2rss.test(feed_config, **test_args)
109
- Outcome.test(test_result)
140
+ Outcome.test(Html2rss.test(feed_config, **test_args))
110
141
  }
111
- },
112
- {
142
+ ),
143
+ Tool.new(
113
144
  name: 'apply',
114
- kind: :config_xor,
145
+ title: 'Apply',
115
146
  description: 'Apply a validated feed config (hash XOR yaml) and return RSS XML in payload.rss. ' \
116
147
  'isError when the feed has zero items (ship gate). payload.item_count is RSS item count. ' \
117
148
  'Use after test succeeds.',
118
149
  input_schema: Contract::APPLY_INPUT_SCHEMA,
119
- handler: :apply_outcome
120
- }
150
+ annotations: Contract::ANNOTATIONS_OPEN_WORLD,
151
+ call: lambda { |url:, config: nil, yaml: nil, **|
152
+ apply_outcome(url:, config:, yaml:)
153
+ }
154
+ )
121
155
  ].freeze
122
156
 
123
- class << self # rubocop:disable Metrics/ClassLength -- registration engine + handlers
157
+ class << self
124
158
  ##
125
- # Registers all MCP tools on +server+ via +registrar+ (Server.define_envelope_tool).
126
- #
127
- # @param server [::MCP::Server]
128
- # @param registrar [Proc]
129
- # @return [void]
130
- def register_all(server, registrar:)
131
- TOOLS.each { |entry| register_tool(server, registrar, entry) }
132
- end
133
-
134
- private
135
-
136
- def register_tool(server, registrar, entry)
137
- case entry[:kind]
138
- when :url then register_url_tool(server, registrar, entry)
139
- when :batch then register_batch_tool(server, registrar, entry)
140
- when :config_xor then register_config_xor_tool(server, registrar, entry)
141
- when :capture then register_capture_tool(server, registrar, entry)
142
- else raise ArgumentError, "unknown tool kind: #{entry[:kind].inspect}"
143
- end
144
- end
145
-
146
- def register_url_tool(server, registrar, entry)
147
- handler = entry[:handler] ? method(entry[:handler]) : entry[:call]
148
- registrar.call(
149
- server,
150
- name: entry[:name],
151
- description: entry[:description],
152
- input_schema: entry[:input_schema],
153
- annotations: entry.fetch(:annotations, Contract::ANNOTATIONS_OPEN_WORLD)
154
- ) do |**kwargs|
155
- handler.call(**tool_kwargs(kwargs))
156
- end
157
- end
158
-
159
- def register_batch_tool(server, registrar, entry) # rubocop:disable Metrics/MethodLength
160
- name = entry[:name]
161
- batch_method = entry[:batch_method]
162
- limit_default = entry[:limit_default]
163
- registrar.call(
164
- server,
165
- name:,
166
- description: entry[:description],
167
- input_schema: entry[:input_schema]
168
- ) do |urls:, strategy: 'auto', **kwargs|
169
- concurrency = kwargs.fetch(:concurrency, Batch::DEFAULT_CONCURRENCY)
170
- batch_args = { urls:, strategy: Runtime.coerce_strategy(strategy), concurrency: }
171
- batch_args[:limit] = kwargs.fetch(:limit, limit_default) unless limit_default.nil?
172
- Outcome.public_send(name, Batch.public_send(batch_method, **batch_args))
173
- end
174
- end
175
-
176
- def register_config_xor_tool(server, registrar, entry)
177
- handler = entry[:handler] ? method(entry[:handler]) : entry[:call]
178
- registrar.call(
179
- server,
180
- name: entry[:name],
181
- description: entry[:description],
182
- input_schema: entry[:input_schema],
183
- annotations: entry.fetch(:annotations, Contract::ANNOTATIONS_OPEN_WORLD)
184
- ) do |**kwargs|
185
- handler.call(**tool_kwargs(kwargs))
186
- end
187
- end
188
-
189
- def register_capture_tool(server, registrar, entry)
190
- handler = method(entry[:handler])
191
- registrar.call(
192
- server,
193
- name: entry[:name],
194
- description: entry[:description],
195
- input_schema: entry[:input_schema]
196
- ) do |**kwargs|
197
- handler.call(**tool_kwargs(kwargs))
198
- end
199
- end
200
-
201
- def tool_kwargs(inputs)
202
- args = inputs.dup
203
- args.delete(:server_context)
204
- args
205
- end
206
-
159
+ # @api private
160
+ # @param url [String]
161
+ # @param strategy [String, Symbol]
162
+ # @param limit [Integer]
163
+ # @param items_selector [String, nil]
164
+ # @return [Outcome]
207
165
  def scrape_outcome(url:, strategy: 'auto', limit: 25, items_selector: nil)
208
166
  wire = Batch.scrape_wire(url:, strategy: Runtime.coerce_strategy(strategy), limit:, items_selector:)
209
167
  Outcome.scrape(
@@ -215,6 +173,20 @@ module Html2rss
215
173
  )
216
174
  end
217
175
 
176
+ ##
177
+ # @api private
178
+ # @param url [String]
179
+ # @param strategy [String, Symbol]
180
+ # @param items_selector [String, nil]
181
+ # @param force [Boolean]
182
+ # @param topics [Array<String>, nil]
183
+ # @param title [String, nil]
184
+ # @param summary [String, nil]
185
+ # @param enhance [Boolean, nil]
186
+ # @param limit [Integer, nil]
187
+ # @param max_redirects [Integer, nil]
188
+ # @param max_requests [Integer, nil]
189
+ # @return [Outcome]
218
190
  def capture_outcome(url:, strategy: 'auto', items_selector: nil, force: false, topics: nil, title: nil, # rubocop:disable Metrics/MethodLength, Metrics/ParameterLists
219
191
  summary: nil, enhance: nil, limit: nil, max_redirects: nil, max_requests: nil)
220
192
  plan = Runtime.coerce_strategy(strategy)
@@ -245,6 +217,12 @@ module Html2rss
245
217
  )
246
218
  end
247
219
 
220
+ ##
221
+ # @api private
222
+ # @param url [String]
223
+ # @param config [Hash, nil]
224
+ # @param yaml [String, nil]
225
+ # @return [Outcome]
248
226
  def apply_outcome(url:, config: nil, yaml: nil)
249
227
  feed_config = HashUtil.deep_dup(ConfigArgument.parse(config:, yaml:).config)
250
228
  feed_config[:channel] ||= {}
@@ -253,12 +231,14 @@ module Html2rss
253
231
  apply_feed_outcome(feed_config, feed_result, outcome)
254
232
  end
255
233
 
234
+ private
235
+
256
236
  def apply_feed_outcome(feed_config, feed_result, outcome) # rubocop:disable Metrics/MethodLength -- quality_report + RSS payload
257
237
  rss = feed_result.to_rss
258
238
  quality_report = Html2rss::Test.quality_report_for(
259
239
  rss.items,
260
240
  channel_url: feed_config.dig(:channel, :url).to_s,
261
- raw_config: feed_config,
241
+ config: Html2rss::Config.from_hash(feed_config),
262
242
  feed_result:,
263
243
  pipeline_outcome: outcome,
264
244
  probe_native_feed: false
@@ -20,6 +20,9 @@ module Html2rss
20
20
  # Loopback bind for HTTP transport (local use only).
21
21
  HTTP_BIND_HOST = '127.0.0.1'
22
22
 
23
+ # Supported transport names.
24
+ TRANSPORTS = Set[:stdio, :http].freeze
25
+
23
26
  # Declarative MCP resource registrations consumed by {register_resources}.
24
27
  RESOURCES = [
25
28
  {
@@ -29,7 +32,7 @@ module Html2rss
29
32
  mime_type: 'application/json',
30
33
  body: lambda {
31
34
  [{ uri: 'html2rss://schema', mimeType: 'application/json',
32
- text: Html2rss::Config.json_schema_json(pretty: true) }]
35
+ text: Html2rss::Config::Schema.json_schema_json(pretty: true) }]
33
36
  }
34
37
  },
35
38
  {
@@ -93,7 +96,7 @@ module Html2rss
93
96
  # @param transport [Symbol] +:stdio+ or +:http+
94
97
  # @param port [Integer] port for HTTP transport
95
98
  def start(transport: :stdio, port: 8080)
96
- raise ArgumentError, "Unknown transport: #{transport.inspect}" unless %i[stdio http].include?(transport)
99
+ raise ArgumentError, "Unknown transport: #{transport.inspect}" unless TRANSPORTS.include?(transport)
97
100
 
98
101
  configure_daemon_logging!
99
102
  app = build
@@ -137,8 +140,12 @@ module Html2rss
137
140
 
138
141
  def protocol_configuration
139
142
  ::MCP::Configuration.new.tap do |config|
140
- config.exception_reporter = method(:report_protocol_exception)
141
- config.around_request = method(:around_protocol_request)
143
+ config.exception_reporter = lambda { |error, server_context|
144
+ report_protocol_exception(error, server_context)
145
+ }
146
+ config.around_request = lambda { |data, &block|
147
+ around_protocol_request(data, &block)
148
+ }
142
149
  config.validate_tool_call_results = true
143
150
  end
144
151
  end
@@ -217,26 +224,27 @@ module Html2rss
217
224
  tool_error_response(error)
218
225
  end
219
226
 
227
+ def register_tools(server)
228
+ Tools::TOOLS.each { |tool| register_envelope_tool(server, tool) }
229
+ end
230
+
220
231
  # rubocop:disable-next Metrics/MethodLength -- listing fields stay together
221
- def define_envelope_tool(server, name:, description:, input_schema:,
222
- annotations: Contract::ANNOTATIONS_OPEN_WORLD)
223
- run = method(:handle_tool_call)
232
+ def register_envelope_tool(server, tool)
233
+ invoke = ->(&block) { handle_tool_call(&block) }
224
234
  server.define_tool(
225
- name:,
226
- title: Contract::TITLES.fetch(name.to_sym),
227
- description:,
228
- annotations:,
229
- input_schema:,
235
+ name: tool.name,
236
+ title: tool.title,
237
+ description: tool.description,
238
+ annotations: tool.annotations,
239
+ input_schema: tool.input_schema,
230
240
  output_schema: Contract.output_schema
231
241
  ) do |**kwargs|
232
- run.call { yield(**kwargs) }
242
+ args = kwargs.dup
243
+ args.delete(:server_context)
244
+ invoke.call { tool.call.call(**args) }
233
245
  end
234
246
  end
235
247
 
236
- def register_tools(server)
237
- Tools.register_all(server, registrar: method(:define_envelope_tool))
238
- end
239
-
240
248
  def register_resources(server)
241
249
  RESOURCES.each do |entry|
242
250
  server.define_resource(
@@ -251,7 +259,7 @@ module Html2rss
251
259
  end
252
260
 
253
261
  def register_prompts(server) # rubocop:disable Metrics/MethodLength -- prompt argument mapping
254
- to_result = method(:prompt_result)
262
+ to_result = ->(text) { prompt_result(text) }
255
263
  PROMPTS.each do |entry|
256
264
  arguments = entry[:arguments].map do |spec|
257
265
  ::MCP::Prompt::Argument.new(name: spec[:name], description: spec[:description],
@@ -13,7 +13,7 @@
13
13
  | `alternate_feeds` | `rel=alternate` RSS/Atom links found in HTML |
14
14
  | `surface_category` | AutoSource surface class (`high_entropy_surface`, `unsupported_surface`, …) |
15
15
  | `articles_count` | Cheap AutoSource extract (limit 10) — diagnostic only, not ship quality |
16
- | `strategy` | Concrete transport used (`inspect` maps `auto` → Faraday) |
16
+ | `strategy` | Concrete transport used (`inspect` maps `auto` → default (HTTPX)) |
17
17
 
18
18
  CLI text output omits `requested_url`; it prints the requested URL as the card title. The `Final:` line appears **only when** `final_url` differs from `requested_url`:
19
19
 
@@ -39,16 +39,16 @@ bin/html2rss inspect https://www.example
39
39
  # high_entropy_surface, articles (no Final: line — requested equals final)
40
40
  ```
41
41
 
42
- Cross-host redirects (e.g. `apex.example` → `www.example`) no longer pin a stale `Host` header from the entry URL. `Config::RequestHeaders` omits `Host` by default; Faraday/Net::HTTP sets it per hop from the current request URL.
42
+ Cross-host redirects (e.g. `apex.example` → `www.example`) no longer pin a stale `Host` header from the entry URL. `Config::RequestHeaders` omits `Host` by default; HTTPX sets it per hop from the current request URL.
43
43
 
44
- When the first streamed fetch loses the redirect payload (empty body at the final URL), `FaradayStrategy` retries once without streaming so inspect/recon see the same HTML as a direct canonical URL fetch.
44
+ When the first streamed fetch loses the redirect payload (empty body at the final URL), `HttpxStrategy` retries once without streaming so inspect/recon see the same HTML as a direct canonical URL fetch.
45
45
 
46
46
  ### What to do
47
47
 
48
48
  1. **Prefer the canonical URL** — if you know the site lives on `www`, pass that URL to inspect, recon, capture, and scrape.
49
49
  2. **Read the `Final:` line** — when `final_url` differs from what you typed and status is 4xx, retry inspect on `final_url` before assuming the site is unreachable.
50
50
  3. **Do not treat apex 403 as “redirect skipped”** — check JSON output (`--format json`) for `requested_url` vs `final_url` when text output is ambiguous.
51
- 4. **Blocked surfaces** — if the canonical URL still fails, try `strategy: botasaurus` on inspect (MCP) or escalate to recon/capture with browser strategy; Faraday-only inspect is intentionally cheap.
51
+ 4. **Blocked surfaces** — if the canonical URL still fails, try `strategy: botasaurus` on inspect (MCP) or escalate to recon/capture with browser strategy; default (HTTPX)-only inspect is intentionally cheap.
52
52
 
53
53
  ## inspect ≠ recon
54
54
 
@@ -65,5 +65,5 @@ Follow golden-path `next_step` from MCP envelopes; do not call recon when inspec
65
65
  | ----------------------------------- | -------------------------------------------------------------------- |
66
66
  | Diagnostic fetch + assess | `PageRecon::Diagnostics` → `PageRecon.probe` |
67
67
  | Surface class + cheap article count | `PageRecon::Assessment` |
68
- | Redirect follow + terminal retry | `RequestService::FaradayStrategy` |
68
+ | Redirect follow + terminal retry | `RequestService::HttpxStrategy` |
69
69
  | Outbound header normalization | `Config::RequestHeaders` (no default `Host`; explicit override only) |
@@ -6,9 +6,11 @@ module Html2rss
6
6
  # Diagnostic inspect path (not Capture or Recon ownership). Fetches via {.probe},
7
7
  # then adds scraper/XHR diagnostics for curation inspect surfaces.
8
8
  module Diagnostics # rubocop:disable Metrics/ModuleLength -- diagnostic wire fields stay co-located
9
+ Report = Data.define(:data)
10
+
9
11
  ##
10
12
  # Typed diagnostic report for inspect wire payloads and Outcome policy.
11
- Report = Data.define(:data) do
13
+ class Report
12
14
  ##
13
15
  # @return [Boolean]
14
16
  def alternate_feeds?
@@ -115,97 +117,85 @@ module Html2rss
115
117
  end
116
118
  end
117
119
 
118
- def build_data(probe, recon, response)
119
- data = recon.to_h.merge(
120
- strategy: probe.strategy,
121
- scraper_eligibility: scraper_info(safe_parsed_body(response)),
122
- html_present: html_present?(recon, response),
123
- likely_js_shell: likely_js_shell?(recon, response),
124
- redirect_summary: redirect_summary(recon)
125
- )
126
- data[:xhr_capture] = xhr_capture_info(response) if probe.strategy == :botasaurus
127
- log_js_shell(data, response.body&.bytesize.to_i) if data[:likely_js_shell]
128
- data
129
- end
130
- module_function :build_data
131
- private_class_method :build_data
120
+ class << self
121
+ private
122
+
123
+ def build_data(probe, recon, response)
124
+ data = recon.to_h.merge(
125
+ strategy: probe.strategy,
126
+ scraper_eligibility: scraper_info(safe_parsed_body(response)),
127
+ html_present: html_present?(recon, response),
128
+ likely_js_shell: likely_js_shell?(recon, response),
129
+ redirect_summary: redirect_summary(recon)
130
+ )
131
+ data[:xhr_capture] = xhr_capture_info(response) if probe.strategy == :botasaurus
132
+ log_js_shell(data, response.body&.bytesize.to_i) if data[:likely_js_shell]
133
+ data
134
+ end
132
135
 
133
- def resolve_inspect_strategy(strategy, deep:)
134
- name = (strategy || :auto).to_sym
135
- return :botasaurus if deep && name == :auto && MCP::Runtime.botasaurus_configured?
136
+ def resolve_inspect_strategy(strategy, deep:)
137
+ name = (strategy || :auto).to_sym
138
+ return :botasaurus if deep && name == :auto && MCP::Runtime.botasaurus_configured?
136
139
 
137
- name
138
- end
139
- module_function :resolve_inspect_strategy
140
- private_class_method :resolve_inspect_strategy
140
+ name
141
+ end
141
142
 
142
- def html_present?(recon, response)
143
- recon.html_response && !response.body.to_s.empty?
144
- end
145
- module_function :html_present?
146
- private_class_method :html_present?
143
+ def html_present?(recon, response)
144
+ recon.html_response && !response.body.to_s.empty?
145
+ end
147
146
 
148
- def likely_js_shell?(recon, response)
149
- return false unless html_present?(recon, response)
150
- return false if recon.articles_count.positive?
151
- return false if recon.blocked_surface || recon.surface_category == :blocked_surface
147
+ def likely_js_shell?(recon, response)
148
+ return false unless html_present?(recon, response)
149
+ return false if recon.articles_count.positive?
150
+ return false if recon.blocked_surface || recon.surface_category == :blocked_surface
152
151
 
153
- return true if recon.surface_category == :app_shell
152
+ return true if recon.surface_category == :app_shell
154
153
 
155
- response.body.bytesize >= JS_SHELL_MIN_BODY_BYTES &&
156
- SurfaceCategory.coerce(recon.surface_category).weak?
157
- end
158
- module_function :likely_js_shell?
159
- private_class_method :likely_js_shell?
154
+ response.body.bytesize >= JS_SHELL_MIN_BODY_BYTES &&
155
+ SurfaceCategory[recon.surface_category].weak?
156
+ end
160
157
 
161
- def redirect_summary(recon)
162
- {
163
- requested_url: recon.requested_url,
164
- final_url: recon.final_url,
165
- status: recon.status,
166
- scheme_downgrade: recon.scheme_downgrade
167
- }
168
- end
169
- module_function :redirect_summary
170
- private_class_method :redirect_summary
158
+ def redirect_summary(recon)
159
+ {
160
+ requested_url: recon.requested_url,
161
+ final_url: recon.final_url,
162
+ status: recon.status,
163
+ scheme_downgrade: recon.scheme_downgrade
164
+ }
165
+ end
171
166
 
172
- def log_js_shell(data, body_bytesize)
173
- Log.debug(
174
- "Diagnostics js_shell: bytesize=#{body_bytesize} surface_category=#{data[:surface_category]}"
175
- )
176
- end
177
- module_function :log_js_shell
178
- private_class_method :log_js_shell
167
+ def log_js_shell(data, body_bytesize)
168
+ Log.debug(
169
+ "Diagnostics js_shell: bytesize=#{body_bytesize} surface_category=#{data[:surface_category]}"
170
+ )
171
+ end
179
172
 
180
- def safe_parsed_body(response)
181
- return unless response.html_response?
173
+ def safe_parsed_body(response)
174
+ return unless response.html_response?
182
175
 
183
- response.parsed_body
184
- rescue RequestService::UnsupportedResponseContentType
185
- nil
186
- end
187
- module_function :safe_parsed_body
188
- private_class_method :safe_parsed_body
189
-
190
- def error_report(url, error) # rubocop:disable Metrics/MethodLength -- error hash mirrors success report shape
191
- Report.new(
192
- data: {
193
- requested_url: url.to_s,
194
- final_url: url.to_s,
195
- status: nil,
196
- scheme_downgrade: false,
197
- alternate_feeds: [],
198
- surface_category: :unsupported_surface,
199
- articles_count: 0,
200
- html_response: false,
201
- content_type: nil,
202
- strategy: nil,
203
- scraper_eligibility: { error: "#{error.class} - #{error.message}" }
204
- }
205
- )
176
+ response.parsed_body
177
+ rescue RequestService::UnsupportedResponseContentType
178
+ nil
179
+ end
180
+
181
+ def error_report(url, error) # rubocop:disable Metrics/MethodLength -- error hash mirrors success report shape
182
+ Report.new(
183
+ data: {
184
+ requested_url: url.to_s,
185
+ final_url: url.to_s,
186
+ status: nil,
187
+ scheme_downgrade: false,
188
+ alternate_feeds: [],
189
+ surface_category: :unsupported_surface,
190
+ articles_count: 0,
191
+ html_response: false,
192
+ content_type: nil,
193
+ strategy: nil,
194
+ scraper_eligibility: { error: "#{error.class} - #{error.message}" }
195
+ }
196
+ )
197
+ end
206
198
  end
207
- module_function :error_report
208
- private_class_method :error_report
209
199
  end
210
200
  end
211
201
  end