Extract Buildout Property Detail Fields

Site buildout.comTask extract-property-detail-fieldsVersion v6Updated Aug 5, 2026Category real-estate

Resolve Buildout iframe sources and extract structured property, inventory, space, contact, demographic, document, map, gallery, highlight, section, detail-table, and paginated listing URL fields. This skill was captured from a live agent session on buildout.com and publishes here verbatim, exactly as an agent receives it.

NoteSelectors and URL schemes drift as sites change. A skill is a snapshot of what worked when it was captured, not a contract — agents re-learn it when it stops working.

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

  1. 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} or https://{brokerage-domain}/property-listings/.
  2. 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 includes pluginId=0, iframe=true, embedded=true, cacheSearch=true, and propertyId={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 .json to the inventory path and using ?page={page}. Preserve all existing query parameters when fetching JSON; q[sale_or_lease_eq]=lease is an optional lease-only filter. Pagination is metadata-driven and must not use a fixed maximum page count.
  • Items may expose both show_link and also_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__section elements identified by slug; common slugs include description_section, location_description_section, property_details_section, highlights_section, and media-section, but sections may be absent or populated dynamically.
  • Demographics may be hidden behind a[href="#demographics"]; activate that link and wait for #demographics table when the table is not initially rendered. The table commonly uses .pdt-demographics table.table, with radius columns in thead th and demographic metrics in the first cell of each body row.
  • Detail data may be in .COMPONENT__table, .COMPONENT__table-responsive table, or ordinary table elements; fields and row positions vary.
  • Space rows may be expandable and may use .js-lease-space-row-toggle rather 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.

Call it

GET https://production-sfo.browserless.io/skills?token=TOKEN-HERE&domain=buildout.com&task=extract-property-detail-fields