const express = require('express'); const axios = require('axios'); const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); const net = require('net'); require('dotenv').config(); const app = express(); const PORT = Number(process.env.PORT) || 11489; const UPSTREAM_HOST = 'https://s0.wp.com'; const CACHE_DIR = path.join(process.cwd(), 'cache'); fs.mkdirSync(CACHE_DIR, { recursive: true }); /** * 计算 SHA1 哈希 * @param {string} input * @returns {string} */ function sha1(input) { return crypto.createHash('sha1').update(input).digest('hex'); } /** * 获取缓存文件路径 * @param {string} key * @returns {{data: string, meta: string}} */ function getCachePaths(key) { return { data: path.join(CACHE_DIR, `${key}.data`), meta: path.join(CACHE_DIR, `${key}.json`), }; } /** * 验证响应是否为有效的图片 * @param {number} status * @param {object} headers * @param {any} data * @returns {boolean} */ function isValidImageResponse(status, headers, data) { const ct = (headers['content-type'] || '').toLowerCase(); const lenHeader = headers['content-length']; const len = Array.isArray(data) ? data.length : (data?.byteLength || 0); const hasPositiveLength = (lenHeader ? parseInt(lenHeader, 10) > 0 : len > 0); // 过滤掉 GIF 图片 (通常是 mShots 的 "Generating" 占位图,约 9KB) // 我们不缓存这些图片,以便下次请求时能再次尝试获取真实截图 if (ct.includes('image/gif') && len < 15000) { return false; } return status === 200 && ct.startsWith('image/') && hasPositiveLength; } /** * 检查主机是否开放 443 端口 (简单的 SSL 判断) * @param {string} host * @returns {Promise} */ function checkPort443(host) { return new Promise(resolve => { // 默认超时 1.5 秒,避免阻塞太久 const socket = net.connect(443, host); socket.setTimeout(1500); socket.on('connect', () => { socket.end(); resolve(true); }); socket.on('error', () => { resolve(false); }); socket.on('timeout', () => { socket.destroy(); resolve(false); }); }); } /** * 解析目标 URL,支持自动补全协议 * @param {string} rawPath * @returns {Promise} */ async function resolveTargetUrl(rawPath) { // 去除开头的 / let target = rawPath.startsWith('/') ? rawPath.slice(1) : rawPath; // 如果已经包含协议,直接返回 if (target.startsWith('http://') || target.startsWith('https://')) { return target; } // 提取主机名 let host = target.split('/')[0].split('?')[0]; // 去除端口号(如果存在) const colonIndex = host.indexOf(':'); if (colonIndex !== -1) { host = host.substring(0, colonIndex); } // 尝试检测 SSL const isHttps = await checkPort443(host); if (!isHttps) { console.log(`[protocol-detect] ${host} : 443 port closed or timeout, falling back to HTTP.`); } return isHttps ? `https://${target}` : `http://${target}`; } /** * 请求上游并处理重试 * @param {string} upstreamUrl * @param {number} tries * @returns {Promise} */ async function fetchUpstreamWithRetry(upstreamUrl, tries = 2) { let lastErr = null; for (let i = 0; i < tries; i++) { try { return await axios.get(upstreamUrl, { responseType: 'arraybuffer', headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120 Safari/537.36', 'Accept': 'image/avif,image/webp,image/apng,image/*;q=0.8,*/*;q=0.5', 'Host': 's0.wp.com', }, maxRedirects: 5, timeout: 20000, // 禁用代理环境变量的干扰 proxy: false, // 明确允许非 2xx 也返回给上层判断 validateStatus: () => true, }); } catch (err) { lastErr = err; console.error(`[upstream-error] try=${i + 1} url=${upstreamUrl} msg=${err.message}`); // 简单退避 await new Promise(r => setTimeout(r, 300)); } } if (lastErr) throw lastErr; } /** * 核心处理逻辑:检查缓存 -> 回源 -> 写入缓存 -> 返回响应 * @param {object} res * @param {string} upstreamUrl */ async function handleProxyRequest(res, upstreamUrl) { const key = sha1(upstreamUrl); const { data: dataPath, meta: metaPath } = getCachePaths(key); 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) { // 缓存元数据损坏,忽略,继续回源 } } // 2. 回源请求 const resp = await fetchUpstreamWithRetry(upstreamUrl); // 3. 仅缓存有效图片 if (isValidImageResponse(resp.status, resp.headers, resp.data)) { const contentType = resp.headers['content-type'] || 'image/jpeg'; const meta = { url: upstreamUrl, contentType, size: resp.data.byteLength, createdAt: new Date().toISOString(), }; try { fs.writeFileSync(dataPath, resp.data); fs.writeFileSync(metaPath, JSON.stringify(meta)); } catch (e) { // 写盘失败也继续返回 } res.type(contentType); res.set('Cache-Control', 'public, max-age=315360000, immutable'); return res.send(resp.data); } // 4. 非图片或非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 (_) {} } // 否则透传上游响应 res.status(resp.status); if (resp.headers['content-type']) res.type(resp.headers['content-type']); return res.send(resp.data); } 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 (_) {} } console.error(`[upstream-failed] url=${upstreamUrl} err=${err.message}`); return res.status(502).type('text/plain').send('Upstream error'); } } // 反代 mShots,路径 /mshots/v1/... app.use('/mshots/v1', async (req, res) => { if (req.method !== 'GET') { return res.status(405).type('text/plain').send('Method Not Allowed'); } // 这里的 req.originalUrl 包含 /mshots/v1 前缀 // 我们假设用户可能访问 /mshots/v1/www.baidu.com // 需要提取出后面的部分进行解析 const prefix = '/mshots/v1'; let pathPart = req.originalUrl; if (pathPart.startsWith(prefix)) { pathPart = pathPart.slice(prefix.length); } // 解析目标 URL(补全协议) const targetUrl = await resolveTargetUrl(pathPart); // 拼接完整上游 URL const upstreamUrl = UPSTREAM_HOST + '/mshots/v1/' + targetUrl; return handleProxyRequest(res, upstreamUrl); }); // 根路径处理:/https://example.com 或 /www.baidu.com app.use(async (req, res) => { if (req.method !== 'GET') { return res.status(405).type('text/plain').send('Method Not Allowed'); } if (req.path === '/') { return res.type('text/plain').send('mShots proxy is running. Try /https://www.baidu.com or /www.baidu.com'); } // 解析目标 URL(补全协议) const targetUrl = await resolveTargetUrl(req.originalUrl); // 拼接完整上游 URL const upstreamUrl = UPSTREAM_HOST + '/mshots/v1/' + targetUrl; return handleProxyRequest(res, upstreamUrl); }); // 简单根路由健康检查 // 根路径健康由上面的 app.use 处理 app.listen(PORT, () => { console.log(`Proxy running at http://localhost:${PORT}`); });