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
@@ -20,6 +20,68 @@ module Html2rss
20
20
  # Loopback bind for HTTP transport (local use only).
21
21
  HTTP_BIND_HOST = '127.0.0.1'
22
22
 
23
+ # Declarative MCP resource registrations consumed by {register_resources}.
24
+ RESOURCES = [
25
+ {
26
+ uri: 'html2rss://schema',
27
+ name: 'Configuration JSON Schema',
28
+ description: 'Full JSON Schema for html2rss feed configurations',
29
+ mime_type: 'application/json',
30
+ body: lambda {
31
+ [{ uri: 'html2rss://schema', mimeType: 'application/json',
32
+ text: Html2rss::Config.json_schema_json(pretty: true) }]
33
+ }
34
+ },
35
+ {
36
+ uri: 'html2rss://extractors',
37
+ name: 'Available Extractors',
38
+ description: 'Registered extractor names for selector configs ' \
39
+ '(full option docs live in html2rss://schema $defs)',
40
+ mime_type: 'application/json',
41
+ body: lambda {
42
+ extractors = Html2rss::Selectors::Extractors::NAME_TO_CLASS.keys.map(&:to_s).sort
43
+ [{ uri: 'html2rss://extractors', mimeType: 'application/json',
44
+ text: JSON.pretty_generate(extractors) }]
45
+ }
46
+ },
47
+ {
48
+ uri: 'html2rss://strategies',
49
+ name: 'Available Strategies',
50
+ description: 'Published MCP request strategy names',
51
+ mime_type: 'application/json',
52
+ body: lambda {
53
+ [{ uri: 'html2rss://strategies', mimeType: 'application/json',
54
+ text: JSON.generate(Contract::STRATEGIES) }]
55
+ }
56
+ },
57
+ {
58
+ uri: 'html2rss://runtime',
59
+ name: 'Runtime capabilities',
60
+ description: 'Gem version, MCP contract version, catalog fingerprint, tool names, and Botasaurus config',
61
+ mime_type: 'application/json',
62
+ body: lambda {
63
+ [{ uri: 'html2rss://runtime', mimeType: 'application/json',
64
+ text: JSON.pretty_generate(Runtime.snapshot.to_h) }]
65
+ }
66
+ }
67
+ ].freeze
68
+
69
+ # Declarative MCP prompt registrations; SDK argument objects built at {register_prompts} time.
70
+ PROMPTS = [
71
+ {
72
+ name: 'scrape-webpage',
73
+ description: 'Guided one-shot scrape: one scrape call (auto already falls back)',
74
+ arguments: [{ name: 'url', description: 'URL to scrape', required: true }],
75
+ body: ->(args) { Outcome::Playbook.scrape_webpage_prompt(args.fetch(:url)) }
76
+ },
77
+ {
78
+ name: 'capture-feed-config',
79
+ description: 'Guided capture → test → apply; YAML draft plus catalog rewrite',
80
+ arguments: [{ name: 'url', description: 'URL to analyze', required: true }],
81
+ body: ->(args) { Outcome::Playbook.capture_feed_config_prompt(args.fetch(:url)) }
82
+ }
83
+ ].freeze
84
+
23
85
  class << self # rubocop:disable Metrics/ClassLength
24
86
  ##
25
87
  # Starts the MCP server with the given transport.
@@ -140,28 +202,8 @@ module Html2rss
140
202
  "(#{error.message}). Install them or use --transport stdio."
141
203
  end
142
204
 
143
- def instructions_text # rubocop:disable Metrics/MethodLength -- agent decision tree is the published contract
144
- <<~TEXT.strip
145
- html2rss MCP — decide which tool to call:
146
-
147
- 1. Need articles now (no saved config)? → scrape_url (1 call)
148
- - strategy "auto" runs Faraday → Botasaurus AutoFallback. Do not retry with explicit faraday after auto.
149
- - Empty scrape is still success (articles-now). Follow next_step / guidance (read_runtime if Botasaurus unset).
150
- 2. Need a reusable feed YAML? → capture_config → validate_config → apply_config
151
- - capture_config returns YAML inside payload.yaml. Draft only: if destination is html2rss-configs, rewrite for directory.topics and explicit channel title/url. Strive enhance: true (false only when chrome leaks).
152
- - validate_config / apply_config accept config hash XOR yaml string.
153
- - apply_config isError when zero RSS items (ship gate). Confirm payload.item_count.
154
- 3. Weak scrape/capture or recon (final URL, status, https→http, rel=alternate feeds)? → inspect_url only if weak or recon.
155
- 4. Have a config already? → validate_config (must succeed) → apply_config
156
- 5. Schema / extractors / strategies / runtime → resources html2rss://schema|extractors|strategies|runtime
157
-
158
- Prefer capture_config for durable config; scrape_url for one-shot extraction.
159
- Follow envelope next_step and guidance. Botasaurus needs BOTASAURUS_SCRAPER_URL in this process env (boolean at html2rss://runtime; the URL is never returned).
160
- TEXT
161
- end
162
-
163
- def botasaurus_configured?
164
- !ENV['BOTASAURUS_SCRAPER_URL'].to_s.strip.empty?
205
+ def instructions_text
206
+ Outcome::Playbook.instructions
165
207
  end
166
208
 
167
209
  def tool_error_response(error)
@@ -175,7 +217,7 @@ module Html2rss
175
217
  tool_error_response(error)
176
218
  end
177
219
 
178
- # rubocop:disable Metrics/MethodLength -- listing fields stay together
220
+ # rubocop:disable-next Metrics/MethodLength -- listing fields stay together
179
221
  def define_envelope_tool(server, name:, description:, input_schema:,
180
222
  annotations: Contract::ANNOTATIONS_OPEN_WORLD)
181
223
  run = method(:handle_tool_call)
@@ -190,201 +232,38 @@ module Html2rss
190
232
  run.call { yield(**kwargs) }
191
233
  end
192
234
  end
193
- # rubocop:enable Metrics/MethodLength
194
235
 
195
236
  def register_tools(server)
196
- register_scrape_url(server)
197
- register_inspect_url(server)
198
- register_capture_config(server)
199
- register_validate_config(server)
200
- register_apply_config(server)
201
- end
202
-
203
- def register_scrape_url(server)
204
- define_envelope_tool(
205
- server,
206
- name: 'scrape_url',
207
- description: 'One-shot article extraction as JSON Feed items. ' \
208
- 'Use when you need articles now without a saved config. ' \
209
- 'strategy "auto" triggers fallback chain (faraday → botasaurus) for JS-rendered sites.',
210
- input_schema: Contract::SCRAPE_INPUT_SCHEMA
211
- ) do |server_context:, url:, strategy: 'auto', limit: 25, items_selector: nil| # rubocop:disable Lint/UnusedBlockArgument
212
- scrape_outcome(url:, strategy:, limit:, items_selector:)
213
- end
214
- end
215
-
216
- def scrape_outcome(url:, strategy:, limit:, items_selector:)
217
- plan = (strategy || :auto).to_sym
218
- feed_result = Html2rss.auto_feed_result(url, strategy: plan, limit:, items_selector:)
219
- feed = feed_result.to_json_feed
220
- Outcome.scrape(
221
- items: feed[:items] || [],
222
- requested_strategy: plan,
223
- channel_title: feed[:title],
224
- admission_drops: feed_result.status.admission_drops,
225
- botasaurus_configured: botasaurus_configured?
226
- )
227
- end
228
-
229
- def register_inspect_url(server)
230
- define_envelope_tool(
231
- server,
232
- name: 'inspect_url',
233
- description: 'Diagnostic page analysis (scrapers, SST, segments) plus recon: ' \
234
- 'final_url, status, scheme_downgrade, rel=alternate RSS/Atom feeds. ' \
235
- 'Use when scrape/capture is weak or you need those recon facts.',
236
- input_schema: Contract::INSPECT_INPUT_SCHEMA
237
- ) do |server_context:, url:, strategy: 'auto'| # rubocop:disable Lint/UnusedBlockArgument
238
- Outcome.inspect(payload: Inspect.call(url:, strategy:))
239
- end
240
- end
241
-
242
- def register_capture_config(server) # rubocop:disable Metrics/MethodLength
243
- define_envelope_tool(
244
- server,
245
- name: 'capture_config',
246
- description: 'Derive a reusable html2rss feed config from a URL. ' \
247
- 'Use when the goal is a durable YAML (then validate_config). ' \
248
- 'Returns YAML inside payload.yaml (same serializer as CLI capture). ' \
249
- 'Draft only — catalog feeds still need directory.topics and title/url; ' \
250
- 'strive enhance: true. Full schema options live in resource html2rss://schema.',
251
- input_schema: Contract::CAPTURE_INPUT_SCHEMA
252
- ) do |server_context:, url:, strategy: 'auto', items_selector: nil| # rubocop:disable Lint/UnusedBlockArgument
253
- capture_outcome(url:, strategy:, items_selector:)
254
- end
255
- end
256
-
257
- def capture_outcome(url:, strategy:, items_selector:) # rubocop:disable Metrics/MethodLength -- CaptureResult maps 1:1 onto Outcome
258
- plan = (strategy || :auto).to_sym
259
- result = Html2rss::Capture.build(url, strategy: plan, items_selector:)
260
- Outcome.capture(
261
- yaml: Config.to_yaml(result.config),
262
- articles_count: result.articles_count,
263
- has_selectors: result.has_selectors,
264
- channel_title: result.channel_title,
265
- requested_strategy: plan,
266
- segment_strategy: result.segment_strategy,
267
- selected_strategy: result.selected_strategy,
268
- admission_drops: result.admission_drops
269
- )
270
- end
271
-
272
- def register_validate_config(server) # rubocop:disable Metrics/MethodLength -- description is the published contract
273
- define_envelope_tool(
274
- server,
275
- name: 'validate_config',
276
- description: 'Validate a feed config hash XOR yaml string against the html2rss JSON schema. ' \
277
- 'Call before apply_config. Failures return isError with payload.errors. ' \
278
- 'Full schema lives in resource html2rss://schema.',
279
- input_schema: Contract::CONFIG_XOR_SCHEMA,
280
- annotations: Contract::ANNOTATIONS_VALIDATE
281
- ) do |server_context:, config: nil, yaml: nil| # rubocop:disable Lint/UnusedBlockArgument
282
- validate_outcome(config:, yaml:)
283
- end
284
- end
285
-
286
- def validate_outcome(config:, yaml:)
287
- validation = Html2rss::Config.validate(ConfigArgument.parse(config:, yaml:).config)
288
- Outcome.validate(errors: validation.success? ? nil : validation.errors.to_h)
289
- end
290
-
291
- def register_apply_config(server)
292
- define_envelope_tool(
293
- server,
294
- name: 'apply_config',
295
- description: 'Apply a validated feed config (hash XOR yaml) and return RSS XML in payload.rss. ' \
296
- 'isError when the feed has zero items (ship gate). payload.item_count is RSS item count. ' \
297
- 'Use after validate_config succeeds.',
298
- input_schema: Contract::APPLY_INPUT_SCHEMA
299
- ) do |server_context:, url:, config: nil, yaml: nil| # rubocop:disable Lint/UnusedBlockArgument
300
- apply_outcome(url:, config:, yaml:)
237
+ Tools.register_all(server, registrar: method(:define_envelope_tool))
238
+ end
239
+
240
+ def register_resources(server)
241
+ RESOURCES.each do |entry|
242
+ server.define_resource(
243
+ uri: entry[:uri],
244
+ name: entry[:name],
245
+ description: entry[:description],
246
+ mime_type: entry[:mime_type]
247
+ ) do |server_context: nil| # rubocop:disable Lint/UnusedBlockArgument
248
+ entry[:body].call
249
+ end
301
250
  end
302
251
  end
303
252
 
304
- def apply_outcome(url:, config:, yaml:)
305
- feed_config = ConfigArgument.parse(config:, yaml:).config
306
- feed_config[:channel] ||= {}
307
- feed_config[:channel][:url] ||= url
308
- feed_result = Html2rss.feed_result(feed_config)
309
- rss = feed_result.to_rss
310
- Outcome.apply(rss: rss.to_s, item_count: rss.items.size, empty: feed_result.empty?)
311
- end
312
-
313
- def register_resources(server) # rubocop:disable Metrics/MethodLength
314
- configured = method(:botasaurus_configured?)
315
- server.define_resource(
316
- uri: 'html2rss://schema',
317
- name: 'Configuration JSON Schema',
318
- description: 'Full JSON Schema for html2rss feed configurations',
319
- mime_type: 'application/json'
320
- ) do |server_context: nil| # rubocop:disable Lint/UnusedBlockArgument
321
- schema = Html2rss::Config.json_schema_json(pretty: true)
322
- [{ uri: 'html2rss://schema', mimeType: 'application/json', text: schema }]
323
- end
324
-
325
- server.define_resource(
326
- uri: 'html2rss://extractors',
327
- name: 'Available Extractors',
328
- description: 'Registered extractor names for selector configs ' \
329
- '(full option docs live in html2rss://schema $defs)',
330
- mime_type: 'application/json'
331
- ) do |server_context: nil| # rubocop:disable Lint/UnusedBlockArgument
332
- extractors = Html2rss::Selectors::Extractors::NAME_TO_CLASS.keys.map(&:to_s).sort
333
- [{ uri: 'html2rss://extractors', mimeType: 'application/json',
334
- text: JSON.pretty_generate(extractors) }]
335
- end
336
-
337
- server.define_resource(
338
- uri: 'html2rss://strategies',
339
- name: 'Available Strategies',
340
- description: 'Published MCP request strategy names',
341
- mime_type: 'application/json'
342
- ) do |server_context: nil| # rubocop:disable Lint/UnusedBlockArgument
343
- [{ uri: 'html2rss://strategies', mimeType: 'application/json',
344
- text: JSON.generate(Contract::STRATEGIES) }]
345
- end
346
-
347
- server.define_resource(
348
- uri: 'html2rss://runtime',
349
- name: 'Runtime capabilities',
350
- description: 'Whether optional transports are configured in this MCP process (never leaks secrets)',
351
- mime_type: 'application/json'
352
- ) do |server_context: nil| # rubocop:disable Lint/UnusedBlockArgument
353
- [{ uri: 'html2rss://runtime', mimeType: 'application/json',
354
- text: JSON.pretty_generate(botasaurus_configured: configured.call) }]
355
- end
356
- end
357
-
358
- def register_prompts(server)
359
- register_scrape_webpage_prompt(server)
360
- register_capture_feed_config_prompt(server)
361
- end
362
-
363
- def register_scrape_webpage_prompt(server) # rubocop:disable Metrics/MethodLength -- SDK prompt types stay together
253
+ def register_prompts(server) # rubocop:disable Metrics/MethodLength -- prompt argument mapping
364
254
  to_result = method(:prompt_result)
365
- text_for = method(:scrape_webpage_text)
366
- server.define_prompt(
367
- name: 'scrape-webpage',
368
- description: 'Guided one-shot scrape: one scrape_url call (auto already falls back)',
369
- arguments: [
370
- ::MCP::Prompt::Argument.new(name: 'url', description: 'URL to scrape', required: true)
371
- ]
372
- ) do |args, server_context:| # rubocop:disable Lint/UnusedBlockArgument
373
- to_result.call(text_for.call(args.fetch(:url)))
374
- end
375
- end
376
-
377
- def register_capture_feed_config_prompt(server) # rubocop:disable Metrics/MethodLength -- SDK prompt types stay together
378
- to_result = method(:prompt_result)
379
- text_for = method(:capture_feed_config_text)
380
- server.define_prompt(
381
- name: 'capture-feed-config',
382
- description: 'Guided capture → validate → apply; YAML draft plus catalog rewrite',
383
- arguments: [
384
- ::MCP::Prompt::Argument.new(name: 'url', description: 'URL to analyze', required: true)
385
- ]
386
- ) do |args, server_context:| # rubocop:disable Lint/UnusedBlockArgument
387
- to_result.call(text_for.call(args.fetch(:url)))
255
+ PROMPTS.each do |entry|
256
+ arguments = entry[:arguments].map do |spec|
257
+ ::MCP::Prompt::Argument.new(name: spec[:name], description: spec[:description],
258
+ required: spec.fetch(:required, false))
259
+ end
260
+ server.define_prompt(
261
+ name: entry[:name],
262
+ description: entry[:description],
263
+ arguments:
264
+ ) do |args, server_context:| # rubocop:disable Lint/UnusedBlockArgument
265
+ to_result.call(entry[:body].call(args))
266
+ end
388
267
  end
389
268
  end
390
269
 
@@ -395,26 +274,6 @@ module Html2rss
395
274
  ]
396
275
  )
397
276
  end
398
-
399
- def scrape_webpage_text(url)
400
- <<~MSG.strip
401
- Scrape #{url} with scrape_url (strategy auto). One call is enough — auto already runs Faraday then Botasaurus.
402
- Follow envelope next_step and guidance. Call inspect_url only if articles are empty/weak or you need recon (final_url, status, scheme_downgrade, alternate_feeds).
403
- Do not retry scrape_url with explicit faraday after auto. Read html2rss://runtime if next_step is read_runtime.
404
- Return payload.items (not a raw JSON array).
405
- MSG
406
- end
407
-
408
- def capture_feed_config_text(url)
409
- <<~MSG.strip
410
- Build a reusable html2rss feed config for #{url}:
411
- 1) capture_config — YAML is payload.yaml. Check payload.articles_count and payload.has_selectors. Strive to keep enhance: true (false only when chrome leaks into items).
412
- 2) Follow next_step. If weak or you need recon, inspect_url. Auto already hops to Botasaurus; do not retry capture with botasaurus unless Faraday was blocked.
413
- 3) validate_config with yaml (or config hash) — must not be isError
414
- 4) apply_config — isError if zero items. Confirm payload.item_count before shipping.
415
- If the destination is html2rss-configs, rewrite the draft for directory.topics and explicit channel title/url. Return YAML.
416
- MSG
417
- end
418
277
  end
419
278
  end
420
279
  end
data/lib/html2rss/mcp.rb CHANGED
@@ -3,6 +3,9 @@
3
3
  module Html2rss
4
4
  ##
5
5
  # MCP server for AI client consumption.
6
+ #
7
+ # {include:file:lib/html2rss/mcp/README.md}
8
+ #
6
9
  # Lazy-loads the mcp gem; no cost when the server is not started.
7
10
  module MCP
8
11
  class << self
@@ -0,0 +1,69 @@
1
+ # PageRecon — inspect diagnostics
2
+
3
+ `PageRecon::Diagnostics` powers the **inspect** verb (CLI, MCP, `Html2rss.inspect`). It fetches once via `PageRecon.probe`, classifies the surface, counts cheap AutoSource articles, and reports redirect facts. Full curation contract: `CONTEXT.md` § Frozen contract.
4
+
5
+ ## What inspect reports
6
+
7
+ | Field | Meaning |
8
+ | ------------------ | --------------------------------------------------------------------------- |
9
+ | `requested_url` | URL you passed in |
10
+ | `final_url` | URL after redirects (may differ from requested) |
11
+ | `status` | HTTP status of the **final** response |
12
+ | `scheme_downgrade` | `true` when HTTPS entry landed on HTTP |
13
+ | `alternate_feeds` | `rel=alternate` RSS/Atom links found in HTML |
14
+ | `surface_category` | AutoSource surface class (`high_entropy_surface`, `unsupported_surface`, …) |
15
+ | `articles_count` | Cheap AutoSource extract (limit 10) — diagnostic only, not ship quality |
16
+ | `strategy` | Concrete transport used (`inspect` maps `auto` → Faraday) |
17
+
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
+
20
+ ```text
21
+ https://apex.example/
22
+ Final: https://www.example/ (HTTP 200)
23
+ Surface: high_entropy_surface (10 articles)
24
+ ```
25
+
26
+ That `Final:` line means the redirect **did** happen — not that inspect stopped at the apex host.
27
+
28
+ ## Apex → www redirects
29
+
30
+ Some publishers redirect apex domains to `www` (301). html2rss follows redirects and records the landing URL in `final_url`.
31
+
32
+ Observed behavior:
33
+
34
+ ```bash
35
+ bin/html2rss inspect https://apex.example
36
+ # Final: https://www.example/ (HTTP 200), high_entropy_surface, articles
37
+
38
+ bin/html2rss inspect https://www.example
39
+ # high_entropy_surface, articles (no Final: line — requested equals final)
40
+ ```
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.
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.
45
+
46
+ ### What to do
47
+
48
+ 1. **Prefer the canonical URL** — if you know the site lives on `www`, pass that URL to inspect, recon, capture, and scrape.
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
+ 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.
52
+
53
+ ## inspect ≠ recon
54
+
55
+ | Verb | Adds beyond diagnostics |
56
+ | ------- | -------------------------------------------------------------------- |
57
+ | inspect | Scraper eligibility, XHR hints (Botasaurus), surface + article count |
58
+ | recon | Verdict (`:build` / `:defer` / `:drop`), native feed preference |
59
+
60
+ Follow golden-path `next_step` from MCP envelopes; do not call recon when inspect already answers the question.
61
+
62
+ ## Ownership
63
+
64
+ | Concern | Owner |
65
+ | ----------------------------------- | -------------------------------------------------------------------- |
66
+ | Diagnostic fetch + assess | `PageRecon::Diagnostics` → `PageRecon.probe` |
67
+ | Surface class + cheap article count | `PageRecon::Assessment` |
68
+ | Redirect follow + terminal retry | `RequestService::FaradayStrategy` |
69
+ | Outbound header normalization | `Config::RequestHeaders` (no default `Host`; explicit override only) |
@@ -0,0 +1,211 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ class PageRecon
5
+ ##
6
+ # Diagnostic inspect path (not Capture or Recon ownership). Fetches via {.probe},
7
+ # then adds scraper/XHR diagnostics for curation inspect surfaces.
8
+ module Diagnostics # rubocop:disable Metrics/ModuleLength -- diagnostic wire fields stay co-located
9
+ ##
10
+ # Typed diagnostic report for inspect wire payloads and Outcome policy.
11
+ Report = Data.define(:data) do
12
+ ##
13
+ # @return [Boolean]
14
+ def alternate_feeds?
15
+ Array(data[:alternate_feeds]).any?
16
+ end
17
+
18
+ ##
19
+ # @return [Integer]
20
+ def articles_count
21
+ data[:articles_count].to_i
22
+ end
23
+
24
+ ##
25
+ # @return [Hash{Symbol => Object}]
26
+ def to_wire_h
27
+ data
28
+ end
29
+ end
30
+
31
+ # Minimum HTML body size to treat zero-article weak surfaces as likely JS shells.
32
+ JS_SHELL_MIN_BODY_BYTES = 8_192
33
+ private_constant :JS_SHELL_MIN_BODY_BYTES
34
+
35
+ module_function
36
+
37
+ ##
38
+ # @param url [String]
39
+ # @param strategy [String, Symbol]
40
+ # @param deep [Boolean] when true and strategy is auto, one Botasaurus hop if configured
41
+ # @return [Report]
42
+ def call(url:, strategy: :auto, deep: false)
43
+ probe = PageRecon.probe(url, strategy: resolve_inspect_strategy(strategy, deep:))
44
+ recon = probe.result
45
+ response = probe.response
46
+
47
+ Report.new(data: build_data(probe, recon, response))
48
+ end
49
+
50
+ ##
51
+ # Runs diagnostic inspect across URLs with per-URL error isolation.
52
+ #
53
+ # @param urls [Enumerable<String>]
54
+ # @param strategy [Symbol, String]
55
+ # @param concurrency [Integer]
56
+ # @return [Array<Report>]
57
+ def batch(urls:, strategy: :auto, concurrency: Batch::DEFAULT_CONCURRENCY)
58
+ Batch.map(Array(urls), concurrency:) do |url|
59
+ call(url:, strategy:)
60
+ rescue StandardError => error
61
+ error_report(url, error)
62
+ end
63
+ end
64
+
65
+ ##
66
+ # @param response [Html2rss::RequestService::Response]
67
+ # @return [Hash] redacted XHR capture diagnostics (no query strings)
68
+ def xhr_capture_info(response)
69
+ captured = response.captured_responses
70
+ {
71
+ count: captured.size,
72
+ sample_endpoints: captured.first(5).filter_map { |entry| redacted_endpoint(entry) },
73
+ candidate_articles: captured.any? { |entry| xhr_candidate_articles?(entry) }
74
+ }
75
+ end
76
+
77
+ ##
78
+ # @param entry [Hash] captured response hash
79
+ # @return [String, nil] scheme+host+path only
80
+ def redacted_endpoint(entry)
81
+ raw = entry['url'] || entry[:url]
82
+ return unless raw
83
+
84
+ uri = URI.parse(raw.to_s)
85
+ return unless uri.scheme && uri.host
86
+
87
+ "#{uri.scheme}://#{uri.host}#{uri.path}"
88
+ rescue URI::InvalidURIError
89
+ nil
90
+ end
91
+
92
+ ##
93
+ # @param entry [Hash] captured response hash
94
+ # @return [Boolean]
95
+ def xhr_candidate_articles?(entry)
96
+ body = entry['body'] || entry[:body]
97
+ return false unless body.is_a?(String)
98
+
99
+ document = JSON.parse(body, symbolize_names: true)
100
+ AutoSource::Scraper::JsonState::CandidateDetector.candidate_array?(document)
101
+ rescue JSON::ParserError
102
+ false
103
+ end
104
+
105
+ ##
106
+ # @param parsed [Object] parsed response body
107
+ # @return [Array<String>, Hash]
108
+ def scraper_info(parsed)
109
+ return { error: 'Response is not HTML' } unless parsed.is_a?(Nokogiri::HTML::Document)
110
+
111
+ begin
112
+ Html2rss::AutoSource::Scraper.from(parsed).map(&:name)
113
+ rescue Html2rss::AutoSource::Scraper::NoScraperFound => error
114
+ { none_found: error.category.to_s }
115
+ end
116
+ end
117
+
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
132
+
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
+
137
+ name
138
+ end
139
+ module_function :resolve_inspect_strategy
140
+ private_class_method :resolve_inspect_strategy
141
+
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?
147
+
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
152
+
153
+ return true if recon.surface_category == :app_shell
154
+
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?
160
+
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
171
+
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
179
+
180
+ def safe_parsed_body(response)
181
+ return unless response.html_response?
182
+
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
+ )
206
+ end
207
+ module_function :error_report
208
+ private_class_method :error_report
209
+ end
210
+ end
211
+ end