From 13ca7372a8dba7815929398552b763960f3f8acc Mon Sep 17 00:00:00 2001 From: Snowz <372492339@qq.com> Date: Wed, 21 Jan 2026 21:28:59 +0800 Subject: [PATCH] =?UTF-8?q?perf(server):=20=E5=B0=86=E5=90=8C=E6=AD=A5?= =?UTF-8?q?=E6=96=87=E4=BB=B6I/O=E6=9B=BF=E6=8D=A2=E4=B8=BA=E5=BC=82?= =?UTF-8?q?=E6=AD=A5=E6=93=8D=E4=BD=9C=E6=8F=90=E5=8D=87=E5=B9=B6=E5=8F=91?= =?UTF-8?q?=E6=80=A7=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit feat(server): 为浏览器访问添加加载动画HTML页面 --- README.md | 2 + server.js | 297 +++++++++++++++++++++++++++++++++++++++++++++--------- 2 files changed, 250 insertions(+), 49 deletions(-) diff --git a/README.md b/README.md index 0e3142d..e6f5d35 100644 --- a/README.md +++ b/README.md @@ -54,3 +54,5 @@ - 优化协议检测逻辑:当目标主机 443 端口无法连接或超时时,自动降级并打印日志 `falling back to HTTP`,确保非 SSL 站点也能正常访问。 - 优化缓存逻辑:不再缓存小尺寸(< 15KB)的 GIF 图片(通常是 mShots 的 "Generating" 占位图),以便下次请求时能重新尝试获取真实截图。 - 新增备用接口机制:当 mShots 返回无效图片(如生成中 GIF)或失败时,自动降级尝试使用 `thum.io` 获取截图,确保高可用性。 + - 性能优化:将关键路径上的同步文件 I/O (readFileSync/writeFileSync) 替换为异步操作 (fs.promises),防止高并发下 Event Loop 阻塞导致服务无响应。 + - 用户体验优化:当在浏览器中直接访问 API (Accept: text/html) 时,返回一个带有加载动画的 HTML 页面,解决等待过程中的白屏问题。 diff --git a/server.js b/server.js index 80dbc9d..8dd02d8 100644 --- a/server.js +++ b/server.js @@ -1,6 +1,7 @@ const express = require('express'); const axios = require('axios'); const fs = require('fs'); +const fsPromises = require('fs').promises; const path = require('path'); const crypto = require('crypto'); const net = require('net'); @@ -180,26 +181,50 @@ async function handleProxyRequest(res, upstreamUrl, targetUrl) { const key = sha1(upstreamUrl); const { data: dataPath, meta: metaPath } = getCachePaths(key); + // 1. 尝试读取缓存 (使用 async 版本) try { - // 1. 尝试读取缓存 - if (fs.existsSync(dataPath) && fs.existsSync(metaPath)) { - try { - const metaRaw = fs.readFileSync(metaPath, 'utf8'); - const meta = JSON.parse(metaRaw); - if (meta.contentType && meta.contentType.toLowerCase().startsWith('image/')) { - res.set('Cache-Control', 'public, max-age=315360000, immutable'); - res.type(meta.contentType); - const stream = fs.createReadStream(dataPath); - stream.on('error', () => { - if (!res.headersSent) res.status(500).send('Cache read error'); - }); - return stream.pipe(res); - } - } catch (e) { - // 缓存元数据损坏,忽略,继续回源 - } + // 检查文件是否存在 + await fsPromises.access(dataPath); + await fsPromises.access(metaPath); + + const metaRaw = await fsPromises.readFile(metaPath, 'utf8'); + let meta; + try { + meta = JSON.parse(metaRaw); + } catch (e) { + console.warn(`[cache-warn] meta corrupted for ${upstreamUrl}`); + // meta 损坏,视为无缓存,继续回源 } + if (meta) { + // 检查缓存有效期 (例如 30 天) + const cachedTime = new Date(meta.createdAt).getTime(); + const now = Date.now(); + const maxAge = 30 * 24 * 3600 * 1000; + + if (now - cachedTime < maxAge) { + console.log(`[cache-hit] ${upstreamUrl}`); + res.type(meta.contentType); + res.set('Cache-Control', 'public, max-age=315360000, immutable'); + if (meta.source === 'thum.io') { + res.set('X-Source', 'fallback-thum.io-cache'); + } else { + res.set('X-Source', 'mshots-cache'); + } + + // 流式返回 + const stream = fs.createReadStream(dataPath); + stream.pipe(res); + return; + } else { + console.log(`[cache-expired] ${upstreamUrl}`); + } + } + } catch (err) { + // 文件不存在或其他错误,忽略,继续回源 + } + + try { // 2. 回源请求 const resp = await fetchUpstreamWithRetry(upstreamUrl); @@ -231,8 +256,8 @@ async function handleProxyRequest(res, upstreamUrl, targetUrl) { source: isFallback ? 'thum.io' : 'mshots' }; try { - fs.writeFileSync(dataPath, finalResp.data); - fs.writeFileSync(metaPath, JSON.stringify(meta)); + await fsPromises.writeFile(dataPath, finalResp.data); + await fsPromises.writeFile(metaPath, JSON.stringify(meta)); } catch (e) { // 写盘失败也继续返回 } @@ -246,21 +271,22 @@ async function handleProxyRequest(res, upstreamUrl, targetUrl) { // 5. 非图片或非200:兜底策略 // 若本地已有有效缓存(可能是旧的但有效),优先返回缓存 - if (fs.existsSync(dataPath) && fs.existsSync(metaPath)) { - try { - const metaRaw = fs.readFileSync(metaPath, 'utf8'); - const meta = JSON.parse(metaRaw); - if (meta.contentType && meta.contentType.toLowerCase().startsWith('image/')) { - res.set('Cache-Control', 'public, max-age=315360000, immutable'); - res.type(meta.contentType); - const stream = fs.createReadStream(dataPath); - stream.on('error', () => { - if (!res.headersSent) res.status(500).send('Cache read error'); - }); - return stream.pipe(res); - } - } catch (_) {} - } + try { + await fsPromises.access(dataPath); + await fsPromises.access(metaPath); + + const metaRaw = await fsPromises.readFile(metaPath, 'utf8'); + const meta = JSON.parse(metaRaw); + if (meta.contentType && meta.contentType.toLowerCase().startsWith('image/')) { + res.set('Cache-Control', 'public, max-age=315360000, immutable'); + res.type(meta.contentType); + const stream = fs.createReadStream(dataPath); + stream.on('error', () => { + if (!res.headersSent) res.status(500).send('Cache read error'); + }); + return stream.pipe(res); + } + } catch (_) {} // 否则透传上游响应 res.status(resp.status); @@ -270,21 +296,22 @@ async function handleProxyRequest(res, upstreamUrl, targetUrl) { } catch (err) { // 5. 回源彻底失败 // 若本地有缓存可兜底 - if (fs.existsSync(dataPath) && fs.existsSync(metaPath)) { - try { - const metaRaw = fs.readFileSync(metaPath, 'utf8'); - const meta = JSON.parse(metaRaw); - if (meta.contentType && meta.contentType.toLowerCase().startsWith('image/')) { - res.set('Cache-Control', 'public, max-age=315360000, immutable'); - res.type(meta.contentType); - const stream = fs.createReadStream(dataPath); - stream.on('error', () => { - if (!res.headersSent) res.status(500).send('Cache read error'); - }); - return stream.pipe(res); - } - } catch (_) {} - } + try { + await fsPromises.access(dataPath); + await fsPromises.access(metaPath); + + const metaRaw = await fsPromises.readFile(metaPath, 'utf8'); + const meta = JSON.parse(metaRaw); + if (meta.contentType && meta.contentType.toLowerCase().startsWith('image/')) { + res.set('Cache-Control', 'public, max-age=315360000, immutable'); + res.type(meta.contentType); + const stream = fs.createReadStream(dataPath); + stream.on('error', () => { + if (!res.headersSent) res.status(500).send('Cache read error'); + }); + return stream.pipe(res); + } + } catch (_) {} console.error(`[upstream-failed] url=${upstreamUrl} err=${err.message}`); return res.status(502).type('text/plain').send('Upstream error'); } @@ -311,6 +338,92 @@ app.use('/mshots/v1', async (req, res) => { // 拼接完整上游 URL const upstreamUrl = UPSTREAM_HOST + '/mshots/v1/' + targetUrl; + // 浏览器访问优化:如果 Accept 包含 text/html 且没有 ?raw=true 参数 + // 返回一个带有加载动画的 HTML 页面,前端再请求真实图片 + if (req.headers.accept && req.headers.accept.includes('text/html') && !req.query.raw) { + const rawUrl = req.originalUrl.includes('?') + ? `${req.originalUrl}&raw=true` + : `${req.originalUrl}?raw=true`; + + const html = ` + + +
+ + +