feat: add Playwright long image downloads

This commit is contained in:
john
2026-07-02 18:10:33 +08:00
parent 8260ff1e0e
commit f9eb3d1440
16 changed files with 993 additions and 64 deletions
+73 -60
View File
@@ -102,6 +102,12 @@ import {
rasterizeThumbnailSvgToPng,
thumbnailPngPathForSvg,
} from './mindspace-thumbnail-png.mjs';
import {
isLongImageDownloadRequest,
longImagePathForHtml,
renderLongImage,
renderLongImageBuffer,
} from './mindspace-long-image.mjs';
import { scanContent } from './mindspace-content-scan.mjs';
import { renderImageAssetViewerHtml, wantsInlineImageViewer } from './mindspace-asset-preview.mjs';
import { createRechargeService } from './billing-recharge.mjs';
@@ -4390,7 +4396,7 @@ function publishedPageCsp(html, { embed = false, raw = false, wechatShare = fals
return "default-src 'none'; style-src 'unsafe-inline'; img-src data:; font-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'self'";
}
const PUBLIC_FILE_SHARE_SCRIPT = `(function(){var root=document.querySelector('[data-mindspace-public-share]');if(!root)return;var button=root.querySelector('button');var status=root.querySelector('small');var timer=null;function setStatus(text,err){if(!status)return;status.textContent=text||'';status.classList.toggle('is-error',!!err);if(timer)clearTimeout(timer);if(text)timer=setTimeout(function(){status.textContent='';status.classList.remove('is-error');},2200);}function fallbackCopy(text){var ta=document.createElement('textarea');ta.value=text;ta.style.position='fixed';ta.style.left='-9999px';ta.style.opacity='0';document.body.appendChild(ta);ta.focus();ta.select();var ok=document.execCommand('copy');ta.remove();if(!ok)throw new Error('复制失败');}async function copyText(text){if(navigator.clipboard&&navigator.clipboard.writeText){try{await navigator.clipboard.writeText(text);return;}catch(e){}}fallbackCopy(text);}button&&button.addEventListener('click',async function(){var url=location.href.split('#')[0];var title=document.title||'MindSpace';try{if(navigator.share){await navigator.share({title:title,url:url});setStatus('已打开分享');return;}await copyText(url);setStatus('链接已复制');}catch(e){setStatus(e&&e.message?e.message:'分享失败',true);}});})();`;
const PUBLIC_FILE_SHARE_SCRIPT = `(function(){var root=document.querySelector('[data-mindspace-public-share]');if(!root)return;var shareButton=root.querySelector('[data-action="share"]');var captureButton=root.querySelector('[data-action="capture"]');var status=root.querySelector('small');var timer=null;function setStatus(text,err){if(!status)return;status.textContent=text||'';status.classList.toggle('is-error',!!err);if(timer)clearTimeout(timer);if(text)timer=setTimeout(function(){status.textContent='';status.classList.remove('is-error');},2200);}function cleanUrl(){var url=new URL(location.href);url.hash='';url.searchParams.delete('download');url.searchParams.delete('export');return url.toString();}function withParam(url,key,value){var next=new URL(url);next.searchParams.set(key,value);return next.toString();}function fallbackCopy(text){var ta=document.createElement('textarea');ta.value=text;ta.style.position='fixed';ta.style.left='-9999px';ta.style.opacity='0';document.body.appendChild(ta);ta.focus();ta.select();var ok=document.execCommand('copy');ta.remove();if(!ok)throw new Error('复制失败');}async function copyText(text){if(navigator.clipboard&&navigator.clipboard.writeText){try{await navigator.clipboard.writeText(text);return;}catch(e){}}fallbackCopy(text);}shareButton&&shareButton.addEventListener('click',async function(){var url=cleanUrl();var title=document.title||'MindSpace';try{if(navigator.share){await navigator.share({title:title,url:url});setStatus('已打开分享');return;}await copyText(url);setStatus('链接已复制');}catch(e){setStatus(e&&e.message?e.message:'分享失败',true);}});captureButton&&captureButton.addEventListener('click',function(){setStatus('正在生成长图,请稍候');var link=document.createElement('a');link.href=withParam(cleanUrl(),'download','long-image');link.download='';document.body.appendChild(link);link.click();link.remove();});})();`;
const PUBLIC_FILE_SHARE_SCRIPT_HASH = crypto
.createHash('sha256')
.update(PUBLIC_FILE_SHARE_SCRIPT)
@@ -4404,14 +4410,16 @@ function injectPublicFileShareButton(html) {
const markup = `
<style id="mindspace-public-share-style">
[data-mindspace-public-share]{position:fixed;right:18px;bottom:calc(18px + env(safe-area-inset-bottom,0px));z-index:2147483000;display:flex;flex-direction:column;align-items:flex-end;gap:6px;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
[data-mindspace-public-share] div{display:flex;gap:8px;justify-content:flex-end;flex-wrap:wrap}
[data-mindspace-public-share] button{border:0;border-radius:999px;padding:10px 15px;background:#2f6f57;color:#fff;font-size:14px;font-weight:700;box-shadow:0 8px 24px rgba(0,0,0,.18);cursor:pointer}
[data-mindspace-public-share] button[data-action="capture"]{background:#8f6b2f}
[data-mindspace-public-share] button:active{transform:translateY(1px)}
[data-mindspace-public-share] small{min-height:18px;max-width:180px;border-radius:999px;padding:4px 9px;background:rgba(255,255,255,.92);color:#245845;font-size:12px;text-align:right;box-shadow:0 4px 14px rgba(0,0,0,.1)}
[data-mindspace-public-share] small:empty{display:none}
[data-mindspace-public-share] small.is-error{color:#8b2d20}
@media(max-width:640px){[data-mindspace-public-share]{right:12px;bottom:calc(12px + env(safe-area-inset-bottom,0px))}[data-mindspace-public-share] button{padding:9px 13px;font-size:13px}}
</style>
<div data-mindspace-public-share><small aria-live="polite"></small><button type="button">公开分享</button></div>
<div data-mindspace-public-share><small aria-live="polite"></small><div><button type="button" data-action="capture">保存长图</button><button type="button" data-action="share">公开分享</button></div></div>
<script>${PUBLIC_FILE_SHARE_SCRIPT}</script>`;
if (/<\/body>/i.test(source)) {
return {
@@ -4446,6 +4454,16 @@ function appendQueryParam(url, key, value) {
return `${url}${separator}${encodeURIComponent(key)}=${encodeURIComponent(value)}`;
}
function removeQueryParam(url, key) {
if (!url || !url.includes('?')) return url;
const [base, queryAndHash] = url.split('?', 2);
const [query, hash = ''] = queryAndHash.split('#', 2);
const params = new URLSearchParams(query);
params.delete(key);
const nextQuery = params.toString();
return `${base}${nextQuery ? `?${nextQuery}` : ''}${hash ? `#${hash}` : ''}`;
}
function resolveRequestOrigin(req) {
const host = (req.headers['x-forwarded-host'] || req.headers.host || '').toString().split(',')[0].trim();
if (!host) return '';
@@ -4460,18 +4478,19 @@ function detectPublishedPageTitle(html) {
return match?.[1]?.replace(/\s+/g, ' ').trim() || 'MindSpace 页面';
}
function publishedPageShellHtml({ iframeUrl, shareUrl, title }) {
function publishedPageShellHtml({ iframeUrl, shareUrl, title, longImageUrl }) {
const iframeSrc = escapePublicHtml(iframeUrl);
const safeShareUrl = escapePublicHtml(shareUrl);
const safeTitle = escapePublicHtml(title);
const serializedShareUrl = JSON.stringify(shareUrl).replace(/</g, '\\u003c');
const serializedTitle = JSON.stringify(title).replace(/</g, '\\u003c');
const serializedLongImageUrl = JSON.stringify(longImageUrl).replace(/</g, '\\u003c');
return `<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; img-src data:; font-src 'none'; connect-src 'self' https://cdn.jsdelivr.net; script-src 'unsafe-inline' https://cdn.jsdelivr.net; frame-src 'self'; base-uri 'none'; form-action 'none'">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; img-src data:; font-src 'none'; connect-src 'self'; script-src 'unsafe-inline'; frame-src 'self'; base-uri 'none'; form-action 'none'">
<title>${safeTitle}</title>
<style>
* { box-sizing: border-box; }
@@ -4655,13 +4674,13 @@ function publishedPageShellHtml({ iframeUrl, shareUrl, title }) {
(function () {
var shareUrl = ${serializedShareUrl};
var shareTitle = ${serializedTitle};
var longImageUrl = ${serializedLongImageUrl};
var fab = document.getElementById('publication-share-fab');
var sheet = document.getElementById('publication-share-sheet');
var close = document.getElementById('publication-share-close');
var message = document.getElementById('publication-share-message');
var frame = document.querySelector('.publication-frame');
var actionButtons = sheet ? Array.prototype.slice.call(sheet.querySelectorAll('.publication-share-option')) : [];
var html2canvasLoader = null;
function setBusy(busy) {
actionButtons.forEach(function (button) {
button.disabled = !!busy;
@@ -4703,54 +4722,10 @@ function publishedPageShellHtml({ iframeUrl, shareUrl, title }) {
textarea.remove();
if (!copied) throw new Error('复制失败,请手动复制链接');
}
function loadHtml2Canvas() {
if (window.html2canvas) return Promise.resolve(window.html2canvas);
if (html2canvasLoader) return html2canvasLoader;
html2canvasLoader = new Promise(function (resolve, reject) {
var script = document.createElement('script');
script.src = 'https://cdn.jsdelivr.net/npm/html2canvas@1.4.1/dist/html2canvas.min.js';
script.async = true;
script.onload = function () {
if (!window.html2canvas) {
reject(new Error('无法加载页面截图能力'));
return;
}
resolve(window.html2canvas);
};
script.onerror = function () { reject(new Error('无法加载页面截图能力')); };
document.body.appendChild(script);
}).catch(function (error) {
html2canvasLoader = null;
throw error;
});
return html2canvasLoader;
}
async function saveLongImage() {
if (!frame || !frame.contentDocument || !frame.contentDocument.body) {
throw new Error('页面内容暂时不可访问');
}
var renderer = await loadHtml2Canvas();
var doc = frame.contentDocument;
var body = doc.body;
var width = Math.max(doc.documentElement ? doc.documentElement.scrollWidth : 0, body.scrollWidth, 320);
var height = Math.max(doc.documentElement ? doc.documentElement.scrollHeight : 0, body.scrollHeight, 320);
var canvas = await renderer(body, {
useCORS: true,
scale: 2,
backgroundColor: '#ffffff',
width: width,
height: height,
windowWidth: width,
windowHeight: height,
x: 0,
y: 0,
scrollX: 0,
scrollY: 0
});
var link = document.createElement('a');
var timestamp = new Date().toISOString().replace(/[T:]/g, '-').replace(/\..+/, '');
link.href = canvas.toDataURL('image/png');
link.download = 'mindspace-public-page-' + timestamp + '.png';
link.href = longImageUrl;
link.download = '';
document.body.appendChild(link);
link.click();
link.remove();
@@ -4824,10 +4799,12 @@ function publishedPageShellHtml({ iframeUrl, shareUrl, title }) {
</html>`;
}
function sendPublishedPage(req, res, result, { embed = false, raw = false } = {}) {
async function sendPublishedPage(req, res, result, { embed = false, raw = false } = {}) {
let html = result.html;
const origin = resolveRequestOrigin(req);
const pageUrl = req.originalUrl ? new URL(req.originalUrl, origin || 'http://localhost').toString().split('#')[0] : '';
const originalPath = req.originalUrl || req.url || '';
const sharePath = removeQueryParam(removeQueryParam(originalPath, 'download'), 'export');
const pageUrl = originalPath ? new URL(sharePath, origin || 'http://localhost').toString().split('#')[0] : '';
const pageDirUrl = pageUrl ? `${pageUrl.slice(0, pageUrl.lastIndexOf('/') + 1)}` : '';
const wechatShare = !embed && isWechatUserAgent(req.get('user-agent') || '');
if (embed) {
@@ -4845,14 +4822,31 @@ function sendPublishedPage(req, res, result, { embed = false, raw = false } = {}
}
}
const isFullHtml = /^\s*<!doctype html/i.test(html) || /^\s*<html[\s>]/i.test(html);
if (!embed && !raw && isFullHtml && isLongImageDownloadRequest(req.query)) {
try {
const rawUrl = new URL(appendQueryParam(sharePath || originalPath, 'view', 'raw'), origin || 'http://localhost');
const image = await renderLongImageBuffer({ url: rawUrl.toString() });
res.set('Content-Type', 'image/png');
res.set('Content-Disposition', 'attachment; filename="mindspace-public-page.long.png"');
res.set('Cache-Control', 'no-store');
return res.send(image);
} catch (error) {
return res
.status(500)
.type('text/plain; charset=utf-8')
.send(`长图生成失败:${error?.message || '未知错误'}`);
}
}
const canWrapWithShell = !embed && !raw && isFullHtml && result.publication?.accessMode !== 'password';
if (canWrapWithShell) {
const title = detectPublishedPageTitle(html);
const rawUrl = appendQueryParam(req.originalUrl || req.url || '', 'view', 'raw');
const rawUrl = appendQueryParam(sharePath || originalPath, 'view', 'raw');
const longImageUrl = appendQueryParam(sharePath || originalPath, 'download', 'long-image');
let shellHtml = publishedPageShellHtml({
iframeUrl: rawUrl,
shareUrl: pageUrl,
title,
longImageUrl,
});
try {
shellHtml = injectOgTags(shellHtml, {
@@ -5019,7 +5013,7 @@ async function resolvePublishedRoute(req, res, password = null) {
referrer: req.get('referer'),
},
);
return sendPublishedPage(req, res, result, {
return await sendPublishedPage(req, res, result, {
embed: isPlazaEmbedRequest(req.query),
raw: String(req.query.view ?? '').toLowerCase() === 'raw',
});
@@ -5098,7 +5092,7 @@ app.get('/s/:token', async (req, res) => {
if (!mindSpacePublications) return res.status(503).send('MindSpace 未启用');
try {
const viewer = req.userSession && userAuth ? await userAuth.getMe(req.userToken) : null;
return sendPublishedPage(
return await sendPublishedPage(
req,
res,
await mindSpacePublications.resolvePrivateLink(req.params.token, viewer?.id, {
@@ -5136,13 +5130,32 @@ async function resolvePublishDirKey(segment) {
* Send a file, injecting Open Graph tags for .html so forwarded links unfurl with a cover.
* Non-HTML files (assets, etc.) are streamed unchanged via res.sendFile.
*/
function sendPublishFile(req, res, filePath) {
async function sendLongImageDownloadIfRequested(req, res, filePath) {
if (!isLongImageDownloadRequest(req.query)) return false;
const longImagePath = longImagePathForHtml(filePath);
try {
await renderLongImage({ htmlPath: filePath, outputPath: longImagePath });
res.set('Cache-Control', 'no-store');
res.download(longImagePath, path.basename(longImagePath), (err) => {
if (err && !res.headersSent) res.status(404).json({ message: '长图文件不存在' });
});
} catch (error) {
res
.status(500)
.type('text/plain; charset=utf-8')
.send(`长图生成失败:${error?.message || '未知错误'}`);
}
return true;
}
async function sendPublishFile(req, res, filePath) {
if (!filePath.toLowerCase().endsWith('.html')) {
res.sendFile(filePath, (err) => {
if (err && !res.headersSent) res.status(404).json({ message: '文件不存在' });
});
return;
}
if (await sendLongImageDownloadIfRequested(req, res, filePath)) return;
let html;
try {
html = fs.readFileSync(filePath, 'utf8');
@@ -5320,7 +5333,7 @@ async function serveUserPublishFile(req, res, next) {
}
const recoveredPublicHtml = await recoverMisplacedPublicHtml(targetDir, resolvedRoot, rest);
if (recoveredPublicHtml) {
sendPublishFile(req, res, recoveredPublicHtml);
await sendPublishFile(req, res, recoveredPublicHtml);
return;
}
res.status(404).json({ message: '文件不存在' });
@@ -5330,14 +5343,14 @@ async function serveUserPublishFile(req, res, next) {
if (fs.statSync(resolvedPath).isDirectory()) {
const indexPath = path.join(resolvedPath, 'index.html');
if (fs.existsSync(indexPath)) {
sendPublishFile(req, res, indexPath);
await sendPublishFile(req, res, indexPath);
return;
}
res.status(404).json({ message: '目录中没有 index.html' });
return;
}
sendPublishFile(req, res, resolvedPath);
await sendPublishFile(req, res, resolvedPath);
}
app.use(`/${PUBLISH_ROOT_DIR}`, async (req, res, next) => {