|
#!/usr/bin/env bun |
|
// Usage: bun ss-scan.ts [pages] [priceTo] [daysBack] |
|
// bun ss-scan.ts 40 600 10 |
|
|
|
const PAGES = Number(Bun.argv[2] ?? 8); |
|
const PRICE_TO = Number(Bun.argv[3] ?? 550); |
|
const DAYS_BACK = Number(Bun.argv[4] ?? 5); |
|
const CUTOFF = new Date(Date.now() - DAYS_BACK * 86400_000); |
|
const UA = { "User-Agent": "Mozilla/5.0" }; |
|
|
|
async function get(url: string): Promise<string> { |
|
for (let i = 0; i < 4; i++) { |
|
try { |
|
const r = await fetch(url, { headers: UA, signal: AbortSignal.timeout(30_000) }); |
|
if (r.ok) return await r.text(); |
|
} catch {} |
|
await Bun.sleep(1000 * (i + 1)); |
|
} |
|
throw new Error(`fetch failed: ${url}`); |
|
} |
|
|
|
function nextData(html: string): any { |
|
const m = html.match(/<script id="__NEXT_DATA__"[^>]*>(.+?)<\/script>/s); |
|
return m ? JSON.parse(m[1]) : null; |
|
} |
|
|
|
async function pool<T, R>(items: T[], n: number, fn: (x: T) => Promise<R>): Promise<R[]> { |
|
const out: R[] = new Array(items.length); |
|
let i = 0; |
|
await Promise.all(Array.from({ length: n }, async () => { |
|
while (true) { |
|
const k = i++; |
|
if (k >= items.length) return; |
|
out[k] = await fn(items[k]); |
|
} |
|
})); |
|
return out; |
|
} |
|
|
|
const listUrl = (p: number) => |
|
`https://home.ss.ge/en/real-estate/l/Flat/For-Rent?cityIdList=96&areaFrom=40¤cyId=2&priceType=1&priceTo=${PRICE_TO}&rooms=2%2C3%2C4&order=1&page=${p}`; |
|
|
|
const fetchList = async (p: number) => |
|
nextData(await get(listUrl(p))).props.pageProps.applicationList.realStateItemModel as any[]; |
|
|
|
const fetchDetail = async (id: number): Promise<{ infoType: number | null; phone: string | null }> => { |
|
const d = nextData(await get(`https://home.ss.ge/en/real-estate/en-${id}`)); |
|
const pp = d?.props?.pageProps ?? {}; |
|
const raw = pp.applicationData?.applicationPhones?.find((p: any) => p.isMain)?.phoneNumber |
|
?? pp.applicationData?.applicationPhones?.[0]?.phoneNumber |
|
?? null; |
|
return { infoType: pp.initialAgentInfoData?.infoType ?? null, phone: formatPhone(raw) }; |
|
}; |
|
|
|
// Georgian mobile numbers are 9 digits (5XXXXXXXX). International: +995 XXX XX XX XX. |
|
function formatPhone(raw: string | null): string | null { |
|
if (!raw) return null; |
|
let d = raw.replace(/\D/g, ""); |
|
if (d.startsWith("995")) d = d.slice(3); |
|
if (d.length !== 9) return `+${raw}`; |
|
return `+995 ${d.slice(0, 3)} ${d.slice(3, 5)} ${d.slice(5, 7)} ${d.slice(7)}`; |
|
} |
|
|
|
const pages = Array.from({ length: PAGES }, (_, i) => i + 1); |
|
const raw = (await pool(pages, 4, fetchList)).flat(); |
|
console.log(`total list items: ${raw.length}`); |
|
|
|
const recent = raw.filter(a => { |
|
if (!a.title?.includes("room Flat For Rent")) return false; |
|
const cd = a.createDate ? new Date(a.createDate) : null; |
|
return cd && cd >= CUTOFF; |
|
}); |
|
console.log(`filtered: ${recent.length}`); |
|
|
|
const details = await pool(recent, 6, a => fetchDetail(a.applicationId)); |
|
|
|
const priv = recent |
|
.map((a, i) => ({ ...a, _phone: details[i].phone, _type: details[i].infoType })) |
|
.filter(a => a._type === 0) |
|
.sort((a, b) => +new Date(b.createDate) - +new Date(a.createDate)); |
|
console.log(`private: ${priv.length}`); |
|
|
|
const fmt = (a: any) => { |
|
const addr = [a.address?.subdistrictTitle, a.address?.streetTitle, a.address?.streetNumber].filter(Boolean).join(", ") || "?"; |
|
const rooms = a.title?.split(" ")[0] ?? "?"; |
|
const cd = new Date(a.createDate).toLocaleDateString("en-GB", { day: "2-digit", month: "short" }); |
|
return `| ${cd} | https://home.ss.ge/en/real-estate/en-${a.applicationId} | ${addr} | ${rooms} | ${a.totalArea} | $${a.price?.priceUsd} | ${a._phone ?? "?"} |`; |
|
}; |
|
|
|
const out = [ |
|
`# Non-agent Batumi flat rentals (40+ m², 2-4 rooms, ≤$${PRICE_TO}, last ${DAYS_BACK} days)`, |
|
``, |
|
`Scanned pages 1-${PAGES} (${raw.length} raw). ${recent.length} in window; ${priv.length} owner-posted.`, |
|
``, |
|
`| Created | URL | Address | Rooms | m² | Price (USD) | Phone |`, |
|
`|---|---|---|---|---|---|---|`, |
|
...priv.map(fmt), |
|
].join("\n"); |
|
|
|
await Bun.write("ss-non-agent-listings.md", out); |
|
console.log("wrote ss-non-agent-listings.md"); |