html2rss 0.22.2 → 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 +11 -108
- data/lib/html2rss/{articles → article}/deduplicator.rb +3 -4
- data/lib/html2rss/{rss_builder → article}/enclosure.rb +6 -22
- data/lib/html2rss/article.rb +213 -0
- data/lib/html2rss/auto_source/discovery/dom_clustering/group_scorer.rb +80 -0
- data/lib/html2rss/auto_source/discovery/dom_clustering/overlap_resolver.rb +86 -0
- data/lib/html2rss/auto_source/discovery/dom_clustering.rb +119 -0
- data/lib/html2rss/auto_source/discovery/list_candidates.rb +94 -0
- data/lib/html2rss/auto_source/discovery/semantic_anchor_candidates.rb +219 -0
- data/lib/html2rss/auto_source/discovery/semantic_containers.rb +71 -0
- data/lib/html2rss/auto_source/discovery/sitemap.rb +159 -0
- data/lib/html2rss/auto_source/discovery.rb +14 -0
- data/lib/html2rss/auto_source/link_heuristics/anchor_signals.rb +28 -0
- data/lib/html2rss/auto_source/link_heuristics/container_assessor.rb +106 -0
- data/lib/html2rss/auto_source/link_heuristics/container_signals.rb +80 -0
- data/lib/html2rss/auto_source/link_heuristics/destination_facts.rb +42 -0
- data/lib/html2rss/auto_source/link_heuristics/href_extractor.rb +38 -0
- data/lib/html2rss/auto_source/link_heuristics/path_classifier.rb +221 -0
- data/lib/html2rss/auto_source/link_heuristics/text_classifier.rb +66 -0
- data/lib/html2rss/auto_source/link_heuristics.rb +139 -0
- data/lib/html2rss/auto_source/scraper/html.rb +22 -33
- data/lib/html2rss/auto_source/scraper/json_state.rb +1 -1
- data/lib/html2rss/auto_source/scraper/meta_oembed.rb +163 -0
- data/lib/html2rss/auto_source/scraper/microformats2.rb +146 -0
- 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 +35 -7
- data/lib/html2rss/auto_source/scraper/schema.rb +61 -18
- data/lib/html2rss/auto_source/scraper/semantic_html/{deduplicator.rb → entry_deduplicator.rb} +3 -3
- data/lib/html2rss/auto_source/scraper/semantic_html.rb +23 -171
- data/lib/html2rss/auto_source/scraper/sitemap.rb +153 -0
- data/lib/html2rss/auto_source/scraper/wordpress_api/page_scope/date_archive_range.rb +110 -0
- data/lib/html2rss/auto_source/scraper/wordpress_api/page_scope.rb +2 -78
- data/lib/html2rss/auto_source/scraper/wordpress_api.rb +9 -0
- data/lib/html2rss/auto_source/scraper.rb +10 -3
- data/lib/html2rss/auto_source.rb +30 -35
- data/lib/html2rss/channel.rb +198 -0
- data/lib/html2rss/cli.rb +5 -6
- data/lib/html2rss/config/auto_source_contract.rb +51 -0
- data/lib/html2rss/config/request_controls.rb +136 -0
- data/lib/html2rss/config/request_headers.rb +1 -1
- data/lib/html2rss/config/schema.rb +65 -114
- data/lib/html2rss/{selectors/config.rb → config/selectors_validator.rb} +56 -7
- data/lib/html2rss/config/validator.rb +6 -6
- data/lib/html2rss/config.rb +176 -19
- data/lib/html2rss/{configuration.rb → defaults.rb} +12 -10
- data/lib/html2rss/feed_builder/item_presentation.rb +42 -0
- data/lib/html2rss/feed_builder/json_feed/item.rb +112 -0
- data/lib/html2rss/feed_builder/json_feed.rb +81 -0
- data/lib/html2rss/feed_builder/rss/stylesheet.rb +65 -0
- data/lib/html2rss/feed_builder/rss.rb +116 -0
- data/lib/html2rss/feed_builder.rb +15 -0
- data/lib/html2rss/feed_pipeline/auto_fallback.rb +120 -60
- data/lib/html2rss/feed_pipeline/runtime_policy.rb +108 -0
- data/lib/html2rss/feed_pipeline/strategy_plan.rb +64 -0
- data/lib/html2rss/feed_pipeline.rb +83 -89
- data/lib/html2rss/feed_result.rb +92 -0
- data/lib/html2rss/html/article_extractor/category_extractor.rb +123 -0
- data/lib/html2rss/html/article_extractor/date_extractor.rb +22 -0
- data/lib/html2rss/html/article_extractor/enclosure_extractor.rb +95 -0
- data/lib/html2rss/html/article_extractor/heading_extractor.rb +54 -0
- data/lib/html2rss/html/article_extractor/id_generator.rb +69 -0
- data/lib/html2rss/html/article_extractor/image_extractor.rb +60 -0
- data/lib/html2rss/html/article_extractor.rb +134 -0
- data/lib/html2rss/html/navigator/text_extractor.rb +79 -0
- data/lib/html2rss/html/navigator.rb +146 -0
- data/lib/html2rss/html/rendering/audio_renderer.rb +38 -0
- data/lib/html2rss/html/rendering/description_builder.rb +89 -0
- data/lib/html2rss/html/rendering/image_renderer.rb +43 -0
- data/lib/html2rss/html/rendering/media_renderer.rb +39 -0
- data/lib/html2rss/html/rendering/pdf_renderer.rb +36 -0
- data/lib/html2rss/html/rendering/video_renderer.rb +38 -0
- data/lib/html2rss/html/rendering.rb +25 -0
- data/lib/html2rss/html.rb +8 -0
- data/lib/html2rss/request_service/blocked_surface.rb +67 -0
- data/lib/html2rss/request_service/botasaurus_contract.rb +33 -9
- data/lib/html2rss/request_service/botasaurus_strategy.rb +21 -30
- data/lib/html2rss/request_service/browserless_strategy.rb +2 -23
- data/lib/html2rss/request_service/budget.rb +97 -18
- data/lib/html2rss/request_service/context.rb +1 -3
- data/lib/html2rss/request_service/faraday_strategy.rb +12 -26
- data/lib/html2rss/request_service/local_file_strategy.rb +12 -4
- data/lib/html2rss/request_service/network_guard.rb +125 -0
- data/lib/html2rss/request_service/policy.rb +10 -82
- data/lib/html2rss/request_service/puppet_commander/navigation_guards.rb +148 -0
- data/lib/html2rss/request_service/puppet_commander/preload_runner.rb +86 -0
- data/lib/html2rss/request_service/puppet_commander.rb +22 -191
- data/lib/html2rss/request_service/response.rb +9 -1
- data/lib/html2rss/request_service/response_guard.rb +1 -1
- data/lib/html2rss/request_service/strategy.rb +123 -5
- data/lib/html2rss/request_service.rb +7 -2
- data/lib/html2rss/request_session/pager/base.rb +83 -0
- data/lib/html2rss/request_session/pager/custom_selector.rb +39 -0
- data/lib/html2rss/request_session/pager/json_cursor.rb +78 -0
- data/lib/html2rss/request_session/pager/offset.rb +48 -0
- data/lib/html2rss/request_session/pager/rel_next.rb +23 -0
- data/lib/html2rss/request_session/pager/url_template.rb +48 -0
- data/lib/html2rss/request_session/pager.rb +75 -0
- data/lib/html2rss/request_session.rb +29 -16
- data/lib/html2rss/selectors/item_scope.rb +30 -0
- data/lib/html2rss/selectors/post_processors/base.rb +1 -1
- data/lib/html2rss/selectors/post_processors/markdown_to_html.rb +0 -1
- data/lib/html2rss/selectors/post_processors/sanitize_html.rb +0 -2
- data/lib/html2rss/selectors/post_processors/template.rb +7 -3
- data/lib/html2rss/selectors.rb +84 -64
- data/lib/html2rss/status.rb +132 -0
- data/lib/html2rss/version.rb +1 -3
- data/lib/html2rss.rb +34 -22
- data/schema/html2rss-config.schema.json +109 -9
- metadata +71 -43
- data/lib/html2rss/auto_source/scraper/html/class_clustering.rb +0 -196
- data/lib/html2rss/auto_source/scraper/link_heuristics.rb +0 -401
- data/lib/html2rss/auto_source/scraper/schema/list_item.rb +0 -28
- data/lib/html2rss/auto_source/scraper/semantic_html/anchor_selector.rb +0 -49
- data/lib/html2rss/blocked_surface.rb +0 -65
- data/lib/html2rss/category_extractor.rb +0 -116
- data/lib/html2rss/config/class_methods.rb +0 -183
- data/lib/html2rss/html_extractor/date_extractor.rb +0 -20
- data/lib/html2rss/html_extractor/enclosure_extractor.rb +0 -91
- data/lib/html2rss/html_extractor/heading_extractor.rb +0 -50
- data/lib/html2rss/html_extractor/id_generator.rb +0 -67
- data/lib/html2rss/html_extractor/image_extractor.rb +0 -58
- data/lib/html2rss/html_extractor/list_candidates.rb +0 -88
- data/lib/html2rss/html_extractor/semantic_anchor_candidates.rb +0 -260
- data/lib/html2rss/html_extractor/semantic_containers.rb +0 -69
- data/lib/html2rss/html_extractor/text_extractor.rb +0 -77
- data/lib/html2rss/html_extractor.rb +0 -190
- data/lib/html2rss/html_navigator.rb +0 -71
- data/lib/html2rss/json_feed_builder/item.rb +0 -94
- data/lib/html2rss/json_feed_builder.rb +0 -59
- data/lib/html2rss/rendering/audio_renderer.rb +0 -36
- data/lib/html2rss/rendering/description_builder.rb +0 -87
- data/lib/html2rss/rendering/image_renderer.rb +0 -41
- data/lib/html2rss/rendering/media_renderer.rb +0 -37
- data/lib/html2rss/rendering/pdf_renderer.rb +0 -34
- data/lib/html2rss/rendering/video_renderer.rb +0 -36
- data/lib/html2rss/rendering.rb +0 -23
- data/lib/html2rss/request_controls.rb +0 -133
- data/lib/html2rss/request_session/rel_next_pager.rb +0 -70
- data/lib/html2rss/request_session/runtime_input.rb +0 -71
- data/lib/html2rss/request_session/runtime_policy.rb +0 -84
- data/lib/html2rss/rss_builder/article.rb +0 -208
- data/lib/html2rss/rss_builder/channel.rb +0 -118
- data/lib/html2rss/rss_builder/stylesheet.rb +0 -63
- data/lib/html2rss/rss_builder.rb +0 -101
|
@@ -1,183 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Html2rss
|
|
4
|
-
class Config
|
|
5
|
-
##
|
|
6
|
-
# Public class-level helpers for loading, validating, and exporting config.
|
|
7
|
-
module ClassMethods
|
|
8
|
-
# Sentinel to differentiate omitted params from explicit `nil`.
|
|
9
|
-
UNSET = Object.new.freeze
|
|
10
|
-
|
|
11
|
-
##
|
|
12
|
-
# Returns the exported JSON Schema for html2rss configuration.
|
|
13
|
-
#
|
|
14
|
-
# @return [Hash{String => Object}] JSON Schema represented as a Ruby hash
|
|
15
|
-
def json_schema
|
|
16
|
-
Schema.json_schema
|
|
17
|
-
end
|
|
18
|
-
|
|
19
|
-
##
|
|
20
|
-
# Returns the exported JSON Schema as JSON.
|
|
21
|
-
#
|
|
22
|
-
# @param pretty [Boolean] whether to pretty-print the JSON output
|
|
23
|
-
# @return [String] serialized JSON Schema
|
|
24
|
-
def json_schema_json(pretty: true)
|
|
25
|
-
pretty ? JSON.pretty_generate(json_schema) : JSON.generate(json_schema)
|
|
26
|
-
end
|
|
27
|
-
|
|
28
|
-
##
|
|
29
|
-
# Validates a configuration hash with the runtime validator.
|
|
30
|
-
#
|
|
31
|
-
# @param config [Hash{Symbol => Object}] the configuration hash
|
|
32
|
-
# @param params [Hash{Symbol => Object, Hash{String => Object, nil}}] dynamic parameters for string formatting
|
|
33
|
-
# @return [Dry::Validation::Result] validation result after defaults and deprecations are applied
|
|
34
|
-
def validate(config, params: UNSET)
|
|
35
|
-
prepared_config = prepare_for_validation(resolve_effective_config(config, params:))
|
|
36
|
-
|
|
37
|
-
Validator.new.call(prepared_config)
|
|
38
|
-
rescue DynamicParams::ParamsMissing => error
|
|
39
|
-
prepared_config = prepare_for_validation(HashUtil.deep_symbolize_keys(config, context: 'config'))
|
|
40
|
-
prepared_config[:dynamic_params_error] = error.message
|
|
41
|
-
|
|
42
|
-
Validator.new.call(prepared_config)
|
|
43
|
-
end
|
|
44
|
-
|
|
45
|
-
##
|
|
46
|
-
# Returns the packaged JSON Schema file path.
|
|
47
|
-
#
|
|
48
|
-
# @return [String] absolute path to the packaged JSON Schema file
|
|
49
|
-
def schema_path
|
|
50
|
-
Schema.path
|
|
51
|
-
end
|
|
52
|
-
|
|
53
|
-
##
|
|
54
|
-
# Loads and validates a YAML configuration file.
|
|
55
|
-
#
|
|
56
|
-
# @param file [String] the YAML file to load
|
|
57
|
-
# @param feed_name [String, nil] optional feed name for multi-feed files
|
|
58
|
-
# @param multiple_feeds_key [Symbol] key under which multiple feeds are defined
|
|
59
|
-
# @param params [Hash{Symbol => Object, Hash{String => Object, nil}}] dynamic parameters for string formatting
|
|
60
|
-
# @return [Dry::Validation::Result] validation result after defaults and deprecations are applied
|
|
61
|
-
def validate_yaml(file, feed_name = nil, multiple_feeds_key: MultipleFeedsConfig::CONFIG_KEY_FEEDS, params: UNSET)
|
|
62
|
-
validate(load_yaml(file, feed_name, multiple_feeds_key:), params:)
|
|
63
|
-
end
|
|
64
|
-
|
|
65
|
-
##
|
|
66
|
-
# Loads the feed configuration from a YAML file.
|
|
67
|
-
#
|
|
68
|
-
# Supports multiple feeds defined under the specified key (default :feeds).
|
|
69
|
-
#
|
|
70
|
-
# @param file [String] the YAML file to load.
|
|
71
|
-
# @param feed_name [String, nil] the feed name when using multiple feeds.
|
|
72
|
-
# @param multiple_feeds_key [Symbol] the key under which multiple feeds are defined.
|
|
73
|
-
# @return [Hash{Symbol => Object}] the configuration hash.
|
|
74
|
-
# @raise [ArgumentError] if the file doesn't exist or feed is not found.
|
|
75
|
-
# rubocop:disable Metrics/MethodLength
|
|
76
|
-
def load_yaml(file, feed_name = nil, multiple_feeds_key: MultipleFeedsConfig::CONFIG_KEY_FEEDS)
|
|
77
|
-
raise ArgumentError, "File '#{file}' does not exist" unless File.exist?(file)
|
|
78
|
-
raise ArgumentError, "`#{multiple_feeds_key}` is a reserved feed name" if feed_name == multiple_feeds_key
|
|
79
|
-
|
|
80
|
-
yaml = YAML.safe_load_file(file, symbolize_names: true)
|
|
81
|
-
|
|
82
|
-
return yaml unless yaml.key?(multiple_feeds_key)
|
|
83
|
-
|
|
84
|
-
unless feed_name
|
|
85
|
-
available_feeds = yaml.fetch(multiple_feeds_key).keys.join(', ')
|
|
86
|
-
raise ArgumentError,
|
|
87
|
-
"Feed name is required under `#{multiple_feeds_key}`. Available feeds: #{available_feeds}"
|
|
88
|
-
end
|
|
89
|
-
|
|
90
|
-
config = yaml.dig(multiple_feeds_key, feed_name.to_sym)
|
|
91
|
-
raise ArgumentError, "Feed '#{feed_name}' not found under `#{multiple_feeds_key}` key." unless config
|
|
92
|
-
|
|
93
|
-
MultipleFeedsConfig.to_single_feed(config, yaml, multiple_feeds_key:)
|
|
94
|
-
end
|
|
95
|
-
# rubocop:enable Metrics/MethodLength
|
|
96
|
-
|
|
97
|
-
##
|
|
98
|
-
# Processes the provided configuration hash, applying dynamic parameters if given,
|
|
99
|
-
# and returns a new configuration object.
|
|
100
|
-
#
|
|
101
|
-
# @param config [Hash{Symbol => Object}] the configuration hash.
|
|
102
|
-
# @param params [Hash{Symbol => Object, Hash{String => Object, nil}}] dynamic parameters for string formatting.
|
|
103
|
-
# @return [Html2rss::Config] the configuration object.
|
|
104
|
-
def from_hash(config, params: UNSET)
|
|
105
|
-
new(resolve_effective_config(config, params:))
|
|
106
|
-
end
|
|
107
|
-
|
|
108
|
-
##
|
|
109
|
-
# Builds a top-level auto-source feed config for the public shortcut APIs.
|
|
110
|
-
#
|
|
111
|
-
# @param url [String] source page URL
|
|
112
|
-
# @param items_selector [String, nil] optional selector hint for item extraction
|
|
113
|
-
# @param request_controls [Html2rss::RequestControls, nil] explicit request controls to write
|
|
114
|
-
# @return [Hash{Symbol => Object}] feed config hash ready for {from_hash}
|
|
115
|
-
def auto_source_config(url:, items_selector: nil, request_controls: nil)
|
|
116
|
-
config = {
|
|
117
|
-
channel: default_config[:channel].merge(url:),
|
|
118
|
-
auto_source: AutoSource::DEFAULT_CONFIG
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
request_controls ||= Html2rss::RequestControls.new
|
|
122
|
-
request_controls.apply_to(config)
|
|
123
|
-
|
|
124
|
-
config[:selectors] = { items: { selector: items_selector, enhance: true } } if items_selector
|
|
125
|
-
config
|
|
126
|
-
end
|
|
127
|
-
|
|
128
|
-
##
|
|
129
|
-
# Provides a default configuration.
|
|
130
|
-
#
|
|
131
|
-
# @return [Hash{Symbol => Object}] a hash with default configuration values.
|
|
132
|
-
def default_config
|
|
133
|
-
{
|
|
134
|
-
strategy: default_strategy_name,
|
|
135
|
-
request: default_request_config,
|
|
136
|
-
channel: { time_zone: 'UTC' },
|
|
137
|
-
headers: RequestHeaders.browser_defaults,
|
|
138
|
-
stylesheets: Html2rss.configuration.stylesheets || []
|
|
139
|
-
}
|
|
140
|
-
end
|
|
141
|
-
|
|
142
|
-
# @return [Symbol] the default strategy for feed orchestration
|
|
143
|
-
def default_strategy_name
|
|
144
|
-
Html2rss.configuration.default_strategy || :auto
|
|
145
|
-
end
|
|
146
|
-
|
|
147
|
-
private
|
|
148
|
-
|
|
149
|
-
def default_request_config
|
|
150
|
-
{
|
|
151
|
-
max_redirects: RequestService::Policy::DEFAULTS[:max_redirects],
|
|
152
|
-
max_requests: RequestService::Policy::DEFAULTS[:max_requests],
|
|
153
|
-
total_timeout_seconds: RequestService::Policy::DEFAULTS[:total_timeout_seconds]
|
|
154
|
-
}
|
|
155
|
-
end
|
|
156
|
-
|
|
157
|
-
def resolve_effective_config(config, params:)
|
|
158
|
-
effective_config = HashUtil.deep_symbolize_keys(config, context: 'config')
|
|
159
|
-
resolved_params = parameter_defaults(effective_config)
|
|
160
|
-
unless params.equal?(UNSET) || params.nil?
|
|
161
|
-
resolved_params.merge!(HashUtil.deep_symbolize_keys(params, context: 'params'))
|
|
162
|
-
end
|
|
163
|
-
|
|
164
|
-
effective_config[:headers] = DynamicParams.call(effective_config[:headers], resolved_params)
|
|
165
|
-
effective_config[:channel] = DynamicParams.call(effective_config[:channel], resolved_params)
|
|
166
|
-
|
|
167
|
-
effective_config
|
|
168
|
-
end
|
|
169
|
-
|
|
170
|
-
def parameter_defaults(config)
|
|
171
|
-
config.fetch(:parameters, {})
|
|
172
|
-
.filter_map do |name, definition|
|
|
173
|
-
[name, definition[:default]] if definition.is_a?(Hash) && definition.key?(:default)
|
|
174
|
-
end
|
|
175
|
-
.to_h
|
|
176
|
-
end
|
|
177
|
-
|
|
178
|
-
def prepare_for_validation(config)
|
|
179
|
-
Config::Preparer.new.call(HashUtil.deep_dup(config))
|
|
180
|
-
end
|
|
181
|
-
end
|
|
182
|
-
end
|
|
183
|
-
end
|
|
@@ -1,20 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Html2rss
|
|
4
|
-
class HtmlExtractor
|
|
5
|
-
# Extracts the earliest date from an article_tag.
|
|
6
|
-
class DateExtractor
|
|
7
|
-
# @param article_tag [Nokogiri::XML::Element] article container node
|
|
8
|
-
# @return [DateTime, nil]
|
|
9
|
-
def self.call(article_tag)
|
|
10
|
-
times = article_tag.css('[datetime]').filter_map do |tag|
|
|
11
|
-
DateTime.parse(tag['datetime'])
|
|
12
|
-
rescue ArgumentError, TypeError
|
|
13
|
-
nil
|
|
14
|
-
end
|
|
15
|
-
|
|
16
|
-
times.min
|
|
17
|
-
end
|
|
18
|
-
end
|
|
19
|
-
end
|
|
20
|
-
end
|
|
@@ -1,91 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Html2rss
|
|
4
|
-
class HtmlExtractor
|
|
5
|
-
##
|
|
6
|
-
# Extracts enclosures from HTML tags using various strategies.
|
|
7
|
-
class EnclosureExtractor
|
|
8
|
-
# CSS union query covering images, media, PDFs, iframes, and archives.
|
|
9
|
-
SELECTOR = [
|
|
10
|
-
'img[src]:not([src^="data"])',
|
|
11
|
-
'video source[src]',
|
|
12
|
-
'audio source[src]',
|
|
13
|
-
'audio[src]',
|
|
14
|
-
'a[href$=".pdf"]',
|
|
15
|
-
'iframe[src]',
|
|
16
|
-
'a[href$=".zip"]',
|
|
17
|
-
'a[href$=".tar.gz"]',
|
|
18
|
-
'a[href$=".tgz"]'
|
|
19
|
-
].join(',').freeze
|
|
20
|
-
|
|
21
|
-
# @param article_tag [Nokogiri::XML::Element] article container node
|
|
22
|
-
# @param base_url [String, Html2rss::Url] base URL for relative enclosure links
|
|
23
|
-
# @return [Array<Hash{Symbol => Object}>] normalized enclosure hashes
|
|
24
|
-
def self.call(article_tag, base_url)
|
|
25
|
-
article_tag.css(SELECTOR).filter_map do |element|
|
|
26
|
-
extract_from_element(element, base_url)
|
|
27
|
-
end
|
|
28
|
-
end
|
|
29
|
-
|
|
30
|
-
def self.extract_from_element(element, base_url)
|
|
31
|
-
case element.name
|
|
32
|
-
when 'img'
|
|
33
|
-
extract_image(element, base_url)
|
|
34
|
-
when 'video', 'audio', 'source'
|
|
35
|
-
extract_media(element, base_url)
|
|
36
|
-
when 'iframe'
|
|
37
|
-
extract_iframe(element, base_url)
|
|
38
|
-
when 'a'
|
|
39
|
-
extract_a(element, base_url)
|
|
40
|
-
end
|
|
41
|
-
end
|
|
42
|
-
|
|
43
|
-
def self.extract_image(img, base_url)
|
|
44
|
-
src = img['src'].to_s
|
|
45
|
-
return if src.empty?
|
|
46
|
-
|
|
47
|
-
abs_url = Url.from_relative(src, base_url)
|
|
48
|
-
{
|
|
49
|
-
url: abs_url,
|
|
50
|
-
type: RssBuilder::Enclosure.guess_content_type_from_url(abs_url, default: 'image/jpeg')
|
|
51
|
-
}
|
|
52
|
-
end
|
|
53
|
-
|
|
54
|
-
def self.extract_media(element, base_url)
|
|
55
|
-
src = element['src'].to_s
|
|
56
|
-
return if src.empty?
|
|
57
|
-
|
|
58
|
-
{
|
|
59
|
-
url: Url.from_relative(src, base_url),
|
|
60
|
-
type: element['type']
|
|
61
|
-
}
|
|
62
|
-
end
|
|
63
|
-
|
|
64
|
-
def self.extract_iframe(iframe, base_url)
|
|
65
|
-
src = iframe['src'].to_s
|
|
66
|
-
return if src.empty?
|
|
67
|
-
|
|
68
|
-
abs_url = Url.from_relative(src, base_url)
|
|
69
|
-
{
|
|
70
|
-
url: abs_url,
|
|
71
|
-
type: RssBuilder::Enclosure.guess_content_type_from_url(abs_url, default: 'text/html')
|
|
72
|
-
}
|
|
73
|
-
end
|
|
74
|
-
|
|
75
|
-
def self.extract_a(link, base_url)
|
|
76
|
-
href = link['href'].to_s
|
|
77
|
-
return if href.empty?
|
|
78
|
-
|
|
79
|
-
abs_url = Url.from_relative(href, base_url)
|
|
80
|
-
|
|
81
|
-
if href.end_with?('.pdf')
|
|
82
|
-
{ url: abs_url, type: RssBuilder::Enclosure.guess_content_type_from_url(abs_url) }
|
|
83
|
-
else
|
|
84
|
-
{ url: abs_url, type: 'application/zip' }
|
|
85
|
-
end
|
|
86
|
-
end
|
|
87
|
-
|
|
88
|
-
private_class_method :extract_from_element, :extract_image, :extract_media, :extract_iframe, :extract_a
|
|
89
|
-
end
|
|
90
|
-
end
|
|
91
|
-
end
|
|
@@ -1,50 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Html2rss
|
|
4
|
-
class HtmlExtractor
|
|
5
|
-
##
|
|
6
|
-
# HeadingExtractor identifies and returns the best heading element within a container.
|
|
7
|
-
class HeadingExtractor
|
|
8
|
-
# Heading tags used to prioritize title extraction.
|
|
9
|
-
HEADING_TAGS = HtmlExtractor::HEADING_TAGS
|
|
10
|
-
|
|
11
|
-
class << self
|
|
12
|
-
##
|
|
13
|
-
# @param article_tag [Nokogiri::XML::Element] container node
|
|
14
|
-
# @param fallback_anchorless [Boolean] whether to use fallback search
|
|
15
|
-
# @param selected_anchor [Nokogiri::XML::Node, nil] anchor element
|
|
16
|
-
# @return [Nokogiri::XML::Node, nil] the heading node, if found
|
|
17
|
-
def call(article_tag, fallback_anchorless:, selected_anchor:)
|
|
18
|
-
tags = article_tag.css(HEADING_TAGS.join(','))
|
|
19
|
-
if tags.any?
|
|
20
|
-
select_best_heading(tags)
|
|
21
|
-
elsif fallback_anchorless && selected_anchor.nil?
|
|
22
|
-
fallback_heading(article_tag)
|
|
23
|
-
end
|
|
24
|
-
end
|
|
25
|
-
|
|
26
|
-
private
|
|
27
|
-
|
|
28
|
-
def select_best_heading(tags)
|
|
29
|
-
min_tag_name = tags.map(&:name).min
|
|
30
|
-
best_tag = nil
|
|
31
|
-
max_size = -1
|
|
32
|
-
|
|
33
|
-
tags.each do |tag|
|
|
34
|
-
next if tag.name != min_tag_name
|
|
35
|
-
|
|
36
|
-
size = TextExtractor.call(tag)&.size.to_i
|
|
37
|
-
(best_tag = tag) && (max_size = size) if size > max_size
|
|
38
|
-
end
|
|
39
|
-
|
|
40
|
-
best_tag
|
|
41
|
-
end
|
|
42
|
-
|
|
43
|
-
def fallback_heading(article_tag)
|
|
44
|
-
fallback_tags = article_tag.css('strong, b, [class*="title"], [class*="font-bold"], [class*="font-semibold"]')
|
|
45
|
-
fallback_tags.find { |t| !TextExtractor.call(t).to_s.strip.empty? }
|
|
46
|
-
end
|
|
47
|
-
end
|
|
48
|
-
end
|
|
49
|
-
end
|
|
50
|
-
end
|
|
@@ -1,67 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require 'zlib'
|
|
4
|
-
|
|
5
|
-
module Html2rss
|
|
6
|
-
class HtmlExtractor
|
|
7
|
-
##
|
|
8
|
-
# IdGenerator determines the unique ID for an article container node.
|
|
9
|
-
class IdGenerator
|
|
10
|
-
class << self
|
|
11
|
-
##
|
|
12
|
-
# @param article_tag [Nokogiri::XML::Element] container node
|
|
13
|
-
# @param heading [Nokogiri::XML::Node, nil] heading node
|
|
14
|
-
# @param url [Html2rss::Url, nil] absolute article URL
|
|
15
|
-
# @param selected_anchor [Nokogiri::XML::Node, nil] anchor element
|
|
16
|
-
# @param fallback_anchorless [Boolean] whether to use fallback hashing
|
|
17
|
-
# @return [String, nil] the generated ID, if any
|
|
18
|
-
def call(article_tag, heading:, url:, selected_anchor:, fallback_anchorless:)
|
|
19
|
-
id_from_dom = parse_id_from_dom(article_tag, url, selected_anchor)
|
|
20
|
-
return id_from_dom if id_from_dom
|
|
21
|
-
|
|
22
|
-
heading_text = resolve_heading_text(article_tag, heading, fallback_anchorless)
|
|
23
|
-
if heading_text && !heading_text.strip.empty?
|
|
24
|
-
generate_slug(heading_text)
|
|
25
|
-
elsif fallback_anchorless
|
|
26
|
-
generate_content_hash(article_tag)
|
|
27
|
-
end
|
|
28
|
-
end
|
|
29
|
-
|
|
30
|
-
private
|
|
31
|
-
|
|
32
|
-
def parse_id_from_dom(article_tag, url, selected_anchor)
|
|
33
|
-
candidates = [article_tag['id'], article_tag.at_css('[id]')&.attr('id')]
|
|
34
|
-
candidates += [url&.path, url&.query] if selected_anchor
|
|
35
|
-
candidates.compact.reject(&:empty?).first
|
|
36
|
-
end
|
|
37
|
-
|
|
38
|
-
def resolve_heading_text(article_tag, heading, fallback_anchorless)
|
|
39
|
-
text = heading ? TextExtractor.call(heading) : nil
|
|
40
|
-
if text.nil? || text.strip.empty?
|
|
41
|
-
fallback_text_node_content(article_tag, fallback_anchorless)
|
|
42
|
-
else
|
|
43
|
-
text
|
|
44
|
-
end
|
|
45
|
-
end
|
|
46
|
-
|
|
47
|
-
def fallback_text_node_content(article_tag, fallback_anchorless)
|
|
48
|
-
return unless fallback_anchorless
|
|
49
|
-
|
|
50
|
-
article_tag.xpath('.//text()').find { |t| !t.text.strip.empty? }&.text&.strip
|
|
51
|
-
end
|
|
52
|
-
|
|
53
|
-
def generate_slug(text)
|
|
54
|
-
slug = text.downcase.gsub(/[^a-z0-9]+/, '-')
|
|
55
|
-
slug = slug[1..] if slug.start_with?('-')
|
|
56
|
-
slug = slug[0..-2] if slug.end_with?('-')
|
|
57
|
-
slug unless slug.empty?
|
|
58
|
-
end
|
|
59
|
-
|
|
60
|
-
def generate_content_hash(article_tag)
|
|
61
|
-
text = TextExtractor.call(article_tag).to_s.strip
|
|
62
|
-
Zlib.crc32(text).to_s(36) unless text.empty?
|
|
63
|
-
end
|
|
64
|
-
end
|
|
65
|
-
end
|
|
66
|
-
end
|
|
67
|
-
end
|
|
@@ -1,58 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Html2rss
|
|
4
|
-
class HtmlExtractor
|
|
5
|
-
##
|
|
6
|
-
# Image is responsible for extracting image URLs the article_tag.
|
|
7
|
-
class ImageExtractor
|
|
8
|
-
# @param article_tag [Nokogiri::XML::Element] article container node
|
|
9
|
-
# @param base_url [String, Html2rss::Url] base URL for relative image URLs
|
|
10
|
-
# @return [Html2rss::Url, nil] best candidate image URL
|
|
11
|
-
def self.call(article_tag, base_url:)
|
|
12
|
-
img_src = from_source(article_tag) ||
|
|
13
|
-
from_img(article_tag) ||
|
|
14
|
-
from_style(article_tag)
|
|
15
|
-
|
|
16
|
-
Url.from_relative(img_src, base_url) if img_src
|
|
17
|
-
end
|
|
18
|
-
|
|
19
|
-
# @param article_tag [Nokogiri::XML::Element] article container node
|
|
20
|
-
# @return [String, nil] src attribute from first matching image tag
|
|
21
|
-
def self.from_img(article_tag)
|
|
22
|
-
article_tag.at_css('img[src]:not([src^="data"])')&.[]('src')
|
|
23
|
-
end
|
|
24
|
-
|
|
25
|
-
##
|
|
26
|
-
# Extracts the largest image source from the srcset attribute
|
|
27
|
-
# of an img tag or a source tag inside a picture tag.
|
|
28
|
-
#
|
|
29
|
-
# @param article_tag [Nokogiri::XML::Element] article container node
|
|
30
|
-
# @return [String, nil] largest srcset URL candidate
|
|
31
|
-
# @see <https://developer.mozilla.org/en-US/docs/Learn/HTML/Multimedia_and_embedding/Responsive_images>
|
|
32
|
-
# @see <https://developer.mozilla.org/en-US/docs/Web/HTML/Element/img#srcset>
|
|
33
|
-
# @see <https://developer.mozilla.org/en-US/docs/Web/HTML/Element/picture>
|
|
34
|
-
def self.from_source(article_tag) # rubocop:disable Metrics/AbcSize
|
|
35
|
-
hash = article_tag.css('img[srcset], picture > source[srcset]').flat_map do |source|
|
|
36
|
-
source['srcset'].to_s.scan(/(\S+)\s+(\d+w|\d+h)[\s,]?/).map do |url, width|
|
|
37
|
-
next if url.nil? || url.start_with?('data:')
|
|
38
|
-
|
|
39
|
-
width_value = width.to_i.zero? ? 0 : width.scan(/\d+/).first.to_i
|
|
40
|
-
|
|
41
|
-
[width_value, url.strip]
|
|
42
|
-
end
|
|
43
|
-
end.compact.to_h
|
|
44
|
-
|
|
45
|
-
hash[hash.keys.max]
|
|
46
|
-
end
|
|
47
|
-
|
|
48
|
-
# @param article_tag [Nokogiri::XML::Element] article container node
|
|
49
|
-
# @return [String, nil] best style-based background image URL
|
|
50
|
-
def self.from_style(article_tag)
|
|
51
|
-
article_tag.css('[style*="url"]')
|
|
52
|
-
.filter_map { |tag| tag['style'][/url\(['"]?(.*?)['"]?\)/, 1] }
|
|
53
|
-
.reject { |src| src.start_with?('data:') }
|
|
54
|
-
.max_by(&:size)
|
|
55
|
-
end
|
|
56
|
-
end
|
|
57
|
-
end
|
|
58
|
-
end
|
|
@@ -1,88 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Html2rss
|
|
4
|
-
class HtmlExtractor
|
|
5
|
-
##
|
|
6
|
-
# Builds repeated-list article container candidates from generic HTML.
|
|
7
|
-
class ListCandidates
|
|
8
|
-
##
|
|
9
|
-
# Simplify an XPath selector by removing index notation.
|
|
10
|
-
#
|
|
11
|
-
# @param xpath [String] original XPath
|
|
12
|
-
# @return [String] XPath without positional indexes
|
|
13
|
-
def self.simplify_xpath(xpath)
|
|
14
|
-
xpath.gsub(/\[\d+\]/, '')
|
|
15
|
-
end
|
|
16
|
-
|
|
17
|
-
# @param parsed_body [Nokogiri::HTML::Document] parsed document
|
|
18
|
-
# @param minimum_selector_frequency [Integer] minimum repeated anchor path count
|
|
19
|
-
# @param use_top_selectors [Integer] number of frequent anchor paths to inspect
|
|
20
|
-
def initialize(parsed_body, minimum_selector_frequency:, use_top_selectors:)
|
|
21
|
-
@parsed_body = parsed_body
|
|
22
|
-
@minimum_selector_frequency = minimum_selector_frequency
|
|
23
|
-
@use_top_selectors = use_top_selectors
|
|
24
|
-
end
|
|
25
|
-
|
|
26
|
-
##
|
|
27
|
-
# @param anchor_filter [#call] predicate for scraper-specific anchor eligibility
|
|
28
|
-
# @param boundary_condition [#call] predicate for article container boundary
|
|
29
|
-
# @yieldparam article_tag [Nokogiri::XML::Node] candidate article container
|
|
30
|
-
# @yieldparam selected_anchor [Nokogiri::XML::Node] anchor that made the container eligible
|
|
31
|
-
# @return [Enumerator]
|
|
32
|
-
def each_article_tag(anchor_filter:, boundary_condition:)
|
|
33
|
-
return enum_for(:each_article_tag, anchor_filter:, boundary_condition:) unless block_given?
|
|
34
|
-
|
|
35
|
-
article_tags(anchor_filter:, boundary_condition:).each { yield _1[:article_tag], _1[:selected_anchor] }
|
|
36
|
-
end
|
|
37
|
-
|
|
38
|
-
private
|
|
39
|
-
|
|
40
|
-
attr_reader :parsed_body, :minimum_selector_frequency, :use_top_selectors
|
|
41
|
-
|
|
42
|
-
def article_tags(anchor_filter:, boundary_condition:)
|
|
43
|
-
selectors(anchor_filter:).flat_map do |selector|
|
|
44
|
-
article_tags_for_selector(selector, boundary_condition)
|
|
45
|
-
end
|
|
46
|
-
end
|
|
47
|
-
|
|
48
|
-
def article_tags_for_selector(selector, boundary_condition)
|
|
49
|
-
parsed_body.xpath(selector).filter_map do |selected_tag|
|
|
50
|
-
next if HtmlExtractor.ignored_container_path?(selected_tag)
|
|
51
|
-
|
|
52
|
-
article_tag = HtmlNavigator.parent_until_condition(selected_tag, boundary_condition)
|
|
53
|
-
next unless article_tag
|
|
54
|
-
|
|
55
|
-
{ article_tag:, selected_anchor: selected_tag }
|
|
56
|
-
end
|
|
57
|
-
end
|
|
58
|
-
|
|
59
|
-
def selectors(anchor_filter:)
|
|
60
|
-
anchor_counts(anchor_filter:)
|
|
61
|
-
.select { |_selector, count| count >= minimum_selector_frequency }
|
|
62
|
-
.max_by(use_top_selectors, &:last)
|
|
63
|
-
.map(&:first)
|
|
64
|
-
end
|
|
65
|
-
|
|
66
|
-
def anchor_counts(anchor_filter:)
|
|
67
|
-
Hash.new(0).tap do |counts|
|
|
68
|
-
each_anchor(anchor_filter:) do |node|
|
|
69
|
-
path = self.class.simplify_xpath(node.path)
|
|
70
|
-
counts[path] += 1 unless HtmlExtractor.ignored_container_path?(path)
|
|
71
|
-
end
|
|
72
|
-
end
|
|
73
|
-
end
|
|
74
|
-
|
|
75
|
-
def each_anchor(anchor_filter:)
|
|
76
|
-
return enum_for(:each_anchor, anchor_filter:) unless block_given?
|
|
77
|
-
|
|
78
|
-
traversal_root&.css(HtmlExtractor::MAIN_ANCHOR_SELECTOR)&.each do |node|
|
|
79
|
-
yield node if anchor_filter.call(node)
|
|
80
|
-
end
|
|
81
|
-
end
|
|
82
|
-
|
|
83
|
-
def traversal_root
|
|
84
|
-
parsed_body.at_css('body, html') || parsed_body.root
|
|
85
|
-
end
|
|
86
|
-
end
|
|
87
|
-
end
|
|
88
|
-
end
|