@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.
Files changed (78) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/LICENSE +202 -0
  3. package/NOTICE +32 -0
  4. package/README.md +116 -2
  5. package/atomic_io.py +94 -0
  6. package/autostart.py +199 -0
  7. package/bin/smart-tool.mjs +86 -0
  8. package/browser_service_client.py +232 -0
  9. package/capabilities.py +63 -0
  10. package/client_hooks.py +192 -0
  11. package/code_graph.py +403 -0
  12. package/code_graph_java.mjs +49 -0
  13. package/code_graph_js.cjs +135 -0
  14. package/config.py +241 -0
  15. package/daemon_launcher.py +165 -0
  16. package/directory_watch.py +131 -0
  17. package/document_text.py +69 -0
  18. package/duplicates.py +313 -0
  19. package/embedding_cache.py +99 -0
  20. package/endpoint_sync.py +179 -0
  21. package/folder_picker.py +73 -0
  22. package/freshness.py +278 -0
  23. package/gateway.py +220 -0
  24. package/hook_decision.py +96 -0
  25. package/hooks/pretool_router.py +45 -0
  26. package/index_inventory.py +75 -0
  27. package/index_profile.py +167 -0
  28. package/index_scope.py +458 -0
  29. package/index_views.py +121 -0
  30. package/indexer.py +878 -0
  31. package/install.py +353 -0
  32. package/install_runtime.py +283 -0
  33. package/integration_metrics.py +82 -0
  34. package/local_embedder.py +172 -0
  35. package/model_adapters/__init__.py +0 -0
  36. package/model_adapters/openai_compatible.py +57 -0
  37. package/model_catalog.py +74 -0
  38. package/model_client.py +38 -0
  39. package/model_defaults.py +70 -0
  40. package/package.json +33 -4
  41. package/paths.py +6 -0
  42. package/project_identity.py +42 -0
  43. package/project_insights_ui.py +38 -0
  44. package/project_maps_ui.py +96 -0
  45. package/project_monitor.py +204 -0
  46. package/project_scheduler.py +61 -0
  47. package/project_storage.py +165 -0
  48. package/project_store.py +220 -0
  49. package/projects_ui.py +146 -0
  50. package/requirements.txt +8 -0
  51. package/research_cache.py +171 -0
  52. package/research_metrics.py +110 -0
  53. package/router.py +582 -0
  54. package/secret_store.py +121 -0
  55. package/setup_ui.py +986 -0
  56. package/site_memory.py +53 -0
  57. package/smart_tool_daemon.py +3604 -0
  58. package/tray.py +245 -0
  59. package/update_check.py +22 -0
  60. package/usage_meter.py +124 -0
  61. package/version.py +18 -0
  62. package/view_compare.py +68 -0
  63. package/web_adapters/browser/browser_service.py +302 -0
  64. package/web_adapters/browser/crawl4ai_search.py +236 -0
  65. package/web_adapters/browser/fetch_camoufox.py +28 -0
  66. package/web_adapters/browser/requirements.txt +2 -0
  67. package/web_adapters/node/adapter-common.mjs +40 -0
  68. package/web_adapters/node/ddg-search.mjs +89 -0
  69. package/web_adapters/node/open-websearch-adapter.mjs +77 -0
  70. package/web_adapters/node/package-lock.json +2720 -0
  71. package/web_adapters/node/package.json +12 -0
  72. package/web_adapters/node/wikipedia-search.mjs +44 -0
  73. package/web_document_graph.py +71 -0
  74. package/web_fetch.py +242 -0
  75. package/web_search_adapters.py +193 -0
  76. package/web_search_ddgs.py +42 -0
  77. package/web_search_external.py +288 -0
  78. 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())