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.
- checksums.yaml +4 -4
- data/README.md +82 -25
- data/html2rss.gemspec +0 -1
- data/lib/html2rss/article.rb +1 -2
- data/lib/html2rss/auto_source/README.md +12 -9
- data/lib/html2rss/auto_source/cleanup.rb +92 -1
- data/lib/html2rss/auto_source/scraper/json_state/document_scanner.rb +3 -5
- data/lib/html2rss/auto_source/scraper/meta_oembed.rb +15 -5
- data/lib/html2rss/auto_source/scraper/microdata.rb +8 -6
- data/lib/html2rss/auto_source/scraper/native_feed.rb +93 -0
- data/lib/html2rss/auto_source/scraper/schema.rb +39 -21
- data/lib/html2rss/auto_source/scraper.rb +6 -4
- data/lib/html2rss/auto_source/segmenter/cluster.rb +2 -3
- data/lib/html2rss/auto_source/segmenter/primary_link.rb +1 -2
- data/lib/html2rss/auto_source/segmenter.rb +1 -2
- data/lib/html2rss/auto_source.rb +32 -11
- data/lib/html2rss/batch.rb +169 -0
- data/lib/html2rss/capture/README.md +15 -6
- data/lib/html2rss/capture.rb +158 -15
- data/lib/html2rss/channel.rb +1 -2
- data/lib/html2rss/cli/probe_view.rb +66 -0
- data/lib/html2rss/cli/render.rb +124 -0
- data/lib/html2rss/cli/validate.rb +91 -0
- data/lib/html2rss/cli.rb +318 -193
- data/lib/html2rss/config/auto_source_contract.rb +7 -0
- data/lib/html2rss/config/request_headers.rb +4 -14
- data/lib/html2rss/config/schema.rb +7 -5
- data/lib/html2rss/config/validator.rb +1 -0
- data/lib/html2rss/config.rb +64 -18
- data/lib/html2rss/doctor/botasaurus.rb +130 -0
- data/lib/html2rss/feed_builder/rss.rb +1 -2
- data/lib/html2rss/feed_pipeline/README.md +7 -5
- data/lib/html2rss/feed_pipeline/auto_fallback.rb +78 -19
- data/lib/html2rss/feed_pipeline/runtime_policy.rb +1 -0
- data/lib/html2rss/feed_pipeline.rb +48 -21
- data/lib/html2rss/feed_resolution/README.md +52 -0
- data/lib/html2rss/feed_resolution/candidate_generator.rb +139 -0
- data/lib/html2rss/feed_resolution/diag.rb +32 -0
- data/lib/html2rss/feed_resolution/options.rb +31 -0
- data/lib/html2rss/feed_resolution/policy.rb +47 -0
- data/lib/html2rss/feed_resolution/probe.rb +59 -0
- data/lib/html2rss/feed_resolution/scorer.rb +49 -0
- data/lib/html2rss/feed_resolution.rb +268 -0
- data/lib/html2rss/html/article_extractor/heading_extractor.rb +24 -14
- data/lib/html2rss/html/article_extractor.rb +4 -9
- data/lib/html2rss/html/feed_link.rb +4 -2
- data/lib/html2rss/html/navigator.rb +4 -39
- data/lib/html2rss/html/probe.rb +82 -0
- data/lib/html2rss/html/sst_article_extractor.rb +1 -2
- data/lib/html2rss/link_destination/noise_policy.rb +1 -2
- data/lib/html2rss/link_destination/path_classifier.rb +20 -19
- data/lib/html2rss/mcp/README.md +80 -0
- data/lib/html2rss/mcp/contract.rb +135 -13
- data/lib/html2rss/mcp/outcome/playbook.rb +122 -0
- data/lib/html2rss/mcp/outcome.rb +112 -40
- data/lib/html2rss/mcp/runtime.rb +45 -0
- data/lib/html2rss/mcp/server/tools.rb +277 -0
- data/lib/html2rss/mcp/server.rb +91 -232
- data/lib/html2rss/mcp.rb +3 -0
- data/lib/html2rss/page_recon/README.md +69 -0
- data/lib/html2rss/page_recon/diagnostics.rb +211 -0
- data/lib/html2rss/page_recon.rb +297 -0
- data/lib/html2rss/recon.rb +252 -0
- data/lib/html2rss/request_service/blocked_surface.rb +1 -0
- data/lib/html2rss/request_service/botasaurus_contract.rb +8 -1
- data/lib/html2rss/request_service/botasaurus_strategy.rb +1 -1
- data/lib/html2rss/request_service/faraday_strategy.rb +11 -7
- data/lib/html2rss/request_service/policy.rb +1 -2
- data/lib/html2rss/request_service/response.rb +27 -4
- data/lib/html2rss/request_service/strategy.rb +1 -2
- data/lib/html2rss/request_service.rb +15 -1
- data/lib/html2rss/request_session.rb +4 -2
- data/lib/html2rss/scrape_target.rb +24 -0
- data/lib/html2rss/selectors/post_processors/sanitize_html.rb +2 -4
- data/lib/html2rss/selectors/post_processors.rb +0 -1
- data/lib/html2rss/selectors.rb +33 -10
- data/lib/html2rss/sst/attrs.rb +1 -2
- data/lib/html2rss/sst/normalizer.rb +49 -11
- data/lib/html2rss/status.rb +48 -24
- data/lib/html2rss/surface_category.rb +63 -0
- data/lib/html2rss/syndication/README.md +21 -0
- data/lib/html2rss/syndication/candidate_catalog.rb +26 -0
- data/lib/html2rss/syndication/discovery.rb +215 -0
- data/lib/html2rss/syndication/parser.rb +137 -0
- data/lib/html2rss/syndication.rb +10 -0
- data/lib/html2rss/test/enhance_audit.rb +192 -0
- data/lib/html2rss/test.rb +458 -0
- data/lib/html2rss/url.rb +23 -2
- data/lib/html2rss/version.rb +1 -1
- data/lib/html2rss.rb +157 -35
- data/schema/html2rss-config.schema.json +47 -29
- metadata +34 -18
- data/lib/html2rss/mcp/inspect.rb +0 -241
- 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?(
|
|
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?(
|
|
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?(
|
|
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?(
|
|
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 &&
|
|
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? { |
|
|
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 |
|
|
121
|
-
|
|
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? &&
|
|
152
|
+
segments.any? && high_confidence_junk_at?(0)
|
|
152
153
|
end
|
|
153
154
|
|
|
154
155
|
def any_high_confidence_junk_segment?
|
|
155
|
-
segments.any? { |
|
|
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|
|
|
182
|
+
(0...limit).all? { |i| high_confidence_junk_at?(i) }
|
|
182
183
|
end
|
|
183
184
|
|
|
184
|
-
def
|
|
185
|
-
SEGMENT_SETS.fetch(:high_confidence_junk).include?(
|
|
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
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
-
|
|
213
|
-
|
|
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 +
|
|
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 +
|
|
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 +
|
|
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 +
|
|
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 +
|
|
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 +
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
#
|
|
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
|