132 lines
4.3 KiB
TypeScript
132 lines
4.3 KiB
TypeScript
|
|
import type { Tool } from "@magiccode/core";
|
||
|
|
import { optNum, optStr, str } from "./helpers";
|
||
|
|
|
||
|
|
const DEFAULT_TIMEOUT = 30_000; // 30 secondes
|
||
|
|
const OUTPUT_MAX = 100_000; // 100 KB
|
||
|
|
|
||
|
|
interface FetchOptions {
|
||
|
|
method?: string;
|
||
|
|
headers?: Record<string, string>;
|
||
|
|
body?: string;
|
||
|
|
timeout?: number;
|
||
|
|
}
|
||
|
|
|
||
|
|
export const webfetchTool: Tool = {
|
||
|
|
spec: {
|
||
|
|
name: "WebFetch",
|
||
|
|
description:
|
||
|
|
"Récupère le contenu d'une URL (HTTP/HTTPS). Retourne le statut, les en-têtes et le corps de la réponse (texte ou JSON).",
|
||
|
|
inputSchema: {
|
||
|
|
type: "object",
|
||
|
|
properties: {
|
||
|
|
url: { type: "string", description: "URL à récupérer (http:// ou https://)" },
|
||
|
|
method: { type: "string", description: 'Méthode HTTP (GET par défaut, ou POST, PUT, DELETE, etc.)' },
|
||
|
|
headers: {
|
||
|
|
type: "object",
|
||
|
|
description: "En-têtes HTTP personnalisés",
|
||
|
|
additionalProperties: { type: "string" },
|
||
|
|
},
|
||
|
|
body: { type: "string", description: "Corps de la requête (pour POST, PUT, etc.)" },
|
||
|
|
timeout: { type: "number", description: "Timeout en millisecondes (30000 par défaut)" },
|
||
|
|
},
|
||
|
|
required: ["url"],
|
||
|
|
},
|
||
|
|
},
|
||
|
|
permission: "ask",
|
||
|
|
describe: (i) => `WebFetch ${String(i.method ?? "GET")} ${String(i.url ?? "")}`,
|
||
|
|
async execute(input) {
|
||
|
|
const url = str(input, "url");
|
||
|
|
const method = optStr(input, "method")?.toUpperCase() ?? "GET";
|
||
|
|
const headers = (input.headers as Record<string, string>) ?? {};
|
||
|
|
const body = optStr(input, "body");
|
||
|
|
const timeout = optNum(input, "timeout") ?? DEFAULT_TIMEOUT;
|
||
|
|
|
||
|
|
// Validation basique de l'URL
|
||
|
|
let parsedUrl: URL;
|
||
|
|
try {
|
||
|
|
parsedUrl = new URL(url);
|
||
|
|
if (!["http:", "https:"].includes(parsedUrl.protocol)) {
|
||
|
|
throw new Error(`Protocole non supporté : ${parsedUrl.protocol}`);
|
||
|
|
}
|
||
|
|
} catch (error) {
|
||
|
|
throw new Error(`URL invalide : ${url} (${(error as Error).message})`);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Préparer les options de fetch
|
||
|
|
const fetchOptions: FetchOptions = {
|
||
|
|
method,
|
||
|
|
timeout,
|
||
|
|
};
|
||
|
|
|
||
|
|
if (Object.keys(headers).length > 0) {
|
||
|
|
fetchOptions.headers = headers;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (body) {
|
||
|
|
fetchOptions.body = body;
|
||
|
|
// Ajouter Content-Type par défaut si non défini
|
||
|
|
if (!fetchOptions.headers) {
|
||
|
|
fetchOptions.headers = {};
|
||
|
|
}
|
||
|
|
if (!fetchOptions.headers["Content-Type"]) {
|
||
|
|
fetchOptions.headers["Content-Type"] = "application/json";
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
try {
|
||
|
|
const response = await fetch(url, fetchOptions);
|
||
|
|
|
||
|
|
// Construire l'en-tête de réponse
|
||
|
|
const responseHeaders: Record<string, string> = {};
|
||
|
|
response.headers.forEach((value, key) => {
|
||
|
|
responseHeaders[key] = value;
|
||
|
|
});
|
||
|
|
|
||
|
|
// Déterminer le type de contenu
|
||
|
|
const contentType = responseHeaders["content-type"] || "";
|
||
|
|
let bodyText: string;
|
||
|
|
|
||
|
|
if (contentType.includes("application/json")) {
|
||
|
|
// Pour JSON, retourner le texte formaté
|
||
|
|
const text = await response.text();
|
||
|
|
try {
|
||
|
|
const parsed = JSON.parse(text);
|
||
|
|
bodyText = JSON.stringify(parsed, null, 2);
|
||
|
|
} catch {
|
||
|
|
bodyText = text;
|
||
|
|
}
|
||
|
|
} else if (contentType.includes("text/") || contentType.includes("application/xml")) {
|
||
|
|
bodyText = await response.text();
|
||
|
|
} else {
|
||
|
|
// Pour les autres types (images, binaires), retourner un résumé
|
||
|
|
const buffer = await response.arrayBuffer();
|
||
|
|
bodyText = `[Contenu binaire : ${buffer.byteLength} bytes (type: ${contentType || "inconnu"})]`;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Tronquer si trop volumineux
|
||
|
|
if (bodyText.length > OUTPUT_MAX) {
|
||
|
|
bodyText = `${bodyText.slice(0, OUTPUT_MAX)}\n… (tronqué, ${bodyText.length} bytes au total)`;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Assembler la réponse
|
||
|
|
const result = [
|
||
|
|
`Status: ${response.status} ${response.statusText}`,
|
||
|
|
`\n[Headers]`,
|
||
|
|
Object.entries(responseHeaders)
|
||
|
|
.map(([k, v]) => `${k}: ${v}`)
|
||
|
|
.join("\n"),
|
||
|
|
`\n[Body]`,
|
||
|
|
bodyText,
|
||
|
|
].join("\n");
|
||
|
|
|
||
|
|
return result;
|
||
|
|
} catch (error) {
|
||
|
|
const message = error instanceof Error ? error.message : String(error);
|
||
|
|
if (message.includes("timeout") || message.includes("Timeout")) {
|
||
|
|
throw new Error(`Timeout après ${timeout}ms pour ${url}`);
|
||
|
|
}
|
||
|
|
throw new Error(`Erreur lors de la récupération de ${url}: ${message}`);
|
||
|
|
}
|
||
|
|
},
|
||
|
|
};
|