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
This commit is contained in:
2026-10-06 16:37:59 +08:00
parent 44851a1c29
commit 5d9db48a26
16 changed files with 1354 additions and 647 deletions
+86 -58
View File
@@ -1,77 +1,105 @@
# AGENTS.md
Project: Carousell new-listing monitor (Python stdlib, Docker, NocoDB, Telegram).
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
`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"`. Listings whose
`seller_name` is in the `IgnoredSellers` table are archived but never alerted
(`skip_notify=true`). Listings whose **title** contains a keyword listed for their
watch in the `IgnoredKeywords` table (linked to the watch's `Settings` row,
case-insensitive) are also archived but never alerted. Runs 24/7 as a Docker
container on DSM (network `bridge_hoelee`, reaches NocoDB at `http://nocodb:10380`).
(`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 — only env vars / `SECRETS.md` (private repo).
Operational knobs (`enabled` / `notify` / `check_interval_minutes`) live in the
NocoDB **Settings** table, adjustable from the UI without redeploy.
- Ignored sellers live in the NocoDB **IgnoredSellers** table (one `seller_name`
per row). When a pending listing's `seller_name` matches an ignored seller, the
monitor sets `skip_notify=true` + `notified=true` and does NOT send Telegram.
The list is reloaded every notification cycle, so UI add/remove takes effect
immediately.
- Ignored keywords live in the NocoDB **IgnoredKeywords** table: `watch` is a real
**Link column → `Settings`** (pick the watch from a dropdown). A listing is
silenced when its **title** contains any keyword for its watch, case-insensitive
substring match. Per-watch, not global. Reloaded every cycle, so UI edits take
effect immediately. Bootstrap creates the `watch` column and drops any legacy
`search_url` column.
- Dedupe key is `product_url` (`https://www.carousell.com.my/p/<id>/`), not 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: container is unhealthy if the last tick is >10 min old or the
last run had a failure (failed extract / 403 / rate-limit).
- Image thumbnail: `image` Attachment field stores the remote URL (NocoDB hotlinks
it — media.karousell.com is GCS-backed, `Access-Control-Allow-Origin: *`). The raw
URL is also kept in `image_url`.
- 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`.
## Verified facts (2026-09-02)
## Layout
- Carousell search page: 1.6 MB HTML, state blob ~1.27 MB, ~49 `listingCards` per
load. Card fields: `listingID`, `title`, `price` (e.g. "RM85"), `thumbnailURL`,
| 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 (Like new/Brand new/...).
`belowFold` paragraphs where `paragraph[1]` = condition.
- `https://www.carousell.com.my/p/<id>/` 301-redirects to the canonical slug URL.
- NocoDB instance: DSM `http://192.168.137.2:10380` (IP drifts; container DNS
`nocodb:10380` on bridge_hoelee). Base `Carousell` = `poqw1zjw3hnsk37`.
Workspace token in SECRETS.md. v2 API: records use column *titles* as JSON keys;
Attachment field accepts a JSON string `[{"path","mimetype","title"}]` and keeps the
remote URL (does not re-host).
- 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.
## Build / deploy
## Working on the code
Container is managed by **Portainer stack 240** (standalone; compose + build
context live at `/volume1/docker/portainer/compose/240/` on DSM). Deploy flow:
- 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.
1. Edit code, commit, push to Gitea (`git.hoelee.com/hoelee/carousell-monitor`).
2. Sync the build-context files to `/volume1/docker/portainer/compose/240/`
(`monitor.py`, `healthcheck.py`, `Dockerfile`, `docker-compose.yml`).
3. If `monitor.py` / `Dockerfile` changed, rebuild the image first (Portainer's
standalone PUT does **not** rebuild):
## Deploying
```bash
sudo /usr/local/bin/docker build -t carousell-monitor:latest \
/volume1/docker/portainer/compose/240
```
Generic:
4. Update stack 240 via the Portainer API: `PUT /api/stacks/240?endpointId=2`
with the repo `docker-compose.yml` as `stackFileContent` and the current env
array (see the `portainer-api` skill; ⚠ never echo masked `***` values back).
```bash
git pull
docker compose -f docker-compose.allinone.yml up -d --build
```
Portainer recreates the container with the new config. Private build on DSM —
NO registry (do not push to Docker Hub).
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.