File size: 6,327 Bytes
2695555
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
"""openTARS 5.1: pesquisa web pelo SearXNG (decisão do Enzo em 11/10). Módulo puro, só biblioteca padrão.

Regras (plano 5.1, "Pesquisa web integrada"):
  * Ligada por padrão (26/10); sem SearXNG respondendo, o pedido cai na busca do navegador. Ligar mostra o que sai: o texto da pesquisa vai para o SearXNG, que o repassa aos buscadores.
  * Com ela desligada, nada é enviado (as ferramentas nem aparecem).
  * O que vem das páginas é DADO, nunca instrução, e entra cortado (contexto pequeno).
  * Só texto: sem scripts, sem baixar arquivos. read_page não abre endereço local nem de rede interna (senão uma página
    podia fazer a IA ler o Ollama ou outro serviço do próprio PC).

A configuração fica em ~/.config/opentars/config.json ("web": {"ligada", "endereco"}).
"""

import html.parser
import ipaddress
import json
import socket
import urllib.error
import urllib.parse
import urllib.request

import tars_i18n

ENDERECO_PADRAO = "http://127.0.0.1:8888"
LIMITE_RESULTADOS = 8
LIMITE_PAGINA_BYTES = 1_000_000
LIMITE_TEXTO_PAGINA = 8000
TIMEOUT = 10
AGENTE = "openTARS/5.1 (+https://project-opentars.github.io/OpenTARS/)"


def config():
    bruto = tars_i18n.ler_config().get("web")
    bruto = bruto if isinstance(bruto, dict) else {}
    endereco = str(bruto.get("endereco") or ENDERECO_PADRAO).strip().rstrip("/")
    return {"ligada": bool(bruto.get("ligada", True)), "endereco": endereco}


def salvar_config(ligada, endereco=None):
    atual = config()
    tars_i18n.salvar_config(web={"ligada": bool(ligada), "endereco": (endereco or atual["endereco"]).strip().rstrip("/")})


def buscar(consulta, endereco=None, abrir=None):
    """[{'titulo', 'url', 'trecho'}] do SearXNG (formato JSON). Levanta ValueError com um motivo curto se falhar."""
    consulta = " ".join(str(consulta or "").split())[:300]
    if not consulta:
        raise ValueError("empty query")
    url = f"{(endereco or config()['endereco'])}/search?" + urllib.parse.urlencode({"q": consulta, "format": "json"})
    pedido = urllib.request.Request(url, headers={"User-Agent": AGENTE, "Accept": "application/json"})
    try:
        with (abrir or urllib.request.urlopen)(pedido, timeout=TIMEOUT) as r:
            dados = json.loads(r.read(LIMITE_PAGINA_BYTES) or b"{}")
    except urllib.error.HTTPError as e:
        raise ValueError("json format disabled in SearXNG (settings.yml: search.formats)" if e.code == 403 else f"HTTP {e.code}")
    except (OSError, ValueError) as e:
        raise ValueError(f"SearXNG not reachable: {e}")
    saida = []
    for item in (dados.get("results") or [])[:LIMITE_RESULTADOS]:
        if isinstance(item, dict) and str(item.get("url", "")).startswith(("http://", "https://")):
            saida.append({"titulo": " ".join(str(item.get("title") or "").split())[:200], "url": item["url"],
                          "trecho": " ".join(str(item.get("content") or "").split())[:400]})
    return saida


def endereco_publico(url, resolver=None):
    """True só para http(s) cujo nome resolve para endereços públicos (nada de 127.0.0.1, 192.168.x, ::1, .local...)."""
    partes = urllib.parse.urlsplit(str(url or ""))
    if partes.scheme not in ("http", "https") or not partes.hostname:
        return False
    try:
        enderecos = {info[4][0] for info in (resolver or socket.getaddrinfo)(partes.hostname, partes.port or 443)}
    except (OSError, UnicodeError):
        return False
    for e in enderecos:
        ip = ipaddress.ip_address(e.split("%")[0])
        if not ip.is_global or ip.is_multicast:
            return False
    return bool(enderecos)


class _Texto(html.parser.HTMLParser):
    _PULAR = {"script", "style", "noscript", "svg", "template", "head"}
    _BLOCO = {"p", "div", "br", "li", "h1", "h2", "h3", "h4", "h5", "h6", "tr", "section", "article", "header", "footer"}

    def __init__(self):
        super().__init__(convert_charrefs=True)
        self.partes, self._pulando = [], 0

    def handle_starttag(self, tag, attrs):
        if tag in self._PULAR:
            self._pulando += 1
        elif tag in self._BLOCO:
            self.partes.append("\n")

    def handle_endtag(self, tag):
        if tag in self._PULAR and self._pulando:
            self._pulando -= 1

    def handle_data(self, dados):
        if not self._pulando and dados.strip():
            self.partes.append(" ".join(dados.split()) + " ")


def texto_do_html(conteudo):
    p = _Texto()
    p.feed(conteudo)
    linhas = [" ".join(l.split()) for l in "".join(p.partes).splitlines()]
    return "\n".join(l for l in linhas if l)


class _SemRedirecionarParaDentro(urllib.request.HTTPRedirectHandler):
    def redirect_request(self, req, fp, code, msg, headers, newurl):
        if not endereco_publico(newurl):
            raise ValueError("redirect to a local address blocked")
        return super().redirect_request(req, fp, code, msg, headers, newurl)


def ler_pagina(url, abrir=None, resolver=None):
    """O texto de uma página pública (cortado). Levanta ValueError com o motivo."""
    if not endereco_publico(url, resolver):
        raise ValueError("only public http(s) addresses can be read")
    pedido = urllib.request.Request(url, headers={"User-Agent": AGENTE, "Accept": "text/html, text/plain"})
    abrir = abrir or urllib.request.build_opener(_SemRedirecionarParaDentro).open
    try:
        with abrir(pedido, timeout=TIMEOUT) as r:
            tipo = (r.headers.get("Content-Type") or "").lower()
            if not tipo.startswith(("text/html", "text/plain", "application/xhtml")):
                raise ValueError(f"not a text page ({tipo or 'unknown type'})")
            corpo = r.read(LIMITE_PAGINA_BYTES).decode(r.headers.get_content_charset() or "utf-8", errors="replace")
    except urllib.error.HTTPError as e:
        raise ValueError(f"HTTP {e.code}")
    except OSError as e:
        raise ValueError(str(e))
    texto = texto_do_html(corpo) if "html" in tipo else corpo
    return texto[:LIMITE_TEXTO_PAGINA] + ("\n(... cut)" if len(texto) > LIMITE_TEXTO_PAGINA else "")


CONFIG_SEARXNG = """# openTARS: SearXNG só nesta máquina, com o formato JSON ligado (o openTARS lê os resultados por ele)
use_default_settings: true
server:
  secret_key: "{segredo}"
  limiter: false
  image_proxy: false
search:
  formats:
    - html
    - json
"""