create-panal-agent 0.12.0 → 0.13.0
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/package.json +6 -4
- package/template/_package.json +1 -0
- package/template/src/adjuntos.ts +359 -0
- package/template/src/agent.ts +38 -41
- package/template/src/salida.ts +391 -0
- package/template/src/zip.ts +245 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-panal-agent",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.0",
|
|
4
4
|
"description": "Crea un agente de IA para Panal, funcionando y cobrando on-chain, en cinco minutos",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -30,9 +30,11 @@
|
|
|
30
30
|
"node": ">=20"
|
|
31
31
|
},
|
|
32
32
|
"devDependencies": {
|
|
33
|
-
"
|
|
33
|
+
"@types/node": "^22.0.0",
|
|
34
34
|
"tsx": "^4.19.0",
|
|
35
|
-
"
|
|
35
|
+
"typescript": "^5.6.0",
|
|
36
|
+
"unpdf": "^1.8.1",
|
|
37
|
+
"xlsx": "^0.18.5"
|
|
36
38
|
},
|
|
37
39
|
"dependencies": {
|
|
38
40
|
"viem": "^2.21.0"
|
|
@@ -40,6 +42,6 @@
|
|
|
40
42
|
"scripts": {
|
|
41
43
|
"build": "tsc -p tsconfig.json",
|
|
42
44
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
43
|
-
"test": "tsx test/i18n.test.ts && tsx test/memoria.test.ts && tsx test/reintento.test.ts && tsx test/scaffold.test.ts"
|
|
45
|
+
"test": "tsx test/i18n.test.ts && tsx test/memoria.test.ts && tsx test/reintento.test.ts && tsx test/zip.test.ts && tsx test/adjuntos.test.ts && tsx test/salida.test.ts && tsx test/scaffold.test.ts"
|
|
44
46
|
}
|
|
45
47
|
}
|
package/template/_package.json
CHANGED
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lo que el cliente adjuntó, convertido en algo que un modelo pueda usar.
|
|
3
|
+
*
|
|
4
|
+
* Un encargo puede traer cinco archivos de hasta 25 MB, y llegan verificados:
|
|
5
|
+
* su hash se anunció DENTRO del encargo antes de que se pagara, así que son
|
|
6
|
+
* exactamente los que el escrow cubre. Lo que hace este archivo es la otra
|
|
7
|
+
* mitad: abrirlos.
|
|
8
|
+
*
|
|
9
|
+
* Cada tipo entra por donde puede:
|
|
10
|
+
*
|
|
11
|
+
* imagen (png/jpeg/gif/webp) se le ENSEÑA al modelo, no se describe
|
|
12
|
+
* texto (código, md, csv…) tal cual
|
|
13
|
+
* PDF se le extrae el texto
|
|
14
|
+
* .docx es un ZIP con XML dentro
|
|
15
|
+
* .zip se abre y se leen los que sí se puedan
|
|
16
|
+
* cualquier otra cosa se NOMBRA, y se dice que no se pudo abrir
|
|
17
|
+
*
|
|
18
|
+
* ESE ÚLTIMO CASO NO ES UN DESCUIDO. Callar un archivo que no se pudo abrir
|
|
19
|
+
* hace que el modelo conteste como si el cliente no hubiera mandado nada, y el
|
|
20
|
+
* cliente recibe una respuesta que ignora la mitad de lo que pidió sin decir
|
|
21
|
+
* por qué. Decirlo cuesta una línea y convierte un fallo en una explicación.
|
|
22
|
+
*
|
|
23
|
+
* EL TIPO SE MIRA POR LOS BYTES, no por el nombre ni por el `content-type` que
|
|
24
|
+
* mandó el cliente: los dos los escribe él, y un `.txt` que en realidad es un
|
|
25
|
+
* PDF es un accidente normal, no un ataque.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { esZip, leerZip } from './zip.js';
|
|
29
|
+
|
|
30
|
+
export interface AdjuntoRecibido {
|
|
31
|
+
name: string;
|
|
32
|
+
mime?: string;
|
|
33
|
+
bytes: Uint8Array;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Una imagen lista para enseñársela al modelo. */
|
|
37
|
+
export interface ImagenAdjunta {
|
|
38
|
+
mime: string;
|
|
39
|
+
bytes: Uint8Array;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface AdjuntosLeidos {
|
|
43
|
+
/** Ya etiquetado y listo para pegar al encargo. Vacío si no hay nada. */
|
|
44
|
+
texto: string;
|
|
45
|
+
/** Las que el modelo puede mirar de verdad. */
|
|
46
|
+
imagenes: ImagenAdjunta[];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Tope de caracteres que aporta UN adjunto. El encargo lo paga el agente. */
|
|
50
|
+
const MAX_CHARS_POR_ADJUNTO = 8_000;
|
|
51
|
+
/** Y el de todos juntos, porque cinco archivos al tope son demasiado. */
|
|
52
|
+
const MAX_CHARS_TOTAL = 24_000;
|
|
53
|
+
/** Archivos que se miran dentro de un ZIP. */
|
|
54
|
+
const MAX_DENTRO_DEL_ZIP = 40;
|
|
55
|
+
|
|
56
|
+
const MIMES_IMAGEN: Record<string, string> = {
|
|
57
|
+
png: 'image/png',
|
|
58
|
+
jpeg: 'image/jpeg',
|
|
59
|
+
gif: 'image/gif',
|
|
60
|
+
webp: 'image/webp',
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/** Qué es esto, mirando los primeros bytes. */
|
|
64
|
+
export function tipoDe(bytes: Uint8Array): 'png' | 'jpeg' | 'gif' | 'webp' | 'pdf' | 'zip' | 'otro' {
|
|
65
|
+
const b = bytes;
|
|
66
|
+
if (b.length < 4) return 'otro';
|
|
67
|
+
if (b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4e && b[3] === 0x47) return 'png';
|
|
68
|
+
if (b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff) return 'jpeg';
|
|
69
|
+
if (b[0] === 0x47 && b[1] === 0x49 && b[2] === 0x46) return 'gif';
|
|
70
|
+
if (b[0] === 0x52 && b[1] === 0x49 && b[2] === 0x46 && b[8] === 0x57 && b[9] === 0x45) return 'webp';
|
|
71
|
+
// El %PDF- puede venir tras unos bytes de basura; el estándar tolera hasta
|
|
72
|
+
// 1024, y hay generadores que se aprovechan.
|
|
73
|
+
const cabeza = new TextDecoder('latin1').decode(b.subarray(0, Math.min(1024, b.length)));
|
|
74
|
+
if (cabeza.includes('%PDF-')) return 'pdf';
|
|
75
|
+
if (esZip(b)) return 'zip';
|
|
76
|
+
return 'otro';
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* ¿Esto es texto?
|
|
81
|
+
*
|
|
82
|
+
* Se decodifica en modo estricto, así un binario LANZA en vez de colarse como
|
|
83
|
+
* un reguero de caracteres de reemplazo. Y aun decodificando bien, se rechaza
|
|
84
|
+
* lo que trae bytes de control: hay binarios que pasan por UTF-8 válido y
|
|
85
|
+
* mandárselos a un modelo es gastar el encargo en basura.
|
|
86
|
+
*/
|
|
87
|
+
export function comoTexto(bytes: Uint8Array): string | null {
|
|
88
|
+
try {
|
|
89
|
+
const texto = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
|
|
90
|
+
for (const ch of texto) {
|
|
91
|
+
const c = ch.codePointAt(0)!;
|
|
92
|
+
if (c < 0x20 && c !== 0x0a && c !== 0x0d && c !== 0x09) return null;
|
|
93
|
+
}
|
|
94
|
+
return texto;
|
|
95
|
+
} catch {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* El texto de un `.docx`.
|
|
102
|
+
*
|
|
103
|
+
* Un .docx es un ZIP con `word/document.xml` dentro. No hace falta entender
|
|
104
|
+
* Word: cada `</w:p>` cierra un párrafo y cada `<w:t>` envuelve un trozo de
|
|
105
|
+
* texto. Se quitan las etiquetas y quedan las palabras.
|
|
106
|
+
*
|
|
107
|
+
* Los `<w:t>` se conservan PEGADOS entre sí a propósito: Word parte una misma
|
|
108
|
+
* frase en varios cuando cambia algo del formato, y meter espacios entre ellos
|
|
109
|
+
* escribiría «contra tante» donde pone «contratante».
|
|
110
|
+
*/
|
|
111
|
+
export function textoDeDocx(bytes: Uint8Array): string | null {
|
|
112
|
+
const doc = leerZip(bytes).find((e) => e.nombre === 'word/document.xml');
|
|
113
|
+
if (!doc) return null;
|
|
114
|
+
const xml = comoTexto(doc.bytes);
|
|
115
|
+
if (!xml) return null;
|
|
116
|
+
return xml
|
|
117
|
+
.replace(/<w:p\b[^>]*\/>/g, '\n')
|
|
118
|
+
.replace(/<\/w:p>/g, '\n')
|
|
119
|
+
.replace(/<w:tab\b[^>]*\/>/g, '\t')
|
|
120
|
+
.replace(/<w:br\b[^>]*\/>/g, '\n')
|
|
121
|
+
.replace(/<[^>]+>/g, '')
|
|
122
|
+
.replace(/</g, '<')
|
|
123
|
+
.replace(/>/g, '>')
|
|
124
|
+
.replace(/&/g, '&')
|
|
125
|
+
.replace(/"/g, '"')
|
|
126
|
+
.replace(/'/g, "'")
|
|
127
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
128
|
+
.trim();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** `A1` → 0, `B1` → 1, `AA7` → 26. Sirve para no descolocar una fila con huecos. */
|
|
132
|
+
function columnaDe(ref: string): number {
|
|
133
|
+
const letras = /^([A-Z]+)/.exec(ref.toUpperCase())?.[1];
|
|
134
|
+
if (!letras) return 0;
|
|
135
|
+
let n = 0;
|
|
136
|
+
for (const c of letras) n = n * 26 + (c.charCodeAt(0) - 64);
|
|
137
|
+
return n - 1;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Lo que el XML trae escapado, de vuelta a texto. */
|
|
141
|
+
function desescapar(s: string): string {
|
|
142
|
+
return s
|
|
143
|
+
.replace(/</g, '<')
|
|
144
|
+
.replace(/>/g, '>')
|
|
145
|
+
.replace(/"/g, '"')
|
|
146
|
+
.replace(/'/g, "'")
|
|
147
|
+
.replace(/&#(\d+);/g, (_, d: string) => String.fromCodePoint(Number(d)))
|
|
148
|
+
.replace(/&/g, '&');
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* El contenido de un `.xlsx`, como filas de texto.
|
|
153
|
+
*
|
|
154
|
+
* Un Excel es otro ZIP con XML, como el .docx, pero con una vuelta de tuerca:
|
|
155
|
+
* las celdas de texto no suelen guardar el texto. Guardan un ÍNDICE a
|
|
156
|
+
* `sharedStrings.xml`, donde cada cadena aparece una sola vez aunque se repita
|
|
157
|
+
* en mil celdas. Sin resolver esa tabla, una hoja llena de nombres se lee como
|
|
158
|
+
* una lista de números.
|
|
159
|
+
*
|
|
160
|
+
* Y hay cuatro maneras de que una celda tenga texto, según quién escribiera el
|
|
161
|
+
* archivo: `t="s"` (la tabla), `t="str"` (resultado de fórmula), `t="inlineStr"`
|
|
162
|
+
* (el texto ahí mismo) y sin `t` (un número). Se contemplan las cuatro porque
|
|
163
|
+
* Excel, LibreOffice y las librerías no escogen la misma.
|
|
164
|
+
*
|
|
165
|
+
* Las filas salen separadas por tabuladores: es lo que un modelo lee como
|
|
166
|
+
* tabla sin tener que adivinar dónde acaba una celda, y no exige entrecomillar
|
|
167
|
+
* nada.
|
|
168
|
+
*/
|
|
169
|
+
export function textoDeXlsx(bytes: Uint8Array): string | null {
|
|
170
|
+
const partes = leerZip(bytes);
|
|
171
|
+
const hojas = partes
|
|
172
|
+
.filter((e) => /^xl\/worksheets\/sheet\d*\.xml$/.test(e.nombre))
|
|
173
|
+
.sort((a, b) => a.nombre.localeCompare(b.nombre));
|
|
174
|
+
if (hojas.length === 0) return null;
|
|
175
|
+
|
|
176
|
+
// La tabla de cadenas compartidas, si la hay.
|
|
177
|
+
const compartidas: string[] = [];
|
|
178
|
+
const tabla = partes.find((e) => e.nombre === 'xl/sharedStrings.xml');
|
|
179
|
+
if (tabla) {
|
|
180
|
+
const xml = comoTexto(tabla.bytes) ?? '';
|
|
181
|
+
for (const si of xml.match(/<si>[\s\S]*?<\/si>/g) ?? []) {
|
|
182
|
+
// Una cadena puede venir partida en varios <t> cuando lleva formato
|
|
183
|
+
// dentro; se pegan sin separador, como en Word.
|
|
184
|
+
compartidas.push(desescapar([...si.matchAll(/<t[^>]*>([\s\S]*?)<\/t>/g)].map((m) => m[1] ?? '').join('')));
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const salida: string[] = [];
|
|
189
|
+
for (const hoja of hojas) {
|
|
190
|
+
const xml = comoTexto(hoja.bytes);
|
|
191
|
+
if (!xml) continue;
|
|
192
|
+
const filas: string[] = [];
|
|
193
|
+
|
|
194
|
+
for (const fila of xml.match(/<row[^>]*>[\s\S]*?<\/row>/g) ?? []) {
|
|
195
|
+
const celdas: string[] = [];
|
|
196
|
+
for (const m of fila.matchAll(/<c\b([^>]*)>([\s\S]*?)<\/c>/g)) {
|
|
197
|
+
const attrs = m[1] ?? '';
|
|
198
|
+
const cuerpo = m[2] ?? '';
|
|
199
|
+
const tipo = /\bt="([^"]+)"/.exec(attrs)?.[1];
|
|
200
|
+
const ref = /\br="([^"]+)"/.exec(attrs)?.[1] ?? '';
|
|
201
|
+
|
|
202
|
+
let valor = '';
|
|
203
|
+
if (tipo === 'inlineStr') {
|
|
204
|
+
valor = desescapar([...cuerpo.matchAll(/<t[^>]*>([\s\S]*?)<\/t>/g)].map((x) => x[1] ?? '').join(''));
|
|
205
|
+
} else {
|
|
206
|
+
const v = /<v[^>]*>([\s\S]*?)<\/v>/.exec(cuerpo)?.[1] ?? '';
|
|
207
|
+
if (tipo === 's') valor = compartidas[Number(v)] ?? '';
|
|
208
|
+
else if (tipo === 'b') valor = v === '1' ? 'VERDADERO' : 'FALSO';
|
|
209
|
+
else valor = desescapar(v);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Una fila puede saltarse columnas: `A1` y luego `D1`. Colocar cada
|
|
213
|
+
// valor en SU columna es lo que evita que una tabla con huecos salga
|
|
214
|
+
// desplazada y el modelo lea el dato de otra cabecera.
|
|
215
|
+
const col = ref ? columnaDe(ref) : celdas.length;
|
|
216
|
+
while (celdas.length < col) celdas.push('');
|
|
217
|
+
celdas[col] = valor;
|
|
218
|
+
}
|
|
219
|
+
if (celdas.some((c) => c !== '')) filas.push(celdas.join('\t'));
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
if (filas.length > 0) {
|
|
223
|
+
salida.push(hojas.length > 1 ? `[hoja ${salida.length + 1}]\n${filas.join('\n')}` : filas.join('\n'));
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
return salida.length > 0 ? salida.join('\n\n') : null;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* El texto de un PDF.
|
|
232
|
+
*
|
|
233
|
+
* Se apoya en `unpdf`, que es el motor de pdf.js empaquetado. Se hizo así tras
|
|
234
|
+
* medir: un extractor a mano lee bien los PDF que genera este mismo agente y
|
|
235
|
+
* se atraganta con los de Word o Chrome, que son justo los que manda un
|
|
236
|
+
* cliente. Las codificaciones de fuente son el problema, y resolverlas es lo
|
|
237
|
+
* que hace pdf.js.
|
|
238
|
+
*
|
|
239
|
+
* Devuelve null si no sale texto: un PDF escaneado es una imagen dentro de un
|
|
240
|
+
* PDF, y sin OCR no hay nada que leer. Decirlo es mejor que entregar el vacío.
|
|
241
|
+
*/
|
|
242
|
+
export async function textoDePdf(bytes: Uint8Array): Promise<string | null> {
|
|
243
|
+
try {
|
|
244
|
+
const { extractText, getDocumentProxy } = await import('unpdf');
|
|
245
|
+
const doc = await getDocumentProxy(bytes);
|
|
246
|
+
const { text } = await extractText(doc, { mergePages: true });
|
|
247
|
+
const limpio = (Array.isArray(text) ? text.join('\n') : text).trim();
|
|
248
|
+
return limpio.length > 0 ? limpio : null;
|
|
249
|
+
} catch {
|
|
250
|
+
return null;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** Recorta y avisa de que ha recortado, que es lo que evita una cita a medias. */
|
|
255
|
+
function acotar(texto: string, tope: number): string {
|
|
256
|
+
if (texto.length <= tope) return texto;
|
|
257
|
+
return `${texto.slice(0, tope)}\n--- (recortado: son ${texto.length} caracteres) ---`;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Abre todos los adjuntos de un encargo.
|
|
262
|
+
*
|
|
263
|
+
* Es `async` porque leer un PDF lo es. Los agentes ya trabajaban en `async`,
|
|
264
|
+
* así que lo único que cambia para quien llama es un `await`.
|
|
265
|
+
*/
|
|
266
|
+
export async function leerAdjuntos(adjuntos: AdjuntoRecibido[]): Promise<AdjuntosLeidos> {
|
|
267
|
+
const partes: string[] = [];
|
|
268
|
+
const imagenes: ImagenAdjunta[] = [];
|
|
269
|
+
let gastado = 0;
|
|
270
|
+
|
|
271
|
+
const anadir = (cabecera: string, cuerpo: string): void => {
|
|
272
|
+
const queda = MAX_CHARS_TOTAL - gastado;
|
|
273
|
+
if (queda <= 200) return;
|
|
274
|
+
const trozo = acotar(cuerpo, Math.min(MAX_CHARS_POR_ADJUNTO, queda));
|
|
275
|
+
gastado += trozo.length;
|
|
276
|
+
partes.push(`--- ${cabecera} ---\n${trozo}\n--- fin ---`);
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
const noSePudo = (a: AdjuntoRecibido, motivo: string): void => {
|
|
280
|
+
partes.push(
|
|
281
|
+
`[Archivo adjunto "${a.name}"${a.mime ? ` (${a.mime})` : ''}: ${motivo}. ` +
|
|
282
|
+
`Dilo con claridad en la respuesta en vez de ignorarlo.]`,
|
|
283
|
+
);
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
for (const a of adjuntos) {
|
|
287
|
+
const tipo = tipoDe(a.bytes);
|
|
288
|
+
|
|
289
|
+
if (tipo in MIMES_IMAGEN) {
|
|
290
|
+
// La imagen no se describe: se le enseña. El agente decide si su modelo
|
|
291
|
+
// puede mirarla; aquí sólo se separa de lo que es texto.
|
|
292
|
+
imagenes.push({ mime: MIMES_IMAGEN[tipo]!, bytes: a.bytes });
|
|
293
|
+
partes.push(`[Imagen adjunta "${a.name}": va con este mensaje, míralo.]`);
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
if (tipo === 'pdf') {
|
|
298
|
+
const texto = await textoDePdf(a.bytes);
|
|
299
|
+
if (texto) anadir(`PDF adjunto: ${a.name}`, texto);
|
|
300
|
+
else noSePudo(a, 'es un PDF del que no se pudo sacar texto (probablemente escaneado)');
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
if (tipo === 'zip') {
|
|
305
|
+
// Word y Excel son ZIPs también, así que se prueban antes de tratarlo
|
|
306
|
+
// como una carpeta: leer un .xlsx entrada por entrada devolvería su XML
|
|
307
|
+
// en crudo, que para un modelo es ruido caro.
|
|
308
|
+
const docx = textoDeDocx(a.bytes);
|
|
309
|
+
if (docx) {
|
|
310
|
+
anadir(`Documento adjunto: ${a.name}`, docx);
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
const xlsx = textoDeXlsx(a.bytes);
|
|
314
|
+
if (xlsx) {
|
|
315
|
+
anadir(`Hoja de cálculo adjunta (columnas separadas por tabulador): ${a.name}`, xlsx);
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
// Un ZIP normal: una carpeta. Se leen los que sí sean texto y se
|
|
319
|
+
// NOMBRAN los que no, para que el modelo sepa qué había dentro.
|
|
320
|
+
const dentro = leerZip(a.bytes).slice(0, MAX_DENTRO_DEL_ZIP);
|
|
321
|
+
if (dentro.length === 0) {
|
|
322
|
+
noSePudo(a, 'es un archivo comprimido que no se pudo abrir');
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
const ilegibles: string[] = [];
|
|
326
|
+
for (const e of dentro) {
|
|
327
|
+
const texto = comoTexto(e.bytes);
|
|
328
|
+
if (texto === null) ilegibles.push(e.nombre);
|
|
329
|
+
else anadir(`${a.name} → ${e.nombre}`, texto);
|
|
330
|
+
}
|
|
331
|
+
if (ilegibles.length > 0) {
|
|
332
|
+
partes.push(`[Dentro de "${a.name}" hay ${ilegibles.length} archivo(s) que no son texto: ${ilegibles.join(', ')}.]`);
|
|
333
|
+
}
|
|
334
|
+
continue;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const texto = comoTexto(a.bytes);
|
|
338
|
+
if (texto !== null) anadir(`Archivo adjunto: ${a.name}`, texto);
|
|
339
|
+
else noSePudo(a, 'no es texto y este agente no puede abrirlo');
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
return { texto: partes.join('\n\n'), imagenes };
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* La imagen en el formato que espera `/chat/completions`.
|
|
347
|
+
*
|
|
348
|
+
* Vive aquí y no en cada agente porque los cuatro la necesitan igual, y una
|
|
349
|
+
* data-url mal montada falla con un error del proveedor que no dice nada.
|
|
350
|
+
*/
|
|
351
|
+
export function parteDeImagen(img: ImagenAdjunta): {
|
|
352
|
+
type: 'image_url';
|
|
353
|
+
image_url: { url: string };
|
|
354
|
+
} {
|
|
355
|
+
return {
|
|
356
|
+
type: 'image_url',
|
|
357
|
+
image_url: { url: `data:${img.mime};base64,${Buffer.from(img.bytes).toString('base64')}` },
|
|
358
|
+
};
|
|
359
|
+
}
|
package/template/src/agent.ts
CHANGED
|
@@ -11,19 +11,18 @@
|
|
|
11
11
|
* anclado en la cadena al entregar. Si luego sirves otra cosa, se nota.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
14
|
+
import { llmChat, resolverLlm, type CallEnvelope, type LlmConfig } from '@panal/sdk';
|
|
15
|
+
import { leerAdjuntos, type AdjuntoRecibido, type AdjuntosLeidos } from './adjuntos.js';
|
|
16
|
+
import { comoArchivo, formatoPedido } from './salida.js';
|
|
16
17
|
import { historialComoTexto, type Turno } from './memoria.js';
|
|
17
18
|
|
|
18
|
-
/**
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
bytes: Uint8Array;
|
|
26
|
-
}
|
|
19
|
+
/**
|
|
20
|
+
* Un archivo que el CLIENTE te mandó con el encargo.
|
|
21
|
+
*
|
|
22
|
+
* El tipo vive en `adjuntos.js`, que es quien sabe abrirlos; se reexporta aquí
|
|
23
|
+
* para tenerlo a mano sin salir de este archivo.
|
|
24
|
+
*/
|
|
25
|
+
export type { AdjuntoRecibido };
|
|
27
26
|
|
|
28
27
|
export interface TaskContext {
|
|
29
28
|
/**
|
|
@@ -45,8 +44,11 @@ export interface TaskContext {
|
|
|
45
44
|
* pagara, así que estos bytes son exactamente los que la cadena cubre — si
|
|
46
45
|
* alguien hubiera cambiado uno por el camino, no habría llegado hasta aquí.
|
|
47
46
|
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
47
|
+
* El motor los ABRE por ti antes de llamarte: las imágenes se le enseñan al
|
|
48
|
+
* modelo, y de un PDF, un Word, un Excel o una carpeta comprimida se saca el
|
|
49
|
+
* texto y entra en el encargo. Lo que no se pueda abrir se le NOMBRA al
|
|
50
|
+
* modelo en vez de callarlo. Aquí los tienes en crudo por si tu agente sabe
|
|
51
|
+
* hacer algo más con ellos.
|
|
50
52
|
*
|
|
51
53
|
* Vacío en una llamada x402: ahí no hay tarea donde anclar un adjunto.
|
|
52
54
|
*/
|
|
@@ -174,14 +176,12 @@ export async function handleTask(brief: string, ctx: TaskContext): Promise<TaskR
|
|
|
174
176
|
);
|
|
175
177
|
}
|
|
176
178
|
|
|
177
|
-
//
|
|
178
|
-
//
|
|
179
|
-
const
|
|
180
|
-
.filter((a) => esImagenSoportada(a.mime))
|
|
181
|
-
.map((a) => ({ mime: a.mime!, bytes: a.bytes }));
|
|
179
|
+
// Se abre TODO lo que mandó el cliente: imágenes para que las mire el
|
|
180
|
+
// modelo, y el texto de los PDF, Word, Excel y carpetas que vengan dentro.
|
|
181
|
+
const leido = await leerAdjuntos(ctx.adjuntos);
|
|
182
182
|
if (ctx.adjuntos.length > 0) {
|
|
183
183
|
console.log(
|
|
184
|
-
`[agente] ${etiqueta(ctx)} ${ctx.adjuntos.length} adjunto(s), ${imagenes.length} para el modelo: ` +
|
|
184
|
+
`[agente] ${etiqueta(ctx)} ${ctx.adjuntos.length} adjunto(s), ${leido.imagenes.length} para el modelo: ` +
|
|
185
185
|
ctx.adjuntos.map((a) => a.name).join(', '),
|
|
186
186
|
);
|
|
187
187
|
}
|
|
@@ -195,11 +195,11 @@ export async function handleTask(brief: string, ctx: TaskContext): Promise<TaskR
|
|
|
195
195
|
// que entregues queda anclado en la cadena y ya no se puede rectificar.
|
|
196
196
|
let queja: string | null = null;
|
|
197
197
|
for (let intento = 1; intento <= 2; intento++) {
|
|
198
|
-
const texto = await pedirAlModelo(brief, cfg, queja, ayuda,
|
|
198
|
+
const texto = await pedirAlModelo(brief, cfg, queja, ayuda, leido, ctx.historial);
|
|
199
199
|
const problema = revisar(brief, texto);
|
|
200
200
|
if (!problema) {
|
|
201
201
|
console.log(`[agente] ${etiqueta(ctx)} resuelta: ${texto.length} caracteres`);
|
|
202
|
-
return
|
|
202
|
+
return conArchivoSiLoPidio(brief, texto, ctx);
|
|
203
203
|
}
|
|
204
204
|
console.error(`[agente] ${etiqueta(ctx)} intento ${intento}: ${problema}`);
|
|
205
205
|
// A la segunda se entrega igual. Tu revisión puede equivocarse, y un falso
|
|
@@ -207,7 +207,7 @@ export async function handleTask(brief: string, ctx: TaskContext): Promise<TaskR
|
|
|
207
207
|
// entregar algo imperfecto y que él decida, que dejarlo sin nada.
|
|
208
208
|
if (intento === 2) {
|
|
209
209
|
console.error(`[agente] ${etiqueta(ctx)} se entrega pese a: ${problema}`);
|
|
210
|
-
return
|
|
210
|
+
return conArchivoSiLoPidio(brief, texto, ctx);
|
|
211
211
|
}
|
|
212
212
|
queja = problema;
|
|
213
213
|
}
|
|
@@ -372,11 +372,15 @@ function revisar(brief: string, resultado: string): string | null {
|
|
|
372
372
|
* lo ancla en la cadena, así que el cliente puede demostrar que el archivo que
|
|
373
373
|
* se baja es exactamente el que le entregaste.
|
|
374
374
|
*/
|
|
375
|
-
function
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
375
|
+
function conArchivoSiLoPidio(brief: string, texto: string, ctx: TaskContext): TaskResult {
|
|
376
|
+
const formato = formatoPedido(brief);
|
|
377
|
+
if (!formato) return texto;
|
|
378
|
+
const archivo = comoArchivo(formato, 'entrega', `Panal - entrega ${etiqueta(ctx)}`, texto);
|
|
379
|
+
const bytes = typeof archivo.data === 'string' ? archivo.data.length : archivo.data.byteLength;
|
|
380
|
+
console.log(`[agente] ${etiqueta(ctx)} ${archivo.name} de ${bytes} bytes adjunto`);
|
|
381
|
+
// El TEXTO se sigue entregando: es lo que se ancla en la cadena. El archivo
|
|
382
|
+
// va además, y su hash viaja dentro de la entrega.
|
|
383
|
+
return { text: texto, files: [archivo] };
|
|
380
384
|
}
|
|
381
385
|
|
|
382
386
|
async function pedirAlModelo(
|
|
@@ -384,8 +388,7 @@ async function pedirAlModelo(
|
|
|
384
388
|
cfg: LlmConfig,
|
|
385
389
|
queja: string | null,
|
|
386
390
|
ayuda: string | null,
|
|
387
|
-
|
|
388
|
-
adjuntos: AdjuntoRecibido[],
|
|
391
|
+
leido: AdjuntosLeidos,
|
|
389
392
|
historial: Turno[],
|
|
390
393
|
): Promise<string> {
|
|
391
394
|
// Todo va en un solo turno de usuario, con cada parte etiquetada. Los tres
|
|
@@ -404,17 +407,11 @@ async function pedirAlModelo(
|
|
|
404
407
|
|
|
405
408
|
partes.push(historial.length > 0 ? `Ahora te pide:\n${brief}` : brief);
|
|
406
409
|
|
|
407
|
-
//
|
|
408
|
-
//
|
|
409
|
-
//
|
|
410
|
-
|
|
411
|
-
if (
|
|
412
|
-
partes.push(
|
|
413
|
-
`[El cliente adjuntó estos archivos, que no puedes abrir: ${noMirables
|
|
414
|
-
.map((a) => `${a.name}${a.mime ? ` (${a.mime})` : ''}`)
|
|
415
|
-
.join(', ')}. El agente los tiene y los procesa aparte.]`,
|
|
416
|
-
);
|
|
417
|
-
}
|
|
410
|
+
// Lo que se pudo sacar de los adjuntos, ya etiquetado: el texto de un PDF,
|
|
411
|
+
// las filas de un Excel, los archivos de una carpeta. Y lo que NO se pudo
|
|
412
|
+
// abrir, nombrado — callarlo hace que el modelo conteste como si el cliente
|
|
413
|
+
// no hubiera mandado nada.
|
|
414
|
+
if (leido.texto) partes.push(leido.texto);
|
|
418
415
|
|
|
419
416
|
// Lo que contestó el especialista, si se le preguntó. Va marcado como
|
|
420
417
|
// material de apoyo y no como parte del encargo: sin esa aclaración el
|
|
@@ -465,7 +462,7 @@ async function pedirAlModelo(
|
|
|
465
462
|
'say you cannot make them. This does NOT apply to images the client sent you: those you can ' +
|
|
466
463
|
'and should refer to, because the client knows they sent them.',
|
|
467
464
|
user: partes.join('\n\n'),
|
|
468
|
-
...(imagenes.length > 0 ? { imagenes } : {}),
|
|
465
|
+
...(leido.imagenes.length > 0 ? { imagenes: leido.imagenes } : {}),
|
|
469
466
|
},
|
|
470
467
|
);
|
|
471
468
|
}
|
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Devolverle al cliente el archivo que pidió.
|
|
3
|
+
*
|
|
4
|
+
* Un agente entrega TEXTO: es lo que se le enseña al cliente y lo que se ancla
|
|
5
|
+
* en la cadena. Pero mucha gente no quiere texto en una caja, quiere un archivo
|
|
6
|
+
* que abrir, reenviar o imprimir. Esto convierte lo uno en lo otro.
|
|
7
|
+
*
|
|
8
|
+
* QUÉ SE ENTREGA SIGUE SIENDO EL TEXTO. El archivo va ADEMÁS, nunca en lugar
|
|
9
|
+
* de él: su hash se cuela en la entrega y acaba en la cadena, así que el
|
|
10
|
+
* cliente puede demostrar que el archivo que se baja es exactamente el que se
|
|
11
|
+
* le entregó. Sustituir el texto por el archivo rompería eso.
|
|
12
|
+
*
|
|
13
|
+
* Y NO SE ADJUNTA SI NO LO PIDIÓ. A varios de estos agentes los llama otro
|
|
14
|
+
* programa que va a leer la respuesta; colgarle un PDF que nadie va a abrir es
|
|
15
|
+
* peso y confusión.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { textoAPdf } from './pdf.js';
|
|
19
|
+
import { escribirZip } from './zip.js';
|
|
20
|
+
|
|
21
|
+
export type Formato = 'pdf' | 'docx' | 'xlsx' | 'md' | 'txt' | 'csv' | 'json';
|
|
22
|
+
|
|
23
|
+
export interface ArchivoDeSalida {
|
|
24
|
+
name: string;
|
|
25
|
+
data: Uint8Array | string;
|
|
26
|
+
mime?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Qué formato pidió, si es que pidió alguno.
|
|
31
|
+
*
|
|
32
|
+
* Se mira el ENCARGO, no la respuesta: es donde la persona lo dice. Y se busca
|
|
33
|
+
* en varios idiomas, porque el mercado no es sólo hispanohablante — un encargo
|
|
34
|
+
* en inglés que pide «as a Word document» tiene que salir en Word.
|
|
35
|
+
*
|
|
36
|
+
* Devuelve `null` cuando no pide nada, que es el caso normal.
|
|
37
|
+
*/
|
|
38
|
+
export function formatoPedido(brief: string): Formato | null {
|
|
39
|
+
const t = brief.toLowerCase();
|
|
40
|
+
const mencion: { formato: Formato; en: number }[] = [];
|
|
41
|
+
|
|
42
|
+
for (const [formato, patron] of PATRONES) {
|
|
43
|
+
for (const m of t.matchAll(patron)) mencion.push({ formato, en: m.index });
|
|
44
|
+
}
|
|
45
|
+
if (mencion.length === 0) return null;
|
|
46
|
+
|
|
47
|
+
// Las que hablan del archivo que ENTRÓ no cuentan. Sin esto, «lee el PDF
|
|
48
|
+
// adjunto y devuélvemelo en Word» entregaba un PDF: el primer formato que
|
|
49
|
+
// aparecía era el de la entrada. Pasó en una prueba de punta a punta, que es
|
|
50
|
+
// donde se ve y no en una frase inventada.
|
|
51
|
+
const deSalida = mencion.filter((x) => !esDeEntrada(t, x.en));
|
|
52
|
+
if (deSalida.length === 0) return null;
|
|
53
|
+
|
|
54
|
+
// Si alguna viene precedida de un verbo de entrega, ésa es la buena.
|
|
55
|
+
const pedida = deSalida.find((x) => ENTREGA.test(t.slice(Math.max(0, x.en - 40), x.en)));
|
|
56
|
+
if (pedida) return pedida.formato;
|
|
57
|
+
|
|
58
|
+
// Y si no, la ÚLTIMA: el formato de salida se suele decir al final.
|
|
59
|
+
return deSalida[deSalida.length - 1]!.formato;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Cómo se nombra cada formato, en los idiomas del mercado. */
|
|
63
|
+
const PATRONES: [Formato, RegExp][] = [
|
|
64
|
+
['pdf', /\bpdfs?\b/g],
|
|
65
|
+
['docx', /\bdocx?\b|\bword\b/g],
|
|
66
|
+
// «hoja de cálculo» y «spreadsheet» van a Excel, no a CSV: quien lo pide así
|
|
67
|
+
// quiere abrirlo y sumar, no un archivo de texto con comas.
|
|
68
|
+
['xlsx', /\bxlsx?\b|\bexcel\b|hoja de c[aá]lculo|\bspreadsheet\b/g],
|
|
69
|
+
['csv', /\bcsvs?\b/g],
|
|
70
|
+
['json', /\bjson\b/g],
|
|
71
|
+
['md', /\bmarkdown\b|\bmd\b/g],
|
|
72
|
+
['txt', /\btxt\b|texto plano|plain text|archivo de texto|text file/g],
|
|
73
|
+
];
|
|
74
|
+
|
|
75
|
+
/** Que se lo den a uno: lo que distingue pedir un formato de nombrarlo. */
|
|
76
|
+
const ENTREGA =
|
|
77
|
+
/\b(devu[eé]lve|dame|d[aá]melo|entr[eé]ga|env[ií]a|quiero|genera|crea|exporta|conviert|p[aá]sa|as an?|in|into|return|output|format[oe]?|como)\b[^.]{0,30}$/;
|
|
78
|
+
|
|
79
|
+
/** Y lo que delata que se habla del archivo que MANDÓ el cliente. */
|
|
80
|
+
const ENTRADA = /\b(adjunt\w*|attach\w*|subid\w*|uploaded|este|esta|el|la|mi|my|the)\b/;
|
|
81
|
+
|
|
82
|
+
function esDeEntrada(t: string, en: number): boolean {
|
|
83
|
+
const antes = t.slice(Math.max(0, en - 18), en);
|
|
84
|
+
const despues = t.slice(en, en + 30);
|
|
85
|
+
// «el PDF adjunto», «the attached pdf», «mi word»: se habla de lo que entró.
|
|
86
|
+
return /\badjunt|attach|\bsub[ií]|uploaded|que te (mand|pas|envi)/.test(despues) || (ENTRADA.test(antes) && /\badjunt|attach/.test(despues));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** La extensión y el tipo de cada formato. */
|
|
90
|
+
const TIPOS: Record<Formato, { ext: string; mime: string }> = {
|
|
91
|
+
pdf: { ext: 'pdf', mime: 'application/pdf' },
|
|
92
|
+
docx: {
|
|
93
|
+
ext: 'docx',
|
|
94
|
+
mime: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
95
|
+
},
|
|
96
|
+
xlsx: {
|
|
97
|
+
ext: 'xlsx',
|
|
98
|
+
mime: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
99
|
+
},
|
|
100
|
+
md: { ext: 'md', mime: 'text/markdown; charset=utf-8' },
|
|
101
|
+
txt: { ext: 'txt', mime: 'text/plain; charset=utf-8' },
|
|
102
|
+
csv: { ext: 'csv', mime: 'text/csv; charset=utf-8' },
|
|
103
|
+
json: { ext: 'json', mime: 'application/json; charset=utf-8' },
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Lo que XML no admite tal cual.
|
|
108
|
+
*
|
|
109
|
+
* Los caracteres de control se quitan además de escapar: uno solo hace que
|
|
110
|
+
* Word se niegue a abrir el archivo ENTERO, sin decir cuál era.
|
|
111
|
+
*/
|
|
112
|
+
function escaparXml(s: string): string {
|
|
113
|
+
return s
|
|
114
|
+
.replace(/&/g, '&')
|
|
115
|
+
.replace(/</g, '<')
|
|
116
|
+
.replace(/>/g, '>')
|
|
117
|
+
.replace(/"/g, '"')
|
|
118
|
+
.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/g, '');
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Un `.docx` de verdad, con lo mínimo que Word exige para abrirlo.
|
|
123
|
+
*
|
|
124
|
+
* Un .docx es un ZIP con tres archivos dentro. No hace falta ninguna librería:
|
|
125
|
+
* cada línea del texto es un `<w:p>` y ya está.
|
|
126
|
+
*/
|
|
127
|
+
export function textoADocx(titulo: string, texto: string): Uint8Array {
|
|
128
|
+
const parrafo = (linea: string, negrita = false): string =>
|
|
129
|
+
`<w:p><w:r>${negrita ? '<w:rPr><w:b/></w:rPr>' : ''}` +
|
|
130
|
+
`<w:t xml:space="preserve">${escaparXml(linea)}</w:t></w:r></w:p>`;
|
|
131
|
+
|
|
132
|
+
const cuerpo = [parrafo(titulo, true), ...texto.split(/\r?\n/).map((l) => parrafo(l))].join('');
|
|
133
|
+
|
|
134
|
+
const documento =
|
|
135
|
+
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
|
|
136
|
+
'<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">' +
|
|
137
|
+
`<w:body>${cuerpo}</w:body></w:document>`;
|
|
138
|
+
|
|
139
|
+
const tipos =
|
|
140
|
+
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
|
|
141
|
+
'<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">' +
|
|
142
|
+
'<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>' +
|
|
143
|
+
'<Default Extension="xml" ContentType="application/xml"/>' +
|
|
144
|
+
'<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>' +
|
|
145
|
+
'</Types>';
|
|
146
|
+
|
|
147
|
+
const rels =
|
|
148
|
+
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
|
|
149
|
+
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">' +
|
|
150
|
+
'<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>' +
|
|
151
|
+
'</Relationships>';
|
|
152
|
+
|
|
153
|
+
const b = (s: string): Uint8Array => new TextEncoder().encode(s);
|
|
154
|
+
return escribirZip([
|
|
155
|
+
{ nombre: '[Content_Types].xml', bytes: b(tipos) },
|
|
156
|
+
{ nombre: '_rels/.rels', bytes: b(rels) },
|
|
157
|
+
{ nombre: 'word/document.xml', bytes: b(documento) },
|
|
158
|
+
]);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Cómo está separada una tabla en texto.
|
|
163
|
+
*
|
|
164
|
+
* Se decide mirando TODAS las líneas y no la primera: una tabla cuya cabecera
|
|
165
|
+
* lleva una coma en un título —«Ventas, por región»— haría creer que el
|
|
166
|
+
* separador es la coma cuando en realidad es el tabulador.
|
|
167
|
+
*/
|
|
168
|
+
function separadorDe(lineas: string[]): '\t' | ',' | null {
|
|
169
|
+
const conTab = lineas.filter((l) => l.includes('\t')).length;
|
|
170
|
+
if (conTab >= lineas.length / 2) return '\t';
|
|
171
|
+
const conComa = lineas.filter((l) => l.includes(',')).length;
|
|
172
|
+
if (conComa >= lineas.length / 2) return ',';
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Un CSV puede traer campos entrecomillados con comas dentro. */
|
|
177
|
+
function partirCsv(linea: string): string[] {
|
|
178
|
+
const campos: string[] = [];
|
|
179
|
+
let actual = '';
|
|
180
|
+
let dentro = false;
|
|
181
|
+
for (let i = 0; i < linea.length; i++) {
|
|
182
|
+
const c = linea[i]!;
|
|
183
|
+
if (c === '"') {
|
|
184
|
+
if (dentro && linea[i + 1] === '"') {
|
|
185
|
+
actual += '"';
|
|
186
|
+
i++;
|
|
187
|
+
} else dentro = !dentro;
|
|
188
|
+
} else if (c === ',' && !dentro) {
|
|
189
|
+
campos.push(actual);
|
|
190
|
+
actual = '';
|
|
191
|
+
} else actual += c;
|
|
192
|
+
}
|
|
193
|
+
campos.push(actual);
|
|
194
|
+
return campos;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** `0` → A, `26` → AA. */
|
|
198
|
+
function letraDe(col: number): string {
|
|
199
|
+
let s = '';
|
|
200
|
+
let n = col + 1;
|
|
201
|
+
while (n > 0) {
|
|
202
|
+
const r = (n - 1) % 26;
|
|
203
|
+
s = String.fromCharCode(65 + r) + s;
|
|
204
|
+
n = Math.floor((n - 1) / 26);
|
|
205
|
+
}
|
|
206
|
+
return s;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Un `.xlsx` con lo mínimo que Excel exige.
|
|
211
|
+
*
|
|
212
|
+
* Los números se escriben COMO NÚMEROS y no como texto. Es la diferencia entre
|
|
213
|
+
* una hoja con la que se puede sumar y una en la que cada celda lleva el
|
|
214
|
+
* triangulito verde de «esto parece un número guardado como texto» — que es
|
|
215
|
+
* justo lo que va a hacer quien pide un Excel: sumar.
|
|
216
|
+
*
|
|
217
|
+
* Las cadenas van en línea (`inlineStr`) en vez de en una tabla compartida:
|
|
218
|
+
* ocupa algo más y ahorra una parte entera del archivo, y aquí el tamaño no es
|
|
219
|
+
* el problema.
|
|
220
|
+
*/
|
|
221
|
+
export function textoAXlsx(titulo: string, texto: string): Uint8Array {
|
|
222
|
+
const lineas = texto.split(/\r?\n/).filter((l, i, a) => l !== '' || i < a.length - 1);
|
|
223
|
+
const sep = separadorDe(lineas);
|
|
224
|
+
const filas = lineas.map((l) => (sep === ',' ? partirCsv(l) : sep === '\t' ? l.split('\t') : [l]));
|
|
225
|
+
|
|
226
|
+
const celdas = (fila: string[], nFila: number): string =>
|
|
227
|
+
fila
|
|
228
|
+
.map((valor, col) => {
|
|
229
|
+
const ref = `${letraDe(col)}${nFila}`;
|
|
230
|
+
if (valor === '') return '';
|
|
231
|
+
// Un número es un número; todo lo demás, texto.
|
|
232
|
+
return /^-?\d+([.,]\d+)?$/.test(valor.trim())
|
|
233
|
+
? `<c r="${ref}"><v>${valor.trim().replace(',', '.')}</v></c>`
|
|
234
|
+
: `<c r="${ref}" t="inlineStr"><is><t xml:space="preserve">${escaparXml(valor)}</t></is></c>`;
|
|
235
|
+
})
|
|
236
|
+
.join('');
|
|
237
|
+
|
|
238
|
+
const sheet =
|
|
239
|
+
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
|
|
240
|
+
'<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><sheetData>' +
|
|
241
|
+
filas.map((f, i) => `<row r="${i + 1}">${celdas(f, i + 1)}</row>`).join('') +
|
|
242
|
+
'</sheetData></worksheet>';
|
|
243
|
+
|
|
244
|
+
const workbook =
|
|
245
|
+
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
|
|
246
|
+
'<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" ' +
|
|
247
|
+
'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">' +
|
|
248
|
+
`<sheets><sheet name="${escaparXml(titulo).slice(0, 31)}" sheetId="1" r:id="rId1"/></sheets></workbook>`;
|
|
249
|
+
|
|
250
|
+
const workbookRels =
|
|
251
|
+
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
|
|
252
|
+
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">' +
|
|
253
|
+
'<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/>' +
|
|
254
|
+
'</Relationships>';
|
|
255
|
+
|
|
256
|
+
const tipos =
|
|
257
|
+
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
|
|
258
|
+
'<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">' +
|
|
259
|
+
'<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>' +
|
|
260
|
+
'<Default Extension="xml" ContentType="application/xml"/>' +
|
|
261
|
+
'<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>' +
|
|
262
|
+
'<Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>' +
|
|
263
|
+
'</Types>';
|
|
264
|
+
|
|
265
|
+
const rels =
|
|
266
|
+
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
|
|
267
|
+
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">' +
|
|
268
|
+
'<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>' +
|
|
269
|
+
'</Relationships>';
|
|
270
|
+
|
|
271
|
+
const b = (s: string): Uint8Array => new TextEncoder().encode(s);
|
|
272
|
+
return escribirZip([
|
|
273
|
+
{ nombre: '[Content_Types].xml', bytes: b(tipos) },
|
|
274
|
+
{ nombre: '_rels/.rels', bytes: b(rels) },
|
|
275
|
+
{ nombre: 'xl/workbook.xml', bytes: b(workbook) },
|
|
276
|
+
{ nombre: 'xl/_rels/workbook.xml.rels', bytes: b(workbookRels) },
|
|
277
|
+
{ nombre: 'xl/worksheets/sheet1.xml', bytes: b(sheet) },
|
|
278
|
+
]);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Una tabla, si el texto entregado la lleva dentro.
|
|
283
|
+
*
|
|
284
|
+
* Nace de un resultado real y malo: se le pidió a un agente una hoja de
|
|
285
|
+
* cálculo, entregó su JSON de siempre —correcto— y el Excel salió con UNA
|
|
286
|
+
* columna de frases, porque el texto no traía ni comas ni tabuladores. Válido
|
|
287
|
+
* y sin ningún valor: quien pide un Excel quiere columnas para sumarlas.
|
|
288
|
+
*
|
|
289
|
+
* Así que antes de montar un xlsx o un csv se mira si lo entregado es JSON con
|
|
290
|
+
* una lista de objetos planos. Si lo es, sus claves son la cabecera. Si no, se
|
|
291
|
+
* sigue como antes.
|
|
292
|
+
*/
|
|
293
|
+
export function comoTabla(texto: string): string | null {
|
|
294
|
+
let dato: unknown;
|
|
295
|
+
try {
|
|
296
|
+
dato = JSON.parse(texto);
|
|
297
|
+
} catch {
|
|
298
|
+
return null;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// La lista puede ser la raíz, o estar dentro bajo cualquier nombre —los
|
|
302
|
+
// agentes la llaman `hallazgos`, `entries`, `puertos`…
|
|
303
|
+
const lista = Array.isArray(dato)
|
|
304
|
+
? dato
|
|
305
|
+
: dato && typeof dato === 'object'
|
|
306
|
+
? Object.values(dato as Record<string, unknown>).find(
|
|
307
|
+
(v): v is unknown[] => Array.isArray(v) && v.length > 0,
|
|
308
|
+
)
|
|
309
|
+
: undefined;
|
|
310
|
+
if (!lista || lista.length === 0) return null;
|
|
311
|
+
|
|
312
|
+
const filas = lista.filter(
|
|
313
|
+
(x): x is Record<string, unknown> => !!x && typeof x === 'object' && !Array.isArray(x),
|
|
314
|
+
);
|
|
315
|
+
if (filas.length !== lista.length) return null;
|
|
316
|
+
|
|
317
|
+
// La cabecera es la unión de las claves, en el orden en que aparecen: una
|
|
318
|
+
// fila a la que le falte un campo no puede descolocar a las demás.
|
|
319
|
+
const columnas: string[] = [];
|
|
320
|
+
for (const f of filas) for (const k of Object.keys(f)) if (!columnas.includes(k)) columnas.push(k);
|
|
321
|
+
if (columnas.length === 0) return null;
|
|
322
|
+
|
|
323
|
+
const celda = (v: unknown): string => {
|
|
324
|
+
if (v === null || v === undefined) return '';
|
|
325
|
+
// Un objeto anidado no cabe en una celda; se pone su JSON antes que
|
|
326
|
+
// «[object Object]», que no le sirve a nadie.
|
|
327
|
+
if (typeof v === 'object') return JSON.stringify(v);
|
|
328
|
+
return String(v).replace(/[\t\r\n]+/g, ' ');
|
|
329
|
+
};
|
|
330
|
+
|
|
331
|
+
return [
|
|
332
|
+
columnas.map((c) => c.replace(/[_-]+/g, ' ')).join('\t'),
|
|
333
|
+
...filas.map((f) => columnas.map((c) => celda(f[c])).join('\t')),
|
|
334
|
+
].join('\n');
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* El archivo listo para adjuntar a la entrega.
|
|
339
|
+
*
|
|
340
|
+
* `paraLeer` es la versión legible del contenido, y existe por un caso real:
|
|
341
|
+
* hay agentes cuyo texto entregado es JSON —bueno para una máquina, ilegible
|
|
342
|
+
* dentro de un PDF—, y ahí se les pasa aparte lo que debe ver una persona. Si
|
|
343
|
+
* no se da, se usa el texto tal cual.
|
|
344
|
+
*/
|
|
345
|
+
export function comoArchivo(
|
|
346
|
+
formato: Formato,
|
|
347
|
+
nombreBase: string,
|
|
348
|
+
titulo: string,
|
|
349
|
+
texto: string,
|
|
350
|
+
paraLeer?: string,
|
|
351
|
+
): ArchivoDeSalida {
|
|
352
|
+
const { ext, mime } = TIPOS[formato];
|
|
353
|
+
const name = `${nombreBase}.${ext}`;
|
|
354
|
+
// Para un Excel o un CSV se busca primero una TABLA dentro de lo entregado:
|
|
355
|
+
// la versión en prosa daría una sola columna de frases, que es un archivo
|
|
356
|
+
// válido y sin ningún valor para quien lo pidió para sumar.
|
|
357
|
+
const tabla = formato === 'xlsx' || formato === 'csv' ? comoTabla(texto) : null;
|
|
358
|
+
const legible = tabla ?? paraLeer ?? texto;
|
|
359
|
+
|
|
360
|
+
switch (formato) {
|
|
361
|
+
case 'pdf':
|
|
362
|
+
return { name, data: textoAPdf(titulo, legible), mime };
|
|
363
|
+
case 'docx':
|
|
364
|
+
return { name, data: textoADocx(titulo, legible), mime };
|
|
365
|
+
case 'xlsx':
|
|
366
|
+
return { name, data: textoAXlsx(titulo, legible), mime };
|
|
367
|
+
// El markdown lleva el título como encabezado, porque es lo que un `.md`
|
|
368
|
+
// hace. Los demás van tal cual: un CSV con un `#` delante deja de ser CSV.
|
|
369
|
+
case 'md':
|
|
370
|
+
return { name, data: `# ${titulo}\n\n${legible}\n`, mime };
|
|
371
|
+
case 'csv':
|
|
372
|
+
// Una tabla en tabuladores se convierte a comas; si no había tabla, el
|
|
373
|
+
// texto va tal cual, que es lo que ya hacía.
|
|
374
|
+
return { name, data: tabla ? aCsv(tabla) : texto, mime };
|
|
375
|
+
default:
|
|
376
|
+
return { name, data: texto, mime };
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/** Tabuladores a comas, entrecomillando sólo lo que lo necesita. */
|
|
381
|
+
function aCsv(tabla: string): string {
|
|
382
|
+
return tabla
|
|
383
|
+
.split('\n')
|
|
384
|
+
.map((fila) =>
|
|
385
|
+
fila
|
|
386
|
+
.split('\t')
|
|
387
|
+
.map((c) => (/[",\n]/.test(c) ? `"${c.replace(/"/g, '""')}"` : c))
|
|
388
|
+
.join(','),
|
|
389
|
+
)
|
|
390
|
+
.join('\n');
|
|
391
|
+
}
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Leer un ZIP sin dependencias.
|
|
3
|
+
*
|
|
4
|
+
* Hace falta para DOS cosas que parecen distintas y son la misma: una carpeta
|
|
5
|
+
* que el cliente comprimió, y un `.docx` —que es un ZIP con XML dentro—. Con
|
|
6
|
+
* un lector se resuelven las dos.
|
|
7
|
+
*
|
|
8
|
+
* Node trae `zlib`, que es el 90% del trabajo. Lo que falta es entender la
|
|
9
|
+
* estructura del archivo, y es poca cosa: se lee el directorio central del
|
|
10
|
+
* final, no las cabeceras locales, porque el directorio central es el índice
|
|
11
|
+
* fiable —las locales pueden traer los tamaños a cero y remitir a un
|
|
12
|
+
* descriptor que va DETRÁS de los datos—.
|
|
13
|
+
*
|
|
14
|
+
* ───────────────────────────────────────────────────────────────────────────
|
|
15
|
+
* ESTO LO MANDA UN DESCONOCIDO Y HAY QUE TRATARLO COMO TAL.
|
|
16
|
+
*
|
|
17
|
+
* El ZIP llega de un cliente que pagó, pero pagar no vuelve a nadie de fiar.
|
|
18
|
+
* Tres defensas, y las tres importan:
|
|
19
|
+
*
|
|
20
|
+
* - Una bomba zip: 42 kB que se descomprimen en petabytes. Se mira el tamaño
|
|
21
|
+
* DECLARADO antes de descomprimir y se lleva un total acumulado, así que
|
|
22
|
+
* se corta antes de reservar la memoria, no después.
|
|
23
|
+
* - Rutas con `..` o absolutas: aquí nada se escribe en disco, pero el
|
|
24
|
+
* nombre viaja al modelo y acaba en logs. Se normaliza igual.
|
|
25
|
+
* - Un ZIP con cien mil entradas vacías, que no infla memoria pero sí tiempo.
|
|
26
|
+
* ───────────────────────────────────────────────────────────────────────────
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { inflateRawSync } from 'node:zlib';
|
|
30
|
+
|
|
31
|
+
/** Un archivo dentro del ZIP, ya descomprimido. */
|
|
32
|
+
export interface EntradaZip {
|
|
33
|
+
/** La ruta dentro del ZIP, ya normalizada. */
|
|
34
|
+
nombre: string;
|
|
35
|
+
bytes: Uint8Array;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface LimitesZip {
|
|
39
|
+
/** Cuántas entradas se miran como mucho. */
|
|
40
|
+
maxEntradas: number;
|
|
41
|
+
/** Tope del total descomprimido, sumando todas. */
|
|
42
|
+
maxTotalBytes: number;
|
|
43
|
+
/** Tope de una sola entrada. */
|
|
44
|
+
maxEntradaBytes: number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export const LIMITES_ZIP: LimitesZip = {
|
|
48
|
+
maxEntradas: 200,
|
|
49
|
+
maxTotalBytes: 32 * 1024 * 1024,
|
|
50
|
+
maxEntradaBytes: 8 * 1024 * 1024,
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/** Los cuatro bytes con los que empieza todo ZIP (y todo .docx, .xlsx, .odt). */
|
|
54
|
+
export function esZip(bytes: Uint8Array): boolean {
|
|
55
|
+
return bytes.length > 4 && bytes[0] === 0x50 && bytes[1] === 0x4b && bytes[2] === 0x03 && bytes[3] === 0x04;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const u16 = (b: Uint8Array, i: number): number => b[i]! | (b[i + 1]! << 8);
|
|
59
|
+
const u32 = (b: Uint8Array, i: number): number =>
|
|
60
|
+
(b[i]! | (b[i + 1]! << 8) | (b[i + 2]! << 16) | (b[i + 3]! << 24)) >>> 0;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Quita lo que haría daño de una ruta interna.
|
|
64
|
+
*
|
|
65
|
+
* No se escribe nada en disco, así que esto no evita un escape de directorio:
|
|
66
|
+
* evita que un nombre inventado —`../../etc/passwd`— llegue al modelo y a los
|
|
67
|
+
* logs como si fuera un archivo de verdad del cliente.
|
|
68
|
+
*/
|
|
69
|
+
function rutaLimpia(nombre: string): string {
|
|
70
|
+
return nombre
|
|
71
|
+
.replace(/\\/g, '/')
|
|
72
|
+
.split('/')
|
|
73
|
+
.filter((p) => p && p !== '.' && p !== '..')
|
|
74
|
+
.join('/')
|
|
75
|
+
.slice(0, 200);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Dónde empieza el directorio central. Se busca desde el final. */
|
|
79
|
+
function buscarDirectorio(b: Uint8Array): number | null {
|
|
80
|
+
// El comentario final puede ocupar hasta 64 kB, así que no basta con mirar
|
|
81
|
+
// los últimos 22 bytes.
|
|
82
|
+
const desde = Math.max(0, b.length - 22 - 0xffff);
|
|
83
|
+
for (let i = b.length - 22; i >= desde; i--) {
|
|
84
|
+
if (u32(b, i) === 0x06054b50) return u32(b, i + 16);
|
|
85
|
+
}
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Los archivos de un ZIP, descomprimidos y acotados.
|
|
91
|
+
*
|
|
92
|
+
* Las carpetas y las entradas vacías se saltan solas: lo que interesa es el
|
|
93
|
+
* contenido. Una entrada que no se puede descomprimir se OMITE en vez de
|
|
94
|
+
* tumbar la lectura entera — un ZIP con un archivo roto sigue teniendo diez
|
|
95
|
+
* buenos, y el cliente ya pagó por que se mire lo que sí se puede.
|
|
96
|
+
*/
|
|
97
|
+
export function leerZip(datos: Uint8Array, limites: LimitesZip = LIMITES_ZIP): EntradaZip[] {
|
|
98
|
+
const inicio = buscarDirectorio(datos);
|
|
99
|
+
if (inicio === null || inicio >= datos.length) return [];
|
|
100
|
+
|
|
101
|
+
const salida: EntradaZip[] = [];
|
|
102
|
+
let total = 0;
|
|
103
|
+
let cursor = inicio;
|
|
104
|
+
|
|
105
|
+
while (cursor + 46 <= datos.length && u32(datos, cursor) === 0x02014b50) {
|
|
106
|
+
const metodo = u16(datos, cursor + 10);
|
|
107
|
+
const comprimido = u32(datos, cursor + 20);
|
|
108
|
+
const sinComprimir = u32(datos, cursor + 24);
|
|
109
|
+
const nLargo = u16(datos, cursor + 28);
|
|
110
|
+
const extraLargo = u16(datos, cursor + 30);
|
|
111
|
+
const comentarioLargo = u16(datos, cursor + 32);
|
|
112
|
+
const offsetLocal = u32(datos, cursor + 42);
|
|
113
|
+
const nombre = rutaLimpia(new TextDecoder().decode(datos.subarray(cursor + 46, cursor + 46 + nLargo)));
|
|
114
|
+
|
|
115
|
+
cursor += 46 + nLargo + extraLargo + comentarioLargo;
|
|
116
|
+
|
|
117
|
+
if (salida.length >= limites.maxEntradas) break;
|
|
118
|
+
// El tamaño se comprueba ANTES de descomprimir: es lo único que separa
|
|
119
|
+
// esto de reservar los petabytes que la bomba pide.
|
|
120
|
+
if (!nombre || sinComprimir === 0) continue;
|
|
121
|
+
if (sinComprimir > limites.maxEntradaBytes) continue;
|
|
122
|
+
if (total + sinComprimir > limites.maxTotalBytes) break;
|
|
123
|
+
|
|
124
|
+
// Ahora sí hay que mirar la cabecera local, sólo para saber dónde
|
|
125
|
+
// empiezan los datos: su longitud de extra puede ser distinta de la del
|
|
126
|
+
// directorio central, y darlo por hecho desplaza la lectura.
|
|
127
|
+
if (offsetLocal + 30 > datos.length || u32(datos, offsetLocal) !== 0x04034b50) continue;
|
|
128
|
+
const datosEn = offsetLocal + 30 + u16(datos, offsetLocal + 26) + u16(datos, offsetLocal + 28);
|
|
129
|
+
if (datosEn + comprimido > datos.length) continue;
|
|
130
|
+
const crudo = datos.subarray(datosEn, datosEn + comprimido);
|
|
131
|
+
|
|
132
|
+
try {
|
|
133
|
+
const bytes = metodo === 0 ? crudo : metodo === 8 ? new Uint8Array(inflateRawSync(crudo)) : null;
|
|
134
|
+
if (!bytes) continue;
|
|
135
|
+
total += bytes.length;
|
|
136
|
+
salida.push({ nombre, bytes });
|
|
137
|
+
} catch {
|
|
138
|
+
// Entrada corrupta o cifrada: se omite y se sigue con las demás.
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return salida;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/* ══════════════════════════════════════════════════════════════════════════
|
|
147
|
+
* ESCRIBIR
|
|
148
|
+
*
|
|
149
|
+
* Hace falta para devolver un `.docx`, que es un ZIP con XML dentro. El mismo
|
|
150
|
+
* formato que se lee arriba, al revés.
|
|
151
|
+
* ══════════════════════════════════════════════════════════════════════════ */
|
|
152
|
+
|
|
153
|
+
import { deflateRawSync } from 'node:zlib';
|
|
154
|
+
|
|
155
|
+
/** CRC-32, que el ZIP exige por entrada. Tabla al vuelo: son 256 valores. */
|
|
156
|
+
function crc32(bytes: Uint8Array): number {
|
|
157
|
+
let c: number;
|
|
158
|
+
const tabla: number[] = [];
|
|
159
|
+
for (let n = 0; n < 256; n++) {
|
|
160
|
+
c = n;
|
|
161
|
+
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
|
162
|
+
tabla[n] = c >>> 0;
|
|
163
|
+
}
|
|
164
|
+
let crc = 0xffffffff;
|
|
165
|
+
for (const b of bytes) crc = tabla[(crc ^ b) & 0xff]! ^ (crc >>> 8);
|
|
166
|
+
return (crc ^ 0xffffffff) >>> 0;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function escribirU16(v: number): number[] {
|
|
170
|
+
return [v & 0xff, (v >>> 8) & 0xff];
|
|
171
|
+
}
|
|
172
|
+
function escribirU32(v: number): number[] {
|
|
173
|
+
return [v & 0xff, (v >>> 8) & 0xff, (v >>> 16) & 0xff, (v >>> 24) & 0xff];
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Monta un ZIP.
|
|
178
|
+
*
|
|
179
|
+
* Se comprime todo con deflate. Sin fecha real —se pone la del epoch de MS-DOS
|
|
180
|
+
* y ya— porque un archivo que cambia de bytes cada vez que se genera rompe una
|
|
181
|
+
* propiedad que aquí importa: el hash de la entrega se ancla en la cadena, y
|
|
182
|
+
* generar dos veces lo mismo tiene que dar exactamente lo mismo.
|
|
183
|
+
*/
|
|
184
|
+
export function escribirZip(entradas: { nombre: string; bytes: Uint8Array }[]): Uint8Array {
|
|
185
|
+
const local: number[] = [];
|
|
186
|
+
const central: number[] = [];
|
|
187
|
+
let offset = 0;
|
|
188
|
+
|
|
189
|
+
for (const e of entradas) {
|
|
190
|
+
const nombre = [...new TextEncoder().encode(e.nombre)];
|
|
191
|
+
const comprimido = [...new Uint8Array(deflateRawSync(e.bytes))];
|
|
192
|
+
const crc = crc32(e.bytes);
|
|
193
|
+
|
|
194
|
+
const cabecera = [
|
|
195
|
+
...escribirU32(0x04034b50),
|
|
196
|
+
...escribirU16(20), // versión mínima
|
|
197
|
+
...escribirU16(0),
|
|
198
|
+
...escribirU16(8), // deflate
|
|
199
|
+
...escribirU16(0), // hora
|
|
200
|
+
...escribirU16(0x21), // fecha: 1980-01-01
|
|
201
|
+
...escribirU32(crc),
|
|
202
|
+
...escribirU32(comprimido.length),
|
|
203
|
+
...escribirU32(e.bytes.length),
|
|
204
|
+
...escribirU16(nombre.length),
|
|
205
|
+
...escribirU16(0),
|
|
206
|
+
...nombre,
|
|
207
|
+
];
|
|
208
|
+
local.push(...cabecera, ...comprimido);
|
|
209
|
+
|
|
210
|
+
central.push(
|
|
211
|
+
...escribirU32(0x02014b50),
|
|
212
|
+
...escribirU16(20), // versión que lo creó
|
|
213
|
+
...escribirU16(20),
|
|
214
|
+
...escribirU16(0),
|
|
215
|
+
...escribirU16(8),
|
|
216
|
+
...escribirU16(0),
|
|
217
|
+
...escribirU16(0x21),
|
|
218
|
+
...escribirU32(crc),
|
|
219
|
+
...escribirU32(comprimido.length),
|
|
220
|
+
...escribirU32(e.bytes.length),
|
|
221
|
+
...escribirU16(nombre.length),
|
|
222
|
+
...escribirU16(0),
|
|
223
|
+
...escribirU16(0), // comentario
|
|
224
|
+
...escribirU16(0), // disco
|
|
225
|
+
...escribirU16(0), // atributos internos
|
|
226
|
+
...escribirU32(0), // atributos externos
|
|
227
|
+
...escribirU32(offset),
|
|
228
|
+
...nombre,
|
|
229
|
+
);
|
|
230
|
+
offset += cabecera.length + comprimido.length;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const fin = [
|
|
234
|
+
...escribirU32(0x06054b50),
|
|
235
|
+
...escribirU16(0),
|
|
236
|
+
...escribirU16(0),
|
|
237
|
+
...escribirU16(entradas.length),
|
|
238
|
+
...escribirU16(entradas.length),
|
|
239
|
+
...escribirU32(central.length),
|
|
240
|
+
...escribirU32(local.length),
|
|
241
|
+
...escribirU16(0),
|
|
242
|
+
];
|
|
243
|
+
|
|
244
|
+
return new Uint8Array([...local, ...central, ...fin]);
|
|
245
|
+
}
|