← back to Govarbitrage
src/lib/places.ts
548 lines
// Google Places API (New) — Text Search for COMMERCIAL real-estate professionals
// near a government-surplus property: buyer / acquisition brokers + leasing agents.
//
// Commercial-only by construction: the text queries target commercial CRE, and a
// light residential filter drops obvious home-sale / apartment-locator results
// ("just commercial for this build"). Server-side ONLY — the API key never reaches
// the client. Live-query with `no-store`; we don't persist Google place data beyond
// the response (Places ToS: no long-term caching of place fields except the id).
export type AgentKind = "buyer" | "leasing";
export interface AgentResult {
id: string;
name: string;
address: string | null;
rating: number | null;
reviews: number;
phone: string | null;
website: string | null;
mapsUrl: string | null;
primaryType: string | null;
kinds: AgentKind[]; // which query lens(es) surfaced it
}
export interface CommercialAgentSearch {
city?: string;
state?: string;
zip?: string;
kinds: AgentKind[];
}
export interface CommercialAgentResponse {
location: string;
kinds: AgentKind[];
results: AgentResult[];
queries: number; // billed Places Text Search calls (for cost surfacing)
}
const ENDPOINT = "https://places.googleapis.com/v1/places:searchText";
const FIELD_MASK = [
"places.id",
"places.displayName",
"places.formattedAddress",
"places.rating",
"places.userRatingCount",
"places.nationalPhoneNumber",
"places.websiteUri",
"places.googleMapsUri",
"places.businessStatus",
"places.primaryTypeDisplayName",
"places.types",
"places.primaryType",
].join(",");
// Curated query "lenses" per kind — a small set (≤3) of complementary CRE
// angles that widen coverage without ballooning cost. Each lens is one billed
// Places Text Search call; results are unioned across lenses by place id.
const KIND_LENSES: Record<AgentKind, string[]> = {
buyer: [
"commercial real estate broker",
"commercial real estate investment sales broker",
"tenant representation broker",
],
leasing: [
"commercial real estate leasing agent",
"office space leasing broker",
"retail industrial leasing broker",
],
};
// Positive COMMERCIAL name evidence — the primary "keep" gate. A real-estate
// result is only kept if it carries one of these strong commercial signals (or
// an allowlisted brokerage brand, below). "office"/"retail"/"industrial" are
// asset-class words that reliably read commercial in a broker name; "leasing"
// stays because our query lenses are commercial-leasing lenses.
const COMMERCIAL_RE =
/\b(commercial|CRE|industrial|retail|office|investment\s+sales|investments?|tenant\s+rep(?:resentation)?|net\s+lease|leasing|brokerage|advisors?|capital\s+markets|properties\s+group|commercial\s+properties|multifamily|commercial\s+group|income\s+propert(?:y|ies))\b/i;
// Known national / regional COMMERCIAL brokerage brands. Case-insensitive
// substring match — presence of any of these keeps the result outright, on par
// with a strong commercial name signal (and ahead of the residential drop).
const COMMERCIAL_BRANDS: string[] = [
"cbre",
"jll",
"jones lang",
"cushman",
"wakefield",
"colliers",
"newmark",
"marcus & millichap",
"marcus and millichap",
"kidder mathews",
"lee & associates",
"lee and associates",
"avison young",
"savills",
"matthews real estate",
"stan johnson",
"northmarq",
"berkadia",
"institutional property advisors",
"voit",
"daum",
"nai ",
"nai capital",
"cresa",
"transwestern",
"stream realty",
"hughes marino",
];
// RESIDENTIAL / irrelevant name signals — drop UNLESS a commercial signal or an
// allowlisted brand overrides (apply order enforced in isCommercial). Kept
// word-boundary aware so "homes" hits "Earnest Homes" but the standalone-noun
// senses don't over-match. Generic "realty"/"real estate" with no commercial
// evidence is handled by the positive-gate default-drop, not here.
const RESIDENTIAL_RE =
/\b(residential|homes?|home\s+sales?|for\s+sale|realtor|property\s+m(?:anage|gmt)ment|apartments?|condos?|single\s+family|first[-\s]?time|home\s+team|realty\s+team|real\s+estate\s+team|mortgage|home\s+loans?)\b/i;
// Places `types` signals. `real_estate_agency` is the generic real-estate type
// (does NOT distinguish commercial vs residential — name text decides).
const REAL_ESTATE_TYPES = new Set(["real_estate_agency"]);
// Off-vertical / off-topic Google place types — drop on match regardless of
// name. "The Maimon Group" came back as travel_agency; storage/lodging/moving/
// insurance are common mis-tags that are never a commercial broker.
//
// Matched by EXACT type-token equality (not a \b-regex): Google's place types
// are underscore-joined tokens (e.g. "furniture_store"), and "_" is a JS word
// character, so a \bstore\b boundary never fires inside "furniture_store" —
// it silently let every "*_store" / "shopping_mall" / etc. type through. Any
// type ending in "_store" is also treated as off-topic (Places has dozens of
// retail *_store subtypes: hardware_store, electronics_store, furniture_store…).
const OFF_TOPIC_TYPES = new Set([
"travel_agency",
"lodging",
"insurance_agency",
"moving_company",
"storage",
"self_storage",
"restaurant",
"food",
"store",
"school",
"hospital",
"gym",
"shopping_mall",
"finance", // banks/lenders mis-tagged onto RE queries — not a broker
"lawyer",
"accounting",
"car_dealer",
"car_rental",
"general_contractor",
"home_improvement_store",
]);
function isOffTopicType(t: string): boolean {
const tt = t.toLowerCase();
return OFF_TOPIC_TYPES.has(tt) || tt.endsWith("_store");
}
// Residential-flavored place types — exact-token match (same underscore-boundary
// reason as above). Complements the off-topic blocklist for apartment/home mis-tags.
const RESIDENTIAL_TYPES = new Set(["apartment_complex", "home_goods_store"]);
// FINANCIAL-ADVISORY / wealth / securities name signals — the "broker" and
// "finance" verticals overlap, so a Places text search for "commercial real
// estate broker" reliably surfaces wealth-management, financial-advisory and
// securities firms. Google's `finance` type-gate only catches the ones Google
// explicitly tagged finance; many advisory firms come back tagged
// `establishment`/`point_of_interest` and slip past it. These names carry the
// finance-ambiguous tokens ("investments", "advisors", "capital markets") that
// ALSO appear in legit CRE names, so a firm matching this is dropped UNLESS it
// also carries an unambiguous real-estate anchor (RE_ANCHOR_RE, below).
// Note: "investment sales" is a CRE-specific phrase and is EXCLUDED here (it's a
// real-estate anchor, below) — only bare "investment(s)" / "investment advisors"
// (with no RE anchor) reads as a wealth firm. The negative lookahead on
// "investment" avoids matching "investment sales".
const FINANCIAL_ADVISORY_RE =
/\b(wealth|financial\s+advisors?|financial\s+planning|financial\s+services|financial\s+group|securities|asset\s+management|wealth\s+management|investment\s+management|investment\s+advisors?|investments?(?!\s+sales)|private\s+equity|hedge\s+fund|insurance|retirement|portfolio\s+management|mutual\s+funds?|stockbrokers?|401k?)\b/i;
// Unambiguous REAL-ESTATE anchor words. Presence of one proves a
// finance-ambiguous name (e.g. "Investment Sales Advisors") is genuinely a
// commercial-RE broker and not a wealth firm, so it survives the financial-
// advisory drop. "real estate" / "realty" / "property/properties" / "leasing"
// are the reliable RE anchors; "CRE" and "commercial real estate" too.
const RE_ANCHOR_RE =
/\b(real\s+estate|realty|CRE|propert(?:y|ies)|leasing|net\s+lease|tenant\s+rep(?:resentation)?|multifamily|land\s+brokerage|investment\s+sales|commercial)\b/i;
// CRE-ADJACENT SERVICE / TRADE name signals — firms that WORK on commercial
// property but are NOT buyer/leasing brokers: appraisal/valuation, title &
// escrow, construction & general contracting, mortgage lending / loan servicing,
// property inspection, environmental, interior design, architecture/engineering,
// surveying, facilities & property management, and the trades (roofing/HVAC/…).
// A Places "commercial real estate broker" text search surfaces these because
// their names carry the same "commercial"/"property" asset-class tokens that
// satisfy COMMERCIAL_RE (e.g. "Commercial Property Appraisers", "Sunbelt
// Commercial Construction", "Apex Commercial Mortgage Capital"), so they'd
// otherwise be kept as brokers and pollute the "brokers near this property"
// answer. This is distinct from the FINANCIAL_ADVISORY drop (wealth/securities
// firms) and from the off-topic Google-`types` drop (retail/mall mis-tags): here
// the NAME itself reads commercial-RE, but the firm is a CRE trade/service, not a
// brokerage. Dropped UNLESS the name ALSO carries an explicit broker anchor
// (BROKER_ANCHOR_RE) or an allowlisted brokerage brand — a full-service
// brokerage that happens to run a valuation / property-management arm survives.
const CRE_SERVICE_RE =
/\b(appraisals?|appraisers?|valuations?|title(?:\s+(?:company|services|insurance|&\s+escrow))?|escrow|construction|general\s+contract(?:or|ing)|contractors?|mortgage(?!\s+broker)|lending|lenders?|loan\s+servicing|inspections?|inspectors?|environmental|interior\s+design|architects?|architecture|engineering|surveyors?|surveying|janitorial|cleaning|landscaping|roofing|hvac|plumbing|restoration|property\s+tax|facilit(?:y|ies)\s+m(?:anage|gmt)ment|property\s+m(?:anage|gmt)ment)\b/i;
// Explicit BROKER anchor — proves the firm actually does brokerage even if its
// name also mentions a service word (e.g. a "Realty & Property Management" firm
// or a "Real Estate & Construction Advisors" brokerage). Presence of one of
// these overrides the CRE-service drop and keeps the listing.
const BROKER_ANCHOR_RE =
/\b(brokers?|brokerage|realty|real\s+estate|leasing|tenant\s+rep(?:resentation)?|investment\s+sales|net\s+lease|realtors?)\b/i;
function hasCommercialBrand(name: string): boolean {
const n = name.toLowerCase();
return COMMERCIAL_BRANDS.some((b) => n.includes(b));
}
// True when the name reads as a CRE-adjacent SERVICE / trade firm (appraisal,
// title/escrow, construction, mortgage lending, inspection, property/facilities
// management, …) that carries NO explicit broker anchor — i.e. a service
// false-positive we must drop even though its "commercial"/"property" tokens
// would otherwise satisfy COMMERCIAL_RE. An allowlisted brokerage brand always
// overrides (a real brokerage's valuation / PM arm stays).
function isCreServiceNoBroker(name: string): boolean {
return (
CRE_SERVICE_RE.test(name) &&
!BROKER_ANCHOR_RE.test(name) &&
!hasCommercialBrand(name)
);
}
// True when the name reads as a financial-advisory / wealth firm that lacks a
// real-estate anchor — i.e. a finance false-positive we must drop even though a
// bare finance-ambiguous token (investments/advisors/capital markets) would
// otherwise satisfy COMMERCIAL_RE. An allowlisted CRE brand always overrides
// (a real brokerage that happens to include "insurance" etc. in its name stays).
function isFinancialAdvisoryNoRE(name: string): boolean {
return (
FINANCIAL_ADVISORY_RE.test(name) &&
!RE_ANCHOR_RE.test(name) &&
!hasCommercialBrand(name)
);
}
// --- Commercial-relevance ranking weight ----------------------------------
// Every result in the ranked list has ALREADY passed isCommercial() (precision
// gate), but they're not equally strong CRE-broker matches. A listing that is
// an allowlisted national brokerage AND was surfaced by BOTH the buyer- and
// leasing-lenses is a far more confident "commercial broker near this property"
// answer than a lone generic result that only just cleared the commercial name
// gate. The old ranking (pure rating × log(reviews)) ignored this entirely, so
// a highly-rated but weakly-commercial listing outranked a two-lens national
// brokerage with modest reviews.
//
// relevanceScore() returns a small multiplier (1.0 baseline → up to ~1.6) that
// nudges stronger CRE matches upward. It's deliberately BOUNDED and multiplied
// into the existing rating-weight so it re-orders near-comparable results and
// breaks near-ties in favor of the better commercial match — WITHOUT letting a
// zero-review brand leapfrog a genuinely well-reviewed office (a 0-review brand
// still scores rating×log(1)=0 and stays below any reviewed listing). Pure +
// deterministic; unit-tested offline with zero live Places billing.
export function relevanceScore(r: AgentResult): number {
let s = 1;
// Allowlisted national/regional brokerage brand → the most confident signal.
if (hasCommercialBrand(r.name)) s += 0.3;
// Strong commercial name evidence (CRE/industrial/office/investment sales…).
if (COMMERCIAL_RE.test(r.name)) s += 0.2;
// Surfaced by BOTH the buyer- and leasing-lens → broader CRE relevance.
if (r.kinds.includes("buyer") && r.kinds.includes("leasing")) s += 0.1;
return s;
}
// --- Contact-based dedupe -------------------------------------------------
// Google Places returns the SAME physical brokerage office under several of our
// query lenses as DIFFERENT place ids (e.g. "CBRE" from the buyer lens and
// "CBRE Investment Sales" from the investment-sales lens). Place-id union alone
// can't collapse those, so we add a precision-first second pass:
//
// • PHONE is the authoritative per-office identity — one number rings one
// desk. Same normalized phone ⇒ same office ⇒ merge.
// • WEBSITE is a WEAKER signal: national brands share ONE domain across every
// branch (all CBRE offices are cbre.com). So a website match only merges
// when the phones DON'T contradict (at least one side is phone-less, or the
// phones are equal). Two same-domain entries with two DIFFERENT phones are
// two real branch offices and are kept apart.
// Normalize a US phone to its 10 significant digits, or null if it isn't a
// usable 10-digit number (precision-first: never key a merge on partial digits).
export function normalizePhone(phone: string | null): string | null {
if (!phone) return null;
let d = phone.replace(/\D+/g, "");
if (d.length === 11 && d.startsWith("1")) d = d.slice(1);
return d.length === 10 ? d : null;
}
// Normalize a website to its lowercased host without a leading "www.", or null
// if it doesn't parse. Full host (not just registrable domain) keeps it precise:
// a duplicate of the same office carries the identical URL, so it still matches.
export function normalizeHost(website: string | null): string | null {
if (!website) return null;
try {
const h = new URL(website).hostname.toLowerCase().replace(/^www\./, "");
return h || null;
} catch {
return null;
}
}
// Collapse near-identical listings that are the same office surfaced twice.
// Keeps the first-seen record as canonical (preserving the ranked insertion
// order), unions its `kinds`, adopts the richer rating/review pair, and fills
// any missing contact fields from the duplicate. Pure + deterministic — no I/O,
// so it's unit-tested offline with zero live Places billing.
export function dedupeByContact(results: AgentResult[]): AgentResult[] {
const canon: AgentResult[] = [];
const byPhone = new Map<string, AgentResult>();
const byHost = new Map<string, AgentResult>();
const merge = (into: AgentResult, dup: AgentResult) => {
for (const k of dup.kinds) if (!into.kinds.includes(k)) into.kinds.push(k);
// Adopt the more-rated listing's rating/review pair (don't double-count).
if (dup.reviews > into.reviews) {
into.rating = dup.rating;
into.reviews = dup.reviews;
}
into.address ??= dup.address;
into.phone ??= dup.phone;
into.website ??= dup.website;
into.primaryType ??= dup.primaryType;
into.mapsUrl ??= dup.mapsUrl;
// Register any contact key the canonical only just adopted from the dup.
const p = normalizePhone(into.phone);
if (p) byPhone.set(p, into);
const h = normalizeHost(into.website);
if (h && !byHost.has(h)) byHost.set(h, into);
};
for (const r of results) {
const p = normalizePhone(r.phone);
const h = normalizeHost(r.website);
// 1. Same phone ⇒ same office (authoritative).
if (p && byPhone.has(p)) {
merge(byPhone.get(p)!, r);
continue;
}
// 2. Same website ⇒ merge ONLY when phones don't prove they're different
// offices (national brands share one domain across many branches).
if (h && byHost.has(h)) {
const c = byHost.get(h)!;
const cp = normalizePhone(c.phone);
if (!p || !cp || p === cp) {
merge(c, r);
continue;
}
}
// New canonical office.
canon.push(r);
if (p) byPhone.set(p, r);
if (h && !byHost.has(h)) byHost.set(h, r);
}
return canon;
}
export interface RawPlace {
id?: string;
displayName?: { text?: string };
formattedAddress?: string;
rating?: number;
userRatingCount?: number;
nationalPhoneNumber?: string;
websiteUri?: string;
googleMapsUri?: string;
businessStatus?: string;
primaryTypeDisplayName?: { text?: string };
primaryType?: string;
types?: string[];
}
function locationString(city?: string, state?: string, zip?: string): string {
return [city?.trim(), state?.trim(), zip?.trim()].filter(Boolean).join(", ");
}
// Precision-first commercial classifier. Google's `real_estate_agency` type does
// NOT distinguish commercial from residential, so we REQUIRE positive commercial
// evidence (a strong name signal or an allowlisted brokerage brand) to keep a
// result, instead of defaulting to keep. Apply order:
// 1. Off-topic Google type (travel/lodging/storage/…) → DROP regardless.
// 2. Financial-advisory / wealth firm WITHOUT a real-estate anchor → DROP.
// (Runs before the commercial keep because finance names carry ambiguous
// "investments"/"advisors"/"capital markets" tokens that would otherwise
// satisfy COMMERCIAL_RE — e.g. "Sterling Financial Advisors" is a wealth
// firm, not a CRE broker. An allowlisted CRE brand overrides this.)
// 3. CRE-adjacent SERVICE / trade firm WITHOUT an explicit broker anchor →
// DROP. (Also runs before the commercial keep: appraisal/title/escrow/
// construction/mortgage-lending/property-management names carry
// "commercial"/"property" tokens that satisfy COMMERCIAL_RE but are NOT
// brokers — e.g. "Sunbelt Commercial Construction". Broker anchor or brand
// overrides.)
// 4. Strong commercial name signal OR allowlisted brand → KEEP (wins over
// any residential signal, e.g. "Commercial Realty Advisors").
// 5. Residential / irrelevant name signal → DROP.
// 6. Residential-flavored place type → DROP.
// 7. Generic real-estate result with no commercial evidence → DROP.
// 8. Non-real-estate result with no signal either way → DROP (nothing kept it).
export function isCommercial(p: RawPlace): boolean {
const name = p.displayName?.text ?? "";
const types = [p.primaryType ?? "", ...(p.types ?? [])];
// 1. Off-vertical place type → drop outright (catches the travel_agency mis-tag
// AND every underscore-compound retail type like furniture_store/shopping_mall).
if (types.some((t) => isOffTopicType(t))) return false;
const hasResidentialType = types.some((t) => RESIDENTIAL_TYPES.has(t.toLowerCase()));
// 2. Financial-advisory / wealth / securities firm with no real-estate anchor →
// drop. Guards against the broker↔finance vertical overlap that surfaces
// wealth firms on a "commercial real estate broker" text search.
if (isFinancialAdvisoryNoRE(name)) return false;
// 3. CRE-adjacent SERVICE / trade firm with no explicit broker anchor → drop.
// Catches appraisal/title/escrow/construction/mortgage-lending/inspection/
// property-management firms whose "commercial"/"property" tokens satisfy
// COMMERCIAL_RE but which are NOT buyer/leasing brokers. Runs before the
// positive keep for the same reason as the finance gate. An allowlisted
// brand or a broker anchor in the name overrides (handled inside).
if (isCreServiceNoBroker(name)) return false;
// 4. Positive commercial evidence wins over everything below.
if (COMMERCIAL_RE.test(name) || hasCommercialBrand(name)) return true;
// 5. Explicit residential / irrelevant name → drop.
if (RESIDENTIAL_RE.test(name)) return false;
// 6. Residential-flavored place type with no commercial evidence → drop.
if (hasResidentialType) return false;
// 7 & 8. No commercial evidence at all — whether or not it's a generic
// real_estate_agency, we can't confirm it's commercial, so drop it.
// (Precision > recall: the page promises commercial-only.)
return false;
}
async function textSearch(query: string, apiKey: string): Promise<RawPlace[]> {
const res = await fetch(ENDPOINT, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Goog-Api-Key": apiKey,
"X-Goog-FieldMask": FIELD_MASK,
},
body: JSON.stringify({ textQuery: query, maxResultCount: 20, languageCode: "en", regionCode: "US" }),
cache: "no-store",
});
if (!res.ok) {
const detail = await res.text().catch(() => "");
throw new Error(`Places ${res.status}: ${detail.slice(0, 200)}`);
}
const json = (await res.json()) as { places?: RawPlace[] };
return json.places ?? [];
}
export async function searchCommercialAgents(
input: CommercialAgentSearch,
): Promise<CommercialAgentResponse> {
const apiKey = process.env.GOOGLE_PLACES_API_KEY;
if (!apiKey) throw new Error("GOOGLE_PLACES_API_KEY is not configured");
const loc = locationString(input.city, input.state, input.zip);
if (!loc) throw new Error("A city, state, or ZIP is required");
const kinds = input.kinds.length ? input.kinds : (["buyer", "leasing"] as AgentKind[]);
const byId = new Map<string, AgentResult>();
// Build the full billed call plan: up to 3 lenses per kind. Each entry is one
// Places Text Search call, so `queries` = plan length = actual billed calls.
const plan = kinds.flatMap((kind) =>
KIND_LENSES[kind].map((lens) => ({ kind, query: `${lens} in ${loc}` })),
);
// Fire all lens fetches concurrently. Use allSettled so one failing lens
// (Google 429/500 on a single query) doesn't discard the results that DID
// succeed. `queries` counts only fulfilled calls — the honest billed count.
const settled = await Promise.allSettled(plan.map((p) => textSearch(p.query, apiKey)));
// If EVERY lens failed, surface the failure (route maps it to 502) rather than
// silently returning an empty "no agents found".
if (!settled.some((r) => r.status === "fulfilled")) {
const firstErr = settled.find((r) => r.status === "rejected") as PromiseRejectedResult | undefined;
throw firstErr?.reason instanceof Error ? firstErr.reason : new Error("Places search failed");
}
let queries = 0;
// Union results by place id, unioning the `kinds` array so a place surfaced by
// both a buyer- and leasing-lens carries both.
settled.forEach((r, i) => {
if (r.status !== "fulfilled") return;
queries++;
const places = r.value;
const kind = plan[i].kind;
for (const p of places) {
if (!p.id || !p.displayName?.text) continue;
if (p.businessStatus && p.businessStatus !== "OPERATIONAL") continue;
if (!isCommercial(p)) continue;
const existing = byId.get(p.id);
if (existing) {
if (!existing.kinds.includes(kind)) existing.kinds.push(kind);
continue;
}
byId.set(p.id, {
id: p.id,
name: p.displayName.text,
address: p.formattedAddress ?? null,
rating: typeof p.rating === "number" ? p.rating : null,
reviews: p.userRatingCount ?? 0,
phone: p.nationalPhoneNumber ?? null,
website: p.websiteUri ?? null,
mapsUrl: p.googleMapsUri ?? null,
primaryType: p.primaryTypeDisplayName?.text ?? null,
kinds: [kind],
});
}
});
// Collapse the same office surfaced under multiple lenses (same phone, or
// same website when phones don't contradict) BEFORE ranking.
const deduped = dedupeByContact([...byId.values()]);
// Rank by rating weighted by review volume AND commercial-relevance, then by
// relevance alone (surfaces a strong CRE match ahead of a weakly-commercial
// one at equal rating-weight), then rating, then review count. The relevance
// multiplier is bounded (≤~1.6) so it re-orders near-comparable results and
// breaks near-ties toward the better commercial match, but a 0-review listing
// still weighs rating×log(1)=0 and cannot leapfrog a genuinely reviewed office.
const results = deduped.sort((a, b) => {
const sa = (a.rating ?? 0) * Math.log((a.reviews ?? 0) + 1) * relevanceScore(a);
const sb = (b.rating ?? 0) * Math.log((b.reviews ?? 0) + 1) * relevanceScore(b);
if (sb !== sa) return sb - sa;
const ra = relevanceScore(a);
const rb = relevanceScore(b);
if (rb !== ra) return rb - ra;
if ((b.rating ?? 0) !== (a.rating ?? 0)) return (b.rating ?? 0) - (a.rating ?? 0);
return (b.reviews ?? 0) - (a.reviews ?? 0);
});
return { location: loc, kinds, results, queries };
}