- 通知重构:归档与通知解耦(Listings.notified 列),tick 末尾统一发,1s 间隔,失败自动重试(30s tick) - 每商品一条图文消息:title/price/condition/seller 名/product_url + 高清图 - 图片用 multipart 上传本地字节(原先传 URL 让 Telegram 下载,遇 carousell CDN 不稳定导致间歇 400) - 图片 URL 去 _progressive_thumbnail 后缀(高清原图) - condition 归一化:New→Brand new、Used→Used,加第 6 档;从所有 paragraph 找 condition(修位置漂移) - listed_at 加 active_bump fallback(被顶置商品) - compose extra_hosts 钉 api.telegram.org 到 IPv4 149.154.166.110(容器无 IPv6 时 DNS 只返 AAAA) - bot 换 @carousellFoundBot,chat @MrFullStackDev
10 KiB
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 |
Secrets + tunables (gitignored, NOT in the repo) |
DOCUMENTATION.md |
This file |
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 |
HEALTH_STALE_SECONDS |
600 |
healthcheck staleness window |
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(防重复通知) |
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_SECONDSthe container reloads the Settings table and polls each enabled watch whose interval has elapsed. - Dedupe:
product_urlis the identity. On startup the container loads every existingproduct_urlfrom 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_atis null): seeds the current listings as an archive withnotified=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 everynotified=falselisting one message each (photo + title/price/condition/seller/url), 1 second apart, then setsnotified=true. - 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_atis 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 |
| 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 image is built on DSM from this folder. To ship a code change:
# 1) edit monitor.py / Dockerfile / compose in the repo (D:\dev\carousell-monitor)
# 2) copy the changed file(s) to this folder, then:
cd /volume1/docker/carousell-monitor
sudo /usr/local/bin/docker compose up -d --build
--build rebuilds the image and recreates the container; the NocoDB schema bootstrap
and dedupe seeding are idempotent, so a rebuild 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 |
| DSM deploy dir | /volume1/docker/carousell-monitor |
| 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
.envcontains secrets and is gitignored — it exists only on this DSM and is never committed. Credential inventory:SECRETS.mdin 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-08 通知重构:每商品一条图文消息(title/price/condition/seller/url),归档与通知解耦(
notified列 + tick 末尾统一发 + 1s 间隔)。图片改用高清 URL(去_progressive_thumbnail)。condition 归一化(New→Brand new、Used→Used,加第 6 档)。listed_at 加active_bumpfallback。修复 Telegram IPv6/DNS 问题(composeextra_hosts钉 IPv4)。bot 换@carousellFoundBot。