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
data/lib/html2rss/url.rb CHANGED
@@ -26,37 +26,74 @@ 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
+
35
+ # Raised when a string cannot be parsed or resolved to a valid URL.
36
+ class InvalidUrlError < Html2rss::Error
37
+ # @return [Symbol]
38
+ def kind = :invalid_url
39
+ end
40
+
29
41
  # Regular expression for basic URI format validation
30
42
  URI_REGEXP = Addressable::URI::URIREGEX
31
43
  # Schemes accepted by channel URL validation.
32
44
  SUPPORTED_SCHEMES = %w[http https].to_set.freeze
45
+ SCHEME_PREFIX = /\A([a-z][a-z0-9+.-]*):/i
46
+ private_constant :SCHEME_PREFIX
33
47
 
34
48
  ##
35
49
  # Creates a URL from a relative path and base URL.
36
50
  #
37
51
  # @param relative_url [String, Html2rss::Url] the relative URL to resolve
38
52
  # @param base_url [String, Html2rss::Url] the base URL to resolve against
39
- # @return [Url] the resolved absolute URL
40
- # @raise [ArgumentError] if the URL cannot be parsed
53
+ # @return [Url] the resolved, normalized absolute URL
54
+ # @raise [InvalidUrlError] if the URL cannot be parsed
41
55
  def self.from_relative(relative_url, base_url)
42
- url = Addressable::URI.parse(relative_url.to_s.strip)
43
- 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?
44
58
 
45
- base_url.is_a?(self) ? base_url.join_uri(url) : join_parsed_base(base_url, url)
59
+ join_parsed_base(base_url, url)
46
60
  rescue Addressable::URI::InvalidURIError
47
- raise ArgumentError, 'URL could not be parsed'
61
+ raise InvalidUrlError, 'URL could not be parsed'
48
62
  end
49
63
 
50
- # @param base_url [String]
64
+ # @param base_url [String, Url]
51
65
  # @param url [Addressable::URI]
52
66
  # @return [Url]
53
67
  def self.join_parsed_base(base_url, url)
54
68
  uri = Addressable::URI.parse(base_url.to_s)
55
- uri.path = '/' if uri.path.empty?
56
69
  new(uri.join(url).normalize)
57
70
  end
58
71
  private_class_method :join_parsed_base
59
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
+
60
97
  ##
61
98
  # Creates a URL by sanitizing a raw URL string.
62
99
  # Removes spaces and extracts the first valid URL from the string.
@@ -64,10 +101,10 @@ module Html2rss
64
101
  # @param raw_url [String] the raw URL string to sanitize
65
102
  # @return [Url, nil] the sanitized URL, or nil if no valid URL found
66
103
  def self.sanitize(raw_url)
67
- match = raw_url.to_s.match(%r{(?:(?:https?|ftp|mailto)://|mailto:)[^\s<>"]+})
104
+ match = raw_url.to_s.match(%r{(?:https?:[/\\]{2}|ftp://|mailto:)[^\s<>"]+})
68
105
  return unless match
69
106
 
70
- new(Addressable::URI.parse(match[0].strip).normalize)
107
+ new(parse(match[0]).normalize)
71
108
  end
72
109
 
73
110
  ##
@@ -75,15 +112,15 @@ module Html2rss
75
112
  #
76
113
  # @param url_string [String, Html2rss::Url] the absolute URL to parse
77
114
  # @return [Url] the parsed and normalized URL
78
- # @raise [ArgumentError] if the URL is not absolute or cannot be parsed
115
+ # @raise [InvalidUrlError] if the URL is not absolute or cannot be parsed
79
116
  def self.from_absolute(url_string)
80
117
  return url_string if url_string.is_a?(self)
81
118
 
82
- new(Addressable::URI.parse(url_string.to_s.strip).normalize).tap do |url|
83
- raise ArgumentError, 'URL must be absolute' unless url.absolute?
119
+ new(parse(url_string.to_s.lstrip).normalize).tap do |url|
120
+ raise InvalidUrlError, 'URL must be absolute' unless url.absolute?
84
121
  end
85
122
  rescue Addressable::URI::InvalidURIError
86
- raise ArgumentError, 'URL must be absolute'
123
+ raise InvalidUrlError, 'URL must be absolute'
87
124
  end
88
125
 
89
126
  ##
@@ -92,20 +129,47 @@ module Html2rss
92
129
  #
93
130
  # @param url_string [String] the URL string to validate and parse
94
131
  # @return [Url] the validated and parsed URL
95
- # @raise [ArgumentError] if the URL doesn't meet channel requirements
132
+ # @raise [InvalidUrlError] if the URL doesn't meet channel requirements
96
133
  # @example Creating a channel URL
97
134
  # Url.for_channel('https://example.com')
98
135
  # # => #<Html2rss::Url:... @uri=#<Addressable::URI:... URI:https://example.com>>
99
136
  # @example Invalid channel URL
100
137
  # Url.for_channel('/relative/path')
101
- # # => raises ArgumentError: "URL must be absolute"
138
+ # # => raises InvalidUrlError: "URL must be absolute"
102
139
  def self.for_channel(url_string)
103
- stripped = url_string.to_s.strip
140
+ stripped = url_string.to_s.lstrip
104
141
  return nil if stripped.empty?
105
142
 
106
143
  from_absolute(stripped).tap { validate_channel_url(_1) }
107
144
  end
108
145
 
146
+ ##
147
+ # Creates an absolute http(s) URL, for example for a channel image.
148
+ #
149
+ # @param url_string [String, Html2rss::Url] the URL to validate and parse
150
+ # @return [Url] the validated and parsed URL
151
+ # @raise [InvalidUrlError] if the URL is not absolute or not http(s)
152
+ def self.from_absolute_web(url_string)
153
+ from_absolute(url_string).tap { validate_scheme(_1) }
154
+ end
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
+
109
173
  ##
110
174
  # Extracts a base URL from HTML metadata tags.
111
175
  #
@@ -116,10 +180,10 @@ module Html2rss
116
180
  tags = { 'link[rel="canonical"]' => 'href', 'meta[property="og:url"]' => 'content',
117
181
  'meta[name="twitter:url"]' => 'content', 'base[href]' => 'href' }
118
182
  tags.each do |sel, attr|
119
- val = doc.at_css(sel)&.[](attr).to_s.strip
120
- return from_absolute(val) unless val.empty?
121
- rescue ArgumentError
122
- next
183
+ val = doc.at_css(sel)&.[](attr)
184
+ return from_absolute(val)
185
+ rescue InvalidUrlError
186
+ nil
123
187
  end
124
188
  nil
125
189
  end
@@ -128,31 +192,34 @@ module Html2rss
128
192
  # Validates that a URL meets channel requirements.
129
193
  #
130
194
  # @param url [Url] the URL to validate
131
- # @raise [ArgumentError] if the URL doesn't meet channel requirements
195
+ # @raise [InvalidUrlError] if the URL doesn't meet channel requirements
132
196
  def self.validate_channel_url(url)
133
- raise ArgumentError, 'URL must be absolute' unless url.absolute?
197
+ has_forbidden_at = url.user
198
+ has_forbidden_at ||= [url.query, url.fragment].compact.any? { |value| value.include?('@') }
199
+ raise InvalidUrlError, 'URL must not contain an @ character' if has_forbidden_at
134
200
 
135
- uri = Addressable::URI.parse(url.to_s)
136
- has_forbidden_at = uri.user || uri.password
137
- has_forbidden_at ||= [uri.query, uri.fragment].compact.any? { |value| value.include?('@') }
138
- raise ArgumentError, 'URL must not contain an @ character' if has_forbidden_at
201
+ validate_scheme(url)
202
+ end
139
203
 
140
- scheme = url.scheme
141
- raise ArgumentError, "URL scheme '#{scheme}' is not supported" unless SUPPORTED_SCHEMES.include?(scheme)
204
+ def self.validate_scheme(url)
205
+ raise InvalidUrlError, "URL scheme '#{url.scheme}' is not supported" unless url.web?
142
206
  end
143
207
 
144
- private_class_method :validate_channel_url
208
+ private_class_method :validate_channel_url, :validate_scheme
145
209
 
146
210
  ##
147
- # @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)
148
215
  def initialize(uri)
149
- @uri = uri.freeze
150
- @path_segments = @uri.path.to_s.split('/').reject(&:empty?).freeze
216
+ @uri = uri
217
+ @path_segments = path.split('/').reject(&:empty?).freeze
151
218
  freeze
152
219
  end
153
220
 
154
221
  # @return [String] normalized URL string
155
- def to_s = @uri.to_s
222
+ def to_s = @uri.to_str
156
223
 
157
224
  # @return [String, nil] URI scheme, for example `http` or `https`
158
225
  def scheme = @uri.scheme
@@ -160,8 +227,11 @@ module Html2rss
160
227
  # @return [String, nil] URI host component
161
228
  def host = @uri.host
162
229
 
163
- # Registrable domain (eTLD+1). Same-site hosts like www.wp.pl and wiadomosci.wp.pl
164
- # share `wp.pl`. IP literals stay the host so 1.2.3.4 is not treated as `3.4`.
230
+ # @return [String, nil] userinfo user, without the password
231
+ def user = @uri.user
232
+
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`.
165
235
  #
166
236
  # @return [String, nil]
167
237
  def domain
@@ -185,11 +255,15 @@ module Html2rss
185
255
  # @return [Boolean] whether the URL includes scheme and host
186
256
  def absolute? = @uri.absolute?
187
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
+
188
262
  ##
189
263
  # Returns the URL query string as a hash of string keys and values.
190
264
  #
191
265
  # @return [Hash{String => String}] normalized query parameters
192
- def query_values = @uri.query_values(Hash) || {}
266
+ def query_values = @uri.query_values || {}
193
267
 
194
268
  ##
195
269
  # Returns the URL path split into non-empty segments.
@@ -205,7 +279,7 @@ module Html2rss
205
279
  def with_path(path)
206
280
  uri = @uri.dup
207
281
  uri.path = path
208
- self.class.from_absolute(uri.normalize.to_s)
282
+ self.class.from_absolute(uri)
209
283
  end
210
284
 
211
285
  ##
@@ -213,7 +287,31 @@ module Html2rss
213
287
  #
214
288
  # @return [Url] this URL when already fragment-free; otherwise a new URL
215
289
  def without_fragment
216
- @uri.fragment ? self.class.from_absolute(@uri.omit(:fragment).normalize.to_s) : self
290
+ fragment ? self.class.from_absolute(@uri.omit(:fragment)) : self
291
+ end
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)
217
315
  end
218
316
 
219
317
  ##
@@ -224,7 +322,7 @@ module Html2rss
224
322
  def with_query_values(values)
225
323
  uri = @uri.dup
226
324
  uri.query_values = values.transform_keys(&:to_s).transform_values(&:to_s)
227
- self.class.from_absolute(uri.normalize.to_s)
325
+ self.class.from_absolute(uri)
228
326
  end
229
327
 
230
328
  ##
@@ -240,15 +338,7 @@ module Html2rss
240
338
  # url = Url.from_absolute('https://example.com/hello%20world/article.html')
241
339
  # url.titleized # => "Hello World Article"
242
340
  def titleized
243
- path = @uri.path
244
- return '' if path.empty?
245
-
246
- nicer_path = CGI.unescapeURIComponent(path)
247
- .split('/')
248
- .flat_map do |part|
249
- part.gsub(/[^a-zA-Z0-9.]/, ' ').gsub(/\s+/, ' ').split
250
- end
251
-
341
+ nicer_path = CGI.unescapeURIComponent(path).gsub(/[^a-zA-Z0-9.]/, ' ').split
252
342
  nicer_path.map!(&:capitalize)
253
343
  File.basename(nicer_path.join(' '), '.*')
254
344
  end
@@ -266,8 +356,7 @@ module Html2rss
266
356
  # url = Url.from_absolute('https://example.com')
267
357
  # url.channel_titleized # => "example.com"
268
358
  def channel_titleized
269
- nicer_path = CGI.unescapeURIComponent(@uri.path).split('/').reject(&:empty?)
270
- host = @uri.host
359
+ nicer_path = CGI.unescapeURIComponent(path).split('/').reject(&:empty?)
271
360
 
272
361
  nicer_path.any? ? "#{host}: #{nicer_path.map(&:capitalize).join(' ')}" : host
273
362
  end
@@ -318,29 +407,41 @@ module Html2rss
318
407
  # @param relative_uri [Addressable::URI]
319
408
  # @return [Url]
320
409
  def join_uri(relative_uri)
321
- base = @uri
322
- if base.path.empty?
323
- base = base.dup
324
- base.path = '/'
325
- end
326
- self.class.new(base.join(relative_uri).normalize)
410
+ self.class.new(@uri.join(relative_uri).normalize)
327
411
  end
328
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
+
329
423
  protected
330
424
 
331
- # 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.
332
427
  #
333
428
  # @return [String]
334
429
  def document_identity
335
- uri = @uri.omit(:fragment)
336
- "#{uri.scheme}\0#{uri.authority}\0#{uri.path.to_s.chomp('/')}\0#{uri.query}"
430
+ "#{scheme}\0#{@uri.authority}\0#{path.chomp('/')}\0#{query}"
337
431
  end
338
432
 
339
433
  private
340
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
+
341
443
  def ip_address_host?
342
- IPAddr.new(host.to_s)
343
- true
444
+ !IPAddr.new(host).nil?
344
445
  rescue IPAddr::Error
345
446
  false
346
447
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Html2rss
4
4
  # Current application version.
5
- VERSION = '0.32.4'
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
@@ -101,15 +101,19 @@ module Html2rss # rubocop:disable Metrics/ModuleLength
101
101
  #
102
102
  # @param config_input [Hash, String] config hash, YAML string, or file path
103
103
  # @param feed_name [String, nil] optional feed name in multi-feed file
104
- # @param min_items [Integer] minimum required items (default: 1)
104
+ # @param min_items [Integer] minimum required items, >= 0 (default: 1)
105
105
  # @param params [Hash] dynamic feed params
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
- Test.call(config_input, feed_name, min_items:, params:, strategy:, strict_quality:, compare_enhance:)
112
+ compare_enhance: false, sample_limit: 3)
113
+ Option.integer(:min_items, min_items, min: 0)
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:)
113
117
  end
114
118
 
115
119
  ##
@@ -135,6 +139,7 @@ module Html2rss # rubocop:disable Metrics/ModuleLength
135
139
  # @param local_file_path [String, nil] optional local HTML file path
136
140
  # @param limit [Integer, nil] max articles to keep (default: {AutoSource::DEFAULT_LIMIT})
137
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
138
143
  # @return [Html2rss::FeedResult]
139
144
  def self.scrape(url,
140
145
  strategy: :auto,
@@ -143,9 +148,10 @@ module Html2rss # rubocop:disable Metrics/ModuleLength
143
148
  max_requests: 4,
144
149
  local_file_path: nil,
145
150
  limit: nil,
146
- keep_different_domain: nil)
151
+ keep_different_domain: nil,
152
+ keep_sponsored: nil)
147
153
  auto_feed_result(url, strategy:, items_selector:, max_redirects:, max_requests:,
148
- local_file_path:, limit:, keep_different_domain:)
154
+ local_file_path:, limit:, keep_different_domain:, keep_sponsored:)
149
155
  end
150
156
 
151
157
  ##
@@ -249,6 +255,7 @@ module Html2rss # rubocop:disable Metrics/ModuleLength
249
255
  # @param local_file_path [String, nil] optional local HTML file path
250
256
  # @param limit [Integer, nil] max articles to keep (default: {AutoSource::DEFAULT_LIMIT})
251
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
252
259
  # @return [Html2rss::FeedResult]
253
260
  def self.auto_feed_result(url,
254
261
  strategy: :auto,
@@ -257,9 +264,10 @@ module Html2rss # rubocop:disable Metrics/ModuleLength
257
264
  max_requests: 4,
258
265
  local_file_path: nil,
259
266
  limit: nil,
260
- keep_different_domain: nil)
267
+ keep_different_domain: nil,
268
+ keep_sponsored: nil)
261
269
  feed_result(build_auto_source_config(url:, strategy:, items_selector:, max_redirects:, max_requests:,
262
- local_file_path:, limit:, keep_different_domain:))
270
+ local_file_path:, limit:, keep_different_domain:, keep_sponsored:))
263
271
  end
264
272
 
265
273
  ##
@@ -359,13 +367,15 @@ module Html2rss # rubocop:disable Metrics/ModuleLength
359
367
 
360
368
  # rubocop:disable-next Metrics/ParameterLists, Metrics/MethodLength
361
369
  def build_auto_source_config(url:, strategy:, items_selector:, max_redirects:, max_requests:,
362
- 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)
363
372
  config = Config.auto_source_config(
364
373
  url:,
365
374
  items_selector:,
366
375
  request_controls: Config::RequestControls.from_shortcut(strategy:, max_redirects:, max_requests:),
367
376
  limit:,
368
- keep_different_domain:
377
+ keep_different_domain:,
378
+ keep_sponsored:
369
379
  )
370
380
  if local_file_path
371
381
  config[:request] ||= {}