fastbrowser_cli 1.0.37 → 1.0.40

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 (98) hide show
  1. package/dist/contribs/_shared/fastbrowser_helper.d.ts +13 -0
  2. package/dist/contribs/_shared/fastbrowser_helper.d.ts.map +1 -0
  3. package/dist/contribs/_shared/fastbrowser_helper.js +44 -0
  4. package/dist/contribs/_shared/fastbrowser_helper.js.map +1 -0
  5. package/dist/contribs/linkedin_cli/src/cli.d.ts +3 -0
  6. package/dist/contribs/linkedin_cli/src/cli.d.ts.map +1 -0
  7. package/dist/contribs/linkedin_cli/src/cli.js +299 -0
  8. package/dist/contribs/linkedin_cli/src/cli.js.map +1 -0
  9. package/dist/contribs/linkedin_cli/src/libs/linkedin_profile_helper.d.ts +73 -0
  10. package/dist/contribs/linkedin_cli/src/libs/linkedin_profile_helper.d.ts.map +1 -0
  11. package/dist/contribs/linkedin_cli/src/libs/linkedin_profile_helper.js +866 -0
  12. package/dist/contribs/linkedin_cli/src/libs/linkedin_profile_helper.js.map +1 -0
  13. package/dist/contribs/linkedin_cli/src/libs/linkedin_recent_posts_helper.d.ts +61 -0
  14. package/dist/contribs/linkedin_cli/src/libs/linkedin_recent_posts_helper.d.ts.map +1 -0
  15. package/dist/contribs/linkedin_cli/src/libs/linkedin_recent_posts_helper.js +885 -0
  16. package/dist/contribs/linkedin_cli/src/libs/linkedin_recent_posts_helper.js.map +1 -0
  17. package/dist/contribs/linkedin_cli/src/libs/linkedin_thread_helper.d.ts +11 -0
  18. package/dist/contribs/linkedin_cli/src/libs/linkedin_thread_helper.d.ts.map +1 -0
  19. package/dist/contribs/linkedin_cli/src/libs/linkedin_thread_helper.js +145 -0
  20. package/dist/contribs/linkedin_cli/src/libs/linkedin_thread_helper.js.map +1 -0
  21. package/dist/contribs/twitter_cli/src/cli.d.ts +3 -0
  22. package/dist/contribs/twitter_cli/src/cli.d.ts.map +1 -0
  23. package/dist/contribs/twitter_cli/src/cli.js +273 -0
  24. package/dist/contribs/twitter_cli/src/cli.js.map +1 -0
  25. package/dist/contribs/twitter_cli/src/libs/twitter_profile_helper.d.ts +28 -0
  26. package/dist/contribs/twitter_cli/src/libs/twitter_profile_helper.d.ts.map +1 -0
  27. package/dist/contribs/twitter_cli/src/libs/twitter_profile_helper.js +274 -0
  28. package/dist/contribs/twitter_cli/src/libs/twitter_profile_helper.js.map +1 -0
  29. package/dist/contribs/twitter_cli/src/libs/twitter_recent_posts_helper.d.ts +43 -0
  30. package/dist/contribs/twitter_cli/src/libs/twitter_recent_posts_helper.d.ts.map +1 -0
  31. package/dist/contribs/twitter_cli/src/libs/twitter_recent_posts_helper.js +519 -0
  32. package/dist/contribs/twitter_cli/src/libs/twitter_recent_posts_helper.js.map +1 -0
  33. package/dist/contribs/twitter_cli/src/libs/twitter_thread_helper.d.ts +11 -0
  34. package/dist/contribs/twitter_cli/src/libs/twitter_thread_helper.d.ts.map +1 -0
  35. package/dist/contribs/twitter_cli/src/libs/twitter_thread_helper.js +213 -0
  36. package/dist/contribs/twitter_cli/src/libs/twitter_thread_helper.js.map +1 -0
  37. package/dist/fastbrowser_cli/fastbrowser_cli.js +43 -0
  38. package/dist/fastbrowser_cli/fastbrowser_cli.js.map +1 -1
  39. package/dist/fastbrowser_httpd/libs/tool-schemas.d.ts +4 -0
  40. package/dist/fastbrowser_httpd/libs/tool-schemas.d.ts.map +1 -1
  41. package/dist/fastbrowser_httpd/libs/tool-schemas.js +4 -0
  42. package/dist/fastbrowser_httpd/libs/tool-schemas.js.map +1 -1
  43. package/dist/fastbrowser_mcp/fastbrowser_mcp.js +36 -2
  44. package/dist/fastbrowser_mcp/fastbrowser_mcp.js.map +1 -1
  45. package/dist/fastbrowser_mcp/libs/mcp_target_helper.d.ts +2 -0
  46. package/dist/fastbrowser_mcp/libs/mcp_target_helper.d.ts.map +1 -1
  47. package/dist/fastbrowser_mcp/libs/mcp_target_helper.js +12 -0
  48. package/dist/fastbrowser_mcp/libs/mcp_target_helper.js.map +1 -1
  49. package/dist/fastbrowser_mcp/libs/response_formatter.d.ts +1 -0
  50. package/dist/fastbrowser_mcp/libs/response_formatter.d.ts.map +1 -1
  51. package/dist/fastbrowser_mcp/libs/response_formatter.js +27 -0
  52. package/dist/fastbrowser_mcp/libs/response_formatter.js.map +1 -1
  53. package/dist/shared/fastbrowser_helper.d.ts +13 -0
  54. package/dist/shared/fastbrowser_helper.d.ts.map +1 -0
  55. package/dist/shared/fastbrowser_helper.js +39 -0
  56. package/dist/shared/fastbrowser_helper.js.map +1 -0
  57. package/examples/linkedin_cli_TOREMOVE/README.md +7 -0
  58. package/examples/{linkedin_cli → linkedin_cli_TOREMOVE}/linkedin_dm.sh +8 -4
  59. package/examples/linkedin_cli_TOREMOVE/linkedin_dm.ts +326 -0
  60. package/examples/linkedin_cli_TOREMOVE/linkedin_dm_messages.ts +279 -0
  61. package/examples/linkedin_cli_TOREMOVE/linkedin_full_cycle.sh +5 -0
  62. package/examples/{linkedin_cli → linkedin_cli_TOREMOVE}/linkedin_post.sh +3 -0
  63. package/examples/linkedin_cli_TOREMOVE/message_thread.a11y.txt +252 -0
  64. package/listitem +4 -0
  65. package/package.json +7 -3
  66. package/skills/fastbrowser/SKILL.md +33 -25
  67. package/src/contribs/_shared/fastbrowser_helper.ts +54 -0
  68. package/src/contribs/linkedin_cli/README.md +80 -0
  69. package/src/contribs/linkedin_cli/data/linkedin_posts_jeromeetienne.a11y.txt +2364 -0
  70. package/src/contribs/linkedin_cli/data/linkedin_posts_jontwigge.a11y.txt +2740 -0
  71. package/src/contribs/linkedin_cli/data/linkedin_posts_julien_guezennec.a11y.txt +2073 -0
  72. package/src/contribs/linkedin_cli/data/linkedin_profile_jeromeetienne.a11y.txt +1863 -0
  73. package/src/contribs/linkedin_cli/data/linkedin_profile_jontwigge.a11y.txt +1738 -0
  74. package/src/contribs/linkedin_cli/data/linkedin_profile_julien_guezennec.a11y.txt +2182 -0
  75. package/src/contribs/linkedin_cli/src/cli.ts +345 -0
  76. package/src/contribs/linkedin_cli/src/libs/linkedin_profile_helper.ts +964 -0
  77. package/src/contribs/linkedin_cli/src/libs/linkedin_recent_posts_helper.ts +982 -0
  78. package/src/contribs/linkedin_cli/src/libs/linkedin_thread_helper.ts +171 -0
  79. package/src/contribs/twitter_cli/README.md +79 -0
  80. package/src/contribs/twitter_cli/data/twitter_chat.a11y.txt +215 -0
  81. package/src/contribs/twitter_cli/data/twitter_home.a11y.txt +467 -0
  82. package/src/contribs/twitter_cli/data/twitter_profile.a11y.txt +418 -0
  83. package/src/contribs/twitter_cli/data/twitter_profile_jontwigge.a11y.txt +484 -0
  84. package/src/contribs/twitter_cli/data/twitter_profile_molokoloco.a11y.txt +483 -0
  85. package/src/contribs/twitter_cli/src/cli.ts +315 -0
  86. package/src/contribs/twitter_cli/src/libs/twitter_profile_helper.ts +328 -0
  87. package/src/contribs/twitter_cli/src/libs/twitter_recent_posts_helper.ts +607 -0
  88. package/src/contribs/twitter_cli/src/libs/twitter_thread_helper.ts +240 -0
  89. package/src/fastbrowser_cli/fastbrowser_cli.ts +51 -0
  90. package/src/fastbrowser_httpd/libs/tool-schemas.ts +6 -0
  91. package/src/fastbrowser_mcp/fastbrowser_mcp.ts +46 -3
  92. package/src/fastbrowser_mcp/libs/mcp_target_helper.ts +11 -0
  93. package/src/fastbrowser_mcp/libs/response_formatter.ts +29 -0
  94. package/src/shared/fastbrowser_helper.ts +49 -0
  95. package/tsconfig.json +1 -1
  96. package/examples/mcp_client_playwright.ts +0 -34
  97. /package/examples/{linkedin_cli → linkedin_cli_TOREMOVE}/linkedin.snapshot.txt +0 -0
  98. /package/examples/{twitter_cli → twitter_cli_TOREMOVE}/twitter_post.sh +0 -0
@@ -0,0 +1,964 @@
1
+ // npm imports
2
+ import { A11yQuery, A11yTree, AxNode } from 'a11y_parse';
3
+
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ ///////////////////////////////////////////////////////////////////////////////
6
+ //
7
+ ///////////////////////////////////////////////////////////////////////////////
8
+ ///////////////////////////////////////////////////////////////////////////////
9
+
10
+ export type LinkedinExperienceRole = {
11
+ role: string | null;
12
+ employmentType: string | null;
13
+ duration: string | null;
14
+ location: string | null;
15
+ description: string | null;
16
+ };
17
+
18
+ export type LinkedinExperience = {
19
+ company: string | null;
20
+ totalDuration: string | null;
21
+ roles: LinkedinExperienceRole[];
22
+ };
23
+
24
+ export type LinkedinEducation = {
25
+ school: string | null;
26
+ degree: string | null;
27
+ dates: string | null;
28
+ };
29
+
30
+ export type LinkedinProfile = {
31
+ slug: string;
32
+ displayName: string | null;
33
+ headline: string | null;
34
+ location: string | null;
35
+ connectionsCount: string | null;
36
+ followersCount: string | null;
37
+ about: string | null;
38
+ website: string | null;
39
+ currentCompany: string | null;
40
+ currentEducation: string | null;
41
+ openToWork: boolean;
42
+ experience: LinkedinExperience[];
43
+ education: LinkedinEducation[];
44
+ };
45
+
46
+ const HERO_BUTTON_BLACKLIST = new Set<string>([
47
+ 'Resources',
48
+ 'Open to',
49
+ 'Enhance profile',
50
+ 'More',
51
+ 'Message',
52
+ 'Profile photo',
53
+ 'Featured overflow menu',
54
+ 'Next',
55
+ 'Previous',
56
+ 'Add experience',
57
+ 'Add education',
58
+ ]);
59
+
60
+ const DURATION_REGEXP = /\d{4}|Present|\byrs?\b|\bmos?\b/;
61
+
62
+ const EMPLOYMENT_TYPES = new Set<string>([
63
+ 'Full-time',
64
+ 'Part-time',
65
+ 'Self-employed',
66
+ 'Contract',
67
+ 'Freelance',
68
+ 'Internship',
69
+ 'Apprenticeship',
70
+ 'Seasonal',
71
+ ]);
72
+
73
+ ///////////////////////////////////////////////////////////////////////////////
74
+ ///////////////////////////////////////////////////////////////////////////////
75
+ //
76
+ ///////////////////////////////////////////////////////////////////////////////
77
+ ///////////////////////////////////////////////////////////////////////////////
78
+
79
+ export class LinkedinProfileHelper {
80
+
81
+ ///////////////////////////////////////////////////////////////////////////////
82
+ ///////////////////////////////////////////////////////////////////////////////
83
+ //
84
+ ///////////////////////////////////////////////////////////////////////////////
85
+ ///////////////////////////////////////////////////////////////////////////////
86
+
87
+ static parseProfile(rawSnapshot: string, slug: string): LinkedinProfile {
88
+ const treeText = LinkedinProfileHelper.extractAxTreeText(rawSnapshot);
89
+ const profile: LinkedinProfile = {
90
+ slug,
91
+ displayName: null,
92
+ headline: null,
93
+ location: null,
94
+ connectionsCount: null,
95
+ followersCount: null,
96
+ about: null,
97
+ website: null,
98
+ currentCompany: null,
99
+ currentEducation: null,
100
+ openToWork: false,
101
+ experience: [],
102
+ education: [],
103
+ };
104
+ if (treeText.length === 0) {
105
+ return profile;
106
+ }
107
+ const root = A11yTree.parse(treeText);
108
+ const heroLink = LinkedinProfileHelper.findHeroProfileLink(root, slug);
109
+ const heroCard = heroLink !== undefined ? LinkedinProfileHelper.findHeroCard(heroLink) : undefined;
110
+ const contactInfoLink = A11yQuery.querySelector(root, 'link[name="Contact info"]');
111
+
112
+ profile.displayName = LinkedinProfileHelper.extractDisplayName(heroLink);
113
+ profile.location = LinkedinProfileHelper.extractLocation(contactInfoLink);
114
+ profile.headline = LinkedinProfileHelper.extractHeadline(heroCard, profile.location);
115
+ profile.connectionsCount = LinkedinProfileHelper.extractConnectionsCount(root);
116
+ profile.followersCount = LinkedinProfileHelper.extractFollowersCount(root);
117
+ profile.about = LinkedinProfileHelper.extractAbout(root);
118
+ profile.openToWork = LinkedinProfileHelper.extractOpenToWork(root);
119
+
120
+ const heroButtons = heroCard !== undefined ? LinkedinProfileHelper.collectHeroButtons(heroCard) : [];
121
+ profile.website = LinkedinProfileHelper.extractWebsite(root);
122
+ const companyAndSchool = LinkedinProfileHelper.extractCompanyAndSchool(heroButtons);
123
+ profile.currentCompany = companyAndSchool.company;
124
+ profile.currentEducation = companyAndSchool.school;
125
+
126
+ profile.experience = LinkedinProfileHelper.extractExperience(root);
127
+ profile.education = LinkedinProfileHelper.extractEducation(root);
128
+
129
+ return profile;
130
+ }
131
+
132
+ ///////////////////////////////////////////////////////////////////////////////
133
+ ///////////////////////////////////////////////////////////////////////////////
134
+ //
135
+ ///////////////////////////////////////////////////////////////////////////////
136
+ ///////////////////////////////////////////////////////////////////////////////
137
+
138
+ static formatMarkdown(profile: LinkedinProfile): string {
139
+ const headerName = profile.displayName !== null ? profile.displayName : profile.slug;
140
+ const lines: string[] = [];
141
+ lines.push(`# ${headerName} (${profile.slug})`);
142
+ const fields: { label: string; value: string | null; }[] = [
143
+ { label: 'Headline', value: profile.headline },
144
+ { label: 'Location', value: profile.location },
145
+ { label: 'Connections', value: profile.connectionsCount },
146
+ { label: 'Followers', value: profile.followersCount },
147
+ { label: 'Current', value: profile.currentCompany },
148
+ { label: 'Education', value: profile.currentEducation },
149
+ { label: 'Website', value: profile.website },
150
+ ];
151
+ for (const field of fields) {
152
+ if (field.value === null) {
153
+ continue;
154
+ }
155
+ lines.push(`- ${field.label}: ${field.value}`);
156
+ }
157
+ if (profile.openToWork === true) {
158
+ lines.push('- Open to work: yes');
159
+ }
160
+ if (profile.about !== null) {
161
+ lines.push('');
162
+ lines.push('## About');
163
+ lines.push(profile.about);
164
+ }
165
+ if (profile.experience.length > 0) {
166
+ lines.push('');
167
+ lines.push('## Experience');
168
+ for (const exp of profile.experience) {
169
+ const heading = exp.totalDuration !== null
170
+ ? `### ${exp.company ?? ''} — ${exp.totalDuration}`
171
+ : `### ${exp.company ?? ''}`;
172
+ lines.push(heading);
173
+ for (const role of exp.roles) {
174
+ lines.push(LinkedinProfileHelper.formatRoleLine(role));
175
+ if (role.description !== null) {
176
+ lines.push(` ${role.description}`);
177
+ }
178
+ }
179
+ }
180
+ }
181
+ if (profile.education.length > 0) {
182
+ lines.push('');
183
+ lines.push('## Education');
184
+ for (const edu of profile.education) {
185
+ lines.push(LinkedinProfileHelper.formatEducationLine(edu));
186
+ }
187
+ }
188
+ return lines.join('\n');
189
+ }
190
+
191
+ ///////////////////////////////////////////////////////////////////////////////
192
+ ///////////////////////////////////////////////////////////////////////////////
193
+ //
194
+ ///////////////////////////////////////////////////////////////////////////////
195
+ ///////////////////////////////////////////////////////////////////////////////
196
+
197
+ static resolveSafetyUrl(url: string): string {
198
+ const prefix = 'https://www.linkedin.com/safety/go/?';
199
+ if (url.startsWith(prefix) === false) {
200
+ return url;
201
+ }
202
+ const queryString = url.slice(prefix.length);
203
+ const params = new URLSearchParams(queryString);
204
+ const target = params.get('url');
205
+ if (target === null) {
206
+ return url;
207
+ }
208
+ return target;
209
+ }
210
+
211
+ ///////////////////////////////////////////////////////////////////////////////
212
+ ///////////////////////////////////////////////////////////////////////////////
213
+ // Private — common helpers
214
+ ///////////////////////////////////////////////////////////////////////////////
215
+ ///////////////////////////////////////////////////////////////////////////////
216
+
217
+ private static extractAxTreeText(rawOutput: string): string {
218
+ const lines: string[] = [];
219
+ for (const line of rawOutput.split('\n')) {
220
+ if (/^\s*uid=/.test(line) === true) {
221
+ lines.push(line);
222
+ }
223
+ }
224
+ return lines.join('\n');
225
+ }
226
+
227
+ private static cleanString(value: string | undefined): string | null {
228
+ if (value === undefined) {
229
+ return null;
230
+ }
231
+ const trimmed = value.trim();
232
+ if (trimmed.length === 0) {
233
+ return null;
234
+ }
235
+ return trimmed;
236
+ }
237
+
238
+ private static getValue(node: AxNode): string | null {
239
+ return LinkedinProfileHelper.cleanString(node.attributes['value']);
240
+ }
241
+
242
+ private static collectParagraphValues(node: AxNode, options: { stopAtList: boolean; }): string[] {
243
+ const values: string[] = [];
244
+ LinkedinProfileHelper.collectParagraphValuesInner(node, options, values);
245
+ return values;
246
+ }
247
+
248
+ private static collectParagraphValuesInner(node: AxNode, options: { stopAtList: boolean; }, out: string[]): void {
249
+ for (const child of node.children) {
250
+ if (options.stopAtList === true && child.role === 'list') {
251
+ continue;
252
+ }
253
+ if (child.role === 'paragraph') {
254
+ const value = LinkedinProfileHelper.getValue(child);
255
+ if (value !== null) {
256
+ out.push(value);
257
+ }
258
+ }
259
+ LinkedinProfileHelper.collectParagraphValuesInner(child, options, out);
260
+ }
261
+ }
262
+
263
+ private static collectSubtreeText(node: AxNode): string | null {
264
+ const parts: string[] = [];
265
+ for (const descendant of A11yTree.walk(node)) {
266
+ if (descendant.role === 'StaticText' && descendant.name !== undefined) {
267
+ const trimmed = descendant.name.trim();
268
+ if (trimmed.length > 0) {
269
+ parts.push(trimmed);
270
+ }
271
+ }
272
+ }
273
+ if (parts.length === 0) {
274
+ return null;
275
+ }
276
+ return parts.join(' ').trim();
277
+ }
278
+
279
+ ///////////////////////////////////////////////////////////////////////////////
280
+ ///////////////////////////////////////////////////////////////////////////////
281
+ // Private — hero card identification
282
+ ///////////////////////////////////////////////////////////////////////////////
283
+ ///////////////////////////////////////////////////////////////////////////////
284
+
285
+ private static findHeroProfileLink(root: AxNode, slug: string): AxNode | undefined {
286
+ const candidates = A11yQuery.querySelectorAll(root, `link[url="https://www.linkedin.com/in/${slug}/"]`);
287
+ for (const candidate of candidates) {
288
+ const heading = A11yQuery.querySelector(candidate, 'heading[level="2"]');
289
+ if (heading !== undefined) {
290
+ return candidate;
291
+ }
292
+ }
293
+ return undefined;
294
+ }
295
+
296
+ private static findHeroCard(heroLink: AxNode): AxNode {
297
+ // Walk up until we find an ancestor that also contains the contact info link,
298
+ // then go up one more level so the hero card includes the sibling block where
299
+ // LinkedIn renders the current company / school / website buttons.
300
+ let cursor: AxNode | undefined = heroLink.parent;
301
+ for (let depth = 0; depth < 8; depth++) {
302
+ if (cursor === undefined) {
303
+ break;
304
+ }
305
+ const contact = A11yQuery.querySelector(cursor, 'link[name="Contact info"]');
306
+ if (contact !== undefined) {
307
+ return cursor.parent ?? cursor;
308
+ }
309
+ cursor = cursor.parent;
310
+ }
311
+ return heroLink;
312
+ }
313
+
314
+ ///////////////////////////////////////////////////////////////////////////////
315
+ ///////////////////////////////////////////////////////////////////////////////
316
+ // Private — header fields
317
+ ///////////////////////////////////////////////////////////////////////////////
318
+ ///////////////////////////////////////////////////////////////////////////////
319
+
320
+ private static extractDisplayName(heroLink: AxNode | undefined): string | null {
321
+ if (heroLink === undefined) {
322
+ return null;
323
+ }
324
+ const heading = A11yQuery.querySelector(heroLink, 'heading[level="2"]');
325
+ if (heading === undefined) {
326
+ return null;
327
+ }
328
+ return LinkedinProfileHelper.cleanString(heading.name);
329
+ }
330
+
331
+ private static extractLocation(contactInfoLink: AxNode | undefined): string | null {
332
+ if (contactInfoLink === undefined) {
333
+ return null;
334
+ }
335
+ const grandparent = contactInfoLink.parent?.parent;
336
+ if (grandparent === undefined) {
337
+ return null;
338
+ }
339
+ for (const child of grandparent.children) {
340
+ if (child.role !== 'paragraph') {
341
+ continue;
342
+ }
343
+ const value = LinkedinProfileHelper.getValue(child);
344
+ if (value === null) {
345
+ continue;
346
+ }
347
+ if (value === '·') {
348
+ continue;
349
+ }
350
+ return value;
351
+ }
352
+ return null;
353
+ }
354
+
355
+ private static extractHeadline(heroCard: AxNode | undefined, location: string | null): string | null {
356
+ if (heroCard === undefined) {
357
+ return null;
358
+ }
359
+ for (const node of A11yTree.walk(heroCard)) {
360
+ if (node.role !== 'paragraph') {
361
+ continue;
362
+ }
363
+ const value = LinkedinProfileHelper.getValue(node);
364
+ if (value === null) {
365
+ continue;
366
+ }
367
+ if (value.startsWith('·') === true) {
368
+ continue;
369
+ }
370
+ if (location !== null && value === location) {
371
+ continue;
372
+ }
373
+ if (/^[\d,]+\+?\s+(followers|connections)$/.test(value) === true) {
374
+ continue;
375
+ }
376
+ return value;
377
+ }
378
+ return null;
379
+ }
380
+
381
+ private static extractConnectionsCount(root: AxNode): string | null {
382
+ for (const node of A11yTree.walk(root)) {
383
+ if (node.role !== 'paragraph') {
384
+ continue;
385
+ }
386
+ const value = LinkedinProfileHelper.getValue(node);
387
+ if (value === null) {
388
+ continue;
389
+ }
390
+ const match = /^([\d,]+\+?) connections$/.exec(value);
391
+ if (match === null) {
392
+ continue;
393
+ }
394
+ return match[1];
395
+ }
396
+ return null;
397
+ }
398
+
399
+ private static extractFollowersCount(root: AxNode): string | null {
400
+ for (const node of A11yTree.walk(root)) {
401
+ const candidates: (string | undefined)[] = [
402
+ node.attributes['value'],
403
+ node.name,
404
+ ];
405
+ for (const candidate of candidates) {
406
+ if (candidate === undefined) {
407
+ continue;
408
+ }
409
+ const match = /^([\d,]+\+?) followers$/.exec(candidate.trim());
410
+ if (match === null) {
411
+ continue;
412
+ }
413
+ return match[1];
414
+ }
415
+ }
416
+ return null;
417
+ }
418
+
419
+ private static extractAbout(root: AxNode): string | null {
420
+ const heading = LinkedinProfileHelper.findSectionHeading(root, 'About');
421
+ if (heading === undefined) {
422
+ return null;
423
+ }
424
+ const section = LinkedinProfileHelper.findSectionBody(heading);
425
+ if (section === undefined) {
426
+ return null;
427
+ }
428
+ return LinkedinProfileHelper.collectSubtreeText(section);
429
+ }
430
+
431
+ private static extractOpenToWork(root: AxNode): boolean {
432
+ for (const node of A11yTree.walk(root)) {
433
+ if (node.attributes['value'] === 'Open to work') {
434
+ return true;
435
+ }
436
+ }
437
+ return false;
438
+ }
439
+
440
+ ///////////////////////////////////////////////////////////////////////////////
441
+ ///////////////////////////////////////////////////////////////////////////////
442
+ // Private — hero buttons (current company / education / website)
443
+ ///////////////////////////////////////////////////////////////////////////////
444
+ ///////////////////////////////////////////////////////////////////////////////
445
+
446
+ private static collectHeroButtons(heroCard: AxNode): AxNode[] {
447
+ const buttons: AxNode[] = [];
448
+ for (const node of A11yTree.walk(heroCard)) {
449
+ if (node.role !== 'button') {
450
+ continue;
451
+ }
452
+ const name = LinkedinProfileHelper.cleanString(node.name);
453
+ if (name === null) {
454
+ continue;
455
+ }
456
+ if (HERO_BUTTON_BLACKLIST.has(name) === true) {
457
+ continue;
458
+ }
459
+ if (/notification|Premium|verifications/i.test(name) === true) {
460
+ continue;
461
+ }
462
+ buttons.push(node);
463
+ }
464
+ return buttons;
465
+ }
466
+
467
+ private static extractWebsite(root: AxNode): string | null {
468
+ // Walk in document order. The website link sits between the displayName heading
469
+ // and the first "content section" heading (Highlights / About / Analytics / etc.).
470
+ // LinkedIn places it inside a `button` whose child link points to its safety/go redirector.
471
+ const sectionBoundaryNames = new Set<string>([
472
+ 'Highlights',
473
+ 'About',
474
+ 'Analytics',
475
+ 'Featured',
476
+ 'Activity',
477
+ 'Services',
478
+ 'Experience',
479
+ 'Education',
480
+ ]);
481
+ let inHeroRegion = false;
482
+ for (const node of A11yTree.walk(root)) {
483
+ if (node.role === 'heading' && node.attributes['level'] === '2') {
484
+ const name = node.name;
485
+ if (inHeroRegion === false) {
486
+ if (name !== undefined && name !== '0 notifications' && name !== '0 notifications total') {
487
+ inHeroRegion = true;
488
+ }
489
+ continue;
490
+ }
491
+ if (name !== undefined && sectionBoundaryNames.has(name) === true) {
492
+ return null;
493
+ }
494
+ continue;
495
+ }
496
+ if (inHeroRegion === false) {
497
+ continue;
498
+ }
499
+ if (node.role !== 'link') {
500
+ continue;
501
+ }
502
+ const url = node.attributes['url'];
503
+ if (url === undefined) {
504
+ continue;
505
+ }
506
+ if (url.startsWith('https://www.linkedin.com/safety/go/') === false) {
507
+ continue;
508
+ }
509
+ return LinkedinProfileHelper.resolveSafetyUrl(url);
510
+ }
511
+ return null;
512
+ }
513
+
514
+ private static extractCompanyAndSchool(heroButtons: AxNode[]): { company: string | null; school: string | null; } {
515
+ const result: { company: string | null; school: string | null; } = { company: null, school: null };
516
+ const remaining: AxNode[] = [];
517
+ for (const button of heroButtons) {
518
+ const link = A11yQuery.querySelector(button, 'link[url^="https://www.linkedin.com/safety/go/"]');
519
+ if (link !== undefined) {
520
+ continue;
521
+ }
522
+ remaining.push(button);
523
+ }
524
+ if (remaining.length >= 1) {
525
+ result.company = LinkedinProfileHelper.cleanString(remaining[0].name);
526
+ }
527
+ if (remaining.length >= 2) {
528
+ result.school = LinkedinProfileHelper.cleanString(remaining[1].name);
529
+ }
530
+ return result;
531
+ }
532
+
533
+ ///////////////////////////////////////////////////////////////////////////////
534
+ ///////////////////////////////////////////////////////////////////////////////
535
+ // Private — section helpers
536
+ ///////////////////////////////////////////////////////////////////////////////
537
+ ///////////////////////////////////////////////////////////////////////////////
538
+
539
+ private static findSectionHeading(root: AxNode, name: string): AxNode | undefined {
540
+ for (const node of A11yQuery.querySelectorAll(root, 'heading[level="2"]')) {
541
+ if (node.name === name) {
542
+ return node;
543
+ }
544
+ if (node.name !== undefined && node.name.startsWith(`${name} (`) === true) {
545
+ return node;
546
+ }
547
+ }
548
+ return undefined;
549
+ }
550
+
551
+ private static findSectionBody(heading: AxNode): AxNode | undefined {
552
+ // Find the first sibling (or sibling-of-ancestor) after the heading that holds
553
+ // real content. Skip over wrappers that only contain action buttons / links
554
+ // (LinkedIn renders Add / Edit controls between the heading and the body on
555
+ // the user's own profile).
556
+ let cursor: AxNode | undefined = heading;
557
+ for (let depth = 0; depth < 4; depth++) {
558
+ if (cursor === undefined) {
559
+ break;
560
+ }
561
+ const parent: AxNode | undefined = cursor.parent;
562
+ if (parent === undefined) {
563
+ break;
564
+ }
565
+ const idx = parent.children.indexOf(cursor);
566
+ for (let i = idx + 1; i < parent.children.length; i++) {
567
+ const sibling = parent.children[i];
568
+ if (sibling.role !== 'generic' && sibling.role !== 'paragraph' && sibling.role !== 'list') {
569
+ continue;
570
+ }
571
+ if (LinkedinProfileHelper.isControlContainer(sibling) === true) {
572
+ continue;
573
+ }
574
+ return sibling;
575
+ }
576
+ cursor = parent;
577
+ }
578
+ return undefined;
579
+ }
580
+
581
+ private static isControlContainer(node: AxNode): boolean {
582
+ if (node.role !== 'generic') {
583
+ return false;
584
+ }
585
+ if (node.children.length === 0) {
586
+ return false;
587
+ }
588
+ for (const child of node.children) {
589
+ if (child.role !== 'button' && child.role !== 'link') {
590
+ return false;
591
+ }
592
+ }
593
+ return true;
594
+ }
595
+
596
+ ///////////////////////////////////////////////////////////////////////////////
597
+ ///////////////////////////////////////////////////////////////////////////////
598
+ // Private — experience
599
+ ///////////////////////////////////////////////////////////////////////////////
600
+ ///////////////////////////////////////////////////////////////////////////////
601
+
602
+ private static extractExperience(root: AxNode): LinkedinExperience[] {
603
+ const heading = LinkedinProfileHelper.findSectionHeading(root, 'Experience');
604
+ if (heading === undefined) {
605
+ return [];
606
+ }
607
+ const body = LinkedinProfileHelper.findSectionBody(heading);
608
+ if (body === undefined) {
609
+ return [];
610
+ }
611
+ const entries: LinkedinExperience[] = [];
612
+ for (const child of body.children) {
613
+ if (child.role !== 'generic') {
614
+ continue;
615
+ }
616
+ const entry = LinkedinProfileHelper.parseExperienceEntry(child);
617
+ if (entry === null) {
618
+ continue;
619
+ }
620
+ entries.push(entry);
621
+ }
622
+ return entries;
623
+ }
624
+
625
+ private static parseExperienceEntry(entry: AxNode): LinkedinExperience | null {
626
+ const logoName = LinkedinProfileHelper.findLogoName(entry);
627
+ const directList = LinkedinProfileHelper.findDirectChildList(entry);
628
+ if (directList !== undefined) {
629
+ return LinkedinProfileHelper.parseMultiRoleExperience(entry, directList, logoName);
630
+ }
631
+ return LinkedinProfileHelper.parseSingleRoleExperience(entry, logoName);
632
+ }
633
+
634
+ private static buildSkipValues(values: (string | null)[]): Set<string> {
635
+ const skip = new Set<string>();
636
+ for (const value of values) {
637
+ if (value === null) {
638
+ continue;
639
+ }
640
+ skip.add(value);
641
+ }
642
+ return skip;
643
+ }
644
+
645
+ private static findLogoName(entry: AxNode): string | null {
646
+ for (const node of A11yTree.walk(entry)) {
647
+ if (node.role !== 'img') {
648
+ continue;
649
+ }
650
+ const name = LinkedinProfileHelper.cleanString(node.name);
651
+ if (name === null) {
652
+ continue;
653
+ }
654
+ if (name.endsWith(' logo') === true) {
655
+ return name.slice(0, name.length - ' logo'.length).trim();
656
+ }
657
+ }
658
+ return null;
659
+ }
660
+
661
+ private static findDirectChildList(entry: AxNode): AxNode | undefined {
662
+ const stack: AxNode[] = [...entry.children];
663
+ while (stack.length > 0) {
664
+ const node = stack.shift();
665
+ if (node === undefined) {
666
+ continue;
667
+ }
668
+ if (node.role === 'list') {
669
+ return node;
670
+ }
671
+ if (node.role === 'listitem' || node.role === 'list') {
672
+ continue;
673
+ }
674
+ // Only descend through wrappers that are likely to be siblings of a list,
675
+ // not into actual list contents.
676
+ if (node.role === 'generic') {
677
+ stack.push(...node.children);
678
+ }
679
+ }
680
+ return undefined;
681
+ }
682
+
683
+ private static parseMultiRoleExperience(entry: AxNode, list: AxNode, logoName: string | null): LinkedinExperience {
684
+ const headerValues = LinkedinProfileHelper.collectParagraphValues(entry, { stopAtList: true });
685
+ const company = headerValues.length > 0 ? headerValues[0] : logoName;
686
+ const totalDuration = headerValues.length > 1 ? headerValues[1] : null;
687
+ const skip = LinkedinProfileHelper.buildSkipValues([company, totalDuration]);
688
+ const roles: LinkedinExperienceRole[] = [];
689
+ for (const item of list.children) {
690
+ if (item.role !== 'listitem') {
691
+ continue;
692
+ }
693
+ roles.push(LinkedinProfileHelper.parseRoleEntry(item, skip));
694
+ }
695
+ return {
696
+ company,
697
+ totalDuration,
698
+ roles,
699
+ };
700
+ }
701
+
702
+ private static parseSingleRoleExperience(entry: AxNode, logoName: string | null): LinkedinExperience {
703
+ const skip = LinkedinProfileHelper.buildSkipValues([logoName]);
704
+ const role = LinkedinProfileHelper.parseRoleEntry(entry, skip);
705
+ let company: string | null = logoName;
706
+ const values = LinkedinProfileHelper.collectParagraphValues(entry, { stopAtList: false });
707
+ // The company name is generally one of the paragraphs near the top.
708
+ // We trust the logo name first, but fall back to a paragraph that looks like a company.
709
+ if (company === null) {
710
+ for (const value of values) {
711
+ if (DURATION_REGEXP.test(value) === true) {
712
+ continue;
713
+ }
714
+ if (value === role.role) {
715
+ continue;
716
+ }
717
+ company = value;
718
+ break;
719
+ }
720
+ }
721
+ return {
722
+ company,
723
+ totalDuration: null,
724
+ roles: [role],
725
+ };
726
+ }
727
+
728
+ private static parseRoleEntry(entry: AxNode, skipValues: Set<string>): LinkedinExperienceRole {
729
+ const values = LinkedinProfileHelper.collectParagraphValues(entry, { stopAtList: true });
730
+ const role: LinkedinExperienceRole = {
731
+ role: null,
732
+ employmentType: null,
733
+ duration: null,
734
+ location: null,
735
+ description: null,
736
+ };
737
+ const consumed = new Set<number>();
738
+ // First non-empty value is the role title.
739
+ for (let i = 0; i < values.length; i++) {
740
+ const value = values[i];
741
+ role.role = value;
742
+ consumed.add(i);
743
+ break;
744
+ }
745
+ // Find duration (matches a date-like pattern).
746
+ for (let i = 0; i < values.length; i++) {
747
+ if (consumed.has(i) === true) {
748
+ continue;
749
+ }
750
+ if (DURATION_REGEXP.test(values[i]) === true) {
751
+ role.duration = values[i];
752
+ consumed.add(i);
753
+ break;
754
+ }
755
+ }
756
+ // Detect employment type, possibly embedded in "Company · Type".
757
+ for (let i = 0; i < values.length; i++) {
758
+ if (consumed.has(i) === true) {
759
+ continue;
760
+ }
761
+ const value = values[i];
762
+ const type = LinkedinProfileHelper.extractEmploymentType(value);
763
+ if (type !== null) {
764
+ role.employmentType = type;
765
+ consumed.add(i);
766
+ break;
767
+ }
768
+ }
769
+ // Remaining unconsumed values: pick the first one that's not a URL and
770
+ // not a known company / total-duration value already consumed by the caller.
771
+ for (let i = 0; i < values.length; i++) {
772
+ if (consumed.has(i) === true) {
773
+ continue;
774
+ }
775
+ const value = values[i];
776
+ if (value.startsWith('http://') === true || value.startsWith('https://') === true) {
777
+ continue;
778
+ }
779
+ if (skipValues.has(value) === true) {
780
+ continue;
781
+ }
782
+ role.location = value;
783
+ consumed.add(i);
784
+ break;
785
+ }
786
+ role.description = LinkedinProfileHelper.findRoleDescription(entry);
787
+ return role;
788
+ }
789
+
790
+ private static extractEmploymentType(value: string): string | null {
791
+ if (EMPLOYMENT_TYPES.has(value) === true) {
792
+ return value;
793
+ }
794
+ const parts = value.split('·').map((s) => s.trim());
795
+ for (const part of parts) {
796
+ if (EMPLOYMENT_TYPES.has(part) === true) {
797
+ return part;
798
+ }
799
+ }
800
+ return null;
801
+ }
802
+
803
+ private static findRoleDescription(entry: AxNode): string | null {
804
+ // Description lives in a StaticText with substantial length, before any "skill chips" link.
805
+ let bestText: string | null = null;
806
+ let bestLength = 0;
807
+ for (const node of A11yTree.walk(entry)) {
808
+ if (node.role !== 'StaticText') {
809
+ continue;
810
+ }
811
+ const text = LinkedinProfileHelper.cleanString(node.name);
812
+ if (text === null) {
813
+ continue;
814
+ }
815
+ if (text.length < 20) {
816
+ continue;
817
+ }
818
+ if (text.length > bestLength) {
819
+ bestText = text;
820
+ bestLength = text.length;
821
+ }
822
+ }
823
+ // Some descriptions live in `paragraph > generic[value]` instead of StaticText.
824
+ if (bestText === null) {
825
+ for (const node of A11yTree.walk(entry)) {
826
+ if (node.role !== 'generic') {
827
+ continue;
828
+ }
829
+ const value = LinkedinProfileHelper.getValue(node);
830
+ if (value === null) {
831
+ continue;
832
+ }
833
+ if (value.length < 20) {
834
+ continue;
835
+ }
836
+ if (value.length > bestLength) {
837
+ bestText = value;
838
+ bestLength = value.length;
839
+ }
840
+ }
841
+ }
842
+ return bestText;
843
+ }
844
+
845
+ ///////////////////////////////////////////////////////////////////////////////
846
+ ///////////////////////////////////////////////////////////////////////////////
847
+ // Private — education
848
+ ///////////////////////////////////////////////////////////////////////////////
849
+ ///////////////////////////////////////////////////////////////////////////////
850
+
851
+ private static extractEducation(root: AxNode): LinkedinEducation[] {
852
+ const heading = LinkedinProfileHelper.findSectionHeading(root, 'Education');
853
+ if (heading === undefined) {
854
+ return [];
855
+ }
856
+ const body = LinkedinProfileHelper.findSectionBody(heading);
857
+ if (body === undefined) {
858
+ return [];
859
+ }
860
+ const entries: LinkedinEducation[] = [];
861
+ for (const child of body.children) {
862
+ if (child.role !== 'generic') {
863
+ continue;
864
+ }
865
+ const entry = LinkedinProfileHelper.parseEducationEntry(child);
866
+ if (entry === null) {
867
+ continue;
868
+ }
869
+ entries.push(entry);
870
+ }
871
+ return entries;
872
+ }
873
+
874
+ private static parseEducationEntry(entry: AxNode): LinkedinEducation | null {
875
+ const logoName = LinkedinProfileHelper.findLogoName(entry);
876
+ const values = LinkedinProfileHelper.collectParagraphValues(entry, { stopAtList: false });
877
+ if (values.length === 0 && logoName === null) {
878
+ return null;
879
+ }
880
+ const result: LinkedinEducation = {
881
+ school: null,
882
+ degree: null,
883
+ dates: null,
884
+ };
885
+ const consumed = new Set<number>();
886
+ // Prefer the logo name as school when available, otherwise take the first paragraph.
887
+ if (logoName !== null) {
888
+ result.school = logoName;
889
+ for (let i = 0; i < values.length; i++) {
890
+ if (values[i] === logoName) {
891
+ consumed.add(i);
892
+ break;
893
+ }
894
+ }
895
+ } else if (values.length > 0) {
896
+ result.school = values[0];
897
+ consumed.add(0);
898
+ }
899
+ // Find dates (e.g. "1984 – 1988").
900
+ for (let i = 0; i < values.length; i++) {
901
+ if (consumed.has(i) === true) {
902
+ continue;
903
+ }
904
+ if (DURATION_REGEXP.test(values[i]) === true) {
905
+ result.dates = values[i];
906
+ consumed.add(i);
907
+ break;
908
+ }
909
+ }
910
+ // Degree is the next remaining paragraph.
911
+ for (let i = 0; i < values.length; i++) {
912
+ if (consumed.has(i) === true) {
913
+ continue;
914
+ }
915
+ result.degree = values[i];
916
+ consumed.add(i);
917
+ break;
918
+ }
919
+ return result;
920
+ }
921
+
922
+ ///////////////////////////////////////////////////////////////////////////////
923
+ ///////////////////////////////////////////////////////////////////////////////
924
+ // Private — markdown formatting
925
+ ///////////////////////////////////////////////////////////////////////////////
926
+ ///////////////////////////////////////////////////////////////////////////////
927
+
928
+ private static formatRoleLine(role: LinkedinExperienceRole): string {
929
+ const parts: string[] = [];
930
+ const titleParts: string[] = [];
931
+ if (role.role !== null) {
932
+ titleParts.push(role.role);
933
+ }
934
+ if (role.employmentType !== null) {
935
+ titleParts.push(role.employmentType);
936
+ }
937
+ if (titleParts.length > 0) {
938
+ parts.push(titleParts.join(' · '));
939
+ }
940
+ if (role.duration !== null) {
941
+ parts.push(role.duration);
942
+ }
943
+ if (role.location !== null) {
944
+ parts.push(role.location);
945
+ }
946
+ const body = parts.length > 0 ? parts.join(' — ') : '(role)';
947
+ return `- ${body}`;
948
+ }
949
+
950
+ private static formatEducationLine(edu: LinkedinEducation): string {
951
+ const parts: string[] = [];
952
+ if (edu.school !== null) {
953
+ parts.push(edu.school);
954
+ }
955
+ if (edu.degree !== null) {
956
+ parts.push(edu.degree);
957
+ }
958
+ if (edu.dates !== null) {
959
+ parts.push(edu.dates);
960
+ }
961
+ const body = parts.length > 0 ? parts.join(' — ') : '(education)';
962
+ return `- ${body}`;
963
+ }
964
+ }