html2rss 0.24.0 → 0.26.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +76 -2
- data/html2rss.gemspec +4 -1
- data/lib/html2rss/auto_source/cleanup.rb +62 -36
- data/lib/html2rss/auto_source/scraper/html.rb +68 -145
- data/lib/html2rss/auto_source/scraper/json_state/article_normalizer.rb +116 -0
- data/lib/html2rss/auto_source/scraper/json_state/candidate_detector.rb +63 -0
- data/lib/html2rss/auto_source/scraper/json_state/document_scanner.rb +179 -0
- data/lib/html2rss/auto_source/scraper/json_state/value_finder.rb +55 -0
- data/lib/html2rss/auto_source/scraper/json_state.rb +31 -401
- data/lib/html2rss/auto_source/scraper/schema/item_list.rb +2 -14
- data/lib/html2rss/auto_source/scraper/semantic_html/entry_deduplicator.rb +35 -30
- data/lib/html2rss/auto_source/scraper/semantic_html.rb +40 -120
- data/lib/html2rss/auto_source/scraper/sitemap/parser.rb +161 -0
- data/lib/html2rss/auto_source/scraper/sitemap.rb +10 -7
- data/lib/html2rss/auto_source/scraper/wordpress_api.rb +6 -2
- data/lib/html2rss/auto_source/scraper/xhr_articles.rb +69 -0
- data/lib/html2rss/auto_source/scraper.rb +109 -53
- data/lib/html2rss/auto_source/segment.rb +29 -0
- data/lib/html2rss/auto_source/segmenter/cluster.rb +163 -0
- data/lib/html2rss/auto_source/segmenter/list.rb +103 -0
- data/lib/html2rss/auto_source/segmenter/primary_link.rb +80 -0
- data/lib/html2rss/auto_source/segmenter/semantic.rb +104 -0
- data/lib/html2rss/auto_source/segmenter.rb +95 -0
- data/lib/html2rss/auto_source.rb +79 -21
- data/lib/html2rss/capture.rb +315 -0
- data/lib/html2rss/cli.rb +72 -18
- data/lib/html2rss/config/auto_source_contract.rb +5 -1
- data/lib/html2rss/config/request_controls.rb +33 -0
- data/lib/html2rss/config/schema.rb +67 -26
- data/lib/html2rss/config/selectors_validator.rb +60 -21
- data/lib/html2rss/config/validator.rb +33 -35
- data/lib/html2rss/config.rb +6 -2
- data/lib/html2rss/error.rb +15 -3
- data/lib/html2rss/feed_builder/rss.rb +17 -5
- data/lib/html2rss/feed_pipeline/auto_fallback.rb +16 -6
- data/lib/html2rss/feed_pipeline/runtime_policy.rb +3 -43
- data/lib/html2rss/feed_pipeline.rb +11 -0
- data/lib/html2rss/feed_result.rb +1 -1
- data/lib/html2rss/hash_util.rb +17 -0
- data/lib/html2rss/html/article_extractor/category_extractor.rb +10 -36
- data/lib/html2rss/html/article_extractor/date_extractor.rb +2 -7
- data/lib/html2rss/html/article_extractor/enclosure_extractor.rb +5 -50
- data/lib/html2rss/html/article_extractor/image_extractor.rb +5 -16
- data/lib/html2rss/html/article_rules/category.rb +55 -0
- data/lib/html2rss/html/article_rules/date.rb +25 -0
- data/lib/html2rss/html/article_rules/enclosure.rb +72 -0
- data/lib/html2rss/html/article_rules/image.rb +109 -0
- data/lib/html2rss/html/article_rules.rb +10 -0
- data/lib/html2rss/html/rendering/audio_renderer.rb +2 -12
- data/lib/html2rss/html/rendering/escaped_attributes.rb +27 -0
- data/lib/html2rss/html/rendering/image_renderer.rb +2 -12
- data/lib/html2rss/html/rendering/pdf_renderer.rb +2 -8
- data/lib/html2rss/html/rendering/video_renderer.rb +2 -12
- data/lib/html2rss/html/sst_article_extractor.rb +308 -0
- data/lib/html2rss/link_destination/destination_facts.rb +40 -0
- data/lib/html2rss/link_destination/noise_policy.rb +68 -0
- data/lib/html2rss/link_destination/path_classifier.rb +208 -0
- data/lib/html2rss/link_destination/text_classifier.rb +64 -0
- data/lib/html2rss/link_destination.rb +8 -0
- data/lib/html2rss/mcp/server.rb +577 -0
- data/lib/html2rss/mcp.rb +21 -0
- data/lib/html2rss/request_service/blocked_surface.rb +24 -1
- data/lib/html2rss/request_service/botasaurus_contract.rb +89 -9
- data/lib/html2rss/request_service/botasaurus_strategy.rb +4 -2
- data/lib/html2rss/request_service/budget.rb +7 -35
- data/lib/html2rss/request_service/context.rb +0 -6
- data/lib/html2rss/request_service/faraday_strategy.rb +87 -3
- data/lib/html2rss/request_service/network_guard.rb +5 -3
- data/lib/html2rss/request_service/policy.rb +1 -1
- data/lib/html2rss/request_service/response.rb +15 -1
- data/lib/html2rss/request_service/strategy.rb +1 -2
- data/lib/html2rss/request_service.rb +4 -9
- data/lib/html2rss/scoring/cluster_scorer.rb +87 -0
- data/lib/html2rss/scoring/container_assessor.rb +77 -0
- data/lib/html2rss/scoring/engine.rb +145 -0
- data/lib/html2rss/scoring/link_resolver.rb +84 -0
- data/lib/html2rss/scoring/observation.rb +53 -0
- data/lib/html2rss/scoring/ranked_segment.rb +33 -0
- data/lib/html2rss/scoring/score.rb +11 -0
- data/lib/html2rss/scoring.rb +8 -0
- data/lib/html2rss/selectors/extractors/attribute.rb +15 -0
- data/lib/html2rss/selectors/extractors/href.rb +12 -0
- data/lib/html2rss/selectors/extractors/html.rb +12 -0
- data/lib/html2rss/selectors/extractors/static.rb +14 -0
- data/lib/html2rss/selectors/extractors/text.rb +11 -0
- data/lib/html2rss/selectors/post_processors/gsub.rb +18 -0
- data/lib/html2rss/selectors/post_processors/html_to_markdown.rb +11 -0
- data/lib/html2rss/selectors/post_processors/markdown_to_html.rb +12 -0
- data/lib/html2rss/selectors/post_processors/parse_time.rb +11 -0
- data/lib/html2rss/selectors/post_processors/parse_uri.rb +11 -0
- data/lib/html2rss/selectors/post_processors/sanitize_html.rb +23 -1
- data/lib/html2rss/selectors/post_processors/substring.rb +21 -0
- data/lib/html2rss/selectors/post_processors/template.rb +20 -0
- data/lib/html2rss/selectors/schema_doc.rb +99 -0
- data/lib/html2rss/selectors.rb +0 -20
- data/lib/html2rss/sst/attrs.rb +91 -0
- data/lib/html2rss/sst/document.rb +23 -0
- data/lib/html2rss/sst/index.rb +112 -0
- data/lib/html2rss/sst/node.rb +147 -0
- data/lib/html2rss/sst/normalizer.rb +171 -0
- data/lib/html2rss/sst/tags.rb +26 -0
- data/lib/html2rss/sst/text.rb +81 -0
- data/lib/html2rss/sst.rb +8 -0
- data/lib/html2rss/url.rb +11 -7
- data/lib/html2rss/version.rb +1 -1
- data/lib/html2rss.rb +57 -26
- data/schema/html2rss-config.schema.json +428 -85
- metadata +93 -27
- data/lib/html2rss/auto_source/discovery/dom_clustering/group_scorer.rb +0 -80
- data/lib/html2rss/auto_source/discovery/dom_clustering/overlap_resolver.rb +0 -86
- data/lib/html2rss/auto_source/discovery/dom_clustering.rb +0 -119
- data/lib/html2rss/auto_source/discovery/list_candidates.rb +0 -94
- data/lib/html2rss/auto_source/discovery/semantic_anchor_candidates.rb +0 -219
- data/lib/html2rss/auto_source/discovery/semantic_containers.rb +0 -71
- data/lib/html2rss/auto_source/discovery/sitemap.rb +0 -159
- data/lib/html2rss/auto_source/discovery.rb +0 -14
- data/lib/html2rss/auto_source/link_heuristics/anchor_signals.rb +0 -28
- data/lib/html2rss/auto_source/link_heuristics/container_assessor.rb +0 -106
- data/lib/html2rss/auto_source/link_heuristics/container_signals.rb +0 -80
- data/lib/html2rss/auto_source/link_heuristics/destination_facts.rb +0 -42
- data/lib/html2rss/auto_source/link_heuristics/href_extractor.rb +0 -38
- data/lib/html2rss/auto_source/link_heuristics/path_classifier.rb +0 -221
- data/lib/html2rss/auto_source/link_heuristics/text_classifier.rb +0 -66
- data/lib/html2rss/auto_source/link_heuristics.rb +0 -139
- data/lib/html2rss/request_service/browserless_strategy.rb +0 -132
- data/lib/html2rss/request_service/puppet_commander/navigation_guards.rb +0 -148
- data/lib/html2rss/request_service/puppet_commander/preload_runner.rb +0 -86
- data/lib/html2rss/request_service/puppet_commander.rb +0 -95
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Html2rss
|
|
4
|
+
class AutoSource
|
|
5
|
+
class Segmenter
|
|
6
|
+
##
|
|
7
|
+
# Collects leaf semantic containers (port of Discovery::SemanticContainers).
|
|
8
|
+
module Semantic
|
|
9
|
+
# Tag names collected as leaf semantic containers.
|
|
10
|
+
CANDIDATE_NAMES = %i[article section li tr div].to_set.freeze
|
|
11
|
+
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
##
|
|
15
|
+
# @param segmenter [Segmenter]
|
|
16
|
+
# @return [Array<Segment>]
|
|
17
|
+
def call(segmenter)
|
|
18
|
+
containers = collect_containers(segmenter.index)
|
|
19
|
+
primary = PrimaryLink.new(segmenter)
|
|
20
|
+
|
|
21
|
+
containers.each_with_index.filter_map do |container, position|
|
|
22
|
+
link = primary.select(container)
|
|
23
|
+
next unless link || segmenter.permit_unanchored
|
|
24
|
+
|
|
25
|
+
Segment.build(root_node: container, primary_link: link, strategy: :semantic, position:)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def collect_containers(index)
|
|
30
|
+
non_leaf = mark_non_leaf_candidates(index.root)
|
|
31
|
+
candidates = []
|
|
32
|
+
index.each_node do |node|
|
|
33
|
+
next unless CANDIDATE_NAMES.include?(node.name)
|
|
34
|
+
next if non_leaf.include?(node)
|
|
35
|
+
next if index.ignored_chrome?(node)
|
|
36
|
+
|
|
37
|
+
candidates << node
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
candidates = filter_nested(candidates, index)
|
|
41
|
+
sort_by_depth(candidates, index)
|
|
42
|
+
end
|
|
43
|
+
module_function :collect_containers
|
|
44
|
+
private_class_method :collect_containers
|
|
45
|
+
|
|
46
|
+
TAG_MASKS = { article: 1, section: 2, li: 4, tr: 8, div: 16 }.freeze
|
|
47
|
+
private_constant :TAG_MASKS
|
|
48
|
+
|
|
49
|
+
# 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
|
|
51
|
+
non_leaf = Set.new.compare_by_identity
|
|
52
|
+
visit = lambda do |node|
|
|
53
|
+
seen_mask = 0
|
|
54
|
+
node.children.each do |child|
|
|
55
|
+
child_mask = visit.call(child)
|
|
56
|
+
seen_mask |= child_mask | (TAG_MASKS[child.name] || 0)
|
|
57
|
+
end
|
|
58
|
+
node_mask = TAG_MASKS[node.name]
|
|
59
|
+
non_leaf.add(node) if node_mask && seen_mask.anybits?(node_mask)
|
|
60
|
+
seen_mask
|
|
61
|
+
end
|
|
62
|
+
visit.call(root)
|
|
63
|
+
non_leaf
|
|
64
|
+
end
|
|
65
|
+
module_function :mark_non_leaf_candidates
|
|
66
|
+
private_class_method :mark_non_leaf_candidates
|
|
67
|
+
|
|
68
|
+
def filter_nested(candidates, index)
|
|
69
|
+
candidate_set = {}.compare_by_identity
|
|
70
|
+
candidates.each { |c| candidate_set[c] = true }
|
|
71
|
+
rejected = {}.compare_by_identity
|
|
72
|
+
|
|
73
|
+
candidates.each do |candidate|
|
|
74
|
+
next if candidate.name == :div
|
|
75
|
+
|
|
76
|
+
reject_ancestors(candidate, candidate_set, rejected, index)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
candidates.reject { |c| rejected[c] }
|
|
80
|
+
end
|
|
81
|
+
module_function :filter_nested
|
|
82
|
+
private_class_method :filter_nested
|
|
83
|
+
|
|
84
|
+
def reject_ancestors(node, candidate_set, rejected, index)
|
|
85
|
+
curr = index.parent_of(node)
|
|
86
|
+
while curr && curr.name != :html
|
|
87
|
+
rejected[curr] = true if candidate_set[curr]
|
|
88
|
+
curr = index.parent_of(curr)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
module_function :reject_ancestors
|
|
92
|
+
private_class_method :reject_ancestors
|
|
93
|
+
|
|
94
|
+
def sort_by_depth(candidates, index)
|
|
95
|
+
candidates.each_with_index
|
|
96
|
+
.sort_by { |node, i| [-index.depth_of(node), i] }
|
|
97
|
+
.map(&:first)
|
|
98
|
+
end
|
|
99
|
+
module_function :sort_by_depth
|
|
100
|
+
private_class_method :sort_by_depth
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Html2rss
|
|
4
|
+
class AutoSource
|
|
5
|
+
##
|
|
6
|
+
# Discovers candidate content segments from an SST::Document.
|
|
7
|
+
#
|
|
8
|
+
# Strategies:
|
|
9
|
+
# - +:semantic+ — leaf article/section/li/tr/div containers with a primary link
|
|
10
|
+
# - +:list+ — repeated tag_path anchors walked to a shared container boundary
|
|
11
|
+
# - +:cluster+ — class / structure clustering for anchorless card grids
|
|
12
|
+
class Segmenter
|
|
13
|
+
# @param document [SST::Document]
|
|
14
|
+
# @param base_url [String, Html2rss::Url]
|
|
15
|
+
# @param strategy [Symbol]
|
|
16
|
+
# @param opts [Hash] segmentation options
|
|
17
|
+
# @option opts [Boolean] :permit_unanchored keep containers without a primary link
|
|
18
|
+
# @option opts [Integer] :minimum_selector_frequency list strategy frequency floor
|
|
19
|
+
# @option opts [Integer] :use_top_selectors list strategy selector budget
|
|
20
|
+
# @option opts [Scoring::LinkResolver, nil] :link_resolver page-scoped destination facts cache
|
|
21
|
+
# @return [Array<Segment>]
|
|
22
|
+
# rubocop:disable Style/ArgumentsForwarding -- keep named opts for YARD @option
|
|
23
|
+
def self.call(document, base_url:, strategy:, **opts)
|
|
24
|
+
new(document, base_url:, strategy:, **opts).call
|
|
25
|
+
end
|
|
26
|
+
# rubocop:enable Style/ArgumentsForwarding
|
|
27
|
+
|
|
28
|
+
# @param document [SST::Document]
|
|
29
|
+
# @param base_url [String, Html2rss::Url]
|
|
30
|
+
# @param strategy [Symbol]
|
|
31
|
+
# @param opts [Hash] segmentation options
|
|
32
|
+
# @option opts [Boolean] :permit_unanchored keep containers without a primary link
|
|
33
|
+
# @option opts [Integer] :minimum_selector_frequency list strategy frequency floor
|
|
34
|
+
# @option opts [Integer] :use_top_selectors list strategy selector budget
|
|
35
|
+
# @option opts [Scoring::LinkResolver, nil] :link_resolver page-scoped destination facts cache
|
|
36
|
+
def initialize(document, base_url:, strategy:, **opts)
|
|
37
|
+
raise ArgumentError, 'document must be SST::Document' unless document.is_a?(SST::Document)
|
|
38
|
+
raise ArgumentError, "unknown strategy: #{strategy.inspect}" unless Segment::STRATEGIES.include?(strategy)
|
|
39
|
+
|
|
40
|
+
@document = document
|
|
41
|
+
@base_url = base_url
|
|
42
|
+
@strategy = strategy
|
|
43
|
+
@permit_unanchored = opts.fetch(:permit_unanchored, false)
|
|
44
|
+
@minimum_selector_frequency = opts.fetch(:minimum_selector_frequency, 2)
|
|
45
|
+
@use_top_selectors = opts.fetch(:use_top_selectors, 5)
|
|
46
|
+
@link_resolver = opts[:link_resolver] || Scoring::LinkResolver.new(base_url)
|
|
47
|
+
@noise_policy = LinkDestination::NoisePolicy.new(link_resolver: @link_resolver)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# @return [Array<Segment>]
|
|
51
|
+
def call
|
|
52
|
+
case @strategy
|
|
53
|
+
when :semantic then Semantic.call(self)
|
|
54
|
+
when :list then List.call(self)
|
|
55
|
+
when :cluster then Cluster.call(self)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
attr_reader :document, :base_url, :permit_unanchored, :minimum_selector_frequency,
|
|
60
|
+
:use_top_selectors, :link_resolver, :noise_policy
|
|
61
|
+
|
|
62
|
+
# @return [SST::Index]
|
|
63
|
+
def index = document.index
|
|
64
|
+
|
|
65
|
+
##
|
|
66
|
+
# Whether +anchor+ has a utility-landmark ancestor outside +container+.
|
|
67
|
+
#
|
|
68
|
+
# @param anchor [SST::Node]
|
|
69
|
+
# @param container [SST::Node]
|
|
70
|
+
# @return [Boolean]
|
|
71
|
+
def landmark_ancestor?(anchor, container)
|
|
72
|
+
cache = (@landmark_cache ||= {})
|
|
73
|
+
key = [anchor.object_id, container.object_id]
|
|
74
|
+
return cache[key] if cache.key?(key)
|
|
75
|
+
|
|
76
|
+
cache[key] = compute_landmark_ancestor?(anchor:, container:)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
private
|
|
80
|
+
|
|
81
|
+
def compute_landmark_ancestor?(anchor:, container:) # rubocop:disable Metrics/CyclomaticComplexity
|
|
82
|
+
return false unless anchor && container
|
|
83
|
+
|
|
84
|
+
curr = index.parent_of(anchor)
|
|
85
|
+
while curr && curr.name != :html
|
|
86
|
+
return true if curr != container && curr.utility_landmark?
|
|
87
|
+
return false if curr.equal?(container)
|
|
88
|
+
|
|
89
|
+
curr = index.parent_of(curr)
|
|
90
|
+
end
|
|
91
|
+
false
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
data/lib/html2rss/auto_source.rb
CHANGED
|
@@ -15,17 +15,22 @@ module Html2rss
|
|
|
15
15
|
# @see Html2rss::AutoSource::Scraper::Schema
|
|
16
16
|
# @see Html2rss::AutoSource::Scraper::SemanticHtml
|
|
17
17
|
# @see Html2rss::AutoSource::Scraper::Html
|
|
18
|
+
# rubocop:disable Metrics/ClassLength -- defaults + tiered extract stay on the contributor entry type
|
|
18
19
|
class AutoSource
|
|
20
|
+
# Default max articles to keep (also the short-circuit floor across scraper tiers).
|
|
21
|
+
DEFAULT_LIMIT = 25
|
|
22
|
+
|
|
19
23
|
# Default auto-source configuration shipped for scraper and cleanup behavior.
|
|
20
24
|
DEFAULT_CONFIG = {
|
|
25
|
+
limit: DEFAULT_LIMIT,
|
|
21
26
|
scraper: {
|
|
22
27
|
wordpress_api: {
|
|
23
28
|
enabled: true
|
|
24
29
|
},
|
|
25
30
|
sitemap: {
|
|
26
31
|
enabled: true,
|
|
27
|
-
min_priority:
|
|
28
|
-
max_age_days:
|
|
32
|
+
min_priority: Scraper::Sitemap::Parser::DEFAULT_MIN_PRIORITY,
|
|
33
|
+
max_age_days: Scraper::Sitemap::Parser::DEFAULT_MAX_AGE_DAYS
|
|
29
34
|
},
|
|
30
35
|
schema: {
|
|
31
36
|
enabled: true
|
|
@@ -39,6 +44,9 @@ module Html2rss
|
|
|
39
44
|
json_state: {
|
|
40
45
|
enabled: true
|
|
41
46
|
},
|
|
47
|
+
xhr_articles: {
|
|
48
|
+
enabled: true
|
|
49
|
+
},
|
|
42
50
|
meta_oembed: {
|
|
43
51
|
enabled: true
|
|
44
52
|
},
|
|
@@ -80,28 +88,25 @@ module Html2rss
|
|
|
80
88
|
# @param response [Html2rss::RequestService::Response] initial page response
|
|
81
89
|
# @param opts [Hash] validated auto-source options
|
|
82
90
|
# @param request_session [Html2rss::RequestSession, nil] shared request session for follow-up fetches
|
|
91
|
+
# @option opts [Integer] :limit max articles to keep; later tiers stop once this many survive Cleanup
|
|
83
92
|
# @option opts [Hash] :scraper scraper configuration map
|
|
84
93
|
# @option opts [Hash] :cleanup cleanup configuration map
|
|
85
94
|
# @return [void]
|
|
86
95
|
def initialize(response, opts = DEFAULT_CONFIG, request_session: nil)
|
|
87
96
|
@parsed_body = response.parsed_body
|
|
97
|
+
@body = response.body
|
|
88
98
|
@url = response.url
|
|
99
|
+
@captured_responses = response.captured_responses
|
|
89
100
|
@opts = opts
|
|
90
101
|
@request_session = request_session
|
|
91
102
|
end
|
|
92
103
|
|
|
93
104
|
##
|
|
94
|
-
# Extracts
|
|
95
|
-
# page shape, running those scrapers, and normalizing the resulting hashes
|
|
96
|
-
# into `Article` objects.
|
|
105
|
+
# Extracts articles by running scraper tiers until a sufficient set is found.
|
|
97
106
|
#
|
|
98
|
-
#
|
|
99
|
-
#
|
|
100
|
-
#
|
|
101
|
-
# 3. clean and deduplicate the merged article list
|
|
102
|
-
#
|
|
103
|
-
# Scrapers with expensive precomputation, such as `SemanticHtml`, keep that
|
|
104
|
-
# state on the instance so detection and extraction can reuse the same work.
|
|
107
|
+
# Tiers: in-page structured → follow-up IO → SemanticHtml → Html.
|
|
108
|
+
# SST is built only when a heuristic tier runs. Later tiers are skipped once
|
|
109
|
+
# +limit+ articles with url+title remain after Cleanup; the result is capped to +limit+.
|
|
105
110
|
#
|
|
106
111
|
# @return [Array<Html2rss::Article>] extracted articles
|
|
107
112
|
def articles
|
|
@@ -113,22 +118,74 @@ module Html2rss
|
|
|
113
118
|
|
|
114
119
|
private
|
|
115
120
|
|
|
116
|
-
attr_reader :url, :parsed_body, :request_session
|
|
121
|
+
attr_reader :url, :parsed_body, :body, :request_session, :captured_responses
|
|
117
122
|
|
|
123
|
+
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
118
124
|
def extract_articles
|
|
119
|
-
|
|
120
|
-
|
|
125
|
+
articles = []
|
|
126
|
+
matched = false
|
|
127
|
+
document = nil
|
|
128
|
+
link_resolver = nil
|
|
129
|
+
scraper_opts = @opts.fetch(:scraper, {})
|
|
130
|
+
|
|
131
|
+
Scraper::SCRAPER_TIERS.each do |tier|
|
|
132
|
+
break if enough_articles?(articles)
|
|
133
|
+
|
|
134
|
+
if Scraper.heuristic_tier?(tier)
|
|
135
|
+
document ||= Scraper.normalize_sst(parsed_body)
|
|
136
|
+
next unless document
|
|
137
|
+
|
|
138
|
+
link_resolver ||= ::Html2rss::Scoring::LinkResolver.new(url)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
tier.each do |scraper_class|
|
|
142
|
+
instance = Scraper.build_instance(
|
|
143
|
+
scraper_class,
|
|
144
|
+
parsed_body,
|
|
145
|
+
opts: scraper_opts,
|
|
146
|
+
url:,
|
|
147
|
+
request_session:,
|
|
148
|
+
body:,
|
|
149
|
+
document:,
|
|
150
|
+
link_resolver:,
|
|
151
|
+
captured_responses:
|
|
152
|
+
)
|
|
153
|
+
next unless instance
|
|
154
|
+
next unless Scraper.extractable_instance?(instance, parsed_body)
|
|
121
155
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
156
|
+
matched = true
|
|
157
|
+
articles.concat(run_scraper(instance))
|
|
158
|
+
end
|
|
125
159
|
end
|
|
126
|
-
|
|
160
|
+
|
|
161
|
+
raise Scraper.no_scraper_found_for(parsed_body, body:) unless matched
|
|
162
|
+
|
|
163
|
+
Cleanup.call(articles, url:, **cleanup_options).first(article_limit)
|
|
164
|
+
end
|
|
165
|
+
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
166
|
+
|
|
167
|
+
def enough_articles?(articles)
|
|
168
|
+
threshold = article_limit
|
|
169
|
+
return false if articles.size < threshold
|
|
170
|
+
|
|
171
|
+
cleaned = Cleanup.call(articles.dup, url:, **cleanup_options)
|
|
172
|
+
cleaned.count { |article| article.url && !article.title.to_s.empty? } >= threshold
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def article_limit
|
|
176
|
+
@opts.fetch(:limit, DEFAULT_LIMIT)
|
|
127
177
|
end
|
|
128
178
|
|
|
129
179
|
def run_scraper(instance)
|
|
130
|
-
instance.each.map do |
|
|
131
|
-
|
|
180
|
+
instance.each.map do |item|
|
|
181
|
+
case item
|
|
182
|
+
when Article
|
|
183
|
+
item
|
|
184
|
+
when Hash
|
|
185
|
+
Article.new(**item, scraper: instance.class)
|
|
186
|
+
else
|
|
187
|
+
raise TypeError, "#{instance.class} yielded #{item.class}; expected Article or Hash"
|
|
188
|
+
end
|
|
132
189
|
end
|
|
133
190
|
end
|
|
134
191
|
|
|
@@ -136,4 +193,5 @@ module Html2rss
|
|
|
136
193
|
@opts.fetch(:cleanup, {})
|
|
137
194
|
end
|
|
138
195
|
end
|
|
196
|
+
# rubocop:enable Metrics/ClassLength
|
|
139
197
|
end
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Html2rss
|
|
4
|
+
##
|
|
5
|
+
# Analyzes a URL and produces a reusable feed config hash with derived CSS selectors.
|
|
6
|
+
#
|
|
7
|
+
# Uses the auto-source pipeline to extract articles, then traces back through
|
|
8
|
+
# SST segments to build items + attribute selectors suitable for a static feed config.
|
|
9
|
+
class Capture # rubocop:disable Metrics/ClassLength
|
|
10
|
+
LEADING_TRIM_TAGS = %w[html body].freeze
|
|
11
|
+
private_constant :LEADING_TRIM_TAGS
|
|
12
|
+
|
|
13
|
+
##
|
|
14
|
+
# Result of a capture operation.
|
|
15
|
+
# @!attribute config [Hash] feed config hash with +:channel+ and +:selectors+
|
|
16
|
+
# @!attribute articles_count [Integer] number of articles extracted
|
|
17
|
+
# @!attribute channel_title [String] derived channel title
|
|
18
|
+
CaptureResult = Data.define(:config, :articles_count, :channel_title)
|
|
19
|
+
|
|
20
|
+
class << self
|
|
21
|
+
##
|
|
22
|
+
# Analyzes a URL and builds a reusable feed config.
|
|
23
|
+
#
|
|
24
|
+
# @param url [String] source page URL
|
|
25
|
+
# @param strategy [Symbol] request strategy (+:auto+, +:faraday+, +:botasaurus+)
|
|
26
|
+
# @option options [String, nil] :items_selector optional selector hint
|
|
27
|
+
# @option options [Integer, nil] :max_redirects optional redirect limit override
|
|
28
|
+
# @option options [Integer, nil] :max_requests optional request budget override
|
|
29
|
+
# @option options [Integer, nil] :limit max articles to keep
|
|
30
|
+
# @option options [String, nil] :local_file_path optional local HTML file path
|
|
31
|
+
# @return [CaptureResult]
|
|
32
|
+
def build(url, strategy: :auto, **)
|
|
33
|
+
new(url, strategy:, **).build
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
##
|
|
38
|
+
# @param url [String] source page URL
|
|
39
|
+
# @param strategy [Symbol] request strategy
|
|
40
|
+
# @param options [Hash] additional options
|
|
41
|
+
# @option options [String, nil] :items_selector optional selector hint
|
|
42
|
+
# @option options [Integer, nil] :max_redirects optional redirect limit override
|
|
43
|
+
# @option options [Integer, nil] :max_requests optional request budget override
|
|
44
|
+
# @option options [Integer, nil] :limit max articles to keep
|
|
45
|
+
# @option options [String, nil] :local_file_path optional local HTML file path
|
|
46
|
+
def initialize(url, strategy: :auto, **options)
|
|
47
|
+
@url = url
|
|
48
|
+
@strategy = strategy
|
|
49
|
+
@items_selector_hint = options.delete(:items_selector)
|
|
50
|
+
@max_redirects = options.delete(:max_redirects)
|
|
51
|
+
@max_requests = options.delete(:max_requests)
|
|
52
|
+
@limit = options.delete(:limit)
|
|
53
|
+
@local_file_path = options.delete(:local_file_path)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
##
|
|
57
|
+
# Runs the capture pipeline.
|
|
58
|
+
#
|
|
59
|
+
# @return [CaptureResult]
|
|
60
|
+
def build # rubocop:disable Metrics/MethodLength
|
|
61
|
+
response = fetch_response
|
|
62
|
+
articles = extract_articles(response)
|
|
63
|
+
selectors = derive_selectors(response, articles)
|
|
64
|
+
|
|
65
|
+
config = {
|
|
66
|
+
channel: build_channel(response),
|
|
67
|
+
selectors: selectors.empty? ? nil : selectors,
|
|
68
|
+
**local_file_request_overlay
|
|
69
|
+
}.compact
|
|
70
|
+
|
|
71
|
+
CaptureResult.new(
|
|
72
|
+
config:,
|
|
73
|
+
articles_count: articles.size,
|
|
74
|
+
channel_title: channel_title_from(response)
|
|
75
|
+
)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
private
|
|
79
|
+
|
|
80
|
+
def raw_config
|
|
81
|
+
@raw_config ||= build_raw_config
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def build_raw_config # rubocop:disable Metrics/MethodLength
|
|
85
|
+
Config.auto_source_config(
|
|
86
|
+
url: @url,
|
|
87
|
+
items_selector: @items_selector_hint,
|
|
88
|
+
request_controls: Config::RequestControls.from_shortcut(
|
|
89
|
+
strategy: @strategy,
|
|
90
|
+
max_redirects: @max_redirects,
|
|
91
|
+
max_requests: @max_requests
|
|
92
|
+
),
|
|
93
|
+
limit: @limit
|
|
94
|
+
).tap do |config|
|
|
95
|
+
config[:strategy] = resolve_strategy(config[:strategy] || @strategy)
|
|
96
|
+
apply_local_file_path!(config)
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def apply_local_file_path!(config)
|
|
101
|
+
return unless @local_file_path
|
|
102
|
+
|
|
103
|
+
config[:strategy] = :local_file
|
|
104
|
+
config[:request] ||= {}
|
|
105
|
+
config[:request][:local_file_path] = @local_file_path
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def local_file_request_overlay
|
|
109
|
+
return {} unless @local_file_path
|
|
110
|
+
|
|
111
|
+
{ strategy: :local_file, request: { local_file_path: @local_file_path } }
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def resolve_strategy(strategy)
|
|
115
|
+
plan = FeedPipeline::StrategyPlan.resolve(strategy)
|
|
116
|
+
plan.is_a?(FeedPipeline::StrategyPlan::Auto) ? :faraday : plan.strategy
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def fetch_response
|
|
120
|
+
config = Config.from_hash(raw_config)
|
|
121
|
+
resources = FeedPipeline::RuntimePolicy.resources_for(config)
|
|
122
|
+
session = RequestSession.build(
|
|
123
|
+
config:,
|
|
124
|
+
strategy: config.strategy,
|
|
125
|
+
budget: resources.budget,
|
|
126
|
+
policy: resources.policy
|
|
127
|
+
)
|
|
128
|
+
session.fetch_initial_response
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def extract_articles(response)
|
|
132
|
+
auto_source_opts = raw_config[:auto_source] || AutoSource::DEFAULT_CONFIG
|
|
133
|
+
AutoSource.new(response, auto_source_opts).articles
|
|
134
|
+
rescue AutoSource::Scraper::NoScraperFound
|
|
135
|
+
[]
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def derive_selectors(response, articles) # rubocop:disable Metrics/MethodLength
|
|
139
|
+
return {} if articles.empty? || !response.html_response?
|
|
140
|
+
|
|
141
|
+
sst = normalize_sst(response)
|
|
142
|
+
return {} unless sst
|
|
143
|
+
|
|
144
|
+
segments = discover_segments(sst)
|
|
145
|
+
return {} if segments.empty?
|
|
146
|
+
|
|
147
|
+
matched = match_segments_to_articles(segments, articles)
|
|
148
|
+
return {} if matched.empty?
|
|
149
|
+
|
|
150
|
+
build_selector_hash(matched, sst)
|
|
151
|
+
rescue ArgumentError => error
|
|
152
|
+
Log.warn("Capture selector derivation failed: #{error.message}")
|
|
153
|
+
{}
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def normalize_sst(response)
|
|
157
|
+
SST::Normalizer.call(response.body)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def discover_segments(sst)
|
|
161
|
+
link_resolver = Scoring::LinkResolver.new(@url)
|
|
162
|
+
|
|
163
|
+
AutoSource::Segmenter.call(
|
|
164
|
+
sst,
|
|
165
|
+
base_url: @url,
|
|
166
|
+
strategy: :list,
|
|
167
|
+
permit_unanchored: false,
|
|
168
|
+
link_resolver:
|
|
169
|
+
)
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def match_segments_to_articles(segments, articles)
|
|
173
|
+
articles_by_url = articles.each_with_object({}) do |article, hash|
|
|
174
|
+
url_str = article.url.to_s
|
|
175
|
+
hash[url_str] = article unless url_str.empty?
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
segments.filter_map do |segment|
|
|
179
|
+
next unless segment.primary_link
|
|
180
|
+
|
|
181
|
+
article = find_matching_article(segment, articles_by_url)
|
|
182
|
+
{ segment:, article: } if article
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def find_matching_article(segment, articles_by_url) # rubocop:disable Metrics/AbcSize
|
|
187
|
+
href = segment.primary_link.attrs.href.to_s
|
|
188
|
+
return articles_by_url.values.find { |a| a.title == title_from_segment(segment) } if href.empty?
|
|
189
|
+
|
|
190
|
+
resolved = Url.from_relative(href, @url)
|
|
191
|
+
articles_by_url[resolved.to_s] || articles_by_url.values.find { |a| fuzzy_url_match?(a.url, resolved) }
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def fuzzy_url_match?(article_url, resolved)
|
|
195
|
+
article_url.to_s.end_with?(resolved.path.to_s)
|
|
196
|
+
rescue StandardError
|
|
197
|
+
false
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def title_from_segment(segment)
|
|
201
|
+
segment.root_node.visible_text.to_s.strip
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def build_selector_hash(matched, _sst)
|
|
205
|
+
items_sel = items_selector(matched)
|
|
206
|
+
return {} unless items_sel
|
|
207
|
+
|
|
208
|
+
first = matched.first
|
|
209
|
+
root = first[:segment].root_node
|
|
210
|
+
|
|
211
|
+
attrs = {}.tap do |a|
|
|
212
|
+
a[:title] = title_selector(root)
|
|
213
|
+
a[:url] = url_selector(first[:segment])
|
|
214
|
+
a[:description] = description_selector(root, first[:segment])
|
|
215
|
+
end.compact
|
|
216
|
+
|
|
217
|
+
{ items: { selector: items_sel }, **attrs }
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def items_selector(matched)
|
|
221
|
+
roots = matched.map { |m| m[:segment].root_node }
|
|
222
|
+
shared = shared_class_items_selector(roots)
|
|
223
|
+
return shared if shared
|
|
224
|
+
|
|
225
|
+
paths = roots.map(&:tag_path)
|
|
226
|
+
common = common_path_prefix(paths)
|
|
227
|
+
tag_path = common.empty? ? paths.first.to_s : common
|
|
228
|
+
return nil if tag_path.empty?
|
|
229
|
+
|
|
230
|
+
css_from_trimmed_tag_path(tag_path)
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def shared_class_items_selector(roots)
|
|
234
|
+
shared = roots.map { |root| root.attrs.class_names }.reduce { |left, right| left & right }
|
|
235
|
+
return nil if shared.nil? || shared.empty?
|
|
236
|
+
|
|
237
|
+
"#{roots.first.name}.#{shared.min}"
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def css_from_trimmed_tag_path(tag_path)
|
|
241
|
+
segments = tag_path.split('/').reject(&:empty?)
|
|
242
|
+
trimmed = trim_leading_items_segments(segments)
|
|
243
|
+
trimmed.join(' > ')
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def trim_leading_items_segments(segments)
|
|
247
|
+
return segments if segments.empty?
|
|
248
|
+
|
|
249
|
+
trimmed = segments.dup
|
|
250
|
+
trimmed.shift while trimmed.any? && LEADING_TRIM_TAGS.include?(trimmed.first)
|
|
251
|
+
trimmed.shift while trimmed.length > 1 && trimmed.first == 'div'
|
|
252
|
+
trimmed.empty? ? [segments.last] : trimmed
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def common_path_prefix(paths) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity
|
|
256
|
+
return '' if paths.empty?
|
|
257
|
+
|
|
258
|
+
segments = paths.map { |p| p.split('/').reject(&:empty?) }
|
|
259
|
+
prefix = segments.first
|
|
260
|
+
segments.each do |seg|
|
|
261
|
+
i = 0
|
|
262
|
+
i += 1 while i < prefix.length && i < seg.length && prefix[i] == seg[i]
|
|
263
|
+
prefix = prefix[0...i]
|
|
264
|
+
end
|
|
265
|
+
"/#{prefix.join('/')}"
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def title_selector(root) # rubocop:disable Metrics/CyclomaticComplexity
|
|
269
|
+
heading = root.find(&:heading?)
|
|
270
|
+
relative = css_for_path(heading.tag_path, root.tag_path) if heading
|
|
271
|
+
relative ||= begin
|
|
272
|
+
link = root.find { |n| n.link? && n.visible_text.to_s.strip.length > 3 }
|
|
273
|
+
css_for_path(link.tag_path, root.tag_path) if link
|
|
274
|
+
end
|
|
275
|
+
return nil unless relative
|
|
276
|
+
|
|
277
|
+
{ selector: relative }
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
def url_selector(segment)
|
|
281
|
+
return nil unless segment.primary_link
|
|
282
|
+
|
|
283
|
+
relative = css_for_path(segment.primary_link.tag_path, segment.root_node.tag_path)
|
|
284
|
+
{ selector: relative, extractor: 'href' }
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def description_selector(root, segment)
|
|
288
|
+
relative = css_for_path(root.tag_path, segment.root_node.tag_path)
|
|
289
|
+
return nil if relative.nil? || relative.empty? || relative == '.'
|
|
290
|
+
|
|
291
|
+
{ selector: relative }
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
def css_for_path(full_path, root_path)
|
|
295
|
+
relative = full_path.delete_prefix(root_path)
|
|
296
|
+
return '.' if relative.empty?
|
|
297
|
+
|
|
298
|
+
relative.split('/').reject(&:empty?).join(' > ')
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
def build_channel(response)
|
|
302
|
+
{
|
|
303
|
+
url: @url,
|
|
304
|
+
title: channel_title_from(response),
|
|
305
|
+
time_zone: 'UTC'
|
|
306
|
+
}.compact
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
def channel_title_from(response)
|
|
310
|
+
Channel.from_response(response).title
|
|
311
|
+
rescue StandardError
|
|
312
|
+
nil
|
|
313
|
+
end
|
|
314
|
+
end
|
|
315
|
+
end
|