html2rss 0.24.0 → 0.25.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 (89) hide show
  1. checksums.yaml +4 -4
  2. data/lib/html2rss/auto_source/scraper/html.rb +68 -145
  3. data/lib/html2rss/auto_source/scraper/json_state/article_normalizer.rb +116 -0
  4. data/lib/html2rss/auto_source/scraper/json_state/candidate_detector.rb +63 -0
  5. data/lib/html2rss/auto_source/scraper/json_state/document_scanner.rb +179 -0
  6. data/lib/html2rss/auto_source/scraper/json_state/value_finder.rb +55 -0
  7. data/lib/html2rss/auto_source/scraper/json_state.rb +0 -379
  8. data/lib/html2rss/auto_source/scraper/semantic_html/entry_deduplicator.rb +35 -30
  9. data/lib/html2rss/auto_source/scraper/semantic_html.rb +40 -120
  10. data/lib/html2rss/auto_source/scraper/sitemap/parser.rb +161 -0
  11. data/lib/html2rss/auto_source/scraper/sitemap.rb +10 -7
  12. data/lib/html2rss/auto_source/scraper/wordpress_api.rb +6 -2
  13. data/lib/html2rss/auto_source/scraper.rb +81 -44
  14. data/lib/html2rss/auto_source/segment.rb +29 -0
  15. data/lib/html2rss/auto_source/segmenter/cluster.rb +163 -0
  16. data/lib/html2rss/auto_source/segmenter/list.rb +101 -0
  17. data/lib/html2rss/auto_source/segmenter/primary_link.rb +81 -0
  18. data/lib/html2rss/auto_source/segmenter/semantic.rb +104 -0
  19. data/lib/html2rss/auto_source/segmenter.rb +66 -0
  20. data/lib/html2rss/auto_source.rb +74 -21
  21. data/lib/html2rss/cli.rb +8 -13
  22. data/lib/html2rss/config/auto_source_contract.rb +2 -0
  23. data/lib/html2rss/config/request_controls.rb +33 -0
  24. data/lib/html2rss/config/validator.rb +15 -8
  25. data/lib/html2rss/config.rb +6 -2
  26. data/lib/html2rss/feed_builder/rss.rb +17 -5
  27. data/lib/html2rss/feed_pipeline.rb +4 -0
  28. data/lib/html2rss/feed_result.rb +1 -1
  29. data/lib/html2rss/html/article_extractor/category_extractor.rb +10 -36
  30. data/lib/html2rss/html/article_extractor/date_extractor.rb +2 -7
  31. data/lib/html2rss/html/article_extractor/enclosure_extractor.rb +5 -50
  32. data/lib/html2rss/html/article_extractor/image_extractor.rb +5 -16
  33. data/lib/html2rss/html/article_rules/category.rb +55 -0
  34. data/lib/html2rss/html/article_rules/date.rb +25 -0
  35. data/lib/html2rss/html/article_rules/enclosure.rb +72 -0
  36. data/lib/html2rss/html/article_rules/image.rb +109 -0
  37. data/lib/html2rss/html/article_rules.rb +10 -0
  38. data/lib/html2rss/html/rendering/audio_renderer.rb +2 -12
  39. data/lib/html2rss/html/rendering/escaped_attributes.rb +27 -0
  40. data/lib/html2rss/html/rendering/image_renderer.rb +2 -12
  41. data/lib/html2rss/html/rendering/pdf_renderer.rb +2 -8
  42. data/lib/html2rss/html/rendering/video_renderer.rb +2 -12
  43. data/lib/html2rss/html/sst_article_extractor.rb +279 -0
  44. data/lib/html2rss/link_destination/destination_facts.rb +40 -0
  45. data/lib/html2rss/link_destination/noise_policy.rb +79 -0
  46. data/lib/html2rss/link_destination/path_classifier.rb +205 -0
  47. data/lib/html2rss/link_destination/text_classifier.rb +64 -0
  48. data/lib/html2rss/link_destination.rb +8 -0
  49. data/lib/html2rss/request_service/faraday_strategy.rb +42 -1
  50. data/lib/html2rss/request_service/network_guard.rb +5 -3
  51. data/lib/html2rss/scoring/anchor_score.rb +34 -0
  52. data/lib/html2rss/scoring/cluster_scorer.rb +87 -0
  53. data/lib/html2rss/scoring/container_assessor.rb +83 -0
  54. data/lib/html2rss/scoring/engine.rb +101 -0
  55. data/lib/html2rss/scoring/link_resolver.rb +72 -0
  56. data/lib/html2rss/scoring/observation.rb +53 -0
  57. data/lib/html2rss/scoring/ranked_segment.rb +45 -0
  58. data/lib/html2rss/scoring/score.rb +30 -0
  59. data/lib/html2rss/scoring.rb +33 -0
  60. data/lib/html2rss/selectors/post_processors/sanitize_html.rb +10 -1
  61. data/lib/html2rss/selectors.rb +0 -20
  62. data/lib/html2rss/sst/attrs.rb +91 -0
  63. data/lib/html2rss/sst/document.rb +23 -0
  64. data/lib/html2rss/sst/index.rb +112 -0
  65. data/lib/html2rss/sst/node.rb +147 -0
  66. data/lib/html2rss/sst/normalizer.rb +171 -0
  67. data/lib/html2rss/sst/tags.rb +26 -0
  68. data/lib/html2rss/sst/text.rb +81 -0
  69. data/lib/html2rss/sst.rb +8 -0
  70. data/lib/html2rss/version.rb +1 -1
  71. data/lib/html2rss.rb +26 -26
  72. data/schema/html2rss-config.schema.json +20 -0
  73. metadata +42 -18
  74. data/lib/html2rss/auto_source/discovery/dom_clustering/group_scorer.rb +0 -80
  75. data/lib/html2rss/auto_source/discovery/dom_clustering/overlap_resolver.rb +0 -86
  76. data/lib/html2rss/auto_source/discovery/dom_clustering.rb +0 -119
  77. data/lib/html2rss/auto_source/discovery/list_candidates.rb +0 -94
  78. data/lib/html2rss/auto_source/discovery/semantic_anchor_candidates.rb +0 -219
  79. data/lib/html2rss/auto_source/discovery/semantic_containers.rb +0 -71
  80. data/lib/html2rss/auto_source/discovery/sitemap.rb +0 -159
  81. data/lib/html2rss/auto_source/discovery.rb +0 -14
  82. data/lib/html2rss/auto_source/link_heuristics/anchor_signals.rb +0 -28
  83. data/lib/html2rss/auto_source/link_heuristics/container_assessor.rb +0 -106
  84. data/lib/html2rss/auto_source/link_heuristics/container_signals.rb +0 -80
  85. data/lib/html2rss/auto_source/link_heuristics/destination_facts.rb +0 -42
  86. data/lib/html2rss/auto_source/link_heuristics/href_extractor.rb +0 -38
  87. data/lib/html2rss/auto_source/link_heuristics/path_classifier.rb +0 -221
  88. data/lib/html2rss/auto_source/link_heuristics/text_classifier.rb +0 -66
  89. data/lib/html2rss/auto_source/link_heuristics.rb +0 -139
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8e71e2d9112602603e9cbdf23907d2ad6baabc633c22bd9305094c2ab405f9ad
4
- data.tar.gz: 1239aecc1f56a8ab4f5f83990671d4655127ad481907552e89fcce30af7d25ef
3
+ metadata.gz: 1b6b0ddc57b5cf372cc9bc6405bb21f7dc6d00aa526e234132974ccc6ef609f7
4
+ data.tar.gz: 59a60ef044a99686838b5498f0712a455a6c5a832c27619db0c99ba21742f6a8
5
5
  SHA512:
6
- metadata.gz: fe0e1fe5a2b81886b0bda2f9087e12c42a5b309d105bcd7c1ea2268690740ee36390ad54bafb94757d758204968617569f981f489afe05572f41be28c1e4025a
7
- data.tar.gz: cfad45944b508e5fafaae484cdb625e33ee43e336a2ab74b336f398c7eaab306432cf06d524ddb81b7ec5a002e77c065d7af491a6b245d0a2f5b2df6098aeda2
6
+ metadata.gz: 8994aa3b90d791ea8cf75df9b576669fffe505fe0895e207745ab0e591f187ab490382d00a95d49fc1073570f886d6940f27f1c1ca80ba7c57817740f48c4bc4
7
+ data.tar.gz: dc62483a0ade2b582fb3839778a3a518a7f1a08e781d2105c0e93c70aaa26313e153a4e30121170b8a59e8472517e6cb8d9958b9c06fa3a862358631f469c18a
@@ -1,21 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'nokogiri'
4
-
5
3
  module Html2rss
6
4
  class AutoSource
7
5
  module Scraper
8
6
  ##
9
- # Scrapes article-like blocks from plain HTML by looking for repeated link
10
- # structures when richer structured data is unavailable.
11
- #
12
- # The approach is intentionally heuristic:
13
- # 1. collect repeated anchor paths
14
- # 2. walk upward to a shared container shape
15
- # 3. extract the best anchor found inside each container
16
- #
17
- # This scraper is broader and noisier than `SemanticHtml`, so it acts as a
18
- # fallback for pages without stronger semantic signals.
7
+ # Fallback HTML list/cluster scraper via SST pipeline
8
+ # (Normalizer Segmenter Scoring::Engine Html::SstArticleExtractor).
19
9
  class Html
20
10
  include Enumerable
21
11
 
@@ -25,53 +15,44 @@ module Html2rss
25
15
  DEFAULT_MINIMUM_SELECTOR_FREQUENCY = 2
26
16
  # Number of most frequent selectors kept for container extraction.
27
17
  DEFAULT_USE_TOP_SELECTORS = 5
18
+ # Maximum articles materialized after eligibility filtering.
19
+ TOP_K = Scoring::Engine::TOP_K
28
20
 
29
21
  ##
30
- # @return [Symbol] config key used to enable or configure this scraper
22
+ # @return [Symbol]
31
23
  def self.options_key = :html
32
24
 
33
25
  ##
34
- # Probes whether the document appears to contain repeated anchor
35
- # structures that this fallback scraper can cluster into article-like
36
- # containers.
37
- #
38
- # @param parsed_body [Nokogiri::HTML::Document] parsed HTML document
39
- # @return [Boolean] true when the scraper can likely extract articles
26
+ # @param parsed_body [Nokogiri::HTML::Document]
27
+ # @return [Boolean]
40
28
  def self.articles?(parsed_body)
41
- new(parsed_body, url: DETECTION_BASE_URL).any?
42
- end
29
+ return false unless parsed_body
43
30
 
44
- ##
45
- # Simplify an XPath selector by removing the index notation.
46
- # This keeps repeated anchor paths comparable across sibling blocks.
47
- #
48
- # @param xpath [String] original XPath
49
- # @return [String] XPath without positional indexes
50
- def self.simplify_xpath(xpath)
51
- Discovery::ListCandidates.simplify_xpath(xpath)
31
+ new(parsed_body, url: DETECTION_BASE_URL).extractable?
32
+ rescue ArgumentError
33
+ false
52
34
  end
53
35
 
54
- # @param parsed_body [Nokogiri::HTML::Document] The parsed HTML document.
55
- # @param url [String] The base URL.
56
- # @param extractor [Class] The extractor class to handle article extraction.
57
- # @param opts [Hash] Additional options.
58
- # @option opts [Integer] :minimum_selector_frequency minimum count before a selector is considered stable
59
- # @option opts [Integer] :use_top_selectors number of top selectors to keep
60
- def initialize(parsed_body, url:, extractor: Html2rss::Html::ArticleExtractor, **opts)
36
+ # @param parsed_body [Nokogiri::HTML::Document, nil] parsed HTML (when +document:+ omitted)
37
+ # @param url [String, Html2rss::Url]
38
+ # @param document [SST::Document, nil] memoized SST document from AutoSource
39
+ # @param link_resolver [Scoring::LinkResolver, nil] shared page-scoped resolver
40
+ # @param opts [Hash]
41
+ # @option opts [Boolean] :fallback_anchorless keep anchorless cluster cards
42
+ # @option opts [Integer] :minimum_selector_frequency list frequency floor
43
+ # @option opts [Integer] :use_top_selectors list selector budget
44
+ def initialize(parsed_body = nil, url:, document: nil, link_resolver: nil, **opts)
61
45
  @parsed_body = parsed_body
46
+ @provided_document = document
47
+ @provided_link_resolver = link_resolver
62
48
  @url = url
63
- @extractor = extractor
64
49
  @opts = opts
65
50
  @fallback_anchorless = opts.fetch(:fallback_anchorless, false)
66
- @link_heuristics = LinkHeuristics.new(url)
67
- @ignored_cache = {}.compare_by_identity
68
51
  end
69
52
 
70
- attr_reader :parsed_body
71
-
72
53
  ##
73
- # @yieldparam [Hash] The scraped article hash
74
- # @return [Enumerator] Enumerator for the scraped articles
54
+ # @yieldparam article [Html2rss::Article]
55
+ # @return [Enumerator]
75
56
  def each
76
57
  return enum_for(:each) unless block_given?
77
58
 
@@ -79,135 +60,77 @@ module Html2rss
79
60
  end
80
61
 
81
62
  ##
82
- # @return [Boolean] true when the scraper can likely extract articles
63
+ # @return [Boolean]
83
64
  def extractable?
84
- articles.any?
85
- end
86
-
87
- ##
88
- # Decides whether a traversed node has reached a useful article-like
89
- # boundary for the generic HTML scraper.
90
- #
91
- # The predicate prefers containers that add surrounding link context,
92
- # which helps the scraper move from a leaf anchor toward a repeated
93
- # teaser/card wrapper.
94
- #
95
- # @param node [Nokogiri::XML::Node] candidate boundary node
96
- # @return [Boolean] true when the node is a good extraction boundary
97
- def article_tag_condition?(node)
98
- # Ignore tags that are below ignored DOM chrome.
99
- return false if Html2rss::Html::Navigator.ignored_container_path?(node, @ignored_cache)
100
- return true if %w[body html].include?(node.name)
101
- return false unless (parent = node.parent)
102
-
103
- anchor_count(parent) > anchor_count(node)
65
+ ranked_segments.any?
66
+ rescue ArgumentError
67
+ false
104
68
  end
105
69
 
106
70
  private
107
71
 
108
72
  def articles
109
73
  @articles ||= begin
110
- extracted = each_article_tag.filter_map do |article_tag, selected_anchor|
111
- extract_article(article_tag, selected_anchor:)
74
+ ranked = list_ranked
75
+ if ranked.empty? && @fallback_anchorless
76
+ materialize(cluster_ranked, fallback_anchorless: true)
77
+ else
78
+ materialize(ranked)
112
79
  end
113
-
114
- extracted += find_anchorless_articles if @fallback_anchorless
115
- extracted
116
80
  end
81
+ rescue ArgumentError
82
+ []
117
83
  end
118
84
 
119
- def find_anchorless_articles
120
- Discovery::DomClustering.call(
121
- parsed_body,
122
- minimum_selector_frequency:
123
- ).map do |node|
124
- @extractor.new(node, base_url: @url, selected_anchor: nil, fallback_anchorless: true).call
85
+ def ranked_segments
86
+ @ranked_segments ||= begin
87
+ ranked = list_ranked
88
+ ranked = cluster_ranked if @fallback_anchorless && ranked.empty?
89
+ ranked
125
90
  end
126
91
  end
127
92
 
128
- ##
129
- # @return [Integer]
130
- def minimum_selector_frequency = @opts[:minimum_selector_frequency] || DEFAULT_MINIMUM_SELECTOR_FREQUENCY
131
-
132
- ##
133
- # @return [Boolean]
134
- def use_top_selectors = @opts[:use_top_selectors] || DEFAULT_USE_TOP_SELECTORS
135
-
136
- ##
137
- # @param node [Nokogiri::XML::Node]
138
- # @return [Integer]
139
- def anchor_count(node)
140
- (@anchor_counts ||= {}.compare_by_identity)[node] ||= node.name == 'a' ? 1 : node.css('a').size
141
- end
142
-
143
- ##
144
- # @param node [Nokogiri::XML::Node]
145
- # @return [Boolean]
146
- def relevant_anchor?(node)
147
- destination_facts = @link_heuristics.destination_facts(node)
148
- return false unless destination_facts
149
-
150
- !noise_anchor?(node, destination_facts:)
93
+ def list_ranked
94
+ @list_ranked ||= rank_strategy(:list, permit_unanchored: false)
151
95
  end
152
96
 
153
- ##
154
- # @yield [article_tag, selected_anchor]
155
- # @yieldparam article_tag [Nokogiri::XML::Node]
156
- # @yieldparam selected_anchor [Nokogiri::XML::Node]
157
- # @return [Enumerator, nil]
158
- def each_article_tag(&block)
159
- return enum_for(:each_article_tag) unless block
160
-
161
- anchor_filter = ->(node) { relevant_anchor?(node) }
162
- boundary_condition = ->(node) { article_tag_condition?(node) }
163
-
164
- list_candidates.each_article_tag(anchor_filter:, boundary_condition:, &block)
97
+ def cluster_ranked
98
+ @cluster_ranked ||= rank_strategy(:cluster, permit_unanchored: true)
165
99
  end
166
100
 
167
- ##
168
- # @param article_tag [Nokogiri::XML::Node]
169
- # @param selected_anchor [Nokogiri::XML::Node, nil]
170
- # @return [Hash, nil]
171
- def extract_article(article_tag, selected_anchor: nil)
172
- selected_anchor ||= preferred_anchor_for(article_tag)
173
- return unless selected_anchor
174
- return if noise_anchor?(
175
- selected_anchor,
176
- destination_facts: @link_heuristics.destination_facts(selected_anchor),
177
- container: article_tag
101
+ def rank_strategy(strategy, permit_unanchored:)
102
+ segments = Segmenter.call(
103
+ document,
104
+ base_url: @url,
105
+ strategy:,
106
+ permit_unanchored:,
107
+ minimum_selector_frequency:,
108
+ use_top_selectors:,
109
+ link_resolver:
178
110
  )
179
-
180
- @extractor.call(article_tag, base_url: @url, selected_anchor:)
111
+ engine.select_eligible(segments, limit: TOP_K)
181
112
  end
182
113
 
183
- ##
184
- # @param anchor [Nokogiri::XML::Node]
185
- # @param destination_facts [LinkHeuristics::DestinationFacts, nil]
186
- # @return [Boolean]
187
- def noise_anchor?(anchor, destination_facts:, container: nil)
188
- (@noise_anchors ||= {}.compare_by_identity)[anchor] ||= begin
189
- text = Html2rss::Html::Navigator.extract_visible_text(anchor).to_s.strip
190
- @link_heuristics.noise_anchor?(text:, destination_facts:, anchor:, container:)
114
+ def materialize(ranked, fallback_anchorless: false)
115
+ ranked.filter_map do |entry|
116
+ ::Html2rss::Html::SstArticleExtractor.call(entry, base_url: @url, scraper: self.class, fallback_anchorless:)
191
117
  end
192
118
  end
193
119
 
194
- ##
195
- # @param article_tag [Nokogiri::XML::Node]
196
- # @return [Nokogiri::XML::Node, nil]
197
- def preferred_anchor_for(article_tag)
198
- article_tag.css(Html2rss::Html::Navigator::MAIN_ANCHOR_SELECTOR).find { relevant_anchor?(_1) } ||
199
- Html2rss::Html::Navigator.main_anchor_for(article_tag)
120
+ def engine
121
+ @engine ||= Scoring::Engine.new(link_resolver:)
200
122
  end
201
123
 
202
- ##
203
- # @return [Discovery::ListCandidates]
204
- def list_candidates
205
- Discovery::ListCandidates.new(
206
- parsed_body,
207
- minimum_selector_frequency:,
208
- use_top_selectors:
209
- )
124
+ def link_resolver
125
+ @link_resolver ||= @provided_link_resolver || Scoring::LinkResolver.new(@url)
210
126
  end
127
+
128
+ def document
129
+ @document ||= @provided_document || SST::Normalizer.call(@parsed_body)
130
+ end
131
+
132
+ def minimum_selector_frequency = @opts[:minimum_selector_frequency] || DEFAULT_MINIMUM_SELECTOR_FREQUENCY
133
+ def use_top_selectors = @opts[:use_top_selectors] || DEFAULT_USE_TOP_SELECTORS
211
134
  end
212
135
  end
213
136
  end
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ class AutoSource
5
+ module Scraper
6
+ class JsonState
7
+ # Shapes raw entries into the structure required downstream.
8
+ module ArticleNormalizer
9
+ # Preferred keys when extracting title-like values from state payloads.
10
+ TITLE_KEYS = %i[title headline name text].freeze
11
+ # Preferred keys when extracting URL-like values from state payloads.
12
+ URL_KEYS = %i[url link href permalink slug path canonicalUrl shortUrl].freeze
13
+ # Preferred keys when extracting description-like values from state payloads.
14
+ DESCRIPTION_KEYS = %i[description summary excerpt dek subheading].freeze
15
+ # Preferred keys when extracting image-like values from state payloads.
16
+ IMAGE_KEYS = %i[image imageUrl thumbnailUrl thumbnail src featuredImage coverImage heroImage].freeze
17
+ # Preferred keys when extracting publication timestamps from state payloads.
18
+ PUBLISHED_AT_KEYS = %i[published_at publishedAt datePublished date publicationDate pubDate updatedAt
19
+ updated_at createdAt created_at].freeze
20
+ # Preferred keys when extracting category-like values from state payloads.
21
+ CATEGORY_KEYS = %i[categories tags section sections topic topics channel].freeze
22
+ # Preferred keys when extracting identifier-like values from state payloads.
23
+ ID_KEYS = %i[id guid uuid slug key].freeze
24
+
25
+ module_function
26
+
27
+ # rubocop:disable Metrics/MethodLength
28
+ # @param entry [Hash] raw article entry candidate
29
+ # @param base_url [String, Html2rss::Url] base URL for relative link resolution
30
+ # @return [Hash{Symbol => Object, nil}] normalized article hash for downstream extraction
31
+ def normalise(entry, base_url:)
32
+ return unless entry.is_a?(Hash)
33
+
34
+ title = string(ValueFinder.fetch(entry, TITLE_KEYS))
35
+ description = string(ValueFinder.fetch(entry, DESCRIPTION_KEYS))
36
+ article_url = resolve_link(entry, keys: URL_KEYS, base_url:,
37
+ log_key: 'JsonState: invalid URL encountered')
38
+ return unless article_url
39
+ return if title.nil? && description.nil?
40
+
41
+ {
42
+ title:,
43
+ description:,
44
+ url: article_url,
45
+ image: resolve_link(entry, keys: IMAGE_KEYS, base_url:,
46
+ log_key: 'JsonState: invalid image URL encountered'),
47
+ published_at: string(ValueFinder.fetch(entry, PUBLISHED_AT_KEYS)),
48
+ categories: categories(entry),
49
+ id: identifier(entry, article_url)
50
+ }.compact
51
+ end
52
+ # rubocop:enable Metrics/MethodLength
53
+
54
+ # @param value [Object] candidate scalar value
55
+ # @return [String, nil] normalized non-empty string value
56
+ def string(value)
57
+ trimmed = value.to_s.strip
58
+ trimmed unless trimmed.empty?
59
+ end
60
+
61
+ # @param entry [Hash] raw article entry candidate
62
+ # @param keys [Array<String>] preferred link keys
63
+ # @param base_url [String, Html2rss::Url] base URL for relative link resolution
64
+ # @param log_key [String] structured log message key
65
+ # @return [Html2rss::Url, nil] resolved absolute URL
66
+ def resolve_link(entry, keys:, base_url:, log_key:)
67
+ value = ValueFinder.fetch(entry, keys)
68
+ value = ValueFinder.fetch(value, keys) if value.is_a?(Hash)
69
+ string = string(value)
70
+ return unless string
71
+
72
+ Url.from_relative(string, base_url)
73
+ rescue ArgumentError
74
+ Log.debug(log_key, url: string)
75
+ nil
76
+ end
77
+
78
+ # rubocop:disable Metrics/MethodLength
79
+ # @param entry [Hash] raw article entry candidate
80
+ # @return [Array<String>, nil] normalized unique categories
81
+ def categories(entry)
82
+ raw = ValueFinder.fetch(entry, CATEGORY_KEYS)
83
+ names = case raw
84
+ when Array then raw
85
+ when Hash then raw.values
86
+ when String then [raw]
87
+ else []
88
+ end
89
+
90
+ result = names.flat_map do |value|
91
+ case value
92
+ when Hash
93
+ string(ValueFinder.fetch(value, %i[name title label]))
94
+ else
95
+ string(value)
96
+ end
97
+ end.compact
98
+
99
+ result.uniq!
100
+ result unless result.empty?
101
+ end
102
+ # rubocop:enable Metrics/MethodLength
103
+
104
+ # @param entry [Hash] raw article entry candidate
105
+ # @param article_url [Html2rss::Url] resolved article URL
106
+ # @return [String] stable article identifier fallbacking to resolved URL
107
+ def identifier(entry, article_url)
108
+ value = ValueFinder.fetch(entry, ID_KEYS)
109
+ value = ValueFinder.fetch(value, ID_KEYS) if value.is_a?(Hash)
110
+ string(value) || article_url.to_s
111
+ end
112
+ end
113
+ end
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ class AutoSource
5
+ module Scraper
6
+ class JsonState
7
+ # Identifies arrays that look like collections of article hashes.
8
+ module CandidateDetector
9
+ # Preferred keys when extracting title-like values from state payloads.
10
+ TITLE_KEYS = %i[title headline name text].freeze
11
+ # Preferred keys when extracting URL-like values from state payloads.
12
+ URL_KEYS = %i[url link href permalink slug path canonicalUrl shortUrl].freeze
13
+
14
+ module_function
15
+
16
+ # @param document [Hash, Array, Object] candidate document node
17
+ # @return [Boolean] whether the node contains article-like arrays
18
+ def candidate_array?(document)
19
+ case document
20
+ when Array
21
+ return true if array_of_articles?(document)
22
+
23
+ document.any? { traversable_candidate?(_1) }
24
+ when Hash then document.each_value.any? { candidate_array?(_1) }
25
+ else false
26
+ end
27
+ end
28
+
29
+ # @param value [Hash, Array, Object] candidate nested value
30
+ # @return [Boolean] whether nested value should be traversed for article candidates
31
+ def traversable_candidate?(value)
32
+ case value
33
+ when Array, Hash then candidate_array?(value)
34
+ else false
35
+ end
36
+ end
37
+
38
+ # @param array [Array<Object>] candidate list of entries
39
+ # @return [Boolean] whether array includes hash entries with title and URL fields
40
+ def array_of_articles?(array)
41
+ array.any? do |element|
42
+ next unless element.is_a?(Hash)
43
+
44
+ title_from(element) && url_from(element)
45
+ end
46
+ end
47
+
48
+ # @param object [Hash] article candidate object
49
+ # @return [Object, nil] detected title-like value
50
+ def title_from(object)
51
+ ValueFinder.fetch(object, TITLE_KEYS)
52
+ end
53
+
54
+ # @param object [Hash] article candidate object
55
+ # @return [Object, nil] detected URL-like value
56
+ def url_from(object)
57
+ ValueFinder.fetch(object, URL_KEYS)
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,179 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module Html2rss
6
+ class AutoSource
7
+ module Scraper
8
+ class JsonState
9
+ # Scans DOM nodes for JSON payloads containing article data.
10
+ module DocumentScanner # rubocop:disable Metrics/ModuleLength
11
+ # Selector for JSON-only script tags.
12
+ JSON_SCRIPT_SELECTOR = 'script[type="application/json"]'
13
+ # Regex patterns for known global JavaScript state assignments.
14
+ GLOBAL_ASSIGNMENT_PATTERNS = [
15
+ /(?:window|self|globalThis)\.__NEXT_DATA__\s*=\s*/m,
16
+ /(?:window|self|globalThis)\.__NUXT__\s*=\s*/m,
17
+ /(?:window|self|globalThis)\.STATE\s*=\s*/m,
18
+ /(?:window|self|globalThis)\.__REDUX_STATE__\s*=\s*/m,
19
+ /(?:window|self|globalThis)\.__PRELOADED_STATE__\s*=\s*/m,
20
+ /(?:window|self|globalThis)\.__APOLLO_STATE__\s*=\s*/m,
21
+ /(?:window|self|globalThis)\.__remixContext\s*=\s*/m,
22
+ /(?:window|self|globalThis)\.__sveltekit_data\s*=\s*/m,
23
+ /(?:window|self|globalThis)\.GATSBY_STATE\s*=\s*/m,
24
+ /(?:window|self|globalThis)\.__ember_meta\s*=\s*/m,
25
+ /(?:window|self|globalThis)\.angular\s*=\s*/m
26
+ ].freeze
27
+
28
+ # Combined regex for faster matching of global assignments.
29
+ GLOBAL_ASSIGNMENT_REGEXP = Regexp.union(GLOBAL_ASSIGNMENT_PATTERNS).freeze
30
+
31
+ module_function
32
+
33
+ # @param parsed_body [Nokogiri::HTML::Document] parsed HTML document
34
+ # @return [Array<Hash, Array>] parsed JSON documents discovered in scripts
35
+ def json_documents(parsed_body)
36
+ # Use identity-based cache to avoid double-parsing of the same document.
37
+ # WeakMap allows the Nokogiri Document (key) to be garbage collected.
38
+ # rubocop:disable ThreadSafety/ClassInstanceVariable
39
+ (@cache ||= ObjectSpace::WeakMap.new)[parsed_body] ||=
40
+ script_documents(parsed_body) + assignment_documents(parsed_body)
41
+ # rubocop:enable ThreadSafety/ClassInstanceVariable
42
+ end
43
+
44
+ # @param parsed_body [Nokogiri::HTML::Document] parsed HTML document
45
+ # @return [Array<Hash, Array>] JSON documents extracted from JSON script tags
46
+ def script_documents(parsed_body)
47
+ parsed_body.css(JSON_SCRIPT_SELECTOR).filter_map { parse_json(_1.text) }
48
+ end
49
+
50
+ # @param parsed_body [Nokogiri::HTML::Document] parsed HTML document
51
+ # @return [Array<Hash, Array>] JSON documents extracted from global assignments
52
+ def assignment_documents(parsed_body)
53
+ parsed_body.css('script').filter_map { parse_assignment(_1.text) }
54
+ end
55
+
56
+ # @param text [String] script text that may contain a global assignment
57
+ # @return [Hash, Array, nil] parsed assignment payload when available
58
+ def parse_assignment(text)
59
+ payload = assignment_payload(text)
60
+ parse_json(payload) if payload
61
+ end
62
+
63
+ # @param text [String] script text to inspect for known assignment patterns
64
+ # @return [String, nil] extracted JSON-like assignment payload
65
+ def assignment_payload(text)
66
+ trimmed = text.to_s.strip
67
+ return if trimmed.empty?
68
+ return unless trimmed.match?(GLOBAL_ASSIGNMENT_REGEXP)
69
+
70
+ payload = trimmed.sub(GLOBAL_ASSIGNMENT_REGEXP, '')
71
+ extract_assignment_payload(payload)
72
+ end
73
+
74
+ # @param text [String] text potentially containing JSON-like payloads
75
+ # @return [String, nil] normalized assignment payload
76
+ def extract_assignment_payload(text)
77
+ extract_json_block(text) || text
78
+ end
79
+
80
+ # @param text [String] text potentially containing JSON blocks
81
+ # @return [String, nil] extracted JSON block spanning balanced brackets
82
+ def extract_json_block(text)
83
+ start_index = text.index(/[\[{]/)
84
+ return unless start_index
85
+
86
+ stop_index = scan_for_json_end(text, start_index)
87
+ text[start_index..stop_index] if stop_index
88
+ end
89
+
90
+ # @param text [String] text starting with a JSON object/array opening token
91
+ # @param start_index [Integer] index where JSON-like content starts
92
+ # @return [Integer, nil] index where the balanced JSON payload ends
93
+ def scan_for_json_end(text, start_index) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
94
+ stack = []
95
+ in_string = false
96
+ escape = false
97
+
98
+ i = start_index
99
+ len = text.length
100
+ while i < len
101
+ char = text[i]
102
+
103
+ if in_string
104
+ if escape
105
+ escape = false
106
+ elsif char == '\\'
107
+ escape = true
108
+ elsif char == '"'
109
+ in_string = false
110
+ end
111
+ else
112
+ case char
113
+ when '"' then in_string = true
114
+ when '{' then stack << '}'
115
+ when '[' then stack << ']'
116
+ when '}', ']'
117
+ expected = stack.pop
118
+ return i if expected == char && stack.empty?
119
+ end
120
+ end
121
+ i += 1
122
+ end
123
+
124
+ nil
125
+ end
126
+
127
+ # @param payload [String, nil] JSON payload to parse
128
+ # @return [Hash, Array, nil] parsed payload or nil when parsing fails
129
+ def parse_json(payload)
130
+ return unless payload
131
+
132
+ JSON.parse(payload, symbolize_names: true)
133
+ rescue JSON::ParserError => error
134
+ parse_js_object(payload, error)
135
+ end
136
+
137
+ # @param payload [String] JavaScript object-literal payload
138
+ # @param _original_error [JSON::ParserError] original JSON parse error
139
+ # @return [Hash, Array, nil] parsed payload after JavaScript coercion
140
+ def parse_js_object(payload, _original_error)
141
+ coerced = coerce_javascript_object(payload)
142
+ return unless coerced
143
+
144
+ # Some sites emit JavaScript object literals (unquoted keys, trailing commas).
145
+ # Coerce those payloads into valid JSON so we keep the same parsing pipeline.
146
+ JSON.parse(coerced, symbolize_names: true)
147
+ rescue JSON::ParserError => error
148
+ Html2rss::Log.debug("JsonState: failed to parse coerced JavaScript object (#{error.message})")
149
+ nil
150
+ end
151
+
152
+ # @param payload [String] JavaScript object-literal payload
153
+ # @return [String] JSON-compatible payload string
154
+ def coerce_javascript_object(payload)
155
+ string = payload.dup
156
+
157
+ # KISS approach: mutate common JS literal quirks instead of a full parser.
158
+ strip_trailing_commas(quote_unquoted_keys(string))
159
+ end
160
+
161
+ # @param jsonish [String] JSON-like string with potentially unquoted keys
162
+ # @return [String] payload with unquoted object keys quoted
163
+ def quote_unquoted_keys(jsonish)
164
+ jsonish.gsub(/(?<prefix>\A\s*|[{,\[]\s*)(?<key>[A-Za-z_]\w*)(?<suffix>\s*:)/) do
165
+ captures = Regexp.last_match.named_captures(symbolize_names: true)
166
+ "#{captures[:prefix]}\"#{captures[:key]}\"#{captures[:suffix]}"
167
+ end
168
+ end
169
+
170
+ # @param jsonish [String] JSON-like string with potential trailing commas
171
+ # @return [String] payload without trailing commas before closing tokens
172
+ def strip_trailing_commas(jsonish)
173
+ jsonish.gsub(/,(\s*[\]}])/, '\1')
174
+ end
175
+ end
176
+ end
177
+ end
178
+ end
179
+ end