@hraness/ghostget 0.17.6 → 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 (114) hide show
  1. package/CHANGELOG.md +301 -0
  2. package/README.md +16 -9
  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-cytf8d9p.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 +147 -0
  23. package/docs/imessage-direct-provider.md +31 -7
  24. package/docs/messaging-automation.md +103 -0
  25. package/package.json +66 -7
  26. package/skills/ghostget/SKILL.md +3 -1
  27. package/skills/ghostget/references/control-panel.md +43 -0
  28. package/skills/ghostget/references/install.md +5 -5
  29. package/skills/ghostget/references/linkedin-adapter.md +222 -12
  30. package/skills/ghostget/references/platform-patterns.md +1 -1
  31. package/src/args.ts +18 -5
  32. package/src/assets/adapters/imessage/wrench-web-adapter.json +126 -0
  33. package/src/assets/adapters/linkedin/wrench-web-adapter.json +1 -1
  34. package/src/assets/adapters/whatsapp/wrench-web-adapter.json +144 -0
  35. package/src/assets/messaging-runtime/NOTICE.txt +2580 -0
  36. package/src/assets/messaging-runtime/imsg-darwin-arm64.gz +0 -0
  37. package/src/assets/messaging-runtime/phone-number-info.plist.gz +0 -0
  38. package/src/assets/messaging-runtime/phone-number-metadata.json.gz +0 -0
  39. package/src/assets/messaging-runtime/phone-number-privacy.plist.gz +0 -0
  40. package/src/assets/messaging-runtime/wacli-darwin-arm64.gz +0 -0
  41. package/src/auth.ts +35 -1
  42. package/src/beeper-client-types.ts +1 -1
  43. package/src/cli.ts +18 -0
  44. package/src/confirmed-write-platform.ts +16 -1
  45. package/src/control/account-revision.ts +16 -0
  46. package/src/control/activity.ts +104 -0
  47. package/src/control/approval-broker.ts +59 -0
  48. package/src/control/approval-client.ts +49 -0
  49. package/src/control/bundled-interfaces.ts +20 -0
  50. package/src/control/cli.ts +20 -0
  51. package/src/control/connections.ts +87 -0
  52. package/src/control/credential-helper.ts +152 -0
  53. package/src/control/helper.ts +79 -0
  54. package/src/control/interface-cli.ts +22 -0
  55. package/src/control/interface-json.ts +94 -0
  56. package/src/control/interface-schema.ts +120 -0
  57. package/src/control/interfaces.ts +438 -0
  58. package/src/control/protocol.ts +182 -0
  59. package/src/control/service.ts +104 -0
  60. package/src/control/validation.ts +103 -0
  61. package/src/control/vault.ts +105 -0
  62. package/src/control/web-gateway.ts +62 -0
  63. package/src/control/web-policy.ts +56 -0
  64. package/src/ghostget.ts +18 -4
  65. package/src/messaging-automation-api.ts +12 -0
  66. package/src/messaging-automation-descriptors.ts +29 -0
  67. package/src/messaging-automation-factory.ts +148 -0
  68. package/src/messaging-automation-server.ts +183 -0
  69. package/src/messaging-automation-types.ts +155 -0
  70. package/src/messaging-automation-validation.ts +110 -0
  71. package/src/messaging-automation.ts +356 -0
  72. package/src/messaging-runtime.ts +3 -0
  73. package/src/oauth-google.ts +11 -5
  74. package/src/omni-runtime.ts +18 -3
  75. package/src/operation-permission-store.ts +92 -0
  76. package/src/operation-permission.ts +308 -0
  77. package/src/pinned-https.ts +5 -0
  78. package/src/plugins/imessage-direct/plugin.ts +12 -1
  79. package/src/plugins/imessage-direct/vendor/0003-feat-rpc-add-no-fetch-rich-cards.patch +276 -0
  80. package/src/plugins/imessage-direct/vendor/provenance.json +84 -10
  81. package/src/plugins/linkedin-web/plugin.ts +1 -1
  82. package/src/plugins/whatsapp-linked-device/plugin.ts +7 -2
  83. package/src/plugins/whatsapp-linked-device/vendor/0001-ghostget-private-messaging.patch +1093 -0
  84. package/src/plugins/whatsapp-linked-device/vendor/README.md +39 -0
  85. package/src/plugins/whatsapp-linked-device/vendor/provenance.json +45 -0
  86. package/src/provider-http.ts +11 -3
  87. package/src/provider-plugin-contract-identity.ts +2 -2
  88. package/src/provider-plugin-import-analysis.ts +52 -0
  89. package/src/provider-plugin-module-analysis.ts +21 -1
  90. package/src/provider-plugin-registry.ts +4 -8
  91. package/src/provider-plugin.ts +18 -8
  92. package/src/providers/imessage-automation.ts +250 -0
  93. package/src/providers/imessage-direct-install.ts +7 -0
  94. package/src/providers/imessage-direct-runtime.ts +32 -2
  95. package/src/providers/imessage-direct.ts +13 -3
  96. package/src/providers/linkedin-contact-failure.ts +21 -0
  97. package/src/providers/linkedin-contact-platform.ts +8 -1
  98. package/src/providers/linkedin-contact-program.ts +34 -5
  99. package/src/providers/linkedin-web-contact.ts +1223 -35
  100. package/src/providers/linkedin-web-profile-browser.ts +659 -45
  101. package/src/providers/linkedin-web-runtime.ts +1 -0
  102. package/src/providers/linkedin-web.ts +46 -2
  103. package/src/providers/messaging-native-artifacts.ts +38 -0
  104. package/src/providers/messaging-native-install.ts +75 -0
  105. package/src/providers/whatsapp-automation-runtime.ts +240 -0
  106. package/src/providers/whatsapp-automation.ts +153 -0
  107. package/src/read-client.ts +12 -2
  108. package/src/runtime.ts +81 -9
  109. package/src/state-helper.ts +2 -0
  110. package/src/storage.ts +71 -1
  111. package/src/usage.ts +9 -1
  112. package/src/version.ts +1 -1
  113. package/src/web-session-contract-definitions.ts +1 -1
  114. 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;
@@ -42,7 +96,33 @@ const MAX_CODE_TAGS = 256;
42
96
  const MAX_COMO_ASSIGNMENTS = 8;
43
97
  const MAX_COMO_DECODED_ROOTS = 256;
44
98
  const MAX_WALK_NODES = 500_000;
99
+ const MAX_WALK_DEPTH = 128;
45
100
  const RSC_FLIGHT_ROW = /(?:^|\n)(\d+):/u;
101
+ const PROFILE_ID = /^[A-Za-z0-9_-]{1,256}$/u;
102
+ const DISTANCE_IN_TEXT =
103
+ /(?:^|["'\\{,])(?:networkDistance|memberDistance|distance)\\*"?\s*:\s*\\*"?(DISTANCE_[A-Z0-9]+|OUT_OF_NETWORK|SELF|[0-9]+)\\*"?/gu;
104
+ const PROFILE_URN_IN_TEXT = /urn:li:fsd_profile:[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;
110
+ const VANITY_IN_TEXT =
111
+ /(?:vanityName|publicIdentifier)\\*"?\s*:\s*\\*"?([A-Za-z0-9][A-Za-z0-9_-]{1,99})/gu;
112
+ const MAX_JSON_STRING_PEELS = 4;
113
+ const KEYED_IDENTITY_KEYS = new Set([
114
+ "distance",
115
+ "entityUrn",
116
+ "isSelfView",
117
+ "memberDistance",
118
+ "networkDistance",
119
+ "objectUrn",
120
+ "profileUrn",
121
+ "publicIdentifier",
122
+ "vanityName",
123
+ "vieweeMemberUrn",
124
+ "vieweeProfileId",
125
+ ]);
46
126
  const MAX_PHONES = 8;
47
127
  const MAX_WEBSITES = 8;
48
128
  const CONTACT_TYPE_SUFFIX = "ProfileContactInfo";
@@ -83,6 +163,49 @@ export type LinkedInContactInfoJsonInput = {
83
163
  readonly queryId?: string;
84
164
  };
85
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
+
86
209
  type JsonRecord = Readonly<Record<string, unknown>>;
87
210
 
88
211
  function isRecord(value: unknown): value is JsonRecord {
@@ -153,6 +276,82 @@ export function linkedInProfileContactInfoGraphqlUrl(input: {
153
276
  return new URL(buildLinkedInProfileContactInfoGraphqlPath(input), LINKEDIN_ORIGIN);
154
277
  }
155
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
+
156
355
  export function resolveLinkedInProfileContactInfoQueryId(html: unknown): string | undefined {
157
356
  if (typeof html !== "string" || html.length < 1 || html.length > MAX_HTML_BYTES) {
158
357
  return undefined;
@@ -167,6 +366,51 @@ export function resolveLinkedInProfileContactInfoQueryId(html: unknown): string
167
366
  return unique.values().next().value;
168
367
  }
169
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
+
170
414
  function assertLinkedInContactInfoGraphqlUrl(url: URL): void {
171
415
  const queryNames = [...url.searchParams.keys()];
172
416
  const queryKey = queryNames[1];
@@ -199,7 +443,9 @@ export function assertLinkedInContactInfoRequest(requestValue: unknown): void {
199
443
  }
200
444
  const method = boundedText(requestValue.method, "LinkedIn contact-info request method", 16)
201
445
  .toUpperCase();
202
- 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
+ }
203
449
  const rawUrl = requestValue.url;
204
450
  if (!(rawUrl instanceof URL) && typeof rawUrl !== "string") {
205
451
  throw new Error("LinkedIn contact-info request URL is invalid");
@@ -211,6 +457,13 @@ export function assertLinkedInContactInfoRequest(requestValue: unknown): void {
211
457
  && url.password === ""
212
458
  && url.hash === ""
213
459
  ) {
460
+ if (method === "POST") {
461
+ assertLinkedInContactInfoNavigationPostUrl(url);
462
+ if (requestValue.body !== undefined) {
463
+ assertLinkedInContactInfoNavigationBody(requestValue.body);
464
+ }
465
+ return;
466
+ }
214
467
  if (url.search === "" && url.pathname === "/voyager/api/me") return;
215
468
  if (url.search === "" && /^\/in\/[A-Za-z0-9][A-Za-z0-9_-]{1,99}\/$/u.test(url.pathname)) {
216
469
  linkedInPersonalProfileTarget(url.href);
@@ -220,6 +473,10 @@ export function assertLinkedInContactInfoRequest(requestValue: unknown): void {
220
473
  assertLinkedInContactInfoGraphqlUrl(url);
221
474
  return;
222
475
  }
476
+ if (LINKEDIN_CONTACT_INFO_OVERLAY_PATH.test(url.pathname)) {
477
+ assertLinkedInContactInfoOverlayUrl(url);
478
+ return;
479
+ }
223
480
  }
224
481
  throw new Error("LinkedIn contact-info request escaped its exact reviewed route");
225
482
  }
@@ -315,6 +572,171 @@ function looksLikeRscFlight(value: string): boolean {
315
572
  return RSC_FLIGHT_ROW.test(value);
316
573
  }
317
574
 
575
+ function extractJsonString(source: string, start: number): string | undefined {
576
+ if (source[start] !== "\"") return undefined;
577
+ let escaped = false;
578
+ const limit = Math.min(source.length, start + MAX_HTML_BYTES);
579
+ for (let index = start + 1; index < limit; index += 1) {
580
+ const character = source[index];
581
+ if (character === undefined) break;
582
+ if (escaped) {
583
+ escaped = false;
584
+ continue;
585
+ }
586
+ if (character === "\\") {
587
+ escaped = true;
588
+ continue;
589
+ }
590
+ if (character === "\"") return source.slice(start, index + 1);
591
+ }
592
+ return undefined;
593
+ }
594
+
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 {
651
+ const distances = [...value.matchAll(DISTANCE_IN_TEXT)]
652
+ .map((match) => match[1])
653
+ .filter((item): item is string => item !== undefined);
654
+ const urns = [...value.matchAll(PROFILE_URN_IN_TEXT)]
655
+ .map((match) => match[0])
656
+ .filter((item): item is string => item !== undefined);
657
+ const vieweeIds = [...value.matchAll(VIEWEE_PROFILE_ID_IN_TEXT)]
658
+ .map((match) => match[1])
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);
663
+ const vanities = [...value.matchAll(VANITY_IN_TEXT)]
664
+ .map((match) => match[1])
665
+ .filter((item): item is string => item !== undefined);
666
+ if (
667
+ distances.length === 0
668
+ && urns.length === 0
669
+ && vieweeIds.length === 0
670
+ && vieweeMembers.length === 0
671
+ && vanities.length === 0
672
+ ) return null;
673
+ const record: Record<string, unknown> = {};
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;
683
+ }
684
+ if (vieweeIds.length === 1) record.vieweeProfileId = vieweeIds[0];
685
+ if (vanities.length === 1) record.vanityName = vanities[0];
686
+ return Object.keys(record).length > 0 ? Object.freeze(record) : null;
687
+ }
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
+
697
+ function stringLooksLikeBootstrap(value: string): boolean {
698
+ return value.includes("networkDistance")
699
+ || value.includes("memberDistance")
700
+ || value.includes("vieweeProfileId")
701
+ || value.includes("vieweeMemberUrn")
702
+ || value.includes("vanityName")
703
+ || value.includes("publicIdentifier")
704
+ || /"distance"\s*:/u.test(value);
705
+ }
706
+
707
+ function decodeStringBootstrap(value: string): unknown[] {
708
+ const trimmed = value.trim();
709
+ if (trimmed.length < 8 || trimmed.length > 1024 * 1024) return [];
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
+ }
718
+ }
719
+ }
720
+ if (!stringLooksLikeBootstrap(trimmed) && !stringLooksLikeBootstrap(peeled)) {
721
+ return [];
722
+ }
723
+ const breadcrumb = breadcrumbRecordFromText(peeled);
724
+ return breadcrumb === null ? [] : [breadcrumb];
725
+ }
726
+
727
+ function recordFromKeyedArray(value: readonly unknown[]): JsonRecord | null {
728
+ if (value.length < 2 || value.length % 2 !== 0 || value.length > 64) return null;
729
+ const record: Record<string, unknown> = {};
730
+ let recognized = 0;
731
+ for (let index = 0; index < value.length; index += 2) {
732
+ const key = value[index];
733
+ if (typeof key !== "string" || key.length < 1 || key.length > 128) return null;
734
+ record[key] = value[index + 1];
735
+ if (KEYED_IDENTITY_KEYS.has(key)) recognized += 1;
736
+ }
737
+ return recognized > 0 ? Object.freeze(record) : null;
738
+ }
739
+
318
740
  function decodeRscFlightRecords(text: string): unknown[] {
319
741
  const records: unknown[] = [];
320
742
  let index = 0;
@@ -334,7 +756,28 @@ function decodeRscFlightRecords(text: string): unknown[] {
334
756
  index += json.length;
335
757
  continue;
336
758
  }
337
- const nextRow = text.slice(index).search(/\n\d+:/u);
759
+ if (first === "\"") {
760
+ const json = extractJsonString(text, index);
761
+ if (json === undefined) {
762
+ index += 1;
763
+ continue;
764
+ }
765
+ try {
766
+ const decoded = JSON.parse(json) as unknown;
767
+ if (typeof decoded === "string") records.push(...decodeStringBootstrap(decoded));
768
+ else records.push(decoded);
769
+ } catch {
770
+ const breadcrumb = breadcrumbRecordFromText(json);
771
+ if (breadcrumb !== null) records.push(breadcrumb);
772
+ }
773
+ index += json.length;
774
+ continue;
775
+ }
776
+ const remainder = text.slice(index);
777
+ const nextRow = remainder.search(/\n\d+:/u);
778
+ const rowText = nextRow === -1 ? remainder : remainder.slice(0, nextRow);
779
+ const breadcrumb = breadcrumbRecordFromText(rowText);
780
+ if (breadcrumb !== null) records.push(breadcrumb);
338
781
  index = nextRow === -1 ? text.length : index + nextRow + 1;
339
782
  }
340
783
  return records;
@@ -342,7 +785,9 @@ function decodeRscFlightRecords(text: string): unknown[] {
342
785
 
343
786
  function decodeComoRehydrationValue(value: unknown): unknown[] {
344
787
  if (typeof value === "string") {
345
- return looksLikeRscFlight(value) ? decodeRscFlightRecords(value) : [];
788
+ return looksLikeRscFlight(value)
789
+ ? decodeRscFlightRecords(value)
790
+ : decodeStringBootstrap(value);
346
791
  }
347
792
  if (Array.isArray(value)) {
348
793
  if (value.length > 20_000) {
@@ -433,13 +878,21 @@ function embeddedRecords(html: unknown): readonly JsonRecord[] {
433
878
  while (stack.length > 0) {
434
879
  const next = stack.pop()!;
435
880
  nodes += 1;
436
- if (nodes > MAX_WALK_NODES || next.depth > 32) {
881
+ if (nodes > MAX_WALK_NODES || next.depth > MAX_WALK_DEPTH) {
437
882
  throw new Error("LinkedIn contact-info bootstrap exceeded its traversal bound");
438
883
  }
884
+ if (typeof next.value === "string") {
885
+ for (const value of decodeStringBootstrap(next.value)) {
886
+ stack.push({ value, depth: next.depth + 1 });
887
+ }
888
+ continue;
889
+ }
439
890
  if (Array.isArray(next.value)) {
440
891
  if (next.value.length > 20_000) {
441
892
  throw new Error("LinkedIn contact-info bootstrap array exceeded its reviewed bound");
442
893
  }
894
+ const keyed = recordFromKeyedArray(next.value);
895
+ if (keyed !== null) records.push(keyed);
443
896
  for (const value of next.value) stack.push({ value, depth: next.depth + 1 });
444
897
  continue;
445
898
  }
@@ -505,25 +958,160 @@ function vanityFromRecord(record: JsonRecord): string | null {
505
958
  ?? vanityFromHref(record.navigationUrl);
506
959
  }
507
960
 
508
- function profileUrnFromRecord(record: JsonRecord): string | null {
509
- for (const key of ["entityUrn", "objectUrn", "profileUrn", "vieweeMemberUrn"] as const) {
510
- const value = record[key];
511
- if (typeof value !== "string") continue;
961
+ function profileUrnFromIdentity(value: unknown): string | null {
962
+ if (typeof value !== "string" || value.length < 1 || value.length > 512) return null;
963
+ if (value.startsWith("urn:li:fsd_profile:")) {
512
964
  try {
513
965
  return profileUrn(value);
514
966
  } catch {
515
- continue;
967
+ return null;
516
968
  }
517
969
  }
970
+ if (!PROFILE_ID.test(value)) return null;
971
+ try {
972
+ return profileUrn(`urn:li:fsd_profile:${value}`);
973
+ } catch {
974
+ return null;
975
+ }
976
+ }
977
+
978
+ function profileUrnFromRecord(record: JsonRecord): string | null {
979
+ for (const key of ["entityUrn", "objectUrn", "profileUrn", "vieweeMemberUrn"] as const) {
980
+ const urn = profileUrnFromIdentity(record[key]);
981
+ if (urn !== null) return urn;
982
+ }
983
+ return profileUrnFromIdentity(record.vieweeProfileId);
984
+ }
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
+ }
518
1001
  return null;
519
1002
  }
520
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
+
1012
+ function recordIsSelfView(record: JsonRecord): boolean {
1013
+ return record.isSelfView === true;
1014
+ }
1015
+
521
1016
  function selfProfileError(): Error {
522
1017
  return new Error(
523
1018
  "LinkedIn contacts.read reads one 1st-degree connection; use profiles.read for the signed-in self profile",
524
1019
  );
525
1020
  }
526
1021
 
1022
+ function omittedDistanceError(): Error {
1023
+ return new Error(
1024
+ "LinkedIn contact-info profile page omitted or contradicted its relationship distance",
1025
+ );
1026
+ }
1027
+
1028
+ function uniqueDistances(values: readonly (string | number)[]): (string | number)[] {
1029
+ const unique = [...new Set(values.map((value) => String(value)))];
1030
+ return unique.map((value) => values.find((candidate) => String(candidate) === value)!);
1031
+ }
1032
+
1033
+ function recordMatchesTarget(
1034
+ record: JsonRecord,
1035
+ slug: string,
1036
+ urn: string,
1037
+ ): boolean {
1038
+ const vanity = vanityFromRecord(record);
1039
+ const recordUrn = profileUrnFromRecord(record);
1040
+ return vanity === slug || recordUrn === urn;
1041
+ }
1042
+
1043
+ function recordContradictsTarget(
1044
+ record: JsonRecord,
1045
+ slug: string,
1046
+ urn: string,
1047
+ ): boolean {
1048
+ const vanity = vanityFromRecord(record);
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];
1069
+ }
1070
+
1071
+ function relationshipDistance(
1072
+ records: readonly JsonRecord[],
1073
+ slug: string,
1074
+ urn: string,
1075
+ viewer: string,
1076
+ ): string | number {
1077
+ const bound = records
1078
+ .filter((record) => recordMatchesTarget(record, slug, urn))
1079
+ .map(distanceValue)
1080
+ .filter((value): value is string | number => value !== null);
1081
+ const boundDistance = uniqueNonSelfDistance(bound);
1082
+ if (boundDistance !== undefined) return boundDistance;
1083
+ if (bound.some(isSelfDistance) && urn === viewer) throw selfProfileError();
1084
+
1085
+ const vieweeJoined = records
1086
+ .filter((record) => (
1087
+ !recordContradictsTarget(record, slug, urn)
1088
+ && !recordIsViewerViewee(record, viewer)
1089
+ && vieweeMemberIdentity(record) !== null
1090
+ ))
1091
+ .map(distanceValue)
1092
+ .filter((value): value is string | number => value !== null);
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();
1097
+ }
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)))) {
1110
+ throw selfProfileError();
1111
+ }
1112
+ throw omittedDistanceError();
1113
+ }
1114
+
527
1115
  export function projectLinkedInProfileContactBinding(input: {
528
1116
  readonly profileHtml: unknown;
529
1117
  readonly profileUrl: unknown;
@@ -536,18 +1124,23 @@ export function projectLinkedInProfileContactBinding(input: {
536
1124
  if (vanityRecords.length < 1) {
537
1125
  throw new Error("LinkedIn contact-info profile page did not bind the requested vanity");
538
1126
  }
1127
+ if (vanityRecords.some(recordIsSelfView)) throw selfProfileError();
539
1128
  const urns = new Set<string>();
540
1129
  for (const record of vanityRecords) {
541
- const urn = profileUrnFromRecord(record);
1130
+ const urn = durableProfileUrnFromRecord(record);
542
1131
  if (urn !== null) urns.add(urn);
543
1132
  }
544
1133
  if (urns.size < 1) {
545
1134
  const joined = new Set<string>();
546
1135
  let sawSelf = false;
547
1136
  for (const record of records) {
548
- const urn = profileUrnFromRecord(record);
1137
+ const urn = durableProfileUrnFromRecord(record);
549
1138
  const distance = distanceValue(record);
550
- if (urn === viewer || (distance !== null && isSelfDistance(distance))) {
1139
+ if (
1140
+ recordIsSelfView(record)
1141
+ || urn === viewer
1142
+ || (distance !== null && isSelfDistance(distance))
1143
+ ) {
551
1144
  sawSelf = true;
552
1145
  }
553
1146
  if (urn === null || urn === viewer || distance === null) continue;
@@ -567,17 +1160,7 @@ export function projectLinkedInProfileContactBinding(input: {
567
1160
  }
568
1161
  const urn = urns.values().next().value!;
569
1162
  if (urn === viewer) throw selfProfileError();
570
- const related = records.filter((record) =>
571
- vanityFromRecord(record) === target.slug || profileUrnFromRecord(record) === urn
572
- );
573
- const distances = related
574
- .map(distanceValue)
575
- .filter((value): value is string | number => value !== null);
576
- const unique = [...new Set(distances.map((value) => String(value)))];
577
- if (unique.length !== 1 || distances[0] === undefined) {
578
- throw new Error("LinkedIn contact-info profile page omitted or contradicted its relationship distance");
579
- }
580
- const distance = distances[0];
1163
+ const distance = relationshipDistance(records, target.slug, urn, viewer);
581
1164
  if (isSelfDistance(distance)) throw selfProfileError();
582
1165
  if (!isFirstDegree(distance)) {
583
1166
  throw new Error(
@@ -592,14 +1175,603 @@ export function projectLinkedInProfileContactBinding(input: {
592
1175
  });
593
1176
  }
594
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
+
595
1634
  export function projectLinkedInEmbeddedContactFields(
596
1635
  html: unknown,
597
1636
  vanity: string,
598
1637
  ): LinkedInContactFields | undefined {
599
- const records = embeddedRecords(html);
600
- const entities = collectRecords(records).filter(contactEntity);
601
- if (entities.length < 1) return undefined;
602
- 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);
603
1775
  }
604
1776
 
605
1777
  function oneUnique<T>(
@@ -749,8 +1921,13 @@ function websitesFromUnknown(value: unknown): readonly string[] {
749
1921
  }));
750
1922
  }
751
1923
 
752
- function labelKey(value: unknown): string {
753
- 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();
754
1931
  }
755
1932
 
756
1933
  function labeledFields(value: unknown): JsonRecord | null {
@@ -768,7 +1945,8 @@ function labeledFields(value: unknown): JsonRecord | null {
768
1945
  const label = item.label ?? item.title ?? item.heading;
769
1946
  const raw = item.value ?? item.text ?? item.href;
770
1947
  if (typeof label !== "string") continue;
771
- const key = labelKey(label);
1948
+ const key = optionalLabelKey(label);
1949
+ if (key === undefined) continue;
772
1950
  if (key === "email") {
773
1951
  const email = optionalEmail(raw, "LinkedIn contact-info Email");
774
1952
  if (email !== null) collected.email = email;
@@ -826,22 +2004,32 @@ function collectRecords(value: unknown): readonly JsonRecord[] {
826
2004
  while (stack.length > 0) {
827
2005
  const next = stack.pop()!;
828
2006
  nodes += 1;
829
- if (nodes > MAX_WALK_NODES || next.depth > 32) {
2007
+ if (nodes > MAX_WALK_NODES || next.depth > MAX_WALK_DEPTH) {
830
2008
  throw new Error("LinkedIn contact-info payload exceeded its traversal bound");
831
2009
  }
832
2010
  if (Array.isArray(next.value)) {
833
2011
  if (next.value.length > 20_000) {
834
2012
  throw new Error("LinkedIn contact-info payload array exceeded its reviewed bound");
835
2013
  }
836
- const labeled = labeledFields(next.value);
2014
+ const labeled = labeledFields(next.value) ?? overlayTextPairFields(next.value);
837
2015
  if (labeled !== null) records.push(labeled);
838
2016
  for (const item of next.value) stack.push({ value: item, depth: next.depth + 1 });
839
2017
  continue;
840
2018
  }
841
2019
  if (!isRecord(next.value)) continue;
842
2020
  records.push(next.value);
843
- 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
+ );
844
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
+ }
845
2033
  for (const item of Object.values(next.value)) {
846
2034
  stack.push({ value: item, depth: next.depth + 1 });
847
2035
  }
@@ -900,7 +2088,7 @@ export function projectLinkedInContactInfo(input: {
900
2088
  profileUrl: input.profileUrl,
901
2089
  expectedViewerSubject: input.expectedViewerSubject,
902
2090
  });
903
- const contact = projectFields(input.contactPayload, binding.vanity);
2091
+ const contact = projectOverlayContactFields(input.contactPayload, binding.vanity);
904
2092
  const hasAny = contact.email !== null
905
2093
  || contact.connectedSince !== null
906
2094
  || contact.phones.length > 0