Changeset 0101d0a in Klonkt


Ignore:
Timestamp:
07/28/2026 08:22:37 AM (6 weeks ago)
Author:
Robin Genis <roboburr@…>
Branches:
main
Children:
c52dc82
Parents:
b258a79
Message:

Embeds toonden niets: OpenGraph erbij en een backfill

Twee oorzaken, allebei mijn eigen gat.

OPENGRAPH ONTBRAK. Ik had oEmbed gebouwd als de generieke weg, maar het grootste
deel van het web levert dat helemaal niet. Link-previews met een thumbnail
draaien in de praktijk op OpenGraph, en dat is ook wat Mastodon leest. Gecheckt
op een URL uit onze eigen feed: og:image en og:title wel, oEmbed niet. Zonder die
stap loste vrijwel elke link op naar kind=link en werd er dus niets opgeslagen,
en bleef de kaart leeg. OpenGraph zit nu in dezelfde keten, na oEmbed en voor de
kale link, en gebruikt dezelfde pagina-fetch: het kost geen extra request. De
fetch stuurt nu ook een User-Agent mee, want een deel van het web geeft z-n
og-tags alleen aan iets dat op een browser lijkt.

GEEN BACKFILL. embed_json werd alleen gevuld bij nieuwe binnenkomende posts, dus
alle 359 bestaande rijen bleven leeg: precies wat je ziet als je de app opent.
Self-heal v16 haalt het alsnog op, alleen voor rijen zonder quote en zonder
embed, dus een pagina-fetch per kandidaat en nooit opnieuw.

Changed files:
src/services/EmbedResolver.js

  • findOpenGraph (og:image/title/site_name, met twitter:-fallback)
  • resolveEmbed: OpenGraph na oEmbed, een pagina-fetch voor beide
  • liveIO: User-Agent op de pagina-fetch

src/services/ActivityPubService.js

  • SELFHEAL_VERSION 15 -> 16: embed_json backfillen

test/embed-resolver.test.js

  • 4 tests: og lezen + entities, twitter-fallback en non-http image geweigerd, oEmbed wint nog steeds van OpenGraph, en een og-only pagina levert een kaart

remarks: 213 tests groen. End-to-end gedraaid op een echte feed-URL: titel,
provider en thumbnail komen eruit.

-robo
Co-Authored-By: Claude Opus 4.8 <noreply@…>

Files:
3 edited

Legend:

Unmodified
Added
Removed
  • src/services/ActivityPubService.js

    rb258a79 r0101d0a  
    28492849// during a flux window, e.g. a fleet-wide update), and drops notes that are gone
    28502850// (404/410). Bump SELFHEAL_VERSION only on a release that warrants a re-sync.
    2851 const SELFHEAL_VERSION = 15; // v15: re-capture emoji_json/link_json for boosts that arrived before the Announce-path tag fix
     2851const SELFHEAL_VERSION = 16; // v16: resolve external link previews (embed_json) for posts that predate the embed pipeline
    28522852async function fetchNoteAP(url) {
    28532853  try {
     
    31593159    if (cur >= SELFHEAL_VERSION) return; // already healed for this version — skip on normal boots
    31603160    let rows = [];
    3161     try { rows = db.prepare('SELECT id, slug, content, media_json, nsfw, cw, url, emoji_json, link_json, quote_json, author_uri, author_name, author_emoji_json, reblog_name, reblog_handle, reblog_emoji_json FROM ap_timeline ORDER BY rowid DESC LIMIT 200').all(); } catch { /* no table */ }
     3161    try { rows = db.prepare('SELECT id, slug, content, media_json, nsfw, cw, url, emoji_json, link_json, quote_json, author_uri, author_name, author_emoji_json, reblog_name, reblog_handle, reblog_emoji_json, embed_json FROM ap_timeline ORDER BY rowid DESC LIMIT 200').all(); } catch { /* no table */ }
    31623162    let healed = 0, failed = 0;
    31633163    for (const r of rows) {
     
    31853185          const ai = actorInfo(await fetchActor(r.author_uri), r.author_uri);
    31863186          if (ai.emojis) { try { db.prepare('UPDATE ap_timeline SET author_emoji_json = ? WHERE id = ?').run(JSON.stringify(ai.emojis), r.id); } catch { /* ignore */ } }
     3187        }
     3188        // v16: link previews. A post from before the embed pipeline has no
     3189        // card at all, which is why nothing showed. Only for rows that have no
     3190        // quote (a quote already IS the card) and no embed yet, so this costs
     3191        // one page fetch per candidate and never repeats.
     3192        if (!r.quote_json && !r.embed_json) {
     3193          const ej = await resolveExternalEmbed(html || r.content).catch(() => null);
     3194          if (ej) { try { db.prepare('UPDATE ap_timeline SET embed_json = ? WHERE id = ?').run(ej, r.id); } catch { /* ignore */ } }
    31873195        }
    31883196        // v14: same for the booster's display name ("X boosted"). The row stores
  • src/services/EmbedResolver.js

    rb258a79 r0101d0a  
    3535function decodeEntities(s) {
    3636  return String(s).replace(/&amp;/g, '&').replace(/&quot;/g, '"').replace(/&#39;/g, "'");
     37}
     38
     39/**
     40 * OpenGraph, the one that actually carries link previews on the open web.
     41 * oEmbed is the richer protocol but most sites simply do not implement it;
     42 * og:image / og:title is what Mastodon and everyone else reads, so it is the
     43 * fallback that makes thumbnails appear at all. Same page fetch as the oEmbed
     44 * discovery, so it costs nothing extra.
     45 */
     46export function findOpenGraph(html) {
     47  if (!html || typeof html !== 'string') return null;
     48  const meta = {};
     49  for (const tag of html.match(/<meta\b[^>]*>/gi) || []) {
     50    const key = (tag.match(/\b(?:property|name)\s*=\s*["']([^"']+)["']/i) || [])[1];
     51    if (!key) continue;
     52    const k = key.toLowerCase();
     53    if (!/^(og:image|og:title|og:site_name|og:description|twitter:image|twitter:title)$/.test(k)) continue;
     54    const val = (tag.match(/\bcontent\s*=\s*["']([^"']*)["']/i) || [])[1];
     55    if (val && !meta[k]) meta[k] = decodeEntities(val);
     56  }
     57  const image = meta['og:image'] || meta['twitter:image'];
     58  const title = meta['og:title'] || meta['twitter:title'];
     59  if (!image && !title) return null;
     60  return { image: image && /^https?:\/\//i.test(image) ? image : null, title: title || null, site: meta['og:site_name'] || null };
    3761}
    3862
     
    118142  }
    119143
    120   // 3. oEmbed: the generic path for everything else.
    121   if (io.getPage && io.getJSON) {
     144  // 3. oEmbed, then OpenGraph. One page fetch serves both: oEmbed is the richer
     145  //    protocol, OpenGraph is the one most of the web actually ships.
     146  if (io.getPage) {
    122147    const page = await io.getPage(url).catch(() => null);
    123     const endpoint = findOEmbedEndpoint(page);
    124     if (endpoint) {
    125       const o = await io.getJSON(endpoint).catch(() => null);
    126       const card = fromOEmbed(url, o);
    127       if (card) return card;
     148    if (page) {
     149      const endpoint = findOEmbedEndpoint(page);
     150      if (endpoint && io.getJSON) {
     151        const o = await io.getJSON(endpoint).catch(() => null);
     152        const card = fromOEmbed(url, o);
     153        if (card) return card;
     154      }
     155      const og = findOpenGraph(page);
     156      if (og) {
     157        return {
     158          kind: 'opengraph',
     159          url,
     160          title: og.title,
     161          author: og.site ? { name: og.site, handle: null, icon: null } : null,
     162          provider: og.site,
     163          html: null,
     164          media: og.image ? [{ url: og.image, type: 'image/*' }] : [],
     165        };
     166      }
    128167    }
    129168  }
     
    140179
    141180const MAX_BODY = 512_000;   // an oEmbed page/endpoint is small; refuse the rest
    142 
    143 async function safeText(safeFetch, url, accept) {
     181const UA = 'Mozilla/5.0 (compatible; Klonkt/1.0; +https://klonkt.com)';
     182
     183async function safeText(safeFetch, url, accept, extra = {}) {
    144184  try {
    145     const r = await safeFetch(url, { headers: { Accept: accept } });
     185    const r = await safeFetch(url, { headers: { Accept: accept, ...extra } });
    146186    if (!r.ok) return null;
    147187    if (Number(r.headers.get('content-length') || 0) > MAX_BODY) return null;
     
    163203      try { return JSON.parse(body); } catch { return null; }   // an HTML page is simply not AP
    164204    },
    165     getPage: (u) => safeText(safeFetch, u, 'text/html'),
     205    // Plenty of sites only hand out their OpenGraph tags to something that
     206    // looks like a browser, so the page fetch identifies itself.
     207    getPage: (u) => safeText(safeFetch, u, 'text/html', { 'User-Agent': UA }),
    166208    getJSON: async (u) => {
    167209      const body = await safeText(safeFetch, u, 'application/json');
     
    179221}
    180222
    181 export default { resolveEmbed, findOEmbedEndpoint, looksLikeAPObject, fromOEmbed, fromAPObject, liveIO };
     223export default { resolveEmbed, findOEmbedEndpoint, findOpenGraph, looksLikeAPObject, fromOEmbed, fromAPObject, liveIO };
  • test/embed-resolver.test.js

    rb258a79 r0101d0a  
    120120  assert.ok(calls.some((c) => c[1].includes('activity+json')), 'AP asks for activity+json');
    121121});
     122
     123// OpenGraph: the one that actually carries link previews on the open web.
     124// oEmbed is richer, but most sites simply do not ship it, which is why cards
     125// stayed empty until this fallback existed.
     126const OG_PAGE = '<html><head><meta property="og:title" content="Linux f&amp;uuml;r Einsteiger">'
     127  + '<meta property="og:site_name" content="Linux Guides">'
     128  + '<meta property="og:image" content="https://lg.example/tux.png"></head></html>';
     129
     130test('findOpenGraph reads og:image/title/site and decodes entities', async () => {
     131  const { findOpenGraph } = await import('../src/services/EmbedResolver.js');
     132  const og = findOpenGraph(OG_PAGE);
     133  assert.equal(og.image, 'https://lg.example/tux.png');
     134  assert.equal(og.site, 'Linux Guides');
     135  assert.ok(og.title.startsWith('Linux f'));
     136  assert.equal(findOpenGraph('<html><head><title>x</title></head></html>'), null);
     137  assert.equal(findOpenGraph(null), null);
     138});
     139
     140test('findOpenGraph falls back to twitter:image and refuses a non-http image', async () => {
     141  const { findOpenGraph } = await import('../src/services/EmbedResolver.js');
     142  const tw = findOpenGraph('<meta name="twitter:image" content="https://x/y.png"><meta name="twitter:title" content="T">');
     143  assert.equal(tw.image, 'https://x/y.png');
     144  const bad = findOpenGraph('<meta property="og:image" content="javascript:alert(1)"><meta property="og:title" content="T">');
     145  assert.equal(bad.image, null, 'a non-http image is dropped, the title survives');
     146  assert.equal(bad.title, 'T');
     147});
     148
     149test('oEmbed still wins over OpenGraph when a page offers both', async () => {
     150  const both = OEMBED_PAGE.replace('</head>', '<meta property="og:title" content="OG"></head>');
     151  const r = await resolveEmbed('https://v.example/1', io({
     152    getPage: async () => both, getJSON: async () => OEMBED_JSON,
     153  }));
     154  assert.equal(r.kind, 'oembed');
     155  assert.equal(r.title, 'A talk');
     156});
     157
     158test('a page with only OpenGraph yields a thumbnail card', async () => {
     159  const r = await resolveEmbed('https://lg.example/artikel', io({ getPage: async () => OG_PAGE }));
     160  assert.equal(r.kind, 'opengraph');
     161  assert.equal(r.media[0].url, 'https://lg.example/tux.png');
     162  assert.equal(r.provider, 'Linux Guides');
     163  assert.equal(r.url, 'https://lg.example/artikel');
     164});
Note: See TracChangeset for help on using the changeset viewer.