html2rss 0.33.0 → 0.34.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 +4 -2
- data/lib/html2rss/article/deduplicator.rb +5 -2
- data/lib/html2rss/article/enclosure.rb +185 -21
- data/lib/html2rss/article/title.rb +203 -0
- data/lib/html2rss/article.rb +79 -13
- data/lib/html2rss/auto_source/README.md +16 -8
- data/lib/html2rss/auto_source/cleanup.rb +126 -50
- data/lib/html2rss/auto_source/corroboration.rb +167 -0
- data/lib/html2rss/auto_source/page_links/anchor_claims.rb +82 -0
- data/lib/html2rss/auto_source/page_links/card_units.rb +77 -0
- data/lib/html2rss/auto_source/page_links/label_binding.rb +79 -0
- data/lib/html2rss/auto_source/page_links/sponsored_cards.rb +147 -0
- data/lib/html2rss/auto_source/page_links.rb +164 -0
- data/lib/html2rss/auto_source/scraper/html.rb +11 -39
- data/lib/html2rss/auto_source/scraper/json_document.rb +1 -3
- data/lib/html2rss/auto_source/scraper/json_state/article_normalizer.rb +119 -42
- data/lib/html2rss/auto_source/scraper/json_state/candidate_detector.rb +5 -16
- data/lib/html2rss/auto_source/scraper/json_state/value_finder.rb +2 -10
- data/lib/html2rss/auto_source/scraper/json_state.rb +2 -3
- data/lib/html2rss/auto_source/scraper/meta_oembed.rb +6 -14
- data/lib/html2rss/auto_source/scraper/microdata.rb +6 -4
- data/lib/html2rss/auto_source/scraper/microformats2.rb +1 -3
- data/lib/html2rss/auto_source/scraper/native_feed.rb +27 -17
- data/lib/html2rss/auto_source/scraper/schema/item_list.rb +5 -3
- data/lib/html2rss/auto_source/scraper/schema/thing.rb +68 -27
- data/lib/html2rss/auto_source/scraper/schema.rb +5 -8
- data/lib/html2rss/auto_source/scraper/semantic_html/entry_deduplicator.rb +3 -9
- data/lib/html2rss/auto_source/scraper/semantic_html.rb +1 -5
- data/lib/html2rss/auto_source/scraper/sitemap.rb +4 -3
- data/lib/html2rss/auto_source/scraper/wordpress_api.rb +3 -3
- data/lib/html2rss/auto_source/scraper/xhr_articles.rb +1 -3
- data/lib/html2rss/auto_source/scraper.rb +10 -2
- data/lib/html2rss/auto_source/segment.rb +19 -5
- data/lib/html2rss/auto_source/segmenter/cluster.rb +13 -11
- data/lib/html2rss/auto_source/segmenter/list.rb +13 -5
- data/lib/html2rss/auto_source/segmenter/primary_link.rb +29 -4
- data/lib/html2rss/auto_source/segmenter/semantic.rb +17 -6
- data/lib/html2rss/auto_source/segmenter.rb +4 -8
- data/lib/html2rss/auto_source.rb +81 -26
- data/lib/html2rss/batch.rb +2 -0
- data/lib/html2rss/capture/README.md +5 -2
- data/lib/html2rss/capture.rb +44 -32
- data/lib/html2rss/channel.rb +30 -12
- data/lib/html2rss/cli.rb +22 -11
- data/lib/html2rss/config/auto_source_contract.rb +52 -45
- data/lib/html2rss/config/issue_mapper.rb +4 -1
- data/lib/html2rss/config/request_controls.rb +6 -3
- data/lib/html2rss/config/schema.rb +15 -5
- data/lib/html2rss/config/selectors_validator.rb +2 -2
- data/lib/html2rss/config/validation_issue.rb +4 -1
- data/lib/html2rss/config/validator.rb +11 -0
- data/lib/html2rss/config.rb +53 -27
- data/lib/html2rss/contract_error.rb +9 -0
- data/lib/html2rss/defaults.rb +21 -23
- data/lib/html2rss/doctor/botasaurus.rb +7 -3
- data/lib/html2rss/error.rb +10 -0
- data/lib/html2rss/error_kind.rb +39 -0
- data/lib/html2rss/feed_builder/item_presentation.rb +22 -9
- data/lib/html2rss/feed_builder/json_feed/item.rb +2 -1
- data/lib/html2rss/feed_builder/json_feed.rb +2 -2
- data/lib/html2rss/feed_builder/rss/stylesheet.rb +3 -3
- data/lib/html2rss/feed_builder/rss.rb +49 -6
- data/lib/html2rss/feed_builder.rb +2 -1
- data/lib/html2rss/feed_pipeline/README.md +1 -1
- data/lib/html2rss/feed_pipeline/auto_fallback.rb +13 -31
- data/lib/html2rss/feed_pipeline/strategy_plan.rb +3 -3
- data/lib/html2rss/feed_pipeline.rb +40 -27
- data/lib/html2rss/feed_resolution/README.md +1 -1
- data/lib/html2rss/feed_resolution/candidate_generator.rb +3 -4
- data/lib/html2rss/feed_resolution/policy.rb +1 -1
- data/lib/html2rss/feed_resolution/probe.rb +3 -1
- data/lib/html2rss/feed_resolution.rb +11 -12
- data/lib/html2rss/feed_result.rb +9 -5
- data/lib/html2rss/hash_util.rb +3 -3
- data/lib/html2rss/html/article_extractor/category_extractor.rb +1 -1
- data/lib/html2rss/html/article_extractor/enclosure_extractor.rb +12 -31
- data/lib/html2rss/html/article_extractor/heading_extractor.rb +2 -3
- data/lib/html2rss/html/article_extractor/id_generator.rb +6 -9
- data/lib/html2rss/html/article_extractor/image_extractor.rb +9 -1
- data/lib/html2rss/html/article_extractor.rb +19 -47
- data/lib/html2rss/html/article_rules/category.rb +3 -3
- data/lib/html2rss/html/article_rules/description.rb +6 -15
- data/lib/html2rss/html/article_rules/enclosure.rb +70 -42
- data/lib/html2rss/html/article_rules/image.rb +30 -16
- data/lib/html2rss/html/feed_link.rb +7 -9
- data/lib/html2rss/html/navigator/text_extractor.rb +26 -16
- data/lib/html2rss/html/navigator.rb +39 -9
- data/lib/html2rss/html/probe.rb +4 -0
- data/lib/html2rss/html/rendering/description_builder.rb +46 -32
- data/lib/html2rss/html/rendering.rb +2 -9
- data/lib/html2rss/html/sst_article_extractor.rb +55 -145
- data/lib/html2rss/html/title_pieces/kicker.rb +147 -0
- data/lib/html2rss/html/title_pieces/nodes.rb +107 -0
- data/lib/html2rss/html/title_pieces/nokogiri_nodes.rb +70 -0
- data/lib/html2rss/html/title_pieces/sst_nodes.rb +71 -0
- data/lib/html2rss/html/title_pieces.rb +169 -0
- data/lib/html2rss/html/visibility.rb +56 -0
- data/lib/html2rss/html.rb +2 -0
- data/lib/html2rss/link_destination/destination_facts.rb +17 -1
- data/lib/html2rss/link_destination/noise_policy.rb +11 -10
- data/lib/html2rss/link_destination/path_classifier.rb +100 -9
- data/lib/html2rss/link_destination/sponsorship/label_text.rb +82 -0
- data/lib/html2rss/link_destination/sponsorship.rb +184 -0
- data/lib/html2rss/link_destination/text_classifier.rb +104 -8
- data/lib/html2rss/mcp/README.md +13 -1
- data/lib/html2rss/mcp/config_argument.rb +5 -2
- data/lib/html2rss/mcp/contract.rb +13 -4
- data/lib/html2rss/mcp/outcome.rb +14 -12
- data/lib/html2rss/mcp/runtime.rb +39 -2
- data/lib/html2rss/mcp/server.rb +13 -4
- data/lib/html2rss/page_recon/README.md +1 -1
- data/lib/html2rss/page_recon/diagnostics.rb +2 -0
- data/lib/html2rss/recon.rb +10 -12
- data/lib/html2rss/request_service/blocked_surface.rb +2 -1
- data/lib/html2rss/request_service/botasaurus_contract.rb +25 -3
- data/lib/html2rss/request_service/botasaurus_strategy.rb +42 -10
- data/lib/html2rss/request_service/context.rb +15 -14
- data/lib/html2rss/request_service/cookie_jar.rb +89 -0
- data/lib/html2rss/request_service/cookie_store.rb +79 -0
- data/lib/html2rss/request_service/feed_advertisement.rb +72 -0
- data/lib/html2rss/request_service/httpx_strategy.rb +7 -3
- data/lib/html2rss/request_service/local_file_strategy.rb +2 -2
- data/lib/html2rss/request_service/policy.rb +48 -11
- data/lib/html2rss/request_service/replay_breaker.rb +56 -0
- data/lib/html2rss/request_service/response.rb +30 -13
- data/lib/html2rss/request_service/response_cache.rb +109 -0
- data/lib/html2rss/request_service/response_caching.rb +95 -0
- data/lib/html2rss/request_service/strategy.rb +5 -11
- data/lib/html2rss/request_service.rb +71 -12
- data/lib/html2rss/request_session/pager/base.rb +0 -7
- data/lib/html2rss/request_session/pager.rb +1 -1
- data/lib/html2rss/request_session.rb +6 -2
- data/lib/html2rss/scoring/cluster_scorer.rb +1 -1
- data/lib/html2rss/scoring/container_assessor.rb +12 -13
- data/lib/html2rss/scoring/engine.rb +6 -6
- data/lib/html2rss/scoring/observation.rb +2 -2
- data/lib/html2rss/selector_candidates.rb +19 -8
- data/lib/html2rss/selectors/README.md +1 -1
- data/lib/html2rss/selectors/option_spec.rb +4 -1
- data/lib/html2rss/selectors/post_processors/base.rb +0 -8
- data/lib/html2rss/selectors/post_processors/gsub.rb +7 -7
- data/lib/html2rss/selectors/post_processors/html_transformers/keep_playable_media.rb +50 -0
- data/lib/html2rss/selectors/post_processors/html_transformers/transform_urls_to_absolute_ones.rb +1 -3
- data/lib/html2rss/selectors/post_processors/parse_time.rb +3 -3
- data/lib/html2rss/selectors/post_processors/parse_uri.rb +1 -1
- data/lib/html2rss/selectors/post_processors/sanitize_html.rb +18 -5
- data/lib/html2rss/selectors/post_processors/substring.rb +1 -1
- data/lib/html2rss/selectors.rb +53 -12
- data/lib/html2rss/sst/attrs.rb +2 -2
- data/lib/html2rss/sst/document.rb +3 -3
- data/lib/html2rss/sst/index.rb +30 -11
- data/lib/html2rss/sst/node.rb +33 -26
- data/lib/html2rss/sst/normalizer.rb +31 -19
- data/lib/html2rss/sst/tags.rb +113 -2
- data/lib/html2rss/sst/text.rb +69 -29
- data/lib/html2rss/sst.rb +2 -0
- data/lib/html2rss/status.rb +6 -3
- data/lib/html2rss/surface_category.rb +1 -1
- data/lib/html2rss/syndication/README.md +12 -6
- data/lib/html2rss/syndication/alternate_links.rb +1 -1
- data/lib/html2rss/syndication/channel_facts.rb +87 -0
- data/lib/html2rss/syndication/discovery.rb +82 -28
- data/lib/html2rss/syndication/feed.rb +20 -0
- data/lib/html2rss/syndication/item_media.rb +103 -0
- data/lib/html2rss/syndication/parser.rb +75 -51
- data/lib/html2rss/test/enhance_audit.rb +2 -2
- data/lib/html2rss/test/item_sample.rb +43 -0
- data/lib/html2rss/test.rb +42 -14
- data/lib/html2rss/url.rb +115 -14
- data/lib/html2rss/version.rb +1 -1
- data/lib/html2rss/word_count.rb +53 -0
- data/lib/html2rss.rb +17 -8
- data/schema/html2rss-config.schema.json +56 -41
- metadata +31 -4
- data/lib/html2rss/html/rendering/media_renderer.rb +0 -39
- data/lib/html2rss/html/rendering/pdf_renderer.rb +0 -30
data/lib/html2rss/auto_source.rb
CHANGED
|
@@ -22,7 +22,13 @@ module Html2rss
|
|
|
22
22
|
# Default max articles to keep (also the short-circuit floor across scraper tiers).
|
|
23
23
|
DEFAULT_LIMIT = 25
|
|
24
24
|
|
|
25
|
-
#
|
|
25
|
+
# Raised when a scraper yields an item that is neither an Article nor a Hash (a scraper bug).
|
|
26
|
+
class ScraperContractError < Html2rss::ContractError; end
|
|
27
|
+
|
|
28
|
+
# Operational scraper failures quarantined within a tier; programmer errors (including any
|
|
29
|
+
# {Html2rss::ContractError}) propagate.
|
|
30
|
+
# +ArgumentError+ stays for stdlib raises on publisher bytes (e.g. +invalid byte sequence+ after
|
|
31
|
+
# +CGI.unescapeHTML+ of a surrogate entity); lib raises are {Html2rss::Error} (CONTEXT.md § Error handling).
|
|
26
32
|
OPERATIONAL_ERRORS = [
|
|
27
33
|
Html2rss::Error,
|
|
28
34
|
ArgumentError,
|
|
@@ -71,14 +77,12 @@ module Html2rss
|
|
|
71
77
|
enabled: true
|
|
72
78
|
},
|
|
73
79
|
semantic_html: {
|
|
74
|
-
enabled: true
|
|
75
|
-
fallback_anchorless: true
|
|
80
|
+
enabled: true
|
|
76
81
|
},
|
|
77
82
|
html: {
|
|
78
83
|
enabled: true,
|
|
79
84
|
minimum_selector_frequency: Scraper::Html::DEFAULT_MINIMUM_SELECTOR_FREQUENCY,
|
|
80
|
-
use_top_selectors: Scraper::Html::DEFAULT_USE_TOP_SELECTORS
|
|
81
|
-
fallback_anchorless: true
|
|
85
|
+
use_top_selectors: Scraper::Html::DEFAULT_USE_TOP_SELECTORS
|
|
82
86
|
}
|
|
83
87
|
},
|
|
84
88
|
cleanup: Cleanup::DEFAULT_CONFIG
|
|
@@ -120,14 +124,18 @@ module Html2rss
|
|
|
120
124
|
@captured_responses = response.captured_responses
|
|
121
125
|
@opts = opts
|
|
122
126
|
@request_session = request_session
|
|
127
|
+
@cards_read = false
|
|
123
128
|
end
|
|
124
129
|
|
|
125
130
|
##
|
|
126
131
|
# Extracts articles by running scraper tiers until a sufficient set is found.
|
|
127
132
|
#
|
|
128
133
|
# Tiers: in-page structured → follow-up IO → SemanticHtml → Html.
|
|
129
|
-
# SST is built
|
|
130
|
-
#
|
|
134
|
+
# SST is built at most once, lazily, by the first consumer: a heuristic tier or the
|
|
135
|
+
# {Corroboration} page check of structured-tier items. Later tiers are skipped once
|
|
136
|
+
# +limit+ counting articles with url+title remain after Cleanup ({#counting}); the result
|
|
137
|
+
# is capped to +limit+, counting articles first. A native feed's items come first, and
|
|
138
|
+
# Cleanup merges a page copy of the same article into them.
|
|
131
139
|
# Html is skipped when earlier tiers already admitted at least one clean article
|
|
132
140
|
# below +limit+ — quality over padding with weaker heuristic junk.
|
|
133
141
|
#
|
|
@@ -153,6 +161,13 @@ module Html2rss
|
|
|
153
161
|
@admission_drops || {}
|
|
154
162
|
end
|
|
155
163
|
|
|
164
|
+
##
|
|
165
|
+
# What the native feed this page advertises states about its channel ({Scraper::NativeFeed#channel}):
|
|
166
|
+
# nil until {#articles} runs, or when no feed was read.
|
|
167
|
+
#
|
|
168
|
+
# @return [Html2rss::Syndication::ChannelFacts, nil]
|
|
169
|
+
def feed_channel = @native_feed&.channel
|
|
170
|
+
|
|
156
171
|
private
|
|
157
172
|
|
|
158
173
|
attr_reader :url, :parsed_body, :body, :request_session, :captured_responses
|
|
@@ -163,21 +178,13 @@ module Html2rss
|
|
|
163
178
|
|
|
164
179
|
articles = []
|
|
165
180
|
matched = false
|
|
166
|
-
document = nil
|
|
167
|
-
link_resolver = nil
|
|
168
181
|
scraper_opts = @opts.fetch(:scraper, {})
|
|
169
182
|
|
|
170
183
|
Scraper::SCRAPER_TIERS.each do |tier|
|
|
171
184
|
break if enough_articles?(articles)
|
|
172
185
|
break if skip_html_padding?(tier, articles)
|
|
173
186
|
|
|
174
|
-
if Scraper.heuristic_tier?(tier)
|
|
175
|
-
document ||= SST::Normalizer.call(parsed_body)
|
|
176
|
-
next unless document
|
|
177
|
-
|
|
178
|
-
link_resolver ||= ::Html2rss::Scoring::LinkResolver.new(url)
|
|
179
|
-
end
|
|
180
|
-
|
|
187
|
+
@cards_read = true if Scraper.heuristic_tier?(tier)
|
|
181
188
|
tier.each do |scraper_class|
|
|
182
189
|
instance = Scraper.build_instance(
|
|
183
190
|
scraper_class,
|
|
@@ -186,7 +193,7 @@ module Html2rss
|
|
|
186
193
|
url:,
|
|
187
194
|
request_session:,
|
|
188
195
|
body:,
|
|
189
|
-
document
|
|
196
|
+
document: (page_document if Scraper.heuristic_tier?(tier)),
|
|
190
197
|
link_resolver:,
|
|
191
198
|
captured_responses:
|
|
192
199
|
)
|
|
@@ -195,6 +202,7 @@ module Html2rss
|
|
|
195
202
|
|
|
196
203
|
matched = true
|
|
197
204
|
articles.concat(run_scraper(instance))
|
|
205
|
+
@native_feed = instance if instance.is_a?(Scraper::NativeFeed)
|
|
198
206
|
end
|
|
199
207
|
end
|
|
200
208
|
|
|
@@ -231,10 +239,11 @@ module Html2rss
|
|
|
231
239
|
|
|
232
240
|
def finish_articles(articles)
|
|
233
241
|
captured_count = articles.size
|
|
234
|
-
result =
|
|
242
|
+
result = cleanup_result(articles)
|
|
235
243
|
@admission_drops = result.drop_tallies
|
|
236
244
|
log_admission_emptied(captured_count, result) if captured_count.positive? && result.articles.empty?
|
|
237
|
-
result.articles
|
|
245
|
+
counting = counting(result.articles)
|
|
246
|
+
(counting + (result.articles - counting)).first(article_limit)
|
|
238
247
|
end
|
|
239
248
|
|
|
240
249
|
def log_admission_emptied(captured_count, result)
|
|
@@ -252,7 +261,19 @@ module Html2rss
|
|
|
252
261
|
def enough_articles?(articles)
|
|
253
262
|
return false if articles.size < article_limit
|
|
254
263
|
|
|
255
|
-
admitted_articles(articles).size >= article_limit
|
|
264
|
+
counting(admitted_articles(articles)).size >= article_limit
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
# The articles that count toward +limit+, in order: those the page shows in its content
|
|
268
|
+
# ({Corroboration#shown?}), media items (audio, video) only on a media page
|
|
269
|
+
# ({Corroboration#media_page?}). The others stay admitted after the counting ones. A page is
|
|
270
|
+
# judged a media page only once a heuristic tier has read its cards: structured items alone
|
|
271
|
+
# cannot tell a podcast page from a news page with an audio list. A media page's counting
|
|
272
|
+
# items are in page order ({Corroboration#page_order}), not tier order.
|
|
273
|
+
def counting(articles)
|
|
274
|
+
media_page = @cards_read && corroboration.media_page?(articles)
|
|
275
|
+
counted = articles.select { (media_page || !_1.media?) && corroboration.shown?(_1) }
|
|
276
|
+
media_page ? corroboration.page_order(counted) : counted
|
|
256
277
|
end
|
|
257
278
|
|
|
258
279
|
# Prefer fewer clean items over refilling +limit+ from the Html heuristic tier.
|
|
@@ -264,8 +285,39 @@ module Html2rss
|
|
|
264
285
|
tier == [Scraper::Html]
|
|
265
286
|
end
|
|
266
287
|
|
|
288
|
+
# Cleanup is deterministic and +articles+ only grows, so one result per size is reused
|
|
289
|
+
# by the tier checks and by {#finish_articles}.
|
|
290
|
+
def cleanup_result(articles)
|
|
291
|
+
return @cleanup_memo.last if @cleanup_memo&.first == articles.size
|
|
292
|
+
|
|
293
|
+
Cleanup.call(articles.dup, url:, corroboration:, page_links: -> { page_links },
|
|
294
|
+
feed_url: @native_feed&.feed_url, **cleanup_options)
|
|
295
|
+
.tap { @cleanup_memo = [articles.size, _1] }
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
# Page SST shared by the heuristic tiers and the page check; nil for a JSON response
|
|
299
|
+
# or a page without SST nodes.
|
|
300
|
+
def page_document
|
|
301
|
+
return @page_document if defined?(@page_document)
|
|
302
|
+
|
|
303
|
+
@page_document = (SST::Normalizer.call(parsed_body) unless json_response?)
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
def link_resolver
|
|
307
|
+
@link_resolver ||= Scoring::LinkResolver.new(url)
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
def corroboration
|
|
311
|
+
@corroboration ||= Corroboration.new { page_links }
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
# Rendered links of the page, built once for the page check and sponsorship.
|
|
315
|
+
def page_links
|
|
316
|
+
@page_links ||= PageLinks.build(page_document, link_resolver:)
|
|
317
|
+
end
|
|
318
|
+
|
|
267
319
|
def admitted_articles(articles)
|
|
268
|
-
|
|
320
|
+
cleanup_result(articles).articles.select do |article|
|
|
269
321
|
article.url && !article.title.to_s.empty?
|
|
270
322
|
end
|
|
271
323
|
end
|
|
@@ -276,11 +328,11 @@ module Html2rss
|
|
|
276
328
|
|
|
277
329
|
def run_scraper(instance)
|
|
278
330
|
instance.map { |item| article_from_scraper_item(item, instance) }
|
|
331
|
+
rescue ContractError
|
|
332
|
+
raise
|
|
279
333
|
rescue *OPERATIONAL_ERRORS => error
|
|
280
334
|
Log.warn({
|
|
281
|
-
event_name: 'auto_source.quarantined',
|
|
282
|
-
host: url.host,
|
|
283
|
-
error_class: error.class.name,
|
|
335
|
+
event_name: 'auto_source.quarantined', host: url.host, error_class: error.class.name,
|
|
284
336
|
message: "#{self.class}: #{instance.class} quarantined #{error.class}: #{error.message}"
|
|
285
337
|
}.to_json)
|
|
286
338
|
[]
|
|
@@ -291,9 +343,12 @@ module Html2rss
|
|
|
291
343
|
when Article
|
|
292
344
|
item
|
|
293
345
|
when Hash
|
|
294
|
-
|
|
346
|
+
# Hash items carry publisher-declared or structured titles: verbatim (collapse only), except an item
|
|
347
|
+
# with an explicit +kicker+, which composes +Kicker: Headline+. Heuristic scrapers yield Articles
|
|
348
|
+
# composed by Html::SstArticleExtractor.
|
|
349
|
+
Article.new(**item, compose_title: item.key?(:kicker), scraper: instance.class)
|
|
295
350
|
else
|
|
296
|
-
raise
|
|
351
|
+
raise ScraperContractError, "#{instance.class} yielded #{item.class}; expected Article or Hash"
|
|
297
352
|
end
|
|
298
353
|
end
|
|
299
354
|
|
data/lib/html2rss/batch.rb
CHANGED
|
@@ -125,6 +125,8 @@ module Html2rss
|
|
|
125
125
|
url: url.to_s, ok: true, items_count: wire[:items].size,
|
|
126
126
|
items: wire[:items], channel_title: wire[:channel_title]
|
|
127
127
|
}
|
|
128
|
+
rescue Html2rss::ContractError
|
|
129
|
+
raise
|
|
128
130
|
rescue Html2rss::Error => error
|
|
129
131
|
{ url: url.to_s, ok: false, error: error.message }
|
|
130
132
|
end
|
|
@@ -29,19 +29,22 @@ rss = Html2rss.apply(result.config)
|
|
|
29
29
|
|
|
30
30
|
`Html2rss.capture` and `Capture.build` both return a `CaptureResult` with YAML (`#yaml`, includes the schema modeline) and quality meta (`has_selectors`, `segment_strategy`, `admission_drops`, `selected_strategy`, `native_feed`, `candidates`).
|
|
31
31
|
|
|
32
|
-
`#candidates` is ranked discovery evidence: `{ items:, title:, link:, published: }` arrays (best-first, deduplicated). Items entries are `{ selector:, enhance:, sample: }`; field entries are `{ selector:, sample: }`. `sample` stays on `#candidates` only — emitted YAML is still `items` + `enhance`. Buckets may be empty when evidence is missing. The default `a[href]` YAML fallback is not listed as discovered evidence.
|
|
32
|
+
`#candidates` is ranked discovery evidence: `{ items:, title:, link:, published: }` arrays (best-first, deduplicated). Items entries are `{ selector:, enhance:, sample: }`; field entries are `{ selector:, sample: }`. `sample` stays on `#candidates` only — emitted YAML is still `items` + `enhance`. `#candidates[:candidates_by_item]` is additive: for each of the top 5 item candidates (same order as `items`) it holds `{ selector:, title:, link:, published: }`, where the field buckets are computed from that item candidate's own matched nodes (reusing the discovery evidence, no refetch). Use it when the top-level `title` / `link` / `published` (which describe the first item candidate only) do not match the item selector you choose. Its first entry equals the top-level buckets. Buckets may be empty when evidence is missing. The default `a[href]` YAML fallback is not listed as discovered evidence.
|
|
33
33
|
|
|
34
34
|
**Wire vs internal:** user-facing **`Html2rss.apply`** ships RSS from a config Hash. Pipeline internals **`Html2rss.feed` / `feed_result`** stay unchanged — use `apply` in CLI/MCP/docs examples, not `feed`.
|
|
35
35
|
|
|
36
36
|
### Options
|
|
37
37
|
|
|
38
38
|
```ruby
|
|
39
|
-
Html2rss.capture('https://
|
|
39
|
+
Html2rss.capture('https://example.com', strategy: :botasaurus)
|
|
40
40
|
Html2rss.capture('https://example.com', items_selector: '.article-card')
|
|
41
41
|
Html2rss.capture('https://example.com', strategy: :local_file, local_file_path: './page.html')
|
|
42
42
|
Html2rss.capture('https://example.com', max_redirects: 8, max_requests: 4)
|
|
43
|
+
Html2rss.capture('https://example.com', total_timeout_seconds: 20)
|
|
43
44
|
```
|
|
44
45
|
|
|
46
|
+
`total_timeout_seconds:` (positive Integer, default 30) bounds the **whole** request chain: the `:auto` fallback hops (`default` → `botasaurus`) share one budget, so the bound is not per hop. When it elapses, capture raises `RequestService::RequestTimedOut`. Use it to stay under a host's own request deadline.
|
|
47
|
+
|
|
45
48
|
`strategy: :auto` uses the same AutoFallback chain as scrape (`default` → `botasaurus`). When AutoFallback selects a concrete strategy (or you pin one), Capture **stamps** `strategy:` into the emitted config so later `Html2rss.apply(config)` replays the same transport.
|
|
46
49
|
|
|
47
50
|
## CLI
|
data/lib/html2rss/capture.rb
CHANGED
|
@@ -89,6 +89,8 @@ module Html2rss
|
|
|
89
89
|
# @option options [Boolean, nil] :enhance whether to force enhance: true
|
|
90
90
|
# @option options [Integer, nil] :max_redirects optional redirect limit override
|
|
91
91
|
# @option options [Integer, nil] :max_requests optional request budget override
|
|
92
|
+
# @option options [Integer, nil] :total_timeout_seconds optional wall-clock bound (seconds) for the whole
|
|
93
|
+
# request chain, including every +:auto+ fallback hop (shared budget, not per hop)
|
|
92
94
|
# @option options [Integer, nil] :limit max articles to keep
|
|
93
95
|
# @option options [String, nil] :local_file_path optional local HTML file path
|
|
94
96
|
# @return [CaptureResult]
|
|
@@ -108,6 +110,8 @@ module Html2rss
|
|
|
108
110
|
# @option options [Boolean, nil] :enhance whether to force enhance: true
|
|
109
111
|
# @option options [Integer, nil] :max_redirects optional redirect limit override
|
|
110
112
|
# @option options [Integer, nil] :max_requests optional request budget override
|
|
113
|
+
# @option options [Integer, nil] :total_timeout_seconds optional wall-clock bound (seconds) for the whole
|
|
114
|
+
# request chain, including every +:auto+ fallback hop (shared budget, not per hop)
|
|
111
115
|
# @option options [Integer, nil] :limit max articles to keep
|
|
112
116
|
# @option options [String, nil] :local_file_path optional local HTML file path
|
|
113
117
|
def initialize(url, strategy: :auto, **options) # rubocop:disable Metrics/MethodLength
|
|
@@ -119,8 +123,7 @@ module Html2rss
|
|
|
119
123
|
@summary = options.delete(:summary)
|
|
120
124
|
@force = options.delete(:force) || false
|
|
121
125
|
@enhance_option = options.delete(:enhance)
|
|
122
|
-
@
|
|
123
|
-
@max_requests = options.delete(:max_requests)
|
|
126
|
+
@request_options = options.slice(:max_redirects, :max_requests, :total_timeout_seconds)
|
|
124
127
|
@limit = options.delete(:limit)
|
|
125
128
|
@local_file_path = options.delete(:local_file_path)
|
|
126
129
|
end
|
|
@@ -172,14 +175,9 @@ module Html2rss
|
|
|
172
175
|
end
|
|
173
176
|
|
|
174
177
|
def probe_native_feed(response)
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
page_url: @url,
|
|
179
|
-
request_session: discovery_session,
|
|
180
|
-
parsed_body: (response.parsed_body if response.html_response?),
|
|
181
|
-
html: response.body
|
|
182
|
-
)&.to_s
|
|
178
|
+
Syndication::Discovery.native_feed_url(response:, request_session: discovery_session)&.to_s
|
|
179
|
+
rescue Html2rss::ContractError
|
|
180
|
+
raise
|
|
183
181
|
rescue Html2rss::Error
|
|
184
182
|
nil
|
|
185
183
|
end
|
|
@@ -236,11 +234,7 @@ module Html2rss
|
|
|
236
234
|
Config.auto_source_config(
|
|
237
235
|
url: @url,
|
|
238
236
|
items_selector: @items_selector_hint,
|
|
239
|
-
request_controls: Config::RequestControls.from_shortcut(
|
|
240
|
-
strategy: @strategy,
|
|
241
|
-
max_redirects: @max_redirects,
|
|
242
|
-
max_requests: @max_requests
|
|
243
|
-
),
|
|
237
|
+
request_controls: Config::RequestControls.from_shortcut(strategy: @strategy, **@request_options),
|
|
244
238
|
limit: @limit
|
|
245
239
|
).tap { |config| apply_local_file_path!(config) }
|
|
246
240
|
end
|
|
@@ -296,7 +290,7 @@ module Html2rss
|
|
|
296
290
|
|
|
297
291
|
SEGMENT_STRATEGIES.flat_map do |strategy|
|
|
298
292
|
segments = AutoSource::Segmenter.call(
|
|
299
|
-
sst, base_url: @url, strategy:,
|
|
293
|
+
sst, base_url: @url, strategy:, link_resolver:
|
|
300
294
|
)
|
|
301
295
|
matched = match_segments_to_articles(segments, articles)
|
|
302
296
|
next [] if matched.size < MIN_SELECTOR_MATCHES
|
|
@@ -344,23 +338,24 @@ module Html2rss
|
|
|
344
338
|
end
|
|
345
339
|
|
|
346
340
|
def lift_heading_link_roots(roots)
|
|
347
|
-
|
|
341
|
+
enclosed = enclosed_root_counts(roots)
|
|
342
|
+
roots.map { |root| lift_heading_link_root(root, enclosed) }
|
|
348
343
|
end
|
|
349
344
|
|
|
350
|
-
def lift_heading_link_root(root,
|
|
345
|
+
def lift_heading_link_root(root, enclosed)
|
|
351
346
|
return root unless heading_or_inner_title_link?(root)
|
|
352
347
|
|
|
353
348
|
index = SST::Index.for_node(root)
|
|
354
349
|
return root unless index
|
|
355
350
|
|
|
356
|
-
walk_usable_card(root, index,
|
|
351
|
+
walk_usable_card(root, index, enclosed)
|
|
357
352
|
end
|
|
358
353
|
|
|
359
|
-
def walk_usable_card(root, index,
|
|
354
|
+
def walk_usable_card(root, index, enclosed)
|
|
360
355
|
candidate = root
|
|
361
356
|
parent = index.parent_of(root)
|
|
362
|
-
while
|
|
363
|
-
break if contains_other_root?(parent,
|
|
357
|
+
while Html::Navigator.usable_card_parent?(parent)
|
|
358
|
+
break if contains_other_root?(parent, enclosed)
|
|
364
359
|
|
|
365
360
|
candidate = parent
|
|
366
361
|
parent = index.parent_of(parent)
|
|
@@ -368,6 +363,28 @@ module Html2rss
|
|
|
368
363
|
candidate
|
|
369
364
|
end
|
|
370
365
|
|
|
366
|
+
# Identity-keyed tally of how many distinct roots each node is, or strictly encloses, so the
|
|
367
|
+
# card walk answers "does this ancestor hold another root?" without rescanning all roots.
|
|
368
|
+
def enclosed_root_counts(roots)
|
|
369
|
+
tally = Hash.new(0).compare_by_identity
|
|
370
|
+
roots.uniq(&:object_id).each do |root|
|
|
371
|
+
tally[root] += 1
|
|
372
|
+
count_enclosing_ancestors(root, tally)
|
|
373
|
+
end
|
|
374
|
+
tally
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
def count_enclosing_ancestors(root, tally)
|
|
378
|
+
index = SST::Index.for_node(root)
|
|
379
|
+
return unless index
|
|
380
|
+
|
|
381
|
+
ancestor = index.parent_of(root)
|
|
382
|
+
while ancestor
|
|
383
|
+
tally[ancestor] += 1
|
|
384
|
+
ancestor = index.parent_of(ancestor)
|
|
385
|
+
end
|
|
386
|
+
end
|
|
387
|
+
|
|
371
388
|
def heading_or_inner_title_link?(node)
|
|
372
389
|
name = node.name.to_s
|
|
373
390
|
return false if wrapping_anchor_root?(node)
|
|
@@ -382,16 +399,9 @@ module Html2rss
|
|
|
382
399
|
node.find { |child| !child.equal?(node) && tags.include?(child.name.to_s) }
|
|
383
400
|
end
|
|
384
401
|
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
other.equal?(parent) || sst_descendant?(other, parent)
|
|
390
|
-
end
|
|
391
|
-
end
|
|
392
|
-
|
|
393
|
-
def sst_descendant?(child, ancestor)
|
|
394
|
-
SST::Index.for_node(child)&.descendant_of?(child, ancestor)
|
|
402
|
+
# The walked-from root is itself tallied under every ancestor, so more than one means another root.
|
|
403
|
+
def contains_other_root?(parent, enclosed)
|
|
404
|
+
enclosed[parent] > 1
|
|
395
405
|
end
|
|
396
406
|
|
|
397
407
|
def shared_class_items_selector(roots)
|
|
@@ -446,6 +456,8 @@ module Html2rss
|
|
|
446
456
|
|
|
447
457
|
def channel_title_from(response)
|
|
448
458
|
Channel.from_response(response).title
|
|
459
|
+
rescue Html2rss::ContractError
|
|
460
|
+
raise
|
|
449
461
|
rescue Html2rss::Error
|
|
450
462
|
nil
|
|
451
463
|
end
|
data/lib/html2rss/channel.rb
CHANGED
|
@@ -3,7 +3,9 @@
|
|
|
3
3
|
require 'time'
|
|
4
4
|
|
|
5
5
|
module Html2rss
|
|
6
|
-
Channel = Data.define(
|
|
6
|
+
Channel = Data.define(
|
|
7
|
+
:title, :url, :description, :language, :ttl, :last_build_date, :image, :author, :categories, :explicit
|
|
8
|
+
)
|
|
7
9
|
|
|
8
10
|
##
|
|
9
11
|
# Materialized channel metadata for feed builders and {FeedResult}.
|
|
@@ -12,6 +14,10 @@ module Html2rss
|
|
|
12
14
|
# do not own channel extraction. Safe to Marshal (no Response / Nokogiri retained).
|
|
13
15
|
# Strings and image URL are normalized and frozen at construction (including Marshal load).
|
|
14
16
|
#
|
|
17
|
+
# The author is the config +channel.author+, else the followed native feed's author
|
|
18
|
+
# ({Syndication::ChannelFacts}), else the page's +<meta name="author">+. Categories and the explicit
|
|
19
|
+
# flag come only from that feed, when it states them.
|
|
20
|
+
#
|
|
15
21
|
# @!attribute [r] title
|
|
16
22
|
# @return [String]
|
|
17
23
|
# @!attribute [r] url
|
|
@@ -28,6 +34,10 @@ module Html2rss
|
|
|
28
34
|
# @return [Html2rss::Url, nil]
|
|
29
35
|
# @!attribute [r] author
|
|
30
36
|
# @return [String, nil]
|
|
37
|
+
# @!attribute [r] categories
|
|
38
|
+
# @return [Array<Array<String>>] each category followed by its subcategories (empty when unstated)
|
|
39
|
+
# @!attribute [r] explicit
|
|
40
|
+
# @return [Boolean, nil] whether the content is declared explicit; nil when unstated
|
|
31
41
|
class Channel # rubocop:disable Metrics/ClassLength -- Channel value object owns extract + freeze + Marshal
|
|
32
42
|
class << self
|
|
33
43
|
##
|
|
@@ -35,8 +45,9 @@ module Html2rss
|
|
|
35
45
|
#
|
|
36
46
|
# @param response [Html2rss::RequestService::Response]
|
|
37
47
|
# @param overrides [Hash{Symbol => Object}] optional overrides for channel attributes
|
|
48
|
+
# @param feed [Html2rss::Syndication::ChannelFacts, nil] what the followed native feed states
|
|
38
49
|
# @return [Html2rss::Channel]
|
|
39
|
-
def from_response(response, overrides: {})
|
|
50
|
+
def from_response(response, overrides: {}, feed: nil) # rubocop:disable Metrics/MethodLength -- one line per member
|
|
40
51
|
new(
|
|
41
52
|
title: title_for(response, overrides),
|
|
42
53
|
url: url_for(response),
|
|
@@ -45,7 +56,9 @@ module Html2rss
|
|
|
45
56
|
ttl: ttl_for(response, overrides),
|
|
46
57
|
last_build_date: last_build_date_for(response),
|
|
47
58
|
image: image_for(response, overrides),
|
|
48
|
-
author:
|
|
59
|
+
author: overrides[:author] || feed&.author || meta_author(response),
|
|
60
|
+
categories: feed&.categories || [],
|
|
61
|
+
explicit: feed&.explicit
|
|
49
62
|
)
|
|
50
63
|
end
|
|
51
64
|
|
|
@@ -117,8 +130,7 @@ module Html2rss
|
|
|
117
130
|
parsed_body(response)['lang'] || parsed_body(response).at_css('[lang]')&.[]('lang')
|
|
118
131
|
end
|
|
119
132
|
|
|
120
|
-
def
|
|
121
|
-
return overrides[:author] if overrides[:author]
|
|
133
|
+
def meta_author(response)
|
|
122
134
|
return unless html_response?(response)
|
|
123
135
|
|
|
124
136
|
parsed_body(response).at_css('meta[name="author"]')&.[]('content')
|
|
@@ -156,8 +168,11 @@ module Html2rss
|
|
|
156
168
|
# @param last_build_date [Time, String]
|
|
157
169
|
# @param image [Html2rss::Url, String, nil]
|
|
158
170
|
# @param author [String, nil]
|
|
159
|
-
#
|
|
160
|
-
|
|
171
|
+
# @param categories [Array<Array<String>>] each category followed by its subcategories
|
|
172
|
+
# @param explicit [Boolean, nil]
|
|
173
|
+
# rubocop:disable-next Metrics/ParameterLists, Metrics/MethodLength -- Data.define members are the channel contract
|
|
174
|
+
def initialize(title:, url:, description:, language:, ttl:, last_build_date:, image:, author:,
|
|
175
|
+
categories: [], explicit: nil)
|
|
161
176
|
super(
|
|
162
177
|
title: freeze_string(title),
|
|
163
178
|
url: url.is_a?(Url) ? url : Url.from_absolute(url),
|
|
@@ -166,16 +181,19 @@ module Html2rss
|
|
|
166
181
|
ttl: Integer(ttl),
|
|
167
182
|
last_build_date: normalize_last_build_date(last_build_date),
|
|
168
183
|
image: normalize_image(image),
|
|
169
|
-
author: author && freeze_string(author)
|
|
184
|
+
author: author && freeze_string(author),
|
|
185
|
+
categories: categories.map { |path| path.map { freeze_string(_1) }.freeze }.freeze,
|
|
186
|
+
explicit:
|
|
170
187
|
)
|
|
171
188
|
end
|
|
172
189
|
|
|
173
190
|
private
|
|
174
191
|
|
|
175
|
-
def marshal_dump = [title, url, description, language, ttl, last_build_date, image, author]
|
|
192
|
+
def marshal_dump = [title, url, description, language, ttl, last_build_date, image, author, categories, explicit]
|
|
176
193
|
|
|
177
|
-
def marshal_load((title, url, description, language, ttl, last_build_date, image, author))
|
|
178
|
-
initialize(title:, url:, description:, language:, ttl:, last_build_date:, image:, author
|
|
194
|
+
def marshal_load((title, url, description, language, ttl, last_build_date, image, author, categories, explicit))
|
|
195
|
+
initialize(title:, url:, description:, language:, ttl:, last_build_date:, image:, author:, categories:,
|
|
196
|
+
explicit:)
|
|
179
197
|
end
|
|
180
198
|
|
|
181
199
|
def freeze_string(value) = value.to_s.dup.freeze
|
|
@@ -189,7 +207,7 @@ module Html2rss
|
|
|
189
207
|
|
|
190
208
|
def normalize_last_build_date(value)
|
|
191
209
|
return value if value.is_a?(Time)
|
|
192
|
-
raise
|
|
210
|
+
raise InvalidOption, 'last_build_date must be a Time or HTTP-date String' unless value.is_a?(String)
|
|
193
211
|
|
|
194
212
|
Time.httpdate(value)
|
|
195
213
|
end
|
data/lib/html2rss/cli.rb
CHANGED
|
@@ -136,7 +136,7 @@ module Html2rss
|
|
|
136
136
|
end
|
|
137
137
|
|
|
138
138
|
if result.native_feed? && !options[:force]
|
|
139
|
-
$stderr.puts "First-party RSS/Atom feed detected at #{result.native_feed}. Use --force to capture anyway." # rubocop:disable Style/StderrPuts
|
|
139
|
+
$stderr.puts "First-party RSS/Atom/RDF feed detected at #{result.native_feed}. Use --force to capture anyway." # rubocop:disable Style/StderrPuts
|
|
140
140
|
exit(3)
|
|
141
141
|
end
|
|
142
142
|
|
|
@@ -198,13 +198,8 @@ module Html2rss
|
|
|
198
198
|
# @param source [String, nil] YAML file path or '-'
|
|
199
199
|
# @param feed_name [String, nil] optional named feed
|
|
200
200
|
# @return [void]
|
|
201
|
-
def apply(source = nil, feed_name = nil)
|
|
202
|
-
config =
|
|
203
|
-
Html2rss.config_from_yaml_file(source.to_s, feed_name)
|
|
204
|
-
else
|
|
205
|
-
raw_content, = read_config_input(source)
|
|
206
|
-
Config.from_yaml(raw_content)
|
|
207
|
-
end
|
|
201
|
+
def apply(source = nil, feed_name = nil)
|
|
202
|
+
config = execute_feed { load_apply_config(source, feed_name) }
|
|
208
203
|
config[:params] = options[:params] || {}
|
|
209
204
|
execute_feed { apply_runtime_request_overrides!(config) }
|
|
210
205
|
apply_local_file_input!(config, options[:input]) if options[:input]
|
|
@@ -220,6 +215,8 @@ module Html2rss
|
|
|
220
215
|
method_option :keep_different_domain, type: :boolean, default: false,
|
|
221
216
|
desc: 'Allow published items to link to any domain ' \
|
|
222
217
|
'supplied by the source (no affiliation/safety check)'
|
|
218
|
+
method_option :keep_sponsored, type: :boolean, default: false,
|
|
219
|
+
desc: 'Keep items the page marks as sponsored (rel="sponsored" or ad labels)'
|
|
223
220
|
# @param url [String, nil]
|
|
224
221
|
# @return [void]
|
|
225
222
|
def scrape(url = nil)
|
|
@@ -257,6 +254,8 @@ module Html2rss
|
|
|
257
254
|
# @return [void]
|
|
258
255
|
def mcp
|
|
259
256
|
Html2rss::MCP.start(transport: options[:transport].to_sym, port: options[:port])
|
|
257
|
+
rescue Html2rss::MCP::Server::HttpTransportUnavailable => error
|
|
258
|
+
raise Thor::Error, error.message
|
|
260
259
|
end
|
|
261
260
|
|
|
262
261
|
desc 'doctor [TYPE]', 'Runtime preflight checks (botasaurus)'
|
|
@@ -309,7 +308,7 @@ module Html2rss
|
|
|
309
308
|
|
|
310
309
|
expected = Recon::Verdict[verdict_filter.downcase]
|
|
311
310
|
results.select { |r| r.verdict == expected }
|
|
312
|
-
rescue
|
|
311
|
+
rescue InvalidOption => error
|
|
313
312
|
raise Thor::Error, error.message
|
|
314
313
|
end
|
|
315
314
|
|
|
@@ -332,7 +331,7 @@ module Html2rss
|
|
|
332
331
|
|
|
333
332
|
def handle_capture_output(result, url) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
|
334
333
|
if options[:output_dir]
|
|
335
|
-
dir = File.join(options[:output_dir],
|
|
334
|
+
dir = File.join(options[:output_dir], Html2rss::Url.from_absolute(url).host.to_s.delete_prefix('www.'))
|
|
336
335
|
FileUtils.mkdir_p(dir)
|
|
337
336
|
file_path = File.join(dir, 'index.yml')
|
|
338
337
|
File.write(file_path, result.yaml)
|
|
@@ -412,6 +411,13 @@ module Html2rss
|
|
|
412
411
|
options[:strategy]&.to_sym || :auto
|
|
413
412
|
end
|
|
414
413
|
|
|
414
|
+
def load_apply_config(source, feed_name)
|
|
415
|
+
return Html2rss.config_from_yaml_file(source.to_s, feed_name) if File.file?(source.to_s)
|
|
416
|
+
|
|
417
|
+
raw_content, = read_config_input(source)
|
|
418
|
+
Config.from_yaml(raw_content)
|
|
419
|
+
end
|
|
420
|
+
|
|
415
421
|
def execute_feed # rubocop:disable Metrics/MethodLength
|
|
416
422
|
yield
|
|
417
423
|
rescue Html2rss::RequestService::RedirectLimitReached => error
|
|
@@ -427,6 +433,7 @@ module Html2rss
|
|
|
427
433
|
Html2rss::RequestService::BlockedSurfaceDetected,
|
|
428
434
|
Html2rss::NoFeedItemsExtracted,
|
|
429
435
|
Html2rss::InvalidOption,
|
|
436
|
+
Html2rss::Config::InvalidConfig,
|
|
430
437
|
Html2rss::Url::InvalidUrlError => error
|
|
431
438
|
raise Thor::Error, error.message
|
|
432
439
|
end
|
|
@@ -440,10 +447,14 @@ module Html2rss
|
|
|
440
447
|
max_requests: options[:max_requests],
|
|
441
448
|
local_file_path:,
|
|
442
449
|
limit: options[:limit],
|
|
443
|
-
|
|
450
|
+
**cleanup_overrides
|
|
444
451
|
)
|
|
445
452
|
end
|
|
446
453
|
|
|
454
|
+
def cleanup_overrides
|
|
455
|
+
{ keep_different_domain: options[:keep_different_domain], keep_sponsored: options[:keep_sponsored] }
|
|
456
|
+
end
|
|
457
|
+
|
|
447
458
|
def explain_json!(payload)
|
|
448
459
|
$stderr.puts JSON.pretty_generate(payload) # rubocop:disable Style/StderrPuts
|
|
449
460
|
end
|