# CLAUDE.md This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. ## What this is FastAPI service that polls the Ivano-Frankivsk energy company (oe.if.ua) for power outages at a configured address/account and pushes notifications to Telegram and (optionally) Matrix. Two independent data sources: - **PO** (planned outages) — HTML table scraped from `https://oe.if.ua/uk/shutdowns_table`, parsed with lxml XPath per row (`div[@class='table-row flex']`). - **GPV** (hourly outage schedule) — JSON from `https://be-svitlo.oe.if.ua/schedule-by-search` by account number (requested with `verify=False`). Messages and log text are in Ukrainian; keep that. ## Commands ```bash .venv/bin/pip install -r requirements.txt .venv/bin/python main.py # runs uvicorn with host/port from configs/config.yaml ``` There is no test suite or linter configured. `test/` holds fixtures only (`data_po_2days_*.html`, `data_gpv.json`). To exercise the app without contacting real sites or messengers, set `application.debug: "true"` in `configs/config.yaml`: fetches then read the fixtures, and senders `print()` instead of sending. Note fixtures are dated April 2026, so entries are pruned as expired unless dates are shifted. ## Configuration - `configs/config.yaml` is loaded at import of `main.py` via `cfg.init_config()`; values use `!secret ` resolved from `configs/secrets.yaml` (gitignored, must exist for a real run). The `!secret` tag is registered on a private loader subclass in `config_file.read_yaml`. - `app_config.py` exposes settings as **module-level globals** (`cfg.TG_TOKEN`, `cfg.IS_DEBUG`, …) populated by `parse_config()`. Always access them as `cfg.X` at call time — `from app_config import X` would capture the pre-config default. - Booleans in YAML may be strings (`"true"`) — parse with `as_bool`. - `configs/log.yaml` is the uvicorn log config. All modules log through the single `cfg.LOG` logger, which also feeds the in-memory `LOG_LIST` served by `/api/v1/logs` (unauthenticated — never log secrets; use `utils.safe_error(e)` for request exceptions, since TG URLs contain the token). ## Architecture - `main.py` — FastAPI `app` with a sub-app `api` mounted at `/api/v1` (the root HTML page in `templates/info.html` lists `api` routes). `lifespan` creates an `AsyncIOScheduler` with one cron job per (hour in `[start_hour, stop_hour)`, minute in `PROCESSING_MINUTES`) — every 2 minutes in debug — and runs an initial check as a background task. `check_and_notify()` runs `process_gpv` then `process_po` under `PROCESS_LOCK` so scheduled runs and `/update` never overlap (prevents duplicate notifications). - `utils.py` — fetching, parsing, state and notification. State is in-memory only (`PARSED_PO_DATAS`, `PARSED_GPV_DATAS`); lost on restart, so everything current is re-notified after a restart. Blocking `requests` calls are run via `asyncio.to_thread`; every request has `cfg.HTTP_TIMEOUT`; `process_*` catch network/parse errors and return status codes instead of raising. - `model.py` — `POData` / `GPVData` dataclasses. Service fields (`notified_at`, `updated_at`, `need_to_send`, `start_ts`) are `compare=False`, so `==` means "same outage content"; this drives change detection. ### Notification lifecycle (per poll) 1. Parse fresh entries → `update_*_data` merges into the list by `key` (PO: start ts + address; GPV: event date + queue). - not yet sent → replaced with fresh data; - already sent and content changed (PO `!=`, GPV `is_same_schedule`) → replaced, so it is re-sent; - PO only: also re-sent by `is_reminder_needed` (a day since last notification, or the first processing slot of the day passed). 2. `remove_old_*_data` prunes expired entries (PO: 1h after start; GPV: 30 min after the last power-on / end of day). GPV `start_ts` holds power-**on** timestamps despite the name; `00:00` end rolls to the next day. 3. `check_and_send_*` sends entries with `need_to_send` via `notify()`, which sends to TG and, if enabled, Matrix (v3 `PUT` with txn id); an entry is marked sent if at least one channel succeeded. Messages use Telegram HTML `parse_mode` — escape any scraped text with `html.escape`. ## Constraints - Don't change the public API (paths, params, response shapes): `/`, `/api/v1/status`, `/update`, `/health`, `/test_message/{tg|mx}`, `/logs`. Dataclasses in `/status` are serialized as-is, so renaming their fields changes the API.