Purpose
Navigate directly to a Gilbert Group Real Estate property page from a known property identifier, open the external Buildout listing page, verify it loaded, and extract the property's available demographics and broker contact information.
When to Use
Use when the caller provides a Gilbert Group property identifier, such as {property-id}, and needs the corresponding listing, demographic metrics, or contact details inspected.
Workflow
- Build the direct listing URL:
https://gilbertgrouprealestate.com/property-listings/?propertyId={property-id}-lease#search_listings/. - In one browser-agent call,
gotothat URL, wait up to 8 seconds foriframe[src*="buildout.com"], then evaluate the following script to navigate to the preferred external Buildout frame:
(() => {
const frames = [...document.querySelectorAll("iframe[src]")].filter((f) => {
try {
return new URL(f.src).host !== location.host;
} catch {
return false;
}
});
const preferred = frames.filter((f) => f.src.includes("buildout.com"));
const pool = preferred.length ? preferred : frames;
pool.sort(
(a, b) => b.clientWidth * b.clientHeight - a.clientWidth * a.clientHeight,
);
if (!pool.length) return { navigated: false, reason: "no-external-listing-frame" };
window.location.href = pool[0].src;
return { navigated: true, frameUrl: pool[0].src };
})();- Wait for
h1for up to 10 seconds, then run this self-contained extractor on the loaded Buildout page:
(() => {
const clean = (value) => (value ? value.replace(/\s+/g, " ").trim() : null);
const table = document.querySelector(
'[slug="demographics"] table, .pdt-demographics table',
);
const output = {
url: location.href,
title: clean(document.title),
heading: clean(document.querySelector("h1")?.textContent),
loaded: !!document.querySelector("h1"),
demographics: [],
contacts: [],
};
if (table) {
const headers = [...table.querySelectorAll("thead th")].map((th) =>
clean(th.textContent),
);
const radii = headers
.map((header) => {
const match = /(?:^|\b)(\d+(?:\.\d+)?)\s*Mile/i.exec(header || "");
return match ? Number(match[1]) : null;
})
.filter((radius) => radius !== null);
output.demographics = radii.map((radiusMiles) => ({ radiusMiles }));
[...table.querySelectorAll("tbody tr")].forEach((row) => {
const cells = [...row.querySelectorAll("td")];
if (cells.length < 2) return;
const label = clean(cells[0].textContent)?.toLowerCase() || "";
const values = cells.slice(1).map((td) => clean(td.textContent));
radii.forEach((radiusMiles, index) => {
const item = output.demographics[index];
const raw = values[index];
if (!raw || /^0(?:\.0)?%?$/.test(raw)) return;
if (/total households/i.test(label))
item.households = parseInt(raw.replace(/[^0-9]/g, ""), 10) || null;
else if (/total population/i.test(label))
item.population = parseInt(raw.replace(/[^0-9]/g, ""), 10) || null;
else if (/average household income/i.test(label))
item.avgHHIncome = parseFloat(raw.replace(/[^0-9.]/g, "")) || null;
else if (/per capita income/i.test(label))
item.perCapitaIncome = parseFloat(raw.replace(/[^0-9.]/g, "")) || null;
});
});
}
const cards = [...document.querySelectorAll(".pdt-broker, .broker-info")];
const seen = new Set();
cards.forEach((card) => {
const key = clean(card.innerText);
if (!key || seen.has(key)) return;
seen.add(key);
output.contacts.push({
name: clean(
card.querySelector('.broker-name, [class*="broker-name"], h3, h4, strong')
?.textContent,
),
title: clean(
card.querySelector('.broker-title, [class*="broker-title"], .title')
?.textContent,
),
phones: [...card.querySelectorAll('a[href^="tel:"]')]
.map((a) => clean(a.textContent))
.filter(Boolean),
email: clean(card.querySelector('a[href^="mailto:"]')?.textContent),
license: clean(
card.querySelector('.broker-license, [class*="license"]')?.textContent,
),
img: card.querySelector("img")?.src || null,
text: key,
});
});
if (!output.contacts.length) {
const fallback = [
...document.querySelectorAll(
'.agent-info, [slug*="broker"], [slug*="contact"], [slug*="agent"], .contact-info, .broker-card, .js-broker, .pdt-contacts',
),
];
fallback.forEach((card) => {
const text = clean(card.innerText);
if (!text || seen.has(text)) return;
seen.add(text);
output.contacts.push({
name: clean(card.querySelector("h3, h4, strong")?.textContent),
title: clean(card.querySelector('.title, [class*="title"]')?.textContent),
phones: [...card.querySelectorAll('a[href^="tel:"]')]
.map((a) => clean(a.textContent))
.filter(Boolean),
email: clean(card.querySelector('a[href^="mailto:"]')?.textContent),
license: null,
img: card.querySelector("img")?.src || null,
text,
});
});
}
return output;
})();Site-Specific Gotchas
- The target route is a single-page property listing shell keyed by the query parameter
propertyId; the observed listing form appends-leaseto the property identifier. - Retain the
#search_listings/fragment in the direct route. - The useful property content is embedded in an external Buildout iframe. Prefer an iframe whose
srccontainsbuildout.com; otherwise select the largest external iframe and navigate to itssrc. - Because the Buildout frame is external, navigate to its URL before extracting its DOM rather than assuming the parent page can query the frame contents.
- Do not guess an opaque property identifier. Substitute the caller-provided
{property-id}into the URL. - Demographics may be absent. The extractor returns
demographics: []when no matching table exists. - Broker markup may use
.pdt-brokeror.broker-info; if neither exists, the extractor checks agent, contact, broker, andpdt-contactsvariants and returns raw card text as a fallback. - Contact cards may contain duplicate broker representations; the extractor deduplicates them by normalized visible text.
- Demographic table selectors rely on the Buildout
slug="demographics"convention or the.pdt-demographicsclass; verify these selectors if Buildout changes its markup.
Expected Output
The browser ends on the loaded external Buildout property page. The extractor returns an object containing the final url, document title, first-level heading, a boolean loaded confirmation, demographics as an array of radius objects with available households, population, avgHHIncome, and perCapitaIncome fields, and contacts as an array containing broker names, titles, phone links, email links, licenses, images, and fallback visible text.