Add monitoring TG service
This commit is contained in:
205
src/parser_bot/main.py
Normal file
205
src/parser_bot/main.py
Normal file
@@ -0,0 +1,205 @@
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
|
||||
import structlog
|
||||
import uvicorn
|
||||
from fastapi import Depends, FastAPI, HTTPException
|
||||
from fastapi.openapi.docs import get_redoc_html, get_swagger_ui_html
|
||||
from fastapi.openapi.utils import get_openapi
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from starlette.types import Scope
|
||||
|
||||
from parser_bot.access import require_admin, require_admin_network
|
||||
from parser_bot.api.routes import router
|
||||
from parser_bot.config import settings
|
||||
from parser_bot.scheduler.poller import build_scheduler
|
||||
from parser_bot.telegram.client import is_authorized, start_client, stop_client
|
||||
|
||||
structlog.configure(
|
||||
processors=[
|
||||
structlog.processors.TimeStamper(fmt="iso"),
|
||||
structlog.processors.add_log_level,
|
||||
structlog.processors.JSONRenderer(),
|
||||
]
|
||||
)
|
||||
log = structlog.get_logger()
|
||||
|
||||
STATIC_DIR = Path(__file__).parent / "web" / "static"
|
||||
NOCACHE = {"Cache-Control": "no-cache, must-revalidate"}
|
||||
|
||||
|
||||
class NoCacheStaticFiles(StaticFiles):
|
||||
"""StaticFiles with Cache-Control: no-cache.
|
||||
|
||||
The browser still gets to validate via ETag/Last-Modified (304 is fine),
|
||||
but it will not silently serve a stale JS bundle after a deploy.
|
||||
"""
|
||||
|
||||
async def get_response(self, path: str, scope: Scope):
|
||||
response = await super().get_response(path, scope)
|
||||
response.headers["Cache-Control"] = "no-cache, must-revalidate"
|
||||
return response
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
await start_client()
|
||||
scheduler = build_scheduler()
|
||||
scheduler.start()
|
||||
authorized = await is_authorized()
|
||||
log.info(
|
||||
"startup", poll_interval=settings.poll_interval_seconds, authorized=authorized
|
||||
)
|
||||
if not authorized:
|
||||
log.warning("not_authorized", action="open /auth.html to log in")
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
scheduler.shutdown(wait=False)
|
||||
await stop_client()
|
||||
log.info("shutdown")
|
||||
|
||||
|
||||
def _serve_section_template(vertical_dir: str, page: str) -> FileResponse:
|
||||
"""Resolve a section-scoped URL to a single shared template.
|
||||
|
||||
Sections are dynamic (created via UI), so `/real-estate/dubai/channels.html`
|
||||
can't be a real file. We serve `web/static/<vertical_dir>/section/<page>`
|
||||
for any section slug — the section name is read from the URL by JS.
|
||||
"""
|
||||
target_name = page if page else "index.html"
|
||||
if "/" in target_name or target_name.startswith(".."):
|
||||
raise HTTPException(404)
|
||||
target = STATIC_DIR / vertical_dir / "section" / target_name
|
||||
if not target.is_file():
|
||||
raise HTTPException(404)
|
||||
return FileResponse(target, headers=NOCACHE)
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
public_base = settings.public_base_path.rstrip("/")
|
||||
# Disable the default /docs, /redoc and /openapi.json — we serve our own
|
||||
# admin-gated versions below.
|
||||
app = FastAPI(
|
||||
title="parser-tg-bot",
|
||||
lifespan=lifespan,
|
||||
docs_url=None,
|
||||
redoc_url=None,
|
||||
openapi_url=None,
|
||||
)
|
||||
app.include_router(router, prefix="/api/v1")
|
||||
|
||||
@app.get("/healthz")
|
||||
async def healthz() -> dict[str, str]:
|
||||
return {"status": "ok"}
|
||||
|
||||
@app.get("/", include_in_schema=False)
|
||||
async def index() -> FileResponse:
|
||||
return FileResponse(STATIC_DIR / "index.html", headers=NOCACHE)
|
||||
|
||||
# Admin-only: Telegram login page. Registered BEFORE the static catch-all
|
||||
# so the static mount can't accidentally serve it to non-admin visitors.
|
||||
@app.get(
|
||||
"/admin.html",
|
||||
include_in_schema=False,
|
||||
dependencies=[Depends(require_admin_network)],
|
||||
)
|
||||
async def admin_page() -> FileResponse:
|
||||
return FileResponse(STATIC_DIR / "admin.html", headers=NOCACHE)
|
||||
|
||||
@app.get(
|
||||
"/auth.html",
|
||||
include_in_schema=False,
|
||||
dependencies=[Depends(require_admin)],
|
||||
)
|
||||
async def auth_page() -> FileResponse:
|
||||
return FileResponse(STATIC_DIR / "auth.html", headers=NOCACHE)
|
||||
|
||||
# Admin-only: OpenAPI surface. Custom routes so we can wrap them in
|
||||
# `require_admin`; the auto-generated ones from FastAPI bypass it.
|
||||
@app.get(
|
||||
"/openapi.json",
|
||||
include_in_schema=False,
|
||||
dependencies=[Depends(require_admin)],
|
||||
)
|
||||
async def openapi_json() -> JSONResponse:
|
||||
return JSONResponse(
|
||||
get_openapi(
|
||||
title=app.title,
|
||||
version=app.version,
|
||||
openapi_version=app.openapi_version,
|
||||
description=app.description,
|
||||
routes=app.routes,
|
||||
)
|
||||
)
|
||||
|
||||
@app.get(
|
||||
"/docs",
|
||||
include_in_schema=False,
|
||||
dependencies=[Depends(require_admin)],
|
||||
)
|
||||
async def docs() -> FileResponse:
|
||||
return get_swagger_ui_html(
|
||||
openapi_url=f"{public_base}/openapi.json" if public_base else "/openapi.json",
|
||||
title=app.title + " — docs",
|
||||
)
|
||||
|
||||
@app.get(
|
||||
"/redoc",
|
||||
include_in_schema=False,
|
||||
dependencies=[Depends(require_admin)],
|
||||
)
|
||||
async def redoc() -> FileResponse:
|
||||
return get_redoc_html(
|
||||
openapi_url=f"{public_base}/openapi.json" if public_base else "/openapi.json",
|
||||
title=app.title + " — redoc",
|
||||
)
|
||||
|
||||
# IMPORTANT: register /static and /media mounts BEFORE the dynamic
|
||||
# vertical/section routes. Starlette matches routes in registration order,
|
||||
# and a generic /{v}/{s}/{page} pattern would otherwise eat /static/*.
|
||||
app.mount("/static", NoCacheStaticFiles(directory=STATIC_DIR), name="static")
|
||||
media_dir = Path(settings.media_dir)
|
||||
media_dir.mkdir(parents=True, exist_ok=True)
|
||||
# /media is fine to cache — file names are content-stable.
|
||||
app.mount("/media", StaticFiles(directory=media_dir), name="media")
|
||||
|
||||
# Section-templated dynamic routes, explicit per vertical so /static/*,
|
||||
# /api/*, /media/* (and any future top-level path) can't be captured.
|
||||
@app.get("/real-estate/{section}/", include_in_schema=False)
|
||||
async def re_section_root(section: str) -> FileResponse:
|
||||
return _serve_section_template("real-estate", "index.html")
|
||||
|
||||
@app.get("/real-estate/{section}/{page}", include_in_schema=False)
|
||||
async def re_section_page(section: str, page: str) -> FileResponse:
|
||||
return _serve_section_template("real-estate", page)
|
||||
|
||||
@app.get("/hr/{section}/", include_in_schema=False)
|
||||
async def hr_section_root(section: str) -> FileResponse:
|
||||
return _serve_section_template("hr", "index.html")
|
||||
|
||||
@app.get("/hr/{section}/{page}", include_in_schema=False)
|
||||
async def hr_section_page(section: str, page: str) -> FileResponse:
|
||||
return _serve_section_template("hr", page)
|
||||
|
||||
# Catch-all for top-level static pages (chooser, css, etc.). auth.html is
|
||||
# already handled above, so the static catch-all can't bypass the guard.
|
||||
app.mount("/", NoCacheStaticFiles(directory=STATIC_DIR, html=True), name="pages")
|
||||
return app
|
||||
|
||||
|
||||
app = create_app()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
uvicorn.run(
|
||||
"parser_bot.main:app",
|
||||
host=settings.api_host,
|
||||
port=settings.api_port,
|
||||
log_config=None,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user