Polls maac.com property pages and pushes an ntfy alert when a unit matching your beds/baths/rent rule shows up. Python stdlib only.
Find a file
2026-10-09 04:08:52 -04:00
systemd Add ApartmentList as a second source, plus two watches for the Sep 29 move 2026-09-11 19:50:42 -04:00
.gitignore Untrack __pycache__ and ignore Python bytecode 2026-10-09 04:08:52 -04:00
config.json Add ApartmentList as a second source, plus two watches for the Sep 29 move 2026-09-11 19:50:42 -04:00
maa_check.py Add ApartmentList as a second source, plus two watches for the Sep 29 move 2026-09-11 19:50:42 -04:00
README.md MAA apartment price checker 2026-09-03 12:13:52 -04:00

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 only
  • config.json — properties, rule, ntfy target (no secrets)
  • state.json — previous snapshot; delete to reset the baseline
  • systemd/ — 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.