html2rss 0.27.2 → 0.29.1

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 (94) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +82 -25
  3. data/html2rss.gemspec +0 -1
  4. data/lib/html2rss/article.rb +1 -2
  5. data/lib/html2rss/auto_source/README.md +12 -9
  6. data/lib/html2rss/auto_source/cleanup.rb +92 -1
  7. data/lib/html2rss/auto_source/scraper/json_state/document_scanner.rb +3 -5
  8. data/lib/html2rss/auto_source/scraper/meta_oembed.rb +15 -5
  9. data/lib/html2rss/auto_source/scraper/microdata.rb +8 -6
  10. data/lib/html2rss/auto_source/scraper/native_feed.rb +93 -0
  11. data/lib/html2rss/auto_source/scraper/schema.rb +39 -21
  12. data/lib/html2rss/auto_source/scraper.rb +6 -4
  13. data/lib/html2rss/auto_source/segmenter/cluster.rb +2 -3
  14. data/lib/html2rss/auto_source/segmenter/primary_link.rb +1 -2
  15. data/lib/html2rss/auto_source/segmenter.rb +1 -2
  16. data/lib/html2rss/auto_source.rb +32 -11
  17. data/lib/html2rss/batch.rb +169 -0
  18. data/lib/html2rss/capture/README.md +15 -6
  19. data/lib/html2rss/capture.rb +158 -15
  20. data/lib/html2rss/channel.rb +1 -2
  21. data/lib/html2rss/cli/probe_view.rb +66 -0
  22. data/lib/html2rss/cli/render.rb +124 -0
  23. data/lib/html2rss/cli/validate.rb +91 -0
  24. data/lib/html2rss/cli.rb +318 -193
  25. data/lib/html2rss/config/auto_source_contract.rb +7 -0
  26. data/lib/html2rss/config/request_headers.rb +4 -14
  27. data/lib/html2rss/config/schema.rb +7 -5
  28. data/lib/html2rss/config/validator.rb +1 -0
  29. data/lib/html2rss/config.rb +64 -18
  30. data/lib/html2rss/doctor/botasaurus.rb +130 -0
  31. data/lib/html2rss/feed_builder/rss.rb +1 -2
  32. data/lib/html2rss/feed_pipeline/README.md +7 -5
  33. data/lib/html2rss/feed_pipeline/auto_fallback.rb +78 -19
  34. data/lib/html2rss/feed_pipeline/runtime_policy.rb +1 -0
  35. data/lib/html2rss/feed_pipeline.rb +48 -21
  36. data/lib/html2rss/feed_resolution/README.md +52 -0
  37. data/lib/html2rss/feed_resolution/candidate_generator.rb +139 -0
  38. data/lib/html2rss/feed_resolution/diag.rb +32 -0
  39. data/lib/html2rss/feed_resolution/options.rb +31 -0
  40. data/lib/html2rss/feed_resolution/policy.rb +47 -0
  41. data/lib/html2rss/feed_resolution/probe.rb +59 -0
  42. data/lib/html2rss/feed_resolution/scorer.rb +49 -0
  43. data/lib/html2rss/feed_resolution.rb +268 -0
  44. data/lib/html2rss/html/article_extractor/heading_extractor.rb +24 -14
  45. data/lib/html2rss/html/article_extractor.rb +4 -9
  46. data/lib/html2rss/html/feed_link.rb +4 -2
  47. data/lib/html2rss/html/navigator.rb +4 -39
  48. data/lib/html2rss/html/probe.rb +82 -0
  49. data/lib/html2rss/html/sst_article_extractor.rb +1 -2
  50. data/lib/html2rss/link_destination/noise_policy.rb +1 -2
  51. data/lib/html2rss/link_destination/path_classifier.rb +20 -19
  52. data/lib/html2rss/mcp/README.md +80 -0
  53. data/lib/html2rss/mcp/contract.rb +135 -13
  54. data/lib/html2rss/mcp/outcome/playbook.rb +122 -0
  55. data/lib/html2rss/mcp/outcome.rb +112 -40
  56. data/lib/html2rss/mcp/runtime.rb +45 -0
  57. data/lib/html2rss/mcp/server/tools.rb +277 -0
  58. data/lib/html2rss/mcp/server.rb +91 -232
  59. data/lib/html2rss/mcp.rb +3 -0
  60. data/lib/html2rss/page_recon/README.md +69 -0
  61. data/lib/html2rss/page_recon/diagnostics.rb +211 -0
  62. data/lib/html2rss/page_recon.rb +297 -0
  63. data/lib/html2rss/recon.rb +252 -0
  64. data/lib/html2rss/request_service/blocked_surface.rb +1 -0
  65. data/lib/html2rss/request_service/botasaurus_contract.rb +8 -1
  66. data/lib/html2rss/request_service/botasaurus_strategy.rb +1 -1
  67. data/lib/html2rss/request_service/faraday_strategy.rb +11 -7
  68. data/lib/html2rss/request_service/policy.rb +1 -2
  69. data/lib/html2rss/request_service/response.rb +27 -4
  70. data/lib/html2rss/request_service/strategy.rb +1 -2
  71. data/lib/html2rss/request_service.rb +15 -1
  72. data/lib/html2rss/request_session.rb +4 -2
  73. data/lib/html2rss/scrape_target.rb +24 -0
  74. data/lib/html2rss/selectors/post_processors/sanitize_html.rb +2 -4
  75. data/lib/html2rss/selectors/post_processors.rb +0 -1
  76. data/lib/html2rss/selectors.rb +33 -10
  77. data/lib/html2rss/sst/attrs.rb +1 -2
  78. data/lib/html2rss/sst/normalizer.rb +49 -11
  79. data/lib/html2rss/status.rb +48 -24
  80. data/lib/html2rss/surface_category.rb +63 -0
  81. data/lib/html2rss/syndication/README.md +21 -0
  82. data/lib/html2rss/syndication/candidate_catalog.rb +26 -0
  83. data/lib/html2rss/syndication/discovery.rb +215 -0
  84. data/lib/html2rss/syndication/parser.rb +137 -0
  85. data/lib/html2rss/syndication.rb +10 -0
  86. data/lib/html2rss/test/enhance_audit.rb +192 -0
  87. data/lib/html2rss/test.rb +458 -0
  88. data/lib/html2rss/url.rb +23 -2
  89. data/lib/html2rss/version.rb +1 -1
  90. data/lib/html2rss.rb +157 -35
  91. data/schema/html2rss-config.schema.json +47 -29
  92. metadata +34 -18
  93. data/lib/html2rss/mcp/inspect.rb +0 -241
  94. data/lib/html2rss/selectors/post_processors/html_to_markdown.rb +0 -59
@@ -14,11 +14,15 @@ module Html2rss
14
14
 
15
15
  # Bodies that look like HTML even when Content-Type is missing, empty, or wrong.
16
16
  HTML_BODY_SNIFF = /\A\s*(?:<!DOCTYPE\s+html|<html)/i
17
+ # Content-Type markers for RSS/Atom syndication responses.
18
+ FEED_CT_MARKERS = %w[rss+xml atom+xml rss atom].freeze
19
+ # Body sniff for unlabeled syndication (first 800 bytes, downcased).
20
+ FEED_BODY_MARKERS = ['<rss', '<feed xmlns', '<feed '].freeze
17
21
  # Charset from Content-Type or a leading <meta charset>.
18
22
  CHARSET_PARAMETER = /charset\s*=\s*["']?([\w.:-]+)/i
19
23
  # Bytes scanned for a meta charset hint (HTML spec looks at the first 1024).
20
24
  META_CHARSET_BYTES = 2048
21
- private_constant :CHARSET_PARAMETER, :META_CHARSET_BYTES
25
+ private_constant :CHARSET_PARAMETER, :META_CHARSET_BYTES, :FEED_CT_MARKERS, :FEED_BODY_MARKERS
22
26
 
23
27
  ##
24
28
  # @param body [String] the body of the response
@@ -27,7 +31,7 @@ module Html2rss
27
31
  # @param status [Integer, nil] the HTTP status code when available
28
32
  # @param transport_meta [Hash] allowlisted upstream telemetry (frozen when present)
29
33
  # @param captured_responses [Array<Hash>] JSON XHR/fetch bodies captured during browser scrapes
30
- # rubocop:disable Metrics/MethodLength, Metrics/ParameterLists -- transport + capture fields stay co-located
34
+ # rubocop:disable-next Metrics/MethodLength, Metrics/ParameterLists -- transport + capture fields stay co-located
31
35
  def initialize(body:, url:, headers: {}, status: nil, transport_meta: EMPTY_TRANSPORT_META,
32
36
  captured_responses: EMPTY_CAPTURED_RESPONSES)
33
37
  @body = body
@@ -46,7 +50,6 @@ module Html2rss
46
50
  captured_responses.freeze
47
51
  end
48
52
  end
49
- # rubocop:enable Metrics/MethodLength, Metrics/ParameterLists
50
53
 
51
54
  # @return [String] the raw body of the response
52
55
  attr_reader :body
@@ -74,7 +77,14 @@ module Html2rss
74
77
 
75
78
  # @return [Boolean] whether response content is HTML (header or sniffed body, never JSON)
76
79
  def html_response?
77
- content_type.include?('text/html') || (!json_response? && html_looking_body?)
80
+ content_type.include?('text/html') || (!json_response? && !feed_response? && html_looking_body?)
81
+ end
82
+
83
+ # @return [Boolean] whether response content is RSS/Atom (header or sniffed body)
84
+ def feed_response?
85
+ return @feed_response if defined?(@feed_response)
86
+
87
+ @feed_response = feed_content_type? || (!json_response? && !html_looking_body? && feed_looking_body?)
78
88
  end
79
89
 
80
90
  ##
@@ -143,6 +153,19 @@ module Html2rss
143
153
  def html_looking_body?
144
154
  body.to_s.b.match?(HTML_BODY_SNIFF)
145
155
  end
156
+
157
+ def feed_content_type?
158
+ ct = content_type.downcase
159
+ return false if ct.empty? || ct.include?('html') || ct.include?('json')
160
+
161
+ # Substring match against Content-Type (not Array#intersect? on a String).
162
+ FEED_CT_MARKERS.any? { |marker| ct.include?(marker) }
163
+ end
164
+
165
+ def feed_looking_body?
166
+ snippet = body.to_s[0, 800].downcase
167
+ FEED_BODY_MARKERS.any? { |marker| snippet.include?(marker) }
168
+ end
146
169
  end
147
170
  end
148
171
  end
@@ -111,7 +111,7 @@ module Html2rss
111
111
 
112
112
  # @param reason [String] timeout classification (budget_exhausted / transport)
113
113
  # @return [void]
114
- # rubocop:disable Metrics/AbcSize -- structured timeout fields stay in one log line
114
+ # rubocop:disable-next Metrics/AbcSize -- structured timeout fields stay in one log line
115
115
  def log_timeout!(reason:)
116
116
  remaining = ctx.budget.remaining_timeout_seconds
117
117
  remaining_label = remaining.nil? ? 'untracked' : format('%.3f', remaining)
@@ -124,7 +124,6 @@ module Html2rss
124
124
  ]
125
125
  Log.info("#{self.class}: request timeout #{detail.join(' ')}")
126
126
  end
127
- # rubocop:enable Metrics/AbcSize
128
127
 
129
128
  # @param error [StandardError]
130
129
  # @return [void]
@@ -29,10 +29,24 @@ module Html2rss
29
29
  class CrossOriginFollowUpDenied < Html2rss::Error; end
30
30
  # Raised when a response exceeds configured size limits.
31
31
  class ResponseTooLarge < Html2rss::Error; end
32
+ # Raised when HTTP redirect limits are exceeded.
33
+ class RedirectLimitReached < Html2rss::Error; end
32
34
  # Raised when blocked content surfaces are detected.
33
35
  class BlockedSurfaceDetected < Html2rss::Error; end
36
+
34
37
  # Raised when a request times out.
35
- class RequestTimedOut < Html2rss::Error; end
38
+ class RequestTimedOut < Html2rss::Error
39
+ ##
40
+ # @param message [String, nil] timeout failure summary
41
+ # @param timeout_phase [String, nil] scrape-api stage when known (+queue+/+boot+/+work+)
42
+ def initialize(message = nil, timeout_phase: nil)
43
+ @timeout_phase = timeout_phase
44
+ super(message)
45
+ end
46
+
47
+ # @return [String, nil] scrape-api timeout stage, or nil for transport/budget timeouts
48
+ attr_reader :timeout_phase
49
+ end
36
50
 
37
51
  # Raised when Botasaurus configuration is missing or invalid.
38
52
  class BotasaurusConfigurationError < Html2rss::Error
@@ -15,11 +15,13 @@ module Html2rss
15
15
  # @param strategy [Symbol] request strategy for the session
16
16
  # @param budget [RequestService::Budget] shared request budget
17
17
  # @param policy [RequestService::Policy] request policy (from FeedPipeline::RuntimePolicy.resources_for)
18
+ # @param scrape_url [String, nil] effective fetch URL when it differs from config channel URL
18
19
  # @param logger [Logger] logger used for operational warnings
19
20
  # @return [RequestSession] configured request session
20
- def build(config:, strategy:, budget:, policy:, logger: Html2rss::Log)
21
+ # rubocop:disable-next Metrics/ParameterLists -- scrape_url override stays beside config
22
+ def build(config:, strategy:, budget:, policy:, scrape_url: nil, logger: Html2rss::Log)
21
23
  context = RequestService::Context.new(
22
- url: config.url, headers: config.headers, request: config.request, policy:, budget:
24
+ url: scrape_url || config.url, headers: config.headers, request: config.request, policy:, budget:
23
25
  )
24
26
  new(context:, strategy:, logger:)
25
27
  end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ ##
5
+ # Immutable entry vs effective scrape URLs for one pipeline run.
6
+ #
7
+ # Replaces mutating +Config#scrape_url=+ after {FeedResolution} rewrites the fetch URL.
8
+ ScrapeTarget = Data.define(:entry_url, :effective_url) do
9
+ ##
10
+ # @param config [Html2rss::Config]
11
+ # @return [ScrapeTarget]
12
+ def self.from_config(config)
13
+ entry = config.url
14
+ new(entry_url: entry, effective_url: entry)
15
+ end
16
+
17
+ ##
18
+ # @param url [String, Html2rss::Url]
19
+ # @return [ScrapeTarget]
20
+ def with_effective(url)
21
+ self.class.new(entry_url:, effective_url: Url.from_absolute(url).to_s)
22
+ end
23
+ end
24
+ end
@@ -105,7 +105,7 @@ module Html2rss
105
105
  # @param html [String]
106
106
  # @param url [String, Html2rss::Url]
107
107
  # @return [String, nil]
108
- # rubocop:disable ThreadSafety/ClassInstanceVariable
108
+ # rubocop:disable-next ThreadSafety/ClassInstanceVariable
109
109
  def self.get(html, url)
110
110
  return nil if String(html).empty?
111
111
 
@@ -117,12 +117,11 @@ module Html2rss
117
117
  context = Selectors::Context.new(config: { channel: { url: } }, options: {})
118
118
  @fragment_cache[key] = new(html, context).get
119
119
  end
120
- # rubocop:enable ThreadSafety/ClassInstanceVariable
121
120
 
122
121
  ##
123
122
  # @param channel_url [String, Html2rss::Url]
124
123
  # @return [Hash] the memoized sanitize configuration
125
- # rubocop:disable Metrics/MethodLength, ThreadSafety/ClassInstanceVariable
124
+ # rubocop:disable-next Metrics/MethodLength, ThreadSafety/ClassInstanceVariable
126
125
  def self.sanitize_config(channel_url)
127
126
  @sanitize_configs ||= {}
128
127
  @sanitize_configs[channel_url] ||= begin
@@ -143,7 +142,6 @@ module Html2rss
143
142
  config.freeze
144
143
  end
145
144
  end
146
- # rubocop:enable Metrics/MethodLength, ThreadSafety/ClassInstanceVariable
147
145
 
148
146
  ##
149
147
  # @return [String, nil]
@@ -23,7 +23,6 @@ module Html2rss
23
23
  # The key is the name to use in the feed config.
24
24
  NAME_TO_CLASS = {
25
25
  gsub: Gsub,
26
- html_to_markdown: HtmlToMarkdown,
27
26
  markdown_to_html: MarkdownToHtml,
28
27
  parse_time: ParseTime,
29
28
  parse_uri: ParseUri,
@@ -95,13 +95,16 @@ module Html2rss
95
95
  # @return [Hash] Hash of attributes for the article.
96
96
  def extract_article(item, page_response = response)
97
97
  scope = item_scope_for(item, page_response.url)
98
- @rss_item_attributes.each_with_object({}) do |selector_key, hash|
98
+ hash = @rss_item_attributes.each_with_object({}) do |selector_key, h|
99
99
  value = scope.select(selector_key)
100
100
  next if value.nil?
101
101
 
102
102
  article_key = SELECTOR_TO_ARTICLE_KEY.fetch(selector_key, selector_key)
103
- hash[article_key] = article_key == :enclosures ? wrap_enclosure_value(value) : value
103
+ h[article_key] = article_key == :enclosures ? wrap_enclosure_value(value) : value
104
104
  end
105
+
106
+ hash[:url] ||= default_item_url(item, page_response.url) if anchor_element?(item)
107
+ hash
105
108
  end
106
109
 
107
110
  ##
@@ -112,7 +115,7 @@ module Html2rss
112
115
  # @param article_tag [Nokogiri::XML::Element] HTML element to extract additional info from.
113
116
  # @param base_url [String, Html2rss::Url] base URL for normalization during enhancement
114
117
  # @return [Hash] The enhanced article hash.
115
- # rubocop:disable Metrics/MethodLength
118
+ # rubocop:disable-next Metrics/MethodLength
116
119
  def enhance_article_hash(article_hash, article_tag, base_url = @url)
117
120
  selected_anchor = Html2rss::Html::Navigator.main_anchor_for(article_tag)
118
121
  extracted = Html2rss::Html::ArticleExtractor.call(
@@ -130,7 +133,6 @@ module Html2rss
130
133
  hash[key] = value
131
134
  end
132
135
  end
133
- # rubocop:enable Metrics/MethodLength
134
136
 
135
137
  ##
136
138
  # Selects the value for a given attribute from an HTML element.
@@ -157,13 +159,11 @@ module Html2rss
157
159
 
158
160
  raise InvalidSelectorName, "Attribute selector '#{name}' is reserved for items." if name == ITEMS_SELECTOR_KEY
159
161
 
160
- selector_key, config = selector_config_for(name)
162
+ selector_key, config = selector_config_for(name, allow_nil: name == :url)
163
+ return default_item_url(scope.item, scope.base_url) if fallback_url_selector?(selector_key, config, scope.item)
164
+ raise InvalidSelectorName, "Selector for '#{selector_key}' is not defined." if config.nil?
161
165
 
162
- if SPECIAL_ATTRIBUTES.member?(selector_key)
163
- select_special(selector_key, scope:, config:)
164
- else
165
- select_regular(selector_key, scope:, config:)
166
- end
166
+ dispatch_select(selector_key, scope:, config:)
167
167
  end
168
168
 
169
169
  private
@@ -317,5 +317,28 @@ module Html2rss
317
317
 
318
318
  { url:, type: config[:content_type] }
319
319
  end
320
+
321
+ def anchor_element?(item)
322
+ item.respond_to?(:name) && item.name.to_s.casecmp('a').zero?
323
+ end
324
+
325
+ def fallback_url_selector?(selector_key, config, item)
326
+ selector_key == :url && config.nil? && anchor_element?(item)
327
+ end
328
+
329
+ def dispatch_select(selector_key, scope:, config:)
330
+ if SPECIAL_ATTRIBUTES.member?(selector_key)
331
+ select_special(selector_key, scope:, config:)
332
+ else
333
+ select_regular(selector_key, scope:, config:)
334
+ end
335
+ end
336
+
337
+ def default_item_url(item, base_url)
338
+ href = item['href'].to_s.strip
339
+ return if href.empty?
340
+
341
+ Url.from_relative(href, base_url)
342
+ end
320
343
  end
321
344
  end
@@ -29,7 +29,7 @@ module Html2rss
29
29
  # @param type [String, nil]
30
30
  # @param raw [Hash{String => String}, nil]
31
31
  # @return [Attrs]
32
- # rubocop:disable Metrics/MethodLength, Metrics/ParameterLists
32
+ # rubocop:disable-next Metrics/MethodLength, Metrics/ParameterLists
33
33
  def build(href: nil, src: nil, id: nil, class_names: nil, datetime: nil, itemprop: nil,
34
34
  style: nil, srcset: nil, type: nil, raw: nil)
35
35
  new(
@@ -45,7 +45,6 @@ module Html2rss
45
45
  raw: normalize_raw(raw)
46
46
  )
47
47
  end
48
- # rubocop:enable Metrics/MethodLength, Metrics/ParameterLists
49
48
 
50
49
  ##
51
50
  # @return [Attrs]
@@ -8,6 +8,9 @@ module Html2rss
8
8
  # Sole Nokogiri consumer on the heuristic auto-source path. Builds an
9
9
  # immutable SST::Document with parent/depth/chrome indices.
10
10
  class Normalizer # rubocop:disable Metrics/ClassLength
11
+ # Raised when no SST nodes survive normalization for the chosen root.
12
+ class EmptyTree < ArgumentError; end
13
+
11
14
  # Hard ceiling for SST node allocations; beyond this we degrade.
12
15
  MAX_NODES = 5_000
13
16
 
@@ -50,6 +53,12 @@ module Html2rss
50
53
  )
51
54
  /xi
52
55
 
56
+ # Typed Attrs fields excluded from the leftover raw hash.
57
+ TYPED_ATTR_NAMES = %w[href src id class datetime itemprop style srcset type].to_set.freeze
58
+
59
+ # String form of Tags::IGNORED_CONTAINER_NAMES for chrome checks without to_sym.
60
+ IGNORED_CONTAINER_TAGS = Tags::IGNORED_CONTAINER_NAMES.to_set(&:to_s).freeze
61
+
53
62
  class << self
54
63
  ##
55
64
  # @param input [String, Nokogiri::HTML::Document, Nokogiri::XML::Node]
@@ -89,9 +98,9 @@ module Html2rss
89
98
  ##
90
99
  # @return [Document]
91
100
  def call
92
- root_nk = @parsed_body.respond_to?(:root) ? (@parsed_body.at_css('html') || @parsed_body.root) : @parsed_body
101
+ root_nk = resolve_root_nk
93
102
  root = normalize_element(root_nk, parent: nil, depth: 0, path: '', chrome: false)
94
- raise ArgumentError, 'SST Normalizer produced an empty tree' unless root
103
+ raise EmptyTree, 'SST Normalizer produced an empty tree' unless root
95
104
 
96
105
  index = Index.new(root:, parents: @parents, depths: @depths, ignored_chrome: @ignored_chrome)
97
106
  Document.build(root:, index:, degraded: @degraded, node_count: @node_count)
@@ -99,14 +108,45 @@ module Html2rss
99
108
 
100
109
  private
101
110
 
102
- # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
111
+ # @return [Nokogiri::XML::Node]
112
+ # rubocop:disable-next Metrics/MethodLength -- explicit root discovery fallback chain
113
+ def resolve_root_nk
114
+ parsed = @parsed_body
115
+
116
+ if parsed.respond_to?(:at_css)
117
+ html = parsed.at_css('html')
118
+ return html if html
119
+
120
+ body = parsed.at_css('body')
121
+ if body
122
+ Html2rss::Log.warn('sst.normalizer root fallback: body')
123
+ return body
124
+ end
125
+ end
126
+
127
+ if parsed.respond_to?(:element_children)
128
+ first = parsed.element_children.find { |child| element_root?(child) }
129
+ if first
130
+ Html2rss::Log.warn('sst.normalizer root fallback: first element child')
131
+ return first
132
+ end
133
+ end
134
+
135
+ Html2rss::Log.warn('sst.normalizer root fallback: self')
136
+ parsed
137
+ end
138
+
139
+ def element_root?(node)
140
+ node.element? && !STRIPPED_TAGS.include?(Html2rss::Html::Probe.tag(node))
141
+ end
142
+
143
+ # rubocop:disable-next Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
103
144
  def normalize_element(nk_node, parent:, depth:, path:, chrome:)
104
145
  return unless nk_node.respond_to?(:name)
105
- return if nk_node.text? || nk_node.comment? || nk_node.cdata?
146
+ return unless nk_node.element?
106
147
 
107
- tag = nk_node.name.to_s.downcase
148
+ tag = Html2rss::Html::Probe.tag(nk_node)
108
149
  return if STRIPPED_TAGS.include?(tag)
109
- return if @degraded && !SEMANTIC_DEGRADE_TAGS.include?(tag)
110
150
 
111
151
  if @node_count >= MAX_NODES && !@degraded
112
152
  @degraded = true
@@ -119,9 +159,9 @@ module Html2rss
119
159
  tag_path = path.empty? ? "/#{tag}" : "#{path}/#{tag}"
120
160
  attrs = extract_attrs(nk_node)
121
161
  own_text = direct_text(nk_node)
122
- chrome_here = chrome || Tags::IGNORED_CONTAINER_NAMES.include?(tag.to_sym)
162
+ chrome_here = chrome || IGNORED_CONTAINER_TAGS.include?(tag)
123
163
 
124
- children = nk_node.children.filter_map do |child|
164
+ children = nk_node.element_children.filter_map do |child|
125
165
  normalize_element(child, parent: :pending, depth: depth + 1, path: tag_path, chrome: chrome_here)
126
166
  end.freeze
127
167
 
@@ -135,7 +175,6 @@ module Html2rss
135
175
 
136
176
  node
137
177
  end
138
- # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
139
178
 
140
179
  def extract_attrs(nk_node) # rubocop:disable Metrics/MethodLength
141
180
  Attrs.build(
@@ -153,10 +192,9 @@ module Html2rss
153
192
  end
154
193
 
155
194
  def raw_attrs(nk_node)
156
- typed = %w[href src id class datetime itemprop style srcset type].to_set
157
195
  nk_node.attribute_nodes.each_with_object({}) do |attr, raw|
158
196
  name = attr.name.to_s
159
- next if typed.include?(name)
197
+ next if TYPED_ATTR_NAMES.include?(name)
160
198
  next unless name.match?(RAW_ATTR_KEEP)
161
199
 
162
200
  raw[name] = attr.value.to_s
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module Html2rss
3
+ module Html2rss # rubocop:disable Metrics/ModuleLength -- Status Data.define + marshal stay co-located
4
4
  ##
5
5
  # Shared RSS +generator+ / JSON Feed +user_comment+ formatter string.
6
6
  #
@@ -9,7 +9,7 @@ module Html2rss
9
9
  # Tallies and counters are validated and frozen at construction (including Marshal load).
10
10
  Status = Data.define(
11
11
  :version, :scraper_tallies, :dedup_dropped, :selected_strategy, :attempt_count,
12
- :strategy_attempts, :admission_drops
12
+ :strategy_attempts, :admission_drops, :entry_url, :scrape_url, :entry_resolution
13
13
  ) do
14
14
  class << self
15
15
  ##
@@ -21,10 +21,15 @@ module Html2rss
21
21
  # @param attempt_count [Integer] auto-fallback attempt count (0 when not under +:auto+)
22
22
  # @param strategy_attempts [Array<Hash>] auto-fallback attempt hashes (empty outside +:auto+)
23
23
  # @param admission_drops [Hash{String => Integer}] Cleanup reason → count (empty when unused)
24
+ # @param scrape_target [Html2rss::ScrapeTarget, nil] domain URL pair (mapped to wire strings)
25
+ # @param entry_url [String, nil] original channel URL when entry resolution ran
26
+ # @param scrape_url [String, nil] effective scrape URL after resolution
27
+ # @param entry_resolution [Html2rss::FeedResolution::Diag, Hash, nil] resolution diagnostics
24
28
  # @return [Html2rss::Status]
25
- # rubocop:disable Metrics/ParameterLists -- Status kwargs stay co-located
29
+ # rubocop:disable-next Metrics/ParameterLists, Metrics/MethodLength -- Status kwargs stay co-located
26
30
  def build(articles:, dedup_dropped: 0, selected_strategy: nil, attempt_count: 0,
27
- strategy_attempts: [], admission_drops: {})
31
+ strategy_attempts: [], admission_drops: {}, scrape_target: nil,
32
+ entry_url: nil, scrape_url: nil, entry_resolution: nil)
28
33
  tallies = articles.filter_map(&:scraper).tally.transform_keys { |klass| scraper_name(klass) }
29
34
  new(
30
35
  version: Html2rss::VERSION,
@@ -33,10 +38,12 @@ module Html2rss
33
38
  selected_strategy:,
34
39
  attempt_count:,
35
40
  strategy_attempts:,
36
- admission_drops:
41
+ admission_drops:,
42
+ entry_url: entry_url || scrape_target&.entry_url,
43
+ scrape_url: scrape_url || scrape_target&.effective_url,
44
+ entry_resolution:
37
45
  )
38
46
  end
39
- # rubocop:enable Metrics/ParameterLists
40
47
 
41
48
  ##
42
49
  # @param klass [Class, #to_s] scraper class
@@ -54,10 +61,13 @@ module Html2rss
54
61
  # @param attempt_count [Integer]
55
62
  # @param strategy_attempts [Array<Hash>]
56
63
  # @param admission_drops [Hash{String => Integer}]
57
- # rubocop:disable Metrics/ParameterLists, Metrics/MethodLength -- Status Data.define members
64
+ # @param entry_url [String, nil]
65
+ # @param scrape_url [String, nil]
66
+ # @param entry_resolution [Hash, nil]
67
+ # rubocop:disable-next Metrics/ParameterLists, Metrics/MethodLength -- Status Data.define members
58
68
  def initialize(
59
69
  version:, scraper_tallies:, dedup_dropped:, selected_strategy: nil, attempt_count: 0,
60
- strategy_attempts: [], admission_drops: {}
70
+ strategy_attempts: [], admission_drops: {}, entry_url: nil, scrape_url: nil, entry_resolution: nil
61
71
  )
62
72
  dedup = Integer(dedup_dropped)
63
73
  attempts = Integer(attempt_count)
@@ -70,21 +80,18 @@ module Html2rss
70
80
  selected_strategy:,
71
81
  attempt_count: attempts,
72
82
  strategy_attempts: freeze_attempts(strategy_attempts),
73
- admission_drops: freeze_tallies(admission_drops)
83
+ admission_drops: freeze_tallies(admission_drops),
84
+ entry_url: freeze_optional_string(entry_url),
85
+ scrape_url: freeze_optional_string(scrape_url),
86
+ entry_resolution: freeze_entry_resolution(entry_resolution)
74
87
  )
75
88
  end
76
- # rubocop:enable Metrics/ParameterLists, Metrics/MethodLength
77
89
 
78
90
  ##
79
- # Observability hash for web (+scraper_status+). Omits empty/absent optional keys:
80
- # +:scraper_tallies+ when empty, +:selected_strategy+ when +nil+, +:attempt_count+ when zero,
81
- # +:strategy_attempts+ / +:admission_drops+ when empty.
82
- # Data members remain available via readers even when omitted here.
91
+ # Observability hash for web (+scraper_status+). Omits empty/absent optional keys.
83
92
  #
84
- # @return [Hash{Symbol => Object}] always +:version+ (String), +:dedup_dropped+ (Integer);
85
- # optionally +:scraper_tallies+, +:selected_strategy+, +:attempt_count+,
86
- # +:strategy_attempts+, +:admission_drops+
87
- # rubocop:disable Metrics/AbcSize -- omit-empty optional keys stay explicit
93
+ # @return [Hash{Symbol => Object}]
94
+ # rubocop:disable-next Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity -- omit-empty optional keys stay explicit
88
95
  def to_h
89
96
  {
90
97
  version:,
@@ -93,10 +100,12 @@ module Html2rss
93
100
  **(selected_strategy.nil? ? {} : { selected_strategy: }),
94
101
  **(attempt_count.positive? ? { attempt_count: } : {}),
95
102
  **(strategy_attempts.any? ? { strategy_attempts: } : {}),
96
- **(admission_drops.any? ? { admission_drops: } : {})
103
+ **(admission_drops.any? ? { admission_drops: } : {}),
104
+ **(entry_url ? { entry_url: } : {}),
105
+ **(scrape_url ? { scrape_url: } : {}),
106
+ **(entry_resolution ? { entry_resolution: } : {})
97
107
  }
98
108
  end
99
- # rubocop:enable Metrics/AbcSize
100
109
 
101
110
  ##
102
111
  # Formats the RSS +generator+ string and JSON Feed +user_comment+.
@@ -115,13 +124,15 @@ module Html2rss
115
124
 
116
125
  def marshal_dump
117
126
  [version, scraper_tallies, dedup_dropped, selected_strategy, attempt_count, strategy_attempts,
118
- admission_drops]
127
+ admission_drops, entry_url, scrape_url, entry_resolution]
119
128
  end
120
129
 
121
- def marshal_load((version, scraper_tallies, dedup_dropped, selected_strategy, attempt_count,
122
- strategy_attempts, admission_drops))
130
+ def marshal_load(payload)
131
+ version, scraper_tallies, dedup_dropped, selected_strategy, attempt_count,
132
+ strategy_attempts, admission_drops, entry_url, scrape_url, entry_resolution = payload
123
133
  initialize(version:, scraper_tallies:, dedup_dropped:, selected_strategy:, attempt_count:,
124
- strategy_attempts:, admission_drops: admission_drops || {})
134
+ strategy_attempts:, admission_drops: admission_drops || {}, entry_url:, scrape_url:,
135
+ entry_resolution:)
125
136
  end
126
137
 
127
138
  def freeze_tallies(tallies)
@@ -132,6 +143,19 @@ module Html2rss
132
143
  Array(attempts).map { |attempt| attempt.to_h.freeze }.freeze
133
144
  end
134
145
 
146
+ def freeze_optional_string(value)
147
+ return if value.nil?
148
+
149
+ value.to_s.dup.freeze
150
+ end
151
+
152
+ def freeze_entry_resolution(value)
153
+ return if value.nil?
154
+
155
+ hash = value.respond_to?(:to_h) ? value.to_h : value
156
+ hash.to_h.transform_keys(&:to_sym).freeze
157
+ end
158
+
135
159
  def validate_counters!(dedup:, attempts:, selected_strategy:)
136
160
  raise ArgumentError, 'dedup_dropped must be >= 0' if dedup.negative?
137
161
  raise ArgumentError, 'attempt_count must be >= 0' if attempts.negative?
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ ##
5
+ # Closed surface class for no-scraper / page-assessment gates.
6
+ #
7
+ # Construction: {AutoSource::Scraper.classify_no_scraper_surface} and {PageRecon.assess}.
8
+ # Predicates own weak/blocked/listing-bonus decisions — do not re-list WEAK sets in Policy/Scorer.
9
+ class SurfaceCategory
10
+ # Surfaces that warrant listing/feed resolution (hubs / shells — not blocked).
11
+ WEAK = Set[:high_entropy_surface, :app_shell, :unsupported_surface].freeze
12
+
13
+ class << self
14
+ ##
15
+ # @param value [SurfaceCategory, Symbol, String, nil]
16
+ # @return [SurfaceCategory]
17
+ def coerce(value)
18
+ return value if value.is_a?(self)
19
+ return new(name: nil) if value.nil?
20
+
21
+ new(name: value.to_sym)
22
+ end
23
+ end
24
+
25
+ ##
26
+ # @return [Symbol, nil]
27
+ attr_reader :name
28
+
29
+ ##
30
+ # @param name [Symbol, nil]
31
+ def initialize(name:)
32
+ @name = name
33
+ end
34
+
35
+ ##
36
+ # @return [Boolean]
37
+ def weak? = WEAK.include?(name)
38
+
39
+ ##
40
+ # @return [Boolean]
41
+ def blocked? = name == :blocked_surface
42
+
43
+ ##
44
+ # @return [Boolean] non-weak, non-blocked surface eligible for listing bonus
45
+ def listing_bonus? = !name.nil? && !weak? && !blocked?
46
+
47
+ ##
48
+ # @return [Symbol, nil]
49
+ def to_sym = name
50
+
51
+ ##
52
+ # @param other [Object]
53
+ # @return [Boolean]
54
+ def ==(other)
55
+ other.is_a?(self.class) && name == other.name
56
+ end
57
+ alias eql? ==
58
+
59
+ ##
60
+ # @return [Integer]
61
+ def hash = [self.class, name].hash
62
+ end
63
+ end
@@ -0,0 +1,21 @@
1
+ # Syndication
2
+
3
+ Native RSS/Atom discovery and parse for auto-source promotion and direct feed URLs.
4
+
5
+ ## Ownership
6
+
7
+ | Concern | Owner |
8
+ | ---------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- |
9
+ | Head `rel=alternate` + common path probes + status-gated feedish | `Syndication::Discovery` |
10
+ | RSS 2.0 / Atom → article hashes | `Syndication::Parser` |
11
+ | Strict head-only link parse (no path guessing) | `Html::FeedLink` |
12
+ | Response Content-Type / body feed sniff | `RequestService::Response#feed_response?` (sole sniff owner; Discovery only adds HTTP status gate) |
13
+
14
+ ## Non-goals
15
+
16
+ - Entry-URL tournament / listing resolution (`FeedResolution`)
17
+ - Page recon / surface classification (`PageRecon`)
18
+ - Feed channel metadata for output (`Channel` / `FeedBuilder`)
19
+ - Ranking or quality scoring of competing feeds beyond first feedish hit
20
+
21
+ Discovery stops at the first same-origin feedish URL (lazy probe). Path guessing mirrors the legacy configs `probe_rss` script; that script should eventually thin-wrap this module.
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ module Syndication
5
+ ##
6
+ # Shared path lexicon for syndication discovery and entry-resolution candidates.
7
+ module CandidateCatalog
8
+ # Common feed path suffixes probed after head +rel=alternate+ hints.
9
+ FEED_PATHS = %w[
10
+ /feed
11
+ /feed.xml
12
+ /rss
13
+ /rss.xml
14
+ /atom.xml
15
+ /index.xml
16
+ /news/rss
17
+ /news/feed
18
+ /blog/feed
19
+ /blog/rss.xml
20
+ ].freeze
21
+
22
+ # HTML listing paths probed only by {FeedResolution::CandidateGenerator}.
23
+ LISTING_PATHS = %w[/news /blog /releases /changelog /updates].freeze
24
+ end
25
+ end
26
+ end