html2rss 0.30.0 → 0.32.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 +4 -4
- data/README.md +6 -3
- data/lib/html2rss/article/deduplicator.rb +2 -4
- data/lib/html2rss/article.rb +16 -39
- data/lib/html2rss/auto_source/README.md +12 -9
- data/lib/html2rss/auto_source/cleanup.rb +34 -7
- data/lib/html2rss/auto_source/scraper/html.rb +5 -0
- data/lib/html2rss/auto_source/scraper/json_document.rb +135 -0
- data/lib/html2rss/auto_source/scraper/json_state/article_normalizer.rb +74 -11
- data/lib/html2rss/auto_source/scraper/json_state/candidate_detector.rb +4 -8
- data/lib/html2rss/auto_source/scraper/json_state.rb +5 -0
- data/lib/html2rss/auto_source/scraper/meta_oembed.rb +8 -1
- data/lib/html2rss/auto_source/scraper/microdata.rb +8 -0
- data/lib/html2rss/auto_source/scraper/microformats2.rb +8 -0
- data/lib/html2rss/auto_source/scraper/native_feed.rb +3 -0
- data/lib/html2rss/auto_source/scraper/schema/thing.rb +4 -5
- data/lib/html2rss/auto_source/scraper/schema.rb +8 -0
- data/lib/html2rss/auto_source/scraper/semantic_html.rb +5 -0
- data/lib/html2rss/auto_source/scraper/sitemap.rb +3 -0
- data/lib/html2rss/auto_source/scraper/wordpress_api.rb +3 -0
- data/lib/html2rss/auto_source/scraper.rb +12 -17
- data/lib/html2rss/auto_source/segment.rb +7 -4
- data/lib/html2rss/auto_source/segmenter/cluster.rb +22 -23
- data/lib/html2rss/auto_source/segmenter/list.rb +54 -31
- data/lib/html2rss/auto_source/segmenter.rb +5 -5
- data/lib/html2rss/auto_source.rb +58 -3
- data/lib/html2rss/batch.rb +3 -1
- data/lib/html2rss/capture/README.md +6 -4
- data/lib/html2rss/capture.rb +67 -35
- data/lib/html2rss/channel.rb +3 -3
- data/lib/html2rss/cli/probe_view.rb +5 -3
- data/lib/html2rss/cli/render.rb +6 -5
- data/lib/html2rss/cli/validate.rb +28 -27
- data/lib/html2rss/cli.rb +6 -2
- data/lib/html2rss/config/auto_source_contract.rb +3 -0
- data/lib/html2rss/config/issue_mapper.rb +108 -0
- data/lib/html2rss/config/request_headers.rb +2 -3
- data/lib/html2rss/config/schema.rb +16 -1
- data/lib/html2rss/config/selectors_validator.rb +92 -70
- data/lib/html2rss/config/validation_issue.rb +60 -0
- data/lib/html2rss/config/validation_report.rb +55 -0
- data/lib/html2rss/config/validator.rb +10 -6
- data/lib/html2rss/config.rb +44 -114
- data/lib/html2rss/defaults.rb +42 -30
- data/lib/html2rss/doctor/botasaurus.rb +6 -4
- data/lib/html2rss/doctor.rb +8 -0
- data/lib/html2rss/error.rb +1 -1
- data/lib/html2rss/feed_builder/rss.rb +9 -14
- data/lib/html2rss/feed_pipeline/auto_fallback.rb +11 -2
- data/lib/html2rss/feed_pipeline/runtime_policy.rb +14 -10
- data/lib/html2rss/feed_pipeline/strategy_plan.rb +6 -2
- data/lib/html2rss/feed_resolution/candidate_generator.rb +1 -4
- data/lib/html2rss/feed_resolution/diag.rb +3 -1
- data/lib/html2rss/feed_resolution/options.rb +3 -1
- data/lib/html2rss/feed_resolution/policy.rb +2 -2
- data/lib/html2rss/feed_resolution.rb +30 -2
- data/lib/html2rss/html/article_extractor/date_extractor.rb +1 -1
- data/lib/html2rss/html/article_extractor.rb +2 -4
- data/lib/html2rss/html/feed_link.rb +3 -1
- data/lib/html2rss/html/navigator/text_extractor.rb +1 -1
- data/lib/html2rss/html/navigator.rb +10 -10
- data/lib/html2rss/html/rendering/description_builder.rb +1 -1
- data/lib/html2rss/html/sst_article_extractor.rb +8 -5
- data/lib/html2rss/link_destination/destination_facts.rb +4 -2
- data/lib/html2rss/link_destination/noise_policy.rb +10 -1
- data/lib/html2rss/link_destination/path_classifier.rb +45 -38
- data/lib/html2rss/mcp/README.md +2 -0
- data/lib/html2rss/mcp/config_argument.rb +3 -1
- data/lib/html2rss/mcp/contract.rb +7 -21
- data/lib/html2rss/mcp/outcome/playbook.rb +2 -2
- data/lib/html2rss/mcp/outcome.rb +36 -43
- data/lib/html2rss/mcp/server/tools.rb +111 -131
- data/lib/html2rss/mcp/server.rb +26 -18
- data/lib/html2rss/page_recon/README.md +5 -5
- data/lib/html2rss/page_recon/diagnostics.rb +71 -81
- data/lib/html2rss/page_recon.rb +9 -5
- data/lib/html2rss/recon.rb +19 -30
- data/lib/html2rss/request_service/botasaurus_contract.rb +11 -12
- data/lib/html2rss/request_service/botasaurus_strategy.rb +1 -1
- data/lib/html2rss/request_service/compressed_body.rb +4 -1
- data/lib/html2rss/request_service/httpx_strategy.rb +39 -28
- data/lib/html2rss/request_service/network_guard.rb +7 -5
- data/lib/html2rss/request_service/response.rb +6 -4
- data/lib/html2rss/request_service/strategy.rb +3 -8
- data/lib/html2rss/request_service.rb +11 -2
- data/lib/html2rss/scoring/container_assessor.rb +3 -1
- data/lib/html2rss/scoring/engine.rb +8 -8
- data/lib/html2rss/scoring/observation.rb +5 -3
- data/lib/html2rss/scoring/ranked_segment.rb +3 -1
- data/lib/html2rss/scrape_target.rb +3 -1
- data/lib/html2rss/selector_candidates.rb +234 -0
- data/lib/html2rss/selectors/README.md +64 -0
- data/lib/html2rss/selectors/extractors/attribute.rb +13 -15
- data/lib/html2rss/selectors/extractors/href.rb +15 -16
- data/lib/html2rss/selectors/extractors/html.rb +8 -9
- data/lib/html2rss/selectors/extractors/static.rb +12 -14
- data/lib/html2rss/selectors/extractors/text.rb +8 -9
- data/lib/html2rss/selectors/extractors.rb +33 -20
- data/lib/html2rss/selectors/item_env.rb +31 -0
- data/lib/html2rss/selectors/{object_to_xml_converter.rb → json_xml.rb} +11 -12
- data/lib/html2rss/selectors/option_spec.rb +140 -0
- data/lib/html2rss/selectors/post_processors/base.rb +79 -31
- data/lib/html2rss/selectors/post_processors/gsub.rb +103 -39
- data/lib/html2rss/selectors/post_processors/html_transformers/transform_urls_to_absolute_ones.rb +4 -4
- data/lib/html2rss/selectors/post_processors/markdown_to_html.rb +10 -11
- data/lib/html2rss/selectors/post_processors/parse_time.rb +50 -26
- data/lib/html2rss/selectors/post_processors/parse_uri.rb +11 -7
- data/lib/html2rss/selectors/post_processors/sanitize_html.rb +46 -32
- data/lib/html2rss/selectors/post_processors/substring.rb +14 -25
- data/lib/html2rss/selectors/post_processors/template.rb +23 -19
- data/lib/html2rss/selectors/post_processors.rb +6 -5
- data/lib/html2rss/selectors/{schema_doc.rb → schema_export.rb} +12 -31
- data/lib/html2rss/selectors/step_env.rb +24 -0
- data/lib/html2rss/selectors.rb +231 -142
- data/lib/html2rss/sst/attrs.rb +5 -3
- data/lib/html2rss/sst/document.rb +3 -1
- data/lib/html2rss/sst/index.rb +3 -1
- data/lib/html2rss/sst/node.rb +29 -10
- data/lib/html2rss/sst/normalizer.rb +12 -16
- data/lib/html2rss/sst/tags.rb +7 -7
- data/lib/html2rss/status.rb +13 -8
- data/lib/html2rss/surface_category.rb +14 -32
- data/lib/html2rss/syndication/parser.rb +2 -2
- data/lib/html2rss/test/enhance_audit.rb +1 -3
- data/lib/html2rss/test/policy.rb +106 -0
- data/lib/html2rss/test.rb +126 -173
- data/lib/html2rss/url.rb +25 -4
- data/lib/html2rss/version.rb +1 -1
- data/lib/html2rss.rb +32 -19
- data/lib/tasks/config_schema.rake +2 -2
- data/schema/html2rss-config.schema.json +26 -8
- metadata +16 -6
- data/lib/html2rss/selectors/item_scope.rb +0 -30
data/lib/html2rss/capture.rb
CHANGED
|
@@ -25,12 +25,15 @@ module Html2rss
|
|
|
25
25
|
CHROME_DROP_THRESHOLD = 3
|
|
26
26
|
private_constant :CHROME_DROP_THRESHOLD
|
|
27
27
|
|
|
28
|
-
##
|
|
29
|
-
# Result of a capture operation (config plus quality meta).
|
|
30
28
|
CaptureResult = Data.define(
|
|
31
29
|
:config, :yaml, :articles_count, :channel_title, :has_selectors, :segment_strategy,
|
|
32
|
-
:admission_drops, :selected_strategy, :inferred_topics, :native_feed, :suggested_channel_url
|
|
33
|
-
|
|
30
|
+
:admission_drops, :selected_strategy, :inferred_topics, :native_feed, :suggested_channel_url,
|
|
31
|
+
:candidates
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
##
|
|
35
|
+
# Result of a capture operation (config plus quality meta).
|
|
36
|
+
class CaptureResult
|
|
34
37
|
# rubocop:disable Metrics/ParameterLists
|
|
35
38
|
##
|
|
36
39
|
# @param config [Hash]
|
|
@@ -44,9 +47,10 @@ module Html2rss
|
|
|
44
47
|
# @param inferred_topics [Array<String>]
|
|
45
48
|
# @param native_feed [String, nil]
|
|
46
49
|
# @param suggested_channel_url [String, nil]
|
|
50
|
+
# @param candidates [Hash{Symbol=>Array}, nil] ranked selector buckets from {SelectorCandidates}
|
|
47
51
|
def initialize(config:, articles_count:, channel_title:, has_selectors:, segment_strategy:, # rubocop:disable Metrics/MethodLength
|
|
48
52
|
yaml: nil, admission_drops: {}, selected_strategy: nil, inferred_topics: [], native_feed: nil,
|
|
49
|
-
suggested_channel_url: nil)
|
|
53
|
+
suggested_channel_url: nil, candidates: nil)
|
|
50
54
|
super(
|
|
51
55
|
config:,
|
|
52
56
|
yaml: yaml || "#{SCHEMA_MODELINE}\n#{Config.to_yaml(config)}",
|
|
@@ -58,7 +62,8 @@ module Html2rss
|
|
|
58
62
|
selected_strategy:,
|
|
59
63
|
inferred_topics:,
|
|
60
64
|
native_feed:,
|
|
61
|
-
suggested_channel_url
|
|
65
|
+
suggested_channel_url:,
|
|
66
|
+
candidates: candidates || SelectorCandidates.empty
|
|
62
67
|
)
|
|
63
68
|
end
|
|
64
69
|
# rubocop:enable Metrics/ParameterLists
|
|
@@ -127,7 +132,7 @@ module Html2rss
|
|
|
127
132
|
def build # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
|
128
133
|
outcome = FeedPipeline.new(raw_config).to_outcome
|
|
129
134
|
@admission_drops = outcome.admission_drops
|
|
130
|
-
selectors, segment_strategy = derive_selectors(outcome.response, outcome.articles)
|
|
135
|
+
selectors, segment_strategy, candidates = derive_selectors(outcome.response, outcome.articles)
|
|
131
136
|
ch_title = @title || channel_title_from(outcome.response)
|
|
132
137
|
topics = @topics || infer_topics("#{@url} #{ch_title}")
|
|
133
138
|
native_feed = probe_native_feed(outcome.response) unless @force
|
|
@@ -151,7 +156,8 @@ module Html2rss
|
|
|
151
156
|
selected_strategy: outcome.selected_strategy,
|
|
152
157
|
inferred_topics: topics,
|
|
153
158
|
native_feed:,
|
|
154
|
-
suggested_channel_url: suggested_channel_url(outcome)
|
|
159
|
+
suggested_channel_url: suggested_channel_url(outcome),
|
|
160
|
+
candidates:
|
|
155
161
|
)
|
|
156
162
|
end
|
|
157
163
|
|
|
@@ -254,40 +260,68 @@ module Html2rss
|
|
|
254
260
|
{ strategy: :local_file, request: { local_file_path: @local_file_path } }
|
|
255
261
|
end
|
|
256
262
|
|
|
257
|
-
# @return [Array(Hash, Symbol, nil)] selectors
|
|
263
|
+
# @return [Array(Hash, Symbol, nil, Hash)] selectors, segment strategy, candidate buckets
|
|
258
264
|
def derive_selectors(response, articles)
|
|
259
|
-
return hint_selectors if @items_selector_hint
|
|
260
|
-
return [{}, nil
|
|
265
|
+
return [*hint_selectors, SelectorCandidates.empty] if @items_selector_hint
|
|
266
|
+
return [{}, nil, SelectorCandidates.empty] unless response.html_response?
|
|
267
|
+
return [*default_selectors, SelectorCandidates.empty] if articles.empty?
|
|
261
268
|
|
|
262
269
|
sst = SST::Normalizer.call(response.body)
|
|
263
|
-
return [
|
|
270
|
+
return [*default_selectors, SelectorCandidates.empty] unless sst
|
|
264
271
|
|
|
265
272
|
select_enhance_selectors(sst, articles)
|
|
266
273
|
rescue ArgumentError => error
|
|
267
274
|
Log.warn("Capture selector derivation failed: #{error.message}")
|
|
268
|
-
[
|
|
275
|
+
[*default_selectors, SelectorCandidates.empty]
|
|
269
276
|
end
|
|
270
277
|
|
|
271
278
|
def hint_selectors
|
|
272
279
|
[{ items: { selector: @items_selector_hint, enhance: resolve_enhance } }, :hint]
|
|
273
280
|
end
|
|
274
281
|
|
|
275
|
-
def
|
|
282
|
+
def default_selectors
|
|
283
|
+
[{ items: { selector: Selectors::DEFAULT_ITEMS_SELECTOR, enhance: resolve_enhance } }, :default]
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
def select_enhance_selectors(sst, articles)
|
|
287
|
+
evidence = collect_items_evidence(sst, articles)
|
|
288
|
+
candidates = SelectorCandidates.call(items_evidence: evidence, min_matches: MIN_SELECTOR_MATCHES)
|
|
289
|
+
top = candidates[:items].first
|
|
290
|
+
return [*default_selectors, candidates] unless top
|
|
291
|
+
|
|
292
|
+
strategy = evidence.find { |entry| entry[:selector] == top[:selector] }&.fetch(:strategy)
|
|
293
|
+
items = { selector: top[:selector], enhance: top[:enhance] }
|
|
294
|
+
[{ items: }, strategy, candidates]
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
def collect_items_evidence(sst, articles)
|
|
276
298
|
link_resolver = Scoring::LinkResolver.new(@url)
|
|
277
299
|
enhance = resolve_enhance
|
|
278
300
|
|
|
279
|
-
SEGMENT_STRATEGIES.
|
|
301
|
+
SEGMENT_STRATEGIES.flat_map do |strategy|
|
|
280
302
|
segments = AutoSource::Segmenter.call(
|
|
281
303
|
sst, base_url: @url, strategy:, permit_unanchored: false, link_resolver:
|
|
282
304
|
)
|
|
283
305
|
matched = match_segments_to_articles(segments, articles)
|
|
284
|
-
|
|
285
|
-
|
|
306
|
+
next [] if matched.size < MIN_SELECTOR_MATCHES
|
|
307
|
+
|
|
308
|
+
items_evidence_for(matched, strategy:, enhance:)
|
|
309
|
+
end
|
|
310
|
+
end
|
|
286
311
|
|
|
287
|
-
|
|
312
|
+
def items_evidence_for(matched, strategy:, enhance:)
|
|
313
|
+
roots = lift_heading_link_roots(matched.map { |pair| pair[:segment].root_node })
|
|
314
|
+
selector_variants_for(roots).map do |kind, selector|
|
|
315
|
+
{ selector:, enhance:, strategy:, kind:, match_count: matched.size, roots: }
|
|
288
316
|
end
|
|
317
|
+
end
|
|
289
318
|
|
|
290
|
-
|
|
319
|
+
def selector_variants_for(roots)
|
|
320
|
+
[
|
|
321
|
+
[:shared_class, shared_class_items_selector(roots)],
|
|
322
|
+
[:unique_tag, unique_tag_items_selector(roots)],
|
|
323
|
+
[:path, path_items_selector(roots)]
|
|
324
|
+
].filter_map { |kind, selector| [kind, selector] if selector }
|
|
291
325
|
end
|
|
292
326
|
|
|
293
327
|
def match_segments_to_articles(segments, articles)
|
|
@@ -322,21 +356,6 @@ module Html2rss
|
|
|
322
356
|
segment.root_node.visible_text.to_s.strip
|
|
323
357
|
end
|
|
324
358
|
|
|
325
|
-
def items_selector(matched)
|
|
326
|
-
return nil if matched.empty?
|
|
327
|
-
|
|
328
|
-
roots = lift_heading_link_roots(matched.map { |m| m[:segment].root_node })
|
|
329
|
-
shared = shared_class_items_selector(roots)
|
|
330
|
-
return shared if shared
|
|
331
|
-
|
|
332
|
-
paths = roots.map(&:tag_path)
|
|
333
|
-
common = common_path_prefix(paths)
|
|
334
|
-
tag_path = common.empty? ? paths.first.to_s : common
|
|
335
|
-
return nil if tag_path.empty?
|
|
336
|
-
|
|
337
|
-
css_from_trimmed_tag_path(tag_path)
|
|
338
|
-
end
|
|
339
|
-
|
|
340
359
|
def lift_heading_link_roots(roots)
|
|
341
360
|
roots.map { |root| lift_heading_link_root(root, roots) }
|
|
342
361
|
end
|
|
@@ -371,7 +390,6 @@ module Html2rss
|
|
|
371
390
|
|
|
372
391
|
def wrapping_anchor_root?(node)
|
|
373
392
|
return false unless node.name.to_s == 'a'
|
|
374
|
-
return false unless node.respond_to?(:find)
|
|
375
393
|
|
|
376
394
|
tags = Html::Navigator::WRAPPING_ANCHOR_CHILD_TAGS
|
|
377
395
|
node.find { |child| !child.equal?(node) && tags.include?(child.name.to_s) }
|
|
@@ -396,6 +414,20 @@ module Html2rss
|
|
|
396
414
|
"#{roots.first.name}.#{shared.min}"
|
|
397
415
|
end
|
|
398
416
|
|
|
417
|
+
def unique_tag_items_selector(roots)
|
|
418
|
+
names = roots.map { |root| root.name.to_s }.uniq
|
|
419
|
+
names.join(', ') if names.size > 1
|
|
420
|
+
end
|
|
421
|
+
|
|
422
|
+
def path_items_selector(roots)
|
|
423
|
+
paths = roots.map(&:tag_path)
|
|
424
|
+
common = common_path_prefix(paths)
|
|
425
|
+
tag_path = common.empty? ? paths.first.to_s : common
|
|
426
|
+
return if tag_path.empty?
|
|
427
|
+
|
|
428
|
+
css_from_trimmed_tag_path(tag_path)
|
|
429
|
+
end
|
|
430
|
+
|
|
399
431
|
def css_from_trimmed_tag_path(tag_path)
|
|
400
432
|
segments = tag_path.split('/').reject(&:empty?)
|
|
401
433
|
trimmed = trim_leading_items_segments(segments)
|
data/lib/html2rss/channel.rb
CHANGED
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
require 'time'
|
|
4
4
|
|
|
5
|
-
# rubocop:disable Metrics/ModuleLength -- Channel value object owns extract + freeze + Marshal
|
|
6
5
|
module Html2rss
|
|
6
|
+
Channel = Data.define(:title, :url, :description, :language, :ttl, :last_build_date, :image, :author)
|
|
7
|
+
|
|
7
8
|
##
|
|
8
9
|
# Materialized channel metadata for feed builders and {FeedResult}.
|
|
9
10
|
#
|
|
@@ -27,7 +28,7 @@ module Html2rss
|
|
|
27
28
|
# @return [Html2rss::Url, nil]
|
|
28
29
|
# @!attribute [r] author
|
|
29
30
|
# @return [String, nil]
|
|
30
|
-
Channel
|
|
31
|
+
class Channel # rubocop:disable Metrics/ClassLength -- Channel value object owns extract + freeze + Marshal
|
|
31
32
|
class << self
|
|
32
33
|
##
|
|
33
34
|
# Materializes channel attributes from an HTTP response and optional overrides.
|
|
@@ -193,5 +194,4 @@ module Html2rss
|
|
|
193
194
|
Time.httpdate(value)
|
|
194
195
|
end
|
|
195
196
|
end
|
|
196
|
-
# rubocop:enable Metrics/ModuleLength
|
|
197
197
|
end
|
|
@@ -2,8 +2,6 @@
|
|
|
2
2
|
|
|
3
3
|
module Html2rss
|
|
4
4
|
class CLI
|
|
5
|
-
##
|
|
6
|
-
# Display-only probe facts for inspect and recon text cards.
|
|
7
5
|
ProbeView = Data.define(
|
|
8
6
|
:requested,
|
|
9
7
|
:final,
|
|
@@ -15,7 +13,11 @@ module Html2rss
|
|
|
15
13
|
:alternate_feeds,
|
|
16
14
|
:notes,
|
|
17
15
|
:strategy
|
|
18
|
-
)
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
##
|
|
19
|
+
# Display-only probe facts for inspect and recon text cards.
|
|
20
|
+
class ProbeView
|
|
19
21
|
class << self
|
|
20
22
|
##
|
|
21
23
|
# @param data [Hash] inspect wire payload
|
data/lib/html2rss/cli/render.rb
CHANGED
|
@@ -93,9 +93,11 @@ module Html2rss
|
|
|
93
93
|
end
|
|
94
94
|
end
|
|
95
95
|
else
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
result.
|
|
96
|
+
$stderr.puts "\e[31m✗ Test failed\e[0m (#{source})" # rubocop:disable Style/StderrPuts
|
|
97
|
+
$stderr.puts " Error: #{result.error_message}" if result.error_message # rubocop:disable Style/StderrPuts
|
|
98
|
+
result.validation_issues&.each do |issue|
|
|
99
|
+
$stderr.puts " Schema error #{issue}" # rubocop:disable Style/StderrPuts
|
|
100
|
+
end
|
|
99
101
|
end
|
|
100
102
|
end
|
|
101
103
|
|
|
@@ -115,8 +117,7 @@ module Html2rss
|
|
|
115
117
|
if view.final && view.final != view.requested
|
|
116
118
|
puts " Final: #{view.final} (HTTP #{view.status || 'ERR'})"
|
|
117
119
|
end
|
|
118
|
-
|
|
119
|
-
puts " Surface: #{surface} (#{view.articles_count} articles)"
|
|
120
|
+
puts " Surface: #{view.surface} (#{view.articles_count} articles)"
|
|
120
121
|
end
|
|
121
122
|
private_class_method :probe_lines
|
|
122
123
|
end
|
|
@@ -29,11 +29,10 @@ module Html2rss
|
|
|
29
29
|
if result.success?
|
|
30
30
|
puts(target_files.size == 1 ? 'Configuration is valid' : "ok #{file}") unless quiet
|
|
31
31
|
else
|
|
32
|
-
|
|
33
|
-
raise Thor::Error, "Invalid configuration: #{error_details}" if target_files.size == 1
|
|
32
|
+
raise Thor::Error, format_failure(result) if target_files.size == 1
|
|
34
33
|
|
|
35
34
|
warn "FAIL #{file}"
|
|
36
|
-
|
|
35
|
+
result.issues.each { |issue| warn " #{issue}" }
|
|
37
36
|
failed << file
|
|
38
37
|
end
|
|
39
38
|
end
|
|
@@ -57,35 +56,37 @@ module Html2rss
|
|
|
57
56
|
resolved.uniq
|
|
58
57
|
end
|
|
59
58
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
raise Thor::Error, "Invalid configuration: #{result.errors.to_h}" unless result.success?
|
|
59
|
+
class << self
|
|
60
|
+
private
|
|
63
61
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
62
|
+
def run_named_feed(file, feed_name, params:, quiet:)
|
|
63
|
+
result = Html2rss.validate(file, feed_name, params:)
|
|
64
|
+
raise Thor::Error, format_failure(result) unless result.success?
|
|
65
|
+
|
|
66
|
+
puts 'Configuration is valid' unless quiet
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def validate_file(file, params:)
|
|
70
|
+
if file == '-'
|
|
71
|
+
Html2rss.validate($stdin.read, params:)
|
|
72
|
+
else
|
|
73
|
+
Html2rss.validate(file, params:)
|
|
74
|
+
end
|
|
74
75
|
end
|
|
75
|
-
end
|
|
76
|
-
module_function :validate_file
|
|
77
|
-
private_class_method :validate_file
|
|
78
76
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
return true if path.end_with?('.yml', '.yaml')
|
|
83
|
-
return true if path.include?('/') || path.include?('*')
|
|
77
|
+
def format_failure(report)
|
|
78
|
+
"Invalid configuration: #{report}"
|
|
79
|
+
end
|
|
84
80
|
|
|
85
|
-
|
|
81
|
+
def path_like_config?(arg)
|
|
82
|
+
path = arg.to_s
|
|
83
|
+
return true if path.start_with?('-')
|
|
84
|
+
return true if path.end_with?('.yml', '.yaml')
|
|
85
|
+
return true if path.include?('/') || path.include?('*')
|
|
86
|
+
|
|
87
|
+
false
|
|
88
|
+
end
|
|
86
89
|
end
|
|
87
|
-
module_function :path_like_config?
|
|
88
|
-
private_class_method :path_like_config?
|
|
89
90
|
end
|
|
90
91
|
end
|
|
91
92
|
end
|
data/lib/html2rss/cli.rb
CHANGED
|
@@ -213,6 +213,9 @@ module Html2rss
|
|
|
213
213
|
desc 'scrape [URL]', 'Automatically source an RSS feed from a URL (one-shot auto-source)'
|
|
214
214
|
method_option :items_selector, type: :string, desc: 'CSS selector hint for items'
|
|
215
215
|
method_option :limit, type: :numeric, desc: 'Max articles (auto-source)'
|
|
216
|
+
method_option :keep_different_domain, type: :boolean, default: false,
|
|
217
|
+
desc: 'Allow published items to link to any domain ' \
|
|
218
|
+
'supplied by the source (no affiliation/safety check)'
|
|
216
219
|
# @param url [String, nil]
|
|
217
220
|
# @return [void]
|
|
218
221
|
def scrape(url = nil)
|
|
@@ -300,7 +303,7 @@ module Html2rss
|
|
|
300
303
|
def filter_recon_results(results, verdict_filter)
|
|
301
304
|
return results unless verdict_filter
|
|
302
305
|
|
|
303
|
-
expected = Recon::Verdict
|
|
306
|
+
expected = Recon::Verdict[verdict_filter.downcase]
|
|
304
307
|
results.select { |r| r.verdict == expected }
|
|
305
308
|
rescue ArgumentError => error
|
|
306
309
|
raise Thor::Error, error.message
|
|
@@ -430,7 +433,8 @@ module Html2rss
|
|
|
430
433
|
max_redirects: options[:max_redirects],
|
|
431
434
|
max_requests: options[:max_requests],
|
|
432
435
|
local_file_path:,
|
|
433
|
-
limit: options[:limit]&.to_i
|
|
436
|
+
limit: options[:limit]&.to_i,
|
|
437
|
+
keep_different_domain: options[:keep_different_domain]
|
|
434
438
|
)
|
|
435
439
|
end
|
|
436
440
|
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Html2rss
|
|
4
|
+
class Config
|
|
5
|
+
##
|
|
6
|
+
# Maps internal Dry::Validation messages to {ValidationIssue} (predicate → code).
|
|
7
|
+
# Not a public API — Dry stays inside Validator / this mapper.
|
|
8
|
+
module IssueMapper
|
|
9
|
+
# Dry schema predicates → closed {ValidationIssue::CODES}.
|
|
10
|
+
PREDICATE_CODES = {
|
|
11
|
+
key?: :missing_key,
|
|
12
|
+
filled?: :missing_key,
|
|
13
|
+
str?: :type_mismatch,
|
|
14
|
+
int?: :type_mismatch,
|
|
15
|
+
bool?: :type_mismatch,
|
|
16
|
+
hash?: :type_mismatch,
|
|
17
|
+
array?: :type_mismatch,
|
|
18
|
+
type?: :type_mismatch,
|
|
19
|
+
format?: :invalid_value,
|
|
20
|
+
included_in?: :unknown_value,
|
|
21
|
+
gt?: :constraint,
|
|
22
|
+
gteq?: :constraint,
|
|
23
|
+
lt?: :constraint,
|
|
24
|
+
lteq?: :constraint,
|
|
25
|
+
max_size?: :constraint,
|
|
26
|
+
min_size?: :constraint
|
|
27
|
+
}.freeze
|
|
28
|
+
|
|
29
|
+
class << self
|
|
30
|
+
##
|
|
31
|
+
# @param dry_result [Dry::Validation::Result]
|
|
32
|
+
# @param values [Hash, nil] config under validation (for actual/expected dig)
|
|
33
|
+
# @return [Html2rss::Config::ValidationReport]
|
|
34
|
+
def from(dry_result, values: nil)
|
|
35
|
+
return ValidationReport.ok if dry_result.success?
|
|
36
|
+
|
|
37
|
+
ValidationReport.failure(dry_result.errors.map { |message| issue_for(message, values:) })
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
##
|
|
41
|
+
# @param message [String]
|
|
42
|
+
# @return [Html2rss::Config::ValidationReport]
|
|
43
|
+
def parse_failure(message)
|
|
44
|
+
ValidationReport.failure(
|
|
45
|
+
[
|
|
46
|
+
ValidationIssue.new(path: %i[parse], code: :parse, message:)
|
|
47
|
+
]
|
|
48
|
+
)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def issue_for(message, values:)
|
|
54
|
+
path = Array(message.path).compact
|
|
55
|
+
ValidationIssue.new(
|
|
56
|
+
path:,
|
|
57
|
+
code: code_for(message.predicate),
|
|
58
|
+
message: message.text,
|
|
59
|
+
expected: expected_for(path, values),
|
|
60
|
+
actual: actual_for(path, values, message)
|
|
61
|
+
)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def code_for(predicate)
|
|
65
|
+
return :invalid_value if predicate.nil?
|
|
66
|
+
|
|
67
|
+
PREDICATE_CODES.fetch(predicate) do
|
|
68
|
+
raise ArgumentError, "unmapped Dry validation predicate: #{predicate.inspect}"
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def actual_for(path, values, message)
|
|
73
|
+
dug = dig_path(values, path)
|
|
74
|
+
return dug unless dug.nil?
|
|
75
|
+
return message.input if message.is_a?(Dry::Schema::Message) && !message.input.nil?
|
|
76
|
+
|
|
77
|
+
nil
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def expected_for(path, values)
|
|
81
|
+
return if values.nil? || path.empty?
|
|
82
|
+
return unless path.last.is_a?(Symbol)
|
|
83
|
+
|
|
84
|
+
parent = dig_path(values, path[0...-1])
|
|
85
|
+
return unless parent.is_a?(Hash)
|
|
86
|
+
|
|
87
|
+
option = Selectors::OptionSpec.option_for(parent, path.last)
|
|
88
|
+
return unless option
|
|
89
|
+
|
|
90
|
+
{
|
|
91
|
+
type: option.json_type,
|
|
92
|
+
required: option.required
|
|
93
|
+
}
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def dig_path(values, path)
|
|
97
|
+
return if values.nil? || path.empty?
|
|
98
|
+
|
|
99
|
+
path.reduce(values) do |cursor, segment|
|
|
100
|
+
break unless cursor.is_a?(Hash) || (cursor.is_a?(Array) && segment.is_a?(Integer))
|
|
101
|
+
|
|
102
|
+
cursor[segment]
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
@@ -33,18 +33,17 @@ module Html2rss
|
|
|
33
33
|
}.freeze
|
|
34
34
|
|
|
35
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 =
|
|
36
|
+
FORBIDDEN_H2_HEADERS = Set['connection', 'keep-alive', 'proxy-connection', 'transfer-encoding', 'upgrade'].freeze
|
|
37
37
|
|
|
38
38
|
class << self
|
|
39
39
|
##
|
|
40
|
-
# :reek:ManualDispatch
|
|
41
40
|
# :reek:TooManyStatements
|
|
42
41
|
#
|
|
43
42
|
# @return [Hash{String => String}] the default header set merged with global defaults
|
|
44
43
|
def browser_defaults
|
|
45
44
|
defaults = DEFAULT_HEADERS.dup
|
|
46
45
|
global_headers = Html2rss.defaults.headers
|
|
47
|
-
global_headers = global_headers.call if global_headers.
|
|
46
|
+
global_headers = global_headers.call if global_headers.is_a?(Proc)
|
|
48
47
|
|
|
49
48
|
if global_headers.is_a?(Hash)
|
|
50
49
|
global_headers.each do |key, value|
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
3
5
|
module Html2rss
|
|
4
6
|
class Config
|
|
5
7
|
##
|
|
@@ -19,6 +21,15 @@ module Html2rss
|
|
|
19
21
|
Builder.call
|
|
20
22
|
end
|
|
21
23
|
|
|
24
|
+
##
|
|
25
|
+
# Returns the exported JSON Schema as JSON.
|
|
26
|
+
#
|
|
27
|
+
# @param pretty [Boolean] whether to pretty-print the JSON output
|
|
28
|
+
# @return [String] serialized JSON Schema
|
|
29
|
+
def json_schema_json(pretty: true)
|
|
30
|
+
pretty ? JSON.pretty_generate(json_schema) : JSON.generate(json_schema)
|
|
31
|
+
end
|
|
32
|
+
|
|
22
33
|
##
|
|
23
34
|
# Resolves the packaged schema path used by downstream tools.
|
|
24
35
|
#
|
|
@@ -107,12 +118,16 @@ module Html2rss
|
|
|
107
118
|
# @param registry [Hash{Symbol => Class}]
|
|
108
119
|
# @return [Hash{String => Hash}]
|
|
109
120
|
def catalog_from_registry(registry)
|
|
110
|
-
registry.keys.sort.to_h { |name| [name.to_s, registry.fetch(name).
|
|
121
|
+
registry.keys.sort.to_h { |name| [name.to_s, registry.fetch(name).schema_export] }
|
|
111
122
|
end
|
|
112
123
|
|
|
113
124
|
# rubocop:disable-next Metrics/MethodLength, Metrics/AbcSize, Layout/LineLength
|
|
114
125
|
def overlay
|
|
115
126
|
items_schema = Html2rss::Config::SelectorsValidator::Items.new.schema.json_schema(loose: true)
|
|
127
|
+
items_schema[:required] = Array(items_schema[:required]).reject { |key| key.to_sym == :selector }
|
|
128
|
+
items_schema[:properties][:selector] = items_schema.fetch(:properties).fetch(:selector).merge(
|
|
129
|
+
default: Html2rss::Selectors::DEFAULT_ITEMS_SELECTOR
|
|
130
|
+
)
|
|
116
131
|
items_schema[:properties][:enhance] = items_schema.fetch(:properties).fetch(:enhance).merge(
|
|
117
132
|
description: 'List-card enrichment: run Html::ArticleExtractor on each matched item node ' \
|
|
118
133
|
'to fill missing fields from the card HTML.'
|