fix(og): fall back to thumbnail when declared cover file is missing

WeChat link cards were pointing og:image at mindspace-cover paths that
never landed on disk; serve-time injection now uses thumbnail.png instead.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
john
2026-07-04 14:07:49 +08:00
parent 29d9a87d4f
commit 03c46b37c3
4 changed files with 63 additions and 5 deletions
+38 -3
View File
@@ -5,6 +5,7 @@
// <meta name="description"> / hero <img>), reused via extractCoverSignals. Authors who // <meta name="description"> / hero <img>), reused via extractCoverSignals. Authors who
// already wrote their own og:image are left untouched; missing site_name / description / // already wrote their own og:image are left untouched; missing site_name / description /
// brand icon are still backfilled. // brand icon are still backfilled.
import path from 'node:path';
import { extractCoverSignals } from './mindspace-thumbnails.mjs'; import { extractCoverSignals } from './mindspace-thumbnails.mjs';
export const PLATFORM_SITE_NAME = 'TKMind 智趣'; export const PLATFORM_SITE_NAME = 'TKMind 智趣';
@@ -40,6 +41,15 @@ function resolveImageUrl(image, { origin, pageDirUrl }) {
return `${pageDirUrl}${trimmed}`; return `${pageDirUrl}${trimmed}`;
} }
/** Relative cover paths must exist beside the HTML file; remote/absolute refs are trusted. */
export function coverImageRefExists(imageRef, htmlFilePath, fileExists = () => false) {
const ref = String(imageRef ?? '').trim();
if (!ref || ref.startsWith('data:')) return false;
if (/^https?:\/\//i.test(ref) || ref.startsWith('//') || ref.startsWith('/')) return true;
if (!htmlFilePath) return true;
return fileExists(path.join(path.dirname(htmlFilePath), ref));
}
function rawMetaContent(html, attr, name) { function rawMetaContent(html, attr, name) {
const pattern = new RegExp( const pattern = new RegExp(
`<meta[^>]+${attr}=["']${name}["'][^>]+content=["']([^"']*)["']|<meta[^>]+content=["']([^"']*)["'][^>]+${attr}=["']${name}["']`, `<meta[^>]+${attr}=["']${name}["'][^>]+content=["']([^"']*)["']|<meta[^>]+content=["']([^"']*)["'][^>]+${attr}=["']${name}["']`,
@@ -82,11 +92,23 @@ function resolveShareDescription(html, { siteName = PLATFORM_SITE_NAME, meta = {
); );
} }
function resolveShareImageUrl(html, { origin, pageDirUrl, fallbackImageUrl = '', meta = {} } = {}) { function resolveShareImageUrl(
html,
{ origin, pageDirUrl, fallbackImageUrl = '', meta = {}, htmlFilePath = '', fileExists = null } = {},
) {
const existing = rawMetaContent(html, 'property', 'og:image'); const existing = rawMetaContent(html, 'property', 'og:image');
if (existing) return existing; if (existing) return existing;
const signals = extractCoverSignals(html, meta); const signals = extractCoverSignals(html, meta);
return resolveImageUrl(signals.image, { origin, pageDirUrl }) || fallbackImageUrl || ''; const imageRef = signals.image;
if (imageRef) {
const exists =
typeof fileExists === 'function' ? coverImageRefExists(imageRef, htmlFilePath, fileExists) : true;
if (exists) {
const resolved = resolveImageUrl(imageRef, { origin, pageDirUrl });
if (resolved) return resolved;
}
}
return fallbackImageUrl || '';
} }
/** /**
@@ -102,12 +124,21 @@ export function extractSharePreviewMeta(
siteName = PLATFORM_SITE_NAME, siteName = PLATFORM_SITE_NAME,
brandIconUrl = '', brandIconUrl = '',
meta = {}, meta = {},
htmlFilePath = '',
fileExists = null,
} = {}, } = {},
) { ) {
const source = String(html ?? ''); const source = String(html ?? '');
const title = rawMetaContent(source, 'property', 'og:title') || rawTitleFromHtml(source) || 'MindSpace 页面'; const title = rawMetaContent(source, 'property', 'og:title') || rawTitleFromHtml(source) || 'MindSpace 页面';
const description = resolveShareDescription(source, { siteName, meta }); const description = resolveShareDescription(source, { siteName, meta });
const imageUrl = resolveShareImageUrl(source, { origin, pageDirUrl, fallbackImageUrl, meta }); const imageUrl = resolveShareImageUrl(source, {
origin,
pageDirUrl,
fallbackImageUrl,
meta,
htmlFilePath,
fileExists,
});
const resolvedSiteName = rawMetaContent(source, 'property', 'og:site_name') || siteName; const resolvedSiteName = rawMetaContent(source, 'property', 'og:site_name') || siteName;
const iconUrl = brandIconUrl || (origin ? `${origin}${PLATFORM_BRAND_ICON_PATH}` : PLATFORM_BRAND_ICON_PATH); const iconUrl = brandIconUrl || (origin ? `${origin}${PLATFORM_BRAND_ICON_PATH}` : PLATFORM_BRAND_ICON_PATH);
return { return {
@@ -136,6 +167,8 @@ export function injectOgTags(
siteName = PLATFORM_SITE_NAME, siteName = PLATFORM_SITE_NAME,
brandIconUrl = '', brandIconUrl = '',
meta = {}, meta = {},
htmlFilePath = '',
fileExists = null,
} = {}, } = {},
) { ) {
const source = String(html); const source = String(html);
@@ -147,6 +180,8 @@ export function injectOgTags(
siteName, siteName,
brandIconUrl, brandIconUrl,
meta, meta,
htmlFilePath,
fileExists,
}); });
const tags = []; const tags = [];
+19 -2
View File
@@ -39,13 +39,30 @@ test('falls back to the thumbnail png when the page has no cover of its own', ()
assert.match(out, /<meta name="twitter:card" content="summary_large_image">/); assert.match(out, /<meta name="twitter:card" content="summary_large_image">/);
}); });
test('prefers the page cover over the thumbnail fallback', () => { test('prefers the page cover over the thumbnail fallback when the cover file exists', () => {
const html = `<head><title>X</title><meta name="mindspace-cover" content='{"cover":"assets/hero.jpg"}'></head>`; const html = `<head><title>X</title><meta name="mindspace-cover" content='{"cover":"assets/hero.jpg"}'></head>`;
const out = injectOgTags(html, { ...ctx, fallbackImageUrl: 'https://m.tkmind.cn/x.thumbnail.png' }); const out = injectOgTags(html, {
...ctx,
fallbackImageUrl: 'https://m.tkmind.cn/x.thumbnail.png',
htmlFilePath: '/tmp/public/page.html',
fileExists: (target) => String(target).endsWith('assets/hero.jpg'),
});
assert.match(out, /og:image" content="https:\/\/m\.tkmind\.cn\/MindSpace\/john\/public\/assets\/hero\.jpg"/); assert.match(out, /og:image" content="https:\/\/m\.tkmind\.cn\/MindSpace\/john\/public\/assets\/hero\.jpg"/);
assert.doesNotMatch(out, /thumbnail\.png/); assert.doesNotMatch(out, /thumbnail\.png/);
}); });
test('falls back to thumbnail png when the declared cover file is missing on disk', () => {
const html = `<head><title>X</title><meta name="mindspace-cover" content='{"cover":"assets/hero.jpg"}'></head>`;
const out = injectOgTags(html, {
...ctx,
fallbackImageUrl: 'https://m.tkmind.cn/x.thumbnail.png',
htmlFilePath: '/tmp/public/page.html',
fileExists: () => false,
});
assert.match(out, /<meta property="og:image" content="https:\/\/m\.tkmind\.cn\/x\.thumbnail\.png">/);
assert.doesNotMatch(out, /<meta property="og:image" content="[^"]*assets\/hero\.jpg"/);
});
test('keeps an author-provided og:image and still backfills site_name', () => { test('keeps an author-provided og:image and still backfills site_name', () => {
const html = `<head><title>T</title><meta property="og:image" content="https://x/y.png"></head>`; const html = `<head><title>T</title><meta property="og:image" content="https://x/y.png"></head>`;
const out = injectOgTags(html, ctx); const out = injectOgTags(html, ctx);
+5
View File
@@ -1,4 +1,5 @@
import crypto from 'node:crypto'; import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
const INLINE_SCRIPT_PATTERN = /<script\b(?![^>]*\bsrc\b)[^>]*>([\s\S]*?)<\/script>/gi; const INLINE_SCRIPT_PATTERN = /<script\b(?![^>]*\bsrc\b)[^>]*>([\s\S]*?)<\/script>/gi;
@@ -47,6 +48,8 @@ export function decorateMindSpacePublishedHtml({
html, html,
embed = false, embed = false,
context, context,
htmlFilePath = '',
fileExists = fs.existsSync,
userAgent = '', userAgent = '',
preparePublicationHtmlForEmbed, preparePublicationHtmlForEmbed,
injectOgTags, injectOgTags,
@@ -69,6 +72,8 @@ export function decorateMindSpacePublishedHtml({
pageUrl: context.pageUrl, pageUrl: context.pageUrl,
pageDirUrl: context.pageDirUrl, pageDirUrl: context.pageDirUrl,
fallbackImageUrl: context.fallbackImageUrl, fallbackImageUrl: context.fallbackImageUrl,
htmlFilePath,
fileExists,
}); });
const wechatShare = !embed && isWechatUserAgent(userAgent || ''); const wechatShare = !embed && isWechatUserAgent(userAgent || '');
if (wechatShare) { if (wechatShare) {
+1
View File
@@ -5493,6 +5493,7 @@ async function sendPublishFile(req, res, filePath) {
html, html,
embed, embed,
context, context,
htmlFilePath: filePath,
userAgent: req.get('user-agent') || '', userAgent: req.get('user-agent') || '',
preparePublicationHtmlForEmbed, preparePublicationHtmlForEmbed,
injectOgTags, injectOgTags,