From 96ed7e7d92da6754fdc3c3db892d6b6d6d4e4312 Mon Sep 17 00:00:00 2001 From: mike Date: Fri, 7 Aug 2026 15:51:06 +0800 Subject: [PATCH] Update server.js via upload script - 2026-08-07 15:50:49 --- server.js | 161 ++++++++++++++---------------------------------------- 1 file changed, 42 insertions(+), 119 deletions(-) diff --git a/server.js b/server.js index c65c591..1a835a3 100644 --- a/server.js +++ b/server.js @@ -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解析时报EPROTO(wrong 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' - ? `⚠️ ${name} 检测异常,无法连接\n协议:${line.protocol}\n地址:${line.addr}:${line.port}\n详情:${detail}` + ? `⚠️ ${name} 检测异常,无法连接\n协议:${line.protocol}\n地址:${line.addr}:${line.port}` : `✅ ${name} 已恢复正常\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' - ? `⚠️ ${name} 检测异常,无法连接\n协议:${line.protocol}\n地址:${line.addr}:${line.port}\n详情:${detail}` + ? `⚠️ ${name} 检测异常,无法连接\n协议:${line.protocol}\n地址:${line.addr}:${line.port}` : `✅ ${name} 已恢复正常\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}失败,请确认服务器已安装markitdown(SSH执行: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: '没有识别/读取到任何文字内容,请确认文件清晰、内容完整' }); // 识别出来的文本太长就截断,避免超出模型输出预算;产品目录类文档核心信息通常靠前