html2rss 0.33.0 → 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 -21
- 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 +126 -50
- 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 -39
- data/lib/html2rss/auto_source/scraper/json_document.rb +1 -3
- data/lib/html2rss/auto_source/scraper/json_state/article_normalizer.rb +119 -42
- data/lib/html2rss/auto_source/scraper/json_state/candidate_detector.rb +5 -16
- data/lib/html2rss/auto_source/scraper/json_state/value_finder.rb +2 -10
- data/lib/html2rss/auto_source/scraper/json_state.rb +2 -3
- data/lib/html2rss/auto_source/scraper/meta_oembed.rb +6 -14
- data/lib/html2rss/auto_source/scraper/microdata.rb +6 -4
- data/lib/html2rss/auto_source/scraper/microformats2.rb +1 -3
- data/lib/html2rss/auto_source/scraper/native_feed.rb +27 -17
- data/lib/html2rss/auto_source/scraper/schema/item_list.rb +5 -3
- data/lib/html2rss/auto_source/scraper/schema/thing.rb +68 -27
- data/lib/html2rss/auto_source/scraper/schema.rb +5 -8
- data/lib/html2rss/auto_source/scraper/semantic_html/entry_deduplicator.rb +3 -9
- data/lib/html2rss/auto_source/scraper/semantic_html.rb +1 -5
- data/lib/html2rss/auto_source/scraper/sitemap.rb +4 -3
- data/lib/html2rss/auto_source/scraper/wordpress_api.rb +3 -3
- data/lib/html2rss/auto_source/scraper/xhr_articles.rb +1 -3
- data/lib/html2rss/auto_source/scraper.rb +10 -2
- data/lib/html2rss/auto_source/segment.rb +19 -5
- data/lib/html2rss/auto_source/segmenter/cluster.rb +13 -11
- data/lib/html2rss/auto_source/segmenter/list.rb +13 -5
- data/lib/html2rss/auto_source/segmenter/primary_link.rb +29 -4
- data/lib/html2rss/auto_source/segmenter/semantic.rb +17 -6
- data/lib/html2rss/auto_source/segmenter.rb +4 -8
- data/lib/html2rss/auto_source.rb +81 -26
- data/lib/html2rss/batch.rb +2 -0
- data/lib/html2rss/capture/README.md +5 -2
- data/lib/html2rss/capture.rb +44 -32
- data/lib/html2rss/channel.rb +30 -12
- data/lib/html2rss/cli.rb +22 -11
- 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 +6 -3
- data/lib/html2rss/config/schema.rb +15 -5
- data/lib/html2rss/config/selectors_validator.rb +2 -2
- data/lib/html2rss/config/validation_issue.rb +4 -1
- data/lib/html2rss/config/validator.rb +11 -0
- data/lib/html2rss/config.rb +53 -27
- data/lib/html2rss/contract_error.rb +9 -0
- data/lib/html2rss/defaults.rb +21 -23
- data/lib/html2rss/doctor/botasaurus.rb +7 -3
- data/lib/html2rss/error.rb +10 -0
- 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 +49 -6
- 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 +13 -31
- 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 +3 -4
- data/lib/html2rss/feed_resolution/policy.rb +1 -1
- data/lib/html2rss/feed_resolution/probe.rb +3 -1
- data/lib/html2rss/feed_resolution.rb +11 -12
- data/lib/html2rss/feed_result.rb +9 -5
- data/lib/html2rss/hash_util.rb +3 -3
- data/lib/html2rss/html/article_extractor/category_extractor.rb +1 -1
- data/lib/html2rss/html/article_extractor/enclosure_extractor.rb +12 -31
- data/lib/html2rss/html/article_extractor/heading_extractor.rb +2 -3
- data/lib/html2rss/html/article_extractor/id_generator.rb +6 -9
- 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 +4 -0
- 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 +100 -9
- 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 +5 -2
- data/lib/html2rss/mcp/contract.rb +13 -4
- data/lib/html2rss/mcp/outcome.rb +14 -12
- data/lib/html2rss/mcp/runtime.rb +39 -2
- data/lib/html2rss/mcp/server.rb +13 -4
- data/lib/html2rss/page_recon/README.md +1 -1
- data/lib/html2rss/page_recon/diagnostics.rb +2 -0
- data/lib/html2rss/recon.rb +10 -12
- data/lib/html2rss/request_service/blocked_surface.rb +2 -1
- data/lib/html2rss/request_service/botasaurus_contract.rb +25 -3
- data/lib/html2rss/request_service/botasaurus_strategy.rb +42 -10
- data/lib/html2rss/request_service/context.rb +15 -14
- 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 -2
- data/lib/html2rss/request_service/policy.rb +48 -11
- data/lib/html2rss/request_service/replay_breaker.rb +56 -0
- data/lib/html2rss/request_service/response.rb +30 -13
- 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 +0 -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 +6 -6
- 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/option_spec.rb +4 -1
- data/lib/html2rss/selectors/post_processors/base.rb +0 -8
- data/lib/html2rss/selectors/post_processors/gsub.rb +7 -7
- 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 +3 -3
- 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.rb +53 -12
- 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 +33 -26
- data/lib/html2rss/sst/normalizer.rb +31 -19
- data/lib/html2rss/sst/tags.rb +113 -2
- data/lib/html2rss/sst/text.rb +69 -29
- data/lib/html2rss/sst.rb +2 -0
- data/lib/html2rss/status.rb +6 -3
- data/lib/html2rss/surface_category.rb +1 -1
- data/lib/html2rss/syndication/README.md +12 -6
- data/lib/html2rss/syndication/alternate_links.rb +1 -1
- data/lib/html2rss/syndication/channel_facts.rb +87 -0
- data/lib/html2rss/syndication/discovery.rb +82 -28
- data/lib/html2rss/syndication/feed.rb +20 -0
- data/lib/html2rss/syndication/item_media.rb +103 -0
- data/lib/html2rss/syndication/parser.rb +75 -51
- data/lib/html2rss/test/enhance_audit.rb +2 -2
- data/lib/html2rss/test/item_sample.rb +43 -0
- data/lib/html2rss/test.rb +42 -14
- data/lib/html2rss/url.rb +115 -14
- data/lib/html2rss/version.rb +1 -1
- data/lib/html2rss/word_count.rb +53 -0
- data/lib/html2rss.rb +17 -8
- data/schema/html2rss-config.schema.json +56 -41
- metadata +31 -4
- data/lib/html2rss/html/rendering/media_renderer.rb +0 -39
- data/lib/html2rss/html/rendering/pdf_renderer.rb +0 -30
data/lib/html2rss/url.rb
CHANGED
|
@@ -26,24 +26,35 @@ module Html2rss
|
|
|
26
26
|
class Url
|
|
27
27
|
include Comparable
|
|
28
28
|
|
|
29
|
+
# Closed set of query params that carry campaign or referrer data, never page identity.
|
|
30
|
+
TRACKING_PARAM = /\A(?:utm_.*|fbclid|gclid|msclkid|mc_cid|mc_eid|ref|ref_src)\z/i
|
|
31
|
+
|
|
32
|
+
NO_PARAMS = [].freeze
|
|
33
|
+
private_constant :NO_PARAMS
|
|
34
|
+
|
|
29
35
|
# Raised when a string cannot be parsed or resolved to a valid URL.
|
|
30
|
-
class InvalidUrlError < Html2rss::Error
|
|
36
|
+
class InvalidUrlError < Html2rss::Error
|
|
37
|
+
# @return [Symbol]
|
|
38
|
+
def kind = :invalid_url
|
|
39
|
+
end
|
|
31
40
|
|
|
32
41
|
# Regular expression for basic URI format validation
|
|
33
42
|
URI_REGEXP = Addressable::URI::URIREGEX
|
|
34
43
|
# Schemes accepted by channel URL validation.
|
|
35
44
|
SUPPORTED_SCHEMES = %w[http https].to_set.freeze
|
|
45
|
+
SCHEME_PREFIX = /\A([a-z][a-z0-9+.-]*):/i
|
|
46
|
+
private_constant :SCHEME_PREFIX
|
|
36
47
|
|
|
37
48
|
##
|
|
38
49
|
# Creates a URL from a relative path and base URL.
|
|
39
50
|
#
|
|
40
51
|
# @param relative_url [String, Html2rss::Url] the relative URL to resolve
|
|
41
52
|
# @param base_url [String, Html2rss::Url] the base URL to resolve against
|
|
42
|
-
# @return [Url] the resolved absolute URL
|
|
53
|
+
# @return [Url] the resolved, normalized absolute URL
|
|
43
54
|
# @raise [InvalidUrlError] if the URL cannot be parsed
|
|
44
55
|
def self.from_relative(relative_url, base_url)
|
|
45
|
-
url =
|
|
46
|
-
return new(url) if url.absolute?
|
|
56
|
+
url = parse(relative_url.to_s.strip, base_url.to_s[SCHEME_PREFIX, 1])
|
|
57
|
+
return new(url.normalize) if url.absolute?
|
|
47
58
|
|
|
48
59
|
join_parsed_base(base_url, url)
|
|
49
60
|
rescue Addressable::URI::InvalidURIError
|
|
@@ -59,6 +70,30 @@ module Html2rss
|
|
|
59
70
|
end
|
|
60
71
|
private_class_method :join_parsed_base
|
|
61
72
|
|
|
73
|
+
##
|
|
74
|
+
# The one parse entry. Like browsers (WHATWG URL), a backslash before the query or fragment of an
|
|
75
|
+
# http(s) URL is a path separator: +files\a.pdf+ is +files/a.pdf+ and +https:\\example.com\x+ is
|
|
76
|
+
# +https://example.com/x+. A backslash ends the authority, so +https://example.com\@host.example/+
|
|
77
|
+
# keeps +example.com+ as host. Query and fragment, and non-http(s) URLs, are left untouched.
|
|
78
|
+
#
|
|
79
|
+
# @param text [String] raw URL or reference
|
|
80
|
+
# @param base_scheme [String, nil] scheme a scheme-less +text+ resolves against
|
|
81
|
+
# @return [Addressable::URI]
|
|
82
|
+
# @raise [Addressable::URI::InvalidURIError]
|
|
83
|
+
def self.parse(text, base_scheme = nil)
|
|
84
|
+
return Addressable::URI.parse(text) unless text.include?('\\') && web_scheme?(text, base_scheme)
|
|
85
|
+
|
|
86
|
+
head, separator, tail = text.partition(/[?#]/)
|
|
87
|
+
Addressable::URI.parse("#{head.tr('\\', '/')}#{separator}#{tail}")
|
|
88
|
+
end
|
|
89
|
+
private_class_method :parse
|
|
90
|
+
|
|
91
|
+
# @return [Boolean] whether +text+, or the +base_scheme+ a scheme-less +text+ resolves against, is http(s)
|
|
92
|
+
def self.web_scheme?(text, base_scheme)
|
|
93
|
+
SUPPORTED_SCHEMES.include?((text[SCHEME_PREFIX, 1] || base_scheme)&.downcase)
|
|
94
|
+
end
|
|
95
|
+
private_class_method :web_scheme?
|
|
96
|
+
|
|
62
97
|
##
|
|
63
98
|
# Creates a URL by sanitizing a raw URL string.
|
|
64
99
|
# Removes spaces and extracts the first valid URL from the string.
|
|
@@ -66,10 +101,10 @@ module Html2rss
|
|
|
66
101
|
# @param raw_url [String] the raw URL string to sanitize
|
|
67
102
|
# @return [Url, nil] the sanitized URL, or nil if no valid URL found
|
|
68
103
|
def self.sanitize(raw_url)
|
|
69
|
-
match = raw_url.to_s.match(%r{(?:
|
|
104
|
+
match = raw_url.to_s.match(%r{(?:https?:[/\\]{2}|ftp://|mailto:)[^\s<>"]+})
|
|
70
105
|
return unless match
|
|
71
106
|
|
|
72
|
-
new(
|
|
107
|
+
new(parse(match[0]).normalize)
|
|
73
108
|
end
|
|
74
109
|
|
|
75
110
|
##
|
|
@@ -81,7 +116,7 @@ module Html2rss
|
|
|
81
116
|
def self.from_absolute(url_string)
|
|
82
117
|
return url_string if url_string.is_a?(self)
|
|
83
118
|
|
|
84
|
-
new(
|
|
119
|
+
new(parse(url_string.to_s.lstrip).normalize).tap do |url|
|
|
85
120
|
raise InvalidUrlError, 'URL must be absolute' unless url.absolute?
|
|
86
121
|
end
|
|
87
122
|
rescue Addressable::URI::InvalidURIError
|
|
@@ -118,6 +153,23 @@ module Html2rss
|
|
|
118
153
|
from_absolute(url_string).tap { validate_scheme(_1) }
|
|
119
154
|
end
|
|
120
155
|
|
|
156
|
+
##
|
|
157
|
+
# The one resolver for a declared web reference (a media file, an item link, a player +src+, an
|
|
158
|
+
# advertised feed): +ref+ resolved against +base_url+ when relative, kept only when http(s) ({#web?}).
|
|
159
|
+
# A +javascript:+, +data:+, +ftp:+ or +mailto:+ reference, or a relative one without a base, names none.
|
|
160
|
+
#
|
|
161
|
+
# @param ref [String, Html2rss::Url, nil] an absolute or relative URL, possibly padded with whitespace
|
|
162
|
+
# @param base_url [String, Html2rss::Url, nil] the document the reference appears in
|
|
163
|
+
# @return [Url, nil] the absolute http(s) URL, or nil when +ref+ is blank, unparseable or not http(s)
|
|
164
|
+
def self.resolve_web(ref, base_url = nil)
|
|
165
|
+
return unless ref.to_s.match?(/\S/)
|
|
166
|
+
|
|
167
|
+
url = from_relative(ref, base_url)
|
|
168
|
+
url if url.web?
|
|
169
|
+
rescue InvalidUrlError
|
|
170
|
+
nil
|
|
171
|
+
end
|
|
172
|
+
|
|
121
173
|
##
|
|
122
174
|
# Extracts a base URL from HTML metadata tags.
|
|
123
175
|
#
|
|
@@ -150,16 +202,18 @@ module Html2rss
|
|
|
150
202
|
end
|
|
151
203
|
|
|
152
204
|
def self.validate_scheme(url)
|
|
153
|
-
scheme
|
|
154
|
-
raise InvalidUrlError, "URL scheme '#{scheme}' is not supported" unless SUPPORTED_SCHEMES.include?(scheme)
|
|
205
|
+
raise InvalidUrlError, "URL scheme '#{url.scheme}' is not supported" unless url.web?
|
|
155
206
|
end
|
|
156
207
|
|
|
157
208
|
private_class_method :validate_channel_url, :validate_scheme
|
|
158
209
|
|
|
159
210
|
##
|
|
160
|
-
#
|
|
211
|
+
# The wrapped URI stays unfrozen and private: freezing makes Addressable recompute every
|
|
212
|
+
# normalized part that +normalize+ just produced. Mutating operations dup first.
|
|
213
|
+
#
|
|
214
|
+
# @param uri [Addressable::URI] the normalized Addressable::URI (internal use only)
|
|
161
215
|
def initialize(uri)
|
|
162
|
-
@uri = uri
|
|
216
|
+
@uri = uri
|
|
163
217
|
@path_segments = path.split('/').reject(&:empty?).freeze
|
|
164
218
|
freeze
|
|
165
219
|
end
|
|
@@ -176,8 +230,8 @@ module Html2rss
|
|
|
176
230
|
# @return [String, nil] userinfo user, without the password
|
|
177
231
|
def user = @uri.user
|
|
178
232
|
|
|
179
|
-
# Registrable domain (eTLD+1). Same-site hosts like www.
|
|
180
|
-
# share `
|
|
233
|
+
# Registrable domain (eTLD+1). Same-site hosts like www.example.com and news.example.com
|
|
234
|
+
# share `example.com`. IP literals stay the host so 1.2.3.4 is not treated as `3.4`.
|
|
181
235
|
#
|
|
182
236
|
# @return [String, nil]
|
|
183
237
|
def domain
|
|
@@ -201,6 +255,10 @@ module Html2rss
|
|
|
201
255
|
# @return [Boolean] whether the URL includes scheme and host
|
|
202
256
|
def absolute? = @uri.absolute?
|
|
203
257
|
|
|
258
|
+
# @return [Boolean] whether the URL is an absolute http(s) URL ({SUPPORTED_SCHEMES}), the only kind a feed
|
|
259
|
+
# ships as a link, enclosure or player source
|
|
260
|
+
def web? = SUPPORTED_SCHEMES.include?(scheme)
|
|
261
|
+
|
|
204
262
|
##
|
|
205
263
|
# Returns the URL query string as a hash of string keys and values.
|
|
206
264
|
#
|
|
@@ -232,6 +290,30 @@ module Html2rss
|
|
|
232
290
|
fragment ? self.class.from_absolute(@uri.omit(:fragment)) : self
|
|
233
291
|
end
|
|
234
292
|
|
|
293
|
+
##
|
|
294
|
+
# Returns a copy of the URL with {TRACKING_PARAM} query params removed; the remaining
|
|
295
|
+
# params keep their order.
|
|
296
|
+
#
|
|
297
|
+
# @return [Url] this URL when it carries no tracking param; otherwise a new URL
|
|
298
|
+
def without_tracking_params
|
|
299
|
+
return self if query_params.none? { tracking_param?(_1) }
|
|
300
|
+
|
|
301
|
+
uri = @uri.dup
|
|
302
|
+
uri.query = query_params.reject { tracking_param?(_1) }.join('&')
|
|
303
|
+
self.class.from_absolute(uri)
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
##
|
|
307
|
+
# Returns a copy of the URL with the provided scheme.
|
|
308
|
+
#
|
|
309
|
+
# @param scheme [String] for example +https+
|
|
310
|
+
# @return [Url] a new URL with the updated scheme
|
|
311
|
+
def with_scheme(scheme)
|
|
312
|
+
uri = @uri.dup
|
|
313
|
+
uri.scheme = scheme
|
|
314
|
+
self.class.from_absolute(uri)
|
|
315
|
+
end
|
|
316
|
+
|
|
235
317
|
##
|
|
236
318
|
# Returns a copy of the URL with the provided query values.
|
|
237
319
|
#
|
|
@@ -328,9 +410,20 @@ module Html2rss
|
|
|
328
410
|
self.class.new(@uri.join(relative_uri).normalize)
|
|
329
411
|
end
|
|
330
412
|
|
|
413
|
+
##
|
|
414
|
+
# "Same article" key: host, port, slash-stripped path, and the sorted query params minus
|
|
415
|
+
# {TRACKING_PARAM}. Scheme, fragment, trailing slash, tracking params, and param order are
|
|
416
|
+
# ignored, so +http://example.com/a+ and +https://example.com/a/?utm_source=x#top+ share it;
|
|
417
|
+
# every other query param stays significant. Keys page placement, self-link rejection, and
|
|
418
|
+
# feed-item dedupe, unlike {#same_document?}, which stays scheme- and query-exact.
|
|
419
|
+
#
|
|
420
|
+
# @return [Array] equal for URLs naming the same article
|
|
421
|
+
def article_identity = [host, port, path.chomp('/'), article_params]
|
|
422
|
+
|
|
331
423
|
protected
|
|
332
424
|
|
|
333
|
-
#
|
|
425
|
+
# Same-document key: scheme, authority, slash-stripped path, and query (fragment omitted).
|
|
426
|
+
# Two URLs share it exactly when {#same_document?} holds.
|
|
334
427
|
#
|
|
335
428
|
# @return [String]
|
|
336
429
|
def document_identity
|
|
@@ -339,6 +432,14 @@ module Html2rss
|
|
|
339
432
|
|
|
340
433
|
private
|
|
341
434
|
|
|
435
|
+
# @return [Array<String>] raw +key=value+ query pairs, in source order
|
|
436
|
+
def query_params = query ? query.split('&') : NO_PARAMS
|
|
437
|
+
|
|
438
|
+
# @return [Array<String>] sorted non-tracking query pairs; the shared empty list without a query
|
|
439
|
+
def article_params = query ? query_params.reject { tracking_param?(_1) }.sort : NO_PARAMS
|
|
440
|
+
|
|
441
|
+
def tracking_param?(pair) = TRACKING_PARAM.match?(pair.partition('=').first)
|
|
442
|
+
|
|
342
443
|
def ip_address_host?
|
|
343
444
|
!IPAddr.new(host).nil?
|
|
344
445
|
rescue IPAddr::Error
|
data/lib/html2rss/version.rb
CHANGED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Html2rss
|
|
4
|
+
##
|
|
5
|
+
# Counts text in two facts. {.count} is the one definition of a word for the length rules on text (title
|
|
6
|
+
# floor, short anchor labels, description richness): a run of letters and digits, where Han, Hiragana
|
|
7
|
+
# and Katakana text, which carries no spaces, counts one word per {CJK_CHUNK} characters
|
|
8
|
+
# (+アクメ 会員の登録情報…+ is several words, not one). {.runs} counts raw letter/digit runs, an
|
|
9
|
+
# unspaced CJK run as one: structural scoring ({Scoring::ContainerAssessor}, {SST::Node#run_count}) is
|
|
10
|
+
# calibrated on it, and counting CJK chunks there moves which container wins.
|
|
11
|
+
module WordCount
|
|
12
|
+
# A run of letters and digits holding at least one Han, Hiragana or Katakana character.
|
|
13
|
+
CJK = /[\p{Han}\p{Hiragana}\p{Katakana}]/
|
|
14
|
+
# Characters of unspaced CJK text that make up one word.
|
|
15
|
+
CJK_CHUNK = 6
|
|
16
|
+
# A run of letters and digits.
|
|
17
|
+
RUN = /\p{Alnum}+/
|
|
18
|
+
|
|
19
|
+
class << self
|
|
20
|
+
##
|
|
21
|
+
# @param text [String, nil]
|
|
22
|
+
# @param ceiling [Integer, nil] stops counting once this many words are seen
|
|
23
|
+
# @return [Integer] words in +text+, or at least +ceiling+ when counting stopped there
|
|
24
|
+
def count(text, ceiling: nil)
|
|
25
|
+
total = 0
|
|
26
|
+
text.to_s.scan(RUN) do |run|
|
|
27
|
+
total += run.match?(CJK) ? run.length.ceildiv(CJK_CHUNK) : 1
|
|
28
|
+
return total if ceiling && total >= ceiling
|
|
29
|
+
end
|
|
30
|
+
total
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
##
|
|
34
|
+
# @param text [String, nil]
|
|
35
|
+
# @param limit [Integer]
|
|
36
|
+
# @return [Boolean] whether +text+ holds at least +limit+ words
|
|
37
|
+
def at_least?(text, limit) = count(text, ceiling: limit) >= limit
|
|
38
|
+
|
|
39
|
+
##
|
|
40
|
+
# @param text [String, nil]
|
|
41
|
+
# @param ceiling [Integer, nil] stops counting once this many runs are seen
|
|
42
|
+
# @return [Integer] letter/digit runs in +text+, or +ceiling+ when counting stopped there
|
|
43
|
+
def runs(text, ceiling: nil)
|
|
44
|
+
total = 0
|
|
45
|
+
text.to_s.scan(RUN) do
|
|
46
|
+
total += 1
|
|
47
|
+
return total if total == ceiling
|
|
48
|
+
end
|
|
49
|
+
total
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
data/lib/html2rss.rb
CHANGED
|
@@ -106,11 +106,14 @@ module Html2rss # rubocop:disable Metrics/ModuleLength
|
|
|
106
106
|
# @param strategy [Symbol, nil] optional strategy override
|
|
107
107
|
# @param strict_quality [Boolean] when true, fail on ship-quality audit thresholds
|
|
108
108
|
# @param compare_enhance [Boolean] diagnostic enhance off vs on comparison on cached HTML
|
|
109
|
+
# @param sample_limit [Integer] maximum number of +Result#items+, >= 0 (default: 3)
|
|
109
110
|
# @return [Html2rss::Test::Result]
|
|
110
111
|
def self.test(config_input, feed_name = nil, min_items: 1, params: {}, strategy: nil, strict_quality: false, # rubocop:disable Metrics/ParameterLists
|
|
111
|
-
compare_enhance: false)
|
|
112
|
+
compare_enhance: false, sample_limit: 3)
|
|
112
113
|
Option.integer(:min_items, min_items, min: 0)
|
|
113
|
-
|
|
114
|
+
Option.integer(:sample_limit, sample_limit, min: 0)
|
|
115
|
+
Test.call(config_input, feed_name, min_items:, params:, strategy:, strict_quality:, compare_enhance:,
|
|
116
|
+
sample_limit:)
|
|
114
117
|
end
|
|
115
118
|
|
|
116
119
|
##
|
|
@@ -136,6 +139,7 @@ module Html2rss # rubocop:disable Metrics/ModuleLength
|
|
|
136
139
|
# @param local_file_path [String, nil] optional local HTML file path
|
|
137
140
|
# @param limit [Integer, nil] max articles to keep (default: {AutoSource::DEFAULT_LIMIT})
|
|
138
141
|
# @param keep_different_domain [Boolean, nil] when set, overrides cleanup.keep_different_domain
|
|
142
|
+
# @param keep_sponsored [Boolean, nil] when set, overrides cleanup.keep_sponsored
|
|
139
143
|
# @return [Html2rss::FeedResult]
|
|
140
144
|
def self.scrape(url,
|
|
141
145
|
strategy: :auto,
|
|
@@ -144,9 +148,10 @@ module Html2rss # rubocop:disable Metrics/ModuleLength
|
|
|
144
148
|
max_requests: 4,
|
|
145
149
|
local_file_path: nil,
|
|
146
150
|
limit: nil,
|
|
147
|
-
keep_different_domain: nil
|
|
151
|
+
keep_different_domain: nil,
|
|
152
|
+
keep_sponsored: nil)
|
|
148
153
|
auto_feed_result(url, strategy:, items_selector:, max_redirects:, max_requests:,
|
|
149
|
-
local_file_path:, limit:, keep_different_domain:)
|
|
154
|
+
local_file_path:, limit:, keep_different_domain:, keep_sponsored:)
|
|
150
155
|
end
|
|
151
156
|
|
|
152
157
|
##
|
|
@@ -250,6 +255,7 @@ module Html2rss # rubocop:disable Metrics/ModuleLength
|
|
|
250
255
|
# @param local_file_path [String, nil] optional local HTML file path
|
|
251
256
|
# @param limit [Integer, nil] max articles to keep (default: {AutoSource::DEFAULT_LIMIT})
|
|
252
257
|
# @param keep_different_domain [Boolean, nil] when set, overrides cleanup.keep_different_domain
|
|
258
|
+
# @param keep_sponsored [Boolean, nil] when set, overrides cleanup.keep_sponsored
|
|
253
259
|
# @return [Html2rss::FeedResult]
|
|
254
260
|
def self.auto_feed_result(url,
|
|
255
261
|
strategy: :auto,
|
|
@@ -258,9 +264,10 @@ module Html2rss # rubocop:disable Metrics/ModuleLength
|
|
|
258
264
|
max_requests: 4,
|
|
259
265
|
local_file_path: nil,
|
|
260
266
|
limit: nil,
|
|
261
|
-
keep_different_domain: nil
|
|
267
|
+
keep_different_domain: nil,
|
|
268
|
+
keep_sponsored: nil)
|
|
262
269
|
feed_result(build_auto_source_config(url:, strategy:, items_selector:, max_redirects:, max_requests:,
|
|
263
|
-
local_file_path:, limit:, keep_different_domain:))
|
|
270
|
+
local_file_path:, limit:, keep_different_domain:, keep_sponsored:))
|
|
264
271
|
end
|
|
265
272
|
|
|
266
273
|
##
|
|
@@ -360,13 +367,15 @@ module Html2rss # rubocop:disable Metrics/ModuleLength
|
|
|
360
367
|
|
|
361
368
|
# rubocop:disable-next Metrics/ParameterLists, Metrics/MethodLength
|
|
362
369
|
def build_auto_source_config(url:, strategy:, items_selector:, max_redirects:, max_requests:,
|
|
363
|
-
local_file_path: nil, limit: nil, keep_different_domain: nil
|
|
370
|
+
local_file_path: nil, limit: nil, keep_different_domain: nil,
|
|
371
|
+
keep_sponsored: nil)
|
|
364
372
|
config = Config.auto_source_config(
|
|
365
373
|
url:,
|
|
366
374
|
items_selector:,
|
|
367
375
|
request_controls: Config::RequestControls.from_shortcut(strategy:, max_redirects:, max_requests:),
|
|
368
376
|
limit:,
|
|
369
|
-
keep_different_domain
|
|
377
|
+
keep_different_domain:,
|
|
378
|
+
keep_sponsored:
|
|
370
379
|
)
|
|
371
380
|
if local_file_path
|
|
372
381
|
config[:request] ||= {}
|
|
@@ -13,7 +13,8 @@
|
|
|
13
13
|
"properties": {
|
|
14
14
|
"url": {
|
|
15
15
|
"type": "string",
|
|
16
|
-
"minLength": 1
|
|
16
|
+
"minLength": 1,
|
|
17
|
+
"pattern": "^(([^:\\/?#]+):)?(\\/\\/([^\\/?#]*))?([^?#]*)(\\?([^#]*))?(#(.*))?$"
|
|
17
18
|
},
|
|
18
19
|
"title": {
|
|
19
20
|
"type": [
|
|
@@ -31,7 +32,8 @@
|
|
|
31
32
|
"type": [
|
|
32
33
|
"null",
|
|
33
34
|
"string"
|
|
34
|
-
]
|
|
35
|
+
],
|
|
36
|
+
"pattern": "\\A[a-z]{2}(-[A-Z]{2})?\\z"
|
|
35
37
|
},
|
|
36
38
|
"ttl": {
|
|
37
39
|
"type": [
|
|
@@ -56,7 +58,8 @@
|
|
|
56
58
|
"type": [
|
|
57
59
|
"null",
|
|
58
60
|
"string"
|
|
59
|
-
]
|
|
61
|
+
],
|
|
62
|
+
"pattern": "^(([^:\\/?#]+):)?(\\/\\/([^\\/?#]*))?([^?#]*)(\\?([^#]*))?(#(.*))?$"
|
|
60
63
|
}
|
|
61
64
|
},
|
|
62
65
|
"required": [
|
|
@@ -79,8 +82,8 @@
|
|
|
79
82
|
},
|
|
80
83
|
"topics": {
|
|
81
84
|
"type": "array",
|
|
85
|
+
"minItems": 1,
|
|
82
86
|
"items": {
|
|
83
|
-
"minLength": 1,
|
|
84
87
|
"type": "string",
|
|
85
88
|
"enum": [
|
|
86
89
|
"sports",
|
|
@@ -104,8 +107,7 @@
|
|
|
104
107
|
"rights",
|
|
105
108
|
"transport"
|
|
106
109
|
]
|
|
107
|
-
}
|
|
108
|
-
"minItems": 1
|
|
110
|
+
}
|
|
109
111
|
}
|
|
110
112
|
},
|
|
111
113
|
"required": [
|
|
@@ -194,7 +196,8 @@
|
|
|
194
196
|
}
|
|
195
197
|
}
|
|
196
198
|
},
|
|
197
|
-
"required": []
|
|
199
|
+
"required": [],
|
|
200
|
+
"additionalProperties": false
|
|
198
201
|
},
|
|
199
202
|
"wordpress_api": {
|
|
200
203
|
"type": "object",
|
|
@@ -206,7 +209,8 @@
|
|
|
206
209
|
}
|
|
207
210
|
}
|
|
208
211
|
},
|
|
209
|
-
"required": []
|
|
212
|
+
"required": [],
|
|
213
|
+
"additionalProperties": false
|
|
210
214
|
},
|
|
211
215
|
"sitemap": {
|
|
212
216
|
"type": "object",
|
|
@@ -218,7 +222,14 @@
|
|
|
218
222
|
}
|
|
219
223
|
},
|
|
220
224
|
"min_priority": {
|
|
221
|
-
"
|
|
225
|
+
"anyOf": [
|
|
226
|
+
{
|
|
227
|
+
"type": "integer"
|
|
228
|
+
},
|
|
229
|
+
{
|
|
230
|
+
"type": "number"
|
|
231
|
+
}
|
|
232
|
+
],
|
|
222
233
|
"not": {
|
|
223
234
|
"type": "null"
|
|
224
235
|
}
|
|
@@ -231,7 +242,8 @@
|
|
|
231
242
|
"exclusiveMinimum": 0
|
|
232
243
|
}
|
|
233
244
|
},
|
|
234
|
-
"required": []
|
|
245
|
+
"required": [],
|
|
246
|
+
"additionalProperties": false
|
|
235
247
|
},
|
|
236
248
|
"schema": {
|
|
237
249
|
"type": "object",
|
|
@@ -243,7 +255,8 @@
|
|
|
243
255
|
}
|
|
244
256
|
}
|
|
245
257
|
},
|
|
246
|
-
"required": []
|
|
258
|
+
"required": [],
|
|
259
|
+
"additionalProperties": false
|
|
247
260
|
},
|
|
248
261
|
"microdata": {
|
|
249
262
|
"type": "object",
|
|
@@ -255,7 +268,8 @@
|
|
|
255
268
|
}
|
|
256
269
|
}
|
|
257
270
|
},
|
|
258
|
-
"required": []
|
|
271
|
+
"required": [],
|
|
272
|
+
"additionalProperties": false
|
|
259
273
|
},
|
|
260
274
|
"microformats2": {
|
|
261
275
|
"type": "object",
|
|
@@ -267,7 +281,8 @@
|
|
|
267
281
|
}
|
|
268
282
|
}
|
|
269
283
|
},
|
|
270
|
-
"required": []
|
|
284
|
+
"required": [],
|
|
285
|
+
"additionalProperties": false
|
|
271
286
|
},
|
|
272
287
|
"json_state": {
|
|
273
288
|
"type": "object",
|
|
@@ -279,7 +294,8 @@
|
|
|
279
294
|
}
|
|
280
295
|
}
|
|
281
296
|
},
|
|
282
|
-
"required": []
|
|
297
|
+
"required": [],
|
|
298
|
+
"additionalProperties": false
|
|
283
299
|
},
|
|
284
300
|
"json_document": {
|
|
285
301
|
"type": "object",
|
|
@@ -291,7 +307,8 @@
|
|
|
291
307
|
}
|
|
292
308
|
}
|
|
293
309
|
},
|
|
294
|
-
"required": []
|
|
310
|
+
"required": [],
|
|
311
|
+
"additionalProperties": false
|
|
295
312
|
},
|
|
296
313
|
"xhr_articles": {
|
|
297
314
|
"type": "object",
|
|
@@ -303,7 +320,8 @@
|
|
|
303
320
|
}
|
|
304
321
|
}
|
|
305
322
|
},
|
|
306
|
-
"required": []
|
|
323
|
+
"required": [],
|
|
324
|
+
"additionalProperties": false
|
|
307
325
|
},
|
|
308
326
|
"meta_oembed": {
|
|
309
327
|
"type": "object",
|
|
@@ -315,7 +333,8 @@
|
|
|
315
333
|
}
|
|
316
334
|
}
|
|
317
335
|
},
|
|
318
|
-
"required": []
|
|
336
|
+
"required": [],
|
|
337
|
+
"additionalProperties": false
|
|
319
338
|
},
|
|
320
339
|
"semantic_html": {
|
|
321
340
|
"type": "object",
|
|
@@ -325,15 +344,10 @@
|
|
|
325
344
|
"not": {
|
|
326
345
|
"type": "null"
|
|
327
346
|
}
|
|
328
|
-
},
|
|
329
|
-
"fallback_anchorless": {
|
|
330
|
-
"type": "boolean",
|
|
331
|
-
"not": {
|
|
332
|
-
"type": "null"
|
|
333
|
-
}
|
|
334
347
|
}
|
|
335
348
|
},
|
|
336
|
-
"required": []
|
|
349
|
+
"required": [],
|
|
350
|
+
"additionalProperties": false
|
|
337
351
|
},
|
|
338
352
|
"html": {
|
|
339
353
|
"type": "object",
|
|
@@ -357,18 +371,14 @@
|
|
|
357
371
|
"type": "null"
|
|
358
372
|
},
|
|
359
373
|
"exclusiveMinimum": 0
|
|
360
|
-
},
|
|
361
|
-
"fallback_anchorless": {
|
|
362
|
-
"type": "boolean",
|
|
363
|
-
"not": {
|
|
364
|
-
"type": "null"
|
|
365
|
-
}
|
|
366
374
|
}
|
|
367
375
|
},
|
|
368
|
-
"required": []
|
|
376
|
+
"required": [],
|
|
377
|
+
"additionalProperties": false
|
|
369
378
|
}
|
|
370
379
|
},
|
|
371
|
-
"required": []
|
|
380
|
+
"required": [],
|
|
381
|
+
"additionalProperties": false
|
|
372
382
|
},
|
|
373
383
|
"cleanup": {
|
|
374
384
|
"type": "object",
|
|
@@ -378,6 +388,12 @@
|
|
|
378
388
|
"not": {
|
|
379
389
|
"type": "null"
|
|
380
390
|
}
|
|
391
|
+
},
|
|
392
|
+
"keep_sponsored": {
|
|
393
|
+
"type": "boolean",
|
|
394
|
+
"not": {
|
|
395
|
+
"type": "null"
|
|
396
|
+
}
|
|
381
397
|
}
|
|
382
398
|
},
|
|
383
399
|
"required": []
|
|
@@ -424,18 +440,17 @@
|
|
|
424
440
|
"enabled": true
|
|
425
441
|
},
|
|
426
442
|
"semantic_html": {
|
|
427
|
-
"enabled": true
|
|
428
|
-
"fallback_anchorless": true
|
|
443
|
+
"enabled": true
|
|
429
444
|
},
|
|
430
445
|
"html": {
|
|
431
446
|
"enabled": true,
|
|
432
447
|
"minimum_selector_frequency": 2,
|
|
433
|
-
"use_top_selectors": 5
|
|
434
|
-
"fallback_anchorless": true
|
|
448
|
+
"use_top_selectors": 5
|
|
435
449
|
}
|
|
436
450
|
},
|
|
437
451
|
"cleanup": {
|
|
438
|
-
"keep_different_domain": false
|
|
452
|
+
"keep_different_domain": false,
|
|
453
|
+
"keep_sponsored": false
|
|
439
454
|
}
|
|
440
455
|
}
|
|
441
456
|
},
|
|
@@ -465,7 +480,7 @@
|
|
|
465
480
|
"not": {
|
|
466
481
|
"type": "null"
|
|
467
482
|
},
|
|
468
|
-
"description": "
|
|
483
|
+
"description": "Fills missing title, link, description, date, image and categories from each item's own markup via Html::ArticleExtractor. Explicit selectors win; no extra requests are made."
|
|
469
484
|
},
|
|
470
485
|
"pagination": {
|
|
471
486
|
"description": "Pagination configuration or maximum page count integer.",
|
|
@@ -589,7 +604,8 @@
|
|
|
589
604
|
},
|
|
590
605
|
"content_type": {
|
|
591
606
|
"type": "string",
|
|
592
|
-
"minLength": 1
|
|
607
|
+
"minLength": 1,
|
|
608
|
+
"pattern": "^[\\w-]+\\/[\\w-]+$"
|
|
593
609
|
}
|
|
594
610
|
},
|
|
595
611
|
"required": [],
|
|
@@ -726,8 +742,7 @@
|
|
|
726
742
|
"auto",
|
|
727
743
|
"get",
|
|
728
744
|
"google_get",
|
|
729
|
-
"google_get_bypass"
|
|
730
|
-
"organic_get"
|
|
745
|
+
"google_get_bypass"
|
|
731
746
|
]
|
|
732
747
|
},
|
|
733
748
|
"max_retries": {
|