From f66d1b76210ebde39a345a3ddec35a042c6aa375 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 05:29:35 +0000 Subject: [PATCH] =?UTF-8?q?Lot=2042=20:=20outils=20web=20de=20l'agent=20ex?= =?UTF-8?q?=C3=A9cut=C3=A9s=20par=20la=20fa=C3=A7ade?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Les outils web des tours de la façade (web_search, web_images, web_open, web_read, web_grep) ne passent plus par /tool/exec du Go : src/webtools.rs porte chat_internet_tools.go, chat_internet.go et chat_web_images.go. Le cache des pages (10 min, clé MD5 des options comme le Go) est dans la façade ; client Crawl4AI au corps près, réponses lues comme json.Unmarshal ; résultats DuckDuckGo tirés du markdown (uddg, entités, extraits) ; recherche d'images avec le jeton vqd et les cookies gardés par hôte ; web_grep sur les regex du Go, erreurs au texte de regexp. Le moteur intégré lit le HTML avec go-readability et html-to-markdown, que rien en Rust ne reproduit au caractère près : la conversion d'une page et la lecture des résultats restent au Go, derrière /internal/v1/web/fetch et /internal/v1/web/search (sans état). web_screenshot reste au Go. Le traducteur go_regex refuse la répétition d'une répétition et les comptes au-delà de 1000, et rend littéral un « { » qui n'ouvre pas de répétition, comme le Go (motifs retry_patterns compris). Parité : journal de 74 appels tiré du Go (parity_web_tools_test.go → tests/parity_web/outils.json : résultats, requêtes reçues par les faux Crawl4AI et DuckDuckGo, pages demandées au moteur intégré), rejoué par tests/webtools.rs ; un tour de la façade qui ouvre une page (tests/lot6.rs) ; TestInternalMoteurWebIntegre côté Go. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv --- ...-rust-migration-lot42-outils-web-design.md | 121 ++ internal/loki/chat_internet.go | 13 +- internal/loki/chat_web_images.go | 7 +- internal/loki/parity_web_tools_test.go | 542 ++++++ internal/loki/web_fetch_go.go | 2 +- internal/loki/web_internal.go | 4 + internal/loki/web_internal_web.go | 52 + internal/loki/web_internal_web_test.go | 58 + rust/loki-rs/src/chat/generate.rs | 1 + rust/loki-rs/src/chat/host.rs | 9 + rust/loki-rs/src/chat/stream.rs | 84 + rust/loki-rs/src/gojson.rs | 2 +- rust/loki-rs/src/lib.rs | 1 + rust/loki-rs/src/webtools.rs | 1578 +++++++++++++++++ rust/loki-rs/src/webtools/tests.rs | 192 ++ rust/loki-rs/tests/common/fakego.rs | 28 + rust/loki-rs/tests/lot6.rs | 42 + rust/loki-rs/tests/parity_web/outils.json | 1541 ++++++++++++++++ rust/loki-rs/tests/webtools.rs | 291 +++ 19 files changed, 4561 insertions(+), 7 deletions(-) create mode 100644 docs/superpowers/specs/2026-10-16-rust-migration-lot42-outils-web-design.md create mode 100644 internal/loki/parity_web_tools_test.go create mode 100644 internal/loki/web_internal_web.go create mode 100644 internal/loki/web_internal_web_test.go create mode 100644 rust/loki-rs/src/webtools.rs create mode 100644 rust/loki-rs/src/webtools/tests.rs create mode 100644 rust/loki-rs/tests/parity_web/outils.json create mode 100644 rust/loki-rs/tests/webtools.rs diff --git a/docs/superpowers/specs/2026-10-16-rust-migration-lot42-outils-web-design.md b/docs/superpowers/specs/2026-10-16-rust-migration-lot42-outils-web-design.md new file mode 100644 index 0000000..a13f100 --- /dev/null +++ b/docs/superpowers/specs/2026-10-16-rust-migration-lot42-outils-web-design.md @@ -0,0 +1,121 @@ +# Lot 42 — Outils web de l'agent dans la façade + +## Contexte + +La façade tient la conversation par défaut. Les outils web de ses tours +partaient pourtant au Go (`/internal/v1/tool/exec`) : + +- `web_search` ; +- `web_images` ; +- `web_open`, `web_read`, `web_grep`. + +Le cache des pages (10 min) était donc au Go : `web_open` puis `web_read` +faisaient chacun un aller-retour. + +## Le lot + +`src/webtools.rs` porte les outils. Sources Go : + +- `chat_internet_tools.go` : les quatre outils de lecture et de recherche ; +- `chat_internet.go` : client Crawl4AI, cache, lecture des résultats + DuckDuckGo ; +- `chat_web_images.go` : recherche d'images. + +Ce qui est porté : + +- **le cache** : + - clé : l'URL, suivie de l'empreinte MD5 des options qui changent la page + (actions, attente, bandeaux), à l'échappement HTML près du Go ; + - `web_read` et `web_grep` lisent l'ouverture la plus récente d'une URL, + toutes options confondues ; + - 10 minutes ; +- **Crawl4AI** : + - corps envoyé au Go près (clés triées, échappement HTML) ; + - clé d'accès en `Bearer` ; + - réponse `{results:[…]}` ou résultat seul, lue comme `json.Unmarshal` + (types faux, `null`, casse des clés) ; + - markdown en chaîne ou `{raw_markdown, fit_markdown}` ; + - erreurs : HTTP (fin du corps), échec du crawl, réponse vide, serveur + injoignable (texte de l'erreur du client Go) ; +- **les lignes** : `\r\n`, blancs de fin, vides et doublons consécutifs + réduits ; plan des titres ; +- **la recherche par Crawl4AI** : titres, liens `uddg` décodés deux fois + comme le Go, entités HTML, extrait tiré des liens qui suivent, doublons et + liens DuckDuckGo écartés, défi anti-bot ; +- **les images** : + - jeton `vqd` de la page, puis `i.js` ; + - mêmes en-têtes ; + - cookies gardés par hôte, comme le jar du client Go ; + - réponse lue comme `json.Unmarshal` (erreurs de syntaxe et de type au + texte du Go) ; +- **`web_grep`** : + - regex du Go (`(?i)`, classes ASCII) ; + - erreurs au texte de `regexp` pour les fautes courantes ; +- **le plafond** de 8000 caractères ; +- **les arguments** : nombres tronqués comme `int(float64)` du Go. + +Le moteur intégré lit le HTML avec go-readability, html-to-markdown et le +parseur HTML5 de x/net : rien en Rust ne les reproduit au caractère près. +Ces deux étapes restent au Go, derrière deux appels sans état : + +- `POST /internal/v1/web/fetch` `{url}` → `{markdown}` ou `{error}` + (`goFetchMarkdown`) ; +- `POST /internal/v1/web/search` `{query, limit}` → `{results}` ou `{error}` + (`goSearch`). + +`ChatShared.web` porte les outils ; `GoTurnHost::exec_tool` les exécute +avant tout le reste. `web_screenshot` reste au Go (navigateur piloté). + +Le traducteur des regex du Go (`go_regex`) refuse désormais : + +- la répétition d'une répétition (`a**`, `a*??`, `a{2}*`) ; +- un compte au-delà de 1000 ou inversé. + +Un `{` qui n'ouvre pas de répétition y devient littéral, comme au Go. Les +motifs `retry_patterns` en profitent. + +Côté Go, pour le journal : + +- `webNow` : l'horloge du cache ; +- `ddgImagesBase` et `ddgHTMLBase` : les adresses de DuckDuckGo. + +## Écart connu + +reqwest pose `Accept: */*` sur une requête qui n'en a pas ; le client Go +n'en pose aucun. Seule la page de recherche d'images est concernée, sans +effet sur DuckDuckGo. + +## Parité + +`internal/loki/parity_web_tools_test.go` tire du Go un journal de 74 +appels (`tests/parity_web/outils.json`). Chaque appel porte : + +- le résultat ; +- les requêtes reçues par le faux Crawl4AI (méthode, clé, type, corps) ; +- les requêtes reçues par le faux DuckDuckGo (chemin, en-têtes, cookies) ; +- les pages et recherches demandées au moteur intégré. + +Il couvre : + +- recherche : limites, anti-bot, aucun résultat, panne ; +- lecture : plages, bornes, au-delà, page jamais ouverte ; +- grep : contexte fusionné, plafond, sans résultat, classes ASCII, sept + regex invalides ; +- cache : réouverture, forcée, options, la plus récente, péremption ; +- GitHub ; +- les réponses de Crawl4AI ; +- serveur injoignable, sans serveur, sans clé ; +- moteur intégré : ouverture, texte brut, page absente, recherche ; +- images : doublons, cookie gardé, jeton absent, pannes, réponses + illisibles ou mal typées, `null`. + +`tests/webtools.rs` le rejoue contre le port. Le faux Go sert ce que le +moteur intégré a tiré des pages. + +D'autres tests complètent le journal : + +- `tests/lot6.rs` (`outil_web_par_la_facade`) : un tour de la façade ouvre + une page sans passer par `/tool/exec` ; +- `TestInternalMoteurWebIntegre` : les deux appels internes du Go ; +- tests unitaires : MD5, clés du cache, URL, entités, nombres, erreurs de + regex, réponses de Crawl4AI. diff --git a/internal/loki/chat_internet.go b/internal/loki/chat_internet.go index 0f4b642..92ec2a0 100644 --- a/internal/loki/chat_internet.go +++ b/internal/loki/chat_internet.go @@ -347,6 +347,9 @@ var ( const pageCacheTTL = 10 * time.Minute +// webNow : l'horloge du cache des pages (fixée par le journal de parité). +var webNow = time.Now + type fetchOptions struct { force bool actions []string @@ -371,7 +374,7 @@ func findCached(rawURL string) *cacheEntry { var best *cacheEntry for k, v := range pageCache { if k == u || strings.HasPrefix(k, u+"#") { - if time.Since(v.fetchedAt) > pageCacheTTL { + if webNow().Sub(v.fetchedAt) > pageCacheTTL { continue } if best == nil || v.fetchedAt.After(best.fetchedAt) { @@ -388,7 +391,7 @@ func getPage(rawURL string, opts fetchOptions) (*cacheEntry, error) { pageCacheMu.Lock() cached := pageCache[key] pageCacheMu.Unlock() - if !opts.force && cached != nil && time.Since(cached.fetchedAt) < pageCacheTTL { + if !opts.force && cached != nil && webNow().Sub(cached.fetchedAt) < pageCacheTTL { return cached, nil } @@ -418,7 +421,7 @@ func getPage(rawURL string, opts fetchOptions) (*cacheEntry, error) { if strings.TrimSpace(md) == "" { return nil, fmt.Errorf("page vide") } - entry := &cacheEntry{url: u, lines: normalizeLines(md), fetchedAt: time.Now()} + entry := &cacheEntry{url: u, lines: normalizeLines(md), fetchedAt: webNow()} pageCacheMu.Lock() pageCache[key] = entry pageCacheMu.Unlock() @@ -493,6 +496,10 @@ func decodeHTMLEntities(s string) string { }) } +// ddgHTMLBase : le point d'entrée HTML de DuckDuckGo lu par le moteur intégré +// (goSearch ; un faux pour les tests). +var ddgHTMLBase = "https://html.duckduckgo.com" + // decodeUddg : DDG enrobe les résultats en //duckduckgo.com/l/?uddg=URL_ENCODÉE func decodeUddg(raw string) string { s := raw diff --git a/internal/loki/chat_web_images.go b/internal/loki/chat_web_images.go index f8442e5..3f297cb 100644 --- a/internal/loki/chat_web_images.go +++ b/internal/loki/chat_web_images.go @@ -46,6 +46,9 @@ type imageResult struct { var ddgVqdRe = regexp.MustCompile(`vqd=["']?([0-9-]+)`) +// ddgImagesBase : DuckDuckGo (un faux pour les tests). +var ddgImagesBase = "https://duckduckgo.com" + func ddgGet(ctx context.Context, target, referer string) ([]byte, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil) if err != nil { @@ -75,7 +78,7 @@ func ddgGet(ctx context.Context, target, referer string) ([]byte, error) { func duckduckgoImages(query string, limit int) ([]imageResult, error) { ctx, cancel := context.WithTimeout(context.Background(), goFetchTimeout) defer cancel() - page := "https://duckduckgo.com/?q=" + url.QueryEscape(query) + "&iax=images&ia=images" + page := ddgImagesBase + "/?q=" + url.QueryEscape(query) + "&iax=images&ia=images" body, err := ddgGet(ctx, page, "") if err != nil { return nil, err @@ -84,7 +87,7 @@ func duckduckgoImages(query string, limit int) ([]imageResult, error) { if m == nil { return nil, fmt.Errorf("jeton DuckDuckGo introuvable (défi anti-bot ?)") } - api := "https://duckduckgo.com/i.js?l=fr-fr&o=json&f=,,,,,&p=1&q=" + url.QueryEscape(query) + "&vqd=" + string(m[1]) + api := ddgImagesBase + "/i.js?l=fr-fr&o=json&f=,,,,,&p=1&q=" + url.QueryEscape(query) + "&vqd=" + string(m[1]) body, err = ddgGet(ctx, api, "https://duckduckgo.com/") if err != nil { return nil, err diff --git a/internal/loki/parity_web_tools_test.go b/internal/loki/parity_web_tools_test.go new file mode 100644 index 0000000..cd9d837 --- /dev/null +++ b/internal/loki/parity_web_tools_test.go @@ -0,0 +1,542 @@ +package loki + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" +) + +// parity_web_tools_test.go — journal de référence des outils web de l'agent +// (lot 42 de la migration Rust) : web_search, web_images, web_open, web_read +// et web_grep, sur les deux moteurs. +// +// Un faux Crawl4AI rend des réponses rangées dans le journal, URL par URL ; +// un faux DuckDuckGo sert la recherche d'images. Le moteur intégré (Go) lit +// de vraies pages HTML d'un faux site et de faux résultats DuckDuckGo : ce +// qu'il en tire (goFetchMarkdown, goSearch) est rangé tel quel, la façade le +// demandant au Go. Chaque appel porte son résultat et les requêtes reçues +// par les faux services. Rangé dans rust/loki-rs/tests/parity_web/outils.json ; +// tests/webtools.rs le rejoue contre le port. +// +// LOKI_PARITY_UPDATE=1 go test ./internal/loki -run TestPariteOutilsWeb + +const parityWebFile = "../../rust/loki-rs/tests/parity_web/outils.json" + +type parityCrawlResp struct { + Status int `json:"status"` + Body string `json:"body"` +} + +type parityImgResp struct { + PageStatus int `json:"page_status"` + Page string `json:"page"` + Cookie string `json:"cookie,omitempty"` + APIStatus int `json:"api_status"` + API string `json:"api"` +} + +type parityWebReq struct { + Path string `json:"path"` + Auth string `json:"auth,omitempty"` + CType string `json:"ctype,omitempty"` + Body string `json:"body,omitempty"` + // DuckDuckGo + Referer string `json:"referer,omitempty"` + Accept string `json:"accept,omitempty"` + Lang string `json:"lang,omitempty"` + UA string `json:"ua,omitempty"` + Cookie string `json:"cookie,omitempty"` +} + +type parityWebCall struct { + Name string `json:"name"` + Setup map[string]string `json:"setup,omitempty"` // WEB_ENGINE, CRAWL4AI_URL, crawl_key ("-" efface) + Advance int `json:"advance_s,omitempty"` + Tool string `json:"tool"` + Args map[string]any `json:"args"` + Result string `json:"result"` + Crawls []parityWebReq `json:"crawls"` + Images []parityWebReq `json:"images"` + GoFetch []string `json:"go_fetch"` + GoSearch []string `json:"go_search"` +} + +type parityGoOut struct { + Markdown string `json:"markdown,omitempty"` + Results []map[string]string `json:"results,omitempty"` + Error string `json:"error,omitempty"` +} + +type parityWebJournal struct { + Crawl map[string][]parityCrawlResp `json:"crawl"` + Images map[string]parityImgResp `json:"images"` + GoFetch map[string]parityGoOut `json:"go_fetch"` + GoSearch map[string]parityGoOut `json:"go_search"` + Calls []parityWebCall `json:"calls"` +} + +// Les réponses du faux Crawl4AI, par URL demandée (consommées dans l'ordre, +// la dernière reste). +func parityCrawlFixtures() map[string][]parityCrawlResp { + ddg := func(q string) string { + return "https://html.duckduckgo.com/html/?q=" + q + } + obj := func(v any) string { + b, _ := json.Marshal(v) + return string(b) + } + long := strings.Repeat("une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. ", 3) + var longLines []string + for i := 0; i < 60; i++ { + longLines = append(longLines, fmt.Sprintf("%d %s", i, long)) + } + searchMD := strings.Join([]string{ + "# DuckDuckGo", + "", + "## [Loki & **Rust**](//duckduckgo.com/l/?uddg=https%3A%2F%2Floki.example%2Frust%3Fa%3D1%26b%3D2&rut=abc)", + "", + "[loki.example/rust](//duckduckgo.com/l/?uddg=x) [![](icon.png)](x) [Un port en **Rust** étape par étape](//duckduckgo.com/l/?uddg=y)", + "## [Publicité](https://duckduckgo.com/y.js?ad=1)", + "## [Deuxième "résultat"](https://deux.example/page)", + "", + "", + "", + "", + "", + "[trop loin pour le résumé](x)", + "## [Loki encore](//duckduckgo.com/l/?uddg=https%3A%2F%2Floki.example%2Frust%3Fa%3D1%26b%3D2)", + "## [Troisième](https://trois.example/) ", + "[!bang ignoré](x) [vraie description](y)", + "## [Quatrième](//duckduckgo.com/l/?uddg=%ZZ)", + "## [](https://vide.example/)", + }, "\n") + docMD := strings.Join([]string{ + "", + "# Documentation ", + "", + "Intro ligne 1", + "Intro ligne 1", + "", + "", + "", + "## Installation", + "Lancer `loki install`.\t", + "Erreur : moteur absent", + "### Détails", + "ligne a", + "ligne b", + "Erreur : port pris", + "ligne c", + "ligne d", + "ligne e", + "ligne f", + "Erreur : disque plein", + "####### pas un titre", + "## Fin\r", + "", + }, "\n") + return map[string][]parityCrawlResp{ + ddg("loki+rust"): {{200, obj(map[string]any{"results": []any{map[string]any{"success": true, + "markdown": map[string]any{"raw_markdown": searchMD, "fit_markdown": "ignoré"}}}})}}, + ddg("anti-bot"): {{200, obj(map[string]any{"results": []any{map[string]any{"success": true, + "markdown": "
"}}})}}, + ddg("rien"): {{200, obj(map[string]any{"results": []any{map[string]any{"success": true, "markdown": "# Rien\n\npas de résultat"}}})}}, + ddg("panne"): {{500, "Internal Server Error: " + strings.Repeat("é", 320)}}, + "https://exemple.test/doc": { + {200, obj(map[string]any{"results": []any{map[string]any{"success": true, + "markdown": map[string]any{"raw_markdown": "brut", "fit_markdown": docMD}}}})}, + {200, obj(map[string]any{"results": []any{map[string]any{"success": true, + "markdown": "# Version 2\n\nnouveau contenu"}}})}, + {200, obj(map[string]any{"results": []any{map[string]any{"success": true, + "markdown": "# Avec actions\n\nbouton cliqué"}}})}, + }, + "https://raw.githubusercontent.com/r0m1k3/loki/HEAD/README.md": {{200, obj(map[string]any{"results": []any{map[string]any{"success": true, "markdown": "# Loki\n\nLe README."}}})}}, + "https://exemple.test/echec": {{200, obj(map[string]any{"results": []any{map[string]any{"success": false, + "error_message": "net::ERR_NAME_NOT_RESOLVED " + strings.Repeat("x", 310)}}})}}, + "https://exemple.test/echec-muet": {{200, `{"results":[{"success":false}]}`}}, + "https://exemple.test/vide": {{200, `{"results":[]}`}}, + "https://exemple.test/tableau": {{200, `[]`}}, + "https://exemple.test/illisible": {{200, `pas du json`}}, + "https://exemple.test/seul": {{200, `{"success":true,"markdown":"# Seul\n\nun résultat seul"}`}}, + "https://exemple.test/mal-type": {{200, `{"results":[{"success":"oui","markdown":"x"}]}`}}, + "https://exemple.test/blanc": {{200, `{"results":[{"success":true,"markdown":" \n\t\n"}]}`}}, + "https://exemple.test/objet-faux": {{200, `{"results":[{"success":true,"markdown":{"raw_markdown":5}}]}`}}, + "https://exemple.test/longue": {{200, obj(map[string]any{"results": []any{map[string]any{"success": true, "markdown": strings.Join(longLines, "\n")}}})}}, + "https://exemple.test/brut-absent": {{200, `{"results":[{"success":true,"markdown":{"fit_markdown":"# Fit seul\n\nok"}}]}`}}, + } +} + +func parityImageFixtures() map[string]parityImgResp { + page := func(vqd string) string { + return `` + } + res := func(items ...map[string]any) string { + b, _ := json.Marshal(map[string]any{"results": items}) + return string(b) + } + it := func(img, title, u string, w, h int) map[string]any { + return map[string]any{"image": img, "title": title, "url": u, "width": w, "height": h, "thumbnail": "t"} + } + return map[string]parityImgResp{ + "chats": {200, page("4-123-456"), "dcm=7; Path=/; HttpOnly", 200, res( + it("https://img.example/1.jpg", "Chat roux", "https://page.example/1", 800, 600), + it("https://img.example/1.jpg", "Doublon", "https://page.example/1b", 1, 1), + it("", "Sans image", "https://page.example/2", 1, 1), + it("data:image/png;base64,xx", "Pas http", "https://page.example/3", 1, 1), + it("http://img.example/4.png", "Chat & chien <3", "https://page.example/4", 1024, 768), + it("https://img.example/5.jpg", "Cinquième", "https://page.example/5", 10, 20), + )}, + "sans jeton": {200, "rien", "", 200, "{}"}, + "api en panne": {200, page("1"), "", 500, "oops"}, + "page en panne": {403, "interdit", "", 200, "{}"}, + "illisible": {200, page("2"), "", 200, "pas du json"}, + "apres": {200, page("3"), "", 200, `{"results":[]} x`}, + "largeur texte": {200, page("4"), "", 200, `{"results":[{"image":"https://i/1","width":"800"}]}`}, + "vide": {200, page("5"), "", 200, `{"results":[]}`}, + "null": {200, page("6"), "", 200, `{"results":null,"next":"i.js?x"}`}, + "resultats faux": {200, page("7"), "", 200, `{"results":"non"}`}, + "apostrophe": {200, `vqd='77-88'`, "", 200, `{"results":[{"image":"https://i/a","title":"A","url":"https://p/a","width":1,"height":2}]}`}, + } +} + +// Le faux site du moteur intégré. +var parityWebPages = map[string]string{ + "/article.html": `Guide de Loki + +

Guide de Loki

+

Loki fait tourner un modèle de langage sur votre machine, sans rien envoyer ailleurs. Ce guide explique l'installation, la configuration et les premiers pas avec l'agent.

+

Installation

+

Téléchargez le binaire depuis la page des versions, puis lancez-le. Le moteur llama.cpp est installé au premier démarrage, précompilé pour votre carte graphique.

+

Configuration

+

Les réglages se font dans l'interface : modèle, contexte, outils. Erreur fréquente : un port déjà pris par un autre service. Changez alors le port dans les réglages avancés.

+

Erreur moins fréquente : un disque plein empêche le téléchargement du modèle.

+
© Loki
`, + "/texte.txt": "Ligne brute 1\nLigne brute 2\n", +} + +// Les faux résultats DuckDuckGo (page HTML) du moteur intégré. +var parityWebDDGHTML = map[string]string{ + "loki": ``, + "bot": ``, +} + +func TestPariteOutilsWeb(t *testing.T) { + testHome(t) + crawlFx := parityCrawlFixtures() + imgFx := parityImageFixtures() + + var mu sync.Mutex + var crawls, images []parityWebReq + served := map[string]int{} + crawlSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + var in struct { + URLs []string `json:"urls"` + } + _ = json.Unmarshal(body, &in) + mu.Lock() + crawls = append(crawls, parityWebReq{Path: r.Method + " " + r.URL.Path, Auth: r.Header.Get("Authorization"), + CType: r.Header.Get("Content-Type"), Body: string(body)}) + target := "" + if len(in.URLs) > 0 { + target = in.URLs[0] + } + list := crawlFx[target] + n := served[target] + served[target]++ + mu.Unlock() + if len(list) == 0 { + w.WriteHeader(404) + _, _ = io.WriteString(w, "inconnu : "+target) + return + } + if n >= len(list) { + n = len(list) - 1 + } + w.WriteHeader(list[n].Status) + _, _ = io.WriteString(w, list[n].Body) + })) + defer crawlSrv.Close() + imgSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + images = append(images, parityWebReq{Path: r.URL.RequestURI(), Referer: r.Header.Get("Referer"), + Accept: r.Header.Get("Accept"), Lang: r.Header.Get("Accept-Language"), + UA: r.Header.Get("User-Agent"), Cookie: r.Header.Get("Cookie")}) + mu.Unlock() + fx := imgFx[r.URL.Query().Get("q")] + if r.URL.Path == "/i.js" { + w.WriteHeader(fx.APIStatus) + _, _ = io.WriteString(w, fx.API) + return + } + if fx.Cookie != "" { + w.Header().Set("Set-Cookie", fx.Cookie) + } + w.WriteHeader(fx.PageStatus) + _, _ = io.WriteString(w, fx.Page) + })) + defer imgSrv.Close() + var pagesHit, searchHit []string + pagesSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + pagesHit = append(pagesHit, r.URL.Path) + mu.Unlock() + p, ok := parityWebPages[r.URL.Path] + if !ok { + http.NotFound(w, r) + return + } + if strings.HasSuffix(r.URL.Path, ".txt") { + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + } else { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + } + _, _ = io.WriteString(w, p) + })) + defer pagesSrv.Close() + ddgHTMLSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query().Get("q") + mu.Lock() + searchHit = append(searchHit, q) + mu.Unlock() + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = io.WriteString(w, parityWebDDGHTML[q]) + })) + defer ddgHTMLSrv.Close() + dead, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + deadAddr := dead.Addr().String() + dead.Close() + + oldImg, oldHTML, oldNow, oldJar := ddgImagesBase, ddgHTMLBase, webNow, goHTTPClient.Jar + ddgImagesBase, ddgHTMLBase = imgSrv.URL, ddgHTMLSrv.URL + goHTTPClient.Jar = newCookieJar() // les cookies d'un passage précédent ne comptent pas + base := time.Now() + var skew time.Duration + webNow = func() time.Time { return base.Add(skew) } + pageCacheMu.Lock() + pageCache = map[string]*cacheEntry{} + pageCacheMu.Unlock() + defer func() { + ddgImagesBase, ddgHTMLBase, webNow, goHTTPClient.Jar = oldImg, oldHTML, oldNow, oldJar + pageCacheMu.Lock() + pageCache = map[string]*cacheEntry{} + pageCacheMu.Unlock() + }() + + mask := func(s string) string { + s = strings.ReplaceAll(s, crawlSrv.URL, "{{CRAWL}}") + s = strings.ReplaceAll(s, imgSrv.URL, "{{DDG}}") + s = strings.ReplaceAll(s, pagesSrv.URL, "{{PAGES}}") + s = strings.ReplaceAll(s, "http://"+deadAddr, "{{DEAD}}") + return strings.ReplaceAll(s, deadAddr, "{{DEADADDR}}") + } + unmask := func(s string) string { + s = strings.ReplaceAll(s, "{{CRAWL}}", crawlSrv.URL) + s = strings.ReplaceAll(s, "{{PAGES}}", pagesSrv.URL) + return strings.ReplaceAll(s, "{{DEAD}}", "http://"+deadAddr) + } + maskReqs := func(rs []parityWebReq) []parityWebReq { + out := []parityWebReq{} + for _, r := range rs { + r.Path, r.Body, r.Referer = mask(r.Path), mask(r.Body), mask(r.Referer) + out = append(out, r) + } + return out + } + + // Ce que le moteur intégré tire des pages et des résultats, rangé pour la + // façade (qui le demande au Go). + goFetch := map[string]parityGoOut{} + for _, p := range []string{"/article.html", "/texte.txt", "/absente"} { + md, err := goFetchMarkdown(pagesSrv.URL + p) + o := parityGoOut{Markdown: mask(md)} + if err != nil { + o = parityGoOut{Error: mask(err.Error())} + } + goFetch["{{PAGES}}"+p] = o + } + goSearchOut := map[string]parityGoOut{} + for _, q := range []struct { + q string + n int + }{{"loki", 8}, {"loki", 1}, {"bot", 8}} { + res, err := goSearch(q.q, q.n) + o := parityGoOut{Results: []map[string]string{}} + for _, r := range res { + o.Results = append(o.Results, map[string]string{"title": r.Title, "url": r.URL, "snippet": r.Snippet}) + } + if err != nil { + o = parityGoOut{Error: err.Error()} + } + goSearchOut[fmt.Sprintf("%s|%d", q.q, q.n)] = o + } + mu.Lock() + pagesHit, searchHit = nil, nil + mu.Unlock() + + crawlSetup := map[string]string{"WEB_ENGINE": "crawl4ai", "CRAWL4AI_URL": "{{CRAWL}}", "crawl_key": "sesame"} + var calls []parityWebCall + run := func(c parityWebCall) { + for k, v := range c.Setup { + switch k { + case "crawl_key": + if v == "-" { + v = "" + } + if err := putStr(bkState, "crawl_key", v); err != nil { + t.Fatal(err) + } + default: + if v == "-" { + v = "" + } + if err := SetConfigKey(k, unmask(v)); err != nil { + t.Fatal(err) + } + } + } + skew += time.Duration(c.Advance) * time.Second + args := map[string]any{} + raw, _ := json.Marshal(c.Args) + _ = json.Unmarshal([]byte(unmask(string(raw))), &args) + res := execTool(t.Context(), c.Tool, args, toolCallLabel(c.Tool, args), Caps{Agent: true}) + mu.Lock() + c.Result = mask(res.Result) + c.Crawls, c.Images = maskReqs(crawls), maskReqs(images) + c.GoFetch, c.GoSearch = []string{}, []string{} + for _, p := range pagesHit { + c.GoFetch = append(c.GoFetch, "{{PAGES}}"+p) + } + c.GoSearch = append(c.GoSearch, searchHit...) + crawls, images, pagesHit, searchHit = nil, nil, nil, nil + mu.Unlock() + if c.Args == nil { + c.Args = map[string]any{} + } + calls = append(calls, c) + } + a := func(kv ...any) map[string]any { + m := map[string]any{} + for i := 0; i+1 < len(kv); i += 2 { + m[kv[i].(string)] = kv[i+1] + } + return m + } + + // Crawl4AI. + run(parityWebCall{Name: "recherche", Setup: crawlSetup, Tool: "web_search", Args: a("query", "loki rust")}) + run(parityWebCall{Name: "recherche limitée", Tool: "web_search", Args: a("query", "loki rust", "limit", 1.0)}) + run(parityWebCall{Name: "recherche, limite hors bornes", Tool: "web_search", Args: a("query", "loki rust", "limit", 99.0)}) + run(parityWebCall{Name: "recherche, limite nulle", Tool: "web_search", Args: a("query", "loki rust", "limit", -3.0)}) + run(parityWebCall{Name: "recherche, limite texte", Tool: "web_search", Args: a("query", "loki rust", "limit", "2")}) + run(parityWebCall{Name: "défi anti-bot", Tool: "web_search", Args: a("query", "anti-bot")}) + run(parityWebCall{Name: "aucun résultat", Tool: "web_search", Args: a("query", "rien")}) + run(parityWebCall{Name: "Crawl4AI en panne", Tool: "web_search", Args: a("query", "panne")}) + run(parityWebCall{Name: "ouverture", Tool: "web_open", Args: a("url", "https://exemple.test/doc")}) + run(parityWebCall{Name: "lecture", Tool: "web_read", Args: a("url", "https://exemple.test/doc")}) + run(parityWebCall{Name: "lecture d'une plage", Tool: "web_read", Args: a("url", "https://exemple.test/doc", "offset", 5.0, "limit", 3.0)}) + run(parityWebCall{Name: "lecture au-delà", Tool: "web_read", Args: a("url", "https://exemple.test/doc", "offset", 400.0)}) + run(parityWebCall{Name: "lecture, bornes", Tool: "web_read", Args: a("url", "https://exemple.test/doc", "offset", -2.0, "limit", 0.0)}) + run(parityWebCall{Name: "grep", Tool: "web_grep", Args: a("url", "https://exemple.test/doc", "pattern", "erreur")}) + run(parityWebCall{Name: "grep sans contexte", Tool: "web_grep", Args: a("url", "https://exemple.test/doc", "pattern", "^ligne [ae]$", "context", 0.0)}) + run(parityWebCall{Name: "grep plafonné", Tool: "web_grep", Args: a("url", "https://exemple.test/doc", "pattern", "ligne", "max_matches", 2.0, "context", 1.0)}) + run(parityWebCall{Name: "grep, contexte large", Tool: "web_grep", Args: a("url", "https://exemple.test/doc", "pattern", "Détails", "context", 50.0)}) + run(parityWebCall{Name: "grep sans résultat", Tool: "web_grep", Args: a("url", "https://exemple.test/doc", "pattern", "introuvable")}) + run(parityWebCall{Name: "grep, ascii", Tool: "web_grep", Args: a("url", "https://exemple.test/doc", "pattern", `\bD\w+ils`)}) + for i, p := range []string{"(", "a)", "[abc", "*x", "a**", `\q`, `a\`} { + run(parityWebCall{Name: fmt.Sprintf("regex invalide %d", i+1), Tool: "web_grep", Args: a("url", "https://exemple.test/doc", "pattern", p)}) + } + run(parityWebCall{Name: "page jamais ouverte", Tool: "web_read", Args: a("url", "https://exemple.test/autre")}) + run(parityWebCall{Name: "grep, page jamais ouverte", Tool: "web_grep", Args: a("url", "https://exemple.test/autre", "pattern", "x")}) + run(parityWebCall{Name: "réouverture : cache", Tool: "web_open", Args: a("url", "https://exemple.test/doc")}) + run(parityWebCall{Name: "réouverture forcée", Tool: "web_open", Args: a("url", "https://exemple.test/doc", "refresh", true)}) + run(parityWebCall{Name: "ouverture avec actions", Advance: 5, Tool: "web_open", Args: a("url", "https://exemple.test/doc", + "actions", []any{"document.querySelector('#plus').click();", 3.0, "x && y < z"}, "wait_for", "css:#fin", "dismiss_popups", false)}) + run(parityWebCall{Name: "lecture : la plus récente", Tool: "web_read", Args: a("url", "https://exemple.test/doc")}) + run(parityWebCall{Name: "attente seule", Tool: "web_open", Args: a("url", "https://exemple.test/seul", "wait_for", "js:() => true")}) + run(parityWebCall{Name: "sans fermer les bandeaux", Tool: "web_open", Args: a("url", "https://exemple.test/brut-absent", "dismiss_popups", false)}) + run(parityWebCall{Name: "cache périmé", Advance: 601, Tool: "web_read", Args: a("url", "https://exemple.test/doc")}) + run(parityWebCall{Name: "ouverture après péremption", Tool: "web_open", Args: a("url", "https://exemple.test/doc")}) + run(parityWebCall{Name: "dépôt GitHub", Tool: "web_open", Args: a("url", "https://github.com/r0m1k3/loki/")}) + run(parityWebCall{Name: "lecture du dépôt", Tool: "web_read", Args: a("url", "https://github.com/r0m1k3/loki")}) + run(parityWebCall{Name: "GitHub, pas un dépôt", Tool: "web_open", Args: a("url", "https://github.com/r0m1k3")}) + run(parityWebCall{Name: "crawl en échec", Tool: "web_open", Args: a("url", "https://exemple.test/echec")}) + run(parityWebCall{Name: "crawl en échec, sans message", Tool: "web_open", Args: a("url", "https://exemple.test/echec-muet")}) + run(parityWebCall{Name: "réponse vide", Tool: "web_open", Args: a("url", "https://exemple.test/vide")}) + run(parityWebCall{Name: "réponse tableau", Tool: "web_open", Args: a("url", "https://exemple.test/tableau")}) + run(parityWebCall{Name: "réponse illisible", Tool: "web_open", Args: a("url", "https://exemple.test/illisible")}) + run(parityWebCall{Name: "résultat mal typé", Tool: "web_open", Args: a("url", "https://exemple.test/mal-type")}) + run(parityWebCall{Name: "page blanche", Tool: "web_open", Args: a("url", "https://exemple.test/blanc")}) + run(parityWebCall{Name: "markdown objet faux", Tool: "web_open", Args: a("url", "https://exemple.test/objet-faux")}) + run(parityWebCall{Name: "page longue", Tool: "web_open", Args: a("url", "https://exemple.test/longue")}) + run(parityWebCall{Name: "lecture tronquée", Tool: "web_read", Args: a("url", "https://exemple.test/longue", "limit", 500.0)}) + run(parityWebCall{Name: "URL inconnue du faux", Tool: "web_open", Args: a("url", "https://exemple.test/inconnue")}) + run(parityWebCall{Name: "sans clé", Setup: map[string]string{"crawl_key": "-"}, Tool: "web_open", Args: a("url", "https://exemple.test/seul", "refresh", true)}) + run(parityWebCall{Name: "Crawl4AI injoignable", Setup: map[string]string{"CRAWL4AI_URL": "{{DEAD}}/"}, Tool: "web_open", Args: a("url", "https://exemple.test/x")}) + run(parityWebCall{Name: "sans serveur Crawl4AI", Setup: map[string]string{"CRAWL4AI_URL": "-"}, Tool: "web_open", Args: a("url", "https://exemple.test/x")}) + run(parityWebCall{Name: "lecture d'une page vue par Crawl4AI", Tool: "web_read", Args: a("url", "https://exemple.test/seul", "limit", 1.0)}) + + // Moteur intégré. + goSetup := map[string]string{"WEB_ENGINE": "go"} + run(parityWebCall{Name: "intégré : ouverture", Setup: goSetup, Tool: "web_open", Args: a("url", "{{PAGES}}/article.html")}) + run(parityWebCall{Name: "intégré : grep", Tool: "web_grep", Args: a("url", "{{PAGES}}/article.html", "pattern", "erreur", "context", 0.0)}) + run(parityWebCall{Name: "intégré : texte brut", Tool: "web_open", Args: a("url", "{{PAGES}}/texte.txt", "actions", []any{"x()"})}) + run(parityWebCall{Name: "intégré : lecture", Tool: "web_read", Args: a("url", "{{PAGES}}/texte.txt")}) + run(parityWebCall{Name: "intégré : page absente", Tool: "web_open", Args: a("url", "{{PAGES}}/absente")}) + run(parityWebCall{Name: "intégré : recherche", Tool: "web_search", Args: a("query", "loki")}) + run(parityWebCall{Name: "intégré : recherche limitée", Tool: "web_search", Args: a("query", "loki", "limit", 1.0)}) + run(parityWebCall{Name: "intégré : anti-bot", Tool: "web_search", Args: a("query", "bot")}) + + // Images (même client quel que soit le moteur). + run(parityWebCall{Name: "images", Tool: "web_images", Args: a("query", "chats")}) + run(parityWebCall{Name: "images, cookie gardé", Tool: "web_images", Args: a("query", "chats", "limit", 1.0)}) + for _, q := range []string{"sans jeton", "api en panne", "page en panne", "illisible", "apres", "largeur texte", "vide", "null", "resultats faux", "apostrophe"} { + run(parityWebCall{Name: "images : " + q, Tool: "web_images", Args: a("query", q)}) + } + + j := parityWebJournal{Crawl: crawlFx, Images: imgFx, GoFetch: goFetch, GoSearch: goSearchOut, Calls: calls} + var buf bytes.Buffer + enc := json.NewEncoder(&buf) + enc.SetEscapeHTML(false) + enc.SetIndent("", " ") + if err := enc.Encode(j); err != nil { + t.Fatal(err) + } + got := buf.Bytes() + if os.Getenv("LOKI_PARITY_UPDATE") == "1" { + if err := os.MkdirAll(filepath.Dir(parityWebFile), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(parityWebFile, got, 0o644); err != nil { + t.Fatal(err) + } + return + } + want, err := os.ReadFile(parityWebFile) + if err != nil { + t.Fatalf("journal de référence absent (%v) : LOKI_PARITY_UPDATE=1 pour le créer", err) + } + if !bytes.Equal(got, want) { + t.Errorf("les outils web ne produisent plus le journal rangé dans %s.\n"+ + "LOKI_PARITY_UPDATE=1 pour le régénérer si le changement est voulu.", parityWebFile) + } +} diff --git a/internal/loki/web_fetch_go.go b/internal/loki/web_fetch_go.go index 1d96af0..6f5d6de 100644 --- a/internal/loki/web_fetch_go.go +++ b/internal/loki/web_fetch_go.go @@ -309,7 +309,7 @@ func goSearch(query string, limit int) ([]searchResult, error) { ctx, cancel := context.WithTimeout(context.Background(), goFetchTimeout) defer cancel() - target := "https://html.duckduckgo.com/html/?q=" + url.QueryEscape(query) + target := ddgHTMLBase + "/html/?q=" + url.QueryEscape(query) body, contentType, _, err := goFetch(ctx, target) if err != nil { return nil, err diff --git a/internal/loki/web_internal.go b/internal/loki/web_internal.go index ce5a857..3a28ccb 100644 --- a/internal/loki/web_internal.go +++ b/internal/loki/web_internal.go @@ -137,6 +137,10 @@ func withInternalAPI(next http.Handler) http.Handler { handleInternalDictateStop(w, r) case "/internal/v1/web/reach/reset": handleInternalReachReset(w, r) + case "/internal/v1/web/fetch": + handleInternalWebFetch(w, r) + case "/internal/v1/web/search": + handleInternalWebSearch(w, r) case "/internal/v1/cdp/shutdown": handleInternalCDPShutdown(w, r) case "/internal/v1/service/action": diff --git a/internal/loki/web_internal_web.go b/internal/loki/web_internal_web.go new file mode 100644 index 0000000..3e26917 --- /dev/null +++ b/internal/loki/web_internal_web.go @@ -0,0 +1,52 @@ +package loki + +import "net/http" + +// API interne du moteur web intégré (lot 42 de la migration Rust). +// +// La façade exécute les outils web de ses tours (web_search, web_images, +// web_open, web_read, web_grep) : cache des pages, client Crawl4AI, lecture +// des résultats, mise en forme. Le moteur intégré lit le HTML avec +// go-readability, html-to-markdown et le parseur HTML5 de x/net : rien en +// Rust ne les reproduit au caractère près, ils restent donc ici, derrière +// deux appels sans état. +// +// POST /internal/v1/web/fetch {"url":"…"} +// → {"markdown":"…"} | {"error":"…"} +// POST /internal/v1/web/search {"query":"…","limit":8} +// → {"results":[{"title":"…","url":"…","snippet":"…"}]} | {"error":"…"} + +func handleInternalWebFetch(w http.ResponseWriter, r *http.Request) { + var in struct { + URL string `json:"url"` + } + if !readInternalJSON(w, r, &in) { + return + } + md, err := goFetchMarkdown(in.URL) + if err != nil { + sendJSON(w, http.StatusOK, map[string]any{"error": err.Error()}) + return + } + sendJSON(w, http.StatusOK, map[string]any{"markdown": md}) +} + +func handleInternalWebSearch(w http.ResponseWriter, r *http.Request) { + var in struct { + Query string `json:"query"` + Limit int `json:"limit"` + } + if !readInternalJSON(w, r, &in) { + return + } + results, err := goSearch(in.Query, in.Limit) + if err != nil { + sendJSON(w, http.StatusOK, map[string]any{"error": err.Error()}) + return + } + out := make([]map[string]string, 0, len(results)) + for _, r := range results { + out = append(out, map[string]string{"title": r.Title, "url": r.URL, "snippet": r.Snippet}) + } + sendJSON(w, http.StatusOK, map[string]any{"results": out}) +} diff --git a/internal/loki/web_internal_web_test.go b/internal/loki/web_internal_web_test.go new file mode 100644 index 0000000..5cbd403 --- /dev/null +++ b/internal/loki/web_internal_web_test.go @@ -0,0 +1,58 @@ +package loki + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// L'API interne du moteur web intégré (lot 42) : la conversion d'une page et +// la recherche DuckDuckGo, telles que la façade les demande. +func TestInternalMoteurWebIntegre(t *testing.T) { + site := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/page.txt": + w.Header().Set("Content-Type", "text/plain") + _, _ = io.WriteString(w, "bonjour\n") + case "/html/": + w.Header().Set("Content-Type", "text/html") + _, _ = io.WriteString(w, `
R`+ + `résumé
`) + default: + http.NotFound(w, r) + } + })) + defer site.Close() + old := ddgHTMLBase + ddgHTMLBase = site.URL + defer func() { ddgHTMLBase = old }() + + call := func(h http.HandlerFunc, method, body string) (int, map[string]any) { + w := httptest.NewRecorder() + h(w, httptest.NewRequest(method, "/x", strings.NewReader(body))) + var out map[string]any + _ = json.Unmarshal(w.Body.Bytes(), &out) + return w.Code, out + } + if code, out := call(handleInternalWebFetch, "POST", `{"url":"`+site.URL+`/page.txt"}`); code != 200 || out["markdown"] != "bonjour\n" { + t.Fatalf("fetch : %d %v", code, out) + } + if code, out := call(handleInternalWebFetch, "POST", `{"url":"`+site.URL+`/absente"}`); code != 200 || + out["error"] != "HTTP 404 sur "+site.URL+"/absente" { + t.Fatalf("fetch absente : %d %v", code, out) + } + code, out := call(handleInternalWebSearch, "POST", `{"query":"q","limit":5}`) + res, _ := json.Marshal(out["results"]) + if code != 200 || string(res) != `[{"snippet":"résumé","title":"R","url":"https://r.example/"}]` { + t.Fatalf("search : %d %s", code, res) + } + if code, _ := call(handleInternalWebSearch, "GET", ""); code != http.StatusMethodNotAllowed { + t.Fatalf("GET : %d", code) + } + if code, _ := call(handleInternalWebFetch, "POST", "pas du json"); code != http.StatusBadRequest { + t.Fatalf("corps illisible : %d", code) + } +} diff --git a/rust/loki-rs/src/chat/generate.rs b/rust/loki-rs/src/chat/generate.rs index aa589da..0d4ef4c 100644 --- a/rust/loki-rs/src/chat/generate.rs +++ b/rust/loki-rs/src/chat/generate.rs @@ -1322,6 +1322,7 @@ impl ChatShared { mcp: None, tasks: None, memory: None, + web: Default::default(), go_version: Default::default(), } } diff --git a/rust/loki-rs/src/chat/host.rs b/rust/loki-rs/src/chat/host.rs index 1c99111..ebb4d7d 100644 --- a/rust/loki-rs/src/chat/host.rs +++ b/rust/loki-rs/src/chat/host.rs @@ -347,6 +347,8 @@ pub struct ChatShared { /// La mémoire, quand la façade la tient (LOKI_MEM=rust) : les outils /// `mem_*` et `tracker` du tour s'exécutent ici (lot 40). pub memory: Option>, + /// Les outils web (lot 42) : cache des pages, Crawl4AI, DuckDuckGo. + pub web: Arc, /// La version du Go, annoncée aux serveurs MCP à une reconnexion ; lue /// une fois. pub go_version: tokio::sync::OnceCell, @@ -698,6 +700,13 @@ impl TurnHost for GoTurnHost { }; } } + // Outils web (lot 42) : le cache des pages est ici. + if let Some(result) = self.shared.web.exec(&self.shared.store, name, args).await { + return ToolOutcome { + result, + ..Default::default() + }; + } // Outils task_*, quand la façade tient les tâches (lot 37). if crate::tasks::tools::is_tool(name) && let Some(tasks) = &self.shared.tasks diff --git a/rust/loki-rs/src/chat/stream.rs b/rust/loki-rs/src/chat/stream.rs index 1b0d83f..69614fb 100644 --- a/rust/loki-rs/src/chat/stream.rs +++ b/rust/loki-rs/src/chat/stream.rs @@ -968,8 +968,67 @@ pub fn go_regex(p: &str) -> Option { let cs: Vec = p.chars().collect(); let mut out = String::with_capacity(p.len() + 16); let (mut i, mut class, mut class_start) = (0, false, false); + // Répétition qui précède : 0 aucune, 1 une répétition (un « ? » la + // rend paresseuse), 2 déjà paresseuse. Le Go refuse d'en répéter une. + let mut rep = 0u8; while i < cs.len() { let c = cs[i]; + if !class { + match c { + '*' | '+' | '?' => { + if rep == 1 && c == '?' { + rep = 2; + } else if rep != 0 { + return None; + } else { + rep = 1; + } + out.push(c); + i += 1; + continue; + } + '{' => { + // `{n}`, `{n,}`, `{n,m}` (1000 au plus) ; sinon un « { » + // littéral pour le Go. + let rest: String = cs[i + 1..].iter().collect(); + let body = rest.split_once('}').map(|(b, _)| b); + let parsed = body.and_then(|b| { + let (lo, hi) = match b.split_once(',') { + Some((lo, hi)) => (lo, Some(hi)), + None => (b, None), + }; + let num = |t: &str| { + (!t.is_empty() && t.bytes().all(|d| d.is_ascii_digit())) + .then(|| t.parse::().unwrap_or(u64::MAX)) + }; + let lo = num(lo)?; + let hi = match hi { + None => Some(lo), + Some("") => None, + Some(h) => Some(num(h)?), + }; + Some((b.chars().count(), lo, hi)) + }); + match parsed { + Some((len, lo, hi)) => { + if lo > 1000 || hi.is_some_and(|h| h > 1000 || h < lo) || rep != 0 { + return None; + } + rep = 1; + out.extend(&cs[i..i + len + 2]); + i += len + 2; + } + None => { + rep = 0; + out.push_str(r"\{"); + i += 1; + } + } + continue; + } + _ => rep = 0, + } + } if c == '\\' && i + 1 < cs.len() { let n = cs[i + 1]; i += 2; @@ -990,6 +1049,18 @@ pub fn go_regex(p: &str) -> Option { class_start = false; continue; } + // `\p{…}`, `\P{…}`, `\x{…}` : les accolades sont à l'échappement. + if matches!(n, 'p' | 'P' | 'x') + && cs.get(i) == Some(&'{') + && let Some(k) = cs[i..].iter().position(|&c| c == '}') + { + out.push('\\'); + out.push(n); + out.extend(&cs[i..=i + k]); + i += k + 1; + class_start = false; + continue; + } // Un chiffre seul est une référence arrière pour le Go, qui la // refuse ; avec l'octal permis, `regex` l'accepterait. if ('1'..='7').contains(&n) && !cs.get(i).is_some_and(|d| ('0'..='7').contains(d)) { @@ -2872,6 +2943,19 @@ mod tests { assert_eq!(m(r"\Q(x", "a(x"), Some("(x".into())); // Le Go refuse \Q dans une classe. assert_eq!(go_regex(r"[\Q]-\E]+"), None); + // Répétition d'une répétition : refusée par le Go ; « ? » paresseux + // permis une fois ; « { » hors répétition littéral ; bornes ≤ 1000. + for p in ["a**", "a+*", "a*??", "a{2}*", "a*{2}", "a{1001}", "a{3,2}"] { + assert_eq!(go_regex(p), None, "{p}"); + } + assert_eq!(go_regex("a*?b+?c??").as_deref(), Some("a*?b+?c??")); + assert_eq!(go_regex("a{2,}{").as_deref(), Some(r"a{2,}\{")); + assert_eq!(go_regex("x{a}").as_deref(), Some(r"x\{a}")); + assert!(compile_go_regex("x{,3}").unwrap().is_match("x{,3}")); + assert_eq!( + go_regex(r"\p{Greek}+\x{41}{2}").as_deref(), + Some(r"\p{Greek}+\x{41}{2}") + ); // Dans une classe, « [ », « && », « -- » et « ~~ » sont littéraux. assert_eq!(m(r"[a[]+", "x[a["), Some("[a[".into())); assert_eq!(m(r"[a&&b]+", "x&ab"), Some("&ab".into())); diff --git a/rust/loki-rs/src/gojson.rs b/rust/loki-rs/src/gojson.rs index 415b322..ac4d9b4 100644 --- a/rust/loki-rs/src/gojson.rs +++ b/rust/loki-rs/src/gojson.rs @@ -304,7 +304,7 @@ fn kind_of(v: &Value) -> &'static str { } /// `quoteChar` du scanner Go. -fn quote_char(c: u8) -> String { +pub(crate) fn quote_char(c: u8) -> String { match c { b'\'' => r"'\''".into(), b'"' => "'\"'".into(), diff --git a/rust/loki-rs/src/lib.rs b/rust/loki-rs/src/lib.rs index 4d14062..3faa175 100644 --- a/rust/loki-rs/src/lib.rs +++ b/rust/loki-rs/src/lib.rs @@ -52,3 +52,4 @@ pub mod tune; pub mod tz; pub mod ui; pub mod update; +pub mod webtools; diff --git a/rust/loki-rs/src/webtools.rs b/rust/loki-rs/src/webtools.rs new file mode 100644 index 0000000..6a2b297 --- /dev/null +++ b/rust/loki-rs/src/webtools.rs @@ -0,0 +1,1578 @@ +//! Outils web de l'agent (lot 42 de la migration) — port de +//! `chat_internet_tools.go` (`web_search`, `web_open`, `web_read`, +//! `web_grep`), de `chat_internet.go` (client Crawl4AI, cache des pages, +//! lecture des résultats DuckDuckGo) et de `chat_web_images.go` +//! (`web_images`). +//! +//! Le cache des pages (10 min) est ici : `web_open` puis `web_read` ou +//! `web_grep` dans un tour de la façade ne passent plus par le Go. Le +//! moteur intégré lit le HTML avec go-readability, html-to-markdown et le +//! parseur HTML5 de x/net, que rien en Rust ne reproduit au caractère près : +//! la conversion d'une page et la lecture des résultats DuckDuckGo restent +//! au Go (`/internal/v1/web/fetch`, `/internal/v1/web/search`), sans état. +//! `web_screenshot` reste au Go (navigateur piloté). + +use std::collections::HashMap; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use hyper::Method; +use serde_json::{Map, Value, json}; + +use crate::hf::go_query_escape; +use crate::store::{BK_STATE, Store}; +use crate::system::{ENGINE_GO, crawl4ai_url, go_lossy, web_engine}; + +/// `webMaxOutput` : plafond d'une réponse d'outil web (en caractères). +pub const MAX_OUTPUT: usize = 8000; +/// `pageCacheTTL`. +pub const PAGE_TTL: Duration = Duration::from_secs(600); +/// `goFetchUA`. +pub const UA: &str = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"; +/// `goFetchMaxBytes`. +const MAX_BYTES: usize = 8 << 20; +/// `goFetchTimeout`. +const FETCH_TIMEOUT: Duration = Duration::from_secs(30); +/// Délai d'un appel à Crawl4AI. +const CRAWL_TIMEOUT: Duration = Duration::from_secs(60); + +/// `autoDismissJS` : bandeaux cookies et consentement fermés dans la page. +pub const AUTO_DISMISS_JS: &str = r#"(() => { try { + const TEXT = /^(accept all|accept|i accept|agree|i agree|got it|i understand|j'accepte|tout accepter|accepter|d'accord|allow all|allow|consent|continue|ok)$/i; + const isOverlayish = (el) => { + try { + let cur = el; + for (let i = 0; i < 6 && cur; i++) { + const s = getComputedStyle(cur); + if (s.position === 'fixed' || s.position === 'sticky') return true; + const idcls = ((cur.id || '') + ' ' + (cur.className || '')).toLowerCase(); + if (/cookie|consent|gdpr|cmp|privacy/.test(idcls)) return true; + cur = cur.parentElement; + } + } catch {} + return false; + }; + const candidates = document.querySelectorAll('button, [role="button"], input[type="button"], input[type="submit"]'); + let clicked = 0; + for (const b of candidates) { + if (clicked >= 2) break; + const t = (b.innerText || b.value || b.getAttribute('aria-label') || '').trim(); + if (!t || !TEXT.test(t)) continue; + if (b.offsetParent === null) continue; + if (!isOverlayish(b)) continue; + try { b.click(); clicked++; } catch {} + } + document.querySelectorAll('[id*="cookie" i],[id*="consent" i],[class*="cookie" i],[class*="consent" i],[id*="gdpr" i],[class*="gdpr" i],[id*="cmp" i],[class*="cmp" i]') + .forEach(el => { try { + const s = getComputedStyle(el); + if (s.position === 'fixed' || s.position === 'sticky') el.remove(); + } catch {} }); + document.documentElement.style.overflow = 'auto'; + if (document.body) document.body.style.overflow = 'auto'; +} catch {} })();"#; + +/// Les outils servis ici. +pub fn is_tool(name: &str) -> bool { + matches!( + name, + "web_search" | "web_images" | "web_open" | "web_read" | "web_grep" + ) +} + +/// Une page ouverte (`cacheEntry`). +#[derive(Debug)] +pub struct Entry { + pub url: String, + pub lines: Vec, + at: Instant, +} + +/// `fetchOptions`. +#[derive(Clone, Debug, Default)] +pub struct FetchOpts { + pub force: bool, + pub actions: Vec, + pub dismiss_popups: bool, + pub wait_for: String, +} + +/// `crwlOptions`. +#[derive(Default)] +struct CrwlOpts { + js_code: Vec, + wait_for: String, + page_timeout_ms: i64, + raw_markdown: bool, +} + +/// `searchResult`. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SearchResult { + pub title: String, + pub url: String, + pub snippet: String, +} + +/// `imageResult`. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct ImageResult { + pub image: String, + pub title: String, + pub url: String, + pub width: i64, + pub height: i64, +} + +/// Les outils web d'une façade : cache des pages, clients HTTP. +pub struct Web { + cache: std::sync::Mutex>>, + crawl: reqwest::Client, + http: reqwest::Client, + /// Cookies posés par les sites joints par `http` (le jar en mémoire du + /// client du moteur intégré) : (hôte, nom, valeur), dans l'ordre. + jar: std::sync::Mutex>, + /// `ddgImagesBase`. + pub ddg_base: String, + /// Avance de l'horloge du cache (tests). + skew: std::sync::Mutex, +} + +impl Default for Web { + fn default() -> Self { + Self::new() + } +} + +impl Web { + pub fn new() -> Self { + Self::with_ddg_base("https://duckduckgo.com") + } + + /// Pour les tests : un faux DuckDuckGo. + pub fn with_ddg_base(base: &str) -> Self { + crate::chat::engine::install_crypto(); + Web { + cache: Default::default(), + crawl: reqwest::Client::builder().build().unwrap_or_default(), + http: reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::limited(10)) + .timeout(FETCH_TIMEOUT) + .build() + .unwrap_or_default(), + jar: Default::default(), + ddg_base: base.trim_end_matches('/').to_string(), + skew: Default::default(), + } + } + + /// Avance l'horloge du cache (tests). + pub fn advance(&self, d: Duration) { + *self.skew.lock().unwrap_or_else(|e| e.into_inner()) += d; + } + + fn now(&self) -> Instant { + Instant::now() + *self.skew.lock().unwrap_or_else(|e| e.into_inner()) + } + + /// Un outil web : le résultat que le modèle reçoit, `None` pour un autre + /// outil. + pub async fn exec( + &self, + store: &Store, + name: &str, + args: &Map, + ) -> Option { + let out = match name { + "web_search" => self.tool_search(store, args).await, + "web_images" => self.tool_images(args).await, + "web_open" => self.tool_open(store, args).await, + "web_read" => self.tool_read(args), + "web_grep" => self.tool_grep(args), + _ => return None, + }; + Some(cap_output(&out)) + } + + // --- Outils ------------------------------------------------------------- + + /// `toolWebSearch`. + async fn tool_search(&self, store: &Store, args: &Map) -> String { + let query = arg_str(args, "query"); + let limit = arg_int(args, "limit").unwrap_or(8).clamp(1, 20) as usize; + let results = match self.search(store, &query, limit).await { + Ok(r) => r, + Err(e) => return format!("❌ Recherche échouée : {e}"), + }; + if results.is_empty() { + return format!("Aucun résultat pour « {query} »"); + } + let mut b = format!( + "# Recherche : {query}\n{} résultat(s) DuckDuckGo\n\n", + results.len() + ); + for (i, r) in results.iter().enumerate() { + b.push_str(&format!( + "{}. {}\n {}\n {}\n\n", + i + 1, + r.title, + r.url, + r.snippet + )); + } + b.trim_end_matches('\n').to_string() + } + + /// `toolWebImages`. + async fn tool_images(&self, args: &Map) -> String { + let query = arg_str(args, "query"); + let limit = arg_int(args, "limit").unwrap_or(6).clamp(1, 20) as usize; + let results = match self.images(&query, limit).await { + Ok(r) => r, + Err(e) => return format!("❌ Recherche d'images échouée : {e}"), + }; + if results.is_empty() { + return format!("Aucune image pour « {query} »"); + } + let mut b = format!( + "# Images : {query}\n{} résultat(s) DuckDuckGo\n\n", + results.len() + ); + for (i, r) in results.iter().enumerate() { + b.push_str(&format!( + "{}. {} ({}x{})\n image : {}\n page : {}\n\n", + i + 1, + r.title, + r.width, + r.height, + r.image, + r.url + )); + } + b.push_str("To show an image to the user, write ![title](image URL) in your answer."); + b + } + + /// `toolWebOpen`. + async fn tool_open(&self, store: &Store, args: &Map) -> String { + let u = arg_str(args, "url"); + let mut opts = FetchOpts { + dismiss_popups: true, + ..Default::default() + }; + if let Some(Value::Bool(b)) = args.get("refresh") { + opts.force = *b; + } + if let Some(Value::Bool(b)) = args.get("dismiss_popups") { + opts.dismiss_popups = *b; + } + if let Some(Value::String(s)) = args.get("wait_for") { + opts.wait_for = s.clone(); + } + if let Some(Value::Array(a)) = args.get("actions") { + opts.actions = a + .iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect(); + } + let entry = match self.get_page(store, &u, &opts).await { + Ok(e) => e, + Err(e) => return format!("❌ {e}"), + }; + let total = entry.lines.len(); + let chars = total + entry.lines.iter().map(String::len).sum::(); + format!( + "# Ouvert : {}\nTotal : {total} lignes, {} ({chars} caractères)\nEn cache 10 min. Utilise web_read ou web_grep pour lire.\n\n## Plan (n° de ligne des titres)\n```\n{}\n```", + entry.url, + format_bytes(chars), + extract_outline(&entry.lines) + ) + } + + /// `toolWebRead`. + fn tool_read(&self, args: &Map) -> String { + let u = arg_str(args, "url"); + let Some(entry) = self.find_cached(&u) else { + return format!("❌ Page absente du cache. Appelle d'abord web_open(\"{u}\")."); + }; + let total = entry.lines.len() as i64; + let offset = arg_int(args, "offset").unwrap_or(1).max(1); + let limit = arg_int(args, "limit").unwrap_or(80).clamp(1, 500); + let start = (offset - 1).min(total); + let end = start.saturating_add(limit).min(total); + let slice = &entry.lines[start as usize..end as usize]; + let remaining = total - end; + let tail = if remaining > 0 { + format!(" ({remaining} de plus en dessous)") + } else { + " (fin de page)".to_string() + }; + format!( + "# {}\nLignes {offset}–{end} sur {total}{tail}\n\n```\n{}\n```", + entry.url, + format_lines(slice, offset) + ) + } + + /// `toolWebGrep`. + fn tool_grep(&self, args: &Map) -> String { + let u = arg_str(args, "url"); + let pattern = arg_str(args, "pattern"); + let Some(entry) = self.find_cached(&u) else { + return format!("❌ Page absente du cache. Appelle d'abord web_open(\"{u}\")."); + }; + let expr = format!("(?i){pattern}"); + let Some(re) = crate::chat::stream::compile_go_regex(&expr) else { + return format!("❌ Regex invalide : {}", go_regexp_error(&expr)); + }; + let ctx = arg_int(args, "context").unwrap_or(2).max(0); + let max_matches = arg_int(args, "max_matches").unwrap_or(30).max(1); + let lines = &entry.lines; + let mut idx: Vec = Vec::new(); + for (i, l) in lines.iter().enumerate() { + if idx.len() as i64 >= max_matches { + break; + } + if re.is_match(l) { + idx.push(i); + } + } + if idx.is_empty() { + return format!("# {}\nAucun match pour /{pattern}/i", entry.url); + } + // Fenêtres de contexte fusionnées quand elles se chevauchent. + let last = lines.len() as i64 - 1; + let mut ranges: Vec<(i64, i64)> = Vec::new(); + for &i in &idx { + let i = i as i64; + let s = i.saturating_sub(ctx).max(0); + let e = i.saturating_add(ctx).min(last); + match ranges.last_mut() { + Some(r) if s <= r.1 + 1 => r.1 = r.1.max(e), + _ => ranges.push((s, e)), + } + } + let blocks: Vec = ranges + .iter() + .map(|&(s, e)| { + format!( + "```\n{}\n```", + format_lines(&lines[s as usize..=e as usize], s + 1) + ) + }) + .collect(); + let capped = if idx.len() as i64 == max_matches { + format!(" (plafonné à {max_matches})") + } else { + String::new() + }; + format!( + "# {}\n{} match(es) pour /{pattern}/i{capped}\n\n{}", + entry.url, + idx.len(), + blocks.join("\n\n---\n\n") + ) + } + + // --- Cache -------------------------------------------------------------- + + /// `findCached` : l'ouverture la plus récente d'une URL, toutes options. + pub fn find_cached(&self, raw: &str) -> Option> { + let u = normalize_crawl_url(raw); + let now = self.now(); + let cache = self.cache.lock().unwrap_or_else(|e| e.into_inner()); + let mut best: Option<&Arc> = None; + for (k, v) in cache.iter() { + if k == &u || k.starts_with(&format!("{u}#")) { + if now.saturating_duration_since(v.at) > PAGE_TTL { + continue; + } + if best.is_none_or(|b| v.at > b.at) { + best = Some(v); + } + } + } + best.cloned() + } + + /// `getPage` : la page, du cache ou récupérée. + pub async fn get_page( + &self, + store: &Store, + raw: &str, + opts: &FetchOpts, + ) -> Result, String> { + let u = normalize_crawl_url(raw); + let key = cache_key_for(&u, opts); + if !opts.force { + let cache = self.cache.lock().unwrap_or_else(|e| e.into_inner()); + if let Some(c) = cache.get(&key) + && self.now().saturating_duration_since(c.at) < PAGE_TTL + { + return Ok(c.clone()); + } + } + let mut js_code = Vec::new(); + if opts.dismiss_popups { + js_code.push(AUTO_DISMISS_JS.to_string()); + } + js_code.extend(opts.actions.iter().cloned()); + let page_timeout_ms = if !opts.wait_for.is_empty() || !opts.actions.is_empty() { + 45000 + } else { + 0 + }; + let cfg = store.config().await; + let md = if web_engine(&cfg) == ENGINE_GO { + go_fetch(store, &u).await? + } else { + self.run_crwl( + store, + &cfg, + &u, + &CrwlOpts { + js_code, + wait_for: opts.wait_for.clone(), + page_timeout_ms, + raw_markdown: false, + }, + ) + .await? + }; + if md.trim().is_empty() { + return Err("page vide".into()); + } + let entry = Arc::new(Entry { + url: u, + lines: normalize_lines(&md), + at: self.now(), + }); + self.cache + .lock() + .unwrap_or_else(|e| e.into_inner()) + .insert(key, entry.clone()); + Ok(entry) + } + + // --- Crawl4AI ----------------------------------------------------------- + + /// `runCrwl` : POST {base}/crawl, le markdown du premier résultat. + async fn run_crwl( + &self, + store: &Store, + cfg: &HashMap, + target: &str, + opts: &CrwlOpts, + ) -> Result { + let base = crawl4ai_url(cfg); + if base.is_empty() { + return Err("aucun serveur Crawl4AI configuré (CRAWL4AI_URL)".into()); + } + // Clés dans l'ordre où `json.Marshal` écrit une map : triées. + let mut params = Map::new(); + if !opts.js_code.is_empty() { + params.insert("js_code".into(), json!(opts.js_code)); + } + if opts.page_timeout_ms > 0 { + params.insert("page_timeout".into(), json!(opts.page_timeout_ms)); + } + if !opts.wait_for.is_empty() { + params.insert("wait_for".into(), json!(opts.wait_for)); + } + let body = crate::gojson::marshal(&json!({ + "browser_config": { "params": { "headless": true }, "type": "BrowserConfig" }, + "crawler_config": { "params": params, "type": "CrawlerRunConfig" }, + "urls": [target], + })); + let url = format!("{base}/crawl"); + let key = store.get_str(BK_STATE, "crawl_key").await; + let mut rq = self + .crawl + .post(&url) + .header("Content-Type", "application/json") + .body(body); + if !key.is_empty() { + rq = rq.header("Authorization", format!("Bearer {key}")); + } + let call = async { + let resp = rq.send().await.map_err(|e| { + format!( + "Crawl4AI injoignable ({base}): {}", + go_net_err("Post", &url, &e) + ) + })?; + let status = resp.status().as_u16(); + let raw = resp.bytes().await.map(|b| b.to_vec()).unwrap_or_default(); + Ok::<_, String>((status, raw)) + }; + let (status, raw) = tokio::time::timeout(CRAWL_TIMEOUT, call) + .await + .map_err(|_| { + format!("Crawl4AI injoignable ({base}): Post \"{url}\": context deadline exceeded") + })??; + if status != 200 { + return Err(format!( + "Crawl4AI HTTP {status}: {}", + tail_runes(&go_lossy(&raw), 300) + )); + } + crawl_markdown(&raw, opts.raw_markdown) + } + + // --- Recherche ---------------------------------------------------------- + + /// `duckduckgoSearch`. + async fn search( + &self, + store: &Store, + query: &str, + limit: usize, + ) -> Result, String> { + let cfg = store.config().await; + if web_engine(&cfg) == ENGINE_GO { + return go_search(store, query, limit).await; + } + let url = format!( + "https://html.duckduckgo.com/html/?q={}", + go_query_escape(query.as_bytes()) + ); + let md = self + .run_crwl( + store, + &cfg, + &url, + &CrwlOpts { + raw_markdown: true, + page_timeout_ms: 30000, + ..Default::default() + }, + ) + .await?; + parse_ddg_markdown(&md, limit) + } + + /// `duckduckgoImages` : le jeton vqd de la page de recherche, puis i.js. + async fn images(&self, query: &str, limit: usize) -> Result, String> { + let deadline = tokio::time::Instant::now() + FETCH_TIMEOUT; + let q = go_query_escape(query.as_bytes()); + let page = format!("{}/?q={q}&iax=images&ia=images", self.ddg_base); + let body = self.ddg_get(&page, "", deadline).await?; + let vqd = regex::bytes::Regex::new(r#"vqd=["']?([0-9-]+)"#).expect("regex"); + let Some(m) = vqd.captures(&body) else { + return Err("jeton DuckDuckGo introuvable (défi anti-bot ?)".into()); + }; + let token = String::from_utf8_lossy(&m[1]).into_owned(); + let api = format!( + "{}/i.js?l=fr-fr&o=json&f=,,,,,&p=1&q={q}&vqd={token}", + self.ddg_base + ); + let body = self + .ddg_get(&api, "https://duckduckgo.com/", deadline) + .await?; + let all = decode_images(&body).map_err(|e| format!("réponse illisible : {e}"))?; + let mut out: Vec = Vec::new(); + let mut seen = std::collections::HashSet::new(); + for it in all { + if it.image.is_empty() || seen.contains(&it.image) || !it.image.starts_with("http") { + continue; + } + seen.insert(it.image.clone()); + out.push(it); + if out.len() >= limit { + break; + } + } + Ok(out) + } + + /// `ddgGet`. + async fn ddg_get( + &self, + target: &str, + referer: &str, + deadline: tokio::time::Instant, + ) -> Result, String> { + let host = url::Url::parse(target) + .ok() + .and_then(|u| u.host_str().map(String::from)) + .unwrap_or_default(); + let mut rq = self + .http + .get(target) + .header("User-Agent", UA) + .header("Accept-Language", "fr-FR,fr;q=0.9,en;q=0.8"); + if !referer.is_empty() { + rq = rq + .header("Referer", referer) + .header("Accept", "application/json, text/javascript, */*; q=0.01"); + } + let cookies: Vec = self + .jar + .lock() + .unwrap_or_else(|e| e.into_inner()) + .iter() + .filter(|(h, _, _)| *h == host) + .map(|(_, n, v)| format!("{n}={v}")) + .collect(); + if !cookies.is_empty() { + rq = rq.header("Cookie", cookies.join("; ")); + } + let call = async { + let resp = rq + .send() + .await + .map_err(|e| format!("échec de la requête : {}", go_net_err("Get", target, &e)))?; + self.keep_cookies(&host, resp.headers()); + let status = resp.status().as_u16(); + let mut body = resp.bytes().await.map_err(|e| e.to_string())?.to_vec(); + body.truncate(MAX_BYTES); + Ok::<_, String>((status, body)) + }; + let (status, body) = tokio::time::timeout_at(deadline, call) + .await + .map_err(|_| { + format!("échec de la requête : Get \"{target}\": context deadline exceeded") + })??; + if status >= 400 { + return Err(format!("HTTP {status}")); + } + Ok(body) + } + + /// Les `Set-Cookie` d'une réponse, gardés pour l'hôte (nom=valeur ; le + /// suivant du même nom remplace, à sa place). + fn keep_cookies(&self, host: &str, headers: &reqwest::header::HeaderMap) { + let mut jar = self.jar.lock().unwrap_or_else(|e| e.into_inner()); + for v in headers.get_all("set-cookie") { + let Ok(v) = v.to_str() else { continue }; + let pair = v.split(';').next().unwrap_or("").trim(); + let Some((name, value)) = pair.split_once('=') else { + continue; + }; + let (name, value) = (name.trim().to_string(), value.trim().to_string()); + if name.is_empty() { + continue; + } + match jar.iter_mut().find(|(h, n, _)| h == host && *n == name) { + Some(c) => c.2 = value, + None => jar.push((host.to_string(), name, value)), + } + } + } +} + +// --- Moteur intégré, au Go --------------------------------------------------- + +/// `goFetchMarkdown`, par l'API interne du Go. +async fn go_fetch(store: &Store, u: &str) -> Result { + let v = store + .internal_json( + Method::POST, + "/internal/v1/web/fetch", + Some(&json!({ "url": u })), + ) + .await + .map_err(|e| format!("moteur web intégré injoignable : {e}"))?; + if let Some(e) = v + .get("error") + .and_then(Value::as_str) + .filter(|e| !e.is_empty()) + { + return Err(e.to_string()); + } + Ok(v.get("markdown") + .and_then(Value::as_str) + .unwrap_or("") + .to_string()) +} + +/// `goSearch`, par l'API interne du Go. +async fn go_search(store: &Store, query: &str, limit: usize) -> Result, String> { + let v = store + .internal_json( + Method::POST, + "/internal/v1/web/search", + Some(&json!({ "query": query, "limit": limit })), + ) + .await + .map_err(|e| format!("moteur web intégré injoignable : {e}"))?; + if let Some(e) = v + .get("error") + .and_then(Value::as_str) + .filter(|e| !e.is_empty()) + { + return Err(e.to_string()); + } + let s = |r: &Value, k: &str| r.get(k).and_then(Value::as_str).unwrap_or("").to_string(); + Ok(v.get("results") + .and_then(Value::as_array) + .into_iter() + .flatten() + .map(|r| SearchResult { + title: s(r, "title"), + url: s(r, "url"), + snippet: s(r, "snippet"), + }) + .collect()) +} + +// --- Fonctions pures --------------------------------------------------------- + +/// Texte d'un argument (`args[k].(string)`). +fn arg_str(args: &Map, k: &str) -> String { + args.get(k) + .and_then(Value::as_str) + .unwrap_or("") + .to_string() +} + +/// Entier d'un argument nombre (`int(args[k].(float64))`). +fn arg_int(args: &Map, k: &str) -> Option { + args.get(k).and_then(Value::as_f64).map(go_int) +} + +/// `int(f)` du Go : tronqué ; hors bornes, l'entier indéfini d'amd64 (ou la +/// saturation d'arm64). +pub fn go_int(f: f64) -> i64 { + const TWO63: f64 = 9_223_372_036_854_775_808.0; + if f.is_nan() || !(-TWO63..TWO63).contains(&f) { + if cfg!(target_arch = "x86_64") { + return i64::MIN; + } + return f as i64; + } + f as i64 +} + +/// `capWebOutput` : le début gardé, la suite à relire par tranches. +pub fn cap_output(s: &str) -> String { + match s.char_indices().nth(MAX_OUTPUT) { + None => s.to_string(), + Some((i, _)) => format!( + "{}\n…[tronqué : réponse trop longue. Relis par tranches avec web_read(offset, limit) ou cible avec web_grep.]", + &s[..i] + ), + } +} + +/// `tailRunes`. +pub fn tail_runes(s: &str, n: usize) -> String { + let count = s.chars().count(); + if count <= n { + return s.to_string(); + } + s.chars().skip(count - n).collect() +} + +/// `formatBytes`. +pub fn format_bytes(n: usize) -> String { + if n < 1024 { + format!("{n} B") + } else if n < 1024 * 1024 { + format!("{:.1} KB", n as f64 / 1024.0) + } else { + format!("{:.2} MB", n as f64 / 1024.0 / 1024.0) + } +} + +/// `formatLines` : lignes numérotées à partir de `start`. +pub fn format_lines(lines: &[String], start: i64) -> String { + let mut b = String::new(); + for (i, l) in lines.iter().enumerate() { + b.push_str(&format!("{:5} | {l}\n", start + i as i64)); + } + b.trim_end_matches('\n').to_string() +} + +/// `extractOutline` : les titres markdown et leur n° de ligne. +pub fn extract_outline(lines: &[String]) -> String { + let re = go_re(r"^(#{1,6})\s+(.+?)\s*$"); + let mut out = Vec::new(); + for (i, l) in lines.iter().enumerate() { + if let Some(m) = re.captures(l) { + let indent = " ".repeat(m[1].len() - 1); + out.push(format!("{:5} | {indent}{}", i + 1, &m[2])); + } + } + if out.is_empty() { + return "(aucun titre markdown trouvé)".into(); + } + out.join("\n") +} + +/// Une regex écrite pour le Go, traduite. +fn go_re(p: &str) -> regex::Regex { + crate::chat::stream::compile_go_regex(p).expect("regex du Go") +} + +/// `normalizeLines` : \r\n → \n, fins de ligne nettoyées, vides et +/// doublons consécutifs réduits, vides du bord retirés. +pub fn normalize_lines(raw: &str) -> Vec { + let src = raw.replace("\r\n", "\n"); + let mut out: Vec = Vec::new(); + let mut prev = String::new(); + let mut blank_run = 0; + for l in src.split('\n') { + let l = l.trim_end_matches([' ', '\t']); + if l.trim().is_empty() { + blank_run += 1; + if blank_run > 1 { + continue; + } + out.push(String::new()); + prev.clear(); + continue; + } + blank_run = 0; + if l == prev { + continue; + } + out.push(l.to_string()); + prev = l.to_string(); + } + let start = out.iter().position(|l| !l.is_empty()).unwrap_or(out.len()); + let end = out + .iter() + .rposition(|l| !l.is_empty()) + .map_or(start, |e| e + 1); + out[start..end.max(start)].to_vec() +} + +/// `cacheKeyFor` : l'URL, suivie de l'empreinte des options qui changent la +/// page. +pub fn cache_key_for(u: &str, opts: &FetchOpts) -> String { + let a = if opts.actions.is_empty() { + Value::Null + } else { + json!(opts.actions) + }; + let fp = + crate::gojson::marshal(&json!({ "a": a, "d": opts.dismiss_popups, "w": opts.wait_for })); + if fp == r#"{"a":null,"d":true,"w":""}"# { + return u.to_string(); + } + format!("{u}#{}", &hex::encode(md5(fp.as_bytes()))[..8]) +} + +/// Les morceaux d'une URL lue comme `url.Parse` du Go : (hôte, chemin +/// décodé, requête brute) ; `None` si le Go la refuse. +fn go_url_parts(raw: &str) -> Option<(String, String, String)> { + if raw.bytes().any(|b| b < 0x20 || b == 0x7f) { + return None; + } + let s = raw.split('#').next().unwrap_or(""); + // Schéma : une lettre, puis lettres, chiffres, « + - . », avant « : ». + let mut rest = s; + for (i, c) in s.char_indices() { + match c { + 'a'..='z' | 'A'..='Z' => {} + '0'..='9' | '+' | '-' | '.' if i > 0 => {} + ':' => { + if i == 0 { + return None; + } + rest = &s[i + 1..]; + break; + } + _ => break, + } + } + let (rest, query) = match rest.split_once('?') { + Some((r, q)) => (r, q.to_string()), + None => (rest, String::new()), + }; + let (host, path) = match rest.strip_prefix("//") { + Some(a) if !rest.starts_with("///") || s.len() != rest.len() => { + let end = a.find('/').unwrap_or(a.len()); + let auth = &a[..end]; + let host = auth.rsplit_once('@').map_or(auth, |(_, h)| h); + (host.to_string(), &a[end..]) + } + _ => (String::new(), rest), + }; + let path = percent_decode(path, false)?; + Some((host, go_lossy(&path), query)) +} + +/// `%XX` décodés (`+` en espace pour une requête) ; `None` sur un `%` mal +/// formé, comme le Go. +fn percent_decode(s: &str, plus: bool) -> Option> { + let b = s.as_bytes(); + let mut out = Vec::with_capacity(b.len()); + let mut i = 0; + while i < b.len() { + match b[i] { + b'%' => { + let h = |c: u8| (c as char).to_digit(16); + let (Some(hi), Some(lo)) = ( + b.get(i + 1).and_then(|&c| h(c)), + b.get(i + 2).and_then(|&c| h(c)), + ) else { + return None; + }; + out.push((hi * 16 + lo) as u8); + i += 3; + } + b'+' if plus => { + out.push(b' '); + i += 1; + } + c => { + out.push(c); + i += 1; + } + } + } + Some(out) +} + +/// `normalizeCrawlURL` : github.com/owner/repo → son README brut. +pub fn normalize_crawl_url(raw: &str) -> String { + let Some((host, path, _)) = go_url_parts(raw) else { + return raw.to_string(); + }; + if host == "github.com" { + let parts: Vec<&str> = path.trim_matches('/').split('/').collect(); + if parts.len() == 2 { + return format!( + "https://raw.githubusercontent.com/{}/{}/HEAD/README.md", + parts[0], parts[1] + ); + } + } + raw.to_string() +} + +/// `decodeHTMLEntities`. +pub fn decode_html_entities(s: &str) -> String { + const REPL: [(&str, &str); 7] = [ + ("&", "&"), + ("<", "<"), + (">", ">"), + (""", "\""), + ("'", "'"), + ("/", "/"), + (" ", " "), + ]; + let mut out = String::with_capacity(s.len()); + let mut i = 0; + 'outer: while i < s.len() { + for (old, new) in REPL { + if s[i..].starts_with(old) { + out.push_str(new); + i += old.len(); + continue 'outer; + } + } + let c = s[i..].chars().next().expect("caractère"); + out.push(c); + i += c.len_utf8(); + } + let re = go_re(r"&#(\d+);"); + re.replace_all(&out, |m: ®ex::Captures| { + let n: i64 = m[1].parse().unwrap_or(0); + if n > 0 { + char::from_u32(n as i32 as u32) + .filter(|_| (n as i32) >= 0) + .unwrap_or('\u{fffd}') + .to_string() + } else { + m[0].to_string() + } + }) + .into_owned() +} + +/// `decodeUddg` : DuckDuckGo enrobe ses résultats en +/// `//duckduckgo.com/l/?uddg=URL_ENCODÉE`. +pub fn decode_uddg(raw: &str) -> String { + let s = if raw.starts_with("//") { + format!("https:{raw}") + } else { + raw.to_string() + }; + let Some((_, _, query)) = go_url_parts(&s) else { + return s; + }; + // `u.Query().Get("uddg")` : la première paire lisible. + let mut q: Option = None; + for pair in query.split('&') { + if pair.is_empty() || pair.contains(';') { + continue; + } + let (k, v) = pair.split_once('=').unwrap_or((pair, "")); + let (Some(k), Some(v)) = (percent_decode(k, true), percent_decode(v, true)) else { + continue; + }; + if k == b"uddg" { + q = Some(go_lossy(&v)); + break; + } + } + match q.filter(|q| !q.is_empty()) { + Some(q) => match percent_decode(&q, true) { + Some(dec) => go_lossy(&dec), + None => s, + }, + None => s, + } +} + +/// La lecture des résultats DuckDuckGo dans le markdown de Crawl4AI +/// (`duckduckgoSearch`). +pub fn parse_ddg_markdown(md: &str, limit: usize) -> Result, String> { + if md.contains("anomaly-modal") || md.contains("anomaly.js") { + return Err("DuckDuckGo a renvoyé un défi anti-bot".into()); + } + let head = go_re(r"^##\s+\[([^\]]+)\]\(([^)]+)\)\s*$"); + let link = go_re(r"\[([^\]]+)\]\([^)]+\)"); + let urlish = go_re(r"^[\w.-]+\.[a-z]{2,}"); + let lines: Vec<&str> = md.split('\n').collect(); + let mut results: Vec = Vec::new(); + let mut i = 0; + while i < lines.len() && results.len() < limit { + let Some(h) = head.captures(lines[i]) else { + i += 1; + continue; + }; + let title = decode_html_entities(&h[1]) + .replace("**", "") + .trim() + .to_string(); + let u = decode_uddg(&h[2]); + if title.is_empty() || u.is_empty() || u.contains("duckduckgo.com") { + i += 1; + continue; + } + let mut snippet = String::new(); + let mut j = i + 1; + while j < i + 6 && j < lines.len() { + let ln = lines[j].trim(); + j += 1; + if ln.is_empty() { + continue; + } + for lm in link.captures_iter(ln) { + let text = lm[1].trim(); + if text.is_empty() || text.starts_with('!') || urlish.is_match(text) { + continue; + } + snippet = decode_html_entities(text) + .replace("**", "") + .trim() + .to_string(); + break; + } + if !snippet.is_empty() { + break; + } + } + if !results.iter().any(|r| r.url == u) { + results.push(SearchResult { + title, + url: u, + snippet, + }); + } + i += 1; + } + Ok(results) +} + +/// Le markdown du premier résultat d'une réponse de Crawl4AI : `{results: +/// [...]}` ou un résultat seul, lus comme `json.Unmarshal` du Go. +pub fn crawl_markdown(raw: &[u8], prefer_raw: bool) -> Result { + let v = go_unmarshal_value(raw).ok(); + // `{results:[…]}` d'abord. + let mut results: Option> = None; + let mut results_ok = false; + if let Some(v) = &v { + match v { + Value::Object(o) => match go_field(o, "results") { + None | Some(Value::Null) => results_ok = true, + Some(Value::Array(a)) => { + let mut ok = true; + let list: Vec = a + .iter() + .map(|e| { + let (r, good) = CrwlResult::from(e); + ok &= good; + r + }) + .collect(); + results_ok = ok; + results = Some(list); + } + Some(_) => {} + }, + Value::Null => results_ok = true, + _ => {} + } + } + let mut list = results.unwrap_or_default(); + if !results_ok || list.is_empty() { + // Un résultat seul. + let single = match &v { + Some(Value::Null) => Some(CrwlResult::default()), + Some(o @ Value::Object(_)) => { + let (r, good) = CrwlResult::from(o); + good.then_some(r) + } + _ => None, + }; + if let Some(s) = single { + list = vec![s]; + } + } + let Some(r) = list.into_iter().next() else { + return Err("Crawl4AI : réponse vide".into()); + }; + if !r.success { + let msg = if r.error_message.is_empty() { + "échec du crawl".to_string() + } else { + r.error_message + }; + return Err(format!("Crawl4AI : {}", tail_runes(&msg, 300))); + } + Ok(r.markdown(prefer_raw)) +} + +/// `crwlResult`. +#[derive(Default)] +struct CrwlResult { + success: bool, + error_message: String, + markdown: Option, +} + +impl CrwlResult { + /// Le résultat, et faux si un champ a le mauvais type (erreur du Go). + fn from(v: &Value) -> (Self, bool) { + let mut r = CrwlResult::default(); + let Value::Object(o) = v else { + return (r, v.is_null()); + }; + let mut ok = true; + match go_field(o, "success") { + None | Some(Value::Null) => {} + Some(Value::Bool(b)) => r.success = *b, + Some(_) => ok = false, + } + match go_field(o, "error_message") { + None | Some(Value::Null) => {} + Some(Value::String(s)) => r.error_message = s.clone(), + Some(_) => ok = false, + } + r.markdown = go_field(o, "markdown").cloned(); + (r, ok) + } + + /// `crwlResult.markdown` : une chaîne, ou `{raw_markdown, fit_markdown}`. + fn markdown(&self, prefer_raw: bool) -> String { + match &self.markdown { + None | Some(Value::Null) => String::new(), + Some(Value::String(s)) => s.clone(), + Some(Value::Object(o)) => { + let field = |k: &str| match go_field(o, k) { + None | Some(Value::Null) => Some(String::new()), + Some(Value::String(s)) => Some(s.clone()), + Some(_) => None, + }; + let (Some(raw), Some(fit)) = (field("raw_markdown"), field("fit_markdown")) else { + return String::new(); + }; + if prefer_raw { + if !raw.is_empty() { raw } else { fit } + } else if !fit.is_empty() { + fit + } else { + raw + } + } + Some(_) => String::new(), + } + } +} + +/// Un champ d'objet comme le cherche `encoding/json` : le nom exact, sinon +/// sans casse ; la dernière occurrence l'emporte (déjà vrai d'un objet lu +/// par serde_json). +fn go_field<'a>(o: &'a Map, name: &str) -> Option<&'a Value> { + o.get(name).or_else(|| { + o.iter() + .filter(|(k, _)| k.eq_ignore_ascii_case(name)) + .map(|(_, v)| v) + .next_back() + }) +} + +/// `json.Unmarshal` dans une valeur : erreurs de syntaxe au texte du Go. +pub fn go_unmarshal_value(raw: &[u8]) -> Result { + let v = match crate::gojson::first_value(raw) { + Ok(v) => v, + Err(e) if e == "EOF" || e == "unexpected EOF" => { + return Err("unexpected end of JSON input".into()); + } + Err(e) => return Err(e), + }; + let start = v.as_ptr() as usize - raw.as_ptr() as usize; + if let Some(&c) = raw[start + v.len()..] + .iter() + .find(|c| !matches!(c, b' ' | b'\t' | b'\r' | b'\n')) + { + return Err(format!( + "invalid character {} after top-level value", + crate::gojson::quote_char(c) + )); + } + serde_json::from_slice(v).map_err(|e| e.to_string()) +} + +/// La réponse de `i.js`, lue comme `json.Unmarshal` dans +/// `struct{ Results []imageResult }` : les erreurs de type au texte du Go +/// (la première), après avoir tout lu. +pub fn decode_images(raw: &[u8]) -> Result, String> { + let v = go_unmarshal_value(raw)?; + let mut err: Option = None; + let mut fail = |e: String| { + if err.is_none() { + err = Some(e); + } + }; + let mut out = Vec::new(); + match &v { + Value::Object(o) => match go_field(o, "results") { + None | Some(Value::Null) => {} + Some(Value::Array(a)) => { + for e in a { + let mut r = ImageResult::default(); + match e { + Value::Null => {} + Value::Object(f) => { + for (name, slot) in [("image", 0), ("title", 1), ("url", 2)] { + match go_field(f, name) { + None | Some(Value::Null) => {} + Some(Value::String(s)) => match slot { + 0 => r.image = s.clone(), + 1 => r.title = s.clone(), + _ => r.url = s.clone(), + }, + Some(other) => fail(format!( + "json: cannot unmarshal {} into Go struct field imageResult.results.{name} of type string", + go_kind(other) + )), + } + } + for (name, wide) in [("width", true), ("height", false)] { + match go_field(f, name) { + None | Some(Value::Null) => {} + Some(Value::Number(n)) => match n.as_i64() { + Some(i) if !n.to_string().contains(['.', 'e', 'E']) => { + if wide { + r.width = i + } else { + r.height = i + } + } + _ => fail(format!( + "json: cannot unmarshal number {n} into Go struct field imageResult.results.{name} of type int" + )), + }, + Some(other) => fail(format!( + "json: cannot unmarshal {} into Go struct field imageResult.results.{name} of type int", + go_kind(other) + )), + } + } + } + other => fail(format!( + "json: cannot unmarshal {} into Go struct field .results of type loki.imageResult", + go_kind(other) + )), + } + out.push(r); + } + } + Some(other) => fail(format!( + "json: cannot unmarshal {} into Go struct field .results of type []loki.imageResult", + go_kind(other) + )), + }, + Value::Null => {} + other => fail(format!( + "json: cannot unmarshal {} into Go value of type struct {{ Results []loki.imageResult \"json:\\\"results\\\"\" }}", + go_kind(other) + )), + } + match err { + Some(e) => Err(e), + None => Ok(out), + } +} + +/// Le nom d'une valeur JSON dans les erreurs du Go. +fn go_kind(v: &Value) -> &'static str { + match v { + Value::Null => "null", + Value::Bool(_) => "bool", + Value::Number(_) => "number", + Value::String(_) => "string", + Value::Array(_) => "array", + Value::Object(_) => "object", + } +} + +/// L'erreur d'un client HTTP du Go (`*url.Error`), au mieux. +fn go_net_err(method: &str, url: &str, e: &reqwest::Error) -> String { + if e.is_timeout() { + return format!("{method} \"{url}\": context deadline exceeded"); + } + if e.is_redirect() { + return format!("{method} \"{url}\": trop de redirections"); + } + if e.is_connect() { + let mut src: Option<&(dyn std::error::Error + 'static)> = Some(e); + while let Some(s) = src { + if let Some(io) = s.downcast_ref::() + && io.kind() == std::io::ErrorKind::ConnectionRefused + { + let addr = url::Url::parse(url) + .ok() + .and_then(|u| { + let port = u.port_or_known_default()?; + Some(match u.host()? { + url::Host::Ipv6(a) => format!("[{a}]:{port}"), + h => format!("{h}:{port}"), + }) + }) + .unwrap_or_default(); + return format!("{method} \"{url}\": dial tcp {addr}: connect: connection refused"); + } + src = s.source(); + } + } + format!("{method} \"{url}\": {e}") +} + +/// L'erreur de `regexp.Compile` du Go pour les fautes courantes (parenthèse, +/// crochet, répétition sans argument ou imbriquée, échappement inconnu) ; +/// sinon « Perl non pris en charge ». +pub fn go_regexp_error(expr: &str) -> String { + let fmt = |code: &str, frag: &str| format!("error parsing regexp: {code}: `{frag}`"); + let cs: Vec = expr.chars().collect(); + let mut depth = 0i32; + // Peut-on répéter ce qui précède ? (début, après « ( » ou « | » : non) + let mut can_repeat = false; + // Le précédent était-il déjà une répétition (et son début) ? + let mut last_repeat: Option = None; + let mut i = 0; + while i < cs.len() { + let c = cs[i]; + match c { + '\\' => { + let Some(&n) = cs.get(i + 1) else { + return fmt("trailing backslash at end of expression", ""); + }; + let ok = n.is_ascii_punctuation() + || matches!( + n, + 'a' | 'f' + | 't' + | 'n' + | 'r' + | 'v' + | 'd' + | 'D' + | 's' + | 'S' + | 'w' + | 'W' + | 'b' + | 'B' + | 'A' + | 'z' + | 'p' + | 'P' + | 'Q' + | 'E' + | 'x' + | 'C' + | '0'..='7' + ); + if !ok || ('1'..='7').contains(&n) { + return fmt("invalid escape sequence", &format!("\\{n}")); + } + i += 2; + can_repeat = true; + last_repeat = None; + continue; + } + '[' => { + // Jusqu'au « ] » fermant (un « ] » en tête est littéral). + let start = i; + let mut j = i + 1; + if cs.get(j) == Some(&'^') { + j += 1; + } + if cs.get(j) == Some(&']') { + j += 1; + } + let mut closed = false; + while j < cs.len() { + match cs[j] { + '\\' => j += 2, + '[' if cs.get(j + 1) == Some(&':') => { + match (j + 2..cs.len().saturating_sub(1)) + .find(|&k| cs[k] == ':' && cs[k + 1] == ']') + { + Some(k) => j = k + 2, + None => j += 1, + } + } + ']' => { + closed = true; + break; + } + _ => j += 1, + } + } + if !closed { + return fmt("missing closing ]", &cs[start..].iter().collect::()); + } + i = j + 1; + can_repeat = true; + last_repeat = None; + continue; + } + '(' => { + depth += 1; + if cs.get(i + 1) == Some(&'?') { + // Drapeaux seuls « (?i) » : rien à répéter ensuite. + let k = cs[i..].iter().position(|&c| c == ')' || c == ':'); + if let Some(k) = k + && cs[i + k] == ')' + { + depth -= 1; + i += k + 1; + can_repeat = false; + last_repeat = None; + continue; + } + i += k.map_or(2, |k| k + 1); + } else { + i += 1; + } + can_repeat = false; + last_repeat = None; + continue; + } + ')' => { + if depth == 0 { + return fmt("unexpected )", expr); + } + depth -= 1; + can_repeat = true; + last_repeat = None; + } + '|' => { + can_repeat = false; + last_repeat = None; + } + '*' | '+' | '?' => { + if let Some(start) = last_repeat { + // « ? » après une répétition la rend paresseuse, une fois. + let prev = cs[i - 1]; + if !(c == '?' && prev != '?' && i - start == 1) { + return fmt( + "invalid nested repetition operator", + &cs[start..=i].iter().collect::(), + ); + } + i += 1; + continue; + } + if !can_repeat { + return fmt("missing argument to repetition operator", &c.to_string()); + } + last_repeat = Some(i); + i += 1; + continue; + } + _ => { + can_repeat = true; + last_repeat = None; + } + } + i += 1; + } + if depth > 0 { + return fmt("missing closing )", expr); + } + fmt("invalid or unsupported Perl syntax", expr) +} + +/// MD5 (RFC 1321), pour l'empreinte des options du cache. +pub fn md5(data: &[u8]) -> [u8; 16] { + const S: [u32; 64] = [ + 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 5, 9, 14, 20, 5, 9, 14, 20, 5, + 9, 14, 20, 5, 9, 14, 20, 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23, 6, 10, + 15, 21, 6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21, + ]; + const K: [u32; 64] = [ + 0xd76aa478, 0xe8c7b756, 0x242070db, 0xc1bdceee, 0xf57c0faf, 0x4787c62a, 0xa8304613, + 0xfd469501, 0x698098d8, 0x8b44f7af, 0xffff5bb1, 0x895cd7be, 0x6b901122, 0xfd987193, + 0xa679438e, 0x49b40821, 0xf61e2562, 0xc040b340, 0x265e5a51, 0xe9b6c7aa, 0xd62f105d, + 0x02441453, 0xd8a1e681, 0xe7d3fbc8, 0x21e1cde6, 0xc33707d6, 0xf4d50d87, 0x455a14ed, + 0xa9e3e905, 0xfcefa3f8, 0x676f02d9, 0x8d2a4c8a, 0xfffa3942, 0x8771f681, 0x6d9d6122, + 0xfde5380c, 0xa4beea44, 0x4bdecfa9, 0xf6bb4b60, 0xbebfbc70, 0x289b7ec6, 0xeaa127fa, + 0xd4ef3085, 0x04881d05, 0xd9d4d039, 0xe6db99e5, 0x1fa27cf8, 0xc4ac5665, 0xf4292244, + 0x432aff97, 0xab9423a7, 0xfc93a039, 0x655b59c3, 0x8f0ccc92, 0xffeff47d, 0x85845dd1, + 0x6fa87e4f, 0xfe2ce6e0, 0xa3014314, 0x4e0811a1, 0xf7537e82, 0xbd3af235, 0x2ad7d2bb, + 0xeb86d391, + ]; + let (mut a0, mut b0, mut c0, mut d0) = ( + 0x6745_2301u32, + 0xefcd_ab89u32, + 0x98ba_dcfeu32, + 0x1032_5476u32, + ); + let mut msg = data.to_vec(); + let bits = (data.len() as u64).wrapping_mul(8); + msg.push(0x80); + while msg.len() % 64 != 56 { + msg.push(0); + } + msg.extend_from_slice(&bits.to_le_bytes()); + for chunk in msg.chunks(64) { + let m: Vec = chunk + .chunks(4) + .map(|w| u32::from_le_bytes([w[0], w[1], w[2], w[3]])) + .collect(); + let (mut a, mut b, mut c, mut d) = (a0, b0, c0, d0); + for i in 0..64 { + let (f, g) = match i / 16 { + 0 => ((b & c) | (!b & d), i), + 1 => ((d & b) | (!d & c), (5 * i + 1) % 16), + 2 => (b ^ c ^ d, (3 * i + 5) % 16), + _ => (c ^ (b | !d), (7 * i) % 16), + }; + let f = f.wrapping_add(a).wrapping_add(K[i]).wrapping_add(m[g]); + a = d; + d = c; + c = b; + b = b.wrapping_add(f.rotate_left(S[i])); + } + a0 = a0.wrapping_add(a); + b0 = b0.wrapping_add(b); + c0 = c0.wrapping_add(c); + d0 = d0.wrapping_add(d); + } + let mut out = [0u8; 16]; + for (i, w) in [a0, b0, c0, d0].iter().enumerate() { + out[i * 4..i * 4 + 4].copy_from_slice(&w.to_le_bytes()); + } + out +} + +#[cfg(test)] +mod tests; diff --git a/rust/loki-rs/src/webtools/tests.rs b/rust/loki-rs/src/webtools/tests.rs new file mode 100644 index 0000000..a3ab463 --- /dev/null +++ b/rust/loki-rs/src/webtools/tests.rs @@ -0,0 +1,192 @@ +use super::*; + +#[test] +fn md5_rfc1321() { + for (input, want) in [ + ("", "d41d8cd98f00b204e9800998ecf8427e"), + ("a", "0cc175b9c0f1b6a831c399e269772661"), + ("abc", "900150983cd24fb0d6963f7d28e17f72"), + ("message digest", "f96b697d7cb7938d525a2f31aaf161d0"), + ( + "12345678901234567890123456789012345678901234567890123456789012345678901234567890", + "57edf4a22be3c955ac49da2e2107b67a", + ), + ] { + assert_eq!(hex::encode(md5(input.as_bytes())), want, "{input}"); + } +} + +#[test] +fn plafond_de_sortie() { + let s = "é".repeat(MAX_OUTPUT); + assert_eq!(cap_output(&s), s); + let long = format!("{s}x"); + let out = cap_output(&long); + assert!(out.starts_with(&s)); + assert!(out.ends_with("ou cible avec web_grep.]")); + assert!(!out.contains('x')); +} + +#[test] +fn cles_du_cache() { + let base = FetchOpts { + dismiss_popups: true, + ..Default::default() + }; + assert_eq!(cache_key_for("u", &base), "u"); + let o = FetchOpts { + dismiss_popups: false, + ..Default::default() + }; + // Valeurs tirées de cacheKeyFor du Go. + assert_eq!(cache_key_for("u", &o), "u#77fc370a"); + let o = FetchOpts { + dismiss_popups: true, + actions: vec!["a < b && c".into()], + ..Default::default() + }; + assert_eq!(cache_key_for("u", &o), "u#d1533b44"); +} + +#[test] +fn urls() { + for (raw, want) in [ + ( + "https://github.com/a/b", + "https://raw.githubusercontent.com/a/b/HEAD/README.md", + ), + ( + "//github.com/a/b/", + "https://raw.githubusercontent.com/a/b/HEAD/README.md", + ), + ( + "https://github.com/a%20x/b?tab=1#f", + "https://raw.githubusercontent.com/a x/b/HEAD/README.md", + ), + ("https://GitHub.com/a/b", "https://GitHub.com/a/b"), + ("https://github.com:443/a/b", "https://github.com:443/a/b"), + ("https://github.com/a", "https://github.com/a"), + ("https://github.com/a/b/c", "https://github.com/a/b/c"), + ("https://github.com/%zz/b", "https://github.com/%zz/b"), + ("github.com/a/b", "github.com/a/b"), + (":github.com/a/b", ":github.com/a/b"), + ] { + assert_eq!(normalize_crawl_url(raw), want, "{raw}"); + } + for (raw, want) in [ + ( + "//duckduckgo.com/l/?uddg=https%3A%2F%2Fa.example%2F%3Fx%3D1%26y%3D2&rut=z", + "https://a.example/?x=1&y=2", + ), + ("https://b.example/", "https://b.example/"), + ( + "//duckduckgo.com/l/?uddg=%ZZ", + "https://duckduckgo.com/l/?uddg=%ZZ", + ), + ("//duckduckgo.com/l/?x;y=1&uddg=a%2Bb+c", "a b c"), + ("/l/?uddg=", "/l/?uddg="), + ] { + assert_eq!(decode_uddg(raw), want, "{raw}"); + } +} + +#[test] +fn entites() { + assert_eq!( + decode_html_entities( + "a &lt; été � � � "" + ), + "a < été � A � \"" + ); +} + +#[test] +fn nombres_du_go() { + assert_eq!(go_int(3.9), 3); + assert_eq!(go_int(-3.9), -3); + if cfg!(target_arch = "x86_64") { + assert_eq!(go_int(1e300), i64::MIN); + assert_eq!(go_int(f64::NAN), i64::MIN); + } + assert_eq!(format_bytes(1023), "1023 B"); + assert_eq!(format_bytes(1536), "1.5 KB"); + assert_eq!(format_bytes(3 * 1024 * 1024 + 1), "3.00 MB"); + assert_eq!(tail_runes("abcdé", 2), "dé"); + assert_eq!(tail_runes("ab", 5), "ab"); +} + +#[test] +fn erreurs_de_regex() { + for (expr, want) in [ + ("(?i)(", "missing closing ): `(?i)(`"), + ("(?i)a)", "unexpected ): `(?i)a)`"), + ("(?i)[abc", "missing closing ]: `[abc`"), + ("(?i)[[:alpha:]", "missing closing ]: `[[:alpha:]`"), + ("(?i)*x", "missing argument to repetition operator: `*`"), + ("(?i)a|+", "missing argument to repetition operator: `+`"), + ("(?i)a**", "invalid nested repetition operator: `**`"), + ("(?i)a*??", "invalid nested repetition operator: `*??`"), + ("(?i)\\q", "invalid escape sequence: `\\q`"), + ("(?i)\\1", "invalid escape sequence: `\\1`"), + ("(?i)a\\", "trailing backslash at end of expression: ``"), + ] { + assert_eq!( + go_regexp_error(expr), + format!("error parsing regexp: {want}"), + "{expr}" + ); + } +} + +#[test] +fn reponses_de_crawl4ai() { + let md = |raw: &str, pr: bool| crawl_markdown(raw.as_bytes(), pr); + assert_eq!( + md( + r#"{"results":[{"success":true,"markdown":{"raw_markdown":"r","fit_markdown":"f"}}]}"#, + true + ), + Ok("r".into()) + ); + assert_eq!( + md( + r#"{"results":[{"success":true,"markdown":{"raw_markdown":"","fit_markdown":"f"}}]}"#, + true + ), + Ok("f".into()) + ); + assert_eq!( + md( + r#"{"results":[{"success":true,"markdown":{"raw_markdown":"r"}}]}"#, + false + ), + Ok("r".into()) + ); + assert_eq!( + md(r#"{"Results":[{"Success":true,"Markdown":"x"}]}"#, false), + Ok("x".into()) + ); + assert_eq!(md("null", false), Err("Crawl4AI : échec du crawl".into())); + assert_eq!(md("5", false), Err("Crawl4AI : réponse vide".into())); + assert_eq!( + md(r#"{"results":[{"success":"x"}],"success":"y"}"#, false), + Err("Crawl4AI : échec du crawl".into()) + ); + assert_eq!( + md(r#"{"results":[null,{"success":true}]}"#, false), + Err("Crawl4AI : échec du crawl".into()) + ); + assert_eq!( + md(r#"{"results":[{"success":true,"markdown":null}]}"#, false), + Ok(String::new()) + ); +} + +#[test] +fn lignes_normalisees() { + assert_eq!( + normalize_lines("\n\n a \r\nb \n\n\n\nb\nb\nc\t\n\n"), + [" a", "b", "", "b", "c"] + ); + assert!(normalize_lines(" \n\t\n").is_empty()); +} diff --git a/rust/loki-rs/tests/common/fakego.rs b/rust/loki-rs/tests/common/fakego.rs index 4a37412..a97ab71 100644 --- a/rust/loki-rs/tests/common/fakego.rs +++ b/rust/loki-rs/tests/common/fakego.rs @@ -149,6 +149,12 @@ pub struct FakeGo { pub projsnaps: Mutex>, pub projsnap_refresh: Mutex>, pub projsnap_refreshes: Mutex>, + /// Moteur web intégré (lot 42) : réponses de /internal/v1/web/fetch par + /// URL et de /internal/v1/web/search par « requête|limite » ; appels + /// reçus (« fetch URL », « search requête|limite »). + pub web_fetch: Mutex>, + pub web_search: Mutex>, + pub web_log: Mutex>, } impl FakeGo { @@ -505,6 +511,28 @@ async fn handle(st: Arc, req: Request) -> Response 200, serde_json::json!({ "generating": *st.generating.lock().unwrap() }).to_string(), ), + "/internal/v1/web/fetch" | "/internal/v1/web/search" => { + let fetch = path == "/internal/v1/web/fetch"; + let body = req.into_body().collect().await.unwrap().to_bytes(); + let v: serde_json::Value = serde_json::from_slice(&body).unwrap_or_default(); + let key = if fetch { + v["url"].as_str().unwrap_or("").to_string() + } else { + format!("{}|{}", v["query"].as_str().unwrap_or(""), v["limit"]) + }; + st.web_log + .lock() + .unwrap() + .push(format!("{} {key}", if fetch { "fetch" } else { "search" })); + let map = if fetch { &st.web_fetch } else { &st.web_search }; + let out = map + .lock() + .unwrap() + .get(&key) + .cloned() + .unwrap_or_else(|| serde_json::json!({ "error": format!("inconnu : {key}") })); + resp(200, out.to_string()) + } "/internal/v1/web/reach/reset" => { *st.reach_resets.lock().unwrap() += 1; resp(204, "") diff --git a/rust/loki-rs/tests/lot6.rs b/rust/loki-rs/tests/lot6.rs index 68653c3..4fb1323 100644 --- a/rust/loki-rs/tests/lot6.rs +++ b/rust/loki-rs/tests/lot6.rs @@ -671,6 +671,7 @@ async fn tour_complet_par_le_go() { mcp: None, tasks: None, memory: None, + web: Default::default(), go_version: Default::default(), }); let cfg = std::collections::HashMap::new(); @@ -2746,3 +2747,44 @@ async fn outil_memoire_par_la_facade() { .clone(); assert_eq!(tool, serde_json::json!("[ok] page 'chat' créée")); } + +/// Lot 42 : un outil web d'un tour de la façade s'exécute chez elle (cache +/// des pages compris) ; seule la conversion du moteur intégré est demandée +/// au Go, jamais /tool/exec. +#[tokio::test] +async fn outil_web_par_la_facade() { + let call = sse(&[ + serde_json::json!({"choices":[{"delta":{"tool_calls":[{"index":0,"id":"w1","type":"function","function":{"name":"web_open","arguments":"{\"url\":\"https://exemple.test/doc\"}"}}]}}]}), + serde_json::json!({"choices":[{"delta":{},"finish_reason":"tool_calls"}]}), + ]); + let (url, seen) = fake_engine(vec![call, answer("lu")]).await; + let (svc, go, _stop, _t) = service_with(Some(&url), |_| {}).await; + go.set("config", "WEB_ENGINE", "go"); + go.web_fetch.lock().unwrap().insert( + "https://exemple.test/doc".into(), + serde_json::json!({"markdown": "# Doc\n\n## Partie\ntexte"}), + ); + svc.send(serde_json::json!({"message": "lis la doc", "cid": "w"})) + .await + .unwrap(); + until_done(&svc, 1).await; + assert!(go.tool_log.lock().unwrap().is_empty(), "{:?}", go.tool_log); + assert_eq!( + *go.web_log.lock().unwrap(), + vec!["fetch https://exemple.test/doc".to_string()] + ); + let seen = seen.lock().unwrap().clone(); + let tool = seen[1].1["messages"] + .as_array() + .unwrap() + .iter() + .rfind(|m| m["role"] == "tool") + .unwrap()["content"] + .clone(); + assert_eq!( + tool, + serde_json::json!( + "# Ouvert : https://exemple.test/doc\nTotal : 4 lignes, 23 B (23 caractères)\nEn cache 10 min. Utilise web_read ou web_grep pour lire.\n\n## Plan (n° de ligne des titres)\n```\n 1 | Doc\n 3 | Partie\n```" + ) + ); +} diff --git a/rust/loki-rs/tests/parity_web/outils.json b/rust/loki-rs/tests/parity_web/outils.json new file mode 100644 index 0000000..7a66aa7 --- /dev/null +++ b/rust/loki-rs/tests/parity_web/outils.json @@ -0,0 +1,1541 @@ +{ + "crawl": { + "https://exemple.test/blanc": [ + { + "status": 200, + "body": "{\"results\":[{\"success\":true,\"markdown\":\" \\n\\t\\n\"}]}" + } + ], + "https://exemple.test/brut-absent": [ + { + "status": 200, + "body": "{\"results\":[{\"success\":true,\"markdown\":{\"fit_markdown\":\"# Fit seul\\n\\nok\"}}]}" + } + ], + "https://exemple.test/doc": [ + { + "status": 200, + "body": "{\"results\":[{\"markdown\":{\"fit_markdown\":\"\\n# Documentation \\n\\nIntro ligne 1\\nIntro ligne 1\\n\\n\\n\\n## Installation\\nLancer `loki install`.\\t\\nErreur : moteur absent\\n### Détails\\nligne a\\nligne b\\nErreur : port pris\\nligne c\\nligne d\\nligne e\\nligne f\\nErreur : disque plein\\n####### pas un titre\\n## Fin\\r\\n\",\"raw_markdown\":\"brut\"},\"success\":true}]}" + }, + { + "status": 200, + "body": "{\"results\":[{\"markdown\":\"# Version 2\\n\\nnouveau contenu\",\"success\":true}]}" + }, + { + "status": 200, + "body": "{\"results\":[{\"markdown\":\"# Avec actions\\n\\nbouton cliqué\",\"success\":true}]}" + } + ], + "https://exemple.test/echec": [ + { + "status": 200, + "body": "{\"results\":[{\"error_message\":\"net::ERR_NAME_NOT_RESOLVED xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\",\"success\":false}]}" + } + ], + "https://exemple.test/echec-muet": [ + { + "status": 200, + "body": "{\"results\":[{\"success\":false}]}" + } + ], + "https://exemple.test/illisible": [ + { + "status": 200, + "body": "pas du json" + } + ], + "https://exemple.test/longue": [ + { + "status": 200, + "body": "{\"results\":[{\"markdown\":\"0 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. \\n1 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. \\n2 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. \\n3 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. \\n4 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. \\n5 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. \\n6 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. \\n7 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. \\n8 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. \\n9 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. \\n10 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. \\n11 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. \\n12 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. \\n13 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. \\n14 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. \\n15 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. \\n16 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. \\n17 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. \\n18 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. \\n19 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. \\n20 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. \\n21 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. \\n22 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. \\n23 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. \\n24 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. \\n25 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. \\n26 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. \\n27 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. \\n28 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. \\n29 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. \\n30 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. \\n31 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. \\n32 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. \\n33 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. \\n34 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. \\n35 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. \\n36 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. \\n37 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. \\n38 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. \\n39 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. \\n40 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. \\n41 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. \\n42 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. \\n43 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. \\n44 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. \\n45 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. \\n46 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. \\n47 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. \\n48 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. \\n49 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. \\n50 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. \\n51 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. \\n52 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. \\n53 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. \\n54 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. \\n55 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. \\n56 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. \\n57 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. \\n58 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. \\n59 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. \",\"success\":true}]}" + } + ], + "https://exemple.test/mal-type": [ + { + "status": 200, + "body": "{\"results\":[{\"success\":\"oui\",\"markdown\":\"x\"}]}" + } + ], + "https://exemple.test/objet-faux": [ + { + "status": 200, + "body": "{\"results\":[{\"success\":true,\"markdown\":{\"raw_markdown\":5}}]}" + } + ], + "https://exemple.test/seul": [ + { + "status": 200, + "body": "{\"success\":true,\"markdown\":\"# Seul\\n\\nun résultat seul\"}" + } + ], + "https://exemple.test/tableau": [ + { + "status": 200, + "body": "[]" + } + ], + "https://exemple.test/vide": [ + { + "status": 200, + "body": "{\"results\":[]}" + } + ], + "https://html.duckduckgo.com/html/?q=anti-bot": [ + { + "status": 200, + "body": "{\"results\":[{\"markdown\":\"\\u003cdiv class=\\\"anomaly-modal\\\"\\u003e\",\"success\":true}]}" + } + ], + "https://html.duckduckgo.com/html/?q=loki+rust": [ + { + "status": 200, + "body": "{\"results\":[{\"markdown\":{\"fit_markdown\":\"ignoré\",\"raw_markdown\":\"# DuckDuckGo\\n\\n## [Loki \\u0026amp; **Rust**](//duckduckgo.com/l/?uddg=https%3A%2F%2Floki.example%2Frust%3Fa%3D1%26b%3D2\\u0026rut=abc)\\n\\n[loki.example/rust](//duckduckgo.com/l/?uddg=x) [![](icon.png)](x) [Un port en **Rust** \\u0026#233;tape par \\u0026#233;tape](//duckduckgo.com/l/?uddg=y)\\n## [Publicité](https://duckduckgo.com/y.js?ad=1)\\n## [Deuxième \\u0026quot;résultat\\u0026quot;](https://deux.example/page)\\n\\n\\n\\n\\n\\n[trop loin pour le résumé](x)\\n## [Loki encore](//duckduckgo.com/l/?uddg=https%3A%2F%2Floki.example%2Frust%3Fa%3D1%26b%3D2)\\n## [Troisième](https://trois.example/) \\n[!bang ignoré](x) [vraie description](y)\\n## [Quatrième](//duckduckgo.com/l/?uddg=%ZZ)\\n## [](https://vide.example/)\"},\"success\":true}]}" + } + ], + "https://html.duckduckgo.com/html/?q=panne": [ + { + "status": 500, + "body": "Internal Server Error: éééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééé" + } + ], + "https://html.duckduckgo.com/html/?q=rien": [ + { + "status": 200, + "body": "{\"results\":[{\"markdown\":\"# Rien\\n\\npas de résultat\",\"success\":true}]}" + } + ], + "https://raw.githubusercontent.com/r0m1k3/loki/HEAD/README.md": [ + { + "status": 200, + "body": "{\"results\":[{\"markdown\":\"# Loki\\n\\nLe README.\",\"success\":true}]}" + } + ] + }, + "images": { + "api en panne": { + "page_status": 200, + "page": "", + "api_status": 500, + "api": "oops" + }, + "apostrophe": { + "page_status": 200, + "page": "vqd='77-88'", + "api_status": 200, + "api": "{\"results\":[{\"image\":\"https://i/a\",\"title\":\"A\",\"url\":\"https://p/a\",\"width\":1,\"height\":2}]}" + }, + "apres": { + "page_status": 200, + "page": "", + "api_status": 200, + "api": "{\"results\":[]} x" + }, + "chats": { + "page_status": 200, + "page": "", + "cookie": "dcm=7; Path=/; HttpOnly", + "api_status": 200, + "api": "{\"results\":[{\"height\":600,\"image\":\"https://img.example/1.jpg\",\"thumbnail\":\"t\",\"title\":\"Chat roux\",\"url\":\"https://page.example/1\",\"width\":800},{\"height\":1,\"image\":\"https://img.example/1.jpg\",\"thumbnail\":\"t\",\"title\":\"Doublon\",\"url\":\"https://page.example/1b\",\"width\":1},{\"height\":1,\"image\":\"\",\"thumbnail\":\"t\",\"title\":\"Sans image\",\"url\":\"https://page.example/2\",\"width\":1},{\"height\":1,\"image\":\"data:image/png;base64,xx\",\"thumbnail\":\"t\",\"title\":\"Pas http\",\"url\":\"https://page.example/3\",\"width\":1},{\"height\":768,\"image\":\"http://img.example/4.png\",\"thumbnail\":\"t\",\"title\":\"Chat \\u0026 chien \\u003c3\",\"url\":\"https://page.example/4\",\"width\":1024},{\"height\":20,\"image\":\"https://img.example/5.jpg\",\"thumbnail\":\"t\",\"title\":\"Cinquième\",\"url\":\"https://page.example/5\",\"width\":10}]}" + }, + "illisible": { + "page_status": 200, + "page": "", + "api_status": 200, + "api": "pas du json" + }, + "largeur texte": { + "page_status": 200, + "page": "", + "api_status": 200, + "api": "{\"results\":[{\"image\":\"https://i/1\",\"width\":\"800\"}]}" + }, + "null": { + "page_status": 200, + "page": "", + "api_status": 200, + "api": "{\"results\":null,\"next\":\"i.js?x\"}" + }, + "page en panne": { + "page_status": 403, + "page": "interdit", + "api_status": 200, + "api": "{}" + }, + "resultats faux": { + "page_status": 200, + "page": "", + "api_status": 200, + "api": "{\"results\":\"non\"}" + }, + "sans jeton": { + "page_status": 200, + "page": "rien", + "api_status": 200, + "api": "{}" + }, + "vide": { + "page_status": 200, + "page": "", + "api_status": 200, + "api": "{\"results\":[]}" + } + }, + "go_fetch": { + "{{PAGES}}/absente": { + "error": "HTTP 404 sur {{PAGES}}/absente" + }, + "{{PAGES}}/article.html": { + "markdown": "# Guide de Loki\n\nLoki fait tourner un modèle de langage sur votre machine, sans rien envoyer ailleurs. Ce guide explique l'installation, la configuration et les premiers pas avec l'agent.\n\n## Installation\n\nTéléchargez le binaire depuis la [page des versions](https://loki.example/releases), puis lancez-le. Le moteur llama.cpp est installé au premier démarrage, précompilé pour votre carte graphique.\n\n## Configuration\n\nLes réglages se font dans l'interface : modèle, contexte, outils. Erreur fréquente : un port déjà pris par un autre service. Changez alors le port dans les réglages avancés.\n\nErreur moins fréquente : un disque plein empêche le téléchargement du modèle." + }, + "{{PAGES}}/texte.txt": { + "markdown": "Ligne brute 1\nLigne brute 2\n" + } + }, + "go_search": { + "bot|8": { + "error": "DuckDuckGo a renvoyé un défi anti-bot" + }, + "loki|1": { + "results": [ + { + "snippet": "Un assistant local et privé.", + "title": "Loki local", + "url": "https://loki.example/" + } + ] + }, + "loki|8": { + "results": [ + { + "snippet": "Un assistant local et privé.", + "title": "Loki local", + "url": "https://loki.example/" + }, + { + "snippet": "", + "title": "Deux", + "url": "https://deux.example/" + } + ] + } + }, + "calls": [ + { + "name": "recherche", + "setup": { + "CRAWL4AI_URL": "{{CRAWL}}", + "WEB_ENGINE": "crawl4ai", + "crawl_key": "sesame" + }, + "tool": "web_search", + "args": { + "query": "loki rust" + }, + "result": "# Recherche : loki rust\n3 résultat(s) DuckDuckGo\n\n1. Loki & Rust\n https://loki.example/rust?a=1&b=2\n Un port en Rust étape par étape\n\n2. Deuxième \"résultat\"\n https://deux.example/page\n \n\n3. Troisième\n https://trois.example/\n vraie description", + "crawls": [ + { + "path": "POST /crawl", + "auth": "Bearer sesame", + "ctype": "application/json", + "body": "{\"browser_config\":{\"params\":{\"headless\":true},\"type\":\"BrowserConfig\"},\"crawler_config\":{\"params\":{\"page_timeout\":30000},\"type\":\"CrawlerRunConfig\"},\"urls\":[\"https://html.duckduckgo.com/html/?q=loki+rust\"]}" + } + ], + "images": [], + "go_fetch": [], + "go_search": [] + }, + { + "name": "recherche limitée", + "tool": "web_search", + "args": { + "limit": 1, + "query": "loki rust" + }, + "result": "# Recherche : loki rust\n1 résultat(s) DuckDuckGo\n\n1. Loki & Rust\n https://loki.example/rust?a=1&b=2\n Un port en Rust étape par étape", + "crawls": [ + { + "path": "POST /crawl", + "auth": "Bearer sesame", + "ctype": "application/json", + "body": "{\"browser_config\":{\"params\":{\"headless\":true},\"type\":\"BrowserConfig\"},\"crawler_config\":{\"params\":{\"page_timeout\":30000},\"type\":\"CrawlerRunConfig\"},\"urls\":[\"https://html.duckduckgo.com/html/?q=loki+rust\"]}" + } + ], + "images": [], + "go_fetch": [], + "go_search": [] + }, + { + "name": "recherche, limite hors bornes", + "tool": "web_search", + "args": { + "limit": 99, + "query": "loki rust" + }, + "result": "# Recherche : loki rust\n3 résultat(s) DuckDuckGo\n\n1. Loki & Rust\n https://loki.example/rust?a=1&b=2\n Un port en Rust étape par étape\n\n2. Deuxième \"résultat\"\n https://deux.example/page\n \n\n3. Troisième\n https://trois.example/\n vraie description", + "crawls": [ + { + "path": "POST /crawl", + "auth": "Bearer sesame", + "ctype": "application/json", + "body": "{\"browser_config\":{\"params\":{\"headless\":true},\"type\":\"BrowserConfig\"},\"crawler_config\":{\"params\":{\"page_timeout\":30000},\"type\":\"CrawlerRunConfig\"},\"urls\":[\"https://html.duckduckgo.com/html/?q=loki+rust\"]}" + } + ], + "images": [], + "go_fetch": [], + "go_search": [] + }, + { + "name": "recherche, limite nulle", + "tool": "web_search", + "args": { + "limit": -3, + "query": "loki rust" + }, + "result": "# Recherche : loki rust\n1 résultat(s) DuckDuckGo\n\n1. Loki & Rust\n https://loki.example/rust?a=1&b=2\n Un port en Rust étape par étape", + "crawls": [ + { + "path": "POST /crawl", + "auth": "Bearer sesame", + "ctype": "application/json", + "body": "{\"browser_config\":{\"params\":{\"headless\":true},\"type\":\"BrowserConfig\"},\"crawler_config\":{\"params\":{\"page_timeout\":30000},\"type\":\"CrawlerRunConfig\"},\"urls\":[\"https://html.duckduckgo.com/html/?q=loki+rust\"]}" + } + ], + "images": [], + "go_fetch": [], + "go_search": [] + }, + { + "name": "recherche, limite texte", + "tool": "web_search", + "args": { + "limit": "2", + "query": "loki rust" + }, + "result": "# Recherche : loki rust\n3 résultat(s) DuckDuckGo\n\n1. Loki & Rust\n https://loki.example/rust?a=1&b=2\n Un port en Rust étape par étape\n\n2. Deuxième \"résultat\"\n https://deux.example/page\n \n\n3. Troisième\n https://trois.example/\n vraie description", + "crawls": [ + { + "path": "POST /crawl", + "auth": "Bearer sesame", + "ctype": "application/json", + "body": "{\"browser_config\":{\"params\":{\"headless\":true},\"type\":\"BrowserConfig\"},\"crawler_config\":{\"params\":{\"page_timeout\":30000},\"type\":\"CrawlerRunConfig\"},\"urls\":[\"https://html.duckduckgo.com/html/?q=loki+rust\"]}" + } + ], + "images": [], + "go_fetch": [], + "go_search": [] + }, + { + "name": "défi anti-bot", + "tool": "web_search", + "args": { + "query": "anti-bot" + }, + "result": "❌ Recherche échouée : DuckDuckGo a renvoyé un défi anti-bot", + "crawls": [ + { + "path": "POST /crawl", + "auth": "Bearer sesame", + "ctype": "application/json", + "body": "{\"browser_config\":{\"params\":{\"headless\":true},\"type\":\"BrowserConfig\"},\"crawler_config\":{\"params\":{\"page_timeout\":30000},\"type\":\"CrawlerRunConfig\"},\"urls\":[\"https://html.duckduckgo.com/html/?q=anti-bot\"]}" + } + ], + "images": [], + "go_fetch": [], + "go_search": [] + }, + { + "name": "aucun résultat", + "tool": "web_search", + "args": { + "query": "rien" + }, + "result": "Aucun résultat pour « rien »", + "crawls": [ + { + "path": "POST /crawl", + "auth": "Bearer sesame", + "ctype": "application/json", + "body": "{\"browser_config\":{\"params\":{\"headless\":true},\"type\":\"BrowserConfig\"},\"crawler_config\":{\"params\":{\"page_timeout\":30000},\"type\":\"CrawlerRunConfig\"},\"urls\":[\"https://html.duckduckgo.com/html/?q=rien\"]}" + } + ], + "images": [], + "go_fetch": [], + "go_search": [] + }, + { + "name": "Crawl4AI en panne", + "tool": "web_search", + "args": { + "query": "panne" + }, + "result": "❌ Recherche échouée : Crawl4AI HTTP 500: éééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééé", + "crawls": [ + { + "path": "POST /crawl", + "auth": "Bearer sesame", + "ctype": "application/json", + "body": "{\"browser_config\":{\"params\":{\"headless\":true},\"type\":\"BrowserConfig\"},\"crawler_config\":{\"params\":{\"page_timeout\":30000},\"type\":\"CrawlerRunConfig\"},\"urls\":[\"https://html.duckduckgo.com/html/?q=panne\"]}" + } + ], + "images": [], + "go_fetch": [], + "go_search": [] + }, + { + "name": "ouverture", + "tool": "web_open", + "args": { + "url": "https://exemple.test/doc" + }, + "result": "# Ouvert : https://exemple.test/doc\nTotal : 18 lignes, 224 B (224 caractères)\nEn cache 10 min. Utilise web_read ou web_grep pour lire.\n\n## Plan (n° de ligne des titres)\n```\n 1 | Documentation\n 5 | Installation\n 8 | Détails\n 18 | Fin\n```", + "crawls": [ + { + "path": "POST /crawl", + "auth": "Bearer sesame", + "ctype": "application/json", + "body": "{\"browser_config\":{\"params\":{\"headless\":true},\"type\":\"BrowserConfig\"},\"crawler_config\":{\"params\":{\"js_code\":[\"(() =\\u003e { try {\\n const TEXT = /^(accept all|accept|i accept|agree|i agree|got it|i understand|j'accepte|tout accepter|accepter|d'accord|allow all|allow|consent|continue|ok)$/i;\\n const isOverlayish = (el) =\\u003e {\\n try {\\n let cur = el;\\n for (let i = 0; i \\u003c 6 \\u0026\\u0026 cur; i++) {\\n const s = getComputedStyle(cur);\\n if (s.position === 'fixed' || s.position === 'sticky') return true;\\n const idcls = ((cur.id || '') + ' ' + (cur.className || '')).toLowerCase();\\n if (/cookie|consent|gdpr|cmp|privacy/.test(idcls)) return true;\\n cur = cur.parentElement;\\n }\\n } catch {}\\n return false;\\n };\\n const candidates = document.querySelectorAll('button, [role=\\\"button\\\"], input[type=\\\"button\\\"], input[type=\\\"submit\\\"]');\\n let clicked = 0;\\n for (const b of candidates) {\\n if (clicked \\u003e= 2) break;\\n const t = (b.innerText || b.value || b.getAttribute('aria-label') || '').trim();\\n if (!t || !TEXT.test(t)) continue;\\n if (b.offsetParent === null) continue;\\n if (!isOverlayish(b)) continue;\\n try { b.click(); clicked++; } catch {}\\n }\\n document.querySelectorAll('[id*=\\\"cookie\\\" i],[id*=\\\"consent\\\" i],[class*=\\\"cookie\\\" i],[class*=\\\"consent\\\" i],[id*=\\\"gdpr\\\" i],[class*=\\\"gdpr\\\" i],[id*=\\\"cmp\\\" i],[class*=\\\"cmp\\\" i]')\\n .forEach(el =\\u003e { try {\\n const s = getComputedStyle(el);\\n if (s.position === 'fixed' || s.position === 'sticky') el.remove();\\n } catch {} });\\n document.documentElement.style.overflow = 'auto';\\n if (document.body) document.body.style.overflow = 'auto';\\n} catch {} })();\"]},\"type\":\"CrawlerRunConfig\"},\"urls\":[\"https://exemple.test/doc\"]}" + } + ], + "images": [], + "go_fetch": [], + "go_search": [] + }, + { + "name": "lecture", + "tool": "web_read", + "args": { + "url": "https://exemple.test/doc" + }, + "result": "# https://exemple.test/doc\nLignes 1–18 sur 18 (fin de page)\n\n```\n 1 | # Documentation\n 2 | \n 3 | Intro ligne 1\n 4 | \n 5 | ## Installation\n 6 | Lancer `loki install`.\n 7 | Erreur : moteur absent\n 8 | ### Détails\n 9 | ligne a\n 10 | ligne b\n 11 | Erreur : port pris\n 12 | ligne c\n 13 | ligne d\n 14 | ligne e\n 15 | ligne f\n 16 | Erreur : disque plein\n 17 | ####### pas un titre\n 18 | ## Fin\n```", + "crawls": [], + "images": [], + "go_fetch": [], + "go_search": [] + }, + { + "name": "lecture d'une plage", + "tool": "web_read", + "args": { + "limit": 3, + "offset": 5, + "url": "https://exemple.test/doc" + }, + "result": "# https://exemple.test/doc\nLignes 5–7 sur 18 (11 de plus en dessous)\n\n```\n 5 | ## Installation\n 6 | Lancer `loki install`.\n 7 | Erreur : moteur absent\n```", + "crawls": [], + "images": [], + "go_fetch": [], + "go_search": [] + }, + { + "name": "lecture au-delà", + "tool": "web_read", + "args": { + "offset": 400, + "url": "https://exemple.test/doc" + }, + "result": "# https://exemple.test/doc\nLignes 400–18 sur 18 (fin de page)\n\n```\n\n```", + "crawls": [], + "images": [], + "go_fetch": [], + "go_search": [] + }, + { + "name": "lecture, bornes", + "tool": "web_read", + "args": { + "limit": 0, + "offset": -2, + "url": "https://exemple.test/doc" + }, + "result": "# https://exemple.test/doc\nLignes 1–1 sur 18 (17 de plus en dessous)\n\n```\n 1 | # Documentation\n```", + "crawls": [], + "images": [], + "go_fetch": [], + "go_search": [] + }, + { + "name": "grep", + "tool": "web_grep", + "args": { + "pattern": "erreur", + "url": "https://exemple.test/doc" + }, + "result": "# https://exemple.test/doc\n3 match(es) pour /erreur/i\n\n```\n 5 | ## Installation\n 6 | Lancer `loki install`.\n 7 | Erreur : moteur absent\n 8 | ### Détails\n 9 | ligne a\n 10 | ligne b\n 11 | Erreur : port pris\n 12 | ligne c\n 13 | ligne d\n 14 | ligne e\n 15 | ligne f\n 16 | Erreur : disque plein\n 17 | ####### pas un titre\n 18 | ## Fin\n```", + "crawls": [], + "images": [], + "go_fetch": [], + "go_search": [] + }, + { + "name": "grep sans contexte", + "tool": "web_grep", + "args": { + "context": 0, + "pattern": "^ligne [ae]$", + "url": "https://exemple.test/doc" + }, + "result": "# https://exemple.test/doc\n2 match(es) pour /^ligne [ae]$/i\n\n```\n 9 | ligne a\n```\n\n---\n\n```\n 14 | ligne e\n```", + "crawls": [], + "images": [], + "go_fetch": [], + "go_search": [] + }, + { + "name": "grep plafonné", + "tool": "web_grep", + "args": { + "context": 1, + "max_matches": 2, + "pattern": "ligne", + "url": "https://exemple.test/doc" + }, + "result": "# https://exemple.test/doc\n2 match(es) pour /ligne/i (plafonné à 2)\n\n```\n 2 | \n 3 | Intro ligne 1\n 4 | \n```\n\n---\n\n```\n 8 | ### Détails\n 9 | ligne a\n 10 | ligne b\n```", + "crawls": [], + "images": [], + "go_fetch": [], + "go_search": [] + }, + { + "name": "grep, contexte large", + "tool": "web_grep", + "args": { + "context": 50, + "pattern": "Détails", + "url": "https://exemple.test/doc" + }, + "result": "# https://exemple.test/doc\n1 match(es) pour /Détails/i\n\n```\n 1 | # Documentation\n 2 | \n 3 | Intro ligne 1\n 4 | \n 5 | ## Installation\n 6 | Lancer `loki install`.\n 7 | Erreur : moteur absent\n 8 | ### Détails\n 9 | ligne a\n 10 | ligne b\n 11 | Erreur : port pris\n 12 | ligne c\n 13 | ligne d\n 14 | ligne e\n 15 | ligne f\n 16 | Erreur : disque plein\n 17 | ####### pas un titre\n 18 | ## Fin\n```", + "crawls": [], + "images": [], + "go_fetch": [], + "go_search": [] + }, + { + "name": "grep sans résultat", + "tool": "web_grep", + "args": { + "pattern": "introuvable", + "url": "https://exemple.test/doc" + }, + "result": "# https://exemple.test/doc\nAucun match pour /introuvable/i", + "crawls": [], + "images": [], + "go_fetch": [], + "go_search": [] + }, + { + "name": "grep, ascii", + "tool": "web_grep", + "args": { + "pattern": "\\bD\\w+ils", + "url": "https://exemple.test/doc" + }, + "result": "# https://exemple.test/doc\nAucun match pour /\\bD\\w+ils/i", + "crawls": [], + "images": [], + "go_fetch": [], + "go_search": [] + }, + { + "name": "regex invalide 1", + "tool": "web_grep", + "args": { + "pattern": "(", + "url": "https://exemple.test/doc" + }, + "result": "❌ Regex invalide : error parsing regexp: missing closing ): `(?i)(`", + "crawls": [], + "images": [], + "go_fetch": [], + "go_search": [] + }, + { + "name": "regex invalide 2", + "tool": "web_grep", + "args": { + "pattern": "a)", + "url": "https://exemple.test/doc" + }, + "result": "❌ Regex invalide : error parsing regexp: unexpected ): `(?i)a)`", + "crawls": [], + "images": [], + "go_fetch": [], + "go_search": [] + }, + { + "name": "regex invalide 3", + "tool": "web_grep", + "args": { + "pattern": "[abc", + "url": "https://exemple.test/doc" + }, + "result": "❌ Regex invalide : error parsing regexp: missing closing ]: `[abc`", + "crawls": [], + "images": [], + "go_fetch": [], + "go_search": [] + }, + { + "name": "regex invalide 4", + "tool": "web_grep", + "args": { + "pattern": "*x", + "url": "https://exemple.test/doc" + }, + "result": "❌ Regex invalide : error parsing regexp: missing argument to repetition operator: `*`", + "crawls": [], + "images": [], + "go_fetch": [], + "go_search": [] + }, + { + "name": "regex invalide 5", + "tool": "web_grep", + "args": { + "pattern": "a**", + "url": "https://exemple.test/doc" + }, + "result": "❌ Regex invalide : error parsing regexp: invalid nested repetition operator: `**`", + "crawls": [], + "images": [], + "go_fetch": [], + "go_search": [] + }, + { + "name": "regex invalide 6", + "tool": "web_grep", + "args": { + "pattern": "\\q", + "url": "https://exemple.test/doc" + }, + "result": "❌ Regex invalide : error parsing regexp: invalid escape sequence: `\\q`", + "crawls": [], + "images": [], + "go_fetch": [], + "go_search": [] + }, + { + "name": "regex invalide 7", + "tool": "web_grep", + "args": { + "pattern": "a\\", + "url": "https://exemple.test/doc" + }, + "result": "❌ Regex invalide : error parsing regexp: trailing backslash at end of expression: ``", + "crawls": [], + "images": [], + "go_fetch": [], + "go_search": [] + }, + { + "name": "page jamais ouverte", + "tool": "web_read", + "args": { + "url": "https://exemple.test/autre" + }, + "result": "❌ Page absente du cache. Appelle d'abord web_open(\"https://exemple.test/autre\").", + "crawls": [], + "images": [], + "go_fetch": [], + "go_search": [] + }, + { + "name": "grep, page jamais ouverte", + "tool": "web_grep", + "args": { + "pattern": "x", + "url": "https://exemple.test/autre" + }, + "result": "❌ Page absente du cache. Appelle d'abord web_open(\"https://exemple.test/autre\").", + "crawls": [], + "images": [], + "go_fetch": [], + "go_search": [] + }, + { + "name": "réouverture : cache", + "tool": "web_open", + "args": { + "url": "https://exemple.test/doc" + }, + "result": "# Ouvert : https://exemple.test/doc\nTotal : 18 lignes, 224 B (224 caractères)\nEn cache 10 min. Utilise web_read ou web_grep pour lire.\n\n## Plan (n° de ligne des titres)\n```\n 1 | Documentation\n 5 | Installation\n 8 | Détails\n 18 | Fin\n```", + "crawls": [], + "images": [], + "go_fetch": [], + "go_search": [] + }, + { + "name": "réouverture forcée", + "tool": "web_open", + "args": { + "refresh": true, + "url": "https://exemple.test/doc" + }, + "result": "# Ouvert : https://exemple.test/doc\nTotal : 3 lignes, 29 B (29 caractères)\nEn cache 10 min. Utilise web_read ou web_grep pour lire.\n\n## Plan (n° de ligne des titres)\n```\n 1 | Version 2\n```", + "crawls": [ + { + "path": "POST /crawl", + "auth": "Bearer sesame", + "ctype": "application/json", + "body": "{\"browser_config\":{\"params\":{\"headless\":true},\"type\":\"BrowserConfig\"},\"crawler_config\":{\"params\":{\"js_code\":[\"(() =\\u003e { try {\\n const TEXT = /^(accept all|accept|i accept|agree|i agree|got it|i understand|j'accepte|tout accepter|accepter|d'accord|allow all|allow|consent|continue|ok)$/i;\\n const isOverlayish = (el) =\\u003e {\\n try {\\n let cur = el;\\n for (let i = 0; i \\u003c 6 \\u0026\\u0026 cur; i++) {\\n const s = getComputedStyle(cur);\\n if (s.position === 'fixed' || s.position === 'sticky') return true;\\n const idcls = ((cur.id || '') + ' ' + (cur.className || '')).toLowerCase();\\n if (/cookie|consent|gdpr|cmp|privacy/.test(idcls)) return true;\\n cur = cur.parentElement;\\n }\\n } catch {}\\n return false;\\n };\\n const candidates = document.querySelectorAll('button, [role=\\\"button\\\"], input[type=\\\"button\\\"], input[type=\\\"submit\\\"]');\\n let clicked = 0;\\n for (const b of candidates) {\\n if (clicked \\u003e= 2) break;\\n const t = (b.innerText || b.value || b.getAttribute('aria-label') || '').trim();\\n if (!t || !TEXT.test(t)) continue;\\n if (b.offsetParent === null) continue;\\n if (!isOverlayish(b)) continue;\\n try { b.click(); clicked++; } catch {}\\n }\\n document.querySelectorAll('[id*=\\\"cookie\\\" i],[id*=\\\"consent\\\" i],[class*=\\\"cookie\\\" i],[class*=\\\"consent\\\" i],[id*=\\\"gdpr\\\" i],[class*=\\\"gdpr\\\" i],[id*=\\\"cmp\\\" i],[class*=\\\"cmp\\\" i]')\\n .forEach(el =\\u003e { try {\\n const s = getComputedStyle(el);\\n if (s.position === 'fixed' || s.position === 'sticky') el.remove();\\n } catch {} });\\n document.documentElement.style.overflow = 'auto';\\n if (document.body) document.body.style.overflow = 'auto';\\n} catch {} })();\"]},\"type\":\"CrawlerRunConfig\"},\"urls\":[\"https://exemple.test/doc\"]}" + } + ], + "images": [], + "go_fetch": [], + "go_search": [] + }, + { + "name": "ouverture avec actions", + "advance_s": 5, + "tool": "web_open", + "args": { + "actions": [ + "document.querySelector('#plus').click();", + 3, + "x && y < z" + ], + "dismiss_popups": false, + "url": "https://exemple.test/doc", + "wait_for": "css:#fin" + }, + "result": "# Ouvert : https://exemple.test/doc\nTotal : 3 lignes, 31 B (31 caractères)\nEn cache 10 min. Utilise web_read ou web_grep pour lire.\n\n## Plan (n° de ligne des titres)\n```\n 1 | Avec actions\n```", + "crawls": [ + { + "path": "POST /crawl", + "auth": "Bearer sesame", + "ctype": "application/json", + "body": "{\"browser_config\":{\"params\":{\"headless\":true},\"type\":\"BrowserConfig\"},\"crawler_config\":{\"params\":{\"js_code\":[\"document.querySelector('#plus').click();\",\"x \\u0026\\u0026 y \\u003c z\"],\"page_timeout\":45000,\"wait_for\":\"css:#fin\"},\"type\":\"CrawlerRunConfig\"},\"urls\":[\"https://exemple.test/doc\"]}" + } + ], + "images": [], + "go_fetch": [], + "go_search": [] + }, + { + "name": "lecture : la plus récente", + "tool": "web_read", + "args": { + "url": "https://exemple.test/doc" + }, + "result": "# https://exemple.test/doc\nLignes 1–3 sur 3 (fin de page)\n\n```\n 1 | # Avec actions\n 2 | \n 3 | bouton cliqué\n```", + "crawls": [], + "images": [], + "go_fetch": [], + "go_search": [] + }, + { + "name": "attente seule", + "tool": "web_open", + "args": { + "url": "https://exemple.test/seul", + "wait_for": "js:() => true" + }, + "result": "# Ouvert : https://exemple.test/seul\nTotal : 3 lignes, 26 B (26 caractères)\nEn cache 10 min. Utilise web_read ou web_grep pour lire.\n\n## Plan (n° de ligne des titres)\n```\n 1 | Seul\n```", + "crawls": [ + { + "path": "POST /crawl", + "auth": "Bearer sesame", + "ctype": "application/json", + "body": "{\"browser_config\":{\"params\":{\"headless\":true},\"type\":\"BrowserConfig\"},\"crawler_config\":{\"params\":{\"js_code\":[\"(() =\\u003e { try {\\n const TEXT = /^(accept all|accept|i accept|agree|i agree|got it|i understand|j'accepte|tout accepter|accepter|d'accord|allow all|allow|consent|continue|ok)$/i;\\n const isOverlayish = (el) =\\u003e {\\n try {\\n let cur = el;\\n for (let i = 0; i \\u003c 6 \\u0026\\u0026 cur; i++) {\\n const s = getComputedStyle(cur);\\n if (s.position === 'fixed' || s.position === 'sticky') return true;\\n const idcls = ((cur.id || '') + ' ' + (cur.className || '')).toLowerCase();\\n if (/cookie|consent|gdpr|cmp|privacy/.test(idcls)) return true;\\n cur = cur.parentElement;\\n }\\n } catch {}\\n return false;\\n };\\n const candidates = document.querySelectorAll('button, [role=\\\"button\\\"], input[type=\\\"button\\\"], input[type=\\\"submit\\\"]');\\n let clicked = 0;\\n for (const b of candidates) {\\n if (clicked \\u003e= 2) break;\\n const t = (b.innerText || b.value || b.getAttribute('aria-label') || '').trim();\\n if (!t || !TEXT.test(t)) continue;\\n if (b.offsetParent === null) continue;\\n if (!isOverlayish(b)) continue;\\n try { b.click(); clicked++; } catch {}\\n }\\n document.querySelectorAll('[id*=\\\"cookie\\\" i],[id*=\\\"consent\\\" i],[class*=\\\"cookie\\\" i],[class*=\\\"consent\\\" i],[id*=\\\"gdpr\\\" i],[class*=\\\"gdpr\\\" i],[id*=\\\"cmp\\\" i],[class*=\\\"cmp\\\" i]')\\n .forEach(el =\\u003e { try {\\n const s = getComputedStyle(el);\\n if (s.position === 'fixed' || s.position === 'sticky') el.remove();\\n } catch {} });\\n document.documentElement.style.overflow = 'auto';\\n if (document.body) document.body.style.overflow = 'auto';\\n} catch {} })();\"],\"page_timeout\":45000,\"wait_for\":\"js:() =\\u003e true\"},\"type\":\"CrawlerRunConfig\"},\"urls\":[\"https://exemple.test/seul\"]}" + } + ], + "images": [], + "go_fetch": [], + "go_search": [] + }, + { + "name": "sans fermer les bandeaux", + "tool": "web_open", + "args": { + "dismiss_popups": false, + "url": "https://exemple.test/brut-absent" + }, + "result": "# Ouvert : https://exemple.test/brut-absent\nTotal : 3 lignes, 15 B (15 caractères)\nEn cache 10 min. Utilise web_read ou web_grep pour lire.\n\n## Plan (n° de ligne des titres)\n```\n 1 | Fit seul\n```", + "crawls": [ + { + "path": "POST /crawl", + "auth": "Bearer sesame", + "ctype": "application/json", + "body": "{\"browser_config\":{\"params\":{\"headless\":true},\"type\":\"BrowserConfig\"},\"crawler_config\":{\"params\":{},\"type\":\"CrawlerRunConfig\"},\"urls\":[\"https://exemple.test/brut-absent\"]}" + } + ], + "images": [], + "go_fetch": [], + "go_search": [] + }, + { + "name": "cache périmé", + "advance_s": 601, + "tool": "web_read", + "args": { + "url": "https://exemple.test/doc" + }, + "result": "❌ Page absente du cache. Appelle d'abord web_open(\"https://exemple.test/doc\").", + "crawls": [], + "images": [], + "go_fetch": [], + "go_search": [] + }, + { + "name": "ouverture après péremption", + "tool": "web_open", + "args": { + "url": "https://exemple.test/doc" + }, + "result": "# Ouvert : https://exemple.test/doc\nTotal : 3 lignes, 31 B (31 caractères)\nEn cache 10 min. Utilise web_read ou web_grep pour lire.\n\n## Plan (n° de ligne des titres)\n```\n 1 | Avec actions\n```", + "crawls": [ + { + "path": "POST /crawl", + "auth": "Bearer sesame", + "ctype": "application/json", + "body": "{\"browser_config\":{\"params\":{\"headless\":true},\"type\":\"BrowserConfig\"},\"crawler_config\":{\"params\":{\"js_code\":[\"(() =\\u003e { try {\\n const TEXT = /^(accept all|accept|i accept|agree|i agree|got it|i understand|j'accepte|tout accepter|accepter|d'accord|allow all|allow|consent|continue|ok)$/i;\\n const isOverlayish = (el) =\\u003e {\\n try {\\n let cur = el;\\n for (let i = 0; i \\u003c 6 \\u0026\\u0026 cur; i++) {\\n const s = getComputedStyle(cur);\\n if (s.position === 'fixed' || s.position === 'sticky') return true;\\n const idcls = ((cur.id || '') + ' ' + (cur.className || '')).toLowerCase();\\n if (/cookie|consent|gdpr|cmp|privacy/.test(idcls)) return true;\\n cur = cur.parentElement;\\n }\\n } catch {}\\n return false;\\n };\\n const candidates = document.querySelectorAll('button, [role=\\\"button\\\"], input[type=\\\"button\\\"], input[type=\\\"submit\\\"]');\\n let clicked = 0;\\n for (const b of candidates) {\\n if (clicked \\u003e= 2) break;\\n const t = (b.innerText || b.value || b.getAttribute('aria-label') || '').trim();\\n if (!t || !TEXT.test(t)) continue;\\n if (b.offsetParent === null) continue;\\n if (!isOverlayish(b)) continue;\\n try { b.click(); clicked++; } catch {}\\n }\\n document.querySelectorAll('[id*=\\\"cookie\\\" i],[id*=\\\"consent\\\" i],[class*=\\\"cookie\\\" i],[class*=\\\"consent\\\" i],[id*=\\\"gdpr\\\" i],[class*=\\\"gdpr\\\" i],[id*=\\\"cmp\\\" i],[class*=\\\"cmp\\\" i]')\\n .forEach(el =\\u003e { try {\\n const s = getComputedStyle(el);\\n if (s.position === 'fixed' || s.position === 'sticky') el.remove();\\n } catch {} });\\n document.documentElement.style.overflow = 'auto';\\n if (document.body) document.body.style.overflow = 'auto';\\n} catch {} })();\"]},\"type\":\"CrawlerRunConfig\"},\"urls\":[\"https://exemple.test/doc\"]}" + } + ], + "images": [], + "go_fetch": [], + "go_search": [] + }, + { + "name": "dépôt GitHub", + "tool": "web_open", + "args": { + "url": "https://github.com/r0m1k3/loki/" + }, + "result": "# Ouvert : https://raw.githubusercontent.com/r0m1k3/loki/HEAD/README.md\nTotal : 3 lignes, 19 B (19 caractères)\nEn cache 10 min. Utilise web_read ou web_grep pour lire.\n\n## Plan (n° de ligne des titres)\n```\n 1 | Loki\n```", + "crawls": [ + { + "path": "POST /crawl", + "auth": "Bearer sesame", + "ctype": "application/json", + "body": "{\"browser_config\":{\"params\":{\"headless\":true},\"type\":\"BrowserConfig\"},\"crawler_config\":{\"params\":{\"js_code\":[\"(() =\\u003e { try {\\n const TEXT = /^(accept all|accept|i accept|agree|i agree|got it|i understand|j'accepte|tout accepter|accepter|d'accord|allow all|allow|consent|continue|ok)$/i;\\n const isOverlayish = (el) =\\u003e {\\n try {\\n let cur = el;\\n for (let i = 0; i \\u003c 6 \\u0026\\u0026 cur; i++) {\\n const s = getComputedStyle(cur);\\n if (s.position === 'fixed' || s.position === 'sticky') return true;\\n const idcls = ((cur.id || '') + ' ' + (cur.className || '')).toLowerCase();\\n if (/cookie|consent|gdpr|cmp|privacy/.test(idcls)) return true;\\n cur = cur.parentElement;\\n }\\n } catch {}\\n return false;\\n };\\n const candidates = document.querySelectorAll('button, [role=\\\"button\\\"], input[type=\\\"button\\\"], input[type=\\\"submit\\\"]');\\n let clicked = 0;\\n for (const b of candidates) {\\n if (clicked \\u003e= 2) break;\\n const t = (b.innerText || b.value || b.getAttribute('aria-label') || '').trim();\\n if (!t || !TEXT.test(t)) continue;\\n if (b.offsetParent === null) continue;\\n if (!isOverlayish(b)) continue;\\n try { b.click(); clicked++; } catch {}\\n }\\n document.querySelectorAll('[id*=\\\"cookie\\\" i],[id*=\\\"consent\\\" i],[class*=\\\"cookie\\\" i],[class*=\\\"consent\\\" i],[id*=\\\"gdpr\\\" i],[class*=\\\"gdpr\\\" i],[id*=\\\"cmp\\\" i],[class*=\\\"cmp\\\" i]')\\n .forEach(el =\\u003e { try {\\n const s = getComputedStyle(el);\\n if (s.position === 'fixed' || s.position === 'sticky') el.remove();\\n } catch {} });\\n document.documentElement.style.overflow = 'auto';\\n if (document.body) document.body.style.overflow = 'auto';\\n} catch {} })();\"]},\"type\":\"CrawlerRunConfig\"},\"urls\":[\"https://raw.githubusercontent.com/r0m1k3/loki/HEAD/README.md\"]}" + } + ], + "images": [], + "go_fetch": [], + "go_search": [] + }, + { + "name": "lecture du dépôt", + "tool": "web_read", + "args": { + "url": "https://github.com/r0m1k3/loki" + }, + "result": "# https://raw.githubusercontent.com/r0m1k3/loki/HEAD/README.md\nLignes 1–3 sur 3 (fin de page)\n\n```\n 1 | # Loki\n 2 | \n 3 | Le README.\n```", + "crawls": [], + "images": [], + "go_fetch": [], + "go_search": [] + }, + { + "name": "GitHub, pas un dépôt", + "tool": "web_open", + "args": { + "url": "https://github.com/r0m1k3" + }, + "result": "❌ Crawl4AI HTTP 404: inconnu : https://github.com/r0m1k3", + "crawls": [ + { + "path": "POST /crawl", + "auth": "Bearer sesame", + "ctype": "application/json", + "body": "{\"browser_config\":{\"params\":{\"headless\":true},\"type\":\"BrowserConfig\"},\"crawler_config\":{\"params\":{\"js_code\":[\"(() =\\u003e { try {\\n const TEXT = /^(accept all|accept|i accept|agree|i agree|got it|i understand|j'accepte|tout accepter|accepter|d'accord|allow all|allow|consent|continue|ok)$/i;\\n const isOverlayish = (el) =\\u003e {\\n try {\\n let cur = el;\\n for (let i = 0; i \\u003c 6 \\u0026\\u0026 cur; i++) {\\n const s = getComputedStyle(cur);\\n if (s.position === 'fixed' || s.position === 'sticky') return true;\\n const idcls = ((cur.id || '') + ' ' + (cur.className || '')).toLowerCase();\\n if (/cookie|consent|gdpr|cmp|privacy/.test(idcls)) return true;\\n cur = cur.parentElement;\\n }\\n } catch {}\\n return false;\\n };\\n const candidates = document.querySelectorAll('button, [role=\\\"button\\\"], input[type=\\\"button\\\"], input[type=\\\"submit\\\"]');\\n let clicked = 0;\\n for (const b of candidates) {\\n if (clicked \\u003e= 2) break;\\n const t = (b.innerText || b.value || b.getAttribute('aria-label') || '').trim();\\n if (!t || !TEXT.test(t)) continue;\\n if (b.offsetParent === null) continue;\\n if (!isOverlayish(b)) continue;\\n try { b.click(); clicked++; } catch {}\\n }\\n document.querySelectorAll('[id*=\\\"cookie\\\" i],[id*=\\\"consent\\\" i],[class*=\\\"cookie\\\" i],[class*=\\\"consent\\\" i],[id*=\\\"gdpr\\\" i],[class*=\\\"gdpr\\\" i],[id*=\\\"cmp\\\" i],[class*=\\\"cmp\\\" i]')\\n .forEach(el =\\u003e { try {\\n const s = getComputedStyle(el);\\n if (s.position === 'fixed' || s.position === 'sticky') el.remove();\\n } catch {} });\\n document.documentElement.style.overflow = 'auto';\\n if (document.body) document.body.style.overflow = 'auto';\\n} catch {} })();\"]},\"type\":\"CrawlerRunConfig\"},\"urls\":[\"https://github.com/r0m1k3\"]}" + } + ], + "images": [], + "go_fetch": [], + "go_search": [] + }, + { + "name": "crawl en échec", + "tool": "web_open", + "args": { + "url": "https://exemple.test/echec" + }, + "result": "❌ Crawl4AI : xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "crawls": [ + { + "path": "POST /crawl", + "auth": "Bearer sesame", + "ctype": "application/json", + "body": "{\"browser_config\":{\"params\":{\"headless\":true},\"type\":\"BrowserConfig\"},\"crawler_config\":{\"params\":{\"js_code\":[\"(() =\\u003e { try {\\n const TEXT = /^(accept all|accept|i accept|agree|i agree|got it|i understand|j'accepte|tout accepter|accepter|d'accord|allow all|allow|consent|continue|ok)$/i;\\n const isOverlayish = (el) =\\u003e {\\n try {\\n let cur = el;\\n for (let i = 0; i \\u003c 6 \\u0026\\u0026 cur; i++) {\\n const s = getComputedStyle(cur);\\n if (s.position === 'fixed' || s.position === 'sticky') return true;\\n const idcls = ((cur.id || '') + ' ' + (cur.className || '')).toLowerCase();\\n if (/cookie|consent|gdpr|cmp|privacy/.test(idcls)) return true;\\n cur = cur.parentElement;\\n }\\n } catch {}\\n return false;\\n };\\n const candidates = document.querySelectorAll('button, [role=\\\"button\\\"], input[type=\\\"button\\\"], input[type=\\\"submit\\\"]');\\n let clicked = 0;\\n for (const b of candidates) {\\n if (clicked \\u003e= 2) break;\\n const t = (b.innerText || b.value || b.getAttribute('aria-label') || '').trim();\\n if (!t || !TEXT.test(t)) continue;\\n if (b.offsetParent === null) continue;\\n if (!isOverlayish(b)) continue;\\n try { b.click(); clicked++; } catch {}\\n }\\n document.querySelectorAll('[id*=\\\"cookie\\\" i],[id*=\\\"consent\\\" i],[class*=\\\"cookie\\\" i],[class*=\\\"consent\\\" i],[id*=\\\"gdpr\\\" i],[class*=\\\"gdpr\\\" i],[id*=\\\"cmp\\\" i],[class*=\\\"cmp\\\" i]')\\n .forEach(el =\\u003e { try {\\n const s = getComputedStyle(el);\\n if (s.position === 'fixed' || s.position === 'sticky') el.remove();\\n } catch {} });\\n document.documentElement.style.overflow = 'auto';\\n if (document.body) document.body.style.overflow = 'auto';\\n} catch {} })();\"]},\"type\":\"CrawlerRunConfig\"},\"urls\":[\"https://exemple.test/echec\"]}" + } + ], + "images": [], + "go_fetch": [], + "go_search": [] + }, + { + "name": "crawl en échec, sans message", + "tool": "web_open", + "args": { + "url": "https://exemple.test/echec-muet" + }, + "result": "❌ Crawl4AI : échec du crawl", + "crawls": [ + { + "path": "POST /crawl", + "auth": "Bearer sesame", + "ctype": "application/json", + "body": "{\"browser_config\":{\"params\":{\"headless\":true},\"type\":\"BrowserConfig\"},\"crawler_config\":{\"params\":{\"js_code\":[\"(() =\\u003e { try {\\n const TEXT = /^(accept all|accept|i accept|agree|i agree|got it|i understand|j'accepte|tout accepter|accepter|d'accord|allow all|allow|consent|continue|ok)$/i;\\n const isOverlayish = (el) =\\u003e {\\n try {\\n let cur = el;\\n for (let i = 0; i \\u003c 6 \\u0026\\u0026 cur; i++) {\\n const s = getComputedStyle(cur);\\n if (s.position === 'fixed' || s.position === 'sticky') return true;\\n const idcls = ((cur.id || '') + ' ' + (cur.className || '')).toLowerCase();\\n if (/cookie|consent|gdpr|cmp|privacy/.test(idcls)) return true;\\n cur = cur.parentElement;\\n }\\n } catch {}\\n return false;\\n };\\n const candidates = document.querySelectorAll('button, [role=\\\"button\\\"], input[type=\\\"button\\\"], input[type=\\\"submit\\\"]');\\n let clicked = 0;\\n for (const b of candidates) {\\n if (clicked \\u003e= 2) break;\\n const t = (b.innerText || b.value || b.getAttribute('aria-label') || '').trim();\\n if (!t || !TEXT.test(t)) continue;\\n if (b.offsetParent === null) continue;\\n if (!isOverlayish(b)) continue;\\n try { b.click(); clicked++; } catch {}\\n }\\n document.querySelectorAll('[id*=\\\"cookie\\\" i],[id*=\\\"consent\\\" i],[class*=\\\"cookie\\\" i],[class*=\\\"consent\\\" i],[id*=\\\"gdpr\\\" i],[class*=\\\"gdpr\\\" i],[id*=\\\"cmp\\\" i],[class*=\\\"cmp\\\" i]')\\n .forEach(el =\\u003e { try {\\n const s = getComputedStyle(el);\\n if (s.position === 'fixed' || s.position === 'sticky') el.remove();\\n } catch {} });\\n document.documentElement.style.overflow = 'auto';\\n if (document.body) document.body.style.overflow = 'auto';\\n} catch {} })();\"]},\"type\":\"CrawlerRunConfig\"},\"urls\":[\"https://exemple.test/echec-muet\"]}" + } + ], + "images": [], + "go_fetch": [], + "go_search": [] + }, + { + "name": "réponse vide", + "tool": "web_open", + "args": { + "url": "https://exemple.test/vide" + }, + "result": "❌ Crawl4AI : échec du crawl", + "crawls": [ + { + "path": "POST /crawl", + "auth": "Bearer sesame", + "ctype": "application/json", + "body": "{\"browser_config\":{\"params\":{\"headless\":true},\"type\":\"BrowserConfig\"},\"crawler_config\":{\"params\":{\"js_code\":[\"(() =\\u003e { try {\\n const TEXT = /^(accept all|accept|i accept|agree|i agree|got it|i understand|j'accepte|tout accepter|accepter|d'accord|allow all|allow|consent|continue|ok)$/i;\\n const isOverlayish = (el) =\\u003e {\\n try {\\n let cur = el;\\n for (let i = 0; i \\u003c 6 \\u0026\\u0026 cur; i++) {\\n const s = getComputedStyle(cur);\\n if (s.position === 'fixed' || s.position === 'sticky') return true;\\n const idcls = ((cur.id || '') + ' ' + (cur.className || '')).toLowerCase();\\n if (/cookie|consent|gdpr|cmp|privacy/.test(idcls)) return true;\\n cur = cur.parentElement;\\n }\\n } catch {}\\n return false;\\n };\\n const candidates = document.querySelectorAll('button, [role=\\\"button\\\"], input[type=\\\"button\\\"], input[type=\\\"submit\\\"]');\\n let clicked = 0;\\n for (const b of candidates) {\\n if (clicked \\u003e= 2) break;\\n const t = (b.innerText || b.value || b.getAttribute('aria-label') || '').trim();\\n if (!t || !TEXT.test(t)) continue;\\n if (b.offsetParent === null) continue;\\n if (!isOverlayish(b)) continue;\\n try { b.click(); clicked++; } catch {}\\n }\\n document.querySelectorAll('[id*=\\\"cookie\\\" i],[id*=\\\"consent\\\" i],[class*=\\\"cookie\\\" i],[class*=\\\"consent\\\" i],[id*=\\\"gdpr\\\" i],[class*=\\\"gdpr\\\" i],[id*=\\\"cmp\\\" i],[class*=\\\"cmp\\\" i]')\\n .forEach(el =\\u003e { try {\\n const s = getComputedStyle(el);\\n if (s.position === 'fixed' || s.position === 'sticky') el.remove();\\n } catch {} });\\n document.documentElement.style.overflow = 'auto';\\n if (document.body) document.body.style.overflow = 'auto';\\n} catch {} })();\"]},\"type\":\"CrawlerRunConfig\"},\"urls\":[\"https://exemple.test/vide\"]}" + } + ], + "images": [], + "go_fetch": [], + "go_search": [] + }, + { + "name": "réponse tableau", + "tool": "web_open", + "args": { + "url": "https://exemple.test/tableau" + }, + "result": "❌ Crawl4AI : réponse vide", + "crawls": [ + { + "path": "POST /crawl", + "auth": "Bearer sesame", + "ctype": "application/json", + "body": "{\"browser_config\":{\"params\":{\"headless\":true},\"type\":\"BrowserConfig\"},\"crawler_config\":{\"params\":{\"js_code\":[\"(() =\\u003e { try {\\n const TEXT = /^(accept all|accept|i accept|agree|i agree|got it|i understand|j'accepte|tout accepter|accepter|d'accord|allow all|allow|consent|continue|ok)$/i;\\n const isOverlayish = (el) =\\u003e {\\n try {\\n let cur = el;\\n for (let i = 0; i \\u003c 6 \\u0026\\u0026 cur; i++) {\\n const s = getComputedStyle(cur);\\n if (s.position === 'fixed' || s.position === 'sticky') return true;\\n const idcls = ((cur.id || '') + ' ' + (cur.className || '')).toLowerCase();\\n if (/cookie|consent|gdpr|cmp|privacy/.test(idcls)) return true;\\n cur = cur.parentElement;\\n }\\n } catch {}\\n return false;\\n };\\n const candidates = document.querySelectorAll('button, [role=\\\"button\\\"], input[type=\\\"button\\\"], input[type=\\\"submit\\\"]');\\n let clicked = 0;\\n for (const b of candidates) {\\n if (clicked \\u003e= 2) break;\\n const t = (b.innerText || b.value || b.getAttribute('aria-label') || '').trim();\\n if (!t || !TEXT.test(t)) continue;\\n if (b.offsetParent === null) continue;\\n if (!isOverlayish(b)) continue;\\n try { b.click(); clicked++; } catch {}\\n }\\n document.querySelectorAll('[id*=\\\"cookie\\\" i],[id*=\\\"consent\\\" i],[class*=\\\"cookie\\\" i],[class*=\\\"consent\\\" i],[id*=\\\"gdpr\\\" i],[class*=\\\"gdpr\\\" i],[id*=\\\"cmp\\\" i],[class*=\\\"cmp\\\" i]')\\n .forEach(el =\\u003e { try {\\n const s = getComputedStyle(el);\\n if (s.position === 'fixed' || s.position === 'sticky') el.remove();\\n } catch {} });\\n document.documentElement.style.overflow = 'auto';\\n if (document.body) document.body.style.overflow = 'auto';\\n} catch {} })();\"]},\"type\":\"CrawlerRunConfig\"},\"urls\":[\"https://exemple.test/tableau\"]}" + } + ], + "images": [], + "go_fetch": [], + "go_search": [] + }, + { + "name": "réponse illisible", + "tool": "web_open", + "args": { + "url": "https://exemple.test/illisible" + }, + "result": "❌ Crawl4AI : réponse vide", + "crawls": [ + { + "path": "POST /crawl", + "auth": "Bearer sesame", + "ctype": "application/json", + "body": "{\"browser_config\":{\"params\":{\"headless\":true},\"type\":\"BrowserConfig\"},\"crawler_config\":{\"params\":{\"js_code\":[\"(() =\\u003e { try {\\n const TEXT = /^(accept all|accept|i accept|agree|i agree|got it|i understand|j'accepte|tout accepter|accepter|d'accord|allow all|allow|consent|continue|ok)$/i;\\n const isOverlayish = (el) =\\u003e {\\n try {\\n let cur = el;\\n for (let i = 0; i \\u003c 6 \\u0026\\u0026 cur; i++) {\\n const s = getComputedStyle(cur);\\n if (s.position === 'fixed' || s.position === 'sticky') return true;\\n const idcls = ((cur.id || '') + ' ' + (cur.className || '')).toLowerCase();\\n if (/cookie|consent|gdpr|cmp|privacy/.test(idcls)) return true;\\n cur = cur.parentElement;\\n }\\n } catch {}\\n return false;\\n };\\n const candidates = document.querySelectorAll('button, [role=\\\"button\\\"], input[type=\\\"button\\\"], input[type=\\\"submit\\\"]');\\n let clicked = 0;\\n for (const b of candidates) {\\n if (clicked \\u003e= 2) break;\\n const t = (b.innerText || b.value || b.getAttribute('aria-label') || '').trim();\\n if (!t || !TEXT.test(t)) continue;\\n if (b.offsetParent === null) continue;\\n if (!isOverlayish(b)) continue;\\n try { b.click(); clicked++; } catch {}\\n }\\n document.querySelectorAll('[id*=\\\"cookie\\\" i],[id*=\\\"consent\\\" i],[class*=\\\"cookie\\\" i],[class*=\\\"consent\\\" i],[id*=\\\"gdpr\\\" i],[class*=\\\"gdpr\\\" i],[id*=\\\"cmp\\\" i],[class*=\\\"cmp\\\" i]')\\n .forEach(el =\\u003e { try {\\n const s = getComputedStyle(el);\\n if (s.position === 'fixed' || s.position === 'sticky') el.remove();\\n } catch {} });\\n document.documentElement.style.overflow = 'auto';\\n if (document.body) document.body.style.overflow = 'auto';\\n} catch {} })();\"]},\"type\":\"CrawlerRunConfig\"},\"urls\":[\"https://exemple.test/illisible\"]}" + } + ], + "images": [], + "go_fetch": [], + "go_search": [] + }, + { + "name": "résultat mal typé", + "tool": "web_open", + "args": { + "url": "https://exemple.test/mal-type" + }, + "result": "❌ Crawl4AI : échec du crawl", + "crawls": [ + { + "path": "POST /crawl", + "auth": "Bearer sesame", + "ctype": "application/json", + "body": "{\"browser_config\":{\"params\":{\"headless\":true},\"type\":\"BrowserConfig\"},\"crawler_config\":{\"params\":{\"js_code\":[\"(() =\\u003e { try {\\n const TEXT = /^(accept all|accept|i accept|agree|i agree|got it|i understand|j'accepte|tout accepter|accepter|d'accord|allow all|allow|consent|continue|ok)$/i;\\n const isOverlayish = (el) =\\u003e {\\n try {\\n let cur = el;\\n for (let i = 0; i \\u003c 6 \\u0026\\u0026 cur; i++) {\\n const s = getComputedStyle(cur);\\n if (s.position === 'fixed' || s.position === 'sticky') return true;\\n const idcls = ((cur.id || '') + ' ' + (cur.className || '')).toLowerCase();\\n if (/cookie|consent|gdpr|cmp|privacy/.test(idcls)) return true;\\n cur = cur.parentElement;\\n }\\n } catch {}\\n return false;\\n };\\n const candidates = document.querySelectorAll('button, [role=\\\"button\\\"], input[type=\\\"button\\\"], input[type=\\\"submit\\\"]');\\n let clicked = 0;\\n for (const b of candidates) {\\n if (clicked \\u003e= 2) break;\\n const t = (b.innerText || b.value || b.getAttribute('aria-label') || '').trim();\\n if (!t || !TEXT.test(t)) continue;\\n if (b.offsetParent === null) continue;\\n if (!isOverlayish(b)) continue;\\n try { b.click(); clicked++; } catch {}\\n }\\n document.querySelectorAll('[id*=\\\"cookie\\\" i],[id*=\\\"consent\\\" i],[class*=\\\"cookie\\\" i],[class*=\\\"consent\\\" i],[id*=\\\"gdpr\\\" i],[class*=\\\"gdpr\\\" i],[id*=\\\"cmp\\\" i],[class*=\\\"cmp\\\" i]')\\n .forEach(el =\\u003e { try {\\n const s = getComputedStyle(el);\\n if (s.position === 'fixed' || s.position === 'sticky') el.remove();\\n } catch {} });\\n document.documentElement.style.overflow = 'auto';\\n if (document.body) document.body.style.overflow = 'auto';\\n} catch {} })();\"]},\"type\":\"CrawlerRunConfig\"},\"urls\":[\"https://exemple.test/mal-type\"]}" + } + ], + "images": [], + "go_fetch": [], + "go_search": [] + }, + { + "name": "page blanche", + "tool": "web_open", + "args": { + "url": "https://exemple.test/blanc" + }, + "result": "❌ page vide", + "crawls": [ + { + "path": "POST /crawl", + "auth": "Bearer sesame", + "ctype": "application/json", + "body": "{\"browser_config\":{\"params\":{\"headless\":true},\"type\":\"BrowserConfig\"},\"crawler_config\":{\"params\":{\"js_code\":[\"(() =\\u003e { try {\\n const TEXT = /^(accept all|accept|i accept|agree|i agree|got it|i understand|j'accepte|tout accepter|accepter|d'accord|allow all|allow|consent|continue|ok)$/i;\\n const isOverlayish = (el) =\\u003e {\\n try {\\n let cur = el;\\n for (let i = 0; i \\u003c 6 \\u0026\\u0026 cur; i++) {\\n const s = getComputedStyle(cur);\\n if (s.position === 'fixed' || s.position === 'sticky') return true;\\n const idcls = ((cur.id || '') + ' ' + (cur.className || '')).toLowerCase();\\n if (/cookie|consent|gdpr|cmp|privacy/.test(idcls)) return true;\\n cur = cur.parentElement;\\n }\\n } catch {}\\n return false;\\n };\\n const candidates = document.querySelectorAll('button, [role=\\\"button\\\"], input[type=\\\"button\\\"], input[type=\\\"submit\\\"]');\\n let clicked = 0;\\n for (const b of candidates) {\\n if (clicked \\u003e= 2) break;\\n const t = (b.innerText || b.value || b.getAttribute('aria-label') || '').trim();\\n if (!t || !TEXT.test(t)) continue;\\n if (b.offsetParent === null) continue;\\n if (!isOverlayish(b)) continue;\\n try { b.click(); clicked++; } catch {}\\n }\\n document.querySelectorAll('[id*=\\\"cookie\\\" i],[id*=\\\"consent\\\" i],[class*=\\\"cookie\\\" i],[class*=\\\"consent\\\" i],[id*=\\\"gdpr\\\" i],[class*=\\\"gdpr\\\" i],[id*=\\\"cmp\\\" i],[class*=\\\"cmp\\\" i]')\\n .forEach(el =\\u003e { try {\\n const s = getComputedStyle(el);\\n if (s.position === 'fixed' || s.position === 'sticky') el.remove();\\n } catch {} });\\n document.documentElement.style.overflow = 'auto';\\n if (document.body) document.body.style.overflow = 'auto';\\n} catch {} })();\"]},\"type\":\"CrawlerRunConfig\"},\"urls\":[\"https://exemple.test/blanc\"]}" + } + ], + "images": [], + "go_fetch": [], + "go_search": [] + }, + { + "name": "markdown objet faux", + "tool": "web_open", + "args": { + "url": "https://exemple.test/objet-faux" + }, + "result": "❌ page vide", + "crawls": [ + { + "path": "POST /crawl", + "auth": "Bearer sesame", + "ctype": "application/json", + "body": "{\"browser_config\":{\"params\":{\"headless\":true},\"type\":\"BrowserConfig\"},\"crawler_config\":{\"params\":{\"js_code\":[\"(() =\\u003e { try {\\n const TEXT = /^(accept all|accept|i accept|agree|i agree|got it|i understand|j'accepte|tout accepter|accepter|d'accord|allow all|allow|consent|continue|ok)$/i;\\n const isOverlayish = (el) =\\u003e {\\n try {\\n let cur = el;\\n for (let i = 0; i \\u003c 6 \\u0026\\u0026 cur; i++) {\\n const s = getComputedStyle(cur);\\n if (s.position === 'fixed' || s.position === 'sticky') return true;\\n const idcls = ((cur.id || '') + ' ' + (cur.className || '')).toLowerCase();\\n if (/cookie|consent|gdpr|cmp|privacy/.test(idcls)) return true;\\n cur = cur.parentElement;\\n }\\n } catch {}\\n return false;\\n };\\n const candidates = document.querySelectorAll('button, [role=\\\"button\\\"], input[type=\\\"button\\\"], input[type=\\\"submit\\\"]');\\n let clicked = 0;\\n for (const b of candidates) {\\n if (clicked \\u003e= 2) break;\\n const t = (b.innerText || b.value || b.getAttribute('aria-label') || '').trim();\\n if (!t || !TEXT.test(t)) continue;\\n if (b.offsetParent === null) continue;\\n if (!isOverlayish(b)) continue;\\n try { b.click(); clicked++; } catch {}\\n }\\n document.querySelectorAll('[id*=\\\"cookie\\\" i],[id*=\\\"consent\\\" i],[class*=\\\"cookie\\\" i],[class*=\\\"consent\\\" i],[id*=\\\"gdpr\\\" i],[class*=\\\"gdpr\\\" i],[id*=\\\"cmp\\\" i],[class*=\\\"cmp\\\" i]')\\n .forEach(el =\\u003e { try {\\n const s = getComputedStyle(el);\\n if (s.position === 'fixed' || s.position === 'sticky') el.remove();\\n } catch {} });\\n document.documentElement.style.overflow = 'auto';\\n if (document.body) document.body.style.overflow = 'auto';\\n} catch {} })();\"]},\"type\":\"CrawlerRunConfig\"},\"urls\":[\"https://exemple.test/objet-faux\"]}" + } + ], + "images": [], + "go_fetch": [], + "go_search": [] + }, + { + "name": "page longue", + "tool": "web_open", + "args": { + "url": "https://exemple.test/longue" + }, + "result": "# Ouvert : https://exemple.test/longue\nTotal : 60 lignes, 13.9 KB (14210 caractères)\nEn cache 10 min. Utilise web_read ou web_grep pour lire.\n\n## Plan (n° de ligne des titres)\n```\n(aucun titre markdown trouvé)\n```", + "crawls": [ + { + "path": "POST /crawl", + "auth": "Bearer sesame", + "ctype": "application/json", + "body": "{\"browser_config\":{\"params\":{\"headless\":true},\"type\":\"BrowserConfig\"},\"crawler_config\":{\"params\":{\"js_code\":[\"(() =\\u003e { try {\\n const TEXT = /^(accept all|accept|i accept|agree|i agree|got it|i understand|j'accepte|tout accepter|accepter|d'accord|allow all|allow|consent|continue|ok)$/i;\\n const isOverlayish = (el) =\\u003e {\\n try {\\n let cur = el;\\n for (let i = 0; i \\u003c 6 \\u0026\\u0026 cur; i++) {\\n const s = getComputedStyle(cur);\\n if (s.position === 'fixed' || s.position === 'sticky') return true;\\n const idcls = ((cur.id || '') + ' ' + (cur.className || '')).toLowerCase();\\n if (/cookie|consent|gdpr|cmp|privacy/.test(idcls)) return true;\\n cur = cur.parentElement;\\n }\\n } catch {}\\n return false;\\n };\\n const candidates = document.querySelectorAll('button, [role=\\\"button\\\"], input[type=\\\"button\\\"], input[type=\\\"submit\\\"]');\\n let clicked = 0;\\n for (const b of candidates) {\\n if (clicked \\u003e= 2) break;\\n const t = (b.innerText || b.value || b.getAttribute('aria-label') || '').trim();\\n if (!t || !TEXT.test(t)) continue;\\n if (b.offsetParent === null) continue;\\n if (!isOverlayish(b)) continue;\\n try { b.click(); clicked++; } catch {}\\n }\\n document.querySelectorAll('[id*=\\\"cookie\\\" i],[id*=\\\"consent\\\" i],[class*=\\\"cookie\\\" i],[class*=\\\"consent\\\" i],[id*=\\\"gdpr\\\" i],[class*=\\\"gdpr\\\" i],[id*=\\\"cmp\\\" i],[class*=\\\"cmp\\\" i]')\\n .forEach(el =\\u003e { try {\\n const s = getComputedStyle(el);\\n if (s.position === 'fixed' || s.position === 'sticky') el.remove();\\n } catch {} });\\n document.documentElement.style.overflow = 'auto';\\n if (document.body) document.body.style.overflow = 'auto';\\n} catch {} })();\"]},\"type\":\"CrawlerRunConfig\"},\"urls\":[\"https://exemple.test/longue\"]}" + } + ], + "images": [], + "go_fetch": [], + "go_search": [] + }, + { + "name": "lecture tronquée", + "tool": "web_read", + "args": { + "limit": 500, + "url": "https://exemple.test/longue" + }, + "result": "# https://exemple.test/longue\nLignes 1–60 sur 60 (fin de page)\n\n```\n 1 | 0 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore.\n 2 | 1 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore.\n 3 | 2 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore.\n 4 | 3 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore.\n 5 | 4 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore.\n 6 | 5 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore.\n 7 | 6 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore.\n 8 | 7 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore.\n 9 | 8 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore.\n 10 | 9 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore.\n 11 | 10 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore.\n 12 | 11 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore.\n 13 | 12 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore.\n 14 | 13 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore.\n 15 | 14 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore.\n 16 | 15 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore.\n 17 | 16 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore.\n 18 | 17 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore.\n 19 | 18 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore.\n 20 | 19 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore.\n 21 | 20 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore.\n 22 | 21 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore.\n 23 | 22 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore.\n 24 | 23 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore.\n 25 | 24 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore.\n 26 | 25 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore.\n 27 | 26 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore.\n 28 | 27 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore.\n 29 | 28 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore.\n 30 | 29 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore.\n 31 | 30 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore.\n 32 | 31 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore.\n 33 | 32 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour rempl\n…[tronqué : réponse trop longue. Relis par tranches avec web_read(offset, limit) ou cible avec web_grep.]", + "crawls": [], + "images": [], + "go_fetch": [], + "go_search": [] + }, + { + "name": "URL inconnue du faux", + "tool": "web_open", + "args": { + "url": "https://exemple.test/inconnue" + }, + "result": "❌ Crawl4AI HTTP 404: inconnu : https://exemple.test/inconnue", + "crawls": [ + { + "path": "POST /crawl", + "auth": "Bearer sesame", + "ctype": "application/json", + "body": "{\"browser_config\":{\"params\":{\"headless\":true},\"type\":\"BrowserConfig\"},\"crawler_config\":{\"params\":{\"js_code\":[\"(() =\\u003e { try {\\n const TEXT = /^(accept all|accept|i accept|agree|i agree|got it|i understand|j'accepte|tout accepter|accepter|d'accord|allow all|allow|consent|continue|ok)$/i;\\n const isOverlayish = (el) =\\u003e {\\n try {\\n let cur = el;\\n for (let i = 0; i \\u003c 6 \\u0026\\u0026 cur; i++) {\\n const s = getComputedStyle(cur);\\n if (s.position === 'fixed' || s.position === 'sticky') return true;\\n const idcls = ((cur.id || '') + ' ' + (cur.className || '')).toLowerCase();\\n if (/cookie|consent|gdpr|cmp|privacy/.test(idcls)) return true;\\n cur = cur.parentElement;\\n }\\n } catch {}\\n return false;\\n };\\n const candidates = document.querySelectorAll('button, [role=\\\"button\\\"], input[type=\\\"button\\\"], input[type=\\\"submit\\\"]');\\n let clicked = 0;\\n for (const b of candidates) {\\n if (clicked \\u003e= 2) break;\\n const t = (b.innerText || b.value || b.getAttribute('aria-label') || '').trim();\\n if (!t || !TEXT.test(t)) continue;\\n if (b.offsetParent === null) continue;\\n if (!isOverlayish(b)) continue;\\n try { b.click(); clicked++; } catch {}\\n }\\n document.querySelectorAll('[id*=\\\"cookie\\\" i],[id*=\\\"consent\\\" i],[class*=\\\"cookie\\\" i],[class*=\\\"consent\\\" i],[id*=\\\"gdpr\\\" i],[class*=\\\"gdpr\\\" i],[id*=\\\"cmp\\\" i],[class*=\\\"cmp\\\" i]')\\n .forEach(el =\\u003e { try {\\n const s = getComputedStyle(el);\\n if (s.position === 'fixed' || s.position === 'sticky') el.remove();\\n } catch {} });\\n document.documentElement.style.overflow = 'auto';\\n if (document.body) document.body.style.overflow = 'auto';\\n} catch {} })();\"]},\"type\":\"CrawlerRunConfig\"},\"urls\":[\"https://exemple.test/inconnue\"]}" + } + ], + "images": [], + "go_fetch": [], + "go_search": [] + }, + { + "name": "sans clé", + "setup": { + "crawl_key": "-" + }, + "tool": "web_open", + "args": { + "refresh": true, + "url": "https://exemple.test/seul" + }, + "result": "# Ouvert : https://exemple.test/seul\nTotal : 3 lignes, 26 B (26 caractères)\nEn cache 10 min. Utilise web_read ou web_grep pour lire.\n\n## Plan (n° de ligne des titres)\n```\n 1 | Seul\n```", + "crawls": [ + { + "path": "POST /crawl", + "ctype": "application/json", + "body": "{\"browser_config\":{\"params\":{\"headless\":true},\"type\":\"BrowserConfig\"},\"crawler_config\":{\"params\":{\"js_code\":[\"(() =\\u003e { try {\\n const TEXT = /^(accept all|accept|i accept|agree|i agree|got it|i understand|j'accepte|tout accepter|accepter|d'accord|allow all|allow|consent|continue|ok)$/i;\\n const isOverlayish = (el) =\\u003e {\\n try {\\n let cur = el;\\n for (let i = 0; i \\u003c 6 \\u0026\\u0026 cur; i++) {\\n const s = getComputedStyle(cur);\\n if (s.position === 'fixed' || s.position === 'sticky') return true;\\n const idcls = ((cur.id || '') + ' ' + (cur.className || '')).toLowerCase();\\n if (/cookie|consent|gdpr|cmp|privacy/.test(idcls)) return true;\\n cur = cur.parentElement;\\n }\\n } catch {}\\n return false;\\n };\\n const candidates = document.querySelectorAll('button, [role=\\\"button\\\"], input[type=\\\"button\\\"], input[type=\\\"submit\\\"]');\\n let clicked = 0;\\n for (const b of candidates) {\\n if (clicked \\u003e= 2) break;\\n const t = (b.innerText || b.value || b.getAttribute('aria-label') || '').trim();\\n if (!t || !TEXT.test(t)) continue;\\n if (b.offsetParent === null) continue;\\n if (!isOverlayish(b)) continue;\\n try { b.click(); clicked++; } catch {}\\n }\\n document.querySelectorAll('[id*=\\\"cookie\\\" i],[id*=\\\"consent\\\" i],[class*=\\\"cookie\\\" i],[class*=\\\"consent\\\" i],[id*=\\\"gdpr\\\" i],[class*=\\\"gdpr\\\" i],[id*=\\\"cmp\\\" i],[class*=\\\"cmp\\\" i]')\\n .forEach(el =\\u003e { try {\\n const s = getComputedStyle(el);\\n if (s.position === 'fixed' || s.position === 'sticky') el.remove();\\n } catch {} });\\n document.documentElement.style.overflow = 'auto';\\n if (document.body) document.body.style.overflow = 'auto';\\n} catch {} })();\"]},\"type\":\"CrawlerRunConfig\"},\"urls\":[\"https://exemple.test/seul\"]}" + } + ], + "images": [], + "go_fetch": [], + "go_search": [] + }, + { + "name": "Crawl4AI injoignable", + "setup": { + "CRAWL4AI_URL": "{{DEAD}}/" + }, + "tool": "web_open", + "args": { + "url": "https://exemple.test/x" + }, + "result": "❌ Crawl4AI injoignable ({{DEAD}}): Post \"{{DEAD}}/crawl\": dial tcp {{DEADADDR}}: connect: connection refused", + "crawls": [], + "images": [], + "go_fetch": [], + "go_search": [] + }, + { + "name": "sans serveur Crawl4AI", + "setup": { + "CRAWL4AI_URL": "-" + }, + "tool": "web_open", + "args": { + "url": "https://exemple.test/x" + }, + "result": "❌ aucun serveur Crawl4AI configuré (CRAWL4AI_URL)", + "crawls": [], + "images": [], + "go_fetch": [], + "go_search": [] + }, + { + "name": "lecture d'une page vue par Crawl4AI", + "tool": "web_read", + "args": { + "limit": 1, + "url": "https://exemple.test/seul" + }, + "result": "# https://exemple.test/seul\nLignes 1–1 sur 3 (2 de plus en dessous)\n\n```\n 1 | # Seul\n```", + "crawls": [], + "images": [], + "go_fetch": [], + "go_search": [] + }, + { + "name": "intégré : ouverture", + "setup": { + "WEB_ENGINE": "go" + }, + "tool": "web_open", + "args": { + "url": "{{PAGES}}/article.html" + }, + "result": "# Ouvert : {{PAGES}}/article.html\nTotal : 13 lignes, 692 B (692 caractères)\nEn cache 10 min. Utilise web_read ou web_grep pour lire.\n\n## Plan (n° de ligne des titres)\n```\n 1 | Guide de Loki\n 5 | Installation\n 9 | Configuration\n```", + "crawls": [], + "images": [], + "go_fetch": [ + "{{PAGES}}/article.html" + ], + "go_search": [] + }, + { + "name": "intégré : grep", + "tool": "web_grep", + "args": { + "context": 0, + "pattern": "erreur", + "url": "{{PAGES}}/article.html" + }, + "result": "# {{PAGES}}/article.html\n2 match(es) pour /erreur/i\n\n```\n 11 | Les réglages se font dans l'interface : modèle, contexte, outils. Erreur fréquente : un port déjà pris par un autre service. Changez alors le port dans les réglages avancés.\n```\n\n---\n\n```\n 13 | Erreur moins fréquente : un disque plein empêche le téléchargement du modèle.\n```", + "crawls": [], + "images": [], + "go_fetch": [], + "go_search": [] + }, + { + "name": "intégré : texte brut", + "tool": "web_open", + "args": { + "actions": [ + "x()" + ], + "url": "{{PAGES}}/texte.txt" + }, + "result": "# Ouvert : {{PAGES}}/texte.txt\nTotal : 2 lignes, 28 B (28 caractères)\nEn cache 10 min. Utilise web_read ou web_grep pour lire.\n\n## Plan (n° de ligne des titres)\n```\n(aucun titre markdown trouvé)\n```", + "crawls": [], + "images": [], + "go_fetch": [ + "{{PAGES}}/texte.txt" + ], + "go_search": [] + }, + { + "name": "intégré : lecture", + "tool": "web_read", + "args": { + "url": "{{PAGES}}/texte.txt" + }, + "result": "# {{PAGES}}/texte.txt\nLignes 1–2 sur 2 (fin de page)\n\n```\n 1 | Ligne brute 1\n 2 | Ligne brute 2\n```", + "crawls": [], + "images": [], + "go_fetch": [], + "go_search": [] + }, + { + "name": "intégré : page absente", + "tool": "web_open", + "args": { + "url": "{{PAGES}}/absente" + }, + "result": "❌ HTTP 404 sur {{PAGES}}/absente", + "crawls": [], + "images": [], + "go_fetch": [ + "{{PAGES}}/absente" + ], + "go_search": [] + }, + { + "name": "intégré : recherche", + "tool": "web_search", + "args": { + "query": "loki" + }, + "result": "# Recherche : loki\n2 résultat(s) DuckDuckGo\n\n1. Loki local\n https://loki.example/\n Un assistant local et privé.\n\n2. Deux\n https://deux.example/\n ", + "crawls": [], + "images": [], + "go_fetch": [], + "go_search": [ + "loki" + ] + }, + { + "name": "intégré : recherche limitée", + "tool": "web_search", + "args": { + "limit": 1, + "query": "loki" + }, + "result": "# Recherche : loki\n1 résultat(s) DuckDuckGo\n\n1. Loki local\n https://loki.example/\n Un assistant local et privé.", + "crawls": [], + "images": [], + "go_fetch": [], + "go_search": [ + "loki" + ] + }, + { + "name": "intégré : anti-bot", + "tool": "web_search", + "args": { + "query": "bot" + }, + "result": "❌ Recherche échouée : DuckDuckGo a renvoyé un défi anti-bot", + "crawls": [], + "images": [], + "go_fetch": [], + "go_search": [ + "bot" + ] + }, + { + "name": "images", + "tool": "web_images", + "args": { + "query": "chats" + }, + "result": "# Images : chats\n3 résultat(s) DuckDuckGo\n\n1. Chat roux (800x600)\n image : https://img.example/1.jpg\n page : https://page.example/1\n\n2. Chat & chien <3 (1024x768)\n image : http://img.example/4.png\n page : https://page.example/4\n\n3. Cinquième (10x20)\n image : https://img.example/5.jpg\n page : https://page.example/5\n\nTo show an image to the user, write ![title](image URL) in your answer.", + "crawls": [], + "images": [ + { + "path": "/?q=chats&iax=images&ia=images", + "lang": "fr-FR,fr;q=0.9,en;q=0.8", + "ua": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36" + }, + { + "path": "/i.js?l=fr-fr&o=json&f=,,,,,&p=1&q=chats&vqd=4-123-456", + "referer": "https://duckduckgo.com/", + "accept": "application/json, text/javascript, */*; q=0.01", + "lang": "fr-FR,fr;q=0.9,en;q=0.8", + "ua": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "cookie": "dcm=7" + } + ], + "go_fetch": [], + "go_search": [] + }, + { + "name": "images, cookie gardé", + "tool": "web_images", + "args": { + "limit": 1, + "query": "chats" + }, + "result": "# Images : chats\n1 résultat(s) DuckDuckGo\n\n1. Chat roux (800x600)\n image : https://img.example/1.jpg\n page : https://page.example/1\n\nTo show an image to the user, write ![title](image URL) in your answer.", + "crawls": [], + "images": [ + { + "path": "/?q=chats&iax=images&ia=images", + "lang": "fr-FR,fr;q=0.9,en;q=0.8", + "ua": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "cookie": "dcm=7" + }, + { + "path": "/i.js?l=fr-fr&o=json&f=,,,,,&p=1&q=chats&vqd=4-123-456", + "referer": "https://duckduckgo.com/", + "accept": "application/json, text/javascript, */*; q=0.01", + "lang": "fr-FR,fr;q=0.9,en;q=0.8", + "ua": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "cookie": "dcm=7" + } + ], + "go_fetch": [], + "go_search": [] + }, + { + "name": "images : sans jeton", + "tool": "web_images", + "args": { + "query": "sans jeton" + }, + "result": "❌ Recherche d'images échouée : jeton DuckDuckGo introuvable (défi anti-bot ?)", + "crawls": [], + "images": [ + { + "path": "/?q=sans+jeton&iax=images&ia=images", + "lang": "fr-FR,fr;q=0.9,en;q=0.8", + "ua": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "cookie": "dcm=7" + } + ], + "go_fetch": [], + "go_search": [] + }, + { + "name": "images : api en panne", + "tool": "web_images", + "args": { + "query": "api en panne" + }, + "result": "❌ Recherche d'images échouée : HTTP 500", + "crawls": [], + "images": [ + { + "path": "/?q=api+en+panne&iax=images&ia=images", + "lang": "fr-FR,fr;q=0.9,en;q=0.8", + "ua": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "cookie": "dcm=7" + }, + { + "path": "/i.js?l=fr-fr&o=json&f=,,,,,&p=1&q=api+en+panne&vqd=1", + "referer": "https://duckduckgo.com/", + "accept": "application/json, text/javascript, */*; q=0.01", + "lang": "fr-FR,fr;q=0.9,en;q=0.8", + "ua": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "cookie": "dcm=7" + } + ], + "go_fetch": [], + "go_search": [] + }, + { + "name": "images : page en panne", + "tool": "web_images", + "args": { + "query": "page en panne" + }, + "result": "❌ Recherche d'images échouée : HTTP 403", + "crawls": [], + "images": [ + { + "path": "/?q=page+en+panne&iax=images&ia=images", + "lang": "fr-FR,fr;q=0.9,en;q=0.8", + "ua": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "cookie": "dcm=7" + } + ], + "go_fetch": [], + "go_search": [] + }, + { + "name": "images : illisible", + "tool": "web_images", + "args": { + "query": "illisible" + }, + "result": "❌ Recherche d'images échouée : réponse illisible : invalid character 'p' looking for beginning of value", + "crawls": [], + "images": [ + { + "path": "/?q=illisible&iax=images&ia=images", + "lang": "fr-FR,fr;q=0.9,en;q=0.8", + "ua": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "cookie": "dcm=7" + }, + { + "path": "/i.js?l=fr-fr&o=json&f=,,,,,&p=1&q=illisible&vqd=2", + "referer": "https://duckduckgo.com/", + "accept": "application/json, text/javascript, */*; q=0.01", + "lang": "fr-FR,fr;q=0.9,en;q=0.8", + "ua": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "cookie": "dcm=7" + } + ], + "go_fetch": [], + "go_search": [] + }, + { + "name": "images : apres", + "tool": "web_images", + "args": { + "query": "apres" + }, + "result": "❌ Recherche d'images échouée : réponse illisible : invalid character 'x' after top-level value", + "crawls": [], + "images": [ + { + "path": "/?q=apres&iax=images&ia=images", + "lang": "fr-FR,fr;q=0.9,en;q=0.8", + "ua": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "cookie": "dcm=7" + }, + { + "path": "/i.js?l=fr-fr&o=json&f=,,,,,&p=1&q=apres&vqd=3", + "referer": "https://duckduckgo.com/", + "accept": "application/json, text/javascript, */*; q=0.01", + "lang": "fr-FR,fr;q=0.9,en;q=0.8", + "ua": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "cookie": "dcm=7" + } + ], + "go_fetch": [], + "go_search": [] + }, + { + "name": "images : largeur texte", + "tool": "web_images", + "args": { + "query": "largeur texte" + }, + "result": "❌ Recherche d'images échouée : réponse illisible : json: cannot unmarshal string into Go struct field imageResult.results.width of type int", + "crawls": [], + "images": [ + { + "path": "/?q=largeur+texte&iax=images&ia=images", + "lang": "fr-FR,fr;q=0.9,en;q=0.8", + "ua": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "cookie": "dcm=7" + }, + { + "path": "/i.js?l=fr-fr&o=json&f=,,,,,&p=1&q=largeur+texte&vqd=4", + "referer": "https://duckduckgo.com/", + "accept": "application/json, text/javascript, */*; q=0.01", + "lang": "fr-FR,fr;q=0.9,en;q=0.8", + "ua": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "cookie": "dcm=7" + } + ], + "go_fetch": [], + "go_search": [] + }, + { + "name": "images : vide", + "tool": "web_images", + "args": { + "query": "vide" + }, + "result": "Aucune image pour « vide »", + "crawls": [], + "images": [ + { + "path": "/?q=vide&iax=images&ia=images", + "lang": "fr-FR,fr;q=0.9,en;q=0.8", + "ua": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "cookie": "dcm=7" + }, + { + "path": "/i.js?l=fr-fr&o=json&f=,,,,,&p=1&q=vide&vqd=5", + "referer": "https://duckduckgo.com/", + "accept": "application/json, text/javascript, */*; q=0.01", + "lang": "fr-FR,fr;q=0.9,en;q=0.8", + "ua": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "cookie": "dcm=7" + } + ], + "go_fetch": [], + "go_search": [] + }, + { + "name": "images : null", + "tool": "web_images", + "args": { + "query": "null" + }, + "result": "Aucune image pour « null »", + "crawls": [], + "images": [ + { + "path": "/?q=null&iax=images&ia=images", + "lang": "fr-FR,fr;q=0.9,en;q=0.8", + "ua": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "cookie": "dcm=7" + }, + { + "path": "/i.js?l=fr-fr&o=json&f=,,,,,&p=1&q=null&vqd=6", + "referer": "https://duckduckgo.com/", + "accept": "application/json, text/javascript, */*; q=0.01", + "lang": "fr-FR,fr;q=0.9,en;q=0.8", + "ua": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "cookie": "dcm=7" + } + ], + "go_fetch": [], + "go_search": [] + }, + { + "name": "images : resultats faux", + "tool": "web_images", + "args": { + "query": "resultats faux" + }, + "result": "❌ Recherche d'images échouée : réponse illisible : json: cannot unmarshal string into Go struct field .results of type []loki.imageResult", + "crawls": [], + "images": [ + { + "path": "/?q=resultats+faux&iax=images&ia=images", + "lang": "fr-FR,fr;q=0.9,en;q=0.8", + "ua": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "cookie": "dcm=7" + }, + { + "path": "/i.js?l=fr-fr&o=json&f=,,,,,&p=1&q=resultats+faux&vqd=7", + "referer": "https://duckduckgo.com/", + "accept": "application/json, text/javascript, */*; q=0.01", + "lang": "fr-FR,fr;q=0.9,en;q=0.8", + "ua": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "cookie": "dcm=7" + } + ], + "go_fetch": [], + "go_search": [] + }, + { + "name": "images : apostrophe", + "tool": "web_images", + "args": { + "query": "apostrophe" + }, + "result": "# Images : apostrophe\n1 résultat(s) DuckDuckGo\n\n1. A (1x2)\n image : https://i/a\n page : https://p/a\n\nTo show an image to the user, write ![title](image URL) in your answer.", + "crawls": [], + "images": [ + { + "path": "/?q=apostrophe&iax=images&ia=images", + "lang": "fr-FR,fr;q=0.9,en;q=0.8", + "ua": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "cookie": "dcm=7" + }, + { + "path": "/i.js?l=fr-fr&o=json&f=,,,,,&p=1&q=apostrophe&vqd=77-88", + "referer": "https://duckduckgo.com/", + "accept": "application/json, text/javascript, */*; q=0.01", + "lang": "fr-FR,fr;q=0.9,en;q=0.8", + "ua": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "cookie": "dcm=7" + } + ], + "go_fetch": [], + "go_search": [] + } + ] +} diff --git a/rust/loki-rs/tests/webtools.rs b/rust/loki-rs/tests/webtools.rs new file mode 100644 index 0000000..dea18f6 --- /dev/null +++ b/rust/loki-rs/tests/webtools.rs @@ -0,0 +1,291 @@ +//! Lot 42 : outils web de l'agent (`web_search`, `web_images`, `web_open`, +//! `web_read`, `web_grep`) exécutés par la façade. +//! +//! Le journal tiré du Go (`internal/loki/parity_web_tools_test.go` → +//! `tests/parity_web/outils.json`) est rejoué contre le port : mêmes +//! réponses du faux Crawl4AI et du faux DuckDuckGo, ce que le moteur intégré +//! du Go a tiré des pages servi par le faux Go ; chaque appel doit rendre le +//! même résultat et faire les mêmes requêtes (corps envoyés à Crawl4AI, +//! en-têtes et cookies envoyés à DuckDuckGo, pages et recherches demandées +//! au Go). + +mod common; + +use std::collections::HashMap; +use std::convert::Infallible; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use bytes::Bytes; +use common::fakego; +use http_body_util::{BodyExt, Full}; +use hyper::service::service_fn; +use hyper::{Request, Response}; +use hyper_util::rt::TokioIo; +use loki_rs::store::Store; +use loki_rs::webtools::Web; +use serde_json::{Map, Value, json}; +use tokio::net::TcpListener; + +fn journal() -> Value { + serde_json::from_str(include_str!("parity_web/outils.json")).expect("journal") +} + +fn s(v: &Value) -> &str { + v.as_str().unwrap_or("") +} + +type Log = Arc>>; + +/// Un petit serveur HTTP : `f(méthode, chemin?requête, en-têtes, corps)` +/// rend (statut, en-têtes, corps). +async fn serve(f: F) -> String +where + F: Fn(String, String, hyper::HeaderMap, Vec) -> (u16, Vec<(String, String)>, String) + + Send + + Sync + + 'static, +{ + let l = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = l.local_addr().unwrap(); + let f = Arc::new(f); + tokio::spawn(async move { + while let Ok((st, _)) = l.accept().await { + let f = f.clone(); + tokio::spawn(async move { + let svc = service_fn(move |req: Request| { + let f = f.clone(); + async move { + let method = req.method().to_string(); + let uri = req + .uri() + .path_and_query() + .map(|p| p.to_string()) + .unwrap_or_default(); + let headers = req.headers().clone(); + let body = req.into_body().collect().await.unwrap().to_bytes().to_vec(); + let (code, hs, out) = f(method, uri, headers, body); + let mut r = Response::new(Full::new(Bytes::from(out))); + *r.status_mut() = hyper::StatusCode::from_u16(code).unwrap(); + for (k, v) in hs { + r.headers_mut().insert( + hyper::header::HeaderName::from_bytes(k.as_bytes()).unwrap(), + v.parse().unwrap(), + ); + } + Ok::<_, Infallible>(r) + } + }); + let _ = hyper::server::conn::http1::Builder::new() + .serve_connection(TokioIo::new(st), svc) + .await; + }); + } + }); + format!("http://{addr}") +} + +fn header(h: &hyper::HeaderMap, k: &str) -> String { + h.get(k) + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .to_string() +} + +/// Une requête reçue, champs vides omis (comme `omitempty`). +fn req(fields: &[(&str, String)]) -> Value { + let mut m = Map::new(); + for (k, v) in fields { + if !v.is_empty() { + m.insert((*k).into(), json!(v)); + } + } + Value::Object(m) +} + +/// Le faux Crawl4AI : la réponse rangée pour l'URL demandée (dans l'ordre, +/// la dernière reste). +async fn fake_crawl(fx: Value, log: Log) -> String { + let served: Arc>> = Default::default(); + serve(move |method, uri, h, body| { + let path = uri.split('?').next().unwrap_or("").to_string(); + log.lock().unwrap().push(req(&[ + ("path", format!("{method} {path}")), + ("auth", header(&h, "authorization")), + ("ctype", header(&h, "content-type")), + ("body", String::from_utf8_lossy(&body).into_owned()), + ])); + let v: Value = serde_json::from_slice(&body).unwrap_or_default(); + let target = s(&v["urls"][0]).to_string(); + let Some(list) = fx[&target].as_array().filter(|l| !l.is_empty()) else { + return (404, vec![], format!("inconnu : {target}")); + }; + let mut served = served.lock().unwrap(); + let n = served.entry(target).or_default(); + let r = &list[(*n).min(list.len() - 1)]; + *n += 1; + ( + r["status"].as_u64().unwrap() as u16, + vec![], + s(&r["body"]).to_string(), + ) + }) + .await +} + +/// Le faux DuckDuckGo des images. +async fn fake_ddg(fx: Value, log: Log) -> String { + serve(move |_, uri, h, _| { + log.lock().unwrap().push(req(&[ + ("path", uri.clone()), + ("referer", header(&h, "referer")), + ("accept", header(&h, "accept")), + ("lang", header(&h, "accept-language")), + ("ua", header(&h, "user-agent")), + ("cookie", header(&h, "cookie")), + ])); + let (path, query) = uri.split_once('?').unwrap_or((&uri, "")); + let q = url::form_urlencoded::parse(query.as_bytes()) + .find(|(k, _)| k == "q") + .map(|(_, v)| v.into_owned()) + .unwrap_or_default(); + let f = &fx[&q]; + if path == "/i.js" { + return ( + f["api_status"].as_u64().unwrap() as u16, + vec![], + s(&f["api"]).to_string(), + ); + } + let mut hs = vec![]; + if !s(&f["cookie"]).is_empty() { + hs.push(("set-cookie".to_string(), s(&f["cookie"]).to_string())); + } + ( + f["page_status"].as_u64().unwrap() as u16, + hs, + s(&f["page"]).to_string(), + ) + }) + .await +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn outils_web_comme_le_go() { + let fx = journal(); + let tmp = tempfile::tempdir().unwrap(); + let (_, go, _stop) = fakego::start(tmp.path().to_path_buf()).await; + let backend = go.backend.lock().unwrap().unwrap(); + let store = Store::new(backend, fakego::JETON.into()); + + let (crawl_log, img_log): (Log, Log) = Default::default(); + let crawl = fake_crawl(fx["crawl"].clone(), crawl_log.clone()).await; + let ddg = fake_ddg(fx["images"].clone(), img_log.clone()).await; + let dead = { + let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + l.local_addr().unwrap().to_string() + }; + let pages = "http://pages.invalid".to_string(); + let unmask = |v: &str| { + v.replace("{{CRAWL}}", &crawl) + .replace("{{DDG}}", &ddg) + .replace("{{PAGES}}", &pages) + .replace("{{DEADADDR}}", &dead) + .replace("{{DEAD}}", &format!("http://{dead}")) + }; + let mask = |v: &str| { + v.replace(&crawl, "{{CRAWL}}") + .replace(&ddg, "{{DDG}}") + .replace(&pages, "{{PAGES}}") + .replace(&format!("http://{dead}"), "{{DEAD}}") + .replace(&dead, "{{DEADADDR}}") + }; + for (k, v) in fx["go_fetch"].as_object().unwrap() { + let v: Value = serde_json::from_str(&unmask(&v.to_string())).unwrap(); + go.web_fetch.lock().unwrap().insert(unmask(k), v); + } + for (k, v) in fx["go_search"].as_object().unwrap() { + go.web_search.lock().unwrap().insert(k.clone(), v.clone()); + } + + let web = Web::with_ddg_base(&ddg); + for c in fx["calls"].as_array().unwrap() { + let name = s(&c["name"]); + for (k, v) in c["setup"].as_object().into_iter().flatten() { + let (bucket, v) = if k == "crawl_key" { + ("state", s(v).to_string()) + } else { + ("config", unmask(s(v))) + }; + if v == "-" { + go.del(bucket, k); + } else { + go.set(bucket, k, &v); + } + } + if let Some(n) = c["advance_s"].as_u64() { + web.advance(Duration::from_secs(n)); + } + let args: Map = + serde_json::from_str(&unmask(&c["args"].to_string())).unwrap(); + let got = web + .exec(&store, s(&c["tool"]), &args) + .await + .expect("un outil web"); + assert_eq!(mask(&got), s(&c["result"]), "{name}"); + + let masked = |log: &Log| -> Value { + let v = Value::Array(std::mem::take(&mut *log.lock().unwrap())); + serde_json::from_str(&mask(&v.to_string())).unwrap() + }; + assert_eq!(masked(&crawl_log), c["crawls"], "{name} : Crawl4AI"); + // reqwest pose « Accept: */* » quand la requête n'en a pas ; le + // client du Go n'en pose aucun (sans effet sur DuckDuckGo). + let mut imgs = masked(&img_log); + for r in imgs.as_array_mut().into_iter().flatten() { + if r["accept"] == "*/*" { + r.as_object_mut().unwrap().remove("accept"); + } + } + assert_eq!(imgs, c["images"], "{name} : DuckDuckGo"); + let calls = std::mem::take(&mut *go.web_log.lock().unwrap()); + let fetched: Vec = calls + .iter() + .filter_map(|l| l.strip_prefix("fetch ")) + .map(&mask) + .collect(); + let searched: Vec = calls + .iter() + .filter_map(|l| l.strip_prefix("search ")) + .map(|q| q.rsplit_once('|').map_or(q, |(q, _)| q).to_string()) + .collect(); + assert_eq!(json!(fetched), c["go_fetch"], "{name} : pages du Go"); + assert_eq!(json!(searched), c["go_search"], "{name} : recherches du Go"); + } +} + +/// Le Go injoignable : le moteur intégré (par défaut sans configuration) +/// le dit ; un autre outil n'est pas servi ici. +#[tokio::test] +async fn moteur_integre_injoignable() { + let dead = std::net::TcpListener::bind("127.0.0.1:0") + .unwrap() + .local_addr() + .unwrap(); + let store = Store::new(dead, fakego::JETON.into()); + let web = Web::new(); + let mut args = Map::new(); + args.insert("url".into(), json!("https://exemple.test/")); + let got = web.exec(&store, "web_open", &args).await.unwrap(); + assert!( + got.starts_with("❌ moteur web intégré injoignable : "), + "{got}" + ); + args.insert("query".into(), json!("x")); + let got = web.exec(&store, "web_search", &args).await.unwrap(); + assert!( + got.starts_with("❌ Recherche échouée : moteur web intégré injoignable : "), + "{got}" + ); + assert_eq!(web.exec(&store, "web_screenshot", &args).await, None); +}