html2rss 0.30.0 → 0.31.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 +2 -2
- data/lib/html2rss/article/deduplicator.rb +2 -4
- data/lib/html2rss/article.rb +16 -39
- data/lib/html2rss/auto_source/README.md +1 -1
- data/lib/html2rss/auto_source/cleanup.rb +15 -6
- data/lib/html2rss/auto_source/scraper/html.rb +5 -0
- data/lib/html2rss/auto_source/scraper/json_state.rb +5 -0
- data/lib/html2rss/auto_source/scraper/meta_oembed.rb +8 -1
- data/lib/html2rss/auto_source/scraper/microdata.rb +8 -0
- data/lib/html2rss/auto_source/scraper/microformats2.rb +8 -0
- data/lib/html2rss/auto_source/scraper/native_feed.rb +3 -0
- data/lib/html2rss/auto_source/scraper/schema/thing.rb +4 -5
- data/lib/html2rss/auto_source/scraper/schema.rb +8 -0
- data/lib/html2rss/auto_source/scraper/semantic_html.rb +5 -0
- data/lib/html2rss/auto_source/scraper/sitemap.rb +3 -0
- data/lib/html2rss/auto_source/scraper/wordpress_api.rb +3 -0
- data/lib/html2rss/auto_source/scraper.rb +4 -14
- data/lib/html2rss/auto_source/segment.rb +7 -4
- data/lib/html2rss/auto_source/segmenter/cluster.rb +22 -23
- data/lib/html2rss/auto_source/segmenter/list.rb +54 -31
- data/lib/html2rss/auto_source/segmenter.rb +5 -5
- data/lib/html2rss/auto_source.rb +2 -2
- data/lib/html2rss/batch.rb +3 -1
- data/lib/html2rss/capture/README.md +6 -4
- data/lib/html2rss/capture.rb +67 -35
- data/lib/html2rss/channel.rb +3 -3
- data/lib/html2rss/cli/probe_view.rb +5 -3
- data/lib/html2rss/cli/render.rb +6 -5
- data/lib/html2rss/cli/validate.rb +28 -27
- data/lib/html2rss/cli.rb +1 -1
- data/lib/html2rss/config/issue_mapper.rb +108 -0
- data/lib/html2rss/config/request_headers.rb +2 -3
- data/lib/html2rss/config/schema.rb +16 -1
- data/lib/html2rss/config/selectors_validator.rb +92 -70
- data/lib/html2rss/config/validation_issue.rb +60 -0
- data/lib/html2rss/config/validation_report.rb +55 -0
- data/lib/html2rss/config/validator.rb +10 -6
- data/lib/html2rss/config.rb +35 -113
- data/lib/html2rss/defaults.rb +42 -30
- data/lib/html2rss/doctor/botasaurus.rb +6 -4
- data/lib/html2rss/doctor.rb +8 -0
- data/lib/html2rss/error.rb +1 -1
- data/lib/html2rss/feed_builder/rss.rb +9 -14
- data/lib/html2rss/feed_pipeline/runtime_policy.rb +14 -10
- data/lib/html2rss/feed_pipeline/strategy_plan.rb +6 -2
- data/lib/html2rss/feed_resolution/candidate_generator.rb +1 -4
- data/lib/html2rss/feed_resolution/diag.rb +3 -1
- data/lib/html2rss/feed_resolution/options.rb +3 -1
- data/lib/html2rss/feed_resolution/policy.rb +2 -2
- data/lib/html2rss/html/article_extractor/date_extractor.rb +1 -1
- data/lib/html2rss/html/article_extractor.rb +2 -4
- data/lib/html2rss/html/feed_link.rb +3 -1
- data/lib/html2rss/html/navigator/text_extractor.rb +1 -1
- data/lib/html2rss/html/navigator.rb +10 -10
- data/lib/html2rss/html/rendering/description_builder.rb +1 -1
- data/lib/html2rss/html/sst_article_extractor.rb +8 -5
- data/lib/html2rss/link_destination/destination_facts.rb +4 -2
- data/lib/html2rss/link_destination/noise_policy.rb +10 -1
- data/lib/html2rss/link_destination/path_classifier.rb +45 -38
- data/lib/html2rss/mcp/README.md +2 -0
- data/lib/html2rss/mcp/config_argument.rb +3 -1
- data/lib/html2rss/mcp/contract.rb +6 -20
- data/lib/html2rss/mcp/outcome/playbook.rb +2 -2
- data/lib/html2rss/mcp/outcome.rb +36 -43
- data/lib/html2rss/mcp/server/tools.rb +111 -131
- data/lib/html2rss/mcp/server.rb +26 -18
- data/lib/html2rss/page_recon/README.md +5 -5
- data/lib/html2rss/page_recon/diagnostics.rb +71 -81
- data/lib/html2rss/page_recon.rb +9 -5
- data/lib/html2rss/recon.rb +19 -30
- data/lib/html2rss/request_service/botasaurus_contract.rb +11 -12
- data/lib/html2rss/request_service/botasaurus_strategy.rb +1 -1
- data/lib/html2rss/request_service/compressed_body.rb +4 -1
- data/lib/html2rss/request_service/httpx_strategy.rb +39 -28
- data/lib/html2rss/request_service/network_guard.rb +7 -5
- data/lib/html2rss/request_service/response.rb +1 -2
- data/lib/html2rss/request_service/strategy.rb +3 -8
- data/lib/html2rss/scoring/container_assessor.rb +3 -1
- data/lib/html2rss/scoring/engine.rb +8 -8
- data/lib/html2rss/scoring/observation.rb +5 -3
- data/lib/html2rss/scoring/ranked_segment.rb +3 -1
- data/lib/html2rss/scrape_target.rb +3 -1
- data/lib/html2rss/selector_candidates.rb +234 -0
- data/lib/html2rss/selectors/README.md +64 -0
- data/lib/html2rss/selectors/extractors/attribute.rb +13 -15
- data/lib/html2rss/selectors/extractors/href.rb +15 -16
- data/lib/html2rss/selectors/extractors/html.rb +8 -9
- data/lib/html2rss/selectors/extractors/static.rb +12 -14
- data/lib/html2rss/selectors/extractors/text.rb +8 -9
- data/lib/html2rss/selectors/extractors.rb +33 -20
- data/lib/html2rss/selectors/item_env.rb +31 -0
- data/lib/html2rss/selectors/{object_to_xml_converter.rb → json_xml.rb} +11 -12
- data/lib/html2rss/selectors/option_spec.rb +140 -0
- data/lib/html2rss/selectors/post_processors/base.rb +79 -31
- data/lib/html2rss/selectors/post_processors/gsub.rb +103 -39
- data/lib/html2rss/selectors/post_processors/html_transformers/transform_urls_to_absolute_ones.rb +4 -4
- data/lib/html2rss/selectors/post_processors/markdown_to_html.rb +10 -11
- data/lib/html2rss/selectors/post_processors/parse_time.rb +50 -26
- data/lib/html2rss/selectors/post_processors/parse_uri.rb +11 -7
- data/lib/html2rss/selectors/post_processors/sanitize_html.rb +46 -32
- data/lib/html2rss/selectors/post_processors/substring.rb +14 -25
- data/lib/html2rss/selectors/post_processors/template.rb +23 -19
- data/lib/html2rss/selectors/post_processors.rb +6 -5
- data/lib/html2rss/selectors/{schema_doc.rb → schema_export.rb} +12 -31
- data/lib/html2rss/selectors/step_env.rb +24 -0
- data/lib/html2rss/selectors.rb +231 -142
- data/lib/html2rss/sst/attrs.rb +5 -3
- data/lib/html2rss/sst/document.rb +3 -1
- data/lib/html2rss/sst/index.rb +3 -1
- data/lib/html2rss/sst/node.rb +29 -10
- data/lib/html2rss/sst/normalizer.rb +12 -16
- data/lib/html2rss/sst/tags.rb +7 -7
- data/lib/html2rss/status.rb +13 -8
- data/lib/html2rss/surface_category.rb +14 -32
- data/lib/html2rss/syndication/parser.rb +2 -2
- data/lib/html2rss/test/enhance_audit.rb +1 -3
- data/lib/html2rss/test/policy.rb +106 -0
- data/lib/html2rss/test.rb +126 -173
- data/lib/html2rss/url.rb +25 -4
- data/lib/html2rss/version.rb +1 -1
- data/lib/html2rss.rb +21 -13
- data/lib/tasks/config_schema.rake +2 -2
- data/schema/html2rss-config.schema.json +11 -8
- metadata +15 -6
- data/lib/html2rss/selectors/item_scope.rb +0 -30
data/lib/html2rss/selectors.rb
CHANGED
|
@@ -12,18 +12,19 @@ module Html2rss
|
|
|
12
12
|
#
|
|
13
13
|
# Additionally, it uniquely offers the capability to convert JSON into XML,
|
|
14
14
|
# extending its versatility for diverse data processing workflows.
|
|
15
|
-
|
|
15
|
+
#
|
|
16
|
+
# {include:file:lib/html2rss/selectors/README.md}
|
|
17
|
+
class Selectors # rubocop:disable Metrics/ClassLength -- public API + private field dispatch
|
|
18
|
+
# Raised when a selector key is missing or not allowed for extraction.
|
|
16
19
|
class InvalidSelectorName < Html2rss::Error; end
|
|
17
20
|
|
|
18
21
|
include Enumerable
|
|
19
22
|
|
|
20
|
-
#
|
|
21
|
-
#
|
|
22
|
-
|
|
23
|
-
Context = Struct.new('Context', :options, :item, :config, :scraper, :item_scope, keyword_init: true)
|
|
24
|
-
|
|
23
|
+
# Wire spelling of the default items selector.
|
|
24
|
+
# Runtime CSS is {Html::Navigator::MAIN_ANCHOR_SELECTOR}.
|
|
25
|
+
DEFAULT_ITEMS_SELECTOR = 'a[href]'
|
|
25
26
|
# Default selectors options merged into user configuration.
|
|
26
|
-
DEFAULT_CONFIG = { items: { enhance: true } }.freeze
|
|
27
|
+
DEFAULT_CONFIG = { items: { selector: DEFAULT_ITEMS_SELECTOR, enhance: true } }.freeze
|
|
27
28
|
|
|
28
29
|
# Selector key that points to the root list of article nodes.
|
|
29
30
|
ITEMS_SELECTOR_KEY = :items
|
|
@@ -35,7 +36,12 @@ module Html2rss
|
|
|
35
36
|
# +:enclosure+ stays singular in YAML; Article stores +:enclosures+.
|
|
36
37
|
SELECTOR_TO_ARTICLE_KEY = { enclosure: :enclosures }.freeze
|
|
37
38
|
# Selector keys that may be copied onto an Article (PROVIDED_KEYS + mapped aliases).
|
|
38
|
-
SELECTABLE_SELECTOR_KEYS = (Html2rss::Article::PROVIDED_KEYS + SELECTOR_TO_ARTICLE_KEY.keys).
|
|
39
|
+
SELECTABLE_SELECTOR_KEYS = Set[*(Html2rss::Article::PROVIDED_KEYS + SELECTOR_TO_ARTICLE_KEY.keys)].freeze
|
|
40
|
+
# ArticleExtractor keys that enhance can fill. Skip the extractor when all are already present.
|
|
41
|
+
ENHANCEABLE_KEYS = %i[title url description published_at enclosures categories].freeze
|
|
42
|
+
# Per-field extraction plan compiled once from the static selector config.
|
|
43
|
+
CompiledField = Data.define(:name, :article_key, :config, :extractor_class, :post_process_steps, :special)
|
|
44
|
+
private_constant :CompiledField
|
|
39
45
|
|
|
40
46
|
##
|
|
41
47
|
# Initializes a new Selectors instance.
|
|
@@ -48,7 +54,10 @@ module Html2rss
|
|
|
48
54
|
@url = response.url
|
|
49
55
|
@selectors = selectors
|
|
50
56
|
@time_zone = time_zone
|
|
51
|
-
@rss_item_attributes = @selectors.keys.select {
|
|
57
|
+
@rss_item_attributes = @selectors.keys.select { SELECTABLE_SELECTOR_KEYS.include?(_1) }
|
|
58
|
+
@parsed_body = nil
|
|
59
|
+
@compiled_fields = compile_selector_fields
|
|
60
|
+
@compiled_item_fields = @rss_item_attributes.filter_map { @compiled_fields[_1] }
|
|
52
61
|
end
|
|
53
62
|
|
|
54
63
|
##
|
|
@@ -71,56 +80,130 @@ module Html2rss
|
|
|
71
80
|
enhance = enhance?
|
|
72
81
|
|
|
73
82
|
parsed_body.css(items_selector).each do |item|
|
|
74
|
-
article_hash = extract_article(item
|
|
83
|
+
article_hash = extract_article(item)
|
|
75
84
|
|
|
76
|
-
enhance_article_hash(article_hash, item
|
|
85
|
+
enhance_article_hash(article_hash, item) if enhance
|
|
77
86
|
|
|
78
87
|
yield Html2rss::Article.new(**article_hash, scraper: self.class)
|
|
79
88
|
end
|
|
80
89
|
end
|
|
81
90
|
|
|
82
91
|
##
|
|
83
|
-
#
|
|
84
|
-
#
|
|
85
|
-
|
|
92
|
+
# Yields +baseline+, +enhanced+, and the item node per list entry for enhance diagnostics.
|
|
93
|
+
# Consumed by {Test::EnhanceAudit} so the extraction loop has one owner.
|
|
94
|
+
#
|
|
95
|
+
# @yieldparam baseline [Hash]
|
|
96
|
+
# @yieldparam enhanced [Hash]
|
|
97
|
+
# @yieldparam item [Nokogiri::XML::Element]
|
|
98
|
+
# @return [Enumerator] if no block is given
|
|
99
|
+
def each_enhance_pair
|
|
100
|
+
return enum_for(:each_enhance_pair) unless block_given?
|
|
86
101
|
|
|
87
|
-
|
|
88
|
-
|
|
102
|
+
parsed_body.css(items_selector).each do |item|
|
|
103
|
+
baseline = extract_article(item)
|
|
104
|
+
enhanced = enhance_article_hash(baseline.dup, item)
|
|
105
|
+
yield baseline, enhanced, item
|
|
106
|
+
end
|
|
107
|
+
end
|
|
89
108
|
|
|
90
109
|
##
|
|
91
|
-
#
|
|
110
|
+
# Selects the value for a given attribute from an HTML element.
|
|
92
111
|
#
|
|
93
|
-
# @param
|
|
94
|
-
# @param
|
|
95
|
-
# @
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
112
|
+
# @param name [Symbol, String] Name of the attribute.
|
|
113
|
+
# @param item [Nokogiri::XML::Element] The HTML element to process.
|
|
114
|
+
# @param base_url [String, Html2rss::Url] base URL for relative extraction values
|
|
115
|
+
# @return [Object, Array<Object>] The selected value(s).
|
|
116
|
+
# @raise [InvalidSelectorName] If the attribute name is invalid or not defined.
|
|
117
|
+
def select(name, item, base_url: @url)
|
|
118
|
+
select_in_scope(name, item_env_for(item, base_url))
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
##
|
|
122
|
+
# Selects the value for a given attribute within an existing {ItemEnv}.
|
|
123
|
+
# Used by {ItemEnv#select} so nested selects reuse one env per extraction pass.
|
|
124
|
+
#
|
|
125
|
+
# @param name [Symbol, String] Name of the attribute.
|
|
126
|
+
# @param scope [ItemEnv] Per-item extraction scope.
|
|
127
|
+
# @return [Object, Array<Object>] The selected value(s).
|
|
128
|
+
# @raise [InvalidSelectorName] If the attribute name is invalid or not defined.
|
|
129
|
+
def select_in_scope(name, scope)
|
|
130
|
+
name = name.to_sym
|
|
131
|
+
raise InvalidSelectorName, "Attribute selector '#{name}' is reserved for items." if name == ITEMS_SELECTOR_KEY
|
|
132
|
+
|
|
133
|
+
field = @compiled_fields[name]
|
|
134
|
+
return select_compiled(field, scope) if field
|
|
135
|
+
return select_field(name, scope:, config: nil) if name == :url
|
|
136
|
+
|
|
137
|
+
raise InvalidSelectorName, "Selector for '#{name}' is not defined."
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
##
|
|
141
|
+
# Resolves a selector name to +[key, config]+. Used by category field dispatch.
|
|
142
|
+
#
|
|
143
|
+
# @param name [Symbol, String]
|
|
144
|
+
# @param allow_nil [Boolean]
|
|
145
|
+
# @return [Array(Symbol, Hash, nil)]
|
|
146
|
+
def selector_config_for(name, allow_nil: false)
|
|
147
|
+
selector_key = name.to_sym
|
|
148
|
+
|
|
149
|
+
return [selector_key, @selectors[selector_key]] if @selectors.key?(selector_key)
|
|
150
|
+
return [selector_key, nil] if allow_nil
|
|
151
|
+
|
|
152
|
+
raise InvalidSelectorName, "Selector for '#{selector_key}' is not defined."
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
private
|
|
156
|
+
|
|
157
|
+
attr_reader :response
|
|
158
|
+
|
|
159
|
+
def items_selector
|
|
160
|
+
selector = @selectors.dig(ITEMS_SELECTOR_KEY, :selector)
|
|
161
|
+
return Html::Navigator::MAIN_ANCHOR_SELECTOR if default_items_selector?(selector)
|
|
162
|
+
|
|
163
|
+
selector
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def default_items_selector?(selector)
|
|
167
|
+
text = selector.to_s.strip
|
|
168
|
+
text.empty? || text == DEFAULT_ITEMS_SELECTOR
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def enhance? = !!@selectors.dig(ITEMS_SELECTOR_KEY, :enhance)
|
|
172
|
+
|
|
173
|
+
def parsed_body
|
|
174
|
+
@parsed_body ||= build_parsed_body(response)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def build_parsed_body(page_response)
|
|
178
|
+
if page_response.json_response?
|
|
179
|
+
fragment = JsonXml.call(page_response.parsed_body)
|
|
180
|
+
Nokogiri::HTML5.fragment(fragment)
|
|
181
|
+
else
|
|
182
|
+
page_response.parsed_body
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def extract_article(item)
|
|
187
|
+
scope = item_env_for(item, @url)
|
|
188
|
+
hash = @compiled_item_fields.each_with_object({}) do |field, h|
|
|
189
|
+
value = select_compiled(field, scope)
|
|
100
190
|
next if value.nil?
|
|
101
191
|
|
|
102
|
-
article_key =
|
|
192
|
+
article_key = field.article_key
|
|
103
193
|
h[article_key] = article_key == :enclosures ? wrap_enclosure_value(value) : value
|
|
104
194
|
end
|
|
105
195
|
|
|
106
|
-
hash[:url] ||= default_item_url(item
|
|
196
|
+
hash[:url] ||= default_item_url(item) if anchor_element?(item)
|
|
107
197
|
hash
|
|
108
198
|
end
|
|
109
199
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
#
|
|
114
|
-
# @param article_hash [Hash] The original article hash.
|
|
115
|
-
# @param article_tag [Nokogiri::XML::Element] HTML element to extract additional info from.
|
|
116
|
-
# @param base_url [String, Html2rss::Url] base URL for normalization during enhancement
|
|
117
|
-
# @return [Hash] The enhanced article hash.
|
|
118
|
-
# rubocop:disable-next Metrics/MethodLength
|
|
119
|
-
def enhance_article_hash(article_hash, article_tag, base_url = @url)
|
|
200
|
+
def enhance_article_hash(article_hash, article_tag) # rubocop:disable Metrics/MethodLength -- merge extracted keys
|
|
201
|
+
return article_hash if enhanceable_fields_present?(article_hash)
|
|
202
|
+
|
|
120
203
|
selected_anchor = Html2rss::Html::Navigator.main_anchor_for(article_tag)
|
|
121
204
|
extracted = Html2rss::Html::ArticleExtractor.call(
|
|
122
205
|
article_tag,
|
|
123
|
-
base_url
|
|
206
|
+
base_url: @url,
|
|
124
207
|
selected_anchor:,
|
|
125
208
|
fallback_anchorless: true,
|
|
126
209
|
time_zone: @time_zone
|
|
@@ -134,63 +217,77 @@ module Html2rss
|
|
|
134
217
|
end
|
|
135
218
|
end
|
|
136
219
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
#
|
|
140
|
-
# @param name [Symbol, String] Name of the attribute.
|
|
141
|
-
# @param item [Nokogiri::XML::Element] The HTML element to process.
|
|
142
|
-
# @param base_url [String, Html2rss::Url] base URL for relative extraction values
|
|
143
|
-
# @return [Object, Array<Object>] The selected value(s).
|
|
144
|
-
# @raise [InvalidSelectorName] If the attribute name is invalid or not defined.
|
|
145
|
-
def select(name, item, base_url: @url)
|
|
146
|
-
select_in_scope(name, item_scope_for(item, base_url))
|
|
220
|
+
def enhanceable_fields_present?(article_hash)
|
|
221
|
+
ENHANCEABLE_KEYS.all? { |key| article_hash[key] }
|
|
147
222
|
end
|
|
148
223
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
#
|
|
153
|
-
# @param name [Symbol, String] Name of the attribute.
|
|
154
|
-
# @param scope [ItemScope] Per-item extraction scope.
|
|
155
|
-
# @return [Object, Array<Object>] The selected value(s).
|
|
156
|
-
# @raise [InvalidSelectorName] If the attribute name is invalid or not defined.
|
|
157
|
-
def select_in_scope(name, scope)
|
|
158
|
-
name = name.to_sym
|
|
159
|
-
|
|
160
|
-
raise InvalidSelectorName, "Attribute selector '#{name}' is reserved for items." if name == ITEMS_SELECTOR_KEY
|
|
224
|
+
def wrap_enclosure_value(value)
|
|
225
|
+
value.is_a?(Array) ? value : [value]
|
|
226
|
+
end
|
|
161
227
|
|
|
162
|
-
|
|
228
|
+
def select_field(selector_key, scope:, config:)
|
|
163
229
|
return default_item_url(scope.item, scope.base_url) if fallback_url_selector?(selector_key, config, scope.item)
|
|
164
230
|
raise InvalidSelectorName, "Selector for '#{selector_key}' is not defined." if config.nil?
|
|
165
231
|
|
|
166
|
-
|
|
232
|
+
if SPECIAL_ATTRIBUTES.member?(selector_key)
|
|
233
|
+
select_special(selector_key, scope:, config:)
|
|
234
|
+
else
|
|
235
|
+
select_regular(selector_key, scope:, config:)
|
|
236
|
+
end
|
|
167
237
|
end
|
|
168
238
|
|
|
169
|
-
|
|
239
|
+
def select_compiled(field, scope)
|
|
240
|
+
if fallback_url_selector?(field.name, field.config, scope.item)
|
|
241
|
+
return default_item_url(scope.item, scope.base_url)
|
|
242
|
+
end
|
|
243
|
+
raise InvalidSelectorName, "Selector for '#{field.name}' is not defined." if field.config.nil?
|
|
170
244
|
|
|
171
|
-
|
|
245
|
+
if field.special
|
|
246
|
+
select_special(field.name, scope:, config: field.config)
|
|
247
|
+
else
|
|
248
|
+
select_regular_compiled(field, scope)
|
|
249
|
+
end
|
|
250
|
+
end
|
|
172
251
|
|
|
173
|
-
def
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
252
|
+
def compile_selector_fields
|
|
253
|
+
@selectors.each_key.with_object({}) do |name, acc|
|
|
254
|
+
next if name == ITEMS_SELECTOR_KEY
|
|
255
|
+
|
|
256
|
+
acc[name] = compile_field(name)
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
def compile_field(name)
|
|
261
|
+
config = @selectors[name]
|
|
262
|
+
CompiledField.new(
|
|
263
|
+
name:,
|
|
264
|
+
article_key: SELECTOR_TO_ARTICLE_KEY.fetch(name, name),
|
|
265
|
+
config:,
|
|
266
|
+
extractor_class: extractor_class_for(config),
|
|
267
|
+
post_process_steps: compile_post_process_steps(config),
|
|
268
|
+
special: SPECIAL_ATTRIBUTES.member?(name)
|
|
179
269
|
)
|
|
180
270
|
end
|
|
181
271
|
|
|
182
|
-
def
|
|
183
|
-
|
|
272
|
+
def extractor_class_for(config)
|
|
273
|
+
return unless config.is_a?(Hash)
|
|
274
|
+
|
|
275
|
+
Extractors::NAME_TO_CLASS[config[:extractor]&.to_sym || Extractors::DEFAULT_EXTRACTOR]
|
|
184
276
|
end
|
|
185
277
|
|
|
186
|
-
def
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
278
|
+
def compile_post_process_steps(config)
|
|
279
|
+
return unless config.is_a?(Hash) && config[:post_process]
|
|
280
|
+
|
|
281
|
+
steps = config[:post_process]
|
|
282
|
+
(steps.is_a?(Array) ? steps : [steps]).map { |step| compile_post_process_step(step) }
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def compile_post_process_step(step)
|
|
286
|
+
klass = PostProcessors::NAME_TO_CLASS[step[:name].to_sym]
|
|
287
|
+
raise PostProcessors::UnknownPostProcessorName, "Unknown name '#{step[:name]}'" unless klass
|
|
288
|
+
|
|
289
|
+
klass.validate_options!(StepEnv.new(step_config: step, time_zone: @time_zone))
|
|
290
|
+
[klass, step]
|
|
194
291
|
end
|
|
195
292
|
|
|
196
293
|
def select_special(name, scope:, config:)
|
|
@@ -205,27 +302,55 @@ module Html2rss
|
|
|
205
302
|
end
|
|
206
303
|
|
|
207
304
|
def select_regular(_name, scope:, config:)
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
305
|
+
value = Extractors.call(config, scope.item, base_url: scope.base_url)
|
|
306
|
+
|
|
307
|
+
apply_post_process_steps(scope:, value:, post_process_steps: config[:post_process])
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
def select_regular_compiled(field, scope)
|
|
311
|
+
value = field.extractor_class.new(
|
|
312
|
+
scope.item,
|
|
313
|
+
selector: field.config[:selector],
|
|
314
|
+
attribute: field.config[:attribute],
|
|
315
|
+
static: field.config[:static],
|
|
316
|
+
base_url: scope.base_url
|
|
317
|
+
).call
|
|
318
|
+
|
|
319
|
+
apply_compiled_post_process(scope, value, field.post_process_steps)
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
def apply_post_process_steps(scope:, value:, post_process_steps:)
|
|
323
|
+
return value unless value && post_process_steps
|
|
324
|
+
|
|
325
|
+
steps = post_process_steps.is_a?(Array) ? post_process_steps : [post_process_steps]
|
|
326
|
+
post_process(scope, value, steps)
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
def post_process(scope, value, post_process_steps)
|
|
330
|
+
post_process_steps.each do |step_config|
|
|
331
|
+
value = PostProcessors.call(step_config[:name], value, scope.context_for(step_config:))
|
|
216
332
|
end
|
|
217
333
|
|
|
218
334
|
value
|
|
219
335
|
end
|
|
220
336
|
|
|
221
|
-
def
|
|
222
|
-
|
|
223
|
-
|
|
337
|
+
def apply_compiled_post_process(scope, value, steps)
|
|
338
|
+
return value unless value && steps
|
|
339
|
+
|
|
340
|
+
steps.each do |klass, step_config|
|
|
341
|
+
value = klass.new(value, scope.context_for(step_config:)).call
|
|
224
342
|
end
|
|
225
343
|
|
|
226
344
|
value
|
|
227
345
|
end
|
|
228
346
|
|
|
347
|
+
# Flattens configured category selector names into discrete category strings.
|
|
348
|
+
# When a selector matches multiple nodes, each node is extracted independently
|
|
349
|
+
# (with optional post-processors) so multi-tag UIs become multiple categories.
|
|
350
|
+
#
|
|
351
|
+
# @param category_selectors [Array, String, Symbol]
|
|
352
|
+
# @param scope [ItemEnv]
|
|
353
|
+
# @return [Array<String>]
|
|
229
354
|
def select_categories(category_selectors:, scope:)
|
|
230
355
|
Array(category_selectors).flat_map do |selector_name|
|
|
231
356
|
extract_category_values(selector_name, scope:)
|
|
@@ -233,17 +358,17 @@ module Html2rss
|
|
|
233
358
|
end
|
|
234
359
|
|
|
235
360
|
def extract_category_values(selector_name, scope:)
|
|
236
|
-
|
|
361
|
+
_selector_key, config = selector_config_for(selector_name, allow_nil: true)
|
|
237
362
|
return [] unless config
|
|
238
363
|
|
|
239
364
|
nodes = extract_nodes(item: scope.item, config:)
|
|
240
|
-
return Array(
|
|
365
|
+
return Array(scope.select(selector_name)) unless node_set_with_multiple_elements?(nodes)
|
|
241
366
|
|
|
242
367
|
Array(nodes).flat_map { |node| extract_categories_from_node(node, scope:, config:) }
|
|
243
368
|
end
|
|
244
369
|
|
|
245
370
|
def extract_categories_from_node(node, scope:, config:)
|
|
246
|
-
values = Extractors.
|
|
371
|
+
values = Extractors.call(config, node, base_url: scope.base_url, selector: nil)
|
|
247
372
|
values = apply_post_process_steps(scope:, value: values, post_process_steps: config[:post_process])
|
|
248
373
|
|
|
249
374
|
Array(values).filter_map { |category| extract_category_text(category) }
|
|
@@ -265,49 +390,12 @@ module Html2rss
|
|
|
265
390
|
nodes.is_a?(Nokogiri::XML::NodeSet) && nodes.length > 1
|
|
266
391
|
end
|
|
267
392
|
|
|
268
|
-
def category_node_options(selector_config, scope:)
|
|
269
|
-
@category_node_configs ||= {}
|
|
270
|
-
@category_node_configs[[selector_config.object_id, scope.base_url]] ||= selector_config.merge(
|
|
271
|
-
channel: scope.channel,
|
|
272
|
-
selector: nil
|
|
273
|
-
).freeze
|
|
274
|
-
end
|
|
275
|
-
|
|
276
|
-
def apply_post_process_steps(scope:, value:, post_process_steps:)
|
|
277
|
-
return value unless value && post_process_steps
|
|
278
|
-
|
|
279
|
-
steps = post_process_steps.is_a?(Array) ? post_process_steps : [post_process_steps]
|
|
280
|
-
post_process(scope, value, steps)
|
|
281
|
-
end
|
|
282
|
-
|
|
283
|
-
def selector_config_for(name, allow_nil: false)
|
|
284
|
-
selector_key = name.to_sym
|
|
285
|
-
|
|
286
|
-
return [selector_key, @selectors[selector_key]] if @selectors.key?(selector_key)
|
|
287
|
-
return [selector_key, nil] if allow_nil
|
|
288
|
-
|
|
289
|
-
raise InvalidSelectorName, "Selector for '#{selector_key}' is not defined."
|
|
290
|
-
end
|
|
291
|
-
|
|
292
393
|
def extract_nodes(item:, config:)
|
|
293
|
-
return unless config.
|
|
394
|
+
return unless config.is_a?(Hash) && config[:selector]
|
|
294
395
|
|
|
295
396
|
Extractors.element(item, config[:selector])
|
|
296
397
|
end
|
|
297
398
|
|
|
298
|
-
def channel_context(base_url)
|
|
299
|
-
@channel_contexts ||= {}
|
|
300
|
-
@channel_contexts[base_url] ||= { url: base_url, time_zone: @time_zone }.freeze
|
|
301
|
-
end
|
|
302
|
-
|
|
303
|
-
# Keep a single enclosure Hash as one list entry; +Array(hash)+ would split pairs.
|
|
304
|
-
#
|
|
305
|
-
# @param value [Hash, Array] enclosure hash or list of hashes
|
|
306
|
-
# @return [Array]
|
|
307
|
-
def wrap_enclosure_value(value)
|
|
308
|
-
value.is_a?(Array) ? value : [value]
|
|
309
|
-
end
|
|
310
|
-
|
|
311
399
|
# @return [Hash, nil] enclosure details, or nil when the selector yields nothing.
|
|
312
400
|
def enclosure(scope:, config:)
|
|
313
401
|
selected = select_regular(:enclosure, scope:, config:)
|
|
@@ -318,27 +406,28 @@ module Html2rss
|
|
|
318
406
|
{ url:, type: config[:content_type] }
|
|
319
407
|
end
|
|
320
408
|
|
|
321
|
-
def anchor_element?(item)
|
|
322
|
-
item.respond_to?(:name) && item.name.to_s.casecmp('a').zero?
|
|
323
|
-
end
|
|
324
|
-
|
|
325
409
|
def fallback_url_selector?(selector_key, config, item)
|
|
326
410
|
selector_key == :url && config.nil? && anchor_element?(item)
|
|
327
411
|
end
|
|
328
412
|
|
|
329
|
-
def
|
|
330
|
-
|
|
331
|
-
select_special(selector_key, scope:, config:)
|
|
332
|
-
else
|
|
333
|
-
select_regular(selector_key, scope:, config:)
|
|
334
|
-
end
|
|
413
|
+
def anchor_element?(item)
|
|
414
|
+
item.name.to_s.casecmp('a').zero?
|
|
335
415
|
end
|
|
336
416
|
|
|
337
|
-
def default_item_url(item, base_url)
|
|
417
|
+
def default_item_url(item, base_url = @url)
|
|
338
418
|
href = item['href'].to_s.strip
|
|
339
419
|
return if href.empty?
|
|
340
420
|
|
|
341
421
|
Url.from_relative(href, base_url)
|
|
342
422
|
end
|
|
423
|
+
|
|
424
|
+
def item_env_for(item, base_url)
|
|
425
|
+
ItemEnv.new(
|
|
426
|
+
item:,
|
|
427
|
+
base_url:,
|
|
428
|
+
scraper: self,
|
|
429
|
+
time_zone: @time_zone
|
|
430
|
+
)
|
|
431
|
+
end
|
|
343
432
|
end
|
|
344
433
|
end
|
data/lib/html2rss/sst/attrs.rb
CHANGED
|
@@ -11,11 +11,13 @@ module Html2rss
|
|
|
11
11
|
EMPTY_RAW = {}.freeze
|
|
12
12
|
end
|
|
13
13
|
|
|
14
|
-
##
|
|
15
|
-
# Typed HTML attributes for an SST node. Absent fields are +nil+.
|
|
16
14
|
Attrs = Data.define(
|
|
17
15
|
:href, :src, :id, :class_names, :datetime, :itemprop, :style, :srcset, :type, :raw
|
|
18
|
-
)
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
##
|
|
19
|
+
# Typed HTML attributes for an SST node. Absent fields are +nil+.
|
|
20
|
+
class Attrs
|
|
19
21
|
class << self
|
|
20
22
|
##
|
|
21
23
|
# @param href [String, nil]
|
|
@@ -2,9 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
module Html2rss
|
|
4
4
|
module SST
|
|
5
|
+
Document = Data.define(:root, :index, :degraded, :node_count)
|
|
6
|
+
|
|
5
7
|
##
|
|
6
8
|
# Immutable SST document: root node plus relationship index.
|
|
7
|
-
Document
|
|
9
|
+
class Document
|
|
8
10
|
##
|
|
9
11
|
# @param root [Node]
|
|
10
12
|
# @param index [Index]
|
data/lib/html2rss/sst/index.rb
CHANGED
|
@@ -63,7 +63,9 @@ module Html2rss
|
|
|
63
63
|
def memo_word_count(node)
|
|
64
64
|
return @word_count[node] if @word_count.key?(node)
|
|
65
65
|
|
|
66
|
-
|
|
66
|
+
count = 0
|
|
67
|
+
memo_visible_text(node).to_s.scan(/\p{Alnum}+/) { count += 1 }
|
|
68
|
+
@word_count[node] = count
|
|
67
69
|
end
|
|
68
70
|
|
|
69
71
|
##
|
data/lib/html2rss/sst/node.rb
CHANGED
|
@@ -2,10 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
module Html2rss
|
|
4
4
|
module SST
|
|
5
|
+
Node = Data.define(:name, :attrs, :own_text, :children, :tag_path)
|
|
6
|
+
|
|
5
7
|
##
|
|
6
8
|
# Immutable simplified semantic tree node. Predicates and traversal live on
|
|
7
9
|
# the type so Scoring/Segmenter never touch Nokogiri.
|
|
8
|
-
Node
|
|
10
|
+
class Node
|
|
9
11
|
##
|
|
10
12
|
# @param name [Symbol, String]
|
|
11
13
|
# @param attrs [Attrs, nil]
|
|
@@ -86,11 +88,26 @@ module Html2rss
|
|
|
86
88
|
#
|
|
87
89
|
# @yieldparam node [Node]
|
|
88
90
|
# @return [Enumerator, Array<Node>]
|
|
89
|
-
def each_node(&
|
|
90
|
-
return enum_for(:each_node) unless
|
|
91
|
+
def each_node(&)
|
|
92
|
+
return enum_for(:each_node) unless block_given?
|
|
91
93
|
|
|
92
94
|
yield self
|
|
93
|
-
children.each { |child| child.each_node(&
|
|
95
|
+
children.each { |child| child.each_node(&) }
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
##
|
|
99
|
+
# Non-allocating traversal that counts descendants matching the block.
|
|
100
|
+
# When no block is given, counts all descendants.
|
|
101
|
+
#
|
|
102
|
+
# @yieldparam node [Node]
|
|
103
|
+
# @return [Integer]
|
|
104
|
+
def count_descendants(&)
|
|
105
|
+
total = 0
|
|
106
|
+
children.each do |child|
|
|
107
|
+
total += 1 if !block_given? || yield(child)
|
|
108
|
+
total += child.count_descendants(&)
|
|
109
|
+
end
|
|
110
|
+
total
|
|
94
111
|
end
|
|
95
112
|
|
|
96
113
|
##
|
|
@@ -101,14 +118,14 @@ module Html2rss
|
|
|
101
118
|
|
|
102
119
|
##
|
|
103
120
|
# @return [Array<Node>]
|
|
104
|
-
def find_all(&
|
|
105
|
-
each_node.select(&
|
|
121
|
+
def find_all(&)
|
|
122
|
+
each_node.select(&)
|
|
106
123
|
end
|
|
107
124
|
|
|
108
125
|
##
|
|
109
126
|
# @return [Node, nil]
|
|
110
|
-
def find(&
|
|
111
|
-
each_node.find(&
|
|
127
|
+
def find(&)
|
|
128
|
+
each_node.find(&)
|
|
112
129
|
end
|
|
113
130
|
|
|
114
131
|
##
|
|
@@ -132,14 +149,16 @@ module Html2rss
|
|
|
132
149
|
return index.memo_word_count(self)
|
|
133
150
|
end
|
|
134
151
|
|
|
135
|
-
|
|
152
|
+
count = 0
|
|
153
|
+
visible_text.to_s.scan(/\p{Alnum}+/) { count += 1 }
|
|
154
|
+
count
|
|
136
155
|
end
|
|
137
156
|
|
|
138
157
|
##
|
|
139
158
|
# @return [Float] words per descendant link
|
|
140
159
|
def text_density
|
|
141
160
|
words = word_count
|
|
142
|
-
links =
|
|
161
|
+
links = count_descendants(&:link?)
|
|
143
162
|
links.zero? ? words.to_f : words.to_f / links
|
|
144
163
|
end
|
|
145
164
|
end
|