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
|
@@ -29,6 +29,24 @@ module Html2rss
|
|
|
29
29
|
#
|
|
30
30
|
# See the doc on [String#gsub](https://ruby-doc.org/core/String.html#method-i-gsub) for more info.
|
|
31
31
|
class Gsub < Base
|
|
32
|
+
# Required config field types (validator introspection via +Options+).
|
|
33
|
+
OPTION_TYPES = { pattern: String, replacement: String }.freeze
|
|
34
|
+
|
|
35
|
+
# Config fields required by this post-processor (validator / schema introspection).
|
|
36
|
+
Options = Struct.new(*OPTION_TYPES.keys, keyword_init: true)
|
|
37
|
+
|
|
38
|
+
# JSON Schema description exported via +schema_doc+.
|
|
39
|
+
DESCRIPTION = 'Replace matches of `pattern` in the extracted string with `replacement` ' \
|
|
40
|
+
'(Ruby String#gsub; pattern may be a regexp-like string).'
|
|
41
|
+
|
|
42
|
+
# Example post-process objects for JSON Schema +examples+.
|
|
43
|
+
EXAMPLES = [
|
|
44
|
+
{ 'name' => 'gsub', 'pattern' => 'boo', 'replacement' => 'baz' }
|
|
45
|
+
].freeze
|
|
46
|
+
|
|
47
|
+
# @return [Hash{Symbol => Object}] JSON Schema fragment for this post-processor
|
|
48
|
+
def self.schema_doc = SchemaDoc.for_post_processor(name: :gsub, klass: self)
|
|
49
|
+
|
|
32
50
|
# @param value [String] extracted selector value
|
|
33
51
|
# @param context [Selectors::Context] post-processor context
|
|
34
52
|
# @return [void]
|
|
@@ -28,6 +28,17 @@ module Html2rss
|
|
|
28
28
|
# Would return:
|
|
29
29
|
# 'Lorem **ipsum** dolor'
|
|
30
30
|
class HtmlToMarkdown < Base
|
|
31
|
+
# JSON Schema description exported via +schema_doc+.
|
|
32
|
+
DESCRIPTION = 'Sanitize HTML then convert it to Markdown (via ReverseMarkdown).'
|
|
33
|
+
|
|
34
|
+
# Example post-process objects for JSON Schema +examples+.
|
|
35
|
+
EXAMPLES = [
|
|
36
|
+
{ 'name' => 'html_to_markdown' }
|
|
37
|
+
].freeze
|
|
38
|
+
|
|
39
|
+
# @return [Hash{Symbol => Object}] JSON Schema fragment for this post-processor
|
|
40
|
+
def self.schema_doc = SchemaDoc.for_post_processor(name: :html_to_markdown, klass: self)
|
|
41
|
+
|
|
31
42
|
# @param value [String] extracted selector value
|
|
32
43
|
# @param context [Selectors::Context] post-processor context
|
|
33
44
|
# @return [void]
|
|
@@ -33,6 +33,18 @@ module Html2rss
|
|
|
33
33
|
#
|
|
34
34
|
# <p>Price: 12.34</p>
|
|
35
35
|
class MarkdownToHtml < Base
|
|
36
|
+
# JSON Schema description exported via +schema_doc+.
|
|
37
|
+
DESCRIPTION = 'Convert Markdown to HTML (Kramdown) and sanitize the result. ' \
|
|
38
|
+
'Often chained after `template`.'
|
|
39
|
+
|
|
40
|
+
# Example post-process objects for JSON Schema +examples+.
|
|
41
|
+
EXAMPLES = [
|
|
42
|
+
{ 'name' => 'markdown_to_html' }
|
|
43
|
+
].freeze
|
|
44
|
+
|
|
45
|
+
# @return [Hash{Symbol => Object}] JSON Schema fragment for this post-processor
|
|
46
|
+
def self.schema_doc = SchemaDoc.for_post_processor(name: :markdown_to_html, klass: self)
|
|
47
|
+
|
|
36
48
|
# @param value [String] extracted selector value
|
|
37
49
|
# @param context [Selectors::Context] post-processor context
|
|
38
50
|
# @return [void]
|
|
@@ -27,6 +27,17 @@ module Html2rss
|
|
|
27
27
|
#
|
|
28
28
|
# It uses `Time.parse`.
|
|
29
29
|
class ParseTime < Base
|
|
30
|
+
# JSON Schema description exported via +schema_doc+.
|
|
31
|
+
DESCRIPTION = 'Parse a time string with Time.parse and return RFC822, using the channel `time_zone`.'
|
|
32
|
+
|
|
33
|
+
# Example post-process objects for JSON Schema +examples+.
|
|
34
|
+
EXAMPLES = [
|
|
35
|
+
{ 'name' => 'parse_time' }
|
|
36
|
+
].freeze
|
|
37
|
+
|
|
38
|
+
# @return [Hash{Symbol => Object}] JSON Schema fragment for this post-processor
|
|
39
|
+
def self.schema_doc = SchemaDoc.for_post_processor(name: :parse_time, klass: self)
|
|
40
|
+
|
|
30
41
|
# @param value [String] extracted selector value
|
|
31
42
|
# @param context [Selectors::Context] post-processor context
|
|
32
43
|
# @return [void]
|
|
@@ -23,6 +23,17 @@ module Html2rss
|
|
|
23
23
|
# Would return:
|
|
24
24
|
# 'http://why-not-use-a-link.uh'
|
|
25
25
|
class ParseUri < Base
|
|
26
|
+
# JSON Schema description exported via +schema_doc+.
|
|
27
|
+
DESCRIPTION = 'Normalize a URL string; resolve relative URLs against the channel URL.'
|
|
28
|
+
|
|
29
|
+
# Example post-process objects for JSON Schema +examples+.
|
|
30
|
+
EXAMPLES = [
|
|
31
|
+
{ 'name' => 'parse_uri' }
|
|
32
|
+
].freeze
|
|
33
|
+
|
|
34
|
+
# @return [Hash{Symbol => Object}] JSON Schema fragment for this post-processor
|
|
35
|
+
def self.schema_doc = SchemaDoc.for_post_processor(name: :parse_uri, klass: self)
|
|
36
|
+
|
|
26
37
|
# @param value [String] extracted selector value
|
|
27
38
|
# @param _context [Selectors::Context] post-processor context
|
|
28
39
|
# @return [void]
|
|
@@ -38,6 +38,15 @@ module Html2rss
|
|
|
38
38
|
# Would return:
|
|
39
39
|
# '<p>Lorem <b>ipsum</b> dolor ...</p>'
|
|
40
40
|
class SanitizeHtml < Base
|
|
41
|
+
# JSON Schema description exported via +schema_doc+.
|
|
42
|
+
DESCRIPTION = 'Sanitize HTML (sanitize gem RELAXED plus html2rss defaults: absolute URLs, ' \
|
|
43
|
+
'safe link/img attributes, wrap lone images in anchors).'
|
|
44
|
+
|
|
45
|
+
# Example post-process objects for JSON Schema +examples+.
|
|
46
|
+
EXAMPLES = [
|
|
47
|
+
{ 'name' => 'sanitize_html' }
|
|
48
|
+
].freeze
|
|
49
|
+
|
|
41
50
|
# @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Referrer-Policy
|
|
42
51
|
TAG_ATTRIBUTES = {
|
|
43
52
|
'a' => {
|
|
@@ -81,6 +90,10 @@ module Html2rss
|
|
|
81
90
|
'preload' => 'none'
|
|
82
91
|
}
|
|
83
92
|
}.freeze
|
|
93
|
+
|
|
94
|
+
# @return [Hash{Symbol => Object}] JSON Schema fragment for this post-processor
|
|
95
|
+
def self.schema_doc = SchemaDoc.for_post_processor(name: :sanitize_html, klass: self)
|
|
96
|
+
|
|
84
97
|
# @param value [String] extracted selector value
|
|
85
98
|
# @param context [Selectors::Context] post-processor context
|
|
86
99
|
# @return [void]
|
|
@@ -30,6 +30,27 @@ module Html2rss
|
|
|
30
30
|
# Would return:
|
|
31
31
|
# 'bar'
|
|
32
32
|
class Substring < Base
|
|
33
|
+
# Required config field types (validator introspection via +Options+).
|
|
34
|
+
OPTION_TYPES = { start: Integer }.freeze
|
|
35
|
+
|
|
36
|
+
# Optional config field types (validated when the key is present and non-nil).
|
|
37
|
+
OPTIONAL_OPTION_TYPES = { end: Integer }.freeze
|
|
38
|
+
|
|
39
|
+
# Config fields required by this post-processor (validator / schema introspection).
|
|
40
|
+
Options = Struct.new(*OPTION_TYPES.keys, keyword_init: true)
|
|
41
|
+
|
|
42
|
+
# JSON Schema description exported via +schema_doc+.
|
|
43
|
+
DESCRIPTION = 'Return a slice of the extracted string using Integer `start` and optional `end` ' \
|
|
44
|
+
'(Ruby String#[] range semantics; end may be omitted).'
|
|
45
|
+
|
|
46
|
+
# Example post-process objects for JSON Schema +examples+.
|
|
47
|
+
EXAMPLES = [
|
|
48
|
+
{ 'name' => 'substring', 'start' => 4, 'end' => 6 }
|
|
49
|
+
].freeze
|
|
50
|
+
|
|
51
|
+
# @return [Hash{Symbol => Object}] JSON Schema fragment for this post-processor
|
|
52
|
+
def self.schema_doc = SchemaDoc.for_post_processor(name: :substring, klass: self)
|
|
53
|
+
|
|
33
54
|
# @param value [String] extracted selector value
|
|
34
55
|
# @param context [Selectors::Context] post-processor context
|
|
35
56
|
# @return [void]
|
|
@@ -34,6 +34,26 @@ module Html2rss
|
|
|
34
34
|
# Would return:
|
|
35
35
|
# 'Product (23,42€)'
|
|
36
36
|
class Template < Base
|
|
37
|
+
# Required config field types (validator introspection via +Options+).
|
|
38
|
+
OPTION_TYPES = { string: String }.freeze
|
|
39
|
+
|
|
40
|
+
# Config fields required by this post-processor (validator / schema introspection).
|
|
41
|
+
Options = Struct.new(*OPTION_TYPES.keys, keyword_init: true)
|
|
42
|
+
|
|
43
|
+
# JSON Schema description exported via +schema_doc+.
|
|
44
|
+
# rubocop:disable Style/FormatStringToken -- documents Kernel#format `%{key}` placeholders
|
|
45
|
+
DESCRIPTION = 'Format a string with Kernel#format-style placeholders (`%{key}` / `%<key>s`). ' \
|
|
46
|
+
'`%{self}` is the current selector value; other keys resolve sibling selectors.'
|
|
47
|
+
|
|
48
|
+
# Example post-process objects for JSON Schema +examples+.
|
|
49
|
+
EXAMPLES = [
|
|
50
|
+
{ 'name' => 'template', 'string' => '`%{self}` (`%{price}`)' }
|
|
51
|
+
].freeze
|
|
52
|
+
# rubocop:enable Style/FormatStringToken
|
|
53
|
+
|
|
54
|
+
# @return [Hash{Symbol => Object}] JSON Schema fragment for this post-processor
|
|
55
|
+
def self.schema_doc = SchemaDoc.for_post_processor(name: :template, klass: self)
|
|
56
|
+
|
|
37
57
|
# @param value [String] extracted selector value
|
|
38
58
|
# @param context [Selectors::Context] post-processor context
|
|
39
59
|
# @return [void]
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Html2rss
|
|
4
|
+
class Selectors
|
|
5
|
+
##
|
|
6
|
+
# Builds JSON Schema fragments from extractor / post-processor class constants.
|
|
7
|
+
#
|
|
8
|
+
# Each registry class owns +DESCRIPTION+, +EXAMPLES+, and optional +OPTION_TYPES+;
|
|
9
|
+
# this module is the export adapter only.
|
|
10
|
+
module SchemaDoc
|
|
11
|
+
# Maps Ruby option types to JSON Schema +type+ strings.
|
|
12
|
+
RUBY_TO_JSON_TYPE = {
|
|
13
|
+
String => 'string',
|
|
14
|
+
Integer => 'integer',
|
|
15
|
+
Hash => 'object',
|
|
16
|
+
Array => 'array'
|
|
17
|
+
}.freeze
|
|
18
|
+
|
|
19
|
+
module_function
|
|
20
|
+
|
|
21
|
+
##
|
|
22
|
+
# @param name [Symbol, String] registry key
|
|
23
|
+
# @param klass [Class] post-processor class
|
|
24
|
+
# @return [Hash{Symbol => Object}] JSON Schema object for one post-processor
|
|
25
|
+
def for_post_processor(name:, klass:)
|
|
26
|
+
name = name.to_s
|
|
27
|
+
{
|
|
28
|
+
type: 'object',
|
|
29
|
+
title: name,
|
|
30
|
+
description: klass::DESCRIPTION,
|
|
31
|
+
examples: klass::EXAMPLES,
|
|
32
|
+
properties: post_processor_properties(name, klass),
|
|
33
|
+
required: post_processor_required(klass),
|
|
34
|
+
additionalProperties: true
|
|
35
|
+
}
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
##
|
|
39
|
+
# @param name [Symbol, String] registry key
|
|
40
|
+
# @param klass [Class] extractor class
|
|
41
|
+
# @return [Hash{Symbol => Object}] JSON Schema for one extractor name
|
|
42
|
+
def for_extractor(name:, klass:)
|
|
43
|
+
name = name.to_s
|
|
44
|
+
{
|
|
45
|
+
type: 'string',
|
|
46
|
+
const: name,
|
|
47
|
+
title: name,
|
|
48
|
+
description: klass::DESCRIPTION,
|
|
49
|
+
examples: klass::EXAMPLES
|
|
50
|
+
}
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
##
|
|
54
|
+
# @param name [String] registry key
|
|
55
|
+
# @param klass [Class]
|
|
56
|
+
# @return [Hash{Symbol => Hash}]
|
|
57
|
+
def post_processor_properties(name, klass)
|
|
58
|
+
properties = { name: { type: 'string', const: name } }
|
|
59
|
+
option_types_for(klass).each do |field, ruby_type|
|
|
60
|
+
properties[field] = { type: json_type_for(ruby_type) }
|
|
61
|
+
end
|
|
62
|
+
properties
|
|
63
|
+
end
|
|
64
|
+
module_function :post_processor_properties
|
|
65
|
+
|
|
66
|
+
##
|
|
67
|
+
# @param klass [Class]
|
|
68
|
+
# @return [Array<String>]
|
|
69
|
+
def post_processor_required(klass)
|
|
70
|
+
required = ['name']
|
|
71
|
+
return required unless klass.const_defined?(:OPTION_TYPES)
|
|
72
|
+
|
|
73
|
+
required + klass::OPTION_TYPES.keys.map(&:to_s)
|
|
74
|
+
end
|
|
75
|
+
module_function :post_processor_required
|
|
76
|
+
|
|
77
|
+
##
|
|
78
|
+
# @param klass [Class]
|
|
79
|
+
# @return [Hash{Symbol => Class}]
|
|
80
|
+
def option_types_for(klass)
|
|
81
|
+
types = {}
|
|
82
|
+
types.merge!(klass::OPTION_TYPES) if klass.const_defined?(:OPTION_TYPES)
|
|
83
|
+
types.merge!(klass::OPTIONAL_OPTION_TYPES) if klass.const_defined?(:OPTIONAL_OPTION_TYPES)
|
|
84
|
+
types
|
|
85
|
+
end
|
|
86
|
+
module_function :option_types_for
|
|
87
|
+
|
|
88
|
+
##
|
|
89
|
+
# @param ruby_type [Class]
|
|
90
|
+
# @return [String]
|
|
91
|
+
def json_type_for(ruby_type)
|
|
92
|
+
RUBY_TO_JSON_TYPE.fetch(ruby_type) do
|
|
93
|
+
raise ArgumentError, "unsupported OPTION_TYPES mapping for #{ruby_type}"
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
module_function :json_type_for
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
data/lib/html2rss/selectors.rb
CHANGED
data/lib/html2rss/status.rb
CHANGED
|
@@ -8,7 +8,8 @@ module Html2rss
|
|
|
8
8
|
# Stable telemetry payload for cross-repo consumers (e.g. html2rss-web observability).
|
|
9
9
|
# Tallies and counters are validated and frozen at construction (including Marshal load).
|
|
10
10
|
Status = Data.define(
|
|
11
|
-
:version, :scraper_tallies, :dedup_dropped, :selected_strategy, :attempt_count,
|
|
11
|
+
:version, :scraper_tallies, :dedup_dropped, :selected_strategy, :attempt_count,
|
|
12
|
+
:strategy_attempts, :admission_drops
|
|
12
13
|
) do
|
|
13
14
|
class << self
|
|
14
15
|
##
|
|
@@ -19,8 +20,11 @@ module Html2rss
|
|
|
19
20
|
# @param selected_strategy [Symbol, nil] concrete strategy that succeeded under +:auto+ (else +nil+)
|
|
20
21
|
# @param attempt_count [Integer] auto-fallback attempt count (0 when not under +:auto+)
|
|
21
22
|
# @param strategy_attempts [Array<Hash>] auto-fallback attempt hashes (empty outside +:auto+)
|
|
23
|
+
# @param admission_drops [Hash{String => Integer}] Cleanup reason → count (empty when unused)
|
|
22
24
|
# @return [Html2rss::Status]
|
|
23
|
-
|
|
25
|
+
# rubocop:disable Metrics/ParameterLists -- Status kwargs stay co-located
|
|
26
|
+
def build(articles:, dedup_dropped: 0, selected_strategy: nil, attempt_count: 0,
|
|
27
|
+
strategy_attempts: [], admission_drops: {})
|
|
24
28
|
tallies = articles.filter_map(&:scraper).tally.transform_keys { |klass| scraper_name(klass) }
|
|
25
29
|
new(
|
|
26
30
|
version: Html2rss::VERSION,
|
|
@@ -28,9 +32,11 @@ module Html2rss
|
|
|
28
32
|
dedup_dropped:,
|
|
29
33
|
selected_strategy:,
|
|
30
34
|
attempt_count:,
|
|
31
|
-
strategy_attempts
|
|
35
|
+
strategy_attempts:,
|
|
36
|
+
admission_drops:
|
|
32
37
|
)
|
|
33
38
|
end
|
|
39
|
+
# rubocop:enable Metrics/ParameterLists
|
|
34
40
|
|
|
35
41
|
##
|
|
36
42
|
# @param klass [Class, #to_s] scraper class
|
|
@@ -47,9 +53,11 @@ module Html2rss
|
|
|
47
53
|
# @param selected_strategy [Symbol, nil]
|
|
48
54
|
# @param attempt_count [Integer]
|
|
49
55
|
# @param strategy_attempts [Array<Hash>]
|
|
56
|
+
# @param admission_drops [Hash{String => Integer}]
|
|
50
57
|
# rubocop:disable Metrics/ParameterLists, Metrics/MethodLength -- Status Data.define members
|
|
51
58
|
def initialize(
|
|
52
|
-
version:, scraper_tallies:, dedup_dropped:, selected_strategy: nil, attempt_count: 0,
|
|
59
|
+
version:, scraper_tallies:, dedup_dropped:, selected_strategy: nil, attempt_count: 0,
|
|
60
|
+
strategy_attempts: [], admission_drops: {}
|
|
53
61
|
)
|
|
54
62
|
dedup = Integer(dedup_dropped)
|
|
55
63
|
attempts = Integer(attempt_count)
|
|
@@ -61,7 +69,8 @@ module Html2rss
|
|
|
61
69
|
dedup_dropped: dedup,
|
|
62
70
|
selected_strategy:,
|
|
63
71
|
attempt_count: attempts,
|
|
64
|
-
strategy_attempts: freeze_attempts(strategy_attempts)
|
|
72
|
+
strategy_attempts: freeze_attempts(strategy_attempts),
|
|
73
|
+
admission_drops: freeze_tallies(admission_drops)
|
|
65
74
|
)
|
|
66
75
|
end
|
|
67
76
|
# rubocop:enable Metrics/ParameterLists, Metrics/MethodLength
|
|
@@ -69,11 +78,13 @@ module Html2rss
|
|
|
69
78
|
##
|
|
70
79
|
# Observability hash for web (+scraper_status+). Omits empty/absent optional keys:
|
|
71
80
|
# +:scraper_tallies+ when empty, +:selected_strategy+ when +nil+, +:attempt_count+ when zero,
|
|
72
|
-
# +:strategy_attempts+ when empty.
|
|
81
|
+
# +:strategy_attempts+ / +:admission_drops+ when empty.
|
|
73
82
|
# Data members remain available via readers even when omitted here.
|
|
74
83
|
#
|
|
75
84
|
# @return [Hash{Symbol => Object}] always +:version+ (String), +:dedup_dropped+ (Integer);
|
|
76
|
-
# optionally +:scraper_tallies+, +:selected_strategy+, +:attempt_count+,
|
|
85
|
+
# optionally +:scraper_tallies+, +:selected_strategy+, +:attempt_count+,
|
|
86
|
+
# +:strategy_attempts+, +:admission_drops+
|
|
87
|
+
# rubocop:disable Metrics/AbcSize -- omit-empty optional keys stay explicit
|
|
77
88
|
def to_h
|
|
78
89
|
{
|
|
79
90
|
version:,
|
|
@@ -81,9 +92,11 @@ module Html2rss
|
|
|
81
92
|
**(scraper_tallies.any? ? { scraper_tallies: } : {}),
|
|
82
93
|
**(selected_strategy.nil? ? {} : { selected_strategy: }),
|
|
83
94
|
**(attempt_count.positive? ? { attempt_count: } : {}),
|
|
84
|
-
**(strategy_attempts.any? ? { strategy_attempts: } : {})
|
|
95
|
+
**(strategy_attempts.any? ? { strategy_attempts: } : {}),
|
|
96
|
+
**(admission_drops.any? ? { admission_drops: } : {})
|
|
85
97
|
}
|
|
86
98
|
end
|
|
99
|
+
# rubocop:enable Metrics/AbcSize
|
|
87
100
|
|
|
88
101
|
##
|
|
89
102
|
# Formats the RSS +generator+ string and JSON Feed +user_comment+.
|
|
@@ -101,11 +114,14 @@ module Html2rss
|
|
|
101
114
|
private
|
|
102
115
|
|
|
103
116
|
def marshal_dump
|
|
104
|
-
[version, scraper_tallies, dedup_dropped, selected_strategy, attempt_count, strategy_attempts
|
|
117
|
+
[version, scraper_tallies, dedup_dropped, selected_strategy, attempt_count, strategy_attempts,
|
|
118
|
+
admission_drops]
|
|
105
119
|
end
|
|
106
120
|
|
|
107
|
-
def marshal_load((version, scraper_tallies, dedup_dropped, selected_strategy, attempt_count,
|
|
108
|
-
|
|
121
|
+
def marshal_load((version, scraper_tallies, dedup_dropped, selected_strategy, attempt_count,
|
|
122
|
+
strategy_attempts, admission_drops))
|
|
123
|
+
initialize(version:, scraper_tallies:, dedup_dropped:, selected_strategy:, attempt_count:,
|
|
124
|
+
strategy_attempts:, admission_drops: admission_drops || {})
|
|
109
125
|
end
|
|
110
126
|
|
|
111
127
|
def freeze_tallies(tallies)
|
data/lib/html2rss/url.rb
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require 'addressable/uri'
|
|
4
4
|
require 'cgi'
|
|
5
|
+
require 'ipaddr'
|
|
5
6
|
require 'nokogiri'
|
|
6
7
|
|
|
7
8
|
module Html2rss
|
|
@@ -21,6 +22,7 @@ module Html2rss
|
|
|
21
22
|
# url = Url.from_relative('/foo-bar/baz.txt', 'https://example.com')
|
|
22
23
|
# url.titleized # => "Foo Bar Baz"
|
|
23
24
|
# url.channel_titleized # => "example.com: Foo Bar Baz"
|
|
25
|
+
# rubocop:disable Metrics/ClassLength -- value object owns resolve/sanitize/titleize/identity
|
|
24
26
|
class Url
|
|
25
27
|
include Comparable
|
|
26
28
|
|
|
@@ -151,6 +153,16 @@ module Html2rss
|
|
|
151
153
|
# @return [String, nil] URI host component
|
|
152
154
|
def host = @uri.host
|
|
153
155
|
|
|
156
|
+
# Registrable domain (eTLD+1). Same-site hosts like www.wp.pl and wiadomosci.wp.pl
|
|
157
|
+
# share `wp.pl`. IP literals stay the host so 1.2.3.4 is not treated as `3.4`.
|
|
158
|
+
#
|
|
159
|
+
# @return [String, nil]
|
|
160
|
+
def domain
|
|
161
|
+
return host if ip_address_host?
|
|
162
|
+
|
|
163
|
+
@uri.domain || host
|
|
164
|
+
end
|
|
165
|
+
|
|
154
166
|
# @return [Integer, nil] URI port component
|
|
155
167
|
def port = @uri.port
|
|
156
168
|
|
|
@@ -189,6 +201,14 @@ module Html2rss
|
|
|
189
201
|
self.class.from_absolute(uri.normalize.to_s)
|
|
190
202
|
end
|
|
191
203
|
|
|
204
|
+
##
|
|
205
|
+
# Returns a copy of the URL with the fragment removed (dedup / self-link identity).
|
|
206
|
+
#
|
|
207
|
+
# @return [Url] this URL when already fragment-free; otherwise a new URL
|
|
208
|
+
def without_fragment
|
|
209
|
+
@uri.fragment ? self.class.from_absolute(@uri.omit(:fragment).normalize.to_s) : self
|
|
210
|
+
end
|
|
211
|
+
|
|
192
212
|
##
|
|
193
213
|
# Returns a copy of the URL with the provided query values.
|
|
194
214
|
#
|
|
@@ -259,13 +279,7 @@ module Html2rss
|
|
|
259
279
|
# @param other [Object] the other object to compare with
|
|
260
280
|
# @return [Boolean] true if the URLs are equal
|
|
261
281
|
def ==(other) = other.is_a?(Url) && to_s == other.to_s
|
|
262
|
-
|
|
263
|
-
##
|
|
264
|
-
# Supports hash-based comparisons by ensuring equality semantics match `hash`.
|
|
265
|
-
#
|
|
266
|
-
# @param other [Object] the other object to compare with
|
|
267
|
-
# @return [Boolean] true if the URLs are considered equal
|
|
268
|
-
def eql?(other) = other.is_a?(Url) && to_s == other.to_s
|
|
282
|
+
alias eql? ==
|
|
269
283
|
|
|
270
284
|
##
|
|
271
285
|
# Returns the hash code for this URL.
|
|
@@ -278,5 +292,15 @@ module Html2rss
|
|
|
278
292
|
#
|
|
279
293
|
# @return [String] the debug representation
|
|
280
294
|
def inspect = "#<#{self.class}:#{object_id} @uri=#{@uri.inspect}>"
|
|
295
|
+
|
|
296
|
+
private
|
|
297
|
+
|
|
298
|
+
def ip_address_host?
|
|
299
|
+
IPAddr.new(host.to_s)
|
|
300
|
+
true
|
|
301
|
+
rescue IPAddr::Error
|
|
302
|
+
false
|
|
303
|
+
end
|
|
281
304
|
end
|
|
305
|
+
# rubocop:enable Metrics/ClassLength
|
|
282
306
|
end
|
data/lib/html2rss/version.rb
CHANGED
data/lib/html2rss.rb
CHANGED
|
@@ -5,7 +5,8 @@ require 'zeitwerk'
|
|
|
5
5
|
loader = Zeitwerk::Loader.for_gem
|
|
6
6
|
loader.inflector.inflect(
|
|
7
7
|
'cli' => 'CLI',
|
|
8
|
-
'sst' => 'SST'
|
|
8
|
+
'sst' => 'SST',
|
|
9
|
+
'mcp' => 'MCP'
|
|
9
10
|
)
|
|
10
11
|
loader.setup
|
|
11
12
|
|
|
@@ -69,11 +70,35 @@ module Html2rss
|
|
|
69
70
|
|
|
70
71
|
# rubocop:disable Metrics/ParameterLists
|
|
71
72
|
|
|
73
|
+
##
|
|
74
|
+
# Scrapes the provided URL without hand-written selectors and returns {FeedResult}.
|
|
75
|
+
#
|
|
76
|
+
# Prefer this when callers need {FeedResult#status} (MCP envelope payload, CLI +--explain+).
|
|
77
|
+
#
|
|
78
|
+
# @param url [String] source page URL
|
|
79
|
+
# @param strategy [Symbol] request strategy to use
|
|
80
|
+
# @param items_selector [String, nil] optional selector hint for item extraction
|
|
81
|
+
# @param max_redirects [Integer, nil] optional redirect limit override
|
|
82
|
+
# @param max_requests [Integer] optional request budget override (default: 4 for sitemap sub-fetches)
|
|
83
|
+
# @param local_file_path [String, nil] optional local HTML file path
|
|
84
|
+
# @param limit [Integer, nil] max articles to keep (default: {AutoSource::DEFAULT_LIMIT})
|
|
85
|
+
# @return [Html2rss::FeedResult]
|
|
86
|
+
def self.auto_feed_result(url,
|
|
87
|
+
strategy: :auto,
|
|
88
|
+
items_selector: nil,
|
|
89
|
+
max_redirects: nil,
|
|
90
|
+
max_requests: 4,
|
|
91
|
+
local_file_path: nil,
|
|
92
|
+
limit: nil)
|
|
93
|
+
feed_result(build_auto_source_config(url:, strategy:, items_selector:, max_redirects:, max_requests:,
|
|
94
|
+
local_file_path:, limit:))
|
|
95
|
+
end
|
|
96
|
+
|
|
72
97
|
##
|
|
73
98
|
# Scrapes the provided URL without hand-written selectors and returns an RSS object.
|
|
74
99
|
#
|
|
75
100
|
# Builds an auto_source config, then FeedPipeline runs structured scrapers and
|
|
76
|
-
# (when needed) the SST heuristic path — see
|
|
101
|
+
# (when needed) the SST heuristic path — see lib/html2rss/auto_source/README.md.
|
|
77
102
|
#
|
|
78
103
|
# @param url [String] source page URL
|
|
79
104
|
# @param strategy [Symbol] request strategy to use
|
|
@@ -90,14 +115,14 @@ module Html2rss
|
|
|
90
115
|
max_requests: 4,
|
|
91
116
|
local_file_path: nil,
|
|
92
117
|
limit: nil)
|
|
93
|
-
|
|
94
|
-
|
|
118
|
+
auto_feed_result(url, strategy:, items_selector:, max_redirects:, max_requests:,
|
|
119
|
+
local_file_path:, limit:).to_rss
|
|
95
120
|
end
|
|
96
121
|
|
|
97
122
|
##
|
|
98
123
|
# Scrapes the provided URL without hand-written selectors and returns a JSONFeed 1.1 hash.
|
|
99
124
|
#
|
|
100
|
-
# Same auto_source pipeline as {.auto_source}; see
|
|
125
|
+
# Same auto_source pipeline as {.auto_source}; see lib/html2rss/auto_source/README.md.
|
|
101
126
|
#
|
|
102
127
|
# @param url [String] source page URL
|
|
103
128
|
# @param strategy [Symbol] request strategy to use
|
|
@@ -114,8 +139,38 @@ module Html2rss
|
|
|
114
139
|
max_requests: 4,
|
|
115
140
|
local_file_path: nil,
|
|
116
141
|
limit: nil)
|
|
117
|
-
|
|
118
|
-
|
|
142
|
+
auto_feed_result(url, strategy:, items_selector:, max_redirects:, max_requests:,
|
|
143
|
+
local_file_path:, limit:).to_json_feed
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
##
|
|
147
|
+
# Analyzes a URL and produces a reusable YAML-ready feed config hash.
|
|
148
|
+
#
|
|
149
|
+
# Uses auto-source discovery to extract articles, then derives CSS selectors
|
|
150
|
+
# from the structural analysis.
|
|
151
|
+
#
|
|
152
|
+
# @param url [String] source page URL
|
|
153
|
+
# @param strategy [Symbol] request strategy (+:auto+, +:faraday+, +:botasaurus+)
|
|
154
|
+
# @param items_selector [String, nil] optional CSS selector hint for items
|
|
155
|
+
# @param max_redirects [Integer, nil] optional redirect limit override
|
|
156
|
+
# @param max_requests [Integer, nil] optional request budget override
|
|
157
|
+
# @param limit [Integer, nil] max articles to keep
|
|
158
|
+
# @param local_file_path [String, nil] optional local HTML file path
|
|
159
|
+
# @return [Hash] feed config hash with +:channel+ and +:selectors+
|
|
160
|
+
def self.capture(url,
|
|
161
|
+
strategy: :auto,
|
|
162
|
+
items_selector: nil,
|
|
163
|
+
max_redirects: nil,
|
|
164
|
+
max_requests: nil,
|
|
165
|
+
limit: nil,
|
|
166
|
+
local_file_path: nil)
|
|
167
|
+
Capture.build(url,
|
|
168
|
+
strategy:,
|
|
169
|
+
items_selector:,
|
|
170
|
+
max_redirects:,
|
|
171
|
+
max_requests:,
|
|
172
|
+
limit:,
|
|
173
|
+
local_file_path:).config
|
|
119
174
|
end
|
|
120
175
|
|
|
121
176
|
# rubocop:enable Metrics/ParameterLists
|