This commit is contained in:
Binary file not shown.
@@ -1,18 +1,83 @@
|
||||
from fastapi import FastAPI
|
||||
from fastapi.openapi.utils import get_openapi
|
||||
from fastapi.security import APIKeyHeader
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
from app.database import init_db, cleanup_old_cache_entries
|
||||
from app.config import CLEANUP_CRON, CACHE_EXPIRY_HOURS
|
||||
from app.routes import health, browser, cache
|
||||
from app.services.browser import force_cleanup_old_pages
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _read_version() -> str:
|
||||
version_file = Path(__file__).resolve().parents[1] / "version"
|
||||
try:
|
||||
v = version_file.read_text(encoding="utf-8").strip()
|
||||
return v or "latest"
|
||||
except Exception:
|
||||
return "latest"
|
||||
|
||||
|
||||
API_KEY_HEADER_NAME = "X-API-Key"
|
||||
api_key_header = APIKeyHeader(name=API_KEY_HEADER_NAME, auto_error=False)
|
||||
|
||||
# Create FastAPI app
|
||||
app = FastAPI()
|
||||
app = FastAPI(
|
||||
title="Playwright API Server",
|
||||
version=_read_version(),
|
||||
description=(
|
||||
"FastAPI server that uses Playwright to visit pages, capture screenshots, "
|
||||
"extract SEO/meta information, and manage a cache.\n\n"
|
||||
f"Authentication: provide `{API_KEY_HEADER_NAME}` on requests."
|
||||
),
|
||||
docs_url="/docs",
|
||||
redoc_url="/redoc",
|
||||
openapi_url="/openapi.json",
|
||||
swagger_ui_parameters={"persistAuthorization": True},
|
||||
)
|
||||
|
||||
|
||||
def custom_openapi():
|
||||
if app.openapi_schema:
|
||||
return app.openapi_schema
|
||||
|
||||
openapi_schema = get_openapi(
|
||||
title=app.title,
|
||||
version=app.version,
|
||||
description=app.description,
|
||||
routes=app.routes,
|
||||
)
|
||||
|
||||
components = openapi_schema.setdefault("components", {})
|
||||
security_schemes = components.setdefault("securitySchemes", {})
|
||||
security_schemes["ApiKeyAuth"] = {
|
||||
"type": "apiKey",
|
||||
"in": "header",
|
||||
"name": API_KEY_HEADER_NAME,
|
||||
"description": f"Send your API key in the `{API_KEY_HEADER_NAME}` header.",
|
||||
}
|
||||
|
||||
# Apply API key auth globally (individual endpoints may still allow anonymous access;
|
||||
# this just documents the expected auth mechanism).
|
||||
openapi_schema["security"] = [{"ApiKeyAuth": []}]
|
||||
|
||||
openapi_schema["tags"] = [
|
||||
{"name": "Health", "description": "Liveness / basic health checks."},
|
||||
{"name": "Browser", "description": "Playwright-powered browsing utilities."},
|
||||
{"name": "Cache", "description": "Cache management and statistics."},
|
||||
]
|
||||
|
||||
app.openapi_schema = openapi_schema
|
||||
return app.openapi_schema
|
||||
|
||||
|
||||
app.openapi = custom_openapi
|
||||
|
||||
# Include routers
|
||||
app.include_router(health.router)
|
||||
app.include_router(browser.router)
|
||||
app.include_router(cache.router)
|
||||
app.include_router(health.router, tags=["Health"])
|
||||
app.include_router(browser.router, tags=["Browser"])
|
||||
app.include_router(cache.router, tags=["Cache"])
|
||||
|
||||
# Initialize scheduler for periodic cache cleanup
|
||||
scheduler = BackgroundScheduler()
|
||||
|
||||
@@ -14,7 +14,7 @@ from app.services.browser import (
|
||||
get_resulting_url_service
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
router = APIRouter(tags=["Browser"])
|
||||
|
||||
class ClickInteraction(BaseModel):
|
||||
action: Literal["click"]
|
||||
@@ -43,14 +43,21 @@ class Viewport(BaseModel):
|
||||
|
||||
|
||||
class InteractionsRequest(BaseModel):
|
||||
url: str
|
||||
returnType: Optional[Literal["screenshot", "html"]] = "screenshot"
|
||||
url: str = Field(..., description="Target URL (will be URL-decoded server-side).")
|
||||
returnType: Optional[Literal["screenshot", "html"]] = Field(
|
||||
"screenshot",
|
||||
description="Return a PNG screenshot or HTML content.",
|
||||
)
|
||||
viewport: Optional[Viewport] = None
|
||||
scrollX: Optional[int] = None
|
||||
scrollY: Optional[int] = None
|
||||
interactions: List[Interaction]
|
||||
|
||||
@router.get("/")
|
||||
@router.get(
|
||||
"/",
|
||||
summary="Visit URL",
|
||||
description="Navigate to `url` and return HTML content (cached unless `skipCache=true`).",
|
||||
)
|
||||
async def visit_url(url: str, skipCache: bool = False, x_api_key: Optional[str] = Header(None)):
|
||||
# Validate API key
|
||||
if not x_api_key or x_api_key != API_KEY:
|
||||
@@ -78,7 +85,16 @@ async def visit_url(url: str, skipCache: bool = False, x_api_key: Optional[str]
|
||||
print(f"Error visiting URL {decoded_url}: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.get("/screenshot")
|
||||
@router.get(
|
||||
"/screenshot",
|
||||
summary="Capture screenshot",
|
||||
description="Capture a PNG screenshot for `url`.",
|
||||
responses={
|
||||
200: {"content": {"image/png": {}}},
|
||||
401: {"description": "Invalid API key"},
|
||||
500: {"description": "Screenshot capture failed"},
|
||||
},
|
||||
)
|
||||
async def screenshot_url(url: str, fullPage: bool = True, x_api_key: Optional[str] = Header(None)):
|
||||
"""Capture a screenshot of a website and return it as PNG"""
|
||||
if not x_api_key or x_api_key != API_KEY:
|
||||
@@ -102,7 +118,26 @@ async def screenshot_url(url: str, fullPage: bool = True, x_api_key: Optional[st
|
||||
print(f"Error taking screenshot for URL {decoded_url}: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.post("/interactions")
|
||||
@router.post(
|
||||
"/interactions",
|
||||
summary="Run interactions",
|
||||
description=(
|
||||
"Run a sequence of interactions (click/type) on a page.\n\n"
|
||||
"- `returnType=screenshot` returns `image/png`\n"
|
||||
"- `returnType=html` returns `text/html`"
|
||||
),
|
||||
responses={
|
||||
200: {
|
||||
"content": {
|
||||
"image/png": {},
|
||||
"text/html": {},
|
||||
"application/json": {},
|
||||
}
|
||||
},
|
||||
401: {"description": "Invalid API key"},
|
||||
500: {"description": "Interaction failed"},
|
||||
},
|
||||
)
|
||||
async def run_interactions(payload: InteractionsRequest, x_api_key: Optional[str] = Header(None)):
|
||||
if not x_api_key or x_api_key != API_KEY:
|
||||
raise HTTPException(status_code=401, detail="Invalid API key")
|
||||
@@ -137,7 +172,11 @@ async def run_interactions(payload: InteractionsRequest, x_api_key: Optional[str
|
||||
print(f"Error running interactions on URL {decoded_url}: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.get("/seo")
|
||||
@router.get(
|
||||
"/seo",
|
||||
summary="Extract SEO information",
|
||||
description="Extract SEO information from `url` (cached unless `skipCache=true`).",
|
||||
)
|
||||
async def extract_seo(url: str, skipCache: bool = False, x_api_key: Optional[str] = Header(None)):
|
||||
"""Extract SEO information from a website"""
|
||||
# Validate API key
|
||||
@@ -165,7 +204,11 @@ async def extract_seo(url: str, skipCache: bool = False, x_api_key: Optional[str
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.get("/meta")
|
||||
@router.get(
|
||||
"/meta",
|
||||
summary="Extract meta tags",
|
||||
description="Extract meta tags / Open Graph / Twitter card data from `url` (cached unless `skipCache=true`).",
|
||||
)
|
||||
async def extract_meta_tags(url: str, skipCache: bool = False, x_api_key: Optional[str] = Header(None)):
|
||||
"""Extract meta tags from a website"""
|
||||
# Validate API key
|
||||
@@ -194,7 +237,11 @@ async def extract_meta_tags(url: str, skipCache: bool = False, x_api_key: Option
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/outgoing-calls")
|
||||
@router.get(
|
||||
"/outgoing-calls",
|
||||
summary="Capture outgoing calls",
|
||||
description="Capture outgoing API calls from `url` (cached unless `skipCache=true`).",
|
||||
)
|
||||
async def capture_outgoing_calls(url: str, skipCache: bool = False, x_api_key: Optional[str] = Header(None)):
|
||||
"""Capture outgoing API calls from a website"""
|
||||
# Validate API key
|
||||
@@ -224,7 +271,11 @@ async def capture_outgoing_calls(url: str, skipCache: bool = False, x_api_key: O
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/resulting-url")
|
||||
@router.get(
|
||||
"/resulting-url",
|
||||
summary="Get resulting URL",
|
||||
description="Get the final URL after navigation/redirects for `url` (cached unless `skipCache=true`).",
|
||||
)
|
||||
async def get_resulting_url(url: str, skipCache: bool = False, x_api_key: Optional[str] = Header(None)):
|
||||
"""Get the resulting URL after navigation (handles redirects)"""
|
||||
# Validate API key
|
||||
|
||||
@@ -3,7 +3,7 @@ from typing import Optional
|
||||
from app.config import API_KEY
|
||||
from app.services.cache import clear_cache, get_cache_stats
|
||||
|
||||
router = APIRouter()
|
||||
router = APIRouter(tags=["Cache"])
|
||||
|
||||
@router.get("/cache/clear")
|
||||
async def clear_cache_route(x_api_key: Optional[str] = Header(None)):
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
router = APIRouter()
|
||||
router = APIRouter(tags=["Health"])
|
||||
|
||||
@router.head("/")
|
||||
async def health_check():
|
||||
|
||||
Reference in New Issue
Block a user