neuronal / mind /web_tool.py
amogaddy's picture
amogaddyofficial
claude sonnet 4.6
Neuronal
d601f7c
Raw History Blame Contribute Delete
13.6 kB
"""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 <hex della domanda> [risultati]
python web_tool.py leggi <hex dell'indirizzo> [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"<meta[^>]+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(("<!doctype", "<html")):
titolo, corpo = html_a_testo(testo)
elif "json" in tipo:
titolo = ""
try:
corpo = json.dumps(json.loads(testo), ensure_ascii=False, indent=1)
except ValueError:
corpo = testo
else:
titolo, corpo = "", testo
if len(corpo) < 40:
print("La pagina non ha testo leggibile (forse si costruisce con JavaScript).")
return 1
taglio = len(corpo) > 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'<div class="result ', t)[1:]:
m = re.search(r'class="result__a"[^>]*href="([^"]+)"[^>]*>(.*?)</a>', 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"[^>]*>(.*?)</a>', 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'<li class="b_algo".*?</li>', t, re.S):
m = re.search(r'<h2[^>]*><a[^>]+href="([^"]+)"[^>]*>(.*?)</a>', 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"<p[^>]*>(.*?)</p>", 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 <hex> [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())