@dench.com/cli 2.7.5 → 2.7.6-staging.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,389 @@
1
+ /**
2
+ * Exa Company Search — entity parsing, Apollo-shape projection, and matching.
3
+ *
4
+ * The company twin of `exa-people.ts`. Exa's Company Search (`POST /search`
5
+ * with `category: "company"`) covers 50M+ company pages and returns structured
6
+ * firmographics on `results[].entities[]` as
7
+ * `{ id, type: "company", version, properties }`.
8
+ *
9
+ * Same two jobs as the people mapper: project into the Apollo-shaped envelope
10
+ * the existing extraction machinery already speaks, and refuse to hand back a
11
+ * company whose identity is not corroborated (Exa is a search, so it always
12
+ * returns *something*).
13
+ *
14
+ * Company matching is materially safer than person matching, because a company
15
+ * has a domain and a domain is an identity. When the caller knows the domain
16
+ * we compare hostnames and that is that; only the name-only path needs care.
17
+ *
18
+ * Field shapes are taken from live responses, not just the docs — real
19
+ * payloads carry an undocumented `research` key and frequently null out
20
+ * `webTraffic` and `financials.fundingLatestRound`, so everything is read
21
+ * defensively.
22
+ */
23
+
24
+ import {
25
+ asArray,
26
+ asObject,
27
+ asString,
28
+ normalizeCompany,
29
+ withScheme,
30
+ } from "./exa-people";
31
+
32
+ // ---------------------------------------------------------------------------
33
+ // Types
34
+ // ---------------------------------------------------------------------------
35
+
36
+ export type ExaCompanyHeadquarters = {
37
+ address?: string;
38
+ city?: string;
39
+ postalCode?: string;
40
+ country?: string;
41
+ };
42
+
43
+ export type ExaFundingRound = {
44
+ name?: string;
45
+ date?: string;
46
+ amount?: number;
47
+ };
48
+
49
+ export type ExaCompanyFinancials = {
50
+ revenueAnnual?: number;
51
+ fundingTotal?: number;
52
+ fundingLatestRound?: ExaFundingRound | null;
53
+ };
54
+
55
+ export type ExaWebTraffic = {
56
+ visitsMonthly?: number;
57
+ countryRank?: number;
58
+ avgDurationSeconds?: number;
59
+ };
60
+
61
+ export type ExaCompany = {
62
+ /** Stable Exa company entity id, when present. */
63
+ id?: string;
64
+ name?: string;
65
+ foundedYear?: number;
66
+ description?: string;
67
+ /** Estimated total headcount (`workforce.total`). */
68
+ headcount?: number;
69
+ headquarters?: ExaCompanyHeadquarters | null;
70
+ financials?: ExaCompanyFinancials | null;
71
+ webTraffic?: ExaWebTraffic | null;
72
+ };
73
+
74
+ export type ExaCompanyHit = {
75
+ /** Company URL from the enclosing search result. May be "" if absent. */
76
+ url: string;
77
+ title?: string;
78
+ highlights?: string[];
79
+ company: ExaCompany;
80
+ };
81
+
82
+ // ---------------------------------------------------------------------------
83
+ // Defensive readers
84
+ // ---------------------------------------------------------------------------
85
+
86
+ function asFiniteNumber(value: unknown): number | undefined {
87
+ return typeof value === "number" && Number.isFinite(value)
88
+ ? value
89
+ : undefined;
90
+ }
91
+
92
+ function asStringArray(value: unknown): string[] | undefined {
93
+ if (!Array.isArray(value)) return undefined;
94
+ const out = value.filter(
95
+ (item): item is string =>
96
+ typeof item === "string" && item.trim().length > 0,
97
+ );
98
+ return out.length > 0 ? out : undefined;
99
+ }
100
+
101
+ function readHeadquarters(value: unknown): ExaCompanyHeadquarters | null {
102
+ const raw = asObject(value);
103
+ if (!raw) return null;
104
+ const hq: ExaCompanyHeadquarters = {
105
+ address: asString(raw.address),
106
+ city: asString(raw.city),
107
+ postalCode: asString(raw.postalCode),
108
+ country: asString(raw.country),
109
+ };
110
+ return Object.values(hq).some((part) => part !== undefined) ? hq : null;
111
+ }
112
+
113
+ function readFundingRound(value: unknown): ExaFundingRound | null {
114
+ const raw = asObject(value);
115
+ if (!raw) return null;
116
+ const round: ExaFundingRound = {
117
+ name: asString(raw.name),
118
+ date: asString(raw.date),
119
+ amount: asFiniteNumber(raw.amount),
120
+ };
121
+ return Object.values(round).some((part) => part !== undefined) ? round : null;
122
+ }
123
+
124
+ function readFinancials(value: unknown): ExaCompanyFinancials | null {
125
+ const raw = asObject(value);
126
+ if (!raw) return null;
127
+ const financials: ExaCompanyFinancials = {
128
+ revenueAnnual: asFiniteNumber(raw.revenueAnnual),
129
+ fundingTotal: asFiniteNumber(raw.fundingTotal),
130
+ fundingLatestRound: readFundingRound(raw.fundingLatestRound),
131
+ };
132
+ const hasValue =
133
+ financials.revenueAnnual !== undefined ||
134
+ financials.fundingTotal !== undefined ||
135
+ financials.fundingLatestRound !== null;
136
+ return hasValue ? financials : null;
137
+ }
138
+
139
+ function readWebTraffic(value: unknown): ExaWebTraffic | null {
140
+ const raw = asObject(value);
141
+ if (!raw) return null;
142
+ const traffic: ExaWebTraffic = {
143
+ visitsMonthly: asFiniteNumber(raw.visitsMonthly),
144
+ countryRank: asFiniteNumber(raw.countryRank),
145
+ avgDurationSeconds: asFiniteNumber(raw.avgDurationSeconds),
146
+ };
147
+ return Object.values(traffic).some((part) => part !== undefined)
148
+ ? traffic
149
+ : null;
150
+ }
151
+
152
+ // ---------------------------------------------------------------------------
153
+ // Parsing
154
+ // ---------------------------------------------------------------------------
155
+
156
+ /** Walk an Exa `/search` response and pull out every company entity. */
157
+ export function parseExaCompanyResults(data: unknown): ExaCompanyHit[] {
158
+ const root = asObject(data);
159
+ if (!root) return [];
160
+ const hits: ExaCompanyHit[] = [];
161
+
162
+ for (const entry of asArray(root.results)) {
163
+ const result = asObject(entry);
164
+ if (!result) continue;
165
+ const url = asString(result.url) ?? asString(result.link) ?? "";
166
+ const title = asString(result.title);
167
+ const highlights = asStringArray(result.highlights);
168
+
169
+ for (const entityEntry of asArray(result.entities)) {
170
+ const entity = asObject(entityEntry);
171
+ if (!entity) continue;
172
+ if (asString(entity.type) !== "company") continue;
173
+ const properties = asObject(entity.properties);
174
+ if (!properties) continue;
175
+
176
+ hits.push({
177
+ url,
178
+ title,
179
+ highlights,
180
+ company: {
181
+ id: asString(entity.id),
182
+ name: asString(properties.name),
183
+ foundedYear: asFiniteNumber(properties.foundedYear),
184
+ description: asString(properties.description),
185
+ headcount: asFiniteNumber(asObject(properties.workforce)?.total),
186
+ headquarters: readHeadquarters(properties.headquarters),
187
+ financials: readFinancials(properties.financials),
188
+ webTraffic: readWebTraffic(properties.webTraffic),
189
+ },
190
+ });
191
+ }
192
+ }
193
+
194
+ return hits;
195
+ }
196
+
197
+ // ---------------------------------------------------------------------------
198
+ // Domain helpers
199
+ // ---------------------------------------------------------------------------
200
+
201
+ /** Hostname without protocol, `www.`, path, query, or trailing dot. */
202
+ export function normalizeDomain(value: string): string {
203
+ const trimmed = value.trim();
204
+ if (!trimmed) return "";
205
+ let host = trimmed;
206
+ try {
207
+ host = new URL(withScheme(trimmed)).hostname;
208
+ } catch {
209
+ host = trimmed.split("/")[0].split("?")[0];
210
+ }
211
+ return host
212
+ .toLowerCase()
213
+ .replace(/^www\./, "")
214
+ .replace(/\.$/, "");
215
+ }
216
+
217
+ /**
218
+ * A domain is accepted as identity evidence only when hostnames are exact.
219
+ * Parent/subdomain matching is unsafe on shared hosting:
220
+ * `tenant.vercel.app` and `vercel.app` are not necessarily the same company.
221
+ */
222
+ function domainsAgree(left: string, right: string): boolean {
223
+ const a = normalizeDomain(left);
224
+ const b = normalizeDomain(right);
225
+ if (!a || !b) return false;
226
+ return a === b;
227
+ }
228
+
229
+ /** Format a USD amount the way the CRM funding columns expect ("$11.3B"). */
230
+ export function formatUsdCompact(amount: number | undefined): string | null {
231
+ if (amount === undefined || !Number.isFinite(amount)) return null;
232
+ const abs = Math.abs(amount);
233
+ const units: Array<[number, string]> = [
234
+ [1e12, "T"],
235
+ [1e9, "B"],
236
+ [1e6, "M"],
237
+ [1e3, "K"],
238
+ ];
239
+ for (const [size, suffix] of units) {
240
+ if (abs >= size) {
241
+ const scaled = amount / size;
242
+ // One decimal below 100 ("$11.3B"), none above ("$250M").
243
+ const digits = Math.abs(scaled) < 100 ? 1 : 0;
244
+ return `$${scaled.toFixed(digits).replace(/\.0$/, "")}${suffix}`;
245
+ }
246
+ }
247
+ return `$${Math.round(amount)}`;
248
+ }
249
+
250
+ // ---------------------------------------------------------------------------
251
+ // Apollo-shape projection
252
+ // ---------------------------------------------------------------------------
253
+
254
+ /**
255
+ * Project a company hit into the Apollo-shaped envelope the existing
256
+ * enrichment extractors understand.
257
+ *
258
+ * The keys mirror the `apolloPath` values already declared by
259
+ * COMPANY_ENRICHMENT_COLUMNS (`organization.name`, `organization.domain`,
260
+ * `organization.description`, `organization.headcount`, …) so Exa can back
261
+ * those columns without touching the extractor.
262
+ */
263
+ export function toCompanyApolloEnvelope(
264
+ hit: ExaCompanyHit,
265
+ ): Record<string, unknown> {
266
+ const { company } = hit;
267
+ const hq = company.headquarters;
268
+ const financials = company.financials;
269
+ const domain = hit.url ? normalizeDomain(hit.url) : null;
270
+ const hqLocation =
271
+ [hq?.city, hq?.country].filter(Boolean).join(", ") || hq?.address || null;
272
+
273
+ const organization: Record<string, unknown> = {
274
+ id: company.id ?? null,
275
+ name: company.name ?? null,
276
+ domain: domain || null,
277
+ website_url: hit.url || null,
278
+ description: company.description ?? null,
279
+ founded_year: company.foundedYear ?? null,
280
+ headcount: company.headcount ?? null,
281
+ hq_location: hqLocation,
282
+ hq_address: hq?.address ?? null,
283
+ hq_city: hq?.city ?? null,
284
+ hq_country: hq?.country ?? null,
285
+ total_funding: financials?.fundingTotal ?? null,
286
+ total_funding_printed: formatUsdCompact(financials?.fundingTotal),
287
+ revenue_annual: financials?.revenueAnnual ?? null,
288
+ revenue_annual_printed: formatUsdCompact(financials?.revenueAnnual),
289
+ latest_funding_round: financials?.fundingLatestRound?.name ?? null,
290
+ latest_funding_date: financials?.fundingLatestRound?.date ?? null,
291
+ latest_funding_amount: financials?.fundingLatestRound?.amount ?? null,
292
+ monthly_visits: company.webTraffic?.visitsMonthly ?? null,
293
+ };
294
+
295
+ return {
296
+ organization,
297
+ // Mirror at the top level too: some column definitions use bare paths
298
+ // (`name`, `domain`) as extraction fallbacks.
299
+ name: organization.name,
300
+ domain: organization.domain,
301
+ // See the people envelope: the URL is useful provenance, the provider
302
+ // name is not — this object is handed back as the tool result.
303
+ source: { url: hit.url || null },
304
+ };
305
+ }
306
+
307
+ // ---------------------------------------------------------------------------
308
+ // Match gating
309
+ // ---------------------------------------------------------------------------
310
+
311
+ export type ExaCompanyMatchCriteria = {
312
+ /** Preferred: a domain is an identity, so this is an exact match. */
313
+ domain?: string;
314
+ /** Fallback when no domain is known. Weaker — names collide. */
315
+ name?: string;
316
+ };
317
+
318
+ export type ExaCompanyMatch = {
319
+ hit: ExaCompanyHit;
320
+ confidence: "exact" | "high";
321
+ matchedOn: string;
322
+ };
323
+
324
+ export type ExaCompanyMatchOutcome =
325
+ | { status: "matched"; match: ExaCompanyMatch }
326
+ | { status: "no_match" }
327
+ | { status: "insufficient_criteria"; missing: string[] };
328
+
329
+ /**
330
+ * Pick the one company we are willing to write into a CRM cell, or refuse.
331
+ *
332
+ * Domain wins outright when supplied — it is an identity, not a hint. Falling
333
+ * back to the name requires an exact normalized match (after stripping legal
334
+ * suffixes), never containment: "Example" must not match "Example Health" when
335
+ * those are different companies, and Exa will happily rank both.
336
+ */
337
+ export function matchExaCompany(
338
+ hits: ExaCompanyHit[],
339
+ criteria: ExaCompanyMatchCriteria,
340
+ ): ExaCompanyMatchOutcome {
341
+ const domain = criteria.domain?.trim();
342
+ const name = criteria.name?.trim();
343
+ if (!domain && !name) {
344
+ return { status: "insufficient_criteria", missing: ["domain or name"] };
345
+ }
346
+
347
+ if (domain) {
348
+ for (const hit of hits) {
349
+ if (hit.url && domainsAgree(hit.url, domain)) {
350
+ return {
351
+ status: "matched",
352
+ match: { hit, confidence: "exact", matchedOn: "domain" },
353
+ };
354
+ }
355
+ }
356
+ // A domain was supplied and nothing served it. Do NOT quietly fall back to
357
+ // a name match: the caller told us exactly which company they meant.
358
+ return { status: "no_match" };
359
+ }
360
+
361
+ const wanted = normalizeCompany(name ?? "");
362
+ if (!wanted) return { status: "insufficient_criteria", missing: ["name"] };
363
+ const exactNameMatches = hits.filter((hit) => {
364
+ const candidate = hit.company.name;
365
+ return candidate ? normalizeCompany(candidate) === wanted : false;
366
+ });
367
+ if (exactNameMatches.length === 1) {
368
+ return {
369
+ status: "matched",
370
+ match: {
371
+ hit: exactNameMatches[0],
372
+ confidence: "high",
373
+ matchedOn: "unique_exact_name",
374
+ },
375
+ };
376
+ }
377
+ return { status: "no_match" };
378
+ }
379
+
380
+ /** Natural-language query for resolving ONE known company. */
381
+ export function buildCompanyLookupQuery(
382
+ criteria: ExaCompanyMatchCriteria,
383
+ ): string {
384
+ if (criteria.name && criteria.domain) {
385
+ return `${criteria.name} (${normalizeDomain(criteria.domain)})`;
386
+ }
387
+ if (criteria.name) return criteria.name;
388
+ return normalizeDomain(criteria.domain ?? "");
389
+ }