html2rss 0.30.0 → 0.31.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 (126) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +2 -2
  3. data/lib/html2rss/article/deduplicator.rb +2 -4
  4. data/lib/html2rss/article.rb +16 -39
  5. data/lib/html2rss/auto_source/README.md +1 -1
  6. data/lib/html2rss/auto_source/cleanup.rb +15 -6
  7. data/lib/html2rss/auto_source/scraper/html.rb +5 -0
  8. data/lib/html2rss/auto_source/scraper/json_state.rb +5 -0
  9. data/lib/html2rss/auto_source/scraper/meta_oembed.rb +8 -1
  10. data/lib/html2rss/auto_source/scraper/microdata.rb +8 -0
  11. data/lib/html2rss/auto_source/scraper/microformats2.rb +8 -0
  12. data/lib/html2rss/auto_source/scraper/native_feed.rb +3 -0
  13. data/lib/html2rss/auto_source/scraper/schema/thing.rb +4 -5
  14. data/lib/html2rss/auto_source/scraper/schema.rb +8 -0
  15. data/lib/html2rss/auto_source/scraper/semantic_html.rb +5 -0
  16. data/lib/html2rss/auto_source/scraper/sitemap.rb +3 -0
  17. data/lib/html2rss/auto_source/scraper/wordpress_api.rb +3 -0
  18. data/lib/html2rss/auto_source/scraper.rb +4 -14
  19. data/lib/html2rss/auto_source/segment.rb +7 -4
  20. data/lib/html2rss/auto_source/segmenter/cluster.rb +22 -23
  21. data/lib/html2rss/auto_source/segmenter/list.rb +54 -31
  22. data/lib/html2rss/auto_source/segmenter.rb +5 -5
  23. data/lib/html2rss/auto_source.rb +2 -2
  24. data/lib/html2rss/batch.rb +3 -1
  25. data/lib/html2rss/capture/README.md +6 -4
  26. data/lib/html2rss/capture.rb +67 -35
  27. data/lib/html2rss/channel.rb +3 -3
  28. data/lib/html2rss/cli/probe_view.rb +5 -3
  29. data/lib/html2rss/cli/render.rb +6 -5
  30. data/lib/html2rss/cli/validate.rb +28 -27
  31. data/lib/html2rss/cli.rb +1 -1
  32. data/lib/html2rss/config/issue_mapper.rb +108 -0
  33. data/lib/html2rss/config/request_headers.rb +2 -3
  34. data/lib/html2rss/config/schema.rb +16 -1
  35. data/lib/html2rss/config/selectors_validator.rb +92 -70
  36. data/lib/html2rss/config/validation_issue.rb +60 -0
  37. data/lib/html2rss/config/validation_report.rb +55 -0
  38. data/lib/html2rss/config/validator.rb +10 -6
  39. data/lib/html2rss/config.rb +35 -113
  40. data/lib/html2rss/defaults.rb +42 -30
  41. data/lib/html2rss/doctor/botasaurus.rb +6 -4
  42. data/lib/html2rss/doctor.rb +8 -0
  43. data/lib/html2rss/error.rb +1 -1
  44. data/lib/html2rss/feed_builder/rss.rb +9 -14
  45. data/lib/html2rss/feed_pipeline/runtime_policy.rb +14 -10
  46. data/lib/html2rss/feed_pipeline/strategy_plan.rb +6 -2
  47. data/lib/html2rss/feed_resolution/candidate_generator.rb +1 -4
  48. data/lib/html2rss/feed_resolution/diag.rb +3 -1
  49. data/lib/html2rss/feed_resolution/options.rb +3 -1
  50. data/lib/html2rss/feed_resolution/policy.rb +2 -2
  51. data/lib/html2rss/html/article_extractor/date_extractor.rb +1 -1
  52. data/lib/html2rss/html/article_extractor.rb +2 -4
  53. data/lib/html2rss/html/feed_link.rb +3 -1
  54. data/lib/html2rss/html/navigator/text_extractor.rb +1 -1
  55. data/lib/html2rss/html/navigator.rb +10 -10
  56. data/lib/html2rss/html/rendering/description_builder.rb +1 -1
  57. data/lib/html2rss/html/sst_article_extractor.rb +8 -5
  58. data/lib/html2rss/link_destination/destination_facts.rb +4 -2
  59. data/lib/html2rss/link_destination/noise_policy.rb +10 -1
  60. data/lib/html2rss/link_destination/path_classifier.rb +45 -38
  61. data/lib/html2rss/mcp/README.md +2 -0
  62. data/lib/html2rss/mcp/config_argument.rb +3 -1
  63. data/lib/html2rss/mcp/contract.rb +6 -20
  64. data/lib/html2rss/mcp/outcome/playbook.rb +2 -2
  65. data/lib/html2rss/mcp/outcome.rb +36 -43
  66. data/lib/html2rss/mcp/server/tools.rb +111 -131
  67. data/lib/html2rss/mcp/server.rb +26 -18
  68. data/lib/html2rss/page_recon/README.md +5 -5
  69. data/lib/html2rss/page_recon/diagnostics.rb +71 -81
  70. data/lib/html2rss/page_recon.rb +9 -5
  71. data/lib/html2rss/recon.rb +19 -30
  72. data/lib/html2rss/request_service/botasaurus_contract.rb +11 -12
  73. data/lib/html2rss/request_service/botasaurus_strategy.rb +1 -1
  74. data/lib/html2rss/request_service/compressed_body.rb +4 -1
  75. data/lib/html2rss/request_service/httpx_strategy.rb +39 -28
  76. data/lib/html2rss/request_service/network_guard.rb +7 -5
  77. data/lib/html2rss/request_service/response.rb +1 -2
  78. data/lib/html2rss/request_service/strategy.rb +3 -8
  79. data/lib/html2rss/scoring/container_assessor.rb +3 -1
  80. data/lib/html2rss/scoring/engine.rb +8 -8
  81. data/lib/html2rss/scoring/observation.rb +5 -3
  82. data/lib/html2rss/scoring/ranked_segment.rb +3 -1
  83. data/lib/html2rss/scrape_target.rb +3 -1
  84. data/lib/html2rss/selector_candidates.rb +234 -0
  85. data/lib/html2rss/selectors/README.md +64 -0
  86. data/lib/html2rss/selectors/extractors/attribute.rb +13 -15
  87. data/lib/html2rss/selectors/extractors/href.rb +15 -16
  88. data/lib/html2rss/selectors/extractors/html.rb +8 -9
  89. data/lib/html2rss/selectors/extractors/static.rb +12 -14
  90. data/lib/html2rss/selectors/extractors/text.rb +8 -9
  91. data/lib/html2rss/selectors/extractors.rb +33 -20
  92. data/lib/html2rss/selectors/item_env.rb +31 -0
  93. data/lib/html2rss/selectors/{object_to_xml_converter.rb → json_xml.rb} +11 -12
  94. data/lib/html2rss/selectors/option_spec.rb +140 -0
  95. data/lib/html2rss/selectors/post_processors/base.rb +79 -31
  96. data/lib/html2rss/selectors/post_processors/gsub.rb +103 -39
  97. data/lib/html2rss/selectors/post_processors/html_transformers/transform_urls_to_absolute_ones.rb +4 -4
  98. data/lib/html2rss/selectors/post_processors/markdown_to_html.rb +10 -11
  99. data/lib/html2rss/selectors/post_processors/parse_time.rb +50 -26
  100. data/lib/html2rss/selectors/post_processors/parse_uri.rb +11 -7
  101. data/lib/html2rss/selectors/post_processors/sanitize_html.rb +46 -32
  102. data/lib/html2rss/selectors/post_processors/substring.rb +14 -25
  103. data/lib/html2rss/selectors/post_processors/template.rb +23 -19
  104. data/lib/html2rss/selectors/post_processors.rb +6 -5
  105. data/lib/html2rss/selectors/{schema_doc.rb → schema_export.rb} +12 -31
  106. data/lib/html2rss/selectors/step_env.rb +24 -0
  107. data/lib/html2rss/selectors.rb +231 -142
  108. data/lib/html2rss/sst/attrs.rb +5 -3
  109. data/lib/html2rss/sst/document.rb +3 -1
  110. data/lib/html2rss/sst/index.rb +3 -1
  111. data/lib/html2rss/sst/node.rb +29 -10
  112. data/lib/html2rss/sst/normalizer.rb +12 -16
  113. data/lib/html2rss/sst/tags.rb +7 -7
  114. data/lib/html2rss/status.rb +13 -8
  115. data/lib/html2rss/surface_category.rb +14 -32
  116. data/lib/html2rss/syndication/parser.rb +2 -2
  117. data/lib/html2rss/test/enhance_audit.rb +1 -3
  118. data/lib/html2rss/test/policy.rb +106 -0
  119. data/lib/html2rss/test.rb +126 -173
  120. data/lib/html2rss/url.rb +25 -4
  121. data/lib/html2rss/version.rb +1 -1
  122. data/lib/html2rss.rb +21 -13
  123. data/lib/tasks/config_schema.rake +2 -2
  124. data/schema/html2rss-config.schema.json +11 -8
  125. metadata +15 -6
  126. data/lib/html2rss/selectors/item_scope.rb +0 -30
@@ -35,6 +35,7 @@ module Html2rss
35
35
  style |
36
36
  datetime |
37
37
  itemprop |
38
+ role |
38
39
  type |
39
40
  category |
40
41
  categories |
@@ -54,7 +55,7 @@ module Html2rss
54
55
  /xi
55
56
 
56
57
  # 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
+ TYPED_ATTR_NAMES = Set['href', 'src', 'id', 'class', 'datetime', 'itemprop', 'style', 'srcset', 'type'].freeze
58
59
 
59
60
  # String form of Tags::IGNORED_CONTAINER_NAMES for chrome checks without to_sym.
60
61
  IGNORED_CONTAINER_TAGS = Tags::IGNORED_CONTAINER_NAMES.to_set(&:to_s).freeze
@@ -113,23 +114,19 @@ module Html2rss
113
114
  def resolve_root_nk
114
115
  parsed = @parsed_body
115
116
 
116
- if parsed.respond_to?(:at_css)
117
- html = parsed.at_css('html')
118
- return html if html
117
+ html = parsed.at_css('html')
118
+ return html if html
119
119
 
120
- body = parsed.at_css('body')
121
- if body
122
- Html2rss::Log.warn('sst.normalizer root fallback: body')
123
- return body
124
- end
120
+ body = parsed.at_css('body')
121
+ if body
122
+ Html2rss::Log.warn('sst.normalizer root fallback: body')
123
+ return body
125
124
  end
126
125
 
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
126
+ first = parsed.element_children.find { |child| element_root?(child) }
127
+ if first
128
+ Html2rss::Log.warn('sst.normalizer root fallback: first element child')
129
+ return first
133
130
  end
134
131
 
135
132
  Html2rss::Log.warn('sst.normalizer root fallback: self')
@@ -142,7 +139,6 @@ module Html2rss
142
139
 
143
140
  # rubocop:disable-next Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
144
141
  def normalize_element(nk_node, parent:, depth:, path:, chrome:)
145
- return unless nk_node.respond_to?(:name)
146
142
  return unless nk_node.element?
147
143
 
148
144
  tag = Html2rss::Html::Probe.tag(nk_node)
@@ -6,21 +6,21 @@ module Html2rss
6
6
  # Shared tag-name and href constants for SST nodes.
7
7
  module Tags
8
8
  # Heading element names.
9
- HEADING_NAMES = %i[h1 h2 h3 h4 h5 h6].to_set.freeze
9
+ HEADING_NAMES = Set[:h1, :h2, :h3, :h4, :h5, :h6].freeze
10
10
  # Block-level names that contribute structural newlines in text.
11
- BLOCK_NAMES = %i[p div li ul ol h1 h2 h3 h4 h5 h6 tr br].to_set.freeze
11
+ BLOCK_NAMES = Set[:p, :div, :li, :ul, :ol, :h1, :h2, :h3, :h4, :h5, :h6, :tr, :br].freeze
12
12
  # Names skipped when collecting visible text.
13
- INVISIBLE_NAMES = %i[svg script noscript style template].to_set.freeze
13
+ INVISIBLE_NAMES = Set[:svg, :script, :noscript, :style, :template].freeze
14
14
  # Landmark names treated as utility chrome.
15
- UTILITY_LANDMARK_NAMES = %i[nav aside footer menu].to_set.freeze
15
+ UTILITY_LANDMARK_NAMES = Set[:nav, :aside, :footer, :menu].freeze
16
16
  # Container names ignored as article roots.
17
- IGNORED_CONTAINER_NAMES = %i[nav footer header svg script style].to_set.freeze
17
+ IGNORED_CONTAINER_NAMES = Set[:nav, :footer, :header, :svg, :script, :style].freeze
18
18
  # Names excluded from class/structure clustering.
19
- CLUSTER_EXCLUDED_NAMES = %i[html body nav footer header svg script style].to_set.freeze
19
+ CLUSTER_EXCLUDED_NAMES = Set[:html, :body, :nav, :footer, :header, :svg, :script, :style].freeze
20
20
  # Href prefixes that never count as content destinations.
21
21
  SKIP_HREF_PREFIXES = ['#', 'javascript:', 'mailto:', 'tel:', 'file://', 'sms:', 'data:'].freeze
22
22
  # Layout-ish names used when resolving nested wrapper groups.
23
- LAYOUT_NAMES = %i[div section article li ul ol].to_set.freeze
23
+ LAYOUT_NAMES = Set[:div, :section, :article, :li, :ul, :ol].freeze
24
24
  end
25
25
  end
26
26
  end
@@ -1,16 +1,18 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module Html2rss # rubocop:disable Metrics/ModuleLength -- Status Data.define + marshal stay co-located
3
+ module Html2rss
4
+ Status = Data.define(
5
+ :version, :scraper_tallies, :dedup_dropped, :selected_strategy, :attempt_count,
6
+ :strategy_attempts, :admission_drops, :entry_url, :scrape_url, :entry_resolution
7
+ )
8
+
4
9
  ##
5
10
  # Shared RSS +generator+ / JSON Feed +user_comment+ formatter string.
6
11
  #
7
12
  # Exposed publicly via {FeedResult#status}. Safe to log without reading articles.
8
13
  # Stable telemetry payload for cross-repo consumers (e.g. html2rss-web observability).
9
14
  # Tallies and counters are validated and frozen at construction (including Marshal load).
10
- Status = Data.define(
11
- :version, :scraper_tallies, :dedup_dropped, :selected_strategy, :attempt_count,
12
- :strategy_attempts, :admission_drops, :entry_url, :scrape_url, :entry_resolution
13
- ) do
15
+ class Status
14
16
  class << self
15
17
  ##
16
18
  # Builds status from extracted articles and scrape telemetry.
@@ -63,7 +65,7 @@ module Html2rss # rubocop:disable Metrics/ModuleLength -- Status Data.define + m
63
65
  # @param admission_drops [Hash{String => Integer}]
64
66
  # @param entry_url [String, nil]
65
67
  # @param scrape_url [String, nil]
66
- # @param entry_resolution [Hash, nil]
68
+ # @param entry_resolution [Html2rss::FeedResolution::Diag, Hash, nil]
67
69
  # rubocop:disable-next Metrics/ParameterLists, Metrics/MethodLength -- Status Data.define members
68
70
  def initialize(
69
71
  version:, scraper_tallies:, dedup_dropped:, selected_strategy: nil, attempt_count: 0,
@@ -152,8 +154,11 @@ module Html2rss # rubocop:disable Metrics/ModuleLength -- Status Data.define + m
152
154
  def freeze_entry_resolution(value)
153
155
  return if value.nil?
154
156
 
155
- hash = value.respond_to?(:to_h) ? value.to_h : value
156
- hash.to_h.transform_keys(&:to_sym).freeze
157
+ case value
158
+ when Hash, FeedResolution::Diag then value.to_h
159
+ else
160
+ raise ArgumentError, "entry_resolution must be a Hash or FeedResolution::Diag, got #{value.class}"
161
+ end.transform_keys(&:to_sym).freeze
157
162
  end
158
163
 
159
164
  def validate_counters!(dedup:, attempts:, selected_strategy:)
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Html2rss
4
+ SurfaceCategory = Data.define(:name)
5
+
4
6
  ##
5
7
  # Closed surface class for no-scraper / page-assessment gates.
6
8
  #
@@ -10,27 +12,15 @@ module Html2rss
10
12
  # Surfaces that warrant listing/feed resolution (hubs / shells — not blocked).
11
13
  WEAK = Set[:high_entropy_surface, :app_shell, :unsupported_surface].freeze
12
14
 
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
15
+ # Frozen name → instance table.
16
+ ALL = %i[listing blocked_surface app_shell high_entropy_surface unsupported_surface]
17
+ .to_h { |name| [name, new(name:)] }.freeze
18
+ private_class_method :new
28
19
 
29
20
  ##
30
- # @param name [Symbol, nil]
31
- def initialize(name:)
32
- @name = name
33
- end
21
+ # @param name [Symbol, String]
22
+ # @return [SurfaceCategory]
23
+ def self.[](name) = ALL.fetch(name.to_sym) { raise ArgumentError, "unknown surface category: #{name.inspect}" }
34
24
 
35
25
  ##
36
26
  # @return [Boolean]
@@ -41,23 +31,15 @@ module Html2rss
41
31
  def blocked? = name == :blocked_surface
42
32
 
43
33
  ##
44
- # @return [Boolean] non-weak, non-blocked surface eligible for listing bonus
45
- def listing_bonus? = !name.nil? && !weak? && !blocked?
34
+ # @return [Boolean] listing surface eligible for listing bonus
35
+ def listing_bonus? = name == :listing
46
36
 
47
37
  ##
48
- # @return [Symbol, nil]
38
+ # @return [Symbol]
49
39
  def to_sym = name
50
40
 
51
41
  ##
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
42
+ # @return [String]
43
+ def to_s = name.to_s
62
44
  end
63
45
  end
@@ -95,9 +95,9 @@ module Html2rss
95
95
  private_class_method :atom_link
96
96
 
97
97
  def atom_text(node)
98
- return if node.nil?
98
+ return unless node.is_a?(RSS::Atom::TextConstruct) || node.is_a?(RSS::Atom::Feed::Entry::Content)
99
99
 
100
- node.respond_to?(:content) ? node.content : node.to_s
100
+ node.content
101
101
  end
102
102
  module_function :atom_text
103
103
  private_class_method :atom_text
@@ -40,9 +40,7 @@ module Html2rss
40
40
  }
41
41
  warnings = []
42
42
 
43
- response.parsed_body.css(selector_engine.items_selector).each do |item|
44
- baseline = selector_engine.extract_article(item, response)
45
- enhanced = selector_engine.enhance_article_hash(baseline.dup, item, response.url)
43
+ selector_engine.each_enhance_pair do |baseline, enhanced, item|
46
44
  item_keys = keys_added(baseline, enhanced)
47
45
  merge_keys_added!(gains, item_keys)
48
46
  append_item_warnings!(warnings, item:, enhanced:, item_keys:)
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ module Test
5
+ ##
6
+ # Pass/fail classification for a configuration test after live extraction.
7
+ # Owns min_items vs quality thresholds; {Test.call} orchestrates only.
8
+ module Policy
9
+ module_function
10
+
11
+ Outcome = Data.define(:passed, :failure_kind, :error_message)
12
+
13
+ ##
14
+ # Outcome of evaluating test thresholds.
15
+ class Outcome
16
+ # @return [Boolean]
17
+ def success? = passed
18
+
19
+ # @return [Boolean]
20
+ def failure? = !passed
21
+ end
22
+
23
+ ##
24
+ # Evaluates whether the test passed thresholds and determines any failure kind and error message.
25
+ #
26
+ # @param item_count [Integer]
27
+ # @param min_items [Integer]
28
+ # @param strict_quality [Boolean]
29
+ # @param quality_report [QualityReport]
30
+ # @return [Outcome]
31
+ def evaluate(item_count:, min_items:, strict_quality:, quality_report:)
32
+ min_items_passed = item_count >= min_items
33
+ quality_failed = strict_quality && min_items_passed && quality_failure?(quality_report)
34
+ passed = min_items_passed && !quality_failed
35
+ failure_kind, error_message = outcome_failure(
36
+ min_items_passed:, quality_failed:, item_count:, min_items:, quality_report:
37
+ )
38
+ log_strict_quality_failure(failure_kind, item_count) if quality_failed
39
+ Outcome.new(passed:, failure_kind:, error_message:)
40
+ end
41
+
42
+ def log_strict_quality_failure(failure_kind, item_count)
43
+ Log.info("Test strict quality: failure_kind=#{failure_kind.to_sym} item_count=#{item_count}")
44
+ end
45
+ private_class_method :log_strict_quality_failure
46
+
47
+ ##
48
+ # @param quality_report [QualityReport]
49
+ # @return [Boolean]
50
+ def quality_failure?(quality_report)
51
+ metrics = quality_report.metrics
52
+ item_count = metrics[:item_count]
53
+ return false if item_count.zero?
54
+
55
+ duplicate_urls_failure?(metrics) || junk_title_ratio_failure?(metrics) || short_title_failure?(metrics)
56
+ end
57
+
58
+ ##
59
+ # @param min_items_passed [Boolean]
60
+ # @param quality_failed [Boolean]
61
+ # @param item_count [Integer]
62
+ # @param min_items [Integer]
63
+ # @param quality_report [QualityReport]
64
+ # @return [Array(FailureKind, nil), Array(String, nil)]
65
+ def outcome_failure(min_items_passed:, quality_failed:, item_count:, min_items:, quality_report:)
66
+ return [nil, nil] if min_items_passed && !quality_failed
67
+ unless min_items_passed
68
+ return [FailureKind[:min_items],
69
+ "Extracted #{item_count} items (minimum required: #{min_items})"]
70
+ end
71
+
72
+ [FailureKind[:quality], quality_failure_message(quality_report)]
73
+ end
74
+
75
+ def quality_failure_message(quality_report)
76
+ reasons = quality_failure_reasons(quality_report.metrics)
77
+ "Feed quality check failed (#{reasons.join(', ')})"
78
+ end
79
+ private_class_method :quality_failure_message
80
+
81
+ def quality_failure_reasons(metrics)
82
+ reasons = []
83
+ reasons << 'duplicate_urls' if duplicate_urls_failure?(metrics)
84
+ reasons << 'generic_titles' if junk_title_ratio_failure?(metrics)
85
+ reasons << 'short_titles' if short_title_failure?(metrics)
86
+ reasons
87
+ end
88
+ private_class_method :quality_failure_reasons
89
+
90
+ def duplicate_urls_failure?(metrics)
91
+ metrics[:item_count] >= 2 && metrics[:unique_url_count] < 2
92
+ end
93
+ private_class_method :duplicate_urls_failure?
94
+
95
+ def junk_title_ratio_failure?(metrics)
96
+ metrics[:junk_title_count] > (metrics[:item_count] / 2.0)
97
+ end
98
+ private_class_method :junk_title_ratio_failure?
99
+
100
+ def short_title_failure?(metrics)
101
+ metrics[:short_title_count].positive?
102
+ end
103
+ private_class_method :short_title_failure?
104
+ end
105
+ end
106
+ end