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
|
@@ -5,137 +5,138 @@ require 'rss'
|
|
|
5
5
|
module Html2rss
|
|
6
6
|
module Syndication
|
|
7
7
|
##
|
|
8
|
-
# Parses RSS 2.0 and Atom documents into AutoSource article hashes
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
8
|
+
# Parses RSS 2.0, RSS 1.0 (RDF), and Atom documents into a {Feed}: AutoSource article hashes, with
|
|
9
|
+
# the media files and image each item declares ({ItemMedia}), and what the feed states about its
|
|
10
|
+
# channel ({ChannelFacts}), read from the same parse.
|
|
11
|
+
#
|
|
12
|
+
# An item's URL is its link (an Atom entry's +rel="alternate"+ link or a link without +rel+). A
|
|
13
|
+
# link-less item (a podcast episode on a hosting platform) takes its guid, Atom +id+ or RDF +about+
|
|
14
|
+
# when that is an absolute http(s) URL, else its first audio or video file; an item with none of
|
|
15
|
+
# these has no URL and is skipped. Links and files resolve against the feed URL through
|
|
16
|
+
# {Url.resolve_web}: a +javascript:+, +data:+ or +ftp:+ one counts as absent, so no item URL is one.
|
|
17
|
+
module Parser
|
|
18
|
+
class << self
|
|
19
|
+
##
|
|
20
|
+
# @param response [Html2rss::RequestService::Response]
|
|
21
|
+
# @return [Feed]
|
|
22
|
+
# @raise [RequestService::UnsupportedResponseContentType] when the response is not a syndication feed
|
|
23
|
+
def parse_response(response)
|
|
24
|
+
unless response.feed_response?
|
|
25
|
+
raise RequestService::UnsupportedResponseContentType, 'response is not a syndication feed'
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
parse(response.body, base_url: response.url)
|
|
29
|
+
end
|
|
21
30
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
[]
|
|
38
|
-
end
|
|
31
|
+
##
|
|
32
|
+
# @param body [String] raw RSS/RDF/Atom document
|
|
33
|
+
# @param base_url [String, Html2rss::Url, nil] fallback base for relative item links
|
|
34
|
+
# @return [Feed] no articles and no channel facts when the document fails to parse
|
|
35
|
+
def parse(body, base_url: nil)
|
|
36
|
+
feed = RSS::Parser.parse(body.to_s, false)
|
|
37
|
+
Feed.new(articles: articles_from(feed, body, base_url:), channel: ChannelFacts.from(feed))
|
|
38
|
+
rescue RSS::Error => error
|
|
39
|
+
Log.warn({
|
|
40
|
+
event_name: 'syndication.parse_failed',
|
|
41
|
+
error_class: error.class.name,
|
|
42
|
+
message: "Syndication::Parser: failed to parse feed (#{error.class}: #{error.message})"
|
|
43
|
+
}.to_json)
|
|
44
|
+
Feed::NONE
|
|
45
|
+
end
|
|
39
46
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def articles_from(feed, body, base_url:)
|
|
50
|
+
items = items_from(feed)
|
|
51
|
+
media = ItemMedia.for_items(body, count: items.size, url: base_url)
|
|
52
|
+
|
|
53
|
+
items.zip(media).filter_map { |item, item_media| article_hash(item, item_media, base_url:) }
|
|
45
54
|
end
|
|
46
|
-
end
|
|
47
|
-
module_function :items_from
|
|
48
|
-
private_class_method :items_from
|
|
49
55
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
56
|
+
def items_from(feed)
|
|
57
|
+
case feed
|
|
58
|
+
when RSS::Rss, RSS::RDF then feed.items
|
|
59
|
+
when RSS::Atom::Feed then feed.entries
|
|
60
|
+
else []
|
|
61
|
+
end
|
|
54
62
|
end
|
|
55
|
-
end
|
|
56
|
-
module_function :article_hash
|
|
57
|
-
private_class_method :article_hash
|
|
58
|
-
|
|
59
|
-
def rss_item_hash(item, base_url:) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength -- RSS item mapping
|
|
60
|
-
url = absolute_url(item.link, base_url:)
|
|
61
|
-
return unless url
|
|
62
|
-
|
|
63
|
-
{
|
|
64
|
-
id: present(item.guid&.content) || url.to_s,
|
|
65
|
-
title: present(item.title),
|
|
66
|
-
description: present(item.description),
|
|
67
|
-
url:,
|
|
68
|
-
published_at: item.date || item.pubDate,
|
|
69
|
-
author: present(item.author),
|
|
70
|
-
categories: Array(item.categories).filter_map { |cat| present(cat.content) }
|
|
71
|
-
}.compact
|
|
72
|
-
end
|
|
73
|
-
module_function :rss_item_hash
|
|
74
|
-
private_class_method :rss_item_hash
|
|
75
|
-
|
|
76
|
-
def atom_entry_hash(entry, base_url:) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity -- Atom entry mapping
|
|
77
|
-
url = atom_link(entry, base_url:)
|
|
78
|
-
return unless url
|
|
79
|
-
|
|
80
|
-
{
|
|
81
|
-
id: present(entry.id&.content) || url.to_s,
|
|
82
|
-
title: present(atom_text(entry.title)),
|
|
83
|
-
description: present(atom_text(entry.content) || atom_text(entry.summary)),
|
|
84
|
-
url:,
|
|
85
|
-
published_at: entry.published&.content || entry.updated&.content,
|
|
86
|
-
author: present(atom_author(entry)),
|
|
87
|
-
categories: Array(entry.categories).filter_map { |cat| present(cat.term) }
|
|
88
|
-
}.compact
|
|
89
|
-
end
|
|
90
|
-
module_function :atom_entry_hash
|
|
91
|
-
private_class_method :atom_entry_hash
|
|
92
63
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
64
|
+
def article_hash(item, media, base_url:)
|
|
65
|
+
case item
|
|
66
|
+
when RSS::Rss::Channel::Item then rss_item_hash(item, media, base_url:)
|
|
67
|
+
when RSS::RDF::Item then rdf_item_hash(item, media, base_url:)
|
|
68
|
+
when RSS::Atom::Feed::Entry then atom_entry_hash(item, media, base_url:)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
100
71
|
|
|
101
|
-
|
|
102
|
-
|
|
72
|
+
def rss_item_hash(item, media, base_url:)
|
|
73
|
+
article(
|
|
74
|
+
Url.resolve_web(item.link, base_url), media,
|
|
75
|
+
id: item.guid&.content, title: item.title, description: item.description,
|
|
76
|
+
published_at: item.pubDate, author: item.author, categories: item.categories.map(&:content)
|
|
77
|
+
)
|
|
78
|
+
end
|
|
103
79
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
80
|
+
def rdf_item_hash(item, media, base_url:)
|
|
81
|
+
article(
|
|
82
|
+
Url.resolve_web(item.link, base_url), media,
|
|
83
|
+
id: item.about, title: item.title, description: item.description,
|
|
84
|
+
published_at: item.dc_date, author: item.dc_creator, categories: item.dc_subjects.map(&:content)
|
|
85
|
+
)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def atom_entry_hash(entry, media, base_url:)
|
|
89
|
+
article(
|
|
90
|
+
atom_link(entry, base_url:), media,
|
|
91
|
+
id: entry.id&.content, title: entry.title&.content, description: atom_text(entry.content, entry.summary),
|
|
92
|
+
published_at: atom_text(entry.published, entry.updated),
|
|
93
|
+
author: atom_author(entry), categories: entry.categories.map(&:term)
|
|
94
|
+
)
|
|
95
|
+
end
|
|
108
96
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
97
|
+
##
|
|
98
|
+
# Single owner of the article hash shape: blank text becomes absent, +id+ falls back to the url,
|
|
99
|
+
# +media+ adds the item's +enclosures+ and +image+. The url is the item's http(s) link, else its id
|
|
100
|
+
# when that is an absolute http(s) URL, whatever +isPermaLink+ says (+false+ only denies a guaranteed
|
|
101
|
+
# permalink, and such a guid is mostly the item's page on the publisher or its host; a relative, +urn:+
|
|
102
|
+
# or +tag:+ id names no page), else its first audio or video file.
|
|
103
|
+
def article(link, media, id:, title:, description:, published_at:, author:, categories:) # rubocop:disable Metrics/ParameterLists -- the article shape
|
|
104
|
+
return unless (url = link || Url.resolve_web(id) || media_url(media))
|
|
105
|
+
|
|
106
|
+
{
|
|
107
|
+
id: present(id) || url.to_s,
|
|
108
|
+
title: present(title),
|
|
109
|
+
description: present(description),
|
|
110
|
+
url:, published_at:,
|
|
111
|
+
author: present(author),
|
|
112
|
+
categories: categories.filter_map { |category| present(category) },
|
|
113
|
+
**media
|
|
114
|
+
}.compact
|
|
115
|
+
end
|
|
112
116
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
private_class_method :atom_author
|
|
117
|
+
def media_url(media)
|
|
118
|
+
media.fetch(:enclosures, []).find { |facts| Article::Enclosure.new(**facts).media? }&.fetch(:url)
|
|
119
|
+
end
|
|
117
120
|
|
|
118
|
-
|
|
119
|
-
value = present(raw)
|
|
120
|
-
return unless value
|
|
121
|
+
def atom_text(*nodes) = nodes.find { |node| node }&.content
|
|
121
122
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
Html2rss::Url.from_absolute(value)
|
|
123
|
+
def atom_link(entry, base_url:)
|
|
124
|
+
link = entry.links.find { |node| node.rel.nil? || node.rel == 'alternate' }
|
|
125
|
+
Url.resolve_web(link&.href, base_url)
|
|
126
126
|
end
|
|
127
|
-
rescue ArgumentError
|
|
128
|
-
nil
|
|
129
|
-
end
|
|
130
|
-
module_function :absolute_url
|
|
131
|
-
private_class_method :absolute_url
|
|
132
127
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
128
|
+
def atom_author(entry)
|
|
129
|
+
author = entry.author
|
|
130
|
+
return unless author
|
|
131
|
+
|
|
132
|
+
present(author.name&.content) || present(author.email&.content)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def present(value)
|
|
136
|
+
text = value.to_s.strip
|
|
137
|
+
text unless text.empty?
|
|
138
|
+
end
|
|
136
139
|
end
|
|
137
|
-
module_function :present
|
|
138
|
-
private_class_method :present
|
|
139
140
|
end
|
|
140
141
|
end
|
|
141
142
|
end
|
|
@@ -6,8 +6,6 @@ module Html2rss
|
|
|
6
6
|
# Probes list-card +enhance+ value on cached listing HTML.
|
|
7
7
|
# Sole owner of enhance_gains metrics and enhance-specific warn-only warnings.
|
|
8
8
|
module EnhanceAudit # rubocop:disable Metrics/ModuleLength -- probe + compare + heuristics stay co-located
|
|
9
|
-
module_function
|
|
10
|
-
|
|
11
9
|
TRACKED_KEYS = %i[title url description published_at categories author image id enclosures].freeze
|
|
12
10
|
CURATOR_KEYS = %i[title url description published_at].freeze
|
|
13
11
|
private_constant :TRACKED_KEYS, :CURATOR_KEYS
|
|
@@ -28,7 +26,7 @@ module Html2rss
|
|
|
28
26
|
# @param selectors [Hash{Symbol => Object}]
|
|
29
27
|
# @param time_zone [String]
|
|
30
28
|
# @return [AuditSlice, nil] nil when selectors are absent
|
|
31
|
-
def probe(response:, selectors:, time_zone:) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength -- per-item probe loop
|
|
29
|
+
def self.probe(response:, selectors:, time_zone:) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength -- per-item probe loop
|
|
32
30
|
return nil unless selectors&.dig(:items, :selector)
|
|
33
31
|
|
|
34
32
|
selector_engine = Selectors.new(response, selectors:, time_zone:)
|
|
@@ -40,7 +38,7 @@ module Html2rss
|
|
|
40
38
|
}
|
|
41
39
|
warnings = []
|
|
42
40
|
|
|
43
|
-
selector_engine.each_enhance_pair do |baseline, enhanced, item|
|
|
41
|
+
selector_engine.each_enhance_pair(linked_only: true) do |baseline, enhanced, item|
|
|
44
42
|
item_keys = keys_added(baseline, enhanced)
|
|
45
43
|
merge_keys_added!(gains, item_keys)
|
|
46
44
|
append_item_warnings!(warnings, item:, enhanced:, item_keys:)
|
|
@@ -64,7 +62,7 @@ module Html2rss
|
|
|
64
62
|
# @param selectors [Hash{Symbol => Object}]
|
|
65
63
|
# @param time_zone [String]
|
|
66
64
|
# @return [Hash{Symbol => Object}, nil]
|
|
67
|
-
def compare(response:, selectors:, time_zone:) # rubocop:disable Metrics/MethodLength -- enhance off/on stats + delta
|
|
65
|
+
def self.compare(response:, selectors:, time_zone:) # rubocop:disable Metrics/MethodLength -- enhance off/on stats + delta
|
|
68
66
|
return nil unless selectors&.dig(:items, :selector)
|
|
69
67
|
|
|
70
68
|
off = extraction_stats(response, selectors, time_zone, enhance: false)
|
|
@@ -84,7 +82,7 @@ module Html2rss
|
|
|
84
82
|
|
|
85
83
|
##
|
|
86
84
|
# @return [EnhanceGains] zeroed gains for compare fallback
|
|
87
|
-
def empty_gains
|
|
85
|
+
def self.empty_gains
|
|
88
86
|
EnhanceGains.new(
|
|
89
87
|
items_probed: 0,
|
|
90
88
|
keys_added: zero_keys_added,
|
|
@@ -93,12 +91,12 @@ module Html2rss
|
|
|
93
91
|
)
|
|
94
92
|
end
|
|
95
93
|
|
|
96
|
-
def zero_keys_added
|
|
94
|
+
def self.zero_keys_added
|
|
97
95
|
TRACKED_KEYS.to_h { |key| [key, 0] }.freeze
|
|
98
96
|
end
|
|
99
97
|
private_class_method :zero_keys_added
|
|
100
98
|
|
|
101
|
-
def extraction_stats(response, selectors, time_zone, enhance:)
|
|
99
|
+
def self.extraction_stats(response, selectors, time_zone, enhance:)
|
|
102
100
|
merged = selectors_with_enhance(selectors, enhance:)
|
|
103
101
|
articles = Selectors.new(response, selectors: merged, time_zone:).articles
|
|
104
102
|
{
|
|
@@ -108,14 +106,14 @@ module Html2rss
|
|
|
108
106
|
end
|
|
109
107
|
private_class_method :extraction_stats
|
|
110
108
|
|
|
111
|
-
def selectors_with_enhance(selectors, enhance:)
|
|
109
|
+
def self.selectors_with_enhance(selectors, enhance:)
|
|
112
110
|
merged = HashUtil.deep_dup(selectors)
|
|
113
111
|
merged[:items] = merged.fetch(:items, {}).merge(enhance:)
|
|
114
112
|
merged
|
|
115
113
|
end
|
|
116
114
|
private_class_method :selectors_with_enhance
|
|
117
115
|
|
|
118
|
-
def keys_added(baseline, enhanced)
|
|
116
|
+
def self.keys_added(baseline, enhanced)
|
|
119
117
|
enhanced.each_with_object({}) do |(key, value), counts|
|
|
120
118
|
next if value.nil?
|
|
121
119
|
|
|
@@ -127,7 +125,7 @@ module Html2rss
|
|
|
127
125
|
end
|
|
128
126
|
private_class_method :keys_added
|
|
129
127
|
|
|
130
|
-
def merge_keys_added!(gains, item_keys)
|
|
128
|
+
def self.merge_keys_added!(gains, item_keys)
|
|
131
129
|
gains[:items_probed] += 1
|
|
132
130
|
item_keys.each do |key, count|
|
|
133
131
|
next unless TRACKED_KEYS.include?(key)
|
|
@@ -138,13 +136,13 @@ module Html2rss
|
|
|
138
136
|
end
|
|
139
137
|
private_class_method :merge_keys_added!
|
|
140
138
|
|
|
141
|
-
def finalize_gains!(gains)
|
|
139
|
+
def self.finalize_gains!(gains)
|
|
142
140
|
curator_total = CURATOR_KEYS.sum { |key| gains[:keys_added][key] }
|
|
143
141
|
gains[:no_op] = curator_total.zero?
|
|
144
142
|
end
|
|
145
143
|
private_class_method :finalize_gains!
|
|
146
144
|
|
|
147
|
-
def append_item_warnings!(warnings, item:, enhanced:, item_keys:)
|
|
145
|
+
def self.append_item_warnings!(warnings, item:, enhanced:, item_keys:)
|
|
148
146
|
curator_added = item_keys.slice(*CURATOR_KEYS)
|
|
149
147
|
return if curator_added.empty?
|
|
150
148
|
return unless curator_added.key?(:description)
|
|
@@ -160,16 +158,16 @@ module Html2rss
|
|
|
160
158
|
end
|
|
161
159
|
private_class_method :append_item_warnings!
|
|
162
160
|
|
|
163
|
-
def category_only_description?(description, enhanced)
|
|
161
|
+
def self.category_only_description?(description, enhanced)
|
|
164
162
|
normalized = description.downcase
|
|
165
|
-
return true if Html2rss::
|
|
163
|
+
return true if Html2rss::Article::Title::TYPE_MARKERS.include?(normalized)
|
|
166
164
|
|
|
167
165
|
categories = Array(enhanced[:categories]).map { |category| category.to_s.strip }
|
|
168
166
|
categories.any? { |category| category.casecmp?(description) }
|
|
169
167
|
end
|
|
170
168
|
private_class_method :category_only_description?
|
|
171
169
|
|
|
172
|
-
def image_only_description?(description, item)
|
|
170
|
+
def self.image_only_description?(description, item)
|
|
173
171
|
images = item.css('img')
|
|
174
172
|
return description == images.first['alt'].to_s.strip if images.size == 1 && images.first['alt']
|
|
175
173
|
|
|
@@ -177,7 +175,7 @@ module Html2rss
|
|
|
177
175
|
end
|
|
178
176
|
private_class_method :image_only_description?
|
|
179
177
|
|
|
180
|
-
def prose_shaped?(text)
|
|
178
|
+
def self.prose_shaped?(text)
|
|
181
179
|
return false if text.match?(IMAGE_FILENAME)
|
|
182
180
|
return false if text.match?(%r{\Ahttps?://}i)
|
|
183
181
|
return false if text.match?(%r{\A/[\w./-]+\z})
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Html2rss
|
|
4
|
+
module Test
|
|
5
|
+
##
|
|
6
|
+
# Builds the bounded +items+ preview of a test run from extracted articles,
|
|
7
|
+
# independent of the quality gate and of the rendered RSS.
|
|
8
|
+
module ItemSample
|
|
9
|
+
# Maximum characters kept in an item's plain-text description.
|
|
10
|
+
DESCRIPTION_LIMIT = 200
|
|
11
|
+
|
|
12
|
+
##
|
|
13
|
+
# @param articles [Array<Html2rss::Article>] extracted articles
|
|
14
|
+
# @param limit [Integer] maximum number of items, >= 0
|
|
15
|
+
# @return [Array<Hash{Symbol => Object}>]
|
|
16
|
+
def self.call(articles, limit:)
|
|
17
|
+
articles.first(limit).map { |article| item_for(article) }
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
##
|
|
21
|
+
# @param article [Html2rss::Article]
|
|
22
|
+
# @return [Hash{Symbol => Object}]
|
|
23
|
+
def self.item_for(article)
|
|
24
|
+
{
|
|
25
|
+
title: article.title.to_s.strip,
|
|
26
|
+
url: article.url&.to_s,
|
|
27
|
+
published_at: article.published_at&.iso8601,
|
|
28
|
+
description: plain_description(article.description),
|
|
29
|
+
image: article.image&.to_s,
|
|
30
|
+
categories: article.categories
|
|
31
|
+
}
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
##
|
|
35
|
+
# @param html [String, nil]
|
|
36
|
+
# @return [String] whitespace-collapsed text truncated to {DESCRIPTION_LIMIT} characters
|
|
37
|
+
def self.plain_description(html)
|
|
38
|
+
text = Nokogiri::HTML.fragment(html).text.gsub(/\s+/, ' ').strip
|
|
39
|
+
text[0, DESCRIPTION_LIMIT]
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
data/lib/html2rss/test/policy.rb
CHANGED
|
@@ -6,8 +6,6 @@ module Html2rss
|
|
|
6
6
|
# Pass/fail classification for a configuration test after live extraction.
|
|
7
7
|
# Owns min_items vs quality thresholds; {Test.call} orchestrates only.
|
|
8
8
|
module Policy
|
|
9
|
-
module_function
|
|
10
|
-
|
|
11
9
|
Outcome = Data.define(:passed, :failure_kind, :error_message)
|
|
12
10
|
|
|
13
11
|
##
|
|
@@ -28,7 +26,7 @@ module Html2rss
|
|
|
28
26
|
# @param strict_quality [Boolean]
|
|
29
27
|
# @param quality_report [QualityReport]
|
|
30
28
|
# @return [Outcome]
|
|
31
|
-
def evaluate(item_count:, min_items:, strict_quality:, quality_report:)
|
|
29
|
+
def self.evaluate(item_count:, min_items:, strict_quality:, quality_report:)
|
|
32
30
|
min_items_passed = item_count >= min_items
|
|
33
31
|
quality_failed = strict_quality && min_items_passed && quality_failure?(quality_report)
|
|
34
32
|
passed = min_items_passed && !quality_failed
|
|
@@ -39,7 +37,7 @@ module Html2rss
|
|
|
39
37
|
Outcome.new(passed:, failure_kind:, error_message:)
|
|
40
38
|
end
|
|
41
39
|
|
|
42
|
-
def log_strict_quality_failure(failure_kind, item_count)
|
|
40
|
+
def self.log_strict_quality_failure(failure_kind, item_count)
|
|
43
41
|
Log.info("Test strict quality: failure_kind=#{failure_kind.to_sym} item_count=#{item_count}")
|
|
44
42
|
end
|
|
45
43
|
private_class_method :log_strict_quality_failure
|
|
@@ -47,7 +45,7 @@ module Html2rss
|
|
|
47
45
|
##
|
|
48
46
|
# @param quality_report [QualityReport]
|
|
49
47
|
# @return [Boolean]
|
|
50
|
-
def quality_failure?(quality_report)
|
|
48
|
+
def self.quality_failure?(quality_report)
|
|
51
49
|
metrics = quality_report.metrics
|
|
52
50
|
item_count = metrics[:item_count]
|
|
53
51
|
return false if item_count.zero?
|
|
@@ -62,7 +60,7 @@ module Html2rss
|
|
|
62
60
|
# @param min_items [Integer]
|
|
63
61
|
# @param quality_report [QualityReport]
|
|
64
62
|
# @return [Array(FailureKind, nil), Array(String, nil)]
|
|
65
|
-
def outcome_failure(min_items_passed:, quality_failed:, item_count:, min_items:, quality_report:)
|
|
63
|
+
def self.outcome_failure(min_items_passed:, quality_failed:, item_count:, min_items:, quality_report:)
|
|
66
64
|
return [nil, nil] if min_items_passed && !quality_failed
|
|
67
65
|
unless min_items_passed
|
|
68
66
|
return [FailureKind[:min_items],
|
|
@@ -72,13 +70,13 @@ module Html2rss
|
|
|
72
70
|
[FailureKind[:quality], quality_failure_message(quality_report)]
|
|
73
71
|
end
|
|
74
72
|
|
|
75
|
-
def quality_failure_message(quality_report)
|
|
73
|
+
def self.quality_failure_message(quality_report)
|
|
76
74
|
reasons = quality_failure_reasons(quality_report.metrics)
|
|
77
75
|
"Feed quality check failed (#{reasons.join(', ')})"
|
|
78
76
|
end
|
|
79
77
|
private_class_method :quality_failure_message
|
|
80
78
|
|
|
81
|
-
def quality_failure_reasons(metrics)
|
|
79
|
+
def self.quality_failure_reasons(metrics)
|
|
82
80
|
reasons = []
|
|
83
81
|
reasons << 'duplicate_urls' if duplicate_urls_failure?(metrics)
|
|
84
82
|
reasons << 'generic_titles' if junk_title_ratio_failure?(metrics)
|
|
@@ -87,17 +85,17 @@ module Html2rss
|
|
|
87
85
|
end
|
|
88
86
|
private_class_method :quality_failure_reasons
|
|
89
87
|
|
|
90
|
-
def duplicate_urls_failure?(metrics)
|
|
88
|
+
def self.duplicate_urls_failure?(metrics)
|
|
91
89
|
metrics[:item_count] >= 2 && metrics[:unique_url_count] < 2
|
|
92
90
|
end
|
|
93
91
|
private_class_method :duplicate_urls_failure?
|
|
94
92
|
|
|
95
|
-
def junk_title_ratio_failure?(metrics)
|
|
93
|
+
def self.junk_title_ratio_failure?(metrics)
|
|
96
94
|
metrics[:junk_title_count] > (metrics[:item_count] / 2.0)
|
|
97
95
|
end
|
|
98
96
|
private_class_method :junk_title_ratio_failure?
|
|
99
97
|
|
|
100
|
-
def short_title_failure?(metrics)
|
|
98
|
+
def self.short_title_failure?(metrics)
|
|
101
99
|
metrics[:short_title_count].positive?
|
|
102
100
|
end
|
|
103
101
|
private_class_method :short_title_failure?
|