Files
readest/apps/readest-app/src/services/send/conversion/siteRules.ts
T
Huang Xin f4de55e8f3 feat(send): twitter/x site rule + meta-tag fallback for stale rules (#4270)
Two layered additions to the clip-to-Readest pipeline:

* **Twitter / X article rule** — `[data-testid="twitterArticleReadView"]`
  for the body, `User-Name`/`UserAvatar-Container-*` testids for byline
  and avatar. Readability can't latch onto X's class-mangled CSS-in-JS,
  so the testid hooks are the only reliable anchors.

* **Universal meta-tag fallback** — new `META_FALLBACK` constant in
  `siteRules.ts` holding OpenGraph / Twitter Card selectors for title,
  byline, and author image. Site rules are now hints, not contracts:
  when their selectors miss (after a frontend redesign) the pipeline
  drops down to meta tags, then to Readability for content, before
  giving up. The fallback also fires when no site rule matched at all,
  so previously-unruled sites pick up byline + cover image they used
  to lack.

Wiring in `convertToEpub.ts`:
  - `extractWithSiteRule` consults `META_FALLBACK.{title,byline}` when
    rule selectors return empty.
  - The Readability branch does the same so non-ruled sites get a real
    byline/title instead of `''`.
  - `buildArticleCover` tries `og:image` / `twitter:image` between the
    rule's `authorImage` and the favicon fallback.
  - `extractHtmlTitle` prepends `og:title` before `<title>` / `<h1>`.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 18:44:19 +02:00

133 lines
5.4 KiB
TypeScript

/**
* Per-site extraction rules. Run *before* Readability — when a rule matches,
* we trust the selectors over Readability's scoring, because sites that fail
* Readability fail it consistently (custom containers, JS-revealed content,
* marketing chrome that scores higher than the article).
*
* Stays a fast path, not a wall: if a rule's content selector doesn't match
* or yields too little text, the pipeline falls through to Readability and
* then to the generic selector fallback. A stale rule never silently loses
* content — worst case it adds one no-op query before the real extraction.
*
* Designed as a pure-data module with no DOM dependencies so the future
* Phase 4 browser extension's content script can import it verbatim and run
* the same rules in the user's authenticated tab.
*/
export interface SiteRule {
name: string;
/** Match by parsed URL. Hostname is the most reliable; URL pathnames
* change too often. */
match: (url: URL) => boolean;
/** CSS selector for the article body. The element's innerHTML becomes
* the chapter content. */
content: string;
/** Override Readability's title. */
title?: string;
/** Override Readability's byline. */
byline?: string;
/** CSS selector for an `<img>` (or element with a usable `src`) that
* represents the author / public-account avatar. Used by the cover
* generator as a circular avatar — visually richer than a site-wide
* favicon. Best-effort: when the selector misses or the fetch fails,
* the cover falls back to the favicon. */
authorImage?: string;
/** Selectors to remove from the content before bundling (e.g. "open in
* app" CTAs, QR codes, share buttons). */
strip?: string[];
}
const RULES: SiteRule[] = [
{
// Articles wrap the body in `<div id="js_content">` with
// `visibility:hidden; opacity:0`, then JS reveals it. Readability mis-
// scores the page because the recommendation rail outweighs the
// hidden article in its heuristics.
name: 'mp.weixin.qq.com',
match: (u) => u.hostname === 'mp.weixin.qq.com',
content: '#js_content',
title: '#activity-name, h1.rich_media_title',
byline: '#js_name, .rich_media_meta_nickname',
authorImage:
'.profile_avatar img, .rich_media_meta_nickname_avatar img, .weui_media_avatar img, img.identity_icon, .wx_follow_avatar img',
strip: [
// Inline QR codes (PC + mobile variants)
'.qr_code_pc',
'#js_pc_qr_code',
// Reward / tip popups
'.reward_area',
'.reward_qrcode_area',
'.reward_user_info',
// Promotional inserts
'.promotion_card',
// Cross-app CTAs
'.weui-dialog_appmsg',
'.open_in_weixin_wrap',
],
},
{
name: 'x.com',
match: (u) => u.hostname === 'x.com' || u.hostname.endsWith('.x.com'),
content: '[data-testid="twitterArticleReadView"]',
title: '[data-testid="twitter-article-title"]',
// The User-Name container holds both the display name and the @handle.
// The handle's anchor is keyboard-hidden via `tabindex="-1"`; the
// display-name anchor has no tabindex. Picking the non-tabindexed
// anchor lets us grab just "mousepotato" without the "@iluciddreaming"
// suffix. The verified-account SVG sits next to the name but
// contributes no text content, so textContent stays clean.
byline: '[data-testid="User-Name"] a:not([tabindex])',
// `UserAvatar-Container-<handle>` — prefix match keeps the rule
// handle-agnostic. The inner <img> uses `_x96` size variant; the
// cover generator may upscale via URL rewriting.
authorImage: '[data-testid^="UserAvatar-Container-"] img',
strip: [
// Reply / Repost / Like / Bookmark / Share / Views row at top.
// aria-label is locale-specific, so match by the structural fact
// that this group contains a "reply" button.
'[role="group"]:has([data-testid="reply"])',
],
},
];
/**
* Universal meta-tag fallbacks for fields that a per-site rule can't
* extract — either because no rule matched the host, or because the
* site shipped a frontend redesign and our CSS hooks no longer find
* anything. Every reputable publisher emits these tags for crawlers
* and link unfurlers (Twitter Card, Slack, iMessage previews), so
* they're the most stable fallback short of giving up.
*
* Values live in the `content` attribute, not textContent — read with
* `el.getAttribute('content')`. The lists are ordered by reliability:
* try the first that matches.
*
* No `content` (body) fallback here — Readability already owns that
* path when a site rule's content selector misses, and Readability
* does a better job than any meta tag would.
*/
export const META_FALLBACK = {
title: ['meta[property="og:title"]', 'meta[name="twitter:title"]', 'meta[name="title"]'],
byline: [
'meta[name="author"]',
'meta[property="article:author"]',
'meta[name="twitter:creator"]',
'meta[property="og:site_name"]', // last-ditch: at least name the publication
],
authorImage: [
'meta[property="og:image"]',
'meta[property="og:image:url"]',
'meta[name="twitter:image"]',
'meta[name="twitter:image:src"]',
],
} as const;
/** Find the rule matching `url`, if any. Returns null on parse error. */
export function findSiteRule(url: string): SiteRule | null {
try {
const parsed = new URL(url);
return RULES.find((r) => r.match(parsed)) ?? null;
} catch {
return null;
}
}