html2rss 0.27.2 → 0.29.1
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 +82 -25
- data/html2rss.gemspec +0 -1
- data/lib/html2rss/article.rb +1 -2
- data/lib/html2rss/auto_source/README.md +12 -9
- data/lib/html2rss/auto_source/cleanup.rb +92 -1
- data/lib/html2rss/auto_source/scraper/json_state/document_scanner.rb +3 -5
- data/lib/html2rss/auto_source/scraper/meta_oembed.rb +15 -5
- data/lib/html2rss/auto_source/scraper/microdata.rb +8 -6
- data/lib/html2rss/auto_source/scraper/native_feed.rb +93 -0
- data/lib/html2rss/auto_source/scraper/schema.rb +39 -21
- data/lib/html2rss/auto_source/scraper.rb +6 -4
- data/lib/html2rss/auto_source/segmenter/cluster.rb +2 -3
- data/lib/html2rss/auto_source/segmenter/primary_link.rb +1 -2
- data/lib/html2rss/auto_source/segmenter.rb +1 -2
- data/lib/html2rss/auto_source.rb +32 -11
- data/lib/html2rss/batch.rb +169 -0
- data/lib/html2rss/capture/README.md +15 -6
- data/lib/html2rss/capture.rb +158 -15
- data/lib/html2rss/channel.rb +1 -2
- data/lib/html2rss/cli/probe_view.rb +66 -0
- data/lib/html2rss/cli/render.rb +124 -0
- data/lib/html2rss/cli/validate.rb +91 -0
- data/lib/html2rss/cli.rb +318 -193
- data/lib/html2rss/config/auto_source_contract.rb +7 -0
- data/lib/html2rss/config/request_headers.rb +4 -14
- data/lib/html2rss/config/schema.rb +7 -5
- data/lib/html2rss/config/validator.rb +1 -0
- data/lib/html2rss/config.rb +64 -18
- data/lib/html2rss/doctor/botasaurus.rb +130 -0
- data/lib/html2rss/feed_builder/rss.rb +1 -2
- data/lib/html2rss/feed_pipeline/README.md +7 -5
- data/lib/html2rss/feed_pipeline/auto_fallback.rb +78 -19
- data/lib/html2rss/feed_pipeline/runtime_policy.rb +1 -0
- data/lib/html2rss/feed_pipeline.rb +48 -21
- data/lib/html2rss/feed_resolution/README.md +52 -0
- data/lib/html2rss/feed_resolution/candidate_generator.rb +139 -0
- data/lib/html2rss/feed_resolution/diag.rb +32 -0
- data/lib/html2rss/feed_resolution/options.rb +31 -0
- data/lib/html2rss/feed_resolution/policy.rb +47 -0
- data/lib/html2rss/feed_resolution/probe.rb +59 -0
- data/lib/html2rss/feed_resolution/scorer.rb +49 -0
- data/lib/html2rss/feed_resolution.rb +268 -0
- data/lib/html2rss/html/article_extractor/heading_extractor.rb +24 -14
- data/lib/html2rss/html/article_extractor.rb +4 -9
- data/lib/html2rss/html/feed_link.rb +4 -2
- data/lib/html2rss/html/navigator.rb +4 -39
- data/lib/html2rss/html/probe.rb +82 -0
- data/lib/html2rss/html/sst_article_extractor.rb +1 -2
- data/lib/html2rss/link_destination/noise_policy.rb +1 -2
- data/lib/html2rss/link_destination/path_classifier.rb +20 -19
- data/lib/html2rss/mcp/README.md +80 -0
- data/lib/html2rss/mcp/contract.rb +135 -13
- data/lib/html2rss/mcp/outcome/playbook.rb +122 -0
- data/lib/html2rss/mcp/outcome.rb +112 -40
- data/lib/html2rss/mcp/runtime.rb +45 -0
- data/lib/html2rss/mcp/server/tools.rb +277 -0
- data/lib/html2rss/mcp/server.rb +91 -232
- data/lib/html2rss/mcp.rb +3 -0
- data/lib/html2rss/page_recon/README.md +69 -0
- data/lib/html2rss/page_recon/diagnostics.rb +211 -0
- data/lib/html2rss/page_recon.rb +297 -0
- data/lib/html2rss/recon.rb +252 -0
- data/lib/html2rss/request_service/blocked_surface.rb +1 -0
- data/lib/html2rss/request_service/botasaurus_contract.rb +8 -1
- data/lib/html2rss/request_service/botasaurus_strategy.rb +1 -1
- data/lib/html2rss/request_service/faraday_strategy.rb +11 -7
- data/lib/html2rss/request_service/policy.rb +1 -2
- data/lib/html2rss/request_service/response.rb +27 -4
- data/lib/html2rss/request_service/strategy.rb +1 -2
- data/lib/html2rss/request_service.rb +15 -1
- data/lib/html2rss/request_session.rb +4 -2
- data/lib/html2rss/scrape_target.rb +24 -0
- data/lib/html2rss/selectors/post_processors/sanitize_html.rb +2 -4
- data/lib/html2rss/selectors/post_processors.rb +0 -1
- data/lib/html2rss/selectors.rb +33 -10
- data/lib/html2rss/sst/attrs.rb +1 -2
- data/lib/html2rss/sst/normalizer.rb +49 -11
- data/lib/html2rss/status.rb +48 -24
- data/lib/html2rss/surface_category.rb +63 -0
- data/lib/html2rss/syndication/README.md +21 -0
- data/lib/html2rss/syndication/candidate_catalog.rb +26 -0
- data/lib/html2rss/syndication/discovery.rb +215 -0
- data/lib/html2rss/syndication/parser.rb +137 -0
- data/lib/html2rss/syndication.rb +10 -0
- data/lib/html2rss/test/enhance_audit.rb +192 -0
- data/lib/html2rss/test.rb +458 -0
- data/lib/html2rss/url.rb +23 -2
- data/lib/html2rss/version.rb +1 -1
- data/lib/html2rss.rb +157 -35
- data/schema/html2rss-config.schema.json +47 -29
- metadata +34 -18
- data/lib/html2rss/mcp/inspect.rb +0 -241
- data/lib/html2rss/selectors/post_processors/html_to_markdown.rb +0 -59
|
@@ -15,27 +15,33 @@ module Html2rss
|
|
|
15
15
|
class Schema
|
|
16
16
|
include Enumerable
|
|
17
17
|
|
|
18
|
-
#
|
|
19
|
-
|
|
18
|
+
# Matches a leading schema.org URL prefix on @type values (http or https).
|
|
19
|
+
SCHEMA_ORG_PREFIX_RE = %r{\Ahttps?://schema\.org/}i
|
|
20
|
+
|
|
21
|
+
# Container types that must never be emitted as feed items (walk children only).
|
|
22
|
+
DENIED_CONTAINER_TYPES = Set[
|
|
23
|
+
'ItemList', 'Blog', 'BreadcrumbList', 'WebPage', 'CollectionPage'
|
|
24
|
+
].freeze
|
|
25
|
+
|
|
26
|
+
# Canonical Schema.org type names keyed by folded wire forms (case-insensitive lookup).
|
|
27
|
+
CANONICAL_BY_DOWNCASE = begin
|
|
28
|
+
canonical_types = Thing::SUPPORTED_TYPES | ItemList::SUPPORTED_TYPES | DENIED_CONTAINER_TYPES | Set['Product']
|
|
29
|
+
canonical_types.to_h { |type| [::Html2rss::Html::Probe.fold(type), type] }.freeze
|
|
30
|
+
end.freeze
|
|
20
31
|
|
|
21
32
|
# Pre-compiled regex for supported schema types (short name or schema.org URL; string or array @type).
|
|
22
33
|
# Allows preceding entries in a JSON @type array (e.g. ["WebPage","NewsArticle"]).
|
|
23
34
|
SUPPORTED_TYPES_RE = begin
|
|
24
35
|
types = Thing::SUPPORTED_TYPES | ItemList::SUPPORTED_TYPES
|
|
25
36
|
type_re = Regexp.union(types.to_a)
|
|
26
|
-
%r{"@type"\s*:\s*(?:\[\s*(?:"[^"]*"\s*,\s*)*)?"(?:https?://schema\.org/)?(?:#{type_re.source})"}
|
|
37
|
+
%r{(?i)"@type"\s*:\s*(?:\[\s*(?:"[^"]*"\s*,\s*)*)?"(?:https?://schema\.org/)?(?:#{type_re.source})"}
|
|
27
38
|
end.freeze
|
|
28
39
|
|
|
29
|
-
# Matches a leading schema.org URL prefix on @type values (http or https).
|
|
30
|
-
SCHEMA_ORG_PREFIX_RE = %r{\Ahttps?://schema\.org/}i
|
|
31
|
-
|
|
32
40
|
# Prefer these keys when recursively walking unsupported container objects.
|
|
33
41
|
COLLECTION_KEYS = %i[itemListElement blogPost mainEntity hasPart].freeze
|
|
34
42
|
|
|
35
|
-
#
|
|
36
|
-
|
|
37
|
-
'ItemList', 'Blog', 'BreadcrumbList', 'WebPage', 'CollectionPage'
|
|
38
|
-
].freeze
|
|
43
|
+
# Shared empty type set for nil/unsupported wire forms (avoids per-call Set.new).
|
|
44
|
+
EMPTY_TYPES = Set.new.freeze
|
|
39
45
|
|
|
40
46
|
# @return [Symbol] scraper config key
|
|
41
47
|
def self.options_key = :schema
|
|
@@ -44,7 +50,8 @@ module Html2rss
|
|
|
44
50
|
# @param parsed_body [Nokogiri::HTML::Document] parsed HTML document
|
|
45
51
|
# @return [Boolean] whether the page includes supported schema types
|
|
46
52
|
def articles?(parsed_body)
|
|
47
|
-
|
|
53
|
+
::Html2rss::Html::Probe.scripts(parsed_body, ::Html2rss::Html::Probe::APPLICATION_LD_JSON)
|
|
54
|
+
.any? { |script| supported_schema_type?(script) }
|
|
48
55
|
end
|
|
49
56
|
|
|
50
57
|
# @param script [Nokogiri::XML::Element] schema JSON-LD script tag
|
|
@@ -105,14 +112,26 @@ module Html2rss
|
|
|
105
112
|
case object
|
|
106
113
|
when Array
|
|
107
114
|
object.each_with_object(Set.new) { |item, set| set.merge(normalize_types(item)) }
|
|
108
|
-
when String, Symbol
|
|
109
|
-
short = object.to_s.sub(SCHEMA_ORG_PREFIX_RE, '')
|
|
110
|
-
short.empty? ? Set.new : Set[short]
|
|
111
115
|
else
|
|
112
|
-
|
|
116
|
+
name = canonicalize_type(object)
|
|
117
|
+
name ? Set[name] : EMPTY_TYPES
|
|
113
118
|
end
|
|
114
119
|
end
|
|
115
120
|
|
|
121
|
+
# Canonical short Schema.org type name for a scalar wire form.
|
|
122
|
+
#
|
|
123
|
+
# @param object [String, Symbol, nil] raw `@type` / itemtype token
|
|
124
|
+
# @return [String, nil]
|
|
125
|
+
# @api private
|
|
126
|
+
def canonicalize_type(object)
|
|
127
|
+
return unless object.is_a?(String) || object.is_a?(Symbol)
|
|
128
|
+
|
|
129
|
+
short = object.to_s.sub(SCHEMA_ORG_PREFIX_RE, '')
|
|
130
|
+
return if short.empty?
|
|
131
|
+
|
|
132
|
+
CANONICAL_BY_DOWNCASE.fetch(::Html2rss::Html::Probe.fold(short), short)
|
|
133
|
+
end
|
|
134
|
+
|
|
116
135
|
private
|
|
117
136
|
|
|
118
137
|
# @param hash [Hash] candidate schema object
|
|
@@ -150,15 +169,15 @@ module Html2rss
|
|
|
150
169
|
##
|
|
151
170
|
# @yield [Hash] Each scraped article_hash
|
|
152
171
|
# @return [Array<Hash>] the scraped article_hashes
|
|
153
|
-
def each
|
|
172
|
+
def each
|
|
154
173
|
return enum_for(:each) unless block_given?
|
|
155
174
|
|
|
156
|
-
schema_objects.
|
|
175
|
+
schema_objects.each do |schema_object|
|
|
157
176
|
next unless (klass = self.class.scraper_for_schema_object(schema_object))
|
|
158
177
|
next unless (results = klass.new(schema_object, url:).call)
|
|
159
178
|
|
|
160
179
|
if results.is_a?(Array)
|
|
161
|
-
results.each {
|
|
180
|
+
results.each { yield(_1) }
|
|
162
181
|
else
|
|
163
182
|
yield(results)
|
|
164
183
|
end
|
|
@@ -168,9 +187,8 @@ module Html2rss
|
|
|
168
187
|
private
|
|
169
188
|
|
|
170
189
|
def schema_objects
|
|
171
|
-
@parsed_body
|
|
172
|
-
|
|
173
|
-
end
|
|
190
|
+
::Html2rss::Html::Probe.scripts(@parsed_body, ::Html2rss::Html::Probe::APPLICATION_LD_JSON)
|
|
191
|
+
.flat_map { |tag| Schema.from(tag) }
|
|
174
192
|
end
|
|
175
193
|
|
|
176
194
|
attr_reader :parsed_body, :url
|
|
@@ -23,7 +23,9 @@ module Html2rss
|
|
|
23
23
|
|
|
24
24
|
# Extraction tiers: merge within a tier, then stop when AutoSource has enough articles.
|
|
25
25
|
# Heuristic scrapers are separate tiers so SemanticHtml can satisfy before Html runs.
|
|
26
|
+
# NativeFeed is tier 0 so syndication wins before structured HTML scrapers.
|
|
26
27
|
SCRAPER_TIERS = [
|
|
28
|
+
[NativeFeed].freeze,
|
|
27
29
|
[Schema, Microdata, Microformats2, JsonState, XhrArticles].freeze,
|
|
28
30
|
[WordpressApi, Sitemap, MetaOembed].freeze,
|
|
29
31
|
[SemanticHtml].freeze,
|
|
@@ -36,7 +38,7 @@ module Html2rss
|
|
|
36
38
|
# Heuristic scrapers that share one memoized SST::Document per page.
|
|
37
39
|
HEURISTIC_SCRAPERS = [SemanticHtml, Html].freeze
|
|
38
40
|
# Scrapers that accept a shared follow-up +request_session+.
|
|
39
|
-
REQUEST_SESSION_SCRAPERS = [WordpressApi, Sitemap, MetaOembed].freeze
|
|
41
|
+
REQUEST_SESSION_SCRAPERS = [NativeFeed, WordpressApi, Sitemap, MetaOembed].freeze
|
|
40
42
|
# Scrapers that consume browser-captured XHR/fetch JSON bodies.
|
|
41
43
|
CAPTURED_RESPONSE_SCRAPERS = [XhrArticles].freeze
|
|
42
44
|
|
|
@@ -83,6 +85,7 @@ module Html2rss
|
|
|
83
85
|
# Returns an array of scraper classes that claim to find articles in the parsed body.
|
|
84
86
|
# @param parsed_body [Nokogiri::HTML::Document] The parsed HTML document.
|
|
85
87
|
# @param opts [Hash] The options hash.
|
|
88
|
+
# @option opts [Hash] :native_feed scraper toggle and configuration
|
|
86
89
|
# @option opts [Hash] :wordpress_api scraper toggle and configuration
|
|
87
90
|
# @option opts [Hash] :schema scraper toggle and configuration
|
|
88
91
|
# @option opts [Hash] :microdata scraper toggle and configuration
|
|
@@ -115,7 +118,7 @@ module Html2rss
|
|
|
115
118
|
# @return [SST::Document, nil]
|
|
116
119
|
def self.normalize_sst(parsed_body)
|
|
117
120
|
SST::Normalizer.call(parsed_body)
|
|
118
|
-
rescue
|
|
121
|
+
rescue SST::Normalizer::EmptyTree
|
|
119
122
|
nil
|
|
120
123
|
end
|
|
121
124
|
|
|
@@ -142,7 +145,7 @@ module Html2rss
|
|
|
142
145
|
# @option opts [Hash] :html scraper toggle and configuration
|
|
143
146
|
# @option opts [Hash] :sitemap scraper toggle and configuration
|
|
144
147
|
# @return [Object, nil]
|
|
145
|
-
# rubocop:disable Metrics/ParameterLists, Metrics/MethodLength -- construction context for structured and heuristic scrapers
|
|
148
|
+
# rubocop:disable-next Metrics/ParameterLists, Metrics/MethodLength -- construction context for structured and heuristic scrapers
|
|
146
149
|
def self.build_instance(scraper, parsed_body, opts:, url:, request_session: nil, body: nil, document: nil,
|
|
147
150
|
link_resolver: nil, captured_responses: [])
|
|
148
151
|
return unless opts.dig(scraper.options_key, :enabled)
|
|
@@ -159,7 +162,6 @@ module Html2rss
|
|
|
159
162
|
)
|
|
160
163
|
scraper.new(parsed_body, url:, **kwargs, **scraper_opts)
|
|
161
164
|
end
|
|
162
|
-
# rubocop:enable Metrics/ParameterLists, Metrics/MethodLength
|
|
163
165
|
|
|
164
166
|
##
|
|
165
167
|
# @param instance [Object]
|
|
@@ -64,7 +64,7 @@ module Html2rss
|
|
|
64
64
|
def normalize_class(class_names)
|
|
65
65
|
return '' if class_names.empty?
|
|
66
66
|
|
|
67
|
-
class_names.sort.join(' ')
|
|
67
|
+
class_names.map { ::Html2rss::Html::Probe.fold(_1) }.sort.join(' ')
|
|
68
68
|
end
|
|
69
69
|
module_function :normalize_class
|
|
70
70
|
private_class_method :normalize_class
|
|
@@ -122,7 +122,7 @@ module Html2rss
|
|
|
122
122
|
groups.sort_by { |_key, nodes| -nodes.size }.first(MAX_GROUPS).to_h
|
|
123
123
|
end
|
|
124
124
|
|
|
125
|
-
# rubocop:disable Metrics/MethodLength
|
|
125
|
+
# rubocop:disable-next Metrics/MethodLength
|
|
126
126
|
def container_of?(nodes_a, nodes_b)
|
|
127
127
|
return false unless @layout_tags.include?(nodes_b.first.name)
|
|
128
128
|
|
|
@@ -139,7 +139,6 @@ module Html2rss
|
|
|
139
139
|
count > 1
|
|
140
140
|
end
|
|
141
141
|
end
|
|
142
|
-
# rubocop:enable Metrics/MethodLength
|
|
143
142
|
|
|
144
143
|
def resolve_1_to_1_overlap(cls_a, cls_b, groups, discarded)
|
|
145
144
|
nodes_a = groups[cls_a]
|
|
@@ -37,7 +37,7 @@ module Html2rss
|
|
|
37
37
|
|
|
38
38
|
private
|
|
39
39
|
|
|
40
|
-
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
40
|
+
# rubocop:disable-next Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
41
41
|
def candidate_facts(anchor, container)
|
|
42
42
|
destination = @link_resolver.destination_facts(anchor)
|
|
43
43
|
return unless destination
|
|
@@ -63,7 +63,6 @@ module Html2rss
|
|
|
63
63
|
|
|
64
64
|
{ anchor:, destination: destination.destination, score: }
|
|
65
65
|
end
|
|
66
|
-
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
67
66
|
|
|
68
67
|
def first_heading(container)
|
|
69
68
|
(@headings ||= {}.compare_by_identity)[container] ||= container.find(&:heading?)
|
|
@@ -19,11 +19,10 @@ module Html2rss
|
|
|
19
19
|
# @option opts [Integer] :use_top_selectors list strategy selector budget
|
|
20
20
|
# @option opts [Scoring::LinkResolver, nil] :link_resolver page-scoped destination facts cache
|
|
21
21
|
# @return [Array<Segment>]
|
|
22
|
-
# rubocop:disable Style/ArgumentsForwarding -- keep named opts for YARD @option
|
|
22
|
+
# rubocop:disable-next Style/ArgumentsForwarding -- keep named opts for YARD @option
|
|
23
23
|
def self.call(document, base_url:, strategy:, **opts)
|
|
24
24
|
new(document, base_url:, strategy:, **opts).call
|
|
25
25
|
end
|
|
26
|
-
# rubocop:enable Style/ArgumentsForwarding
|
|
27
26
|
|
|
28
27
|
# @param document [SST::Document]
|
|
29
28
|
# @param base_url [String, Html2rss::Url]
|
data/lib/html2rss/auto_source.rb
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'dry-validation'
|
|
4
|
+
require 'json'
|
|
5
|
+
require 'nokogiri'
|
|
4
6
|
|
|
5
7
|
module Html2rss
|
|
6
8
|
##
|
|
@@ -20,10 +22,25 @@ module Html2rss
|
|
|
20
22
|
# Default max articles to keep (also the short-circuit floor across scraper tiers).
|
|
21
23
|
DEFAULT_LIMIT = 25
|
|
22
24
|
|
|
25
|
+
# Operational scraper failures quarantined within a tier; programmer errors propagate.
|
|
26
|
+
OPERATIONAL_ERRORS = [
|
|
27
|
+
Html2rss::Error,
|
|
28
|
+
ArgumentError,
|
|
29
|
+
::JSON::ParserError,
|
|
30
|
+
::Nokogiri::SyntaxError
|
|
31
|
+
].freeze
|
|
32
|
+
|
|
23
33
|
# Default auto-source configuration shipped for scraper and cleanup behavior.
|
|
24
34
|
DEFAULT_CONFIG = {
|
|
25
35
|
limit: DEFAULT_LIMIT,
|
|
36
|
+
entry_resolution: {
|
|
37
|
+
enabled: true,
|
|
38
|
+
max_probes: 5
|
|
39
|
+
}.freeze,
|
|
26
40
|
scraper: {
|
|
41
|
+
native_feed: {
|
|
42
|
+
enabled: true
|
|
43
|
+
},
|
|
27
44
|
wordpress_api: {
|
|
28
45
|
enabled: true
|
|
29
46
|
},
|
|
@@ -131,7 +148,7 @@ module Html2rss
|
|
|
131
148
|
|
|
132
149
|
attr_reader :url, :parsed_body, :body, :request_session, :captured_responses
|
|
133
150
|
|
|
134
|
-
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
151
|
+
# rubocop:disable-next Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
135
152
|
def extract_articles
|
|
136
153
|
articles = []
|
|
137
154
|
matched = false
|
|
@@ -176,7 +193,6 @@ module Html2rss
|
|
|
176
193
|
@admission_drops = result.drop_tallies
|
|
177
194
|
result.articles.first(article_limit)
|
|
178
195
|
end
|
|
179
|
-
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
180
196
|
|
|
181
197
|
def enough_articles?(articles)
|
|
182
198
|
return false if articles.size < article_limit
|
|
@@ -204,15 +220,20 @@ module Html2rss
|
|
|
204
220
|
end
|
|
205
221
|
|
|
206
222
|
def run_scraper(instance)
|
|
207
|
-
instance.
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
223
|
+
instance.map { |item| article_from_scraper_item(item, instance) }
|
|
224
|
+
rescue *OPERATIONAL_ERRORS => error
|
|
225
|
+
Log.warn("#{self.class}: #{instance.class} quarantined #{error.class}: #{error.message}")
|
|
226
|
+
[]
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def article_from_scraper_item(item, instance)
|
|
230
|
+
case item
|
|
231
|
+
when Article
|
|
232
|
+
item
|
|
233
|
+
when Hash
|
|
234
|
+
Article.new(**item, scraper: instance.class)
|
|
235
|
+
else
|
|
236
|
+
raise TypeError, "#{instance.class} yielded #{item.class}; expected Article or Hash"
|
|
216
237
|
end
|
|
217
238
|
end
|
|
218
239
|
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Html2rss
|
|
4
|
+
##
|
|
5
|
+
# Concurrency-bounded batch execution over enumerables with input order preservation
|
|
6
|
+
# and per-item error isolation.
|
|
7
|
+
module Batch
|
|
8
|
+
# Maximum permitted worker threads for batch operations.
|
|
9
|
+
MAX_CONCURRENCY = 10
|
|
10
|
+
# Default worker threads for batch operations.
|
|
11
|
+
DEFAULT_CONCURRENCY = 5
|
|
12
|
+
|
|
13
|
+
# Value object representing the outcome of a batch operation.
|
|
14
|
+
BatchResult = Data.define(:total, :successful, :results) do
|
|
15
|
+
##
|
|
16
|
+
# @return [Hash{Symbol => Object}]
|
|
17
|
+
def to_h
|
|
18
|
+
{ total:, successful:, results: }
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
class << self
|
|
23
|
+
##
|
|
24
|
+
# Executes a block concurrently across items using a bounded worker pool.
|
|
25
|
+
# Results are returned in input order.
|
|
26
|
+
#
|
|
27
|
+
# @param items [Enumerable<Object>]
|
|
28
|
+
# @param concurrency [Integer] max parallel worker threads (1..10, default: 5)
|
|
29
|
+
# @yieldparam item [Object]
|
|
30
|
+
# @return [Array<Object>]
|
|
31
|
+
def map(items, concurrency: DEFAULT_CONCURRENCY, &)
|
|
32
|
+
list = Array(items)
|
|
33
|
+
return [] if list.empty?
|
|
34
|
+
|
|
35
|
+
worker_count = worker_pool_size(list.size, concurrency)
|
|
36
|
+
process_queue(list, worker_count, &)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
##
|
|
40
|
+
# Runs a batch operation that yields items and wraps the results in a {BatchResult}.
|
|
41
|
+
#
|
|
42
|
+
# @param items [Enumerable<Object>]
|
|
43
|
+
# @param concurrency [Integer] max parallel worker threads (1..10, default: 5)
|
|
44
|
+
# @yieldparam item [Object]
|
|
45
|
+
# @return [BatchResult]
|
|
46
|
+
def run(items, concurrency: DEFAULT_CONCURRENCY, &)
|
|
47
|
+
list = Array(items)
|
|
48
|
+
results = map(list, concurrency:, &)
|
|
49
|
+
successful = results.count { |entry| entry.is_a?(Hash) ? entry[:ok] : !entry.nil? }
|
|
50
|
+
|
|
51
|
+
BatchResult.new(total: list.size, successful:, results:)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
##
|
|
55
|
+
# Single-URL scrape wire extraction: auto_feed_result → JSON Feed items.
|
|
56
|
+
#
|
|
57
|
+
# @param url [String]
|
|
58
|
+
# @param strategy [Symbol, String]
|
|
59
|
+
# @param limit [Integer]
|
|
60
|
+
# @param items_selector [String, nil]
|
|
61
|
+
# @return [Hash{Symbol => Object}] +:strategy+, +:items+, +:channel_title+, +:admission_drops+
|
|
62
|
+
def scrape_wire(url:, strategy:, limit:, items_selector: nil) # rubocop:disable Metrics/MethodLength
|
|
63
|
+
plan = (strategy || :auto).to_sym
|
|
64
|
+
feed_args = { strategy: plan, limit: }
|
|
65
|
+
feed_args[:items_selector] = items_selector unless items_selector.nil?
|
|
66
|
+
feed_result = Html2rss.auto_feed_result(url, **feed_args)
|
|
67
|
+
feed = feed_result.to_json_feed
|
|
68
|
+
{
|
|
69
|
+
strategy: plan,
|
|
70
|
+
items: feed[:items] || [],
|
|
71
|
+
channel_title: feed[:title],
|
|
72
|
+
admission_drops: feed_result.status.admission_drops
|
|
73
|
+
}
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
##
|
|
77
|
+
# Scrapes multiple URLs in parallel with per-URL error isolation.
|
|
78
|
+
#
|
|
79
|
+
# @param urls [Enumerable<String>] list of URLs to scrape
|
|
80
|
+
# @param strategy [Symbol, String] request strategy (+:auto+, +:faraday+, +:botasaurus+)
|
|
81
|
+
# @param limit [Integer] max articles to extract per URL
|
|
82
|
+
# @param concurrency [Integer] number of worker threads (default 5, max 10)
|
|
83
|
+
# @return [BatchResult]
|
|
84
|
+
def batch_scrape(urls:, strategy: :auto, limit: 10, concurrency: DEFAULT_CONCURRENCY)
|
|
85
|
+
run(urls, concurrency:) { |url| scrape_single_url(url:, strategy:, limit:) }
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
##
|
|
89
|
+
# Inspects multiple URLs in parallel with per-URL error isolation.
|
|
90
|
+
#
|
|
91
|
+
# @param urls [Enumerable<String>] list of URLs to inspect
|
|
92
|
+
# @param strategy [Symbol, String] request strategy (+:auto+, +:faraday+, +:botasaurus+)
|
|
93
|
+
# @param concurrency [Integer] number of worker threads (default 5, max 10)
|
|
94
|
+
# @return [BatchResult]
|
|
95
|
+
def batch_inspect(urls:, strategy: :auto, concurrency: DEFAULT_CONCURRENCY)
|
|
96
|
+
reports = PageRecon::Diagnostics.batch(urls:, strategy:, concurrency:)
|
|
97
|
+
results = reports.map(&:to_wire_h)
|
|
98
|
+
successful = reports.count { |report| diagnostic_success?(report) }
|
|
99
|
+
|
|
100
|
+
BatchResult.new(total: results.size, successful:, results:)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
##
|
|
104
|
+
# Runs recon across multiple URLs in parallel with per-URL error isolation.
|
|
105
|
+
#
|
|
106
|
+
# @param urls [Enumerable<String>] list of URLs to recon
|
|
107
|
+
# @param strategy [Symbol, String] request strategy (+:auto+, +:faraday+, +:botasaurus+)
|
|
108
|
+
# @param concurrency [Integer] number of worker threads (default 5, max 10)
|
|
109
|
+
# @option options [String, nil] :cache_dir optional HTML cache directory
|
|
110
|
+
# @return [BatchResult]
|
|
111
|
+
def batch_recon(urls:, strategy: :auto, concurrency: DEFAULT_CONCURRENCY, **)
|
|
112
|
+
results = Recon.batch(urls, strategy: (strategy || :auto).to_sym, max_threads: concurrency, **)
|
|
113
|
+
successful = results.count { |r| r.status ? r.status < 400 : false }
|
|
114
|
+
BatchResult.new(total: results.size, successful:, results: results.map(&:to_h))
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
private
|
|
118
|
+
|
|
119
|
+
def diagnostic_success?(report)
|
|
120
|
+
status = report.to_wire_h[:status]
|
|
121
|
+
status ? status < 400 : false
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def scrape_single_url(url:, strategy:, limit:)
|
|
125
|
+
wire = scrape_wire(url:, strategy:, limit:)
|
|
126
|
+
{
|
|
127
|
+
url: url.to_s, ok: true, items_count: wire[:items].size,
|
|
128
|
+
items: wire[:items], channel_title: wire[:channel_title]
|
|
129
|
+
}
|
|
130
|
+
rescue StandardError => error
|
|
131
|
+
{ url: url.to_s, ok: false, error: error.message }
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def worker_pool_size(item_count, requested_concurrency)
|
|
135
|
+
return 1 if item_count <= 1
|
|
136
|
+
|
|
137
|
+
[[1, requested_concurrency.to_i].max, item_count, MAX_CONCURRENCY].min
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def process_queue(items, worker_count, &)
|
|
141
|
+
queue = build_work_queue(items)
|
|
142
|
+
results = Array.new(items.size)
|
|
143
|
+
|
|
144
|
+
threads = Array.new(worker_count) do
|
|
145
|
+
Thread.new { drain_queue(queue, results, &) } # rubocop:disable ThreadSafety/NewThread
|
|
146
|
+
end
|
|
147
|
+
threads.each(&:join)
|
|
148
|
+
results
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def build_work_queue(items)
|
|
152
|
+
Queue.new.tap do |queue|
|
|
153
|
+
items.each_with_index { |item, index| queue << [item, index] }
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def drain_queue(queue, results)
|
|
158
|
+
until queue.empty?
|
|
159
|
+
begin
|
|
160
|
+
item, index = queue.pop(true)
|
|
161
|
+
rescue ThreadError
|
|
162
|
+
break
|
|
163
|
+
end
|
|
164
|
+
results[index] = yield(item)
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
# Capture
|
|
2
2
|
|
|
3
|
-
`Html2rss.capture` (and CLI `html2rss capture`) analyzes a URL through the feed pipeline and produces a reusable config with **items selector + `enhance: true` only** — no title/url/description attribute-selector soup. At feed-build time, `enhance: true` fills missing article fields via `Html::ArticleExtractor` on each matched item
|
|
3
|
+
`Html2rss.capture` (and CLI `html2rss capture`) analyzes a URL through the feed pipeline and produces a reusable config with **items selector + `enhance: true` only** — no title/url/description attribute-selector soup. At feed-build time, `enhance: true` fills missing article fields via `Html::ArticleExtractor` on each **matched list-card item node**.
|
|
4
|
+
|
|
5
|
+
## API
|
|
6
|
+
|
|
7
|
+
`Html2rss.capture` / `Capture.build` return `Capture::CaptureResult` — use `#yaml` or `#config`, not a bare Hash.
|
|
4
8
|
|
|
5
9
|
## When to use it
|
|
6
10
|
|
|
@@ -9,8 +13,9 @@ Point `capture` at a listing URL when you want a first-draft YAML config instead
|
|
|
9
13
|
## Gem API
|
|
10
14
|
|
|
11
15
|
```ruby
|
|
12
|
-
|
|
16
|
+
result = Html2rss.capture('https://example.com/articles')
|
|
13
17
|
|
|
18
|
+
# result.config =>
|
|
14
19
|
# {
|
|
15
20
|
# channel: { url: "...", title: "...", time_zone: "UTC" },
|
|
16
21
|
# selectors: {
|
|
@@ -18,11 +23,13 @@ config = Html2rss.capture('https://example.com/articles')
|
|
|
18
23
|
# }
|
|
19
24
|
# }
|
|
20
25
|
|
|
21
|
-
File.write('my-feed.yml',
|
|
22
|
-
|
|
26
|
+
File.write('my-feed.yml', result.yaml)
|
|
27
|
+
rss = Html2rss.apply(result.config)
|
|
23
28
|
```
|
|
24
29
|
|
|
25
|
-
`Capture.build`
|
|
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`).
|
|
31
|
+
|
|
32
|
+
**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`.
|
|
26
33
|
|
|
27
34
|
### Options
|
|
28
35
|
|
|
@@ -30,15 +37,17 @@ feed = Html2rss.feed(config)
|
|
|
30
37
|
Html2rss.capture('https://spa-site.com', strategy: :botasaurus)
|
|
31
38
|
Html2rss.capture('https://example.com', items_selector: '.article-card')
|
|
32
39
|
Html2rss.capture('https://example.com', strategy: :local_file, local_file_path: './page.html')
|
|
40
|
+
Html2rss.capture('https://example.com', max_redirects: 8, max_requests: 4)
|
|
33
41
|
```
|
|
34
42
|
|
|
35
|
-
`strategy: :auto` uses the same AutoFallback chain as scrape (`faraday` → `botasaurus`). When AutoFallback selects a concrete strategy (or you pin one), Capture **stamps** `strategy:` into the emitted config so later `Html2rss.
|
|
43
|
+
`strategy: :auto` uses the same AutoFallback chain as scrape (`faraday` → `botasaurus`). When AutoFallback selects a concrete strategy (or you pin one), Capture **stamps** `strategy:` into the emitted config so later `Html2rss.apply(config)` replays the same transport.
|
|
36
44
|
|
|
37
45
|
## CLI
|
|
38
46
|
|
|
39
47
|
```bash
|
|
40
48
|
html2rss capture https://example.com/articles
|
|
41
49
|
html2rss capture https://example.com --strategy botasaurus
|
|
50
|
+
html2rss capture https://example.com --max-redirects 8 --max-requests 4
|
|
42
51
|
html2rss capture https://example.com/articles > my-feed.yml
|
|
43
52
|
html2rss capture https://example.com --input ./page.html
|
|
44
53
|
html2rss capture https://example.com --explain # quality JSON on stderr; YAML on stdout
|