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
@@ -6,7 +6,20 @@ module Html2rss
6
6
  module FeedBuilder
7
7
  ##
8
8
  # Builds an RSS Feed by providing channel, articles and stylesheets.
9
- class Rss
9
+ #
10
+ # A media feed ({ItemPresentation.media_feed?}) also gets the podcast tags clients play from:
11
+ # channel +itunes:image+, +itunes:author+, +itunes:category+ (nesting its subcategories) and
12
+ # +itunes:explicit+ (+true+ or +false+), item +itunes:duration+ (+HH:MM:SS+) and +itunes:image+, each
13
+ # only when known; categories and the explicit flag only when the followed native feed states them.
14
+ #
15
+ # +managingEditor+ carries the channel author only when it is an RSS 2.0 email address
16
+ # (+editor@example.com+ or +editor@example.com (Name)+); a plain name is no managing editor.
17
+ class Rss # rubocop:disable Metrics/ClassLength -- channel, item and podcast projections stay co-located
18
+ # First duration in seconds that +itunes:duration+ cannot spell as +HH:MM:SS+ (100 hours).
19
+ MAX_CLOCK_DURATION = 100 * 3600
20
+ # An RSS 2.0 person: an email address, optionally followed by the name in parentheses.
21
+ EMAIL_PERSON = /\A[^\s@()]+@[^\s@()]+(?: \(.+\))?\z/
22
+
10
23
  class << self
11
24
  # @param article [Html2rss::Article] source article
12
25
  # @param item_maker [RSS::Maker::RSS20::ItemsBase::ItemBase] RSS item builder
@@ -37,7 +50,7 @@ module Html2rss
37
50
  item_maker.title = article.title
38
51
  item_maker.author = article.author
39
52
  description = ItemPresentation.description_for(article)
40
- item_maker.description = description if description && !description.empty?
53
+ item_maker.description = description if description
41
54
  item_maker.link = article.url.to_s if article.url
42
55
  item_maker.pubDate = article.published_at&.rfc2822
43
56
  end
@@ -60,12 +73,13 @@ module Html2rss
60
73
  # @param stylesheets [Array<Hash>] An optional array of stylesheet configurations.
61
74
  # @param generator [String] required preformatted generator comment (from {Status})
62
75
  def initialize(channel:, articles:, generator:, stylesheets: [])
63
- raise ArgumentError, 'generator must be a non-blank String' if blank_string?(generator)
76
+ raise InvalidOption, 'generator must be a non-blank String' if blank_string?(generator)
64
77
 
65
78
  @channel = channel
66
79
  @articles = articles
67
80
  @stylesheets = stylesheets
68
81
  @generator = generator
82
+ @media_feed = ItemPresentation.media_feed?(articles)
69
83
  end
70
84
 
71
85
  # @return [RSS::Rss] RSS 2.0 document instance
@@ -80,7 +94,7 @@ module Html2rss
80
94
 
81
95
  private
82
96
 
83
- attr_reader :channel, :articles, :generator
97
+ attr_reader :channel, :articles, :generator, :media_feed
84
98
 
85
99
  def blank_string?(value)
86
100
  !value.is_a?(String) || value.strip.empty?
@@ -98,13 +112,25 @@ module Html2rss
98
112
  channel_maker.description = channel.description
99
113
  channel_maker.ttl = channel.ttl
100
114
 
101
- channel_maker.managingEditor = channel.author if channel.author
102
- channel_maker.author = channel.author if channel.author
115
+ channel_maker.managingEditor = channel.author if EMAIL_PERSON.match?(channel.author)
103
116
  channel_maker.link = channel.url.to_s
104
117
  channel_maker.generator = generator
105
118
  channel_maker.updated = channel.last_build_date
106
119
 
107
120
  make_image(maker.image) if channel.image
121
+ make_podcast_channel(channel_maker) if media_feed
122
+ end
123
+
124
+ def make_podcast_channel(channel_maker)
125
+ channel_maker.itunes_image = channel.image
126
+ channel_maker.itunes_author = channel.author
127
+ channel_maker.itunes_explicit = channel.explicit&.to_s
128
+ channel.categories.each { add_itunes_category(channel_maker, *_1) }
129
+ end
130
+
131
+ def add_itunes_category(channel_maker, name, *subcategories)
132
+ category = channel_maker.new_itunes_category(name)
133
+ subcategories.each { category.new_category.text = _1 }
108
134
  end
109
135
 
110
136
  def make_image(image_maker)
@@ -114,9 +140,26 @@ module Html2rss
114
140
 
115
141
  def make_items(maker)
116
142
  articles.each do |article|
117
- maker.items.new_item { |item_maker| self.class.add_item(article, item_maker) }
143
+ maker.items.new_item do |item_maker|
144
+ self.class.add_item(article, item_maker)
145
+ make_podcast_item(article, item_maker) if media_feed
146
+ end
118
147
  end
119
148
  end
149
+
150
+ def make_podcast_item(article, item_maker)
151
+ duration = ItemPresentation.rss_enclosure_for(article)&.duration
152
+ item_maker.itunes_duration = format_duration(duration) if duration
153
+ item_maker.itunes_image = article.image
154
+ end
155
+
156
+ # +HH:MM:SS+; plain seconds from 100 hours on, which +HH+ cannot spell.
157
+ def format_duration(seconds)
158
+ return seconds.to_s if seconds >= MAX_CLOCK_DURATION
159
+
160
+ format('%<hours>02d:%<minutes>02d:%<seconds>02d',
161
+ hours: seconds / 3600, minutes: seconds % 3600 / 60, seconds: seconds % 60)
162
+ end
120
163
  end
121
164
  end
122
165
  end
@@ -8,7 +8,8 @@ module Html2rss
8
8
  # FeedResult; +feed_url+ is a render-time JSON Feed-only option.
9
9
  #
10
10
  # Channel field projection:
11
- # - Rss: language, title, description, ttl, link, updated
11
+ # - Rss: language, title, description, ttl, link, updated, managingEditor (author in email form only)
12
+ # (+ itunes:image, itunes:author, itunes:category, itunes:explicit on a media feed)
12
13
  # - JsonFeed: title, home_page_url, description, language, icon, authors (+ feed_url)
13
14
  module FeedBuilder
14
15
  end
@@ -11,7 +11,7 @@ Use `:auto` when you want `default` (HTTPX) first and a browser-backed hop only
11
11
  1. **Default (HTTPX)** — plain HTTP (faster, cheaper).
12
12
  2. **Botasaurus** — attempted when default raises a fallback-eligible error (for example `BlockedSurfaceDetected` or timeout) or extracts zero feed items.
13
13
 
14
- Before escalating default → Botasaurus on a **weak** auto-source extract (empty / below floor / high-entropy / app-shell / unsupported — not blocked), `FeedResolution` may probe up to five same-origin listing or native-feed candidates and rewrite the effective scrape URL via {ScrapeTarget}. Botasaurus then uses the resolved URL. Direct RSS/Atom entry URLs are parsed via `Syndication::Parser` without HTML AutoSource.
14
+ Before escalating default → Botasaurus on a **weak** auto-source extract (empty / below floor / high-entropy / app-shell / unsupported — not blocked), `FeedResolution` may probe up to five same-origin listing or native-feed candidates and rewrite the effective scrape URL via {ScrapeTarget}. Botasaurus then uses the resolved URL. Direct RSS/Atom/RDF entry URLs are parsed via `Syndication::Parser` without HTML AutoSource.
15
15
 
16
16
  There is no Browserless / Puppeteer-in-gem tier. Pin `botasaurus` when you want browser rendering without default first. Botasaurus needs `BOTASAURUS_SCRAPER_URL`.
17
17
 
@@ -83,7 +83,7 @@ module Html2rss
83
83
  # @param elapsed_ms [Integer, nil] monotonic hop duration in milliseconds
84
84
  # @return [void]
85
85
  def record_error(strategy:, error:, elapsed_ms: nil)
86
- meta = error.is_a?(Html2rss::Error) ? error.transport_meta : nil
86
+ meta = error.transport_meta if error.is_a?(Error)
87
87
  append_attempt(strategy:, error_class: error.class.name, transport_meta: meta, elapsed_ms:)
88
88
  @last_error = error
89
89
  return unless TERMINAL_TRANSPORT_ERRORS.any? { |klass| error.is_a?(klass) }
@@ -108,26 +108,15 @@ module Html2rss
108
108
  end
109
109
 
110
110
  # @param response [RequestService::Response] successful response
111
- # @param articles [Array] extracted articles
112
- # @param dedup_dropped [Integer] articles removed by deduplication
111
+ # @param extraction [Html2rss::FeedPipeline::Extraction] its articles, drops and feed channel facts
113
112
  # @param selected_strategy [Symbol] concrete strategy that produced items
114
113
  # @param attempt_count [Integer] number of attempts recorded for this chain
115
- # @param admission_drops [Hash{String => Integer}] Cleanup drop tallies
116
114
  # @param scrape_target [Html2rss::ScrapeTarget]
117
115
  # @return [void]
118
- # rubocop:disable-next Metrics/ParameterLists, Metrics/MethodLength -- PipelineOutcome kwargs stay co-located
119
- def succeed!(response:, articles:, dedup_dropped:, selected_strategy:, attempt_count:,
120
- scrape_target:, admission_drops: {})
116
+ def succeed!(response:, extraction:, selected_strategy:, attempt_count:, scrape_target:)
121
117
  @result = PipelineOutcome.new(
122
- response:,
123
- articles:,
124
- dedup_dropped:,
125
- selected_strategy:,
126
- attempt_count:,
127
- strategy_attempts: attempts,
128
- admission_drops:,
129
- scrape_target:,
130
- entry_resolution:
118
+ response:, selected_strategy:, attempt_count:, strategy_attempts: attempts,
119
+ scrape_target:, entry_resolution:, **extraction.to_h
131
120
  )
132
121
  end
133
122
 
@@ -137,7 +126,7 @@ module Html2rss
137
126
  # @raise [Html2rss::Error, StandardError]
138
127
  def raise_failure!
139
128
  if (error = failure_error)
140
- error.attempts = attempts if error.is_a?(Html2rss::Error)
129
+ error.attempts = attempts if error.is_a?(Error)
141
130
  raise error
142
131
  end
143
132
 
@@ -215,18 +204,18 @@ module Html2rss
215
204
  rescue *NON_FALLBACK_ERRORS => error
216
205
  state.record_error(strategy:, error:, elapsed_ms: monotonic_ms - started_ms)
217
206
  state.abort!
218
- rescue StandardError => error
207
+ rescue Html2rss::ContractError
208
+ raise
209
+ rescue Html2rss::Error => error
219
210
  state.record_error(strategy:, error:, elapsed_ms: monotonic_ms - started_ms)
220
211
  log_fallback_error(strategy:, next_strategy:, error:, request_session:) if next_strategy && request_session
221
212
  end
222
213
 
223
214
  # rubocop:disable-next Metrics/MethodLength, Metrics/AbcSize, Metrics/ParameterLists -- extract + resolution delegate
224
215
  def process_response(response:, strategy:, next_strategy:, request_session:, state:, elapsed_ms: nil)
225
- articles, dedup_dropped, admission_drops = pipeline.deduplicated_articles(
226
- config:, response:, request_session:
227
- )
216
+ extraction = pipeline.extract(config:, response:, request_session:)
228
217
  state.remember_response(response)
229
- items_count = articles.size
218
+ items_count = extraction.articles.size
230
219
  state.record_items(strategy:, items_count:, transport_meta: response.transport_meta, elapsed_ms:)
231
220
  Log.debug("#{self.class}: strategy=#{strategy} items=#{items_count} " \
232
221
  "host=#{response.url.host} elapsed=#{format('%.3f', budget.elapsed_seconds)}s " \
@@ -234,16 +223,13 @@ module Html2rss
234
223
 
235
224
  if (outcome = FeedResolution.try_apply!(
236
225
  pipeline:, config:, response:, session: request_session, strategy:, resources:,
237
- articles:, scrape_target:, state:, budget:
226
+ articles: extraction.articles, scrape_target:, state:, budget:
238
227
  ))
239
228
  @scrape_target = outcome.scrape_target
240
229
  return
241
230
  end
242
231
 
243
- if items_count.positive?
244
- return record_success(response:, strategy:, articles:, dedup_dropped:, admission_drops:,
245
- state:)
246
- end
232
+ return record_success(response:, strategy:, extraction:, state:) if items_count.positive?
247
233
 
248
234
  if DETERMINISTIC_HTTP_STATUSES.member?(response&.status)
249
235
  state.abort!
@@ -254,14 +240,10 @@ module Html2rss
254
240
  log_info_fallback_zero_items(strategy:, next_strategy:, response:) if next_strategy
255
241
  end
256
242
 
257
- # rubocop:disable-next Metrics/ParameterLists, Metrics/MethodLength -- success kwargs + structured selected log
258
- def record_success(response:, strategy:, articles:, dedup_dropped:, admission_drops:, state:)
243
+ # rubocop:disable-next Metrics/MethodLength -- success + structured selected log
244
+ def record_success(response:, strategy:, extraction:, state:)
259
245
  attempt_count = state.attempts.size
260
- state.succeed!(
261
- response:, articles:, dedup_dropped:, selected_strategy: strategy,
262
- attempt_count:, admission_drops:,
263
- scrape_target:
264
- )
246
+ state.succeed!(response:, extraction:, selected_strategy: strategy, attempt_count:, scrape_target:)
265
247
  return unless attempt_count > 1
266
248
 
267
249
  Log.info({
@@ -33,13 +33,13 @@ module Html2rss
33
33
  ##
34
34
  # @param name [Symbol, String] config or configure strategy value
35
35
  # @return [Auto, Concrete] resolved feed-level plan
36
- # @raise [ArgumentError] when +name+ is neither +:auto+ nor a registered strategy
36
+ # @raise [InvalidOption] when +name+ is neither +:auto+ nor a registered strategy
37
37
  def resolve(name)
38
38
  normalized = name.to_sym
39
39
  return Auto.new if normalized == AUTO_NAME
40
40
 
41
41
  unless RequestService.strategy_registered?(normalized)
42
- raise ArgumentError, "unknown strategy plan: #{name.inspect}"
42
+ raise InvalidOption, "unknown strategy plan: #{name.inspect}"
43
43
  end
44
44
 
45
45
  Concrete.new(strategy: normalized)
@@ -64,7 +64,7 @@ module Html2rss
64
64
 
65
65
  resolve(name)
66
66
  true
67
- rescue ArgumentError
67
+ rescue InvalidOption
68
68
  false
69
69
  end
70
70
 
@@ -10,11 +10,19 @@ module Html2rss
10
10
  # selected_strategy: set on :auto success; nil otherwise.
11
11
  # attempt_count: auto attempts attempted; 0 outside :auto.
12
12
  # strategy_attempts: auto attempt hashes (with optional transport_meta); empty outside :auto.
13
+ # feed_channel: channel facts of the native feed followed ({Syndication::ChannelFacts}); nil without one.
13
14
  PipelineOutcome = Data.define(
14
15
  :response, :articles, :dedup_dropped, :selected_strategy, :attempt_count, :strategy_attempts,
15
- :admission_drops, :scrape_target, :entry_resolution
16
+ :admission_drops, :scrape_target, :entry_resolution, :feed_channel
16
17
  )
17
18
 
19
+ # What one response yields after extraction and dedup ({#extract}); its fields are the
20
+ # {PipelineOutcome} fields of the same names.
21
+ # articles: unique articles. dedup_dropped: articles removed by deduplication.
22
+ # admission_drops: Cleanup drop tallies. feed_channel: the followed native feed's channel facts
23
+ # ({Syndication::ChannelFacts}); nil without one.
24
+ Extraction = Data.define(:articles, :dedup_dropped, :admission_drops, :feed_channel)
25
+
18
26
  ##
19
27
  # @param raw_config [Hash{Symbol => Object}] user-provided feed config
20
28
  def initialize(raw_config)
@@ -71,11 +79,11 @@ module Html2rss
71
79
  # @param config [Html2rss::Config]
72
80
  # @param response [Html2rss::RequestService::Response]
73
81
  # @param request_session [Html2rss::RequestSession]
74
- # @return [Array(Array<Html2rss::Article>, Integer, Hash)] unique articles, dedup drops, admission drops
75
- def deduplicated_articles(config:, response:, request_session:)
76
- collected, admission_drops = collect_articles(config:, response:, request_session:)
82
+ # @return [Extraction] unique articles with their dedup and admission drops and feed channel facts
83
+ def extract(config:, response:, request_session:)
84
+ collected, admission_drops, feed_channel = collect_articles(config:, response:, request_session:)
77
85
  unique = Article::Deduplicator.new(collected).call
78
- [unique, collected.size - unique.size, admission_drops]
86
+ Extraction.new(articles: unique, dedup_dropped: collected.size - unique.size, admission_drops:, feed_channel:)
79
87
  end
80
88
 
81
89
  private
@@ -95,13 +103,12 @@ module Html2rss
95
103
  def run_pipeline_for_strategy(config, strategy:, resources:)
96
104
  request_session = request_session_for(config, strategy:, resources:)
97
105
  response = request_session.fetch_initial_response
98
- articles, dedup_dropped, admission_drops = deduplicated_articles(config:, response:, request_session:)
99
- raise_empty_auto_source!(strategy:, response:) if config.auto_source && articles.empty?
106
+ extraction = extract(config:, response:, request_session:)
107
+ raise_empty_auto_source!(strategy:, response:) if config.auto_source && extraction.articles.empty?
100
108
 
101
109
  PipelineOutcome.new(
102
- response:, articles:, dedup_dropped:, selected_strategy: nil, attempt_count: 0,
103
- strategy_attempts: [], admission_drops:,
104
- scrape_target: ScrapeTarget.from_config(config), entry_resolution: nil
110
+ response:, selected_strategy: nil, attempt_count: 0, strategy_attempts: [],
111
+ scrape_target: ScrapeTarget.from_config(config), entry_resolution: nil, **extraction.to_h
105
112
  )
106
113
  end
107
114
 
@@ -127,49 +134,55 @@ module Html2rss
127
134
  end
128
135
 
129
136
  def collect_articles(config:, response:, request_session:)
130
- selector = selector_articles(config:, response:, request_session:)
131
- auto, admission_drops = auto_source_articles(config:, response:, request_session:)
132
- [selector + auto, admission_drops]
137
+ selector, selector_drops = selector_articles(config:, response:, request_session:)
138
+ auto, auto_drops, feed_channel = auto_source_articles(config:, response:, request_session:)
139
+ [selector + auto, selector_drops.merge(auto_drops) { |_reason, left, right| left + right }, feed_channel]
133
140
  end
134
141
 
135
- # rubocop:disable-next Metrics/MethodLength
142
+ # @return [Array(Array<Html2rss::Article>, Hash{String => Integer})] articles and unlinked-item drop tallies
136
143
  def selector_articles(config:, response:, request_session:)
137
- return [] unless (selectors = config.selectors)
138
- return [] if response.feed_response?
144
+ return [[], {}] unless (selectors = config.selectors)
145
+ return [[], {}] if response.feed_response?
139
146
 
140
147
  page_responses = request_session.page_responses(
141
148
  response,
142
149
  pagination_config: selectors.dig(:items, :pagination)
143
150
  )
151
+ engines = page_responses.lazy.map { |page| Selectors.new(page, selectors:, time_zone: config.time_zone) }
152
+ collect_selector_pages(engines)
153
+ end
144
154
 
155
+ # Stops at the first empty page after articles were found; drop tallies sum over the pages read.
156
+ def collect_selector_pages(engines)
145
157
  articles = []
146
- page_responses.each do |page_response|
147
- page_articles = Selectors.new(page_response, selectors:, time_zone: config.time_zone).articles
158
+ drops = Hash.new(0)
159
+ engines.each do |engine|
160
+ page_articles = engine.articles
161
+ engine.admission_drops.each { |reason, count| drops[reason] += count }
148
162
  break if page_articles.empty? && articles.any?
149
163
 
150
164
  articles.concat(page_articles)
151
165
  end
152
- articles
166
+ [articles, drops]
153
167
  end
154
168
 
155
- # @return [Array(Array<Html2rss::Article>, Hash{String => Integer})]
169
+ # @return [Array(Array<Html2rss::Article>, Hash{String => Integer}, Html2rss::Syndication::ChannelFacts)]
156
170
  def auto_source_articles(config:, response:, request_session:)
157
- return [[], {}] unless (auto_source = config.auto_source)
171
+ return [[], {}, nil] unless (auto_source = config.auto_source)
158
172
  return syndication_articles(response) if response.feed_response?
159
173
 
160
174
  source = AutoSource.new(response, auto_source, request_session:)
161
- [source.articles, source.admission_drops]
175
+ [source.articles, source.admission_drops, source.feed_channel]
162
176
  end
163
177
 
164
178
  def syndication_articles(response)
165
- articles = Syndication::Parser.parse_response(response).map do |hash|
166
- Article.new(**hash, scraper: AutoSource::Scraper::NativeFeed)
167
- end
168
- [articles, {}]
179
+ feed = Syndication::Parser.parse_response(response)
180
+ articles = feed.articles.map { |hash| Article.new(**hash, scraper: AutoSource::Scraper::NativeFeed) }
181
+ [articles, {}, feed.channel]
169
182
  end
170
183
 
171
184
  def feed_result_for(config, outcome) # rubocop:disable Metrics/MethodLength
172
- channel = Channel.from_response(outcome.response, overrides: config.channel)
185
+ channel = Channel.from_response(outcome.response, overrides: config.channel, feed: outcome.feed_channel)
173
186
  status = Status.build(
174
187
  articles: outcome.articles,
175
188
  dedup_dropped: outcome.dedup_dropped,
@@ -24,7 +24,7 @@ tournament on item count (feed-as-means) — intentional.
24
24
 
25
25
  ### CandidateGenerator mix
26
26
 
27
- - **Feed slot:** first `Syndication::Discovery.candidate_urls` only (alternates precede path guesses).
27
+ - **Feed slot:** first same-domain `Syndication::Discovery.candidate_urls` entry. Alternates precede path guesses, except audio-hinted ones on a page without podcast signals. An alternate on another domain is skipped.
28
28
  - **Listing fill** (cap = `max - 1` when `max > 1`; `max == 1` → feed only): nav (`taxonomy_path +3`, `content_path +1`) → segment first-wins (`:list` → `:cluster` → `:semantic`, ≥2 same-origin primary links) → `LISTING_PATHS`.
29
29
  - Same-page / same-registrable-domain filters; feed never pads into listing slots.
30
30
 
@@ -46,14 +46,12 @@ module Html2rss
46
46
 
47
47
  def listing_cap = max > 1 ? max - 1 : 0
48
48
 
49
+ # First eligible Discovery candidate: an alternate on another domain (Discovery ranks
50
+ # audio-hinted ones first on a podcast page) must not displace a same-domain feed or path guess.
49
51
  def feed_slot
50
52
  return unless parsed_html
51
53
 
52
- url = Syndication::Discovery.candidate_urls(page_url: entry_url, parsed_body: parsed_html).first
53
- return unless url
54
- return unless eligible_url?(url)
55
-
56
- url
54
+ Syndication::Discovery.candidate_urls(page_url: entry_url, parsed_body: parsed_html).find { eligible_url?(_1) }
57
55
  end
58
56
 
59
57
  def listing_urls
@@ -86,6 +84,8 @@ module Html2rss
86
84
  return [] unless parsed_html
87
85
 
88
86
  sst = SST::Normalizer.call(parsed_html)
87
+ return [] unless sst
88
+
89
89
  link_resolver = Scoring::LinkResolver.new(entry_url)
90
90
 
91
91
  SEGMENT_STRATEGIES.each do |strategy|
@@ -93,8 +93,6 @@ module Html2rss
93
93
  return urls if urls.size >= 2
94
94
  end
95
95
  []
96
- rescue StandardError
97
- []
98
96
  end
99
97
 
100
98
  def segment_urls_for(sst, strategy, link_resolver)
@@ -120,7 +118,7 @@ module Html2rss
120
118
  return if href.nil? || href.empty? || href.start_with?('#', 'mailto:', 'javascript:')
121
119
 
122
120
  Html2rss::Url.from_relative(href, entry_url)
123
- rescue ArgumentError
121
+ rescue Html2rss::Url::InvalidUrlError
124
122
  nil
125
123
  end
126
124
 
@@ -8,15 +8,11 @@ module Html2rss
8
8
  # Typed +auto_source.entry_resolution+ options (one expansion from config Hash).
9
9
  class Options
10
10
  ##
11
- # @param auto_source [Hash, nil]
11
+ # @param auto_source [Hash, nil] validated +auto_source+ config
12
12
  # @return [Options]
13
13
  def self.from_auto_source(auto_source)
14
- raw = auto_source.is_a?(Hash) ? auto_source[:entry_resolution] : nil
15
- raw = {} unless raw.is_a?(Hash)
16
- new(
17
- enabled: raw.fetch(:enabled, FeedResolution::DEFAULT_CONFIG[:enabled]) != false,
18
- max_probes: Integer(raw.fetch(:max_probes, FeedResolution::DEFAULT_CONFIG[:max_probes]))
19
- )
14
+ raw = auto_source&.dig(:entry_resolution) || {}
15
+ new(enabled: raw.fetch(:enabled, true), max_probes: raw.fetch(:max_probes, DEFAULT_CONFIG[:max_probes]))
20
16
  end
21
17
 
22
18
  ##
@@ -8,40 +8,36 @@ module Html2rss
8
8
  # Minimum admitted articles before the entry URL is considered strong enough.
9
9
  ARTICLE_FLOOR = 3
10
10
 
11
- module_function
12
-
13
- ##
14
- # @param config [Html2rss::Config]
15
- # @param articles [Array<Html2rss::Article>] typed extract results (count derived)
16
- # @param surface_category [Html2rss::SurfaceCategory, Symbol]
17
- # @return [Boolean]
18
- def resolve?(config:, articles:, surface_category:)
19
- raise ArgumentError, 'articles must be an Array' unless articles.is_a?(Array)
20
- return false unless eligible_config?(config)
21
-
22
- category = SurfaceCategory[surface_category]
23
- return false if category.blocked?
24
-
25
- articles.size < ARTICLE_FLOOR || category.weak? || native_feed_majority?(articles)
26
- end
27
-
28
- def eligible_config?(config)
29
- return false unless config.auto_source
30
- return false if config.selectors
31
-
32
- Options.from_auto_source(config.auto_source).enabled?
33
- end
34
- module_function :eligible_config?
35
- private_class_method :eligible_config?
36
-
37
- def native_feed_majority?(articles)
38
- return false if articles.empty?
39
-
40
- native = articles.count { |article| article.scraper == AutoSource::Scraper::NativeFeed }
41
- native * 2 >= articles.size
11
+ class << self
12
+ ##
13
+ # @param config [Html2rss::Config]
14
+ # @param articles [Array<Html2rss::Article>] typed extract results (count derived)
15
+ # @param surface_category [Html2rss::SurfaceCategory, Symbol]
16
+ # @return [Boolean]
17
+ def resolve?(config:, articles:, surface_category:)
18
+ raise InvalidError, 'articles must be an Array' unless articles.is_a?(Array)
19
+ return false unless eligible_config?(config)
20
+
21
+ category = SurfaceCategory[surface_category]
22
+ return false if category.blocked?
23
+
24
+ articles.size < ARTICLE_FLOOR || category.weak? || native_feed_majority?(articles)
25
+ end
26
+
27
+ private
28
+
29
+ def eligible_config?(config)
30
+ return false unless config.auto_source
31
+ return false if config.selectors
32
+
33
+ Options.from_auto_source(config.auto_source).enabled?
34
+ end
35
+
36
+ def native_feed_majority?(articles)
37
+ native = articles.count { |article| article.scraper == AutoSource::Scraper::NativeFeed }
38
+ native * 2 >= articles.size
39
+ end
42
40
  end
43
- module_function :native_feed_majority?
44
- private_class_method :native_feed_majority?
45
41
  end
46
42
  end
47
43
  end
@@ -16,7 +16,7 @@ module Html2rss
16
16
  # @param origin_url [String, Html2rss::Url]
17
17
  def initialize(request_session:, origin_url:)
18
18
  @request_session = request_session
19
- @origin_url = Html2rss::Url.from_absolute(origin_url)
19
+ @origin_url = Url.from_absolute(origin_url)
20
20
  end
21
21
 
22
22
  ##
@@ -28,7 +28,9 @@ module Html2rss
28
28
  relation: :entry_resolution,
29
29
  origin_url:
30
30
  )
31
- score_response(url, response)
31
+ score_response(response)
32
+ rescue Html2rss::ContractError
33
+ raise
32
34
  rescue Html2rss::Error
33
35
  nil
34
36
  end
@@ -37,9 +39,9 @@ module Html2rss
37
39
 
38
40
  attr_reader :request_session, :origin_url
39
41
 
40
- def score_response(_url, response) # rubocop:disable Metrics/MethodLength -- feed vs HTML branches
42
+ def score_response(response) # rubocop:disable Metrics/MethodLength -- feed vs HTML branches
41
43
  if response.feed_response?
42
- count = Syndication::Parser.parse_response(response).size
44
+ count = Syndication::Parser.parse_response(response).articles.size
43
45
  return Scored.new(
44
46
  url: response.url,
45
47
  score: Scorer.score_feed(articles_count: count),
@@ -11,38 +11,38 @@ module Html2rss
11
11
  # Bonus when the surface looks like a listing rather than a hub/shell.
12
12
  LISTING_SURFACE_BONUS = 5
13
13
 
14
- module_function
14
+ class << self
15
+ ##
16
+ # @param articles_count [Integer]
17
+ # @return [Integer]
18
+ def score_feed(articles_count:)
19
+ articles_count * ARTICLE_WEIGHT
20
+ end
15
21
 
16
- ##
17
- # @param articles_count [Integer]
18
- # @return [Integer]
19
- def score_feed(articles_count:)
20
- articles_count.to_i * ARTICLE_WEIGHT
21
- end
22
-
23
- ##
24
- # @param assessment [Html2rss::PageRecon::Assessment]
25
- # @return [Integer]
26
- def score_assessment(assessment)
27
- score = assessment.articles_count * ARTICLE_WEIGHT
28
- score += LISTING_SURFACE_BONUS if assessment.listing_bonus?
29
- drops = assessment.admission_drops.values.sum
30
- total = assessment.articles_count + drops
31
- score -= ((drops.to_f / total) * ARTICLE_WEIGHT).round if total.positive?
32
- score
33
- end
22
+ ##
23
+ # @param assessment [Html2rss::PageRecon::Assessment]
24
+ # @return [Integer]
25
+ def score_assessment(assessment)
26
+ score = assessment.articles_count * ARTICLE_WEIGHT
27
+ score += LISTING_SURFACE_BONUS if assessment.listing_bonus?
28
+ drops = assessment.admission_drops.values.sum
29
+ total = assessment.articles_count + drops
30
+ score -= ((drops.to_f / total) * ARTICLE_WEIGHT).round if total.positive?
31
+ score
32
+ end
34
33
 
35
- ##
36
- # @param scored [Array<Probe::Scored>]
37
- # @param entry_articles_count [Integer]
38
- # @return [Probe::Scored, nil]
39
- def pick_winner(scored:, entry_articles_count:)
40
- winner = scored.max_by(&:score)
41
- return unless winner
42
- return if winner.articles_count <= entry_articles_count.to_i
43
- return if winner.score <= 0
34
+ ##
35
+ # @param scored [Array<Probe::Scored>]
36
+ # @param entry_articles_count [Integer]
37
+ # @return [Probe::Scored, nil]
38
+ def pick_winner(scored:, entry_articles_count:)
39
+ winner = scored.max_by(&:score)
40
+ return unless winner
41
+ return if winner.articles_count <= entry_articles_count.to_i
42
+ return if winner.score <= 0
44
43
 
45
- winner
44
+ winner
45
+ end
46
46
  end
47
47
  end
48
48
  end