| import base64 |
| from urllib.parse import quote, urlparse, parse_qs |
| import requests |
| import trafilatura |
| from playwright.sync_api import sync_playwright, TimeoutError as PlaywrightTimeoutError |
| from errors import get_logger, GenerAIError, ErrorCode, fmt_exc |
|
|
| log = get_logger("scraper") |
|
|
| SEARCH_URL = "https://www.bing.com/search" |
| 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" |
| NAV_TIMEOUT_MS = 15000 |
|
|
|
|
| def _launch_browser(p): |
| return p.chromium.launch( |
| headless=True, |
| args=["--no-sandbox", "--disable-setuid-sandbox", "--disable-dev-shm-usage"], |
| ) |
|
|
|
|
| def _decode_bing_redirect(href: str) -> str: |
| """Bing avvolge i link organici in redirect di tracking (bing.com/ck/a?...&u=<base64>).""" |
| if "bing.com/ck/a" not in href: |
| return href |
| try: |
| u = parse_qs(urlparse(href).query).get("u", [""])[0] |
| if u.startswith("a1"): |
| u = u[2:] |
| pad = "=" * (-len(u) % 4) |
| return base64.urlsafe_b64decode(u + pad).decode("utf-8", errors="ignore") |
| except Exception: |
| return href |
|
|
|
|
| def _search_links(page, query: str, max_results: int) -> list[dict]: |
| """Cerca su Bing e restituisce [{title, url, snippet}].""" |
| url = f"{SEARCH_URL}?q={quote(query)}&setlang=it" |
| page.goto(url, timeout=NAV_TIMEOUT_MS, wait_until="domcontentloaded") |
|
|
| hits = [] |
| rows = page.locator("li.b_algo").all()[: max_results * 2] |
| for row in rows: |
| try: |
| link = row.locator("h2 a").first |
| if not link.count(): |
| continue |
| href = link.get_attribute("href") |
| title = link.text_content() or "" |
| if not href: |
| continue |
| snippet_el = row.locator("p, .b_lineclamp2, .b_lineclamp3, .b_lineclamp4").first |
| snippet = snippet_el.text_content() if snippet_el.count() else "" |
| real_url = _decode_bing_redirect(href) |
| if not real_url.startswith("http"): |
| continue |
| hits.append({"title": title.strip(), "url": real_url, "snippet": (snippet or "").strip()}) |
| except Exception: |
| continue |
| return hits |
|
|
|
|
| def _extract_text(page, url: str, snippet: str) -> str | None: |
| """Naviga alla pagina e ne estrae il testo pulito con trafilatura.""" |
| try: |
| page.goto(url, timeout=NAV_TIMEOUT_MS, wait_until="domcontentloaded") |
| html = page.content() |
| text = trafilatura.extract( |
| html, |
| url=url, |
| include_links=False, |
| include_images=False, |
| include_tables=False, |
| no_fallback=False, |
| ) |
| except PlaywrightTimeoutError: |
| log.debug("Timeout caricamento pagina: %s", url) |
| text = None |
| except Exception as e: |
| log.debug("Fetch fallito per %s — %s", url, fmt_exc(e)) |
| text = None |
|
|
| if not text or len(text) < 80: |
| text = snippet |
| if not text or len(text) < 20: |
| return None |
| return text[:2000] |
|
|
|
|
| def _chromium_search(query: str, max_results: int) -> list[dict]: |
| """Cerca ed estrae testo usando Chromium headless (Playwright) via Bing.""" |
| results: list[dict] = [] |
| try: |
| with sync_playwright() as p: |
| browser = _launch_browser(p) |
| context = browser.new_context(user_agent=USER_AGENT, locale="it-IT") |
| page = context.new_page() |
| try: |
| hits = _search_links(page, query, max_results) |
| except PlaywrightTimeoutError: |
| log.warning("[%s] Timeout ricerca Bing per: %r", ErrorCode.WEB_SEARCH_FAILED.value, query) |
| hits = [] |
| except Exception as e: |
| log.warning("[%s] Ricerca Bing fallita: %s", ErrorCode.WEB_SEARCH_FAILED.value, fmt_exc(e)) |
| hits = [] |
|
|
| for hit in hits: |
| if len(results) >= max_results: |
| break |
| log.debug("Fetching: %s", hit["url"]) |
| text = _extract_text(page, hit["url"], hit["snippet"]) |
| if not text: |
| log.debug("Testo troppo corto per: %s", hit["url"]) |
| continue |
| log.info("Estratti %d chars da: %s", len(text), hit["url"]) |
| results.append({"title": hit["title"], "url": hit["url"], "text": text}) |
|
|
| browser.close() |
| except Exception as e: |
| err = GenerAIError(ErrorCode.WEB_SEARCH_FAILED, f"Browser Chromium non avviato: {fmt_exc(e)}", cause=e) |
| err.log(log) |
| return [] |
| return results |
|
|
|
|
| def _wikipedia_search(query: str) -> list[dict]: |
| """Fallback diretto su Wikipedia italiana + inglese.""" |
| results = [] |
| for lang in ("it", "en"): |
| if len(results) >= 2: |
| break |
| try: |
| api = f"https://{lang}.wikipedia.org/w/api.php" |
| s = requests.get(api, params={ |
| "action": "query", "list": "search", |
| "srsearch": query, "format": "json", "srlimit": 2, |
| }, timeout=8, headers={"User-Agent": "GenerAI/3.0"}) |
| if s.status_code != 200: |
| continue |
| for hit in s.json().get("query", {}).get("search", []): |
| title = hit["title"] |
| p = requests.get(api, params={ |
| "action": "query", "prop": "extracts", |
| "exintro": "1", "explaintext": "1", |
| "titles": title, "format": "json", |
| }, timeout=8, headers={"User-Agent": "GenerAI/3.0"}) |
| if p.status_code != 200: |
| continue |
| for page in p.json().get("query", {}).get("pages", {}).values(): |
| extract = page.get("extract", "").strip() |
| if len(extract) > 50: |
| results.append({ |
| "title": page["title"], |
| "url": f"https://{lang}.wikipedia.org/wiki/{page['title'].replace(' ', '_')}", |
| "text": extract[:2000], |
| }) |
| break |
| except Exception as e: |
| log.debug("Wikipedia %s fallita: %s", lang, fmt_exc(e)) |
| if results: |
| log.info("Wikipedia fallback → %d risultati", len(results)) |
| return results |
|
|
|
|
| def search_and_extract(query: str, max_results: int = 3) -> list[dict]: |
| """Cerca sul web con Chromium (Playwright, Bing) + fallback Wikipedia. Estrae testo pulito.""" |
| log.info("Ricerca web (Chromium) per: %r", query) |
|
|
| results = _chromium_search(query, max_results) |
|
|
| if not results: |
| log.info("Chromium non ha prodotto risultati — provo Wikipedia...") |
| results = _wikipedia_search(query) |
|
|
| if not results: |
| log.warning("[%s] Nessun risultato per: %r", ErrorCode.WEB_NO_RESULTS.value, query) |
|
|
| return results |
|
|