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
|
@@ -8,12 +8,15 @@ module Html2rss
|
|
|
8
8
|
#
|
|
9
9
|
# Config does not know MCP. YAML parsing stays on {Html2rss::Config.from_yaml}.
|
|
10
10
|
class ConfigArgument
|
|
11
|
+
# Raised unless exactly one of +config+ or +yaml+ is present.
|
|
12
|
+
class ExclusiveInputError < Html2rss::InvalidOption; end
|
|
13
|
+
|
|
11
14
|
class << self
|
|
12
15
|
##
|
|
13
16
|
# @param config [Hash, nil] feed configuration hash
|
|
14
17
|
# @param yaml [String, nil] feed configuration YAML
|
|
15
18
|
# @return [ConfigArgument]
|
|
16
|
-
# @raise [
|
|
19
|
+
# @raise [ExclusiveInputError] unless exactly one of +config+ or +yaml+ is present
|
|
17
20
|
# @raise [Html2rss::InvalidOption] when +config+ is not a Hash or +yaml+ is not a String
|
|
18
21
|
# @raise [Contract::UnpublishedRequestError] when the parsed config uses
|
|
19
22
|
# an unpublished MCP strategy or +request.local_file_path+
|
|
@@ -24,7 +27,7 @@ module Html2rss
|
|
|
24
27
|
in [false, true]
|
|
25
28
|
Config.from_yaml(require_type!(yaml, String, 'yaml'))
|
|
26
29
|
else
|
|
27
|
-
raise
|
|
30
|
+
raise ExclusiveInputError, 'Provide exactly one of config or yaml'
|
|
28
31
|
end
|
|
29
32
|
Contract.assert_published_request!(parsed)
|
|
30
33
|
new(config: parsed)
|
|
@@ -16,11 +16,11 @@ module Html2rss
|
|
|
16
16
|
ALL_ACCEPTED_STRATEGIES = (STRATEGIES + MIGRATION_STRATEGIES).freeze
|
|
17
17
|
|
|
18
18
|
# Bump when tool names, required inputs, or envelope semantics change (independent of gem +VERSION+).
|
|
19
|
-
MCP_CONTRACT_VERSION =
|
|
19
|
+
MCP_CONTRACT_VERSION = 4
|
|
20
20
|
public_constant :MCP_CONTRACT_VERSION
|
|
21
21
|
|
|
22
22
|
# Raised when apply/validate config uses an unpublished MCP request adapter.
|
|
23
|
-
class UnpublishedRequestError <
|
|
23
|
+
class UnpublishedRequestError < Html2rss::InvalidOption; end
|
|
24
24
|
|
|
25
25
|
# JSON Schema property for a source page URL.
|
|
26
26
|
URL_PROPERTY = {
|
|
@@ -244,7 +244,14 @@ module Html2rss
|
|
|
244
244
|
ok: { type: 'boolean' },
|
|
245
245
|
next_step: { type: 'string', enum: Outcome::NextStep::ALL.keys.map(&:to_s) },
|
|
246
246
|
guidance: { type: 'string' },
|
|
247
|
-
payload: { type: 'object' }
|
|
247
|
+
payload: { type: 'object' },
|
|
248
|
+
cache: {
|
|
249
|
+
type: 'object',
|
|
250
|
+
description: 'Present when part of this result came from the response cache instead of the live page',
|
|
251
|
+
additionalProperties: false,
|
|
252
|
+
required: %w[hits max_age_seconds],
|
|
253
|
+
properties: { hits: { type: 'integer' }, max_age_seconds: { type: 'integer' } }
|
|
254
|
+
}
|
|
248
255
|
}
|
|
249
256
|
}
|
|
250
257
|
end
|
|
@@ -253,9 +260,11 @@ module Html2rss
|
|
|
253
260
|
# One envelope Hash, one compact JSON body, no +_meta+.
|
|
254
261
|
#
|
|
255
262
|
# @param outcome [Outcome]
|
|
263
|
+
# @param cache_ages [Array<Integer>] ages in seconds of cached page fetches behind this result
|
|
256
264
|
# @return [::MCP::Tool::Response]
|
|
257
|
-
def response(outcome)
|
|
265
|
+
def response(outcome, cache_ages: [])
|
|
258
266
|
wire = outcome.to_h
|
|
267
|
+
wire[:cache] = { hits: cache_ages.size, max_age_seconds: cache_ages.max } unless cache_ages.empty?
|
|
259
268
|
::MCP::Tool::Response.new(
|
|
260
269
|
[{ type: 'text', text: JSON.generate(wire) }],
|
|
261
270
|
error: !outcome.ok,
|
data/lib/html2rss/mcp/outcome.rb
CHANGED
|
@@ -8,8 +8,9 @@ module Html2rss
|
|
|
8
8
|
# Typed MCP tool result. Owns next-step policy; guidance copy lives in
|
|
9
9
|
# {Playbook}.
|
|
10
10
|
class Outcome # rubocop:disable Metrics/ClassLength -- next-step policy + factories stay co-located
|
|
11
|
-
#
|
|
12
|
-
|
|
11
|
+
# Raised when an outcome is built with a wrong-typed next step or payload.
|
|
12
|
+
class InvalidError < Html2rss::ContractError; end
|
|
13
|
+
|
|
13
14
|
NextStep = Data.define(:name, :guidance)
|
|
14
15
|
|
|
15
16
|
##
|
|
@@ -22,7 +23,7 @@ module Html2rss
|
|
|
22
23
|
##
|
|
23
24
|
# @param name [Symbol, String]
|
|
24
25
|
# @return [NextStep]
|
|
25
|
-
def self.[](name) = ALL.fetch(name.to_sym) { raise
|
|
26
|
+
def self.[](name) = ALL.fetch(name.to_sym) { raise InvalidOption, "unknown next_step: #{name.inspect}" }
|
|
26
27
|
end
|
|
27
28
|
|
|
28
29
|
##
|
|
@@ -31,8 +32,8 @@ module Html2rss
|
|
|
31
32
|
# @param guidance [String]
|
|
32
33
|
# @param payload [Hash]
|
|
33
34
|
def initialize(ok:, next_step:, guidance:, payload:) # rubocop:disable Naming/MethodParameterName -- +ok+ is the envelope field
|
|
34
|
-
raise
|
|
35
|
-
raise
|
|
35
|
+
raise InvalidError, 'next_step must be a NextStep' unless next_step.is_a?(NextStep)
|
|
36
|
+
raise InvalidError, 'payload must be a Hash' unless payload.is_a?(Hash)
|
|
36
37
|
|
|
37
38
|
super(ok: !!ok, next_step:, guidance: guidance.to_s.freeze, payload: payload.dup.freeze)
|
|
38
39
|
end
|
|
@@ -218,7 +219,13 @@ module Html2rss
|
|
|
218
219
|
warnings = test_result.quality_report&.warnings
|
|
219
220
|
return base if warnings.nil? || warnings.empty?
|
|
220
221
|
|
|
221
|
-
"#{base} Review payload.quality_report warnings: #{warnings.join(', ')}
|
|
222
|
+
"#{base} Review payload.quality_report warnings: #{warnings.join(', ')}.#{items_without_url_hint(warnings)}"
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def items_without_url_hint(warnings)
|
|
226
|
+
return '' unless warnings.include?(:items_without_url)
|
|
227
|
+
|
|
228
|
+
' Items were dropped for having no URL: fix the selectors.url selector (it must match a link on each item).'
|
|
222
229
|
end
|
|
223
230
|
|
|
224
231
|
def capture_payload(yaml:, articles_count:, has_selectors:, channel_title:, requested_strategy:, # rubocop:disable Metrics/ParameterLists
|
|
@@ -237,15 +244,10 @@ module Html2rss
|
|
|
237
244
|
def next_step_for_error(error)
|
|
238
245
|
case error
|
|
239
246
|
when RequestService::BotasaurusConfigurationError then NextStep[:read_runtime]
|
|
240
|
-
when Contract::UnpublishedRequestError then NextStep[:validate]
|
|
241
|
-
when ArgumentError then argument_error_next_step(error)
|
|
247
|
+
when Contract::UnpublishedRequestError, ConfigArgument::ExclusiveInputError then NextStep[:validate]
|
|
242
248
|
else NextStep[:inspect]
|
|
243
249
|
end
|
|
244
250
|
end
|
|
245
|
-
|
|
246
|
-
def argument_error_next_step(error)
|
|
247
|
-
XOR_ERROR.match?(error.message) ? NextStep[:validate] : NextStep[:inspect]
|
|
248
|
-
end
|
|
249
251
|
end
|
|
250
252
|
end
|
|
251
253
|
end
|
data/lib/html2rss/mcp/runtime.rb
CHANGED
|
@@ -11,9 +11,16 @@ module Html2rss
|
|
|
11
11
|
:mcp_contract_version,
|
|
12
12
|
:catalog_fingerprint,
|
|
13
13
|
:tools,
|
|
14
|
-
:botasaurus_configured
|
|
14
|
+
:botasaurus_configured,
|
|
15
|
+
:response_cache_ttl
|
|
15
16
|
)
|
|
16
17
|
|
|
18
|
+
# Seconds an initial page fetch is reused when +HTML2RSS_RESPONSE_CACHE_TTL+ is unset.
|
|
19
|
+
DEFAULT_RESPONSE_CACHE_TTL = 300
|
|
20
|
+
# Values of +HTML2RSS_RESPONSE_CACHE_TTL+ that disable the cache.
|
|
21
|
+
CACHE_OFF_VALUES = %w[0 off false].freeze
|
|
22
|
+
private_constant :CACHE_OFF_VALUES
|
|
23
|
+
|
|
17
24
|
class << self
|
|
18
25
|
##
|
|
19
26
|
# @return [Boolean] whether Botasaurus transport is configured in this process
|
|
@@ -21,6 +28,18 @@ module Html2rss
|
|
|
21
28
|
!ENV['BOTASAURUS_SCRAPER_URL'].to_s.strip.empty?
|
|
22
29
|
end
|
|
23
30
|
|
|
31
|
+
##
|
|
32
|
+
# Installs the initial-fetch cache for the long-lived MCP process, so an agent iterating
|
|
33
|
+
# +inspect -> capture -> validate -> test+ on one URL fetches it once per TTL window.
|
|
34
|
+
# +HTML2RSS_RESPONSE_CACHE_TTL+ sets the seconds ({DEFAULT_RESPONSE_CACHE_TTL} when unset);
|
|
35
|
+
# +0+, +off+ or +false+ disables caching. An unusable value logs a warning and uses the default.
|
|
36
|
+
#
|
|
37
|
+
# @return [void]
|
|
38
|
+
def install_response_cache!
|
|
39
|
+
ttl = response_cache_ttl
|
|
40
|
+
RequestService.response_cache = ttl && RequestService::ResponseCache.new(ttl:)
|
|
41
|
+
end
|
|
42
|
+
|
|
24
43
|
##
|
|
25
44
|
# @return [Snapshot] runtime capabilities and catalog identity for MCP clients
|
|
26
45
|
def snapshot
|
|
@@ -29,9 +48,27 @@ module Html2rss
|
|
|
29
48
|
mcp_contract_version: Contract::MCP_CONTRACT_VERSION,
|
|
30
49
|
catalog_fingerprint: Contract.catalog_fingerprint,
|
|
31
50
|
tools: Contract.catalog_tools,
|
|
32
|
-
botasaurus_configured: botasaurus_configured
|
|
51
|
+
botasaurus_configured: botasaurus_configured?,
|
|
52
|
+
response_cache_ttl: RequestService.response_cache&.ttl
|
|
33
53
|
)
|
|
34
54
|
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def response_cache_ttl
|
|
59
|
+
raw = ENV['HTML2RSS_RESPONSE_CACHE_TTL'].to_s.strip.downcase
|
|
60
|
+
return DEFAULT_RESPONSE_CACHE_TTL if raw.empty?
|
|
61
|
+
return if CACHE_OFF_VALUES.include?(raw)
|
|
62
|
+
|
|
63
|
+
ttl = Integer(raw, 10, exception: false)
|
|
64
|
+
ttl&.positive? ? ttl : invalid_cache_ttl(raw)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def invalid_cache_ttl(raw)
|
|
68
|
+
Log.warn("HTML2RSS_RESPONSE_CACHE_TTL=#{raw.inspect} is not a positive integer; " \
|
|
69
|
+
"using #{DEFAULT_RESPONSE_CACHE_TTL}s")
|
|
70
|
+
DEFAULT_RESPONSE_CACHE_TTL
|
|
71
|
+
end
|
|
35
72
|
end
|
|
36
73
|
end
|
|
37
74
|
end
|
data/lib/html2rss/mcp/server.rb
CHANGED
|
@@ -23,6 +23,9 @@ module Html2rss
|
|
|
23
23
|
# Supported transport names.
|
|
24
24
|
TRANSPORTS = Set[:stdio, :http].freeze
|
|
25
25
|
|
|
26
|
+
# Raised when the HTTP transport's optional gems (+rackup+, +webrick+) cannot be loaded.
|
|
27
|
+
class HttpTransportUnavailable < Html2rss::Error; end
|
|
28
|
+
|
|
26
29
|
# Declarative MCP resource registrations consumed by {register_resources}.
|
|
27
30
|
RESOURCES = [
|
|
28
31
|
{
|
|
@@ -95,10 +98,13 @@ module Html2rss
|
|
|
95
98
|
#
|
|
96
99
|
# @param transport [Symbol] +:stdio+ or +:http+
|
|
97
100
|
# @param port [Integer] port for HTTP transport
|
|
101
|
+
# @raise [InvalidOption] for an unknown transport
|
|
102
|
+
# @raise [HttpTransportUnavailable] when +:http+ is requested without the rackup and webrick gems
|
|
98
103
|
def start(transport: :stdio, port: 8080)
|
|
99
|
-
raise
|
|
104
|
+
raise InvalidOption, "Unknown transport: #{transport.inspect}" unless TRANSPORTS.include?(transport)
|
|
100
105
|
|
|
101
106
|
configure_daemon_logging!
|
|
107
|
+
Runtime.install_response_cache!
|
|
102
108
|
app = build
|
|
103
109
|
Log.info(start_banner(transport:, port:))
|
|
104
110
|
return start_http(app, port:) if transport == :http
|
|
@@ -204,7 +210,7 @@ module Html2rss
|
|
|
204
210
|
Logger: Html2rss.logger
|
|
205
211
|
)
|
|
206
212
|
rescue LoadError => error
|
|
207
|
-
raise
|
|
213
|
+
raise HttpTransportUnavailable,
|
|
208
214
|
'HTTP transport requires the rackup and webrick gems ' \
|
|
209
215
|
"(#{error.message}). Install them or use --transport stdio."
|
|
210
216
|
end
|
|
@@ -218,8 +224,11 @@ module Html2rss
|
|
|
218
224
|
Contract.response(Outcome.from_error(error))
|
|
219
225
|
end
|
|
220
226
|
|
|
221
|
-
def handle_tool_call
|
|
222
|
-
|
|
227
|
+
def handle_tool_call(&)
|
|
228
|
+
outcome, cache_ages = RequestService.observe_cache_hits(&)
|
|
229
|
+
Contract.response(outcome, cache_ages:)
|
|
230
|
+
rescue Html2rss::ContractError
|
|
231
|
+
raise
|
|
223
232
|
rescue StandardError => error
|
|
224
233
|
tool_error_response(error)
|
|
225
234
|
end
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
| `final_url` | URL after redirects (may differ from requested) |
|
|
11
11
|
| `status` | HTTP status of the **final** response |
|
|
12
12
|
| `scheme_downgrade` | `true` when HTTPS entry landed on HTTP |
|
|
13
|
-
| `alternate_feeds` | `rel=alternate` RSS/Atom links found in HTML |
|
|
13
|
+
| `alternate_feeds` | `rel=alternate` RSS/Atom/RDF links found in HTML |
|
|
14
14
|
| `surface_category` | AutoSource surface class (`high_entropy_surface`, `unsupported_surface`, …) |
|
|
15
15
|
| `articles_count` | Cheap AutoSource extract (limit 10) — diagnostic only, not ship quality |
|
|
16
16
|
| `strategy` | Concrete transport used (`inspect` maps `auto` → default (HTTPX)) |
|
|
@@ -57,6 +57,8 @@ module Html2rss
|
|
|
57
57
|
def self.batch(urls:, strategy: :auto, concurrency: Batch::DEFAULT_CONCURRENCY)
|
|
58
58
|
Batch.map(Array(urls), concurrency:) do |url|
|
|
59
59
|
call(url:, strategy:)
|
|
60
|
+
rescue Html2rss::ContractError
|
|
61
|
+
raise
|
|
60
62
|
rescue Html2rss::Error => error
|
|
61
63
|
error_report(url, error)
|
|
62
64
|
end
|
data/lib/html2rss/recon.rb
CHANGED
|
@@ -7,7 +7,7 @@ require 'fileutils'
|
|
|
7
7
|
module Html2rss
|
|
8
8
|
##
|
|
9
9
|
# Service that runs reconnaissance on a URL to discover redirect chains,
|
|
10
|
-
# native RSS/Atom feeds, surface categorization, and emit an actionable verdict.
|
|
10
|
+
# native RSS/Atom/RDF feeds, surface categorization, and emit an actionable verdict.
|
|
11
11
|
module Recon # rubocop:disable Metrics/ModuleLength
|
|
12
12
|
Verdict = Data.define(:name)
|
|
13
13
|
|
|
@@ -21,7 +21,7 @@ module Html2rss
|
|
|
21
21
|
##
|
|
22
22
|
# @param name [Symbol, String]
|
|
23
23
|
# @return [Verdict]
|
|
24
|
-
def self.[](name) = ALL.fetch(name.to_sym) { raise
|
|
24
|
+
def self.[](name) = ALL.fetch(name.to_sym) { raise InvalidOption, "unknown verdict: #{name.inspect}" }
|
|
25
25
|
|
|
26
26
|
##
|
|
27
27
|
# @return [Boolean]
|
|
@@ -109,6 +109,8 @@ module Html2rss
|
|
|
109
109
|
|
|
110
110
|
begin
|
|
111
111
|
probe = PageRecon.probe(url_obj, strategy:, **)
|
|
112
|
+
rescue Html2rss::ContractError
|
|
113
|
+
raise
|
|
112
114
|
rescue Html2rss::Error => error
|
|
113
115
|
return error_result(url_obj, error)
|
|
114
116
|
end
|
|
@@ -116,7 +118,7 @@ module Html2rss
|
|
|
116
118
|
cache_html_body(probe.response.body, url_obj, cache_dir, cache_mutex) if cache_dir && probe.response.body
|
|
117
119
|
|
|
118
120
|
recon = probe.result
|
|
119
|
-
native_feed, discovery_note = find_native_feed(
|
|
121
|
+
native_feed, discovery_note = find_native_feed(probe.session, probe.response)
|
|
120
122
|
notes = build_notes(recon, native_feed, probe.response)
|
|
121
123
|
notes << discovery_note if discovery_note
|
|
122
124
|
verdict = determine_verdict(recon, native_feed)
|
|
@@ -160,18 +162,14 @@ module Html2rss
|
|
|
160
162
|
end
|
|
161
163
|
|
|
162
164
|
##
|
|
163
|
-
# @param url_obj [Html2rss::Url]
|
|
164
165
|
# @param session [Html2rss::RequestSession]
|
|
165
|
-
# @param response [Html2rss::RequestService::Response]
|
|
166
|
+
# @param response [Html2rss::RequestService::Response] the fetched page, where any redirect landed
|
|
166
167
|
# @return [Array(Html2rss::Url, nil), String, nil)] feed URL and optional discovery error note
|
|
167
|
-
def self.find_native_feed(
|
|
168
|
-
feed = Syndication::Discovery.
|
|
169
|
-
page_url: url_obj,
|
|
170
|
-
request_session: session,
|
|
171
|
-
parsed_body: (response.parsed_body if response.html_response?),
|
|
172
|
-
html: response.body
|
|
173
|
-
)
|
|
168
|
+
def self.find_native_feed(session, response)
|
|
169
|
+
feed = Syndication::Discovery.native_feed_url(response:, request_session: session)
|
|
174
170
|
[feed, nil]
|
|
171
|
+
rescue Html2rss::ContractError
|
|
172
|
+
raise
|
|
175
173
|
rescue Html2rss::Error => error
|
|
176
174
|
[nil, "discovery_error=#{error.class}: #{error.message}"]
|
|
177
175
|
end
|
|
@@ -34,7 +34,8 @@ module Html2rss
|
|
|
34
34
|
/interstitial/i
|
|
35
35
|
],
|
|
36
36
|
message: 'Blocked surface detected: DataDome anti-bot challenge page. ' \
|
|
37
|
-
'Target a direct listing URL
|
|
37
|
+
'Target a direct listing URL. Clearance cookies from a successful Botasaurus scrape ' \
|
|
38
|
+
'are reused automatically; to supply your own, set request.botasaurus.cookies.'
|
|
38
39
|
},
|
|
39
40
|
{
|
|
40
41
|
key: :vercel_security_checkpoint,
|
|
@@ -11,7 +11,7 @@ module Html2rss
|
|
|
11
11
|
# Closed set from OpenAPI ExecutionMode.
|
|
12
12
|
EXECUTION_MODES = Set['auto', 'request', 'browser'].freeze
|
|
13
13
|
# Closed set from OpenAPI NavigationMode.
|
|
14
|
-
NAVIGATION_MODES = Set['auto', 'get', 'google_get', 'google_get_bypass'
|
|
14
|
+
NAVIGATION_MODES = Set['auto', 'get', 'google_get', 'google_get_bypass'].freeze
|
|
15
15
|
# Closed set from OpenAPI ErrorCategory.
|
|
16
16
|
ERROR_CATEGORIES = Set['timeout', 'challenge_block', 'navigation_error', 'metadata_error', 'validation'].freeze
|
|
17
17
|
|
|
@@ -84,6 +84,15 @@ module Html2rss
|
|
|
84
84
|
# @return [String, nil] final URL reported by upstream
|
|
85
85
|
def final_url = payload['final_url']
|
|
86
86
|
|
|
87
|
+
# Session credentials: keep out of logs, transport_meta and feed output.
|
|
88
|
+
# @return [Hash{String => String}] cookies earned for the target host (absent or malformed → {})
|
|
89
|
+
def cookies
|
|
90
|
+
raw = payload['cookies']
|
|
91
|
+
return {} unless raw.is_a?(Hash)
|
|
92
|
+
|
|
93
|
+
raw.select { |name, value| name.is_a?(String) && value.is_a?(String) }
|
|
94
|
+
end
|
|
95
|
+
|
|
87
96
|
# @return [Hash{String => Object}] diagnostics plus success-only metadata_error (frozen)
|
|
88
97
|
def transport_meta
|
|
89
98
|
meta = diagnostics.dup
|
|
@@ -258,6 +267,7 @@ module Html2rss
|
|
|
258
267
|
# @param url [Html2rss::Url] canonical URL to scrape
|
|
259
268
|
# @param headers [Hash] request headers from context
|
|
260
269
|
# @param options [Hash] validated request.botasaurus options
|
|
270
|
+
# @param stored_cookies [Hash{String => String}] cookies replayed from the cookie store
|
|
261
271
|
# @option options [String] :execution_mode
|
|
262
272
|
# @option options [String] :navigation_mode
|
|
263
273
|
# @option options [Integer] :max_retries
|
|
@@ -275,18 +285,23 @@ module Html2rss
|
|
|
275
285
|
# @option options [String] :lang
|
|
276
286
|
# @option options [Hash] :cookies
|
|
277
287
|
# @option options [Hash] :headers
|
|
278
|
-
def initialize(url:, headers: {}, options: {})
|
|
288
|
+
def initialize(url:, headers: {}, options: {}, stored_cookies: {})
|
|
279
289
|
@url = url
|
|
280
290
|
@headers = headers
|
|
281
291
|
@options = options
|
|
292
|
+
@stored_cookies = stored_cookies
|
|
282
293
|
end
|
|
283
294
|
|
|
295
|
+
# @return [String] omits options and stored cookies (session credentials)
|
|
296
|
+
def inspect = "#<#{self.class} url=#{url}>"
|
|
297
|
+
|
|
284
298
|
# @return [Hash] payload for POST /scrape (explicit options plus wait cap)
|
|
285
299
|
def request_payload
|
|
286
300
|
payload = { url: url.to_s }.merge(filtered_options)
|
|
287
301
|
forwarded_headers = merged_headers
|
|
288
302
|
payload[:headers] = forwarded_headers if forwarded_headers&.any?
|
|
289
303
|
cap_wait_timeout!(payload)
|
|
304
|
+
merge_cookies!(payload)
|
|
290
305
|
payload
|
|
291
306
|
end
|
|
292
307
|
|
|
@@ -302,7 +317,7 @@ module Html2rss
|
|
|
302
317
|
|
|
303
318
|
private
|
|
304
319
|
|
|
305
|
-
attr_reader :url, :headers, :options
|
|
320
|
+
attr_reader :url, :headers, :options, :stored_cookies
|
|
306
321
|
|
|
307
322
|
def json_object(body)
|
|
308
323
|
payload = JSON.parse(body)
|
|
@@ -326,6 +341,13 @@ module Html2rss
|
|
|
326
341
|
end
|
|
327
342
|
end
|
|
328
343
|
|
|
344
|
+
# Stored cookies are replayed in every execution_mode; cookies from the feed config win.
|
|
345
|
+
def merge_cookies!(payload)
|
|
346
|
+
configured = (options[:cookies] || {}).transform_keys(&:to_s)
|
|
347
|
+
merged = stored_cookies.merge(configured)
|
|
348
|
+
merged.any? ? payload[:cookies] = merged : payload.delete(:cookies)
|
|
349
|
+
end
|
|
350
|
+
|
|
329
351
|
def cap_wait_timeout!(payload)
|
|
330
352
|
wait = payload[:wait_timeout_seconds]
|
|
331
353
|
return unless wait
|
|
@@ -8,7 +8,7 @@ module Html2rss
|
|
|
8
8
|
class RequestService
|
|
9
9
|
##
|
|
10
10
|
# Strategy to delegate fetching to a Botasaurus scrape API.
|
|
11
|
-
class BotasaurusStrategy < Strategy
|
|
11
|
+
class BotasaurusStrategy < Strategy # rubocop:disable Metrics/ClassLength -- transport + cookie-jar retry stay co-located
|
|
12
12
|
# Content-Type negotiation for the scrape API transport hop.
|
|
13
13
|
TRANSPORT_ACCEPT = 'application/json'
|
|
14
14
|
# Disable compressed bodies so HTTPX returns raw JSON without implicit decoding surprises.
|
|
@@ -18,16 +18,49 @@ module Html2rss
|
|
|
18
18
|
|
|
19
19
|
private
|
|
20
20
|
|
|
21
|
+
# With a stored jar, tries the cheap HTTP tier first; cookies never reach the browser tier,
|
|
22
|
+
# so a block falls back to a plain scrape without them.
|
|
21
23
|
def fetch
|
|
22
|
-
|
|
24
|
+
stored = jar.stored
|
|
25
|
+
return scrape({}) unless replay?(stored)
|
|
26
|
+
|
|
27
|
+
replay(stored)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def replay?(stored) = botasaurus_options[:execution_mode] != 'browser' && jar.replayable?(stored)
|
|
31
|
+
|
|
32
|
+
def replay(stored)
|
|
33
|
+
response = scrape(stored, execution_mode: 'request')
|
|
34
|
+
jar.replayed(:pass, stored)
|
|
35
|
+
response
|
|
36
|
+
rescue BlockedSurfaceDetected
|
|
37
|
+
jar.replayed(:block, stored)
|
|
38
|
+
scrape({})
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def scrape(stored, overrides = {})
|
|
42
|
+
parsed_response = post_scrape_request(stored, overrides)
|
|
23
43
|
raise_from_error!(parsed_response) if parsed_response.is_a?(BotasaurusContract::Error)
|
|
24
44
|
|
|
25
|
-
build_response(parsed_response)
|
|
45
|
+
response = build_response(parsed_response)
|
|
46
|
+
ResponseGuard.new(policy: ctx.policy).inspect_body!(response.body)
|
|
47
|
+
jar.refresh(stored, response.cookies)
|
|
48
|
+
response
|
|
26
49
|
end
|
|
27
50
|
|
|
28
|
-
def
|
|
51
|
+
def jar
|
|
52
|
+
@jar ||= CookieJar.new(
|
|
53
|
+
host: ctx.url.host, scraper_base_url: scraper_base_url.to_s, proxy: botasaurus_options[:proxy],
|
|
54
|
+
user_agent: botasaurus_options[:user_agent] || ctx.headers['User-Agent']
|
|
55
|
+
)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def botasaurus_options = ctx.request.fetch(:botasaurus, {})
|
|
59
|
+
|
|
60
|
+
def post_scrape_request(stored, overrides)
|
|
29
61
|
request_id = SecureRandom.uuid
|
|
30
62
|
Log.debug("#{self.class}: POST /scrape #{REQUEST_ID_HEADER}=#{request_id}")
|
|
63
|
+
contract = contract(stored, overrides)
|
|
31
64
|
payload = JSON.generate(contract.request_payload)
|
|
32
65
|
response = client.post(scrape_endpoint, body: payload, headers: post_headers(request_id))
|
|
33
66
|
raise response.error if response.is_a?(HTTPX::ErrorResponse)
|
|
@@ -44,7 +77,8 @@ module Html2rss
|
|
|
44
77
|
url: response_url(parsed_response.final_url),
|
|
45
78
|
status: parsed_response.status,
|
|
46
79
|
transport_meta: parsed_response.transport_meta,
|
|
47
|
-
captured_responses: parsed_response.xhr_responses
|
|
80
|
+
captured_responses: parsed_response.xhr_responses,
|
|
81
|
+
cookies: parsed_response.cookies
|
|
48
82
|
)
|
|
49
83
|
end
|
|
50
84
|
|
|
@@ -69,11 +103,9 @@ module Html2rss
|
|
|
69
103
|
ctx.url
|
|
70
104
|
end
|
|
71
105
|
|
|
72
|
-
def contract
|
|
73
|
-
|
|
74
|
-
url: ctx.url,
|
|
75
|
-
headers: ctx.headers,
|
|
76
|
-
options: ctx.request.fetch(:botasaurus, {})
|
|
106
|
+
def contract(stored, overrides)
|
|
107
|
+
BotasaurusContract.new(
|
|
108
|
+
url: ctx.url, headers: ctx.headers, options: botasaurus_options.merge(overrides), stored_cookies: stored
|
|
77
109
|
)
|
|
78
110
|
end
|
|
79
111
|
|
|
@@ -6,6 +6,9 @@ module Html2rss
|
|
|
6
6
|
# Holds information needed to send requests to websites.
|
|
7
7
|
# To be passed down to the RequestService's strategies.
|
|
8
8
|
class Context
|
|
9
|
+
# Raised when a context is built without its runtime policy or request budget.
|
|
10
|
+
class InvalidError < Html2rss::ContractError; end
|
|
11
|
+
|
|
9
12
|
##
|
|
10
13
|
# @param url [String, Html2rss::Url] the URL to request
|
|
11
14
|
# @param headers [Hash] HTTP request headers
|
|
@@ -15,7 +18,8 @@ module Html2rss
|
|
|
15
18
|
# @option request_options [String, Html2rss::Url, nil] :origin_url originating URL for same-origin checks
|
|
16
19
|
# @option request_options [Policy] :policy runtime request policy
|
|
17
20
|
# @option request_options [Budget] :budget shared request budget for the feed build
|
|
18
|
-
# @
|
|
21
|
+
# @option request_options [FeedAdvertisement, nil] :advertisement page proof for an +:advertised_feed+ follow-up
|
|
22
|
+
# @raise [InvalidError] if policy or budget is nil or budget is missing
|
|
19
23
|
def initialize(url:, headers: {}, request: {}, **request_options)
|
|
20
24
|
@url = Html2rss::Url.from_absolute(url)
|
|
21
25
|
@headers = normalize_headers(headers).freeze
|
|
@@ -44,23 +48,19 @@ module Html2rss
|
|
|
44
48
|
# @return [Budget] the shared request budget
|
|
45
49
|
attr_reader :budget
|
|
46
50
|
|
|
51
|
+
# @return [FeedAdvertisement, nil] the page's advertised feeds, for an +:advertised_feed+ follow-up
|
|
52
|
+
attr_reader :advertisement
|
|
53
|
+
|
|
47
54
|
##
|
|
48
55
|
# Builds a follow-up request context sharing headers, budget, and policy.
|
|
49
56
|
#
|
|
50
57
|
# @param url [String, Html2rss::Url] the follow-up URL
|
|
51
58
|
# @param relation [Symbol] why the follow-up is being made
|
|
52
59
|
# @param origin_url [String, Html2rss::Url] effective origin for same-origin checks
|
|
60
|
+
# @param advertisement [FeedAdvertisement, nil] page proof for an +:advertised_feed+ follow-up
|
|
53
61
|
# @return [Context] derived request context
|
|
54
|
-
def follow_up(url:, relation:, origin_url: self.origin_url)
|
|
55
|
-
self.class.new(
|
|
56
|
-
url:,
|
|
57
|
-
headers:,
|
|
58
|
-
request:,
|
|
59
|
-
relation:,
|
|
60
|
-
origin_url:,
|
|
61
|
-
policy:,
|
|
62
|
-
budget:
|
|
63
|
-
)
|
|
62
|
+
def follow_up(url:, relation:, origin_url: self.origin_url, advertisement: nil)
|
|
63
|
+
self.class.new(url:, headers:, request:, relation:, origin_url:, policy:, budget:, advertisement:)
|
|
64
64
|
end
|
|
65
65
|
|
|
66
66
|
private
|
|
@@ -68,11 +68,12 @@ module Html2rss
|
|
|
68
68
|
def assign_request_options(request_options)
|
|
69
69
|
@relation = request_options.fetch(:relation, :initial)
|
|
70
70
|
@policy = request_options.fetch(:policy, Policy.default)
|
|
71
|
-
raise
|
|
71
|
+
raise InvalidError, 'policy must not be nil' if @policy.nil?
|
|
72
72
|
|
|
73
73
|
@origin_url = normalized_origin_url(request_options[:origin_url])
|
|
74
|
-
@
|
|
75
|
-
raise
|
|
74
|
+
@advertisement = request_options[:advertisement]
|
|
75
|
+
@budget = request_options.fetch(:budget) { raise InvalidError, 'budget is required' }
|
|
76
|
+
raise InvalidError, 'budget must not be nil' if @budget.nil?
|
|
76
77
|
end
|
|
77
78
|
|
|
78
79
|
def normalized_origin_url(origin_url)
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Html2rss
|
|
4
|
+
class RequestService
|
|
5
|
+
##
|
|
6
|
+
# One host/egress-scoped view onto the {CookieStore}: replay, refresh and evict with count-only telemetry.
|
|
7
|
+
# Cookie names and values are session credentials and never reach logs.
|
|
8
|
+
class CookieJar
|
|
9
|
+
# Share of replayable requests that deliberately skip replay, so the pass-rate effect stays measurable.
|
|
10
|
+
HOLDOUT_RATE = Float(ENV.fetch('BOTASAURUS_COOKIE_HOLDOUT_RATE', 0.1))
|
|
11
|
+
# Shared across jars so blocks accumulate per key within the process.
|
|
12
|
+
BREAKER = ReplayBreaker.new
|
|
13
|
+
|
|
14
|
+
##
|
|
15
|
+
# @param host [String] target host
|
|
16
|
+
# @param scraper_base_url [String] scrape-api base URL
|
|
17
|
+
# @param proxy [String, nil] egress proxy
|
|
18
|
+
# @param user_agent [String, nil] browser user agent
|
|
19
|
+
# @param store [#fetch, #write, #evict] the configured cookie store
|
|
20
|
+
def initialize(host:, scraper_base_url:, proxy:, user_agent:, store: RequestService.cookie_store)
|
|
21
|
+
@store = store
|
|
22
|
+
@host = host
|
|
23
|
+
@key = CookieStore.key(host:, scraper_base_url:, proxy:, user_agent:)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# @return [Hash{String => String}] live stored cookies ({} when none)
|
|
27
|
+
def stored = store.fetch(key)
|
|
28
|
+
|
|
29
|
+
##
|
|
30
|
+
# @param stored [Hash{String => String}] cookies in the jar
|
|
31
|
+
# @return [Boolean] whether to replay them (not when empty, the breaker is open, or in the holdout)
|
|
32
|
+
def replayable?(stored)
|
|
33
|
+
return false if stored.empty?
|
|
34
|
+
|
|
35
|
+
reason = skip_reason
|
|
36
|
+
log(reason, 0) if reason
|
|
37
|
+
reason.nil?
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
##
|
|
41
|
+
# Records what a replay led to: a pass resets the breaker; a block evicts the jar and counts toward it.
|
|
42
|
+
#
|
|
43
|
+
# @param outcome [Symbol] +:pass+ or +:block+
|
|
44
|
+
# @param stored [Hash{String => String}] cookies that were replayed
|
|
45
|
+
# @return [void]
|
|
46
|
+
def replayed(outcome, stored)
|
|
47
|
+
log("replay_#{outcome}", stored.size)
|
|
48
|
+
return BREAKER.record_pass(key) if outcome == :pass
|
|
49
|
+
|
|
50
|
+
evict(stored)
|
|
51
|
+
log('breaker_open', 0) if BREAKER.record_block(key, ttl: CookieStore::DEFAULT_TTL_SECONDS)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
##
|
|
55
|
+
# @param stored [Hash{String => String}] cookies that were replayed
|
|
56
|
+
# @param earned [Hash{String => String}] cookies returned by the scrape
|
|
57
|
+
# @return [void]
|
|
58
|
+
def refresh(stored, earned)
|
|
59
|
+
ttl = CookieStore::DEFAULT_TTL_SECONDS
|
|
60
|
+
return if earned.empty? || ttl <= 0
|
|
61
|
+
|
|
62
|
+
store.write(key, stored.merge(earned), ttl:)
|
|
63
|
+
log('refresh', earned.size)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
##
|
|
67
|
+
# @param stored [Hash{String => String}] cookies that were rejected
|
|
68
|
+
# @return [void]
|
|
69
|
+
def evict(stored)
|
|
70
|
+
store.evict(key)
|
|
71
|
+
log('evict', stored.size)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
attr_reader :store, :key, :host
|
|
77
|
+
|
|
78
|
+
def skip_reason
|
|
79
|
+
return 'breaker_open' if BREAKER.open?(key)
|
|
80
|
+
|
|
81
|
+
'holdout' if rand < HOLDOUT_RATE
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def log(action, count)
|
|
85
|
+
Log.info({ event_name: 'botasaurus.cookie_jar', action:, host:, cookie_count: count }.to_json)
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|