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(/
]*>([^<]{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 `