html2rss 0.24.0 → 0.26.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 (129) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +76 -2
  3. data/html2rss.gemspec +4 -1
  4. data/lib/html2rss/auto_source/cleanup.rb +62 -36
  5. data/lib/html2rss/auto_source/scraper/html.rb +68 -145
  6. data/lib/html2rss/auto_source/scraper/json_state/article_normalizer.rb +116 -0
  7. data/lib/html2rss/auto_source/scraper/json_state/candidate_detector.rb +63 -0
  8. data/lib/html2rss/auto_source/scraper/json_state/document_scanner.rb +179 -0
  9. data/lib/html2rss/auto_source/scraper/json_state/value_finder.rb +55 -0
  10. data/lib/html2rss/auto_source/scraper/json_state.rb +31 -401
  11. data/lib/html2rss/auto_source/scraper/schema/item_list.rb +2 -14
  12. data/lib/html2rss/auto_source/scraper/semantic_html/entry_deduplicator.rb +35 -30
  13. data/lib/html2rss/auto_source/scraper/semantic_html.rb +40 -120
  14. data/lib/html2rss/auto_source/scraper/sitemap/parser.rb +161 -0
  15. data/lib/html2rss/auto_source/scraper/sitemap.rb +10 -7
  16. data/lib/html2rss/auto_source/scraper/wordpress_api.rb +6 -2
  17. data/lib/html2rss/auto_source/scraper/xhr_articles.rb +69 -0
  18. data/lib/html2rss/auto_source/scraper.rb +109 -53
  19. data/lib/html2rss/auto_source/segment.rb +29 -0
  20. data/lib/html2rss/auto_source/segmenter/cluster.rb +163 -0
  21. data/lib/html2rss/auto_source/segmenter/list.rb +103 -0
  22. data/lib/html2rss/auto_source/segmenter/primary_link.rb +80 -0
  23. data/lib/html2rss/auto_source/segmenter/semantic.rb +104 -0
  24. data/lib/html2rss/auto_source/segmenter.rb +95 -0
  25. data/lib/html2rss/auto_source.rb +79 -21
  26. data/lib/html2rss/capture.rb +315 -0
  27. data/lib/html2rss/cli.rb +72 -18
  28. data/lib/html2rss/config/auto_source_contract.rb +5 -1
  29. data/lib/html2rss/config/request_controls.rb +33 -0
  30. data/lib/html2rss/config/schema.rb +67 -26
  31. data/lib/html2rss/config/selectors_validator.rb +60 -21
  32. data/lib/html2rss/config/validator.rb +33 -35
  33. data/lib/html2rss/config.rb +6 -2
  34. data/lib/html2rss/error.rb +15 -3
  35. data/lib/html2rss/feed_builder/rss.rb +17 -5
  36. data/lib/html2rss/feed_pipeline/auto_fallback.rb +16 -6
  37. data/lib/html2rss/feed_pipeline/runtime_policy.rb +3 -43
  38. data/lib/html2rss/feed_pipeline.rb +11 -0
  39. data/lib/html2rss/feed_result.rb +1 -1
  40. data/lib/html2rss/hash_util.rb +17 -0
  41. data/lib/html2rss/html/article_extractor/category_extractor.rb +10 -36
  42. data/lib/html2rss/html/article_extractor/date_extractor.rb +2 -7
  43. data/lib/html2rss/html/article_extractor/enclosure_extractor.rb +5 -50
  44. data/lib/html2rss/html/article_extractor/image_extractor.rb +5 -16
  45. data/lib/html2rss/html/article_rules/category.rb +55 -0
  46. data/lib/html2rss/html/article_rules/date.rb +25 -0
  47. data/lib/html2rss/html/article_rules/enclosure.rb +72 -0
  48. data/lib/html2rss/html/article_rules/image.rb +109 -0
  49. data/lib/html2rss/html/article_rules.rb +10 -0
  50. data/lib/html2rss/html/rendering/audio_renderer.rb +2 -12
  51. data/lib/html2rss/html/rendering/escaped_attributes.rb +27 -0
  52. data/lib/html2rss/html/rendering/image_renderer.rb +2 -12
  53. data/lib/html2rss/html/rendering/pdf_renderer.rb +2 -8
  54. data/lib/html2rss/html/rendering/video_renderer.rb +2 -12
  55. data/lib/html2rss/html/sst_article_extractor.rb +308 -0
  56. data/lib/html2rss/link_destination/destination_facts.rb +40 -0
  57. data/lib/html2rss/link_destination/noise_policy.rb +68 -0
  58. data/lib/html2rss/link_destination/path_classifier.rb +208 -0
  59. data/lib/html2rss/link_destination/text_classifier.rb +64 -0
  60. data/lib/html2rss/link_destination.rb +8 -0
  61. data/lib/html2rss/mcp/server.rb +577 -0
  62. data/lib/html2rss/mcp.rb +21 -0
  63. data/lib/html2rss/request_service/blocked_surface.rb +24 -1
  64. data/lib/html2rss/request_service/botasaurus_contract.rb +89 -9
  65. data/lib/html2rss/request_service/botasaurus_strategy.rb +4 -2
  66. data/lib/html2rss/request_service/budget.rb +7 -35
  67. data/lib/html2rss/request_service/context.rb +0 -6
  68. data/lib/html2rss/request_service/faraday_strategy.rb +87 -3
  69. data/lib/html2rss/request_service/network_guard.rb +5 -3
  70. data/lib/html2rss/request_service/policy.rb +1 -1
  71. data/lib/html2rss/request_service/response.rb +15 -1
  72. data/lib/html2rss/request_service/strategy.rb +1 -2
  73. data/lib/html2rss/request_service.rb +4 -9
  74. data/lib/html2rss/scoring/cluster_scorer.rb +87 -0
  75. data/lib/html2rss/scoring/container_assessor.rb +77 -0
  76. data/lib/html2rss/scoring/engine.rb +145 -0
  77. data/lib/html2rss/scoring/link_resolver.rb +84 -0
  78. data/lib/html2rss/scoring/observation.rb +53 -0
  79. data/lib/html2rss/scoring/ranked_segment.rb +33 -0
  80. data/lib/html2rss/scoring/score.rb +11 -0
  81. data/lib/html2rss/scoring.rb +8 -0
  82. data/lib/html2rss/selectors/extractors/attribute.rb +15 -0
  83. data/lib/html2rss/selectors/extractors/href.rb +12 -0
  84. data/lib/html2rss/selectors/extractors/html.rb +12 -0
  85. data/lib/html2rss/selectors/extractors/static.rb +14 -0
  86. data/lib/html2rss/selectors/extractors/text.rb +11 -0
  87. data/lib/html2rss/selectors/post_processors/gsub.rb +18 -0
  88. data/lib/html2rss/selectors/post_processors/html_to_markdown.rb +11 -0
  89. data/lib/html2rss/selectors/post_processors/markdown_to_html.rb +12 -0
  90. data/lib/html2rss/selectors/post_processors/parse_time.rb +11 -0
  91. data/lib/html2rss/selectors/post_processors/parse_uri.rb +11 -0
  92. data/lib/html2rss/selectors/post_processors/sanitize_html.rb +23 -1
  93. data/lib/html2rss/selectors/post_processors/substring.rb +21 -0
  94. data/lib/html2rss/selectors/post_processors/template.rb +20 -0
  95. data/lib/html2rss/selectors/schema_doc.rb +99 -0
  96. data/lib/html2rss/selectors.rb +0 -20
  97. data/lib/html2rss/sst/attrs.rb +91 -0
  98. data/lib/html2rss/sst/document.rb +23 -0
  99. data/lib/html2rss/sst/index.rb +112 -0
  100. data/lib/html2rss/sst/node.rb +147 -0
  101. data/lib/html2rss/sst/normalizer.rb +171 -0
  102. data/lib/html2rss/sst/tags.rb +26 -0
  103. data/lib/html2rss/sst/text.rb +81 -0
  104. data/lib/html2rss/sst.rb +8 -0
  105. data/lib/html2rss/url.rb +11 -7
  106. data/lib/html2rss/version.rb +1 -1
  107. data/lib/html2rss.rb +57 -26
  108. data/schema/html2rss-config.schema.json +428 -85
  109. metadata +93 -27
  110. data/lib/html2rss/auto_source/discovery/dom_clustering/group_scorer.rb +0 -80
  111. data/lib/html2rss/auto_source/discovery/dom_clustering/overlap_resolver.rb +0 -86
  112. data/lib/html2rss/auto_source/discovery/dom_clustering.rb +0 -119
  113. data/lib/html2rss/auto_source/discovery/list_candidates.rb +0 -94
  114. data/lib/html2rss/auto_source/discovery/semantic_anchor_candidates.rb +0 -219
  115. data/lib/html2rss/auto_source/discovery/semantic_containers.rb +0 -71
  116. data/lib/html2rss/auto_source/discovery/sitemap.rb +0 -159
  117. data/lib/html2rss/auto_source/discovery.rb +0 -14
  118. data/lib/html2rss/auto_source/link_heuristics/anchor_signals.rb +0 -28
  119. data/lib/html2rss/auto_source/link_heuristics/container_assessor.rb +0 -106
  120. data/lib/html2rss/auto_source/link_heuristics/container_signals.rb +0 -80
  121. data/lib/html2rss/auto_source/link_heuristics/destination_facts.rb +0 -42
  122. data/lib/html2rss/auto_source/link_heuristics/href_extractor.rb +0 -38
  123. data/lib/html2rss/auto_source/link_heuristics/path_classifier.rb +0 -221
  124. data/lib/html2rss/auto_source/link_heuristics/text_classifier.rb +0 -66
  125. data/lib/html2rss/auto_source/link_heuristics.rb +0 -139
  126. data/lib/html2rss/request_service/browserless_strategy.rb +0 -132
  127. data/lib/html2rss/request_service/puppet_commander/navigation_guards.rb +0 -148
  128. data/lib/html2rss/request_service/puppet_commander/preload_runner.rb +0 -86
  129. data/lib/html2rss/request_service/puppet_commander.rb +0 -95
@@ -4,31 +4,12 @@ module Html2rss
4
4
  class AutoSource
5
5
  module Scraper
6
6
  ##
7
- # Scrapes semantic containers by choosing one primary content link per
8
- # block before extraction.
9
- #
10
- # This scraper is intentionally container-first:
11
- # 1. collect candidate semantic containers once
12
- # 2. select the strongest content-like anchor within each container
13
- # 3. extract fields from the container while honoring that anchor choice
14
- #
15
- # The result is lower recall on weak-signal blocks, but much better link
16
- # quality on modern teaser cards that mix headlines, utility links, and
17
- # duplicate image overlays.
7
+ # Scrapes semantic containers via SST: Normalizer Segmenter Scoring → SstArticleExtractor.
18
8
  class SemanticHtml
19
9
  include Enumerable
20
10
 
21
- # Container plus selected anchor, scoring metadata, and extracted article.
22
- Entry = Data.define(
23
- :container,
24
- :selected_anchor,
25
- :destination_facts,
26
- :quality_score,
27
- :junk_score,
28
- :final_score,
29
- :position,
30
- :article
31
- )
11
+ # Maximum articles materialized after ranking.
12
+ TOP_K = Scoring::Engine::TOP_K
32
13
 
33
14
  ##
34
15
  # @return [Symbol] config key used to enable or configure this scraper
@@ -40,133 +21,72 @@ module Html2rss
40
21
  return false unless parsed_body
41
22
 
42
23
  new(parsed_body, url: 'https://example.com').extractable?
24
+ rescue ArgumentError
25
+ false
43
26
  end
44
27
 
45
- # @param parsed_body [Nokogiri::HTML::Document] parsed HTML document
28
+ # @param parsed_body [Nokogiri::HTML::Document, nil] parsed HTML (when +document:+ omitted)
46
29
  # @param url [String, Html2rss::Url] base url
47
- # @param extractor [Class] extractor class used for article extraction
30
+ # @param document [SST::Document, nil] memoized SST document from AutoSource
31
+ # @param link_resolver [Scoring::LinkResolver, nil] shared page-scoped resolver
48
32
  # @param opts [Hash] scraper-specific options
49
33
  # @option opts [Boolean] :fallback_anchorless whether to keep containers without a primary anchor
50
- def initialize(parsed_body, url:, extractor: Html2rss::Html::ArticleExtractor, **opts)
34
+ def initialize(parsed_body = nil, url:, document: nil, link_resolver: nil, **opts)
51
35
  @parsed_body = parsed_body
36
+ @provided_document = document
37
+ @provided_link_resolver = link_resolver
52
38
  @url = url
53
- @extractor = extractor
54
39
  @permit_unanchored = opts.fetch(:fallback_anchorless, false)
55
- @link_heuristics = LinkHeuristics.new(url)
56
40
  end
57
41
 
58
- attr_reader :parsed_body
59
-
60
42
  ##
61
- # Yields extracted article hashes for each semantic container that
62
- # survives anchor selection.
63
- #
64
- # Detection and extraction share the same memoized entry list so this
65
- # scraper does not rerun anchor ranking once a page has already been
66
- # accepted as extractable.
67
- #
68
- # @yieldparam article_hash [Hash] extracted article hash
69
- # @return [Enumerator<Hash>]
43
+ # @yieldparam article [Html2rss::Article]
44
+ # @return [Enumerator<Html2rss::Article>]
70
45
  def each
71
46
  return enum_for(:each) unless block_given?
72
47
 
73
- ranked_entries.each { yield _1.article }
48
+ articles.each { yield _1 }
74
49
  end
75
50
 
76
51
  ##
77
- # Reports whether the page contains at least one semantic container with
78
- # a selectable primary anchor.
79
- #
80
- # @return [Boolean] true when at least one candidate container yields a primary anchor
52
+ # @return [Boolean]
81
53
  def extractable?
82
- extractable_entries.any?
83
- end
84
-
85
- # @return [Array<Nokogiri::XML::Node>]
86
- def candidate_containers
87
- @candidate_containers ||= collect_candidate_containers
88
- end
89
-
90
- # @param container [Nokogiri::XML::Node]
91
- # @return [Nokogiri::XML::Node, nil]
92
- def primary_anchor_for(container)
93
- Discovery::SemanticAnchorCandidates.new(
94
- container,
95
- link_heuristics: @link_heuristics
96
- ).to_a.max_by(&:score)&.anchor
54
+ ranked_segments.any?
55
+ rescue ArgumentError
56
+ false
97
57
  end
98
58
 
99
- # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
100
- def extractable_entries
101
- @extractable_entries ||= candidate_containers.filter_map do |container|
102
- selected_anchor = primary_anchor_for(container)
103
-
104
- next unless selected_anchor || @permit_unanchored
105
-
106
- destination_facts = selected_anchor ? normalized_destination(selected_anchor) : nil
107
- next if selected_anchor && !destination_facts
108
- # Cheap path-only reject before title/DOM hard-junk observations.
109
- next if destination_facts&.high_confidence_junk_path
110
-
111
- signals = @link_heuristics.assess_container(container, selected_anchor, destination_facts:)
112
- next if signals.hard_junk?
59
+ private
113
60
 
114
- Entry.new(
115
- container:,
116
- selected_anchor:,
117
- destination_facts:,
118
- quality_score: signals.quality_score,
119
- junk_score: signals.junk_score,
120
- final_score: signals.final_score,
121
- position: document_position(container),
122
- article: nil
123
- )
61
+ def articles
62
+ @articles ||= begin
63
+ deduplicator = EntryDeduplicator.new(@url, scraper: self.class, link_resolver:)
64
+ entries = deduplicator.call(ranked_segments)
65
+ entries.filter_map { |ranked| deduplicator.article_for(ranked) }
124
66
  end
67
+ rescue ArgumentError
68
+ []
125
69
  end
126
- # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
127
-
128
- # rubocop:disable Metrics/MethodLength
129
- def ranked_entries
130
- @ranked_entries ||= begin
131
- deduplicator = EntryDeduplicator.new(@url, @extractor)
132
- entries = deduplicator.call(extractable_entries)
133
- entries = stable_rank(entries)
134
-
135
- entries.filter_map do |entry|
136
- article = deduplicator.article_for(entry)
137
- next unless article
138
70
 
139
- Entry.new(
140
- container: entry.container,
141
- selected_anchor: entry.selected_anchor,
142
- destination_facts: entry.destination_facts,
143
- quality_score: entry.quality_score,
144
- junk_score: entry.junk_score,
145
- final_score: entry.final_score,
146
- position: entry.position,
147
- article:
148
- )
149
- end
71
+ def ranked_segments
72
+ @ranked_segments ||= begin
73
+ segments = Segmenter.call(
74
+ document,
75
+ base_url: @url,
76
+ strategy: :semantic,
77
+ permit_unanchored: @permit_unanchored,
78
+ link_resolver:
79
+ )
80
+ Scoring::Engine.new(link_resolver:).rank_top(segments, limit: TOP_K)
150
81
  end
151
82
  end
152
- # rubocop:enable Metrics/MethodLength
153
-
154
- def collect_candidate_containers
155
- Discovery::SemanticContainers.call(parsed_body)
156
- end
157
-
158
- private
159
-
160
- def document_position(container)
161
- (@document_positions ||= candidate_containers.each_with_index.to_h).fetch(container)
162
- end
163
83
 
164
- def normalized_destination(anchor)
165
- (@normalized_destinations ||= {}.compare_by_identity)[anchor] ||= @link_heuristics.destination_facts(anchor)
84
+ def link_resolver
85
+ @link_resolver ||= @provided_link_resolver || Scoring::LinkResolver.new(@url)
166
86
  end
167
87
 
168
- def stable_rank(entries)
169
- entries.sort_by { |entry| [-entry.final_score, entry.position] }
88
+ def document
89
+ @document ||= @provided_document || SST::Normalizer.call(@parsed_body)
170
90
  end
171
91
  end
172
92
  end
@@ -0,0 +1,161 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'date'
4
+ require 'nokogiri'
5
+
6
+ module Html2rss
7
+ class AutoSource
8
+ module Scraper
9
+ class Sitemap
10
+ ##
11
+ # Parses XML sitemap documents and extracts prioritized, recent article entries.
12
+ class Parser
13
+ # Default minimum priority threshold (0.0 to 1.0)
14
+ DEFAULT_MIN_PRIORITY = 0.3
15
+ # Default maximum age in days for sitemap entries
16
+ DEFAULT_MAX_AGE_DAYS = 30
17
+ # Google News sitemap XML namespace
18
+ NEWS_NS = 'http://www.google.com/schemas/sitemap-news/0.9'
19
+
20
+ # Result value object returned by .call
21
+ # @!attribute entries [Array<SitemapEntry>] parsed entries (empty for sitemapindex docs)
22
+ # @!attribute sub_sitemap_urls [Array<String>] child sitemap URLs (empty for urlset docs)
23
+ Result = Data.define(:entries, :sub_sitemap_urls)
24
+
25
+ # Immutable facts for one sitemap entry
26
+ SitemapEntry = Data.define(:url, :title, :published_at, :priority, :changefreq)
27
+
28
+ class << self
29
+ ##
30
+ # Parses sitemap XML body and returns a Result.
31
+ #
32
+ # @param xml_content [String] sitemap XML body string
33
+ # @param min_priority [Float] minimum priority score (0.0..1.0)
34
+ # @param max_age_days [Integer] maximum age in days for entries
35
+ # @return [Result] result with entries and/or sub_sitemap_urls
36
+ def call(xml_content, min_priority: DEFAULT_MIN_PRIORITY, max_age_days: DEFAULT_MAX_AGE_DAYS)
37
+ new(xml_content, min_priority:, max_age_days:).call
38
+ end
39
+ end
40
+
41
+ ##
42
+ # @param xml_content [String]
43
+ # @param min_priority [Float]
44
+ # @param max_age_days [Integer]
45
+ def initialize(xml_content, min_priority: DEFAULT_MIN_PRIORITY, max_age_days: DEFAULT_MAX_AGE_DAYS)
46
+ @xml_content = xml_content
47
+ @min_priority = min_priority
48
+ @max_age_days = max_age_days
49
+ end
50
+
51
+ ##
52
+ # @return [Result]
53
+ def call
54
+ document = Nokogiri::XML(xml_content)
55
+ return Result.new(entries: [], sub_sitemap_urls: []) if invalid_xml?(document)
56
+
57
+ if sitemapindex?(document)
58
+ Result.new(entries: [], sub_sitemap_urls: extract_sub_sitemap_urls(document))
59
+ else
60
+ Result.new(entries: extract_entries(document), sub_sitemap_urls: [])
61
+ end
62
+ end
63
+
64
+ private
65
+
66
+ attr_reader :xml_content, :min_priority, :max_age_days
67
+
68
+ def invalid_xml?(document)
69
+ document.errors.any? && document.at_css('urlset, sitemapindex').nil?
70
+ end
71
+
72
+ def sitemapindex?(document)
73
+ !document.at_xpath('//*[local-name()="sitemapindex"]').nil?
74
+ end
75
+
76
+ def extract_sub_sitemap_urls(document)
77
+ document.xpath('//*[local-name()="sitemap"]/*[local-name()="loc"]').filter_map do |node|
78
+ text = node.text.strip
79
+ text unless text.empty?
80
+ end
81
+ end
82
+
83
+ def extract_entries(document)
84
+ document.xpath('//*[local-name()="url"]').filter_map { build_entry(_1) }
85
+ end
86
+
87
+ def build_entry(url_node)
88
+ loc = parse_loc(url_node)
89
+ return unless loc
90
+ return if invalid_entry_signals?(url_node)
91
+
92
+ published_at = parse_date(url_node)
93
+ return if stale_date?(published_at)
94
+
95
+ build_entry_facts(url_node, loc, published_at)
96
+ end
97
+
98
+ def build_entry_facts(url_node, loc, published_at)
99
+ SitemapEntry.new(
100
+ url: loc,
101
+ title: parse_title(url_node),
102
+ published_at:,
103
+ priority: parse_priority(url_node),
104
+ changefreq: parse_changefreq(url_node)
105
+ )
106
+ end
107
+
108
+ def parse_loc(url_node)
109
+ node = url_node.at_xpath('./*[local-name()="loc"]')
110
+ text = node&.text&.strip
111
+ text unless text.to_s.empty?
112
+ end
113
+
114
+ def parse_changefreq(url_node)
115
+ node = url_node.at_xpath('./*[local-name()="changefreq"]')
116
+ node ? node.text.strip.downcase : nil
117
+ end
118
+
119
+ def invalid_entry_signals?(url_node)
120
+ parse_priority(url_node) < min_priority || parse_changefreq(url_node) == 'never'
121
+ end
122
+
123
+ def parse_priority(url_node)
124
+ node = url_node.at_xpath('./*[local-name()="priority"]')
125
+ text = node&.text&.strip
126
+ text ? text.to_f : 0.5
127
+ end
128
+
129
+ def parse_date(url_node)
130
+ raw_date = raw_date_from(url_node)
131
+ return unless raw_date
132
+
133
+ Time.parse(raw_date).utc.iso8601
134
+ rescue ArgumentError
135
+ nil
136
+ end
137
+
138
+ def raw_date_from(url_node)
139
+ news_node = url_node.at_xpath('.//news:publication_date', 'news' => NEWS_NS)
140
+ lastmod_node = url_node.at_xpath('./*[local-name()="lastmod"]')
141
+ (news_node || lastmod_node)&.text&.strip
142
+ end
143
+
144
+ def parse_title(url_node)
145
+ node = url_node.at_xpath('.//news:title', 'news' => NEWS_NS)
146
+ node&.text&.strip
147
+ end
148
+
149
+ def stale_date?(published_at)
150
+ return false unless published_at
151
+
152
+ time = Time.parse(published_at)
153
+ (Time.now.utc - time) > (max_age_days * 86_400)
154
+ rescue ArgumentError
155
+ false
156
+ end
157
+ end
158
+ end
159
+ end
160
+ end
161
+ end
@@ -42,14 +42,16 @@ module Html2rss
42
42
  # @param parsed_body [Nokogiri::HTML::Document] parsed HTML document
43
43
  # @param url [String, Html2rss::Url] canonical page URL
44
44
  # @param request_session [Html2rss::RequestSession, nil] shared request session for follow-up fetches
45
+ # @param body [String, nil] raw response body for direct sitemap XML parsing
45
46
  # @option _opts [Float] :min_priority minimum priority score (default: 0.3)
46
47
  # @option _opts [Integer] :max_age_days maximum entry age in days (default: 30)
47
- def initialize(parsed_body, url:, request_session: nil, **opts)
48
+ def initialize(parsed_body, url:, request_session: nil, body: nil, **opts)
48
49
  @parsed_body = parsed_body
50
+ @body = body
49
51
  @url = Html2rss::Url.from_absolute(url)
50
52
  @request_session = request_session
51
- @min_priority = opts.fetch(:min_priority, Discovery::Sitemap::DEFAULT_MIN_PRIORITY)
52
- @max_age_days = opts.fetch(:max_age_days, Discovery::Sitemap::DEFAULT_MAX_AGE_DAYS)
53
+ @min_priority = opts.fetch(:min_priority, Scraper::Sitemap::Parser::DEFAULT_MIN_PRIORITY)
54
+ @max_age_days = opts.fetch(:max_age_days, Scraper::Sitemap::Parser::DEFAULT_MAX_AGE_DAYS)
53
55
  end
54
56
 
55
57
  ##
@@ -71,7 +73,7 @@ module Html2rss
71
73
 
72
74
  private
73
75
 
74
- attr_reader :parsed_body, :url, :request_session, :min_priority, :max_age_days
76
+ attr_reader :parsed_body, :body, :url, :request_session, :min_priority, :max_age_days
75
77
 
76
78
  def fetch_and_parse_entries
77
79
  entries = parse_direct_sitemap
@@ -87,16 +89,17 @@ module Html2rss
87
89
  end
88
90
 
89
91
  def parse_sitemap_xml(xml_body)
90
- result = Discovery::Sitemap.call(xml_body, min_priority:, max_age_days:)
92
+ result = Scraper::Sitemap::Parser.call(xml_body, min_priority:, max_age_days:)
91
93
  return fetch_sub_sitemaps(result.sub_sitemap_urls) if result.sub_sitemap_urls.any?
92
94
 
93
95
  result.entries
94
96
  end
95
97
 
96
98
  def parse_direct_sitemap
99
+ return [] unless body
97
100
  return [] unless parsed_body.at_xpath('//*[local-name()="urlset" or local-name()="sitemapindex"]')
98
101
 
99
- Discovery::Sitemap.call(parsed_body.to_s, min_priority:, max_age_days:).entries
102
+ Scraper::Sitemap::Parser.call(body, min_priority:, max_age_days:).entries
100
103
  end
101
104
 
102
105
  def fetch_sub_sitemaps(sub_urls)
@@ -114,7 +117,7 @@ module Html2rss
114
117
  xml = fetch_xml(sub_url)
115
118
  return [] unless xml
116
119
 
117
- Discovery::Sitemap.call(xml, min_priority:, max_age_days:).entries
120
+ Scraper::Sitemap::Parser.call(xml, min_priority:, max_age_days:).entries
118
121
  rescue Html2rss::RequestService::RequestBudgetExceeded
119
122
  Log.warn("#{self.class}: sitemap fan-out stopped — request budget exhausted")
120
123
  nil
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'cgi'
3
4
  require 'date'
4
- require 'nokogiri'
5
5
 
6
6
  module Html2rss
7
7
  class AutoSource
@@ -153,7 +153,11 @@ module Html2rss
153
153
  end
154
154
 
155
155
  def rendered_text(value)
156
- rendered_html(value)&.then { Nokogiri::HTML.fragment(_1).text.strip }
156
+ html = rendered_html(value)
157
+ return unless html
158
+
159
+ text = CGI.unescapeHTML(html.gsub(/<[^>]*>/m, ' ')).gsub(/\s+/, ' ').strip
160
+ text unless text.empty?
157
161
  end
158
162
 
159
163
  def rendered_html(value)
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module Html2rss
6
+ class AutoSource
7
+ module Scraper
8
+ # Extracts articles from JSON XHR/fetch responses captured during a
9
+ # browser-tier scrape (see RequestService::Response#captured_responses).
10
+ class XhrArticles
11
+ include Enumerable
12
+
13
+ # @return [Symbol] scraper config key
14
+ def self.options_key = :xhr_articles
15
+
16
+ # @param _opts [Hash] unused scraper options
17
+ # @return [Integer] zero — no additional HTTP requests
18
+ def self.request_slots(_opts = {}) = 0
19
+
20
+ # Not detectable from HTML alone; instance {#extractable?} uses captures.
21
+ #
22
+ # @param _parsed_body [Nokogiri::HTML::Document, nil]
23
+ # @return [Boolean]
24
+ def self.articles?(_parsed_body) = false
25
+
26
+ # @param _parsed_body [Nokogiri::HTML::Document, nil] unused HTML document
27
+ # @param url [String, Html2rss::Url] page URL used to resolve relative links
28
+ # @param captured_responses [Array<Hash>] JSON bodies from Response#captured_responses
29
+ # @param _opts [Hash] scraper-specific options
30
+ # @option _opts [Object] :_reserved reserved for future scraper-specific options
31
+ def initialize(_parsed_body, url:, captured_responses: [], **_opts)
32
+ @url = url
33
+ @captured_responses = captured_responses
34
+ end
35
+
36
+ # @return [Boolean] true when any captured body contains article-like arrays
37
+ def extractable?
38
+ parsed_bodies.any? { |doc| JsonState::CandidateDetector.candidate_array?(doc) }
39
+ end
40
+
41
+ # @yield [Hash{Symbol => Object}] normalized article hash
42
+ # @return [Enumerator, void] article enumerator when no block is given
43
+ def each
44
+ return enum_for(:each) unless block_given?
45
+
46
+ parsed_bodies.each do |doc|
47
+ JsonState.discover_articles(doc, base_url: @url) { |article| yield article if article }
48
+ end
49
+ end
50
+
51
+ private
52
+
53
+ def parsed_bodies
54
+ @parsed_bodies ||= @captured_responses.filter_map { |captured| parse(captured) }
55
+ .select { |doc| JsonState::CandidateDetector.candidate_array?(doc) }
56
+ end
57
+
58
+ def parse(captured)
59
+ body = captured[:body] || captured['body']
60
+ return unless body.is_a?(String)
61
+
62
+ JSON.parse(body, symbolize_names: true)
63
+ rescue JSON::ParserError
64
+ nil
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end