amogaddy commited on
Commit
b1a33c2
·
verified ·
1 Parent(s): 248586b

Sostituita ricerca web SearXNG con Chromium/Playwright; aggiunto Dockerfile per Chromium su HF Space

Browse files
Files changed (4) hide show
  1. Dockerfile +27 -0
  2. app.py +6 -223
  3. requirements.txt +1 -1
  4. scraper.py +98 -109
Dockerfile ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ WORKDIR /app
4
+
5
+ # Dipendenze di sistema richieste da Chromium (Playwright)
6
+ RUN apt-get update && apt-get install -y --no-install-recommends \
7
+ wget gnupg ca-certificates \
8
+ libnss3 libnspr4 libatk1.0-0 libatk-bridge2.0-0 libcups2 \
9
+ libdrm2 libdbus-1-3 libxkbcommon0 libxcomposite1 libxdamage1 \
10
+ libxfixes3 libxrandr2 libgbm1 libasound2 libpango-1.0-0 \
11
+ libcairo2 libatspi2.0-0 libx11-6 libxext6 fonts-liberation \
12
+ && rm -rf /var/lib/apt/lists/*
13
+
14
+ COPY requirements.txt .
15
+ RUN pip install --no-cache-dir -r requirements.txt
16
+ RUN python -m playwright install chromium
17
+
18
+ COPY . .
19
+
20
+ # HF Spaces esegue il container come utente non-root (id 1000)
21
+ RUN mkdir -p /app/database && chmod -R 777 /app
22
+ USER 1000
23
+
24
+ EXPOSE 7860
25
+ ENV PYTHONUNBUFFERED=1
26
+
27
+ CMD ["python", "app.py"]
app.py CHANGED
@@ -24,7 +24,6 @@ def page(title: str, content: str, active: str = "") -> str:
24
  nav_items = [
25
  ("/", "🏠", "Home"),
26
  ("/chat", "💬", "Chat"),
27
- ("/instances/ui","🌐", "Istanze"),
28
  ("/commands", "⚡", "Comandi"),
29
  ("/docs", "📖", "API Docs"),
30
  ]
@@ -101,7 +100,7 @@ async def home():
101
  <tr><th>Componente</th><th>Stato</th><th>Info</th></tr>
102
  <tr><td>AI Brain</td><td><span class="badge {badge}">{status}</span></td><td>KB + Web search</td></tr>
103
  <tr><td>Knowledge Base</td><td><span class="badge green">Online</span></td><td>{kb} documenti in memoria</td></tr>
104
- <tr><td>Ricerca Web</td><td><span class="badge blue">SearXNG</span></td><td>Multi-istanza con fallback</td></tr>
105
  </table>
106
  </div>
107
 
@@ -109,7 +108,6 @@ async def home():
109
  <h2>Accesso rapido</h2>
110
  <div style="display:flex;gap:10px;flex-wrap:wrap">
111
  <a href="/chat" class="btn btn-primary">💬 Chat</a>
112
- <a href="/instances/ui" class="btn btn-secondary">🌐 Istanze SearXNG</a>
113
  <a href="/commands" class="btn btn-secondary">⚡ Comandi terminale</a>
114
  <a href="/docs" class="btn btn-secondary">📖 API Docs</a>
115
  </div>
@@ -191,91 +189,6 @@ document.getElementById('inp').addEventListener('keydown', e => {{
191
  return HTMLResponse(page("Chat", content, "/chat"))
192
 
193
 
194
- # ── Istanze UI ─────────────────────────────────────────────────────────────────
195
-
196
- @app.get("/instances/ui", response_class=HTMLResponse)
197
- def instances_ui():
198
- content = f"""
199
- <h1>🌐 Istanze SearXNG</h1>
200
- <p class="sub">Testa tutte le istanze pubbliche e seleziona la più veloce.</p>
201
-
202
- <div class="card">
203
- <div style="display:flex;gap:10px;align-items:center;flex-wrap:wrap">
204
- <button class="btn btn-primary" onclick="testAll()">🔍 Testa tutte</button>
205
- <span id="scan-status" style="color:#888;font-size:13px"></span>
206
- </div>
207
- <div id="active-info" style="margin-top:12px;font-size:13px;color:#888"></div>
208
- </div>
209
-
210
- <div id="results-card" class="card" style="display:none">
211
- <h2>Risultati <span id="count-badge"></span></h2>
212
- <table>
213
- <thead><tr><th>Istanza</th><th>Stato</th><th>Velocità</th><th>Paese</th><th>Versione</th><th>Uptime</th><th>HTTPS</th><th>Motori</th><th>Azione</th></tr></thead>
214
- <tbody id="inst-table"></tbody>
215
- </table>
216
- </div>
217
-
218
- <script>
219
- async function loadActive() {{
220
- const r = await fetch('{BASE}/instances/active');
221
- const d = await r.json();
222
- document.getElementById('active-info').innerHTML =
223
- 'Istanza attiva: <span style="color:#7c9ef8;font-family:monospace">' + d.active + '</span>';
224
- }}
225
-
226
- async function select(url) {{
227
- const r = await fetch('{BASE}/instances/select?url=' + encodeURIComponent(url), {{method:'POST'}});
228
- const d = await r.json();
229
- if (d.ok) {{ alert('Impostata: ' + d.active); loadActive(); }}
230
- else alert('Errore: ' + JSON.stringify(d));
231
- }}
232
-
233
- async function testAll() {{
234
- document.getElementById('scan-status').innerText = 'Scansione in corso... (~30s)';
235
- document.getElementById('results-card').style.display = 'none';
236
- const r = await fetch('{BASE}/instances');
237
- const d = await r.json();
238
- document.getElementById('scan-status').innerText =
239
- 'Completato: ' + d.online_with_results + ' online su ' + d.total_tested + ' testate';
240
- document.getElementById('count-badge').innerHTML =
241
- '<span class="badge green">' + d.online_with_results + ' online</span>';
242
-
243
- const tbody = document.getElementById('inst-table');
244
- tbody.innerHTML = '';
245
- d.all.forEach(i => {{
246
- const ok = i.online && i.results;
247
- const color = ok ? '#3cb371' : i.online ? '#f0a000' : '#e05555';
248
- const stato = ok ? '✅ Online' : i.online ? '⚠️ No risultati' : '❌ Offline';
249
- const ms = i.ms ? i.ms + 'ms' : '—';
250
- const up = i.uptime_day != null ? i.uptime_day + '%' : '—';
251
- const grade = i.https_grade || '—';
252
- const gradeColor = grade.startsWith('A') ? '#3cb371' : grade === '—' ? '#888' : '#f0a000';
253
- const engines = (i.engines_live && i.engines_live.length
254
- ? i.engines_live : i.engines_meta || []).join(', ') || '—';
255
- const btn = ok
256
- ? `<button class="btn btn-secondary" style="font-size:11px;padding:3px 8px" onclick="select('${{i.url}}')">Seleziona</button>`
257
- : '—';
258
- tbody.innerHTML += `<tr>
259
- <td style="font-family:monospace;font-size:11px">${{i.url}}</td>
260
- <td><span style="color:${{color}}">${{stato}}</span></td>
261
- <td>${{ms}}</td>
262
- <td>${{i.country || '—'}}</td>
263
- <td style="font-size:11px;color:#888">${{i.version || '—'}}</td>
264
- <td>${{up}}</td>
265
- <td style="color:${{gradeColor}}">${{grade}}</td>
266
- <td style="font-size:11px;color:#888;max-width:200px;overflow:hidden;text-overflow:ellipsis">${{engines}}</td>
267
- <td>${{btn}}</td>
268
- </tr>`;
269
- }});
270
- document.getElementById('results-card').style.display = 'block';
271
- }}
272
-
273
- loadActive();
274
- </script>
275
- """
276
- return HTMLResponse(page("Istanze", content, "/instances/ui"))
277
-
278
-
279
  # ── Comandi ────────────────────────────────────────────────────────────────────
280
 
281
  @app.get("/commands", response_class=HTMLResponse)
@@ -309,17 +222,6 @@ def commands_page():
309
  f'curl -N -X POST "{BASE}/ask/stream" -H "Content-Type: application/json" -d "{{\\"prompt\\":\\"chi e napoleone?\\"}}"',
310
  f'curl -N -X POST "{BASE}/ask/stream" -H "Content-Type: application/json" -d "{{\\"prompt\\":\\"chi e napoleone?\\"}}"'),
311
  ],
312
- "Istanze SearXNG": [
313
- cmd_block("b3","Testa tutte le istanze",
314
- f'Invoke-RestMethod "{BASE}/instances" | ConvertTo-Json -Depth 5',
315
- f'curl "{BASE}/instances"'),
316
- cmd_block("b4","Vedi istanza attiva",
317
- f'Invoke-RestMethod "{BASE}/instances/active"',
318
- f'curl "{BASE}/instances/active"'),
319
- cmd_block("b5","Seleziona istanza (cambia URL)",
320
- f'Invoke-RestMethod -Uri "{BASE}/instances/select?url=https://searx.be" -Method POST',
321
- f'curl -X POST "{BASE}/instances/select?url=https://searx.be"'),
322
- ],
323
  "Feedback": [
324
  cmd_block("b6","Risposta utile",
325
  f'Invoke-RestMethod -Uri "{BASE}/feedback?positive=true" -Method POST',
@@ -332,7 +234,7 @@ def commands_page():
332
 
333
  sections_html = ""
334
  for section, items in blocks.items():
335
- icon = {"Stato AI":"🟢","Chat":"💬","Istanze SearXNG":"🌐","Feedback":"👍"}.get(section,"")
336
  sections_html += f'<div style="margin-bottom:28px"><h2 style="color:#a0c4ff;font-size:15px;margin-bottom:12px;border-left:3px solid #7c9ef8;padding-left:10px">{icon} {section}</h2>{"".join(items)}</div>'
337
 
338
  content = f"""
@@ -379,17 +281,6 @@ function AskStream($d) {{
379
  curl -s -N -X POST "$BASE/ask/stream" -H "Content-Type: application/json" -d (@{{prompt=$d}}|ConvertTo-Json -Compress)
380
  }}
381
  function Stato {{ Invoke-RestMethod "$BASE/" | ConvertTo-Json }}
382
- function Istanze {{
383
- Write-Host "Scansione istanze..." -ForegroundColor Yellow
384
- $r = Invoke-RestMethod "$BASE/instances"
385
- Write-Host "Online: $($r.online_with_results)/$($r.total_tested)" -ForegroundColor Green
386
- $r.best | ForEach-Object {{ Write-Host " $($_.url) $($_.ms)ms" -ForegroundColor Green }}
387
- Write-Host "Attiva: $($r.active)" -ForegroundColor Cyan
388
- }}
389
- function Seleziona($url) {{
390
- $r = Invoke-RestMethod -Uri "$BASE/instances/select?url=$url" -Method POST
391
- Write-Host "Impostata: $($r.active)" -ForegroundColor Green
392
- }}
393
  function Feedback($p) {{
394
  Invoke-RestMethod -Uri "$BASE/feedback?positive=$p" -Method POST | Out-Null
395
  Write-Host "Feedback inviato!" -ForegroundColor Green
@@ -397,15 +288,13 @@ function Feedback($p) {{
397
 
398
  while ($true) {{
399
  Write-Host "`n===== GENERAI =====" -ForegroundColor Magenta
400
- Write-Host "1. Domanda 2. Streaming 3. Stato 4. Istanze 5. Seleziona istanza 6/7. Feedback 0. Esci"
401
  switch (Read-Host "Scelta") {{
402
  "1" {{ Ask (Read-Host "Domanda") }}
403
  "2" {{ AskStream (Read-Host "Domanda") }}
404
  "3" {{ Stato }}
405
- "4" {{ Istanze }}
406
- "5" {{ Seleziona (Read-Host "URL istanza") }}
407
- "6" {{ Feedback "true" }}
408
- "7" {{ Feedback "false" }}
409
  "0" {{ exit }}
410
  default {{ Write-Host "Scelta non valida" -ForegroundColor Red }}
411
  }}
@@ -422,13 +311,11 @@ set BASE={BASE}
422
  :menu
423
  echo.
424
  echo ===== GENERAI =====
425
- echo 1.Domanda 2.Streaming 3.Stato 4.Istanze 5.Seleziona istanza 0.Esci
426
  set /p s=Scelta:
427
  if "%s%"=="1" (set /p d=Domanda: & curl -s -X POST "%BASE%/ask" -H "Content-Type: application/json" -d "{{\\"prompt\\":\\"%d%\\"}}" & echo.) & goto menu
428
  if "%s%"=="2" (set /p d=Domanda: & curl -N -X POST "%BASE%/ask/stream" -H "Content-Type: application/json" -d "{{\\"prompt\\":\\"%d%\\"}}" & echo.) & goto menu
429
  if "%s%"=="3" (curl -s "%BASE%/" & echo.) & goto menu
430
- if "%s%"=="4" (curl -s "%BASE%/instances" & echo.) & goto menu
431
- if "%s%"=="5" (set /p u=URL: & curl -s -X POST "%BASE%/instances/select?url=%u%" & echo.) & goto menu
432
  if "%s%"=="0" exit /b
433
  goto menu
434
  """
@@ -475,110 +362,6 @@ async def ask_stream(req: AskRequest):
475
 
476
  return StreamingResponse(generate(), media_type="text/event-stream")
477
 
478
- @app.get("/instances")
479
- async def instances_json():
480
- import requests as req, time
481
- from scraper import get_active_instance
482
-
483
- def fetch_list():
484
- try:
485
- r = req.get("https://searx.space/data/instances.json", timeout=10)
486
- data = r.json().get("instances", {})
487
- result = {}
488
- for url, info in data.items():
489
- if info.get("network_type", "normal") != "normal":
490
- continue
491
- result[url.rstrip("/")] = {
492
- "version": info.get("version", "?"),
493
- "network": info.get("network_type", "normal"),
494
- "uptime": round(info.get("uptime", {}).get("uptimeDay", 0), 1) if isinstance(info.get("uptime"), dict) else None,
495
- "engines": list(info.get("engines", {}).keys())[:8],
496
- "country": info.get("network", {}).get("country", "?") if isinstance(info.get("network"), dict) else "?",
497
- "https": info.get("tls", {}).get("grade", "?") if isinstance(info.get("tls"), dict) else "?",
498
- }
499
- return result
500
- except Exception:
501
- return {
502
- "https://searx.be": {"version":"?","network":"normal","uptime":None,"engines":[],"country":"BE","https":"?"},
503
- "https://search.bus-hit.me": {"version":"?","network":"normal","uptime":None,"engines":[],"country":"?","https":"?"},
504
- "https://searxng.site": {"version":"?","network":"normal","uptime":None,"engines":[],"country":"?","https":"?"},
505
- }
506
-
507
- def check(url, meta):
508
- try:
509
- start = time.time()
510
- r = req.get(f"{url}/search", params={"q":"test","format":"json"}, timeout=5,
511
- headers={"User-Agent":"GenerAI/1.0"})
512
- ms = int((time.time()-start)*1000)
513
- online = r.status_code == 200
514
- try:
515
- results_data = r.json()
516
- has_results = len(results_data.get("results", [])) > 0
517
- engines_used = list({e for res in results_data.get("results", []) for e in res.get("engines", [])})
518
- except Exception:
519
- has_results = False
520
- engines_used = []
521
- return {
522
- "url": url,
523
- "online": online,
524
- "results": has_results,
525
- "ms": ms,
526
- "version": meta.get("version", "?"),
527
- "country": meta.get("country", "?"),
528
- "uptime_day": meta.get("uptime"),
529
- "https_grade": meta.get("https", "?"),
530
- "engines_live": engines_used[:6],
531
- "engines_meta": meta.get("engines", []),
532
- "network": meta.get("network", "normal"),
533
- }
534
- except Exception as e:
535
- return {
536
- "url": url, "online": False, "results": False, "ms": None,
537
- "version": meta.get("version","?"), "country": meta.get("country","?"),
538
- "uptime_day": meta.get("uptime"), "https_grade": meta.get("https","?"),
539
- "engines_live": [], "engines_meta": meta.get("engines",[]),
540
- "network": meta.get("network","normal"), "error": str(e)[:60],
541
- }
542
-
543
- meta_map = await asyncio.to_thread(fetch_list)
544
- checks = await asyncio.gather(*[asyncio.to_thread(check, u, m) for u, m in meta_map.items()])
545
- srt = sorted(checks, key=lambda x: (not x["online"], not x.get("results"), x.get("ms") or 9999))
546
- online = [c for c in srt if c["online"] and c.get("results")]
547
- return {"active": get_active_instance(), "total_tested": len(srt),
548
- "online_with_results": len(online), "best": online[:5], "all": srt}
549
-
550
- @app.get("/instances/active")
551
- def get_active():
552
- from scraper import get_active_instance
553
- return {"active": get_active_instance()}
554
-
555
- @app.get("/engines")
556
- def get_engines():
557
- from scraper import get_engines
558
- return {"engines": get_engines(), "available": ["brave","bing","google","duckduckgo","torch","yahoo","startpage","qwant"]}
559
-
560
- @app.post("/engines")
561
- def set_engines(engines: str):
562
- from scraper import set_engines
563
- set_engines(engines)
564
- return {"ok": True, "engines": engines}
565
-
566
- @app.post("/instances/select")
567
- async def select_instance(url: str):
568
- import requests as req
569
- from scraper import set_active_instance
570
- def test(u):
571
- try:
572
- r = req.get(f"{u}/search", params={"q":"test","format":"json"}, timeout=5,
573
- headers={"User-Agent":"GenerAI/1.0"})
574
- return r.status_code == 200
575
- except Exception:
576
- return False
577
- if not await asyncio.to_thread(test, url):
578
- raise HTTPException(status_code=400, detail=f"Istanza non raggiungibile: {url}")
579
- set_active_instance(url)
580
- return {"ok": True, "active": url}
581
-
582
  @app.post("/feedback")
583
  def feedback(positive: bool):
584
  if brain:
 
24
  nav_items = [
25
  ("/", "🏠", "Home"),
26
  ("/chat", "💬", "Chat"),
 
27
  ("/commands", "⚡", "Comandi"),
28
  ("/docs", "📖", "API Docs"),
29
  ]
 
100
  <tr><th>Componente</th><th>Stato</th><th>Info</th></tr>
101
  <tr><td>AI Brain</td><td><span class="badge {badge}">{status}</span></td><td>KB + Web search</td></tr>
102
  <tr><td>Knowledge Base</td><td><span class="badge green">Online</span></td><td>{kb} documenti in memoria</td></tr>
103
+ <tr><td>Ricerca Web</td><td><span class="badge blue">Chromium</span></td><td>Browser headless (Playwright)</td></tr>
104
  </table>
105
  </div>
106
 
 
108
  <h2>Accesso rapido</h2>
109
  <div style="display:flex;gap:10px;flex-wrap:wrap">
110
  <a href="/chat" class="btn btn-primary">💬 Chat</a>
 
111
  <a href="/commands" class="btn btn-secondary">⚡ Comandi terminale</a>
112
  <a href="/docs" class="btn btn-secondary">📖 API Docs</a>
113
  </div>
 
189
  return HTMLResponse(page("Chat", content, "/chat"))
190
 
191
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
192
  # ── Comandi ────────────────────────────────────────────────────────────────────
193
 
194
  @app.get("/commands", response_class=HTMLResponse)
 
222
  f'curl -N -X POST "{BASE}/ask/stream" -H "Content-Type: application/json" -d "{{\\"prompt\\":\\"chi e napoleone?\\"}}"',
223
  f'curl -N -X POST "{BASE}/ask/stream" -H "Content-Type: application/json" -d "{{\\"prompt\\":\\"chi e napoleone?\\"}}"'),
224
  ],
 
 
 
 
 
 
 
 
 
 
 
225
  "Feedback": [
226
  cmd_block("b6","Risposta utile",
227
  f'Invoke-RestMethod -Uri "{BASE}/feedback?positive=true" -Method POST',
 
234
 
235
  sections_html = ""
236
  for section, items in blocks.items():
237
+ icon = {"Stato AI":"🟢","Chat":"💬","Feedback":"👍"}.get(section,"")
238
  sections_html += f'<div style="margin-bottom:28px"><h2 style="color:#a0c4ff;font-size:15px;margin-bottom:12px;border-left:3px solid #7c9ef8;padding-left:10px">{icon} {section}</h2>{"".join(items)}</div>'
239
 
240
  content = f"""
 
281
  curl -s -N -X POST "$BASE/ask/stream" -H "Content-Type: application/json" -d (@{{prompt=$d}}|ConvertTo-Json -Compress)
282
  }}
283
  function Stato {{ Invoke-RestMethod "$BASE/" | ConvertTo-Json }}
 
 
 
 
 
 
 
 
 
 
 
284
  function Feedback($p) {{
285
  Invoke-RestMethod -Uri "$BASE/feedback?positive=$p" -Method POST | Out-Null
286
  Write-Host "Feedback inviato!" -ForegroundColor Green
 
288
 
289
  while ($true) {{
290
  Write-Host "`n===== GENERAI =====" -ForegroundColor Magenta
291
+ Write-Host "1. Domanda 2. Streaming 3. Stato 4/5. Feedback 0. Esci"
292
  switch (Read-Host "Scelta") {{
293
  "1" {{ Ask (Read-Host "Domanda") }}
294
  "2" {{ AskStream (Read-Host "Domanda") }}
295
  "3" {{ Stato }}
296
+ "4" {{ Feedback "true" }}
297
+ "5" {{ Feedback "false" }}
 
 
298
  "0" {{ exit }}
299
  default {{ Write-Host "Scelta non valida" -ForegroundColor Red }}
300
  }}
 
311
  :menu
312
  echo.
313
  echo ===== GENERAI =====
314
+ echo 1.Domanda 2.Streaming 3.Stato 0.Esci
315
  set /p s=Scelta:
316
  if "%s%"=="1" (set /p d=Domanda: & curl -s -X POST "%BASE%/ask" -H "Content-Type: application/json" -d "{{\\"prompt\\":\\"%d%\\"}}" & echo.) & goto menu
317
  if "%s%"=="2" (set /p d=Domanda: & curl -N -X POST "%BASE%/ask/stream" -H "Content-Type: application/json" -d "{{\\"prompt\\":\\"%d%\\"}}" & echo.) & goto menu
318
  if "%s%"=="3" (curl -s "%BASE%/" & echo.) & goto menu
 
 
319
  if "%s%"=="0" exit /b
320
  goto menu
321
  """
 
362
 
363
  return StreamingResponse(generate(), media_type="text/event-stream")
364
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
365
  @app.post("/feedback")
366
  def feedback(positive: bool):
367
  if brain:
requirements.txt CHANGED
@@ -5,4 +5,4 @@ chromadb
5
  sentence-transformers
6
  trafilatura
7
  lxml_html_clean
8
- requests
 
5
  sentence-transformers
6
  trafilatura
7
  lxml_html_clean
8
+ playwright
scraper.py CHANGED
@@ -1,129 +1,118 @@
1
- import os
2
- import requests
3
  import trafilatura
 
4
  from errors import get_logger, GenerAIError, ErrorCode, fmt_exc
5
 
6
  log = get_logger("scraper")
7
 
8
- # Istanza SearXNG — configurabile via Secret SEARXNG_URL
9
- SEARXNG_URL = os.environ.get("SEARXNG_URL", "https://searx.be")
10
- _active_instance = SEARXNG_URL
11
 
12
- PUBLIC_INSTANCES = [
13
- "https://searx.be",
14
- "https://search.bus-hit.me",
15
- "https://searxng.site",
16
- ]
17
 
18
- # Motori attivi dentro SearXNG — brave, bing (Edge), torch, duckduckgo come fallback
19
- SEARXNG_ENGINES = os.environ.get("SEARXNG_ENGINES", "brave,bing,torch,duckduckgo")
 
 
 
20
 
21
- def get_active_instance() -> str:
22
- return _active_instance
23
 
24
- def set_active_instance(url: str):
25
- global _active_instance
26
- _active_instance = url.rstrip("/")
27
- log.info("Istanza SearXNG cambiata: %s", _active_instance)
28
 
29
- def get_engines() -> str:
30
- return SEARXNG_ENGINES
31
-
32
- def set_engines(engines: str):
33
- global SEARXNG_ENGINES
34
- SEARXNG_ENGINES = engines
35
- log.info("Motori SearXNG aggiornati: %s", SEARXNG_ENGINES)
36
-
37
-
38
- def _searxng_search(query: str, max_results: int) -> list[dict]:
39
- """Cerca con SearXNG usando più motori. Prova più istanze se la prima fallisce."""
40
- active = get_active_instance()
41
- instances = [active] + [i for i in PUBLIC_INSTANCES if i != active]
42
- engines = get_engines()
43
-
44
- for instance in instances:
45
  try:
46
- resp = requests.get(
47
- f"{instance}/search",
48
- params={"q": query, "format": "json", "language": "it", "engines": engines},
49
- timeout=8,
50
- headers={"User-Agent": "GenerAI/1.0"},
51
- )
52
- if resp.status_code == 200:
53
- data = resp.json()
54
- hits = data.get("results", [])[:max_results * 2]
55
- if hits:
56
- log.info("SearXNG (%s) [%s] → %d risultati", instance, engines, len(hits))
57
- return hits
58
- # Nessun risultato con questi motori → riprova senza filtro motori
59
- resp2 = requests.get(
60
- f"{instance}/search",
61
- params={"q": query, "format": "json", "language": "it"},
62
- timeout=8,
63
- headers={"User-Agent": "GenerAI/1.0"},
64
- )
65
- if resp2.status_code == 200:
66
- hits2 = resp2.json().get("results", [])[:max_results * 2]
67
- log.info("SearXNG (%s) [fallback tutti motori] → %d risultati", instance, len(hits2))
68
- return hits2
69
- except Exception as e:
70
- log.warning("SearXNG %s fallita: %s", instance, fmt_exc(e))
71
- return []
 
 
 
 
 
 
72
 
 
 
 
73
 
74
- def search_and_extract(query: str, max_results: int = 3) -> list[dict]:
75
- """Cerca con SearXNG ed estrae testo pulito. Returns [] on total failure."""
76
- log.info("Ricerca web per: %r", query)
77
 
78
- hits = _searxng_search(query, max_results)
79
 
80
- if not hits:
81
- log.warning("[%s] Nessun risultato SearXNG per: %r", ErrorCode.WEB_NO_RESULTS.value, query)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
82
  return []
83
 
84
- results = []
85
- for hit in hits:
86
- if len(results) >= max_results:
87
- break
88
-
89
- # SearXNG include già uno snippet — usalo se il fetch fallisce
90
- url = hit.get("url", "")
91
- title = hit.get("title", "")
92
- snippet = hit.get("content", "")
93
-
94
- if not url:
95
- continue
96
-
97
- log.debug("Fetching: %s", url)
98
- text = None
99
- try:
100
- downloaded = trafilatura.fetch_url(url, timeout=10)
101
- if downloaded:
102
- text = trafilatura.extract(
103
- downloaded,
104
- include_links=False,
105
- include_images=False,
106
- include_tables=False,
107
- no_fallback=False,
108
- )
109
- except Exception as e:
110
- log.warning("[%s] fetch fallito per %s — %s", ErrorCode.WEB_FETCH_FAILED.value, url, fmt_exc(e))
111
-
112
- # Fallback allo snippet SearXNG se trafilatura non estrae nulla
113
- if not text or len(text) < 100:
114
- text = snippet
115
-
116
- if not text or len(text) < 50:
117
- log.debug("Testo troppo corto per: %s", url)
118
- continue
119
-
120
- log.info("Estratti %d chars da: %s", len(text), url)
121
- results.append({
122
- "title": title,
123
- "url": url,
124
- "text": text[:2000],
125
- })
126
-
127
  if not results:
128
  log.warning("[%s] Nessun testo utile estratto per: %r", ErrorCode.WEB_NO_RESULTS.value, query)
129
 
 
1
+ from urllib.parse import quote
 
2
  import trafilatura
3
+ from playwright.sync_api import sync_playwright, TimeoutError as PlaywrightTimeoutError
4
  from errors import get_logger, GenerAIError, ErrorCode, fmt_exc
5
 
6
  log = get_logger("scraper")
7
 
8
+ SEARCH_URL = "https://html.duckduckgo.com/html/"
9
+ USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"
10
+ NAV_TIMEOUT_MS = 15000
11
 
 
 
 
 
 
12
 
13
+ def _launch_browser(p):
14
+ return p.chromium.launch(
15
+ headless=True,
16
+ args=["--no-sandbox", "--disable-setuid-sandbox", "--disable-dev-shm-usage"],
17
+ )
18
 
 
 
19
 
20
+ def _search_links(page, query: str, max_results: int) -> list[dict]:
21
+ """Cerca su DuckDuckGo (HTML, no JS) e restituisce [{title, url, snippet}]."""
22
+ url = f"{SEARCH_URL}?q={quote(query)}&kl=it-it"
23
+ page.goto(url, timeout=NAV_TIMEOUT_MS, wait_until="domcontentloaded")
24
 
25
+ hits = []
26
+ rows = page.locator(".result").all()[: max_results * 2]
27
+ for row in rows:
 
 
 
 
 
 
 
 
 
 
 
 
 
28
  try:
29
+ link = row.locator("a.result__a").first
30
+ href = link.get_attribute("href")
31
+ title = link.inner_text().strip()
32
+ if not href or not href.startswith("http"):
33
+ continue
34
+ snippet_el = row.locator(".result__snippet").first
35
+ snippet = snippet_el.inner_text().strip() if snippet_el.count() else ""
36
+ hits.append({"title": title, "url": href, "snippet": snippet})
37
+ except Exception:
38
+ continue
39
+ return hits
40
+
41
+
42
+ def _extract_text(page, url: str, snippet: str) -> str | None:
43
+ """Naviga alla pagina e ne estrae il testo pulito con trafilatura."""
44
+ try:
45
+ page.goto(url, timeout=NAV_TIMEOUT_MS, wait_until="domcontentloaded")
46
+ html = page.content()
47
+ text = trafilatura.extract(
48
+ html,
49
+ url=url,
50
+ include_links=False,
51
+ include_images=False,
52
+ include_tables=False,
53
+ no_fallback=False,
54
+ )
55
+ except PlaywrightTimeoutError:
56
+ log.warning("[%s] Timeout caricamento pagina: %s", ErrorCode.WEB_FETCH_FAILED.value, url)
57
+ text = None
58
+ except Exception as e:
59
+ log.warning("[%s] Fetch fallito per %s — %s", ErrorCode.WEB_FETCH_FAILED.value, url, fmt_exc(e))
60
+ text = None
61
 
62
+ # Fallback allo snippet del motore di ricerca se l'estrazione non basta
63
+ if not text or len(text) < 100:
64
+ text = snippet
65
 
66
+ if not text or len(text) < 50:
67
+ return None
68
+ return text[:2000]
69
 
 
70
 
71
+ def search_and_extract(query: str, max_results: int = 3) -> list[dict]:
72
+ """Cerca sul web tramite Chromium (Playwright) ed estrae testo pulito.
73
+
74
+ Ritorna [] se la ricerca fallisce completamente. Interfaccia invariata
75
+ rispetto alla precedente implementazione basata su SearXNG.
76
+ """
77
+ log.info("Ricerca web (Chromium) per: %r", query)
78
+
79
+ try:
80
+ with sync_playwright() as p:
81
+ browser = _launch_browser(p)
82
+ context = browser.new_context(user_agent=USER_AGENT)
83
+ page = context.new_page()
84
+ try:
85
+ hits = _search_links(page, query, max_results)
86
+ except PlaywrightTimeoutError:
87
+ log.warning("[%s] Timeout ricerca DuckDuckGo per: %r", ErrorCode.WEB_SEARCH_FAILED.value, query)
88
+ hits = []
89
+ except Exception as e:
90
+ log.warning("[%s] Ricerca DuckDuckGo fallita: %s", ErrorCode.WEB_SEARCH_FAILED.value, fmt_exc(e))
91
+ hits = []
92
+
93
+ if not hits:
94
+ browser.close()
95
+ log.warning("[%s] Nessun risultato per: %r", ErrorCode.WEB_NO_RESULTS.value, query)
96
+ return []
97
+
98
+ results = []
99
+ for hit in hits:
100
+ if len(results) >= max_results:
101
+ break
102
+ log.debug("Fetching: %s", hit["url"])
103
+ text = _extract_text(page, hit["url"], hit["snippet"])
104
+ if not text:
105
+ log.debug("Testo troppo corto per: %s", hit["url"])
106
+ continue
107
+ log.info("Estratti %d chars da: %s", len(text), hit["url"])
108
+ results.append({"title": hit["title"], "url": hit["url"], "text": text})
109
+
110
+ browser.close()
111
+ except Exception as e:
112
+ err = GenerAIError(ErrorCode.WEB_SEARCH_FAILED, f"Browser Chromium non avviato: {fmt_exc(e)}", cause=e)
113
+ err.log(log)
114
  return []
115
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
116
  if not results:
117
  log.warning("[%s] Nessun testo utile estratto per: %r", ErrorCode.WEB_NO_RESULTS.value, query)
118