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: '没有识别/读取到任何文字内容,请确认文件清晰、内容完整' });
// 识别出来的文本太长就截断,避免超出模型输出预算;产品目录类文档核心信息通常靠前