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.
Files changed (202) 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 -25
  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 +127 -53
  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 -45
  16. data/lib/html2rss/auto_source/scraper/json_document.rb +3 -6
  17. data/lib/html2rss/auto_source/scraper/json_state/article_normalizer.rb +202 -125
  18. data/lib/html2rss/auto_source/scraper/json_state/candidate_detector.rb +27 -38
  19. data/lib/html2rss/auto_source/scraper/json_state/document_scanner.rb +123 -123
  20. data/lib/html2rss/auto_source/scraper/json_state/value_finder.rb +27 -35
  21. data/lib/html2rss/auto_source/scraper/json_state.rb +4 -9
  22. data/lib/html2rss/auto_source/scraper/meta_oembed.rb +27 -5
  23. data/lib/html2rss/auto_source/scraper/microdata.rb +233 -248
  24. data/lib/html2rss/auto_source/scraper/microformats2.rb +10 -4
  25. data/lib/html2rss/auto_source/scraper/native_feed.rb +28 -18
  26. data/lib/html2rss/auto_source/scraper/schema/category_extractor.rb +7 -2
  27. data/lib/html2rss/auto_source/scraper/schema/item_list.rb +5 -3
  28. data/lib/html2rss/auto_source/scraper/schema/thing.rb +140 -22
  29. data/lib/html2rss/auto_source/scraper/schema.rb +52 -8
  30. data/lib/html2rss/auto_source/scraper/semantic_html/entry_deduplicator.rb +3 -9
  31. data/lib/html2rss/auto_source/scraper/semantic_html.rb +12 -20
  32. data/lib/html2rss/auto_source/scraper/sitemap/parser.rb +1 -4
  33. data/lib/html2rss/auto_source/scraper/sitemap.rb +7 -14
  34. data/lib/html2rss/auto_source/scraper/wordpress_api/page_scope.rb +2 -2
  35. data/lib/html2rss/auto_source/scraper/wordpress_api/posts_endpoint.rb +1 -3
  36. data/lib/html2rss/auto_source/scraper/wordpress_api.rb +7 -7
  37. data/lib/html2rss/auto_source/scraper/xhr_articles.rb +2 -4
  38. data/lib/html2rss/auto_source/scraper.rb +10 -13
  39. data/lib/html2rss/auto_source/segment.rb +19 -5
  40. data/lib/html2rss/auto_source/segmenter/cluster.rb +18 -22
  41. data/lib/html2rss/auto_source/segmenter/list.rb +35 -59
  42. data/lib/html2rss/auto_source/segmenter/primary_link.rb +29 -4
  43. data/lib/html2rss/auto_source/segmenter/semantic.rb +23 -19
  44. data/lib/html2rss/auto_source/segmenter.rb +4 -8
  45. data/lib/html2rss/auto_source.rb +81 -26
  46. data/lib/html2rss/batch.rb +24 -32
  47. data/lib/html2rss/capture/README.md +6 -3
  48. data/lib/html2rss/capture.rb +51 -59
  49. data/lib/html2rss/channel.rb +30 -12
  50. data/lib/html2rss/cli/render.rb +97 -96
  51. data/lib/html2rss/cli/validate.rb +2 -4
  52. data/lib/html2rss/cli.rb +55 -38
  53. data/lib/html2rss/config/auto_source_contract.rb +52 -45
  54. data/lib/html2rss/config/issue_mapper.rb +4 -1
  55. data/lib/html2rss/config/request_controls.rb +16 -5
  56. data/lib/html2rss/config/schema.rb +23 -15
  57. data/lib/html2rss/config/selectors_validator.rb +3 -2
  58. data/lib/html2rss/config/validation_issue.rb +4 -1
  59. data/lib/html2rss/config/validator.rb +14 -3
  60. data/lib/html2rss/config.rb +54 -28
  61. data/lib/html2rss/contract_error.rb +9 -0
  62. data/lib/html2rss/defaults.rb +25 -38
  63. data/lib/html2rss/doctor/botasaurus.rb +18 -22
  64. data/lib/html2rss/error.rb +17 -3
  65. data/lib/html2rss/error_kind.rb +39 -0
  66. data/lib/html2rss/feed_builder/item_presentation.rb +22 -9
  67. data/lib/html2rss/feed_builder/json_feed/item.rb +2 -1
  68. data/lib/html2rss/feed_builder/json_feed.rb +2 -2
  69. data/lib/html2rss/feed_builder/rss/stylesheet.rb +3 -3
  70. data/lib/html2rss/feed_builder/rss.rb +50 -7
  71. data/lib/html2rss/feed_builder.rb +2 -1
  72. data/lib/html2rss/feed_pipeline/README.md +1 -1
  73. data/lib/html2rss/feed_pipeline/auto_fallback.rb +16 -34
  74. data/lib/html2rss/feed_pipeline/strategy_plan.rb +3 -3
  75. data/lib/html2rss/feed_pipeline.rb +40 -27
  76. data/lib/html2rss/feed_resolution/README.md +1 -1
  77. data/lib/html2rss/feed_resolution/candidate_generator.rb +6 -8
  78. data/lib/html2rss/feed_resolution/options.rb +3 -7
  79. data/lib/html2rss/feed_resolution/policy.rb +29 -33
  80. data/lib/html2rss/feed_resolution/probe.rb +6 -4
  81. data/lib/html2rss/feed_resolution/scorer.rb +29 -29
  82. data/lib/html2rss/feed_resolution.rb +16 -21
  83. data/lib/html2rss/feed_result.rb +11 -6
  84. data/lib/html2rss/hash_util.rb +91 -90
  85. data/lib/html2rss/html/article_extractor/category_extractor.rb +7 -10
  86. data/lib/html2rss/html/article_extractor/date_extractor.rb +1 -1
  87. data/lib/html2rss/html/article_extractor/enclosure_extractor.rb +12 -33
  88. data/lib/html2rss/html/article_extractor/heading_extractor.rb +2 -3
  89. data/lib/html2rss/html/article_extractor/id_generator.rb +7 -10
  90. data/lib/html2rss/html/article_extractor/image_extractor.rb +9 -1
  91. data/lib/html2rss/html/article_extractor.rb +19 -47
  92. data/lib/html2rss/html/article_rules/category.rb +3 -3
  93. data/lib/html2rss/html/article_rules/description.rb +6 -15
  94. data/lib/html2rss/html/article_rules/enclosure.rb +70 -42
  95. data/lib/html2rss/html/article_rules/image.rb +30 -16
  96. data/lib/html2rss/html/feed_link.rb +7 -9
  97. data/lib/html2rss/html/navigator/text_extractor.rb +26 -16
  98. data/lib/html2rss/html/navigator.rb +39 -9
  99. data/lib/html2rss/html/probe.rb +55 -51
  100. data/lib/html2rss/html/rendering/description_builder.rb +46 -32
  101. data/lib/html2rss/html/rendering.rb +2 -9
  102. data/lib/html2rss/html/sst_article_extractor.rb +55 -145
  103. data/lib/html2rss/html/title_pieces/kicker.rb +147 -0
  104. data/lib/html2rss/html/title_pieces/nodes.rb +107 -0
  105. data/lib/html2rss/html/title_pieces/nokogiri_nodes.rb +70 -0
  106. data/lib/html2rss/html/title_pieces/sst_nodes.rb +71 -0
  107. data/lib/html2rss/html/title_pieces.rb +169 -0
  108. data/lib/html2rss/html/visibility.rb +56 -0
  109. data/lib/html2rss/html.rb +2 -0
  110. data/lib/html2rss/link_destination/destination_facts.rb +17 -1
  111. data/lib/html2rss/link_destination/noise_policy.rb +11 -10
  112. data/lib/html2rss/link_destination/path_classifier.rb +111 -42
  113. data/lib/html2rss/link_destination/sponsorship/label_text.rb +82 -0
  114. data/lib/html2rss/link_destination/sponsorship.rb +184 -0
  115. data/lib/html2rss/link_destination/text_classifier.rb +104 -8
  116. data/lib/html2rss/mcp/README.md +13 -1
  117. data/lib/html2rss/mcp/config_argument.rb +14 -4
  118. data/lib/html2rss/mcp/contract.rb +20 -9
  119. data/lib/html2rss/mcp/outcome.rb +18 -17
  120. data/lib/html2rss/mcp/runtime.rb +53 -23
  121. data/lib/html2rss/mcp/server/tools.rb +9 -9
  122. data/lib/html2rss/mcp/server.rb +13 -4
  123. data/lib/html2rss/option.rb +48 -0
  124. data/lib/html2rss/page_recon/README.md +1 -1
  125. data/lib/html2rss/page_recon/diagnostics.rb +9 -9
  126. data/lib/html2rss/page_recon.rb +0 -4
  127. data/lib/html2rss/recon.rb +19 -23
  128. data/lib/html2rss/request_service/blocked_surface.rb +5 -8
  129. data/lib/html2rss/request_service/botasaurus_contract.rb +25 -3
  130. data/lib/html2rss/request_service/botasaurus_strategy.rb +44 -12
  131. data/lib/html2rss/request_service/budget.rb +3 -24
  132. data/lib/html2rss/request_service/compressed_body.rb +36 -48
  133. data/lib/html2rss/request_service/context.rb +16 -17
  134. data/lib/html2rss/request_service/cookie_jar.rb +89 -0
  135. data/lib/html2rss/request_service/cookie_store.rb +79 -0
  136. data/lib/html2rss/request_service/feed_advertisement.rb +72 -0
  137. data/lib/html2rss/request_service/httpx_strategy.rb +7 -3
  138. data/lib/html2rss/request_service/local_file_strategy.rb +2 -3
  139. data/lib/html2rss/request_service/network_guard.rb +3 -2
  140. data/lib/html2rss/request_service/policy.rb +55 -36
  141. data/lib/html2rss/request_service/replay_breaker.rb +56 -0
  142. data/lib/html2rss/request_service/response.rb +33 -19
  143. data/lib/html2rss/request_service/response_cache.rb +109 -0
  144. data/lib/html2rss/request_service/response_caching.rb +95 -0
  145. data/lib/html2rss/request_service/strategy.rb +5 -11
  146. data/lib/html2rss/request_service.rb +71 -12
  147. data/lib/html2rss/request_session/pager/base.rb +3 -10
  148. data/lib/html2rss/request_session/pager/custom_selector.rb +4 -4
  149. data/lib/html2rss/request_session/pager/offset.rb +1 -7
  150. data/lib/html2rss/request_session/pager/rel_next.rb +1 -1
  151. data/lib/html2rss/request_session/pager/url_template.rb +1 -7
  152. data/lib/html2rss/request_session/pager.rb +1 -1
  153. data/lib/html2rss/request_session.rb +6 -2
  154. data/lib/html2rss/scoring/cluster_scorer.rb +1 -1
  155. data/lib/html2rss/scoring/container_assessor.rb +12 -13
  156. data/lib/html2rss/scoring/engine.rb +10 -38
  157. data/lib/html2rss/scoring/link_resolver.rb +2 -7
  158. data/lib/html2rss/scoring/observation.rb +2 -2
  159. data/lib/html2rss/selector_candidates.rb +19 -8
  160. data/lib/html2rss/selectors/README.md +1 -1
  161. data/lib/html2rss/selectors/extractors/href.rb +2 -4
  162. data/lib/html2rss/selectors/json_xml.rb +33 -34
  163. data/lib/html2rss/selectors/option_spec.rb +4 -1
  164. data/lib/html2rss/selectors/post_processors/base.rb +0 -8
  165. data/lib/html2rss/selectors/post_processors/gsub.rb +9 -10
  166. data/lib/html2rss/selectors/post_processors/html_transformers/keep_playable_media.rb +50 -0
  167. data/lib/html2rss/selectors/post_processors/html_transformers/transform_urls_to_absolute_ones.rb +1 -3
  168. data/lib/html2rss/selectors/post_processors/parse_time.rb +5 -6
  169. data/lib/html2rss/selectors/post_processors/parse_uri.rb +1 -1
  170. data/lib/html2rss/selectors/post_processors/sanitize_html.rb +18 -5
  171. data/lib/html2rss/selectors/post_processors/substring.rb +1 -1
  172. data/lib/html2rss/selectors/schema_export.rb +60 -60
  173. data/lib/html2rss/selectors.rb +67 -30
  174. data/lib/html2rss/sst/attrs.rb +2 -2
  175. data/lib/html2rss/sst/document.rb +3 -3
  176. data/lib/html2rss/sst/index.rb +30 -11
  177. data/lib/html2rss/sst/node.rb +42 -29
  178. data/lib/html2rss/sst/normalizer.rb +47 -34
  179. data/lib/html2rss/sst/tags.rb +113 -2
  180. data/lib/html2rss/sst/text.rb +94 -58
  181. data/lib/html2rss/sst.rb +2 -0
  182. data/lib/html2rss/status.rb +27 -31
  183. data/lib/html2rss/surface_category.rb +1 -1
  184. data/lib/html2rss/syndication/README.md +12 -6
  185. data/lib/html2rss/syndication/alternate_links.rb +39 -0
  186. data/lib/html2rss/syndication/channel_facts.rb +87 -0
  187. data/lib/html2rss/syndication/discovery.rb +171 -187
  188. data/lib/html2rss/syndication/feed.rb +20 -0
  189. data/lib/html2rss/syndication/item_media.rb +103 -0
  190. data/lib/html2rss/syndication/parser.rb +117 -116
  191. data/lib/html2rss/test/enhance_audit.rb +15 -17
  192. data/lib/html2rss/test/item_sample.rb +43 -0
  193. data/lib/html2rss/test/policy.rb +9 -11
  194. data/lib/html2rss/test.rb +66 -57
  195. data/lib/html2rss/url.rb +163 -62
  196. data/lib/html2rss/version.rb +1 -1
  197. data/lib/html2rss/word_count.rb +53 -0
  198. data/lib/html2rss.rb +19 -9
  199. data/schema/html2rss-config.schema.json +56 -41
  200. metadata +33 -4
  201. data/lib/html2rss/html/rendering/media_renderer.rb +0 -39
  202. 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
- module Parser # rubocop:disable Metrics/ModuleLength -- feed formats + structured parse warn
10
- module_function
11
-
12
- ##
13
- # @param response [Html2rss::RequestService::Response]
14
- # @return [Array<Hash{Symbol => Object}>] article hashes (may be empty)
15
- # @raise [ArgumentError] when the response is not a syndication feed
16
- def parse_response(response)
17
- raise ArgumentError, 'response is not a syndication feed' unless response.feed_response?
18
-
19
- parse(response.body, base_url: response.url)
20
- end
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
- # @param body [String] raw RSS/Atom document
24
- # @param base_url [String, Html2rss::Url, nil] fallback base for relative item links
25
- # @return [Array<Hash{Symbol => Object}>]
26
- def parse(body, base_url: nil)
27
- feed = RSS::Parser.parse(body.to_s, false)
28
- return [] unless feed
29
-
30
- items_from(feed, base_url:).filter_map { |item| article_hash(item, base_url:) }
31
- rescue RSS::Error => error
32
- Log.warn({
33
- event_name: 'syndication.parse_failed',
34
- error_class: error.class.name,
35
- message: "Syndication::Parser: failed to parse feed (#{error.class}: #{error.message})"
36
- }.to_json)
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
- def items_from(feed, base_url:) # rubocop:disable Lint/UnusedMethodArgument -- base reserved for callers
41
- case feed
42
- when RSS::Rss then Array(feed.items)
43
- when RSS::Atom::Feed then Array(feed.entries)
44
- else []
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
- def article_hash(item, base_url:)
51
- case item
52
- when RSS::Rss::Channel::Item then rss_item_hash(item, base_url:)
53
- when RSS::Atom::Entry, RSS::Atom::Feed::Entry then atom_entry_hash(item, base_url:)
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
- def atom_link(entry, base_url:)
94
- links = Array(entry.links)
95
- link = links.find { |node| node.rel.nil? || node.rel == 'alternate' } || links.first
96
- absolute_url(link&.href, base_url:)
97
- end
98
- module_function :atom_link
99
- private_class_method :atom_link
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
- def atom_text(node)
102
- return unless node.is_a?(RSS::Atom::TextConstruct) || node.is_a?(RSS::Atom::Feed::Entry::Content)
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
- node.content
105
- end
106
- module_function :atom_text
107
- private_class_method :atom_text
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
- def atom_author(entry)
110
- author = Array(entry.author).first
111
- return unless author
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
- present(author.name&.content) || present(author.email&.content)
114
- end
115
- module_function :atom_author
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
- def absolute_url(raw, base_url:)
119
- value = present(raw)
120
- return unless value
121
+ def atom_text(*nodes) = nodes.find { |node| node }&.content
121
122
 
122
- if base_url
123
- Html2rss::Url.from_relative(value, base_url)
124
- else
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
- def present(value)
134
- text = value.to_s.strip
135
- text unless text.empty?
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::Html::ArticleRules::Description::TYPE_CHIPS.include?(normalized)
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
@@ -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?