@hraness/ghostget 0.18.0 → 0.18.2

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 (76) hide show
  1. package/CHANGELOG.md +283 -0
  2. package/README.md +7 -7
  3. package/dist/apple-photos-client.js +2 -1
  4. package/dist/beeper-client.js +2 -1
  5. package/dist/client.js +1 -0
  6. package/dist/imessage-direct-install-2gm3kge7.js +324 -0
  7. package/dist/index-01eeae9e.js +5 -0
  8. package/dist/index-2ymnp8xv.js +145 -0
  9. package/dist/index-en5hycxp.js +175 -0
  10. package/dist/{index-9wca02er.js → index-hfbygww8.js} +1 -1
  11. package/dist/index-n4szk3nw.js +50 -0
  12. package/dist/index-yq6maz71.js +1105 -0
  13. package/dist/index-z1w83f81.js +4 -0
  14. package/dist/index.js +4 -47
  15. package/dist/messaging-automation-api.js +21 -0
  16. package/dist/messaging-automation-j4274hvc.js +609 -0
  17. package/dist/messaging-native-install-8w1j36ah.js +16 -0
  18. package/dist/messaging.js +1 -0
  19. package/dist/omni-client.js +1 -0
  20. package/dist/whatsapp-automation-runtime-hgh1e9rm.js +845 -0
  21. package/dist/whatsapp-client.js +1 -0
  22. package/docs/control-panel.md +2 -2
  23. package/docs/imessage-direct-provider.md +31 -7
  24. package/docs/messaging-automation.md +103 -0
  25. package/package.json +22 -2
  26. package/skills/ghostget/references/control-panel.md +1 -1
  27. package/skills/ghostget/references/install.md +5 -5
  28. package/skills/ghostget/references/linkedin-adapter.md +209 -13
  29. package/skills/ghostget/references/platform-patterns.md +1 -1
  30. package/src/args.ts +18 -5
  31. package/src/assets/adapters/imessage/wrench-web-adapter.json +126 -0
  32. package/src/assets/adapters/linkedin/wrench-web-adapter.json +1 -1
  33. package/src/assets/adapters/whatsapp/wrench-web-adapter.json +144 -0
  34. package/src/assets/messaging-runtime/NOTICE.txt +2580 -0
  35. package/src/assets/messaging-runtime/imsg-darwin-arm64.gz +0 -0
  36. package/src/assets/messaging-runtime/phone-number-info.plist.gz +0 -0
  37. package/src/assets/messaging-runtime/phone-number-metadata.json.gz +0 -0
  38. package/src/assets/messaging-runtime/phone-number-privacy.plist.gz +0 -0
  39. package/src/assets/messaging-runtime/wacli-darwin-arm64.gz +0 -0
  40. package/src/beeper-client-types.ts +1 -1
  41. package/src/ghostget.ts +16 -4
  42. package/src/messaging-automation-api.ts +12 -0
  43. package/src/messaging-automation-descriptors.ts +29 -0
  44. package/src/messaging-automation-factory.ts +148 -0
  45. package/src/messaging-automation-server.ts +183 -0
  46. package/src/messaging-automation-types.ts +155 -0
  47. package/src/messaging-automation-validation.ts +110 -0
  48. package/src/messaging-automation.ts +356 -0
  49. package/src/plugins/imessage-direct/plugin.ts +12 -1
  50. package/src/plugins/imessage-direct/vendor/0003-feat-rpc-add-no-fetch-rich-cards.patch +276 -0
  51. package/src/plugins/imessage-direct/vendor/provenance.json +84 -10
  52. package/src/plugins/linkedin-web/plugin.ts +1 -1
  53. package/src/plugins/whatsapp-linked-device/plugin.ts +7 -2
  54. package/src/plugins/whatsapp-linked-device/vendor/0001-ghostget-private-messaging.patch +1093 -0
  55. package/src/plugins/whatsapp-linked-device/vendor/README.md +39 -0
  56. package/src/plugins/whatsapp-linked-device/vendor/provenance.json +45 -0
  57. package/src/provider-plugin.ts +14 -0
  58. package/src/providers/imessage-automation.ts +250 -0
  59. package/src/providers/imessage-direct-install.ts +7 -0
  60. package/src/providers/imessage-direct-runtime.ts +32 -2
  61. package/src/providers/imessage-direct.ts +13 -3
  62. package/src/providers/linkedin-contact-failure.ts +21 -0
  63. package/src/providers/linkedin-contact-platform.ts +8 -1
  64. package/src/providers/linkedin-contact-program.ts +34 -5
  65. package/src/providers/linkedin-web-contact.ts +1023 -51
  66. package/src/providers/linkedin-web-profile-browser.ts +659 -45
  67. package/src/providers/linkedin-web-runtime.ts +1 -0
  68. package/src/providers/linkedin-web.ts +46 -2
  69. package/src/providers/messaging-native-artifacts.ts +38 -0
  70. package/src/providers/messaging-native-install.ts +75 -0
  71. package/src/providers/whatsapp-automation-runtime.ts +240 -0
  72. package/src/providers/whatsapp-automation.ts +153 -0
  73. package/src/usage.ts +3 -1
  74. package/src/version.ts +1 -1
  75. package/src/web-session-contract-definitions.ts +1 -1
  76. package/tsconfig.json +3 -0
@@ -10,8 +10,51 @@ export const LINKEDIN_PROFILE_CONTACT_INFO_QUERY_NAME =
10
10
  "voyagerIdentityDashProfileContactInfo";
11
11
  export const LINKEDIN_CONTACT_DETAILS_OVERLAY_SCREEN_ID =
12
12
  "com.linkedin.sdui.flagshipnav.profile.ProfileContactDetailsOverlay";
13
+ export const LINKEDIN_CONTACT_DETAILS_OVERLAY_PATH =
14
+ "/flagship-web/rsc-action/actions/navigation";
15
+ export const LINKEDIN_CONTACT_NAVIGATION_SDUIID_MAX_LENGTH = 256;
16
+ export const LINKEDIN_CONTACT_DETAILS_PAGE_KEY = "profile_view_base_contact_details";
17
+ export const LINKEDIN_CONTACT_NAVIGATION_CLIENT_ARGUMENT_KEYS = Object.freeze([
18
+ "$type",
19
+ "requestedStateKeys",
20
+ "payload",
21
+ "requestMetadata",
22
+ ] as const);
23
+ export const LINKEDIN_CONTACT_NAVIGATION_PAYLOAD_KEYS = Object.freeze([
24
+ "vanityName",
25
+ "givenName",
26
+ "familyName",
27
+ "isVanityNameResolved",
28
+ ] as const);
29
+ export const LINKEDIN_CONTACT_NAVIGATION_REQUESTED_ARGUMENTS_TYPE =
30
+ "proto.sdui.actions.requests.RequestedArguments";
31
+ export const LINKEDIN_CONTACT_NAVIGATION_REQUEST_METADATA_TYPE =
32
+ "proto.sdui.common.RequestMetadata";
33
+ export const LINKEDIN_CONTACT_NAVIGATION_REQUEST_METADATA_KEYS = Object.freeze([
34
+ "$type",
35
+ "states",
36
+ "screenId",
37
+ "knownTemplates",
38
+ ] as const);
39
+ const CONTACT_NAVIGATION_CLIENT_ARGUMENT_KEY_SET = new Set<string>(
40
+ LINKEDIN_CONTACT_NAVIGATION_CLIENT_ARGUMENT_KEYS,
41
+ );
42
+ const CONTACT_NAVIGATION_PAYLOAD_KEY_SET = new Set<string>(
43
+ LINKEDIN_CONTACT_NAVIGATION_PAYLOAD_KEYS,
44
+ );
45
+ const CONTACT_NAVIGATION_REQUEST_METADATA_KEY_SET = new Set<string>(
46
+ LINKEDIN_CONTACT_NAVIGATION_REQUEST_METADATA_KEYS,
47
+ );
48
+ const LINKEDIN_SDUI_TYPE = /^com\.linkedin(?:\.[A-Za-z][A-Za-z0-9]*)+$/u;
49
+ const PROTO_SDUI_TYPE = /^proto\.sdui(?:\.[A-Za-z][A-Za-z0-9]*)+$/u;
50
+ const CONTACT_NAVIGATION_HREF =
51
+ /(?:https:\/\/www\.linkedin\.com)?(\/flagship-web\/rsc-action\/actions\/navigation\?[^\s"'<>]+)/gu;
13
52
  const CONTACT_QUERY_ID =
14
53
  /^voyagerIdentityDashProfileContactInfo\.[0-9a-f]{32}$/u;
54
+ const LINKEDIN_CONTACT_INFO_OVERLAY_PATH =
55
+ /^\/in\/([A-Za-z0-9][A-Za-z0-9_-]{1,99})\/overlay\/contact-info\/$/u;
56
+ const LINKEDIN_CONTACT_INFO_FIELD_LABEL_ERROR =
57
+ "LinkedIn contact-info field label must be a bounded string";
15
58
 
16
59
  const LINKEDIN_ORIGIN = "https://www.linkedin.com";
17
60
  const PROFILE_URN = /^urn:li:fsd_profile:[A-Za-z0-9_-]{1,256}$/u;
@@ -20,20 +63,31 @@ const EMAIL = /^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,24}$/u;
20
63
  const PHONE = /^\+?[0-9][0-9 .\-()]{6,30}[0-9]$/u;
21
64
  const BIRTHDAY = /^(?:[0-9]{4}-)?(?:0[1-9]|1[0-2])-(?:0[1-9]|[12][0-9]|3[01])$/u;
22
65
  const CONNECTED_DISPLAY =
23
- /^(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) ([1-9]|[12][0-9]|3[01]), ([0-9]{4})$/u;
66
+ /^(January|February|March|April|May|June|July|August|September|October|November|December|Jan|Feb|Mar|Apr|Jun|Jul|Aug|Sep|Oct|Nov|Dec) ([1-9]|[12][0-9]|3[01]), ([0-9]{4})$/u;
24
67
  const MONTHS = Object.freeze({
25
68
  Jan: "01",
69
+ January: "01",
26
70
  Feb: "02",
71
+ February: "02",
27
72
  Mar: "03",
73
+ March: "03",
28
74
  Apr: "04",
75
+ April: "04",
29
76
  May: "05",
30
77
  Jun: "06",
78
+ June: "06",
31
79
  Jul: "07",
80
+ July: "07",
32
81
  Aug: "08",
82
+ August: "08",
33
83
  Sep: "09",
84
+ September: "09",
34
85
  Oct: "10",
86
+ October: "10",
35
87
  Nov: "11",
88
+ November: "11",
36
89
  Dec: "12",
90
+ December: "12",
37
91
  });
38
92
  const HTML_ENTITY =
39
93
  /&(?:nbsp|quot|amp|lt|gt|apos|#(?:[xX][0-9A-Fa-f]{1,6}|[0-9]{1,7}));/gu;
@@ -46,11 +100,16 @@ const MAX_WALK_DEPTH = 128;
46
100
  const RSC_FLIGHT_ROW = /(?:^|\n)(\d+):/u;
47
101
  const PROFILE_ID = /^[A-Za-z0-9_-]{1,256}$/u;
48
102
  const DISTANCE_IN_TEXT =
49
- /(?:^|["'\\{,])(?:networkDistance|memberDistance|distance)"?\s*:\s*"?(DISTANCE_[A-Z0-9]+|OUT_OF_NETWORK|SELF|[0-9]+)"?/gu;
103
+ /(?:^|["'\\{,])(?:networkDistance|memberDistance|distance)\\*"?\s*:\s*\\*"?(DISTANCE_[A-Z0-9]+|OUT_OF_NETWORK|SELF|[0-9]+)\\*"?/gu;
50
104
  const PROFILE_URN_IN_TEXT = /urn:li:fsd_profile:[A-Za-z0-9_-]{1,256}/gu;
51
- const VIEWEE_PROFILE_ID_IN_TEXT = /vieweeProfileId"\s*:\s*"([A-Za-z0-9_-]{1,256})"/gu;
105
+ const VIEWEE_PROFILE_ID_IN_TEXT =
106
+ /vieweeProfileId\\*"?\s*:\s*\\*"?([A-Za-z0-9_-]{1,256})/gu;
107
+ const VIEWEE_MEMBER_URN_IN_TEXT =
108
+ /vieweeMemberUrn\\*"?\s*:\s*\\*"?(urn:li:(?:fsd_profile|member):[A-Za-z0-9_-]{1,256}|[0-9]{1,32})/gu;
109
+ const MEMBER_URN = /^urn:li:member:[0-9]{1,32}$/u;
52
110
  const VANITY_IN_TEXT =
53
- /(?:vanityName|publicIdentifier)"\s*:\s*"([A-Za-z0-9][A-Za-z0-9_-]{1,99})"/gu;
111
+ /(?:vanityName|publicIdentifier)\\*"?\s*:\s*\\*"?([A-Za-z0-9][A-Za-z0-9_-]{1,99})/gu;
112
+ const MAX_JSON_STRING_PEELS = 4;
54
113
  const KEYED_IDENTITY_KEYS = new Set([
55
114
  "distance",
56
115
  "entityUrn",
@@ -104,6 +163,49 @@ export type LinkedInContactInfoJsonInput = {
104
163
  readonly queryId?: string;
105
164
  };
106
165
 
166
+ export type LinkedInContactNavigationRequestMetadata = {
167
+ readonly $type?: string;
168
+ readonly states?: readonly string[];
169
+ readonly screenId?: string;
170
+ readonly knownTemplates?: readonly string[];
171
+ };
172
+
173
+ export type LinkedInContactNavigationPayload = {
174
+ readonly vanityName?: string;
175
+ readonly givenName?: string;
176
+ readonly familyName?: string;
177
+ readonly isVanityNameResolved?: boolean;
178
+ readonly requestMetadata?: LinkedInContactNavigationRequestMetadata;
179
+ };
180
+
181
+ export type LinkedInContactNavigationClientArguments = {
182
+ readonly $type?: string;
183
+ readonly requestedStateKeys?: readonly string[];
184
+ readonly payload: LinkedInContactNavigationPayload;
185
+ };
186
+
187
+ export type LinkedInContactNavigationAction = {
188
+ readonly sduiid: string;
189
+ readonly clientArguments: LinkedInContactNavigationClientArguments;
190
+ readonly isModal: true;
191
+ };
192
+
193
+ export type LinkedInContactNavigationInput = {
194
+ readonly profileUrl: string;
195
+ readonly profileUrn: string;
196
+ readonly sduiid: string;
197
+ readonly clientArguments: LinkedInContactNavigationClientArguments;
198
+ };
199
+
200
+ export type LinkedInContactNavigationActionResult =
201
+ | { readonly kind: "action"; readonly action: LinkedInContactNavigationAction }
202
+ | { readonly kind: "absent" }
203
+ | { readonly kind: "omitted-sduiid" }
204
+ | { readonly kind: "unreviewed-sduiid" }
205
+ | { readonly kind: "ambiguous-sduiid" }
206
+ | { readonly kind: "unreviewed-client-arguments"; readonly keys: readonly string[] }
207
+ | { readonly kind: "unbound-client-arguments" };
208
+
107
209
  type JsonRecord = Readonly<Record<string, unknown>>;
108
210
 
109
211
  function isRecord(value: unknown): value is JsonRecord {
@@ -174,6 +276,82 @@ export function linkedInProfileContactInfoGraphqlUrl(input: {
174
276
  return new URL(buildLinkedInProfileContactInfoGraphqlPath(input), LINKEDIN_ORIGIN);
175
277
  }
176
278
 
279
+ export function buildLinkedInProfileContactDetailsOverlayPath(input: {
280
+ readonly profileUrn: unknown;
281
+ }): string {
282
+ const urn = profileUrn(input.profileUrn);
283
+ return `${LINKEDIN_CONTACT_DETAILS_OVERLAY_PATH}?screenId=${encodeURIComponent(LINKEDIN_CONTACT_DETAILS_OVERLAY_SCREEN_ID)}&profileUrn=${encodeURIComponent(urn)}`;
284
+ }
285
+
286
+ export function linkedInProfileContactDetailsOverlayUrl(input: {
287
+ readonly profileUrn: unknown;
288
+ }): URL {
289
+ return new URL(buildLinkedInProfileContactDetailsOverlayPath(input), LINKEDIN_ORIGIN);
290
+ }
291
+
292
+ export function parseLinkedInContactNavigationSduiid(raw: unknown): string {
293
+ const sduiid = boundedText(
294
+ raw,
295
+ "LinkedIn contact-info navigation sduiid",
296
+ LINKEDIN_CONTACT_NAVIGATION_SDUIID_MAX_LENGTH,
297
+ );
298
+ if (
299
+ /[\s"'<>\\]/u.test(sduiid)
300
+ || sduiid !== LINKEDIN_CONTACT_DETAILS_OVERLAY_SCREEN_ID
301
+ ) {
302
+ throw new Error("LinkedIn contact-info navigation sduiid must equal ProfileContactDetailsOverlay");
303
+ }
304
+ return sduiid;
305
+ }
306
+
307
+ export function buildLinkedInProfileContactDetailsNavigationPostPath(
308
+ input: { readonly sduiid?: unknown } = {},
309
+ ): string {
310
+ const sduiid = input.sduiid === undefined
311
+ ? LINKEDIN_CONTACT_DETAILS_OVERLAY_SCREEN_ID
312
+ : parseLinkedInContactNavigationSduiid(input.sduiid);
313
+ // Live LinkedIn rsc-action routes use `sduiid`, not `sduid`. The headed 200
314
+ // Contact-info POST and public SDUI server-request/pagination URLs keep that
315
+ // spelling; `sduid=` notes are transcription typos.
316
+ return `${LINKEDIN_CONTACT_DETAILS_OVERLAY_PATH}?screenId=${encodeURIComponent(LINKEDIN_CONTACT_DETAILS_OVERLAY_SCREEN_ID)}&sduiid=${encodeURIComponent(sduiid)}`;
317
+ }
318
+
319
+ export function linkedInProfileContactDetailsNavigationPostUrl(input: {
320
+ readonly sduiid: unknown;
321
+ }): URL {
322
+ return new URL(buildLinkedInProfileContactDetailsNavigationPostPath(input), LINKEDIN_ORIGIN);
323
+ }
324
+
325
+ export function buildLinkedInContactNavigationBody(
326
+ input: Pick<LinkedInContactNavigationAction, "clientArguments">,
327
+ ): string {
328
+ return JSON.stringify({
329
+ clientArguments: canonicalContactNavigationClientArguments(input.clientArguments),
330
+ isModal: true,
331
+ });
332
+ }
333
+
334
+ export function buildLinkedInProfileContactInfoOverlayPath(input: {
335
+ readonly profileUrl: unknown;
336
+ }): string {
337
+ const target = linkedInPersonalProfileTarget(input.profileUrl);
338
+ return `/in/${target.slug}/overlay/contact-info/`;
339
+ }
340
+
341
+ export function linkedInProfileContactInfoOverlayUrl(input: {
342
+ readonly profileUrl: unknown;
343
+ }): URL {
344
+ return new URL(buildLinkedInProfileContactInfoOverlayPath(input), LINKEDIN_ORIGIN);
345
+ }
346
+
347
+ function assertLinkedInContactInfoOverlayUrl(url: URL): void {
348
+ const match = LINKEDIN_CONTACT_INFO_OVERLAY_PATH.exec(url.pathname);
349
+ if (url.search !== "" || match?.[1] === undefined) {
350
+ throw new Error("LinkedIn contact-info request escaped its exact reviewed route");
351
+ }
352
+ linkedInPersonalProfilePublicIdentifier(match[1]);
353
+ }
354
+
177
355
  export function resolveLinkedInProfileContactInfoQueryId(html: unknown): string | undefined {
178
356
  if (typeof html !== "string" || html.length < 1 || html.length > MAX_HTML_BYTES) {
179
357
  return undefined;
@@ -188,6 +366,51 @@ export function resolveLinkedInProfileContactInfoQueryId(html: unknown): string
188
366
  return unique.values().next().value;
189
367
  }
190
368
 
369
+ function assertLinkedInContactInfoNavigationPostUrl(url: URL): void {
370
+ const queryNames = [...url.searchParams.keys()];
371
+ if (
372
+ url.pathname !== LINKEDIN_CONTACT_DETAILS_OVERLAY_PATH
373
+ || queryNames.length !== 2
374
+ || queryNames[0] !== "screenId"
375
+ || queryNames[1] !== "sduiid"
376
+ || url.searchParams.get("screenId") !== LINKEDIN_CONTACT_DETAILS_OVERLAY_SCREEN_ID
377
+ || url.searchParams.getAll("screenId").length !== 1
378
+ || url.searchParams.getAll("sduiid").length !== 1
379
+ ) throw new Error("LinkedIn contact-info request escaped its exact reviewed route");
380
+ parseLinkedInContactNavigationSduiid(url.searchParams.get("sduiid"));
381
+ }
382
+
383
+ function assertLinkedInContactInfoNavigationBody(body: unknown): void {
384
+ if (typeof body === "string") {
385
+ if (body.length < 1 || body.length > 4_096) {
386
+ throw new Error("LinkedIn contact-info navigation body exceeded its reviewed bound");
387
+ }
388
+ try {
389
+ body = JSON.parse(body) as unknown;
390
+ } catch {
391
+ throw new Error("LinkedIn contact-info navigation body must be JSON");
392
+ }
393
+ }
394
+ if (!isRecord(body)) throw new Error("LinkedIn contact-info navigation body must be an object");
395
+ const keys = Object.keys(body);
396
+ if (
397
+ keys.length !== 2
398
+ || keys[0] !== "clientArguments"
399
+ || keys[1] !== "isModal"
400
+ || body.isModal !== true
401
+ || !isRecord(body.clientArguments)
402
+ ) throw new Error("LinkedIn contact-info navigation body escaped its reviewed shape");
403
+ const reviewed = reviewedContactNavigationClientArguments(body.clientArguments);
404
+ if (reviewed.kind === "unreviewed") {
405
+ throw new Error(
406
+ `LinkedIn contact-info navigation clientArguments used unreviewed keys: ${reviewed.keys.join(", ")}`,
407
+ );
408
+ }
409
+ if (reviewed.kind !== "args") {
410
+ throw new Error("LinkedIn contact-info navigation body escaped its reviewed shape");
411
+ }
412
+ }
413
+
191
414
  function assertLinkedInContactInfoGraphqlUrl(url: URL): void {
192
415
  const queryNames = [...url.searchParams.keys()];
193
416
  const queryKey = queryNames[1];
@@ -220,7 +443,9 @@ export function assertLinkedInContactInfoRequest(requestValue: unknown): void {
220
443
  }
221
444
  const method = boundedText(requestValue.method, "LinkedIn contact-info request method", 16)
222
445
  .toUpperCase();
223
- if (method !== "GET") throw new Error("LinkedIn contact-info reads require GET");
446
+ if (method !== "GET" && method !== "POST") {
447
+ throw new Error("LinkedIn contact-info reads require GET or the reviewed navigation POST");
448
+ }
224
449
  const rawUrl = requestValue.url;
225
450
  if (!(rawUrl instanceof URL) && typeof rawUrl !== "string") {
226
451
  throw new Error("LinkedIn contact-info request URL is invalid");
@@ -232,6 +457,13 @@ export function assertLinkedInContactInfoRequest(requestValue: unknown): void {
232
457
  && url.password === ""
233
458
  && url.hash === ""
234
459
  ) {
460
+ if (method === "POST") {
461
+ assertLinkedInContactInfoNavigationPostUrl(url);
462
+ if (requestValue.body !== undefined) {
463
+ assertLinkedInContactInfoNavigationBody(requestValue.body);
464
+ }
465
+ return;
466
+ }
235
467
  if (url.search === "" && url.pathname === "/voyager/api/me") return;
236
468
  if (url.search === "" && /^\/in\/[A-Za-z0-9][A-Za-z0-9_-]{1,99}\/$/u.test(url.pathname)) {
237
469
  linkedInPersonalProfileTarget(url.href);
@@ -241,6 +473,10 @@ export function assertLinkedInContactInfoRequest(requestValue: unknown): void {
241
473
  assertLinkedInContactInfoGraphqlUrl(url);
242
474
  return;
243
475
  }
476
+ if (LINKEDIN_CONTACT_INFO_OVERLAY_PATH.test(url.pathname)) {
477
+ assertLinkedInContactInfoOverlayUrl(url);
478
+ return;
479
+ }
244
480
  }
245
481
  throw new Error("LinkedIn contact-info request escaped its exact reviewed route");
246
482
  }
@@ -356,15 +592,74 @@ function extractJsonString(source: string, start: number): string | undefined {
356
592
  return undefined;
357
593
  }
358
594
 
359
- function breadcrumbRecordFromText(value: string): JsonRecord | null {
360
- if (value.length < 8 || value.length > 1024 * 1024) return null;
595
+ function uniqueTextCapture(values: readonly string[]): string | null {
596
+ const unique = [...new Set(values)];
597
+ return unique.length === 1 ? unique[0]! : null;
598
+ }
599
+
600
+ function peelOneJsonStringLayer(value: string): string {
601
+ if (!value.includes("\\")) return value;
602
+ let peeled = "";
603
+ for (let index = 0; index < value.length; index += 1) {
604
+ const character = value[index];
605
+ if (character !== "\\") {
606
+ peeled += character;
607
+ continue;
608
+ }
609
+ const escaped = value[index + 1];
610
+ if (escaped === undefined) {
611
+ peeled += character;
612
+ break;
613
+ }
614
+ if (escaped === "\"" || escaped === "\\" || escaped === "/") {
615
+ peeled += escaped;
616
+ index += 1;
617
+ continue;
618
+ }
619
+ if (escaped === "n" || escaped === "r" || escaped === "t") {
620
+ peeled += escaped === "n" ? "\n" : escaped === "r" ? "\r" : "\t";
621
+ index += 1;
622
+ continue;
623
+ }
624
+ if (escaped === "u") {
625
+ const hex = value.slice(index + 2, index + 6);
626
+ if (/^[0-9A-Fa-f]{4}$/u.test(hex)) {
627
+ const codePoint = Number.parseInt(hex, 16);
628
+ if (codePoint < 0xD800 || codePoint > 0xDFFF) {
629
+ peeled += String.fromCharCode(codePoint);
630
+ index += 5;
631
+ continue;
632
+ }
633
+ }
634
+ }
635
+ peeled += character;
636
+ }
637
+ return peeled;
638
+ }
639
+
640
+ function peelJsonStringEscapes(value: string): string {
641
+ let current = value;
642
+ for (let peel = 0; peel < MAX_JSON_STRING_PEELS; peel += 1) {
643
+ const next = peelOneJsonStringLayer(current);
644
+ if (next === current) break;
645
+ current = next;
646
+ }
647
+ return current;
648
+ }
649
+
650
+ function captureBreadcrumbRecord(value: string): JsonRecord | null {
361
651
  const distances = [...value.matchAll(DISTANCE_IN_TEXT)]
362
652
  .map((match) => match[1])
363
653
  .filter((item): item is string => item !== undefined);
364
- const urns = [...value.matchAll(PROFILE_URN_IN_TEXT)];
654
+ const urns = [...value.matchAll(PROFILE_URN_IN_TEXT)]
655
+ .map((match) => match[0])
656
+ .filter((item): item is string => item !== undefined);
365
657
  const vieweeIds = [...value.matchAll(VIEWEE_PROFILE_ID_IN_TEXT)]
366
658
  .map((match) => match[1])
367
659
  .filter((item): item is string => item !== undefined);
660
+ const vieweeMembers = [...value.matchAll(VIEWEE_MEMBER_URN_IN_TEXT)]
661
+ .map((match) => match[1])
662
+ .filter((item): item is string => item !== undefined);
368
663
  const vanities = [...value.matchAll(VANITY_IN_TEXT)]
369
664
  .map((match) => match[1])
370
665
  .filter((item): item is string => item !== undefined);
@@ -372,25 +667,33 @@ function breadcrumbRecordFromText(value: string): JsonRecord | null {
372
667
  distances.length === 0
373
668
  && urns.length === 0
374
669
  && vieweeIds.length === 0
670
+ && vieweeMembers.length === 0
375
671
  && vanities.length === 0
376
672
  ) return null;
377
673
  const record: Record<string, unknown> = {};
378
- if (distances.length === 1) {
379
- const raw = distances[0]!;
380
- record.networkDistance = /^[0-9]+$/u.test(raw) ? Number(raw) : raw;
381
- } else if (distances.length > 1) {
382
- const unique = [...new Set(distances)];
383
- if (unique.length === 1) {
384
- const raw = unique[0]!;
385
- record.networkDistance = /^[0-9]+$/u.test(raw) ? Number(raw) : raw;
386
- }
674
+ const distance = uniqueTextCapture(distances);
675
+ if (distance !== null) {
676
+ record.networkDistance = /^[0-9]+$/u.test(distance) ? Number(distance) : distance;
677
+ }
678
+ const vieweeMemberUrn = uniqueTextCapture(vieweeMembers);
679
+ if (vieweeMemberUrn !== null) record.vieweeMemberUrn = vieweeMemberUrn;
680
+ else {
681
+ const incidental = uniqueTextCapture(urns);
682
+ if (incidental !== null) record.profileUrn = incidental;
387
683
  }
388
- if (urns.length === 1) record.profileUrn = urns[0]![0];
389
684
  if (vieweeIds.length === 1) record.vieweeProfileId = vieweeIds[0];
390
685
  if (vanities.length === 1) record.vanityName = vanities[0];
391
686
  return Object.keys(record).length > 0 ? Object.freeze(record) : null;
392
687
  }
393
688
 
689
+ function breadcrumbRecordFromText(value: string): JsonRecord | null {
690
+ if (value.length < 8 || value.length > 1024 * 1024) return null;
691
+ const peeled = peelJsonStringEscapes(value);
692
+ return captureBreadcrumbRecord(peeled) ?? (
693
+ peeled === value ? null : captureBreadcrumbRecord(value)
694
+ );
695
+ }
696
+
394
697
  function stringLooksLikeBootstrap(value: string): boolean {
395
698
  return value.includes("networkDistance")
396
699
  || value.includes("memberDistance")
@@ -404,15 +707,20 @@ function stringLooksLikeBootstrap(value: string): boolean {
404
707
  function decodeStringBootstrap(value: string): unknown[] {
405
708
  const trimmed = value.trim();
406
709
  if (trimmed.length < 8 || trimmed.length > 1024 * 1024) return [];
407
- if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
408
- try {
409
- return [JSON.parse(trimmed) as unknown];
410
- } catch {
411
- // Fall through to breadcrumb extraction from partial RSC string rows.
710
+ const peeled = peelJsonStringEscapes(trimmed);
711
+ for (const candidate of peeled === trimmed ? [trimmed] : [trimmed, peeled]) {
712
+ if (candidate.startsWith("{") || candidate.startsWith("[")) {
713
+ try {
714
+ return [JSON.parse(candidate) as unknown];
715
+ } catch {
716
+ // Fall through to breadcrumb extraction from partial RSC string rows.
717
+ }
412
718
  }
413
719
  }
414
- if (!stringLooksLikeBootstrap(trimmed)) return [];
415
- const breadcrumb = breadcrumbRecordFromText(trimmed);
720
+ if (!stringLooksLikeBootstrap(trimmed) && !stringLooksLikeBootstrap(peeled)) {
721
+ return [];
722
+ }
723
+ const breadcrumb = breadcrumbRecordFromText(peeled);
416
724
  return breadcrumb === null ? [] : [breadcrumb];
417
725
  }
418
726
 
@@ -477,7 +785,9 @@ function decodeRscFlightRecords(text: string): unknown[] {
477
785
 
478
786
  function decodeComoRehydrationValue(value: unknown): unknown[] {
479
787
  if (typeof value === "string") {
480
- return looksLikeRscFlight(value) ? decodeRscFlightRecords(value) : [];
788
+ return looksLikeRscFlight(value)
789
+ ? decodeRscFlightRecords(value)
790
+ : decodeStringBootstrap(value);
481
791
  }
482
792
  if (Array.isArray(value)) {
483
793
  if (value.length > 20_000) {
@@ -673,6 +983,32 @@ function profileUrnFromRecord(record: JsonRecord): string | null {
673
983
  return profileUrnFromIdentity(record.vieweeProfileId);
674
984
  }
675
985
 
986
+ function durableProfileUrnFromRecord(record: JsonRecord): string | null {
987
+ for (const key of ["entityUrn", "objectUrn", "profileUrn"] as const) {
988
+ const urn = profileUrnFromIdentity(record[key]);
989
+ if (urn !== null) return urn;
990
+ }
991
+ const viewee = profileUrnFromIdentity(record.vieweeMemberUrn);
992
+ if (viewee !== null && !VIEWER_SUBJECT.test(viewee)) return viewee;
993
+ return profileUrnFromIdentity(record.vieweeProfileId);
994
+ }
995
+
996
+ function classicProfileUrnFromRecord(record: JsonRecord): string | null {
997
+ for (const key of ["entityUrn", "objectUrn", "profileUrn"] as const) {
998
+ const urn = profileUrnFromIdentity(record[key]);
999
+ if (urn !== null) return urn;
1000
+ }
1001
+ return null;
1002
+ }
1003
+
1004
+ function vieweeMemberIdentity(record: JsonRecord): string | null {
1005
+ const value = record.vieweeMemberUrn;
1006
+ if (typeof value !== "string" || value.length < 1 || value.length > 512) return null;
1007
+ return profileUrnFromIdentity(value)
1008
+ ?? (MEMBER_URN.test(value) ? value : null)
1009
+ ?? (PROFILE_ID.test(value) ? value : null);
1010
+ }
1011
+
676
1012
  function recordIsSelfView(record: JsonRecord): boolean {
677
1013
  return record.isSelfView === true;
678
1014
  }
@@ -710,8 +1046,26 @@ function recordContradictsTarget(
710
1046
  urn: string,
711
1047
  ): boolean {
712
1048
  const vanity = vanityFromRecord(record);
713
- const recordUrn = profileUrnFromRecord(record);
714
- return (vanity !== null && vanity !== slug) || (recordUrn !== null && recordUrn !== urn);
1049
+ const classicUrn = classicProfileUrnFromRecord(record);
1050
+ return (vanity !== null && vanity !== slug)
1051
+ || (classicUrn !== null && classicUrn !== urn);
1052
+ }
1053
+
1054
+ function recordIsViewerViewee(
1055
+ record: JsonRecord,
1056
+ viewer: string,
1057
+ ): boolean {
1058
+ const member = vieweeMemberIdentity(record);
1059
+ return member === viewer || profileUrnFromIdentity(member) === viewer;
1060
+ }
1061
+
1062
+ function uniqueNonSelfDistance(
1063
+ values: readonly (string | number)[],
1064
+ ): string | number | undefined {
1065
+ const other = values.filter((value) => !isSelfDistance(value));
1066
+ const unique = uniqueDistances(other);
1067
+ if (unique.length > 1) throw omittedDistanceError();
1068
+ return unique[0];
715
1069
  }
716
1070
 
717
1071
  function relationshipDistance(
@@ -724,22 +1078,35 @@ function relationshipDistance(
724
1078
  .filter((record) => recordMatchesTarget(record, slug, urn))
725
1079
  .map(distanceValue)
726
1080
  .filter((value): value is string | number => value !== null);
727
- const boundOther = bound.filter((value) => !isSelfDistance(value));
728
- const boundUnique = uniqueDistances(boundOther);
729
- if (boundUnique.length === 1 && boundUnique[0] !== undefined) return boundUnique[0];
730
- if (boundUnique.length > 1) throw omittedDistanceError();
1081
+ const boundDistance = uniqueNonSelfDistance(bound);
1082
+ if (boundDistance !== undefined) return boundDistance;
731
1083
  if (bound.some(isSelfDistance) && urn === viewer) throw selfProfileError();
732
1084
 
733
- const breadcrumbs = records
734
- .filter((record) => !recordContradictsTarget(record, slug, urn))
1085
+ const vieweeJoined = records
1086
+ .filter((record) => (
1087
+ !recordContradictsTarget(record, slug, urn)
1088
+ && !recordIsViewerViewee(record, viewer)
1089
+ && vieweeMemberIdentity(record) !== null
1090
+ ))
735
1091
  .map(distanceValue)
736
1092
  .filter((value): value is string | number => value !== null);
737
- const breadcrumbOther = breadcrumbs.filter((value) => !isSelfDistance(value));
738
- const breadcrumbUnique = uniqueDistances(breadcrumbOther);
739
- if (breadcrumbUnique.length === 1 && breadcrumbUnique[0] !== undefined) {
740
- return breadcrumbUnique[0];
1093
+ const vieweeDistance = uniqueNonSelfDistance(vieweeJoined);
1094
+ if (vieweeDistance !== undefined) return vieweeDistance;
1095
+ if (vieweeJoined.some(isSelfDistance) && (urn === viewer || !vieweeJoined.some((value) => !isSelfDistance(value)))) {
1096
+ throw selfProfileError();
741
1097
  }
742
- if (breadcrumbs.some(isSelfDistance) && (urn === viewer || breadcrumbOther.length === 0)) {
1098
+
1099
+ const breadcrumbs = records
1100
+ .filter((record) => (
1101
+ !recordContradictsTarget(record, slug, urn)
1102
+ && !recordMatchesTarget(record, slug, urn)
1103
+ && vieweeMemberIdentity(record) === null
1104
+ ))
1105
+ .map(distanceValue)
1106
+ .filter((value): value is string | number => value !== null);
1107
+ const breadcrumbDistance = uniqueNonSelfDistance(breadcrumbs);
1108
+ if (breadcrumbDistance !== undefined) return breadcrumbDistance;
1109
+ if (breadcrumbs.some(isSelfDistance) && (urn === viewer || !breadcrumbs.some((value) => !isSelfDistance(value)))) {
743
1110
  throw selfProfileError();
744
1111
  }
745
1112
  throw omittedDistanceError();
@@ -760,14 +1127,14 @@ export function projectLinkedInProfileContactBinding(input: {
760
1127
  if (vanityRecords.some(recordIsSelfView)) throw selfProfileError();
761
1128
  const urns = new Set<string>();
762
1129
  for (const record of vanityRecords) {
763
- const urn = profileUrnFromRecord(record);
1130
+ const urn = durableProfileUrnFromRecord(record);
764
1131
  if (urn !== null) urns.add(urn);
765
1132
  }
766
1133
  if (urns.size < 1) {
767
1134
  const joined = new Set<string>();
768
1135
  let sawSelf = false;
769
1136
  for (const record of records) {
770
- const urn = profileUrnFromRecord(record);
1137
+ const urn = durableProfileUrnFromRecord(record);
771
1138
  const distance = distanceValue(record);
772
1139
  if (
773
1140
  recordIsSelfView(record)
@@ -808,14 +1175,603 @@ export function projectLinkedInProfileContactBinding(input: {
808
1175
  });
809
1176
  }
810
1177
 
1178
+ function isOverlayContactScreen(value: unknown): boolean {
1179
+ return value === LINKEDIN_CONTACT_DETAILS_OVERLAY_SCREEN_ID
1180
+ || value === "ProfileContactDetailsOverlay";
1181
+ }
1182
+
1183
+ function isContactDetailsPageKey(value: unknown): boolean {
1184
+ return value === LINKEDIN_CONTACT_DETAILS_PAGE_KEY;
1185
+ }
1186
+
1187
+ function reviewedSduiType(value: unknown): string | null {
1188
+ if (typeof value !== "string" || value.length < 12 || value.length > 256) return null;
1189
+ return LINKEDIN_SDUI_TYPE.test(value) || PROTO_SDUI_TYPE.test(value) ? value : null;
1190
+ }
1191
+
1192
+ function reviewedStateKeyList(value: unknown): readonly string[] | null {
1193
+ if (!Array.isArray(value) || value.length > 32) return null;
1194
+ const keys: string[] = [];
1195
+ for (const item of value) {
1196
+ if (typeof item !== "string" || item.length < 1 || item.length > 256 || /[\s"'<>\\]/u.test(item)) {
1197
+ return null;
1198
+ }
1199
+ keys.push(item);
1200
+ }
1201
+ return Object.freeze(keys);
1202
+ }
1203
+
1204
+ function optionalPersonName(value: unknown): string | null {
1205
+ if (typeof value !== "string" || value.length < 1 || value.length > 128) return null;
1206
+ if (/[@:/\\<>]/u.test(value)) return null;
1207
+ return value;
1208
+ }
1209
+
1210
+ type OptionalSduiid =
1211
+ | { readonly kind: "absent" }
1212
+ | { readonly kind: "reviewed"; readonly sduiid: string }
1213
+ | { readonly kind: "unreviewed" };
1214
+
1215
+ function optionalNavigationSduiid(value: unknown): OptionalSduiid {
1216
+ if (value === undefined || value === null || value === "") return { kind: "absent" };
1217
+ if (typeof value !== "string") return { kind: "unreviewed" };
1218
+ try {
1219
+ return { kind: "reviewed", sduiid: parseLinkedInContactNavigationSduiid(value) };
1220
+ } catch {
1221
+ return { kind: "unreviewed" };
1222
+ }
1223
+ }
1224
+
1225
+ function parseContactNavigationHref(value: unknown): {
1226
+ readonly screenId: string | null;
1227
+ readonly sduiid: OptionalSduiid;
1228
+ } | null {
1229
+ if (typeof value !== "string" || value.length < 1 || value.length > 2_048) return null;
1230
+ let url: URL;
1231
+ try {
1232
+ url = new URL(
1233
+ value.startsWith("http")
1234
+ ? value
1235
+ : `https://www.linkedin.com${value.startsWith("/") ? value : `/${value}`}`,
1236
+ );
1237
+ } catch {
1238
+ return null;
1239
+ }
1240
+ if (
1241
+ (url.hostname !== "www.linkedin.com" && url.hostname !== "linkedin.com")
1242
+ || url.pathname !== LINKEDIN_CONTACT_DETAILS_OVERLAY_PATH
1243
+ ) return null;
1244
+ const screenId = url.searchParams.get("screenId");
1245
+ return Object.freeze({
1246
+ screenId,
1247
+ sduiid: optionalNavigationSduiid(url.searchParams.get("sduiid")),
1248
+ });
1249
+ }
1250
+
1251
+ function peelContactNavigationClientArguments(value: unknown): JsonRecord | null {
1252
+ if (!isRecord(value)) return null;
1253
+ if (isRecord(value.clientArguments)) return value.clientArguments;
1254
+ const requested = isRecord(value.requestedArguments) ? value.requestedArguments : null;
1255
+ const nested = isRecord(value.arguments) ? value.arguments : null;
1256
+ for (const candidate of [requested, nested]) {
1257
+ if (candidate === null) continue;
1258
+ if (isRecord(candidate.clientArguments)) return candidate.clientArguments;
1259
+ if (isRecord(candidate.payload) || candidate.$type !== undefined) return candidate;
1260
+ return Object.freeze({ payload: candidate });
1261
+ }
1262
+ if (isRecord(value.payload) || value.requestMetadata !== undefined) {
1263
+ const root: Record<string, unknown> = {};
1264
+ if (value.$type !== undefined) root.$type = value.$type;
1265
+ if (value.requestedStateKeys !== undefined) root.requestedStateKeys = value.requestedStateKeys;
1266
+ if (isRecord(value.payload)) root.payload = value.payload;
1267
+ if (value.requestMetadata !== undefined) root.requestMetadata = value.requestMetadata;
1268
+ return Object.freeze(root);
1269
+ }
1270
+ return null;
1271
+ }
1272
+
1273
+ function requestMetadataEquals(
1274
+ left: LinkedInContactNavigationRequestMetadata,
1275
+ right: LinkedInContactNavigationRequestMetadata,
1276
+ ): boolean {
1277
+ return left.$type === right.$type
1278
+ && left.screenId === right.screenId
1279
+ && JSON.stringify(left.states ?? null) === JSON.stringify(right.states ?? null)
1280
+ && JSON.stringify(left.knownTemplates ?? null) === JSON.stringify(right.knownTemplates ?? null);
1281
+ }
1282
+
1283
+ function reviewedRequestMetadata(
1284
+ raw: unknown,
1285
+ ):
1286
+ | { readonly kind: "meta"; readonly meta: LinkedInContactNavigationRequestMetadata }
1287
+ | { readonly kind: "unreviewed"; readonly keys: readonly string[] }
1288
+ | { readonly kind: "empty" }
1289
+ {
1290
+ if (raw === undefined) return { kind: "empty" };
1291
+ if (!isRecord(raw)) return { kind: "unreviewed", keys: Object.freeze(["requestMetadata"]) };
1292
+ const extra = Object.keys(raw).filter((key) => !CONTACT_NAVIGATION_REQUEST_METADATA_KEY_SET.has(key));
1293
+ if (extra.length > 0) return { kind: "unreviewed", keys: Object.freeze(extra.map((key) => `requestMetadata.${key}`)) };
1294
+ const meta: {
1295
+ $type?: string;
1296
+ states?: readonly string[];
1297
+ screenId?: string;
1298
+ knownTemplates?: readonly string[];
1299
+ } = {};
1300
+ if (raw.$type !== undefined) {
1301
+ const type = reviewedSduiType(raw.$type);
1302
+ if (type === null) return { kind: "unreviewed", keys: Object.freeze(["requestMetadata.$type"]) };
1303
+ meta.$type = type;
1304
+ }
1305
+ if (raw.states !== undefined) {
1306
+ const states = reviewedStateKeyList(raw.states);
1307
+ if (states === null) return { kind: "unreviewed", keys: Object.freeze(["requestMetadata.states"]) };
1308
+ meta.states = states;
1309
+ }
1310
+ if (raw.screenId !== undefined) {
1311
+ if (!isOverlayContactScreen(raw.screenId)) {
1312
+ return { kind: "unreviewed", keys: Object.freeze(["requestMetadata.screenId"]) };
1313
+ }
1314
+ meta.screenId = LINKEDIN_CONTACT_DETAILS_OVERLAY_SCREEN_ID;
1315
+ }
1316
+ if (raw.knownTemplates !== undefined) {
1317
+ const templates = reviewedStateKeyList(raw.knownTemplates);
1318
+ if (templates === null) return { kind: "unreviewed", keys: Object.freeze(["requestMetadata.knownTemplates"]) };
1319
+ meta.knownTemplates = templates;
1320
+ }
1321
+ return Object.keys(meta).length === 0
1322
+ ? { kind: "empty" }
1323
+ : { kind: "meta", meta: Object.freeze(meta) };
1324
+ }
1325
+
1326
+ function headedContactRequestMetadata(
1327
+ meta?: LinkedInContactNavigationRequestMetadata,
1328
+ ): LinkedInContactNavigationRequestMetadata {
1329
+ return Object.freeze({
1330
+ $type: meta?.$type ?? LINKEDIN_CONTACT_NAVIGATION_REQUEST_METADATA_TYPE,
1331
+ states: meta?.states ?? Object.freeze([]),
1332
+ screenId: LINKEDIN_CONTACT_DETAILS_OVERLAY_SCREEN_ID,
1333
+ knownTemplates: meta?.knownTemplates ?? Object.freeze([]),
1334
+ });
1335
+ }
1336
+
1337
+ function canonicalContactNavigationPayload(
1338
+ payload: LinkedInContactNavigationPayload,
1339
+ ): LinkedInContactNavigationPayload {
1340
+ const next: {
1341
+ vanityName?: string;
1342
+ givenName?: string;
1343
+ familyName?: string;
1344
+ isVanityNameResolved?: boolean;
1345
+ requestMetadata: LinkedInContactNavigationRequestMetadata;
1346
+ } = {
1347
+ requestMetadata: headedContactRequestMetadata(payload.requestMetadata),
1348
+ };
1349
+ if (payload.vanityName !== undefined) next.vanityName = payload.vanityName;
1350
+ if (payload.givenName !== undefined) next.givenName = payload.givenName;
1351
+ if (payload.familyName !== undefined) next.familyName = payload.familyName;
1352
+ if (payload.isVanityNameResolved !== undefined) next.isVanityNameResolved = payload.isVanityNameResolved;
1353
+ return Object.freeze({
1354
+ ...(next.vanityName === undefined ? {} : { vanityName: next.vanityName }),
1355
+ ...(next.givenName === undefined ? {} : { givenName: next.givenName }),
1356
+ ...(next.familyName === undefined ? {} : { familyName: next.familyName }),
1357
+ ...(next.isVanityNameResolved === undefined ? {} : { isVanityNameResolved: next.isVanityNameResolved }),
1358
+ requestMetadata: next.requestMetadata,
1359
+ });
1360
+ }
1361
+
1362
+ function canonicalContactNavigationClientArguments(
1363
+ args: LinkedInContactNavigationClientArguments,
1364
+ ): LinkedInContactNavigationClientArguments {
1365
+ return Object.freeze({
1366
+ $type: args.$type ?? LINKEDIN_CONTACT_NAVIGATION_REQUESTED_ARGUMENTS_TYPE,
1367
+ requestedStateKeys: args.requestedStateKeys ?? Object.freeze([]),
1368
+ payload: canonicalContactNavigationPayload(args.payload),
1369
+ });
1370
+ }
1371
+
1372
+ function reviewedContactNavigationClientArguments(
1373
+ raw: JsonRecord | null,
1374
+ ):
1375
+ | { readonly kind: "args"; readonly args: LinkedInContactNavigationClientArguments }
1376
+ | { readonly kind: "unreviewed"; readonly keys: readonly string[] }
1377
+ | { readonly kind: "empty" }
1378
+ {
1379
+ if (raw === null) return { kind: "empty" };
1380
+ if (
1381
+ isRecord(raw.payload)
1382
+ || raw.$type !== undefined
1383
+ || raw.requestedStateKeys !== undefined
1384
+ || raw.requestMetadata !== undefined
1385
+ ) {
1386
+ const extra = Object.keys(raw).filter((key) => !CONTACT_NAVIGATION_CLIENT_ARGUMENT_KEY_SET.has(key));
1387
+ if (extra.length > 0) return { kind: "unreviewed", keys: Object.freeze(extra) };
1388
+ const args: {
1389
+ $type?: string;
1390
+ requestedStateKeys?: readonly string[];
1391
+ payload: LinkedInContactNavigationPayload;
1392
+ } = { payload: Object.freeze({}) };
1393
+ if (raw.$type !== undefined) {
1394
+ const type = reviewedSduiType(raw.$type);
1395
+ if (type === null) return { kind: "unreviewed", keys: Object.freeze(["$type"]) };
1396
+ args.$type = type;
1397
+ }
1398
+ if (raw.requestedStateKeys !== undefined) {
1399
+ const keys = reviewedStateKeyList(raw.requestedStateKeys);
1400
+ if (keys === null) return { kind: "unreviewed", keys: Object.freeze(["requestedStateKeys"]) };
1401
+ args.requestedStateKeys = keys;
1402
+ }
1403
+ const siblingMetadata = reviewedRequestMetadata(raw.requestMetadata);
1404
+ if (siblingMetadata.kind === "unreviewed") return siblingMetadata;
1405
+ let nestedMetadata:
1406
+ | { readonly kind: "meta"; readonly meta: LinkedInContactNavigationRequestMetadata }
1407
+ | { readonly kind: "unreviewed"; readonly keys: readonly string[] }
1408
+ | { readonly kind: "empty" } = { kind: "empty" };
1409
+ const payload: {
1410
+ vanityName?: string;
1411
+ givenName?: string;
1412
+ familyName?: string;
1413
+ isVanityNameResolved?: boolean;
1414
+ requestMetadata?: LinkedInContactNavigationRequestMetadata;
1415
+ } = {};
1416
+ if (raw.payload !== undefined) {
1417
+ if (!isRecord(raw.payload)) return { kind: "unreviewed", keys: Object.freeze(["payload"]) };
1418
+ const extraPayload = Object.keys(raw.payload)
1419
+ .filter((key) => key !== "requestMetadata" && !CONTACT_NAVIGATION_PAYLOAD_KEY_SET.has(key));
1420
+ if (extraPayload.length > 0) {
1421
+ return { kind: "unreviewed", keys: Object.freeze(extraPayload.map((key) => `payload.${key}`)) };
1422
+ }
1423
+ if (raw.payload.vanityName !== undefined) {
1424
+ const vanity = optionalPublicIdentifier(raw.payload.vanityName);
1425
+ if (vanity === null) return { kind: "unreviewed", keys: Object.freeze(["payload.vanityName"]) };
1426
+ payload.vanityName = vanity;
1427
+ }
1428
+ if (raw.payload.givenName !== undefined) {
1429
+ const givenName = optionalPersonName(raw.payload.givenName);
1430
+ if (givenName === null) return { kind: "unreviewed", keys: Object.freeze(["payload.givenName"]) };
1431
+ payload.givenName = givenName;
1432
+ }
1433
+ if (raw.payload.familyName !== undefined) {
1434
+ const familyName = optionalPersonName(raw.payload.familyName);
1435
+ if (familyName === null) return { kind: "unreviewed", keys: Object.freeze(["payload.familyName"]) };
1436
+ payload.familyName = familyName;
1437
+ }
1438
+ if (raw.payload.isVanityNameResolved !== undefined) {
1439
+ if (typeof raw.payload.isVanityNameResolved !== "boolean") {
1440
+ return { kind: "unreviewed", keys: Object.freeze(["payload.isVanityNameResolved"]) };
1441
+ }
1442
+ payload.isVanityNameResolved = raw.payload.isVanityNameResolved;
1443
+ }
1444
+ nestedMetadata = reviewedRequestMetadata(raw.payload.requestMetadata);
1445
+ if (nestedMetadata.kind === "unreviewed") {
1446
+ return {
1447
+ kind: "unreviewed",
1448
+ keys: Object.freeze(nestedMetadata.keys.map((key) => (
1449
+ key.startsWith("requestMetadata") ? `payload.${key}` : key
1450
+ ))),
1451
+ };
1452
+ }
1453
+ }
1454
+ if (siblingMetadata.kind === "meta" && nestedMetadata.kind === "meta") {
1455
+ if (!requestMetadataEquals(siblingMetadata.meta, nestedMetadata.meta)) {
1456
+ return { kind: "unreviewed", keys: Object.freeze(["requestMetadata"]) };
1457
+ }
1458
+ payload.requestMetadata = headedContactRequestMetadata(siblingMetadata.meta);
1459
+ } else if (siblingMetadata.kind === "meta") {
1460
+ payload.requestMetadata = headedContactRequestMetadata(siblingMetadata.meta);
1461
+ } else if (nestedMetadata.kind === "meta") {
1462
+ payload.requestMetadata = headedContactRequestMetadata(nestedMetadata.meta);
1463
+ }
1464
+ args.payload = Object.freeze(payload);
1465
+ return { kind: "args", args: canonicalContactNavigationClientArguments(Object.freeze(args)) };
1466
+ }
1467
+ const extra = Object.keys(raw).filter((key) => (
1468
+ key !== "vanityName"
1469
+ && key !== "publicIdentifier"
1470
+ && key !== "vanity"
1471
+ ));
1472
+ if (extra.length > 0) return { kind: "unreviewed", keys: Object.freeze(extra) };
1473
+ const vanity = optionalPublicIdentifier(raw.vanityName)
1474
+ ?? optionalPublicIdentifier(raw.publicIdentifier)
1475
+ ?? optionalPublicIdentifier(raw.vanity);
1476
+ return vanity === null
1477
+ ? { kind: "empty" }
1478
+ : { kind: "args", args: Object.freeze({ payload: Object.freeze({ vanityName: vanity }) }) };
1479
+ }
1480
+
1481
+ function clientArgumentsBindTarget(
1482
+ args: LinkedInContactNavigationClientArguments,
1483
+ publicIdentifier: string,
1484
+ ): boolean {
1485
+ const vanity = optionalPublicIdentifier(args.payload.vanityName);
1486
+ if (vanity !== null && vanity !== publicIdentifier) return false;
1487
+ return vanity === publicIdentifier || vanity === null;
1488
+ }
1489
+
1490
+ function actionNameLooksLikeNavigateToScreen(value: unknown): boolean {
1491
+ return typeof value === "string" && /NavigateToScreen/iu.test(value);
1492
+ }
1493
+
1494
+ function collectContactNavigationCandidates(
1495
+ records: readonly JsonRecord[],
1496
+ html: string,
1497
+ ): readonly {
1498
+ readonly sduiid: OptionalSduiid;
1499
+ readonly clientArguments: JsonRecord | null;
1500
+ }[] {
1501
+ const candidates: {
1502
+ sduiid: OptionalSduiid;
1503
+ clientArguments: JsonRecord | null;
1504
+ }[] = [];
1505
+ const push = (sduiid: OptionalSduiid, clientArguments: JsonRecord | null) => {
1506
+ candidates.push({ sduiid, clientArguments });
1507
+ };
1508
+ for (const record of records) {
1509
+ const href = parseContactNavigationHref(
1510
+ record.href ?? record.url ?? record.navigationUrl ?? record.canonicalUrl,
1511
+ );
1512
+ const screen = record.screenId
1513
+ ?? record.destinationScreen
1514
+ ?? record.screen
1515
+ ?? record.destination;
1516
+ const pageKey = record.pageKey ?? record.page_key;
1517
+ const overlay = isOverlayContactScreen(screen)
1518
+ || isContactDetailsPageKey(pageKey)
1519
+ || (href !== null && isOverlayContactScreen(href.screenId));
1520
+ const navigate = actionNameLooksLikeNavigateToScreen(
1521
+ record.actionName ?? record.action ?? record.$type ?? record.type,
1522
+ );
1523
+ const sduiid = optionalNavigationSduiid(record.sduiid ?? record.sduiId ?? record.sduiID);
1524
+ const hrefSduiid = href?.sduiid ?? { kind: "absent" as const };
1525
+ const args = peelContactNavigationClientArguments(record);
1526
+ if (!overlay) continue;
1527
+ if (
1528
+ !navigate
1529
+ && sduiid.kind === "absent"
1530
+ && hrefSduiid.kind === "absent"
1531
+ && args === null
1532
+ && href === null
1533
+ && !isContactDetailsPageKey(pageKey)
1534
+ ) continue;
1535
+ push(
1536
+ sduiid.kind !== "absent" ? sduiid : hrefSduiid,
1537
+ args,
1538
+ );
1539
+ }
1540
+ for (const match of html.matchAll(CONTACT_NAVIGATION_HREF)) {
1541
+ const href = parseContactNavigationHref(match[1] ?? match[0]);
1542
+ if (href === null || !isOverlayContactScreen(href.screenId)) continue;
1543
+ push(href.sduiid, null);
1544
+ }
1545
+ return Object.freeze(candidates);
1546
+ }
1547
+
1548
+ export function linkedInContactNavigationActionError(
1549
+ result: Exclude<LinkedInContactNavigationActionResult, { kind: "action" | "absent" }>,
1550
+ ): Error {
1551
+ if (result.kind === "omitted-sduiid") {
1552
+ return new Error("LinkedIn contact-info profile page omitted its Contact-info navigation sduiid");
1553
+ }
1554
+ if (result.kind === "unreviewed-sduiid") {
1555
+ return new Error(
1556
+ "LinkedIn contact-info navigation sduiid must equal ProfileContactDetailsOverlay",
1557
+ );
1558
+ }
1559
+ if (result.kind === "ambiguous-sduiid") {
1560
+ return new Error(
1561
+ "LinkedIn contact-info profile page exposed ambiguous Contact-info navigation sduiids",
1562
+ );
1563
+ }
1564
+ if (result.kind === "unreviewed-client-arguments") {
1565
+ return new Error(
1566
+ `LinkedIn contact-info navigation clientArguments used unreviewed keys: ${result.keys.join(", ")}`,
1567
+ );
1568
+ }
1569
+ return new Error(
1570
+ "LinkedIn contact-info navigation clientArguments did not bind the requested profile",
1571
+ );
1572
+ }
1573
+
1574
+ export function extractLinkedInContactNavigationAction(input: {
1575
+ readonly profileHtml: unknown;
1576
+ readonly publicIdentifier: string;
1577
+ readonly profileUrn: string;
1578
+ }): LinkedInContactNavigationActionResult {
1579
+ const publicIdentifier = linkedInPersonalProfilePublicIdentifier(input.publicIdentifier);
1580
+ profileUrn(input.profileUrn);
1581
+ const html = typeof input.profileHtml === "string" ? input.profileHtml : "";
1582
+ const records = embeddedRecords(input.profileHtml);
1583
+ const candidates = collectContactNavigationCandidates(records, html);
1584
+ if (candidates.length < 1) return { kind: "absent" };
1585
+ if (candidates.some((candidate) => candidate.sduiid.kind === "unreviewed")) {
1586
+ return { kind: "unreviewed-sduiid" };
1587
+ }
1588
+ const sduiids = [...new Set(
1589
+ candidates
1590
+ .map((candidate) => candidate.sduiid.kind === "reviewed" ? candidate.sduiid.sduiid : undefined)
1591
+ .filter((value): value is string => value !== undefined),
1592
+ )];
1593
+ if (sduiids.length > 1) return { kind: "ambiguous-sduiid" };
1594
+ let reviewed:
1595
+ | { readonly kind: "args"; readonly args: LinkedInContactNavigationClientArguments }
1596
+ | { readonly kind: "empty" }
1597
+ | undefined;
1598
+ for (const candidate of candidates) {
1599
+ const peeled = reviewedContactNavigationClientArguments(candidate.clientArguments);
1600
+ if (peeled.kind === "unreviewed") {
1601
+ return { kind: "unreviewed-client-arguments", keys: peeled.keys };
1602
+ }
1603
+ if (peeled.kind === "args") {
1604
+ if (!clientArgumentsBindTarget(peeled.args, publicIdentifier)) {
1605
+ return { kind: "unbound-client-arguments" };
1606
+ }
1607
+ reviewed = peeled;
1608
+ }
1609
+ }
1610
+ const clientArguments = reviewed?.kind === "args"
1611
+ ? canonicalContactNavigationClientArguments({
1612
+ ...reviewed.args,
1613
+ payload: {
1614
+ ...reviewed.args.payload,
1615
+ vanityName: reviewed.args.payload.vanityName ?? publicIdentifier,
1616
+ },
1617
+ })
1618
+ : canonicalContactNavigationClientArguments({
1619
+ payload: { vanityName: publicIdentifier },
1620
+ });
1621
+ if (!clientArgumentsBindTarget(clientArguments, publicIdentifier)) {
1622
+ return { kind: "unbound-client-arguments" };
1623
+ }
1624
+ return {
1625
+ kind: "action",
1626
+ action: Object.freeze({
1627
+ sduiid: LINKEDIN_CONTACT_DETAILS_OVERLAY_SCREEN_ID,
1628
+ clientArguments,
1629
+ isModal: true,
1630
+ }),
1631
+ };
1632
+ }
1633
+
811
1634
  export function projectLinkedInEmbeddedContactFields(
812
1635
  html: unknown,
813
1636
  vanity: string,
814
1637
  ): LinkedInContactFields | undefined {
815
- const records = embeddedRecords(html);
816
- const entities = collectRecords(records).filter(contactEntity);
817
- if (entities.length < 1) return undefined;
818
- return projectFields(records, vanity);
1638
+ try {
1639
+ const records = embeddedRecords(html);
1640
+ const entities = collectRecords(records).filter(contactEntity);
1641
+ if (entities.length < 1) return undefined;
1642
+ return projectFields(records, vanity);
1643
+ } catch (error) {
1644
+ if (error instanceof Error && error.message === LINKEDIN_CONTACT_INFO_FIELD_LABEL_ERROR) {
1645
+ return undefined;
1646
+ }
1647
+ throw error;
1648
+ }
1649
+ }
1650
+
1651
+ function overlayMailtoFields(value: string): JsonRecord | null {
1652
+ const emails = [...value.matchAll(/mailto:([A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,24})/giu)]
1653
+ .map((match) => match[1]?.toLowerCase())
1654
+ .filter((item): item is string => item !== undefined);
1655
+ const unique = [...new Set(emails)];
1656
+ if (unique.length !== 1) return null;
1657
+ return Object.freeze({ emailAddress: unique[0] });
1658
+ }
1659
+
1660
+ function overlayTextPairFields(value: readonly unknown[]): JsonRecord | null {
1661
+ const texts: string[] = [];
1662
+ for (const item of value) {
1663
+ if (typeof item === "string") {
1664
+ const text = item.trim();
1665
+ if (text.length > 0 && text.length <= 2_048) texts.push(text);
1666
+ continue;
1667
+ }
1668
+ if (!isRecord(item)) continue;
1669
+ const text = item.text ?? item.value ?? item.title ?? item.label;
1670
+ if (typeof text === "string") {
1671
+ const trimmed = text.trim();
1672
+ if (trimmed.length > 0 && trimmed.length <= 2_048) texts.push(trimmed);
1673
+ }
1674
+ const href = item.href ?? item.url;
1675
+ if (typeof href === "string" && href.toLowerCase().startsWith("mailto:")) {
1676
+ texts.push(href);
1677
+ }
1678
+ }
1679
+ if (texts.length < 2) return null;
1680
+ const collected: {
1681
+ email?: string;
1682
+ connectedSince?: string;
1683
+ phones: string[];
1684
+ websites: string[];
1685
+ birthday?: string;
1686
+ } = { phones: [], websites: [] };
1687
+ for (let index = 0; index < texts.length - 1; index += 1) {
1688
+ const label = texts[index];
1689
+ const raw = texts[index + 1];
1690
+ if (label === undefined || raw === undefined) continue;
1691
+ const key = optionalLabelKey(label);
1692
+ if (key === undefined) continue;
1693
+ if (key === "email") {
1694
+ const email = optionalEmail(
1695
+ raw.toLowerCase().startsWith("mailto:") ? raw.slice("mailto:".length) : raw,
1696
+ "LinkedIn contact-info Email",
1697
+ );
1698
+ if (email !== null) collected.email = email;
1699
+ continue;
1700
+ }
1701
+ if (key === "connected since") {
1702
+ const connected = connectedSinceValue(raw);
1703
+ if (connected !== null) collected.connectedSince = connected;
1704
+ }
1705
+ }
1706
+ if (collected.email === undefined && collected.connectedSince === undefined) return null;
1707
+ return Object.freeze({
1708
+ emailAddress: collected.email ?? null,
1709
+ connectedAt: collected.connectedSince ?? null,
1710
+ phoneNumbers: collected.phones,
1711
+ websites: collected.websites,
1712
+ birthDateOn: collected.birthday ?? null,
1713
+ });
1714
+ }
1715
+
1716
+ function normalizeOverlayPayload(value: unknown): unknown {
1717
+ if (typeof value !== "string") return value;
1718
+ if (value.length < 1 || value.length > MAX_HTML_BYTES) {
1719
+ throw new Error("LinkedIn contact-info overlay payload exceeded its reviewed bound");
1720
+ }
1721
+ const trimmed = value.trim();
1722
+ if (looksLikeRscFlight(trimmed)) return decodeRscFlightRecords(trimmed);
1723
+ if (trimmed.startsWith("{") || trimmed.startsWith("[")) return parseJsonRoot(trimmed);
1724
+ const mailto = overlayMailtoFields(trimmed);
1725
+ return mailto === null ? trimmed : [mailto];
1726
+ }
1727
+
1728
+ const LINKEDIN_CONTACT_OVERLAY_OMITTED_FIELDS =
1729
+ "LinkedIn contact-info overlay omitted its contact fields";
1730
+
1731
+ function overlayHasProjectedField(fields: LinkedInContactFields): boolean {
1732
+ return fields.email !== null
1733
+ || fields.connectedSince !== null
1734
+ || fields.phones.length > 0
1735
+ || fields.websites.length > 0
1736
+ || fields.birthday !== null;
1737
+ }
1738
+
1739
+ function leftoverOverlayMailtoEmails(value: string): string[] {
1740
+ return [...new Set(
1741
+ [...value.matchAll(/mailto:([A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,24})/giu)]
1742
+ .map((match) => match[1]?.toLowerCase())
1743
+ .filter((item): item is string => item !== undefined),
1744
+ )];
1745
+ }
1746
+
1747
+ function projectOverlayContactFields(
1748
+ overlay: unknown,
1749
+ vanity: string,
1750
+ ): LinkedInContactFields {
1751
+ const normalized = normalizeOverlayPayload(overlay);
1752
+ if (typeof normalized !== "string") return projectFields(normalized, vanity);
1753
+ if (leftoverOverlayMailtoEmails(normalized).length > 1) {
1754
+ throw new Error("LinkedIn contact-info email was ambiguous");
1755
+ }
1756
+ try {
1757
+ const embedded = projectLinkedInEmbeddedContactFields(normalized, vanity);
1758
+ if (embedded !== undefined && overlayHasProjectedField(embedded)) return embedded;
1759
+ } catch (error) {
1760
+ if (
1761
+ !(error instanceof Error)
1762
+ || !/bootstrap|HTML bound|too many code payloads/u.test(error.message)
1763
+ ) {
1764
+ throw error;
1765
+ }
1766
+ }
1767
+ throw new Error(LINKEDIN_CONTACT_OVERLAY_OMITTED_FIELDS);
1768
+ }
1769
+
1770
+ export function projectLinkedInOverlayContactFields(
1771
+ overlay: unknown,
1772
+ vanity: string,
1773
+ ): LinkedInContactFields {
1774
+ return projectOverlayContactFields(overlay, vanity);
819
1775
  }
820
1776
 
821
1777
  function oneUnique<T>(
@@ -965,8 +1921,13 @@ function websitesFromUnknown(value: unknown): readonly string[] {
965
1921
  }));
966
1922
  }
967
1923
 
968
- function labelKey(value: unknown): string {
969
- return boundedText(value, "LinkedIn contact-info field label", 64).trim().toLowerCase();
1924
+ function optionalLabelKey(value: unknown): string | undefined {
1925
+ if (typeof value !== "string") return undefined;
1926
+ const trimmed = value.trim();
1927
+ if (trimmed.length < 1 || trimmed.length > 64 || /[\0\r]/u.test(trimmed)) {
1928
+ return undefined;
1929
+ }
1930
+ return trimmed.toLowerCase();
970
1931
  }
971
1932
 
972
1933
  function labeledFields(value: unknown): JsonRecord | null {
@@ -984,7 +1945,8 @@ function labeledFields(value: unknown): JsonRecord | null {
984
1945
  const label = item.label ?? item.title ?? item.heading;
985
1946
  const raw = item.value ?? item.text ?? item.href;
986
1947
  if (typeof label !== "string") continue;
987
- const key = labelKey(label);
1948
+ const key = optionalLabelKey(label);
1949
+ if (key === undefined) continue;
988
1950
  if (key === "email") {
989
1951
  const email = optionalEmail(raw, "LinkedIn contact-info Email");
990
1952
  if (email !== null) collected.email = email;
@@ -1049,15 +2011,25 @@ function collectRecords(value: unknown): readonly JsonRecord[] {
1049
2011
  if (next.value.length > 20_000) {
1050
2012
  throw new Error("LinkedIn contact-info payload array exceeded its reviewed bound");
1051
2013
  }
1052
- const labeled = labeledFields(next.value);
2014
+ const labeled = labeledFields(next.value) ?? overlayTextPairFields(next.value);
1053
2015
  if (labeled !== null) records.push(labeled);
1054
2016
  for (const item of next.value) stack.push({ value: item, depth: next.depth + 1 });
1055
2017
  continue;
1056
2018
  }
1057
2019
  if (!isRecord(next.value)) continue;
1058
2020
  records.push(next.value);
1059
- const nestedLabels = labeledFields(next.value.fields ?? next.value.items ?? next.value.rows);
2021
+ const nestedLabels = labeledFields(next.value.fields ?? next.value.items ?? next.value.rows)
2022
+ ?? overlayTextPairFields(
2023
+ Array.isArray(next.value.fields) ? next.value.fields
2024
+ : Array.isArray(next.value.items) ? next.value.items
2025
+ : Array.isArray(next.value.rows) ? next.value.rows
2026
+ : [],
2027
+ );
1060
2028
  if (nestedLabels !== null) records.push(nestedLabels);
2029
+ if (typeof next.value.href === "string" || typeof next.value.url === "string") {
2030
+ const mailto = overlayMailtoFields(String(next.value.href ?? next.value.url));
2031
+ if (mailto !== null) records.push(mailto);
2032
+ }
1061
2033
  for (const item of Object.values(next.value)) {
1062
2034
  stack.push({ value: item, depth: next.depth + 1 });
1063
2035
  }
@@ -1116,7 +2088,7 @@ export function projectLinkedInContactInfo(input: {
1116
2088
  profileUrl: input.profileUrl,
1117
2089
  expectedViewerSubject: input.expectedViewerSubject,
1118
2090
  });
1119
- const contact = projectFields(input.contactPayload, binding.vanity);
2091
+ const contact = projectOverlayContactFields(input.contactPayload, binding.vanity);
1120
2092
  const hasAny = contact.email !== null
1121
2093
  || contact.connectedSince !== null
1122
2094
  || contact.phones.length > 0