indiecrm-cli 0.1.0 → 0.2.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.
Files changed (45) hide show
  1. package/CHANGELOG.md +447 -0
  2. package/CODE_OF_CONDUCT.md +35 -0
  3. package/CONTRIBUTING.md +7 -0
  4. package/README.md +14 -2
  5. package/RESEARCH.md +346 -0
  6. package/SECURITY.md +35 -0
  7. package/dist/affiliate-copy.js +49 -0
  8. package/dist/auth.js +453 -0
  9. package/dist/bigquery.js +199 -0
  10. package/dist/chrome-browser.js +231 -0
  11. package/dist/cli.js +19652 -0
  12. package/dist/company-identity-review.js +78 -0
  13. package/dist/company-leads.js +422 -0
  14. package/dist/company-recovery.js +84 -0
  15. package/dist/deel-outreach.js +469 -0
  16. package/dist/deel-salesnav.js +368 -0
  17. package/dist/direct-path.js +326 -0
  18. package/dist/domain.js +53 -0
  19. package/dist/domainfinder.js +764 -0
  20. package/dist/engine.js +216 -0
  21. package/dist/historical-queries.js +189 -0
  22. package/dist/hunter-emailfinder.js +252 -0
  23. package/dist/icp-templates.js +171 -0
  24. package/dist/indiecrm/commands.js +108 -0
  25. package/dist/indiecrm-cli.js +2 -104
  26. package/dist/instantly.js +136 -0
  27. package/dist/io.js +21 -0
  28. package/dist/leadlists-funnel.js +148 -0
  29. package/dist/linkedin-companies.js +562 -0
  30. package/dist/linkedin-product-details.js +1203 -0
  31. package/dist/linkedin-product-search.js +1081 -0
  32. package/dist/linkedin-products.js +786 -0
  33. package/dist/linkedin-session-contracts.js +3 -0
  34. package/dist/linkedin-session.js +846 -0
  35. package/dist/providers.js +1 -0
  36. package/dist/research-browser-preference.js +37 -0
  37. package/dist/sales-navigator.js +1231 -0
  38. package/dist/salesnav-backfill.js +710 -0
  39. package/dist/sample-data.js +34 -0
  40. package/dist/session-recovery.js +62 -0
  41. package/dist/vendor/salesprompter-shared/extension-session-contracts.js +29 -0
  42. package/dist/vendor/salesprompter-shared/linkedin-session.js +22 -0
  43. package/dist/vendor/salesprompter-shared/phantombuster-contracts.js +16 -0
  44. package/dist/vendor/salesprompter-shared/session-vault-contracts.js +17 -0
  45. package/package.json +73 -14
@@ -0,0 +1,562 @@
1
+ import { load } from "cheerio";
2
+ import { BigQuery } from "@google-cloud/bigquery";
3
+ import { DEFAULT_LINKEDIN_SCRAPER_USER_AGENT } from "./linkedin-session-contracts.js";
4
+ const DEFAULT_LINKEDIN_BASE_URL = "https://www.linkedin.com";
5
+ const DEFAULT_RAPIDAPI_LINKEDIN_COMPANY_HOST = "web-scraping-api2.p.rapidapi.com";
6
+ const DEFAULT_RAPIDAPI_LINKEDIN_COMPANY_ENDPOINT = "https://web-scraping-api2.p.rapidapi.com/get-company-by-linkedinurl";
7
+ const DEFAULT_BIGQUERY_PROJECT_ID = process.env.BQ_PROJECT_ID ?? process.env.GOOGLE_CLOUD_PROJECT ?? process.env.GCLOUD_PROJECT ?? "icpidentifier";
8
+ const LINKEDIN_COMPANIES_TABLE = "`icpidentifier.SalesGPT.linkedin_companies_processed`";
9
+ const LEADPOOL_TABLE = "`icpidentifier.SalesGPT.leadPool_new`";
10
+ function normalizeWhitespace(value) {
11
+ return (value ?? "").replace(/\s+/g, " ").trim();
12
+ }
13
+ function getLinkedInBaseUrl(env = process.env) {
14
+ const override = env.SALESPROMPTER_LINKEDIN_BASE_URL?.trim();
15
+ if (!override) {
16
+ return DEFAULT_LINKEDIN_BASE_URL;
17
+ }
18
+ return override.replace(/\/+$/, "");
19
+ }
20
+ function buildLinkedInCompanyUrl(companyId, env = process.env) {
21
+ return new URL(`/company/${companyId}/`, `${getLinkedInBaseUrl(env)}/`).toString().replace(/\/+$/, "");
22
+ }
23
+ function getCompanyHandleFromUrl(url) {
24
+ if (!url) {
25
+ return undefined;
26
+ }
27
+ const pathname = new URL(url).pathname;
28
+ const segments = pathname.split("/").filter(Boolean);
29
+ const companyIndex = segments.findIndex((segment) => segment.toLowerCase() === "company");
30
+ if (companyIndex === -1) {
31
+ return undefined;
32
+ }
33
+ const handle = normalizeWhitespace(segments[companyIndex + 1]);
34
+ if (!handle || /^\d+$/.test(handle)) {
35
+ return undefined;
36
+ }
37
+ return handle.toLowerCase();
38
+ }
39
+ function extractDomainFromWebsite(website) {
40
+ if (!website) {
41
+ return undefined;
42
+ }
43
+ try {
44
+ return new URL(website).hostname.replace(/^www\./i, "").toLowerCase();
45
+ }
46
+ catch {
47
+ return undefined;
48
+ }
49
+ }
50
+ function getRapidApiLinkedInCompanyConfig(env = process.env) {
51
+ const apiKey = env.RAPIDAPI_KEY?.trim();
52
+ if (!apiKey) {
53
+ return null;
54
+ }
55
+ return {
56
+ apiKey,
57
+ host: env.RAPIDAPI_LINKEDIN_COMPANY_HOST?.trim() || DEFAULT_RAPIDAPI_LINKEDIN_COMPANY_HOST,
58
+ endpoint: env.RAPIDAPI_LINKEDIN_COMPANY_ENDPOINT?.trim() || DEFAULT_RAPIDAPI_LINKEDIN_COMPANY_ENDPOINT
59
+ };
60
+ }
61
+ function parseJsonLdOrganization($) {
62
+ const scripts = $('script[type="application/ld+json"]')
63
+ .map((_, element) => $(element).text())
64
+ .get();
65
+ for (const rawText of scripts) {
66
+ const text = rawText.trim();
67
+ if (!text) {
68
+ continue;
69
+ }
70
+ try {
71
+ const parsed = JSON.parse(text);
72
+ const items = Array.isArray(parsed) ? parsed : [parsed];
73
+ for (const item of items) {
74
+ if (!item || typeof item !== "object") {
75
+ continue;
76
+ }
77
+ const candidate = item;
78
+ const type = normalizeWhitespace(String(candidate["@type"] ?? ""));
79
+ if (type.toLowerCase() === "organization") {
80
+ return candidate;
81
+ }
82
+ }
83
+ }
84
+ catch {
85
+ continue;
86
+ }
87
+ }
88
+ return null;
89
+ }
90
+ function readDefinitionMap($) {
91
+ const entries = new Map();
92
+ $("dt").each((_, element) => {
93
+ const term = normalizeWhitespace($(element).text()).toLowerCase();
94
+ const description = normalizeWhitespace($(element).next("dd").text());
95
+ if (term && description) {
96
+ entries.set(term, description);
97
+ }
98
+ });
99
+ return entries;
100
+ }
101
+ function parseEmployeeCount(value) {
102
+ const digits = normalizeWhitespace(value).replace(/[^\d]/g, "");
103
+ if (!digits) {
104
+ return undefined;
105
+ }
106
+ const parsed = Number(digits);
107
+ return Number.isFinite(parsed) ? parsed : undefined;
108
+ }
109
+ function parseFoundedYear(value) {
110
+ const match = normalizeWhitespace(value).match(/\b(19|20)\d{2}\b/);
111
+ if (!match) {
112
+ return undefined;
113
+ }
114
+ return Number(match[0]);
115
+ }
116
+ export function parseLinkedInCompanyPage(html, requestUrl) {
117
+ const $ = load(html);
118
+ const timestamp = new Date().toISOString();
119
+ const canonicalUrl = normalizeWhitespace($('link[rel="canonical"]').attr("href")) || requestUrl;
120
+ const unavailableTitle = normalizeWhitespace($("title").text()).toLowerCase();
121
+ const unavailableBody = normalizeWhitespace($("body").text()).toLowerCase();
122
+ const unavailable = unavailableTitle.includes("page not found") ||
123
+ unavailableTitle.includes("linkedin") && unavailableTitle.includes("unavailable") ||
124
+ unavailableBody.includes("page isn't available") ||
125
+ unavailableBody.includes("this page doesn't exist");
126
+ const jsonLd = parseJsonLdOrganization($);
127
+ const definitions = readDefinitionMap($);
128
+ const requestIdMatch = requestUrl.match(/\/company\/(\d+)(?:\/|$)/i);
129
+ const canonicalIdMatch = canonicalUrl.match(/\/company\/(\d+)(?:\/|$)/i);
130
+ const resolvedId = Number(canonicalIdMatch?.[1] ?? requestIdMatch?.[1]);
131
+ if (!Number.isFinite(resolvedId)) {
132
+ throw new Error(`LinkedIn company URL is missing a numeric company id: ${requestUrl}`);
133
+ }
134
+ const name = normalizeWhitespace(String(jsonLd?.name ?? "")) ||
135
+ normalizeWhitespace($("h1").first().text()) ||
136
+ normalizeWhitespace($('meta[property="og:title"]').attr("content")) ||
137
+ undefined;
138
+ const jsonLdUrl = normalizeWhitespace(String(jsonLd?.url ?? ""));
139
+ const jsonLdWebsite = (() => {
140
+ if (!jsonLdUrl)
141
+ return undefined;
142
+ try {
143
+ const hostname = new URL(jsonLdUrl).hostname;
144
+ return /(^|\.)linkedin\.com$/i.test(hostname) ? undefined : jsonLdUrl;
145
+ }
146
+ catch {
147
+ return undefined;
148
+ }
149
+ })();
150
+ const website = normalizeWhitespace(definitions.get("website")) ||
151
+ jsonLdWebsite ||
152
+ undefined;
153
+ const description = normalizeWhitespace(String(jsonLd?.description ?? "")) ||
154
+ normalizeWhitespace($('meta[name="description"]').attr("content")) ||
155
+ undefined;
156
+ const tagline = normalizeWhitespace($(".top-card-layout__headline").first().text()) ||
157
+ normalizeWhitespace($(".org-top-card-summary__tagline").first().text()) ||
158
+ undefined;
159
+ const industry = normalizeWhitespace(definitions.get("industry")) || undefined;
160
+ const companySize = normalizeWhitespace(definitions.get("company size")) || undefined;
161
+ const headquarters = normalizeWhitespace(definitions.get("headquarters")) || undefined;
162
+ const specialties = normalizeWhitespace(definitions.get("specialties")) || undefined;
163
+ const employeesOnLinkedIn = parseEmployeeCount(definitions.get("employees"));
164
+ const founded = parseFoundedYear(definitions.get("founded"));
165
+ return {
166
+ id: resolvedId,
167
+ handle: getCompanyHandleFromUrl(canonicalUrl),
168
+ name,
169
+ industry,
170
+ companySize,
171
+ headquarters,
172
+ website,
173
+ domain: extractDomainFromWebsite(website),
174
+ employeesOnLinkedIn,
175
+ description,
176
+ tagline,
177
+ specialties,
178
+ founded,
179
+ unavailable,
180
+ error: unavailable ? "LinkedIn company page is unavailable" : undefined,
181
+ timestamp
182
+ };
183
+ }
184
+ function parseRapidApiCompanyProfile(payload, candidate) {
185
+ const data = payload.data;
186
+ if (!data) {
187
+ throw new Error(`RapidAPI company response did not include data for ${candidate.companyUrl}`);
188
+ }
189
+ const id = Number(data.company_id ?? candidate.companyId);
190
+ if (!Number.isFinite(id)) {
191
+ throw new Error(`RapidAPI company response is missing a numeric company id for ${candidate.companyUrl}`);
192
+ }
193
+ const website = normalizeWhitespace(data.website) || undefined;
194
+ const linkedinUrl = normalizeWhitespace(data.linkedin_url) || candidate.companyUrl;
195
+ const industries = Array.isArray(data.industries) ? data.industries.map((value) => normalizeWhitespace(value)).filter(Boolean) : [];
196
+ return {
197
+ id,
198
+ handle: getCompanyHandleFromUrl(linkedinUrl),
199
+ name: normalizeWhitespace(data.company_name) || candidate.companyName,
200
+ industry: industries[0],
201
+ companySize: normalizeWhitespace(data.employee_range) || undefined,
202
+ headquarters: normalizeWhitespace(data.hq_full_address) || undefined,
203
+ website,
204
+ domain: normalizeWhitespace(data.domain) || extractDomainFromWebsite(website),
205
+ employeesOnLinkedIn: typeof data.employee_count === "number" && Number.isFinite(data.employee_count) ? data.employee_count : undefined,
206
+ description: normalizeWhitespace(data.description) || undefined,
207
+ tagline: normalizeWhitespace(data.tagline) || undefined,
208
+ specialties: normalizeWhitespace(data.specialties) || undefined,
209
+ founded: typeof data.year_founded === "number" && Number.isFinite(data.year_founded) ? data.year_founded : undefined,
210
+ unavailable: false,
211
+ timestamp: new Date().toISOString()
212
+ };
213
+ }
214
+ export function buildLinkedInCompanyBackfillSql(clientId, limit) {
215
+ return `WITH backlog AS (
216
+ SELECT
217
+ companyId,
218
+ ANY_VALUE(companyName) AS companyName,
219
+ ANY_VALUE(company_filter) AS companyFilter
220
+ FROM ${LEADPOOL_TABLE}
221
+ WHERE clientId = ${clientId}
222
+ AND companyId IS NOT NULL
223
+ AND (
224
+ COALESCE(company_toBeCrawled, FALSE) = TRUE
225
+ OR LOWER(CAST(company_filter AS STRING)) IN (
226
+ '02: company unavailable',
227
+ '02: linkedin companies unavailable',
228
+ '03: company to be crawled'
229
+ )
230
+ )
231
+ GROUP BY companyId
232
+ )
233
+ SELECT
234
+ companyId,
235
+ companyName,
236
+ companyFilter
237
+ FROM backlog
238
+ WHERE NOT EXISTS (
239
+ SELECT 1
240
+ FROM ${LINKEDIN_COMPANIES_TABLE} processed
241
+ WHERE processed.query = CONCAT('https://www.linkedin.com/company/', CAST(backlog.companyId AS STRING))
242
+ )
243
+ ORDER BY companyId
244
+ LIMIT ${limit}`;
245
+ }
246
+ function sqlString(value) {
247
+ if (value === undefined) {
248
+ return "NULL";
249
+ }
250
+ return `'${value.replaceAll("\\", "\\\\").replaceAll("'", "\\'")}'`;
251
+ }
252
+ function sqlInteger(value) {
253
+ return typeof value === "number" && Number.isFinite(value) ? String(Math.trunc(value)) : "NULL";
254
+ }
255
+ function sqlBoolean(value) {
256
+ return value ? "TRUE" : "FALSE";
257
+ }
258
+ export function buildLinkedInCompaniesMergeSql(rows) {
259
+ if (rows.length === 0) {
260
+ throw new Error("At least one LinkedIn company row is required for merge SQL.");
261
+ }
262
+ const sourceRows = rows
263
+ .map((row) => `STRUCT(
264
+ ${sqlInteger(row.id)} AS id,
265
+ ${sqlString(row.handle)} AS handle,
266
+ ${sqlString(row.name)} AS name,
267
+ NULL AS industryId,
268
+ ${sqlString(row.industry)} AS industry,
269
+ NULL AS industry_input,
270
+ NULL AS companySizeId,
271
+ ${sqlString(row.companySize)} AS companySize,
272
+ ${sqlString(row.headquarters)} AS headquarters,
273
+ NULL AS countryCode,
274
+ ${sqlString(row.website)} AS website_linkedin,
275
+ ${sqlString(row.domain)} AS domain_linkedin,
276
+ NULL AS hunter_emailCount,
277
+ NULL AS domainFinder_name,
278
+ NULL AS domain,
279
+ NULL AS emailDomainFinder_ts,
280
+ NULL AS company_missingHeadquarters,
281
+ FALSE AS blacklisted_bySalesPrompter,
282
+ FALSE AS company_countryCodeToBeProcessed,
283
+ FALSE AS domainBlacklisted,
284
+ FALSE AS company_emailDomainFinder_toBeProcessed,
285
+ FALSE AS company_emailDomainNotFound,
286
+ ${sqlInteger(row.employeesOnLinkedIn)} AS employeesOnLinkedIn,
287
+ NULL AS growth6Mth,
288
+ NULL AS growth1Yr,
289
+ NULL AS growth2Yr,
290
+ ${sqlString(row.description)} AS description,
291
+ ${sqlString(row.tagline)} AS tagline,
292
+ ${sqlString(row.specialties)} AS specialties,
293
+ ${sqlInteger(row.founded)} AS founded,
294
+ ${sqlString(row.error)} AS error,
295
+ ${sqlBoolean(row.unavailable)} AS linkedin_companies_companyUnavailable,
296
+ TIMESTAMP(${sqlString(row.timestamp)}) AS timestamp
297
+ )`)
298
+ .join(",\n");
299
+ return `MERGE ${LINKEDIN_COMPANIES_TABLE} AS target
300
+ USING (
301
+ SELECT *
302
+ FROM UNNEST([
303
+ ${sourceRows}
304
+ ])
305
+ ) AS source
306
+ ON target.id = source.id
307
+ WHEN MATCHED THEN UPDATE SET
308
+ handle = source.handle,
309
+ name = source.name,
310
+ industryId = source.industryId,
311
+ industry = source.industry,
312
+ industry_input = source.industry_input,
313
+ companySizeId = source.companySizeId,
314
+ companySize = source.companySize,
315
+ headquarters = source.headquarters,
316
+ countryCode = source.countryCode,
317
+ website_linkedin = source.website_linkedin,
318
+ domain_linkedin = source.domain_linkedin,
319
+ hunter_emailCount = source.hunter_emailCount,
320
+ domainFinder_name = source.domainFinder_name,
321
+ domain = source.domain,
322
+ emailDomainFinder_ts = source.emailDomainFinder_ts,
323
+ company_missingHeadquarters = source.company_missingHeadquarters,
324
+ blacklisted_bySalesPrompter = source.blacklisted_bySalesPrompter,
325
+ company_countryCodeToBeProcessed = source.company_countryCodeToBeProcessed,
326
+ domainBlacklisted = source.domainBlacklisted,
327
+ company_emailDomainFinder_toBeProcessed = source.company_emailDomainFinder_toBeProcessed,
328
+ company_emailDomainNotFound = source.company_emailDomainNotFound,
329
+ employeesOnLinkedIn = source.employeesOnLinkedIn,
330
+ growth6Mth = source.growth6Mth,
331
+ growth1Yr = source.growth1Yr,
332
+ growth2Yr = source.growth2Yr,
333
+ description = source.description,
334
+ tagline = source.tagline,
335
+ specialties = source.specialties,
336
+ founded = source.founded,
337
+ error = source.error,
338
+ linkedin_companies_companyUnavailable = source.linkedin_companies_companyUnavailable,
339
+ timestamp = source.timestamp
340
+ WHEN NOT MATCHED THEN INSERT (
341
+ id,
342
+ handle,
343
+ name,
344
+ industryId,
345
+ industry,
346
+ industry_input,
347
+ companySizeId,
348
+ companySize,
349
+ headquarters,
350
+ countryCode,
351
+ website_linkedin,
352
+ domain_linkedin,
353
+ hunter_emailCount,
354
+ domainFinder_name,
355
+ domain,
356
+ emailDomainFinder_ts,
357
+ company_missingHeadquarters,
358
+ blacklisted_bySalesPrompter,
359
+ company_countryCodeToBeProcessed,
360
+ domainBlacklisted,
361
+ company_emailDomainFinder_toBeProcessed,
362
+ company_emailDomainNotFound,
363
+ employeesOnLinkedIn,
364
+ growth6Mth,
365
+ growth1Yr,
366
+ growth2Yr,
367
+ description,
368
+ tagline,
369
+ specialties,
370
+ founded,
371
+ error,
372
+ linkedin_companies_companyUnavailable,
373
+ timestamp
374
+ ) VALUES (
375
+ source.id,
376
+ source.handle,
377
+ source.name,
378
+ source.industryId,
379
+ source.industry,
380
+ source.industry_input,
381
+ source.companySizeId,
382
+ source.companySize,
383
+ source.headquarters,
384
+ source.countryCode,
385
+ source.website_linkedin,
386
+ source.domain_linkedin,
387
+ source.hunter_emailCount,
388
+ source.domainFinder_name,
389
+ source.domain,
390
+ source.emailDomainFinder_ts,
391
+ source.company_missingHeadquarters,
392
+ source.blacklisted_bySalesPrompter,
393
+ source.company_countryCodeToBeProcessed,
394
+ source.domainBlacklisted,
395
+ source.company_emailDomainFinder_toBeProcessed,
396
+ source.company_emailDomainNotFound,
397
+ source.employeesOnLinkedIn,
398
+ source.growth6Mth,
399
+ source.growth1Yr,
400
+ source.growth2Yr,
401
+ source.description,
402
+ source.tagline,
403
+ source.specialties,
404
+ source.founded,
405
+ source.error,
406
+ source.linkedin_companies_companyUnavailable,
407
+ source.timestamp
408
+ )`;
409
+ }
410
+ export function createLinkedInCompaniesBigQueryClient(env = process.env) {
411
+ const credentialsJson = env.GOOGLE_SERVICE_ACCOUNT_KEY?.trim();
412
+ if (credentialsJson) {
413
+ const normalizedCredentialsJson = credentialsJson.replace(/"private_key":"([\s\S]*?)"/, (_, privateKey) => `"private_key":"${privateKey.replace(/\n/g, "\\n")}"`);
414
+ return new BigQuery({
415
+ projectId: DEFAULT_BIGQUERY_PROJECT_ID,
416
+ credentials: JSON.parse(normalizedCredentialsJson)
417
+ });
418
+ }
419
+ return new BigQuery({
420
+ projectId: DEFAULT_BIGQUERY_PROJECT_ID
421
+ });
422
+ }
423
+ export async function fetchLinkedInCompanyBackfillCandidates(bigQuery, clientId, limit) {
424
+ const [rows] = await bigQuery.query({
425
+ query: buildLinkedInCompanyBackfillSql(clientId, limit),
426
+ useLegacySql: false
427
+ });
428
+ return rows.map((row) => {
429
+ const companyId = Number(row.companyId);
430
+ if (!Number.isFinite(companyId)) {
431
+ throw new Error(`BigQuery backlog row is missing a numeric companyId: ${JSON.stringify(row)}`);
432
+ }
433
+ return {
434
+ companyId,
435
+ companyName: normalizeWhitespace(String(row.companyName ?? "")) || undefined,
436
+ companyFilter: normalizeWhitespace(String(row.companyFilter ?? "")) || undefined,
437
+ companyUrl: buildLinkedInCompanyUrl(companyId)
438
+ };
439
+ });
440
+ }
441
+ async function fetchHtml(url) {
442
+ const response = await fetch(url, {
443
+ headers: {
444
+ "User-Agent": DEFAULT_LINKEDIN_SCRAPER_USER_AGENT
445
+ }
446
+ });
447
+ return {
448
+ status: response.status,
449
+ html: await response.text()
450
+ };
451
+ }
452
+ export async function scrapeLinkedInCompany(candidate) {
453
+ const rapidApiConfig = getRapidApiLinkedInCompanyConfig();
454
+ if (rapidApiConfig) {
455
+ const url = new URL(rapidApiConfig.endpoint);
456
+ url.searchParams.set("linkedin_url", candidate.companyUrl);
457
+ const response = await fetch(url, {
458
+ headers: {
459
+ "x-rapidapi-key": rapidApiConfig.apiKey,
460
+ "x-rapidapi-host": rapidApiConfig.host,
461
+ "User-Agent": DEFAULT_LINKEDIN_SCRAPER_USER_AGENT
462
+ }
463
+ });
464
+ const text = await response.text();
465
+ const parsed = (text ? JSON.parse(text) : {});
466
+ if (response.status === 404) {
467
+ return {
468
+ id: candidate.companyId,
469
+ name: candidate.companyName,
470
+ unavailable: true,
471
+ error: `RapidAPI company endpoint returned 404 for ${candidate.companyUrl}`,
472
+ timestamp: new Date().toISOString()
473
+ };
474
+ }
475
+ if (!response.ok) {
476
+ throw new Error(`RapidAPI company endpoint returned ${response.status} for ${candidate.companyUrl}`);
477
+ }
478
+ return parseRapidApiCompanyProfile(parsed, candidate);
479
+ }
480
+ const response = await fetchHtml(candidate.companyUrl);
481
+ if (response.status === 404 || response.status === 410) {
482
+ return {
483
+ id: candidate.companyId,
484
+ name: candidate.companyName,
485
+ unavailable: true,
486
+ error: `LinkedIn company page returned ${response.status}`,
487
+ timestamp: new Date().toISOString()
488
+ };
489
+ }
490
+ if (response.status >= 400) {
491
+ throw new Error(`LinkedIn company page returned ${response.status} for ${candidate.companyUrl}`);
492
+ }
493
+ const parsed = parseLinkedInCompanyPage(response.html, candidate.companyUrl);
494
+ return {
495
+ ...parsed,
496
+ id: candidate.companyId,
497
+ name: parsed.name ?? candidate.companyName
498
+ };
499
+ }
500
+ async function mapWithConcurrency(values, concurrency, mapper) {
501
+ const results = new Array(values.length);
502
+ let currentIndex = 0;
503
+ async function worker() {
504
+ while (currentIndex < values.length) {
505
+ const index = currentIndex;
506
+ currentIndex += 1;
507
+ results[index] = await mapper(values[index]);
508
+ }
509
+ }
510
+ const workers = Array.from({ length: Math.max(1, Math.min(concurrency, values.length || 1)) }, () => worker());
511
+ await Promise.all(workers);
512
+ return results;
513
+ }
514
+ export async function backfillLinkedInCompanies(options) {
515
+ const limit = options.limit ?? 25;
516
+ const concurrency = options.concurrency ?? 4;
517
+ const bigQuery = createLinkedInCompaniesBigQueryClient();
518
+ const candidates = await fetchLinkedInCompanyBackfillCandidates(bigQuery, options.clientId, limit);
519
+ const attempts = await mapWithConcurrency(candidates, concurrency, async (candidate) => {
520
+ try {
521
+ return {
522
+ candidate,
523
+ ok: true,
524
+ profile: await scrapeLinkedInCompany(candidate)
525
+ };
526
+ }
527
+ catch (error) {
528
+ return {
529
+ candidate,
530
+ ok: false,
531
+ error: error instanceof Error ? error.message : "LinkedIn company scrape failed"
532
+ };
533
+ }
534
+ });
535
+ const profiles = attempts.filter((attempt) => attempt.ok)
536
+ .map((attempt) => attempt.profile);
537
+ const results = attempts.map((attempt) => ({
538
+ companyId: attempt.candidate.companyId,
539
+ companyUrl: attempt.candidate.companyUrl,
540
+ companyName: attempt.ok ? attempt.profile.name : attempt.candidate.companyName,
541
+ unavailable: attempt.ok ? attempt.profile.unavailable : false,
542
+ error: attempt.ok ? attempt.profile.error : attempt.error
543
+ }));
544
+ if (options.dryRun || profiles.length === 0) {
545
+ return {
546
+ clientId: options.clientId,
547
+ candidates,
548
+ results,
549
+ mergeSql: profiles.length > 0 ? buildLinkedInCompaniesMergeSql(profiles) : undefined
550
+ };
551
+ }
552
+ const mergeSql = buildLinkedInCompaniesMergeSql(profiles);
553
+ await bigQuery.query({
554
+ query: mergeSql,
555
+ useLegacySql: false
556
+ });
557
+ return {
558
+ clientId: options.clientId,
559
+ candidates,
560
+ results
561
+ };
562
+ }