costco-watch: ntfy alert when a Costco tire is back in stock
This commit is contained in:
@@ -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.
|
||||
@@ -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}"
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user