html2rss 0.20.1 → 0.22.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 (49) hide show
  1. checksums.yaml +4 -4
  2. data/html2rss.gemspec +1 -2
  3. data/lib/html2rss/auto_source/scraper/html/class_clustering.rb +185 -0
  4. data/lib/html2rss/auto_source/scraper/html.rb +73 -16
  5. data/lib/html2rss/auto_source/scraper/json_state.rb +41 -28
  6. data/lib/html2rss/auto_source/scraper/link_heuristics.rb +85 -131
  7. data/lib/html2rss/auto_source/scraper/microdata.rb +2 -2
  8. data/lib/html2rss/auto_source/scraper/schema/category_extractor.rb +74 -28
  9. data/lib/html2rss/auto_source/scraper/schema/list_item.rb +3 -2
  10. data/lib/html2rss/auto_source/scraper/schema/thing.rb +31 -60
  11. data/lib/html2rss/auto_source/scraper/schema.rb +8 -2
  12. data/lib/html2rss/auto_source/scraper/semantic_html/deduplicator.rb +4 -18
  13. data/lib/html2rss/auto_source/scraper/semantic_html.rb +55 -11
  14. data/lib/html2rss/auto_source/scraper.rb +0 -3
  15. data/lib/html2rss/auto_source.rb +5 -12
  16. data/lib/html2rss/category_extractor.rb +54 -20
  17. data/lib/html2rss/cli.rb +61 -10
  18. data/lib/html2rss/config/class_methods.rb +3 -3
  19. data/lib/html2rss/config/validator.rb +1 -0
  20. data/lib/html2rss/config.rb +2 -2
  21. data/lib/html2rss/html_extractor/enclosure_extractor.rb +60 -89
  22. data/lib/html2rss/html_extractor/heading_extractor.rb +50 -0
  23. data/lib/html2rss/html_extractor/id_generator.rb +67 -0
  24. data/lib/html2rss/html_extractor/list_candidates.rb +2 -8
  25. data/lib/html2rss/html_extractor/semantic_anchor_candidates.rb +29 -12
  26. data/lib/html2rss/html_extractor/semantic_containers.rb +9 -35
  27. data/lib/html2rss/html_extractor/text_extractor.rb +77 -0
  28. data/lib/html2rss/html_extractor.rb +80 -61
  29. data/lib/html2rss/rendering/description_builder.rb +3 -3
  30. data/lib/html2rss/rendering.rb +2 -2
  31. data/lib/html2rss/request_service/local_file_strategy.rb +29 -0
  32. data/lib/html2rss/request_service/puppet_commander.rb +4 -0
  33. data/lib/html2rss/request_service.rb +2 -1
  34. data/lib/html2rss/rss_builder/article.rb +44 -23
  35. data/lib/html2rss/rss_builder/enclosure.rb +4 -2
  36. data/lib/html2rss/selectors/extractors/attribute.rb +1 -1
  37. data/lib/html2rss/selectors/extractors/href.rb +1 -1
  38. data/lib/html2rss/selectors/extractors/html.rb +1 -1
  39. data/lib/html2rss/selectors/extractors/static.rb +1 -1
  40. data/lib/html2rss/selectors/extractors/text.rb +1 -1
  41. data/lib/html2rss/selectors/post_processors/sanitize_html.rb +32 -36
  42. data/lib/html2rss/selectors/post_processors/substring.rb +11 -18
  43. data/lib/html2rss/selectors/post_processors/template.rb +3 -2
  44. data/lib/html2rss/selectors.rb +19 -5
  45. data/lib/html2rss/url.rb +29 -15
  46. data/lib/html2rss/version.rb +1 -1
  47. data/lib/html2rss.rb +28 -6
  48. data/schema/html2rss-config.schema.json +12 -1
  49. metadata +8 -17
@@ -24,19 +24,30 @@ module Html2rss
24
24
  ) do
25
25
  # @param url [Html2rss::Url] normalized destination URL
26
26
  # @return [DestinationFacts] route facts for downstream link scoring
27
- def self.build(url)
27
+ def self.build(url) # rubocop:disable Metrics/MethodLength
28
28
  classifier = PathClassifier.new(url.path_segments)
29
29
 
30
30
  new(
31
31
  url:,
32
32
  destination: url.to_s,
33
- **classifier.destination_attributes
33
+ segments: classifier.segments,
34
+ strong_post_suffix: classifier.strong_post_suffix?,
35
+ content_path: classifier.content_path?,
36
+ utility_path: classifier.utility_path?,
37
+ taxonomy_path: classifier.taxonomy_path?,
38
+ vanity_path: classifier.vanity_path?,
39
+ shallow: classifier.shallow?,
40
+ high_confidence_junk_path: classifier.junk_path?,
41
+ high_confidence_utility_destination: classifier.utility_destination?
34
42
  )
35
43
  end
36
44
  end
37
45
 
38
46
  # Extracts a normalized href from a Nokogiri anchor or raw href value.
39
47
  class HrefExtractor
48
+ # Regexp to capture everything before the first '#'
49
+ HREF_BASE_PATTERN = /\A([^#]*)/
50
+
40
51
  # @param anchor_or_href [Nokogiri::XML::Element, String, #to_s] anchor element or href-like value
41
52
  # @return [String, nil] href without fragment, or nil when blank
42
53
  def self.call(anchor_or_href) = new(anchor_or_href).call
@@ -48,20 +59,18 @@ module Html2rss
48
59
 
49
60
  # @return [String, nil] href without fragment, or nil when blank
50
61
  def call
51
- raw_href.to_s.split('#', 2).first.to_s.strip.then do |href|
52
- href unless href.empty?
53
- end
54
- end
62
+ href = case @anchor_or_href
63
+ when Nokogiri::XML::Node
64
+ @anchor_or_href['href']
65
+ else
66
+ @anchor_or_href
67
+ end
55
68
 
56
- private
69
+ return unless href
57
70
 
58
- def raw_href
59
- case @anchor_or_href
60
- when Nokogiri::XML::Node
61
- @anchor_or_href['href']
62
- else
63
- @anchor_or_href
64
- end
71
+ # Extract base part before # and strip whitespace
72
+ base = href.to_s[HREF_BASE_PATTERN, 1].strip
73
+ base unless base.empty?
65
74
  end
66
75
  end
67
76
 
@@ -125,8 +134,7 @@ module Html2rss
125
134
  end
126
135
 
127
136
  # Classifies normalized destination path segments for scoring.
128
- # rubocop:disable Metrics/ClassLength
129
- class PathClassifier
137
+ class PathClassifier # rubocop:disable Metrics/ClassLength
130
138
  attr_reader :segments
131
139
 
132
140
  # Segment groups used to classify article, taxonomy, utility, and vanity routes.
@@ -206,48 +214,25 @@ module Html2rss
206
214
  @segments = segments
207
215
  end
208
216
 
209
- # @return [Hash] destination attributes consumed by DestinationFacts
210
- def destination_attributes
211
- route_attributes.merge(confidence_attributes)
212
- end
213
-
214
- # @return [Hash] baseline path classification attributes
215
- def route_attributes
216
- {
217
- segments:,
218
- content_path: content_path?,
219
- utility_path: utility_path?,
220
- taxonomy_path: taxonomy_path?,
221
- vanity_path: vanity_path?,
222
- shallow: shallow?,
223
- strong_post_suffix: strong_post_suffix?
224
- }
225
- end
226
-
227
- # @return [Hash] high-confidence noise classification attributes
228
- def confidence_attributes
229
- ConfidenceClassifier.new(self).attributes
230
- end
231
-
232
217
  # @return [Boolean] true when the route has article-like path evidence
233
218
  def content_path?
234
- @content_path ||= SEGMENT_SETS.fetch(:content).intersect?(segments.to_set) ||
219
+ @content_path ||= segments.any? { |s| SEGMENT_SETS[:content].include?(s) } ||
235
220
  yearish_content_context?
236
221
  end
237
222
 
238
223
  # @return [Boolean] true when the route includes utility/navigation evidence
239
224
  def utility_path?
240
- @utility_path ||= SEGMENT_SETS.fetch(:utility).intersect?(segments.to_set)
225
+ @utility_path ||= segments.any? { |s| SEGMENT_SETS[:utility].include?(s) }
241
226
  end
242
227
 
243
228
  # @return [Boolean] true when the route points at conversion or account chrome
244
229
  def vanity_path?
245
- @vanity_path ||= SEGMENT_SETS.fetch(:vanity).intersect?(segments.to_set)
230
+ @vanity_path ||= segments.any? { |s| SEGMENT_SETS[:vanity].include?(s) }
246
231
  end
247
232
 
248
233
  # @return [Boolean] true when the route points at taxonomy/listing chrome
249
234
  def taxonomy_path?
250
- @taxonomy_path ||= SEGMENT_SETS.fetch(:taxonomy).intersect?(segments.to_set)
235
+ @taxonomy_path ||= segments.any? { |s| SEGMENT_SETS[:taxonomy].include?(s) }
251
236
  end
252
237
 
253
238
  # @return [Boolean] true when the route is too shallow to strongly indicate an article
@@ -260,7 +245,9 @@ module Html2rss
260
245
 
261
246
  # @return [Boolean] true when the final path segment looks like a post slug
262
247
  def strong_post_suffix?
263
- PostSuffixClassifier.new(segments).strong?
248
+ @strong_post_suffix ||= segments.any? &&
249
+ included_last_segment? &&
250
+ trusted_post_context?(segments.size - 1)
264
251
  end
265
252
 
266
253
  # @return [Boolean] true when every path segment is utility chrome
@@ -282,131 +269,81 @@ module Html2rss
282
269
 
283
270
  # @return [Boolean] true when the leading segments are all utility chrome
284
271
  def deep_utility_context_route?
285
- LeadingSegments.new(segments).all_junk?
272
+ all_junk?(segments.size - 1)
286
273
  end
287
274
 
288
- private
289
-
290
- def yearish_content_context?
291
- segments.any? { |segment| segment.match?(YEARISH_SEGMENT) } &&
292
- (strong_post_suffix? || LeadingSegments.new(segments).trusted_post_context?)
293
- end
294
- end
295
- # rubocop:enable Metrics/ClassLength
296
-
297
- # Classifies high-confidence junk and utility routes from path facts.
298
- class ConfidenceClassifier
299
- # @param path [PathClassifier] classified destination path
300
- def initialize(path)
301
- @path = path
302
- end
303
-
304
- # @return [Hash] high-confidence route classification attributes
305
- def attributes
306
- {
307
- high_confidence_junk_path: junk_path?,
308
- high_confidence_utility_destination: utility_destination?
309
- }
310
- end
311
-
312
- private
313
-
275
+ # @return [Boolean] true when the route is shallow and contains high-confidence noise
314
276
  def junk_path?
315
277
  return false if excluded_content_route?
316
278
 
317
- @path.taxonomy_path? ||
318
- @path.utility_only_route? ||
319
- @path.deep_utility_context_route? ||
320
- @path.shallow_high_confidence_route?
279
+ taxonomy_path? ||
280
+ utility_only_route? ||
281
+ deep_utility_context_route? ||
282
+ shallow_high_confidence_route?
321
283
  end
322
284
 
285
+ # @return [Boolean] true when the route points at conversion or account chrome
323
286
  def utility_destination?
324
287
  return false if excluded_content_route?
325
288
 
326
- @path.vanity_path? || utility_route?
289
+ vanity_path? || utility_route?
290
+ end
291
+
292
+ private
293
+
294
+ def yearish_content_context?
295
+ segments.any? { |segment| segment.match?(YEARISH_SEGMENT) } &&
296
+ (strong_post_suffix? || trusted_post_context?(segments.size - 1))
327
297
  end
328
298
 
329
299
  def excluded_content_route?
330
- @path.segments.empty? || @path.content_path? || @path.strong_post_suffix?
300
+ segments.empty? || content_path? || strong_post_suffix?
331
301
  end
332
302
 
333
303
  def utility_route?
334
- @path.taxonomy_path? ||
335
- @path.utility_only_route? ||
336
- @path.deep_utility_context_route? ||
304
+ taxonomy_path? ||
305
+ utility_only_route? ||
306
+ deep_utility_context_route? ||
337
307
  shallow_utility_route?
338
308
  end
339
309
 
340
310
  def shallow_utility_route?
341
- @path.shallow? && @path.utility_path?
342
- end
343
- end
344
-
345
- # Classifies route context before the final segment.
346
- class LeadingSegments
347
- # @param segments [Array<String>] normalized URL path segments
348
- def initialize(segments)
349
- @segments = segments[0...-1]
311
+ shallow? && utility_path?
350
312
  end
351
313
 
352
- # @return [Boolean] true when every leading segment is utility chrome
353
- def all_junk?
354
- junk_segments = PathClassifier::SEGMENT_SETS.fetch(:high_confidence_junk)
314
+ def all_junk?(limit)
315
+ return false if limit <= 0
355
316
 
356
- @segments.any? && @segments.all? { |segment| junk_segments.include?(segment) }
317
+ junk_segments = SEGMENT_SETS.fetch(:high_confidence_junk)
318
+ (0...limit).all? { |i| junk_segments.include?(segments[i]) }
357
319
  end
358
320
 
359
- # @return [Boolean] true when leading segments provide article context
360
- def trusted_post_context?
361
- content_segments = PathClassifier::SEGMENT_SETS.fetch(:content)
362
- context_segments = PathClassifier::SEGMENT_SETS.fetch(:deep_post_context)
321
+ def trusted_post_context?(limit)
322
+ return false if limit <= 0
323
+
324
+ content_segments = SEGMENT_SETS.fetch(:content)
325
+ context_segments = SEGMENT_SETS.fetch(:deep_post_context)
363
326
 
364
- @segments.any? do |segment|
327
+ (0...limit).any? do |i|
328
+ segment = segments[i]
365
329
  content_segments.include?(segment) ||
366
330
  segment.match?(PathClassifier::YEARISH_SEGMENT) ||
367
331
  context_segments.include?(segment)
368
332
  end
369
333
  end
370
- end
371
-
372
- # Classifies whether the final segment is a strong post-like suffix.
373
- class PostSuffixClassifier
374
- # @param segments [Array<String>] normalized URL path segments
375
- def initialize(segments)
376
- @segments = segments
377
- end
378
-
379
- # @return [Boolean] true when the final path segment looks like a post slug
380
- def strong?
381
- @segments.any? &&
382
- included_last_segment? &&
383
- LeadingSegments.new(@segments).trusted_post_context?
384
- end
385
-
386
- private
387
334
 
388
335
  def included_last_segment?
389
336
  !excluded_last_segment? && slug_last_segment?
390
337
  end
391
338
 
392
339
  def excluded_last_segment?
393
- excluded_segments.any? { |segment| segment.include?(last_segment) }
394
- end
395
-
396
- def excluded_segments
397
- [
398
- PathClassifier::SEGMENT_SETS.fetch(:high_confidence_junk),
399
- PathClassifier::SEGMENT_SETS.fetch(:vanity)
400
- ]
340
+ last = segments.last
341
+ [SEGMENT_SETS[:high_confidence_junk], SEGMENT_SETS[:vanity]].any? { |set| set.include?(last) }
401
342
  end
402
343
 
403
344
  def slug_last_segment?
404
- last_segment.match?(PathClassifier::YEARISH_SEGMENT) ||
405
- last_segment.match?(PathClassifier::POST_SLUG_SEGMENT)
406
- end
407
-
408
- def last_segment
409
- @segments.last
345
+ last = segments.last
346
+ last.match?(YEARISH_SEGMENT) || last.match?(POST_SLUG_SEGMENT)
410
347
  end
411
348
  end
412
349
 
@@ -421,11 +358,15 @@ module Html2rss
421
358
  # @param anchor_or_href [Nokogiri::XML::Element, String, #to_s] anchor element or href-like value
422
359
  # @return [DestinationFacts, nil] normalized destination facts, or nil for blank/invalid URLs
423
360
  def destination_facts(anchor_or_href)
361
+ return node_facts[anchor_or_href] if node_facts.key?(anchor_or_href)
362
+
424
363
  href = HrefExtractor.call(anchor_or_href)
425
364
  return unless href
426
365
 
427
- url = Html2rss::Url.from_relative(href, @base_url)
428
- DestinationFacts.build(url)
366
+ res = memoized_destination_facts(href)
367
+
368
+ node_facts[anchor_or_href] = res if anchor_or_href.is_a?(Nokogiri::XML::Node)
369
+ res
429
370
  rescue ArgumentError
430
371
  nil
431
372
  end
@@ -441,6 +382,19 @@ module Html2rss
441
382
  # @param text [String, #to_s] visible anchor text
442
383
  # @return [Boolean] true when text identifies recommendation chrome
443
384
  def recommended_text?(text) = @text_classifier.recommended?(text)
385
+
386
+ private
387
+
388
+ def node_facts
389
+ @node_facts ||= {}.compare_by_identity
390
+ end
391
+
392
+ def memoized_destination_facts(href)
393
+ (@destination_facts ||= {})[href] ||= begin
394
+ url = Html2rss::Url.from_relative(href, @base_url)
395
+ DestinationFacts.build(url)
396
+ end
397
+ end
444
398
  end
445
399
  end
446
400
  end
@@ -92,7 +92,7 @@ module Html2rss
92
92
  attr_reader :parsed_body, :url
93
93
 
94
94
  # @param root [Nokogiri::XML::Element] supported Microdata root node
95
- # @return [Hash{Symbol => Object}, nil] normalized article hash
95
+ # @return [Hash{Symbol => Object, nil}] normalized article hash
96
96
  def article_from(root)
97
97
  schema_object = SchemaObjectBuilder.call(root)
98
98
  return unless schema_object
@@ -378,7 +378,7 @@ module Html2rss
378
378
  extend ValueNormalizer
379
379
 
380
380
  # @param root [Nokogiri::XML::Element] supported microdata root node
381
- # @return [Hash{Symbol => Object}, nil] compact schema-like object
381
+ # @return [Hash{Symbol => Object, nil}] compact schema-like object
382
382
  def call(root)
383
383
  type = Microdata.supported_type_name(root)
384
384
  return unless type
@@ -13,11 +13,10 @@ module Html2rss
13
13
  # @param schema_object [Hash] The schema object
14
14
  # @return [Array<String>] Array of category strings
15
15
  def self.call(schema_object)
16
- # Build union of all category sources
17
- field_categories = extract_field_categories(schema_object)
18
- about_categories = extract_about_categories(schema_object)
19
-
20
- (field_categories | about_categories).to_a
16
+ Set.new.tap do |categories|
17
+ extract_field_categories!(categories, schema_object)
18
+ extract_about_categories!(categories, schema_object)
19
+ end.to_a
21
20
  end
22
21
 
23
22
  ##
@@ -26,10 +25,18 @@ module Html2rss
26
25
  # @param schema_object [Hash] The schema object
27
26
  # @return [Set<String>] Set of category strings
28
27
  def self.extract_field_categories(schema_object)
29
- Set.new.tap do |categories|
30
- %w[keywords categories tags].each do |field|
31
- categories.merge(extract_field_value(schema_object, field))
32
- end
28
+ Set.new.tap { |categories| extract_field_categories!(categories, schema_object) }
29
+ end
30
+
31
+ ##
32
+ # Extracts categories from keywords, categories, and tags fields.
33
+ #
34
+ # @param categories [Set<String>] Accumulator set
35
+ # @param schema_object [Hash] The schema object
36
+ # @return [void]
37
+ def self.extract_field_categories!(categories, schema_object)
38
+ %i[keywords categories tags].each do |field|
39
+ extract_field_value!(categories, schema_object[field])
33
40
  end
34
41
  end
35
42
 
@@ -39,15 +46,23 @@ module Html2rss
39
46
  # @param schema_object [Hash] The schema object
40
47
  # @return [Set<String>] Set of category strings
41
48
  def self.extract_about_categories(schema_object)
49
+ Set.new.tap { |categories| extract_about_categories!(categories, schema_object) }
50
+ end
51
+
52
+ ##
53
+ # Extracts categories from the about field.
54
+ #
55
+ # @param categories [Set<String>] Accumulator set
56
+ # @param schema_object [Hash] The schema object
57
+ # @return [void]
58
+ def self.extract_about_categories!(categories, schema_object)
42
59
  about = schema_object[:about]
43
- return Set.new unless about
60
+ return unless about
44
61
 
45
62
  if about.is_a?(Array)
46
- extract_about_array(about)
63
+ extract_about_array!(categories, about)
47
64
  elsif about.is_a?(String)
48
- extract_string_categories(about)
49
- else
50
- Set.new
65
+ extract_string_categories!(categories, about)
51
66
  end
52
67
  end
53
68
 
@@ -58,15 +73,25 @@ module Html2rss
58
73
  # @param field [String] The field name
59
74
  # @return [Set<String>] Set of category strings
60
75
  def self.extract_field_value(schema_object, field)
61
- value = schema_object[field.to_sym]
62
- return Set.new unless value
76
+ Set.new.tap { |categories| extract_field_value!(categories, schema_object[field.to_sym]) }
77
+ end
78
+
79
+ ##
80
+ # Extracts categories from a single field value.
81
+ #
82
+ # @param categories [Set<String>] Accumulator set
83
+ # @param value [Object] The field value
84
+ # @return [void]
85
+ def self.extract_field_value!(categories, value)
86
+ return unless value
63
87
 
64
88
  if value.is_a?(Array)
65
- Set.new(value.map(&:to_s).reject(&:empty?))
89
+ value.each do |item|
90
+ s = item.to_s
91
+ categories.add(s) unless s.empty?
92
+ end
66
93
  elsif value.is_a?(String)
67
- extract_string_categories(value)
68
- else
69
- Set.new
94
+ extract_string_categories!(categories, value)
70
95
  end
71
96
  end
72
97
 
@@ -76,13 +101,21 @@ module Html2rss
76
101
  # @param about [Array] The about array
77
102
  # @return [Set<String>] Set of category strings
78
103
  def self.extract_about_array(about)
79
- Set.new.tap do |categories|
80
- about.each do |item|
81
- if item.is_a?(Hash) && item[:name]
82
- categories.add(item[:name].to_s)
83
- elsif item.is_a?(String)
84
- categories.add(item)
85
- end
104
+ Set.new.tap { |categories| extract_about_array!(categories, about) }
105
+ end
106
+
107
+ ##
108
+ # Extracts categories from an about array.
109
+ #
110
+ # @param categories [Set<String>] Accumulator set
111
+ # @param about [Array] The about array
112
+ # @return [void]
113
+ def self.extract_about_array!(categories, about)
114
+ about.each do |item|
115
+ if item.is_a?(Hash) && item[:name]
116
+ categories.add(item[:name].to_s)
117
+ elsif item.is_a?(String)
118
+ categories.add(item)
86
119
  end
87
120
  end
88
121
  end
@@ -93,7 +126,20 @@ module Html2rss
93
126
  # @param string [String] source string that may contain category delimiters
94
127
  # @return [Set<String>] Set of category strings
95
128
  def self.extract_string_categories(string)
96
- Set.new(string.split(/[,;|]/).map(&:strip).reject(&:empty?))
129
+ Set.new.tap { |categories| extract_string_categories!(categories, string) }
130
+ end
131
+
132
+ ##
133
+ # Extracts categories from a string by splitting on separators.
134
+ #
135
+ # @param categories [Set<String>] Accumulator set
136
+ # @param string [String] source string that may contain category delimiters
137
+ # @return [void]
138
+ def self.extract_string_categories!(categories, string)
139
+ string.split(/[,;|]/).each do |part|
140
+ s = part.strip
141
+ categories.add(s) unless s.empty?
142
+ end
97
143
  end
98
144
  end
99
145
  end
@@ -16,9 +16,10 @@ module Html2rss
16
16
 
17
17
  # @return [Html2rss::Url, nil]
18
18
  def url
19
- url = schema_object.dig(:item, :url) || super
19
+ return @url if defined?(@url)
20
20
 
21
- Url.from_relative(url, base_url || url) if url
21
+ item_url = schema_object.dig(:item, :url)
22
+ @url = item_url ? Url.from_relative(item_url, base_url || item_url) : super
22
23
  end
23
24
  end
24
25
  end