mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-11 17:26:57 +02:00
v0.7.8 : l'acces internet sans rien installer
Moteur web integre en pur Go (http + readability + html-to-markdown), selectionnable par WEB_ENGINE. Crawl4AI reste disponible pour le rendu JavaScript et reste le defaut des installations qui l'ont deja configure. Le schema de web_open n'annonce plus actions/wait_for/dismiss_popups avec le moteur integre, et une page riche en HTML mais sans texte est diagnostiquee comme rendue en JavaScript, pour que le modele change de source au lieu de rouvrir la meme URL en boucle.
This commit is contained in:
1 parent
9dedba0e47
commit
3dba323aab
12 files changed
+648
-103
No files matched your search
@@ -87,9 +87,12 @@ func crawlAuth(req *http.Request) {
|
||||
}
|
||||
|
||||
// internetEnabled : accès internet actif = drapeau .internet_enabled présent ET
|
||||
// une URL Crawl4AI configurée. Même modèle que agentEnabled() (chat_agent.go).
|
||||
// un moteur web utilisable. Même modèle que agentEnabled() (chat_agent.go).
|
||||
//
|
||||
// Avec le moteur intégré (webEngine() == engineGo) il n'y a rien à configurer :
|
||||
// le drapeau suffit. Avec Crawl4AI il faut en plus une URL de serveur.
|
||||
func internetEnabled() bool {
|
||||
if crawl4aiURL() == "" {
|
||||
if webEngine() == engineCrawl && crawl4aiURL() == "" {
|
||||
return false
|
||||
}
|
||||
_, err := os.Stat(internetFlag())
|
||||
@@ -124,6 +127,12 @@ var (
|
||||
const reachTTL = 30 * time.Second
|
||||
|
||||
func crawlReachable() bool {
|
||||
if webEngine() == engineGo {
|
||||
// Moteur intégré : rien à joindre, il tourne dans ce process. On ne
|
||||
// teste pas la connectivité internet ici — un ping à chaque tour de
|
||||
// chat coûterait plus cher que l'échec de la requête réelle.
|
||||
return true
|
||||
}
|
||||
base := crawl4aiURL()
|
||||
if base == "" {
|
||||
return false
|
||||
@@ -432,10 +441,20 @@ func getPage(rawURL string, opts fetchOptions) (*cacheEntry, error) {
|
||||
if opts.waitFor != "" || len(opts.actions) > 0 {
|
||||
pageTimeout = 45000
|
||||
}
|
||||
md, err := runCrwl(u, crwlOptions{jsCode: jsCode, waitFor: opts.waitFor, pageTimeoutMs: pageTimeout})
|
||||
var md string
|
||||
var err error
|
||||
if webEngine() == engineGo {
|
||||
// Moteur intégré : pas de DOM vivant, donc jsCode / waitFor / actions
|
||||
// sont sans objet (voir web_fetch_go.go).
|
||||
md, err = goFetchMarkdown(u)
|
||||
} else {
|
||||
md, err = runCrwl(u, crwlOptions{jsCode: jsCode, waitFor: opts.waitFor, pageTimeoutMs: pageTimeout})
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Le diagnostic « probablement du JavaScript » est posé par goFetchMarkdown,
|
||||
// qui seul connaît la taille du HTML brut. Ici on n'attrape que le cas trivial.
|
||||
if strings.TrimSpace(md) == "" {
|
||||
return nil, fmt.Errorf("page vide")
|
||||
}
|
||||
@@ -534,6 +553,11 @@ func decodeUddg(raw string) string {
|
||||
}
|
||||
|
||||
func duckduckgoSearch(query string, limit int) ([]searchResult, error) {
|
||||
if webEngine() == engineGo {
|
||||
// Le moteur intégré lit la structure HTML des résultats plutôt que de
|
||||
// reparser un markdown intermédiaire.
|
||||
return goSearch(query, limit)
|
||||
}
|
||||
searchURL := "https://html.duckduckgo.com/html/?q=" + url.QueryEscape(query)
|
||||
md, err := runCrwl(searchURL, crwlOptions{rawMarkdown: true, pageTimeoutMs: 30000})
|
||||
if err != nil {
|
||||
|
||||
@@ -25,23 +25,40 @@ func webSearchTool() Tool {
|
||||
}}
|
||||
}
|
||||
|
||||
// webOpenTool : le schéma DÉPEND du moteur web actif.
|
||||
//
|
||||
// Le moteur intégré n'a pas de DOM vivant : actions / wait_for / dismiss_popups
|
||||
// seraient acceptés puis ignorés en silence. Les déclarer quand même reviendrait
|
||||
// à mentir au modèle — il croirait pouvoir déplier une section ou fermer un
|
||||
// bandeau, constaterait que rien ne change, et réessaierait en boucle (le failure
|
||||
// mode classique, cf. le garde-fou anti-boucle de chat_agent.go). On ne déclare
|
||||
// donc que ce que le moteur sait réellement faire, et on annonce la limite du JS
|
||||
// dans la description pour que le modèle change de source au lieu d'insister.
|
||||
func webOpenTool() Tool {
|
||||
desc := "Récupère une URL et renvoie SEULEMENT les métadonnées (taille, nb de lignes, plan des titres). " +
|
||||
"Ne renvoie PAS le contenu. Toujours appeler ceci d'abord avant de lire. Résultat en cache 10 min " +
|
||||
"— les web_read / web_grep suivants le réutilisent."
|
||||
props := map[string]any{
|
||||
"url": map[string]any{"type": "string", "description": "URL complète à récupérer"},
|
||||
"refresh": map[string]any{"type": "boolean", "description": "Ignore le cache et re-fetch. Défaut false."},
|
||||
}
|
||||
if webEngine() == engineGo {
|
||||
desc += " ⚠ Ce moteur lit le HTML servi SANS exécuter le JavaScript : une page rendue " +
|
||||
"entièrement côté client ressortira vide. Dans ce cas, ne réessaie pas la même URL — " +
|
||||
"cherche une autre source (doc officielle, dépôt, article)."
|
||||
} else {
|
||||
props["actions"] = map[string]any{"type": "array", "items": map[string]any{"type": "string"},
|
||||
"description": "Snippets JS à exécuter sur la page AVANT extraction (déplier des sections, cliquer 'voir plus', etc.)."}
|
||||
props["dismiss_popups"] = map[string]any{"type": "boolean", "description": "Ferme auto les bandeaux cookies/overlays. Défaut true."}
|
||||
props["wait_for"] = map[string]any{"type": "string", "description": "Sélecteur CSS ou expr JS à attendre après les actions."}
|
||||
}
|
||||
return Tool{Type: "function", Function: ToolFunction{
|
||||
Name: "web_open",
|
||||
Description: "Récupère une URL et renvoie SEULEMENT les métadonnées (taille, nb de lignes, plan des titres). " +
|
||||
"Ne renvoie PAS le contenu. Toujours appeler ceci d'abord avant de lire. Résultat en cache 10 min " +
|
||||
"— les web_read / web_grep suivants le réutilisent.",
|
||||
Name: "web_open",
|
||||
Description: desc,
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"url": map[string]any{"type": "string", "description": "URL complète à récupérer"},
|
||||
"refresh": map[string]any{"type": "boolean", "description": "Ignore le cache et re-fetch. Défaut false."},
|
||||
"actions": map[string]any{"type": "array", "items": map[string]any{"type": "string"},
|
||||
"description": "Snippets JS à exécuter sur la page AVANT extraction (déplier des sections, cliquer 'voir plus', etc.)."},
|
||||
"dismiss_popups": map[string]any{"type": "boolean", "description": "Ferme auto les bandeaux cookies/overlays. Défaut true."},
|
||||
"wait_for": map[string]any{"type": "string", "description": "Sélecteur CSS ou expr JS à attendre après les actions."},
|
||||
},
|
||||
"required": []string{"url"},
|
||||
"type": "object",
|
||||
"properties": props,
|
||||
"required": []string{"url"},
|
||||
},
|
||||
}}
|
||||
}
|
||||
@@ -268,7 +285,7 @@ func toolWebGrep(args map[string]any) string {
|
||||
entry.url, len(matchIdx), pattern, capped, strings.Join(blocks, "\n\n---\n\n"))
|
||||
}
|
||||
|
||||
// ─── CLI : ajean internet [on|off|status|url <url>] ──────────────────────────
|
||||
// ─── CLI : ajean internet [on|off|status|engine|url|key] ────────────────────
|
||||
|
||||
func cmdInternet(args []string) error {
|
||||
sub := ""
|
||||
@@ -277,8 +294,9 @@ func cmdInternet(args []string) error {
|
||||
}
|
||||
switch sub {
|
||||
case "on":
|
||||
if crawl4aiURL() == "" {
|
||||
return fmt.Errorf("configure d'abord l'URL : ajean internet url <url>")
|
||||
// Le moteur intégré ne demande aucun réglage ; Crawl4AI exige un serveur.
|
||||
if webEngine() == engineCrawl && crawl4aiURL() == "" {
|
||||
return fmt.Errorf("configure d'abord l'URL : ajean internet url <url> (ou bascule sur le moteur intégré : ajean internet engine go)")
|
||||
}
|
||||
if err := setInternetEnabled(true); err != nil {
|
||||
return err
|
||||
@@ -317,12 +335,31 @@ func cmdInternet(args []string) error {
|
||||
} else {
|
||||
fmt.Println(green("[ok]") + " clé Crawl4AI enregistrée")
|
||||
}
|
||||
case "engine":
|
||||
if len(args) < 2 {
|
||||
return fmt.Errorf("usage: ajean internet engine <go|crawl4ai>")
|
||||
}
|
||||
e := strings.ToLower(strings.TrimSpace(args[1]))
|
||||
if err := setWebEngine(e); err != nil {
|
||||
return err
|
||||
}
|
||||
if e == engineGo {
|
||||
fmt.Println(green("[ok]") + " moteur intégré — aucune installation requise (pas de rendu JavaScript)")
|
||||
} else {
|
||||
fmt.Println(green("[ok]") + " moteur Crawl4AI — configure le serveur : ajean internet url <url>")
|
||||
}
|
||||
case "", "status", "list":
|
||||
state := dim("off")
|
||||
if internetEnabled() {
|
||||
state = green("on")
|
||||
}
|
||||
fmt.Printf("%s état: %s\n", cyan("Accès internet"), state)
|
||||
if webEngine() == engineGo {
|
||||
fmt.Printf(" moteur : %s (aucune installation, pas de rendu JavaScript)\n", bold("intégré"))
|
||||
fmt.Printf(" outils : web_search, web_open, web_read, web_grep\n")
|
||||
return nil
|
||||
}
|
||||
fmt.Printf(" moteur : %s\n", bold("crawl4ai"))
|
||||
u := crawl4aiURL()
|
||||
if u == "" {
|
||||
fmt.Printf(" serveur : %s — configure : ajean internet url <url>\n", dim("(non configuré)"))
|
||||
@@ -335,7 +372,7 @@ func cmdInternet(args []string) error {
|
||||
fmt.Printf(" serveur : %s (%s)\n", bold(u), reach)
|
||||
fmt.Printf(" outils : web_search, web_open, web_read, web_grep\n")
|
||||
default:
|
||||
return fmt.Errorf("usage: ajean internet [on|off|status|url <url>]")
|
||||
return fmt.Errorf("usage: ajean internet [on|off|status|engine <go|crawl4ai>|url <url>|key <clé>]")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
const Version = "0.7.7"
|
||||
const Version = "0.7.8"
|
||||
|
||||
// Main est le vrai main() du binaire (cmd/ajean ne fait que l'appeler).
|
||||
func Main() {
|
||||
@@ -140,7 +140,8 @@ Presets:
|
||||
Interaction:
|
||||
chat [system-prompt] chat terminal streamé
|
||||
web [PORT] UI web (défaut :8090) — chat + presets + mode agent
|
||||
internet [on|off|status|url <url>|key <clé>] accès web de l'IA via un serveur Crawl4AI (web_search/open/read/grep)
|
||||
internet [on|off|status|engine <go|crawl4ai>|url <url>|key <clé>]
|
||||
accès web de l'IA (web_search/open/read/grep) — moteur intégré ou serveur Crawl4AI
|
||||
memory [off|ondemand|always|status] mode mémoire de l'IA (off / sur demande / auto)
|
||||
|
||||
Accès distant (ajean.link) :
|
||||
|
||||
@@ -916,22 +916,33 @@ button:hover{border-color:var(--dim);color:var(--text)}
|
||||
</div>
|
||||
</div>
|
||||
<div id="net-block" class="gated-block">
|
||||
<div class="subhead">Accès internet<span id="internet-badge" class="sbadge"></span><span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Ajoute les outils <b>web_search / web_open / web_read / web_grep</b> via un serveur <b>Crawl4AI</b> que tu héberges toi-même (ex. <code>docker run -d -p 11235:11235 --shm-size=1g unclecode/crawl4ai:latest</code>). Actifs seulement si le <b>mode agent</b> est actif et que le serveur répond.</span></span></div>
|
||||
<div class="subhead">Accès internet<span id="internet-badge" class="sbadge"></span><span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Ajoute les outils <b>web_search / web_open / web_read / web_grep</b>. Le moteur <b>intégré</b> ne demande aucune installation. Le moteur <b>Crawl4AI</b> exige un serveur que tu héberges toi-même (ex. <code>docker run -d -p 11235:11235 --shm-size=1g unclecode/crawl4ai:latest</code>) mais exécute le JavaScript des pages. Actifs seulement si le <b>mode agent</b> est actif.</span></span></div>
|
||||
<label class="switchrow"><span class="switch"><input type="checkbox" id="internet-toggle" onchange="toggleInternet()"><span class="slider"></span></span> donner internet à l'IA</label>
|
||||
<!-- URL et clé s'enregistrent en quittant le champ (onchange) : deux boutons
|
||||
<!-- Moteur web : « go » = intégré au binaire (rien à installer, pas de rendu
|
||||
JavaScript) · « crawl4ai » = serveur externe avec Chromium headless. -->
|
||||
<div class="row" style="margin-top:6px">
|
||||
<select id="web-engine" class="sctl" onchange="saveWebEngine()">
|
||||
<option value="go">moteur intégré — rien à installer</option>
|
||||
<option value="crawl4ai">serveur Crawl4AI — rendu JavaScript</option>
|
||||
</select>
|
||||
</div>
|
||||
<!-- Réglages propres à Crawl4AI : masqués avec le moteur intégré.
|
||||
URL et clé s'enregistrent en quittant le champ (onchange) : deux boutons
|
||||
« enregistrer » côte à côte n'apportaient rien et brouillaient la carte. -->
|
||||
<div class="row" style="margin-top:6px">
|
||||
<input id="crawl-url" type="text" placeholder="http://localhost:11235" class="sctl" onchange="saveCrawlUrl()">
|
||||
<div id="crawl-fields">
|
||||
<div class="row" style="margin-top:6px">
|
||||
<input id="crawl-url" type="text" placeholder="http://localhost:11235" class="sctl" onchange="saveCrawlUrl()">
|
||||
</div>
|
||||
<!-- Clé d'accès du serveur Crawl4AI (facultative) : envoyée en
|
||||
« Authorization: Bearer … ». Jamais relue par l'API, on n'affiche
|
||||
qu'un indice sur les 4 derniers caractères. -->
|
||||
<div class="row" style="margin-top:6px">
|
||||
<input id="crawl-key" type="password" autocomplete="off" placeholder="clé API (si le serveur en exige une)" class="sctl" onchange="saveCrawlKey()">
|
||||
<button id="crawl-key-clear" onclick="clearCrawlKey()" style="display:none">retirer</button>
|
||||
</div>
|
||||
<div class="muted" style="font-size:11px;margin-top:4px">enregistré automatiquement en quittant le champ</div>
|
||||
<div id="crawl-key-state" class="muted" style="font-size:11px;margin-top:3px"></div>
|
||||
</div>
|
||||
<!-- Clé d'accès du serveur Crawl4AI (facultative) : envoyée en
|
||||
« Authorization: Bearer … ». Jamais relue par l'API, on n'affiche
|
||||
qu'un indice sur les 4 derniers caractères. -->
|
||||
<div class="row" style="margin-top:6px">
|
||||
<input id="crawl-key" type="password" autocomplete="off" placeholder="clé API (si le serveur en exige une)" class="sctl" onchange="saveCrawlKey()">
|
||||
<button id="crawl-key-clear" onclick="clearCrawlKey()" style="display:none">retirer</button>
|
||||
</div>
|
||||
<div class="muted" style="font-size:11px;margin-top:4px">enregistré automatiquement en quittant le champ</div>
|
||||
<div id="crawl-key-state" class="muted" style="font-size:11px;margin-top:3px"></div>
|
||||
<div id="internet-status" class="muted" style="font-size:11px;margin-top:4px"></div>
|
||||
</div>
|
||||
<div id="mcp-block" class="gated-block">
|
||||
@@ -1895,15 +1906,21 @@ async function setMemMode(){
|
||||
renderMemModeDesc(r.mode||mode);
|
||||
}
|
||||
// Accès internet : serveur Crawl4AI + drapeau. Actif ET fonctionnel = pastille verte.
|
||||
let internetOn=false;
|
||||
let internetOn=false, webEngine='go';
|
||||
function renderInternet(s){
|
||||
internetOn = !!s.enabled;
|
||||
webEngine = s.engine || 'go';
|
||||
document.getElementById('internet-toggle').checked = internetOn;
|
||||
const sel=document.getElementById('web-engine');
|
||||
if(sel && document.activeElement!==sel) sel.value = webEngine;
|
||||
// Les réglages Crawl4AI n'ont aucun sens avec le moteur intégré.
|
||||
const cf=document.getElementById('crawl-fields');
|
||||
if(cf) cf.style.display = (webEngine==='crawl4ai') ? '' : 'none';
|
||||
if(document.activeElement !== document.getElementById('crawl-url'))
|
||||
document.getElementById('crawl-url').value = s.url || '';
|
||||
// Actif mais serveur injoignable : la pastille le dit (sinon l'UI ment — les
|
||||
// outils web ne sont pas proposés au modèle dans ce cas).
|
||||
if(internetOn && s.url && !s.reachable) setBadge('internet-badge','warn','injoignable');
|
||||
if(internetOn && !s.reachable) setBadge('internet-badge','warn','injoignable');
|
||||
else setBadge('internet-badge', internetOn, internetOn?'actif':'inactif');
|
||||
// Clé du serveur Crawl4AI : le champ reste vide (la clé n'est jamais renvoyée),
|
||||
// on indique seulement si une clé est enregistrée.
|
||||
@@ -1916,7 +1933,8 @@ function renderInternet(s){
|
||||
if(ki) ki.placeholder = s.key_set ? 'clé enregistrée — saisir pour remplacer' : 'clé API (si le serveur en exige une)';
|
||||
}
|
||||
const st=document.getElementById('internet-status');
|
||||
if(!s.url){ st.textContent='serveur non configuré'; st.style.color=''; }
|
||||
if(webEngine!=='crawl4ai'){ st.innerHTML='<span style="color:var(--accent)">✓</span> moteur intégré — aucune installation requise (pas de rendu JavaScript)'; }
|
||||
else if(!s.url){ st.textContent='serveur non configuré'; st.style.color=''; }
|
||||
else if(s.reachable){ st.innerHTML='<span style="color:var(--accent)">✓</span> serveur joignable — outils web actifs'; }
|
||||
else { st.innerHTML='⚠ serveur injoignable — les outils web ne seront pas proposés'; }
|
||||
}
|
||||
@@ -1989,9 +2007,16 @@ async function apiKeySet(){
|
||||
async function toggleInternet(){
|
||||
const on=document.getElementById('internet-toggle').checked;
|
||||
const url=document.getElementById('crawl-url').value.trim();
|
||||
if(on && !url){ toast('renseigne d\'abord l\'URL du serveur Crawl4AI'); document.getElementById('internet-toggle').checked=false; return; }
|
||||
// Crawl4AI sans URL de serveur = rien à joindre. Le moteur intégré, lui, n'a
|
||||
// besoin d'aucun réglage : on active directement.
|
||||
if(on && webEngine==='crawl4ai' && !url){ toast('renseigne d\'abord l\'URL du serveur Crawl4AI'); document.getElementById('internet-toggle').checked=false; return; }
|
||||
renderInternet(await jpost('/api/internet',{enabled:on, url}));
|
||||
}
|
||||
async function saveWebEngine(){
|
||||
const engine=document.getElementById('web-engine').value;
|
||||
renderInternet(await jpost('/api/internet',{engine}));
|
||||
toast(engine==='crawl4ai' ? 'moteur Crawl4AI sélectionné' : 'moteur intégré sélectionné');
|
||||
}
|
||||
async function saveCrawlUrl(){
|
||||
const url=document.getElementById('crawl-url').value.trim();
|
||||
renderInternet(await jpost('/api/internet',{url}));
|
||||
|
||||
@@ -88,22 +88,33 @@
|
||||
</div>
|
||||
</div>
|
||||
<div id="net-block" class="gated-block">
|
||||
<div class="subhead">Accès internet<span id="internet-badge" class="sbadge"></span><span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Ajoute les outils <b>web_search / web_open / web_read / web_grep</b> via un serveur <b>Crawl4AI</b> que tu héberges toi-même (ex. <code>docker run -d -p 11235:11235 --shm-size=1g unclecode/crawl4ai:latest</code>). Actifs seulement si le <b>mode agent</b> est actif et que le serveur répond.</span></span></div>
|
||||
<div class="subhead">Accès internet<span id="internet-badge" class="sbadge"></span><span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Ajoute les outils <b>web_search / web_open / web_read / web_grep</b>. Le moteur <b>intégré</b> ne demande aucune installation. Le moteur <b>Crawl4AI</b> exige un serveur que tu héberges toi-même (ex. <code>docker run -d -p 11235:11235 --shm-size=1g unclecode/crawl4ai:latest</code>) mais exécute le JavaScript des pages. Actifs seulement si le <b>mode agent</b> est actif.</span></span></div>
|
||||
<label class="switchrow"><span class="switch"><input type="checkbox" id="internet-toggle" onchange="toggleInternet()"><span class="slider"></span></span> donner internet à l'IA</label>
|
||||
<!-- URL et clé s'enregistrent en quittant le champ (onchange) : deux boutons
|
||||
<!-- Moteur web : « go » = intégré au binaire (rien à installer, pas de rendu
|
||||
JavaScript) · « crawl4ai » = serveur externe avec Chromium headless. -->
|
||||
<div class="row" style="margin-top:6px">
|
||||
<select id="web-engine" class="sctl" onchange="saveWebEngine()">
|
||||
<option value="go">moteur intégré — rien à installer</option>
|
||||
<option value="crawl4ai">serveur Crawl4AI — rendu JavaScript</option>
|
||||
</select>
|
||||
</div>
|
||||
<!-- Réglages propres à Crawl4AI : masqués avec le moteur intégré.
|
||||
URL et clé s'enregistrent en quittant le champ (onchange) : deux boutons
|
||||
« enregistrer » côte à côte n'apportaient rien et brouillaient la carte. -->
|
||||
<div class="row" style="margin-top:6px">
|
||||
<input id="crawl-url" type="text" placeholder="http://localhost:11235" class="sctl" onchange="saveCrawlUrl()">
|
||||
<div id="crawl-fields">
|
||||
<div class="row" style="margin-top:6px">
|
||||
<input id="crawl-url" type="text" placeholder="http://localhost:11235" class="sctl" onchange="saveCrawlUrl()">
|
||||
</div>
|
||||
<!-- Clé d'accès du serveur Crawl4AI (facultative) : envoyée en
|
||||
« Authorization: Bearer … ». Jamais relue par l'API, on n'affiche
|
||||
qu'un indice sur les 4 derniers caractères. -->
|
||||
<div class="row" style="margin-top:6px">
|
||||
<input id="crawl-key" type="password" autocomplete="off" placeholder="clé API (si le serveur en exige une)" class="sctl" onchange="saveCrawlKey()">
|
||||
<button id="crawl-key-clear" onclick="clearCrawlKey()" style="display:none">retirer</button>
|
||||
</div>
|
||||
<div class="muted" style="font-size:11px;margin-top:4px">enregistré automatiquement en quittant le champ</div>
|
||||
<div id="crawl-key-state" class="muted" style="font-size:11px;margin-top:3px"></div>
|
||||
</div>
|
||||
<!-- Clé d'accès du serveur Crawl4AI (facultative) : envoyée en
|
||||
« Authorization: Bearer … ». Jamais relue par l'API, on n'affiche
|
||||
qu'un indice sur les 4 derniers caractères. -->
|
||||
<div class="row" style="margin-top:6px">
|
||||
<input id="crawl-key" type="password" autocomplete="off" placeholder="clé API (si le serveur en exige une)" class="sctl" onchange="saveCrawlKey()">
|
||||
<button id="crawl-key-clear" onclick="clearCrawlKey()" style="display:none">retirer</button>
|
||||
</div>
|
||||
<div class="muted" style="font-size:11px;margin-top:4px">enregistré automatiquement en quittant le champ</div>
|
||||
<div id="crawl-key-state" class="muted" style="font-size:11px;margin-top:3px"></div>
|
||||
<div id="internet-status" class="muted" style="font-size:11px;margin-top:4px"></div>
|
||||
</div>
|
||||
<div id="mcp-block" class="gated-block">
|
||||
|
||||
@@ -132,15 +132,21 @@ async function setMemMode(){
|
||||
renderMemModeDesc(r.mode||mode);
|
||||
}
|
||||
// Accès internet : serveur Crawl4AI + drapeau. Actif ET fonctionnel = pastille verte.
|
||||
let internetOn=false;
|
||||
let internetOn=false, webEngine='go';
|
||||
function renderInternet(s){
|
||||
internetOn = !!s.enabled;
|
||||
webEngine = s.engine || 'go';
|
||||
document.getElementById('internet-toggle').checked = internetOn;
|
||||
const sel=document.getElementById('web-engine');
|
||||
if(sel && document.activeElement!==sel) sel.value = webEngine;
|
||||
// Les réglages Crawl4AI n'ont aucun sens avec le moteur intégré.
|
||||
const cf=document.getElementById('crawl-fields');
|
||||
if(cf) cf.style.display = (webEngine==='crawl4ai') ? '' : 'none';
|
||||
if(document.activeElement !== document.getElementById('crawl-url'))
|
||||
document.getElementById('crawl-url').value = s.url || '';
|
||||
// Actif mais serveur injoignable : la pastille le dit (sinon l'UI ment — les
|
||||
// outils web ne sont pas proposés au modèle dans ce cas).
|
||||
if(internetOn && s.url && !s.reachable) setBadge('internet-badge','warn','injoignable');
|
||||
if(internetOn && !s.reachable) setBadge('internet-badge','warn','injoignable');
|
||||
else setBadge('internet-badge', internetOn, internetOn?'actif':'inactif');
|
||||
// Clé du serveur Crawl4AI : le champ reste vide (la clé n'est jamais renvoyée),
|
||||
// on indique seulement si une clé est enregistrée.
|
||||
@@ -153,7 +159,8 @@ function renderInternet(s){
|
||||
if(ki) ki.placeholder = s.key_set ? 'clé enregistrée — saisir pour remplacer' : 'clé API (si le serveur en exige une)';
|
||||
}
|
||||
const st=document.getElementById('internet-status');
|
||||
if(!s.url){ st.textContent='serveur non configuré'; st.style.color=''; }
|
||||
if(webEngine!=='crawl4ai'){ st.innerHTML='<span style="color:var(--accent)">✓</span> moteur intégré — aucune installation requise (pas de rendu JavaScript)'; }
|
||||
else if(!s.url){ st.textContent='serveur non configuré'; st.style.color=''; }
|
||||
else if(s.reachable){ st.innerHTML='<span style="color:var(--accent)">✓</span> serveur joignable — outils web actifs'; }
|
||||
else { st.innerHTML='⚠ serveur injoignable — les outils web ne seront pas proposés'; }
|
||||
}
|
||||
@@ -226,9 +233,16 @@ async function apiKeySet(){
|
||||
async function toggleInternet(){
|
||||
const on=document.getElementById('internet-toggle').checked;
|
||||
const url=document.getElementById('crawl-url').value.trim();
|
||||
if(on && !url){ toast('renseigne d\'abord l\'URL du serveur Crawl4AI'); document.getElementById('internet-toggle').checked=false; return; }
|
||||
// Crawl4AI sans URL de serveur = rien à joindre. Le moteur intégré, lui, n'a
|
||||
// besoin d'aucun réglage : on active directement.
|
||||
if(on && webEngine==='crawl4ai' && !url){ toast('renseigne d\'abord l\'URL du serveur Crawl4AI'); document.getElementById('internet-toggle').checked=false; return; }
|
||||
renderInternet(await jpost('/api/internet',{enabled:on, url}));
|
||||
}
|
||||
async function saveWebEngine(){
|
||||
const engine=document.getElementById('web-engine').value;
|
||||
renderInternet(await jpost('/api/internet',{engine}));
|
||||
toast(engine==='crawl4ai' ? 'moteur Crawl4AI sélectionné' : 'moteur intégré sélectionné');
|
||||
}
|
||||
async function saveCrawlUrl(){
|
||||
const url=document.getElementById('crawl-url').value.trim();
|
||||
renderInternet(await jpost('/api/internet',{url}));
|
||||
|
||||
@@ -582,8 +582,17 @@ func handleInternet(w http.ResponseWriter, r *http.Request) {
|
||||
Enabled *bool `json:"enabled"`
|
||||
URL *string `json:"url"`
|
||||
Key *string `json:"key"`
|
||||
Engine *string `json:"engine"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
// Moteur web : "go" (intégré, rien à installer) ou "crawl4ai" (serveur
|
||||
// externe, rendu JavaScript).
|
||||
if req.Engine != nil {
|
||||
if err := setWebEngine(strings.ToLower(strings.TrimSpace(*req.Engine))); err != nil {
|
||||
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
// Clé d'accès au serveur Crawl4AI : chaîne vide = on l'enlève.
|
||||
if req.Key != nil {
|
||||
if err := writeCrawlKey(strings.TrimSpace(*req.Key)); err != nil {
|
||||
@@ -622,6 +631,7 @@ func handleInternet(w http.ResponseWriter, r *http.Request) {
|
||||
sendJSON(w, 200, map[string]any{
|
||||
"ok": true,
|
||||
"enabled": internetEnabled(),
|
||||
"engine": webEngine(),
|
||||
"url": crawl4aiURL(),
|
||||
"reachable": crawlReachable(),
|
||||
"key_set": key != "",
|
||||
|
||||
@@ -0,0 +1,390 @@
|
||||
package ajean
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
readability "codeberg.org/readeck/go-readability/v2"
|
||||
htmltomarkdown "github.com/JohannesKaufmann/html-to-markdown/v2"
|
||||
"github.com/JohannesKaufmann/html-to-markdown/v2/converter"
|
||||
"golang.org/x/net/html"
|
||||
"golang.org/x/net/html/charset"
|
||||
)
|
||||
|
||||
// Moteur web INTÉGRÉ (pur Go) — alternative à Crawl4AI, sans rien à installer.
|
||||
//
|
||||
// Crawl4AI = Chromium headless piloté par Playwright : il exécute le JS de la
|
||||
// page avant d'en extraire le markdown. C'est puissant mais ça demande à
|
||||
// l'utilisateur de faire tourner un serveur Python/Docker — rédhibitoire pour
|
||||
// un utilisateur non technique.
|
||||
//
|
||||
// Ce moteur-ci remplace UNIQUEMENT l'étape « URL → markdown » (runCrwl) par du
|
||||
// Go pur :
|
||||
//
|
||||
// http.Get → readability (port Go de Readability de Firefox, qui vire nav,
|
||||
// pubs, pieds de page) → html-to-markdown
|
||||
//
|
||||
// Ce qu'on perd : le rendu JavaScript. Une page 100 % React sans SSR ressort
|
||||
// vide, et les options js_code / wait_for / actions n'ont plus de sens (pas de
|
||||
// DOM vivant). En pratique, les sources que l'IA lit — docs, articles, blogs,
|
||||
// Wikipédia, GitHub, forums — sont servies en HTML et passent très bien.
|
||||
//
|
||||
// Le choix se fait par la clé de config WEB_ENGINE ("go" ou "crawl4ai"). Voir
|
||||
// webEngine(). Crawl4AI reste entièrement supporté.
|
||||
|
||||
// ─── sélection du moteur ────────────────────────────────────────────────────
|
||||
|
||||
const (
|
||||
engineGo = "go"
|
||||
engineCrawl = "crawl4ai"
|
||||
)
|
||||
|
||||
// webEngine renvoie le moteur web actif : engineGo ou engineCrawl.
|
||||
//
|
||||
// Par défaut on prend le moteur intégré. L'exception est l'installation qui a
|
||||
// DÉJÀ une URL Crawl4AI configurée sans avoir jamais choisi de moteur : elle
|
||||
// tournait sur Crawl4AI, on ne change pas son comportement dans son dos.
|
||||
func webEngine() string {
|
||||
switch strings.ToLower(strings.TrimSpace(ReadConfig()["WEB_ENGINE"])) {
|
||||
case engineGo:
|
||||
return engineGo
|
||||
case engineCrawl:
|
||||
return engineCrawl
|
||||
}
|
||||
if crawl4aiURL() != "" {
|
||||
return engineCrawl
|
||||
}
|
||||
return engineGo
|
||||
}
|
||||
|
||||
// setWebEngine enregistre le moteur web choisi et invalide le cache de
|
||||
// reachability (l'état affiché dépend du moteur).
|
||||
func setWebEngine(engine string) error {
|
||||
if engine != engineGo && engine != engineCrawl {
|
||||
return fmt.Errorf("moteur web inconnu : %q", engine)
|
||||
}
|
||||
if err := SetConfigKey("WEB_ENGINE", engine); err != nil {
|
||||
return err
|
||||
}
|
||||
reachMu.Lock()
|
||||
reachURL = ""
|
||||
reachMu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// ─── récupération HTTP ──────────────────────────────────────────────────────
|
||||
|
||||
// Un vrai User-Agent : beaucoup de sites servent une page dégradée (ou un 403)
|
||||
// aux clients qui s'annoncent comme des robots.
|
||||
const goFetchUA = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
|
||||
|
||||
const (
|
||||
goFetchTimeout = 30 * time.Second
|
||||
goFetchMaxBytes = 8 << 20 // 8 Mo : au-delà ce n'est plus une page à lire
|
||||
)
|
||||
|
||||
// goHTTPClient : client dédié au moteur web. Timeout global et plafond de
|
||||
// redirections. On ne touche pas à http.DefaultClient (utilisé ailleurs).
|
||||
var goHTTPClient = &http.Client{
|
||||
Timeout: goFetchTimeout,
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
if len(via) >= 10 {
|
||||
return fmt.Errorf("trop de redirections")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// goFetch effectue le GET et renvoie le corps (plafonné), le Content-Type et
|
||||
// l'URL finale après redirections.
|
||||
func goFetch(ctx context.Context, target string) (body []byte, contentType, finalURL string, err error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil)
|
||||
if err != nil {
|
||||
return nil, "", "", err
|
||||
}
|
||||
req.Header.Set("User-Agent", goFetchUA)
|
||||
req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,text/plain;q=0.8,*/*;q=0.7")
|
||||
req.Header.Set("Accept-Language", "fr-FR,fr;q=0.9,en;q=0.8")
|
||||
// Pas d'Accept-Encoding manuel : le transport gère gzip tout seul.
|
||||
|
||||
resp, err := goHTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, "", "", fmt.Errorf("échec de la requête : %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
b, err := io.ReadAll(io.LimitReader(resp.Body, goFetchMaxBytes))
|
||||
if err != nil {
|
||||
return nil, "", "", fmt.Errorf("lecture interrompue : %v", err)
|
||||
}
|
||||
if resp.StatusCode >= 400 {
|
||||
return nil, "", "", fmt.Errorf("HTTP %d sur %s", resp.StatusCode, target)
|
||||
}
|
||||
return b, resp.Header.Get("Content-Type"), resp.Request.URL.String(), nil
|
||||
}
|
||||
|
||||
// goParseHTML décode le corps selon le charset annoncé (ou détecté) puis le
|
||||
// parse en arbre DOM.
|
||||
func goParseHTML(body []byte, contentType string) (*html.Node, error) {
|
||||
r, err := charset.NewReader(strings.NewReader(string(body)), contentType)
|
||||
if err != nil {
|
||||
// Charset inconnu : on tente en l'état plutôt que d'abandonner.
|
||||
r = strings.NewReader(string(body))
|
||||
}
|
||||
return html.Parse(r)
|
||||
}
|
||||
|
||||
// ─── URL → markdown ─────────────────────────────────────────────────────────
|
||||
|
||||
// goFetchMarkdown est l'équivalent pur Go de runCrwl : elle récupère une URL et
|
||||
// en renvoie le contenu en markdown.
|
||||
//
|
||||
// Les contenus non-HTML (texte brut, markdown, JSON…) sont renvoyés tels quels :
|
||||
// c'est le cas des README GitHub, vers lesquels normalizeCrawlURL redirige.
|
||||
func goFetchMarkdown(target string) (string, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), goFetchTimeout)
|
||||
defer cancel()
|
||||
|
||||
body, contentType, finalURL, err := goFetch(ctx, target)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
ct := strings.ToLower(contentType)
|
||||
isHTML := strings.Contains(ct, "html") || strings.Contains(ct, "xml")
|
||||
if ct == "" {
|
||||
// Certains serveurs n'annoncent rien : on devine sur le contenu.
|
||||
isHTML = strings.Contains(strings.ToLower(string(body[:min(len(body), 1024)])), "<html")
|
||||
}
|
||||
if !isHTML {
|
||||
if strings.Contains(ct, "text/") || strings.Contains(ct, "json") || ct == "" {
|
||||
return string(body), nil
|
||||
}
|
||||
return "", fmt.Errorf("type de contenu non lisible : %s", contentType)
|
||||
}
|
||||
|
||||
doc, err := goParseHTML(body, contentType)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("HTML illisible : %v", err)
|
||||
}
|
||||
|
||||
base, _ := url.Parse(finalURL)
|
||||
md, title := goArticleMarkdown(doc, base)
|
||||
if strings.TrimSpace(md) == "" {
|
||||
// Readability n'a rien trouvé d'« article » (page d'accueil, listing,
|
||||
// appli…). On convertit la page entière plutôt que de renvoyer vide.
|
||||
md, err = goConvert(doc, finalURL)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
if title != "" && !strings.HasPrefix(strings.TrimSpace(md), "# ") {
|
||||
md = "# " + title + "\n\n" + md
|
||||
}
|
||||
|
||||
// Diagnostic du rendu client. Beaucoup de HTML (scripts, bundles, templates)
|
||||
// mais presque aucun texte extractible = coquille de SPA. Le rapport compte
|
||||
// plus que la taille absolue : une vraie petite page (example.com : ~1 Ko de
|
||||
// HTML, ~170 caractères de texte) n'est PAS suspecte, alors qu'une page de
|
||||
// 300 Ko qui ne rend que 80 caractères l'est clairement.
|
||||
//
|
||||
// Sans ce message le modèle relançait la même URL en boucle (cf. le garde-fou
|
||||
// anti-boucle de chat_agent.go) : on lui dit explicitement de changer de source.
|
||||
if len(strings.TrimSpace(md)) < jsShellTextMax && len(body) > jsShellHTMLMin {
|
||||
return "", fmt.Errorf("page récupérée (%s de HTML) mais seulement %d caractères de texte : "+
|
||||
"elle est très probablement rendue en JavaScript, ce que le moteur web intégré n'exécute pas. "+
|
||||
"Ne réessaie PAS cette URL — cherche une autre source (documentation officielle, dépôt du projet, "+
|
||||
"article qui en parle)", formatBytes(len(body)), len(strings.TrimSpace(md)))
|
||||
}
|
||||
return md, nil
|
||||
}
|
||||
|
||||
// Seuils du diagnostic « rendu JavaScript » (voir goFetchMarkdown).
|
||||
const (
|
||||
jsShellTextMax = 200 // texte extrait en dessous duquel il n'y a rien à lire
|
||||
jsShellHTMLMin = 15000 // HTML au-dessus duquel « rien à lire » devient suspect
|
||||
)
|
||||
|
||||
// goArticleMarkdown applique Readability puis convertit le résultat. Renvoie du
|
||||
// markdown vide (sans erreur) si la page n'a pas de contenu d'article isolable.
|
||||
func goArticleMarkdown(doc *html.Node, base *url.URL) (md, title string) {
|
||||
// Readability mute l'arbre qu'on lui donne ; on lui passe une copie pour
|
||||
// pouvoir retomber sur le document complet en cas d'échec.
|
||||
clone, err := goCloneDoc(doc)
|
||||
if err != nil {
|
||||
return "", ""
|
||||
}
|
||||
art, err := readability.FromDocument(clone, base)
|
||||
if err != nil || art.Node == nil {
|
||||
return "", ""
|
||||
}
|
||||
out, err := goConvert(art.Node, baseString(base))
|
||||
if err != nil {
|
||||
return "", ""
|
||||
}
|
||||
return out, strings.TrimSpace(art.Title())
|
||||
}
|
||||
|
||||
// goConvert rend un nœud HTML en markdown, en réécrivant les liens et images
|
||||
// relatifs en URLs absolues (sinon l'IA ne peut pas les rouvrir).
|
||||
func goConvert(node *html.Node, baseURL string) (string, error) {
|
||||
opts := []converter.ConvertOptionFunc{}
|
||||
if baseURL != "" {
|
||||
opts = append(opts, converter.WithDomain(baseURL))
|
||||
}
|
||||
b, err := htmltomarkdown.ConvertNode(node, opts...)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("conversion markdown : %v", err)
|
||||
}
|
||||
return string(b), nil
|
||||
}
|
||||
|
||||
// goCloneDoc duplique un document HTML en le re-parsant depuis son rendu.
|
||||
func goCloneDoc(doc *html.Node) (*html.Node, error) {
|
||||
var sb strings.Builder
|
||||
if err := html.Render(&sb, doc); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return html.Parse(strings.NewReader(sb.String()))
|
||||
}
|
||||
|
||||
func baseString(u *url.URL) string {
|
||||
if u == nil {
|
||||
return ""
|
||||
}
|
||||
return u.Scheme + "://" + u.Host
|
||||
}
|
||||
|
||||
// ─── recherche DuckDuckGo (parsing HTML direct) ─────────────────────────────
|
||||
|
||||
// goSearch interroge le point d'entrée HTML de DuckDuckGo et lit les résultats
|
||||
// directement dans le DOM.
|
||||
//
|
||||
// Le chemin Crawl4AI passe par une conversion en markdown avant de reparser le
|
||||
// texte à coups de regex (duckduckgoSearch) ; ici on lit la structure réelle
|
||||
// (.result__a, .result__snippet), ce qui est nettement plus robuste.
|
||||
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)
|
||||
body, contentType, _, err := goFetch(ctx, target)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if strings.Contains(string(body), "anomaly-modal") || strings.Contains(string(body), "anomaly.js") {
|
||||
return nil, fmt.Errorf("DuckDuckGo a renvoyé un défi anti-bot")
|
||||
}
|
||||
doc, err := goParseHTML(body, contentType)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("HTML illisible : %v", err)
|
||||
}
|
||||
|
||||
var results []searchResult
|
||||
seen := map[string]bool{}
|
||||
for _, block := range goFindByClass(doc, "result") {
|
||||
if len(results) >= limit {
|
||||
break
|
||||
}
|
||||
link := goFirstByClass(block, "result__a")
|
||||
if link == nil {
|
||||
continue
|
||||
}
|
||||
href := decodeUddg(goAttr(link, "href"))
|
||||
title := goText(link)
|
||||
if title == "" || href == "" || strings.Contains(href, "duckduckgo.com") || seen[href] {
|
||||
continue
|
||||
}
|
||||
snippet := ""
|
||||
if s := goFirstByClass(block, "result__snippet"); s != nil {
|
||||
snippet = goText(s)
|
||||
}
|
||||
seen[href] = true
|
||||
results = append(results, searchResult{Title: title, URL: href, Snippet: snippet})
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// ─── petits helpers DOM (x/net/html, sans sélecteur CSS) ────────────────────
|
||||
|
||||
func goHasClass(n *html.Node, class string) bool {
|
||||
if n.Type != html.ElementNode {
|
||||
return false
|
||||
}
|
||||
for _, f := range strings.Fields(goAttr(n, "class")) {
|
||||
if f == class {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func goAttr(n *html.Node, name string) string {
|
||||
if n == nil {
|
||||
return ""
|
||||
}
|
||||
for _, a := range n.Attr {
|
||||
if a.Key == name {
|
||||
return a.Val
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// goFindByClass renvoie tous les nœuds portant la classe, sans descendre dans
|
||||
// un nœud déjà retenu (les résultats DDG ne s'imbriquent pas).
|
||||
func goFindByClass(root *html.Node, class string) []*html.Node {
|
||||
var out []*html.Node
|
||||
var walk func(*html.Node)
|
||||
walk = func(n *html.Node) {
|
||||
if goHasClass(n, class) {
|
||||
out = append(out, n)
|
||||
return
|
||||
}
|
||||
for c := n.FirstChild; c != nil; c = c.NextSibling {
|
||||
walk(c)
|
||||
}
|
||||
}
|
||||
walk(root)
|
||||
return out
|
||||
}
|
||||
|
||||
func goFirstByClass(root *html.Node, class string) *html.Node {
|
||||
var found *html.Node
|
||||
var walk func(*html.Node)
|
||||
walk = func(n *html.Node) {
|
||||
if found != nil {
|
||||
return
|
||||
}
|
||||
if goHasClass(n, class) {
|
||||
found = n
|
||||
return
|
||||
}
|
||||
for c := n.FirstChild; c != nil; c = c.NextSibling {
|
||||
walk(c)
|
||||
}
|
||||
}
|
||||
walk(root)
|
||||
return found
|
||||
}
|
||||
|
||||
// goText concatène le texte d'un sous-arbre, espaces normalisés.
|
||||
func goText(n *html.Node) string {
|
||||
var sb strings.Builder
|
||||
var walk func(*html.Node)
|
||||
walk = func(n *html.Node) {
|
||||
if n.Type == html.TextNode {
|
||||
sb.WriteString(n.Data)
|
||||
}
|
||||
for c := n.FirstChild; c != nil; c = c.NextSibling {
|
||||
walk(c)
|
||||
}
|
||||
}
|
||||
walk(n)
|
||||
return strings.TrimSpace(wsRe.ReplaceAllString(sb.String(), " "))
|
||||
}
|
||||
Reference in new issue
Block a user