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
data/lib/html2rss/url.rb CHANGED
@@ -26,24 +26,35 @@ module Html2rss
26
26
  class Url
27
27
  include Comparable
28
28
 
29
+ # Closed set of query params that carry campaign or referrer data, never page identity.
30
+ TRACKING_PARAM = /\A(?:utm_.*|fbclid|gclid|msclkid|mc_cid|mc_eid|ref|ref_src)\z/i
31
+
32
+ NO_PARAMS = [].freeze
33
+ private_constant :NO_PARAMS
34
+
29
35
  # Raised when a string cannot be parsed or resolved to a valid URL.
30
- class InvalidUrlError < Html2rss::Error; end
36
+ class InvalidUrlError < Html2rss::Error
37
+ # @return [Symbol]
38
+ def kind = :invalid_url
39
+ end
31
40
 
32
41
  # Regular expression for basic URI format validation
33
42
  URI_REGEXP = Addressable::URI::URIREGEX
34
43
  # Schemes accepted by channel URL validation.
35
44
  SUPPORTED_SCHEMES = %w[http https].to_set.freeze
45
+ SCHEME_PREFIX = /\A([a-z][a-z0-9+.-]*):/i
46
+ private_constant :SCHEME_PREFIX
36
47
 
37
48
  ##
38
49
  # Creates a URL from a relative path and base URL.
39
50
  #
40
51
  # @param relative_url [String, Html2rss::Url] the relative URL to resolve
41
52
  # @param base_url [String, Html2rss::Url] the base URL to resolve against
42
- # @return [Url] the resolved absolute URL
53
+ # @return [Url] the resolved, normalized absolute URL
43
54
  # @raise [InvalidUrlError] if the URL cannot be parsed
44
55
  def self.from_relative(relative_url, base_url)
45
- url = Addressable::URI.parse(relative_url.to_s.strip)
46
- return new(url) if url.absolute?
56
+ url = parse(relative_url.to_s.strip, base_url.to_s[SCHEME_PREFIX, 1])
57
+ return new(url.normalize) if url.absolute?
47
58
 
48
59
  join_parsed_base(base_url, url)
49
60
  rescue Addressable::URI::InvalidURIError
@@ -59,6 +70,30 @@ module Html2rss
59
70
  end
60
71
  private_class_method :join_parsed_base
61
72
 
73
+ ##
74
+ # The one parse entry. Like browsers (WHATWG URL), a backslash before the query or fragment of an
75
+ # http(s) URL is a path separator: +files\a.pdf+ is +files/a.pdf+ and +https:\\example.com\x+ is
76
+ # +https://example.com/x+. A backslash ends the authority, so +https://example.com\@host.example/+
77
+ # keeps +example.com+ as host. Query and fragment, and non-http(s) URLs, are left untouched.
78
+ #
79
+ # @param text [String] raw URL or reference
80
+ # @param base_scheme [String, nil] scheme a scheme-less +text+ resolves against
81
+ # @return [Addressable::URI]
82
+ # @raise [Addressable::URI::InvalidURIError]
83
+ def self.parse(text, base_scheme = nil)
84
+ return Addressable::URI.parse(text) unless text.include?('\\') && web_scheme?(text, base_scheme)
85
+
86
+ head, separator, tail = text.partition(/[?#]/)
87
+ Addressable::URI.parse("#{head.tr('\\', '/')}#{separator}#{tail}")
88
+ end
89
+ private_class_method :parse
90
+
91
+ # @return [Boolean] whether +text+, or the +base_scheme+ a scheme-less +text+ resolves against, is http(s)
92
+ def self.web_scheme?(text, base_scheme)
93
+ SUPPORTED_SCHEMES.include?((text[SCHEME_PREFIX, 1] || base_scheme)&.downcase)
94
+ end
95
+ private_class_method :web_scheme?
96
+
62
97
  ##
63
98
  # Creates a URL by sanitizing a raw URL string.
64
99
  # Removes spaces and extracts the first valid URL from the string.
@@ -66,10 +101,10 @@ module Html2rss
66
101
  # @param raw_url [String] the raw URL string to sanitize
67
102
  # @return [Url, nil] the sanitized URL, or nil if no valid URL found
68
103
  def self.sanitize(raw_url)
69
- match = raw_url.to_s.match(%r{(?:(?:https?|ftp)://|mailto:)[^\s<>"]+})
104
+ match = raw_url.to_s.match(%r{(?:https?:[/\\]{2}|ftp://|mailto:)[^\s<>"]+})
70
105
  return unless match
71
106
 
72
- new(Addressable::URI.parse(match[0]).normalize)
107
+ new(parse(match[0]).normalize)
73
108
  end
74
109
 
75
110
  ##
@@ -81,7 +116,7 @@ module Html2rss
81
116
  def self.from_absolute(url_string)
82
117
  return url_string if url_string.is_a?(self)
83
118
 
84
- new(Addressable::URI.parse(url_string.to_s.lstrip).normalize).tap do |url|
119
+ new(parse(url_string.to_s.lstrip).normalize).tap do |url|
85
120
  raise InvalidUrlError, 'URL must be absolute' unless url.absolute?
86
121
  end
87
122
  rescue Addressable::URI::InvalidURIError
@@ -118,6 +153,23 @@ module Html2rss
118
153
  from_absolute(url_string).tap { validate_scheme(_1) }
119
154
  end
120
155
 
156
+ ##
157
+ # The one resolver for a declared web reference (a media file, an item link, a player +src+, an
158
+ # advertised feed): +ref+ resolved against +base_url+ when relative, kept only when http(s) ({#web?}).
159
+ # A +javascript:+, +data:+, +ftp:+ or +mailto:+ reference, or a relative one without a base, names none.
160
+ #
161
+ # @param ref [String, Html2rss::Url, nil] an absolute or relative URL, possibly padded with whitespace
162
+ # @param base_url [String, Html2rss::Url, nil] the document the reference appears in
163
+ # @return [Url, nil] the absolute http(s) URL, or nil when +ref+ is blank, unparseable or not http(s)
164
+ def self.resolve_web(ref, base_url = nil)
165
+ return unless ref.to_s.match?(/\S/)
166
+
167
+ url = from_relative(ref, base_url)
168
+ url if url.web?
169
+ rescue InvalidUrlError
170
+ nil
171
+ end
172
+
121
173
  ##
122
174
  # Extracts a base URL from HTML metadata tags.
123
175
  #
@@ -150,16 +202,18 @@ module Html2rss
150
202
  end
151
203
 
152
204
  def self.validate_scheme(url)
153
- scheme = url.scheme
154
- raise InvalidUrlError, "URL scheme '#{scheme}' is not supported" unless SUPPORTED_SCHEMES.include?(scheme)
205
+ raise InvalidUrlError, "URL scheme '#{url.scheme}' is not supported" unless url.web?
155
206
  end
156
207
 
157
208
  private_class_method :validate_channel_url, :validate_scheme
158
209
 
159
210
  ##
160
- # @param uri [Addressable::URI] the underlying Addressable::URI object (internal use only)
211
+ # The wrapped URI stays unfrozen and private: freezing makes Addressable recompute every
212
+ # normalized part that +normalize+ just produced. Mutating operations dup first.
213
+ #
214
+ # @param uri [Addressable::URI] the normalized Addressable::URI (internal use only)
161
215
  def initialize(uri)
162
- @uri = uri.freeze
216
+ @uri = uri
163
217
  @path_segments = path.split('/').reject(&:empty?).freeze
164
218
  freeze
165
219
  end
@@ -176,8 +230,8 @@ module Html2rss
176
230
  # @return [String, nil] userinfo user, without the password
177
231
  def user = @uri.user
178
232
 
179
- # Registrable domain (eTLD+1). Same-site hosts like www.wp.pl and wiadomosci.wp.pl
180
- # share `wp.pl`. IP literals stay the host so 1.2.3.4 is not treated as `3.4`.
233
+ # Registrable domain (eTLD+1). Same-site hosts like www.example.com and news.example.com
234
+ # share `example.com`. IP literals stay the host so 1.2.3.4 is not treated as `3.4`.
181
235
  #
182
236
  # @return [String, nil]
183
237
  def domain
@@ -201,6 +255,10 @@ module Html2rss
201
255
  # @return [Boolean] whether the URL includes scheme and host
202
256
  def absolute? = @uri.absolute?
203
257
 
258
+ # @return [Boolean] whether the URL is an absolute http(s) URL ({SUPPORTED_SCHEMES}), the only kind a feed
259
+ # ships as a link, enclosure or player source
260
+ def web? = SUPPORTED_SCHEMES.include?(scheme)
261
+
204
262
  ##
205
263
  # Returns the URL query string as a hash of string keys and values.
206
264
  #
@@ -232,6 +290,30 @@ module Html2rss
232
290
  fragment ? self.class.from_absolute(@uri.omit(:fragment)) : self
233
291
  end
234
292
 
293
+ ##
294
+ # Returns a copy of the URL with {TRACKING_PARAM} query params removed; the remaining
295
+ # params keep their order.
296
+ #
297
+ # @return [Url] this URL when it carries no tracking param; otherwise a new URL
298
+ def without_tracking_params
299
+ return self if query_params.none? { tracking_param?(_1) }
300
+
301
+ uri = @uri.dup
302
+ uri.query = query_params.reject { tracking_param?(_1) }.join('&')
303
+ self.class.from_absolute(uri)
304
+ end
305
+
306
+ ##
307
+ # Returns a copy of the URL with the provided scheme.
308
+ #
309
+ # @param scheme [String] for example +https+
310
+ # @return [Url] a new URL with the updated scheme
311
+ def with_scheme(scheme)
312
+ uri = @uri.dup
313
+ uri.scheme = scheme
314
+ self.class.from_absolute(uri)
315
+ end
316
+
235
317
  ##
236
318
  # Returns a copy of the URL with the provided query values.
237
319
  #
@@ -328,9 +410,20 @@ module Html2rss
328
410
  self.class.new(@uri.join(relative_uri).normalize)
329
411
  end
330
412
 
413
+ ##
414
+ # "Same article" key: host, port, slash-stripped path, and the sorted query params minus
415
+ # {TRACKING_PARAM}. Scheme, fragment, trailing slash, tracking params, and param order are
416
+ # ignored, so +http://example.com/a+ and +https://example.com/a/?utm_source=x#top+ share it;
417
+ # every other query param stays significant. Keys page placement, self-link rejection, and
418
+ # feed-item dedupe, unlike {#same_document?}, which stays scheme- and query-exact.
419
+ #
420
+ # @return [Array] equal for URLs naming the same article
421
+ def article_identity = [host, port, path.chomp('/'), article_params]
422
+
331
423
  protected
332
424
 
333
- # Scheme, authority, slash-stripped path, and query (fragment omitted).
425
+ # Same-document key: scheme, authority, slash-stripped path, and query (fragment omitted).
426
+ # Two URLs share it exactly when {#same_document?} holds.
334
427
  #
335
428
  # @return [String]
336
429
  def document_identity
@@ -339,6 +432,14 @@ module Html2rss
339
432
 
340
433
  private
341
434
 
435
+ # @return [Array<String>] raw +key=value+ query pairs, in source order
436
+ def query_params = query ? query.split('&') : NO_PARAMS
437
+
438
+ # @return [Array<String>] sorted non-tracking query pairs; the shared empty list without a query
439
+ def article_params = query ? query_params.reject { tracking_param?(_1) }.sort : NO_PARAMS
440
+
441
+ def tracking_param?(pair) = TRACKING_PARAM.match?(pair.partition('=').first)
442
+
342
443
  def ip_address_host?
343
444
  !IPAddr.new(host).nil?
344
445
  rescue IPAddr::Error
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Html2rss
4
4
  # Current application version.
5
- VERSION = '0.33.0'
5
+ VERSION = '0.34.0'
6
6
  public_constant :VERSION
7
7
  end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ ##
5
+ # Counts text in two facts. {.count} is the one definition of a word for the length rules on text (title
6
+ # floor, short anchor labels, description richness): a run of letters and digits, where Han, Hiragana
7
+ # and Katakana text, which carries no spaces, counts one word per {CJK_CHUNK} characters
8
+ # (+アクメ 会員の登録情報…+ is several words, not one). {.runs} counts raw letter/digit runs, an
9
+ # unspaced CJK run as one: structural scoring ({Scoring::ContainerAssessor}, {SST::Node#run_count}) is
10
+ # calibrated on it, and counting CJK chunks there moves which container wins.
11
+ module WordCount
12
+ # A run of letters and digits holding at least one Han, Hiragana or Katakana character.
13
+ CJK = /[\p{Han}\p{Hiragana}\p{Katakana}]/
14
+ # Characters of unspaced CJK text that make up one word.
15
+ CJK_CHUNK = 6
16
+ # A run of letters and digits.
17
+ RUN = /\p{Alnum}+/
18
+
19
+ class << self
20
+ ##
21
+ # @param text [String, nil]
22
+ # @param ceiling [Integer, nil] stops counting once this many words are seen
23
+ # @return [Integer] words in +text+, or at least +ceiling+ when counting stopped there
24
+ def count(text, ceiling: nil)
25
+ total = 0
26
+ text.to_s.scan(RUN) do |run|
27
+ total += run.match?(CJK) ? run.length.ceildiv(CJK_CHUNK) : 1
28
+ return total if ceiling && total >= ceiling
29
+ end
30
+ total
31
+ end
32
+
33
+ ##
34
+ # @param text [String, nil]
35
+ # @param limit [Integer]
36
+ # @return [Boolean] whether +text+ holds at least +limit+ words
37
+ def at_least?(text, limit) = count(text, ceiling: limit) >= limit
38
+
39
+ ##
40
+ # @param text [String, nil]
41
+ # @param ceiling [Integer, nil] stops counting once this many runs are seen
42
+ # @return [Integer] letter/digit runs in +text+, or +ceiling+ when counting stopped there
43
+ def runs(text, ceiling: nil)
44
+ total = 0
45
+ text.to_s.scan(RUN) do
46
+ total += 1
47
+ return total if total == ceiling
48
+ end
49
+ total
50
+ end
51
+ end
52
+ end
53
+ end
data/lib/html2rss.rb CHANGED
@@ -106,11 +106,14 @@ module Html2rss # rubocop:disable Metrics/ModuleLength
106
106
  # @param strategy [Symbol, nil] optional strategy override
107
107
  # @param strict_quality [Boolean] when true, fail on ship-quality audit thresholds
108
108
  # @param compare_enhance [Boolean] diagnostic enhance off vs on comparison on cached HTML
109
+ # @param sample_limit [Integer] maximum number of +Result#items+, >= 0 (default: 3)
109
110
  # @return [Html2rss::Test::Result]
110
111
  def self.test(config_input, feed_name = nil, min_items: 1, params: {}, strategy: nil, strict_quality: false, # rubocop:disable Metrics/ParameterLists
111
- compare_enhance: false)
112
+ compare_enhance: false, sample_limit: 3)
112
113
  Option.integer(:min_items, min_items, min: 0)
113
- Test.call(config_input, feed_name, min_items:, params:, strategy:, strict_quality:, compare_enhance:)
114
+ Option.integer(:sample_limit, sample_limit, min: 0)
115
+ Test.call(config_input, feed_name, min_items:, params:, strategy:, strict_quality:, compare_enhance:,
116
+ sample_limit:)
114
117
  end
115
118
 
116
119
  ##
@@ -136,6 +139,7 @@ module Html2rss # rubocop:disable Metrics/ModuleLength
136
139
  # @param local_file_path [String, nil] optional local HTML file path
137
140
  # @param limit [Integer, nil] max articles to keep (default: {AutoSource::DEFAULT_LIMIT})
138
141
  # @param keep_different_domain [Boolean, nil] when set, overrides cleanup.keep_different_domain
142
+ # @param keep_sponsored [Boolean, nil] when set, overrides cleanup.keep_sponsored
139
143
  # @return [Html2rss::FeedResult]
140
144
  def self.scrape(url,
141
145
  strategy: :auto,
@@ -144,9 +148,10 @@ module Html2rss # rubocop:disable Metrics/ModuleLength
144
148
  max_requests: 4,
145
149
  local_file_path: nil,
146
150
  limit: nil,
147
- keep_different_domain: nil)
151
+ keep_different_domain: nil,
152
+ keep_sponsored: nil)
148
153
  auto_feed_result(url, strategy:, items_selector:, max_redirects:, max_requests:,
149
- local_file_path:, limit:, keep_different_domain:)
154
+ local_file_path:, limit:, keep_different_domain:, keep_sponsored:)
150
155
  end
151
156
 
152
157
  ##
@@ -250,6 +255,7 @@ module Html2rss # rubocop:disable Metrics/ModuleLength
250
255
  # @param local_file_path [String, nil] optional local HTML file path
251
256
  # @param limit [Integer, nil] max articles to keep (default: {AutoSource::DEFAULT_LIMIT})
252
257
  # @param keep_different_domain [Boolean, nil] when set, overrides cleanup.keep_different_domain
258
+ # @param keep_sponsored [Boolean, nil] when set, overrides cleanup.keep_sponsored
253
259
  # @return [Html2rss::FeedResult]
254
260
  def self.auto_feed_result(url,
255
261
  strategy: :auto,
@@ -258,9 +264,10 @@ module Html2rss # rubocop:disable Metrics/ModuleLength
258
264
  max_requests: 4,
259
265
  local_file_path: nil,
260
266
  limit: nil,
261
- keep_different_domain: nil)
267
+ keep_different_domain: nil,
268
+ keep_sponsored: nil)
262
269
  feed_result(build_auto_source_config(url:, strategy:, items_selector:, max_redirects:, max_requests:,
263
- local_file_path:, limit:, keep_different_domain:))
270
+ local_file_path:, limit:, keep_different_domain:, keep_sponsored:))
264
271
  end
265
272
 
266
273
  ##
@@ -360,13 +367,15 @@ module Html2rss # rubocop:disable Metrics/ModuleLength
360
367
 
361
368
  # rubocop:disable-next Metrics/ParameterLists, Metrics/MethodLength
362
369
  def build_auto_source_config(url:, strategy:, items_selector:, max_redirects:, max_requests:,
363
- local_file_path: nil, limit: nil, keep_different_domain: nil)
370
+ local_file_path: nil, limit: nil, keep_different_domain: nil,
371
+ keep_sponsored: nil)
364
372
  config = Config.auto_source_config(
365
373
  url:,
366
374
  items_selector:,
367
375
  request_controls: Config::RequestControls.from_shortcut(strategy:, max_redirects:, max_requests:),
368
376
  limit:,
369
- keep_different_domain:
377
+ keep_different_domain:,
378
+ keep_sponsored:
370
379
  )
371
380
  if local_file_path
372
381
  config[:request] ||= {}
@@ -13,7 +13,8 @@
13
13
  "properties": {
14
14
  "url": {
15
15
  "type": "string",
16
- "minLength": 1
16
+ "minLength": 1,
17
+ "pattern": "^(([^:\\/?#]+):)?(\\/\\/([^\\/?#]*))?([^?#]*)(\\?([^#]*))?(#(.*))?$"
17
18
  },
18
19
  "title": {
19
20
  "type": [
@@ -31,7 +32,8 @@
31
32
  "type": [
32
33
  "null",
33
34
  "string"
34
- ]
35
+ ],
36
+ "pattern": "\\A[a-z]{2}(-[A-Z]{2})?\\z"
35
37
  },
36
38
  "ttl": {
37
39
  "type": [
@@ -56,7 +58,8 @@
56
58
  "type": [
57
59
  "null",
58
60
  "string"
59
- ]
61
+ ],
62
+ "pattern": "^(([^:\\/?#]+):)?(\\/\\/([^\\/?#]*))?([^?#]*)(\\?([^#]*))?(#(.*))?$"
60
63
  }
61
64
  },
62
65
  "required": [
@@ -79,8 +82,8 @@
79
82
  },
80
83
  "topics": {
81
84
  "type": "array",
85
+ "minItems": 1,
82
86
  "items": {
83
- "minLength": 1,
84
87
  "type": "string",
85
88
  "enum": [
86
89
  "sports",
@@ -104,8 +107,7 @@
104
107
  "rights",
105
108
  "transport"
106
109
  ]
107
- },
108
- "minItems": 1
110
+ }
109
111
  }
110
112
  },
111
113
  "required": [
@@ -194,7 +196,8 @@
194
196
  }
195
197
  }
196
198
  },
197
- "required": []
199
+ "required": [],
200
+ "additionalProperties": false
198
201
  },
199
202
  "wordpress_api": {
200
203
  "type": "object",
@@ -206,7 +209,8 @@
206
209
  }
207
210
  }
208
211
  },
209
- "required": []
212
+ "required": [],
213
+ "additionalProperties": false
210
214
  },
211
215
  "sitemap": {
212
216
  "type": "object",
@@ -218,7 +222,14 @@
218
222
  }
219
223
  },
220
224
  "min_priority": {
221
- "type": "number",
225
+ "anyOf": [
226
+ {
227
+ "type": "integer"
228
+ },
229
+ {
230
+ "type": "number"
231
+ }
232
+ ],
222
233
  "not": {
223
234
  "type": "null"
224
235
  }
@@ -231,7 +242,8 @@
231
242
  "exclusiveMinimum": 0
232
243
  }
233
244
  },
234
- "required": []
245
+ "required": [],
246
+ "additionalProperties": false
235
247
  },
236
248
  "schema": {
237
249
  "type": "object",
@@ -243,7 +255,8 @@
243
255
  }
244
256
  }
245
257
  },
246
- "required": []
258
+ "required": [],
259
+ "additionalProperties": false
247
260
  },
248
261
  "microdata": {
249
262
  "type": "object",
@@ -255,7 +268,8 @@
255
268
  }
256
269
  }
257
270
  },
258
- "required": []
271
+ "required": [],
272
+ "additionalProperties": false
259
273
  },
260
274
  "microformats2": {
261
275
  "type": "object",
@@ -267,7 +281,8 @@
267
281
  }
268
282
  }
269
283
  },
270
- "required": []
284
+ "required": [],
285
+ "additionalProperties": false
271
286
  },
272
287
  "json_state": {
273
288
  "type": "object",
@@ -279,7 +294,8 @@
279
294
  }
280
295
  }
281
296
  },
282
- "required": []
297
+ "required": [],
298
+ "additionalProperties": false
283
299
  },
284
300
  "json_document": {
285
301
  "type": "object",
@@ -291,7 +307,8 @@
291
307
  }
292
308
  }
293
309
  },
294
- "required": []
310
+ "required": [],
311
+ "additionalProperties": false
295
312
  },
296
313
  "xhr_articles": {
297
314
  "type": "object",
@@ -303,7 +320,8 @@
303
320
  }
304
321
  }
305
322
  },
306
- "required": []
323
+ "required": [],
324
+ "additionalProperties": false
307
325
  },
308
326
  "meta_oembed": {
309
327
  "type": "object",
@@ -315,7 +333,8 @@
315
333
  }
316
334
  }
317
335
  },
318
- "required": []
336
+ "required": [],
337
+ "additionalProperties": false
319
338
  },
320
339
  "semantic_html": {
321
340
  "type": "object",
@@ -325,15 +344,10 @@
325
344
  "not": {
326
345
  "type": "null"
327
346
  }
328
- },
329
- "fallback_anchorless": {
330
- "type": "boolean",
331
- "not": {
332
- "type": "null"
333
- }
334
347
  }
335
348
  },
336
- "required": []
349
+ "required": [],
350
+ "additionalProperties": false
337
351
  },
338
352
  "html": {
339
353
  "type": "object",
@@ -357,18 +371,14 @@
357
371
  "type": "null"
358
372
  },
359
373
  "exclusiveMinimum": 0
360
- },
361
- "fallback_anchorless": {
362
- "type": "boolean",
363
- "not": {
364
- "type": "null"
365
- }
366
374
  }
367
375
  },
368
- "required": []
376
+ "required": [],
377
+ "additionalProperties": false
369
378
  }
370
379
  },
371
- "required": []
380
+ "required": [],
381
+ "additionalProperties": false
372
382
  },
373
383
  "cleanup": {
374
384
  "type": "object",
@@ -378,6 +388,12 @@
378
388
  "not": {
379
389
  "type": "null"
380
390
  }
391
+ },
392
+ "keep_sponsored": {
393
+ "type": "boolean",
394
+ "not": {
395
+ "type": "null"
396
+ }
381
397
  }
382
398
  },
383
399
  "required": []
@@ -424,18 +440,17 @@
424
440
  "enabled": true
425
441
  },
426
442
  "semantic_html": {
427
- "enabled": true,
428
- "fallback_anchorless": true
443
+ "enabled": true
429
444
  },
430
445
  "html": {
431
446
  "enabled": true,
432
447
  "minimum_selector_frequency": 2,
433
- "use_top_selectors": 5,
434
- "fallback_anchorless": true
448
+ "use_top_selectors": 5
435
449
  }
436
450
  },
437
451
  "cleanup": {
438
- "keep_different_domain": false
452
+ "keep_different_domain": false,
453
+ "keep_sponsored": false
439
454
  }
440
455
  }
441
456
  },
@@ -465,7 +480,7 @@
465
480
  "not": {
466
481
  "type": "null"
467
482
  },
468
- "description": "List-card enrichment: run Html::ArticleExtractor on each matched item node to fill missing fields from the card HTML."
483
+ "description": "Fills missing title, link, description, date, image and categories from each item's own markup via Html::ArticleExtractor. Explicit selectors win; no extra requests are made."
469
484
  },
470
485
  "pagination": {
471
486
  "description": "Pagination configuration or maximum page count integer.",
@@ -589,7 +604,8 @@
589
604
  },
590
605
  "content_type": {
591
606
  "type": "string",
592
- "minLength": 1
607
+ "minLength": 1,
608
+ "pattern": "^[\\w-]+\\/[\\w-]+$"
593
609
  }
594
610
  },
595
611
  "required": [],
@@ -726,8 +742,7 @@
726
742
  "auto",
727
743
  "get",
728
744
  "google_get",
729
- "google_get_bypass",
730
- "organic_get"
745
+ "google_get_bypass"
731
746
  ]
732
747
  },
733
748
  "max_retries": {