22 Commits
Author SHA1 Message Date
hoelee d19e63207b Add donation + service sections, GitHub funding button, Gitea repo public
- README: "Support this project" (GitHub Sponsors + Buy Me a Coffee, both verified live)
  and "Need this set up - or something built?" (setup service, custom development,
  maintenance) with the Hoelee Enterprise contact block
- .github/FUNDING.yml: github + buy_me_a_coffee, so GitHub renders the Sponsor button
- Gitea repo visibility flipped to public (API private:false; anonymous access verified 200)
2026-10-06 17:00:25 +08:00
hoelee 4c58d53b3c Require NOCODB_BASE_ID: drop the baked-in fallback, fail with a clear message
- monitor.py: no default base id any more, and main() now reports whichever of
  NOCODB_TOKEN / NOCODB_BASE_ID is missing (writing the same text to
  /data/health.json) instead of dying inside bootstrap with a traceback
- docker-compose.yml: NOCODB_BASE_ID is a required variable now (production
  passes it in .env, so runtime behaviour is unchanged there)
- README: the contributing note matches reality again

Verified: python test_pagination.py passes, python -m py_compile clean, and a
run without the two variables prints "NOCODB_TOKEN, NOCODB_BASE_ID not set",
writes ok:false to health.json and exits 2.
2026-10-06 16:54:45 +08:00
hoelee cb572a494c Docs: use a placeholder base id in the examples
The README/NocoDB guide used a real base id copied out of the author's own
NocoDB URL. Examples now use an obviously fake id (the compose default and
monitor.py's fallback are unchanged: production always sets NOCODB_BASE_ID).
2026-10-06 16:49:43 +08:00
hoelee 64165ce5aa Portainer: deploy from the Git repository (web editor cannot build)
Verified on Portainer 2.45: an inline/web-editor stack with `build: .`
fails with "compose build operation failed: failed to solve: failed to read
dockerfile: open …/Dockerfile: no such file or directory".

- README + docs/QUICKSTART-PORTAINER: Route 1 = Repository deploy (Portainer
  clones the repo, so build: . resolves), staged so NocoDB comes up first and
  the base id/token are added afterwards; Route 2 = web editor with a
  hand-built image and the build: line removed
- docker-compose.allinone.yml header: same correction
- validated with: docker compose -f docker-compose.allinone.yml config
2026-10-06 16:41:03 +08:00
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
hoelee 44851a1c29 ROOT CAUSE: tg() sent the Telegram method name as the HTTP verb
tg() passed its 'method' argument (e.g. 'sendMessage') straight into
urllib Request(method=...), producing the request line

    sendMessage /bot<token>/sendMessage HTTP/1.1

'sendMessage' is not an HTTP verb, so Telegram's edge rejects it with 400
(bare nginx page, no JSON). Every sendMessage-based alert therefore failed
silently: the debounced failure alert, the recovery notice, and text-only
listing notifications. Listing alerts with an image survived only because
sendPhoto goes through the multipart helper which builds its own POST.

Combined with the 1000-row pagination bug, this is why no notifications
arrived while the container still reported healthy.

- tg(): always use HTTP POST for the request, keep the Telegram method in the path
- test: assert req.get_method() == 'POST' and that body is attached
  (mutation-verified: restoring the bug fails the check)
- also restore the patched global urlopen between test sections

Verified live in the container: fixed tg -> 200 ok:true, message_id 493
delivered; unfixed tg -> HTTP 400.
2026-09-22 04:08:45 +08:00
hoelee f99a0d878e Harden against Carousell soft-blocks; stop one flaky watch failing the tick
Two problems found while verifying the pagination fix on the live stack:

1. fetch_listings() did state['SearchListing']['listingCards'] with no guard.
   Carousell serves HTTP 200 with listingCards=null under soft rate limiting,
   so the tick died with TypeError: 'NoneType' object is not iterable ->
   ok:false -> container unhealthy, repeatedly.
2. run_tick() set ok = (no failures at all), so a single soft-blocked watch
   out of 7 marked the whole monitor failed. That flaps on transient blocks
   and (now that failures alert) would spam Telegram.

- fetch_listings: explicit null check -> clear retryable RuntimeError
- add FAILURE_RATIO_THRESHOLD (default 1.0 = all watches must fail); partial
  failures are reported as '[partial n/N] ...' without failing the tick
- health gains failed_watches
- wire the knob into compose/.env.example/DOCUMENTATION/COMPOSE-SETUP
- 15 new checks: null vs empty cards, real card still parses, threshold edges
2026-09-22 03:52:14 +08:00
hoelee b88f6d0963 Fix NameError in load_ignored_keywords from pagination refactor
The previous commit renamed the record list to 'rows' but missed the loop in
load_ignored_keywords, leaving a bare 'j' -> 'tick error: name j is not
defined' every tick. The loop died, health.json went ok:false, the container
reported unhealthy, and alert_on_health fired against a live failure.

- fix the stale reference
- use real emoji instead of literal backslash escapes in alert text
- test: exercise the real loaders end-to-end against a fake NocoDB so a
  missed rename is caught (mutation-verified: reintroducing the bug fails 3
  checks)
2026-09-22 03:44:24 +08:00
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
hoelee 323fd10838 Document Portainer stack 240 as the deploy path
Replace the legacy DSM-dir 'docker compose up -d --build' flow in AGENTS.md,
COMPOSE-SETUP.md, DOCUMENTATION.md, README.md with the current deploy:
sync build context to /volume1/docker/portainer/compose/240/, rebuild image
if code changed, then PUT /api/stacks/240 (compose + env array, never echo
masked *** values). Legacy dir marked as such; live tg IP note synced.
2026-09-13 20:24:32 +08:00
hoelee 04bfd2d3a0 Space watch URL fetches with FETCH_GAP_SECONDS (default 1s)
Same tick previously fired every due watch back-to-back (burst of N requests,
worst right after a restart when all watches are due at once). Now each watch
URL fetch is followed by a pause of FETCH_GAP_SECONDS (float, default 1, env
tunable; 0 disables) on both success and failure paths.

Also thread kw_fk_col into load_ignored_keywords() inside
send_pending_notifications() so the physical-FK lookup (445040a) actually takes
effect instead of silently falling back to the watch Link column.
2026-09-13 18:59:57 +08:00
hoelee 9cea82e24e Fix run_tick/send_pending_notifications signatures missing kw_fk_col 2026-09-13 18:49:44 +08:00
hoelee 92f9276b67 Read IgnoredKeywords watch link via physical FK column
Previously load_ignored_keywords() relied on the 'watch' Link column's
expanded shape ({'Id': N, ...}), which NocoDB does not guarantee. Now:

- _ensure_keyword_watch_link() discovers the physical FK column title
  (e.g. nc_xxx___Settings_id, uidt=ForeignKey) from the table meta and
  returns it from bootstrap()
- load_ignored_keywords() prefers reading the FK column (pure numeric Id
  in the records API), falling back to the watch object only if absent
- kw_fk_col is threaded bootstrap -> main -> run_tick ->
  send_pending_notifications -> load_ignored_keywords

Same one-Settings-fetch-per-cycle design, no N+1.
2026-09-13 18:37:20 +08:00
hoelee a381490f5f Drop IgnoredKeywords.search_url column; resolve URL via watch link
The search_url Lookup column is redundant: the watch Link -> Settings row
already defines the association. Remove handling for the column:
- _ensure_keyword_watch_link() no longer creates a Lookup; deletes any
  legacy search_url (URL or Lookup type) on bootstrap
- load_ignored_keywords(kw_tid, settings_tid) resolves Settings.url via
  the watch link, one Settings fetch per cycle (no N+1)
- send_pending_notifications passes settings_tid through
- Add COMPOSE-SETUP.md: detail documentation for the compose stack
  (topology, compose block-by-block, Dockerfile, healthcheck, deploy paths,
  troubleshooting); referenced from README + DOCUMENTATION.md
2026-09-13 18:22:58 +08:00
hoelee 531da933ca Make IgnoredKeywords.watch a real Link column to Settings
Replace the hand-copied search_url (URL type) with a proper relation:
- watch: LinkToAnotherRecord (belongs-to, many keywords -> one Settings row),
  picked from a dropdown in the UI so a wrong URL can't silently disable a keyword
- search_url: Lookup column that auto-fills Settings.url through the link

_ensure_keyword_watch_link() builds both columns idempotently on bootstrap and
upgrades a legacy URL-typed search_url to Link+Lookup. load_ignored_keywords()
now normalises Lookup values (string or array).
2026-09-13 18:11:51 +08:00
hoelee 9263b91276 Add per-watch ignored keywords (IgnoredKeywords table)
Silence Telegram alerts for listings whose title contains a keyword
configured for that watch, matched case-insensitively as a substring.

- New IgnoredKeywords table: search_url (watch key) + keyword (one per row)
- load_ignored_keywords() returns {search_url: {lowercase keywords}}, reloaded
  every notification cycle so UI edits apply immediately
- title_matches_keyword() does the case-insensitive substring check
- send_pending_notifications() now also pulls search_url on pending records;
  skip_notify=true + notified=true on a hit (still archived, no Telegram)
- Docs updated: AGENTS.md, README.md, DOCUMENTATION.md
2026-09-13 17:50:11 +08:00
hoelee bcd858106d Add seller ignore list: skip_notify column + IgnoredSellers table
New Listings.skip_notify checkbox and IgnoredSellers table (seller_name).\nPending listings whose seller_name matches the ignore list are marked\nskip_notify=true + notified=true and never sent to Telegram. List reloads\nevery notification cycle so UI add/remove takes effect immediately.
2026-09-11 11:37:35 +08:00
hoelee df5ed83664 Exclude Carousell ads; fix thumbnail suffix strip for extension-less URLs
- fetch_listings 跳过 listingID=0 的广告卡片(title 常为 ap_promo_*)
- strip_thumbnail_suffix 改用 replace,同时覆盖 _progressive_thumbnail.jpg 和 _progressive_thumbnail(无扩展名)两种形态
2026-09-09 05:41:14 +08:00
hoelee d280e5e587 Fix Telegram notifications: multipart upload + richer message format
- 通知重构:归档与通知解耦(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
2026-09-09 05:31:55 +08:00
hoelee d4bfcf92f5 Add deployment & operations documentation 2026-09-02 08:43:17 +08:00
hoelee 68b2fbb081 Private build on DSM (no registry); drop Docker Hub 2026-09-02 08:30:01 +08:00
hoelee 17b7c4c55f Carousell new-listing monitor: NocoDB archive + Telegram alerts 2026-09-02 08:25:12 +08:00