html2rss 0.33.0 → 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 (177) 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 -21
  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 +126 -50
  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 -39
  16. data/lib/html2rss/auto_source/scraper/json_document.rb +1 -3
  17. data/lib/html2rss/auto_source/scraper/json_state/article_normalizer.rb +119 -42
  18. data/lib/html2rss/auto_source/scraper/json_state/candidate_detector.rb +5 -16
  19. data/lib/html2rss/auto_source/scraper/json_state/value_finder.rb +2 -10
  20. data/lib/html2rss/auto_source/scraper/json_state.rb +2 -3
  21. data/lib/html2rss/auto_source/scraper/meta_oembed.rb +6 -14
  22. data/lib/html2rss/auto_source/scraper/microdata.rb +6 -4
  23. data/lib/html2rss/auto_source/scraper/microformats2.rb +1 -3
  24. data/lib/html2rss/auto_source/scraper/native_feed.rb +27 -17
  25. data/lib/html2rss/auto_source/scraper/schema/item_list.rb +5 -3
  26. data/lib/html2rss/auto_source/scraper/schema/thing.rb +68 -27
  27. data/lib/html2rss/auto_source/scraper/schema.rb +5 -8
  28. data/lib/html2rss/auto_source/scraper/semantic_html/entry_deduplicator.rb +3 -9
  29. data/lib/html2rss/auto_source/scraper/semantic_html.rb +1 -5
  30. data/lib/html2rss/auto_source/scraper/sitemap.rb +4 -3
  31. data/lib/html2rss/auto_source/scraper/wordpress_api.rb +3 -3
  32. data/lib/html2rss/auto_source/scraper/xhr_articles.rb +1 -3
  33. data/lib/html2rss/auto_source/scraper.rb +10 -2
  34. data/lib/html2rss/auto_source/segment.rb +19 -5
  35. data/lib/html2rss/auto_source/segmenter/cluster.rb +13 -11
  36. data/lib/html2rss/auto_source/segmenter/list.rb +13 -5
  37. data/lib/html2rss/auto_source/segmenter/primary_link.rb +29 -4
  38. data/lib/html2rss/auto_source/segmenter/semantic.rb +17 -6
  39. data/lib/html2rss/auto_source/segmenter.rb +4 -8
  40. data/lib/html2rss/auto_source.rb +81 -26
  41. data/lib/html2rss/batch.rb +2 -0
  42. data/lib/html2rss/capture/README.md +5 -2
  43. data/lib/html2rss/capture.rb +44 -32
  44. data/lib/html2rss/channel.rb +30 -12
  45. data/lib/html2rss/cli.rb +22 -11
  46. data/lib/html2rss/config/auto_source_contract.rb +52 -45
  47. data/lib/html2rss/config/issue_mapper.rb +4 -1
  48. data/lib/html2rss/config/request_controls.rb +6 -3
  49. data/lib/html2rss/config/schema.rb +15 -5
  50. data/lib/html2rss/config/selectors_validator.rb +2 -2
  51. data/lib/html2rss/config/validation_issue.rb +4 -1
  52. data/lib/html2rss/config/validator.rb +11 -0
  53. data/lib/html2rss/config.rb +53 -27
  54. data/lib/html2rss/contract_error.rb +9 -0
  55. data/lib/html2rss/defaults.rb +21 -23
  56. data/lib/html2rss/doctor/botasaurus.rb +7 -3
  57. data/lib/html2rss/error.rb +10 -0
  58. data/lib/html2rss/error_kind.rb +39 -0
  59. data/lib/html2rss/feed_builder/item_presentation.rb +22 -9
  60. data/lib/html2rss/feed_builder/json_feed/item.rb +2 -1
  61. data/lib/html2rss/feed_builder/json_feed.rb +2 -2
  62. data/lib/html2rss/feed_builder/rss/stylesheet.rb +3 -3
  63. data/lib/html2rss/feed_builder/rss.rb +49 -6
  64. data/lib/html2rss/feed_builder.rb +2 -1
  65. data/lib/html2rss/feed_pipeline/README.md +1 -1
  66. data/lib/html2rss/feed_pipeline/auto_fallback.rb +13 -31
  67. data/lib/html2rss/feed_pipeline/strategy_plan.rb +3 -3
  68. data/lib/html2rss/feed_pipeline.rb +40 -27
  69. data/lib/html2rss/feed_resolution/README.md +1 -1
  70. data/lib/html2rss/feed_resolution/candidate_generator.rb +3 -4
  71. data/lib/html2rss/feed_resolution/policy.rb +1 -1
  72. data/lib/html2rss/feed_resolution/probe.rb +3 -1
  73. data/lib/html2rss/feed_resolution.rb +11 -12
  74. data/lib/html2rss/feed_result.rb +9 -5
  75. data/lib/html2rss/hash_util.rb +3 -3
  76. data/lib/html2rss/html/article_extractor/category_extractor.rb +1 -1
  77. data/lib/html2rss/html/article_extractor/enclosure_extractor.rb +12 -31
  78. data/lib/html2rss/html/article_extractor/heading_extractor.rb +2 -3
  79. data/lib/html2rss/html/article_extractor/id_generator.rb +6 -9
  80. data/lib/html2rss/html/article_extractor/image_extractor.rb +9 -1
  81. data/lib/html2rss/html/article_extractor.rb +19 -47
  82. data/lib/html2rss/html/article_rules/category.rb +3 -3
  83. data/lib/html2rss/html/article_rules/description.rb +6 -15
  84. data/lib/html2rss/html/article_rules/enclosure.rb +70 -42
  85. data/lib/html2rss/html/article_rules/image.rb +30 -16
  86. data/lib/html2rss/html/feed_link.rb +7 -9
  87. data/lib/html2rss/html/navigator/text_extractor.rb +26 -16
  88. data/lib/html2rss/html/navigator.rb +39 -9
  89. data/lib/html2rss/html/probe.rb +4 -0
  90. data/lib/html2rss/html/rendering/description_builder.rb +46 -32
  91. data/lib/html2rss/html/rendering.rb +2 -9
  92. data/lib/html2rss/html/sst_article_extractor.rb +55 -145
  93. data/lib/html2rss/html/title_pieces/kicker.rb +147 -0
  94. data/lib/html2rss/html/title_pieces/nodes.rb +107 -0
  95. data/lib/html2rss/html/title_pieces/nokogiri_nodes.rb +70 -0
  96. data/lib/html2rss/html/title_pieces/sst_nodes.rb +71 -0
  97. data/lib/html2rss/html/title_pieces.rb +169 -0
  98. data/lib/html2rss/html/visibility.rb +56 -0
  99. data/lib/html2rss/html.rb +2 -0
  100. data/lib/html2rss/link_destination/destination_facts.rb +17 -1
  101. data/lib/html2rss/link_destination/noise_policy.rb +11 -10
  102. data/lib/html2rss/link_destination/path_classifier.rb +100 -9
  103. data/lib/html2rss/link_destination/sponsorship/label_text.rb +82 -0
  104. data/lib/html2rss/link_destination/sponsorship.rb +184 -0
  105. data/lib/html2rss/link_destination/text_classifier.rb +104 -8
  106. data/lib/html2rss/mcp/README.md +13 -1
  107. data/lib/html2rss/mcp/config_argument.rb +5 -2
  108. data/lib/html2rss/mcp/contract.rb +13 -4
  109. data/lib/html2rss/mcp/outcome.rb +14 -12
  110. data/lib/html2rss/mcp/runtime.rb +39 -2
  111. data/lib/html2rss/mcp/server.rb +13 -4
  112. data/lib/html2rss/page_recon/README.md +1 -1
  113. data/lib/html2rss/page_recon/diagnostics.rb +2 -0
  114. data/lib/html2rss/recon.rb +10 -12
  115. data/lib/html2rss/request_service/blocked_surface.rb +2 -1
  116. data/lib/html2rss/request_service/botasaurus_contract.rb +25 -3
  117. data/lib/html2rss/request_service/botasaurus_strategy.rb +42 -10
  118. data/lib/html2rss/request_service/context.rb +15 -14
  119. data/lib/html2rss/request_service/cookie_jar.rb +89 -0
  120. data/lib/html2rss/request_service/cookie_store.rb +79 -0
  121. data/lib/html2rss/request_service/feed_advertisement.rb +72 -0
  122. data/lib/html2rss/request_service/httpx_strategy.rb +7 -3
  123. data/lib/html2rss/request_service/local_file_strategy.rb +2 -2
  124. data/lib/html2rss/request_service/policy.rb +48 -11
  125. data/lib/html2rss/request_service/replay_breaker.rb +56 -0
  126. data/lib/html2rss/request_service/response.rb +30 -13
  127. data/lib/html2rss/request_service/response_cache.rb +109 -0
  128. data/lib/html2rss/request_service/response_caching.rb +95 -0
  129. data/lib/html2rss/request_service/strategy.rb +5 -11
  130. data/lib/html2rss/request_service.rb +71 -12
  131. data/lib/html2rss/request_session/pager/base.rb +0 -7
  132. data/lib/html2rss/request_session/pager.rb +1 -1
  133. data/lib/html2rss/request_session.rb +6 -2
  134. data/lib/html2rss/scoring/cluster_scorer.rb +1 -1
  135. data/lib/html2rss/scoring/container_assessor.rb +12 -13
  136. data/lib/html2rss/scoring/engine.rb +6 -6
  137. data/lib/html2rss/scoring/observation.rb +2 -2
  138. data/lib/html2rss/selector_candidates.rb +19 -8
  139. data/lib/html2rss/selectors/README.md +1 -1
  140. data/lib/html2rss/selectors/option_spec.rb +4 -1
  141. data/lib/html2rss/selectors/post_processors/base.rb +0 -8
  142. data/lib/html2rss/selectors/post_processors/gsub.rb +7 -7
  143. data/lib/html2rss/selectors/post_processors/html_transformers/keep_playable_media.rb +50 -0
  144. data/lib/html2rss/selectors/post_processors/html_transformers/transform_urls_to_absolute_ones.rb +1 -3
  145. data/lib/html2rss/selectors/post_processors/parse_time.rb +3 -3
  146. data/lib/html2rss/selectors/post_processors/parse_uri.rb +1 -1
  147. data/lib/html2rss/selectors/post_processors/sanitize_html.rb +18 -5
  148. data/lib/html2rss/selectors/post_processors/substring.rb +1 -1
  149. data/lib/html2rss/selectors.rb +53 -12
  150. data/lib/html2rss/sst/attrs.rb +2 -2
  151. data/lib/html2rss/sst/document.rb +3 -3
  152. data/lib/html2rss/sst/index.rb +30 -11
  153. data/lib/html2rss/sst/node.rb +33 -26
  154. data/lib/html2rss/sst/normalizer.rb +31 -19
  155. data/lib/html2rss/sst/tags.rb +113 -2
  156. data/lib/html2rss/sst/text.rb +69 -29
  157. data/lib/html2rss/sst.rb +2 -0
  158. data/lib/html2rss/status.rb +6 -3
  159. data/lib/html2rss/surface_category.rb +1 -1
  160. data/lib/html2rss/syndication/README.md +12 -6
  161. data/lib/html2rss/syndication/alternate_links.rb +1 -1
  162. data/lib/html2rss/syndication/channel_facts.rb +87 -0
  163. data/lib/html2rss/syndication/discovery.rb +82 -28
  164. data/lib/html2rss/syndication/feed.rb +20 -0
  165. data/lib/html2rss/syndication/item_media.rb +103 -0
  166. data/lib/html2rss/syndication/parser.rb +75 -51
  167. data/lib/html2rss/test/enhance_audit.rb +2 -2
  168. data/lib/html2rss/test/item_sample.rb +43 -0
  169. data/lib/html2rss/test.rb +42 -14
  170. data/lib/html2rss/url.rb +115 -14
  171. data/lib/html2rss/version.rb +1 -1
  172. data/lib/html2rss/word_count.rb +53 -0
  173. data/lib/html2rss.rb +17 -8
  174. data/schema/html2rss-config.schema.json +56 -41
  175. metadata +31 -4
  176. data/lib/html2rss/html/rendering/media_renderer.rb +0 -39
  177. data/lib/html2rss/html/rendering/pdf_renderer.rb +0 -30
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+
5
+ module Html2rss
6
+ class RequestService
7
+ ##
8
+ # Opt-in reuse of initial page fetches, e.g. while a person iterates on selectors for one page.
9
+ # Extended onto {RequestService}; the cache is consulted only for +relation == :initial+ requests,
10
+ # follow-ups always hit the network.
11
+ #
12
+ # The key is a SHA256 digest of the URL, strategy, headers, request options and the policy fields
13
+ # that shape a response, so credentials never reach the cache and a response fetched under a
14
+ # permissive {Policy} is never served to a stricter one. Cookie-store state is not part of the key.
15
+ module ResponseCaching
16
+ # Mutex guarding the process-wide response cache reference.
17
+ RESPONSE_CACHE_MUTEX = Mutex.new
18
+ # Fiber-storage key for the active {#observe_cache_hits} collector.
19
+ CACHE_HITS_KEY = :html2rss_cache_hits
20
+ # Fiber-storage key for the cache installed by {#with_response_cache}.
21
+ SCOPED_CACHE_KEY = :html2rss_scoped_response_cache
22
+ private_constant :RESPONSE_CACHE_MUTEX, :CACHE_HITS_KEY, :SCOPED_CACHE_KEY
23
+
24
+ # @param cache [#fetch, nil] receives +fetch(key) { response }+ and returns the cached or freshly
25
+ # built {Response} (tagged with {Response#cache_age} on a hit), e.g. {ResponseCache};
26
+ # +nil+ (the default) disables caching. A raising block must not be cached.
27
+ # @return [void]
28
+ def response_cache=(cache)
29
+ RESPONSE_CACHE_MUTEX.synchronize { @response_cache = cache }
30
+ end
31
+
32
+ # @return [#fetch, nil] the scoped cache from {#with_response_cache} if one is active, otherwise
33
+ # the globally configured initial-response cache, if any
34
+ def response_cache
35
+ scoped = Fiber[SCOPED_CACHE_KEY]
36
+ return scoped.first if scoped
37
+
38
+ RESPONSE_CACHE_MUTEX.synchronize { @response_cache }
39
+ end
40
+
41
+ # Runs the block with +cache+ as the initial-response cache for the current fiber (and fibers or
42
+ # threads it spawns), taking precedence over {#response_cache=}. The previous scope is restored
43
+ # when the block exits, even by raising.
44
+ #
45
+ # @param cache [#fetch, nil] cache for the block; +nil+ disables caching inside it
46
+ # @yieldreturn [Object] the block's result
47
+ # @return [Object] the block's result
48
+ def with_response_cache(cache)
49
+ previous = Fiber[SCOPED_CACHE_KEY]
50
+ Fiber[SCOPED_CACHE_KEY] = [cache]
51
+ yield
52
+ ensure
53
+ Fiber[SCOPED_CACHE_KEY] = previous
54
+ end
55
+
56
+ # Runs the block and reports the cache ages of every initial fetch it served from {#response_cache},
57
+ # including fetches on threads the block spawns (fiber storage is inherited by reference).
58
+ #
59
+ # @yieldreturn [Object] the block's result
60
+ # @return [Array(Object, Array<Integer>)] the block's result and the served cache ages in seconds
61
+ def observe_cache_hits
62
+ hits = Fiber[CACHE_HITS_KEY] = Queue.new
63
+ result = yield
64
+ [result, Array.new(hits.size) { hits.pop }]
65
+ ensure
66
+ Fiber[CACHE_HITS_KEY] = nil
67
+ end
68
+
69
+ private
70
+
71
+ ##
72
+ # Runs +fetch+ directly, or through {#response_cache} for initial requests.
73
+ # Callers always receive their own {Response} so parsed documents are never shared.
74
+ # @param ctx [Context] the request context
75
+ # @param strategy [Symbol] the resolved strategy name
76
+ # @yieldreturn [Response] the freshly fetched response
77
+ # @return [Response]
78
+ def fetch_initial_through_cache(ctx, strategy, &)
79
+ cache = response_cache
80
+ return yield unless cache && ctx.relation == :initial
81
+
82
+ cache.fetch(response_cache_key(ctx, strategy), &).replica.tap { |response| record_cache_hit(response) }
83
+ end
84
+
85
+ def record_cache_hit(response)
86
+ Fiber[CACHE_HITS_KEY]&.push(response.cache_age) if response.cache_age
87
+ end
88
+
89
+ def response_cache_key(ctx, strategy)
90
+ parts = [ctx.url.to_s, strategy, ctx.headers.sort, ctx.request, ctx.policy.cache_key]
91
+ Digest::SHA256.hexdigest(parts.inspect)
92
+ end
93
+ end
94
+ end
95
+ end
@@ -18,12 +18,13 @@ module Html2rss
18
18
  # Executes the request through the shared preflight/postflight path.
19
19
  #
20
20
  # @return [Response] the response from the strategy
21
- # @raise [NotImplementedError] if the subclass does not implement `#fetch`
22
21
  def execute
23
22
  check_timeout!
24
23
  response = perform_execute
25
24
  postflight!(response)
26
25
  response
26
+ rescue Html2rss::ContractError
27
+ raise
27
28
  rescue StandardError => error
28
29
  handle_error(error)
29
30
  end
@@ -42,15 +43,6 @@ module Html2rss
42
43
  fetch
43
44
  end
44
45
 
45
- ##
46
- # Adapter hook: perform the transport-specific fetch after preflight.
47
- #
48
- # @return [Response] normalized response
49
- # @raise [NotImplementedError] when a subclass omits the hook
50
- def fetch
51
- raise NotImplementedError, 'Subclass must implement #fetch'
52
- end
53
-
54
46
  ##
55
47
  # Consumes a request slot and validates the destination before transport work.
56
48
  #
@@ -60,7 +52,9 @@ module Html2rss
60
52
  return if skip_preflight?
61
53
 
62
54
  ctx.budget.consume! if consume_budget
63
- ctx.policy.validate_request!(url: ctx.url, origin_url: ctx.origin_url, relation: ctx.relation)
55
+ ctx.policy.validate_request!(
56
+ url: ctx.url, origin_url: ctx.origin_url, relation: ctx.relation, advertisement: ctx.advertisement
57
+ )
64
58
  end
65
59
 
66
60
  ##
@@ -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
  ##
@@ -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
@@ -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
@@ -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
@@ -11,6 +11,9 @@ module Html2rss
11
11
  # introspection SoT (+for+ / +expectation_for+); {SchemaExport} maps Ruby
12
12
  # types to JSON Schema only.
13
13
  class OptionSpec
14
+ # Raised when an extractor/post-processor +OPTIONS+ declares a type with no JSON Schema mapping.
15
+ class InvalidError < Html2rss::ContractError; end
16
+
14
17
  # Maps Ruby option types to JSON Schema +type+ strings.
15
18
  RUBY_TO_JSON_TYPE = {
16
19
  String => 'string',
@@ -83,7 +86,7 @@ module Html2rss
83
86
  when Array then ruby_type.map { |t| json_type_for(t) }
84
87
  else
85
88
  RUBY_TO_JSON_TYPE.fetch(ruby_type) do
86
- raise ArgumentError, "unsupported OPTIONS type mapping for #{ruby_type}"
89
+ raise InvalidError, "unsupported OPTIONS type mapping for #{ruby_type}"
87
90
  end
88
91
  end
89
92
  end
@@ -114,14 +114,6 @@ module Html2rss
114
114
  end
115
115
 
116
116
  attr_reader :value, :context
117
-
118
- # Abstract method to be implemented by subclasses
119
- #
120
- # @return [Object] transformed value
121
- # @raise [NotImplementedError] if not implemented in subclass
122
- def call
123
- raise NotImplementedError, 'You must implement the `call` method in the post processor'
124
- end
125
117
  end
126
118
  end
127
119
  end
@@ -70,7 +70,7 @@ module Html2rss
70
70
  #
71
71
  # @param string [String]
72
72
  # @return [Regexp]
73
- # @raise [ArgumentError] when +string+ is not a String, exceeds the character cap,
73
+ # @raise [InvalidOption] when +string+ is not a String, exceeds the character cap,
74
74
  # contains nested quantifiers, or does not parse
75
75
  def self.compiled_pattern(string)
76
76
  compiled_patterns[string] ||= compile_regexp_string(string)
@@ -88,25 +88,25 @@ module Html2rss
88
88
  # to maintain backwards compatibility before building the Regexp.
89
89
  # Length is the character count of the authored string (the JSON Schema
90
90
  # +maxLength+ unit), then the AST is walked for nested quantifiers,
91
- # then +to_re+. Parser errors are re-raised as ArgumentError so
91
+ # then +to_re+. Parser errors are re-raised as InvalidOption so
92
92
  # validation and execution share one path.
93
93
  #
94
94
  # @param string [String]
95
95
  # @return [Regexp]
96
- # @raise [ArgumentError] when +string+ is not a String, exceeds {MAX_PATTERN_LENGTH},
96
+ # @raise [InvalidOption] when +string+ is not a String, exceeds {MAX_PATTERN_LENGTH},
97
97
  # contains nested quantifiers, or does not parse
98
98
  def self.compile_regexp_string(string)
99
- raise ArgumentError, 'must be a string!' unless string.is_a?(String)
100
- raise ArgumentError, "pattern exceeds #{MAX_PATTERN_LENGTH} characters" if string.length > MAX_PATTERN_LENGTH
99
+ raise InvalidOption, 'must be a string!' unless string.is_a?(String)
100
+ raise InvalidOption, "pattern exceeds #{MAX_PATTERN_LENGTH} characters" if string.length > MAX_PATTERN_LENGTH
101
101
 
102
102
  source = regexp_source(string)
103
103
 
104
104
  expression = Regexp::Parser.parse(source)
105
- raise ArgumentError, 'pattern contains nested quantifiers' if nested_quantifiers?(expression)
105
+ raise InvalidOption, 'pattern contains nested quantifiers' if nested_quantifiers?(expression)
106
106
 
107
107
  expression.to_re
108
108
  rescue Regexp::Parser::Error => error
109
- raise ArgumentError, error.message, [], cause: nil
109
+ raise InvalidOption, error.message, [], cause: nil
110
110
  end
111
111
  private_class_method :compile_regexp_string
112
112
 
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ class Selectors
5
+ module PostProcessors
6
+ module HtmlTransformers
7
+ ##
8
+ # Transformer that keeps only media a reader can play: an +http(s)+ +src+ on +<audio>+, +<video>+
9
+ # and +<source>+, made absolute; any other +src+ (missing, blank, +javascript:+, +data:+, unparsable)
10
+ # is removed. A +<source>+ left without one is dropped, and so is an +<audio>+ or +<video>+ with no
11
+ # playable +src+ of its own or on a child +<source>+ (no empty player shell).
12
+ class KeepPlayableMedia
13
+ # Elements that play a file named by +src+ (theirs or a child +<source>+).
14
+ PLAYERS = %w[audio video].freeze
15
+
16
+ # @param base_url [String, Html2rss::Url] base URL used to resolve relative sources
17
+ def initialize(base_url)
18
+ @base_url = base_url
19
+ end
20
+
21
+ ##
22
+ # @param node_name [String] node name currently being transformed
23
+ # @param node [Nokogiri::XML::Node] node currently being transformed
24
+ # @param _env [Hash] transformer context
25
+ # @option _env [Object] :_reserved reserved for transformer pipeline context
26
+ # @return [void]
27
+ def call(node_name:, node:, **_env)
28
+ if node_name == 'source'
29
+ node.unlink unless absolutize_src(node)
30
+ elsif PLAYERS.include?(node_name)
31
+ # Children are transformed after their parent, so child sources are only checked here.
32
+ playable = absolutize_src(node) || node.css('source').any? { Url.resolve_web(_1['src'], @base_url) }
33
+ node.unlink unless playable
34
+ end
35
+ end
36
+
37
+ private
38
+
39
+ # Rewrites +src+ to its absolute playable URL (Nokogiri stores its +to_s+) or removes it;
40
+ # returns the URL kept, if any.
41
+ def absolutize_src(node)
42
+ url = Url.resolve_web(node['src'], @base_url)
43
+ url ? node['src'] = url : node.remove_attribute('src')
44
+ url
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -12,9 +12,7 @@ module Html2rss
12
12
  URL_ELEMENTS_WITH_URL_ATTRIBUTE = {
13
13
  'a' => :href, # Visible link
14
14
  'img' => :src, # Visible image
15
- 'iframe' => :src, # Embedded frame (visible content)
16
- 'audio' => :src, # Can show controls, so potentially visible
17
- 'video' => :src # Video player is visible
15
+ 'iframe' => :src # Embedded frame (visible content)
18
16
  }.freeze
19
17
 
20
18
  # @param base_url [String, Html2rss::Url] base URL used to resolve relative links
@@ -63,14 +63,14 @@ module Html2rss
63
63
  # @param _value [String] extracted time string (unused; type-checked via VALUE_TYPE)
64
64
  # @param context [Selectors::StepEnv] must carry a usable +time_zone+
65
65
  # @return [void]
66
- # @raise [ArgumentError] when time_zone is nil or empty
66
+ # @raise [MissingOption] when time_zone is nil or empty
67
67
  def self.validate_args!(_value, context)
68
68
  time_zone_value = context.time_zone
69
69
 
70
- raise ArgumentError, 'time_zone cannot be nil or empty', [], cause: nil if time_zone_value.nil?
70
+ raise MissingOption, 'time_zone cannot be nil or empty', [], cause: nil if time_zone_value.nil?
71
71
 
72
72
  assert_type(time_zone_value, String, :time_zone, context:)
73
- raise ArgumentError, 'time_zone cannot be nil or empty', [], cause: nil if time_zone_value.empty?
73
+ raise MissingOption, 'time_zone cannot be nil or empty', [], cause: nil if time_zone_value.empty?
74
74
  end
75
75
 
76
76
  ##