Add monitoring TG service

This commit is contained in:
Grendgi
2026-06-04 14:55:41 +03:00
commit f9e072774c
74 changed files with 7232 additions and 0 deletions

205
src/parser_bot/main.py Normal file
View 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()