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.
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 theadminscreen) 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.
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).
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).
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).
cp .env.example .env # edit secrets; .env is gitignored
docker compose up --build # migrate → load fixture → run pipeline → serveThen 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).
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 :3000A 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-otp → POST /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).
TypeScript · Playwright · BullMQ + Redis · PostgreSQL · NestJS · Next.js + Recharts + Tailwind.
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.
MIT.