Initial commit: DuckDuckGo MCP server
Carved out from the google-mcp monorepo. MCP server exposing DuckDuckGo search over SearXNG, with the shared search library.
This commit is contained in:
commit
6aed7974ba
2
.env.example
Normal file
2
.env.example
Normal file
@ -0,0 +1,2 @@
|
||||
# SearXNG secret key (generate: python -c "import secrets; print(secrets.token_hex(32))")
|
||||
SEARXNG_SECRET=
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Jarian Cottingham
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
2
conftest.py
Normal file
2
conftest.py
Normal file
@ -0,0 +1,2 @@
|
||||
# Ensures the repo root is on sys.path so `lib` and the server packages
|
||||
# are importable in tests without installation.
|
||||
56
docker-compose.yml
Normal file
56
docker-compose.yml
Normal file
@ -0,0 +1,56 @@
|
||||
services:
|
||||
searxng:
|
||||
image: searxng/searxng:latest
|
||||
container_name: searxng
|
||||
expose:
|
||||
- "8080"
|
||||
volumes:
|
||||
- ./searxng-settings.yml:/etc/searxng/settings.yml:ro
|
||||
environment:
|
||||
- SEARXNG_BASE_URL=http://localhost:8080/
|
||||
- SEARXNG_SECRET=${SEARXNG_SECRET:-}
|
||||
- UWSGI_WORKERS=4
|
||||
- UWSGI_THREADS=4
|
||||
restart: unless-stopped
|
||||
stop_grace_period: 5s
|
||||
networks:
|
||||
- mcp-internal
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8080/healthz')"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 15s
|
||||
|
||||
duckduckgo-search:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: duckduckgo-mcp/Dockerfile
|
||||
container_name: duckduckgo-mcp
|
||||
ports:
|
||||
- "3002:3002"
|
||||
environment:
|
||||
- RATE_LIMIT_SECONDS=3
|
||||
- MCP_PORT=3002
|
||||
- SEARXNG_URL=http://searxng:8080
|
||||
- PYTHONUNBUFFERED=1
|
||||
depends_on:
|
||||
searxng:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
stop_grace_period: 5s
|
||||
networks:
|
||||
- mcp-internal
|
||||
- mcp-external
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:3002/health')"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
|
||||
networks:
|
||||
mcp-internal:
|
||||
internal: true
|
||||
mcp-external:
|
||||
driver: bridge
|
||||
18
duckduckgo-mcp/Dockerfile
Normal file
18
duckduckgo-mcp/Dockerfile
Normal file
@ -0,0 +1,18 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY lib/ ./lib/
|
||||
COPY duckduckgo-mcp/ ./duckduckgo-mcp/
|
||||
|
||||
ENV PYTHONPATH=/app
|
||||
ENV RATE_LIMIT_SECONDS=3
|
||||
ENV SEARXNG_URL=http://searxng:8080
|
||||
ENV MCP_PORT=3002
|
||||
|
||||
CMD ["python", "-m", "duckduckgo-mcp.server"]
|
||||
0
duckduckgo-mcp/__init__.py
Normal file
0
duckduckgo-mcp/__init__.py
Normal file
313
duckduckgo-mcp/server.py
Normal file
313
duckduckgo-mcp/server.py
Normal file
@ -0,0 +1,313 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import signal
|
||||
import sys
|
||||
from collections import defaultdict
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import httpx
|
||||
from mcp.server import Server
|
||||
from mcp.server.sse import SseServerTransport
|
||||
from mcp.types import TextContent, Tool
|
||||
from starlette.applications import Starlette
|
||||
from starlette.middleware import Middleware
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import JSONResponse, Response
|
||||
from starlette.routing import Mount, Route
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] %(message)s",
|
||||
stream=sys.stderr,
|
||||
)
|
||||
logger = logging.getLogger("duckduckgo-mcp")
|
||||
|
||||
app = Server("duckduckgo-search")
|
||||
PORT = int(os.environ.get("MCP_PORT", "3002"))
|
||||
SEARXNG_URL = os.environ.get("SEARXNG_URL", "http://searxng:8080")
|
||||
RATE_LIMIT_SECONDS = float(os.environ.get("RATE_LIMIT_SECONDS", "3"))
|
||||
|
||||
DEFAULT_RESULTS = 10
|
||||
MAX_RESULTS = 20
|
||||
MAX_QUERY_LEN = 500
|
||||
|
||||
|
||||
def parse_num_results(value) -> int:
|
||||
"""Parse a result-count value, clamped to 1..MAX_RESULTS."""
|
||||
try:
|
||||
num = int(value)
|
||||
except (TypeError, ValueError):
|
||||
return DEFAULT_RESULTS
|
||||
return max(1, min(num, MAX_RESULTS))
|
||||
|
||||
|
||||
# --- HTTPX singleton (#8) ---
|
||||
|
||||
_httpx_client: httpx.AsyncClient | None = None
|
||||
|
||||
|
||||
async def get_httpx_client() -> httpx.AsyncClient:
|
||||
global _httpx_client
|
||||
if _httpx_client is None:
|
||||
_httpx_client = httpx.AsyncClient(
|
||||
timeout=httpx.Timeout(15.0),
|
||||
limits=httpx.Limits(max_connections=32, max_keepalive_connections=16),
|
||||
)
|
||||
return _httpx_client
|
||||
|
||||
|
||||
async def close_httpx_client():
|
||||
global _httpx_client
|
||||
if _httpx_client is not None:
|
||||
await _httpx_client.aclose()
|
||||
_httpx_client = None
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app):
|
||||
logger.info("HTTPX client initialized")
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
await close_httpx_client()
|
||||
logger.info("HTTPX client closed")
|
||||
|
||||
|
||||
# --- Rate limiter ---
|
||||
|
||||
class RateLimiter:
|
||||
def __init__(self, min_interval: float):
|
||||
self.min_interval = min_interval
|
||||
self._last_request = None
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
async def acquire(self):
|
||||
async with self._lock:
|
||||
now = asyncio.get_running_loop().time()
|
||||
if self._last_request:
|
||||
elapsed = now - self._last_request
|
||||
wait = self.min_interval - elapsed
|
||||
if wait > 0:
|
||||
logger.info(f"Rate limit: waiting {wait:.1f}s")
|
||||
await asyncio.sleep(wait)
|
||||
self._last_request = asyncio.get_running_loop().time()
|
||||
|
||||
|
||||
rate_limiter = RateLimiter(RATE_LIMIT_SECONDS)
|
||||
|
||||
|
||||
# --- Per-IP rate limiting middleware (#3) ---
|
||||
|
||||
class RateLimitMiddleware(BaseHTTPMiddleware):
|
||||
"""Per-IP rate limiting for HTTP endpoints."""
|
||||
|
||||
def __init__(self, app, max_requests: int = 30, window_seconds: int = 60):
|
||||
super().__init__(app)
|
||||
self.max_requests = max_requests
|
||||
self.window_seconds = window_seconds
|
||||
self._requests: dict[str, list[float]] = defaultdict(list)
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
if request.url.path == "/health":
|
||||
return await call_next(request)
|
||||
|
||||
client_ip = request.client.host if request.client else "unknown"
|
||||
now = datetime.now(timezone.utc).timestamp()
|
||||
cutoff = now - self.window_seconds
|
||||
|
||||
async with self._lock:
|
||||
if len(self._requests) > 1000:
|
||||
for ip in [
|
||||
ip
|
||||
for ip, ts in self._requests.items()
|
||||
if not ts or ts[-1] <= cutoff
|
||||
]:
|
||||
del self._requests[ip]
|
||||
timestamps = self._requests[client_ip]
|
||||
self._requests[client_ip] = [t for t in timestamps if t > cutoff]
|
||||
if len(self._requests[client_ip]) >= self.max_requests:
|
||||
return JSONResponse(
|
||||
{"error": "Rate limit exceeded. Try again later."},
|
||||
status_code=429,
|
||||
)
|
||||
self._requests[client_ip].append(now)
|
||||
|
||||
response = await call_next(request)
|
||||
return response
|
||||
|
||||
|
||||
# --- Search logic ---
|
||||
|
||||
async def do_search(query: str, num_results: int, engine: str):
|
||||
"""Shared search logic."""
|
||||
await rate_limiter.acquire()
|
||||
|
||||
client = await get_httpx_client()
|
||||
resp = await client.get(
|
||||
f"{SEARXNG_URL}/search",
|
||||
params={
|
||||
"q": query,
|
||||
"format": "json",
|
||||
"engines": engine,
|
||||
"categories": "general",
|
||||
"language": "en",
|
||||
},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
|
||||
results = data.get("results", [])[:num_results]
|
||||
return [
|
||||
{
|
||||
"title": r.get("title", ""),
|
||||
"url": r.get("url", ""),
|
||||
"snippet": r.get("content", "")[:200],
|
||||
}
|
||||
for r in results
|
||||
]
|
||||
|
||||
|
||||
# --- HTTP endpoint ---
|
||||
|
||||
async def search_http(request: Request):
|
||||
query = request.query_params.get("q", "").strip()
|
||||
if not query:
|
||||
return JSONResponse({"error": "'q' parameter required"}, status_code=400)
|
||||
if len(query) > MAX_QUERY_LEN:
|
||||
return JSONResponse(
|
||||
{"error": f"'q' must be {MAX_QUERY_LEN} characters or fewer"},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
num = parse_num_results(request.query_params.get("num"))
|
||||
try:
|
||||
results = await do_search(query, num, "duckduckgo")
|
||||
return JSONResponse({"query": query, "results": results})
|
||||
except Exception:
|
||||
logger.exception("Search failed")
|
||||
return JSONResponse({"error": "Search service unavailable"}, status_code=503)
|
||||
|
||||
|
||||
# --- Health check (#6) ---
|
||||
|
||||
async def health_check(request: Request):
|
||||
return JSONResponse({"status": "ok", "service": "duckduckgo-mcp"})
|
||||
|
||||
|
||||
# --- MCP tool handlers ---
|
||||
|
||||
@app.list_tools()
|
||||
async def list_tools() -> list[Tool]:
|
||||
return [
|
||||
Tool(
|
||||
name="duckduckgo_search",
|
||||
description="Search DuckDuckGo and return results with titles, URLs, and snippets.",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {"type": "string", "description": "The search query."},
|
||||
"num_results": {
|
||||
"type": "integer",
|
||||
"description": "Maximum number of results (default 10).",
|
||||
"default": 10,
|
||||
},
|
||||
},
|
||||
"required": ["query"],
|
||||
},
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
@app.call_tool()
|
||||
async def call_tool(name: str, arguments: dict) -> list[TextContent]:
|
||||
if name != "duckduckgo_search":
|
||||
raise ValueError(f"Unknown tool: {name}")
|
||||
|
||||
query = arguments.get("query", "")
|
||||
if not isinstance(query, str) or not query:
|
||||
return [TextContent(type="text", text="Error: 'query' is required.")]
|
||||
if len(query) > MAX_QUERY_LEN:
|
||||
return [
|
||||
TextContent(
|
||||
type="text",
|
||||
text=f"Error: query must be {MAX_QUERY_LEN} characters or fewer.",
|
||||
)
|
||||
]
|
||||
|
||||
num_results = parse_num_results(arguments.get("num_results"))
|
||||
|
||||
try:
|
||||
results = await do_search(query, num_results, "duckduckgo")
|
||||
except Exception:
|
||||
logger.exception("Search failed")
|
||||
return [TextContent(type="text", text="Search failed: service unavailable")]
|
||||
|
||||
if not results:
|
||||
return [TextContent(type="text", text=f"No results found for: {query}")]
|
||||
|
||||
lines = [f"Search results for: {query}\n"]
|
||||
for i, r in enumerate(results, 1):
|
||||
lines.append(f"{i}. {r['title']}")
|
||||
lines.append(f" URL: {r['url']}")
|
||||
if r["snippet"]:
|
||||
lines.append(f" {r['snippet']}")
|
||||
lines.append("")
|
||||
|
||||
return [TextContent(type="text", text="\n".join(lines))]
|
||||
|
||||
|
||||
# --- SSE MCP transport ---
|
||||
|
||||
sse = SseServerTransport("/messages/")
|
||||
|
||||
|
||||
async def handle_sse(request):
|
||||
async with sse.connect_sse(
|
||||
request.scope, request.receive, request._send
|
||||
) as (read_stream, write_stream):
|
||||
await app.run(
|
||||
read_stream,
|
||||
write_stream,
|
||||
app.create_initialization_options(),
|
||||
)
|
||||
return Response()
|
||||
|
||||
|
||||
starlette_app = Starlette(
|
||||
debug=False,
|
||||
lifespan=lifespan,
|
||||
routes=[
|
||||
Route("/health", endpoint=health_check),
|
||||
Route("/search", endpoint=search_http),
|
||||
Route("/sse", endpoint=handle_sse),
|
||||
Mount("/messages/", app=sse.handle_post_message),
|
||||
],
|
||||
middleware=[Middleware(RateLimitMiddleware)],
|
||||
)
|
||||
|
||||
|
||||
async def main():
|
||||
logger.info(f"DuckDuckGo MCP server ready on port {PORT}")
|
||||
|
||||
import uvicorn
|
||||
|
||||
config = uvicorn.Config(starlette_app, host="0.0.0.0", port=PORT, log_level="info")
|
||||
server = uvicorn.Server(config)
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
def handle_signal():
|
||||
server.should_exit = True
|
||||
|
||||
for sig in (signal.SIGINT, signal.SIGTERM):
|
||||
loop.add_signal_handler(sig, handle_signal)
|
||||
|
||||
await server.serve()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
25
lib/__init__.py
Normal file
25
lib/__init__.py
Normal file
@ -0,0 +1,25 @@
|
||||
"""Shared search utilities.
|
||||
|
||||
Playwright-based scrapers are imported lazily so the package (and its
|
||||
rate limiter) works without the optional ``playwright`` dependency.
|
||||
"""
|
||||
|
||||
from lib.rate_limiter import RateLimiter
|
||||
|
||||
__all__ = ["PlaywrightManager", "RateLimiter", "GoogleSearch", "DuckDuckGoSearch"]
|
||||
|
||||
|
||||
def __getattr__(name):
|
||||
if name == "PlaywrightManager":
|
||||
from lib.playwright_manager import PlaywrightManager
|
||||
|
||||
return PlaywrightManager
|
||||
if name == "GoogleSearch":
|
||||
from lib.google_search import GoogleSearch
|
||||
|
||||
return GoogleSearch
|
||||
if name == "DuckDuckGoSearch":
|
||||
from lib.duckduckgo_search import DuckDuckGoSearch
|
||||
|
||||
return DuckDuckGoSearch
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
79
lib/duckduckgo_search.py
Normal file
79
lib/duckduckgo_search.py
Normal file
@ -0,0 +1,79 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from lib.playwright_manager import PlaywrightManager
|
||||
from lib.rate_limiter import RateLimiter
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DDG_URL = "https://duckduckgo.com/html/"
|
||||
|
||||
|
||||
class DuckDuckGoSearch:
|
||||
"""
|
||||
Search DuckDuckGo using Playwright headless browser.
|
||||
Falls back to lightweight HTTP mode if available.
|
||||
"""
|
||||
|
||||
def __init__(self, min_interval: float = 3.0):
|
||||
self.rate_limiter = RateLimiter(min_interval_seconds=min_interval)
|
||||
|
||||
async def search(
|
||||
self, query: str, num_results: int = 10
|
||||
) -> List[Dict[str, Any]]:
|
||||
await self.rate_limiter.acquire()
|
||||
|
||||
page = None
|
||||
try:
|
||||
page = await PlaywrightManager.get_page()
|
||||
logger.info(f"Navigating to DuckDuckGo search: {query}")
|
||||
|
||||
await page.goto(DDG_URL, wait_until="domcontentloaded", timeout=30000)
|
||||
|
||||
await page.fill('input[name="q"]', query)
|
||||
await page.keyboard.press("Enter")
|
||||
|
||||
await page.wait_for_selector(
|
||||
"#rweb-results .result", state="attached", timeout=15000
|
||||
)
|
||||
await asyncio.sleep(1)
|
||||
|
||||
results = await self._parse_page(page, num_results)
|
||||
logger.info(
|
||||
f"DuckDuckGo search returned {len(results)} results for: {query}"
|
||||
)
|
||||
return results
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"DuckDuckGo search failed for '{query}': {e}")
|
||||
raise RuntimeError(f"DuckDuckGo search failed: {e}") from e
|
||||
finally:
|
||||
if page:
|
||||
await PlaywrightManager.close_page(page)
|
||||
|
||||
async def _parse_page(
|
||||
self, page, max_results: int
|
||||
) -> List[Dict[str, Any]]:
|
||||
results = await page.evaluate(
|
||||
"""() => {
|
||||
const items = document.querySelectorAll('#rweb-results .result');
|
||||
const results = [];
|
||||
for (const item of items) {
|
||||
const a = item.querySelector('.result__a');
|
||||
const h2 = item.querySelector('.result__title');
|
||||
const snippetEl = item.querySelector('.result__snippet');
|
||||
if (!a || !h2) continue;
|
||||
|
||||
const url = a.href || '';
|
||||
const title = h2.textContent?.trim() || '';
|
||||
const snippet = snippetEl?.textContent?.trim() || '';
|
||||
|
||||
if (title) {
|
||||
results.push({ title, url, snippet });
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}"""
|
||||
)
|
||||
return results[:max_results]
|
||||
163
lib/google_search.py
Normal file
163
lib/google_search.py
Normal file
@ -0,0 +1,163 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
import urllib.parse
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from lib.playwright_manager import PlaywrightManager
|
||||
from lib.rate_limiter import RateLimiter
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
GOOGLE_URL = "https://www.google.com/search"
|
||||
|
||||
|
||||
class GoogleSearch:
|
||||
"""
|
||||
Search Google using Playwright headless browser with stealth mode.
|
||||
Respects rate limits and cleans up page resources after each search.
|
||||
"""
|
||||
|
||||
def __init__(self, min_interval: float = 5.0):
|
||||
self.rate_limiter = RateLimiter(min_interval_seconds=min_interval)
|
||||
|
||||
async def search(
|
||||
self, query: str, num_results: int = 10, language: str = "en"
|
||||
) -> List[Dict[str, Any]]:
|
||||
await self.rate_limiter.acquire()
|
||||
|
||||
page = None
|
||||
try:
|
||||
page = await PlaywrightManager.get_page(stealth=True)
|
||||
|
||||
url_params = (
|
||||
f"{GOOGLE_URL}?q={urllib.parse.quote(query)}"
|
||||
f"&num={min(num_results, 20)}&hl={language}"
|
||||
)
|
||||
logger.info(f"Navigating to Google search: {query}")
|
||||
|
||||
await page.goto(url_params, wait_until="domcontentloaded", timeout=30000)
|
||||
await asyncio.sleep(3)
|
||||
|
||||
html = await page.content()
|
||||
results = self._parse_results(html, num_results)
|
||||
logger.info(f"Google search returned {len(results)} results for: {query}")
|
||||
return results
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Google search failed for '{query}': {e}")
|
||||
raise RuntimeError(f"Google search failed: {e}") from e
|
||||
finally:
|
||||
if page:
|
||||
await PlaywrightManager.close_page(page)
|
||||
|
||||
def _parse_results(
|
||||
self, html: str, max_results: int
|
||||
) -> List[Dict[str, Any]]:
|
||||
results = []
|
||||
seen_urls = set()
|
||||
|
||||
# Strategy 1: Parse /url?q= links (Google's redirect URLs)
|
||||
url_pattern = re.findall(
|
||||
r'<a[^>]*href="(/url\?q=([^&"]+)&[^"]*)"[^>]*>(.*?)</a>',
|
||||
html,
|
||||
re.DOTALL,
|
||||
)
|
||||
|
||||
for _, raw_url, anchor_html in url_pattern:
|
||||
if len(results) >= max_results:
|
||||
break
|
||||
|
||||
url = self._clean_url(raw_url)
|
||||
if not url or url in seen_urls or "google.com" in url:
|
||||
continue
|
||||
seen_urls.add(url)
|
||||
|
||||
title = re.sub(r'<[^>]+>', "", anchor_html).strip()
|
||||
if not title or len(title) < 3:
|
||||
continue
|
||||
|
||||
results.append({"title": title, "url": url, "snippet": ""})
|
||||
|
||||
# Strategy 2: If we got results, try to pair them with nearby snippets
|
||||
if results:
|
||||
results = self._add_snippets(html, results)
|
||||
else:
|
||||
# Strategy 3: Parse data-href attributes (modern Google)
|
||||
data_hrefs = re.findall(r'data-href="(.*?)"', html)
|
||||
for raw_url in data_hrefs:
|
||||
if len(results) >= max_results:
|
||||
break
|
||||
url = self._clean_url(raw_url)
|
||||
if not url or url in seen_urls or "google.com" in url:
|
||||
continue
|
||||
seen_urls.add(url)
|
||||
results.append({"title": url, "url": url, "snippet": ""})
|
||||
|
||||
# Strategy 4: Parse from <h3> tags and nearby <a> tags
|
||||
if not results:
|
||||
results = self._parse_from_h3(html, max_results)
|
||||
|
||||
return results[:max_results]
|
||||
|
||||
def _clean_url(self, raw_url: str) -> str:
|
||||
"""Decode Google redirect URL."""
|
||||
raw_url = urllib.parse.unquote(raw_url)
|
||||
# Remove tracking parameters
|
||||
url = re.sub(r"&[a-z_]+=.*$", "", raw_url)
|
||||
url = re.sub(r"[?#].*$", "", url)
|
||||
if not url.startswith("http"):
|
||||
url = "https://" + url.lstrip("//")
|
||||
return url.strip()
|
||||
|
||||
def _add_snippets(
|
||||
self, html: str, results: List[Dict[str, Any]]
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Try to find snippets near result URLs."""
|
||||
snippet_blocks = re.findall(
|
||||
r'<span[^>]*>([\s\S]*?)</span>', html
|
||||
)
|
||||
for i, result in enumerate(results):
|
||||
for block in snippet_blocks:
|
||||
clean = re.sub(r'<[^>]+>', "", block).strip()
|
||||
if not 50 < len(clean) < 300:
|
||||
continue
|
||||
pos = html.find(clean)
|
||||
if pos == -1:
|
||||
continue
|
||||
window = html[max(0, pos - 500): pos + 500]
|
||||
if result["url"] in window:
|
||||
result["snippet"] = clean[:200]
|
||||
break
|
||||
return results
|
||||
|
||||
def _parse_from_h3(
|
||||
self, html: str, max_results: int
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Parse results from h3 title tags and nearby links."""
|
||||
results = []
|
||||
h3_pattern = re.findall(
|
||||
r'<h3[^>]*>([\s\S]*?)</h3>', html, re.DOTALL
|
||||
)
|
||||
|
||||
for h3_content in h3_pattern:
|
||||
if len(results) >= max_results:
|
||||
break
|
||||
|
||||
# Extract title from h3
|
||||
title = re.sub(r'<[^>]+>', "", h3_content).strip()
|
||||
if not title or len(title) < 3:
|
||||
continue
|
||||
|
||||
# Find the link inside the h3
|
||||
link_match = re.search(r'href="([^"]+)"', h3_content)
|
||||
url = ""
|
||||
if link_match:
|
||||
url = self._clean_url(link_match.group(1))
|
||||
|
||||
if url and "google.com" not in url:
|
||||
results.append({"title": title, "url": url, "snippet": ""})
|
||||
elif title:
|
||||
results.append({"title": title, "url": url, "snippet": ""})
|
||||
|
||||
return results
|
||||
114
lib/playwright_manager.py
Normal file
114
lib/playwright_manager.py
Normal file
@ -0,0 +1,114 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from playwright.async_api import Browser, BrowserContext, Page, async_playwright
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PlaywrightManager:
|
||||
"""
|
||||
Singleton-style manager for a single Playwright browser instance.
|
||||
Ensures only ONE browser/process runs per container, with proper cleanup.
|
||||
"""
|
||||
|
||||
_instance: Optional["PlaywrightManager"] = None
|
||||
_pw_cm = None
|
||||
_pw = None
|
||||
_browser: Optional[Browser] = None
|
||||
_context: Optional[BrowserContext] = None
|
||||
_initialized = False
|
||||
_cleanup_lock = asyncio.Lock()
|
||||
|
||||
def __new__(cls):
|
||||
if cls._instance is None:
|
||||
cls._instance = super().__new__(cls)
|
||||
return cls._instance
|
||||
|
||||
@classmethod
|
||||
async def initialize(cls, user_agent: Optional[str] = None, headless: bool = True):
|
||||
if cls._initialized:
|
||||
logger.debug("Playwright already initialized, reusing instance")
|
||||
return cls._instance
|
||||
|
||||
cls._pw_cm = async_playwright()
|
||||
cls._pw = await cls._pw_cm.__aenter__()
|
||||
browser_args = {
|
||||
"headless": headless,
|
||||
"args": [
|
||||
"--no-sandbox",
|
||||
"--disable-setuid-sandbox",
|
||||
"--disable-dev-shm-usage",
|
||||
"--disable-gpu",
|
||||
"--disable-blink-features=AutomationControlled",
|
||||
],
|
||||
}
|
||||
cls._browser = await cls._pw.chromium.launch(**browser_args)
|
||||
|
||||
ua = user_agent or (
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
||||
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
||||
"Chrome/131.0.0.0 Safari/131.0.0.0"
|
||||
)
|
||||
cls._context = await cls._browser.new_context(
|
||||
user_agent=ua,
|
||||
viewport={"width": 1920, "height": 1080},
|
||||
locale="en-US",
|
||||
timezone_id="America/New_York",
|
||||
extra_http_headers={"Accept-Language": "en-US,en;q=0.9"},
|
||||
)
|
||||
cls._initialized = True
|
||||
logger.info("Playwright browser initialized (single instance)")
|
||||
return cls._instance
|
||||
|
||||
@classmethod
|
||||
async def get_page(cls, stealth: bool = False) -> Page:
|
||||
if not cls._initialized:
|
||||
raise RuntimeError(
|
||||
"PlaywrightManager not initialized. Call initialize() first."
|
||||
)
|
||||
page = await cls._context.new_page()
|
||||
if stealth:
|
||||
try:
|
||||
from playwright_stealth import Stealth
|
||||
stealth = Stealth()
|
||||
await stealth.apply_stealth_async(page)
|
||||
logger.debug("Stealth mode applied to page")
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not apply stealth: {e}")
|
||||
return page
|
||||
|
||||
@classmethod
|
||||
async def close_page(cls, page: Page):
|
||||
try:
|
||||
await page.close()
|
||||
except Exception as e:
|
||||
logger.debug(f"Error closing page: {e}")
|
||||
|
||||
@classmethod
|
||||
async def shutdown(cls):
|
||||
async with cls._cleanup_lock:
|
||||
if not cls._initialized:
|
||||
return
|
||||
logger.info("Shutting down Playwright browser...")
|
||||
try:
|
||||
if cls._context:
|
||||
await cls._context.close()
|
||||
cls._context = None
|
||||
if cls._browser:
|
||||
await cls._browser.close()
|
||||
cls._browser = None
|
||||
if cls._pw_cm:
|
||||
await cls._pw_cm.__aexit__(None, None, None)
|
||||
cls._pw = None
|
||||
cls._pw_cm = None
|
||||
except Exception as e:
|
||||
logger.error(f"Error during Playwright shutdown: {e}")
|
||||
finally:
|
||||
cls._initialized = False
|
||||
cls._instance = None
|
||||
|
||||
@classmethod
|
||||
def is_initialized(cls) -> bool:
|
||||
return cls._initialized
|
||||
32
lib/rate_limiter.py
Normal file
32
lib/rate_limiter.py
Normal file
@ -0,0 +1,32 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RateLimiter:
|
||||
"""
|
||||
Enforces minimum delay between requests to avoid throttling.
|
||||
Thread-safe for async use within a single event loop.
|
||||
"""
|
||||
|
||||
def __init__(self, min_interval_seconds: float = 3.0):
|
||||
self.min_interval = min_interval_seconds
|
||||
self._last_request_time: Optional[float] = None
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
async def acquire(self):
|
||||
async with self._lock:
|
||||
now = time.monotonic()
|
||||
if self._last_request_time is not None:
|
||||
elapsed = now - self._last_request_time
|
||||
wait_time = self.min_interval - elapsed
|
||||
if wait_time > 0:
|
||||
logger.info(
|
||||
f"Rate limiter: waiting {wait_time:.1f}s "
|
||||
f"(min interval: {self.min_interval}s)"
|
||||
)
|
||||
await asyncio.sleep(wait_time)
|
||||
self._last_request_time = time.monotonic()
|
||||
2
requirements.txt
Normal file
2
requirements.txt
Normal file
@ -0,0 +1,2 @@
|
||||
mcp>=1.0.0,<2.0.0
|
||||
httpx>=0.27.0
|
||||
88
searxng-settings.yml
Normal file
88
searxng-settings.yml
Normal file
@ -0,0 +1,88 @@
|
||||
use_default_settings: True
|
||||
|
||||
general:
|
||||
instance_name: "SearXNG"
|
||||
debug: false
|
||||
mail_to: admin@example.com
|
||||
contact_url: false
|
||||
donation_url: false
|
||||
enable_metrics: true
|
||||
|
||||
search:
|
||||
safe_search: 0
|
||||
autocomplete: "google"
|
||||
default_lang: "en-US"
|
||||
formats:
|
||||
- html
|
||||
- json
|
||||
|
||||
server:
|
||||
port: 8080
|
||||
bind_address: "0.0.0.0"
|
||||
secret_key: "${SEARXNG_SECRET}"
|
||||
limiter: true
|
||||
image_proxy: true
|
||||
method: "GET"
|
||||
|
||||
engines:
|
||||
- name: google
|
||||
engine: google
|
||||
sorting: relevance
|
||||
use_mobile_ui: false
|
||||
max_pages: 10
|
||||
enabled: true
|
||||
weight: 2
|
||||
|
||||
- name: duckduckgo
|
||||
engine: duckduckgo
|
||||
use_mobile_ui: false
|
||||
enabled: true
|
||||
weight: 1
|
||||
|
||||
- name: brave
|
||||
engine: brave
|
||||
enabled: true
|
||||
weight: 1
|
||||
|
||||
- name: wikipedia
|
||||
engine: wikipedia
|
||||
enabled: true
|
||||
weight: 1
|
||||
|
||||
- name: bing
|
||||
engine: bing
|
||||
enabled: true
|
||||
weight: 1
|
||||
|
||||
- name: startpage
|
||||
engine: startpage
|
||||
enabled: true
|
||||
weight: 1
|
||||
|
||||
- name: qwant
|
||||
engine: qwant
|
||||
enabled: true
|
||||
weight: 1
|
||||
|
||||
- name: mozillian_ahrefs
|
||||
engine: mozillian_ahrefs
|
||||
enabled: true
|
||||
|
||||
- name: library_genesis
|
||||
engine: library_genesis
|
||||
enabled: false
|
||||
|
||||
- name: google news
|
||||
engine: google_news
|
||||
enabled: true
|
||||
|
||||
- name: duckduckgo news
|
||||
engine: duckduckgo_news
|
||||
enabled: true
|
||||
|
||||
outgoing:
|
||||
request_timeout: 5.0
|
||||
max_retries: 1
|
||||
user_agent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/131.0.0.0"
|
||||
max_connection_limit: 128
|
||||
max_keepalive_connection: 20
|
||||
176
test_client.py
Normal file
176
test_client.py
Normal file
@ -0,0 +1,176 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Test client that connects to an MCP server over stdio and runs a search.
|
||||
Usage:
|
||||
python test_client.py google # tests google-mcp container
|
||||
python test_client.py duckduckgo # tests duckduckgo-mcp container
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
|
||||
async def test_mcp_server(service_name: str):
|
||||
container_name = f"{service_name}-mcp"
|
||||
query = "Python programming language"
|
||||
print(f"\n{'='*60}")
|
||||
print(f"Testing {container_name} with query: '{query}'")
|
||||
print(f"{'='*60}")
|
||||
|
||||
proc = subprocess.Popen(
|
||||
[
|
||||
"docker", "exec", "-i", container_name,
|
||||
"python", "-c",
|
||||
f"""
|
||||
import sys
|
||||
sys.path.insert(0, '/app')
|
||||
import asyncio
|
||||
from mcp.server.stdio import stdio_server
|
||||
from {service_name.replace('-', '_')}_mcp.server import app
|
||||
|
||||
async def test():
|
||||
async with stdio_server() as (read, write):
|
||||
await asyncio.sleep(0.5)
|
||||
init_req = json.loads(await read.__anext__())
|
||||
# Send initialize response
|
||||
init_result = {{
|
||||
'jsonrpc': '2.0', 'id': init_req['id'],
|
||||
'result': {{
|
||||
'protocolVersion': '2024-11-05', 'capabilities': {{}},
|
||||
'serverInfo': {{'name': 'test', 'version': '1.0'}},
|
||||
}},
|
||||
}}
|
||||
await write.send(json.dumps(init_result))
|
||||
# Client sends initialized notification
|
||||
await read.__anext__()
|
||||
# List tools
|
||||
list_req = {{'jsonrpc': '2.0', 'id': 1, 'method': 'tools/list', 'params': {{}}}}
|
||||
await write.send(json.dumps(list_req))
|
||||
list_resp = json.loads(await read.__anext__())
|
||||
print("TOOLS:", json.dumps(list_resp.get('result', {{}}).get('tools', []), indent=2))
|
||||
""",
|
||||
],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
stdin=subprocess.PIPE,
|
||||
)
|
||||
|
||||
try:
|
||||
stdout, stderr = proc.communicate(timeout=30)
|
||||
print("STDOUT:", stdout.decode())
|
||||
if stderr:
|
||||
print("STDERR:", stderr.decode())
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
print("TIMEOUT: Test timed out after 30s")
|
||||
return False
|
||||
|
||||
return proc.returncode == 0
|
||||
|
||||
|
||||
async def test_with_curl(service_name: str):
|
||||
"""Test using docker exec to run a Python test directly in the container."""
|
||||
container_name = f"{service_name}-mcp"
|
||||
|
||||
test_script = f"""
|
||||
import sys, asyncio, os
|
||||
sys.path.insert(0, '/app')
|
||||
os.environ.setdefault('RATE_LIMIT_SECONDS', '1')
|
||||
|
||||
from lib.playwright_manager import PlaywrightManager
|
||||
from lib.google_search import GoogleSearch
|
||||
from lib.duckduckgo_search import DuckDuckGoSearch
|
||||
|
||||
async def test():
|
||||
await PlaywrightManager.initialize()
|
||||
try:
|
||||
query = "Python programming language"
|
||||
if "{service_name}" == "google":
|
||||
engine = GoogleSearch(min_interval=1)
|
||||
results = await engine.search(query, num_results=5)
|
||||
else:
|
||||
engine = DuckDuckGoSearch(min_interval=1)
|
||||
results = await engine.search(query, num_results=5)
|
||||
|
||||
print(f"Query: {{query}}")
|
||||
print(f"Results found: {{len(results)}}")
|
||||
for i, r in enumerate(results, 1):
|
||||
title = r.get('title', 'N/A')
|
||||
url = r.get('url', 'N/A')
|
||||
snippet = r.get('snippet', '')[:100]
|
||||
print(f" {{i}}. {{title}}")
|
||||
print(f" URL: {{url}}")
|
||||
if snippet:
|
||||
print(f" {{snippet}}")
|
||||
|
||||
if results:
|
||||
print("\\nSUCCESS: Got {{len(results)}} results")
|
||||
sys.exit(0)
|
||||
else:
|
||||
print("\\nFAILURE: No results returned")
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
print(f"ERROR: {{e}}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
sys.exit(1)
|
||||
finally:
|
||||
await PlaywrightManager.shutdown()
|
||||
|
||||
asyncio.run(test())
|
||||
"""
|
||||
|
||||
print(f"\n{'='*60}")
|
||||
print(f"Testing {container_name}")
|
||||
print(f"{'='*60}")
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[
|
||||
"docker", "exec", "-i", container_name,
|
||||
"python", "-c", test_script,
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
)
|
||||
print(result.stdout)
|
||||
if result.stderr:
|
||||
print("STDERR:", result.stderr)
|
||||
return result.returncode == 0
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
print("TIMEOUT: Test timed out after 60s")
|
||||
return False
|
||||
except FileNotFoundError:
|
||||
print("ERROR: docker not found")
|
||||
return False
|
||||
|
||||
|
||||
async def main():
|
||||
if len(sys.argv) < 2:
|
||||
print("Usage: python test_client.py [google|duckduckgo|both]")
|
||||
sys.exit(1)
|
||||
|
||||
target = sys.argv[1]
|
||||
services = ["google", "duckduckgo"] if target == "both" else [target]
|
||||
|
||||
results = {}
|
||||
for service in services:
|
||||
success = await test_with_curl(service)
|
||||
results[service] = success
|
||||
|
||||
print(f"\n{'='*60}")
|
||||
print("SUMMARY")
|
||||
print(f"{'='*60}")
|
||||
for service, success in results.items():
|
||||
status = "PASS" if success else "FAIL"
|
||||
print(f" {service}: {status}")
|
||||
|
||||
all_passed = all(results.values())
|
||||
sys.exit(0 if all_passed else 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
Loading…
x
Reference in New Issue
Block a user