html2rss 0.32.4 → 0.34.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +4 -2
- data/lib/html2rss/article/deduplicator.rb +5 -2
- data/lib/html2rss/article/enclosure.rb +185 -25
- data/lib/html2rss/article/title.rb +203 -0
- data/lib/html2rss/article.rb +79 -13
- data/lib/html2rss/auto_source/README.md +16 -8
- data/lib/html2rss/auto_source/cleanup.rb +127 -53
- data/lib/html2rss/auto_source/corroboration.rb +167 -0
- data/lib/html2rss/auto_source/page_links/anchor_claims.rb +82 -0
- data/lib/html2rss/auto_source/page_links/card_units.rb +77 -0
- data/lib/html2rss/auto_source/page_links/label_binding.rb +79 -0
- data/lib/html2rss/auto_source/page_links/sponsored_cards.rb +147 -0
- data/lib/html2rss/auto_source/page_links.rb +164 -0
- data/lib/html2rss/auto_source/scraper/html.rb +11 -45
- data/lib/html2rss/auto_source/scraper/json_document.rb +3 -6
- data/lib/html2rss/auto_source/scraper/json_state/article_normalizer.rb +202 -125
- data/lib/html2rss/auto_source/scraper/json_state/candidate_detector.rb +27 -38
- data/lib/html2rss/auto_source/scraper/json_state/document_scanner.rb +123 -123
- data/lib/html2rss/auto_source/scraper/json_state/value_finder.rb +27 -35
- data/lib/html2rss/auto_source/scraper/json_state.rb +4 -9
- data/lib/html2rss/auto_source/scraper/meta_oembed.rb +27 -5
- data/lib/html2rss/auto_source/scraper/microdata.rb +233 -248
- data/lib/html2rss/auto_source/scraper/microformats2.rb +10 -4
- data/lib/html2rss/auto_source/scraper/native_feed.rb +28 -18
- data/lib/html2rss/auto_source/scraper/schema/category_extractor.rb +7 -2
- data/lib/html2rss/auto_source/scraper/schema/item_list.rb +5 -3
- data/lib/html2rss/auto_source/scraper/schema/thing.rb +140 -22
- data/lib/html2rss/auto_source/scraper/schema.rb +52 -8
- data/lib/html2rss/auto_source/scraper/semantic_html/entry_deduplicator.rb +3 -9
- data/lib/html2rss/auto_source/scraper/semantic_html.rb +12 -20
- data/lib/html2rss/auto_source/scraper/sitemap/parser.rb +1 -4
- data/lib/html2rss/auto_source/scraper/sitemap.rb +7 -14
- data/lib/html2rss/auto_source/scraper/wordpress_api/page_scope.rb +2 -2
- data/lib/html2rss/auto_source/scraper/wordpress_api/posts_endpoint.rb +1 -3
- data/lib/html2rss/auto_source/scraper/wordpress_api.rb +7 -7
- data/lib/html2rss/auto_source/scraper/xhr_articles.rb +2 -4
- data/lib/html2rss/auto_source/scraper.rb +10 -13
- data/lib/html2rss/auto_source/segment.rb +19 -5
- data/lib/html2rss/auto_source/segmenter/cluster.rb +18 -22
- data/lib/html2rss/auto_source/segmenter/list.rb +35 -59
- data/lib/html2rss/auto_source/segmenter/primary_link.rb +29 -4
- data/lib/html2rss/auto_source/segmenter/semantic.rb +23 -19
- data/lib/html2rss/auto_source/segmenter.rb +4 -8
- data/lib/html2rss/auto_source.rb +81 -26
- data/lib/html2rss/batch.rb +24 -32
- data/lib/html2rss/capture/README.md +6 -3
- data/lib/html2rss/capture.rb +51 -59
- data/lib/html2rss/channel.rb +30 -12
- data/lib/html2rss/cli/render.rb +97 -96
- data/lib/html2rss/cli/validate.rb +2 -4
- data/lib/html2rss/cli.rb +55 -38
- data/lib/html2rss/config/auto_source_contract.rb +52 -45
- data/lib/html2rss/config/issue_mapper.rb +4 -1
- data/lib/html2rss/config/request_controls.rb +16 -5
- data/lib/html2rss/config/schema.rb +23 -15
- data/lib/html2rss/config/selectors_validator.rb +3 -2
- data/lib/html2rss/config/validation_issue.rb +4 -1
- data/lib/html2rss/config/validator.rb +14 -3
- data/lib/html2rss/config.rb +54 -28
- data/lib/html2rss/contract_error.rb +9 -0
- data/lib/html2rss/defaults.rb +25 -38
- data/lib/html2rss/doctor/botasaurus.rb +18 -22
- data/lib/html2rss/error.rb +17 -3
- data/lib/html2rss/error_kind.rb +39 -0
- data/lib/html2rss/feed_builder/item_presentation.rb +22 -9
- data/lib/html2rss/feed_builder/json_feed/item.rb +2 -1
- data/lib/html2rss/feed_builder/json_feed.rb +2 -2
- data/lib/html2rss/feed_builder/rss/stylesheet.rb +3 -3
- data/lib/html2rss/feed_builder/rss.rb +50 -7
- data/lib/html2rss/feed_builder.rb +2 -1
- data/lib/html2rss/feed_pipeline/README.md +1 -1
- data/lib/html2rss/feed_pipeline/auto_fallback.rb +16 -34
- data/lib/html2rss/feed_pipeline/strategy_plan.rb +3 -3
- data/lib/html2rss/feed_pipeline.rb +40 -27
- data/lib/html2rss/feed_resolution/README.md +1 -1
- data/lib/html2rss/feed_resolution/candidate_generator.rb +6 -8
- data/lib/html2rss/feed_resolution/options.rb +3 -7
- data/lib/html2rss/feed_resolution/policy.rb +29 -33
- data/lib/html2rss/feed_resolution/probe.rb +6 -4
- data/lib/html2rss/feed_resolution/scorer.rb +29 -29
- data/lib/html2rss/feed_resolution.rb +16 -21
- data/lib/html2rss/feed_result.rb +11 -6
- data/lib/html2rss/hash_util.rb +91 -90
- data/lib/html2rss/html/article_extractor/category_extractor.rb +7 -10
- data/lib/html2rss/html/article_extractor/date_extractor.rb +1 -1
- data/lib/html2rss/html/article_extractor/enclosure_extractor.rb +12 -33
- data/lib/html2rss/html/article_extractor/heading_extractor.rb +2 -3
- data/lib/html2rss/html/article_extractor/id_generator.rb +7 -10
- data/lib/html2rss/html/article_extractor/image_extractor.rb +9 -1
- data/lib/html2rss/html/article_extractor.rb +19 -47
- data/lib/html2rss/html/article_rules/category.rb +3 -3
- data/lib/html2rss/html/article_rules/description.rb +6 -15
- data/lib/html2rss/html/article_rules/enclosure.rb +70 -42
- data/lib/html2rss/html/article_rules/image.rb +30 -16
- data/lib/html2rss/html/feed_link.rb +7 -9
- data/lib/html2rss/html/navigator/text_extractor.rb +26 -16
- data/lib/html2rss/html/navigator.rb +39 -9
- data/lib/html2rss/html/probe.rb +55 -51
- data/lib/html2rss/html/rendering/description_builder.rb +46 -32
- data/lib/html2rss/html/rendering.rb +2 -9
- data/lib/html2rss/html/sst_article_extractor.rb +55 -145
- data/lib/html2rss/html/title_pieces/kicker.rb +147 -0
- data/lib/html2rss/html/title_pieces/nodes.rb +107 -0
- data/lib/html2rss/html/title_pieces/nokogiri_nodes.rb +70 -0
- data/lib/html2rss/html/title_pieces/sst_nodes.rb +71 -0
- data/lib/html2rss/html/title_pieces.rb +169 -0
- data/lib/html2rss/html/visibility.rb +56 -0
- data/lib/html2rss/html.rb +2 -0
- data/lib/html2rss/link_destination/destination_facts.rb +17 -1
- data/lib/html2rss/link_destination/noise_policy.rb +11 -10
- data/lib/html2rss/link_destination/path_classifier.rb +111 -42
- data/lib/html2rss/link_destination/sponsorship/label_text.rb +82 -0
- data/lib/html2rss/link_destination/sponsorship.rb +184 -0
- data/lib/html2rss/link_destination/text_classifier.rb +104 -8
- data/lib/html2rss/mcp/README.md +13 -1
- data/lib/html2rss/mcp/config_argument.rb +14 -4
- data/lib/html2rss/mcp/contract.rb +20 -9
- data/lib/html2rss/mcp/outcome.rb +18 -17
- data/lib/html2rss/mcp/runtime.rb +53 -23
- data/lib/html2rss/mcp/server/tools.rb +9 -9
- data/lib/html2rss/mcp/server.rb +13 -4
- data/lib/html2rss/option.rb +48 -0
- data/lib/html2rss/page_recon/README.md +1 -1
- data/lib/html2rss/page_recon/diagnostics.rb +9 -9
- data/lib/html2rss/page_recon.rb +0 -4
- data/lib/html2rss/recon.rb +19 -23
- data/lib/html2rss/request_service/blocked_surface.rb +5 -8
- data/lib/html2rss/request_service/botasaurus_contract.rb +25 -3
- data/lib/html2rss/request_service/botasaurus_strategy.rb +44 -12
- data/lib/html2rss/request_service/budget.rb +3 -24
- data/lib/html2rss/request_service/compressed_body.rb +36 -48
- data/lib/html2rss/request_service/context.rb +16 -17
- data/lib/html2rss/request_service/cookie_jar.rb +89 -0
- data/lib/html2rss/request_service/cookie_store.rb +79 -0
- data/lib/html2rss/request_service/feed_advertisement.rb +72 -0
- data/lib/html2rss/request_service/httpx_strategy.rb +7 -3
- data/lib/html2rss/request_service/local_file_strategy.rb +2 -3
- data/lib/html2rss/request_service/network_guard.rb +3 -2
- data/lib/html2rss/request_service/policy.rb +55 -36
- data/lib/html2rss/request_service/replay_breaker.rb +56 -0
- data/lib/html2rss/request_service/response.rb +33 -19
- data/lib/html2rss/request_service/response_cache.rb +109 -0
- data/lib/html2rss/request_service/response_caching.rb +95 -0
- data/lib/html2rss/request_service/strategy.rb +5 -11
- data/lib/html2rss/request_service.rb +71 -12
- data/lib/html2rss/request_session/pager/base.rb +3 -10
- data/lib/html2rss/request_session/pager/custom_selector.rb +4 -4
- data/lib/html2rss/request_session/pager/offset.rb +1 -7
- data/lib/html2rss/request_session/pager/rel_next.rb +1 -1
- data/lib/html2rss/request_session/pager/url_template.rb +1 -7
- data/lib/html2rss/request_session/pager.rb +1 -1
- data/lib/html2rss/request_session.rb +6 -2
- data/lib/html2rss/scoring/cluster_scorer.rb +1 -1
- data/lib/html2rss/scoring/container_assessor.rb +12 -13
- data/lib/html2rss/scoring/engine.rb +10 -38
- data/lib/html2rss/scoring/link_resolver.rb +2 -7
- data/lib/html2rss/scoring/observation.rb +2 -2
- data/lib/html2rss/selector_candidates.rb +19 -8
- data/lib/html2rss/selectors/README.md +1 -1
- data/lib/html2rss/selectors/extractors/href.rb +2 -4
- data/lib/html2rss/selectors/json_xml.rb +33 -34
- data/lib/html2rss/selectors/option_spec.rb +4 -1
- data/lib/html2rss/selectors/post_processors/base.rb +0 -8
- data/lib/html2rss/selectors/post_processors/gsub.rb +9 -10
- data/lib/html2rss/selectors/post_processors/html_transformers/keep_playable_media.rb +50 -0
- data/lib/html2rss/selectors/post_processors/html_transformers/transform_urls_to_absolute_ones.rb +1 -3
- data/lib/html2rss/selectors/post_processors/parse_time.rb +5 -6
- data/lib/html2rss/selectors/post_processors/parse_uri.rb +1 -1
- data/lib/html2rss/selectors/post_processors/sanitize_html.rb +18 -5
- data/lib/html2rss/selectors/post_processors/substring.rb +1 -1
- data/lib/html2rss/selectors/schema_export.rb +60 -60
- data/lib/html2rss/selectors.rb +67 -30
- data/lib/html2rss/sst/attrs.rb +2 -2
- data/lib/html2rss/sst/document.rb +3 -3
- data/lib/html2rss/sst/index.rb +30 -11
- data/lib/html2rss/sst/node.rb +42 -29
- data/lib/html2rss/sst/normalizer.rb +47 -34
- data/lib/html2rss/sst/tags.rb +113 -2
- data/lib/html2rss/sst/text.rb +94 -58
- data/lib/html2rss/sst.rb +2 -0
- data/lib/html2rss/status.rb +27 -31
- data/lib/html2rss/surface_category.rb +1 -1
- data/lib/html2rss/syndication/README.md +12 -6
- data/lib/html2rss/syndication/alternate_links.rb +39 -0
- data/lib/html2rss/syndication/channel_facts.rb +87 -0
- data/lib/html2rss/syndication/discovery.rb +171 -187
- data/lib/html2rss/syndication/feed.rb +20 -0
- data/lib/html2rss/syndication/item_media.rb +103 -0
- data/lib/html2rss/syndication/parser.rb +117 -116
- data/lib/html2rss/test/enhance_audit.rb +15 -17
- data/lib/html2rss/test/item_sample.rb +43 -0
- data/lib/html2rss/test/policy.rb +9 -11
- data/lib/html2rss/test.rb +66 -57
- data/lib/html2rss/url.rb +163 -62
- data/lib/html2rss/version.rb +1 -1
- data/lib/html2rss/word_count.rb +53 -0
- data/lib/html2rss.rb +19 -9
- data/schema/html2rss-config.schema.json +56 -41
- metadata +33 -4
- data/lib/html2rss/html/rendering/media_renderer.rb +0 -39
- data/lib/html2rss/html/rendering/pdf_renderer.rb +0 -30
|
@@ -7,7 +7,7 @@ module Html2rss
|
|
|
7
7
|
module Scraper
|
|
8
8
|
##
|
|
9
9
|
# Scrapes articles from XML sitemaps explicitly linked in HTML or discovered via standard sitemap paths.
|
|
10
|
-
class Sitemap
|
|
10
|
+
class Sitemap
|
|
11
11
|
include Enumerable
|
|
12
12
|
|
|
13
13
|
# Selector for sitemap link tags in HTML head
|
|
@@ -43,8 +43,9 @@ module Html2rss
|
|
|
43
43
|
# @param url [String, Html2rss::Url] canonical page URL
|
|
44
44
|
# @param request_session [Html2rss::RequestSession, nil] shared request session for follow-up fetches
|
|
45
45
|
# @param body [String, nil] raw response body for direct sitemap XML parsing
|
|
46
|
-
# @
|
|
47
|
-
# @option
|
|
46
|
+
# @param opts [Hash] sitemap options
|
|
47
|
+
# @option opts [Float] :min_priority minimum priority score (default: 0.3)
|
|
48
|
+
# @option opts [Integer] :max_age_days maximum entry age in days (default: 30)
|
|
48
49
|
def initialize(parsed_body, url:, request_session: nil, body: nil, **opts)
|
|
49
50
|
@parsed_body = parsed_body
|
|
50
51
|
@body = body
|
|
@@ -65,13 +66,7 @@ module Html2rss
|
|
|
65
66
|
def each
|
|
66
67
|
return enum_for(:each) unless block_given?
|
|
67
68
|
|
|
68
|
-
|
|
69
|
-
return if entries.empty?
|
|
70
|
-
|
|
71
|
-
entries.each do |entry|
|
|
72
|
-
article_hash = article_from_entry(entry)
|
|
73
|
-
yield article_hash if article_hash
|
|
74
|
-
end
|
|
69
|
+
fetch_and_parse_entries.each { |entry| yield article_from_entry(entry) }
|
|
75
70
|
end
|
|
76
71
|
|
|
77
72
|
private
|
|
@@ -137,11 +132,11 @@ module Html2rss
|
|
|
137
132
|
|
|
138
133
|
# rubocop:disable-next Metrics/MethodLength -- structured fetch-failed warn
|
|
139
134
|
def fetch_xml(target_url)
|
|
140
|
-
return unless request_session
|
|
135
|
+
return unless request_session
|
|
141
136
|
|
|
142
137
|
response = request_session.follow_up(url: target_url, relation: :auto_source, origin_url: url)
|
|
143
138
|
response&.body
|
|
144
|
-
rescue Html2rss::RequestService::RequestBudgetExceeded
|
|
139
|
+
rescue Html2rss::RequestService::RequestBudgetExceeded, Html2rss::ContractError
|
|
145
140
|
raise
|
|
146
141
|
rescue Html2rss::Error => error
|
|
147
142
|
Log.warn({
|
|
@@ -160,8 +155,6 @@ module Html2rss
|
|
|
160
155
|
end
|
|
161
156
|
|
|
162
157
|
def article_from_entry(entry)
|
|
163
|
-
return unless entry.url
|
|
164
|
-
|
|
165
158
|
{
|
|
166
159
|
url: entry.url,
|
|
167
160
|
title: entry.title,
|
|
@@ -154,7 +154,7 @@ module Html2rss
|
|
|
154
154
|
end
|
|
155
155
|
|
|
156
156
|
def archive_like?
|
|
157
|
-
|
|
157
|
+
body_classes.include?('archive')
|
|
158
158
|
end
|
|
159
159
|
|
|
160
160
|
def singular_like?
|
|
@@ -178,7 +178,7 @@ module Html2rss
|
|
|
178
178
|
|
|
179
179
|
canonical_url = Html2rss::Url.from_relative(href, url)
|
|
180
180
|
same_origin_url?(canonical_url, url) ? canonical_url : url
|
|
181
|
-
rescue
|
|
181
|
+
rescue Html2rss::Url::InvalidUrlError
|
|
182
182
|
url
|
|
183
183
|
end
|
|
184
184
|
|
|
@@ -54,7 +54,7 @@ module Html2rss
|
|
|
54
54
|
return log_missing_api_root if href.empty?
|
|
55
55
|
|
|
56
56
|
Html2rss::Url.from_relative(href, page_url)
|
|
57
|
-
rescue
|
|
57
|
+
rescue Html2rss::Url::InvalidUrlError => error
|
|
58
58
|
logger.warn("#{WordpressApi}: invalid WordPress API endpoint #{href.inspect} (#{error.message})")
|
|
59
59
|
nil
|
|
60
60
|
end
|
|
@@ -110,13 +110,11 @@ module Html2rss
|
|
|
110
110
|
def normalized_api_path(path)
|
|
111
111
|
segments = path.to_s.split('/').reject(&:empty?)
|
|
112
112
|
normalized_path = "/#{segments.join('/')}"
|
|
113
|
-
normalized_path = '/' if normalized_path == '/'
|
|
114
113
|
normalized_path.end_with?('/') ? normalized_path : "#{normalized_path}/"
|
|
115
114
|
end
|
|
116
115
|
|
|
117
116
|
def normalized_rest_route(route)
|
|
118
117
|
value = route.to_s
|
|
119
|
-
value = '/' if value.empty?
|
|
120
118
|
value = "/#{value}" unless value.start_with?('/')
|
|
121
119
|
trim_trailing_slashes(value)
|
|
122
120
|
end
|
|
@@ -46,10 +46,8 @@ module Html2rss
|
|
|
46
46
|
# @param parsed_body [Nokogiri::HTML::Document] parsed HTML document
|
|
47
47
|
# @param url [String, Html2rss::Url] canonical page URL
|
|
48
48
|
# @param request_session [Html2rss::RequestSession, nil] shared request session for follow-up fetches
|
|
49
|
-
# @param _opts [Hash] unused scraper-specific options
|
|
50
|
-
# @option _opts [Object] :_reserved reserved for future scraper-specific options
|
|
51
49
|
# @return [void]
|
|
52
|
-
def initialize(parsed_body, url:, request_session: nil
|
|
50
|
+
def initialize(parsed_body, url:, request_session: nil)
|
|
53
51
|
@parsed_body = parsed_body
|
|
54
52
|
@url = Html2rss::Url.from_absolute(url)
|
|
55
53
|
@request_session = request_session
|
|
@@ -109,6 +107,8 @@ module Html2rss
|
|
|
109
107
|
relation: :auto_source,
|
|
110
108
|
origin_url: url
|
|
111
109
|
)
|
|
110
|
+
rescue Html2rss::ContractError
|
|
111
|
+
raise
|
|
112
112
|
rescue Html2rss::Error => error
|
|
113
113
|
Log.warn({
|
|
114
114
|
event_name: 'wordpress_api.fetch_failed',
|
|
@@ -132,8 +132,8 @@ module Html2rss
|
|
|
132
132
|
absolute_link(post[:link])
|
|
133
133
|
end
|
|
134
134
|
|
|
135
|
-
def article_id(
|
|
136
|
-
root_path_query_id(article_url) || present_string(article_url.path)
|
|
135
|
+
def article_id(article_url)
|
|
136
|
+
root_path_query_id(article_url) || present_string(article_url.path)
|
|
137
137
|
end
|
|
138
138
|
|
|
139
139
|
def article_title(post)
|
|
@@ -154,7 +154,7 @@ module Html2rss
|
|
|
154
154
|
|
|
155
155
|
def article_attributes(post, article_url)
|
|
156
156
|
{
|
|
157
|
-
id: article_id(
|
|
157
|
+
id: article_id(article_url),
|
|
158
158
|
title: article_title(post),
|
|
159
159
|
description: article_description(post),
|
|
160
160
|
url: article_url,
|
|
@@ -168,7 +168,7 @@ module Html2rss
|
|
|
168
168
|
return unless value
|
|
169
169
|
|
|
170
170
|
Html2rss::Url.from_relative(value, url)
|
|
171
|
-
rescue
|
|
171
|
+
rescue Html2rss::Url::InvalidUrlError
|
|
172
172
|
nil
|
|
173
173
|
end
|
|
174
174
|
|
|
@@ -26,16 +26,14 @@ module Html2rss
|
|
|
26
26
|
# @param _parsed_body [Nokogiri::HTML::Document, nil] unused HTML document
|
|
27
27
|
# @param url [String, Html2rss::Url] page URL used to resolve relative links
|
|
28
28
|
# @param captured_responses [Array<Hash>] JSON bodies from Response#captured_responses
|
|
29
|
-
|
|
30
|
-
# @option _opts [Object] :_reserved reserved for future scraper-specific options
|
|
31
|
-
def initialize(_parsed_body, url:, captured_responses: [], **_opts)
|
|
29
|
+
def initialize(_parsed_body, url:, captured_responses: [])
|
|
32
30
|
@url = url
|
|
33
31
|
@captured_responses = captured_responses
|
|
34
32
|
end
|
|
35
33
|
|
|
36
34
|
# @return [Boolean] true when any captured body contains article-like arrays
|
|
37
35
|
def extractable?
|
|
38
|
-
parsed_bodies.any?
|
|
36
|
+
parsed_bodies.any?
|
|
39
37
|
end
|
|
40
38
|
|
|
41
39
|
# @yield [Hash{Symbol => Object}] normalized article hash
|
|
@@ -38,8 +38,16 @@ module Html2rss
|
|
|
38
38
|
# Flat ordered list (request slot accounting, detection helpers).
|
|
39
39
|
SCRAPERS = (DOCUMENT_SCRAPERS + SCRAPER_TIERS.flatten).freeze
|
|
40
40
|
|
|
41
|
-
# Heuristic scrapers that share one memoized SST::Document per page.
|
|
41
|
+
# Heuristic scrapers that share one memoized SST::Document per page. The only titles composed
|
|
42
|
+
# (+Kicker: Headline+, noise stripped): their cards are read by Html::SstArticleExtractor. Every
|
|
43
|
+
# other scraper yields publisher-declared or structured headlines, kept verbatim (collapse only).
|
|
42
44
|
HEURISTIC_SCRAPERS = Set[SemanticHtml, Html].freeze
|
|
45
|
+
# In-page structured scrapers whose items {Corroboration} checks against the rendered page.
|
|
46
|
+
# Publisher-declared sources (NativeFeed, WordpressApi, Sitemap, MetaOembed) stay exempt.
|
|
47
|
+
CORROBORATED_SCRAPERS = Set[Schema, Microdata, Microformats2, JsonState, XhrArticles, JsonDocument].freeze
|
|
48
|
+
# Scrapers whose items are read off the rendered page: such an item needs a title ({Cleanup}
|
|
49
|
+
# drops it as +blank_title+), while a publisher-declared item (feed, API) may be untitled.
|
|
50
|
+
PAGE_SCRAPERS = (CORROBORATED_SCRAPERS | HEURISTIC_SCRAPERS).freeze
|
|
43
51
|
# Scrapers that accept a shared follow-up +request_session+.
|
|
44
52
|
REQUEST_SESSION_SCRAPERS = Set[NativeFeed, WordpressApi, Sitemap, MetaOembed].freeze
|
|
45
53
|
# Scrapers that consume browser-captured XHR/fetch JSON bodies.
|
|
@@ -80,7 +88,7 @@ module Html2rss
|
|
|
80
88
|
return if CATEGORY_MESSAGES.key?(category)
|
|
81
89
|
|
|
82
90
|
valid_categories = CATEGORY_MESSAGES.keys.join(', ')
|
|
83
|
-
raise
|
|
91
|
+
raise InvalidOption, "Unknown category: #{category.inspect}. Valid categories are: #{valid_categories}"
|
|
84
92
|
end
|
|
85
93
|
end
|
|
86
94
|
|
|
@@ -117,15 +125,6 @@ module Html2rss
|
|
|
117
125
|
HEURISTIC_SCRAPERS.intersect?(tier)
|
|
118
126
|
end
|
|
119
127
|
|
|
120
|
-
##
|
|
121
|
-
# @param parsed_body [Nokogiri::HTML::Document]
|
|
122
|
-
# @return [SST::Document, nil]
|
|
123
|
-
def self.normalize_sst(parsed_body)
|
|
124
|
-
SST::Normalizer.call(parsed_body)
|
|
125
|
-
rescue SST::Normalizer::EmptyTree
|
|
126
|
-
nil
|
|
127
|
-
end
|
|
128
|
-
|
|
129
128
|
##
|
|
130
129
|
# Builds a scraper when enabled; returns nil when disabled.
|
|
131
130
|
#
|
|
@@ -220,8 +219,6 @@ module Html2rss
|
|
|
220
219
|
private_class_method :app_shell_surface?
|
|
221
220
|
|
|
222
221
|
def self.high_entropy_surface?(parsed_body)
|
|
223
|
-
return false if parsed_body.nil?
|
|
224
|
-
|
|
225
222
|
parsed_body.css('body a[href]').size >= HIGH_ENTROPY_MIN_ANCHORS
|
|
226
223
|
end
|
|
227
224
|
private_class_method :high_entropy_surface?
|
|
@@ -7,6 +7,9 @@ module Html2rss
|
|
|
7
7
|
##
|
|
8
8
|
# One candidate content block discovered by {Segmenter}.
|
|
9
9
|
class Segment
|
|
10
|
+
# Raised when a segment (or the Segmenter producing segments) gets invalid input.
|
|
11
|
+
class InvalidError < Html2rss::ContractError; end
|
|
12
|
+
|
|
10
13
|
# Allowed Segmenter strategies.
|
|
11
14
|
STRATEGIES = Set[:semantic, :list, :cluster].freeze
|
|
12
15
|
|
|
@@ -16,17 +19,28 @@ module Html2rss
|
|
|
16
19
|
# @param strategy [Symbol]
|
|
17
20
|
# @param position [Integer]
|
|
18
21
|
# @return [Segment]
|
|
19
|
-
# @raise [
|
|
22
|
+
# @raise [InvalidError] on invalid construction
|
|
20
23
|
def self.build(root_node:, strategy:, position:, primary_link: nil)
|
|
21
|
-
raise
|
|
24
|
+
raise InvalidError, 'root_node must be SST::Node' unless root_node.is_a?(SST::Node)
|
|
22
25
|
unless primary_link.nil? || primary_link.is_a?(SST::Node)
|
|
23
|
-
raise
|
|
26
|
+
raise InvalidError, 'primary_link must be SST::Node or nil'
|
|
24
27
|
end
|
|
25
|
-
|
|
26
|
-
|
|
28
|
+
|
|
29
|
+
strategy!(strategy)
|
|
30
|
+
raise InvalidError, 'position must be Integer' unless position.is_a?(Integer)
|
|
27
31
|
|
|
28
32
|
new(root_node:, primary_link:, strategy:, position:)
|
|
29
33
|
end
|
|
34
|
+
|
|
35
|
+
##
|
|
36
|
+
# @param strategy [Symbol]
|
|
37
|
+
# @return [Symbol] +strategy+ when it is one of {STRATEGIES}
|
|
38
|
+
# @raise [InvalidError] for any other value
|
|
39
|
+
def self.strategy!(strategy)
|
|
40
|
+
return strategy if STRATEGIES.include?(strategy)
|
|
41
|
+
|
|
42
|
+
raise InvalidError, "unknown strategy: #{strategy.inspect}"
|
|
43
|
+
end
|
|
30
44
|
end
|
|
31
45
|
end
|
|
32
46
|
end
|
|
@@ -7,23 +7,21 @@ module Html2rss
|
|
|
7
7
|
# Class / structure clustering for anchorless pages
|
|
8
8
|
# (port of Discovery::DomClustering + OverlapResolver; group scoring owned by Scoring::ClusterScorer).
|
|
9
9
|
module Cluster
|
|
10
|
-
module_function
|
|
11
|
-
|
|
12
10
|
##
|
|
13
11
|
# @param segmenter [Segmenter]
|
|
14
12
|
# @return [Array<Segment>]
|
|
15
|
-
def call(segmenter)
|
|
13
|
+
def self.call(segmenter)
|
|
16
14
|
primary = PrimaryLink.new(segmenter)
|
|
17
15
|
nodes = best_group_nodes(segmenter)
|
|
18
16
|
nodes.each_with_index.filter_map do |node, position|
|
|
19
17
|
link = primary.select(node)
|
|
20
|
-
next unless link
|
|
18
|
+
next unless link
|
|
21
19
|
|
|
22
20
|
Segment.build(root_node: node, primary_link: link, strategy: :cluster, position:)
|
|
23
21
|
end
|
|
24
22
|
end
|
|
25
23
|
|
|
26
|
-
def best_group_nodes(segmenter)
|
|
24
|
+
def self.best_group_nodes(segmenter)
|
|
27
25
|
groups = collect_groups(segmenter)
|
|
28
26
|
return [] if groups.empty?
|
|
29
27
|
|
|
@@ -33,10 +31,9 @@ module Html2rss
|
|
|
33
31
|
final_groups = resolver.filter_1_to_1_overlap(non_containers)
|
|
34
32
|
scorer.select_best_group(final_groups)
|
|
35
33
|
end
|
|
36
|
-
module_function :best_group_nodes
|
|
37
34
|
private_class_method :best_group_nodes
|
|
38
35
|
|
|
39
|
-
def collect_groups(segmenter) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength
|
|
36
|
+
def self.collect_groups(segmenter) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength
|
|
40
37
|
class_groups = Hash.new { |hash, key| hash[key] = [] }
|
|
41
38
|
structure_groups = Hash.new { |hash, key| hash[key] = [] }
|
|
42
39
|
min = segmenter.minimum_selector_frequency
|
|
@@ -57,24 +54,21 @@ module Html2rss
|
|
|
57
54
|
|
|
58
55
|
structure_groups.select { |_, nodes| nodes.size >= min }
|
|
59
56
|
end
|
|
60
|
-
module_function :collect_groups
|
|
61
57
|
private_class_method :collect_groups
|
|
62
58
|
|
|
63
|
-
def normalize_class(class_names)
|
|
59
|
+
def self.normalize_class(class_names)
|
|
64
60
|
return '' if class_names.empty?
|
|
65
61
|
|
|
66
62
|
class_names.map { ::Html2rss::Html::Probe.fold(_1) }.sort.join(' ')
|
|
67
63
|
end
|
|
68
|
-
module_function :normalize_class
|
|
69
64
|
private_class_method :normalize_class
|
|
70
65
|
|
|
71
|
-
def structure_signature(node)
|
|
66
|
+
def self.structure_signature(node)
|
|
72
67
|
kids = node.children
|
|
73
68
|
return '' if kids.empty?
|
|
74
69
|
|
|
75
70
|
kids.map { |child| child.name.to_s }.join('>')
|
|
76
71
|
end
|
|
77
|
-
module_function :structure_signature
|
|
78
72
|
private_class_method :structure_signature
|
|
79
73
|
|
|
80
74
|
##
|
|
@@ -121,21 +115,23 @@ module Html2rss
|
|
|
121
115
|
groups.sort_by { |_key, nodes| -nodes.size }.first(MAX_GROUPS).to_h
|
|
122
116
|
end
|
|
123
117
|
|
|
124
|
-
#
|
|
118
|
+
# A group is a container of +nodes_b+ when one of its nodes strictly encloses two or more of them.
|
|
125
119
|
def container_of?(nodes_a, nodes_b)
|
|
126
120
|
return false unless @layout_tags.include?(nodes_b.first.name)
|
|
127
121
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
next if node_a.equal?(node_b)
|
|
122
|
+
counts = enclosed_counts(nodes_b)
|
|
123
|
+
nodes_a.any? { |node_a| counts[node_a] > 1 }
|
|
124
|
+
end
|
|
132
125
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
126
|
+
# Number of +nodes+ strictly beneath each ancestor, tallied once per group instead of per pair.
|
|
127
|
+
def enclosed_counts(nodes)
|
|
128
|
+
@enclosed_counts ||= {}.compare_by_identity
|
|
129
|
+
@enclosed_counts[nodes] ||= nodes.each_with_object(Hash.new(0).compare_by_identity) do |node, tally|
|
|
130
|
+
ancestor = @index.parent_of(node)
|
|
131
|
+
while ancestor
|
|
132
|
+
tally[ancestor] += 1
|
|
133
|
+
ancestor = @index.parent_of(ancestor)
|
|
137
134
|
end
|
|
138
|
-
count > 1
|
|
139
135
|
end
|
|
140
136
|
end
|
|
141
137
|
|
|
@@ -7,118 +7,94 @@ 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
|
|
36
|
+
link_counts = relevant_link_counts(segmenter)
|
|
51
37
|
|
|
52
|
-
index.each_node do |node|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
next unless relevant_anchor?(segmenter, node)
|
|
56
|
-
next if main && !inside_main?(index, node, main)
|
|
57
|
-
|
|
58
|
-
card = parent_until_boundary(segmenter, node)
|
|
59
|
-
next unless card
|
|
60
|
-
|
|
61
|
-
parent = index.parent_of(card)
|
|
62
|
-
next unless parent
|
|
63
|
-
|
|
64
|
-
(groups[parent] ||= []) << [card, node]
|
|
38
|
+
index.each_node.select { |node| card_anchor?(segmenter, index, main, node) }.each do |node|
|
|
39
|
+
card = parent_until_boundary(segmenter, node, link_counts)
|
|
40
|
+
(groups[index.parent_of(card)] ||= []) << [card, node]
|
|
65
41
|
end
|
|
66
42
|
groups
|
|
67
43
|
end
|
|
68
|
-
module_function :cards_by_parent
|
|
69
44
|
private_class_method :cards_by_parent
|
|
70
45
|
|
|
71
|
-
def
|
|
46
|
+
def self.card_anchor?(segmenter, index, main, node)
|
|
47
|
+
node.link? && !index.ignored_chrome?(node) && relevant_anchor?(segmenter, node) &&
|
|
48
|
+
(main.nil? || inside_main?(index, node, main))
|
|
49
|
+
end
|
|
50
|
+
private_class_method :card_anchor?
|
|
51
|
+
|
|
52
|
+
def self.main_landmark(index)
|
|
72
53
|
index.each_node.find { |node| node.name == :main || node.attrs.raw['role'] == 'main' }
|
|
73
54
|
end
|
|
74
|
-
module_function :main_landmark
|
|
75
55
|
private_class_method :main_landmark
|
|
76
56
|
|
|
77
|
-
def inside_main?(index, node, main)
|
|
57
|
+
def self.inside_main?(index, node, main)
|
|
78
58
|
node.equal?(main) || index.descendant_of?(node, main)
|
|
79
59
|
end
|
|
80
|
-
module_function :inside_main?
|
|
81
60
|
private_class_method :inside_main?
|
|
82
61
|
|
|
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
|
|
62
|
+
def self.relevant_anchor?(segmenter, node)
|
|
88
63
|
!segmenter.noise_policy.noise_anchor?(
|
|
89
|
-
text
|
|
64
|
+
text: node.visible_text, destination_facts: segmenter.link_resolver.destination_facts(node),
|
|
65
|
+
anchor: node
|
|
90
66
|
)
|
|
91
67
|
end
|
|
92
|
-
module_function :relevant_anchor?
|
|
93
68
|
private_class_method :relevant_anchor?
|
|
94
69
|
|
|
95
70
|
BOUNDARY_TAGS = Set[:body, :html].freeze
|
|
96
71
|
private_constant :BOUNDARY_TAGS
|
|
97
72
|
|
|
98
|
-
def parent_until_boundary(segmenter, node)
|
|
73
|
+
def self.parent_until_boundary(segmenter, node, link_counts)
|
|
99
74
|
index = segmenter.index
|
|
100
|
-
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
84
|
+
# Node-keyed counts shared across every anchor of one run, so shared ancestors are counted once.
|
|
85
|
+
# Keyed by identity: SST nodes are Data, whose structural +#hash+ walks the whole subtree.
|
|
86
|
+
def self.relevant_link_counts(segmenter)
|
|
87
|
+
relevant = Hash.new { |hash, node| hash[node] = relevant_anchor?(segmenter, node) }.compare_by_identity
|
|
88
|
+
Hash.new { |hash, node| hash[node] = count_relevant_links(node, relevant) }.compare_by_identity
|
|
89
|
+
end
|
|
90
|
+
private_class_method :relevant_link_counts
|
|
91
|
+
|
|
92
|
+
# Walk nodes are relevant anchors or their non-link ancestors, so a link counts as one.
|
|
93
|
+
def self.count_relevant_links(node, relevant)
|
|
94
|
+
return 1 if node.link?
|
|
95
|
+
|
|
96
|
+
node.count_descendants { |child| child.link? && relevant[child] }
|
|
120
97
|
end
|
|
121
|
-
module_function :count_relevant_links
|
|
122
98
|
private_class_method :count_relevant_links
|
|
123
99
|
end
|
|
124
100
|
end
|
|
@@ -4,8 +4,9 @@ module Html2rss
|
|
|
4
4
|
class AutoSource
|
|
5
5
|
class Segmenter
|
|
6
6
|
##
|
|
7
|
-
# Selects the strongest content-like primary link
|
|
8
|
-
# (port of Discovery::SemanticAnchorCandidates).
|
|
7
|
+
# Selects the strongest content-like primary link of a container
|
|
8
|
+
# (port of Discovery::SemanticAnchorCandidates). A card without a qualifying link of its own
|
|
9
|
+
# falls back to the anchor that wraps the whole card.
|
|
9
10
|
class PrimaryLink
|
|
10
11
|
# @param segmenter [Segmenter]
|
|
11
12
|
def initialize(segmenter)
|
|
@@ -32,11 +33,28 @@ module Html2rss
|
|
|
32
33
|
winners[dest] = facts if current.nil? || facts[:score] > current[:score]
|
|
33
34
|
end
|
|
34
35
|
|
|
35
|
-
winners.values.max_by { |f| f[:score] }&.fetch(:anchor)
|
|
36
|
+
winners.values.max_by { |f| f[:score] }&.fetch(:anchor) || wrapping_link(container)
|
|
36
37
|
end
|
|
37
38
|
|
|
38
39
|
private
|
|
39
40
|
|
|
41
|
+
# The nearest ancestor link when it wraps a whole card.
|
|
42
|
+
def wrapping_link(container)
|
|
43
|
+
link = @index.parent_until(@index.parent_of(container), :link?.to_proc)
|
|
44
|
+
return unless link && !@index.ignored_chrome?(link) && card_link?(link)
|
|
45
|
+
|
|
46
|
+
link if candidate_facts(link, container)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# A card link holds the card's heading and no other link. A link holding other links is a
|
|
50
|
+
# parser-recovered unclosed anchor spanning a region; one without a heading wraps a fragment
|
|
51
|
+
# of a story (such as one live-blog entry), not a card.
|
|
52
|
+
def card_link?(link)
|
|
53
|
+
(@card_links ||= {}.compare_by_identity).fetch(link) do
|
|
54
|
+
@card_links[link] = link.find(&:heading?) && link.find { |node| node.link? && !node.equal?(link) }.nil?
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
40
58
|
# rubocop:disable-next Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
41
59
|
def candidate_facts(anchor, container)
|
|
42
60
|
destination = @link_resolver.destination_facts(anchor)
|
|
@@ -45,8 +63,8 @@ module Html2rss
|
|
|
45
63
|
text = anchor.visible_text.to_s.strip
|
|
46
64
|
heading = first_heading(container)
|
|
47
65
|
heading_text = heading ? heading.visible_text.to_s.strip : ''
|
|
48
|
-
heading_anchor = heading_anchor?(anchor, heading)
|
|
49
66
|
meaningful = text.match?(/\p{Alnum}/)
|
|
67
|
+
heading_anchor = heading_anchor?(anchor, heading) || (!meaningful && overlay_link?(anchor, heading))
|
|
50
68
|
content_like = destination.content_path
|
|
51
69
|
heading_match = meaningful && heading_text.match?(/\p{Alnum}/) && heading_text == text
|
|
52
70
|
|
|
@@ -73,6 +91,13 @@ module Html2rss
|
|
|
73
91
|
|
|
74
92
|
anchor.equal?(heading) || @index.descendant_of?(anchor, heading)
|
|
75
93
|
end
|
|
94
|
+
|
|
95
|
+
# An empty overlay link beside the heading (or a wrapper of it) stands in for the heading's link.
|
|
96
|
+
def overlay_link?(anchor, heading)
|
|
97
|
+
return false unless heading&.visible_text.to_s.match?(/\p{Alnum}/)
|
|
98
|
+
|
|
99
|
+
@index.descendant_of?(heading, @index.parent_of(anchor))
|
|
100
|
+
end
|
|
76
101
|
end
|
|
77
102
|
end
|
|
78
103
|
end
|