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
@@ -11,21 +11,25 @@ Entry: `FeedPipeline` → `AutoSource#articles` → `Scraper.build_instance` →
11
11
  ## Live flow
12
12
 
13
13
  1. **Request** — `RequestSession` returns a `Response` with `body` (String) and `parsed_body` (Nokogiri HTML, or parsed JSON for JSON media types). Direct syndication responses skip HTML scrapers and parse via `Syndication::Parser`.
14
- 2. **JSON document body** — When the response is a JSON media type (`Response#json_response?`), AutoSource runs `JsonDocument` only (no HTML tiers). Scalar JSON is `unsupported_surface`. Same-domain Cleanup stays on by default (`keep_different_domain: false`). If the scraper yielded items and Cleanup kept none, `Log.warn` emits `auto_source.admission_emptied` with drop tallies; a `different_domain` tally hints at `auto_source.cleanup.keep_different_domain: true` / `--keep-different-domain`, which lets the remote source control off-domain link targets (no affiliation or safety check). Guidance stays in help/docs and that structured warning — stdout feed output stays composable.
15
- 3. **Scraper tiers** (HTML only) — Enabled scrapers that claim the page (shallow `articles?` or instance `extractable?`) run in `Scraper::SCRAPER_TIERS` order. Merge within a tier, then stop when enough articles survive Cleanup:
14
+ 2. **JSON document body** — When the response is a JSON media type (`Response#json_response?`), AutoSource runs `JsonDocument` only (no HTML tiers). Scalar JSON is `unsupported_surface`. Same-domain Cleanup stays on by default (`keep_different_domain: false`). If the scraper yielded items and Cleanup kept none, `Log.warn` emits `auto_source.admission_emptied` with drop tallies; a `different_domain` tally hints at `auto_source.cleanup.keep_different_domain: true` / `--keep-different-domain`, which lets the remote source control off-domain link targets (no affiliation or safety check); a `sponsored` tally hints at `auto_source.cleanup.keep_sponsored: true` / `--keep-sponsored`. Guidance stays in help/docs and that structured warning — stdout feed output stays composable.
15
+ 3. **Scraper tiers** (HTML only) — Enabled scrapers that claim the page (shallow `articles?` or instance `extractable?`) run in `Scraper::SCRAPER_TIERS` order. Merge within a tier, then stop when `limit` counting articles survive Cleanup. Every admitted item counts except a media item (`Article#media?`: a Schema `VideoObject`, `AudioObject`, `PodcastEpisode` or `Clip`, standalone, in `@graph` or an array, or an ItemList element) on a page that is no media page, and a structured item the page does not show in its content (`Corroboration#shown?`: a `CORROBORATED_SCRAPERS` item kept by score, not rendered as a content anchor); both stay admitted but never stop the tiers, and the final cap to `limit` takes counting items first, then fills with the rest. A media page (`Corroboration#media_page?`: more than half of the items read off the page and shown in its content are media, each article identity counted once, such as a podcast's episode list) counts its media items and puts them first: media items, then the other counting items, each group in the document order of its content anchors (`Corroboration#page_order` over `PageLinks#content_position`, from the placement walk), not tier order. A page is judged a media page only after a heuristic tier has read its cards. A native feed's items come first and the page tiers still run to fill up to `limit`; a page copy of a feed article merges into the feed item (below):
16
16
 
17
- 0. Native feed: NativeFeed (head alternates + path discovery → RSS/Atom parse)
17
+ 0. Native feed: NativeFeed (head alternates + path discovery → RSS/Atom/RDF parse); what the feed states about its channel (author, iTunes categories, explicit flag) is kept as `AutoSource#feed_channel` for `Channel` (`CONTEXT.md` § Channel), and where it was read (`NativeFeed#feed_url`) for Cleanup's domain rule. A link-less feed item takes its guid when that is an absolute http(s) URL, else its first audio or video file (`Syndication::Parser`)
18
18
  1. In-page structured: Schema, Microdata, Microformats2, JsonState, XhrArticles
19
19
  2. Follow-up IO: WordPress API, Sitemap, MetaOembed
20
20
  3. Heuristic: SemanticHtml
21
21
  4. Heuristic: Html (skipped when earlier tiers already admitted at least one clean article)
22
22
 
23
- 4. **Structured / API scrapers** — Schema, Microdata, MF2, JSON state, XHR JSON, oEmbed, WordPress REST, and Sitemap work on Nokogiri CSS/XPath or JSON/XML parsers. They do not use SST. `JsonDocument` walks a JSON response body (JSON Feed mapping or `JsonState.discover_articles`).
24
- 5. **Heuristic scrapers** — `SemanticHtml` and `Html` normalize once into an `SST::Document`, then:
23
+ 4. **Structured / API scrapers** — Schema, Microdata, MF2, JSON state, XHR JSON, oEmbed, WordPress REST, and Sitemap work on Nokogiri CSS/XPath or JSON/XML parsers. They do not extract from SST. `JsonDocument` walks a JSON response body (JSON Feed mapping or `JsonState.discover_articles`).
24
+ 5. **Heuristic scrapers** — `SemanticHtml` and `Html` share the page `SST::Document` (built lazily, at most once per page, by the first consumer: a heuristic tier or the page check), then:
25
25
 
26
26
  `SST::Normalizer` → `AutoSource::Segmenter` → `Scoring::Engine` → extractor / article materialization.
27
27
 
28
- 6. **Cleanup** — Merge, dedupe, hard-exclude non-article destinations (via `PathClassifier` facts), drop junk titles, and trim to `limit`. Html is skipped when earlier tiers already admitted clean items.
28
+ 6. **Cleanup** — Page-check structured items, merge copies of one article (`Url#article_identity`) into one item (the first copy keeps every field it carries — a native feed item its date, guid, description, publisher title, declared media files and image, even when the page card's image is larger — and later copies fill only the fields it lacks, such as the page card's image when the feed item has none, or categories, via `Article#merge`), drop explicitly sponsored items of every source, drop items linking off the page's domain (unless `keep_different_domain`; a followed native feed's own items may also link to that feed's domain, such as a podcast platform's episode pages; an item whose URL is its own audio/video enclosure, a link-less episode served from a CDN, skips the rule), hard-exclude non-article destinations (via `PathClassifier` facts), drop untitled page-derived items (`blank_title`; publisher feeds may carry untitled posts), titles under three words (`WordCount`: unspaced Japanese or Chinese text counts one word per six characters) and junk titles, and trim to `limit`. Titles from the heuristic scrapers (`Scraper::HEURISTIC_SCRAPERS`) reach Cleanup composed by `Article::Title` (`Kicker: Headline` read from elements by `Html::TitlePieces`: kicker leaves in or beside the heading, the headline piece of an element-only heading or anchor or its `data-title`, and no date, time, duration, concealed, lead or trailing tag elements, nor teaser clock, label or type noise in en, de, fr, es, it, nl, pl, sv and ja; a Japanese leading `【…】`/`<…>` is dropped when it names a content type and is the kicker otherwise); every other scraper carries a publisher-declared headline (structured data, feeds, APIs) and keeps it verbatim apart from whitespace collapse and invisible characters (soft hyphens, zero-width characters). Cleanup rejects junk titles but never rewrites them. Html is skipped when earlier tiers already admitted clean items.
29
+
30
+ Page check: items from `Scraper::CORROBORATED_SCRAPERS` go through `Corroboration#verdict` before dedupe. `PageLinks` places each URL on the rendered page (content, peripheral, chrome, absent). Content keeps; chrome-only drops as `chrome_only`; unrendered drops as `not_on_page` when the page renders at least half of the item's scraper cohort (or the cohort has fewer than three items), otherwise it is scored with absence counting against it; peripheral items, and any item on a sparse page or a JSON response (no page), keep only on a private article-likeness score, else `unlikely`. Dropped items never count toward tier stop, so the heuristic tiers run and merge; `PageLinks` keys URLs by `Url#article_identity` (ignores scheme, fragment, trailing slash, and tracking query params; other params stay significant), the same key Cleanup dedupes by. A kept structured item whose URL differs from its rendered content anchor only by those takes the anchor's URL minus tracking params, never downgraded from `https` to `http`, so the item guid stays stable (`PageLinks#canonical_url` via `Corroboration#canonical_url`), so it merges with the heuristic copy; for a shared URL the kept structured copy wins dedupe.
31
+
32
+ Sponsored items: `PageLinks` marks a URL sponsored when every card linking it carries a `LinkDestination::Sponsorship` marker. A container marker (`rel="sponsored"`, a truthy `data-sponsored`/`data-advertorial`, a sponsored or native-advertising class token such as `card--sponsored` or `adv-native-strip`, or an ARIA ad label) marks every destination inside it; a label inside a card link marks that link's destination across its one-card unit; any other label (an ad-label class, or a standalone element whose whole text is an ad word such as "Anzeige" or a paid phrase such as "Sponsored by …") marks the headline destination of the `article`, `section`, `aside` or `li` it sits in; and a paid phrase outside every anchor binds at its first ancestor holding a destination: the one card it sits in alone (bare `div` cards included) is marked, and so is every card of a section whose header it is (a heading, a `header` element or a header box), never from plain text before the cards. Label words in headlines, anchor text or prose, ad words on topic chips, and unpaid attribution ("Presented by …"), never count. Cleanup drops such items from every source as `sponsored`, a native feed's paid post included; `auto_source.cleanup.keep_sponsored: true` keeps them, titled without the paid label. Rules: `CONTEXT.md` § Sponsored items.
29
33
 
30
34
  7. **Entry URL resolution** (pipeline, not this class) — when AutoFallback sees a weak homepage extract, `FeedResolution` may rewrite the scrape URL to a listing/feed before escalating strategies. See {Html2rss::FeedPipeline} and {Html2rss::FeedResolution}.
31
35
 
@@ -37,13 +41,13 @@ Segmenter strategies: `:semantic` (leaf containers + primary link), `:list` (car
37
41
  | --------------------------------------------------------------------------------------------- | ---------------------------------------------------------------- |
38
42
  | `Response#parsed_body` | HTML document, or Hash/Array for JSON bodies |
39
43
  | `JsonDocument` | Parsed JSON Hash/Array (no Nokogiri) |
40
- | Schema / Microdata / MF2 / JsonState / XhrArticles / MetaOembed / app-shell detection | Nokogiri |
44
+ | Schema / Microdata / MF2 / JsonState / XhrArticles / MetaOembed / app-shell detection | Nokogiri (items page-checked against SST anchors via `PageLinks`) |
41
45
  | Sitemap detection (CSS/XPath) | Nokogiri; URL list parsing uses raw `response.body` (XML string) |
42
46
  | Selectors path / Sanitize transformers | Nokogiri (unchanged) |
43
47
  | `SST::Normalizer` | Sole Nokogiri consumer on the heuristic auto-source path |
44
48
  | Segmenter, Scoring, `Html::SstArticleExtractor`, heuristic chrome (`SST::Tags` / `SST::Text`) | SST only |
45
49
 
46
- Production heuristic scrapers should reuse one `SST::Document` memoized from `parsed_body` (or a shared Document passed in), not re-parse HTML strings.
50
+ Production heuristic scrapers and the page check reuse one `SST::Document` and one page `Scoring::LinkResolver` memoized by `AutoSource`, not re-parse HTML strings.
47
51
 
48
52
  ## Constraints
49
53
 
@@ -51,6 +55,10 @@ Production heuristic scrapers should reuse one `SST::Document` memoized from `pa
51
55
  - **Top-K** — `Scoring::Engine::TOP_K` (99) caps ranked segments materialized into articles; list strategy also budgets `use_top_selectors`.
52
56
  - **Typed stages** — Pipeline stages take `SST::Document` / `Segment` / `RankedSegment`, not ad-hoc Hash bags. Internal scraper APIs may change; the public gem surface is `lib/html2rss.rb`.
53
57
 
58
+ ## Measuring changes
59
+
60
+ Precision and recall changes are measured on the labeled corpus (`bin/auto-source-corpus`, ratchet spec, `make corpus-baseline`); see `CONTEXT.md` § Auto-source corpus. Wall/alloc budgets: `make perf-baseline` (auto-source), `make perf-baseline-selectors`, `make perf-baseline-feed` (native-feed parse, RSS/JSON Feed render, descriptions); `make perf-check` gates all three (`bin/lib/perf_baseline.rb` owns the 1.15x allocation rule). Revision-to-revision effect on saved real pages: `bin/compare-revisions`.
61
+
54
62
  ## Non-goals
55
63
 
56
64
  - Replacing Selectors, Schema, Microdata, MF2, JsonState, JsonDocument, or XhrArticles with SST.
@@ -6,14 +6,16 @@ module Html2rss
6
6
  # Cleanup is responsible for cleaning up the extracted articles.
7
7
  # :reek:MissingSafeMethod { enabled: false }
8
8
  # It applies various strategies to filter and refine the article list.
9
- # Sole producer of admission drop tallies for {Html2rss::Status}.
9
+ # Producer of the auto-source admission drop tallies for {Html2rss::Status}; {Html2rss::Selectors} tallies
10
+ # its own +missing_url+ drops.
10
11
  class Cleanup # rubocop:disable Metrics/ClassLength -- reject steps + tallies stay co-located
11
12
  # Default cleanup behavior for auto-sourced article lists.
12
13
  DEFAULT_CONFIG = {
13
- keep_different_domain: false
14
+ keep_different_domain: false,
15
+ keep_sponsored: false
14
16
  }.freeze
15
17
 
16
- # Minimum alphanumeric word count for present titles.
18
+ # Minimum word count ({WordCount}) for present titles.
17
19
  MIN_WORDS = 3
18
20
 
19
21
  # Allowed URL schemes for article filtering.
@@ -48,12 +50,14 @@ module Html2rss
48
50
  [:video_chrome, /\AVideo\s*[•·]/i],
49
51
  [:template, /\ACreated\s+from\s+Template\s+ID\b/i],
50
52
  [:template, /(\{\{[^}]+\}\}|%\{\w+\})/],
51
- [:cta, /\Aread more\z/i],
52
- [:cta, /\Alearn more\z/i],
53
53
  [:cta, /\Apdf\z/i]
54
54
  ].freeze
55
55
  private_constant :JUNK_TITLE_RULES
56
56
 
57
+ # Owner of the read-more vocabulary ("Read more", "More about X" in nine languages): a +:cta+ title.
58
+ TEXT_CLASSIFIER = LinkDestination::TextClassifier.new
59
+ private_constant :TEXT_CLASSIFIER
60
+
57
61
  # Minimum present-title length before short-title warnings.
58
62
  MIN_TITLE_LENGTH = 4
59
63
 
@@ -69,32 +73,56 @@ module Html2rss
69
73
  'auto_source.cleanup.keep_different_domain: true (or --keep-different-domain); ' \
70
74
  'allows published items to link to any domain supplied by the source'
71
75
 
76
+ # Config key / CLI flag named when sponsored drops emptied admission.
77
+ SPONSORED_HINT =
78
+ 'auto_source.cleanup.keep_sponsored: true (or --keep-sponsored); ' \
79
+ 'keeps items the page marks as sponsored'
80
+
81
+ ADMISSION_HINTS = { 'different_domain' => DIFFERENT_DOMAIN_HINT, 'sponsored' => SPONSORED_HINT }.freeze
82
+ private_constant :ADMISSION_HINTS
83
+
72
84
  class << self
73
- # Mitigating config hint when admission drops emptied a non-empty capture.
85
+ # Mitigating config hints when admission drops emptied a non-empty capture.
74
86
  # Other drop reasons remain counts only.
75
87
  #
76
88
  # @param drop_tallies [Hash{String => Integer}]
77
89
  # @return [String, nil]
78
90
  def admission_hint(drop_tallies)
79
- return unless drop_tallies.fetch('different_domain', 0).positive?
80
-
81
- DIFFERENT_DOMAIN_HINT
91
+ hints = ADMISSION_HINTS.select { |reason, _| drop_tallies.fetch(reason, 0).positive? }.values
92
+ hints.join(' | ') unless hints.empty?
82
93
  end
83
94
 
84
95
  # @param articles [Array<Article>] extracted article candidates
85
96
  # @param url [Html2rss::Url] feed source URL used for same-host filtering
86
97
  # @param keep_different_domain [Boolean] when true, keep items whose URLs are on a
87
98
  # different domain than +url+ (source-controlled off-domain links; default false)
99
+ # @param feed_url [Html2rss::Url, nil] the native feed the page advertises and that was read
100
+ # ({Scraper::NativeFeed#feed_url}); that feed's own items may also link to its domain, or be their
101
+ # own audio/video file on any domain
102
+ # @param keep_sponsored [Boolean] when true, keep items the page marks as sponsored
103
+ # (default false)
104
+ # @param corroboration [Corroboration, nil] page check for structured-tier items, run
105
+ # before dedupe so a dropped structured copy leaves the URL to a later tier
106
+ # @param page_links [#call, nil] lazy source of the page's {PageLinks}; called only when
107
+ # a page-derived item is checked for sponsorship
88
108
  # @return [Result] cleaned articles and frozen drop tallies
89
- def call(articles, url:, keep_different_domain: DEFAULT_CONFIG.fetch(:keep_different_domain)) # rubocop:disable Metrics/MethodLength -- ordered reject pipeline
109
+ # rubocop:disable-next Metrics/AbcSize, Metrics/MethodLength, Metrics/ParameterLists -- ordered reject pipeline
110
+ def call(articles, url:, keep_different_domain: DEFAULT_CONFIG.fetch(:keep_different_domain),
111
+ keep_sponsored: DEFAULT_CONFIG.fetch(:keep_sponsored), corroboration: nil, page_links: nil,
112
+ feed_url: nil)
90
113
  Log.debug "Cleanup: start with #{articles.size} articles"
91
114
  tallies = Hash.new(0)
92
115
 
93
116
  reject_invalid!(articles, tallies)
117
+ if corroboration
118
+ reject_uncorroborated!(articles, corroboration, tallies)
119
+ canonicalize_urls!(articles, corroboration)
120
+ end
94
121
  deduplicate_by_url!(articles, tallies)
122
+ reject_sponsored!(articles, page_links, tallies) unless keep_sponsored
95
123
  keep_only_http_urls!(articles, tallies)
96
124
  reject_self_links!(articles, url, tallies)
97
- reject_different_domain!(articles, url, tallies) unless keep_different_domain
125
+ reject_different_domain!(articles, url, feed_url, tallies) unless keep_different_domain
98
126
  reject_excluded_destinations!(articles, tallies)
99
127
  reject_low_quality_titles!(articles, tallies)
100
128
 
@@ -112,7 +140,8 @@ module Html2rss
112
140
  normalized = normalize_title(title)
113
141
  return if normalized.empty?
114
142
 
115
- JUNK_TITLE_RULES.find { |_, pattern| pattern.match?(normalized) }&.first
143
+ JUNK_TITLE_RULES.find { |_, pattern| pattern.match?(normalized) }&.first ||
144
+ (:cta if TEXT_CLASSIFIER.read_more?(normalized))
116
145
  end
117
146
 
118
147
  # @param items [Array<Html2rss::Article, RSS::Rss::Channel::Item>]
@@ -141,7 +170,7 @@ module Html2rss
141
170
  end
142
171
 
143
172
  def audit_url_diversity(items, metrics, violations)
144
- unique_urls = items.filter_map { |item| url_identity(item_url(item)) }.uniq
173
+ unique_urls = items.filter_map { |item| item_url(item)&.article_identity }.uniq
145
174
  metrics[:unique_url_count] = unique_urls.size
146
175
  return [] unless items.size >= 2 && unique_urls.size < 2
147
176
 
@@ -171,7 +200,7 @@ module Html2rss
171
200
  if reason
172
201
  metrics[:junk_title_count] += 1
173
202
  violations[reason] += 1
174
- elsif !word_count_at_least?(title, MIN_WORDS)
203
+ elsif !WordCount.at_least?(title, MIN_WORDS)
175
204
  metrics[:low_word_count] += 1
176
205
  violations[:low_word_count] += 1
177
206
  end
@@ -197,12 +226,48 @@ module Html2rss
197
226
  tally_reject!(articles, tallies, 'invalid') { |article| !article.valid? }
198
227
  end
199
228
 
229
+ # Tallies each non-keep verdict (+chrome_only+, +not_on_page+, +unlikely+) as its reason;
230
+ # the truthy tally count rejects the item.
231
+ def reject_uncorroborated!(articles, corroboration, tallies)
232
+ cohorts = articles.group_by(&:scraper)
233
+ articles.reject! do |article|
234
+ verdict = corroboration.verdict(article, cohort: cohorts.fetch(article.scraper))
235
+ next if verdict == :keep
236
+
237
+ tallies[verdict.to_s] += 1
238
+ end
239
+ end
240
+
241
+ # Kept structured items take the rendered anchor's URL, so dedupe merges them with the
242
+ # heuristic copy and the feed links the page's canonical form.
243
+ def canonicalize_urls!(articles, corroboration)
244
+ articles.map! do |article|
245
+ canonical = corroboration.canonical_url(article)
246
+ canonical ? article.with_url(canonical) : article
247
+ end
248
+ end
249
+
250
+ # One item per article identity: the first copy wins and later copies fill only the fields it
251
+ # lacks ({Article#merge}), so a native feed item keeps its date, guid and title and takes the
252
+ # page card's image. A read-more titled copy always loses to a copy with a real title: the
253
+ # link text is chrome, the other copy carries the headline.
200
254
  def deduplicate_by_url!(articles, tallies)
201
- seen = {}
202
- tally_reject!(articles, tallies, 'duplicate_url') do |article|
203
- identity = url_identity(article.url)
204
- identity.nil? || seen.key?(identity).tap { seen[identity] = true }
255
+ reject_read_more_copies!(articles, tallies)
256
+ kept = articles.each_with_object({}) do |article, by_identity|
257
+ identity = article.url.article_identity
258
+ by_identity[identity] = by_identity[identity]&.merge(article) || article
205
259
  end
260
+ folded = articles.size - kept.size
261
+ tallies['duplicate_url'] += folded if folded.positive?
262
+ articles.replace(kept.values)
263
+ end
264
+
265
+ # Items the page marks as sponsored ({PageLinks#sponsored?}), whatever their source: a paid
266
+ # post in the site's own feed is as sponsored as its card. Item text and URL paths are never read.
267
+ def reject_sponsored!(articles, page_links, tallies)
268
+ return unless page_links
269
+
270
+ tally_reject!(articles, tallies, 'sponsored') { |article| page_links.call.sponsored?(article.url) }
206
271
  end
207
272
 
208
273
  def keep_only_http_urls!(articles, tallies)
@@ -212,19 +277,32 @@ module Html2rss
212
277
  end
213
278
 
214
279
  def reject_self_links!(articles, base_url, tallies)
215
- source_identity = url_identity(base_url)
280
+ source_identity = base_url.article_identity
216
281
  tally_reject!(articles, tallies, 'self_link') do |article|
217
- url_identity(article.url) == source_identity
282
+ article.url.article_identity == source_identity
218
283
  end
219
284
  end
220
285
 
221
- def reject_different_domain!(articles, base_url, tallies)
222
- base_domain = base_url.domain
286
+ # Items link to the page's domain. A followed native feed's own items may also link to the feed's
287
+ # domain (a podcast platform's episode pages) or be their own audio/video file, which hosted podcasts
288
+ # serve from a third-party CDN; every other item keeps the rule.
289
+ def reject_different_domain!(articles, base_url, feed_url, tallies)
290
+ page_domain = base_url.domain
223
291
  tally_reject!(articles, tallies, 'different_domain') do |article|
224
- article.url&.domain != base_domain
292
+ article.url.domain != page_domain && !followed_feed_exempt?(article, feed_url)
225
293
  end
226
294
  end
227
295
 
296
+ # A followed feed's item is exempt when it links to the feed's domain, or when its URL is its own
297
+ # audio/video file on any domain, whether the feed linked that file or the item is a link-less episode
298
+ # that took the file as its URL ({Syndication::Parser#article}).
299
+ def followed_feed_exempt?(article, feed_url)
300
+ return false unless feed_url && article.scraper == Scraper::NativeFeed
301
+
302
+ article.url.domain == feed_url.domain ||
303
+ article.enclosures.any? { |enclosure| enclosure.media? && enclosure.url == article.url }
304
+ end
305
+
228
306
  # Hard-exclude non-article destination classes (commerce/affiliate/utility chrome).
229
307
  # PathClassifier owns route facts; Cleanup owns feed-item admission.
230
308
  def reject_excluded_destinations!(articles, tallies)
@@ -234,34 +312,43 @@ module Html2rss
234
312
  end
235
313
 
236
314
  def excluded_destination?(url)
237
- return false unless url
238
-
239
315
  facts = LinkDestination::DestinationFacts.build(url)
240
316
  return true if facts.high_confidence_junk_path || facts.high_confidence_utility_destination
241
317
 
242
318
  facts.utility_path && !facts.content_path && !facts.strong_post_suffix
243
319
  end
244
320
 
245
- # Keep missing titles (nil provenance). Drop present junk/unnatural titles —
246
- # blanking them would hide bad extraction as "unknown" and inflate empty items.
247
- def reject_low_quality_titles!(articles, tallies) # rubocop:disable Metrics/MethodLength -- junk vs word-count reasons
321
+ # Drops untitled page-derived items and junk/unnatural titles — blanking a bad title would
322
+ # hide bad extraction as "unknown" and admit empty items. A publisher-declared item may be
323
+ # untitled (microblog feed posts) and is kept. {Article} collapses a blank title to nil.
324
+ def reject_low_quality_titles!(articles, tallies)
248
325
  articles.reject! do |article|
249
- title = article.title
250
- next false if title.nil?
251
-
252
- reason = junk_reason(title)
253
- if reason
254
- tallies[reason.to_s] += 1
255
- next true
256
- end
326
+ reason = title_reject_reason(article)
327
+ tallies[reason] += 1 if reason
328
+ reason
329
+ end
330
+ end
257
331
 
258
- next false if word_count_at_least?(title, MIN_WORDS)
332
+ def title_reject_reason(article)
333
+ title = article.title
334
+ if title.nil?
335
+ 'blank_title' if Scraper::PAGE_SCRAPERS.include?(article.scraper)
336
+ else
337
+ junk_reason(title)&.to_s || ('low_word_count' unless WordCount.at_least?(title, MIN_WORDS))
338
+ end
339
+ end
259
340
 
260
- tallies['low_word_count'] += 1
261
- true
341
+ def reject_read_more_copies!(articles, tallies)
342
+ titled = articles.reject { |article| read_more_title?(article) }.to_set { |a| a.url.article_identity }
343
+ tally_reject!(articles, tallies, 'duplicate_url') do |article|
344
+ read_more_title?(article) && titled.include?(article.url.article_identity)
262
345
  end
263
346
  end
264
347
 
348
+ def read_more_title?(article)
349
+ article.title && junk_reason(article.title) == :cta
350
+ end
351
+
265
352
  def tally_reject!(articles, tallies, reason)
266
353
  articles.reject! do |article|
267
354
  next false unless yield(article)
@@ -271,10 +358,6 @@ module Html2rss
271
358
  end
272
359
  end
273
360
 
274
- def url_identity(url)
275
- url&.without_fragment&.to_s
276
- end
277
-
278
361
  def feed_item_as_article(item)
279
362
  case item
280
363
  when Article then item
@@ -286,22 +369,13 @@ module Html2rss
286
369
  def item_url(item)
287
370
  raw = item.url
288
371
  raw.nil? || raw.to_s.empty? ? nil : Html2rss::Url.from_absolute(raw.to_s)
289
- rescue ArgumentError
372
+ rescue Html2rss::Url::InvalidUrlError
290
373
  nil
291
374
  end
292
375
 
293
376
  def normalize_title(title)
294
377
  title.to_s.strip.gsub(/\s+/, ' ')
295
378
  end
296
-
297
- def word_count_at_least?(str, min_words)
298
- count = 0
299
- str.to_s.scan(/\p{Alnum}+/) do
300
- count += 1
301
- return true if count >= min_words
302
- end
303
- false
304
- end
305
379
  end
306
380
  end
307
381
  end
@@ -0,0 +1,167 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ class AutoSource
5
+ ##
6
+ # Keep/drop verdict for structured-tier items, checked against the rendered page.
7
+ #
8
+ # Only {Scraper::CORROBORATED_SCRAPERS} items are checked; publisher-declared sources
9
+ # (native feed, CMS REST API, sitemap, oEmbed) are kept as-is. A URL rendered as a
10
+ # content anchor is kept. On a page that can corroborate, a URL rendered only in chrome
11
+ # is +:chrome_only+. A URL rendered nowhere is +:not_on_page+ when the page renders its
12
+ # cohort (at least half the cohort as content anchors, or a cohort too small to tell);
13
+ # an item of a cohort the page mostly does not render (an app shell, an aggregator linking
14
+ # through redirects) is scored with absence counting against it. A peripheral URL (aside,
15
+ # menu landmark), or any URL on a sparse page or without a page ({PageLinks#sparse?}),
16
+ # is decided by a private article-likeness score: +:keep+ or +:unlikely+.
17
+ #
18
+ # A kept item takes the rendered anchor's URL ({#canonical_url}); {Cleanup} applies it.
19
+ #
20
+ # {Cleanup} owns admission and tallies the drop verdicts.
21
+ class Corroboration
22
+ # Article-likeness score at or above which an uncorroborated item is kept.
23
+ LIKELY_SCORE = 2
24
+ # Fewest items in a scraper's cohort before its URL cohesion or render share counts.
25
+ MIN_COHORT_SIZE = 3
26
+
27
+ ##
28
+ # @yieldreturn [PageLinks] the page's links; called at most once, on the first checked item
29
+ def initialize(&page_links)
30
+ @page_links_source = page_links
31
+ @cohesion = {}.compare_by_identity
32
+ @rendered = {}.compare_by_identity
33
+ end
34
+
35
+ ##
36
+ # @param article [Html2rss::Article] item with a URL (Cleanup checks validity first)
37
+ # @param cohort [Array<Html2rss::Article>] the items produced by +article+'s scraper
38
+ # @return [Symbol] +:keep+, +:chrome_only+, +:not_on_page+, or +:unlikely+
39
+ def verdict(article, cohort:)
40
+ return :keep unless Scraper::CORROBORATED_SCRAPERS.include?(article.scraper)
41
+
42
+ case page_links.placement(article.url)
43
+ when :content then :keep
44
+ when :chrome then uncorroborated(:chrome_only, article, cohort)
45
+ when :absent then unrendered(article, cohort)
46
+ else scored(article, cohort)
47
+ end
48
+ end
49
+
50
+ ##
51
+ # Whether the page shows an item in its content. Only checked items can fail: one kept by
52
+ # score (rendered in chrome, a peripheral landmark, or nowhere) is not shown. Exempt items
53
+ # (publisher-declared, or read off the page by a heuristic tier) are taken as shown.
54
+ #
55
+ # @param article [Html2rss::Article] admitted item
56
+ # @return [Boolean]
57
+ def shown?(article)
58
+ !Scraper::CORROBORATED_SCRAPERS.include?(article.scraper) || page_links.placement(article.url) == :content
59
+ end
60
+
61
+ ##
62
+ # Whether the page is a media page (a podcast or video listing): more than half of the
63
+ # items read off the page ({Scraper::PAGE_SCRAPERS}) and shown in its content ({#shown?})
64
+ # are media items ({Html2rss::Article#media?}). Each article identity is one item: a
65
+ # structured media item and the card that renders it count once.
66
+ #
67
+ # @param articles [Array<Html2rss::Article>] admitted items
68
+ # @return [Boolean]
69
+ def media_page?(articles)
70
+ cards = articles.select { Scraper::PAGE_SCRAPERS.include?(_1.scraper) && shown?(_1) }
71
+ .uniq { _1.url.article_identity }
72
+ cards.count(&:media?) * 2 > cards.size
73
+ end
74
+
75
+ ##
76
+ # A media page's order: its media items first, then the other items, each group in document
77
+ # order (the position of its content anchor), with items the page does not show in its
78
+ # content last in their given order.
79
+ #
80
+ # @param articles [Array<Html2rss::Article>]
81
+ # @return [Array<Html2rss::Article>]
82
+ def page_order(articles)
83
+ articles.partition(&:media?).flat_map do |group|
84
+ placed, unplaced = group.partition { page_links.content_position(_1.url) }
85
+ placed.sort_by { page_links.content_position(_1.url) } + unplaced
86
+ end
87
+ end
88
+
89
+ ##
90
+ # The page's own form of a checked item's URL, when it differs only by scheme, tracking
91
+ # params, trailing slash, or fragment from a rendered content anchor.
92
+ #
93
+ # @param article [Html2rss::Article]
94
+ # @return [Html2rss::Url, nil] nil when the item is exempt, not a content anchor, or already canonical
95
+ def canonical_url(article)
96
+ return unless Scraper::CORROBORATED_SCRAPERS.include?(article.scraper)
97
+
98
+ canonical = page_links.canonical_url(article.url)
99
+ canonical unless canonical.to_s == article.url.to_s
100
+ end
101
+
102
+ private
103
+
104
+ # A sparse page cannot show where a URL is missing from, so the score decides there.
105
+ def uncorroborated(reason, article, cohort)
106
+ page_links.sparse? ? scored(article, cohort) : reason
107
+ end
108
+
109
+ # Absence is evidence only against a cohort the page renders: a page showing most of a
110
+ # list left the rest out. A page that renders little of the cohort shows the list
111
+ # elsewhere (client-side, through redirects), so there absence only lowers the score.
112
+ def unrendered(article, cohort)
113
+ return uncorroborated(:not_on_page, article, cohort) if page_links.sparse? || rendered?(cohort)
114
+
115
+ scored(article, cohort, absent: true)
116
+ end
117
+
118
+ def scored(article, cohort, absent: false)
119
+ likely?(article, cohort, absent:) ? :keep : :unlikely
120
+ end
121
+
122
+ def page_links
123
+ @page_links ||= @page_links_source.call
124
+ end
125
+
126
+ def likely?(article, cohort, absent:)
127
+ score = [article.published_at, article.image, article.description].count(&:itself)
128
+ score += url_shape(article.url)
129
+ score += 1 if cohesive?(cohort)
130
+ score -= 1 if absent
131
+ score >= LIKELY_SCORE
132
+ end
133
+
134
+ # +1 for an article-shaped path, -1 for a section root, 0 otherwise. A shallow path
135
+ # ending in a post slug (+/harbor-ferry-route-returns+) is a root-level permalink, not a
136
+ # section root.
137
+ def url_shape(url)
138
+ facts = LinkDestination::DestinationFacts.build(url)
139
+ return 1 if facts.content_path || facts.strong_post_suffix
140
+ return 0 unless facts.shallow
141
+
142
+ facts.segments.last&.match?(LinkDestination::PathClassifier::POST_SLUG_SEGMENT) ? 0 : -1
143
+ end
144
+
145
+ # A cohort is cohesive when at least half its URLs share one parent path.
146
+ def cohesive?(cohort)
147
+ return false if cohort.size < MIN_COHORT_SIZE
148
+
149
+ @cohesion.fetch(cohort) do
150
+ modal = cohort.map { _1.url.path_segments[0...-1] }.tally.values.max
151
+ @cohesion[cohort] = modal * 2 >= cohort.size
152
+ end
153
+ end
154
+
155
+ # A cohort is rendered when at least half its URLs are content anchors, or when it is
156
+ # too small for its share to tell a rendered list from an unrendered one.
157
+ def rendered?(cohort)
158
+ return true if cohort.size < MIN_COHORT_SIZE
159
+
160
+ @rendered.fetch(cohort) do
161
+ content = cohort.count { page_links.placement(_1.url) == :content }
162
+ @rendered[cohort] = content * 2 >= cohort.size
163
+ end
164
+ end
165
+ end
166
+ end
167
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ class AutoSource
5
+ class PageLinks
6
+ ##
7
+ # The anchors of one sponsorship walk, in document order, and which of them sit in a marked scope.
8
+ # Destinations resolve only when a scope asks for them. A claimed destination is sponsored unless
9
+ # some anchor to it stays unclaimed: a promo slot never hides the editorial card linking the story.
10
+ class AnchorClaims
11
+ # @param link_resolver [Scoring::LinkResolver]
12
+ def initialize(link_resolver)
13
+ @link_resolver = link_resolver
14
+ @anchors = []
15
+ @headline = []
16
+ @claimed = []
17
+ @keys = Set.new
18
+ end
19
+
20
+ # @return [Integer] anchors recorded so far; the next anchor's index
21
+ def size = @anchors.size
22
+
23
+ ##
24
+ # @param anchor [SST::Node]
25
+ # @param in_heading [Boolean] whether the anchor sits inside a heading
26
+ # @return [Integer] the anchor's index
27
+ def record(anchor, in_heading:)
28
+ @headline << in_heading
29
+ @anchors << anchor
30
+ @anchors.size - 1
31
+ end
32
+
33
+ # @param index [Integer]
34
+ # @return [Array, nil] article identity of the anchor's destination
35
+ def key_at(index) = key_of(@anchors.fetch(index))
36
+
37
+ # @param anchor [SST::Node]
38
+ # @return [Array, nil] article identity of the anchor's destination
39
+ def key_of(anchor) = @link_resolver.destination_facts(anchor)&.url&.article_identity
40
+
41
+ # @param range [Range] anchor indexes
42
+ # @return [Array<Array>] distinct destinations in +range+
43
+ def keys_in(range) = range.filter_map { key_at(_1) }.uniq
44
+
45
+ ##
46
+ # The one destination a card in +range+ stands for: its only one, else its only headline one (an
47
+ # anchor in or around a heading).
48
+ #
49
+ # @param range [Range] anchor indexes
50
+ # @return [Array, nil]
51
+ def dominant_key(range)
52
+ keys = keys_in(range)
53
+ return keys.first if keys.one?
54
+
55
+ headline_keys = keys_in(range.select { headline?(_1) })
56
+ headline_keys.first if headline_keys.one?
57
+ end
58
+
59
+ # Marks +keys+ sponsored and every anchor in +range+ as inside a marked scope.
60
+ #
61
+ # @param range [Range] anchor indexes
62
+ # @param keys [Array<Array>] destinations to mark; every one in +range+ by default
63
+ # @return [void]
64
+ def claim(range, keys = keys_in(range))
65
+ @keys.merge(keys)
66
+ @claimed.fill(true, range)
67
+ end
68
+
69
+ # @return [Set<Array>] claimed destinations, less those also linked outside every claimed range
70
+ def sponsored_keys
71
+ @anchors.each_index { @keys.delete(key_at(_1)) unless @claimed[_1] } unless @keys.empty?
72
+ @keys
73
+ end
74
+
75
+ private
76
+
77
+ def headline?(index) = @headline.fetch(index) || @anchors.fetch(index).children.any? { _1.find(&:heading?) }
78
+ end
79
+ private_constant :AnchorClaims
80
+ end
81
+ end
82
+ end