Spaces:
Sleeping
Sleeping
| """ | |
| Standalone web search + content fetching for the research engine. | |
| Uses DuckDuckGo (no API key needed) with httpx for content fetching. | |
| """ | |
| import hashlib | |
| import json | |
| import logging | |
| import re | |
| import time | |
| from datetime import datetime, timedelta | |
| from pathlib import Path | |
| from typing import Dict, List, Optional | |
| from urllib.parse import urlparse | |
| import httpx | |
| from bs4 import BeautifulSoup | |
| logger = logging.getLogger(__name__) | |
| # Cache directory | |
| _CACHE_DIR = Path("data/research_cache") | |
| _CACHE_DIR.mkdir(parents=True, exist_ok=True) | |
| # Domains to skip (paywall, login walls, etc.) | |
| _SKIP_DOMAINS = { | |
| "facebook.com", "twitter.com", "x.com", "instagram.com", | |
| "linkedin.com/in/", "tiktok.com", "youtube.com", | |
| } | |
| 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" | |
| ) | |
| def _cache_key(text: str) -> str: | |
| return hashlib.sha256(text.encode()).hexdigest()[:16] | |
| def _read_cache(key: str, ttl_hours: int = 4) -> Optional[dict]: | |
| f = _CACHE_DIR / f"{key}.json" | |
| if not f.exists(): | |
| return None | |
| try: | |
| data = json.loads(f.read_text(encoding="utf-8")) | |
| ts = datetime.fromisoformat(data["ts"]) | |
| if datetime.now() - ts < timedelta(hours=ttl_hours): | |
| return data["payload"] | |
| except Exception: | |
| pass | |
| return None | |
| def _write_cache(key: str, payload) -> None: | |
| try: | |
| f = _CACHE_DIR / f"{key}.json" | |
| f.write_text( | |
| json.dumps({"ts": datetime.now().isoformat(), "payload": payload}), | |
| encoding="utf-8", | |
| ) | |
| except Exception: | |
| pass | |
| def web_search(query: str, max_results: int = 8) -> List[Dict]: | |
| """ | |
| Search the web using DuckDuckGo. Returns list of {url, title, snippet}. | |
| Falls back to Bing scraping if DDG fails. | |
| """ | |
| key = _cache_key(f"search:{query}:{max_results}") | |
| cached = _read_cache(key, ttl_hours=2) | |
| if cached: | |
| logger.debug(f"Search cache hit: {query}") | |
| return cached | |
| results = _ddg_search(query, max_results) | |
| if not results: | |
| results = _bing_scrape(query, max_results) | |
| _write_cache(key, results) | |
| return results | |
| def _ddg_search(query: str, max_results: int) -> List[Dict]: | |
| try: | |
| try: | |
| from ddgs import DDGS # new package name | |
| except ImportError: | |
| from duckduckgo_search import DDGS # old name fallback | |
| with DDGS(timeout=15) as ddgs: | |
| raw = list(ddgs.text(query, max_results=max_results)) | |
| results = [] | |
| for r in raw: | |
| url = r.get("href") or r.get("url", "") | |
| if not url or _should_skip(url): | |
| continue | |
| results.append({ | |
| "url": url, | |
| "title": r.get("title", ""), | |
| "snippet": r.get("body", "")[:300], | |
| }) | |
| logger.info(f"DDG search '{query}': {len(results)} results") | |
| return results | |
| except Exception as e: | |
| logger.warning(f"DDG search failed: {e}") | |
| return [] | |
| def _bing_scrape(query: str, max_results: int) -> List[Dict]: | |
| """Fallback: scrape Bing search results.""" | |
| try: | |
| url = f"https://www.bing.com/search?q={query.replace(' ', '+')}&count={max_results}" | |
| resp = httpx.get(url, headers={"User-Agent": USER_AGENT}, timeout=10, follow_redirects=True) | |
| soup = BeautifulSoup(resp.text, "lxml") | |
| results = [] | |
| for li in soup.find_all("li", class_="b_algo")[:max_results]: | |
| link = li.find("a", href=True) | |
| snippet_el = li.find("p") or li.find("div", class_="b_caption") | |
| if not link: | |
| continue | |
| href = link["href"] | |
| if _should_skip(href): | |
| continue | |
| results.append({ | |
| "url": href, | |
| "title": link.get_text(strip=True), | |
| "snippet": snippet_el.get_text(strip=True)[:300] if snippet_el else "", | |
| }) | |
| logger.info(f"Bing fallback '{query}': {len(results)} results") | |
| return results | |
| except Exception as e: | |
| logger.warning(f"Bing fallback failed: {e}") | |
| return [] | |
| def _should_skip(url: str) -> bool: | |
| try: | |
| domain = urlparse(url).netloc.lower() | |
| return any(skip in domain for skip in _SKIP_DOMAINS) | |
| except Exception: | |
| return False | |
| def fetch_page_content(url: str, timeout: int = 10) -> Dict: | |
| """ | |
| Fetch a URL and extract its main text content. | |
| Returns {success, url, title, content, og_image}. | |
| """ | |
| key = _cache_key(f"content:{url}") | |
| cached = _read_cache(key, ttl_hours=12) | |
| if cached: | |
| return cached | |
| result = {"success": False, "url": url, "title": "", "content": "", "og_image": ""} | |
| try: | |
| resp = httpx.get( | |
| url, | |
| headers={"User-Agent": USER_AGENT, "Accept-Language": "en-US,en;q=0.9"}, | |
| timeout=timeout, | |
| follow_redirects=True, | |
| ) | |
| if resp.status_code != 200: | |
| return result | |
| content_type = resp.headers.get("content-type", "") | |
| if "text/html" not in content_type and "text/plain" not in content_type: | |
| return result | |
| soup = BeautifulSoup(resp.text, "lxml") | |
| # Title | |
| title_el = soup.find("title") | |
| result["title"] = title_el.get_text(strip=True) if title_el else "" | |
| # OG image | |
| og = soup.find("meta", property="og:image") | |
| if og: | |
| result["og_image"] = og.get("content", "") | |
| # Remove boilerplate | |
| for tag in soup(["script", "style", "noscript", "nav", "header", "footer", | |
| "aside", "form", "iframe", "advertisement"]): | |
| tag.decompose() | |
| # Try article / main / content div first | |
| main = ( | |
| soup.find("article") | |
| or soup.find("main") | |
| or soup.find(id=re.compile(r"content|main|article|post", re.I)) | |
| or soup.find(class_=re.compile(r"content|main|article|post|body", re.I)) | |
| ) | |
| if main: | |
| text = main.get_text(separator="\n", strip=True) | |
| else: | |
| text = soup.get_text(separator="\n", strip=True) | |
| # Clean up excessive whitespace | |
| text = re.sub(r"\n{3,}", "\n\n", text).strip() | |
| if len(text) < 100: | |
| return result | |
| result["content"] = text[:20000] | |
| result["success"] = True | |
| _write_cache(key, result) | |
| return result | |
| except Exception as e: | |
| logger.warning(f"Failed to fetch {url}: {e}") | |
| return result | |