@customize-agent/knowledge 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/chunking/text-chunker.d.ts +24 -0
- package/dist/chunking/text-chunker.js +90 -0
- package/dist/classification/classifier.d.ts +12 -0
- package/dist/classification/classifier.js +95 -0
- package/dist/constants.d.ts +8 -0
- package/dist/constants.js +40 -0
- package/dist/core/change-tracker.d.ts +10 -0
- package/dist/core/change-tracker.js +66 -0
- package/dist/core/file-scanner.d.ts +8 -0
- package/dist/core/file-scanner.js +32 -0
- package/dist/core/index-state-store.d.ts +103 -0
- package/dist/core/index-state-store.js +439 -0
- package/dist/core/knowledge-base-manager.d.ts +76 -0
- package/dist/core/knowledge-base-manager.js +300 -0
- package/dist/core/multi-project-manager.d.ts +30 -0
- package/dist/core/multi-project-manager.js +163 -0
- package/dist/core/project-config.d.ts +9 -0
- package/dist/core/project-config.js +73 -0
- package/dist/core/project-id.d.ts +1 -0
- package/dist/core/project-id.js +8 -0
- package/dist/core/project-registry.d.ts +10 -0
- package/dist/core/project-registry.js +70 -0
- package/dist/dedup/dedup-engine.d.ts +20 -0
- package/dist/dedup/dedup-engine.js +78 -0
- package/dist/dedup/relationship-detector.d.ts +10 -0
- package/dist/dedup/relationship-detector.js +84 -0
- package/dist/embedding/embedding-provider.d.ts +15 -0
- package/dist/embedding/embedding-provider.js +31 -0
- package/dist/extraction/content-extractor.d.ts +36 -0
- package/dist/extraction/content-extractor.js +655 -0
- package/dist/extraction/external-extractor.d.ts +63 -0
- package/dist/extraction/external-extractor.js +139 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.js +22 -0
- package/dist/search/federation-search.d.ts +41 -0
- package/dist/search/federation-search.js +102 -0
- package/dist/server/dashboard-client.d.ts +2 -0
- package/dist/server/dashboard-client.js +396 -0
- package/dist/server/dashboard-i18n.d.ts +112 -0
- package/dist/server/dashboard-i18n.js +220 -0
- package/dist/server/dashboard-page.d.ts +6 -0
- package/dist/server/dashboard-page.js +138 -0
- package/dist/server/dashboard-server.d.ts +13 -0
- package/dist/server/dashboard-server.js +225 -0
- package/dist/server/dashboard-styles.d.ts +1 -0
- package/dist/server/dashboard-styles.js +152 -0
- package/dist/types.d.ts +82 -0
- package/dist/types.js +1 -0
- package/dist/vector/chroma-store.d.ts +39 -0
- package/dist/vector/chroma-store.js +131 -0
- package/dist/vector/collection-manager.d.ts +16 -0
- package/dist/vector/collection-manager.js +77 -0
- package/dist/vector/types.d.ts +33 -0
- package/dist/vector/types.js +1 -0
- package/dist/vector/vector-indexer.d.ts +18 -0
- package/dist/vector/vector-indexer.js +61 -0
- package/package.json +45 -0
|
@@ -0,0 +1,655 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import * as fs from 'node:fs';
|
|
3
|
+
import * as path from 'node:path';
|
|
4
|
+
import { ExternalExtractorRegistry } from './external-extractor.js';
|
|
5
|
+
export class ContentExtractor {
|
|
6
|
+
externalExtractors;
|
|
7
|
+
constructor(externalExtractors = ExternalExtractorRegistry.fromEnvironment()) {
|
|
8
|
+
this.externalExtractors = externalExtractors;
|
|
9
|
+
}
|
|
10
|
+
async extract(file) {
|
|
11
|
+
const start = Date.now();
|
|
12
|
+
const warnings = [];
|
|
13
|
+
let text;
|
|
14
|
+
const metadata = {
|
|
15
|
+
mimeType: file.mimeType,
|
|
16
|
+
category: file.category,
|
|
17
|
+
format: file.format,
|
|
18
|
+
};
|
|
19
|
+
const external = this.tryExternalExtractor(file);
|
|
20
|
+
if (external) {
|
|
21
|
+
text = external.text;
|
|
22
|
+
Object.assign(metadata, external.metadata);
|
|
23
|
+
warnings.push(...external.warnings);
|
|
24
|
+
}
|
|
25
|
+
else if (file.category === 'cad') {
|
|
26
|
+
const result = this.extractCad(file);
|
|
27
|
+
text = result.text;
|
|
28
|
+
Object.assign(metadata, result.metadata);
|
|
29
|
+
warnings.push(...result.warnings);
|
|
30
|
+
}
|
|
31
|
+
else if (file.category === 'data') {
|
|
32
|
+
const result = this.extractData(file);
|
|
33
|
+
text = result.text;
|
|
34
|
+
Object.assign(metadata, result.metadata);
|
|
35
|
+
warnings.push(...result.warnings);
|
|
36
|
+
}
|
|
37
|
+
else if (file.category === 'diagram') {
|
|
38
|
+
const result = this.extractDiagram(file);
|
|
39
|
+
text = result.text;
|
|
40
|
+
Object.assign(metadata, result.metadata);
|
|
41
|
+
warnings.push(...result.warnings);
|
|
42
|
+
}
|
|
43
|
+
else if (file.category === 'document' && file.format === 'pdf') {
|
|
44
|
+
const result = await this.extractPdf(file);
|
|
45
|
+
text = result.text;
|
|
46
|
+
Object.assign(metadata, result.metadata);
|
|
47
|
+
warnings.push(...result.warnings);
|
|
48
|
+
}
|
|
49
|
+
else if (file.category === 'document' && ['office', 'presentation'].includes(file.format)) {
|
|
50
|
+
const result = await this.extractOfficeDocument(file);
|
|
51
|
+
text = result.text;
|
|
52
|
+
Object.assign(metadata, result.metadata);
|
|
53
|
+
warnings.push(...result.warnings);
|
|
54
|
+
}
|
|
55
|
+
else if (file.category === 'spreadsheet' && ['csv', 'tsv'].includes(file.format)) {
|
|
56
|
+
const result = this.extractDelimitedText(file);
|
|
57
|
+
text = result.text;
|
|
58
|
+
Object.assign(metadata, result.metadata);
|
|
59
|
+
warnings.push(...result.warnings);
|
|
60
|
+
}
|
|
61
|
+
else if (file.category === 'spreadsheet') {
|
|
62
|
+
const result = await this.extractSpreadsheet(file);
|
|
63
|
+
text = result.text;
|
|
64
|
+
Object.assign(metadata, result.metadata);
|
|
65
|
+
warnings.push(...result.warnings);
|
|
66
|
+
}
|
|
67
|
+
else if (file.category === 'archive') {
|
|
68
|
+
const result = await this.extractArchive(file);
|
|
69
|
+
text = result.text;
|
|
70
|
+
Object.assign(metadata, result.metadata);
|
|
71
|
+
warnings.push(...result.warnings);
|
|
72
|
+
}
|
|
73
|
+
else if (file.category === 'image' && file.format !== 'vector') {
|
|
74
|
+
const result = await this.extractRasterImage(file);
|
|
75
|
+
text = result.text;
|
|
76
|
+
Object.assign(metadata, result.metadata);
|
|
77
|
+
warnings.push(...result.warnings);
|
|
78
|
+
}
|
|
79
|
+
else if (file.category === 'image' && file.format === 'vector') {
|
|
80
|
+
const result = this.extractSvg(file);
|
|
81
|
+
text = result.text;
|
|
82
|
+
Object.assign(metadata, result.metadata);
|
|
83
|
+
warnings.push(...result.warnings);
|
|
84
|
+
}
|
|
85
|
+
else if (this.isTextReadable(file)) {
|
|
86
|
+
text = fs.readFileSync(file.absolutePath, 'utf8');
|
|
87
|
+
metadata.extractionMode = 'plain_text';
|
|
88
|
+
metadata.vectorizable = true;
|
|
89
|
+
}
|
|
90
|
+
else {
|
|
91
|
+
text = this.metadataOnlyText(file);
|
|
92
|
+
metadata.extractionMode = 'metadata_only';
|
|
93
|
+
metadata.vectorizable = true;
|
|
94
|
+
metadata.contentCoverage = 'metadata';
|
|
95
|
+
warnings.push(`暂不支持 ${file.category}/${file.format} 内容提取,未解析出正文,未入库`);
|
|
96
|
+
}
|
|
97
|
+
return {
|
|
98
|
+
text: text.trim(),
|
|
99
|
+
metadata,
|
|
100
|
+
warnings,
|
|
101
|
+
extractionTimeMs: Date.now() - start,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
tryExternalExtractor(file) {
|
|
105
|
+
const extractors = this.externalExtractors.findAll(file);
|
|
106
|
+
if (extractors.length === 0)
|
|
107
|
+
return undefined;
|
|
108
|
+
const warnings = [];
|
|
109
|
+
for (const extractor of extractors) {
|
|
110
|
+
try {
|
|
111
|
+
const result = extractor.extract(file);
|
|
112
|
+
const text = result.text.trim();
|
|
113
|
+
if (!text) {
|
|
114
|
+
warnings.push(`外部解析器 ${extractor.name} 未提取到正文`);
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
return {
|
|
118
|
+
text,
|
|
119
|
+
metadata: {
|
|
120
|
+
extractionMode: 'external_advanced_plugin',
|
|
121
|
+
vectorizable: true,
|
|
122
|
+
contentCoverage: 'external_full_text',
|
|
123
|
+
externalExtractor: extractor.id,
|
|
124
|
+
...(result.metadata ?? {}),
|
|
125
|
+
},
|
|
126
|
+
warnings: [...warnings, ...(result.warnings ?? [])],
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
catch (error) {
|
|
130
|
+
warnings.push(`外部解析器 ${extractor.name} 失败: ${error instanceof Error ? error.message : String(error)}`);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return undefined;
|
|
134
|
+
}
|
|
135
|
+
extractCad(file) {
|
|
136
|
+
const metadata = { extractionMode: 'builtin_cad_structural', vectorizable: true };
|
|
137
|
+
const warnings = [];
|
|
138
|
+
const ext = path.extname(file.absolutePath).toLowerCase();
|
|
139
|
+
if (file.format === 'autocad' && ext === '.dxf') {
|
|
140
|
+
const raw = fs.readFileSync(file.absolutePath, 'utf8');
|
|
141
|
+
const layers = this.matchAll(raw, /\n\s*8\s*\n([^\n]+)/gu).slice(0, 300);
|
|
142
|
+
const textEntities = this.matchAll(raw, /\n\s*(?:1|3)\s*\n([^\n]+)/gu).slice(0, 500);
|
|
143
|
+
const blocks = this.matchAll(raw, /\n\s*2\s*\n([^\n]+)/gu).slice(0, 300);
|
|
144
|
+
const entityTypes = this.matchAll(raw, /\n\s*0\s*\n([A-Z][A-Z0-9_]+)/gu).slice(0, 1000);
|
|
145
|
+
metadata.layerCount = new Set(layers).size;
|
|
146
|
+
metadata.textEntityCount = textEntities.length;
|
|
147
|
+
metadata.blockCount = new Set(blocks).size;
|
|
148
|
+
metadata.entityTypeCount = new Set(entityTypes).size;
|
|
149
|
+
metadata.contentCoverage = 'dxf_layers_blocks_entities_text';
|
|
150
|
+
return {
|
|
151
|
+
text: [
|
|
152
|
+
this.metadataOnlyText(file),
|
|
153
|
+
`CAD DXF 图层: ${Array.from(new Set(layers)).join(', ')}`,
|
|
154
|
+
`CAD DXF 块/符号: ${Array.from(new Set(blocks)).join(', ')}`,
|
|
155
|
+
`CAD DXF 实体类型: ${Array.from(new Set(entityTypes)).join(', ')}`,
|
|
156
|
+
`CAD DXF 标注/文本:\n${textEntities.join('\n')}`,
|
|
157
|
+
].join('\n'),
|
|
158
|
+
metadata,
|
|
159
|
+
warnings,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
if (file.format === 'step') {
|
|
163
|
+
const raw = fs.readFileSync(file.absolutePath, 'utf8');
|
|
164
|
+
const products = this.matchAll(raw, /PRODUCT\s*\(\s*'([^']*)'\s*,\s*'([^']*)'/giu).slice(0, 300);
|
|
165
|
+
const materials = this.matchAll(raw, /MATERIAL[^']*'([^']+)'/giu).slice(0, 120);
|
|
166
|
+
const entities = this.matchAll(raw, /#\d+\s*=\s*([A-Z0-9_]+)/gu).slice(0, 1000);
|
|
167
|
+
const names = this.matchAll(raw, /'([^']{2,120})'/gu).slice(0, 500);
|
|
168
|
+
metadata.productCount = products.length;
|
|
169
|
+
metadata.materialCount = materials.length;
|
|
170
|
+
metadata.entityTypeCount = new Set(entities).size;
|
|
171
|
+
metadata.contentCoverage = 'step_products_materials_entities_names';
|
|
172
|
+
return {
|
|
173
|
+
text: [
|
|
174
|
+
this.metadataOnlyText(file),
|
|
175
|
+
`STEP 产品/零件:\n${products.join('\n')}`,
|
|
176
|
+
`STEP 材料: ${materials.join(', ')}`,
|
|
177
|
+
`STEP 实体类型: ${Array.from(new Set(entities)).join(', ')}`,
|
|
178
|
+
`STEP 名称/属性:\n${names.join('\n')}`,
|
|
179
|
+
].join('\n'),
|
|
180
|
+
metadata,
|
|
181
|
+
warnings,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
if (file.format === 'iges') {
|
|
185
|
+
const raw = fs.readFileSync(file.absolutePath, 'utf8');
|
|
186
|
+
const names = this.matchAll(raw, /'([^']{2,120})'/gu).slice(0, 500);
|
|
187
|
+
const entityTypes = this.matchAll(raw, /^\s*(\d{3,4})\s*,/gmu).slice(0, 1000);
|
|
188
|
+
metadata.entityNameCount = names.length;
|
|
189
|
+
metadata.entityTypeCount = new Set(entityTypes).size;
|
|
190
|
+
metadata.contentCoverage = 'iges_entity_names_types';
|
|
191
|
+
return {
|
|
192
|
+
text: [this.metadataOnlyText(file), `IGES 实体类型: ${Array.from(new Set(entityTypes)).join(', ')}`, `IGES 实体/名称:\n${names.join('\n')}`].join('\n'),
|
|
193
|
+
metadata,
|
|
194
|
+
warnings,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
if (file.format === 'mesh') {
|
|
198
|
+
const result = this.extractCadMesh(file, ext, metadata);
|
|
199
|
+
if (result.text.trim())
|
|
200
|
+
return result;
|
|
201
|
+
}
|
|
202
|
+
const binaryStrings = this.extractBinaryStrings(file.absolutePath).slice(0, 500);
|
|
203
|
+
metadata.extractionMode = 'builtin_cad_binary_strings';
|
|
204
|
+
metadata.contentCoverage = binaryStrings.length > 0 ? 'cad_binary_strings' : 'metadata';
|
|
205
|
+
metadata.stringCount = binaryStrings.length;
|
|
206
|
+
if (binaryStrings.length === 0)
|
|
207
|
+
warnings.push(`${file.format} 内置 CAD 解析器未提取到可用文本,未入库`);
|
|
208
|
+
return {
|
|
209
|
+
text: binaryStrings.length > 0 ? [this.metadataOnlyText(file), `CAD 二进制字符串/标题块:\n${binaryStrings.join('\n')}`].join('\n') : '',
|
|
210
|
+
metadata,
|
|
211
|
+
warnings,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
extractCadMesh(file, ext, metadata) {
|
|
215
|
+
const warnings = [];
|
|
216
|
+
if (['.obj', '.gltf'].includes(ext)) {
|
|
217
|
+
const raw = fs.readFileSync(file.absolutePath, 'utf8');
|
|
218
|
+
const objectNames = this.matchAll(raw, /^(?:o|g)\s+(.+)$/gmu).slice(0, 300);
|
|
219
|
+
const nodeNames = this.matchAll(raw, /"name"\s*:\s*"([^"]+)"/gu).slice(0, 300);
|
|
220
|
+
metadata.objectCount = objectNames.length + nodeNames.length;
|
|
221
|
+
metadata.contentCoverage = 'mesh_object_node_names';
|
|
222
|
+
return {
|
|
223
|
+
text: [this.metadataOnlyText(file), `Mesh 对象/节点/分组:\n${[...objectNames, ...nodeNames].join('\n')}`].join('\n'),
|
|
224
|
+
metadata,
|
|
225
|
+
warnings,
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
if (ext === '.stl') {
|
|
229
|
+
const buffer = fs.readFileSync(file.absolutePath);
|
|
230
|
+
const header = buffer.subarray(0, 80).toString('utf8').replace(/\0/gu, ' ').trim();
|
|
231
|
+
const rawStart = buffer.subarray(0, Math.min(buffer.length, 20_000)).toString('utf8');
|
|
232
|
+
const solids = this.matchAll(rawStart, /solid\s+([^\r\n]+)/giu).slice(0, 100);
|
|
233
|
+
metadata.contentCoverage = 'stl_header_solids';
|
|
234
|
+
metadata.solidCount = solids.length;
|
|
235
|
+
return { text: [this.metadataOnlyText(file), `STL 头信息: ${header}`, `STL solid 名称:\n${solids.join('\n')}`].join('\n'), metadata, warnings };
|
|
236
|
+
}
|
|
237
|
+
if (ext === '.3mf') {
|
|
238
|
+
const strings = this.extractBinaryStrings(file.absolutePath).slice(0, 500);
|
|
239
|
+
metadata.contentCoverage = strings.length > 0 ? '3mf_model_strings' : 'metadata';
|
|
240
|
+
metadata.stringCount = strings.length;
|
|
241
|
+
return { text: strings.length > 0 ? [this.metadataOnlyText(file), `3MF 模型字符串/部件信息:\n${strings.join('\n')}`].join('\n') : '', metadata, warnings };
|
|
242
|
+
}
|
|
243
|
+
const binaryStrings = this.extractBinaryStrings(file.absolutePath).slice(0, 300);
|
|
244
|
+
metadata.contentCoverage = binaryStrings.length > 0 ? 'mesh_binary_strings' : 'metadata';
|
|
245
|
+
return { text: binaryStrings.length > 0 ? [this.metadataOnlyText(file), `Mesh 二进制字符串:\n${binaryStrings.join('\n')}`].join('\n') : '', metadata, warnings };
|
|
246
|
+
}
|
|
247
|
+
extractBinaryStrings(filePath) {
|
|
248
|
+
const buffer = fs.readFileSync(filePath);
|
|
249
|
+
const raw = buffer.toString('latin1');
|
|
250
|
+
return Array.from(raw.matchAll(/[A-Za-z0-9_ .:\-/\\\u4e00-\u9fa5]{4,}/gu), match => match[0].trim())
|
|
251
|
+
.filter(value => value.length >= 4 && !/^\d+$/u.test(value))
|
|
252
|
+
.slice(0, 2_000);
|
|
253
|
+
}
|
|
254
|
+
extractData(file) {
|
|
255
|
+
const raw = fs.readFileSync(file.absolutePath, 'utf8');
|
|
256
|
+
const metadata = { extractionMode: 'structured_data', vectorizable: true };
|
|
257
|
+
try {
|
|
258
|
+
if (file.format === 'json') {
|
|
259
|
+
const lines = path.extname(file.absolutePath).toLowerCase() === '.jsonl'
|
|
260
|
+
? raw.split(/\r?\n/u).filter(Boolean).slice(0, 200).flatMap((line, index) => this.flattenJson(JSON.parse(line), `line${index + 1}`))
|
|
261
|
+
: this.flattenJson(JSON.parse(raw));
|
|
262
|
+
metadata.fieldCount = lines.length;
|
|
263
|
+
metadata.contentCoverage = 'json_paths_values';
|
|
264
|
+
return { text: [this.metadataOnlyText(file), ...lines.slice(0, 1000)].join('\n'), metadata, warnings: [] };
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
catch {
|
|
268
|
+
metadata.parseError = true;
|
|
269
|
+
}
|
|
270
|
+
if (file.format === 'xml') {
|
|
271
|
+
const elements = this.matchAll(raw, /<([A-Za-z_][\w:.-]*)\b[^>]*>([^<]{1,200})<\/\1>/gu).slice(0, 1000);
|
|
272
|
+
metadata.elementTextCount = elements.length;
|
|
273
|
+
metadata.contentCoverage = 'xml_element_text';
|
|
274
|
+
return { text: [this.metadataOnlyText(file), ...elements].join('\n'), metadata, warnings: [] };
|
|
275
|
+
}
|
|
276
|
+
metadata.contentCoverage = 'plain_structured_text';
|
|
277
|
+
return { text: [this.metadataOnlyText(file), raw].join('\n'), metadata, warnings: [] };
|
|
278
|
+
}
|
|
279
|
+
extractDiagram(file) {
|
|
280
|
+
const raw = fs.readFileSync(file.absolutePath, 'utf8');
|
|
281
|
+
const metadata = { extractionMode: 'diagram_structural', vectorizable: true };
|
|
282
|
+
if (file.format === 'drawio') {
|
|
283
|
+
const labels = this.matchAll(raw, /(?:value|label)="([^"]+)"/gu).map(value => this.stripXml(value)).slice(0, 300);
|
|
284
|
+
metadata.nodeTextCount = labels.length;
|
|
285
|
+
metadata.contentCoverage = 'drawio_labels';
|
|
286
|
+
return { text: [this.metadataOnlyText(file), `Draw.io 节点/连线文本:\n${labels.join('\n')}`].join('\n'), metadata, warnings: [] };
|
|
287
|
+
}
|
|
288
|
+
if (file.format === 'excalidraw') {
|
|
289
|
+
try {
|
|
290
|
+
const parsed = JSON.parse(raw);
|
|
291
|
+
const texts = (parsed.elements ?? []).filter(element => element.text).map(element => `${element.type ?? 'shape'}: ${element.text}`).slice(0, 300);
|
|
292
|
+
metadata.elementTextCount = texts.length;
|
|
293
|
+
metadata.contentCoverage = 'excalidraw_text_elements';
|
|
294
|
+
return { text: [this.metadataOnlyText(file), `Excalidraw 图形文本:\n${texts.join('\n')}`].join('\n'), metadata, warnings: [] };
|
|
295
|
+
}
|
|
296
|
+
catch {
|
|
297
|
+
metadata.parseError = true;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
metadata.contentCoverage = 'diagram_source_text';
|
|
301
|
+
return { text: [this.metadataOnlyText(file), raw].join('\n'), metadata, warnings: [] };
|
|
302
|
+
}
|
|
303
|
+
parseDelimitedLine(line, delimiter) {
|
|
304
|
+
const values = [];
|
|
305
|
+
let current = '';
|
|
306
|
+
let quoted = false;
|
|
307
|
+
for (let index = 0; index < line.length; index++) {
|
|
308
|
+
const char = line[index];
|
|
309
|
+
if (char === '"') {
|
|
310
|
+
if (quoted && line[index + 1] === '"') {
|
|
311
|
+
current += '"';
|
|
312
|
+
index++;
|
|
313
|
+
}
|
|
314
|
+
else {
|
|
315
|
+
quoted = !quoted;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
else if (char === delimiter && !quoted) {
|
|
319
|
+
values.push(current.trim());
|
|
320
|
+
current = '';
|
|
321
|
+
}
|
|
322
|
+
else {
|
|
323
|
+
current += char;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
values.push(current.trim());
|
|
327
|
+
return values;
|
|
328
|
+
}
|
|
329
|
+
extractDelimitedText(file) {
|
|
330
|
+
const raw = fs.readFileSync(file.absolutePath, 'utf8');
|
|
331
|
+
const delimiter = file.format === 'tsv' ? '\t' : ',';
|
|
332
|
+
const rows = raw.split(/\r?\n/u).filter(line => line.trim().length > 0);
|
|
333
|
+
const header = rows[0] ? this.parseDelimitedLine(rows[0], delimiter) : [];
|
|
334
|
+
const structured = rows.slice(1, 501).flatMap((line, rowIndex) => {
|
|
335
|
+
const values = this.parseDelimitedLine(line, delimiter);
|
|
336
|
+
return values.map((value, colIndex) => {
|
|
337
|
+
const column = header[colIndex] || `COL${colIndex + 1}`;
|
|
338
|
+
return `R${rowIndex + 2}C${colIndex + 1} ${column}: ${value}`;
|
|
339
|
+
});
|
|
340
|
+
});
|
|
341
|
+
return {
|
|
342
|
+
text: [this.metadataOnlyText(file), `表头: ${header.join(' | ')}`, ...structured, raw].join('\n'),
|
|
343
|
+
metadata: {
|
|
344
|
+
extractionMode: 'delimited_text_structured',
|
|
345
|
+
vectorizable: true,
|
|
346
|
+
delimiter: file.format === 'tsv' ? 'tab' : 'comma',
|
|
347
|
+
rowCount: rows.length,
|
|
348
|
+
columnCount: header.length,
|
|
349
|
+
contentCoverage: 'table_headers_cells_text',
|
|
350
|
+
},
|
|
351
|
+
warnings: [],
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
async extractOfficeDocument(file) {
|
|
355
|
+
if (path.extname(file.absolutePath).toLowerCase() === '.docx') {
|
|
356
|
+
try {
|
|
357
|
+
const mammoth = await import('mammoth');
|
|
358
|
+
const result = await mammoth.extractRawText({ path: file.absolutePath });
|
|
359
|
+
const text = result.value.trim();
|
|
360
|
+
if (text) {
|
|
361
|
+
return {
|
|
362
|
+
text,
|
|
363
|
+
metadata: { extractionMode: 'builtin_mammoth', vectorizable: true, contentCoverage: 'office_full_text' },
|
|
364
|
+
warnings: result.messages.map(message => message.message),
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
catch {
|
|
369
|
+
// fallback below
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
return this.extractOfficeZip(file);
|
|
373
|
+
}
|
|
374
|
+
async extractSpreadsheet(file) {
|
|
375
|
+
try {
|
|
376
|
+
const XLSX = await import('xlsx');
|
|
377
|
+
const workbook = XLSX.readFile(file.absolutePath, { cellDates: true, cellFormula: true, cellNF: true, cellStyles: true });
|
|
378
|
+
const sheetTexts = [];
|
|
379
|
+
let cellCount = 0;
|
|
380
|
+
let formulaCount = 0;
|
|
381
|
+
let mergeCount = 0;
|
|
382
|
+
for (const name of workbook.SheetNames) {
|
|
383
|
+
const sheet = workbook.Sheets[name];
|
|
384
|
+
if (!sheet || !sheet['!ref'])
|
|
385
|
+
continue;
|
|
386
|
+
const range = XLSX.utils.decode_range(sheet['!ref']);
|
|
387
|
+
const lines = [`工作表: ${name}`, `范围: ${sheet['!ref']}`];
|
|
388
|
+
const merges = sheet['!merges'] ?? [];
|
|
389
|
+
mergeCount += merges.length;
|
|
390
|
+
if (merges.length > 0) {
|
|
391
|
+
lines.push(`合并单元格: ${merges.map(item => `${XLSX.utils.encode_cell(item.s)}:${XLSX.utils.encode_cell(item.e)}`).join(', ')}`);
|
|
392
|
+
}
|
|
393
|
+
for (let row = range.s.r; row <= range.e.r; row++) {
|
|
394
|
+
for (let col = range.s.c; col <= range.e.c; col++) {
|
|
395
|
+
const address = XLSX.utils.encode_cell({ r: row, c: col });
|
|
396
|
+
const cell = sheet[address];
|
|
397
|
+
if (!cell || (cell.v == null && !cell.f))
|
|
398
|
+
continue;
|
|
399
|
+
cellCount++;
|
|
400
|
+
if (cell.f)
|
|
401
|
+
formulaCount++;
|
|
402
|
+
const display = cell.w ?? String(cell.v ?? '');
|
|
403
|
+
const formula = cell.f ? ` 公式=${cell.f}` : '';
|
|
404
|
+
lines.push(`${address}: ${display}${formula}`);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
if (lines.length > 2)
|
|
408
|
+
sheetTexts.push(lines.slice(0, 5_000).join('\n'));
|
|
409
|
+
}
|
|
410
|
+
if (sheetTexts.length > 0) {
|
|
411
|
+
return {
|
|
412
|
+
text: sheetTexts.join('\n\n'),
|
|
413
|
+
metadata: { extractionMode: 'builtin_xlsx_structured_cells', vectorizable: true, sheetCount: sheetTexts.length, cellCount, formulaCount, mergeCount, contentCoverage: 'spreadsheet_cells_formulas_merges' },
|
|
414
|
+
warnings: [],
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
catch {
|
|
419
|
+
// fallback below
|
|
420
|
+
}
|
|
421
|
+
return this.extractOfficeZip(file);
|
|
422
|
+
}
|
|
423
|
+
async extractOfficeZip(file) {
|
|
424
|
+
const metadata = { extractionMode: 'office_zip_text', vectorizable: true };
|
|
425
|
+
try {
|
|
426
|
+
const JSZip = (await import('jszip')).default;
|
|
427
|
+
const zip = await JSZip.loadAsync(fs.readFileSync(file.absolutePath));
|
|
428
|
+
const texts = [];
|
|
429
|
+
const xmlEntries = Object.values(zip.files).filter(entry => !entry.dir && /\.(xml|rels)$/iu.test(entry.name)).slice(0, 80);
|
|
430
|
+
for (const entry of xmlEntries) {
|
|
431
|
+
const xml = await entry.async('text');
|
|
432
|
+
const stripped = this.stripXml(xml).replace(/\s+/gu, ' ').trim();
|
|
433
|
+
if (stripped)
|
|
434
|
+
texts.push(`${entry.name}: ${stripped.slice(0, 8_000)}`);
|
|
435
|
+
}
|
|
436
|
+
metadata.entryCount = Object.keys(zip.files).length;
|
|
437
|
+
metadata.contentCoverage = texts.length > 0 ? 'office_zip_xml_text' : 'metadata_filename';
|
|
438
|
+
return { text: [this.metadataOnlyText(file), ...texts].join('\n'), metadata, warnings: texts.length ? [] : ['未从 Office 压缩结构中提取到正文,已跳过入库'] };
|
|
439
|
+
}
|
|
440
|
+
catch (error) {
|
|
441
|
+
metadata.extractionMode = 'office_zip_failed';
|
|
442
|
+
metadata.parseError = error instanceof Error ? error.message : String(error);
|
|
443
|
+
return { text: '', metadata, warnings: ['Office/表格/演示文件解析失败,内置解析器未提取到正文,未入库'] };
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
async extractArchive(file) {
|
|
447
|
+
const metadata = { extractionMode: 'archive_manifest', vectorizable: true };
|
|
448
|
+
if (path.extname(file.absolutePath).toLowerCase() !== '.zip') {
|
|
449
|
+
metadata.contentCoverage = 'metadata_filename';
|
|
450
|
+
return { text: this.metadataOnlyText(file), metadata, warnings: ['压缩包未提取到正文,未入库;仅 zip 可提取文件清单'] };
|
|
451
|
+
}
|
|
452
|
+
try {
|
|
453
|
+
const JSZip = (await import('jszip')).default;
|
|
454
|
+
const zip = await JSZip.loadAsync(fs.readFileSync(file.absolutePath));
|
|
455
|
+
const entries = Object.values(zip.files).map(entry => `${entry.dir ? '目录' : '文件'}: ${entry.name}`).slice(0, 1_000);
|
|
456
|
+
metadata.entryCount = Object.keys(zip.files).length;
|
|
457
|
+
metadata.contentCoverage = 'zip_manifest';
|
|
458
|
+
return { text: [this.metadataOnlyText(file), '压缩包文件清单:', ...entries].join('\n'), metadata, warnings: [] };
|
|
459
|
+
}
|
|
460
|
+
catch (error) {
|
|
461
|
+
metadata.parseError = error instanceof Error ? error.message : String(error);
|
|
462
|
+
return { text: '', metadata, warnings: ['压缩包解析失败,内置解析器未提取到文件清单,未入库'] };
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
async extractRasterImage(file) {
|
|
466
|
+
const metadata = { extractionMode: 'builtin_tesseract_ocr_isolated', vectorizable: true };
|
|
467
|
+
const validationError = this.validateRasterImage(file.absolutePath);
|
|
468
|
+
if (validationError) {
|
|
469
|
+
metadata.contentCoverage = 'invalid_image';
|
|
470
|
+
metadata.parseError = validationError;
|
|
471
|
+
return { text: '', metadata, warnings: [`图片文件无效或不完整:${validationError},未入库`] };
|
|
472
|
+
}
|
|
473
|
+
const result = spawnSync(process.execPath, [
|
|
474
|
+
'--input-type=module',
|
|
475
|
+
'-e',
|
|
476
|
+
`const { createWorker } = await import('tesseract.js');
|
|
477
|
+
const worker = await createWorker('chi_sim+eng');
|
|
478
|
+
try {
|
|
479
|
+
const result = await worker.recognize(process.argv[1]);
|
|
480
|
+
process.stdout.write(result.data.text || '');
|
|
481
|
+
} finally {
|
|
482
|
+
await worker.terminate();
|
|
483
|
+
}`,
|
|
484
|
+
file.absolutePath,
|
|
485
|
+
], { encoding: 'utf8', timeout: 60_000, maxBuffer: 10 * 1024 * 1024 });
|
|
486
|
+
if (result.status !== 0 || result.error) {
|
|
487
|
+
const message = result.error?.message || result.stderr.trim() || `OCR 子进程退出码 ${result.status ?? 'unknown'}`;
|
|
488
|
+
metadata.contentCoverage = 'ocr_failed';
|
|
489
|
+
metadata.parseError = message;
|
|
490
|
+
return { text: '', metadata, warnings: [`内置 OCR 解析失败:${message},未入库`] };
|
|
491
|
+
}
|
|
492
|
+
const text = result.stdout.trim();
|
|
493
|
+
metadata.contentCoverage = text ? 'ocr_text' : 'metadata_filename';
|
|
494
|
+
return {
|
|
495
|
+
text,
|
|
496
|
+
metadata,
|
|
497
|
+
warnings: text ? [] : ['内置 OCR 未识别到文字,未入库'],
|
|
498
|
+
};
|
|
499
|
+
}
|
|
500
|
+
validateRasterImage(filePath) {
|
|
501
|
+
const buffer = fs.readFileSync(filePath);
|
|
502
|
+
if (buffer.length < 12)
|
|
503
|
+
return '文件过小,无法识别图片头';
|
|
504
|
+
if (buffer.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) {
|
|
505
|
+
return buffer.includes(Buffer.from([0x49, 0x45, 0x4e, 0x44])) ? undefined : 'PNG 缺少 IEND 结束块';
|
|
506
|
+
}
|
|
507
|
+
if (buffer[0] === 0xff && buffer[1] === 0xd8) {
|
|
508
|
+
return buffer.length > 4 && buffer[buffer.length - 2] === 0xff && buffer[buffer.length - 1] === 0xd9 ? undefined : 'JPEG 缺少 EOI 结束标记';
|
|
509
|
+
}
|
|
510
|
+
const header = buffer.subarray(0, 12).toString('ascii');
|
|
511
|
+
if (header.startsWith('GIF87a') || header.startsWith('GIF89a')) {
|
|
512
|
+
return buffer[buffer.length - 1] === 0x3b ? undefined : 'GIF 缺少 trailer 结束标记';
|
|
513
|
+
}
|
|
514
|
+
if (header.startsWith('RIFF') && buffer.subarray(8, 12).toString('ascii') === 'WEBP') {
|
|
515
|
+
const expectedSize = buffer.readUInt32LE(4) + 8;
|
|
516
|
+
return buffer.length >= expectedSize ? undefined : 'WebP 文件长度小于 RIFF 声明长度';
|
|
517
|
+
}
|
|
518
|
+
if (buffer[0] === 0x42 && buffer[1] === 0x4d) {
|
|
519
|
+
const expectedSize = buffer.readUInt32LE(2);
|
|
520
|
+
return buffer.length >= expectedSize ? undefined : 'BMP 文件长度小于头部声明长度';
|
|
521
|
+
}
|
|
522
|
+
return undefined;
|
|
523
|
+
}
|
|
524
|
+
async extractPdf(file) {
|
|
525
|
+
const metadata = { extractionMode: 'pdf_text', vectorizable: true };
|
|
526
|
+
try {
|
|
527
|
+
const raw = fs.readFileSync(file.absolutePath);
|
|
528
|
+
const text = await this.extractPdfText(raw);
|
|
529
|
+
if (text.trim()) {
|
|
530
|
+
metadata.contentCoverage = 'pdf_text_streams';
|
|
531
|
+
return { text: [this.metadataOnlyText(file), text].join('\n'), metadata, warnings: [] };
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
catch (error) {
|
|
535
|
+
metadata.parseError = error instanceof Error ? error.message : String(error);
|
|
536
|
+
}
|
|
537
|
+
metadata.extractionMode = 'pdf_metadata_only';
|
|
538
|
+
metadata.contentCoverage = 'metadata_filename';
|
|
539
|
+
return {
|
|
540
|
+
text: '',
|
|
541
|
+
metadata,
|
|
542
|
+
warnings: ['PDF 正文暂未提取到文本,内置解析器未提取到正文,未入库'],
|
|
543
|
+
};
|
|
544
|
+
}
|
|
545
|
+
async extractPdfText(buffer) {
|
|
546
|
+
try {
|
|
547
|
+
const mod = await import('pdf-parse');
|
|
548
|
+
const PDFParse = mod.PDFParse;
|
|
549
|
+
if (PDFParse) {
|
|
550
|
+
const parser = new PDFParse({ data: new Uint8Array(buffer) });
|
|
551
|
+
try {
|
|
552
|
+
const result = await parser.getText();
|
|
553
|
+
if (result.text.trim())
|
|
554
|
+
return result.text.slice(0, 250_000);
|
|
555
|
+
}
|
|
556
|
+
finally {
|
|
557
|
+
await parser.destroy();
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
catch {
|
|
562
|
+
// fallback to legacy API/raw stream extraction below
|
|
563
|
+
}
|
|
564
|
+
try {
|
|
565
|
+
const mod = await import('pdf-parse');
|
|
566
|
+
const pdfParse = mod.default;
|
|
567
|
+
if (pdfParse) {
|
|
568
|
+
const result = await pdfParse(buffer);
|
|
569
|
+
if (result.text.trim())
|
|
570
|
+
return result.text.slice(0, 250_000);
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
catch {
|
|
574
|
+
// fallback below
|
|
575
|
+
}
|
|
576
|
+
const raw = buffer.toString('latin1');
|
|
577
|
+
const matches = Array.from(raw.matchAll(/\(([^()]{2,500})\)\s*T[jJ]/gu), match => match[1] ?? '')
|
|
578
|
+
.concat(Array.from(raw.matchAll(/\[([^\]]{2,2000})\]\s*TJ/gu), match => match[1] ?? ''));
|
|
579
|
+
return matches
|
|
580
|
+
.map(value => value.replace(/\\([()\\])/gu, '$1').replace(/\\n|\\r/gu, ' '))
|
|
581
|
+
.join('\n')
|
|
582
|
+
.split('')
|
|
583
|
+
.map(char => {
|
|
584
|
+
const code = char.charCodeAt(0);
|
|
585
|
+
return (code < 32 && code !== 9 && code !== 10 && code !== 13) ? ' ' : char;
|
|
586
|
+
})
|
|
587
|
+
.join('')
|
|
588
|
+
.trim();
|
|
589
|
+
}
|
|
590
|
+
extractSvg(file) {
|
|
591
|
+
const raw = fs.readFileSync(file.absolutePath, 'utf8');
|
|
592
|
+
const texts = this.matchAll(raw, /<text\b[^>]*>([\s\S]*?)<\/text>/giu).map(value => this.stripXml(value)).slice(0, 300);
|
|
593
|
+
const titles = this.matchAll(raw, /<(?:title|desc)\b[^>]*>([\s\S]*?)<\/(?:title|desc)>/giu).map(value => this.stripXml(value)).slice(0, 100);
|
|
594
|
+
return {
|
|
595
|
+
text: [this.metadataOnlyText(file), `SVG 标题/描述:\n${titles.join('\n')}`, `SVG 文本节点:\n${texts.join('\n')}`].join('\n'),
|
|
596
|
+
metadata: { extractionMode: 'svg_text_nodes', vectorizable: true, textNodeCount: texts.length, contentCoverage: 'svg_text_title_desc' },
|
|
597
|
+
warnings: [],
|
|
598
|
+
};
|
|
599
|
+
}
|
|
600
|
+
isTextReadable(file) {
|
|
601
|
+
if (file.category === 'code' || file.category === 'web')
|
|
602
|
+
return true;
|
|
603
|
+
if (file.category === 'document')
|
|
604
|
+
return ['markdown', 'plaintext'].includes(file.format);
|
|
605
|
+
if (file.category === 'spreadsheet')
|
|
606
|
+
return file.format === 'csv';
|
|
607
|
+
if (file.category === 'image')
|
|
608
|
+
return file.format === 'vector';
|
|
609
|
+
return file.category === 'other' && this.looksTextFile(file.absolutePath);
|
|
610
|
+
}
|
|
611
|
+
looksTextFile(filePath) {
|
|
612
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
613
|
+
return ['.log', '.text'].includes(ext);
|
|
614
|
+
}
|
|
615
|
+
matchAll(input, pattern) {
|
|
616
|
+
return Array.from(input.matchAll(pattern), match => match.slice(1).filter(Boolean).join(' | ').trim()).filter(Boolean);
|
|
617
|
+
}
|
|
618
|
+
stripXml(value) {
|
|
619
|
+
return value
|
|
620
|
+
.replace(/<[^>]+>/gu, ' ')
|
|
621
|
+
.replace(/"/gu, '"')
|
|
622
|
+
.replace(/'/gu, "'")
|
|
623
|
+
.replace(/</gu, '<')
|
|
624
|
+
.replace(/>/gu, '>')
|
|
625
|
+
.replace(/&/gu, '&')
|
|
626
|
+
.replace(/\s+/gu, ' ')
|
|
627
|
+
.trim();
|
|
628
|
+
}
|
|
629
|
+
flattenJson(value, prefix = '') {
|
|
630
|
+
if (value == null || typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
|
|
631
|
+
return [`${prefix || 'value'}: ${String(value)}`];
|
|
632
|
+
}
|
|
633
|
+
if (Array.isArray(value)) {
|
|
634
|
+
return value.slice(0, 50).flatMap((item, index) => this.flattenJson(item, `${prefix}[${index}]`));
|
|
635
|
+
}
|
|
636
|
+
if (typeof value === 'object') {
|
|
637
|
+
return Object.entries(value).flatMap(([key, item]) => this.flattenJson(item, prefix ? `${prefix}.${key}` : key));
|
|
638
|
+
}
|
|
639
|
+
return [];
|
|
640
|
+
}
|
|
641
|
+
metadataOnlyText(file) {
|
|
642
|
+
const fileName = path.basename(file.relativePath);
|
|
643
|
+
const directory = path.dirname(file.relativePath);
|
|
644
|
+
const searchableName = fileName.replace(/[_\-.]+/gu, ' ');
|
|
645
|
+
return [
|
|
646
|
+
`文件名: ${fileName}`,
|
|
647
|
+
`文件路径: ${file.relativePath}`,
|
|
648
|
+
`所在目录: ${directory === '.' ? 'knowledgeBase' : directory}`,
|
|
649
|
+
`可搜索名称: ${searchableName}`,
|
|
650
|
+
`文件类型: ${file.category}/${file.format}`,
|
|
651
|
+
`MIME: ${file.mimeType}`,
|
|
652
|
+
`文件大小: ${file.fileSize} bytes`,
|
|
653
|
+
].join('\n');
|
|
654
|
+
}
|
|
655
|
+
}
|