diff --git a/apps/costco-watch/build/README.md b/apps/costco-watch/build/README.md new file mode 100644 index 0000000..71b23af --- /dev/null +++ b/apps/costco-watch/build/README.md @@ -0,0 +1,13 @@ +# costco-watch image + +Dependency-free Node service (node:22-alpine + curl for the healthcheck). +Source: https://git.alexzaw.dev/alexz/costco-watch (working copy: +~/projects/costco-watch). Build + push with ./build.sh. + +Gotchas: +- TARGET_URL must go through the costco-tire cloudflared proxy AND that + hostname needs a Cloudflare Transform Rule adding browser headers + (User-Agent, Accept, Accept-Language, Sec-Fetch-*). Without it Costco stalls + the request until timeout and the checker reports "error"/"blocked". +- State (last stock value, transition history) lives in ${APP_DATA_DIR}/data; + wiping it means the next check can re-alert. diff --git a/apps/costco-watch/build/build.sh b/apps/costco-watch/build/build.sh new file mode 100644 index 0000000..dc56a11 --- /dev/null +++ b/apps/costco-watch/build/build.sh @@ -0,0 +1,7 @@ +#!/bin/bash +set -euo pipefail +IMAGE_NAME="${IMAGE_NAME:-git.alexzaw.dev/alexz/costco-watch}" +IMAGE_TAG="${IMAGE_TAG:-latest}" +SRC="${SRC:-$HOME/projects/costco-watch}" +docker build "$@" -t "${IMAGE_NAME}:${IMAGE_TAG}" "$SRC" +docker push "${IMAGE_NAME}:${IMAGE_TAG}" diff --git a/apps/costco-watch/config.json b/apps/costco-watch/config.json new file mode 100644 index 0000000..ac65393 --- /dev/null +++ b/apps/costco-watch/config.json @@ -0,0 +1,84 @@ +{ + "$schema": "../app-info-schema.json", + "name": "Costco Stock Watch", + "id": "costco-watch", + "available": true, + "port": 8592, + "exposable": false, + "dynamic_config": true, + "no_gui": true, + "tipi_version": 1, + "version": "1.0.0", + "categories": [ + "automation", + "utilities" + ], + "description": "Polls a Costco tire product page on an interval and pushes an ntfy alert the moment it flips from out-of-stock to in-stock. Points at the costco-tire cloudflared proxy, which needs a Cloudflare Transform Rule attaching browser-like request headers - Costco stalls non-browser header sets.", + "short_desc": "Alerts when a Costco tire is back in stock", + "author": "alexz", + "source": "https://git.alexzaw.dev/alexz/costco-watch", + "form_fields": [ + { + "type": "text", + "label": "Product URL", + "env_variable": "TARGET_URL", + "required": true, + "default": "https://costco-tire.alexzaw.dev/SearchResultsByItemOrPart?ItemNo=1273727", + "hint": "Full product page URL to watch, via the costco-tire proxy" + }, + { + "type": "text", + "label": "Item Label", + "env_variable": "ITEM_LABEL", + "required": false, + "default": "Michelin Primacy Tour A/S 235/45R18", + "hint": "Friendly name used in the notification text" + }, + { + "type": "text", + "label": "ntfy Topic", + "env_variable": "NTFY_TOPIC", + "required": true, + "hint": "Topic to publish to, e.g. costco-tire. Subscribe to it in the ntfy app." + }, + { + "type": "text", + "label": "ntfy URL", + "env_variable": "NTFY_URL", + "required": false, + "default": "https://ntfy.alexzaw.dev", + "hint": "ntfy server base URL" + }, + { + "type": "password", + "label": "ntfy Token", + "env_variable": "NTFY_TOKEN", + "required": false, + "hint": "Only needed if the topic requires auth. Leave blank for an open topic." + }, + { + "type": "text", + "label": "Check Interval (minutes)", + "env_variable": "INTERVAL_MINUTES", + "required": false, + "default": "60", + "hint": "Keep at 60 or more - this is a courtesy check, not a scraper" + }, + { + "type": "random", + "label": "API Key", + "env_variable": "API_KEY", + "required": true, + "min": 24, + "encoding": "hex", + "hint": "Guards /status and /check. Auto-generated." + } + ], + "supported_architectures": [ + "amd64" + ], + "created_at": 1754995000000, + "updated_at": 1754995000000, + "deprecated": false, + "min_tipi_version": "4.5.0" +} diff --git a/apps/costco-watch/docker-compose.json b/apps/costco-watch/docker-compose.json new file mode 100644 index 0000000..2d324da --- /dev/null +++ b/apps/costco-watch/docker-compose.json @@ -0,0 +1,37 @@ +{ + "schemaVersion": 2, + "$schema": "https://schemas.runtipi.io/dynamic-compose.json", + "services": [ + { + "name": "costco-watch", + "image": "git.alexzaw.dev/alexz/costco-watch:latest", + "isMain": true, + "internalPort": 8080, + "environment": [ + { "key": "PORT", "value": "8080" }, + { "key": "DATA_DIR", "value": "/data" }, + { "key": "TARGET_URL", "value": "${TARGET_URL}" }, + { "key": "ITEM_LABEL", "value": "${ITEM_LABEL:-Costco item}" }, + { "key": "NTFY_URL", "value": "${NTFY_URL:-https://ntfy.alexzaw.dev}" }, + { "key": "NTFY_TOPIC", "value": "${NTFY_TOPIC}" }, + { "key": "NTFY_TOKEN", "value": "${NTFY_TOKEN:-}" }, + { "key": "INTERVAL_MINUTES", "value": "${INTERVAL_MINUTES:-60}" }, + { "key": "API_KEY", "value": "${API_KEY}" } + ], + "volumes": [ + { + "hostPath": "${APP_DATA_DIR}/data", + "containerPath": "/data", + "readOnly": false + } + ], + "healthCheck": { + "test": "curl -f http://127.0.0.1:8080/health || exit 1", + "interval": "60s", + "timeout": "5s", + "retries": 3, + "startPeriod": "10s" + } + } + ] +} diff --git a/apps/costco-watch/metadata/description.md b/apps/costco-watch/metadata/description.md new file mode 100644 index 0000000..7c92a86 --- /dev/null +++ b/apps/costco-watch/metadata/description.md @@ -0,0 +1,40 @@ +# costco-watch + +Polls a Costco tire product page on an interval and pushes an ntfy alert when +the item flips from out-of-stock to in-stock. + +## Why it points at a proxy, not costco.com + +Costco's bot mitigation silently stalls requests carrying a non-browser header +set (the connection completes TLS, then nothing comes back - no 403, just a +hang). The page loads fine through `costco-tire.alexzaw.dev`, a cloudflared +ingress rule with `httpHostHeader: tires.costco.com`, **provided** a Cloudflare +Transform Rule attaches browser-like request headers (`User-Agent`, `Accept`, +`Accept-Language`, `Sec-Fetch-*`, `Upgrade-Insecure-Requests`) for that +hostname. cloudflared itself cannot add headers - its `originRequest` block has +no such option - which is why the rule lives at the edge. + +If the checker starts reporting `blocked`, that Transform Rule is the first +thing to verify. + +## Config + +| Env | Purpose | +|-----|---------| +| `TARGET_URL` | Product page to watch (required) | +| `ITEM_LABEL` | Friendly name used in notifications | +| `NTFY_URL` / `NTFY_TOPIC` | ntfy server and topic (topic required) | +| `NTFY_TOKEN` | Bearer token, if the topic is protected | +| `INTERVAL_MINUTES` | Poll interval, default 60 | +| `API_KEY` | Guards `/status` and `/check` | + +## Endpoints + +- `GET /health` - liveness +- `GET /status` - current stock state, last check, transition history +- `POST /check` - force a check now + +## Etiquette + +`tires.costco.com/robots.txt` allows `User-agent: *` on all paths, so a personal +low-frequency check is within its terms. Keep the interval at an hour or more.