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.
- checksums.yaml +4 -4
- data/lib/html2rss/article/enclosure.rb +2 -6
- data/lib/html2rss/auto_source/cleanup.rb +1 -3
- data/lib/html2rss/auto_source/scraper/html.rb +2 -8
- data/lib/html2rss/auto_source/scraper/json_document.rb +2 -3
- data/lib/html2rss/auto_source/scraper/json_state/article_normalizer.rb +128 -128
- data/lib/html2rss/auto_source/scraper/json_state/candidate_detector.rb +37 -37
- data/lib/html2rss/auto_source/scraper/json_state/document_scanner.rb +123 -123
- data/lib/html2rss/auto_source/scraper/json_state/value_finder.rb +34 -34
- data/lib/html2rss/auto_source/scraper/json_state.rb +2 -6
- data/lib/html2rss/auto_source/scraper/meta_oembed.rb +32 -2
- data/lib/html2rss/auto_source/scraper/microdata.rb +228 -245
- data/lib/html2rss/auto_source/scraper/microformats2.rb +9 -1
- data/lib/html2rss/auto_source/scraper/native_feed.rb +1 -1
- data/lib/html2rss/auto_source/scraper/schema/category_extractor.rb +7 -2
- data/lib/html2rss/auto_source/scraper/schema/item_list.rb +2 -2
- data/lib/html2rss/auto_source/scraper/schema/thing.rb +98 -21
- data/lib/html2rss/auto_source/scraper/schema.rb +49 -2
- data/lib/html2rss/auto_source/scraper/semantic_html.rb +12 -16
- data/lib/html2rss/auto_source/scraper/sitemap/parser.rb +1 -4
- data/lib/html2rss/auto_source/scraper/sitemap.rb +3 -11
- data/lib/html2rss/auto_source/scraper/wordpress_api/page_scope.rb +2 -2
- data/lib/html2rss/auto_source/scraper/wordpress_api/posts_endpoint.rb +1 -3
- data/lib/html2rss/auto_source/scraper/wordpress_api.rb +4 -4
- data/lib/html2rss/auto_source/scraper/xhr_articles.rb +1 -1
- data/lib/html2rss/auto_source/scraper.rb +0 -11
- data/lib/html2rss/auto_source/segmenter/cluster.rb +5 -11
- data/lib/html2rss/auto_source/segmenter/list.rb +25 -57
- data/lib/html2rss/auto_source/segmenter/semantic.rb +6 -13
- data/lib/html2rss/auto_source.rb +1 -1
- data/lib/html2rss/batch.rb +22 -32
- data/lib/html2rss/capture/README.md +1 -1
- data/lib/html2rss/capture.rb +7 -27
- data/lib/html2rss/cli/render.rb +97 -96
- data/lib/html2rss/cli/validate.rb +2 -4
- data/lib/html2rss/cli.rb +33 -27
- data/lib/html2rss/config/request_controls.rb +10 -2
- data/lib/html2rss/config/schema.rb +8 -10
- data/lib/html2rss/config/selectors_validator.rb +1 -0
- data/lib/html2rss/config/validator.rb +3 -3
- data/lib/html2rss/config.rb +1 -1
- data/lib/html2rss/defaults.rb +5 -16
- data/lib/html2rss/doctor/botasaurus.rb +11 -19
- data/lib/html2rss/error.rb +54 -1
- data/lib/html2rss/feed_builder/rss.rb +1 -1
- data/lib/html2rss/feed_pipeline/README.md +1 -1
- data/lib/html2rss/feed_pipeline/auto_fallback.rb +58 -55
- data/lib/html2rss/feed_resolution/candidate_generator.rb +3 -4
- data/lib/html2rss/feed_resolution/options.rb +3 -7
- data/lib/html2rss/feed_resolution/policy.rb +29 -33
- data/lib/html2rss/feed_resolution/probe.rb +3 -3
- data/lib/html2rss/feed_resolution/scorer.rb +29 -29
- data/lib/html2rss/feed_resolution.rb +5 -9
- data/lib/html2rss/feed_result.rb +4 -3
- data/lib/html2rss/hash_util.rb +91 -90
- data/lib/html2rss/html/article_extractor/category_extractor.rb +6 -9
- data/lib/html2rss/html/article_extractor/date_extractor.rb +1 -1
- data/lib/html2rss/html/article_extractor/enclosure_extractor.rb +1 -3
- data/lib/html2rss/html/article_extractor/id_generator.rb +1 -1
- data/lib/html2rss/html/probe.rb +51 -51
- data/lib/html2rss/link_destination/path_classifier.rb +11 -33
- data/lib/html2rss/mcp/config_argument.rb +9 -2
- data/lib/html2rss/mcp/contract.rb +7 -5
- data/lib/html2rss/mcp/outcome.rb +4 -5
- data/lib/html2rss/mcp/runtime.rb +17 -24
- data/lib/html2rss/mcp/server/tools.rb +9 -9
- data/lib/html2rss/option.rb +48 -0
- data/lib/html2rss/page_recon/diagnostics.rb +7 -9
- data/lib/html2rss/page_recon.rb +0 -4
- data/lib/html2rss/recon.rb +10 -12
- data/lib/html2rss/request_service/blocked_surface.rb +3 -7
- data/lib/html2rss/request_service/botasaurus_strategy.rb +2 -2
- data/lib/html2rss/request_service/budget.rb +3 -24
- data/lib/html2rss/request_service/compressed_body.rb +36 -48
- data/lib/html2rss/request_service/context.rb +1 -3
- data/lib/html2rss/request_service/local_file_strategy.rb +3 -1
- data/lib/html2rss/request_service/network_guard.rb +3 -2
- data/lib/html2rss/request_service/policy.rb +8 -26
- data/lib/html2rss/request_service/response.rb +3 -6
- data/lib/html2rss/request_service/strategy.rb +2 -2
- data/lib/html2rss/request_service.rb +26 -2
- data/lib/html2rss/request_session/pager/base.rb +3 -3
- data/lib/html2rss/request_session/pager/custom_selector.rb +4 -4
- data/lib/html2rss/request_session/pager/offset.rb +1 -7
- data/lib/html2rss/request_session/pager/rel_next.rb +1 -1
- data/lib/html2rss/request_session/pager/url_template.rb +1 -7
- data/lib/html2rss/scoring/engine.rb +4 -32
- data/lib/html2rss/scoring/link_resolver.rb +2 -7
- data/lib/html2rss/selectors/extractors/href.rb +2 -4
- data/lib/html2rss/selectors/json_xml.rb +33 -34
- data/lib/html2rss/selectors/post_processors/gsub.rb +2 -3
- data/lib/html2rss/selectors/post_processors/parse_time.rb +4 -5
- data/lib/html2rss/selectors/schema_export.rb +60 -60
- data/lib/html2rss/selectors.rb +14 -18
- data/lib/html2rss/sst/node.rb +9 -3
- data/lib/html2rss/sst/normalizer.rb +25 -24
- data/lib/html2rss/sst/text.rb +53 -57
- data/lib/html2rss/status.rb +23 -30
- data/lib/html2rss/syndication/alternate_links.rb +39 -0
- data/lib/html2rss/syndication/discovery.rb +117 -187
- data/lib/html2rss/syndication/parser.rb +92 -115
- data/lib/html2rss/test/enhance_audit.rb +13 -15
- data/lib/html2rss/test/policy.rb +9 -11
- data/lib/html2rss/test.rb +29 -48
- data/lib/html2rss/url.rb +53 -53
- data/lib/html2rss/version.rb +1 -1
- data/lib/html2rss.rb +2 -1
- metadata +4 -2
data/lib/html2rss/hash_util.rb
CHANGED
|
@@ -3,116 +3,117 @@
|
|
|
3
3
|
module Html2rss
|
|
4
4
|
# Shared helpers for hash normalization and structural operations.
|
|
5
5
|
module HashUtil
|
|
6
|
-
|
|
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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
52
|
+
object
|
|
35
53
|
end
|
|
36
54
|
end
|
|
37
|
-
end
|
|
38
55
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
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
|
-
|
|
89
|
-
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
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
|
-
|
|
106
|
-
end
|
|
107
|
+
private
|
|
107
108
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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: []
|
|
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 '
|
|
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
|
|
35
|
+
candidates += [url&.path] if selected_anchor
|
|
36
36
|
candidates.compact.reject(&:empty?).first
|
|
37
37
|
end
|
|
38
38
|
|
data/lib/html2rss/html/probe.rb
CHANGED
|
@@ -16,66 +16,66 @@ module Html2rss
|
|
|
16
16
|
# @see #mime_match?
|
|
17
17
|
APPLICATION_ATOM_XML = 'application/atom+xml'
|
|
18
18
|
|
|
19
|
-
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
37
|
-
|
|
35
|
+
fold(name)
|
|
36
|
+
end
|
|
38
37
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
47
|
-
|
|
45
|
+
fold(raw.split(';', 2).first.to_s.strip)
|
|
46
|
+
end
|
|
48
47
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
|
|
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?(
|
|
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
|
-
|
|
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
|
|
125
|
-
def
|
|
126
|
-
|
|
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
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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
|
|
data/lib/html2rss/mcp/outcome.rb
CHANGED
|
@@ -60,7 +60,7 @@ module Html2rss
|
|
|
60
60
|
##
|
|
61
61
|
# @param report [PageRecon::Diagnostics::Report]
|
|
62
62
|
# @return [Outcome]
|
|
63
|
-
def
|
|
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:
|
|
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
|
|
227
|
-
suggested_channel_url:
|
|
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 } : {}),
|