html2rss 0.33.0 → 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 -21
- 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 +126 -50
- 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 -39
- data/lib/html2rss/auto_source/scraper/json_document.rb +1 -3
- data/lib/html2rss/auto_source/scraper/json_state/article_normalizer.rb +119 -42
- data/lib/html2rss/auto_source/scraper/json_state/candidate_detector.rb +5 -16
- data/lib/html2rss/auto_source/scraper/json_state/value_finder.rb +2 -10
- data/lib/html2rss/auto_source/scraper/json_state.rb +2 -3
- data/lib/html2rss/auto_source/scraper/meta_oembed.rb +6 -14
- data/lib/html2rss/auto_source/scraper/microdata.rb +6 -4
- data/lib/html2rss/auto_source/scraper/microformats2.rb +1 -3
- data/lib/html2rss/auto_source/scraper/native_feed.rb +27 -17
- data/lib/html2rss/auto_source/scraper/schema/item_list.rb +5 -3
- data/lib/html2rss/auto_source/scraper/schema/thing.rb +68 -27
- data/lib/html2rss/auto_source/scraper/schema.rb +5 -8
- data/lib/html2rss/auto_source/scraper/semantic_html/entry_deduplicator.rb +3 -9
- data/lib/html2rss/auto_source/scraper/semantic_html.rb +1 -5
- data/lib/html2rss/auto_source/scraper/sitemap.rb +4 -3
- data/lib/html2rss/auto_source/scraper/wordpress_api.rb +3 -3
- data/lib/html2rss/auto_source/scraper/xhr_articles.rb +1 -3
- data/lib/html2rss/auto_source/scraper.rb +10 -2
- data/lib/html2rss/auto_source/segment.rb +19 -5
- data/lib/html2rss/auto_source/segmenter/cluster.rb +13 -11
- data/lib/html2rss/auto_source/segmenter/list.rb +13 -5
- data/lib/html2rss/auto_source/segmenter/primary_link.rb +29 -4
- data/lib/html2rss/auto_source/segmenter/semantic.rb +17 -6
- data/lib/html2rss/auto_source/segmenter.rb +4 -8
- data/lib/html2rss/auto_source.rb +81 -26
- data/lib/html2rss/batch.rb +2 -0
- data/lib/html2rss/capture/README.md +5 -2
- data/lib/html2rss/capture.rb +44 -32
- data/lib/html2rss/channel.rb +30 -12
- data/lib/html2rss/cli.rb +22 -11
- 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 +6 -3
- data/lib/html2rss/config/schema.rb +15 -5
- data/lib/html2rss/config/selectors_validator.rb +2 -2
- data/lib/html2rss/config/validation_issue.rb +4 -1
- data/lib/html2rss/config/validator.rb +11 -0
- data/lib/html2rss/config.rb +53 -27
- data/lib/html2rss/contract_error.rb +9 -0
- data/lib/html2rss/defaults.rb +21 -23
- data/lib/html2rss/doctor/botasaurus.rb +7 -3
- data/lib/html2rss/error.rb +10 -0
- 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 +49 -6
- 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 +13 -31
- 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 +3 -4
- data/lib/html2rss/feed_resolution/policy.rb +1 -1
- data/lib/html2rss/feed_resolution/probe.rb +3 -1
- data/lib/html2rss/feed_resolution.rb +11 -12
- data/lib/html2rss/feed_result.rb +9 -5
- data/lib/html2rss/hash_util.rb +3 -3
- data/lib/html2rss/html/article_extractor/category_extractor.rb +1 -1
- data/lib/html2rss/html/article_extractor/enclosure_extractor.rb +12 -31
- data/lib/html2rss/html/article_extractor/heading_extractor.rb +2 -3
- data/lib/html2rss/html/article_extractor/id_generator.rb +6 -9
- 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 +4 -0
- 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 +100 -9
- 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 +5 -2
- data/lib/html2rss/mcp/contract.rb +13 -4
- data/lib/html2rss/mcp/outcome.rb +14 -12
- data/lib/html2rss/mcp/runtime.rb +39 -2
- data/lib/html2rss/mcp/server.rb +13 -4
- data/lib/html2rss/page_recon/README.md +1 -1
- data/lib/html2rss/page_recon/diagnostics.rb +2 -0
- data/lib/html2rss/recon.rb +10 -12
- data/lib/html2rss/request_service/blocked_surface.rb +2 -1
- data/lib/html2rss/request_service/botasaurus_contract.rb +25 -3
- data/lib/html2rss/request_service/botasaurus_strategy.rb +42 -10
- data/lib/html2rss/request_service/context.rb +15 -14
- 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 -2
- data/lib/html2rss/request_service/policy.rb +48 -11
- data/lib/html2rss/request_service/replay_breaker.rb +56 -0
- data/lib/html2rss/request_service/response.rb +30 -13
- 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 +0 -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 +6 -6
- 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/option_spec.rb +4 -1
- data/lib/html2rss/selectors/post_processors/base.rb +0 -8
- data/lib/html2rss/selectors/post_processors/gsub.rb +7 -7
- 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 +3 -3
- 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.rb +53 -12
- 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 +33 -26
- data/lib/html2rss/sst/normalizer.rb +31 -19
- data/lib/html2rss/sst/tags.rb +113 -2
- data/lib/html2rss/sst/text.rb +69 -29
- data/lib/html2rss/sst.rb +2 -0
- data/lib/html2rss/status.rb +6 -3
- data/lib/html2rss/surface_category.rb +1 -1
- data/lib/html2rss/syndication/README.md +12 -6
- data/lib/html2rss/syndication/alternate_links.rb +1 -1
- data/lib/html2rss/syndication/channel_facts.rb +87 -0
- data/lib/html2rss/syndication/discovery.rb +82 -28
- data/lib/html2rss/syndication/feed.rb +20 -0
- data/lib/html2rss/syndication/item_media.rb +103 -0
- data/lib/html2rss/syndication/parser.rb +75 -51
- data/lib/html2rss/test/enhance_audit.rb +2 -2
- data/lib/html2rss/test/item_sample.rb +43 -0
- data/lib/html2rss/test.rb +42 -14
- data/lib/html2rss/url.rb +115 -14
- data/lib/html2rss/version.rb +1 -1
- data/lib/html2rss/word_count.rb +53 -0
- data/lib/html2rss.rb +17 -8
- data/schema/html2rss-config.schema.json +56 -41
- metadata +31 -4
- data/lib/html2rss/html/rendering/media_renderer.rb +0 -39
- data/lib/html2rss/html/rendering/pdf_renderer.rb +0 -30
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Html2rss
|
|
4
|
+
module Syndication
|
|
5
|
+
##
|
|
6
|
+
# Reads the media files and image a feed item declares, for {Parser}: the +rss+ library parses the
|
|
7
|
+
# item text but drops Media RSS, so this module reads the same document's item elements.
|
|
8
|
+
#
|
|
9
|
+
# Enclosures, in document order and once per URL: RSS 2.0 +enclosure+ (+url+, +type+, +length+),
|
|
10
|
+
# Atom +link rel="enclosure"+ (+href+, +type+, +length+) and Media RSS +media:content+, also inside
|
|
11
|
+
# +media:group+ (+url+, +type+, +fileSize+, +duration+). A Media RSS +medium+ (+audio+, +video+, +image+)
|
|
12
|
+
# is the file's kind, typing an untyped file ({Article::Enclosure::KIND_TYPES}); it never makes a watch or
|
|
13
|
+
# player page ({Article::Enclosure#player_page?}, judged without it) a file, except that +image+ keeps one.
|
|
14
|
+
# An item +itunes:duration+ is the duration of every file that declares none ({Article::Enclosure} keeps it
|
|
15
|
+
# only on audio and video). The item image is its +itunes:image+ (+href+, or the URL as its text), else its
|
|
16
|
+
# first +media:thumbnail+ (also inside +media:group+); without either, {Article} makes its first image file
|
|
17
|
+
# the item image. Every declared URL resolves against the feed URL through {Url.resolve_web}, so a
|
|
18
|
+
# +javascript:+, +data:+ or +ftp:+ file or image is dropped, never shipped nor made an item URL ({Parser}).
|
|
19
|
+
# Sizes, durations and kinds come from {Article::Enclosure}. Items match by namespace
|
|
20
|
+
# ({ITEMS}); a document without any media marker ({MEDIA_MARKERS}) is not parsed a second time.
|
|
21
|
+
module ItemMedia
|
|
22
|
+
# Namespace prefixes the item queries bind.
|
|
23
|
+
NAMESPACES = {
|
|
24
|
+
'rss1' => 'http://purl.org/rss/1.0/',
|
|
25
|
+
'rdf' => 'http://www.w3.org/1999/02/22-rdf-syntax-ns#',
|
|
26
|
+
'atom' => 'http://www.w3.org/2005/Atom',
|
|
27
|
+
'media' => 'http://search.yahoo.com/mrss/',
|
|
28
|
+
'itunes' => 'http://www.itunes.com/dtds/podcast-1.0.dtd'
|
|
29
|
+
}.freeze
|
|
30
|
+
# Item elements in document order, by format: RSS 2.0 +item+ (no namespace) under +rss/channel+,
|
|
31
|
+
# RSS 1.0 +item+ under +rdf:RDF+, Atom +entry+ under +feed+. Extension elements named +item+ never count.
|
|
32
|
+
ITEMS = '/rss/channel/item | /rdf:RDF/rss1:item | /atom:feed/atom:entry'
|
|
33
|
+
# Text only a document declaring item media contains: an RSS 2.0 enclosure, an Atom enclosure link,
|
|
34
|
+
# or the Media RSS or iTunes namespace. Any other document is not parsed a second time.
|
|
35
|
+
MEDIA_MARKERS = Regexp.union('<enclosure', /rel=["']enclosure["']/, NAMESPACES['media'], NAMESPACES['itunes'])
|
|
36
|
+
# Enclosure elements of one item, in document order.
|
|
37
|
+
ENCLOSURES = "enclosure | atom:link[@rel='enclosure'] | media:content | media:group/media:content"
|
|
38
|
+
# Thumbnail URLs of one item, in document order (blank ones skipped, like a blank iTunes image).
|
|
39
|
+
THUMBNAILS = 'media:thumbnail/@url[normalize-space()] | media:group/media:thumbnail/@url[normalize-space()]'
|
|
40
|
+
# The item's iTunes image URL: its +href+, else the URL written as the element text.
|
|
41
|
+
ITUNES_IMAGE = 'itunes:image/@href[normalize-space()] | ' \
|
|
42
|
+
'itunes:image[not(normalize-space(@href))][normalize-space()]'
|
|
43
|
+
|
|
44
|
+
class << self
|
|
45
|
+
##
|
|
46
|
+
# @param body [String] the feed document
|
|
47
|
+
# @param count [Integer] items the +rss+ library read from +body+
|
|
48
|
+
# @param url [String, Html2rss::Url, nil] the feed URL (nil when unknown): the base of relative file and
|
|
49
|
+
# image URLs, and named in the log when items cannot be matched
|
|
50
|
+
# @return [Array<Hash{Symbol => Object}>] per item, in document order, its +enclosures+ and +image+
|
|
51
|
+
# when present; all empty when the document declares no media or its item elements do not
|
|
52
|
+
# number +count+ (logged)
|
|
53
|
+
def for_items(body, count:, url:)
|
|
54
|
+
return Array.new(count) { {} } unless MEDIA_MARKERS.match?(body)
|
|
55
|
+
|
|
56
|
+
nodes = Nokogiri::XML(body).xpath(ITEMS, NAMESPACES)
|
|
57
|
+
return nodes.map { |node| item_media(node, url) } if nodes.size == count
|
|
58
|
+
|
|
59
|
+
warn_mismatch(nodes.size, count, url)
|
|
60
|
+
Array.new(count) { {} }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
def item_media(node, feed_url)
|
|
66
|
+
duration = Article::Enclosure.parse_duration(node.at_xpath('itunes:duration', NAMESPACES)&.text)
|
|
67
|
+
files = node.xpath(ENCLOSURES, NAMESPACES).filter_map { file(_1, duration, feed_url) }
|
|
68
|
+
declared = node.at_xpath(ITUNES_IMAGE, NAMESPACES) || node.at_xpath(THUMBNAILS, NAMESPACES)
|
|
69
|
+
{ enclosures: files.uniq { _1[:url].to_s }, image: declared && Url.resolve_web(declared.content, feed_url) }
|
|
70
|
+
.reject { |_, value| value.nil? || value == [] }
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# The file's facts: its declared type, its +medium+ as kind, its byte length and its own duration, else
|
|
74
|
+
# the item's.
|
|
75
|
+
#
|
|
76
|
+
# @return [Hash{Symbol => Object}, nil] nil without an http(s) URL or for a watch or player page not marked
|
|
77
|
+
# an image
|
|
78
|
+
def file(element, item_duration, feed_url)
|
|
79
|
+
return unless (url = Url.resolve_web(attribute(element, 'url', 'href'), feed_url))
|
|
80
|
+
|
|
81
|
+
type = attribute(element, 'type')
|
|
82
|
+
bytes_length = Article::Enclosure.parse_size(attribute(element, 'length', 'fileSize'))
|
|
83
|
+
kind = Article::Enclosure::KIND_TYPES.each_key.find { _1.name == element['medium'] }
|
|
84
|
+
return if kind != :image && Article::Enclosure.new(url:, type:, bytes_length:).player_page?
|
|
85
|
+
|
|
86
|
+
duration = Article::Enclosure.parse_duration(attribute(element, 'duration')) || item_duration
|
|
87
|
+
{ url:, type:, kind:, bytes_length:, duration: }.compact
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def attribute(element, *names)
|
|
91
|
+
names.filter_map { element[_1]&.strip }.find { !_1.empty? }
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def warn_mismatch(elements, count, url)
|
|
95
|
+
Log.warn({
|
|
96
|
+
event_name: 'syndication.item_media_mismatch', feed_url: url,
|
|
97
|
+
message: "Syndication::ItemMedia: #{elements} item elements for #{count} parsed items; no item media read"
|
|
98
|
+
}.to_json)
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
@@ -5,87 +5,124 @@ require 'rss'
|
|
|
5
5
|
module Html2rss
|
|
6
6
|
module Syndication
|
|
7
7
|
##
|
|
8
|
-
# Parses RSS 2.0 and Atom documents into AutoSource article hashes
|
|
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.
|
|
9
17
|
module Parser
|
|
10
18
|
class << self
|
|
11
19
|
##
|
|
12
20
|
# @param response [Html2rss::RequestService::Response]
|
|
13
|
-
# @return [
|
|
14
|
-
# @raise [
|
|
21
|
+
# @return [Feed]
|
|
22
|
+
# @raise [RequestService::UnsupportedResponseContentType] when the response is not a syndication feed
|
|
15
23
|
def parse_response(response)
|
|
16
|
-
|
|
24
|
+
unless response.feed_response?
|
|
25
|
+
raise RequestService::UnsupportedResponseContentType, 'response is not a syndication feed'
|
|
26
|
+
end
|
|
17
27
|
|
|
18
28
|
parse(response.body, base_url: response.url)
|
|
19
29
|
end
|
|
20
30
|
|
|
21
31
|
##
|
|
22
|
-
# @param body [String] raw RSS/Atom document
|
|
32
|
+
# @param body [String] raw RSS/RDF/Atom document
|
|
23
33
|
# @param base_url [String, Html2rss::Url, nil] fallback base for relative item links
|
|
24
|
-
# @return [
|
|
34
|
+
# @return [Feed] no articles and no channel facts when the document fails to parse
|
|
25
35
|
def parse(body, base_url: nil)
|
|
26
36
|
feed = RSS::Parser.parse(body.to_s, false)
|
|
27
|
-
|
|
28
|
-
items_from(feed).filter_map { |item| article_hash(item, base_url:) }
|
|
37
|
+
Feed.new(articles: articles_from(feed, body, base_url:), channel: ChannelFacts.from(feed))
|
|
29
38
|
rescue RSS::Error => error
|
|
30
39
|
Log.warn({
|
|
31
40
|
event_name: 'syndication.parse_failed',
|
|
32
41
|
error_class: error.class.name,
|
|
33
42
|
message: "Syndication::Parser: failed to parse feed (#{error.class}: #{error.message})"
|
|
34
43
|
}.to_json)
|
|
35
|
-
|
|
44
|
+
Feed::NONE
|
|
36
45
|
end
|
|
37
46
|
|
|
38
47
|
private
|
|
39
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:) }
|
|
54
|
+
end
|
|
55
|
+
|
|
40
56
|
def items_from(feed)
|
|
41
57
|
case feed
|
|
42
|
-
when RSS::Rss then feed.items
|
|
58
|
+
when RSS::Rss, RSS::RDF then feed.items
|
|
43
59
|
when RSS::Atom::Feed then feed.entries
|
|
44
60
|
else []
|
|
45
61
|
end
|
|
46
62
|
end
|
|
47
63
|
|
|
48
|
-
def article_hash(item, base_url:)
|
|
64
|
+
def article_hash(item, media, base_url:)
|
|
49
65
|
case item
|
|
50
|
-
when RSS::Rss::Channel::Item then rss_item_hash(item, base_url:)
|
|
51
|
-
when RSS::
|
|
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:)
|
|
52
69
|
end
|
|
53
70
|
end
|
|
54
71
|
|
|
55
|
-
def rss_item_hash(item, base_url:)
|
|
56
|
-
|
|
57
|
-
|
|
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
|
|
58
79
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
description:
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
+
)
|
|
68
95
|
end
|
|
69
96
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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))
|
|
73
105
|
|
|
74
106
|
{
|
|
75
|
-
id: present(
|
|
76
|
-
title: present(
|
|
77
|
-
description: present(
|
|
78
|
-
url:,
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
|
82
114
|
}.compact
|
|
83
115
|
end
|
|
84
116
|
|
|
117
|
+
def media_url(media)
|
|
118
|
+
media.fetch(:enclosures, []).find { |facts| Article::Enclosure.new(**facts).media? }&.fetch(:url)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def atom_text(*nodes) = nodes.find { |node| node }&.content
|
|
122
|
+
|
|
85
123
|
def atom_link(entry, base_url:)
|
|
86
|
-
|
|
87
|
-
link
|
|
88
|
-
absolute_url(link&.href, base_url:)
|
|
124
|
+
link = entry.links.find { |node| node.rel.nil? || node.rel == 'alternate' }
|
|
125
|
+
Url.resolve_web(link&.href, base_url)
|
|
89
126
|
end
|
|
90
127
|
|
|
91
128
|
def atom_author(entry)
|
|
@@ -95,19 +132,6 @@ module Html2rss
|
|
|
95
132
|
present(author.name&.content) || present(author.email&.content)
|
|
96
133
|
end
|
|
97
134
|
|
|
98
|
-
def absolute_url(raw, base_url:)
|
|
99
|
-
value = present(raw)
|
|
100
|
-
return unless value
|
|
101
|
-
|
|
102
|
-
if base_url
|
|
103
|
-
Html2rss::Url.from_relative(value, base_url)
|
|
104
|
-
else
|
|
105
|
-
Html2rss::Url.from_absolute(value)
|
|
106
|
-
end
|
|
107
|
-
rescue Html2rss::Url::InvalidUrlError
|
|
108
|
-
nil
|
|
109
|
-
end
|
|
110
|
-
|
|
111
135
|
def present(value)
|
|
112
136
|
text = value.to_s.strip
|
|
113
137
|
text unless text.empty?
|
|
@@ -38,7 +38,7 @@ module Html2rss
|
|
|
38
38
|
}
|
|
39
39
|
warnings = []
|
|
40
40
|
|
|
41
|
-
selector_engine.each_enhance_pair do |baseline, enhanced, item|
|
|
41
|
+
selector_engine.each_enhance_pair(linked_only: true) do |baseline, enhanced, item|
|
|
42
42
|
item_keys = keys_added(baseline, enhanced)
|
|
43
43
|
merge_keys_added!(gains, item_keys)
|
|
44
44
|
append_item_warnings!(warnings, item:, enhanced:, item_keys:)
|
|
@@ -160,7 +160,7 @@ module Html2rss
|
|
|
160
160
|
|
|
161
161
|
def self.category_only_description?(description, enhanced)
|
|
162
162
|
normalized = description.downcase
|
|
163
|
-
return true if Html2rss::
|
|
163
|
+
return true if Html2rss::Article::Title::TYPE_MARKERS.include?(normalized)
|
|
164
164
|
|
|
165
165
|
categories = Array(enhanced[:categories]).map { |category| category.to_s.strip }
|
|
166
166
|
categories.any? { |category| category.casecmp?(description) }
|
|
@@ -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.rb
CHANGED
|
@@ -17,7 +17,7 @@ module Html2rss
|
|
|
17
17
|
##
|
|
18
18
|
# @param name [Symbol, String]
|
|
19
19
|
# @return [FailureKind]
|
|
20
|
-
def self.[](name) = ALL.fetch(name.to_sym) { raise
|
|
20
|
+
def self.[](name) = ALL.fetch(name.to_sym) { raise InvalidOption, "unknown failure kind: #{name.inspect}" }
|
|
21
21
|
|
|
22
22
|
##
|
|
23
23
|
# @return [Boolean]
|
|
@@ -78,6 +78,7 @@ module Html2rss
|
|
|
78
78
|
:success,
|
|
79
79
|
:item_count,
|
|
80
80
|
:sample_items,
|
|
81
|
+
:items,
|
|
81
82
|
:channel_title,
|
|
82
83
|
:channel_url,
|
|
83
84
|
:strategy_used,
|
|
@@ -100,6 +101,8 @@ module Html2rss
|
|
|
100
101
|
# @param success [Boolean]
|
|
101
102
|
# @param item_count [Integer]
|
|
102
103
|
# @param sample_items [Array<Hash>]
|
|
104
|
+
# @param items [Array<Hash>] up to +sample_limit+ items (title, url, published_at, description, image,
|
|
105
|
+
# categories) built from extracted articles regardless of the quality gate
|
|
103
106
|
# @param channel_title [String, nil]
|
|
104
107
|
# @param channel_url [String, nil]
|
|
105
108
|
# @param strategy_used [Symbol, nil]
|
|
@@ -113,7 +116,7 @@ module Html2rss
|
|
|
113
116
|
# @param response_body [String, nil] fetched page HTML copied from the pipeline response
|
|
114
117
|
def initialize(success:, item_count:, sample_items:, channel_title:, channel_url:, # rubocop:disable Metrics/ParameterLists
|
|
115
118
|
strategy_used:, duration_seconds:, validation_issues:, error_message:,
|
|
116
|
-
failure_kind:, rss:, quality_report: nil, enhance_compare: nil, response_body: nil)
|
|
119
|
+
failure_kind:, rss:, items: [], quality_report: nil, enhance_compare: nil, response_body: nil)
|
|
117
120
|
super
|
|
118
121
|
end
|
|
119
122
|
|
|
@@ -136,6 +139,7 @@ module Html2rss
|
|
|
136
139
|
success:,
|
|
137
140
|
item_count:,
|
|
138
141
|
sample_items:,
|
|
142
|
+
items:,
|
|
139
143
|
channel_title:,
|
|
140
144
|
channel_url:,
|
|
141
145
|
strategy_used:,
|
|
@@ -160,15 +164,18 @@ module Html2rss
|
|
|
160
164
|
# @param strategy [Symbol, nil] optional strategy override
|
|
161
165
|
# @param strict_quality [Boolean] when true, fail on ship-quality audit thresholds
|
|
162
166
|
# @param compare_enhance [Boolean] diagnostic enhance off vs on comparison on cached HTML
|
|
167
|
+
# @param sample_limit [Integer] maximum number of {Result#items} (default 3)
|
|
163
168
|
# @return [Html2rss::Test::Result]
|
|
164
169
|
def self.call(config_input, feed_name = nil, min_items: 1, params: {}, strategy: nil, # rubocop:disable Metrics/ParameterLists
|
|
165
|
-
strict_quality: false, compare_enhance: false)
|
|
170
|
+
strict_quality: false, compare_enhance: false, sample_limit: 3)
|
|
166
171
|
raw_config, validation = Config.resolve_and_validate(config_input, feed_name:, params:)
|
|
167
172
|
return validation_failure_result(validation, raw_config) unless validation.success?
|
|
168
173
|
|
|
169
174
|
config = build_test_config(raw_config, strategy, params)
|
|
170
175
|
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
171
|
-
execute_timed_pipeline(raw_config, config, started, min_items:, strict_quality:, compare_enhance:)
|
|
176
|
+
execute_timed_pipeline(raw_config, config, started, min_items:, strict_quality:, compare_enhance:, sample_limit:)
|
|
177
|
+
rescue Html2rss::ContractError
|
|
178
|
+
raise
|
|
172
179
|
rescue Html2rss::Error => error
|
|
173
180
|
duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started
|
|
174
181
|
execution_failure_result(error, raw_config, duration)
|
|
@@ -193,19 +200,20 @@ module Html2rss
|
|
|
193
200
|
private_class_method :compile_quality_report
|
|
194
201
|
|
|
195
202
|
def self.execute_timed_pipeline(raw_config, config, started, min_items:, strict_quality:, # rubocop:disable Metrics/ParameterLists
|
|
196
|
-
compare_enhance:)
|
|
203
|
+
compare_enhance:, sample_limit:)
|
|
197
204
|
feed_result, pipeline_outcome = extract_feed(raw_config)
|
|
198
205
|
duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started
|
|
199
206
|
quality_report = compile_quality_report(raw_config, config, feed_result, pipeline_outcome)
|
|
200
207
|
enhance_compare = build_enhance_compare(config, pipeline_outcome) if compare_enhance
|
|
201
208
|
|
|
202
209
|
build_test_result(raw_config, feed_result, duration, quality_report, enhance_compare,
|
|
203
|
-
min_items:, strict_quality:, response_body: pipeline_outcome.response.body
|
|
210
|
+
min_items:, strict_quality:, response_body: pipeline_outcome.response.body,
|
|
211
|
+
items: ItemSample.call(pipeline_outcome.articles, limit: sample_limit))
|
|
204
212
|
end
|
|
205
213
|
private_class_method :execute_timed_pipeline
|
|
206
214
|
|
|
207
215
|
def self.build_test_result(raw_config, feed_result, duration, quality_report, enhance_compare, # rubocop:disable Metrics/AbcSize, Metrics/MethodLength, Metrics/ParameterLists
|
|
208
|
-
min_items:, strict_quality:, response_body:)
|
|
216
|
+
min_items:, strict_quality:, response_body:, items:)
|
|
209
217
|
rss_doc = feed_result.to_rss
|
|
210
218
|
outcome = Policy.evaluate(item_count: rss_doc.items.size, min_items:, strict_quality:, quality_report:)
|
|
211
219
|
strategy_used = feed_result.status.selected_strategy || raw_config[:strategy] ||
|
|
@@ -215,6 +223,7 @@ module Html2rss
|
|
|
215
223
|
success: outcome.passed,
|
|
216
224
|
item_count: rss_doc.items.size,
|
|
217
225
|
sample_items: extract_samples(rss_doc.items),
|
|
226
|
+
items:,
|
|
218
227
|
channel_title: feed_result.channel_title,
|
|
219
228
|
channel_url: raw_config.dig(:channel, :url).to_s,
|
|
220
229
|
strategy_used:,
|
|
@@ -246,14 +255,16 @@ module Html2rss
|
|
|
246
255
|
# @param config [Html2rss::Config]
|
|
247
256
|
# @param feed_result [Html2rss::FeedResult]
|
|
248
257
|
# @param pipeline_outcome [Html2rss::FeedPipeline::PipelineOutcome]
|
|
249
|
-
# @param probe_native_feed [Boolean] when false, skip syndication discovery (apply ship gate)
|
|
258
|
+
# @param probe_native_feed [Boolean] when false, skip syndication discovery (apply ship gate);
|
|
259
|
+
# when true, discovery reads the page in +pipeline_outcome.response+ (no extra page fetch)
|
|
250
260
|
# @return [QualityReport]
|
|
251
261
|
def self.quality_report_for(items, channel_url:, config:, feed_result:, pipeline_outcome:, # rubocop:disable Metrics/ParameterLists
|
|
252
262
|
probe_native_feed: true)
|
|
253
263
|
audit = AutoSource::Cleanup.audit_feed_items(items)
|
|
254
|
-
native_feed =
|
|
264
|
+
native_feed = probe_native_feed_url(channel_url, config, pipeline_outcome.response)&.to_s if probe_native_feed
|
|
255
265
|
report = QualityReport.from_audit(audit, native_feed:)
|
|
256
266
|
report = append_url_mismatch_warning(report, channel_url, feed_result)
|
|
267
|
+
report = append_items_without_url_warning(report, pipeline_outcome)
|
|
257
268
|
merge_enhance_audit(report, config, pipeline_outcome)
|
|
258
269
|
end
|
|
259
270
|
|
|
@@ -313,6 +324,21 @@ module Html2rss
|
|
|
313
324
|
end
|
|
314
325
|
private_class_method :enhance_gains_to_h
|
|
315
326
|
|
|
327
|
+
# Selector items dropped for having no URL ({Selectors#admission_drops}) leave nothing for the audit to
|
|
328
|
+
# see, so the warning names the +url+ selector as the place to look.
|
|
329
|
+
def self.append_items_without_url_warning(report, pipeline_outcome)
|
|
330
|
+
dropped = pipeline_outcome.admission_drops.fetch(Selectors::MISSING_URL_REASON, 0)
|
|
331
|
+
return report unless dropped.positive?
|
|
332
|
+
|
|
333
|
+
QualityReport.new(
|
|
334
|
+
warnings: (report.warnings + [:items_without_url]).freeze,
|
|
335
|
+
metrics: report.metrics.merge(items_without_url: dropped),
|
|
336
|
+
native_feed: report.native_feed,
|
|
337
|
+
defer_reason: report.defer_reason
|
|
338
|
+
)
|
|
339
|
+
end
|
|
340
|
+
private_class_method :append_items_without_url_warning
|
|
341
|
+
|
|
316
342
|
def self.append_url_mismatch_warning(report, channel_url, feed_result)
|
|
317
343
|
return report unless url_mismatch?(channel_url, feed_result)
|
|
318
344
|
|
|
@@ -340,15 +366,17 @@ module Html2rss
|
|
|
340
366
|
end
|
|
341
367
|
private_class_method :url_mismatch?
|
|
342
368
|
|
|
343
|
-
|
|
369
|
+
# The page the pipeline fetched (where any redirect landed) is the page probed, as in Recon and Capture: no
|
|
370
|
+
# extra page fetch.
|
|
371
|
+
def self.probe_native_feed_url(channel_url, config, response)
|
|
344
372
|
return nil if channel_url.to_s.empty?
|
|
345
373
|
|
|
346
374
|
resources = FeedPipeline::RuntimePolicy.resources_for(config)
|
|
347
375
|
strategy = FeedPipeline::StrategyPlan.concrete_for_diagnostic(config.strategy)
|
|
348
|
-
session = RequestSession.build(
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
376
|
+
session = RequestSession.build(config:, strategy:, budget: resources.budget, policy: resources.policy)
|
|
377
|
+
Syndication::Discovery.native_feed_url(response:, request_session: session)
|
|
378
|
+
rescue Html2rss::ContractError
|
|
379
|
+
raise
|
|
352
380
|
rescue Html2rss::Error
|
|
353
381
|
nil
|
|
354
382
|
end
|