- Python 99.7%
- Shell 0.2%
- Batchfile 0.1%
BFMR's API now routinely takes 20-30s per page (measured live), so the 15s timeout made nearly every poll fail with a request timeout. |
||
|---|---|---|
| groups | ||
| .env.example | ||
| .gitignore | ||
| discord_notifier.py | ||
| LICENSE | ||
| logger.py | ||
| main.py | ||
| README.md | ||
| requirements.txt | ||
| run.bat | ||
| run.sh | ||
| state.py | ||
| test_script.py | ||
BuyingGroupAlerts
Polls buying-group deal APIs and posts new deals to Discord as they appear. Currently supports:
- BFMR (
groups/bfmr/) - MaxOutDeals (
groups/maxoutdeals/)
Each group runs its own poll loop on its own thread with its own interval and its own Discord webhook, so groups are fully independent of each other.
Setup
Windows:
python -m venv venv
venv\Scripts\activate
pip install -r requirements.txt
copy .env.example .env
Linux / macOS:
python3 -m venv venv
source venv/bin/activate
pip install -r requirements.txt
cp .env.example .env
Fill in .env with your credentials (see Configuration below).
Running
python main.py
On first run for each group, it records the currently active deals as a baseline and sends no alerts (so you don't get spammed with everything that's already live). From then on, only genuinely new deals trigger an alert.
State is persisted per group in seen_<group>.json at the project root (gitignored).
Running persistently (survives reboots)
main.py runs forever in the foreground (one thread per group, looping on its own interval), so to keep it alive across restarts you need to hand it to the OS's own persistence mechanism instead of just leaving a terminal open.
Two wrapper scripts are included so these all cd to the project folder first, regardless of what directory they're invoked from:
run.bat(Windows) — resolves its own location, then runsvenv\Scripts\python.exe main.py.run.sh(Linux/macOS) — resolves its own location, then runsvenv/bin/python main.py. Make it executable once:chmod +x run.sh.
Windows: Task Scheduler
Run once from an elevated (Admin) prompt to register a task that starts run.bat whenever you log in:
schtasks /create /tn "BuyingGroupAlerts" /tr "C:\path\to\BuyingGroupAlerts\run.bat" /sc onlogon /rl highest
- Adjust the path to wherever you cloned the repo.
/sc onlogonstarts it at login under your own account — no stored password needed. If you want it to start even when nobody's logged in, use/sc onstartinstead, which will prompt for your account password (/ru//rp) since it needs to run outside a login session.
Useful follow-ups:
schtasks /run /tn "BuyingGroupAlerts" # start it right now, without waiting for next logon
schtasks /query /tn "BuyingGroupAlerts" # check status
schtasks /delete /tn "BuyingGroupAlerts" /f # remove the task
Prefer a GUI? Open Task Scheduler → Create Task → set a Trigger of "At log on" → set an Action of "Start a program" pointing at run.bat.
Linux: screen + crontab
screen keeps main.py running in a detached session you can reattach to for logs; a crontab @reboot entry restarts that session automatically after a reboot.
Start it manually in a detached screen session:
screen -dmS buyinggroupalerts ./run.sh
Reattach to see live output, detach again with Ctrl+A then D:
screen -r buyinggroupalerts
screen -ls # list running sessions
To have it come back automatically after a reboot, add this to crontab -e (use the absolute path, since cron's working directory can't be relied on):
@reboot screen -dmS buyinggroupalerts /path/to/BuyingGroupAlerts/run.sh
Testing
python test_script.py # offline logic tests + live API/Discord calls
python test_script.py --offline # offline logic tests only, no network calls
The offline tests cover embed formatting, dedup logic, and (for MOD) row-to-deal grouping, all using synthetic data. The live tests hit both real APIs and send a sample embed to each group's Discord webhook.
MaxOutDeals rate limit: their API only allows one request per 5 minutes. If a live MOD test/poll gets firewall-blocked, that's why — wait and retry rather than hammering it.
Configuration
All config lives in .env (see .env.example for the full template).
| Variable | Group | Description |
|---|---|---|
BFMR_API_KEY, BFMR_API_SECRET |
BFMR | API credentials |
BFMR_BASE_URL |
BFMR | API base URL |
MOD_USER_ID, MOD_EMAIL, MOD_API_TOKEN |
MOD | API credentials |
MOD_BASE_URL |
MOD | API base URL |
DISCORD_WEBHOOK_URL |
both | Default webhook; used by BFMR, and by MOD as a fallback |
MOD_DISCORD_WEBHOOK_URL |
MOD | Optional separate webhook, so MOD alerts can post to their own channel |
POLL_INTERVAL_SECONDS |
both | Default poll interval (seconds); used as a fallback |
BFMR_POLL_INTERVAL_SECONDS |
BFMR | Optional override of the default interval |
MOD_POLL_INTERVAL_SECONDS |
MOD | Optional override of the default interval |
BFMR_ENABLED |
BFMR | Set to false/0/no/off to stop this group from polling entirely. Defaults to enabled. |
MOD_ENABLED |
MOD | Same, for MaxOutDeals. |
Architecture
Each group is a self-contained package under groups/<name>/ with three files:
client.py— talks to the group's API, returns raw deal data.formatter.py— turns a deal into a Discord embed.poller.py— dedup/filtering logic; exposesget_baseline_ids(client)andpoll_once(client, seen_ids).
main.py registers each group in a GROUPS dict and runs one thread per group, polling on that group's own interval. discord_notifier.py and state.py are shared across all groups.
BFMR
Deals come back as clean JSON, one deal per API response with nested items and retailer links. poll_once alerts on any deal not yet seen, skipping ones whose reservation is closed.
MaxOutDeals
The API returns CSV, not JSON, with one row per (deal, retailer) pair — the same deal repeats across multiple rows for its different retailers and color/variant options. group_deals() in poller.py collapses these flat rows into deals:
- A deal is identified by
(DEAL, TITLE, BATCH). If MOD reissues the same product under a newBATCH(e.g. a restock), it's treated as a new deal and will alert again. - Within a deal, rows are further grouped by
COMMIT ITEM, since that's where MOD encodes color/variant — there's no separate structured color field, so it's grouped by exact string match rather than parsed. - Each retailer row's
COMMIT STORE+COMMIT LINKbecomes one entry in that variant's retailer list. - A deal is only suppressed as "at capacity" (and skipped) if every retailer row for it is full.
- "Closing Time" is the earliest
COMMIT EXPIRESacross all of a deal's retailer rows, since that field varies per retailer rather than being deal-wide. - Only
TOTAL(the payout) is shown as "Payout Price" — MOD'sPRICE/COMMISSIONcolumns aren't reliable, so there's no Retail Price or Commission field, unlike BFMR. - There's no single canonical "commit" link for a MOD deal (each retailer has its own), so the embed title isn't a hyperlink. The "Deal Link" field instead points at the official MaxOutDeals Discord: the below-cost channel if
FLAGScontainsBELOW COST, otherwise the general deals channel. Per-retailer commit links are listed inside each item's Retailers list.
To add a new group, create groups/<name>/ with the same three-file shape, then register it in main.py's GROUPS dict (poll_interval_env should point at a new <NAME>_POLL_INTERVAL_SECONDS env var, and enabled_env at a new <NAME>_ENABLED env var). New groups should also ping @everyone on alerts (via discord_notifier.EVERYONE_PING), matching BFMR and MOD, unless told otherwise.