Spaces:
Running on Zero
Running on Zero
| """Small fixed-source web-search backend for the local OpenClaude proxy.""" | |
| from __future__ import annotations | |
| import html | |
| import os | |
| import re | |
| import unicodedata | |
| import xml.etree.ElementTree as ET | |
| from datetime import timezone | |
| from email.utils import parsedate_to_datetime | |
| from html.parser import HTMLParser | |
| from typing import Any, Callable | |
| from urllib.parse import parse_qs, quote, urlparse | |
| import httpx | |
| MAX_RESULTS = 10 | |
| TARGET_PROVIDER_COUNT = 2 | |
| SEARCH_TIMEOUT = float(os.getenv("LOCAL_WEB_SEARCH_TIMEOUT", "20")) | |
| USER_AGENT = ( | |
| "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 " | |
| "(KHTML, like Gecko) Chrome/124.0 Safari/537.36" | |
| ) | |
| RECENT_NEWS_TERMS = { | |
| "agora", | |
| "atual", | |
| "atualizada", | |
| "atualizado", | |
| "hoje", | |
| "latest", | |
| "news", | |
| "noticia", | |
| "noticias", | |
| "recente", | |
| "recentes", | |
| "ultima", | |
| "ultimas", | |
| "ultimo", | |
| "ultimos", | |
| } | |
| QUERY_STOP_WORDS = RECENT_NEWS_TERMS | { | |
| "a", | |
| "as", | |
| "da", | |
| "das", | |
| "de", | |
| "do", | |
| "dos", | |
| "e", | |
| "em", | |
| "na", | |
| "nas", | |
| "no", | |
| "nos", | |
| "o", | |
| "os", | |
| "para", | |
| "sobre", | |
| } | |
| class SearchUnavailable(RuntimeError): | |
| pass | |
| def _clean_text(value: str) -> str: | |
| cleaned = re.sub(r"\s+", " ", html.unescape(value)).strip() | |
| return re.sub(r"\s+([,.;:!?])", r"\1", cleaned) | |
| def _fold_text(value: str) -> str: | |
| normalized = unicodedata.normalize("NFKD", str(value)) | |
| return "".join( | |
| character for character in normalized if not unicodedata.combining(character) | |
| ).casefold() | |
| def _query_words(value: str) -> list[str]: | |
| words = re.findall(r"[a-z0-9]+", _fold_text(value)) | |
| return list(dict.fromkeys(word for word in words if word not in QUERY_STOP_WORDS)) | |
| def _is_recent_news_query(query: str) -> bool: | |
| return bool(set(re.findall(r"[a-z0-9]+", _fold_text(query))) & RECENT_NEWS_TERMS) | |
| def _targets_rio_de_janeiro(query: str) -> bool: | |
| folded = _fold_text(query) | |
| return "rio de janeiro" in folded or bool(re.search(r"\brj\b", folded)) | |
| def _hostname(url: str) -> str: | |
| return (urlparse(url).hostname or "").lower() | |
| def _result_url(raw_url: str) -> str | None: | |
| value = html.unescape(raw_url).strip() | |
| if value.startswith("//"): | |
| value = "https:" + value | |
| parsed = urlparse(value) | |
| if parsed.hostname in {"duckduckgo.com", "www.duckduckgo.com"}: | |
| target = parse_qs(parsed.query).get("uddg", []) | |
| if target: | |
| value = target[0] | |
| parsed = urlparse(value) | |
| if parsed.scheme not in {"http", "https"} or not parsed.hostname: | |
| return None | |
| return value | |
| class DuckDuckGoLiteParser(HTMLParser): | |
| def __init__(self) -> None: | |
| super().__init__(convert_charrefs=True) | |
| self.results: list[dict[str, str]] = [] | |
| self._anchor_depth = 0 | |
| self._anchor_href = "" | |
| self._anchor_text: list[str] = [] | |
| self._active_result_index: int | None = None | |
| self._snippet_depth = 0 | |
| self._snippet_text: list[str] = [] | |
| self._snippet_result_index: int | None = None | |
| def _classes(attributes: list[tuple[str, str | None]]) -> set[str]: | |
| value = next((value for key, value in attributes if key == "class"), "") | |
| return set((value or "").split()) | |
| def handle_starttag( | |
| self, tag: str, attributes: list[tuple[str, str | None]] | |
| ) -> None: | |
| if tag == "a" and "result-link" in self._classes(attributes): | |
| self._anchor_depth = 1 | |
| self._anchor_href = next( | |
| (value or "" for key, value in attributes if key == "href"), "" | |
| ) | |
| self._anchor_text = [] | |
| self._active_result_index = None | |
| return | |
| if self._anchor_depth: | |
| self._anchor_depth += 1 | |
| if tag == "td" and "result-snippet" in self._classes(attributes): | |
| self._snippet_depth = 1 | |
| self._snippet_text = [] | |
| self._snippet_result_index = self._active_result_index | |
| return | |
| if self._snippet_depth: | |
| self._snippet_depth += 1 | |
| def handle_endtag(self, tag: str) -> None: | |
| if self._anchor_depth: | |
| self._anchor_depth -= 1 | |
| if self._anchor_depth == 0 and tag == "a": | |
| url = _result_url(self._anchor_href) | |
| title = _clean_text("".join(self._anchor_text)) | |
| if url and title: | |
| self.results.append( | |
| { | |
| "title": title, | |
| "url": url, | |
| "description": "", | |
| "source": _hostname(url), | |
| } | |
| ) | |
| self._active_result_index = len(self.results) - 1 | |
| if self._snippet_depth: | |
| self._snippet_depth -= 1 | |
| if self._snippet_depth == 0 and tag == "td": | |
| if self._snippet_result_index is not None: | |
| self.results[self._snippet_result_index]["description"] = ( | |
| _clean_text("".join(self._snippet_text)) | |
| ) | |
| self._snippet_result_index = None | |
| def handle_data(self, data: str) -> None: | |
| if self._anchor_depth: | |
| self._anchor_text.append(data) | |
| if self._snippet_depth: | |
| self._snippet_text.append(data) | |
| def parse_duckduckgo_lite(payload: str) -> list[dict[str, str]]: | |
| parser = DuckDuckGoLiteParser() | |
| parser.feed(payload) | |
| return _deduplicate(parser.results) | |
| def parse_bing_rss(payload: str) -> list[dict[str, str]]: | |
| root = ET.fromstring(payload) | |
| results: list[dict[str, str]] = [] | |
| for item in root.findall("./channel/item"): | |
| url = _result_url(item.findtext("link", "")) | |
| title = _clean_text(item.findtext("title", "")) | |
| if not url or not title: | |
| continue | |
| description = _clean_text( | |
| re.sub(r"<[^>]+>", " ", item.findtext("description", "")) | |
| ) | |
| results.append( | |
| { | |
| "title": title, | |
| "url": url, | |
| "description": description, | |
| "source": _hostname(url), | |
| } | |
| ) | |
| return _deduplicate(results) | |
| def _format_publication_date(raw_value: str) -> str: | |
| value = _clean_text(raw_value) | |
| if not value: | |
| return "" | |
| try: | |
| parsed = parsedate_to_datetime(value) | |
| except (TypeError, ValueError, OverflowError): | |
| return "" | |
| if parsed.tzinfo is not None: | |
| parsed = parsed.astimezone(timezone.utc) | |
| return parsed.strftime("%d/%m/%Y %H:%M UTC") | |
| return parsed.strftime("%d/%m/%Y %H:%M") | |
| def _news_description( | |
| raw_description: str, | |
| title: str, | |
| publisher: str, | |
| publication_date: str, | |
| ) -> str: | |
| snippet = _clean_text(re.sub(r"<[^>]+>", " ", raw_description)) | |
| for repeated in (title, publisher): | |
| if repeated: | |
| snippet = re.sub(re.escape(repeated), " ", snippet, flags=re.IGNORECASE) | |
| snippet = _clean_text(snippet) | |
| metadata: list[str] = [] | |
| if publication_date: | |
| metadata.append(f"Publicado em {publication_date}") | |
| if publisher: | |
| metadata.append(f"Fonte: {publisher}") | |
| prefix = " — ".join(metadata) | |
| if prefix and snippet: | |
| return f"{prefix}. {snippet}" | |
| if prefix: | |
| return prefix + "." | |
| return snippet | |
| def parse_google_news_rss(payload: str) -> list[dict[str, str]]: | |
| root = ET.fromstring(payload) | |
| results: list[dict[str, str]] = [] | |
| for item in root.findall("./channel/item"): | |
| url = _result_url(item.findtext("link", "")) | |
| title = _clean_text(item.findtext("title", "")) | |
| if not url or not title: | |
| continue | |
| source_node = item.find("source") | |
| publisher = ( | |
| _clean_text(source_node.text or "") if source_node is not None else "" | |
| ) | |
| source_url = ( | |
| source_node.attrib.get("url", "") if source_node is not None else "" | |
| ) | |
| source = publisher or _hostname(source_url) or _hostname(url) | |
| publication_date = _format_publication_date(item.findtext("pubDate", "")) | |
| description = _news_description( | |
| item.findtext("description", ""), | |
| title, | |
| publisher, | |
| publication_date, | |
| ) | |
| results.append( | |
| { | |
| "title": title, | |
| "url": url, | |
| "description": description, | |
| "source": source, | |
| } | |
| ) | |
| return _deduplicate(results) | |
| def _deduplicate( | |
| results: list[dict[str, str]], limit: int | None = MAX_RESULTS | |
| ) -> list[dict[str, str]]: | |
| unique: list[dict[str, str]] = [] | |
| seen_urls: set[str] = set() | |
| seen_titles: set[str] = set() | |
| for result in results: | |
| url = result.get("url", "") | |
| title = _fold_text(result.get("title", "")).strip() | |
| if not url or url in seen_urls or (title and title in seen_titles): | |
| continue | |
| seen_urls.add(url) | |
| if title: | |
| seen_titles.add(title) | |
| unique.append(result) | |
| if limit is not None and len(unique) >= limit: | |
| break | |
| return unique | |
| def _contains_word(text: str, word: str) -> bool: | |
| return bool(re.search(rf"(?<![a-z0-9]){re.escape(word)}(?![a-z0-9])", text)) | |
| def _result_score(result: dict[str, str], query: str) -> int: | |
| title = _fold_text(result.get("title", "")) | |
| description = _fold_text(result.get("description", "")) | |
| source = _fold_text(result.get("source", "")) | |
| url = _fold_text(result.get("url", "")) | |
| score = 0 | |
| for word in _query_words(query): | |
| if _contains_word(title, word): | |
| score += 8 | |
| if _contains_word(description, word): | |
| score += 3 | |
| if _contains_word(source, word) or _contains_word(url, word): | |
| score += 1 | |
| if description.startswith("publicado em "): | |
| score += 2 | |
| if _targets_rio_de_janeiro(query): | |
| combined = f"{title} {description} {source} {url}" | |
| if "rio de janeiro" in title: | |
| score += 28 | |
| elif "rio de janeiro" in combined: | |
| score += 16 | |
| if _contains_word(title, "rj"): | |
| score += 18 | |
| elif _contains_word(combined, "rj"): | |
| score += 10 | |
| if re.search(r"(?:^|[/.?&=_-])rj(?:$|[/.?&=_-])", url): | |
| score += 14 | |
| if "rio grande do sul" in combined: | |
| score -= 40 | |
| if "porto alegre" in combined: | |
| score -= 28 | |
| if _contains_word(combined, "rs"): | |
| score -= 20 | |
| if any( | |
| clue in combined | |
| for clue in ( | |
| "agorars.com", | |
| "gauchazh", | |
| "jornal o sul", | |
| "poa24horas", | |
| "/rs/rio-grande-do-sul", | |
| ) | |
| ): | |
| score -= 28 | |
| return score | |
| def _rank_results( | |
| results: list[dict[str, str]], query: str | |
| ) -> list[dict[str, str]]: | |
| unique = _deduplicate(results, limit=None) | |
| indexed = list(enumerate(unique)) | |
| indexed.sort(key=lambda pair: (-_result_score(pair[1], query), pair[0])) | |
| return [result for _, result in indexed[:MAX_RESULTS]] | |
| def _duckduckgo_lite(client: httpx.Client, query: str) -> list[dict[str, str]]: | |
| response = client.get( | |
| "https://lite.duckduckgo.com/lite/", | |
| params={"q": query, "kl": "br-pt"}, | |
| ) | |
| response.raise_for_status() | |
| return parse_duckduckgo_lite(response.text) | |
| def _bing_rss(client: httpx.Client, query: str) -> list[dict[str, str]]: | |
| response = client.get( | |
| "https://www.bing.com/search", | |
| params={"q": query, "format": "rss", "setlang": "pt-BR"}, | |
| ) | |
| response.raise_for_status() | |
| return parse_bing_rss(response.text) | |
| def _google_news_rss( | |
| client: httpx.Client, query: str | |
| ) -> list[dict[str, str]]: | |
| response = client.get( | |
| "https://news.google.com/rss/search", | |
| params={ | |
| "q": query, | |
| "hl": "pt-BR", | |
| "gl": "BR", | |
| "ceid": "BR:pt-419", | |
| }, | |
| ) | |
| response.raise_for_status() | |
| return parse_google_news_rss(response.text) | |
| def _wikipedia(client: httpx.Client, query: str) -> list[dict[str, str]]: | |
| response = client.get( | |
| "https://pt.wikipedia.org/w/api.php", | |
| params={ | |
| "action": "query", | |
| "list": "search", | |
| "srsearch": query, | |
| "format": "json", | |
| "utf8": "1", | |
| }, | |
| ) | |
| response.raise_for_status() | |
| rows = response.json().get("query", {}).get("search", []) | |
| results: list[dict[str, str]] = [] | |
| for row in rows: | |
| if not isinstance(row, dict) or not row.get("title"): | |
| continue | |
| title = str(row["title"]) | |
| url = "https://pt.wikipedia.org/wiki/" + quote( | |
| title.replace(" ", "_"), safe="()_-" | |
| ) | |
| results.append( | |
| { | |
| "title": title, | |
| "url": url, | |
| "description": _clean_text( | |
| re.sub(r"<[^>]+>", " ", str(row.get("snippet", ""))) | |
| ), | |
| "source": "pt.wikipedia.org", | |
| } | |
| ) | |
| return _deduplicate(results) | |
| def search_web(query: str) -> dict[str, Any]: | |
| normalized = _clean_text(query) | |
| if not normalized: | |
| raise ValueError("A consulta de busca não pode estar vazia.") | |
| if len(normalized) > 500: | |
| raise ValueError("A consulta de busca excede 500 caracteres.") | |
| providers: list[ | |
| tuple[str, Callable[[httpx.Client, str], list[dict[str, str]]]] | |
| ] | |
| if _is_recent_news_query(normalized): | |
| providers = [ | |
| ("google-news", _google_news_rss), | |
| ("duckduckgo-lite", _duckduckgo_lite), | |
| ("bing-rss", _bing_rss), | |
| ] | |
| else: | |
| providers = [ | |
| ("duckduckgo-lite", _duckduckgo_lite), | |
| ("bing-rss", _bing_rss), | |
| ("wikipedia-pt", _wikipedia), | |
| ] | |
| errors: list[str] = [] | |
| successful_providers: list[str] = [] | |
| aggregated_results: list[dict[str, str]] = [] | |
| with httpx.Client( | |
| timeout=SEARCH_TIMEOUT, | |
| follow_redirects=True, | |
| headers={ | |
| "User-Agent": USER_AGENT, | |
| "Accept-Language": "pt-BR,pt;q=0.9,en;q=0.7", | |
| }, | |
| ) as client: | |
| for provider_name, provider in providers: | |
| try: | |
| results = provider(client, normalized) | |
| except (httpx.HTTPError, ET.ParseError, ValueError, TypeError) as error: | |
| errors.append(f"{provider_name}: {error}") | |
| continue | |
| if results: | |
| successful_providers.append(provider_name) | |
| aggregated_results.extend(results) | |
| if len(successful_providers) >= TARGET_PROVIDER_COUNT: | |
| break | |
| else: | |
| errors.append(f"{provider_name}: nenhum resultado") | |
| if aggregated_results: | |
| return { | |
| "query": normalized, | |
| "provider": "+".join(successful_providers), | |
| "results": _rank_results(aggregated_results, normalized), | |
| } | |
| detail = "; ".join(errors) if errors else "nenhuma fonte disponível" | |
| raise SearchUnavailable(f"A busca web local falhou: {detail}") | |