brickwise 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +70 -0
  3. package/bin/brickwise.js +100 -0
  4. package/eslint.config.mjs +18 -0
  5. package/next.config.ts +14 -0
  6. package/package.json +62 -0
  7. package/postcss.config.mjs +7 -0
  8. package/public/file.svg +1 -0
  9. package/public/globe.svg +1 -0
  10. package/public/next.svg +1 -0
  11. package/public/vercel.svg +1 -0
  12. package/public/window.svg +1 -0
  13. package/src/app/api/dashboard/route.ts +15 -0
  14. package/src/app/api/favorites/route.ts +71 -0
  15. package/src/app/api/gyms/route.ts +30 -0
  16. package/src/app/api/listing/[id]/route.ts +30 -0
  17. package/src/app/api/locations/route.ts +13 -0
  18. package/src/app/api/recompute/route.ts +28 -0
  19. package/src/app/api/search/route.ts +26 -0
  20. package/src/app/favicon.ico +0 -0
  21. package/src/app/favorites/page.tsx +603 -0
  22. package/src/app/globals.css +78 -0
  23. package/src/app/layout.tsx +37 -0
  24. package/src/app/listing/[id]/page.tsx +230 -0
  25. package/src/app/map/page.tsx +5 -0
  26. package/src/app/page.tsx +155 -0
  27. package/src/app/search/page.tsx +87 -0
  28. package/src/components/ChromeHeader.tsx +16 -0
  29. package/src/components/FiltersForm.tsx +240 -0
  30. package/src/components/ListingCard.tsx +145 -0
  31. package/src/components/Nav.tsx +37 -0
  32. package/src/components/PsqftChart.tsx +249 -0
  33. package/src/components/TransactionsTable.tsx +40 -0
  34. package/src/components/YieldBadge.tsx +24 -0
  35. package/src/lib/analyze.ts +51 -0
  36. package/src/lib/db.ts +143 -0
  37. package/src/lib/dedupe.ts +34 -0
  38. package/src/lib/format.ts +13 -0
  39. package/src/lib/gyms.ts +104 -0
  40. package/src/lib/pf/client.ts +45 -0
  41. package/src/lib/pf/listing.ts +16 -0
  42. package/src/lib/pf/locations.ts +73 -0
  43. package/src/lib/pf/parse.ts +15 -0
  44. package/src/lib/pf/search.ts +57 -0
  45. package/src/lib/pf/transactions.ts +85 -0
  46. package/src/lib/store.ts +182 -0
  47. package/src/lib/types.ts +122 -0
  48. package/src/lib/yield.ts +80 -0
  49. package/tsconfig.json +34 -0
@@ -0,0 +1,40 @@
1
+ import { fmtAED, fmtDate, fmtSqft } from "@/lib/format";
2
+ import { TxRow } from "@/lib/pf/transactions";
3
+
4
+ export default function TransactionsTable({ title, rows, kind }: { title: string; rows: TxRow[]; kind: "buy" | "rent" }) {
5
+ return (
6
+ <div className="rounded-xl border border-neutral-200 bg-white p-4 dark:border-neutral-800 dark:bg-neutral-900">
7
+ <h3 className="mb-3 text-sm font-bold">
8
+ {title} <span className="font-normal text-neutral-500">({rows.length})</span>
9
+ </h3>
10
+ {rows.length === 0 ? (
11
+ <p className="text-sm text-neutral-500">Sin transacciones registradas.</p>
12
+ ) : (
13
+ <div className="max-h-96 overflow-auto">
14
+ <table className="w-full text-left text-sm">
15
+ <thead className="sticky top-0 bg-white text-xs text-neutral-500 dark:bg-neutral-900">
16
+ <tr>
17
+ <th className="py-1.5 pr-3 font-semibold">Fecha</th>
18
+ <th className="py-1.5 pr-3 font-semibold">{kind === "rent" ? "Renta anual" : "Precio"}</th>
19
+ <th className="py-1.5 pr-3 font-semibold">Tamaño</th>
20
+ <th className="py-1.5 pr-3 font-semibold">Hab</th>
21
+ <th className="py-1.5 font-semibold">Unidad</th>
22
+ </tr>
23
+ </thead>
24
+ <tbody>
25
+ {rows.map((t) => (
26
+ <tr key={t.id} className="border-t border-neutral-100 dark:border-neutral-800">
27
+ <td className="py-1.5 pr-3 whitespace-nowrap">{fmtDate(t.date)}</td>
28
+ <td className="py-1.5 pr-3 whitespace-nowrap font-medium">{fmtAED(t.amount)}</td>
29
+ <td className="py-1.5 pr-3 whitespace-nowrap">{fmtSqft(t.size_sqft)}</td>
30
+ <td className="py-1.5 pr-3">{t.bedrooms ?? "—"}</td>
31
+ <td className="py-1.5">{t.unit ?? "—"}</td>
32
+ </tr>
33
+ ))}
34
+ </tbody>
35
+ </table>
36
+ </div>
37
+ )}
38
+ </div>
39
+ );
40
+ }
@@ -0,0 +1,24 @@
1
+ import { fmtPct } from "@/lib/format";
2
+
3
+ export default function YieldBadge({ value, n }: { value: number | null; n?: number }) {
4
+ if (value == null)
5
+ return (
6
+ <span className="inline-flex items-center rounded-full bg-neutral-200 px-2 py-0.5 text-xs font-semibold text-neutral-600 dark:bg-neutral-800 dark:text-neutral-400">
7
+ sin datos
8
+ </span>
9
+ );
10
+ const color =
11
+ value >= 0.07
12
+ ? "bg-emerald-100 text-emerald-800 dark:bg-emerald-950 dark:text-emerald-300"
13
+ : value >= 0.055
14
+ ? "bg-lime-100 text-lime-800 dark:bg-lime-950 dark:text-lime-300"
15
+ : value >= 0.04
16
+ ? "bg-amber-100 text-amber-800 dark:bg-amber-950 dark:text-amber-300"
17
+ : "bg-red-100 text-red-800 dark:bg-red-950 dark:text-red-300";
18
+ return (
19
+ <span className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-semibold ${color}`}>
20
+ {fmtPct(value)}
21
+ {n != null && <span className="font-normal opacity-70">({n} tx)</span>}
22
+ </span>
23
+ );
24
+ }
@@ -0,0 +1,51 @@
1
+ import { scrapeListing } from "./pf/listing";
2
+ import { getTransactions, syncTransactions } from "./pf/transactions";
3
+ import { computeYield, similarToTxRows } from "./yield";
4
+ import { nearestGym } from "./gyms";
5
+ import { getAnalysis, getListing, saveAnalysis, upsertListing } from "./store";
6
+ import { AnalysisRow, ListingRow } from "./types";
7
+
8
+ const ANALYSIS_TTL_MS = 24 * 3600 * 1000;
9
+
10
+ export interface AnalyzeResult {
11
+ listing: ListingRow;
12
+ analysis: AnalysisRow;
13
+ }
14
+
15
+ /**
16
+ * Full analysis of one listing: detail scrape (for photos/description/fallback
17
+ * transactions), tower transaction history, yield computation and gym distance.
18
+ */
19
+ export async function analyzeListing(id: string, url?: string, force = false): Promise<AnalyzeResult> {
20
+ let listing = getListing(id);
21
+ const cached = getAnalysis(id);
22
+ if (!force && listing && cached && Date.now() - Date.parse(cached.computed_at) < ANALYSIS_TTL_MS) {
23
+ return { listing, analysis: cached };
24
+ }
25
+
26
+ const listingUrl = url || listing?.url;
27
+ if (!listingUrl) throw new Error(`Unknown listing ${id} and no URL provided`);
28
+
29
+ const detail = await scrapeListing(listingUrl);
30
+ listing = upsertListing(detail.property, detail.citySlug);
31
+
32
+ let buy = similarToTxRows(detail.property.similar_price_transactions?.buy || [], "buy");
33
+ let rent = similarToTxRows(detail.property.similar_price_transactions?.rent || [], "rent");
34
+
35
+ if (listing.tower_slug) {
36
+ await syncTransactions(detail.citySlug, listing.tower_slug);
37
+ const fullBuy = getTransactions(listing.tower_slug, "buy");
38
+ const fullRent = getTransactions(listing.tower_slug, "rent");
39
+ if (fullBuy.length) buy = fullBuy;
40
+ if (fullRent.length) rent = fullRent;
41
+ }
42
+
43
+ const yieldRes = computeYield(listing.price, listing.size_sqft, listing.bedrooms, buy, rent);
44
+ const gym =
45
+ listing.lat != null && listing.lon != null
46
+ ? await nearestGym(listing.lat, listing.lon)
47
+ : { gym_name: null, distance_m: null };
48
+
49
+ saveAnalysis(listing.id, yieldRes, gym);
50
+ return { listing, analysis: getAnalysis(listing.id)! };
51
+ }
package/src/lib/db.ts ADDED
@@ -0,0 +1,143 @@
1
+ import Database from "better-sqlite3";
2
+ import path from "path";
3
+ import os from "os";
4
+ import fs from "fs";
5
+
6
+ let db: Database.Database | null = null;
7
+
8
+ /**
9
+ * Stable per-user location so data survives no matter how the app is launched
10
+ * (including `npx`, which runs from an ephemeral cache). Override with BRICKWISE_DB.
11
+ * One-time migrates a legacy ./brickwise.db from earlier local runs.
12
+ */
13
+ function resolveDbPath(): string {
14
+ if (process.env.BRICKWISE_DB) return process.env.BRICKWISE_DB;
15
+ const dir = path.join(os.homedir(), ".brickwise");
16
+ fs.mkdirSync(dir, { recursive: true });
17
+ const target = path.join(dir, "brickwise.db");
18
+ if (!fs.existsSync(target)) {
19
+ const legacy = path.join(process.cwd(), "brickwise.db");
20
+ if (fs.existsSync(legacy)) {
21
+ try {
22
+ fs.copyFileSync(legacy, target);
23
+ } catch {
24
+ /* start fresh if the copy fails */
25
+ }
26
+ }
27
+ }
28
+ return target;
29
+ }
30
+
31
+ const SCHEMA = `
32
+ CREATE TABLE IF NOT EXISTS locations (
33
+ id TEXT PRIMARY KEY,
34
+ name TEXT NOT NULL,
35
+ path_name TEXT NOT NULL DEFAULT '',
36
+ slug TEXT NOT NULL DEFAULT '',
37
+ city_slug TEXT NOT NULL DEFAULT '',
38
+ type TEXT NOT NULL DEFAULT '',
39
+ path TEXT NOT NULL DEFAULT ''
40
+ );
41
+ CREATE INDEX IF NOT EXISTS idx_locations_name ON locations(name);
42
+
43
+ CREATE TABLE IF NOT EXISTS listings (
44
+ id TEXT PRIMARY KEY,
45
+ url TEXT NOT NULL,
46
+ title TEXT NOT NULL,
47
+ price REAL NOT NULL,
48
+ size_sqft REAL,
49
+ bedrooms INTEGER,
50
+ bathrooms INTEGER,
51
+ property_type TEXT NOT NULL DEFAULT '',
52
+ tower_id TEXT,
53
+ tower_slug TEXT,
54
+ tower_name TEXT,
55
+ city_slug TEXT,
56
+ lat REAL,
57
+ lon REAL,
58
+ images_json TEXT NOT NULL DEFAULT '[]',
59
+ amenities_json TEXT NOT NULL DEFAULT '[]',
60
+ agent_json TEXT NOT NULL DEFAULT '{}',
61
+ price_per_sqft REAL,
62
+ description TEXT,
63
+ first_seen_at TEXT NOT NULL,
64
+ last_scraped_at TEXT NOT NULL
65
+ );
66
+
67
+ CREATE TABLE IF NOT EXISTS transactions (
68
+ id TEXT PRIMARY KEY,
69
+ tower_slug TEXT NOT NULL,
70
+ kind TEXT NOT NULL CHECK (kind IN ('buy','rent')),
71
+ date TEXT NOT NULL,
72
+ amount REAL NOT NULL,
73
+ size_sqft REAL,
74
+ price_per_sqft REAL,
75
+ bedrooms INTEGER,
76
+ unit TEXT,
77
+ scraped_at TEXT NOT NULL
78
+ );
79
+ CREATE INDEX IF NOT EXISTS idx_tx_tower ON transactions(tower_slug, kind);
80
+
81
+ CREATE TABLE IF NOT EXISTS tx_sync (
82
+ tower_slug TEXT NOT NULL,
83
+ kind TEXT NOT NULL,
84
+ synced_at TEXT NOT NULL,
85
+ PRIMARY KEY (tower_slug, kind)
86
+ );
87
+
88
+ CREATE TABLE IF NOT EXISTS gym_cache (
89
+ geo_key TEXT PRIMARY KEY,
90
+ gym_name TEXT,
91
+ gym_lat REAL,
92
+ gym_lon REAL,
93
+ distance_m REAL,
94
+ fetched_at TEXT NOT NULL
95
+ );
96
+
97
+ CREATE TABLE IF NOT EXISTS gyms_list_cache (
98
+ geo_key TEXT PRIMARY KEY,
99
+ gyms_json TEXT NOT NULL,
100
+ fetched_at TEXT NOT NULL
101
+ );
102
+
103
+ CREATE TABLE IF NOT EXISTS favorites (
104
+ listing_id TEXT PRIMARY KEY REFERENCES listings(id),
105
+ notes TEXT NOT NULL DEFAULT '',
106
+ added_at TEXT NOT NULL
107
+ );
108
+
109
+ CREATE TABLE IF NOT EXISTS analyses (
110
+ listing_id TEXT PRIMARY KEY REFERENCES listings(id),
111
+ gross_yield REAL,
112
+ yield_vs_market REAL,
113
+ market_value_est REAL,
114
+ median_rent REAL,
115
+ median_buy_psqft REAL,
116
+ rent_n INTEGER NOT NULL DEFAULT 0,
117
+ buy_n INTEGER NOT NULL DEFAULT 0,
118
+ premium_pct REAL,
119
+ gym_distance_m REAL,
120
+ gym_name TEXT,
121
+ computed_at TEXT NOT NULL
122
+ );
123
+ `;
124
+
125
+ export function getDb(): Database.Database {
126
+ if (!db) {
127
+ db = new Database(resolveDbPath());
128
+ db.pragma("journal_mode = WAL");
129
+ db.exec(SCHEMA);
130
+ migrate(db);
131
+ }
132
+ return db;
133
+ }
134
+
135
+ function migrate(db: Database.Database) {
136
+ const cols = (db.prepare(`PRAGMA table_info(analyses)`).all() as { name: string }[]).map((c) => c.name);
137
+ if (!cols.includes("median_sale_price")) db.exec(`ALTER TABLE analyses ADD COLUMN median_sale_price REAL`);
138
+ if (!cols.includes("asking_yield")) db.exec(`ALTER TABLE analyses ADD COLUMN asking_yield REAL`);
139
+ }
140
+
141
+ export function now(): string {
142
+ return new Date().toISOString();
143
+ }
@@ -0,0 +1,34 @@
1
+ interface UnitLike {
2
+ id: string;
3
+ tower_slug: string | null;
4
+ bedrooms: number | null;
5
+ size_sqft: number | null;
6
+ price: number;
7
+ }
8
+
9
+ /**
10
+ * The same physical apartment is often listed by several agents under different
11
+ * Property Finder ids. Those share the building, bedroom count, size and asking
12
+ * price (agents copy the same DLD figures), so collapse them to one card.
13
+ *
14
+ * When any of those fields is missing we cannot be confident two rows are the
15
+ * same unit, so we key on the id and never merge.
16
+ */
17
+ function unitSignature(r: UnitLike): string {
18
+ if (!r.tower_slug || r.size_sqft == null || !r.price) return `id:${r.id}`;
19
+ const bd = r.bedrooms == null ? "?" : r.bedrooms;
20
+ return `${r.tower_slug}|${bd}|${Math.round(r.size_sqft)}|${r.price}`;
21
+ }
22
+
23
+ /** Keep the first occurrence of each physical unit (newest first with ob=mr). */
24
+ export function dedupeListings<T extends UnitLike>(rows: T[]): { unique: T[]; removed: number } {
25
+ const seen = new Set<string>();
26
+ const unique: T[] = [];
27
+ for (const r of rows) {
28
+ const sig = unitSignature(r);
29
+ if (seen.has(sig)) continue;
30
+ seen.add(sig);
31
+ unique.push(r);
32
+ }
33
+ return { unique, removed: rows.length - unique.length };
34
+ }
@@ -0,0 +1,13 @@
1
+ export const fmtAED = (n: number | null | undefined) =>
2
+ n == null ? "—" : new Intl.NumberFormat("en-AE", { maximumFractionDigits: 0 }).format(n) + " AED";
3
+
4
+ export const fmtPct = (x: number | null | undefined, digits = 1) =>
5
+ x == null ? "—" : (x * 100).toFixed(digits) + " %";
6
+
7
+ export const fmtSqft = (n: number | null | undefined) =>
8
+ n == null ? "—" : new Intl.NumberFormat("en-AE", { maximumFractionDigits: 0 }).format(n) + " sqft";
9
+
10
+ export const fmtDist = (m: number | null | undefined) =>
11
+ m == null ? "—" : m < 1000 ? `${Math.round(m)} m` : `${(m / 1000).toFixed(1)} km`;
12
+
13
+ export const fmtDate = (d: string | null | undefined) => (d ? d.slice(0, 10) : "—");
@@ -0,0 +1,104 @@
1
+ /* eslint-disable @typescript-eslint/no-explicit-any */
2
+ import { getDb, now } from "./db";
3
+
4
+ // Overpass instances are flaky under load; try each twice, in order.
5
+ const OVERPASS_ENDPOINTS = [
6
+ "https://overpass-api.de/api/interpreter",
7
+ "https://overpass-api.de/api/interpreter",
8
+ "https://overpass.private.coffee/api/interpreter",
9
+ "https://overpass.kumi.systems/api/interpreter",
10
+ ];
11
+ const RADIUS_M = 2000;
12
+ const TTL_MS = 30 * 24 * 3600 * 1000;
13
+
14
+ export interface Gym {
15
+ name: string;
16
+ lat: number;
17
+ lon: number;
18
+ distance_m: number;
19
+ }
20
+
21
+ export interface GymResult {
22
+ gym_name: string | null;
23
+ distance_m: number | null;
24
+ }
25
+
26
+ function haversineM(lat1: number, lon1: number, lat2: number, lon2: number): number {
27
+ const R = 6371000;
28
+ const toRad = (d: number) => (d * Math.PI) / 180;
29
+ const dLat = toRad(lat2 - lat1);
30
+ const dLon = toRad(lon2 - lon1);
31
+ const a =
32
+ Math.sin(dLat / 2) ** 2 +
33
+ Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2;
34
+ return 2 * R * Math.asin(Math.sqrt(a));
35
+ }
36
+
37
+ // Overpass allows very few concurrent requests per IP — serialize ours.
38
+ let queue: Promise<unknown> = Promise.resolve();
39
+
40
+ /** All gyms (OSM fitness centres) within 2 km, nearest first; cached by ~100 m grid cell. */
41
+ export function gymsNear(lat: number, lon: number): Promise<Gym[] | null> {
42
+ const task = queue.then(() => doGymsNear(lat, lon));
43
+ queue = task.catch(() => null);
44
+ return task;
45
+ }
46
+
47
+ /** null = Overpass unavailable (not cached); [] = confirmed no gyms nearby. */
48
+ async function doGymsNear(lat: number, lon: number): Promise<Gym[] | null> {
49
+ const db = getDb();
50
+ const geoKey = `${lat.toFixed(3)},${lon.toFixed(3)}`;
51
+ const cached = db.prepare(`SELECT * FROM gyms_list_cache WHERE geo_key = ?`).get(geoKey) as any;
52
+ if (cached && Date.now() - Date.parse(cached.fetched_at) < TTL_MS) {
53
+ return JSON.parse(cached.gyms_json);
54
+ }
55
+
56
+ const query = `[out:json][timeout:15];(node["leisure"="fitness_centre"](around:${RADIUS_M},${lat},${lon});way["leisure"="fitness_centre"](around:${RADIUS_M},${lat},${lon}););out center 60;`;
57
+ for (const endpoint of OVERPASS_ENDPOINTS) {
58
+ try {
59
+ const res = await fetch(endpoint, {
60
+ method: "POST",
61
+ body: "data=" + encodeURIComponent(query),
62
+ headers: {
63
+ "Content-Type": "application/x-www-form-urlencoded",
64
+ // overpass-api.de answers 406 to requests without a User-Agent
65
+ "User-Agent": "brickwise-local/0.1 (personal use)",
66
+ },
67
+ signal: AbortSignal.timeout(20000),
68
+ });
69
+ if (!res.ok) continue;
70
+ const j = await res.json();
71
+ // Overloaded instances answer 200 + empty elements + a "remark" runtime error.
72
+ if (j.remark && !(j.elements || []).length) continue;
73
+ const gyms: Gym[] = [];
74
+ for (const el of j.elements || []) {
75
+ const eLat = el.lat ?? el.center?.lat;
76
+ const eLon = el.lon ?? el.center?.lon;
77
+ if (eLat == null || eLon == null) continue;
78
+ gyms.push({
79
+ name: el.tags?.name || "Gym",
80
+ lat: eLat,
81
+ lon: eLon,
82
+ distance_m: Math.round(haversineM(lat, lon, eLat, eLon)),
83
+ });
84
+ }
85
+ gyms.sort((a, b) => a.distance_m - b.distance_m);
86
+ db.prepare(
87
+ `INSERT OR REPLACE INTO gyms_list_cache (geo_key, gyms_json, fetched_at) VALUES (?, ?, ?)`
88
+ ).run(geoKey, JSON.stringify(gyms), now());
89
+ return gyms;
90
+ } catch {
91
+ continue;
92
+ }
93
+ }
94
+ return null; // all endpoints down: unknown, not cached
95
+ }
96
+
97
+ /** Nearest gym within 2 km (derived from the cached list). */
98
+ export async function nearestGym(lat: number, lon: number): Promise<GymResult> {
99
+ const gyms = await gymsNear(lat, lon);
100
+ const best = gyms?.[0];
101
+ return best
102
+ ? { gym_name: best.name, distance_m: best.distance_m }
103
+ : { gym_name: null, distance_m: null };
104
+ }
@@ -0,0 +1,45 @@
1
+ const UA =
2
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36";
3
+
4
+ const MIN_INTERVAL_MS = 1000;
5
+
6
+ let queue: Promise<unknown> = Promise.resolve();
7
+ let lastRequestAt = 0;
8
+
9
+ function sleep(ms: number) {
10
+ return new Promise((r) => setTimeout(r, ms));
11
+ }
12
+
13
+ async function doFetch(url: string): Promise<string> {
14
+ for (let attempt = 1; attempt <= 3; attempt++) {
15
+ const wait = lastRequestAt + MIN_INTERVAL_MS - Date.now();
16
+ if (wait > 0) await sleep(wait);
17
+ lastRequestAt = Date.now();
18
+ try {
19
+ const res = await fetch(url, {
20
+ headers: {
21
+ "User-Agent": UA,
22
+ "Accept-Language": "en",
23
+ Accept: "text/html,application/xhtml+xml,application/json;q=0.9,*/*;q=0.8",
24
+ },
25
+ redirect: "follow",
26
+ signal: AbortSignal.timeout(30000),
27
+ });
28
+ if (res.status === 404) throw Object.assign(new Error(`404: ${url}`), { permanent: true });
29
+ if (!res.ok) throw new Error(`HTTP ${res.status}: ${url}`);
30
+ return await res.text();
31
+ } catch (err) {
32
+ const e = err as Error & { permanent?: boolean };
33
+ if (e.permanent || attempt === 3) throw e;
34
+ await sleep(1500 * attempt);
35
+ }
36
+ }
37
+ throw new Error("unreachable");
38
+ }
39
+
40
+ /** Serialized, rate-limited fetch against propertyfinder.ae. */
41
+ export function fetchPfPage(url: string): Promise<string> {
42
+ const task = queue.then(() => doFetch(url));
43
+ queue = task.catch(() => {});
44
+ return task;
45
+ }
@@ -0,0 +1,16 @@
1
+ import { fetchPfPage } from "./client";
2
+ import { extractPageProps } from "./parse";
3
+ import { PfProperty } from "../types";
4
+
5
+ export interface ListingDetail {
6
+ property: PfProperty;
7
+ citySlug: string;
8
+ }
9
+
10
+ export async function scrapeListing(url: string): Promise<ListingDetail> {
11
+ const html = await fetchPfPage(url);
12
+ const pp = extractPageProps(html);
13
+ const property = pp.propertyResult?.property;
14
+ if (!property) throw new Error(`propertyResult.property missing for ${url}`);
15
+ return { property, citySlug: pp.citySlug || "dubai" };
16
+ }
@@ -0,0 +1,73 @@
1
+ /* eslint-disable @typescript-eslint/no-explicit-any */
2
+ import { fetchPfPage } from "./client";
3
+ import { getDb } from "../db";
4
+
5
+ const LIST_URL =
6
+ "https://www.propertyfinder.ae/api/pwa/location/list?l_t=TOWER%2CSUBCOMMUNITY%2CCOMMUNITY%2CCITY&c=1&locale=en";
7
+
8
+ export interface LocationRow {
9
+ id: string;
10
+ name: string;
11
+ path_name: string;
12
+ slug: string;
13
+ city_slug: string;
14
+ type: string;
15
+ path: string;
16
+ }
17
+
18
+ const DEPTH_TYPES = ["CITY", "COMMUNITY", "SUBCOMMUNITY"];
19
+
20
+ export async function syncLocations(): Promise<number> {
21
+ const raw = JSON.parse(await fetchPfPage(LIST_URL));
22
+ const items: any[] = Array.isArray(raw) ? raw : raw?.data || [];
23
+ const db = getDb();
24
+ const insert = db.prepare(
25
+ `INSERT OR REPLACE INTO locations (id, name, path_name, slug, city_slug, type, path) VALUES (?, ?, ?, ?, ?, ?, ?)`
26
+ );
27
+ const insertDerived = db.prepare(
28
+ `INSERT OR IGNORE INTO locations (id, name, path_name, slug, city_slug, type, path) VALUES (?, ?, ?, ?, ?, ?, ?)`
29
+ );
30
+ const write = db.transaction(() => {
31
+ for (const l of items) {
32
+ insert.run(String(l.id), l.n || "", l.p_n || l.en_p_n || "", l.en_s || l.s || "", l.en_c_s || "", l.l_t || "", l.p || "");
33
+ }
34
+ // The feed only contains leaf-ish locations (towers/subcommunities and a few
35
+ // communities). Cities and most communities — e.g. "Dubai Marina" — only appear
36
+ // as ancestors inside each row's id path (p: "1.86.1198.4328") and path name
37
+ // (p_n: "Dubai, Palm Jumeirah, Marina Residences"), so derive them from there.
38
+ for (const l of items) {
39
+ const ids = String(l.p || "").split(".");
40
+ const names = String(l.p_n || l.en_p_n || "").split(",").map((s: string) => s.trim()).filter(Boolean);
41
+ if (ids.length < 2 || names.length !== ids.length - 1) continue;
42
+ for (let i = 0; i < names.length; i++) {
43
+ insertDerived.run(
44
+ ids[i],
45
+ names[i],
46
+ names.slice(0, i).join(", "),
47
+ "",
48
+ l.en_c_s || "",
49
+ DEPTH_TYPES[Math.min(i, DEPTH_TYPES.length - 1)],
50
+ ids.slice(0, i + 1).join(".")
51
+ );
52
+ }
53
+ }
54
+ });
55
+ write();
56
+ return items.length;
57
+ }
58
+
59
+ export async function searchLocations(q: string, limit = 12): Promise<LocationRow[]> {
60
+ const db = getDb();
61
+ const count = (db.prepare(`SELECT COUNT(*) AS c FROM locations`).get() as { c: number }).c;
62
+ if (count === 0) await syncLocations();
63
+ const like = `%${q.trim().replace(/\s+/g, "%")}%`;
64
+ return db
65
+ .prepare(
66
+ `SELECT * FROM locations
67
+ WHERE name LIKE ? OR path_name LIKE ?
68
+ ORDER BY CASE type WHEN 'CITY' THEN 0 WHEN 'COMMUNITY' THEN 1 WHEN 'SUBCOMMUNITY' THEN 2 ELSE 3 END,
69
+ length(name)
70
+ LIMIT ?`
71
+ )
72
+ .all(like, like, limit) as LocationRow[];
73
+ }
@@ -0,0 +1,15 @@
1
+ /* eslint-disable @typescript-eslint/no-explicit-any */
2
+
3
+ /** Extract the JSON payload of <script id="__NEXT_DATA__"> and return props.pageProps. */
4
+ export function extractPageProps(html: string): any {
5
+ const marker = '<script id="__NEXT_DATA__" type="application/json">';
6
+ const start = html.indexOf(marker);
7
+ if (start === -1) throw new Error("__NEXT_DATA__ not found in page");
8
+ const from = start + marker.length;
9
+ const end = html.indexOf("</script>", from);
10
+ if (end === -1) throw new Error("__NEXT_DATA__ script tag not closed");
11
+ const json = JSON.parse(html.slice(from, end));
12
+ const pageProps = json?.props?.pageProps;
13
+ if (!pageProps) throw new Error("pageProps missing in __NEXT_DATA__");
14
+ return pageProps;
15
+ }
@@ -0,0 +1,57 @@
1
+ /* eslint-disable @typescript-eslint/no-explicit-any */
2
+ import { fetchPfPage } from "./client";
3
+ import { extractPageProps } from "./parse";
4
+ import { PfProperty, SearchFilters } from "../types";
5
+
6
+ const BASE = "https://www.propertyfinder.ae";
7
+
8
+ export function buildSearchUrl(f: SearchFilters, page: number): string {
9
+ const params = new URLSearchParams();
10
+ params.set("l", f.locationId || "1"); // default: Dubai
11
+ params.set("c", "1"); // category: buy
12
+ params.set("fu", "0");
13
+ params.set("ob", "mr"); // most recent
14
+ if (f.minPrice) params.set("pf", String(f.minPrice));
15
+ if (f.maxPrice) params.set("pt", String(f.maxPrice));
16
+ if (f.minArea) params.set("af", String(f.minArea));
17
+ if (f.maxArea) params.set("at", String(f.maxArea));
18
+ if (f.propertyTypeId) params.set("t", f.propertyTypeId);
19
+ for (const b of f.bedrooms || []) params.append("bdr[]", b);
20
+ for (const b of f.bathrooms || []) params.append("btr[]", b);
21
+ for (const a of f.amenities || []) params.append("am[]", a);
22
+ if (page > 1) params.set("page", String(page));
23
+ return `${BASE}/en/search?${params.toString()}`;
24
+ }
25
+
26
+ export interface SearchPageResult {
27
+ properties: PfProperty[];
28
+ pageCount: number;
29
+ totalCount: number;
30
+ }
31
+
32
+ export async function scrapeSearchPage(f: SearchFilters, page: number): Promise<SearchPageResult> {
33
+ const html = await fetchPfPage(buildSearchUrl(f, page));
34
+ const pp = extractPageProps(html);
35
+ const sr = pp.searchResult;
36
+ if (!sr) throw new Error("searchResult missing on search page");
37
+ const properties: PfProperty[] = (sr.listings || [])
38
+ .filter((l: any) => l.listing_type === "property" && l.property)
39
+ .map((l: any) => l.property);
40
+ return {
41
+ properties,
42
+ pageCount: sr.meta?.page_count ?? 1,
43
+ totalCount: sr.meta?.total_count ?? properties.length,
44
+ };
45
+ }
46
+
47
+ export async function scrapeSearch(f: SearchFilters): Promise<{ properties: PfProperty[]; totalCount: number }> {
48
+ const maxPages = Math.min(f.maxPages || 2, 10);
49
+ const first = await scrapeSearchPage(f, 1);
50
+ const all = [...first.properties];
51
+ const pages = Math.min(maxPages, first.pageCount);
52
+ for (let p = 2; p <= pages; p++) {
53
+ const r = await scrapeSearchPage(f, p);
54
+ all.push(...r.properties);
55
+ }
56
+ return { properties: all, totalCount: first.totalCount };
57
+ }