@allansantos-dev/smart-tool 0.0.0-stage → 0.9.0

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 (77) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/LICENSE +202 -0
  3. package/NOTICE +32 -0
  4. package/README.md +116 -3
  5. package/atomic_io.py +94 -0
  6. package/autostart.py +199 -0
  7. package/bin/smart-tool.mjs +82 -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 +251 -0
  32. package/install_runtime.py +249 -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 +35 -6
  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/requirements.txt +2 -0
  66. package/web_adapters/node/adapter-common.mjs +40 -0
  67. package/web_adapters/node/ddg-search.mjs +89 -0
  68. package/web_adapters/node/open-websearch-adapter.mjs +77 -0
  69. package/web_adapters/node/package-lock.json +2720 -0
  70. package/web_adapters/node/package.json +12 -0
  71. package/web_adapters/node/wikipedia-search.mjs +44 -0
  72. package/web_document_graph.py +71 -0
  73. package/web_fetch.py +242 -0
  74. package/web_search_adapters.py +193 -0
  75. package/web_search_ddgs.py +42 -0
  76. package/web_search_external.py +288 -0
  77. package/web_search_health.py +88 -0
@@ -0,0 +1,302 @@
1
+ #!/usr/bin/env python3
2
+ """Navegador Camoufox residente: uma instância, abas reaproveitadas, protocolo JSON por linha.
3
+
4
+ Entrada (stdin): {"id": str, "engine": "google|duckduckgo|startpage", "query": str, "num": int, "expires_at": epoch}
5
+ Saída (stdout): {"ready": true} ao subir; depois {"id": str, "results": [...]} ou
6
+ {"id": str, "error": str, "captcha": bool, "skipped": bool}.
7
+ Encerra sozinho após IDLE_S sem pedidos, quando o stdin fecha ou quando o navegador cai.
8
+ """
9
+ import asyncio
10
+ import json
11
+ import os
12
+ import sys
13
+ import time
14
+ import urllib.parse
15
+ import uuid
16
+
17
+ TABS = 2
18
+ IDLE_S = 300
19
+ PAGE_OP_TIMEOUT_S = 10
20
+ # Google flagged the IP after ~150 searches in bursts of 4; 40 sequential searches ~4 s apart passed.
21
+ MIN_GAP_S = {"google": 4.0}
22
+ # Startpage's Anubis challenge can hold a tab for minutes; never let it take every tab.
23
+ MAX_CONCURRENT = {"startpage": 1}
24
+ SESSION_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "sessions", "browser.storage_state.json")
25
+
26
+ GOOGLE_JS = (
27
+ "() => [...document.querySelectorAll('#search a:has(h3)')].map(a => { "
28
+ "const box = a.closest('[data-hveid], .g') || a.parentElement; "
29
+ "const s = box && box.querySelector('.VwiC3b, [data-sncf], [style*=\"line-clamp\"]'); "
30
+ "return {url: a.href, title: a.querySelector('h3').innerText, snippet: s ? s.innerText : ''}; })"
31
+ )
32
+ DDG_JS = (
33
+ "() => [...document.querySelectorAll('.result')].map(el => { "
34
+ "const a = el.querySelector('.result__a'); const s = el.querySelector('.result__snippet'); "
35
+ "return {url: a ? (a.getAttribute('href') || '') : '', title: a ? (a.textContent || '').trim() : '', "
36
+ "snippet: s ? (s.textContent || '').trim() : ''}; }).filter(r => r.url)"
37
+ )
38
+ STARTPAGE_JS = (
39
+ "() => [...document.querySelectorAll('.result')].map(el => { "
40
+ "const t = el.querySelector('[class*=\"result-title\" i]'); const a = t || el.querySelector('a'); "
41
+ "const s = el.querySelector('p, [class*=\"description\" i], [class*=\"snippet\" i]'); "
42
+ "return {url: a ? a.href : '', title: a ? (a.textContent || '').trim() : '', "
43
+ "snippet: s ? (s.textContent || '').trim() : ''}; }).filter(r => r.url)"
44
+ )
45
+ STARTPAGE_CHALLENGE_MARKERS = ("anubis", "verifying your request", "making sure you're not a bot")
46
+
47
+
48
+ class Captcha(Exception):
49
+ pass
50
+
51
+
52
+ class Skipped(Exception):
53
+ pass
54
+
55
+
56
+ def _ddg_real_url(href):
57
+ parsed = urllib.parse.urlparse(href if href.startswith("http") else f"https:{href}")
58
+ target = urllib.parse.parse_qs(parsed.query).get("uddg")
59
+ return target[0] if target else href
60
+
61
+
62
+ async def _body_text(page):
63
+ return (await page.evaluate("document.body ? document.body.innerText : ''"))[:3000].lower()
64
+
65
+
66
+ async def google(page, query, timeout_s):
67
+ await page.goto("https://www.google.com/search?" + urllib.parse.urlencode({"hl": "en", "q": query}),
68
+ wait_until="domcontentloaded", timeout=timeout_s * 1000)
69
+ if "/sorry/" in page.url:
70
+ raise Captcha("google: /sorry/ page (unusual traffic)")
71
+ try:
72
+ await page.wait_for_selector("#search a:has(h3)", timeout=min(4, timeout_s) * 1000)
73
+ except Exception:
74
+ if "unusual traffic" in await _body_text(page):
75
+ raise Captcha("google: unusual traffic")
76
+ rows = await page.evaluate(GOOGLE_JS)
77
+ targets = await asyncio.gather(*(_google_target(page, row["url"]) for row in rows))
78
+ return [{**row, "url": target} for row, target in zip(rows, targets) if target]
79
+
80
+
81
+ async def _google_target(page, href):
82
+ # Result links are opaque /goto?url=... redirects; the session resolves them without following.
83
+ parsed = urllib.parse.urlparse(href)
84
+ if not parsed.netloc.endswith("google.com") or not parsed.path.startswith(("/goto", "/url")):
85
+ return href
86
+ try:
87
+ response = await page.context.request.get(href, max_redirects=0, timeout=5000)
88
+ except Exception:
89
+ return None
90
+ return response.headers.get("location")
91
+
92
+
93
+ async def duckduckgo(page, query, timeout_s):
94
+ await page.goto("https://html.duckduckgo.com/html/?" + urllib.parse.urlencode({"q": query}),
95
+ wait_until="domcontentloaded", timeout=timeout_s * 1000)
96
+ try:
97
+ await page.wait_for_selector(".result__a", timeout=min(8, timeout_s) * 1000)
98
+ except Exception:
99
+ if "bots use duckduckgo too" in await _body_text(page):
100
+ raise Captcha("duckduckgo: image challenge")
101
+ return [{**row, "url": _ddg_real_url(row["url"])} for row in await page.evaluate(DDG_JS)]
102
+
103
+
104
+ async def startpage(page, query, timeout_s):
105
+ started = time.monotonic()
106
+ await page.goto("https://www.startpage.com/sp/search?" + urllib.parse.urlencode({"query": query}),
107
+ wait_until="domcontentloaded", timeout=timeout_s * 1000)
108
+ while True:
109
+ rows = await page.evaluate(STARTPAGE_JS)
110
+ if rows:
111
+ return rows
112
+ text = await _body_text(page)
113
+ challenged = any(marker in text for marker in STARTPAGE_CHALLENGE_MARKERS)
114
+ if not challenged and time.monotonic() - started > 5:
115
+ return []
116
+ if time.monotonic() - started > timeout_s - 2:
117
+ return []
118
+ await asyncio.sleep(1)
119
+
120
+
121
+ ENGINES = {"google": google, "duckduckgo": duckduckgo, "startpage": startpage}
122
+
123
+
124
+ class Service:
125
+ def __init__(self, context, emit, exit_process):
126
+ self.context = context
127
+ self.emit = emit
128
+ self.exit_process = exit_process
129
+ self.pages = asyncio.Queue()
130
+ self.page_lock = asyncio.Lock()
131
+ self.pace_locks = {engine: asyncio.Lock() for engine in MIN_GAP_S}
132
+ self.last_run = {}
133
+ self.limits = {engine: asyncio.Semaphore(n) for engine, n in MAX_CONCURRENT.items()}
134
+ self.last_activity = time.monotonic()
135
+ self.in_flight = 0
136
+
137
+ async def start(self):
138
+ for _ in range(TABS):
139
+ self.pages.put_nowait(await self._new_page())
140
+
141
+ async def _new_page(self):
142
+ # Opening several tabs at once hangs Camoufox navigations; tabs are created one at a time.
143
+ async with self.page_lock:
144
+ page = await asyncio.wait_for(self.context.new_page(), PAGE_OP_TIMEOUT_S)
145
+ await asyncio.wait_for(page.goto("about:blank"), PAGE_OP_TIMEOUT_S)
146
+ return page
147
+
148
+ async def _recycle(self, page, healthy):
149
+ try:
150
+ if not healthy:
151
+ raise asyncio.TimeoutError
152
+ await asyncio.wait_for(page.goto("about:blank"), PAGE_OP_TIMEOUT_S)
153
+ return page
154
+ except Exception:
155
+ try:
156
+ await asyncio.wait_for(page.close(), PAGE_OP_TIMEOUT_S)
157
+ except Exception:
158
+ pass
159
+ try:
160
+ return await self._new_page()
161
+ except Exception as exc:
162
+ print(f"tab could not be recreated; browser shut down: {exc}", file=sys.stderr, flush=True)
163
+ self.exit_process(1)
164
+
165
+ async def save_session(self):
166
+ try:
167
+ os.makedirs(os.path.dirname(SESSION_PATH), exist_ok=True)
168
+ tmp = f"{SESSION_PATH}.{uuid.uuid4().hex}.tmp"
169
+ state = await asyncio.wait_for(self.context.storage_state(), PAGE_OP_TIMEOUT_S)
170
+ with open(tmp, "w", encoding="utf-8") as stream:
171
+ json.dump(state, stream)
172
+ os.replace(tmp, SESSION_PATH)
173
+ except Exception as exc:
174
+ print(f"session not saved: {type(exc).__name__}: {exc}", file=sys.stderr, flush=True)
175
+
176
+ def _left(self, request):
177
+ return float(request["expires_at"]) - time.time()
178
+
179
+ async def _pace(self, engine, request):
180
+ gap = MIN_GAP_S.get(engine)
181
+ if not gap:
182
+ return
183
+ async with self.pace_locks[engine]:
184
+ wait = self.last_run.get(engine, 0) + gap - time.monotonic()
185
+ if wait > 0:
186
+ if wait >= self._left(request) - 1:
187
+ raise Skipped(f"{engine}: minimum interval between searches exceeds the deadline")
188
+ await asyncio.sleep(wait)
189
+ self.last_run[engine] = time.monotonic()
190
+
191
+ async def handle(self, request):
192
+ self.in_flight += 1
193
+ try:
194
+ self.emit(await self._run(request))
195
+ finally:
196
+ self.in_flight -= 1
197
+ self.last_activity = time.monotonic()
198
+
199
+ async def _run(self, request):
200
+ engine_name = request.get("engine")
201
+ if engine_name not in ENGINES:
202
+ return {"id": request.get("id"), "error": f"unknown engine: {engine_name!r}", "captcha": False}
203
+ limit = self.limits.get(engine_name)
204
+ try:
205
+ if limit:
206
+ await asyncio.wait_for(limit.acquire(), max(0.01, self._left(request)))
207
+ try:
208
+ return await self._run_on_tab(request, engine_name)
209
+ finally:
210
+ if limit:
211
+ limit.release()
212
+ except asyncio.TimeoutError:
213
+ return self._skipped(request, f"{engine_name}: another search holds the engine past the deadline")
214
+ except Skipped as exc:
215
+ return self._skipped(request, str(exc))
216
+
217
+ def _skipped(self, request, reason):
218
+ return {"id": request.get("id"), "error": reason, "captcha": False, "skipped": True}
219
+
220
+ async def _run_on_tab(self, request, engine_name):
221
+ try:
222
+ page = await asyncio.wait_for(self.pages.get(), max(0.01, self._left(request)))
223
+ except asyncio.TimeoutError:
224
+ raise Skipped(f"{engine_name}: no free tab before the deadline")
225
+ healthy = True
226
+ try:
227
+ await self._pace(engine_name, request)
228
+ left = self._left(request)
229
+ if left < 1:
230
+ raise Skipped(f"{engine_name}: deadline expired before running")
231
+ rows = await asyncio.wait_for(ENGINES[engine_name](page, request["query"], left), left)
232
+ rows = [r for r in rows if str(r.get("url", "")).startswith("http")][:int(request.get("num") or 8)]
233
+ if rows and engine_name == "startpage":
234
+ await self.save_session()
235
+ return {"id": request["id"], "results": rows}
236
+ except Skipped:
237
+ raise
238
+ except Captcha as exc:
239
+ return {"id": request["id"], "error": str(exc), "captcha": True}
240
+ except Exception as exc:
241
+ healthy = not isinstance(exc, asyncio.TimeoutError)
242
+ return {"id": request["id"], "error": f"{engine_name}: {type(exc).__name__}: {exc}"[:400],
243
+ "captcha": False}
244
+ finally:
245
+ self.pages.put_nowait(await self._recycle(page, healthy))
246
+
247
+
248
+ def emit(message):
249
+ sys.stdout.write(json.dumps(message, ensure_ascii=False) + "\n")
250
+ sys.stdout.flush()
251
+
252
+
253
+ async def main():
254
+ from camoufox.async_api import AsyncCamoufox
255
+
256
+ loop = asyncio.get_running_loop()
257
+ browser = await AsyncCamoufox(headless=True).__aenter__()
258
+ browser.on("disconnected", lambda *_: os._exit(1))
259
+ state = None
260
+ try:
261
+ with open(SESSION_PATH, encoding="utf-8") as stream:
262
+ state = json.load(stream)
263
+ except (OSError, ValueError):
264
+ pass
265
+ service = Service(await browser.new_context(storage_state=state), emit, os._exit)
266
+ await service.start()
267
+ emit({"ready": True})
268
+ tasks = set()
269
+ reader = loop.run_in_executor(None, sys.stdin.readline)
270
+ while True:
271
+ done, _ = await asyncio.wait({reader}, timeout=5)
272
+ if not done:
273
+ if service.in_flight == 0 and time.monotonic() - service.last_activity > IDLE_S:
274
+ break
275
+ continue
276
+ line = reader.result()
277
+ if not line:
278
+ break
279
+ reader = loop.run_in_executor(None, sys.stdin.readline)
280
+ try:
281
+ request = json.loads(line)
282
+ if not isinstance(request, dict):
283
+ raise ValueError("request is not an object")
284
+ except ValueError:
285
+ print(f"invalid line ignored: {line[:200]!r}", file=sys.stderr, flush=True)
286
+ continue
287
+ service.last_activity = time.monotonic()
288
+ task = asyncio.create_task(service.handle(request))
289
+ tasks.add(task)
290
+ task.add_done_callback(tasks.discard)
291
+ if tasks:
292
+ await asyncio.wait(tasks, timeout=10)
293
+ try:
294
+ await asyncio.wait_for(browser.close(), PAGE_OP_TIMEOUT_S)
295
+ finally:
296
+ os._exit(0)
297
+
298
+
299
+ if __name__ == "__main__":
300
+ sys.stdout.reconfigure(encoding="utf-8")
301
+ sys.stderr.reconfigure(encoding="utf-8")
302
+ asyncio.run(main())
@@ -0,0 +1,236 @@
1
+ #!/usr/bin/env python3
2
+ # Uso: python crawl4ai_search.py "<url semente>" [--max-pages 5]
3
+ # Saida: JSON no stdout -- {"pages": [{"url": ..., "markdown": ...}, ...]}
4
+ # Tier 4 do web_search do Smart Tool: crawl multi-pagina (BFS, mesmo dominio) a partir
5
+ # de uma URL ja resolvida pelo chamador (nao faz busca — quem decide a URL semente e o
6
+ # daemon, via Tier 1, antes de invocar este script).
7
+ import asyncio
8
+ import ipaddress
9
+ import json
10
+ import socket
11
+ import sys
12
+ import urllib.error
13
+ import urllib.parse
14
+ import urllib.request
15
+
16
+ from crawl4ai import AsyncWebCrawler, BFSDeepCrawlStrategy, CacheMode, CrawlerRunConfig
17
+ from crawl4ai.async_crawler_strategy import AsyncPlaywrightCrawlerStrategy
18
+ from crawl4ai.deep_crawling.filters import FilterChain, URLFilter
19
+ from playwright.async_api import Error as PlaywrightError
20
+
21
+ sys.stdout.reconfigure(encoding="utf-8")
22
+ sys.stderr.reconfigure(encoding="utf-8")
23
+
24
+ MAX_REDIRECT_HOPS = 5
25
+
26
+
27
+ def _is_safe_target(url):
28
+ # Mesma checagem do daemon (_is_safe_crawl_target): resolve o host de
29
+ # verdade via DNS, não confia só na string da URL, recusa IP privado/
30
+ # loopback/link-local/reservado/multicast. Duplicada aqui (não importada
31
+ # do daemon) porque este script é quem de fato faz a navegação de rede —
32
+ # a validação do daemon sozinha não cobre redirect HTTP nem link
33
+ # descoberto durante o crawl, só a URL semente original.
34
+ try:
35
+ parsed = urllib.parse.urlparse(url)
36
+ except ValueError:
37
+ return False
38
+ if parsed.scheme not in ("http", "https") or not parsed.hostname:
39
+ return False
40
+ try:
41
+ addrs = socket.getaddrinfo(parsed.hostname, None)
42
+ except socket.gaierror:
43
+ return False
44
+ for _family, _type, _proto, _canonname, sockaddr in addrs:
45
+ try:
46
+ ip = ipaddress.ip_address(sockaddr[0])
47
+ except ValueError:
48
+ return False
49
+ if not ip.is_global:
50
+ return False
51
+ return True
52
+
53
+
54
+ class _NoAutoRedirect(urllib.request.HTTPRedirectHandler):
55
+ def redirect_request(self, req, fp, code, msg, headers, newurl):
56
+ return None # deixa o 3xx virar HTTPError em vez de seguir automaticamente
57
+
58
+
59
+ _NO_REDIRECT_OPENER = urllib.request.build_opener(_NoAutoRedirect)
60
+
61
+
62
+ def resolve_safe_final_url(url, max_hops=MAX_REDIRECT_HOPS):
63
+ # Segue a cadeia de redirect manualmente, validando CADA salto antes de
64
+ # seguir — sem isto, uma URL semente pública (que passa a validação
65
+ # inicial) responder com "302 Location: http://127.0.0.1:.../status"
66
+ # levaria o browser real a seguir o redirect sem qualquer checagem, já
67
+ # que o navegador do Crawl4AI segue 3xx de forma transparente.
68
+ current = url
69
+ for _ in range(max_hops + 1):
70
+ if not _is_safe_target(current):
71
+ raise ValueError(f"URL rejected (internal/reserved network target): {current}")
72
+ req = urllib.request.Request(current, headers={"User-Agent": "Mozilla/5.0"})
73
+ try:
74
+ resp = _NO_REDIRECT_OPENER.open(req, timeout=10)
75
+ resp.close()
76
+ return current
77
+ except urllib.error.HTTPError as e:
78
+ if e.code not in (301, 302, 303, 307, 308):
79
+ return current # erro não-redirect (404 etc.) — deixa o crawler real reportar
80
+ location = e.headers.get("Location")
81
+ if not location:
82
+ raise ValueError("Redirect without a Location header")
83
+ current = urllib.parse.urljoin(current, location)
84
+ raise ValueError("Too many redirects while resolving the seed URL")
85
+
86
+
87
+ class _SSRFSafeURLFilter(URLFilter):
88
+ # Primeira triagem, barata, de cada link descoberto no BFS antes de
89
+ # sequer abrir uma página pra ele — a defesa real (que cobre redirect
90
+ # HTTP em qualquer salto e navegação por meta-refresh/JS, que este
91
+ # filtro de string não alcança) é o route guard via Playwright abaixo.
92
+ def apply(self, url):
93
+ try:
94
+ return _is_safe_target(url)
95
+ except Exception:
96
+ return False
97
+
98
+
99
+ _MAX_ROUTE_REDIRECT_HOPS = 5
100
+
101
+
102
+ async def _abort_route(route):
103
+ try:
104
+ await route.abort()
105
+ except PlaywrightError:
106
+ # O contexto pode fechar enquanto um sub-recurso ainda está em rota.
107
+ # Nesse estado a requisição já foi descartada pelo navegador.
108
+ pass
109
+
110
+
111
+ async def _ssrf_route_guard(route):
112
+ # Cobre a navegação (inicial, redirect HTTP em qualquer salto, e
113
+ # meta-refresh/JS) e sub-recursos da PÁGINA em que foi instalado — o que
114
+ # `page.route()` alcança. Novas páginas/abas (popup, target="_blank") e
115
+ # WebSocket ficam fora do alcance dessa API e são bloqueados por outros
116
+ # mecanismos (ver `_install_ssrf_route_guard`), não por este guard.
117
+ # Playwright não emite um novo evento de rota pro destino de um redirect
118
+ # HTTP — o browser segue o 3xx internamente — por isso a cadeia é seguida
119
+ # manualmente via route.fetch(max_redirects=0), validando cada salto
120
+ # antes de buscar o próximo; route.fulfill só ocorre quando o destino
121
+ # final também é seguro, e qualquer salto reprovado aborta a requisição.
122
+ current_url = route.request.url
123
+ for _ in range(_MAX_ROUTE_REDIRECT_HOPS + 1):
124
+ try:
125
+ # to_thread pra não bloquear o event loop enquanto outras páginas
126
+ # do mesmo lote (arun_many) carregam em paralelo.
127
+ safe = await asyncio.to_thread(_is_safe_target, current_url)
128
+ except Exception:
129
+ safe = False
130
+ if not safe:
131
+ await _abort_route(route)
132
+ return
133
+ try:
134
+ response = await route.fetch(url=current_url, max_redirects=0)
135
+ except Exception:
136
+ await _abort_route(route)
137
+ return
138
+ if response.status in (301, 302, 303, 307, 308):
139
+ location = response.headers.get("location")
140
+ if not location:
141
+ await _abort_route(route)
142
+ return
143
+ current_url = urllib.parse.urljoin(current_url, location)
144
+ continue
145
+ try:
146
+ await route.fulfill(response=response)
147
+ except PlaywrightError:
148
+ await _abort_route(route)
149
+ return
150
+ await _abort_route(route)
151
+
152
+
153
+ async def _ssrf_ws_guard(ws_route):
154
+ # page.route() não intercepta WebSocket (API separada do Playwright) —
155
+ # sem isto, JS na página estabeleceria a conexão TCP direto, sem passar
156
+ # por _is_safe_target nenhuma vez. Extração de markdown não depende de
157
+ # WebSocket, então a conexão é sempre fechada sem nunca chamar
158
+ # connect_to_server (o servidor real nunca é contatado).
159
+ await ws_route.close()
160
+
161
+
162
+ async def _install_ssrf_route_guard(page, context=None, config=None):
163
+ # Registrar no context (não no page) cobre também popup/nova aba
164
+ # (window.open, target="_blank") desde a primeira requisição — routing
165
+ # por Page só existe depois que a página já foi criada, tarde o
166
+ # bastante pra uma navegação inicial disparada por popup escapar sem
167
+ # checagem.
168
+ target = context if context is not None else page
169
+ if getattr(target, "_ssrf_guard_installed", False):
170
+ return
171
+ target._ssrf_guard_installed = True
172
+ # As duas chamadas abaixo não são atômicas — depende deste hook disparar
173
+ # estritamente antes de qualquer goto()/JS no context (garantia do
174
+ # crawl4ai enquanto cada página nasce em about:blank sem reaproveitar
175
+ # sessão/context já em uso; deixaria de valer se o script um dia passar
176
+ # a configurar reuso de sessão/context no BrowserConfig).
177
+ await asyncio.gather(
178
+ target.route("**/*", _ssrf_route_guard),
179
+ target.route_web_socket("**/*", _ssrf_ws_guard),
180
+ )
181
+
182
+
183
+ def parse_args(argv):
184
+ max_pages = 5
185
+ url_parts = []
186
+ i = 0
187
+ while i < len(argv):
188
+ if argv[i] == "--max-pages" and i + 1 < len(argv):
189
+ max_pages = int(argv[i + 1])
190
+ i += 2
191
+ else:
192
+ url_parts.append(argv[i])
193
+ i += 1
194
+ if not url_parts:
195
+ raise ValueError('Usage: python crawl4ai_search.py "<seed url>" [--max-pages 5]')
196
+ return " ".join(url_parts), max_pages
197
+
198
+
199
+ async def crawl(seed_url, max_pages):
200
+ safe_seed_url = resolve_safe_final_url(seed_url)
201
+ config = CrawlerRunConfig(
202
+ deep_crawl_strategy=BFSDeepCrawlStrategy(
203
+ max_depth=2, max_pages=max_pages, filter_chain=FilterChain([_SSRFSafeURLFilter()]),
204
+ ),
205
+ cache_mode=CacheMode.BYPASS,
206
+ page_timeout=30000,
207
+ )
208
+ strategy = AsyncPlaywrightCrawlerStrategy()
209
+ strategy.set_hook("on_page_context_created", _install_ssrf_route_guard)
210
+ async with AsyncWebCrawler(crawler_strategy=strategy) as crawler:
211
+ results = await crawler.arun(url=safe_seed_url, config=config)
212
+ return [
213
+ {"url": r.url, "markdown": r.markdown.raw_markdown}
214
+ for r in results
215
+ if r.success and r.markdown and r.markdown.raw_markdown
216
+ ]
217
+
218
+
219
+ def main():
220
+ seed_url, max_pages = parse_args(sys.argv[1:])
221
+ pages = asyncio.run(crawl(seed_url, max_pages))
222
+ if not pages:
223
+ print("No page extracted: the target may have blocked the crawler or has no content.", file=sys.stderr)
224
+ sys.exit(1)
225
+ sys.stdout.buffer.write(json.dumps({"pages": pages}, ensure_ascii=False).encode("utf-8"))
226
+ sys.stdout.buffer.write(b"\n")
227
+
228
+
229
+ if __name__ == "__main__":
230
+ try:
231
+ main()
232
+ except SystemExit:
233
+ raise
234
+ except Exception as exc:
235
+ print(str(exc), file=sys.stderr)
236
+ sys.exit(1)
@@ -0,0 +1,2 @@
1
+ camoufox==0.5.6
2
+ crawl4ai==0.9.3
@@ -0,0 +1,40 @@
1
+ // Partes comuns dos adaptadores de busca em Node: texto de HTML e argumentos de linha de comando.
2
+
3
+ const ENTITIES = {
4
+ amp: '&', lt: '<', gt: '>', quot: '"', '#39': "'", '#x27': "'", nbsp: ' ',
5
+ };
6
+
7
+ function decodeEntities(s) {
8
+ return s.replace(/&(#x?[0-9a-fA-F]+|[a-zA-Z0-9#]+);/g, (m, code) => {
9
+ if (code[0] === '#') {
10
+ const num = code[1] === 'x' || code[1] === 'X'
11
+ ? parseInt(code.slice(2), 16)
12
+ : parseInt(code.slice(1), 10);
13
+ return Number.isNaN(num) ? m : String.fromCodePoint(num);
14
+ }
15
+ return ENTITIES[code] ?? m;
16
+ });
17
+ }
18
+
19
+ export function stripTags(html) {
20
+ return decodeEntities(html.replace(/<[^>]+>/g, '')).trim();
21
+ }
22
+
23
+ // `defaults` lists the accepted --flags; a numeric default makes the flag numeric.
24
+ export function parseArgs(argv, usage, defaults) {
25
+ const values = { ...defaults };
26
+ const queryParts = [];
27
+ for (let i = 0; i < argv.length; i++) {
28
+ const flag = argv[i].startsWith('--') ? argv[i].slice(2) : null;
29
+ if (flag !== null && flag in defaults && i + 1 < argv.length) {
30
+ const raw = argv[++i];
31
+ values[flag] = typeof defaults[flag] === 'number' ? Number(raw) || defaults[flag] : raw;
32
+ } else {
33
+ queryParts.push(argv[i]);
34
+ }
35
+ }
36
+ if (queryParts.length === 0) {
37
+ throw new Error(usage);
38
+ }
39
+ return { query: queryParts.join(' '), ...values };
40
+ }
@@ -0,0 +1,89 @@
1
+ #!/usr/bin/env node
2
+ // Uso: node ddg-search.mjs "consulta" [--num 8] [--region br-pt]
3
+ // Saída: JSON no stdout — [{ title, url, snippet }, ...]
4
+ import { parseArgs, stripTags } from './adapter-common.mjs';
5
+
6
+ function realUrlFromDdgHref(href) {
7
+ const match = /uddg=([^&]+)/.exec(href);
8
+ if (!match) return href.startsWith('//') ? `https:${href}` : href;
9
+ try {
10
+ return decodeURIComponent(match[1]);
11
+ } catch {
12
+ return match[1];
13
+ }
14
+ }
15
+
16
+ async function main() {
17
+ const { query, num, region } = parseArgs(process.argv.slice(2),
18
+ 'Usage: node ddg-search.mjs "query" [--num 8] [--region br-pt]', { num: 8, region: null });
19
+
20
+ const params = new URLSearchParams({ q: query });
21
+ if (region) params.set('kl', region);
22
+ const url = `https://html.duckduckgo.com/html/?${params.toString()}`;
23
+
24
+ const res = await fetch(url, {
25
+ headers: {
26
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0 Safari/537.36',
27
+ 'Accept-Language': 'pt-BR,pt;q=0.9,en;q=0.8',
28
+ },
29
+ signal: AbortSignal.timeout(15000),
30
+ });
31
+
32
+ if (!res.ok) {
33
+ throw new Error(`DuckDuckGo answered HTTP ${res.status}`);
34
+ }
35
+
36
+ const html = await res.text();
37
+
38
+ // Um resultado patrocinado usa a mesma classe `result__a` no link de título que um
39
+ // resultado orgânico — só o container (`<div class="result ... result--ad ...">`)
40
+ // distingue os dois. Por isso a extração é por bloco (do início de um `<div
41
+ // class="result ` até o início do próximo), não por duas regexes globais pareadas por
42
+ // posição: título+snippet do mesmo bloco nunca desalinha com o resultado seguinte, e um
43
+ // bloco com `result--ad` na própria classe é descartado antes de extrair.
44
+ const BLOCK_START = '<div class="result ';
45
+ const startPositions = [];
46
+ for (let i = html.indexOf(BLOCK_START); i !== -1; i = html.indexOf(BLOCK_START, i + BLOCK_START.length)) {
47
+ startPositions.push(i);
48
+ }
49
+
50
+ const titleRe = /<a[^>]*class="result__a"[^>]*href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/;
51
+ const snippetRe = /<a[^>]*class="result__snippet"[^>]*href="[^"]+"[^>]*>([\s\S]*?)<\/a>/;
52
+
53
+ const results = [];
54
+ for (let i = 0; i < startPositions.length && results.length < num; i++) {
55
+ const start = startPositions[i];
56
+ const end = i + 1 < startPositions.length ? startPositions[i + 1] : html.length;
57
+ const block = html.slice(start, end);
58
+
59
+ const classEnd = block.indexOf('"', BLOCK_START.length);
60
+ const classAttr = block.slice(BLOCK_START.length, classEnd);
61
+ if (classAttr.split(/\s+/).includes('result--ad')) continue;
62
+
63
+ const titleMatch = titleRe.exec(block);
64
+ if (!titleMatch) continue;
65
+ const snippetMatch = snippetRe.exec(block);
66
+
67
+ results.push({
68
+ title: stripTags(titleMatch[2]),
69
+ url: realUrlFromDdgHref(titleMatch[1]),
70
+ snippet: snippetMatch ? stripTags(snippetMatch[1]) : '',
71
+ });
72
+ }
73
+
74
+ if (results.length === 0) {
75
+ throw new Error('No result extracted: DuckDuckGo may have blocked or changed the HTML. Try again or rephrase the query.');
76
+ }
77
+
78
+ process.stdout.write(JSON.stringify(results, null, 2));
79
+ }
80
+
81
+ // Nunca chamar process.exit() aqui: com stdout/stderr em pipe (caso do
82
+ // subprocess.run que consome este script), abortar o processo enquanto ainda
83
+ // há handles assíncronos pendentes (ex.: o timer do AbortSignal.timeout do
84
+ // fetch) pode disparar um crash nativo do libuv no Windows. Só marcar o
85
+ // exit code e deixar o event loop drenar sozinho.
86
+ main().catch((err) => {
87
+ console.error(String(err?.message ?? err));
88
+ process.exitCode = 1;
89
+ });