html2rss 0.25.0 → 0.27.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +99 -2
- data/html2rss.gemspec +4 -1
- data/lib/html2rss/auto_source/README.md +57 -0
- data/lib/html2rss/auto_source/cleanup.rb +140 -49
- data/lib/html2rss/auto_source/scraper/json_state.rb +31 -22
- data/lib/html2rss/auto_source/scraper/schema/item_list.rb +2 -14
- data/lib/html2rss/auto_source/scraper/xhr_articles.rb +69 -0
- data/lib/html2rss/auto_source/scraper.rb +47 -15
- data/lib/html2rss/auto_source/segmenter/list.rb +3 -1
- data/lib/html2rss/auto_source/segmenter/primary_link.rb +6 -7
- data/lib/html2rss/auto_source/segmenter.rb +30 -1
- data/lib/html2rss/auto_source.rb +41 -10
- data/lib/html2rss/capture/README.md +61 -0
- data/lib/html2rss/capture.rb +318 -0
- data/lib/html2rss/cli.rb +94 -17
- data/lib/html2rss/config/auto_source_contract.rb +3 -1
- data/lib/html2rss/config/schema.rb +79 -26
- data/lib/html2rss/config/selectors_validator.rb +60 -21
- data/lib/html2rss/config/validator.rb +46 -32
- data/lib/html2rss/config.rb +28 -0
- data/lib/html2rss/error.rb +31 -1
- data/lib/html2rss/feed_pipeline/README.md +42 -0
- data/lib/html2rss/feed_pipeline/auto_fallback.rb +38 -15
- data/lib/html2rss/feed_pipeline/runtime_policy.rb +3 -43
- data/lib/html2rss/feed_pipeline/strategy_plan.rb +11 -0
- data/lib/html2rss/feed_pipeline.rb +40 -15
- data/lib/html2rss/hash_util.rb +17 -0
- 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 +147 -31
- data/lib/html2rss/link_destination/noise_policy.rb +7 -18
- data/lib/html2rss/link_destination/path_classifier.rb +49 -32
- 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 +421 -0
- data/lib/html2rss/mcp.rb +21 -0
- data/lib/html2rss/request_service/blocked_surface.rb +24 -1
- data/lib/html2rss/request_service/botasaurus_contract.rb +261 -90
- data/lib/html2rss/request_service/botasaurus_strategy.rb +18 -9
- data/lib/html2rss/request_service/budget.rb +7 -35
- data/lib/html2rss/request_service/compressed_body.rb +109 -0
- data/lib/html2rss/request_service/context.rb +0 -6
- data/lib/html2rss/request_service/faraday_strategy.rb +48 -3
- data/lib/html2rss/request_service/policy.rb +2 -2
- data/lib/html2rss/request_service/response.rb +72 -7
- data/lib/html2rss/request_service/strategy.rb +1 -2
- data/lib/html2rss/request_service.rb +10 -10
- data/lib/html2rss/scoring/container_assessor.rb +9 -15
- data/lib/html2rss/scoring/engine.rb +49 -5
- data/lib/html2rss/scoring/link_resolver.rb +12 -0
- data/lib/html2rss/scoring/ranked_segment.rb +0 -12
- data/lib/html2rss/scoring/score.rb +1 -20
- data/lib/html2rss/scoring.rb +0 -25
- data/lib/html2rss/selectors/extractors/attribute.rb +15 -0
- data/lib/html2rss/selectors/extractors/href.rb +12 -0
- data/lib/html2rss/selectors/extractors/html.rb +12 -0
- data/lib/html2rss/selectors/extractors/static.rb +14 -0
- data/lib/html2rss/selectors/extractors/text.rb +11 -0
- data/lib/html2rss/selectors/post_processors/gsub.rb +18 -0
- data/lib/html2rss/selectors/post_processors/html_to_markdown.rb +11 -0
- data/lib/html2rss/selectors/post_processors/markdown_to_html.rb +12 -0
- data/lib/html2rss/selectors/post_processors/parse_time.rb +11 -0
- data/lib/html2rss/selectors/post_processors/parse_uri.rb +11 -0
- data/lib/html2rss/selectors/post_processors/sanitize_html.rb +13 -0
- data/lib/html2rss/selectors/post_processors/substring.rb +21 -0
- data/lib/html2rss/selectors/post_processors/template.rb +20 -0
- data/lib/html2rss/selectors/schema_doc.rb +99 -0
- data/lib/html2rss/selectors.rb +2 -1
- data/lib/html2rss/status.rb +27 -11
- data/lib/html2rss/url.rb +31 -7
- data/lib/html2rss/version.rb +1 -1
- data/lib/html2rss.rb +62 -7
- data/schema/html2rss-config.schema.json +428 -94
- metadata +65 -12
- data/lib/html2rss/request_service/browserless_strategy.rb +0 -132
- data/lib/html2rss/request_service/puppet_commander/navigation_guards.rb +0 -148
- data/lib/html2rss/request_service/puppet_commander/preload_runner.rb +0 -86
- data/lib/html2rss/request_service/puppet_commander.rb +0 -95
- data/lib/html2rss/scoring/anchor_score.rb +0 -34
|
@@ -11,18 +11,20 @@ module Html2rss
|
|
|
11
11
|
# Detection is intentionally shallow for most scrapers, but instance-based
|
|
12
12
|
# matching is available for scrapers that need to carry expensive selection
|
|
13
13
|
# state forward into extraction.
|
|
14
|
-
module Scraper
|
|
14
|
+
module Scraper # rubocop:disable Metrics/ModuleLength -- tier registry + construction helpers
|
|
15
15
|
# Root markers indicating likely app-shell/client-rendered surfaces.
|
|
16
16
|
APP_SHELL_ROOT_SELECTORS = '#app, #root, #__next, [data-reactroot], [ng-app], [id*="app-shell"]'
|
|
17
17
|
# Maximum anchors tolerated before app-shell detection is considered unlikely.
|
|
18
18
|
APP_SHELL_MAX_ANCHORS = 2
|
|
19
|
+
# Minimum same-page anchors suggesting a high-entropy homepage/hub surface.
|
|
20
|
+
HIGH_ENTROPY_MIN_ANCHORS = 20
|
|
19
21
|
# Maximum visible text length tolerated for app-shell classification.
|
|
20
22
|
APP_SHELL_MAX_VISIBLE_TEXT_LENGTH = 220
|
|
21
23
|
|
|
22
24
|
# Extraction tiers: merge within a tier, then stop when AutoSource has enough articles.
|
|
23
25
|
# Heuristic scrapers are separate tiers so SemanticHtml can satisfy before Html runs.
|
|
24
26
|
SCRAPER_TIERS = [
|
|
25
|
-
[Schema, Microdata, Microformats2, JsonState].freeze,
|
|
27
|
+
[Schema, Microdata, Microformats2, JsonState, XhrArticles].freeze,
|
|
26
28
|
[WordpressApi, Sitemap, MetaOembed].freeze,
|
|
27
29
|
[SemanticHtml].freeze,
|
|
28
30
|
[Html].freeze
|
|
@@ -35,19 +37,24 @@ module Html2rss
|
|
|
35
37
|
HEURISTIC_SCRAPERS = [SemanticHtml, Html].freeze
|
|
36
38
|
# Scrapers that accept a shared follow-up +request_session+.
|
|
37
39
|
REQUEST_SESSION_SCRAPERS = [WordpressApi, Sitemap, MetaOembed].freeze
|
|
40
|
+
# Scrapers that consume browser-captured XHR/fetch JSON bodies.
|
|
41
|
+
CAPTURED_RESPONSE_SCRAPERS = [XhrArticles].freeze
|
|
38
42
|
|
|
39
43
|
##
|
|
40
44
|
# Error raised when no suitable scraper is found.
|
|
41
45
|
class NoScraperFound < Html2rss::Error
|
|
42
|
-
#
|
|
46
|
+
# Surface diagnostics shared with {Html2rss::NoFeedItemsExtracted} (one string home).
|
|
43
47
|
CATEGORY_MESSAGES = {
|
|
44
|
-
blocked_surface: '
|
|
45
|
-
'
|
|
48
|
+
blocked_surface: 'blocked surface likely (anti-bot or interstitial). ' \
|
|
49
|
+
'Target a direct listing URL, configure BOTASAURUS_SCRAPER_URL, ' \
|
|
46
50
|
'or run from an environment that can complete anti-bot checks.',
|
|
47
|
-
app_shell: '
|
|
48
|
-
'server-rendered article HTML).
|
|
51
|
+
app_shell: 'app-shell surface detected (client-rendered page with little or no ' \
|
|
52
|
+
'server-rendered article HTML). Configure BOTASAURUS_SCRAPER_URL or target a direct ' \
|
|
49
53
|
'listing/update URL instead of a homepage or shell entrypoint.',
|
|
50
|
-
|
|
54
|
+
high_entropy_surface: 'high-entropy surface (e.g. news homepage) with few or no durable ' \
|
|
55
|
+
'article destinations after admission. Target a section, topic, or ' \
|
|
56
|
+
'listing/update URL instead of the site homepage.',
|
|
57
|
+
unsupported_surface: 'unsupported extraction surface for auto mode. ' \
|
|
51
58
|
'Try a direct listing/changelog/category URL, ' \
|
|
52
59
|
'or use explicit selectors in a feed config.'
|
|
53
60
|
}.freeze
|
|
@@ -57,7 +64,7 @@ module Html2rss
|
|
|
57
64
|
def initialize(message = nil, category: :unsupported_surface)
|
|
58
65
|
validate_category!(category)
|
|
59
66
|
@category = category
|
|
60
|
-
super(message || CATEGORY_MESSAGES.fetch(@category))
|
|
67
|
+
super(message || "No scrapers found: #{CATEGORY_MESSAGES.fetch(@category)}")
|
|
61
68
|
end
|
|
62
69
|
|
|
63
70
|
attr_reader :category
|
|
@@ -81,6 +88,7 @@ module Html2rss
|
|
|
81
88
|
# @option opts [Hash] :microdata scraper toggle and configuration
|
|
82
89
|
# @option opts [Hash] :microformats2 scraper toggle and configuration
|
|
83
90
|
# @option opts [Hash] :json_state scraper toggle and configuration
|
|
91
|
+
# @option opts [Hash] :xhr_articles scraper toggle and configuration
|
|
84
92
|
# @option opts [Hash] :meta_oembed scraper toggle and configuration
|
|
85
93
|
# @option opts [Hash] :semantic_html scraper toggle and configuration
|
|
86
94
|
# @option opts [Hash] :html scraper toggle and configuration
|
|
@@ -122,27 +130,36 @@ module Html2rss
|
|
|
122
130
|
# @param body [String, nil]
|
|
123
131
|
# @param document [SST::Document, nil]
|
|
124
132
|
# @param link_resolver [Scoring::LinkResolver, nil]
|
|
133
|
+
# @param captured_responses [Array<Hash>] browser-captured JSON XHR/fetch bodies
|
|
125
134
|
# @option opts [Hash] :wordpress_api scraper toggle and configuration
|
|
126
135
|
# @option opts [Hash] :schema scraper toggle and configuration
|
|
127
136
|
# @option opts [Hash] :microdata scraper toggle and configuration
|
|
128
137
|
# @option opts [Hash] :microformats2 scraper toggle and configuration
|
|
129
138
|
# @option opts [Hash] :json_state scraper toggle and configuration
|
|
139
|
+
# @option opts [Hash] :xhr_articles scraper toggle and configuration
|
|
130
140
|
# @option opts [Hash] :meta_oembed scraper toggle and configuration
|
|
131
141
|
# @option opts [Hash] :semantic_html scraper toggle and configuration
|
|
132
142
|
# @option opts [Hash] :html scraper toggle and configuration
|
|
133
143
|
# @option opts [Hash] :sitemap scraper toggle and configuration
|
|
134
144
|
# @return [Object, nil]
|
|
135
|
-
# rubocop:disable Metrics/ParameterLists -- construction context for structured and heuristic scrapers
|
|
145
|
+
# rubocop:disable Metrics/ParameterLists, Metrics/MethodLength -- construction context for structured and heuristic scrapers
|
|
136
146
|
def self.build_instance(scraper, parsed_body, opts:, url:, request_session: nil, body: nil, document: nil,
|
|
137
|
-
link_resolver: nil)
|
|
147
|
+
link_resolver: nil, captured_responses: [])
|
|
138
148
|
return unless opts.dig(scraper.options_key, :enabled)
|
|
139
149
|
return if HEURISTIC_SCRAPERS.include?(scraper) && document.nil?
|
|
140
150
|
|
|
141
151
|
scraper_opts = opts.fetch(scraper.options_key, {}).except(:enabled)
|
|
142
|
-
kwargs = construction_kwargs(
|
|
152
|
+
kwargs = construction_kwargs(
|
|
153
|
+
scraper,
|
|
154
|
+
request_session:,
|
|
155
|
+
body:,
|
|
156
|
+
document:,
|
|
157
|
+
link_resolver:,
|
|
158
|
+
captured_responses:
|
|
159
|
+
)
|
|
143
160
|
scraper.new(parsed_body, url:, **kwargs, **scraper_opts)
|
|
144
161
|
end
|
|
145
|
-
# rubocop:enable Metrics/ParameterLists
|
|
162
|
+
# rubocop:enable Metrics/ParameterLists, Metrics/MethodLength
|
|
146
163
|
|
|
147
164
|
##
|
|
148
165
|
# @param instance [Object]
|
|
@@ -162,25 +179,33 @@ module Html2rss
|
|
|
162
179
|
NoScraperFound.new(category: classify_no_scraper_surface(parsed_body, body:))
|
|
163
180
|
end
|
|
164
181
|
|
|
165
|
-
def self.construction_kwargs(scraper, request_session:, body:, document:, link_resolver:
|
|
182
|
+
def self.construction_kwargs(scraper, request_session:, body:, document:, link_resolver:, # rubocop:disable Metrics/ParameterLists -- scraper construction bag
|
|
183
|
+
captured_responses:)
|
|
166
184
|
if HEURISTIC_SCRAPERS.include?(scraper)
|
|
167
185
|
{ document:, link_resolver: }.compact
|
|
168
186
|
else
|
|
169
187
|
{}.tap do |kwargs|
|
|
170
188
|
kwargs[:request_session] = request_session if REQUEST_SESSION_SCRAPERS.include?(scraper)
|
|
171
189
|
kwargs[:body] = body if scraper == Sitemap
|
|
190
|
+
kwargs[:captured_responses] = captured_responses if CAPTURED_RESPONSE_SCRAPERS.include?(scraper)
|
|
172
191
|
end
|
|
173
192
|
end
|
|
174
193
|
end
|
|
175
194
|
private_class_method :construction_kwargs
|
|
176
195
|
|
|
196
|
+
##
|
|
197
|
+
# Classifies why scrapers could not extract from a parsed page.
|
|
198
|
+
#
|
|
199
|
+
# @param parsed_body [Nokogiri::HTML::Document]
|
|
200
|
+
# @param body [String, nil] raw body for blocked-surface detection
|
|
201
|
+
# @return [Symbol] one of {NoScraperFound::CATEGORY_MESSAGES} keys
|
|
177
202
|
def self.classify_no_scraper_surface(parsed_body, body: nil)
|
|
178
203
|
return :blocked_surface if blocked_surface?(parsed_body, body:)
|
|
179
204
|
return :app_shell if app_shell_surface?(parsed_body)
|
|
205
|
+
return :high_entropy_surface if high_entropy_surface?(parsed_body)
|
|
180
206
|
|
|
181
207
|
:unsupported_surface
|
|
182
208
|
end
|
|
183
|
-
private_class_method :classify_no_scraper_surface
|
|
184
209
|
|
|
185
210
|
def self.blocked_surface?(parsed_body, body: nil)
|
|
186
211
|
Html2rss::RequestService::BlockedSurface.interstitial?(body || parsed_body.to_html)
|
|
@@ -197,6 +222,13 @@ module Html2rss
|
|
|
197
222
|
end
|
|
198
223
|
private_class_method :app_shell_surface?
|
|
199
224
|
|
|
225
|
+
def self.high_entropy_surface?(parsed_body)
|
|
226
|
+
return false if parsed_body.nil?
|
|
227
|
+
|
|
228
|
+
parsed_body.css('body a[href]').size >= HIGH_ENTROPY_MIN_ANCHORS
|
|
229
|
+
end
|
|
230
|
+
private_class_method :high_entropy_surface?
|
|
231
|
+
|
|
200
232
|
def self.sparse_anchor_surface?(parsed_body)
|
|
201
233
|
parsed_body.css('body a[href]').size <= APP_SHELL_MAX_ANCHORS
|
|
202
234
|
end
|
|
@@ -70,7 +70,9 @@ module Html2rss
|
|
|
70
70
|
return false unless facts
|
|
71
71
|
|
|
72
72
|
text = node.visible_text.to_s.strip
|
|
73
|
-
!segmenter.noise_policy.noise_anchor?(
|
|
73
|
+
!segmenter.noise_policy.noise_anchor?(
|
|
74
|
+
text:, destination_facts: facts, anchor: node, utility_landmark_ancestor: false
|
|
75
|
+
)
|
|
74
76
|
end
|
|
75
77
|
module_function :relevant_anchor?
|
|
76
78
|
private_class_method :relevant_anchor?
|
|
@@ -51,16 +51,15 @@ module Html2rss
|
|
|
51
51
|
heading_match = meaningful && heading_text.match?(/\p{Alnum}/) && heading_text == text
|
|
52
52
|
|
|
53
53
|
return if @noise_policy.noise_anchor?(
|
|
54
|
-
text:, destination_facts: destination, anchor:, container:, heading_anchor
|
|
54
|
+
text:, destination_facts: destination, anchor:, container:, heading_anchor:,
|
|
55
|
+
utility_landmark_ancestor: @segmenter.landmark_ancestor?(anchor, container)
|
|
55
56
|
)
|
|
56
57
|
return unless meaningful || content_like || heading_anchor
|
|
57
58
|
|
|
58
|
-
score =
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
content_like_destination: content_like
|
|
63
|
-
)
|
|
59
|
+
score = (heading_anchor ? 100 : 0) +
|
|
60
|
+
(heading_match ? 20 : 0) +
|
|
61
|
+
(meaningful ? 10 : 0) +
|
|
62
|
+
(content_like ? 10 : 0)
|
|
64
63
|
|
|
65
64
|
{ anchor:, destination: destination.destination, score: }
|
|
66
65
|
end
|
|
@@ -44,7 +44,7 @@ module Html2rss
|
|
|
44
44
|
@minimum_selector_frequency = opts.fetch(:minimum_selector_frequency, 2)
|
|
45
45
|
@use_top_selectors = opts.fetch(:use_top_selectors, 5)
|
|
46
46
|
@link_resolver = opts[:link_resolver] || Scoring::LinkResolver.new(base_url)
|
|
47
|
-
@noise_policy = LinkDestination::NoisePolicy.new(link_resolver: @link_resolver
|
|
47
|
+
@noise_policy = LinkDestination::NoisePolicy.new(link_resolver: @link_resolver)
|
|
48
48
|
end
|
|
49
49
|
|
|
50
50
|
# @return [Array<Segment>]
|
|
@@ -61,6 +61,35 @@ module Html2rss
|
|
|
61
61
|
|
|
62
62
|
# @return [SST::Index]
|
|
63
63
|
def index = document.index
|
|
64
|
+
|
|
65
|
+
##
|
|
66
|
+
# Whether +anchor+ has a utility-landmark ancestor outside +container+.
|
|
67
|
+
#
|
|
68
|
+
# @param anchor [SST::Node]
|
|
69
|
+
# @param container [SST::Node]
|
|
70
|
+
# @return [Boolean]
|
|
71
|
+
def landmark_ancestor?(anchor, container)
|
|
72
|
+
cache = (@landmark_cache ||= {})
|
|
73
|
+
key = [anchor.object_id, container.object_id]
|
|
74
|
+
return cache[key] if cache.key?(key)
|
|
75
|
+
|
|
76
|
+
cache[key] = compute_landmark_ancestor?(anchor:, container:)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
private
|
|
80
|
+
|
|
81
|
+
def compute_landmark_ancestor?(anchor:, container:) # rubocop:disable Metrics/CyclomaticComplexity
|
|
82
|
+
return false unless anchor && container
|
|
83
|
+
|
|
84
|
+
curr = index.parent_of(anchor)
|
|
85
|
+
while curr && curr.name != :html
|
|
86
|
+
return true if curr != container && curr.utility_landmark?
|
|
87
|
+
return false if curr.equal?(container)
|
|
88
|
+
|
|
89
|
+
curr = index.parent_of(curr)
|
|
90
|
+
end
|
|
91
|
+
false
|
|
92
|
+
end
|
|
64
93
|
end
|
|
65
94
|
end
|
|
66
95
|
end
|
data/lib/html2rss/auto_source.rb
CHANGED
|
@@ -15,8 +15,8 @@ module Html2rss
|
|
|
15
15
|
# @see Html2rss::AutoSource::Scraper::Schema
|
|
16
16
|
# @see Html2rss::AutoSource::Scraper::SemanticHtml
|
|
17
17
|
# @see Html2rss::AutoSource::Scraper::Html
|
|
18
|
-
#
|
|
19
|
-
class AutoSource
|
|
18
|
+
# {include:file:lib/html2rss/auto_source/README.md}
|
|
19
|
+
class AutoSource # rubocop:disable Metrics/ClassLength -- defaults + tiered extract stay on the contributor entry type
|
|
20
20
|
# Default max articles to keep (also the short-circuit floor across scraper tiers).
|
|
21
21
|
DEFAULT_LIMIT = 25
|
|
22
22
|
|
|
@@ -44,6 +44,9 @@ module Html2rss
|
|
|
44
44
|
json_state: {
|
|
45
45
|
enabled: true
|
|
46
46
|
},
|
|
47
|
+
xhr_articles: {
|
|
48
|
+
enabled: true
|
|
49
|
+
},
|
|
47
50
|
meta_oembed: {
|
|
48
51
|
enabled: true
|
|
49
52
|
},
|
|
@@ -93,6 +96,7 @@ module Html2rss
|
|
|
93
96
|
@parsed_body = response.parsed_body
|
|
94
97
|
@body = response.body
|
|
95
98
|
@url = response.url
|
|
99
|
+
@captured_responses = response.captured_responses
|
|
96
100
|
@opts = opts
|
|
97
101
|
@request_session = request_session
|
|
98
102
|
end
|
|
@@ -103,18 +107,29 @@ module Html2rss
|
|
|
103
107
|
# Tiers: in-page structured → follow-up IO → SemanticHtml → Html.
|
|
104
108
|
# SST is built only when a heuristic tier runs. Later tiers are skipped once
|
|
105
109
|
# +limit+ articles with url+title remain after Cleanup; the result is capped to +limit+.
|
|
110
|
+
# Html is skipped when earlier tiers already admitted at least one clean article
|
|
111
|
+
# below +limit+ — quality over padding with weaker heuristic junk.
|
|
106
112
|
#
|
|
107
113
|
# @return [Array<Html2rss::Article>] extracted articles
|
|
108
114
|
def articles
|
|
109
115
|
@articles ||= extract_articles
|
|
110
116
|
rescue Html2rss::AutoSource::Scraper::NoScraperFound => error
|
|
111
117
|
Log.warn "#{self.class}: no scraper matched #{url} (#{error.message})"
|
|
118
|
+
@admission_drops = {}.freeze
|
|
112
119
|
[]
|
|
113
120
|
end
|
|
114
121
|
|
|
122
|
+
##
|
|
123
|
+
# Reason → count tallies from the final {Cleanup} pass (empty until {#articles} runs).
|
|
124
|
+
#
|
|
125
|
+
# @return [Hash{String => Integer}]
|
|
126
|
+
def admission_drops
|
|
127
|
+
@admission_drops || {}
|
|
128
|
+
end
|
|
129
|
+
|
|
115
130
|
private
|
|
116
131
|
|
|
117
|
-
attr_reader :url, :parsed_body, :body, :request_session
|
|
132
|
+
attr_reader :url, :parsed_body, :body, :request_session, :captured_responses
|
|
118
133
|
|
|
119
134
|
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
120
135
|
def extract_articles
|
|
@@ -126,6 +141,7 @@ module Html2rss
|
|
|
126
141
|
|
|
127
142
|
Scraper::SCRAPER_TIERS.each do |tier|
|
|
128
143
|
break if enough_articles?(articles)
|
|
144
|
+
break if skip_html_padding?(tier, articles)
|
|
129
145
|
|
|
130
146
|
if Scraper.heuristic_tier?(tier)
|
|
131
147
|
document ||= Scraper.normalize_sst(parsed_body)
|
|
@@ -143,7 +159,8 @@ module Html2rss
|
|
|
143
159
|
request_session:,
|
|
144
160
|
body:,
|
|
145
161
|
document:,
|
|
146
|
-
link_resolver
|
|
162
|
+
link_resolver:,
|
|
163
|
+
captured_responses:
|
|
147
164
|
)
|
|
148
165
|
next unless instance
|
|
149
166
|
next unless Scraper.extractable_instance?(instance, parsed_body)
|
|
@@ -155,16 +172,31 @@ module Html2rss
|
|
|
155
172
|
|
|
156
173
|
raise Scraper.no_scraper_found_for(parsed_body, body:) unless matched
|
|
157
174
|
|
|
158
|
-
Cleanup.call(articles, url:, **cleanup_options)
|
|
175
|
+
result = Cleanup.call(articles, url:, **cleanup_options)
|
|
176
|
+
@admission_drops = result.drop_tallies
|
|
177
|
+
result.articles.first(article_limit)
|
|
159
178
|
end
|
|
160
179
|
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
161
180
|
|
|
162
181
|
def enough_articles?(articles)
|
|
163
|
-
|
|
164
|
-
return false if articles.size < threshold
|
|
182
|
+
return false if articles.size < article_limit
|
|
165
183
|
|
|
166
|
-
|
|
167
|
-
|
|
184
|
+
admitted_articles(articles).size >= article_limit
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# Prefer fewer clean items over refilling +limit+ from the Html heuristic tier.
|
|
188
|
+
def skip_html_padding?(tier, articles)
|
|
189
|
+
html_only_tier?(tier) && admitted_articles(articles).any?
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def html_only_tier?(tier)
|
|
193
|
+
tier == [Scraper::Html]
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def admitted_articles(articles)
|
|
197
|
+
Cleanup.call(articles.dup, url:, **cleanup_options).articles.select do |article|
|
|
198
|
+
article.url && !article.title.to_s.empty?
|
|
199
|
+
end
|
|
168
200
|
end
|
|
169
201
|
|
|
170
202
|
def article_limit
|
|
@@ -188,5 +220,4 @@ module Html2rss
|
|
|
188
220
|
@opts.fetch(:cleanup, {})
|
|
189
221
|
end
|
|
190
222
|
end
|
|
191
|
-
# rubocop:enable Metrics/ClassLength
|
|
192
223
|
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# Capture
|
|
2
|
+
|
|
3
|
+
`Html2rss.capture` (and CLI `html2rss capture`) analyzes a URL through the feed pipeline and produces a reusable config with **items selector + `enhance: true` only** — no title/url/description attribute-selector soup. At feed-build time, `enhance: true` fills missing article fields via `Html::ArticleExtractor` on each matched item.
|
|
4
|
+
|
|
5
|
+
## When to use it
|
|
6
|
+
|
|
7
|
+
Point `capture` at a listing URL when you want a first-draft YAML config instead of hand-writing selectors. Treat the output as a draft: selector quality depends on page structure.
|
|
8
|
+
|
|
9
|
+
## Gem API
|
|
10
|
+
|
|
11
|
+
```ruby
|
|
12
|
+
config = Html2rss.capture('https://example.com/articles')
|
|
13
|
+
|
|
14
|
+
# {
|
|
15
|
+
# channel: { url: "...", title: "...", time_zone: "UTC" },
|
|
16
|
+
# selectors: {
|
|
17
|
+
# items: { selector: "div.post", enhance: true }
|
|
18
|
+
# }
|
|
19
|
+
# }
|
|
20
|
+
|
|
21
|
+
File.write('my-feed.yml', YAML.dump(Html2rss::HashUtil.deep_stringify_keys(config)))
|
|
22
|
+
feed = Html2rss.feed(config)
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
`Capture.build` returns a `CaptureResult` with quality meta (`has_selectors`, `segment_strategy`, `admission_drops`, `selected_strategy`). `Html2rss.capture` returns only the config hash.
|
|
26
|
+
|
|
27
|
+
### Options
|
|
28
|
+
|
|
29
|
+
```ruby
|
|
30
|
+
Html2rss.capture('https://spa-site.com', strategy: :botasaurus)
|
|
31
|
+
Html2rss.capture('https://example.com', items_selector: '.article-card')
|
|
32
|
+
Html2rss.capture('https://example.com', strategy: :local_file, local_file_path: './page.html')
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
`strategy: :auto` uses the same AutoFallback chain as scrape (`faraday` → `botasaurus`). When AutoFallback selects a concrete strategy (or you pin one), Capture **stamps** `strategy:` into the emitted config so later `Html2rss.feed(config)` replays the same transport.
|
|
36
|
+
|
|
37
|
+
## CLI
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
html2rss capture https://example.com/articles
|
|
41
|
+
html2rss capture https://example.com --strategy botasaurus
|
|
42
|
+
html2rss capture https://example.com/articles > my-feed.yml
|
|
43
|
+
html2rss capture https://example.com --input ./page.html
|
|
44
|
+
html2rss capture https://example.com --explain # quality JSON on stderr; YAML on stdout
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## How it works
|
|
48
|
+
|
|
49
|
+
1. **Request** — `FeedPipeline` (AutoFallback when `:auto`)
|
|
50
|
+
2. **Discover** — AutoSource extracts admitted articles
|
|
51
|
+
3. **Segment** — try SST Segmenter strategies `:list` → `:cluster` → `:semantic`
|
|
52
|
+
4. **Gate** — emit items selector only when ≥ `MIN_SELECTOR_MATCHES` (2) articles match
|
|
53
|
+
5. **Assemble** — `{ items: { selector:, enhance: true } }` plus channel
|
|
54
|
+
|
|
55
|
+
## Constraints
|
|
56
|
+
|
|
57
|
+
- Selector quality depends on page structure; treat output as a first draft.
|
|
58
|
+
- When the quality gate fails, selectors are omitted (`has_selectors: false`) rather than inventing attribute selectors.
|
|
59
|
+
- Botasaurus hops need `BOTASAURUS_SCRAPER_URL`.
|
|
60
|
+
|
|
61
|
+
See also {Html2rss::AutoSource} for article discovery and {Html2rss::FeedPipeline} for the `:auto` request chain.
|