ai-query对接新版freesun-agent接口的分支
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

285 lines
9.0 KiB

'use strict';
const superagent = require('superagent');
const qiniu = require('qiniu');
const mammoth = require('mammoth');
const cheerio = require('cheerio');
const path = require('path');
const { randomUUID } = require('crypto');
const {
DOCX_IMPORT_MAX_FILE_SIZE,
DOCX_IMPORT_RESPONSE_TIMEOUT,
DOCX_IMPORT_DEADLINE_TIMEOUT,
DOCX_ZIP_MAGIC,
DOCX_CONTENT_TYPE_PATTERN,
} = require('./constants');
const ensureHttpUrl = (value, label = 'sourceFileUrl') => {
const raw = String(value || '').trim();
if (!raw) throw `missing param: ${label}`;
let parsed = null;
try {
parsed = new URL(raw);
} catch (error) {
throw `invalid param: ${label}`;
}
if (!['http:', 'https:'].includes(parsed.protocol)) {
throw `invalid param: ${label}`;
}
return parsed.toString();
};
const decodeURIComponentSafely = value => {
try {
return decodeURIComponent(value);
} catch (error) {
return value;
}
};
const parseDispositionFilename = (disposition = '') => {
const utf8Match = disposition.match(/filename\*\s*=\s*UTF-8''([^;]+)/i);
if (utf8Match && utf8Match[1]) {
return decodeURIComponentSafely(utf8Match[1].trim().replace(/^"|"$/g, ''));
}
const plainMatch = disposition.match(/filename\s*=\s*("?)([^";]+)\1/i);
if (plainMatch && plainMatch[2]) return plainMatch[2].trim();
return '';
};
const getSourceFileName = (sourceFileUrl, contentDisposition = '') => {
const fromDisposition = parseDispositionFilename(String(contentDisposition || ''));
if (fromDisposition) return fromDisposition;
try {
const parsed = new URL(sourceFileUrl);
const base = path.basename(parsed.pathname || '');
if (base) return decodeURIComponentSafely(base);
} catch (error) { }
return 'import.docx';
};
const toBuffer = data => {
if (!data) return Buffer.alloc(0);
if (Buffer.isBuffer(data)) return data;
if (data instanceof ArrayBuffer) return Buffer.from(data);
if (ArrayBuffer.isView(data)) return Buffer.from(data.buffer, data.byteOffset, data.byteLength);
if (typeof data === 'string') return Buffer.from(data);
return Buffer.alloc(0);
};
const hasDocxZipMagic = buffer => {
if (!Buffer.isBuffer(buffer) || buffer.length < DOCX_ZIP_MAGIC.length) return false;
return DOCX_ZIP_MAGIC.every((byte, index) => buffer[index] === byte);
};
const ensureDocxFile = ({ sourceFileUrl, contentType, fileBuffer }) => {
const safeContentType = String(contentType || '').toLowerCase();
const hasDocxMime = DOCX_CONTENT_TYPE_PATTERN.test(safeContentType);
const pathname = (() => {
try {
return new URL(sourceFileUrl).pathname || '';
} catch (error) {
return '';
}
})();
const hasDocxExt = pathname.toLowerCase().endsWith('.docx');
if (!hasDocxZipMagic(fileBuffer)) {
throw 'source file is not a valid docx file';
}
if (!hasDocxExt && !hasDocxMime) {
throw 'source file must be a docx file';
}
};
const downloadDocxFile = async sourceFileUrl => {
const response = await superagent
.get(sourceFileUrl)
.redirects(3)
.responseType('arraybuffer')
.timeout({
response: DOCX_IMPORT_RESPONSE_TIMEOUT,
deadline: DOCX_IMPORT_DEADLINE_TIMEOUT,
});
const fileBuffer = toBuffer(response.body);
if (!fileBuffer.length) throw 'source file is empty';
if (fileBuffer.length > DOCX_IMPORT_MAX_FILE_SIZE) {
throw `source file too large, max ${Math.floor(DOCX_IMPORT_MAX_FILE_SIZE / 1024 / 1024)}MB`;
}
const contentType = String(response.header?.['content-type'] || '').trim();
const sourceFileName = getSourceFileName(
sourceFileUrl,
String(response.header?.['content-disposition'] || '')
);
ensureDocxFile({
sourceFileUrl,
contentType,
fileBuffer,
});
return {
fileBuffer,
contentType,
sourceFileName,
};
};
const ensureQiniuConfig = ctx => {
const qiniuConfig = ctx.config?.qiniu || {};
const bucket = String(qiniuConfig.bkt || '').trim();
const accessKey = String(qiniuConfig.ak || '').trim();
const secretKey = String(qiniuConfig.sk || '').trim();
const domain = String(qiniuConfig.dmn || '').trim().replace(/\/+$/, '');
if (!bucket || !accessKey || !secretKey) {
throw 'qiniu config is incomplete';
}
return {
bucket,
accessKey,
secretKey,
domain,
};
};
const createQiniuUploader = ctx => {
const { bucket, accessKey, secretKey, domain } = ensureQiniuConfig(ctx);
const mac = new qiniu.auth.digest.Mac(accessKey, secretKey);
const putPolicy = new qiniu.rs.PutPolicy({ scope: bucket });
const uploadToken = putPolicy.uploadToken(mac);
const config = new qiniu.conf.Config();
config.zone = qiniu.zone.Zone_z0;
const formUploader = new qiniu.form_up.FormUploader(config);
return async (key, buffer, mimeType = 'application/octet-stream') => {
const putExtra = new qiniu.form_up.PutExtra();
putExtra.mimeType = mimeType;
const uploadBody = await new Promise((resolve, reject) => {
formUploader.put(uploadToken, key, buffer, putExtra, (respErr, respBody, respInfo) => {
if (respErr) {
reject(respErr);
return;
}
if (respInfo?.statusCode && respInfo.statusCode >= 300) {
reject(new Error(`qiniu upload failed with status ${respInfo.statusCode}`));
return;
}
resolve(respBody || {});
});
});
const finalKey = String(uploadBody.key || key);
return domain ? `${domain}/${finalKey}` : finalKey;
};
};
const getImageExtensionByContentType = contentType => {
const normalized = String(contentType || '').toLowerCase();
const map = {
'image/png': 'png',
'image/jpeg': 'jpg',
'image/jpg': 'jpg',
'image/webp': 'webp',
'image/gif': 'gif',
'image/bmp': 'bmp',
'image/tiff': 'tiff',
'image/svg+xml': 'svg',
};
return map[normalized] || 'bin';
};
const buildDocxImageQiniuKey = contentType => {
const extension = getImageExtensionByContentType(contentType);
const datePart = new Date().toISOString().slice(0, 10).replace(/-/g, '');
return `report/docx-import/images/${datePart}/${randomUUID()}.${extension}`;
};
const convertDocxToHtml = async (ctx, fileBuffer) => {
let uploadToQiniu = null;
const result = await mammoth.convertToHtml(
{ buffer: fileBuffer },
{
convertImage: mammoth.images.imgElement(async image => {
const contentType = String(image.contentType || 'application/octet-stream');
const imageBuffer = toBuffer(await image.read());
if (!imageBuffer.length) throw new Error('empty image in docx');
if (!uploadToQiniu) {
uploadToQiniu = createQiniuUploader(ctx);
}
const imageKey = buildDocxImageQiniuKey(contentType);
const src = await uploadToQiniu(imageKey, imageBuffer, contentType);
return { src };
})
}
);
return String(result?.value || '').trim();
};
const parseDocxHtmlToChapters = html => {
const $ = cheerio.load(`<div id="docx-import-root">${String(html || '')}</div>`, { decodeEntities: false });
const root = $('#docx-import-root');
const chapters = [];
const stack = [];
const pendingBeforeFirstHeading = [];
root.contents().each((_, element) => {
if (element.type === 'text' && !String(element.data || '').trim()) return;
const serialized = String($.html(element) || '').trim();
if (!serialized) return;
const tagName = String(element.name || element.tagName || '').toLowerCase();
const headingMatch = tagName.match(/^h([1-6])$/);
if (!headingMatch) {
if (!stack.length) {
pendingBeforeFirstHeading.push(serialized);
return;
}
stack[stack.length - 1].contentParts.push(serialized);
return;
}
const level = Number(headingMatch[1]);
const title = String($(element).text() || '').replace(/\s+/g, ' ').trim();
const node = {
title: title || 'Untitled Chapter',
level,
contentParts: [],
children: [],
};
if (!stack.length && pendingBeforeFirstHeading.length) {
node.contentParts.push(...pendingBeforeFirstHeading);
pendingBeforeFirstHeading.length = 0;
}
while (stack.length && stack[stack.length - 1].level >= level) {
stack.pop();
}
if (stack.length) {
stack[stack.length - 1].children.push(node);
} else {
chapters.push(node);
}
stack.push(node);
});
if (!chapters.length) {
throw 'docx 文档未识别到标题,请使用 heading 样式';
}
const normalizeNode = node => ({
title: node.title,
contentHtml: node.contentParts.join('\n').trim(),
children: (node.children || []).map(normalizeNode),
});
return chapters.map(normalizeNode);
};
module.exports = {
ensureHttpUrl,
downloadDocxFile,
convertDocxToHtml,
parseDocxHtmlToChapters,
};