html2rss 0.23.0 → 0.24.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 +2 -2
- data/lib/html2rss/article.rb +42 -35
- data/lib/html2rss/auto_source/scraper/schema/category_extractor.rb +29 -114
- data/lib/html2rss/auto_source/scraper/schema/item_list.rb +50 -11
- data/lib/html2rss/auto_source/scraper/schema/thing.rb +14 -5
- data/lib/html2rss/auto_source/scraper/schema.rb +61 -18
- data/lib/html2rss/channel.rb +159 -79
- data/lib/html2rss/feed_builder/item_presentation.rb +42 -0
- data/lib/html2rss/feed_builder/json_feed/item.rb +20 -4
- data/lib/html2rss/feed_builder/json_feed.rb +22 -2
- data/lib/html2rss/feed_builder/rss.rb +19 -19
- data/lib/html2rss/feed_builder.rb +9 -17
- data/lib/html2rss/feed_pipeline/auto_fallback.rb +71 -28
- data/lib/html2rss/feed_pipeline.rb +63 -56
- data/lib/html2rss/feed_result.rb +92 -0
- data/lib/html2rss/request_service/botasaurus_contract.rb +33 -5
- data/lib/html2rss/request_service/botasaurus_strategy.rb +13 -4
- data/lib/html2rss/request_service/budget.rb +7 -2
- data/lib/html2rss/request_service/response.rb +9 -1
- data/lib/html2rss/request_service/strategy.rb +22 -0
- data/lib/html2rss/request_session.rb +4 -0
- data/lib/html2rss/selectors.rb +26 -4
- data/lib/html2rss/status.rb +132 -0
- data/lib/html2rss/version.rb +1 -1
- data/lib/html2rss.rb +15 -3
- metadata +5 -3
- data/lib/html2rss/auto_source/scraper/schema/list_item.rb +0 -28
data/lib/html2rss/selectors.rb
CHANGED
|
@@ -31,6 +31,11 @@ module Html2rss
|
|
|
31
31
|
ITEM_TAGS = %i[title url description author comments published_at guid enclosure categories].freeze
|
|
32
32
|
# Item attributes that require dedicated extraction logic.
|
|
33
33
|
SPECIAL_ATTRIBUTES = Set[:guid, :enclosure, :categories].freeze
|
|
34
|
+
# Config selector keys that map onto a different {Article} attribute.
|
|
35
|
+
# +:enclosure+ stays singular in YAML; Article stores +:enclosures+.
|
|
36
|
+
SELECTOR_TO_ARTICLE_KEY = { enclosure: :enclosures }.freeze
|
|
37
|
+
# Selector keys that may be copied onto an Article (PROVIDED_KEYS + mapped aliases).
|
|
38
|
+
SELECTABLE_SELECTOR_KEYS = (Html2rss::Article::PROVIDED_KEYS + SELECTOR_TO_ARTICLE_KEY.keys).to_set.freeze
|
|
34
39
|
|
|
35
40
|
##
|
|
36
41
|
# Initializes a new Selectors instance.
|
|
@@ -45,7 +50,7 @@ module Html2rss
|
|
|
45
50
|
@time_zone = time_zone
|
|
46
51
|
|
|
47
52
|
prepare_selectors!
|
|
48
|
-
@rss_item_attributes = @selectors.keys
|
|
53
|
+
@rss_item_attributes = @selectors.keys.select { |key| SELECTABLE_SELECTOR_KEYS.include?(key) }
|
|
49
54
|
end
|
|
50
55
|
|
|
51
56
|
##
|
|
@@ -92,7 +97,13 @@ module Html2rss
|
|
|
92
97
|
# @return [Hash] Hash of attributes for the article.
|
|
93
98
|
def extract_article(item, page_response = response)
|
|
94
99
|
scope = item_scope_for(item, page_response.url)
|
|
95
|
-
@rss_item_attributes.
|
|
100
|
+
@rss_item_attributes.each_with_object({}) do |selector_key, hash|
|
|
101
|
+
value = scope.select(selector_key)
|
|
102
|
+
next if value.nil?
|
|
103
|
+
|
|
104
|
+
article_key = SELECTOR_TO_ARTICLE_KEY.fetch(selector_key, selector_key)
|
|
105
|
+
hash[article_key] = article_key == :enclosures ? wrap_enclosure_value(value) : value
|
|
106
|
+
end
|
|
96
107
|
end
|
|
97
108
|
|
|
98
109
|
##
|
|
@@ -308,9 +319,20 @@ module Html2rss
|
|
|
308
319
|
@channel_contexts[base_url] ||= { url: base_url, time_zone: @time_zone }.freeze
|
|
309
320
|
end
|
|
310
321
|
|
|
311
|
-
#
|
|
322
|
+
# Keep a single enclosure Hash as one list entry; +Array(hash)+ would split pairs.
|
|
323
|
+
#
|
|
324
|
+
# @param value [Hash, Array] enclosure hash or list of hashes
|
|
325
|
+
# @return [Array]
|
|
326
|
+
def wrap_enclosure_value(value)
|
|
327
|
+
value.is_a?(Array) ? value : [value]
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
# @return [Hash, nil] enclosure details, or nil when the selector yields nothing.
|
|
312
331
|
def enclosure(scope:, config:)
|
|
313
|
-
|
|
332
|
+
selected = select_regular(:enclosure, scope:, config:)
|
|
333
|
+
return if selected.nil? || selected.to_s.strip.empty?
|
|
334
|
+
|
|
335
|
+
url = Url.from_relative(selected, scope.base_url)
|
|
314
336
|
|
|
315
337
|
{ url:, type: config[:content_type] }
|
|
316
338
|
end
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Html2rss
|
|
4
|
+
##
|
|
5
|
+
# Shared RSS +generator+ / JSON Feed +user_comment+ formatter string.
|
|
6
|
+
#
|
|
7
|
+
# Exposed publicly via {FeedResult#status}. Safe to log without reading articles.
|
|
8
|
+
# Stable telemetry payload for cross-repo consumers (e.g. html2rss-web observability).
|
|
9
|
+
# Tallies and counters are validated and frozen at construction (including Marshal load).
|
|
10
|
+
Status = Data.define(
|
|
11
|
+
:version, :scraper_tallies, :dedup_dropped, :selected_strategy, :attempt_count, :strategy_attempts
|
|
12
|
+
) do
|
|
13
|
+
class << self
|
|
14
|
+
##
|
|
15
|
+
# Builds status from extracted articles and scrape telemetry.
|
|
16
|
+
#
|
|
17
|
+
# @param articles [Array<Html2rss::Article>] articles kept after deduplication
|
|
18
|
+
# @param dedup_dropped [Integer] number of articles removed by deduplication
|
|
19
|
+
# @param selected_strategy [Symbol, nil] concrete strategy that succeeded under +:auto+ (else +nil+)
|
|
20
|
+
# @param attempt_count [Integer] auto-fallback attempt count (0 when not under +:auto+)
|
|
21
|
+
# @param strategy_attempts [Array<Hash>] auto-fallback attempt hashes (empty outside +:auto+)
|
|
22
|
+
# @return [Html2rss::Status]
|
|
23
|
+
def build(articles:, dedup_dropped: 0, selected_strategy: nil, attempt_count: 0, strategy_attempts: [])
|
|
24
|
+
tallies = articles.filter_map(&:scraper).tally.transform_keys { |klass| scraper_name(klass) }
|
|
25
|
+
new(
|
|
26
|
+
version: Html2rss::VERSION,
|
|
27
|
+
scraper_tallies: tallies,
|
|
28
|
+
dedup_dropped:,
|
|
29
|
+
selected_strategy:,
|
|
30
|
+
attempt_count:,
|
|
31
|
+
strategy_attempts:
|
|
32
|
+
)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
##
|
|
36
|
+
# @param klass [Class, #to_s] scraper class
|
|
37
|
+
# @return [String] short scraper label for tallies / generator text
|
|
38
|
+
def scraper_name(klass)
|
|
39
|
+
klass.to_s.gsub(/(?:Html2rss|Scraper)::/, '')
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
##
|
|
44
|
+
# @param version [String]
|
|
45
|
+
# @param scraper_tallies [Hash{String => Integer}]
|
|
46
|
+
# @param dedup_dropped [Integer]
|
|
47
|
+
# @param selected_strategy [Symbol, nil]
|
|
48
|
+
# @param attempt_count [Integer]
|
|
49
|
+
# @param strategy_attempts [Array<Hash>]
|
|
50
|
+
# rubocop:disable Metrics/ParameterLists, Metrics/MethodLength -- Status Data.define members
|
|
51
|
+
def initialize(
|
|
52
|
+
version:, scraper_tallies:, dedup_dropped:, selected_strategy: nil, attempt_count: 0, strategy_attempts: []
|
|
53
|
+
)
|
|
54
|
+
dedup = Integer(dedup_dropped)
|
|
55
|
+
attempts = Integer(attempt_count)
|
|
56
|
+
validate_counters!(dedup:, attempts:, selected_strategy:)
|
|
57
|
+
|
|
58
|
+
super(
|
|
59
|
+
version: version.to_s.dup.freeze,
|
|
60
|
+
scraper_tallies: freeze_tallies(scraper_tallies),
|
|
61
|
+
dedup_dropped: dedup,
|
|
62
|
+
selected_strategy:,
|
|
63
|
+
attempt_count: attempts,
|
|
64
|
+
strategy_attempts: freeze_attempts(strategy_attempts)
|
|
65
|
+
)
|
|
66
|
+
end
|
|
67
|
+
# rubocop:enable Metrics/ParameterLists, Metrics/MethodLength
|
|
68
|
+
|
|
69
|
+
##
|
|
70
|
+
# Observability hash for web (+scraper_status+). Omits empty/absent optional keys:
|
|
71
|
+
# +:scraper_tallies+ when empty, +:selected_strategy+ when +nil+, +:attempt_count+ when zero,
|
|
72
|
+
# +:strategy_attempts+ when empty.
|
|
73
|
+
# Data members remain available via readers even when omitted here.
|
|
74
|
+
#
|
|
75
|
+
# @return [Hash{Symbol => Object}] always +:version+ (String), +:dedup_dropped+ (Integer);
|
|
76
|
+
# optionally +:scraper_tallies+, +:selected_strategy+, +:attempt_count+, +:strategy_attempts+
|
|
77
|
+
def to_h
|
|
78
|
+
{
|
|
79
|
+
version:,
|
|
80
|
+
dedup_dropped:,
|
|
81
|
+
**(scraper_tallies.any? ? { scraper_tallies: } : {}),
|
|
82
|
+
**(selected_strategy.nil? ? {} : { selected_strategy: }),
|
|
83
|
+
**(attempt_count.positive? ? { attempt_count: } : {}),
|
|
84
|
+
**(strategy_attempts.any? ? { strategy_attempts: } : {})
|
|
85
|
+
}
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
##
|
|
89
|
+
# Formats the RSS +generator+ string and JSON Feed +user_comment+.
|
|
90
|
+
# Scraper-focused only — auto strategy summary stays on {#to_h}, not this string.
|
|
91
|
+
# Omits the +(scrapers: …)+ clause when tallies are empty.
|
|
92
|
+
#
|
|
93
|
+
# @return [String]
|
|
94
|
+
def to_generator_comment
|
|
95
|
+
return "html2rss V. #{version}" if scraper_tallies.empty?
|
|
96
|
+
|
|
97
|
+
counts = scraper_tallies.map { |name, count| "#{name} (#{count})" }
|
|
98
|
+
"html2rss V. #{version} (scrapers: #{counts.join(', ')})"
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
private
|
|
102
|
+
|
|
103
|
+
def marshal_dump
|
|
104
|
+
[version, scraper_tallies, dedup_dropped, selected_strategy, attempt_count, strategy_attempts]
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def marshal_load((version, scraper_tallies, dedup_dropped, selected_strategy, attempt_count, strategy_attempts))
|
|
108
|
+
initialize(version:, scraper_tallies:, dedup_dropped:, selected_strategy:, attempt_count:, strategy_attempts:)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def freeze_tallies(tallies)
|
|
112
|
+
tallies.to_h.transform_keys(&:to_s).transform_values { |count| Integer(count) }.freeze
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def freeze_attempts(attempts)
|
|
116
|
+
Array(attempts).map { |attempt| attempt.to_h.freeze }.freeze
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def validate_counters!(dedup:, attempts:, selected_strategy:)
|
|
120
|
+
raise ArgumentError, 'dedup_dropped must be >= 0' if dedup.negative?
|
|
121
|
+
raise ArgumentError, 'attempt_count must be >= 0' if attempts.negative?
|
|
122
|
+
|
|
123
|
+
unless selected_strategy.nil? || selected_strategy.is_a?(Symbol)
|
|
124
|
+
raise ArgumentError, 'selected_strategy must be a Symbol or nil'
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
return unless selected_strategy && attempts < 1
|
|
128
|
+
|
|
129
|
+
raise ArgumentError, 'attempt_count must be >= 1 when selected_strategy is set'
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
data/lib/html2rss/version.rb
CHANGED
data/lib/html2rss.rb
CHANGED
|
@@ -34,22 +34,34 @@ module Html2rss
|
|
|
34
34
|
Config.load_yaml(file, feed_name)
|
|
35
35
|
end
|
|
36
36
|
|
|
37
|
+
##
|
|
38
|
+
# Returns an opaque, Marshal-cacheable result of one scrape.
|
|
39
|
+
#
|
|
40
|
+
# Prefer this when the same scrape must render as RSS and JSON Feed (e.g. web cache).
|
|
41
|
+
#
|
|
42
|
+
# @param raw_config [Hash{Symbol => Object}] feed configuration
|
|
43
|
+
# @return [Html2rss::FeedResult]
|
|
44
|
+
def self.feed_result(raw_config)
|
|
45
|
+
FeedPipeline.new(raw_config).to_result
|
|
46
|
+
end
|
|
47
|
+
|
|
37
48
|
##
|
|
38
49
|
# Returns an RSS object generated from the provided configuration.
|
|
39
50
|
#
|
|
40
51
|
# @param raw_config [Hash{Symbol => Object}] feed configuration
|
|
41
52
|
# @return [RSS::Rss] generated RSS feed
|
|
42
53
|
def self.feed(raw_config)
|
|
43
|
-
|
|
54
|
+
feed_result(raw_config).to_rss
|
|
44
55
|
end
|
|
45
56
|
|
|
46
57
|
##
|
|
47
58
|
# Returns a JSONFeed 1.1 hash generated from the provided configuration.
|
|
48
59
|
#
|
|
49
60
|
# @param raw_config [Hash{Symbol => Object}] feed configuration
|
|
61
|
+
# @param feed_url [String, nil] optional self URL for the feed (JSON Feed +feed_url+)
|
|
50
62
|
# @return [Hash] JSONFeed-compliant hash
|
|
51
|
-
def self.json_feed(raw_config)
|
|
52
|
-
|
|
63
|
+
def self.json_feed(raw_config, feed_url: nil)
|
|
64
|
+
feed_result(raw_config).to_json_feed(feed_url:)
|
|
53
65
|
end
|
|
54
66
|
|
|
55
67
|
# rubocop:disable Metrics/ParameterLists
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: html2rss
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.24.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Gil Desmarais
|
|
@@ -303,7 +303,6 @@ files:
|
|
|
303
303
|
- lib/html2rss/auto_source/scraper/schema.rb
|
|
304
304
|
- lib/html2rss/auto_source/scraper/schema/category_extractor.rb
|
|
305
305
|
- lib/html2rss/auto_source/scraper/schema/item_list.rb
|
|
306
|
-
- lib/html2rss/auto_source/scraper/schema/list_item.rb
|
|
307
306
|
- lib/html2rss/auto_source/scraper/schema/thing.rb
|
|
308
307
|
- lib/html2rss/auto_source/scraper/semantic_html.rb
|
|
309
308
|
- lib/html2rss/auto_source/scraper/semantic_html/entry_deduplicator.rb
|
|
@@ -326,6 +325,7 @@ files:
|
|
|
326
325
|
- lib/html2rss/defaults.rb
|
|
327
326
|
- lib/html2rss/error.rb
|
|
328
327
|
- lib/html2rss/feed_builder.rb
|
|
328
|
+
- lib/html2rss/feed_builder/item_presentation.rb
|
|
329
329
|
- lib/html2rss/feed_builder/json_feed.rb
|
|
330
330
|
- lib/html2rss/feed_builder/json_feed/item.rb
|
|
331
331
|
- lib/html2rss/feed_builder/rss.rb
|
|
@@ -334,6 +334,7 @@ files:
|
|
|
334
334
|
- lib/html2rss/feed_pipeline/auto_fallback.rb
|
|
335
335
|
- lib/html2rss/feed_pipeline/runtime_policy.rb
|
|
336
336
|
- lib/html2rss/feed_pipeline/strategy_plan.rb
|
|
337
|
+
- lib/html2rss/feed_result.rb
|
|
337
338
|
- lib/html2rss/hash_util.rb
|
|
338
339
|
- lib/html2rss/html.rb
|
|
339
340
|
- lib/html2rss/html/article_extractor.rb
|
|
@@ -398,6 +399,7 @@ files:
|
|
|
398
399
|
- lib/html2rss/selectors/post_processors/sanitize_html.rb
|
|
399
400
|
- lib/html2rss/selectors/post_processors/substring.rb
|
|
400
401
|
- lib/html2rss/selectors/post_processors/template.rb
|
|
402
|
+
- lib/html2rss/status.rb
|
|
401
403
|
- lib/html2rss/url.rb
|
|
402
404
|
- lib/html2rss/version.rb
|
|
403
405
|
- lib/tasks/config_schema.rake
|
|
@@ -407,7 +409,7 @@ licenses:
|
|
|
407
409
|
- MIT
|
|
408
410
|
metadata:
|
|
409
411
|
allowed_push_host: https://rubygems.org
|
|
410
|
-
changelog_uri: https://github.com/html2rss/html2rss/releases/tag/v0.
|
|
412
|
+
changelog_uri: https://github.com/html2rss/html2rss/releases/tag/v0.24.0
|
|
411
413
|
rubygems_mfa_required: 'true'
|
|
412
414
|
rdoc_options: []
|
|
413
415
|
require_paths:
|
|
@@ -1,28 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Html2rss
|
|
4
|
-
class AutoSource
|
|
5
|
-
module Scraper
|
|
6
|
-
class Schema
|
|
7
|
-
##
|
|
8
|
-
# @see https://schema.org/ListItem
|
|
9
|
-
class ListItem < Thing
|
|
10
|
-
# @return [String, nil] stable list-item identifier
|
|
11
|
-
def id = (id = (schema_object.dig(:item, :@id) || super).to_s).empty? ? nil : id
|
|
12
|
-
# @return [String, nil] list-item title
|
|
13
|
-
def title = schema_object.dig(:item, :name) || super || url&.titleized
|
|
14
|
-
# @return [String, nil] list-item description
|
|
15
|
-
def description = schema_object.dig(:item, :description) || super
|
|
16
|
-
|
|
17
|
-
# @return [Html2rss::Url, nil]
|
|
18
|
-
def url
|
|
19
|
-
return @url if defined?(@url)
|
|
20
|
-
|
|
21
|
-
item_url = schema_object.dig(:item, :url)
|
|
22
|
-
@url = item_url ? Url.from_relative(item_url, base_url || item_url) : super
|
|
23
|
-
end
|
|
24
|
-
end
|
|
25
|
-
end
|
|
26
|
-
end
|
|
27
|
-
end
|
|
28
|
-
end
|