html2rss 0.28.0 → 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 (80) 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 +8 -8
  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 +5 -2
  11. data/lib/html2rss/auto_source/scraper/schema.rb +39 -21
  12. data/lib/html2rss/auto_source/scraper.rb +2 -3
  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 +25 -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/request_headers.rb +4 -14
  26. data/lib/html2rss/config/schema.rb +7 -5
  27. data/lib/html2rss/config/validator.rb +1 -0
  28. data/lib/html2rss/config.rb +59 -22
  29. data/lib/html2rss/doctor/botasaurus.rb +130 -0
  30. data/lib/html2rss/feed_builder/rss.rb +1 -2
  31. data/lib/html2rss/feed_pipeline/README.md +5 -5
  32. data/lib/html2rss/feed_pipeline/auto_fallback.rb +29 -10
  33. data/lib/html2rss/feed_pipeline.rb +27 -16
  34. data/lib/html2rss/feed_resolution/README.md +11 -11
  35. data/lib/html2rss/feed_resolution.rb +6 -12
  36. data/lib/html2rss/html/article_extractor/heading_extractor.rb +24 -14
  37. data/lib/html2rss/html/article_extractor.rb +4 -9
  38. data/lib/html2rss/html/feed_link.rb +4 -2
  39. data/lib/html2rss/html/navigator.rb +4 -39
  40. data/lib/html2rss/html/probe.rb +82 -0
  41. data/lib/html2rss/html/sst_article_extractor.rb +1 -2
  42. data/lib/html2rss/link_destination/noise_policy.rb +1 -2
  43. data/lib/html2rss/link_destination/path_classifier.rb +20 -19
  44. data/lib/html2rss/mcp/README.md +80 -0
  45. data/lib/html2rss/mcp/contract.rb +135 -13
  46. data/lib/html2rss/mcp/outcome/playbook.rb +122 -0
  47. data/lib/html2rss/mcp/outcome.rb +112 -42
  48. data/lib/html2rss/mcp/runtime.rb +45 -0
  49. data/lib/html2rss/mcp/server/tools.rb +277 -0
  50. data/lib/html2rss/mcp/server.rb +91 -232
  51. data/lib/html2rss/mcp.rb +3 -0
  52. data/lib/html2rss/page_recon/README.md +69 -0
  53. data/lib/html2rss/page_recon/diagnostics.rb +211 -0
  54. data/lib/html2rss/page_recon.rb +56 -2
  55. data/lib/html2rss/recon.rb +252 -0
  56. data/lib/html2rss/request_service/blocked_surface.rb +1 -0
  57. data/lib/html2rss/request_service/faraday_strategy.rb +11 -7
  58. data/lib/html2rss/request_service/policy.rb +1 -2
  59. data/lib/html2rss/request_service/response.rb +3 -4
  60. data/lib/html2rss/request_service/strategy.rb +1 -2
  61. data/lib/html2rss/request_service.rb +2 -0
  62. data/lib/html2rss/request_session.rb +1 -2
  63. data/lib/html2rss/scrape_target.rb +1 -1
  64. data/lib/html2rss/selectors/post_processors/sanitize_html.rb +2 -4
  65. data/lib/html2rss/selectors/post_processors.rb +0 -1
  66. data/lib/html2rss/selectors.rb +33 -10
  67. data/lib/html2rss/sst/attrs.rb +1 -2
  68. data/lib/html2rss/sst/normalizer.rb +49 -11
  69. data/lib/html2rss/status.rb +3 -6
  70. data/lib/html2rss/syndication/README.md +6 -6
  71. data/lib/html2rss/syndication/discovery.rb +2 -4
  72. data/lib/html2rss/test/enhance_audit.rb +192 -0
  73. data/lib/html2rss/test.rb +458 -0
  74. data/lib/html2rss/url.rb +1 -2
  75. data/lib/html2rss/version.rb +1 -1
  76. data/lib/html2rss.rb +157 -35
  77. data/schema/html2rss-config.schema.json +9 -29
  78. metadata +17 -18
  79. data/lib/html2rss/mcp/inspect.rb +0 -138
  80. data/lib/html2rss/selectors/post_processors/html_to_markdown.rb +0 -59
@@ -19,11 +19,10 @@ module Html2rss
19
19
  # @option opts [Integer] :use_top_selectors list strategy selector budget
20
20
  # @option opts [Scoring::LinkResolver, nil] :link_resolver page-scoped destination facts cache
21
21
  # @return [Array<Segment>]
22
- # rubocop:disable Style/ArgumentsForwarding -- keep named opts for YARD @option
22
+ # rubocop:disable-next Style/ArgumentsForwarding -- keep named opts for YARD @option
23
23
  def self.call(document, base_url:, strategy:, **opts)
24
24
  new(document, base_url:, strategy:, **opts).call
25
25
  end
26
- # rubocop:enable Style/ArgumentsForwarding
27
26
 
28
27
  # @param document [SST::Document]
29
28
  # @param base_url [String, Html2rss::Url]
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'dry-validation'
4
+ require 'json'
5
+ require 'nokogiri'
4
6
 
5
7
  module Html2rss
6
8
  ##
@@ -20,6 +22,14 @@ module Html2rss
20
22
  # Default max articles to keep (also the short-circuit floor across scraper tiers).
21
23
  DEFAULT_LIMIT = 25
22
24
 
25
+ # Operational scraper failures quarantined within a tier; programmer errors propagate.
26
+ OPERATIONAL_ERRORS = [
27
+ Html2rss::Error,
28
+ ArgumentError,
29
+ ::JSON::ParserError,
30
+ ::Nokogiri::SyntaxError
31
+ ].freeze
32
+
23
33
  # Default auto-source configuration shipped for scraper and cleanup behavior.
24
34
  DEFAULT_CONFIG = {
25
35
  limit: DEFAULT_LIMIT,
@@ -138,7 +148,7 @@ module Html2rss
138
148
 
139
149
  attr_reader :url, :parsed_body, :body, :request_session, :captured_responses
140
150
 
141
- # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
151
+ # rubocop:disable-next Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
142
152
  def extract_articles
143
153
  articles = []
144
154
  matched = false
@@ -183,7 +193,6 @@ module Html2rss
183
193
  @admission_drops = result.drop_tallies
184
194
  result.articles.first(article_limit)
185
195
  end
186
- # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
187
196
 
188
197
  def enough_articles?(articles)
189
198
  return false if articles.size < article_limit
@@ -211,15 +220,20 @@ module Html2rss
211
220
  end
212
221
 
213
222
  def run_scraper(instance)
214
- instance.each.map do |item|
215
- case item
216
- when Article
217
- item
218
- when Hash
219
- Article.new(**item, scraper: instance.class)
220
- else
221
- raise TypeError, "#{instance.class} yielded #{item.class}; expected Article or Hash"
222
- end
223
+ instance.map { |item| article_from_scraper_item(item, instance) }
224
+ rescue *OPERATIONAL_ERRORS => error
225
+ Log.warn("#{self.class}: #{instance.class} quarantined #{error.class}: #{error.message}")
226
+ []
227
+ end
228
+
229
+ def article_from_scraper_item(item, instance)
230
+ case item
231
+ when Article
232
+ item
233
+ when Hash
234
+ Article.new(**item, scraper: instance.class)
235
+ else
236
+ raise TypeError, "#{instance.class} yielded #{item.class}; expected Article or Hash"
223
237
  end
224
238
  end
225
239
 
@@ -0,0 +1,169 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ ##
5
+ # Concurrency-bounded batch execution over enumerables with input order preservation
6
+ # and per-item error isolation.
7
+ module Batch
8
+ # Maximum permitted worker threads for batch operations.
9
+ MAX_CONCURRENCY = 10
10
+ # Default worker threads for batch operations.
11
+ DEFAULT_CONCURRENCY = 5
12
+
13
+ # Value object representing the outcome of a batch operation.
14
+ BatchResult = Data.define(:total, :successful, :results) do
15
+ ##
16
+ # @return [Hash{Symbol => Object}]
17
+ def to_h
18
+ { total:, successful:, results: }
19
+ end
20
+ end
21
+
22
+ class << self
23
+ ##
24
+ # Executes a block concurrently across items using a bounded worker pool.
25
+ # Results are returned in input order.
26
+ #
27
+ # @param items [Enumerable<Object>]
28
+ # @param concurrency [Integer] max parallel worker threads (1..10, default: 5)
29
+ # @yieldparam item [Object]
30
+ # @return [Array<Object>]
31
+ def map(items, concurrency: DEFAULT_CONCURRENCY, &)
32
+ list = Array(items)
33
+ return [] if list.empty?
34
+
35
+ worker_count = worker_pool_size(list.size, concurrency)
36
+ process_queue(list, worker_count, &)
37
+ end
38
+
39
+ ##
40
+ # Runs a batch operation that yields items and wraps the results in a {BatchResult}.
41
+ #
42
+ # @param items [Enumerable<Object>]
43
+ # @param concurrency [Integer] max parallel worker threads (1..10, default: 5)
44
+ # @yieldparam item [Object]
45
+ # @return [BatchResult]
46
+ def run(items, concurrency: DEFAULT_CONCURRENCY, &)
47
+ list = Array(items)
48
+ results = map(list, concurrency:, &)
49
+ successful = results.count { |entry| entry.is_a?(Hash) ? entry[:ok] : !entry.nil? }
50
+
51
+ BatchResult.new(total: list.size, successful:, results:)
52
+ end
53
+
54
+ ##
55
+ # Single-URL scrape wire extraction: auto_feed_result → JSON Feed items.
56
+ #
57
+ # @param url [String]
58
+ # @param strategy [Symbol, String]
59
+ # @param limit [Integer]
60
+ # @param items_selector [String, nil]
61
+ # @return [Hash{Symbol => Object}] +:strategy+, +:items+, +:channel_title+, +:admission_drops+
62
+ def scrape_wire(url:, strategy:, limit:, items_selector: nil) # rubocop:disable Metrics/MethodLength
63
+ plan = (strategy || :auto).to_sym
64
+ feed_args = { strategy: plan, limit: }
65
+ feed_args[:items_selector] = items_selector unless items_selector.nil?
66
+ feed_result = Html2rss.auto_feed_result(url, **feed_args)
67
+ feed = feed_result.to_json_feed
68
+ {
69
+ strategy: plan,
70
+ items: feed[:items] || [],
71
+ channel_title: feed[:title],
72
+ admission_drops: feed_result.status.admission_drops
73
+ }
74
+ end
75
+
76
+ ##
77
+ # Scrapes multiple URLs in parallel with per-URL error isolation.
78
+ #
79
+ # @param urls [Enumerable<String>] list of URLs to scrape
80
+ # @param strategy [Symbol, String] request strategy (+:auto+, +:faraday+, +:botasaurus+)
81
+ # @param limit [Integer] max articles to extract per URL
82
+ # @param concurrency [Integer] number of worker threads (default 5, max 10)
83
+ # @return [BatchResult]
84
+ def batch_scrape(urls:, strategy: :auto, limit: 10, concurrency: DEFAULT_CONCURRENCY)
85
+ run(urls, concurrency:) { |url| scrape_single_url(url:, strategy:, limit:) }
86
+ end
87
+
88
+ ##
89
+ # Inspects multiple URLs in parallel with per-URL error isolation.
90
+ #
91
+ # @param urls [Enumerable<String>] list of URLs to inspect
92
+ # @param strategy [Symbol, String] request strategy (+:auto+, +:faraday+, +:botasaurus+)
93
+ # @param concurrency [Integer] number of worker threads (default 5, max 10)
94
+ # @return [BatchResult]
95
+ def batch_inspect(urls:, strategy: :auto, concurrency: DEFAULT_CONCURRENCY)
96
+ reports = PageRecon::Diagnostics.batch(urls:, strategy:, concurrency:)
97
+ results = reports.map(&:to_wire_h)
98
+ successful = reports.count { |report| diagnostic_success?(report) }
99
+
100
+ BatchResult.new(total: results.size, successful:, results:)
101
+ end
102
+
103
+ ##
104
+ # Runs recon across multiple URLs in parallel with per-URL error isolation.
105
+ #
106
+ # @param urls [Enumerable<String>] list of URLs to recon
107
+ # @param strategy [Symbol, String] request strategy (+:auto+, +:faraday+, +:botasaurus+)
108
+ # @param concurrency [Integer] number of worker threads (default 5, max 10)
109
+ # @option options [String, nil] :cache_dir optional HTML cache directory
110
+ # @return [BatchResult]
111
+ def batch_recon(urls:, strategy: :auto, concurrency: DEFAULT_CONCURRENCY, **)
112
+ results = Recon.batch(urls, strategy: (strategy || :auto).to_sym, max_threads: concurrency, **)
113
+ successful = results.count { |r| r.status ? r.status < 400 : false }
114
+ BatchResult.new(total: results.size, successful:, results: results.map(&:to_h))
115
+ end
116
+
117
+ private
118
+
119
+ def diagnostic_success?(report)
120
+ status = report.to_wire_h[:status]
121
+ status ? status < 400 : false
122
+ end
123
+
124
+ def scrape_single_url(url:, strategy:, limit:)
125
+ wire = scrape_wire(url:, strategy:, limit:)
126
+ {
127
+ url: url.to_s, ok: true, items_count: wire[:items].size,
128
+ items: wire[:items], channel_title: wire[:channel_title]
129
+ }
130
+ rescue StandardError => error
131
+ { url: url.to_s, ok: false, error: error.message }
132
+ end
133
+
134
+ def worker_pool_size(item_count, requested_concurrency)
135
+ return 1 if item_count <= 1
136
+
137
+ [[1, requested_concurrency.to_i].max, item_count, MAX_CONCURRENCY].min
138
+ end
139
+
140
+ def process_queue(items, worker_count, &)
141
+ queue = build_work_queue(items)
142
+ results = Array.new(items.size)
143
+
144
+ threads = Array.new(worker_count) do
145
+ Thread.new { drain_queue(queue, results, &) } # rubocop:disable ThreadSafety/NewThread
146
+ end
147
+ threads.each(&:join)
148
+ results
149
+ end
150
+
151
+ def build_work_queue(items)
152
+ Queue.new.tap do |queue|
153
+ items.each_with_index { |item, index| queue << [item, index] }
154
+ end
155
+ end
156
+
157
+ def drain_queue(queue, results)
158
+ until queue.empty?
159
+ begin
160
+ item, index = queue.pop(true)
161
+ rescue ThreadError
162
+ break
163
+ end
164
+ results[index] = yield(item)
165
+ end
166
+ end
167
+ end
168
+ end
169
+ end
@@ -1,6 +1,10 @@
1
1
  # Capture
2
2
 
3
- `Html2rss.capture` (and CLI `html2rss capture`) analyzes a URL through the feed pipeline and produces a reusable config with **items selector + `enhance: true` only** — no title/url/description attribute-selector soup. At feed-build time, `enhance: true` fills missing article fields via `Html::ArticleExtractor` on each matched item.
3
+ `Html2rss.capture` (and CLI `html2rss capture`) analyzes a URL through the feed pipeline and produces a reusable config with **items selector + `enhance: true` only** — no title/url/description attribute-selector soup. At feed-build time, `enhance: true` fills missing article fields via `Html::ArticleExtractor` on each **matched list-card item node**.
4
+
5
+ ## API
6
+
7
+ `Html2rss.capture` / `Capture.build` return `Capture::CaptureResult` — use `#yaml` or `#config`, not a bare Hash.
4
8
 
5
9
  ## When to use it
6
10
 
@@ -9,8 +13,9 @@ Point `capture` at a listing URL when you want a first-draft YAML config instead
9
13
  ## Gem API
10
14
 
11
15
  ```ruby
12
- config = Html2rss.capture('https://example.com/articles')
16
+ result = Html2rss.capture('https://example.com/articles')
13
17
 
18
+ # result.config =>
14
19
  # {
15
20
  # channel: { url: "...", title: "...", time_zone: "UTC" },
16
21
  # selectors: {
@@ -18,11 +23,13 @@ config = Html2rss.capture('https://example.com/articles')
18
23
  # }
19
24
  # }
20
25
 
21
- File.write('my-feed.yml', YAML.dump(Html2rss::HashUtil.deep_stringify_keys(config)))
22
- feed = Html2rss.feed(config)
26
+ File.write('my-feed.yml', result.yaml)
27
+ rss = Html2rss.apply(result.config)
23
28
  ```
24
29
 
25
- `Capture.build` returns a `CaptureResult` with quality meta (`has_selectors`, `segment_strategy`, `admission_drops`, `selected_strategy`). `Html2rss.capture` returns only the config hash.
30
+ `Html2rss.capture` and `Capture.build` both return a `CaptureResult` with YAML (`#yaml`, includes the schema modeline) and quality meta (`has_selectors`, `segment_strategy`, `admission_drops`, `selected_strategy`, `native_feed`).
31
+
32
+ **Wire vs internal:** user-facing **`Html2rss.apply`** ships RSS from a config Hash. Pipeline internals **`Html2rss.feed` / `feed_result`** stay unchanged — use `apply` in CLI/MCP/docs examples, not `feed`.
26
33
 
27
34
  ### Options
28
35
 
@@ -30,15 +37,17 @@ feed = Html2rss.feed(config)
30
37
  Html2rss.capture('https://spa-site.com', strategy: :botasaurus)
31
38
  Html2rss.capture('https://example.com', items_selector: '.article-card')
32
39
  Html2rss.capture('https://example.com', strategy: :local_file, local_file_path: './page.html')
40
+ Html2rss.capture('https://example.com', max_redirects: 8, max_requests: 4)
33
41
  ```
34
42
 
35
- `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.feed(config)` replays the same transport.
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.
36
44
 
37
45
  ## CLI
38
46
 
39
47
  ```bash
40
48
  html2rss capture https://example.com/articles
41
49
  html2rss capture https://example.com --strategy botasaurus
50
+ html2rss capture https://example.com --max-redirects 8 --max-requests 4
42
51
  html2rss capture https://example.com/articles > my-feed.yml
43
52
  html2rss capture https://example.com --input ./page.html
44
53
  html2rss capture https://example.com --explain # quality JSON on stderr; YAML on stdout
@@ -2,28 +2,73 @@
2
2
 
3
3
  module Html2rss
4
4
  ##
5
- # Analyzes a URL and produces a durable feed config: items selector + +enhance: true+.
5
+ # Analyzes a URL and produces a durable feed config: items selector + metadata.
6
6
  #
7
7
  # Fetches via {FeedPipeline} (including AutoFallback for +:auto+), extracts articles,
8
- # then derives a reusable items CSS selector from SST segments (list cluster semantic).
8
+ # derives reusable items CSS selectors from SST segments, and infers directory catalog metadata.
9
9
  #
10
10
  # {include:file:lib/html2rss/capture/README.md}
11
11
  class Capture # rubocop:disable Metrics/ClassLength -- segment strategies + CSS trim stay co-located
12
12
  LEADING_TRIM_TAGS = %w[html body].freeze
13
13
  private_constant :LEADING_TRIM_TAGS
14
14
 
15
+ # Packaged YAML language server modeline.
16
+ SCHEMA_MODELINE = '# yaml-language-server: $schema=https://raw.githubusercontent.com/html2rss/html2rss/refs/heads/master/schema/html2rss-config.schema.json'
17
+
15
18
  # Ordered Segmenter strategies tried until the items selector quality gate passes.
16
19
  SEGMENT_STRATEGIES = %i[list cluster semantic].freeze
17
20
 
18
21
  # Minimum matched segment/article pairs required to emit an items selector.
19
22
  MIN_SELECTOR_MATCHES = 2
20
23
 
24
+ # Admission drops at or above this sum default enhance to false (chrome-heavy listing).
25
+ CHROME_DROP_THRESHOLD = 3
26
+ private_constant :CHROME_DROP_THRESHOLD
27
+
21
28
  ##
22
29
  # Result of a capture operation (config plus quality meta).
23
30
  CaptureResult = Data.define(
24
- :config, :articles_count, :channel_title, :has_selectors, :segment_strategy,
25
- :admission_drops, :selected_strategy
26
- )
31
+ :config, :yaml, :articles_count, :channel_title, :has_selectors, :segment_strategy,
32
+ :admission_drops, :selected_strategy, :inferred_topics, :native_feed, :suggested_channel_url
33
+ ) do
34
+ # rubocop:disable Metrics/ParameterLists
35
+ ##
36
+ # @param config [Hash]
37
+ # @param articles_count [Integer]
38
+ # @param channel_title [String]
39
+ # @param has_selectors [Boolean]
40
+ # @param segment_strategy [Symbol]
41
+ # @param yaml [String, nil]
42
+ # @param admission_drops [Hash]
43
+ # @param selected_strategy [Symbol, nil]
44
+ # @param inferred_topics [Array<String>]
45
+ # @param native_feed [String, nil]
46
+ # @param suggested_channel_url [String, nil]
47
+ def initialize(config:, articles_count:, channel_title:, has_selectors:, segment_strategy:, # rubocop:disable Metrics/MethodLength
48
+ yaml: nil, admission_drops: {}, selected_strategy: nil, inferred_topics: [], native_feed: nil,
49
+ suggested_channel_url: nil)
50
+ super(
51
+ config:,
52
+ yaml: yaml || "#{SCHEMA_MODELINE}\n#{Config.to_yaml(config)}",
53
+ articles_count:,
54
+ channel_title:,
55
+ has_selectors:,
56
+ segment_strategy:,
57
+ admission_drops:,
58
+ selected_strategy:,
59
+ inferred_topics:,
60
+ native_feed:,
61
+ suggested_channel_url:
62
+ )
63
+ end
64
+ # rubocop:enable Metrics/ParameterLists
65
+
66
+ ##
67
+ # @return [Boolean] whether first-party RSS was detected
68
+ def native_feed?
69
+ !native_feed.nil?
70
+ end
71
+ end
27
72
 
28
73
  class << self
29
74
  ##
@@ -32,6 +77,11 @@ module Html2rss
32
77
  # @param url [String] source page URL
33
78
  # @param strategy [Symbol] request strategy (+:auto+, +:faraday+, +:botasaurus+)
34
79
  # @option options [String, nil] :items_selector optional selector hint
80
+ # @option options [Array<String>, nil] :topics optional directory topics override
81
+ # @option options [String, nil] :title optional title override
82
+ # @option options [String, nil] :summary optional summary override
83
+ # @option options [Boolean] :force whether to ignore native feed detection
84
+ # @option options [Boolean, nil] :enhance whether to force enhance: true
35
85
  # @option options [Integer, nil] :max_redirects optional redirect limit override
36
86
  # @option options [Integer, nil] :max_requests optional request budget override
37
87
  # @option options [Integer, nil] :limit max articles to keep
@@ -45,16 +95,25 @@ module Html2rss
45
95
  ##
46
96
  # @param url [String] source page URL
47
97
  # @param strategy [Symbol] request strategy
48
- # @param options [Hash] additional options
49
98
  # @option options [String, nil] :items_selector optional selector hint
99
+ # @option options [Array<String>, nil] :topics optional directory topics override
100
+ # @option options [String, nil] :title optional title override
101
+ # @option options [String, nil] :summary optional summary override
102
+ # @option options [Boolean] :force whether to ignore native feed detection
103
+ # @option options [Boolean, nil] :enhance whether to force enhance: true
50
104
  # @option options [Integer, nil] :max_redirects optional redirect limit override
51
105
  # @option options [Integer, nil] :max_requests optional request budget override
52
106
  # @option options [Integer, nil] :limit max articles to keep
53
107
  # @option options [String, nil] :local_file_path optional local HTML file path
54
- def initialize(url, strategy: :auto, **options)
108
+ def initialize(url, strategy: :auto, **options) # rubocop:disable Metrics/MethodLength
55
109
  @url = url
56
110
  @strategy = strategy
57
111
  @items_selector_hint = options.delete(:items_selector)
112
+ @topics = options.delete(:topics)
113
+ @title = options.delete(:title)
114
+ @summary = options.delete(:summary)
115
+ @force = options.delete(:force) || false
116
+ @enhance_option = options.delete(:enhance)
58
117
  @max_redirects = options.delete(:max_redirects)
59
118
  @max_requests = options.delete(:max_requests)
60
119
  @limit = options.delete(:limit)
@@ -65,12 +124,17 @@ module Html2rss
65
124
  # Runs the capture pipeline.
66
125
  #
67
126
  # @return [CaptureResult]
68
- def build # rubocop:disable Metrics/AbcSize, Metrics/MethodLength -- outcome + selector + result assembly
127
+ def build # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
69
128
  outcome = FeedPipeline.new(raw_config).to_outcome
129
+ @admission_drops = outcome.admission_drops
70
130
  selectors, segment_strategy = derive_selectors(outcome.response, outcome.articles)
131
+ ch_title = @title || channel_title_from(outcome.response)
132
+ topics = @topics || infer_topics("#{@url} #{ch_title}")
133
+ native_feed = probe_native_feed(outcome.response) unless @force
71
134
 
72
135
  config = {
73
- channel: build_channel(outcome.response),
136
+ directory: build_directory(ch_title, topics),
137
+ channel: build_channel(outcome.response, ch_title),
74
138
  selectors: selectors.empty? ? nil : selectors,
75
139
  **strategy_stamp(outcome),
76
140
  **local_file_request_overlay
@@ -78,17 +142,75 @@ module Html2rss
78
142
 
79
143
  CaptureResult.new(
80
144
  config:,
145
+ yaml: "#{SCHEMA_MODELINE}\n#{Config.to_yaml(config)}",
81
146
  articles_count: outcome.articles.size,
82
- channel_title: channel_title_from(outcome.response),
147
+ channel_title: ch_title,
83
148
  has_selectors: !selectors.empty?,
84
149
  segment_strategy:,
85
150
  admission_drops: outcome.admission_drops,
86
- selected_strategy: outcome.selected_strategy
151
+ selected_strategy: outcome.selected_strategy,
152
+ inferred_topics: topics,
153
+ native_feed:,
154
+ suggested_channel_url: suggested_channel_url(outcome)
87
155
  )
88
156
  end
89
157
 
90
158
  private
91
159
 
160
+ def build_directory(title, topics)
161
+ summary_text = @summary || "#{title} updates and announcements."
162
+ {
163
+ topics: topics.take(2),
164
+ title:,
165
+ summary: summary_text[0, 160]
166
+ }.compact
167
+ end
168
+
169
+ def probe_native_feed(response)
170
+ return nil unless response && @url
171
+
172
+ Syndication::Discovery.best_feed_url(
173
+ page_url: @url,
174
+ request_session: discovery_session,
175
+ parsed_body: (response.parsed_body if response.html_response?),
176
+ html: response.body
177
+ )&.to_s
178
+ rescue StandardError
179
+ nil
180
+ end
181
+
182
+ def discovery_session
183
+ config = Config.from_hash(raw_config)
184
+ resources = FeedPipeline::RuntimePolicy.resources_for(config)
185
+ strategy = FeedPipeline::StrategyPlan.concrete_for_diagnostic(@strategy)
186
+ RequestSession.build(
187
+ config:,
188
+ strategy:,
189
+ budget: resources.budget,
190
+ policy: resources.policy
191
+ )
192
+ end
193
+
194
+ def infer_topics(text) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/AbcSize, Metrics/MethodLength
195
+ t = text.to_s.downcase
196
+ inferred = []
197
+ inferred << 'tech' if /tech|developer|software|cloud|api|compute|ai|data|model|hardware|cyber/.match?(t)
198
+ inferred << 'security' if /security|vulnerab|advis|threat|breach|cve|cert|patch/.match?(t)
199
+ inferred << 'science' if /science|space|astron|physics|bio|research|institut|discover|nature/.match?(t)
200
+ inferred << 'energy' if /energy|wind|solar|power|grid|renewab|oil|gas|hydro/.match?(t)
201
+ inferred << 'finance' if /financ|bank|invest|monetary|market|regulat|treasury|econom/.match?(t)
202
+ inferred << 'civic' if /govern|policy|council|parliament|court|treaty|public/.match?(t)
203
+ inferred << 'environment' if /climat|environ|planet|sustainab|carbon|ecolog|earth/.match?(t)
204
+ inferred << 'consumer' if /consum|recall|safety|rating|test|product/.match?(t)
205
+ inferred << 'travel' if /travel|touris|destination|trip|flight|hotel|transit|rail/.match?(t)
206
+ inferred << 'sports' if /sport|olympic|racing|football|soccer|tennis|game/.match?(t)
207
+ inferred << 'health' if /health|medic|pharma|hospital|disease|patient/.match?(t)
208
+ inferred << 'education' if /educat|universit|school|student|academi|learn/.match?(t)
209
+ inferred << 'transport' if /transport|traffic|automotive|vehicle|train|bus/.match?(t)
210
+ valid = inferred.select { |topic| Config::Validator::DIRECTORY_TOPICS.include?(topic) }
211
+ valid.empty? ? ['news'] : valid.first(2)
212
+ end
213
+
92
214
  def strategy_stamp(outcome)
93
215
  concrete = outcome.selected_strategy || concrete_request_strategy
94
216
  return {} if concrete.nil? || concrete == :auto
@@ -147,11 +269,12 @@ module Html2rss
147
269
  end
148
270
 
149
271
  def hint_selectors
150
- [{ items: { selector: @items_selector_hint, enhance: true } }, :hint]
272
+ [{ items: { selector: @items_selector_hint, enhance: resolve_enhance } }, :hint]
151
273
  end
152
274
 
153
275
  def select_enhance_selectors(sst, articles) # rubocop:disable Metrics/MethodLength -- strategy loop + gate
154
276
  link_resolver = Scoring::LinkResolver.new(@url)
277
+ enhance = resolve_enhance
155
278
 
156
279
  SEGMENT_STRATEGIES.each do |strategy|
157
280
  segments = AutoSource::Segmenter.call(
@@ -161,7 +284,7 @@ module Html2rss
161
284
  items_sel = items_selector(matched)
162
285
  next unless items_sel && matched.size >= MIN_SELECTOR_MATCHES
163
286
 
164
- return [{ items: { selector: items_sel, enhance: true } }, strategy]
287
+ return [{ items: { selector: items_sel, enhance: } }, strategy]
165
288
  end
166
289
 
167
290
  [{}, nil]
@@ -301,10 +424,10 @@ module Html2rss
301
424
  "/#{prefix.join('/')}"
302
425
  end
303
426
 
304
- def build_channel(response)
427
+ def build_channel(response, title = nil)
305
428
  {
306
429
  url: @url,
307
- title: channel_title_from(response),
430
+ title: title || channel_title_from(response),
308
431
  time_zone: 'UTC'
309
432
  }.compact
310
433
  end
@@ -314,5 +437,25 @@ module Html2rss
314
437
  rescue StandardError
315
438
  nil
316
439
  end
440
+
441
+ def resolve_enhance
442
+ return @enhance_option unless @enhance_option.nil?
443
+
444
+ drops = (@admission_drops || {}).values.sum
445
+ drops < CHROME_DROP_THRESHOLD
446
+ end
447
+
448
+ def suggested_channel_url(outcome)
449
+ entry = outcome.scrape_target.entry_url.to_s
450
+ effective = outcome.scrape_target.effective_url.to_s
451
+ return effective unless Url.from_absolute(entry).same_document?(Url.from_absolute(effective))
452
+
453
+ final = outcome.response.url.to_s
454
+ return final unless Url.from_absolute(@url).same_document?(Url.from_absolute(final))
455
+
456
+ nil
457
+ rescue ArgumentError
458
+ nil
459
+ end
317
460
  end
318
461
  end
@@ -155,7 +155,7 @@ module Html2rss
155
155
  # @param last_build_date [Time, String]
156
156
  # @param image [Html2rss::Url, String, nil]
157
157
  # @param author [String, nil]
158
- # rubocop:disable Metrics/ParameterLists -- Data.define members are the channel contract
158
+ # rubocop:disable-next Metrics/ParameterLists -- Data.define members are the channel contract
159
159
  def initialize(title:, url:, description:, language:, ttl:, last_build_date:, image:, author:)
160
160
  super(
161
161
  title: freeze_string(title),
@@ -168,7 +168,6 @@ module Html2rss
168
168
  author: author && freeze_string(author)
169
169
  )
170
170
  end
171
- # rubocop:enable Metrics/ParameterLists
172
171
 
173
172
  private
174
173
 
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ class CLI
5
+ ##
6
+ # Display-only probe facts for inspect and recon text cards.
7
+ ProbeView = Data.define(
8
+ :requested,
9
+ :final,
10
+ :status,
11
+ :surface,
12
+ :articles_count,
13
+ :verdict,
14
+ :native_feed,
15
+ :alternate_feeds,
16
+ :notes,
17
+ :strategy
18
+ ) do
19
+ class << self
20
+ ##
21
+ # @param data [Hash] inspect wire payload
22
+ # @return [ProbeView]
23
+ def from_wire(data) # rubocop:disable Metrics/MethodLength
24
+ new(
25
+ requested: wire_val(data, :requested_url),
26
+ final: wire_val(data, :final_url),
27
+ status: wire_val(data, :status),
28
+ surface: wire_val(data, :surface_category),
29
+ articles_count: wire_val(data, :articles_count),
30
+ verdict: nil,
31
+ native_feed: nil,
32
+ alternate_feeds: wire_val(data, :alternate_feeds) || [],
33
+ notes: [],
34
+ strategy: wire_val(data, :strategy)
35
+ )
36
+ end
37
+
38
+ ##
39
+ # @param result [Html2rss::Recon::Result]
40
+ # @return [ProbeView]
41
+ def from_recon(result) # rubocop:disable Metrics/MethodLength
42
+ new(
43
+ requested: result.requested_url.to_s,
44
+ final: result.final_url.to_s,
45
+ status: result.status,
46
+ surface: result.surface_category,
47
+ articles_count: result.articles_count,
48
+ verdict: result.verdict,
49
+ native_feed: result.native_feed&.to_s,
50
+ alternate_feeds: [],
51
+ notes: result.notes,
52
+ strategy: nil
53
+ )
54
+ end
55
+
56
+ ##
57
+ # @param data [Hash]
58
+ # @param key [Symbol]
59
+ # @return [Object, nil]
60
+ def wire_val(data, key)
61
+ data[key] || data[key.to_s]
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end