/** * Exa Company Search — entity parsing, Apollo-shape projection, and matching. * * The company twin of `exa-people.ts`. Exa's Company Search (`POST /search` * with `category: "company"`) covers 50M+ company pages and returns structured * firmographics on `results[].entities[]` as * `{ id, type: "company", version, properties }`. * * Same two jobs as the people mapper: project into the Apollo-shaped envelope * the existing extraction machinery already speaks, and refuse to hand back a * company whose identity is not corroborated (Exa is a search, so it always * returns *something*). * * Company matching is materially safer than person matching, because a company * has a domain and a domain is an identity. When the caller knows the domain * we compare hostnames and that is that; only the name-only path needs care. * * Field shapes are taken from live responses, not just the docs — real * payloads carry an undocumented `research` key and frequently null out * `webTraffic` and `financials.fundingLatestRound`, so everything is read * defensively. */ import { asArray, asObject, asString, normalizeCompany, withScheme, } from "./exa-people"; // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- export type ExaCompanyHeadquarters = { address?: string; city?: string; postalCode?: string; country?: string; }; export type ExaFundingRound = { name?: string; date?: string; amount?: number; }; export type ExaCompanyFinancials = { revenueAnnual?: number; fundingTotal?: number; fundingLatestRound?: ExaFundingRound | null; }; export type ExaWebTraffic = { visitsMonthly?: number; countryRank?: number; avgDurationSeconds?: number; }; export type ExaCompany = { /** Stable Exa company entity id, when present. */ id?: string; name?: string; foundedYear?: number; description?: string; /** Estimated total headcount (`workforce.total`). */ headcount?: number; headquarters?: ExaCompanyHeadquarters | null; financials?: ExaCompanyFinancials | null; webTraffic?: ExaWebTraffic | null; }; export type ExaCompanyHit = { /** Company URL from the enclosing search result. May be "" if absent. */ url: string; title?: string; highlights?: string[]; company: ExaCompany; }; // --------------------------------------------------------------------------- // Defensive readers // --------------------------------------------------------------------------- function asFiniteNumber(value: unknown): number | undefined { return typeof value === "number" && Number.isFinite(value) ? value : undefined; } function asStringArray(value: unknown): string[] | undefined { if (!Array.isArray(value)) return undefined; const out = value.filter( (item): item is string => typeof item === "string" && item.trim().length > 0, ); return out.length > 0 ? out : undefined; } function readHeadquarters(value: unknown): ExaCompanyHeadquarters | null { const raw = asObject(value); if (!raw) return null; const hq: ExaCompanyHeadquarters = { address: asString(raw.address), city: asString(raw.city), postalCode: asString(raw.postalCode), country: asString(raw.country), }; return Object.values(hq).some((part) => part !== undefined) ? hq : null; } function readFundingRound(value: unknown): ExaFundingRound | null { const raw = asObject(value); if (!raw) return null; const round: ExaFundingRound = { name: asString(raw.name), date: asString(raw.date), amount: asFiniteNumber(raw.amount), }; return Object.values(round).some((part) => part !== undefined) ? round : null; } function readFinancials(value: unknown): ExaCompanyFinancials | null { const raw = asObject(value); if (!raw) return null; const financials: ExaCompanyFinancials = { revenueAnnual: asFiniteNumber(raw.revenueAnnual), fundingTotal: asFiniteNumber(raw.fundingTotal), fundingLatestRound: readFundingRound(raw.fundingLatestRound), }; const hasValue = financials.revenueAnnual !== undefined || financials.fundingTotal !== undefined || financials.fundingLatestRound !== null; return hasValue ? financials : null; } function readWebTraffic(value: unknown): ExaWebTraffic | null { const raw = asObject(value); if (!raw) return null; const traffic: ExaWebTraffic = { visitsMonthly: asFiniteNumber(raw.visitsMonthly), countryRank: asFiniteNumber(raw.countryRank), avgDurationSeconds: asFiniteNumber(raw.avgDurationSeconds), }; return Object.values(traffic).some((part) => part !== undefined) ? traffic : null; } // --------------------------------------------------------------------------- // Parsing // --------------------------------------------------------------------------- /** Walk an Exa `/search` response and pull out every company entity. */ export function parseExaCompanyResults(data: unknown): ExaCompanyHit[] { const root = asObject(data); if (!root) return []; const hits: ExaCompanyHit[] = []; for (const entry of asArray(root.results)) { const result = asObject(entry); if (!result) continue; const url = asString(result.url) ?? asString(result.link) ?? ""; const title = asString(result.title); const highlights = asStringArray(result.highlights); for (const entityEntry of asArray(result.entities)) { const entity = asObject(entityEntry); if (!entity) continue; if (asString(entity.type) !== "company") continue; const properties = asObject(entity.properties); if (!properties) continue; hits.push({ url, title, highlights, company: { id: asString(entity.id), name: asString(properties.name), foundedYear: asFiniteNumber(properties.foundedYear), description: asString(properties.description), headcount: asFiniteNumber(asObject(properties.workforce)?.total), headquarters: readHeadquarters(properties.headquarters), financials: readFinancials(properties.financials), webTraffic: readWebTraffic(properties.webTraffic), }, }); } } return hits; } // --------------------------------------------------------------------------- // Domain helpers // --------------------------------------------------------------------------- /** Hostname without protocol, `www.`, path, query, or trailing dot. */ export function normalizeDomain(value: string): string { const trimmed = value.trim(); if (!trimmed) return ""; let host = trimmed; try { host = new URL(withScheme(trimmed)).hostname; } catch { host = trimmed.split("/")[0].split("?")[0]; } return host .toLowerCase() .replace(/^www\./, "") .replace(/\.$/, ""); } /** * A domain is accepted as identity evidence only when hostnames are exact. * Parent/subdomain matching is unsafe on shared hosting: * `tenant.vercel.app` and `vercel.app` are not necessarily the same company. */ function domainsAgree(left: string, right: string): boolean { const a = normalizeDomain(left); const b = normalizeDomain(right); if (!a || !b) return false; return a === b; } /** Format a USD amount the way the CRM funding columns expect ("$11.3B"). */ export function formatUsdCompact(amount: number | undefined): string | null { if (amount === undefined || !Number.isFinite(amount)) return null; const abs = Math.abs(amount); const units: Array<[number, string]> = [ [1e12, "T"], [1e9, "B"], [1e6, "M"], [1e3, "K"], ]; for (const [size, suffix] of units) { if (abs >= size) { const scaled = amount / size; // One decimal below 100 ("$11.3B"), none above ("$250M"). const digits = Math.abs(scaled) < 100 ? 1 : 0; return `$${scaled.toFixed(digits).replace(/\.0$/, "")}${suffix}`; } } return `$${Math.round(amount)}`; } // --------------------------------------------------------------------------- // Apollo-shape projection // --------------------------------------------------------------------------- /** * Project a company hit into the Apollo-shaped envelope the existing * enrichment extractors understand. * * The keys mirror the `apolloPath` values already declared by * COMPANY_ENRICHMENT_COLUMNS (`organization.name`, `organization.domain`, * `organization.description`, `organization.headcount`, …) so Exa can back * those columns without touching the extractor. */ export function toCompanyApolloEnvelope( hit: ExaCompanyHit, ): Record { const { company } = hit; const hq = company.headquarters; const financials = company.financials; const domain = hit.url ? normalizeDomain(hit.url) : null; const hqLocation = [hq?.city, hq?.country].filter(Boolean).join(", ") || hq?.address || null; const organization: Record = { id: company.id ?? null, name: company.name ?? null, domain: domain || null, website_url: hit.url || null, description: company.description ?? null, founded_year: company.foundedYear ?? null, headcount: company.headcount ?? null, hq_location: hqLocation, hq_address: hq?.address ?? null, hq_city: hq?.city ?? null, hq_country: hq?.country ?? null, total_funding: financials?.fundingTotal ?? null, total_funding_printed: formatUsdCompact(financials?.fundingTotal), revenue_annual: financials?.revenueAnnual ?? null, revenue_annual_printed: formatUsdCompact(financials?.revenueAnnual), latest_funding_round: financials?.fundingLatestRound?.name ?? null, latest_funding_date: financials?.fundingLatestRound?.date ?? null, latest_funding_amount: financials?.fundingLatestRound?.amount ?? null, monthly_visits: company.webTraffic?.visitsMonthly ?? null, }; return { organization, // Mirror at the top level too: some column definitions use bare paths // (`name`, `domain`) as extraction fallbacks. name: organization.name, domain: organization.domain, // See the people envelope: the URL is useful provenance, the provider // name is not — this object is handed back as the tool result. source: { url: hit.url || null }, }; } // --------------------------------------------------------------------------- // Match gating // --------------------------------------------------------------------------- export type ExaCompanyMatchCriteria = { /** Preferred: a domain is an identity, so this is an exact match. */ domain?: string; /** Fallback when no domain is known. Weaker — names collide. */ name?: string; }; export type ExaCompanyMatch = { hit: ExaCompanyHit; confidence: "exact" | "high"; matchedOn: string; }; export type ExaCompanyMatchOutcome = | { status: "matched"; match: ExaCompanyMatch } | { status: "no_match" } | { status: "insufficient_criteria"; missing: string[] }; /** * Pick the one company we are willing to write into a CRM cell, or refuse. * * Domain wins outright when supplied — it is an identity, not a hint. Falling * back to the name requires an exact normalized match (after stripping legal * suffixes), never containment: "Example" must not match "Example Health" when * those are different companies, and Exa will happily rank both. */ export function matchExaCompany( hits: ExaCompanyHit[], criteria: ExaCompanyMatchCriteria, ): ExaCompanyMatchOutcome { const domain = criteria.domain?.trim(); const name = criteria.name?.trim(); if (!domain && !name) { return { status: "insufficient_criteria", missing: ["domain or name"] }; } if (domain) { for (const hit of hits) { if (hit.url && domainsAgree(hit.url, domain)) { return { status: "matched", match: { hit, confidence: "exact", matchedOn: "domain" }, }; } } // A domain was supplied and nothing served it. Do NOT quietly fall back to // a name match: the caller told us exactly which company they meant. return { status: "no_match" }; } const wanted = normalizeCompany(name ?? ""); if (!wanted) return { status: "insufficient_criteria", missing: ["name"] }; const exactNameMatches = hits.filter((hit) => { const candidate = hit.company.name; return candidate ? normalizeCompany(candidate) === wanted : false; }); if (exactNameMatches.length === 1) { return { status: "matched", match: { hit: exactNameMatches[0], confidence: "high", matchedOn: "unique_exact_name", }, }; } return { status: "no_match" }; } /** Natural-language query for resolving ONE known company. */ export function buildCompanyLookupQuery( criteria: ExaCompanyMatchCriteria, ): string { if (criteria.name && criteria.domain) { return `${criteria.name} (${normalizeDomain(criteria.domain)})`; } if (criteria.name) return criteria.name; return normalizeDomain(criteria.domain ?? ""); }