Files
Domain_web/api/main.py
Андрей Бобырев e5ea14f046 feat(deploy): full one-line install with search API
Bundle static site, FastAPI search, nginx proxy, and systemd unit so
a fresh Ubuntu VPS can run curl install.sh and get /search working.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 22:29:10 +03:00

140 lines
3.9 KiB
Python

"""Domain Web search API — reuses domain-finder-bot discovery."""
from __future__ import annotations
import asyncio
import os
import sys
from contextlib import asynccontextmanager
from typing import Any
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
BOT_DIR = os.environ.get("DOMAIN_FINDER_BOT_DIR", "/opt/domain-finder-bot")
if BOT_DIR not in sys.path:
sys.path.insert(0, BOT_DIR)
from discovery import ( # noqa: E402
MAX_BATCH_DOMAINS,
discover_batch_with_timeout,
discover_with_timeout,
format_batch_domains_column,
format_batch_full_report,
format_batch_ips_column,
format_batch_v2fly_column,
format_domains_column,
format_full_report,
format_ips_column,
format_v2fly_column,
parse_domains_from_message,
)
from router_bat import ( # noqa: E402
format_router_bat,
format_router_bat_batch,
sanitize_bat_filename,
sanitize_batch_bat_filename,
)
from v2fly_lookup import start_background_sync # noqa: E402
MAX_CONCURRENT = max(1, int(os.environ.get("MAX_CONCURRENT_SEARCHES", "4")))
_SEARCH_SEM = asyncio.Semaphore(MAX_CONCURRENT)
class SearchRequest(BaseModel):
domain: str | None = None
domains: list[str] | None = None
class SearchResponse(BaseModel):
domains: list[str]
is_batch: bool
report: str
domains_column: str
ips_column: str
geosite_geoip: str
bat_content: str
bat_filename: str
def _resolve_domains(body: SearchRequest) -> list[str]:
if body.domains:
raw = "\n".join(body.domains)
elif body.domain:
raw = body.domain
else:
raise HTTPException(status_code=400, detail="Provide domain or domains")
domains = parse_domains_from_message(raw)
if not domains:
raise HTTPException(status_code=400, detail="No valid domains")
if len(domains) > MAX_BATCH_DOMAINS:
raise HTTPException(
status_code=400,
detail=f"Max {MAX_BATCH_DOMAINS} domains per request",
)
return domains
def _build_response(domains: list[str], results: list[Any]) -> SearchResponse:
is_batch = len(domains) > 1
if is_batch:
report = format_batch_full_report(results)
domains_column = format_batch_domains_column(results)
ips_column = format_batch_ips_column(results)
geosite_geoip = format_batch_v2fly_column(results)
bat_content = format_router_bat_batch(results)
bat_filename = sanitize_batch_bat_filename(domains[0], len(domains))
else:
result = results[0]
domain = domains[0]
report = format_full_report(result)
domains_column = format_domains_column(result)
ips_column = format_ips_column(result)
geosite_geoip = format_v2fly_column(result)
bat_content = format_router_bat(domain, result)
bat_filename = sanitize_bat_filename(domain)
return SearchResponse(
domains=domains,
is_batch=is_batch,
report=report,
domains_column=domains_column,
ips_column=ips_column,
geosite_geoip=geosite_geoip,
bat_content=bat_content,
bat_filename=bat_filename,
)
@asynccontextmanager
async def lifespan(_app: FastAPI):
start_background_sync()
yield
app = FastAPI(title="Domain Web Search API", lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["POST", "GET", "OPTIONS"],
allow_headers=["*"],
)
@app.get("/api/health")
async def health() -> dict[str, str]:
return {"status": "ok"}
@app.post("/api/search", response_model=SearchResponse)
async def search(body: SearchRequest) -> SearchResponse:
domains = _resolve_domains(body)
async with _SEARCH_SEM:
if len(domains) == 1:
results = [await discover_with_timeout(domains[0])]
else:
results = await discover_batch_with_timeout(domains)
return _build_response(domains, results)