Shared links unfurl: per-route OG meta for match and team pages

Crawler-visible <title> + og:title/description are injected into the
SPA shell server-side: match links carry the score or the model's
probability split, team links the current title odds — honest framing
included. Plain shell for every other route.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 01:22:36 +02:00
parent 64f5d28e1a
commit 9aea49ba42
+45 -4
View File
@@ -1,5 +1,5 @@
import path from 'node:path';
import { existsSync } from 'node:fs';
import { existsSync, readFileSync } from 'node:fs';
import Fastify from 'fastify';
import type { FastifyRequest } from 'fastify';
import fastifyWebsocket from '@fastify/websocket';
@@ -184,12 +184,53 @@ export function buildServer() {
}, 24 * 60 * 60 * 1000);
app.addHook('onClose', async () => clearInterval(refitTimer));
// ---- static SPA (prod) with history fallback ----
// ---- static SPA (prod) with history fallback + per-route OG meta ----
if (existsSync(STATIC_DIR)) {
void app.register(fastifyStatic, { root: STATIC_DIR, wildcard: false });
// Shared links unfurl with the model's numbers: swap <title> and inject
// og: meta for /match/:num and /team/:name before serving the SPA shell.
const indexHtml = readFileSync(`${STATIC_DIR}/index.html`, 'utf8');
const esc = (s: string): string => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/"/g, '&quot;');
const withMeta = (title: string, desc: string): string =>
indexHtml.replace(
/<title>[^<]*<\/title>/,
`<title>${esc(title)}</title>\n <meta property="og:title" content="${esc(title)}" />\n <meta property="og:description" content="${esc(desc)}" />\n <meta name="description" content="${esc(desc)}" />`,
);
const ogFor = (url: string): string | null => {
try {
const mMatch = /^\/match\/(\d+)(?:\?|$)/.exec(url);
if (mMatch) {
const f = state.allFixtures().find((x) => x.num === Number(mMatch[1]));
if (!f) return null;
const pred = model.current().matches.find((m) => m.num === f.num);
const score = f.homeScore != null && f.status !== 'scheduled' ? ` ${f.homeScore}${f.awayScore}` : '';
const odds = pred ? ` · Model: ${Math.round(pred.probs.home * 100)}/${Math.round(pred.probs.draw * 100)}/${Math.round(pred.probs.away * 100)}` : '';
return withMeta(
`${f.home.label}${score || ' vs'} ${f.away.label} — Cup26`,
`World Cup 2026 · ${f.venue}${odds} (model probabilities, not betting advice)`,
);
}
const mTeam = /^\/team\/([^/?]+)(?:\?|$)/.exec(url);
if (mTeam) {
const team = decodeURIComponent(mTeam[1]!);
const odds = model.current().odds.find((o) => o.team === team);
if (!odds) return null;
return withMeta(
`${team} — Cup26`,
`World Cup 2026 · title odds ${(odds.champion * 100).toFixed(1)}% by the Cup26 model`,
);
}
} catch { /* fall through to the plain shell */ }
return null;
};
app.setNotFoundHandler((req, reply) => {
if (req.raw.url?.startsWith('/ws')) return reply.code(426).send('Upgrade Required');
if (req.raw.url?.startsWith('/api')) return reply.code(404).send({ error: 'not found' });
const url = req.raw.url ?? '/';
if (url.startsWith('/ws')) return reply.code(426).send('Upgrade Required');
if (url.startsWith('/api')) return reply.code(404).send({ error: 'not found' });
const html = ogFor(url);
if (html) return reply.type('text/html; charset=utf-8').send(html);
return reply.sendFile('index.html');
});
}