html2rss 0.25.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 (68) 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/json_state.rb +31 -22
  6. data/lib/html2rss/auto_source/scraper/schema/item_list.rb +2 -14
  7. data/lib/html2rss/auto_source/scraper/xhr_articles.rb +69 -0
  8. data/lib/html2rss/auto_source/scraper.rb +34 -15
  9. data/lib/html2rss/auto_source/segmenter/list.rb +3 -1
  10. data/lib/html2rss/auto_source/segmenter/primary_link.rb +6 -7
  11. data/lib/html2rss/auto_source/segmenter.rb +30 -1
  12. data/lib/html2rss/auto_source.rb +7 -2
  13. data/lib/html2rss/capture.rb +315 -0
  14. data/lib/html2rss/cli.rb +64 -5
  15. data/lib/html2rss/config/auto_source_contract.rb +3 -1
  16. data/lib/html2rss/config/schema.rb +67 -26
  17. data/lib/html2rss/config/selectors_validator.rb +60 -21
  18. data/lib/html2rss/config/validator.rb +18 -27
  19. data/lib/html2rss/error.rb +15 -3
  20. data/lib/html2rss/feed_pipeline/auto_fallback.rb +16 -6
  21. data/lib/html2rss/feed_pipeline/runtime_policy.rb +3 -43
  22. data/lib/html2rss/feed_pipeline.rb +10 -3
  23. data/lib/html2rss/hash_util.rb +17 -0
  24. data/lib/html2rss/html/sst_article_extractor.rb +36 -7
  25. data/lib/html2rss/link_destination/noise_policy.rb +7 -18
  26. data/lib/html2rss/link_destination/path_classifier.rb +3 -0
  27. data/lib/html2rss/mcp/server.rb +577 -0
  28. data/lib/html2rss/mcp.rb +21 -0
  29. data/lib/html2rss/request_service/blocked_surface.rb +24 -1
  30. data/lib/html2rss/request_service/botasaurus_contract.rb +89 -9
  31. data/lib/html2rss/request_service/botasaurus_strategy.rb +4 -2
  32. data/lib/html2rss/request_service/budget.rb +7 -35
  33. data/lib/html2rss/request_service/context.rb +0 -6
  34. data/lib/html2rss/request_service/faraday_strategy.rb +45 -2
  35. data/lib/html2rss/request_service/policy.rb +1 -1
  36. data/lib/html2rss/request_service/response.rb +15 -1
  37. data/lib/html2rss/request_service/strategy.rb +1 -2
  38. data/lib/html2rss/request_service.rb +4 -9
  39. data/lib/html2rss/scoring/container_assessor.rb +9 -15
  40. data/lib/html2rss/scoring/engine.rb +49 -5
  41. data/lib/html2rss/scoring/link_resolver.rb +12 -0
  42. data/lib/html2rss/scoring/ranked_segment.rb +0 -12
  43. data/lib/html2rss/scoring/score.rb +1 -20
  44. data/lib/html2rss/scoring.rb +0 -25
  45. data/lib/html2rss/selectors/extractors/attribute.rb +15 -0
  46. data/lib/html2rss/selectors/extractors/href.rb +12 -0
  47. data/lib/html2rss/selectors/extractors/html.rb +12 -0
  48. data/lib/html2rss/selectors/extractors/static.rb +14 -0
  49. data/lib/html2rss/selectors/extractors/text.rb +11 -0
  50. data/lib/html2rss/selectors/post_processors/gsub.rb +18 -0
  51. data/lib/html2rss/selectors/post_processors/html_to_markdown.rb +11 -0
  52. data/lib/html2rss/selectors/post_processors/markdown_to_html.rb +12 -0
  53. data/lib/html2rss/selectors/post_processors/parse_time.rb +11 -0
  54. data/lib/html2rss/selectors/post_processors/parse_uri.rb +11 -0
  55. data/lib/html2rss/selectors/post_processors/sanitize_html.rb +13 -0
  56. data/lib/html2rss/selectors/post_processors/substring.rb +21 -0
  57. data/lib/html2rss/selectors/post_processors/template.rb +20 -0
  58. data/lib/html2rss/selectors/schema_doc.rb +99 -0
  59. data/lib/html2rss/url.rb +11 -7
  60. data/lib/html2rss/version.rb +1 -1
  61. data/lib/html2rss.rb +32 -1
  62. data/schema/html2rss-config.schema.json +408 -85
  63. metadata +54 -12
  64. data/lib/html2rss/request_service/browserless_strategy.rb +0 -132
  65. data/lib/html2rss/request_service/puppet_commander/navigation_guards.rb +0 -148
  66. data/lib/html2rss/request_service/puppet_commander/preload_runner.rb +0 -86
  67. data/lib/html2rss/request_service/puppet_commander.rb +0 -95
  68. data/lib/html2rss/scoring/anchor_score.rb +0 -34
@@ -5,18 +5,6 @@ module Html2rss
5
5
  ##
6
6
  # A segment paired with its composite score.
7
7
  RankedSegment = Data.define(:segment, :score) do
8
- ##
9
- # @param segment [Html2rss::AutoSource::Segment]
10
- # @param score [Score]
11
- # @return [RankedSegment]
12
- # @raise [ArgumentError] on invalid types
13
- def self.build(segment:, score:)
14
- raise ArgumentError, 'segment must be AutoSource::Segment' unless segment.is_a?(Html2rss::AutoSource::Segment)
15
- raise ArgumentError, 'score must be Scoring::Score' unless score.is_a?(Score)
16
-
17
- new(segment:, score:)
18
- end
19
-
20
8
  ##
21
9
  # @return [SST::Node]
22
10
  def root_node = segment.root_node
@@ -4,26 +4,7 @@ module Html2rss
4
4
  module Scoring
5
5
  ##
6
6
  # Composite score with quality/junk split and optional typed feature breakdown.
7
- Score = Data.define(:composite, :quality, :junk, :breakdown) do
8
- ##
9
- # @param composite [Numeric]
10
- # @param quality [Numeric, nil]
11
- # @param junk [Numeric, nil]
12
- # @param breakdown [Hash{Symbol => Numeric}, nil]
13
- # @return [Score]
14
- # @raise [ArgumentError] when composite is not numeric
15
- def self.build(composite:, quality: nil, junk: nil, breakdown: nil)
16
- raise ArgumentError, 'composite must be Numeric' unless composite.is_a?(Numeric)
17
-
18
- parts = breakdown.nil? ? Score::EMPTY_BREAKDOWN : breakdown.transform_keys { FeatureId.assert!(_1) }.freeze
19
- new(
20
- composite: composite.to_f,
21
- quality: (quality.nil? ? composite : quality).to_f,
22
- junk: (junk || 0).to_f,
23
- breakdown: parts
24
- )
25
- end
26
- end
7
+ Score = Data.define(:composite, :quality, :junk, :breakdown)
27
8
  # Shared empty feature breakdown for scores without per-feature tallies.
28
9
  Score::EMPTY_BREAKDOWN = {}.freeze
29
10
  end
@@ -4,30 +4,5 @@ module Html2rss
4
4
  ##
5
5
  # Declarative feature scoring for AutoSource SST segments.
6
6
  module Scoring
7
- ##
8
- # Closed set of feature identifiers used by the scoring registry.
9
- module FeatureId
10
- # Closed set of allowed feature identifier symbols.
11
- IDS = %i[
12
- title_word_count_ge3 title_word_count_ge7 path_length_gt6 content_path
13
- publish_marker descriptive_context article_container content_tokens
14
- non_content_utility_path utility_prefix_title_short shallow weak_container
15
- recommended_title_non_content high_confidence_junk_path junk_tokens
16
- heading_anchor heading_text_match meaningful_text content_like_destination
17
- cluster_size cluster_avg_words cluster_heading cluster_time cluster_date
18
- ].to_set.freeze
19
-
20
- module_function
21
-
22
- ##
23
- # @param id [Symbol]
24
- # @return [Symbol]
25
- # @raise [ArgumentError] when id is not in the closed set
26
- def assert!(id)
27
- raise ArgumentError, "unknown FeatureId: #{id.inspect}" unless IDS.include?(id)
28
-
29
- id
30
- end
31
- end
32
7
  end
33
8
  end
@@ -24,9 +24,24 @@ module Html2rss
24
24
  # In case you're extracting a date or a time, consider parsing it
25
25
  # during post processing with {PostProcessors::ParseTime}.
26
26
  class Attribute
27
+ # Config-facing option types (excluding shared selector fields supplied at runtime).
28
+ OPTION_TYPES = { attribute: String }.freeze
29
+
27
30
  # The available options for the attribute extractor.
28
31
  Options = Struct.new('AttributeOptions', :selector, :attribute, keyword_init: true)
29
32
 
33
+ # JSON Schema description exported via +schema_doc+.
34
+ DESCRIPTION = 'Return the value of an HTML attribute on the selected element. ' \
35
+ 'Requires sibling selector option `attribute` (attribute name).'
36
+
37
+ # Example extractor name values for JSON Schema +examples+.
38
+ EXAMPLES = [
39
+ 'attribute'
40
+ ].freeze
41
+
42
+ # @return [Hash{Symbol => Object}] JSON Schema fragment for this extractor name
43
+ def self.schema_doc = SchemaDoc.for_extractor(name: :attribute, klass: self)
44
+
30
45
  ##
31
46
  # Initializes the Attribute extractor.
32
47
  #
@@ -27,6 +27,18 @@ module Html2rss
27
27
  # The available options for the href (attribute) extractor.
28
28
  Options = Struct.new('HrefOptions', :selector, :channel, keyword_init: true)
29
29
 
30
+ # JSON Schema description exported via +schema_doc+.
31
+ DESCRIPTION = 'Return the absolute URL from the selected element\'s `href` attribute ' \
32
+ '(relative hrefs are resolved against the channel URL).'
33
+
34
+ # Example extractor name values for JSON Schema +examples+.
35
+ EXAMPLES = [
36
+ 'href'
37
+ ].freeze
38
+
39
+ # @return [Hash{Symbol => Object}] JSON Schema fragment for this extractor name
40
+ def self.schema_doc = SchemaDoc.for_extractor(name: :href, klass: self)
41
+
30
42
  ##
31
43
  # Initializes the Href extractor.
32
44
  #
@@ -26,6 +26,18 @@ module Html2rss
26
26
  # The available options for the html extractor.
27
27
  Options = Struct.new('HtmlOptions', :selector, keyword_init: true)
28
28
 
29
+ # JSON Schema description exported via +schema_doc+.
30
+ DESCRIPTION = 'Return the outer HTML of the selected element. ' \
31
+ 'Sanitize during post-processing (e.g. `sanitize_html`).'
32
+
33
+ # Example extractor name values for JSON Schema +examples+.
34
+ EXAMPLES = [
35
+ 'html'
36
+ ].freeze
37
+
38
+ # @return [Hash{Symbol => Object}] JSON Schema fragment for this extractor name
39
+ def self.schema_doc = SchemaDoc.for_extractor(name: :html, klass: self)
40
+
29
41
  ##
30
42
  # Initializes the Html extractor.
31
43
  #
@@ -16,9 +16,23 @@ module Html2rss
16
16
  # Would return:
17
17
  # 'Foobar'
18
18
  class Static
19
+ # Config-facing option types (excluding shared selector fields supplied at runtime).
20
+ OPTION_TYPES = { static: String }.freeze
21
+
19
22
  # The available option for the static extractor.
20
23
  Options = Struct.new('StaticOptions', :static, keyword_init: true)
21
24
 
25
+ # JSON Schema description exported via +schema_doc+.
26
+ DESCRIPTION = 'Return a fixed value from sibling selector option `static` (no DOM read).'
27
+
28
+ # Example extractor name values for JSON Schema +examples+.
29
+ EXAMPLES = [
30
+ 'static'
31
+ ].freeze
32
+
33
+ # @return [Hash{Symbol => Object}] JSON Schema fragment for this extractor name
34
+ def self.schema_doc = SchemaDoc.for_extractor(name: :static, klass: self)
35
+
22
36
  ##
23
37
  # Initializes the Static extractor.
24
38
  #
@@ -24,6 +24,17 @@ module Html2rss
24
24
  # The available options for the text extractor.
25
25
  Options = Struct.new('TextOptions', :selector, keyword_init: true)
26
26
 
27
+ # JSON Schema description exported via +schema_doc+.
28
+ DESCRIPTION = 'Return collapsed visible text of the selected element (default extractor).'
29
+
30
+ # Example extractor name values for JSON Schema +examples+.
31
+ EXAMPLES = [
32
+ 'text'
33
+ ].freeze
34
+
35
+ # @return [Hash{Symbol => Object}] JSON Schema fragment for this extractor name
36
+ def self.schema_doc = SchemaDoc.for_extractor(name: :text, klass: self)
37
+
27
38
  ##
28
39
  # Initializes the Text extractor.
29
40
  #
@@ -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
data/lib/html2rss/url.rb CHANGED
@@ -21,6 +21,7 @@ module Html2rss
21
21
  # url = Url.from_relative('/foo-bar/baz.txt', 'https://example.com')
22
22
  # url.titleized # => "Foo Bar Baz"
23
23
  # url.channel_titleized # => "example.com: Foo Bar Baz"
24
+ # rubocop:disable Metrics/ClassLength -- value object owns resolve/sanitize/titleize/identity
24
25
  class Url
25
26
  include Comparable
26
27
 
@@ -189,6 +190,14 @@ module Html2rss
189
190
  self.class.from_absolute(uri.normalize.to_s)
190
191
  end
191
192
 
193
+ ##
194
+ # Returns a copy of the URL with the fragment removed (dedup / self-link identity).
195
+ #
196
+ # @return [Url] this URL when already fragment-free; otherwise a new URL
197
+ def without_fragment
198
+ @uri.fragment ? self.class.from_absolute(@uri.omit(:fragment).normalize.to_s) : self
199
+ end
200
+
192
201
  ##
193
202
  # Returns a copy of the URL with the provided query values.
194
203
  #
@@ -259,13 +268,7 @@ module Html2rss
259
268
  # @param other [Object] the other object to compare with
260
269
  # @return [Boolean] true if the URLs are equal
261
270
  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
271
+ alias eql? ==
269
272
 
270
273
  ##
271
274
  # Returns the hash code for this URL.
@@ -279,4 +282,5 @@ module Html2rss
279
282
  # @return [String] the debug representation
280
283
  def inspect = "#<#{self.class}:#{object_id} @uri=#{@uri.inspect}>"
281
284
  end
285
+ # rubocop:enable Metrics/ClassLength
282
286
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Html2rss
4
4
  # Current application version.
5
- VERSION = '0.25.0'
5
+ VERSION = '0.26.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
 
@@ -118,6 +119,36 @@ module Html2rss
118
119
  local_file_path:, limit:))
119
120
  end
120
121
 
122
+ ##
123
+ # Analyzes a URL and produces a reusable YAML-ready feed config hash.
124
+ #
125
+ # Uses auto-source discovery to extract articles, then derives CSS selectors
126
+ # from the structural analysis.
127
+ #
128
+ # @param url [String] source page URL
129
+ # @param strategy [Symbol] request strategy (+:auto+, +:faraday+, +:botasaurus+)
130
+ # @param items_selector [String, nil] optional CSS selector hint for items
131
+ # @param max_redirects [Integer, nil] optional redirect limit override
132
+ # @param max_requests [Integer, nil] optional request budget override
133
+ # @param limit [Integer, nil] max articles to keep
134
+ # @param local_file_path [String, nil] optional local HTML file path
135
+ # @return [Hash] feed config hash with +:channel+ and +:selectors+
136
+ def self.capture(url,
137
+ strategy: :auto,
138
+ items_selector: nil,
139
+ max_redirects: nil,
140
+ max_requests: nil,
141
+ limit: nil,
142
+ local_file_path: nil)
143
+ Capture.build(url,
144
+ strategy:,
145
+ items_selector:,
146
+ max_redirects:,
147
+ max_requests:,
148
+ limit:,
149
+ local_file_path:).config
150
+ end
151
+
121
152
  # rubocop:enable Metrics/ParameterLists
122
153
 
123
154
  # rubocop:disable ThreadSafety/ClassInstanceVariable