talon-agent 1.47.0 → 1.47.2
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 +2 -1
- package/src/core/engine/gateway-actions/fetch-url.ts +78 -64
- package/src/core/engine/gateway-actions/native.ts +61 -6
- package/src/core/engine/gateway-actions/shared.ts +2 -13
- package/src/core/tools/bridge.ts +30 -5
- package/src/core/types.ts +6 -0
- package/src/util/http-body.ts +51 -0
- package/src/util/web-content.ts +115 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "talon-agent",
|
|
3
|
-
"version": "1.47.
|
|
3
|
+
"version": "1.47.2",
|
|
4
4
|
"description": "Multi-frontend AI agent with full tool access, streaming, cron jobs, and plugin system",
|
|
5
5
|
"author": "Dylan Neve",
|
|
6
6
|
"license": "MIT",
|
|
@@ -110,6 +110,7 @@
|
|
|
110
110
|
"croner": "^10.0.1",
|
|
111
111
|
"cross-spawn": "^7.0.6",
|
|
112
112
|
"discord.js": "^14.16.3",
|
|
113
|
+
"file-type": "^22.0.1",
|
|
113
114
|
"grammy": "^1.42.0",
|
|
114
115
|
"liquidjs": "^10.27.0",
|
|
115
116
|
"marked": "^18.0.0",
|
|
@@ -3,12 +3,35 @@
|
|
|
3
3
|
* binary content (validated by magic bytes) into the uploads workspace.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
+
import { randomUUID } from "node:crypto";
|
|
6
7
|
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
7
8
|
import { resolve } from "node:path";
|
|
9
|
+
import {
|
|
10
|
+
readBodyLimited,
|
|
11
|
+
ResponseTooLargeError,
|
|
12
|
+
} from "../../../util/http-body.js";
|
|
13
|
+
import {
|
|
14
|
+
advertisedBinaryKind,
|
|
15
|
+
decodeText,
|
|
16
|
+
detectBinaryType,
|
|
17
|
+
extractText,
|
|
18
|
+
isHtmlContent,
|
|
19
|
+
isTextContent,
|
|
20
|
+
matchesBinaryKind,
|
|
21
|
+
} from "../../../util/web-content.js";
|
|
8
22
|
import { dirs } from "../../../util/paths.js";
|
|
9
|
-
import { extractText } from "./shared.js";
|
|
10
23
|
import type { SharedActionHandlers } from "./types.js";
|
|
11
24
|
|
|
25
|
+
const MAX_RESPONSE_MB = 50;
|
|
26
|
+
const MAX_RESPONSE_BYTES = MAX_RESPONSE_MB * 1024 * 1024;
|
|
27
|
+
const MAX_TEXT_CHARS = 50_000;
|
|
28
|
+
|
|
29
|
+
/** Cap returned text, marking the cut so truncation is never silent. */
|
|
30
|
+
function capText(text: string): string {
|
|
31
|
+
if (text.length <= MAX_TEXT_CHARS) return text;
|
|
32
|
+
return `${text.slice(0, MAX_TEXT_CHARS)}\n\n[Content truncated at ${MAX_TEXT_CHARS} characters]`;
|
|
33
|
+
}
|
|
34
|
+
|
|
12
35
|
export const fetchUrlHandlers: SharedActionHandlers = {
|
|
13
36
|
fetch_url: async (body) => {
|
|
14
37
|
const url = String(body.url ?? "");
|
|
@@ -32,84 +55,75 @@ export const fetchUrlHandlers: SharedActionHandlers = {
|
|
|
32
55
|
|
|
33
56
|
// Reject oversized responses before downloading the body.
|
|
34
57
|
// The Content-Length header is advisory but saves bandwidth when present.
|
|
35
|
-
const MAX_BYTES = 20 * 1024 * 1024; // 20 MB
|
|
36
58
|
const contentLength = resp.headers.get("content-length");
|
|
37
|
-
if (contentLength && Number(contentLength) >
|
|
59
|
+
if (contentLength && Number(contentLength) > MAX_RESPONSE_BYTES) {
|
|
38
60
|
return {
|
|
39
61
|
ok: false,
|
|
40
|
-
error: `File too large (${(Number(contentLength) / 1024 / 1024).toFixed(0)}MB, max
|
|
62
|
+
error: `File too large (${(Number(contentLength) / 1024 / 1024).toFixed(0)}MB, max ${MAX_RESPONSE_MB}MB)`,
|
|
41
63
|
};
|
|
42
64
|
}
|
|
43
65
|
|
|
44
|
-
// Binary content: download and save to workspace
|
|
45
66
|
const mimeType = ct.split(";")[0].trim().toLowerCase();
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
if (
|
|
51
|
-
return { ok: false, error: "File too large (max 20MB)" };
|
|
52
|
-
if (buffer.length === 0)
|
|
53
|
-
return { ok: false, error: "Empty response (0 bytes)" };
|
|
54
|
-
|
|
55
|
-
// Validate magic bytes — prevent saving HTML error pages as images
|
|
56
|
-
// (servers can return error pages with image content-type headers)
|
|
57
|
-
const magic = buffer.subarray(0, 16);
|
|
58
|
-
const isRealImage =
|
|
59
|
-
(magic[0] === 0xff && magic[1] === 0xd8) || // JPEG
|
|
60
|
-
(magic[0] === 0x89 &&
|
|
61
|
-
magic[1] === 0x50 &&
|
|
62
|
-
magic[2] === 0x4e &&
|
|
63
|
-
magic[3] === 0x47) || // PNG
|
|
64
|
-
(magic[0] === 0x47 && magic[1] === 0x49 && magic[2] === 0x46) || // GIF
|
|
65
|
-
(magic[0] === 0x52 &&
|
|
66
|
-
magic[1] === 0x49 &&
|
|
67
|
-
magic[2] === 0x46 &&
|
|
68
|
-
magic[3] === 0x46 &&
|
|
69
|
-
magic[8] === 0x57 &&
|
|
70
|
-
magic[9] === 0x45 &&
|
|
71
|
-
magic[10] === 0x42 &&
|
|
72
|
-
magic[11] === 0x50); // WebP
|
|
73
|
-
|
|
74
|
-
// If content-type says image but bytes say otherwise, treat as text
|
|
75
|
-
if (ct.startsWith("image/") && !isRealImage) {
|
|
76
|
-
const text = extractText(buffer.toString("utf-8"), 500);
|
|
67
|
+
let buffer: Buffer;
|
|
68
|
+
try {
|
|
69
|
+
buffer = await readBodyLimited(resp, MAX_RESPONSE_BYTES);
|
|
70
|
+
} catch (err) {
|
|
71
|
+
if (err instanceof ResponseTooLargeError) {
|
|
77
72
|
return {
|
|
78
73
|
ok: false,
|
|
79
|
-
error: `
|
|
74
|
+
error: `Response too large (max ${MAX_RESPONSE_MB}MB)`,
|
|
80
75
|
};
|
|
81
76
|
}
|
|
77
|
+
throw err;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (isTextContent(mimeType, buffer)) {
|
|
81
|
+
const trimmed = decodeText(buffer, ct).trim();
|
|
82
|
+
if (!trimmed)
|
|
83
|
+
return { ok: true, text: "(Page has no readable content)" };
|
|
82
84
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
:
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
85
|
+
// extractText is a DOM extractor — running it on JSON/XML/JavaScript/
|
|
86
|
+
// plain text strips small payloads like {"status":"ok"} to nothing,
|
|
87
|
+
// so only HTML (declared or sniffed) goes through it.
|
|
88
|
+
if (!isHtmlContent(mimeType, trimmed)) {
|
|
89
|
+
return { ok: true, text: capText(trimmed) };
|
|
90
|
+
}
|
|
91
|
+
const text = extractText(trimmed, Number.POSITIVE_INFINITY);
|
|
92
|
+
if (text.length < 20)
|
|
93
|
+
return { ok: true, text: "(Page has no readable content)" };
|
|
94
|
+
return { ok: true, text: capText(text) };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (buffer.length === 0)
|
|
98
|
+
return { ok: false, error: "Empty response (0 bytes)" };
|
|
99
|
+
|
|
100
|
+
const detected = await detectBinaryType(buffer);
|
|
101
|
+
const advertised = advertisedBinaryKind(mimeType);
|
|
102
|
+
|
|
103
|
+
// Do not save an error page or arbitrary bytes under a trusted-looking
|
|
104
|
+
// image/PDF/ZIP extension merely because the server advertised one.
|
|
105
|
+
if (advertised && !matchesBinaryKind(advertised, detected, buffer)) {
|
|
106
|
+
const text = extractText(decodeText(buffer, ct), 500);
|
|
103
107
|
return {
|
|
104
|
-
ok:
|
|
105
|
-
|
|
108
|
+
ok: false,
|
|
109
|
+
error: `Server returned invalid ${advertised} content.${text ? ` Content: ${text}` : ""}`,
|
|
106
110
|
};
|
|
107
111
|
}
|
|
108
|
-
|
|
109
|
-
const
|
|
110
|
-
if (
|
|
111
|
-
|
|
112
|
-
|
|
112
|
+
|
|
113
|
+
const uploadsDir = dirs.uploads;
|
|
114
|
+
if (!existsSync(uploadsDir)) mkdirSync(uploadsDir, { recursive: true });
|
|
115
|
+
const filePath = resolve(
|
|
116
|
+
uploadsDir,
|
|
117
|
+
`${Date.now()}-${randomUUID().slice(0, 8)}-fetched.${detected?.ext ?? "bin"}`,
|
|
118
|
+
);
|
|
119
|
+
writeFileSync(filePath, buffer);
|
|
120
|
+
const typeLabel = detected?.mime.startsWith("image/")
|
|
121
|
+
? "image"
|
|
122
|
+
: (detected?.ext ?? ct.split("/")[1]?.split(";")[0] ?? "file");
|
|
123
|
+
return {
|
|
124
|
+
ok: true,
|
|
125
|
+
text: `Downloaded ${typeLabel} (${(buffer.length / 1024).toFixed(0)}KB) to: ${filePath}\nRead it with the Read tool or send it with send(type="file", file_path="${filePath}").`,
|
|
126
|
+
};
|
|
113
127
|
} catch (err) {
|
|
114
128
|
return {
|
|
115
129
|
ok: false,
|
|
@@ -25,7 +25,7 @@ import {
|
|
|
25
25
|
writeFile,
|
|
26
26
|
} from "node:fs/promises";
|
|
27
27
|
import { tmpdir } from "node:os";
|
|
28
|
-
import { dirname, join } from "node:path";
|
|
28
|
+
import { dirname, extname, join } from "node:path";
|
|
29
29
|
import { getMeshService } from "../../mesh/index.js";
|
|
30
30
|
import {
|
|
31
31
|
clearTeleport,
|
|
@@ -39,7 +39,12 @@ import {
|
|
|
39
39
|
} from "../../../util/exec-output.js";
|
|
40
40
|
import type { SharedActionHandlers } from "./types.js";
|
|
41
41
|
|
|
42
|
-
type Result = {
|
|
42
|
+
type Result = {
|
|
43
|
+
ok: boolean;
|
|
44
|
+
text: string;
|
|
45
|
+
/** Set for image files so the tool result carries a viewable image block. */
|
|
46
|
+
image?: { data: string; mimeType: string };
|
|
47
|
+
};
|
|
43
48
|
|
|
44
49
|
/**
|
|
45
50
|
* ripgrep binary, resolved from PATH (env-overridable for tests). NOT a
|
|
@@ -70,6 +75,21 @@ const TELEPORT_TIMEOUT_HINT =
|
|
|
70
75
|
"background it in-shell instead: `cmd > /tmp/out.log 2>&1 &`, then poll the log " +
|
|
71
76
|
"with read, or bound the command itself: `logcat -d`, `timeout 30 …`, `head -n 200`.)";
|
|
72
77
|
const MAX_READ_LINES = 2_000;
|
|
78
|
+
/**
|
|
79
|
+
* Image extensions the model can actually view, mapped to their MIME type.
|
|
80
|
+
* Reading one returns an image content block instead of the file's raw bytes
|
|
81
|
+
* decoded as (garbage) UTF-8 — so `read`ing a photo/screenshot/design shows
|
|
82
|
+
* the picture, not mojibake.
|
|
83
|
+
*/
|
|
84
|
+
const IMAGE_MIME: Record<string, string> = {
|
|
85
|
+
".png": "image/png",
|
|
86
|
+
".jpg": "image/jpeg",
|
|
87
|
+
".jpeg": "image/jpeg",
|
|
88
|
+
".gif": "image/gif",
|
|
89
|
+
".webp": "image/webp",
|
|
90
|
+
};
|
|
91
|
+
/** Anthropic caps a single image around 5MB; refuse larger with a downscale hint. */
|
|
92
|
+
const MAX_IMAGE_BYTES = 5 * 1024 * 1024;
|
|
73
93
|
/** Caps for the pure-JS glob/search fallbacks (rg unavailable). */
|
|
74
94
|
const MAX_JS_RESULTS = 2_000;
|
|
75
95
|
const MAX_JS_FILE_BYTES = 2 * 1024 * 1024;
|
|
@@ -437,23 +457,58 @@ async function read(
|
|
|
437
457
|
): Promise<Result> {
|
|
438
458
|
const p = str(path);
|
|
439
459
|
if (!p) return { ok: false, text: "A file path is required." };
|
|
460
|
+
const active = await getTeleport(chatId);
|
|
461
|
+
const where = active ? active.deviceName : "local";
|
|
462
|
+
|
|
463
|
+
// Image files: return a viewable image block, not the raw bytes decoded as
|
|
464
|
+
// UTF-8. Without this, `read`ing a photo/screenshot/design hands the model
|
|
465
|
+
// mojibake and it can't see the picture at all.
|
|
466
|
+
const mime = IMAGE_MIME[extname(p).toLowerCase()];
|
|
467
|
+
if (mime) {
|
|
468
|
+
let bytes: Buffer;
|
|
469
|
+
if (active) {
|
|
470
|
+
const res = await getMeshService().readFileBytes(active.deviceId, p);
|
|
471
|
+
if ("error" in res) return { ok: false, text: res.error };
|
|
472
|
+
bytes = res.data;
|
|
473
|
+
} else {
|
|
474
|
+
try {
|
|
475
|
+
bytes = await readFile(p);
|
|
476
|
+
} catch (err) {
|
|
477
|
+
return {
|
|
478
|
+
ok: false,
|
|
479
|
+
text: `Cannot read ${p}: ${(err as Error).message}`,
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
if (bytes.length > MAX_IMAGE_BYTES) {
|
|
484
|
+
return {
|
|
485
|
+
ok: false,
|
|
486
|
+
text:
|
|
487
|
+
`${p} [${where}] is ${(bytes.length / 1_048_576).toFixed(1)}MB — over the ` +
|
|
488
|
+
`${MAX_IMAGE_BYTES / 1_048_576}MB image limit. Downscale it first ` +
|
|
489
|
+
`(e.g. \`convert '${p}' -resize 1568x /tmp/small.jpg\`) and read that.`,
|
|
490
|
+
};
|
|
491
|
+
}
|
|
492
|
+
return {
|
|
493
|
+
ok: true,
|
|
494
|
+
text: `${p} [${where}] — image (${mime}, ${bytes.length} bytes)`,
|
|
495
|
+
image: { data: bytes.toString("base64"), mimeType: mime },
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
|
|
440
499
|
const start = num(offset) ?? 0;
|
|
441
500
|
const max = Math.min(num(limit) ?? MAX_READ_LINES, MAX_READ_LINES);
|
|
442
|
-
const active = await getTeleport(chatId);
|
|
443
501
|
let content: string;
|
|
444
|
-
let where: string;
|
|
445
502
|
if (active) {
|
|
446
503
|
const res = await getMeshService().readFileBytes(active.deviceId, p);
|
|
447
504
|
if ("error" in res) return { ok: false, text: res.error };
|
|
448
505
|
content = res.data.toString("utf8");
|
|
449
|
-
where = active.deviceName;
|
|
450
506
|
} else {
|
|
451
507
|
try {
|
|
452
508
|
content = await readFile(p, "utf8");
|
|
453
509
|
} catch (err) {
|
|
454
510
|
return { ok: false, text: `Cannot read ${p}: ${(err as Error).message}` };
|
|
455
511
|
}
|
|
456
|
-
where = "local";
|
|
457
512
|
}
|
|
458
513
|
const lines = content.split("\n");
|
|
459
514
|
const slice = lines.slice(start, start + max);
|
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Helpers shared across shared-action domains: due-date parsing
|
|
3
|
-
*
|
|
2
|
+
* Helpers shared across shared-action domains: due-date parsing and per-job
|
|
3
|
+
* model-override validation.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import * as cheerio from "cheerio";
|
|
7
6
|
import { resolveExplicitModelRef } from "../../models/active-model.js";
|
|
8
7
|
import {
|
|
9
8
|
getBackendForChat,
|
|
@@ -20,16 +19,6 @@ export function parseDueDate(value: unknown): number | undefined {
|
|
|
20
19
|
return Number.isFinite(ms) ? ms : undefined;
|
|
21
20
|
}
|
|
22
21
|
|
|
23
|
-
/** Extract readable text from HTML using cheerio (proper DOM parser). */
|
|
24
|
-
export function extractText(html: string, maxLength = 8000): string {
|
|
25
|
-
const $ = cheerio.load(html);
|
|
26
|
-
// Remove non-content elements
|
|
27
|
-
$("script, style, noscript, iframe, svg, nav, footer, header").remove();
|
|
28
|
-
// Get text content, normalize whitespace
|
|
29
|
-
const text = $("body").text().replace(/\s+/g, " ").trim();
|
|
30
|
-
return text.slice(0, maxLength);
|
|
31
|
-
}
|
|
32
|
-
|
|
33
22
|
/**
|
|
34
23
|
* Validate a per-job model override so create_cron_job / trigger_create can
|
|
35
24
|
* reject a bad override up front — the agent retries or tells the user —
|
package/src/core/tools/bridge.ts
CHANGED
|
@@ -125,14 +125,39 @@ export function createBridge(
|
|
|
125
125
|
|
|
126
126
|
/** Wrap a bridge result into the MCP content format. */
|
|
127
127
|
export function textResult(result: unknown): {
|
|
128
|
-
content: Array<
|
|
128
|
+
content: Array<
|
|
129
|
+
| { type: "text"; text: string }
|
|
130
|
+
| { type: "image"; data: string; mimeType: string }
|
|
131
|
+
>;
|
|
129
132
|
isError?: boolean;
|
|
130
133
|
} {
|
|
131
|
-
const r = result as {
|
|
134
|
+
const r = result as {
|
|
135
|
+
ok?: boolean;
|
|
136
|
+
text?: string;
|
|
137
|
+
error?: string;
|
|
138
|
+
image?: { data?: unknown; mimeType?: unknown };
|
|
139
|
+
};
|
|
140
|
+
const content: Array<
|
|
141
|
+
| { type: "text"; text: string }
|
|
142
|
+
| { type: "image"; data: string; mimeType: string }
|
|
143
|
+
> = [{ type: "text" as const, text: r.text ?? JSON.stringify(result) }];
|
|
144
|
+
// A result may carry an image (e.g. `read` on a photo) — surface it as an
|
|
145
|
+
// MCP image block so the model actually sees the picture, not base64 text.
|
|
146
|
+
if (
|
|
147
|
+
r &&
|
|
148
|
+
typeof r === "object" &&
|
|
149
|
+
r.image &&
|
|
150
|
+
typeof r.image.data === "string" &&
|
|
151
|
+
typeof r.image.mimeType === "string"
|
|
152
|
+
) {
|
|
153
|
+
content.push({
|
|
154
|
+
type: "image" as const,
|
|
155
|
+
data: r.image.data,
|
|
156
|
+
mimeType: r.image.mimeType,
|
|
157
|
+
});
|
|
158
|
+
}
|
|
132
159
|
return {
|
|
133
|
-
content
|
|
134
|
-
{ type: "text" as const, text: r.text ?? JSON.stringify(result) },
|
|
135
|
-
],
|
|
160
|
+
content,
|
|
136
161
|
// Gateway results carry ok:false on failure — mark those as tool errors
|
|
137
162
|
// so the model treats the message as a failure, not a success payload.
|
|
138
163
|
...(r && typeof r === "object" && r.ok === false ? { isError: true } : {}),
|
package/src/core/types.ts
CHANGED
|
@@ -233,6 +233,12 @@ export type ActionResult = {
|
|
|
233
233
|
* tool calls (react/edit/delete) that target this message.
|
|
234
234
|
*/
|
|
235
235
|
message_id?: number | string;
|
|
236
|
+
/**
|
|
237
|
+
* Set by tools that return a viewable image (e.g. `read` on a photo). The
|
|
238
|
+
* MCP layer turns this into an image content block so the model sees the
|
|
239
|
+
* picture instead of base64 text.
|
|
240
|
+
*/
|
|
241
|
+
image?: { data: string; mimeType: string };
|
|
236
242
|
[key: string]: unknown;
|
|
237
243
|
};
|
|
238
244
|
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bounded reading of fetch response bodies. Content-Length is optional and
|
|
3
|
+
* advisory, so the only trustworthy size limit is one enforced while the
|
|
4
|
+
* body streams in.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export class ResponseTooLargeError extends Error {}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Read a response incrementally so a missing or dishonest Content-Length
|
|
11
|
+
* header cannot buffer an unbounded body in memory.
|
|
12
|
+
*/
|
|
13
|
+
export async function readBodyLimited(
|
|
14
|
+
response: Response,
|
|
15
|
+
maxBytes: number,
|
|
16
|
+
): Promise<Buffer> {
|
|
17
|
+
if (!response.body) {
|
|
18
|
+
const buffer = Buffer.from(await response.arrayBuffer());
|
|
19
|
+
if (buffer.length > maxBytes) throw new ResponseTooLargeError();
|
|
20
|
+
return buffer;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const reader = response.body.getReader();
|
|
24
|
+
const chunks: Buffer[] = [];
|
|
25
|
+
let totalBytes = 0;
|
|
26
|
+
|
|
27
|
+
try {
|
|
28
|
+
while (true) {
|
|
29
|
+
const { done, value } = await reader.read();
|
|
30
|
+
if (done) break;
|
|
31
|
+
if (!value?.byteLength) continue;
|
|
32
|
+
|
|
33
|
+
totalBytes += value.byteLength;
|
|
34
|
+
if (totalBytes > maxBytes) {
|
|
35
|
+
try {
|
|
36
|
+
await reader.cancel("response exceeds download limit");
|
|
37
|
+
} catch {
|
|
38
|
+
// Cancellation is cleanup only; preserve the useful size error.
|
|
39
|
+
}
|
|
40
|
+
throw new ResponseTooLargeError();
|
|
41
|
+
}
|
|
42
|
+
chunks.push(
|
|
43
|
+
Buffer.from(value.buffer, value.byteOffset, value.byteLength),
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
} finally {
|
|
47
|
+
reader.releaseLock();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return Buffer.concat(chunks, totalBytes);
|
|
51
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Interpretation of fetched web content: charset decoding, text/binary
|
|
3
|
+
* classification, HTML text extraction, and magic-byte validation of binary
|
|
4
|
+
* payloads (via file-type) so server-declared Content-Type headers are never
|
|
5
|
+
* trusted on their own.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import * as cheerio from "cheerio";
|
|
9
|
+
import { fileTypeFromBuffer } from "file-type";
|
|
10
|
+
|
|
11
|
+
/** Binary categories whose content Talon validates against magic bytes. */
|
|
12
|
+
export type BinaryKind = "image" | "pdf" | "zip";
|
|
13
|
+
|
|
14
|
+
export interface DetectedBinary {
|
|
15
|
+
ext: string;
|
|
16
|
+
mime: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function isTextMimeType(mimeType: string): boolean {
|
|
20
|
+
return (
|
|
21
|
+
mimeType.startsWith("text/") ||
|
|
22
|
+
mimeType === "application/json" ||
|
|
23
|
+
mimeType.endsWith("+json") ||
|
|
24
|
+
mimeType === "application/xml" ||
|
|
25
|
+
mimeType.endsWith("+xml") ||
|
|
26
|
+
mimeType === "application/javascript" ||
|
|
27
|
+
mimeType === "application/x-javascript"
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function looksLikeStructuredText(buffer: Buffer): boolean {
|
|
32
|
+
if (buffer.subarray(0, 512).includes(0)) return false;
|
|
33
|
+
const sample = buffer.subarray(0, 512).toString("utf-8").trimStart();
|
|
34
|
+
return (
|
|
35
|
+
sample.startsWith("{") ||
|
|
36
|
+
sample.startsWith("[") ||
|
|
37
|
+
sample.startsWith("<?xml") ||
|
|
38
|
+
/^<!doctype\s+html/i.test(sample) ||
|
|
39
|
+
/^<[a-z][\w:-]*(?:\s|>)/i.test(sample)
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Whether a response should be treated as readable text. Servers frequently
|
|
45
|
+
* omit Content-Type or fall back to application/octet-stream, so structured
|
|
46
|
+
* text is also sniffed from the body when the header says nothing useful.
|
|
47
|
+
*/
|
|
48
|
+
export function isTextContent(mimeType: string, buffer: Buffer): boolean {
|
|
49
|
+
if (isTextMimeType(mimeType)) return true;
|
|
50
|
+
const isGenericMime = !mimeType || mimeType === "application/octet-stream";
|
|
51
|
+
return isGenericMime && looksLikeStructuredText(buffer);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Whether textual content is an HTML document (declared or sniffed) and thus
|
|
56
|
+
* benefits from DOM text extraction rather than being returned verbatim.
|
|
57
|
+
*/
|
|
58
|
+
export function isHtmlContent(mimeType: string, text: string): boolean {
|
|
59
|
+
return (
|
|
60
|
+
mimeType.includes("html") || /^<!doctype\s+html|^<html[\s>]/i.test(text)
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Decode a body honoring the declared response charset, defaulting to UTF-8. */
|
|
65
|
+
export function decodeText(buffer: Buffer, contentType: string): string {
|
|
66
|
+
const charset = /charset\s*=\s*["']?([^;\s"']+)/i.exec(contentType)?.[1];
|
|
67
|
+
try {
|
|
68
|
+
return new TextDecoder(charset || "utf-8").decode(buffer);
|
|
69
|
+
} catch {
|
|
70
|
+
return buffer.toString("utf-8");
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Extract readable text from HTML using cheerio (proper DOM parser). */
|
|
75
|
+
export function extractText(html: string, maxLength = 8000): string {
|
|
76
|
+
const $ = cheerio.load(html);
|
|
77
|
+
// Remove non-content elements
|
|
78
|
+
$("script, style, noscript, iframe, svg, nav, footer, header").remove();
|
|
79
|
+
// Get text content, normalize whitespace
|
|
80
|
+
const text = $("body").text().replace(/\s+/g, " ").trim();
|
|
81
|
+
return text.slice(0, maxLength);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Identify a binary payload from its magic bytes. */
|
|
85
|
+
export async function detectBinaryType(
|
|
86
|
+
buffer: Buffer,
|
|
87
|
+
): Promise<DetectedBinary | null> {
|
|
88
|
+
const detected = await fileTypeFromBuffer(buffer);
|
|
89
|
+
return detected ? { ext: detected.ext, mime: detected.mime } : null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** The binary kind a Content-Type header promises, if it promises one. */
|
|
93
|
+
export function advertisedBinaryKind(mimeType: string): BinaryKind | null {
|
|
94
|
+
if (mimeType.startsWith("image/")) return "image";
|
|
95
|
+
if (mimeType.includes("pdf")) return "pdf";
|
|
96
|
+
if (mimeType.includes("zip")) return "zip";
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Whether the actual bytes satisfy what the Content-Type header promised. */
|
|
101
|
+
export function matchesBinaryKind(
|
|
102
|
+
kind: BinaryKind,
|
|
103
|
+
detected: DetectedBinary | null,
|
|
104
|
+
buffer: Buffer,
|
|
105
|
+
): boolean {
|
|
106
|
+
switch (kind) {
|
|
107
|
+
case "image":
|
|
108
|
+
return detected?.mime.startsWith("image/") ?? false;
|
|
109
|
+
case "pdf":
|
|
110
|
+
return detected?.ext === "pdf";
|
|
111
|
+
case "zip":
|
|
112
|
+
// The whole zip family (docx, jar, epub, …) shares the PK signature.
|
|
113
|
+
return buffer[0] === 0x50 && buffer[1] === 0x4b;
|
|
114
|
+
}
|
|
115
|
+
}
|