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/capture.rb
CHANGED
|
@@ -89,6 +89,8 @@ module Html2rss
|
|
|
89
89
|
# @option options [Boolean, nil] :enhance whether to force enhance: true
|
|
90
90
|
# @option options [Integer, nil] :max_redirects optional redirect limit override
|
|
91
91
|
# @option options [Integer, nil] :max_requests optional request budget override
|
|
92
|
+
# @option options [Integer, nil] :total_timeout_seconds optional wall-clock bound (seconds) for the whole
|
|
93
|
+
# request chain, including every +:auto+ fallback hop (shared budget, not per hop)
|
|
92
94
|
# @option options [Integer, nil] :limit max articles to keep
|
|
93
95
|
# @option options [String, nil] :local_file_path optional local HTML file path
|
|
94
96
|
# @return [CaptureResult]
|
|
@@ -108,6 +110,8 @@ module Html2rss
|
|
|
108
110
|
# @option options [Boolean, nil] :enhance whether to force enhance: true
|
|
109
111
|
# @option options [Integer, nil] :max_redirects optional redirect limit override
|
|
110
112
|
# @option options [Integer, nil] :max_requests optional request budget override
|
|
113
|
+
# @option options [Integer, nil] :total_timeout_seconds optional wall-clock bound (seconds) for the whole
|
|
114
|
+
# request chain, including every +:auto+ fallback hop (shared budget, not per hop)
|
|
111
115
|
# @option options [Integer, nil] :limit max articles to keep
|
|
112
116
|
# @option options [String, nil] :local_file_path optional local HTML file path
|
|
113
117
|
def initialize(url, strategy: :auto, **options) # rubocop:disable Metrics/MethodLength
|
|
@@ -119,8 +123,7 @@ module Html2rss
|
|
|
119
123
|
@summary = options.delete(:summary)
|
|
120
124
|
@force = options.delete(:force) || false
|
|
121
125
|
@enhance_option = options.delete(:enhance)
|
|
122
|
-
@
|
|
123
|
-
@max_requests = options.delete(:max_requests)
|
|
126
|
+
@request_options = options.slice(:max_redirects, :max_requests, :total_timeout_seconds)
|
|
124
127
|
@limit = options.delete(:limit)
|
|
125
128
|
@local_file_path = options.delete(:local_file_path)
|
|
126
129
|
end
|
|
@@ -147,7 +150,6 @@ module Html2rss
|
|
|
147
150
|
|
|
148
151
|
CaptureResult.new(
|
|
149
152
|
config:,
|
|
150
|
-
yaml: "#{SCHEMA_MODELINE}\n#{Config.to_yaml(config)}",
|
|
151
153
|
articles_count: outcome.articles.size,
|
|
152
154
|
channel_title: ch_title,
|
|
153
155
|
has_selectors: !selectors.empty?,
|
|
@@ -173,15 +175,10 @@ module Html2rss
|
|
|
173
175
|
end
|
|
174
176
|
|
|
175
177
|
def probe_native_feed(response)
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
request_session: discovery_session,
|
|
181
|
-
parsed_body: (response.parsed_body if response.html_response?),
|
|
182
|
-
html: response.body
|
|
183
|
-
)&.to_s
|
|
184
|
-
rescue StandardError
|
|
178
|
+
Syndication::Discovery.native_feed_url(response:, request_session: discovery_session)&.to_s
|
|
179
|
+
rescue Html2rss::ContractError
|
|
180
|
+
raise
|
|
181
|
+
rescue Html2rss::Error
|
|
185
182
|
nil
|
|
186
183
|
end
|
|
187
184
|
|
|
@@ -237,11 +234,7 @@ module Html2rss
|
|
|
237
234
|
Config.auto_source_config(
|
|
238
235
|
url: @url,
|
|
239
236
|
items_selector: @items_selector_hint,
|
|
240
|
-
request_controls: Config::RequestControls.from_shortcut(
|
|
241
|
-
strategy: @strategy,
|
|
242
|
-
max_redirects: @max_redirects,
|
|
243
|
-
max_requests: @max_requests
|
|
244
|
-
),
|
|
237
|
+
request_controls: Config::RequestControls.from_shortcut(strategy: @strategy, **@request_options),
|
|
245
238
|
limit: @limit
|
|
246
239
|
).tap { |config| apply_local_file_path!(config) }
|
|
247
240
|
end
|
|
@@ -270,9 +263,6 @@ module Html2rss
|
|
|
270
263
|
return [*default_selectors, SelectorCandidates.empty] unless sst
|
|
271
264
|
|
|
272
265
|
select_enhance_selectors(sst, articles)
|
|
273
|
-
rescue ArgumentError => error
|
|
274
|
-
Log.warn("Capture selector derivation failed: #{error.message}")
|
|
275
|
-
[*default_selectors, SelectorCandidates.empty]
|
|
276
266
|
end
|
|
277
267
|
|
|
278
268
|
def hint_selectors
|
|
@@ -300,7 +290,7 @@ module Html2rss
|
|
|
300
290
|
|
|
301
291
|
SEGMENT_STRATEGIES.flat_map do |strategy|
|
|
302
292
|
segments = AutoSource::Segmenter.call(
|
|
303
|
-
sst, base_url: @url, strategy:,
|
|
293
|
+
sst, base_url: @url, strategy:, link_resolver:
|
|
304
294
|
)
|
|
305
295
|
matched = match_segments_to_articles(segments, articles)
|
|
306
296
|
next [] if matched.size < MIN_SELECTOR_MATCHES
|
|
@@ -338,42 +328,34 @@ module Html2rss
|
|
|
338
328
|
end
|
|
339
329
|
end
|
|
340
330
|
|
|
341
|
-
def find_matching_article(segment, articles_by_url)
|
|
342
|
-
|
|
343
|
-
return articles_by_url.values.find { |a| a.title == title_from_segment(segment) } if href.empty?
|
|
344
|
-
|
|
345
|
-
resolved = Url.from_relative(href, @url)
|
|
331
|
+
def find_matching_article(segment, articles_by_url)
|
|
332
|
+
resolved = Url.from_relative(segment.primary_link.attrs.href.to_s, @url)
|
|
346
333
|
articles_by_url[resolved.to_s] || articles_by_url.values.find { |a| fuzzy_url_match?(a.url, resolved) }
|
|
347
334
|
end
|
|
348
335
|
|
|
349
336
|
def fuzzy_url_match?(article_url, resolved)
|
|
350
337
|
article_url.to_s.end_with?(resolved.path.to_s)
|
|
351
|
-
rescue StandardError
|
|
352
|
-
false
|
|
353
|
-
end
|
|
354
|
-
|
|
355
|
-
def title_from_segment(segment)
|
|
356
|
-
segment.root_node.visible_text.to_s.strip
|
|
357
338
|
end
|
|
358
339
|
|
|
359
340
|
def lift_heading_link_roots(roots)
|
|
360
|
-
|
|
341
|
+
enclosed = enclosed_root_counts(roots)
|
|
342
|
+
roots.map { |root| lift_heading_link_root(root, enclosed) }
|
|
361
343
|
end
|
|
362
344
|
|
|
363
|
-
def lift_heading_link_root(root,
|
|
345
|
+
def lift_heading_link_root(root, enclosed)
|
|
364
346
|
return root unless heading_or_inner_title_link?(root)
|
|
365
347
|
|
|
366
348
|
index = SST::Index.for_node(root)
|
|
367
349
|
return root unless index
|
|
368
350
|
|
|
369
|
-
walk_usable_card(root, index,
|
|
351
|
+
walk_usable_card(root, index, enclosed)
|
|
370
352
|
end
|
|
371
353
|
|
|
372
|
-
def walk_usable_card(root, index,
|
|
354
|
+
def walk_usable_card(root, index, enclosed)
|
|
373
355
|
candidate = root
|
|
374
356
|
parent = index.parent_of(root)
|
|
375
|
-
while
|
|
376
|
-
break if contains_other_root?(parent,
|
|
357
|
+
while Html::Navigator.usable_card_parent?(parent)
|
|
358
|
+
break if contains_other_root?(parent, enclosed)
|
|
377
359
|
|
|
378
360
|
candidate = parent
|
|
379
361
|
parent = index.parent_of(parent)
|
|
@@ -381,6 +363,28 @@ module Html2rss
|
|
|
381
363
|
candidate
|
|
382
364
|
end
|
|
383
365
|
|
|
366
|
+
# Identity-keyed tally of how many distinct roots each node is, or strictly encloses, so the
|
|
367
|
+
# card walk answers "does this ancestor hold another root?" without rescanning all roots.
|
|
368
|
+
def enclosed_root_counts(roots)
|
|
369
|
+
tally = Hash.new(0).compare_by_identity
|
|
370
|
+
roots.uniq(&:object_id).each do |root|
|
|
371
|
+
tally[root] += 1
|
|
372
|
+
count_enclosing_ancestors(root, tally)
|
|
373
|
+
end
|
|
374
|
+
tally
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
def count_enclosing_ancestors(root, tally)
|
|
378
|
+
index = SST::Index.for_node(root)
|
|
379
|
+
return unless index
|
|
380
|
+
|
|
381
|
+
ancestor = index.parent_of(root)
|
|
382
|
+
while ancestor
|
|
383
|
+
tally[ancestor] += 1
|
|
384
|
+
ancestor = index.parent_of(ancestor)
|
|
385
|
+
end
|
|
386
|
+
end
|
|
387
|
+
|
|
384
388
|
def heading_or_inner_title_link?(node)
|
|
385
389
|
name = node.name.to_s
|
|
386
390
|
return false if wrapping_anchor_root?(node)
|
|
@@ -395,16 +399,9 @@ module Html2rss
|
|
|
395
399
|
node.find { |child| !child.equal?(node) && tags.include?(child.name.to_s) }
|
|
396
400
|
end
|
|
397
401
|
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
other.equal?(parent) || sst_descendant?(other, parent)
|
|
403
|
-
end
|
|
404
|
-
end
|
|
405
|
-
|
|
406
|
-
def sst_descendant?(child, ancestor)
|
|
407
|
-
SST::Index.for_node(child)&.descendant_of?(child, ancestor)
|
|
402
|
+
# The walked-from root is itself tallied under every ancestor, so more than one means another root.
|
|
403
|
+
def contains_other_root?(parent, enclosed)
|
|
404
|
+
enclosed[parent] > 1
|
|
408
405
|
end
|
|
409
406
|
|
|
410
407
|
def shared_class_items_selector(roots)
|
|
@@ -420,12 +417,7 @@ module Html2rss
|
|
|
420
417
|
end
|
|
421
418
|
|
|
422
419
|
def path_items_selector(roots)
|
|
423
|
-
|
|
424
|
-
common = common_path_prefix(paths)
|
|
425
|
-
tag_path = common.empty? ? paths.first.to_s : common
|
|
426
|
-
return if tag_path.empty?
|
|
427
|
-
|
|
428
|
-
css_from_trimmed_tag_path(tag_path)
|
|
420
|
+
css_from_trimmed_tag_path(common_path_prefix(roots.map(&:tag_path)))
|
|
429
421
|
end
|
|
430
422
|
|
|
431
423
|
def css_from_trimmed_tag_path(tag_path)
|
|
@@ -443,9 +435,7 @@ module Html2rss
|
|
|
443
435
|
trimmed.empty? ? [segments.last] : trimmed
|
|
444
436
|
end
|
|
445
437
|
|
|
446
|
-
def common_path_prefix(paths)
|
|
447
|
-
return '' if paths.empty?
|
|
448
|
-
|
|
438
|
+
def common_path_prefix(paths)
|
|
449
439
|
segments = paths.map { |p| p.split('/').reject(&:empty?) }
|
|
450
440
|
prefix = segments.first
|
|
451
441
|
segments.each do |seg|
|
|
@@ -466,7 +456,9 @@ module Html2rss
|
|
|
466
456
|
|
|
467
457
|
def channel_title_from(response)
|
|
468
458
|
Channel.from_response(response).title
|
|
469
|
-
rescue
|
|
459
|
+
rescue Html2rss::ContractError
|
|
460
|
+
raise
|
|
461
|
+
rescue Html2rss::Error
|
|
470
462
|
nil
|
|
471
463
|
end
|
|
472
464
|
|
|
@@ -486,7 +478,7 @@ module Html2rss
|
|
|
486
478
|
return final unless Url.from_absolute(@url).same_document?(Url.from_absolute(final))
|
|
487
479
|
|
|
488
480
|
nil
|
|
489
|
-
rescue
|
|
481
|
+
rescue Html2rss::Url::InvalidUrlError
|
|
490
482
|
nil
|
|
491
483
|
end
|
|
492
484
|
end
|
data/lib/html2rss/channel.rb
CHANGED
|
@@ -3,7 +3,9 @@
|
|
|
3
3
|
require 'time'
|
|
4
4
|
|
|
5
5
|
module Html2rss
|
|
6
|
-
Channel = Data.define(
|
|
6
|
+
Channel = Data.define(
|
|
7
|
+
:title, :url, :description, :language, :ttl, :last_build_date, :image, :author, :categories, :explicit
|
|
8
|
+
)
|
|
7
9
|
|
|
8
10
|
##
|
|
9
11
|
# Materialized channel metadata for feed builders and {FeedResult}.
|
|
@@ -12,6 +14,10 @@ module Html2rss
|
|
|
12
14
|
# do not own channel extraction. Safe to Marshal (no Response / Nokogiri retained).
|
|
13
15
|
# Strings and image URL are normalized and frozen at construction (including Marshal load).
|
|
14
16
|
#
|
|
17
|
+
# The author is the config +channel.author+, else the followed native feed's author
|
|
18
|
+
# ({Syndication::ChannelFacts}), else the page's +<meta name="author">+. Categories and the explicit
|
|
19
|
+
# flag come only from that feed, when it states them.
|
|
20
|
+
#
|
|
15
21
|
# @!attribute [r] title
|
|
16
22
|
# @return [String]
|
|
17
23
|
# @!attribute [r] url
|
|
@@ -28,6 +34,10 @@ module Html2rss
|
|
|
28
34
|
# @return [Html2rss::Url, nil]
|
|
29
35
|
# @!attribute [r] author
|
|
30
36
|
# @return [String, nil]
|
|
37
|
+
# @!attribute [r] categories
|
|
38
|
+
# @return [Array<Array<String>>] each category followed by its subcategories (empty when unstated)
|
|
39
|
+
# @!attribute [r] explicit
|
|
40
|
+
# @return [Boolean, nil] whether the content is declared explicit; nil when unstated
|
|
31
41
|
class Channel # rubocop:disable Metrics/ClassLength -- Channel value object owns extract + freeze + Marshal
|
|
32
42
|
class << self
|
|
33
43
|
##
|
|
@@ -35,8 +45,9 @@ module Html2rss
|
|
|
35
45
|
#
|
|
36
46
|
# @param response [Html2rss::RequestService::Response]
|
|
37
47
|
# @param overrides [Hash{Symbol => Object}] optional overrides for channel attributes
|
|
48
|
+
# @param feed [Html2rss::Syndication::ChannelFacts, nil] what the followed native feed states
|
|
38
49
|
# @return [Html2rss::Channel]
|
|
39
|
-
def from_response(response, overrides: {})
|
|
50
|
+
def from_response(response, overrides: {}, feed: nil) # rubocop:disable Metrics/MethodLength -- one line per member
|
|
40
51
|
new(
|
|
41
52
|
title: title_for(response, overrides),
|
|
42
53
|
url: url_for(response),
|
|
@@ -45,7 +56,9 @@ module Html2rss
|
|
|
45
56
|
ttl: ttl_for(response, overrides),
|
|
46
57
|
last_build_date: last_build_date_for(response),
|
|
47
58
|
image: image_for(response, overrides),
|
|
48
|
-
author:
|
|
59
|
+
author: overrides[:author] || feed&.author || meta_author(response),
|
|
60
|
+
categories: feed&.categories || [],
|
|
61
|
+
explicit: feed&.explicit
|
|
49
62
|
)
|
|
50
63
|
end
|
|
51
64
|
|
|
@@ -117,8 +130,7 @@ module Html2rss
|
|
|
117
130
|
parsed_body(response)['lang'] || parsed_body(response).at_css('[lang]')&.[]('lang')
|
|
118
131
|
end
|
|
119
132
|
|
|
120
|
-
def
|
|
121
|
-
return overrides[:author] if overrides[:author]
|
|
133
|
+
def meta_author(response)
|
|
122
134
|
return unless html_response?(response)
|
|
123
135
|
|
|
124
136
|
parsed_body(response).at_css('meta[name="author"]')&.[]('content')
|
|
@@ -156,8 +168,11 @@ module Html2rss
|
|
|
156
168
|
# @param last_build_date [Time, String]
|
|
157
169
|
# @param image [Html2rss::Url, String, nil]
|
|
158
170
|
# @param author [String, nil]
|
|
159
|
-
#
|
|
160
|
-
|
|
171
|
+
# @param categories [Array<Array<String>>] each category followed by its subcategories
|
|
172
|
+
# @param explicit [Boolean, nil]
|
|
173
|
+
# rubocop:disable-next Metrics/ParameterLists, Metrics/MethodLength -- Data.define members are the channel contract
|
|
174
|
+
def initialize(title:, url:, description:, language:, ttl:, last_build_date:, image:, author:,
|
|
175
|
+
categories: [], explicit: nil)
|
|
161
176
|
super(
|
|
162
177
|
title: freeze_string(title),
|
|
163
178
|
url: url.is_a?(Url) ? url : Url.from_absolute(url),
|
|
@@ -166,16 +181,19 @@ module Html2rss
|
|
|
166
181
|
ttl: Integer(ttl),
|
|
167
182
|
last_build_date: normalize_last_build_date(last_build_date),
|
|
168
183
|
image: normalize_image(image),
|
|
169
|
-
author: author && freeze_string(author)
|
|
184
|
+
author: author && freeze_string(author),
|
|
185
|
+
categories: categories.map { |path| path.map { freeze_string(_1) }.freeze }.freeze,
|
|
186
|
+
explicit:
|
|
170
187
|
)
|
|
171
188
|
end
|
|
172
189
|
|
|
173
190
|
private
|
|
174
191
|
|
|
175
|
-
def marshal_dump = [title, url, description, language, ttl, last_build_date, image, author]
|
|
192
|
+
def marshal_dump = [title, url, description, language, ttl, last_build_date, image, author, categories, explicit]
|
|
176
193
|
|
|
177
|
-
def marshal_load((title, url, description, language, ttl, last_build_date, image, author))
|
|
178
|
-
initialize(title:, url:, description:, language:, ttl:, last_build_date:, image:, author
|
|
194
|
+
def marshal_load((title, url, description, language, ttl, last_build_date, image, author, categories, explicit))
|
|
195
|
+
initialize(title:, url:, description:, language:, ttl:, last_build_date:, image:, author:, categories:,
|
|
196
|
+
explicit:)
|
|
179
197
|
end
|
|
180
198
|
|
|
181
199
|
def freeze_string(value) = value.to_s.dup.freeze
|
|
@@ -189,7 +207,7 @@ module Html2rss
|
|
|
189
207
|
|
|
190
208
|
def normalize_last_build_date(value)
|
|
191
209
|
return value if value.is_a?(Time)
|
|
192
|
-
raise
|
|
210
|
+
raise InvalidOption, 'last_build_date must be a Time or HTTP-date String' unless value.is_a?(String)
|
|
193
211
|
|
|
194
212
|
Time.httpdate(value)
|
|
195
213
|
end
|
data/lib/html2rss/cli/render.rb
CHANGED
|
@@ -10,116 +10,117 @@ module Html2rss
|
|
|
10
10
|
# ANSI colors for recon verdict labels in text output.
|
|
11
11
|
VERDICT_COLORS = { build: "\e[32m", defer: "\e[33m" }.freeze
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
13
|
+
class << self
|
|
14
|
+
##
|
|
15
|
+
# @param results [Array<Hash>]
|
|
16
|
+
# @param format [String]
|
|
17
|
+
# @param batch_mode [Boolean]
|
|
18
|
+
# @return [void]
|
|
19
|
+
def inspect_output(results, format:, batch_mode:)
|
|
20
|
+
if format == 'json'
|
|
21
|
+
json(results, batch_mode)
|
|
22
|
+
else
|
|
23
|
+
results.each { |data| probe_card(ProbeView.from_wire(data)) }
|
|
24
|
+
end
|
|
25
25
|
end
|
|
26
|
-
end
|
|
27
26
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
27
|
+
##
|
|
28
|
+
# @param results [Array<Html2rss::Recon::Result>]
|
|
29
|
+
# @param format [String]
|
|
30
|
+
# @param batch_mode [Boolean]
|
|
31
|
+
# @param url_only [Boolean]
|
|
32
|
+
# @return [void]
|
|
33
|
+
def recon_output(results, format:, batch_mode:, url_only: false) # rubocop:disable Metrics/MethodLength, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
34
|
+
if url_only
|
|
35
|
+
results.each { |r| puts r.requested_url }
|
|
36
|
+
elsif format == 'json'
|
|
37
|
+
json(results.map(&:to_h), batch_mode)
|
|
38
|
+
elsif format == 'tsv'
|
|
39
|
+
puts %w[verdict status requested_url final_url native_feed notes].join("\t")
|
|
40
|
+
results.each do |r|
|
|
41
|
+
row = [
|
|
42
|
+
r.verdict.to_s.upcase,
|
|
43
|
+
r.status || '-',
|
|
44
|
+
r.requested_url,
|
|
45
|
+
r.final_url,
|
|
46
|
+
r.native_feed || '-',
|
|
47
|
+
r.notes.join('; ')
|
|
48
|
+
]
|
|
49
|
+
puts row.join("\t")
|
|
50
|
+
end
|
|
51
|
+
else
|
|
52
|
+
results.each { |r| probe_card(ProbeView.from_recon(r)) }
|
|
51
53
|
end
|
|
52
|
-
else
|
|
53
|
-
results.each { |r| probe_card(ProbeView.from_recon(r)) }
|
|
54
54
|
end
|
|
55
|
-
end
|
|
56
55
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
56
|
+
##
|
|
57
|
+
# @param view [ProbeView]
|
|
58
|
+
# @return [void]
|
|
59
|
+
def probe_card(view) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
60
|
+
if view.verdict
|
|
61
|
+
color = VERDICT_COLORS.fetch(view.verdict.to_sym, "\e[31m")
|
|
62
|
+
puts "#{color}[#{view.verdict.to_s.upcase}]\e[0m #{view.requested}"
|
|
63
|
+
else
|
|
64
|
+
puts view.requested
|
|
65
|
+
end
|
|
66
|
+
probe_lines(view)
|
|
67
|
+
if view.alternate_feeds.any?
|
|
68
|
+
hrefs = view.alternate_feeds.filter_map { |f| ProbeView.wire_val(f, :href) }
|
|
69
|
+
puts " Feeds: #{hrefs.join(', ')}" if hrefs.any?
|
|
70
|
+
end
|
|
71
|
+
puts " Feed: #{view.native_feed}" if view.native_feed
|
|
72
|
+
puts " Notes: #{view.notes.join(', ')}" if view.notes.any?
|
|
73
|
+
puts " Strategy: #{view.strategy}" if view.strategy
|
|
74
|
+
puts ''
|
|
71
75
|
end
|
|
72
|
-
puts " Feed: #{view.native_feed}" if view.native_feed
|
|
73
|
-
puts " Notes: #{view.notes.join(', ')}" if view.notes.any?
|
|
74
|
-
puts " Strategy: #{view.strategy}" if view.strategy
|
|
75
|
-
puts ''
|
|
76
|
-
end
|
|
77
76
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
77
|
+
##
|
|
78
|
+
# @param result [Html2rss::Test::Result]
|
|
79
|
+
# @param source [String]
|
|
80
|
+
# @return [void]
|
|
81
|
+
def test_card(result, source) # rubocop:disable Metrics/MethodLength, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
82
|
+
if result.success
|
|
83
|
+
puts "\e[32m✓ Schema valid\e[0m (#{source})"
|
|
84
|
+
dur = "#{result.duration_seconds}s"
|
|
85
|
+
puts "\e[32m✓ Extracted #{result.item_count} items in #{dur}\e[0m (strategy: #{result.strategy_used})"
|
|
86
|
+
puts "\nChannel: #{result.channel_title} (#{result.channel_url})"
|
|
87
|
+
if result.sample_items.any?
|
|
88
|
+
puts 'Sample items:'
|
|
89
|
+
result.sample_items.each_with_index do |item, i|
|
|
90
|
+
puts " #{i + 1}. #{"#{item[:published_at]} | " if item[:published_at]}#{item[:title]}"
|
|
91
|
+
puts " #{item[:url]}"
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
else
|
|
95
|
+
$stderr.puts "\e[31m✗ Test failed\e[0m (#{source})" # rubocop:disable Style/StderrPuts
|
|
96
|
+
$stderr.puts " Error: #{result.error_message}" if result.error_message # rubocop:disable Style/StderrPuts
|
|
97
|
+
result.validation_issues&.each do |issue|
|
|
98
|
+
$stderr.puts " Schema error #{issue}" # rubocop:disable Style/StderrPuts
|
|
93
99
|
end
|
|
94
|
-
end
|
|
95
|
-
else
|
|
96
|
-
$stderr.puts "\e[31m✗ Test failed\e[0m (#{source})" # rubocop:disable Style/StderrPuts
|
|
97
|
-
$stderr.puts " Error: #{result.error_message}" if result.error_message # rubocop:disable Style/StderrPuts
|
|
98
|
-
result.validation_issues&.each do |issue|
|
|
99
|
-
$stderr.puts " Schema error #{issue}" # rubocop:disable Style/StderrPuts
|
|
100
100
|
end
|
|
101
101
|
end
|
|
102
|
-
end
|
|
103
102
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
103
|
+
##
|
|
104
|
+
# @param data [Object]
|
|
105
|
+
# @param batch_mode [Boolean]
|
|
106
|
+
# @return [void]
|
|
107
|
+
def json(data, batch_mode)
|
|
108
|
+
payload = batch_mode ? data : data.first
|
|
109
|
+
puts JSON.pretty_generate(payload)
|
|
110
|
+
end
|
|
112
111
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
112
|
+
private
|
|
113
|
+
|
|
114
|
+
##
|
|
115
|
+
# @param view [ProbeView]
|
|
116
|
+
# @return [void]
|
|
117
|
+
def probe_lines(view)
|
|
118
|
+
if view.final && view.final != view.requested
|
|
119
|
+
puts " Final: #{view.final} (HTTP #{view.status || 'ERR'})"
|
|
120
|
+
end
|
|
121
|
+
puts " Surface: #{view.surface} (#{view.articles_count} articles)"
|
|
119
122
|
end
|
|
120
|
-
puts " Surface: #{view.surface} (#{view.articles_count} articles)"
|
|
121
123
|
end
|
|
122
|
-
private_class_method :probe_lines
|
|
123
124
|
end
|
|
124
125
|
end
|
|
125
126
|
end
|
|
@@ -5,15 +5,13 @@ module Html2rss
|
|
|
5
5
|
##
|
|
6
6
|
# Validate command: file/glob/stdin branching and multi-file reporting.
|
|
7
7
|
module Validate
|
|
8
|
-
module_function
|
|
9
|
-
|
|
10
8
|
##
|
|
11
9
|
# @param files [Array<String>]
|
|
12
10
|
# @param params [Hash]
|
|
13
11
|
# @param quiet [Boolean]
|
|
14
12
|
# @return [void]
|
|
15
13
|
# @raise [Thor::Error]
|
|
16
|
-
def run(files, params:, quiet:) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
14
|
+
def self.run(files, params:, quiet:) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
17
15
|
if files.size == 2 && File.file?(files[0].to_s) && !File.exist?(files[1].to_s)
|
|
18
16
|
raise Thor::Error, "No such file: #{files[1]}" if path_like_config?(files[1])
|
|
19
17
|
|
|
@@ -45,7 +43,7 @@ module Html2rss
|
|
|
45
43
|
##
|
|
46
44
|
# @param files [Array<String>]
|
|
47
45
|
# @return [Array<String>]
|
|
48
|
-
def resolve_files(files)
|
|
46
|
+
def self.resolve_files(files)
|
|
49
47
|
return ['-'] if files.empty? || files == ['-']
|
|
50
48
|
|
|
51
49
|
resolved = []
|