html2rss 0.25.0 → 0.26.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 +76 -2
- data/html2rss.gemspec +4 -1
- data/lib/html2rss/auto_source/cleanup.rb +62 -36
- 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 +34 -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 +7 -2
- data/lib/html2rss/capture.rb +315 -0
- data/lib/html2rss/cli.rb +64 -5
- data/lib/html2rss/config/auto_source_contract.rb +3 -1
- data/lib/html2rss/config/schema.rb +67 -26
- data/lib/html2rss/config/selectors_validator.rb +60 -21
- data/lib/html2rss/config/validator.rb +18 -27
- data/lib/html2rss/error.rb +15 -3
- data/lib/html2rss/feed_pipeline/auto_fallback.rb +16 -6
- data/lib/html2rss/feed_pipeline/runtime_policy.rb +3 -43
- data/lib/html2rss/feed_pipeline.rb +10 -3
- data/lib/html2rss/hash_util.rb +17 -0
- data/lib/html2rss/html/sst_article_extractor.rb +36 -7
- data/lib/html2rss/link_destination/noise_policy.rb +7 -18
- data/lib/html2rss/link_destination/path_classifier.rb +3 -0
- data/lib/html2rss/mcp/server.rb +577 -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 +89 -9
- data/lib/html2rss/request_service/botasaurus_strategy.rb +4 -2
- data/lib/html2rss/request_service/budget.rb +7 -35
- data/lib/html2rss/request_service/context.rb +0 -6
- data/lib/html2rss/request_service/faraday_strategy.rb +45 -2
- data/lib/html2rss/request_service/policy.rb +1 -1
- data/lib/html2rss/request_service/response.rb +15 -1
- data/lib/html2rss/request_service/strategy.rb +1 -2
- data/lib/html2rss/request_service.rb +4 -9
- 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/url.rb +11 -7
- data/lib/html2rss/version.rb +1 -1
- data/lib/html2rss.rb +32 -1
- data/schema/html2rss-config.schema.json +408 -85
- metadata +54 -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
|
@@ -5,18 +5,6 @@ module Html2rss
|
|
|
5
5
|
##
|
|
6
6
|
# A segment paired with its composite score.
|
|
7
7
|
RankedSegment = Data.define(:segment, :score) do
|
|
8
|
-
##
|
|
9
|
-
# @param segment [Html2rss::AutoSource::Segment]
|
|
10
|
-
# @param score [Score]
|
|
11
|
-
# @return [RankedSegment]
|
|
12
|
-
# @raise [ArgumentError] on invalid types
|
|
13
|
-
def self.build(segment:, score:)
|
|
14
|
-
raise ArgumentError, 'segment must be AutoSource::Segment' unless segment.is_a?(Html2rss::AutoSource::Segment)
|
|
15
|
-
raise ArgumentError, 'score must be Scoring::Score' unless score.is_a?(Score)
|
|
16
|
-
|
|
17
|
-
new(segment:, score:)
|
|
18
|
-
end
|
|
19
|
-
|
|
20
8
|
##
|
|
21
9
|
# @return [SST::Node]
|
|
22
10
|
def root_node = segment.root_node
|
|
@@ -4,26 +4,7 @@ module Html2rss
|
|
|
4
4
|
module Scoring
|
|
5
5
|
##
|
|
6
6
|
# Composite score with quality/junk split and optional typed feature breakdown.
|
|
7
|
-
Score = Data.define(:composite, :quality, :junk, :breakdown)
|
|
8
|
-
##
|
|
9
|
-
# @param composite [Numeric]
|
|
10
|
-
# @param quality [Numeric, nil]
|
|
11
|
-
# @param junk [Numeric, nil]
|
|
12
|
-
# @param breakdown [Hash{Symbol => Numeric}, nil]
|
|
13
|
-
# @return [Score]
|
|
14
|
-
# @raise [ArgumentError] when composite is not numeric
|
|
15
|
-
def self.build(composite:, quality: nil, junk: nil, breakdown: nil)
|
|
16
|
-
raise ArgumentError, 'composite must be Numeric' unless composite.is_a?(Numeric)
|
|
17
|
-
|
|
18
|
-
parts = breakdown.nil? ? Score::EMPTY_BREAKDOWN : breakdown.transform_keys { FeatureId.assert!(_1) }.freeze
|
|
19
|
-
new(
|
|
20
|
-
composite: composite.to_f,
|
|
21
|
-
quality: (quality.nil? ? composite : quality).to_f,
|
|
22
|
-
junk: (junk || 0).to_f,
|
|
23
|
-
breakdown: parts
|
|
24
|
-
)
|
|
25
|
-
end
|
|
26
|
-
end
|
|
7
|
+
Score = Data.define(:composite, :quality, :junk, :breakdown)
|
|
27
8
|
# Shared empty feature breakdown for scores without per-feature tallies.
|
|
28
9
|
Score::EMPTY_BREAKDOWN = {}.freeze
|
|
29
10
|
end
|
data/lib/html2rss/scoring.rb
CHANGED
|
@@ -4,30 +4,5 @@ module Html2rss
|
|
|
4
4
|
##
|
|
5
5
|
# Declarative feature scoring for AutoSource SST segments.
|
|
6
6
|
module Scoring
|
|
7
|
-
##
|
|
8
|
-
# Closed set of feature identifiers used by the scoring registry.
|
|
9
|
-
module FeatureId
|
|
10
|
-
# Closed set of allowed feature identifier symbols.
|
|
11
|
-
IDS = %i[
|
|
12
|
-
title_word_count_ge3 title_word_count_ge7 path_length_gt6 content_path
|
|
13
|
-
publish_marker descriptive_context article_container content_tokens
|
|
14
|
-
non_content_utility_path utility_prefix_title_short shallow weak_container
|
|
15
|
-
recommended_title_non_content high_confidence_junk_path junk_tokens
|
|
16
|
-
heading_anchor heading_text_match meaningful_text content_like_destination
|
|
17
|
-
cluster_size cluster_avg_words cluster_heading cluster_time cluster_date
|
|
18
|
-
].to_set.freeze
|
|
19
|
-
|
|
20
|
-
module_function
|
|
21
|
-
|
|
22
|
-
##
|
|
23
|
-
# @param id [Symbol]
|
|
24
|
-
# @return [Symbol]
|
|
25
|
-
# @raise [ArgumentError] when id is not in the closed set
|
|
26
|
-
def assert!(id)
|
|
27
|
-
raise ArgumentError, "unknown FeatureId: #{id.inspect}" unless IDS.include?(id)
|
|
28
|
-
|
|
29
|
-
id
|
|
30
|
-
end
|
|
31
|
-
end
|
|
32
7
|
end
|
|
33
8
|
end
|
|
@@ -24,9 +24,24 @@ module Html2rss
|
|
|
24
24
|
# In case you're extracting a date or a time, consider parsing it
|
|
25
25
|
# during post processing with {PostProcessors::ParseTime}.
|
|
26
26
|
class Attribute
|
|
27
|
+
# Config-facing option types (excluding shared selector fields supplied at runtime).
|
|
28
|
+
OPTION_TYPES = { attribute: String }.freeze
|
|
29
|
+
|
|
27
30
|
# The available options for the attribute extractor.
|
|
28
31
|
Options = Struct.new('AttributeOptions', :selector, :attribute, keyword_init: true)
|
|
29
32
|
|
|
33
|
+
# JSON Schema description exported via +schema_doc+.
|
|
34
|
+
DESCRIPTION = 'Return the value of an HTML attribute on the selected element. ' \
|
|
35
|
+
'Requires sibling selector option `attribute` (attribute name).'
|
|
36
|
+
|
|
37
|
+
# Example extractor name values for JSON Schema +examples+.
|
|
38
|
+
EXAMPLES = [
|
|
39
|
+
'attribute'
|
|
40
|
+
].freeze
|
|
41
|
+
|
|
42
|
+
# @return [Hash{Symbol => Object}] JSON Schema fragment for this extractor name
|
|
43
|
+
def self.schema_doc = SchemaDoc.for_extractor(name: :attribute, klass: self)
|
|
44
|
+
|
|
30
45
|
##
|
|
31
46
|
# Initializes the Attribute extractor.
|
|
32
47
|
#
|
|
@@ -27,6 +27,18 @@ module Html2rss
|
|
|
27
27
|
# The available options for the href (attribute) extractor.
|
|
28
28
|
Options = Struct.new('HrefOptions', :selector, :channel, keyword_init: true)
|
|
29
29
|
|
|
30
|
+
# JSON Schema description exported via +schema_doc+.
|
|
31
|
+
DESCRIPTION = 'Return the absolute URL from the selected element\'s `href` attribute ' \
|
|
32
|
+
'(relative hrefs are resolved against the channel URL).'
|
|
33
|
+
|
|
34
|
+
# Example extractor name values for JSON Schema +examples+.
|
|
35
|
+
EXAMPLES = [
|
|
36
|
+
'href'
|
|
37
|
+
].freeze
|
|
38
|
+
|
|
39
|
+
# @return [Hash{Symbol => Object}] JSON Schema fragment for this extractor name
|
|
40
|
+
def self.schema_doc = SchemaDoc.for_extractor(name: :href, klass: self)
|
|
41
|
+
|
|
30
42
|
##
|
|
31
43
|
# Initializes the Href extractor.
|
|
32
44
|
#
|
|
@@ -26,6 +26,18 @@ module Html2rss
|
|
|
26
26
|
# The available options for the html extractor.
|
|
27
27
|
Options = Struct.new('HtmlOptions', :selector, keyword_init: true)
|
|
28
28
|
|
|
29
|
+
# JSON Schema description exported via +schema_doc+.
|
|
30
|
+
DESCRIPTION = 'Return the outer HTML of the selected element. ' \
|
|
31
|
+
'Sanitize during post-processing (e.g. `sanitize_html`).'
|
|
32
|
+
|
|
33
|
+
# Example extractor name values for JSON Schema +examples+.
|
|
34
|
+
EXAMPLES = [
|
|
35
|
+
'html'
|
|
36
|
+
].freeze
|
|
37
|
+
|
|
38
|
+
# @return [Hash{Symbol => Object}] JSON Schema fragment for this extractor name
|
|
39
|
+
def self.schema_doc = SchemaDoc.for_extractor(name: :html, klass: self)
|
|
40
|
+
|
|
29
41
|
##
|
|
30
42
|
# Initializes the Html extractor.
|
|
31
43
|
#
|
|
@@ -16,9 +16,23 @@ module Html2rss
|
|
|
16
16
|
# Would return:
|
|
17
17
|
# 'Foobar'
|
|
18
18
|
class Static
|
|
19
|
+
# Config-facing option types (excluding shared selector fields supplied at runtime).
|
|
20
|
+
OPTION_TYPES = { static: String }.freeze
|
|
21
|
+
|
|
19
22
|
# The available option for the static extractor.
|
|
20
23
|
Options = Struct.new('StaticOptions', :static, keyword_init: true)
|
|
21
24
|
|
|
25
|
+
# JSON Schema description exported via +schema_doc+.
|
|
26
|
+
DESCRIPTION = 'Return a fixed value from sibling selector option `static` (no DOM read).'
|
|
27
|
+
|
|
28
|
+
# Example extractor name values for JSON Schema +examples+.
|
|
29
|
+
EXAMPLES = [
|
|
30
|
+
'static'
|
|
31
|
+
].freeze
|
|
32
|
+
|
|
33
|
+
# @return [Hash{Symbol => Object}] JSON Schema fragment for this extractor name
|
|
34
|
+
def self.schema_doc = SchemaDoc.for_extractor(name: :static, klass: self)
|
|
35
|
+
|
|
22
36
|
##
|
|
23
37
|
# Initializes the Static extractor.
|
|
24
38
|
#
|
|
@@ -24,6 +24,17 @@ module Html2rss
|
|
|
24
24
|
# The available options for the text extractor.
|
|
25
25
|
Options = Struct.new('TextOptions', :selector, keyword_init: true)
|
|
26
26
|
|
|
27
|
+
# JSON Schema description exported via +schema_doc+.
|
|
28
|
+
DESCRIPTION = 'Return collapsed visible text of the selected element (default extractor).'
|
|
29
|
+
|
|
30
|
+
# Example extractor name values for JSON Schema +examples+.
|
|
31
|
+
EXAMPLES = [
|
|
32
|
+
'text'
|
|
33
|
+
].freeze
|
|
34
|
+
|
|
35
|
+
# @return [Hash{Symbol => Object}] JSON Schema fragment for this extractor name
|
|
36
|
+
def self.schema_doc = SchemaDoc.for_extractor(name: :text, klass: self)
|
|
37
|
+
|
|
27
38
|
##
|
|
28
39
|
# Initializes the Text extractor.
|
|
29
40
|
#
|
|
@@ -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/url.rb
CHANGED
|
@@ -21,6 +21,7 @@ module Html2rss
|
|
|
21
21
|
# url = Url.from_relative('/foo-bar/baz.txt', 'https://example.com')
|
|
22
22
|
# url.titleized # => "Foo Bar Baz"
|
|
23
23
|
# url.channel_titleized # => "example.com: Foo Bar Baz"
|
|
24
|
+
# rubocop:disable Metrics/ClassLength -- value object owns resolve/sanitize/titleize/identity
|
|
24
25
|
class Url
|
|
25
26
|
include Comparable
|
|
26
27
|
|
|
@@ -189,6 +190,14 @@ module Html2rss
|
|
|
189
190
|
self.class.from_absolute(uri.normalize.to_s)
|
|
190
191
|
end
|
|
191
192
|
|
|
193
|
+
##
|
|
194
|
+
# Returns a copy of the URL with the fragment removed (dedup / self-link identity).
|
|
195
|
+
#
|
|
196
|
+
# @return [Url] this URL when already fragment-free; otherwise a new URL
|
|
197
|
+
def without_fragment
|
|
198
|
+
@uri.fragment ? self.class.from_absolute(@uri.omit(:fragment).normalize.to_s) : self
|
|
199
|
+
end
|
|
200
|
+
|
|
192
201
|
##
|
|
193
202
|
# Returns a copy of the URL with the provided query values.
|
|
194
203
|
#
|
|
@@ -259,13 +268,7 @@ module Html2rss
|
|
|
259
268
|
# @param other [Object] the other object to compare with
|
|
260
269
|
# @return [Boolean] true if the URLs are equal
|
|
261
270
|
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
|
|
271
|
+
alias eql? ==
|
|
269
272
|
|
|
270
273
|
##
|
|
271
274
|
# Returns the hash code for this URL.
|
|
@@ -279,4 +282,5 @@ module Html2rss
|
|
|
279
282
|
# @return [String] the debug representation
|
|
280
283
|
def inspect = "#<#{self.class}:#{object_id} @uri=#{@uri.inspect}>"
|
|
281
284
|
end
|
|
285
|
+
# rubocop:enable Metrics/ClassLength
|
|
282
286
|
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
|
|
|
@@ -118,6 +119,36 @@ module Html2rss
|
|
|
118
119
|
local_file_path:, limit:))
|
|
119
120
|
end
|
|
120
121
|
|
|
122
|
+
##
|
|
123
|
+
# Analyzes a URL and produces a reusable YAML-ready feed config hash.
|
|
124
|
+
#
|
|
125
|
+
# Uses auto-source discovery to extract articles, then derives CSS selectors
|
|
126
|
+
# from the structural analysis.
|
|
127
|
+
#
|
|
128
|
+
# @param url [String] source page URL
|
|
129
|
+
# @param strategy [Symbol] request strategy (+:auto+, +:faraday+, +:botasaurus+)
|
|
130
|
+
# @param items_selector [String, nil] optional CSS selector hint for items
|
|
131
|
+
# @param max_redirects [Integer, nil] optional redirect limit override
|
|
132
|
+
# @param max_requests [Integer, nil] optional request budget override
|
|
133
|
+
# @param limit [Integer, nil] max articles to keep
|
|
134
|
+
# @param local_file_path [String, nil] optional local HTML file path
|
|
135
|
+
# @return [Hash] feed config hash with +:channel+ and +:selectors+
|
|
136
|
+
def self.capture(url,
|
|
137
|
+
strategy: :auto,
|
|
138
|
+
items_selector: nil,
|
|
139
|
+
max_redirects: nil,
|
|
140
|
+
max_requests: nil,
|
|
141
|
+
limit: nil,
|
|
142
|
+
local_file_path: nil)
|
|
143
|
+
Capture.build(url,
|
|
144
|
+
strategy:,
|
|
145
|
+
items_selector:,
|
|
146
|
+
max_redirects:,
|
|
147
|
+
max_requests:,
|
|
148
|
+
limit:,
|
|
149
|
+
local_file_path:).config
|
|
150
|
+
end
|
|
151
|
+
|
|
121
152
|
# rubocop:enable Metrics/ParameterLists
|
|
122
153
|
|
|
123
154
|
# rubocop:disable ThreadSafety/ClassInstanceVariable
|