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.
Files changed (177) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +4 -2
  3. data/lib/html2rss/article/deduplicator.rb +5 -2
  4. data/lib/html2rss/article/enclosure.rb +185 -21
  5. data/lib/html2rss/article/title.rb +203 -0
  6. data/lib/html2rss/article.rb +79 -13
  7. data/lib/html2rss/auto_source/README.md +16 -8
  8. data/lib/html2rss/auto_source/cleanup.rb +126 -50
  9. data/lib/html2rss/auto_source/corroboration.rb +167 -0
  10. data/lib/html2rss/auto_source/page_links/anchor_claims.rb +82 -0
  11. data/lib/html2rss/auto_source/page_links/card_units.rb +77 -0
  12. data/lib/html2rss/auto_source/page_links/label_binding.rb +79 -0
  13. data/lib/html2rss/auto_source/page_links/sponsored_cards.rb +147 -0
  14. data/lib/html2rss/auto_source/page_links.rb +164 -0
  15. data/lib/html2rss/auto_source/scraper/html.rb +11 -39
  16. data/lib/html2rss/auto_source/scraper/json_document.rb +1 -3
  17. data/lib/html2rss/auto_source/scraper/json_state/article_normalizer.rb +119 -42
  18. data/lib/html2rss/auto_source/scraper/json_state/candidate_detector.rb +5 -16
  19. data/lib/html2rss/auto_source/scraper/json_state/value_finder.rb +2 -10
  20. data/lib/html2rss/auto_source/scraper/json_state.rb +2 -3
  21. data/lib/html2rss/auto_source/scraper/meta_oembed.rb +6 -14
  22. data/lib/html2rss/auto_source/scraper/microdata.rb +6 -4
  23. data/lib/html2rss/auto_source/scraper/microformats2.rb +1 -3
  24. data/lib/html2rss/auto_source/scraper/native_feed.rb +27 -17
  25. data/lib/html2rss/auto_source/scraper/schema/item_list.rb +5 -3
  26. data/lib/html2rss/auto_source/scraper/schema/thing.rb +68 -27
  27. data/lib/html2rss/auto_source/scraper/schema.rb +5 -8
  28. data/lib/html2rss/auto_source/scraper/semantic_html/entry_deduplicator.rb +3 -9
  29. data/lib/html2rss/auto_source/scraper/semantic_html.rb +1 -5
  30. data/lib/html2rss/auto_source/scraper/sitemap.rb +4 -3
  31. data/lib/html2rss/auto_source/scraper/wordpress_api.rb +3 -3
  32. data/lib/html2rss/auto_source/scraper/xhr_articles.rb +1 -3
  33. data/lib/html2rss/auto_source/scraper.rb +10 -2
  34. data/lib/html2rss/auto_source/segment.rb +19 -5
  35. data/lib/html2rss/auto_source/segmenter/cluster.rb +13 -11
  36. data/lib/html2rss/auto_source/segmenter/list.rb +13 -5
  37. data/lib/html2rss/auto_source/segmenter/primary_link.rb +29 -4
  38. data/lib/html2rss/auto_source/segmenter/semantic.rb +17 -6
  39. data/lib/html2rss/auto_source/segmenter.rb +4 -8
  40. data/lib/html2rss/auto_source.rb +81 -26
  41. data/lib/html2rss/batch.rb +2 -0
  42. data/lib/html2rss/capture/README.md +5 -2
  43. data/lib/html2rss/capture.rb +44 -32
  44. data/lib/html2rss/channel.rb +30 -12
  45. data/lib/html2rss/cli.rb +22 -11
  46. data/lib/html2rss/config/auto_source_contract.rb +52 -45
  47. data/lib/html2rss/config/issue_mapper.rb +4 -1
  48. data/lib/html2rss/config/request_controls.rb +6 -3
  49. data/lib/html2rss/config/schema.rb +15 -5
  50. data/lib/html2rss/config/selectors_validator.rb +2 -2
  51. data/lib/html2rss/config/validation_issue.rb +4 -1
  52. data/lib/html2rss/config/validator.rb +11 -0
  53. data/lib/html2rss/config.rb +53 -27
  54. data/lib/html2rss/contract_error.rb +9 -0
  55. data/lib/html2rss/defaults.rb +21 -23
  56. data/lib/html2rss/doctor/botasaurus.rb +7 -3
  57. data/lib/html2rss/error.rb +10 -0
  58. data/lib/html2rss/error_kind.rb +39 -0
  59. data/lib/html2rss/feed_builder/item_presentation.rb +22 -9
  60. data/lib/html2rss/feed_builder/json_feed/item.rb +2 -1
  61. data/lib/html2rss/feed_builder/json_feed.rb +2 -2
  62. data/lib/html2rss/feed_builder/rss/stylesheet.rb +3 -3
  63. data/lib/html2rss/feed_builder/rss.rb +49 -6
  64. data/lib/html2rss/feed_builder.rb +2 -1
  65. data/lib/html2rss/feed_pipeline/README.md +1 -1
  66. data/lib/html2rss/feed_pipeline/auto_fallback.rb +13 -31
  67. data/lib/html2rss/feed_pipeline/strategy_plan.rb +3 -3
  68. data/lib/html2rss/feed_pipeline.rb +40 -27
  69. data/lib/html2rss/feed_resolution/README.md +1 -1
  70. data/lib/html2rss/feed_resolution/candidate_generator.rb +3 -4
  71. data/lib/html2rss/feed_resolution/policy.rb +1 -1
  72. data/lib/html2rss/feed_resolution/probe.rb +3 -1
  73. data/lib/html2rss/feed_resolution.rb +11 -12
  74. data/lib/html2rss/feed_result.rb +9 -5
  75. data/lib/html2rss/hash_util.rb +3 -3
  76. data/lib/html2rss/html/article_extractor/category_extractor.rb +1 -1
  77. data/lib/html2rss/html/article_extractor/enclosure_extractor.rb +12 -31
  78. data/lib/html2rss/html/article_extractor/heading_extractor.rb +2 -3
  79. data/lib/html2rss/html/article_extractor/id_generator.rb +6 -9
  80. data/lib/html2rss/html/article_extractor/image_extractor.rb +9 -1
  81. data/lib/html2rss/html/article_extractor.rb +19 -47
  82. data/lib/html2rss/html/article_rules/category.rb +3 -3
  83. data/lib/html2rss/html/article_rules/description.rb +6 -15
  84. data/lib/html2rss/html/article_rules/enclosure.rb +70 -42
  85. data/lib/html2rss/html/article_rules/image.rb +30 -16
  86. data/lib/html2rss/html/feed_link.rb +7 -9
  87. data/lib/html2rss/html/navigator/text_extractor.rb +26 -16
  88. data/lib/html2rss/html/navigator.rb +39 -9
  89. data/lib/html2rss/html/probe.rb +4 -0
  90. data/lib/html2rss/html/rendering/description_builder.rb +46 -32
  91. data/lib/html2rss/html/rendering.rb +2 -9
  92. data/lib/html2rss/html/sst_article_extractor.rb +55 -145
  93. data/lib/html2rss/html/title_pieces/kicker.rb +147 -0
  94. data/lib/html2rss/html/title_pieces/nodes.rb +107 -0
  95. data/lib/html2rss/html/title_pieces/nokogiri_nodes.rb +70 -0
  96. data/lib/html2rss/html/title_pieces/sst_nodes.rb +71 -0
  97. data/lib/html2rss/html/title_pieces.rb +169 -0
  98. data/lib/html2rss/html/visibility.rb +56 -0
  99. data/lib/html2rss/html.rb +2 -0
  100. data/lib/html2rss/link_destination/destination_facts.rb +17 -1
  101. data/lib/html2rss/link_destination/noise_policy.rb +11 -10
  102. data/lib/html2rss/link_destination/path_classifier.rb +100 -9
  103. data/lib/html2rss/link_destination/sponsorship/label_text.rb +82 -0
  104. data/lib/html2rss/link_destination/sponsorship.rb +184 -0
  105. data/lib/html2rss/link_destination/text_classifier.rb +104 -8
  106. data/lib/html2rss/mcp/README.md +13 -1
  107. data/lib/html2rss/mcp/config_argument.rb +5 -2
  108. data/lib/html2rss/mcp/contract.rb +13 -4
  109. data/lib/html2rss/mcp/outcome.rb +14 -12
  110. data/lib/html2rss/mcp/runtime.rb +39 -2
  111. data/lib/html2rss/mcp/server.rb +13 -4
  112. data/lib/html2rss/page_recon/README.md +1 -1
  113. data/lib/html2rss/page_recon/diagnostics.rb +2 -0
  114. data/lib/html2rss/recon.rb +10 -12
  115. data/lib/html2rss/request_service/blocked_surface.rb +2 -1
  116. data/lib/html2rss/request_service/botasaurus_contract.rb +25 -3
  117. data/lib/html2rss/request_service/botasaurus_strategy.rb +42 -10
  118. data/lib/html2rss/request_service/context.rb +15 -14
  119. data/lib/html2rss/request_service/cookie_jar.rb +89 -0
  120. data/lib/html2rss/request_service/cookie_store.rb +79 -0
  121. data/lib/html2rss/request_service/feed_advertisement.rb +72 -0
  122. data/lib/html2rss/request_service/httpx_strategy.rb +7 -3
  123. data/lib/html2rss/request_service/local_file_strategy.rb +2 -2
  124. data/lib/html2rss/request_service/policy.rb +48 -11
  125. data/lib/html2rss/request_service/replay_breaker.rb +56 -0
  126. data/lib/html2rss/request_service/response.rb +30 -13
  127. data/lib/html2rss/request_service/response_cache.rb +109 -0
  128. data/lib/html2rss/request_service/response_caching.rb +95 -0
  129. data/lib/html2rss/request_service/strategy.rb +5 -11
  130. data/lib/html2rss/request_service.rb +71 -12
  131. data/lib/html2rss/request_session/pager/base.rb +0 -7
  132. data/lib/html2rss/request_session/pager.rb +1 -1
  133. data/lib/html2rss/request_session.rb +6 -2
  134. data/lib/html2rss/scoring/cluster_scorer.rb +1 -1
  135. data/lib/html2rss/scoring/container_assessor.rb +12 -13
  136. data/lib/html2rss/scoring/engine.rb +6 -6
  137. data/lib/html2rss/scoring/observation.rb +2 -2
  138. data/lib/html2rss/selector_candidates.rb +19 -8
  139. data/lib/html2rss/selectors/README.md +1 -1
  140. data/lib/html2rss/selectors/option_spec.rb +4 -1
  141. data/lib/html2rss/selectors/post_processors/base.rb +0 -8
  142. data/lib/html2rss/selectors/post_processors/gsub.rb +7 -7
  143. data/lib/html2rss/selectors/post_processors/html_transformers/keep_playable_media.rb +50 -0
  144. data/lib/html2rss/selectors/post_processors/html_transformers/transform_urls_to_absolute_ones.rb +1 -3
  145. data/lib/html2rss/selectors/post_processors/parse_time.rb +3 -3
  146. data/lib/html2rss/selectors/post_processors/parse_uri.rb +1 -1
  147. data/lib/html2rss/selectors/post_processors/sanitize_html.rb +18 -5
  148. data/lib/html2rss/selectors/post_processors/substring.rb +1 -1
  149. data/lib/html2rss/selectors.rb +53 -12
  150. data/lib/html2rss/sst/attrs.rb +2 -2
  151. data/lib/html2rss/sst/document.rb +3 -3
  152. data/lib/html2rss/sst/index.rb +30 -11
  153. data/lib/html2rss/sst/node.rb +33 -26
  154. data/lib/html2rss/sst/normalizer.rb +31 -19
  155. data/lib/html2rss/sst/tags.rb +113 -2
  156. data/lib/html2rss/sst/text.rb +69 -29
  157. data/lib/html2rss/sst.rb +2 -0
  158. data/lib/html2rss/status.rb +6 -3
  159. data/lib/html2rss/surface_category.rb +1 -1
  160. data/lib/html2rss/syndication/README.md +12 -6
  161. data/lib/html2rss/syndication/alternate_links.rb +1 -1
  162. data/lib/html2rss/syndication/channel_facts.rb +87 -0
  163. data/lib/html2rss/syndication/discovery.rb +82 -28
  164. data/lib/html2rss/syndication/feed.rb +20 -0
  165. data/lib/html2rss/syndication/item_media.rb +103 -0
  166. data/lib/html2rss/syndication/parser.rb +75 -51
  167. data/lib/html2rss/test/enhance_audit.rb +2 -2
  168. data/lib/html2rss/test/item_sample.rb +43 -0
  169. data/lib/html2rss/test.rb +42 -14
  170. data/lib/html2rss/url.rb +115 -14
  171. data/lib/html2rss/version.rb +1 -1
  172. data/lib/html2rss/word_count.rb +53 -0
  173. data/lib/html2rss.rb +17 -8
  174. data/schema/html2rss-config.schema.json +56 -41
  175. metadata +31 -4
  176. data/lib/html2rss/html/rendering/media_renderer.rb +0 -39
  177. 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 [Array<Hash{Symbol => Object}>] article hashes (may be empty)
14
- # @raise [ArgumentError] when the response is not a syndication feed
21
+ # @return [Feed]
22
+ # @raise [RequestService::UnsupportedResponseContentType] when the response is not a syndication feed
15
23
  def parse_response(response)
16
- raise ArgumentError, 'response is not a syndication feed' unless response.feed_response?
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 [Array<Hash{Symbol => Object}>]
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::Atom::Feed::Entry then atom_entry_hash(item, base_url:)
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:) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength -- RSS item mapping
56
- url = absolute_url(item.link, base_url:)
57
- return unless url
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
- id: present(item.guid&.content) || url.to_s,
61
- title: present(item.title),
62
- description: present(item.description),
63
- url:,
64
- published_at: item.pubDate,
65
- author: present(item.author),
66
- categories: item.categories.filter_map { |cat| present(cat.content) }
67
- }.compact
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
- def atom_entry_hash(entry, base_url:) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity -- Atom entry mapping
71
- url = atom_link(entry, base_url:)
72
- return unless url
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(entry.id&.content) || url.to_s,
76
- title: present(entry.title&.content),
77
- description: present(entry.content&.content || entry.summary&.content),
78
- url:,
79
- published_at: entry.published&.content || entry.updated&.content,
80
- author: atom_author(entry),
81
- categories: entry.categories.filter_map { |cat| present(cat.term) }
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
- links = entry.links
87
- link = links.find { |node| node.rel.nil? || node.rel == 'alternate' } || links.first
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::Html::ArticleRules::Description::TYPE_CHIPS.include?(normalized)
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 ArgumentError, "unknown failure kind: #{name.inspect}" }
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 = probe_native_feed ? probe_native_feed_url(channel_url, config)&.to_s : nil
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
- def self.probe_native_feed_url(channel_url, config)
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
- config:, strategy:, budget: resources.budget, policy: resources.policy
350
- )
351
- Syndication::Discovery.best_feed_url(page_url: channel_url, request_session: session)
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