- Python 100%
| systemd | ||
| .gitignore | ||
| config.json | ||
| maa_check.py | ||
| README.md | ||
MAA price checker
Polls maac.com property pages and pushes an ntfy alert when a unit matching your beds/baths/rent rule shows up.
Deployed: CT 101 (personal) on milotron, /opt/maa-price-checker/, systemd timers.
Operational notes live in the vault: Documentation/Homelab/milotron/maa-price-checker.md.
Why this is simple
MAA runs on Next.js and server-renders every available unit into a __NEXT_DATA__
JSON blob on the property page — unit number, beds, baths, sqft, exact rent,
availability date, and an apply link. So there is no HTML parsing, no headless
browser, no bot wall, and no third-party dependencies — Python 3.9+ stdlib only.
The unit data sits under a componentProps key named by a GUID that changes on
every MAA deploy, so the script walks the JSON tree looking for the record shape
rather than a hardcoded path. That is the one piece most likely to need attention
if MAA redesigns, which is why breakage alerts exist (below).
Config
config.json holds everything non-secret:
{
"ntfy_server": "http://192.168.1.105:8083",
"ntfy_topic": "apartment-hunting",
"beds": 2,
"baths": 2,
"max_rent": 1965,
"failure_alert_after": 3,
"properties": [
{ "name": "MAA Centennial Park",
"url": "https://www.maac.com/georgia/atlanta/maa-centennial-park/" }
]
}
beds/baths/max_rent can also be set per-property to override the defaults.
Add more MAA properties to the list and they all get checked in one run, each with
independent state keyed on name — renaming a property resets its history.
Secrets come from the environment, never the file. NTFY_URL and NTFY_TOKEN
live in /etc/maa-price-checker.env (0600) and win over config.json.
MAX_RENT and HEALTHCHECKS_URL are also env-overridable.
Commands
python3 maa_check.py check # one poll (what the timer runs)
python3 maa_check.py list # dump every available unit, any layout
python3 maa_check.py test # publish a sample alert
Flags: --dry-run (print instead of send, skip the state write), --force-report
(always summarise — the daily timer), --config, --state.
On the server:
pct exec 101 -- bash -c 'set -a; . /etc/maa-price-checker.env; set +a; \
cd /opt/maa-price-checker && python3 maa_check.py check'
pct exec 101 -- systemctl list-timers 'maa-price-checker*'
pct exec 101 -- journalctl -u maa-price-checker -n 50
Alert tiers
| Tier | ntfy priority | Fires when |
|---|---|---|
| hit | max |
A unit matches your layout and is at or under max_rent. Click action goes straight to the apply URL. |
| floor | default |
The cheapest matching-layout unit changed price or unit number. Shows the delta and how far it still is from your cap. |
| broken | high |
The page failed to fetch or parse failure_alert_after runs in a row. |
A unit already reported at a given price will not re-alert every run; it only
re-fires if its price changes. Suppression state lives in state.json.
That third tier is the one that earns its keep. Without it, "no alerts" is ambiguous between nothing matched and this has silently returned garbage since the site redesign three weeks ago. Every run also logs a heartbeat naming what it actually saw:
[MAA Centennial Park] alive -- 30 units listed, 9 2b2b, floor $2,045, cap $1,965
Alerts are banked only on confirmed delivery. If the ntfy POST fails, the hits stay unbanked and retry next run — otherwise one outage would silence a unit permanently. Same for the floor baseline.
Current reality (checked 2026-09-01)
MAA Centennial Park had 30 units listed, 9 of them 2b2b:
#2424 $2,045 993sf avail 09/03/2026 <- cheapest 2b2b
#2516 $2,070 1130sf avail 08/24/2026
#2419 $2,080 993sf avail 10/06/2026
...
#1155 $2,490 1178sf avail 10/21/2026
The 2b2b floor is $2,045, so a $1,965 cap is $80 away — close enough that the floor-change alerts are the ones you'll actually hear from day to day.
Note that everything in the $1,385–$1,720 range is studios and 1b1b. A price-band
filter of $1,600–1,800 as a proxy for "not a 1b1b" would do the exact opposite of
what you want here — it would return only 1b1b. This script filters on the real
beds/baths integers instead, so the price threshold is independent of layout.
Files
maa_check.py— the whole thing, stdlib onlyconfig.json— properties, rule, ntfy target (no secrets)state.json— previous snapshot; delete to reset the baselinesystemd/— hourly timer + daily digest, as deployed on CT 101
Deploying an update
scp maa_check.py config.json pve:/root/staging/maa/
ssh pve 'pct push 101 /root/staging/maa/maa_check.py \
/opt/maa-price-checker/maa_check.py --perms 755'
Use pct push, not a host-path write — the latter lands files nobody:nogroup
inside an unprivileged container.