diff --git a/mindspace-og-tags.mjs b/mindspace-og-tags.mjs index 3faba0f..677c46e 100644 --- a/mindspace-og-tags.mjs +++ b/mindspace-og-tags.mjs @@ -5,6 +5,7 @@ // / hero ), reused via extractCoverSignals. Authors who // already wrote their own og:image are left untouched; missing site_name / description / // brand icon are still backfilled. +import path from 'node:path'; import { extractCoverSignals } from './mindspace-thumbnails.mjs'; export const PLATFORM_SITE_NAME = 'TKMind 智趣'; @@ -40,6 +41,15 @@ function resolveImageUrl(image, { origin, pageDirUrl }) { 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) { const pattern = new RegExp( `]+${attr}=["']${name}["'][^>]+content=["']([^"']*)["']|]+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'); if (existing) return existing; 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, brandIconUrl = '', meta = {}, + htmlFilePath = '', + fileExists = null, } = {}, ) { const source = String(html ?? ''); const title = rawMetaContent(source, 'property', 'og:title') || rawTitleFromHtml(source) || 'MindSpace 页面'; 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 iconUrl = brandIconUrl || (origin ? `${origin}${PLATFORM_BRAND_ICON_PATH}` : PLATFORM_BRAND_ICON_PATH); return { @@ -136,6 +167,8 @@ export function injectOgTags( siteName = PLATFORM_SITE_NAME, brandIconUrl = '', meta = {}, + htmlFilePath = '', + fileExists = null, } = {}, ) { const source = String(html); @@ -147,6 +180,8 @@ export function injectOgTags( siteName, brandIconUrl, meta, + htmlFilePath, + fileExists, }); const tags = []; diff --git a/mindspace-og-tags.test.mjs b/mindspace-og-tags.test.mjs index 12758d1..d26c3aa 100644 --- a/mindspace-og-tags.test.mjs +++ b/mindspace-og-tags.test.mjs @@ -39,13 +39,30 @@ test('falls back to the thumbnail png when the page has no cover of its own', () assert.match(out, //); }); -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 = `X`; - 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.doesNotMatch(out, /thumbnail\.png/); }); +test('falls back to thumbnail png when the declared cover file is missing on disk', () => { + const html = `X`; + const out = injectOgTags(html, { + ...ctx, + fallbackImageUrl: 'https://m.tkmind.cn/x.thumbnail.png', + htmlFilePath: '/tmp/public/page.html', + fileExists: () => false, + }); + assert.match(out, //); + assert.doesNotMatch(out, / { const html = `T`; const out = injectOgTags(html, ctx); diff --git a/mindspace-public-delivery.mjs b/mindspace-public-delivery.mjs index b4395e8..2c366e5 100644 --- a/mindspace-public-delivery.mjs +++ b/mindspace-public-delivery.mjs @@ -1,4 +1,5 @@ import crypto from 'node:crypto'; +import fs from 'node:fs'; import path from 'node:path'; const INLINE_SCRIPT_PATTERN = /]*\bsrc\b)[^>]*>([\s\S]*?)<\/script>/gi; @@ -47,6 +48,8 @@ export function decorateMindSpacePublishedHtml({ html, embed = false, context, + htmlFilePath = '', + fileExists = fs.existsSync, userAgent = '', preparePublicationHtmlForEmbed, injectOgTags, @@ -69,6 +72,8 @@ export function decorateMindSpacePublishedHtml({ pageUrl: context.pageUrl, pageDirUrl: context.pageDirUrl, fallbackImageUrl: context.fallbackImageUrl, + htmlFilePath, + fileExists, }); const wechatShare = !embed && isWechatUserAgent(userAgent || ''); if (wechatShare) { diff --git a/server.mjs b/server.mjs index ae5a54e..4544e13 100644 --- a/server.mjs +++ b/server.mjs @@ -5493,6 +5493,7 @@ async function sendPublishFile(req, res, filePath) { html, embed, context, + htmlFilePath: filePath, userAgent: req.get('user-agent') || '', preparePublicationHtmlForEmbed, injectOgTags,