What you're actually buying into, not just the listing.
Built end-to-end — Barcelona, Madrid, and Valencia coverage, city-aware tax/legal logic, an 8-dimension scoring engine, a documented incident-driven review protocol — then open-sourced under MIT once the build was complete, so the engineering could be useful (and visible) beyond a single hosted app. Issues and PRs are welcome.
Read more:
- I shipped a property analysis tool 4 days ago — what happened next and what I learned building with AI (Medium)
- Launch post (LinkedIn)
Enter any address in Barcelona, Madrid, or Valencia and get a report covering what estate agents don't tell you and sellers aren't legally required to disclose:
- AI verdict — plain-language summary generated by Claude, personalised to your buyer profile
- Market position — how the asking price sits against active Fotocasa listings in the same district, with comparable table and percentile bar
- Acquisition cost breakdown — total cash needed including ITP/IVA, notary, registry, gestoria
- Seller economics — estimated seller costs (agency commission, plusvalía, energy cert) → seller floor price and negotiation headroom
- Hidden cost breakdown — IBI, community fees, derrama risk, energy upgrade liability
- Pre-purchase disclosures — 4–6 must-know items before signing (legal, structural, cost, neighbourhood)
- Tourist apartment pressure — Airbnb density in the building and within 500m
- School quality composite — type, language of instruction, proximity, Google ratings
- Noise ecosystem — day / night / weekend scores, floor-adjusted
- Neighbourhood trajectory — rising, stable, or declining area
- 8-dimension composite score — Convenience, Safety, Property, Market, Risk, Liveability, Hidden Costs, Intangible
- Personalised buyer questionnaire — 8 questions adjust scoring weights to your situation
- Compare mode — AI side-by-side comparison of 2–3 properties
- Bilingual — full English and Chinese (中文) interface
| Feature | Spanish portals | Beyond Price |
|---|---|---|
| Acquisition cost calculator (ITP + fees) | ❌ | ✅ |
| Seller floor price & negotiation room | ❌ | ✅ |
| Tourist apartment saturation | ❌ | ✅ |
| School quality (not just proximity) | ❌ | ✅ |
| Floor-adjusted noise analysis | ❌ | ✅ |
| Hidden costs (IBI + community + derrama) | ❌ | ✅ |
| AI plain-language verdict | ❌ | ✅ |
| Multi-property AI comparison | ❌ | ✅ |
| Neighbourhood trajectory | ❌ | ✅ |
| Bilingual EN / 中文 | ❌ | ✅ |
- Python 3.11+
- Node 20+
- Docker Desktop (for Redis — optional, degrades gracefully without it)
- Claude Code installed and authenticated (
claude --version)
git clone https://github.com/Hao1992/property_analysis.git
cd property_analysis
# Backend
python -m venv .venv && source .venv/bin/activate
pip install -r backend/requirements.txt
# Frontend
cd frontend && npm installcp backend/.env.example backend/.env
# Required: GOOGLE_PLACES_API_KEY (Google Cloud free tier — see .env.example for setup notes)
# AI narrative — pick one:
# - Local dev: leave ANTHROPIC_API_KEY unset, set USE_CLAUDE_CLI=true (routes through
# `claude -p`, requires a Claude Code subscription, zero extra API billing)
# - Server deployment: set ANTHROPIC_API_KEY=sk-ant-... (the claude CLI isn't available
# on most remote hosts)
# Optional: DATABASE_URL=<postgres-url> (persistent analytics storage; Neon recommended)
# Optional: ANALYTICS_TOKEN=<secret> (enables /admin/analytics dashboard)
# Optional: AREA_VERDA_CACHE_TTL_SECONDS=86400 (refresh official parking map cache daily)Three terminals:
# Terminal 1 — Redis (optional but enables 24h analysis caching)
docker compose up redis
# Terminal 2 — Backend (from repo root)
source .venv/bin/activate
cd backend && uvicorn main:app --reload --port 8000
# Terminal 3 — Frontend (from repo root)
cd frontend && npm run devOpen http://localhost:5173.
Why not Docker for everything? The AI narrative uses
claude -p(subprocess calling the Claude Code CLI). Theclaudebinary lives on your host machine, not inside Docker. Running the backend locally means it can call the CLI without complex volume mounting.
property_analysis/
├── backend/
│ ├── main.py # FastAPI app, CORS, router registration
│ ├── api/routes/
│ │ ├── analyze.py # POST /analyze POST /compare
│ │ │ # GET /admin/analytics (HTML dashboard)
│ │ │ # POST /track (frontend events)
│ │ └── health.py # GET /health
│ ├── services/ # External data fetchers (all async)
│ │ ├── geocoder.py # Nominatim: address → lat/lng
│ │ ├── overpass.py # OSM: POIs, bars, schools, transit
│ │ ├── google_places.py # OSM baseline ratings (Google fallback)
│ │ ├── catastro.py # Catastro: surface, age, energy cert, cadastral value
│ │ ├── ine.py # INE: census section (reference data)
│ │ ├── fotocasa_scraper.py # Fotocasa: active listing comparables by district
│ │ ├── open_data_bcn.py # BCN open data: district safety, business licences
│ │ ├── airbnb_saturation.py # Inside Airbnb: tourist apartment density
│ │ ├── school_quality.py # OSM + ratings: school composite score
│ │ ├── noise_ecosystem.py # OSM + floor level: noise estimate
│ │ ├── neighbourhood_trajectory.py # BCN licences API: area trend
│ │ ├── ai_narrative.py # Claude Code CLI: plain-language verdict
│ │ └── disclosures.py # Rule-based pre-purchase disclosure items
│ ├── scoring/ # Pure calculation, no I/O
│ │ ├── engine.py # 8-dimension composite + penalty multipliers
│ │ ├── convenience.py # Transit + amenity density + Google quality
│ │ ├── safety.py # 4 crime indices → safety score
│ │ ├── property_score.py # Age + energy cert + unit features
│ │ ├── market.py # Price fairness (Fotocasa) + yield + trend
│ │ ├── risk.py # Structural + flood + legal + trajectory
│ │ ├── liveability.py # School + noise + neighbourhood trend
│ │ ├── hidden_costs.py # IBI + community fees + derrama risk
│ │ ├── transaction_costs.py # Buyer acquisition costs + seller economics
│ │ └── valuation.py # INE reference (background signal, not displayed)
│ ├── models/
│ │ ├── request.py # AnalyzeRequest (address, price, UserAnswers)
│ │ ├── response.py # All Pydantic response models
│ │ └── user_profile.py # UserAnswers questionnaire + weight computation
│ └── utils/
│ ├── cache.py # Redis @cached decorator (graceful fallback)
│ ├── analytics.py # Event log: analyses + frontend events
│ ├── dashboard.py # HTML dashboard generator (Chart.js, self-contained)
│ └── rate_limiter.py # 5 analyses/day/IP limit
├── frontend/src/
│ ├── App.tsx # Tab routing: Analyse / Compare
│ ├── pages/
│ │ ├── Home.tsx # Address input + buyer questionnaire
│ │ ├── Report.tsx # Full analysis report (data-section attributes)
│ │ └── Compare.tsx # Side-by-side comparison page
│ ├── components/
│ │ ├── NarrativeCard.tsx # AI verdict (hero element)
│ │ ├── ScoreCard.tsx # 8-dimension grid + score adjustments
│ │ ├── ValuationModule.tsx # Market comparables + acquisition cost + seller economics
│ │ ├── HiddenCostBreakdown.tsx # Monthly cost waterfall
│ │ ├── DisclosureSection.tsx # Pre-purchase disclosure cards
│ │ ├── AirbnbSaturation.tsx # Tourist apartment gauge
│ │ ├── SchoolQualityModule.tsx # School composite display
│ │ ├── NoiseEcosystem.tsx # Day/night/weekend bar chart
│ │ ├── NeighbourhoodTrajectory.tsx # Trend arrow + business count
│ │ ├── SafetyModule.tsx # 4-index horizontal bars
│ │ ├── NeighborhoodModule.tsx # POI category grid
│ │ ├── PropertyMap.tsx # Leaflet map with POI pins
│ │ ├── PropertyDetails.tsx # Cadastral property details
│ │ ├── NegotiationTips.tsx # Rule-based negotiation cards
│ │ └── WaterfallChart.tsx # Recharts waterfall (hidden cost detail)
│ ├── hooks/
│ │ └── useAnalytics.ts # IntersectionObserver section tracking + events
│ ├── contexts/
│ │ └── LanguageContext.tsx # EN / 中文 language context
│ ├── i18n/strings.ts # All UI strings in EN + ZH
│ ├── api/client.ts # Axios: analyzeProperty, compareProperties, trackEvent
│ └── types/analysis.ts # TypeScript interfaces matching Pydantic models
├── docker-compose.yml # Redis only
└── README.md
| Source | What | Cost |
|---|---|---|
| Nominatim | Geocoding (address → lat/lng) | Free |
| Overpass API | POIs, schools, bars, transit within 500m | Free |
| Catastro | Surface, year built, energy cert, cadastral value | Free |
| Fotocasa | Active listing comparables by district and size | Free (scraper) |
| INE | Census sections, median price/m² (reference only) | Free |
| Open Data BCN | Safety indices, business licences, works permits | Free |
| Area Verda | Official regulated street-parking segments, tariffs, hours, resident zones | Free |
| Inside Airbnb | Tourist apartment density | Free |
| OSM Baseline Ratings | POI ratings from OSM name/type tags | Free |
| Claude Code CLI | AI narrative generation | Free (subscription) |
The primary price signal is real active listings scraped from Fotocasa, filtered by district and size range. This gives an accurate picture of what similar properties are asking today.
INE median price/m² data (census section level) is retained as a background reference signal but is no longer shown as a "fair value estimate" — INE data underestimates the Barcelona market by 30–50% and the resulting valuation range (±24%) was too wide to be actionable.
The narrative verdict is generated by calling the claude CLI as a subprocess — identical to the pattern used in voice_blog's ClaudeCodeBackend. No separate Anthropic API key billing; it runs against the developer's existing Claude Code subscription.
| Dimension | What it measures | Key signals |
|---|---|---|
| Convenience | Day-to-day access | Transit proximity, amenity density (log-saturated), Google quality |
| Safety | Crime risk | Theft, vehicle crime, vandalism, night safety (Open Data BCN) |
| Property | Building quality | Age + ITE status, energy certificate, unit features (lift, terrace, parking) |
| Market | Price position | Fotocasa comparables position, price trend, rental yield |
| Risk | Hidden liabilities | Structural age, flood zone, legal clarity |
| Liveability | Quality of life | School quality, night noise, day noise, neighbourhood trajectory |
| HiddenCosts | Ongoing cost burden | IBI, community fees, derrama risk, energy upgrade liability |
| Intangible | Neighbourhood character | Cultural density, local market, green/urban quality |
The questionnaire replaces static buyer profiles. Each answer adjusts dimension and sub-dimension weights:
| Question | What it adjusts |
|---|---|
| Q1: Children? | Liveability (school weight), Safety |
| Q2: Stay duration? | Market weight, Risk weight |
| Q3: Drive regularly? | Convenience sub-weights (transit vs. amenity), Safety (vehicle crime) |
| Q4: Rental intent? | Market weight, rental yield sub-weight |
| Q5: Noise tolerance? | Liveability noise sub-weights |
| Q6: Work situation? | Convenience (transit), Liveability (day noise) |
| Q7: Renovation appetite? | HiddenCosts, Property, Risk |
| Q8: Neighbourhood character? | Intangible dimension weight |
Legacy string profiles (balanced, family, investor, retiree, expat) remain supported for the /compare endpoint.
Applied multiplicatively after the weighted sum:
| Penalty | Trigger | Multiplier |
|---|---|---|
| Critical risk | Any risk sub-score < 20 | ×0.70 |
| Overpriced | Listing well above district comparables | ×0.80–0.94 |
| Derrama risk | Derrama risk = high | ×0.93 |
| Tourist saturation | Airbnb risk = very_high (owner-occupier) | ×0.94 |
- Missing data = neutral, not worst-case. If Catastro doesn't return energy cert or year built, scoring uses district-average neutral values. Absence from a public registry is common in Spain and does not indicate a problem.
- Floor level adjusts noise. Each floor above ground adds ~5 points to the noise score (cap +40 at ~8th floor).
- Bars are convenience, not just noise. Bars/pubs/cafes contribute to Convenience amenity density. Only
amenity=nightclubis weighted heavily as a noise source. - Log-saturated POI density. Diminishing returns on POI counts:
100 × (1 − 1/(1 + count/half_sat)). - Price fairness from real listings.
price_fairnesssub-score in the Market dimension uses the Fotocasa comparables position (well_below→100,within_range→65,well_above→20), not INE estimates.
{
"address": "Carrer de Mallorca 401, Barcelona",
"listing_price": 480000,
"buyer_profile": "balanced",
"user_answers": {
"children_age": "young",
"stay_duration": "long",
"has_car": false,
"rental_intent": "none",
"noise_tolerance": "need_quiet",
"work_situation": "commute",
"renovation_appetite": "move_in_ready",
"lifestyle_priority": "important"
},
"year_built": 1975,
"floor": 3,
"surface_m2": 90,
"energy_cert": "D",
"condition": "renovated",
"language": "en"
}All fields except address are optional. user_answers overrides buyer_profile when provided.
Returns AnalyzeResponse — see backend/models/response.py for the full schema. Key fields:
| Field | Description |
|---|---|
market_comparables |
Fotocasa active listings in district, P25/median/P75, asking position |
acquisition_costs |
ITP or IVA+AJD, notary, registry, gestoria, total cash needed, min savings |
seller_economics |
Agency commission, plusvalía estimate, seller floor, negotiation headroom % |
disclosures |
4–6 pre-purchase disclosure items (severity: red/yellow/green) |
score |
Composite + 8 dimensions + penalty multipliers |
narrative |
AI verdict, key risks, key positives, negotiation angle |
{
"addresses": ["Carrer de Mallorca 401, Barcelona", "Passeig de Gràcia 55, Barcelona"],
"listing_prices": [480000, 650000],
"buyer_profile": "family"
}Frontend behaviour events (section views, PDF downloads, language switches). Fired automatically by useReportAnalytics hook.
{
"session_id": "uuid-from-sessionstorage",
"request_id": "uuid-from-analysis",
"event": "section_view",
"data": { "section": "valuation" }
}Returns the HTML analytics dashboard. Shows DAU, district distribution, price ranges, language split, Fotocasa success rate, duration percentiles, section views, and a recent analyses table.
Raw JSON at /admin/analytics/json?token=<token>.
{"status": "ok", "service": "property-analyzer"}Analytics uses Postgres when DATABASE_URL is set, and automatically creates the required tables on first use:
analysis_reports— successful generated reportsanalysis_errors— failed analysis attempts and exception summariesreport_events— frontend behaviour events such as section views and PDF downloads
When DATABASE_URL is unset or unreachable, analytics falls back to $ANALYTICS_FILE (default /tmp/pa_analytics.jsonl) and $EVENTS_FILE (/tmp/pa_events.jsonl). This fallback is useful locally but ephemeral on Railway.
Each analysis log entry captures: timestamp, anonymised IP hash, district, composite score, price bucket, language, duration (ms), Fotocasa scraper success, UserAnswers summary, score by dimension.
For production, create a Neon Postgres database and set DATABASE_URL in Railway using the pooled connection string, usually ending in sslmode=require. Set ANALYTICS_TOKEN to enable the dashboard at /admin/analytics.
Full analyses are cached in Redis for 24 hours keyed on all input parameters. The @cached decorator in utils/cache.py falls back gracefully to no-cache if Redis is unavailable (development without Docker).
To clear during development:
docker exec property_analysis-redis-1 redis-cli FLUSHALL| Area | Current state | Notes |
|---|---|---|
| Fotocasa comparables | Scraped on each fresh analysis; may fail if anti-bot measures change | Falls back to district statistics |
| Catastro data | Coordinate-based lookup; some buildings return no data | Score falls back to neutral values |
| Acquisition costs | Barcelona/Catalunya, Madrid, and Valencia rates supported; other autonomous communities are not | New build (IVA+AJD) supported |
| Seller plusvalía | Estimated from cadastral value + ~10 year ownership assumption | Actual varies significantly |
| Airbnb data | Quarterly CSV snapshot | Auto-downloaded on first run, cached locally |
| Safety data | District-level, not street-level | 10 BCN districts |
| Market data | Annual growth and yield are 2024 static averages | INE reference only |
| Madrid / Valencia | ITP/IBI/Plusvalía, disclosures, and Airbnb heuristics are city-aware | Safety scoring and neighbourhood trajectory remain Barcelona-only — no equivalent open data source found yet for the other two cities |
| Cities beyond BCN/Madrid/Valencia | Not supported | Geocoder resets to unsupported city and degrades gracefully (see backend/services/geocoder.py) |
- Buyer questionnaire (8 questions replacing static profiles)
- Fotocasa market comparables as primary price signal
- Acquisition cost calculator (ITP/IVA, notary, registry)
- Seller economics panel (seller floor + negotiation headroom)
- Pre-purchase disclosures module
- Full Chinese (中文) i18n
- Analytics pipeline (event log + HTML dashboard + frontend tracking)
- Madrid and Valencia support (city-aware tax/legal logic; safety data stays BCN-only)
- Freemium gate (5 free analyses/day currently; paywall for AI narrative)
- PDF export (print CSS exists; dedicated export page TBD)
- Property save + price-change alerts (requires auth)
- Renovation cost estimator (surface × condition → cost range)
- Community meeting minutes analyser (PDF → AI summary)
-
is_new_buildflag in request (currently defaults to resale for acquisition costs) - Safety/crime and neighbourhood-trajectory data sources for Madrid and Valencia
Bug reports and PRs welcome. See CONTRIBUTING.md before opening a PR — this project had a real quality incident early on (10 "done" features, 18 bugs, 3 production crashes) and the repo's CLAUDE.md documents the review protocol adopted afterward. It's a good read even if you're not using Claude Code.
MIT — see LICENSE.

