html2rss 0.20.1 → 0.22.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 (49) hide show
  1. checksums.yaml +4 -4
  2. data/html2rss.gemspec +1 -2
  3. data/lib/html2rss/auto_source/scraper/html/class_clustering.rb +185 -0
  4. data/lib/html2rss/auto_source/scraper/html.rb +73 -16
  5. data/lib/html2rss/auto_source/scraper/json_state.rb +41 -28
  6. data/lib/html2rss/auto_source/scraper/link_heuristics.rb +85 -131
  7. data/lib/html2rss/auto_source/scraper/microdata.rb +2 -2
  8. data/lib/html2rss/auto_source/scraper/schema/category_extractor.rb +74 -28
  9. data/lib/html2rss/auto_source/scraper/schema/list_item.rb +3 -2
  10. data/lib/html2rss/auto_source/scraper/schema/thing.rb +31 -60
  11. data/lib/html2rss/auto_source/scraper/schema.rb +8 -2
  12. data/lib/html2rss/auto_source/scraper/semantic_html/deduplicator.rb +4 -18
  13. data/lib/html2rss/auto_source/scraper/semantic_html.rb +55 -11
  14. data/lib/html2rss/auto_source/scraper.rb +0 -3
  15. data/lib/html2rss/auto_source.rb +5 -12
  16. data/lib/html2rss/category_extractor.rb +54 -20
  17. data/lib/html2rss/cli.rb +61 -10
  18. data/lib/html2rss/config/class_methods.rb +3 -3
  19. data/lib/html2rss/config/validator.rb +1 -0
  20. data/lib/html2rss/config.rb +2 -2
  21. data/lib/html2rss/html_extractor/enclosure_extractor.rb +60 -89
  22. data/lib/html2rss/html_extractor/heading_extractor.rb +50 -0
  23. data/lib/html2rss/html_extractor/id_generator.rb +67 -0
  24. data/lib/html2rss/html_extractor/list_candidates.rb +2 -8
  25. data/lib/html2rss/html_extractor/semantic_anchor_candidates.rb +29 -12
  26. data/lib/html2rss/html_extractor/semantic_containers.rb +9 -35
  27. data/lib/html2rss/html_extractor/text_extractor.rb +77 -0
  28. data/lib/html2rss/html_extractor.rb +80 -61
  29. data/lib/html2rss/rendering/description_builder.rb +3 -3
  30. data/lib/html2rss/rendering.rb +2 -2
  31. data/lib/html2rss/request_service/local_file_strategy.rb +29 -0
  32. data/lib/html2rss/request_service/puppet_commander.rb +4 -0
  33. data/lib/html2rss/request_service.rb +2 -1
  34. data/lib/html2rss/rss_builder/article.rb +44 -23
  35. data/lib/html2rss/rss_builder/enclosure.rb +4 -2
  36. data/lib/html2rss/selectors/extractors/attribute.rb +1 -1
  37. data/lib/html2rss/selectors/extractors/href.rb +1 -1
  38. data/lib/html2rss/selectors/extractors/html.rb +1 -1
  39. data/lib/html2rss/selectors/extractors/static.rb +1 -1
  40. data/lib/html2rss/selectors/extractors/text.rb +1 -1
  41. data/lib/html2rss/selectors/post_processors/sanitize_html.rb +32 -36
  42. data/lib/html2rss/selectors/post_processors/substring.rb +11 -18
  43. data/lib/html2rss/selectors/post_processors/template.rb +3 -2
  44. data/lib/html2rss/selectors.rb +19 -5
  45. data/lib/html2rss/url.rb +29 -15
  46. data/lib/html2rss/version.rb +1 -1
  47. data/lib/html2rss.rb +28 -6
  48. data/schema/html2rss-config.schema.json +12 -1
  49. metadata +8 -17
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3eb20836fb55a5e33d114634c7c20bf2a65afd1a923af1bba82896797fdb099d
4
- data.tar.gz: 97c00923e0ca5744cf82f22d54aa428cc784cba3e259bbcdcd9252928f9bc3c0
3
+ metadata.gz: 9ff7cdc4e25f3abc6da000e4f672d6832fb6027e49885aecc0cad38329c5e6ae
4
+ data.tar.gz: e42c216e328bb2c56971dd58871f023f15e672563e27741c56c6cf7fe4cb322a
5
5
  SHA512:
6
- metadata.gz: 36431edafddcca32a53f562a75fbcd77fae969ca8c8fa7c2b6de77f121add2ee3c34c2b6e2e7b57109742780d62eeddde823b1a9bf8f4c1aaeed08a0ae4e5c90
7
- data.tar.gz: d3eafa9cbbecc5ccaded7b21508e0af1c43337999cb5651f2d879df5217c1aa1f3e5484bcfd6354037d709715db1cc106f2946dd592745c0ac58252bcdd26ac8
6
+ metadata.gz: e52812f947561b9a52537f1b28c530f63e116194642d13ff526fac1ad32f02d7ea6ff8ca9b5ee16e2d7f686e1babec1908ca51399ba42ef9461ed3dbe0d02117
7
+ data.tar.gz: 4754495a5947aca6de71846d1c88128d9fc1826e1014fd00806f58e7cd1e1575dc3bd2380ced3aeecd0cd6d51e0366ddee7a1f7db0220750e13f66645de2edde
data/html2rss.gemspec CHANGED
@@ -14,7 +14,7 @@ Gem::Specification.new do |spec|
14
14
  spec.description = 'Supports JSON content, custom HTTP headers, and post-processing of extracted content.'
15
15
  spec.homepage = 'https://github.com/html2rss/html2rss'
16
16
  spec.license = 'MIT'
17
- spec.required_ruby_version = '>= 3.2'
17
+ spec.required_ruby_version = '>= 3.3'
18
18
 
19
19
  if spec.respond_to?(:metadata)
20
20
  spec.metadata['allowed_push_host'] = 'https://rubygems.org'
@@ -41,7 +41,6 @@ Gem::Specification.new do |spec|
41
41
  spec.add_dependency 'kramdown'
42
42
  spec.add_dependency 'mime-types', '> 3.0'
43
43
  spec.add_dependency 'nokogiri', '>= 1.10', '< 2.0'
44
- spec.add_dependency 'parallel'
45
44
  spec.add_dependency 'puppeteer-ruby'
46
45
  spec.add_dependency 'regexp_parser'
47
46
  spec.add_dependency 'reverse_markdown', '~> 3.0'
@@ -0,0 +1,185 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ class AutoSource
5
+ module Scraper
6
+ class Html
7
+ ##
8
+ # ClassClustering clusters DOM elements on anchorless pages by class lists and scores
9
+ # candidate groups to find the best list of content cards/articles.
10
+ # rubocop:disable Metrics/ClassLength
11
+ class ClassClustering
12
+ # Node tags considered layout containers
13
+ LAYOUT_TAG_NAMES = Set['div', 'section', 'article'].freeze
14
+ # HTML/layout tags excluded from candidate nodes
15
+ EXCLUDED_TAGS = Set['html', 'body', 'nav', 'footer', 'header', 'svg', 'script', 'style'].freeze
16
+
17
+ class << self
18
+ ##
19
+ # Clusters elements in parsed_body and returns the best set of content card nodes.
20
+ #
21
+ # @param parsed_body [Nokogiri::HTML::Document] parsed HTML document
22
+ # @param minimum_selector_frequency [Integer] minimum frequency for class groups
23
+ # @return [Array<Nokogiri::XML::Node>] candidate nodes of the top-scoring class group
24
+ def call(parsed_body, minimum_selector_frequency:)
25
+ new(parsed_body, minimum_selector_frequency:).call
26
+ end
27
+ end
28
+
29
+ # @param parsed_body [Nokogiri::HTML::Document]
30
+ # @param minimum_selector_frequency [Integer]
31
+ def initialize(parsed_body, minimum_selector_frequency:)
32
+ @parsed_body = parsed_body
33
+ @minimum_frequency = minimum_selector_frequency
34
+ @text_words = {}.compare_by_identity
35
+ @has_date = {}.compare_by_identity
36
+ end
37
+
38
+ # @return [Array<Nokogiri::XML::Node>]
39
+ def call
40
+ candidate_groups = collect_candidate_groups
41
+ return [] if candidate_groups.empty?
42
+
43
+ non_containers = filter_containers(candidate_groups)
44
+ final_groups = filter_1_to_1_overlap(non_containers)
45
+
46
+ select_best_group(final_groups)
47
+ end
48
+
49
+ private
50
+
51
+ def collect_candidate_groups
52
+ class_groups = Hash.new { |h, k| h[k] = [] }
53
+ cache = {}.compare_by_identity
54
+
55
+ @parsed_body.css('[class]').each { |node| add_node_to_groups(node, class_groups, cache) }
56
+
57
+ class_groups.select { |_, nodes| nodes.size >= @minimum_frequency }
58
+ end
59
+
60
+ def add_node_to_groups(node, class_groups, cache)
61
+ return if EXCLUDED_TAGS.include?(node.name) || HtmlExtractor.ignored_container_path?(node, cache)
62
+
63
+ cls = normalize_class(node['class'])
64
+ class_groups[cls] << node unless cls.empty?
65
+ end
66
+
67
+ def normalize_class(class_attr)
68
+ class_str = class_attr.to_s.strip
69
+ return '' if class_str.empty?
70
+
71
+ # Bypass split/sort/join allocation for single-class lists
72
+ if class_str.include?(' ')
73
+ class_str.split(/\s+/).sort.join(' ')
74
+ else
75
+ class_str
76
+ end
77
+ end
78
+
79
+ # Discard group A if any node of A contains > 1 node of another group B
80
+ def filter_containers(groups)
81
+ groups.reject do |cls_a, nodes_a|
82
+ groups.any? { |cls_b, nodes_b| cls_a != cls_b && container_of?(nodes_a, nodes_b) }
83
+ end
84
+ end
85
+
86
+ def container_of?(nodes_a, nodes_b)
87
+ return false unless LAYOUT_TAG_NAMES.include?(nodes_b.first.name)
88
+
89
+ nodes_a.any? do |node_a|
90
+ nodes_b.count { |node_b| node_a != node_b && node_b.ancestors.include?(node_a) } > 1
91
+ end
92
+ end
93
+
94
+ # If group A contains group B, and they have the same size:
95
+ # - If B (the descendant) contains >= 80% of A's words, AND B's tag is div/section/article,
96
+ # B is the actual content card. Discard A.
97
+ # - Otherwise, B is a sub-element (header, metadata line, button). Discard B.
98
+ def filter_1_to_1_overlap(groups)
99
+ discarded = Set.new
100
+ groups.each_key do |cls_a|
101
+ groups.each_key do |cls_b|
102
+ next if cls_a == cls_b || discarded.include?(cls_a) || discarded.include?(cls_b)
103
+
104
+ resolve_1_to_1_overlap(cls_a, cls_b, groups, discarded)
105
+ end
106
+ end
107
+
108
+ groups.except(*discarded)
109
+ end
110
+
111
+ def resolve_1_to_1_overlap(cls_a, cls_b, groups, discarded)
112
+ nodes_a = groups[cls_a]
113
+ nodes_b = groups[cls_b]
114
+ return if nodes_a.size != nodes_b.size
115
+ return unless nodes_a.zip(nodes_b).all? { |a, b| a != b && b.ancestors.include?(a) }
116
+
117
+ discarded << (keep_descendant?(nodes_a, nodes_b) ? cls_a : cls_b)
118
+ end
119
+
120
+ def keep_descendant?(nodes_a, nodes_b)
121
+ avg_words(nodes_b) >= 0.8 * avg_words(nodes_a) &&
122
+ LAYOUT_TAG_NAMES.include?(nodes_b.first.name)
123
+ end
124
+
125
+ def select_best_group(groups)
126
+ best_nodes = []
127
+ best_score = -1
128
+
129
+ groups.each_value do |nodes|
130
+ score = score_group(nodes)
131
+ next if score.negative?
132
+
133
+ (best_nodes = nodes) && (best_score = score) if score > best_score
134
+ end
135
+
136
+ best_nodes
137
+ end
138
+
139
+ def score_group(nodes)
140
+ avg_w = avg_words(nodes)
141
+ return -1 if avg_w < 5
142
+
143
+ score = nodes.size + (avg_w / 5.0)
144
+ score += 20 if nodes_heading?(nodes)
145
+ score += 20 if nodes_time?(nodes)
146
+ score += 40 if nodes_date?(nodes)
147
+ score
148
+ end
149
+
150
+ def nodes_heading?(nodes)
151
+ nodes.any? do |n|
152
+ n.at_css(HtmlExtractor::HEADING_TAGS.join(',')) ||
153
+ n.at_css('.font-bold, .font-semibold')
154
+ end
155
+ end
156
+
157
+ def nodes_time?(nodes)
158
+ nodes.any? { |n| n.at_css('time, [datetime]') }
159
+ end
160
+
161
+ def nodes_date?(nodes)
162
+ nodes.any? { |n| date?(n) }
163
+ end
164
+
165
+ def avg_words(nodes)
166
+ nodes.sum { |n| text_words(n) } / nodes.size.to_f
167
+ end
168
+
169
+ def text_words(node)
170
+ @text_words[node] ||= HtmlExtractor.extract_visible_text(node).to_s.scan(/\p{Alnum}+/).size
171
+ end
172
+
173
+ def date?(node)
174
+ @has_date[node] ||= begin
175
+ text = HtmlExtractor.extract_visible_text(node).to_s
176
+ text.match?(%r{\b\d{4}[-/]\d{2}[-/]\d{2}\b}) ||
177
+ text.match?(/\b(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\b/i)
178
+ end
179
+ end
180
+ # rubocop:enable Metrics/ClassLength
181
+ end
182
+ end
183
+ end
184
+ end
185
+ end
@@ -62,7 +62,9 @@ module Html2rss
62
62
  @url = url
63
63
  @extractor = extractor
64
64
  @opts = opts
65
+ @fallback_anchorless = opts.fetch(:fallback_anchorless, false)
65
66
  @link_heuristics = LinkHeuristics.new(url)
67
+ @ignored_cache = {}.compare_by_identity
66
68
  end
67
69
 
68
70
  attr_reader :parsed_body
@@ -73,10 +75,13 @@ module Html2rss
73
75
  def each
74
76
  return enum_for(:each) unless block_given?
75
77
 
76
- each_article_tag do |article_tag, selected_anchor|
77
- article_hash = extract_article(article_tag, selected_anchor:)
78
- yield article_hash if article_hash
79
- end
78
+ articles.each { yield _1 }
79
+ end
80
+
81
+ ##
82
+ # @return [Boolean] true when the scraper can likely extract articles
83
+ def extractable?
84
+ articles.any?
80
85
  end
81
86
 
82
87
  ##
@@ -91,7 +96,7 @@ module Html2rss
91
96
  # @return [Boolean] true when the node is a good extraction boundary
92
97
  def article_tag_condition?(node)
93
98
  # Ignore tags that are below ignored DOM chrome.
94
- return false if HtmlExtractor.ignored_container_path?(node)
99
+ return false if HtmlExtractor.ignored_container_path?(node, @ignored_cache)
95
100
  return true if %w[body html].include?(node.name)
96
101
  return false unless (parent = node.parent)
97
102
 
@@ -100,14 +105,41 @@ module Html2rss
100
105
 
101
106
  private
102
107
 
108
+ def articles
109
+ @articles ||= begin
110
+ extracted = each_article_tag.filter_map do |article_tag, selected_anchor|
111
+ extract_article(article_tag, selected_anchor:)
112
+ end
113
+
114
+ extracted += find_anchorless_articles if @fallback_anchorless
115
+ extracted
116
+ end
117
+ end
118
+
119
+ def find_anchorless_articles
120
+ ClassClustering.call(parsed_body, minimum_selector_frequency:).map do |node|
121
+ @extractor.new(node, base_url: @url, selected_anchor: nil, fallback_anchorless: true).call
122
+ end
123
+ end
124
+
125
+ ##
126
+ # @return [Integer]
103
127
  def minimum_selector_frequency = @opts[:minimum_selector_frequency] || DEFAULT_MINIMUM_SELECTOR_FREQUENCY
128
+
129
+ ##
130
+ # @return [Boolean]
104
131
  def use_top_selectors = @opts[:use_top_selectors] || DEFAULT_USE_TOP_SELECTORS
105
132
 
133
+ ##
134
+ # @param node [Nokogiri::XML::Node]
135
+ # @return [Integer]
106
136
  def anchor_count(node)
107
- @anchor_counts ||= {}
108
- @anchor_counts[node.path] ||= node.name == 'a' ? 1 : node.css('a').size
137
+ (@anchor_counts ||= {}.compare_by_identity)[node] ||= node.name == 'a' ? 1 : node.css('a').size
109
138
  end
110
139
 
140
+ ##
141
+ # @param node [Nokogiri::XML::Node]
142
+ # @return [Boolean]
111
143
  def relevant_anchor?(node)
112
144
  destination_facts = @link_heuristics.destination_facts(node)
113
145
  return false unless destination_facts
@@ -115,14 +147,24 @@ module Html2rss
115
147
  !noise_anchor?(node, destination_facts)
116
148
  end
117
149
 
150
+ ##
151
+ # @yield [article_tag, selected_anchor]
152
+ # @yieldparam article_tag [Nokogiri::XML::Node]
153
+ # @yieldparam selected_anchor [Nokogiri::XML::Node]
154
+ # @return [Enumerator, nil]
118
155
  def each_article_tag(&block)
119
156
  return enum_for(:each_article_tag) unless block
120
157
 
121
- list_candidates.each_article_tag(anchor_filter: method(:relevant_anchor?),
122
- boundary_condition: method(:article_tag_condition?),
123
- &block)
158
+ anchor_filter = ->(node) { relevant_anchor?(node) }
159
+ boundary_condition = ->(node) { article_tag_condition?(node) }
160
+
161
+ list_candidates.each_article_tag(anchor_filter:, boundary_condition:, &block)
124
162
  end
125
163
 
164
+ ##
165
+ # @param article_tag [Nokogiri::XML::Node]
166
+ # @param selected_anchor [Nokogiri::XML::Node, nil]
167
+ # @return [Hash, nil]
126
168
  def extract_article(article_tag, selected_anchor: nil)
127
169
  selected_anchor ||= preferred_anchor_for(article_tag)
128
170
  return unless selected_anchor
@@ -131,18 +173,28 @@ module Html2rss
131
173
  @extractor.new(article_tag, base_url: @url, selected_anchor:).call
132
174
  end
133
175
 
176
+ ##
177
+ # @param anchor [Nokogiri::XML::Node]
178
+ # @param destination_facts [DestinationFacts]
179
+ # @return [Boolean]
134
180
  def noise_anchor?(anchor, destination_facts) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
135
181
  return true unless destination_facts
136
182
 
137
- text = HtmlExtractor.extract_visible_text(anchor).to_s.strip
183
+ (@noise_anchors ||= {}.compare_by_identity)[anchor] ||= begin
184
+ text = HtmlExtractor.extract_visible_text(anchor).to_s.strip
138
185
 
139
- destination_facts.taxonomy_path ||
140
- short_utility_label?(text, destination_facts) ||
141
- (@link_heuristics.recommended_text?(text) && destination_facts.shallow) ||
142
- (@link_heuristics.utility_prefix_text?(text) && destination_facts.high_confidence_utility_destination) ||
143
- (@link_heuristics.utility_text?(text) && destination_facts.vanity_path)
186
+ destination_facts.taxonomy_path ||
187
+ short_utility_label?(text, destination_facts) ||
188
+ (@link_heuristics.recommended_text?(text) && destination_facts.shallow) ||
189
+ (@link_heuristics.utility_prefix_text?(text) && destination_facts.high_confidence_utility_destination) ||
190
+ (@link_heuristics.utility_text?(text) && destination_facts.vanity_path)
191
+ end
144
192
  end
145
193
 
194
+ ##
195
+ # @param text [String]
196
+ # @param destination_facts [DestinationFacts]
197
+ # @return [Boolean]
146
198
  def short_utility_label?(text, destination_facts)
147
199
  destination_facts.utility_path &&
148
200
  !destination_facts.content_path &&
@@ -150,11 +202,16 @@ module Html2rss
150
202
  text.scan(/\p{Alnum}+/).size <= 3
151
203
  end
152
204
 
205
+ ##
206
+ # @param article_tag [Nokogiri::XML::Node]
207
+ # @return [Nokogiri::XML::Node, nil]
153
208
  def preferred_anchor_for(article_tag)
154
209
  article_tag.css(HtmlExtractor::MAIN_ANCHOR_SELECTOR).find { relevant_anchor?(_1) } ||
155
210
  HtmlExtractor.main_anchor_for(article_tag)
156
211
  end
157
212
 
213
+ ##
214
+ # @return [HtmlExtractor::ListCandidates]
158
215
  def list_candidates
159
216
  HtmlExtractor::ListCandidates.new(
160
217
  parsed_body,
@@ -30,6 +30,9 @@ module Html2rss
30
30
  /(?:window|self|globalThis)\.angular\s*=\s*/m
31
31
  ].freeze
32
32
 
33
+ # Combined regex for faster matching of global assignments.
34
+ GLOBAL_ASSIGNMENT_REGEXP = Regexp.union(GLOBAL_ASSIGNMENT_PATTERNS).freeze
35
+
33
36
  # Preferred keys when extracting title-like values from state payloads.
34
37
  TITLE_KEYS = %i[title headline name text].freeze
35
38
  # Preferred keys when extracting URL-like values from state payloads.
@@ -53,7 +56,12 @@ module Html2rss
53
56
  # @param parsed_body [Nokogiri::HTML::Document] parsed HTML document
54
57
  # @return [Array<Hash, Array>] parsed JSON documents discovered in scripts
55
58
  def json_documents(parsed_body)
56
- script_documents(parsed_body) + assignment_documents(parsed_body)
59
+ # Use identity-based cache to avoid double-parsing of the same document.
60
+ # WeakMap allows the Nokogiri Document (key) to be garbage collected.
61
+ # rubocop:disable ThreadSafety/ClassInstanceVariable
62
+ (@cache ||= ObjectSpace::WeakMap.new)[parsed_body] ||=
63
+ script_documents(parsed_body) + assignment_documents(parsed_body)
64
+ # rubocop:enable ThreadSafety/ClassInstanceVariable
57
65
  end
58
66
 
59
67
  # @param parsed_body [Nokogiri::HTML::Document] parsed HTML document
@@ -80,15 +88,10 @@ module Html2rss
80
88
  def assignment_payload(text)
81
89
  trimmed = text.to_s.strip
82
90
  return if trimmed.empty?
91
+ return unless trimmed.match?(GLOBAL_ASSIGNMENT_REGEXP)
83
92
 
84
- GLOBAL_ASSIGNMENT_PATTERNS.each do |pattern|
85
- next unless trimmed.match?(pattern)
86
-
87
- payload = trimmed.sub(pattern, '')
88
- return extract_assignment_payload(payload)
89
- end
90
-
91
- nil
93
+ payload = trimmed.sub(GLOBAL_ASSIGNMENT_REGEXP, '')
94
+ extract_assignment_payload(payload)
92
95
  end
93
96
 
94
97
  # @param text [String] text potentially containing JSON-like payloads
@@ -116,8 +119,10 @@ module Html2rss
116
119
  in_string = false
117
120
  escape = false
118
121
 
119
- text.each_char.with_index do |char, index|
120
- next if index < start_index
122
+ i = start_index
123
+ len = text.length
124
+ while i < len
125
+ char = text[i]
121
126
 
122
127
  if in_string
123
128
  if escape
@@ -127,24 +132,22 @@ module Html2rss
127
132
  elsif char == '"'
128
133
  in_string = false
129
134
  end
130
- next
131
- end
132
-
133
- case char
134
- when '"'
135
- in_string = true
136
- when '{'
137
- stack << '}'
138
- when '['
139
- stack << ']'
140
- when '}', ']'
141
- expected = stack.pop
142
- return index if expected == char && stack.empty?
135
+ else
136
+ case char
137
+ when '"' then in_string = true
138
+ when '{' then stack << '}'
139
+ when '[' then stack << ']'
140
+ when '}', ']'
141
+ expected = stack.pop
142
+ return i if expected == char && stack.empty?
143
+ end
143
144
  end
145
+ i += 1
144
146
  end
145
147
 
146
148
  nil
147
149
  end
150
+
148
151
  # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
149
152
 
150
153
  # @param payload [String, nil] JSON payload to parse
@@ -184,8 +187,9 @@ module Html2rss
184
187
  # @param jsonish [String] JSON-like string with potentially unquoted keys
185
188
  # @return [String] payload with unquoted object keys quoted
186
189
  def quote_unquoted_keys(jsonish)
187
- jsonish.gsub(/(\A\s*|[{,\[]\s*)([A-Za-z_]\w*)(\s*:)/) do
188
- "#{Regexp.last_match(1)}\"#{Regexp.last_match(2)}\"#{Regexp.last_match(3)}"
190
+ jsonish.gsub(/(?<prefix>\A\s*|[{,\[]\s*)(?<key>[A-Za-z_]\w*)(?<suffix>\s*:)/) do
191
+ captures = Regexp.last_match.named_captures(symbolize_names: true)
192
+ "#{captures[:prefix]}\"#{captures[:key]}\"#{captures[:suffix]}"
189
193
  end
190
194
  end
191
195
 
@@ -301,7 +305,7 @@ module Html2rss
301
305
  # rubocop:disable Metrics/MethodLength
302
306
  # @param entry [Hash] raw article entry candidate
303
307
  # @param base_url [String, Html2rss::Url] base URL for relative link resolution
304
- # @return [Hash{Symbol => Object}, nil] normalized article hash for downstream extraction
308
+ # @return [Hash{Symbol => Object, nil}] normalized article hash for downstream extraction
305
309
  def normalise(entry, base_url:)
306
310
  return unless entry.is_a?(Hash)
307
311
 
@@ -415,12 +419,17 @@ module Html2rss
415
419
 
416
420
  attr_reader :parsed_body
417
421
 
422
+ # @return [Boolean] true when the page contains article-like arrays in JSON state
423
+ def extractable?
424
+ json_documents.any? { CandidateDetector.candidate_array?(_1) }
425
+ end
426
+
418
427
  # @yield [Hash{Symbol => Object}] normalized article hash
419
428
  # @return [Enumerator, void] article enumerator when no block is given
420
429
  def each
421
430
  return enum_for(:each) unless block_given?
422
431
 
423
- DocumentScanner.json_documents(parsed_body).each do |document|
432
+ json_documents.each do |document|
424
433
  discover_articles(document) do |article|
425
434
  yield article if article
426
435
  end
@@ -431,6 +440,10 @@ module Html2rss
431
440
 
432
441
  attr_reader :url
433
442
 
443
+ def json_documents
444
+ self.class.json_documents(parsed_body)
445
+ end
446
+
434
447
  def discover_articles(document, &block)
435
448
  case document
436
449
  when Array then handle_array(document, &block)