CLAUDE.md
Rama master · réplica de lectura del repositorio original.
# CLAUDE.md
Guidance for working in this repo. It documents the conventions the code already
follows so changes stay consistent rather than ad hoc.
## What this is
A Reddit monitor that watches subreddits/threads for keyword matches and sends push
notifications. Three deployable pieces (see `docker-compose.yml`):
- **`bot.py`** — the long-running monitor loop (`python bot.py`). Fetches posts/comments,
applies filters, sends notifications. Hot-reloads `search.json` and `credentials.json`.
- **`api.py`** — a Flask REST API (`python api.py`, port 5001) backing the web UI. Reads/
writes `search.json` (monitors + source order) and `credentials.json`.
- **`frontend/`** — a Next.js web UI (`npm run dev`).
The reusable logic lives in the **`reddit_scraper/`** package; `api.py` and `bot.py` are
thin entry points that compose it.
### `reddit_scraper/` modules
| Module | Responsibility |
|--------|----------------|
| `config.py` | Data paths (`DATA_DIR`), `search.json` access, source-order resolution. **The single source of truth for `VALID_SOURCES` / `DEFAULT_SOURCE_ORDER`** — reference these, don't hardcode source lists. |
| `credentials.py` | Credential loading (file + env), the non-ASCII encoding guard, PRAW auth. |
| `sources.py` | The data-source pathways (`oauth`/`sylvia`/`json`/`rss`) and the fetch dispatcher. Runtime state is encapsulated in `_SourceState` (see below). |
| `monitor.py` | `RedditMonitor` — evaluates a subreddit/thread against filter rules. |
| `notifications.py` | Apprise dispatch. |
| `status.py` / `health.py` | Bot-status file + Uptime Kuma heartbeats. |
## Commands
```bash
python3 -m pytest -q # run the test suite (fast, no network — HTTP is mocked)
python3 -m ruff check . # lint
python3 -m ruff format . # auto-format
python3 -m ruff check . --fix # lint + autofix
```
Frontend: `npm run lint` / `npm run build` in `frontend/`.
**Before committing Python changes, run `ruff check .` and `ruff format .`.** Config is in
`ruff.toml` (E/W/F/I, line-length 120, `quote-style = preserve`). Ruff is in
`requirements-test.txt`.
## Coding principles
### Python
- **Style is enforced by ruff** — don't hand-format; run `ruff format`. Line length 120.
- **snake_case** functions/variables, **PascalCase** classes. Module-level "private" helpers
are prefixed `_`.
- **No type annotations** — this codebase deliberately doesn't use them. Convey types through
clear names and docstrings; don't add hints piecemeal.
- **Logging, not print** — use the `logging` module with **f-string** messages
(`logging.info(f"...")`). Emoji prefixes are used for scannable status lines (📡 🔐 ⚠️).
`print` is only for a CLI startup banner in `__main__`.
- **Docstrings explain _why_, not _what_** — a one-liner for simple functions; for non-obvious
logic, say what problem it solves (see `_SourceState`, `_coalesce`). Keep them concise.
- **Centralize shared access** — paths/config through `config.py`, credentials through
`credentials.py`. Don't re-read env vars or files that a helper already owns.
### Single source of truth (avoid cross-layer coupling)
The bot, the API, and the frontend must not each encode the same domain knowledge — when they
drift, you get bugs like "Sylvia shown as a fallback." Rules, in priority order:
1. **Define a decision once, in `config.py`, and derive everywhere.** Source topology lives
there and nowhere else: `VALID_SOURCES`, `DEFAULT_SOURCE_ORDER`, `RICH_SOURCES` /
`supports_rich_filters()`, `SOURCE_ORDER_PRESETS`. The bot, `api.py`, and tests all import
these — adding a source in one place keeps them in agreement. Don't hardcode a parallel
copy (the earlier `api.py` `('oauth','rss','json')` list is the anti-pattern).
2. **The backend owns business decisions; the frontend consumes API values — it never
re-derives them.** The UI reads flags like `rich_filters_supported` and
`using_json_fallback` from `/api/status` and renders them. It must **not** re-compute a
decision from raw fields (e.g. `activeSource in ('rss','json')` is banned — that logic
belongs in the backend, keyed off `RICH_SOURCES`).
3. **Shared data _shapes_ are generated, not mirrored.** The `Monitor` TypeScript type is
generated from the Pydantic model in `reddit_scraper/models.py` (the schema-of-record) —
see "Generated types" below. Don't hand-write a parallel interface.
4. **The few remaining hand-mirrored constants** (`DEFAULT_COLORS` in `config.py` ↔
`frontend/types/monitor.ts`; `DEFAULT_MONITOR` _values_ ↔ backend defaults) are the **only**
acceptable duplication. Each copy carries a `MUST stay in sync with <path>` comment, and you
change both together. (Migrating these onto the generated model is future work.)
Rule of thumb: if changing a rule means editing more than one file *and* those files can't see
each other's value, you've coupled them — hoist the value into `config.py` (backend), expose it
through the API, or put the shape in `models.py` and generate it.
### Generated types
`reddit_scraper/models.py` (Pydantic) is the single schema-of-record for shapes shared with the
frontend. `frontend/types/generated.ts` is produced from it and **committed** — never edit it
by hand. Regenerate after changing a model:
```bash
npm --prefix frontend run gen:types # or: python3 scripts/gen_types.py
```
CI regenerates and fails if the committed file is stale, and a pytest guard
(`tests/test_models.py`) checks every model field is present. Backend validation/serialization
against these models is being adopted incrementally (the API already round-trips `Monitor`).
### Error handling
- **Fetchers signal blocking by raising, transient/empty by returning `None`.** RSS/JSON/Sylvia
fetchers `raise` on 403/429/auth failure so the dispatcher cools the source down and falls
through; they return `None` on a malformed-but-non-blocking response.
- **Broad `except Exception` belongs at boundaries** — the fetch dispatcher, the monitor loop,
and Flask routes catch broadly to degrade gracefully (log + continue / return an error JSON).
Elsewhere, catch the specific exception you expect.
- **Fail safe.** When a source can't provide a field (e.g. RSS has no score/domain), filters
that need it don't match rather than firing false notifications.
### State
- **`sources.py` runtime state lives in `_SourceState`** (cooldowns/backoff, active source,
coalescing cache, proxy rotation, RSS throttle, notification flags), owned by a single
`_state` instance. Module-level functions are a thin facade over it — call those (and the
`get_active_source()` / `get_last_fetch_success_ts()` accessors) rather than reaching into
fields. Tuning knobs (`SOURCE_COOLDOWN_*`, `FETCH_CACHE_TTL`, `_PROXIES`, `SYLVIA_API_KEY`,
…) stay module-level config so they can be overridden by env/tests.
### Data sources
The dispatcher tries sources in `config.get_source_order()`, falling through on failure with
exponential backoff, and coalesces duplicate concurrent fetches. Order is set via the UI
(→ `search.json`), then `REDDIT_SOURCE_ORDER`, then the default. `sylvia` is opt-in (needs
`SYLVIA_API_KEY`); anonymous fetches can be routed through a proxy (`REDDIT_PROXY(IES)`).
### Frontend
- **PascalCase components**, one concern each. Large modals are split into focused sections
(see `components/settings/`), with shared types in a co-located `types.ts`.
- Lint with the repo `eslint.config.mjs`.
## Testing
- **pytest**, one `test_<module>.py` per module under `tests/`; shared setup in `conftest.py`.
- **No network in tests** — HTTP is mocked with `responses` (or by monkeypatching `requests`).
- **Reset module/global state in an `autouse` fixture** (see `test_sources.py`'s
`reset_source_state`, which calls `sources._state.reset()`).
- **Test through the public surface** — call the public functions/accessors, not private
state, so tests survive internal refactors.
- Add/adjust tests with any behavior change; keep the suite green.
### Frontend lint dependency compatibility
Keep ESLint on 9.39.x while eslint-plugin-react 7.37.5 declares support only
through ESLint 9; ESLint 10 removes context APIs this plugin still uses.
The scoped minimatch 11.0.5 override under typescript-estree fixes
GHSA-g3hv-75wg-m249. Its only caller uses the retained named minimatch function
with the dot option; it does not use the removed AST/makeRe API. Node 20 in CI
and the frontend container satisfies its engine requirement. Remove the override
when typescript-eslint declares a fixed minimatch dependency upstream.