"""Strumenti web di Neuronal: cercare su internet e leggere una pagina. Lo lancia il motore come programma a parte, con i parametri scritti in esadecimale (niente problemi di virgolette): python web_tool.py cerca [risultati] python web_tool.py leggi [caratteri] Scrive su stdout il testo da dare al modello e esce con 0 se ha trovato qualcosa, 1 altrimenti. Solo libreria standard. Sicurezza: legge solo http e https, mai indirizzi interni (localhost, rete di casa, servizi dei provider di cloud): si controlla l'indirizzo vero a cui ci si collega e ogni passaggio di un reindirizzamento. Pagine e risposte hanno un tetto di dimensione e di tempo. Il testo delle pagine è materiale da leggere, non istruzioni: il motore lo dice al modello. Motori di ricerca, nell'ordine (il primo che risponde): un SearXNG tuo (NEURONAL_SEARXNG=https://...), DuckDuckGo, Bing, Wikipedia. Nessuna chiave o registrazione.""" import base64, html, http.client, ipaddress, json, os, re, socket, ssl, sys, time, urllib.parse from html.parser import HTMLParser sys.stdout.reconfigure(encoding="utf-8") UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0 Safari/537.36 Neuronal" MAX_BYTES = 2_500_000 TOTALE = 24.0 # secondi massimi per l'intera operazione T0 = time.time() def tempo(minimo=2.0): return max(minimo, TOTALE - (time.time() - T0)) # ---------------------------------------------------------------- rete sicura class Vietato(Exception): pass def indirizzo_ok(ip): a = ipaddress.ip_address(ip) if a.version == 6 and a.ipv4_mapped: a = a.ipv4_mapped return not (a.is_private or a.is_loopback or a.is_link_local or a.is_multicast or a.is_reserved or a.is_unspecified or str(a).startswith("100.64.") or str(a) == "169.254.169.254") def risolvi(host, porta): try: info = socket.getaddrinfo(host, porta, type=socket.SOCK_STREAM) except socket.gaierror: raise Vietato("indirizzo non trovato") ok = [i for i in info if indirizzo_ok(i[4][0])] if not ok or len(ok) != len(info): raise Vietato("indirizzo interno o non pubblico: non lo apro") return ok[0] def apri(url, dati=None, intestazioni=None, passaggi=4): """Scarica un indirizzo (GET, o POST se ci sono dati) seguendo i reindirizzamenti con gli stessi controlli. Ritorna (stato, intestazioni, byte, url finale).""" for _ in range(passaggi + 1): p = urllib.parse.urlsplit(url) if p.scheme not in ("http", "https") or not p.hostname: raise Vietato("solo indirizzi http e https") porta = p.port or (443 if p.scheme == "https" else 80) fam, tipo, proto, _, sa = risolvi(p.hostname, porta) s = socket.socket(fam, tipo, proto) s.settimeout(min(10, tempo())) s.connect(sa) # ci si collega all'indirizzo già controllato if p.scheme == "https": s = ssl.create_default_context().wrap_socket(s, server_hostname=p.hostname) conn = http.client.HTTPConnection(p.hostname, porta, timeout=min(15, tempo())) conn.sock = s path = (p.path or "/") + (("?" + p.query) if p.query else "") h = {"User-Agent": UA, "Accept": "text/html,application/xhtml+xml,application/json,text/plain;q=0.9,*/*;q=0.5", "Accept-Language": "it-IT,it;q=0.9,en;q=0.6", "Accept-Encoding": "identity", "Connection": "close"} h.update(intestazioni or {}) corpo = None if dati is not None: corpo = urllib.parse.urlencode(dati).encode() h["Content-Type"] = "application/x-www-form-urlencoded" conn.request("POST" if corpo else "GET", path, body=corpo, headers=h) r = conn.getresponse() if r.status in (301, 302, 303, 307, 308) and r.getheader("Location"): url = urllib.parse.urljoin(url, r.getheader("Location")) dati = None if r.status in (301, 302, 303) else dati conn.close() continue buf = bytearray() while len(buf) < MAX_BYTES and time.time() - T0 < TOTALE: pezzo = r.read(65536) if not pezzo: break buf += pezzo intest = {k.lower(): v for k, v in r.getheaders()} conn.close() return r.status, intest, bytes(buf), url raise Vietato("troppi reindirizzamenti") def decodifica(byte, intest): cs = re.search(r"charset=([\w-]+)", intest.get("content-type", ""), re.I) if not cs: m = re.search(rb"]+charset=[\"']?([\w-]+)", byte[:4096], re.I) cs = m and m.group(1).decode("ascii", "ignore") else: cs = cs.group(1) for enc in (cs, "utf-8", "latin-1"): try: return byte.decode(enc or "utf-8") except (UnicodeDecodeError, LookupError): continue return byte.decode("utf-8", "replace") # ---------------------------------------------------------------- da HTML a testo class Testo(HTMLParser): SALTA = {"script", "style", "noscript", "svg", "template", "iframe", "head", "nav", "footer", "form", "aside", "button"} A_CAPO = {"p", "div", "br", "li", "tr", "h1", "h2", "h3", "h4", "h5", "h6", "section", "article", "blockquote", "pre", "table", "ul", "ol", "dt", "dd"} def __init__(self): super().__init__(convert_charrefs=True) self.out, self.salta, self.titolo, self.in_titolo = [], 0, "", False def handle_starttag(self, tag, attrs): if tag == "title": self.in_titolo = True if tag in self.SALTA: self.salta += 1 if tag in self.A_CAPO and not self.salta: self.out.append("\n") if tag in ("h1", "h2", "h3") and not self.salta: self.out.append("## ") if tag == "li" and not self.salta: self.out.append("- ") def handle_endtag(self, tag): if tag == "title": self.in_titolo = False if tag in self.SALTA and self.salta: self.salta -= 1 if tag in self.A_CAPO and not self.salta: self.out.append("\n") def handle_data(self, d): if self.in_titolo: self.titolo += d elif not self.salta: self.out.append(d) def html_a_testo(testo): p = Testo() try: p.feed(testo) except Exception: pass t = "".join(p.out) t = re.sub(r"[ \t\r\f\v]+", " ", t) t = re.sub(r" ?\n ?", "\n", t) t = re.sub(r"\n{3,}", "\n\n", t).strip() return p.titolo.strip(), t # ---------------------------------------------------------------- leggere una pagina def leggi(url, massimo): if not re.match(r"^[a-z][a-z0-9+.-]*://", url, re.I): url = "https://" + url stato, intest, byte, finale = apri(url) tipo = intest.get("content-type", "").split(";")[0].strip().lower() if stato >= 400: print(f"La pagina ha risposto con errore {stato}.") return 1 if tipo == "application/pdf" or byte[:5] == b"%PDF-": print("È un PDF: da qui non lo leggo. Scarica il file e allegalo alla chat, poi lo leggo da lì.") return 1 if tipo.startswith("image/") or tipo.startswith(("audio/", "video/")) or tipo == "application/octet-stream": print(f"È un file di tipo {tipo or 'sconosciuto'}, non una pagina di testo.") return 1 testo = decodifica(byte, intest) if "html" in tipo or "xml" in tipo or testo.lstrip()[:15].lower().startswith((" massimo corpo = corpo[:massimo] print(f"Pagina: {finale}") if titolo: print(f"Titolo: {titolo}") print("---") print(corpo) if taglio: print(f"\n… (pagina più lunga: letti i primi {massimo} caratteri)") return 0 # ---------------------------------------------------------------- cercare def pulisci(t): return re.sub(r"\s+", " ", html.unescape(re.sub(r"<[^>]+>", "", t or ""))).strip() def cerca_searxng(q, n): base = os.environ.get("NEURONAL_SEARXNG", "").rstrip("/") if not base: return [] st, _, b, _ = apri(base + "/search?" + urllib.parse.urlencode({"q": q, "format": "json", "language": "it"})) if st != 200: return [] return [(r.get("title", ""), r.get("url", ""), r.get("content", "")) for r in json.loads(b).get("results", [])[:n]] def cerca_ddg(q, n): st, _, b, _ = apri("https://html.duckduckgo.com/html/", dati={"q": q, "kl": "it-it"}) t = b.decode("utf-8", "replace") if st != 200 or "result__a" not in t: return [] out = [] for blocco in re.split(r'
]*href="([^"]+)"[^>]*>(.*?)', blocco, re.S) if not m: continue url = html.unescape(m.group(1)) if "uddg=" in url: url = urllib.parse.unquote(urllib.parse.parse_qs(urllib.parse.urlsplit(url.replace("//duckduckgo.com", "https://duckduckgo.com", 1) if url.startswith("//") else url).query).get("uddg", [url])[0]) s = re.search(r'class="result__snippet"[^>]*>(.*?)', blocco, re.S) if "duckduckgo.com/y.js" in url: # pubblicità continue out.append((pulisci(m.group(2)), url, pulisci(s.group(1) if s else ""))) if len(out) >= n: break return out def cerca_bing(q, n): st, _, b, _ = apri("https://www.bing.com/search?" + urllib.parse.urlencode({"q": q, "setlang": "it", "cc": "IT"})) t = b.decode("utf-8", "replace") if st != 200: return [] out = [] for blocco in re.findall(r'
  • ', t, re.S): m = re.search(r']*>]+href="([^"]+)"[^>]*>(.*?)', blocco, re.S) if not m: continue url = html.unescape(m.group(1)) if "bing.com/ck/a" in url: # indirizzo avvolto in un reindirizzamento u = urllib.parse.parse_qs(urllib.parse.urlsplit(url).query).get("u", [""])[0] if u.startswith("a1"): try: url = base64.urlsafe_b64decode(u[2:] + "=" * (-len(u[2:]) % 4)).decode() except Exception: pass s = re.search(r"]*>(.*?)

    ", blocco, re.S) out.append((pulisci(m.group(2)), url, pulisci(s.group(1) if s else ""))) if len(out) >= n: break return out def cerca_wikipedia(q, n): out = [] for lingua in ("it", "en"): st, _, b, _ = apri(f"https://{lingua}.wikipedia.org/w/api.php?" + urllib.parse.urlencode( {"action": "query", "list": "search", "srsearch": q, "format": "json", "srlimit": n, "utf8": 1})) if st != 200: continue for r in json.loads(b)["query"]["search"]: titolo = r["title"] out.append((titolo + f" (Wikipedia {lingua})", f"https://{lingua}.wikipedia.org/wiki/" + urllib.parse.quote(titolo.replace(" ", "_")), pulisci(r.get("snippet", "")))) if out: break return out[:n] def cerca(q, n): motori = [("SearXNG", cerca_searxng), ("DuckDuckGo", cerca_ddg), ("Bing", cerca_bing), ("Wikipedia", cerca_wikipedia)] errori = [] for nome, fn in motori: if time.time() - T0 > TOTALE - 3: break try: r = fn(q, n) except Exception as e: errori.append(f"{nome}: {type(e).__name__}") continue r = [x for x in r if x[1].startswith("http")] if r: print(f"Risultati per «{q}» ({nome}):") for i, (t, u, s) in enumerate(r, 1): print(f"{i}. {t}\n {u}\n {s[:300]}") print("\nPer leggere una pagina per intero usa leggi_pagina con il suo indirizzo.") return 0 print(f"Nessun risultato per «{q}»." + (f" (motori non raggiungibili: {', '.join(errori)})" if errori else "")) return 1 def main(): if len(sys.argv) < 3 or sys.argv[1] not in ("cerca", "leggi"): print("uso: web_tool.py cerca|leggi [numero]") return 2 try: arg = bytes.fromhex(sys.argv[2]).decode("utf-8").strip() except ValueError: print("parametro non valido") return 2 extra = int(sys.argv[3]) if len(sys.argv) > 3 and sys.argv[3].isdigit() else None if not arg: print("manca il testo da cercare" if sys.argv[1] == "cerca" else "manca l'indirizzo") return 2 try: if sys.argv[1] == "cerca": return cerca(arg[:300], max(1, min(extra or 6, 10))) return leggi(arg[:2000], max(500, min(extra or 7000, 20000))) except Vietato as e: print(f"Non posso aprirlo: {e}.") return 1 except (socket.timeout, TimeoutError): print("La pagina non ha risposto in tempo.") return 1 except (OSError, http.client.HTTPException, ssl.SSLError) as e: print(f"Non riesco a collegarmi ({type(e).__name__}).") return 1 if __name__ == "__main__": sys.exit(main())