03c46b37c3
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>
387 lines
15 KiB
JavaScript
387 lines
15 KiB
JavaScript
// 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 <head> (mindspace-cover JSON /
|
|
// <meta name="description"> / hero <img>), 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 <title> 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}</title>
|
|
<style>
|
|
* { box-sizing: border-box; }
|
|
body { margin: 0; min-height: 100vh; padding: 24px; background: #ececec; font-family: ui-sans-serif, -apple-system, BlinkMacSystemFont, sans-serif; color: #111; }
|
|
.wrap { max-width: 420px; margin: 0 auto; }
|
|
h1 { margin: 0 0 8px; font-size: 18px; }
|
|
.hint, .note { margin: 0 0 16px; color: #666; font-size: 13px; line-height: 1.6; }
|
|
.card { display: grid; grid-template-columns: 1fr 72px; gap: 12px; padding: 12px; border-radius: 8px; background: #fff; box-shadow: 0 1px 0 rgba(0,0,0,.06); }
|
|
.card-main { min-width: 0; }
|
|
.card-title { margin: 0; font-size: 16px; font-weight: 600; line-height: 1.35; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
|
|
.card-desc { margin: 6px 0 0; color: #888; font-size: 13px; line-height: 1.45; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
|
|
.card-foot { display: flex; align-items: center; gap: 6px; margin-top: 10px; color: #999; font-size: 12px; }
|
|
.card-foot img { width: 16px; height: 16px; border-radius: 999px; object-fit: cover; }
|
|
.card-thumb { width: 72px; height: 72px; border-radius: 4px; object-fit: cover; background: #f3f3f3; }
|
|
.card-thumb-empty { display: grid; place-items: center; color: #bbb; font-size: 12px; }
|
|
.meta { margin-top: 18px; padding: 14px; border-radius: 10px; background: rgba(255,255,255,.72); font-size: 12px; line-height: 1.7; word-break: break-all; }
|
|
code { background: rgba(0,0,0,.06); padding: 1px 4px; border-radius: 4px; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="wrap">
|
|
<h1>微信链接卡片预览</h1>
|
|
<p class="hint">本地模拟的是「粘贴链接后看到的卡片」,不是 JS-SDK 内部分享弹层。${pageUrl ? `源链接:<code>${pageUrl}</code>` : ''}</p>
|
|
${noteHtml}
|
|
<article class="card" aria-label="微信分享卡片预览">
|
|
<div class="card-main">
|
|
<h2 class="card-title">${title}</h2>
|
|
${description ? `<p class="card-desc">${description}</p>` : ''}
|
|
<div class="card-foot">
|
|
<img src="${iconUrl}" alt="" onerror="this.style.display='none'">
|
|
<span>${siteName}</span>
|
|
</div>
|
|
</div>
|
|
${
|
|
imageUrl
|
|
? `<img class="card-thumb" src="${imageUrl}" alt="">`
|
|
: `<div class="card-thumb card-thumb-empty">无图</div>`
|
|
}
|
|
</article>
|
|
<div class="meta">
|
|
<div><strong>og:title</strong> ${title}</div>
|
|
<div><strong>og:description</strong> ${description || '(空)'}</div>
|
|
<div><strong>og:site_name</strong> ${siteName}</div>
|
|
<div><strong>og:image</strong> ${imageUrl || '(空)'}</div>
|
|
<div><strong>icon</strong> ${iconUrl}</div>
|
|
</div>
|
|
</div>
|
|
</body>
|
|
</html>`;
|
|
}
|