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.
- 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 +8 -8
- 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 +5 -2
- data/lib/html2rss/auto_source/scraper/schema.rb +39 -21
- data/lib/html2rss/auto_source/scraper.rb +2 -3
- 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 +25 -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/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 +59 -22
- data/lib/html2rss/doctor/botasaurus.rb +130 -0
- data/lib/html2rss/feed_builder/rss.rb +1 -2
- data/lib/html2rss/feed_pipeline/README.md +5 -5
- data/lib/html2rss/feed_pipeline/auto_fallback.rb +29 -10
- data/lib/html2rss/feed_pipeline.rb +27 -16
- data/lib/html2rss/feed_resolution/README.md +11 -11
- data/lib/html2rss/feed_resolution.rb +6 -12
- 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 -42
- 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 +56 -2
- data/lib/html2rss/recon.rb +252 -0
- data/lib/html2rss/request_service/blocked_surface.rb +1 -0
- 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 +3 -4
- data/lib/html2rss/request_service/strategy.rb +1 -2
- data/lib/html2rss/request_service.rb +2 -0
- data/lib/html2rss/request_session.rb +1 -2
- data/lib/html2rss/scrape_target.rb +1 -1
- 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 +3 -6
- data/lib/html2rss/syndication/README.md +6 -6
- data/lib/html2rss/syndication/discovery.rb +2 -4
- data/lib/html2rss/test/enhance_audit.rb +192 -0
- data/lib/html2rss/test.rb +458 -0
- data/lib/html2rss/url.rb +1 -2
- data/lib/html2rss/version.rb +1 -1
- data/lib/html2rss.rb +157 -35
- data/schema/html2rss-config.schema.json +9 -29
- metadata +17 -18
- data/lib/html2rss/mcp/inspect.rb +0 -138
- 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]
|
data/lib/html2rss/auto_source.rb
CHANGED
|
@@ -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.
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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
|
-
|
|
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',
|
|
22
|
-
|
|
26
|
+
File.write('my-feed.yml', result.yaml)
|
|
27
|
+
rss = Html2rss.apply(result.config)
|
|
23
28
|
```
|
|
24
29
|
|
|
25
|
-
`Capture.build`
|
|
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.
|
|
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
|
data/lib/html2rss/capture.rb
CHANGED
|
@@ -2,28 +2,73 @@
|
|
|
2
2
|
|
|
3
3
|
module Html2rss
|
|
4
4
|
##
|
|
5
|
-
# Analyzes a URL and produces a durable feed config: items selector +
|
|
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
|
-
#
|
|
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
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
data/lib/html2rss/channel.rb
CHANGED
|
@@ -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
|