html2rss 0.29.1 → 0.30.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 32c090eccd93210c9631778ff50ec7d32d36c7eccaa711cdd1a0dd5c3ea53616
4
- data.tar.gz: 93e4d2f253a019d7de585c8acaaa8d06143accb4f3a43a4f8d13f9c96d838390
3
+ metadata.gz: 2d839914f2d4d26e86998293a431a76168033523857ec8bc65f3f447441cb07e
4
+ data.tar.gz: fd0008a8d7a527b0a2128df3a5603a06c154d61b522f6a14decded9889615b78
5
5
  SHA512:
6
- metadata.gz: 93956c4dc3d35e06c81dff939d4ea5184da93834a0fb48f1b3dbd4dea21d615177ad45fa34e4f16abf396a184cc18acbe31c354df6764ba858ec340c78a3b08c
7
- data.tar.gz: b906784ed2ae53b80aa2fc00fe813550befcfac723d97346ffb87cff7f01ca6b0f5db0a6e4a2430dd10485c20f6d4b251a54a1b9e0462998f2ab628c0123a809
6
+ metadata.gz: 418b9fd5973f19985d7e80abb7734530689bff0753ec948813907f35fd1d2a139ec9fd26353c8c4c87ffc456f446be4a9cdfee9c406022fdfb6c6f0077f886fa
7
+ data.tar.gz: 1a470470f9bdaea585fdccd56e231c3308624e745a98b830bfd1f0861dad4ce22a98c4a2216306cfd893d6d271efc1d68c6efe15ea6e015d43776086db3094ad
data/README.md CHANGED
@@ -13,7 +13,7 @@ Most people looking for a first working feed should start with `html2rss-web`, r
13
13
  Detailed usage guides, reference docs, and the feed directory live on the project website:
14
14
 
15
15
  - [Ruby gem documentation](https://html2rss.github.io/ruby-gem)
16
- - [Request strategies](https://html2rss.github.io/ruby-gem/reference/strategy) (`auto` = `faraday` → `botasaurus`; or pin concrete strategies)
16
+ - [Request strategies](https://html2rss.github.io/ruby-gem/reference/strategy) (`auto` = `default` → `botasaurus`; or pin concrete strategies)
17
17
  - [Selectors & pagination](https://html2rss.github.io/ruby-gem/reference/selectors#paginated-feeds)
18
18
  - [Web application](https://html2rss.github.io/web-application)
19
19
  - [Feed directory](https://html2rss.github.io/feed-directory)
@@ -26,7 +26,7 @@ Cloud development: [Open in GitHub Codespaces](https://github.com/codespaces/new
26
26
  ## Architecture
27
27
 
28
28
  1. **Config** — loads and validates configuration (YAML/hash); schema via `html2rss schema` / `schema/html2rss-config.schema.json`
29
- 2. **RequestService** — fetches pages (`faraday`, `botasaurus`, or `local_file`)
29
+ 2. **RequestService** — fetches pages (`default` (HTTPX), `botasaurus`, or `local_file`)
30
30
  3. **Selectors** — extracts content via CSS selectors with extractors/post-processors
31
31
  4. **AutoSource** — auto-detects content (Schema.org, JSON state, semantic HTML, structural patterns)
32
32
  5. **FeedBuilder** — assembles Article objects and renders feeds (RSS 2.0 / JSON Feed 1.1)
@@ -157,7 +157,7 @@ Module guide: [`lib/html2rss/mcp/README.md`](lib/html2rss/mcp/README.md).
157
157
  | ----------------------- | --------------------------------------------------------------- |
158
158
  | `html2rss://schema` | Full JSON Schema for feed configurations |
159
159
  | `html2rss://extractors` | Registered extractor **names** (options live in schema `$defs`) |
160
- | `html2rss://strategies` | Published MCP strategies (`auto`, `faraday`, `botasaurus`) |
160
+ | `html2rss://strategies` | Published MCP strategies (`auto`, `default`, `httpx`, `botasaurus`) |
161
161
  | `html2rss://runtime` | `version`, `mcp_contract_version`, `catalog_fingerprint`, `tools`, `botasaurus_configured` (never the scraper URL) |
162
162
 
163
163
  ### Prompts
@@ -183,11 +183,11 @@ Set `BOTASAURUS_SCRAPER_URL` to `http://127.0.0.1:4010` and use strategy `botasa
183
183
 
184
184
  | Strategy | Description |
185
185
  | ------------ | --------------------------------------------------------------------------- |
186
- | `auto` | Tries `faraday`, falls back to `botasaurus` (default in gem/CLI/MCP scrape) |
187
- | `faraday` | Plain HTTP requests via Faraday |
186
+ | `auto` | Tries `default`, falls back to `botasaurus` (default in gem/CLI/MCP scrape) |
187
+ | `default` | Plain HTTP requests via HTTPX (alias: `httpx`; legacy: `faraday`) |
188
188
  | `botasaurus` | Puppeteer-backed scraping for JavaScript pages |
189
189
 
190
- `inspect` keeps Faraday when `auto` for cheap diagnostics. Elsewhere, strategy can be set via CLI (`--strategy`), gem API keyword argument, or feed config `request.strategy`. See the [request strategies docs](https://html2rss.github.io/ruby-gem/reference/strategy) for more details.
190
+ `inspect` keeps `default` when `auto` for cheap diagnostics. Elsewhere, strategy can be set via CLI (`--strategy`), gem API keyword argument, or feed config `strategy`. See the [request strategies docs](https://html2rss.github.io/ruby-gem/reference/strategy) for more details.
191
191
 
192
192
  ## License
193
193
 
data/html2rss.gemspec CHANGED
@@ -35,9 +35,7 @@ Gem::Specification.new do |spec|
35
35
  spec.add_dependency 'addressable', '~> 2.7'
36
36
  spec.add_dependency 'brotli'
37
37
  spec.add_dependency 'dry-validation'
38
- spec.add_dependency 'faraday', '> 2.0.1', '< 3.0'
39
- spec.add_dependency 'faraday-follow_redirects'
40
- spec.add_dependency 'faraday-gzip', '~> 3'
38
+ spec.add_dependency 'httpx', '~> 1.8'
41
39
  spec.add_dependency 'kramdown'
42
40
  spec.add_dependency 'mcp', '~> 1.2'
43
41
  spec.add_dependency 'mime-types', '> 3.0'
@@ -77,7 +77,7 @@ module Html2rss
77
77
  # Scrapes multiple URLs in parallel with per-URL error isolation.
78
78
  #
79
79
  # @param urls [Enumerable<String>] list of URLs to scrape
80
- # @param strategy [Symbol, String] request strategy (+:auto+, +:faraday+, +:botasaurus+)
80
+ # @param strategy [Symbol, String] request strategy (+:auto+, +:default+, +:botasaurus+)
81
81
  # @param limit [Integer] max articles to extract per URL
82
82
  # @param concurrency [Integer] number of worker threads (default 5, max 10)
83
83
  # @return [BatchResult]
@@ -89,7 +89,7 @@ module Html2rss
89
89
  # Inspects multiple URLs in parallel with per-URL error isolation.
90
90
  #
91
91
  # @param urls [Enumerable<String>] list of URLs to inspect
92
- # @param strategy [Symbol, String] request strategy (+:auto+, +:faraday+, +:botasaurus+)
92
+ # @param strategy [Symbol, String] request strategy (+:auto+, +:default+, +:botasaurus+)
93
93
  # @param concurrency [Integer] number of worker threads (default 5, max 10)
94
94
  # @return [BatchResult]
95
95
  def batch_inspect(urls:, strategy: :auto, concurrency: DEFAULT_CONCURRENCY)
@@ -104,7 +104,7 @@ module Html2rss
104
104
  # Runs recon across multiple URLs in parallel with per-URL error isolation.
105
105
  #
106
106
  # @param urls [Enumerable<String>] list of URLs to recon
107
- # @param strategy [Symbol, String] request strategy (+:auto+, +:faraday+, +:botasaurus+)
107
+ # @param strategy [Symbol, String] request strategy (+:auto+, +:default+, +:botasaurus+)
108
108
  # @param concurrency [Integer] number of worker threads (default 5, max 10)
109
109
  # @option options [String, nil] :cache_dir optional HTML cache directory
110
110
  # @return [BatchResult]
@@ -40,7 +40,7 @@ Html2rss.capture('https://example.com', strategy: :local_file, local_file_path:
40
40
  Html2rss.capture('https://example.com', max_redirects: 8, max_requests: 4)
41
41
  ```
42
42
 
43
- `strategy: :auto` uses the same AutoFallback chain as scrape (`faraday` → `botasaurus`). When AutoFallback selects a concrete strategy (or you pin one), Capture **stamps** `strategy:` into the emitted config so later `Html2rss.apply(config)` replays the same transport.
43
+ `strategy: :auto` uses the same AutoFallback chain as scrape (`default` → `botasaurus`). When AutoFallback selects a concrete strategy (or you pin one), Capture **stamps** `strategy:` into the emitted config so later `Html2rss.apply(config)` replays the same transport.
44
44
 
45
45
  ## CLI
46
46
 
@@ -75,7 +75,7 @@ module Html2rss
75
75
  # Analyzes a URL and builds a reusable feed config.
76
76
  #
77
77
  # @param url [String] source page URL
78
- # @param strategy [Symbol] request strategy (+:auto+, +:faraday+, +:botasaurus+)
78
+ # @param strategy [Symbol] request strategy (+:auto+, +:default+, +:botasaurus+)
79
79
  # @option options [String, nil] :items_selector optional selector hint
80
80
  # @option options [Array<String>, nil] :topics optional directory topics override
81
81
  # @option options [String, nil] :title optional title override
@@ -24,7 +24,6 @@ module Html2rss
24
24
  DEFAULT_HEADERS = {
25
25
  'Accept' => DEFAULT_ACCEPT,
26
26
  'Cache-Control' => 'max-age=0',
27
- 'Connection' => 'keep-alive',
28
27
  'Sec-Fetch-Dest' => 'document',
29
28
  'Sec-Fetch-Mode' => 'navigate',
30
29
  'Sec-Fetch-Site' => 'none',
@@ -33,6 +32,9 @@ module Html2rss
33
32
  'User-Agent' => DEFAULT_USER_AGENT
34
33
  }.freeze
35
34
 
35
+ # Hop-by-hop headers forbidden in HTTP/2 requests (RFC 7540 §8.1.2.2 / RFC 9113 §8.2.1).
36
+ FORBIDDEN_H2_HEADERS = %w[connection keep-alive proxy-connection transfer-encoding upgrade].to_set.freeze
37
+
36
38
  class << self
37
39
  ##
38
40
  # :reek:ManualDispatch
@@ -75,7 +77,7 @@ module Html2rss
75
77
  ##
76
78
  # @return [Hash{String => String}] normalized HTTP headers
77
79
  def to_h
78
- defaults = DEFAULT_HEADERS.dup
80
+ defaults = self.class.browser_defaults
79
81
  normalized = normalize_custom_headers(headers)
80
82
 
81
83
  accept_override = normalized.delete('Accept')
@@ -84,6 +86,7 @@ module Html2rss
84
86
  defaults['Accept'] = normalize_accept(accept_override)
85
87
  defaults['Accept-Language'] = build_accept_language
86
88
 
89
+ defaults.reject! { |key, _| FORBIDDEN_H2_HEADERS.include?(key.downcase) }
87
90
  defaults.compact
88
91
  end
89
92
 
@@ -93,6 +93,18 @@ module Html2rss
93
93
  Validator.new.call(prepared_config)
94
94
  end
95
95
 
96
+ ##
97
+ # Loads and validates a YAML configuration file.
98
+ #
99
+ # @param file [String] the YAML file to load
100
+ # @param feed_name [String, nil] optional feed name for multi-feed files
101
+ # @param multiple_feeds_key [Symbol] key under which multiple feeds are defined
102
+ # @param params [Hash{Symbol => Object, Hash{String => Object, nil}}] dynamic parameters for string formatting
103
+ # @return [Dry::Validation::Result, Html2rss::Config::ValidationResult]
104
+ def validate_yaml(file, feed_name = nil, multiple_feeds_key: MultipleFeedsConfig::CONFIG_KEY_FEEDS, params: UNSET)
105
+ validate(load_yaml(file, feed_name, multiple_feeds_key:), params:)
106
+ end
107
+
96
108
  ##
97
109
  # Returns the packaged JSON Schema file path.
98
110
  #
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'faraday'
3
+ require 'httpx'
4
4
  require 'json'
5
5
 
6
6
  module Html2rss
@@ -68,15 +68,23 @@ module Html2rss
68
68
  # @param base_url [String]
69
69
  # @return [Hash{Symbol => Object}]
70
70
  def health_check(base_url)
71
- uri = Url.for_channel(base_url)
72
- client = Faraday.new(url: uri.to_s.chomp('/'), request: { timeout: 5 })
73
- response = client.get('/health')
74
- body = JSON.parse(response.body)
71
+ endpoint = "#{Url.for_channel(base_url).to_s.chomp('/')}/health"
72
+ response = health_session.get(endpoint)
73
+ raise response.error if response.is_a?(HTTPX::ErrorResponse)
74
+
75
+ body = JSON.parse(response.body.to_s)
75
76
  { ok: response.status == 200, status: response.status, version: body['version'] }
76
77
  rescue StandardError => error
77
78
  { ok: false, error: "#{error.class}: #{error.message}" }
78
79
  end
79
80
 
81
+ def health_session
82
+ HTTPX.with(timeout: { operation_timeout: 5, request_timeout: 5, total_request_timeout: 5 },
83
+ resolver_class: :system)
84
+ end
85
+ module_function :health_session
86
+ private_class_method :health_session
87
+
80
88
  ##
81
89
  # @param url [String]
82
90
  # @return [Hash{Symbol => Object}]
@@ -2,25 +2,25 @@
2
2
 
3
3
  `:auto` is the default request plan for feed builds (`auto_source`, `auto_json_feed`, Capture, and MCP `scrape` / `capture`). `FeedPipeline::StrategyPlan` resolves it; `FeedPipeline::AutoFallback` executes `AutoFallback::CHAIN`.
4
4
 
5
- Use `:auto` when you want Faraday first and a browser-backed hop only if that fetch fails or yields zero items. Pin a concrete strategy (`faraday`, `botasaurus`, `local_file`) when you need a single transport.
5
+ Use `:auto` when you want `default` (HTTPX) first and a browser-backed hop only if that fetch fails or yields zero items. Pin a concrete strategy (`default`, `botasaurus`, `local_file`) when you need a single transport.
6
6
 
7
7
  ## Chain
8
8
 
9
9
  `AutoFallback::CHAIN` is:
10
10
 
11
- 1. **Faraday** — plain HTTP (faster, cheaper).
12
- 2. **Botasaurus** — attempted when Faraday raises a fallback-eligible error (for example `BlockedSurfaceDetected` or timeout) or extracts zero feed items.
11
+ 1. **Default (HTTPX)** — plain HTTP (faster, cheaper).
12
+ 2. **Botasaurus** — attempted when default raises a fallback-eligible error (for example `BlockedSurfaceDetected` or timeout) or extracts zero feed items.
13
13
 
14
- Before escalating Faraday → Botasaurus on a **weak** auto-source extract (empty / below floor / high-entropy / app-shell / unsupported — not blocked), `FeedResolution` may probe up to five same-origin listing or native-feed candidates and rewrite the effective scrape URL via {ScrapeTarget}. Botasaurus then uses the resolved URL. Direct RSS/Atom entry URLs are parsed via `Syndication::Parser` without HTML AutoSource.
14
+ Before escalating default → Botasaurus on a **weak** auto-source extract (empty / below floor / high-entropy / app-shell / unsupported — not blocked), `FeedResolution` may probe up to five same-origin listing or native-feed candidates and rewrite the effective scrape URL via {ScrapeTarget}. Botasaurus then uses the resolved URL. Direct RSS/Atom entry URLs are parsed via `Syndication::Parser` without HTML AutoSource.
15
15
 
16
- There is no Browserless / Puppeteer-in-gem tier. Pin `botasaurus` when you want browser rendering without Faraday first. Botasaurus needs `BOTASAURUS_SCRAPER_URL`.
16
+ There is no Browserless / Puppeteer-in-gem tier. Pin `botasaurus` when you want browser rendering without default first. Botasaurus needs `BOTASAURUS_SCRAPER_URL`.
17
17
 
18
18
  ## Surfaces
19
19
 
20
20
  | Surface | `:auto` behavior |
21
21
  | ---------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
22
- | Gem / CLI `apply`, MCP `scrape`, Capture | Full AutoFallback chain (`faraday` → `botasaurus`) |
23
- | MCP `inspect` | Cheap diagnostic: `StrategyPlan.concrete_for_diagnostic` maps `auto` to Faraday (pin `botasaurus` when you need browser rendering) |
22
+ | Gem / CLI `apply`, MCP `scrape`, Capture | Full AutoFallback chain (`default` → `botasaurus`) |
23
+ | MCP `inspect` | Cheap diagnostic: `StrategyPlan.concrete_for_diagnostic` maps `auto` to default (pin `botasaurus` when you need browser rendering) |
24
24
 
25
25
  ## Fallback vs abort
26
26
 
@@ -28,7 +28,7 @@ These typically hop to the next chain member (among other `StandardError`s `Auto
28
28
 
29
29
  - `Html2rss::RequestService::BlockedSurfaceDetected`
30
30
  - `Html2rss::RequestService::RequestTimedOut`
31
- - Faraday connection / timeout errors
31
+ - HTTPX connection / timeout errors
32
32
  - Empty extraction results when a later chain member may succeed
33
33
 
34
34
  These abort immediately (`AutoFallback::NON_FALLBACK_ERRORS`): unknown strategy, invalid URL, unsupported scheme, budget exceeded, private network denied, cross-origin follow-up denied, response too large.
@@ -8,7 +8,7 @@ module Html2rss
8
8
  # Hosted by the pipeline instance: session + extract call back into FeedPipeline.
9
9
  class AutoFallback # rubocop:disable Metrics/ClassLength -- attempt state + logging stay co-located
10
10
  # Ordered list of concrete request strategies attempted by the :auto plan.
11
- CHAIN = %i[faraday botasaurus].freeze
11
+ CHAIN = %i[default botasaurus].freeze
12
12
 
13
13
  # Deterministic HTTP statuses that indicate permanent failure rather than anti-bot challenges.
14
14
  DETERMINISTIC_HTTP_STATUSES = Set[400, 404, 410, 422, 451].freeze
@@ -42,7 +42,7 @@ module Html2rss
42
42
  end
43
43
 
44
44
  ##
45
- # Cheap single-request diagnostic default: +:auto+ → +:faraday+.
45
+ # Cheap single-request diagnostic default: +:auto+ → +:default+.
46
46
  # Does not run {AutoFallback}. Prefer {resolve} for scrape/capture feeds.
47
47
  #
48
48
  # @param name [Symbol, String, nil] plan name (+nil+ → +:auto+)
@@ -20,7 +20,7 @@ Batch: `batch_inspect`, `batch_recon`, `batch_scrape`.
20
20
 
21
21
  ## Decision tree
22
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`.
23
+ 1. **Articles now (no saved config)?** → `scrape` (or `batch_scrape` for multiple URLs). `strategy: "auto"` runs default (HTTPX) → Botasaurus; do not retry with explicit `default` after `auto`.
24
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
25
  3. **Weak scrape/capture or recon?** → `inspect` (or `batch_inspect`). When alternates warrant it, follow `next_step` to `recon`.
26
26
  4. **Config already in hand?** → `validate` (schema only) → `test` → `apply`.
@@ -61,7 +61,7 @@ Do not duplicate playbook prose in `server.rb`.
61
61
  | ----------------------- | ---------------------------------------------------------- |
62
62
  | `html2rss://schema` | Full JSON Schema for feed configurations |
63
63
  | `html2rss://extractors` | Registered extractor names |
64
- | `html2rss://strategies` | Published MCP strategies (`auto`, `faraday`, `botasaurus`) |
64
+ | `html2rss://strategies` | Published MCP strategies (`auto`, `default`, `httpx`, `botasaurus`) |
65
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
66
 
67
67
  ## Prompts
@@ -73,7 +73,7 @@ Do not duplicate playbook prose in `server.rb`.
73
73
 
74
74
  ## Strategy note
75
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.
76
+ `scrape` / `capture` with `strategy: "auto"` run the full AutoFallback chain. `inspect` maps `auto` to default (HTTPX) for cheap diagnostics; pin `botasaurus` when you need browser rendering for inspect.
77
77
 
78
78
  ## Inspect redirects
79
79
 
@@ -9,7 +9,11 @@ module Html2rss
9
9
  # annotations, and the single compact JSON envelope response.
10
10
  module Contract # rubocop:disable Metrics/ModuleLength -- published listing constants stay co-located
11
11
  # Published MCP request strategies (excludes +local_file+).
12
- STRATEGIES = %w[auto faraday botasaurus].freeze
12
+ STRATEGIES = %w[auto default httpx botasaurus].freeze
13
+ # Accepted migration strategies retained for backwards compatibility.
14
+ MIGRATION_STRATEGIES = %w[faraday].freeze
15
+ # Complete set of strategies accepted by {.assert_published_request!}.
16
+ ALL_ACCEPTED_STRATEGIES = (STRATEGIES + MIGRATION_STRATEGIES).freeze
13
17
 
14
18
  # Bump when tool names, required inputs, or envelope semantics change (independent of gem +VERSION+).
15
19
  MCP_CONTRACT_VERSION = 2
@@ -30,12 +34,12 @@ module Html2rss
30
34
  type: 'string',
31
35
  enum: STRATEGIES,
32
36
  default: 'auto',
33
- description: 'Request strategy (auto runs faraday → botasaurus fallback chain)'
37
+ description: 'Request strategy (auto runs default → botasaurus fallback chain)'
34
38
  }.freeze
35
39
 
36
- # JSON Schema property for inspect +strategy+ (auto stays on Faraday).
40
+ # JSON Schema property for inspect +strategy+ (auto stays on default).
37
41
  INSPECT_STRATEGY_PROPERTY = STRATEGY_PROPERTY.merge(
38
- description: 'Request strategy (auto uses Faraday for cheap diagnostics; pin botasaurus when needed)'
42
+ description: 'Request strategy (auto uses default for cheap diagnostics; pin botasaurus when needed)'
39
43
  ).freeze
40
44
 
41
45
  # JSON Schema +oneOf+ requiring exactly one of +config+ or +yaml+.
@@ -281,7 +285,7 @@ module Html2rss
281
285
  # or +request.local_file_path+ is present
282
286
  def assert_published_request!(config)
283
287
  strategy = config[:strategy]
284
- unless strategy.nil? || STRATEGIES.include?(strategy.to_s)
288
+ unless strategy.nil? || ALL_ACCEPTED_STRATEGIES.include?(strategy.to_s)
285
289
  raise UnpublishedRequestError,
286
290
  "MCP does not accept strategy #{strategy} (published: #{STRATEGIES.join(', ')})"
287
291
  end
@@ -36,7 +36,7 @@ module Html2rss
36
36
  html2rss MCP — decide which tool to call:
37
37
 
38
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.
39
+ - strategy "auto" runs default (HTTPX) → Botasaurus AutoFallback. Do not retry with explicit default after auto.
40
40
  - Empty scrape is still success (articles-now). Follow next_step / guidance (read_runtime if Botasaurus unset).
41
41
  2. Need a reusable feed YAML? → capture → test → apply
42
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.
@@ -58,9 +58,9 @@ module Html2rss
58
58
  # @return [String]
59
59
  def scrape_webpage_prompt(url)
60
60
  <<~MSG.strip
61
- Scrape #{url} with scrape (strategy auto). One call is enough — auto already runs Faraday then Botasaurus.
61
+ Scrape #{url} with scrape (strategy auto). One call is enough — auto already runs default then Botasaurus.
62
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.
63
+ Do not retry scrape with explicit default after auto. Read html2rss://runtime if next_step is read_runtime.
64
64
  Return payload.items (not a raw JSON array).
65
65
  MSG
66
66
  end
@@ -72,7 +72,7 @@ module Html2rss
72
72
  <<~MSG.strip
73
73
  Build a reusable html2rss feed config for #{url}:
74
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.
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 default was blocked.
76
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
77
  4) apply — isError if zero items. Confirm payload.item_count and payload.quality_report (including enhance_gains) before shipping.
78
78
  If the destination is html2rss-configs, rewrite the draft for directory.topics and explicit channel title/url. Return YAML.
@@ -95,7 +95,7 @@ module Html2rss
95
95
  if data[:blocked_surface] || data[:surface_category].to_s == 'blocked_surface'
96
96
  return 'Blocked or anti-bot interstitial likely. Retry scrape with strategy botasaurus once ' \
97
97
  '(or CLI inspect --deep when BOTASAURUS_SCRAPER_URL is set). ' \
98
- 'Do not retry explicit faraday after auto.'
98
+ 'Do not retry explicit default after auto.'
99
99
  end
100
100
  if data[:likely_js_shell]
101
101
  return 'JS-rendered shell likely (html_present, zero articles). Use strategy auto or botasaurus; ' \
@@ -13,7 +13,7 @@ module Html2rss
13
13
  kind: :url,
14
14
  description: 'One-shot article extraction as JSON Feed items. ' \
15
15
  'Use when you need articles now without a saved config. ' \
16
- 'strategy "auto" triggers fallback chain (faraday → botasaurus) for JS-rendered sites.',
16
+ 'strategy "auto" triggers fallback chain (default → botasaurus) for JS-rendered sites.',
17
17
  input_schema: Contract::SCRAPE_INPUT_SCHEMA,
18
18
  handler: :scrape_outcome
19
19
  },
@@ -10,7 +10,7 @@ module Html2rss
10
10
  # This module maps MCP kwargs to those APIs, then {Outcome} + {Contract} shape the envelope.
11
11
  #
12
12
  # Strategy note: MCP +auto+ passes through to FeedPipeline AutoFallback
13
- # (faraday → botasaurus). Concrete strategies are used as-is.
13
+ # (default → botasaurus). Concrete strategies are used as-is.
14
14
  # Botasaurus requires +BOTASAURUS_SCRAPER_URL+.
15
15
  module Server # rubocop:disable Metrics/ModuleLength
16
16
  # MCP server display name.
@@ -111,7 +111,7 @@ module Html2rss
111
111
  # Runs reconnaissance on a single URL.
112
112
  #
113
113
  # @param url [String, Html2rss::Url] source page URL
114
- # @param strategy [Symbol] request strategy (:auto, :faraday, :botasaurus)
114
+ # @param strategy [Symbol] request strategy (:auto, :default, :botasaurus)
115
115
  # @param cache_dir [String, nil] optional directory to cache raw HTML bodies
116
116
  # @param cache_mutex [Mutex, nil] optional mutex serializing cache writes
117
117
  # @option options [Integer, nil] :max_redirects optional maximum redirects
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'faraday'
3
+ require 'httpx'
4
4
  require 'json'
5
5
  require 'securerandom'
6
6
 
@@ -11,7 +11,7 @@ module Html2rss
11
11
  class BotasaurusStrategy < Strategy
12
12
  # Content-Type negotiation for the scrape API transport hop.
13
13
  TRANSPORT_ACCEPT = 'application/json'
14
- # Disable compressed bodies so Faraday returns raw JSON without implicit decoding surprises.
14
+ # Disable compressed bodies so HTTPX returns raw JSON without implicit decoding surprises.
15
15
  TRANSPORT_ENCODING = 'identity'
16
16
  # Correlates each POST /scrape with botasaurus-scrape-api request logs.
17
17
  REQUEST_ID_HEADER = 'X-Request-Id'
@@ -28,10 +28,15 @@ module Html2rss
28
28
  def post_scrape_request
29
29
  request_id = SecureRandom.uuid
30
30
  Log.debug("#{self.class}: POST /scrape #{REQUEST_ID_HEADER}=#{request_id}")
31
- transport_response = client.post('/scrape', JSON.generate(contract.request_payload), post_headers(request_id))
32
- contract.parse_response(transport_response)
31
+ payload = JSON.generate(contract.request_payload)
32
+ response = client.post(scrape_endpoint, body: payload, headers: post_headers(request_id))
33
+ raise response.error if response.is_a?(HTTPX::ErrorResponse)
34
+
35
+ contract.parse_response(response)
33
36
  end
34
37
 
38
+ def scrape_endpoint = "#{scraper_base_url.to_s.chomp('/')}/scrape"
39
+
35
40
  def build_response(parsed_response)
36
41
  Response.new(
37
42
  body: parsed_response.html,
@@ -44,22 +49,14 @@ module Html2rss
44
49
  end
45
50
 
46
51
  def raise_from_error!(error)
47
- raise_if_challenge_blocked!(error)
48
- raise_if_timed_out!(error)
49
- raise BotasaurusServiceError, error.failure_message
50
- end
51
-
52
- def raise_if_challenge_blocked!(error)
53
- return unless error.challenge_block?
52
+ raise BlockedSurfaceDetected, "Blocked surface detected: #{error.challenge_message}" if error.challenge_block?
54
53
 
55
- raise BlockedSurfaceDetected, "Blocked surface detected: #{error.challenge_message}"
56
- end
57
-
58
- def raise_if_timed_out!(error)
59
- return unless error.timeout?
54
+ if error.timeout?
55
+ log_timeout!(reason: 'botasaurus_upstream')
56
+ raise RequestTimedOut.new(error.failure_message, timeout_phase: error.timeout_phase)
57
+ end
60
58
 
61
- log_timeout!(reason: 'botasaurus_upstream')
62
- raise RequestTimedOut.new(error.failure_message, timeout_phase: error.timeout_phase)
59
+ raise BotasaurusServiceError, error.failure_message
63
60
  end
64
61
 
65
62
  def response_url(final_url)
@@ -79,8 +76,18 @@ module Html2rss
79
76
  end
80
77
 
81
78
  def client
82
- # No :gzip middleware on this client — compression is for remote target fetches only.
83
- @client ||= Faraday.new(url: scraper_base_url.to_s, headers: client_headers, request: request_options)
79
+ timeout = attempt_timeout_seconds.to_f
80
+ @client ||= HTTPX.with(headers: client_headers, timeout: client_timeouts(timeout), resolver_class: :system)
81
+ end
82
+
83
+ def client_timeouts(timeout)
84
+ {
85
+ operation_timeout: timeout,
86
+ connect_timeout: [ctx.policy.connect_timeout_seconds, timeout].min,
87
+ read_timeout: [ctx.policy.read_timeout_seconds, timeout].min,
88
+ request_timeout: timeout,
89
+ total_request_timeout: timeout
90
+ }
84
91
  end
85
92
 
86
93
  def client_headers
@@ -92,14 +99,7 @@ module Html2rss
92
99
  end
93
100
 
94
101
  def post_headers(request_id)
95
- {
96
- 'Content-Type' => 'application/json',
97
- REQUEST_ID_HEADER => request_id
98
- }
99
- end
100
-
101
- def request_options
102
- { timeout: attempt_timeout_seconds.to_i }
102
+ { 'Content-Type' => 'application/json', REQUEST_ID_HEADER => request_id }
103
103
  end
104
104
 
105
105
  def attempt_timeout_seconds
@@ -123,6 +123,10 @@ module Html2rss
123
123
  def translate_connection_error(error)
124
124
  raise BotasaurusConnectionFailed, "Botasaurus connection failed: #{error.message}"
125
125
  end
126
+
127
+ def timeout_error?(error) = error.is_a?(HTTPX::TimeoutError) || super
128
+
129
+ def connection_error?(error) = error.is_a?(HTTPX::ConnectionError) || error.is_a?(HTTPX::TLSError) || super
126
130
  end
127
131
  end
128
132
  end
@@ -45,9 +45,12 @@ module Html2rss
45
45
 
46
46
  def inflate(raw, encoding)
47
47
  case encoding
48
- when 'gzip' then uncompress_gzip(raw)
49
- when 'deflate' then inflate_deflate(raw)
48
+ when 'gzip'
49
+ return raw unless raw.start_with?(GZIP_MAGIC)
50
+
51
+ uncompress_gzip(raw)
50
52
  when 'br' then try_brotli(raw, require_html: false)
53
+ when 'deflate' then inflate_deflate(raw)
51
54
  end
52
55
  rescue Zlib::Error, Brotli::Error, ArgumentError
53
56
  nil
@@ -55,12 +58,6 @@ module Html2rss
55
58
  module_function :inflate
56
59
  private_class_method :inflate
57
60
 
58
- def uncompress_gzip(raw)
59
- Zlib::GzipReader.wrap(StringIO.new(raw), encoding: 'ASCII-8BIT', &:read)
60
- end
61
- module_function :uncompress_gzip
62
- private_class_method :uncompress_gzip
63
-
64
61
  def inflate_deflate(raw)
65
62
  inflater = nil
66
63
  Zlib::Inflate.inflate(raw)
@@ -73,6 +70,14 @@ module Html2rss
73
70
  module_function :inflate_deflate
74
71
  private_class_method :inflate_deflate
75
72
 
73
+ def uncompress_gzip(raw)
74
+ Zlib::GzipReader.wrap(StringIO.new(raw), encoding: 'ASCII-8BIT', &:read)
75
+ rescue Zlib::Error
76
+ nil
77
+ end
78
+ module_function :uncompress_gzip
79
+ private_class_method :uncompress_gzip
80
+
76
81
  def try_brotli(raw, require_html: true)
77
82
  inflated = Brotli.inflate(raw)
78
83
  return inflated unless require_html