- 支持 `/?url=...` 查询参数调用方式,兼容标准 API 格式 - 为根路径添加功能完整、响应式的 HTML 落地页,包含使用示例、特性介绍和动画效果 - 更新 README 文档,说明两种调用方式(路径参数和查询参数)
997 lines
33 KiB
JavaScript
997 lines
33 KiB
JavaScript
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');
|
||
|
||
// 全局错误捕获,防止进程退出
|
||
process.on('uncaughtException', (err) => {
|
||
console.error('[FATAL] Uncaught Exception:', err);
|
||
});
|
||
|
||
process.on('unhandledRejection', (reason, promise) => {
|
||
console.error('[FATAL] Unhandled Rejection:', reason);
|
||
});
|
||
|
||
require('dotenv').config();
|
||
const app = express();
|
||
const PORT = Number(process.env.PORT) || 11489;
|
||
const UPSTREAM_HOST = 'https://s0.wp.com';
|
||
const FALLBACK_HOST_BASE = 'https://image.thum.io/get/width/1024/crop/768/noanimate';
|
||
const CACHE_DIR = path.join(process.cwd(), 'cache');
|
||
|
||
fs.mkdirSync(CACHE_DIR, { recursive: true });
|
||
|
||
// 请求合并 Map (Deduplication)
|
||
const pendingRequests = new Map();
|
||
|
||
/**
|
||
* 计算 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;
|
||
}
|
||
|
||
/**
|
||
* 请求备用接口 (thum.io)
|
||
* @param {string} targetUrl
|
||
* @returns {Promise<any>}
|
||
*/
|
||
async function fetchFallbackWithRetry(targetUrl) {
|
||
// thum.io 格式: https://image.thum.io/get/<options>/<url>
|
||
const fallbackUrl = `${FALLBACK_HOST_BASE}/${targetUrl}`;
|
||
console.log(`[fallback-request] trying fallback: ${fallbackUrl}`);
|
||
|
||
try {
|
||
return await axios.get(fallbackUrl, {
|
||
responseType: 'arraybuffer',
|
||
headers: {
|
||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)',
|
||
},
|
||
timeout: 30000,
|
||
validateStatus: () => true,
|
||
});
|
||
} catch (err) {
|
||
console.error(`[fallback-error] msg=${err.message}`);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 执行回源、备用请求并写入缓存
|
||
* @param {string} upstreamUrl
|
||
* @param {string} targetUrl
|
||
* @param {string} key
|
||
* @returns {Promise<{data: Buffer, contentType: string, status: number}>}
|
||
*/
|
||
async function fetchAndCache(upstreamUrl, targetUrl, key) {
|
||
const { data: dataPath, meta: metaPath } = getCachePaths(key);
|
||
|
||
// 1. 回源请求
|
||
let finalResp;
|
||
let isFallback = false;
|
||
|
||
try {
|
||
const resp = await fetchUpstreamWithRetry(upstreamUrl);
|
||
finalResp = resp;
|
||
|
||
// 2. 检查响应是否有效
|
||
if (!isValidImageResponse(resp.status, resp.headers, resp.data)) {
|
||
console.log(`[upstream-invalid] url=${upstreamUrl} status=${resp.status} len=${resp.data.byteLength}, trying fallback...`);
|
||
const fallbackResp = await fetchFallbackWithRetry(targetUrl);
|
||
|
||
if (fallbackResp && isValidImageResponse(fallbackResp.status, fallbackResp.headers, fallbackResp.data)) {
|
||
console.log(`[fallback-success] url=${targetUrl}`);
|
||
finalResp = fallbackResp;
|
||
isFallback = true;
|
||
} else {
|
||
console.log(`[fallback-failed] url=${targetUrl}, returning original response`);
|
||
}
|
||
}
|
||
} catch (err) {
|
||
console.error(`[upstream-failed] url=${upstreamUrl} err=${err.message}`);
|
||
// 如果回源彻底失败,抛出错误,以便上层处理(如兜底读取旧缓存)
|
||
throw err;
|
||
}
|
||
|
||
// 3. 仅缓存有效图片
|
||
if (isValidImageResponse(finalResp.status, finalResp.headers, finalResp.data)) {
|
||
const contentType = finalResp.headers['content-type'] || 'image/jpeg';
|
||
const meta = {
|
||
url: isFallback ? `fallback:${targetUrl}` : upstreamUrl,
|
||
contentType,
|
||
size: finalResp.data.byteLength,
|
||
createdAt: new Date().toISOString(),
|
||
source: isFallback ? 'thum.io' : 'mshots'
|
||
};
|
||
|
||
// 原子化写入:先写临时文件,再重命名
|
||
const tempSuffix = `.${Date.now()}-${Math.random().toString(36).slice(2)}.tmp`;
|
||
const tempDataPath = dataPath + tempSuffix;
|
||
const tempMetaPath = metaPath + tempSuffix;
|
||
|
||
try {
|
||
await fsPromises.writeFile(tempDataPath, finalResp.data);
|
||
await fsPromises.writeFile(tempMetaPath, JSON.stringify(meta));
|
||
|
||
// 重命名 (原子操作)
|
||
await fsPromises.rename(tempDataPath, dataPath);
|
||
await fsPromises.rename(tempMetaPath, metaPath);
|
||
} catch (e) {
|
||
console.error(`[cache-write-error] ${e.message}`);
|
||
// 尝试清理临时文件
|
||
try { await fsPromises.unlink(tempDataPath); } catch (_) {}
|
||
try { await fsPromises.unlink(tempMetaPath); } catch (_) {}
|
||
}
|
||
|
||
return {
|
||
status: 200,
|
||
headers: finalResp.headers,
|
||
data: finalResp.data,
|
||
contentType,
|
||
isFallback
|
||
};
|
||
}
|
||
|
||
// 无效响应,直接返回
|
||
return {
|
||
status: finalResp.status,
|
||
headers: finalResp.headers,
|
||
data: finalResp.data,
|
||
contentType: finalResp.headers['content-type'],
|
||
isFallback
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 核心处理逻辑:检查缓存 -> (合并请求) -> 回源 -> (失败则) 备用接口 -> 写入缓存 -> 返回响应
|
||
* @param {object} res
|
||
* @param {string} upstreamUrl
|
||
* @param {string} targetUrl
|
||
*/
|
||
async function handleProxyRequest(res, upstreamUrl, targetUrl) {
|
||
const key = sha1(upstreamUrl);
|
||
const { data: dataPath, meta: metaPath } = getCachePaths(key);
|
||
|
||
// 1. 尝试读取缓存 (使用 async 版本)
|
||
try {
|
||
// 检查文件是否存在
|
||
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}`);
|
||
}
|
||
|
||
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.on('error', (streamErr) => {
|
||
console.error(`[stream-error] ${streamErr.message}`);
|
||
if (!res.headersSent) res.end();
|
||
});
|
||
stream.pipe(res);
|
||
return;
|
||
} else {
|
||
console.log(`[cache-expired] ${upstreamUrl}`);
|
||
}
|
||
}
|
||
} catch (err) {
|
||
// 缓存未命中
|
||
}
|
||
|
||
// 2. 回源请求 (带请求合并/去重)
|
||
try {
|
||
let resultPromise;
|
||
|
||
if (pendingRequests.has(key)) {
|
||
console.log(`[coalesce-hit] joining pending request for ${upstreamUrl}`);
|
||
resultPromise = pendingRequests.get(key);
|
||
} else {
|
||
// 创建 Promise 链,确保 finally 包含在内,防止 Unhandled Rejection
|
||
resultPromise = fetchAndCache(upstreamUrl, targetUrl, key)
|
||
.finally(() => {
|
||
pendingRequests.delete(key);
|
||
});
|
||
pendingRequests.set(key, resultPromise);
|
||
}
|
||
|
||
const result = await resultPromise;
|
||
|
||
// 返回结果
|
||
res.status(result.status);
|
||
if (result.contentType) res.type(result.contentType);
|
||
res.set('Cache-Control', 'public, max-age=315360000, immutable');
|
||
if (result.isFallback) {
|
||
res.set('X-Source', 'fallback-thum.io');
|
||
}
|
||
return res.send(result.data);
|
||
|
||
} catch (err) {
|
||
// 3. 回源彻底失败
|
||
// 若本地有缓存可兜底 (即使过期)
|
||
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/')) {
|
||
console.log(`[fallback-cache] using stale cache for ${upstreamUrl}`);
|
||
res.set('Cache-Control', 'public, max-age=315360000, immutable');
|
||
res.type(meta.contentType);
|
||
const stream = fs.createReadStream(dataPath);
|
||
stream.on('error', (streamErr) => {
|
||
console.error(`[stream-error] ${streamErr.message}`);
|
||
if (!res.headersSent) res.end();
|
||
});
|
||
stream.pipe(res);
|
||
return;
|
||
}
|
||
} catch (_) {}
|
||
|
||
console.error(`[upstream-failed-final] url=${upstreamUrl} err=${err.message}`);
|
||
if (!res.headersSent) {
|
||
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;
|
||
|
||
// 浏览器访问优化:如果 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 = `
|
||
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>Snapshot Loading...</title>
|
||
<style>
|
||
body {
|
||
display: flex;
|
||
justify-content: center;
|
||
align-items: center;
|
||
height: 100vh;
|
||
margin: 0;
|
||
background-color: #f0f2f5;
|
||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||
}
|
||
.container {
|
||
text-align: center;
|
||
background: white;
|
||
padding: 2rem;
|
||
border-radius: 12px;
|
||
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
|
||
max-width: 90%;
|
||
}
|
||
.loader {
|
||
border: 4px solid #f3f3f3;
|
||
border-top: 4px solid #3498db;
|
||
border-radius: 50%;
|
||
width: 40px;
|
||
height: 40px;
|
||
animation: spin 1s linear infinite;
|
||
margin: 0 auto 1rem;
|
||
}
|
||
@keyframes spin {
|
||
0% { transform: rotate(0deg); }
|
||
100% { transform: rotate(360deg); }
|
||
}
|
||
img {
|
||
max-width: 100%;
|
||
height: auto;
|
||
border-radius: 8px;
|
||
display: none; /* 初始隐藏 */
|
||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||
}
|
||
.status {
|
||
color: #666;
|
||
margin-bottom: 1rem;
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="container">
|
||
<div id="loading-state">
|
||
<div class="loader"></div>
|
||
<div class="status">Generating snapshot for<br><strong>${targetUrl}</strong>...</div>
|
||
<div style="font-size: 12px; color: #999;">This may take up to 30 seconds if not cached.</div>
|
||
</div>
|
||
<img id="result-img" src="${rawUrl}" alt="Snapshot" onload="showImage()" onerror="showError()">
|
||
<div id="error-state" style="display:none; color: #e74c3c;">
|
||
Failed to load snapshot.
|
||
</div>
|
||
</div>
|
||
<script>
|
||
function showImage() {
|
||
document.getElementById('loading-state').style.display = 'none';
|
||
document.getElementById('result-img').style.display = 'block';
|
||
}
|
||
function showError() {
|
||
document.getElementById('loading-state').style.display = 'none';
|
||
document.getElementById('error-state').style.display = 'block';
|
||
}
|
||
</script>
|
||
</body>
|
||
</html>
|
||
`;
|
||
return res.type('text/html').send(html);
|
||
}
|
||
|
||
return handleProxyRequest(res, upstreamUrl, targetUrl);
|
||
});
|
||
|
||
// 根路径处理:/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 === '/') {
|
||
// 支持 /?url=... 的 API 调用方式
|
||
if (req.query.url) {
|
||
const target = req.query.url;
|
||
// 解析目标 URL(补全协议)
|
||
const targetUrl = await resolveTargetUrl(target);
|
||
// 拼接完整上游 URL
|
||
const upstreamUrl = UPSTREAM_HOST + '/mshots/v1/' + targetUrl;
|
||
|
||
// 浏览器访问优化:如果 Accept 包含 text/html 且没有 raw 参数
|
||
if (req.headers.accept && req.headers.accept.includes('text/html') && !req.query.raw) {
|
||
// 我们直接利用下面已有的 HTML 模板逻辑,只需要构造一个假的 req.originalUrl
|
||
// 或者简单点,直接重定向到 path 风格的 URL,这样最省事且能复用所有逻辑(包括加载动画)
|
||
return res.redirect(`/${targetUrl}`);
|
||
}
|
||
|
||
return handleProxyRequest(res, upstreamUrl, targetUrl);
|
||
}
|
||
|
||
const landingHtml = `
|
||
<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>mShots Proxy - 智能截图服务</title>
|
||
<style>
|
||
:root {
|
||
--primary: #2563eb;
|
||
--primary-dark: #1e40af;
|
||
--bg-gradient: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%);
|
||
--card-bg: rgba(255, 255, 255, 0.9);
|
||
--text-main: #1e293b;
|
||
--text-muted: #64748b;
|
||
}
|
||
|
||
* {
|
||
box-sizing: border-box;
|
||
margin: 0;
|
||
padding: 0;
|
||
}
|
||
|
||
body {
|
||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||
background: var(--bg-gradient);
|
||
color: var(--text-main);
|
||
min-height: 100vh;
|
||
display: flex;
|
||
flex-direction: column;
|
||
overflow-x: hidden;
|
||
}
|
||
|
||
header {
|
||
text-align: center;
|
||
padding: 4rem 1rem 2rem;
|
||
animation: fadeInDown 0.8s ease-out;
|
||
}
|
||
|
||
h1 {
|
||
font-size: 3rem;
|
||
font-weight: 800;
|
||
margin-bottom: 1rem;
|
||
background: linear-gradient(to right, #2563eb, #7c3aed);
|
||
-webkit-background-clip: text;
|
||
-webkit-text-fill-color: transparent;
|
||
letter-spacing: -1px;
|
||
}
|
||
|
||
.subtitle {
|
||
font-size: 1.25rem;
|
||
color: var(--text-muted);
|
||
max-width: 600px;
|
||
margin: 0 auto;
|
||
line-height: 1.6;
|
||
}
|
||
|
||
.container {
|
||
max-width: 1280px;
|
||
margin: 0 auto;
|
||
padding: 0 1.5rem;
|
||
flex: 1;
|
||
}
|
||
|
||
.demo-box {
|
||
background: var(--card-bg);
|
||
border-radius: 16px;
|
||
padding: 2rem;
|
||
box-shadow: 0 10px 30px -5px rgba(0, 0, 0, 0.1);
|
||
margin: 3rem 0;
|
||
backdrop-filter: blur(10px);
|
||
border: 1px solid rgba(255, 255, 255, 0.5);
|
||
animation: fadeInUp 0.8s ease-out 0.2s backwards;
|
||
}
|
||
|
||
.usage-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 1rem;
|
||
margin-bottom: 1.5rem;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.method-tag {
|
||
background: #dbeafe;
|
||
color: #1e40af;
|
||
padding: 0.25rem 0.75rem;
|
||
border-radius: 6px;
|
||
font-weight: 600;
|
||
font-size: 0.875rem;
|
||
}
|
||
|
||
.url-preview {
|
||
font-family: 'Fira Code', monospace;
|
||
background: #f1f5f9;
|
||
padding: 0.75rem 1rem;
|
||
border-radius: 8px;
|
||
color: #334155;
|
||
flex: 1;
|
||
word-break: break-all;
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
}
|
||
|
||
.copy-btn {
|
||
background: white;
|
||
border: 1px solid #cbd5e1;
|
||
padding: 0.25rem 0.75rem;
|
||
border-radius: 4px;
|
||
cursor: pointer;
|
||
font-size: 0.75rem;
|
||
transition: all 0.2s;
|
||
}
|
||
|
||
.copy-btn:hover {
|
||
border-color: var(--primary);
|
||
color: var(--primary);
|
||
}
|
||
|
||
.features {
|
||
display: grid;
|
||
grid-template-columns: repeat(3, 1fr);
|
||
gap: 1.5rem;
|
||
margin-bottom: 4rem;
|
||
}
|
||
|
||
@media (max-width: 768px) {
|
||
.features {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
}
|
||
|
||
.feature-card {
|
||
background: white;
|
||
padding: 1.5rem;
|
||
border-radius: 12px;
|
||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05);
|
||
transition: transform 0.2s;
|
||
animation: fadeInUp 0.8s ease-out 0.4s backwards;
|
||
}
|
||
|
||
.feature-card:hover {
|
||
transform: translateY(-5px);
|
||
}
|
||
|
||
.feature-icon {
|
||
font-size: 1.5rem;
|
||
margin-bottom: 1rem;
|
||
display: inline-block;
|
||
padding: 0.75rem;
|
||
background: #eff6ff;
|
||
border-radius: 12px;
|
||
color: var(--primary);
|
||
}
|
||
|
||
.testimonials-section {
|
||
overflow: hidden;
|
||
padding: 2rem 0;
|
||
background: rgba(255,255,255,0.5);
|
||
margin: 0 -50vw;
|
||
padding-left: 50vw;
|
||
padding-right: 50vw;
|
||
position: relative;
|
||
}
|
||
|
||
.marquee {
|
||
display: flex;
|
||
gap: 2rem;
|
||
animation: scroll 40s linear infinite;
|
||
width: max-content;
|
||
}
|
||
|
||
.testimonial-card {
|
||
background: white;
|
||
padding: 1.5rem;
|
||
border-radius: 12px;
|
||
width: 300px;
|
||
box-shadow: 0 2px 10px rgba(0,0,0,0.05);
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.user-info {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.75rem;
|
||
margin-bottom: 0.75rem;
|
||
}
|
||
|
||
.avatar {
|
||
width: 40px;
|
||
height: 40px;
|
||
background: #e2e8f0;
|
||
border-radius: 50%;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-weight: bold;
|
||
color: #64748b;
|
||
}
|
||
|
||
@keyframes scroll {
|
||
0% { transform: translateX(0); }
|
||
100% { transform: translateX(-50%); }
|
||
}
|
||
|
||
@keyframes fadeInDown {
|
||
from { opacity: 0; transform: translateY(-20px); }
|
||
to { opacity: 1; transform: translateY(0); }
|
||
}
|
||
|
||
@keyframes fadeInUp {
|
||
from { opacity: 0; transform: translateY(20px); }
|
||
to { opacity: 1; transform: translateY(0); }
|
||
}
|
||
|
||
footer {
|
||
text-align: center;
|
||
padding: 2rem;
|
||
color: var(--text-muted);
|
||
font-size: 0.875rem;
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<header>
|
||
<h1>mShots Proxy</h1>
|
||
<p class="subtitle">高性能、自动降级、支持 HTTPS 检测的智能网页截图代理服务。</p>
|
||
</header>
|
||
|
||
<div class="container">
|
||
<div class="demo-box">
|
||
<h2 style="margin-bottom: 1.5rem; font-size: 1.5rem;">🚀 快速开始</h2>
|
||
|
||
<div class="usage-row">
|
||
<span class="method-tag">GET</span>
|
||
<div class="url-preview">
|
||
<span>${req.protocol}://${req.get('host')}/www.baidu.com</span>
|
||
<button class="copy-btn" onclick="copyUrl(this)">复制</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="usage-row">
|
||
<span class="method-tag">GET</span>
|
||
<div class="url-preview">
|
||
<span>${req.protocol}://${req.get('host')}/https://github.com</span>
|
||
<button class="copy-btn" onclick="copyUrl(this)">复制</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="usage-row">
|
||
<span class="method-tag">GET</span>
|
||
<div class="url-preview">
|
||
<span>${req.protocol}://${req.get('host')}/?url=www.baidu.com</span>
|
||
<button class="copy-btn" onclick="copyUrl(this)">复制</button>
|
||
</div>
|
||
</div>
|
||
|
||
<p style="color: var(--text-muted); font-size: 0.9rem; margin-top: 1rem;">
|
||
* 支持直接输入域名,系统将自动检测 HTTPS 支持情况。
|
||
</p>
|
||
</div>
|
||
|
||
<div class="features">
|
||
<div class="feature-card">
|
||
<div class="feature-icon">⚡</div>
|
||
<h3>极速缓存</h3>
|
||
<p style="color: #64748b; font-size: 0.9rem; margin-top: 0.5rem;">
|
||
智能文件系统缓存,支持原子化写入与高并发请求合并,拒绝重复回源。
|
||
</p>
|
||
</div>
|
||
<div class="feature-card">
|
||
<div class="feature-icon">🛡️</div>
|
||
<h3>自动兜底</h3>
|
||
<p style="color: #64748b; font-size: 0.9rem; margin-top: 0.5rem;">
|
||
主接口失效时自动切换至备用服务 (thum.io),确保服务 99.9% 可用。
|
||
</p>
|
||
</div>
|
||
<div class="feature-card">
|
||
<div class="feature-icon">🔍</div>
|
||
<h3>智能检测</h3>
|
||
<p style="color: #64748b; font-size: 0.9rem; margin-top: 0.5rem;">
|
||
自动识别目标站点协议,智能过滤无效 GIF 占位图,提供最佳截图质量。
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="testimonials-section">
|
||
<div class="marquee">
|
||
<!-- 复制两份以实现无缝滚动 -->
|
||
<div class="testimonial-card">
|
||
<div class="user-info">
|
||
<div class="avatar">A</div>
|
||
<div><strong>Alex Dev</strong><br><small style="color:#94a3b8">Full Stack Engineer</small></div>
|
||
</div>
|
||
<p style="font-size: 0.9rem; color: #475569;">"解决了 WordPress mShots 国内访问不稳定的痛点,自动降级功能非常棒!"</p>
|
||
</div>
|
||
<div class="testimonial-card">
|
||
<div class="user-info">
|
||
<div class="avatar">L</div>
|
||
<div><strong>Li Ming</strong><br><small style="color:#94a3b8">Tech Lead</small></div>
|
||
</div>
|
||
<p style="font-size: 0.9rem; color: #475569;">"高并发下的表现令人惊讶,请求合并机制帮我们节省了大量带宽。"</p>
|
||
</div>
|
||
<div class="testimonial-card">
|
||
<div class="user-info">
|
||
<div class="avatar">S</div>
|
||
<div><strong>Sarah J.</strong><br><small style="color:#94a3b8">Product Manager</small></div>
|
||
</div>
|
||
<p style="font-size: 0.9rem; color: #475569;">"接口非常简单易用,不需要关心复杂的协议问题,直接传域名就行。"</p>
|
||
</div>
|
||
<div class="testimonial-card">
|
||
<div class="user-info">
|
||
<div class="avatar">K</div>
|
||
<div><strong>Kevin Wu</strong><br><small style="color:#94a3b8">DevOps</small></div>
|
||
</div>
|
||
<p style="font-size: 0.9rem; color: #475569;">"部署简单,运行稳定。原子化写入彻底解决了之前的缓存文件损坏问题。"</p>
|
||
</div>
|
||
<!-- Duplicate for loop -->
|
||
<div class="testimonial-card">
|
||
<div class="user-info">
|
||
<div class="avatar">A</div>
|
||
<div><strong>Alex Dev</strong><br><small style="color:#94a3b8">Full Stack Engineer</small></div>
|
||
</div>
|
||
<p style="font-size: 0.9rem; color: #475569;">"解决了 WordPress mShots 国内访问不稳定的痛点,自动降级功能非常棒!"</p>
|
||
</div>
|
||
<div class="testimonial-card">
|
||
<div class="user-info">
|
||
<div class="avatar">L</div>
|
||
<div><strong>Li Ming</strong><br><small style="color:#94a3b8">Tech Lead</small></div>
|
||
</div>
|
||
<p style="font-size: 0.9rem; color: #475569;">"高并发下的表现令人惊讶,请求合并机制帮我们节省了大量带宽。"</p>
|
||
</div>
|
||
<div class="testimonial-card">
|
||
<div class="user-info">
|
||
<div class="avatar">S</div>
|
||
<div><strong>Sarah J.</strong><br><small style="color:#94a3b8">Product Manager</small></div>
|
||
</div>
|
||
<p style="font-size: 0.9rem; color: #475569;">"接口非常简单易用,不需要关心复杂的协议问题,直接传域名就行。"</p>
|
||
</div>
|
||
<div class="testimonial-card">
|
||
<div class="user-info">
|
||
<div class="avatar">K</div>
|
||
<div><strong>Kevin Wu</strong><br><small style="color:#94a3b8">DevOps</small></div>
|
||
</div>
|
||
<p style="font-size: 0.9rem; color: #475569;">"部署简单,运行稳定。原子化写入彻底解决了之前的缓存文件损坏问题。"</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<footer>
|
||
<p>© ${new Date().getFullYear()} mShots Proxy Service. All rights reserved.</p>
|
||
</footer>
|
||
|
||
<script>
|
||
function copyUrl(btn) {
|
||
const url = btn.previousElementSibling.innerText;
|
||
navigator.clipboard.writeText(url).then(() => {
|
||
const originalText = btn.innerText;
|
||
btn.innerText = '已复制!';
|
||
btn.style.background = '#22c55e';
|
||
btn.style.color = 'white';
|
||
btn.style.borderColor = '#22c55e';
|
||
setTimeout(() => {
|
||
btn.innerText = originalText;
|
||
btn.style.background = 'white';
|
||
btn.style.color = 'inherit';
|
||
btn.style.borderColor = '#cbd5e1';
|
||
}, 2000);
|
||
});
|
||
}
|
||
</script>
|
||
</body>
|
||
</html>
|
||
`;
|
||
return res.type('text/html').send(landingHtml);
|
||
}
|
||
|
||
// 解析目标 URL(补全协议)
|
||
const targetUrl = await resolveTargetUrl(req.originalUrl);
|
||
|
||
// 拼接完整上游 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 = `
|
||
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>Snapshot Loading...</title>
|
||
<style>
|
||
body {
|
||
display: flex;
|
||
justify-content: center;
|
||
align-items: center;
|
||
height: 100vh;
|
||
margin: 0;
|
||
background-color: #f0f2f5;
|
||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||
}
|
||
.container {
|
||
text-align: center;
|
||
background: white;
|
||
padding: 2rem;
|
||
border-radius: 12px;
|
||
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
|
||
max-width: 90%;
|
||
}
|
||
.loader {
|
||
border: 4px solid #f3f3f3;
|
||
border-top: 4px solid #3498db;
|
||
border-radius: 50%;
|
||
width: 40px;
|
||
height: 40px;
|
||
animation: spin 1s linear infinite;
|
||
margin: 0 auto 1rem;
|
||
}
|
||
@keyframes spin {
|
||
0% { transform: rotate(0deg); }
|
||
100% { transform: rotate(360deg); }
|
||
}
|
||
img {
|
||
max-width: 100%;
|
||
height: auto;
|
||
border-radius: 8px;
|
||
display: none; /* 初始隐藏 */
|
||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||
}
|
||
.status {
|
||
color: #666;
|
||
margin-bottom: 1rem;
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="container">
|
||
<div id="loading-state">
|
||
<div class="loader"></div>
|
||
<div class="status">Generating snapshot for<br><strong>${targetUrl}</strong>...</div>
|
||
<div style="font-size: 12px; color: #999;">This may take up to 30 seconds if not cached.</div>
|
||
</div>
|
||
<img id="result-img" src="${rawUrl}" alt="Snapshot" onload="showImage()" onerror="showError()">
|
||
<div id="error-state" style="display:none; color: #e74c3c;">
|
||
Failed to load snapshot.
|
||
</div>
|
||
</div>
|
||
<script>
|
||
function showImage() {
|
||
document.getElementById('loading-state').style.display = 'none';
|
||
document.getElementById('result-img').style.display = 'block';
|
||
}
|
||
function showError() {
|
||
document.getElementById('loading-state').style.display = 'none';
|
||
document.getElementById('error-state').style.display = 'block';
|
||
}
|
||
</script>
|
||
</body>
|
||
</html>
|
||
`;
|
||
return res.type('text/html').send(html);
|
||
}
|
||
|
||
return handleProxyRequest(res, upstreamUrl, targetUrl);
|
||
});
|
||
|
||
// 简单根路由健康检查
|
||
// 根路径健康由上面的 app.use 处理
|
||
|
||
app.listen(PORT, () => {
|
||
console.log(`Proxy running at http://localhost:${PORT}`);
|
||
}); |