@allansantos-dev/smart-tool 0.0.0-stage → 0.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +35 -0
- package/LICENSE +202 -0
- package/NOTICE +32 -0
- package/README.md +116 -2
- package/atomic_io.py +94 -0
- package/autostart.py +199 -0
- package/bin/smart-tool.mjs +86 -0
- package/browser_service_client.py +232 -0
- package/capabilities.py +63 -0
- package/client_hooks.py +192 -0
- package/code_graph.py +403 -0
- package/code_graph_java.mjs +49 -0
- package/code_graph_js.cjs +135 -0
- package/config.py +241 -0
- package/daemon_launcher.py +165 -0
- package/directory_watch.py +131 -0
- package/document_text.py +69 -0
- package/duplicates.py +313 -0
- package/embedding_cache.py +99 -0
- package/endpoint_sync.py +179 -0
- package/folder_picker.py +73 -0
- package/freshness.py +278 -0
- package/gateway.py +220 -0
- package/hook_decision.py +96 -0
- package/hooks/pretool_router.py +45 -0
- package/index_inventory.py +75 -0
- package/index_profile.py +167 -0
- package/index_scope.py +458 -0
- package/index_views.py +121 -0
- package/indexer.py +878 -0
- package/install.py +353 -0
- package/install_runtime.py +283 -0
- package/integration_metrics.py +82 -0
- package/local_embedder.py +172 -0
- package/model_adapters/__init__.py +0 -0
- package/model_adapters/openai_compatible.py +57 -0
- package/model_catalog.py +74 -0
- package/model_client.py +38 -0
- package/model_defaults.py +70 -0
- package/package.json +33 -4
- package/paths.py +6 -0
- package/project_identity.py +42 -0
- package/project_insights_ui.py +38 -0
- package/project_maps_ui.py +96 -0
- package/project_monitor.py +204 -0
- package/project_scheduler.py +61 -0
- package/project_storage.py +165 -0
- package/project_store.py +220 -0
- package/projects_ui.py +146 -0
- package/requirements.txt +8 -0
- package/research_cache.py +171 -0
- package/research_metrics.py +110 -0
- package/router.py +582 -0
- package/secret_store.py +121 -0
- package/setup_ui.py +986 -0
- package/site_memory.py +53 -0
- package/smart_tool_daemon.py +3604 -0
- package/tray.py +245 -0
- package/update_check.py +22 -0
- package/usage_meter.py +124 -0
- package/version.py +18 -0
- package/view_compare.py +68 -0
- package/web_adapters/browser/browser_service.py +302 -0
- package/web_adapters/browser/crawl4ai_search.py +236 -0
- package/web_adapters/browser/fetch_camoufox.py +28 -0
- package/web_adapters/browser/requirements.txt +2 -0
- package/web_adapters/node/adapter-common.mjs +40 -0
- package/web_adapters/node/ddg-search.mjs +89 -0
- package/web_adapters/node/open-websearch-adapter.mjs +77 -0
- package/web_adapters/node/package-lock.json +2720 -0
- package/web_adapters/node/package.json +12 -0
- package/web_adapters/node/wikipedia-search.mjs +44 -0
- package/web_document_graph.py +71 -0
- package/web_fetch.py +242 -0
- package/web_search_adapters.py +193 -0
- package/web_search_ddgs.py +42 -0
- package/web_search_external.py +288 -0
- package/web_search_health.py +88 -0
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"private": true,
|
|
3
|
+
"name": "smart-tool-web-adapters",
|
|
4
|
+
"version": "0.9.0",
|
|
5
|
+
"description": "Adaptadores Node empacotados com a Smart Tool",
|
|
6
|
+
"dependencies": {
|
|
7
|
+
"@angular/compiler": "20.3.0",
|
|
8
|
+
"java-parser": "3.0.1",
|
|
9
|
+
"open-websearch": "2.1.11",
|
|
10
|
+
"typescript": "5.9.3"
|
|
11
|
+
}
|
|
12
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Uso: node wikipedia-search.mjs "consulta" [--num 8] [--lang pt]
|
|
3
|
+
// Saída: JSON no stdout — [{ title, url, snippet }, ...]
|
|
4
|
+
// API oficial da Wikipedia (MediaWiki `action=query&list=search`), não scraping de SERP --
|
|
5
|
+
// fonte de índice genuinamente diferente dos demais adaptadores (enciclopédica, sem
|
|
6
|
+
// desafio anti-bot), mas só cobre conteúdo enciclopédico -- não serve pra cotação/notícia
|
|
7
|
+
// do dia, por exemplo.
|
|
8
|
+
import { parseArgs, stripTags } from './adapter-common.mjs';
|
|
9
|
+
|
|
10
|
+
async function main() {
|
|
11
|
+
const { query, num, lang } = parseArgs(process.argv.slice(2),
|
|
12
|
+
'Usage: node wikipedia-search.mjs "query" [--num 8] [--lang pt]', { num: 8, lang: 'pt' });
|
|
13
|
+
|
|
14
|
+
const url = `https://${lang}.wikipedia.org/w/api.php?` + new URLSearchParams({
|
|
15
|
+
action: 'query', list: 'search', format: 'json', srlimit: String(num), srsearch: query,
|
|
16
|
+
});
|
|
17
|
+
const res = await fetch(url, {
|
|
18
|
+
headers: { 'user-agent': process.env.SMART_TOOL_USER_AGENT || 'SmartTool (local MCP search tool)' },
|
|
19
|
+
signal: AbortSignal.timeout(15000),
|
|
20
|
+
});
|
|
21
|
+
if (!res.ok) {
|
|
22
|
+
throw new Error(`Wikipedia answered HTTP ${res.status}`);
|
|
23
|
+
}
|
|
24
|
+
const data = await res.json();
|
|
25
|
+
const hits = data?.query?.search ?? [];
|
|
26
|
+
if (hits.length === 0) {
|
|
27
|
+
throw new Error(`No Wikipedia (${lang}) result for this query.`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const results = hits.slice(0, num).map((h) => ({
|
|
31
|
+
title: h.title,
|
|
32
|
+
url: `https://${lang}.wikipedia.org/wiki/${encodeURIComponent(h.title.replace(/ /g, '_'))}`,
|
|
33
|
+
snippet: stripTags(h.snippet || ''),
|
|
34
|
+
}));
|
|
35
|
+
process.stdout.write(JSON.stringify(results));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Nunca process.exit() aqui: ver mesma nota em ddg-search.mjs (handle assíncrono do
|
|
39
|
+
// AbortSignal.timeout ainda pendente na hora de um exit() imediato crasharia o libuv
|
|
40
|
+
// no Windows) -- só marca o exit code e deixa o event loop drenar sozinho.
|
|
41
|
+
main().catch((err) => {
|
|
42
|
+
console.error(String(err?.message ?? err));
|
|
43
|
+
process.exitCode = 1;
|
|
44
|
+
});
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Referências locais de HTML, CSS e Markdown; documentos textuais mantêm cobertura."""
|
|
2
|
+
import posixpath
|
|
3
|
+
import re
|
|
4
|
+
from html.parser import HTMLParser
|
|
5
|
+
from urllib.parse import unquote,urlsplit
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def analyze(sources,all_paths):
|
|
9
|
+
paths=set(all_paths);dependencies=[];diagnostics=[];parsed=[]
|
|
10
|
+
def reference(origin,value,line,kind):
|
|
11
|
+
if not value or value.startswith(('#','data:','javascript:','mailto:','tel:')):return
|
|
12
|
+
url=urlsplit(value)
|
|
13
|
+
if url.scheme or url.netloc:
|
|
14
|
+
dependencies.append({'source':origin,'target':None,'specifier':value[:300],'line':line,'kind':kind,'resolution':'external'});return
|
|
15
|
+
target=posixpath.normpath(posixpath.join(posixpath.dirname(origin),unquote(url.path))) if not url.path.startswith('/') else unquote(url.path).lstrip('/')
|
|
16
|
+
if target.startswith('../') or target==origin:return
|
|
17
|
+
found=target if target in paths else None
|
|
18
|
+
dependencies.append({'source':origin,'target':found,'specifier':value[:300],'line':line,'kind':kind,'resolution':'resolved' if found else 'unresolved'})
|
|
19
|
+
class Html(HTMLParser):
|
|
20
|
+
def handle_starttag(self,tag,attrs):
|
|
21
|
+
attrs=dict(attrs)
|
|
22
|
+
for attr in ('src','href','poster'):
|
|
23
|
+
if attr in attrs:reference(self.origin,attrs[attr],self.getpos()[0],'html_resource' if tag!='a' else 'document_link')
|
|
24
|
+
if attrs.get('srcset'):
|
|
25
|
+
for item in attrs['srcset'].split(','):
|
|
26
|
+
value=item.strip().split(' ')[0]
|
|
27
|
+
if value:reference(self.origin,value,self.getpos()[0],'html_resource')
|
|
28
|
+
def css_links(text):
|
|
29
|
+
# Scanner de tokens: ignora comentários e strings que apenas mencionam url().
|
|
30
|
+
i=0
|
|
31
|
+
def quoted(at):
|
|
32
|
+
quote=text[at];j=at+1;out=[]
|
|
33
|
+
while j<len(text):
|
|
34
|
+
if text[j]=='\\' and j+1<len(text):out.append(text[j+1]);j+=2;continue
|
|
35
|
+
if text[j]==quote:return ''.join(out),j+1
|
|
36
|
+
out.append(text[j]);j+=1
|
|
37
|
+
return ''.join(out),j
|
|
38
|
+
while i<len(text):
|
|
39
|
+
if text.startswith('/*',i):
|
|
40
|
+
end=text.find('*/',i+2);i=len(text) if end<0 else end+2;continue
|
|
41
|
+
if text[i] in ('"',"'"):
|
|
42
|
+
_,i=quoted(i);continue
|
|
43
|
+
match=re.match(r'(?i)(@import\s+|url\s*\()',text[i:])
|
|
44
|
+
if match and (i==0 or not (text[i-1].isalnum() or text[i-1] in '_-')):
|
|
45
|
+
start=i;i+=len(match[0])
|
|
46
|
+
while i<len(text) and text[i].isspace():i+=1
|
|
47
|
+
if i<len(text) and text[i] in ('"',"'"):value,i=quoted(i);yield value,text.count('\n',0,start)+1
|
|
48
|
+
elif match[0].lower().startswith('url'):
|
|
49
|
+
end=text.find(')',i)
|
|
50
|
+
if end<0:return
|
|
51
|
+
value=text[i:end].strip();i=end+1;yield value,text.count('\n',0,start)+1
|
|
52
|
+
continue
|
|
53
|
+
i+=1
|
|
54
|
+
for path,text in sources.items():
|
|
55
|
+
extension=posixpath.splitext(path)[1].lower()
|
|
56
|
+
if extension in ('.html','.htm'):
|
|
57
|
+
parser=Html(convert_charrefs=True);parser.origin=path
|
|
58
|
+
try:parser.feed(text);parsed.append(path)
|
|
59
|
+
except (ValueError,AssertionError):diagnostics.append({'path':path,'reason':'HTML could not be fully parsed.'})
|
|
60
|
+
elif extension in ('.css','.scss','.sass','.less'):
|
|
61
|
+
for target,line in css_links(text):reference(path,target,line,'style_resource')
|
|
62
|
+
parsed.append(path)
|
|
63
|
+
elif extension in ('.md','.markdown'):
|
|
64
|
+
in_fence=False
|
|
65
|
+
for line_no,line in enumerate(text.splitlines(),1):
|
|
66
|
+
if re.match(r'^\s*(```|~~~)',line):in_fence=not in_fence;continue
|
|
67
|
+
if in_fence:continue
|
|
68
|
+
line=re.sub(r'`[^`]*`','',line)
|
|
69
|
+
for match in re.finditer(r'!?\[[^\]]*\]\(<?([^\s)>]+)>?(?:\s+[^)]*)?\)',line):reference(path,match[1],line_no,'document_link')
|
|
70
|
+
parsed.append(path)
|
|
71
|
+
return {'dependencies':dependencies,'diagnostics':diagnostics,'parsed':parsed}
|
package/web_fetch.py
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
"""web_fetch: lê uma URL conhecida e devolve só a resposta ao pedido, feita pelo modelo mini do gateway.
|
|
2
|
+
|
|
3
|
+
Desenho copiado do WebFetch do Claude Code (Markdown + pedido num modelo separado; code.claude.com/docs/en/tools-reference)
|
|
4
|
+
e do OpenCode (`packages/opencode/src/tool/webfetch.ts`: limite de 5 MB, User-Agent de navegador). Medido em 2026-10-05
|
|
5
|
+
(docs/spikes/web_fetch_rejudge.py), 37 pares (URL, pedido) reais do WebFetch nativo, juiz cego contra a página
|
|
6
|
+
renderizada: melhor resposta 18 × 18 (1 empate), respondeu certo 22 × 16, caracteres devolvidos 35 mil × 72 mil;
|
|
7
|
+
3,6 s de mediana, navegador em 7 de 37 (páginas montadas por JavaScript). Repetições da mesma URL nos transcripts:
|
|
8
|
+
92 de 124 dentro de 24 h (PAGE_TTL_S).
|
|
9
|
+
"""
|
|
10
|
+
import codecs
|
|
11
|
+
import http.client
|
|
12
|
+
import ipaddress
|
|
13
|
+
import json
|
|
14
|
+
import socket
|
|
15
|
+
import ssl
|
|
16
|
+
import threading
|
|
17
|
+
import time
|
|
18
|
+
import urllib.parse
|
|
19
|
+
|
|
20
|
+
import trafilatura
|
|
21
|
+
|
|
22
|
+
import model_client
|
|
23
|
+
import research_cache
|
|
24
|
+
|
|
25
|
+
CACHE_NAMESPACE = "web_fetch"
|
|
26
|
+
PAGE_TTL_S = 86400
|
|
27
|
+
PAGE_MAX_CHARS = 60_000
|
|
28
|
+
MAX_DOWNLOAD_BYTES = 5 * 1024 * 1024
|
|
29
|
+
MAX_REDIRECTS = 5
|
|
30
|
+
HTTP_TIMEOUT_S = 20
|
|
31
|
+
READ_CHUNK = 64 * 1024
|
|
32
|
+
THIN_CHARS = 1500
|
|
33
|
+
PLACEHOLDER_SCAN_CHARS = 5000
|
|
34
|
+
MIN_CHARS = 200
|
|
35
|
+
ANSWER_MAX_TOKENS = 1500
|
|
36
|
+
FAILED_TTL_S = 600
|
|
37
|
+
PLACEHOLDERS = ("carregando", "loading...", "enable javascript", "habilite o javascript", "ative o javascript")
|
|
38
|
+
TEXT_TYPES = ("text/plain", "text/markdown", "text/x-markdown", "application/json", "text/csv", "application/xml",
|
|
39
|
+
"text/xml")
|
|
40
|
+
USER_AGENT = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) "
|
|
41
|
+
"Chrome/140.0 Safari/537.36")
|
|
42
|
+
ANSWER_SYSTEM = (
|
|
43
|
+
"You answer a request about one web page using ONLY the page content provided. Cover every item the request "
|
|
44
|
+
"asks for. Keep exact names, versions, numbers, prices, flags, code and URLs; quote the exact lines when the "
|
|
45
|
+
"request asks for exact values or wording. Say explicitly which requested items the page does not contain. "
|
|
46
|
+
"No preamble. Answer in the language of the request."
|
|
47
|
+
)
|
|
48
|
+
_failed_lock = threading.Lock()
|
|
49
|
+
_failed = {}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class FetchError(RuntimeError):
|
|
53
|
+
pass
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _ip_allowed(ip):
|
|
57
|
+
return ipaddress.ip_address(ip).is_global
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _public_address(host, port):
|
|
61
|
+
"""Resolves once; every address must be publicly routable (no private, loopback, link-local, CGNAT/Tailscale)."""
|
|
62
|
+
try:
|
|
63
|
+
infos = socket.getaddrinfo(host, port, type=socket.SOCK_STREAM)
|
|
64
|
+
except (socket.gaierror, UnicodeError):
|
|
65
|
+
return None
|
|
66
|
+
addresses = [info[4][0] for info in infos]
|
|
67
|
+
try:
|
|
68
|
+
if not addresses or not all(_ip_allowed(address) for address in addresses):
|
|
69
|
+
return None
|
|
70
|
+
except ValueError:
|
|
71
|
+
return None
|
|
72
|
+
return addresses[0]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _target(url):
|
|
76
|
+
try:
|
|
77
|
+
parsed = urllib.parse.urlsplit(url)
|
|
78
|
+
port = parsed.port or (443 if parsed.scheme == "https" else 80)
|
|
79
|
+
except ValueError:
|
|
80
|
+
return None
|
|
81
|
+
if parsed.scheme not in ("http", "https") or not parsed.hostname:
|
|
82
|
+
return None
|
|
83
|
+
address = _public_address(parsed.hostname, port)
|
|
84
|
+
return (parsed, port, address) if address else None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def is_safe_target(url):
|
|
88
|
+
return _target(url) is not None
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class _PinnedHTTP(http.client.HTTPConnection):
|
|
92
|
+
"""Connects to the address validated by _public_address, so a second DNS answer (rebinding) is never used."""
|
|
93
|
+
|
|
94
|
+
def __init__(self, host, port, address, timeout):
|
|
95
|
+
super().__init__(host, port, timeout=timeout)
|
|
96
|
+
self._address = address
|
|
97
|
+
|
|
98
|
+
def connect(self):
|
|
99
|
+
self.sock = socket.create_connection((self._address, self.port), self.timeout)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class _PinnedHTTPS(http.client.HTTPSConnection):
|
|
103
|
+
def __init__(self, host, port, address, timeout):
|
|
104
|
+
super().__init__(host, port, timeout=timeout, context=ssl.create_default_context())
|
|
105
|
+
self._address = address
|
|
106
|
+
|
|
107
|
+
def connect(self):
|
|
108
|
+
sock = socket.create_connection((self._address, self.port), self.timeout)
|
|
109
|
+
self.sock = self._context.wrap_socket(sock, server_hostname=self.host)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def normalize(url):
|
|
113
|
+
return urllib.parse.urldefrag(str(url or "").strip())[0]
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def mark_failed(key):
|
|
117
|
+
with _failed_lock:
|
|
118
|
+
now = time.monotonic()
|
|
119
|
+
for old in [k for k, at in _failed.items() if now - at >= FAILED_TTL_S]:
|
|
120
|
+
del _failed[old]
|
|
121
|
+
_failed[normalize(key)] = now
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def recently_failed(key):
|
|
125
|
+
with _failed_lock:
|
|
126
|
+
at = _failed.get(normalize(key))
|
|
127
|
+
return at is not None and time.monotonic() - at < FAILED_TTL_S
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _left(deadline):
|
|
131
|
+
left = deadline - time.monotonic()
|
|
132
|
+
if left <= 1:
|
|
133
|
+
raise TimeoutError("Deadline expired before reading the page.")
|
|
134
|
+
return left
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _get(url, deadline):
|
|
138
|
+
target = _target(url)
|
|
139
|
+
if target is None:
|
|
140
|
+
raise FetchError(f"URL rejected (internal, reserved or invalid network target): {url}")
|
|
141
|
+
parsed, port, address = target
|
|
142
|
+
cls = _PinnedHTTPS if parsed.scheme == "https" else _PinnedHTTP
|
|
143
|
+
conn = cls(parsed.hostname, port, address, min(HTTP_TIMEOUT_S, _left(deadline)))
|
|
144
|
+
path = urllib.parse.urlunsplit(("", "", parsed.path or "/", parsed.query, ""))
|
|
145
|
+
try:
|
|
146
|
+
conn.request("GET", path, headers={"User-Agent": USER_AGENT, "Accept-Language": "en,pt-BR;q=0.8",
|
|
147
|
+
"Accept": "text/markdown, text/html;q=0.9, text/plain;q=0.8, */*;q=0.5"})
|
|
148
|
+
sock = conn.sock
|
|
149
|
+
response = conn.getresponse()
|
|
150
|
+
if response.status in (301, 302, 303, 307, 308):
|
|
151
|
+
return response.status, response.getheader("Location"), None, None
|
|
152
|
+
if response.status >= 400:
|
|
153
|
+
raise FetchError(f"HTTP {response.status} at {url}")
|
|
154
|
+
body = bytearray()
|
|
155
|
+
while True:
|
|
156
|
+
if sock.fileno() != -1:
|
|
157
|
+
sock.settimeout(min(HTTP_TIMEOUT_S, _left(deadline)))
|
|
158
|
+
chunk = response.read(READ_CHUNK)
|
|
159
|
+
if not chunk:
|
|
160
|
+
break
|
|
161
|
+
body += chunk
|
|
162
|
+
if len(body) > MAX_DOWNLOAD_BYTES:
|
|
163
|
+
raise FetchError("Page larger than 5 MB.")
|
|
164
|
+
return response.status, None, response.headers, bytes(body)
|
|
165
|
+
except (OSError, http.client.HTTPException) as exc:
|
|
166
|
+
raise FetchError(f"Network failure at {url}: {exc}") from None
|
|
167
|
+
finally:
|
|
168
|
+
conn.close()
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def fetch_http(url, deadline):
|
|
172
|
+
"""GET with redirects followed by hand (every hop re-validated). Returns (final_url, content_type, charset, body)."""
|
|
173
|
+
current = url
|
|
174
|
+
for _ in range(MAX_REDIRECTS + 1):
|
|
175
|
+
status, location, headers, body = _get(current, deadline)
|
|
176
|
+
if headers is None:
|
|
177
|
+
if not location:
|
|
178
|
+
raise FetchError(f"HTTP {status} without a target at {current}")
|
|
179
|
+
current = urllib.parse.urljoin(current, location)
|
|
180
|
+
continue
|
|
181
|
+
return current, headers.get_content_type(), headers.get_content_charset(), body
|
|
182
|
+
raise FetchError(f"More than {MAX_REDIRECTS} redirects from {url}.")
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _decode(body, charset):
|
|
186
|
+
try:
|
|
187
|
+
codecs.lookup(charset or "utf-8")
|
|
188
|
+
except LookupError:
|
|
189
|
+
charset = None
|
|
190
|
+
return body.decode(charset or "utf-8", errors="replace")
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def thin(text):
|
|
194
|
+
stripped = text.strip()
|
|
195
|
+
if len(stripped) < THIN_CHARS:
|
|
196
|
+
return True
|
|
197
|
+
return len(stripped) < PLACEHOLDER_SCAN_CHARS and any(p in stripped[:600].lower() for p in PLACEHOLDERS)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def read_page(url, deadline, render):
|
|
201
|
+
"""Page text (Markdown) from the 24 h cache, HTTP + trafilatura, or `render(url, deadline)` when HTTP comes thin."""
|
|
202
|
+
url = normalize(url)
|
|
203
|
+
for entry in research_cache.candidates(CACHE_NAMESPACE, url, limit=1):
|
|
204
|
+
if entry.get("query") == url and time.time() - entry["saved_at"] < PAGE_TTL_S:
|
|
205
|
+
return {**entry["result"], "cached": True, "fetched_at": entry["saved_at"]}
|
|
206
|
+
final_url, kind, charset, body = fetch_http(url, deadline)
|
|
207
|
+
rendered, render_error = False, None
|
|
208
|
+
if "html" in kind:
|
|
209
|
+
# Bytes, not str: trafilatura detects the encoding declared in <meta charset> when the header has none.
|
|
210
|
+
text = trafilatura.extract(body, output_format="markdown", include_links=True, include_tables=True,
|
|
211
|
+
include_formatting=True, url=final_url) or ""
|
|
212
|
+
if thin(text):
|
|
213
|
+
try:
|
|
214
|
+
markdown = render(final_url, deadline)
|
|
215
|
+
except Exception as exc:
|
|
216
|
+
markdown, render_error = "", f"{type(exc).__name__}: {exc}"[:200]
|
|
217
|
+
if len(markdown.strip()) > len(text.strip()):
|
|
218
|
+
text, rendered = markdown, True
|
|
219
|
+
elif kind in TEXT_TYPES:
|
|
220
|
+
text = _decode(body, charset)
|
|
221
|
+
else:
|
|
222
|
+
raise FetchError(f"Content type {kind} is not read by web_fetch ({final_url}).")
|
|
223
|
+
if len(text.strip()) < MIN_CHARS:
|
|
224
|
+
detail = f"; browser failed: {render_error}" if render_error else ", not even when rendered in the browser"
|
|
225
|
+
raise FetchError(f"Page without readable text ({final_url}){detail}.")
|
|
226
|
+
page = {"url": url, "final_url": final_url, "text": text[:PAGE_MAX_CHARS], "truncated": len(text) > PAGE_MAX_CHARS,
|
|
227
|
+
"rendered": rendered}
|
|
228
|
+
saved_at = research_cache.put(CACHE_NAMESPACE, url, page, {})
|
|
229
|
+
return {**page, "cached": False, "fetched_at": saved_at or time.time(), "render_error": render_error}
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def answer(model, prompt, page, deadline):
|
|
233
|
+
response = model_client.fetch("/v1/chat/completions", model_client.get_token(), method="POST",
|
|
234
|
+
timeout=min(90, _left(deadline)), body={
|
|
235
|
+
"model": model, "temperature": 0, "max_tokens": ANSWER_MAX_TOKENS,
|
|
236
|
+
"messages": [{"role": "system", "content": ANSWER_SYSTEM},
|
|
237
|
+
{"role": "user", "content": json.dumps({"request": prompt, "page": page["text"]},
|
|
238
|
+
ensure_ascii=False)}]})
|
|
239
|
+
text = (response["choices"][0]["message"].get("content") or "").strip()
|
|
240
|
+
if not text:
|
|
241
|
+
raise RuntimeError("The model returned no answer for the page.")
|
|
242
|
+
return text
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Registro único dos adaptadores de motor de busca do `web_search`. Cada adaptador é um
|
|
3
|
+
subprocesso independente (executável + script) que recebe `[query, "--num", n, *extra_args]`
|
|
4
|
+
e devolve `[{title, url, snippet}, ...]` em JSON no stdout — mesmo contrato pra todos, então
|
|
5
|
+
`smart_tool_daemon._handle_web_search` itera a lista sem precisar saber o que cada um faz por
|
|
6
|
+
dentro. Acrescentar/remover/reordenar um motor é só editar `ADAPTERS`; `router.py` monta o
|
|
7
|
+
prompt/schema do modelo de roteamento a partir do campo `hint` de cada entrada, então também
|
|
8
|
+
não precisa mudar por adaptador novo.
|
|
9
|
+
|
|
10
|
+
Três etapas, marcadas por `fan_out` e `fallback_group` (ver ADR 0006):
|
|
11
|
+
|
|
12
|
+
- `fan_out=True` ("duckduckgo", "open-websearch", "wikipedia", "yahoo", "exa", "parallel", "keenable",
|
|
13
|
+
"google"): baratos e rápidos — `_handle_web_search` dispara todos em paralelo (google pelo navegador
|
|
14
|
+
residente). exa/parallel/keenable seguem o padrão de Hermes e OpenCode: plano público sem chave, chave
|
|
15
|
+
opcional do usuário no mesmo endpoint. e combina os resultados
|
|
16
|
+
de quem responder a tempo, pra garantir diversidade de fonte em toda chamada em vez de
|
|
17
|
+
parar no primeiro que responder.
|
|
18
|
+
- `fallback_group="http"` ("tavily", "firecrawl"): APIs com cota gratuita sem chave
|
|
19
|
+
e chave pessoal opcional; entram sob demanda se a cobertura ainda for insuficiente.
|
|
20
|
+
- `fallback_group="browser"` ("camoufox", "startpage"): navegador real, mais lento;
|
|
21
|
+
entra só quando as fontes anteriores ainda forem insuficientes, em sequência.
|
|
22
|
+
- `service_engine`: o tier roda no navegador Camoufox residente (browser_service.py), uma
|
|
23
|
+
instância com abas reaproveitadas para todos os consumidores, em vez de um script por chamada.
|
|
24
|
+
|
|
25
|
+
Sem entrada pra Bing: por períodos ele devolve um SERP genérico da entidade (ou de outro
|
|
26
|
+
assunto), sempre com 200 e sem erro. Em 2026-10-02 um cliente HTTP com TLS de navegador
|
|
27
|
+
recebeu resultados reais (44 de 50); em 2026-10-03 o mesmo cliente, o Camoufox e o Chromium
|
|
28
|
+
receberam a isca. Por não lançar erro, mascararia qualquer fallback depois dele; o índice do
|
|
29
|
+
Bing entra pelo tier yahoo.
|
|
30
|
+
"""
|
|
31
|
+
import os
|
|
32
|
+
import sys
|
|
33
|
+
|
|
34
|
+
PACKAGE_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
35
|
+
NODE_ADAPTER_DIR = os.path.join(PACKAGE_DIR, "web_adapters", "node")
|
|
36
|
+
BROWSER_ADAPTER_DIR = os.path.join(PACKAGE_DIR, "web_adapters", "browser")
|
|
37
|
+
LOCAL_NODE_EXECUTABLE = os.path.join(NODE_ADAPTER_DIR, ".node-runtime", "node.exe")
|
|
38
|
+
NODE_EXECUTABLE = LOCAL_NODE_EXECUTABLE if os.path.isfile(LOCAL_NODE_EXECUTABLE) else "node"
|
|
39
|
+
CAMOUFOX_VENV_PYTHON = os.path.join(BROWSER_ADAPTER_DIR, ".venv", "Scripts", "python.exe")
|
|
40
|
+
BROWSER_SERVICE_SCRIPT = os.path.join(BROWSER_ADAPTER_DIR, "browser_service.py")
|
|
41
|
+
DDG_SEARCH_SCRIPT = os.path.join(NODE_ADAPTER_DIR, "ddg-search.mjs")
|
|
42
|
+
OPEN_WEBSEARCH_ADAPTER_SCRIPT = os.path.join(NODE_ADAPTER_DIR, "open-websearch-adapter.mjs")
|
|
43
|
+
WIKIPEDIA_SEARCH_SCRIPT = os.path.join(NODE_ADAPTER_DIR, "wikipedia-search.mjs")
|
|
44
|
+
EXTERNAL_SEARCH_SCRIPT = os.path.join(PACKAGE_DIR, "web_search_external.py")
|
|
45
|
+
DDGS_SEARCH_SCRIPT = os.path.join(PACKAGE_DIR, "web_search_ddgs.py")
|
|
46
|
+
|
|
47
|
+
FETCH_TIMEOUT_S = 15
|
|
48
|
+
OPEN_WEBSEARCH_TIMEOUT_S = 30
|
|
49
|
+
WIKIPEDIA_TIMEOUT_S = 15
|
|
50
|
+
EXTERNAL_TIMEOUT_S = 18
|
|
51
|
+
DDGS_TIMEOUT_S = 15
|
|
52
|
+
GOOGLE_TIMEOUT_S = 12
|
|
53
|
+
BROWSER_TIMEOUT_S = 60
|
|
54
|
+
# O desafio do Startpage continua caro: duas buscas reais isoladas em 2026-09-29
|
|
55
|
+
# retornaram resultados em 101s e 154s, mesmo com o cookie curto da primeira ainda
|
|
56
|
+
# válido na segunda. Nenhum cookie semanal do Anubis foi emitido; o pré-aquecimento
|
|
57
|
+
# periódico foi removido porque não evitava o custo da busca seguinte. Este tier só
|
|
58
|
+
# roda como último fallback quando as fontes anteriores não cobrem a consulta.
|
|
59
|
+
STARTPAGE_TIMEOUT_S = 240
|
|
60
|
+
|
|
61
|
+
ADAPTERS = [
|
|
62
|
+
{
|
|
63
|
+
"name": "duckduckgo",
|
|
64
|
+
"executable": NODE_EXECUTABLE,
|
|
65
|
+
"script": DDG_SEARCH_SCRIPT,
|
|
66
|
+
"extra_args": [],
|
|
67
|
+
"timeout_s": FETCH_TIMEOUT_S,
|
|
68
|
+
"fan_out": True,
|
|
69
|
+
# Em inglês (não PT-BR): entra literal no system prompt do router_model em
|
|
70
|
+
# router.py, que é contrato com o modelo — ver convenção de idioma do AGENTS.md.
|
|
71
|
+
"hint": "plain fetch against DuckDuckGo HTML — fastest and cheapest, but fails "
|
|
72
|
+
"when DDG serves its JS anti-bot challenge (fetch never executes JS)",
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
"name": "open-websearch",
|
|
76
|
+
"executable": NODE_EXECUTABLE,
|
|
77
|
+
"script": OPEN_WEBSEARCH_ADAPTER_SCRIPT,
|
|
78
|
+
"extra_args": ["--engine", "duckduckgo"],
|
|
79
|
+
"timeout_s": OPEN_WEBSEARCH_TIMEOUT_S,
|
|
80
|
+
"fan_out": True,
|
|
81
|
+
"hint": "same DuckDuckGo index, via the open-websearch npm package's own HTTP "
|
|
82
|
+
"client — no browser, cheaper than camoufox; different access "
|
|
83
|
+
"technique than the plain-fetch duckduckgo tier",
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
"name": "wikipedia",
|
|
87
|
+
"executable": NODE_EXECUTABLE,
|
|
88
|
+
"script": WIKIPEDIA_SEARCH_SCRIPT,
|
|
89
|
+
"extra_args": [],
|
|
90
|
+
"timeout_s": WIKIPEDIA_TIMEOUT_S,
|
|
91
|
+
"fan_out": True,
|
|
92
|
+
"hint": "official Wikipedia search API, not a search-engine scrape — genuinely "
|
|
93
|
+
"different index (encyclopedic), no anti-bot risk at all, but narrow: "
|
|
94
|
+
"useless for time-sensitive queries (prices, news, weather)",
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
"name": "yahoo",
|
|
98
|
+
"executable": sys.executable,
|
|
99
|
+
"script": DDGS_SEARCH_SCRIPT,
|
|
100
|
+
"extra_args": ["--backend", "yahoo"],
|
|
101
|
+
"timeout_s": DDGS_TIMEOUT_S,
|
|
102
|
+
"fan_out": True,
|
|
103
|
+
"hint": "Yahoo SERP (Bing index) via the ddgs library — a different index than the "
|
|
104
|
+
"DuckDuckGo tiers, ~1 s, keyless; intermittently returns nothing",
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
"name": "exa",
|
|
108
|
+
"executable": sys.executable,
|
|
109
|
+
"script": EXTERNAL_SEARCH_SCRIPT,
|
|
110
|
+
"extra_args": ["--provider", "exa"],
|
|
111
|
+
"timeout_s": EXTERNAL_TIMEOUT_S,
|
|
112
|
+
"fan_out": True,
|
|
113
|
+
"hint": "Exa neural index via its public MCP (keyless free tier, optional user key) — strong on official docs and primary sources",
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
"name": "parallel",
|
|
117
|
+
"executable": sys.executable,
|
|
118
|
+
"script": EXTERNAL_SEARCH_SCRIPT,
|
|
119
|
+
"extra_args": ["--provider", "parallel"],
|
|
120
|
+
"timeout_s": EXTERNAL_TIMEOUT_S,
|
|
121
|
+
"fan_out": True,
|
|
122
|
+
"hint": "Parallel index via its public search MCP (keyless free tier, optional user key) — ranked excerpts, ~1 s",
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
"name": "keenable",
|
|
126
|
+
"executable": sys.executable,
|
|
127
|
+
"script": EXTERNAL_SEARCH_SCRIPT,
|
|
128
|
+
"extra_args": ["--provider", "keenable"],
|
|
129
|
+
"timeout_s": EXTERNAL_TIMEOUT_S,
|
|
130
|
+
"fan_out": True,
|
|
131
|
+
"hint": "Keenable independent index (own crawler and ranking) via its public endpoint, optional user key — ~0.3 s",
|
|
132
|
+
},
|
|
133
|
+
{
|
|
134
|
+
"name": "google",
|
|
135
|
+
"service_engine": "google",
|
|
136
|
+
"timeout_s": GOOGLE_TIMEOUT_S,
|
|
137
|
+
"fan_out": True,
|
|
138
|
+
"hint": "Google SERP via the resident Camoufox browser — best index, ~1-2 s once the "
|
|
139
|
+
"browser is open; one search at a time, paused for an hour after a captcha",
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
"name": "tavily",
|
|
143
|
+
"executable": sys.executable,
|
|
144
|
+
"script": EXTERNAL_SEARCH_SCRIPT,
|
|
145
|
+
"extra_args": ["--provider", "tavily"],
|
|
146
|
+
"timeout_s": EXTERNAL_TIMEOUT_S,
|
|
147
|
+
"fan_out": False,
|
|
148
|
+
"fallback_group": "http",
|
|
149
|
+
"hint": "independent ranked web search via Tavily; keyless quota is rate-limited, "
|
|
150
|
+
"so use only when existing results are incomplete or irrelevant",
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
"name": "firecrawl",
|
|
154
|
+
"executable": sys.executable,
|
|
155
|
+
"script": EXTERNAL_SEARCH_SCRIPT,
|
|
156
|
+
"extra_args": ["--provider", "firecrawl"],
|
|
157
|
+
"timeout_s": EXTERNAL_TIMEOUT_S,
|
|
158
|
+
"fan_out": False,
|
|
159
|
+
"fallback_group": "http",
|
|
160
|
+
"hint": "independent Firecrawl search API with clean snippets; keyless quota is "
|
|
161
|
+
"rate-limited, so use only when cheaper results remain insufficient",
|
|
162
|
+
},
|
|
163
|
+
{
|
|
164
|
+
"name": "camoufox",
|
|
165
|
+
"service_engine": "duckduckgo",
|
|
166
|
+
"timeout_s": BROWSER_TIMEOUT_S,
|
|
167
|
+
"fan_out": False,
|
|
168
|
+
"fallback_group": "browser",
|
|
169
|
+
"hint": "same DuckDuckGo index, via the resident Camoufox browser — solves the "
|
|
170
|
+
"anti-bot challenge the duckduckgo and open-websearch tiers can't",
|
|
171
|
+
},
|
|
172
|
+
{
|
|
173
|
+
"name": "startpage",
|
|
174
|
+
"service_engine": "startpage",
|
|
175
|
+
"timeout_s": STARTPAGE_TIMEOUT_S,
|
|
176
|
+
"fan_out": False,
|
|
177
|
+
"fallback_group": "browser",
|
|
178
|
+
"hint": "different index (Startpage, via the resident Camoufox browser) — the only fallback "
|
|
179
|
+
"that survives DuckDuckGo itself being down, not just the access "
|
|
180
|
+
"technique; subject to burst rate-limiting",
|
|
181
|
+
},
|
|
182
|
+
]
|
|
183
|
+
|
|
184
|
+
# Merge order of the round-robin. Measured 2026-10-03 (docs/spikes/merge_order_spike.py, 40 real queries, same
|
|
185
|
+
# collected results): this order beat the previous random order 16 x 5 (19 ties), SearXNG consensus scoring 14 x 13.
|
|
186
|
+
MERGE_PRIORITY = ["exa", "parallel", "google", "tavily", "firecrawl", "camoufox", "startpage", "keenable", "yahoo",
|
|
187
|
+
"duckduckgo", "open-websearch", "wikipedia", "memoria"]
|
|
188
|
+
DEFAULT_TIER_ORDER = [a["name"] for a in ADAPTERS]
|
|
189
|
+
SPECS = {a["name"]: a for a in ADAPTERS}
|
|
190
|
+
FAN_OUT_TIERS = [a["name"] for a in ADAPTERS if a["fan_out"]]
|
|
191
|
+
FALLBACK_TIERS = [a["name"] for a in ADAPTERS if not a["fan_out"]]
|
|
192
|
+
HTTP_FALLBACK_TIERS = [a["name"] for a in ADAPTERS if a.get("fallback_group") == "http"]
|
|
193
|
+
BROWSER_FALLBACK_TIERS = [a["name"] for a in ADAPTERS if a.get("fallback_group") == "browser"]
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Adaptador da biblioteca ddgs: um backend por chamada, sem chave."""
|
|
3
|
+
import json
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
from ddgs import DDGS
|
|
7
|
+
from ddgs.exceptions import DDGSException
|
|
8
|
+
|
|
9
|
+
sys.stdout.reconfigure(encoding="utf-8")
|
|
10
|
+
sys.stderr.reconfigure(encoding="utf-8")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def parse_args(argv):
|
|
14
|
+
# Manual parsing: the query is positional and may start with "-" (e.g. "-v flag in pytest").
|
|
15
|
+
options, query_parts, i = {"--num": "8", "--backend": None, "--timeout": "10"}, [], 0
|
|
16
|
+
while i < len(argv):
|
|
17
|
+
if argv[i] in options and i + 1 < len(argv):
|
|
18
|
+
options[argv[i]] = argv[i + 1]
|
|
19
|
+
i += 2
|
|
20
|
+
else:
|
|
21
|
+
query_parts.append(argv[i])
|
|
22
|
+
i += 1
|
|
23
|
+
if not query_parts or not options["--backend"]:
|
|
24
|
+
raise ValueError('Usage: python web_search_ddgs.py "query" --backend name [--num 8] [--timeout 10]')
|
|
25
|
+
return " ".join(query_parts), int(options["--num"]), options["--backend"], int(options["--timeout"])
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def main():
|
|
29
|
+
query, num, backend, timeout = parse_args(sys.argv[1:])
|
|
30
|
+
try:
|
|
31
|
+
rows = DDGS(timeout=timeout).text(query, max_results=num, backend=backend)
|
|
32
|
+
except DDGSException as exc:
|
|
33
|
+
print(f"{backend}: {exc}", file=sys.stderr)
|
|
34
|
+
return 1
|
|
35
|
+
results = [{"title": row.get("title", ""), "url": row.get("href", ""), "snippet": row.get("body", "")}
|
|
36
|
+
for row in rows if row.get("href")]
|
|
37
|
+
print(json.dumps(results[:num], ensure_ascii=False))
|
|
38
|
+
return 0
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
if __name__ == "__main__":
|
|
42
|
+
sys.exit(main())
|