← back to Nationalrealestate
src/ingest/listings/weichert.ts
78 lines
/**
* Weichert adapter. Discovery: paginated listings sitemap
* `sitemaplistings.ashx?page=N&justlisted=False&weichert=True` → PDP URLs
* `https://www.weichert.com/{id}/`. Facts from JSON-LD Residence + Product/Offer +
* Place.geo (address, price, lat/lng; Weichert does not expose beds/baths/sqft in
* JSON-LD, left null). source_id = the numeric id in the URL. Robots: no listing
* disallows (verified 2026-07-30); the engine re-checks each path before fetch.
*/
import type { ListingAdapter, ListingFacts } from './types.ts';
import { extractJsonLd, typeMatches, num, rawFetch } from './shared.ts';
const SITEMAP = (page: number) =>
`https://www.weichert.com/sitemaplistings.ashx?page=${page}&justlisted=False&weichert=True`;
/** Flatten JSON-LD: expand @graph, keep plain nodes. */
function flatten(nodes: any[]): any[] {
return nodes.flatMap(n => (Array.isArray(n?.['@graph']) ? n['@graph'] : [n])).filter(Boolean);
}
export const weichert: ListingAdapter = {
source: 'weichert',
// FULL: discovery paginates the complete `sitemaplistings.ashx` (justlisted=False) set, i.e.
// every currently-listed property, so a fresh crawl re-surfaces still-active listings and a
// missing one is genuinely gone → withdrawal detection is valid for this source.
mode: 'full',
host: 'www.weichert.com',
listingsPathHint: '/137101627/', // representative PDP path for the robots honor check
async discover(cap) {
const urls: string[] = [];
for (let page = 1; page <= 30 && urls.length < cap; page++) {
const { ok, body } = await rawFetch(SITEMAP(page));
if (!ok) break;
const locs = [...body.matchAll(/<loc>\s*([^<]+?)\s*<\/loc>/gi)]
.map(m => m[1].replace(/&/g, '&'))
.filter(u => /^https?:\/\/www\.weichert\.com\/\d+\/?$/.test(u));
if (!locs.length) break; // ran past the last populated page
urls.push(...locs);
}
return urls.slice(0, cap);
},
parse(url, html): ListingFacts | null {
const idm = url.match(/\/(\d+)\/?$/);
const sourceId = idm?.[1];
if (!sourceId) return null;
const flat = flatten(extractJsonLd(html));
// Weichert splits facts across nodes: Residence(address) · Product(.offers.price) ·
// (no geo in JSON-LD — region resolves off addressRegion/state instead).
const residence = flat.find(n => typeMatches(n, 'Residence', 'SingleFamilyResidence', 'House'));
const product = flat.find(n => typeMatches(n, 'Product'));
const offer = product?.offers || flat.find(n => typeMatches(n, 'Offer')) || {};
const addr = residence?.address || flat.find(n => typeMatches(n, 'PostalAddress')) || {};
const geo = (flat.find(n => n?.geo)?.geo) || {};
const streetParts = [addr.streetAddress, addr.addressLocality, addr.addressRegion, addr.postalCode]
.filter(Boolean).join(', ');
const facts: ListingFacts = {
sourceId,
url,
address: streetParts || residence?.name || product?.name || null,
city: addr.addressLocality ?? null,
state: addr.addressRegion ?? null,
zip: addr.postalCode ?? null,
price: num(offer?.price),
beds: num(residence?.numberOfBedrooms),
baths: num(residence?.numberOfBathroomsTotal),
sqft: null,
lat: geo.latitude != null ? Number(geo.latitude) : null,
lng: geo.longitude != null ? Number(geo.longitude) : null,
status: 'active',
};
if (!facts.address && facts.price == null) return null;
return facts;
},
};