Skip to content

Repository files navigation

Manzara

The whole market, in one view. Real price intelligence for Uzbekistan's property market — scraped, cleaned, deduplicated, analysed.

scrape → normalise → dedup / entity-resolution → analytics → dashboard


Manzara collects rental and sale listings from Uzbekistan's property sites, resolves the same flat listed in several places into one property, normalises the prices, and turns the mess into answers: what does a 2-room in this district actually cost, how has it moved, is this listing over- or under-priced.

The product is not a listings site with a CRUD form. The value is entirely in the data pipeline: scraping at scale without getting blocked, resolving duplicate listings into real entities, normalising dirty prices, and building time-series and valuations on top. Forms are the boring 5%; the pipeline is the 95%. The full contract is docs/TZ.md.

The insight (built from the pipeline)

What Tashkent's sale market actually looks like — median asking price per m² (USD), from 847 deduplicated properties. Numbers below are produced by this repo's pipeline, not typed in: run pnpm pipeline:eval (or the admin screen) to reproduce them.

District Median $/m² Properties
Yakkasaroy $1 323 52
Mirobod $1 310 51
Yunusobod $1 185 75
Mirzo Ulugbek $1 170 90
Shayxontohur $1 059 58
Chilonzor $947 108
Olmazor $902 46
Yashnobod $882 44
Uchtepa $785 30
Sergeli $717 32

The central districts (Yakkasaroy, Mirobod) command a ~85% premium per m² over the outer belt (Uchtepa, Sergeli) — the kind of spread an agency currently guesses at.

The pipeline (the product IS this pipeline)

 sources ─▶ scrapers ─▶ raw_listings ─▶ normalise ─▶ dedup / entity-resolution ─▶ properties
 (OLX,       (Playwright, (immutable,     (Phase 2)     (Phase 3, the hard part)      │
  uybor,      polite)      verbatim)                                                  │
  joylash)                                            analytics (median / p25 / p75   │
                                                       / trend / valuation) ◀─────────┘
                                                                    │
                                                  NestJS API + Next.js dashboard ◀────┘

Every table downstream of raw_listings is derived and re-computable; re-runs are idempotent and raw source data is never lost or mutated (docs/TZ.md §5.5).

Entity resolution — measured, not asserted

Dedup is where the project earns its keep, so it is measured against a hand-labelled set of 150 pairs (seed/eval_pairs.jsonl). The matcher blocks on district × rooms × area-bucket, scores each candidate pair on address / area / price / phone / floor / title similarity, and clusters with union-find. The labels are ground truth; the algorithm never sees them.

Value
Precision 98.5 %
Recall 92.9 %
F1 95.6 %
Operating threshold 0.50 (chosen from the F1 sweep, not a guess)

On the demo dataset the matcher recovers 847 properties from 1 865 clean listings — within 0.4 % of the 850 true underlying flats. The threshold sweep (pnpm pipeline:eval) shows the real precision/recall trade-off; the number is not faked (BUILD-PROMPT §3, §10).

Real scrapers, deterministic demo

The scraper modules in packages/scrapers are real, working code — Playwright extraction with robots.txt respect, randomised throttling, retry-with-backoff, resume checkpoints, a uniform output shape, and a BullMQ queue/worker for scheduling. That is the proof the scraping skill is real.

But live scraping during a review is unreliable (ToS, bans, layout drift, latency), so the demo pipeline runs off a captured / synthetic fixture (seed/raw/*.jsonl) that reproduces the shape of the market: the same flat on 1–3 sources, three currencies (UZS / USD / "у.е."), collapsed 2/4/9 structure strings, districts spelled many ways, reposts with price changes, and ~5% genuine garbage. A reviewer sees the entire pipeline flow — without touching any live site. This is derived market structure, not copied listings (see Legal below).

Quickstart

cp .env.example .env        # edit secrets; .env is gitignored
docker compose up --build   # migrate → load fixture → run pipeline → serve

Then open http://localhost:3000. Sign in with any email — in dev the OTP code is shown right in the login UI (EMAIL_PROVIDER=console, AUTH_DEV_RETURN_CODE=true).

Without Docker

pnpm install
# Postgres + Redis reachable via DATABASE_URL / REDIS_URL in your shell
pnpm --filter @manzara/db migrate
pnpm seed:generate      # (re)build the fixture — deterministic
pnpm seed:load          # fixture -> raw_listings
pnpm pipeline:run       # normalise -> dedup -> analytics -> persist
pnpm pipeline:eval      # print precision/recall + a market sample (no DB needed)
pnpm dev:api            # NestJS API on :4000
pnpm dev:web            # Next.js dashboard on :3000

Architecture

A pnpm monorepo (docs/TZ.md §9):

Package Role
packages/shared Domain types + pure normalisation primitives (currency, area, districts, similarity)
packages/db Postgres schema, migrations, pooled client
packages/pipeline Normalise · dedup/entity-resolution · analytics/valuation · eval harness · fixture generator
packages/scrapers Playwright source modules + politeness engine + BullMQ worker
apps/api NestJS analytics API + email-OTP auth
apps/web Next.js + Recharts dashboard, explorer, valuation, admin (Uzbek UI)

Screens: Dashboard (KPIs, district medians, price trend, over/under-priced feed) · Explorer (property search + multi-source listing history + price history) · Valuation (transparent district-comp estimate) · Admin (pipeline funnel, scraper status, dedup precision/recall, normalisation flags, parked garbage).

Auth: email + one-time code (POST /auth/request-otpPOST /auth/verify-otp → JWT). Codes are hashed, TTL-bound and rate-limited; the EmailProvider is pluggable (console in dev, SMTP/Eskiz in prod). Internal analyst gate only — no consumer accounts (docs/TZ.md §7).

Stack

TypeScript · Playwright · BullMQ + Redis · PostgreSQL · NestJS · Next.js + Recharts + Tailwind.

Legal / ethical stance

Manzara sells derived market statistics, not copied listings (docs/TZ.md §8). The scrapers respect robots.txt, throttle with randomised delays, and back off on failure; only what is needed is stored, phone numbers are hashed (never stored raw), and nothing is republished wholesale. Where a source offers an API or data partnership, that is preferred over scraping. The committed demo dataset is a synthetic/captured fixture reproducing market structure — not a copy of any site's content.

License

MIT.

About

Property-market intelligence for Uzbekistan — scrape → normalise → dedupe → analyse. Entity resolution with measured precision/recall, not a CRUD app.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages