292 lines
9.0 KiB
JavaScript
292 lines
9.0 KiB
JavaScript
|
|
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<boolean>}
|
|||
|
|
*/
|
|||
|
|
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<string>}
|
|||
|
|
*/
|
|||
|
|
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<any>}
|
|||
|
|
*/
|
|||
|
|
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}`);
|
|||
|
|
});
|