--- paths: backend/**/*.py --- # Backend conventions — Python 3.12 / FastAPI ## psycopg v3 (CRITICAL — recurring bug class) - `import psycopg2` → **ModuleNotFoundError** — его нет в зависимостях. Только `psycopg[binary]>=3.2.0`. - Bulk INSERT: `cur.executemany()` или COPY — НЕ `execute_values` (это psycopg2 API). - В SQL `text(...)`: всегда `CAST(:name AS type)` — НИКОГДА `:name::type`. - SQLAlchemy 2.0 + psycopg3 **игнорирует** `::type` после `:name` — `psycopg.errors.SyntaxError: syntax error at or near ':'` - Исключение: `ARRAY[:rc]::int[]` РАБОТАЕТ — `::` приклеено к `)`, не к bind-name - Pre-PR check: `grep -nE ':[a-z_]+::[a-z]' backend/app` → должен быть пуст - Reference: vault `Pattern_CAST_AS_Type`, `Bug_SQLAlchemy_DoubleColon_Cast` ## SAVEPOINT pattern (loop upserts) В цикле INSERT/UPSERT — **обязательно** `with db.begin_nested():` per-row: ```python for row in items: try: with db.begin_nested(): db.execute(text("INSERT INTO ..."), {...}) except Exception as e: logger.warning("row failed: %s", e) ``` ❌ Bare `db.rollback()` в loop — откатывает **всю outer tx**, счётчики `inserted`/`updated` продолжают расти → inconsistent state. Reference: vault `Bug_Pzz_Loader_Missing_Savepoint_May14`, `domrf_kn.py:427/452/472`. ## SQL injection prevention ❌ **Никогда**: - f-string в SQL: `text(f"INSERT ... VALUES ({values})")` - Home-rolled quote escape: `c.replace(chr(39), chr(39)*2)` - String concat: `"... WHERE id = " + str(id)` ✅ **Canonical**: parametrized batch через SQLAlchemy `text` + list of dicts: ```python db.execute( text("INSERT INTO targets (job_id, cad_num) VALUES (:job_id, :cad_num) " "ON CONFLICT (job_id, cad_num) DO NOTHING"), [{"job_id": j, "cad_num": c} for c in cad_nums], ) ``` Reference: vault `Bug_Nspd_Geo_Sql_Injection_May14`. ## Ruff / code style - **Line length 100** (`backend/pyproject.toml`) - Типичные ловушки E501: - SQL-литералы в `text("""...""")` — переноси после `FROM` / `WHERE` / `AND` - `logger.info("...", a, b, c, d)` — разбивай на 2 строки - Длинная сигнатура — аргументы по одному - Ruff rules: `E F I B UP N RUF ASYNC` (RUF001/002/003 игнор — кириллица OK) - mypy `strict`: `app.services.generative.*` + `app.services.site_finder.scorer` - target-version `py312` — `dict | None`, `list[int]`, walrus OK ## Async / Celery - FastAPI handlers: `async def` - Celery tasks: `def` (Celery sync) — НЕ `async def` внутри task - Sync↔async bridge через `asyncio.run()` внутри Celery task - Tests: pytest + `asyncio_mode = auto` ## Запреты - ❌ `print()` — только `logger.info/warning/error` - ❌ `import requests` — только `httpx` - ❌ `except Exception: pass` без re-raise или `logger.exception` - ❌ Hardcode credentials — `os.environ.get("KEY")` или `settings.KEY` - ❌ `Any` без комментария почему ## Web probing (scrapers — use playwright MCP, not curl) При исследовании веба (как страница реагирует на параметры/фильтры, что меняется в DOM, какие селекторы возвращают данные, anti-bot behavior) — **сначала** `mcp__playwright__*`, а не curl/curl_cffi. **Why:** Реальный браузер видит финальный URL после canonicalization + JS state без anti-bot ловушек. Curl попадает в captcha на 2-3-й итерации. User rule (2026-05-23): «если что-то не понятно лучше через плэйврайт смотри чем через консоль». **How to apply:** - Discover URL params / filter behavior → `browser_navigate` + `browser_snapshot` - Verify selectors / count cards → `browser_evaluate` с JS возвращающим counts - Inspect network → `browser_network_requests` - Captcha detection → `browser_snapshot` + check для challenge elements **Curl/curl_cffi оставляем** для actual scraping в production-скриптах **после** того как param set уже известен (playwright медленнее + heavier для bulk fetch). Reference incident: 2026-05-23 reverse-engineering Yandex SERP filter params через curl — captcha на 2-й итерации, recovery через cookie-warm session. С playwright сразу видели бы JS state. ## Worker crash checklist Worker падает на старте → скорее всего import error: `pyproject.toml` имеет dep но `uv.lock` не обновлён → `cd backend && uv lock` → commit lock → push.