1
0
Files
WordPress-mShots-Proxy/server.js
Snowz 781bc3bd86 feat: 新增WordPress mShots代理服务实现
实现基于Node.js的WordPress mShots反向代理服务,主要功能包括:
- 代理请求到上游服务并缓存响应
- 自动检测目标主机SSL支持
- 提供缓存兜底机制
- 支持省略协议自动补全
2026-01-20 03:08:15 +08:00

292 lines
9.0 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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}`);
});