html2rss 0.32.3 → 0.33.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 (108) hide show
  1. checksums.yaml +4 -4
  2. data/lib/html2rss/article/enclosure.rb +2 -6
  3. data/lib/html2rss/auto_source/cleanup.rb +1 -3
  4. data/lib/html2rss/auto_source/scraper/html.rb +2 -8
  5. data/lib/html2rss/auto_source/scraper/json_document.rb +2 -3
  6. data/lib/html2rss/auto_source/scraper/json_state/article_normalizer.rb +128 -128
  7. data/lib/html2rss/auto_source/scraper/json_state/candidate_detector.rb +37 -37
  8. data/lib/html2rss/auto_source/scraper/json_state/document_scanner.rb +123 -123
  9. data/lib/html2rss/auto_source/scraper/json_state/value_finder.rb +34 -34
  10. data/lib/html2rss/auto_source/scraper/json_state.rb +2 -6
  11. data/lib/html2rss/auto_source/scraper/meta_oembed.rb +32 -2
  12. data/lib/html2rss/auto_source/scraper/microdata.rb +228 -245
  13. data/lib/html2rss/auto_source/scraper/microformats2.rb +9 -1
  14. data/lib/html2rss/auto_source/scraper/native_feed.rb +1 -1
  15. data/lib/html2rss/auto_source/scraper/schema/category_extractor.rb +7 -2
  16. data/lib/html2rss/auto_source/scraper/schema/item_list.rb +2 -2
  17. data/lib/html2rss/auto_source/scraper/schema/thing.rb +98 -21
  18. data/lib/html2rss/auto_source/scraper/schema.rb +49 -2
  19. data/lib/html2rss/auto_source/scraper/semantic_html.rb +12 -16
  20. data/lib/html2rss/auto_source/scraper/sitemap/parser.rb +1 -4
  21. data/lib/html2rss/auto_source/scraper/sitemap.rb +3 -11
  22. data/lib/html2rss/auto_source/scraper/wordpress_api/page_scope.rb +2 -2
  23. data/lib/html2rss/auto_source/scraper/wordpress_api/posts_endpoint.rb +1 -3
  24. data/lib/html2rss/auto_source/scraper/wordpress_api.rb +4 -4
  25. data/lib/html2rss/auto_source/scraper/xhr_articles.rb +1 -1
  26. data/lib/html2rss/auto_source/scraper.rb +0 -11
  27. data/lib/html2rss/auto_source/segmenter/cluster.rb +5 -11
  28. data/lib/html2rss/auto_source/segmenter/list.rb +25 -57
  29. data/lib/html2rss/auto_source/segmenter/semantic.rb +6 -13
  30. data/lib/html2rss/auto_source.rb +1 -1
  31. data/lib/html2rss/batch.rb +22 -32
  32. data/lib/html2rss/capture/README.md +1 -1
  33. data/lib/html2rss/capture.rb +7 -27
  34. data/lib/html2rss/cli/render.rb +97 -96
  35. data/lib/html2rss/cli/validate.rb +2 -4
  36. data/lib/html2rss/cli.rb +33 -27
  37. data/lib/html2rss/config/request_controls.rb +10 -2
  38. data/lib/html2rss/config/schema.rb +8 -10
  39. data/lib/html2rss/config/selectors_validator.rb +1 -0
  40. data/lib/html2rss/config/validator.rb +3 -3
  41. data/lib/html2rss/config.rb +1 -1
  42. data/lib/html2rss/defaults.rb +5 -16
  43. data/lib/html2rss/doctor/botasaurus.rb +11 -19
  44. data/lib/html2rss/error.rb +54 -1
  45. data/lib/html2rss/feed_builder/rss.rb +1 -1
  46. data/lib/html2rss/feed_pipeline/README.md +1 -1
  47. data/lib/html2rss/feed_pipeline/auto_fallback.rb +58 -55
  48. data/lib/html2rss/feed_resolution/candidate_generator.rb +3 -4
  49. data/lib/html2rss/feed_resolution/options.rb +3 -7
  50. data/lib/html2rss/feed_resolution/policy.rb +29 -33
  51. data/lib/html2rss/feed_resolution/probe.rb +3 -3
  52. data/lib/html2rss/feed_resolution/scorer.rb +29 -29
  53. data/lib/html2rss/feed_resolution.rb +5 -9
  54. data/lib/html2rss/feed_result.rb +4 -3
  55. data/lib/html2rss/hash_util.rb +91 -90
  56. data/lib/html2rss/html/article_extractor/category_extractor.rb +6 -9
  57. data/lib/html2rss/html/article_extractor/date_extractor.rb +1 -1
  58. data/lib/html2rss/html/article_extractor/enclosure_extractor.rb +1 -3
  59. data/lib/html2rss/html/article_extractor/id_generator.rb +1 -1
  60. data/lib/html2rss/html/probe.rb +51 -51
  61. data/lib/html2rss/link_destination/path_classifier.rb +11 -33
  62. data/lib/html2rss/mcp/config_argument.rb +9 -2
  63. data/lib/html2rss/mcp/contract.rb +7 -5
  64. data/lib/html2rss/mcp/outcome.rb +4 -5
  65. data/lib/html2rss/mcp/runtime.rb +17 -24
  66. data/lib/html2rss/mcp/server/tools.rb +9 -9
  67. data/lib/html2rss/option.rb +48 -0
  68. data/lib/html2rss/page_recon/diagnostics.rb +7 -9
  69. data/lib/html2rss/page_recon.rb +0 -4
  70. data/lib/html2rss/recon.rb +10 -12
  71. data/lib/html2rss/request_service/blocked_surface.rb +3 -7
  72. data/lib/html2rss/request_service/botasaurus_strategy.rb +2 -2
  73. data/lib/html2rss/request_service/budget.rb +3 -24
  74. data/lib/html2rss/request_service/compressed_body.rb +36 -48
  75. data/lib/html2rss/request_service/context.rb +1 -3
  76. data/lib/html2rss/request_service/local_file_strategy.rb +3 -1
  77. data/lib/html2rss/request_service/network_guard.rb +3 -2
  78. data/lib/html2rss/request_service/policy.rb +8 -26
  79. data/lib/html2rss/request_service/response.rb +3 -6
  80. data/lib/html2rss/request_service/strategy.rb +2 -2
  81. data/lib/html2rss/request_service.rb +26 -2
  82. data/lib/html2rss/request_session/pager/base.rb +3 -3
  83. data/lib/html2rss/request_session/pager/custom_selector.rb +4 -4
  84. data/lib/html2rss/request_session/pager/offset.rb +1 -7
  85. data/lib/html2rss/request_session/pager/rel_next.rb +1 -1
  86. data/lib/html2rss/request_session/pager/url_template.rb +1 -7
  87. data/lib/html2rss/scoring/engine.rb +4 -32
  88. data/lib/html2rss/scoring/link_resolver.rb +2 -7
  89. data/lib/html2rss/selectors/extractors/href.rb +2 -4
  90. data/lib/html2rss/selectors/json_xml.rb +33 -34
  91. data/lib/html2rss/selectors/post_processors/gsub.rb +2 -3
  92. data/lib/html2rss/selectors/post_processors/parse_time.rb +4 -5
  93. data/lib/html2rss/selectors/schema_export.rb +60 -60
  94. data/lib/html2rss/selectors.rb +14 -18
  95. data/lib/html2rss/sst/node.rb +9 -3
  96. data/lib/html2rss/sst/normalizer.rb +25 -24
  97. data/lib/html2rss/sst/text.rb +53 -57
  98. data/lib/html2rss/status.rb +23 -30
  99. data/lib/html2rss/syndication/alternate_links.rb +39 -0
  100. data/lib/html2rss/syndication/discovery.rb +117 -187
  101. data/lib/html2rss/syndication/parser.rb +92 -115
  102. data/lib/html2rss/test/enhance_audit.rb +13 -15
  103. data/lib/html2rss/test/policy.rb +9 -11
  104. data/lib/html2rss/test.rb +29 -48
  105. data/lib/html2rss/url.rb +53 -53
  106. data/lib/html2rss/version.rb +1 -1
  107. data/lib/html2rss.rb +2 -1
  108. metadata +4 -2
@@ -3,116 +3,117 @@
3
3
  module Html2rss
4
4
  # Shared helpers for hash normalization and structural operations.
5
5
  module HashUtil
6
- module_function
6
+ class << self
7
+ # Deeply duplicates nested arrays and hashes.
8
+ #
9
+ # @param object [Object] nested value from configuration or runtime state
10
+ # @return [Object] deep duplicated object
11
+ def deep_dup(object)
12
+ case object
13
+ in Hash
14
+ object.transform_values { deep_dup(_1) }
15
+ in Array
16
+ object.map { deep_dup(_1) }
17
+ else
18
+ object.dup
19
+ end
20
+ end
7
21
 
8
- # Deeply duplicates nested arrays and hashes.
9
- #
10
- # @param object [Object] nested value from configuration or runtime state
11
- # @return [Object] deep duplicated object
12
- def deep_dup(object)
13
- case object
14
- in Hash
15
- object.transform_values { deep_dup(_1) }
16
- in Array
17
- object.map { deep_dup(_1) }
18
- else
19
- object.dup rescue StandardError # rubocop:disable Style/RescueModifier
22
+ # Deeply merges nested hashes while replacing non-hash values from override.
23
+ #
24
+ # @param base [Hash] base hash
25
+ # @param override [Hash] override hash
26
+ # @return [Hash] merged hash
27
+ def deep_merge(base, override)
28
+ base.merge(override) do |_key, old_val, new_val|
29
+ case [old_val, new_val]
30
+ in [Hash, Hash]
31
+ deep_merge(old_val, new_val)
32
+ else
33
+ new_val
34
+ end
35
+ end
20
36
  end
21
- end
22
37
 
23
- # Deeply merges nested hashes while replacing non-hash values from override.
24
- #
25
- # @param base [Hash] base hash
26
- # @param override [Hash] override hash
27
- # @return [Hash] merged hash
28
- def deep_merge(base, override)
29
- base.merge(override) do |_key, old_val, new_val|
30
- case [old_val, new_val]
31
- in [Hash, Hash]
32
- deep_merge(old_val, new_val)
38
+ # Converts string-keyed hashes to symbol-keyed hashes recursively.
39
+ #
40
+ # @param object [Object] value to normalize
41
+ # @param context [String] error context
42
+ # @return [Object] normalized value
43
+ def deep_symbolize_keys(object, context: 'hash')
44
+ case object
45
+ in Hash
46
+ object.each_with_object({}) do |(k, v), memo|
47
+ memo[symbol_key(k, context:)] = deep_symbolize_keys(v, context:)
48
+ end
49
+ in Array
50
+ object.map { deep_symbolize_keys(_1, context:) }
33
51
  else
34
- new_val
52
+ object
35
53
  end
36
54
  end
37
- end
38
55
 
39
- # Converts string-keyed hashes to symbol-keyed hashes recursively.
40
- #
41
- # @param object [Object] value to normalize
42
- # @param context [String] error context
43
- # @return [Object] normalized value
44
- def deep_symbolize_keys(object, context: 'hash')
45
- case object
46
- in Hash
47
- object.each_with_object({}) do |(k, v), memo|
48
- memo[symbol_key(k, context:)] = deep_symbolize_keys(v, context:)
56
+ # Converts hash keys (and Symbol values) to strings recursively.
57
+ #
58
+ # @param object [Object] value to normalize
59
+ # @return [Object] normalized value with string keys
60
+ def deep_stringify_keys(object)
61
+ case object
62
+ when Hash
63
+ object.to_h { |key, value| [key.to_s, deep_stringify_keys(value)] }
64
+ when Array
65
+ object.map { deep_stringify_keys(_1) }
66
+ when Symbol
67
+ object.to_s
68
+ else
69
+ object
49
70
  end
50
- in Array
51
- object.map { deep_symbolize_keys(_1, context:) }
52
- else
53
- object
54
71
  end
55
- end
56
72
 
57
- # Converts hash keys (and Symbol values) to strings recursively.
58
- #
59
- # @param object [Object] value to normalize
60
- # @return [Object] normalized value with string keys
61
- def deep_stringify_keys(object)
62
- case object
63
- when Hash
64
- object.to_h { |key, value| [key.to_s, deep_stringify_keys(value)] }
65
- when Array
66
- object.map { deep_stringify_keys(_1) }
67
- when Symbol
68
- object.to_s
69
- else
70
- object
71
- end
72
- end
73
+ # Validates that hash keys are symbols.
74
+ #
75
+ # @param value [Object] candidate hash container whose keys must be symbols
76
+ # @param context [String] error context
77
+ # @param deep [Boolean] whether nested hashes should also be validated
78
+ # @return [void]
79
+ def assert_symbol_keys!(value, context: 'hash', deep: true)
80
+ return unless value in Hash
73
81
 
74
- # Validates that hash keys are symbols.
75
- #
76
- # @param value [Object] candidate hash container whose keys must be symbols
77
- # @param context [String] error context
78
- # @param deep [Boolean] whether nested hashes should also be validated
79
- # @return [void]
80
- def assert_symbol_keys!(value, context: 'hash', deep: true)
81
- return unless value in Hash
82
+ unless value.each_key.all?(Symbol)
83
+ invalid_key = value.keys.find { _1.class != Symbol }
84
+ raise ArgumentError, "#{context} must use symbol keys (found #{invalid_key.inspect})"
85
+ end
82
86
 
83
- unless value.each_key.all?(Symbol)
84
- invalid_key = value.keys.find { _1.class != Symbol }
85
- raise ArgumentError, "#{context} must use symbol keys (found #{invalid_key.inspect})"
87
+ value.each_value { assert_symbol_keys!(_1, context:, deep:) } if deep
86
88
  end
87
89
 
88
- value.each_value { assert_symbol_keys!(_1, context:, deep:) } if deep
89
- end
90
+ # Validates that hash keys are strings.
91
+ #
92
+ # @param value [Object] candidate hash container whose keys must be strings
93
+ # @param context [String] error context
94
+ # @param deep [Boolean] whether nested hashes should also be validated
95
+ # @return [void]
96
+ def assert_string_keys!(value, context: 'hash', deep: true)
97
+ return unless value in Hash
90
98
 
91
- # Validates that hash keys are strings.
92
- #
93
- # @param value [Object] candidate hash container whose keys must be strings
94
- # @param context [String] error context
95
- # @param deep [Boolean] whether nested hashes should also be validated
96
- # @return [void]
97
- def assert_string_keys!(value, context: 'hash', deep: true)
98
- return unless value in Hash
99
+ unless value.each_key.all?(String)
100
+ invalid_key = value.keys.find { _1.class != String }
101
+ raise ArgumentError, "#{context} must use string keys (found #{invalid_key.inspect})"
102
+ end
99
103
 
100
- unless value.each_key.all?(String)
101
- invalid_key = value.keys.find { _1.class != String }
102
- raise ArgumentError, "#{context} must use string keys (found #{invalid_key.inspect})"
104
+ value.each_value { assert_string_keys!(_1, context:, deep:) } if deep
103
105
  end
104
106
 
105
- value.each_value { assert_string_keys!(_1, context:, deep:) } if deep
106
- end
107
+ private
107
108
 
108
- def symbol_key(key, context:)
109
- case key
110
- in Symbol then key
111
- in String then key.to_sym
112
- else
113
- raise ArgumentError, "#{context} must use string or symbol keys (found #{key.inspect})"
109
+ def symbol_key(key, context:)
110
+ case key
111
+ in Symbol then key
112
+ in String then key.to_sym
113
+ else
114
+ raise ArgumentError, "#{context} must use string or symbol keys (found #{key.inspect})"
115
+ end
114
116
  end
115
117
  end
116
- private_class_method :symbol_key
117
118
  end
118
119
  end
@@ -28,23 +28,20 @@ module Html2rss
28
28
  def self.call(article_tag, title: nil)
29
29
  return [] unless article_tag
30
30
 
31
- extract_all_categories(article_tag, title:)
32
- .map(&:strip)
33
- .reject(&:empty?)
31
+ extract_all_categories(article_tag, title:).to_a
34
32
  end
35
33
 
36
34
  ##
37
35
  # Optimized single DOM traversal that extracts all category types.
36
+ # `Node#css` only yields descendants, so the container itself is never visited.
38
37
  #
39
38
  # @param article_tag [Nokogiri::XML::Element] The article element
40
39
  # @param title [String, nil]
41
40
  # @return [Set<String>] Set of category strings
42
- def self.extract_all_categories(article_tag, title: nil)
41
+ def self.extract_all_categories(article_tag, title:)
43
42
  Set.new.tap do |categories|
44
43
  extract_element_data_categories!(categories, article_tag, title:)
45
44
  article_tag.css(CATEGORY_SELECTORS.join(',')).each do |element|
46
- next if element == article_tag
47
-
48
45
  if ArticleRules::Category.class_match?(element['class'])
49
46
  extract_text_categories!(categories, element, title:)
50
47
  end
@@ -60,7 +57,7 @@ module Html2rss
60
57
  # @param element [Nokogiri::XML::Element] metadata element that may contain category links
61
58
  # @param title [String, nil]
62
59
  # @return [void]
63
- def self.extract_element_data_categories!(categories, element, title: nil)
60
+ def self.extract_element_data_categories!(categories, element, title:)
64
61
  element.attributes.each_value do |attr|
65
62
  next unless ArticleRules::Category.attr_name_match?(attr.name)
66
63
 
@@ -75,7 +72,7 @@ module Html2rss
75
72
  # @param element [Nokogiri::XML::Element] metadata element whose text may contain delimiters
76
73
  # @param title [String, nil]
77
74
  # @return [void]
78
- def self.extract_text_categories!(categories, element, title: nil)
75
+ def self.extract_text_categories!(categories, element, title:)
79
76
  return if category_container?(element)
80
77
 
81
78
  anchors = element.name == 'a' ? [element] : element.css('a').to_a
@@ -96,7 +93,7 @@ module Html2rss
96
93
  # @param element [Nokogiri::XML::Element] The element to extract text from
97
94
  # @param title [String, nil]
98
95
  # @return [void]
99
- def self.add_text_to_categories!(categories, element, title: nil)
96
+ def self.add_text_to_categories!(categories, element, title:)
100
97
  ArticleRules::Category.add_text!(categories, Navigator.extract_visible_text(element), title:)
101
98
  end
102
99
 
@@ -9,7 +9,7 @@ module Html2rss
9
9
  # @param leftover_lines [Array<String>] leftover visible lines (already split)
10
10
  # @param time_zone [String] channel time zone for naive values
11
11
  # @return [DateTime, nil]
12
- def self.call(article_tag, leftover_lines: [], time_zone: 'UTC')
12
+ def self.call(article_tag, time_zone:, leftover_lines: [])
13
13
  datetimes = article_tag.css('[datetime]').map { |tag| tag['datetime'] }
14
14
  ArticleRules::Date.earliest(datetimes, leftover_lines:, time_zone:)
15
15
  end
@@ -23,8 +23,6 @@ module Html2rss
23
23
  # @param base_url [String, Html2rss::Url] base URL for relative enclosure links
24
24
  # @return [Array<Hash{Symbol => Object}>] normalized enclosure hashes
25
25
  def self.call(article_tag, base_url)
26
- return [] unless article_tag
27
-
28
26
  article_tag.css(SELECTOR).filter_map do |element|
29
27
  extract_from_element(element, base_url)
30
28
  end
@@ -34,7 +32,7 @@ module Html2rss
34
32
  case element.name
35
33
  when 'img'
36
34
  ArticleRules::Enclosure.from_image(element['src'], base_url)
37
- when 'video', 'audio', 'source'
35
+ when 'audio', 'source'
38
36
  ArticleRules::Enclosure.from_media(element['src'], element['type'], base_url)
39
37
  when 'iframe'
40
38
  ArticleRules::Enclosure.from_iframe(element['src'], base_url)
@@ -32,7 +32,7 @@ module Html2rss
32
32
 
33
33
  def parse_id_from_dom(article_tag, url, selected_anchor)
34
34
  candidates = [article_tag['id'], article_tag.at_css('[id]')&.attr('id')]
35
- candidates += [url&.path, url&.query] if selected_anchor
35
+ candidates += [url&.path] if selected_anchor
36
36
  candidates.compact.reject(&:empty?).first
37
37
  end
38
38
 
@@ -16,66 +16,66 @@ module Html2rss
16
16
  # @see #mime_match?
17
17
  APPLICATION_ATOM_XML = 'application/atom+xml'
18
18
 
19
- module_function
19
+ class << self
20
+ ##
21
+ # @param string [String, Symbol, #to_s] wire value
22
+ # @return [String] folded comparison key
23
+ def fold(string)
24
+ string.to_s.downcase
25
+ end
20
26
 
21
- ##
22
- # @param string [String, Symbol, #to_s] wire value
23
- # @return [String] folded comparison key
24
- def fold(string)
25
- string.to_s.downcase
26
- end
27
-
28
- ##
29
- # @param node [Nokogiri::XML::Node]
30
- # @return [String] folded element name
31
- def tag(node)
32
- name = node.name
33
- return fold(name) unless name.is_a?(String)
34
- return name if name.ascii_only? && !name.match?(/[A-Z]/)
27
+ ##
28
+ # @param node [Nokogiri::XML::Node]
29
+ # @return [String] folded element name
30
+ def tag(node)
31
+ name = node.name
32
+ return fold(name) unless name.is_a?(String)
33
+ return name if name.ascii_only? && !name.match?(/[A-Z]/)
35
34
 
36
- fold(name)
37
- end
35
+ fold(name)
36
+ end
38
37
 
39
- ##
40
- # @param type [String, Symbol, #to_s, nil] MIME type attribute value
41
- # @return [String] media type without parameters, folded
42
- def mime_base(type)
43
- raw = type.to_s
44
- return fold(raw) unless raw.include?(';') || raw.match?(/\A\s|\s\z/)
38
+ ##
39
+ # @param type [String, Symbol, #to_s, nil] MIME type attribute value
40
+ # @return [String] media type without parameters, folded
41
+ def mime_base(type)
42
+ raw = type.to_s
43
+ return fold(raw) unless raw.include?(';') || raw.match?(/\A\s|\s\z/)
45
44
 
46
- fold(raw.split(';', 2).first.to_s.strip)
47
- end
45
+ fold(raw.split(';', 2).first.to_s.strip)
46
+ end
48
47
 
49
- ##
50
- # @param actual [String, Symbol, #to_s, nil] observed MIME type
51
- # @param expected [Array<String>] canonical MIME types to match
52
- # @return [Boolean]
53
- def mime_match?(actual, *expected)
54
- base = mime_base(actual)
55
- expected.any? { |candidate| base == candidate || base == mime_base(candidate) }
56
- end
48
+ ##
49
+ # @param actual [String, Symbol, #to_s, nil] observed MIME type
50
+ # @param expected [Array<String>] canonical MIME types to match
51
+ # @return [Boolean]
52
+ def mime_match?(actual, *expected)
53
+ base = mime_base(actual)
54
+ expected.any? { |candidate| base == candidate || base == mime_base(candidate) }
55
+ end
57
56
 
58
- ##
59
- # @param doc [Nokogiri::XML::Node]
60
- # @param mime_types [Array<String>] optional MIME filters (folded once per call)
61
- # @return [Array<Nokogiri::XML::Element>]
62
- def scripts(doc, *mime_types)
63
- return doc.css('script') if mime_types.empty?
57
+ ##
58
+ # @param doc [Nokogiri::XML::Node]
59
+ # @param mime_types [Array<String>] optional MIME filters (folded once per call)
60
+ # @return [Array<Nokogiri::XML::Element>]
61
+ def scripts(doc, *mime_types)
62
+ return doc.css('script') if mime_types.empty?
64
63
 
65
- allowed = mime_types.to_set { |mime| mime_base(mime) }
66
- doc.css('script').select { |script| allowed.include?(mime_base(script['type'])) }
67
- end
64
+ allowed = mime_types.to_set { |mime| mime_base(mime) }
65
+ doc.css('script').select { |script| allowed.include?(mime_base(script['type'])) }
66
+ end
68
67
 
69
- ##
70
- # @param doc [Nokogiri::XML::Node]
71
- # @param rel [String] link rel token
72
- # @param mime [String, nil] optional MIME filter
73
- # @return [Array<Nokogiri::XML::Element>]
74
- def alternate_links(doc, rel:, mime: nil)
75
- nodes = doc.css(%(link[rel~="#{rel}"][href]))
76
- return nodes if mime.nil?
68
+ ##
69
+ # @param doc [Nokogiri::XML::Node]
70
+ # @param rel [String] link rel token
71
+ # @param mime [String, nil] optional MIME filter
72
+ # @return [Array<Nokogiri::XML::Element>]
73
+ def alternate_links(doc, rel:, mime: nil)
74
+ nodes = doc.css(%(link[rel~="#{rel}"][href]))
75
+ return nodes if mime.nil?
77
76
 
78
- nodes.select { |node| mime_match?(node['type'], mime) }
77
+ nodes.select { |node| mime_match?(node['type'], mime) }
78
+ end
79
79
  end
80
80
  end
81
81
  end
@@ -106,13 +106,13 @@ module Html2rss
106
106
  def shallow?
107
107
  segment_count = segments.size
108
108
 
109
- segment_count <= 1 || (segment_count == 2 && high_confidence_junk_at?(segment_count - 1))
109
+ segment_count <= 1 || (segment_count == 2 && high_confidence_junk_at?(1))
110
110
  end
111
111
 
112
112
  # @return [Boolean] true when the final path segment looks like a post slug
113
113
  def strong_post_suffix?
114
114
  @strong_post_suffix ||= segments.any? &&
115
- included_last_segment? &&
115
+ slug_last_segment? &&
116
116
  trusted_post_context?(segments.size - 1)
117
117
  end
118
118
 
@@ -121,13 +121,9 @@ module Html2rss
121
121
  (0...segments.size).all? { |i| high_confidence_junk_at?(i) }
122
122
  end
123
123
 
124
- # @return [Boolean] true when the route is shallow and contains high-confidence noise
125
- def shallow_high_confidence_route?
126
- vanity_segments = SEGMENT_SETS.fetch(:vanity)
127
-
128
- shallow? && (0...segments.size).any? do |i|
129
- high_confidence_junk_at?(i) || vanity_segments.include?(@lexicon[i])
130
- end
124
+ # @return [Boolean] true when the route is shallow and names a soft vanity segment
125
+ def shallow_vanity_route?
126
+ shallow? && @lexicon.any? { |folded| SEGMENT_SETS.fetch(:vanity).include?(folded) }
131
127
  end
132
128
 
133
129
  # @return [Boolean] true when the leading segments are all utility chrome
@@ -139,11 +135,7 @@ module Html2rss
139
135
  def junk_path?
140
136
  return false if excluded_content_route?
141
137
 
142
- any_high_confidence_junk_segment? ||
143
- taxonomy_path? ||
144
- utility_only_route? ||
145
- deep_utility_context_route? ||
146
- shallow_high_confidence_route?
138
+ any_high_confidence_junk_segment? || shallow_vanity_route?
147
139
  end
148
140
 
149
141
  # @return [Boolean] true when the route points at conversion or account chrome
@@ -164,8 +156,7 @@ module Html2rss
164
156
  end
165
157
 
166
158
  def yearish_content_context?
167
- segments.any? { |segment| segment.match?(YEARISH_SEGMENT) } &&
168
- (strong_post_suffix? || trusted_post_context?(segments.size - 1))
159
+ segments.any? { |segment| segment.match?(YEARISH_SEGMENT) } && trusted_post_context?(segments.size - 1)
169
160
  end
170
161
 
171
162
  def excluded_content_route?
@@ -199,28 +190,15 @@ module Html2rss
199
190
  end
200
191
 
201
192
  def trusted_post_context?(limit)
202
- return false if limit <= 0
203
193
  return false if leading_high_confidence_junk?
204
194
 
205
- content_segments = SEGMENT_SETS.fetch(:content)
206
- context_segments = SEGMENT_SETS.fetch(:deep_post_context)
207
-
208
- (0...limit).any? do |i|
209
- content_segments.include?(@lexicon[i]) ||
210
- segments[i].match?(PathClassifier::YEARISH_SEGMENT) ||
211
- context_segments.include?(@lexicon[i])
195
+ content = SEGMENT_SETS.fetch(:content)
196
+ context = SEGMENT_SETS.fetch(:deep_post_context)
197
+ segments.zip(@lexicon).first(limit.clamp(0..)).any? do |segment, folded|
198
+ content.include?(folded) || segment.match?(YEARISH_SEGMENT) || context.include?(folded)
212
199
  end
213
200
  end
214
201
 
215
- def included_last_segment?
216
- !excluded_last_segment? && slug_last_segment?
217
- end
218
-
219
- def excluded_last_segment?
220
- last_i = segments.size - 1
221
- high_confidence_junk_at?(last_i) || SEGMENT_SETS[:vanity].include?(@lexicon[last_i])
222
- end
223
-
224
202
  def slug_last_segment?
225
203
  last = segments.last
226
204
  last.match?(YEARISH_SEGMENT) || last.match?(POST_SLUG_SEGMENT)
@@ -14,14 +14,15 @@ module Html2rss
14
14
  # @param yaml [String, nil] feed configuration YAML
15
15
  # @return [ConfigArgument]
16
16
  # @raise [ArgumentError] unless exactly one of +config+ or +yaml+ is present
17
+ # @raise [Html2rss::InvalidOption] when +config+ is not a Hash or +yaml+ is not a String
17
18
  # @raise [Contract::UnpublishedRequestError] when the parsed config uses
18
19
  # an unpublished MCP strategy or +request.local_file_path+
19
20
  def parse(config: nil, yaml: nil)
20
21
  parsed = case [present?(config), present?(yaml)]
21
22
  in [true, false]
22
- HashUtil.deep_symbolize_keys(config, context: 'config')
23
+ HashUtil.deep_symbolize_keys(require_type!(config, Hash, 'config'), context: 'config')
23
24
  in [false, true]
24
- Config.from_yaml(yaml)
25
+ Config.from_yaml(require_type!(yaml, String, 'yaml'))
25
26
  else
26
27
  raise ArgumentError, 'Provide exactly one of config or yaml'
27
28
  end
@@ -31,6 +32,12 @@ module Html2rss
31
32
 
32
33
  private
33
34
 
35
+ def require_type!(value, type, name)
36
+ return value if value.is_a?(type)
37
+
38
+ raise Html2rss::InvalidOption, "#{name} must be a #{type}, got #{value.class}"
39
+ end
40
+
34
41
  def present?(value)
35
42
  case value
36
43
  in nil then false
@@ -103,7 +103,7 @@ module Html2rss
103
103
  properties: {
104
104
  url: URL_PROPERTY,
105
105
  strategy: STRATEGY_PROPERTY,
106
- limit: { type: 'integer', description: 'Max articles to keep (default 25)', default: 25 },
106
+ limit: { type: 'integer', minimum: 1, description: 'Max articles to keep (default 25)', default: 25 },
107
107
  items_selector: { type: 'string', description: 'Optional CSS selector hint for items' }
108
108
  }.freeze,
109
109
  required: %w[url]
@@ -135,9 +135,9 @@ module Html2rss
135
135
  title: { type: 'string', description: 'Channel title override' },
136
136
  summary: { type: 'string', description: 'Directory summary override' },
137
137
  enhance: { type: 'boolean', description: 'Force enhance on or off' },
138
- limit: { type: 'integer', description: 'Max articles to keep' },
139
- max_redirects: { type: 'integer', description: 'Optional redirect limit override' },
140
- max_requests: { type: 'integer', description: 'Optional request budget override' }
138
+ limit: { type: 'integer', minimum: 1, description: 'Max articles to keep' },
139
+ max_redirects: { type: 'integer', minimum: 0, description: 'Optional redirect limit override' },
140
+ max_requests: { type: 'integer', minimum: 1, description: 'Optional request budget override' }
141
141
  }.freeze,
142
142
  required: %w[url]
143
143
  }.freeze
@@ -163,6 +163,8 @@ module Html2rss
163
163
  strategy: strategy_property,
164
164
  concurrency: {
165
165
  type: 'integer',
166
+ minimum: 1,
167
+ maximum: 10,
166
168
  description: 'Max parallel worker threads (1..10, default: 5)',
167
169
  default: 5
168
170
  },
@@ -177,7 +179,7 @@ module Html2rss
177
179
  urls_description: 'List of page URLs to scrape (1..25)',
178
180
  strategy_property: STRATEGY_PROPERTY,
179
181
  extra_properties: {
180
- limit: { type: 'integer', description: 'Max articles per URL to keep (default 10)', default: 10 }
182
+ limit: { type: 'integer', minimum: 1, description: 'Max articles per URL to keep (default 10)', default: 10 }
181
183
  }
182
184
  ).freeze
183
185
 
@@ -60,7 +60,7 @@ module Html2rss
60
60
  ##
61
61
  # @param report [PageRecon::Diagnostics::Report]
62
62
  # @return [Outcome]
63
- def inspect(report:)
63
+ def inspect_report(report:)
64
64
  next_step = inspect_next_step(report)
65
65
  guidance = Playbook.inspect_guidance(report)
66
66
  new(ok: true, next_step:, guidance:, payload: report.to_wire_h)
@@ -194,7 +194,7 @@ module Html2rss
194
194
  NextStep[:scrape]
195
195
  end
196
196
 
197
- def capture_next_step(articles_count:, has_selectors:, native_feed: nil)
197
+ def capture_next_step(articles_count:, has_selectors:, native_feed:)
198
198
  return NextStep[:done] if native_feed
199
199
 
200
200
  articles_count.positive? && has_selectors ? NextStep[:test] : NextStep[:inspect]
@@ -205,7 +205,6 @@ module Html2rss
205
205
 
206
206
  kind = test_result.failure_kind
207
207
  return NextStep[:validate] if kind&.schema?
208
- return NextStep[:capture] if kind&.execution? || kind&.min_items? || kind&.quality?
209
208
 
210
209
  NextStep[:capture]
211
210
  end
@@ -223,8 +222,8 @@ module Html2rss
223
222
  end
224
223
 
225
224
  def capture_payload(yaml:, articles_count:, has_selectors:, channel_title:, requested_strategy:, # rubocop:disable Metrics/ParameterLists
226
- segment_strategy:, selected_strategy:, admission_drops:, native_feed: nil,
227
- suggested_channel_url: nil)
225
+ segment_strategy:, selected_strategy:, admission_drops:, native_feed:,
226
+ suggested_channel_url:)
228
227
  {
229
228
  yaml:, articles_count:, has_selectors:, channel_title:, requested_strategy: requested_strategy.to_s,
230
229
  **(native_feed ? { native_feed: native_feed.to_s } : {}),