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:
+38
-3
@@ -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 = [];
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user