wea-df-demo / server.js
luckiness's picture
Update server.js
f50cce8 verified
const express = require('express');
const fetch = require('node-fetch');
const app = express();
const PORT = process.env.PORT || 8000;
// 替换 Deno 的 Base64 编解码函数
function encodeBase64(data) {
return Buffer.from(data).toString('base64');
}
function decodeBase64(str) {
return Buffer.from(str, 'base64');
}
// 随机域名列表,与原脚本相同
const domains = [
'https://luckiness-wea-df-demo.hf.space'
];
const CACHE_TTL = 5 * 60 * 1000; // 缓存有效期:5分钟
// 简单的内存缓存实现
class MemoryCache {
constructor() {
this.cache = new Map();
}
get(key) {
const item = this.cache.get(key);
if (!item) return null;
// 检查缓存是否过期
if (Date.now() - item.timestamp > CACHE_TTL) {
this.cache.delete(key);
return null;
}
return item.data;
}
set(key, data) {
this.cache.set(key, {
data,
timestamp: Date.now()
});
}
// 清理过期缓存
cleanup() {
const now = Date.now();
for (const [key, item] of this.cache.entries()) {
if (now - item.timestamp > CACHE_TTL) {
this.cache.delete(key);
}
}
}
}
// 创建缓存实例
const cache = new MemoryCache();
// 定期清理缓存
setInterval(() => cache.cleanup(), CACHE_TTL);
// 特定 user-agent,用于模拟手机浏览器访问
const iPhoneUserAgent =
"Mozilla/5.0 (iPhone; CPU iPhone OS 16_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1";
// PNG转WebP的URL转换
function urlPngToWebp(url) {
if (url.includes("/webp/") && url.endsWith(".png"))
return url.replace(/\.png$/, ".webp");
return url;
}
/**
* 字符串转base64
* @param str
*/
function stringToBase64(str) {
return Buffer.from(str).toString('base64');
}
function getRandomAZ2() {
const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
let result = '';
for (let i = 0; i < 2; i++) {
const idx = Math.floor(Math.random() * chars.length);
result += chars[idx];
}
return result;
}
/**
* 获取图片代理地址
* @param pathName
* @param imageUrl
*/
const getProxyImageUrl = (pathName, imageUrl) => {
pathName = domains[Math.floor(Math.random() * domains.length)];
const bfStr = stringToBase64(imageUrl);
const str = getRandomAZ2() + bfStr;
let url = `${pathName}/img/${str}`;
return url;
};
// 添加请求超时和重试功能的工具函数
async function fetchWithTimeout(url, options = {}, timeout = 10000, retries = 2) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);
options.signal = controller.signal;
let lastError;
for (let attempt = 0; attempt <= retries; attempt++) {
try {
if (attempt > 0) {
console.log(`[${new Date().toISOString()}] 重试请求 (${attempt}/${retries}): ${url}`);
}
const response = await fetch(url, options);
clearTimeout(timeoutId);
return response;
} catch (error) {
lastError = error;
console.error(`[${new Date().toISOString()}] 请求失败 (${attempt}/${retries}): ${url}`, error.message);
if (error.name === 'AbortError') {
throw new Error(`请求超时 (${timeout}ms): ${url}`);
}
if (attempt === retries) {
throw new Error(`请求失败 (已重试${retries}次): ${error.message}`);
}
// 等待一段时间后重试
await new Promise(resolve => setTimeout(resolve, 1000 * attempt));
}
}
throw lastError;
}
// 处理天气数据
async function handleWeatherCnData(cacheKey, origin) {
try {
console.log(`[${new Date().toISOString()}] 获取天气数据`);
const res = await fetchWithTimeout("https://m.weathercn.com/weatherMap.do?partner=1000001071_hfaw&language=zh-cn&id=2332685&p_source=&p_type=jump&seadId=&cpoikey=", {
headers: {
"User-Agent": iPhoneUserAgent,
"Host": "m.weathercn.com",
},
}, 15000, 3);
// 检查响应状态
if (!res.ok) {
throw new Error(`服务器返回错误状态: ${res.status} ${res.statusText}`);
}
const html = await res.text();
const match = html.match(/let\s+DATA\s*=\s*(\{[\s\S]+?\});/);
if (!match) {
throw new Error("未找到天气数据(DATA)");
}
// 注意:在生产环境中谨慎使用 eval
const DATA = new Function(`${match[0]}; return DATA;`)(); // ⚠️ 请确保来源可信
// 验证数据结构
if (!DATA || typeof DATA !== 'object') {
throw new Error("无效的天气数据结构");
}
// 处理图片 URL
for (const key in DATA) {
const item = DATA[key];
if (item.pic != null) {
for (let i = 0; i < item.pic.length; i++) {
item.pic[i] = getProxyImageUrl(origin, item.pic[i]);
}
} else if (item.result && item.result.picture_url) {
for (let i = 0; i < item.result.picture_url.length; i++) {
item.result.picture_url[i] = getProxyImageUrl(origin, item.result.picture_url[i]);
}
}
}
// 缓存数据
cache.set(cacheKey, {
body: Buffer.from(JSON.stringify(DATA)),
status: 200,
headers: {'content-type': 'application/json'}
});
return DATA;
} catch (err) {
console.error(`[${new Date().toISOString()}] 获取天气数据错误:`, err);
throw err;
}
}
// 处理短临降水数据
async function handleDuanlinData(pathname, cacheKey, origin) {
try {
const baseUrl = "https://img.weather.com.cn";
const target = baseUrl + pathname.replace("/duanlin", "/mpfv3");
const res = await fetchWithTimeout(target, {
headers: {
"User-Agent": iPhoneUserAgent,
"accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7",
"Host": "img.weather.com.cn",
},
}, 15000, 3);
// 检查响应状态
if (!res.ok) {
throw new Error(`服务器返回错误状态: ${res.status} ${res.statusText}`);
}
const html = await res.text();
// 检查响应内容
if (!html || html.trim() === '' || html.indexOf('{') === -1) {
console.error("Invalid response content:", html);
throw new Error("无效的响应内容,未找到JSON数据");
}
// 尝试解析JSON,增加错误处理
let data;
try {
const jsonStartIndex = html.indexOf('{');
const jsonContent = html.substring(jsonStartIndex);
data = JSON.parse(jsonContent);
} catch (jsonError) {
console.error("JSON解析错误:", jsonError, "原始内容:", html);
throw new Error(`JSON解析失败: ${jsonError.message}`);
}
// 验证必要的数据结构
if (!data.value || !Array.isArray(data.value) || data.value.length === 0) {
throw new Error("数据结构不完整或为空");
}
const imageUrl = baseUrl + "/mpfv3/";
const imageList = [];
const times = [];
for (let i = data.value.length - 1; i >= 0; i--) {
const item = data.value[i];
// 验证数据项结构
if (!item.date || !Array.isArray(item.date) || item.date.length === 0 ||
!item.time || !Array.isArray(item.time) ||
!item.path || !Array.isArray(item.path)) {
console.warn(`跳过无效数据项 #${i}:`, item);
continue;
}
const time = item.date[0].substring(0, 8);
// 修复类型错误
const reversedTimes = [...item.time].reverse();
const reversedPaths = [...item.path].reverse();
console.log('短临:'+origin)
times.push(...reversedTimes.map(m => time + "" + m));
imageList.push(...reversedPaths.map(v => getProxyImageUrl(origin, imageUrl + v)));
}
// 验证是否有有效数据
if (times.length === 0 || imageList.length === 0) {
throw new Error("未找到有效的时间或图片数据");
}
// 安全地获取 stime 值
let stime = 0;
if (data.stime && typeof data.stime === 'string') {
stime = Number(data.stime.replace(/\D/g, "")) || 0;
}
const type = [];
for (let s = 0; s < times.length; s++) {
const time = Number(times[s].replace(/\D/g, ""));
type.push(stime > time ? 1 : 2);
}
const dataParams = {
rain_dl: {
time: {
obstime: data.obstime || "",
stime: data.stime || "",
},
pics_location_range: {
bottom_lat: 10.160640206803123,
left_lon: 73.44630749105424,
top_lat: 53.560640206803123,
right_lon: 135.09,
},
result: {
picture_url: imageList,
forecast_time_list: times,
type: type
},
pic_type: "precipitation",
},
};
// 缓存数据
cache.set(cacheKey, {
body: Buffer.from(JSON.stringify(dataParams)),
status: 200,
headers: {'content-type': 'application/json'}
});
return dataParams;
} catch (error) {
console.error("Error processing duanlin data:", error);
// 返回一个空的有效数据结构,而不是抛出错误
return {
rain_dl: {
time: {
obstime: "",
stime: "",
},
pics_location_range: {
bottom_lat: 10.160640206803123,
left_lon: 73.44630749105424,
top_lat: 53.560640206803123,
right_lon: 135.09,
},
result: {
picture_url: [],
forecast_time_list: [],
type: []
},
pic_type: "precipitation",
error: error.message
},
};
}
}
// 处理图片代理
async function handleImageProxy(pathname, cacheKey) {
try {
const encodedUrl = pathname.replace("/img/", "");
let decodedUrl = null;
// 1. 先尝试 decodeURIComponent
try {
const url = decodeURIComponent(encodedUrl);
if (url.startsWith("http://") || url.startsWith("https://")) {
decodedUrl = url;
}
} catch (error) {
console.log(`[${new Date().toISOString()}] URL解码失败: ${error.message}`);
}
// 2. 如果不是合法 URL,再尝试 Base64 解码
if (!decodedUrl) {
try {
let base64Decoded = decodeBase64(encodedUrl.substring(2)).toString();
console.log(`[${new Date().toISOString()}] Base64解码第一次: ${base64Decoded}`);
if (base64Decoded.startsWith("https://luckiness")) {
const two = base64Decoded.split("/img/")[1].substring(2);
base64Decoded = decodeBase64(two).toString();
console.log(`[${new Date().toISOString()}] Base64解码第二次: ${base64Decoded}`);
}
if (
base64Decoded.startsWith("http://") ||
base64Decoded.startsWith("https://")
) {
decodedUrl = base64Decoded;
}
} catch (error) {
console.log(`[${new Date().toISOString()}] Base64解码失败: ${error.message}`);
}
}
if (!decodedUrl) {
throw new Error("无效的图片URL");
}
console.log(`[${new Date().toISOString()}] 处理图片: ${decodedUrl}`);
decodedUrl = urlPngToWebp(decodedUrl);
const res = await fetchWithTimeout(decodedUrl, {
headers: {
"User-Agent": iPhoneUserAgent,
"accept": "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8",
"Referer": new URL(decodedUrl).origin
},
}, 15000, 2);
// 检查响应状态
if (!res.ok) {
throw new Error(`图片服务器返回错误状态: ${res.status} ${res.statusText}`);
}
const contentType = res.headers.get("content-type") || "image/png";
const body = await res.arrayBuffer();
const imageBuffer = Buffer.from(body);
// 缓存响应
cache.set(cacheKey, {
body: imageBuffer,
status: res.status,
headers: {
'content-type': contentType,
'cache-control': 'public, max-age=86400' // 客户端缓存1天
}
});
return { buffer: imageBuffer, contentType, status: res.status };
} catch (error) {
console.error(`[${new Date().toISOString()}] 图片代理错误:`, error);
throw error;
}
}
// 路由处理
app.use(express.json());
app.use(express.raw({ limit: '50mb', type: () => true }));
// 中间件:处理所有请求的通用逻辑
app.use((req, res, next) => {
const pathname = req.path + (req.url.includes('?') ? req.url.substring(req.url.indexOf('?')) : '');
const cacheKey = `${req.method}:${pathname}`;
// 检查缓存(仅对GET请求)
if (req.method === "GET") {
const cachedResponse = cache.get(cacheKey);
if (cachedResponse) {
res.status(cachedResponse.status);
for (const [key, value] of Object.entries(cachedResponse.headers)) {
if (Array.isArray(value)) {
res.set(key, value[0]);
} else {
res.set(key, value);
}
}
return res.send(cachedResponse.body);
}
}
// 如果没有缓存命中,继续处理请求
next();
});
// 健康检查路由
app.get('/health', (req, res) => {
console.log(`[${new Date().toISOString()}] 健康检查请求`);
res.status(200).json({
status: 'ok',
uptime: process.uptime(),
timestamp: Date.now()
});
});
// 根路由 - 服务状态页面
app.get('/', (req, res) => {
const html = `
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>demo服务</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
max-width: 800px;
margin: 0 auto;
padding: 20px;
line-height: 1.6;
}
h1 {
color: #0066cc;
border-bottom: 1px solid #eee;
padding-bottom: 10px;
}
.status {
background: #f8f9fa;
border-radius: 4px;
padding: 15px;
margin: 20px 0;
}
.status-ok {
color: #28a745;
font-weight: bold;
}
.endpoints {
margin-top: 30px;
}
.endpoint {
margin-bottom: 10px;
padding: 10px;
border-left: 3px solid #0066cc;
background: #f8f9fa;
}
code {
background: #eee;
padding: 2px 4px;
border-radius: 3px;
}
</style>
</head>
<body>
<h1>demo服务</h1>
<div class="status">
<p>服务状态: <span class="status-ok">运行中</span></p>
<p>启动时间: ${new Date(Date.now() - process.uptime() * 1000).toLocaleString()}</p>
</div>
</body>
</html>
`;
res.send(html);
});
// 1. 获取并解析天气数据
app.get('/weathercn-data/', async (req, res) => {
try {
console.log(`[${new Date().toISOString()}] 处理天气数据请求`);
const cacheKey = req.url;
const cachedData = cache.get(cacheKey);
if (cachedData) {
console.log(`[${new Date().toISOString()}] 使用缓存的天气数据`);
res.status(cachedData.status);
for (const [key, value] of Object.entries(cachedData.headers)) {
res.setHeader(key, value);
}
return res.send(cachedData.body);
}
const origin = req.headers.origin || `${req.protocol}://${req.get('host')}`;
const data = await handleWeatherCnData(cacheKey, origin);
if (data) {
return res.json(data);
} else {
throw new Error("处理天气数据失败");
}
} catch (error) {
console.error(`[${new Date().toISOString()}] 天气数据请求错误:`, error);
return res.status(500).json({
error: "处理天气数据时发生错误",
message: error.message
});
}
});
// 2. 代理请求:/mpf/*
app.all('/mpf/*', async (req, res) => {
try {
console.log(`[${new Date().toISOString()}] 处理MPF请求: ${req.path}`);
const pathname = req.path;
const cacheKey = req.url;
const cachedData = cache.get(cacheKey);
if (cachedData) {
console.log(`[${new Date().toISOString()}] 使用缓存的MPF数据: ${cacheKey}`);
res.status(cachedData.status);
for (const [key, value] of Object.entries(cachedData.headers)) {
res.setHeader(key, value);
}
return res.send(cachedData.body);
}
const target = `https://mpf.weather.com.cn${pathname.replace('/mpf', '')}${req.url.includes('?') ? req.url.substring(req.url.indexOf('?')) : ''}`;
const response = await fetchWithTimeout(target, {
method: req.method,
headers: {
'User-Agent': iPhoneUserAgent,
'Referer': 'https://m.weather.com.cn/',
'Host': 'mpf.weather.com.cn'
},
body: req.method !== "GET" && req.method !== "HEAD" ? req.body : undefined
}, 15000, 2);
const status = response.status;
const headers = {};
response.headers.forEach((value, key) => {
if (!['content-encoding', 'content-length'].includes(key)) {
headers[key] = value;
}
});
const body = await response.arrayBuffer();
const buffer = Buffer.from(body);
if (req.method === "GET" && status === 200) {
cache.set(cacheKey, { body: buffer, status, headers });
}
res.status(status);
for (const [key, value] of Object.entries(headers)) {
res.setHeader(key, value);
}
return res.send(buffer);
} catch (error) {
console.error(`[${new Date().toISOString()}] MPF请求错误:`, error);
return res.status(500).json({
error: "处理MPF数据时发生错误",
message: error.message
});
}
});
// 3. 特别处理 /duanlin/* 返回加工后的降水图数据
app.get("/duanlin/*", async (req, res) => {
try {
console.log(`[${new Date().toISOString()}] 处理短临降水请求: ${req.path}`);
const cacheKey = req.url;
const cachedData = cache.get(cacheKey);
if (cachedData) {
console.log(`[${new Date().toISOString()}] 使用缓存的短临降水数据: ${cacheKey}`);
res.status(cachedData.status);
for (const [key, value] of Object.entries(cachedData.headers)) {
res.setHeader(key, value);
}
return res.send(cachedData.body);
}
const origin = req.headers.origin || `${req.protocol}://${req.get('host')}`;
const data = await handleDuanlinData(req.path, cacheKey, origin);
if (data && data.rain_dl) {
if (data.rain_dl.error) {
console.warn(`[${new Date().toISOString()}] 短临降水数据处理警告: ${data.rain_dl.error}`);
}
return res.json(data);
} else {
throw new Error("处理短临降水数据失败");
}
} catch (error) {
console.error(`[${new Date().toISOString()}] 短临降水数据处理错误:`, error);
return res.status(500).json({
error: "处理短临降水数据时发生错误",
message: error.message
});
}
});
// 4. 小米代理
app.get('/wtr-v3/*', async (req, res) => {
try {
console.log(`[${new Date().toISOString()}] 处理小米天气请求: ${req.path}`);
const pathname = req.path;
const cacheKey = req.url;
const cachedData = cache.get(cacheKey);
if (cachedData) {
console.log(`[${new Date().toISOString()}] 使用缓存的小米天气数据: ${cacheKey}`);
res.status(cachedData.status);
for (const [key, value] of Object.entries(cachedData.headers)) {
res.setHeader(key, value);
}
return res.send(cachedData.body);
}
const target = `https://weatherapi.market.xiaomi.com${pathname}${req.url.includes('?') ? req.url.substring(req.url.indexOf('?')) : ''}`;
const response = await fetchWithTimeout(target, {
headers: {
'User-Agent': iPhoneUserAgent
}
}, 15000, 2);
const status = response.status;
const headers = {};
response.headers.forEach((value, key) => {
if (!['content-encoding', 'content-length'].includes(key)) {
headers[key] = value;
}
});
const body = await response.arrayBuffer();
const buffer = Buffer.from(body);
if (status === 200) {
cache.set(cacheKey, { body: buffer, status, headers });
}
res.status(status);
for (const [key, value] of Object.entries(headers)) {
res.setHeader(key, value);
}
return res.send(buffer);
} catch (error) {
console.error(`[${new Date().toISOString()}] 小米天气请求错误:`, error);
return res.status(500).json({
error: "处理小米天气数据时发生错误",
message: error.message
});
}
});
// 5. 图片代理 /img/*
app.get('/img/*', async (req, res) => {
try {
console.log(`[${new Date().toISOString()}] 处理图片代理请求: ${req.path}`);
const pathname = req.path;
const cacheKey = req.url;
const cachedData = cache.get(cacheKey);
if (cachedData) {
console.log(`[${new Date().toISOString()}] 使用缓存的图片: ${cacheKey}`);
res.status(cachedData.status);
for (const [key, value] of Object.entries(cachedData.headers)) {
res.setHeader(key, value);
}
return res.send(cachedData.buffer||cachedData.body);
}
const target = pathname.replace('/img/', '');
const response = await handleImageProxy(pathname, cacheKey);
const status = response.status;
const headers = {contentType:response.contentType};
const buffer =response.buffer;
res.status(status);
for (const [key, value] of Object.entries(headers)) {
res.setHeader(key, value);
}
return res.send(buffer);
} catch (error) {
console.error(`[${new Date().toISOString()}] 图片代理请求错误:`, error);
return res.status(500).json({
error: "处理图片代理请求时发生错误",
message: error.message
});
}
});
// 6. d3代理
app.get('/d3/*', async (req, res) => {
try {
console.log(`[${new Date().toISOString()}] 处理D3天气请求: ${req.path}`);
const pathname = req.path;
const cacheKey = req.url;
const cachedData = cache.get(cacheKey);
if (cachedData) {
console.log(`[${new Date().toISOString()}] 使用缓存的D3天气数据: ${cacheKey}`);
res.status(cachedData.status);
for (const [key, value] of Object.entries(cachedData.headers)) {
res.setHeader(key, value);
}
return res.send(cachedData.body);
}
//https://d3.weather.com.cn 证书过期了先用http
const target = `http://d3.weather.com.cn${pathname.replace('/d3', '')}${req.url.includes('?') ? req.url.substring(req.url.indexOf('?')) : ''}`;
const response = await fetchWithTimeout(target, {
headers: {
'User-Agent': iPhoneUserAgent,
'Referer': 'http://m.weather.com.cn/' //'https://m.weather.com.cn/'
}
}, 15000, 2);
const status = response.status;
const headers = {};
response.headers.forEach((value, key) => {
if (!['content-encoding', 'content-length'].includes(key)) {
headers[key] = value;
}
});
const body = await response.arrayBuffer();
const buffer = Buffer.from(body);
if (status === 200) {
cache.set(cacheKey, { body: buffer, status, headers });
}
res.status(status);
for (const [key, value] of Object.entries(headers)) {
res.setHeader(key, value);
}
return res.send(buffer);
} catch (error) {
console.error(`[${new Date().toISOString()}] D3天气请求错误:`, error);
return res.status(500).json({
error: "处理D3天气数据时发生错误",
message: error.message
});
}
});
// 7. d4代理
app.get('/d4/*', async (req, res) => {
try {
console.log(`[${new Date().toISOString()}] 处理D4天气请求: ${req.path}`);
const pathname = req.path;
const cacheKey = req.url;
const cachedData = cache.get(cacheKey);
if (cachedData) {
console.log(`[${new Date().toISOString()}] 使用缓存的D4天气数据: ${cacheKey}`);
res.status(cachedData.status);
for (const [key, value] of Object.entries(cachedData.headers)) {
res.setHeader(key, value);
}
return res.send(cachedData.body);
}
const target = `https://d4.weather.com.cn${pathname.replace('/d4', '')}${req.url.includes('?') ? req.url.substring(req.url.indexOf('?')) : ''}`;
const response = await fetchWithTimeout(target, {
headers: {
'User-Agent': iPhoneUserAgent,
'Referer': 'https://m.weather.com.cn/'
}
}, 15000, 2);
const status = response.status;
const headers = {};
response.headers.forEach((value, key) => {
if (!['content-encoding', 'content-length'].includes(key)) {
headers[key] = value;
}
});
const body = await response.arrayBuffer();
const buffer = Buffer.from(body);
if (status === 200) {
cache.set(cacheKey, { body: buffer, status, headers });
}
res.status(status);
for (const [key, value] of Object.entries(headers)) {
res.setHeader(key, value);
}
return res.send(buffer);
} catch (error) {
console.error(`[${new Date().toISOString()}] D4天气请求错误:`, error);
return res.status(500).json({
error: "处理D4天气数据时发生错误",
message: error.message
});
}
});
// 添加全局错误处理中间件
app.use((err, req, res, next) => {
console.error(`[${new Date().toISOString()}] 全局错误: ${err.message}`);
console.error(err.stack);
res.status(500).json({
error: '服务器内部错误',
message: err.message
});
});
// 404处理
app.use((req, res) => {
console.log(`[${new Date().toISOString()}] 404 未找到: ${req.originalUrl}`);
res.status(404).json({ error: 'Not Found' });
});
// 启动服务器
app.listen(PORT, () => {
console.log(`[${new Date().toISOString()}] 服务器运行在端口 ${PORT}`);
});
module.exports = app; // 为了在 Hugging Face 部署可能需要导出 app