534 lines
20 KiB
JavaScript
534 lines
20 KiB
JavaScript
import crypto from 'node:crypto';
|
|
import fs from 'node:fs/promises';
|
|
import path from 'node:path';
|
|
import { workspaceThumbnailRelativePath } from './mindspace-workspace-thumbnails.mjs';
|
|
|
|
const FEED_WIDTH = 540;
|
|
const FEED_HEIGHT = 720;
|
|
const MAX_COVER_BYTES = 1.5 * 1024 * 1024;
|
|
const REMOTE_COVER_TIMEOUT_MS = 8000;
|
|
|
|
function escapeXml(value) {
|
|
return String(value)
|
|
.replaceAll('&', '&')
|
|
.replaceAll('<', '<')
|
|
.replaceAll('>', '>')
|
|
.replaceAll('"', '"')
|
|
.replaceAll("'", ''');
|
|
}
|
|
|
|
function titleFromHtml(html) {
|
|
const match = String(html).match(/<title[^>]*>([^<]+)<\/title>/i);
|
|
return match?.[1]?.trim() ?? '';
|
|
}
|
|
|
|
function h1FromHtml(html) {
|
|
const match = String(html).match(/<h1[^>]*>([\s\S]*?)<\/h1>/i);
|
|
if (!match) return '';
|
|
return match[1].replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim();
|
|
}
|
|
|
|
function descriptionFromHtml(html) {
|
|
const meta =
|
|
String(html).match(/<meta[^>]+name=["']description["'][^>]+content=["']([^"']+)["']/i) ??
|
|
String(html).match(/<meta[^>]+content=["']([^"']+)["'][^>]+name=["']description["']/i);
|
|
if (meta?.[1]) return meta[1].trim();
|
|
const paragraph = String(html).match(/<p[^>]*>([^<]{4,120})/i);
|
|
return paragraph?.[1]?.trim() ?? '';
|
|
}
|
|
|
|
function parseCoverMeta(html) {
|
|
const tag = String(html).match(/<meta[^>]*name=["']mindspace-cover["'][^>]*>/i)?.[0];
|
|
if (!tag) return {};
|
|
const contentMatch =
|
|
tag.match(/content=(["'])([\s\S]*?)\1/i) ?? tag.match(/content=["']([^"']+)["']/i);
|
|
const raw = contentMatch?.[2] ?? contentMatch?.[1];
|
|
if (!raw) return {};
|
|
try {
|
|
return JSON.parse(raw.replaceAll('"', '"'));
|
|
} catch {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
function extractEmoji(text) {
|
|
const match = String(text).match(/\p{Extended_Pictographic}/u);
|
|
return match?.[0] ?? '';
|
|
}
|
|
|
|
function coverImageFromHtml(html) {
|
|
const source = String(html);
|
|
const og =
|
|
source.match(/<meta[^>]+property=["']og:image["'][^>]+content=["']([^"']+)["']/i) ??
|
|
source.match(/<meta[^>]+content=["']([^"']+)["'][^>]+property=["']og:image["']/i);
|
|
if (og?.[1]) return og[1].trim();
|
|
const hero =
|
|
source.match(/<img[^>]+class=["'][^"']*hero[^"']*["'][^>]+src=["']([^"']+)["']/i) ??
|
|
source.match(/<img[^>]+src=["']([^"']+)["'][^>]+class=["'][^"']*hero/i);
|
|
if (hero?.[1]) return hero[1].trim();
|
|
const first = source.match(/<img[^>]+src=["']([^"']+)["']/i);
|
|
return first?.[1]?.trim() ?? '';
|
|
}
|
|
|
|
function svgImageHref(value) {
|
|
const raw = String(value);
|
|
if (raw.startsWith('data:')) return raw;
|
|
return escapeXml(raw);
|
|
}
|
|
|
|
function mimeFromImageBytes(buffer) {
|
|
if (buffer[0] === 0xff && buffer[1] === 0xd8) return 'image/jpeg';
|
|
if (buffer[0] === 0x89 && buffer[1] === 0x50) return 'image/png';
|
|
if (buffer.length >= 12 && buffer.slice(0, 4).toString() === 'RIFF' && buffer.slice(8, 12).toString() === 'WEBP') {
|
|
return 'image/webp';
|
|
}
|
|
if (buffer[0] === 0x47 && buffer[1] === 0x49) return 'image/gif';
|
|
return 'image/jpeg';
|
|
}
|
|
|
|
function bytesToDataUri(buffer, mimeType) {
|
|
return `data:${mimeType};base64,${buffer.toString('base64')}`;
|
|
}
|
|
|
|
export function bufferToImageDataUri(buffer) {
|
|
if (!buffer?.length) return null;
|
|
if (buffer.length > MAX_COVER_BYTES) return null;
|
|
return bytesToDataUri(buffer, mimeFromImageBytes(buffer));
|
|
}
|
|
|
|
async function readLocalImageDataUri(filePath) {
|
|
const buffer = await fs.readFile(filePath);
|
|
if (buffer.length === 0 || buffer.length > MAX_COVER_BYTES) return null;
|
|
return bytesToDataUri(buffer, mimeFromImageBytes(buffer));
|
|
}
|
|
|
|
async function fetchRemoteImageDataUri(url) {
|
|
const response = await fetch(url, {
|
|
signal: AbortSignal.timeout(REMOTE_COVER_TIMEOUT_MS),
|
|
headers: { Accept: 'image/*' },
|
|
redirect: 'follow',
|
|
});
|
|
if (!response.ok) return null;
|
|
const contentType = response.headers.get('content-type') ?? '';
|
|
if (contentType && !contentType.startsWith('image/')) return null;
|
|
const buffer = Buffer.from(await response.arrayBuffer());
|
|
if (buffer.length === 0 || buffer.length > MAX_COVER_BYTES) return null;
|
|
const mimeType = contentType.startsWith('image/') ? contentType.split(';')[0] : mimeFromImageBytes(buffer);
|
|
return bytesToDataUri(buffer, mimeType);
|
|
}
|
|
|
|
function resolveLocalImagePath({ storageRoot, contentStorageKey, contentBaseDir, imageUrl }) {
|
|
if (!imageUrl || /^data:|^https?:/i.test(imageUrl)) return null;
|
|
let baseDir = null;
|
|
if (contentStorageKey) {
|
|
baseDir = path.dirname(path.resolve(storageRoot, contentStorageKey));
|
|
const root = path.resolve(storageRoot);
|
|
if (baseDir !== root && !baseDir.startsWith(`${root}${path.sep}`)) return null;
|
|
} else if (contentBaseDir) {
|
|
baseDir = path.resolve(contentBaseDir);
|
|
}
|
|
if (!baseDir) return null;
|
|
const target = path.resolve(baseDir, imageUrl.replace(/^\.\//, ''));
|
|
if (target !== baseDir && !target.startsWith(`${baseDir}${path.sep}`)) return null;
|
|
return target;
|
|
}
|
|
|
|
export async function resolveCoverDataUri({
|
|
storageRoot,
|
|
contentStorageKey,
|
|
contentBaseDir,
|
|
imageUrl,
|
|
resolveAssetDataUri,
|
|
}) {
|
|
const raw = String(imageUrl ?? '').trim();
|
|
if (!raw) return null;
|
|
if (raw.startsWith('data:')) return raw.length <= MAX_COVER_BYTES * 2 ? raw : null;
|
|
if (/^https?:\/\//i.test(raw)) {
|
|
try {
|
|
return await fetchRemoteImageDataUri(raw);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
const assetMatch = raw.match(/\/mindspace\/v1\/assets\/([a-z0-9-]+)(?:\/download)?/i);
|
|
if (assetMatch && resolveAssetDataUri) {
|
|
try {
|
|
return await resolveAssetDataUri(assetMatch[1]);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
const localPath = resolveLocalImagePath({ storageRoot, contentStorageKey, contentBaseDir, imageUrl: raw });
|
|
if (!localPath) return null;
|
|
try {
|
|
return await readLocalImageDataUri(localPath);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function colorsFromHtml(html) {
|
|
const colors = [];
|
|
const hexRe = /#(?:[0-9a-f]{3}){1,2}\b/gi;
|
|
let match;
|
|
while ((match = hexRe.exec(String(html))) !== null && colors.length < 6) {
|
|
const normalized = normalizeHex(match[0]);
|
|
if (!normalized) continue;
|
|
if (['#ffffff', '#fff', '#000000', '#000'].includes(normalized)) continue;
|
|
if (!colors.includes(normalized)) colors.push(normalized);
|
|
}
|
|
return colors;
|
|
}
|
|
|
|
function normalizeHex(value) {
|
|
const raw = String(value).trim().toLowerCase();
|
|
if (!raw.startsWith('#')) return null;
|
|
if (raw.length === 4) {
|
|
return `#${raw[1]}${raw[1]}${raw[2]}${raw[2]}${raw[3]}${raw[3]}`;
|
|
}
|
|
if (raw.length === 7) return raw;
|
|
return null;
|
|
}
|
|
|
|
function darken(hex, amount = 0.28) {
|
|
const color = normalizeHex(hex) ?? '#2f6f57';
|
|
const r = Math.round(parseInt(color.slice(1, 3), 16) * (1 - amount));
|
|
const g = Math.round(parseInt(color.slice(3, 5), 16) * (1 - amount));
|
|
const b = Math.round(parseInt(color.slice(5, 7), 16) * (1 - amount));
|
|
return `#${[r, g, b].map((n) => n.toString(16).padStart(2, '0')).join('')}`;
|
|
}
|
|
|
|
function splitTitleLines(title, maxLines = 2) {
|
|
const cleaned = String(title || '未命名页面').replace(/\s+/g, ' ').trim();
|
|
if (!cleaned) return ['未命名页面'];
|
|
if (cleaned.includes('|')) {
|
|
return cleaned
|
|
.split('|')
|
|
.map((part) => part.trim())
|
|
.filter(Boolean)
|
|
.slice(0, maxLines);
|
|
}
|
|
if (cleaned.length <= 16) return [cleaned];
|
|
const midpoint = Math.ceil(cleaned.length / 2);
|
|
const splitAt =
|
|
cleaned.lastIndexOf(' ', midpoint) > 8 ? cleaned.lastIndexOf(' ', midpoint) : midpoint;
|
|
return [cleaned.slice(0, splitAt).trim(), cleaned.slice(splitAt).trim()].filter(Boolean);
|
|
}
|
|
|
|
function lighten(hex, amount = 0.22) {
|
|
const color = normalizeHex(hex) ?? '#2f6f57';
|
|
const r = Math.min(255, Math.round(parseInt(color.slice(1, 3), 16) + 255 * amount));
|
|
const g = Math.min(255, Math.round(parseInt(color.slice(3, 5), 16) + 255 * amount));
|
|
const b = Math.min(255, Math.round(parseInt(color.slice(5, 7), 16) + 255 * amount));
|
|
return `#${[r, g, b].map((n) => n.toString(16).padStart(2, '0')).join('')}`;
|
|
}
|
|
|
|
function inferTagFromContent(html, title = '') {
|
|
const text = `${title}\n${String(html).slice(0, 8000)}`;
|
|
if (/旅行|旅游|攻略|travel/i.test(text)) return '旅行';
|
|
if (/美食|餐厅|菜谱|料理|food/i.test(text)) return '美食';
|
|
if (/报告|分析|研报|数据|report/i.test(text)) return '报告';
|
|
if (/普拉提|瑜伽|健身|运动|pilates|yoga/i.test(text)) return '运动';
|
|
if (/618|促销|活动|优惠|限时|大促/i.test(text)) return '活动';
|
|
return null;
|
|
}
|
|
|
|
export function shouldUseScenicBackground(signals) {
|
|
return /旅行|travel|美食|food|餐|报告|report|分析/i.test(String(signals.tag ?? ''));
|
|
}
|
|
|
|
function resolvePhotoPalette(signals) {
|
|
const tag = String(signals.tag ?? '');
|
|
const accent = signals.accent;
|
|
const accent2 = signals.accent2;
|
|
|
|
if (/旅行|travel/i.test(tag)) {
|
|
return {
|
|
sky: '#4f8fb8',
|
|
glow: '#f6d7a8',
|
|
horizon: '#e39a4d',
|
|
land: '#24343a',
|
|
shadow: '#0d1518',
|
|
flare: '#ffe9c7',
|
|
bokeh: '#fff8ef',
|
|
};
|
|
}
|
|
if (/美食|food|餐/i.test(tag)) {
|
|
return {
|
|
sky: '#5a2b22',
|
|
glow: '#ffb27a',
|
|
horizon: '#d85f3b',
|
|
land: '#241412',
|
|
shadow: '#120909',
|
|
flare: '#ffd0a8',
|
|
bokeh: '#ffe8d6',
|
|
};
|
|
}
|
|
if (/报告|report|分析/i.test(tag)) {
|
|
return {
|
|
sky: '#3d4f68',
|
|
glow: '#9eb4d8',
|
|
horizon: '#607892',
|
|
land: '#1a2430',
|
|
shadow: '#0a1018',
|
|
flare: '#c8d8ef',
|
|
bokeh: '#eef3fb',
|
|
};
|
|
}
|
|
|
|
return {
|
|
sky: lighten(accent, 0.18),
|
|
glow: lighten(accent, 0.34),
|
|
horizon: accent,
|
|
land: darken(accent2, 0.1),
|
|
shadow: darken(accent2, 0.32),
|
|
flare: lighten(accent, 0.42),
|
|
bokeh: '#fff8f2',
|
|
};
|
|
}
|
|
|
|
export function extractCoverSignals(html, meta = {}) {
|
|
const coverMeta = parseCoverMeta(html);
|
|
const rawTitle = meta.title || h1FromHtml(html) || titleFromHtml(html) || '未命名页面';
|
|
const title = rawTitle.replace(/\p{Extended_Pictographic}/gu, '').replace(/\s+/g, ' ').trim();
|
|
const colors = colorsFromHtml(html);
|
|
const accent = normalizeHex(coverMeta.accent ?? meta.accent ?? colors[0] ?? '#2f6f57');
|
|
const accent2 = normalizeHex(coverMeta.accent2 ?? colors[1] ?? darken(accent, 0.15));
|
|
return {
|
|
title: title || '未命名页面',
|
|
subtitle:
|
|
coverMeta.subtitle ??
|
|
meta.subtitle ??
|
|
descriptionFromHtml(html) ??
|
|
'TKMind 作品',
|
|
tag:
|
|
coverMeta.tag ??
|
|
meta.tag ??
|
|
inferTagFromContent(html, title) ??
|
|
'精选页面',
|
|
emoji: coverMeta.emoji ?? meta.emoji ?? extractEmoji(rawTitle) ?? extractEmoji(h1FromHtml(html)),
|
|
accent,
|
|
accent2,
|
|
mood: coverMeta.mood ?? meta.mood ?? 'photo',
|
|
image:
|
|
coverMeta.cover ??
|
|
coverMeta.image ??
|
|
meta.cover ??
|
|
meta.image ??
|
|
coverImageFromHtml(html),
|
|
};
|
|
}
|
|
|
|
function themeBackgroundLayers(signals) {
|
|
const accent = escapeXml(signals.accent);
|
|
const accent2 = escapeXml(signals.accent2);
|
|
const glow = escapeXml(lighten(signals.accent, 0.28));
|
|
return `<rect width="${FEED_WIDTH}" height="${FEED_HEIGHT}" fill="url(#theme)"/>
|
|
<rect width="${FEED_WIDTH}" height="${FEED_HEIGHT}" fill="url(#themeGlow)"/>
|
|
<rect width="${FEED_WIDTH}" height="${FEED_HEIGHT}" fill="url(#themeShade)" opacity="0.72"/>`;
|
|
}
|
|
|
|
export function buildFeedThumbnailSvg(signals, options = {}) {
|
|
const coverDataUri = options.coverDataUri ?? null;
|
|
const sourceHash = String(options.sourceHash ?? '').trim();
|
|
const hasPhoto = Boolean(coverDataUri);
|
|
const useScenic = !hasPhoto && shouldUseScenicBackground(signals);
|
|
const palette = resolvePhotoPalette(signals);
|
|
const titleLines = splitTitleLines(signals.title);
|
|
const line1 = escapeXml(titleLines[0] ?? signals.title).slice(0, 24);
|
|
const line2 = escapeXml(titleLines[1] ?? '').slice(0, 24);
|
|
const subtitle = escapeXml(signals.subtitle).slice(0, 42);
|
|
const tag = escapeXml(signals.tag).slice(0, 12);
|
|
const sky = escapeXml(palette.sky);
|
|
const glow = escapeXml(palette.glow);
|
|
const horizon = escapeXml(palette.horizon);
|
|
const land = escapeXml(palette.land);
|
|
const shadow = escapeXml(palette.shadow);
|
|
const flare = escapeXml(palette.flare);
|
|
const bokeh = escapeXml(palette.bokeh);
|
|
const titleY2 = line2 ? 652 : 0;
|
|
const scenicLayers = useScenic
|
|
? `<rect width="${FEED_WIDTH}" height="${FEED_HEIGHT}" fill="url(#sky)"/>
|
|
<rect width="${FEED_WIDTH}" height="${FEED_HEIGHT}" fill="url(#sun)"/>
|
|
<circle cx="118" cy="168" r="58" fill="${bokeh}" opacity="0.16" filter="url(#blurSoft)"/>
|
|
<circle cx="430" cy="248" r="42" fill="${bokeh}" opacity="0.2" filter="url(#blurTight)"/>
|
|
<circle cx="360" cy="118" r="26" fill="${flare}" opacity="0.34" filter="url(#blurTight)"/>
|
|
<circle cx="72" cy="320" r="18" fill="${bokeh}" opacity="0.28" filter="url(#blurTight)"/>
|
|
<path d="M0 470 L90 404 L180 438 L286 372 L392 418 L500 360 L540 388 L540 720 L0 720 Z" fill="${land}" opacity="0.92"/>
|
|
<path d="M0 520 L120 468 L250 502 L360 456 L470 492 L540 460 L540 720 L0 720 Z" fill="${shadow}" opacity="0.55"/>`
|
|
: !hasPhoto
|
|
? themeBackgroundLayers(signals)
|
|
: '';
|
|
const photoLayer = hasPhoto
|
|
? `<image href="${svgImageHref(coverDataUri)}" x="0" y="0" width="${FEED_WIDTH}" height="${FEED_HEIGHT}" preserveAspectRatio="xMidYMid slice"/>`
|
|
: '';
|
|
const overlayStops = hasPhoto
|
|
? `<stop offset="0%" stop-color="#000000" stop-opacity="0.12"/>
|
|
<stop offset="45%" stop-color="#000000" stop-opacity="0.02"/>
|
|
<stop offset="100%" stop-color="${shadow}" stop-opacity="0.88"/>`
|
|
: `<stop offset="0%" stop-color="#000000" stop-opacity="0"/>
|
|
<stop offset="52%" stop-color="#000000" stop-opacity="0.08"/>
|
|
<stop offset="100%" stop-color="${shadow}" stop-opacity="0.82"/>`;
|
|
const vignetteOpacity = hasPhoto ? '0.52' : '0.42';
|
|
const grainOpacity = hasPhoto ? '0.18' : '0.28';
|
|
|
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="${FEED_WIDTH}" height="${FEED_HEIGHT}" viewBox="0 0 ${FEED_WIDTH} ${FEED_HEIGHT}"${sourceHash ? ` data-source-hash="${escapeXml(sourceHash)}"` : ''}>
|
|
<defs>
|
|
<linearGradient id="theme" x1="18%" y1="0%" x2="82%" y2="100%">
|
|
<stop offset="0%" stop-color="${escapeXml(lighten(signals.accent, 0.12))}"/>
|
|
<stop offset="48%" stop-color="${escapeXml(signals.accent)}"/>
|
|
<stop offset="100%" stop-color="${escapeXml(signals.accent2)}"/>
|
|
</linearGradient>
|
|
<radialGradient id="themeGlow" cx="72%" cy="16%" r="42%">
|
|
<stop offset="0%" stop-color="${escapeXml(lighten(signals.accent, 0.42))}" stop-opacity="0.55"/>
|
|
<stop offset="100%" stop-color="${escapeXml(signals.accent2)}" stop-opacity="0"/>
|
|
</radialGradient>
|
|
<linearGradient id="themeShade" x1="50%" y1="55%" x2="50%" y2="100%">
|
|
<stop offset="0%" stop-color="#000000" stop-opacity="0"/>
|
|
<stop offset="100%" stop-color="#000000" stop-opacity="0.72"/>
|
|
</linearGradient>
|
|
<linearGradient id="sky" x1="50%" y1="0%" x2="50%" y2="100%">
|
|
<stop offset="0%" stop-color="${sky}"/>
|
|
<stop offset="42%" stop-color="${glow}" stop-opacity="0.88"/>
|
|
<stop offset="68%" stop-color="${horizon}"/>
|
|
<stop offset="100%" stop-color="${land}"/>
|
|
</linearGradient>
|
|
<linearGradient id="overlay" x1="50%" y1="0%" x2="50%" y2="100%">
|
|
${overlayStops}
|
|
</linearGradient>
|
|
<radialGradient id="sun" cx="78%" cy="18%" r="34%">
|
|
<stop offset="0%" stop-color="${flare}" stop-opacity="0.95"/>
|
|
<stop offset="55%" stop-color="${horizon}" stop-opacity="0.24"/>
|
|
<stop offset="100%" stop-color="${horizon}" stop-opacity="0"/>
|
|
</radialGradient>
|
|
<radialGradient id="vignette" cx="50%" cy="48%" r="72%">
|
|
<stop offset="55%" stop-color="#000000" stop-opacity="0"/>
|
|
<stop offset="100%" stop-color="#000000" stop-opacity="0.42"/>
|
|
</radialGradient>
|
|
<filter id="blurSoft" x="-30%" y="-30%" width="160%" height="160%">
|
|
<feGaussianBlur stdDeviation="24"/>
|
|
</filter>
|
|
<filter id="blurTight" x="-40%" y="-40%" width="180%" height="180%">
|
|
<feGaussianBlur stdDeviation="10"/>
|
|
</filter>
|
|
<filter id="grain" x="0%" y="0%" width="100%" height="100%">
|
|
<feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="3" stitchTiles="stitch"/>
|
|
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.14 0"/>
|
|
</filter>
|
|
</defs>
|
|
${photoLayer}
|
|
${scenicLayers}
|
|
<rect width="${FEED_WIDTH}" height="${FEED_HEIGHT}" fill="url(#overlay)"/>
|
|
<rect width="${FEED_WIDTH}" height="${FEED_HEIGHT}" fill="url(#vignette)" opacity="${vignetteOpacity}"/>
|
|
<rect width="${FEED_WIDTH}" height="${FEED_HEIGHT}" filter="url(#grain)" opacity="${grainOpacity}"/>
|
|
<text x="34" y="42" fill="rgba(255,255,255,0.72)" font-family="ui-sans-serif, system-ui, sans-serif" font-size="11" font-weight="700" letter-spacing="3.2">${tag.toUpperCase()}</text>
|
|
<text x="34" y="${line2 ? 612 : 636}" fill="#ffffff" font-family="Georgia, 'Times New Roman', serif" font-size="${line2 ? 34 : 38}" font-weight="600" letter-spacing="-0.4">${line1}</text>
|
|
${line2 ? `<text x="34" y="${titleY2}" fill="rgba(255,255,255,0.94)" font-family="Georgia, 'Times New Roman', serif" font-size="28" font-weight="500">${line2}</text>` : ''}
|
|
<text x="34" y="${line2 ? 686 : 672}" fill="rgba(255,255,255,0.72)" font-family="ui-sans-serif, system-ui, sans-serif" font-size="14" letter-spacing="0.2">${subtitle}</text>
|
|
<text x="506" y="698" text-anchor="end" fill="rgba(255,255,255,0.34)" font-family="ui-sans-serif, system-ui, sans-serif" font-size="10" letter-spacing="2.8">TKMIND</text>
|
|
</svg>`;
|
|
}
|
|
|
|
/** @deprecated use buildFeedThumbnailSvg */
|
|
export function buildThumbnailSvg({ title, subtitle, accent = '#2f6f57' }) {
|
|
return buildFeedThumbnailSvg({
|
|
title,
|
|
subtitle,
|
|
tag: '页面',
|
|
emoji: '✦',
|
|
accent,
|
|
accent2: darken(accent, 0.15),
|
|
mood: 'legacy',
|
|
});
|
|
}
|
|
|
|
export function assetThumbnailKey(userId, assetId) {
|
|
return path.posix.join('users', userId, 'assets', assetId, 'thumbnail.svg');
|
|
}
|
|
|
|
export function pageThumbnailKey(userId, pageId) {
|
|
return path.posix.join('users', userId, 'pages', pageId, 'thumbnail.svg');
|
|
}
|
|
|
|
export async function writeThumbnail(storageRoot, storageKey, svg) {
|
|
const target = path.resolve(storageRoot, storageKey);
|
|
const root = path.resolve(storageRoot);
|
|
if (target !== root && !target.startsWith(`${root}${path.sep}`)) {
|
|
throw new Error('缩略图路径越界');
|
|
}
|
|
await fs.mkdir(path.dirname(target), { recursive: true });
|
|
await fs.writeFile(target, svg, 'utf8');
|
|
return target;
|
|
}
|
|
|
|
export function isModernFeedThumbnail(svg) {
|
|
if (!svg) return false;
|
|
return /width="540" height="720"/.test(svg) && /filter id="grain"/.test(svg);
|
|
}
|
|
|
|
export async function generateHtmlThumbnail(storageRoot, storageKey, html, meta = {}) {
|
|
const signals = extractCoverSignals(html, meta);
|
|
const coverDataUri = await resolveCoverDataUri({
|
|
storageRoot,
|
|
contentStorageKey: meta.contentStorageKey,
|
|
contentBaseDir: meta.contentBaseDir,
|
|
imageUrl: signals.image,
|
|
resolveAssetDataUri: meta.resolveAssetDataUri,
|
|
});
|
|
const sourceHash = crypto.createHash('sha256').update(String(html)).digest('hex');
|
|
const svg = buildFeedThumbnailSvg(signals, { coverDataUri, sourceHash });
|
|
await writeThumbnail(storageRoot, storageKey, svg);
|
|
return svg;
|
|
}
|
|
|
|
export async function ensureHtmlThumbnail(storageRoot, storageKey, html, meta = {}) {
|
|
const existing = await readThumbnailIfExists(storageRoot, storageKey);
|
|
const sourceHash = crypto.createHash('sha256').update(String(html)).digest('hex');
|
|
const existingSourceHash = existing?.match(/\bdata-source-hash=["']([a-f0-9]{64})["']/i)?.[1] ?? null;
|
|
if (
|
|
existing &&
|
|
isModernFeedThumbnail(existing) &&
|
|
existingSourceHash === sourceHash &&
|
|
!meta.force
|
|
) {
|
|
return existing;
|
|
}
|
|
return generateHtmlThumbnail(storageRoot, storageKey, html, meta);
|
|
}
|
|
|
|
export async function ensurePageThumbnail({
|
|
storageRoot,
|
|
pageThumbnailStorageKey,
|
|
html,
|
|
meta = {},
|
|
workspacePublishDir = null,
|
|
workspaceHtmlRelativePath = null,
|
|
}) {
|
|
if (workspacePublishDir && workspaceHtmlRelativePath) {
|
|
const sidecar = await readThumbnailIfExists(
|
|
workspacePublishDir,
|
|
workspaceThumbnailRelativePath(workspaceHtmlRelativePath),
|
|
);
|
|
if (sidecar && isModernFeedThumbnail(sidecar)) {
|
|
await writeThumbnail(storageRoot, pageThumbnailStorageKey, sidecar);
|
|
return sidecar;
|
|
}
|
|
}
|
|
return ensureHtmlThumbnail(storageRoot, pageThumbnailStorageKey, html, meta);
|
|
}
|
|
|
|
export async function readThumbnailIfExists(storageRoot, storageKey) {
|
|
try {
|
|
return await fs.readFile(path.resolve(storageRoot, storageKey), 'utf8');
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export function scheduleHtmlThumbnail(storageRoot, storageKey, html, meta = {}) {
|
|
queueMicrotask(() => {
|
|
void ensureHtmlThumbnail(storageRoot, storageKey, html, meta).catch(() => {});
|
|
});
|
|
}
|