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.
Files changed (146) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +11 -108
  3. data/lib/html2rss/{articles → article}/deduplicator.rb +3 -4
  4. data/lib/html2rss/{rss_builder → article}/enclosure.rb +6 -22
  5. data/lib/html2rss/article.rb +213 -0
  6. data/lib/html2rss/auto_source/discovery/dom_clustering/group_scorer.rb +80 -0
  7. data/lib/html2rss/auto_source/discovery/dom_clustering/overlap_resolver.rb +86 -0
  8. data/lib/html2rss/auto_source/discovery/dom_clustering.rb +119 -0
  9. data/lib/html2rss/auto_source/discovery/list_candidates.rb +94 -0
  10. data/lib/html2rss/auto_source/discovery/semantic_anchor_candidates.rb +219 -0
  11. data/lib/html2rss/auto_source/discovery/semantic_containers.rb +71 -0
  12. data/lib/html2rss/auto_source/discovery/sitemap.rb +159 -0
  13. data/lib/html2rss/auto_source/discovery.rb +14 -0
  14. data/lib/html2rss/auto_source/link_heuristics/anchor_signals.rb +28 -0
  15. data/lib/html2rss/auto_source/link_heuristics/container_assessor.rb +106 -0
  16. data/lib/html2rss/auto_source/link_heuristics/container_signals.rb +80 -0
  17. data/lib/html2rss/auto_source/link_heuristics/destination_facts.rb +42 -0
  18. data/lib/html2rss/auto_source/link_heuristics/href_extractor.rb +38 -0
  19. data/lib/html2rss/auto_source/link_heuristics/path_classifier.rb +221 -0
  20. data/lib/html2rss/auto_source/link_heuristics/text_classifier.rb +66 -0
  21. data/lib/html2rss/auto_source/link_heuristics.rb +139 -0
  22. data/lib/html2rss/auto_source/scraper/html.rb +22 -33
  23. data/lib/html2rss/auto_source/scraper/json_state.rb +1 -1
  24. data/lib/html2rss/auto_source/scraper/meta_oembed.rb +163 -0
  25. data/lib/html2rss/auto_source/scraper/microformats2.rb +146 -0
  26. data/lib/html2rss/auto_source/scraper/schema/category_extractor.rb +29 -114
  27. data/lib/html2rss/auto_source/scraper/schema/item_list.rb +50 -11
  28. data/lib/html2rss/auto_source/scraper/schema/thing.rb +35 -7
  29. data/lib/html2rss/auto_source/scraper/schema.rb +61 -18
  30. data/lib/html2rss/auto_source/scraper/semantic_html/{deduplicator.rb → entry_deduplicator.rb} +3 -3
  31. data/lib/html2rss/auto_source/scraper/semantic_html.rb +23 -171
  32. data/lib/html2rss/auto_source/scraper/sitemap.rb +153 -0
  33. data/lib/html2rss/auto_source/scraper/wordpress_api/page_scope/date_archive_range.rb +110 -0
  34. data/lib/html2rss/auto_source/scraper/wordpress_api/page_scope.rb +2 -78
  35. data/lib/html2rss/auto_source/scraper/wordpress_api.rb +9 -0
  36. data/lib/html2rss/auto_source/scraper.rb +10 -3
  37. data/lib/html2rss/auto_source.rb +30 -35
  38. data/lib/html2rss/channel.rb +198 -0
  39. data/lib/html2rss/cli.rb +5 -6
  40. data/lib/html2rss/config/auto_source_contract.rb +51 -0
  41. data/lib/html2rss/config/request_controls.rb +136 -0
  42. data/lib/html2rss/config/request_headers.rb +1 -1
  43. data/lib/html2rss/config/schema.rb +65 -114
  44. data/lib/html2rss/{selectors/config.rb → config/selectors_validator.rb} +56 -7
  45. data/lib/html2rss/config/validator.rb +6 -6
  46. data/lib/html2rss/config.rb +176 -19
  47. data/lib/html2rss/{configuration.rb → defaults.rb} +12 -10
  48. data/lib/html2rss/feed_builder/item_presentation.rb +42 -0
  49. data/lib/html2rss/feed_builder/json_feed/item.rb +112 -0
  50. data/lib/html2rss/feed_builder/json_feed.rb +81 -0
  51. data/lib/html2rss/feed_builder/rss/stylesheet.rb +65 -0
  52. data/lib/html2rss/feed_builder/rss.rb +116 -0
  53. data/lib/html2rss/feed_builder.rb +15 -0
  54. data/lib/html2rss/feed_pipeline/auto_fallback.rb +120 -60
  55. data/lib/html2rss/feed_pipeline/runtime_policy.rb +108 -0
  56. data/lib/html2rss/feed_pipeline/strategy_plan.rb +64 -0
  57. data/lib/html2rss/feed_pipeline.rb +83 -89
  58. data/lib/html2rss/feed_result.rb +92 -0
  59. data/lib/html2rss/html/article_extractor/category_extractor.rb +123 -0
  60. data/lib/html2rss/html/article_extractor/date_extractor.rb +22 -0
  61. data/lib/html2rss/html/article_extractor/enclosure_extractor.rb +95 -0
  62. data/lib/html2rss/html/article_extractor/heading_extractor.rb +54 -0
  63. data/lib/html2rss/html/article_extractor/id_generator.rb +69 -0
  64. data/lib/html2rss/html/article_extractor/image_extractor.rb +60 -0
  65. data/lib/html2rss/html/article_extractor.rb +134 -0
  66. data/lib/html2rss/html/navigator/text_extractor.rb +79 -0
  67. data/lib/html2rss/html/navigator.rb +146 -0
  68. data/lib/html2rss/html/rendering/audio_renderer.rb +38 -0
  69. data/lib/html2rss/html/rendering/description_builder.rb +89 -0
  70. data/lib/html2rss/html/rendering/image_renderer.rb +43 -0
  71. data/lib/html2rss/html/rendering/media_renderer.rb +39 -0
  72. data/lib/html2rss/html/rendering/pdf_renderer.rb +36 -0
  73. data/lib/html2rss/html/rendering/video_renderer.rb +38 -0
  74. data/lib/html2rss/html/rendering.rb +25 -0
  75. data/lib/html2rss/html.rb +8 -0
  76. data/lib/html2rss/request_service/blocked_surface.rb +67 -0
  77. data/lib/html2rss/request_service/botasaurus_contract.rb +33 -9
  78. data/lib/html2rss/request_service/botasaurus_strategy.rb +21 -30
  79. data/lib/html2rss/request_service/browserless_strategy.rb +2 -23
  80. data/lib/html2rss/request_service/budget.rb +97 -18
  81. data/lib/html2rss/request_service/context.rb +1 -3
  82. data/lib/html2rss/request_service/faraday_strategy.rb +12 -26
  83. data/lib/html2rss/request_service/local_file_strategy.rb +12 -4
  84. data/lib/html2rss/request_service/network_guard.rb +125 -0
  85. data/lib/html2rss/request_service/policy.rb +10 -82
  86. data/lib/html2rss/request_service/puppet_commander/navigation_guards.rb +148 -0
  87. data/lib/html2rss/request_service/puppet_commander/preload_runner.rb +86 -0
  88. data/lib/html2rss/request_service/puppet_commander.rb +22 -191
  89. data/lib/html2rss/request_service/response.rb +9 -1
  90. data/lib/html2rss/request_service/response_guard.rb +1 -1
  91. data/lib/html2rss/request_service/strategy.rb +123 -5
  92. data/lib/html2rss/request_service.rb +7 -2
  93. data/lib/html2rss/request_session/pager/base.rb +83 -0
  94. data/lib/html2rss/request_session/pager/custom_selector.rb +39 -0
  95. data/lib/html2rss/request_session/pager/json_cursor.rb +78 -0
  96. data/lib/html2rss/request_session/pager/offset.rb +48 -0
  97. data/lib/html2rss/request_session/pager/rel_next.rb +23 -0
  98. data/lib/html2rss/request_session/pager/url_template.rb +48 -0
  99. data/lib/html2rss/request_session/pager.rb +75 -0
  100. data/lib/html2rss/request_session.rb +29 -16
  101. data/lib/html2rss/selectors/item_scope.rb +30 -0
  102. data/lib/html2rss/selectors/post_processors/base.rb +1 -1
  103. data/lib/html2rss/selectors/post_processors/markdown_to_html.rb +0 -1
  104. data/lib/html2rss/selectors/post_processors/sanitize_html.rb +0 -2
  105. data/lib/html2rss/selectors/post_processors/template.rb +7 -3
  106. data/lib/html2rss/selectors.rb +84 -64
  107. data/lib/html2rss/status.rb +132 -0
  108. data/lib/html2rss/version.rb +1 -3
  109. data/lib/html2rss.rb +34 -22
  110. data/schema/html2rss-config.schema.json +109 -9
  111. metadata +71 -43
  112. data/lib/html2rss/auto_source/scraper/html/class_clustering.rb +0 -196
  113. data/lib/html2rss/auto_source/scraper/link_heuristics.rb +0 -401
  114. data/lib/html2rss/auto_source/scraper/schema/list_item.rb +0 -28
  115. data/lib/html2rss/auto_source/scraper/semantic_html/anchor_selector.rb +0 -49
  116. data/lib/html2rss/blocked_surface.rb +0 -65
  117. data/lib/html2rss/category_extractor.rb +0 -116
  118. data/lib/html2rss/config/class_methods.rb +0 -183
  119. data/lib/html2rss/html_extractor/date_extractor.rb +0 -20
  120. data/lib/html2rss/html_extractor/enclosure_extractor.rb +0 -91
  121. data/lib/html2rss/html_extractor/heading_extractor.rb +0 -50
  122. data/lib/html2rss/html_extractor/id_generator.rb +0 -67
  123. data/lib/html2rss/html_extractor/image_extractor.rb +0 -58
  124. data/lib/html2rss/html_extractor/list_candidates.rb +0 -88
  125. data/lib/html2rss/html_extractor/semantic_anchor_candidates.rb +0 -260
  126. data/lib/html2rss/html_extractor/semantic_containers.rb +0 -69
  127. data/lib/html2rss/html_extractor/text_extractor.rb +0 -77
  128. data/lib/html2rss/html_extractor.rb +0 -190
  129. data/lib/html2rss/html_navigator.rb +0 -71
  130. data/lib/html2rss/json_feed_builder/item.rb +0 -94
  131. data/lib/html2rss/json_feed_builder.rb +0 -59
  132. data/lib/html2rss/rendering/audio_renderer.rb +0 -36
  133. data/lib/html2rss/rendering/description_builder.rb +0 -87
  134. data/lib/html2rss/rendering/image_renderer.rb +0 -41
  135. data/lib/html2rss/rendering/media_renderer.rb +0 -37
  136. data/lib/html2rss/rendering/pdf_renderer.rb +0 -34
  137. data/lib/html2rss/rendering/video_renderer.rb +0 -36
  138. data/lib/html2rss/rendering.rb +0 -23
  139. data/lib/html2rss/request_controls.rb +0 -133
  140. data/lib/html2rss/request_session/rel_next_pager.rb +0 -70
  141. data/lib/html2rss/request_session/runtime_input.rb +0 -71
  142. data/lib/html2rss/request_session/runtime_policy.rb +0 -84
  143. data/lib/html2rss/rss_builder/article.rb +0 -208
  144. data/lib/html2rss/rss_builder/channel.rb +0 -118
  145. data/lib/html2rss/rss_builder/stylesheet.rb +0 -63
  146. 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