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>/i); return match?.[1]?.trim() ?? ''; } function h1FromHtml(html) { const match = String(html).match(/]*>([\s\S]*?)<\/h1>/i); if (!match) return ''; return match[1].replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim(); } function descriptionFromHtml(html) { const meta = String(html).match(/]+name=["']description["'][^>]+content=["']([^"']+)["']/i) ?? String(html).match(/]+content=["']([^"']+)["'][^>]+name=["']description["']/i); if (meta?.[1]) return meta[1].trim(); const paragraph = String(html).match(/]*>([^<]{4,120})/i); return paragraph?.[1]?.trim() ?? ''; } function parseCoverMeta(html) { const tag = String(html).match(/]*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(/]+property=["']og:image["'][^>]+content=["']([^"']+)["']/i) ?? source.match(/]+content=["']([^"']+)["'][^>]+property=["']og:image["']/i); if (og?.[1]) return og[1].trim(); const hero = source.match(/]+class=["'][^"']*hero[^"']*["'][^>]+src=["']([^"']+)["']/i) ?? source.match(/]+src=["']([^"']+)["'][^>]+class=["'][^"']*hero/i); if (hero?.[1]) return hero[1].trim(); const first = source.match(/]+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 ` `; } 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 ? ` ` : !hasPhoto ? themeBackgroundLayers(signals) : ''; const photoLayer = hasPhoto ? `` : ''; const overlayStops = hasPhoto ? ` ` : ` `; const vignetteOpacity = hasPhoto ? '0.52' : '0.42'; const grainOpacity = hasPhoto ? '0.18' : '0.28'; return ` ${overlayStops} ${photoLayer} ${scenicLayers} ${tag.toUpperCase()} ${line1} ${line2 ? `${line2}` : ''} ${subtitle} TKMIND `; } /** @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(() => {}); }); }