html2rss 0.30.0 → 0.32.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 +6 -3
- data/lib/html2rss/article/deduplicator.rb +2 -4
- data/lib/html2rss/article.rb +16 -39
- data/lib/html2rss/auto_source/README.md +12 -9
- data/lib/html2rss/auto_source/cleanup.rb +34 -7
- data/lib/html2rss/auto_source/scraper/html.rb +5 -0
- data/lib/html2rss/auto_source/scraper/json_document.rb +135 -0
- data/lib/html2rss/auto_source/scraper/json_state/article_normalizer.rb +74 -11
- data/lib/html2rss/auto_source/scraper/json_state/candidate_detector.rb +4 -8
- data/lib/html2rss/auto_source/scraper/json_state.rb +5 -0
- data/lib/html2rss/auto_source/scraper/meta_oembed.rb +8 -1
- data/lib/html2rss/auto_source/scraper/microdata.rb +8 -0
- data/lib/html2rss/auto_source/scraper/microformats2.rb +8 -0
- data/lib/html2rss/auto_source/scraper/native_feed.rb +3 -0
- data/lib/html2rss/auto_source/scraper/schema/thing.rb +4 -5
- data/lib/html2rss/auto_source/scraper/schema.rb +8 -0
- data/lib/html2rss/auto_source/scraper/semantic_html.rb +5 -0
- data/lib/html2rss/auto_source/scraper/sitemap.rb +3 -0
- data/lib/html2rss/auto_source/scraper/wordpress_api.rb +3 -0
- data/lib/html2rss/auto_source/scraper.rb +12 -17
- data/lib/html2rss/auto_source/segment.rb +7 -4
- data/lib/html2rss/auto_source/segmenter/cluster.rb +22 -23
- data/lib/html2rss/auto_source/segmenter/list.rb +54 -31
- data/lib/html2rss/auto_source/segmenter.rb +5 -5
- data/lib/html2rss/auto_source.rb +58 -3
- data/lib/html2rss/batch.rb +3 -1
- data/lib/html2rss/capture/README.md +6 -4
- data/lib/html2rss/capture.rb +67 -35
- data/lib/html2rss/channel.rb +3 -3
- data/lib/html2rss/cli/probe_view.rb +5 -3
- data/lib/html2rss/cli/render.rb +6 -5
- data/lib/html2rss/cli/validate.rb +28 -27
- data/lib/html2rss/cli.rb +6 -2
- data/lib/html2rss/config/auto_source_contract.rb +3 -0
- data/lib/html2rss/config/issue_mapper.rb +108 -0
- data/lib/html2rss/config/request_headers.rb +2 -3
- data/lib/html2rss/config/schema.rb +16 -1
- data/lib/html2rss/config/selectors_validator.rb +92 -70
- data/lib/html2rss/config/validation_issue.rb +60 -0
- data/lib/html2rss/config/validation_report.rb +55 -0
- data/lib/html2rss/config/validator.rb +10 -6
- data/lib/html2rss/config.rb +44 -114
- data/lib/html2rss/defaults.rb +42 -30
- data/lib/html2rss/doctor/botasaurus.rb +6 -4
- data/lib/html2rss/doctor.rb +8 -0
- data/lib/html2rss/error.rb +1 -1
- data/lib/html2rss/feed_builder/rss.rb +9 -14
- data/lib/html2rss/feed_pipeline/auto_fallback.rb +11 -2
- data/lib/html2rss/feed_pipeline/runtime_policy.rb +14 -10
- data/lib/html2rss/feed_pipeline/strategy_plan.rb +6 -2
- data/lib/html2rss/feed_resolution/candidate_generator.rb +1 -4
- data/lib/html2rss/feed_resolution/diag.rb +3 -1
- data/lib/html2rss/feed_resolution/options.rb +3 -1
- data/lib/html2rss/feed_resolution/policy.rb +2 -2
- data/lib/html2rss/feed_resolution.rb +30 -2
- data/lib/html2rss/html/article_extractor/date_extractor.rb +1 -1
- data/lib/html2rss/html/article_extractor.rb +2 -4
- data/lib/html2rss/html/feed_link.rb +3 -1
- data/lib/html2rss/html/navigator/text_extractor.rb +1 -1
- data/lib/html2rss/html/navigator.rb +10 -10
- data/lib/html2rss/html/rendering/description_builder.rb +1 -1
- data/lib/html2rss/html/sst_article_extractor.rb +8 -5
- data/lib/html2rss/link_destination/destination_facts.rb +4 -2
- data/lib/html2rss/link_destination/noise_policy.rb +10 -1
- data/lib/html2rss/link_destination/path_classifier.rb +45 -38
- data/lib/html2rss/mcp/README.md +2 -0
- data/lib/html2rss/mcp/config_argument.rb +3 -1
- data/lib/html2rss/mcp/contract.rb +7 -21
- data/lib/html2rss/mcp/outcome/playbook.rb +2 -2
- data/lib/html2rss/mcp/outcome.rb +36 -43
- data/lib/html2rss/mcp/server/tools.rb +111 -131
- data/lib/html2rss/mcp/server.rb +26 -18
- data/lib/html2rss/page_recon/README.md +5 -5
- data/lib/html2rss/page_recon/diagnostics.rb +71 -81
- data/lib/html2rss/page_recon.rb +9 -5
- data/lib/html2rss/recon.rb +19 -30
- data/lib/html2rss/request_service/botasaurus_contract.rb +11 -12
- data/lib/html2rss/request_service/botasaurus_strategy.rb +1 -1
- data/lib/html2rss/request_service/compressed_body.rb +4 -1
- data/lib/html2rss/request_service/httpx_strategy.rb +39 -28
- data/lib/html2rss/request_service/network_guard.rb +7 -5
- data/lib/html2rss/request_service/response.rb +6 -4
- data/lib/html2rss/request_service/strategy.rb +3 -8
- data/lib/html2rss/request_service.rb +11 -2
- data/lib/html2rss/scoring/container_assessor.rb +3 -1
- data/lib/html2rss/scoring/engine.rb +8 -8
- data/lib/html2rss/scoring/observation.rb +5 -3
- data/lib/html2rss/scoring/ranked_segment.rb +3 -1
- data/lib/html2rss/scrape_target.rb +3 -1
- data/lib/html2rss/selector_candidates.rb +234 -0
- data/lib/html2rss/selectors/README.md +64 -0
- data/lib/html2rss/selectors/extractors/attribute.rb +13 -15
- data/lib/html2rss/selectors/extractors/href.rb +15 -16
- data/lib/html2rss/selectors/extractors/html.rb +8 -9
- data/lib/html2rss/selectors/extractors/static.rb +12 -14
- data/lib/html2rss/selectors/extractors/text.rb +8 -9
- data/lib/html2rss/selectors/extractors.rb +33 -20
- data/lib/html2rss/selectors/item_env.rb +31 -0
- data/lib/html2rss/selectors/{object_to_xml_converter.rb → json_xml.rb} +11 -12
- data/lib/html2rss/selectors/option_spec.rb +140 -0
- data/lib/html2rss/selectors/post_processors/base.rb +79 -31
- data/lib/html2rss/selectors/post_processors/gsub.rb +103 -39
- data/lib/html2rss/selectors/post_processors/html_transformers/transform_urls_to_absolute_ones.rb +4 -4
- data/lib/html2rss/selectors/post_processors/markdown_to_html.rb +10 -11
- data/lib/html2rss/selectors/post_processors/parse_time.rb +50 -26
- data/lib/html2rss/selectors/post_processors/parse_uri.rb +11 -7
- data/lib/html2rss/selectors/post_processors/sanitize_html.rb +46 -32
- data/lib/html2rss/selectors/post_processors/substring.rb +14 -25
- data/lib/html2rss/selectors/post_processors/template.rb +23 -19
- data/lib/html2rss/selectors/post_processors.rb +6 -5
- data/lib/html2rss/selectors/{schema_doc.rb → schema_export.rb} +12 -31
- data/lib/html2rss/selectors/step_env.rb +24 -0
- data/lib/html2rss/selectors.rb +231 -142
- data/lib/html2rss/sst/attrs.rb +5 -3
- data/lib/html2rss/sst/document.rb +3 -1
- data/lib/html2rss/sst/index.rb +3 -1
- data/lib/html2rss/sst/node.rb +29 -10
- data/lib/html2rss/sst/normalizer.rb +12 -16
- data/lib/html2rss/sst/tags.rb +7 -7
- data/lib/html2rss/status.rb +13 -8
- data/lib/html2rss/surface_category.rb +14 -32
- data/lib/html2rss/syndication/parser.rb +2 -2
- data/lib/html2rss/test/enhance_audit.rb +1 -3
- data/lib/html2rss/test/policy.rb +106 -0
- data/lib/html2rss/test.rb +126 -173
- data/lib/html2rss/url.rb +25 -4
- data/lib/html2rss/version.rb +1 -1
- data/lib/html2rss.rb +32 -19
- data/lib/tasks/config_schema.rake +2 -2
- data/schema/html2rss-config.schema.json +26 -8
- metadata +16 -6
- data/lib/html2rss/selectors/item_scope.rb +0 -30
|
@@ -17,6 +17,11 @@ module Html2rss
|
|
|
17
17
|
# @return [Symbol] scraper config key
|
|
18
18
|
def self.options_key = :microdata
|
|
19
19
|
|
|
20
|
+
##
|
|
21
|
+
# @param _opts [Hash] unused options
|
|
22
|
+
# @return [Integer]
|
|
23
|
+
def self.request_slots(_opts = {}) = 0
|
|
24
|
+
|
|
20
25
|
class << self
|
|
21
26
|
# @param parsed_body [Nokogiri::HTML::Document, nil] parsed HTML document
|
|
22
27
|
def articles?(parsed_body)
|
|
@@ -81,6 +86,9 @@ module Html2rss
|
|
|
81
86
|
@url = url
|
|
82
87
|
end
|
|
83
88
|
|
|
89
|
+
# @return [Boolean]
|
|
90
|
+
def extractable? = self.class.articles?(parsed_body)
|
|
91
|
+
|
|
84
92
|
##
|
|
85
93
|
# Iterates over normalized article hashes extracted from supported Microdata roots.
|
|
86
94
|
#
|
|
@@ -18,6 +18,11 @@ module Html2rss
|
|
|
18
18
|
# @return [Symbol] scraper config key
|
|
19
19
|
def self.options_key = :microformats2
|
|
20
20
|
|
|
21
|
+
##
|
|
22
|
+
# @param _opts [Hash] unused options
|
|
23
|
+
# @return [Integer]
|
|
24
|
+
def self.request_slots(_opts = {}) = 0
|
|
25
|
+
|
|
21
26
|
class << self
|
|
22
27
|
# @param parsed_body [Nokogiri::HTML::Document, nil] parsed HTML document
|
|
23
28
|
# @return [Boolean] whether Microformats2 h-entry elements exist
|
|
@@ -38,6 +43,9 @@ module Html2rss
|
|
|
38
43
|
@url = Html2rss::Url.from_absolute(url)
|
|
39
44
|
end
|
|
40
45
|
|
|
46
|
+
# @return [Boolean]
|
|
47
|
+
def extractable? = self.class.articles?(parsed_body)
|
|
48
|
+
|
|
41
49
|
##
|
|
42
50
|
# Yields normalized article hashes extracted from h-entry elements.
|
|
43
51
|
#
|
|
@@ -19,9 +19,6 @@ module Html2rss
|
|
|
19
19
|
ReviewNewsArticle SatiricalArticle ScholarlyArticle SocialMediaPosting TechArticle
|
|
20
20
|
].to_set.freeze
|
|
21
21
|
|
|
22
|
-
# Attributes exposed by `#call` in generated article hashes.
|
|
23
|
-
DEFAULT_ATTRIBUTES = %i[id title description author url image published_at categories].freeze
|
|
24
|
-
|
|
25
22
|
# @param schema_object [Hash{Symbol => Object}] parsed schema.org object
|
|
26
23
|
# @param url [String, Html2rss::Url, nil] base URL used for relative normalization
|
|
27
24
|
def initialize(schema_object, url:)
|
|
@@ -29,8 +26,10 @@ module Html2rss
|
|
|
29
26
|
@base_url = normalized_base_url(url)
|
|
30
27
|
end
|
|
31
28
|
|
|
32
|
-
# @return [Hash]
|
|
33
|
-
def call
|
|
29
|
+
# @return [Hash{Symbol => Object}] scraped article attributes
|
|
30
|
+
def call
|
|
31
|
+
{ id:, title:, description:, author:, url:, image:, published_at:, categories: }
|
|
32
|
+
end
|
|
34
33
|
|
|
35
34
|
# @return [String, nil] stable schema object identifier
|
|
36
35
|
def id
|
|
@@ -46,6 +46,11 @@ module Html2rss
|
|
|
46
46
|
# @return [Symbol] scraper config key
|
|
47
47
|
def self.options_key = :schema
|
|
48
48
|
|
|
49
|
+
##
|
|
50
|
+
# @param _opts [Hash] unused options
|
|
51
|
+
# @return [Integer]
|
|
52
|
+
def self.request_slots(_opts = {}) = 0
|
|
53
|
+
|
|
49
54
|
class << self
|
|
50
55
|
# @param parsed_body [Nokogiri::HTML::Document] parsed HTML document
|
|
51
56
|
# @return [Boolean] whether the page includes supported schema types
|
|
@@ -166,6 +171,9 @@ module Html2rss
|
|
|
166
171
|
@opts = opts
|
|
167
172
|
end
|
|
168
173
|
|
|
174
|
+
# @return [Boolean]
|
|
175
|
+
def extractable? = self.class.articles?(parsed_body)
|
|
176
|
+
|
|
169
177
|
##
|
|
170
178
|
# @yield [Hash] Each scraped article_hash
|
|
171
179
|
# @return [Array<Hash>] the scraped article_hashes
|
|
@@ -15,6 +15,11 @@ module Html2rss
|
|
|
15
15
|
# @return [Symbol] config key used to enable or configure this scraper
|
|
16
16
|
def self.options_key = :semantic_html
|
|
17
17
|
|
|
18
|
+
##
|
|
19
|
+
# @param _opts [Hash] unused options
|
|
20
|
+
# @return [Integer]
|
|
21
|
+
def self.request_slots(_opts = {}) = 0
|
|
22
|
+
|
|
18
23
|
# @param parsed_body [Nokogiri::HTML::Document] parsed HTML document
|
|
19
24
|
# @return [Boolean] true when at least one semantic container has an eligible anchor
|
|
20
25
|
def self.articles?(parsed_body)
|
|
@@ -32,15 +32,18 @@ module Html2rss
|
|
|
32
32
|
[Html].freeze
|
|
33
33
|
].freeze
|
|
34
34
|
|
|
35
|
+
# JSON response body scrapers (routed by AutoSource before SCRAPER_TIERS).
|
|
36
|
+
DOCUMENT_SCRAPERS = [JsonDocument].freeze
|
|
37
|
+
|
|
35
38
|
# Flat ordered list (request slot accounting, detection helpers).
|
|
36
|
-
SCRAPERS = SCRAPER_TIERS.flatten.freeze
|
|
39
|
+
SCRAPERS = (DOCUMENT_SCRAPERS + SCRAPER_TIERS.flatten).freeze
|
|
37
40
|
|
|
38
41
|
# Heuristic scrapers that share one memoized SST::Document per page.
|
|
39
|
-
HEURISTIC_SCRAPERS = [SemanticHtml, Html].freeze
|
|
42
|
+
HEURISTIC_SCRAPERS = Set[SemanticHtml, Html].freeze
|
|
40
43
|
# Scrapers that accept a shared follow-up +request_session+.
|
|
41
|
-
REQUEST_SESSION_SCRAPERS = [NativeFeed, WordpressApi, Sitemap, MetaOembed].freeze
|
|
44
|
+
REQUEST_SESSION_SCRAPERS = Set[NativeFeed, WordpressApi, Sitemap, MetaOembed].freeze
|
|
42
45
|
# Scrapers that consume browser-captured XHR/fetch JSON bodies.
|
|
43
|
-
CAPTURED_RESPONSE_SCRAPERS = [XhrArticles].freeze
|
|
46
|
+
CAPTURED_RESPONSE_SCRAPERS = Set[XhrArticles].freeze
|
|
44
47
|
|
|
45
48
|
##
|
|
46
49
|
# Error raised when no suitable scraper is found.
|
|
@@ -51,8 +54,8 @@ module Html2rss
|
|
|
51
54
|
'Target a direct listing URL, configure BOTASAURUS_SCRAPER_URL, ' \
|
|
52
55
|
'or run from an environment that can complete anti-bot checks.',
|
|
53
56
|
app_shell: 'app-shell surface detected (client-rendered page with little or no ' \
|
|
54
|
-
'server-rendered article HTML).
|
|
55
|
-
'
|
|
57
|
+
'server-rendered article HTML). Target a direct listing/update URL instead of a ' \
|
|
58
|
+
'homepage or shell entrypoint; a browser strategy does not add extractable markup here.',
|
|
56
59
|
high_entropy_surface: 'high-entropy surface (e.g. news homepage) with few or no durable ' \
|
|
57
60
|
'article destinations after admission. Target a section, topic, or ' \
|
|
58
61
|
'listing/update URL instead of the site homepage.',
|
|
@@ -91,6 +94,7 @@ module Html2rss
|
|
|
91
94
|
# @option opts [Hash] :microdata scraper toggle and configuration
|
|
92
95
|
# @option opts [Hash] :microformats2 scraper toggle and configuration
|
|
93
96
|
# @option opts [Hash] :json_state scraper toggle and configuration
|
|
97
|
+
# @option opts [Hash] :json_document scraper toggle and configuration
|
|
94
98
|
# @option opts [Hash] :xhr_articles scraper toggle and configuration
|
|
95
99
|
# @option opts [Hash] :meta_oembed scraper toggle and configuration
|
|
96
100
|
# @option opts [Hash] :semantic_html scraper toggle and configuration
|
|
@@ -110,7 +114,7 @@ module Html2rss
|
|
|
110
114
|
# @param tier [Array<Class>]
|
|
111
115
|
# @return [Boolean]
|
|
112
116
|
def self.heuristic_tier?(tier)
|
|
113
|
-
|
|
117
|
+
HEURISTIC_SCRAPERS.intersect?(tier)
|
|
114
118
|
end
|
|
115
119
|
|
|
116
120
|
##
|
|
@@ -139,6 +143,7 @@ module Html2rss
|
|
|
139
143
|
# @option opts [Hash] :microdata scraper toggle and configuration
|
|
140
144
|
# @option opts [Hash] :microformats2 scraper toggle and configuration
|
|
141
145
|
# @option opts [Hash] :json_state scraper toggle and configuration
|
|
146
|
+
# @option opts [Hash] :json_document scraper toggle and configuration
|
|
142
147
|
# @option opts [Hash] :xhr_articles scraper toggle and configuration
|
|
143
148
|
# @option opts [Hash] :meta_oembed scraper toggle and configuration
|
|
144
149
|
# @option opts [Hash] :semantic_html scraper toggle and configuration
|
|
@@ -163,16 +168,6 @@ module Html2rss
|
|
|
163
168
|
scraper.new(parsed_body, url:, **kwargs, **scraper_opts)
|
|
164
169
|
end
|
|
165
170
|
|
|
166
|
-
##
|
|
167
|
-
# @param instance [Object]
|
|
168
|
-
# @param parsed_body [Nokogiri::HTML::Document]
|
|
169
|
-
# @return [Boolean]
|
|
170
|
-
def self.extractable_instance?(instance, parsed_body)
|
|
171
|
-
return instance.extractable? if instance.respond_to?(:extractable?)
|
|
172
|
-
|
|
173
|
-
instance.class.articles?(parsed_body)
|
|
174
|
-
end
|
|
175
|
-
|
|
176
171
|
##
|
|
177
172
|
# @param parsed_body [Nokogiri::HTML::Document]
|
|
178
173
|
# @param body [String, nil] raw response body (preferred for blocked-surface checks)
|
|
@@ -2,9 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
module Html2rss
|
|
4
4
|
class AutoSource
|
|
5
|
+
Segment = Data.define(:root_node, :primary_link, :strategy, :position)
|
|
6
|
+
|
|
5
7
|
##
|
|
6
8
|
# One candidate content block discovered by {Segmenter}.
|
|
7
|
-
Segment
|
|
9
|
+
class Segment
|
|
10
|
+
# Allowed Segmenter strategies.
|
|
11
|
+
STRATEGIES = Set[:semantic, :list, :cluster].freeze
|
|
12
|
+
|
|
8
13
|
##
|
|
9
14
|
# @param root_node [SST::Node]
|
|
10
15
|
# @param primary_link [SST::Node, nil]
|
|
@@ -17,13 +22,11 @@ module Html2rss
|
|
|
17
22
|
unless primary_link.nil? || primary_link.is_a?(SST::Node)
|
|
18
23
|
raise ArgumentError, 'primary_link must be SST::Node or nil'
|
|
19
24
|
end
|
|
20
|
-
raise ArgumentError, "unknown strategy: #{strategy.inspect}" unless
|
|
25
|
+
raise ArgumentError, "unknown strategy: #{strategy.inspect}" unless STRATEGIES.include?(strategy)
|
|
21
26
|
raise ArgumentError, 'position must be Integer' unless position.is_a?(Integer)
|
|
22
27
|
|
|
23
28
|
new(root_node:, primary_link:, strategy:, position:)
|
|
24
29
|
end
|
|
25
30
|
end
|
|
26
|
-
# Allowed Segmenter strategies.
|
|
27
|
-
Segment::STRATEGIES = %i[semantic list cluster].to_set.freeze
|
|
28
31
|
end
|
|
29
32
|
end
|
|
@@ -13,15 +13,18 @@ module Html2rss
|
|
|
13
13
|
# @param segmenter [Segmenter]
|
|
14
14
|
# @return [Array<Segment>]
|
|
15
15
|
def call(segmenter)
|
|
16
|
+
primary = PrimaryLink.new(segmenter)
|
|
16
17
|
nodes = best_group_nodes(segmenter)
|
|
17
|
-
nodes.each_with_index.
|
|
18
|
-
|
|
18
|
+
nodes.each_with_index.filter_map do |node, position|
|
|
19
|
+
link = primary.select(node)
|
|
20
|
+
next unless link || segmenter.permit_unanchored
|
|
21
|
+
|
|
22
|
+
Segment.build(root_node: node, primary_link: link, strategy: :cluster, position:)
|
|
19
23
|
end
|
|
20
24
|
end
|
|
21
25
|
|
|
22
26
|
def best_group_nodes(segmenter)
|
|
23
|
-
|
|
24
|
-
groups = class_groups.empty? ? collect_structure_groups(segmenter) : class_groups
|
|
27
|
+
groups = collect_groups(segmenter)
|
|
25
28
|
return [] if groups.empty?
|
|
26
29
|
|
|
27
30
|
resolver = OverlapResolver.new(index: segmenter.index)
|
|
@@ -33,33 +36,29 @@ module Html2rss
|
|
|
33
36
|
module_function :best_group_nodes
|
|
34
37
|
private_class_method :best_group_nodes
|
|
35
38
|
|
|
36
|
-
def
|
|
37
|
-
|
|
39
|
+
def collect_groups(segmenter) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength
|
|
40
|
+
class_groups = Hash.new { |hash, key| hash[key] = [] }
|
|
41
|
+
structure_groups = Hash.new { |hash, key| hash[key] = [] }
|
|
42
|
+
min = segmenter.minimum_selector_frequency
|
|
43
|
+
|
|
38
44
|
segmenter.index.each_node do |node|
|
|
39
45
|
next if SST::Tags::CLUSTER_EXCLUDED_NAMES.include?(node.name)
|
|
40
46
|
next if segmenter.index.ignored_chrome?(node)
|
|
41
47
|
|
|
42
48
|
cls = normalize_class(node.attrs.class_names)
|
|
43
|
-
|
|
44
|
-
end
|
|
45
|
-
groups.select { |_, nodes| nodes.size >= segmenter.minimum_selector_frequency }
|
|
46
|
-
end
|
|
47
|
-
module_function :collect_class_groups
|
|
48
|
-
private_class_method :collect_class_groups
|
|
49
|
-
|
|
50
|
-
def collect_structure_groups(segmenter) # rubocop:disable Metrics/AbcSize
|
|
51
|
-
groups = Hash.new { |h, k| h[k] = [] }
|
|
52
|
-
segmenter.index.each_node do |node|
|
|
53
|
-
next if SST::Tags::CLUSTER_EXCLUDED_NAMES.include?(node.name)
|
|
54
|
-
next if segmenter.index.ignored_chrome?(node)
|
|
49
|
+
class_groups[cls] << node unless cls.empty?
|
|
55
50
|
|
|
56
51
|
sig = structure_signature(node)
|
|
57
|
-
|
|
52
|
+
structure_groups[sig] << node unless sig.empty?
|
|
58
53
|
end
|
|
59
|
-
|
|
54
|
+
|
|
55
|
+
frequent_class = class_groups.select { |_, nodes| nodes.size >= min }
|
|
56
|
+
return frequent_class unless frequent_class.empty?
|
|
57
|
+
|
|
58
|
+
structure_groups.select { |_, nodes| nodes.size >= min }
|
|
60
59
|
end
|
|
61
|
-
module_function :
|
|
62
|
-
private_class_method :
|
|
60
|
+
module_function :collect_groups
|
|
61
|
+
private_class_method :collect_groups
|
|
63
62
|
|
|
64
63
|
def normalize_class(class_names)
|
|
65
64
|
return '' if class_names.empty?
|
|
@@ -73,7 +72,7 @@ module Html2rss
|
|
|
73
72
|
kids = node.children
|
|
74
73
|
return '' if kids.empty?
|
|
75
74
|
|
|
76
|
-
kids.map { |
|
|
75
|
+
kids.map { |child| child.name.to_s }.join('>')
|
|
77
76
|
end
|
|
78
77
|
module_function :structure_signature
|
|
79
78
|
private_class_method :structure_signature
|
|
@@ -4,8 +4,8 @@ module Html2rss
|
|
|
4
4
|
class AutoSource
|
|
5
5
|
class Segmenter
|
|
6
6
|
##
|
|
7
|
-
# Builds repeated-list article segments
|
|
8
|
-
#
|
|
7
|
+
# Builds repeated-list article segments by grouping cards under a shared parent.
|
|
8
|
+
# When the document has a +main+ or +[role=main]+, anchors outside it are dropped.
|
|
9
9
|
module List
|
|
10
10
|
module_function
|
|
11
11
|
|
|
@@ -30,40 +30,55 @@ module Html2rss
|
|
|
30
30
|
end
|
|
31
31
|
|
|
32
32
|
def article_pairs(segmenter)
|
|
33
|
-
|
|
34
|
-
top_paths(segmenter, by_path).flat_map do |path|
|
|
35
|
-
by_path.fetch(path, []).filter_map do |node|
|
|
36
|
-
article_tag = parent_until_boundary(segmenter, node)
|
|
37
|
-
next unless article_tag
|
|
38
|
-
|
|
39
|
-
[article_tag, node]
|
|
40
|
-
end
|
|
41
|
-
end
|
|
33
|
+
top_groups(segmenter).flatten(1)
|
|
42
34
|
end
|
|
43
35
|
module_function :article_pairs
|
|
44
36
|
private_class_method :article_pairs
|
|
45
37
|
|
|
46
|
-
def
|
|
47
|
-
|
|
48
|
-
|
|
38
|
+
def top_groups(segmenter)
|
|
39
|
+
cards_by_parent(segmenter)
|
|
40
|
+
.select { |_parent, cards| cards.size >= segmenter.minimum_selector_frequency }
|
|
41
|
+
.max_by(segmenter.use_top_selectors) { |_parent, cards| cards.size }
|
|
42
|
+
.map(&:last)
|
|
43
|
+
end
|
|
44
|
+
module_function :top_groups
|
|
45
|
+
private_class_method :top_groups
|
|
46
|
+
|
|
47
|
+
def cards_by_parent(segmenter) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
48
|
+
index = segmenter.index
|
|
49
|
+
main = main_landmark(index)
|
|
50
|
+
groups = {}.compare_by_identity
|
|
51
|
+
|
|
52
|
+
index.each_node do |node|
|
|
49
53
|
next unless node.link?
|
|
50
|
-
next if
|
|
54
|
+
next if index.ignored_chrome?(node)
|
|
51
55
|
next unless relevant_anchor?(segmenter, node)
|
|
56
|
+
next if main && !inside_main?(index, node, main)
|
|
57
|
+
|
|
58
|
+
card = parent_until_boundary(segmenter, node)
|
|
59
|
+
next unless card
|
|
52
60
|
|
|
53
|
-
|
|
61
|
+
parent = index.parent_of(card)
|
|
62
|
+
next unless parent
|
|
63
|
+
|
|
64
|
+
(groups[parent] ||= []) << [card, node]
|
|
54
65
|
end
|
|
55
|
-
|
|
66
|
+
groups
|
|
67
|
+
end
|
|
68
|
+
module_function :cards_by_parent
|
|
69
|
+
private_class_method :cards_by_parent
|
|
70
|
+
|
|
71
|
+
def main_landmark(index)
|
|
72
|
+
index.each_node.find { |node| node.name == :main || node.attrs.raw['role'] == 'main' }
|
|
56
73
|
end
|
|
57
|
-
module_function :
|
|
58
|
-
private_class_method :
|
|
74
|
+
module_function :main_landmark
|
|
75
|
+
private_class_method :main_landmark
|
|
59
76
|
|
|
60
|
-
def
|
|
61
|
-
|
|
62
|
-
.max_by(segmenter.use_top_selectors) { |_path, nodes| nodes.size }
|
|
63
|
-
.map(&:first)
|
|
77
|
+
def inside_main?(index, node, main)
|
|
78
|
+
node.equal?(main) || index.descendant_of?(node, main)
|
|
64
79
|
end
|
|
65
|
-
module_function :
|
|
66
|
-
private_class_method :
|
|
80
|
+
module_function :inside_main?
|
|
81
|
+
private_class_method :inside_main?
|
|
67
82
|
|
|
68
83
|
def relevant_anchor?(segmenter, node)
|
|
69
84
|
facts = segmenter.link_resolver.destination_facts(node)
|
|
@@ -77,12 +92,15 @@ module Html2rss
|
|
|
77
92
|
module_function :relevant_anchor?
|
|
78
93
|
private_class_method :relevant_anchor?
|
|
79
94
|
|
|
95
|
+
BOUNDARY_TAGS = Set[:body, :html].freeze
|
|
96
|
+
private_constant :BOUNDARY_TAGS
|
|
97
|
+
|
|
80
98
|
def parent_until_boundary(segmenter, node)
|
|
81
99
|
index = segmenter.index
|
|
82
|
-
link_counts = Hash.new { |hash, curr| hash[curr] =
|
|
100
|
+
link_counts = Hash.new { |hash, curr| hash[curr] = count_relevant_links(segmenter, curr) }
|
|
83
101
|
|
|
84
102
|
index.parent_until(node, lambda { |curr|
|
|
85
|
-
return true if
|
|
103
|
+
return true if BOUNDARY_TAGS.include?(curr.name)
|
|
86
104
|
return false if index.ignored_chrome?(curr)
|
|
87
105
|
|
|
88
106
|
parent = index.parent_of(curr)
|
|
@@ -92,11 +110,16 @@ module Html2rss
|
|
|
92
110
|
module_function :parent_until_boundary
|
|
93
111
|
private_class_method :parent_until_boundary
|
|
94
112
|
|
|
95
|
-
def
|
|
96
|
-
|
|
113
|
+
def count_relevant_links(segmenter, node)
|
|
114
|
+
# Chrome inside a card must not stop the walk before the shared list parent.
|
|
115
|
+
if node.link?
|
|
116
|
+
relevant_anchor?(segmenter, node) ? 1 : 0
|
|
117
|
+
else
|
|
118
|
+
node.count_descendants { |child| child.link? && relevant_anchor?(segmenter, child) }
|
|
119
|
+
end
|
|
97
120
|
end
|
|
98
|
-
module_function :
|
|
99
|
-
private_class_method :
|
|
121
|
+
module_function :count_relevant_links
|
|
122
|
+
private_class_method :count_relevant_links
|
|
100
123
|
end
|
|
101
124
|
end
|
|
102
125
|
end
|
|
@@ -7,7 +7,7 @@ module Html2rss
|
|
|
7
7
|
#
|
|
8
8
|
# Strategies:
|
|
9
9
|
# - +:semantic+ — leaf article/section/li/tr/div containers with a primary link
|
|
10
|
-
# - +:list+ — repeated
|
|
10
|
+
# - +:list+ — repeated cards grouped by parent (`main` is a filter when present)
|
|
11
11
|
# - +:cluster+ — class / structure clustering for anchorless card grids
|
|
12
12
|
class Segmenter
|
|
13
13
|
# @param document [SST::Document]
|
|
@@ -68,11 +68,11 @@ module Html2rss
|
|
|
68
68
|
# @param container [SST::Node]
|
|
69
69
|
# @return [Boolean]
|
|
70
70
|
def landmark_ancestor?(anchor, container)
|
|
71
|
-
cache = (@landmark_cache ||= {})
|
|
72
|
-
|
|
73
|
-
return
|
|
71
|
+
cache = (@landmark_cache ||= {}.compare_by_identity)
|
|
72
|
+
anchor_cache = (cache[anchor] ||= {}.compare_by_identity)
|
|
73
|
+
return anchor_cache[container] if anchor_cache.key?(container)
|
|
74
74
|
|
|
75
|
-
|
|
75
|
+
anchor_cache[container] = compute_landmark_ancestor?(anchor:, container:)
|
|
76
76
|
end
|
|
77
77
|
|
|
78
78
|
private
|
data/lib/html2rss/auto_source.rb
CHANGED
|
@@ -61,6 +61,9 @@ module Html2rss
|
|
|
61
61
|
json_state: {
|
|
62
62
|
enabled: true
|
|
63
63
|
},
|
|
64
|
+
json_document: {
|
|
65
|
+
enabled: true
|
|
66
|
+
},
|
|
64
67
|
xhr_articles: {
|
|
65
68
|
enabled: true
|
|
66
69
|
},
|
|
@@ -93,7 +96,7 @@ module Html2rss
|
|
|
93
96
|
Scraper::SCRAPERS.sum do |scraper|
|
|
94
97
|
if config.dig(:scraper, scraper.options_key, :enabled)
|
|
95
98
|
opts = config.dig(:scraper, scraper.options_key)
|
|
96
|
-
scraper.
|
|
99
|
+
scraper.request_slots(opts)
|
|
97
100
|
else
|
|
98
101
|
0
|
|
99
102
|
end
|
|
@@ -113,6 +116,7 @@ module Html2rss
|
|
|
113
116
|
@parsed_body = response.parsed_body
|
|
114
117
|
@body = response.body
|
|
115
118
|
@url = response.url
|
|
119
|
+
@json_response = response.json_response?
|
|
116
120
|
@captured_responses = response.captured_responses
|
|
117
121
|
@opts = opts
|
|
118
122
|
@request_session = request_session
|
|
@@ -131,7 +135,12 @@ module Html2rss
|
|
|
131
135
|
def articles
|
|
132
136
|
@articles ||= extract_articles
|
|
133
137
|
rescue Html2rss::AutoSource::Scraper::NoScraperFound => error
|
|
134
|
-
Log.warn
|
|
138
|
+
Log.warn({
|
|
139
|
+
event_name: 'auto_source.no_scraper',
|
|
140
|
+
host: url.host,
|
|
141
|
+
category: error.category,
|
|
142
|
+
message: "#{self.class}: no scraper matched #{url} (#{error.message})"
|
|
143
|
+
}.to_json)
|
|
135
144
|
@admission_drops = {}.freeze
|
|
136
145
|
[]
|
|
137
146
|
end
|
|
@@ -150,6 +159,8 @@ module Html2rss
|
|
|
150
159
|
|
|
151
160
|
# rubocop:disable-next Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
152
161
|
def extract_articles
|
|
162
|
+
return extract_json_document_articles if json_response?
|
|
163
|
+
|
|
153
164
|
articles = []
|
|
154
165
|
matched = false
|
|
155
166
|
document = nil
|
|
@@ -180,7 +191,7 @@ module Html2rss
|
|
|
180
191
|
captured_responses:
|
|
181
192
|
)
|
|
182
193
|
next unless instance
|
|
183
|
-
next unless
|
|
194
|
+
next unless instance.extractable?
|
|
184
195
|
|
|
185
196
|
matched = true
|
|
186
197
|
articles.concat(run_scraper(instance))
|
|
@@ -189,11 +200,55 @@ module Html2rss
|
|
|
189
200
|
|
|
190
201
|
raise Scraper.no_scraper_found_for(parsed_body, body:) unless matched
|
|
191
202
|
|
|
203
|
+
finish_articles(articles)
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def json_response?
|
|
207
|
+
@json_response
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def extract_json_document_articles
|
|
211
|
+
instance = json_document_instance
|
|
212
|
+
raise Scraper::NoScraperFound.new(category: :unsupported_surface) unless instance&.extractable?
|
|
213
|
+
|
|
214
|
+
articles = run_scraper(instance)
|
|
215
|
+
raise Scraper::NoScraperFound.new(category: :unsupported_surface) if articles.empty?
|
|
216
|
+
|
|
217
|
+
finish_articles(articles)
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def json_document_instance
|
|
221
|
+
Scraper.build_instance(
|
|
222
|
+
Scraper::JsonDocument,
|
|
223
|
+
parsed_body,
|
|
224
|
+
opts: @opts.fetch(:scraper, {}),
|
|
225
|
+
url:,
|
|
226
|
+
request_session:,
|
|
227
|
+
body:,
|
|
228
|
+
captured_responses:
|
|
229
|
+
)
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def finish_articles(articles)
|
|
233
|
+
captured_count = articles.size
|
|
192
234
|
result = Cleanup.call(articles, url:, **cleanup_options)
|
|
193
235
|
@admission_drops = result.drop_tallies
|
|
236
|
+
log_admission_emptied(captured_count, result) if captured_count.positive? && result.articles.empty?
|
|
194
237
|
result.articles.first(article_limit)
|
|
195
238
|
end
|
|
196
239
|
|
|
240
|
+
def log_admission_emptied(captured_count, result)
|
|
241
|
+
payload = {
|
|
242
|
+
event_name: 'auto_source.admission_emptied',
|
|
243
|
+
host: url.host,
|
|
244
|
+
captured_count:,
|
|
245
|
+
drop_tallies: result.drop_tallies
|
|
246
|
+
}
|
|
247
|
+
hint = Cleanup.admission_hint(result.drop_tallies)
|
|
248
|
+
payload[:hint] = hint if hint
|
|
249
|
+
Log.warn(payload.to_json)
|
|
250
|
+
end
|
|
251
|
+
|
|
197
252
|
def enough_articles?(articles)
|
|
198
253
|
return false if articles.size < article_limit
|
|
199
254
|
|
data/lib/html2rss/batch.rb
CHANGED
|
@@ -10,8 +10,10 @@ module Html2rss
|
|
|
10
10
|
# Default worker threads for batch operations.
|
|
11
11
|
DEFAULT_CONCURRENCY = 5
|
|
12
12
|
|
|
13
|
+
BatchResult = Data.define(:total, :successful, :results)
|
|
14
|
+
|
|
13
15
|
# Value object representing the outcome of a batch operation.
|
|
14
|
-
BatchResult
|
|
16
|
+
class BatchResult
|
|
15
17
|
##
|
|
16
18
|
# @return [Hash{Symbol => Object}]
|
|
17
19
|
def to_h
|
|
@@ -27,7 +27,9 @@ File.write('my-feed.yml', result.yaml)
|
|
|
27
27
|
rss = Html2rss.apply(result.config)
|
|
28
28
|
```
|
|
29
29
|
|
|
30
|
-
`Html2rss.capture` and `Capture.build` both return a `CaptureResult` with YAML (`#yaml`, includes the schema modeline) and quality meta (`has_selectors`, `segment_strategy`, `admission_drops`, `selected_strategy`, `native_feed`).
|
|
30
|
+
`Html2rss.capture` and `Capture.build` both return a `CaptureResult` with YAML (`#yaml`, includes the schema modeline) and quality meta (`has_selectors`, `segment_strategy`, `admission_drops`, `selected_strategy`, `native_feed`, `candidates`).
|
|
31
|
+
|
|
32
|
+
`#candidates` is ranked discovery evidence: `{ items:, title:, link:, published: }` arrays (best-first, deduplicated). Items entries are `{ selector:, enhance:, sample: }`; field entries are `{ selector:, sample: }`. `sample` stays on `#candidates` only — emitted YAML is still `items` + `enhance`. Buckets may be empty when evidence is missing. The default `a[href]` YAML fallback is not listed as discovered evidence.
|
|
31
33
|
|
|
32
34
|
**Wire vs internal:** user-facing **`Html2rss.apply`** ships RSS from a config Hash. Pipeline internals **`Html2rss.feed` / `feed_result`** stay unchanged — use `apply` in CLI/MCP/docs examples, not `feed`.
|
|
33
35
|
|
|
@@ -58,13 +60,13 @@ html2rss capture https://example.com --explain # quality JSON on stderr; YAML
|
|
|
58
60
|
1. **Request** — `FeedPipeline` (AutoFallback when `:auto`)
|
|
59
61
|
2. **Discover** — AutoSource extracts admitted articles
|
|
60
62
|
3. **Segment** — try SST Segmenter strategies `:list` → `:cluster` → `:semantic`
|
|
61
|
-
4. **Gate** —
|
|
62
|
-
5. **Assemble** — `{ items: { selector:, enhance:
|
|
63
|
+
4. **Gate** — keep a derived items selector when ≥ `MIN_SELECTOR_MATCHES` (2) articles match
|
|
64
|
+
5. **Assemble** — `{ items: { selector:, enhance: } }` plus channel. HTML derivation misses fall back to `Selectors::DEFAULT_ITEMS_SELECTOR` (`segment_strategy: :default`) instead of omitting selectors.
|
|
63
65
|
|
|
64
66
|
## Constraints
|
|
65
67
|
|
|
66
68
|
- Selector quality depends on page structure; treat output as a first draft.
|
|
67
|
-
- When
|
|
69
|
+
- When HTML derivation misses (no articles, no SST document, fewer than `MIN_SELECTOR_MATCHES` pairs, or a rescued `ArgumentError`), Capture emits the default items selector with `segment_strategy: :default`. `enhance` still follows admission evidence (false when chrome drops are high). Non-HTML responses still omit selectors.
|
|
68
70
|
- Botasaurus hops need `BOTASAURUS_SCRAPER_URL`.
|
|
69
71
|
|
|
70
72
|
See also {Html2rss::AutoSource} for article discovery and {Html2rss::FeedPipeline} for the `:auto` request chain.
|