Set up Apt Hunter

Automated NYC apartment search with deterministic rules, Claude-powered scoring, tiered email outreach, and a real-time dashboard. This guide walks you through every step from zero to running.

8 listing sources 84 tests ~30 min setup

Architecture

How the pieces fit together before you start configuring.

┌─────────────────────────────────────────────────────────────────┐ │ apt-hunter daemon │ ├──────────┬──────────┬───────────┬──────────┬───────────┬────────┤ │ Scrapers │ Rules │ Claude │ Actions │ Bot │ API │ │ (8 src) │ Engine │ Scorer │ (Gmail) │ (Telegram)│(FastAPI│ ├──────────┴──────────┴───────────┴──────────┴───────────┴────────┤ │ SQLite WAL Database │ └──────────────────────────────┬──────────────────────────────────┘┌──────────┴──────────┐ │ React Dashboard │ │ (apartments.*.com) │ └─────────────────────┘

The daemon is a single Python process that runs scrapers on a schedule, scores listings through the rule engine + Claude, dispatches emails via Gmail, and serves a FastAPI backend. The Telegram bot and React dashboard are your control surfaces.

0 Prerequisites

What you need installed before starting.

RequirementVersionCheck
Python3.11+python --version
Node.js18+node --version
Gitanygit --version
piplatestpip --version

Clone & Install

# Clone the repo (or copy the apt-hunter directory to your server)
cd ~/
git clone <your-repo-url> apt-hunter
cd apt-hunter

# Create virtual environment and install Python deps
python -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt

# Install frontend deps
cd frontend
npm install
cd ..

1 Create a Telegram Bot

The bot is your mobile control plane — scan, track, approve, and monitor from your phone.

Open Telegram and search for @BotFather. Send /newbot.

Choose a name (e.g. Apt Hunter) and a username (e.g. apt_hunter_nyc_bot). BotFather will respond with your bot token — a string like 7123456789:AAH....

Start a conversation with your new bot — open it and press Start. Then get your chat ID by visiting:

https://api.telegram.org/bot<YOUR_BOT_TOKEN>/getUpdates

Look for "chat":{"id": XXXXXXXX} in the response. That number is your chat ID.

Save both values — you'll put them in config.json in step 3.

You can use any Telegram account — the bot token controls the bot identity, and the chat ID controls who receives notifications. They don't need to be on the same account.

Available Commands

Once running, the bot registers these commands automatically:

CommandWhat it does
/statusSource health, tier counts, active listings
/scanTrigger an immediate scan cycle
/pausePause the scan loop
/resumeResume scanning
/top NShow top N listings by score
/rulesList active rules
/track <id>Start tracking a listing for price changes
/untrack <id>Stop tracking
/approve <id>Approve and send a queued email draft
/dismiss <id>Dismiss a listing
/digestGenerate and view today's digest

2 Anthropic API Key

Claude scores every listing for quality, scam risk, and value — and composes outreach emails.

Go to console.anthropic.com and sign in (or create an account).

Navigate to API Keys and create a new key. Copy it immediately — it won't be shown again.

Add billing. Apt Hunter uses approximately $1–3/day at typical NYC listing volume:

  • Haiku for listing scoring (~$0.001/listing, ~500/day)
  • Sonnet for email composition (~$0.01/email, ~5-20/day)
  • Haiku for NL queries (ad-hoc)
The daemon probes the Claude API on startup with a 1-token ping. If the key is invalid or the model ID is wrong, it exits immediately with a clear error. This is intentional — fail loud, not silently.

3 Configure config.json

The central configuration file. Located at the project root.

{
  "telegram_bot_token": "7123456789:AAH...",
  "telegram_chat_id": "8426763327",
  "anthropic_api_key": "sk-ant-...",
  "claude_scoring_model": "claude-haiku-4-5-20251001",
  "claude_email_model": "claude-sonnet-4-6-20260320",
  "api_port": 8080,
  "db_path": "apt_hunter.db",
  "digest_time_et": "09:00",

  "scoring": {
    "rule_weight": 0.6,        // 60% deterministic rules
    "claude_weight": 0.4,      // 40% Claude assessment
    "auto_send_threshold": 85.0, // auto-email above this
    "review_threshold": 65.0,   // draft for review
    "flag_threshold": 40.0      // just flag in dashboard
  },

  "sources": {
    "streeteasy":      { "enabled": true,  "interval_hours": 2.0  },
    "craigslist":      { "enabled": true,  "interval_hours": 2.0  },
    "zillow":          { "enabled": false, "interval_hours": 4.0  },
    "apartments_com":  { "enabled": false, "interval_hours": 4.0  },
    "fb_marketplace":  { "enabled": false, "interval_hours": 6.0  },
    "fb_groups":       { "enabled": false, "interval_hours": 6.0  },
    "leasebreak":      { "enabled": false, "interval_hours": 24.0 },
    "listings_project": { "enabled": false, "interval_hours": 168.0}
  }
}
Start with just StreetEasy + Craigslist enabled. Add more sources once you've verified the core loop works. Enable sources one at a time to isolate any scraping issues.

Key Settings

FieldWhat it controls
auto_send_thresholdListings scoring above this get emailed automatically. Set to 95+ (or disable source) while calibrating.
rule_weight / claude_weightHow much deterministic rules vs Claude assessment influence the composite score. Must sum to 1.0.
interval_hoursHow often each source is scanned. The main loop sleeps for the minimum enabled interval.
claude_scoring_modelHaiku for scoring (cheap, fast). Sonnet for email composition (better writing).

4 Gmail OAuth Setup

Optional — enables automated email outreach from your real Gmail address. Skip this to use the system without email.

Go to console.cloud.google.com. Select or create a project.

Enable the Gmail API: navigate to APIs & Services → Library → search "Gmail API" → Enable.

Create OAuth credentials: APIs & Services → Credentials → Create Credentials → OAuth client ID. Choose Desktop app. Download the JSON file.

Run the initial auth flow to generate a token. Create this script and run it once:

# generate_gmail_token.py
from google_auth_oauthlib.flow import InstalledAppFlow

SCOPES = [
    "https://www.googleapis.com/auth/gmail.send",
    "https://www.googleapis.com/auth/gmail.readonly",
]

flow = InstalledAppFlow.from_client_secrets_file(
    "client_secret.json",  # the file you downloaded
    scopes=SCOPES,
)
creds = flow.run_local_server(port=0)

# Save the token
import json, os
token_dir = os.path.expanduser("~/.config/apt-hunter")
os.makedirs(token_dir, exist_ok=True)
token_path = os.path.join(token_dir, "gmail_token.json")

with open(token_path, "w") as f:
    json.dump(json.loads(creds.to_json()), f, indent=2)

print(f"Token saved to {token_path}")

Run it: python generate_gmail_token.py. A browser window opens — sign in with the Gmail account you want to send from. Authorize the app.

The token file at ~/.config/apt-hunter/gmail_token.json contains your Gmail credentials. Keep it out of version control. The daemon checks for this file on startup — if missing, email features are disabled gracefully.

5 Seed Default Rules

Pre-populate the rule engine with sensible defaults. You'll customize these from the dashboard or Telegram.

# From the project root, with venv active:
python seed_rules.py

# Output:
Seeded 11 rules (2 hard, 9 soft).

Default Hard Filters

RuleFilter
Max priceprice < $5,000
Min bedroomsbedrooms ≥ 1

Default Soft Scoring

RuleWeightLogic
Price sweetness25Linear scale — cheaper is better
Neighborhoods20Tier 1/2/3 scoring by area
Laundry15In-unit > in-building > none
Floor10Floors 2-5 preferred
No-fee bonus10No broker fee = full score
Dishwasher5Binary bonus
Outdoor space5Binary bonus
Photo count58+ photos = full score
Owner listing5Owner > mgmt > broker

Modify these anytime via the Rules page in the dashboard, the /rules Telegram command, or the NL search ("save as rule").

6 Run the Daemon

Start scanning.

# Activate venv and run
source .venv/bin/activate
python main.py

On startup, you'll see:

Claude API connectivity OK (claude-haiku-4-5-20251001)
Telegram bot started in background thread
Telegram bot online
Gmail integration active             # only if token exists
Price tracker and digest builder active
NL query engine active
API listening on port 8080
apt-hunter started — 2 sources enabled: streeteasy, craigslist
=== scan cycle starting ===
scanning streeteasy...
Send /status to your Telegram bot to verify it's running. You should see source health, tier counts, and active listing count.

Set Up Your Applicant Profile

Before the auto-emailer composes messages, fill in your profile. Use the Settings page in the dashboard, or via API:

# Set your profile fields
curl -X POST http://localhost:8080/api/profile \
  -H "Content-Type: application/json" \
  -d '{"key": "name", "value": "Your Name"}'

curl -X POST http://localhost:8080/api/profile \
  -d '{"key": "move_in_date", "value": "August 1"}' \
  -H "Content-Type: application/json"

# Repeat for: current_neighborhood, employment,
# income_summary, household_size, pets, credit_score

7 Deploy the Dashboard

The React dashboard gives you a visual interface for browsing, tracking, and managing listings.

Local Development

cd frontend
npm run dev
# Opens at http://localhost:3000
# API calls proxy to localhost:8080

Production Build

cd frontend
npm run build
# Output in frontend/dist/

Serve via FastAPI

For production, serve the built frontend directly from the FastAPI backend. Add this to api/app.py:

# Add static file serving for the dashboard
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse

# Mount after all /api routes
app.mount("/assets", StaticFiles(
    directory="frontend/dist/assets"
), name="assets")

@app.get("/{path:path}")
async def spa_fallback(path: str):
    return FileResponse("frontend/dist/index.html")

Expose via Cloudflare Tunnel

If running on a server behind a tunnel (e.g. apartments.slurbbot.com), point the tunnel at port 8080. Add Google OAuth middleware for authentication.

8 Systemd Service

Run as an always-on daemon that survives reboots.

# /etc/systemd/system/apt-hunter.service
[Unit]
Description=Apt Hunter - NYC Apartment Search
After=network.target

[Service]
Type=simple
User=seb
WorkingDirectory=/home/seb/apt-hunter
ExecStart=/home/seb/apt-hunter/.venv/bin/python main.py
Restart=always
RestartSec=10
EnvironmentFile=/home/seb/.secrets/secrets.env

[Install]
WantedBy=multi-user.target
# Enable and start
sudo systemctl daemon-reload
sudo systemctl enable apt-hunter
sudo systemctl start apt-hunter

# Check status
sudo systemctl status apt-hunter
journalctl -u apt-hunter -f

9 Troubleshooting

Common issues and how to fix them.

"Claude API probe failed" on startup

The API key is invalid or the model ID doesn't exist. Check anthropic_api_key in config.json. Verify your account has API credits at console.anthropic.com. Ensure the model ID matches an available model.

Telegram bot doesn't respond

Check that telegram_bot_token is correct. Make sure you started a conversation with the bot (press /start). Verify the telegram_chat_id matches the account you're messaging from. Check logs for "Telegram bot started in background thread".

Scraper returns 0 listings

CSS selectors may need calibration against the live site. StreetEasy and Zillow are the most aggressive about bot detection. Check the daemon logs for HTTP status codes. If you see 403/429, reduce rate_limit_rpm in config. Try enabling one source at a time.

"Gmail credentials not found"

Expected if you haven't set up Gmail OAuth (step 4). Email features are disabled gracefully — the daemon still runs. The token file must be at ~/.config/apt-hunter/gmail_token.json.

Dashboard shows no data

The dashboard reads from the API at port 8080. Ensure the daemon is running and the API is accessible. Check the browser console for CORS or network errors. For local dev, the Vite proxy handles CORS — make sure the daemon is on port 8080.

Tests failing after changes

# Run the full test suite
python -m pytest tests/ -v

# Run a specific test file
python -m pytest tests/test_rules.py -v

# Run with output for debugging
python -m pytest tests/ -v -s

Database is corrupted

Delete apt_hunter.db and restart the daemon — it will recreate the schema via migrate(). You'll lose historical data but the rules survive in seed_rules.py. For production, back up the DB file regularly.

All scraper CSS selectors are marked with calibration notes in the source. When a scraper stops working, the fix is usually updating the selectors in scrapers/<source>.py to match the site's current HTML structure.