@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.
- package/CHANGELOG.md +23 -0
- package/LICENSE +202 -0
- package/NOTICE +32 -0
- package/README.md +116 -3
- package/atomic_io.py +94 -0
- package/autostart.py +199 -0
- package/bin/smart-tool.mjs +82 -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 +251 -0
- package/install_runtime.py +249 -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 +35 -6
- 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/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,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,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
|
+
});
|