Files
carousell-monitor/AGENTS.md
T
hoelee 5d9db48a26 Docs overhaul: beginner quick start + all-in-one NocoDB stack
- README rewritten around a copy-paste quick start (CLI and Portainer),
  verified alert test, day-to-day NocoDB operations, troubleshooting table, FAQ
- new docker-compose.allinone.yml: NocoDB (pinned 2026.09.0, SQLite) + monitor
  on a private network, with healthchecks and the Telegram IPv4 pin documented
- docs/: QUICKSTART-PORTAINER, TELEGRAM-SETUP, NOCODB-SETUP, ARCHITECTURE,
  OPERATIONS, TROUBLESHOOTING (replace DOCUMENTATION.md + COMPOSE-SETUP.md)
- secrets: SECRETS.md is gitignored and untracked; tracked template is
  SECRETS.example.md; real base id / chat id removed from .env.example
- LICENSE (MIT), .gitignore/.dockerignore tidied
- AGENTS.md: layout, iron rules, verification gates; host-specific deploy
  details moved to the gitignored OPS-INTERNAL.md
2026-10-06 16:37:59 +08:00

106 lines
5.3 KiB
Markdown

# AGENTS.md
Entry point for AI coding agents working on **carousell-monitor**: a Python-stdlib
monitor that polls Carousell search pages, archives every listing to NocoDB, and
alerts Telegram.
Read this file, then the doc that matches your task. Keep it updated when the
layout or the deployment story changes.
## What it does
`monitor.py` polls Carousell search URLs (`sort_by=3` = recent), extracts listings
from the server-rendered `<script type="application/json">` Redux state
(`SearchListing.listingCards`), dedupes by `product_url` (param-less), archives to
a NocoDB base, and alerts Telegram `"<title>: N new listings"`.
Silencing: a listing whose `seller_name` is in `IgnoredSellers` is archived but
never alerted (`skip_notify=true`); likewise a listing whose **title** contains a
keyword listed for its watch in `IgnoredKeywords` (a real Link column → `Settings`,
case-insensitive substring).
## Iron rules
- Secrets NEVER in code or compose — env vars (`.env`, gitignored) or a stack's
environment. `SECRETS.example.md` is the tracked template; the real inventory is
the gitignored `SECRETS.md`. No default base id, token or chat id may be shipped.
- Operational knobs (`enabled` / `notify` / `check_interval_minutes`) live in the
NocoDB **Settings** table, adjustable from the UI without a redeploy. Only
credentials and tuning belong in the environment.
- Dedupe key is `product_url` (`https://www.carousell.com.my/p/<id>/`), never the
raw listing id and never the query-string URL.
- First run per watch seeds the archive with **no** Telegram alert
(`last_checked_at` null == unseeded).
- Docker HEALTHCHECK: unhealthy when the last tick is older than
`HEALTH_STALE_SECONDS` or the last run failed.
- `image` is an Attachment column storing the remote URL (NocoDB hotlinks it; the
raw URL is also kept in `image_url`).
- Never change `docker-compose.yml` casually: it is the production variant that
joins an existing NocoDB network. The beginner/fresh-install path is
`docker-compose.allinone.yml`.
## Layout
| File | Role |
|---|---|
| `monitor.py` | the whole loop (stdlib only) |
| `healthcheck.py` | Docker HEALTHCHECK probe over `/data/health.json` |
| `Dockerfile` | python:3.11-alpine, no dependencies |
| `docker-compose.allinone.yml` | NocoDB + monitor (fresh install) |
| `docker-compose.yml` | monitor only, existing NocoDB network (production) |
| `test_pagination.py` | stdlib regression suite — must pass before any commit |
| `docs/` | long-form guides: QUICKSTART-PORTAINER, TELEGRAM-SETUP, NOCODB-SETUP, ARCHITECTURE, OPERATIONS, TROUBLESHOOTING |
| `OPS-INTERNAL.md` | gitignored: this deployment's hosts, paths, stack ids (may not exist) |
## Verified facts (Carousell page shape, 2026-09)
- Search page: ~1.6 MB HTML, state blob ~1.27 MB, ~49 `listingCards` per load.
Card fields: `listingID`, `title`, `price` (e.g. "RM85"), `thumbnailURL`,
`seller.username`, `aboveFold[time_created].timestampContent.seconds.low`,
`belowFold` paragraphs where `paragraph[1]` = condition.
- `https://www.carousell.com.my/p/<id>/` 301-redirects to the canonical slug URL.
- Soft rate limiting returns **HTTP 200 with `listingCards: null`** — treat as a
retryable error, never as "no results".
- NocoDB v2 API: records use column *titles* as JSON keys; an Attachment field
accepts a JSON string `[{"path","mimetype","title"}]` and keeps the remote URL.
## Working on the code
- Stdlib only. Do not add dependencies without a very good reason.
- Run `python test_pagination.py` (exit 0 = pass) and
`python -m pyflakes monitor.py` before committing. pyflakes currently reports one
cosmetic finding (`_send_photo_multipart` has a dead `parts = []` local) — anything
beyond that is yours.
- New env knobs must be added to `monitor.py`, both compose files, `.env.example`
and the tables in `README.md` + `docs/ARCHITECTURE.md` in the same commit.
- Tests must execute the real loader functions, not mocks of them, and must be
mutation-checked (reintroduce the bug, watch the check fail, restore).
- `monitor.py` is checked out with CRLF on Windows; prefer a Python
`read → replace → write(newline='')` over fuzzy patch tools on big blocks.
- Grepping pitfall: a `NameError`/arity error inside a tick does **not** fail at
import. The container keeps running and the only symptom is
`/data/health.json` reporting `ok:false` with a `tick error: …` string.
## Deploying
Generic:
```bash
git pull
docker compose -f docker-compose.allinone.yml up -d --build
```
This project's own deployment (host paths, Portainer stack id, NocoDB instance,
tunnel) is deliberately kept out of the repo: see the gitignored `OPS-INTERNAL.md`
or the operator's `carousell-monitor` skill. Two rules that were learned the hard
way:
1. The **build context is a plain directory** on the host, not a git checkout:
the running container is built from the files copied next to the compose file.
Sync `monitor.py` (and `docker-compose*.yml` / `Dockerfile` when they change)
and then rebuild — a stack PUT alone does not run `--build`.
2. **Compare the live container's environment with the file you are about to
deploy before rebuilding.** Env drift between the compose file, the stack's
stored env and the running container is the single most common cause of a
"fixed" deploy that changes nothing.