#!/usr/bin/env python3 # /// script # requires-python = ">=3.11" # dependencies = ["httpx", "rich"] # /// """SearXNG CLI - Privacy-respecting metasearch via your local instance.""" import argparse import os import sys import json import warnings from pathlib import Path import httpx from rich.console import Console from rich.table import Table from rich import print as rprint # Suppress SSL warnings for local self-signed certificates warnings.filterwarnings('ignore', message='Unverified HTTPS request') console = Console() def candidate_searxng_urls(): urls = [] env_url = os.getenv("SEARXNG_URL") if env_url: urls.append(env_url) current = Path(__file__).resolve().parent for base in [current, *current.parents]: cfg = base / ".clawdbot" / "credentials" / "searxng" / "config.json" if cfg.is_file(): try: data = json.loads(cfg.read_text()) url = data.get("url") or data.get("SEARXNG_URL") if isinstance(url, str) and url: urls.append(url) except Exception: pass urls.extend([ os.path.expanduser("~/.clawdbot/credentials/searxng/config.json"), ]) # Read optional home config file path entries expanded = [] for item in urls: if isinstance(item, str) and item.endswith("config.json") and os.path.isfile(item): try: data = json.loads(Path(item).read_text()) url = data.get("url") or data.get("SEARXNG_URL") if isinstance(url, str) and url: expanded.append(url) except Exception: pass else: expanded.append(item) expanded.append("http://localhost:8080") return list(dict.fromkeys([u for u in expanded if u])) SEARXNG_URL = candidate_searxng_urls()[0] def search_searxng( query: str, limit: int = 10, category: str = "general", language: str = "auto", time_range: str = None, output_format: str = "table" ) -> dict: """ Search using SearXNG instance. Args: query: Search query string limit: Number of results to return category: Search category (general, images, news, videos, etc.) language: Language code (auto, en, de, fr, etc.) time_range: Time range filter (day, week, month, year) output_format: Output format (table, json) Returns: Dict with search results """ params = { "q": query, "format": "json", "categories": category, } if language != "auto": params["language"] = language if time_range: params["time_range"] = time_range try: # Disable SSL verification for local self-signed certs response = httpx.get( f"{SEARXNG_URL}/search", params=params, timeout=30, verify=False # For local self-signed certs ) response.raise_for_status() data = response.json() # Limit results if "results" in data: data["results"] = data["results"][:limit] return data except httpx.HTTPError as e: console.print(f"[red]Error connecting to SearXNG at {SEARXNG_URL}:[/red] {e}") return {"error": str(e), "results": []} except Exception as e: console.print(f"[red]Unexpected error while using {SEARXNG_URL}:[/red] {e}") return {"error": str(e), "results": []} def display_results_table(data: dict, query: str): """Display search results in a rich table.""" results = data.get("results", []) if not results: rprint(f"[yellow]No results found for:[/yellow] {query}") return table = Table(title=f"SearXNG Search: {query}", show_lines=False) table.add_column("#", style="dim", width=3) table.add_column("Title", style="bold") table.add_column("URL", style="blue", width=50) table.add_column("Engines", style="green", width=20) for i, result in enumerate(results, 1): title = result.get("title", "No title")[:70] url = result.get("url", "")[:45] + "..." engines = ", ".join(result.get("engines", []))[:18] table.add_row( str(i), title, url, engines ) console.print(table) # Show additional info if data.get("number_of_results"): rprint(f"\n[dim]Total results available: {data['number_of_results']}[/dim]") # Show content snippets for top 3 rprint("\n[bold]Top results:[/bold]") for i, result in enumerate(results[:3], 1): title = result.get("title", "No title") url = result.get("url", "") content = result.get("content", "")[:200] rprint(f"\n[bold cyan]{i}. {title}[/bold cyan]") rprint(f" [blue]{url}[/blue]") if content: rprint(f" [dim]{content}...[/dim]") def display_results_json(data: dict): """Display results in JSON format for programmatic use.""" print(json.dumps(data, indent=2)) def main(): parser = argparse.ArgumentParser( description="SearXNG CLI - Search the web via your local SearXNG instance", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=f""" Examples: %(prog)s search "python asyncio" %(prog)s search "climate change" -n 20 %(prog)s search "cute cats" --category images %(prog)s search "breaking news" --category news --time-range day %(prog)s search "rust tutorial" --format json Environment: SEARXNG_URL: SearXNG instance URL (default: {SEARXNG_URL}) """ ) subparsers = parser.add_subparsers(dest="command", help="Commands") # Search command search_parser = subparsers.add_parser("search", help="Search the web") search_parser.add_argument("query", nargs="+", help="Search query") search_parser.add_argument( "-n", "--limit", type=int, default=10, help="Number of results (default: 10)" ) search_parser.add_argument( "-c", "--category", default="general", choices=["general", "images", "videos", "news", "map", "music", "files", "it", "science"], help="Search category (default: general)" ) search_parser.add_argument( "-l", "--language", default="auto", help="Language code (auto, en, de, fr, etc.)" ) search_parser.add_argument( "-t", "--time-range", choices=["day", "week", "month", "year"], help="Time range filter" ) search_parser.add_argument( "-f", "--format", choices=["table", "json"], default="table", help="Output format (default: table)" ) args = parser.parse_args() if not args.command: parser.print_help() return if args.command == "search": query = " ".join(args.query) data = search_searxng( query=query, limit=args.limit, category=args.category, language=args.language, time_range=args.time_range, output_format=args.format ) if args.format == "json": display_results_json(data) else: display_results_table(data, query) if __name__ == "__main__": main()