html2rss 0.32.4 → 0.33.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/lib/html2rss/article/enclosure.rb +2 -6
- data/lib/html2rss/auto_source/cleanup.rb +1 -3
- data/lib/html2rss/auto_source/scraper/html.rb +2 -8
- data/lib/html2rss/auto_source/scraper/json_document.rb +2 -3
- data/lib/html2rss/auto_source/scraper/json_state/article_normalizer.rb +128 -128
- data/lib/html2rss/auto_source/scraper/json_state/candidate_detector.rb +37 -37
- data/lib/html2rss/auto_source/scraper/json_state/document_scanner.rb +123 -123
- data/lib/html2rss/auto_source/scraper/json_state/value_finder.rb +34 -34
- data/lib/html2rss/auto_source/scraper/json_state.rb +2 -6
- data/lib/html2rss/auto_source/scraper/meta_oembed.rb +32 -2
- data/lib/html2rss/auto_source/scraper/microdata.rb +228 -245
- data/lib/html2rss/auto_source/scraper/microformats2.rb +9 -1
- data/lib/html2rss/auto_source/scraper/native_feed.rb +1 -1
- data/lib/html2rss/auto_source/scraper/schema/category_extractor.rb +7 -2
- data/lib/html2rss/auto_source/scraper/schema/item_list.rb +2 -2
- data/lib/html2rss/auto_source/scraper/schema/thing.rb +98 -21
- data/lib/html2rss/auto_source/scraper/schema.rb +49 -2
- data/lib/html2rss/auto_source/scraper/semantic_html.rb +12 -16
- data/lib/html2rss/auto_source/scraper/sitemap/parser.rb +1 -4
- data/lib/html2rss/auto_source/scraper/sitemap.rb +3 -11
- 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 +4 -4
- data/lib/html2rss/auto_source/scraper/xhr_articles.rb +1 -1
- data/lib/html2rss/auto_source/scraper.rb +0 -11
- data/lib/html2rss/auto_source/segmenter/cluster.rb +5 -11
- data/lib/html2rss/auto_source/segmenter/list.rb +25 -57
- data/lib/html2rss/auto_source/segmenter/semantic.rb +6 -13
- data/lib/html2rss/auto_source.rb +1 -1
- data/lib/html2rss/batch.rb +22 -32
- data/lib/html2rss/capture/README.md +1 -1
- data/lib/html2rss/capture.rb +7 -27
- data/lib/html2rss/cli/render.rb +97 -96
- data/lib/html2rss/cli/validate.rb +2 -4
- data/lib/html2rss/cli.rb +33 -27
- data/lib/html2rss/config/request_controls.rb +10 -2
- data/lib/html2rss/config/schema.rb +8 -10
- data/lib/html2rss/config/selectors_validator.rb +1 -0
- data/lib/html2rss/config/validator.rb +3 -3
- data/lib/html2rss/config.rb +1 -1
- data/lib/html2rss/defaults.rb +5 -16
- data/lib/html2rss/doctor/botasaurus.rb +11 -19
- data/lib/html2rss/error.rb +7 -3
- data/lib/html2rss/feed_builder/rss.rb +1 -1
- data/lib/html2rss/feed_pipeline/auto_fallback.rb +3 -3
- data/lib/html2rss/feed_resolution/candidate_generator.rb +3 -4
- 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 +3 -3
- data/lib/html2rss/feed_resolution/scorer.rb +29 -29
- data/lib/html2rss/feed_resolution.rb +5 -9
- data/lib/html2rss/feed_result.rb +4 -3
- data/lib/html2rss/hash_util.rb +91 -90
- data/lib/html2rss/html/article_extractor/category_extractor.rb +6 -9
- data/lib/html2rss/html/article_extractor/date_extractor.rb +1 -1
- data/lib/html2rss/html/article_extractor/enclosure_extractor.rb +1 -3
- data/lib/html2rss/html/article_extractor/id_generator.rb +1 -1
- data/lib/html2rss/html/probe.rb +51 -51
- data/lib/html2rss/link_destination/path_classifier.rb +11 -33
- data/lib/html2rss/mcp/config_argument.rb +9 -2
- data/lib/html2rss/mcp/contract.rb +7 -5
- data/lib/html2rss/mcp/outcome.rb +4 -5
- data/lib/html2rss/mcp/runtime.rb +17 -24
- data/lib/html2rss/mcp/server/tools.rb +9 -9
- data/lib/html2rss/option.rb +48 -0
- data/lib/html2rss/page_recon/diagnostics.rb +7 -9
- data/lib/html2rss/page_recon.rb +0 -4
- data/lib/html2rss/recon.rb +10 -12
- data/lib/html2rss/request_service/blocked_surface.rb +3 -7
- data/lib/html2rss/request_service/botasaurus_strategy.rb +2 -2
- 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 +1 -3
- data/lib/html2rss/request_service/local_file_strategy.rb +0 -1
- data/lib/html2rss/request_service/network_guard.rb +3 -2
- data/lib/html2rss/request_service/policy.rb +8 -26
- data/lib/html2rss/request_service/response.rb +3 -6
- data/lib/html2rss/request_session/pager/base.rb +3 -3
- 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/scoring/engine.rb +4 -32
- data/lib/html2rss/scoring/link_resolver.rb +2 -7
- data/lib/html2rss/selectors/extractors/href.rb +2 -4
- data/lib/html2rss/selectors/json_xml.rb +33 -34
- data/lib/html2rss/selectors/post_processors/gsub.rb +2 -3
- data/lib/html2rss/selectors/post_processors/parse_time.rb +4 -5
- data/lib/html2rss/selectors/schema_export.rb +60 -60
- data/lib/html2rss/selectors.rb +14 -18
- data/lib/html2rss/sst/node.rb +9 -3
- data/lib/html2rss/sst/normalizer.rb +25 -24
- data/lib/html2rss/sst/text.rb +53 -57
- data/lib/html2rss/status.rb +23 -30
- data/lib/html2rss/syndication/alternate_links.rb +39 -0
- data/lib/html2rss/syndication/discovery.rb +117 -187
- data/lib/html2rss/syndication/parser.rb +92 -115
- data/lib/html2rss/test/enhance_audit.rb +13 -15
- data/lib/html2rss/test/policy.rb +9 -11
- data/lib/html2rss/test.rb +29 -48
- data/lib/html2rss/url.rb +53 -53
- data/lib/html2rss/version.rb +1 -1
- data/lib/html2rss.rb +2 -1
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5e2ebceddb0e3a74ddfcb848eb78f454061a4208b06dbc5443349b102d7edcd6
|
|
4
|
+
data.tar.gz: eeaaa250afd8e6b991bbc112470cd07da4285649f9a7df9c0c8d57b90d67ec8d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c71a7060557c917c1022fb03ea02d73dfbf86a9f9db1edf4b8a46f5855801a721236382346326d34b91029a2c9b902e53d81c11b650de67910fcc21edfa99ece
|
|
7
|
+
data.tar.gz: 64511f26248a9a713cadefe2efae797b243b097063f6170b0e39b5751d38a4b2cda12ab90730e5e87e44129d308618dbd88ff62c704a4ca3e08bdd86d8053dac
|
|
@@ -16,12 +16,8 @@ module Html2rss
|
|
|
16
16
|
def self.guess_content_type_from_url(url, default: 'application/octet-stream')
|
|
17
17
|
return default unless url
|
|
18
18
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
ext = ext[1..] if ext.start_with?('.')
|
|
22
|
-
|
|
23
|
-
content_type = MIME::Types.type_for(ext)
|
|
24
|
-
content_type.first&.to_s || 'application/octet-stream'
|
|
19
|
+
found = MIME::Types.type_for(url.path).first
|
|
20
|
+
found&.content_type || default
|
|
25
21
|
end
|
|
26
22
|
|
|
27
23
|
# @param url [Html2rss::Url] absolute enclosure URL
|
|
@@ -234,8 +234,6 @@ module Html2rss
|
|
|
234
234
|
end
|
|
235
235
|
|
|
236
236
|
def excluded_destination?(url)
|
|
237
|
-
return false unless url
|
|
238
|
-
|
|
239
237
|
facts = LinkDestination::DestinationFacts.build(url)
|
|
240
238
|
return true if facts.high_confidence_junk_path || facts.high_confidence_utility_destination
|
|
241
239
|
|
|
@@ -286,7 +284,7 @@ module Html2rss
|
|
|
286
284
|
def item_url(item)
|
|
287
285
|
raw = item.url
|
|
288
286
|
raw.nil? || raw.to_s.empty? ? nil : Html2rss::Url.from_absolute(raw.to_s)
|
|
289
|
-
rescue
|
|
287
|
+
rescue Html2rss::Url::InvalidUrlError
|
|
290
288
|
nil
|
|
291
289
|
end
|
|
292
290
|
|
|
@@ -34,8 +34,6 @@ module Html2rss
|
|
|
34
34
|
return false unless parsed_body
|
|
35
35
|
|
|
36
36
|
new(parsed_body, url: DETECTION_BASE_URL).extractable?
|
|
37
|
-
rescue ArgumentError
|
|
38
|
-
false
|
|
39
37
|
end
|
|
40
38
|
|
|
41
39
|
# @param parsed_body [Nokogiri::HTML::Document, nil] parsed HTML (when +document:+ omitted)
|
|
@@ -68,8 +66,6 @@ module Html2rss
|
|
|
68
66
|
# @return [Boolean]
|
|
69
67
|
def extractable?
|
|
70
68
|
ranked_segments.any?
|
|
71
|
-
rescue ArgumentError
|
|
72
|
-
false
|
|
73
69
|
end
|
|
74
70
|
|
|
75
71
|
private
|
|
@@ -83,8 +79,6 @@ module Html2rss
|
|
|
83
79
|
materialize(ranked)
|
|
84
80
|
end
|
|
85
81
|
end
|
|
86
|
-
rescue ArgumentError
|
|
87
|
-
[]
|
|
88
82
|
end
|
|
89
83
|
|
|
90
84
|
def ranked_segments
|
|
@@ -96,11 +90,11 @@ module Html2rss
|
|
|
96
90
|
end
|
|
97
91
|
|
|
98
92
|
def list_ranked
|
|
99
|
-
@list_ranked ||= rank_strategy(:list, permit_unanchored: false)
|
|
93
|
+
@list_ranked ||= document ? rank_strategy(:list, permit_unanchored: false) : []
|
|
100
94
|
end
|
|
101
95
|
|
|
102
96
|
def cluster_ranked
|
|
103
|
-
@cluster_ranked ||= rank_strategy(:cluster, permit_unanchored: true)
|
|
97
|
+
@cluster_ranked ||= document ? rank_strategy(:cluster, permit_unanchored: true) : []
|
|
104
98
|
end
|
|
105
99
|
|
|
106
100
|
def rank_strategy(strategy, permit_unanchored:)
|
|
@@ -47,7 +47,6 @@ module Html2rss
|
|
|
47
47
|
# @return [Enumerator, void] article enumerator when no block is given
|
|
48
48
|
def each(&block)
|
|
49
49
|
return enum_for(:each) unless block
|
|
50
|
-
return unless extractable?
|
|
51
50
|
|
|
52
51
|
if json_feed?
|
|
53
52
|
each_json_feed_item(&block)
|
|
@@ -103,7 +102,7 @@ module Html2rss
|
|
|
103
102
|
return unless str
|
|
104
103
|
|
|
105
104
|
Url.from_relative(str, @url)
|
|
106
|
-
rescue
|
|
105
|
+
rescue Html2rss::Url::InvalidUrlError
|
|
107
106
|
nil
|
|
108
107
|
end
|
|
109
108
|
|
|
@@ -112,7 +111,7 @@ module Html2rss
|
|
|
112
111
|
return unless str
|
|
113
112
|
|
|
114
113
|
Url.from_absolute(str)
|
|
115
|
-
rescue
|
|
114
|
+
rescue Html2rss::Url::InvalidUrlError
|
|
116
115
|
nil
|
|
117
116
|
end
|
|
118
117
|
|
|
@@ -28,149 +28,149 @@ module Html2rss
|
|
|
28
28
|
# Snake `_at`/`_on` or camel `At`/`On` suffix after a closed published-at miss.
|
|
29
29
|
DATE_KEY_SUFFIX = /_at\z|_on\z|[a-z]At\z|[a-z]On\z/
|
|
30
30
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
# rubocop:enable Metrics/MethodLength
|
|
59
|
-
|
|
60
|
-
# @param value [Object] candidate scalar, or Hash wrapping +rendered+/+value+/+text+
|
|
61
|
-
# @return [String, nil] normalized non-empty string (HTML preserved for descriptions)
|
|
62
|
-
def string(value)
|
|
63
|
-
value = unwrap_scalar(value)
|
|
64
|
-
return unless value.is_a?(String) || value.is_a?(Numeric)
|
|
65
|
-
|
|
66
|
-
trimmed = value.to_s.strip
|
|
67
|
-
trimmed unless trimmed.empty?
|
|
68
|
-
end
|
|
31
|
+
class << self
|
|
32
|
+
# rubocop:disable Metrics/MethodLength
|
|
33
|
+
# @param entry [Hash] raw article entry candidate
|
|
34
|
+
# @param base_url [String, Html2rss::Url] base URL for relative link resolution
|
|
35
|
+
# @return [Hash{Symbol => Object, nil}] normalized article hash for downstream extraction
|
|
36
|
+
def normalise(entry, base_url:)
|
|
37
|
+
return unless entry.is_a?(Hash)
|
|
38
|
+
|
|
39
|
+
title = string(ValueFinder.fetch(entry, TITLE_KEYS))
|
|
40
|
+
description = string(ValueFinder.fetch(entry, DESCRIPTION_KEYS))
|
|
41
|
+
article_url = resolve_link(entry, keys: URL_KEYS, base_url:,
|
|
42
|
+
log_key: 'JsonState: invalid URL encountered')
|
|
43
|
+
return unless article_url
|
|
44
|
+
return if title.nil? && description.nil?
|
|
45
|
+
|
|
46
|
+
{
|
|
47
|
+
title:,
|
|
48
|
+
description:,
|
|
49
|
+
url: article_url,
|
|
50
|
+
image: resolve_link(entry, keys: IMAGE_KEYS, base_url:,
|
|
51
|
+
log_key: 'JsonState: invalid image URL encountered'),
|
|
52
|
+
published_at: published_at(entry),
|
|
53
|
+
categories: categories(entry),
|
|
54
|
+
id: identifier(entry, article_url)
|
|
55
|
+
}.compact
|
|
56
|
+
end
|
|
57
|
+
# rubocop:enable Metrics/MethodLength
|
|
69
58
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
nested = SCALAR_KEYS.find { |key| value.key?(key) }
|
|
76
|
-
break unless nested
|
|
59
|
+
# @param value [Object] candidate scalar, or Hash wrapping +rendered+/+value+/+text+
|
|
60
|
+
# @return [String, nil] normalized non-empty string (HTML preserved for descriptions)
|
|
61
|
+
def string(value)
|
|
62
|
+
value = unwrap_scalar(value)
|
|
63
|
+
return unless value.is_a?(String) || value.is_a?(Numeric)
|
|
77
64
|
|
|
78
|
-
|
|
79
|
-
|
|
65
|
+
trimmed = value.to_s.strip
|
|
66
|
+
trimmed unless trimmed.empty?
|
|
80
67
|
end
|
|
81
|
-
value unless value.is_a?(Hash) || value.is_a?(Array)
|
|
82
|
-
end
|
|
83
68
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
unwrap_url(ValueFinder.fetch(entry[:links], keys), keys)
|
|
92
|
-
string = string(value)
|
|
93
|
-
return unless string
|
|
94
|
-
|
|
95
|
-
Url.from_relative(string, base_url)
|
|
96
|
-
rescue ArgumentError
|
|
97
|
-
Log.debug("#{log_key}: url=#{string}")
|
|
98
|
-
nil
|
|
99
|
-
end
|
|
69
|
+
# @param value [Object] candidate scalar or nested Hash
|
|
70
|
+
# @return [Object, nil] String/Numeric after at most {MAX_URL_UNWRAP} Hash unwraps
|
|
71
|
+
def unwrap_scalar(value)
|
|
72
|
+
depth = 0
|
|
73
|
+
while value.is_a?(Hash) && depth < MAX_URL_UNWRAP
|
|
74
|
+
nested = SCALAR_KEYS.find { |key| value.key?(key) }
|
|
75
|
+
break unless nested
|
|
100
76
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
depth = 0
|
|
106
|
-
while value.is_a?(Hash) && depth < MAX_URL_UNWRAP
|
|
107
|
-
value = ValueFinder.fetch(value, keys)
|
|
108
|
-
depth += 1
|
|
77
|
+
value = value[nested]
|
|
78
|
+
depth += 1
|
|
79
|
+
end
|
|
80
|
+
value unless value.is_a?(Hash) || value.is_a?(Array)
|
|
109
81
|
end
|
|
110
|
-
value unless value.is_a?(Hash)
|
|
111
|
-
end
|
|
112
82
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
83
|
+
# @param entry [Hash] raw article entry candidate
|
|
84
|
+
# @param keys [Array<Symbol>] preferred link keys
|
|
85
|
+
# @param base_url [String, Html2rss::Url] base URL for relative link resolution
|
|
86
|
+
# @param log_key [String] structured log message key
|
|
87
|
+
# @return [Html2rss::Url, nil] resolved absolute URL
|
|
88
|
+
def resolve_link(entry, keys:, base_url:, log_key:)
|
|
89
|
+
value = unwrap_url(ValueFinder.fetch(entry, keys), keys) ||
|
|
90
|
+
unwrap_url(ValueFinder.fetch(entry[:links], keys), keys)
|
|
91
|
+
string = string(value)
|
|
92
|
+
return unless string
|
|
93
|
+
|
|
94
|
+
Url.from_relative(string, base_url)
|
|
95
|
+
rescue Html2rss::Url::InvalidUrlError
|
|
96
|
+
Log.debug("#{log_key}: url=#{string}")
|
|
97
|
+
nil
|
|
98
|
+
end
|
|
124
99
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
100
|
+
# @param value [Object] candidate URL value, possibly a nested Hash
|
|
101
|
+
# @param keys [Array<Symbol>] preferred link keys
|
|
102
|
+
# @return [Object, nil] scalar URL candidate after at most {MAX_URL_UNWRAP} Hash unwraps
|
|
103
|
+
def unwrap_url(value, keys)
|
|
104
|
+
depth = 0
|
|
105
|
+
while value.is_a?(Hash) && depth < MAX_URL_UNWRAP
|
|
106
|
+
value = ValueFinder.fetch(value, keys)
|
|
107
|
+
depth += 1
|
|
108
|
+
end
|
|
109
|
+
value unless value.is_a?(Hash)
|
|
110
|
+
end
|
|
128
111
|
|
|
129
|
-
|
|
112
|
+
# @param entry [Hash] raw article entry candidate
|
|
113
|
+
# @return [String, nil] publication timestamp string or unix timestamp digits
|
|
114
|
+
def published_at(entry)
|
|
115
|
+
value = ValueFinder.fetch(entry, PUBLISHED_AT_KEYS)
|
|
116
|
+
string(value) || string(suffix_date_value(entry))
|
|
130
117
|
end
|
|
131
118
|
|
|
132
|
-
|
|
133
|
-
|
|
119
|
+
# @param hash [Hash, nil] entry or nested attributes/data hash
|
|
120
|
+
# @return [Object, nil] first string or numeric value whose key matches {DATE_KEY_SUFFIX}
|
|
121
|
+
def suffix_date_value(hash)
|
|
122
|
+
return unless hash.is_a?(Hash)
|
|
134
123
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
key.to_s.match?(DATE_KEY_SUFFIX)
|
|
139
|
-
end
|
|
124
|
+
hash.each do |key, value|
|
|
125
|
+
next unless date_suffix_key?(key)
|
|
126
|
+
next unless value.is_a?(String) || value.is_a?(Numeric)
|
|
140
127
|
|
|
141
|
-
|
|
142
|
-
# @param entry [Hash] raw article entry candidate
|
|
143
|
-
# @return [Array<String>, nil] normalized unique categories
|
|
144
|
-
def categories(entry)
|
|
145
|
-
raw = ValueFinder.fetch(entry, CATEGORY_KEYS)
|
|
146
|
-
names = case raw
|
|
147
|
-
when Array then raw
|
|
148
|
-
when Hash then raw.values
|
|
149
|
-
when String then [raw]
|
|
150
|
-
else []
|
|
151
|
-
end
|
|
152
|
-
|
|
153
|
-
result = names.flat_map do |value|
|
|
154
|
-
case value
|
|
155
|
-
when Hash
|
|
156
|
-
string(ValueFinder.fetch(value, %i[name title label]))
|
|
157
|
-
else
|
|
158
|
-
string(value)
|
|
128
|
+
return value
|
|
159
129
|
end
|
|
160
|
-
end.compact
|
|
161
130
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
131
|
+
suffix_date_value(hash[:attributes]) || suffix_date_value(hash[:data])
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# @param key [Symbol, String] candidate published-at key
|
|
135
|
+
# @return [Boolean] whether the key ends in a date suffix (_at/_on/At/On)
|
|
136
|
+
def date_suffix_key?(key)
|
|
137
|
+
key.to_s.match?(DATE_KEY_SUFFIX)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# rubocop:disable Metrics/MethodLength
|
|
141
|
+
# @param entry [Hash] raw article entry candidate
|
|
142
|
+
# @return [Array<String>, nil] normalized unique categories
|
|
143
|
+
def categories(entry)
|
|
144
|
+
raw = ValueFinder.fetch(entry, CATEGORY_KEYS)
|
|
145
|
+
names = case raw
|
|
146
|
+
when Array then raw
|
|
147
|
+
when Hash then raw.values
|
|
148
|
+
when String then [raw]
|
|
149
|
+
else []
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
result = names.flat_map do |value|
|
|
153
|
+
case value
|
|
154
|
+
when Hash
|
|
155
|
+
string(ValueFinder.fetch(value, %i[name title label]))
|
|
156
|
+
else
|
|
157
|
+
string(value)
|
|
158
|
+
end
|
|
159
|
+
end.compact
|
|
160
|
+
|
|
161
|
+
result.uniq!
|
|
162
|
+
result unless result.empty?
|
|
163
|
+
end
|
|
164
|
+
# rubocop:enable Metrics/MethodLength
|
|
165
|
+
|
|
166
|
+
# @param entry [Hash] raw article entry candidate
|
|
167
|
+
# @param article_url [Html2rss::Url] resolved article URL
|
|
168
|
+
# @return [String] stable article identifier fallbacking to resolved URL
|
|
169
|
+
def identifier(entry, article_url)
|
|
170
|
+
value = ValueFinder.fetch(entry, ID_KEYS)
|
|
171
|
+
value = ValueFinder.fetch(value, ID_KEYS) if value.is_a?(Hash)
|
|
172
|
+
string(value) || article_url.to_s
|
|
173
|
+
end
|
|
174
174
|
end
|
|
175
175
|
end
|
|
176
176
|
end
|
|
@@ -6,51 +6,51 @@ module Html2rss
|
|
|
6
6
|
class JsonState
|
|
7
7
|
# Identifies arrays that look like collections of article hashes.
|
|
8
8
|
module CandidateDetector
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
document.any? {
|
|
19
|
-
|
|
20
|
-
|
|
9
|
+
class << self
|
|
10
|
+
# @param document [Hash, Array, Object] candidate document node
|
|
11
|
+
# @return [Boolean] whether the node contains article-like arrays
|
|
12
|
+
def candidate_array?(document)
|
|
13
|
+
case document
|
|
14
|
+
when Array
|
|
15
|
+
return true if array_of_articles?(document)
|
|
16
|
+
|
|
17
|
+
document.any? { traversable_candidate?(_1) }
|
|
18
|
+
when Hash then document.each_value.any? { candidate_array?(_1) }
|
|
19
|
+
else false
|
|
20
|
+
end
|
|
21
21
|
end
|
|
22
|
-
end
|
|
23
22
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
23
|
+
# @param value [Hash, Array, Object] candidate nested value
|
|
24
|
+
# @return [Boolean] whether nested value should be traversed for article candidates
|
|
25
|
+
def traversable_candidate?(value)
|
|
26
|
+
case value
|
|
27
|
+
when Array, Hash then candidate_array?(value)
|
|
28
|
+
else false
|
|
29
|
+
end
|
|
30
30
|
end
|
|
31
|
-
end
|
|
32
31
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
32
|
+
# @param array [Array<Object>] candidate list of entries
|
|
33
|
+
# @return [Boolean] whether array includes hash entries with title and URL fields
|
|
34
|
+
def array_of_articles?(array)
|
|
35
|
+
array.any? do |element|
|
|
36
|
+
next unless element.is_a?(Hash)
|
|
38
37
|
|
|
39
|
-
|
|
38
|
+
title_from(element) && url_from(element)
|
|
39
|
+
end
|
|
40
40
|
end
|
|
41
|
-
end
|
|
42
41
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
42
|
+
# @param object [Hash] article candidate object
|
|
43
|
+
# @return [Object, nil] detected title-like value
|
|
44
|
+
def title_from(object)
|
|
45
|
+
ValueFinder.fetch(object, ArticleNormalizer::TITLE_KEYS)
|
|
46
|
+
end
|
|
48
47
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
48
|
+
# @param object [Hash] article candidate object
|
|
49
|
+
# @return [Object, nil] detected URL-like value (including links.self)
|
|
50
|
+
def url_from(object)
|
|
51
|
+
ValueFinder.fetch(object, ArticleNormalizer::URL_KEYS) ||
|
|
52
|
+
ValueFinder.fetch(object[:links], ArticleNormalizer::URL_KEYS)
|
|
53
|
+
end
|
|
54
54
|
end
|
|
55
55
|
end
|
|
56
56
|
end
|