html2rss 0.25.0 → 0.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +99 -2
  3. data/html2rss.gemspec +4 -1
  4. data/lib/html2rss/auto_source/README.md +57 -0
  5. data/lib/html2rss/auto_source/cleanup.rb +140 -49
  6. data/lib/html2rss/auto_source/scraper/json_state.rb +31 -22
  7. data/lib/html2rss/auto_source/scraper/schema/item_list.rb +2 -14
  8. data/lib/html2rss/auto_source/scraper/xhr_articles.rb +69 -0
  9. data/lib/html2rss/auto_source/scraper.rb +47 -15
  10. data/lib/html2rss/auto_source/segmenter/list.rb +3 -1
  11. data/lib/html2rss/auto_source/segmenter/primary_link.rb +6 -7
  12. data/lib/html2rss/auto_source/segmenter.rb +30 -1
  13. data/lib/html2rss/auto_source.rb +41 -10
  14. data/lib/html2rss/capture/README.md +61 -0
  15. data/lib/html2rss/capture.rb +318 -0
  16. data/lib/html2rss/cli.rb +94 -17
  17. data/lib/html2rss/config/auto_source_contract.rb +3 -1
  18. data/lib/html2rss/config/schema.rb +79 -26
  19. data/lib/html2rss/config/selectors_validator.rb +60 -21
  20. data/lib/html2rss/config/validator.rb +46 -32
  21. data/lib/html2rss/config.rb +28 -0
  22. data/lib/html2rss/error.rb +31 -1
  23. data/lib/html2rss/feed_pipeline/README.md +42 -0
  24. data/lib/html2rss/feed_pipeline/auto_fallback.rb +38 -15
  25. data/lib/html2rss/feed_pipeline/runtime_policy.rb +3 -43
  26. data/lib/html2rss/feed_pipeline/strategy_plan.rb +11 -0
  27. data/lib/html2rss/feed_pipeline.rb +40 -15
  28. data/lib/html2rss/hash_util.rb +17 -0
  29. data/lib/html2rss/html/article_extractor/category_extractor.rb +36 -22
  30. data/lib/html2rss/html/article_extractor/date_extractor.rb +5 -3
  31. data/lib/html2rss/html/article_extractor.rb +95 -17
  32. data/lib/html2rss/html/article_rules/category.rb +28 -11
  33. data/lib/html2rss/html/article_rules/date.rb +60 -6
  34. data/lib/html2rss/html/article_rules/description.rb +122 -0
  35. data/lib/html2rss/html/card_walk.rb +42 -0
  36. data/lib/html2rss/html/feed_link.rb +34 -0
  37. data/lib/html2rss/html/navigator.rb +18 -0
  38. data/lib/html2rss/html/sst_article_extractor.rb +147 -31
  39. data/lib/html2rss/link_destination/noise_policy.rb +7 -18
  40. data/lib/html2rss/link_destination/path_classifier.rb +49 -32
  41. data/lib/html2rss/mcp/config_argument.rb +42 -0
  42. data/lib/html2rss/mcp/contract.rb +173 -0
  43. data/lib/html2rss/mcp/inspect.rb +241 -0
  44. data/lib/html2rss/mcp/outcome.rb +188 -0
  45. data/lib/html2rss/mcp/server.rb +421 -0
  46. data/lib/html2rss/mcp.rb +21 -0
  47. data/lib/html2rss/request_service/blocked_surface.rb +24 -1
  48. data/lib/html2rss/request_service/botasaurus_contract.rb +261 -90
  49. data/lib/html2rss/request_service/botasaurus_strategy.rb +18 -9
  50. data/lib/html2rss/request_service/budget.rb +7 -35
  51. data/lib/html2rss/request_service/compressed_body.rb +109 -0
  52. data/lib/html2rss/request_service/context.rb +0 -6
  53. data/lib/html2rss/request_service/faraday_strategy.rb +48 -3
  54. data/lib/html2rss/request_service/policy.rb +2 -2
  55. data/lib/html2rss/request_service/response.rb +72 -7
  56. data/lib/html2rss/request_service/strategy.rb +1 -2
  57. data/lib/html2rss/request_service.rb +10 -10
  58. data/lib/html2rss/scoring/container_assessor.rb +9 -15
  59. data/lib/html2rss/scoring/engine.rb +49 -5
  60. data/lib/html2rss/scoring/link_resolver.rb +12 -0
  61. data/lib/html2rss/scoring/ranked_segment.rb +0 -12
  62. data/lib/html2rss/scoring/score.rb +1 -20
  63. data/lib/html2rss/scoring.rb +0 -25
  64. data/lib/html2rss/selectors/extractors/attribute.rb +15 -0
  65. data/lib/html2rss/selectors/extractors/href.rb +12 -0
  66. data/lib/html2rss/selectors/extractors/html.rb +12 -0
  67. data/lib/html2rss/selectors/extractors/static.rb +14 -0
  68. data/lib/html2rss/selectors/extractors/text.rb +11 -0
  69. data/lib/html2rss/selectors/post_processors/gsub.rb +18 -0
  70. data/lib/html2rss/selectors/post_processors/html_to_markdown.rb +11 -0
  71. data/lib/html2rss/selectors/post_processors/markdown_to_html.rb +12 -0
  72. data/lib/html2rss/selectors/post_processors/parse_time.rb +11 -0
  73. data/lib/html2rss/selectors/post_processors/parse_uri.rb +11 -0
  74. data/lib/html2rss/selectors/post_processors/sanitize_html.rb +13 -0
  75. data/lib/html2rss/selectors/post_processors/substring.rb +21 -0
  76. data/lib/html2rss/selectors/post_processors/template.rb +20 -0
  77. data/lib/html2rss/selectors/schema_doc.rb +99 -0
  78. data/lib/html2rss/selectors.rb +2 -1
  79. data/lib/html2rss/status.rb +27 -11
  80. data/lib/html2rss/url.rb +31 -7
  81. data/lib/html2rss/version.rb +1 -1
  82. data/lib/html2rss.rb +62 -7
  83. data/schema/html2rss-config.schema.json +428 -94
  84. metadata +65 -12
  85. data/lib/html2rss/request_service/browserless_strategy.rb +0 -132
  86. data/lib/html2rss/request_service/puppet_commander/navigation_guards.rb +0 -148
  87. data/lib/html2rss/request_service/puppet_commander/preload_runner.rb +0 -86
  88. data/lib/html2rss/request_service/puppet_commander.rb +0 -95
  89. data/lib/html2rss/scoring/anchor_score.rb +0 -34
@@ -29,6 +29,24 @@ module Html2rss
29
29
  #
30
30
  # See the doc on [String#gsub](https://ruby-doc.org/core/String.html#method-i-gsub) for more info.
31
31
  class Gsub < Base
32
+ # Required config field types (validator introspection via +Options+).
33
+ OPTION_TYPES = { pattern: String, replacement: String }.freeze
34
+
35
+ # Config fields required by this post-processor (validator / schema introspection).
36
+ Options = Struct.new(*OPTION_TYPES.keys, keyword_init: true)
37
+
38
+ # JSON Schema description exported via +schema_doc+.
39
+ DESCRIPTION = 'Replace matches of `pattern` in the extracted string with `replacement` ' \
40
+ '(Ruby String#gsub; pattern may be a regexp-like string).'
41
+
42
+ # Example post-process objects for JSON Schema +examples+.
43
+ EXAMPLES = [
44
+ { 'name' => 'gsub', 'pattern' => 'boo', 'replacement' => 'baz' }
45
+ ].freeze
46
+
47
+ # @return [Hash{Symbol => Object}] JSON Schema fragment for this post-processor
48
+ def self.schema_doc = SchemaDoc.for_post_processor(name: :gsub, klass: self)
49
+
32
50
  # @param value [String] extracted selector value
33
51
  # @param context [Selectors::Context] post-processor context
34
52
  # @return [void]
@@ -28,6 +28,17 @@ module Html2rss
28
28
  # Would return:
29
29
  # 'Lorem **ipsum** dolor'
30
30
  class HtmlToMarkdown < Base
31
+ # JSON Schema description exported via +schema_doc+.
32
+ DESCRIPTION = 'Sanitize HTML then convert it to Markdown (via ReverseMarkdown).'
33
+
34
+ # Example post-process objects for JSON Schema +examples+.
35
+ EXAMPLES = [
36
+ { 'name' => 'html_to_markdown' }
37
+ ].freeze
38
+
39
+ # @return [Hash{Symbol => Object}] JSON Schema fragment for this post-processor
40
+ def self.schema_doc = SchemaDoc.for_post_processor(name: :html_to_markdown, klass: self)
41
+
31
42
  # @param value [String] extracted selector value
32
43
  # @param context [Selectors::Context] post-processor context
33
44
  # @return [void]
@@ -33,6 +33,18 @@ module Html2rss
33
33
  #
34
34
  # <p>Price: 12.34</p>
35
35
  class MarkdownToHtml < Base
36
+ # JSON Schema description exported via +schema_doc+.
37
+ DESCRIPTION = 'Convert Markdown to HTML (Kramdown) and sanitize the result. ' \
38
+ 'Often chained after `template`.'
39
+
40
+ # Example post-process objects for JSON Schema +examples+.
41
+ EXAMPLES = [
42
+ { 'name' => 'markdown_to_html' }
43
+ ].freeze
44
+
45
+ # @return [Hash{Symbol => Object}] JSON Schema fragment for this post-processor
46
+ def self.schema_doc = SchemaDoc.for_post_processor(name: :markdown_to_html, klass: self)
47
+
36
48
  # @param value [String] extracted selector value
37
49
  # @param context [Selectors::Context] post-processor context
38
50
  # @return [void]
@@ -27,6 +27,17 @@ module Html2rss
27
27
  #
28
28
  # It uses `Time.parse`.
29
29
  class ParseTime < Base
30
+ # JSON Schema description exported via +schema_doc+.
31
+ DESCRIPTION = 'Parse a time string with Time.parse and return RFC822, using the channel `time_zone`.'
32
+
33
+ # Example post-process objects for JSON Schema +examples+.
34
+ EXAMPLES = [
35
+ { 'name' => 'parse_time' }
36
+ ].freeze
37
+
38
+ # @return [Hash{Symbol => Object}] JSON Schema fragment for this post-processor
39
+ def self.schema_doc = SchemaDoc.for_post_processor(name: :parse_time, klass: self)
40
+
30
41
  # @param value [String] extracted selector value
31
42
  # @param context [Selectors::Context] post-processor context
32
43
  # @return [void]
@@ -23,6 +23,17 @@ module Html2rss
23
23
  # Would return:
24
24
  # 'http://why-not-use-a-link.uh'
25
25
  class ParseUri < Base
26
+ # JSON Schema description exported via +schema_doc+.
27
+ DESCRIPTION = 'Normalize a URL string; resolve relative URLs against the channel URL.'
28
+
29
+ # Example post-process objects for JSON Schema +examples+.
30
+ EXAMPLES = [
31
+ { 'name' => 'parse_uri' }
32
+ ].freeze
33
+
34
+ # @return [Hash{Symbol => Object}] JSON Schema fragment for this post-processor
35
+ def self.schema_doc = SchemaDoc.for_post_processor(name: :parse_uri, klass: self)
36
+
26
37
  # @param value [String] extracted selector value
27
38
  # @param _context [Selectors::Context] post-processor context
28
39
  # @return [void]
@@ -38,6 +38,15 @@ module Html2rss
38
38
  # Would return:
39
39
  # '<p>Lorem <b>ipsum</b> dolor ...</p>'
40
40
  class SanitizeHtml < Base
41
+ # JSON Schema description exported via +schema_doc+.
42
+ DESCRIPTION = 'Sanitize HTML (sanitize gem RELAXED plus html2rss defaults: absolute URLs, ' \
43
+ 'safe link/img attributes, wrap lone images in anchors).'
44
+
45
+ # Example post-process objects for JSON Schema +examples+.
46
+ EXAMPLES = [
47
+ { 'name' => 'sanitize_html' }
48
+ ].freeze
49
+
41
50
  # @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Referrer-Policy
42
51
  TAG_ATTRIBUTES = {
43
52
  'a' => {
@@ -81,6 +90,10 @@ module Html2rss
81
90
  'preload' => 'none'
82
91
  }
83
92
  }.freeze
93
+
94
+ # @return [Hash{Symbol => Object}] JSON Schema fragment for this post-processor
95
+ def self.schema_doc = SchemaDoc.for_post_processor(name: :sanitize_html, klass: self)
96
+
84
97
  # @param value [String] extracted selector value
85
98
  # @param context [Selectors::Context] post-processor context
86
99
  # @return [void]
@@ -30,6 +30,27 @@ module Html2rss
30
30
  # Would return:
31
31
  # 'bar'
32
32
  class Substring < Base
33
+ # Required config field types (validator introspection via +Options+).
34
+ OPTION_TYPES = { start: Integer }.freeze
35
+
36
+ # Optional config field types (validated when the key is present and non-nil).
37
+ OPTIONAL_OPTION_TYPES = { end: Integer }.freeze
38
+
39
+ # Config fields required by this post-processor (validator / schema introspection).
40
+ Options = Struct.new(*OPTION_TYPES.keys, keyword_init: true)
41
+
42
+ # JSON Schema description exported via +schema_doc+.
43
+ DESCRIPTION = 'Return a slice of the extracted string using Integer `start` and optional `end` ' \
44
+ '(Ruby String#[] range semantics; end may be omitted).'
45
+
46
+ # Example post-process objects for JSON Schema +examples+.
47
+ EXAMPLES = [
48
+ { 'name' => 'substring', 'start' => 4, 'end' => 6 }
49
+ ].freeze
50
+
51
+ # @return [Hash{Symbol => Object}] JSON Schema fragment for this post-processor
52
+ def self.schema_doc = SchemaDoc.for_post_processor(name: :substring, klass: self)
53
+
33
54
  # @param value [String] extracted selector value
34
55
  # @param context [Selectors::Context] post-processor context
35
56
  # @return [void]
@@ -34,6 +34,26 @@ module Html2rss
34
34
  # Would return:
35
35
  # 'Product (23,42€)'
36
36
  class Template < Base
37
+ # Required config field types (validator introspection via +Options+).
38
+ OPTION_TYPES = { string: String }.freeze
39
+
40
+ # Config fields required by this post-processor (validator / schema introspection).
41
+ Options = Struct.new(*OPTION_TYPES.keys, keyword_init: true)
42
+
43
+ # JSON Schema description exported via +schema_doc+.
44
+ # rubocop:disable Style/FormatStringToken -- documents Kernel#format `%{key}` placeholders
45
+ DESCRIPTION = 'Format a string with Kernel#format-style placeholders (`%{key}` / `%<key>s`). ' \
46
+ '`%{self}` is the current selector value; other keys resolve sibling selectors.'
47
+
48
+ # Example post-process objects for JSON Schema +examples+.
49
+ EXAMPLES = [
50
+ { 'name' => 'template', 'string' => '`%{self}` (`%{price}`)' }
51
+ ].freeze
52
+ # rubocop:enable Style/FormatStringToken
53
+
54
+ # @return [Hash{Symbol => Object}] JSON Schema fragment for this post-processor
55
+ def self.schema_doc = SchemaDoc.for_post_processor(name: :template, klass: self)
56
+
37
57
  # @param value [String] extracted selector value
38
58
  # @param context [Selectors::Context] post-processor context
39
59
  # @return [void]
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ class Selectors
5
+ ##
6
+ # Builds JSON Schema fragments from extractor / post-processor class constants.
7
+ #
8
+ # Each registry class owns +DESCRIPTION+, +EXAMPLES+, and optional +OPTION_TYPES+;
9
+ # this module is the export adapter only.
10
+ module SchemaDoc
11
+ # Maps Ruby option types to JSON Schema +type+ strings.
12
+ RUBY_TO_JSON_TYPE = {
13
+ String => 'string',
14
+ Integer => 'integer',
15
+ Hash => 'object',
16
+ Array => 'array'
17
+ }.freeze
18
+
19
+ module_function
20
+
21
+ ##
22
+ # @param name [Symbol, String] registry key
23
+ # @param klass [Class] post-processor class
24
+ # @return [Hash{Symbol => Object}] JSON Schema object for one post-processor
25
+ def for_post_processor(name:, klass:)
26
+ name = name.to_s
27
+ {
28
+ type: 'object',
29
+ title: name,
30
+ description: klass::DESCRIPTION,
31
+ examples: klass::EXAMPLES,
32
+ properties: post_processor_properties(name, klass),
33
+ required: post_processor_required(klass),
34
+ additionalProperties: true
35
+ }
36
+ end
37
+
38
+ ##
39
+ # @param name [Symbol, String] registry key
40
+ # @param klass [Class] extractor class
41
+ # @return [Hash{Symbol => Object}] JSON Schema for one extractor name
42
+ def for_extractor(name:, klass:)
43
+ name = name.to_s
44
+ {
45
+ type: 'string',
46
+ const: name,
47
+ title: name,
48
+ description: klass::DESCRIPTION,
49
+ examples: klass::EXAMPLES
50
+ }
51
+ end
52
+
53
+ ##
54
+ # @param name [String] registry key
55
+ # @param klass [Class]
56
+ # @return [Hash{Symbol => Hash}]
57
+ def post_processor_properties(name, klass)
58
+ properties = { name: { type: 'string', const: name } }
59
+ option_types_for(klass).each do |field, ruby_type|
60
+ properties[field] = { type: json_type_for(ruby_type) }
61
+ end
62
+ properties
63
+ end
64
+ module_function :post_processor_properties
65
+
66
+ ##
67
+ # @param klass [Class]
68
+ # @return [Array<String>]
69
+ def post_processor_required(klass)
70
+ required = ['name']
71
+ return required unless klass.const_defined?(:OPTION_TYPES)
72
+
73
+ required + klass::OPTION_TYPES.keys.map(&:to_s)
74
+ end
75
+ module_function :post_processor_required
76
+
77
+ ##
78
+ # @param klass [Class]
79
+ # @return [Hash{Symbol => Class}]
80
+ def option_types_for(klass)
81
+ types = {}
82
+ types.merge!(klass::OPTION_TYPES) if klass.const_defined?(:OPTION_TYPES)
83
+ types.merge!(klass::OPTIONAL_OPTION_TYPES) if klass.const_defined?(:OPTIONAL_OPTION_TYPES)
84
+ types
85
+ end
86
+ module_function :option_types_for
87
+
88
+ ##
89
+ # @param ruby_type [Class]
90
+ # @return [String]
91
+ def json_type_for(ruby_type)
92
+ RUBY_TO_JSON_TYPE.fetch(ruby_type) do
93
+ raise ArgumentError, "unsupported OPTION_TYPES mapping for #{ruby_type}"
94
+ end
95
+ end
96
+ module_function :json_type_for
97
+ end
98
+ end
99
+ end
@@ -119,7 +119,8 @@ module Html2rss
119
119
  article_tag,
120
120
  base_url:,
121
121
  selected_anchor:,
122
- fallback_anchorless: true
122
+ fallback_anchorless: true,
123
+ time_zone: @time_zone
123
124
  )
124
125
  return article_hash unless extracted
125
126
 
@@ -8,7 +8,8 @@ module Html2rss
8
8
  # Stable telemetry payload for cross-repo consumers (e.g. html2rss-web observability).
9
9
  # Tallies and counters are validated and frozen at construction (including Marshal load).
10
10
  Status = Data.define(
11
- :version, :scraper_tallies, :dedup_dropped, :selected_strategy, :attempt_count, :strategy_attempts
11
+ :version, :scraper_tallies, :dedup_dropped, :selected_strategy, :attempt_count,
12
+ :strategy_attempts, :admission_drops
12
13
  ) do
13
14
  class << self
14
15
  ##
@@ -19,8 +20,11 @@ module Html2rss
19
20
  # @param selected_strategy [Symbol, nil] concrete strategy that succeeded under +:auto+ (else +nil+)
20
21
  # @param attempt_count [Integer] auto-fallback attempt count (0 when not under +:auto+)
21
22
  # @param strategy_attempts [Array<Hash>] auto-fallback attempt hashes (empty outside +:auto+)
23
+ # @param admission_drops [Hash{String => Integer}] Cleanup reason → count (empty when unused)
22
24
  # @return [Html2rss::Status]
23
- def build(articles:, dedup_dropped: 0, selected_strategy: nil, attempt_count: 0, strategy_attempts: [])
25
+ # rubocop:disable Metrics/ParameterLists -- Status kwargs stay co-located
26
+ def build(articles:, dedup_dropped: 0, selected_strategy: nil, attempt_count: 0,
27
+ strategy_attempts: [], admission_drops: {})
24
28
  tallies = articles.filter_map(&:scraper).tally.transform_keys { |klass| scraper_name(klass) }
25
29
  new(
26
30
  version: Html2rss::VERSION,
@@ -28,9 +32,11 @@ module Html2rss
28
32
  dedup_dropped:,
29
33
  selected_strategy:,
30
34
  attempt_count:,
31
- strategy_attempts:
35
+ strategy_attempts:,
36
+ admission_drops:
32
37
  )
33
38
  end
39
+ # rubocop:enable Metrics/ParameterLists
34
40
 
35
41
  ##
36
42
  # @param klass [Class, #to_s] scraper class
@@ -47,9 +53,11 @@ module Html2rss
47
53
  # @param selected_strategy [Symbol, nil]
48
54
  # @param attempt_count [Integer]
49
55
  # @param strategy_attempts [Array<Hash>]
56
+ # @param admission_drops [Hash{String => Integer}]
50
57
  # rubocop:disable Metrics/ParameterLists, Metrics/MethodLength -- Status Data.define members
51
58
  def initialize(
52
- version:, scraper_tallies:, dedup_dropped:, selected_strategy: nil, attempt_count: 0, strategy_attempts: []
59
+ version:, scraper_tallies:, dedup_dropped:, selected_strategy: nil, attempt_count: 0,
60
+ strategy_attempts: [], admission_drops: {}
53
61
  )
54
62
  dedup = Integer(dedup_dropped)
55
63
  attempts = Integer(attempt_count)
@@ -61,7 +69,8 @@ module Html2rss
61
69
  dedup_dropped: dedup,
62
70
  selected_strategy:,
63
71
  attempt_count: attempts,
64
- strategy_attempts: freeze_attempts(strategy_attempts)
72
+ strategy_attempts: freeze_attempts(strategy_attempts),
73
+ admission_drops: freeze_tallies(admission_drops)
65
74
  )
66
75
  end
67
76
  # rubocop:enable Metrics/ParameterLists, Metrics/MethodLength
@@ -69,11 +78,13 @@ module Html2rss
69
78
  ##
70
79
  # Observability hash for web (+scraper_status+). Omits empty/absent optional keys:
71
80
  # +:scraper_tallies+ when empty, +:selected_strategy+ when +nil+, +:attempt_count+ when zero,
72
- # +:strategy_attempts+ when empty.
81
+ # +:strategy_attempts+ / +:admission_drops+ when empty.
73
82
  # Data members remain available via readers even when omitted here.
74
83
  #
75
84
  # @return [Hash{Symbol => Object}] always +:version+ (String), +:dedup_dropped+ (Integer);
76
- # optionally +:scraper_tallies+, +:selected_strategy+, +:attempt_count+, +:strategy_attempts+
85
+ # optionally +:scraper_tallies+, +:selected_strategy+, +:attempt_count+,
86
+ # +:strategy_attempts+, +:admission_drops+
87
+ # rubocop:disable Metrics/AbcSize -- omit-empty optional keys stay explicit
77
88
  def to_h
78
89
  {
79
90
  version:,
@@ -81,9 +92,11 @@ module Html2rss
81
92
  **(scraper_tallies.any? ? { scraper_tallies: } : {}),
82
93
  **(selected_strategy.nil? ? {} : { selected_strategy: }),
83
94
  **(attempt_count.positive? ? { attempt_count: } : {}),
84
- **(strategy_attempts.any? ? { strategy_attempts: } : {})
95
+ **(strategy_attempts.any? ? { strategy_attempts: } : {}),
96
+ **(admission_drops.any? ? { admission_drops: } : {})
85
97
  }
86
98
  end
99
+ # rubocop:enable Metrics/AbcSize
87
100
 
88
101
  ##
89
102
  # Formats the RSS +generator+ string and JSON Feed +user_comment+.
@@ -101,11 +114,14 @@ module Html2rss
101
114
  private
102
115
 
103
116
  def marshal_dump
104
- [version, scraper_tallies, dedup_dropped, selected_strategy, attempt_count, strategy_attempts]
117
+ [version, scraper_tallies, dedup_dropped, selected_strategy, attempt_count, strategy_attempts,
118
+ admission_drops]
105
119
  end
106
120
 
107
- def marshal_load((version, scraper_tallies, dedup_dropped, selected_strategy, attempt_count, strategy_attempts))
108
- initialize(version:, scraper_tallies:, dedup_dropped:, selected_strategy:, attempt_count:, strategy_attempts:)
121
+ def marshal_load((version, scraper_tallies, dedup_dropped, selected_strategy, attempt_count,
122
+ strategy_attempts, admission_drops))
123
+ initialize(version:, scraper_tallies:, dedup_dropped:, selected_strategy:, attempt_count:,
124
+ strategy_attempts:, admission_drops: admission_drops || {})
109
125
  end
110
126
 
111
127
  def freeze_tallies(tallies)
data/lib/html2rss/url.rb CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  require 'addressable/uri'
4
4
  require 'cgi'
5
+ require 'ipaddr'
5
6
  require 'nokogiri'
6
7
 
7
8
  module Html2rss
@@ -21,6 +22,7 @@ module Html2rss
21
22
  # url = Url.from_relative('/foo-bar/baz.txt', 'https://example.com')
22
23
  # url.titleized # => "Foo Bar Baz"
23
24
  # url.channel_titleized # => "example.com: Foo Bar Baz"
25
+ # rubocop:disable Metrics/ClassLength -- value object owns resolve/sanitize/titleize/identity
24
26
  class Url
25
27
  include Comparable
26
28
 
@@ -151,6 +153,16 @@ module Html2rss
151
153
  # @return [String, nil] URI host component
152
154
  def host = @uri.host
153
155
 
156
+ # Registrable domain (eTLD+1). Same-site hosts like www.wp.pl and wiadomosci.wp.pl
157
+ # share `wp.pl`. IP literals stay the host so 1.2.3.4 is not treated as `3.4`.
158
+ #
159
+ # @return [String, nil]
160
+ def domain
161
+ return host if ip_address_host?
162
+
163
+ @uri.domain || host
164
+ end
165
+
154
166
  # @return [Integer, nil] URI port component
155
167
  def port = @uri.port
156
168
 
@@ -189,6 +201,14 @@ module Html2rss
189
201
  self.class.from_absolute(uri.normalize.to_s)
190
202
  end
191
203
 
204
+ ##
205
+ # Returns a copy of the URL with the fragment removed (dedup / self-link identity).
206
+ #
207
+ # @return [Url] this URL when already fragment-free; otherwise a new URL
208
+ def without_fragment
209
+ @uri.fragment ? self.class.from_absolute(@uri.omit(:fragment).normalize.to_s) : self
210
+ end
211
+
192
212
  ##
193
213
  # Returns a copy of the URL with the provided query values.
194
214
  #
@@ -259,13 +279,7 @@ module Html2rss
259
279
  # @param other [Object] the other object to compare with
260
280
  # @return [Boolean] true if the URLs are equal
261
281
  def ==(other) = other.is_a?(Url) && to_s == other.to_s
262
-
263
- ##
264
- # Supports hash-based comparisons by ensuring equality semantics match `hash`.
265
- #
266
- # @param other [Object] the other object to compare with
267
- # @return [Boolean] true if the URLs are considered equal
268
- def eql?(other) = other.is_a?(Url) && to_s == other.to_s
282
+ alias eql? ==
269
283
 
270
284
  ##
271
285
  # Returns the hash code for this URL.
@@ -278,5 +292,15 @@ module Html2rss
278
292
  #
279
293
  # @return [String] the debug representation
280
294
  def inspect = "#<#{self.class}:#{object_id} @uri=#{@uri.inspect}>"
295
+
296
+ private
297
+
298
+ def ip_address_host?
299
+ IPAddr.new(host.to_s)
300
+ true
301
+ rescue IPAddr::Error
302
+ false
303
+ end
281
304
  end
305
+ # rubocop:enable Metrics/ClassLength
282
306
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Html2rss
4
4
  # Current application version.
5
- VERSION = '0.25.0'
5
+ VERSION = '0.27.0'
6
6
  public_constant :VERSION
7
7
  end
data/lib/html2rss.rb CHANGED
@@ -5,7 +5,8 @@ require 'zeitwerk'
5
5
  loader = Zeitwerk::Loader.for_gem
6
6
  loader.inflector.inflect(
7
7
  'cli' => 'CLI',
8
- 'sst' => 'SST'
8
+ 'sst' => 'SST',
9
+ 'mcp' => 'MCP'
9
10
  )
10
11
  loader.setup
11
12
 
@@ -69,11 +70,35 @@ module Html2rss
69
70
 
70
71
  # rubocop:disable Metrics/ParameterLists
71
72
 
73
+ ##
74
+ # Scrapes the provided URL without hand-written selectors and returns {FeedResult}.
75
+ #
76
+ # Prefer this when callers need {FeedResult#status} (MCP envelope payload, CLI +--explain+).
77
+ #
78
+ # @param url [String] source page URL
79
+ # @param strategy [Symbol] request strategy to use
80
+ # @param items_selector [String, nil] optional selector hint for item extraction
81
+ # @param max_redirects [Integer, nil] optional redirect limit override
82
+ # @param max_requests [Integer] optional request budget override (default: 4 for sitemap sub-fetches)
83
+ # @param local_file_path [String, nil] optional local HTML file path
84
+ # @param limit [Integer, nil] max articles to keep (default: {AutoSource::DEFAULT_LIMIT})
85
+ # @return [Html2rss::FeedResult]
86
+ def self.auto_feed_result(url,
87
+ strategy: :auto,
88
+ items_selector: nil,
89
+ max_redirects: nil,
90
+ max_requests: 4,
91
+ local_file_path: nil,
92
+ limit: nil)
93
+ feed_result(build_auto_source_config(url:, strategy:, items_selector:, max_redirects:, max_requests:,
94
+ local_file_path:, limit:))
95
+ end
96
+
72
97
  ##
73
98
  # Scrapes the provided URL without hand-written selectors and returns an RSS object.
74
99
  #
75
100
  # Builds an auto_source config, then FeedPipeline runs structured scrapers and
76
- # (when needed) the SST heuristic path — see docs/auto_source.md.
101
+ # (when needed) the SST heuristic path — see lib/html2rss/auto_source/README.md.
77
102
  #
78
103
  # @param url [String] source page URL
79
104
  # @param strategy [Symbol] request strategy to use
@@ -90,14 +115,14 @@ module Html2rss
90
115
  max_requests: 4,
91
116
  local_file_path: nil,
92
117
  limit: nil)
93
- feed(build_auto_source_config(url:, strategy:, items_selector:, max_redirects:, max_requests:,
94
- local_file_path:, limit:))
118
+ auto_feed_result(url, strategy:, items_selector:, max_redirects:, max_requests:,
119
+ local_file_path:, limit:).to_rss
95
120
  end
96
121
 
97
122
  ##
98
123
  # Scrapes the provided URL without hand-written selectors and returns a JSONFeed 1.1 hash.
99
124
  #
100
- # Same auto_source pipeline as {.auto_source}; see docs/auto_source.md.
125
+ # Same auto_source pipeline as {.auto_source}; see lib/html2rss/auto_source/README.md.
101
126
  #
102
127
  # @param url [String] source page URL
103
128
  # @param strategy [Symbol] request strategy to use
@@ -114,8 +139,38 @@ module Html2rss
114
139
  max_requests: 4,
115
140
  local_file_path: nil,
116
141
  limit: nil)
117
- json_feed(build_auto_source_config(url:, strategy:, items_selector:, max_redirects:, max_requests:,
118
- local_file_path:, limit:))
142
+ auto_feed_result(url, strategy:, items_selector:, max_redirects:, max_requests:,
143
+ local_file_path:, limit:).to_json_feed
144
+ end
145
+
146
+ ##
147
+ # Analyzes a URL and produces a reusable YAML-ready feed config hash.
148
+ #
149
+ # Uses auto-source discovery to extract articles, then derives CSS selectors
150
+ # from the structural analysis.
151
+ #
152
+ # @param url [String] source page URL
153
+ # @param strategy [Symbol] request strategy (+:auto+, +:faraday+, +:botasaurus+)
154
+ # @param items_selector [String, nil] optional CSS selector hint for items
155
+ # @param max_redirects [Integer, nil] optional redirect limit override
156
+ # @param max_requests [Integer, nil] optional request budget override
157
+ # @param limit [Integer, nil] max articles to keep
158
+ # @param local_file_path [String, nil] optional local HTML file path
159
+ # @return [Hash] feed config hash with +:channel+ and +:selectors+
160
+ def self.capture(url,
161
+ strategy: :auto,
162
+ items_selector: nil,
163
+ max_redirects: nil,
164
+ max_requests: nil,
165
+ limit: nil,
166
+ local_file_path: nil)
167
+ Capture.build(url,
168
+ strategy:,
169
+ items_selector:,
170
+ max_redirects:,
171
+ max_requests:,
172
+ limit:,
173
+ local_file_path:).config
119
174
  end
120
175
 
121
176
  # rubocop:enable Metrics/ParameterLists