html2rss 0.32.3 → 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 +54 -1
- data/lib/html2rss/feed_builder/rss.rb +1 -1
- data/lib/html2rss/feed_pipeline/README.md +1 -1
- data/lib/html2rss/feed_pipeline/auto_fallback.rb +58 -55
- 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 +3 -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_service/strategy.rb +2 -2
- data/lib/html2rss/request_service.rb +26 -2
- 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
|
@@ -8,7 +8,7 @@ module Html2rss
|
|
|
8
8
|
# Extracts categories from Schema.org structured data.
|
|
9
9
|
module CategoryExtractor
|
|
10
10
|
# Schema.org fields checked for category-like string values.
|
|
11
|
-
CATEGORY_FIELDS = %i[articleSection keywords categories tags].freeze
|
|
11
|
+
CATEGORY_FIELDS = %i[articleSection keywords categories tags occupationalCategory industry].freeze
|
|
12
12
|
|
|
13
13
|
##
|
|
14
14
|
# @param schema_object [Hash] The schema object
|
|
@@ -26,15 +26,20 @@ module Html2rss
|
|
|
26
26
|
def add_field(categories, value)
|
|
27
27
|
case value
|
|
28
28
|
when Array
|
|
29
|
-
value.each { |item|
|
|
29
|
+
value.each { |item| add_item(categories, item) }
|
|
30
30
|
when String
|
|
31
31
|
split_string(categories, value)
|
|
32
32
|
end
|
|
33
33
|
end
|
|
34
34
|
|
|
35
|
+
def add_item(categories, item)
|
|
36
|
+
item.is_a?(String) ? split_string(categories, item) : add_string(categories, item.to_s)
|
|
37
|
+
end
|
|
38
|
+
|
|
35
39
|
def add_about(categories, about)
|
|
36
40
|
case about
|
|
37
41
|
when Array then add_about_items(categories, about)
|
|
42
|
+
when Hash then add_about_items(categories, [about])
|
|
38
43
|
when String then split_string(categories, about)
|
|
39
44
|
end
|
|
40
45
|
end
|
|
@@ -49,11 +49,11 @@ module Html2rss
|
|
|
49
49
|
|
|
50
50
|
# @param object [Hash] candidate schema object
|
|
51
51
|
# @param from_list_item [Boolean] whether object came from a ListItem unwrap
|
|
52
|
-
# @return [Boolean] whether to emit this object as an article
|
|
52
|
+
# @return [Boolean] whether to emit this object as an article (also bare Event, JobPosting and video items)
|
|
53
53
|
def emit?(object, from_list_item:)
|
|
54
54
|
return true if from_list_item
|
|
55
55
|
|
|
56
|
-
Schema.normalize_types(object[:@type]).intersect?(Thing::SUPPORTED_TYPES)
|
|
56
|
+
Schema.normalize_types(object[:@type]).intersect?(Thing::SUPPORTED_TYPES | Thing::ITEM_LIST_TYPES)
|
|
57
57
|
end
|
|
58
58
|
end
|
|
59
59
|
end
|
|
@@ -10,7 +10,7 @@ module Html2rss
|
|
|
10
10
|
# A Thing is kind of the 'base class' for Schema.org schema_objects.
|
|
11
11
|
#
|
|
12
12
|
# @see https://schema.org/Thing
|
|
13
|
-
class Thing
|
|
13
|
+
class Thing # rubocop:disable Metrics/ClassLength -- one accessor per schema.org field
|
|
14
14
|
# Supported Schema.org `@type` values mapped to article extraction.
|
|
15
15
|
SUPPORTED_TYPES = %w[
|
|
16
16
|
AdvertiserContentArticle AnalysisNewsArticle APIReference Article
|
|
@@ -19,6 +19,12 @@ module Html2rss
|
|
|
19
19
|
ReviewNewsArticle SatiricalArticle ScholarlyArticle SocialMediaPosting TechArticle
|
|
20
20
|
].to_set.freeze
|
|
21
21
|
|
|
22
|
+
# Schema.org types emitted only as elements of an ItemList (never standalone).
|
|
23
|
+
ITEM_LIST_TYPES = Set['Event', 'JobPosting', 'VideoObject', 'PodcastEpisode'].freeze
|
|
24
|
+
|
|
25
|
+
# Schema.org types whose own `contentUrl` is the media enclosure.
|
|
26
|
+
MEDIA_TYPES = Set['VideoObject'].freeze
|
|
27
|
+
|
|
22
28
|
# @param schema_object [Hash{Symbol => Object}] parsed schema.org object
|
|
23
29
|
# @param url [String, Html2rss::Url, nil] base URL used for relative normalization
|
|
24
30
|
def initialize(schema_object, url:)
|
|
@@ -28,7 +34,7 @@ module Html2rss
|
|
|
28
34
|
|
|
29
35
|
# @return [Hash{Symbol => Object}] scraped article attributes
|
|
30
36
|
def call
|
|
31
|
-
{ id:, title:, description:, author:, url:, image:, published_at:, categories: }
|
|
37
|
+
{ id:, title:, description:, author:, url:, image:, published_at:, categories:, enclosures: }
|
|
32
38
|
end
|
|
33
39
|
|
|
34
40
|
# @return [String, nil] stable schema object identifier
|
|
@@ -43,31 +49,29 @@ module Html2rss
|
|
|
43
49
|
def title
|
|
44
50
|
schema_object.values_at(:headline, :name, :alternativeHeadline, :title)
|
|
45
51
|
.lazy
|
|
46
|
-
.map { _1.to_s.strip }
|
|
52
|
+
.map { first_scalar(_1).to_s.strip }
|
|
47
53
|
.find { !_1.empty? }
|
|
48
54
|
end
|
|
49
55
|
|
|
50
56
|
# @return [String, nil] longest available description field
|
|
51
57
|
def description
|
|
52
|
-
schema_object.values_at(:description, :articleBody, :abstract, :schema_object_body)
|
|
58
|
+
schema_object.values_at(:description, :articleBody, :abstract, :schema_object_body)
|
|
59
|
+
.map { first_scalar(_1) }
|
|
60
|
+
.max_by { _1.to_s.size }
|
|
53
61
|
end
|
|
54
62
|
|
|
55
63
|
# @return [String, nil] extracted author or publisher name
|
|
56
64
|
def author
|
|
57
|
-
extract_author_name(schema_object[:author]) || extract_author_name(schema_object[:publisher])
|
|
65
|
+
extract_author_name(schema_object[:author]) || extract_author_name(schema_object[:publisher]) ||
|
|
66
|
+
extract_author_name(schema_object[:hiringOrganization]) ||
|
|
67
|
+
extract_author_name(schema_object[:organizer])
|
|
58
68
|
end
|
|
59
69
|
|
|
60
70
|
# @return [Html2rss::Url, nil] the URL of the schema object
|
|
61
71
|
def url
|
|
62
72
|
return @url if defined?(@url)
|
|
63
73
|
|
|
64
|
-
url = schema_object[:url]
|
|
65
|
-
if url.to_s.empty?
|
|
66
|
-
Log.debug("Schema#Thing.url: no url in schema_object: #{schema_object.inspect}")
|
|
67
|
-
return @url = nil
|
|
68
|
-
end
|
|
69
|
-
|
|
70
|
-
@url = Url.from_relative(url, base_url || url)
|
|
74
|
+
@url = build_url(first_scalar(schema_object[:url])) || fallback_url
|
|
71
75
|
end
|
|
72
76
|
|
|
73
77
|
# @return [Html2rss::Url, nil] normalized article image URL
|
|
@@ -78,10 +82,11 @@ module Html2rss
|
|
|
78
82
|
@image = img_url ? Url.from_relative(img_url, base_url || img_url) : nil
|
|
79
83
|
end
|
|
80
84
|
|
|
81
|
-
# @return [String, nil]
|
|
85
|
+
# @return [String, nil] first non-empty of datePublished, datePosted, dateCreated, dateModified, uploadDate
|
|
82
86
|
def published_at
|
|
83
|
-
|
|
84
|
-
|
|
87
|
+
schema_object.values_at(:datePublished, :datePosted, :dateCreated, :dateModified, :uploadDate)
|
|
88
|
+
.map { first_scalar(_1) }
|
|
89
|
+
.find { !_1.to_s.empty? }
|
|
85
90
|
end
|
|
86
91
|
|
|
87
92
|
# @return [Array<String>, nil] extracted category labels
|
|
@@ -89,15 +94,84 @@ module Html2rss
|
|
|
89
94
|
@categories ||= CategoryExtractor.call(schema_object)
|
|
90
95
|
end
|
|
91
96
|
|
|
97
|
+
# @return [Array<Hash{Symbol => Object}>, nil] enclosures from VideoObject/AudioObject `contentUrl`
|
|
98
|
+
def enclosures
|
|
99
|
+
nodes = Array(schema_object.values_at(:video, :audio)).flatten
|
|
100
|
+
nodes << schema_object if own_media?
|
|
101
|
+
found = nodes.filter_map { media_enclosure(_1) }
|
|
102
|
+
found unless found.empty?
|
|
103
|
+
end
|
|
104
|
+
|
|
92
105
|
attr_reader :schema_object, :base_url
|
|
93
106
|
|
|
94
107
|
# @return [Array<String>] normalized image URL candidates
|
|
95
108
|
def image_urls
|
|
96
|
-
@image_urls ||= schema_object.values_at(:image, :thumbnailUrl).filter_map { image_url_from(_1) }
|
|
109
|
+
@image_urls ||= schema_object.values_at(:image, :thumbnailUrl, :thumbnail).filter_map { image_url_from(_1) }
|
|
97
110
|
end
|
|
98
111
|
|
|
99
112
|
private
|
|
100
113
|
|
|
114
|
+
# @return [Boolean] whether the object itself is a media object carrying its own `contentUrl`
|
|
115
|
+
def own_media?
|
|
116
|
+
Schema.normalize_types(schema_object[:@type]).intersect?(MEDIA_TYPES)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# @param node [Object] VideoObject/AudioObject node
|
|
120
|
+
# @return [Hash{Symbol => Object}, nil] enclosure attributes, or nil without a usable absolute contentUrl
|
|
121
|
+
def media_enclosure(node)
|
|
122
|
+
return unless node.is_a?(Hash) && node[:contentUrl].is_a?(String)
|
|
123
|
+
|
|
124
|
+
build_media_enclosure(node[:contentUrl], node[:encodingFormat])
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# @param src [String] contentUrl
|
|
128
|
+
# @param type [Object] encodingFormat (used when a String)
|
|
129
|
+
# @return [Hash{Symbol => Object}, nil] enclosure attributes with an absolute url
|
|
130
|
+
def build_media_enclosure(src, type)
|
|
131
|
+
found = ::Html2rss::Html::ArticleRules::Enclosure.from_media(src, (type if type.is_a?(String)),
|
|
132
|
+
base_url || src)
|
|
133
|
+
found if found&.dig(:url)&.absolute?
|
|
134
|
+
rescue Url::InvalidUrlError
|
|
135
|
+
nil
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# @param value [Object] url candidate
|
|
139
|
+
# @return [Html2rss::Url, nil] absolute url, or nil when missing or unusable
|
|
140
|
+
def build_url(value)
|
|
141
|
+
return Url.from_relative(value, base_url || value) if value.is_a?(String) && !value.empty?
|
|
142
|
+
|
|
143
|
+
Log.debug("Schema#Thing.url: no usable url in schema_object: #{schema_object.inspect}")
|
|
144
|
+
nil
|
|
145
|
+
rescue Url::InvalidUrlError
|
|
146
|
+
Log.debug("Schema#Thing.url: unusable url #{value.inspect}")
|
|
147
|
+
nil
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# @return [Html2rss::Url, nil] first absolute http(s) mainEntityOfPage or @id, without fragment
|
|
151
|
+
def fallback_url
|
|
152
|
+
main = schema_object[:mainEntityOfPage]
|
|
153
|
+
candidates = main.is_a?(Hash) ? [main[:@id], main[:url]] : [main]
|
|
154
|
+
(candidates + [schema_object[:@id]]).lazy.filter_map { web_url(_1) }.first
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# @param value [Object] url candidate
|
|
158
|
+
# @return [Html2rss::Url, nil] fragment-free url when value is an absolute http(s) string
|
|
159
|
+
def web_url(value)
|
|
160
|
+
return unless value.is_a?(String) && value.match?(%r{\Ahttps?://}i)
|
|
161
|
+
|
|
162
|
+
Url.from_absolute(value.strip).without_fragment
|
|
163
|
+
rescue Url::InvalidUrlError
|
|
164
|
+
nil
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# @param value [Object] schema value that may be wrapped in a list (multilingual or multi-valued fields)
|
|
168
|
+
# @return [Object, nil] the value, or the first non-empty String of a list
|
|
169
|
+
def first_scalar(value)
|
|
170
|
+
return value unless value.is_a?(Array)
|
|
171
|
+
|
|
172
|
+
value.find { _1.is_a?(String) && !_1.strip.empty? }
|
|
173
|
+
end
|
|
174
|
+
|
|
101
175
|
# @param obj [String, Hash, Array, nil] schema author or publisher field
|
|
102
176
|
# @return [String, nil] extracted author name
|
|
103
177
|
def extract_author_name(obj)
|
|
@@ -110,12 +184,15 @@ module Html2rss
|
|
|
110
184
|
end
|
|
111
185
|
end
|
|
112
186
|
|
|
113
|
-
# @param obj [String, Hash, nil] image node
|
|
114
|
-
# @return [String, nil] image URL string
|
|
187
|
+
# @param obj [String, Hash, Array, nil] image node or list of image nodes
|
|
188
|
+
# @return [String, nil] image URL string (first usable entry for lists)
|
|
115
189
|
def image_url_from(obj)
|
|
116
190
|
return obj if obj.is_a?(String)
|
|
191
|
+
return obj.filter_map { image_url_from(_1) }.first if obj.is_a?(Array)
|
|
117
192
|
return unless obj.is_a?(Hash)
|
|
118
|
-
|
|
193
|
+
|
|
194
|
+
types = Schema.normalize_types(obj[:@type])
|
|
195
|
+
return unless types.empty? || types.include?('ImageObject')
|
|
119
196
|
|
|
120
197
|
obj[:url] || obj[:contentUrl]
|
|
121
198
|
end
|
|
@@ -129,7 +206,7 @@ module Html2rss
|
|
|
129
206
|
|
|
130
207
|
norm_url = normalized_id_url(text, reference_url:)
|
|
131
208
|
reference_url && norm_url.host == reference_url.host ? normalized_id_value(norm_url) : text
|
|
132
|
-
rescue
|
|
209
|
+
rescue Html2rss::Url::InvalidUrlError
|
|
133
210
|
text
|
|
134
211
|
end
|
|
135
212
|
|
|
@@ -153,7 +230,7 @@ module Html2rss
|
|
|
153
230
|
# @return [Html2rss::Url, nil] normalized absolute URL for schema resolution
|
|
154
231
|
def normalized_base_url(url)
|
|
155
232
|
Url.from_absolute(url) unless url.to_s.strip.empty?
|
|
156
|
-
rescue
|
|
233
|
+
rescue Html2rss::Url::InvalidUrlError
|
|
157
234
|
nil
|
|
158
235
|
end
|
|
159
236
|
end
|
|
@@ -25,7 +25,8 @@ module Html2rss
|
|
|
25
25
|
|
|
26
26
|
# Canonical Schema.org type names keyed by folded wire forms (case-insensitive lookup).
|
|
27
27
|
CANONICAL_BY_DOWNCASE = begin
|
|
28
|
-
canonical_types = Thing::SUPPORTED_TYPES | ItemList::SUPPORTED_TYPES |
|
|
28
|
+
canonical_types = Thing::SUPPORTED_TYPES | ItemList::SUPPORTED_TYPES | Thing::ITEM_LIST_TYPES |
|
|
29
|
+
DENIED_CONTAINER_TYPES | Set['Product']
|
|
29
30
|
canonical_types.to_h { |type| [::Html2rss::Html::Probe.fold(type), type] }.freeze
|
|
30
31
|
end.freeze
|
|
31
32
|
|
|
@@ -43,6 +44,9 @@ module Html2rss
|
|
|
43
44
|
# Shared empty type set for nil/unsupported wire forms (avoids per-call Set.new).
|
|
44
45
|
EMPTY_TYPES = Set.new.freeze
|
|
45
46
|
|
|
47
|
+
# Keys whose `{"@id": ...}` stubs are resolved against sibling nodes of the same JSON-LD document.
|
|
48
|
+
REFERENCE_KEYS = %i[author publisher image].freeze
|
|
49
|
+
|
|
46
50
|
# @return [Symbol] scraper config key
|
|
47
51
|
def self.options_key = :schema
|
|
48
52
|
|
|
@@ -84,6 +88,18 @@ module Html2rss
|
|
|
84
88
|
end
|
|
85
89
|
end
|
|
86
90
|
|
|
91
|
+
##
|
|
92
|
+
# Like {from} for a script tag, but resolves `@id` stubs for {REFERENCE_KEYS} against the same document.
|
|
93
|
+
#
|
|
94
|
+
# @param script [Nokogiri::XML::Element] schema JSON-LD script tag
|
|
95
|
+
# @return [Array<Hash>] the schema_objects with resolved references
|
|
96
|
+
# @api private
|
|
97
|
+
def from_script(script)
|
|
98
|
+
document = parse_script_tag(script)
|
|
99
|
+
index = node_index(document)
|
|
100
|
+
from(document).map { |object| resolve_references(object, index) }
|
|
101
|
+
end
|
|
102
|
+
|
|
87
103
|
# @param object [Hash{Symbol => Object}] schema candidate object
|
|
88
104
|
# @return [Boolean] whether an extractor exists for the candidate object
|
|
89
105
|
def supported_schema_object?(object)
|
|
@@ -139,6 +155,37 @@ module Html2rss
|
|
|
139
155
|
|
|
140
156
|
private
|
|
141
157
|
|
|
158
|
+
# @param object [Hash, Array, Object] parsed JSON-LD value
|
|
159
|
+
# @param index [Hash{String => Hash}] accumulator of nodes (more than just `@id`) keyed by `@id`
|
|
160
|
+
# @return [Hash{String => Hash}] nodes keyed by `@id`
|
|
161
|
+
def node_index(object, index = {})
|
|
162
|
+
case object
|
|
163
|
+
when Hash
|
|
164
|
+
index[object[:@id]] ||= object if object[:@id].is_a?(String) && object.size > 1
|
|
165
|
+
object.each_value { node_index(_1, index) }
|
|
166
|
+
when Array then object.each { node_index(_1, index) }
|
|
167
|
+
end
|
|
168
|
+
index
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# @param object [Hash{Symbol => Object}] schema object
|
|
172
|
+
# @param index [Hash{String => Hash}] nodes keyed by `@id`
|
|
173
|
+
# @return [Hash{Symbol => Object}] object with reference stubs replaced where a sibling node exists
|
|
174
|
+
def resolve_references(object, index)
|
|
175
|
+
object.to_h { |key, value| [key, REFERENCE_KEYS.include?(key) ? resolve_stub(value, index) : value] }
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# @param value [Object] author, publisher or image value
|
|
179
|
+
# @param index [Hash{String => Hash}] nodes keyed by `@id`
|
|
180
|
+
# @return [Object] the referenced node for an `{"@id": ...}` stub, else the value unchanged
|
|
181
|
+
def resolve_stub(value, index)
|
|
182
|
+
case value
|
|
183
|
+
when Array then value.map { resolve_stub(_1, index) }
|
|
184
|
+
when Hash then value.keys == [:@id] ? index.fetch(value[:@id], value) : value
|
|
185
|
+
else value
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
142
189
|
# @param hash [Hash] candidate schema object
|
|
143
190
|
# @return [Array<Hash>] the object itself or nested supported objects
|
|
144
191
|
def from_hash(hash)
|
|
@@ -200,7 +247,7 @@ module Html2rss
|
|
|
200
247
|
|
|
201
248
|
def schema_objects
|
|
202
249
|
::Html2rss::Html::Probe.scripts(@parsed_body, ::Html2rss::Html::Probe::APPLICATION_LD_JSON)
|
|
203
|
-
.flat_map { |tag| Schema.
|
|
250
|
+
.flat_map { |tag| Schema.from_script(tag) }
|
|
204
251
|
end
|
|
205
252
|
|
|
206
253
|
attr_reader :parsed_body, :url
|
|
@@ -26,8 +26,6 @@ module Html2rss
|
|
|
26
26
|
return false unless parsed_body
|
|
27
27
|
|
|
28
28
|
new(parsed_body, url: 'https://example.com').extractable?
|
|
29
|
-
rescue ArgumentError
|
|
30
|
-
false
|
|
31
29
|
end
|
|
32
30
|
|
|
33
31
|
# @param parsed_body [Nokogiri::HTML::Document, nil] parsed HTML (when +document:+ omitted)
|
|
@@ -57,8 +55,6 @@ module Html2rss
|
|
|
57
55
|
# @return [Boolean]
|
|
58
56
|
def extractable?
|
|
59
57
|
ranked_segments.any?
|
|
60
|
-
rescue ArgumentError
|
|
61
|
-
false
|
|
62
58
|
end
|
|
63
59
|
|
|
64
60
|
private
|
|
@@ -69,21 +65,21 @@ module Html2rss
|
|
|
69
65
|
entries = deduplicator.call(ranked_segments)
|
|
70
66
|
entries.filter_map { |ranked| deduplicator.article_for(ranked) }
|
|
71
67
|
end
|
|
72
|
-
rescue ArgumentError
|
|
73
|
-
[]
|
|
74
68
|
end
|
|
75
69
|
|
|
76
70
|
def ranked_segments
|
|
77
|
-
@ranked_segments ||=
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
71
|
+
@ranked_segments ||= document ? rank_document : []
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def rank_document
|
|
75
|
+
segments = Segmenter.call(
|
|
76
|
+
document,
|
|
77
|
+
base_url: @url,
|
|
78
|
+
strategy: :semantic,
|
|
79
|
+
permit_unanchored: @permit_unanchored,
|
|
80
|
+
link_resolver:
|
|
81
|
+
)
|
|
82
|
+
Scoring::Engine.new(link_resolver:).rank_top(segments, limit: TOP_K)
|
|
87
83
|
end
|
|
88
84
|
|
|
89
85
|
def link_resolver
|
|
@@ -149,10 +149,7 @@ module Html2rss
|
|
|
149
149
|
def stale_date?(published_at)
|
|
150
150
|
return false unless published_at
|
|
151
151
|
|
|
152
|
-
|
|
153
|
-
(Time.now.utc - time) > (max_age_days * 86_400)
|
|
154
|
-
rescue ArgumentError
|
|
155
|
-
false
|
|
152
|
+
(Time.now.utc - Time.parse(published_at)) > (max_age_days * 86_400)
|
|
156
153
|
end
|
|
157
154
|
end
|
|
158
155
|
end
|
|
@@ -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
|
|
@@ -65,13 +65,7 @@ module Html2rss
|
|
|
65
65
|
def each
|
|
66
66
|
return enum_for(:each) unless block_given?
|
|
67
67
|
|
|
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
|
|
68
|
+
fetch_and_parse_entries.each { |entry| yield article_from_entry(entry) }
|
|
75
69
|
end
|
|
76
70
|
|
|
77
71
|
private
|
|
@@ -137,7 +131,7 @@ module Html2rss
|
|
|
137
131
|
|
|
138
132
|
# rubocop:disable-next Metrics/MethodLength -- structured fetch-failed warn
|
|
139
133
|
def fetch_xml(target_url)
|
|
140
|
-
return unless request_session
|
|
134
|
+
return unless request_session
|
|
141
135
|
|
|
142
136
|
response = request_session.follow_up(url: target_url, relation: :auto_source, origin_url: url)
|
|
143
137
|
response&.body
|
|
@@ -160,8 +154,6 @@ module Html2rss
|
|
|
160
154
|
end
|
|
161
155
|
|
|
162
156
|
def article_from_entry(entry)
|
|
163
|
-
return unless entry.url
|
|
164
|
-
|
|
165
157
|
{
|
|
166
158
|
url: entry.url,
|
|
167
159
|
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
|
|
@@ -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
|
|
|
@@ -35,7 +35,7 @@ module Html2rss
|
|
|
35
35
|
|
|
36
36
|
# @return [Boolean] true when any captured body contains article-like arrays
|
|
37
37
|
def extractable?
|
|
38
|
-
parsed_bodies.any?
|
|
38
|
+
parsed_bodies.any?
|
|
39
39
|
end
|
|
40
40
|
|
|
41
41
|
# @yield [Hash{Symbol => Object}] normalized article hash
|
|
@@ -117,15 +117,6 @@ module Html2rss
|
|
|
117
117
|
HEURISTIC_SCRAPERS.intersect?(tier)
|
|
118
118
|
end
|
|
119
119
|
|
|
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
120
|
##
|
|
130
121
|
# Builds a scraper when enabled; returns nil when disabled.
|
|
131
122
|
#
|
|
@@ -220,8 +211,6 @@ module Html2rss
|
|
|
220
211
|
private_class_method :app_shell_surface?
|
|
221
212
|
|
|
222
213
|
def self.high_entropy_surface?(parsed_body)
|
|
223
|
-
return false if parsed_body.nil?
|
|
224
|
-
|
|
225
214
|
parsed_body.css('body a[href]').size >= HIGH_ENTROPY_MIN_ANCHORS
|
|
226
215
|
end
|
|
227
216
|
private_class_method :high_entropy_surface?
|
|
@@ -7,12 +7,10 @@ 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|
|
|
@@ -23,7 +21,7 @@ module Html2rss
|
|
|
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
|
##
|