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
@@ -11,16 +11,127 @@ module Html2rss
11
11
  BLOCK_NAMES = Set[:p, :div, :li, :ul, :ol, :h1, :h2, :h3, :h4, :h5, :h6, :tr, :br].freeze
12
12
  # Names skipped when collecting visible text.
13
13
  INVISIBLE_NAMES = Set[:svg, :script, :noscript, :style, :template].freeze
14
- # Landmark names treated as utility chrome.
14
+ # Utility landmark names: peripheral unless chrome (see {.chrome_scope}).
15
15
  UTILITY_LANDMARK_NAMES = Set[:nav, :aside, :footer, :menu].freeze
16
16
  # Container names ignored as article roots.
17
- IGNORED_CONTAINER_NAMES = Set[:nav, :footer, :header, :svg, :script, :style].freeze
17
+ IGNORED_CONTAINER_NAMES = Set[:nav, :footer, :header, :dialog, :svg, :script, :style].freeze
18
+ # Ignored containers that are chrome only at page level (ARIA banner/contentinfo scoping).
19
+ PAGE_CHROME_NAMES = Set[:header, :footer].freeze
20
+ # Ancestors that scope a header/footer to their own section instead of the page.
21
+ SECTIONING_NAMES = Set[:article, :section, :aside, :main].freeze
18
22
  # Names excluded from class/structure clustering.
19
23
  CLUSTER_EXCLUDED_NAMES = Set[:html, :body, :nav, :footer, :header, :svg, :script, :style].freeze
20
24
  # Href prefixes that never count as content destinations.
21
25
  SKIP_HREF_PREFIXES = ['#', 'javascript:', 'mailto:', 'tel:', 'file://', 'sms:', 'data:'].freeze
22
26
  # Layout-ish names used when resolving nested wrapper groups.
23
27
  LAYOUT_NAMES = Set[:div, :section, :article, :li, :ul, :ol].freeze
28
+
29
+ # header/footer/nav: chrome at page level, their parent's scope inside sectioning content.
30
+ PAGE_CHROME = PAGE_CHROME_NAMES.to_set(&:to_s).freeze
31
+ # +noscript+ too: the SST keeps one only for its file links (enclosures), never as content anchors.
32
+ ALWAYS_CHROME = ((IGNORED_CONTAINER_NAMES.to_set(&:to_s) - PAGE_CHROME - Set['nav']) | Set['noscript']).freeze
33
+ UTILITY_LANDMARK = UTILITY_LANDMARK_NAMES.to_set(&:to_s).freeze
34
+ SECTIONING = SECTIONING_NAMES.to_set(&:to_s).freeze
35
+ # ARIA roles whose subtree is chrome anywhere (+search+ is not: it may wrap the result list).
36
+ CHROME_ROLES = Set['navigation', 'menu', 'menubar', 'dialog', 'alertdialog'].freeze
37
+ # ARIA landmark roles that, like header/footer, are chrome only at page level.
38
+ PAGE_CHROME_ROLES = Set['banner', 'contentinfo'].freeze
39
+ # Utility landmark roles: the chrome roles plus +complementary+ (the aside role).
40
+ UTILITY_ROLES = (CHROME_ROLES | Set['complementary']).freeze
41
+ # A nav or list labelled (aria-label) or classed as pagination is chrome in any scope.
42
+ PAGER_NAMES = Set['nav', 'ul', 'ol'].freeze
43
+ # Pagination nouns ("Results pagination", "paginación"), never +paginated+.
44
+ PAGER_LABEL = /\bpagina(?:tion|tie|ción|cion|zione|cja|ção|cao)(?!\p{L})|\bpaginering\b|\bpaginierung\b|
45
+ \bpager\b|\bpaging\b|\bpage\snavigation\b/ix
46
+ # nav: a class-token part (+pagination+, +gsc-pagination+, +pager__list+), not +paginated-list+.
47
+ PAGER_CLASS = /(?:\A|\s|[-_])(?:pagination|pager|paging)(?:\z|\s|[-_])/i
48
+ # ul/ol: only a whole class token (+infinite-pager+ or +js-pagination-container+ is a feed list).
49
+ PAGER_TOKENS = Set['pagination', 'pager', 'paging'].freeze
50
+ PAGER_RELS = Set['next', 'prev', 'previous'].freeze
51
+ # A nav linking rel=next/prev is a pager.
52
+ PAGER_LINK_XPATH = PAGER_RELS.map { "contains(concat(' ', normalize-space(@rel), ' '), ' #{_1} ')" }
53
+ .join(' or ').then { ".//a[#{_1}]" }.freeze
54
+ # Page-navigation link shapes: a page number, an arrow, prev/next wording, or a page query/path.
55
+ PAGE_LINK_TEXT = /\A(?:\d{1,4}|[«‹›»←→<>]+|\W*(?:prev(?:ious)?|next|older|newer|first|last)\b.*)\z/im
56
+ PAGE_LINK_HREF = %r{[?&](?:page|paged|pg|p)=\d+|/page/\d+}i
57
+ private_constant :PAGE_CHROME, :ALWAYS_CHROME, :UTILITY_LANDMARK, :SECTIONING, :CHROME_ROLES,
58
+ :PAGE_CHROME_ROLES, :UTILITY_ROLES, :PAGER_NAMES, :PAGER_LABEL, :PAGER_CLASS,
59
+ :PAGER_TOKENS, :PAGER_RELS, :PAGER_LINK_XPATH, :PAGE_LINK_TEXT, :PAGE_LINK_HREF
60
+
61
+ ##
62
+ # The single chrome-scope rule, folded down the tree by both the SST normalizer
63
+ # and the Nokogiri ancestor walk. +:chrome+ sticks: dialog, noscript and the ARIA navigation,
64
+ # menu and dialog roles are chrome; header/footer/nav and the banner/contentinfo roles
65
+ # are chrome only at +:page+ level; a pager (a nav labelled or classed as pagination or
66
+ # linking rel=next/prev, or a list labelled or whole-token classed as pagination whose
67
+ # links mostly look like page navigation) is chrome in any scope. Otherwise a
68
+ # utility landmark ({.utility_landmark?}) opens a sticky +:peripheral+ scope, and
69
+ # article/section/main (or role=main) open +:sectioned+, so card headers and in-content
70
+ # nav lists stay out of chrome.
71
+ #
72
+ # @param element [Nokogiri::XML::Element] element being entered
73
+ # @param scope [Symbol] parent scope: +:page+, +:sectioned+, +:peripheral+, or +:chrome+
74
+ # @return [Symbol] scope the element's subtree inherits
75
+ def self.chrome_scope(element, scope)
76
+ tag = Html::Probe.tag(element)
77
+ roles = role_tokens(element['role'])
78
+ return :chrome if scope == :chrome || chrome?(element, tag, roles, scope)
79
+ return :peripheral if scope == :peripheral || utility?(tag, roles)
80
+
81
+ SECTIONING.include?(tag) || roles.include?('main') ? :sectioned : scope
82
+ end
83
+
84
+ ##
85
+ # Utility landmark: nav/aside/footer/menu, or a navigation, menu, dialog or
86
+ # complementary role. Peripheral content when not chrome; never an article card.
87
+ #
88
+ # @param tag [String] folded tag name
89
+ # @param role [String, nil] raw +role+ attribute
90
+ # @return [Boolean]
91
+ def self.utility_landmark?(tag, role) = utility?(tag, role_tokens(role))
92
+
93
+ ##
94
+ # Chrome only at page level: header/footer, or the banner/contentinfo role.
95
+ #
96
+ # @param tag [String] folded tag name
97
+ # @param role [String, nil] raw +role+ attribute
98
+ # @return [Boolean]
99
+ def self.page_level_chrome?(tag, role) = page_level?(tag, role_tokens(role))
100
+
101
+ def self.role_tokens(role) = role ? role.downcase.split : []
102
+
103
+ def self.utility?(tag, roles) = UTILITY_LANDMARK.include?(tag) || roles.any? { UTILITY_ROLES.include?(_1) }
104
+
105
+ def self.page_level?(tag, roles) = PAGE_CHROME.include?(tag) || roles.any? { PAGE_CHROME_ROLES.include?(_1) }
106
+
107
+ def self.chrome?(element, tag, roles, scope)
108
+ return true if ALWAYS_CHROME.include?(tag) || roles.any? { CHROME_ROLES.include?(_1) }
109
+
110
+ (scope == :page && (tag == 'nav' || page_level?(tag, roles))) || pager?(element, tag)
111
+ end
112
+
113
+ def self.pager?(element, tag)
114
+ return false unless PAGER_NAMES.include?(tag)
115
+ return list_pager?(element) unless tag == 'nav'
116
+ return true if element['aria-label'].to_s.match?(PAGER_LABEL) || element['class'].to_s.match?(PAGER_CLASS)
117
+
118
+ !element.at_xpath(PAGER_LINK_XPATH).nil?
119
+ end
120
+
121
+ # A list is a pager only when labelled or whole-token classed as one and at least
122
+ # half of its links look like page navigation.
123
+ def self.list_pager?(element)
124
+ named = element['aria-label'].to_s.match?(PAGER_LABEL) ||
125
+ element['class'].to_s.downcase.split.any? { PAGER_TOKENS.include?(_1) }
126
+ links = named ? element.css('a') : []
127
+ !links.empty? && links.count { page_link?(_1) } * 2 >= links.size
128
+ end
129
+
130
+ def self.page_link?(link)
131
+ link['rel'].to_s.downcase.split.any? { PAGER_RELS.include?(_1) } ||
132
+ link['href'].to_s.match?(PAGE_LINK_HREF) || link.text.strip.match?(PAGE_LINK_TEXT)
133
+ end
134
+ private_class_method :role_tokens, :utility?, :page_level?, :chrome?, :pager?, :list_pager?, :page_link?
24
135
  end
25
136
  end
26
137
  end
@@ -4,7 +4,18 @@ module Html2rss
4
4
  module SST
5
5
  ##
6
6
  # Visible-text extraction for SST nodes (port of Navigator::TextExtractor).
7
+ #
8
+ # Block children separate by a newline (or the given separator); other neighbours by one space,
9
+ # except where an inline element embedded in direct text touches it with no whitespace between them
10
+ # in the source: +Straßenbah<span>n</span> für+ stays one word. Two adjacent elements, and an element
11
+ # opening or closing its parent's text (+<span>Live</span>Headline+), always separate, since sites
12
+ # lay out such spans as blocks with CSS.
7
13
  module Text
14
+ # One piece of a node's text: direct text or one child's text, whether it is a block, and whether it
15
+ # glues to the piece before it (no whitespace between them in the source).
16
+ Fragment = Data.define(:text, :block, :glue)
17
+ private_constant :Fragment
18
+
8
19
  class << self
9
20
  ##
10
21
  # @param node [Node]
@@ -12,7 +23,7 @@ module Html2rss
12
23
  # @param exclude [Array<Node>, nil]
13
24
  # @return [String, nil]
14
25
  def extract(node, separator: ' ', exclude: nil)
15
- raise ArgumentError, 'node is required' unless node
26
+ raise InvalidError, 'node is required' unless node
16
27
 
17
28
  excluded = exclude&.each_with_object({}.compare_by_identity) { |n, h| h[n] = true }
18
29
  join_fragments(fragments(node, separator, excluded), separator)
@@ -20,51 +31,80 @@ module Html2rss
20
31
 
21
32
  private
22
33
 
23
- # Ordered [text, block?] pairs: lead text, then each child followed by the text after it.
34
+ # Ordered fragments: lead text, then each shown child followed by the text after it.
24
35
  def fragments(node, separator, excluded)
25
- pairs = [[squish(node.lead_text), false]]
26
- node.children.zip(node.tails).each do |child, tail|
27
- pairs << child_text_and_block(child, separator, excluded) if shown?(child, excluded)
28
- pairs << [squish(tail), false]
36
+ raws = [node.lead_text, *node.tails]
37
+ pieces = [direct_fragment(raws.first, false)]
38
+ node.children.each_with_index do |child, index|
39
+ next pieces << direct_fragment(raws[index + 1], false) unless shown?(child, excluded)
40
+
41
+ pieces.push(*shown_fragments(child, raws, index, separator, excluded))
29
42
  end
30
- pairs
43
+ pieces
31
44
  end
32
45
 
33
- def join_fragments(pairs, separator)
34
- parts = []
35
- last_block = false
36
- pairs.each do |text, block|
37
- next if text.empty?
46
+ # A shown child and the direct text after it. Only a child embedded in direct text (text on both
47
+ # sides) glues to it, where they meet with no whitespace between them: a leading or trailing span
48
+ # beside text (+<span>Live</span>Headline+) is a styled label more often than part of a word.
49
+ def shown_fragments(child, raws, index, separator, excluded)
50
+ before = raws[index].to_s
51
+ after = raws[index + 1].to_s
52
+ embedded = !before.empty? && !after.empty?
53
+ [child_fragment(child, separator, excluded, embedded && before.match?(/\S\z/) && opens_word?(child)),
54
+ direct_fragment(after, embedded && closes_word?(child) && after.match?(/\A\S/))]
55
+ end
56
+
57
+ # Whether the raw text of +node+ starts with a non-space, descending into its first child while it
58
+ # has no lead text.
59
+ def opens_word?(node)
60
+ first = node.children.first
61
+ node.lead_text.empty? && first ? opens_word?(first) : node.lead_text.match?(/\A\S/)
62
+ end
38
63
 
39
- append_separator!(parts, separator, block, last_block)
40
- parts << text
41
- last_block = block
64
+ # Whether the raw text of +node+ ends with a non-space, descending into its last child while no
65
+ # text follows it.
66
+ def closes_word?(node)
67
+ raw = node.tails.last || node.own_text
68
+ last = node.children.last
69
+ raw.empty? && last ? closes_word?(last) : raw.match?(/\S\z/)
70
+ end
71
+
72
+ def join_fragments(pieces, separator)
73
+ last_block = nil
74
+ parts = pieces.each_with_object([]) do |piece, out|
75
+ next if piece.text.empty?
76
+
77
+ out << gap(separator, piece, last_block) unless last_block.nil?
78
+ out << piece.text
79
+ last_block = piece.block
42
80
  end
43
81
  parts.join unless parts.empty?
44
82
  end
45
83
 
84
+ def gap(separator, piece, last_block)
85
+ return (separator == ' ' ? "\n" : separator) if piece.block || last_block
86
+
87
+ piece.glue ? '' : ' '
88
+ end
89
+
46
90
  def shown?(child, excluded) = !excluded&.[](child) && visible_child?(child)
47
91
 
48
92
  def squish(text) = text.to_s.gsub(/\s+/, ' ').strip
49
93
 
50
- def child_text_and_block(child, separator, excluded)
94
+ def direct_fragment(raw, glue) = Fragment.new(squish(raw), false, glue)
95
+
96
+ def child_fragment(child, separator, excluded, glue)
97
+ Fragment.new(child_text(child, separator, excluded), Tags::BLOCK_NAMES.include?(child.name), glue)
98
+ end
99
+
100
+ # A leaf's text is read directly (no fragments to join), the hot path of every text read.
101
+ def child_text(child, separator, excluded)
51
102
  text = if child.children.empty?
52
103
  squish(child.own_text)
53
104
  else
54
- extract(child, separator:, exclude: excluded&.keys).to_s.strip
105
+ extract(child, separator:, exclude: excluded&.keys).to_s
55
106
  end
56
- text = "- #{text}" if child.name == :li && !text.empty?
57
- [text, Tags::BLOCK_NAMES.include?(child.name)]
58
- end
59
-
60
- def append_separator!(parts, separator, is_block, last_was_block)
61
- return if parts.empty?
62
-
63
- parts << if is_block || last_was_block
64
- (separator == ' ' ? "\n" : separator)
65
- else
66
- ' '
67
- end
107
+ child.name == :li && !text.empty? ? "- #{text}" : text
68
108
  end
69
109
 
70
110
  def visible_child?(node)
data/lib/html2rss/sst.rb CHANGED
@@ -4,5 +4,7 @@ module Html2rss
4
4
  ##
5
5
  # Simplified Semantic Tree namespace for the heuristic auto-source pipeline.
6
6
  module SST
7
+ # Raised when a tree node, attrs, text or document is built from invalid parts.
8
+ class InvalidError < Html2rss::ContractError; end
7
9
  end
8
10
  end
@@ -13,6 +13,9 @@ module Html2rss
13
13
  # Stable telemetry payload for cross-repo consumers (e.g. html2rss-web observability).
14
14
  # Tallies and counters are validated and frozen at construction (including Marshal load).
15
15
  class Status
16
+ # Raised when telemetry fields fail validation at construction.
17
+ class InvalidError < Html2rss::ContractError; end
18
+
16
19
  # Keys kept in {#to_h} even when zero or empty.
17
20
  ALWAYS_REPORTED = %i[version dedup_dropped].freeze
18
21
  private_constant :ALWAYS_REPORTED
@@ -157,14 +160,14 @@ module Html2rss
157
160
  case value
158
161
  when Hash, FeedResolution::Diag then value.to_h
159
162
  else
160
- raise ArgumentError, "entry_resolution must be a Hash or FeedResolution::Diag, got #{value.class}"
163
+ raise InvalidError, "entry_resolution must be a Hash or FeedResolution::Diag, got #{value.class}"
161
164
  end.transform_keys(&:to_sym).freeze
162
165
  end
163
166
 
164
167
  def validate_selected_strategy!(selected_strategy, attempts)
165
168
  return if selected_strategy.nil?
166
- raise ArgumentError, 'selected_strategy must be a Symbol or nil' unless selected_strategy.is_a?(Symbol)
167
- raise ArgumentError, 'attempt_count must be >= 1 when selected_strategy is set' if attempts < 1
169
+ raise InvalidError, 'selected_strategy must be a Symbol or nil' unless selected_strategy.is_a?(Symbol)
170
+ raise InvalidError, 'attempt_count must be >= 1 when selected_strategy is set' if attempts < 1
168
171
  end
169
172
  end
170
173
  end
@@ -20,7 +20,7 @@ module Html2rss
20
20
  ##
21
21
  # @param name [Symbol, String]
22
22
  # @return [SurfaceCategory]
23
- def self.[](name) = ALL.fetch(name.to_sym) { raise ArgumentError, "unknown surface category: #{name.inspect}" }
23
+ def self.[](name) = ALL.fetch(name.to_sym) { raise InvalidOption, "unknown surface category: #{name.inspect}" }
24
24
 
25
25
  ##
26
26
  # @return [Boolean]
@@ -1,13 +1,17 @@
1
1
  # Syndication
2
2
 
3
- Native RSS/Atom discovery and parse for auto-source promotion and direct feed URLs.
3
+ Native RSS/Atom/RDF discovery and parse for auto-source promotion and direct feed URLs.
4
4
 
5
5
  ## Ownership
6
6
 
7
7
  | Concern | Owner |
8
8
  | ---------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- |
9
- | Head `rel=alternate` + common path probes + status-gated feedish | `Syndication::Discovery` |
10
- | RSS 2.0 / Atom → article hashes | `Syndication::Parser` |
9
+ | Head `rel=alternate` (audio-hinted first on a podcast page, after the path guesses elsewhere) + common path probes + status-gated feedish | `Syndication::Discovery` |
10
+ | Native feed of a fetched page for Recon, Capture and Test | `Syndication::Discovery.native_feed_url` (one entry point: the page response they already hold, at the URL a redirect landed on) |
11
+ | Cross-origin permission for a page's advertised feed | `RequestService::Policy` (proof: `RequestService::FeedAdvertisement`) |
12
+ | RSS 2.0 / RSS 1.0 (RDF) / Atom → `Syndication::Feed` (article hashes + channel facts); item URL: an http(s) link, else an http(s) guid, else the first audio/video file | `Syndication::Parser` |
13
+ | Item media files and image (enclosure, Media RSS, iTunes) → item | `Syndication::ItemMedia` (called by `Parser`; http(s) only via `Url.resolve_web`; sizes, durations and player pages via `Article::Enclosure`) |
14
+ | Channel facts (author, `itunes:category`, `itunes:explicit`) → `Feed#channel` | `Syndication::ChannelFacts` (called by `Parser` on the same parse; precedence over page meta is `Channel`'s) |
11
15
  | Strict head-only link parse (no path guessing) | `Html::FeedLink` |
12
16
  | Response Content-Type / body feed sniff | `RequestService::Response#feed_response?` (sole sniff owner; Discovery only adds HTTP status gate) |
13
17
 
@@ -15,7 +19,9 @@ Native RSS/Atom discovery and parse for auto-source promotion and direct feed UR
15
19
 
16
20
  - Entry-URL tournament / listing resolution (`FeedResolution`)
17
21
  - Page recon / surface classification (`PageRecon`)
18
- - Feed channel metadata for output (`Channel` / `FeedBuilder`)
19
- - Ranking or quality scoring of competing feeds beyond first feedish hit
22
+ - Feed channel metadata for output and its precedence (`Channel` / `FeedBuilder`); Syndication only reads what a feed states
23
+ - Ranking or quality scoring of competing feeds beyond the audio-hint order and first feedish hit
20
24
 
21
- Discovery stops at the first same-origin feedish URL (lazy probe). Path guessing mirrors the legacy configs `probe_rss` script; that script should eventually thin-wrap this module.
25
+ Discovery stops at the first feedish URL (lazy probe). Head alternates are fetched as `:advertised_feed` with the page's `FeedAdvertisement`, so a feed on another host (a podcast platform) is followed. An advertised `http:` feed off the page origin is fetched as `https:` or skipped. Path guesses stay same-origin. Alternates keep document order, except on a podcast page: `podcast`, `episode` or `audio` (word-ish, plural allowed) in its URL host, path or `<title>`, an `og:type` of `music.*` or `video.*`, or a JSON-LD `@type` `PodcastSeries`, `PodcastEpisode` or `AudioObject` anywhere (regex on the script text, no JSON parse). There, an alternate whose title or URL hints audio (the same words plus `mp3`, `m4a`, `aac`, `opus`) goes first. On any other page those rank after the same-origin path guesses, and other alternates still precede them. A news front page that advertises only its podcast ships its page items or a same-origin feed, not the episodes; the podcast feed is its last candidate.
26
+
27
+ An item without a link takes its guid (Atom `id`, RDF `rdf:about`) when that is an absolute http(s) URL, whatever `isPermaLink` says, else its first audio or video file. Relative, `urn:` and `tag:` ids name no page. An item with none of these is skipped. Links, files and images resolve against the feed URL through `Url.resolve_web`, so a `javascript:`, `data:` or `ftp:` one counts as absent. A followed feed's items may link to the feed's own domain as well as the page's (AutoSource `Cleanup`, which owns that rule). Path guessing mirrors the legacy configs `probe_rss` script; that script should eventually thin-wrap this module.
@@ -31,7 +31,7 @@ module Html2rss
31
31
  end
32
32
 
33
33
  def syndication_type?(type)
34
- type.to_s.downcase.match?(/rss|atom/)
34
+ type.to_s.downcase.match?(/rss|atom|rdf/)
35
35
  end
36
36
  end
37
37
  end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rss'
4
+
5
+ module Html2rss
6
+ module Syndication
7
+ ChannelFacts = Data.define(:author, :categories, :explicit)
8
+
9
+ ##
10
+ # What a native feed states about its show or publication, for {Html2rss::Channel}: the author
11
+ # name, the iTunes categories and the explicit flag. A fact the feed does not state stays unknown
12
+ # (+nil+, no categories); nothing is guessed.
13
+ #
14
+ # RSS 2.0: the author is +itunes:author+, else the name part of +managingEditor+
15
+ # (+editor@example.com (Name)+ gives +Name+, a plain name is kept, a bare email address is no name);
16
+ # each +itunes:category+ is its +text+ followed by its nested subcategory texts; +itunes:explicit+
17
+ # +true+, +yes+ or +explicit+ is true, +false+, +no+ or +clean+ false, any other value unstated.
18
+ # Atom: the author is the feed +author+ name. RSS 1.0 (RDF) states none of these.
19
+ #
20
+ # @!attribute [r] author
21
+ # @return [String, nil] the channel author name
22
+ # @!attribute [r] categories
23
+ # @return [Array<Array<String>>] each category followed by its subcategories
24
+ # @!attribute [r] explicit
25
+ # @return [Boolean, nil] whether the feed declares explicit content; nil when unstated
26
+ class ChannelFacts
27
+ # +itunes:explicit+ values, lowercased, to the flag they state.
28
+ EXPLICIT = {
29
+ 'true' => true, 'yes' => true, 'explicit' => true,
30
+ 'false' => false, 'no' => false, 'clean' => false
31
+ }.freeze
32
+ # A +managingEditor+ written as an email address followed by the name in parentheses.
33
+ NAMED_EMAIL = /\A\s*[^\s@]+@\S+\s+\((.+)\)\s*\z/
34
+
35
+ class << self
36
+ ##
37
+ # @param feed [RSS::Rss, RSS::RDF, RSS::Atom::Feed, nil] a document the +rss+ library parsed
38
+ # @return [ChannelFacts, nil] the facts an RSS 2.0 or Atom feed states; nil for any other document
39
+ def from(feed)
40
+ case feed
41
+ when RSS::Rss then from_rss(feed.channel)
42
+ when RSS::Atom::Feed then new(author: feed.author&.name&.content)
43
+ end
44
+ end
45
+
46
+ private
47
+
48
+ def from_rss(channel)
49
+ new(author: rss_author(channel),
50
+ categories: channel.itunes_categories.map { [_1.text, *_1.itunes_categories.map(&:text)] },
51
+ explicit: EXPLICIT[channel.itunes_explicit.to_s.strip.downcase])
52
+ end
53
+
54
+ # Blank and padded values are normalized by {#initialize}.
55
+ def rss_author(channel)
56
+ author = channel.itunes_author.to_s
57
+ return author if author.match?(/\S/)
58
+
59
+ editor = channel.managingEditor.to_s
60
+ editor[NAMED_EMAIL, 1] || (editor unless editor.include?('@'))
61
+ end
62
+ end
63
+
64
+ ##
65
+ # @param author [String, nil] blank becomes nil
66
+ # @param categories [Array<Array<String, nil>>] a category without a name is dropped, blank
67
+ # subcategories are skipped
68
+ # @param explicit [Boolean, nil]
69
+ def initialize(author: nil, categories: [], explicit: nil)
70
+ super(author: present(author), categories: categories.filter_map { path(_1) }.freeze, explicit:)
71
+ end
72
+
73
+ private
74
+
75
+ def path((name, *subcategories))
76
+ return unless (name = present(name))
77
+
78
+ [name, *subcategories.filter_map { present(_1) }].freeze
79
+ end
80
+
81
+ def present(value)
82
+ text = value.to_s.strip
83
+ text.freeze unless text.empty?
84
+ end
85
+ end
86
+ end
87
+ end
@@ -3,12 +3,34 @@
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
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
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
12
34
 
13
35
  class << self
14
36
  ##
@@ -24,9 +46,23 @@ module Html2rss
24
46
  # rubocop:disable-next Metrics/ParameterLists -- discovery kwargs stay co-located
25
47
  def best_feed_url(page_url:, request_session:, parsed_body: nil, html: nil, extra_paths: [],
26
48
  max_probes: nil)
27
- best_feed_response(
28
- page_url:, request_session:, parsed_body:, html:, extra_paths:, max_probes:
29
- )&.url
49
+ best_feed_response(page_url:, request_session:, parsed_body:, html:, extra_paths:, max_probes:)&.url
50
+ end
51
+
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)
30
66
  end
31
67
 
32
68
  ##
@@ -43,13 +79,13 @@ module Html2rss
43
79
  def best_feed_response(page_url:, request_session:, parsed_body: nil, html: nil, extra_paths: [],
44
80
  max_probes: nil)
45
81
  page = Html2rss::Url.from_absolute(page_url)
46
- candidates = candidate_urls(page_url: page, parsed_body:, html:, extra_paths:)
47
- Log.debug(
48
- "Syndication::Discovery: host=#{page.host} candidate_count=#{candidates.size}"
49
- )
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}")
50
86
 
51
87
  first_feedish_response(
52
- candidates, request_session:, origin_url: page, max_probes:
88
+ candidates, request_session:, origin_url: page, advertisement:, max_probes:
53
89
  ).tap do |response|
54
90
  next unless response
55
91
 
@@ -62,7 +98,8 @@ module Html2rss
62
98
  end
63
99
 
64
100
  ##
65
- # Ordered candidate feed URLs (head alternates first, then path guesses).
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).
66
103
  #
67
104
  # @param page_url [String, Html2rss::Url]
68
105
  # @param parsed_body [Nokogiri::HTML::Document, nil]
@@ -71,8 +108,9 @@ module Html2rss
71
108
  # @return [Array<Html2rss::Url>]
72
109
  def candidate_urls(page_url:, parsed_body: nil, html: nil, extra_paths: [])
73
110
  page = Html2rss::Url.from_absolute(page_url)
74
- (alternate_feed_urls(page, parsed_body:, html:) +
75
- path_guess_urls(page, extra_paths:)).uniq
111
+ doc = document(parsed_body)
112
+ advertisement = RequestService::FeedAdvertisement.from_document(doc, page_url: page)
113
+ candidates(page, advertisement, doc:, html:, extra_paths:)
76
114
  end
77
115
 
78
116
  ##
@@ -100,37 +138,53 @@ module Html2rss
100
138
 
101
139
  private
102
140
 
103
- def successful_status?(response)
104
- status = response.status
105
- status.nil? || status.between?(200, 299)
106
- end
141
+ def successful_status?(response) = response.status.nil? || response.status.between?(200, 299)
107
142
 
108
- def first_feedish_response(urls, request_session:, origin_url:, max_probes: nil)
143
+ def first_feedish_response(urls, request_session:, origin_url:, advertisement:, max_probes: nil)
109
144
  scoped = max_probes.nil? ? urls.lazy : urls.lazy.take(max_probes)
110
145
  scoped.filter_map do |url|
111
- response = probe(url, request_session:, origin_url:)
146
+ response = probe(url, request_session:, origin_url:, advertisement:)
112
147
  response if feedish?(response)
113
148
  end.first
114
149
  end
115
150
 
116
- def probe(url, request_session:, origin_url:)
117
- request_session.follow_up(url:, relation: :auto_source, origin_url:)
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
118
159
  rescue Html2rss::Error => error
119
160
  Log.debug("Syndication::Discovery: probe skipped host=#{origin_url.host} (#{error.class})")
120
161
  nil
121
162
  end
122
163
 
123
- def alternate_feed_urls(page, parsed_body:, html:)
124
- from_document = feed_links_from_document(parsed_body, page)
125
- return from_document if from_document.any?
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
126
168
 
127
- AlternateLinks.hrefs(html.to_s).filter_map { |href| absolute_url(page, href) }.uniq
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
128
172
  end
129
173
 
130
- def feed_links_from_document(parsed_body, page)
131
- return [] unless parsed_body.is_a?(Nokogiri::HTML::Document)
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?
178
+
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
132
182
 
133
- Html::FeedLink.from_document(parsed_body).filter_map { |link| absolute_url(page, link.href) }
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) }
134
188
  end
135
189
 
136
190
  def path_guess_urls(page, extra_paths:)
@@ -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