Update server.js via upload script - 2026-08-07 15:50:49

This commit is contained in:
mike 2026-08-07 15:51:06 +08:00
parent ebf85a5932
commit 96ed7e7d92
1 changed files with 42 additions and 119 deletions

161
server.js
View File

@ -160,81 +160,52 @@ function tcpCheck(host, port, timeoutMs = 6000) {
// 1) Cloudflare的520/521/522/523/524是"源站不可达"专属状态码能连上CDN但源站挂了会明确返回这几个码
// 2) 再做一次真正的WebSocket握手直接对应Xray/sing-box监听WS的那一层比单看状态码更准。
// 两个都过才算正常。
// 注意连接必须用line.addr真实服务器/CDN边缘地址+ line.port真实端口不能写死443
// 很多自建线路会用非标准端口但HTTP Host头和TLS SNI要用line.host/line.sni伪装域名
// 不能也用addr——如果源站是按Host头路由的Nginx server_name/反代规则Host发错了根本到不了
// 正确的源站,会拿到无关响应甚至连接失败,跟真实客户端的连接行为对不上。
function httpStatusCheck(line, timeoutMs = 8000) {
function httpStatusCheck(host, wsPath, timeoutMs = 8000) {
return new Promise((resolve) => {
const routeName = line.host || line.sni || line.addr;
const req = https.request({
hostname: line.addr,
port: Number(line.port) || 443,
path: line.ws_path || '/',
method: 'GET',
timeout: timeoutMs,
servername: line.sni || routeName, // TLS SNI
headers: { 'User-Agent': 'Mozilla/5.0', Host: routeName },
hostname: host, port: 443, path: wsPath || '/', method: 'GET', timeout: timeoutMs,
headers: { 'User-Agent': 'Mozilla/5.0' },
}, (res) => {
res.resume();
const badGateway = [520, 521, 522, 523, 524].includes(res.statusCode);
resolve({ ok: !badGateway, detail: `HTTP ${res.statusCode}` });
resolve(!badGateway);
});
req.on('timeout', () => { req.destroy(); resolve({ ok: false, detail: 'HTTP请求超时' }); });
req.on('error', (err) => resolve({ ok: false, detail: `HTTP错误${err.code || err.message}` }));
req.on('timeout', () => { req.destroy(); resolve(false); });
req.on('error', () => resolve(false));
req.end();
});
}
function wsHandshakeCheck(line, timeoutMs = 8000) {
function wsHandshakeCheck(host, wsPath, timeoutMs = 8000) {
return new Promise((resolve) => {
let done = false;
let ws;
const finish = (ok, detail) => {
const finish = (ok) => {
if (done) return;
done = true;
try { ws && ws.terminate(); } catch (e) {}
resolve({ ok, detail });
resolve(ok);
};
try {
const port = Number(line.port) || 443;
const routeName = line.host || line.sni || line.addr;
ws = new WebSocket(`wss://${line.addr}:${port}${line.ws_path || '/'}`, {
handshakeTimeout: timeoutMs,
headers: { Host: routeName },
servername: line.sni || routeName, // TLS SNI
});
ws.on('open', () => finish(true, 'WS握手成功'));
ws.on('unexpected-response', (req2, res2) => finish(false, `WS握手被拒绝HTTP ${res2 && res2.statusCode}`));
ws.on('error', (err) => finish(false, `WS错误${err.code || err.message}`));
ws = new WebSocket(`wss://${host}${wsPath || '/'}`, { handshakeTimeout: timeoutMs });
ws.on('open', () => finish(true));
ws.on('unexpected-response', () => finish(false));
ws.on('error', () => finish(false));
} catch (e) {
finish(false, `WS异常${e.message}`);
finish(false);
}
setTimeout(() => finish(false, 'WS握手超时'), timeoutMs + 500);
setTimeout(() => finish(false), timeoutMs + 500);
});
}
// 返回 { ok, detail }。
// ⚠️ vless-cdn的"正常/异常"判定口径改成跟SS/VMess/Reality一致——只看TCP连通性。
// 原因httpStatusCheck/wsHandshakeCheck走的是裸TLS握手如果这台VPS本身处在容易被
// SNI关键字探测的网络环境下比如GFW中间设备可能直接往连接里注入垃圾响应
// OpenSSL解析时报EPROTOwrong version number——这是链路层面被幹扰不代表线路真的不可用
// 真实代理客户端Xray等通常有ClientHello分片等反探测手段裸TLS探测测不出真实可用性。
// TCP握手不发SNI不会被这类探测命中所以拿它当判定依据更可靠。HTTP/WS结果继续跑
// 只放进detail做参考不再参与ok的判定。
async function checkLineConnectivity(line) {
if (line.protocol === 'vless-cdn') {
const [tcpOk, httpResult, wsResult] = await Promise.all([
tcpCheck(line.addr, line.port),
httpStatusCheck(line),
wsHandshakeCheck(line),
const [httpOk, wsOk] = await Promise.all([
httpStatusCheck(line.addr, line.ws_path),
wsHandshakeCheck(line.addr, line.ws_path),
]);
return {
ok: tcpOk,
detail: `TCP: ${tcpOk ? '连通' : '连接失败'} | HTTP探测: ${httpResult.detail} | WS握手: ${wsResult.detail}`,
};
return httpOk && wsOk;
}
const tcpOk = await tcpCheck(line.addr, line.port);
return { ok: tcpOk, detail: tcpOk ? 'TCP连通' : 'TCP连接失败/超时' };
return tcpCheck(line.addr, line.port);
}
// 后台定时巡检:每 JMS_CHECK_INTERVAL_MS 跑一轮只在状态发生变化正常↔异常时才发Telegram
@ -250,12 +221,12 @@ async function runJmsMonitorCycle() {
for (const line of lines) {
try {
const { ok, detail } = await checkLineConnectivity(line);
const ok = await checkLineConnectivity(line);
const newStatus = ok ? 'normal' : 'abnormal';
if (line.monitorStatus && line.monitorStatus !== newStatus) {
const name = line.name || line.addr;
const msg = newStatus === 'abnormal'
? `⚠️ <b>${name}</b> 检测异常,无法连接\n协议:${line.protocol}\n地址:${line.addr}:${line.port}\n详情:${detail}`
? `⚠️ <b>${name}</b> 检测异常,无法连接\n协议:${line.protocol}\n地址:${line.addr}:${line.port}`
: `✅ <b>${name}</b> 已恢复正常\n协议:${line.protocol}\n地址:${line.addr}:${line.port}`;
sendTelegramMessage(msg).catch(() => {}); // Telegram没配置/发送失败都不影响检测本身
}
@ -581,16 +552,16 @@ app.post('/api/jms/check-line', requireAuth, jmsCheckLimiter, async (req, res) =
const line = req.body && req.body.line;
if (!line || !line.addr) return res.status(400).json({ error: '线路信息不完整' });
try {
const { ok, detail } = await checkLineConnectivity(line);
const ok = await checkLineConnectivity(line);
const newStatus = ok ? 'normal' : 'abnormal';
if (line.monitorStatus && line.monitorStatus !== newStatus) {
const name = line.name || line.addr;
const msg = newStatus === 'abnormal'
? `⚠️ <b>${name}</b> 检测异常,无法连接\n协议:${line.protocol}\n地址:${line.addr}:${line.port}\n详情:${detail}`
? `⚠️ <b>${name}</b> 检测异常,无法连接\n协议:${line.protocol}\n地址:${line.addr}:${line.port}`
: `✅ <b>${name}</b> 已恢复正常\n协议:${line.protocol}\n地址:${line.addr}:${line.port}`;
sendTelegramMessage(msg).catch(() => {});
}
res.json({ ok: true, status: newStatus, detail, checkedAt: Date.now() });
res.json({ ok: true, status: newStatus, checkedAt: Date.now() });
} catch (e) {
res.status(500).json({ error: e.message });
}
@ -662,14 +633,6 @@ app.post('/api/ai/draft', requireAuth, aiLimiter, async (req, res) => {
// jsdelivr CDN下载语言包VPS需要能访问外网
// 2. 系统程序 poppler-utils只有上传PDF时才需要用来把PDF每页渲染成图片
// apt install -y poppler-utils
// 3. 系统程序 markitdown微软开源Python用来读取DOCX/PPTX/XLSX的文字内容
// 以及给PDF做"有没有文字层"的快速探测——有文字层就直接用markitdown拿干净文本
// 跳过OCR更快更准还保留表格结构没有文字层扫描件/纯图片排版)才退回
// pdftoppm+OCR这条老路。装法root跑
// apt install -y pipx && pipx ensurepath
// pipx install 'markitdown[pdf,docx,pptx,xlsx]'
// ln -s ~/.local/bin/markitdown /usr/local/bin/markitdown # 让Node能直接找到这个命令
// 没装markitdown也不影响现有功能——探测/转换失败会静默退回原来的OCR流程。
const { execFile } = require('child_process');
const Tesseract = require('tesseract.js');
@ -679,16 +642,6 @@ const uploadMemory = multer({
limits: { fileSize: 20 * 1024 * 1024, files: 30 }, // 20MB/文件最多30个文件
});
// 调用markitdown CLI把一个文件DOCX/PPTX/XLSX/PDF等转成Markdown文本
function runMarkitdown(filePath, timeoutMs = 30000) {
return new Promise((resolve, reject) => {
execFile('markitdown', [filePath], { timeout: timeoutMs, maxBuffer: 20 * 1024 * 1024 }, (err, stdout) => {
if (err) return reject(err);
resolve(stdout || '');
});
});
}
// 把一份PDF的每一页渲染成PNG图片用poppler-utils的pdftoppm返回图片路径数组
function renderPdfToImages(pdfBuffer, tmpDir) {
return new Promise((resolve, reject) => {
@ -755,6 +708,12 @@ chargeCurrent充电电流、dimensions尺寸、netWeight净重
哪怕名字里没有明显的"(L)"标记也一样直接当长延时机型处理不要再凭空拆出一个去掉后缀的"标准版"反过来如果只有
"Standard Model"部分有数据"Long-run Model"部分是NA或空说明这个型号只有标准机型也不要凭空造一个带L的长延版本
特别注意多型号规格对比表有些规格表把好几个型号比如1200/1600/2200/3000放在同一张表里
每一行参数对应多个型号列但不是每个参数都是"一型号一个值"有些参数比如输入电压频率范围
全系列共用同一个值直接抄给每个型号但有些参数比如尺寸净重电池型号和数量会因为型号不同
分成几组不同的值比如1200/1600/2200共用一组尺寸3000单独一组这种情况必须先看清楚该行数值
和哪几个型号对齐再把正确的那组数值填到对应型号的记录里不能所有型号都填同一个值也不能填错组
特别注意"UPS配套锂电池系统"这类产品的两段式结构这类产品储能电池大类下的规格表通常明确分成两个区域
一个是"模块/Module"单个电池模块本身的参数一个是"锂电池系统柜/电池系统柜"整个机柜/整套系统的参数
跟模块化UPS"功率模块 + UPS机柜"是同样的两段式逻辑这两个区域各自包含好几项参数请务必把两个区域的每一项
@ -764,16 +723,6 @@ chargeCurrent充电电流、dimensions尺寸、netWeight净重
系统柜重量海拔自放电率这些"锂电池系统柜"区域的参数提取完之后自己检查一遍below schema里所有标注了
"UPS配套锂电池系统专属"的字段只要OCR文字里出现了对应的数值就必须填上不能遗漏
特别注意"不要因为字段不在下面schema里就丢弃信息"不同品牌不同型号的规格表参数项经常不完全一样
A品牌的表里没有某个参数B品牌的表里却有这是正常现象不代表哪个提取错了下面的JSON schema列出的是最常见的
通用字段但覆盖不了所有品牌千差万别的规格表写法原文规格表里出现的每一项参数都要体现在输出里能对应到
下面schema里某个具体字段的就填那个字段如果这份文件里出现了某个参数但schema里找不到对应字段比如
"防雷等级"不平衡输出能力"通讯协议版本"这类不常见字段或者这份文档是全新品类很多字段本来就没在schema里
预定义过不要因为没地方放就跳过不提取把这些参数以{"label":"参数名(用原文的中文/英文均可,优先中文)",
"value":"参数值"}的形式放进extraSpecs数组里每一项参数对应数组里的一条同一批文件里不同型号的extraSpecs
完全可以不一样不需要为了格式整齐而互相补齐或者省略每个型号各自的extraSpecs应该只反映它自己原文里实际
出现的参数
{
"name": "型号,比如 POE-532E",
"brand": "品牌中文名,从厂家名称/logo文字判断看不出就填空字符串",
@ -798,7 +747,7 @@ A品牌的表里没有某个参数B品牌的表里却有这是正常现象
"frequencyRangeBatteryMode": "离线式UPS/在线互动式UPS/机架式UPS/在线式高频UPS专属Frequency range(battery mode)/频率范围(电池模式),如 50/60Hz±1Hz不适用就填空字符串",
"chargeTime": "离线式UPS/在线互动式UPS/机架式UPS/在线式高频UPS专属Charge time/充电时间(或Typical Recharge Time),如 8 hours recover to 90% capacity不适用就填空字符串",
"lcdDisplay": "离线式UPS/在线互动式UPS/机架式UPS/在线式高频UPS专属LCD display/LCD显示(或LCD Panel)能展示哪些信息,把原文整段抄下来,如 UPS status, Load level, Battery level, Input/Output voltage, Discharge timer, and Fault conditions不适用就填空字符串",
"ledDisplay": "离线式UPS/在线互动式UPS专属LED display/LED显示把各个模式对应的灯光颜色都列出来一行一条用\\n分隔如 AC mode: Green lighting\\nBattery mode: Yellow lighting\\nFault: Red lighting不适用就填空字符串",
"ledDisplay": "离线式UPS/在线互动式UPS专属LED display/LED显示把各个模式对应的灯光颜色都列出来一行一条用\\n分隔如 AC mode: Green lighting\\nBattery mode: Yellow lighting\\nFault: Red lighting如果原文是中文且几个模式和颜色紧挨着写在一起、没有明显换行分隔(比如\"市电模式 绿灯常亮 电池模式 黄灯常亮 故障 红灯常亮\"),要把每个模式和它后面紧跟着的颜色状态配对拆开,同样按一行一条的格式输出,如 市电模式:绿灯常亮\\n电池模式黄灯常亮\\n故障红灯常亮不适用就填空字符串",
"fullProtection": "离线式UPS/在线互动式UPS专属Full Protection/全面保护,如 Discharge, Short circuit and overload protection不适用就填空字符串",
"alarmInfo": "离线式UPS/在线互动式UPS/机架式UPS/在线式高频UPS专属Alarm/警告提示,把电池模式/低电量/过载/故障这几种蜂鸣提示规律都列出来(一行一条,用\\n分隔如 Battery mode: Sounding every 10 seconds\\nLow battery: Sounding every second\\nOverload: Sounding every 0.5 seconds\\nFault: Continuously sounding不适用就填空字符串",
"interfacePort": "离线式UPS/在线互动式UPS/机架式UPS/在线式高频UPS专属Interface/控制管理接口说明,如 USB/RS232 port(optional), Support windows xp/vista, windows 7/8/11 and MAC或 Smart RS-232/USB, Supports Windows 2000/2003/XP/Vista/2008, Windows 7/8, Linux, Unix, and MAC不适用就填空字符串",
@ -865,8 +814,11 @@ A品牌的表里没有某个参数B品牌的表里却有这是正常现象
"safetyStandard": "太阳能混网逆变器专属:安规/EMC标准把原文列出来的标准编号都抄下来如 NB/T32004-2018, IEC62109, IEC61000不适用就填空字符串",
"gridStandard": "太阳能混网逆变器专属:并网标准,如 IEC61727, EN50549-1, VDE-4105不适用就填空字符串",
"otherStandard": "太阳能混网逆变器专属:其他标准,如 IEC61683, IEC62116, EN50530不适用就填空字符串",
"applicationScenarios": "应用场景:原文里如果有\"应用/Applications\"这类板块,用图标+文字标注了这个产品适合哪些应用场景(如电脑、投影仪、打印机、摄像机、收银机),把图标下面配的文字标签提取出来,用\"、\"分隔拼成一句话,如 电脑、投影仪、打印机、摄像机、收银机;只提取文字标签,不用管图标本身;原文没有这个板块就填空字符串",
"container20ft": "装箱数量20尺柜能装多少台无栈板如 1000PCS不适用/未提供就填空字符串",
"container40ft": "装箱数量40尺柜能装多少台无栈板如 2080PCS不适用/未提供就填空字符串",
"container40hq": "装箱数量40高柜能装多少台无栈板如 2340PCS不适用/未提供就填空字符串",
"sellingPoints": "如果原文里除了技术规格表之外,还有一些营销性质的卖点/主要特点描述(通常是几条带图标或项目符号的短句,比如\"Up-to-36A maximum input current\"、\"IP66 design\"这种面向客户宣传的亮点,不是纯技术参数表格里的数值),把这些提取出来,每条一行,用\\n分隔如果原文只有技术规格表、没有这种营销卖点描述就填空字符串不要自己瞎编凑数",
"extraSpecs": "数组,装下所有在原文规格表里出现、但上面找不到对应字段的参数,每项格式{\"label\":\"参数名\",\"value\":\"参数值\"};没有这类参数就填空数组[];不要为了凑数量编造不存在的参数,也不要把已经填进上面某个具体字段的参数重复放进来",
"notes": "其他有用但上面字段装不下的信息,一两句话,没有就填空字符串"
}
@ -889,44 +841,15 @@ app.post('/api/ai/parse-catalog', requireAuth, aiLimiter, (req, res) => {
try {
tmpDir = fs.mkdtempSync(path.join(OCR_TMP_ROOT, 'catalog-ocr-'));
const imagePaths = [];
const docTextParts = []; // DOCX/PPTX/XLSX或者"有文字层的PDF"——MarkItDown转出来的干净文本不用跑OCR
const htmlTextParts = []; // HTML文件不用OCR直接读文字内容保留表格标签结构AI能看懂rowspan这种分组关系
for (const file of req.files) {
const lowerName = file.originalname.toLowerCase();
const isPdf = (file.mimetype || '').includes('pdf') || lowerName.endsWith('.pdf');
const isPdf = (file.mimetype || '').includes('pdf') || file.originalname.toLowerCase().endsWith('.pdf');
const isImage = (file.mimetype || '').startsWith('image/');
const isHtml = (file.mimetype || '').includes('html') || /\.html?$/i.test(file.originalname);
const isOfficeDoc = /\.(docx|pptx|xlsx)$/i.test(file.originalname);
if (isOfficeDoc) {
const docPath = path.join(tmpDir, `doc_${docTextParts.length}${path.extname(file.originalname)}`);
fs.writeFileSync(docPath, file.buffer);
try {
const md = await runMarkitdown(docPath);
if (md && md.trim()) docTextParts.push(md.trim());
} catch (e) {
return res.status(400).json({
error: `解析${file.originalname}失败请确认服务器已安装markitdownSSH执行pipx install 'markitdown[docx,pptx,xlsx]'${e.message}`,
});
}
} else if (isPdf) {
if (isPdf) {
const pdfSubDir = fs.mkdtempSync(path.join(tmpDir, 'pdf-'));
// 先试MarkItDown直接读文字层——如果这份PDF本身带干净的文字层不是扫描件/纯图片排版的目录),
// 这条路又快又准还能保留表格结构没装markitdown、或者这份PDF本来就没有文字层返回内容
// 短到没有意义就静默退回老办法整页渲染成图片再OCR不阻断这次上传
let usedTextLayer = false;
try {
const probePath = path.join(pdfSubDir, 'source.pdf');
fs.writeFileSync(probePath, file.buffer);
const md = await runMarkitdown(probePath);
if (md && md.trim().length > 80) {
docTextParts.push(md.trim());
usedTextLayer = true;
}
} catch (e) { /* markitdown不可用或转换失败退回OCR */ }
if (!usedTextLayer) {
const pages = await renderPdfToImages(file.buffer, pdfSubDir);
imagePaths.push(...pages);
}
const pages = await renderPdfToImages(file.buffer, pdfSubDir);
imagePaths.push(...pages);
} else if (isImage) {
const ext = path.extname(file.originalname) || '.png';
const imgPath = path.join(tmpDir, `img_${imagePaths.length}${ext}`);
@ -943,12 +866,12 @@ app.post('/api/ai/parse-catalog', requireAuth, aiLimiter, (req, res) => {
.trim();
if (cleaned) htmlTextParts.push(cleaned);
} else {
return res.status(400).json({ error: `不支持的文件类型:${file.originalname}只支持PDF、图片、HTML、Word/PPT/Excel` });
return res.status(400).json({ error: `不支持的文件类型:${file.originalname}只支持PDF、图片或HTML` });
}
}
const ocrText = imagePaths.length ? await ocrImages(imagePaths) : '';
const text = [ocrText, ...docTextParts, ...htmlTextParts].filter(Boolean).join('\n\n--- 下一份文件 ---\n\n');
const text = [ocrText, ...htmlTextParts].filter(Boolean).join('\n\n--- 下一份文件 ---\n\n');
if (!text) return res.status(400).json({ error: '没有识别/读取到任何文字内容,请确认文件清晰、内容完整' });
// 识别出来的文本太长就截断,避免超出模型输出预算;产品目录类文档核心信息通常靠前