html2rss 0.24.0 → 0.26.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 (129) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +76 -2
  3. data/html2rss.gemspec +4 -1
  4. data/lib/html2rss/auto_source/cleanup.rb +62 -36
  5. data/lib/html2rss/auto_source/scraper/html.rb +68 -145
  6. data/lib/html2rss/auto_source/scraper/json_state/article_normalizer.rb +116 -0
  7. data/lib/html2rss/auto_source/scraper/json_state/candidate_detector.rb +63 -0
  8. data/lib/html2rss/auto_source/scraper/json_state/document_scanner.rb +179 -0
  9. data/lib/html2rss/auto_source/scraper/json_state/value_finder.rb +55 -0
  10. data/lib/html2rss/auto_source/scraper/json_state.rb +31 -401
  11. data/lib/html2rss/auto_source/scraper/schema/item_list.rb +2 -14
  12. data/lib/html2rss/auto_source/scraper/semantic_html/entry_deduplicator.rb +35 -30
  13. data/lib/html2rss/auto_source/scraper/semantic_html.rb +40 -120
  14. data/lib/html2rss/auto_source/scraper/sitemap/parser.rb +161 -0
  15. data/lib/html2rss/auto_source/scraper/sitemap.rb +10 -7
  16. data/lib/html2rss/auto_source/scraper/wordpress_api.rb +6 -2
  17. data/lib/html2rss/auto_source/scraper/xhr_articles.rb +69 -0
  18. data/lib/html2rss/auto_source/scraper.rb +109 -53
  19. data/lib/html2rss/auto_source/segment.rb +29 -0
  20. data/lib/html2rss/auto_source/segmenter/cluster.rb +163 -0
  21. data/lib/html2rss/auto_source/segmenter/list.rb +103 -0
  22. data/lib/html2rss/auto_source/segmenter/primary_link.rb +80 -0
  23. data/lib/html2rss/auto_source/segmenter/semantic.rb +104 -0
  24. data/lib/html2rss/auto_source/segmenter.rb +95 -0
  25. data/lib/html2rss/auto_source.rb +79 -21
  26. data/lib/html2rss/capture.rb +315 -0
  27. data/lib/html2rss/cli.rb +72 -18
  28. data/lib/html2rss/config/auto_source_contract.rb +5 -1
  29. data/lib/html2rss/config/request_controls.rb +33 -0
  30. data/lib/html2rss/config/schema.rb +67 -26
  31. data/lib/html2rss/config/selectors_validator.rb +60 -21
  32. data/lib/html2rss/config/validator.rb +33 -35
  33. data/lib/html2rss/config.rb +6 -2
  34. data/lib/html2rss/error.rb +15 -3
  35. data/lib/html2rss/feed_builder/rss.rb +17 -5
  36. data/lib/html2rss/feed_pipeline/auto_fallback.rb +16 -6
  37. data/lib/html2rss/feed_pipeline/runtime_policy.rb +3 -43
  38. data/lib/html2rss/feed_pipeline.rb +11 -0
  39. data/lib/html2rss/feed_result.rb +1 -1
  40. data/lib/html2rss/hash_util.rb +17 -0
  41. data/lib/html2rss/html/article_extractor/category_extractor.rb +10 -36
  42. data/lib/html2rss/html/article_extractor/date_extractor.rb +2 -7
  43. data/lib/html2rss/html/article_extractor/enclosure_extractor.rb +5 -50
  44. data/lib/html2rss/html/article_extractor/image_extractor.rb +5 -16
  45. data/lib/html2rss/html/article_rules/category.rb +55 -0
  46. data/lib/html2rss/html/article_rules/date.rb +25 -0
  47. data/lib/html2rss/html/article_rules/enclosure.rb +72 -0
  48. data/lib/html2rss/html/article_rules/image.rb +109 -0
  49. data/lib/html2rss/html/article_rules.rb +10 -0
  50. data/lib/html2rss/html/rendering/audio_renderer.rb +2 -12
  51. data/lib/html2rss/html/rendering/escaped_attributes.rb +27 -0
  52. data/lib/html2rss/html/rendering/image_renderer.rb +2 -12
  53. data/lib/html2rss/html/rendering/pdf_renderer.rb +2 -8
  54. data/lib/html2rss/html/rendering/video_renderer.rb +2 -12
  55. data/lib/html2rss/html/sst_article_extractor.rb +308 -0
  56. data/lib/html2rss/link_destination/destination_facts.rb +40 -0
  57. data/lib/html2rss/link_destination/noise_policy.rb +68 -0
  58. data/lib/html2rss/link_destination/path_classifier.rb +208 -0
  59. data/lib/html2rss/link_destination/text_classifier.rb +64 -0
  60. data/lib/html2rss/link_destination.rb +8 -0
  61. data/lib/html2rss/mcp/server.rb +577 -0
  62. data/lib/html2rss/mcp.rb +21 -0
  63. data/lib/html2rss/request_service/blocked_surface.rb +24 -1
  64. data/lib/html2rss/request_service/botasaurus_contract.rb +89 -9
  65. data/lib/html2rss/request_service/botasaurus_strategy.rb +4 -2
  66. data/lib/html2rss/request_service/budget.rb +7 -35
  67. data/lib/html2rss/request_service/context.rb +0 -6
  68. data/lib/html2rss/request_service/faraday_strategy.rb +87 -3
  69. data/lib/html2rss/request_service/network_guard.rb +5 -3
  70. data/lib/html2rss/request_service/policy.rb +1 -1
  71. data/lib/html2rss/request_service/response.rb +15 -1
  72. data/lib/html2rss/request_service/strategy.rb +1 -2
  73. data/lib/html2rss/request_service.rb +4 -9
  74. data/lib/html2rss/scoring/cluster_scorer.rb +87 -0
  75. data/lib/html2rss/scoring/container_assessor.rb +77 -0
  76. data/lib/html2rss/scoring/engine.rb +145 -0
  77. data/lib/html2rss/scoring/link_resolver.rb +84 -0
  78. data/lib/html2rss/scoring/observation.rb +53 -0
  79. data/lib/html2rss/scoring/ranked_segment.rb +33 -0
  80. data/lib/html2rss/scoring/score.rb +11 -0
  81. data/lib/html2rss/scoring.rb +8 -0
  82. data/lib/html2rss/selectors/extractors/attribute.rb +15 -0
  83. data/lib/html2rss/selectors/extractors/href.rb +12 -0
  84. data/lib/html2rss/selectors/extractors/html.rb +12 -0
  85. data/lib/html2rss/selectors/extractors/static.rb +14 -0
  86. data/lib/html2rss/selectors/extractors/text.rb +11 -0
  87. data/lib/html2rss/selectors/post_processors/gsub.rb +18 -0
  88. data/lib/html2rss/selectors/post_processors/html_to_markdown.rb +11 -0
  89. data/lib/html2rss/selectors/post_processors/markdown_to_html.rb +12 -0
  90. data/lib/html2rss/selectors/post_processors/parse_time.rb +11 -0
  91. data/lib/html2rss/selectors/post_processors/parse_uri.rb +11 -0
  92. data/lib/html2rss/selectors/post_processors/sanitize_html.rb +23 -1
  93. data/lib/html2rss/selectors/post_processors/substring.rb +21 -0
  94. data/lib/html2rss/selectors/post_processors/template.rb +20 -0
  95. data/lib/html2rss/selectors/schema_doc.rb +99 -0
  96. data/lib/html2rss/selectors.rb +0 -20
  97. data/lib/html2rss/sst/attrs.rb +91 -0
  98. data/lib/html2rss/sst/document.rb +23 -0
  99. data/lib/html2rss/sst/index.rb +112 -0
  100. data/lib/html2rss/sst/node.rb +147 -0
  101. data/lib/html2rss/sst/normalizer.rb +171 -0
  102. data/lib/html2rss/sst/tags.rb +26 -0
  103. data/lib/html2rss/sst/text.rb +81 -0
  104. data/lib/html2rss/sst.rb +8 -0
  105. data/lib/html2rss/url.rb +11 -7
  106. data/lib/html2rss/version.rb +1 -1
  107. data/lib/html2rss.rb +57 -26
  108. data/schema/html2rss-config.schema.json +428 -85
  109. metadata +93 -27
  110. data/lib/html2rss/auto_source/discovery/dom_clustering/group_scorer.rb +0 -80
  111. data/lib/html2rss/auto_source/discovery/dom_clustering/overlap_resolver.rb +0 -86
  112. data/lib/html2rss/auto_source/discovery/dom_clustering.rb +0 -119
  113. data/lib/html2rss/auto_source/discovery/list_candidates.rb +0 -94
  114. data/lib/html2rss/auto_source/discovery/semantic_anchor_candidates.rb +0 -219
  115. data/lib/html2rss/auto_source/discovery/semantic_containers.rb +0 -71
  116. data/lib/html2rss/auto_source/discovery/sitemap.rb +0 -159
  117. data/lib/html2rss/auto_source/discovery.rb +0 -14
  118. data/lib/html2rss/auto_source/link_heuristics/anchor_signals.rb +0 -28
  119. data/lib/html2rss/auto_source/link_heuristics/container_assessor.rb +0 -106
  120. data/lib/html2rss/auto_source/link_heuristics/container_signals.rb +0 -80
  121. data/lib/html2rss/auto_source/link_heuristics/destination_facts.rb +0 -42
  122. data/lib/html2rss/auto_source/link_heuristics/href_extractor.rb +0 -38
  123. data/lib/html2rss/auto_source/link_heuristics/path_classifier.rb +0 -221
  124. data/lib/html2rss/auto_source/link_heuristics/text_classifier.rb +0 -66
  125. data/lib/html2rss/auto_source/link_heuristics.rb +0 -139
  126. data/lib/html2rss/request_service/browserless_strategy.rb +0 -132
  127. data/lib/html2rss/request_service/puppet_commander/navigation_guards.rb +0 -148
  128. data/lib/html2rss/request_service/puppet_commander/preload_runner.rb +0 -86
  129. data/lib/html2rss/request_service/puppet_commander.rb +0 -95
@@ -79,7 +79,7 @@ module Html2rss
79
79
  RSS::Maker.make('2.0') do |maker|
80
80
  Stylesheet.add(maker, stylesheets)
81
81
 
82
- make_channel(maker.channel)
82
+ make_channel(maker)
83
83
  make_items(maker)
84
84
  end
85
85
  end
@@ -96,14 +96,26 @@ module Html2rss
96
96
  @stylesheets.map { |style| Stylesheet.new(**style) }
97
97
  end
98
98
 
99
+ # rubocop:disable Metrics/AbcSize
99
100
  def make_channel(maker)
101
+ channel_maker = maker.channel
100
102
  %i[language title description ttl].each do |key|
101
- maker.public_send(:"#{key}=", channel.public_send(key))
103
+ channel_maker.public_send(:"#{key}=", channel.public_send(key))
102
104
  end
103
105
 
104
- maker.link = channel.url.to_s
105
- maker.generator = generator
106
- maker.updated = channel.last_build_date
106
+ channel_maker.managingEditor = channel.author if channel.author
107
+ channel_maker.author = channel.author if channel.author
108
+ channel_maker.link = channel.url.to_s
109
+ channel_maker.generator = generator
110
+ channel_maker.updated = channel.last_build_date
111
+
112
+ make_image(maker.image) if channel.image
113
+ end
114
+ # rubocop:enable Metrics/AbcSize
115
+
116
+ def make_image(image_maker)
117
+ image_maker.url = channel.image.to_s
118
+ image_maker.title = channel.title.to_s
107
119
  end
108
120
 
109
121
  def make_items(maker)
@@ -19,18 +19,18 @@ module Html2rss
19
19
  RequestService::RequestBudgetExceeded,
20
20
  RequestService::PrivateNetworkDenied,
21
21
  RequestService::CrossOriginFollowUpDenied,
22
- RequestService::ResponseTooLarge,
23
- RequestService::BrowserlessConfigurationError
22
+ RequestService::ResponseTooLarge
24
23
  ].freeze
25
24
 
26
25
  ##
27
26
  # Mutable run state for one auto-fallback chain: attempts plus selected result.
28
27
  class AttemptState
29
- attr_reader :attempts, :result
28
+ attr_reader :attempts, :result, :last_response
30
29
 
31
30
  def initialize
32
31
  @attempts = []
33
32
  @result = nil
33
+ @last_response = nil
34
34
  end
35
35
 
36
36
  # @return [Boolean] true when a strategy already yielded items
@@ -53,6 +53,12 @@ module Html2rss
53
53
  @attempts << attempt
54
54
  end
55
55
 
56
+ # @param response [RequestService::Response] last response that reached extraction
57
+ # @return [void]
58
+ def remember_response(response)
59
+ @last_response = response
60
+ end
61
+
56
62
  # @param response [RequestService::Response] successful response
57
63
  # @param articles [Array] extracted articles
58
64
  # @param dedup_dropped [Integer] articles removed by deduplication
@@ -92,7 +98,7 @@ module Html2rss
92
98
  state = run_attempts
93
99
  return state.result if state.succeeded?
94
100
 
95
- finalize_failure(attempts: state.attempts)
101
+ finalize_failure(attempts: state.attempts, response: state.last_response)
96
102
  end
97
103
 
98
104
  private
@@ -127,6 +133,7 @@ module Html2rss
127
133
  end
128
134
 
129
135
  def process_response(response:, strategy:, next_strategy:, request_session:, state:)
136
+ state.remember_response(response)
130
137
  articles, dedup_dropped = articles_for(response:, request_session:)
131
138
  items_count = articles.size
132
139
  state.record_items(strategy:, items_count:, transport_meta: response.transport_meta)
@@ -152,8 +159,11 @@ module Html2rss
152
159
  "budget_remaining=#{budget_remaining_label}")
153
160
  end
154
161
 
155
- def finalize_failure(attempts:)
156
- raise NoFeedItemsExtracted.new(attempts:)
162
+ def finalize_failure(attempts:, response:)
163
+ surface_category = response && AutoSource::Scraper.classify_no_scraper_surface(
164
+ response.parsed_body, body: response.body
165
+ )
166
+ raise NoFeedItemsExtracted.new(attempts:, surface_category:)
157
167
  end
158
168
 
159
169
  # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
@@ -6,8 +6,7 @@ module Html2rss
6
6
  # Planner for the runtime request policy and budgets of a feed run.
7
7
  #
8
8
  # HTTP request slots (pagination, document GETs, strategy fallback) are planned
9
- # separately from Browserless preload interaction budget so preload cannot steal
10
- # pagination slots.
9
+ # from configuration options.
11
10
  class RuntimePolicy
12
11
  # Policy plus Budget from one config expansion (shared meters for a feed run).
13
12
  Resources = Data.define(:policy, :budget)
@@ -34,26 +33,18 @@ module Html2rss
34
33
  policy:,
35
34
  budget: RequestService::Budget.new(
36
35
  max_requests: policy.max_requests,
37
- max_interactions: interaction_budget_for(config),
38
36
  total_timeout_seconds: policy.total_timeout_seconds
39
37
  )
40
38
  )
41
39
  end
42
40
 
43
41
  ##
44
- # Builds a Budget with separate request and interaction slot pools.
42
+ # Builds a Budget with request slot pools.
45
43
  #
46
44
  # @param config [Html2rss::Config] validated feed config
47
45
  # @return [Html2rss::RequestService::Budget] shared budget for the feed build
48
46
  def self.budget_for(config) = resources_for(config).budget
49
47
 
50
- ##
51
- # @param config [Html2rss::Config] validated feed config
52
- # @return [Integer] preload interaction slots derived from browserless preload config
53
- def self.interaction_budget_for(config)
54
- browserless_preload_budget_for(config)
55
- end
56
-
57
48
  class << self
58
49
  private
59
50
 
@@ -64,44 +55,13 @@ module Html2rss
64
55
  end
65
56
 
66
57
  # Reserve enough HTTP request slots for the initial request plus predictable
67
- # follow-ups. Preload interactions are planned separately.
58
+ # follow-ups.
68
59
  def baseline_request_budget_for(config)
69
60
  1 +
70
61
  RequestSession::Pager.request_slots_for(config.selectors&.dig(:items, :pagination)) +
71
62
  AutoSource.request_slots_for(config.auto_source) +
72
63
  StrategyPlan.resolve(config.strategy).request_slots
73
64
  end
74
-
75
- def browserless_preload_budget_for(config)
76
- preload = config.request.dig(:browserless, :preload)
77
- return 0 unless preload
78
-
79
- top_level_preload_wait_budget(preload) +
80
- click_selector_preload_budget(preload) +
81
- scroll_preload_budget(preload)
82
- end
83
-
84
- def top_level_preload_wait_budget(preload)
85
- preload[:wait_after_ms] ? 2 : 0
86
- end
87
-
88
- def click_selector_preload_budget(preload)
89
- preload.fetch(:click_selectors, []).sum { preload_action_budget(_1, :max_clicks) }
90
- end
91
-
92
- def scroll_preload_budget(preload)
93
- scroll = preload[:scroll_down]
94
- return 0 unless scroll
95
-
96
- preload_action_budget(scroll, :iterations)
97
- end
98
-
99
- def preload_action_budget(config, count_key)
100
- action_count = config.fetch(count_key, 1)
101
- wait_budget = config[:wait_after_ms] ? action_count : 0
102
-
103
- action_count + wait_budget
104
- end
105
65
  end
106
66
  end
107
67
  end
@@ -81,11 +81,22 @@ module Html2rss
81
81
  request_session = request_session_for(config, strategy:, resources:)
82
82
  response = request_session.fetch_initial_response
83
83
  articles, dedup_dropped = deduplicated_articles(config:, response:, request_session:)
84
+ raise_empty_auto_source!(strategy:, response:) if config.auto_source && articles.empty?
85
+
84
86
  PipelineOutcome.new(
85
87
  response:, articles:, dedup_dropped:, selected_strategy: nil, attempt_count: 0, strategy_attempts: []
86
88
  )
87
89
  end
88
90
 
91
+ def raise_empty_auto_source!(strategy:, response:)
92
+ raise NoFeedItemsExtracted.new(
93
+ attempts: [{ strategy:, items_count: 0, error_class: nil }],
94
+ surface_category: AutoSource::Scraper.classify_no_scraper_surface(
95
+ response.parsed_body, body: response.body
96
+ )
97
+ )
98
+ end
99
+
89
100
  def run_auto_pipeline(config, resources:)
90
101
  AutoFallback.new(
91
102
  strategies: AutoFallback::CHAIN,
@@ -69,7 +69,7 @@ module Html2rss
69
69
 
70
70
  ##
71
71
  # @return [Html2rss::Status] frozen scraper tallies, dedup count, and generator formatter.
72
- # {#to_h} on +status+ is a stable consumer contract for observability payloads.
72
+ # {Status#to_h} on +status+ is a stable consumer contract for observability payloads.
73
73
  attr_reader :status
74
74
 
75
75
  private
@@ -54,6 +54,23 @@ module Html2rss
54
54
  end
55
55
  end
56
56
 
57
+ # Converts hash keys (and Symbol values) to strings recursively.
58
+ #
59
+ # @param object [Object] value to normalize
60
+ # @return [Object] normalized value with string keys
61
+ def deep_stringify_keys(object)
62
+ case object
63
+ when Hash
64
+ object.to_h { |key, value| [key.to_s, deep_stringify_keys(value)] }
65
+ when Array
66
+ object.map { deep_stringify_keys(_1) }
67
+ when Symbol
68
+ object.to_s
69
+ else
70
+ object
71
+ end
72
+ end
73
+
57
74
  # Validates that hash keys are symbols.
58
75
  #
59
76
  # @param value [Object] candidate hash container whose keys must be symbols
@@ -7,20 +7,16 @@ module Html2rss
7
7
  # CategoryExtractor is responsible for extracting categories from HTML elements
8
8
  # by looking for CSS class names containing common category-related terms.
9
9
  class CategoryExtractor
10
- # Common category-related terms to look for in class names
11
- CATEGORY_TERMS = %w[
12
- category categories tag tags topic topics section sections
13
- label labels theme themes subject subjects
14
- ].freeze
10
+ # Shared category vocabulary (owned by {ArticleRules::Category}).
11
+ CATEGORY_TERMS = ArticleRules::Category::CATEGORY_TERMS
12
+ # Shared category attribute/class pattern (owned by {ArticleRules::Category}).
13
+ CATEGORY_ATTR_PATTERN = ArticleRules::Category::CATEGORY_ATTR_PATTERN
15
14
 
16
15
  # CSS selectors to find elements with category-related class names or data attributes
17
16
  CATEGORY_SELECTORS = CATEGORY_TERMS.flat_map do |term|
18
17
  ["[class*=\"#{term}\"]", "[data-#{term}]", "[#{term}]"]
19
18
  end.freeze
20
19
 
21
- # Regex pattern for matching category-related attribute names
22
- CATEGORY_ATTR_PATTERN = /#{CATEGORY_TERMS.join('|')}/i
23
-
24
20
  ##
25
21
  # Extracts categories from the given article tag by looking for elements
26
22
  # with class names containing common category-related terms.
@@ -30,7 +26,6 @@ module Html2rss
30
26
  def self.call(article_tag)
31
27
  return [] unless article_tag
32
28
 
33
- # Single optimized traversal that extracts all category types
34
29
  extract_all_categories(article_tag)
35
30
  .map(&:strip)
36
31
  .reject(&:empty?)
@@ -44,10 +39,7 @@ module Html2rss
44
39
  def self.extract_all_categories(article_tag)
45
40
  Set.new.tap do |categories|
46
41
  article_tag.css(CATEGORY_SELECTORS.join(',')).each do |element|
47
- # Extract text categories from elements with category-related class names
48
- extract_text_categories!(categories, element) if element['class']&.match?(CATEGORY_ATTR_PATTERN)
49
-
50
- # Extract data categories from all elements
42
+ extract_text_categories!(categories, element) if ArticleRules::Category.class_match?(element['class'])
51
43
  extract_element_data_categories!(categories, element)
52
44
  end
53
45
  end
@@ -61,10 +53,9 @@ module Html2rss
61
53
  # @return [void]
62
54
  def self.extract_element_data_categories!(categories, element)
63
55
  element.attributes.each_value do |attr|
64
- next unless attr.name.match?(CATEGORY_ATTR_PATTERN)
56
+ next unless ArticleRules::Category.attr_name_match?(attr.name)
65
57
 
66
- value = attr.value&.strip
67
- categories.add(value) if value && !value.empty?
58
+ ArticleRules::Category.add_text!(categories, attr.value)
68
59
  end
69
60
  end
70
61
 
@@ -85,7 +76,7 @@ module Html2rss
85
76
  if anchors.any?
86
77
  anchors.each { |node| add_text_to_categories!(categories, node) }
87
78
  else
88
- extract_split_text_categories!(categories, element)
79
+ ArticleRules::Category.add_split_text!(categories, element.text)
89
80
  end
90
81
  end
91
82
 
@@ -96,27 +87,10 @@ module Html2rss
96
87
  # @param element [Nokogiri::XML::Element] The element to extract text from
97
88
  # @return [void]
98
89
  def self.add_text_to_categories!(categories, element)
99
- text = Navigator.extract_visible_text(element)
100
- categories.add(text) if text && !text.empty?
101
- end
102
-
103
- ##
104
- # Extracts categories from the element's text by splitting on newlines.
105
- #
106
- # @param categories [Set<String>] Accumulator set
107
- # @param element [Nokogiri::XML::Element] The element to extract text from
108
- # @return [void]
109
- def self.extract_split_text_categories!(categories, element)
110
- text = element.text
111
- return unless text
112
-
113
- text.split(/\n+/).each do |line|
114
- line = line.strip
115
- categories.add(line) unless line.empty?
116
- end
90
+ ArticleRules::Category.add_text!(categories, Navigator.extract_visible_text(element))
117
91
  end
118
92
 
119
- private_class_method :add_text_to_categories!, :extract_split_text_categories!
93
+ private_class_method :add_text_to_categories!
120
94
  end
121
95
  end
122
96
  end
@@ -8,13 +8,8 @@ module Html2rss
8
8
  # @param article_tag [Nokogiri::XML::Element] article container node
9
9
  # @return [DateTime, nil]
10
10
  def self.call(article_tag)
11
- times = article_tag.css('[datetime]').filter_map do |tag|
12
- DateTime.parse(tag['datetime'])
13
- rescue ArgumentError, TypeError
14
- nil
15
- end
16
-
17
- times.min
11
+ datetimes = article_tag.css('[datetime]').map { |tag| tag['datetime'] }
12
+ ArticleRules::Date.earliest(datetimes)
18
13
  end
19
14
  end
20
15
  end
@@ -33,62 +33,17 @@ module Html2rss
33
33
  def self.extract_from_element(element, base_url)
34
34
  case element.name
35
35
  when 'img'
36
- extract_image(element, base_url)
36
+ ArticleRules::Enclosure.from_image(element['src'], base_url)
37
37
  when 'video', 'audio', 'source'
38
- extract_media(element, base_url)
38
+ ArticleRules::Enclosure.from_media(element['src'], element['type'], base_url)
39
39
  when 'iframe'
40
- extract_iframe(element, base_url)
40
+ ArticleRules::Enclosure.from_iframe(element['src'], base_url)
41
41
  when 'a'
42
- extract_a(element, base_url)
42
+ ArticleRules::Enclosure.from_anchor(element['href'], base_url)
43
43
  end
44
44
  end
45
45
 
46
- def self.extract_image(img, base_url)
47
- src = img['src'].to_s
48
- return if src.empty?
49
-
50
- abs_url = Url.from_relative(src, base_url)
51
- {
52
- url: abs_url,
53
- type: Article::Enclosure.guess_content_type_from_url(abs_url, default: 'image/jpeg')
54
- }
55
- end
56
-
57
- def self.extract_media(element, base_url)
58
- src = element['src'].to_s
59
- return if src.empty?
60
-
61
- {
62
- url: Url.from_relative(src, base_url),
63
- type: element['type']
64
- }
65
- end
66
-
67
- def self.extract_iframe(iframe, base_url)
68
- src = iframe['src'].to_s
69
- return if src.empty?
70
-
71
- abs_url = Url.from_relative(src, base_url)
72
- {
73
- url: abs_url,
74
- type: Article::Enclosure.guess_content_type_from_url(abs_url, default: 'text/html')
75
- }
76
- end
77
-
78
- def self.extract_a(link, base_url)
79
- href = link['href'].to_s
80
- return if href.empty?
81
-
82
- abs_url = Url.from_relative(href, base_url)
83
-
84
- if href.end_with?('.pdf')
85
- { url: abs_url, type: Article::Enclosure.guess_content_type_from_url(abs_url) }
86
- else
87
- { url: abs_url, type: 'application/zip' }
88
- end
89
- end
90
-
91
- private_class_method :extract_from_element, :extract_image, :extract_media, :extract_iframe, :extract_a
46
+ private_class_method :extract_from_element
92
47
  end
93
48
  end
94
49
  end
@@ -32,27 +32,16 @@ module Html2rss
32
32
  # @see <https://developer.mozilla.org/en-US/docs/Learn/HTML/Multimedia_and_embedding/Responsive_images>
33
33
  # @see <https://developer.mozilla.org/en-US/docs/Web/HTML/Element/img#srcset>
34
34
  # @see <https://developer.mozilla.org/en-US/docs/Web/HTML/Element/picture>
35
- def self.from_source(article_tag) # rubocop:disable Metrics/AbcSize
36
- hash = article_tag.css('img[srcset], picture > source[srcset]').flat_map do |source|
37
- source['srcset'].to_s.scan(/(\S+)\s+(\d+w|\d+h)[\s,]?/).map do |url, width|
38
- next if url.nil? || url.start_with?('data:')
39
-
40
- width_value = width.to_i.zero? ? 0 : width.scan(/\d+/).first.to_i
41
-
42
- [width_value, url.strip]
43
- end
44
- end.compact.to_h
45
-
46
- hash[hash.keys.max]
35
+ def self.from_source(article_tag)
36
+ srcsets = article_tag.css('img[srcset], picture > source[srcset]').map { |source| source['srcset'] }
37
+ ArticleRules::Image.largest_from_srcsets(srcsets)
47
38
  end
48
39
 
49
40
  # @param article_tag [Nokogiri::XML::Element] article container node
50
41
  # @return [String, nil] best style-based background image URL
51
42
  def self.from_style(article_tag)
52
- article_tag.css('[style*="url"]')
53
- .filter_map { |tag| tag['style'][/url\(['"]?(.*?)['"]?\)/, 1] }
54
- .reject { |src| src.start_with?('data:') }
55
- .max_by(&:size)
43
+ styles = article_tag.css('[style*="url"]').map { |tag| tag['style'] }
44
+ ArticleRules::Image.best_from_styles(styles)
56
45
  end
57
46
  end
58
47
  end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ module Html
5
+ module ArticleRules
6
+ ##
7
+ # Shared category term vocabulary and text accumulation (DOM walk stays in adapters).
8
+ module Category
9
+ # Class/data attribute tokens that mark category metadata.
10
+ CATEGORY_TERMS = %w[
11
+ category categories tag tags topic topics section sections
12
+ label labels theme themes subject subjects
13
+ ].freeze
14
+
15
+ # Regex matching category-related attribute or class names.
16
+ CATEGORY_ATTR_PATTERN = /#{CATEGORY_TERMS.join('|')}/i
17
+
18
+ class << self
19
+ ##
20
+ # @param name [String]
21
+ # @return [Boolean]
22
+ def attr_name_match?(name)
23
+ name.to_s.match?(CATEGORY_ATTR_PATTERN)
24
+ end
25
+
26
+ ##
27
+ # @param class_attr [String, nil]
28
+ # @return [Boolean]
29
+ def class_match?(class_attr)
30
+ class_attr.to_s.match?(CATEGORY_ATTR_PATTERN)
31
+ end
32
+
33
+ ##
34
+ # @param categories [Set<String>]
35
+ # @param text [String, nil]
36
+ # @return [void]
37
+ def add_text!(categories, text)
38
+ value = text.to_s.strip
39
+ categories.add(value) unless value.empty?
40
+ end
41
+
42
+ ##
43
+ # @param categories [Set<String>]
44
+ # @param text [String, nil]
45
+ # @return [void]
46
+ def add_split_text!(categories, text)
47
+ return unless text
48
+
49
+ text.split(/\n+/).each { |line| add_text!(categories, line) }
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ module Html
5
+ module ArticleRules
6
+ ##
7
+ # DOM-agnostic datetime aggregation for article published_at.
8
+ module Date
9
+ class << self
10
+ ##
11
+ # @param datetime_strings [Array<String, nil>] raw datetime attribute values
12
+ # @return [DateTime, nil] earliest successfully parsed instant
13
+ def earliest(datetime_strings)
14
+ times = Array(datetime_strings).filter_map do |value|
15
+ DateTime.parse(value.to_s)
16
+ rescue ArgumentError, TypeError
17
+ nil
18
+ end
19
+ times.min
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ module Html
5
+ module ArticleRules
6
+ ##
7
+ # DOM-agnostic enclosure URL/type normalization.
8
+ module Enclosure
9
+ # Href path suffixes treated as download/archive enclosure links.
10
+ ARCHIVE_HREF_SUFFIXES = %w[.pdf .zip .tar.gz .tgz].freeze
11
+
12
+ class << self
13
+ ##
14
+ # @param href [String]
15
+ # @return [Boolean]
16
+ def archive_href?(href)
17
+ ARCHIVE_HREF_SUFFIXES.any? { |suffix| href.to_s.end_with?(suffix) }
18
+ end
19
+
20
+ ##
21
+ # @param src [String]
22
+ # @param base_url [String, Html2rss::Url]
23
+ # @return [Hash{Symbol => Object}, nil]
24
+ def from_image(src, base_url)
25
+ return if src.to_s.empty?
26
+
27
+ abs = Url.from_relative(src, base_url)
28
+ { url: abs, type: Article::Enclosure.guess_content_type_from_url(abs, default: 'image/jpeg') }
29
+ end
30
+
31
+ ##
32
+ # @param src [String]
33
+ # @param type [String, nil]
34
+ # @param base_url [String, Html2rss::Url]
35
+ # @return [Hash{Symbol => Object}, nil]
36
+ def from_media(src, type, base_url)
37
+ return if src.to_s.empty?
38
+
39
+ { url: Url.from_relative(src, base_url), type: }
40
+ end
41
+
42
+ ##
43
+ # @param src [String]
44
+ # @param base_url [String, Html2rss::Url]
45
+ # @return [Hash{Symbol => Object}, nil]
46
+ def from_iframe(src, base_url)
47
+ return if src.to_s.empty?
48
+
49
+ abs = Url.from_relative(src, base_url)
50
+ { url: abs, type: Article::Enclosure.guess_content_type_from_url(abs, default: 'text/html') }
51
+ end
52
+
53
+ ##
54
+ # @param href [String]
55
+ # @param base_url [String, Html2rss::Url]
56
+ # @return [Hash{Symbol => Object}, nil]
57
+ def from_anchor(href, base_url)
58
+ return if href.to_s.empty?
59
+
60
+ abs = Url.from_relative(href, base_url)
61
+ type = if href.end_with?('.pdf')
62
+ Article::Enclosure.guess_content_type_from_url(abs)
63
+ else
64
+ 'application/zip'
65
+ end
66
+ { url: abs, type: }
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end