// Inject Open Graph / Twitter Card tags into published MindSpace HTML at serve time, // so forwarded links unfurl with a cover image in WeChat / browsers / IM clients. // // Source of truth for the cover is the page's own (mindspace-cover JSON / // / 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 智趣'; export const PLATFORM_BRAND_ICON_PATH = '/brand/tkmind-icon.png'; function rawTitleFromHtml(html) { // Keep the original verbatim (emoji included) for og:title. return String(html).match(/<title[^>]*>([^<]*)<\/title>/i)?.[1]?.trim() ?? ''; } function escapeAttr(value) { return String(value) .replaceAll('&', '&') .replaceAll('"', '"') .replaceAll('<', '<') .replaceAll('>', '>'); } /** * Resolve a cover reference (relative path, root-absolute, or full URL) to an * absolute https URL the unfurling client can fetch. * @returns {string|null} absolute URL, or null when no usable raster cover exists. */ function resolveImageUrl(image, { origin, pageDirUrl }) { if (!image) return null; const trimmed = String(image).trim(); if (!trimmed || trimmed.startsWith('data:')) return null; // og:image must be a raster the client can render; SVG is not honored by WeChat/most unfurlers. if (/\.svg(?:[?#]|$)/i.test(trimmed)) return null; if (/^https?:\/\//i.test(trimmed)) return trimmed; if (trimmed.startsWith('//')) return `https:${trimmed}`; if (trimmed.startsWith('/')) return `${origin}${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) { const pattern = new RegExp( `<meta[^>]+${attr}=["']${name}["'][^>]+content=["']([^"']*)["']|<meta[^>]+content=["']([^"']*)["'][^>]+${attr}=["']${name}["']`, 'i', ); const match = String(html).match(pattern); return match?.[1] || match?.[2] || ''; } function hasMeta(html, attr, name) { return new RegExp(`<meta[^>]+${attr}=["']${name}["']`, 'i').test(String(html)); } function hasLinkRel(html, rel) { return new RegExp(`<link[^>]+rel=["'][^"']*\\b${rel}\\b[^"']*["']`, 'i').test(String(html)); } function escapeScriptString(value) { return JSON.stringify(String(value ?? '')).replace(/</g, '\\u003c'); } function appendBeforeHeadClose(html, block) { const source = String(html); if (!block) return source; if (/<\/head>/i.test(source)) { return source.replace(/<\/head>/i, `${block}\n</head>`); } if (/<head[^>]*>/i.test(source)) { return source.replace(/(<head[^>]*>)/i, `$1${block}`); } return `${block}\n${source}`; } function resolveShareDescription(html, { siteName = PLATFORM_SITE_NAME, meta = {} } = {}) { return ( rawMetaContent(html, 'property', 'og:description') || rawMetaContent(html, 'name', 'description') || extractCoverSignals(html, meta).subtitle || `${siteName} · 精选作品` ); } 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); 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 || ''; } /** * Extract the share-card fields WeChat / IM clients consume from HTML. */ export function extractSharePreviewMeta( html, { origin = '', pageUrl = '', pageDirUrl = '', fallbackImageUrl = '', 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, 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 { title, description, imageUrl, siteName: resolvedSiteName, iconUrl, pageUrl, }; } /** * Inject og:/twitter: meta into an HTML document. * @param {string} html raw page HTML * @param {{ origin: string, pageUrl: string, pageDirUrl: string, fallbackImageUrl?: string, siteName?: string, brandIconUrl?: string, meta?: object }} ctx * @returns {string} HTML with tags injected (or unchanged when not applicable) */ export function injectOgTags( html, { origin, pageUrl, pageDirUrl, fallbackImageUrl = '', siteName = PLATFORM_SITE_NAME, brandIconUrl = '', meta = {}, htmlFilePath = '', fileExists = null, } = {}, ) { const source = String(html); const preview = extractSharePreviewMeta(source, { origin, pageUrl, pageDirUrl, fallbackImageUrl, siteName, brandIconUrl, meta, htmlFilePath, fileExists, }); const tags = []; if (!hasMeta(source, 'property', 'og:type')) { tags.push('<meta property="og:type" content="article">'); } if (pageUrl && !hasMeta(source, 'property', 'og:url')) { tags.push(`<meta property="og:url" content="${escapeAttr(pageUrl)}">`); } if (preview.title && !hasMeta(source, 'property', 'og:title')) { tags.push(`<meta property="og:title" content="${escapeAttr(preview.title)}">`); } if (preview.description && !hasMeta(source, 'property', 'og:description')) { tags.push(`<meta property="og:description" content="${escapeAttr(preview.description)}">`); } if (preview.imageUrl && !hasMeta(source, 'property', 'og:image')) { tags.push(`<meta property="og:image" content="${escapeAttr(preview.imageUrl)}">`); } if (preview.siteName && !hasMeta(source, 'property', 'og:site_name')) { tags.push(`<meta property="og:site_name" content="${escapeAttr(preview.siteName)}">`); } const twitterCard = preview.imageUrl ? 'summary_large_image' : 'summary'; if (!hasMeta(source, 'name', 'twitter:card')) { tags.push(`<meta name="twitter:card" content="${twitterCard}">`); } if (preview.title && !hasMeta(source, 'name', 'twitter:title')) { tags.push(`<meta name="twitter:title" content="${escapeAttr(preview.title)}">`); } if (preview.description && !hasMeta(source, 'name', 'twitter:description')) { tags.push(`<meta name="twitter:description" content="${escapeAttr(preview.description)}">`); } if (preview.imageUrl && !hasMeta(source, 'name', 'twitter:image')) { tags.push(`<meta name="twitter:image" content="${escapeAttr(preview.imageUrl)}">`); } const links = []; if (preview.iconUrl && !hasLinkRel(source, 'icon')) { links.push(`<link rel="icon" type="image/png" href="${escapeAttr(preview.iconUrl)}">`); } if (preview.iconUrl && !hasLinkRel(source, 'apple-touch-icon')) { links.push(`<link rel="apple-touch-icon" href="${escapeAttr(preview.iconUrl)}">`); } const block = [...tags, ...links].map((t) => ` ${t}`).join('\n'); if (!block) return source; return appendBeforeHeadClose(source, `\n${block}\n`); } export function injectWechatShareBridge( html, { pageUrl, signatureEndpoint = '/auth/wechat/public-js-sdk-signature', siteName = PLATFORM_SITE_NAME, } = {}, ) { const source = String(html ?? ''); if (!pageUrl) return source; if (source.includes('data-tkmind-wechat-share="1"')) return source; const title = rawMetaContent(source, 'property', 'og:title') || rawTitleFromHtml(source); const resolvedSiteName = rawMetaContent(source, 'property', 'og:site_name') || siteName; const description = resolveShareDescription(source, { siteName: resolvedSiteName }); const imageUrl = rawMetaContent(source, 'property', 'og:image') || ''; const script = ` <script data-tkmind-wechat-share="1"> (function () { var ua = navigator.userAgent || ''; if (!/MicroMessenger|WindowsWechat/i.test(ua)) return; var pageUrl = ${escapeScriptString(String(pageUrl).split('#')[0])}; var endpoint = ${escapeScriptString(signatureEndpoint)}; var shareData = { title: ${escapeScriptString(title)}, desc: ${escapeScriptString(description)}, link: pageUrl, imgUrl: ${escapeScriptString(imageUrl)} }; function applyShare(wx) { if (!wx) return; var onReady = function () { if (typeof wx.updateAppMessageShareData === 'function') wx.updateAppMessageShareData(shareData); if (typeof wx.updateTimelineShareData === 'function') wx.updateTimelineShareData(shareData); if (typeof wx.onMenuShareAppMessage === 'function') wx.onMenuShareAppMessage(shareData); if (typeof wx.onMenuShareTimeline === 'function') wx.onMenuShareTimeline(shareData); }; fetch(endpoint + '?url=' + encodeURIComponent(pageUrl)) .then(function (res) { return res.ok ? res.json() : Promise.reject(new Error('signature')); }) .then(function (payload) { wx.config({ debug: false, appId: payload.appId, timestamp: payload.timestamp, nonceStr: payload.nonceStr, signature: payload.signature, jsApiList: payload.jsApiList || [ 'updateAppMessageShareData', 'updateTimelineShareData', 'onMenuShareAppMessage', 'onMenuShareTimeline' ] }); wx.ready(onReady); }) .catch(function () {}); } function boot() { if (window.wx) { applyShare(window.wx); return; } var script = document.createElement('script'); script.src = 'https://res.wx.qq.com/open/js/jweixin-1.6.0.js'; script.async = true; script.onload = function () { applyShare(window.wx); }; document.head.appendChild(script); } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', boot, { once: true }); } else { boot(); } })(); </script>`; return appendBeforeHeadClose(source, script); } /** * Render a lightweight WeChat-style share card for local preview. */ export function renderWechatSharePreviewHtml(preview, { note = '' } = {}) { const safe = (value) => String(value ?? '') .replaceAll('&', '&') .replaceAll('<', '<') .replaceAll('>', '>') .replaceAll('"', '"'); const title = safe(preview.title || '页面标题'); const description = safe(preview.description || ''); const siteName = safe(preview.siteName || PLATFORM_SITE_NAME); const imageUrl = safe(preview.imageUrl || ''); const iconUrl = safe(preview.iconUrl || PLATFORM_BRAND_ICON_PATH); const pageUrl = safe(preview.pageUrl || ''); const noteHtml = note ? `<p class="note">${safe(note)}</p>` : ''; return `<!doctype html> <html lang="zh-CN"> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <title>微信分享预览 · ${title}

微信链接卡片预览

本地模拟的是「粘贴链接后看到的卡片」,不是 JS-SDK 内部分享弹层。${pageUrl ? `源链接:${pageUrl}` : ''}

${noteHtml}

${title}

${description ? `

${description}

` : ''}
${siteName}
${ imageUrl ? `` : `
无图
` }
og:title ${title}
og:description ${description || '(空)'}
og:site_name ${siteName}
og:image ${imageUrl || '(空)'}
icon ${iconUrl}
`; }