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
data/lib/html2rss/url.rb
CHANGED
|
@@ -26,37 +26,74 @@ 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
|
+
|
|
35
|
+
# Raised when a string cannot be parsed or resolved to a valid URL.
|
|
36
|
+
class InvalidUrlError < Html2rss::Error
|
|
37
|
+
# @return [Symbol]
|
|
38
|
+
def kind = :invalid_url
|
|
39
|
+
end
|
|
40
|
+
|
|
29
41
|
# Regular expression for basic URI format validation
|
|
30
42
|
URI_REGEXP = Addressable::URI::URIREGEX
|
|
31
43
|
# Schemes accepted by channel URL validation.
|
|
32
44
|
SUPPORTED_SCHEMES = %w[http https].to_set.freeze
|
|
45
|
+
SCHEME_PREFIX = /\A([a-z][a-z0-9+.-]*):/i
|
|
46
|
+
private_constant :SCHEME_PREFIX
|
|
33
47
|
|
|
34
48
|
##
|
|
35
49
|
# Creates a URL from a relative path and base URL.
|
|
36
50
|
#
|
|
37
51
|
# @param relative_url [String, Html2rss::Url] the relative URL to resolve
|
|
38
52
|
# @param base_url [String, Html2rss::Url] the base URL to resolve against
|
|
39
|
-
# @return [Url] the resolved absolute URL
|
|
40
|
-
# @raise [
|
|
53
|
+
# @return [Url] the resolved, normalized absolute URL
|
|
54
|
+
# @raise [InvalidUrlError] if the URL cannot be parsed
|
|
41
55
|
def self.from_relative(relative_url, base_url)
|
|
42
|
-
url =
|
|
43
|
-
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?
|
|
44
58
|
|
|
45
|
-
|
|
59
|
+
join_parsed_base(base_url, url)
|
|
46
60
|
rescue Addressable::URI::InvalidURIError
|
|
47
|
-
raise
|
|
61
|
+
raise InvalidUrlError, 'URL could not be parsed'
|
|
48
62
|
end
|
|
49
63
|
|
|
50
|
-
# @param base_url [String]
|
|
64
|
+
# @param base_url [String, Url]
|
|
51
65
|
# @param url [Addressable::URI]
|
|
52
66
|
# @return [Url]
|
|
53
67
|
def self.join_parsed_base(base_url, url)
|
|
54
68
|
uri = Addressable::URI.parse(base_url.to_s)
|
|
55
|
-
uri.path = '/' if uri.path.empty?
|
|
56
69
|
new(uri.join(url).normalize)
|
|
57
70
|
end
|
|
58
71
|
private_class_method :join_parsed_base
|
|
59
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
|
+
|
|
60
97
|
##
|
|
61
98
|
# Creates a URL by sanitizing a raw URL string.
|
|
62
99
|
# Removes spaces and extracts the first valid URL from the string.
|
|
@@ -64,10 +101,10 @@ module Html2rss
|
|
|
64
101
|
# @param raw_url [String] the raw URL string to sanitize
|
|
65
102
|
# @return [Url, nil] the sanitized URL, or nil if no valid URL found
|
|
66
103
|
def self.sanitize(raw_url)
|
|
67
|
-
match = raw_url.to_s.match(%r{(?:
|
|
104
|
+
match = raw_url.to_s.match(%r{(?:https?:[/\\]{2}|ftp://|mailto:)[^\s<>"]+})
|
|
68
105
|
return unless match
|
|
69
106
|
|
|
70
|
-
new(
|
|
107
|
+
new(parse(match[0]).normalize)
|
|
71
108
|
end
|
|
72
109
|
|
|
73
110
|
##
|
|
@@ -75,15 +112,15 @@ module Html2rss
|
|
|
75
112
|
#
|
|
76
113
|
# @param url_string [String, Html2rss::Url] the absolute URL to parse
|
|
77
114
|
# @return [Url] the parsed and normalized URL
|
|
78
|
-
# @raise [
|
|
115
|
+
# @raise [InvalidUrlError] if the URL is not absolute or cannot be parsed
|
|
79
116
|
def self.from_absolute(url_string)
|
|
80
117
|
return url_string if url_string.is_a?(self)
|
|
81
118
|
|
|
82
|
-
new(
|
|
83
|
-
raise
|
|
119
|
+
new(parse(url_string.to_s.lstrip).normalize).tap do |url|
|
|
120
|
+
raise InvalidUrlError, 'URL must be absolute' unless url.absolute?
|
|
84
121
|
end
|
|
85
122
|
rescue Addressable::URI::InvalidURIError
|
|
86
|
-
raise
|
|
123
|
+
raise InvalidUrlError, 'URL must be absolute'
|
|
87
124
|
end
|
|
88
125
|
|
|
89
126
|
##
|
|
@@ -92,20 +129,47 @@ module Html2rss
|
|
|
92
129
|
#
|
|
93
130
|
# @param url_string [String] the URL string to validate and parse
|
|
94
131
|
# @return [Url] the validated and parsed URL
|
|
95
|
-
# @raise [
|
|
132
|
+
# @raise [InvalidUrlError] if the URL doesn't meet channel requirements
|
|
96
133
|
# @example Creating a channel URL
|
|
97
134
|
# Url.for_channel('https://example.com')
|
|
98
135
|
# # => #<Html2rss::Url:... @uri=#<Addressable::URI:... URI:https://example.com>>
|
|
99
136
|
# @example Invalid channel URL
|
|
100
137
|
# Url.for_channel('/relative/path')
|
|
101
|
-
# # => raises
|
|
138
|
+
# # => raises InvalidUrlError: "URL must be absolute"
|
|
102
139
|
def self.for_channel(url_string)
|
|
103
|
-
stripped = url_string.to_s.
|
|
140
|
+
stripped = url_string.to_s.lstrip
|
|
104
141
|
return nil if stripped.empty?
|
|
105
142
|
|
|
106
143
|
from_absolute(stripped).tap { validate_channel_url(_1) }
|
|
107
144
|
end
|
|
108
145
|
|
|
146
|
+
##
|
|
147
|
+
# Creates an absolute http(s) URL, for example for a channel image.
|
|
148
|
+
#
|
|
149
|
+
# @param url_string [String, Html2rss::Url] the URL to validate and parse
|
|
150
|
+
# @return [Url] the validated and parsed URL
|
|
151
|
+
# @raise [InvalidUrlError] if the URL is not absolute or not http(s)
|
|
152
|
+
def self.from_absolute_web(url_string)
|
|
153
|
+
from_absolute(url_string).tap { validate_scheme(_1) }
|
|
154
|
+
end
|
|
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
|
+
|
|
109
173
|
##
|
|
110
174
|
# Extracts a base URL from HTML metadata tags.
|
|
111
175
|
#
|
|
@@ -116,10 +180,10 @@ module Html2rss
|
|
|
116
180
|
tags = { 'link[rel="canonical"]' => 'href', 'meta[property="og:url"]' => 'content',
|
|
117
181
|
'meta[name="twitter:url"]' => 'content', 'base[href]' => 'href' }
|
|
118
182
|
tags.each do |sel, attr|
|
|
119
|
-
val = doc.at_css(sel)&.[](attr)
|
|
120
|
-
return from_absolute(val)
|
|
121
|
-
rescue
|
|
122
|
-
|
|
183
|
+
val = doc.at_css(sel)&.[](attr)
|
|
184
|
+
return from_absolute(val)
|
|
185
|
+
rescue InvalidUrlError
|
|
186
|
+
nil
|
|
123
187
|
end
|
|
124
188
|
nil
|
|
125
189
|
end
|
|
@@ -128,31 +192,34 @@ module Html2rss
|
|
|
128
192
|
# Validates that a URL meets channel requirements.
|
|
129
193
|
#
|
|
130
194
|
# @param url [Url] the URL to validate
|
|
131
|
-
# @raise [
|
|
195
|
+
# @raise [InvalidUrlError] if the URL doesn't meet channel requirements
|
|
132
196
|
def self.validate_channel_url(url)
|
|
133
|
-
|
|
197
|
+
has_forbidden_at = url.user
|
|
198
|
+
has_forbidden_at ||= [url.query, url.fragment].compact.any? { |value| value.include?('@') }
|
|
199
|
+
raise InvalidUrlError, 'URL must not contain an @ character' if has_forbidden_at
|
|
134
200
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
has_forbidden_at ||= [uri.query, uri.fragment].compact.any? { |value| value.include?('@') }
|
|
138
|
-
raise ArgumentError, 'URL must not contain an @ character' if has_forbidden_at
|
|
201
|
+
validate_scheme(url)
|
|
202
|
+
end
|
|
139
203
|
|
|
140
|
-
|
|
141
|
-
raise
|
|
204
|
+
def self.validate_scheme(url)
|
|
205
|
+
raise InvalidUrlError, "URL scheme '#{url.scheme}' is not supported" unless url.web?
|
|
142
206
|
end
|
|
143
207
|
|
|
144
|
-
private_class_method :validate_channel_url
|
|
208
|
+
private_class_method :validate_channel_url, :validate_scheme
|
|
145
209
|
|
|
146
210
|
##
|
|
147
|
-
#
|
|
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)
|
|
148
215
|
def initialize(uri)
|
|
149
|
-
@uri = uri
|
|
150
|
-
@path_segments =
|
|
216
|
+
@uri = uri
|
|
217
|
+
@path_segments = path.split('/').reject(&:empty?).freeze
|
|
151
218
|
freeze
|
|
152
219
|
end
|
|
153
220
|
|
|
154
221
|
# @return [String] normalized URL string
|
|
155
|
-
def to_s = @uri.
|
|
222
|
+
def to_s = @uri.to_str
|
|
156
223
|
|
|
157
224
|
# @return [String, nil] URI scheme, for example `http` or `https`
|
|
158
225
|
def scheme = @uri.scheme
|
|
@@ -160,8 +227,11 @@ module Html2rss
|
|
|
160
227
|
# @return [String, nil] URI host component
|
|
161
228
|
def host = @uri.host
|
|
162
229
|
|
|
163
|
-
#
|
|
164
|
-
|
|
230
|
+
# @return [String, nil] userinfo user, without the password
|
|
231
|
+
def user = @uri.user
|
|
232
|
+
|
|
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`.
|
|
165
235
|
#
|
|
166
236
|
# @return [String, nil]
|
|
167
237
|
def domain
|
|
@@ -185,11 +255,15 @@ module Html2rss
|
|
|
185
255
|
# @return [Boolean] whether the URL includes scheme and host
|
|
186
256
|
def absolute? = @uri.absolute?
|
|
187
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
|
+
|
|
188
262
|
##
|
|
189
263
|
# Returns the URL query string as a hash of string keys and values.
|
|
190
264
|
#
|
|
191
265
|
# @return [Hash{String => String}] normalized query parameters
|
|
192
|
-
def query_values = @uri.query_values
|
|
266
|
+
def query_values = @uri.query_values || {}
|
|
193
267
|
|
|
194
268
|
##
|
|
195
269
|
# Returns the URL path split into non-empty segments.
|
|
@@ -205,7 +279,7 @@ module Html2rss
|
|
|
205
279
|
def with_path(path)
|
|
206
280
|
uri = @uri.dup
|
|
207
281
|
uri.path = path
|
|
208
|
-
self.class.from_absolute(uri
|
|
282
|
+
self.class.from_absolute(uri)
|
|
209
283
|
end
|
|
210
284
|
|
|
211
285
|
##
|
|
@@ -213,7 +287,31 @@ module Html2rss
|
|
|
213
287
|
#
|
|
214
288
|
# @return [Url] this URL when already fragment-free; otherwise a new URL
|
|
215
289
|
def without_fragment
|
|
216
|
-
|
|
290
|
+
fragment ? self.class.from_absolute(@uri.omit(:fragment)) : self
|
|
291
|
+
end
|
|
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)
|
|
217
315
|
end
|
|
218
316
|
|
|
219
317
|
##
|
|
@@ -224,7 +322,7 @@ module Html2rss
|
|
|
224
322
|
def with_query_values(values)
|
|
225
323
|
uri = @uri.dup
|
|
226
324
|
uri.query_values = values.transform_keys(&:to_s).transform_values(&:to_s)
|
|
227
|
-
self.class.from_absolute(uri
|
|
325
|
+
self.class.from_absolute(uri)
|
|
228
326
|
end
|
|
229
327
|
|
|
230
328
|
##
|
|
@@ -240,15 +338,7 @@ module Html2rss
|
|
|
240
338
|
# url = Url.from_absolute('https://example.com/hello%20world/article.html')
|
|
241
339
|
# url.titleized # => "Hello World Article"
|
|
242
340
|
def titleized
|
|
243
|
-
|
|
244
|
-
return '' if path.empty?
|
|
245
|
-
|
|
246
|
-
nicer_path = CGI.unescapeURIComponent(path)
|
|
247
|
-
.split('/')
|
|
248
|
-
.flat_map do |part|
|
|
249
|
-
part.gsub(/[^a-zA-Z0-9.]/, ' ').gsub(/\s+/, ' ').split
|
|
250
|
-
end
|
|
251
|
-
|
|
341
|
+
nicer_path = CGI.unescapeURIComponent(path).gsub(/[^a-zA-Z0-9.]/, ' ').split
|
|
252
342
|
nicer_path.map!(&:capitalize)
|
|
253
343
|
File.basename(nicer_path.join(' '), '.*')
|
|
254
344
|
end
|
|
@@ -266,8 +356,7 @@ module Html2rss
|
|
|
266
356
|
# url = Url.from_absolute('https://example.com')
|
|
267
357
|
# url.channel_titleized # => "example.com"
|
|
268
358
|
def channel_titleized
|
|
269
|
-
nicer_path = CGI.unescapeURIComponent(
|
|
270
|
-
host = @uri.host
|
|
359
|
+
nicer_path = CGI.unescapeURIComponent(path).split('/').reject(&:empty?)
|
|
271
360
|
|
|
272
361
|
nicer_path.any? ? "#{host}: #{nicer_path.map(&:capitalize).join(' ')}" : host
|
|
273
362
|
end
|
|
@@ -318,29 +407,41 @@ module Html2rss
|
|
|
318
407
|
# @param relative_uri [Addressable::URI]
|
|
319
408
|
# @return [Url]
|
|
320
409
|
def join_uri(relative_uri)
|
|
321
|
-
|
|
322
|
-
if base.path.empty?
|
|
323
|
-
base = base.dup
|
|
324
|
-
base.path = '/'
|
|
325
|
-
end
|
|
326
|
-
self.class.new(base.join(relative_uri).normalize)
|
|
410
|
+
self.class.new(@uri.join(relative_uri).normalize)
|
|
327
411
|
end
|
|
328
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
|
+
|
|
329
423
|
protected
|
|
330
424
|
|
|
331
|
-
#
|
|
425
|
+
# Same-document key: scheme, authority, slash-stripped path, and query (fragment omitted).
|
|
426
|
+
# Two URLs share it exactly when {#same_document?} holds.
|
|
332
427
|
#
|
|
333
428
|
# @return [String]
|
|
334
429
|
def document_identity
|
|
335
|
-
|
|
336
|
-
"#{uri.scheme}\0#{uri.authority}\0#{uri.path.to_s.chomp('/')}\0#{uri.query}"
|
|
430
|
+
"#{scheme}\0#{@uri.authority}\0#{path.chomp('/')}\0#{query}"
|
|
337
431
|
end
|
|
338
432
|
|
|
339
433
|
private
|
|
340
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
|
+
|
|
341
443
|
def ip_address_host?
|
|
342
|
-
IPAddr.new(host.
|
|
343
|
-
true
|
|
444
|
+
!IPAddr.new(host).nil?
|
|
344
445
|
rescue IPAddr::Error
|
|
345
446
|
false
|
|
346
447
|
end
|
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
|
@@ -101,15 +101,19 @@ module Html2rss # rubocop:disable Metrics/ModuleLength
|
|
|
101
101
|
#
|
|
102
102
|
# @param config_input [Hash, String] config hash, YAML string, or file path
|
|
103
103
|
# @param feed_name [String, nil] optional feed name in multi-feed file
|
|
104
|
-
# @param min_items [Integer] minimum required items (default: 1)
|
|
104
|
+
# @param min_items [Integer] minimum required items, >= 0 (default: 1)
|
|
105
105
|
# @param params [Hash] dynamic feed params
|
|
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
|
-
|
|
112
|
+
compare_enhance: false, sample_limit: 3)
|
|
113
|
+
Option.integer(:min_items, min_items, min: 0)
|
|
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:)
|
|
113
117
|
end
|
|
114
118
|
|
|
115
119
|
##
|
|
@@ -135,6 +139,7 @@ module Html2rss # rubocop:disable Metrics/ModuleLength
|
|
|
135
139
|
# @param local_file_path [String, nil] optional local HTML file path
|
|
136
140
|
# @param limit [Integer, nil] max articles to keep (default: {AutoSource::DEFAULT_LIMIT})
|
|
137
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
|
|
138
143
|
# @return [Html2rss::FeedResult]
|
|
139
144
|
def self.scrape(url,
|
|
140
145
|
strategy: :auto,
|
|
@@ -143,9 +148,10 @@ module Html2rss # rubocop:disable Metrics/ModuleLength
|
|
|
143
148
|
max_requests: 4,
|
|
144
149
|
local_file_path: nil,
|
|
145
150
|
limit: nil,
|
|
146
|
-
keep_different_domain: nil
|
|
151
|
+
keep_different_domain: nil,
|
|
152
|
+
keep_sponsored: nil)
|
|
147
153
|
auto_feed_result(url, strategy:, items_selector:, max_redirects:, max_requests:,
|
|
148
|
-
local_file_path:, limit:, keep_different_domain:)
|
|
154
|
+
local_file_path:, limit:, keep_different_domain:, keep_sponsored:)
|
|
149
155
|
end
|
|
150
156
|
|
|
151
157
|
##
|
|
@@ -249,6 +255,7 @@ module Html2rss # rubocop:disable Metrics/ModuleLength
|
|
|
249
255
|
# @param local_file_path [String, nil] optional local HTML file path
|
|
250
256
|
# @param limit [Integer, nil] max articles to keep (default: {AutoSource::DEFAULT_LIMIT})
|
|
251
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
|
|
252
259
|
# @return [Html2rss::FeedResult]
|
|
253
260
|
def self.auto_feed_result(url,
|
|
254
261
|
strategy: :auto,
|
|
@@ -257,9 +264,10 @@ module Html2rss # rubocop:disable Metrics/ModuleLength
|
|
|
257
264
|
max_requests: 4,
|
|
258
265
|
local_file_path: nil,
|
|
259
266
|
limit: nil,
|
|
260
|
-
keep_different_domain: nil
|
|
267
|
+
keep_different_domain: nil,
|
|
268
|
+
keep_sponsored: nil)
|
|
261
269
|
feed_result(build_auto_source_config(url:, strategy:, items_selector:, max_redirects:, max_requests:,
|
|
262
|
-
local_file_path:, limit:, keep_different_domain:))
|
|
270
|
+
local_file_path:, limit:, keep_different_domain:, keep_sponsored:))
|
|
263
271
|
end
|
|
264
272
|
|
|
265
273
|
##
|
|
@@ -359,13 +367,15 @@ module Html2rss # rubocop:disable Metrics/ModuleLength
|
|
|
359
367
|
|
|
360
368
|
# rubocop:disable-next Metrics/ParameterLists, Metrics/MethodLength
|
|
361
369
|
def build_auto_source_config(url:, strategy:, items_selector:, max_redirects:, max_requests:,
|
|
362
|
-
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)
|
|
363
372
|
config = Config.auto_source_config(
|
|
364
373
|
url:,
|
|
365
374
|
items_selector:,
|
|
366
375
|
request_controls: Config::RequestControls.from_shortcut(strategy:, max_redirects:, max_requests:),
|
|
367
376
|
limit:,
|
|
368
|
-
keep_different_domain
|
|
377
|
+
keep_different_domain:,
|
|
378
|
+
keep_sponsored:
|
|
369
379
|
)
|
|
370
380
|
if local_file_path
|
|
371
381
|
config[:request] ||= {}
|