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
@@ -8,8 +8,15 @@ module Html2rss
8
8
  module ArticleNormalizer # rubocop:disable Metrics/ModuleLength -- field keys + URL/scalar unwrap stay co-located
9
9
  # Preferred keys when extracting title-like values from state payloads.
10
10
  TITLE_KEYS = %i[title headline name text].freeze
11
+ # Title keys that mark an article; a bare +name+ also labels people and other entities.
12
+ ARTICLE_TITLE_KEYS = (TITLE_KEYS - %i[name]).freeze
13
+ # Explicit kicker field names (app-state +overline+/+kickerText+, German CMS +topline+/+dachzeile+) that
14
+ # sit beside a headline. The one closed list: no standard Schema/JSON-LD field exists, so none is guessed.
15
+ KICKER_KEYS = %i[overline kicker kickerText topline dachzeile eyebrow].freeze
11
16
  # Preferred keys when extracting URL-like values from state payloads.
12
- URL_KEYS = %i[url link href permalink slug path canonicalUrl shortUrl alias self].freeze
17
+ URL_KEYS = %i[url link href permalink slug path canonicalUrl shortUrl alias self links].freeze
18
+ # URL keys an entry itself can own; +links+ is an envelope sibling of the node, never the entry's own URL.
19
+ DIRECT_URL_KEYS = (URL_KEYS - %i[links]).freeze
13
20
  # Preferred keys when extracting description-like values from state payloads.
14
21
  DESCRIPTION_KEYS = %i[description summary excerpt dek subheading abstract].freeze
15
22
  # Preferred keys when extracting image-like values from state payloads.
@@ -27,6 +34,8 @@ module Html2rss
27
34
  SCALAR_KEYS = %i[rendered value text].freeze
28
35
  # Snake `_at`/`_on` or camel `At`/`On` suffix after a closed published-at miss.
29
36
  DATE_KEY_SUFFIX = /_at\z|_on\z|[a-z]At\z|[a-z]On\z/
37
+ # Levels below a wrapper entry searched for its article node.
38
+ MAX_NODE_DEPTH = 2
30
39
 
31
40
  class << self
32
41
  # rubocop:disable Metrics/MethodLength
@@ -34,28 +43,60 @@ module Html2rss
34
43
  # @param base_url [String, Html2rss::Url] base URL for relative link resolution
35
44
  # @return [Hash{Symbol => Object, nil}] normalized article hash for downstream extraction
36
45
  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')
46
+ sources = article_sources(entry) # nil (skipped entry) yields no URL below
47
+ title = string(ValueFinder.fetch(sources, TITLE_KEYS))
48
+ kicker = title && string(ValueFinder.fetch(sources, KICKER_KEYS))
49
+ description = string(ValueFinder.fetch(sources, DESCRIPTION_KEYS))
50
+ article_url = resolve_link(sources, keys: URL_KEYS, base_url:,
51
+ log_key: 'JsonState: invalid URL encountered')
43
52
  return unless article_url
44
53
  return if title.nil? && description.nil?
45
54
 
46
55
  {
47
56
  title:,
57
+ kicker:,
48
58
  description:,
49
59
  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)
60
+ image: resolve_link(sources, keys: IMAGE_KEYS, base_url:,
61
+ log_key: 'JsonState: invalid image URL encountered'),
62
+ published_at: published_at(sources),
63
+ categories: categories(sources),
64
+ id: identifier(sources, article_url)
55
65
  }.compact
56
66
  end
57
67
  # rubocop:enable Metrics/MethodLength
58
68
 
69
+ # Locates the hashes holding an entry's article fields, article node first.
70
+ #
71
+ # An entry with its own title or description is its own article node. A wrapper entry
72
+ # delegates to the only nested Hash (up to {MAX_NODE_DEPTH} levels down) carrying a title
73
+ # and URL, else to the only nested Hash carrying a title; the entry stays the fallback
74
+ # source (e.g. an envelope's sibling +links+). A wrapper entry with its own URL is the
75
+ # link: it delegates only to the only nested Hash carrying an article title (never a bare
76
+ # +name+), and its URL wins over the node's. An entry whose only URL is +links+ is not that
77
+ # link: the node leads and +links+ stays a fallback. Competing nested nodes skip the entry, so an
78
+ # author object holding a name and URL never becomes the article.
79
+ #
80
+ # @param entry [Object] raw article entry candidate
81
+ # @return [Array<Hash>, nil] field sources in lookup order; nil when not a Hash or ambiguous
82
+ def article_sources(entry)
83
+ return unless entry.is_a?(Hash)
84
+ return [entry] if text?(entry)
85
+
86
+ nodes = candidate_nodes(entry)
87
+ return [entry] if nodes.empty?
88
+ return unless nodes.one?
89
+
90
+ node = nodes.first
91
+ own_url?(entry) && url?(node) ? [entry, node] : [node, entry]
92
+ end
93
+
94
+ # @param sources [Hash, Array<Hash>, nil] entry or field sources from {article_sources}
95
+ # @return [Boolean] whether the sources hold a title and a URL value
96
+ def linked?(sources)
97
+ title?(sources) && url?(sources)
98
+ end
99
+
59
100
  # @param value [Object] candidate scalar, or Hash wrapping +rendered+/+value+/+text+
60
101
  # @return [String, nil] normalized non-empty string (HTML preserved for descriptions)
61
102
  def string(value)
@@ -80,15 +121,13 @@ module Html2rss
80
121
  value unless value.is_a?(Hash) || value.is_a?(Array)
81
122
  end
82
123
 
83
- # @param entry [Hash] raw article entry candidate
124
+ # @param sources [Hash, Array<Hash>] entry or field sources from {article_sources}
84
125
  # @param keys [Array<Symbol>] preferred link keys
85
126
  # @param base_url [String, Html2rss::Url] base URL for relative link resolution
86
127
  # @param log_key [String] structured log message key
87
128
  # @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)
129
+ def resolve_link(sources, keys:, base_url:, log_key:)
130
+ string = string(unwrap_url(ValueFinder.fetch(sources, keys), keys))
92
131
  return unless string
93
132
 
94
133
  Url.from_relative(string, base_url)
@@ -97,38 +136,36 @@ module Html2rss
97
136
  nil
98
137
  end
99
138
 
100
- # @param value [Object] candidate URL value, possibly a nested Hash
139
+ # @param value [Object] candidate URL value, possibly a nested Hash or a links Array
101
140
  # @param keys [Array<Symbol>] preferred link keys
102
- # @return [Object, nil] scalar URL candidate after at most {MAX_URL_UNWRAP} Hash unwraps
141
+ # @return [Object, nil] scalar URL candidate after at most {MAX_URL_UNWRAP} unwraps
103
142
  def unwrap_url(value, keys)
104
143
  depth = 0
105
- while value.is_a?(Hash) && depth < MAX_URL_UNWRAP
144
+ while (value.is_a?(Hash) || value.is_a?(Array)) && depth < MAX_URL_UNWRAP
106
145
  value = ValueFinder.fetch(value, keys)
107
146
  depth += 1
108
147
  end
109
- value unless value.is_a?(Hash)
148
+ value unless value.is_a?(Hash) || value.is_a?(Array)
110
149
  end
111
150
 
112
- # @param entry [Hash] raw article entry candidate
151
+ # @param sources [Hash, Array<Hash>] entry or field sources from {article_sources}
113
152
  # @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))
153
+ def published_at(sources)
154
+ value = ValueFinder.fetch(sources, PUBLISHED_AT_KEYS)
155
+ string(value) || string(suffix_date_value(sources))
117
156
  end
118
157
 
119
- # @param hash [Hash, nil] entry or nested attributes/data hash
158
+ # @param sources [Hash, Array<Hash>, nil] entry or field sources, searched in order
120
159
  # @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)
123
-
124
- hash.each do |key, value|
125
- next unless date_suffix_key?(key)
126
- next unless value.is_a?(String) || value.is_a?(Numeric)
127
-
128
- return value
160
+ def suffix_date_value(sources)
161
+ case sources
162
+ when Hash
163
+ sources.each do |key, value|
164
+ return value if date_suffix_key?(key) && (value.is_a?(String) || value.is_a?(Numeric))
165
+ end
166
+ nil
167
+ when Array then sources.filter_map { suffix_date_value(_1) }.first
129
168
  end
130
-
131
- suffix_date_value(hash[:attributes]) || suffix_date_value(hash[:data])
132
169
  end
133
170
 
134
171
  # @param key [Symbol, String] candidate published-at key
@@ -138,10 +175,10 @@ module Html2rss
138
175
  end
139
176
 
140
177
  # rubocop:disable Metrics/MethodLength
141
- # @param entry [Hash] raw article entry candidate
178
+ # @param sources [Hash, Array<Hash>] entry or field sources from {article_sources}
142
179
  # @return [Array<String>, nil] normalized unique categories
143
- def categories(entry)
144
- raw = ValueFinder.fetch(entry, CATEGORY_KEYS)
180
+ def categories(sources)
181
+ raw = ValueFinder.fetch(sources, CATEGORY_KEYS)
145
182
  names = case raw
146
183
  when Array then raw
147
184
  when Hash then raw.values
@@ -163,14 +200,54 @@ module Html2rss
163
200
  end
164
201
  # rubocop:enable Metrics/MethodLength
165
202
 
166
- # @param entry [Hash] raw article entry candidate
203
+ # @param sources [Hash, Array<Hash>] entry or field sources from {article_sources}
167
204
  # @param article_url [Html2rss::Url] resolved article URL
168
205
  # @return [String] stable article identifier fallbacking to resolved URL
169
- def identifier(entry, article_url)
170
- value = ValueFinder.fetch(entry, ID_KEYS)
206
+ def identifier(sources, article_url)
207
+ value = ValueFinder.fetch(sources, ID_KEYS)
171
208
  value = ValueFinder.fetch(value, ID_KEYS) if value.is_a?(Hash)
172
209
  string(value) || article_url.to_s
173
210
  end
211
+
212
+ private
213
+
214
+ # Shape checks (key holds a value), not usability checks: normalise decides usability.
215
+ def text?(hash)
216
+ title?(hash) || !ValueFinder.fetch(hash, DESCRIPTION_KEYS).nil?
217
+ end
218
+
219
+ def title?(object)
220
+ !ValueFinder.fetch(object, TITLE_KEYS).nil?
221
+ end
222
+
223
+ def url?(object)
224
+ !ValueFinder.fetch(object, URL_KEYS).nil?
225
+ end
226
+
227
+ def own_url?(entry)
228
+ !ValueFinder.fetch(entry, DIRECT_URL_KEYS).nil?
229
+ end
230
+
231
+ # Nested nodes that may hold the article fields of a wrapper entry.
232
+ def candidate_nodes(entry)
233
+ titled = titled_nodes(entry, MAX_NODE_DEPTH)
234
+ return titled.reject { ValueFinder.fetch(_1, ARTICLE_TITLE_KEYS).nil? } if own_url?(entry)
235
+
236
+ linked = titled.select { url?(_1) }
237
+ linked.empty? ? titled : linked
238
+ end
239
+
240
+ # Titled Hash values up to +depth+ levels down; a titled node's children are its own fields.
241
+ def titled_nodes(hash, depth)
242
+ return [] if depth.zero?
243
+
244
+ hash.each_value.flat_map do |value|
245
+ next [] unless value.is_a?(Hash)
246
+ next value if title?(value)
247
+
248
+ titled_nodes(value, depth - 1)
249
+ end
250
+ end
174
251
  end
175
252
  end
176
253
  end
@@ -30,26 +30,15 @@ module Html2rss
30
30
  end
31
31
 
32
32
  # @param array [Array<Object>] candidate list of entries
33
- # @return [Boolean] whether array includes hash entries with title and URL fields
33
+ # @return [Boolean] whether array holds at least one title+url hash
34
34
  def array_of_articles?(array)
35
- array.any? do |element|
36
- next unless element.is_a?(Hash)
37
-
38
- title_from(element) && url_from(element)
39
- end
35
+ array.any? { linked_entry?(_1) }
40
36
  end
41
37
 
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
38
+ private
47
39
 
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)
40
+ def linked_entry?(element)
41
+ ArticleNormalizer.linked?(ArticleNormalizer.article_sources(element))
53
42
  end
54
43
  end
55
44
  end
@@ -19,14 +19,13 @@ module Html2rss
19
19
 
20
20
  # @param hash [Hash] hash candidate traversed during key lookup
21
21
  # @param keys [Array<Symbol>] keys to probe in order
22
- # @return [Object, nil] first matching value from hash or nested metadata
22
+ # @return [Object, nil] value of the first present key
23
23
  def fetch_from_hash(hash, keys)
24
24
  keys.each do |key|
25
25
  return hash[key] if hash.key?(key)
26
26
  end
27
27
 
28
- fetch_nested(hash[:attributes], keys) ||
29
- fetch_nested(hash[:data], keys)
28
+ nil
30
29
  end
31
30
 
32
31
  # @param array [Array] array whose entries may contain target keys
@@ -40,13 +39,6 @@ module Html2rss
40
39
 
41
40
  nil
42
41
  end
43
-
44
- # @param value [Hash, Array, nil] nested value to recurse into
45
- # @param keys [Array<Symbol>] keys to probe in order
46
- # @return [Object, nil] matching nested value
47
- def fetch_nested(value, keys)
48
- fetch(value, keys) if value
49
- end
50
42
  end
51
43
  end
52
44
  end
@@ -21,6 +21,7 @@ module Html2rss
21
21
 
22
22
  class << self
23
23
  # @param parsed_body [Nokogiri::HTML::Document, nil] parsed HTML document
24
+ # @return [Boolean] whether any embedded JSON document holds candidate articles
24
25
  def articles?(parsed_body)
25
26
  return false unless parsed_body
26
27
 
@@ -62,9 +63,7 @@ module Html2rss
62
63
 
63
64
  # @param parsed_body [Nokogiri::HTML::Document, nil] parsed HTML document
64
65
  # @param url [String, Html2rss::Url] page URL used to resolve relative links
65
- # @param _opts [Hash] scraper-specific options
66
- # @option _opts [Object] :_reserved reserved for future scraper-specific options
67
- def initialize(parsed_body, url:, **_opts)
66
+ def initialize(parsed_body, url:)
68
67
  @parsed_body = parsed_body
69
68
  @url = url
70
69
  end
@@ -60,10 +60,8 @@ module Html2rss
60
60
  # @param parsed_body [Nokogiri::HTML::Document] parsed HTML document
61
61
  # @param url [String, Html2rss::Url] base page URL
62
62
  # @param request_session [Html2rss::RequestSession, nil] shared request session for follow-up fetches
63
- # @param _opts [Hash] unused scraper-specific options
64
- # @option _opts [Object] :_reserved reserved for future scraper-specific options
65
63
  # @return [void]
66
- def initialize(parsed_body, url:, request_session: nil, **_opts)
64
+ def initialize(parsed_body, url:, request_session: nil)
67
65
  @parsed_body = parsed_body
68
66
  @url = Html2rss::Url.from_absolute(url)
69
67
  @request_session = request_session
@@ -131,6 +129,8 @@ module Html2rss
131
129
 
132
130
  response = request_session.follow_up(url: oembed_url, relation: :auto_source, origin_url: url)
133
131
  parse_oembed_response(response)
132
+ rescue Html2rss::ContractError
133
+ raise
134
134
  rescue Html2rss::Error, RequestService::UnsupportedResponseContentType, JSON::ParserError => error
135
135
  Log.warn({
136
136
  event_name: 'meta_oembed.fetch_failed',
@@ -181,26 +181,18 @@ module Html2rss
181
181
  end
182
182
 
183
183
  # @param meta_data [Hash{Symbol => Object}] extracted OpenGraph meta tags
184
- # @return [Array<Hash{Symbol => Object}>, nil] og:video / og:audio enclosures (page-type embeds skipped)
184
+ # @return [Array<Hash{Symbol => Object}>, nil] og:video / og:audio enclosures (page-type embeds skipped; no
185
+ # kind, since an og:video is often a player page rather than a file)
185
186
  def media_enclosures(meta_data)
186
187
  found = %i[video audio].filter_map do |kind|
187
188
  type = meta_data[:"#{kind}_type"]
188
189
  next if type && !type.start_with?("#{kind}/")
189
190
 
190
- media_enclosure(meta_data[kind], type)
191
+ Html2rss::Html::ArticleRules::Enclosure.from_media(meta_data[kind], type, url)
191
192
  end
192
193
  found unless found.empty?
193
194
  end
194
195
 
195
- # @param src [String, nil] raw media URL
196
- # @param type [String, nil] declared MIME type
197
- # @return [Hash{Symbol => Object}, nil] enclosure attributes
198
- def media_enclosure(src, type)
199
- ::Html2rss::Html::ArticleRules::Enclosure.from_media(src, type, url)
200
- rescue Html2rss::Url::InvalidUrlError
201
- nil
202
- end
203
-
204
196
  # @param raw_url [String, nil] raw relative or absolute URL string
205
197
  # @return [Html2rss::Url, nil] resolved absolute URL or nil
206
198
  def resolve_url(raw_url)
@@ -24,6 +24,7 @@ module Html2rss
24
24
 
25
25
  class << self
26
26
  # @param parsed_body [Nokogiri::HTML::Document, nil] parsed HTML document
27
+ # @return [Boolean] whether any supported Microdata root exists
27
28
  def articles?(parsed_body)
28
29
  supported_roots(parsed_body).any?
29
30
  end
@@ -37,6 +38,7 @@ module Html2rss
37
38
  end
38
39
 
39
40
  # @param node [Nokogiri::XML::Element] itemscope candidate node
41
+ # @return [Boolean] whether the node is a supported top-level root
40
42
  def supported_root?(node)
41
43
  supported_type_name(node) && top_level_item?(node)
42
44
  end
@@ -59,6 +61,7 @@ module Html2rss
59
61
  end
60
62
 
61
63
  # @param node [Nokogiri::XML::Element] itemscope candidate node
64
+ # @return [Boolean] whether the node is not nested in another item property
62
65
  def top_level_item?(node)
63
66
  return false if node.attribute('itemprop')
64
67
 
@@ -78,10 +81,8 @@ module Html2rss
78
81
  # @param parsed_body [Nokogiri::HTML5::Document, Nokogiri::HTML4::Document, Nokogiri::XML::Node, nil]
79
82
  # the parsed response body to inspect for top-level Microdata items.
80
83
  # @param url [Html2rss::Url] the absolute page URL used to resolve relative links.
81
- # @param _opts [Hash] unused scraper-specific options.
82
- # @option _opts [Object] :_reserved reserved for future scraper-specific options
83
84
  # @return [void]
84
- def initialize(parsed_body, url:, **_opts)
85
+ def initialize(parsed_body, url:)
85
86
  @parsed_body = parsed_body
86
87
  @url = url
87
88
  end
@@ -447,7 +448,8 @@ module Html2rss
447
448
  image: ValueNormalizer.image_value(properties.delete(:image), properties.delete(:thumbnailUrl),
448
449
  properties.delete(:thumbnail)),
449
450
  video: properties.delete(:video),
450
- audio: properties.delete(:audio)
451
+ audio: properties.delete(:audio),
452
+ associatedMedia: properties.delete(:associatedMedia)
451
453
  }
452
454
  end
453
455
 
@@ -35,10 +35,8 @@ module Html2rss
35
35
 
36
36
  # @param parsed_body [Nokogiri::HTML::Document] parsed HTML document
37
37
  # @param url [String, Html2rss::Url] base page URL
38
- # @param _opts [Hash] unused scraper-specific options
39
- # @option _opts [Object] :_reserved reserved for future options
40
38
  # @return [void]
41
- def initialize(parsed_body, url:, **_opts)
39
+ def initialize(parsed_body, url:)
42
40
  @parsed_body = parsed_body
43
41
  @url = Html2rss::Url.from_absolute(url)
44
42
  end
@@ -5,9 +5,15 @@ module Html2rss
5
5
  module Scraper
6
6
  ##
7
7
  # Promotes a page's native RSS/Atom feed via {Syndication::Discovery} + {Syndication::Parser}.
8
+ # After {#each}, {#channel} holds what that feed states about its channel and {#feed_url} where
9
+ # it was read, the provenance {Cleanup} admits the feed's own domain by.
8
10
  class NativeFeed
9
11
  include Enumerable
10
12
 
13
+ # Href shapes that Discovery's path probing can still resolve when the link type is not a feed MIME type.
14
+ FEEDISH_HREF = /rss|atom|feed|\.xml/i
15
+ private_constant :FEEDISH_HREF
16
+
11
17
  # @return [Symbol] scraper config key
12
18
  def self.options_key = :native_feed
13
19
 
@@ -27,25 +33,16 @@ module Html2rss
27
33
  def self.articles?(parsed_body)
28
34
  return false unless parsed_body.is_a?(Nokogiri::HTML::Document)
29
35
 
30
- return true if ::Html2rss::Html::FeedLink.from_document(parsed_body).any?
36
+ return true if Html2rss::Html::FeedLink.from_document(parsed_body).any?
31
37
 
32
- parsed_body.css('head link[rel~="alternate"][href]').any? do |node|
33
- href = node['href'].to_s
34
- ::Html2rss::Html::Probe.mime_match?(
35
- node['type'],
36
- ::Html2rss::Html::Probe::APPLICATION_RSS_XML,
37
- ::Html2rss::Html::Probe::APPLICATION_ATOM_XML
38
- ) || href.match?(/rss|atom|feed|\.xml/i)
39
- end
38
+ parsed_body.css('head link[rel~="alternate"][href]').any? { |node| node['href'].match?(FEEDISH_HREF) }
40
39
  end
41
40
 
42
41
  ##
43
42
  # @param parsed_body [Nokogiri::HTML::Document]
44
43
  # @param url [String, Html2rss::Url]
45
44
  # @param request_session [Html2rss::RequestSession, nil]
46
- # @param _opts [Hash]
47
- # @option _opts [Object] :_reserved reserved for future scraper-specific options
48
- def initialize(parsed_body, url:, request_session: nil, **_opts)
45
+ def initialize(parsed_body, url:, request_session: nil)
49
46
  @parsed_body = parsed_body
50
47
  @url = Html2rss::Url.from_absolute(url)
51
48
  @request_session = request_session
@@ -54,6 +51,16 @@ module Html2rss
54
51
  # @return [Boolean]
55
52
  def extractable? = self.class.articles?(parsed_body)
56
53
 
54
+ ##
55
+ # @return [Syndication::ChannelFacts, nil] what the followed feed states about its channel;
56
+ # nil before {#each} or when no feed was read
57
+ def channel = @feed&.channel
58
+
59
+ ##
60
+ # @return [Html2rss::Url, nil] URL of the feed read (after redirects); nil before {#each} or
61
+ # when no feed was read
62
+ attr_reader :feed_url
63
+
57
64
  ##
58
65
  # @yieldparam article [Hash{Symbol => Object}]
59
66
  # @return [Enumerator, void]
@@ -61,7 +68,7 @@ module Html2rss
61
68
  def each(&)
62
69
  return enum_for(:each) unless block_given?
63
70
 
64
- articles = fetch_articles
71
+ articles = (@feed = fetch_feed).articles
65
72
  if articles.empty?
66
73
  Log.info({
67
74
  event_name: 'auto_source.native_feed.empty',
@@ -84,8 +91,8 @@ module Html2rss
84
91
  attr_reader :parsed_body, :url, :request_session
85
92
 
86
93
  # rubocop:disable-next Metrics/AbcSize, Metrics/MethodLength -- discovery + parse + structured warn
87
- def fetch_articles
88
- return [] unless request_session
94
+ def fetch_feed
95
+ return Syndication::Feed::NONE unless request_session
89
96
 
90
97
  response = Syndication::Discovery.best_feed_response(
91
98
  page_url: url,
@@ -93,9 +100,12 @@ module Html2rss
93
100
  parsed_body:,
94
101
  max_probes: self.class.request_slots
95
102
  )
96
- return [] unless response
103
+ return Syndication::Feed::NONE unless response
97
104
 
105
+ @feed_url = response.url
98
106
  Syndication::Parser.parse_response(response)
107
+ rescue Html2rss::ContractError
108
+ raise
99
109
  rescue Html2rss::Error => error
100
110
  Log.warn({
101
111
  event_name: 'auto_source.native_feed.failed',
@@ -103,7 +113,7 @@ module Html2rss
103
113
  error_class: error.class.name,
104
114
  message: "#{self.class}: host=#{url.host} failed (#{error.class}: #{error.message})"
105
115
  }.to_json)
106
- []
116
+ Syndication::Feed::NONE
107
117
  end
108
118
  end
109
119
  end
@@ -49,11 +49,13 @@ 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 (also bare Event, JobPosting and video items)
52
+ # @return [Boolean] whether to emit this object: a ListItem unwrap admits everything except pure
53
+ # entities ({Thing::ENTITY_TYPES}); a bare element must be an article, media, Event or JobPosting type
53
54
  def emit?(object, from_list_item:)
54
- return true if from_list_item
55
+ types = Schema.normalize_types(object[:@type])
56
+ return types.empty? || !types.subset?(Thing::ENTITY_TYPES) if from_list_item
55
57
 
56
- Schema.normalize_types(object[:@type]).intersect?(Thing::SUPPORTED_TYPES | Thing::ITEM_LIST_TYPES)
58
+ types.intersect?(Thing::ITEM_TYPES | Thing::ITEM_LIST_TYPES)
57
59
  end
58
60
  end
59
61
  end