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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8d37540b2f300af8a1db631e16cf6cabf4cfa76069000a71c7723737225bd6c3
4
- data.tar.gz: bd8f656ae629587336257d790da183c759b9c709c1ba411e5c0041156d35dd6b
3
+ metadata.gz: 5e2ebceddb0e3a74ddfcb848eb78f454061a4208b06dbc5443349b102d7edcd6
4
+ data.tar.gz: eeaaa250afd8e6b991bbc112470cd07da4285649f9a7df9c0c8d57b90d67ec8d
5
5
  SHA512:
6
- metadata.gz: 802b88abad9bbadfa6753a5c62694a66c6eec3774e84d85191beabdfdd2bc8df3e37fdf325bc14c1d6fda0b90528aca2484df15d0d985b6e328c07f4c27a49b9
7
- data.tar.gz: 827d398c34732c10600ced1002525702392448fcb4e8b585f7c27048c8fae38e9c2652307705c8a91e82fba295dbbfa8bfb0cc4dc3a71c4d70cdf9773722a4cf
6
+ metadata.gz: c71a7060557c917c1022fb03ea02d73dfbf86a9f9db1edf4b8a46f5855801a721236382346326d34b91029a2c9b902e53d81c11b650de67910fcc21edfa99ece
7
+ data.tar.gz: 64511f26248a9a713cadefe2efae797b243b097063f6170b0e39b5751d38a4b2cda12ab90730e5e87e44129d308618dbd88ff62c704a4ca3e08bdd86d8053dac
@@ -16,12 +16,8 @@ module Html2rss
16
16
  def self.guess_content_type_from_url(url, default: 'application/octet-stream')
17
17
  return default unless url
18
18
 
19
- path = url.path
20
- ext = File.extname(path)
21
- ext = ext[1..] if ext.start_with?('.')
22
-
23
- content_type = MIME::Types.type_for(ext)
24
- content_type.first&.to_s || 'application/octet-stream'
19
+ found = MIME::Types.type_for(url.path).first
20
+ found&.content_type || default
25
21
  end
26
22
 
27
23
  # @param url [Html2rss::Url] absolute enclosure URL
@@ -234,8 +234,6 @@ module Html2rss
234
234
  end
235
235
 
236
236
  def excluded_destination?(url)
237
- return false unless url
238
-
239
237
  facts = LinkDestination::DestinationFacts.build(url)
240
238
  return true if facts.high_confidence_junk_path || facts.high_confidence_utility_destination
241
239
 
@@ -286,7 +284,7 @@ module Html2rss
286
284
  def item_url(item)
287
285
  raw = item.url
288
286
  raw.nil? || raw.to_s.empty? ? nil : Html2rss::Url.from_absolute(raw.to_s)
289
- rescue ArgumentError
287
+ rescue Html2rss::Url::InvalidUrlError
290
288
  nil
291
289
  end
292
290
 
@@ -34,8 +34,6 @@ module Html2rss
34
34
  return false unless parsed_body
35
35
 
36
36
  new(parsed_body, url: DETECTION_BASE_URL).extractable?
37
- rescue ArgumentError
38
- false
39
37
  end
40
38
 
41
39
  # @param parsed_body [Nokogiri::HTML::Document, nil] parsed HTML (when +document:+ omitted)
@@ -68,8 +66,6 @@ module Html2rss
68
66
  # @return [Boolean]
69
67
  def extractable?
70
68
  ranked_segments.any?
71
- rescue ArgumentError
72
- false
73
69
  end
74
70
 
75
71
  private
@@ -83,8 +79,6 @@ module Html2rss
83
79
  materialize(ranked)
84
80
  end
85
81
  end
86
- rescue ArgumentError
87
- []
88
82
  end
89
83
 
90
84
  def ranked_segments
@@ -96,11 +90,11 @@ module Html2rss
96
90
  end
97
91
 
98
92
  def list_ranked
99
- @list_ranked ||= rank_strategy(:list, permit_unanchored: false)
93
+ @list_ranked ||= document ? rank_strategy(:list, permit_unanchored: false) : []
100
94
  end
101
95
 
102
96
  def cluster_ranked
103
- @cluster_ranked ||= rank_strategy(:cluster, permit_unanchored: true)
97
+ @cluster_ranked ||= document ? rank_strategy(:cluster, permit_unanchored: true) : []
104
98
  end
105
99
 
106
100
  def rank_strategy(strategy, permit_unanchored:)
@@ -47,7 +47,6 @@ module Html2rss
47
47
  # @return [Enumerator, void] article enumerator when no block is given
48
48
  def each(&block)
49
49
  return enum_for(:each) unless block
50
- return unless extractable?
51
50
 
52
51
  if json_feed?
53
52
  each_json_feed_item(&block)
@@ -103,7 +102,7 @@ module Html2rss
103
102
  return unless str
104
103
 
105
104
  Url.from_relative(str, @url)
106
- rescue ArgumentError
105
+ rescue Html2rss::Url::InvalidUrlError
107
106
  nil
108
107
  end
109
108
 
@@ -112,7 +111,7 @@ module Html2rss
112
111
  return unless str
113
112
 
114
113
  Url.from_absolute(str)
115
- rescue ArgumentError
114
+ rescue Html2rss::Url::InvalidUrlError
116
115
  nil
117
116
  end
118
117
 
@@ -28,149 +28,149 @@ module Html2rss
28
28
  # Snake `_at`/`_on` or camel `At`/`On` suffix after a closed published-at miss.
29
29
  DATE_KEY_SUFFIX = /_at\z|_on\z|[a-z]At\z|[a-z]On\z/
30
30
 
31
- module_function
32
-
33
- # rubocop:disable Metrics/MethodLength
34
- # @param entry [Hash] raw article entry candidate
35
- # @param base_url [String, Html2rss::Url] base URL for relative link resolution
36
- # @return [Hash{Symbol => Object, nil}] normalized article hash for downstream extraction
37
- def normalise(entry, base_url:)
38
- return unless entry.is_a?(Hash)
39
-
40
- title = string(ValueFinder.fetch(entry, TITLE_KEYS))
41
- description = string(ValueFinder.fetch(entry, DESCRIPTION_KEYS))
42
- article_url = resolve_link(entry, keys: URL_KEYS, base_url:,
43
- log_key: 'JsonState: invalid URL encountered')
44
- return unless article_url
45
- return if title.nil? && description.nil?
46
-
47
- {
48
- title:,
49
- description:,
50
- url: article_url,
51
- image: resolve_link(entry, keys: IMAGE_KEYS, base_url:,
52
- log_key: 'JsonState: invalid image URL encountered'),
53
- published_at: published_at(entry),
54
- categories: categories(entry),
55
- id: identifier(entry, article_url)
56
- }.compact
57
- end
58
- # rubocop:enable Metrics/MethodLength
59
-
60
- # @param value [Object] candidate scalar, or Hash wrapping +rendered+/+value+/+text+
61
- # @return [String, nil] normalized non-empty string (HTML preserved for descriptions)
62
- def string(value)
63
- value = unwrap_scalar(value)
64
- return unless value.is_a?(String) || value.is_a?(Numeric)
65
-
66
- trimmed = value.to_s.strip
67
- trimmed unless trimmed.empty?
68
- end
31
+ class << self
32
+ # rubocop:disable Metrics/MethodLength
33
+ # @param entry [Hash] raw article entry candidate
34
+ # @param base_url [String, Html2rss::Url] base URL for relative link resolution
35
+ # @return [Hash{Symbol => Object, nil}] normalized article hash for downstream extraction
36
+ def normalise(entry, base_url:)
37
+ return unless entry.is_a?(Hash)
38
+
39
+ title = string(ValueFinder.fetch(entry, TITLE_KEYS))
40
+ description = string(ValueFinder.fetch(entry, DESCRIPTION_KEYS))
41
+ article_url = resolve_link(entry, keys: URL_KEYS, base_url:,
42
+ log_key: 'JsonState: invalid URL encountered')
43
+ return unless article_url
44
+ return if title.nil? && description.nil?
45
+
46
+ {
47
+ title:,
48
+ description:,
49
+ url: article_url,
50
+ image: resolve_link(entry, keys: IMAGE_KEYS, base_url:,
51
+ log_key: 'JsonState: invalid image URL encountered'),
52
+ published_at: published_at(entry),
53
+ categories: categories(entry),
54
+ id: identifier(entry, article_url)
55
+ }.compact
56
+ end
57
+ # rubocop:enable Metrics/MethodLength
69
58
 
70
- # @param value [Object] candidate scalar or nested Hash
71
- # @return [Object, nil] String/Numeric after at most {MAX_URL_UNWRAP} Hash unwraps
72
- def unwrap_scalar(value)
73
- depth = 0
74
- while value.is_a?(Hash) && depth < MAX_URL_UNWRAP
75
- nested = SCALAR_KEYS.find { |key| value.key?(key) }
76
- break unless nested
59
+ # @param value [Object] candidate scalar, or Hash wrapping +rendered+/+value+/+text+
60
+ # @return [String, nil] normalized non-empty string (HTML preserved for descriptions)
61
+ def string(value)
62
+ value = unwrap_scalar(value)
63
+ return unless value.is_a?(String) || value.is_a?(Numeric)
77
64
 
78
- value = value[nested]
79
- depth += 1
65
+ trimmed = value.to_s.strip
66
+ trimmed unless trimmed.empty?
80
67
  end
81
- value unless value.is_a?(Hash) || value.is_a?(Array)
82
- end
83
68
 
84
- # @param entry [Hash] raw article entry candidate
85
- # @param keys [Array<Symbol>] preferred link keys
86
- # @param base_url [String, Html2rss::Url] base URL for relative link resolution
87
- # @param log_key [String] structured log message key
88
- # @return [Html2rss::Url, nil] resolved absolute URL
89
- def resolve_link(entry, keys:, base_url:, log_key:)
90
- value = unwrap_url(ValueFinder.fetch(entry, keys), keys) ||
91
- unwrap_url(ValueFinder.fetch(entry[:links], keys), keys)
92
- string = string(value)
93
- return unless string
94
-
95
- Url.from_relative(string, base_url)
96
- rescue ArgumentError
97
- Log.debug("#{log_key}: url=#{string}")
98
- nil
99
- end
69
+ # @param value [Object] candidate scalar or nested Hash
70
+ # @return [Object, nil] String/Numeric after at most {MAX_URL_UNWRAP} Hash unwraps
71
+ def unwrap_scalar(value)
72
+ depth = 0
73
+ while value.is_a?(Hash) && depth < MAX_URL_UNWRAP
74
+ nested = SCALAR_KEYS.find { |key| value.key?(key) }
75
+ break unless nested
100
76
 
101
- # @param value [Object] candidate URL value, possibly a nested Hash
102
- # @param keys [Array<Symbol>] preferred link keys
103
- # @return [Object, nil] scalar URL candidate after at most {MAX_URL_UNWRAP} Hash unwraps
104
- def unwrap_url(value, keys)
105
- depth = 0
106
- while value.is_a?(Hash) && depth < MAX_URL_UNWRAP
107
- value = ValueFinder.fetch(value, keys)
108
- depth += 1
77
+ value = value[nested]
78
+ depth += 1
79
+ end
80
+ value unless value.is_a?(Hash) || value.is_a?(Array)
109
81
  end
110
- value unless value.is_a?(Hash)
111
- end
112
82
 
113
- # @param entry [Hash] raw article entry candidate
114
- # @return [String, nil] publication timestamp string or unix timestamp digits
115
- def published_at(entry)
116
- value = ValueFinder.fetch(entry, PUBLISHED_AT_KEYS)
117
- string(value) || string(suffix_date_value(entry))
118
- end
119
-
120
- # @param hash [Hash, nil] entry or nested attributes/data hash
121
- # @return [Object, nil] first string or numeric value whose key matches {DATE_KEY_SUFFIX}
122
- def suffix_date_value(hash)
123
- return unless hash.is_a?(Hash)
83
+ # @param entry [Hash] raw article entry candidate
84
+ # @param keys [Array<Symbol>] preferred link keys
85
+ # @param base_url [String, Html2rss::Url] base URL for relative link resolution
86
+ # @param log_key [String] structured log message key
87
+ # @return [Html2rss::Url, nil] resolved absolute URL
88
+ def resolve_link(entry, keys:, base_url:, log_key:)
89
+ value = unwrap_url(ValueFinder.fetch(entry, keys), keys) ||
90
+ unwrap_url(ValueFinder.fetch(entry[:links], keys), keys)
91
+ string = string(value)
92
+ return unless string
93
+
94
+ Url.from_relative(string, base_url)
95
+ rescue Html2rss::Url::InvalidUrlError
96
+ Log.debug("#{log_key}: url=#{string}")
97
+ nil
98
+ end
124
99
 
125
- hash.each do |key, value|
126
- next unless date_suffix_key?(key)
127
- next unless value.is_a?(String) || value.is_a?(Numeric)
100
+ # @param value [Object] candidate URL value, possibly a nested Hash
101
+ # @param keys [Array<Symbol>] preferred link keys
102
+ # @return [Object, nil] scalar URL candidate after at most {MAX_URL_UNWRAP} Hash unwraps
103
+ def unwrap_url(value, keys)
104
+ depth = 0
105
+ while value.is_a?(Hash) && depth < MAX_URL_UNWRAP
106
+ value = ValueFinder.fetch(value, keys)
107
+ depth += 1
108
+ end
109
+ value unless value.is_a?(Hash)
110
+ end
128
111
 
129
- return value
112
+ # @param entry [Hash] raw article entry candidate
113
+ # @return [String, nil] publication timestamp string or unix timestamp digits
114
+ def published_at(entry)
115
+ value = ValueFinder.fetch(entry, PUBLISHED_AT_KEYS)
116
+ string(value) || string(suffix_date_value(entry))
130
117
  end
131
118
 
132
- suffix_date_value(hash[:attributes]) || suffix_date_value(hash[:data])
133
- end
119
+ # @param hash [Hash, nil] entry or nested attributes/data hash
120
+ # @return [Object, nil] first string or numeric value whose key matches {DATE_KEY_SUFFIX}
121
+ def suffix_date_value(hash)
122
+ return unless hash.is_a?(Hash)
134
123
 
135
- # @param key [Symbol, String] candidate published-at key
136
- # @return [Boolean] whether the key ends in a date suffix (_at/_on/At/On)
137
- def date_suffix_key?(key)
138
- key.to_s.match?(DATE_KEY_SUFFIX)
139
- end
124
+ hash.each do |key, value|
125
+ next unless date_suffix_key?(key)
126
+ next unless value.is_a?(String) || value.is_a?(Numeric)
140
127
 
141
- # rubocop:disable Metrics/MethodLength
142
- # @param entry [Hash] raw article entry candidate
143
- # @return [Array<String>, nil] normalized unique categories
144
- def categories(entry)
145
- raw = ValueFinder.fetch(entry, CATEGORY_KEYS)
146
- names = case raw
147
- when Array then raw
148
- when Hash then raw.values
149
- when String then [raw]
150
- else []
151
- end
152
-
153
- result = names.flat_map do |value|
154
- case value
155
- when Hash
156
- string(ValueFinder.fetch(value, %i[name title label]))
157
- else
158
- string(value)
128
+ return value
159
129
  end
160
- end.compact
161
130
 
162
- result.uniq!
163
- result unless result.empty?
164
- end
165
- # rubocop:enable Metrics/MethodLength
166
-
167
- # @param entry [Hash] raw article entry candidate
168
- # @param article_url [Html2rss::Url] resolved article URL
169
- # @return [String] stable article identifier fallbacking to resolved URL
170
- def identifier(entry, article_url)
171
- value = ValueFinder.fetch(entry, ID_KEYS)
172
- value = ValueFinder.fetch(value, ID_KEYS) if value.is_a?(Hash)
173
- string(value) || article_url.to_s
131
+ suffix_date_value(hash[:attributes]) || suffix_date_value(hash[:data])
132
+ end
133
+
134
+ # @param key [Symbol, String] candidate published-at key
135
+ # @return [Boolean] whether the key ends in a date suffix (_at/_on/At/On)
136
+ def date_suffix_key?(key)
137
+ key.to_s.match?(DATE_KEY_SUFFIX)
138
+ end
139
+
140
+ # rubocop:disable Metrics/MethodLength
141
+ # @param entry [Hash] raw article entry candidate
142
+ # @return [Array<String>, nil] normalized unique categories
143
+ def categories(entry)
144
+ raw = ValueFinder.fetch(entry, CATEGORY_KEYS)
145
+ names = case raw
146
+ when Array then raw
147
+ when Hash then raw.values
148
+ when String then [raw]
149
+ else []
150
+ end
151
+
152
+ result = names.flat_map do |value|
153
+ case value
154
+ when Hash
155
+ string(ValueFinder.fetch(value, %i[name title label]))
156
+ else
157
+ string(value)
158
+ end
159
+ end.compact
160
+
161
+ result.uniq!
162
+ result unless result.empty?
163
+ end
164
+ # rubocop:enable Metrics/MethodLength
165
+
166
+ # @param entry [Hash] raw article entry candidate
167
+ # @param article_url [Html2rss::Url] resolved article URL
168
+ # @return [String] stable article identifier fallbacking to resolved URL
169
+ def identifier(entry, article_url)
170
+ value = ValueFinder.fetch(entry, ID_KEYS)
171
+ value = ValueFinder.fetch(value, ID_KEYS) if value.is_a?(Hash)
172
+ string(value) || article_url.to_s
173
+ end
174
174
  end
175
175
  end
176
176
  end
@@ -6,51 +6,51 @@ module Html2rss
6
6
  class JsonState
7
7
  # Identifies arrays that look like collections of article hashes.
8
8
  module CandidateDetector
9
- module_function
10
-
11
- # @param document [Hash, Array, Object] candidate document node
12
- # @return [Boolean] whether the node contains article-like arrays
13
- def candidate_array?(document)
14
- case document
15
- when Array
16
- return true if array_of_articles?(document)
17
-
18
- document.any? { traversable_candidate?(_1) }
19
- when Hash then document.each_value.any? { candidate_array?(_1) }
20
- else false
9
+ class << self
10
+ # @param document [Hash, Array, Object] candidate document node
11
+ # @return [Boolean] whether the node contains article-like arrays
12
+ def candidate_array?(document)
13
+ case document
14
+ when Array
15
+ return true if array_of_articles?(document)
16
+
17
+ document.any? { traversable_candidate?(_1) }
18
+ when Hash then document.each_value.any? { candidate_array?(_1) }
19
+ else false
20
+ end
21
21
  end
22
- end
23
22
 
24
- # @param value [Hash, Array, Object] candidate nested value
25
- # @return [Boolean] whether nested value should be traversed for article candidates
26
- def traversable_candidate?(value)
27
- case value
28
- when Array, Hash then candidate_array?(value)
29
- else false
23
+ # @param value [Hash, Array, Object] candidate nested value
24
+ # @return [Boolean] whether nested value should be traversed for article candidates
25
+ def traversable_candidate?(value)
26
+ case value
27
+ when Array, Hash then candidate_array?(value)
28
+ else false
29
+ end
30
30
  end
31
- end
32
31
 
33
- # @param array [Array<Object>] candidate list of entries
34
- # @return [Boolean] whether array includes hash entries with title and URL fields
35
- def array_of_articles?(array)
36
- array.any? do |element|
37
- next unless element.is_a?(Hash)
32
+ # @param array [Array<Object>] candidate list of entries
33
+ # @return [Boolean] whether array includes hash entries with title and URL fields
34
+ def array_of_articles?(array)
35
+ array.any? do |element|
36
+ next unless element.is_a?(Hash)
38
37
 
39
- title_from(element) && url_from(element)
38
+ title_from(element) && url_from(element)
39
+ end
40
40
  end
41
- end
42
41
 
43
- # @param object [Hash] article candidate object
44
- # @return [Object, nil] detected title-like value
45
- def title_from(object)
46
- ValueFinder.fetch(object, ArticleNormalizer::TITLE_KEYS)
47
- end
42
+ # @param object [Hash] article candidate object
43
+ # @return [Object, nil] detected title-like value
44
+ def title_from(object)
45
+ ValueFinder.fetch(object, ArticleNormalizer::TITLE_KEYS)
46
+ end
48
47
 
49
- # @param object [Hash] article candidate object
50
- # @return [Object, nil] detected URL-like value (including links.self)
51
- def url_from(object)
52
- ValueFinder.fetch(object, ArticleNormalizer::URL_KEYS) ||
53
- ValueFinder.fetch(object[:links], ArticleNormalizer::URL_KEYS)
48
+ # @param object [Hash] article candidate object
49
+ # @return [Object, nil] detected URL-like value (including links.self)
50
+ def url_from(object)
51
+ ValueFinder.fetch(object, ArticleNormalizer::URL_KEYS) ||
52
+ ValueFinder.fetch(object[:links], ArticleNormalizer::URL_KEYS)
53
+ end
54
54
  end
55
55
  end
56
56
  end