html2rss 0.32.4 → 0.34.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 (202) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +4 -2
  3. data/lib/html2rss/article/deduplicator.rb +5 -2
  4. data/lib/html2rss/article/enclosure.rb +185 -25
  5. data/lib/html2rss/article/title.rb +203 -0
  6. data/lib/html2rss/article.rb +79 -13
  7. data/lib/html2rss/auto_source/README.md +16 -8
  8. data/lib/html2rss/auto_source/cleanup.rb +127 -53
  9. data/lib/html2rss/auto_source/corroboration.rb +167 -0
  10. data/lib/html2rss/auto_source/page_links/anchor_claims.rb +82 -0
  11. data/lib/html2rss/auto_source/page_links/card_units.rb +77 -0
  12. data/lib/html2rss/auto_source/page_links/label_binding.rb +79 -0
  13. data/lib/html2rss/auto_source/page_links/sponsored_cards.rb +147 -0
  14. data/lib/html2rss/auto_source/page_links.rb +164 -0
  15. data/lib/html2rss/auto_source/scraper/html.rb +11 -45
  16. data/lib/html2rss/auto_source/scraper/json_document.rb +3 -6
  17. data/lib/html2rss/auto_source/scraper/json_state/article_normalizer.rb +202 -125
  18. data/lib/html2rss/auto_source/scraper/json_state/candidate_detector.rb +27 -38
  19. data/lib/html2rss/auto_source/scraper/json_state/document_scanner.rb +123 -123
  20. data/lib/html2rss/auto_source/scraper/json_state/value_finder.rb +27 -35
  21. data/lib/html2rss/auto_source/scraper/json_state.rb +4 -9
  22. data/lib/html2rss/auto_source/scraper/meta_oembed.rb +27 -5
  23. data/lib/html2rss/auto_source/scraper/microdata.rb +233 -248
  24. data/lib/html2rss/auto_source/scraper/microformats2.rb +10 -4
  25. data/lib/html2rss/auto_source/scraper/native_feed.rb +28 -18
  26. data/lib/html2rss/auto_source/scraper/schema/category_extractor.rb +7 -2
  27. data/lib/html2rss/auto_source/scraper/schema/item_list.rb +5 -3
  28. data/lib/html2rss/auto_source/scraper/schema/thing.rb +140 -22
  29. data/lib/html2rss/auto_source/scraper/schema.rb +52 -8
  30. data/lib/html2rss/auto_source/scraper/semantic_html/entry_deduplicator.rb +3 -9
  31. data/lib/html2rss/auto_source/scraper/semantic_html.rb +12 -20
  32. data/lib/html2rss/auto_source/scraper/sitemap/parser.rb +1 -4
  33. data/lib/html2rss/auto_source/scraper/sitemap.rb +7 -14
  34. data/lib/html2rss/auto_source/scraper/wordpress_api/page_scope.rb +2 -2
  35. data/lib/html2rss/auto_source/scraper/wordpress_api/posts_endpoint.rb +1 -3
  36. data/lib/html2rss/auto_source/scraper/wordpress_api.rb +7 -7
  37. data/lib/html2rss/auto_source/scraper/xhr_articles.rb +2 -4
  38. data/lib/html2rss/auto_source/scraper.rb +10 -13
  39. data/lib/html2rss/auto_source/segment.rb +19 -5
  40. data/lib/html2rss/auto_source/segmenter/cluster.rb +18 -22
  41. data/lib/html2rss/auto_source/segmenter/list.rb +35 -59
  42. data/lib/html2rss/auto_source/segmenter/primary_link.rb +29 -4
  43. data/lib/html2rss/auto_source/segmenter/semantic.rb +23 -19
  44. data/lib/html2rss/auto_source/segmenter.rb +4 -8
  45. data/lib/html2rss/auto_source.rb +81 -26
  46. data/lib/html2rss/batch.rb +24 -32
  47. data/lib/html2rss/capture/README.md +6 -3
  48. data/lib/html2rss/capture.rb +51 -59
  49. data/lib/html2rss/channel.rb +30 -12
  50. data/lib/html2rss/cli/render.rb +97 -96
  51. data/lib/html2rss/cli/validate.rb +2 -4
  52. data/lib/html2rss/cli.rb +55 -38
  53. data/lib/html2rss/config/auto_source_contract.rb +52 -45
  54. data/lib/html2rss/config/issue_mapper.rb +4 -1
  55. data/lib/html2rss/config/request_controls.rb +16 -5
  56. data/lib/html2rss/config/schema.rb +23 -15
  57. data/lib/html2rss/config/selectors_validator.rb +3 -2
  58. data/lib/html2rss/config/validation_issue.rb +4 -1
  59. data/lib/html2rss/config/validator.rb +14 -3
  60. data/lib/html2rss/config.rb +54 -28
  61. data/lib/html2rss/contract_error.rb +9 -0
  62. data/lib/html2rss/defaults.rb +25 -38
  63. data/lib/html2rss/doctor/botasaurus.rb +18 -22
  64. data/lib/html2rss/error.rb +17 -3
  65. data/lib/html2rss/error_kind.rb +39 -0
  66. data/lib/html2rss/feed_builder/item_presentation.rb +22 -9
  67. data/lib/html2rss/feed_builder/json_feed/item.rb +2 -1
  68. data/lib/html2rss/feed_builder/json_feed.rb +2 -2
  69. data/lib/html2rss/feed_builder/rss/stylesheet.rb +3 -3
  70. data/lib/html2rss/feed_builder/rss.rb +50 -7
  71. data/lib/html2rss/feed_builder.rb +2 -1
  72. data/lib/html2rss/feed_pipeline/README.md +1 -1
  73. data/lib/html2rss/feed_pipeline/auto_fallback.rb +16 -34
  74. data/lib/html2rss/feed_pipeline/strategy_plan.rb +3 -3
  75. data/lib/html2rss/feed_pipeline.rb +40 -27
  76. data/lib/html2rss/feed_resolution/README.md +1 -1
  77. data/lib/html2rss/feed_resolution/candidate_generator.rb +6 -8
  78. data/lib/html2rss/feed_resolution/options.rb +3 -7
  79. data/lib/html2rss/feed_resolution/policy.rb +29 -33
  80. data/lib/html2rss/feed_resolution/probe.rb +6 -4
  81. data/lib/html2rss/feed_resolution/scorer.rb +29 -29
  82. data/lib/html2rss/feed_resolution.rb +16 -21
  83. data/lib/html2rss/feed_result.rb +11 -6
  84. data/lib/html2rss/hash_util.rb +91 -90
  85. data/lib/html2rss/html/article_extractor/category_extractor.rb +7 -10
  86. data/lib/html2rss/html/article_extractor/date_extractor.rb +1 -1
  87. data/lib/html2rss/html/article_extractor/enclosure_extractor.rb +12 -33
  88. data/lib/html2rss/html/article_extractor/heading_extractor.rb +2 -3
  89. data/lib/html2rss/html/article_extractor/id_generator.rb +7 -10
  90. data/lib/html2rss/html/article_extractor/image_extractor.rb +9 -1
  91. data/lib/html2rss/html/article_extractor.rb +19 -47
  92. data/lib/html2rss/html/article_rules/category.rb +3 -3
  93. data/lib/html2rss/html/article_rules/description.rb +6 -15
  94. data/lib/html2rss/html/article_rules/enclosure.rb +70 -42
  95. data/lib/html2rss/html/article_rules/image.rb +30 -16
  96. data/lib/html2rss/html/feed_link.rb +7 -9
  97. data/lib/html2rss/html/navigator/text_extractor.rb +26 -16
  98. data/lib/html2rss/html/navigator.rb +39 -9
  99. data/lib/html2rss/html/probe.rb +55 -51
  100. data/lib/html2rss/html/rendering/description_builder.rb +46 -32
  101. data/lib/html2rss/html/rendering.rb +2 -9
  102. data/lib/html2rss/html/sst_article_extractor.rb +55 -145
  103. data/lib/html2rss/html/title_pieces/kicker.rb +147 -0
  104. data/lib/html2rss/html/title_pieces/nodes.rb +107 -0
  105. data/lib/html2rss/html/title_pieces/nokogiri_nodes.rb +70 -0
  106. data/lib/html2rss/html/title_pieces/sst_nodes.rb +71 -0
  107. data/lib/html2rss/html/title_pieces.rb +169 -0
  108. data/lib/html2rss/html/visibility.rb +56 -0
  109. data/lib/html2rss/html.rb +2 -0
  110. data/lib/html2rss/link_destination/destination_facts.rb +17 -1
  111. data/lib/html2rss/link_destination/noise_policy.rb +11 -10
  112. data/lib/html2rss/link_destination/path_classifier.rb +111 -42
  113. data/lib/html2rss/link_destination/sponsorship/label_text.rb +82 -0
  114. data/lib/html2rss/link_destination/sponsorship.rb +184 -0
  115. data/lib/html2rss/link_destination/text_classifier.rb +104 -8
  116. data/lib/html2rss/mcp/README.md +13 -1
  117. data/lib/html2rss/mcp/config_argument.rb +14 -4
  118. data/lib/html2rss/mcp/contract.rb +20 -9
  119. data/lib/html2rss/mcp/outcome.rb +18 -17
  120. data/lib/html2rss/mcp/runtime.rb +53 -23
  121. data/lib/html2rss/mcp/server/tools.rb +9 -9
  122. data/lib/html2rss/mcp/server.rb +13 -4
  123. data/lib/html2rss/option.rb +48 -0
  124. data/lib/html2rss/page_recon/README.md +1 -1
  125. data/lib/html2rss/page_recon/diagnostics.rb +9 -9
  126. data/lib/html2rss/page_recon.rb +0 -4
  127. data/lib/html2rss/recon.rb +19 -23
  128. data/lib/html2rss/request_service/blocked_surface.rb +5 -8
  129. data/lib/html2rss/request_service/botasaurus_contract.rb +25 -3
  130. data/lib/html2rss/request_service/botasaurus_strategy.rb +44 -12
  131. data/lib/html2rss/request_service/budget.rb +3 -24
  132. data/lib/html2rss/request_service/compressed_body.rb +36 -48
  133. data/lib/html2rss/request_service/context.rb +16 -17
  134. data/lib/html2rss/request_service/cookie_jar.rb +89 -0
  135. data/lib/html2rss/request_service/cookie_store.rb +79 -0
  136. data/lib/html2rss/request_service/feed_advertisement.rb +72 -0
  137. data/lib/html2rss/request_service/httpx_strategy.rb +7 -3
  138. data/lib/html2rss/request_service/local_file_strategy.rb +2 -3
  139. data/lib/html2rss/request_service/network_guard.rb +3 -2
  140. data/lib/html2rss/request_service/policy.rb +55 -36
  141. data/lib/html2rss/request_service/replay_breaker.rb +56 -0
  142. data/lib/html2rss/request_service/response.rb +33 -19
  143. data/lib/html2rss/request_service/response_cache.rb +109 -0
  144. data/lib/html2rss/request_service/response_caching.rb +95 -0
  145. data/lib/html2rss/request_service/strategy.rb +5 -11
  146. data/lib/html2rss/request_service.rb +71 -12
  147. data/lib/html2rss/request_session/pager/base.rb +3 -10
  148. data/lib/html2rss/request_session/pager/custom_selector.rb +4 -4
  149. data/lib/html2rss/request_session/pager/offset.rb +1 -7
  150. data/lib/html2rss/request_session/pager/rel_next.rb +1 -1
  151. data/lib/html2rss/request_session/pager/url_template.rb +1 -7
  152. data/lib/html2rss/request_session/pager.rb +1 -1
  153. data/lib/html2rss/request_session.rb +6 -2
  154. data/lib/html2rss/scoring/cluster_scorer.rb +1 -1
  155. data/lib/html2rss/scoring/container_assessor.rb +12 -13
  156. data/lib/html2rss/scoring/engine.rb +10 -38
  157. data/lib/html2rss/scoring/link_resolver.rb +2 -7
  158. data/lib/html2rss/scoring/observation.rb +2 -2
  159. data/lib/html2rss/selector_candidates.rb +19 -8
  160. data/lib/html2rss/selectors/README.md +1 -1
  161. data/lib/html2rss/selectors/extractors/href.rb +2 -4
  162. data/lib/html2rss/selectors/json_xml.rb +33 -34
  163. data/lib/html2rss/selectors/option_spec.rb +4 -1
  164. data/lib/html2rss/selectors/post_processors/base.rb +0 -8
  165. data/lib/html2rss/selectors/post_processors/gsub.rb +9 -10
  166. data/lib/html2rss/selectors/post_processors/html_transformers/keep_playable_media.rb +50 -0
  167. data/lib/html2rss/selectors/post_processors/html_transformers/transform_urls_to_absolute_ones.rb +1 -3
  168. data/lib/html2rss/selectors/post_processors/parse_time.rb +5 -6
  169. data/lib/html2rss/selectors/post_processors/parse_uri.rb +1 -1
  170. data/lib/html2rss/selectors/post_processors/sanitize_html.rb +18 -5
  171. data/lib/html2rss/selectors/post_processors/substring.rb +1 -1
  172. data/lib/html2rss/selectors/schema_export.rb +60 -60
  173. data/lib/html2rss/selectors.rb +67 -30
  174. data/lib/html2rss/sst/attrs.rb +2 -2
  175. data/lib/html2rss/sst/document.rb +3 -3
  176. data/lib/html2rss/sst/index.rb +30 -11
  177. data/lib/html2rss/sst/node.rb +42 -29
  178. data/lib/html2rss/sst/normalizer.rb +47 -34
  179. data/lib/html2rss/sst/tags.rb +113 -2
  180. data/lib/html2rss/sst/text.rb +94 -58
  181. data/lib/html2rss/sst.rb +2 -0
  182. data/lib/html2rss/status.rb +27 -31
  183. data/lib/html2rss/surface_category.rb +1 -1
  184. data/lib/html2rss/syndication/README.md +12 -6
  185. data/lib/html2rss/syndication/alternate_links.rb +39 -0
  186. data/lib/html2rss/syndication/channel_facts.rb +87 -0
  187. data/lib/html2rss/syndication/discovery.rb +171 -187
  188. data/lib/html2rss/syndication/feed.rb +20 -0
  189. data/lib/html2rss/syndication/item_media.rb +103 -0
  190. data/lib/html2rss/syndication/parser.rb +117 -116
  191. data/lib/html2rss/test/enhance_audit.rb +15 -17
  192. data/lib/html2rss/test/item_sample.rb +43 -0
  193. data/lib/html2rss/test/policy.rb +9 -11
  194. data/lib/html2rss/test.rb +66 -57
  195. data/lib/html2rss/url.rb +163 -62
  196. data/lib/html2rss/version.rb +1 -1
  197. data/lib/html2rss/word_count.rb +53 -0
  198. data/lib/html2rss.rb +19 -9
  199. data/schema/html2rss-config.schema.json +56 -41
  200. metadata +33 -4
  201. data/lib/html2rss/html/rendering/media_renderer.rb +0 -39
  202. data/lib/html2rss/html/rendering/pdf_renderer.rb +0 -30
@@ -8,28 +8,67 @@ module Html2rss
8
8
  # Feed-level +:auto+ is not registered here — {FeedPipeline::StrategyPlan} resolves
9
9
  # it to a concrete strategy (or {FeedPipeline::AutoFallback} chain) before execute.
10
10
  class RequestService
11
+ extend ResponseCaching
12
+
11
13
  # Raised when an unknown request strategy is requested.
12
14
  class UnknownStrategy < Html2rss::Error; end
15
+
13
16
  # Raised when a URL cannot be parsed or validated.
14
- class InvalidUrl < Html2rss::Error; end
17
+ class InvalidUrl < Html2rss::Error
18
+ # @return [Symbol]
19
+ def kind = :invalid_url
20
+ end
21
+
15
22
  # Raised when a URL uses an unsupported scheme.
16
- class UnsupportedUrlScheme < Html2rss::Error; end
23
+ class UnsupportedUrlScheme < Html2rss::Error
24
+ # @return [Symbol]
25
+ def kind = :invalid_url
26
+ end
27
+
17
28
  # Raised when a response type cannot be parsed.
18
29
  class UnsupportedResponseContentType < Html2rss::Error; end
30
+
19
31
  # Raised when HTTP request slot limits are exceeded.
20
- class RequestBudgetExceeded < Html2rss::Error; end
32
+ class RequestBudgetExceeded < Html2rss::Error
33
+ # @return [Symbol]
34
+ def kind = :budget
35
+ end
36
+
21
37
  # Raised when policy denies private-network access.
22
- class PrivateNetworkDenied < Html2rss::Error; end
38
+ class PrivateNetworkDenied < Html2rss::Error
39
+ # @return [Symbol]
40
+ def kind = :blocked
41
+ end
42
+
23
43
  # Raised when cross-origin follow-up requests are denied.
24
- class CrossOriginFollowUpDenied < Html2rss::Error; end
44
+ class CrossOriginFollowUpDenied < Html2rss::Error
45
+ # @return [Symbol]
46
+ def kind = :blocked
47
+ end
48
+
25
49
  # Raised when a response exceeds configured size limits.
26
- class ResponseTooLarge < Html2rss::Error; end
50
+ class ResponseTooLarge < Html2rss::Error
51
+ # @return [Symbol]
52
+ def kind = :budget
53
+ end
54
+
27
55
  # Raised when HTTP redirect limits are exceeded.
28
- class RedirectLimitReached < Html2rss::Error; end
56
+ class RedirectLimitReached < Html2rss::Error
57
+ # @return [Symbol]
58
+ def kind = :budget
59
+ end
60
+
29
61
  # Raised when blocked content surfaces are detected.
30
- class BlockedSurfaceDetected < Html2rss::Error; end
62
+ class BlockedSurfaceDetected < Html2rss::Error
63
+ # @return [Symbol]
64
+ def kind = :blocked
65
+ end
66
+
31
67
  # Raised when the target connection fails before a response document exists.
32
- class ConnectionFailed < Html2rss::Error; end
68
+ class ConnectionFailed < Html2rss::Error
69
+ # @return [Symbol]
70
+ def kind = :connection
71
+ end
33
72
 
34
73
  # Raised when a request times out.
35
74
  class RequestTimedOut < Html2rss::Error
@@ -43,6 +82,9 @@ module Html2rss
43
82
 
44
83
  # @return [String, nil] scrape-api timeout stage, or nil for transport/budget timeouts
45
84
  attr_reader :timeout_phase
85
+
86
+ # @return [Symbol]
87
+ def kind = :timeout
46
88
  end
47
89
 
48
90
  # Raised when Botasaurus configuration is missing or invalid.
@@ -52,7 +94,11 @@ module Html2rss
52
94
  end
53
95
 
54
96
  # Raised when the Botasaurus service cannot be reached (network / DNS / SSL).
55
- class BotasaurusConnectionFailed < Html2rss::Error; end
97
+ class BotasaurusConnectionFailed < Html2rss::Error
98
+ # @return [Symbol]
99
+ def kind = :connection
100
+ end
101
+
56
102
  # Raised when Botasaurus responds but the scrape fails (upstream error, bad payload).
57
103
  class BotasaurusServiceError < Html2rss::Error; end
58
104
 
@@ -82,9 +128,22 @@ module Html2rss
82
128
  MAX_DEPRECATION_WARNINGS = 256
83
129
  # Mutex guarding process-local deprecation warning deduplication.
84
130
  DEPRECATION_MUTEX = Mutex.new
85
- private_constant :DEPRECATION_MUTEX
131
+ # Mutex guarding the process-wide cookie store reference.
132
+ COOKIE_STORE_MUTEX = Mutex.new
133
+ private_constant :DEPRECATION_MUTEX, :COOKIE_STORE_MUTEX
86
134
 
87
135
  class << self
136
+ # @param store [#fetch, #write, #evict, nil] replacement cookie store (nil restores the in-memory default)
137
+ # @return [void]
138
+ def cookie_store=(store)
139
+ COOKIE_STORE_MUTEX.synchronize { @cookie_store = store }
140
+ end
141
+
142
+ # @return [#fetch, #write, #evict] the configured cookie store (in-memory by default)
143
+ def cookie_store
144
+ COOKIE_STORE_MUTEX.synchronize { @cookie_store ||= CookieStore.new }
145
+ end
146
+
88
147
  # @return [Symbol] the default strategy name
89
148
  def default_strategy_name = DEFAULT_STRATEGY_NAME
90
149
 
@@ -112,7 +171,7 @@ module Html2rss
112
171
  "The strategy '#{strategy}' is not known. Available strategies: #{strategy_names.join(', ')}"
113
172
  end
114
173
 
115
- strategy_class.new(ctx).execute
174
+ fetch_initial_through_cache(ctx, strategy_sym) { strategy_class.new(ctx).execute }
116
175
  end
117
176
 
118
177
  ##
@@ -26,7 +26,7 @@ module Html2rss
26
26
  # @yield [RequestService::Response] each page response
27
27
  # @return [Enumerator] enumerator when no block is given
28
28
  def each
29
- return enum_for(:each) unless block_given?
29
+ return enum_for unless block_given?
30
30
 
31
31
  yield initial_response
32
32
 
@@ -48,14 +48,14 @@ module Html2rss
48
48
 
49
49
  # @return [Integer] configured maximum pages (always a positive Integer)
50
50
  def max_pages
51
- value = config.fetch(:max_pages, DEFAULT_MAX_PAGES)
51
+ value = config[:max_pages]
52
52
  value.is_a?(Integer) && value.positive? ? value : DEFAULT_MAX_PAGES
53
53
  end
54
54
 
55
55
  # @param next_url [Html2rss::Url, String, nil]
56
56
  # @return [Boolean]
57
57
  def follow_up_allowed?(next_url)
58
- !next_url.nil? && !next_url.to_s.empty? && !session.visited?(next_url)
58
+ !next_url.to_s.empty? && !session.visited?(next_url)
59
59
  end
60
60
 
61
61
  # @param next_url [Html2rss::Url, String]
@@ -70,13 +70,6 @@ module Html2rss
70
70
  )
71
71
  nil
72
72
  end
73
-
74
- # @param page_response [RequestService::Response]
75
- # @param page_number [Integer] 1-based target page number (e.g. 2 for second page)
76
- # @return [Html2rss::Url, String, nil]
77
- def next_page_url(page_response, page_number:)
78
- raise NotImplementedError, "#{self.class}#next_page_url must be implemented by subclass"
79
- end
80
73
  end
81
74
  end
82
75
  end
@@ -11,7 +11,7 @@ module Html2rss
11
11
  # @param page_response [RequestService::Response]
12
12
  # @param page_number [Integer]
13
13
  # @return [String, nil]
14
- def next_page_url(page_response, page_number: nil) # rubocop:disable Lint/UnusedMethodArgument
14
+ def next_page_url(page_response, page_number:) # rubocop:disable Lint/UnusedMethodArgument
15
15
  selector = config[:selector]
16
16
  return nil if selector.nil? || selector.empty?
17
17
 
@@ -19,16 +19,16 @@ module Html2rss
19
19
  return nil unless node
20
20
 
21
21
  href = node['href'] || node.text
22
- return nil if href.nil? || href.strip.empty?
22
+ return nil if href.strip.empty?
23
23
 
24
- Html2rss::Url.from_relative(href.strip, page_response.url)
24
+ Html2rss::Url.from_relative(href, page_response.url)
25
25
  end
26
26
 
27
27
  # @param parsed_body [Nokogiri::HTML::Document]
28
28
  # @param selector [String]
29
29
  # @return [Nokogiri::XML::Node, nil]
30
30
  def extract_node(parsed_body, selector)
31
- parsed_body.at_css(selector) || parsed_body.at_xpath(selector)
31
+ parsed_body.at_css(selector)
32
32
  rescue Nokogiri::CSS::SyntaxError, Nokogiri::XML::XPath::SyntaxError
33
33
  # Pure XPath like `descendant::a` fails LOOKS_LIKE_XPATH / at_css; fall back explicitly.
34
34
  parsed_body.at_xpath(selector)
@@ -25,14 +25,8 @@ module Html2rss
25
25
  # @return [Html2rss::Url]
26
26
  def next_page_url(page_response, page_number:)
27
27
  param = config.fetch(:param, DEFAULT_PARAM)
28
- target_offset_val = target_offset_for(page_number)
29
28
  url = Html2rss::Url.from_absolute(page_response.url)
30
-
31
- if url.to_s.include?('{offset}')
32
- Html2rss::Url.from_absolute(url.to_s.gsub('{offset}', target_offset_val.to_s))
33
- else
34
- url.with_query_values(url.query_values.merge(param.to_s => target_offset_val.to_s))
35
- end
29
+ url.with_query_values(url.query_values.merge(param.to_s => target_offset_for(page_number).to_s))
36
30
  end
37
31
 
38
32
  # @param page_number [Integer]
@@ -11,7 +11,7 @@ module Html2rss
11
11
  # @param page_response [RequestService::Response]
12
12
  # @param page_number [Integer]
13
13
  # @return [String, nil]
14
- def next_page_url(page_response, page_number: nil) # rubocop:disable Lint/UnusedMethodArgument
14
+ def next_page_url(page_response, page_number:) # rubocop:disable Lint/UnusedMethodArgument
15
15
  href = page_response.parsed_body.at_css('link[rel~="next"][href], a[rel~="next"][href]')&.[]('href')
16
16
  return nil if href.nil? || href.empty?
17
17
 
@@ -25,14 +25,8 @@ module Html2rss
25
25
  # @return [Html2rss::Url]
26
26
  def next_page_url(page_response, page_number:)
27
27
  param = config.fetch(:param, DEFAULT_PARAM)
28
- target_page_val = target_page_for(page_number)
29
28
  url = Html2rss::Url.from_absolute(page_response.url)
30
-
31
- if url.to_s.include?('{page}')
32
- Html2rss::Url.from_absolute(url.to_s.gsub('{page}', target_page_val.to_s))
33
- else
34
- url.with_query_values(url.query_values.merge(param.to_s => target_page_val.to_s))
35
- end
29
+ url.with_query_values(url.query_values.merge(param.to_s => target_page_for(page_number).to_s))
36
30
  end
37
31
 
38
32
  # @param page_number [Integer]
@@ -47,7 +47,7 @@ module Html2rss
47
47
  strategy_name = normalized_config.fetch(:strategy, :rel_next).to_sym
48
48
 
49
49
  klass = STRATEGIES.fetch(strategy_name) do
50
- raise ArgumentError, "Unknown pagination strategy: #{strategy_name}"
50
+ raise InvalidOption, "Unknown pagination strategy: #{strategy_name}"
51
51
  end
52
52
 
53
53
  klass.new(session:, initial_response:, config: normalized_config)
@@ -65,9 +65,13 @@ module Html2rss
65
65
  # @param url [String, Html2rss::Url] follow-up request url
66
66
  # @param relation [Symbol] why the follow-up is being made
67
67
  # @param origin_url [String, Html2rss::Url] effective origin for same-origin checks
68
+ # @param advertisement [RequestService::FeedAdvertisement, nil] the origin page's advertised feeds;
69
+ # with relation +:advertised_feed+, {RequestService::Policy} lets a listed feed leave the origin
68
70
  # @return [RequestService::Response] follow-up response
69
- def follow_up(url:, relation:, origin_url:)
70
- execute(context.follow_up(url:, relation:, origin_url:)).tap { |response| remember!(response.url) }
71
+ def follow_up(url:, relation:, origin_url:, advertisement: nil)
72
+ execute(context.follow_up(url:, relation:, origin_url:, advertisement:)).tap do |response|
73
+ remember!(response.url)
74
+ end
71
75
  end
72
76
 
73
77
  ##
@@ -72,7 +72,7 @@ module Html2rss
72
72
  end
73
73
 
74
74
  def text_words(node)
75
- @text_words[node] ||= node.word_count
75
+ @text_words[node] ||= node.run_count
76
76
  end
77
77
 
78
78
  def date?(node)
@@ -7,6 +7,8 @@ module Html2rss
7
7
  class ContainerAssessor
8
8
  # Microdata itemprop values treated as publish/update markers.
9
9
  PUBLISH_ITEMPROPS = %w[datePublished dateModified].freeze
10
+ # Letter/digit runs ({WordCount.runs}) beyond the title that make a container descriptive.
11
+ DESCRIPTIVE_RUNS = 8
10
12
 
11
13
  # @param text_classifier [LinkDestination::TextClassifier]
12
14
  # @param content_token_regexp [Regexp]
@@ -25,11 +27,12 @@ module Html2rss
25
27
  # @param destination_facts [LinkDestination::DestinationFacts, nil]
26
28
  # @return [Observation]
27
29
  def call(container, selected_anchor, destination_facts:) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
28
- title = entry_title(container, selected_anchor)
30
+ source = container.find(&:heading?) || selected_anchor
31
+ title = source ? source.visible_text.to_s.strip : ''
29
32
  tokens = "#{container.attrs.class_attr} #{container.attrs.id}"
30
33
 
31
34
  Observation.new(
32
- title_word_count: word_count(title),
35
+ title_runs: source ? source.run_count : 0,
33
36
  path_length: destination_facts&.url&.path.to_s.length,
34
37
  content_path: destination_facts&.content_path,
35
38
  publish_marker: publish_marker?(container),
@@ -59,20 +62,16 @@ module Html2rss
59
62
  end
60
63
 
61
64
  def descriptive_context?(container_text, title)
62
- snippet = container_text.to_s.sub(/\A#{Regexp.escape(title.to_s)}/i, '')
63
- snippet.length > 30 && word_count(snippet) >= 8
65
+ snippet = without_title_prefix(container_text.to_s, title)
66
+ snippet.length > 30 && WordCount.runs(snippet, ceiling: DESCRIPTIVE_RUNS) == DESCRIPTIVE_RUNS
64
67
  end
65
68
 
66
- def entry_title(container, selected_anchor)
67
- heading = container.find(&:heading?)
68
- source = heading || selected_anchor
69
- source ? source.visible_text.to_s.strip : ''
70
- end
69
+ # Case-insensitive prefix strip compared by character count, so multi-character
70
+ # case folds (e.g. "ß" vs "ss") do not match; the title is then left in the snippet.
71
+ def without_title_prefix(text, title)
72
+ return text unless text[0, title.length].casecmp?(title)
71
73
 
72
- def word_count(text)
73
- count = 0
74
- text.to_s.scan(/\p{Alnum}+/) { count += 1 }
75
- count
74
+ text[title.length..]
76
75
  end
77
76
  end
78
77
  end
@@ -11,7 +11,7 @@ module Html2rss
11
11
  SCORE_FLOOR = 0.0
12
12
 
13
13
  FEATURE_IDS = Set[
14
- :title_word_count_ge3, :title_word_count_ge7, :path_length_gt6, :content_path,
14
+ :title_runs_ge3, :title_runs_ge7, :path_length_gt6, :content_path,
15
15
  :publish_marker, :descriptive_context, :article_container, :content_tokens,
16
16
  :non_content_utility_path, :utility_prefix_title_short, :shallow, :weak_container,
17
17
  :recommended_title_non_content, :high_confidence_junk_path, :junk_tokens,
@@ -22,8 +22,8 @@ module Html2rss
22
22
 
23
23
  # Quality feature triples: [feature_id, predicate, weight].
24
24
  QUALITY_RULES = [
25
- [:title_word_count_ge3, ->(o) { o.title_word_count >= 3 }, 40],
26
- [:title_word_count_ge7, ->(o) { o.title_word_count >= 7 }, 15],
25
+ [:title_runs_ge3, ->(o) { o.title_runs >= 3 }, 40],
26
+ [:title_runs_ge7, ->(o) { o.title_runs >= 7 }, 15],
27
27
  [:path_length_gt6, ->(o) { o.path_length > 6 }, 20],
28
28
  [:content_path, lambda(&:content_path), 15],
29
29
  [:publish_marker, lambda(&:publish_marker), 15],
@@ -35,7 +35,7 @@ module Html2rss
35
35
  # Junk feature triples: [feature_id, predicate, weight].
36
36
  JUNK_RULES = [
37
37
  [:non_content_utility_path, ->(o) { o.utility_path && !o.content_path && !o.strong_post_suffix }, 25],
38
- [:utility_prefix_title_short, ->(o) { o.utility_prefix_title && o.title_word_count <= 6 }, 15],
38
+ [:utility_prefix_title_short, ->(o) { o.utility_prefix_title && o.title_runs <= 6 }, 15],
39
39
  [:shallow, lambda(&:shallow), 10],
40
40
  [:weak_container, ->(o) { !o.publish_marker && !o.descriptive_context }, 10],
41
41
  [:recommended_title_non_content, ->(o) { o.recommended_title && !o.content_path }, 10],
@@ -46,9 +46,9 @@ module Html2rss
46
46
  ##
47
47
  # @param id [Symbol]
48
48
  # @return [Symbol]
49
- # @raise [ArgumentError] when id is not in the closed set
49
+ # @raise [InvalidOption] when id is not in the closed set
50
50
  def self.assert_feature_id!(id)
51
- raise ArgumentError, "unknown feature: #{id.inspect}" unless FEATURE_IDS.include?(id)
51
+ raise InvalidOption, "unknown feature: #{id.inspect}" unless FEATURE_IDS.include?(id)
52
52
 
53
53
  id
54
54
  end
@@ -91,21 +91,17 @@ module Html2rss
91
91
 
92
92
  private
93
93
 
94
- def rank_one(segment, hard_junk_mode: :strict) # rubocop:disable Metrics/MethodLength
94
+ def rank_one(segment, hard_junk_mode: :strict) # rubocop:disable Metrics/AbcSize
95
95
  facts = segment.primary_link ? @link_resolver.destination_facts(segment.primary_link) : nil
96
96
  obs = @assessor.call(segment.root_node, segment.primary_link, destination_facts: facts)
97
97
  return if obs.hard_junk?(mode: hard_junk_mode)
98
98
 
99
99
  quality, quality_parts = apply_rules(QUALITY_RULES, obs)
100
100
  junk, junk_parts = apply_rules(JUNK_RULES, obs)
101
- ranked(
101
+ RankedSegment.new(
102
102
  segment:,
103
- score: build_score(
104
- composite: quality - junk,
105
- quality:,
106
- junk:,
107
- breakdown: quality_parts.merge(junk_parts)
108
- )
103
+ score: Score.new(composite: (quality - junk).to_f, quality: quality.to_f, junk: junk.to_f,
104
+ breakdown: quality_parts.merge(junk_parts).freeze)
109
105
  )
110
106
  end
111
107
 
@@ -116,30 +112,6 @@ module Html2rss
116
112
  end
117
113
  [parts.values.sum, parts]
118
114
  end
119
-
120
- def build_score(composite:, quality: nil, junk: nil, breakdown: nil)
121
- raise ArgumentError, 'composite must be Numeric' unless composite.is_a?(Numeric)
122
-
123
- Score.new(
124
- composite: composite.to_f,
125
- quality: (quality.nil? ? composite : quality).to_f,
126
- junk: (junk || 0).to_f,
127
- breakdown: normalize_breakdown(breakdown)
128
- )
129
- end
130
-
131
- def normalize_breakdown(breakdown)
132
- return Score::EMPTY_BREAKDOWN if breakdown.nil?
133
-
134
- breakdown.transform_keys { |id| self.class.assert_feature_id!(id) }.freeze
135
- end
136
-
137
- def ranked(segment:, score:)
138
- raise ArgumentError, 'segment must be AutoSource::Segment' unless segment.is_a?(Html2rss::AutoSource::Segment)
139
- raise ArgumentError, 'score must be Scoring::Score' unless score.is_a?(Score)
140
-
141
- RankedSegment.new(segment:, score:)
142
- end
143
115
  end
144
116
  end
145
117
  end
@@ -24,7 +24,6 @@ module Html2rss
24
24
  def initialize(base_url)
25
25
  @base_url = base_url
26
26
  @by_href = {}
27
- @by_node = {}.compare_by_identity
28
27
  @text_classifier = LinkDestination::TextClassifier.new
29
28
  end
30
29
 
@@ -35,15 +34,11 @@ module Html2rss
35
34
  # @param node_or_href [SST::Node, String, #to_s]
36
35
  # @return [LinkDestination::DestinationFacts, nil]
37
36
  def destination_facts(node_or_href)
38
- return @by_node[node_or_href] if node_or_href.is_a?(SST::Node) && @by_node.key?(node_or_href)
39
-
40
37
  href = extract_href(node_or_href)
41
38
  return unless href
42
39
 
43
- facts = (@by_href[href] ||= build_facts(href))
44
- @by_node[node_or_href] = facts if node_or_href.is_a?(SST::Node)
45
- facts
46
- rescue ArgumentError
40
+ @by_href[href] ||= build_facts(href)
41
+ rescue Html2rss::Url::InvalidUrlError
47
42
  nil
48
43
  end
49
44
 
@@ -3,7 +3,7 @@
3
3
  module Html2rss
4
4
  module Scoring
5
5
  Observation = Data.define(
6
- :title_word_count,
6
+ :title_runs,
7
7
  :path_length,
8
8
  :content_path,
9
9
  :publish_marker,
@@ -40,7 +40,7 @@ module Html2rss
40
40
  when :lenient
41
41
  (high_confidence_junk_path && weak_signals?) || chrome_title_junk?
42
42
  else
43
- raise ArgumentError, "unknown hard_junk mode: #{mode.inspect}"
43
+ raise InvalidOption, "unknown hard_junk mode: #{mode.inspect}"
44
44
  end
45
45
  end
46
46
 
@@ -8,7 +8,12 @@ module Html2rss
8
8
  # Does not emit the default +a[href]+ fallback — that remains Capture's YAML path only.
9
9
  class SelectorCandidates # rubocop:disable Metrics/ClassLength -- ranking + field CSS stay co-located
10
10
  # Empty candidate buckets (no discovered evidence).
11
- EMPTY = { items: [].freeze, title: [].freeze, link: [].freeze, published: [].freeze }.freeze
11
+ EMPTY = {
12
+ items: [].freeze, title: [].freeze, link: [].freeze, published: [].freeze, candidates_by_item: [].freeze
13
+ }.freeze
14
+
15
+ # How many top-ranked item candidates get their own field buckets in +:candidates_by_item+.
16
+ BY_ITEM_LIMIT = 5
12
17
 
13
18
  # Ranking preference for items selector derivation kind (lower is better).
14
19
  KIND_RANK = { shared_class: 0, unique_tag: 1, path: 2 }.freeze
@@ -22,8 +27,8 @@ module Html2rss
22
27
  DEFAULT_MIN_MATCHES = 2
23
28
  private_constant :DEFAULT_MIN_MATCHES
24
29
 
25
- # Class token that identifies a non-heading title node. Same pattern title_node matches.
26
- TITLE_CLASS_PATTERN = /title|font-bold|font-semibold/
30
+ # Class token that identifies a non-heading title node. Owned by the extractor so both match the same nodes.
31
+ TITLE_CLASS_PATTERN = Html::SstArticleExtractor::TITLE_CLASS_PATTERN
27
32
  private_constant :TITLE_CLASS_PATTERN
28
33
 
29
34
  class << self
@@ -37,7 +42,8 @@ module Html2rss
37
42
  # @param items_evidence [Array<Hash>] each with +:selector+, +:enhance+, +:strategy+,
38
43
  # +:kind+, +:match_count+, and +:roots+ (SST nodes under the items selector)
39
44
  # @param min_matches [Integer] minimum repeated field evidence across roots
40
- # @return [Hash{Symbol=>Array<Hash>}] +:items+, +:title+, +:link+, +:published+
45
+ # @return [Hash{Symbol=>Array<Hash>}] +:items+, +:title+, +:link+, +:published+ (buckets for the
46
+ # top item) and +:candidates_by_item+ (per-item buckets for the top {BY_ITEM_LIMIT} items)
41
47
  def call(items_evidence:, min_matches: DEFAULT_MIN_MATCHES)
42
48
  new(items_evidence:, min_matches:).call
43
49
  end
@@ -57,17 +63,22 @@ module Html2rss
57
63
  items = rank_items(@items_evidence)
58
64
  return EMPTY if items.empty?
59
65
 
60
- roots = roots_for(items.first, @items_evidence)
66
+ by_item = items.first(BY_ITEM_LIMIT).map { |item| item_buckets(item) }
67
+ { items:, **by_item.first.slice(:title, :link, :published), candidates_by_item: by_item }
68
+ end
69
+
70
+ private
71
+
72
+ def item_buckets(item)
73
+ roots = roots_for(item, @items_evidence)
61
74
  {
62
- items:,
75
+ selector: item.fetch(:selector),
63
76
  title: field_bucket(roots, :title),
64
77
  link: field_bucket(roots, :link),
65
78
  published: field_bucket(roots, :published)
66
79
  }
67
80
  end
68
81
 
69
- private
70
-
71
82
  def rank_items(evidence)
72
83
  evidence
73
84
  .sort_by { |entry| item_rank_key(entry) }
@@ -11,7 +11,7 @@ YAML / MCP wire vocabulary is unchanged (`selectors`, `extractor`, `post_process
11
11
  ## Live flow
12
12
 
13
13
  1. **Parse** — HTML stays on `Response#parsed_body`. JSON responses are converted once via `JsonXml` into an HTML5 fragment so CSS selectors still run.
14
- 2. **Items** — `items.selector` finds each card/node. Omitted, blank, and `Selectors::DEFAULT_ITEMS_SELECTOR` expand to `Html::Navigator::MAIN_ANCHOR_SELECTOR` (permalink anchors). Any other selector, including `a` and `.card`, is used as written. `Config.prepare_defaults` fills that default, with `enhance: true`, only when `selectors:` is present. `enhance: true` fills missing fields via `Html::ArticleExtractor` (list-card enrichment). Enhancement is skipped when title, url, description, published_at, enclosures, and categories are already populated.
14
+ 2. **Items** — `items.selector` finds each card/node. Omitted, blank, and `Selectors::DEFAULT_ITEMS_SELECTOR` expand to `Html::Navigator::MAIN_ANCHOR_SELECTOR` (permalink anchors). Any other selector, including `a` and `.card`, is used as written. `Config.prepare_defaults` fills that default, with `enhance: true`, only when `selectors:` is present. `enhance: true` fills missing fields via `Html::ArticleExtractor` (list-card enrichment); an item with no link of its own gets no `url`, never a `page#id` fragment, and is left out of the feed (`Article#linked?`, the rule auto-source applies too). An item needs a resolvable link: configure a `url` selector when the items are not anchors or do not contain one. Enhancement is skipped when title, url, description, published_at, enclosures, and categories are already populated.
15
15
  3. **Field dispatch** (private on `Selectors`) — for each selectable key (`title`, `url`, `enclosure`, `categories`, …):
16
16
  - Regular fields → `Extractors.call` → optional `PostProcessors.call` chain
17
17
  - Special keys → enclosure wrap, guid fan-out, multi-node categories
@@ -54,11 +54,9 @@ module Html2rss
54
54
  ##
55
55
  # Retrieves and returns the normalized absolute URL.
56
56
  #
57
- # @return [Html2rss::Url, nil] The absolute URL.
57
+ # @return [Html2rss::Url, nil] The absolute URL, or nil when nothing matched or the +href+ is blank.
58
58
  def call
59
- return nil unless @href
60
-
61
- Url.from_relative(@href, @base_url)
59
+ Url.from_relative(@href, @base_url) unless @href.strip.empty?
62
60
  end
63
61
  end
64
62
  end
@@ -14,48 +14,47 @@ module Html2rss
14
14
  array: ['<array>', '</array>']
15
15
  }.freeze
16
16
 
17
- module_function
18
-
19
- ##
20
- # Converts the object to XML format.
21
- #
22
- # @param object [Object] any Ruby object (Hash, Array, String, Symbol, etc.)
23
- # @return [String] representing the object in XML
24
- def call(object)
25
- object_to_xml(object).tap do |converted|
26
- Html2rss::Log.debug("#{self}: converted object to XML (#{converted.bytesize} bytes)")
17
+ class << self
18
+ ##
19
+ # Converts the object to XML format.
20
+ #
21
+ # @param object [Object] any Ruby object (Hash, Array, String, Symbol, etc.)
22
+ # @return [String] representing the object in XML
23
+ def call(object)
24
+ object_to_xml(object).tap do |converted|
25
+ Html2rss::Log.debug("#{self}: converted object to XML (#{converted.bytesize} bytes)")
26
+ end
27
27
  end
28
- end
29
28
 
30
- def object_to_xml(object)
31
- case object
32
- when Hash
33
- hash_to_xml(object)
34
- when Array
35
- array_to_xml(object)
36
- else
37
- CGI.escapeHTML(object.to_s)
29
+ private
30
+
31
+ def object_to_xml(object)
32
+ case object
33
+ when Hash
34
+ hash_to_xml(object)
35
+ when Array
36
+ array_to_xml(object)
37
+ else
38
+ CGI.escapeHTML(object.to_s)
39
+ end
38
40
  end
39
- end
40
- private_class_method :object_to_xml
41
41
 
42
- def hash_to_xml(object)
43
- prefix, suffix = OBJECT_TO_XML_TAGS[:hash]
44
- inner_xml = object.each_with_object(+'') do |(key, value), str|
45
- str << "<#{key}>#{object_to_xml(value)}</#{key}>"
46
- end
42
+ def hash_to_xml(object)
43
+ prefix, suffix = OBJECT_TO_XML_TAGS[:hash]
44
+ inner_xml = object.each_with_object(+'') do |(key, value), str|
45
+ str << "<#{key}>#{object_to_xml(value)}</#{key}>"
46
+ end
47
47
 
48
- "#{prefix}#{inner_xml}#{suffix}"
49
- end
50
- private_class_method :hash_to_xml
48
+ "#{prefix}#{inner_xml}#{suffix}"
49
+ end
51
50
 
52
- def array_to_xml(object)
53
- prefix, suffix = OBJECT_TO_XML_TAGS[:array]
54
- inner_xml = object.each_with_object(+'') { |value, str| str << object_to_xml(value) }
51
+ def array_to_xml(object)
52
+ prefix, suffix = OBJECT_TO_XML_TAGS[:array]
53
+ inner_xml = object.each_with_object(+'') { |value, str| str << object_to_xml(value) }
55
54
 
56
- "#{prefix}#{inner_xml}#{suffix}"
55
+ "#{prefix}#{inner_xml}#{suffix}"
56
+ end
57
57
  end
58
- private_class_method :array_to_xml
59
58
  end
60
59
  end
61
60
  end