html2rss 0.26.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.
- checksums.yaml +4 -4
- data/README.md +41 -18
- data/html2rss.gemspec +1 -1
- data/lib/html2rss/auto_source/README.md +57 -0
- data/lib/html2rss/auto_source/cleanup.rb +121 -56
- data/lib/html2rss/auto_source/scraper.rb +13 -0
- data/lib/html2rss/auto_source.rb +34 -8
- data/lib/html2rss/capture/README.md +61 -0
- data/lib/html2rss/capture.rb +120 -117
- data/lib/html2rss/cli.rb +35 -17
- data/lib/html2rss/config/schema.rb +12 -0
- data/lib/html2rss/config/validator.rb +31 -8
- data/lib/html2rss/config.rb +28 -0
- data/lib/html2rss/error.rb +24 -6
- data/lib/html2rss/feed_pipeline/README.md +42 -0
- data/lib/html2rss/feed_pipeline/auto_fallback.rb +22 -9
- data/lib/html2rss/feed_pipeline/strategy_plan.rb +11 -0
- data/lib/html2rss/feed_pipeline.rb +30 -12
- data/lib/html2rss/html/article_extractor/category_extractor.rb +36 -22
- data/lib/html2rss/html/article_extractor/date_extractor.rb +5 -3
- data/lib/html2rss/html/article_extractor.rb +95 -17
- data/lib/html2rss/html/article_rules/category.rb +28 -11
- data/lib/html2rss/html/article_rules/date.rb +60 -6
- data/lib/html2rss/html/article_rules/description.rb +122 -0
- data/lib/html2rss/html/card_walk.rb +42 -0
- data/lib/html2rss/html/feed_link.rb +34 -0
- data/lib/html2rss/html/navigator.rb +18 -0
- data/lib/html2rss/html/sst_article_extractor.rb +119 -32
- data/lib/html2rss/link_destination/path_classifier.rb +49 -35
- data/lib/html2rss/mcp/config_argument.rb +42 -0
- data/lib/html2rss/mcp/contract.rb +173 -0
- data/lib/html2rss/mcp/inspect.rb +241 -0
- data/lib/html2rss/mcp/outcome.rb +188 -0
- data/lib/html2rss/mcp/server.rb +253 -409
- data/lib/html2rss/request_service/botasaurus_contract.rb +193 -102
- data/lib/html2rss/request_service/botasaurus_strategy.rb +15 -8
- data/lib/html2rss/request_service/compressed_body.rb +109 -0
- data/lib/html2rss/request_service/faraday_strategy.rb +3 -1
- data/lib/html2rss/request_service/policy.rb +1 -1
- data/lib/html2rss/request_service/response.rb +57 -6
- data/lib/html2rss/request_service.rb +6 -1
- data/lib/html2rss/selectors.rb +2 -1
- data/lib/html2rss/status.rb +27 -11
- data/lib/html2rss/url.rb +20 -0
- data/lib/html2rss/version.rb +1 -1
- data/lib/html2rss.rb +30 -6
- data/schema/html2rss-config.schema.json +26 -15
- metadata +15 -4
|
@@ -13,6 +13,7 @@ module Html2rss
|
|
|
13
13
|
].to_set.freeze
|
|
14
14
|
|
|
15
15
|
# Segment groups used to classify article, taxonomy, utility, and vanity routes.
|
|
16
|
+
# Utility = taxonomy ∪ chrome ∪ soft utility (do not re-list taxonomy tokens).
|
|
16
17
|
SEGMENT_SETS = begin
|
|
17
18
|
content = %w[
|
|
18
19
|
article articles blog blogs changelog changelogs insight insights
|
|
@@ -31,28 +32,23 @@ module Html2rss
|
|
|
31
32
|
join membership plus premium pricing plans subscribe signup
|
|
32
33
|
abonnieren abo suscribirse boletin s-abonner saboner
|
|
33
34
|
].to_set.freeze
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
dating jobs job career careers deals deal shopping shop trading broker
|
|
52
|
-
versicherung tierversicherung insurance vergleich comparison
|
|
53
|
-
partnerboerse singleboerse krypto crypto
|
|
54
|
-
] + SOFT_UTILITY.to_a
|
|
55
|
-
).to_set.freeze
|
|
35
|
+
chrome = %w[
|
|
36
|
+
about account archive archives author authors comment comments contact feedback
|
|
37
|
+
help login logout notification notifications preference preferences profile register
|
|
38
|
+
search settings share signup subscribe feed feeds comment-feed comments-feed privacy
|
|
39
|
+
terms cookie cookies user users autor autoren archiv ueber-uns ueber ueberuns profil
|
|
40
|
+
kontakt impressum suche hilfe anmelden registrieren konto registrierung anmeldung
|
|
41
|
+
abonnieren abo datenschutz nutzungsbedingungen agb autores archivos sobre-nosotros
|
|
42
|
+
sobre quienes-somos buscar busqueda ayuda entrar ingresar registrarse registro cuenta
|
|
43
|
+
suscribirse boletin privacidad condiciones auteur auteurs a-propos apropos recherche
|
|
44
|
+
rechercher aide connexion s-inscrire sinscrire inscription compte s-abonner saboner
|
|
45
|
+
lettre-information confidentialite mentions-legales cgu menu sidebar widget social
|
|
46
|
+
modal popup banner promo ad ads related recommendation recommendations pagination pager
|
|
47
|
+
dating jobs job career careers deals deal shopping shop trading broker versicherung
|
|
48
|
+
tierversicherung insurance vergleich comparison partnerboerse singleboerse krypto crypto
|
|
49
|
+
casinos casino kreditkarten kreditkarte kredit echtgeld vpn games kaufberater leasing
|
|
50
|
+
].to_set.freeze
|
|
51
|
+
utility = (taxonomy | chrome | SOFT_UTILITY).freeze
|
|
56
52
|
{
|
|
57
53
|
content:,
|
|
58
54
|
utility:,
|
|
@@ -66,6 +62,11 @@ module Html2rss
|
|
|
66
62
|
YEARISH_SEGMENT = /\A\d{4,}[\w-]*\z/
|
|
67
63
|
# Hyphenated slug shape common to article permalinks.
|
|
68
64
|
POST_SLUG_SEGMENT = /\A[a-z0-9]+(?:-[a-z0-9]+){2,}\z/i
|
|
65
|
+
# Multi-label host mistaken for a path segment (affiliate/outlink chrome).
|
|
66
|
+
# Final label must be alphabetic (TLD-like); exclude common file extensions.
|
|
67
|
+
HOST_SHAPED_SEGMENT = /\A(?:www\.)?(?:[\w-]+\.)+[a-z]{2,24}\z/i
|
|
68
|
+
# Common file-extension suffixes excluded from host-shaped junk matching.
|
|
69
|
+
FILE_EXTENSION_SEGMENT = /\.(?:pdf|jpe?g|png|gif|svg|webp|css|js|mjs|html?|xml|json|mp4|webm|zip|gz)\z/i
|
|
69
70
|
|
|
70
71
|
# @param segments [Array<String>] normalized URL path segments
|
|
71
72
|
def initialize(segments)
|
|
@@ -74,8 +75,8 @@ module Html2rss
|
|
|
74
75
|
|
|
75
76
|
# @return [Boolean] true when the route has article-like path evidence
|
|
76
77
|
def content_path?
|
|
77
|
-
@content_path ||=
|
|
78
|
-
yearish_content_context?
|
|
78
|
+
@content_path ||= !leading_high_confidence_junk? &&
|
|
79
|
+
(SEGMENT_SETS[:content].intersect?(segments) || yearish_content_context?)
|
|
79
80
|
end
|
|
80
81
|
|
|
81
82
|
# @return [Boolean] true when the route includes utility/navigation evidence
|
|
@@ -96,9 +97,8 @@ module Html2rss
|
|
|
96
97
|
# @return [Boolean] true when the route is too shallow to strongly indicate an article
|
|
97
98
|
def shallow?
|
|
98
99
|
segment_count = segments.size
|
|
99
|
-
junk_segments = SEGMENT_SETS.fetch(:high_confidence_junk)
|
|
100
100
|
|
|
101
|
-
segment_count <= 1 || (segment_count == 2 &&
|
|
101
|
+
segment_count <= 1 || (segment_count == 2 && high_confidence_junk_segment?(segments.last))
|
|
102
102
|
end
|
|
103
103
|
|
|
104
104
|
# @return [Boolean] true when the final path segment looks like a post slug
|
|
@@ -110,18 +110,15 @@ module Html2rss
|
|
|
110
110
|
|
|
111
111
|
# @return [Boolean] true when every path segment is utility chrome
|
|
112
112
|
def utility_only_route?
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
segments.all? { |segment| junk_segments.include?(segment) }
|
|
113
|
+
segments.all? { |segment| high_confidence_junk_segment?(segment) }
|
|
116
114
|
end
|
|
117
115
|
|
|
118
116
|
# @return [Boolean] true when the route is shallow and contains high-confidence noise
|
|
119
117
|
def shallow_high_confidence_route?
|
|
120
|
-
junk_segments = SEGMENT_SETS.fetch(:high_confidence_junk)
|
|
121
118
|
vanity_segments = SEGMENT_SETS.fetch(:vanity)
|
|
122
119
|
|
|
123
120
|
shallow? && segments.any? do |segment|
|
|
124
|
-
|
|
121
|
+
high_confidence_junk_segment?(segment) || vanity_segments.include?(segment)
|
|
125
122
|
end
|
|
126
123
|
end
|
|
127
124
|
|
|
@@ -134,7 +131,8 @@ module Html2rss
|
|
|
134
131
|
def junk_path?
|
|
135
132
|
return false if excluded_content_route?
|
|
136
133
|
|
|
137
|
-
|
|
134
|
+
any_high_confidence_junk_segment? ||
|
|
135
|
+
taxonomy_path? ||
|
|
138
136
|
utility_only_route? ||
|
|
139
137
|
deep_utility_context_route? ||
|
|
140
138
|
shallow_high_confidence_route?
|
|
@@ -149,6 +147,14 @@ module Html2rss
|
|
|
149
147
|
|
|
150
148
|
private
|
|
151
149
|
|
|
150
|
+
def leading_high_confidence_junk?
|
|
151
|
+
segments.any? && high_confidence_junk_segment?(segments.first)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def any_high_confidence_junk_segment?
|
|
155
|
+
segments.any? { |segment| high_confidence_junk_segment?(segment) }
|
|
156
|
+
end
|
|
157
|
+
|
|
152
158
|
def yearish_content_context?
|
|
153
159
|
segments.any? { |segment| segment.match?(YEARISH_SEGMENT) } &&
|
|
154
160
|
(strong_post_suffix? || trusted_post_context?(segments.size - 1))
|
|
@@ -172,12 +178,20 @@ module Html2rss
|
|
|
172
178
|
def all_junk?(limit)
|
|
173
179
|
return false if limit <= 0
|
|
174
180
|
|
|
175
|
-
|
|
176
|
-
|
|
181
|
+
(0...limit).all? { |i| high_confidence_junk_segment?(segments[i]) }
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def high_confidence_junk_segment?(segment)
|
|
185
|
+
SEGMENT_SETS.fetch(:high_confidence_junk).include?(segment) || host_shaped_segment?(segment)
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def host_shaped_segment?(segment)
|
|
189
|
+
segment.match?(HOST_SHAPED_SEGMENT) && !segment.match?(FILE_EXTENSION_SEGMENT)
|
|
177
190
|
end
|
|
178
191
|
|
|
179
192
|
def trusted_post_context?(limit)
|
|
180
193
|
return false if limit <= 0
|
|
194
|
+
return false if leading_high_confidence_junk?
|
|
181
195
|
|
|
182
196
|
content_segments = SEGMENT_SETS.fetch(:content)
|
|
183
197
|
context_segments = SEGMENT_SETS.fetch(:deep_post_context)
|
|
@@ -196,7 +210,7 @@ module Html2rss
|
|
|
196
210
|
|
|
197
211
|
def excluded_last_segment?
|
|
198
212
|
last = segments.last
|
|
199
|
-
|
|
213
|
+
high_confidence_junk_segment?(last) || SEGMENT_SETS[:vanity].include?(last)
|
|
200
214
|
end
|
|
201
215
|
|
|
202
216
|
def slug_last_segment?
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Html2rss
|
|
4
|
+
module MCP
|
|
5
|
+
# Feed config supplied over MCP as a hash XOR a YAML string.
|
|
6
|
+
#
|
|
7
|
+
# Config does not know MCP. YAML parsing stays on {Html2rss::Config.from_yaml}.
|
|
8
|
+
ConfigArgument = Data.define(:config) do
|
|
9
|
+
class << self
|
|
10
|
+
##
|
|
11
|
+
# @param config [Hash, nil] feed configuration hash
|
|
12
|
+
# @param yaml [String, nil] feed configuration YAML
|
|
13
|
+
# @return [ConfigArgument]
|
|
14
|
+
# @raise [ArgumentError] unless exactly one of +config+ or +yaml+ is present
|
|
15
|
+
# @raise [Contract::UnpublishedRequestError] when the parsed config uses
|
|
16
|
+
# an unpublished MCP strategy or +request.local_file_path+
|
|
17
|
+
def parse(config: nil, yaml: nil)
|
|
18
|
+
parsed = case [present?(config), present?(yaml)]
|
|
19
|
+
in [true, false]
|
|
20
|
+
HashUtil.deep_symbolize_keys(config, context: 'config')
|
|
21
|
+
in [false, true]
|
|
22
|
+
Config.from_yaml(yaml)
|
|
23
|
+
else
|
|
24
|
+
raise ArgumentError, 'Provide exactly one of config or yaml'
|
|
25
|
+
end
|
|
26
|
+
Contract.assert_published_request!(parsed)
|
|
27
|
+
new(config: parsed)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def present?(value)
|
|
33
|
+
case value
|
|
34
|
+
in nil then false
|
|
35
|
+
in String then !value.strip.empty?
|
|
36
|
+
else true
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Html2rss
|
|
4
|
+
module MCP
|
|
5
|
+
##
|
|
6
|
+
# Published MCP contract: strategy enum, input/output schemas, listing
|
|
7
|
+
# annotations, and the single compact JSON envelope response.
|
|
8
|
+
module Contract # rubocop:disable Metrics/ModuleLength -- published listing constants stay co-located
|
|
9
|
+
# Published MCP request strategies (excludes +local_file+).
|
|
10
|
+
STRATEGIES = %w[auto faraday botasaurus].freeze
|
|
11
|
+
|
|
12
|
+
# Raised when apply/validate config uses an unpublished MCP request adapter.
|
|
13
|
+
class UnpublishedRequestError < ArgumentError; end
|
|
14
|
+
|
|
15
|
+
# JSON Schema property for a source page URL.
|
|
16
|
+
URL_PROPERTY = {
|
|
17
|
+
type: 'string',
|
|
18
|
+
format: 'uri',
|
|
19
|
+
description: 'Source page URL'
|
|
20
|
+
}.freeze
|
|
21
|
+
|
|
22
|
+
# JSON Schema property for scrape/capture +strategy+.
|
|
23
|
+
STRATEGY_PROPERTY = {
|
|
24
|
+
type: 'string',
|
|
25
|
+
enum: STRATEGIES,
|
|
26
|
+
default: 'auto',
|
|
27
|
+
description: 'Request strategy (auto runs faraday → botasaurus fallback chain)'
|
|
28
|
+
}.freeze
|
|
29
|
+
|
|
30
|
+
# JSON Schema property for inspect +strategy+ (auto stays on Faraday).
|
|
31
|
+
INSPECT_STRATEGY_PROPERTY = STRATEGY_PROPERTY.merge(
|
|
32
|
+
description: 'Request strategy (auto uses Faraday for cheap diagnostics; pin botasaurus when needed)'
|
|
33
|
+
).freeze
|
|
34
|
+
|
|
35
|
+
# JSON Schema +oneOf+ requiring exactly one of +config+ or +yaml+.
|
|
36
|
+
XOR_ONE_OF = [
|
|
37
|
+
{ required: %w[config], not: { required: %w[yaml] } }.freeze,
|
|
38
|
+
{ required: %w[yaml], not: { required: %w[config] } }.freeze
|
|
39
|
+
].freeze
|
|
40
|
+
|
|
41
|
+
# JSON Schema properties for the config/yaml XOR pair.
|
|
42
|
+
CONFIG_XOR_PROPERTIES = {
|
|
43
|
+
config: {
|
|
44
|
+
type: 'object',
|
|
45
|
+
description: 'Feed configuration hash with channel and selectors (XOR yaml)'
|
|
46
|
+
}.freeze,
|
|
47
|
+
yaml: {
|
|
48
|
+
type: 'string',
|
|
49
|
+
pattern: '\\S',
|
|
50
|
+
description: 'Feed configuration YAML string (XOR config)'
|
|
51
|
+
}.freeze
|
|
52
|
+
}.freeze
|
|
53
|
+
|
|
54
|
+
# Input schema for +validate_config+ (config XOR yaml).
|
|
55
|
+
CONFIG_XOR_SCHEMA = {
|
|
56
|
+
type: 'object',
|
|
57
|
+
properties: CONFIG_XOR_PROPERTIES,
|
|
58
|
+
oneOf: XOR_ONE_OF
|
|
59
|
+
}.freeze
|
|
60
|
+
|
|
61
|
+
# Input schema for +apply_config+ (required URL plus config XOR yaml).
|
|
62
|
+
APPLY_INPUT_SCHEMA = {
|
|
63
|
+
type: 'object',
|
|
64
|
+
properties: { url: URL_PROPERTY, **CONFIG_XOR_PROPERTIES }.freeze,
|
|
65
|
+
required: %w[url],
|
|
66
|
+
oneOf: XOR_ONE_OF
|
|
67
|
+
}.freeze
|
|
68
|
+
|
|
69
|
+
# Input schema for +scrape_url+.
|
|
70
|
+
SCRAPE_INPUT_SCHEMA = {
|
|
71
|
+
type: 'object',
|
|
72
|
+
properties: {
|
|
73
|
+
url: URL_PROPERTY,
|
|
74
|
+
strategy: STRATEGY_PROPERTY,
|
|
75
|
+
limit: { type: 'integer', description: 'Max articles to keep (default 25)', default: 25 },
|
|
76
|
+
items_selector: { type: 'string', description: 'Optional CSS selector hint for items' }
|
|
77
|
+
}.freeze,
|
|
78
|
+
required: %w[url]
|
|
79
|
+
}.freeze
|
|
80
|
+
|
|
81
|
+
# Input schema for +inspect_url+.
|
|
82
|
+
INSPECT_INPUT_SCHEMA = {
|
|
83
|
+
type: 'object',
|
|
84
|
+
properties: { url: URL_PROPERTY, strategy: INSPECT_STRATEGY_PROPERTY }.freeze,
|
|
85
|
+
required: %w[url]
|
|
86
|
+
}.freeze
|
|
87
|
+
|
|
88
|
+
# Input schema for +capture_config+.
|
|
89
|
+
CAPTURE_INPUT_SCHEMA = {
|
|
90
|
+
type: 'object',
|
|
91
|
+
properties: {
|
|
92
|
+
url: URL_PROPERTY,
|
|
93
|
+
strategy: STRATEGY_PROPERTY,
|
|
94
|
+
items_selector: { type: 'string', description: 'Optional CSS selector hint for items' }
|
|
95
|
+
}.freeze,
|
|
96
|
+
required: %w[url]
|
|
97
|
+
}.freeze
|
|
98
|
+
|
|
99
|
+
# Tool annotations for open-world read-only tools.
|
|
100
|
+
ANNOTATIONS_OPEN_WORLD = {
|
|
101
|
+
read_only_hint: true,
|
|
102
|
+
destructive_hint: false,
|
|
103
|
+
idempotent_hint: true,
|
|
104
|
+
open_world_hint: true
|
|
105
|
+
}.freeze
|
|
106
|
+
|
|
107
|
+
# Tool annotations for +validate_config+ (closed world).
|
|
108
|
+
ANNOTATIONS_VALIDATE = ANNOTATIONS_OPEN_WORLD.merge(open_world_hint: false).freeze
|
|
109
|
+
|
|
110
|
+
# Human titles for +tools/list+.
|
|
111
|
+
TITLES = {
|
|
112
|
+
scrape_url: 'Scrape URL',
|
|
113
|
+
inspect_url: 'Inspect URL',
|
|
114
|
+
capture_config: 'Capture feed config',
|
|
115
|
+
validate_config: 'Validate feed config',
|
|
116
|
+
apply_config: 'Apply feed config'
|
|
117
|
+
}.freeze
|
|
118
|
+
|
|
119
|
+
class << self
|
|
120
|
+
##
|
|
121
|
+
# Envelope JSON Schema. Built lazily so Zeitwerk can load Contract before Outcome.
|
|
122
|
+
#
|
|
123
|
+
# @return [Hash]
|
|
124
|
+
def output_schema # rubocop:disable Metrics/MethodLength -- schema document is one hash
|
|
125
|
+
{
|
|
126
|
+
type: 'object',
|
|
127
|
+
additionalProperties: false,
|
|
128
|
+
required: %w[ok next_step guidance payload],
|
|
129
|
+
properties: {
|
|
130
|
+
ok: { type: 'boolean' },
|
|
131
|
+
next_step: { type: 'string', enum: Outcome::NextStep::NAMES.map(&:to_s) },
|
|
132
|
+
guidance: { type: 'string' },
|
|
133
|
+
payload: { type: 'object' }
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
##
|
|
139
|
+
# One envelope Hash, one compact JSON body, no +_meta+.
|
|
140
|
+
#
|
|
141
|
+
# @param outcome [Outcome]
|
|
142
|
+
# @return [::MCP::Tool::Response]
|
|
143
|
+
def response(outcome)
|
|
144
|
+
wire = outcome.to_h
|
|
145
|
+
::MCP::Tool::Response.new(
|
|
146
|
+
[{ type: 'text', text: JSON.generate(wire) }],
|
|
147
|
+
error: !outcome.ok,
|
|
148
|
+
structured_content: wire
|
|
149
|
+
)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
##
|
|
153
|
+
# Rejects unpublished MCP request adapters so apply/validate cannot
|
|
154
|
+
# {File.read} arbitrary paths. CLI and Config still allow +local_file+.
|
|
155
|
+
#
|
|
156
|
+
# @param config [Hash]
|
|
157
|
+
# @return [void]
|
|
158
|
+
# @raise [UnpublishedRequestError] when +strategy+ is outside {STRATEGIES}
|
|
159
|
+
# or +request.local_file_path+ is present
|
|
160
|
+
def assert_published_request!(config)
|
|
161
|
+
strategy = config[:strategy]
|
|
162
|
+
unless strategy.nil? || STRATEGIES.include?(strategy.to_s)
|
|
163
|
+
raise UnpublishedRequestError,
|
|
164
|
+
"MCP does not accept strategy #{strategy} (published: #{STRATEGIES.join(', ')})"
|
|
165
|
+
end
|
|
166
|
+
return unless config.dig(:request, :local_file_path)
|
|
167
|
+
|
|
168
|
+
raise UnpublishedRequestError, 'MCP does not accept request.local_file_path'
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Html2rss
|
|
4
|
+
module MCP
|
|
5
|
+
##
|
|
6
|
+
# Diagnostic inspect path (not Capture ownership). Fetches once for SST/scraper stats
|
|
7
|
+
# and recon facts (final URL, status, scheme downgrade, native feed hints).
|
|
8
|
+
module Inspect # rubocop:disable Metrics/ModuleLength -- diagnostic helpers stay co-located
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
##
|
|
12
|
+
# @param url [String]
|
|
13
|
+
# @param strategy [String, Symbol]
|
|
14
|
+
# @return [Hash]
|
|
15
|
+
def call(url:, strategy: :auto) # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
|
|
16
|
+
resolved = FeedPipeline::StrategyPlan.concrete_for_diagnostic(strategy)
|
|
17
|
+
response = fetch_response(url, resolved)
|
|
18
|
+
parsed = response.parsed_body
|
|
19
|
+
|
|
20
|
+
result = recon_fields(url, response, parsed).merge(
|
|
21
|
+
strategy: resolved,
|
|
22
|
+
content_type: response.content_type,
|
|
23
|
+
html_response: response.html_response?,
|
|
24
|
+
scraper_eligibility: scraper_info(parsed),
|
|
25
|
+
sst_stats: sst_stats_from(response)
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
if response.html_response?
|
|
29
|
+
sst = sst_document(response)
|
|
30
|
+
if sst
|
|
31
|
+
result[:sst] = {
|
|
32
|
+
node_count: sst.node_count,
|
|
33
|
+
degraded: sst.degraded,
|
|
34
|
+
segment_stats: segment_stats(sst, url)
|
|
35
|
+
}
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
blocked = Html2rss::RequestService::BlockedSurface.interstitial_signature_for(response.body)
|
|
40
|
+
result[:blocked_surface] = blocked[:key].to_s if blocked
|
|
41
|
+
result[:xhr_capture] = xhr_capture_info(response) if resolved == :botasaurus
|
|
42
|
+
merge_admission_diagnostics!(result, response)
|
|
43
|
+
|
|
44
|
+
result
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
##
|
|
48
|
+
# Surfaces Cleanup admission_drops without re-running full AutoSource discovery.
|
|
49
|
+
# Uses articles already extractable from a cheap AutoSource pass only when HTML.
|
|
50
|
+
#
|
|
51
|
+
# @param result [Hash]
|
|
52
|
+
# @param response [Html2rss::RequestService::Response]
|
|
53
|
+
# @return [void]
|
|
54
|
+
def merge_admission_diagnostics!(result, response)
|
|
55
|
+
return unless response.html_response?
|
|
56
|
+
|
|
57
|
+
source = AutoSource.new(response, AutoSource::DEFAULT_CONFIG.merge(limit: 10))
|
|
58
|
+
articles = source.articles
|
|
59
|
+
result[:articles_count] = articles.size
|
|
60
|
+
drops = source.admission_drops
|
|
61
|
+
result[:admission_drops] = drops if drops.any?
|
|
62
|
+
end
|
|
63
|
+
module_function :merge_admission_diagnostics!
|
|
64
|
+
|
|
65
|
+
##
|
|
66
|
+
# @param response [Html2rss::RequestService::Response]
|
|
67
|
+
# @return [Hash] redacted XHR capture diagnostics (no query strings)
|
|
68
|
+
def xhr_capture_info(response)
|
|
69
|
+
captured = response.captured_responses
|
|
70
|
+
{
|
|
71
|
+
count: captured.size,
|
|
72
|
+
sample_endpoints: captured.first(5).filter_map { |entry| redacted_endpoint(entry) },
|
|
73
|
+
candidate_articles: captured.any? { |entry| xhr_candidate_articles?(entry) }
|
|
74
|
+
}
|
|
75
|
+
end
|
|
76
|
+
module_function :xhr_capture_info
|
|
77
|
+
|
|
78
|
+
##
|
|
79
|
+
# @param entry [Hash] captured response hash
|
|
80
|
+
# @return [String, nil] scheme+host+path only
|
|
81
|
+
def redacted_endpoint(entry)
|
|
82
|
+
raw = entry['url'] || entry[:url]
|
|
83
|
+
return unless raw
|
|
84
|
+
|
|
85
|
+
uri = URI.parse(raw.to_s)
|
|
86
|
+
return unless uri.scheme && uri.host
|
|
87
|
+
|
|
88
|
+
"#{uri.scheme}://#{uri.host}#{uri.path}"
|
|
89
|
+
rescue URI::InvalidURIError
|
|
90
|
+
nil
|
|
91
|
+
end
|
|
92
|
+
module_function :redacted_endpoint
|
|
93
|
+
|
|
94
|
+
##
|
|
95
|
+
# @param entry [Hash] captured response hash
|
|
96
|
+
# @return [Boolean]
|
|
97
|
+
def xhr_candidate_articles?(entry)
|
|
98
|
+
body = entry['body'] || entry[:body]
|
|
99
|
+
return false unless body.is_a?(String)
|
|
100
|
+
|
|
101
|
+
document = JSON.parse(body, symbolize_names: true)
|
|
102
|
+
AutoSource::Scraper::JsonState::CandidateDetector.candidate_array?(document)
|
|
103
|
+
rescue JSON::ParserError
|
|
104
|
+
false
|
|
105
|
+
end
|
|
106
|
+
module_function :xhr_candidate_articles?
|
|
107
|
+
|
|
108
|
+
##
|
|
109
|
+
# @param url [String]
|
|
110
|
+
# @param strategy [Symbol]
|
|
111
|
+
# @return [Html2rss::RequestService::Response]
|
|
112
|
+
def fetch_response(url, strategy) # rubocop:disable Metrics/MethodLength -- session construction
|
|
113
|
+
raw_config = Config.auto_source_config(
|
|
114
|
+
url:,
|
|
115
|
+
request_controls: Config::RequestControls.from_shortcut(strategy:)
|
|
116
|
+
)
|
|
117
|
+
raw_config[:strategy] = strategy
|
|
118
|
+
config = Config.from_hash(raw_config)
|
|
119
|
+
resources = FeedPipeline::RuntimePolicy.resources_for(config)
|
|
120
|
+
session = RequestSession.build(
|
|
121
|
+
config:,
|
|
122
|
+
strategy: config.strategy,
|
|
123
|
+
budget: resources.budget,
|
|
124
|
+
policy: resources.policy
|
|
125
|
+
)
|
|
126
|
+
session.fetch_initial_response
|
|
127
|
+
end
|
|
128
|
+
module_function :fetch_response
|
|
129
|
+
|
|
130
|
+
##
|
|
131
|
+
# @param parsed [Object] parsed response body
|
|
132
|
+
# @return [Array<String>, Hash]
|
|
133
|
+
def scraper_info(parsed)
|
|
134
|
+
return { error: 'Response is not HTML' } unless parsed.is_a?(Nokogiri::HTML::Document)
|
|
135
|
+
|
|
136
|
+
begin
|
|
137
|
+
Html2rss::AutoSource::Scraper.from(parsed).map(&:name)
|
|
138
|
+
rescue Html2rss::AutoSource::Scraper::NoScraperFound => error
|
|
139
|
+
{ none_found: error.category.to_s }
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
module_function :scraper_info
|
|
143
|
+
|
|
144
|
+
##
|
|
145
|
+
# @param response [Html2rss::RequestService::Response]
|
|
146
|
+
# @return [Hash, nil]
|
|
147
|
+
def sst_stats_from(response)
|
|
148
|
+
return nil unless response.html_response?
|
|
149
|
+
|
|
150
|
+
doc = sst_document(response)
|
|
151
|
+
return nil unless doc
|
|
152
|
+
|
|
153
|
+
{ node_count: doc.node_count, degraded: doc.degraded }
|
|
154
|
+
rescue StandardError
|
|
155
|
+
nil
|
|
156
|
+
end
|
|
157
|
+
module_function :sst_stats_from
|
|
158
|
+
|
|
159
|
+
##
|
|
160
|
+
# @param response [Html2rss::RequestService::Response]
|
|
161
|
+
# @return [Html2rss::SST::Document, nil]
|
|
162
|
+
def sst_document(response)
|
|
163
|
+
Html2rss::SST::Normalizer.call(response.body)
|
|
164
|
+
rescue ArgumentError
|
|
165
|
+
nil
|
|
166
|
+
end
|
|
167
|
+
module_function :sst_document
|
|
168
|
+
|
|
169
|
+
##
|
|
170
|
+
# @param sst [Html2rss::SST::Document]
|
|
171
|
+
# @param url [String]
|
|
172
|
+
# @return [Hash]
|
|
173
|
+
def segment_stats(sst, url)
|
|
174
|
+
segments = discover_segments(sst, url)
|
|
175
|
+
return { found: 0 } if segments.empty?
|
|
176
|
+
|
|
177
|
+
{
|
|
178
|
+
found: segments.size,
|
|
179
|
+
strategies: segments.map(&:strategy).uniq,
|
|
180
|
+
sample_paths: segments.first(5).map { |s| s.root_node.tag_path }
|
|
181
|
+
}
|
|
182
|
+
end
|
|
183
|
+
module_function :segment_stats
|
|
184
|
+
|
|
185
|
+
##
|
|
186
|
+
# @param sst [Html2rss::SST::Document]
|
|
187
|
+
# @param url [String]
|
|
188
|
+
# @return [Array]
|
|
189
|
+
def discover_segments(sst, url)
|
|
190
|
+
link_resolver = Scoring::LinkResolver.new(url)
|
|
191
|
+
AutoSource::Segmenter.call(
|
|
192
|
+
sst,
|
|
193
|
+
base_url: url,
|
|
194
|
+
strategy: :list,
|
|
195
|
+
link_resolver:
|
|
196
|
+
)
|
|
197
|
+
rescue StandardError
|
|
198
|
+
[]
|
|
199
|
+
end
|
|
200
|
+
module_function :discover_segments
|
|
201
|
+
|
|
202
|
+
##
|
|
203
|
+
# @param requested_url [String]
|
|
204
|
+
# @param response [Html2rss::RequestService::Response]
|
|
205
|
+
# @param parsed [Object]
|
|
206
|
+
# @return [Hash]
|
|
207
|
+
def recon_fields(requested_url, response, parsed)
|
|
208
|
+
requested = Url.from_absolute(requested_url)
|
|
209
|
+
final = response.url
|
|
210
|
+
|
|
211
|
+
{
|
|
212
|
+
requested_url: requested.to_s,
|
|
213
|
+
final_url: final.to_s,
|
|
214
|
+
status: response.status,
|
|
215
|
+
scheme_downgrade: scheme_downgrade?(requested, final),
|
|
216
|
+
alternate_feeds: alternate_feeds_from(parsed)
|
|
217
|
+
}
|
|
218
|
+
end
|
|
219
|
+
module_function :recon_fields
|
|
220
|
+
|
|
221
|
+
##
|
|
222
|
+
# @param requested [Html2rss::Url]
|
|
223
|
+
# @param final [Html2rss::Url]
|
|
224
|
+
# @return [Boolean] true when the fetch downgraded https to http
|
|
225
|
+
def scheme_downgrade?(requested, final)
|
|
226
|
+
requested.scheme == 'https' && final.scheme == 'http'
|
|
227
|
+
end
|
|
228
|
+
module_function :scheme_downgrade?
|
|
229
|
+
|
|
230
|
+
##
|
|
231
|
+
# @param parsed [Object]
|
|
232
|
+
# @return [Array<Hash{Symbol => String}>]
|
|
233
|
+
def alternate_feeds_from(parsed)
|
|
234
|
+
return [] unless parsed.is_a?(Nokogiri::HTML::Document)
|
|
235
|
+
|
|
236
|
+
Html::FeedLink.from_document(parsed).map { |link| { href: link.href, mime_type: link.mime_type } }
|
|
237
|
+
end
|
|
238
|
+
module_function :alternate_feeds_from
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
end
|