html2rss 0.30.0 → 0.31.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 (126) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +2 -2
  3. data/lib/html2rss/article/deduplicator.rb +2 -4
  4. data/lib/html2rss/article.rb +16 -39
  5. data/lib/html2rss/auto_source/README.md +1 -1
  6. data/lib/html2rss/auto_source/cleanup.rb +15 -6
  7. data/lib/html2rss/auto_source/scraper/html.rb +5 -0
  8. data/lib/html2rss/auto_source/scraper/json_state.rb +5 -0
  9. data/lib/html2rss/auto_source/scraper/meta_oembed.rb +8 -1
  10. data/lib/html2rss/auto_source/scraper/microdata.rb +8 -0
  11. data/lib/html2rss/auto_source/scraper/microformats2.rb +8 -0
  12. data/lib/html2rss/auto_source/scraper/native_feed.rb +3 -0
  13. data/lib/html2rss/auto_source/scraper/schema/thing.rb +4 -5
  14. data/lib/html2rss/auto_source/scraper/schema.rb +8 -0
  15. data/lib/html2rss/auto_source/scraper/semantic_html.rb +5 -0
  16. data/lib/html2rss/auto_source/scraper/sitemap.rb +3 -0
  17. data/lib/html2rss/auto_source/scraper/wordpress_api.rb +3 -0
  18. data/lib/html2rss/auto_source/scraper.rb +4 -14
  19. data/lib/html2rss/auto_source/segment.rb +7 -4
  20. data/lib/html2rss/auto_source/segmenter/cluster.rb +22 -23
  21. data/lib/html2rss/auto_source/segmenter/list.rb +54 -31
  22. data/lib/html2rss/auto_source/segmenter.rb +5 -5
  23. data/lib/html2rss/auto_source.rb +2 -2
  24. data/lib/html2rss/batch.rb +3 -1
  25. data/lib/html2rss/capture/README.md +6 -4
  26. data/lib/html2rss/capture.rb +67 -35
  27. data/lib/html2rss/channel.rb +3 -3
  28. data/lib/html2rss/cli/probe_view.rb +5 -3
  29. data/lib/html2rss/cli/render.rb +6 -5
  30. data/lib/html2rss/cli/validate.rb +28 -27
  31. data/lib/html2rss/cli.rb +1 -1
  32. data/lib/html2rss/config/issue_mapper.rb +108 -0
  33. data/lib/html2rss/config/request_headers.rb +2 -3
  34. data/lib/html2rss/config/schema.rb +16 -1
  35. data/lib/html2rss/config/selectors_validator.rb +92 -70
  36. data/lib/html2rss/config/validation_issue.rb +60 -0
  37. data/lib/html2rss/config/validation_report.rb +55 -0
  38. data/lib/html2rss/config/validator.rb +10 -6
  39. data/lib/html2rss/config.rb +35 -113
  40. data/lib/html2rss/defaults.rb +42 -30
  41. data/lib/html2rss/doctor/botasaurus.rb +6 -4
  42. data/lib/html2rss/doctor.rb +8 -0
  43. data/lib/html2rss/error.rb +1 -1
  44. data/lib/html2rss/feed_builder/rss.rb +9 -14
  45. data/lib/html2rss/feed_pipeline/runtime_policy.rb +14 -10
  46. data/lib/html2rss/feed_pipeline/strategy_plan.rb +6 -2
  47. data/lib/html2rss/feed_resolution/candidate_generator.rb +1 -4
  48. data/lib/html2rss/feed_resolution/diag.rb +3 -1
  49. data/lib/html2rss/feed_resolution/options.rb +3 -1
  50. data/lib/html2rss/feed_resolution/policy.rb +2 -2
  51. data/lib/html2rss/html/article_extractor/date_extractor.rb +1 -1
  52. data/lib/html2rss/html/article_extractor.rb +2 -4
  53. data/lib/html2rss/html/feed_link.rb +3 -1
  54. data/lib/html2rss/html/navigator/text_extractor.rb +1 -1
  55. data/lib/html2rss/html/navigator.rb +10 -10
  56. data/lib/html2rss/html/rendering/description_builder.rb +1 -1
  57. data/lib/html2rss/html/sst_article_extractor.rb +8 -5
  58. data/lib/html2rss/link_destination/destination_facts.rb +4 -2
  59. data/lib/html2rss/link_destination/noise_policy.rb +10 -1
  60. data/lib/html2rss/link_destination/path_classifier.rb +45 -38
  61. data/lib/html2rss/mcp/README.md +2 -0
  62. data/lib/html2rss/mcp/config_argument.rb +3 -1
  63. data/lib/html2rss/mcp/contract.rb +6 -20
  64. data/lib/html2rss/mcp/outcome/playbook.rb +2 -2
  65. data/lib/html2rss/mcp/outcome.rb +36 -43
  66. data/lib/html2rss/mcp/server/tools.rb +111 -131
  67. data/lib/html2rss/mcp/server.rb +26 -18
  68. data/lib/html2rss/page_recon/README.md +5 -5
  69. data/lib/html2rss/page_recon/diagnostics.rb +71 -81
  70. data/lib/html2rss/page_recon.rb +9 -5
  71. data/lib/html2rss/recon.rb +19 -30
  72. data/lib/html2rss/request_service/botasaurus_contract.rb +11 -12
  73. data/lib/html2rss/request_service/botasaurus_strategy.rb +1 -1
  74. data/lib/html2rss/request_service/compressed_body.rb +4 -1
  75. data/lib/html2rss/request_service/httpx_strategy.rb +39 -28
  76. data/lib/html2rss/request_service/network_guard.rb +7 -5
  77. data/lib/html2rss/request_service/response.rb +1 -2
  78. data/lib/html2rss/request_service/strategy.rb +3 -8
  79. data/lib/html2rss/scoring/container_assessor.rb +3 -1
  80. data/lib/html2rss/scoring/engine.rb +8 -8
  81. data/lib/html2rss/scoring/observation.rb +5 -3
  82. data/lib/html2rss/scoring/ranked_segment.rb +3 -1
  83. data/lib/html2rss/scrape_target.rb +3 -1
  84. data/lib/html2rss/selector_candidates.rb +234 -0
  85. data/lib/html2rss/selectors/README.md +64 -0
  86. data/lib/html2rss/selectors/extractors/attribute.rb +13 -15
  87. data/lib/html2rss/selectors/extractors/href.rb +15 -16
  88. data/lib/html2rss/selectors/extractors/html.rb +8 -9
  89. data/lib/html2rss/selectors/extractors/static.rb +12 -14
  90. data/lib/html2rss/selectors/extractors/text.rb +8 -9
  91. data/lib/html2rss/selectors/extractors.rb +33 -20
  92. data/lib/html2rss/selectors/item_env.rb +31 -0
  93. data/lib/html2rss/selectors/{object_to_xml_converter.rb → json_xml.rb} +11 -12
  94. data/lib/html2rss/selectors/option_spec.rb +140 -0
  95. data/lib/html2rss/selectors/post_processors/base.rb +79 -31
  96. data/lib/html2rss/selectors/post_processors/gsub.rb +103 -39
  97. data/lib/html2rss/selectors/post_processors/html_transformers/transform_urls_to_absolute_ones.rb +4 -4
  98. data/lib/html2rss/selectors/post_processors/markdown_to_html.rb +10 -11
  99. data/lib/html2rss/selectors/post_processors/parse_time.rb +50 -26
  100. data/lib/html2rss/selectors/post_processors/parse_uri.rb +11 -7
  101. data/lib/html2rss/selectors/post_processors/sanitize_html.rb +46 -32
  102. data/lib/html2rss/selectors/post_processors/substring.rb +14 -25
  103. data/lib/html2rss/selectors/post_processors/template.rb +23 -19
  104. data/lib/html2rss/selectors/post_processors.rb +6 -5
  105. data/lib/html2rss/selectors/{schema_doc.rb → schema_export.rb} +12 -31
  106. data/lib/html2rss/selectors/step_env.rb +24 -0
  107. data/lib/html2rss/selectors.rb +231 -142
  108. data/lib/html2rss/sst/attrs.rb +5 -3
  109. data/lib/html2rss/sst/document.rb +3 -1
  110. data/lib/html2rss/sst/index.rb +3 -1
  111. data/lib/html2rss/sst/node.rb +29 -10
  112. data/lib/html2rss/sst/normalizer.rb +12 -16
  113. data/lib/html2rss/sst/tags.rb +7 -7
  114. data/lib/html2rss/status.rb +13 -8
  115. data/lib/html2rss/surface_category.rb +14 -32
  116. data/lib/html2rss/syndication/parser.rb +2 -2
  117. data/lib/html2rss/test/enhance_audit.rb +1 -3
  118. data/lib/html2rss/test/policy.rb +106 -0
  119. data/lib/html2rss/test.rb +126 -173
  120. data/lib/html2rss/url.rb +25 -4
  121. data/lib/html2rss/version.rb +1 -1
  122. data/lib/html2rss.rb +21 -13
  123. data/lib/tasks/config_schema.rake +2 -2
  124. data/schema/html2rss-config.schema.json +11 -8
  125. metadata +15 -6
  126. data/lib/html2rss/selectors/item_scope.rb +0 -30
@@ -12,18 +12,19 @@ module Html2rss
12
12
  #
13
13
  # Additionally, it uniquely offers the capability to convert JSON into XML,
14
14
  # extending its versatility for diverse data processing workflows.
15
- class Selectors # rubocop:disable Metrics/ClassLength
15
+ #
16
+ # {include:file:lib/html2rss/selectors/README.md}
17
+ class Selectors # rubocop:disable Metrics/ClassLength -- public API + private field dispatch
18
+ # Raised when a selector key is missing or not allowed for extraction.
16
19
  class InvalidSelectorName < Html2rss::Error; end
17
20
 
18
21
  include Enumerable
19
22
 
20
- # A context instance passed to item extractors and post-processors.
21
- # When built via {ItemScope#context_for}, +item_scope+ carries the per-item
22
- # extraction base_url for nested selects (e.g. Template).
23
- Context = Struct.new('Context', :options, :item, :config, :scraper, :item_scope, keyword_init: true)
24
-
23
+ # Wire spelling of the default items selector.
24
+ # Runtime CSS is {Html::Navigator::MAIN_ANCHOR_SELECTOR}.
25
+ DEFAULT_ITEMS_SELECTOR = 'a[href]'
25
26
  # Default selectors options merged into user configuration.
26
- DEFAULT_CONFIG = { items: { enhance: true } }.freeze
27
+ DEFAULT_CONFIG = { items: { selector: DEFAULT_ITEMS_SELECTOR, enhance: true } }.freeze
27
28
 
28
29
  # Selector key that points to the root list of article nodes.
29
30
  ITEMS_SELECTOR_KEY = :items
@@ -35,7 +36,12 @@ module Html2rss
35
36
  # +:enclosure+ stays singular in YAML; Article stores +:enclosures+.
36
37
  SELECTOR_TO_ARTICLE_KEY = { enclosure: :enclosures }.freeze
37
38
  # 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
39
+ SELECTABLE_SELECTOR_KEYS = Set[*(Html2rss::Article::PROVIDED_KEYS + SELECTOR_TO_ARTICLE_KEY.keys)].freeze
40
+ # ArticleExtractor keys that enhance can fill. Skip the extractor when all are already present.
41
+ ENHANCEABLE_KEYS = %i[title url description published_at enclosures categories].freeze
42
+ # Per-field extraction plan compiled once from the static selector config.
43
+ CompiledField = Data.define(:name, :article_key, :config, :extractor_class, :post_process_steps, :special)
44
+ private_constant :CompiledField
39
45
 
40
46
  ##
41
47
  # Initializes a new Selectors instance.
@@ -48,7 +54,10 @@ module Html2rss
48
54
  @url = response.url
49
55
  @selectors = selectors
50
56
  @time_zone = time_zone
51
- @rss_item_attributes = @selectors.keys.select { |key| SELECTABLE_SELECTOR_KEYS.include?(key) }
57
+ @rss_item_attributes = @selectors.keys.select { SELECTABLE_SELECTOR_KEYS.include?(_1) }
58
+ @parsed_body = nil
59
+ @compiled_fields = compile_selector_fields
60
+ @compiled_item_fields = @rss_item_attributes.filter_map { @compiled_fields[_1] }
52
61
  end
53
62
 
54
63
  ##
@@ -71,56 +80,130 @@ module Html2rss
71
80
  enhance = enhance?
72
81
 
73
82
  parsed_body.css(items_selector).each do |item|
74
- article_hash = extract_article(item, response)
83
+ article_hash = extract_article(item)
75
84
 
76
- enhance_article_hash(article_hash, item, response.url) if enhance
85
+ enhance_article_hash(article_hash, item) if enhance
77
86
 
78
87
  yield Html2rss::Article.new(**article_hash, scraper: self.class)
79
88
  end
80
89
  end
81
90
 
82
91
  ##
83
- # Returns the CSS selector for the items.
84
- # @return [String] the CSS selector for the items
85
- def items_selector = @selectors.dig(ITEMS_SELECTOR_KEY, :selector)
92
+ # Yields +baseline+, +enhanced+, and the item node per list entry for enhance diagnostics.
93
+ # Consumed by {Test::EnhanceAudit} so the extraction loop has one owner.
94
+ #
95
+ # @yieldparam baseline [Hash]
96
+ # @yieldparam enhanced [Hash]
97
+ # @yieldparam item [Nokogiri::XML::Element]
98
+ # @return [Enumerator] if no block is given
99
+ def each_enhance_pair
100
+ return enum_for(:each_enhance_pair) unless block_given?
86
101
 
87
- ## @return [Boolean] whether to enhance the article hash with auto_source's semantic HTML extraction.
88
- def enhance? = !!@selectors.dig(ITEMS_SELECTOR_KEY, :enhance)
102
+ parsed_body.css(items_selector).each do |item|
103
+ baseline = extract_article(item)
104
+ enhanced = enhance_article_hash(baseline.dup, item)
105
+ yield baseline, enhanced, item
106
+ end
107
+ end
89
108
 
90
109
  ##
91
- # Extracts an article hash for a given item element.
110
+ # Selects the value for a given attribute from an HTML element.
92
111
  #
93
- # @param item [Nokogiri::XML::Element] The element to extract from.
94
- # @param page_response [RequestService::Response] response used for selector extraction context
95
- # @return [Hash] Hash of attributes for the article.
96
- def extract_article(item, page_response = response)
97
- scope = item_scope_for(item, page_response.url)
98
- hash = @rss_item_attributes.each_with_object({}) do |selector_key, h|
99
- value = scope.select(selector_key)
112
+ # @param name [Symbol, String] Name of the attribute.
113
+ # @param item [Nokogiri::XML::Element] The HTML element to process.
114
+ # @param base_url [String, Html2rss::Url] base URL for relative extraction values
115
+ # @return [Object, Array<Object>] The selected value(s).
116
+ # @raise [InvalidSelectorName] If the attribute name is invalid or not defined.
117
+ def select(name, item, base_url: @url)
118
+ select_in_scope(name, item_env_for(item, base_url))
119
+ end
120
+
121
+ ##
122
+ # Selects the value for a given attribute within an existing {ItemEnv}.
123
+ # Used by {ItemEnv#select} so nested selects reuse one env per extraction pass.
124
+ #
125
+ # @param name [Symbol, String] Name of the attribute.
126
+ # @param scope [ItemEnv] Per-item extraction scope.
127
+ # @return [Object, Array<Object>] The selected value(s).
128
+ # @raise [InvalidSelectorName] If the attribute name is invalid or not defined.
129
+ def select_in_scope(name, scope)
130
+ name = name.to_sym
131
+ raise InvalidSelectorName, "Attribute selector '#{name}' is reserved for items." if name == ITEMS_SELECTOR_KEY
132
+
133
+ field = @compiled_fields[name]
134
+ return select_compiled(field, scope) if field
135
+ return select_field(name, scope:, config: nil) if name == :url
136
+
137
+ raise InvalidSelectorName, "Selector for '#{name}' is not defined."
138
+ end
139
+
140
+ ##
141
+ # Resolves a selector name to +[key, config]+. Used by category field dispatch.
142
+ #
143
+ # @param name [Symbol, String]
144
+ # @param allow_nil [Boolean]
145
+ # @return [Array(Symbol, Hash, nil)]
146
+ def selector_config_for(name, allow_nil: false)
147
+ selector_key = name.to_sym
148
+
149
+ return [selector_key, @selectors[selector_key]] if @selectors.key?(selector_key)
150
+ return [selector_key, nil] if allow_nil
151
+
152
+ raise InvalidSelectorName, "Selector for '#{selector_key}' is not defined."
153
+ end
154
+
155
+ private
156
+
157
+ attr_reader :response
158
+
159
+ def items_selector
160
+ selector = @selectors.dig(ITEMS_SELECTOR_KEY, :selector)
161
+ return Html::Navigator::MAIN_ANCHOR_SELECTOR if default_items_selector?(selector)
162
+
163
+ selector
164
+ end
165
+
166
+ def default_items_selector?(selector)
167
+ text = selector.to_s.strip
168
+ text.empty? || text == DEFAULT_ITEMS_SELECTOR
169
+ end
170
+
171
+ def enhance? = !!@selectors.dig(ITEMS_SELECTOR_KEY, :enhance)
172
+
173
+ def parsed_body
174
+ @parsed_body ||= build_parsed_body(response)
175
+ end
176
+
177
+ def build_parsed_body(page_response)
178
+ if page_response.json_response?
179
+ fragment = JsonXml.call(page_response.parsed_body)
180
+ Nokogiri::HTML5.fragment(fragment)
181
+ else
182
+ page_response.parsed_body
183
+ end
184
+ end
185
+
186
+ def extract_article(item)
187
+ scope = item_env_for(item, @url)
188
+ hash = @compiled_item_fields.each_with_object({}) do |field, h|
189
+ value = select_compiled(field, scope)
100
190
  next if value.nil?
101
191
 
102
- article_key = SELECTOR_TO_ARTICLE_KEY.fetch(selector_key, selector_key)
192
+ article_key = field.article_key
103
193
  h[article_key] = article_key == :enclosures ? wrap_enclosure_value(value) : value
104
194
  end
105
195
 
106
- hash[:url] ||= default_item_url(item, page_response.url) if anchor_element?(item)
196
+ hash[:url] ||= default_item_url(item) if anchor_element?(item)
107
197
  hash
108
198
  end
109
199
 
110
- ##
111
- # Enhances the article hash using semantic HTML extraction.
112
- # Only adds keys that are missing from the original hash.
113
- #
114
- # @param article_hash [Hash] The original article hash.
115
- # @param article_tag [Nokogiri::XML::Element] HTML element to extract additional info from.
116
- # @param base_url [String, Html2rss::Url] base URL for normalization during enhancement
117
- # @return [Hash] The enhanced article hash.
118
- # rubocop:disable-next Metrics/MethodLength
119
- def enhance_article_hash(article_hash, article_tag, base_url = @url)
200
+ def enhance_article_hash(article_hash, article_tag) # rubocop:disable Metrics/MethodLength -- merge extracted keys
201
+ return article_hash if enhanceable_fields_present?(article_hash)
202
+
120
203
  selected_anchor = Html2rss::Html::Navigator.main_anchor_for(article_tag)
121
204
  extracted = Html2rss::Html::ArticleExtractor.call(
122
205
  article_tag,
123
- base_url:,
206
+ base_url: @url,
124
207
  selected_anchor:,
125
208
  fallback_anchorless: true,
126
209
  time_zone: @time_zone
@@ -134,63 +217,77 @@ module Html2rss
134
217
  end
135
218
  end
136
219
 
137
- ##
138
- # Selects the value for a given attribute from an HTML element.
139
- #
140
- # @param name [Symbol, String] Name of the attribute.
141
- # @param item [Nokogiri::XML::Element] The HTML element to process.
142
- # @param base_url [String, Html2rss::Url] base URL for relative extraction values
143
- # @return [Object, Array<Object>] The selected value(s).
144
- # @raise [InvalidSelectorName] If the attribute name is invalid or not defined.
145
- def select(name, item, base_url: @url)
146
- select_in_scope(name, item_scope_for(item, base_url))
220
+ def enhanceable_fields_present?(article_hash)
221
+ ENHANCEABLE_KEYS.all? { |key| article_hash[key] }
147
222
  end
148
223
 
149
- ##
150
- # Selects the value for a given attribute within an existing {ItemScope}.
151
- # Used by {ItemScope#select} so nested selects reuse one scope per extraction pass.
152
- #
153
- # @param name [Symbol, String] Name of the attribute.
154
- # @param scope [ItemScope] Per-item extraction scope.
155
- # @return [Object, Array<Object>] The selected value(s).
156
- # @raise [InvalidSelectorName] If the attribute name is invalid or not defined.
157
- def select_in_scope(name, scope)
158
- name = name.to_sym
159
-
160
- raise InvalidSelectorName, "Attribute selector '#{name}' is reserved for items." if name == ITEMS_SELECTOR_KEY
224
+ def wrap_enclosure_value(value)
225
+ value.is_a?(Array) ? value : [value]
226
+ end
161
227
 
162
- selector_key, config = selector_config_for(name, allow_nil: name == :url)
228
+ def select_field(selector_key, scope:, config:)
163
229
  return default_item_url(scope.item, scope.base_url) if fallback_url_selector?(selector_key, config, scope.item)
164
230
  raise InvalidSelectorName, "Selector for '#{selector_key}' is not defined." if config.nil?
165
231
 
166
- dispatch_select(selector_key, scope:, config:)
232
+ if SPECIAL_ATTRIBUTES.member?(selector_key)
233
+ select_special(selector_key, scope:, config:)
234
+ else
235
+ select_regular(selector_key, scope:, config:)
236
+ end
167
237
  end
168
238
 
169
- private
239
+ def select_compiled(field, scope)
240
+ if fallback_url_selector?(field.name, field.config, scope.item)
241
+ return default_item_url(scope.item, scope.base_url)
242
+ end
243
+ raise InvalidSelectorName, "Selector for '#{field.name}' is not defined." if field.config.nil?
170
244
 
171
- attr_reader :response
245
+ if field.special
246
+ select_special(field.name, scope:, config: field.config)
247
+ else
248
+ select_regular_compiled(field, scope)
249
+ end
250
+ end
172
251
 
173
- def item_scope_for(item, base_url)
174
- ItemScope.new(
175
- item:,
176
- base_url:,
177
- scraper: self,
178
- channel: channel_context(base_url)
252
+ def compile_selector_fields
253
+ @selectors.each_key.with_object({}) do |name, acc|
254
+ next if name == ITEMS_SELECTOR_KEY
255
+
256
+ acc[name] = compile_field(name)
257
+ end
258
+ end
259
+
260
+ def compile_field(name)
261
+ config = @selectors[name]
262
+ CompiledField.new(
263
+ name:,
264
+ article_key: SELECTOR_TO_ARTICLE_KEY.fetch(name, name),
265
+ config:,
266
+ extractor_class: extractor_class_for(config),
267
+ post_process_steps: compile_post_process_steps(config),
268
+ special: SPECIAL_ATTRIBUTES.member?(name)
179
269
  )
180
270
  end
181
271
 
182
- def parsed_body
183
- parsed_body_for(response)
272
+ def extractor_class_for(config)
273
+ return unless config.is_a?(Hash)
274
+
275
+ Extractors::NAME_TO_CLASS[config[:extractor]&.to_sym || Extractors::DEFAULT_EXTRACTOR]
184
276
  end
185
277
 
186
- def parsed_body_for(page_response)
187
- @parsed_bodies ||= {}
188
- @parsed_bodies[page_response.url] ||= if page_response.json_response?
189
- fragment = ObjectToXmlConverter.new(page_response.parsed_body).call
190
- Nokogiri::HTML5.fragment(fragment)
191
- else
192
- page_response.parsed_body
193
- end
278
+ def compile_post_process_steps(config)
279
+ return unless config.is_a?(Hash) && config[:post_process]
280
+
281
+ steps = config[:post_process]
282
+ (steps.is_a?(Array) ? steps : [steps]).map { |step| compile_post_process_step(step) }
283
+ end
284
+
285
+ def compile_post_process_step(step)
286
+ klass = PostProcessors::NAME_TO_CLASS[step[:name].to_sym]
287
+ raise PostProcessors::UnknownPostProcessorName, "Unknown name '#{step[:name]}'" unless klass
288
+
289
+ klass.validate_options!(StepEnv.new(step_config: step, time_zone: @time_zone))
290
+ [klass, step]
194
291
  end
195
292
 
196
293
  def select_special(name, scope:, config:)
@@ -205,27 +302,55 @@ module Html2rss
205
302
  end
206
303
 
207
304
  def select_regular(_name, scope:, config:)
208
- @merged_configs ||= {}
209
- merged_config = @merged_configs[[config.object_id, scope.base_url]] ||=
210
- config.merge(channel: scope.channel).freeze
211
- value = Extractors.get(merged_config, scope.item)
212
-
213
- if value && (post_process_steps = config[:post_process])
214
- steps = post_process_steps.is_a?(Array) ? post_process_steps : [post_process_steps]
215
- value = post_process(scope, value, steps)
305
+ value = Extractors.call(config, scope.item, base_url: scope.base_url)
306
+
307
+ apply_post_process_steps(scope:, value:, post_process_steps: config[:post_process])
308
+ end
309
+
310
+ def select_regular_compiled(field, scope)
311
+ value = field.extractor_class.new(
312
+ scope.item,
313
+ selector: field.config[:selector],
314
+ attribute: field.config[:attribute],
315
+ static: field.config[:static],
316
+ base_url: scope.base_url
317
+ ).call
318
+
319
+ apply_compiled_post_process(scope, value, field.post_process_steps)
320
+ end
321
+
322
+ def apply_post_process_steps(scope:, value:, post_process_steps:)
323
+ return value unless value && post_process_steps
324
+
325
+ steps = post_process_steps.is_a?(Array) ? post_process_steps : [post_process_steps]
326
+ post_process(scope, value, steps)
327
+ end
328
+
329
+ def post_process(scope, value, post_process_steps)
330
+ post_process_steps.each do |step_config|
331
+ value = PostProcessors.call(step_config[:name], value, scope.context_for(step_config:))
216
332
  end
217
333
 
218
334
  value
219
335
  end
220
336
 
221
- def post_process(scope, value, post_process_steps)
222
- post_process_steps.each do |options|
223
- value = PostProcessors.get(options[:name], value, scope.context_for(options:))
337
+ def apply_compiled_post_process(scope, value, steps)
338
+ return value unless value && steps
339
+
340
+ steps.each do |klass, step_config|
341
+ value = klass.new(value, scope.context_for(step_config:)).call
224
342
  end
225
343
 
226
344
  value
227
345
  end
228
346
 
347
+ # Flattens configured category selector names into discrete category strings.
348
+ # When a selector matches multiple nodes, each node is extracted independently
349
+ # (with optional post-processors) so multi-tag UIs become multiple categories.
350
+ #
351
+ # @param category_selectors [Array, String, Symbol]
352
+ # @param scope [ItemEnv]
353
+ # @return [Array<String>]
229
354
  def select_categories(category_selectors:, scope:)
230
355
  Array(category_selectors).flat_map do |selector_name|
231
356
  extract_category_values(selector_name, scope:)
@@ -233,17 +358,17 @@ module Html2rss
233
358
  end
234
359
 
235
360
  def extract_category_values(selector_name, scope:)
236
- selector_key, config = selector_config_for(selector_name, allow_nil: true)
361
+ _selector_key, config = selector_config_for(selector_name, allow_nil: true)
237
362
  return [] unless config
238
363
 
239
364
  nodes = extract_nodes(item: scope.item, config:)
240
- return Array(select_regular(selector_key, scope:, config:)) unless node_set_with_multiple_elements?(nodes)
365
+ return Array(scope.select(selector_name)) unless node_set_with_multiple_elements?(nodes)
241
366
 
242
367
  Array(nodes).flat_map { |node| extract_categories_from_node(node, scope:, config:) }
243
368
  end
244
369
 
245
370
  def extract_categories_from_node(node, scope:, config:)
246
- values = Extractors.get(category_node_options(config, scope:), node)
371
+ values = Extractors.call(config, node, base_url: scope.base_url, selector: nil)
247
372
  values = apply_post_process_steps(scope:, value: values, post_process_steps: config[:post_process])
248
373
 
249
374
  Array(values).filter_map { |category| extract_category_text(category) }
@@ -265,49 +390,12 @@ module Html2rss
265
390
  nodes.is_a?(Nokogiri::XML::NodeSet) && nodes.length > 1
266
391
  end
267
392
 
268
- def category_node_options(selector_config, scope:)
269
- @category_node_configs ||= {}
270
- @category_node_configs[[selector_config.object_id, scope.base_url]] ||= selector_config.merge(
271
- channel: scope.channel,
272
- selector: nil
273
- ).freeze
274
- end
275
-
276
- def apply_post_process_steps(scope:, value:, post_process_steps:)
277
- return value unless value && post_process_steps
278
-
279
- steps = post_process_steps.is_a?(Array) ? post_process_steps : [post_process_steps]
280
- post_process(scope, value, steps)
281
- end
282
-
283
- def selector_config_for(name, allow_nil: false)
284
- selector_key = name.to_sym
285
-
286
- return [selector_key, @selectors[selector_key]] if @selectors.key?(selector_key)
287
- return [selector_key, nil] if allow_nil
288
-
289
- raise InvalidSelectorName, "Selector for '#{selector_key}' is not defined."
290
- end
291
-
292
393
  def extract_nodes(item:, config:)
293
- return unless config.respond_to?(:[]) && config[:selector]
394
+ return unless config.is_a?(Hash) && config[:selector]
294
395
 
295
396
  Extractors.element(item, config[:selector])
296
397
  end
297
398
 
298
- def channel_context(base_url)
299
- @channel_contexts ||= {}
300
- @channel_contexts[base_url] ||= { url: base_url, time_zone: @time_zone }.freeze
301
- end
302
-
303
- # Keep a single enclosure Hash as one list entry; +Array(hash)+ would split pairs.
304
- #
305
- # @param value [Hash, Array] enclosure hash or list of hashes
306
- # @return [Array]
307
- def wrap_enclosure_value(value)
308
- value.is_a?(Array) ? value : [value]
309
- end
310
-
311
399
  # @return [Hash, nil] enclosure details, or nil when the selector yields nothing.
312
400
  def enclosure(scope:, config:)
313
401
  selected = select_regular(:enclosure, scope:, config:)
@@ -318,27 +406,28 @@ module Html2rss
318
406
  { url:, type: config[:content_type] }
319
407
  end
320
408
 
321
- def anchor_element?(item)
322
- item.respond_to?(:name) && item.name.to_s.casecmp('a').zero?
323
- end
324
-
325
409
  def fallback_url_selector?(selector_key, config, item)
326
410
  selector_key == :url && config.nil? && anchor_element?(item)
327
411
  end
328
412
 
329
- def dispatch_select(selector_key, scope:, config:)
330
- if SPECIAL_ATTRIBUTES.member?(selector_key)
331
- select_special(selector_key, scope:, config:)
332
- else
333
- select_regular(selector_key, scope:, config:)
334
- end
413
+ def anchor_element?(item)
414
+ item.name.to_s.casecmp('a').zero?
335
415
  end
336
416
 
337
- def default_item_url(item, base_url)
417
+ def default_item_url(item, base_url = @url)
338
418
  href = item['href'].to_s.strip
339
419
  return if href.empty?
340
420
 
341
421
  Url.from_relative(href, base_url)
342
422
  end
423
+
424
+ def item_env_for(item, base_url)
425
+ ItemEnv.new(
426
+ item:,
427
+ base_url:,
428
+ scraper: self,
429
+ time_zone: @time_zone
430
+ )
431
+ end
343
432
  end
344
433
  end
@@ -11,11 +11,13 @@ module Html2rss
11
11
  EMPTY_RAW = {}.freeze
12
12
  end
13
13
 
14
- ##
15
- # Typed HTML attributes for an SST node. Absent fields are +nil+.
16
14
  Attrs = Data.define(
17
15
  :href, :src, :id, :class_names, :datetime, :itemprop, :style, :srcset, :type, :raw
18
- ) do
16
+ )
17
+
18
+ ##
19
+ # Typed HTML attributes for an SST node. Absent fields are +nil+.
20
+ class Attrs
19
21
  class << self
20
22
  ##
21
23
  # @param href [String, nil]
@@ -2,9 +2,11 @@
2
2
 
3
3
  module Html2rss
4
4
  module SST
5
+ Document = Data.define(:root, :index, :degraded, :node_count)
6
+
5
7
  ##
6
8
  # Immutable SST document: root node plus relationship index.
7
- Document = Data.define(:root, :index, :degraded, :node_count) do
9
+ class Document
8
10
  ##
9
11
  # @param root [Node]
10
12
  # @param index [Index]
@@ -63,7 +63,9 @@ module Html2rss
63
63
  def memo_word_count(node)
64
64
  return @word_count[node] if @word_count.key?(node)
65
65
 
66
- @word_count[node] = memo_visible_text(node).to_s.scan(/\p{Alnum}+/).size
66
+ count = 0
67
+ memo_visible_text(node).to_s.scan(/\p{Alnum}+/) { count += 1 }
68
+ @word_count[node] = count
67
69
  end
68
70
 
69
71
  ##
@@ -2,10 +2,12 @@
2
2
 
3
3
  module Html2rss
4
4
  module SST
5
+ Node = Data.define(:name, :attrs, :own_text, :children, :tag_path)
6
+
5
7
  ##
6
8
  # Immutable simplified semantic tree node. Predicates and traversal live on
7
9
  # the type so Scoring/Segmenter never touch Nokogiri.
8
- Node = Data.define(:name, :attrs, :own_text, :children, :tag_path) do
10
+ class Node
9
11
  ##
10
12
  # @param name [Symbol, String]
11
13
  # @param attrs [Attrs, nil]
@@ -86,11 +88,26 @@ module Html2rss
86
88
  #
87
89
  # @yieldparam node [Node]
88
90
  # @return [Enumerator, Array<Node>]
89
- def each_node(&block)
90
- return enum_for(:each_node) unless block
91
+ def each_node(&)
92
+ return enum_for(:each_node) unless block_given?
91
93
 
92
94
  yield self
93
- children.each { |child| child.each_node(&block) }
95
+ children.each { |child| child.each_node(&) }
96
+ end
97
+
98
+ ##
99
+ # Non-allocating traversal that counts descendants matching the block.
100
+ # When no block is given, counts all descendants.
101
+ #
102
+ # @yieldparam node [Node]
103
+ # @return [Integer]
104
+ def count_descendants(&)
105
+ total = 0
106
+ children.each do |child|
107
+ total += 1 if !block_given? || yield(child)
108
+ total += child.count_descendants(&)
109
+ end
110
+ total
94
111
  end
95
112
 
96
113
  ##
@@ -101,14 +118,14 @@ module Html2rss
101
118
 
102
119
  ##
103
120
  # @return [Array<Node>]
104
- def find_all(&predicate)
105
- each_node.select(&predicate)
121
+ def find_all(&)
122
+ each_node.select(&)
106
123
  end
107
124
 
108
125
  ##
109
126
  # @return [Node, nil]
110
- def find(&predicate)
111
- each_node.find(&predicate)
127
+ def find(&)
128
+ each_node.find(&)
112
129
  end
113
130
 
114
131
  ##
@@ -132,14 +149,16 @@ module Html2rss
132
149
  return index.memo_word_count(self)
133
150
  end
134
151
 
135
- visible_text.to_s.scan(/\p{Alnum}+/).size
152
+ count = 0
153
+ visible_text.to_s.scan(/\p{Alnum}+/) { count += 1 }
154
+ count
136
155
  end
137
156
 
138
157
  ##
139
158
  # @return [Float] words per descendant link
140
159
  def text_density
141
160
  words = word_count
142
- links = descendants.count(&:link?)
161
+ links = count_descendants(&:link?)
143
162
  links.zero? ? words.to_f : words.to_f / links
144
163
  end
145
164
  end