← 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 };
}