html2rss 0.32.4 → 0.34.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +4 -2
- data/lib/html2rss/article/deduplicator.rb +5 -2
- data/lib/html2rss/article/enclosure.rb +185 -25
- data/lib/html2rss/article/title.rb +203 -0
- data/lib/html2rss/article.rb +79 -13
- data/lib/html2rss/auto_source/README.md +16 -8
- data/lib/html2rss/auto_source/cleanup.rb +127 -53
- data/lib/html2rss/auto_source/corroboration.rb +167 -0
- data/lib/html2rss/auto_source/page_links/anchor_claims.rb +82 -0
- data/lib/html2rss/auto_source/page_links/card_units.rb +77 -0
- data/lib/html2rss/auto_source/page_links/label_binding.rb +79 -0
- data/lib/html2rss/auto_source/page_links/sponsored_cards.rb +147 -0
- data/lib/html2rss/auto_source/page_links.rb +164 -0
- data/lib/html2rss/auto_source/scraper/html.rb +11 -45
- data/lib/html2rss/auto_source/scraper/json_document.rb +3 -6
- data/lib/html2rss/auto_source/scraper/json_state/article_normalizer.rb +202 -125
- data/lib/html2rss/auto_source/scraper/json_state/candidate_detector.rb +27 -38
- data/lib/html2rss/auto_source/scraper/json_state/document_scanner.rb +123 -123
- data/lib/html2rss/auto_source/scraper/json_state/value_finder.rb +27 -35
- data/lib/html2rss/auto_source/scraper/json_state.rb +4 -9
- data/lib/html2rss/auto_source/scraper/meta_oembed.rb +27 -5
- data/lib/html2rss/auto_source/scraper/microdata.rb +233 -248
- data/lib/html2rss/auto_source/scraper/microformats2.rb +10 -4
- data/lib/html2rss/auto_source/scraper/native_feed.rb +28 -18
- data/lib/html2rss/auto_source/scraper/schema/category_extractor.rb +7 -2
- data/lib/html2rss/auto_source/scraper/schema/item_list.rb +5 -3
- data/lib/html2rss/auto_source/scraper/schema/thing.rb +140 -22
- data/lib/html2rss/auto_source/scraper/schema.rb +52 -8
- data/lib/html2rss/auto_source/scraper/semantic_html/entry_deduplicator.rb +3 -9
- data/lib/html2rss/auto_source/scraper/semantic_html.rb +12 -20
- data/lib/html2rss/auto_source/scraper/sitemap/parser.rb +1 -4
- data/lib/html2rss/auto_source/scraper/sitemap.rb +7 -14
- data/lib/html2rss/auto_source/scraper/wordpress_api/page_scope.rb +2 -2
- data/lib/html2rss/auto_source/scraper/wordpress_api/posts_endpoint.rb +1 -3
- data/lib/html2rss/auto_source/scraper/wordpress_api.rb +7 -7
- data/lib/html2rss/auto_source/scraper/xhr_articles.rb +2 -4
- data/lib/html2rss/auto_source/scraper.rb +10 -13
- data/lib/html2rss/auto_source/segment.rb +19 -5
- data/lib/html2rss/auto_source/segmenter/cluster.rb +18 -22
- data/lib/html2rss/auto_source/segmenter/list.rb +35 -59
- data/lib/html2rss/auto_source/segmenter/primary_link.rb +29 -4
- data/lib/html2rss/auto_source/segmenter/semantic.rb +23 -19
- data/lib/html2rss/auto_source/segmenter.rb +4 -8
- data/lib/html2rss/auto_source.rb +81 -26
- data/lib/html2rss/batch.rb +24 -32
- data/lib/html2rss/capture/README.md +6 -3
- data/lib/html2rss/capture.rb +51 -59
- data/lib/html2rss/channel.rb +30 -12
- data/lib/html2rss/cli/render.rb +97 -96
- data/lib/html2rss/cli/validate.rb +2 -4
- data/lib/html2rss/cli.rb +55 -38
- data/lib/html2rss/config/auto_source_contract.rb +52 -45
- data/lib/html2rss/config/issue_mapper.rb +4 -1
- data/lib/html2rss/config/request_controls.rb +16 -5
- data/lib/html2rss/config/schema.rb +23 -15
- data/lib/html2rss/config/selectors_validator.rb +3 -2
- data/lib/html2rss/config/validation_issue.rb +4 -1
- data/lib/html2rss/config/validator.rb +14 -3
- data/lib/html2rss/config.rb +54 -28
- data/lib/html2rss/contract_error.rb +9 -0
- data/lib/html2rss/defaults.rb +25 -38
- data/lib/html2rss/doctor/botasaurus.rb +18 -22
- data/lib/html2rss/error.rb +17 -3
- data/lib/html2rss/error_kind.rb +39 -0
- data/lib/html2rss/feed_builder/item_presentation.rb +22 -9
- data/lib/html2rss/feed_builder/json_feed/item.rb +2 -1
- data/lib/html2rss/feed_builder/json_feed.rb +2 -2
- data/lib/html2rss/feed_builder/rss/stylesheet.rb +3 -3
- data/lib/html2rss/feed_builder/rss.rb +50 -7
- data/lib/html2rss/feed_builder.rb +2 -1
- data/lib/html2rss/feed_pipeline/README.md +1 -1
- data/lib/html2rss/feed_pipeline/auto_fallback.rb +16 -34
- data/lib/html2rss/feed_pipeline/strategy_plan.rb +3 -3
- data/lib/html2rss/feed_pipeline.rb +40 -27
- data/lib/html2rss/feed_resolution/README.md +1 -1
- data/lib/html2rss/feed_resolution/candidate_generator.rb +6 -8
- data/lib/html2rss/feed_resolution/options.rb +3 -7
- data/lib/html2rss/feed_resolution/policy.rb +29 -33
- data/lib/html2rss/feed_resolution/probe.rb +6 -4
- data/lib/html2rss/feed_resolution/scorer.rb +29 -29
- data/lib/html2rss/feed_resolution.rb +16 -21
- data/lib/html2rss/feed_result.rb +11 -6
- data/lib/html2rss/hash_util.rb +91 -90
- data/lib/html2rss/html/article_extractor/category_extractor.rb +7 -10
- data/lib/html2rss/html/article_extractor/date_extractor.rb +1 -1
- data/lib/html2rss/html/article_extractor/enclosure_extractor.rb +12 -33
- data/lib/html2rss/html/article_extractor/heading_extractor.rb +2 -3
- data/lib/html2rss/html/article_extractor/id_generator.rb +7 -10
- data/lib/html2rss/html/article_extractor/image_extractor.rb +9 -1
- data/lib/html2rss/html/article_extractor.rb +19 -47
- data/lib/html2rss/html/article_rules/category.rb +3 -3
- data/lib/html2rss/html/article_rules/description.rb +6 -15
- data/lib/html2rss/html/article_rules/enclosure.rb +70 -42
- data/lib/html2rss/html/article_rules/image.rb +30 -16
- data/lib/html2rss/html/feed_link.rb +7 -9
- data/lib/html2rss/html/navigator/text_extractor.rb +26 -16
- data/lib/html2rss/html/navigator.rb +39 -9
- data/lib/html2rss/html/probe.rb +55 -51
- data/lib/html2rss/html/rendering/description_builder.rb +46 -32
- data/lib/html2rss/html/rendering.rb +2 -9
- data/lib/html2rss/html/sst_article_extractor.rb +55 -145
- data/lib/html2rss/html/title_pieces/kicker.rb +147 -0
- data/lib/html2rss/html/title_pieces/nodes.rb +107 -0
- data/lib/html2rss/html/title_pieces/nokogiri_nodes.rb +70 -0
- data/lib/html2rss/html/title_pieces/sst_nodes.rb +71 -0
- data/lib/html2rss/html/title_pieces.rb +169 -0
- data/lib/html2rss/html/visibility.rb +56 -0
- data/lib/html2rss/html.rb +2 -0
- data/lib/html2rss/link_destination/destination_facts.rb +17 -1
- data/lib/html2rss/link_destination/noise_policy.rb +11 -10
- data/lib/html2rss/link_destination/path_classifier.rb +111 -42
- data/lib/html2rss/link_destination/sponsorship/label_text.rb +82 -0
- data/lib/html2rss/link_destination/sponsorship.rb +184 -0
- data/lib/html2rss/link_destination/text_classifier.rb +104 -8
- data/lib/html2rss/mcp/README.md +13 -1
- data/lib/html2rss/mcp/config_argument.rb +14 -4
- data/lib/html2rss/mcp/contract.rb +20 -9
- data/lib/html2rss/mcp/outcome.rb +18 -17
- data/lib/html2rss/mcp/runtime.rb +53 -23
- data/lib/html2rss/mcp/server/tools.rb +9 -9
- data/lib/html2rss/mcp/server.rb +13 -4
- data/lib/html2rss/option.rb +48 -0
- data/lib/html2rss/page_recon/README.md +1 -1
- data/lib/html2rss/page_recon/diagnostics.rb +9 -9
- data/lib/html2rss/page_recon.rb +0 -4
- data/lib/html2rss/recon.rb +19 -23
- data/lib/html2rss/request_service/blocked_surface.rb +5 -8
- data/lib/html2rss/request_service/botasaurus_contract.rb +25 -3
- data/lib/html2rss/request_service/botasaurus_strategy.rb +44 -12
- data/lib/html2rss/request_service/budget.rb +3 -24
- data/lib/html2rss/request_service/compressed_body.rb +36 -48
- data/lib/html2rss/request_service/context.rb +16 -17
- data/lib/html2rss/request_service/cookie_jar.rb +89 -0
- data/lib/html2rss/request_service/cookie_store.rb +79 -0
- data/lib/html2rss/request_service/feed_advertisement.rb +72 -0
- data/lib/html2rss/request_service/httpx_strategy.rb +7 -3
- data/lib/html2rss/request_service/local_file_strategy.rb +2 -3
- data/lib/html2rss/request_service/network_guard.rb +3 -2
- data/lib/html2rss/request_service/policy.rb +55 -36
- data/lib/html2rss/request_service/replay_breaker.rb +56 -0
- data/lib/html2rss/request_service/response.rb +33 -19
- data/lib/html2rss/request_service/response_cache.rb +109 -0
- data/lib/html2rss/request_service/response_caching.rb +95 -0
- data/lib/html2rss/request_service/strategy.rb +5 -11
- data/lib/html2rss/request_service.rb +71 -12
- data/lib/html2rss/request_session/pager/base.rb +3 -10
- data/lib/html2rss/request_session/pager/custom_selector.rb +4 -4
- data/lib/html2rss/request_session/pager/offset.rb +1 -7
- data/lib/html2rss/request_session/pager/rel_next.rb +1 -1
- data/lib/html2rss/request_session/pager/url_template.rb +1 -7
- data/lib/html2rss/request_session/pager.rb +1 -1
- data/lib/html2rss/request_session.rb +6 -2
- data/lib/html2rss/scoring/cluster_scorer.rb +1 -1
- data/lib/html2rss/scoring/container_assessor.rb +12 -13
- data/lib/html2rss/scoring/engine.rb +10 -38
- data/lib/html2rss/scoring/link_resolver.rb +2 -7
- data/lib/html2rss/scoring/observation.rb +2 -2
- data/lib/html2rss/selector_candidates.rb +19 -8
- data/lib/html2rss/selectors/README.md +1 -1
- data/lib/html2rss/selectors/extractors/href.rb +2 -4
- data/lib/html2rss/selectors/json_xml.rb +33 -34
- data/lib/html2rss/selectors/option_spec.rb +4 -1
- data/lib/html2rss/selectors/post_processors/base.rb +0 -8
- data/lib/html2rss/selectors/post_processors/gsub.rb +9 -10
- data/lib/html2rss/selectors/post_processors/html_transformers/keep_playable_media.rb +50 -0
- data/lib/html2rss/selectors/post_processors/html_transformers/transform_urls_to_absolute_ones.rb +1 -3
- data/lib/html2rss/selectors/post_processors/parse_time.rb +5 -6
- data/lib/html2rss/selectors/post_processors/parse_uri.rb +1 -1
- data/lib/html2rss/selectors/post_processors/sanitize_html.rb +18 -5
- data/lib/html2rss/selectors/post_processors/substring.rb +1 -1
- data/lib/html2rss/selectors/schema_export.rb +60 -60
- data/lib/html2rss/selectors.rb +67 -30
- data/lib/html2rss/sst/attrs.rb +2 -2
- data/lib/html2rss/sst/document.rb +3 -3
- data/lib/html2rss/sst/index.rb +30 -11
- data/lib/html2rss/sst/node.rb +42 -29
- data/lib/html2rss/sst/normalizer.rb +47 -34
- data/lib/html2rss/sst/tags.rb +113 -2
- data/lib/html2rss/sst/text.rb +94 -58
- data/lib/html2rss/sst.rb +2 -0
- data/lib/html2rss/status.rb +27 -31
- data/lib/html2rss/surface_category.rb +1 -1
- data/lib/html2rss/syndication/README.md +12 -6
- data/lib/html2rss/syndication/alternate_links.rb +39 -0
- data/lib/html2rss/syndication/channel_facts.rb +87 -0
- data/lib/html2rss/syndication/discovery.rb +171 -187
- data/lib/html2rss/syndication/feed.rb +20 -0
- data/lib/html2rss/syndication/item_media.rb +103 -0
- data/lib/html2rss/syndication/parser.rb +117 -116
- data/lib/html2rss/test/enhance_audit.rb +15 -17
- data/lib/html2rss/test/item_sample.rb +43 -0
- data/lib/html2rss/test/policy.rb +9 -11
- data/lib/html2rss/test.rb +66 -57
- data/lib/html2rss/url.rb +163 -62
- data/lib/html2rss/version.rb +1 -1
- data/lib/html2rss/word_count.rb +53 -0
- data/lib/html2rss.rb +19 -9
- data/schema/html2rss-config.schema.json +56 -41
- metadata +33 -4
- data/lib/html2rss/html/rendering/media_renderer.rb +0 -39
- data/lib/html2rss/html/rendering/pdf_renderer.rb +0 -30
|
@@ -9,24 +9,33 @@ module Html2rss
|
|
|
9
9
|
# Tag names collected as leaf semantic containers.
|
|
10
10
|
CANDIDATE_NAMES = %i[article section li tr div].to_set.freeze
|
|
11
11
|
|
|
12
|
-
module_function
|
|
13
|
-
|
|
14
12
|
##
|
|
15
13
|
# @param segmenter [Segmenter]
|
|
16
14
|
# @return [Array<Segment>]
|
|
17
|
-
def call(segmenter)
|
|
18
|
-
|
|
19
|
-
|
|
15
|
+
def self.call(segmenter)
|
|
16
|
+
picks = linked_containers(segmenter)
|
|
17
|
+
# A link wrapping its container is a whole card: it roots one segment, not one per div inside
|
|
18
|
+
# it, and none when a container already selected that link from inside.
|
|
19
|
+
claimed = picks.filter_map { |_, link, inside| link if inside }.to_set.compare_by_identity
|
|
20
|
+
|
|
21
|
+
picks.each_with_index.filter_map do |(container, link, inside), position|
|
|
22
|
+
root = inside ? container : (link if claimed.add?(link))
|
|
23
|
+
Segment.build(root_node: root, primary_link: link, strategy: :semantic, position:) if root
|
|
24
|
+
end
|
|
25
|
+
end
|
|
20
26
|
|
|
21
|
-
|
|
27
|
+
# @return [Array<Array(SST::Node, SST::Node, Boolean)>] container, its primary link, link inside it
|
|
28
|
+
def self.linked_containers(segmenter)
|
|
29
|
+
index = segmenter.index
|
|
30
|
+
primary = PrimaryLink.new(segmenter)
|
|
31
|
+
collect_containers(index).filter_map do |container|
|
|
22
32
|
link = primary.select(container)
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
Segment.build(root_node: container, primary_link: link, strategy: :semantic, position:)
|
|
33
|
+
[container, link, index.descendant_of?(link, container)] if link
|
|
26
34
|
end
|
|
27
35
|
end
|
|
36
|
+
private_class_method :linked_containers
|
|
28
37
|
|
|
29
|
-
def collect_containers(index)
|
|
38
|
+
def self.collect_containers(index)
|
|
30
39
|
non_leaf = mark_non_leaf_candidates(index.root)
|
|
31
40
|
candidates = []
|
|
32
41
|
index.each_node do |node|
|
|
@@ -40,14 +49,13 @@ module Html2rss
|
|
|
40
49
|
candidates = filter_nested(candidates, index)
|
|
41
50
|
sort_by_depth(candidates, index)
|
|
42
51
|
end
|
|
43
|
-
module_function :collect_containers
|
|
44
52
|
private_class_method :collect_containers
|
|
45
53
|
|
|
46
54
|
TAG_MASKS = { article: 1, section: 2, li: 4, tr: 8, div: 16 }.freeze
|
|
47
55
|
private_constant :TAG_MASKS
|
|
48
56
|
|
|
49
57
|
# Single O(N) post-order pass: mark candidate nodes that contain a same-name candidate.
|
|
50
|
-
def mark_non_leaf_candidates(root) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
|
58
|
+
def self.mark_non_leaf_candidates(root) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
|
51
59
|
non_leaf = Set.new.compare_by_identity
|
|
52
60
|
visit = lambda do |node|
|
|
53
61
|
seen_mask = 0
|
|
@@ -62,10 +70,9 @@ module Html2rss
|
|
|
62
70
|
visit.call(root)
|
|
63
71
|
non_leaf
|
|
64
72
|
end
|
|
65
|
-
module_function :mark_non_leaf_candidates
|
|
66
73
|
private_class_method :mark_non_leaf_candidates
|
|
67
74
|
|
|
68
|
-
def filter_nested(candidates, index)
|
|
75
|
+
def self.filter_nested(candidates, index)
|
|
69
76
|
candidate_set = {}.compare_by_identity
|
|
70
77
|
candidates.each { |c| candidate_set[c] = true }
|
|
71
78
|
rejected = {}.compare_by_identity
|
|
@@ -78,25 +85,22 @@ module Html2rss
|
|
|
78
85
|
|
|
79
86
|
candidates.reject { |c| rejected[c] }
|
|
80
87
|
end
|
|
81
|
-
module_function :filter_nested
|
|
82
88
|
private_class_method :filter_nested
|
|
83
89
|
|
|
84
|
-
def reject_ancestors(node, candidate_set, rejected, index)
|
|
90
|
+
def self.reject_ancestors(node, candidate_set, rejected, index)
|
|
85
91
|
curr = index.parent_of(node)
|
|
86
92
|
while curr && curr.name != :html
|
|
87
93
|
rejected[curr] = true if candidate_set[curr]
|
|
88
94
|
curr = index.parent_of(curr)
|
|
89
95
|
end
|
|
90
96
|
end
|
|
91
|
-
module_function :reject_ancestors
|
|
92
97
|
private_class_method :reject_ancestors
|
|
93
98
|
|
|
94
|
-
def sort_by_depth(candidates, index)
|
|
99
|
+
def self.sort_by_depth(candidates, index)
|
|
95
100
|
candidates.each_with_index
|
|
96
101
|
.sort_by { |node, i| [-index.depth_of(node), i] }
|
|
97
102
|
.map(&:first)
|
|
98
103
|
end
|
|
99
|
-
module_function :sort_by_depth
|
|
100
104
|
private_class_method :sort_by_depth
|
|
101
105
|
end
|
|
102
106
|
end
|
|
@@ -14,11 +14,10 @@ module Html2rss
|
|
|
14
14
|
# @param base_url [String, Html2rss::Url]
|
|
15
15
|
# @param strategy [Symbol]
|
|
16
16
|
# @param opts [Hash] segmentation options
|
|
17
|
-
# @option opts [Boolean] :permit_unanchored keep containers without a primary link
|
|
18
17
|
# @option opts [Integer] :minimum_selector_frequency list strategy frequency floor
|
|
19
18
|
# @option opts [Integer] :use_top_selectors list strategy selector budget
|
|
20
19
|
# @option opts [Scoring::LinkResolver, nil] :link_resolver page-scoped destination facts cache
|
|
21
|
-
# @return [Array<Segment>]
|
|
20
|
+
# @return [Array<Segment>] linked segments only
|
|
22
21
|
# rubocop:disable-next Style/ArgumentsForwarding -- keep named opts for YARD @option
|
|
23
22
|
def self.call(document, base_url:, strategy:, **opts)
|
|
24
23
|
new(document, base_url:, strategy:, **opts).call
|
|
@@ -28,18 +27,15 @@ module Html2rss
|
|
|
28
27
|
# @param base_url [String, Html2rss::Url]
|
|
29
28
|
# @param strategy [Symbol]
|
|
30
29
|
# @param opts [Hash] segmentation options
|
|
31
|
-
# @option opts [Boolean] :permit_unanchored keep containers without a primary link
|
|
32
30
|
# @option opts [Integer] :minimum_selector_frequency list strategy frequency floor
|
|
33
31
|
# @option opts [Integer] :use_top_selectors list strategy selector budget
|
|
34
32
|
# @option opts [Scoring::LinkResolver, nil] :link_resolver page-scoped destination facts cache
|
|
35
33
|
def initialize(document, base_url:, strategy:, **opts)
|
|
36
|
-
raise
|
|
37
|
-
raise ArgumentError, "unknown strategy: #{strategy.inspect}" unless Segment::STRATEGIES.include?(strategy)
|
|
34
|
+
raise Segment::InvalidError, 'document must be SST::Document' unless document.is_a?(SST::Document)
|
|
38
35
|
|
|
39
36
|
@document = document
|
|
40
37
|
@base_url = base_url
|
|
41
|
-
@strategy = strategy
|
|
42
|
-
@permit_unanchored = opts.fetch(:permit_unanchored, false)
|
|
38
|
+
@strategy = Segment.strategy!(strategy)
|
|
43
39
|
@minimum_selector_frequency = opts.fetch(:minimum_selector_frequency, 2)
|
|
44
40
|
@use_top_selectors = opts.fetch(:use_top_selectors, 5)
|
|
45
41
|
@link_resolver = opts[:link_resolver] || Scoring::LinkResolver.new(base_url)
|
|
@@ -55,7 +51,7 @@ module Html2rss
|
|
|
55
51
|
end
|
|
56
52
|
end
|
|
57
53
|
|
|
58
|
-
attr_reader :document, :base_url, :
|
|
54
|
+
attr_reader :document, :base_url, :minimum_selector_frequency,
|
|
59
55
|
:use_top_selectors, :link_resolver, :noise_policy
|
|
60
56
|
|
|
61
57
|
# @return [SST::Index]
|
data/lib/html2rss/auto_source.rb
CHANGED
|
@@ -22,7 +22,13 @@ module Html2rss
|
|
|
22
22
|
# Default max articles to keep (also the short-circuit floor across scraper tiers).
|
|
23
23
|
DEFAULT_LIMIT = 25
|
|
24
24
|
|
|
25
|
-
#
|
|
25
|
+
# Raised when a scraper yields an item that is neither an Article nor a Hash (a scraper bug).
|
|
26
|
+
class ScraperContractError < Html2rss::ContractError; end
|
|
27
|
+
|
|
28
|
+
# Operational scraper failures quarantined within a tier; programmer errors (including any
|
|
29
|
+
# {Html2rss::ContractError}) propagate.
|
|
30
|
+
# +ArgumentError+ stays for stdlib raises on publisher bytes (e.g. +invalid byte sequence+ after
|
|
31
|
+
# +CGI.unescapeHTML+ of a surrogate entity); lib raises are {Html2rss::Error} (CONTEXT.md § Error handling).
|
|
26
32
|
OPERATIONAL_ERRORS = [
|
|
27
33
|
Html2rss::Error,
|
|
28
34
|
ArgumentError,
|
|
@@ -71,14 +77,12 @@ module Html2rss
|
|
|
71
77
|
enabled: true
|
|
72
78
|
},
|
|
73
79
|
semantic_html: {
|
|
74
|
-
enabled: true
|
|
75
|
-
fallback_anchorless: true
|
|
80
|
+
enabled: true
|
|
76
81
|
},
|
|
77
82
|
html: {
|
|
78
83
|
enabled: true,
|
|
79
84
|
minimum_selector_frequency: Scraper::Html::DEFAULT_MINIMUM_SELECTOR_FREQUENCY,
|
|
80
|
-
use_top_selectors: Scraper::Html::DEFAULT_USE_TOP_SELECTORS
|
|
81
|
-
fallback_anchorless: true
|
|
85
|
+
use_top_selectors: Scraper::Html::DEFAULT_USE_TOP_SELECTORS
|
|
82
86
|
}
|
|
83
87
|
},
|
|
84
88
|
cleanup: Cleanup::DEFAULT_CONFIG
|
|
@@ -120,14 +124,18 @@ module Html2rss
|
|
|
120
124
|
@captured_responses = response.captured_responses
|
|
121
125
|
@opts = opts
|
|
122
126
|
@request_session = request_session
|
|
127
|
+
@cards_read = false
|
|
123
128
|
end
|
|
124
129
|
|
|
125
130
|
##
|
|
126
131
|
# Extracts articles by running scraper tiers until a sufficient set is found.
|
|
127
132
|
#
|
|
128
133
|
# Tiers: in-page structured → follow-up IO → SemanticHtml → Html.
|
|
129
|
-
# SST is built
|
|
130
|
-
#
|
|
134
|
+
# SST is built at most once, lazily, by the first consumer: a heuristic tier or the
|
|
135
|
+
# {Corroboration} page check of structured-tier items. Later tiers are skipped once
|
|
136
|
+
# +limit+ counting articles with url+title remain after Cleanup ({#counting}); the result
|
|
137
|
+
# is capped to +limit+, counting articles first. A native feed's items come first, and
|
|
138
|
+
# Cleanup merges a page copy of the same article into them.
|
|
131
139
|
# Html is skipped when earlier tiers already admitted at least one clean article
|
|
132
140
|
# below +limit+ — quality over padding with weaker heuristic junk.
|
|
133
141
|
#
|
|
@@ -153,6 +161,13 @@ module Html2rss
|
|
|
153
161
|
@admission_drops || {}
|
|
154
162
|
end
|
|
155
163
|
|
|
164
|
+
##
|
|
165
|
+
# What the native feed this page advertises states about its channel ({Scraper::NativeFeed#channel}):
|
|
166
|
+
# nil until {#articles} runs, or when no feed was read.
|
|
167
|
+
#
|
|
168
|
+
# @return [Html2rss::Syndication::ChannelFacts, nil]
|
|
169
|
+
def feed_channel = @native_feed&.channel
|
|
170
|
+
|
|
156
171
|
private
|
|
157
172
|
|
|
158
173
|
attr_reader :url, :parsed_body, :body, :request_session, :captured_responses
|
|
@@ -163,21 +178,13 @@ module Html2rss
|
|
|
163
178
|
|
|
164
179
|
articles = []
|
|
165
180
|
matched = false
|
|
166
|
-
document = nil
|
|
167
|
-
link_resolver = nil
|
|
168
181
|
scraper_opts = @opts.fetch(:scraper, {})
|
|
169
182
|
|
|
170
183
|
Scraper::SCRAPER_TIERS.each do |tier|
|
|
171
184
|
break if enough_articles?(articles)
|
|
172
185
|
break if skip_html_padding?(tier, articles)
|
|
173
186
|
|
|
174
|
-
if Scraper.heuristic_tier?(tier)
|
|
175
|
-
document ||= Scraper.normalize_sst(parsed_body)
|
|
176
|
-
next unless document
|
|
177
|
-
|
|
178
|
-
link_resolver ||= ::Html2rss::Scoring::LinkResolver.new(url)
|
|
179
|
-
end
|
|
180
|
-
|
|
187
|
+
@cards_read = true if Scraper.heuristic_tier?(tier)
|
|
181
188
|
tier.each do |scraper_class|
|
|
182
189
|
instance = Scraper.build_instance(
|
|
183
190
|
scraper_class,
|
|
@@ -186,7 +193,7 @@ module Html2rss
|
|
|
186
193
|
url:,
|
|
187
194
|
request_session:,
|
|
188
195
|
body:,
|
|
189
|
-
document
|
|
196
|
+
document: (page_document if Scraper.heuristic_tier?(tier)),
|
|
190
197
|
link_resolver:,
|
|
191
198
|
captured_responses:
|
|
192
199
|
)
|
|
@@ -195,6 +202,7 @@ module Html2rss
|
|
|
195
202
|
|
|
196
203
|
matched = true
|
|
197
204
|
articles.concat(run_scraper(instance))
|
|
205
|
+
@native_feed = instance if instance.is_a?(Scraper::NativeFeed)
|
|
198
206
|
end
|
|
199
207
|
end
|
|
200
208
|
|
|
@@ -231,10 +239,11 @@ module Html2rss
|
|
|
231
239
|
|
|
232
240
|
def finish_articles(articles)
|
|
233
241
|
captured_count = articles.size
|
|
234
|
-
result =
|
|
242
|
+
result = cleanup_result(articles)
|
|
235
243
|
@admission_drops = result.drop_tallies
|
|
236
244
|
log_admission_emptied(captured_count, result) if captured_count.positive? && result.articles.empty?
|
|
237
|
-
result.articles
|
|
245
|
+
counting = counting(result.articles)
|
|
246
|
+
(counting + (result.articles - counting)).first(article_limit)
|
|
238
247
|
end
|
|
239
248
|
|
|
240
249
|
def log_admission_emptied(captured_count, result)
|
|
@@ -252,7 +261,19 @@ module Html2rss
|
|
|
252
261
|
def enough_articles?(articles)
|
|
253
262
|
return false if articles.size < article_limit
|
|
254
263
|
|
|
255
|
-
admitted_articles(articles).size >= article_limit
|
|
264
|
+
counting(admitted_articles(articles)).size >= article_limit
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
# The articles that count toward +limit+, in order: those the page shows in its content
|
|
268
|
+
# ({Corroboration#shown?}), media items (audio, video) only on a media page
|
|
269
|
+
# ({Corroboration#media_page?}). The others stay admitted after the counting ones. A page is
|
|
270
|
+
# judged a media page only once a heuristic tier has read its cards: structured items alone
|
|
271
|
+
# cannot tell a podcast page from a news page with an audio list. A media page's counting
|
|
272
|
+
# items are in page order ({Corroboration#page_order}), not tier order.
|
|
273
|
+
def counting(articles)
|
|
274
|
+
media_page = @cards_read && corroboration.media_page?(articles)
|
|
275
|
+
counted = articles.select { (media_page || !_1.media?) && corroboration.shown?(_1) }
|
|
276
|
+
media_page ? corroboration.page_order(counted) : counted
|
|
256
277
|
end
|
|
257
278
|
|
|
258
279
|
# Prefer fewer clean items over refilling +limit+ from the Html heuristic tier.
|
|
@@ -264,8 +285,39 @@ module Html2rss
|
|
|
264
285
|
tier == [Scraper::Html]
|
|
265
286
|
end
|
|
266
287
|
|
|
288
|
+
# Cleanup is deterministic and +articles+ only grows, so one result per size is reused
|
|
289
|
+
# by the tier checks and by {#finish_articles}.
|
|
290
|
+
def cleanup_result(articles)
|
|
291
|
+
return @cleanup_memo.last if @cleanup_memo&.first == articles.size
|
|
292
|
+
|
|
293
|
+
Cleanup.call(articles.dup, url:, corroboration:, page_links: -> { page_links },
|
|
294
|
+
feed_url: @native_feed&.feed_url, **cleanup_options)
|
|
295
|
+
.tap { @cleanup_memo = [articles.size, _1] }
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
# Page SST shared by the heuristic tiers and the page check; nil for a JSON response
|
|
299
|
+
# or a page without SST nodes.
|
|
300
|
+
def page_document
|
|
301
|
+
return @page_document if defined?(@page_document)
|
|
302
|
+
|
|
303
|
+
@page_document = (SST::Normalizer.call(parsed_body) unless json_response?)
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
def link_resolver
|
|
307
|
+
@link_resolver ||= Scoring::LinkResolver.new(url)
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
def corroboration
|
|
311
|
+
@corroboration ||= Corroboration.new { page_links }
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
# Rendered links of the page, built once for the page check and sponsorship.
|
|
315
|
+
def page_links
|
|
316
|
+
@page_links ||= PageLinks.build(page_document, link_resolver:)
|
|
317
|
+
end
|
|
318
|
+
|
|
267
319
|
def admitted_articles(articles)
|
|
268
|
-
|
|
320
|
+
cleanup_result(articles).articles.select do |article|
|
|
269
321
|
article.url && !article.title.to_s.empty?
|
|
270
322
|
end
|
|
271
323
|
end
|
|
@@ -276,11 +328,11 @@ module Html2rss
|
|
|
276
328
|
|
|
277
329
|
def run_scraper(instance)
|
|
278
330
|
instance.map { |item| article_from_scraper_item(item, instance) }
|
|
331
|
+
rescue ContractError
|
|
332
|
+
raise
|
|
279
333
|
rescue *OPERATIONAL_ERRORS => error
|
|
280
334
|
Log.warn({
|
|
281
|
-
event_name: 'auto_source.quarantined',
|
|
282
|
-
host: url.host,
|
|
283
|
-
error_class: error.class.name,
|
|
335
|
+
event_name: 'auto_source.quarantined', host: url.host, error_class: error.class.name,
|
|
284
336
|
message: "#{self.class}: #{instance.class} quarantined #{error.class}: #{error.message}"
|
|
285
337
|
}.to_json)
|
|
286
338
|
[]
|
|
@@ -291,9 +343,12 @@ module Html2rss
|
|
|
291
343
|
when Article
|
|
292
344
|
item
|
|
293
345
|
when Hash
|
|
294
|
-
|
|
346
|
+
# Hash items carry publisher-declared or structured titles: verbatim (collapse only), except an item
|
|
347
|
+
# with an explicit +kicker+, which composes +Kicker: Headline+. Heuristic scrapers yield Articles
|
|
348
|
+
# composed by Html::SstArticleExtractor.
|
|
349
|
+
Article.new(**item, compose_title: item.key?(:kicker), scraper: instance.class)
|
|
295
350
|
else
|
|
296
|
-
raise
|
|
351
|
+
raise ScraperContractError, "#{instance.class} yielded #{item.class}; expected Article or Hash"
|
|
297
352
|
end
|
|
298
353
|
end
|
|
299
354
|
|
data/lib/html2rss/batch.rb
CHANGED
|
@@ -10,17 +10,9 @@ module Html2rss
|
|
|
10
10
|
# Default worker threads for batch operations.
|
|
11
11
|
DEFAULT_CONCURRENCY = 5
|
|
12
12
|
|
|
13
|
+
# Outcome of a batch operation; +to_h+ is provided by Data.
|
|
13
14
|
BatchResult = Data.define(:total, :successful, :results)
|
|
14
15
|
|
|
15
|
-
# Value object representing the outcome of a batch operation.
|
|
16
|
-
class BatchResult
|
|
17
|
-
##
|
|
18
|
-
# @return [Hash{Symbol => Object}]
|
|
19
|
-
def to_h
|
|
20
|
-
{ total:, successful:, results: }
|
|
21
|
-
end
|
|
22
|
-
end
|
|
23
|
-
|
|
24
16
|
class << self
|
|
25
17
|
##
|
|
26
18
|
# Executes a block concurrently across items using a bounded worker pool.
|
|
@@ -31,11 +23,8 @@ module Html2rss
|
|
|
31
23
|
# @yieldparam item [Object]
|
|
32
24
|
# @return [Array<Object>]
|
|
33
25
|
def map(items, concurrency: DEFAULT_CONCURRENCY, &)
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
worker_count = worker_pool_size(list.size, concurrency)
|
|
38
|
-
process_queue(list, worker_count, &)
|
|
26
|
+
Option.integer(:concurrency, concurrency, min: 1, max: MAX_CONCURRENCY)
|
|
27
|
+
process_queue(Option.list(:items, items), [concurrency, items.size].min, &)
|
|
39
28
|
end
|
|
40
29
|
|
|
41
30
|
##
|
|
@@ -46,11 +35,10 @@ module Html2rss
|
|
|
46
35
|
# @yieldparam item [Object]
|
|
47
36
|
# @return [BatchResult]
|
|
48
37
|
def run(items, concurrency: DEFAULT_CONCURRENCY, &)
|
|
49
|
-
|
|
50
|
-
results = map(list, concurrency:, &)
|
|
38
|
+
results = map(items, concurrency:, &)
|
|
51
39
|
successful = results.count { |entry| entry.is_a?(Hash) ? entry[:ok] : !entry.nil? }
|
|
52
40
|
|
|
53
|
-
BatchResult.new(total:
|
|
41
|
+
BatchResult.new(total: items.size, successful:, results:)
|
|
54
42
|
end
|
|
55
43
|
|
|
56
44
|
##
|
|
@@ -62,7 +50,7 @@ module Html2rss
|
|
|
62
50
|
# @param items_selector [String, nil]
|
|
63
51
|
# @return [Hash{Symbol => Object}] +:strategy+, +:items+, +:channel_title+, +:admission_drops+
|
|
64
52
|
def scrape_wire(url:, strategy:, limit:, items_selector: nil) # rubocop:disable Metrics/MethodLength
|
|
65
|
-
plan = (strategy
|
|
53
|
+
plan = Option.strategy(strategy)
|
|
66
54
|
feed_args = { strategy: plan, limit: }
|
|
67
55
|
feed_args[:items_selector] = items_selector unless items_selector.nil?
|
|
68
56
|
feed_result = Html2rss.auto_feed_result(url, **feed_args)
|
|
@@ -84,7 +72,9 @@ module Html2rss
|
|
|
84
72
|
# @param concurrency [Integer] number of worker threads (default 5, max 10)
|
|
85
73
|
# @return [BatchResult]
|
|
86
74
|
def batch_scrape(urls:, strategy: :auto, limit: 10, concurrency: DEFAULT_CONCURRENCY)
|
|
87
|
-
|
|
75
|
+
strategy = Option.strategy(strategy)
|
|
76
|
+
Option.integer(:limit, limit, min: 1)
|
|
77
|
+
run(Option.list(:urls, urls), concurrency:) { |url| scrape_single_url(url:, strategy:, limit:) }
|
|
88
78
|
end
|
|
89
79
|
|
|
90
80
|
##
|
|
@@ -95,7 +85,8 @@ module Html2rss
|
|
|
95
85
|
# @param concurrency [Integer] number of worker threads (default 5, max 10)
|
|
96
86
|
# @return [BatchResult]
|
|
97
87
|
def batch_inspect(urls:, strategy: :auto, concurrency: DEFAULT_CONCURRENCY)
|
|
98
|
-
reports = PageRecon::Diagnostics.batch(urls
|
|
88
|
+
reports = PageRecon::Diagnostics.batch(urls: Option.list(:urls, urls), strategy: Option.strategy(strategy),
|
|
89
|
+
concurrency:)
|
|
99
90
|
results = reports.map(&:to_wire_h)
|
|
100
91
|
successful = reports.count { |report| diagnostic_success?(report) }
|
|
101
92
|
|
|
@@ -111,16 +102,21 @@ module Html2rss
|
|
|
111
102
|
# @option options [String, nil] :cache_dir optional HTML cache directory
|
|
112
103
|
# @return [BatchResult]
|
|
113
104
|
def batch_recon(urls:, strategy: :auto, concurrency: DEFAULT_CONCURRENCY, **)
|
|
114
|
-
|
|
115
|
-
|
|
105
|
+
Option.integer(:concurrency, concurrency, min: 1, max: MAX_CONCURRENCY)
|
|
106
|
+
results = Recon.batch(Option.list(:urls, urls), strategy: Option.strategy(strategy),
|
|
107
|
+
max_threads: concurrency, **)
|
|
108
|
+
successful = results.count { |result| http_success?(result.status) }
|
|
116
109
|
BatchResult.new(total: results.size, successful:, results: results.map(&:to_h))
|
|
117
110
|
end
|
|
118
111
|
|
|
119
112
|
private
|
|
120
113
|
|
|
121
114
|
def diagnostic_success?(report)
|
|
122
|
-
|
|
123
|
-
|
|
115
|
+
http_success?(report.to_wire_h[:status])
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def http_success?(status)
|
|
119
|
+
!status.nil? && status < 400
|
|
124
120
|
end
|
|
125
121
|
|
|
126
122
|
def scrape_single_url(url:, strategy:, limit:)
|
|
@@ -129,16 +125,12 @@ module Html2rss
|
|
|
129
125
|
url: url.to_s, ok: true, items_count: wire[:items].size,
|
|
130
126
|
items: wire[:items], channel_title: wire[:channel_title]
|
|
131
127
|
}
|
|
132
|
-
rescue
|
|
128
|
+
rescue Html2rss::ContractError
|
|
129
|
+
raise
|
|
130
|
+
rescue Html2rss::Error => error
|
|
133
131
|
{ url: url.to_s, ok: false, error: error.message }
|
|
134
132
|
end
|
|
135
133
|
|
|
136
|
-
def worker_pool_size(item_count, requested_concurrency)
|
|
137
|
-
return 1 if item_count <= 1
|
|
138
|
-
|
|
139
|
-
[[1, requested_concurrency.to_i].max, item_count, MAX_CONCURRENCY].min
|
|
140
|
-
end
|
|
141
|
-
|
|
142
134
|
def process_queue(items, worker_count, &)
|
|
143
135
|
queue = build_work_queue(items)
|
|
144
136
|
results = Array.new(items.size)
|
|
@@ -157,7 +149,7 @@ module Html2rss
|
|
|
157
149
|
end
|
|
158
150
|
|
|
159
151
|
def drain_queue(queue, results)
|
|
160
|
-
|
|
152
|
+
loop do
|
|
161
153
|
begin
|
|
162
154
|
item, index = queue.pop(true)
|
|
163
155
|
rescue ThreadError
|
|
@@ -29,19 +29,22 @@ rss = Html2rss.apply(result.config)
|
|
|
29
29
|
|
|
30
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
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.
|
|
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`. `#candidates[:candidates_by_item]` is additive: for each of the top 5 item candidates (same order as `items`) it holds `{ selector:, title:, link:, published: }`, where the field buckets are computed from that item candidate's own matched nodes (reusing the discovery evidence, no refetch). Use it when the top-level `title` / `link` / `published` (which describe the first item candidate only) do not match the item selector you choose. Its first entry equals the top-level buckets. Buckets may be empty when evidence is missing. The default `a[href]` YAML fallback is not listed as discovered evidence.
|
|
33
33
|
|
|
34
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`.
|
|
35
35
|
|
|
36
36
|
### Options
|
|
37
37
|
|
|
38
38
|
```ruby
|
|
39
|
-
Html2rss.capture('https://
|
|
39
|
+
Html2rss.capture('https://example.com', strategy: :botasaurus)
|
|
40
40
|
Html2rss.capture('https://example.com', items_selector: '.article-card')
|
|
41
41
|
Html2rss.capture('https://example.com', strategy: :local_file, local_file_path: './page.html')
|
|
42
42
|
Html2rss.capture('https://example.com', max_redirects: 8, max_requests: 4)
|
|
43
|
+
Html2rss.capture('https://example.com', total_timeout_seconds: 20)
|
|
43
44
|
```
|
|
44
45
|
|
|
46
|
+
`total_timeout_seconds:` (positive Integer, default 30) bounds the **whole** request chain: the `:auto` fallback hops (`default` → `botasaurus`) share one budget, so the bound is not per hop. When it elapses, capture raises `RequestService::RequestTimedOut`. Use it to stay under a host's own request deadline.
|
|
47
|
+
|
|
45
48
|
`strategy: :auto` uses the same AutoFallback chain as scrape (`default` → `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.
|
|
46
49
|
|
|
47
50
|
## CLI
|
|
@@ -66,7 +69,7 @@ html2rss capture https://example.com --explain # quality JSON on stderr; YAML
|
|
|
66
69
|
## Constraints
|
|
67
70
|
|
|
68
71
|
- Selector quality depends on page structure; treat output as a first draft.
|
|
69
|
-
- When HTML derivation misses (no articles, no SST document, fewer than `MIN_SELECTOR_MATCHES` pairs
|
|
72
|
+
- When HTML derivation misses (no articles, no SST document, fewer than `MIN_SELECTOR_MATCHES` pairs), 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.
|
|
70
73
|
- Botasaurus hops need `BOTASAURUS_SCRAPER_URL`.
|
|
71
74
|
|
|
72
75
|
See also {Html2rss::AutoSource} for article discovery and {Html2rss::FeedPipeline} for the `:auto` request chain.
|