html2rss 0.32.3 → 0.33.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 (108) hide show
  1. checksums.yaml +4 -4
  2. data/lib/html2rss/article/enclosure.rb +2 -6
  3. data/lib/html2rss/auto_source/cleanup.rb +1 -3
  4. data/lib/html2rss/auto_source/scraper/html.rb +2 -8
  5. data/lib/html2rss/auto_source/scraper/json_document.rb +2 -3
  6. data/lib/html2rss/auto_source/scraper/json_state/article_normalizer.rb +128 -128
  7. data/lib/html2rss/auto_source/scraper/json_state/candidate_detector.rb +37 -37
  8. data/lib/html2rss/auto_source/scraper/json_state/document_scanner.rb +123 -123
  9. data/lib/html2rss/auto_source/scraper/json_state/value_finder.rb +34 -34
  10. data/lib/html2rss/auto_source/scraper/json_state.rb +2 -6
  11. data/lib/html2rss/auto_source/scraper/meta_oembed.rb +32 -2
  12. data/lib/html2rss/auto_source/scraper/microdata.rb +228 -245
  13. data/lib/html2rss/auto_source/scraper/microformats2.rb +9 -1
  14. data/lib/html2rss/auto_source/scraper/native_feed.rb +1 -1
  15. data/lib/html2rss/auto_source/scraper/schema/category_extractor.rb +7 -2
  16. data/lib/html2rss/auto_source/scraper/schema/item_list.rb +2 -2
  17. data/lib/html2rss/auto_source/scraper/schema/thing.rb +98 -21
  18. data/lib/html2rss/auto_source/scraper/schema.rb +49 -2
  19. data/lib/html2rss/auto_source/scraper/semantic_html.rb +12 -16
  20. data/lib/html2rss/auto_source/scraper/sitemap/parser.rb +1 -4
  21. data/lib/html2rss/auto_source/scraper/sitemap.rb +3 -11
  22. data/lib/html2rss/auto_source/scraper/wordpress_api/page_scope.rb +2 -2
  23. data/lib/html2rss/auto_source/scraper/wordpress_api/posts_endpoint.rb +1 -3
  24. data/lib/html2rss/auto_source/scraper/wordpress_api.rb +4 -4
  25. data/lib/html2rss/auto_source/scraper/xhr_articles.rb +1 -1
  26. data/lib/html2rss/auto_source/scraper.rb +0 -11
  27. data/lib/html2rss/auto_source/segmenter/cluster.rb +5 -11
  28. data/lib/html2rss/auto_source/segmenter/list.rb +25 -57
  29. data/lib/html2rss/auto_source/segmenter/semantic.rb +6 -13
  30. data/lib/html2rss/auto_source.rb +1 -1
  31. data/lib/html2rss/batch.rb +22 -32
  32. data/lib/html2rss/capture/README.md +1 -1
  33. data/lib/html2rss/capture.rb +7 -27
  34. data/lib/html2rss/cli/render.rb +97 -96
  35. data/lib/html2rss/cli/validate.rb +2 -4
  36. data/lib/html2rss/cli.rb +33 -27
  37. data/lib/html2rss/config/request_controls.rb +10 -2
  38. data/lib/html2rss/config/schema.rb +8 -10
  39. data/lib/html2rss/config/selectors_validator.rb +1 -0
  40. data/lib/html2rss/config/validator.rb +3 -3
  41. data/lib/html2rss/config.rb +1 -1
  42. data/lib/html2rss/defaults.rb +5 -16
  43. data/lib/html2rss/doctor/botasaurus.rb +11 -19
  44. data/lib/html2rss/error.rb +54 -1
  45. data/lib/html2rss/feed_builder/rss.rb +1 -1
  46. data/lib/html2rss/feed_pipeline/README.md +1 -1
  47. data/lib/html2rss/feed_pipeline/auto_fallback.rb +58 -55
  48. data/lib/html2rss/feed_resolution/candidate_generator.rb +3 -4
  49. data/lib/html2rss/feed_resolution/options.rb +3 -7
  50. data/lib/html2rss/feed_resolution/policy.rb +29 -33
  51. data/lib/html2rss/feed_resolution/probe.rb +3 -3
  52. data/lib/html2rss/feed_resolution/scorer.rb +29 -29
  53. data/lib/html2rss/feed_resolution.rb +5 -9
  54. data/lib/html2rss/feed_result.rb +4 -3
  55. data/lib/html2rss/hash_util.rb +91 -90
  56. data/lib/html2rss/html/article_extractor/category_extractor.rb +6 -9
  57. data/lib/html2rss/html/article_extractor/date_extractor.rb +1 -1
  58. data/lib/html2rss/html/article_extractor/enclosure_extractor.rb +1 -3
  59. data/lib/html2rss/html/article_extractor/id_generator.rb +1 -1
  60. data/lib/html2rss/html/probe.rb +51 -51
  61. data/lib/html2rss/link_destination/path_classifier.rb +11 -33
  62. data/lib/html2rss/mcp/config_argument.rb +9 -2
  63. data/lib/html2rss/mcp/contract.rb +7 -5
  64. data/lib/html2rss/mcp/outcome.rb +4 -5
  65. data/lib/html2rss/mcp/runtime.rb +17 -24
  66. data/lib/html2rss/mcp/server/tools.rb +9 -9
  67. data/lib/html2rss/option.rb +48 -0
  68. data/lib/html2rss/page_recon/diagnostics.rb +7 -9
  69. data/lib/html2rss/page_recon.rb +0 -4
  70. data/lib/html2rss/recon.rb +10 -12
  71. data/lib/html2rss/request_service/blocked_surface.rb +3 -7
  72. data/lib/html2rss/request_service/botasaurus_strategy.rb +2 -2
  73. data/lib/html2rss/request_service/budget.rb +3 -24
  74. data/lib/html2rss/request_service/compressed_body.rb +36 -48
  75. data/lib/html2rss/request_service/context.rb +1 -3
  76. data/lib/html2rss/request_service/local_file_strategy.rb +3 -1
  77. data/lib/html2rss/request_service/network_guard.rb +3 -2
  78. data/lib/html2rss/request_service/policy.rb +8 -26
  79. data/lib/html2rss/request_service/response.rb +3 -6
  80. data/lib/html2rss/request_service/strategy.rb +2 -2
  81. data/lib/html2rss/request_service.rb +26 -2
  82. data/lib/html2rss/request_session/pager/base.rb +3 -3
  83. data/lib/html2rss/request_session/pager/custom_selector.rb +4 -4
  84. data/lib/html2rss/request_session/pager/offset.rb +1 -7
  85. data/lib/html2rss/request_session/pager/rel_next.rb +1 -1
  86. data/lib/html2rss/request_session/pager/url_template.rb +1 -7
  87. data/lib/html2rss/scoring/engine.rb +4 -32
  88. data/lib/html2rss/scoring/link_resolver.rb +2 -7
  89. data/lib/html2rss/selectors/extractors/href.rb +2 -4
  90. data/lib/html2rss/selectors/json_xml.rb +33 -34
  91. data/lib/html2rss/selectors/post_processors/gsub.rb +2 -3
  92. data/lib/html2rss/selectors/post_processors/parse_time.rb +4 -5
  93. data/lib/html2rss/selectors/schema_export.rb +60 -60
  94. data/lib/html2rss/selectors.rb +14 -18
  95. data/lib/html2rss/sst/node.rb +9 -3
  96. data/lib/html2rss/sst/normalizer.rb +25 -24
  97. data/lib/html2rss/sst/text.rb +53 -57
  98. data/lib/html2rss/status.rb +23 -30
  99. data/lib/html2rss/syndication/alternate_links.rb +39 -0
  100. data/lib/html2rss/syndication/discovery.rb +117 -187
  101. data/lib/html2rss/syndication/parser.rb +92 -115
  102. data/lib/html2rss/test/enhance_audit.rb +13 -15
  103. data/lib/html2rss/test/policy.rb +9 -11
  104. data/lib/html2rss/test.rb +29 -48
  105. data/lib/html2rss/url.rb +53 -53
  106. data/lib/html2rss/version.rb +1 -1
  107. data/lib/html2rss.rb +2 -1
  108. metadata +4 -2
@@ -8,7 +8,7 @@ module Html2rss
8
8
  # Extracts categories from Schema.org structured data.
9
9
  module CategoryExtractor
10
10
  # Schema.org fields checked for category-like string values.
11
- CATEGORY_FIELDS = %i[articleSection keywords categories tags].freeze
11
+ CATEGORY_FIELDS = %i[articleSection keywords categories tags occupationalCategory industry].freeze
12
12
 
13
13
  ##
14
14
  # @param schema_object [Hash] The schema object
@@ -26,15 +26,20 @@ module Html2rss
26
26
  def add_field(categories, value)
27
27
  case value
28
28
  when Array
29
- value.each { |item| add_string(categories, item.to_s) }
29
+ value.each { |item| add_item(categories, item) }
30
30
  when String
31
31
  split_string(categories, value)
32
32
  end
33
33
  end
34
34
 
35
+ def add_item(categories, item)
36
+ item.is_a?(String) ? split_string(categories, item) : add_string(categories, item.to_s)
37
+ end
38
+
35
39
  def add_about(categories, about)
36
40
  case about
37
41
  when Array then add_about_items(categories, about)
42
+ when Hash then add_about_items(categories, [about])
38
43
  when String then split_string(categories, about)
39
44
  end
40
45
  end
@@ -49,11 +49,11 @@ module Html2rss
49
49
 
50
50
  # @param object [Hash] candidate schema object
51
51
  # @param from_list_item [Boolean] whether object came from a ListItem unwrap
52
- # @return [Boolean] whether to emit this object as an article
52
+ # @return [Boolean] whether to emit this object as an article (also bare Event, JobPosting and video items)
53
53
  def emit?(object, from_list_item:)
54
54
  return true if from_list_item
55
55
 
56
- Schema.normalize_types(object[:@type]).intersect?(Thing::SUPPORTED_TYPES)
56
+ Schema.normalize_types(object[:@type]).intersect?(Thing::SUPPORTED_TYPES | Thing::ITEM_LIST_TYPES)
57
57
  end
58
58
  end
59
59
  end
@@ -10,7 +10,7 @@ module Html2rss
10
10
  # A Thing is kind of the 'base class' for Schema.org schema_objects.
11
11
  #
12
12
  # @see https://schema.org/Thing
13
- class Thing
13
+ class Thing # rubocop:disable Metrics/ClassLength -- one accessor per schema.org field
14
14
  # Supported Schema.org `@type` values mapped to article extraction.
15
15
  SUPPORTED_TYPES = %w[
16
16
  AdvertiserContentArticle AnalysisNewsArticle APIReference Article
@@ -19,6 +19,12 @@ module Html2rss
19
19
  ReviewNewsArticle SatiricalArticle ScholarlyArticle SocialMediaPosting TechArticle
20
20
  ].to_set.freeze
21
21
 
22
+ # Schema.org types emitted only as elements of an ItemList (never standalone).
23
+ ITEM_LIST_TYPES = Set['Event', 'JobPosting', 'VideoObject', 'PodcastEpisode'].freeze
24
+
25
+ # Schema.org types whose own `contentUrl` is the media enclosure.
26
+ MEDIA_TYPES = Set['VideoObject'].freeze
27
+
22
28
  # @param schema_object [Hash{Symbol => Object}] parsed schema.org object
23
29
  # @param url [String, Html2rss::Url, nil] base URL used for relative normalization
24
30
  def initialize(schema_object, url:)
@@ -28,7 +34,7 @@ module Html2rss
28
34
 
29
35
  # @return [Hash{Symbol => Object}] scraped article attributes
30
36
  def call
31
- { id:, title:, description:, author:, url:, image:, published_at:, categories: }
37
+ { id:, title:, description:, author:, url:, image:, published_at:, categories:, enclosures: }
32
38
  end
33
39
 
34
40
  # @return [String, nil] stable schema object identifier
@@ -43,31 +49,29 @@ module Html2rss
43
49
  def title
44
50
  schema_object.values_at(:headline, :name, :alternativeHeadline, :title)
45
51
  .lazy
46
- .map { _1.to_s.strip }
52
+ .map { first_scalar(_1).to_s.strip }
47
53
  .find { !_1.empty? }
48
54
  end
49
55
 
50
56
  # @return [String, nil] longest available description field
51
57
  def description
52
- schema_object.values_at(:description, :articleBody, :abstract, :schema_object_body).max_by { _1.to_s.size }
58
+ schema_object.values_at(:description, :articleBody, :abstract, :schema_object_body)
59
+ .map { first_scalar(_1) }
60
+ .max_by { _1.to_s.size }
53
61
  end
54
62
 
55
63
  # @return [String, nil] extracted author or publisher name
56
64
  def author
57
- extract_author_name(schema_object[:author]) || extract_author_name(schema_object[:publisher])
65
+ extract_author_name(schema_object[:author]) || extract_author_name(schema_object[:publisher]) ||
66
+ extract_author_name(schema_object[:hiringOrganization]) ||
67
+ extract_author_name(schema_object[:organizer])
58
68
  end
59
69
 
60
70
  # @return [Html2rss::Url, nil] the URL of the schema object
61
71
  def url
62
72
  return @url if defined?(@url)
63
73
 
64
- url = schema_object[:url]
65
- if url.to_s.empty?
66
- Log.debug("Schema#Thing.url: no url in schema_object: #{schema_object.inspect}")
67
- return @url = nil
68
- end
69
-
70
- @url = Url.from_relative(url, base_url || url)
74
+ @url = build_url(first_scalar(schema_object[:url])) || fallback_url
71
75
  end
72
76
 
73
77
  # @return [Html2rss::Url, nil] normalized article image URL
@@ -78,10 +82,11 @@ module Html2rss
78
82
  @image = img_url ? Url.from_relative(img_url, base_url || img_url) : nil
79
83
  end
80
84
 
81
- # @return [String, nil] published-at timestamp (datePublished, else dateModified)
85
+ # @return [String, nil] first non-empty of datePublished, datePosted, dateCreated, dateModified, uploadDate
82
86
  def published_at
83
- value = schema_object[:datePublished] || schema_object[:dateModified]
84
- value.to_s.empty? ? nil : value
87
+ schema_object.values_at(:datePublished, :datePosted, :dateCreated, :dateModified, :uploadDate)
88
+ .map { first_scalar(_1) }
89
+ .find { !_1.to_s.empty? }
85
90
  end
86
91
 
87
92
  # @return [Array<String>, nil] extracted category labels
@@ -89,15 +94,84 @@ module Html2rss
89
94
  @categories ||= CategoryExtractor.call(schema_object)
90
95
  end
91
96
 
97
+ # @return [Array<Hash{Symbol => Object}>, nil] enclosures from VideoObject/AudioObject `contentUrl`
98
+ def enclosures
99
+ nodes = Array(schema_object.values_at(:video, :audio)).flatten
100
+ nodes << schema_object if own_media?
101
+ found = nodes.filter_map { media_enclosure(_1) }
102
+ found unless found.empty?
103
+ end
104
+
92
105
  attr_reader :schema_object, :base_url
93
106
 
94
107
  # @return [Array<String>] normalized image URL candidates
95
108
  def image_urls
96
- @image_urls ||= schema_object.values_at(:image, :thumbnailUrl).filter_map { image_url_from(_1) }
109
+ @image_urls ||= schema_object.values_at(:image, :thumbnailUrl, :thumbnail).filter_map { image_url_from(_1) }
97
110
  end
98
111
 
99
112
  private
100
113
 
114
+ # @return [Boolean] whether the object itself is a media object carrying its own `contentUrl`
115
+ def own_media?
116
+ Schema.normalize_types(schema_object[:@type]).intersect?(MEDIA_TYPES)
117
+ end
118
+
119
+ # @param node [Object] VideoObject/AudioObject node
120
+ # @return [Hash{Symbol => Object}, nil] enclosure attributes, or nil without a usable absolute contentUrl
121
+ def media_enclosure(node)
122
+ return unless node.is_a?(Hash) && node[:contentUrl].is_a?(String)
123
+
124
+ build_media_enclosure(node[:contentUrl], node[:encodingFormat])
125
+ end
126
+
127
+ # @param src [String] contentUrl
128
+ # @param type [Object] encodingFormat (used when a String)
129
+ # @return [Hash{Symbol => Object}, nil] enclosure attributes with an absolute url
130
+ def build_media_enclosure(src, type)
131
+ found = ::Html2rss::Html::ArticleRules::Enclosure.from_media(src, (type if type.is_a?(String)),
132
+ base_url || src)
133
+ found if found&.dig(:url)&.absolute?
134
+ rescue Url::InvalidUrlError
135
+ nil
136
+ end
137
+
138
+ # @param value [Object] url candidate
139
+ # @return [Html2rss::Url, nil] absolute url, or nil when missing or unusable
140
+ def build_url(value)
141
+ return Url.from_relative(value, base_url || value) if value.is_a?(String) && !value.empty?
142
+
143
+ Log.debug("Schema#Thing.url: no usable url in schema_object: #{schema_object.inspect}")
144
+ nil
145
+ rescue Url::InvalidUrlError
146
+ Log.debug("Schema#Thing.url: unusable url #{value.inspect}")
147
+ nil
148
+ end
149
+
150
+ # @return [Html2rss::Url, nil] first absolute http(s) mainEntityOfPage or @id, without fragment
151
+ def fallback_url
152
+ main = schema_object[:mainEntityOfPage]
153
+ candidates = main.is_a?(Hash) ? [main[:@id], main[:url]] : [main]
154
+ (candidates + [schema_object[:@id]]).lazy.filter_map { web_url(_1) }.first
155
+ end
156
+
157
+ # @param value [Object] url candidate
158
+ # @return [Html2rss::Url, nil] fragment-free url when value is an absolute http(s) string
159
+ def web_url(value)
160
+ return unless value.is_a?(String) && value.match?(%r{\Ahttps?://}i)
161
+
162
+ Url.from_absolute(value.strip).without_fragment
163
+ rescue Url::InvalidUrlError
164
+ nil
165
+ end
166
+
167
+ # @param value [Object] schema value that may be wrapped in a list (multilingual or multi-valued fields)
168
+ # @return [Object, nil] the value, or the first non-empty String of a list
169
+ def first_scalar(value)
170
+ return value unless value.is_a?(Array)
171
+
172
+ value.find { _1.is_a?(String) && !_1.strip.empty? }
173
+ end
174
+
101
175
  # @param obj [String, Hash, Array, nil] schema author or publisher field
102
176
  # @return [String, nil] extracted author name
103
177
  def extract_author_name(obj)
@@ -110,12 +184,15 @@ module Html2rss
110
184
  end
111
185
  end
112
186
 
113
- # @param obj [String, Hash, nil] image node
114
- # @return [String, nil] image URL string
187
+ # @param obj [String, Hash, Array, nil] image node or list of image nodes
188
+ # @return [String, nil] image URL string (first usable entry for lists)
115
189
  def image_url_from(obj)
116
190
  return obj if obj.is_a?(String)
191
+ return obj.filter_map { image_url_from(_1) }.first if obj.is_a?(Array)
117
192
  return unless obj.is_a?(Hash)
118
- return unless Schema.normalize_types(obj[:@type]).include?('ImageObject')
193
+
194
+ types = Schema.normalize_types(obj[:@type])
195
+ return unless types.empty? || types.include?('ImageObject')
119
196
 
120
197
  obj[:url] || obj[:contentUrl]
121
198
  end
@@ -129,7 +206,7 @@ module Html2rss
129
206
 
130
207
  norm_url = normalized_id_url(text, reference_url:)
131
208
  reference_url && norm_url.host == reference_url.host ? normalized_id_value(norm_url) : text
132
- rescue ArgumentError
209
+ rescue Html2rss::Url::InvalidUrlError
133
210
  text
134
211
  end
135
212
 
@@ -153,7 +230,7 @@ module Html2rss
153
230
  # @return [Html2rss::Url, nil] normalized absolute URL for schema resolution
154
231
  def normalized_base_url(url)
155
232
  Url.from_absolute(url) unless url.to_s.strip.empty?
156
- rescue ArgumentError
233
+ rescue Html2rss::Url::InvalidUrlError
157
234
  nil
158
235
  end
159
236
  end
@@ -25,7 +25,8 @@ module Html2rss
25
25
 
26
26
  # Canonical Schema.org type names keyed by folded wire forms (case-insensitive lookup).
27
27
  CANONICAL_BY_DOWNCASE = begin
28
- canonical_types = Thing::SUPPORTED_TYPES | ItemList::SUPPORTED_TYPES | DENIED_CONTAINER_TYPES | Set['Product']
28
+ canonical_types = Thing::SUPPORTED_TYPES | ItemList::SUPPORTED_TYPES | Thing::ITEM_LIST_TYPES |
29
+ DENIED_CONTAINER_TYPES | Set['Product']
29
30
  canonical_types.to_h { |type| [::Html2rss::Html::Probe.fold(type), type] }.freeze
30
31
  end.freeze
31
32
 
@@ -43,6 +44,9 @@ module Html2rss
43
44
  # Shared empty type set for nil/unsupported wire forms (avoids per-call Set.new).
44
45
  EMPTY_TYPES = Set.new.freeze
45
46
 
47
+ # Keys whose `{"@id": ...}` stubs are resolved against sibling nodes of the same JSON-LD document.
48
+ REFERENCE_KEYS = %i[author publisher image].freeze
49
+
46
50
  # @return [Symbol] scraper config key
47
51
  def self.options_key = :schema
48
52
 
@@ -84,6 +88,18 @@ module Html2rss
84
88
  end
85
89
  end
86
90
 
91
+ ##
92
+ # Like {from} for a script tag, but resolves `@id` stubs for {REFERENCE_KEYS} against the same document.
93
+ #
94
+ # @param script [Nokogiri::XML::Element] schema JSON-LD script tag
95
+ # @return [Array<Hash>] the schema_objects with resolved references
96
+ # @api private
97
+ def from_script(script)
98
+ document = parse_script_tag(script)
99
+ index = node_index(document)
100
+ from(document).map { |object| resolve_references(object, index) }
101
+ end
102
+
87
103
  # @param object [Hash{Symbol => Object}] schema candidate object
88
104
  # @return [Boolean] whether an extractor exists for the candidate object
89
105
  def supported_schema_object?(object)
@@ -139,6 +155,37 @@ module Html2rss
139
155
 
140
156
  private
141
157
 
158
+ # @param object [Hash, Array, Object] parsed JSON-LD value
159
+ # @param index [Hash{String => Hash}] accumulator of nodes (more than just `@id`) keyed by `@id`
160
+ # @return [Hash{String => Hash}] nodes keyed by `@id`
161
+ def node_index(object, index = {})
162
+ case object
163
+ when Hash
164
+ index[object[:@id]] ||= object if object[:@id].is_a?(String) && object.size > 1
165
+ object.each_value { node_index(_1, index) }
166
+ when Array then object.each { node_index(_1, index) }
167
+ end
168
+ index
169
+ end
170
+
171
+ # @param object [Hash{Symbol => Object}] schema object
172
+ # @param index [Hash{String => Hash}] nodes keyed by `@id`
173
+ # @return [Hash{Symbol => Object}] object with reference stubs replaced where a sibling node exists
174
+ def resolve_references(object, index)
175
+ object.to_h { |key, value| [key, REFERENCE_KEYS.include?(key) ? resolve_stub(value, index) : value] }
176
+ end
177
+
178
+ # @param value [Object] author, publisher or image value
179
+ # @param index [Hash{String => Hash}] nodes keyed by `@id`
180
+ # @return [Object] the referenced node for an `{"@id": ...}` stub, else the value unchanged
181
+ def resolve_stub(value, index)
182
+ case value
183
+ when Array then value.map { resolve_stub(_1, index) }
184
+ when Hash then value.keys == [:@id] ? index.fetch(value[:@id], value) : value
185
+ else value
186
+ end
187
+ end
188
+
142
189
  # @param hash [Hash] candidate schema object
143
190
  # @return [Array<Hash>] the object itself or nested supported objects
144
191
  def from_hash(hash)
@@ -200,7 +247,7 @@ module Html2rss
200
247
 
201
248
  def schema_objects
202
249
  ::Html2rss::Html::Probe.scripts(@parsed_body, ::Html2rss::Html::Probe::APPLICATION_LD_JSON)
203
- .flat_map { |tag| Schema.from(tag) }
250
+ .flat_map { |tag| Schema.from_script(tag) }
204
251
  end
205
252
 
206
253
  attr_reader :parsed_body, :url
@@ -26,8 +26,6 @@ module Html2rss
26
26
  return false unless parsed_body
27
27
 
28
28
  new(parsed_body, url: 'https://example.com').extractable?
29
- rescue ArgumentError
30
- false
31
29
  end
32
30
 
33
31
  # @param parsed_body [Nokogiri::HTML::Document, nil] parsed HTML (when +document:+ omitted)
@@ -57,8 +55,6 @@ module Html2rss
57
55
  # @return [Boolean]
58
56
  def extractable?
59
57
  ranked_segments.any?
60
- rescue ArgumentError
61
- false
62
58
  end
63
59
 
64
60
  private
@@ -69,21 +65,21 @@ module Html2rss
69
65
  entries = deduplicator.call(ranked_segments)
70
66
  entries.filter_map { |ranked| deduplicator.article_for(ranked) }
71
67
  end
72
- rescue ArgumentError
73
- []
74
68
  end
75
69
 
76
70
  def ranked_segments
77
- @ranked_segments ||= begin
78
- segments = Segmenter.call(
79
- document,
80
- base_url: @url,
81
- strategy: :semantic,
82
- permit_unanchored: @permit_unanchored,
83
- link_resolver:
84
- )
85
- Scoring::Engine.new(link_resolver:).rank_top(segments, limit: TOP_K)
86
- end
71
+ @ranked_segments ||= document ? rank_document : []
72
+ end
73
+
74
+ def rank_document
75
+ segments = Segmenter.call(
76
+ document,
77
+ base_url: @url,
78
+ strategy: :semantic,
79
+ permit_unanchored: @permit_unanchored,
80
+ link_resolver:
81
+ )
82
+ Scoring::Engine.new(link_resolver:).rank_top(segments, limit: TOP_K)
87
83
  end
88
84
 
89
85
  def link_resolver
@@ -149,10 +149,7 @@ module Html2rss
149
149
  def stale_date?(published_at)
150
150
  return false unless published_at
151
151
 
152
- time = Time.parse(published_at)
153
- (Time.now.utc - time) > (max_age_days * 86_400)
154
- rescue ArgumentError
155
- false
152
+ (Time.now.utc - Time.parse(published_at)) > (max_age_days * 86_400)
156
153
  end
157
154
  end
158
155
  end
@@ -7,7 +7,7 @@ module Html2rss
7
7
  module Scraper
8
8
  ##
9
9
  # Scrapes articles from XML sitemaps explicitly linked in HTML or discovered via standard sitemap paths.
10
- class Sitemap # rubocop:disable Metrics/ClassLength -- scraper + structured warn paths
10
+ class Sitemap
11
11
  include Enumerable
12
12
 
13
13
  # Selector for sitemap link tags in HTML head
@@ -65,13 +65,7 @@ module Html2rss
65
65
  def each
66
66
  return enum_for(:each) unless block_given?
67
67
 
68
- entries = fetch_and_parse_entries
69
- return if entries.empty?
70
-
71
- entries.each do |entry|
72
- article_hash = article_from_entry(entry)
73
- yield article_hash if article_hash
74
- end
68
+ fetch_and_parse_entries.each { |entry| yield article_from_entry(entry) }
75
69
  end
76
70
 
77
71
  private
@@ -137,7 +131,7 @@ module Html2rss
137
131
 
138
132
  # rubocop:disable-next Metrics/MethodLength -- structured fetch-failed warn
139
133
  def fetch_xml(target_url)
140
- return unless request_session && target_url
134
+ return unless request_session
141
135
 
142
136
  response = request_session.follow_up(url: target_url, relation: :auto_source, origin_url: url)
143
137
  response&.body
@@ -160,8 +154,6 @@ module Html2rss
160
154
  end
161
155
 
162
156
  def article_from_entry(entry)
163
- return unless entry.url
164
-
165
157
  {
166
158
  url: entry.url,
167
159
  title: entry.title,
@@ -154,7 +154,7 @@ module Html2rss
154
154
  end
155
155
 
156
156
  def archive_like?
157
- category_archive? || tag_archive? || author_archive? || date_archive? || body_classes.include?('archive')
157
+ body_classes.include?('archive')
158
158
  end
159
159
 
160
160
  def singular_like?
@@ -178,7 +178,7 @@ module Html2rss
178
178
 
179
179
  canonical_url = Html2rss::Url.from_relative(href, url)
180
180
  same_origin_url?(canonical_url, url) ? canonical_url : url
181
- rescue ArgumentError
181
+ rescue Html2rss::Url::InvalidUrlError
182
182
  url
183
183
  end
184
184
 
@@ -54,7 +54,7 @@ module Html2rss
54
54
  return log_missing_api_root if href.empty?
55
55
 
56
56
  Html2rss::Url.from_relative(href, page_url)
57
- rescue ArgumentError => error
57
+ rescue Html2rss::Url::InvalidUrlError => error
58
58
  logger.warn("#{WordpressApi}: invalid WordPress API endpoint #{href.inspect} (#{error.message})")
59
59
  nil
60
60
  end
@@ -110,13 +110,11 @@ module Html2rss
110
110
  def normalized_api_path(path)
111
111
  segments = path.to_s.split('/').reject(&:empty?)
112
112
  normalized_path = "/#{segments.join('/')}"
113
- normalized_path = '/' if normalized_path == '/'
114
113
  normalized_path.end_with?('/') ? normalized_path : "#{normalized_path}/"
115
114
  end
116
115
 
117
116
  def normalized_rest_route(route)
118
117
  value = route.to_s
119
- value = '/' if value.empty?
120
118
  value = "/#{value}" unless value.start_with?('/')
121
119
  trim_trailing_slashes(value)
122
120
  end
@@ -132,8 +132,8 @@ module Html2rss
132
132
  absolute_link(post[:link])
133
133
  end
134
134
 
135
- def article_id(_post, article_url)
136
- root_path_query_id(article_url) || present_string(article_url.path) || article_url.to_s
135
+ def article_id(article_url)
136
+ root_path_query_id(article_url) || present_string(article_url.path)
137
137
  end
138
138
 
139
139
  def article_title(post)
@@ -154,7 +154,7 @@ module Html2rss
154
154
 
155
155
  def article_attributes(post, article_url)
156
156
  {
157
- id: article_id(post, article_url),
157
+ id: article_id(article_url),
158
158
  title: article_title(post),
159
159
  description: article_description(post),
160
160
  url: article_url,
@@ -168,7 +168,7 @@ module Html2rss
168
168
  return unless value
169
169
 
170
170
  Html2rss::Url.from_relative(value, url)
171
- rescue ArgumentError
171
+ rescue Html2rss::Url::InvalidUrlError
172
172
  nil
173
173
  end
174
174
 
@@ -35,7 +35,7 @@ module Html2rss
35
35
 
36
36
  # @return [Boolean] true when any captured body contains article-like arrays
37
37
  def extractable?
38
- parsed_bodies.any? { |doc| JsonState::CandidateDetector.candidate_array?(doc) }
38
+ parsed_bodies.any?
39
39
  end
40
40
 
41
41
  # @yield [Hash{Symbol => Object}] normalized article hash
@@ -117,15 +117,6 @@ module Html2rss
117
117
  HEURISTIC_SCRAPERS.intersect?(tier)
118
118
  end
119
119
 
120
- ##
121
- # @param parsed_body [Nokogiri::HTML::Document]
122
- # @return [SST::Document, nil]
123
- def self.normalize_sst(parsed_body)
124
- SST::Normalizer.call(parsed_body)
125
- rescue SST::Normalizer::EmptyTree
126
- nil
127
- end
128
-
129
120
  ##
130
121
  # Builds a scraper when enabled; returns nil when disabled.
131
122
  #
@@ -220,8 +211,6 @@ module Html2rss
220
211
  private_class_method :app_shell_surface?
221
212
 
222
213
  def self.high_entropy_surface?(parsed_body)
223
- return false if parsed_body.nil?
224
-
225
214
  parsed_body.css('body a[href]').size >= HIGH_ENTROPY_MIN_ANCHORS
226
215
  end
227
216
  private_class_method :high_entropy_surface?
@@ -7,12 +7,10 @@ module Html2rss
7
7
  # Class / structure clustering for anchorless pages
8
8
  # (port of Discovery::DomClustering + OverlapResolver; group scoring owned by Scoring::ClusterScorer).
9
9
  module Cluster
10
- module_function
11
-
12
10
  ##
13
11
  # @param segmenter [Segmenter]
14
12
  # @return [Array<Segment>]
15
- def call(segmenter)
13
+ def self.call(segmenter)
16
14
  primary = PrimaryLink.new(segmenter)
17
15
  nodes = best_group_nodes(segmenter)
18
16
  nodes.each_with_index.filter_map do |node, position|
@@ -23,7 +21,7 @@ module Html2rss
23
21
  end
24
22
  end
25
23
 
26
- def best_group_nodes(segmenter)
24
+ def self.best_group_nodes(segmenter)
27
25
  groups = collect_groups(segmenter)
28
26
  return [] if groups.empty?
29
27
 
@@ -33,10 +31,9 @@ module Html2rss
33
31
  final_groups = resolver.filter_1_to_1_overlap(non_containers)
34
32
  scorer.select_best_group(final_groups)
35
33
  end
36
- module_function :best_group_nodes
37
34
  private_class_method :best_group_nodes
38
35
 
39
- def collect_groups(segmenter) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength
36
+ def self.collect_groups(segmenter) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength
40
37
  class_groups = Hash.new { |hash, key| hash[key] = [] }
41
38
  structure_groups = Hash.new { |hash, key| hash[key] = [] }
42
39
  min = segmenter.minimum_selector_frequency
@@ -57,24 +54,21 @@ module Html2rss
57
54
 
58
55
  structure_groups.select { |_, nodes| nodes.size >= min }
59
56
  end
60
- module_function :collect_groups
61
57
  private_class_method :collect_groups
62
58
 
63
- def normalize_class(class_names)
59
+ def self.normalize_class(class_names)
64
60
  return '' if class_names.empty?
65
61
 
66
62
  class_names.map { ::Html2rss::Html::Probe.fold(_1) }.sort.join(' ')
67
63
  end
68
- module_function :normalize_class
69
64
  private_class_method :normalize_class
70
65
 
71
- def structure_signature(node)
66
+ def self.structure_signature(node)
72
67
  kids = node.children
73
68
  return '' if kids.empty?
74
69
 
75
70
  kids.map { |child| child.name.to_s }.join('>')
76
71
  end
77
- module_function :structure_signature
78
72
  private_class_method :structure_signature
79
73
 
80
74
  ##