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
|
@@ -11,7 +11,7 @@ module Html2rss
|
|
|
11
11
|
# Detection is intentionally shallow for most scrapers, but instance-based
|
|
12
12
|
# matching is available for scrapers that need to carry expensive selection
|
|
13
13
|
# state forward into extraction.
|
|
14
|
-
module Scraper
|
|
14
|
+
module Scraper # rubocop:disable Metrics/ModuleLength -- tier registry + construction helpers
|
|
15
15
|
# Root markers indicating likely app-shell/client-rendered surfaces.
|
|
16
16
|
APP_SHELL_ROOT_SELECTORS = '#app, #root, #__next, [data-reactroot], [ng-app], [id*="app-shell"]'
|
|
17
17
|
# Maximum anchors tolerated before app-shell detection is considered unlikely.
|
|
@@ -19,31 +19,37 @@ module Html2rss
|
|
|
19
19
|
# Maximum visible text length tolerated for app-shell classification.
|
|
20
20
|
APP_SHELL_MAX_VISIBLE_TEXT_LENGTH = 220
|
|
21
21
|
|
|
22
|
-
#
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
JsonState,
|
|
30
|
-
MetaOembed,
|
|
31
|
-
SemanticHtml,
|
|
32
|
-
Html
|
|
22
|
+
# Extraction tiers: merge within a tier, then stop when AutoSource has enough articles.
|
|
23
|
+
# Heuristic scrapers are separate tiers so SemanticHtml can satisfy before Html runs.
|
|
24
|
+
SCRAPER_TIERS = [
|
|
25
|
+
[Schema, Microdata, Microformats2, JsonState, XhrArticles].freeze,
|
|
26
|
+
[WordpressApi, Sitemap, MetaOembed].freeze,
|
|
27
|
+
[SemanticHtml].freeze,
|
|
28
|
+
[Html].freeze
|
|
33
29
|
].freeze
|
|
34
30
|
|
|
31
|
+
# Flat ordered list (request slot accounting, detection helpers).
|
|
32
|
+
SCRAPERS = SCRAPER_TIERS.flatten.freeze
|
|
33
|
+
|
|
34
|
+
# Heuristic scrapers that share one memoized SST::Document per page.
|
|
35
|
+
HEURISTIC_SCRAPERS = [SemanticHtml, Html].freeze
|
|
36
|
+
# Scrapers that accept a shared follow-up +request_session+.
|
|
37
|
+
REQUEST_SESSION_SCRAPERS = [WordpressApi, Sitemap, MetaOembed].freeze
|
|
38
|
+
# Scrapers that consume browser-captured XHR/fetch JSON bodies.
|
|
39
|
+
CAPTURED_RESPONSE_SCRAPERS = [XhrArticles].freeze
|
|
40
|
+
|
|
35
41
|
##
|
|
36
42
|
# Error raised when no suitable scraper is found.
|
|
37
43
|
class NoScraperFound < Html2rss::Error
|
|
38
|
-
#
|
|
44
|
+
# Surface diagnostics shared with {Html2rss::NoFeedItemsExtracted} (one string home).
|
|
39
45
|
CATEGORY_MESSAGES = {
|
|
40
|
-
blocked_surface: '
|
|
41
|
-
'
|
|
46
|
+
blocked_surface: 'blocked surface likely (anti-bot or interstitial). ' \
|
|
47
|
+
'Target a direct listing URL, configure BOTASAURUS_SCRAPER_URL, ' \
|
|
42
48
|
'or run from an environment that can complete anti-bot checks.',
|
|
43
|
-
app_shell: '
|
|
44
|
-
'server-rendered article HTML).
|
|
49
|
+
app_shell: 'app-shell surface detected (client-rendered page with little or no ' \
|
|
50
|
+
'server-rendered article HTML). Configure BOTASAURUS_SCRAPER_URL or target a direct ' \
|
|
45
51
|
'listing/update URL instead of a homepage or shell entrypoint.',
|
|
46
|
-
unsupported_surface: '
|
|
52
|
+
unsupported_surface: 'unsupported extraction surface for auto mode. ' \
|
|
47
53
|
'Try a direct listing/changelog/category URL, ' \
|
|
48
54
|
'or use explicit selectors in a feed config.'
|
|
49
55
|
}.freeze
|
|
@@ -53,7 +59,7 @@ module Html2rss
|
|
|
53
59
|
def initialize(message = nil, category: :unsupported_surface)
|
|
54
60
|
validate_category!(category)
|
|
55
61
|
@category = category
|
|
56
|
-
super(message || CATEGORY_MESSAGES.fetch(@category))
|
|
62
|
+
super(message || "No scrapers found: #{CATEGORY_MESSAGES.fetch(@category)}")
|
|
57
63
|
end
|
|
58
64
|
|
|
59
65
|
attr_reader :category
|
|
@@ -77,9 +83,11 @@ module Html2rss
|
|
|
77
83
|
# @option opts [Hash] :microdata scraper toggle and configuration
|
|
78
84
|
# @option opts [Hash] :microformats2 scraper toggle and configuration
|
|
79
85
|
# @option opts [Hash] :json_state scraper toggle and configuration
|
|
86
|
+
# @option opts [Hash] :xhr_articles scraper toggle and configuration
|
|
80
87
|
# @option opts [Hash] :meta_oembed scraper toggle and configuration
|
|
81
88
|
# @option opts [Hash] :semantic_html scraper toggle and configuration
|
|
82
89
|
# @option opts [Hash] :html scraper toggle and configuration
|
|
90
|
+
# @option opts [Hash] :sitemap scraper toggle and configuration
|
|
83
91
|
# @return [Array<Class>] An array of scraper classes that can handle the parsed body.
|
|
84
92
|
def self.from(parsed_body, opts = Html2rss::AutoSource::DEFAULT_CONFIG[:scraper])
|
|
85
93
|
scrapers = SCRAPERS.select { |scraper| opts.dig(scraper.options_key, :enabled) }
|
|
@@ -90,63 +98,111 @@ module Html2rss
|
|
|
90
98
|
scrapers
|
|
91
99
|
end
|
|
92
100
|
|
|
93
|
-
|
|
94
|
-
# @param
|
|
95
|
-
# @
|
|
96
|
-
|
|
97
|
-
|
|
101
|
+
##
|
|
102
|
+
# @param tier [Array<Class>]
|
|
103
|
+
# @return [Boolean]
|
|
104
|
+
def self.heuristic_tier?(tier)
|
|
105
|
+
tier.intersect?(HEURISTIC_SCRAPERS)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
##
|
|
109
|
+
# @param parsed_body [Nokogiri::HTML::Document]
|
|
110
|
+
# @return [SST::Document, nil]
|
|
111
|
+
def self.normalize_sst(parsed_body)
|
|
112
|
+
SST::Normalizer.call(parsed_body)
|
|
113
|
+
rescue ArgumentError
|
|
114
|
+
nil
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
##
|
|
118
|
+
# Builds a scraper when enabled; returns nil when disabled.
|
|
119
|
+
#
|
|
120
|
+
# @param scraper [Class]
|
|
121
|
+
# @param parsed_body [Nokogiri::HTML::Document]
|
|
122
|
+
# @param opts [Hash] full scraper options map
|
|
123
|
+
# @param url [Html2rss::Url, String]
|
|
124
|
+
# @param request_session [Html2rss::RequestSession, nil]
|
|
125
|
+
# @param body [String, nil]
|
|
126
|
+
# @param document [SST::Document, nil]
|
|
127
|
+
# @param link_resolver [Scoring::LinkResolver, nil]
|
|
128
|
+
# @param captured_responses [Array<Hash>] browser-captured JSON XHR/fetch bodies
|
|
98
129
|
# @option opts [Hash] :wordpress_api scraper toggle and configuration
|
|
99
130
|
# @option opts [Hash] :schema scraper toggle and configuration
|
|
100
131
|
# @option opts [Hash] :microdata scraper toggle and configuration
|
|
101
132
|
# @option opts [Hash] :microformats2 scraper toggle and configuration
|
|
102
133
|
# @option opts [Hash] :json_state scraper toggle and configuration
|
|
134
|
+
# @option opts [Hash] :xhr_articles scraper toggle and configuration
|
|
103
135
|
# @option opts [Hash] :meta_oembed scraper toggle and configuration
|
|
104
136
|
# @option opts [Hash] :semantic_html scraper toggle and configuration
|
|
105
137
|
# @option opts [Hash] :html scraper toggle and configuration
|
|
106
|
-
# @
|
|
107
|
-
#
|
|
108
|
-
#
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
instances
|
|
138
|
+
# @option opts [Hash] :sitemap scraper toggle and configuration
|
|
139
|
+
# @return [Object, nil]
|
|
140
|
+
# rubocop:disable Metrics/ParameterLists, Metrics/MethodLength -- construction context for structured and heuristic scrapers
|
|
141
|
+
def self.build_instance(scraper, parsed_body, opts:, url:, request_session: nil, body: nil, document: nil,
|
|
142
|
+
link_resolver: nil, captured_responses: [])
|
|
143
|
+
return unless opts.dig(scraper.options_key, :enabled)
|
|
144
|
+
return if HEURISTIC_SCRAPERS.include?(scraper) && document.nil?
|
|
145
|
+
|
|
146
|
+
scraper_opts = opts.fetch(scraper.options_key, {}).except(:enabled)
|
|
147
|
+
kwargs = construction_kwargs(
|
|
148
|
+
scraper,
|
|
149
|
+
request_session:,
|
|
150
|
+
body:,
|
|
151
|
+
document:,
|
|
152
|
+
link_resolver:,
|
|
153
|
+
captured_responses:
|
|
154
|
+
)
|
|
155
|
+
scraper.new(parsed_body, url:, **kwargs, **scraper_opts)
|
|
126
156
|
end
|
|
157
|
+
# rubocop:enable Metrics/ParameterLists, Metrics/MethodLength
|
|
127
158
|
|
|
159
|
+
##
|
|
160
|
+
# @param instance [Object]
|
|
161
|
+
# @param parsed_body [Nokogiri::HTML::Document]
|
|
162
|
+
# @return [Boolean]
|
|
128
163
|
def self.extractable_instance?(instance, parsed_body)
|
|
129
164
|
return instance.extractable? if instance.respond_to?(:extractable?)
|
|
130
165
|
|
|
131
166
|
instance.class.articles?(parsed_body)
|
|
132
167
|
end
|
|
133
|
-
private_class_method :extractable_instance?
|
|
134
168
|
|
|
135
|
-
|
|
136
|
-
|
|
169
|
+
##
|
|
170
|
+
# @param parsed_body [Nokogiri::HTML::Document]
|
|
171
|
+
# @param body [String, nil] raw response body (preferred for blocked-surface checks)
|
|
172
|
+
# @return [NoScraperFound]
|
|
173
|
+
def self.no_scraper_found_for(parsed_body, body: nil)
|
|
174
|
+
NoScraperFound.new(category: classify_no_scraper_surface(parsed_body, body:))
|
|
137
175
|
end
|
|
138
|
-
private_class_method :no_scraper_found_for
|
|
139
176
|
|
|
140
|
-
def self.
|
|
141
|
-
|
|
177
|
+
def self.construction_kwargs(scraper, request_session:, body:, document:, link_resolver:, # rubocop:disable Metrics/ParameterLists -- scraper construction bag
|
|
178
|
+
captured_responses:)
|
|
179
|
+
if HEURISTIC_SCRAPERS.include?(scraper)
|
|
180
|
+
{ document:, link_resolver: }.compact
|
|
181
|
+
else
|
|
182
|
+
{}.tap do |kwargs|
|
|
183
|
+
kwargs[:request_session] = request_session if REQUEST_SESSION_SCRAPERS.include?(scraper)
|
|
184
|
+
kwargs[:body] = body if scraper == Sitemap
|
|
185
|
+
kwargs[:captured_responses] = captured_responses if CAPTURED_RESPONSE_SCRAPERS.include?(scraper)
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
private_class_method :construction_kwargs
|
|
190
|
+
|
|
191
|
+
##
|
|
192
|
+
# Classifies why scrapers could not extract from a parsed page.
|
|
193
|
+
#
|
|
194
|
+
# @param parsed_body [Nokogiri::HTML::Document]
|
|
195
|
+
# @param body [String, nil] raw body for blocked-surface detection
|
|
196
|
+
# @return [Symbol] one of {NoScraperFound::CATEGORY_MESSAGES} keys
|
|
197
|
+
def self.classify_no_scraper_surface(parsed_body, body: nil)
|
|
198
|
+
return :blocked_surface if blocked_surface?(parsed_body, body:)
|
|
142
199
|
return :app_shell if app_shell_surface?(parsed_body)
|
|
143
200
|
|
|
144
201
|
:unsupported_surface
|
|
145
202
|
end
|
|
146
|
-
private_class_method :classify_no_scraper_surface
|
|
147
203
|
|
|
148
|
-
def self.blocked_surface?(parsed_body)
|
|
149
|
-
Html2rss::RequestService::BlockedSurface.interstitial?(parsed_body.to_html)
|
|
204
|
+
def self.blocked_surface?(parsed_body, body: nil)
|
|
205
|
+
Html2rss::RequestService::BlockedSurface.interstitial?(body || parsed_body.to_html)
|
|
150
206
|
end
|
|
151
207
|
private_class_method :blocked_surface?
|
|
152
208
|
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Html2rss
|
|
4
|
+
class AutoSource
|
|
5
|
+
##
|
|
6
|
+
# One candidate content block discovered by {Segmenter}.
|
|
7
|
+
Segment = Data.define(:root_node, :primary_link, :strategy, :position) do
|
|
8
|
+
##
|
|
9
|
+
# @param root_node [SST::Node]
|
|
10
|
+
# @param primary_link [SST::Node, nil]
|
|
11
|
+
# @param strategy [Symbol]
|
|
12
|
+
# @param position [Integer]
|
|
13
|
+
# @return [Segment]
|
|
14
|
+
# @raise [ArgumentError] on invalid construction
|
|
15
|
+
def self.build(root_node:, strategy:, position:, primary_link: nil)
|
|
16
|
+
raise ArgumentError, 'root_node must be SST::Node' unless root_node.is_a?(SST::Node)
|
|
17
|
+
unless primary_link.nil? || primary_link.is_a?(SST::Node)
|
|
18
|
+
raise ArgumentError, 'primary_link must be SST::Node or nil'
|
|
19
|
+
end
|
|
20
|
+
raise ArgumentError, "unknown strategy: #{strategy.inspect}" unless Segment::STRATEGIES.include?(strategy)
|
|
21
|
+
raise ArgumentError, 'position must be Integer' unless position.is_a?(Integer)
|
|
22
|
+
|
|
23
|
+
new(root_node:, primary_link:, strategy:, position:)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
# Allowed Segmenter strategies.
|
|
27
|
+
Segment::STRATEGIES = %i[semantic list cluster].to_set.freeze
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Html2rss
|
|
4
|
+
class AutoSource
|
|
5
|
+
class Segmenter
|
|
6
|
+
##
|
|
7
|
+
# Class / structure clustering for anchorless pages
|
|
8
|
+
# (port of Discovery::DomClustering + OverlapResolver; group scoring owned by Scoring::ClusterScorer).
|
|
9
|
+
module Cluster
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
##
|
|
13
|
+
# @param segmenter [Segmenter]
|
|
14
|
+
# @return [Array<Segment>]
|
|
15
|
+
def call(segmenter)
|
|
16
|
+
nodes = best_group_nodes(segmenter)
|
|
17
|
+
nodes.each_with_index.map do |node, position|
|
|
18
|
+
Segment.build(root_node: node, primary_link: nil, strategy: :cluster, position:)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def best_group_nodes(segmenter)
|
|
23
|
+
class_groups = collect_class_groups(segmenter)
|
|
24
|
+
groups = class_groups.empty? ? collect_structure_groups(segmenter) : class_groups
|
|
25
|
+
return [] if groups.empty?
|
|
26
|
+
|
|
27
|
+
resolver = OverlapResolver.new(index: segmenter.index)
|
|
28
|
+
scorer = Scoring::ClusterScorer.new
|
|
29
|
+
non_containers = resolver.filter_containers(groups)
|
|
30
|
+
final_groups = resolver.filter_1_to_1_overlap(non_containers)
|
|
31
|
+
scorer.select_best_group(final_groups)
|
|
32
|
+
end
|
|
33
|
+
module_function :best_group_nodes
|
|
34
|
+
private_class_method :best_group_nodes
|
|
35
|
+
|
|
36
|
+
def collect_class_groups(segmenter) # rubocop:disable Metrics/AbcSize
|
|
37
|
+
groups = Hash.new { |h, k| h[k] = [] }
|
|
38
|
+
segmenter.index.each_node do |node|
|
|
39
|
+
next if SST::Tags::CLUSTER_EXCLUDED_NAMES.include?(node.name)
|
|
40
|
+
next if segmenter.index.ignored_chrome?(node)
|
|
41
|
+
|
|
42
|
+
cls = normalize_class(node.attrs.class_names)
|
|
43
|
+
groups[cls] << node unless cls.empty?
|
|
44
|
+
end
|
|
45
|
+
groups.select { |_, nodes| nodes.size >= segmenter.minimum_selector_frequency }
|
|
46
|
+
end
|
|
47
|
+
module_function :collect_class_groups
|
|
48
|
+
private_class_method :collect_class_groups
|
|
49
|
+
|
|
50
|
+
def collect_structure_groups(segmenter) # rubocop:disable Metrics/AbcSize
|
|
51
|
+
groups = Hash.new { |h, k| h[k] = [] }
|
|
52
|
+
segmenter.index.each_node do |node|
|
|
53
|
+
next if SST::Tags::CLUSTER_EXCLUDED_NAMES.include?(node.name)
|
|
54
|
+
next if segmenter.index.ignored_chrome?(node)
|
|
55
|
+
|
|
56
|
+
sig = structure_signature(node)
|
|
57
|
+
groups[sig] << node unless sig.empty?
|
|
58
|
+
end
|
|
59
|
+
groups.select { |_, nodes| nodes.size >= segmenter.minimum_selector_frequency }
|
|
60
|
+
end
|
|
61
|
+
module_function :collect_structure_groups
|
|
62
|
+
private_class_method :collect_structure_groups
|
|
63
|
+
|
|
64
|
+
def normalize_class(class_names)
|
|
65
|
+
return '' if class_names.empty?
|
|
66
|
+
|
|
67
|
+
class_names.sort.join(' ')
|
|
68
|
+
end
|
|
69
|
+
module_function :normalize_class
|
|
70
|
+
private_class_method :normalize_class
|
|
71
|
+
|
|
72
|
+
def structure_signature(node)
|
|
73
|
+
kids = node.children
|
|
74
|
+
return '' if kids.empty?
|
|
75
|
+
|
|
76
|
+
kids.map { |c| c.name.to_s }.join('>')
|
|
77
|
+
end
|
|
78
|
+
module_function :structure_signature
|
|
79
|
+
private_class_method :structure_signature
|
|
80
|
+
|
|
81
|
+
##
|
|
82
|
+
# Filters layout wrapper groups and resolves 1-to-1 nested card wrappers.
|
|
83
|
+
class OverlapResolver
|
|
84
|
+
# Cap pairwise work on dense class grids.
|
|
85
|
+
MAX_GROUPS = 40
|
|
86
|
+
|
|
87
|
+
# @param index [SST::Index]
|
|
88
|
+
def initialize(index:)
|
|
89
|
+
@index = index
|
|
90
|
+
@layout_tags = SST::Tags::LAYOUT_NAMES
|
|
91
|
+
@scorer = Scoring::ClusterScorer.new
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# @param groups [Hash{String => Array<SST::Node>}]
|
|
95
|
+
# @return [Hash{String => Array<SST::Node>}]
|
|
96
|
+
def filter_containers(groups)
|
|
97
|
+
capped = cap_groups(groups)
|
|
98
|
+
capped.reject do |cls_a, nodes_a|
|
|
99
|
+
capped.any? { |cls_b, nodes_b| cls_a != cls_b && container_of?(nodes_a, nodes_b) }
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# @param groups [Hash{String => Array<SST::Node>}]
|
|
104
|
+
# @return [Hash{String => Array<SST::Node>}]
|
|
105
|
+
def filter_1_to_1_overlap(groups)
|
|
106
|
+
discarded = {}
|
|
107
|
+
groups.each_key do |cls_a|
|
|
108
|
+
groups.each_key do |cls_b|
|
|
109
|
+
next if cls_a == cls_b || discarded[cls_a] || discarded[cls_b]
|
|
110
|
+
|
|
111
|
+
resolve_1_to_1_overlap(cls_a, cls_b, groups, discarded)
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
groups.reject { |cls, _| discarded[cls] }
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
private
|
|
118
|
+
|
|
119
|
+
def cap_groups(groups)
|
|
120
|
+
return groups if groups.size <= MAX_GROUPS
|
|
121
|
+
|
|
122
|
+
groups.sort_by { |_key, nodes| -nodes.size }.first(MAX_GROUPS).to_h
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# rubocop:disable Metrics/MethodLength
|
|
126
|
+
def container_of?(nodes_a, nodes_b)
|
|
127
|
+
return false unless @layout_tags.include?(nodes_b.first.name)
|
|
128
|
+
|
|
129
|
+
nodes_a.any? do |node_a|
|
|
130
|
+
count = 0
|
|
131
|
+
nodes_b.each do |node_b|
|
|
132
|
+
next if node_a.equal?(node_b)
|
|
133
|
+
|
|
134
|
+
if @index.descendant_of?(node_b, node_a)
|
|
135
|
+
count += 1
|
|
136
|
+
break if count > 1
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
count > 1
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
# rubocop:enable Metrics/MethodLength
|
|
143
|
+
|
|
144
|
+
def resolve_1_to_1_overlap(cls_a, cls_b, groups, discarded)
|
|
145
|
+
nodes_a = groups[cls_a]
|
|
146
|
+
nodes_b = groups[cls_b]
|
|
147
|
+
return if nodes_a.size != nodes_b.size
|
|
148
|
+
|
|
149
|
+
nested = nodes_a.zip(nodes_b).all? { |a, b| !a.equal?(b) && @index.descendant_of?(b, a) }
|
|
150
|
+
return unless nested
|
|
151
|
+
|
|
152
|
+
discarded[keep_descendant?(nodes_a, nodes_b) ? cls_a : cls_b] = true
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def keep_descendant?(nodes_a, nodes_b)
|
|
156
|
+
@scorer.avg_words(nodes_b) >= 0.8 * @scorer.avg_words(nodes_a) &&
|
|
157
|
+
@layout_tags.include?(nodes_b.first.name)
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Html2rss
|
|
4
|
+
class AutoSource
|
|
5
|
+
class Segmenter
|
|
6
|
+
##
|
|
7
|
+
# Builds repeated-list article segments from tag_path frequency
|
|
8
|
+
# (port of Discovery::ListCandidates using tag_path instead of xpath).
|
|
9
|
+
module List
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
##
|
|
13
|
+
# @param segmenter [Segmenter]
|
|
14
|
+
# @return [Array<Segment>]
|
|
15
|
+
def call(segmenter)
|
|
16
|
+
primary = PrimaryLink.new(segmenter)
|
|
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
|
+
|
|
28
|
+
Segment.build(root_node: article_tag, primary_link: link, strategy: :list, position:)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def article_pairs(segmenter)
|
|
33
|
+
by_path = relevant_links_by_path(segmenter)
|
|
34
|
+
top_paths(segmenter, by_path).flat_map do |path|
|
|
35
|
+
by_path.fetch(path, []).filter_map do |node|
|
|
36
|
+
article_tag = parent_until_boundary(segmenter, node)
|
|
37
|
+
next unless article_tag
|
|
38
|
+
|
|
39
|
+
[article_tag, node]
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
module_function :article_pairs
|
|
44
|
+
private_class_method :article_pairs
|
|
45
|
+
|
|
46
|
+
def relevant_links_by_path(segmenter)
|
|
47
|
+
by_path = Hash.new { |hash, path| hash[path] = [] }
|
|
48
|
+
segmenter.index.each_node do |node|
|
|
49
|
+
next unless node.link?
|
|
50
|
+
next if segmenter.index.ignored_chrome?(node)
|
|
51
|
+
next unless relevant_anchor?(segmenter, node)
|
|
52
|
+
|
|
53
|
+
by_path[node.tag_path] << node
|
|
54
|
+
end
|
|
55
|
+
by_path
|
|
56
|
+
end
|
|
57
|
+
module_function :relevant_links_by_path
|
|
58
|
+
private_class_method :relevant_links_by_path
|
|
59
|
+
|
|
60
|
+
def top_paths(segmenter, by_path)
|
|
61
|
+
by_path.select { |_path, nodes| nodes.size >= segmenter.minimum_selector_frequency }
|
|
62
|
+
.max_by(segmenter.use_top_selectors) { |_path, nodes| nodes.size }
|
|
63
|
+
.map(&:first)
|
|
64
|
+
end
|
|
65
|
+
module_function :top_paths
|
|
66
|
+
private_class_method :top_paths
|
|
67
|
+
|
|
68
|
+
def relevant_anchor?(segmenter, node)
|
|
69
|
+
facts = segmenter.link_resolver.destination_facts(node)
|
|
70
|
+
return false unless facts
|
|
71
|
+
|
|
72
|
+
text = node.visible_text.to_s.strip
|
|
73
|
+
!segmenter.noise_policy.noise_anchor?(
|
|
74
|
+
text:, destination_facts: facts, anchor: node, utility_landmark_ancestor: false
|
|
75
|
+
)
|
|
76
|
+
end
|
|
77
|
+
module_function :relevant_anchor?
|
|
78
|
+
private_class_method :relevant_anchor?
|
|
79
|
+
|
|
80
|
+
def parent_until_boundary(segmenter, node)
|
|
81
|
+
index = segmenter.index
|
|
82
|
+
link_counts = Hash.new { |hash, curr| hash[curr] = count_links(curr) }
|
|
83
|
+
|
|
84
|
+
index.parent_until(node, lambda { |curr|
|
|
85
|
+
return true if %i[body html].include?(curr.name)
|
|
86
|
+
return false if index.ignored_chrome?(curr)
|
|
87
|
+
|
|
88
|
+
parent = index.parent_of(curr)
|
|
89
|
+
parent && link_counts[parent] > link_counts[curr]
|
|
90
|
+
})
|
|
91
|
+
end
|
|
92
|
+
module_function :parent_until_boundary
|
|
93
|
+
private_class_method :parent_until_boundary
|
|
94
|
+
|
|
95
|
+
def count_links(node)
|
|
96
|
+
node.link? ? 1 : node.descendants.count(&:link?)
|
|
97
|
+
end
|
|
98
|
+
module_function :count_links
|
|
99
|
+
private_class_method :count_links
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Html2rss
|
|
4
|
+
class AutoSource
|
|
5
|
+
class Segmenter
|
|
6
|
+
##
|
|
7
|
+
# Selects the strongest content-like primary link inside a container
|
|
8
|
+
# (port of Discovery::SemanticAnchorCandidates).
|
|
9
|
+
class PrimaryLink
|
|
10
|
+
# @param segmenter [Segmenter]
|
|
11
|
+
def initialize(segmenter)
|
|
12
|
+
@segmenter = segmenter
|
|
13
|
+
@index = segmenter.index
|
|
14
|
+
@link_resolver = segmenter.link_resolver
|
|
15
|
+
@noise_policy = segmenter.noise_policy
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
##
|
|
19
|
+
# @param container [SST::Node]
|
|
20
|
+
# @return [SST::Node, nil]
|
|
21
|
+
def select(container) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
22
|
+
winners = {}
|
|
23
|
+
|
|
24
|
+
container.find_all(&:link?).each do |anchor|
|
|
25
|
+
next if @index.ignored_chrome?(anchor)
|
|
26
|
+
|
|
27
|
+
facts = candidate_facts(anchor, container)
|
|
28
|
+
next unless facts
|
|
29
|
+
|
|
30
|
+
dest = facts[:destination]
|
|
31
|
+
current = winners[dest]
|
|
32
|
+
winners[dest] = facts if current.nil? || facts[:score] > current[:score]
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
winners.values.max_by { |f| f[:score] }&.fetch(:anchor)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
41
|
+
def candidate_facts(anchor, container)
|
|
42
|
+
destination = @link_resolver.destination_facts(anchor)
|
|
43
|
+
return unless destination
|
|
44
|
+
|
|
45
|
+
text = anchor.visible_text.to_s.strip
|
|
46
|
+
heading = first_heading(container)
|
|
47
|
+
heading_text = heading ? heading.visible_text.to_s.strip : ''
|
|
48
|
+
heading_anchor = heading_anchor?(anchor, heading)
|
|
49
|
+
meaningful = text.match?(/\p{Alnum}/)
|
|
50
|
+
content_like = destination.content_path
|
|
51
|
+
heading_match = meaningful && heading_text.match?(/\p{Alnum}/) && heading_text == text
|
|
52
|
+
|
|
53
|
+
return if @noise_policy.noise_anchor?(
|
|
54
|
+
text:, destination_facts: destination, anchor:, container:, heading_anchor:,
|
|
55
|
+
utility_landmark_ancestor: @segmenter.landmark_ancestor?(anchor, container)
|
|
56
|
+
)
|
|
57
|
+
return unless meaningful || content_like || heading_anchor
|
|
58
|
+
|
|
59
|
+
score = (heading_anchor ? 100 : 0) +
|
|
60
|
+
(heading_match ? 20 : 0) +
|
|
61
|
+
(meaningful ? 10 : 0) +
|
|
62
|
+
(content_like ? 10 : 0)
|
|
63
|
+
|
|
64
|
+
{ anchor:, destination: destination.destination, score: }
|
|
65
|
+
end
|
|
66
|
+
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
67
|
+
|
|
68
|
+
def first_heading(container)
|
|
69
|
+
(@headings ||= {}.compare_by_identity)[container] ||= container.find(&:heading?)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def heading_anchor?(anchor, heading)
|
|
73
|
+
return false unless heading
|
|
74
|
+
|
|
75
|
+
anchor.equal?(heading) || @index.descendant_of?(anchor, heading)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|