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
@@ -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,150 +34,220 @@ 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
- 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
40
+ class << self
41
+ # rubocop:disable Metrics/MethodLength
42
+ # @param entry [Hash] raw article entry candidate
43
+ # @param base_url [String, Html2rss::Url] base URL for relative link resolution
44
+ # @return [Hash{Symbol => Object, nil}] normalized article hash for downstream extraction
45
+ def normalise(entry, base_url:)
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')
52
+ return unless article_url
53
+ return if title.nil? && description.nil?
59
54
 
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)
55
+ {
56
+ title:,
57
+ kicker:,
58
+ description:,
59
+ url: 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)
65
+ }.compact
66
+ end
67
+ # rubocop:enable Metrics/MethodLength
65
68
 
66
- trimmed = value.to_s.strip
67
- trimmed unless trimmed.empty?
68
- end
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)
69
85
 
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
86
+ nodes = candidate_nodes(entry)
87
+ return [entry] if nodes.empty?
88
+ return unless nodes.one?
77
89
 
78
- value = value[nested]
79
- depth += 1
90
+ node = nodes.first
91
+ own_url?(entry) && url?(node) ? [entry, node] : [node, entry]
80
92
  end
81
- value unless value.is_a?(Hash) || value.is_a?(Array)
82
- end
83
93
 
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
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
100
99
 
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
100
+ # @param value [Object] candidate scalar, or Hash wrapping +rendered+/+value+/+text+
101
+ # @return [String, nil] normalized non-empty string (HTML preserved for descriptions)
102
+ def string(value)
103
+ value = unwrap_scalar(value)
104
+ return unless value.is_a?(String) || value.is_a?(Numeric)
105
+
106
+ trimmed = value.to_s.strip
107
+ trimmed unless trimmed.empty?
109
108
  end
110
- value unless value.is_a?(Hash)
111
- end
112
109
 
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
110
+ # @param value [Object] candidate scalar or nested Hash
111
+ # @return [Object, nil] String/Numeric after at most {MAX_URL_UNWRAP} Hash unwraps
112
+ def unwrap_scalar(value)
113
+ depth = 0
114
+ while value.is_a?(Hash) && depth < MAX_URL_UNWRAP
115
+ nested = SCALAR_KEYS.find { |key| value.key?(key) }
116
+ break unless nested
119
117
 
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)
118
+ value = value[nested]
119
+ depth += 1
120
+ end
121
+ value unless value.is_a?(Hash) || value.is_a?(Array)
122
+ end
124
123
 
125
- hash.each do |key, value|
126
- next unless date_suffix_key?(key)
127
- next unless value.is_a?(String) || value.is_a?(Numeric)
124
+ # @param sources [Hash, Array<Hash>] entry or field sources from {article_sources}
125
+ # @param keys [Array<Symbol>] preferred link keys
126
+ # @param base_url [String, Html2rss::Url] base URL for relative link resolution
127
+ # @param log_key [String] structured log message key
128
+ # @return [Html2rss::Url, nil] resolved absolute URL
129
+ def resolve_link(sources, keys:, base_url:, log_key:)
130
+ string = string(unwrap_url(ValueFinder.fetch(sources, keys), keys))
131
+ return unless string
128
132
 
129
- return value
133
+ Url.from_relative(string, base_url)
134
+ rescue Html2rss::Url::InvalidUrlError
135
+ Log.debug("#{log_key}: url=#{string}")
136
+ nil
130
137
  end
131
138
 
132
- suffix_date_value(hash[:attributes]) || suffix_date_value(hash[:data])
133
- end
139
+ # @param value [Object] candidate URL value, possibly a nested Hash or a links Array
140
+ # @param keys [Array<Symbol>] preferred link keys
141
+ # @return [Object, nil] scalar URL candidate after at most {MAX_URL_UNWRAP} unwraps
142
+ def unwrap_url(value, keys)
143
+ depth = 0
144
+ while (value.is_a?(Hash) || value.is_a?(Array)) && depth < MAX_URL_UNWRAP
145
+ value = ValueFinder.fetch(value, keys)
146
+ depth += 1
147
+ end
148
+ value unless value.is_a?(Hash) || value.is_a?(Array)
149
+ end
134
150
 
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
151
+ # @param sources [Hash, Array<Hash>] entry or field sources from {article_sources}
152
+ # @return [String, nil] publication timestamp string or unix timestamp digits
153
+ def published_at(sources)
154
+ value = ValueFinder.fetch(sources, PUBLISHED_AT_KEYS)
155
+ string(value) || string(suffix_date_value(sources))
156
+ end
140
157
 
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
158
+ # @param sources [Hash, Array<Hash>, nil] entry or field sources, searched in order
159
+ # @return [Object, nil] first string or numeric value whose key matches {DATE_KEY_SUFFIX}
160
+ def suffix_date_value(sources)
161
+ case sources
155
162
  when Hash
156
- string(ValueFinder.fetch(value, %i[name title label]))
157
- else
158
- string(value)
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
159
168
  end
160
- end.compact
169
+ end
161
170
 
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
171
+ # @param key [Symbol, String] candidate published-at key
172
+ # @return [Boolean] whether the key ends in a date suffix (_at/_on/At/On)
173
+ def date_suffix_key?(key)
174
+ key.to_s.match?(DATE_KEY_SUFFIX)
175
+ end
176
+
177
+ # rubocop:disable Metrics/MethodLength
178
+ # @param sources [Hash, Array<Hash>] entry or field sources from {article_sources}
179
+ # @return [Array<String>, nil] normalized unique categories
180
+ def categories(sources)
181
+ raw = ValueFinder.fetch(sources, CATEGORY_KEYS)
182
+ names = case raw
183
+ when Array then raw
184
+ when Hash then raw.values
185
+ when String then [raw]
186
+ else []
187
+ end
188
+
189
+ result = names.flat_map do |value|
190
+ case value
191
+ when Hash
192
+ string(ValueFinder.fetch(value, %i[name title label]))
193
+ else
194
+ string(value)
195
+ end
196
+ end.compact
197
+
198
+ result.uniq!
199
+ result unless result.empty?
200
+ end
201
+ # rubocop:enable Metrics/MethodLength
202
+
203
+ # @param sources [Hash, Array<Hash>] entry or field sources from {article_sources}
204
+ # @param article_url [Html2rss::Url] resolved article URL
205
+ # @return [String] stable article identifier fallbacking to resolved URL
206
+ def identifier(sources, article_url)
207
+ value = ValueFinder.fetch(sources, ID_KEYS)
208
+ value = ValueFinder.fetch(value, ID_KEYS) if value.is_a?(Hash)
209
+ string(value) || article_url.to_s
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
@@ -6,51 +6,40 @@ 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)
38
-
39
- title_from(element) && url_from(element)
32
+ # @param array [Array<Object>] candidate list of entries
33
+ # @return [Boolean] whether array holds at least one title+url hash
34
+ def array_of_articles?(array)
35
+ array.any? { linked_entry?(_1) }
40
36
  end
41
- end
42
37
 
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
38
+ private
48
39
 
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)
40
+ def linked_entry?(element)
41
+ ArticleNormalizer.linked?(ArticleNormalizer.article_sources(element))
42
+ end
54
43
  end
55
44
  end
56
45
  end