Files
carousell-monitor/DOCUMENTATION.md
T
hoelee 3c50d084a0 Paginate NocoDB reads; alert Telegram on tick failure
send_pending_notifications() read Listings with ?limit=1000 and no paging.
Once the table passed 1000 rows the newest records (highest Id, at the tail)
fell outside page 1, so they were never sent and never marked notified ->
notifications silently dead while health.json stayed ok:true. Found live
2026-09-22: Id 1007-1041 (35 rows, ~29h of listings) never alerted.

- add nc_list_all(): offset-paged full-table read
- use it for listings pending, seen, watches, settings, both ignore lists
- add alert_on_health(): Telegram failure alert debounced over
  ERROR_ALERT_AFTER consecutive failed ticks, plus a recovery notice
  (container already reported unhealthy via healthcheck.py on ok:false)
- new env knob ERROR_ALERT_AFTER wired into compose/.env.example/docs
- test_pagination.py: 30 checks incl. the page-2 regression and alert edges
2026-09-22 03:32:31 +08:00

13 KiB
Raw Blame History

carousell-monitor — Deployment & Operations Documentation

Carousell new-listing monitor: watches Carousell search pages (sorted by recent), archives every listing into a NocoDB base (with image thumbnail), and alerts Telegram when a genuinely new listing appears. Runs 24/7 as a Docker container on the DSM.


1. Architecture

Docker container (carousell-monitor, DSM, network bridge_hoelee)
  ├─ every 60 s (TICK_SECONDS) ─ reads watch list from NocoDB Settings table
  ├─ per watch (on its own check_interval_minutes) ─ GET the Carousell search URL
  │     ├─ parse embedded JSON state → SearchListing.listingCards[]
  │     ├─ dedupe by product_url (param-less listing URL)
  │     ├─ INSERT new rows into NocoDB Listings table
  │     └─ archive w/ notified flag (first seed = silent; else pending-notify)
  ├─ end of each tick ─ send ALL pending (notified=false) listings, 1s apart
  │     └─ each notice: photo + title/price/condition/seller/url
  ├─ writes /data/health.json each tick → Docker HEALTHCHECK
  └─ reaches: NocoDB http://nocodb:10380 (container DNS, bridge_hoelee)
              Carousell www.carousell.com.my (public internet)
              Telegram api.telegram.org (public internet)

Nothing is pushed to any image registry — the image is built privately on DSM (build: .). Secrets come from the .env file next to the compose file.


2. Files in this folder

File Purpose
docker-compose.yml Stack definition (build, env, volume, network)
Dockerfile python:3.11-alpine + monitor.py + healthcheck.py, HEALTHCHECK
monitor.py Main loop: schema bootstrap, fetch/parse, NocoDB IO, Telegram
healthcheck.py HEALTHCHECK probe (reads /data/health.json)
.env No longer used — secrets/tunables live in the Portainer stack env (stack 240); the legacy DSM-dir .env is masked and stale
DOCUMENTATION.md Deployment & operations manual
COMPOSE-SETUP.md Stack anatomy reference: compose file, Dockerfile, networks, deployment paths

Source of truth for the code is the private Gitea repo git.hoelee.com/hoelee/carousell-monitor (local checkout D:\dev\carousell-monitor). Credentials are documented in SECRETS.md there.


3. Environment variables (.env)

Var Value / default Notes
NOCODB_URL http://nocodb:10380 container DNS on bridge_hoelee; LAN form http://192.168.137.2:10380
NOCODB_TOKEN (secret) workspace-scoped NocoDB PAT
NOCODB_BASE_ID poqw1zjw3hnsk37 base "Carousell"
TELEGRAM_BOT_TOKEN (secret) @carousellFoundBot
TELEGRAM_CHAT_ID 5648309582 alert destination
TICK_SECONDS 60 scheduler granularity
FETCH_GAP_SECONDS 1 min pause (s) between watch URL fetches within one tick — anti-burst
HEALTH_STALE_SECONDS 600 healthcheck staleness window
ERROR_ALERT_AFTER 3 consecutive failed ticks before a Telegram failure alert is sent (debounce); a recovery notice is sent when it clears

Secrets = env vars (.env). Operational knobs = NocoDB Settings table. Speed, enable/disable, and notify on/off are all changed from the NocoDB UI — no redeploy, no .env edit.


4. NocoDB schema (base Carousell, id poqw1zjw3hnsk37)

The container bootstraps both tables on startup if they are missing (idempotent — deleting a table is safe; it is recreated on the next start).

Listings (archive — every seen listing)

Column Type Purpose
Id auto primary key
product_url URL unique dedupe key — https://www.carousell.com.my/p/<id>/, no query params
title SingleLineText listing title
price Decimal numeric price, "RM" stripped (e.g. 85.00) — filterable/sortable
condition SingleSelect Brand new / Like new / Lightly used / Well used / Heavily used / Used (归一化自 Carousell 的 New/Used 等写法)
image_url URL 高清图 URL(已去 _progressive_thumbnail 后缀)
image Attachment 高清图 — NocoDB hotlinks the URL; renders in grid view
seller_name SingleLineText seller username
seller_url URL https://www.carousell.com.my/u/<username>/
search_title SingleLineText which watch found it (denormalized)
search_url URL the watch's search URL
listed_at DateTime (UTC) 上架时间(优先 time_created,被顶置商品 fallback active_bump)
first_seen_at DateTime (UTC) when the monitor first captured it
notified Checkbox false = 待发通知;发完/静默归档后置 true(防重复通知)

IgnoredSellers (global seller blocklist)

Column Type Purpose
seller_name SingleLineText seller username to silence globally (any watch)

IgnoredKeywords (per-watch title blocklist)

Column Type Purpose
watch Link → Settings pick the watch from a dropdown (belongs-to: many keywords → one watch) — no URL to copy by hand
keyword SingleLineText if the listing title contains this (case-insensitive substring), skip the Telegram alert (still archived)

One keyword per row; add multiple rows for multiple keywords. A keyword only silences listings found by the watch you linked — the same keyword never applies to other watches. Rows with no watch or no keyword are ignored.

watch is a real NocoDB Link column: the monitor resolves Settings.url through it at load time (one fetch of the Settings table per cycle). There is no hand-copied URL that can silently drift out of sync. (The monitor's bootstrap also builds the watch column itself and drops any legacy search_url column.)

Settings (the watch list — you manage this)

Column Type Purpose
Id auto primary key
title SingleLineText human label, e.g. "Uniform"
url URL full Carousell search URL (with sort_by=3)
enabled Checkbox false = paused (skipped entirely)
notify Checkbox false = archive only, no Telegram ping
check_interval_minutes Number per-watch poll interval (default 5)
last_checked_at DateTime null = not yet seeded (first run archives silently)

5. Runtime behaviour

  • Tick loop: every TICK_SECONDS the container reloads the Settings table and polls each enabled watch whose interval has elapsed.
  • Dedupe: product_url is the identity. On startup the container loads every existing product_url from NocoDB into an in-memory set; a listing is "new" only if its URL is not in that set.
  • First run per watch (last_checked_at is null): seeds the current listings as an archive with notified=true (silent, no Telegram). Prevents a flood on setup.
  • Afterwards: new listings are inserted with notified=false (pending). At the end of each tick the monitor sends every notified=false listing one message each (photo + title/price/condition/seller/url), 1 second apart, then sets notified=true.
  • Silencing: a pending listing is marked skip_notify=true + notified=true (no Telegram) if its seller_name is in IgnoredSellers (global), or if its title contains any keyword whose IgnoredKeywords.watch links to the same Settings row (resolved via Settings.url per cycle, per-watch, case-insensitive).
  • Failure handling: a fetch/parse error on any watch marks that tick failed; the container becomes unhealthy until the next fully-successful tick. last_checked_at is only advanced on success, and a hard-failing watch is rate-limited to one attempt per tick so it doesn't hammer the site.

6. Operations (all from the NocoDB UI — no SSH, no redeploy)

Want to… Do this
Add a keyword Add a row to Settings: title, full url (open Carousell → search → sort "Recent" → copy URL), enabled ✓, notify ✓, interval
Remove / pause a watch Set enabled = false (or delete the row)
Stop Telegram pings but keep archiving Set notify = false
Ignore a seller everywhere Add seller_name row in IgnoredSellers
Ignore certain keywords for one watch Add row(s) in IgnoredKeywords: pick the watch in the watch dropdown, keyword = e.g. nike (case-insensitive, matches inside the title). search_url fills itself
Change how often it checks Edit check_interval_minutes (5 = every 5 min)
See what's new Open Listings, sort by first_seen_at desc
Browse with images Listings grid view — the image column renders thumbnails

The URL for a watch must keep sort_by=3 (recent) so new listings sort first, e.g.:

https://www.carousell.com.my/search/uniform?addRecent=true&canChangeKeyword=true&includeSuggestions=true&sort_by=3&t-search_query_source=direct_search

7. Updating the code

The container is owned by Portainer stack 240. To ship a code change:

# 1) edit code in the repo (D:\dev\carousell-monitor), commit, push to Gitea
# 2) sync runtime files to Portainer's build context:
sudo cp monitor.py healthcheck.py Dockerfile docker-compose.yml \
  /volume1/docker/portainer/compose/240/
# 3) if monitor.py / Dockerfile changed, rebuild the image (PUT doesn't --build):
sudo /usr/local/bin/docker build -t carousell-monitor:latest \
  /volume1/docker/portainer/compose/240
# 4) update stack 240 via Portainer API (PUT /api/stacks/240?endpointId=2,
#    repo compose as stackFileContent + current env array — see portainer-api skill;
#    ⚠ never echo masked *** values back, real Telegram token is in SECRETS.md)

Portainer recreates the container; the NocoDB schema bootstrap and dedupe seeding are idempotent, so a redeploy never duplicates rows.


8. Troubleshooting

Symptom Likely cause Fix
Container shows unhealthy last tick failed (403/429/parse) or >10 min stale sudo /usr/local/bin/docker logs carousell-monitor --tail 50 — read the error
"no application/json state found" Carousell rate-limited / blocked the request wait; consider raising check_interval_minutes
No Telegram messages token/chat wrong, or notify off verify .env TELEGRAM_BOT_TOKEN; check notify checkbox
Thumbnails missing in NocoDB image column isn't Attachment, or Carousell blocked hotlink confirm image is Attachment type; image_url (URL) always works as a link
NocoDB unreachable DSM IP drifted, or not on bridge_hoelee from DSM: sudo /usr/local/bin/docker network inspect bridge_hoelee; NocoDB container must be named nocodb
listed_at/first_seen_at look 8 h off they are UTC (NocoDB parses naive datetimes as UTC) expected — set your NocoDB display timezone to MYT (Asia/Kuala_Lumpur) if you want local times

Handy commands (run on DSM)

D="sudo /usr/local/bin/docker"
$D ps --filter name=carousell-monitor          # status + health
$D logs carousell-monitor --tail 100           # last log lines
$D inspect carousell-monitor --format '{{.State.Health.Status}}'   # health
$D restart carousell-monitor                   # restart (state is in NocoDB, safe)

Health file lives at /data/health.json inside the container: {"last_run_epoch": ..., "ok": true, "error": "", "watch_count": N, "new_this_tick": N}.


9. Key identifiers

Item Value
Repo (private) git.hoelee.com/hoelee/carousell-monitor
Local checkout D:\dev\carousell-monitor
Portainer stack 240 (standalone; compose + build context at /volume1/docker/portainer/compose/240/ on DSM)
Container carousell-monitor (network bridge_hoelee)
NocoDB base Carousell = poqw1zjw3hnsk37 (workspace wal4hatt)
Tables Listings + Settings (bootstrap finds by title)
Telegram @carousellFoundBot → chat 5648309582 (@MrFullStackDev)

10. Security notes

  • .env contains secrets and is gitignored — it exists only on this DSM and is never committed. Credential inventory: SECRETS.md in the repo.
  • The image is private (built on DSM, never pushed to Docker Hub).
  • NocoDB token is workspace-scoped; regenerate in NocoDB if it leaks and update .env.

11. Changelog

  • 2026-09-13 IgnoredKeywords.watch = 真正的 NocoDB Link 列(Many-to-One → Settings),UI 下拉选 watch,不用手抄 URL。search_url 列已删除——运行时经 watch 链接 + Settings 表解析出 .url(每周期一次拉取)。bootstrap 自建 watch 列并清理遗留 search_url。
  • 2026-09-13 新增 per-watch 忽略关键词:IgnoredKeywords 表。标题命中该 watch 关键词(大小写不敏感子串)时静默归档、不发 Telegram(skip_notify=true)。
  • 2026-09-08 通知重构:每商品一条图文消息(title/price/condition/seller/url),归档与通知解耦(notified 列 + tick 末尾统一发 + 1s 间隔)。图片改用高清 URL(去 _progressive_thumbnail)。condition 归一化(New→Brand new、Used→Used,加第 6 档)。listed_at 加 active_bump fallback。修复 Telegram IPv6/DNS 问题(compose extra_hosts 钉 IPv4)。bot 换 @carousellFoundBot。