Purpose
Resolve Buildout iframe sources and extract structured fields from Buildout property-detail or inventory pages. For inventory collections, use Buildout's JSON endpoint to retrieve every page and collect listing URLs, optionally filtered by lease or sale status. For property details, extract demographics tables and broker contact records using the page's Buildout-specific markup.
When to Use
Use for Buildout-hosted embedded property detail or inventory pages when the caller provides a brokerage property URL, listings wrapper URL, direct Buildout detail URL, or Buildout inventory URL. Return the resolved iframe src even when only the iframe URL is requested. Use the JSON workflow for collection requests requiring all listing URLs. Use the detail-page extractor for property fields, demographics, and contact selectors.
Workflow
- If a direct Buildout URL is supplied, navigate to it. Otherwise navigate directly to the supplied brokerage URL, preserving its query parameters, such as
https://{brokerage-domain}/view-properties/?propertyId={property-id}orhttps://{brokerage-domain}/property-listings/. - On a brokerage wrapper, wait for an iframe and resolve its exact source without guessing opaque identifiers:
(() => {
const f = document.querySelector(
'#buildout iframe, iframe[src*="buildout.com/plugins/"], iframe',
);
return f ? { src: f.src, title: f.title || null, id: f.id || null } : null;
})();Navigate directly to the returned iframe URL for subsequent extraction. 3. For an inventory collection, navigate directly to the inventory URL, then run the following evaluator. It appends .json, preserves query parameters, and follows metadata-driven pagination until all records are collected. The current URL may include optional q[sale_or_lease_eq]=lease; omit it for an unfiltered collection.
(async () => {
const inventoryPath = location.pathname.replace(/\\/+$/, '') + '.json';
const baseQs = new URLSearchParams(location.search);
const urls = new Set(); const records = []; let page = 0; let total = null; let limit = null; let lastMeta = null;
for (;;) {
const qs = new URLSearchParams(baseQs); qs.set('page', String(page));
const resp = await fetch(inventoryPath + '?' + qs.toString(), {credentials: 'same-origin'});
if (!resp.ok) throw new Error(`Buildout inventory API returned ${resp.status} on page ${page}`);
const data = await resp.json(); const items = Array.isArray(data.inventory) ? data.inventory : []; const meta = data.meta || {}; lastMeta = meta;
if (total == null && Number.isFinite(Number(meta.total))) total = Number(meta.total);
if (limit == null && Number.isFinite(Number(meta.limit))) limit = Number(meta.limit);
if (!items.length) break;
for (const item of items) { const listing = {showLink: item.show_link || null, alternateLink: item.also_for_sale_or_lease_url || null, id: item.id ?? item.property_id ?? null, raw: item}; records.push(listing); if (listing.showLink) urls.add(listing.showLink); if (listing.alternateLink) urls.add(listing.alternateLink); }
const offset = Number(meta.offset);
if ((Number.isFinite(offset) && offset + items.length >= total) || (!Number.isFinite(offset) && total != null && records.length >= total) || (total == null && items.length < (limit || items.length))) break;
page++;
}
return {inventoryUrl: location.href, apiPath: inventoryPath, filter: baseQs.get('q[sale_or_lease_eq]') || null, pages: page + (records.length ? 1 : 0), total, limit, collectedRecords: records.length, listingUrls: [...urls], records, lastMeta};
})()For a lease-only collection, retain existing parameters and add q[sale_or_lease_eq]=lease; do not impose a fixed page limit. 4. For a single property, navigate directly to the resolved iframe source. A typical detail URL is https://buildout.com/plugins/{plugin-id}/{brokerage-domain}/inventory/{property-id}?pluginId=0&iframe=true&embedded=true&cacheSearch=true&propertyId={property-id}. Wait for the detail heading or the demographics section. If the demographics table is not present after initial rendering, activate the existing demographics tab/link (a[href="#demographics"]) and wait for #demographics table; this is a required dynamic-content fallback, not a homepage or search step. 5. Run this concrete extractor on the loaded Buildout detail page:
(() => {
const clean = v => v == null ? null : String(v).replace(/\\s+/g, ' ').trim() || null;
const text = (root, selectors) => { for (const s of selectors) { const e = root?.querySelector(s); const v = clean(e?.innerText || e?.textContent); if (v) return v; } return null; };
const href = (root, selectors, prefix) => { for (const s of selectors) { const e = root?.querySelector(s); const v = clean(e?.getAttribute('href')); if (v && (!prefix || v.toLowerCase().startsWith(prefix))) return v.replace(/^mailto:/i, ''); } return null; };
const propertyId = new URL(location.href).pathname.match(/\\/inventory\\/([^/?#]+)/)?.[1] || new URL(location.href).searchParams.get('propertyId');
const tableRows = table => [...table.querySelectorAll('tr')].map(tr => [...tr.querySelectorAll('th,td')].map(x => clean(x.innerText)).filter(Boolean)).filter(r => r.length);
const details = [...document.querySelectorAll('.COMPONENT__table-responsive table, .COMPONENT__table, table')].map(table => ({caption: clean(table.querySelector('caption')?.innerText), headers: [...table.querySelectorAll('thead th')].map(x => clean(x.innerText)).filter(Boolean), rows: tableRows(table)})).filter(x => x.rows.length);
const demoTable = document.querySelector('#demographics .pdt-demographics table, #demographics table');
const demographics = demoTable ? (() => { const headers = [...demoTable.querySelectorAll('thead th')].map(x => clean(x.innerText)); const rows = [...demoTable.querySelectorAll('tbody tr')]; return {headers, rows: rows.map(tr => { const cells = [...tr.querySelectorAll('td')].map(x => clean(x.innerText)); return {metric: cells[0] || null, values: Object.fromEntries(headers.map((h, i) => [h || String(i), cells[i + 1] || null]))}; }), byRadius: headers.map((h, i) => ({radiusMiles: parseFloat(h) || h || null, ...Object.fromEntries(rows.map(tr => { const c = [...tr.querySelectorAll('td')].map(x => clean(x.innerText)); return [c[0] || String(i), c[i + 1] || null]; }))}))}; })() : null;
const contacts = [...document.querySelectorAll('.pdt-broker, .pdt-contact, .pdt-broker-info, .contact-card')].map(card => ({name: text(card, ['.pdt-broker-name strong', '.pdt-broker-name', '.pdt-contact-name', '[class*="contact-name"]']), title: text(card, ['.pdt-broker-title', '.pdt-contact-title', '[class*="broker-title"]']), cell: text(card, ['.pdt-broker-cell-phone', '.pdt-broker-cell-phone a']), phone: text(card, ['.pdt-broker-phone', '.pdt-contact-phone', '[href^="tel:"]']), email: href(card, ['a[href^="mailto:"]', '.pdt-contact-email', '.pdt-broker-email'], 'mailto:'), license: text(card, ['.pdt-contact-license', '.pdt-broker-license']), photo: card.querySelector('img')?.currentSrc || card.querySelector('img')?.src || null, text: clean(card.innerText)})).filter(x => Object.values(x).some(Boolean));
return {iframeSrc: location.href, url: location.href, propertyId, pageTitle: clean(document.title), title: text(document, ['h1', '.property-title', '.listing-title']), address: text(document, ['h2', '.pdt-address', '[slug*="address"]', '[component*="address"]']), priceOrRate: text(document, ['.pdt-price', '[class*="price"]', '[class*="rate"]']), overviewText: text(document, ['.pdt-site-content']), demographics, sections: {description: clean(document.querySelector('[slug="description_section"]')?.innerText), locationDescription: clean(document.querySelector('[slug="location_description_section"]')?.innerText), propertyDetails: clean(document.querySelector('[slug="property_details_section"]')?.innerText), highlights: clean(document.querySelector('[slug="highlights_section"]')?.innerText), media: !!document.querySelector('[slug="media-section"]')}, highlights: [...document.querySelectorAll('[slug="highlights_section"] li, [slug="highlights_custom_text"] li')].map(x => clean(x.innerText)).filter(Boolean), details, spaces: [...(document.querySelector('#spaces')?.querySelectorAll('.js-lease-space-row-toggle, tbody > tr') || [])].map(row => ({text: clean(row.innerText), cells: [...row.querySelectorAll('td,th')].map(x => clean(x.innerText)).filter(Boolean), attributes: Object.fromEntries([...row.attributes].map(a => [a.name, a.value]))})).filter(x => x.text || x.cells.length), contacts, documents: [...document.querySelectorAll('#documents a[href], .js-doc-link')].map(a => ({text: clean(a.innerText), href: a.href})).filter(x => x.text || x.href), images: [...document.querySelectorAll('.pdt-image-gallery .carousel-item img, .pdt-image-gallery img, .carousel-item img')].map(img => img.currentSrc || img.src).filter(Boolean), map: (() => { const e = document.querySelector('[component="map"], [component*="map"]'); return e ? {lat: clean(e.getAttribute('lat')), lng: clean(e.getAttribute('lng'))} : null; })()};
})()Site-Specific Gotchas
- Buildout property pages are commonly cross-origin iframes embedded in brokerage pages; resolve and navigate to the iframe URL because the useful DOM is not the wrapper DOM.
- Never fabricate opaque plugin or property IDs. Resolve
iframe[src]from the wrapper unless a verified direct URL is already available. - The direct detail route uses
/plugins/{plugin-id}/{brokerage-domain}/inventory/{property-id}and commonly includespluginId=0,iframe=true,embedded=true,cacheSearch=true, andpropertyId={property-id}. - Brokerage inventory pages may use property-specific wrapper routes such as
.../available-properties/{category}/?propertyId={property-id}. - Inventory collections expose a JSON endpoint by appending
.jsonto the inventory path and using?page={page}. Preserve all existing query parameters when fetching JSON;q[sale_or_lease_eq]=leaseis an optional lease-only filter. Pagination is metadata-driven and must not use a fixed maximum page count. - Items may expose both
show_linkandalso_for_sale_or_lease_url; collect and deduplicate both URL fields. - For Mid-America Group, the observed plugin path is
/plugins/576ddf9c9c27a7b27c07039a47dbb4a3d8c933ae/midamericagrp.com/inventory. - Buildout content is divided into
.COMPONENT__sectionelements identified byslug; common slugs includedescription_section,location_description_section,property_details_section,highlights_section, andmedia-section, but sections may be absent or populated dynamically. - Demographics may be hidden behind
a[href="#demographics"]; activate that link and wait for#demographics tablewhen the table is not initially rendered. The table commonly uses.pdt-demographics table.table, with radius columns inthead thand demographic metrics in the first cell of each body row. - Detail data may be in
.COMPONENT__table,.COMPONENT__table-responsive table, or ordinarytableelements; fields and row positions vary. - Space rows may be expandable and may use
.js-lease-space-row-togglerather than ordinary table rows. - Contact markup varies among
.pdt-contact,.pdt-broker,.pdt-broker-info, and contact cards; telephone and mail links provide fallbacks. Broker blocks commonly expose.pdt-broker-name,.pdt-broker-title,.pdt-broker-cell-phone, and.pdt-broker-phone. - The extractor selectors reflect Buildout's current structure and should be treated as site-specific selectors subject to markup changes.
Expected Output
For an iframe-only request, return the exact resolved iframe src. For a collection request, return pagination metadata, the complete deduplicated set of show_link and alternate listing URLs, and optionally the raw records. For a property request, return the resolved URL and property identifier, title, address, price or lease rate, overview and named sections, highlights, detail tables, spaces, demographics, contacts, document links, gallery image URLs, and map coordinates. Missing fields are represented by null, empty arrays, or false rather than inferred values.