From 427ad6a5a1ca5c9739ef4191e62415c9c20abb61 Mon Sep 17 00:00:00 2001 From: Zihao Li Date: Thu, 14 May 2026 22:28:23 -0500 Subject: [PATCH 1/6] adding carmax phase 1 --- Dockerfile | 4 +- control_server.py | 2 +- sites/carmax/PHASE_1_SUMMARY.md | 267 +++ sites/carmax/_bash_test.txt | 1 + sites/carmax/_bashwrite_test.py | 5 + sites/carmax/_health.py | 3 + sites/carmax/app.py | 1990 +++++++++++++++++ sites/carmax/instance/.gitkeep | 0 sites/carmax/requirements.txt | 12 + sites/carmax/scrape_carmax.py | 205 ++ sites/carmax/scraped_data/.gitkeep | 0 sites/carmax/scraped_data/recon_notes.md | 140 ++ sites/carmax/seed_data.py | 900 ++++++++ sites/carmax/static/css/.gitkeep | 0 sites/carmax/static/css/main.css | 221 ++ sites/carmax/static/icons/.gitkeep | 0 sites/carmax/static/js/.gitkeep | 0 sites/carmax/templates/.gitkeep | 0 sites/carmax/templates/404.html | 9 + sites/carmax/templates/500.html | 9 + sites/carmax/templates/_macros.html | 34 + sites/carmax/templates/account.html | 55 + .../carmax/templates/account_appraisals.html | 25 + .../templates/account_change_password.html | 14 + sites/carmax/templates/account_edit.html | 24 + sites/carmax/templates/account_orders.html | 25 + .../templates/account_reservations.html | 31 + .../carmax/templates/account_test_drives.html | 31 + sites/carmax/templates/article_detail.html | 28 + sites/carmax/templates/articles_index.html | 28 + sites/carmax/templates/base.html | 96 + sites/carmax/templates/checkout.html | 67 + sites/carmax/templates/compare.html | 50 + sites/carmax/templates/faq.html | 19 + sites/carmax/templates/faq_category.html | 15 + sites/carmax/templates/faq_detail.html | 10 + sites/carmax/templates/financing.html | 27 + sites/carmax/templates/index.html | 111 + sites/carmax/templates/login.html | 16 + sites/carmax/templates/maxcare.html | 24 + .../carmax/templates/order_confirmation.html | 44 + sites/carmax/templates/pre_qual_form.html | 26 + sites/carmax/templates/pre_qual_result.html | 19 + sites/carmax/templates/register.html | 24 + sites/carmax/templates/research_index.html | 25 + sites/carmax/templates/research_make.html | 16 + sites/carmax/templates/research_model.html | 21 + .../carmax/templates/research_model_year.html | 51 + sites/carmax/templates/reserve.html | 17 + sites/carmax/templates/reviews.html | 32 + sites/carmax/templates/saved.html | 15 + sites/carmax/templates/search.html | 113 + sites/carmax/templates/sell_my_car.html | 45 + sites/carmax/templates/sell_offer.html | 31 + sites/carmax/templates/store_detail.html | 37 + sites/carmax/templates/stores.html | 15 + sites/carmax/templates/stores_state.html | 18 + sites/carmax/templates/test_drive.html | 21 + sites/carmax/templates/value.html | 18 + sites/carmax/templates/value_model.html | 22 + sites/carmax/templates/value_year.html | 17 + sites/carmax/templates/vehicle_detail.html | 144 ++ websyn_start.sh | 8 +- 63 files changed, 5270 insertions(+), 7 deletions(-) create mode 100644 sites/carmax/PHASE_1_SUMMARY.md create mode 100644 sites/carmax/_bash_test.txt create mode 100644 sites/carmax/_bashwrite_test.py create mode 100644 sites/carmax/_health.py create mode 100644 sites/carmax/app.py create mode 100644 sites/carmax/instance/.gitkeep create mode 100644 sites/carmax/requirements.txt create mode 100644 sites/carmax/scrape_carmax.py create mode 100644 sites/carmax/scraped_data/.gitkeep create mode 100644 sites/carmax/scraped_data/recon_notes.md create mode 100644 sites/carmax/seed_data.py create mode 100644 sites/carmax/static/css/.gitkeep create mode 100644 sites/carmax/static/css/main.css create mode 100644 sites/carmax/static/icons/.gitkeep create mode 100644 sites/carmax/static/js/.gitkeep create mode 100644 sites/carmax/templates/.gitkeep create mode 100644 sites/carmax/templates/404.html create mode 100644 sites/carmax/templates/500.html create mode 100644 sites/carmax/templates/_macros.html create mode 100644 sites/carmax/templates/account.html create mode 100644 sites/carmax/templates/account_appraisals.html create mode 100644 sites/carmax/templates/account_change_password.html create mode 100644 sites/carmax/templates/account_edit.html create mode 100644 sites/carmax/templates/account_orders.html create mode 100644 sites/carmax/templates/account_reservations.html create mode 100644 sites/carmax/templates/account_test_drives.html create mode 100644 sites/carmax/templates/article_detail.html create mode 100644 sites/carmax/templates/articles_index.html create mode 100644 sites/carmax/templates/base.html create mode 100644 sites/carmax/templates/checkout.html create mode 100644 sites/carmax/templates/compare.html create mode 100644 sites/carmax/templates/faq.html create mode 100644 sites/carmax/templates/faq_category.html create mode 100644 sites/carmax/templates/faq_detail.html create mode 100644 sites/carmax/templates/financing.html create mode 100644 sites/carmax/templates/index.html create mode 100644 sites/carmax/templates/login.html create mode 100644 sites/carmax/templates/maxcare.html create mode 100644 sites/carmax/templates/order_confirmation.html create mode 100644 sites/carmax/templates/pre_qual_form.html create mode 100644 sites/carmax/templates/pre_qual_result.html create mode 100644 sites/carmax/templates/register.html create mode 100644 sites/carmax/templates/research_index.html create mode 100644 sites/carmax/templates/research_make.html create mode 100644 sites/carmax/templates/research_model.html create mode 100644 sites/carmax/templates/research_model_year.html create mode 100644 sites/carmax/templates/reserve.html create mode 100644 sites/carmax/templates/reviews.html create mode 100644 sites/carmax/templates/saved.html create mode 100644 sites/carmax/templates/search.html create mode 100644 sites/carmax/templates/sell_my_car.html create mode 100644 sites/carmax/templates/sell_offer.html create mode 100644 sites/carmax/templates/store_detail.html create mode 100644 sites/carmax/templates/stores.html create mode 100644 sites/carmax/templates/stores_state.html create mode 100644 sites/carmax/templates/test_drive.html create mode 100644 sites/carmax/templates/value.html create mode 100644 sites/carmax/templates/value_model.html create mode 100644 sites/carmax/templates/value_year.html create mode 100644 sites/carmax/templates/vehicle_detail.html diff --git a/Dockerfile b/Dockerfile index 991e5ab..1e86b1d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # WebHarbor — slim, self-contained image. -# 15 Flask mirror sites + control plane on :8101. +# 16 Flask mirror sites + control plane on :8101. FROM python:3.12-slim-bookworm @@ -33,6 +33,6 @@ COPY control_server.py /opt/control_server.py COPY site_runner.py /opt/site_runner.py RUN chmod +x /opt/websyn_start.sh -EXPOSE 8101 40000-40014 +EXPOSE 8101 40000-40015 CMD ["/opt/websyn_start.sh"] diff --git a/control_server.py b/control_server.py index c255253..f86e426 100644 --- a/control_server.py +++ b/control_server.py @@ -26,7 +26,7 @@ 'allrecipes', 'amazon', 'apple', 'arxiv', 'bbc_news', 'booking', 'github', 'google_flights', 'google_map', 'google_search', 'huggingface', 'wolfram_alpha', 'cambridge_dictionary', - 'coursera', 'espn', + 'coursera', 'espn', 'carmax', ] BASE_PORT = 40000 WEBSYN_DIR = '/opt/WebSyn' diff --git a/sites/carmax/PHASE_1_SUMMARY.md b/sites/carmax/PHASE_1_SUMMARY.md new file mode 100644 index 0000000..74f163f --- /dev/null +++ b/sites/carmax/PHASE_1_SUMMARY.md @@ -0,0 +1,267 @@ +# CarMax mirror — Phase 1 Summary + +Phase 1 of the WebHarbor contribution pipeline for `carmax.com`. Code is +complete; the remaining boot-and-freeze + Docker verification must run +on your local Windows host (sandbox limits prevent it from this side). + +## Files added / modified + +### Code (new) +| File | Lines | Purpose | +|---|---|---| +| `sites/carmax/app.py` | 1990 | Flask app: 13 models, 10 forms, 59 routes, search/research/sell/finance/checkout | +| `sites/carmax/seed_data.py` | 900 | Idempotent seed: 12 stores, ~155 vehicles, 5 users, 20 reviews, 10 articles | +| `sites/carmax/scrape_carmax.py` | 205 | Playwright recipe for harvesting real evox images from carmax.com | +| `sites/carmax/requirements.txt` | 11 | Pinned deps matching the Dockerfile | +| `sites/carmax/templates/` | 1519 (44 files) | base + macros + 42 page templates | +| `sites/carmax/static/css/main.css` | 221 | CarMax brand: navy `#1660a8` + yellow `#FFD900` | +| `sites/carmax/static/images/_pending.svg` | tiny | onerror fallback for missing vehicle photos | +| `sites/carmax/scraped_data/recon_notes.md` | 130 | URL/feature/visual recon captured via WebFetch+WebSearch | + +### Code (modified) +| File | Change | +|---|---| +| `websyn_start.sh` | Added `carmax` to `SITES`; switched the three hardcoded `15`s to `${#SITES[@]}` | +| `control_server.py` | Added `'carmax'` to `SITES` list | +| `Dockerfile` | `EXPOSE 8101 40000-40014` → `40000-40015`; comment "15 sites" → "16 sites" | + +## Seeded row counts per major model + +When `seed_database()` + `seed_benchmark_users()` run from an empty DB: + +| Model | Rows | +|---|---| +| Store | 12 (real CarMax addresses across CA/TX/FL/GA/NY/IL/VA/AZ/CO/NC/WA/MA) | +| Vehicle | ~155 (31 model templates × ~5 variants — actual count = `len(_build_vehicle_seeds())`) | +| Article | 10 | +| Review | 20 | +| User | 5 (`alice.j` `bob.k` `carol.l` `dan.m` `emma.n` @test.com) | +| FinancePreQual | 4 (Dan has no pre-qual) | +| SavedVehicle | 6 | +| Reservation | 1 | +| TestDrive | 2 | +| Appraisal | 3 | +| Order | 1 (Dan's ready-for-pickup vehicle #37) | + +All benchmark users share the same password **`CarMax!2026`** (bcrypt hash +hardcoded for deterministic md5). + +## Byte-identical reset — status + +**Not yet verified.** Requires the boot-and-freeze cycle on your Windows +host (sandbox has neither `pip install Flask` access nor Docker). See +**Verification procedure** below. + +The seed functions are coded for byte-identity: +- Both `seed_database()` and `seed_benchmark_users()` early-return on + populated DB (function-level gate, not row-level). +- All inserted rows use `SEED_NOW = datetime(2026, 1, 15, 12, 0, 0)` + instead of `datetime.utcnow()`. +- Vehicle records are produced by deterministic `_build_vehicle_seeds()` + iterating templates × trims × years; stock numbers and VINs are + derived from those indices. +- Bcrypt password hash for benchmark users is pinned (no random salt). + +## Verification procedure (run on your Windows host) + +### Step 1 — Finish extracting the HF asset tarballs + +Earlier `bash scripts/fetch_assets.sh` downloaded all 15 tarballs but +crashed on the GBK-encoded `✓` after the download. Re-run just the +extract loop: + +```bash +cd /e/GitHub/WebHarbor +for tarball in sites/.cache/tarballs/*.tar.gz; do + site=$(basename "$tarball" .tar.gz) + echo "extracting $site..." + tar --skip-old-files -xzf "$tarball" -C sites/ +done +ls sites/booking/static/images/ | head -3 # verify it actually expanded +``` + +### Step 2 — Harvest CarMax images (one-time) + +```bash +cd /e/GitHub/WebHarbor +pip install playwright httpx +python -m playwright install chromium + +# scrape ~600-900 vehicle photos into sites/carmax/static/images/vehicles/ +python sites/carmax/scrape_carmax.py +ls sites/carmax/static/images/vehicles/ | wc -l # should print >300 +``` + +Why this step: the seeded DB references local paths like +`/static/images/vehicles/-front.jpg`. The scraper grabs the real +evox stock photos from `content-images.carmax.com` (the same CDN the +live site uses) and writes them to those filenames. + +### Step 3 — Boot the app once locally to build the seed DB + +```bash +cd /e/GitHub/WebHarbor/sites/carmax +pip install -r requirements.txt +PORT=5099 python app.py & +sleep 4 +curl -s http://localhost:5099/_health | head # verify {ok: true, vehicles: 15x, ...} +kill %1 # stop the dev server + +# Promote the freshly-created DB to the seed +cp instance/carmax.db instance_seed/carmax.db + +# Re-boot, confirm seeds early-return and md5 matches +PORT=5099 python app.py & +sleep 4 +kill %1 +md5sum instance/carmax.db instance_seed/carmax.db # MUST match +``` + +If they don't match, the typical culprit is a `created_at` that fell +through to `datetime.utcnow()`; grep `seed_data.py` for any row that +omits a timestamp. + +### Step 4 — Build and verify in Docker + +```bash +cd /e/GitHub/WebHarbor +bash scripts/build.sh webharbor:dev + +docker run -d --rm --name wh-test \ + -p 8201:8101 -p 41000-41015:40000-40015 webharbor:dev +sleep 30 + +# carmax is index 15, so port 41015 +curl -so /dev/null -w "carmax:%{http_code}\n" http://localhost:41015/ +curl -s http://localhost:8201/health | python -m json.tool | head + +# all 16 sites should 200 +for p in $(seq 41000 41015); do + curl -so /dev/null -w "$p:%{http_code}\n" http://localhost:$p/ +done + +# byte-identical reset (the strict invariant) +curl -X POST http://localhost:8201/reset/carmax +docker exec wh-test md5sum \ + /opt/WebSyn/carmax/instance/carmax.db \ + /opt/WebSyn/carmax/instance_seed/carmax.db +# the two md5s MUST match + +docker stop wh-test +``` + +## Anything that needs human review + +1. **Two leftover bash test files** in `sites/carmax/`: + `_bash_test.txt` and `_bashwrite_test.py`. My sandbox mount denies + `rm`. Please delete them manually: + `rm sites/carmax/_bash_test.txt sites/carmax/_bashwrite_test.py` +2. **Images directory**: `static/images/vehicles/` will be empty until + Step 2 (scraper) runs. The app still renders — `_pending.svg` is the + onerror fallback. +3. **Article/store images** referenced in the DB (`/static/images/articles/.jpg`, + `/static/images/stores/storefront_default.jpg`) are not harvested by + the current scraper. The site still works; they just fall back to + the pending SVG. If review feedback insists, extend `scrape_carmax.py` + to grab Contentful URLs from the article hero `` selectors. +4. **`scrape_carmax.py` URL provenance**: the script assumes the + `content-images.carmax.com/stockimages////` + convention I observed in WebFetch results. If a particular + year/make/model combo doesn't have evox photos there, that vehicle + will keep showing the pending SVG. +5. **Slug normalization in scrape_carmax.py vs DB**: the script's + `TEMPLATE_INDEX` hand-writes `'silverado-1500'` etc., but + `seed_data.py` uses just `'Silverado'` → slug `silverado`. After + Step 3 boots, run `grep model_slug sites/carmax/instance/carmax.db` + via sqlite3 or a Python one-liner to confirm the joins land. Fix + `TEMPLATE_INDEX` if a model slug mismatches. + +## Phase 2-5 next steps (after Phase 1 verifies) + +| Phase | Skill | Deliverable | +|---|---|---| +| 2 | `.claude/skills/design-tasks` | `sites/carmax/tasks.jsonl` — 15-20 WebVoyager tasks across search/browse/cart/checkout/account/finance/sell. Schema: `{web_name, id, ques, web, upstream_url}` | +| 3 | `.claude/skills/evolve-env` | Walk each task manually; extend mirror to support; fix info leaks, superficial completion, insufficient distractors | +| 4 | `.claude/skills/harden-env` | Audit against the 4 hardening dimensions + 13 leak archetypes; re-verify byte-identical reset | +| 5 | `.claude/skills/seed-database` | Re-confirm seed_*() idempotency; finalize scored token-overlap search (already done in `app.py:search_vehicles`); freeze the canonical instance_seed/carmax.db | + +## Final PR submission (do these after all 5 phases pass) + +### A. Hugging Face assets PR + +```bash +# 1. Pack the carmax assets into a tarball matching the rest of the dataset +cd /e/GitHub/WebHarbor +bash scripts/extract_assets.sh carmax # produces sites/.cache/tarballs/carmax.tar.gz + +# 2. Upload to the HF dataset on a feature branch +hf auth login # only if you don't have a token cached +hf upload ChilleD/WebHarbor \ + sites/.cache/tarballs/carmax.tar.gz \ + carmax.tar.gz \ + --repo-type dataset \ + --revision add-carmax + +# 3. Open a PR on huggingface.co/datasets/ChilleD/WebHarbor merging +# 'add-carmax' -> 'main' with the description: +# "Add carmax.tar.gz (Phase 1-5 mirror, ~150 vehicles + 12 stores, byte-identical reset verified)" + +# 4. After the HF PR merges, note its revision SHA — you'll bump +# .assets-revision to that SHA on the GitHub side. +``` + +### B. GitHub code PR + +```bash +cd /e/GitHub/WebHarbor + +# 1. Make sure scraped_data/, instance/, __pycache__ are NOT staged +git status # eyeball it +git add sites/carmax/{app.py,seed_data.py,requirements.txt,scrape_carmax.py,_health.py,tasks.jsonl} +git add sites/carmax/templates/ sites/carmax/static/css/ sites/carmax/static/icons/ sites/carmax/static/js/ +git add websyn_start.sh control_server.py Dockerfile +git add sites/carmax/PHASE_1_SUMMARY.md # optional - mostly for review traceability +git status + +# 2. Sanity checks (must all pass) +python3 -m py_compile sites/carmax/app.py +bash scripts/build.sh webharbor:dev +# ...full pre-PR checklist from AGENTS.md... + +# 3. Bump the HF asset pin +# Edit .assets-revision: set 'revision:' to the merged HF PR's commit SHA +git add .assets-revision + +# 4. Commit + push +git commit -m "Add carmax mirror (16th site) + +- 13 SQLAlchemy models (User, Store, Vehicle, SavedVehicle, Comparison, + Reservation, TestDrive, Appraisal, FinancePreQual, Order, Review, + Article + ComparisonItem) +- 59 Flask routes covering search/research/comparison/saved/stores/sell/ + finance/reserve/test-drive/checkout/account/articles/FAQ/MaxCare/auth +- ~155 deterministically-seeded vehicles across 31 templates, + 12 real CarMax store locations +- Token-overlap scored search with multi-field weighting +- Idempotent seed_database + seed_benchmark_users (alice.j@test.com et al.) +- Byte-identical reset verified" +git push origin add-carmax + +# 5. Open the GitHub PR with reference to the merged HF revision. +``` + +### C. Final integration check before requesting review + +```bash +# Pull from your fork as if you were a reviewer: +git checkout main && git pull +bash scripts/fetch_assets.sh # should now pull carmax.tar.gz +bash scripts/build.sh webharbor:dev +docker run -d --rm --name wh-final \ + -p 8101:8101 -p 40000-40015:40000-40015 webharbor:dev +curl -s http://localhost:8101/health | python -m json.tool | head +docker stop wh-final +``` + +That's it — when those steps all green, ping the WebHarbor maintainers +for review. diff --git a/sites/carmax/_bash_test.txt b/sites/carmax/_bash_test.txt new file mode 100644 index 0000000..396b34f --- /dev/null +++ b/sites/carmax/_bash_test.txt @@ -0,0 +1 @@ +from bash diff --git a/sites/carmax/_bashwrite_test.py b/sites/carmax/_bashwrite_test.py new file mode 100644 index 0000000..d14068c --- /dev/null +++ b/sites/carmax/_bashwrite_test.py @@ -0,0 +1,5 @@ +#!/usr/bin/env python3 +"""Test file written by bash.""" +x = 1 +y = 2 +print(x + y) diff --git a/sites/carmax/_health.py b/sites/carmax/_health.py new file mode 100644 index 0000000..0a9ec22 --- /dev/null +++ b/sites/carmax/_health.py @@ -0,0 +1,3 @@ +"""Per-site health probe (optional, called by control_server).""" +def health(): + return {"ok": True, "site": "carmax"} diff --git a/sites/carmax/app.py b/sites/carmax/app.py new file mode 100644 index 0000000..ab1af95 --- /dev/null +++ b/sites/carmax/app.py @@ -0,0 +1,1990 @@ +#!/usr/bin/env python3 +"""CarMax.com mirror — Flask application. + +Full inventory search, vehicle detail, research, comparison, saved cars, +sell-my-car appraisal, financing pre-qualification, reserve & test drive +booking, checkout, MaxCare warranty, stores, articles, FAQ, customer +reviews. +""" +import json +import os +import re +from datetime import date, datetime, timedelta + +from flask import (Flask, abort, flash, jsonify, redirect, render_template, + request, session, url_for) +from flask_bcrypt import Bcrypt +from flask_login import (LoginManager, UserMixin, current_user, login_required, + login_user, logout_user) +from flask_sqlalchemy import SQLAlchemy +from flask_wtf import FlaskForm +from flask_wtf.csrf import CSRFProtect, generate_csrf +from sqlalchemy import func +from wtforms import (BooleanField, FloatField, HiddenField, IntegerField, + PasswordField, RadioField, SelectField, StringField, + TextAreaField) +from wtforms.validators import (DataRequired, Email, EqualTo, Length, + NumberRange, Optional, Regexp) + +BASE_DIR = os.path.dirname(os.path.abspath(__file__)) + +app = Flask(__name__) +app.config['SECRET_KEY'] = 'carmax-mirror-webharbor-key' +app.config['SQLALCHEMY_DATABASE_URI'] = ( + f"sqlite:///{os.path.join(BASE_DIR, 'instance', 'carmax.db')}" +) +app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False +app.config['WTF_CSRF_TIME_LIMIT'] = None +app.config['MAX_CONTENT_LENGTH'] = 8 * 1024 * 1024 + +os.makedirs(os.path.join(BASE_DIR, 'instance'), exist_ok=True) + +db = SQLAlchemy(app) +bcrypt = Bcrypt(app) +login_manager = LoginManager(app) +login_manager.login_view = 'login' +login_manager.login_message = 'Please sign in to access your CarMax account.' +login_manager.login_message_category = 'info' +csrf = CSRFProtect(app) + + +# ============================================================================= +# Models +# ============================================================================= + +class User(db.Model, UserMixin): + __tablename__ = 'users' + id = db.Column(db.Integer, primary_key=True) + email = db.Column(db.String(120), unique=True, nullable=False, index=True) + password_hash = db.Column(db.String(255), nullable=False) + first_name = db.Column(db.String(80), default='') + last_name = db.Column(db.String(80), default='') + phone = db.Column(db.String(30), default='') + zip_code = db.Column(db.String(10), default='') + address_line1 = db.Column(db.String(200), default='') + address_line2 = db.Column(db.String(200), default='') + city = db.Column(db.String(80), default='') + state = db.Column(db.String(2), default='') + home_store_id = db.Column(db.Integer, db.ForeignKey('stores.id'), nullable=True) + + pre_qual_active = db.Column(db.Boolean, default=False) + pre_qual_monthly_max = db.Column(db.Float, default=0.0) + pre_qual_term_months = db.Column(db.Integer, default=72) + pre_qual_apr = db.Column(db.Float, default=0.0) + pre_qual_down_payment = db.Column(db.Float, default=2000.0) + pre_qual_credit_tier = db.Column(db.String(20), default='') + pre_qual_expires_at = db.Column(db.Date, nullable=True) + + annual_income = db.Column(db.Integer, default=0) + employment_status = db.Column(db.String(40), default='') + + created_at = db.Column(db.DateTime, default=datetime.utcnow) + + saved_vehicles = db.relationship('SavedVehicle', backref='user', lazy=True, + cascade='all, delete-orphan') + reservations = db.relationship('Reservation', backref='user', lazy=True, + cascade='all, delete-orphan') + test_drives = db.relationship('TestDrive', backref='user', lazy=True, + cascade='all, delete-orphan') + appraisals = db.relationship('Appraisal', backref='user', lazy=True, + cascade='all, delete-orphan') + orders = db.relationship('Order', backref='user', lazy=True, + cascade='all, delete-orphan', + foreign_keys='Order.user_id') + reviews = db.relationship('Review', backref='user', lazy=True, + cascade='all, delete-orphan') + comparisons = db.relationship('Comparison', backref='user', lazy=True, + cascade='all, delete-orphan') + + def set_password(self, pw): + self.password_hash = bcrypt.generate_password_hash(pw).decode('utf-8') + + def check_password(self, pw): + try: + return bcrypt.check_password_hash(self.password_hash, pw) + except Exception: + return False + + @property + def full_name(self): + n = f'{self.first_name} {self.last_name}'.strip() + return n or (self.email.split('@')[0] if self.email else 'CarMax customer') + + @property + def pre_qual_is_valid(self): + return bool(self.pre_qual_active and self.pre_qual_expires_at + and self.pre_qual_expires_at >= date(2026, 5, 14)) + + +class Store(db.Model): + __tablename__ = 'stores' + id = db.Column(db.Integer, primary_key=True) + slug = db.Column(db.String(80), unique=True, nullable=False, index=True) + name = db.Column(db.String(120), nullable=False) + street = db.Column(db.String(200), default='') + city = db.Column(db.String(80), nullable=False, index=True) + state = db.Column(db.String(2), nullable=False, index=True) + zip_code = db.Column(db.String(10), default='') + phone = db.Column(db.String(30), default='') + hours_weekday = db.Column(db.String(40), default='10:00 AM - 9:00 PM') + hours_saturday = db.Column(db.String(40), default='9:00 AM - 9:00 PM') + hours_sunday = db.Column(db.String(40), default='12:00 PM - 7:00 PM') + has_appraisal = db.Column(db.Boolean, default=True) + has_express_pickup = db.Column(db.Boolean, default=True) + has_service = db.Column(db.Boolean, default=True) + has_home_delivery = db.Column(db.Boolean, default=True) + latitude = db.Column(db.Float, default=0.0) + longitude = db.Column(db.Float, default=0.0) + image = db.Column(db.String(255), default='/static/images/stores/storefront_default.jpg') + + vehicles = db.relationship('Vehicle', backref='store', lazy=True) + + @property + def location_label(self): + return f'{self.city}, {self.state}' + + +class Vehicle(db.Model): + __tablename__ = 'vehicles' + id = db.Column(db.Integer, primary_key=True) + stock_number = db.Column(db.String(20), unique=True, nullable=False, index=True) + slug = db.Column(db.String(200), unique=True, nullable=False, index=True) + vin = db.Column(db.String(20), default='') + + year = db.Column(db.Integer, nullable=False, index=True) + make = db.Column(db.String(40), nullable=False, index=True) + make_slug = db.Column(db.String(40), nullable=False, index=True) + model = db.Column(db.String(60), nullable=False, index=True) + model_slug = db.Column(db.String(60), nullable=False, index=True) + trim = db.Column(db.String(60), default='', index=True) + trim_slug = db.Column(db.String(60), default='', index=True) + body_style = db.Column(db.String(30), default='Sedan', index=True) + + exterior_color = db.Column(db.String(40), default='') + interior_color = db.Column(db.String(40), default='') + + mileage = db.Column(db.Integer, nullable=False) + price = db.Column(db.Float, nullable=False, index=True) + list_price = db.Column(db.Float, default=0.0) + + engine_text = db.Column(db.String(80), default='') + engine_displacement = db.Column(db.Float, default=0.0) + horsepower = db.Column(db.Integer, default=0) + torque = db.Column(db.Integer, default=0) + transmission = db.Column(db.String(30), default='Automatic') + drive_type = db.Column(db.String(10), default='FWD') + fuel_type = db.Column(db.String(30), default='Gasoline', index=True) + + mpg_city = db.Column(db.Integer, default=0) + mpg_highway = db.Column(db.Integer, default=0) + mpg_combined = db.Column(db.Integer, default=0) + + seating_capacity = db.Column(db.Integer, default=5) + cargo_volume = db.Column(db.Float, default=0.0) + wheelbase = db.Column(db.Float, default=0.0) + overall_length = db.Column(db.Float, default=0.0) + width = db.Column(db.Float, default=0.0) + height = db.Column(db.Float, default=0.0) + fuel_capacity = db.Column(db.Float, default=0.0) + + features = db.Column(db.Text, default='[]') + description = db.Column(db.Text, default='') + + image = db.Column(db.String(255), default='') + gallery_images = db.Column(db.Text, default='[]') + + customer_rating = db.Column(db.Float, default=4.4) + customer_rating_count = db.Column(db.Integer, default=0) + repairpal_rating = db.Column(db.Float, default=4.0) + + is_certified = db.Column(db.Boolean, default=True) + is_featured = db.Column(db.Boolean, default=False) + is_no_haggle = db.Column(db.Boolean, default=True) + is_new_arrival = db.Column(db.Boolean, default=False) + is_price_drop = db.Column(db.Boolean, default=False) + + store_id = db.Column(db.Integer, db.ForeignKey('stores.id'), nullable=False) + transfer_fee = db.Column(db.Float, default=0.0) + + days_on_lot = db.Column(db.Integer, default=14) + added_at = db.Column(db.DateTime, default=datetime.utcnow) + + @property + def title(self): + parts = [str(self.year), self.make, self.model] + if self.trim: + parts.append(self.trim) + return ' '.join(parts) + + @property + def short_title(self): + return f'{self.year} {self.make} {self.model}' + + @property + def headline_price(self): + return f'${int(self.price):,}' + + @property + def mileage_label(self): + return f'{self.mileage:,} mi' + + def get_features(self): + try: + return json.loads(self.features or '[]') + except Exception: + return [] + + def get_gallery(self): + try: + paths = json.loads(self.gallery_images or '[]') + return [p for p in paths if p] + except Exception: + return [] + + def all_images(self): + gallery = self.get_gallery() + if self.image and self.image not in gallery: + return [self.image] + gallery + return gallery or ([self.image] if self.image else []) + + def has_feature(self, feat): + f = feat.lower() + return any(f == g.lower() for g in self.get_features()) + + def estimated_monthly_payment(self, term_months=72, apr=0.0699, down=2000): + return estimated_payment(self.price, term_months, apr, down) + + def savings(self): + if self.list_price and self.list_price > self.price: + return self.list_price - self.price + return 0.0 + + +class SavedVehicle(db.Model): + __tablename__ = 'saved_vehicles' + __table_args__ = (db.UniqueConstraint('user_id', 'vehicle_id', + name='uq_saved_user_vehicle'),) + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) + vehicle_id = db.Column(db.Integer, db.ForeignKey('vehicles.id'), nullable=False) + saved_at = db.Column(db.DateTime, default=datetime.utcnow) + note = db.Column(db.String(200), default='') + + vehicle = db.relationship('Vehicle', lazy='joined') + + +class Comparison(db.Model): + __tablename__ = 'comparisons' + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True) + session_key = db.Column(db.String(64), default='', index=True) + name = db.Column(db.String(80), default='My comparison') + created_at = db.Column(db.DateTime, default=datetime.utcnow) + items = db.relationship('ComparisonItem', backref='comparison', lazy=True, + cascade='all, delete-orphan') + + +class ComparisonItem(db.Model): + __tablename__ = 'comparison_items' + __table_args__ = (db.UniqueConstraint('comparison_id', 'vehicle_id', + name='uq_compare_item'),) + id = db.Column(db.Integer, primary_key=True) + comparison_id = db.Column(db.Integer, db.ForeignKey('comparisons.id'), nullable=False) + vehicle_id = db.Column(db.Integer, db.ForeignKey('vehicles.id'), nullable=False) + added_at = db.Column(db.DateTime, default=datetime.utcnow) + vehicle = db.relationship('Vehicle', lazy='joined') + + +class Reservation(db.Model): + __tablename__ = 'reservations' + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) + vehicle_id = db.Column(db.Integer, db.ForeignKey('vehicles.id'), nullable=False) + store_id = db.Column(db.Integer, db.ForeignKey('stores.id'), nullable=False) + status = db.Column(db.String(20), default='active') + appointment_date = db.Column(db.Date, nullable=True) + expires_at = db.Column(db.Date, nullable=False) + transfer_required = db.Column(db.Boolean, default=False) + transfer_fee = db.Column(db.Float, default=0.0) + created_at = db.Column(db.DateTime, default=datetime.utcnow) + vehicle = db.relationship('Vehicle', lazy='joined') + store = db.relationship('Store', lazy='joined') + + +class TestDrive(db.Model): + __tablename__ = 'test_drives' + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) + vehicle_id = db.Column(db.Integer, db.ForeignKey('vehicles.id'), nullable=False) + store_id = db.Column(db.Integer, db.ForeignKey('stores.id'), nullable=False) + location_type = db.Column(db.String(20), default='in_store') + scheduled_date = db.Column(db.Date, nullable=False) + scheduled_time = db.Column(db.String(10), default='10:00 AM') + status = db.Column(db.String(20), default='confirmed') + notes = db.Column(db.String(500), default='') + created_at = db.Column(db.DateTime, default=datetime.utcnow) + vehicle = db.relationship('Vehicle', lazy='joined') + store = db.relationship('Store', lazy='joined') + + +class Appraisal(db.Model): + __tablename__ = 'appraisals' + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True) + year = db.Column(db.Integer, nullable=False) + make = db.Column(db.String(40), nullable=False) + model = db.Column(db.String(60), nullable=False) + trim = db.Column(db.String(60), default='') + mileage = db.Column(db.Integer, nullable=False) + condition = db.Column(db.String(20), default='good') + exterior_color = db.Column(db.String(40), default='') + license_plate = db.Column(db.String(20), default='') + license_state = db.Column(db.String(2), default='') + vin = db.Column(db.String(20), default='') + zip_code = db.Column(db.String(10), default='') + has_accidents = db.Column(db.Boolean, default=False) + owner_count = db.Column(db.Integer, default=1) + offer_amount = db.Column(db.Float, nullable=False) + offer_valid_until = db.Column(db.Date, nullable=False) + status = db.Column(db.String(20), default='active') + contact_email = db.Column(db.String(120), default='') + contact_phone = db.Column(db.String(30), default='') + created_at = db.Column(db.DateTime, default=datetime.utcnow) + + @property + def vehicle_label(self): + parts = [str(self.year), self.make, self.model] + if self.trim: + parts.append(self.trim) + return ' '.join(parts) + + +class FinancePreQual(db.Model): + __tablename__ = 'finance_prequals' + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) + annual_income = db.Column(db.Integer, nullable=False) + employment_status = db.Column(db.String(40), nullable=False) + monthly_payment_max = db.Column(db.Float, nullable=False) + down_payment = db.Column(db.Float, default=2000) + term_months = db.Column(db.Integer, default=72) + estimated_apr = db.Column(db.Float, nullable=False) + credit_tier = db.Column(db.String(20), default='good') + status = db.Column(db.String(20), default='active') + created_at = db.Column(db.DateTime, default=datetime.utcnow) + expires_at = db.Column(db.Date, nullable=False) + + +class Order(db.Model): + __tablename__ = 'orders' + id = db.Column(db.Integer, primary_key=True) + order_number = db.Column(db.String(20), unique=True, nullable=False) + user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) + status = db.Column(db.String(20), default='processing') + vehicle_id = db.Column(db.Integer, db.ForeignKey('vehicles.id'), nullable=False) + store_id = db.Column(db.Integer, db.ForeignKey('stores.id'), nullable=False) + subtotal = db.Column(db.Float, default=0) + transfer_fee = db.Column(db.Float, default=0) + tax = db.Column(db.Float, default=0) + title_fee = db.Column(db.Float, default=99) + registration_fee = db.Column(db.Float, default=55) + total = db.Column(db.Float, default=0) + maxcare_plan = db.Column(db.String(40), default='') + maxcare_price = db.Column(db.Float, default=0) + payment_method = db.Column(db.String(30), default='carmax_auto_finance') + payment_last4 = db.Column(db.String(4), default='') + payment_apr = db.Column(db.Float, default=0) + payment_term_months = db.Column(db.Integer, default=72) + monthly_payment = db.Column(db.Float, default=0) + down_payment = db.Column(db.Float, default=0) + trade_in_appraisal_id = db.Column(db.Integer, db.ForeignKey('appraisals.id'), nullable=True) + trade_in_value = db.Column(db.Float, default=0) + pickup_or_delivery = db.Column(db.String(20), default='pickup') + delivery_address = db.Column(db.String(200), default='') + pickup_date = db.Column(db.Date, nullable=True) + created_at = db.Column(db.DateTime, default=datetime.utcnow) + vehicle = db.relationship('Vehicle', lazy='joined') + store = db.relationship('Store', lazy='joined') + trade_in_appraisal = db.relationship('Appraisal', lazy='joined', + foreign_keys=[trade_in_appraisal_id]) + + +class Review(db.Model): + __tablename__ = 'reviews' + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True) + make_slug = db.Column(db.String(40), nullable=False, index=True) + model_slug = db.Column(db.String(60), nullable=False, index=True) + year = db.Column(db.Integer, nullable=False, index=True) + rating = db.Column(db.Integer, nullable=False) + title = db.Column(db.String(120), default='') + body = db.Column(db.Text, default='') + reviewer_name = db.Column(db.String(80), default='Verified buyer') + location = db.Column(db.String(80), default='') + helpful_count = db.Column(db.Integer, default=0) + created_at = db.Column(db.DateTime, default=datetime.utcnow) + + +class Article(db.Model): + __tablename__ = 'articles' + id = db.Column(db.Integer, primary_key=True) + slug = db.Column(db.String(140), unique=True, nullable=False, index=True) + title = db.Column(db.String(200), nullable=False) + category = db.Column(db.String(40), default='research', index=True) + author = db.Column(db.String(80), default='CarMax Editorial') + summary = db.Column(db.String(500), default='') + body = db.Column(db.Text, default='') + hero_image = db.Column(db.String(255), default='') + published_at = db.Column(db.Date, nullable=False) + is_featured = db.Column(db.Boolean, default=False) + + +@login_manager.user_loader +def load_user(uid): + try: + return db.session.get(User, int(uid)) + except Exception: + return None + + +# ============================================================================= +# Forms +# ============================================================================= + +class LoginForm(FlaskForm): + email = StringField('Email', validators=[DataRequired(), Email()]) + password = PasswordField('Password', validators=[DataRequired()]) + remember = BooleanField('Keep me signed in') + + +class RegisterForm(FlaskForm): + first_name = StringField('First name', validators=[DataRequired(), Length(max=80)]) + last_name = StringField('Last name', validators=[DataRequired(), Length(max=80)]) + email = StringField('Email', validators=[DataRequired(), Email()]) + phone = StringField('Phone', validators=[Optional(), Length(max=30)]) + zip_code = StringField('ZIP code', validators=[Optional(), Length(max=10)]) + password = PasswordField('Password', validators=[DataRequired(), Length(min=8)]) + confirm = PasswordField('Confirm password', + validators=[DataRequired(), EqualTo('password')]) + + +class AccountEditForm(FlaskForm): + first_name = StringField('First name', validators=[DataRequired(), Length(max=80)]) + last_name = StringField('Last name', validators=[DataRequired(), Length(max=80)]) + phone = StringField('Phone', validators=[Optional(), Length(max=30)]) + address_line1 = StringField('Street address', validators=[Optional(), Length(max=200)]) + address_line2 = StringField('Apt / Unit', validators=[Optional(), Length(max=200)]) + city = StringField('City', validators=[Optional(), Length(max=80)]) + state = StringField('State', validators=[Optional(), Length(max=2)]) + zip_code = StringField('ZIP', validators=[Optional(), Length(max=10)]) + + +class ChangePasswordForm(FlaskForm): + current_password = PasswordField('Current password', validators=[DataRequired()]) + new_password = PasswordField('New password', + validators=[DataRequired(), Length(min=8)]) + confirm = PasswordField('Confirm new password', + validators=[DataRequired(), EqualTo('new_password')]) + + +class SellMyCarForm(FlaskForm): + year = IntegerField('Year', validators=[DataRequired(), NumberRange(min=1985, max=2027)]) + make = StringField('Make', validators=[DataRequired(), Length(max=40)]) + model = StringField('Model', validators=[DataRequired(), Length(max=60)]) + trim = StringField('Trim', validators=[Optional(), Length(max=60)]) + mileage = IntegerField('Mileage', + validators=[DataRequired(), NumberRange(min=0, max=400000)]) + condition = SelectField('Condition', choices=[ + ('excellent', 'Excellent'), ('good', 'Good'), + ('fair', 'Fair'), ('poor', 'Poor'), + ], default='good') + exterior_color = StringField('Exterior color', validators=[Optional(), Length(max=40)]) + license_plate = StringField('License plate', validators=[Optional(), Length(max=20)]) + license_state = StringField('License state', validators=[Optional(), Length(max=2)]) + vin = StringField('VIN', validators=[Optional(), Length(max=20)]) + zip_code = StringField('ZIP', validators=[DataRequired(), Length(max=10)]) + has_accidents = BooleanField('Reported accidents') + owner_count = IntegerField('Number of owners', + validators=[Optional(), NumberRange(min=1, max=10)], + default=1) + contact_email = StringField('Email', validators=[Optional(), Email()]) + contact_phone = StringField('Phone', validators=[Optional(), Length(max=30)]) + + +class PreQualForm(FlaskForm): + annual_income = IntegerField('Annual income (pre-tax)', + validators=[DataRequired(), NumberRange(min=0, max=10000000)]) + employment_status = SelectField('Employment status', choices=[ + ('employed_full_time', 'Employed (full-time)'), + ('employed_part_time', 'Employed (part-time)'), + ('self_employed', 'Self-employed'), + ('retired', 'Retired'), + ('student', 'Student'), + ('other', 'Other'), + ], default='employed_full_time') + monthly_payment_max = FloatField('Max monthly payment ($)', + validators=[DataRequired(), NumberRange(min=100, max=5000)]) + down_payment = FloatField('Down payment ($)', + validators=[Optional(), NumberRange(min=0)], default=2000) + term_months = SelectField('Loan term', choices=[ + ('36', '36 months'), ('48', '48 months'), ('60', '60 months'), + ('66', '66 months'), ('72', '72 months'), + ], default='72') + credit_tier = SelectField('Credit profile', choices=[ + ('excellent', 'Excellent (720+)'), + ('good', 'Good (660-719)'), + ('fair', 'Fair (620-659)'), + ('building', 'Building credit (<620)'), + ], default='good') + + +class ReserveForm(FlaskForm): + store_id = HiddenField() + appointment_date = StringField('Appointment date', validators=[Optional()]) + + +class TestDriveForm(FlaskForm): + store_id = HiddenField() + location_type = SelectField('Where', choices=[ + ('in_store', 'At a CarMax store'), + ('at_home', 'At my address'), + ], default='in_store') + scheduled_date = StringField('Date (YYYY-MM-DD)', validators=[DataRequired()]) + scheduled_time = SelectField('Time', choices=[ + ('10:00 AM', '10:00 AM'), ('12:00 PM', '12:00 PM'), + ('2:00 PM', '2:00 PM'), ('4:00 PM', '4:00 PM'), + ('6:00 PM', '6:00 PM'), + ], default='10:00 AM') + notes = TextAreaField('Notes', validators=[Optional(), Length(max=500)]) + + +class CheckoutForm(FlaskForm): + pickup_or_delivery = RadioField('Receive your car', choices=[ + ('pickup', 'Pick up at store'), + ('home_delivery', 'Home delivery'), + ], default='pickup') + delivery_address = StringField('Delivery address', + validators=[Optional(), Length(max=200)]) + payment_method = RadioField('Payment method', choices=[ + ('carmax_auto_finance', 'CarMax Auto Finance'), + ('external_loan', 'External lender'), + ('cash', 'Cash / cashiers check'), + ], default='carmax_auto_finance') + card_last4 = StringField('Card / loan last 4', + validators=[Optional(), Length(min=4, max=4), + Regexp(r'^\d{4}$', message='4 digits')]) + apr = FloatField('APR (%)', validators=[Optional(), NumberRange(min=0, max=29.99)], + default=6.99) + term_months = SelectField('Term', choices=[ + ('36', '36 months'), ('48', '48 months'), ('60', '60 months'), + ('66', '66 months'), ('72', '72 months'), + ], default='72') + down_payment = FloatField('Down payment ($)', + validators=[Optional(), NumberRange(min=0)], default=2000) + trade_in_appraisal_id = HiddenField() + maxcare_plan = SelectField('MaxCare extended warranty', choices=[ + ('', 'No MaxCare'), + ('silver', 'Silver - 36 mo / 50,000 mi ($1,495)'), + ('gold', 'Gold - 48 mo / 75,000 mi ($1,895)'), + ('platinum', 'Platinum - 60 mo / 100,000 mi ($2,395)'), + ], default='') + + +class ReviewForm(FlaskForm): + rating = SelectField('Rating', + choices=[(str(i), f'{i} stars') for i in range(5, 0, -1)], + default='5') + title = StringField('Headline', validators=[DataRequired(), Length(max=120)]) + body = TextAreaField('Your review', + validators=[DataRequired(), Length(min=20)]) + location = StringField('Your city, state', + validators=[Optional(), Length(max=80)]) + + +# ============================================================================= +# Helpers +# ============================================================================= + +_TOKEN_RE = re.compile(r'[a-z0-9]+') +MAXCARE_PRICES = {'silver': 1495, 'gold': 1895, 'platinum': 2395} +MAXCARE_LABELS = { + 'silver': 'Silver - 36 mo / 50,000 mi', + 'gold': 'Gold - 48 mo / 75,000 mi', + 'platinum': 'Platinum - 60 mo / 100,000 mi', +} + + +def tokenize(s): + if not s: + return [] + return _TOKEN_RE.findall(s.lower()) + + +def slugify(s): + s = (s or '').lower().strip() + s = re.sub(r'[^a-z0-9]+', '-', s).strip('-') + return s + + +def score_vehicle_match(v, tokens): + if not tokens: + return 0 + text_high = ' '.join([v.make or '', v.model or '', str(v.year or '')]).lower() + text_med = ' '.join([v.trim or '', v.body_style or '', v.exterior_color or '']).lower() + text_low = ' '.join([v.interior_color or '', v.transmission or '', v.drive_type or '', + v.fuel_type or '', v.engine_text or '', v.description or '', + ' '.join(v.get_features() or [])]).lower() + score = 0 + for t in tokens: + if t in text_high: + score += 5 + elif t in text_med: + score += 3 + elif t in text_low: + score += 1 + if v.is_featured: + score += 0.1 + return score + + +def _apply_filters(q, filters): + if not filters: + return q + if filters.get('make'): + q = q.filter(Vehicle.make_slug == slugify(filters['make'])) + if filters.get('model'): + q = q.filter(Vehicle.model_slug == slugify(filters['model'])) + if filters.get('trim'): + q = q.filter(Vehicle.trim_slug == slugify(filters['trim'])) + if filters.get('year_min'): + q = q.filter(Vehicle.year >= int(filters['year_min'])) + if filters.get('year_max'): + q = q.filter(Vehicle.year <= int(filters['year_max'])) + if filters.get('year'): + q = q.filter(Vehicle.year == int(filters['year'])) + if filters.get('price_min'): + q = q.filter(Vehicle.price >= float(filters['price_min'])) + if filters.get('price_max'): + q = q.filter(Vehicle.price <= float(filters['price_max'])) + if filters.get('mileage_max'): + q = q.filter(Vehicle.mileage <= int(filters['mileage_max'])) + if filters.get('body_style'): + q = q.filter(Vehicle.body_style.ilike(filters['body_style'])) + if filters.get('transmission'): + q = q.filter(Vehicle.transmission.ilike(filters['transmission'])) + if filters.get('drive_type'): + q = q.filter(Vehicle.drive_type == filters['drive_type'].upper()) + if filters.get('fuel_type'): + q = q.filter(Vehicle.fuel_type.ilike(filters['fuel_type'])) + if filters.get('exterior_color'): + q = q.filter(Vehicle.exterior_color.ilike(filters['exterior_color'])) + if filters.get('store_id'): + try: + q = q.filter(Vehicle.store_id == int(filters['store_id'])) + except (TypeError, ValueError): + pass + if filters.get('state'): + q = q.join(Store).filter(Store.state == filters['state'].upper()) + if filters.get('feature'): + feat = filters['feature'] + q = q.filter(Vehicle.features.ilike('%"' + feat + '"%')) + if filters.get('certified'): + q = q.filter(Vehicle.is_certified.is_(True)) + if filters.get('featured'): + q = q.filter(Vehicle.is_featured.is_(True)) + if filters.get('new_arrival'): + q = q.filter(Vehicle.is_new_arrival.is_(True)) + if filters.get('price_drop'): + q = q.filter(Vehicle.is_price_drop.is_(True)) + return q + + +def search_vehicles(query=None, filters=None, sort='best_match', + page=1, per_page=24): + q = _apply_filters(Vehicle.query, filters) + tokens = tokenize(query or '') + items = q.all() + if tokens: + scored = [(score_vehicle_match(v, tokens), v) for v in items] + scored = [(s, v) for s, v in scored if s > 0] + if sort == 'price_low': + scored.sort(key=lambda x: (x[1].price, -x[0])) + elif sort == 'price_high': + scored.sort(key=lambda x: (-x[1].price, -x[0])) + elif sort == 'mileage_low': + scored.sort(key=lambda x: (x[1].mileage, -x[0])) + elif sort == 'newest': + scored.sort(key=lambda x: (-x[1].year, -x[0])) + else: + scored.sort(key=lambda x: (-x[0], x[1].price)) + items = [v for _, v in scored] + else: + if sort == 'price_low': + items.sort(key=lambda v: v.price) + elif sort == 'price_high': + items.sort(key=lambda v: -v.price) + elif sort == 'mileage_low': + items.sort(key=lambda v: v.mileage) + elif sort == 'newest': + items.sort(key=lambda v: (-v.year, v.mileage)) + else: + items.sort(key=lambda v: (-int(v.is_featured), v.mileage, v.price)) + total = len(items) + start = (page - 1) * per_page + return items[start:start + per_page], total + + +def estimated_payment(price, term_months=72, apr=0.0699, down=2000): + principal = max(float(price) - float(down), 0) + if principal <= 0: + return 0.0 + if not apr or apr <= 0: + return principal / max(term_months, 1) + r = float(apr) / 12 + n = int(term_months) + return principal * (r * (1 + r) ** n) / ((1 + r) ** n - 1) + + +def estimated_payment_to_principal(monthly, term_months, apr): + if monthly <= 0: + return 0 + if apr <= 0: + return monthly * term_months + r = apr / 12 + n = int(term_months) + return monthly * ((1 + r) ** n - 1) / (r * (1 + r) ** n) + + +def estimate_credit_apr(credit_tier, term_months=72): + base = {'excellent': 5.49, 'good': 7.49, + 'fair': 11.99, 'building': 17.99}.get(credit_tier, 7.99) + if int(term_months) >= 72: + base += 0.5 + elif int(term_months) >= 66: + base += 0.25 + return round(base, 2) + + +def make_appraisal_offer(year, make, model, trim, mileage, condition, + has_accidents=False): + similar = Vehicle.query.filter( + Vehicle.year == int(year), + Vehicle.make.ilike(make), + Vehicle.model.ilike(model), + ).all() + if similar: + anchor = sum(v.price for v in similar) / len(similar) + else: + msrp_guess = 28000 + age = max(2026 - int(year), 0) + anchor = msrp_guess * (0.82 ** age) + expected_miles = max(1, (2026 - int(year))) * 12000 + mileage_factor = 1.0 - max(0, (int(mileage) - expected_miles)) / 200000 + mileage_factor = max(0.55, min(1.05, mileage_factor)) + cond_mult = {'excellent': 0.92, 'good': 0.85, + 'fair': 0.74, 'poor': 0.58}.get(condition, 0.85) + accident_mult = 0.92 if has_accidents else 1.0 + offer = anchor * cond_mult * mileage_factor * accident_mult + return round(offer / 50) * 50 + + +def gen_order_number(): + base = Order.query.count() + 1 + return f'CMX-2026-{base:06d}' + + +def _get_or_create_comparison(create=True): + if current_user.is_authenticated: + comp = (Comparison.query.filter_by(user_id=current_user.id) + .order_by(Comparison.id.desc()).first()) + if not comp and create: + comp = Comparison(user_id=current_user.id, name='My comparison') + db.session.add(comp) + db.session.commit() + return comp + sk = session.get('compare_sk') + if not sk: + sk = os.urandom(16).hex() + session['compare_sk'] = sk + comp = Comparison.query.filter_by(session_key=sk).first() + if not comp and create: + comp = Comparison(session_key=sk, name='My comparison') + db.session.add(comp) + db.session.commit() + return comp + + +# ============================================================================= +# Context processors / template filters +# ============================================================================= + +@app.context_processor +def inject_globals(): + saved_count = 0 + if current_user.is_authenticated: + saved_count = SavedVehicle.query.filter_by(user_id=current_user.id).count() + comp = _get_or_create_comparison(create=False) + compare_count = (ComparisonItem.query.filter_by(comparison_id=comp.id).count() + if comp else 0) + return { + 'saved_count': saved_count, + 'compare_count': compare_count, + 'csrf_token': generate_csrf, + 'current_year': 2026, + 'BRAND_PHONE': '(800) 519-1511', + 'BRAND_NAME': 'CarMax', + } + + +@app.template_filter('money') +def filter_money(v): + try: + return f'${int(round(float(v))):,}' + except Exception: + return '$0' + + +@app.template_filter('miles') +def filter_miles(v): + try: + return f'{int(v):,} mi' + except Exception: + return '-' + + +@app.template_filter('plus_money') +def filter_plus_money(v): + try: + v = int(round(float(v))) + sign = '+' if v >= 0 else '-' + return f'{sign}${abs(v):,}' + except Exception: + return '' + + +@app.template_filter('star_row') +def filter_star_row(rating): + try: + r = max(0, min(5, int(round(float(rating))))) + except Exception: + r = 0 + return '*' * r + '.' * (5 - r) + + +# ============================================================================= +# Routes - home, search, browse +# ============================================================================= + +@app.route('/') +def index(): + featured = (Vehicle.query.filter_by(is_featured=True) + .order_by(Vehicle.price.asc()).limit(8).all()) + new_arrivals = (Vehicle.query.filter_by(is_new_arrival=True) + .order_by(Vehicle.added_at.desc()).limit(8).all()) + popular_makes = (db.session.query(Vehicle.make, Vehicle.make_slug, + func.count(Vehicle.id).label('n')) + .group_by(Vehicle.make, Vehicle.make_slug) + .order_by(func.count(Vehicle.id).desc()).limit(12).all()) + body_styles = (db.session.query(Vehicle.body_style, + func.count(Vehicle.id).label('n')) + .group_by(Vehicle.body_style) + .order_by(func.count(Vehicle.id).desc()).all()) + article_strip = (Article.query.filter_by(is_featured=True) + .order_by(Article.published_at.desc()).limit(3).all()) + return render_template('index.html', + featured=featured, + new_arrivals=new_arrivals, + popular_makes=popular_makes, + body_styles=body_styles, + article_strip=article_strip) + + +def _filters_from_args(): + a = request.args + return { + 'make': a.get('make') or '', + 'model': a.get('model') or '', + 'trim': a.get('trim') or '', + 'year': a.get('year') or '', + 'year_min': a.get('year_min') or '', + 'year_max': a.get('year_max') or '', + 'price_min': a.get('price_min') or '', + 'price_max': a.get('price_max') or '', + 'mileage_max': a.get('mileage_max') or '', + 'body_style': a.get('body_style') or '', + 'transmission': a.get('transmission') or '', + 'drive_type': a.get('drive_type') or '', + 'fuel_type': a.get('fuel_type') or '', + 'exterior_color': a.get('exterior_color') or '', + 'store_id': a.get('store_id') or '', + 'state': a.get('state') or '', + 'feature': a.get('feature') or '', + 'certified': a.get('certified') in ('1', 'true', 'on'), + 'featured': a.get('featured') in ('1', 'true', 'on'), + 'new_arrival': a.get('new_arrival') in ('1', 'true', 'on'), + 'price_drop': a.get('price_drop') in ('1', 'true', 'on'), + } + + +def _facets(filters_query): + base = _apply_filters(Vehicle.query, filters_query) + res = {} + sub = base.with_entities(Vehicle.id) + res['makes'] = (db.session.query(Vehicle.make, Vehicle.make_slug, + func.count(Vehicle.id)) + .filter(Vehicle.id.in_(sub)) + .group_by(Vehicle.make, Vehicle.make_slug) + .order_by(func.count(Vehicle.id).desc()).limit(20).all()) + res['body_styles'] = (db.session.query(Vehicle.body_style, + func.count(Vehicle.id)) + .filter(Vehicle.id.in_(sub)) + .group_by(Vehicle.body_style) + .order_by(func.count(Vehicle.id).desc()).all()) + res['fuel_types'] = (db.session.query(Vehicle.fuel_type, + func.count(Vehicle.id)) + .filter(Vehicle.id.in_(sub)) + .group_by(Vehicle.fuel_type) + .order_by(func.count(Vehicle.id).desc()).all()) + res['drive_types'] = (db.session.query(Vehicle.drive_type, + func.count(Vehicle.id)) + .filter(Vehicle.id.in_(sub)) + .group_by(Vehicle.drive_type) + .order_by(func.count(Vehicle.id).desc()).all()) + return res + + +def _do_search(scope_label, extra_filters=None): + q = request.args.get('q', '').strip() + sort = request.args.get('sort', 'best_match') + try: + page = max(1, int(request.args.get('page', 1))) + except ValueError: + page = 1 + filters = _filters_from_args() + if extra_filters: + filters.update(extra_filters) + items, total = search_vehicles(query=q, filters=filters, sort=sort, + page=page, per_page=24) + facets = _facets(filters) + return render_template('search.html', + items=items, total=total, page=page, per_page=24, + pages=(total + 23) // 24, + query=q, sort=sort, filters=filters, + facets=facets, scope_label=scope_label) + + +@app.route('/cars') +def cars_index(): + return _do_search('All inventory') + + +@app.route('/cars/') +def cars_make(make): + label = f'Used {make.replace("-", " ").title()} for sale' + return _do_search(label, extra_filters={'make': make}) + + +@app.route('/cars//') +def cars_model(make, model): + label = (f'Used {make.replace("-", " ").title()} ' + f'{model.replace("-", " ").title()} for sale') + return _do_search(label, extra_filters={'make': make, 'model': model}) + + +@app.route('/cars///') +def cars_model_year(make, model, year): + label = (f'Used {year} {make.replace("-", " ").title()} ' + f'{model.replace("-", " ").title()} for sale') + return _do_search(label, + extra_filters={'make': make, 'model': model, + 'year': year}) + + +@app.route('/cars///') +def cars_model_trim(make, model, trim): + label = (f'Used {make.replace("-", " ").title()} ' + f'{model.replace("-", " ").title()} ' + f'{trim.replace("-", " ").title()} for sale') + return _do_search(label, + extra_filters={'make': make, 'model': model, + 'trim': trim}) + + +@app.route('/cars////') +def cars_model_trim_year(make, model, trim, year): + label = (f'Used {year} {make.replace("-", " ").title()} ' + f'{model.replace("-", " ").title()} ' + f'{trim.replace("-", " ").title()} for sale') + return _do_search(label, + extra_filters={'make': make, 'model': model, + 'trim': trim, 'year': year}) + + +@app.route('/search') +def search(): + return redirect(url_for('cars_index', **request.args)) + + +# ============================================================================= +# Vehicle detail +# ============================================================================= + +@app.route('/vehicle/') +def vehicle_detail(slug): + v = Vehicle.query.filter_by(slug=slug).first_or_404() + similar = (Vehicle.query + .filter(Vehicle.id != v.id, Vehicle.model_slug == v.model_slug) + .order_by(func.abs(Vehicle.price - v.price)).limit(6).all()) + reviews = (Review.query.filter_by(make_slug=v.make_slug, + model_slug=v.model_slug, year=v.year) + .order_by(Review.created_at.desc()).limit(6).all()) + is_saved = False + if current_user.is_authenticated: + is_saved = (SavedVehicle.query + .filter_by(user_id=current_user.id, vehicle_id=v.id) + .first() is not None) + return render_template('vehicle_detail.html', + vehicle=v, similar=similar, reviews=reviews, + is_saved=is_saved, + default_term=72, default_apr=0.0699, + default_down=2000) + + +@app.route('/vehicle/id/') +def vehicle_detail_by_id(vid): + v = db.session.get(Vehicle, vid) + if not v: + abort(404) + return redirect(url_for('vehicle_detail', slug=v.slug)) + + +# ============================================================================= +# Research +# ============================================================================= + +@app.route('/research') +def research_index(): + popular = (db.session.query(Vehicle.make, Vehicle.make_slug, + Vehicle.model, Vehicle.model_slug, + func.count(Vehicle.id).label('n')) + .group_by(Vehicle.make, Vehicle.make_slug, + Vehicle.model, Vehicle.model_slug) + .order_by(func.count(Vehicle.id).desc()).limit(24).all()) + makes = (db.session.query(Vehicle.make, Vehicle.make_slug, + func.count(Vehicle.id).label('n')) + .group_by(Vehicle.make, Vehicle.make_slug) + .order_by(Vehicle.make.asc()).all()) + return render_template('research_index.html', popular=popular, makes=makes) + + +@app.route('/research/') +def research_make(make): + make_slug = slugify(make) + models = (db.session.query(Vehicle.make, Vehicle.make_slug, + Vehicle.model, Vehicle.model_slug, + func.count(Vehicle.id).label('n')) + .filter(Vehicle.make_slug == make_slug) + .group_by(Vehicle.make, Vehicle.make_slug, + Vehicle.model, Vehicle.model_slug) + .order_by(Vehicle.model.asc()).all()) + if not models: + abort(404) + return render_template('research_make.html', + make_name=models[0].make, make_slug=make_slug, + models=models) + + +@app.route('/research//') +def research_model(make, model): + make_slug, model_slug = slugify(make), slugify(model) + years = (db.session.query(Vehicle.year, func.count(Vehicle.id)) + .filter(Vehicle.make_slug == make_slug, + Vehicle.model_slug == model_slug) + .group_by(Vehicle.year) + .order_by(Vehicle.year.desc()).all()) + if not years: + abort(404) + sample = (Vehicle.query + .filter(Vehicle.make_slug == make_slug, + Vehicle.model_slug == model_slug) + .order_by(Vehicle.year.desc(), Vehicle.price.asc()).first()) + return render_template('research_model.html', + make=sample.make, model=sample.model, + make_slug=make_slug, model_slug=model_slug, + years=years, sample=sample) + + +@app.route('/research///') +def research_model_year(make, model, year): + make_slug, model_slug = slugify(make), slugify(model) + vehicles = (Vehicle.query + .filter(Vehicle.make_slug == make_slug, + Vehicle.model_slug == model_slug, + Vehicle.year == year).all()) + if not vehicles: + abort(404) + v0 = vehicles[0] + trims = sorted({(x.trim or '-', x.trim_slug or '') for x in vehicles if x.trim}) + avg_price = sum(x.price for x in vehicles) / len(vehicles) + min_price = min(x.price for x in vehicles) + max_price = max(x.price for x in vehicles) + reviews = (Review.query + .filter_by(make_slug=make_slug, model_slug=model_slug, year=year) + .order_by(Review.created_at.desc()).limit(8).all()) + avg_rating = (sum(r.rating for r in reviews) / len(reviews)) if reviews else v0.customer_rating + return render_template('research_model_year.html', + make=v0.make, model=v0.model, year=year, + make_slug=make_slug, model_slug=model_slug, + vehicles=vehicles, trims=trims, + avg_price=avg_price, min_price=min_price, + max_price=max_price, sample=v0, + reviews=reviews, avg_rating=avg_rating, + review_count=len(reviews)) + + +@app.route('/research/car-comparison/') +def research_comparison(makemodelyear): + return redirect(url_for('compare_view')) + + +# ============================================================================= +# Customer reviews +# ============================================================================= + +@app.route('/reviews///', methods=['GET', 'POST']) +def reviews_page(make, model, year): + make_slug, model_slug = slugify(make), slugify(model) + exists = Vehicle.query.filter(Vehicle.make_slug == make_slug, + Vehicle.model_slug == model_slug, + Vehicle.year == year).first() + if not exists: + abort(404) + form = ReviewForm() + if form.validate_on_submit(): + if not current_user.is_authenticated: + flash('Please sign in to leave a review.', 'info') + return redirect(url_for('login', next=request.path)) + r = Review(user_id=current_user.id, + make_slug=make_slug, model_slug=model_slug, year=year, + rating=int(form.rating.data), + title=form.title.data.strip(), + body=form.body.data.strip(), + location=form.location.data.strip(), + reviewer_name=current_user.full_name) + db.session.add(r) + db.session.commit() + flash('Thanks - your review has been posted.', 'success') + return redirect(url_for('reviews_page', make=make, model=model, year=year)) + reviews = (Review.query + .filter_by(make_slug=make_slug, model_slug=model_slug, year=year) + .order_by(Review.created_at.desc()).all()) + avg = (sum(r.rating for r in reviews) / len(reviews)) if reviews else 0.0 + return render_template('reviews.html', + make=exists.make, model=exists.model, year=year, + make_slug=make_slug, model_slug=model_slug, + reviews=reviews, avg_rating=avg, form=form) + + +# ============================================================================= +# Compare +# ============================================================================= + +@app.route('/compare') +def compare_view(): + comp = _get_or_create_comparison(create=False) + items = [] + if comp: + items = [it.vehicle for it in + ComparisonItem.query.filter_by(comparison_id=comp.id) + .order_by(ComparisonItem.added_at.asc()).all()] + return render_template('compare.html', items=items) + + +@app.route('/compare/add/', methods=['POST']) +def compare_add(vehicle_id): + v = db.session.get(Vehicle, vehicle_id) + if not v: + abort(404) + comp = _get_or_create_comparison(create=True) + existing = ComparisonItem.query.filter_by(comparison_id=comp.id, + vehicle_id=v.id).first() + if existing: + flash(f'{v.short_title} is already in your comparison.', 'info') + else: + cnt = ComparisonItem.query.filter_by(comparison_id=comp.id).count() + if cnt >= 4: + flash('You can compare up to 4 vehicles at a time.', 'warning') + else: + db.session.add(ComparisonItem(comparison_id=comp.id, + vehicle_id=v.id)) + db.session.commit() + flash(f'Added {v.short_title} to compare.', 'success') + return redirect(request.referrer or url_for('compare_view')) + + +@app.route('/compare/remove/', methods=['POST']) +def compare_remove(vehicle_id): + comp = _get_or_create_comparison(create=False) + if comp: + ComparisonItem.query.filter_by(comparison_id=comp.id, + vehicle_id=vehicle_id).delete() + db.session.commit() + return redirect(request.referrer or url_for('compare_view')) + + +@app.route('/compare/clear', methods=['POST']) +def compare_clear(): + comp = _get_or_create_comparison(create=False) + if comp: + ComparisonItem.query.filter_by(comparison_id=comp.id).delete() + db.session.commit() + return redirect(url_for('compare_view')) + + +# ============================================================================= +# Saved vehicles +# ============================================================================= + +@app.route('/saved') +@login_required +def saved_view(): + rows = (SavedVehicle.query.filter_by(user_id=current_user.id) + .order_by(SavedVehicle.saved_at.desc()).all()) + return render_template('saved.html', rows=rows) + + +@app.route('/saved/add/', methods=['POST']) +@login_required +def saved_add(vehicle_id): + v = db.session.get(Vehicle, vehicle_id) + if not v: + abort(404) + existing = SavedVehicle.query.filter_by(user_id=current_user.id, + vehicle_id=v.id).first() + if not existing: + db.session.add(SavedVehicle(user_id=current_user.id, vehicle_id=v.id)) + db.session.commit() + flash(f'Saved {v.short_title}.', 'success') + return redirect(request.referrer or url_for('vehicle_detail', slug=v.slug)) + + +@app.route('/saved/remove/', methods=['POST']) +@login_required +def saved_remove(vehicle_id): + SavedVehicle.query.filter_by(user_id=current_user.id, + vehicle_id=vehicle_id).delete() + db.session.commit() + flash('Removed from saved.', 'info') + return redirect(request.referrer or url_for('saved_view')) + + +@app.route('/saved/toggle/', methods=['POST']) +@login_required +def saved_toggle(vehicle_id): + v = db.session.get(Vehicle, vehicle_id) + if not v: + abort(404) + row = SavedVehicle.query.filter_by(user_id=current_user.id, + vehicle_id=v.id).first() + if row: + db.session.delete(row) + msg = 'Removed from saved.' + else: + db.session.add(SavedVehicle(user_id=current_user.id, vehicle_id=v.id)) + msg = f'Saved {v.short_title}.' + db.session.commit() + flash(msg, 'success') + return redirect(request.referrer or url_for('vehicle_detail', slug=v.slug)) + + +# ============================================================================= +# Stores +# ============================================================================= + +@app.route('/stores') +def stores_index(): + states = (db.session.query(Store.state, func.count(Store.id).label('n')) + .group_by(Store.state) + .order_by(Store.state.asc()).all()) + return render_template('stores.html', states=states) + + +@app.route('/stores/') +def stores_state(state_code): + sc = state_code.upper() + stores = Store.query.filter_by(state=sc).order_by(Store.city.asc()).all() + if not stores: + abort(404) + return render_template('stores_state.html', state=sc, stores=stores) + + +@app.route('/store/') +def store_detail(slug): + s = Store.query.filter_by(slug=slug).first_or_404() + inv_count = Vehicle.query.filter_by(store_id=s.id).count() + inventory = (Vehicle.query.filter_by(store_id=s.id) + .order_by(Vehicle.price.asc()).limit(12).all()) + return render_template('store_detail.html', + store=s, inventory=inventory, inv_count=inv_count) + + +# ============================================================================= +# Sell-my-car / value +# ============================================================================= + +@app.route('/value') +def value_index(): + makes = (db.session.query(Vehicle.make, Vehicle.make_slug) + .group_by(Vehicle.make, Vehicle.make_slug) + .order_by(Vehicle.make.asc()).all()) + return render_template('value.html', makes=makes) + + +@app.route('/value//') +def value_model(make, model): + make_slug, model_slug = slugify(make), slugify(model) + sample = (Vehicle.query + .filter(Vehicle.make_slug == make_slug, + Vehicle.model_slug == model_slug) + .order_by(Vehicle.year.desc()).first()) + if not sample: + abort(404) + rows = (db.session.query(Vehicle.year, + func.avg(Vehicle.price), + func.min(Vehicle.price), + func.max(Vehicle.price), + func.count(Vehicle.id)) + .filter(Vehicle.make_slug == make_slug, + Vehicle.model_slug == model_slug) + .group_by(Vehicle.year) + .order_by(Vehicle.year.desc()).all()) + return render_template('value_model.html', + make=sample.make, model=sample.model, rows=rows) + + +@app.route('/value///') +def value_year(make, model, year): + make_slug, model_slug = slugify(make), slugify(model) + matches = (Vehicle.query + .filter(Vehicle.make_slug == make_slug, + Vehicle.model_slug == model_slug, + Vehicle.year == year).all()) + if not matches: + abort(404) + avg = sum(v.price for v in matches) / len(matches) + return render_template('value_year.html', + make=matches[0].make, model=matches[0].model, + year=year, avg_price=avg, count=len(matches), + min_price=min(v.price for v in matches), + max_price=max(v.price for v in matches)) + + +@app.route('/sell-my-car', methods=['GET', 'POST']) +def sell_my_car(): + form = SellMyCarForm() + if current_user.is_authenticated and not form.contact_email.data: + form.contact_email.data = current_user.email + form.contact_phone.data = current_user.phone or '' + form.zip_code.data = current_user.zip_code or '' + if form.validate_on_submit(): + offer = make_appraisal_offer(form.year.data, form.make.data, + form.model.data, form.trim.data or '', + form.mileage.data, form.condition.data, + form.has_accidents.data) + a = Appraisal( + user_id=current_user.id if current_user.is_authenticated else None, + year=form.year.data, + make=form.make.data.strip(), + model=form.model.data.strip(), + trim=(form.trim.data or '').strip(), + mileage=form.mileage.data, + condition=form.condition.data, + exterior_color=(form.exterior_color.data or '').strip(), + license_plate=(form.license_plate.data or '').strip().upper(), + license_state=(form.license_state.data or '').strip().upper(), + vin=(form.vin.data or '').strip().upper(), + zip_code=(form.zip_code.data or '').strip(), + has_accidents=form.has_accidents.data, + owner_count=form.owner_count.data or 1, + contact_email=(form.contact_email.data or '').strip(), + contact_phone=(form.contact_phone.data or '').strip(), + offer_amount=offer, + offer_valid_until=date(2026, 5, 14) + timedelta(days=7), + status='active', + ) + db.session.add(a) + db.session.commit() + return redirect(url_for('sell_offer', appraisal_id=a.id)) + return render_template('sell_my_car.html', form=form) + + +@app.route('/sell-my-car/offer/') +def sell_offer(appraisal_id): + a = db.session.get(Appraisal, appraisal_id) + if not a: + abort(404) + return render_template('sell_offer.html', appraisal=a) + + +@app.route('/sell-my-car/offer//redeem', methods=['POST']) +@login_required +def sell_offer_redeem(appraisal_id): + a = db.session.get(Appraisal, appraisal_id) + if not a: + abort(404) + if a.user_id and a.user_id != current_user.id: + abort(403) + a.user_id = current_user.id + a.status = 'redeemed' + db.session.commit() + flash(f'We scheduled redemption for your ${int(a.offer_amount):,} ' + f'offer on the {a.year} {a.make} {a.model}.', 'success') + return redirect(url_for('account_appraisals')) + + +# ============================================================================= +# Financing / pre-qual +# ============================================================================= + +@app.route('/car-financing') +def financing(): + return render_template('financing.html') + + +@app.route('/pre-qual/app', methods=['GET', 'POST']) +def pre_qual(): + form = PreQualForm() + if current_user.is_authenticated: + if not form.annual_income.data: + form.annual_income.data = current_user.annual_income or 60000 + if not form.employment_status.data and current_user.employment_status: + form.employment_status.data = current_user.employment_status + if form.validate_on_submit(): + if not current_user.is_authenticated: + session['pending_prequal'] = { + 'annual_income': form.annual_income.data, + 'employment_status': form.employment_status.data, + 'monthly_payment_max': form.monthly_payment_max.data, + 'down_payment': form.down_payment.data or 2000, + 'term_months': int(form.term_months.data), + 'credit_tier': form.credit_tier.data, + } + flash('Sign in to save your pre-qualification.', 'info') + return redirect(url_for('login', next=url_for('pre_qual'))) + apr = estimate_credit_apr(form.credit_tier.data, + int(form.term_months.data)) + pq = FinancePreQual( + user_id=current_user.id, + annual_income=form.annual_income.data, + employment_status=form.employment_status.data, + monthly_payment_max=form.monthly_payment_max.data, + down_payment=form.down_payment.data or 2000, + term_months=int(form.term_months.data), + estimated_apr=apr, + credit_tier=form.credit_tier.data, + expires_at=date(2026, 5, 14) + timedelta(days=30), + ) + db.session.add(pq) + current_user.pre_qual_active = True + current_user.pre_qual_monthly_max = form.monthly_payment_max.data + current_user.pre_qual_term_months = int(form.term_months.data) + current_user.pre_qual_apr = apr + current_user.pre_qual_down_payment = form.down_payment.data or 2000 + current_user.pre_qual_credit_tier = form.credit_tier.data + current_user.pre_qual_expires_at = pq.expires_at + current_user.annual_income = form.annual_income.data + current_user.employment_status = form.employment_status.data + db.session.commit() + return redirect(url_for('pre_qual_result')) + return render_template('pre_qual_form.html', form=form) + + +@app.route('/pre-qual/result') +@login_required +def pre_qual_result(): + if not current_user.pre_qual_active: + return redirect(url_for('pre_qual')) + max_principal = estimated_payment_to_principal( + current_user.pre_qual_monthly_max, + current_user.pre_qual_term_months, + current_user.pre_qual_apr / 100.0, + ) + current_user.pre_qual_down_payment + affordable = (Vehicle.query + .filter(Vehicle.price <= max_principal) + .order_by(Vehicle.price.desc()).limit(12).all()) + return render_template('pre_qual_result.html', + max_principal=max_principal, affordable=affordable) + + +# ============================================================================= +# Reserve & test drive +# ============================================================================= + +@app.route('/vehicle//reserve', methods=['GET', 'POST']) +@login_required +def reserve(vehicle_id): + v = db.session.get(Vehicle, vehicle_id) + if not v: + abort(404) + form = ReserveForm() + if request.method == 'POST' and form.validate_on_submit(): + appt = form.appointment_date.data + try: + appt_date = (datetime.strptime(appt, '%Y-%m-%d').date() + if appt else (date(2026, 5, 14) + timedelta(days=3))) + except ValueError: + appt_date = date(2026, 5, 14) + timedelta(days=3) + r = Reservation( + user_id=current_user.id, vehicle_id=v.id, store_id=v.store_id, + appointment_date=appt_date, + expires_at=date(2026, 5, 14) + timedelta(days=7), + transfer_required=False, transfer_fee=v.transfer_fee or 0, + status='active', + ) + db.session.add(r) + db.session.commit() + flash(f'Reserved the {v.short_title} for 7 days. ' + f'Appointment: {appt_date}.', 'success') + return redirect(url_for('account_reservations')) + return render_template('reserve.html', vehicle=v, form=form) + + +@app.route('/vehicle//test-drive', methods=['GET', 'POST']) +@login_required +def test_drive(vehicle_id): + v = db.session.get(Vehicle, vehicle_id) + if not v: + abort(404) + form = TestDriveForm() + if request.method == 'POST' and form.validate_on_submit(): + try: + d = datetime.strptime(form.scheduled_date.data, '%Y-%m-%d').date() + except ValueError: + d = date(2026, 5, 14) + timedelta(days=3) + td = TestDrive( + user_id=current_user.id, vehicle_id=v.id, store_id=v.store_id, + location_type=form.location_type.data, + scheduled_date=d, scheduled_time=form.scheduled_time.data, + notes=form.notes.data or '', status='confirmed', + ) + db.session.add(td) + db.session.commit() + flash(f'Test drive booked for {d} at {form.scheduled_time.data}.', + 'success') + return redirect(url_for('account_test_drives')) + return render_template('test_drive.html', vehicle=v, form=form) + + +# ============================================================================= +# Checkout +# ============================================================================= + +@app.route('/vehicle//checkout', methods=['GET', 'POST']) +@login_required +def checkout(vehicle_id): + v = db.session.get(Vehicle, vehicle_id) + if not v: + abort(404) + form = CheckoutForm() + if current_user.pre_qual_active and request.method == 'GET': + form.down_payment.data = current_user.pre_qual_down_payment + form.apr.data = current_user.pre_qual_apr + form.term_months.data = str(current_user.pre_qual_term_months) + trade_options = (Appraisal.query + .filter_by(user_id=current_user.id, status='active') + .order_by(Appraisal.created_at.desc()).all()) + if form.validate_on_submit(): + subtotal = v.price + transfer_fee = v.transfer_fee or 0 + tax = subtotal * 0.06 + title_fee = 99 + registration_fee = 55 + maxcare_price = MAXCARE_PRICES.get(form.maxcare_plan.data, 0) or 0 + trade_value = 0 + trade_appraisal_id = None + if form.trade_in_appraisal_id.data: + try: + a = db.session.get(Appraisal, int(form.trade_in_appraisal_id.data)) + if a and a.user_id == current_user.id and a.status == 'active': + trade_value = a.offer_amount + trade_appraisal_id = a.id + a.status = 'redeemed' + except (TypeError, ValueError): + pass + total = (subtotal + transfer_fee + tax + title_fee + registration_fee + + maxcare_price - trade_value) + down = form.down_payment.data or 0 + if form.payment_method.data == 'cash': + monthly = 0 + else: + apr = (form.apr.data or 6.99) / 100.0 + monthly = estimated_payment(total - down, + term_months=int(form.term_months.data), + apr=apr, down=0) + order = Order( + order_number=gen_order_number(), + user_id=current_user.id, + vehicle_id=v.id, store_id=v.store_id, + subtotal=subtotal, transfer_fee=transfer_fee, tax=tax, + title_fee=title_fee, registration_fee=registration_fee, + total=total, + maxcare_plan=form.maxcare_plan.data or '', + maxcare_price=maxcare_price, + payment_method=form.payment_method.data, + payment_last4=form.card_last4.data or '', + payment_apr=form.apr.data or 0, + payment_term_months=int(form.term_months.data), + monthly_payment=monthly, + down_payment=down, + trade_in_appraisal_id=trade_appraisal_id, + trade_in_value=trade_value, + pickup_or_delivery=form.pickup_or_delivery.data, + delivery_address=form.delivery_address.data or '', + pickup_date=date(2026, 5, 14) + timedelta(days=3), + status='processing', + ) + db.session.add(order) + db.session.commit() + flash(f'Order placed: {order.order_number}', 'success') + return redirect(url_for('order_confirmation', + order_number=order.order_number)) + preview = { + 'subtotal': v.price, + 'transfer_fee': v.transfer_fee or 0, + 'tax_estimate': v.price * 0.06, + 'title_fee': 99, + 'registration_fee': 55, + } + preview['total_before_warranty'] = sum([ + preview['subtotal'], preview['transfer_fee'], preview['tax_estimate'], + preview['title_fee'], preview['registration_fee'], + ]) + return render_template('checkout.html', vehicle=v, form=form, + preview=preview, trade_options=trade_options, + maxcare_prices=MAXCARE_PRICES, + maxcare_labels=MAXCARE_LABELS) + + +@app.route('/order/') +@login_required +def order_confirmation(order_number): + o = Order.query.filter_by(order_number=order_number, + user_id=current_user.id).first() + if not o: + abort(404) + return render_template('order_confirmation.html', order=o) + + +# ============================================================================= +# Account +# ============================================================================= + +@app.route('/account') +@login_required +def account(): + saved_n = SavedVehicle.query.filter_by(user_id=current_user.id).count() + reservations_n = Reservation.query.filter_by( + user_id=current_user.id, status='active').count() + test_drives_n = TestDrive.query.filter_by( + user_id=current_user.id, status='confirmed').count() + appraisals_n = Appraisal.query.filter_by( + user_id=current_user.id, status='active').count() + orders_n = Order.query.filter_by(user_id=current_user.id).count() + recent_saved = (SavedVehicle.query.filter_by(user_id=current_user.id) + .order_by(SavedVehicle.saved_at.desc()).limit(4).all()) + return render_template('account.html', + saved_n=saved_n, + reservations_n=reservations_n, + test_drives_n=test_drives_n, + appraisals_n=appraisals_n, + orders_n=orders_n, + recent_saved=recent_saved) + + +@app.route('/account/edit', methods=['GET', 'POST']) +@login_required +def account_edit(): + form = AccountEditForm(obj=current_user) + if form.validate_on_submit(): + for field in ['first_name', 'last_name', 'phone', 'address_line1', + 'address_line2', 'city', 'state', 'zip_code']: + setattr(current_user, field, getattr(form, field).data or '') + current_user.state = (current_user.state or '').upper()[:2] + db.session.commit() + flash('Profile updated.', 'success') + return redirect(url_for('account')) + return render_template('account_edit.html', form=form) + + +@app.route('/account/change-password', methods=['GET', 'POST']) +@login_required +def account_change_password(): + form = ChangePasswordForm() + if form.validate_on_submit(): + if not current_user.check_password(form.current_password.data): + flash('Current password is incorrect.', 'danger') + else: + current_user.set_password(form.new_password.data) + db.session.commit() + flash('Password updated.', 'success') + return redirect(url_for('account')) + return render_template('account_change_password.html', form=form) + + +@app.route('/account/orders') +@login_required +def account_orders(): + orders = (Order.query.filter_by(user_id=current_user.id) + .order_by(Order.created_at.desc()).all()) + return render_template('account_orders.html', orders=orders) + + +@app.route('/account/reservations') +@login_required +def account_reservations(): + rows = (Reservation.query.filter_by(user_id=current_user.id) + .order_by(Reservation.created_at.desc()).all()) + return render_template('account_reservations.html', rows=rows) + + +@app.route('/account/test-drives') +@login_required +def account_test_drives(): + rows = (TestDrive.query.filter_by(user_id=current_user.id) + .order_by(TestDrive.scheduled_date.desc()).all()) + return render_template('account_test_drives.html', rows=rows) + + +@app.route('/account/appraisals') +@login_required +def account_appraisals(): + rows = (Appraisal.query.filter_by(user_id=current_user.id) + .order_by(Appraisal.created_at.desc()).all()) + return render_template('account_appraisals.html', rows=rows) + + +@app.route('/account/reservations//cancel', methods=['POST']) +@login_required +def reservation_cancel(reservation_id): + r = db.session.get(Reservation, reservation_id) + if not r or r.user_id != current_user.id: + abort(404) + r.status = 'cancelled' + db.session.commit() + flash('Reservation cancelled.', 'info') + return redirect(url_for('account_reservations')) + + +@app.route('/account/test-drives//cancel', methods=['POST']) +@login_required +def test_drive_cancel(td_id): + r = db.session.get(TestDrive, td_id) + if not r or r.user_id != current_user.id: + abort(404) + r.status = 'cancelled' + db.session.commit() + flash('Test drive cancelled.', 'info') + return redirect(url_for('account_test_drives')) + + +# ============================================================================= +# Articles & FAQ & MaxCare +# ============================================================================= + +@app.route('/articles') +def articles_index(): + cat = request.args.get('category', '').strip() + q = Article.query + if cat: + q = q.filter(Article.category == cat) + posts = q.order_by(Article.published_at.desc()).all() + cats = (db.session.query(Article.category, func.count(Article.id)) + .group_by(Article.category).all()) + return render_template('articles_index.html', + posts=posts, cats=cats, current_cat=cat) + + +@app.route('/articles/') +def article_detail(slug): + a = Article.query.filter_by(slug=slug).first_or_404() + related = (Article.query.filter(Article.category == a.category, + Article.id != a.id) + .order_by(Article.published_at.desc()).limit(4).all()) + return render_template('article_detail.html', article=a, related=related) + + +FAQ_DATA = { + 'finding-a-car': [ + ('how-can-i-find-out-when-cars-i-like-are-added-to-carmax-inventory', + 'How can I find out when cars I like are added to inventory?', + 'Save vehicles you like to your CarMax account. We will email you ' + 'when similar matches arrive, and you can also adjust your search ' + 'to email alerts on new listings that meet your criteria.'), + ('how-many-vehicles-does-carmax-have', + 'How many vehicles does CarMax have?', + 'CarMax maintains a nationwide inventory of approximately 50,000 ' + 'used vehicles across more than 240 stores.'), + ('what-is-carmax-certified', + 'What is CarMax Certified?', + 'Every car we sell is CarMax Certified - it has been through our ' + '125+ point inspection, has no flood or frame damage, and has no ' + 'salvage history.'), + ], + 'selling-a-car': [ + ('can-i-get-both-an-online-and-in-store-appraisal', + 'Can I get both an online and in-store appraisal?', + 'Yes - you can start your appraisal online with an instant offer ' + 'in under two minutes, then bring your car to a store for in-person ' + 'verification. The price is the same whether you sell outright or ' + 'trade in.'), + ('how-long-is-my-appraisal-offer-good-for', + 'How long is my appraisal offer good for?', + 'Your written offer is valid for 7 days from the day we make it.'), + ], + 'financing': [ + ('what-is-pre-qualification', + 'What is pre-qualification?', + 'Pre-qualification is a soft credit inquiry that gives you ' + 'personalized monthly payment terms without impacting your credit ' + 'score. It takes about 5 minutes and is valid for 30 days.'), + ('does-carmax-finance-first-time-buyers', + 'Does CarMax finance first-time buyers?', + 'Yes, CarMax has finance sources to accommodate most credit ' + 'profiles, including first-time buyers.'), + ], + 'warranty-and-returns': [ + ('what-is-the-30-day-return-policy', + 'What is the 30-day return policy?', + 'You may return your vehicle within 10 days for any reason for a ' + 'full refund, and every vehicle comes with a 30-day limited ' + 'warranty (60-day in CT/MN/RI, 90-day in MA/NJ/NY).'), + ('what-does-maxcare-cover', + 'What does MaxCare cover?', + 'MaxCare extended service plans cover repairs, with deductible-per-' + 'visit pricing. Plans run up to 60 months and include 24/7 roadside ' + 'assistance, rental reimbursement up to $40/day, and nationwide ' + 'coverage in the U.S. and Canada.'), + ], +} + + +@app.route('/faq') +def faq_index(): + return render_template('faq.html', categories=FAQ_DATA) + + +@app.route('/faq/') +def faq_category(category): + items = FAQ_DATA.get(category) + if not items: + abort(404) + return render_template('faq_category.html', + category=category, items=items) + + +@app.route('/faq//') +def faq_detail(category, slug): + items = FAQ_DATA.get(category) or [] + found = next(((s, q, a) for (s, q, a) in items if s == slug), None) + if not found: + abort(404) + return render_template('faq_detail.html', + category=category, q_slug=found[0], + question=found[1], answer=found[2]) + + +@app.route('/car-buying-process/maxcare-service-plans') +def maxcare_plans(): + return render_template('maxcare.html', + prices=MAXCARE_PRICES, + labels=MAXCARE_LABELS) + + +# ============================================================================= +# Auth +# ============================================================================= + +@app.route('/login', methods=['GET', 'POST']) +def login(): + if current_user.is_authenticated: + return redirect(url_for('account')) + form = LoginForm() + next_url = request.args.get('next') or url_for('account') + if form.validate_on_submit(): + u = User.query.filter_by(email=form.email.data.strip().lower()).first() + if u and u.check_password(form.password.data): + login_user(u, remember=form.remember.data) + flash(f'Welcome back, {u.full_name}!', 'success') + return redirect(next_url) + flash('Invalid email or password.', 'danger') + return render_template('login.html', form=form, next_url=next_url) + + +@app.route('/register', methods=['GET', 'POST']) +def register(): + if current_user.is_authenticated: + return redirect(url_for('account')) + form = RegisterForm() + if form.validate_on_submit(): + email = form.email.data.strip().lower() + if User.query.filter_by(email=email).first(): + flash('That email is already registered. Please sign in.', 'warning') + return redirect(url_for('login')) + u = User(email=email, + first_name=form.first_name.data.strip(), + last_name=form.last_name.data.strip(), + phone=(form.phone.data or '').strip(), + zip_code=(form.zip_code.data or '').strip()) + u.set_password(form.password.data) + db.session.add(u) + db.session.commit() + login_user(u) + flash('Welcome to CarMax!', 'success') + return redirect(url_for('account')) + return render_template('register.html', form=form) + + +@app.route('/logout') +def logout(): + logout_user() + flash('You have been signed out.', 'info') + return redirect(url_for('index')) + + +# ============================================================================= +# Health & errors +# ============================================================================= + +@app.route('/_health') +def health(): + return jsonify({'ok': True, 'site': 'carmax', + 'vehicles': Vehicle.query.count(), + 'stores': Store.query.count(), + 'users': User.query.count()}) + + +@app.errorhandler(404) +def not_found(e): + return render_template('404.html'), 404 + + +@app.errorhandler(500) +def server_error(e): + return render_template('500.html'), 500 + + +# ============================================================================= +# Bootstrap +# ============================================================================= + +from seed_data import seed_database, seed_benchmark_users # noqa: E402 + +with app.app_context(): + db.create_all() + seed_database() + seed_benchmark_users() + + +if __name__ == '__main__': + port = int(os.environ.get('PORT', 5000)) + app.run(host='0.0.0.0', port=port, debug=False) diff --git a/sites/carmax/instance/.gitkeep b/sites/carmax/instance/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/sites/carmax/requirements.txt b/sites/carmax/requirements.txt new file mode 100644 index 0000000..d10ecf9 --- /dev/null +++ b/sites/carmax/requirements.txt @@ -0,0 +1,12 @@ +Flask==3.1.0 +Flask-SQLAlchemy==3.1.1 +Flask-Login==0.6.3 +Flask-WTF==1.2.2 +Flask-Bcrypt==1.0.1 +Werkzeug==3.1.3 +Jinja2==3.1.4 +SQLAlchemy==2.0.36 +WTForms==3.2.1 +email-validator==2.2.0 +Pillow==11.0.0 +# probe diff --git a/sites/carmax/scrape_carmax.py b/sites/carmax/scrape_carmax.py new file mode 100644 index 0000000..4e8b918 --- /dev/null +++ b/sites/carmax/scrape_carmax.py @@ -0,0 +1,205 @@ +#!/usr/bin/env python3 +"""Harvest real CarMax product images for the WebHarbor mirror. + +Drives a real Chromium via Playwright through carmax.com: + 1. Render representative inventory + research pages + 2. Extract the actual image URLs from the post-hydration DOM + 3. Download the image bytes for every seeded vehicle's stock_number + +The seeded DB references local paths like: + /static/images/vehicles/-front.jpg + /static/images/vehicles/-side.jpg + /static/images/vehicles/-rear.jpg + /static/images/vehicles/-dashboard.jpg + /static/images/vehicles/-cargo.jpg + /static/images/vehicles/-interior.jpg + +This script fetches the corresponding evox stock-photo views from +content-images.carmax.com (which CarMax uses for the same year/make/model +across its catalog), then writes them to the local paths above using +each seeded vehicle's stock number as the basename. + +Run from the repo root: + pip install playwright httpx + python -m playwright install chromium + python sites/carmax/scrape_carmax.py +""" +import json +import os +import pathlib +import re +import sys +import time +from urllib.parse import urljoin + +try: + from playwright.sync_api import sync_playwright +except ImportError: + sys.exit("missing playwright. install with:\n pip install playwright httpx\n python -m playwright install chromium") + +try: + import httpx +except ImportError: + sys.exit("missing httpx. install with: pip install httpx") + +ROOT = pathlib.Path(__file__).resolve().parent # sites/carmax/ +SCRAPE_DIR = ROOT / "scraped_data" +IMG_DIR = ROOT / "static" / "images" / "vehicles" +ARTICLE_IMG_DIR = ROOT / "static" / "images" / "articles" +STORE_IMG_DIR = ROOT / "static" / "images" / "stores" +SCRAPE_DIR.mkdir(parents=True, exist_ok=True) +IMG_DIR.mkdir(parents=True, exist_ok=True) +ARTICLE_IMG_DIR.mkdir(parents=True, exist_ok=True) +STORE_IMG_DIR.mkdir(parents=True, exist_ok=True) + +# Map our seeded view-name -> evox view code. +VIEW_CODES = { + 'front': '089', + 'dashboard': '174', + 'side': '037', + 'rear': '119', + 'cargo': '122', + 'interior': '118', +} + +# Build the list of (year, make, model) tuples we need real photos for. +TEMPLATE_INDEX = [ + (2020, 'honda', 'civic'), (2021, 'honda', 'civic'), (2022, 'honda', 'civic'), (2023, 'honda', 'civic'), + (2019, 'honda', 'accord'), (2020, 'honda', 'accord'), (2021, 'honda', 'accord'), (2022, 'honda', 'accord'), + (2019, 'honda', 'cr-v'), (2020, 'honda', 'cr-v'), (2021, 'honda', 'cr-v'), (2022, 'honda', 'cr-v'), (2023, 'honda', 'cr-v'), + (2020, 'honda', 'pilot'), (2021, 'honda', 'pilot'), (2022, 'honda', 'pilot'), + (2019, 'toyota', 'camry'), (2020, 'toyota', 'camry'), (2021, 'toyota', 'camry'), (2022, 'toyota', 'camry'), (2023, 'toyota', 'camry'), + (2020, 'toyota', 'corolla'), (2021, 'toyota', 'corolla'), (2022, 'toyota', 'corolla'), (2023, 'toyota', 'corolla'), + (2019, 'toyota', 'rav4'), (2020, 'toyota', 'rav4'), (2021, 'toyota', 'rav4'), (2022, 'toyota', 'rav4'), (2023, 'toyota', 'rav4'), + (2019, 'toyota', 'tacoma'), (2020, 'toyota', 'tacoma'), (2021, 'toyota', 'tacoma'), (2022, 'toyota', 'tacoma'), (2023, 'toyota', 'tacoma'), + (2020, 'toyota', 'highlander'), (2021, 'toyota', 'highlander'), (2022, 'toyota', 'highlander'), + (2019, 'ford', 'f-150'), (2020, 'ford', 'f-150'), (2021, 'ford', 'f-150'), (2022, 'ford', 'f-150'), (2023, 'ford', 'f-150'), + (2019, 'ford', 'explorer'), (2020, 'ford', 'explorer'), (2021, 'ford', 'explorer'), (2022, 'ford', 'explorer'), + (2019, 'ford', 'mustang'), (2020, 'ford', 'mustang'), (2021, 'ford', 'mustang'), (2022, 'ford', 'mustang'), + (2019, 'ford', 'escape'), (2020, 'ford', 'escape'), (2021, 'ford', 'escape'), (2022, 'ford', 'escape'), + (2019, 'chevrolet', 'silverado-1500'), (2020, 'chevrolet', 'silverado-1500'), (2021, 'chevrolet', 'silverado-1500'), (2022, 'chevrolet', 'silverado-1500'), (2023, 'chevrolet', 'silverado-1500'), + (2019, 'chevrolet', 'equinox'), (2020, 'chevrolet', 'equinox'), (2021, 'chevrolet', 'equinox'), (2022, 'chevrolet', 'equinox'), + (2020, 'chevrolet', 'tahoe'), (2021, 'chevrolet', 'tahoe'), (2022, 'chevrolet', 'tahoe'), (2023, 'chevrolet', 'tahoe'), + (2019, 'nissan', 'altima'), (2020, 'nissan', 'altima'), (2021, 'nissan', 'altima'), (2022, 'nissan', 'altima'), (2023, 'nissan', 'altima'), + (2019, 'nissan', 'rogue'), (2020, 'nissan', 'rogue'), (2021, 'nissan', 'rogue'), (2022, 'nissan', 'rogue'), (2023, 'nissan', 'rogue'), + (2020, 'hyundai', 'elantra'), (2021, 'hyundai', 'elantra'), (2022, 'hyundai', 'elantra'), (2023, 'hyundai', 'elantra'), + (2020, 'hyundai', 'tucson'), (2021, 'hyundai', 'tucson'), (2022, 'hyundai', 'tucson'), (2023, 'hyundai', 'tucson'), + (2019, 'hyundai', 'santa-fe'), (2020, 'hyundai', 'santa-fe'), (2021, 'hyundai', 'santa-fe'), (2022, 'hyundai', 'santa-fe'), + (2019, 'kia', 'sportage'), (2020, 'kia', 'sportage'), (2021, 'kia', 'sportage'), (2022, 'kia', 'sportage'), + (2019, 'kia', 'sorento'), (2020, 'kia', 'sorento'), (2021, 'kia', 'sorento'), (2022, 'kia', 'sorento'), (2023, 'kia', 'sorento'), + (2019, 'jeep', 'grand-cherokee'), (2020, 'jeep', 'grand-cherokee'), (2021, 'jeep', 'grand-cherokee'), (2022, 'jeep', 'grand-cherokee'), (2023, 'jeep', 'grand-cherokee'), + (2019, 'jeep', 'wrangler'), (2020, 'jeep', 'wrangler'), (2021, 'jeep', 'wrangler'), (2022, 'jeep', 'wrangler'), (2023, 'jeep', 'wrangler'), + (2019, 'subaru', 'outback'), (2020, 'subaru', 'outback'), (2021, 'subaru', 'outback'), (2022, 'subaru', 'outback'), (2023, 'subaru', 'outback'), + (2019, 'subaru', 'forester'), (2020, 'subaru', 'forester'), (2021, 'subaru', 'forester'), (2022, 'subaru', 'forester'), + (2019, 'mazda', 'cx-5'), (2020, 'mazda', 'cx-5'), (2021, 'mazda', 'cx-5'), (2022, 'mazda', 'cx-5'), (2023, 'mazda', 'cx-5'), + (2019, 'bmw', '3-series'), (2020, 'bmw', '3-series'), (2021, 'bmw', '3-series'), (2022, 'bmw', '3-series'), + (2019, 'mercedes-benz', 'c-class'), (2020, 'mercedes-benz', 'c-class'), (2021, 'mercedes-benz', 'c-class'), (2022, 'mercedes-benz', 'c-class'), + (2019, 'tesla', 'model-3'), (2020, 'tesla', 'model-3'), (2021, 'tesla', 'model-3'), (2022, 'tesla', 'model-3'), (2023, 'tesla', 'model-3'), +] + + +def discover_evox_urls(): + """Use Playwright to load research pages and grab evox image URLs. + + Returns: dict[(year, make, model)] = list[url] (typically 6 views per car). + """ + found = {} + with sync_playwright() as p: + browser = p.chromium.launch() + page = browser.new_page(viewport={'width': 1440, 'height': 900}, + user_agent='Mozilla/5.0 webharbor-mirror-scraper') + for year, make, model in TEMPLATE_INDEX: + url = f"https://www.carmax.com/research/{make}/{model}/{year}" + print(f" recon {year} {make} {model}", flush=True) + try: + page.goto(url, wait_until='domcontentloaded', timeout=30000) + page.wait_for_timeout(900) + imgs = page.eval_on_selector_all( + 'img', + "els => els.map(e => e.currentSrc || e.src)" + ".filter(u => u && u.includes('content-images.carmax.com/stockimages'))" + ) + if imgs: + # Dedupe by view code in URL + dedup = [] + seen = set() + for u in imgs: + m = re.search(r"st\d+-(\d+)-evoxweb", u) + code = m.group(1) if m else u + if code not in seen: + seen.add(code) + dedup.append(u) + found[(year, make, model)] = dedup + except Exception as e: + print(f" ! {e}") + browser.close() + return found + + +def download_image(client, url, dest): + if dest.exists() and dest.stat().st_size > 1024: + return False + r = client.get(url) + if r.status_code != 200: + return False + dest.write_bytes(r.content) + return True + + +def main(): + # 1. Load every seeded vehicle from the DB. + sys.path.insert(0, str(ROOT)) + os.environ.setdefault('FLASK_RUN_FROM_CLI', '1') + from app import app, Vehicle, Store, Article + vehicles = [] + with app.app_context(): + vehicles = Vehicle.query.order_by(Vehicle.id).all() + stores = Store.query.order_by(Store.id).all() + articles = Article.query.order_by(Article.id).all() + print(f"[scrape] {len(vehicles)} vehicles, {len(stores)} stores, " + f"{len(articles)} articles need images") + + # 2. Use Playwright to discover real evox URLs by (year, make, model). + print("[scrape] discovering image URLs via Playwright...") + url_map = discover_evox_urls() + + cache_json = SCRAPE_DIR / "image_urls.json" + cache_json.write_text(json.dumps( + {f"{y}|{mk}|{md}": urls for (y, mk, md), urls in url_map.items()}, + indent=2, + )) + print(f"[scrape] cached {sum(len(v) for v in url_map.values())} URLs to {cache_json}") + + # 3. Download per-vehicle stock photos. Each seeded vehicle gets up to + # 6 views named -{front,side,rear,dashboard,cargo,interior}.jpg. + print("[scrape] downloading vehicle images...") + downloaded = 0 + skipped = 0 + with httpx.Client(follow_redirects=True, timeout=30, + headers={'User-Agent': 'webharbor-mirror-scraper'}) as cx: + for v in vehicles: + key = (v.year, v.make_slug, v.model_slug) + urls = url_map.get(key, []) + if not urls: + continue + views = list(VIEW_CODES.keys()) + for i, view in enumerate(views): + if i >= len(urls): + break + src = urls[i] + dest = IMG_DIR / f"{v.stock_number}-{view}.jpg" + try: + if download_image(cx, src, dest): + downloaded += 1 + else: + skipped += 1 + except Exception as e: + print(f" ! {dest.name}: {e}") + print(f"[scrape] downloaded {downloaded} new images, skipped {skipped} already-present") + + # 4. (Optional) Save a 'placeholder' for stores using carmax store icon + print("[scrape] done.") + + +if __name__ == '__main__': + main() diff --git a/sites/carmax/scraped_data/.gitkeep b/sites/carmax/scraped_data/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/sites/carmax/scraped_data/recon_notes.md b/sites/carmax/scraped_data/recon_notes.md new file mode 100644 index 0000000..39d7439 --- /dev/null +++ b/sites/carmax/scraped_data/recon_notes.md @@ -0,0 +1,140 @@ +# carmax.com — recon notes (Phase 1.3) + +Captured via WebFetch + WebSearch on 2026-05-14. No browser/image harvesting from +the sandbox; that step happens locally via `scrape_carmax.py`. + +## URL patterns + +| Pattern | Purpose | +|---|---| +| `/` | Homepage | +| `/cars` | Master inventory listing (supports `?location=`, `?make=`, etc.) | +| `/cars/` | Make landing | +| `/cars//` | Model listings | +| `/cars///` | Year-filtered model listings | +| `/cars////` | Trim+year filter | +| `/cars/?year=YYYY-YYYY&mileage=N` | Filter via query params | +| `/research` | Research landing | +| `/research///` | Vehicle research page (specs, trims, FAQ, reviews) | +| `/research/car-comparison/--` | Comparison tool | +| `/reviews///` | Customer reviews | +| `/value` | Used-car value entry | +| `/value///` | Year-specific value lookup | +| `/sell-my-car` | Sell-my-car / instant offer flow | +| `/stores` | National store locator | +| `/stores/` | State stores list | +| `/pre-qual/app` | Pre-qualification application | +| `/car-financing` | Financing landing | +| `/articles` | Articles index | +| `/articles/` | Article detail | +| `/faq` | FAQ root | +| `/car-buying-process/maxcare-service-plans` | MaxCare extended warranty | + +## Functional modules (mirror scope: ALL of these) + +1. **Inventory search** — filters: ZIP/location, make, model, year range, trim, body style, mileage max, price range, color, transmission, drive type, fuel type, features +2. **Vehicle detail** — photos, price, mileage, specs, features list, store assignment, transfer fee, reserve/test-drive/financing CTAs +3. **Research pages** — model overview with specs, trims, pros/cons, ratings, reliability score, FAQ +4. **Customer reviews** — 1-5 star ratings, written review text +5. **Vehicle comparison** — side-by-side +6. **Saved vehicles** — heart icon, per-user list +7. **Store locator** — 42 states, ~250 stores +8. **Pre-qualification** — soft credit check → personalized monthly payment terms +9. **Sell my car** — appraisal form, instant offer valid 7 days +10. **Reserve a vehicle** — hold up to 7 days +11. **Test drive scheduling** — in-store or at-home +12. **Order/Checkout** — buy reserved vehicle online +13. **MaxCare** — extended warranty info +14. **Articles** — content marketing posts +15. **My account** — saved cars, offers, orders, reservations, test drives, pre-qual status + +## Vehicle data shape + +Identity: year, make, model, trim, body_style, stock_number, vin, slug + +Specs: engine_text, horsepower, torque, transmission, drive_type, fuel_type, +fuel_economy_{city,highway,combined}, seating_capacity, cargo_volume_cuft, +wheelbase, length, width, height + +Commercial: price, list_price (MSRP), mileage, days_on_lot, exterior_color, +interior_color, store_id, transfer_fee, is_certified (always True), is_featured, +is_no_haggle (always True), customer_rating, customer_rating_count, repairpal_rating + +Features (json list, sample tokens from real carmax research pages): +- Apple CarPlay, Android Auto, Bluetooth, Navigation System, BOSE Sound System, + AM/FM Stereo, Satellite Radio Ready, Auxiliary Audio Input +- Backup/Rear View Camera, Blind Spot Monitor, Lane Departure Warning, + Automated Cruise Control, Parking Sensors +- Heated Seats, Leather Seats, Power Seats, Power Windows, Power Locks, + Power Mirrors, Heated Mirrors, Remote Start, Smart Key +- Sunroof, Alloy Wheels, Rear Spoiler, Turbo Charged Engine, Manual Transmission +- ABS Brakes, Traction Control, Side Airbags, Overhead Airbags, Air Conditioning, + Rear Defroster + +## Brand visual identity + +- **Primary**: deep navy blue (carmax-blue ≈ `#1660a8` / `#0d3a72`) +- **Accent**: bright yellow (carmax-yellow ≈ `#FFD900` / `#FFC600`) +- **Background**: white + light gray (`#f7f7f7`) +- **Text**: near-black (`#202020`) on white; white on dark blue +- **Logo**: black "CarMax" wordmark, often paired with a yellow box icon +- **Vehicle cards**: white card on light gray, image top, price + mileage prominent, + CarMax Certified badge, transfer fee badge, "shipping available" pill +- **CTAs**: yellow buttons with dark text (primary), navy outline buttons (secondary) + +## Real image URL patterns (for the scrape script) + +``` +https://content-images.carmax.com/stockimages////--evoxwebmedium.png + views: 089 (angled-front), 174 (dashboard), 118 (front), 037 (side), 119 (back), 122 (cargo) + +https://content-images.carmax.com/qeontfmijmzv///.jpg + Contentful CDN for article hero images & lifestyle photos + +/stores/images/CarMax-Icon-Yellow-BOX-HEX.png — logo icon +``` + +## Sales pitch / copy elements + +- "CarMax Certified" (125+ point inspection, no flood/frame damage, no salvage) +- "10-day Money Back Guarantee" +- "30-day limited warranty" (60-day in CT/MN/RI, 90-day in MA/NJ/NY) +- "MaxCare extended service plan" +- "Free vehicle history report" +- "Upfront, no-haggle prices — same price for everyone" +- "Pre-qualified in 5 minutes, no impact to credit" +- "Real offer in under 2 minutes, valid 7 days" +- "Largest used car inventory in the nation, ~50,000 vehicles" +- "We'll buy your car even if you don't buy ours®" +- Shipping fee disclosure: "non-refundable transfer fee may apply" + +## Store distribution (informs Store seed) + +Sample from /stores page: AL 6, AZ 5, AR 1, CA 34, CO 6, CT 3, DE 1, FL 24, +GA 13, ID 1, IL 11, IN 4, IA 1, KS 2, KY 3, LA 5, ME 1, MD 9, MA 4, MI 1, +MN 2, MS 3, MO 4, NE 1, NV 4, NH 1, NJ 6, NM 2, NY 5, NC 13, OH 6, OK 3, +OR 3, PA 5, RI 1, SC 4, TN 10, TX 29, UT 1, VA 12, WA 7, WI 4. Total ≈250. + +For seed we'll sample ~12 stores across diverse states (CA, TX, FL, GA, NY, +IL, VA, AZ, CO, NC, WA, MA). + +## Trim/feature taxonomy (real, from research pages) + +Honda Civic 2022 trims observed: LX, Sport, Sport Touring, EX, EX-L, Touring, SI +Body styles in catalog: Sedan, Hatchback, Coupe, SUV, Truck, Minivan, Convertible, Wagon +Engine sizes observed: 1.5L Turbo, 2.0L NA, 2.4L, 3.5L V6, 5.0L V8, 5.3L V8, 2.5L hybrid + +## Sources + +- https://www.carmax.com/ +- https://www.carmax.com/cars +- https://www.carmax.com/cars/honda/civic/2022 +- https://www.carmax.com/research/honda/civic/2022 +- https://www.carmax.com/stores +- https://www.carmax.com/articles/carmax-questions-answered +- https://www.carmax.com/articles/pre-approval-vs-pre-qualified +- https://www.carmax.com/sell-my-car (referenced) +- https://www.carmax.com/value (referenced) +- https://www.carmax.com/pre-qual/app (referenced) + +WebVoyager upstream_url for tasks: `https://www.carmax.com/` diff --git a/sites/carmax/seed_data.py b/sites/carmax/seed_data.py new file mode 100644 index 0000000..8b8a859 --- /dev/null +++ b/sites/carmax/seed_data.py @@ -0,0 +1,900 @@ +"""CarMax mirror seed data. + +Both seed_database() and seed_benchmark_users() are idempotent at the +function level (early-return on populated DB). All inserted rows use +frozen timestamps so the resulting SQLite file is byte-stable across +boots, which is critical for the WebHarbor reset invariant. + +Image paths point into static/images/vehicles/-.jpg. Those +files are populated by scripts/scrape_carmax.py (Playwright). Until the +scraper has run, templates fall back to _pending.svg via onerror. +""" +import json +from datetime import date, datetime, timedelta + +from app import (Appraisal, Article, ComparisonItem, Comparison, + FinancePreQual, Order, Reservation, Review, SavedVehicle, + Store, TestDrive, User, Vehicle, bcrypt, db) + +# Frozen wall-clock so seeded rows are byte-stable across reset cycles. +SEED_NOW = datetime(2026, 1, 15, 12, 0, 0) +TODAY = date(2026, 5, 14) + + +def _slug(s): + import re + s = (s or '').lower().strip() + return re.sub(r'[^a-z0-9]+', '-', s).strip('-') + + +# ============================================================================= +# Stores: 12 real CarMax locations (public-info addresses) +# ============================================================================= + +STORES = [ + # slug, name, street, city, state, zip, phone, lat, lon + ('atlanta-southlake', 'CarMax Atlanta Southlake', + '6889 Mount Zion Blvd', 'Morrow', 'GA', '30260', + '(770) 477-1480', 33.580, -84.336), + ('houston-katy', 'CarMax Houston Katy', + '21015 Katy Fwy', 'Katy', 'TX', '77449', + '(281) 599-9890', 29.789, -95.741), + ('miami-kendall', 'CarMax Miami Kendall', + '11400 SW 88th St', 'Miami', 'FL', '33176', + '(305) 271-7000', 25.685, -80.371), + ('los-angeles-buena-park', 'CarMax Los Angeles Buena Park', + '6101 Auto Center Dr', 'Buena Park', 'CA', '90621', + '(714) 522-3690', 33.866, -118.011), + ('chicago-tinley-park', 'CarMax Chicago Tinley Park', + '8800 W 159th St', 'Tinley Park', 'IL', '60477', + '(708) 532-1480', 41.595, -87.795), + ('new-york-white-plains', 'CarMax White Plains', + '120 Westchester Ave', 'White Plains', 'NY', '10601', + '(914) 824-7100', 41.030, -73.770), + ('washington-laurel', 'CarMax Laurel', + '8800 Freestate Dr', 'Laurel', 'MD', '20723', + '(301) 776-0070', 39.099, -76.880), + ('boston-norwood', 'CarMax Boston Norwood', + '500 Providence Hwy', 'Norwood', 'MA', '02062', + '(781) 762-7600', 42.193, -71.198), + ('seattle-lynnwood', 'CarMax Seattle Lynnwood', + '17900 Highway 99', 'Lynnwood', 'WA', '98037', + '(425) 670-0091', 47.834, -122.293), + ('phoenix-tempe', 'CarMax Phoenix Tempe', + '8000 S Autoplex Loop', 'Tempe', 'AZ', '85284', + '(480) 753-0200', 33.346, -111.962), + ('denver-thornton', 'CarMax Denver Thornton', + '14150 Lincoln St', 'Thornton', 'CO', '80023', + '(303) 252-7800', 39.954, -104.987), + ('raleigh-cary', 'CarMax Raleigh Cary', + '601 Davis Dr', 'Cary', 'NC', '27513', + '(919) 467-1000', 35.795, -78.795), +] + + +# ============================================================================= +# Vehicle template catalog. Each template generates several vehicles. +# Fields: (make, model, body_style, [trim1,trim2,...], [yr,yr,...], +# engine_text, hp, torque, transmission, drive_type, fuel_type, +# mpg_city, mpg_hwy, seats, msrp_new, base_features, [colors]) +# ============================================================================= + +TEMPLATES = [ + ('Honda', 'Civic', 'Sedan', + ['LX', 'Sport', 'EX', 'Touring'], + [2020, 2021, 2022, 2023], + '2.0L I-4', 158, 138, 'CVT Automatic', 'FWD', 'Gasoline', + 31, 40, 5, 24500, + ['Apple CarPlay', 'Android Auto', 'Backup Camera', 'Bluetooth Technology', + 'Lane Departure Warning', 'Automated Cruise Control'], + ['Modern Steel Metallic', 'Aegean Blue Metallic', 'Crystal Black Pearl', + 'Lunar Silver Metallic', 'Sonic Gray Pearl']), + ('Honda', 'Accord', 'Sedan', + ['LX', 'Sport', 'EX-L', 'Touring'], + [2019, 2020, 2021, 2022], + '1.5L Turbo I-4', 192, 192, 'CVT Automatic', 'FWD', 'Gasoline', + 30, 38, 5, 28500, + ['Apple CarPlay', 'Android Auto', 'Heated Seats', 'Leather Seats', + 'Lane Departure Warning', 'Automated Cruise Control', 'Sunroof', + 'BOSE Sound System'], + ['Modern Steel Metallic', 'Platinum White Pearl', 'Crystal Black Pearl', + 'Radiant Red Metallic', 'Lunar Silver Metallic']), + ('Honda', 'CR-V', 'SUV', + ['LX', 'EX', 'EX-L', 'Touring'], + [2019, 2020, 2021, 2022, 2023], + '1.5L Turbo I-4', 190, 179, 'CVT Automatic', 'AWD', 'Gasoline', + 27, 32, 5, 31200, + ['Apple CarPlay', 'Android Auto', 'Backup Camera', 'Blind Spot Monitor', + 'Heated Seats', 'Power Seats', 'Sunroof', 'Automated Cruise Control'], + ['Crystal Black Pearl', 'Modern Steel Metallic', 'Platinum White Pearl', + 'Radiant Red Metallic', 'Sonic Gray Pearl']), + ('Honda', 'Pilot', 'SUV', + ['LX', 'EX-L', 'Touring'], + [2020, 2021, 2022], + '3.5L V-6', 280, 262, '9-Speed Automatic', 'AWD', 'Gasoline', + 19, 26, 8, 38500, + ['Apple CarPlay', 'Android Auto', 'Leather Seats', 'Power Seats', + 'Heated Seats', 'Blind Spot Monitor', 'Navigation System', 'Sunroof', + 'Third Row Seating'], + ['Modern Steel Metallic', 'Platinum White Pearl', 'Crystal Black Pearl', + 'Forest Mist Metallic']), + ('Toyota', 'Camry', 'Sedan', + ['LE', 'SE', 'XLE', 'XSE'], + [2019, 2020, 2021, 2022, 2023], + '2.5L I-4', 203, 184, '8-Speed Automatic', 'FWD', 'Gasoline', + 28, 39, 5, 27000, + ['Apple CarPlay', 'Android Auto', 'Backup Camera', 'Bluetooth Technology', + 'Lane Departure Warning', 'Automated Cruise Control'], + ['Midnight Black Metallic', 'Celestial Silver Metallic', 'Predawn Gray Mica', + 'Wind Chill Pearl', 'Supersonic Red']), + ('Toyota', 'Corolla', 'Sedan', + ['L', 'LE', 'SE', 'XSE'], + [2020, 2021, 2022, 2023], + '2.0L I-4', 169, 151, 'CVT Automatic', 'FWD', 'Gasoline', + 31, 40, 5, 22500, + ['Apple CarPlay', 'Android Auto', 'Backup Camera', 'Bluetooth Technology', + 'Lane Departure Warning'], + ['Classic Silver Metallic', 'Midnight Black Metallic', 'Blizzard Pearl', + 'Blueprint', 'Barcelona Red Metallic']), + ('Toyota', 'RAV4', 'SUV', + ['LE', 'XLE', 'XLE Premium', 'Limited'], + [2019, 2020, 2021, 2022, 2023], + '2.5L I-4', 203, 184, '8-Speed Automatic', 'AWD', 'Gasoline', + 27, 35, 5, 30200, + ['Apple CarPlay', 'Android Auto', 'Blind Spot Monitor', 'Sunroof', + 'Power Seats', 'Heated Seats', 'Automated Cruise Control'], + ['Magnetic Gray Metallic', 'Midnight Black Metallic', 'Blueprint', + 'Lunar Rock', 'Ruby Flare Pearl']), + ('Toyota', 'Tacoma', 'Truck', + ['SR', 'SR5', 'TRD Sport', 'TRD Off-Road'], + [2019, 2020, 2021, 2022, 2023], + '3.5L V-6', 278, 265, '6-Speed Automatic', '4WD', 'Gasoline', + 18, 22, 5, 32800, + ['Apple CarPlay', 'Android Auto', 'Backup Camera', 'Bluetooth Technology', + 'Skid Plates', 'Bed Liner', 'Tow Hitch'], + ['Magnetic Gray Metallic', 'Midnight Black Metallic', 'Silver Sky Metallic', + 'Cement', 'Army Green']), + ('Toyota', 'Highlander', 'SUV', + ['LE', 'XLE', 'Limited'], + [2020, 2021, 2022], + '3.5L V-6', 295, 263, '8-Speed Automatic', 'AWD', 'Gasoline', + 20, 27, 8, 36500, + ['Apple CarPlay', 'Android Auto', 'Third Row Seating', 'Power Seats', + 'Heated Seats', 'Leather Seats', 'Navigation System', 'Blind Spot Monitor'], + ['Midnight Black Metallic', 'Magnetic Gray Metallic', 'Celestial Silver', + 'Blueprint', 'Wind Chill Pearl']), + ('Ford', 'F-150', 'Truck', + ['XL', 'XLT', 'Lariat', 'King Ranch'], + [2019, 2020, 2021, 2022, 2023], + '5.0L V-8', 400, 410, '10-Speed Automatic', '4WD', 'Gasoline', + 16, 22, 5, 42000, + ['Apple CarPlay', 'Android Auto', 'Backup Camera', 'Tow Package', + 'Power Seats', 'Heated Seats', 'Bluetooth Technology', 'Bed Liner'], + ['Oxford White', 'Agate Black Metallic', 'Iconic Silver Metallic', + 'Velocity Blue Metallic', 'Race Red']), + ('Ford', 'Explorer', 'SUV', + ['Base', 'XLT', 'Limited', 'Platinum'], + [2019, 2020, 2021, 2022], + '2.3L Turbo I-4', 300, 310, '10-Speed Automatic', 'AWD', 'Gasoline', + 20, 28, 7, 35700, + ['Apple CarPlay', 'Android Auto', 'Third Row Seating', 'Power Seats', + 'Heated Seats', 'Sunroof', 'Navigation System'], + ['Agate Black Metallic', 'Iconic Silver Metallic', 'Oxford White', + 'Atlas Blue Metallic', 'Rapid Red Metallic']), + ('Ford', 'Mustang', 'Coupe', + ['EcoBoost', 'GT Premium', 'Mach 1'], + [2019, 2020, 2021, 2022], + '5.0L V-8', 460, 420, '10-Speed Automatic', 'RWD', 'Gasoline', + 15, 24, 4, 37000, + ['Apple CarPlay', 'Android Auto', 'Backup Camera', 'Leather Seats', + 'Heated Seats', 'Bluetooth Technology', 'Sunroof'], + ['Oxford White', 'Race Red', 'Shadow Black', 'Velocity Blue Metallic', + 'Twister Orange Metallic']), + ('Ford', 'Escape', 'SUV', + ['S', 'SE', 'Titanium'], + [2019, 2020, 2021, 2022], + '1.5L Turbo I-3', 181, 190, '8-Speed Automatic', 'AWD', 'Gasoline', + 27, 33, 5, 27800, + ['Apple CarPlay', 'Android Auto', 'Backup Camera', 'Lane Departure Warning', + 'Automated Cruise Control'], + ['Agate Black Metallic', 'Oxford White', 'Iconic Silver Metallic', + 'Atlas Blue Metallic']), + ('Chevrolet', 'Silverado', 'Truck', + ['WT', 'Custom', 'LT', 'RST', 'High Country'], + [2019, 2020, 2021, 2022, 2023], + '5.3L V-8', 355, 383, '8-Speed Automatic', '4WD', 'Gasoline', + 15, 21, 6, 44000, + ['Apple CarPlay', 'Android Auto', 'Backup Camera', 'Tow Package', + 'Bed Liner', 'Bluetooth Technology', 'Power Seats'], + ['Summit White', 'Silver Ice Metallic', 'Black', 'Northsky Blue Metallic', + 'Cherry Red Tintcoat']), + ('Chevrolet', 'Equinox', 'SUV', + ['L', 'LS', 'LT', 'Premier'], + [2019, 2020, 2021, 2022], + '1.5L Turbo I-4', 170, 203, '6-Speed Automatic', 'AWD', 'Gasoline', + 26, 31, 5, 27600, + ['Apple CarPlay', 'Android Auto', 'Backup Camera', 'Blind Spot Monitor', + 'Bluetooth Technology'], + ['Summit White', 'Silver Ice Metallic', 'Mosaic Black Metallic', + 'Pacific Blue Metallic', 'Cayenne Orange Metallic']), + ('Chevrolet', 'Tahoe', 'SUV', + ['LS', 'LT', 'Premier'], + [2020, 2021, 2022, 2023], + '5.3L V-8', 355, 383, '10-Speed Automatic', '4WD', 'Gasoline', + 14, 19, 8, 56000, + ['Apple CarPlay', 'Android Auto', 'Third Row Seating', 'Leather Seats', + 'Heated Seats', 'Power Seats', 'Navigation System', 'Sunroof'], + ['Summit White', 'Black', 'Empire Beige Metallic', 'Northsky Blue Metallic', + 'Silver Ice Metallic']), + ('Nissan', 'Altima', 'Sedan', + ['S', 'SV', 'SR', 'SL'], + [2019, 2020, 2021, 2022, 2023], + '2.5L I-4', 188, 180, 'CVT Automatic', 'FWD', 'Gasoline', + 28, 39, 5, 25300, + ['Apple CarPlay', 'Android Auto', 'Backup Camera', 'Bluetooth Technology', + 'Lane Departure Warning'], + ['Super Black', 'Brilliant Silver Metallic', 'Pearl White Tricoat', + 'Storm Blue Metallic', 'Scarlet Ember Tintcoat']), + ('Nissan', 'Rogue', 'SUV', + ['S', 'SV', 'SL', 'Platinum'], + [2019, 2020, 2021, 2022, 2023], + '2.5L I-4', 181, 181, 'CVT Automatic', 'AWD', 'Gasoline', + 27, 34, 5, 27800, + ['Apple CarPlay', 'Android Auto', 'Backup Camera', 'Blind Spot Monitor', + 'Power Seats', 'Heated Seats'], + ['Super Black', 'Brilliant Silver Metallic', 'Pearl White Tricoat', + 'Caspian Blue Metallic', 'Scarlet Ember Tintcoat']), + ('Hyundai', 'Elantra', 'Sedan', + ['SE', 'SEL', 'Limited'], + [2020, 2021, 2022, 2023], + '2.0L I-4', 147, 132, 'CVT Automatic', 'FWD', 'Gasoline', + 33, 42, 5, 21300, + ['Apple CarPlay', 'Android Auto', 'Backup Camera', 'Lane Departure Warning', + 'Bluetooth Technology', 'Automated Cruise Control'], + ['Phantom Black', 'Phantom Black Pearl', 'Quartz White Pearl', + 'Cyber Gray', 'Lava Orange', 'Intense Blue']), + ('Hyundai', 'Tucson', 'SUV', + ['SE', 'SEL', 'Limited'], + [2020, 2021, 2022, 2023], + '2.5L I-4', 187, 178, '8-Speed Automatic', 'AWD', 'Gasoline', + 24, 29, 5, 28400, + ['Apple CarPlay', 'Android Auto', 'Backup Camera', 'Blind Spot Monitor', + 'Heated Seats', 'Power Seats'], + ['Phantom Black', 'Shimmering Silver', 'Magnetic Force Metallic', + 'Intense Blue', 'Quartz White Pearl']), + ('Hyundai', 'Santa Fe', 'SUV', + ['SE', 'SEL', 'Limited'], + [2019, 2020, 2021, 2022], + '2.5L Turbo I-4', 277, 311, '8-Speed Automatic', 'AWD', 'Gasoline', + 22, 28, 5, 31800, + ['Apple CarPlay', 'Android Auto', 'Leather Seats', 'Heated Seats', + 'Power Seats', 'Blind Spot Monitor', 'Navigation System'], + ['Phantom Black', 'Quartz White Pearl', 'Magnetic Force', + 'Calypso Red', 'Lagoon Blue']), + ('Kia', 'Sportage', 'SUV', + ['LX', 'EX', 'SX Turbo'], + [2019, 2020, 2021, 2022], + '2.4L I-4', 181, 175, '6-Speed Automatic', 'AWD', 'Gasoline', + 22, 28, 5, 25800, + ['Apple CarPlay', 'Android Auto', 'Backup Camera', 'Bluetooth Technology', + 'Blind Spot Monitor'], + ['Snow White Pearl', 'Steel Gray', 'Pacific Blue', 'Hyper Red', + 'Black Cherry']), + ('Kia', 'Sorento', 'SUV', + ['LX', 'EX', 'SX Prestige'], + [2019, 2020, 2021, 2022, 2023], + '2.5L Turbo I-4', 281, 311, '8-Speed Automatic', 'AWD', 'Gasoline', + 21, 28, 7, 31900, + ['Apple CarPlay', 'Android Auto', 'Third Row Seating', 'Leather Seats', + 'Heated Seats', 'Power Seats', 'Sunroof'], + ['Snow White Pearl', 'Ebony Black', 'Steel Gray', 'Sapphire Blue', + 'Runway Red']), + ('Jeep', 'Grand Cherokee', 'SUV', + ['Laredo', 'Limited', 'Overland', 'Summit'], + [2019, 2020, 2021, 2022, 2023], + '3.6L V-6', 293, 260, '8-Speed Automatic', '4WD', 'Gasoline', + 19, 26, 5, 41000, + ['Apple CarPlay', 'Android Auto', 'Leather Seats', 'Heated Seats', + 'Power Seats', 'Navigation System', 'Sunroof', 'Blind Spot Monitor'], + ['Diamond Black Crystal', 'Bright White', 'Velvet Red Pearl', + 'Granite Crystal Metallic', 'Hydro Blue Pearl']), + ('Jeep', 'Wrangler', 'SUV', + ['Sport', 'Sport S', 'Rubicon', 'Sahara'], + [2019, 2020, 2021, 2022, 2023], + '3.6L V-6', 285, 260, '8-Speed Automatic', '4WD', 'Gasoline', + 17, 23, 4, 33500, + ['Apple CarPlay', 'Android Auto', 'Backup Camera', 'Skid Plates', + 'Tow Hooks', 'Bluetooth Technology'], + ['Bright White', 'Black', 'Firecracker Red', 'Sting-Gray', + 'Hellayella', 'Sarge Green']), + ('Subaru', 'Outback', 'Wagon', + ['Base', 'Premium', 'Limited', 'Touring'], + [2019, 2020, 2021, 2022, 2023], + '2.5L I-4', 182, 176, 'CVT Automatic', 'AWD', 'Gasoline', + 26, 33, 5, 28800, + ['Apple CarPlay', 'Android Auto', 'Backup Camera', 'Blind Spot Monitor', + 'Heated Seats', 'Power Seats', 'Automated Cruise Control'], + ['Crystal White Pearl', 'Crystal Black Silica', 'Ice Silver Metallic', + 'Abyss Blue Pearl', 'Autumn Green Metallic']), + ('Subaru', 'Forester', 'SUV', + ['Base', 'Premium', 'Sport', 'Limited'], + [2019, 2020, 2021, 2022], + '2.5L I-4', 182, 176, 'CVT Automatic', 'AWD', 'Gasoline', + 26, 33, 5, 27400, + ['Apple CarPlay', 'Android Auto', 'Backup Camera', 'Lane Departure Warning', + 'Blind Spot Monitor', 'Heated Seats'], + ['Crystal White Pearl', 'Crystal Black Silica', 'Magnetite Gray', + 'Horizon Blue Pearl', 'Jasper Green Metallic']), + ('Mazda', 'CX-5', 'SUV', + ['Sport', 'Touring', 'Grand Touring'], + [2019, 2020, 2021, 2022, 2023], + '2.5L I-4', 187, 186, '6-Speed Automatic', 'AWD', 'Gasoline', + 24, 30, 5, 28250, + ['Apple CarPlay', 'Android Auto', 'Backup Camera', 'Leather Seats', + 'Heated Seats', 'Power Seats', 'Blind Spot Monitor', 'Sunroof'], + ['Jet Black Mica', 'Snowflake White Pearl Mica', 'Sonic Silver Metallic', + 'Soul Red Crystal Metallic', 'Polymetal Gray Metallic']), + ('BMW', '3 Series', 'Sedan', + ['330i', '330i xDrive', 'M340i'], + [2019, 2020, 2021, 2022], + '2.0L Turbo I-4', 255, 295, '8-Speed Automatic', 'AWD', 'Gasoline', + 26, 36, 5, 44000, + ['Apple CarPlay', 'Android Auto', 'Leather Seats', 'Heated Seats', + 'Power Seats', 'Navigation System', 'Sunroof', 'BOSE Sound System'], + ['Alpine White', 'Black Sapphire Metallic', 'Mineral Gray Metallic', + 'Portimao Blue Metallic', 'Skyscraper Gray Metallic']), + ('Mercedes-Benz', 'C-Class', 'Sedan', + ['C300', 'C300 4MATIC', 'AMG C43'], + [2019, 2020, 2021, 2022], + '2.0L Turbo I-4', 255, 273, '9-Speed Automatic', 'AWD', 'Gasoline', + 25, 35, 5, 45000, + ['Apple CarPlay', 'Android Auto', 'Leather Seats', 'Heated Seats', + 'Navigation System', 'Sunroof', 'Blind Spot Monitor'], + ['Polar White', 'Obsidian Black Metallic', 'Iridium Silver Metallic', + 'Mojave Silver Metallic', 'Selenite Gray Metallic']), + ('Tesla', 'Model 3', 'Sedan', + ['Standard Range Plus', 'Long Range', 'Performance'], + [2019, 2020, 2021, 2022, 2023], + 'Dual Electric Motors', 346, 389, 'Single-Speed', 'AWD', 'Electric', + 132, 126, 5, 49000, + ['Backup Camera', 'Automated Cruise Control', 'Lane Departure Warning', + 'Heated Seats', 'Power Seats', 'Navigation System', 'Sunroof'], + ['Pearl White Multi-Coat', 'Solid Black', 'Midnight Silver Metallic', + 'Deep Blue Metallic', 'Red Multi-Coat']), +] + + +# Interior colors keyed by exterior — short, deterministic mapping +INTERIOR_COLORS = ['Black', 'Gray', 'Beige', 'Brown'] + +# Trim feature accruals: each successive trim inherits prior + adds +TRIM_FEATURE_ADDONS = [ + [], # trim 0 (base): no extras + ['Power Seats', 'Power Windows', 'Power Locks', 'Cruise Control'], # trim 1 + ['Heated Seats', 'Sunroof', 'Blind Spot Monitor', 'Smart Key', + 'Power Mirrors', 'Heated Mirrors'], # trim 2 + ['Leather Seats', 'Navigation System', 'BOSE Sound System', + 'Remote Start', 'Parking Sensors'], # trim 3 + ['Premium Sound', 'Wireless Charging', 'Premium Wheels'], # trim 4+ +] + + +def _vehicle_for(template_idx, trim_idx, year_idx, store_idx, color_idx, + mileage_seed): + """Deterministically build one vehicle from a template + indexed choices.""" + t = TEMPLATES[template_idx] + (make, model, body_style, trims, years, engine, hp, torque, + trans, drive, fuel, mpgc, mpgh, seats, msrp, base_feats, colors) = t + trim = trims[trim_idx] + year = years[year_idx] + color = colors[color_idx % len(colors)] + interior = INTERIOR_COLORS[(trim_idx + year_idx) % len(INTERIOR_COLORS)] + + # Features accrue by trim level + feats = list(base_feats) + for i in range(trim_idx + 1): + if i < len(TRIM_FEATURE_ADDONS): + for f in TRIM_FEATURE_ADDONS[i]: + if f not in feats: + feats.append(f) + # Sport/Performance/SI/Type/AMG/Performance/Touring extras + sporty = any(w in trim for w in ('Sport', 'SI', 'AMG', 'Performance', + 'Mach', 'TRD', 'Rubicon', 'M340')) + if sporty: + for f in ('Rear Spoiler', 'Alloy Wheels', 'Turbo Charged Engine'): + if f not in feats: + feats.append(f) + if 'Electric' in fuel: + feats = [f for f in feats if 'Turbo' not in f] + feats.append('All-Electric Drivetrain') + feats.append('CarMax Certified') + + # Depreciation: 18% first year + 11% subsequent (so 5-yr ~ 50%) + age = max(2026 - year, 0) + if age == 0: + depr = 1.0 + else: + depr = 0.82 * (0.89 ** (age - 1)) + trim_premium = 1.0 + 0.04 * trim_idx + price = msrp * depr * trim_premium + price = round(price / 100) * 100 # round to $100 + list_price = round(msrp * (1.0 + 0.02 * trim_idx) / 100) * 100 + + # Mileage: deterministic from age + seed + expected_per_year = 11500 + (mileage_seed % 3) * 1500 + mileage = age * expected_per_year + (mileage_seed % 1500) + mileage = max(2400, mileage) + + # Stock + VIN: deterministic + stock = f"{template_idx:02d}{trim_idx}{year_idx}{store_idx:02d}{color_idx % 6}{mileage_seed % 10}".ljust(8, '0')[:8] + vin = f"1HG{template_idx:02d}{trim_idx}{year_idx % 10}{store_idx:02d}{(template_idx*97 + trim_idx*53 + year_idx*7) % 1000:03d}{color_idx:03d}".replace(' ', '0')[:17].upper() + + slug = _slug(f"{year}-{make}-{model}-{trim}-{stock}") + + image = f"/static/images/vehicles/{stock}-front.jpg" + gallery = [ + f"/static/images/vehicles/{stock}-front.jpg", + f"/static/images/vehicles/{stock}-side.jpg", + f"/static/images/vehicles/{stock}-rear.jpg", + f"/static/images/vehicles/{stock}-dashboard.jpg", + f"/static/images/vehicles/{stock}-cargo.jpg", + f"/static/images/vehicles/{stock}-interior.jpg", + ] + + description = (f"This {year} {make} {model} {trim} comes equipped with a " + f"{engine.lower()} engine, {trans.lower()}, " + f"and {drive} drivetrain. It has been CarMax Certified through our " + f"125+ point inspection — no flood or frame damage, no salvage " + f"history. Eligible for our 30-day limited warranty and " + f"10-day money back guarantee.") + + transfer_fee = 0 if mileage_seed % 4 == 0 else ( + 99 if mileage_seed % 4 == 1 else (199 if mileage_seed % 4 == 2 else 399)) + + days_on_lot = 4 + ((template_idx * 7 + trim_idx * 13 + year_idx * 19 + + store_idx * 23 + mileage_seed) % 40) + + customer_rating = round(3.8 + ((template_idx + year_idx) % 13) / 10.0, 1) + customer_rating_count = 6 + (template_idx + trim_idx * 3) % 24 + repairpal = round(3.5 + (template_idx % 16) / 10.0, 1) + + # Featured/new-arrival/price-drop flags (deterministic) + is_featured = (template_idx + trim_idx) % 7 == 0 + is_new_arrival = days_on_lot <= 9 + is_price_drop = (mileage_seed % 5) == 0 and (list_price > price) + + return { + 'stock_number': stock, + 'slug': slug, + 'vin': vin, + 'year': year, + 'make': make, + 'make_slug': _slug(make), + 'model': model, + 'model_slug': _slug(model), + 'trim': trim, + 'trim_slug': _slug(trim), + 'body_style': body_style, + 'exterior_color': color, + 'interior_color': interior, + 'mileage': mileage, + 'price': price, + 'list_price': list_price, + 'engine_text': engine, + 'engine_displacement': float(engine.split('L')[0].split(' ')[-1]) if 'L' in engine else 0.0, + 'horsepower': hp, + 'torque': torque, + 'transmission': trans, + 'drive_type': drive, + 'fuel_type': fuel, + 'mpg_city': mpgc, + 'mpg_highway': mpgh, + 'mpg_combined': (mpgc + mpgh) // 2 if fuel != 'Electric' else (mpgc + mpgh) // 2, + 'seating_capacity': seats, + 'cargo_volume': 14.0 + (template_idx % 30), + 'wheelbase': 105.0 + (template_idx % 25), + 'overall_length': 178.0 + (template_idx % 40), + 'width': 70.0 + (template_idx % 8), + 'height': 55.0 + (template_idx % 18), + 'fuel_capacity': 13.0 + (template_idx % 12), + 'features': json.dumps(feats), + 'description': description, + 'image': image, + 'gallery_images': json.dumps(gallery), + 'customer_rating': customer_rating, + 'customer_rating_count': customer_rating_count, + 'repairpal_rating': repairpal, + 'is_certified': True, + 'is_featured': is_featured, + 'is_no_haggle': True, + 'is_new_arrival': is_new_arrival, + 'is_price_drop': is_price_drop, + 'store_id': store_idx + 1, # 1-indexed + 'transfer_fee': transfer_fee, + 'days_on_lot': days_on_lot, + 'added_at': SEED_NOW - timedelta(days=days_on_lot), + } + + +def _build_vehicle_seeds(): + """Deterministically iterate templates/trims/years/stores to ~150 vehicles.""" + rows = [] + counter = 0 + for ti, t in enumerate(TEMPLATES): + # Pick 5 vehicle variants per template (roughly): one per trim, varied year + trims = t[3] + years = t[4] + colors_count = len(t[14]) + n_variants = min(5, max(4, len(trims) + 1)) + for variant in range(n_variants): + trim_idx = variant % len(trims) + year_idx = (variant + ti) % len(years) + store_idx = (counter + ti) % len(STORES) + color_idx = (variant * 3 + ti) % colors_count + mileage_seed = (counter * 313 + ti * 97) % 9973 + rows.append(_vehicle_for(ti, trim_idx, year_idx, store_idx, + color_idx, mileage_seed)) + counter += 1 + return rows + + +# ============================================================================= +# Articles +# ============================================================================= + +ARTICLES = [ + ('how-carmax-works', + 'How CarMax Works — Buy, Sell, Finance', + 'research', True, + 'Shop online or in store, get pre-qualified, and enjoy our 10-day money-back guarantee and 30-day limited warranty.', + 'Shopping with CarMax is straightforward. We are customer-focused and want you to have a great car-buying experience. You can shop online, in-store, or a mix of both — whatever works best for you.\n\nEvery car we sell is CarMax Certified, which means no flood or frame damage and no salvage history. Each car undergoes a detailed 125+ point checklist by our trained technicians, and we will repair, replace, or detail anything necessary to meet our standards.\n\nWe are known for being upfront on our pricing — we do not haggle. For a stress-free and transparent customer experience, it is the same price for everyone.\n\nWe understand that buying a vehicle is a big decision, and you want to feel confident about getting it right the first time.'), + ('how-to-sell-your-car-to-carmax', + 'How to Sell Your Car to CarMax', + 'selling', True, + 'Get a real, upfront written offer in under 2 minutes. Good for 7 days. Sell or trade — your choice.', + 'Selling your car to CarMax is fast and transparent. You can start your offer online with just your license plate, mileage, and ZIP, then bring the car to any CarMax store for a brief verification.\n\nThe offer is good for 7 days. The price is the same whether you trade in or sell outright.\n\nOnline appraisals take about 2 minutes; in-store appraisals run 30-45 minutes including the test drive and inspection.'), + ('pre-approval-vs-pre-qualified', + 'Getting Pre-Qualified: Shop with Personalized Financing Terms', + 'financing', True, + 'Pre-qualification uses a soft credit check and gives you personalized monthly payments — no impact to your credit score.', + 'A pre-qualification reviews your current financial situation and credit history with a soft credit inquiry. It does not impact your credit score.\n\nPre-qualifying lets you shop with personalized terms — see actual monthly payments on every car. Final terms require a credit application, which results in a hard inquiry.\n\nAt CarMax, pre-qualifications are valid for 30 days.'), + ('best-compact-sedan-honda-civic-vs-toyota-corolla-vs-nissan-sentra', + 'Best Compact Sedan: Honda Civic vs. Toyota Corolla vs. Nissan Sentra', + 'research', False, + 'Three popular compact sedans compared on price, fuel economy, and features.', + 'The compact sedan segment remains one of the most popular for value-minded buyers. Here is how three of the best-sellers compare.\n\nThe Honda Civic offers refined driving dynamics and a sophisticated cabin. The Toyota Corolla brings legendary reliability and the lowest cost of ownership. The Nissan Sentra punches above its weight with standard advanced driver-assistance features.'), + ('best-hatchback-cars-ranking', + 'Best Used Hatchback Cars for 2024: Ranked', + 'research', False, + 'Practical, versatile, and surprisingly fun — our take on the best used hatchbacks.', + 'Hatchbacks deliver sedan-like fuel economy with SUV-like cargo flexibility. We ranked the most popular used hatchbacks based on sales data, reliability ratings, and cargo capacity.'), + ('how-to-buy-a-used-car', + 'How to Buy a Used Car: From Online to the Lot', + 'how-to', False, + 'A simple step-by-step guide to buying a used car the smart way.', + 'Step 1: Set your budget — or get pre-qualified to make that easy. Step 2: Narrow down body style, then make and model, using research pages. Step 3: Shop the nationwide inventory online; reserve any car for 7 days while you finish paperwork. Step 4: Test drive at the store or at home. Step 5: Finalize financing. Step 6: Take delivery.'), + ('maxcare-explained', + 'MaxCare Service Plans — Coverage Explained', + 'how-to', False, + 'Optional extended-warranty coverage that picks up where the limited warranty leaves off.', + 'MaxCare extended service plans run up to 60 months / 100,000 miles. Each plan includes hassle-free repairs at any licensed shop, 24/7 roadside assistance, and rental reimbursement up to $40/day. You can cancel any time.'), + ('first-time-car-buyer', + 'First-Time Car Buyer? Your Step-by-Step Guide', + 'how-to', False, + 'Building credit, picking a car, and financing your first vehicle without overpaying.', + 'Buying your first car is a big step. The two best things you can do up front: (1) understand your monthly budget, including insurance and fuel; (2) get pre-qualified so you know exactly what you can afford. CarMax has finance sources for first-time buyers.'), + ('best-high-mpg-cars', + 'Best High-MPG Used Cars', + 'research', False, + 'Looking for the best gas mileage? These used cars consistently top fuel economy lists.', + 'Hybrids dominate this list, but even non-hybrid compact sedans can clear 40 mpg highway. Top picks include the Toyota Corolla Hybrid, Honda Civic, Hyundai Elantra, and Toyota Camry.'), + ('attainable-dream-cars-under-50000', + 'Attainable Dream Cars Under $50,000', + 'research', False, + 'For the price of an average new car, you can drive something special.', + 'New cars now average around $50,000 — but the used market opens the door to driving something more exciting. Convertibles, sports coupes, and luxury SUVs at this price point are well within reach.'), +] + + +# ============================================================================= +# Customer reviews (one or two per popular model/year combo) +# ============================================================================= + +REVIEW_TEMPLATES = [ + # (make_slug, model_slug, year, rating, title, body, name, location) + ('honda', 'civic', 2022, 5, 'Best small sedan period', + 'Great gas mileage, refined cabin, and just enough power for everything I need to do. Highway road noise is the only knock.', + 'Marcus T.', 'Chicago, IL'), + ('honda', 'civic', 2022, 4, 'Solid choice for commuters', + 'Comfortable seats, easy to use Apple CarPlay, and reliable so far. The base 2.0L feels a bit slow on hills.', + 'Priya R.', 'Atlanta, GA'), + ('honda', 'accord', 2021, 5, 'Quiet, spacious, and well-built', + 'Roomy interior, smooth ride, and the Touring trim has every feature I wanted. Best sedan I have owned.', + 'David K.', 'Houston, TX'), + ('honda', 'cr-v', 2022, 5, 'Perfect family SUV', + 'Plenty of cargo space, comfortable for road trips, and AWD handles winter just fine. Fuel economy is impressive too.', + 'Sarah B.', 'Denver, CO'), + ('toyota', 'camry', 2022, 5, 'Reliable as ever', + 'Toyota nailed the redesign. Comfortable, gets great mileage, and the tech finally feels modern.', + 'Jennifer L.', 'Los Angeles, CA'), + ('toyota', 'rav4', 2021, 4, 'Great value crossover', + 'Honest, reliable SUV. Not the most fun to drive, but it does everything well and has been bulletproof.', + 'Mike P.', 'Seattle, WA'), + ('toyota', 'tacoma', 2021, 5, 'Best off-road truck for the money', + 'TRD Off-Road has handled everything I have thrown at it. Resale value is unreal.', + 'James W.', 'Phoenix, AZ'), + ('ford', 'f-150', 2022, 5, 'Workhorse and comfortable cruiser', + 'Towed my boat across three states without issue. Cabin is quiet and the SYNC system is finally good.', + 'Robert M.', 'Raleigh, NC'), + ('ford', 'mustang', 2021, 5, 'GT is a riot', + 'V8 sounds incredible, and the chassis is much better than people give it credit for. 10-speed automatic is buttery.', + 'Carlos D.', 'Miami, FL'), + ('chevrolet', 'silverado', 2022, 4, 'Capable and quiet', + 'Tows great, comfortable on long drives, but fuel economy is what you expect from a V8 truck.', + 'Linda H.', 'Houston, TX'), + ('chevrolet', 'tahoe', 2022, 5, 'Family hauler king', + 'Plenty of room for everyone and everything. The new independent rear suspension is a huge upgrade.', + 'Anthony S.', 'Atlanta, GA'), + ('nissan', 'altima', 2021, 4, 'Underrated sedan', + 'Quiet ride, good fuel economy, and the 2.0 turbo is plenty fast. Interior could be a bit nicer.', + 'Ashley N.', 'Tinley Park, IL'), + ('hyundai', 'tucson', 2022, 5, 'Bold redesign, great value', + 'The new styling is polarizing but I love it. Tech features rival cars twice the price.', + 'Wei C.', 'White Plains, NY'), + ('kia', 'sorento', 2022, 5, 'Three-row SUV that feels premium', + 'Comfortable interior, smooth turbo engine, and the third row is actually usable for short trips.', + 'Maria G.', 'Laurel, MD'), + ('jeep', 'wrangler', 2021, 5, 'Lives up to the legend', + 'Off-road capability is unmatched. On-road manners are quirky but that is part of the charm.', + 'Tyler J.', 'Buena Park, CA'), + ('subaru', 'outback', 2022, 5, 'Best all-weather wagon', + 'Standard AWD, generous cargo, and EyeSight has saved me from at least one rear-end. Highly recommend.', + 'Hannah O.', 'Norwood, MA'), + ('mazda', 'cx-5', 2021, 5, 'Most fun crossover in its class', + 'Steering feel and chassis tuning are a cut above. Beautiful cabin too.', + 'Daniel F.', 'Cary, NC'), + ('bmw', '3-series', 2021, 4, 'Still the sport sedan benchmark', + 'Engine and transmission are fantastic. iDrive takes some learning. Ride is firm.', + 'Elena V.', 'Thornton, CO'), + ('tesla', 'model-3', 2021, 5, 'No going back to gas', + 'Instant torque, never visit a gas station, and Supercharging on road trips is easy. Build quality has improved a lot.', + 'Naveen P.', 'Lynnwood, WA'), + ('toyota', 'corolla', 2022, 4, 'Just works', + 'Boring in the best way. Sips fuel, never breaks, easy to park. What more do you want from a commuter?', + 'Beth E.', 'Tempe, AZ'), +] + + +# ============================================================================= +# Seeding functions — IDEMPOTENT at the function level +# ============================================================================= + +def seed_database(): + """Create stores, vehicles, articles, reviews. Early-return if populated.""" + if Vehicle.query.count() > 0: + return + + # Stores + for slug, name, street, city, state, zip_code, phone, lat, lon in STORES: + s = Store(slug=slug, name=name, street=street, city=city, state=state, + zip_code=zip_code, phone=phone, latitude=lat, longitude=lon, + has_appraisal=True, has_express_pickup=True, + has_service=True, has_home_delivery=True, + hours_weekday='10:00 AM - 9:00 PM', + hours_saturday='9:00 AM - 9:00 PM', + hours_sunday='12:00 PM - 7:00 PM', + image='/static/images/stores/storefront_default.jpg') + db.session.add(s) + db.session.flush() + + # Vehicles — built deterministically from templates + seeds = _build_vehicle_seeds() + for s in seeds: + v = Vehicle(**s) + db.session.add(v) + + # Articles + for slug, title, category, featured, summary, body in ARTICLES: + pub = date(2025, 11, 1) + timedelta(days=(hash(slug) % 180)) + a = Article(slug=slug, title=title, category=category, + summary=summary, body=body, + hero_image=f"/static/images/articles/{slug}.jpg", + published_at=pub, is_featured=featured) + db.session.add(a) + + # Reviews — written by the system (no user_id, anonymized name) + for make_slug, model_slug, year, rating, title, body, name, loc in REVIEW_TEMPLATES: + r = Review(make_slug=make_slug, model_slug=model_slug, year=year, + rating=rating, title=title, body=body, + reviewer_name=name, location=loc, + created_at=SEED_NOW) + db.session.add(r) + + db.session.commit() + + +def seed_benchmark_users(): + """Five benchmark users used by WebVoyager tasks. Idempotent.""" + if User.query.filter_by(email='alice.j@test.com').first(): + return + + # Look up store IDs (slug -> id) deterministically + store_id_by_slug = {s.slug: s.id for s in Store.query.all()} + + users = [ + # (email, first, last, phone, zip, addr1, city, state, home_store_slug, + # prequal: (monthly_max, term, apr, down, tier, expires_offset), + # annual_income, employment_status) + ('alice.j@test.com', 'Alice', 'Johnson', '(404) 555-0118', '30303', + '410 Peachtree St NE', 'Atlanta', 'GA', 'atlanta-southlake', + (550.0, 72, 7.49, 2500.0, 'good', 30), 78000, 'employed_full_time'), + ('bob.k@test.com', 'Bob', 'Kim', '(713) 555-0119', '77002', + '1500 Louisiana St', 'Houston', 'TX', 'houston-katy', + (700.0, 60, 5.49, 5000.0, 'excellent', 30), 142000, 'employed_full_time'), + ('carol.l@test.com', 'Carol', 'Lopez', '(305) 555-0120', '33176', + '11400 SW 92nd Ct', 'Miami', 'FL', 'miami-kendall', + (425.0, 72, 11.99, 1500.0, 'fair', 30), 54000, 'self_employed'), + ('dan.m@test.com', 'Dan', 'Murphy', '(617) 555-0121', '02062', + '88 School St', 'Norwood', 'MA', 'boston-norwood', + None, 65000, 'employed_full_time'), + ('emma.n@test.com', 'Emma', 'Nguyen', '(206) 555-0122', '98037', + '17800 Highway 99', 'Lynnwood', 'WA', 'seattle-lynnwood', + (320.0, 66, 17.99, 1000.0, 'building', 30), 38000, 'student'), + ] + + user_objs = {} + for (email, first, last, phone, zip_code, addr1, city, state, + home_slug, prequal, income, emp) in users: + u = User( + email=email, + first_name=first, last_name=last, + phone=phone, zip_code=zip_code, + address_line1=addr1, city=city, state=state, + home_store_id=store_id_by_slug.get(home_slug), + annual_income=income, + employment_status=emp, + created_at=SEED_NOW, + ) + # Set deterministic password (bcrypt is randomized by salt; we need + # to set a stable password_hash by using a fixed pre-computed hash + # OR by setting one via set_password but accepting the salt churn. + # Since the salt randomness would break md5 stability, we use a + # pre-baked bcrypt hash for 'CarMax!2026' generated once. + # NOTE: bcrypt verification still works with this fixed hash. + u.password_hash = '$2b$12$abcdefghijklmnopqrstuuj6phTDGC0QgZUgJBeZsSqG7EdTlBv7K' + if prequal: + mmax, term, apr, down, tier, exp_off = prequal + u.pre_qual_active = True + u.pre_qual_monthly_max = mmax + u.pre_qual_term_months = term + u.pre_qual_apr = apr + u.pre_qual_down_payment = down + u.pre_qual_credit_tier = tier + u.pre_qual_expires_at = TODAY + timedelta(days=exp_off) + db.session.add(u) + user_objs[email] = u + db.session.flush() + + # FinancePreQual rows mirroring the pre-qual snapshots on users + for (email, first, last, phone, zip_code, addr1, city, state, + home_slug, prequal, income, emp) in users: + if not prequal: + continue + u = user_objs[email] + mmax, term, apr, down, tier, exp_off = prequal + pq = FinancePreQual( + user_id=u.id, annual_income=income, employment_status=emp, + monthly_payment_max=mmax, down_payment=down, term_months=term, + estimated_apr=apr, credit_tier=tier, status='active', + created_at=SEED_NOW, + expires_at=TODAY + timedelta(days=exp_off), + ) + db.session.add(pq) + + # Seed a handful of saved vehicles, reservations, test drives, appraisals, + # and an order so the benchmark accounts feel lived-in. + alice = user_objs['alice.j@test.com'] + bob = user_objs['bob.k@test.com'] + carol = user_objs['carol.l@test.com'] + dan = user_objs['dan.m@test.com'] + + # Pick deterministic vehicles by id range + v1 = db.session.get(Vehicle, 1) + v3 = db.session.get(Vehicle, 3) + v5 = db.session.get(Vehicle, 5) + v7 = db.session.get(Vehicle, 7) + v11 = db.session.get(Vehicle, 11) + v15 = db.session.get(Vehicle, 15) + v23 = db.session.get(Vehicle, 23) + v37 = db.session.get(Vehicle, 37) + + if v1: db.session.add(SavedVehicle(user_id=alice.id, vehicle_id=v1.id, saved_at=SEED_NOW)) + if v5: db.session.add(SavedVehicle(user_id=alice.id, vehicle_id=v5.id, saved_at=SEED_NOW)) + if v7: db.session.add(SavedVehicle(user_id=bob.id, vehicle_id=v7.id, saved_at=SEED_NOW)) + if v11: db.session.add(SavedVehicle(user_id=bob.id, vehicle_id=v11.id, saved_at=SEED_NOW)) + if v23: db.session.add(SavedVehicle(user_id=carol.id, vehicle_id=v23.id, saved_at=SEED_NOW)) + if v37: db.session.add(SavedVehicle(user_id=dan.id, vehicle_id=v37.id, saved_at=SEED_NOW)) + + if v3: + db.session.add(Reservation( + user_id=alice.id, vehicle_id=v3.id, store_id=v3.store_id, + status='active', + appointment_date=TODAY + timedelta(days=2), + expires_at=TODAY + timedelta(days=7), + transfer_required=False, transfer_fee=v3.transfer_fee or 0, + created_at=SEED_NOW)) + + if v11: + db.session.add(TestDrive( + user_id=bob.id, vehicle_id=v11.id, store_id=v11.store_id, + location_type='in_store', + scheduled_date=TODAY + timedelta(days=3), + scheduled_time='2:00 PM', + status='confirmed', notes='', + created_at=SEED_NOW)) + + if v15: + db.session.add(TestDrive( + user_id=alice.id, vehicle_id=v15.id, store_id=v15.store_id, + location_type='at_home', + scheduled_date=TODAY + timedelta(days=5), + scheduled_time='4:00 PM', + status='confirmed', notes='Please call when you arrive.', + created_at=SEED_NOW)) + + # Appraisals for benchmark users — deterministic offers + db.session.add(Appraisal( + user_id=alice.id, year=2017, make='Toyota', model='Camry', trim='LE', + mileage=78500, condition='good', + exterior_color='Celestial Silver Metallic', + license_plate='AJC2017', license_state='GA', vin='4T1B11HK1HU000118', + zip_code='30303', has_accidents=False, owner_count=1, + contact_email='alice.j@test.com', contact_phone='(404) 555-0118', + offer_amount=11650.0, offer_valid_until=TODAY + timedelta(days=7), + status='active', created_at=SEED_NOW)) + + db.session.add(Appraisal( + user_id=bob.id, year=2015, make='Honda', model='Accord', trim='Sport', + mileage=125400, condition='fair', + exterior_color='Modern Steel Metallic', + license_plate='BKHX2015', license_state='TX', vin='1HGCR2F33FA000119', + zip_code='77002', has_accidents=True, owner_count=2, + contact_email='bob.k@test.com', contact_phone='(713) 555-0119', + offer_amount=6850.0, offer_valid_until=TODAY + timedelta(days=4), + status='active', created_at=SEED_NOW)) + + db.session.add(Appraisal( + user_id=carol.id, year=2019, make='Nissan', model='Altima', trim='SV', + mileage=42800, condition='excellent', + exterior_color='Pearl White Tricoat', + license_plate='CL2019N', license_state='FL', vin='1N4BL4DV3KC000120', + zip_code='33176', has_accidents=False, owner_count=1, + contact_email='carol.l@test.com', contact_phone='(305) 555-0120', + offer_amount=14750.0, offer_valid_until=TODAY + timedelta(days=6), + status='active', created_at=SEED_NOW)) + + # One completed order for Dan + if v37: + db.session.add(Order( + order_number='CMX-2026-000001', + user_id=dan.id, + status='ready_for_pickup', + vehicle_id=v37.id, store_id=v37.store_id, + subtotal=v37.price, + transfer_fee=v37.transfer_fee or 0, + tax=v37.price * 0.0625, + title_fee=99, registration_fee=55, + total=(v37.price + (v37.transfer_fee or 0) + v37.price * 0.0625 + + 99 + 55), + maxcare_plan='gold', maxcare_price=1895, + payment_method='carmax_auto_finance', + payment_last4='1234', payment_apr=6.49, + payment_term_months=60, + monthly_payment=520.0, down_payment=3000.0, + trade_in_value=0, + pickup_or_delivery='pickup', + delivery_address='', + pickup_date=TODAY + timedelta(days=3), + created_at=SEED_NOW)) + + db.session.commit() diff --git a/sites/carmax/static/css/.gitkeep b/sites/carmax/static/css/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/sites/carmax/static/css/main.css b/sites/carmax/static/css/main.css new file mode 100644 index 0000000..29ede66 --- /dev/null +++ b/sites/carmax/static/css/main.css @@ -0,0 +1,221 @@ +/* CarMax mirror — brand styles. Deep blue + yellow accent. */ +:root { + --cmx-blue: #1660a8; + --cmx-blue-dark: #0d3a72; + --cmx-blue-darker: #06223f; + --cmx-yellow: #FFD900; + --cmx-yellow-dark: #ffc600; + --cmx-text: #1a1a1a; + --cmx-text-light: #585858; + --cmx-bg: #ffffff; + --cmx-bg-soft: #f4f6f8; + --cmx-border: #e0e3e8; + --cmx-success: #008060; + --cmx-danger: #c8302c; + --cmx-warning: #b8770e; + --cmx-radius: 6px; + --cmx-shadow-sm: 0 1px 2px rgba(0,0,0,0.06); + --cmx-shadow: 0 2px 8px rgba(0,0,0,0.08); +} + +* { box-sizing: border-box; } +html, body { margin: 0; padding: 0; font-family: 'Helvetica Neue', Arial, sans-serif; + color: var(--cmx-text); background: var(--cmx-bg); + -webkit-font-smoothing: antialiased; } +a { color: var(--cmx-blue); text-decoration: none; } +a:hover { text-decoration: underline; } +img { max-width: 100%; height: auto; } + +.container { max-width: 1280px; margin: 0 auto; padding: 0 16px; } +.container-narrow { max-width: 960px; margin: 0 auto; padding: 0 16px; } +.container-tight { max-width: 560px; margin: 0 auto; padding: 0 16px; } + +/* ===== Header ===== */ +.site-header { background: var(--cmx-blue-darker); color: #fff; } +.site-header .container { display: flex; align-items: center; gap: 24px; padding: 12px 16px; } +.site-header .logo { font-size: 28px; font-weight: 900; color: #fff; letter-spacing: -0.5px; } +.site-header .logo .accent { color: var(--cmx-yellow); } +.site-header .logo:hover { text-decoration: none; } +.site-header nav { display: flex; gap: 18px; flex: 1; } +.site-header nav a { color: #fff; font-size: 14px; font-weight: 600; padding: 6px 0; border-bottom: 2px solid transparent; } +.site-header nav a:hover { border-bottom-color: var(--cmx-yellow); text-decoration: none; } +.site-header .user-tools { display: flex; gap: 16px; align-items: center; font-size: 14px; } +.site-header .user-tools a { color: #fff; } +.site-header .user-tools .badge { + background: var(--cmx-yellow); color: #000; font-weight: 700; font-size: 11px; + padding: 2px 6px; border-radius: 999px; margin-left: 4px; +} + +.flash-row { padding: 0; } +.flash { padding: 12px 16px; font-size: 14px; } +.flash.success { background: #e8f6f0; color: var(--cmx-success); } +.flash.danger { background: #fceceb; color: var(--cmx-danger); } +.flash.warning { background: #fff5e0; color: var(--cmx-warning); } +.flash.info { background: #e8f1fb; color: var(--cmx-blue); } + +/* ===== Buttons ===== */ +.btn { + display: inline-block; padding: 12px 22px; font-size: 14px; font-weight: 700; + border-radius: var(--cmx-radius); border: 2px solid transparent; cursor: pointer; + text-align: center; text-decoration: none; transition: filter 0.15s; + font-family: inherit; +} +.btn:hover { filter: brightness(0.95); text-decoration: none; } +.btn-primary { background: var(--cmx-yellow); color: #000; } +.btn-primary:hover { background: var(--cmx-yellow-dark); color: #000; } +.btn-secondary { background: var(--cmx-blue); color: #fff; } +.btn-outline { background: #fff; color: var(--cmx-blue); border-color: var(--cmx-blue); } +.btn-outline-light { background: transparent; color: #fff; border-color: #fff; } +.btn-link { background: none; color: var(--cmx-blue); border: none; padding: 0; } +.btn-sm { padding: 6px 12px; font-size: 13px; } +.btn-lg { padding: 16px 32px; font-size: 16px; } +.btn-block { display: block; width: 100%; } +.btn-danger { background: var(--cmx-danger); color: #fff; } +.btn-disabled, .btn:disabled { opacity: 0.5; cursor: not-allowed; } + +/* ===== Hero ===== */ +.hero { + background: linear-gradient(135deg, var(--cmx-blue-darker), var(--cmx-blue)); + color: #fff; padding: 60px 16px; +} +.hero h1 { font-size: 44px; margin: 0 0 16px; font-weight: 900; line-height: 1.1; } +.hero p { font-size: 18px; opacity: 0.92; margin: 0 0 24px; max-width: 720px; } +.hero .hero-search { + background: #fff; border-radius: 8px; padding: 16px; margin-top: 24px; + display: flex; gap: 12px; flex-wrap: wrap; max-width: 760px; +} +.hero .hero-search input[type=text] { + flex: 1; min-width: 240px; padding: 14px 16px; font-size: 16px; + border: 1px solid var(--cmx-border); border-radius: var(--cmx-radius); color: #000; +} + +/* ===== Cards / grid ===== */ +.section { padding: 40px 0; } +.section h2 { font-size: 28px; margin: 0 0 24px; font-weight: 800; } +.section-soft { background: var(--cmx-bg-soft); } + +.grid { display: grid; gap: 20px; } +.grid-3 { grid-template-columns: repeat(3, 1fr); } +.grid-4 { grid-template-columns: repeat(4, 1fr); } +.grid-6 { grid-template-columns: repeat(6, 1fr); } +@media (max-width: 1024px) { .grid-4 { grid-template-columns: repeat(2, 1fr); } .grid-6 { grid-template-columns: repeat(3, 1fr); } } +@media (max-width: 640px) { .grid-3, .grid-4 { grid-template-columns: 1fr; } .grid-6 { grid-template-columns: repeat(2, 1fr); } } + +.vcard { + background: #fff; border: 1px solid var(--cmx-border); border-radius: var(--cmx-radius); + overflow: hidden; display: flex; flex-direction: column; + transition: box-shadow 0.15s; +} +.vcard:hover { box-shadow: var(--cmx-shadow); } +.vcard a { color: inherit; } +.vcard a:hover { text-decoration: none; } +.vcard .vcard-img { + aspect-ratio: 16/10; background: #f0f3f7; display: block; position: relative; + overflow: hidden; +} +.vcard .vcard-img img { width: 100%; height: 100%; object-fit: cover; } +.vcard .badge-strip { position: absolute; top: 10px; left: 10px; display: flex; gap: 6px; flex-wrap: wrap; } +.vcard .badge { + background: #fff; color: var(--cmx-blue-dark); font-size: 11px; font-weight: 700; + padding: 3px 8px; border-radius: 4px; text-transform: uppercase; letter-spacing: 0.3px; +} +.vcard .badge.cert { background: var(--cmx-blue); color: #fff; } +.vcard .badge.deal { background: var(--cmx-yellow); color: #000; } +.vcard .vcard-body { padding: 14px 16px; flex: 1; display: flex; flex-direction: column; } +.vcard .vcard-title { font-size: 16px; font-weight: 700; margin: 0 0 4px; } +.vcard .vcard-trim { font-size: 13px; color: var(--cmx-text-light); margin: 0 0 8px; } +.vcard .vcard-price { font-size: 22px; font-weight: 800; color: var(--cmx-blue-darker); margin: 8px 0 4px; } +.vcard .vcard-mileage { font-size: 13px; color: var(--cmx-text-light); } +.vcard .vcard-store { font-size: 12px; color: var(--cmx-text-light); margin-top: 8px; } +.vcard .vcard-actions { display: flex; gap: 6px; margin-top: 10px; } + +/* Vehicle detail */ +.vdetail { display: grid; grid-template-columns: 2fr 1fr; gap: 32px; margin-top: 28px; } +@media (max-width: 960px) { .vdetail { grid-template-columns: 1fr; } } +.vdetail-gallery { background: #f0f3f7; border-radius: var(--cmx-radius); overflow: hidden; } +.vdetail-gallery .main-img { aspect-ratio: 16/10; background: #f0f3f7; } +.vdetail-gallery .main-img img { width: 100%; height: 100%; object-fit: cover; } +.vdetail-gallery .thumbs { display: grid; grid-template-columns: repeat(6, 1fr); gap: 4px; padding: 4px; } +.vdetail-gallery .thumbs img { aspect-ratio: 1/1; object-fit: cover; cursor: pointer; } +.vdetail-summary h1 { font-size: 28px; margin: 0 0 4px; } +.vdetail-summary .price { font-size: 36px; font-weight: 800; color: var(--cmx-blue-darker); margin: 16px 0 8px; } +.vdetail-summary .meta { font-size: 14px; color: var(--cmx-text-light); } +.vdetail-summary .cta-stack > .btn { margin-bottom: 8px; } +.vdetail-summary .stock-info { font-size: 12px; color: var(--cmx-text-light); margin-top: 16px; padding-top: 16px; border-top: 1px solid var(--cmx-border); } + +.spec-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 12px 32px; } +.spec-grid dt { font-size: 13px; color: var(--cmx-text-light); } +.spec-grid dd { margin: 0 0 8px; font-weight: 600; font-size: 14px; } +.feature-pill { + display: inline-block; background: var(--cmx-bg-soft); color: var(--cmx-text); + padding: 6px 12px; border-radius: 999px; margin: 0 6px 6px 0; font-size: 13px; +} + +/* Search layout */ +.search-layout { display: grid; grid-template-columns: 260px 1fr; gap: 28px; align-items: start; } +@media (max-width: 960px) { .search-layout { grid-template-columns: 1fr; } } +.facets { background: #fff; padding: 16px; border: 1px solid var(--cmx-border); border-radius: var(--cmx-radius); position: sticky; top: 12px; } +.facet-section { padding: 8px 0; border-bottom: 1px solid var(--cmx-border); } +.facet-section:last-child { border-bottom: none; } +.facet-section h4 { font-size: 13px; margin: 0 0 8px; text-transform: uppercase; letter-spacing: 0.5px; color: var(--cmx-text-light); } +.facet-section label { display: block; font-size: 14px; padding: 3px 0; } +.facet-section input[type=number], .facet-section input[type=text], .facet-section select { + width: 100%; padding: 6px 8px; border: 1px solid var(--cmx-border); border-radius: 4px; font-size: 13px; +} +.search-results .toolbar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; } +.search-results .toolbar select { padding: 8px 12px; border: 1px solid var(--cmx-border); border-radius: 4px; } +.pagination { display: flex; gap: 4px; justify-content: center; margin-top: 24px; } +.pagination a, .pagination span { + padding: 6px 12px; border: 1px solid var(--cmx-border); border-radius: 4px; + background: #fff; color: var(--cmx-blue); font-size: 13px; +} +.pagination a:hover { background: var(--cmx-bg-soft); text-decoration: none; } +.pagination .current { background: var(--cmx-blue); color: #fff; font-weight: 700; } + +/* Forms */ +.form-row { margin-bottom: 14px; } +.form-row label { display: block; font-size: 13px; font-weight: 600; margin-bottom: 4px; } +.form-row input[type=text], .form-row input[type=email], .form-row input[type=password], +.form-row input[type=number], .form-row select, .form-row textarea { + width: 100%; padding: 10px 12px; font-size: 14px; border: 1px solid var(--cmx-border); + border-radius: var(--cmx-radius); font-family: inherit; +} +.form-row textarea { min-height: 80px; resize: vertical; } +.form-row .help { font-size: 12px; color: var(--cmx-text-light); margin-top: 4px; } +.form-row .errors { color: var(--cmx-danger); font-size: 12px; margin-top: 4px; } +.form-grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; } + +/* Footer */ +.site-footer { + background: var(--cmx-blue-darker); color: #fff; padding: 40px 16px 20px; + margin-top: 80px; font-size: 14px; +} +.site-footer .footer-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 24px; max-width: 1280px; margin: 0 auto; } +.site-footer h5 { font-size: 13px; text-transform: uppercase; letter-spacing: 1px; margin: 0 0 12px; color: var(--cmx-yellow); } +.site-footer a { color: #c7d1de; display: block; padding: 3px 0; } +.site-footer a:hover { color: #fff; text-decoration: none; } +.site-footer .footer-bottom { max-width: 1280px; margin: 32px auto 0; padding-top: 20px; border-top: 1px solid #2a4974; color: #aeb6c3; } +@media (max-width: 800px) { .site-footer .footer-grid { grid-template-columns: repeat(2, 1fr); } } + +/* Tables */ +table.data { + width: 100%; border-collapse: collapse; background: #fff; + border: 1px solid var(--cmx-border); border-radius: var(--cmx-radius); +} +table.data th, table.data td { padding: 10px 14px; text-align: left; font-size: 14px; border-bottom: 1px solid var(--cmx-border); } +table.data th { background: var(--cmx-bg-soft); font-weight: 700; font-size: 13px; } +table.data tr:last-child td { border-bottom: none; } + +/* Misc */ +.callout { background: #fff5cf; border-left: 4px solid var(--cmx-yellow); padding: 14px 16px; border-radius: 4px; } +.kpi-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 14px; margin: 24px 0; } +@media (max-width: 800px) { .kpi-grid { grid-template-columns: repeat(2, 1fr); } } +.kpi { background: #fff; border: 1px solid var(--cmx-border); padding: 16px; border-radius: var(--cmx-radius); } +.kpi .v { font-size: 28px; font-weight: 800; color: var(--cmx-blue-darker); } +.kpi .l { font-size: 12px; text-transform: uppercase; letter-spacing: 0.5px; color: var(--cmx-text-light); } + +.breadcrumbs { font-size: 13px; color: var(--cmx-text-light); margin: 16px 0; } +.breadcrumbs a { color: var(--cmx-blue); } + +.empty-state { text-align: center; padding: 60px 20px; color: var(--cmx-text-light); } +.empty-state h2 { color: var(--cmx-text); margin: 0 0 8px; } diff --git a/sites/carmax/static/icons/.gitkeep b/sites/carmax/static/icons/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/sites/carmax/static/js/.gitkeep b/sites/carmax/static/js/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/sites/carmax/templates/.gitkeep b/sites/carmax/templates/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/sites/carmax/templates/404.html b/sites/carmax/templates/404.html new file mode 100644 index 0000000..0dab778 --- /dev/null +++ b/sites/carmax/templates/404.html @@ -0,0 +1,9 @@ +{% extends "base.html" %} +{% block title %}Page not found | CarMax{% endblock %} +{% block content %} +
+

That page is parked off-lot.

+

The page you wanted doesn't exist. Let's get you back on the road.

+ Back to CarMax +
+{% endblock %} diff --git a/sites/carmax/templates/500.html b/sites/carmax/templates/500.html new file mode 100644 index 0000000..9fc03f3 --- /dev/null +++ b/sites/carmax/templates/500.html @@ -0,0 +1,9 @@ +{% extends "base.html" %} +{% block title %}Something went wrong | CarMax{% endblock %} +{% block content %} +
+

Our pit crew is on it.

+

Something went wrong. Please try again in a moment.

+ Back to CarMax +
+{% endblock %} diff --git a/sites/carmax/templates/_macros.html b/sites/carmax/templates/_macros.html new file mode 100644 index 0000000..35c57d5 --- /dev/null +++ b/sites/carmax/templates/_macros.html @@ -0,0 +1,34 @@ +{% macro vcard(v) %} + +{% endmacro %} diff --git a/sites/carmax/templates/account.html b/sites/carmax/templates/account.html new file mode 100644 index 0000000..101975e --- /dev/null +++ b/sites/carmax/templates/account.html @@ -0,0 +1,55 @@ +{% extends "base.html" %} +{% block title %}My CarMax account{% endblock %} +{% block content %} +
+

Hi, {{ current_user.first_name or 'CarMax customer' }}

+

{{ current_user.email }}

+ + + + {% if current_user.pre_qual_active %} +
+ You're pre-qualified. + Up to {{ current_user.pre_qual_monthly_max|money }}/mo at {{ current_user.pre_qual_apr }}% APR + for {{ current_user.pre_qual_term_months }} months. Expires {{ current_user.pre_qual_expires_at }}. + View matching cars +
+ {% else %} +
+ Shop with your budget in mind. + Get pre-qualified in 5 minutes - no impact to your credit. +
+ {% endif %} + + + + {% if recent_saved %} +

Recently saved

+ {% from "_macros.html" import vcard %} +
{% for s in recent_saved %}{{ vcard(s.vehicle) }}{% endfor %}
+ {% endif %} +
+{% endblock %} diff --git a/sites/carmax/templates/account_appraisals.html b/sites/carmax/templates/account_appraisals.html new file mode 100644 index 0000000..74bb2b5 --- /dev/null +++ b/sites/carmax/templates/account_appraisals.html @@ -0,0 +1,25 @@ +{% extends "base.html" %} +{% block title %}My offers | CarMax{% endblock %} +{% block content %} +
+

My offers

+ {% if rows %} + + + {% for a in rows %} + + + + + + + + + {% endfor %} +
VehicleMileageConditionOfferValid untilStatus
{{ a.vehicle_label }}{{ a.mileage|miles }}{{ a.condition|capitalize }}{{ a.offer_amount|money }}{{ a.offer_valid_until }}{{ a.status }}
+ {% else %} +

No offers yet.

+ Get an offer
+ {% endif %} +
+{% endblock %} diff --git a/sites/carmax/templates/account_change_password.html b/sites/carmax/templates/account_change_password.html new file mode 100644 index 0000000..d1ae08b --- /dev/null +++ b/sites/carmax/templates/account_change_password.html @@ -0,0 +1,14 @@ +{% extends "base.html" %} +{% block title %}Change password | CarMax{% endblock %} +{% block content %} +
+

Change password

+
+ +
{{ form.current_password() }}
+
{{ form.new_password() }}
+
{{ form.confirm() }}
+ +
+
+{% endblock %} diff --git a/sites/carmax/templates/account_edit.html b/sites/carmax/templates/account_edit.html new file mode 100644 index 0000000..1f62bea --- /dev/null +++ b/sites/carmax/templates/account_edit.html @@ -0,0 +1,24 @@ +{% extends "base.html" %} +{% block title %}Edit profile | CarMax{% endblock %} +{% block content %} +
+

Edit profile

+
+ +
+
{{ form.first_name() }}
+
{{ form.last_name() }}
+
+
{{ form.phone() }}
+
{{ form.address_line1() }}
+
{{ form.address_line2() }}
+
+
{{ form.city() }}
+
{{ form.state() }}
+
+
{{ form.zip_code() }}
+ + Cancel +
+
+{% endblock %} diff --git a/sites/carmax/templates/account_orders.html b/sites/carmax/templates/account_orders.html new file mode 100644 index 0000000..859f380 --- /dev/null +++ b/sites/carmax/templates/account_orders.html @@ -0,0 +1,25 @@ +{% extends "base.html" %} +{% block title %}My orders | CarMax{% endblock %} +{% block content %} +
+

My orders

+ {% if orders %} + + + {% for o in orders %} + + + + + + + + + {% endfor %} +
Order #VehicleTotalStatusDate
{{ o.order_number }}{{ o.vehicle.short_title }}{{ o.total|money }}{{ o.status }}{{ o.created_at.date() }}View
+ {% else %} +

No orders yet.

+ Shop cars
+ {% endif %} +
+{% endblock %} diff --git a/sites/carmax/templates/account_reservations.html b/sites/carmax/templates/account_reservations.html new file mode 100644 index 0000000..94cac78 --- /dev/null +++ b/sites/carmax/templates/account_reservations.html @@ -0,0 +1,31 @@ +{% extends "base.html" %} +{% block title %}My reservations | CarMax{% endblock %} +{% block content %} +
+

My reservations

+ {% if rows %} + + + {% for r in rows %} + + + + + + + + + {% endfor %} +
VehicleStoreAppointmentExpiresStatus
{{ r.vehicle.short_title }}{{ r.store.location_label }}{{ r.appointment_date }}{{ r.expires_at }}{{ r.status }} + {% if r.status == 'active' %} +
+ + +
+ {% endif %} +
+ {% else %} +

No reservations yet.

+ {% endif %} +
+{% endblock %} diff --git a/sites/carmax/templates/account_test_drives.html b/sites/carmax/templates/account_test_drives.html new file mode 100644 index 0000000..75e4348 --- /dev/null +++ b/sites/carmax/templates/account_test_drives.html @@ -0,0 +1,31 @@ +{% extends "base.html" %} +{% block title %}My test drives | CarMax{% endblock %} +{% block content %} +
+

My test drives

+ {% if rows %} + + + {% for r in rows %} + + + + + + + + + {% endfor %} +
VehicleStoreWhenTypeStatus
{{ r.vehicle.short_title }}{{ r.store.location_label }}{{ r.scheduled_date }} {{ r.scheduled_time }}{{ 'At store' if r.location_type == 'in_store' else 'At home' }}{{ r.status }} + {% if r.status == 'confirmed' %} +
+ + +
+ {% endif %} +
+ {% else %} +

No test drives scheduled.

+ {% endif %} +
+{% endblock %} diff --git a/sites/carmax/templates/article_detail.html b/sites/carmax/templates/article_detail.html new file mode 100644 index 0000000..c08537b --- /dev/null +++ b/sites/carmax/templates/article_detail.html @@ -0,0 +1,28 @@ +{% extends "base.html" %} +{% block title %}{{ article.title }} | CarMax{% endblock %} +{% block content %} +
+ +

{{ article.title }}

+

By {{ article.author }} · {{ article.published_at }}

+ {% if article.hero_image %} + {{ article.title }} + {% endif %} +
{{ article.body }}
+ {% if related %} +

Related articles

+
+ {% for r in related %} + +
+

{{ r.title }}

+
+ {% endfor %} +
+ {% endif %} +
+{% endblock %} diff --git a/sites/carmax/templates/articles_index.html b/sites/carmax/templates/articles_index.html new file mode 100644 index 0000000..c049dc0 --- /dev/null +++ b/sites/carmax/templates/articles_index.html @@ -0,0 +1,28 @@ +{% extends "base.html" %} +{% block title %}Articles | CarMax{% endblock %} +{% block content %} +
+

Articles & advice

+
+ All + {% for cat, n in cats %} + {{ cat|capitalize }} ({{ n }}) + {% endfor %} +
+ +
+{% endblock %} diff --git a/sites/carmax/templates/base.html b/sites/carmax/templates/base.html new file mode 100644 index 0000000..0bd2e55 --- /dev/null +++ b/sites/carmax/templates/base.html @@ -0,0 +1,96 @@ + + + + + + {% block title %}CarMax - Shop for used cars, then buy online or at a store{% endblock %} + + + + {% block head_extra %}{% endblock %} + + + + +
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% for category, msg in messages %} +
+
{{ msg }}
+
+ {% endfor %} + {% endwith %} +
+ +
+ {% block content %}{% endblock %} +
+ + + + diff --git a/sites/carmax/templates/checkout.html b/sites/carmax/templates/checkout.html new file mode 100644 index 0000000..dcbbe1a --- /dev/null +++ b/sites/carmax/templates/checkout.html @@ -0,0 +1,67 @@ +{% extends "base.html" %} +{% block title %}Checkout | {{ vehicle.short_title }} | CarMax{% endblock %} +{% block content %} +
+

Buy this {{ vehicle.short_title }} online

+
+
+
+ + +

Receive your car

+
{{ form.pickup_or_delivery() }}
+
{{ form.delivery_address() }}
+ +

Payment

+
{{ form.payment_method() }}
+
+
{{ form.apr() }}
+
{{ form.term_months() }}
+
+
+
{{ form.down_payment() }}
+
{{ form.card_last4() }}{% for e in form.card_last4.errors %}
{{ e }}
{% endfor %}
+
+ + {% if trade_options %} +

Trade-in

+
+ + +
+ {% endif %} + +

Add MaxCare

+
{{ form.maxcare_plan() }}
+ + +
+
+ + +
+
+{% endblock %} diff --git a/sites/carmax/templates/compare.html b/sites/carmax/templates/compare.html new file mode 100644 index 0000000..185c31b --- /dev/null +++ b/sites/carmax/templates/compare.html @@ -0,0 +1,50 @@ +{% extends "base.html" %} +{% block title %}Compare vehicles | CarMax{% endblock %} +{% block content %} +
+

Compare vehicles

+ {% if items %} +

{{ items|length }} of 4 vehicles. Find more cars to compare.

+ + + + {% for v in items %} + + {% endfor %} + + {% for v in items %}{% endfor %} + {% for v in items %}{% endfor %} + {% for v in items %}{% endfor %} + {% for v in items %}{% endfor %} + {% for v in items %}{% endfor %} + {% for v in items %}{% endfor %} + {% for v in items %}{% endfor %} + {% for v in items %}{% endfor %} + {% for v in items %}{% endfor %} + {% for v in items %}{% endfor %} + {% for v in items %}{% endfor %} + {% for v in items %}{% endfor %} + {% for v in items %}{% endfor %} + {% for v in items %}{% endfor %} +
+ +
+ {{ v.short_title }} +
+
+ + +
+
Price{{ v.price|money }}
Mileage{{ v.mileage|miles }}
Trim{{ v.trim }}
Body{{ v.body_style }}
Engine{{ v.engine_text }}
Horsepower{{ v.horsepower }} hp
Transmission{{ v.transmission }}
Drive type{{ v.drive_type }}
Fuel type{{ v.fuel_type }}
Combined MPG{{ v.mpg_combined }}
Exterior color{{ v.exterior_color }}
Seating{{ v.seating_capacity }}
Store{{ v.store.location_label if v.store else '-' }}
Transfer fee{{ v.transfer_fee|money if v.transfer_fee else 'Free' }}
+
+ + +
+ {% else %} +

Nothing to compare yet.

+

Add up to 4 vehicles by tapping "+ Compare" on any car.

+ Browse inventory
+ {% endif %} +
+{% endblock %} diff --git a/sites/carmax/templates/faq.html b/sites/carmax/templates/faq.html new file mode 100644 index 0000000..5083a28 --- /dev/null +++ b/sites/carmax/templates/faq.html @@ -0,0 +1,19 @@ +{% extends "base.html" %} +{% block title %}FAQ | CarMax{% endblock %} +{% block content %} +
+

CarMax FAQ

+
+ {% for cat, items in categories.items() %} +
+

{{ cat|replace('-', ' ')|capitalize }}

+
    + {% for slug, q, a in items %} +
  • {{ q }}
  • + {% endfor %} +
+
+ {% endfor %} +
+
+{% endblock %} diff --git a/sites/carmax/templates/faq_category.html b/sites/carmax/templates/faq_category.html new file mode 100644 index 0000000..3ca136e --- /dev/null +++ b/sites/carmax/templates/faq_category.html @@ -0,0 +1,15 @@ +{% extends "base.html" %} +{% block title %}{{ category|replace('-', ' ')|capitalize }} FAQ | CarMax{% endblock %} +{% block content %} +
+ +

{{ category|replace('-', ' ')|capitalize }} questions

+ {% for slug, q, a in items %} +
+ {{ q }} +

{{ a }}

+ Permalink +
+ {% endfor %} +
+{% endblock %} diff --git a/sites/carmax/templates/faq_detail.html b/sites/carmax/templates/faq_detail.html new file mode 100644 index 0000000..544bdcb --- /dev/null +++ b/sites/carmax/templates/faq_detail.html @@ -0,0 +1,10 @@ +{% extends "base.html" %} +{% block title %}{{ question }} | CarMax FAQ{% endblock %} +{% block content %} +
+ +

{{ question }}

+

{{ answer }}

+
+{% endblock %} diff --git a/sites/carmax/templates/financing.html b/sites/carmax/templates/financing.html new file mode 100644 index 0000000..f3da188 --- /dev/null +++ b/sites/carmax/templates/financing.html @@ -0,0 +1,27 @@ +{% extends "base.html" %} +{% block title %}CarMax Financing | Pre-qualify and shop with personalized terms{% endblock %} +{% block content %} +
+
+

Shop with your budget in mind.

+

Get pre-qualified online and see personalized monthly payments on every car. No impact to your credit.

+

Get pre-qualified

+
+
+
+
+
+

Pre-qualification, not pre-approval

+

A soft credit inquiry only - no hit to your score. You get personalized monthly payment terms valid for 30 days.

+
+
+

CarMax Auto Finance

+

We finance customers across credit profiles, including first-time buyers. Decisions are usually available within 5 minutes.

+
+
+

Bring your own lender

+

Already have financing through your bank or credit union? No problem - we accept cash, external financing, or CarMax Auto Finance.

+
+
+
+{% endblock %} diff --git a/sites/carmax/templates/index.html b/sites/carmax/templates/index.html new file mode 100644 index 0000000..df48be5 --- /dev/null +++ b/sites/carmax/templates/index.html @@ -0,0 +1,111 @@ +{% extends "base.html" %} +{% from "_macros.html" import vcard %} +{% block content %} +
+
+

Shop for used cars. Then buy online or at a store.

+

Browse our nationwide inventory of CarMax Certified vehicles, get pre-qualified + with no impact to your credit, and pick the path that works for you.

+ +
+
+ +
+
+

Shop by body style

+
+ {% for b, n in body_styles %} + +
{{ b }}
+
{{ n }} cars
+
+ {% endfor %} +
+
+
+ +{% if featured %} +
+
+

Featured CarMax Certified vehicles

+
+ {% for v in featured %}{{ vcard(v) }}{% endfor %} +
+
+
+{% endif %} + +
+
+

Shop by make

+
+ {% for make, slug, n in popular_makes %} + +
{{ make }}
+
{{ n }} in stock
+
+ {% endfor %} +
+
+
+ +{% if new_arrivals %} +
+
+

New arrivals this week

+
+ {% for v in new_arrivals %}{{ vcard(v) }}{% endfor %} +
+
+
+{% endif %} + +
+
+
+
+

Sell us your car

+

Real, upfront offer in under 2 minutes. Good for 7 days.

+ Get my offer +
+
+

Pre-qualify in 5 minutes

+

Shop with personalized monthly payments. No impact to credit.

+ Get pre-qualified +
+
+

MaxCare extended warranty

+

Plans up to 60 months / 100,000 miles. 24/7 roadside.

+ See plans +
+
+
+
+ +{% if article_strip %} +
+
+

Research & advice

+
+ {% for a in article_strip %} + +
+ {{ a.title }} +
+
+

{{ a.title }}

+

{{ a.summary }}

+
+
+ {% endfor %} +
+
+
+{% endif %} +{% endblock %} diff --git a/sites/carmax/templates/login.html b/sites/carmax/templates/login.html new file mode 100644 index 0000000..cf3c96b --- /dev/null +++ b/sites/carmax/templates/login.html @@ -0,0 +1,16 @@ +{% extends "base.html" %} +{% block title %}Sign in | CarMax{% endblock %} +{% block content %} +
+

Sign in to CarMax

+
+ + +
{{ form.email(class="") }}{% for e in form.email.errors %}
{{ e }}
{% endfor %}
+
{{ form.password() }}{% for e in form.password.errors %}
{{ e }}
{% endfor %}
+
+ +
+

New to CarMax? Create an account

+
+{% endblock %} diff --git a/sites/carmax/templates/maxcare.html b/sites/carmax/templates/maxcare.html new file mode 100644 index 0000000..20addf8 --- /dev/null +++ b/sites/carmax/templates/maxcare.html @@ -0,0 +1,24 @@ +{% extends "base.html" %} +{% block title %}MaxCare extended service plans | CarMax{% endblock %} +{% block content %} +
+

MaxCare extended service plans

+

Optional coverage that picks up where the limited warranty leaves off.

+
+ {% for tier, label in labels.items() %} +
+

{{ label }}

+
{{ prices[tier]|money }}
+
one-time
+
    +
  • Hassle-free repairs (deductible per visit)
  • +
  • 24/7/365 emergency roadside
  • +
  • Rental reimbursement up to $40/day
  • +
  • Nationwide US & Canada coverage
  • +
  • Cancel any time
  • +
+
+ {% endfor %} +
+
+{% endblock %} diff --git a/sites/carmax/templates/order_confirmation.html b/sites/carmax/templates/order_confirmation.html new file mode 100644 index 0000000..24d7ccc --- /dev/null +++ b/sites/carmax/templates/order_confirmation.html @@ -0,0 +1,44 @@ +{% extends "base.html" %} +{% block title %}Order {{ order.order_number }} | CarMax{% endblock %} +{% block content %} +
+
+

Order placed!

+

Confirmation number: {{ order.order_number }}

+
+ +

Your car

+
+ {{ order.vehicle.title }}
+ {{ order.vehicle.mileage|miles }} · {{ order.vehicle.exterior_color }}
+ Pickup at: {{ order.store.name }} - {{ order.store.location_label }}
+ {% if order.pickup_or_delivery == 'home_delivery' %} + Home delivery to: {{ order.delivery_address }}
+ {% endif %} + Estimated pickup/delivery: {{ order.pickup_date }} +
+ +

Totals

+ + + {% if order.transfer_fee %}{% endif %} + + + + {% if order.maxcare_plan %}{% endif %} + {% if order.trade_in_value %}{% endif %} + +
Vehicle price{{ order.subtotal|money }}
Transfer fee{{ order.transfer_fee|money }}
Sales tax{{ order.tax|money }}
Title fee{{ order.title_fee|money }}
Registration{{ order.registration_fee|money }}
MaxCare ({{ order.maxcare_plan|capitalize }}){{ order.maxcare_price|money }}
Trade-in-{{ order.trade_in_value|money }}
Total{{ order.total|money }}
+ + {% if order.payment_method != 'cash' %} +

Financing

+

{{ order.payment_term_months }} months at {{ order.payment_apr }}% APR. + {{ order.monthly_payment|money }}/mo after {{ order.down_payment|money }} down.

+ {% endif %} + +

+ View all orders + Keep browsing +

+
+{% endblock %} diff --git a/sites/carmax/templates/pre_qual_form.html b/sites/carmax/templates/pre_qual_form.html new file mode 100644 index 0000000..7c93054 --- /dev/null +++ b/sites/carmax/templates/pre_qual_form.html @@ -0,0 +1,26 @@ +{% extends "base.html" %} +{% block title %}Get pre-qualified | CarMax{% endblock %} +{% block content %} +
+

Get pre-qualified

+

See personalized monthly payment terms - no impact to your credit. Takes about 5 minutes. Good for 30 days.

+
+ +
{{ form.annual_income() }}{% for e in form.annual_income.errors %}
{{ e }}
{% endfor %}
+
{{ form.employment_status() }}
+
+
{{ form.monthly_payment_max() }}
+
{{ form.down_payment() }}
+
+
+
{{ form.term_months() }}
+
{{ form.credit_tier() }}
+
+ +
+

+ Pre-qualification uses a soft credit inquiry and does not impact your credit score. + Final financing terms require a credit application. +

+
+{% endblock %} diff --git a/sites/carmax/templates/pre_qual_result.html b/sites/carmax/templates/pre_qual_result.html new file mode 100644 index 0000000..944e757 --- /dev/null +++ b/sites/carmax/templates/pre_qual_result.html @@ -0,0 +1,19 @@ +{% extends "base.html" %} +{% from "_macros.html" import vcard %} +{% block title %}You're pre-qualified | CarMax{% endblock %} +{% block content %} +
+
+

You're pre-qualified.

+

+ Up to {{ max_principal|money }} vehicle price at + {{ current_user.pre_qual_apr }}% APR for + {{ current_user.pre_qual_term_months }} months, + with {{ current_user.pre_qual_down_payment|money }} down. + Good through {{ current_user.pre_qual_expires_at }}. +

+
+

Vehicles within your budget

+
{% for v in affordable %}{{ vcard(v) }}{% endfor %}
+
+{% endblock %} diff --git a/sites/carmax/templates/register.html b/sites/carmax/templates/register.html new file mode 100644 index 0000000..ac7aafc --- /dev/null +++ b/sites/carmax/templates/register.html @@ -0,0 +1,24 @@ +{% extends "base.html" %} +{% block title %}Create an account | CarMax{% endblock %} +{% block content %} +
+

Create your CarMax account

+

Save cars, get pre-qualified, and reserve vehicles online.

+
+ +
+
{{ form.first_name() }}{% for e in form.first_name.errors %}
{{ e }}
{% endfor %}
+
{{ form.last_name() }}{% for e in form.last_name.errors %}
{{ e }}
{% endfor %}
+
+
{{ form.email() }}{% for e in form.email.errors %}
{{ e }}
{% endfor %}
+
+
{{ form.phone() }}
+
{{ form.zip_code() }}
+
+
{{ form.password() }}{% for e in form.password.errors %}
{{ e }}
{% endfor %}
At least 8 characters.
+
{{ form.confirm() }}{% for e in form.confirm.errors %}
{{ e }}
{% endfor %}
+ +
+

Already have an account? Sign in

+
+{% endblock %} diff --git a/sites/carmax/templates/research_index.html b/sites/carmax/templates/research_index.html new file mode 100644 index 0000000..f675759 --- /dev/null +++ b/sites/carmax/templates/research_index.html @@ -0,0 +1,25 @@ +{% extends "base.html" %} +{% block title %}Car research & advice | CarMax{% endblock %} +{% block content %} +
+

Car research & advice

+

Reviews, ratings, specs, and FAQs to help you find your next car.

+

Most-researched models

+
+ {% for make, mslug, model, modelslug, n in popular %} + +
{{ make }} {{ model }}
+
{{ n }} in stock
+
+ {% endfor %} +
+

All makes

+
+ {% for make, slug, n in makes %} + +
{{ make }}
{{ n }}
+
+ {% endfor %} +
+
+{% endblock %} diff --git a/sites/carmax/templates/research_make.html b/sites/carmax/templates/research_make.html new file mode 100644 index 0000000..1ea3b4c --- /dev/null +++ b/sites/carmax/templates/research_make.html @@ -0,0 +1,16 @@ +{% extends "base.html" %} +{% block title %}Used {{ make_name }} | CarMax research{% endblock %} +{% block content %} +
+ +

{{ make_name }} research

+
+ {% for make, mslug, model, modelslug, n in models %} + +
{{ model }}
+
{{ n }} in stock - read review
+
+ {% endfor %} +
+
+{% endblock %} diff --git a/sites/carmax/templates/research_model.html b/sites/carmax/templates/research_model.html new file mode 100644 index 0000000..132c32b --- /dev/null +++ b/sites/carmax/templates/research_model.html @@ -0,0 +1,21 @@ +{% extends "base.html" %} +{% block title %}{{ make }} {{ model }} review | CarMax{% endblock %} +{% block content %} +
+ +

{{ make }} {{ model }} - generations & year-by-year

+

Browse a model year for full specs, trims, and reviews.

+
+ {% for y, n in years %} + +
{{ y }}
{{ n }} in stock
+
+ {% endfor %} +
+

+ Shop {{ make }} {{ model }} inventory +

+
+{% endblock %} diff --git a/sites/carmax/templates/research_model_year.html b/sites/carmax/templates/research_model_year.html new file mode 100644 index 0000000..5573e07 --- /dev/null +++ b/sites/carmax/templates/research_model_year.html @@ -0,0 +1,51 @@ +{% extends "base.html" %} +{% from "_macros.html" import vcard %} +{% block title %}{{ year }} {{ make }} {{ model }} review, photos & specs | CarMax{% endblock %} +{% block content %} +
+ +

{{ year }} {{ make }} {{ model }} review

+
+
{{ '%.1f'|format(avg_rating) }}
Customer rating ({{ review_count }})
+
{{ '%.1f'|format(sample.repairpal_rating) }}
RepairPal reliability
+
{{ sample.mpg_combined }}
Combined MPG
+
{{ avg_price|money }}
CarMax avg ({{ min_price|money }} - {{ max_price|money }})
+
+ +

Available trims for {{ year }}

+
    + {% for trim_name, trim_slug in trims %} +
  • {{ trim_name }}
  • + {% endfor %} +
+ +

Key specs (from sample {{ sample.trim }})

+
+
Engine
{{ sample.engine_text }}
+
Horsepower
{{ sample.horsepower }} hp
+
Drive type
{{ sample.drive_type }}
+
Transmission
{{ sample.transmission }}
+
Fuel type
{{ sample.fuel_type }}
+
EPA MPG
{{ sample.mpg_city }} city / {{ sample.mpg_highway }} hwy
+
Seating capacity
{{ sample.seating_capacity }}
+
Body style
{{ sample.body_style }}
+
+ +

Available {{ year }} {{ model }} inventory

+
{% for v in vehicles[:6] %}{{ vcard(v) }}{% endfor %}
+ + {% if reviews %} +

Customer reviews

+ {% for r in reviews %} +
+ {{ r.title }} {{ r.rating|star_row }} +

{{ r.body }}

+
{{ r.reviewer_name }} - {{ r.location }}
+
+ {% endfor %} +

All reviews

+ {% endif %} +
+{% endblock %} diff --git a/sites/carmax/templates/reserve.html b/sites/carmax/templates/reserve.html new file mode 100644 index 0000000..2e6166f --- /dev/null +++ b/sites/carmax/templates/reserve.html @@ -0,0 +1,17 @@ +{% extends "base.html" %} +{% block title %}Reserve {{ vehicle.short_title }} | CarMax{% endblock %} +{% block content %} +
+

Reserve this {{ vehicle.short_title }}

+

Hold the vehicle for up to 7 days while you arrange financing or paperwork. No obligation to buy.

+
+ {{ vehicle.title }}
+ {{ vehicle.headline_price }} - {{ vehicle.mileage|miles }} - {{ vehicle.store.location_label if vehicle.store else '' }} +
+
+ +
{{ form.appointment_date() }}
Leave blank for the next available slot.
+ +
+
+{% endblock %} diff --git a/sites/carmax/templates/reviews.html b/sites/carmax/templates/reviews.html new file mode 100644 index 0000000..cdb4692 --- /dev/null +++ b/sites/carmax/templates/reviews.html @@ -0,0 +1,32 @@ +{% extends "base.html" %} +{% block title %}{{ year }} {{ make }} {{ model }} reviews | CarMax{% endblock %} +{% block content %} +
+

{{ year }} {{ make }} {{ model }} reviews

+
+
{{ '%.1f'|format(avg_rating) }} / 5
+
{{ reviews|length }} review{{ 's' if reviews|length != 1 else '' }}
+
+

Reviews

+ {% for r in reviews %} +
+ {{ r.title }} + {{ r.rating|star_row }} +

{{ r.body }}

+
{{ r.reviewer_name }}{% if r.location %} - {{ r.location }}{% endif %}
+
+ {% else %} +

Be the first to review the {{ year }} {{ model }}.

+ {% endfor %} + +

Leave a review

+
+ +
{{ form.rating() }}
+
{{ form.title() }}
+
{{ form.body() }}
+
{{ form.location() }}
+ +
+
+{% endblock %} diff --git a/sites/carmax/templates/saved.html b/sites/carmax/templates/saved.html new file mode 100644 index 0000000..105f79d --- /dev/null +++ b/sites/carmax/templates/saved.html @@ -0,0 +1,15 @@ +{% extends "base.html" %} +{% from "_macros.html" import vcard %} +{% block title %}Saved cars | CarMax{% endblock %} +{% block content %} +
+

Saved cars

+ {% if rows %} +
{% for s in rows %}{{ vcard(s.vehicle) }}{% endfor %}
+ {% else %} +

No saved cars yet.

+

Tap the heart on any car to save it for later.

+ Browse inventory
+ {% endif %} +
+{% endblock %} diff --git a/sites/carmax/templates/search.html b/sites/carmax/templates/search.html new file mode 100644 index 0000000..35db909 --- /dev/null +++ b/sites/carmax/templates/search.html @@ -0,0 +1,113 @@ +{% extends "base.html" %} +{% from "_macros.html" import vcard %} +{% block title %}{{ scope_label }} | CarMax{% endblock %} +{% block content %} +
+

{{ scope_label }}

+

{{ total }} vehicles matched{% if query %} for "{{ query }}"{% endif %}

+ +
+ + +
+
+
Showing {{ items|length }} of {{ total }}
+
+ {% for k, v in filters.items() if v %}{% endfor %} + {% if query %}{% endif %} + Sort: + +
+
+ + {% if items %} +
+ {% for v in items %}{{ vcard(v) }}{% endfor %} +
+ {% else %} +
+

No vehicles match your search

+

Try widening your year range or removing a filter.

+ Reset filters +
+ {% endif %} + + {% if pages > 1 %} + + {% endif %} +
+
+
+{% endblock %} diff --git a/sites/carmax/templates/sell_my_car.html b/sites/carmax/templates/sell_my_car.html new file mode 100644 index 0000000..b505f58 --- /dev/null +++ b/sites/carmax/templates/sell_my_car.html @@ -0,0 +1,45 @@ +{% extends "base.html" %} +{% block title %}Sell My Car - Get an Instant Offer Online | CarMax{% endblock %} +{% block content %} +
+
+

Sell us your car. Get an instant offer.

+

Real, upfront offer in under 2 minutes. Good for 7 days. We'll buy your car even if you don't buy ours.

+
+
+
+
+ +

Tell us about your car

+
+
{{ form.year() }}{% for e in form.year.errors %}
{{ e }}
{% endfor %}
+
{{ form.mileage() }}{% for e in form.mileage.errors %}
{{ e }}
{% endfor %}
+
+
+
{{ form.make() }}{% for e in form.make.errors %}
{{ e }}
{% endfor %}
+
{{ form.model() }}{% for e in form.model.errors %}
{{ e }}
{% endfor %}
+
+
+
{{ form.trim() }}
+
{{ form.exterior_color() }}
+
+
{{ form.condition() }}
+
+
{{ form.license_plate() }}
+
{{ form.license_state() }}
+
+
+
{{ form.vin() }}
+
{{ form.owner_count() }}
+
+
{{ form.zip_code() }}{% for e in form.zip_code.errors %}
{{ e }}
{% endfor %}
+
+

Contact info

+
+
{{ form.contact_email() }}
+
{{ form.contact_phone() }}
+
+ +
+
+{% endblock %} diff --git a/sites/carmax/templates/sell_offer.html b/sites/carmax/templates/sell_offer.html new file mode 100644 index 0000000..886766d --- /dev/null +++ b/sites/carmax/templates/sell_offer.html @@ -0,0 +1,31 @@ +{% extends "base.html" %} +{% block title %}Your CarMax offer | {{ appraisal.vehicle_label }}{% endblock %} +{% block content %} +
+
+

Your offer for the {{ appraisal.vehicle_label }}

+
{{ appraisal.offer_amount|money }}
+

Good through {{ appraisal.offer_valid_until }}

+
+ +
+
{{ appraisal.year }} {{ appraisal.make }}
Year / make
+
{{ appraisal.model }} {{ appraisal.trim }}
Model / trim
+
{{ appraisal.mileage|miles }}
Mileage
+
{{ appraisal.condition|capitalize }}
Condition
+
+ +
+

Next step: schedule a visit at any CarMax store for a quick verification. Bring the car, the title, and your ID.

+ {% if current_user.is_authenticated %} +
+ + +
+ {% else %} + Sign in to redeem + {% endif %} + Apply offer as trade-in toward a CarMax car +
+
+{% endblock %} diff --git a/sites/carmax/templates/store_detail.html b/sites/carmax/templates/store_detail.html new file mode 100644 index 0000000..6c30732 --- /dev/null +++ b/sites/carmax/templates/store_detail.html @@ -0,0 +1,37 @@ +{% extends "base.html" %} +{% from "_macros.html" import vcard %} +{% block title %}{{ store.name }} | CarMax{% endblock %} +{% block content %} +
+ +

{{ store.name }}

+
+
+

Address

+

{{ store.street }}
{{ store.city }}, {{ store.state }} {{ store.zip_code }}

+

Phone: {{ store.phone }}

+
+
+

Hours

+

Mon - Fri: {{ store.hours_weekday }}
Sat: {{ store.hours_saturday }}
Sun: {{ store.hours_sunday }}

+
+
+

Services

+
    + {% if store.has_appraisal %}
  • ✓ In-store appraisal
  • {% endif %} + {% if store.has_express_pickup %}
  • ✓ Express pickup
  • {% endif %} + {% if store.has_service %}
  • ✓ Service center
  • {% endif %} + {% if store.has_home_delivery %}
  • ✓ Home delivery
  • {% endif %} +
+
+
+

Inventory at this store ({{ inv_count }})

+
{% for v in inventory %}{{ vcard(v) }}{% endfor %}
+ {% if inv_count > inventory|length %} +

+ See all {{ inv_count }} cars at this store +

+ {% endif %} +
+{% endblock %} diff --git a/sites/carmax/templates/stores.html b/sites/carmax/templates/stores.html new file mode 100644 index 0000000..78cf4aa --- /dev/null +++ b/sites/carmax/templates/stores.html @@ -0,0 +1,15 @@ +{% extends "base.html" %} +{% block title %}Nationwide CarMax locations{% endblock %} +{% block content %} +
+

Find a CarMax store

+

CarMax has stores across {{ states|length }} states.

+
+ {% for state, n in states %} + +
{{ state }}
{{ n }} store{{ 's' if n > 1 else '' }}
+
+ {% endfor %} +
+
+{% endblock %} diff --git a/sites/carmax/templates/stores_state.html b/sites/carmax/templates/stores_state.html new file mode 100644 index 0000000..b17ebf9 --- /dev/null +++ b/sites/carmax/templates/stores_state.html @@ -0,0 +1,18 @@ +{% extends "base.html" %} +{% block title %}CarMax stores in {{ state }}{% endblock %} +{% block content %} +
+ +

CarMax stores in {{ state }}

+ +
+{% endblock %} diff --git a/sites/carmax/templates/test_drive.html b/sites/carmax/templates/test_drive.html new file mode 100644 index 0000000..ec579e3 --- /dev/null +++ b/sites/carmax/templates/test_drive.html @@ -0,0 +1,21 @@ +{% extends "base.html" %} +{% block title %}Schedule a test drive | {{ vehicle.short_title }} | CarMax{% endblock %} +{% block content %} +
+

Schedule a test drive

+
+ {{ vehicle.title }}
+ {{ vehicle.headline_price }} - {{ vehicle.mileage|miles }} +
+
+ +
{{ form.location_type() }}
+
+
{{ form.scheduled_date() }}
+
{{ form.scheduled_time() }}
+
+
{{ form.notes() }}
+ +
+
+{% endblock %} diff --git a/sites/carmax/templates/value.html b/sites/carmax/templates/value.html new file mode 100644 index 0000000..eca1255 --- /dev/null +++ b/sites/carmax/templates/value.html @@ -0,0 +1,18 @@ +{% extends "base.html" %} +{% block title %}How much is my car worth? | CarMax{% endblock %} +{% block content %} +
+

How much is my car worth?

+

Look up estimated CarMax value by make and model, or get a real instant offer in under 2 minutes.

+

Get my instant offer

+ +

Browse by make

+
+ {% for make, slug in makes %} + +
{{ make }}
+
+ {% endfor %} +
+
+{% endblock %} diff --git a/sites/carmax/templates/value_model.html b/sites/carmax/templates/value_model.html new file mode 100644 index 0000000..750633c --- /dev/null +++ b/sites/carmax/templates/value_model.html @@ -0,0 +1,22 @@ +{% extends "base.html" %} +{% block title %}{{ make }} {{ model }} value | CarMax{% endblock %} +{% block content %} +
+

{{ make }} {{ model }} value

+

Based on current CarMax {{ make }} {{ model }} inventory.

+ + + {% for y, avg, mn, mx, c in rows %} + + + + + + + + + {% endfor %} +
YearAvg priceMinMaxCount
{{ y }}{{ avg|money }}{{ mn|money }}{{ mx|money }}{{ c }}Details
+

Get my instant offer

+
+{% endblock %} diff --git a/sites/carmax/templates/value_year.html b/sites/carmax/templates/value_year.html new file mode 100644 index 0000000..54f6d4e --- /dev/null +++ b/sites/carmax/templates/value_year.html @@ -0,0 +1,17 @@ +{% extends "base.html" %} +{% block title %}{{ year }} {{ make }} {{ model }} value | CarMax{% endblock %} +{% block content %} +
+

{{ year }} {{ make }} {{ model }} value

+
+
{{ avg_price|money }}
CarMax average
+
{{ min_price|money }}
Lowest in stock
+
{{ max_price|money }}
Highest in stock
+
{{ count }}
In current inventory
+
+
+ Sell or trade in? Get a real, written offer in under 2 minutes - good for 7 days. + Get my offer +
+
+{% endblock %} diff --git a/sites/carmax/templates/vehicle_detail.html b/sites/carmax/templates/vehicle_detail.html new file mode 100644 index 0000000..b9482fd --- /dev/null +++ b/sites/carmax/templates/vehicle_detail.html @@ -0,0 +1,144 @@ +{% extends "base.html" %} +{% from "_macros.html" import vcard %} +{% block title %}{{ vehicle.title }} | {{ vehicle.mileage|miles }} | CarMax{% endblock %} +{% block content %} +
+ + +
+
+ + +
+

Highlights

+
+
Year
{{ vehicle.year }}
+
Body style
{{ vehicle.body_style }}
+
Mileage
{{ vehicle.mileage|miles }}
+
Trim
{{ vehicle.trim or '-' }}
+
Exterior color
{{ vehicle.exterior_color }}
+
Interior color
{{ vehicle.interior_color }}
+
Engine
{{ vehicle.engine_text }}
+
Horsepower
{{ vehicle.horsepower }} hp / {{ vehicle.torque }} lb-ft
+
Transmission
{{ vehicle.transmission }}
+
Drive type
{{ vehicle.drive_type }}
+
Fuel type
{{ vehicle.fuel_type }}
+
Fuel economy
{{ vehicle.mpg_city }} city / {{ vehicle.mpg_highway }} hwy mpg
+
Seating capacity
{{ vehicle.seating_capacity }}
+
VIN
{{ vehicle.vin or '-' }}
+
Stock #
{{ vehicle.stock_number }}
+
Days at CarMax
{{ vehicle.days_on_lot }} days
+
+
+ +
+

Features

+ {% for f in vehicle.get_features() %} + {{ f }} + {% else %} +

No features listed.

+ {% endfor %} +
+ + {% if vehicle.description %} +
+

About this {{ vehicle.short_title }}

+

{{ vehicle.description }}

+
+ {% endif %} + +
+

Customer reviews ({{ reviews|length }})

+ {% for r in reviews %} +
+
{{ r.title }} {{ r.rating|star_row }}
+

{{ r.body }}

+
{{ r.reviewer_name }}{% if r.location %} - {{ r.location }}{% endif %}
+
+ {% else %} +

No reviews for the {{ vehicle.year }} {{ vehicle.model }} yet.

+ {% endfor %} +

+ See all reviews +

+
+
+ + +
+ + {% if similar %} +
+

Similar vehicles

+
{% for v in similar %}{{ vcard(v) }}{% endfor %}
+
+ {% endif %} +
+{% endblock %} diff --git a/websyn_start.sh b/websyn_start.sh index 72defad..8ce2838 100644 --- a/websyn_start.sh +++ b/websyn_start.sh @@ -5,7 +5,7 @@ set -e SITES=(allrecipes amazon apple arxiv bbc_news booking github google_flights google_map google_search huggingface wolfram_alpha - cambridge_dictionary coursera espn) + cambridge_dictionary coursera espn carmax) BASE_PORT=40000 PID_DIR=/tmp/websyn_pids mkdir -p "$PID_DIR" @@ -17,7 +17,7 @@ for d in "${SITES[@]}"; do cp -a "/opt/WebSyn/$d/instance_seed" "/opt/WebSyn/$d/instance" done -echo "[WebSyn] Starting 15 sites on ports ${BASE_PORT}-$((BASE_PORT + 14))..." +echo "[WebSyn] Starting ${#SITES[@]} sites on ports ${BASE_PORT}-$((BASE_PORT + ${#SITES[@]} - 1))..." for i in "${!SITES[@]}"; do site="${SITES[$i]}" port=$((BASE_PORT + i)) @@ -51,8 +51,8 @@ except Exception: exit(1) ready=$((ready + 1)) fi done - echo " [${elapsed}/${max_wait}s] ${ready}/15 sites ready" - if [ $ready -eq 15 ]; then + echo " [${elapsed}/${max_wait}s] ${ready}/${#SITES[@]} sites ready" + if [ $ready -eq ${#SITES[@]} ]; then break fi done From d3c43802fbe53a50032ea080cce64aa0ea199646 Mon Sep 17 00:00:00 2001 From: Zihao Li Date: Fri, 15 May 2026 00:06:55 -0500 Subject: [PATCH 2/6] phase 1 almost done --- sites/apple/instance/apple_store.db | Bin 0 -> 110592 bytes sites/carmax/app.py | 7 + sites/carmax/instance/carmax.db | Bin 0 -> 380928 bytes sites/carmax/scrape_carmax.py | 246 ++++++------------ sites/carmax/scraped_data/image_urls.json | 1 + sites/carmax/seed_data.py | 9 +- sites/carmax/templates/account.html | 2 +- sites/carmax/templates/index.html | 2 +- sites/carmax/templates/pre_qual_result.html | 2 +- .../carmax/templates/research_model_year.html | 2 +- sites/carmax/templates/saved.html | 2 +- sites/carmax/templates/search.html | 2 +- sites/carmax/templates/store_detail.html | 2 +- sites/carmax/templates/vehicle_detail.html | 2 +- 14 files changed, 106 insertions(+), 173 deletions(-) create mode 100644 sites/apple/instance/apple_store.db create mode 100644 sites/carmax/instance/carmax.db create mode 100644 sites/carmax/scraped_data/image_urls.json diff --git a/sites/apple/instance/apple_store.db b/sites/apple/instance/apple_store.db new file mode 100644 index 0000000000000000000000000000000000000000..623414173c41e57fc0b7dd7d9c1d983a45199256 GIT binary patch literal 110592 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 literal 0 HcmV?d00001 diff --git a/sites/carmax/app.py b/sites/carmax/app.py index ab1af95..17dcc01 100644 --- a/sites/carmax/app.py +++ b/sites/carmax/app.py @@ -827,6 +827,7 @@ def inject_globals(): compare_count = (ComparisonItem.query.filter_by(comparison_id=comp.id).count() if comp else 0) return { + 'current_user': current_user, 'saved_count': saved_count, 'compare_count': compare_count, 'csrf_token': generate_csrf, @@ -1977,6 +1978,12 @@ def server_error(e): # Bootstrap # ============================================================================= +# Map this module under the name 'app' so seed_data's deferred +# 'from app import ...' returns this same instance (not a fresh re-import +# under __name__ == '__main__'). +import sys as _sys +_sys.modules.setdefault('app', _sys.modules[__name__]) + from seed_data import seed_database, seed_benchmark_users # noqa: E402 with app.app_context(): diff --git a/sites/carmax/instance/carmax.db b/sites/carmax/instance/carmax.db new file mode 100644 index 0000000000000000000000000000000000000000..0d06c70fd0c32f8bc7174ebe6c6acb8fcb66f092 GIT binary patch literal 380928 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///st2400--evoxwebmedium.png The seeded DB references local paths like: /static/images/vehicles/-front.jpg - /static/images/vehicles/-side.jpg - /static/images/vehicles/-rear.jpg - /static/images/vehicles/-dashboard.jpg - /static/images/vehicles/-cargo.jpg - /static/images/vehicles/-interior.jpg + /static/images/vehicles/-side.jpg etc. -This script fetches the corresponding evox stock-photo views from -content-images.carmax.com (which CarMax uses for the same year/make/model -across its catalog), then writes them to the local paths above using -each seeded vehicle's stock number as the basename. +This script fetches one set of evox photos per (year, make_slug, model_slug) +tuple, then writes a copy under each matching vehicle's stock_number. Run from the repo root: - pip install playwright httpx - python -m playwright install chromium + pip install httpx python sites/carmax/scrape_carmax.py """ -import json import os import pathlib -import re import sys -import time -from urllib.parse import urljoin - -try: - from playwright.sync_api import sync_playwright -except ImportError: - sys.exit("missing playwright. install with:\n pip install playwright httpx\n python -m playwright install chromium") try: import httpx except ImportError: sys.exit("missing httpx. install with: pip install httpx") -ROOT = pathlib.Path(__file__).resolve().parent # sites/carmax/ -SCRAPE_DIR = ROOT / "scraped_data" +ROOT = pathlib.Path(__file__).resolve().parent IMG_DIR = ROOT / "static" / "images" / "vehicles" -ARTICLE_IMG_DIR = ROOT / "static" / "images" / "articles" -STORE_IMG_DIR = ROOT / "static" / "images" / "stores" -SCRAPE_DIR.mkdir(parents=True, exist_ok=True) IMG_DIR.mkdir(parents=True, exist_ok=True) -ARTICLE_IMG_DIR.mkdir(parents=True, exist_ok=True) -STORE_IMG_DIR.mkdir(parents=True, exist_ok=True) -# Map our seeded view-name -> evox view code. -VIEW_CODES = { - 'front': '089', +# evox view code → our local filename suffix +VIEW_MAP = { + 'front': '089', # angled front (used as main thumbnail) 'dashboard': '174', 'side': '037', 'rear': '119', 'cargo': '122', - 'interior': '118', + 'interior': '118', # front (alt angle, used for interior shot in our gallery) +} + +# Our seed's model_slug → carmax CDN URL model slug. +# Carmax sometimes uses a different convention from our slugify(). +SLUG_REMAP = { + 'f-150': 'f150', + 'silverado': 'silverado-1500', + # 'cr-v' stays 'cr-v', 'model-3' stays 'model-3', etc. + # 'c-class' is the carmax URL slug for Mercedes C-Class. + # If your seed uses something else, add a mapping here. } -# Build the list of (year, make, model) tuples we need real photos for. -TEMPLATE_INDEX = [ - (2020, 'honda', 'civic'), (2021, 'honda', 'civic'), (2022, 'honda', 'civic'), (2023, 'honda', 'civic'), - (2019, 'honda', 'accord'), (2020, 'honda', 'accord'), (2021, 'honda', 'accord'), (2022, 'honda', 'accord'), - (2019, 'honda', 'cr-v'), (2020, 'honda', 'cr-v'), (2021, 'honda', 'cr-v'), (2022, 'honda', 'cr-v'), (2023, 'honda', 'cr-v'), - (2020, 'honda', 'pilot'), (2021, 'honda', 'pilot'), (2022, 'honda', 'pilot'), - (2019, 'toyota', 'camry'), (2020, 'toyota', 'camry'), (2021, 'toyota', 'camry'), (2022, 'toyota', 'camry'), (2023, 'toyota', 'camry'), - (2020, 'toyota', 'corolla'), (2021, 'toyota', 'corolla'), (2022, 'toyota', 'corolla'), (2023, 'toyota', 'corolla'), - (2019, 'toyota', 'rav4'), (2020, 'toyota', 'rav4'), (2021, 'toyota', 'rav4'), (2022, 'toyota', 'rav4'), (2023, 'toyota', 'rav4'), - (2019, 'toyota', 'tacoma'), (2020, 'toyota', 'tacoma'), (2021, 'toyota', 'tacoma'), (2022, 'toyota', 'tacoma'), (2023, 'toyota', 'tacoma'), - (2020, 'toyota', 'highlander'), (2021, 'toyota', 'highlander'), (2022, 'toyota', 'highlander'), - (2019, 'ford', 'f-150'), (2020, 'ford', 'f-150'), (2021, 'ford', 'f-150'), (2022, 'ford', 'f-150'), (2023, 'ford', 'f-150'), - (2019, 'ford', 'explorer'), (2020, 'ford', 'explorer'), (2021, 'ford', 'explorer'), (2022, 'ford', 'explorer'), - (2019, 'ford', 'mustang'), (2020, 'ford', 'mustang'), (2021, 'ford', 'mustang'), (2022, 'ford', 'mustang'), - (2019, 'ford', 'escape'), (2020, 'ford', 'escape'), (2021, 'ford', 'escape'), (2022, 'ford', 'escape'), - (2019, 'chevrolet', 'silverado-1500'), (2020, 'chevrolet', 'silverado-1500'), (2021, 'chevrolet', 'silverado-1500'), (2022, 'chevrolet', 'silverado-1500'), (2023, 'chevrolet', 'silverado-1500'), - (2019, 'chevrolet', 'equinox'), (2020, 'chevrolet', 'equinox'), (2021, 'chevrolet', 'equinox'), (2022, 'chevrolet', 'equinox'), - (2020, 'chevrolet', 'tahoe'), (2021, 'chevrolet', 'tahoe'), (2022, 'chevrolet', 'tahoe'), (2023, 'chevrolet', 'tahoe'), - (2019, 'nissan', 'altima'), (2020, 'nissan', 'altima'), (2021, 'nissan', 'altima'), (2022, 'nissan', 'altima'), (2023, 'nissan', 'altima'), - (2019, 'nissan', 'rogue'), (2020, 'nissan', 'rogue'), (2021, 'nissan', 'rogue'), (2022, 'nissan', 'rogue'), (2023, 'nissan', 'rogue'), - (2020, 'hyundai', 'elantra'), (2021, 'hyundai', 'elantra'), (2022, 'hyundai', 'elantra'), (2023, 'hyundai', 'elantra'), - (2020, 'hyundai', 'tucson'), (2021, 'hyundai', 'tucson'), (2022, 'hyundai', 'tucson'), (2023, 'hyundai', 'tucson'), - (2019, 'hyundai', 'santa-fe'), (2020, 'hyundai', 'santa-fe'), (2021, 'hyundai', 'santa-fe'), (2022, 'hyundai', 'santa-fe'), - (2019, 'kia', 'sportage'), (2020, 'kia', 'sportage'), (2021, 'kia', 'sportage'), (2022, 'kia', 'sportage'), - (2019, 'kia', 'sorento'), (2020, 'kia', 'sorento'), (2021, 'kia', 'sorento'), (2022, 'kia', 'sorento'), (2023, 'kia', 'sorento'), - (2019, 'jeep', 'grand-cherokee'), (2020, 'jeep', 'grand-cherokee'), (2021, 'jeep', 'grand-cherokee'), (2022, 'jeep', 'grand-cherokee'), (2023, 'jeep', 'grand-cherokee'), - (2019, 'jeep', 'wrangler'), (2020, 'jeep', 'wrangler'), (2021, 'jeep', 'wrangler'), (2022, 'jeep', 'wrangler'), (2023, 'jeep', 'wrangler'), - (2019, 'subaru', 'outback'), (2020, 'subaru', 'outback'), (2021, 'subaru', 'outback'), (2022, 'subaru', 'outback'), (2023, 'subaru', 'outback'), - (2019, 'subaru', 'forester'), (2020, 'subaru', 'forester'), (2021, 'subaru', 'forester'), (2022, 'subaru', 'forester'), - (2019, 'mazda', 'cx-5'), (2020, 'mazda', 'cx-5'), (2021, 'mazda', 'cx-5'), (2022, 'mazda', 'cx-5'), (2023, 'mazda', 'cx-5'), - (2019, 'bmw', '3-series'), (2020, 'bmw', '3-series'), (2021, 'bmw', '3-series'), (2022, 'bmw', '3-series'), - (2019, 'mercedes-benz', 'c-class'), (2020, 'mercedes-benz', 'c-class'), (2021, 'mercedes-benz', 'c-class'), (2022, 'mercedes-benz', 'c-class'), - (2019, 'tesla', 'model-3'), (2020, 'tesla', 'model-3'), (2021, 'tesla', 'model-3'), (2022, 'tesla', 'model-3'), (2023, 'tesla', 'model-3'), -] - - -def discover_evox_urls(): - """Use Playwright to load research pages and grab evox image URLs. - - Returns: dict[(year, make, model)] = list[url] (typically 6 views per car). - """ - found = {} - with sync_playwright() as p: - browser = p.chromium.launch() - page = browser.new_page(viewport={'width': 1440, 'height': 900}, - user_agent='Mozilla/5.0 webharbor-mirror-scraper') - for year, make, model in TEMPLATE_INDEX: - url = f"https://www.carmax.com/research/{make}/{model}/{year}" - print(f" recon {year} {make} {model}", flush=True) - try: - page.goto(url, wait_until='domcontentloaded', timeout=30000) - page.wait_for_timeout(900) - imgs = page.eval_on_selector_all( - 'img', - "els => els.map(e => e.currentSrc || e.src)" - ".filter(u => u && u.includes('content-images.carmax.com/stockimages'))" - ) - if imgs: - # Dedupe by view code in URL - dedup = [] - seen = set() - for u in imgs: - m = re.search(r"st\d+-(\d+)-evoxweb", u) - code = m.group(1) if m else u - if code not in seen: - seen.add(code) - dedup.append(u) - found[(year, make, model)] = dedup - except Exception as e: - print(f" ! {e}") - browser.close() - return found - - -def download_image(client, url, dest): - if dest.exists() and dest.stat().st_size > 1024: - return False - r = client.get(url) - if r.status_code != 200: - return False - dest.write_bytes(r.content) - return True +UA = ('Mozilla/5.0 (Windows NT 10.0; Win64; x64) ' + 'AppleWebKit/537.36 (KHTML, like Gecko) ' + 'Chrome/124.0.0.0 Safari/537.36') + + +def build_url(year, make_slug, model_slug, view_code): + msk = SLUG_REMAP.get(model_slug, model_slug) + return (f"https://content-images.carmax.com/stockimages/" + f"{year}/{make_slug}/{msk}/st2400-{view_code}-evoxwebmedium.png") def main(): - # 1. Load every seeded vehicle from the DB. + # Load the seeded vehicles sys.path.insert(0, str(ROOT)) os.environ.setdefault('FLASK_RUN_FROM_CLI', '1') - from app import app, Vehicle, Store, Article - vehicles = [] + from app import app, Vehicle with app.app_context(): vehicles = Vehicle.query.order_by(Vehicle.id).all() - stores = Store.query.order_by(Store.id).all() - articles = Article.query.order_by(Article.id).all() - print(f"[scrape] {len(vehicles)} vehicles, {len(stores)} stores, " - f"{len(articles)} articles need images") - - # 2. Use Playwright to discover real evox URLs by (year, make, model). - print("[scrape] discovering image URLs via Playwright...") - url_map = discover_evox_urls() - - cache_json = SCRAPE_DIR / "image_urls.json" - cache_json.write_text(json.dumps( - {f"{y}|{mk}|{md}": urls for (y, mk, md), urls in url_map.items()}, - indent=2, - )) - print(f"[scrape] cached {sum(len(v) for v in url_map.values())} URLs to {cache_json}") - - # 3. Download per-vehicle stock photos. Each seeded vehicle gets up to - # 6 views named -{front,side,rear,dashboard,cargo,interior}.jpg. - print("[scrape] downloading vehicle images...") + print(f"[scrape] {len(vehicles)} vehicles need images") + + # Cache: (year, make_slug, model_slug) -> {view_name: bytes or None} + # Each unique (year, make, model) gets one CDN fetch per view; we then + # write the bytes under each vehicle's stock_number that matches. + cache = {} downloaded = 0 skipped = 0 - with httpx.Client(follow_redirects=True, timeout=30, - headers={'User-Agent': 'webharbor-mirror-scraper'}) as cx: + missing = 0 + + with httpx.Client(headers={'User-Agent': UA}, + follow_redirects=True, timeout=30) as cx: for v in vehicles: key = (v.year, v.make_slug, v.model_slug) - urls = url_map.get(key, []) - if not urls: - continue - views = list(VIEW_CODES.keys()) - for i, view in enumerate(views): - if i >= len(urls): - break - src = urls[i] - dest = IMG_DIR / f"{v.stock_number}-{view}.jpg" - try: - if download_image(cx, src, dest): - downloaded += 1 - else: - skipped += 1 - except Exception as e: - print(f" ! {dest.name}: {e}") - print(f"[scrape] downloaded {downloaded} new images, skipped {skipped} already-present") - - # 4. (Optional) Save a 'placeholder' for stores using carmax store icon - print("[scrape] done.") + if key not in cache: + cache[key] = {} + for view_name, view_code in VIEW_MAP.items(): + url = build_url(v.year, v.make_slug, v.model_slug, view_code) + try: + r = cx.get(url) + if r.status_code == 200 and len(r.content) > 2000: + cache[key][view_name] = r.content + else: + cache[key][view_name] = None + except Exception as e: + print(f" ! {url}: {e}") + cache[key][view_name] = None + ok_views = sum(1 for b in cache[key].values() if b) + print(f" fetched {ok_views}/6 views for " + f"{v.year} {v.make} {v.model}") + + for view_name, view_code in VIEW_MAP.items(): + dest = IMG_DIR / f"{v.stock_number}-{view_name}.jpg" + if dest.exists() and dest.stat().st_size > 2000: + skipped += 1 + continue + data = cache[key].get(view_name) + if data is None: + missing += 1 + continue + dest.write_bytes(data) + downloaded += 1 + + print(f"\n[scrape] done: downloaded={downloaded}, " + f"skipped (already present)={skipped}, " + f"missing (no CDN match)={missing}") + print(f"[scrape] {len(cache)} unique (year, make, model) tuples") + no_image = [k for k, v in cache.items() + if not any(v.values())] + if no_image: + print(f"[scrape] {len(no_image)} tuples had ZERO images " + f"(check slug mapping):") + for k in no_image[:10]: + print(f" - {k}") if __name__ == '__main__': diff --git a/sites/carmax/scraped_data/image_urls.json b/sites/carmax/scraped_data/image_urls.json new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/sites/carmax/scraped_data/image_urls.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/sites/carmax/seed_data.py b/sites/carmax/seed_data.py index 8b8a859..530c9bb 100644 --- a/sites/carmax/seed_data.py +++ b/sites/carmax/seed_data.py @@ -12,9 +12,6 @@ import json from datetime import date, datetime, timedelta -from app import (Appraisal, Article, ComparisonItem, Comparison, - FinancePreQual, Order, Reservation, Review, SavedVehicle, - Store, TestDrive, User, Vehicle, bcrypt, db) # Frozen wall-clock so seeded rows are byte-stable across reset cycles. SEED_NOW = datetime(2026, 1, 15, 12, 0, 0) @@ -524,7 +521,7 @@ def _build_vehicle_seeds(): # Pick 5 vehicle variants per template (roughly): one per trim, varied year trims = t[3] years = t[4] - colors_count = len(t[14]) + colors_count = len(t[16]) n_variants = min(5, max(4, len(trims) + 1)) for variant in range(n_variants): trim_idx = variant % len(trims) @@ -671,6 +668,7 @@ def _build_vehicle_seeds(): def seed_database(): """Create stores, vehicles, articles, reviews. Early-return if populated.""" + from app import (Article, Review, Store, Vehicle, db) if Vehicle.query.count() > 0: return @@ -715,6 +713,9 @@ def seed_database(): def seed_benchmark_users(): """Five benchmark users used by WebVoyager tasks. Idempotent.""" + from app import (Appraisal, FinancePreQual, Order, Reservation, + Review, SavedVehicle, Store, TestDrive, User, Vehicle, + bcrypt, db) if User.query.filter_by(email='alice.j@test.com').first(): return diff --git a/sites/carmax/templates/account.html b/sites/carmax/templates/account.html index 101975e..00a8e07 100644 --- a/sites/carmax/templates/account.html +++ b/sites/carmax/templates/account.html @@ -48,7 +48,7 @@

Hi, {{ current_user.first_name or 'CarMax customer' }}

{% if recent_saved %}

Recently saved

- {% from "_macros.html" import vcard %} + {% from "_macros.html" import vcard with context %}
{% for s in recent_saved %}{{ vcard(s.vehicle) }}{% endfor %}
{% endif %} diff --git a/sites/carmax/templates/index.html b/sites/carmax/templates/index.html index df48be5..964f385 100644 --- a/sites/carmax/templates/index.html +++ b/sites/carmax/templates/index.html @@ -1,5 +1,5 @@ {% extends "base.html" %} -{% from "_macros.html" import vcard %} +{% from "_macros.html" import vcard with context %} {% block content %}
diff --git a/sites/carmax/templates/pre_qual_result.html b/sites/carmax/templates/pre_qual_result.html index 944e757..8d37fd4 100644 --- a/sites/carmax/templates/pre_qual_result.html +++ b/sites/carmax/templates/pre_qual_result.html @@ -1,5 +1,5 @@ {% extends "base.html" %} -{% from "_macros.html" import vcard %} +{% from "_macros.html" import vcard with context %} {% block title %}You're pre-qualified | CarMax{% endblock %} {% block content %}
diff --git a/sites/carmax/templates/research_model_year.html b/sites/carmax/templates/research_model_year.html index 5573e07..a4016c2 100644 --- a/sites/carmax/templates/research_model_year.html +++ b/sites/carmax/templates/research_model_year.html @@ -1,5 +1,5 @@ {% extends "base.html" %} -{% from "_macros.html" import vcard %} +{% from "_macros.html" import vcard with context %} {% block title %}{{ year }} {{ make }} {{ model }} review, photos & specs | CarMax{% endblock %} {% block content %}
diff --git a/sites/carmax/templates/saved.html b/sites/carmax/templates/saved.html index 105f79d..862fb2a 100644 --- a/sites/carmax/templates/saved.html +++ b/sites/carmax/templates/saved.html @@ -1,5 +1,5 @@ {% extends "base.html" %} -{% from "_macros.html" import vcard %} +{% from "_macros.html" import vcard with context %} {% block title %}Saved cars | CarMax{% endblock %} {% block content %}
diff --git a/sites/carmax/templates/search.html b/sites/carmax/templates/search.html index 35db909..f0da5ad 100644 --- a/sites/carmax/templates/search.html +++ b/sites/carmax/templates/search.html @@ -1,5 +1,5 @@ {% extends "base.html" %} -{% from "_macros.html" import vcard %} +{% from "_macros.html" import vcard with context %} {% block title %}{{ scope_label }} | CarMax{% endblock %} {% block content %}
diff --git a/sites/carmax/templates/store_detail.html b/sites/carmax/templates/store_detail.html index 6c30732..129d0af 100644 --- a/sites/carmax/templates/store_detail.html +++ b/sites/carmax/templates/store_detail.html @@ -1,5 +1,5 @@ {% extends "base.html" %} -{% from "_macros.html" import vcard %} +{% from "_macros.html" import vcard with context %} {% block title %}{{ store.name }} | CarMax{% endblock %} {% block content %}
diff --git a/sites/carmax/templates/vehicle_detail.html b/sites/carmax/templates/vehicle_detail.html index b9482fd..5b014c6 100644 --- a/sites/carmax/templates/vehicle_detail.html +++ b/sites/carmax/templates/vehicle_detail.html @@ -1,5 +1,5 @@ {% extends "base.html" %} -{% from "_macros.html" import vcard %} +{% from "_macros.html" import vcard with context %} {% block title %}{{ vehicle.title }} | {{ vehicle.mileage|miles }} | CarMax{% endblock %} {% block content %}
From 177bdf129ac3b26c3d816b22423b3b5f56c2a769 Mon Sep 17 00:00:00 2001 From: Zihao Li Date: Fri, 15 May 2026 02:06:37 -0500 Subject: [PATCH 3/6] Create tasks.jsonl --- sites/carmax/tasks.jsonl | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 sites/carmax/tasks.jsonl diff --git a/sites/carmax/tasks.jsonl b/sites/carmax/tasks.jsonl new file mode 100644 index 0000000..869226f --- /dev/null +++ b/sites/carmax/tasks.jsonl @@ -0,0 +1,18 @@ +{"web_name": "CarMax", "id": "CarMax--0", "ques": "Find any 2022 Honda Civic in the inventory and report its full title, price, and mileage.", "web": "http://localhost:40015/", "upstream_url": "https://www.carmax.com/"} +{"web_name": "CarMax", "id": "CarMax--1", "ques": "Search for a Toyota Tacoma TRD Off-Road in the inventory and report its store location, mileage, and asking price.", "web": "http://localhost:40015/", "upstream_url": "https://www.carmax.com/"} +{"web_name": "CarMax", "id": "CarMax--2", "ques": "Filter the inventory for AWD SUVs under $25,000 sorted by lowest price. Report the year, make, model, trim and price of the cheapest one.", "web": "http://localhost:40015/", "upstream_url": "https://www.carmax.com/"} +{"web_name": "CarMax", "id": "CarMax--3", "ques": "Search for a Tesla Model 3 with under 50,000 miles. Report how many vehicles match and the price of the lowest-mileage one.", "web": "http://localhost:40015/", "upstream_url": "https://www.carmax.com/"} +{"web_name": "CarMax", "id": "CarMax--4", "ques": "Open the detail page for any 2022 Honda CR-V in inventory and report its horsepower, combined MPG, exterior color, and the store it is located at.", "web": "http://localhost:40015/", "upstream_url": "https://www.carmax.com/"} +{"web_name": "CarMax", "id": "CarMax--5", "ques": "On the 2022 Honda Civic research page, list every available trim, then report both the RepairPal reliability rating and the average customer rating shown on that page.", "web": "http://localhost:40015/", "upstream_url": "https://www.carmax.com/"} +{"web_name": "CarMax", "id": "CarMax--6", "ques": "Add three vehicles to the comparison tool: a 2022 Honda Accord, a 2022 Toyota Camry, and a 2022 Nissan Altima. Then report which of the three has the most horsepower and which has the best combined MPG.", "web": "http://localhost:40015/", "upstream_url": "https://www.carmax.com/"} +{"web_name": "CarMax", "id": "CarMax--7", "ques": "Get an instant offer to sell a 2018 Toyota Camry LE with 78,500 miles in good condition, ZIP 30303, no reported accidents, one previous owner. Report the dollar offer amount and the expiration date.", "web": "http://localhost:40015/", "upstream_url": "https://www.carmax.com/"} +{"web_name": "CarMax", "id": "CarMax--8", "ques": "Go to the CarMax store locator and report (a) how many states have at least one CarMax store and (b) the street address of any CarMax store in California.", "web": "http://localhost:40015/", "upstream_url": "https://www.carmax.com/"} +{"web_name": "CarMax", "id": "CarMax--9", "ques": "Create a new CarMax account with first name Test, last name Buyer, email new.buyer.benchmark@test.com, phone 4045550199, ZIP 30303, password Welcome2026. After signing in, get pre-qualified with $80,000 annual income, employed full-time, $500 max monthly payment, $2,000 down, 72-month term, good credit. Report the estimated APR shown on the result page.", "web": "http://localhost:40015/", "upstream_url": "https://www.carmax.com/"} +{"web_name": "CarMax", "id": "CarMax--10", "ques": "Sign in as alice.j@test.com with password CarMax!2026. Reserve any 2022 Toyota Camry for 7 days, with the appointment date 2026-05-20. Then go to your reservations page and confirm the reservation is listed as active.", "web": "http://localhost:40015/", "upstream_url": "https://www.carmax.com/"} +{"web_name": "CarMax", "id": "CarMax--11", "ques": "Sign in as bob.k@test.com with password CarMax!2026. Schedule an at-home test drive for any 2022 Ford F-150 on 2026-05-22 at 2:00 PM, leaving a note that says 'Please call gate buzzer 4B'. Confirm the test drive shows on your test drives page.", "web": "http://localhost:40015/", "upstream_url": "https://www.carmax.com/"} +{"web_name": "CarMax", "id": "CarMax--12", "ques": "Find the answer in the CarMax FAQ to the question 'How long is my appraisal offer good for?' and report the number of days the offer is valid.", "web": "http://localhost:40015/", "upstream_url": "https://www.carmax.com/"} +{"web_name": "CarMax", "id": "CarMax--13", "ques": "Sign in as alice.j@test.com with password CarMax!2026. You currently have multiple saved cars; remove the saved vehicle with the higher mileage and report the year, make, and model of the remaining saved vehicle.", "web": "http://localhost:40015/", "upstream_url": "https://www.carmax.com/"} +{"web_name": "CarMax", "id": "CarMax--14", "ques": "Sign in as carol.l@test.com with password CarMax!2026. You have one active sell-my-car appraisal offer; identify which vehicle it is for and the offer amount. Then start checkout on any 2022 Honda CR-V, apply that appraisal as a trade-in, choose CarMax Auto Finance with a 60-month term, $3,000 down, 6.49% APR, no MaxCare, and place the order. Report the final order number and total amount.", "web": "http://localhost:40015/", "upstream_url": "https://www.carmax.com/"} +{"web_name": "CarMax", "id": "CarMax--15", "ques": "Find the cheapest 2023 vehicle currently in stock. Open its detail page, then visit the store that has it. Report (a) the vehicle's year, make, model, and price, (b) the store name and city, and (c) whether that store offers home delivery.", "web": "http://localhost:40015/", "upstream_url": "https://www.carmax.com/"} +{"web_name": "CarMax", "id": "CarMax--16", "ques": "Open the article titled 'Getting Pre-Qualified: Shop with Personalized Financing Terms'. According to the article, what is the key difference between pre-qualification and pre-approval at CarMax? Answer in one sentence.", "web": "http://localhost:40015/", "upstream_url": "https://www.carmax.com/"} +{"web_name": "CarMax", "id": "CarMax--17", "ques": "Sign in as dan.m@test.com with password CarMax!2026. Find your account's order history and report (a) the order number, (b) the vehicle year/make/model, (c) the total amount, (d) whether MaxCare coverage was included, and (e) the scheduled pickup date.", "web": "http://localhost:40015/", "upstream_url": "https://www.carmax.com/"} From 1b577f6685d20644bf7ed7cb2a716e31b3bf7e40 Mon Sep 17 00:00:00 2001 From: Zihao Li Date: Fri, 15 May 2026 10:05:40 -0500 Subject: [PATCH 4/6] phase1 docker check passed, phase 2 & 3 finished; update LR windows/linux issue --- .gitattributes | 5 ++ scripts/verify_carmax.sh | 85 ++++++++++++++++++++++++++++++++ sites/carmax/instance/carmax.db | Bin 380928 -> 380928 bytes sites/carmax/seed_data.py | 4 +- sites/carmax/tasks.jsonl | 2 + 5 files changed, 94 insertions(+), 2 deletions(-) create mode 100644 .gitattributes create mode 100644 scripts/verify_carmax.sh diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..ccf5c68 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,5 @@ +# Force LF line endings for shell scripts so they execute in Linux containers +# even when checked out on Windows. +*.sh text eol=lf +*.bash text eol=lf +Dockerfile text eol=lf diff --git a/scripts/verify_carmax.sh b/scripts/verify_carmax.sh new file mode 100644 index 0000000..a3973e0 --- /dev/null +++ b/scripts/verify_carmax.sh @@ -0,0 +1,85 @@ +#!/usr/bin/env bash +# Phase 1.9 — Docker container verification for the carmax mirror. +# Runs build + run + reset + md5sum + cleanup. +# +# Usage: bash scripts/verify_carmax.sh +set -uo pipefail + +cd "$(dirname "$0")/.." + +CONTAINER=wh-test +PORT_CONTROL=8201 +PORT_RANGE_LOW=41000 +PORT_RANGE_HIGH=41015 +SITE=carmax +SITE_PORT=41015 + +cleanup() { + docker stop "$CONTAINER" >/dev/null 2>&1 || true +} +trap cleanup EXIT + +echo "" +echo "============================================================" +echo " Phase 1.9 — Docker verification for site: $SITE" +echo "============================================================" + +# ---- Step 1: clean stale container ---- +echo "" +echo "[1/6] Stop any stale test container..." +docker stop "$CONTAINER" >/dev/null 2>&1 || true + +# ---- Step 2: build image ---- +echo "" +echo "[2/6] Build webharbor:dev (first time ~3 min, warm cache ~30 s)..." +bash scripts/build.sh webharbor:dev || { echo "BUILD FAILED"; exit 1; } + +# ---- Step 3: run container on alt ports ---- +echo "" +echo "[3/6] Run container on alt ports ($PORT_CONTROL + $PORT_RANGE_LOW-$PORT_RANGE_HIGH)..." +docker run -d --rm --name "$CONTAINER" \ + -p "$PORT_CONTROL:8101" \ + -p "$PORT_RANGE_LOW-$PORT_RANGE_HIGH:40000-40015" \ + webharbor:dev >/dev/null \ + || { echo "DOCKER RUN FAILED"; exit 1; } + +echo " container started. waiting 35s for all 16 sites to boot..." +sleep 35 + +# ---- Step 4: health check ---- +echo "" +echo "[4/6] Control plane health + HTTP 200 sweep:" +curl -s "http://localhost:$PORT_CONTROL/health" \ + | python -m json.tool 2>/dev/null \ + | head -50 || echo " (control plane not responding — see docker logs $CONTAINER)" + +echo "" +echo " Per-site HTTP status:" +for p in $(seq $PORT_RANGE_LOW $PORT_RANGE_HIGH); do + code=$(curl -so /dev/null -w "%{http_code}" "http://localhost:$p/" || echo "ERR") + printf " :%d %s\n" "$p" "$code" +done + +# ---- Step 5: byte-identical reset ---- +echo "" +echo "[5/6] /reset/$SITE (the strict invariant)..." +RESET_RESP=$(curl -sX POST "http://localhost:$PORT_CONTROL/reset/$SITE") +echo " reset response: $RESET_RESP" + +echo "" +echo " md5sum (the two values MUST match):" +# MSYS_NO_PATHCONV=1 disables Git Bash's path translation on Windows +# (otherwise /opt/WebSyn becomes C:/Program Files/Git/opt/WebSyn). +MSYS_NO_PATHCONV=1 docker exec "$CONTAINER" md5sum \ + "/opt/WebSyn/$SITE/instance/$SITE.db" \ + "/opt/WebSyn/$SITE/instance_seed/$SITE.db" + +# ---- Step 6: result ---- +echo "" +echo "[6/6] Verification done." +echo "" +echo "============================================================" +echo " If both md5sums above are identical -> Phase 1 PASSED ✅" +echo " If they differ -> seed function isn't idempotent, see" +echo " seed-database skill for diagnosis." +echo "============================================================" diff --git a/sites/carmax/instance/carmax.db b/sites/carmax/instance/carmax.db index 0d06c70fd0c32f8bc7174ebe6c6acb8fcb66f092..b98187b2a0f7d24ab6ec4f897fbb30f532b63b82 100644 GIT binary patch delta 1058 zcmc(eO=wd=5Xaxl%iE-Rd3n!R^ZH@UN2|7KH0eidlqNYSdQhxX6tNh=RtZv-*xCw; z0WBV+*qXyqL|f}e#eDK$71-%O7)ohh z5K3{IEq|v|B?wX6BGcz#&`q6*cn94V1TX#E1iI0ST~_Y~6VYo*p*_C8t-UAS6Fby- zAkls(wmaT=q~J=a2g51ga^mhcfy;0+sY}J^V*;*h~dD zaAZnbr3ixHFeL=iN<`_pw8r$#lu$3J8|~Bf0sB(rmMtXzl&#jYmMdgj5J&}2|Gmgj zx;TU`dOn1uG!{aq>BIt;nI--3NyNPx9gM(|fAn2;H3*^^JDs#L0?Rl=yv6Hit_k+w zmf}1-m7JxoVen(yTnb?*p~u582b_80R)ga=Ui5T5*kE1(!hQ{O+%bm^Ht?r&^W{AB zLjyQ3gO=U}k~S(e13wOe#-u23e7YYVsvxHVeP!&!5R_{mv&>Df9b_bmSy#CwEm%b& zC_CluqE|&Hi(Hm_AyKRM880 z88L)jaMOh$^vt5`H<3MS!d1MQ8?!2dF_7GhBymU;->SRVwKG^<{C9L0@DJ3c$1q{x z(EA(N;R)=6LKXi?X1$Fwf1*GG%iY08K=}OdO!o9HF2{Vphv`&q61j=oOe{Akf%XgK CIx)ro delta 968 zcmcJOO-NKx6vy8=@5AxUJl}1!o-?D&mxVy+aX5Jcf3D=Jsv%D~0cB7~f1dLy-KQxM#9`5!L#fA2Z>cL!72 zU`iW3@2OC;>Y9DV-YI{xZ7X<(Ll}a$FaU&`NH&!xCtu+qh2D)r5Q+@1U8Bo!@L`w3 zxD1-{85*(6ZpjLx1Kq|fxXdB!m$6+iZYf?6^BaseN}18DwBcSfzB|0Y8)Zt!%qXQ! zQ)*mSr*(;#lFt%}MZTautk+t@{LsFH1$E&_O<1on)14*?wc%RbJbU0a3X!~W9{$8b zow?p^Puvj~i7C!!)o~w?>1EN!#74-fRSL&(G7fP{MoB6A9qWItk!wpfd6JVw0>@#L zw#C6uFA#9k;W%jjsd$zt2PZxPfrOkwA1AenUqvcLgcm}eN>zW+LeU8&r`&gh?O)^{ z@)g@nX`JJva>Gydp|qV1>#)Mp`Brpb2Qeb3@n?^np{EH1|LM|fx*qrf?AT&i1;IsM z*MZA8hZ@}xgOhl?goa~KN<(odq?0iSp|4-0Ie13^csxq-+<2VDoQI zWGcp>5>IVPPL7yW$2{8fI)C<;APG!~fC_Gawej+1o z_Hhh61Vpy}5FEWsN?=y?*wUQLOPq4VeyQ+%VSi!J_EcK4_4AVS()5#6WYTV&5wRyg z{ViBUSDMjHds;BS`z4L;)WHi|=|LT;vD-)Io3WBkw4jUTn$gFu>9EQ(tG#N*cs5m0zk$v3u9Cs^srb&9fW{*EFQ3u+L@@tT(WM861IME>R{w#>_?*4eL>s gdxFnF+V~6+wlIs;m`|*wtGPL}g116oxj7NYPo3x&!2kdN diff --git a/sites/carmax/seed_data.py b/sites/carmax/seed_data.py index 530c9bb..f6c0d50 100644 --- a/sites/carmax/seed_data.py +++ b/sites/carmax/seed_data.py @@ -810,7 +810,7 @@ def seed_benchmark_users(): v37 = db.session.get(Vehicle, 37) if v1: db.session.add(SavedVehicle(user_id=alice.id, vehicle_id=v1.id, saved_at=SEED_NOW)) - if v5: db.session.add(SavedVehicle(user_id=alice.id, vehicle_id=v5.id, saved_at=SEED_NOW)) + if v11: db.session.add(SavedVehicle(user_id=alice.id, vehicle_id=v11.id, saved_at=SEED_NOW)) # 2021 Honda CR-V LX — disambig from v1 if v7: db.session.add(SavedVehicle(user_id=bob.id, vehicle_id=v7.id, saved_at=SEED_NOW)) if v11: db.session.add(SavedVehicle(user_id=bob.id, vehicle_id=v11.id, saved_at=SEED_NOW)) if v23: db.session.add(SavedVehicle(user_id=carol.id, vehicle_id=v23.id, saved_at=SEED_NOW)) @@ -886,7 +886,7 @@ def seed_benchmark_users(): tax=v37.price * 0.0625, title_fee=99, registration_fee=55, total=(v37.price + (v37.transfer_fee or 0) + v37.price * 0.0625 - + 99 + 55), + + 99 + 55 + 1895), # includes MaxCare gold maxcare_plan='gold', maxcare_price=1895, payment_method='carmax_auto_finance', payment_last4='1234', payment_apr=6.49, diff --git a/sites/carmax/tasks.jsonl b/sites/carmax/tasks.jsonl index 869226f..faa6e0d 100644 --- a/sites/carmax/tasks.jsonl +++ b/sites/carmax/tasks.jsonl @@ -16,3 +16,5 @@ {"web_name": "CarMax", "id": "CarMax--15", "ques": "Find the cheapest 2023 vehicle currently in stock. Open its detail page, then visit the store that has it. Report (a) the vehicle's year, make, model, and price, (b) the store name and city, and (c) whether that store offers home delivery.", "web": "http://localhost:40015/", "upstream_url": "https://www.carmax.com/"} {"web_name": "CarMax", "id": "CarMax--16", "ques": "Open the article titled 'Getting Pre-Qualified: Shop with Personalized Financing Terms'. According to the article, what is the key difference between pre-qualification and pre-approval at CarMax? Answer in one sentence.", "web": "http://localhost:40015/", "upstream_url": "https://www.carmax.com/"} {"web_name": "CarMax", "id": "CarMax--17", "ques": "Sign in as dan.m@test.com with password CarMax!2026. Find your account's order history and report (a) the order number, (b) the vehicle year/make/model, (c) the total amount, (d) whether MaxCare coverage was included, and (e) the scheduled pickup date.", "web": "http://localhost:40015/", "upstream_url": "https://www.carmax.com/"} +{"web_name": "CarMax", "id": "CarMax--18", "ques": "Visit the CarMax used car value page for the 2020 Honda Accord. Report (a) the CarMax average price across current inventory, (b) the price range (lowest to highest), and (c) the number of 2020 Honda Accords currently in stock.", "web": "http://localhost:40015/", "upstream_url": "https://www.carmax.com/"} +{"web_name": "CarMax", "id": "CarMax--19", "ques": "Open the MaxCare extended service plans page. Compare the Silver, Gold, and Platinum tiers. Report (a) the one-time price of each tier, (b) the price difference between Gold and Silver, and (c) the maximum coverage period (months / miles) of the Platinum plan.", "web": "http://localhost:40015/", "upstream_url": "https://www.carmax.com/"} From a6807284098b4731b9da00ff9a7449830560a96d Mon Sep 17 00:00:00 2001 From: Zihao Li Date: Fri, 15 May 2026 11:19:59 -0500 Subject: [PATCH 5/6] phase 3 done. scraped images --- sites/carmax/instance/carmax.db | Bin 380928 -> 380928 bytes sites/carmax/scrape_articles.py | 107 ++++++++++++++++++++++++++++++++ sites/carmax/seed_data.py | 5 +- sites/carmax/tasks.jsonl | 4 +- 4 files changed, 113 insertions(+), 3 deletions(-) create mode 100644 sites/carmax/scrape_articles.py diff --git a/sites/carmax/instance/carmax.db b/sites/carmax/instance/carmax.db index b98187b2a0f7d24ab6ec4f897fbb30f532b63b82..2db475fdbf104d989fe4f1758a87329c507f6cd6 100644 GIT binary patch delta 358 zcmZozAl|S*e1bHi=0q81M$N{AtqF`v*0VA4Z)D)#xLt7p<8uB;Uq&_t248hY3)3`n zi?k#o0|QHw)Wo!uG_y1#BLg!dOH&Ku6f?_2-%5q#{M>@XqRis_JR<`mGhG8yT|-j^ zLsKgwb1OpwJqt4<3qw;b1_lO3{yPl(ceV>2V7$byqRi~eNyzMN7Z_P4a53}GV&K2W zzlVPo$Z0+N^$N_a44jOP92mA4S?C!Wo0%I}Dlman_+hFr&@(hNH@C1z)%5l zv8929xiQGa%=~W{_}}pV;{Ocfz`%By1B{pXMc7z485ud4!IU2pE0D)2(bmA!*1+7> Kz_P4?H2?sSm{JY^ delta 100 zcmZozAl|S*e1bHi>O>i5M%BiItqF`v)-y2(Y!{rsbccVkfq)zv7XuI=32eK-$T9)H SlC}n>wg%?529{+FtN{Rr=^Kpz diff --git a/sites/carmax/scrape_articles.py b/sites/carmax/scrape_articles.py new file mode 100644 index 0000000..5c41865 --- /dev/null +++ b/sites/carmax/scrape_articles.py @@ -0,0 +1,107 @@ +#!/usr/bin/env python3 +"""Download CarMax article hero images. + +Each seeded article references /static/images/articles/.jpg. This +script fetches the real CarMax content-images.carmax.com hero image for +each article and saves it locally. + +Run from the repo root: + pip install httpx + python sites/carmax/scrape_articles.py +""" +import os +import pathlib +import sys + +# Force UTF-8 stdout so any unicode print does not crash on Windows GBK console. +try: + sys.stdout.reconfigure(encoding='utf-8', errors='replace') +except Exception: + pass + +try: + import httpx +except ImportError: + sys.exit("missing httpx. install with: pip install httpx") + +ROOT = pathlib.Path(__file__).resolve().parent +OUT = ROOT / "static" / "images" / "articles" +OUT.mkdir(parents=True, exist_ok=True) + +UA = ('Mozilla/5.0 (Windows NT 10.0; Win64; x64) ' + 'AppleWebKit/537.36 (KHTML, like Gecko) ' + 'Chrome/124.0.0.0 Safari/537.36') + +# slug (matches seed_data.py) -> hero image URL on carmax content CDN +ARTICLE_HEROES = { + 'how-carmax-works': + 'https://content-images.carmax.com/qeontfmijmzv/3pDBIajbVpFy688qLx6XdN/' + '357c3453f4691ba733764186573ae7b0/01-240117_CarMax_Store_052_2x.jpg', + 'how-to-sell-your-car-to-carmax': + 'https://content-images.carmax.com/qeontfmijmzv/7DIdfvK5g5050BjRMti3bj/' + '71bbced50424d75704b715267d971ad4/' + '02-How_to_Sell_Your_Car_GettyImages-1291494870_HiRes_2.jpg', + 'pre-approval-vs-pre-qualified': + 'https://content-images.carmax.com/qeontfmijmzv/44LpZ9Yk7L3HFoR8sjM0Df/' + '0dbf783f7232012f6781173f87bfa743/' + 'NEW_Hero_Fullwidth_1440x780_GETTYimages.jpg', + 'best-compact-sedan-honda-civic-vs-toyota-corolla-vs-nissan-sentra': + 'https://content-images.carmax.com/qeontfmijmzv/7pHQrxB3gpMjo5bdDP01aC/' + '802b0a22a85e782ee45fe10a510d628a/' + 'Best_Compact_Sedan_Civic-Corolla-Sentra_Hero_Fullwidth_1440x625.jpg', + 'best-hatchback-cars-ranking': + 'https://content-images.carmax.com/qeontfmijmzv/60SzfaTFIamvIj9EmTKeO9/' + '149aaf80562785d7bf8763c7275b56a4/' + '474204_BestHatchbackCars_Hero_Fullwidth_1440x500_2.png', + 'how-to-buy-a-used-car': + 'https://content-images.carmax.com/qeontfmijmzv/4lXC25xUvsSh54qogDVRWq/' + '16c2c97f6ffd238f3de05032a14a7f9f/' + '11-24-25-How_to_Buy_a_Used_Car-GettyImages-2152358874_HiRes.jpg', + 'maxcare-explained': + 'https://content-images.carmax.com/qeontfmijmzv/X7vHNvN8eaeOUTblYEq4b/' + '5fec55afd57715736eb2a7516787e628/' + 'maxcare-explained_Hero_Fullwidth_1440x625.jpg', + 'first-time-car-buyer': + 'https://content-images.carmax.com/qeontfmijmzv/31NdNXfNgoLwSy9uEd6cYP/' + '94b8c26ca9c4b19ac3f3ccf7ba53d6af/' + '601201_Edmunds_5-Steps-to-Financing-Your-First-Car_Hero_Fullwidth_1440x625.jpg', + 'best-high-mpg-cars': + 'https://content-images.carmax.com/qeontfmijmzv/2H3DQYX05wSeyyS2OFRMKL/' + '9e06e606f995eca5aad0246e6556e289/' + '617103_Best-High-MPG-Cars_Hero_Fullwidth_1440x625.jpg', + 'attainable-dream-cars-under-50000': + 'https://content-images.carmax.com/qeontfmijmzv/605BZbR1bg8CoqsBYwB3L/' + 'a687fe4756f7dae4df831449f2620f1d/' + 'Dream_Cars_on_a_Budge_Hero_Fullwidth_1440x625.jpg', +} + + +def main(): + print(f"[articles] downloading {len(ARTICLE_HEROES)} article hero images") + ok = skipped = failed = 0 + with httpx.Client(headers={'User-Agent': UA}, + follow_redirects=True, timeout=30) as cx: + for slug, url in ARTICLE_HEROES.items(): + dest = OUT / f"{slug}.jpg" + if dest.exists() and dest.stat().st_size > 2000: + print(f" -- skipping {slug} (already present)") + skipped += 1 + continue + try: + r = cx.get(url) + if r.status_code == 200 and len(r.content) > 2000: + dest.write_bytes(r.content) + print(f" [OK] {slug} ({len(r.content) // 1024} KB)") + ok += 1 + else: + print(f" [!!] {slug}: HTTP {r.status_code}") + failed += 1 + except Exception as e: + print(f" [!!] {slug}: {e}") + failed += 1 + print(f"\n[articles] done: downloaded={ok}, skipped={skipped}, failed={failed}") + print(f"[articles] images saved under {OUT}") + + +if __name__ == '__main__': + main() diff --git a/sites/carmax/seed_data.py b/sites/carmax/seed_data.py index f6c0d50..f03dd5f 100644 --- a/sites/carmax/seed_data.py +++ b/sites/carmax/seed_data.py @@ -398,9 +398,12 @@ def _vehicle_for(template_idx, trim_idx, year_idx, store_idx, color_idx, sporty = any(w in trim for w in ('Sport', 'SI', 'AMG', 'Performance', 'Mach', 'TRD', 'Rubicon', 'M340')) if sporty: - for f in ('Rear Spoiler', 'Alloy Wheels', 'Turbo Charged Engine'): + for f in ('Rear Spoiler', 'Alloy Wheels'): if f not in feats: feats.append(f) + # Cross-field consistency: only claim Turbo feature when the engine actually is turbo + if 'Turbo' in engine and 'Turbo Charged Engine' not in feats: + feats.append('Turbo Charged Engine') if 'Electric' in fuel: feats = [f for f in feats if 'Turbo' not in f] feats.append('All-Electric Drivetrain') diff --git a/sites/carmax/tasks.jsonl b/sites/carmax/tasks.jsonl index faa6e0d..2e318c0 100644 --- a/sites/carmax/tasks.jsonl +++ b/sites/carmax/tasks.jsonl @@ -1,7 +1,7 @@ {"web_name": "CarMax", "id": "CarMax--0", "ques": "Find any 2022 Honda Civic in the inventory and report its full title, price, and mileage.", "web": "http://localhost:40015/", "upstream_url": "https://www.carmax.com/"} {"web_name": "CarMax", "id": "CarMax--1", "ques": "Search for a Toyota Tacoma TRD Off-Road in the inventory and report its store location, mileage, and asking price.", "web": "http://localhost:40015/", "upstream_url": "https://www.carmax.com/"} {"web_name": "CarMax", "id": "CarMax--2", "ques": "Filter the inventory for AWD SUVs under $25,000 sorted by lowest price. Report the year, make, model, trim and price of the cheapest one.", "web": "http://localhost:40015/", "upstream_url": "https://www.carmax.com/"} -{"web_name": "CarMax", "id": "CarMax--3", "ques": "Search for a Tesla Model 3 with under 50,000 miles. Report how many vehicles match and the price of the lowest-mileage one.", "web": "http://localhost:40015/", "upstream_url": "https://www.carmax.com/"} +{"web_name": "CarMax", "id": "CarMax--3", "ques": "Search the inventory for a Tesla Model 3 with under 50,000 miles. Then sort the results by lowest mileage and open the detail page of the lowest-mileage one. Report its price, mileage, exterior color, and the store it's at.", "web": "http://localhost:40015/", "upstream_url": "https://www.carmax.com/"} {"web_name": "CarMax", "id": "CarMax--4", "ques": "Open the detail page for any 2022 Honda CR-V in inventory and report its horsepower, combined MPG, exterior color, and the store it is located at.", "web": "http://localhost:40015/", "upstream_url": "https://www.carmax.com/"} {"web_name": "CarMax", "id": "CarMax--5", "ques": "On the 2022 Honda Civic research page, list every available trim, then report both the RepairPal reliability rating and the average customer rating shown on that page.", "web": "http://localhost:40015/", "upstream_url": "https://www.carmax.com/"} {"web_name": "CarMax", "id": "CarMax--6", "ques": "Add three vehicles to the comparison tool: a 2022 Honda Accord, a 2022 Toyota Camry, and a 2022 Nissan Altima. Then report which of the three has the most horsepower and which has the best combined MPG.", "web": "http://localhost:40015/", "upstream_url": "https://www.carmax.com/"} @@ -11,7 +11,7 @@ {"web_name": "CarMax", "id": "CarMax--10", "ques": "Sign in as alice.j@test.com with password CarMax!2026. Reserve any 2022 Toyota Camry for 7 days, with the appointment date 2026-05-20. Then go to your reservations page and confirm the reservation is listed as active.", "web": "http://localhost:40015/", "upstream_url": "https://www.carmax.com/"} {"web_name": "CarMax", "id": "CarMax--11", "ques": "Sign in as bob.k@test.com with password CarMax!2026. Schedule an at-home test drive for any 2022 Ford F-150 on 2026-05-22 at 2:00 PM, leaving a note that says 'Please call gate buzzer 4B'. Confirm the test drive shows on your test drives page.", "web": "http://localhost:40015/", "upstream_url": "https://www.carmax.com/"} {"web_name": "CarMax", "id": "CarMax--12", "ques": "Find the answer in the CarMax FAQ to the question 'How long is my appraisal offer good for?' and report the number of days the offer is valid.", "web": "http://localhost:40015/", "upstream_url": "https://www.carmax.com/"} -{"web_name": "CarMax", "id": "CarMax--13", "ques": "Sign in as alice.j@test.com with password CarMax!2026. You currently have multiple saved cars; remove the saved vehicle with the higher mileage and report the year, make, and model of the remaining saved vehicle.", "web": "http://localhost:40015/", "upstream_url": "https://www.carmax.com/"} +{"web_name": "CarMax", "id": "CarMax--13", "ques": "Sign in as alice.j@test.com with password CarMax!2026. You currently have two saved cars from different makes; remove the saved vehicle with the higher mileage. Then report the year, make, model, and store location of the remaining saved vehicle.", "web": "http://localhost:40015/", "upstream_url": "https://www.carmax.com/"} {"web_name": "CarMax", "id": "CarMax--14", "ques": "Sign in as carol.l@test.com with password CarMax!2026. You have one active sell-my-car appraisal offer; identify which vehicle it is for and the offer amount. Then start checkout on any 2022 Honda CR-V, apply that appraisal as a trade-in, choose CarMax Auto Finance with a 60-month term, $3,000 down, 6.49% APR, no MaxCare, and place the order. Report the final order number and total amount.", "web": "http://localhost:40015/", "upstream_url": "https://www.carmax.com/"} {"web_name": "CarMax", "id": "CarMax--15", "ques": "Find the cheapest 2023 vehicle currently in stock. Open its detail page, then visit the store that has it. Report (a) the vehicle's year, make, model, and price, (b) the store name and city, and (c) whether that store offers home delivery.", "web": "http://localhost:40015/", "upstream_url": "https://www.carmax.com/"} {"web_name": "CarMax", "id": "CarMax--16", "ques": "Open the article titled 'Getting Pre-Qualified: Shop with Personalized Financing Terms'. According to the article, what is the key difference between pre-qualification and pre-approval at CarMax? Answer in one sentence.", "web": "http://localhost:40015/", "upstream_url": "https://www.carmax.com/"} From 4220681a81383da9ac4f1b7088440f57028a26ba Mon Sep 17 00:00:00 2001 From: Zihao Li Date: Fri, 15 May 2026 11:36:05 -0500 Subject: [PATCH 6/6] feat(carmax): add the 16th mirror site Adds a Flask mirror of carmax.com. - 13 SQLAlchemy models (User / Store / Vehicle / SavedVehicle / Comparison + ComparisonItem / Reservation / TestDrive / Appraisal / FinancePreQual / Order / Review / Article) - 59 routes covering search / browse / detail / research / compare / saved / sell-my-car / pre-qual / reserve / test-drive / checkout / account / articles / FAQ / MaxCare / stores / auth - Token-overlap scored search with multi-field weighting - 141 deterministically-seeded vehicles across 31 templates - 12 real CarMax store locations - 5 benchmark users with pre-populated saved/reservation/test-drive/ appraisal/order data - 20 WebVoyager tasks in tasks.jsonl (6 Easy / 9 Medium / 5 Hard, including 2 disambiguation tasks) - Idempotent seed at function level; byte-identical reset verified --- sites/carmax/_bash_test.txt | 1 - sites/carmax/_bashwrite_test.py | 5 ----- sites/carmax/instance/carmax.db | Bin 380928 -> 380928 bytes 3 files changed, 6 deletions(-) delete mode 100644 sites/carmax/_bash_test.txt delete mode 100644 sites/carmax/_bashwrite_test.py diff --git a/sites/carmax/_bash_test.txt b/sites/carmax/_bash_test.txt deleted file mode 100644 index 396b34f..0000000 --- a/sites/carmax/_bash_test.txt +++ /dev/null @@ -1 +0,0 @@ -from bash diff --git a/sites/carmax/_bashwrite_test.py b/sites/carmax/_bashwrite_test.py deleted file mode 100644 index d14068c..0000000 --- a/sites/carmax/_bashwrite_test.py +++ /dev/null @@ -1,5 +0,0 @@ -#!/usr/bin/env python3 -"""Test file written by bash.""" -x = 1 -y = 2 -print(x + y) diff --git a/sites/carmax/instance/carmax.db b/sites/carmax/instance/carmax.db index 2db475fdbf104d989fe4f1758a87329c507f6cd6..b2443263885eca5cd8fd553c49bc49c2454c4134 100644 GIT binary patch delta 3076 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 GDE@CJ(d;Gw