Update server.js via upload script - 2026-08-07 15:50:49
This commit is contained in:
parent
ebf85a5932
commit
96ed7e7d92
161
server.js
161
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'
|
||||
? `⚠️ <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}失败,请确认服务器已安装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: '没有识别/读取到任何文字内容,请确认文件清晰、内容完整' });
|
||||
|
||||
// 识别出来的文本太长就截断,避免超出模型输出预算;产品目录类文档核心信息通常靠前
|
||||
|
|
|
|||
Loading…
Reference in New Issue