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,786 @@
1
+ import { load } from "cheerio";
2
+ import { DEFAULT_LINKEDIN_SCRAPER_USER_AGENT } from "./linkedin-session-contracts.js";
3
+ const DEFAULT_LINKEDIN_BASE_URL = "https://www.linkedin.com";
4
+ function normalizeWhitespace(value) {
5
+ return (value ?? "").replace(/\s+/g, " ").trim();
6
+ }
7
+ function normalizeDomainInput(value) {
8
+ return value
9
+ .trim()
10
+ .toLowerCase()
11
+ .replace(/^https?:\/\//, "")
12
+ .replace(/^www\./, "")
13
+ .split("/")[0] ?? "";
14
+ }
15
+ function getLinkedInBaseUrl(env = process.env) {
16
+ const override = env.SALESPROMPTER_LINKEDIN_BASE_URL?.trim();
17
+ if (!override) {
18
+ return DEFAULT_LINKEDIN_BASE_URL;
19
+ }
20
+ return override.replace(/\/+$/, "");
21
+ }
22
+ function isAllowedLinkedInHostname(hostname, env = process.env) {
23
+ if (/(^|\.)linkedin\.com$/i.test(hostname)) {
24
+ return true;
25
+ }
26
+ try {
27
+ return hostname.toLowerCase() === new URL(getLinkedInBaseUrl(env)).hostname.toLowerCase();
28
+ }
29
+ catch {
30
+ return false;
31
+ }
32
+ }
33
+ function buildLinkedInUrl(pathname, env = process.env) {
34
+ return new URL(pathname, `${getLinkedInBaseUrl(env)}/`).toString();
35
+ }
36
+ function toAbsoluteLinkedInUrl(value) {
37
+ const trimmed = normalizeWhitespace(value);
38
+ if (trimmed.length === 0) {
39
+ return undefined;
40
+ }
41
+ const url = trimmed.startsWith("http://") || trimmed.startsWith("https://")
42
+ ? new URL(trimmed)
43
+ : new URL(trimmed, `${getLinkedInBaseUrl()}/`);
44
+ if (!isAllowedLinkedInHostname(url.hostname)) {
45
+ return undefined;
46
+ }
47
+ url.search = "";
48
+ url.hash = "";
49
+ return url.toString().replace(/\/+$/, "");
50
+ }
51
+ function toAbsoluteUrl(value) {
52
+ const trimmed = normalizeWhitespace(value);
53
+ if (trimmed.length === 0) {
54
+ return undefined;
55
+ }
56
+ try {
57
+ return new URL(trimmed, `${getLinkedInBaseUrl()}/`).toString();
58
+ }
59
+ catch {
60
+ return undefined;
61
+ }
62
+ }
63
+ function getLinkedInPathSegments(url) {
64
+ const parsed = new URL(url);
65
+ return parsed.pathname.split("/").filter((segment) => segment.length > 0);
66
+ }
67
+ function getCompanyHandle(url) {
68
+ if (!url) {
69
+ return undefined;
70
+ }
71
+ const segments = getLinkedInPathSegments(url);
72
+ const companyIndex = segments.findIndex((segment) => segment.toLowerCase() === "company");
73
+ if (companyIndex === -1) {
74
+ return undefined;
75
+ }
76
+ const handle = normalizeWhitespace(segments[companyIndex + 1]);
77
+ return handle.length > 0 ? handle.toLowerCase() : undefined;
78
+ }
79
+ function getProductSlug(url) {
80
+ const segments = getLinkedInPathSegments(url);
81
+ const productsIndex = segments.findIndex((segment) => segment.toLowerCase() === "products");
82
+ const slug = normalizeWhitespace(segments[productsIndex + 1]);
83
+ if (productsIndex === -1 || slug.length === 0) {
84
+ throw new Error(`LinkedIn product URL is invalid: ${url}`);
85
+ }
86
+ return decodeURIComponent(slug).toLowerCase();
87
+ }
88
+ function getCategorySlug(url) {
89
+ const segments = getLinkedInPathSegments(url);
90
+ const categoriesIndex = segments.findIndex((segment) => segment.toLowerCase() === "categories");
91
+ const slug = normalizeWhitespace(segments[categoriesIndex + 1]);
92
+ if (categoriesIndex === -1 || slug.length === 0) {
93
+ throw new Error(`LinkedIn category URL is invalid: ${url}`);
94
+ }
95
+ return decodeURIComponent(slug).toLowerCase();
96
+ }
97
+ function parseResultCount(value) {
98
+ const match = normalizeWhitespace(value).match(/([\d,.]+)\s+results/i);
99
+ if (!match) {
100
+ return undefined;
101
+ }
102
+ const digits = match[1]?.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 parseCategoryCodeFromHtml($) {
110
+ const rawValue = $("#filterValues").html();
111
+ if (!rawValue) {
112
+ return undefined;
113
+ }
114
+ const normalized = rawValue.replace(/^<!--/, "").replace(/-->$/, "").trim();
115
+ if (normalized.length === 0) {
116
+ return undefined;
117
+ }
118
+ try {
119
+ const parsed = JSON.parse(normalized);
120
+ const currentUrn = parsed.currentCategoryUrn ?? parsed.productCategoryUrns?.[0];
121
+ const code = currentUrn?.split(":").at(-1)?.trim();
122
+ return code && code.length > 0 ? code : undefined;
123
+ }
124
+ catch {
125
+ return undefined;
126
+ }
127
+ }
128
+ function getImageUrl(element) {
129
+ const delayed = normalizeWhitespace(element.attr("data-delayed-url"));
130
+ if (delayed.length > 0) {
131
+ return toAbsoluteUrl(delayed);
132
+ }
133
+ const src = normalizeWhitespace(element.attr("src"));
134
+ return src.length > 0 ? toAbsoluteUrl(src) : undefined;
135
+ }
136
+ function buildCategoryFromUrl(url, name, options) {
137
+ return {
138
+ name,
139
+ slug: getCategorySlug(url),
140
+ url,
141
+ code: options?.code,
142
+ description: options?.description,
143
+ totalResults: options?.totalResults
144
+ };
145
+ }
146
+ function parseSubtitleForCategory(subtitle) {
147
+ const normalized = normalizeWhitespace(subtitle);
148
+ const match = normalized.match(/^(.*?)\s+by\s+(.*)$/i);
149
+ if (!match) {
150
+ return {};
151
+ }
152
+ return {
153
+ categoryName: normalizeWhitespace(match[1]),
154
+ vendorName: normalizeWhitespace(match[2])
155
+ };
156
+ }
157
+ function uniqueNonEmptyText(values) {
158
+ const seen = new Set();
159
+ const result = [];
160
+ for (const value of values) {
161
+ const normalized = normalizeWhitespace(value);
162
+ if (normalized.length === 0) {
163
+ continue;
164
+ }
165
+ if (seen.has(normalized)) {
166
+ continue;
167
+ }
168
+ seen.add(normalized);
169
+ result.push(normalized);
170
+ }
171
+ return result;
172
+ }
173
+ function pickLongerText(...values) {
174
+ const normalized = values
175
+ .map((value) => normalizeWhitespace(value))
176
+ .filter((value) => value.length > 0);
177
+ normalized.sort((left, right) => right.length - left.length);
178
+ return normalized[0];
179
+ }
180
+ function toRecordFromProductPage(detail, options) {
181
+ return {
182
+ productName: detail.productName,
183
+ productSlug: detail.productSlug,
184
+ productUrl: detail.productUrl,
185
+ imageUrl: detail.imageUrl,
186
+ description: detail.description,
187
+ vendor: detail.vendor,
188
+ category: detail.category,
189
+ pageNumber: options?.pageNumber ?? 1,
190
+ positionOnPage: options?.positionOnPage ?? 1,
191
+ learnMoreUrl: detail.learnMoreUrl,
192
+ intendedRoles: detail.intendedRoles,
193
+ usedBy: detail.usedBy,
194
+ rawPayload: options?.rawPayload ?? {
195
+ source: "product-page"
196
+ }
197
+ };
198
+ }
199
+ function parseProductCard($, card, pageNumber, positionOnPage, fallbackCategory) {
200
+ const productAnchor = card.find("h3 a, .product-serp-card__image-container").first();
201
+ const productUrl = toAbsoluteLinkedInUrl(productAnchor.attr("href"));
202
+ const productName = normalizeWhitespace(card.find("h3").text());
203
+ if (!productUrl || productName.length === 0) {
204
+ return null;
205
+ }
206
+ const subtitle = normalizeWhitespace(card.find("h4").text());
207
+ const subtitleParts = parseSubtitleForCategory(subtitle);
208
+ const vendorAnchor = card.find("h4 a").first();
209
+ const vendorName = normalizeWhitespace(vendorAnchor.text()) || subtitleParts.vendorName;
210
+ const vendorCompanyUrl = toAbsoluteLinkedInUrl(vendorAnchor.attr("href"));
211
+ const vendorHandle = getCompanyHandle(vendorCompanyUrl);
212
+ const categoryName = subtitleParts.categoryName ?? fallbackCategory?.name;
213
+ if (!categoryName || categoryName.length === 0) {
214
+ return null;
215
+ }
216
+ const category = fallbackCategory ?? {
217
+ name: categoryName,
218
+ slug: categoryName.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, ""),
219
+ url: ""
220
+ };
221
+ return {
222
+ productName,
223
+ productSlug: getProductSlug(productUrl),
224
+ productUrl,
225
+ imageUrl: getImageUrl(card.find("img").first()),
226
+ description: normalizeWhitespace(card.find(".product-serp-card__description").text()) || undefined,
227
+ vendor: vendorName
228
+ ? {
229
+ name: vendorName,
230
+ companyUrl: vendorCompanyUrl,
231
+ handle: vendorHandle
232
+ }
233
+ : undefined,
234
+ category,
235
+ pageNumber,
236
+ positionOnPage,
237
+ intendedRoles: [],
238
+ usedBy: [],
239
+ rawPayload: {
240
+ subtitle,
241
+ source: "category-card"
242
+ }
243
+ };
244
+ }
245
+ export function parseLinkedInCategoryPage(html, requestUrl) {
246
+ const $ = load(html);
247
+ const canonicalUrl = toAbsoluteLinkedInUrl($('link[rel="canonical"]').attr("href") ?? requestUrl);
248
+ if (!canonicalUrl || !canonicalUrl.includes("/products/categories/")) {
249
+ throw new Error("LinkedIn category page did not expose a canonical category URL.");
250
+ }
251
+ const categoryName = normalizeWhitespace($(".serp-hero__title").text())
252
+ .replace(/^Find top products in\s+/i, "")
253
+ .replace(/\s+category$/i, "");
254
+ const categoryDescription = normalizeWhitespace($(".serp-hero__subtitle").text()) || undefined;
255
+ const totalResults = parseResultCount($(".serp-hero__results-title").text());
256
+ const categoryCode = parseCategoryCodeFromHtml($);
257
+ const category = buildCategoryFromUrl(canonicalUrl, categoryName, {
258
+ code: categoryCode,
259
+ description: categoryDescription,
260
+ totalResults
261
+ });
262
+ const items = [];
263
+ $('[data-product-cards-list] > li').each((index, element) => {
264
+ const parsed = parseProductCard($, $(element), getRequestedPageNumber(requestUrl), index + 1, category);
265
+ if (parsed) {
266
+ items.push(parsed);
267
+ }
268
+ });
269
+ return {
270
+ category,
271
+ items
272
+ };
273
+ }
274
+ export function parseLinkedInProductPage(html, requestUrl) {
275
+ const $ = load(html);
276
+ const canonicalUrl = toAbsoluteLinkedInUrl($('link[rel="canonical"]').attr("href") ?? requestUrl);
277
+ if (!canonicalUrl || !canonicalUrl.includes("/products/")) {
278
+ throw new Error("LinkedIn product page did not expose a canonical product URL.");
279
+ }
280
+ const productName = normalizeWhitespace($("h1.top-card-layout__title").first().text());
281
+ const categoryAnchor = $('[data-tracking-control-name="products_details_guest_product_category"]').first();
282
+ const categoryUrl = toAbsoluteLinkedInUrl(categoryAnchor.attr("href"));
283
+ const categoryName = normalizeWhitespace(categoryAnchor.text());
284
+ if (!categoryUrl || categoryName.length === 0) {
285
+ throw new Error("LinkedIn product page did not expose a product category.");
286
+ }
287
+ const category = buildCategoryFromUrl(categoryUrl, categoryName);
288
+ const vendorAnchor = $('[data-tracking-control-name="products_details_guest_organization_page"]').first();
289
+ const vendorCompanyUrl = toAbsoluteLinkedInUrl(vendorAnchor.attr("href"));
290
+ const vendorName = normalizeWhitespace(vendorAnchor.text());
291
+ const usedBy = [];
292
+ $(".customer__organization-name").each((_, element) => {
293
+ const anchor = $(element);
294
+ const name = normalizeWhitespace(anchor.text());
295
+ if (!name) {
296
+ return;
297
+ }
298
+ const container = anchor.closest("li, .customer");
299
+ usedBy.push({
300
+ name,
301
+ companyUrl: toAbsoluteLinkedInUrl(anchor.attr("href")),
302
+ logoUrl: getImageUrl(container.find("img").first())
303
+ });
304
+ });
305
+ const metaDescription = normalizeWhitespace($('meta[name="description"]').attr("content"));
306
+ const cleanedMetaDescription = metaDescription.startsWith(`${productName} |`)
307
+ ? normalizeWhitespace(metaDescription.slice(productName.length + 2))
308
+ : metaDescription;
309
+ const aboutDescription = normalizeWhitespace($(".about > p")
310
+ .not(".about__roles-title")
311
+ .first()
312
+ .text());
313
+ const intendedRoles = uniqueNonEmptyText($(".about__roles-item")
314
+ .map((_, element) => $(element).text())
315
+ .get());
316
+ return {
317
+ productName,
318
+ productSlug: getProductSlug(canonicalUrl),
319
+ productUrl: canonicalUrl,
320
+ imageUrl: getImageUrl($("img.top-card-layout__entity-image").first()),
321
+ description: pickLongerText(aboutDescription, cleanedMetaDescription),
322
+ category,
323
+ vendor: vendorName
324
+ ? {
325
+ name: vendorName,
326
+ companyUrl: vendorCompanyUrl,
327
+ handle: getCompanyHandle(vendorCompanyUrl)
328
+ }
329
+ : undefined,
330
+ learnMoreUrl: toAbsoluteUrl($(".top-card-layout__cta--secondary").attr("href")),
331
+ intendedRoles,
332
+ usedBy,
333
+ };
334
+ }
335
+ export function parseLinkedInCompanyPageForMainProduct(html, requestUrl) {
336
+ const $ = load(html);
337
+ const categoryAnchor = $('[data-tracking-control-name="organization_guest_main_product_card_category_link"]').first();
338
+ const productAnchor = $('[data-tracking-control-name="organization_guest_main_product_card"]').first();
339
+ const productUrl = toAbsoluteLinkedInUrl(productAnchor.attr("href"));
340
+ const categoryUrl = toAbsoluteLinkedInUrl(categoryAnchor.attr("href"));
341
+ const categoryName = normalizeWhitespace(categoryAnchor.text());
342
+ const productName = normalizeWhitespace(categoryAnchor.closest(".base-main-card").find(".base-main-card__title").first().text());
343
+ const companyUrl = toAbsoluteLinkedInUrl(requestUrl) ?? requestUrl;
344
+ if (!productUrl || !categoryUrl || categoryName.length === 0 || productName.length === 0) {
345
+ throw new Error("LinkedIn company page did not expose a main product card.");
346
+ }
347
+ return {
348
+ productName,
349
+ productUrl,
350
+ companyUrl,
351
+ category: buildCategoryFromUrl(categoryUrl, categoryName),
352
+ description: normalizeWhitespace(categoryAnchor.closest(".base-main-card").find(".base-main-card__description").text()) || undefined
353
+ };
354
+ }
355
+ export function parseLinkedInProductSearchPage(html, requestUrl) {
356
+ const $ = load(html);
357
+ const query = normalizeWhitespace($(".serp-hero__title").text()).replace(/^Find top products in\s+/i, "").replace(/^"|"$/g, "");
358
+ const items = [];
359
+ $('[data-product-cards-list] > li').each((index, element) => {
360
+ const parsed = parseProductCard($, $(element), getRequestedPageNumber(requestUrl), index + 1);
361
+ if (parsed) {
362
+ items.push(parsed);
363
+ }
364
+ });
365
+ return { query: query || undefined, items };
366
+ }
367
+ function normalizeQueryKey(value) {
368
+ return value.toLowerCase().replace(/[^a-z0-9]+/g, "");
369
+ }
370
+ function pickBestSearchMatch(items, query) {
371
+ const queryKey = normalizeQueryKey(query);
372
+ if (queryKey.length === 0) {
373
+ const first = items[0];
374
+ if (!first) {
375
+ throw new Error("LinkedIn product search did not return any products.");
376
+ }
377
+ return first;
378
+ }
379
+ const scored = items.map((item) => {
380
+ const productKey = normalizeQueryKey(item.productName);
381
+ const productSlugKey = normalizeQueryKey(item.productSlug);
382
+ const vendorKey = normalizeQueryKey(item.vendor?.name ?? "");
383
+ const vendorHandleKey = normalizeQueryKey(item.vendor?.handle ?? "");
384
+ let score = 0;
385
+ for (const candidate of [productKey, productSlugKey, vendorKey, vendorHandleKey]) {
386
+ if (candidate.length === 0) {
387
+ continue;
388
+ }
389
+ if (candidate === queryKey) {
390
+ score += 100;
391
+ }
392
+ else if (candidate.startsWith(queryKey)) {
393
+ score += 40;
394
+ }
395
+ else if (candidate.includes(queryKey)) {
396
+ score += 20;
397
+ }
398
+ if (queryKey.startsWith(candidate) && candidate.length > 1) {
399
+ score += 15;
400
+ }
401
+ }
402
+ return { item, score };
403
+ });
404
+ scored.sort((left, right) => right.score - left.score);
405
+ return scored[0]?.item ?? items[0];
406
+ }
407
+ function classifyLinkedInInput(input) {
408
+ const trimmed = normalizeWhitespace(input);
409
+ if (trimmed.length === 0) {
410
+ throw new Error("A company domain or LinkedIn URL is required.");
411
+ }
412
+ const isUrl = /^https?:\/\//i.test(trimmed);
413
+ if (!isUrl) {
414
+ const domain = normalizeDomainInput(trimmed);
415
+ if (domain.length === 0) {
416
+ throw new Error("Could not understand the input. Pass a domain or LinkedIn URL.");
417
+ }
418
+ return {
419
+ kind: "domain",
420
+ input: trimmed,
421
+ query: domain.split(".")[0] ?? domain
422
+ };
423
+ }
424
+ const parsed = new URL(trimmed);
425
+ const hostname = parsed.hostname.toLowerCase();
426
+ if (!/(^|\.)linkedin\.com$/.test(hostname)) {
427
+ const domain = normalizeDomainInput(trimmed);
428
+ return {
429
+ kind: "domain",
430
+ input: trimmed,
431
+ query: domain.split(".")[0] ?? domain
432
+ };
433
+ }
434
+ if (parsed.pathname.includes("/search/results/products")) {
435
+ const privateCategory = parsePrivateCategoryCode(parsed.searchParams.get("productCategory"));
436
+ if (privateCategory) {
437
+ return {
438
+ kind: "private-category-code",
439
+ input: trimmed,
440
+ categoryCode: privateCategory
441
+ };
442
+ }
443
+ const query = normalizeWhitespace(parsed.searchParams.get("q"));
444
+ if (query) {
445
+ return {
446
+ kind: "search-query",
447
+ input: trimmed,
448
+ url: buildLinkedInUrl(`/products/search/?q=${encodeURIComponent(query)}`),
449
+ query
450
+ };
451
+ }
452
+ throw new Error("LinkedIn private product search URLs need a category code or query.");
453
+ }
454
+ if (parsed.pathname.includes("/products/search")) {
455
+ const query = normalizeWhitespace(parsed.searchParams.get("q") ?? parsed.searchParams.get("keywords"));
456
+ return {
457
+ kind: "search-query",
458
+ input: trimmed,
459
+ url: buildLinkedInUrl(`/products/search/?q=${encodeURIComponent(query)}`),
460
+ query
461
+ };
462
+ }
463
+ if (parsed.pathname.includes("/products/categories/")) {
464
+ return {
465
+ kind: "category-url",
466
+ input: trimmed,
467
+ url: toAbsoluteLinkedInUrl(trimmed) ?? trimmed
468
+ };
469
+ }
470
+ if (parsed.pathname.includes("/products/")) {
471
+ return {
472
+ kind: "product-url",
473
+ input: trimmed,
474
+ url: toAbsoluteLinkedInUrl(trimmed) ?? trimmed
475
+ };
476
+ }
477
+ if (parsed.pathname.includes("/company/")) {
478
+ return {
479
+ kind: "company-url",
480
+ input: trimmed,
481
+ url: toAbsoluteLinkedInUrl(trimmed) ?? trimmed
482
+ };
483
+ }
484
+ throw new Error("Pass a company domain, LinkedIn company page, LinkedIn product page, or LinkedIn category URL.");
485
+ }
486
+ function parsePrivateCategoryCode(value) {
487
+ const normalized = normalizeWhitespace(value);
488
+ if (normalized.length === 0) {
489
+ return undefined;
490
+ }
491
+ try {
492
+ const parsed = JSON.parse(normalized);
493
+ if (Array.isArray(parsed)) {
494
+ const first = normalizeWhitespace(String(parsed[0] ?? ""));
495
+ return first || undefined;
496
+ }
497
+ }
498
+ catch {
499
+ // Fall through to plain value parsing.
500
+ }
501
+ const match = normalized.match(/(\d{3,})/);
502
+ return match?.[1];
503
+ }
504
+ function getRequestedPageNumber(url) {
505
+ try {
506
+ const parsed = new URL(url);
507
+ const page = Number(parsed.searchParams.get("page") ?? "1");
508
+ return Number.isInteger(page) && page > 0 ? page : 1;
509
+ }
510
+ catch {
511
+ return 1;
512
+ }
513
+ }
514
+ function buildCategoryPageUrl(categoryUrl, pageNumber) {
515
+ const url = new URL(categoryUrl);
516
+ if (pageNumber <= 1) {
517
+ url.searchParams.delete("page");
518
+ }
519
+ else {
520
+ url.searchParams.set("page", String(pageNumber));
521
+ }
522
+ return url.toString();
523
+ }
524
+ export function createLinkedInHtmlFetcher(fetchImpl = fetch) {
525
+ return async (url) => {
526
+ const response = await fetchImpl(url, {
527
+ headers: {
528
+ "User-Agent": DEFAULT_LINKEDIN_SCRAPER_USER_AGENT,
529
+ Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
530
+ },
531
+ redirect: "follow"
532
+ });
533
+ if (!response.ok) {
534
+ throw new Error(`LinkedIn request failed (${response.status}) for ${url}`);
535
+ }
536
+ return await response.text();
537
+ };
538
+ }
539
+ async function findCategoryByCode(categoryCode, fetchHtml) {
540
+ const browseHtml = await fetchHtml(buildLinkedInUrl("/products/categories/browse"));
541
+ const $ = load(browseHtml);
542
+ const urls = new Set();
543
+ $('a[href*="/products/categories/"]').each((_, element) => {
544
+ const absolute = toAbsoluteLinkedInUrl($(element).attr("href"));
545
+ if (absolute?.includes("/products/categories/")) {
546
+ urls.add(absolute);
547
+ }
548
+ });
549
+ for (const url of urls) {
550
+ try {
551
+ const html = await fetchHtml(url);
552
+ const parsed = parseLinkedInCategoryPage(html, url);
553
+ if (parsed.category.code === categoryCode) {
554
+ return parsed.category;
555
+ }
556
+ }
557
+ catch {
558
+ continue;
559
+ }
560
+ }
561
+ throw new Error(`Could not resolve LinkedIn product category code ${categoryCode}.`);
562
+ }
563
+ export async function resolveLinkedInProductSource(input, fetchHtml) {
564
+ const resolvedInput = classifyLinkedInInput(input);
565
+ if (resolvedInput.kind === "category-url") {
566
+ const html = await fetchHtml(resolvedInput.url);
567
+ const page = parseLinkedInCategoryPage(html, resolvedInput.url);
568
+ return {
569
+ input,
570
+ kind: resolvedInput.kind,
571
+ category: page.category
572
+ };
573
+ }
574
+ if (resolvedInput.kind === "product-url") {
575
+ const html = await fetchHtml(resolvedInput.url);
576
+ const product = parseLinkedInProductPage(html, resolvedInput.url);
577
+ return {
578
+ input,
579
+ kind: resolvedInput.kind,
580
+ productUrl: product.productUrl,
581
+ matchedProductName: product.productName,
582
+ category: product.category
583
+ };
584
+ }
585
+ if (resolvedInput.kind === "company-url") {
586
+ const html = await fetchHtml(resolvedInput.url);
587
+ const reference = parseLinkedInCompanyPageForMainProduct(html, resolvedInput.url);
588
+ return {
589
+ input,
590
+ kind: resolvedInput.kind,
591
+ companyUrl: reference.companyUrl,
592
+ productUrl: reference.productUrl,
593
+ matchedProductName: reference.productName,
594
+ category: reference.category
595
+ };
596
+ }
597
+ if (resolvedInput.kind === "private-category-code") {
598
+ const category = await findCategoryByCode(resolvedInput.categoryCode, fetchHtml);
599
+ return {
600
+ input,
601
+ kind: resolvedInput.kind,
602
+ category: {
603
+ ...category,
604
+ code: resolvedInput.categoryCode
605
+ }
606
+ };
607
+ }
608
+ const searchQuery = resolvedInput.query ?? "";
609
+ const searchUrl = resolvedInput.kind === "domain"
610
+ ? buildLinkedInUrl(`/products/search/?q=${encodeURIComponent(searchQuery)}`)
611
+ : resolvedInput.url;
612
+ const html = await fetchHtml(searchUrl);
613
+ const search = parseLinkedInProductSearchPage(html, searchUrl);
614
+ const rankedCandidates = (() => {
615
+ const best = pickBestSearchMatch(search.items, searchQuery);
616
+ const seen = new Set();
617
+ const ordered = [];
618
+ for (const candidate of [best, ...search.items]) {
619
+ if (!candidate || seen.has(candidate.productUrl)) {
620
+ continue;
621
+ }
622
+ seen.add(candidate.productUrl);
623
+ ordered.push(candidate);
624
+ }
625
+ return ordered;
626
+ })();
627
+ let product = null;
628
+ let matched = null;
629
+ let lastError = null;
630
+ for (const candidate of rankedCandidates) {
631
+ try {
632
+ const productHtml = await fetchHtml(candidate.productUrl);
633
+ product = parseLinkedInProductPage(productHtml, candidate.productUrl);
634
+ matched = candidate;
635
+ break;
636
+ }
637
+ catch (error) {
638
+ lastError = error instanceof Error ? error.message : String(error);
639
+ }
640
+ }
641
+ if (!product || !matched) {
642
+ throw new Error(lastError
643
+ ? `Could not resolve a LinkedIn product page from search results. Last error: ${lastError}`
644
+ : "Could not resolve a LinkedIn product page from search results.");
645
+ }
646
+ return {
647
+ input,
648
+ kind: resolvedInput.kind,
649
+ query: searchQuery,
650
+ productUrl: product.productUrl,
651
+ matchedProductName: product.productName,
652
+ category: product.category
653
+ };
654
+ }
655
+ function mergeDetailIntoRecord(record, detail) {
656
+ const description = record.description && record.description.length >= (detail.description?.length ?? 0)
657
+ ? record.description
658
+ : detail.description;
659
+ return {
660
+ ...record,
661
+ imageUrl: record.imageUrl ?? detail.imageUrl,
662
+ description,
663
+ vendor: record.vendor ?? detail.vendor,
664
+ category: {
665
+ ...record.category,
666
+ code: record.category.code ?? detail.category.code,
667
+ description: record.category.description ?? detail.category.description,
668
+ totalResults: record.category.totalResults ?? detail.category.totalResults
669
+ },
670
+ learnMoreUrl: detail.learnMoreUrl ?? record.learnMoreUrl,
671
+ intendedRoles: detail.intendedRoles.length > 0 ? detail.intendedRoles : record.intendedRoles,
672
+ usedBy: detail.usedBy.length > 0 ? detail.usedBy : record.usedBy,
673
+ rawPayload: {
674
+ ...record.rawPayload,
675
+ detailSource: "product-page"
676
+ }
677
+ };
678
+ }
679
+ async function mapWithConcurrency(input, concurrency, mapper) {
680
+ if (input.length === 0) {
681
+ return [];
682
+ }
683
+ const safeConcurrency = Math.max(1, Math.min(concurrency, input.length));
684
+ const results = new Array(input.length);
685
+ let nextIndex = 0;
686
+ const worker = async () => {
687
+ while (true) {
688
+ const current = nextIndex;
689
+ nextIndex += 1;
690
+ if (current >= input.length) {
691
+ return;
692
+ }
693
+ results[current] = await mapper(input[current], current);
694
+ }
695
+ };
696
+ await Promise.all(Array.from({ length: safeConcurrency }, () => worker()));
697
+ return results;
698
+ }
699
+ export async function crawlLinkedInProductCategory(options) {
700
+ const fetchHtml = options.fetchHtml ?? createLinkedInHtmlFetcher();
701
+ const source = await resolveLinkedInProductSource(options.input, fetchHtml);
702
+ const maxPages = Math.max(1, options.maxPages ?? 25);
703
+ const limit = options.limit !== undefined ? Math.max(1, options.limit) : undefined;
704
+ const itemsByUrl = new Map();
705
+ let totalPagesFetched = 0;
706
+ if (options.enrichDetails !== false && source.productUrl) {
707
+ try {
708
+ const sourceDetailHtml = await fetchHtml(source.productUrl);
709
+ const sourceDetail = parseLinkedInProductPage(sourceDetailHtml, source.productUrl);
710
+ itemsByUrl.set(sourceDetail.productUrl, toRecordFromProductPage(sourceDetail, {
711
+ rawPayload: {
712
+ source: "resolved-product-page",
713
+ resolvedFromInput: true
714
+ }
715
+ }));
716
+ }
717
+ catch {
718
+ // Keep crawling the category even if the resolved source product detail page is temporarily unavailable.
719
+ }
720
+ }
721
+ for (let pageNumber = 1; pageNumber <= maxPages; pageNumber += 1) {
722
+ const pageUrl = buildCategoryPageUrl(source.category.url, pageNumber);
723
+ const html = await fetchHtml(pageUrl);
724
+ const page = parseLinkedInCategoryPage(html, pageUrl);
725
+ totalPagesFetched = pageNumber;
726
+ if (!source.category.code && page.category.code) {
727
+ source.category.code = page.category.code;
728
+ }
729
+ if (!source.category.description && page.category.description) {
730
+ source.category.description = page.category.description;
731
+ }
732
+ if (!source.category.totalResults && page.category.totalResults) {
733
+ source.category.totalResults = page.category.totalResults;
734
+ }
735
+ if (limit !== undefined && itemsByUrl.size >= limit) {
736
+ break;
737
+ }
738
+ if (page.items.length === 0) {
739
+ break;
740
+ }
741
+ let newItemsOnPage = 0;
742
+ for (const item of page.items) {
743
+ if (!itemsByUrl.has(item.productUrl)) {
744
+ itemsByUrl.set(item.productUrl, item);
745
+ newItemsOnPage += 1;
746
+ }
747
+ if (limit !== undefined && itemsByUrl.size >= limit) {
748
+ break;
749
+ }
750
+ }
751
+ if (limit !== undefined && itemsByUrl.size >= limit) {
752
+ break;
753
+ }
754
+ if (newItemsOnPage === 0) {
755
+ break;
756
+ }
757
+ }
758
+ let items = Array.from(itemsByUrl.values());
759
+ if (limit !== undefined) {
760
+ items = items.slice(0, limit);
761
+ }
762
+ if (options.enrichDetails !== false && items.length > 0) {
763
+ const detailed = await mapWithConcurrency(items, options.detailConcurrency ?? 4, async (item) => {
764
+ try {
765
+ const html = await fetchHtml(item.productUrl);
766
+ const detail = parseLinkedInProductPage(html, item.productUrl);
767
+ return mergeDetailIntoRecord(item, detail);
768
+ }
769
+ catch (error) {
770
+ return {
771
+ ...item,
772
+ rawPayload: {
773
+ ...(item.rawPayload && typeof item.rawPayload === "object" ? item.rawPayload : {}),
774
+ detailFetchError: error instanceof Error ? error.message : String(error)
775
+ }
776
+ };
777
+ }
778
+ });
779
+ items = detailed;
780
+ }
781
+ return {
782
+ source,
783
+ items,
784
+ totalPagesFetched
785
+ };
786
+ }