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
@@ -3,217 +3,201 @@
3
3
  module Html2rss
4
4
  module Syndication
5
5
  ##
6
- # Finds a same-origin RSS/Atom URL for a page via head alternates and path guesses.
6
+ # Finds an RSS/Atom URL for a page via head alternates and same-origin path guesses.
7
7
  #
8
8
  # Ports configs +probe_rss+ path/alternate logic onto {RequestSession#follow_up}.
9
- module Discovery # rubocop:disable Metrics/ModuleLength -- discovery + path probes stay co-located
9
+ # Head alternates keep document order within two groups: those whose title or URL
10
+ # hints audio ({AUDIO_HINT}) and the rest. On a page that shows podcast signals
11
+ # ({PODCAST_PAGE_HINT} in its URL host, path or +<title>+, an +og:type+ matching {MEDIA_OG_TYPE},
12
+ # or a {PODCAST_SCHEMA_TYPE} in its JSON-LD) the order is audio-hinted alternates, other
13
+ # alternates, path guesses. On any other page it is other alternates, path guesses, then the
14
+ # audio-hinted alternates, so a news page that advertises only a podcast feed does not become
15
+ # a media feed. This is the one ordering owner: {.best_feed_url}, {.candidate_urls} and the
16
+ # feed-resolution feed slot all read it. Alternates are fetched as +:advertised_feed+ with the
17
+ # page's {RequestService::FeedAdvertisement}, so a feed the page advertises on another host
18
+ # is followed (see {RequestService::Policy}). Path guesses stay same-origin.
19
+ module Discovery # rubocop:disable Metrics/ModuleLength -- discovery ordering + probe stay on one owner
10
20
  # Common feed path suffixes probed after head +rel=alternate+ hints.
11
21
  DEFAULT_PATHS = CandidateCatalog::FEED_PATHS
12
-
13
- LINK_TAG_RE = /<link\b[^>]*>/i
14
- HREF_RE = /\bhref\s*=\s*["']([^"']+)["']/i
15
- TYPE_RE = /\btype\s*=\s*["']([^"']+)["']/i
16
- REL_RE = /\brel\s*=\s*["']([^"']+)["']/i
17
- private_constant :LINK_TAG_RE, :HREF_RE, :TYPE_RE, :REL_RE
18
-
19
- module_function
20
-
21
- ##
22
- # Probes candidates lazily and returns the first feedish URL.
23
- #
24
- # @param page_url [String, Html2rss::Url] page or origin URL
25
- # @param request_session [Html2rss::RequestSession] shared session for follow-ups
26
- # @param parsed_body [Nokogiri::HTML::Document, nil] parsed HTML when available
27
- # @param html [String, nil] raw HTML when a document is unavailable
28
- # @param extra_paths [Array<String>] additional path guesses
29
- # @param max_probes [Integer, nil] max candidate GETs (nil = uncapped)
30
- # @return [Html2rss::Url, nil]
31
- # rubocop:disable-next Metrics/ParameterLists -- discovery kwargs stay co-located
32
- def best_feed_url(page_url:, request_session:, parsed_body: nil, html: nil, extra_paths: [],
33
- max_probes: nil)
34
- best_feed_response(
35
- page_url:, request_session:, parsed_body:, html:, extra_paths:, max_probes:
36
- )&.url
37
- end
38
-
39
- ##
40
- # Like {#best_feed_url} but returns the validated syndication response for parsing.
41
- #
42
- # @param page_url [String, Html2rss::Url]
43
- # @param request_session [Html2rss::RequestSession]
44
- # @param parsed_body [Nokogiri::HTML::Document, nil]
45
- # @param html [String, nil]
46
- # @param extra_paths [Array<String>]
47
- # @param max_probes [Integer, nil] max candidate GETs (nil = uncapped)
48
- # @return [Html2rss::RequestService::Response, nil]
49
- # rubocop:disable-next Metrics/ParameterLists, Metrics/MethodLength -- discovery kwargs stay co-located
50
- def best_feed_response(page_url:, request_session:, parsed_body: nil, html: nil, extra_paths: [],
51
- max_probes: nil)
52
- page = Html2rss::Url.from_absolute(page_url)
53
- candidates = candidate_urls(page_url: page, parsed_body:, html:, extra_paths:)
54
- Log.debug(
55
- "Syndication::Discovery: host=#{page.host} candidate_count=#{candidates.size}"
56
- )
57
-
58
- first_feedish_response(
59
- candidates, request_session:, origin_url: page, max_probes:
60
- ).tap do |response|
61
- next unless response
62
-
63
- Log.info({
64
- event_name: 'syndication.discovery.selected',
65
- host: page.host,
66
- message: "Syndication::Discovery: host=#{page.host} selected_feed_url=#{response.url}"
67
- }.to_json)
22
+ # Words marking a podcast page in its URL host, path or title; {AUDIO_HINT} adds audio file formats.
23
+ PODCAST_WORDS = %w[podcast episode audio].freeze
24
+ # Word-ish podcast hint in a page's URL host, path or +<title>+ (plural allowed, case-insensitive).
25
+ PODCAST_PAGE_HINT = /(?<![[:alpha:]])(?:#{PODCAST_WORDS.join('|')})s?(?![[:alpha:]])/i
26
+ # Word-ish audio hint in an alternate's title or URL (Podlove-style per-format podcast feeds).
27
+ AUDIO_HINT = /(?<![[:alpha:]])(?:#{(PODCAST_WORDS + %w[mp3 m4a aac opus]).join('|')})s?(?![[:alpha:]])/i
28
+ # +og:type+ values (+music.song+, +video.episode+, ...) marking a podcast or media page.
29
+ MEDIA_OG_TYPE = /\A\s*(?:music|video)\./i
30
+ # A JSON-LD +@type+ naming podcast content (short name or schema.org URL, alone or in an
31
+ # array), matched on the script text anywhere in the page without parsing the JSON.
32
+ PODCAST_SCHEMA_TYPE = %r{"@type"\s*:\s*(?:\[\s*(?:"[^"]*"\s*,\s*)*)?
33
+ "(?:https?://schema\.org/)?(?:PodcastSeries|PodcastEpisode|AudioObject)"}ix
34
+
35
+ class << self
36
+ ##
37
+ # Probes candidates lazily and returns the first feedish URL.
38
+ #
39
+ # @param page_url [String, Html2rss::Url] page or origin URL
40
+ # @param request_session [Html2rss::RequestSession] shared session for follow-ups
41
+ # @param parsed_body [Nokogiri::HTML::Document, nil] parsed HTML when available
42
+ # @param html [String, nil] raw HTML when a document is unavailable
43
+ # @param extra_paths [Array<String>] additional path guesses
44
+ # @param max_probes [Integer, nil] max candidate GETs (nil = uncapped)
45
+ # @return [Html2rss::Url, nil]
46
+ # rubocop:disable-next Metrics/ParameterLists -- discovery kwargs stay co-located
47
+ def best_feed_url(page_url:, request_session:, parsed_body: nil, html: nil, extra_paths: [],
48
+ max_probes: nil)
49
+ best_feed_response(page_url:, request_session:, parsed_body:, html:, extra_paths:, max_probes:)&.url
68
50
  end
69
- end
70
-
71
- ##
72
- # Ordered candidate feed URLs (head alternates first, then path guesses).
73
- #
74
- # @param page_url [String, Html2rss::Url]
75
- # @param parsed_body [Nokogiri::HTML::Document, nil]
76
- # @param html [String, nil]
77
- # @param extra_paths [Array<String>]
78
- # @return [Array<Html2rss::Url>]
79
- def candidate_urls(page_url:, parsed_body: nil, html: nil, extra_paths: [])
80
- page = Html2rss::Url.from_absolute(page_url)
81
- (alternate_feed_urls(page, parsed_body:, html:) +
82
- path_guess_urls(page, extra_paths:)).uniq
83
- end
84
-
85
- ##
86
- # @param response [Html2rss::RequestService::Response, nil]
87
- # @return [Boolean]
88
- def feedish?(response)
89
- return false unless response
90
- return false unless successful_status?(response)
91
-
92
- response.feed_response?
93
- end
94
51
 
95
- def successful_status?(response)
96
- status = response.status
97
- status.nil? || status.between?(200, 299)
98
- end
99
- module_function :successful_status?
100
- private_class_method :successful_status?
101
-
102
- ##
103
- # Directory-relative feed path guesses for a page URL.
104
- #
105
- # @param page_url [String, Html2rss::Url]
106
- # @return [Array<String>] relative paths
107
- def page_dir_paths(page_url)
108
- path = Html2rss::Url.from_absolute(page_url).path
109
- return [] if path.nil? || path.empty? || path == '/'
110
-
111
- dir = path.sub(%r{/[^/]*$}, '/')
112
- ["#{dir}feed", "#{dir}rss.xml", "#{dir}atom.xml"]
113
- end
52
+ ##
53
+ # The native feed of a page already fetched: the one entry point for the curation gates
54
+ # (Recon, Capture, Test), so each reads the page's head alternates (cross-origin only
55
+ # through its {RequestService::FeedAdvertisement}) before same-origin path guesses. The page
56
+ # is the response's own URL, where a redirect landed, so relative alternates and the origin of
57
+ # the follow-ups are the fetched page's, never the entry URL's.
58
+ #
59
+ # @param response [Html2rss::RequestService::Response] the fetched page; a non-HTML
60
+ # response contributes its raw body only
61
+ # @param request_session [Html2rss::RequestSession] session for the probes
62
+ # @return [Html2rss::Url, nil]
63
+ def native_feed_url(response:, request_session:)
64
+ parsed_body = response.parsed_body if response.html_response?
65
+ best_feed_url(page_url: response.url, request_session:, parsed_body:, html: response.body)
66
+ end
114
67
 
115
- def first_feedish_response(urls, request_session:, origin_url:, max_probes: nil)
116
- scoped = max_probes.nil? ? urls.lazy : urls.lazy.take(max_probes)
117
- scoped.filter_map do |url|
118
- response = probe(url, request_session:, origin_url:)
119
- response if feedish?(response)
120
- end.first
121
- end
122
- module_function :first_feedish_response
123
- private_class_method :first_feedish_response
68
+ ##
69
+ # Like {#best_feed_url} but returns the validated syndication response for parsing.
70
+ #
71
+ # @param page_url [String, Html2rss::Url]
72
+ # @param request_session [Html2rss::RequestSession]
73
+ # @param parsed_body [Nokogiri::HTML::Document, nil]
74
+ # @param html [String, nil]
75
+ # @param extra_paths [Array<String>]
76
+ # @param max_probes [Integer, nil] max candidate GETs (nil = uncapped)
77
+ # @return [Html2rss::RequestService::Response, nil]
78
+ # rubocop:disable-next Metrics/ParameterLists, Metrics/MethodLength -- discovery kwargs stay co-located
79
+ def best_feed_response(page_url:, request_session:, parsed_body: nil, html: nil, extra_paths: [],
80
+ max_probes: nil)
81
+ page = Html2rss::Url.from_absolute(page_url)
82
+ doc = document(parsed_body)
83
+ advertisement = RequestService::FeedAdvertisement.from_document(doc, page_url: page)
84
+ candidates = candidates(page, advertisement, doc:, html:, extra_paths:)
85
+ Log.debug("Syndication::Discovery: host=#{page.host} candidate_count=#{candidates.size}")
86
+
87
+ first_feedish_response(
88
+ candidates, request_session:, origin_url: page, advertisement:, max_probes:
89
+ ).tap do |response|
90
+ next unless response
91
+
92
+ Log.info({
93
+ event_name: 'syndication.discovery.selected',
94
+ host: page.host,
95
+ message: "Syndication::Discovery: host=#{page.host} selected_feed_url=#{response.url}"
96
+ }.to_json)
97
+ end
98
+ end
124
99
 
125
- def first_feedish_url(urls, request_session:, origin_url:, max_probes: nil)
126
- first_feedish_response(urls, request_session:, origin_url:, max_probes:)&.url
127
- end
128
- module_function :first_feedish_url
129
- private_class_method :first_feedish_url
130
-
131
- def probe(url, request_session:, origin_url:)
132
- request_session.follow_up(url:, relation: :auto_source, origin_url:)
133
- rescue Html2rss::Error => error
134
- Log.debug(
135
- "Syndication::Discovery: probe skipped host=#{origin_url.host} " \
136
- "(#{error.class})"
137
- )
138
- nil
139
- end
140
- module_function :probe
141
- private_class_method :probe
100
+ ##
101
+ # Ordered candidate feed URLs (head alternates, then path guesses; on a page without podcast
102
+ # signals the audio-hinted alternates come after the guesses).
103
+ #
104
+ # @param page_url [String, Html2rss::Url]
105
+ # @param parsed_body [Nokogiri::HTML::Document, nil]
106
+ # @param html [String, nil]
107
+ # @param extra_paths [Array<String>]
108
+ # @return [Array<Html2rss::Url>]
109
+ def candidate_urls(page_url:, parsed_body: nil, html: nil, extra_paths: [])
110
+ page = Html2rss::Url.from_absolute(page_url)
111
+ doc = document(parsed_body)
112
+ advertisement = RequestService::FeedAdvertisement.from_document(doc, page_url: page)
113
+ candidates(page, advertisement, doc:, html:, extra_paths:)
114
+ end
142
115
 
143
- def alternate_feed_urls(page, parsed_body:, html:)
144
- from_document = feed_links_from_document(parsed_body, page)
145
- return from_document if from_document.any?
116
+ ##
117
+ # @param response [Html2rss::RequestService::Response, nil]
118
+ # @return [Boolean]
119
+ def feedish?(response)
120
+ return false unless response
121
+ return false unless successful_status?(response)
146
122
 
147
- alternate_feed_hrefs(html.to_s, page)
148
- end
149
- module_function :alternate_feed_urls
150
- private_class_method :alternate_feed_urls
151
-
152
- def feed_links_from_document(parsed_body, page)
153
- return [] unless parsed_body.is_a?(Nokogiri::HTML::Document)
123
+ response.feed_response?
124
+ end
154
125
 
155
- Html::FeedLink.from_document(parsed_body).filter_map do |link|
156
- absolute_url(page, link.href)
126
+ ##
127
+ # Directory-relative feed path guesses for a page URL.
128
+ #
129
+ # @param page_url [String, Html2rss::Url]
130
+ # @return [Array<String>] relative paths
131
+ def page_dir_paths(page_url)
132
+ path = Html2rss::Url.from_absolute(page_url).path
133
+ return [] if path == '/'
134
+
135
+ dir = path.sub(%r{/[^/]*$}, '/')
136
+ ["#{dir}feed", "#{dir}rss.xml", "#{dir}atom.xml"]
157
137
  end
158
- end
159
- module_function :feed_links_from_document
160
- private_class_method :feed_links_from_document
161
138
 
162
- def path_guess_urls(page, extra_paths:)
163
- paths = (page_dir_paths(page) + DEFAULT_PATHS + Array(extra_paths)).uniq
164
- paths.filter_map { |path| absolute_url(page, path) }
165
- end
166
- module_function :path_guess_urls
167
- private_class_method :path_guess_urls
139
+ private
168
140
 
169
- def alternate_feed_hrefs(html, page)
170
- return [] if html.nil? || html.empty?
141
+ def successful_status?(response) = response.status.nil? || response.status.between?(200, 299)
171
142
 
172
- html.scan(LINK_TAG_RE).filter_map { |tag| href_from_alternate_link(tag, page) }.uniq
173
- end
174
- module_function :alternate_feed_hrefs
175
- private_class_method :alternate_feed_hrefs
143
+ def first_feedish_response(urls, request_session:, origin_url:, advertisement:, max_probes: nil)
144
+ scoped = max_probes.nil? ? urls.lazy : urls.lazy.take(max_probes)
145
+ scoped.filter_map do |url|
146
+ response = probe(url, request_session:, origin_url:, advertisement:)
147
+ response if feedish?(response)
148
+ end.first
149
+ end
176
150
 
177
- def href_from_alternate_link(tag, page)
178
- return unless alternate_rel?(tag)
151
+ def probe(url, request_session:, origin_url:, advertisement:)
152
+ if advertisement.advertises?(url)
153
+ request_session.follow_up(url:, relation: :advertised_feed, origin_url:, advertisement:)
154
+ else
155
+ request_session.follow_up(url:, relation: :auto_source, origin_url:)
156
+ end
157
+ rescue Html2rss::ContractError
158
+ raise
159
+ rescue Html2rss::Error => error
160
+ Log.debug("Syndication::Discovery: probe skipped host=#{origin_url.host} (#{error.class})")
161
+ nil
162
+ end
179
163
 
180
- href = tag[HREF_RE, 1]
181
- return if href.nil? || href.empty?
182
- return unless syndication_type?(tag[TYPE_RE, 1]) || feed_like_href?(href)
164
+ def candidates(page, advertisement, doc:, html:, extra_paths:)
165
+ leading, trailing = alternate_feed_urls(page, advertisement, doc:, html:)
166
+ (leading + path_guess_urls(page, extra_paths:) + trailing).uniq
167
+ end
183
168
 
184
- absolute_url(page, href.strip)
185
- end
186
- module_function :href_from_alternate_link
187
- private_class_method :href_from_alternate_link
169
+ # An HTML page without a parsed document still gets an (empty) advertisement.
170
+ def document(parsed_body)
171
+ parsed_body.is_a?(Nokogiri::HTML::Document) ? parsed_body : Nokogiri::HTML::Document.new
172
+ end
188
173
 
189
- def alternate_rel?(tag)
190
- tag[REL_RE, 1].to_s.downcase.split(/\s+/).include?('alternate')
191
- end
192
- module_function :alternate_rel?
193
- private_class_method :alternate_rel?
174
+ # @return [Array(Array, Array)] alternates ranked before the path guesses, and those after them
175
+ def alternate_feed_urls(page, advertisement, doc:, html:)
176
+ feeds = advertisement.feeds
177
+ return [AlternateLinks.hrefs(html.to_s).filter_map { |href| absolute_url(page, href) }, []] if feeds.empty?
194
178
 
195
- def syndication_type?(type)
196
- return false if type.nil? || type.empty?
179
+ audio, other = feeds.keys.partition { |url| AUDIO_HINT.match?("#{feeds.fetch(url)} #{url}") }
180
+ podcast_page?(doc, page) ? [audio + other, []] : [other, audio]
181
+ end
197
182
 
198
- t = type.downcase
199
- t.include?('rss+xml') || t.include?('atom+xml') || t.include?('rss') || t.include?('atom')
200
- end
201
- module_function :syndication_type?
202
- private_class_method :syndication_type?
183
+ # The page itself looks like a podcast page: cheap signals only (no request, no JSON parse).
184
+ def podcast_page?(doc, page)
185
+ PODCAST_PAGE_HINT.match?("#{page.host}#{page.path}") || PODCAST_PAGE_HINT.match?(doc.title) ||
186
+ MEDIA_OG_TYPE.match?(doc.at_css('meta[property="og:type"]')&.[]('content')) ||
187
+ Html::Probe.scripts(doc, Html::Probe::APPLICATION_LD_JSON).any? { PODCAST_SCHEMA_TYPE.match?(_1.text) }
188
+ end
203
189
 
204
- def feed_like_href?(href)
205
- href.match?(/rss|atom|feed|\.xml/i)
206
- end
207
- module_function :feed_like_href?
208
- private_class_method :feed_like_href?
190
+ def path_guess_urls(page, extra_paths:)
191
+ paths = (page_dir_paths(page) + DEFAULT_PATHS + Array(extra_paths)).uniq
192
+ paths.filter_map { |path| absolute_url(page, path) }
193
+ end
209
194
 
210
- def absolute_url(base, path)
211
- Html2rss::Url.from_relative(path, base)
212
- rescue ArgumentError
213
- nil
195
+ def absolute_url(base, path)
196
+ Html2rss::Url.from_relative(path, base)
197
+ rescue Html2rss::Url::InvalidUrlError
198
+ nil
199
+ end
214
200
  end
215
- module_function :absolute_url
216
- private_class_method :absolute_url
217
201
  end
218
202
  end
219
203
  end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ module Syndication
5
+ Feed = Data.define(:articles, :channel)
6
+
7
+ ##
8
+ # One parsed native feed ({Parser.parse}): its items as AutoSource article hashes and what it
9
+ # states about its channel ({ChannelFacts}).
10
+ #
11
+ # @!attribute [r] articles
12
+ # @return [Array<Hash{Symbol => Object}>] article hashes (may be empty)
13
+ # @!attribute [r] channel
14
+ # @return [ChannelFacts, nil] nil when the document is no RSS 2.0 or Atom feed or failed to parse
15
+ class Feed
16
+ # No feed read: no articles, no channel facts.
17
+ NONE = new(articles: [].freeze, channel: nil)
18
+ end
19
+ end
20
+ end
@@ -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