BuildBotsBetter
LEADS

Rental Leads / Rental Comps

Real Estate Scraping, Pydantic Normalizer, Comp Valuation & Deal Pipeline

Progress
0%

Select Your Operating System

Commands, environment activation, and scheduled tasks adjust dynamically for your system.

Step 1 of 6 Est: 3 minutes

Set Up Python Environment & Analytics Dependencies

Create an isolated virtual environment and install BeautifulSoup4, Pydantic, Pandas, and Requests.

Install Required Packages

Initialize your workspace and install web scraping and data modeling libraries:

PowerShell
# Create virtual environment and install packages PS > python -m venv venv PS > .\venv\Scripts\Activate.ps1 PS > pip install beautifulsoup4 pydantic pandas requests
zsh
# Create virtual environment and install packages $ python3 -m venv venv $ source venv/bin/activate $ pip install beautifulsoup4 pydantic pandas requests
bash
# Create virtual environment and install packages $ python3 -m venv venv $ source venv/bin/activate $ pip install beautifulsoup4 pydantic pandas requests
Checkpoint: Verify Dependencies Required

Paste output from pip list confirming beautifulsoup4 and pydantic installation:

Step 2 of 6 Est: 4 minutes

Extract Rental Data with BeautifulSoup & JSON-LD

Parse structured apartment listings and embedded schema metadata reliably without fragile selectors.

Parser Script

Target embedded application/ld+json blocks or standard listing cards to extract clean metadata:

parse_listings.py
# parse_listings.py - Scrape & extract rental listings with realistic fixture from bs4 import BeautifulSoup import json SAMPLE_HTML_FIXTURE = """

Spacious Studio in Astoria

$2,100/mo Studio | 1 Bath

31-15 Broadway, Queens, NY 11106

View Listing
""" def extract_listings_from_html(html_text: str) -> list: soup = BeautifulSoup(html_text, "html.parser") extracted = [] # 1. Parse structured JSON-LD schemas scripts = soup.find_all("script", type="application/ld+json") for s in scripts: try: data = json.loads(s.string) if isinstance(data, dict) and data.get("@type") in ("Apartment", "SingleFamilyResidence", "Place"): price = data.get("offers", {}).get("price") if isinstance(data.get("offers"), dict) else None extracted.append({ "title": data.get("name"), "price": price, "address": data.get("address", {}).get("streetAddress", ""), "neighborhood": data.get("address", {}).get("addressLocality", ""), "source": "json-ld", "url": data.get("url") }) except Exception: continue # 2. Parse fallback HTML listing cards for card in soup.select(".listing-item"): title_el = card.select_one(".title") price_el = card.select_one(".price") addr_el = card.select_one(".address") link_el = card.select_one("a.link") if title_el and price_el: price_val = int("".join(c for c in price_el.get_text() if c.isdigit())) extracted.append({ "title": title_el.get_text().strip(), "price": price_val, "address": addr_el.get_text().strip() if addr_el else "", "neighborhood": "Astoria", "source": "dom-card", "url": link_el["href"] if link_el else "" }) return extracted if __name__ == "__main__": listings = extract_listings_from_html(SAMPLE_HTML_FIXTURE) print(f"Parsed {len(listings)} structured rental listings:") for idx, item in enumerate(listings, 1): print(f" {idx}. [{item['source'].upper()}] {item['title']} - ${item['price']}/mo ({item['url']})")
Checkpoint: Verify Listing Parser Required

Run parser test and paste output confirming extracted listings or JSON-LD objects:

Step 3 of 6 Est: 4 minutes

Normalize Data with RentalProperty Model

Validate rental records with strict typing, default fallbacks, and computed metrics.

Pydantic Model

Define a structured model with rent, bedroom, bathroom, and sqft fields:

models.py
# models.py - Pydantic model for rental comps from pydantic import BaseModel, Field from typing import Optional class RentalProperty(BaseModel): property_id: str address: str zip_code: str rent: float bedrooms: int bathrooms: float sqft: Optional[int] = None url: str @property def price_per_sqft(self) -> Optional[float]: if self.sqft and self.sqft > 0: return round(self.rent / self.sqft, 2) return None prop = RentalProperty( property_id="prop-101", address="450 Elm St", zip_code="94103", rent=2800.0, bedrooms=2, bathrooms=1.0, sqft=850, url="https://example.com/prop-101" ) print("Model Validated:", prop.model_dump()) print("Price per sqft:", prop.price_per_sqft)
Checkpoint: Verify Pydantic Model Required

Paste terminal output showing validated RentalProperty model and price_per_sqft calculation:

Step 4 of 6 Est: 5 minutes

Neighborhood Comps Engine & Gross Yields

Group properties by zip code and bedroom tier to compute median market benchmarks and investment yields.

Comp Valuation Engine

Calculate neighborhood median comps and investment return ratios:

comp_engine.py
# comp_engine.py - Calculate market median comps & gross yield import statistics def calculate_neighborhood_comps(properties: list) -> dict: groups = {} for p in properties: key = (p["zip_code"], p["bedrooms"]) groups.setdefault(key, []).append(p["rent"]) comps = {} for (zip_code, beds), rents in groups.items(): comps[f"{zip_code}_{beds}bd"] = { "median_rent": round(statistics.median(rents), 2), "sample_size": len(rents) } return comps def calculate_gross_yield(monthly_rent: float, purchase_price: float) -> float: annual_income = monthly_rent * 12 return round((annual_income / purchase_price) * 100, 2) sample_comps = calculate_neighborhood_comps([ {"zip_code": "94103", "bedrooms": 2, "rent": 2900}, {"zip_code": "94103", "bedrooms": 2, "rent": 3100}, {"zip_code": "94103", "bedrooms": 2, "rent": 3000} ]) print("Calculated Comps:", sample_comps) print("Gross Yield ($3,000/mo on $450k purchase):", calculate_gross_yield(3000, 450000), "%")
Checkpoint: Verify Comp Engine Required

Paste output from running python comp_engine.py showing median rent and gross yield:

Step 5 of 6 Est: 4 minutes

Filter Underpriced Deals & Export Leads

Detect high-margin arbitrage opportunities where asking rent is significantly below market comps.

Lead Filtering & CSV Export

Filter deals with >=12% discount against comps and write structured output to CSV:

export_leads.py
# export_leads.py - Detect underpriced deals & export import pandas as pd def find_deals(properties: list, comps: dict, discount_threshold=0.12) -> list: deals = [] for p in properties: key = f"{p['zip_code']}_{p['bedrooms']}bd" if key in comps: median = comps[key]["median_rent"] discount = (median - p["rent"]) / median if discount >= discount_threshold: deals.append({ "address": p["address"], "rent": p["rent"], "median_comp": median, "discount_pct": round(discount * 100, 1), "url": p["url"] }) return deals deals = find_deals( [{"address": "123 Market St", "zip_code": "94103", "bedrooms": 2, "rent": 2400, "url": "https://sfbay.craigslist.org/sfc/apa/d/san-francisco-2bd-in-soma/7821001.html"}], {"94103_2bd": {"median_rent": 3000, "sample_size": 10}} ) df = pd.DataFrame(deals) df.to_csv("rental_leads.csv", index=False) print("Deals exported to rental_leads.csv:") print(df)
Checkpoint: Verify Deal Export Required

Paste terminal output from python export_leads.py showing exported deal records:

Step 6 of 6 Est: 4 minutes

SQLite Rental History & Automated Pipeline

Track historical rent changes in SQLite and schedule automated daily market scanning.

Database Storage & Schema

Maintain properties and historical price changes to observe market shifts:

init_db.py
# init_db.py - SQLite rental schema import sqlite3 def init_rental_db(db_path="rental_history.db"): with sqlite3.connect(db_path) as conn: conn.execute(""" CREATE TABLE IF NOT EXISTS properties ( property_id TEXT PRIMARY KEY, address TEXT, zip_code TEXT, bedrooms INTEGER, bathrooms REAL, sqft INTEGER ); """) conn.execute(""" CREATE TABLE IF NOT EXISTS rent_history ( id INTEGER PRIMARY KEY AUTOINCREMENT, property_id TEXT, rent REAL, recorded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (property_id) REFERENCES properties (property_id) ); """) print("Database rental_history.db initialized with properties and rent_history tables.") init_rental_db()

Run It Later: Scheduled Background Market Scanner

Configure automated daily collection to capture rent drops and new leads every morning:

Windows Task Scheduler
# Run daily at 7:00 AM PS > $projectDir = "$HOME\projects\rentals" PS > if (-not (Test-Path $projectDir)) { New-Item -ItemType Directory -Path $projectDir -Force | Out-Null } PS > $action = New-ScheduledTaskAction -Execute "python.exe" -Argument "main.py" -WorkingDirectory $projectDir PS > $trigger = New-ScheduledTaskTrigger -Daily -At "7:00AM" PS > Register-ScheduledTask -TaskName "RentalLeadsScanner" -Action $action -Trigger $trigger -Description "Daily Rental Market Comp Collector"
macOS crontab
# Daily at 7:00 AM $ mkdir -p "$HOME/projects/rentals" $ (crontab -l 2>/dev/null; echo "0 7 * * * cd $HOME/projects/rentals && python3 main.py >> logs/cron.log 2>&1") | crontab -
Linux systemd timer
# Create systemd user service and timer $ mkdir -p ~/.config/systemd/user $ cat << 'EOF' > ~/.config/systemd/user/rental-scanner.service [Unit] Description=Rental Market Comp Collector [Service] Type=oneshot WorkingDirectory=%h/projects/rentals ExecStart=/usr/bin/python3 main.py EOF $ cat << 'EOF' > ~/.config/systemd/user/rental-scanner.timer [Unit] Description=Run Rental Market Comp Collector Daily [Timer] OnCalendar=*-*-* 07:00:00 Persistent=true [Install] WantedBy=timers.target EOF $ systemctl --user daemon-reload $ systemctl --user enable --now rental-scanner.timer
Checkpoint: Verify SQLite Pipeline Required

Paste terminal output from initializing rental_history.db:

Lesson 7 of 7 Fleet Safety Standard

Build Bot Better & Wipe Resistance

Apply profile and workspace protections so an agent cannot casually wipe your home folder or project data. Fleet rule: Hub conversation #31.

⚠️ The 2026-09-23 Profile Wipe Lesson

An autonomous agent running without boundary constraints executed a recursive delete targeting the user profile. Prompt rules and command-text deny lists (like rm or Remove-Item) fail when paths are passed inside variables or child scripts. BuildBotsBetter requires the complete defense trio: PreToolUse path inspection, off-volume backups, and a practised restore.

1. Per-Agent Onboarding Checklist (Run Once Per Agent)

Onboarding does not end when software installs. Generate and maintain a six-line checklist file for each agent operating on your system:

PowerShell
$check = Join-Path $env:TEMP "bbb-onboarding-checklist.txt"; @("agent:","rules file:","memory location:","permissions:","delete guard:","backup path:","final check:") | Set-Content -Encoding ascii $check; Get-Content $check; Write-Host "Fill every line for this agent. Rerun this checklist for the next agent."
zsh (macOS)
check="${TMPDIR:-/tmp}/bbb-onboarding-checklist.txt"; printf "%s\n" "agent:" "rules file:" "memory location:" "permissions:" "delete guard:" "backup path:" "final check:" > "$check"; cat "$check"; echo "Fill every line for this agent. Rerun this checklist for the next agent."
bash (Linux)
check="${TMPDIR:-/tmp}/bbb-onboarding-checklist.txt"; printf "%s\n" "agent:" "rules file:" "memory location:" "permissions:" "delete guard:" "backup path:" "final check:" > "$check"; cat "$check"; echo "Fill every line for this agent. Rerun this checklist for the next agent."

2. Variable-Limit Delete Guard Demo

Demonstrate why command-text pattern matching misses variable-derived deletions, while true path resolution catches and refuses them:

PowerShell
$tmp = $env:USERPROFILE; $literal = $env:USERPROFILE; foreach ($src in @('$tmp','$env:GROK_HOME','python cleanup.py')) { if ($src -like "*$literal*") { "HIT $src" } else { "MISS $src" } }; if ([IO.Path]::GetFullPath($tmp) -eq [IO.Path]::GetFullPath($env:USERPROFILE)) { "REFUSE resolved tmp: it is the user profile. Text guards miss dollar-variables and python cleanup.py. 2026-09-23 Remove-Item of user profile is the example." }
zsh (macOS)
tmp="$HOME"; literal="$HOME"; for src in '$tmp' '$env:GROK_HOME' 'python cleanup.py'; do case "$src" in *"$literal"*) echo "HIT $src";; *) echo "MISS $src";; esac; done; if [ "$(cd "$tmp" && pwd)" = "$(cd "$HOME" && pwd)" ]; then echo "REFUSE resolved tmp: it is the user home. Text guards miss dollar-variables and python cleanup.py."; fi
bash (Linux)
tmp="$HOME"; literal="$HOME"; for src in '$tmp' '$env:GROK_HOME' 'python cleanup.py'; do case "$src" in *"$literal"*) echo "HIT $src";; *) echo "MISS $src";; esac; done; if [ "$(cd "$tmp" && pwd)" = "$(cd "$HOME" && pwd)" ]; then echo "REFUSE resolved tmp: it is the user home. Text guards miss dollar-variables and python cleanup.py."; fi

3. Off-Volume Profile & Data Backup

Back up rental comps JSON data, valuation sheets, and scraper logs off the OS volume:

PowerShell
$dest = "$HOME\Backups\rental-leads\$(Get-Date -Format yyyyMMdd-HHmm)" New-Item -ItemType Directory -Force -Path $dest | Out-Null foreach ($profile in @(".agy", ".claude", ".codex", ".grokbot")) { $src = Join-Path $HOME $profile if (Test-Path $src) { robocopy $src (Join-Path $dest $profile) /E /NFL /NDL /NJH /NJS | Out-Null Write-Host "Backed up $src to $dest\$profile" } } if (Test-Path "rental_history.db") { Copy-Item "rental_history.db" "$dest\rental_history.db"; Write-Host "Backed up rental_history.db" } if (Test-Path "rental_leads.csv") { Copy-Item "rental_leads.csv" "$dest\rental_leads.csv"; Write-Host "Backed up rental_leads.csv" } Get-ChildItem $dest
zsh (macOS)
DEST="${BACKUP_ROOT:-$HOME/Backups}/rental-leads/$(date +%Y%m%d-%H%M)" mkdir -p "$DEST" for p in .agy .claude .codex .grokbot; do if [ -d "$HOME/$p" ]; then cp -r "$HOME/$p" "$DEST/$p" echo "Backed up $HOME/$p" fi done if [ -f "rental_history.db" ]; then cp rental_history.db "$DEST/"; echo "Backed up rental_history.db"; fi if [ -f "rental_leads.csv" ]; then cp rental_leads.csv "$DEST/"; echo "Backed up rental_leads.csv"; fi ls -la "$DEST"
bash (Linux)
DEST="${BACKUP_ROOT:-$HOME/Backups}/rental-leads/$(date +%Y%m%d-%H%M)" mkdir -p "$DEST" for p in .agy .claude .codex .grokbot; do if [ -d "$HOME/$p" ]; then cp -r "$HOME/$p" "$DEST/$p" echo "Backed up $HOME/$p" fi done if [ -f "rental_history.db" ]; then cp rental_history.db "$DEST/"; echo "Backed up rental_history.db"; fi if [ -f "rental_leads.csv" ]; then cp rental_leads.csv "$DEST/"; echo "Backed up rental_leads.csv"; fi ls -la "$DEST"

4. Practised Restore Drill

Never trust an unverified backup. Run this safe, self-contained restore drill inside your temp folder:

PowerShell
$drill = Join-Path $env:TEMP "bbb-restore-drill"; $bak = Join-Path $drill "backup"; $restored = Join-Path $drill "restored"; New-Item -ItemType Directory -Force -Path $bak,$restored | Out-Null; Set-Content -Encoding ascii -Path (Join-Path $bak "proof.txt") -Value "bbb-restore-proof"; Copy-Item (Join-Path $bak "proof.txt") (Join-Path $restored "proof.txt"); Get-Content (Join-Path $restored "proof.txt"); Write-Host "Practised restore wrote proof into $restored. Do not delete your user profile to test this."
zsh (macOS)
drill="${TMPDIR:-/tmp}/bbb-restore-drill"; bak="$drill/backup"; restored="$drill/restored"; mkdir -p "$bak" "$restored"; printf "%s\n" "bbb-restore-proof" > "$bak/proof.txt"; cp "$bak/proof.txt" "$restored/proof.txt"; cat "$restored/proof.txt"; echo "Practised restore wrote proof into $restored. Do not delete your home folder to test this."
bash (Linux)
drill="${TMPDIR:-/tmp}/bbb-restore-drill"; bak="$drill/backup"; restored="$drill/restored"; mkdir -p "$bak" "$restored"; printf "%s\n" "bbb-restore-proof" > "$bak/proof.txt"; cp "$bak/proof.txt" "$restored/proof.txt"; cat "$restored/proof.txt"; echo "Practised restore wrote proof into $restored. Do not delete your home folder to test this."

5. Fail-Closed Workspace Policy

Never run automated property comp aggregators with blanket filesystem permissions from the user profile. Always lock output targets to dedicated project directories.

Checkpoint: Confirm Build Bot Better Protection Required

Paste terminal output from the restore drill or variable demo confirming protection: