html2rss 0.32.4 → 0.33.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/lib/html2rss/article/enclosure.rb +2 -6
- data/lib/html2rss/auto_source/cleanup.rb +1 -3
- data/lib/html2rss/auto_source/scraper/html.rb +2 -8
- data/lib/html2rss/auto_source/scraper/json_document.rb +2 -3
- data/lib/html2rss/auto_source/scraper/json_state/article_normalizer.rb +128 -128
- data/lib/html2rss/auto_source/scraper/json_state/candidate_detector.rb +37 -37
- data/lib/html2rss/auto_source/scraper/json_state/document_scanner.rb +123 -123
- data/lib/html2rss/auto_source/scraper/json_state/value_finder.rb +34 -34
- data/lib/html2rss/auto_source/scraper/json_state.rb +2 -6
- data/lib/html2rss/auto_source/scraper/meta_oembed.rb +32 -2
- data/lib/html2rss/auto_source/scraper/microdata.rb +228 -245
- data/lib/html2rss/auto_source/scraper/microformats2.rb +9 -1
- data/lib/html2rss/auto_source/scraper/native_feed.rb +1 -1
- data/lib/html2rss/auto_source/scraper/schema/category_extractor.rb +7 -2
- data/lib/html2rss/auto_source/scraper/schema/item_list.rb +2 -2
- data/lib/html2rss/auto_source/scraper/schema/thing.rb +98 -21
- data/lib/html2rss/auto_source/scraper/schema.rb +49 -2
- data/lib/html2rss/auto_source/scraper/semantic_html.rb +12 -16
- data/lib/html2rss/auto_source/scraper/sitemap/parser.rb +1 -4
- data/lib/html2rss/auto_source/scraper/sitemap.rb +3 -11
- 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 +4 -4
- data/lib/html2rss/auto_source/scraper/xhr_articles.rb +1 -1
- data/lib/html2rss/auto_source/scraper.rb +0 -11
- data/lib/html2rss/auto_source/segmenter/cluster.rb +5 -11
- data/lib/html2rss/auto_source/segmenter/list.rb +25 -57
- data/lib/html2rss/auto_source/segmenter/semantic.rb +6 -13
- data/lib/html2rss/auto_source.rb +1 -1
- data/lib/html2rss/batch.rb +22 -32
- data/lib/html2rss/capture/README.md +1 -1
- data/lib/html2rss/capture.rb +7 -27
- data/lib/html2rss/cli/render.rb +97 -96
- data/lib/html2rss/cli/validate.rb +2 -4
- data/lib/html2rss/cli.rb +33 -27
- data/lib/html2rss/config/request_controls.rb +10 -2
- data/lib/html2rss/config/schema.rb +8 -10
- data/lib/html2rss/config/selectors_validator.rb +1 -0
- data/lib/html2rss/config/validator.rb +3 -3
- data/lib/html2rss/config.rb +1 -1
- data/lib/html2rss/defaults.rb +5 -16
- data/lib/html2rss/doctor/botasaurus.rb +11 -19
- data/lib/html2rss/error.rb +7 -3
- data/lib/html2rss/feed_builder/rss.rb +1 -1
- data/lib/html2rss/feed_pipeline/auto_fallback.rb +3 -3
- data/lib/html2rss/feed_resolution/candidate_generator.rb +3 -4
- 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 +3 -3
- data/lib/html2rss/feed_resolution/scorer.rb +29 -29
- data/lib/html2rss/feed_resolution.rb +5 -9
- data/lib/html2rss/feed_result.rb +4 -3
- data/lib/html2rss/hash_util.rb +91 -90
- data/lib/html2rss/html/article_extractor/category_extractor.rb +6 -9
- data/lib/html2rss/html/article_extractor/date_extractor.rb +1 -1
- data/lib/html2rss/html/article_extractor/enclosure_extractor.rb +1 -3
- data/lib/html2rss/html/article_extractor/id_generator.rb +1 -1
- data/lib/html2rss/html/probe.rb +51 -51
- data/lib/html2rss/link_destination/path_classifier.rb +11 -33
- data/lib/html2rss/mcp/config_argument.rb +9 -2
- data/lib/html2rss/mcp/contract.rb +7 -5
- data/lib/html2rss/mcp/outcome.rb +4 -5
- data/lib/html2rss/mcp/runtime.rb +17 -24
- data/lib/html2rss/mcp/server/tools.rb +9 -9
- data/lib/html2rss/option.rb +48 -0
- data/lib/html2rss/page_recon/diagnostics.rb +7 -9
- data/lib/html2rss/page_recon.rb +0 -4
- data/lib/html2rss/recon.rb +10 -12
- data/lib/html2rss/request_service/blocked_surface.rb +3 -7
- data/lib/html2rss/request_service/botasaurus_strategy.rb +2 -2
- 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 +1 -3
- data/lib/html2rss/request_service/local_file_strategy.rb +0 -1
- data/lib/html2rss/request_service/network_guard.rb +3 -2
- data/lib/html2rss/request_service/policy.rb +8 -26
- data/lib/html2rss/request_service/response.rb +3 -6
- data/lib/html2rss/request_session/pager/base.rb +3 -3
- 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/scoring/engine.rb +4 -32
- data/lib/html2rss/scoring/link_resolver.rb +2 -7
- data/lib/html2rss/selectors/extractors/href.rb +2 -4
- data/lib/html2rss/selectors/json_xml.rb +33 -34
- data/lib/html2rss/selectors/post_processors/gsub.rb +2 -3
- data/lib/html2rss/selectors/post_processors/parse_time.rb +4 -5
- data/lib/html2rss/selectors/schema_export.rb +60 -60
- data/lib/html2rss/selectors.rb +14 -18
- data/lib/html2rss/sst/node.rb +9 -3
- data/lib/html2rss/sst/normalizer.rb +25 -24
- data/lib/html2rss/sst/text.rb +53 -57
- data/lib/html2rss/status.rb +23 -30
- data/lib/html2rss/syndication/alternate_links.rb +39 -0
- data/lib/html2rss/syndication/discovery.rb +117 -187
- data/lib/html2rss/syndication/parser.rb +92 -115
- data/lib/html2rss/test/enhance_audit.rb +13 -15
- data/lib/html2rss/test/policy.rb +9 -11
- data/lib/html2rss/test.rb +29 -48
- data/lib/html2rss/url.rb +53 -53
- data/lib/html2rss/version.rb +1 -1
- data/lib/html2rss.rb +2 -1
- metadata +4 -2
|
@@ -7,118 +7,86 @@ module Html2rss
|
|
|
7
7
|
# Builds repeated-list article segments by grouping cards under a shared parent.
|
|
8
8
|
# When the document has a +main+ or +[role=main]+, anchors outside it are dropped.
|
|
9
9
|
module List
|
|
10
|
-
module_function
|
|
11
|
-
|
|
12
10
|
##
|
|
13
11
|
# @param segmenter [Segmenter]
|
|
14
12
|
# @return [Array<Segment>]
|
|
15
|
-
def call(segmenter)
|
|
16
|
-
|
|
17
|
-
pairs = article_pairs(segmenter)
|
|
18
|
-
seen = {}.compare_by_identity
|
|
19
|
-
|
|
20
|
-
pairs.each_with_index.filter_map do |(article_tag, selected_anchor), position|
|
|
21
|
-
next unless seen[article_tag].nil?
|
|
22
|
-
|
|
23
|
-
seen[article_tag] = true
|
|
24
|
-
|
|
25
|
-
link = selected_anchor || primary.select(article_tag)
|
|
26
|
-
next unless link || segmenter.permit_unanchored
|
|
27
|
-
|
|
13
|
+
def self.call(segmenter)
|
|
14
|
+
article_pairs(segmenter).each_with_index.map do |(article_tag, link), position|
|
|
28
15
|
Segment.build(root_node: article_tag, primary_link: link, strategy: :list, position:)
|
|
29
16
|
end
|
|
30
17
|
end
|
|
31
18
|
|
|
32
|
-
def article_pairs(segmenter)
|
|
19
|
+
def self.article_pairs(segmenter)
|
|
33
20
|
top_groups(segmenter).flatten(1)
|
|
34
21
|
end
|
|
35
|
-
module_function :article_pairs
|
|
36
22
|
private_class_method :article_pairs
|
|
37
23
|
|
|
38
|
-
def top_groups(segmenter)
|
|
24
|
+
def self.top_groups(segmenter)
|
|
39
25
|
cards_by_parent(segmenter)
|
|
40
26
|
.select { |_parent, cards| cards.size >= segmenter.minimum_selector_frequency }
|
|
41
27
|
.max_by(segmenter.use_top_selectors) { |_parent, cards| cards.size }
|
|
42
28
|
.map(&:last)
|
|
43
29
|
end
|
|
44
|
-
module_function :top_groups
|
|
45
30
|
private_class_method :top_groups
|
|
46
31
|
|
|
47
|
-
def cards_by_parent(segmenter)
|
|
32
|
+
def self.cards_by_parent(segmenter)
|
|
48
33
|
index = segmenter.index
|
|
49
34
|
main = main_landmark(index)
|
|
50
35
|
groups = {}.compare_by_identity
|
|
51
36
|
|
|
52
|
-
index.each_node do |node|
|
|
53
|
-
next unless node.link?
|
|
54
|
-
next if index.ignored_chrome?(node)
|
|
55
|
-
next unless relevant_anchor?(segmenter, node)
|
|
56
|
-
next if main && !inside_main?(index, node, main)
|
|
57
|
-
|
|
37
|
+
index.each_node.select { |node| card_anchor?(segmenter, index, main, node) }.each do |node|
|
|
58
38
|
card = parent_until_boundary(segmenter, node)
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
parent = index.parent_of(card)
|
|
62
|
-
next unless parent
|
|
63
|
-
|
|
64
|
-
(groups[parent] ||= []) << [card, node]
|
|
39
|
+
(groups[index.parent_of(card)] ||= []) << [card, node]
|
|
65
40
|
end
|
|
66
41
|
groups
|
|
67
42
|
end
|
|
68
|
-
module_function :cards_by_parent
|
|
69
43
|
private_class_method :cards_by_parent
|
|
70
44
|
|
|
71
|
-
def
|
|
45
|
+
def self.card_anchor?(segmenter, index, main, node)
|
|
46
|
+
node.link? && !index.ignored_chrome?(node) && relevant_anchor?(segmenter, node) &&
|
|
47
|
+
(main.nil? || inside_main?(index, node, main))
|
|
48
|
+
end
|
|
49
|
+
private_class_method :card_anchor?
|
|
50
|
+
|
|
51
|
+
def self.main_landmark(index)
|
|
72
52
|
index.each_node.find { |node| node.name == :main || node.attrs.raw['role'] == 'main' }
|
|
73
53
|
end
|
|
74
|
-
module_function :main_landmark
|
|
75
54
|
private_class_method :main_landmark
|
|
76
55
|
|
|
77
|
-
def inside_main?(index, node, main)
|
|
56
|
+
def self.inside_main?(index, node, main)
|
|
78
57
|
node.equal?(main) || index.descendant_of?(node, main)
|
|
79
58
|
end
|
|
80
|
-
module_function :inside_main?
|
|
81
59
|
private_class_method :inside_main?
|
|
82
60
|
|
|
83
|
-
def relevant_anchor?(segmenter, node)
|
|
84
|
-
facts = segmenter.link_resolver.destination_facts(node)
|
|
85
|
-
return false unless facts
|
|
86
|
-
|
|
87
|
-
text = node.visible_text.to_s.strip
|
|
61
|
+
def self.relevant_anchor?(segmenter, node)
|
|
88
62
|
!segmenter.noise_policy.noise_anchor?(
|
|
89
|
-
text
|
|
63
|
+
text: node.visible_text, destination_facts: segmenter.link_resolver.destination_facts(node),
|
|
64
|
+
anchor: node
|
|
90
65
|
)
|
|
91
66
|
end
|
|
92
|
-
module_function :relevant_anchor?
|
|
93
67
|
private_class_method :relevant_anchor?
|
|
94
68
|
|
|
95
69
|
BOUNDARY_TAGS = Set[:body, :html].freeze
|
|
96
70
|
private_constant :BOUNDARY_TAGS
|
|
97
71
|
|
|
98
|
-
def parent_until_boundary(segmenter, node)
|
|
72
|
+
def self.parent_until_boundary(segmenter, node)
|
|
99
73
|
index = segmenter.index
|
|
100
74
|
link_counts = Hash.new { |hash, curr| hash[curr] = count_relevant_links(segmenter, curr) }
|
|
101
75
|
|
|
102
76
|
index.parent_until(node, lambda { |curr|
|
|
103
77
|
return true if BOUNDARY_TAGS.include?(curr.name)
|
|
104
|
-
return false if index.ignored_chrome?(curr)
|
|
105
78
|
|
|
106
|
-
|
|
107
|
-
parent && link_counts[parent] > link_counts[curr]
|
|
79
|
+
link_counts[index.parent_of(curr)] > link_counts[curr]
|
|
108
80
|
})
|
|
109
81
|
end
|
|
110
|
-
module_function :parent_until_boundary
|
|
111
82
|
private_class_method :parent_until_boundary
|
|
112
83
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
if node.link?
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
node.count_descendants { |child| child.link? && relevant_anchor?(segmenter, child) }
|
|
119
|
-
end
|
|
84
|
+
# Walk nodes are relevant anchors or their non-link ancestors, so a link counts as one.
|
|
85
|
+
def self.count_relevant_links(segmenter, node)
|
|
86
|
+
return 1 if node.link?
|
|
87
|
+
|
|
88
|
+
node.count_descendants { |child| child.link? && relevant_anchor?(segmenter, child) }
|
|
120
89
|
end
|
|
121
|
-
module_function :count_relevant_links
|
|
122
90
|
private_class_method :count_relevant_links
|
|
123
91
|
end
|
|
124
92
|
end
|
|
@@ -9,12 +9,10 @@ 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)
|
|
15
|
+
def self.call(segmenter)
|
|
18
16
|
containers = collect_containers(segmenter.index)
|
|
19
17
|
primary = PrimaryLink.new(segmenter)
|
|
20
18
|
|
|
@@ -26,7 +24,7 @@ module Html2rss
|
|
|
26
24
|
end
|
|
27
25
|
end
|
|
28
26
|
|
|
29
|
-
def collect_containers(index)
|
|
27
|
+
def self.collect_containers(index)
|
|
30
28
|
non_leaf = mark_non_leaf_candidates(index.root)
|
|
31
29
|
candidates = []
|
|
32
30
|
index.each_node do |node|
|
|
@@ -40,14 +38,13 @@ module Html2rss
|
|
|
40
38
|
candidates = filter_nested(candidates, index)
|
|
41
39
|
sort_by_depth(candidates, index)
|
|
42
40
|
end
|
|
43
|
-
module_function :collect_containers
|
|
44
41
|
private_class_method :collect_containers
|
|
45
42
|
|
|
46
43
|
TAG_MASKS = { article: 1, section: 2, li: 4, tr: 8, div: 16 }.freeze
|
|
47
44
|
private_constant :TAG_MASKS
|
|
48
45
|
|
|
49
46
|
# 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
|
|
47
|
+
def self.mark_non_leaf_candidates(root) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
|
51
48
|
non_leaf = Set.new.compare_by_identity
|
|
52
49
|
visit = lambda do |node|
|
|
53
50
|
seen_mask = 0
|
|
@@ -62,10 +59,9 @@ module Html2rss
|
|
|
62
59
|
visit.call(root)
|
|
63
60
|
non_leaf
|
|
64
61
|
end
|
|
65
|
-
module_function :mark_non_leaf_candidates
|
|
66
62
|
private_class_method :mark_non_leaf_candidates
|
|
67
63
|
|
|
68
|
-
def filter_nested(candidates, index)
|
|
64
|
+
def self.filter_nested(candidates, index)
|
|
69
65
|
candidate_set = {}.compare_by_identity
|
|
70
66
|
candidates.each { |c| candidate_set[c] = true }
|
|
71
67
|
rejected = {}.compare_by_identity
|
|
@@ -78,25 +74,22 @@ module Html2rss
|
|
|
78
74
|
|
|
79
75
|
candidates.reject { |c| rejected[c] }
|
|
80
76
|
end
|
|
81
|
-
module_function :filter_nested
|
|
82
77
|
private_class_method :filter_nested
|
|
83
78
|
|
|
84
|
-
def reject_ancestors(node, candidate_set, rejected, index)
|
|
79
|
+
def self.reject_ancestors(node, candidate_set, rejected, index)
|
|
85
80
|
curr = index.parent_of(node)
|
|
86
81
|
while curr && curr.name != :html
|
|
87
82
|
rejected[curr] = true if candidate_set[curr]
|
|
88
83
|
curr = index.parent_of(curr)
|
|
89
84
|
end
|
|
90
85
|
end
|
|
91
|
-
module_function :reject_ancestors
|
|
92
86
|
private_class_method :reject_ancestors
|
|
93
87
|
|
|
94
|
-
def sort_by_depth(candidates, index)
|
|
88
|
+
def self.sort_by_depth(candidates, index)
|
|
95
89
|
candidates.each_with_index
|
|
96
90
|
.sort_by { |node, i| [-index.depth_of(node), i] }
|
|
97
91
|
.map(&:first)
|
|
98
92
|
end
|
|
99
|
-
module_function :sort_by_depth
|
|
100
93
|
private_class_method :sort_by_depth
|
|
101
94
|
end
|
|
102
95
|
end
|
data/lib/html2rss/auto_source.rb
CHANGED
|
@@ -172,7 +172,7 @@ module Html2rss
|
|
|
172
172
|
break if skip_html_padding?(tier, articles)
|
|
173
173
|
|
|
174
174
|
if Scraper.heuristic_tier?(tier)
|
|
175
|
-
document ||=
|
|
175
|
+
document ||= SST::Normalizer.call(parsed_body)
|
|
176
176
|
next unless document
|
|
177
177
|
|
|
178
178
|
link_resolver ||= ::Html2rss::Scoring::LinkResolver.new(url)
|
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,10 @@ 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::Error => error
|
|
133
129
|
{ url: url.to_s, ok: false, error: error.message }
|
|
134
130
|
end
|
|
135
131
|
|
|
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
132
|
def process_queue(items, worker_count, &)
|
|
143
133
|
queue = build_work_queue(items)
|
|
144
134
|
results = Array.new(items.size)
|
|
@@ -157,7 +147,7 @@ module Html2rss
|
|
|
157
147
|
end
|
|
158
148
|
|
|
159
149
|
def drain_queue(queue, results)
|
|
160
|
-
|
|
150
|
+
loop do
|
|
161
151
|
begin
|
|
162
152
|
item, index = queue.pop(true)
|
|
163
153
|
rescue ThreadError
|
|
@@ -66,7 +66,7 @@ html2rss capture https://example.com --explain # quality JSON on stderr; YAML
|
|
|
66
66
|
## Constraints
|
|
67
67
|
|
|
68
68
|
- 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
|
|
69
|
+
- 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
70
|
- Botasaurus hops need `BOTASAURUS_SCRAPER_URL`.
|
|
71
71
|
|
|
72
72
|
See also {Html2rss::AutoSource} for article discovery and {Html2rss::FeedPipeline} for the `:auto` request chain.
|
data/lib/html2rss/capture.rb
CHANGED
|
@@ -147,7 +147,6 @@ module Html2rss
|
|
|
147
147
|
|
|
148
148
|
CaptureResult.new(
|
|
149
149
|
config:,
|
|
150
|
-
yaml: "#{SCHEMA_MODELINE}\n#{Config.to_yaml(config)}",
|
|
151
150
|
articles_count: outcome.articles.size,
|
|
152
151
|
channel_title: ch_title,
|
|
153
152
|
has_selectors: !selectors.empty?,
|
|
@@ -181,7 +180,7 @@ module Html2rss
|
|
|
181
180
|
parsed_body: (response.parsed_body if response.html_response?),
|
|
182
181
|
html: response.body
|
|
183
182
|
)&.to_s
|
|
184
|
-
rescue
|
|
183
|
+
rescue Html2rss::Error
|
|
185
184
|
nil
|
|
186
185
|
end
|
|
187
186
|
|
|
@@ -270,9 +269,6 @@ module Html2rss
|
|
|
270
269
|
return [*default_selectors, SelectorCandidates.empty] unless sst
|
|
271
270
|
|
|
272
271
|
select_enhance_selectors(sst, articles)
|
|
273
|
-
rescue ArgumentError => error
|
|
274
|
-
Log.warn("Capture selector derivation failed: #{error.message}")
|
|
275
|
-
[*default_selectors, SelectorCandidates.empty]
|
|
276
272
|
end
|
|
277
273
|
|
|
278
274
|
def hint_selectors
|
|
@@ -338,22 +334,13 @@ module Html2rss
|
|
|
338
334
|
end
|
|
339
335
|
end
|
|
340
336
|
|
|
341
|
-
def find_matching_article(segment, articles_by_url)
|
|
342
|
-
|
|
343
|
-
return articles_by_url.values.find { |a| a.title == title_from_segment(segment) } if href.empty?
|
|
344
|
-
|
|
345
|
-
resolved = Url.from_relative(href, @url)
|
|
337
|
+
def find_matching_article(segment, articles_by_url)
|
|
338
|
+
resolved = Url.from_relative(segment.primary_link.attrs.href.to_s, @url)
|
|
346
339
|
articles_by_url[resolved.to_s] || articles_by_url.values.find { |a| fuzzy_url_match?(a.url, resolved) }
|
|
347
340
|
end
|
|
348
341
|
|
|
349
342
|
def fuzzy_url_match?(article_url, resolved)
|
|
350
343
|
article_url.to_s.end_with?(resolved.path.to_s)
|
|
351
|
-
rescue StandardError
|
|
352
|
-
false
|
|
353
|
-
end
|
|
354
|
-
|
|
355
|
-
def title_from_segment(segment)
|
|
356
|
-
segment.root_node.visible_text.to_s.strip
|
|
357
344
|
end
|
|
358
345
|
|
|
359
346
|
def lift_heading_link_roots(roots)
|
|
@@ -420,12 +407,7 @@ module Html2rss
|
|
|
420
407
|
end
|
|
421
408
|
|
|
422
409
|
def path_items_selector(roots)
|
|
423
|
-
|
|
424
|
-
common = common_path_prefix(paths)
|
|
425
|
-
tag_path = common.empty? ? paths.first.to_s : common
|
|
426
|
-
return if tag_path.empty?
|
|
427
|
-
|
|
428
|
-
css_from_trimmed_tag_path(tag_path)
|
|
410
|
+
css_from_trimmed_tag_path(common_path_prefix(roots.map(&:tag_path)))
|
|
429
411
|
end
|
|
430
412
|
|
|
431
413
|
def css_from_trimmed_tag_path(tag_path)
|
|
@@ -443,9 +425,7 @@ module Html2rss
|
|
|
443
425
|
trimmed.empty? ? [segments.last] : trimmed
|
|
444
426
|
end
|
|
445
427
|
|
|
446
|
-
def common_path_prefix(paths)
|
|
447
|
-
return '' if paths.empty?
|
|
448
|
-
|
|
428
|
+
def common_path_prefix(paths)
|
|
449
429
|
segments = paths.map { |p| p.split('/').reject(&:empty?) }
|
|
450
430
|
prefix = segments.first
|
|
451
431
|
segments.each do |seg|
|
|
@@ -466,7 +446,7 @@ module Html2rss
|
|
|
466
446
|
|
|
467
447
|
def channel_title_from(response)
|
|
468
448
|
Channel.from_response(response).title
|
|
469
|
-
rescue
|
|
449
|
+
rescue Html2rss::Error
|
|
470
450
|
nil
|
|
471
451
|
end
|
|
472
452
|
|
|
@@ -486,7 +466,7 @@ module Html2rss
|
|
|
486
466
|
return final unless Url.from_absolute(@url).same_document?(Url.from_absolute(final))
|
|
487
467
|
|
|
488
468
|
nil
|
|
489
|
-
rescue
|
|
469
|
+
rescue Html2rss::Url::InvalidUrlError
|
|
490
470
|
nil
|
|
491
471
|
end
|
|
492
472
|
end
|