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.
- checksums.yaml +4 -4
- data/README.md +4 -2
- data/lib/html2rss/article/deduplicator.rb +5 -2
- data/lib/html2rss/article/enclosure.rb +185 -21
- data/lib/html2rss/article/title.rb +203 -0
- data/lib/html2rss/article.rb +79 -13
- data/lib/html2rss/auto_source/README.md +16 -8
- data/lib/html2rss/auto_source/cleanup.rb +126 -50
- data/lib/html2rss/auto_source/corroboration.rb +167 -0
- data/lib/html2rss/auto_source/page_links/anchor_claims.rb +82 -0
- data/lib/html2rss/auto_source/page_links/card_units.rb +77 -0
- data/lib/html2rss/auto_source/page_links/label_binding.rb +79 -0
- data/lib/html2rss/auto_source/page_links/sponsored_cards.rb +147 -0
- data/lib/html2rss/auto_source/page_links.rb +164 -0
- data/lib/html2rss/auto_source/scraper/html.rb +11 -39
- data/lib/html2rss/auto_source/scraper/json_document.rb +1 -3
- data/lib/html2rss/auto_source/scraper/json_state/article_normalizer.rb +119 -42
- data/lib/html2rss/auto_source/scraper/json_state/candidate_detector.rb +5 -16
- data/lib/html2rss/auto_source/scraper/json_state/value_finder.rb +2 -10
- data/lib/html2rss/auto_source/scraper/json_state.rb +2 -3
- data/lib/html2rss/auto_source/scraper/meta_oembed.rb +6 -14
- data/lib/html2rss/auto_source/scraper/microdata.rb +6 -4
- data/lib/html2rss/auto_source/scraper/microformats2.rb +1 -3
- data/lib/html2rss/auto_source/scraper/native_feed.rb +27 -17
- data/lib/html2rss/auto_source/scraper/schema/item_list.rb +5 -3
- data/lib/html2rss/auto_source/scraper/schema/thing.rb +68 -27
- data/lib/html2rss/auto_source/scraper/schema.rb +5 -8
- data/lib/html2rss/auto_source/scraper/semantic_html/entry_deduplicator.rb +3 -9
- data/lib/html2rss/auto_source/scraper/semantic_html.rb +1 -5
- data/lib/html2rss/auto_source/scraper/sitemap.rb +4 -3
- data/lib/html2rss/auto_source/scraper/wordpress_api.rb +3 -3
- data/lib/html2rss/auto_source/scraper/xhr_articles.rb +1 -3
- data/lib/html2rss/auto_source/scraper.rb +10 -2
- data/lib/html2rss/auto_source/segment.rb +19 -5
- data/lib/html2rss/auto_source/segmenter/cluster.rb +13 -11
- data/lib/html2rss/auto_source/segmenter/list.rb +13 -5
- data/lib/html2rss/auto_source/segmenter/primary_link.rb +29 -4
- data/lib/html2rss/auto_source/segmenter/semantic.rb +17 -6
- data/lib/html2rss/auto_source/segmenter.rb +4 -8
- data/lib/html2rss/auto_source.rb +81 -26
- data/lib/html2rss/batch.rb +2 -0
- data/lib/html2rss/capture/README.md +5 -2
- data/lib/html2rss/capture.rb +44 -32
- data/lib/html2rss/channel.rb +30 -12
- data/lib/html2rss/cli.rb +22 -11
- data/lib/html2rss/config/auto_source_contract.rb +52 -45
- data/lib/html2rss/config/issue_mapper.rb +4 -1
- data/lib/html2rss/config/request_controls.rb +6 -3
- data/lib/html2rss/config/schema.rb +15 -5
- data/lib/html2rss/config/selectors_validator.rb +2 -2
- data/lib/html2rss/config/validation_issue.rb +4 -1
- data/lib/html2rss/config/validator.rb +11 -0
- data/lib/html2rss/config.rb +53 -27
- data/lib/html2rss/contract_error.rb +9 -0
- data/lib/html2rss/defaults.rb +21 -23
- data/lib/html2rss/doctor/botasaurus.rb +7 -3
- data/lib/html2rss/error.rb +10 -0
- data/lib/html2rss/error_kind.rb +39 -0
- data/lib/html2rss/feed_builder/item_presentation.rb +22 -9
- data/lib/html2rss/feed_builder/json_feed/item.rb +2 -1
- data/lib/html2rss/feed_builder/json_feed.rb +2 -2
- data/lib/html2rss/feed_builder/rss/stylesheet.rb +3 -3
- data/lib/html2rss/feed_builder/rss.rb +49 -6
- data/lib/html2rss/feed_builder.rb +2 -1
- data/lib/html2rss/feed_pipeline/README.md +1 -1
- data/lib/html2rss/feed_pipeline/auto_fallback.rb +13 -31
- data/lib/html2rss/feed_pipeline/strategy_plan.rb +3 -3
- data/lib/html2rss/feed_pipeline.rb +40 -27
- data/lib/html2rss/feed_resolution/README.md +1 -1
- data/lib/html2rss/feed_resolution/candidate_generator.rb +3 -4
- data/lib/html2rss/feed_resolution/policy.rb +1 -1
- data/lib/html2rss/feed_resolution/probe.rb +3 -1
- data/lib/html2rss/feed_resolution.rb +11 -12
- data/lib/html2rss/feed_result.rb +9 -5
- data/lib/html2rss/hash_util.rb +3 -3
- data/lib/html2rss/html/article_extractor/category_extractor.rb +1 -1
- data/lib/html2rss/html/article_extractor/enclosure_extractor.rb +12 -31
- data/lib/html2rss/html/article_extractor/heading_extractor.rb +2 -3
- data/lib/html2rss/html/article_extractor/id_generator.rb +6 -9
- data/lib/html2rss/html/article_extractor/image_extractor.rb +9 -1
- data/lib/html2rss/html/article_extractor.rb +19 -47
- data/lib/html2rss/html/article_rules/category.rb +3 -3
- data/lib/html2rss/html/article_rules/description.rb +6 -15
- data/lib/html2rss/html/article_rules/enclosure.rb +70 -42
- data/lib/html2rss/html/article_rules/image.rb +30 -16
- data/lib/html2rss/html/feed_link.rb +7 -9
- data/lib/html2rss/html/navigator/text_extractor.rb +26 -16
- data/lib/html2rss/html/navigator.rb +39 -9
- data/lib/html2rss/html/probe.rb +4 -0
- data/lib/html2rss/html/rendering/description_builder.rb +46 -32
- data/lib/html2rss/html/rendering.rb +2 -9
- data/lib/html2rss/html/sst_article_extractor.rb +55 -145
- data/lib/html2rss/html/title_pieces/kicker.rb +147 -0
- data/lib/html2rss/html/title_pieces/nodes.rb +107 -0
- data/lib/html2rss/html/title_pieces/nokogiri_nodes.rb +70 -0
- data/lib/html2rss/html/title_pieces/sst_nodes.rb +71 -0
- data/lib/html2rss/html/title_pieces.rb +169 -0
- data/lib/html2rss/html/visibility.rb +56 -0
- data/lib/html2rss/html.rb +2 -0
- data/lib/html2rss/link_destination/destination_facts.rb +17 -1
- data/lib/html2rss/link_destination/noise_policy.rb +11 -10
- data/lib/html2rss/link_destination/path_classifier.rb +100 -9
- data/lib/html2rss/link_destination/sponsorship/label_text.rb +82 -0
- data/lib/html2rss/link_destination/sponsorship.rb +184 -0
- data/lib/html2rss/link_destination/text_classifier.rb +104 -8
- data/lib/html2rss/mcp/README.md +13 -1
- data/lib/html2rss/mcp/config_argument.rb +5 -2
- data/lib/html2rss/mcp/contract.rb +13 -4
- data/lib/html2rss/mcp/outcome.rb +14 -12
- data/lib/html2rss/mcp/runtime.rb +39 -2
- data/lib/html2rss/mcp/server.rb +13 -4
- data/lib/html2rss/page_recon/README.md +1 -1
- data/lib/html2rss/page_recon/diagnostics.rb +2 -0
- data/lib/html2rss/recon.rb +10 -12
- data/lib/html2rss/request_service/blocked_surface.rb +2 -1
- data/lib/html2rss/request_service/botasaurus_contract.rb +25 -3
- data/lib/html2rss/request_service/botasaurus_strategy.rb +42 -10
- data/lib/html2rss/request_service/context.rb +15 -14
- data/lib/html2rss/request_service/cookie_jar.rb +89 -0
- data/lib/html2rss/request_service/cookie_store.rb +79 -0
- data/lib/html2rss/request_service/feed_advertisement.rb +72 -0
- data/lib/html2rss/request_service/httpx_strategy.rb +7 -3
- data/lib/html2rss/request_service/local_file_strategy.rb +2 -2
- data/lib/html2rss/request_service/policy.rb +48 -11
- data/lib/html2rss/request_service/replay_breaker.rb +56 -0
- data/lib/html2rss/request_service/response.rb +30 -13
- data/lib/html2rss/request_service/response_cache.rb +109 -0
- data/lib/html2rss/request_service/response_caching.rb +95 -0
- data/lib/html2rss/request_service/strategy.rb +5 -11
- data/lib/html2rss/request_service.rb +71 -12
- data/lib/html2rss/request_session/pager/base.rb +0 -7
- data/lib/html2rss/request_session/pager.rb +1 -1
- data/lib/html2rss/request_session.rb +6 -2
- data/lib/html2rss/scoring/cluster_scorer.rb +1 -1
- data/lib/html2rss/scoring/container_assessor.rb +12 -13
- data/lib/html2rss/scoring/engine.rb +6 -6
- data/lib/html2rss/scoring/observation.rb +2 -2
- data/lib/html2rss/selector_candidates.rb +19 -8
- data/lib/html2rss/selectors/README.md +1 -1
- data/lib/html2rss/selectors/option_spec.rb +4 -1
- data/lib/html2rss/selectors/post_processors/base.rb +0 -8
- data/lib/html2rss/selectors/post_processors/gsub.rb +7 -7
- data/lib/html2rss/selectors/post_processors/html_transformers/keep_playable_media.rb +50 -0
- data/lib/html2rss/selectors/post_processors/html_transformers/transform_urls_to_absolute_ones.rb +1 -3
- data/lib/html2rss/selectors/post_processors/parse_time.rb +3 -3
- data/lib/html2rss/selectors/post_processors/parse_uri.rb +1 -1
- data/lib/html2rss/selectors/post_processors/sanitize_html.rb +18 -5
- data/lib/html2rss/selectors/post_processors/substring.rb +1 -1
- data/lib/html2rss/selectors.rb +53 -12
- data/lib/html2rss/sst/attrs.rb +2 -2
- data/lib/html2rss/sst/document.rb +3 -3
- data/lib/html2rss/sst/index.rb +30 -11
- data/lib/html2rss/sst/node.rb +33 -26
- data/lib/html2rss/sst/normalizer.rb +31 -19
- data/lib/html2rss/sst/tags.rb +113 -2
- data/lib/html2rss/sst/text.rb +69 -29
- data/lib/html2rss/sst.rb +2 -0
- data/lib/html2rss/status.rb +6 -3
- data/lib/html2rss/surface_category.rb +1 -1
- data/lib/html2rss/syndication/README.md +12 -6
- data/lib/html2rss/syndication/alternate_links.rb +1 -1
- data/lib/html2rss/syndication/channel_facts.rb +87 -0
- data/lib/html2rss/syndication/discovery.rb +82 -28
- data/lib/html2rss/syndication/feed.rb +20 -0
- data/lib/html2rss/syndication/item_media.rb +103 -0
- data/lib/html2rss/syndication/parser.rb +75 -51
- data/lib/html2rss/test/enhance_audit.rb +2 -2
- data/lib/html2rss/test/item_sample.rb +43 -0
- data/lib/html2rss/test.rb +42 -14
- data/lib/html2rss/url.rb +115 -14
- data/lib/html2rss/version.rb +1 -1
- data/lib/html2rss/word_count.rb +53 -0
- data/lib/html2rss.rb +17 -8
- data/schema/html2rss-config.schema.json +56 -41
- metadata +31 -4
- data/lib/html2rss/html/rendering/media_renderer.rb +0 -39
- 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
|
|
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
|
|
24
|
-
5. **Heuristic scrapers** — `SemanticHtml` and `Html`
|
|
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** —
|
|
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
|
|
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
|
-
#
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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|
|
|
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 !
|
|
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
|
-
|
|
202
|
-
|
|
203
|
-
identity =
|
|
204
|
-
identity
|
|
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 =
|
|
280
|
+
source_identity = base_url.article_identity
|
|
216
281
|
tally_reject!(articles, tallies, 'self_link') do |article|
|
|
217
|
-
|
|
282
|
+
article.url.article_identity == source_identity
|
|
218
283
|
end
|
|
219
284
|
end
|
|
220
285
|
|
|
221
|
-
|
|
222
|
-
|
|
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
|
|
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)
|
|
@@ -240,26 +318,37 @@ module Html2rss
|
|
|
240
318
|
facts.utility_path && !facts.content_path && !facts.strong_post_suffix
|
|
241
319
|
end
|
|
242
320
|
|
|
243
|
-
#
|
|
244
|
-
#
|
|
245
|
-
|
|
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)
|
|
246
325
|
articles.reject! do |article|
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
tallies[reason.to_s] += 1
|
|
253
|
-
next true
|
|
254
|
-
end
|
|
326
|
+
reason = title_reject_reason(article)
|
|
327
|
+
tallies[reason] += 1 if reason
|
|
328
|
+
reason
|
|
329
|
+
end
|
|
330
|
+
end
|
|
255
331
|
|
|
256
|
-
|
|
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
|
|
257
340
|
|
|
258
|
-
|
|
259
|
-
|
|
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)
|
|
260
345
|
end
|
|
261
346
|
end
|
|
262
347
|
|
|
348
|
+
def read_more_title?(article)
|
|
349
|
+
article.title && junk_reason(article.title) == :cta
|
|
350
|
+
end
|
|
351
|
+
|
|
263
352
|
def tally_reject!(articles, tallies, reason)
|
|
264
353
|
articles.reject! do |article|
|
|
265
354
|
next false unless yield(article)
|
|
@@ -269,10 +358,6 @@ module Html2rss
|
|
|
269
358
|
end
|
|
270
359
|
end
|
|
271
360
|
|
|
272
|
-
def url_identity(url)
|
|
273
|
-
url&.without_fragment&.to_s
|
|
274
|
-
end
|
|
275
|
-
|
|
276
361
|
def feed_item_as_article(item)
|
|
277
362
|
case item
|
|
278
363
|
when Article then item
|
|
@@ -291,15 +376,6 @@ module Html2rss
|
|
|
291
376
|
def normalize_title(title)
|
|
292
377
|
title.to_s.strip.gsub(/\s+/, ' ')
|
|
293
378
|
end
|
|
294
|
-
|
|
295
|
-
def word_count_at_least?(str, min_words)
|
|
296
|
-
count = 0
|
|
297
|
-
str.to_s.scan(/\p{Alnum}+/) do
|
|
298
|
-
count += 1
|
|
299
|
-
return true if count >= min_words
|
|
300
|
-
end
|
|
301
|
-
false
|
|
302
|
-
end
|
|
303
379
|
end
|
|
304
380
|
end
|
|
305
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
|