@jackwener/opencli 1.8.0 → 1.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (153) hide show
  1. package/README.md +8 -49
  2. package/README.zh-CN.md +8 -52
  3. package/cli-manifest.json +1796 -191
  4. package/clis/_atlassian/shared.js +577 -0
  5. package/clis/_atlassian/shared.test.js +170 -0
  6. package/clis/bilibili/comment.js +125 -0
  7. package/clis/bilibili/comment.test.js +153 -0
  8. package/clis/bilibili/comments.js +116 -21
  9. package/clis/bilibili/comments.test.js +77 -18
  10. package/clis/bilibili/subtitle.js +76 -31
  11. package/clis/bilibili/subtitle.test.js +156 -9
  12. package/clis/bilibili/utils.js +63 -5
  13. package/clis/bilibili/utils.test.js +45 -1
  14. package/clis/chess/analyze.js +35 -0
  15. package/clis/chess/analyze.test.js +79 -0
  16. package/clis/chess/game.js +114 -0
  17. package/clis/chess/game.test.js +178 -0
  18. package/clis/chess/games.js +67 -0
  19. package/clis/chess/games.test.js +164 -0
  20. package/clis/chess/stats.js +32 -0
  21. package/clis/chess/stats.test.js +79 -0
  22. package/clis/chess/utils.js +170 -0
  23. package/clis/chess/utils.test.js +230 -0
  24. package/clis/confluence/commands.test.js +195 -0
  25. package/clis/confluence/create.js +39 -0
  26. package/clis/confluence/page.js +23 -0
  27. package/clis/confluence/search.js +34 -0
  28. package/clis/confluence/shared.js +173 -0
  29. package/clis/confluence/update.js +38 -0
  30. package/clis/douyin/hashtag.js +84 -23
  31. package/clis/douyin/hashtag.test.js +113 -0
  32. package/clis/geogebra/add-circle.js +46 -0
  33. package/clis/geogebra/add-line.js +35 -0
  34. package/clis/geogebra/add-point.js +27 -0
  35. package/clis/geogebra/add-polygon.js +25 -0
  36. package/clis/geogebra/eval.js +35 -0
  37. package/clis/geogebra/geogebra.test.js +175 -0
  38. package/clis/geogebra/hexagon.js +62 -0
  39. package/clis/geogebra/info.js +72 -0
  40. package/clis/geogebra/list.js +35 -0
  41. package/clis/geogebra/triangle.js +60 -0
  42. package/clis/geogebra/utils.js +271 -0
  43. package/clis/jira/attachments.js +28 -0
  44. package/clis/jira/commands.test.js +287 -0
  45. package/clis/jira/comments.js +28 -0
  46. package/clis/jira/issue.js +28 -0
  47. package/clis/jira/links.js +28 -0
  48. package/clis/jira/search.js +47 -0
  49. package/clis/jira/shared.js +256 -0
  50. package/clis/linkedin/job-detail.js +167 -0
  51. package/clis/linkedin/job-detail.test.js +38 -0
  52. package/clis/linkedin/jobs-preferences.js +113 -0
  53. package/clis/linkedin/jobs-preferences.test.js +43 -0
  54. package/clis/linkedin/post-analytics.js +74 -0
  55. package/clis/linkedin/post-analytics.test.js +40 -0
  56. package/clis/linkedin/posts-core.js +241 -0
  57. package/clis/linkedin/posts.js +22 -0
  58. package/clis/linkedin/posts.test.js +40 -0
  59. package/clis/linkedin/profile-analytics.js +104 -0
  60. package/clis/linkedin/profile-analytics.test.js +67 -0
  61. package/clis/linkedin/profile-experience.js +671 -0
  62. package/clis/linkedin/profile-experience.test.js +152 -0
  63. package/clis/linkedin/profile-projects.js +311 -0
  64. package/clis/linkedin/profile-projects.test.js +111 -0
  65. package/clis/linkedin/profile-read.js +148 -0
  66. package/clis/linkedin/profile-read.test.js +77 -0
  67. package/clis/linkedin/services-read.js +213 -0
  68. package/clis/linkedin/services-read.test.js +105 -0
  69. package/clis/linkedin/shared.js +124 -0
  70. package/clis/linkedin/timeline.js +14 -7
  71. package/clis/notebooklm/add-source.js +269 -0
  72. package/clis/notebooklm/add-source.test.js +97 -0
  73. package/clis/notebooklm/create.js +76 -0
  74. package/clis/notebooklm/create.test.js +58 -0
  75. package/clis/notebooklm/generate-audio.js +91 -0
  76. package/clis/notebooklm/generate-audio.test.js +63 -0
  77. package/clis/notebooklm/generate-slides.js +106 -0
  78. package/clis/notebooklm/generate-slides.test.js +75 -0
  79. package/clis/notebooklm/open.test.js +10 -10
  80. package/clis/notebooklm/rpc.js +20 -6
  81. package/clis/notebooklm/rpc.test.js +27 -1
  82. package/clis/notebooklm/utils.js +100 -24
  83. package/clis/notebooklm/utils.test.js +60 -1
  84. package/clis/notebooklm/write-note.js +103 -0
  85. package/clis/notebooklm/write-note.test.js +70 -0
  86. package/clis/pixiv/detail.js +41 -34
  87. package/clis/pixiv/detail.test.js +93 -0
  88. package/clis/pixiv/user.js +36 -31
  89. package/clis/pixiv/user.test.js +100 -0
  90. package/clis/pixiv/utils.js +56 -7
  91. package/clis/suno/generate.js +5 -0
  92. package/clis/suno/generate.test.js +9 -0
  93. package/clis/suno/status.js +3 -2
  94. package/clis/suno/utils.js +33 -24
  95. package/clis/suno/utils.test.js +106 -0
  96. package/clis/twitter/followers.js +6 -2
  97. package/clis/twitter/followers.test.js +19 -1
  98. package/clis/twitter/following.js +14 -5
  99. package/clis/twitter/following.test.js +29 -0
  100. package/clis/twitter/likes.js +12 -4
  101. package/clis/twitter/likes.test.js +26 -1
  102. package/clis/twitter/list-add.js +1 -1
  103. package/clis/twitter/list-remove.js +1 -1
  104. package/clis/twitter/notifications.js +4 -4
  105. package/clis/twitter/post.js +62 -4
  106. package/clis/twitter/post.test.js +35 -3
  107. package/clis/twitter/profile.js +81 -28
  108. package/clis/twitter/profile.test.js +113 -2
  109. package/clis/twitter/quote.js +9 -4
  110. package/clis/twitter/reply.js +13 -10
  111. package/clis/twitter/reply.test.js +41 -0
  112. package/clis/twitter/search.js +1 -1
  113. package/clis/twitter/search.test.js +35 -0
  114. package/clis/twitter/shared.js +11 -0
  115. package/clis/twitter/shared.test.js +37 -1
  116. package/clis/twitter/utils.js +53 -16
  117. package/clis/upwork/detail.js +132 -0
  118. package/clis/upwork/feed.js +109 -0
  119. package/clis/upwork/search.js +115 -0
  120. package/clis/upwork/upwork.test.js +566 -0
  121. package/clis/upwork/utils.js +323 -0
  122. package/clis/weread/book-search.js +438 -0
  123. package/clis/weread/book-search.test.js +242 -0
  124. package/clis/weread/search-regression.test.js +80 -0
  125. package/clis/weread/search.js +17 -2
  126. package/clis/xiaohongshu/creator-note-detail.js +165 -28
  127. package/clis/xiaohongshu/creator-note-detail.test.js +186 -37
  128. package/clis/xiaohongshu/creator-notes.js +251 -2
  129. package/clis/xiaohongshu/creator-notes.test.js +79 -2
  130. package/clis/xiaohongshu/download.js +97 -39
  131. package/clis/xiaohongshu/download.test.js +201 -0
  132. package/clis/zhihu/answer-comments.js +2 -21
  133. package/clis/zhihu/answer-detail.js +2 -31
  134. package/clis/zhihu/collection.js +2 -14
  135. package/clis/zhihu/collection.test.js +4 -3
  136. package/clis/zhihu/question.js +1 -9
  137. package/clis/zhihu/question.test.js +2 -2
  138. package/clis/zhihu/search.js +1 -12
  139. package/clis/zhihu/search.test.js +2 -2
  140. package/clis/zhihu/text.js +29 -0
  141. package/clis/zhihu/text.test.js +24 -0
  142. package/dist/src/browser/network-cache.js +13 -1
  143. package/dist/src/browser/network-cache.test.js +17 -0
  144. package/dist/src/download/index.js +13 -1
  145. package/dist/src/download/index.test.js +23 -1
  146. package/dist/src/download/media-download.test.js +3 -1
  147. package/dist/src/download/progress.js +2 -2
  148. package/dist/src/download/progress.test.js +12 -1
  149. package/dist/src/output.js +11 -1
  150. package/dist/src/output.test.js +6 -0
  151. package/dist/src/registry.js +1 -0
  152. package/dist/src/registry.test.js +11 -0
  153. package/package.json +1 -1
@@ -0,0 +1,152 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { getRegistry } from '@jackwener/opencli/registry';
3
+ import { CommandExecutionError } from '@jackwener/opencli/errors';
4
+ import './profile-experience.js';
5
+
6
+ const {
7
+ normalizeProfileUrl,
8
+ profileExperienceUrl,
9
+ decodeLinkedInSafetyUrl,
10
+ parseDateRangeParts,
11
+ parseCompanyLine,
12
+ parseLocationLine,
13
+ parseExperienceText,
14
+ parseExperienceSectionText,
15
+ buildExperienceExtractionScript,
16
+ buildDialogExtractionScript,
17
+ normalizeExperience,
18
+ } = await import('./profile-experience.js').then((m) => m.__test__);
19
+
20
+ describe('linkedin profile-experience adapter', () => {
21
+ const command = getRegistry().get('linkedin/profile-experience');
22
+
23
+ it('registers command shape', () => {
24
+ expect(command).toBeDefined();
25
+ expect(command.strategy).toBe('cookie');
26
+ expect(command.browser).toBe(true);
27
+ expect(command.columns).toEqual([
28
+ 'rank',
29
+ 'total_count',
30
+ 'title',
31
+ 'employment_type',
32
+ 'company',
33
+ 'date_range',
34
+ 'start_date',
35
+ 'end_date',
36
+ 'location',
37
+ 'location_type',
38
+ 'description',
39
+ 'skills',
40
+ 'media',
41
+ 'urls',
42
+ 'skill_url',
43
+ 'media_url',
44
+ 'profile_url',
45
+ 'raw_text',
46
+ ]);
47
+ });
48
+
49
+ it('normalizes default and explicit profile URLs', () => {
50
+ expect(normalizeProfileUrl(undefined)).toBe('https://www.linkedin.com/in/me/');
51
+ expect(normalizeProfileUrl('https://www.linkedin.com/in/gauravsaxena1997?x=1')).toBe('https://www.linkedin.com/in/gauravsaxena1997?x=1');
52
+ expect(profileExperienceUrl('https://www.linkedin.com/in/gauravsaxena1997?x=1')).toBe('https://www.linkedin.com/in/gauravsaxena1997/details/experience/');
53
+ });
54
+
55
+ it('rejects non-profile URLs', () => {
56
+ expect(() => normalizeProfileUrl('https://www.linkedin.com/jobs/')).toThrow(CommandExecutionError);
57
+ expect(() => profileExperienceUrl('https://www.linkedin.com/in/me/')).toThrow(CommandExecutionError);
58
+ });
59
+
60
+ it('parses company, date, and location line variants', () => {
61
+ expect(parseCompanyLine('Self Employed · Freelance')).toEqual({
62
+ company: 'Self Employed',
63
+ employment_type: 'Freelance',
64
+ });
65
+ expect(parseDateRangeParts('Feb 2026 – Present · 4 mos')).toEqual({
66
+ dateRange: 'Feb 2026 – Present',
67
+ startDate: 'Feb 2026',
68
+ endDate: 'Present',
69
+ });
70
+ expect(parseLocationLine('Jaipur, Rajasthan, India · Remote')).toEqual({
71
+ location: 'Jaipur, Rajasthan, India',
72
+ location_type: 'Remote',
73
+ });
74
+ });
75
+
76
+ it('parses visible experience text into fields', () => {
77
+ expect(parseExperienceText(`Senior Full-Stack AI Engineer
78
+ Self Employed · Freelance
79
+ Feb 2026 – Present · 4 mos
80
+ Jaipur, Rajasthan, India · Remote
81
+ Building AI-enabled SaaS products and agentic workflows.
82
+ Skills: Generative AI, Next.js, TypeScript`, 'https://www.linkedin.com/in/me/', 0, 1)).toMatchObject({
83
+ rank: 1,
84
+ total_count: 1,
85
+ title: 'Senior Full-Stack AI Engineer',
86
+ employment_type: 'Freelance',
87
+ company: 'Self Employed',
88
+ date_range: 'Feb 2026 – Present',
89
+ start_date: 'Feb 2026',
90
+ end_date: 'Present',
91
+ location: 'Jaipur, Rajasthan, India',
92
+ location_type: 'Remote',
93
+ description: 'Building AI-enabled SaaS products and agentic workflows.',
94
+ skills: 'Generative AI, Next.js, TypeScript',
95
+ });
96
+ });
97
+
98
+ it('parses a LinkedIn experience section rendered as one section', () => {
99
+ const rows = parseExperienceSectionText(`Experience
100
+
101
+ Senior Software Engineer
102
+ Zetwerk Manufacturing
103
+ Apr 2022 – Present · 4 yrs 2 mos
104
+ Bengaluru, Karnataka, India · Hybrid
105
+ Led development and mentored team members.
106
+ Skills: Angular, Node.js
107
+
108
+ Software Engineer
109
+ OnGraph Technologies Pvt. Ltd.
110
+ Jun 2019 – Apr 2022 · 2 yrs 11 mos
111
+ Jaipur, Rajasthan, India · On-site
112
+ Full-stack development for client projects.
113
+ Skills: React, MongoDB
114
+
115
+ Who your viewers also viewed`, 'https://www.linkedin.com/in/gauravsaxena1997/');
116
+
117
+ expect(rows).toHaveLength(2);
118
+ expect(rows[0]).toMatchObject({
119
+ total_count: 2,
120
+ title: 'Senior Software Engineer',
121
+ company: 'Zetwerk Manufacturing',
122
+ date_range: 'Apr 2022 – Present',
123
+ location_type: 'Hybrid',
124
+ });
125
+ expect(rows[1]).toMatchObject({
126
+ total_count: 2,
127
+ title: 'Software Engineer',
128
+ company: 'OnGraph Technologies Pvt. Ltd.',
129
+ date_range: 'Jun 2019 – Apr 2022',
130
+ location_type: 'On-site',
131
+ });
132
+ });
133
+
134
+ it('normalizes rows and requires a title', () => {
135
+ expect(() => normalizeExperience({ title: '' })).toThrow(CommandExecutionError);
136
+ expect(normalizeExperience({ rank: '2', total_count: '4', title: ' AI Engineer ', company: ' Self Employed ', skill_url: ' https://example.com/skills ' }))
137
+ .toMatchObject({ rank: 2, total_count: 4, title: 'AI Engineer', company: 'Self Employed', skill_url: 'https://example.com/skills' });
138
+ });
139
+
140
+ it('decodes LinkedIn safety redirect URLs', () => {
141
+ expect(decodeLinkedInSafetyUrl('https://www.linkedin.com/safety/go/?url=https%3A%2F%2Fexample.com%2Fdemo&urlhash=x'))
142
+ .toBe('https://example.com/demo');
143
+ expect(decodeLinkedInSafetyUrl('https://www.linkedin.com/safety/go/?url=javascript%3Aalert(1)&urlhash=x'))
144
+ .toBe('');
145
+ expect(decodeLinkedInSafetyUrl('javascript:alert(1)')).toBe('');
146
+ });
147
+
148
+ it('builds browser extraction scripts that parse as JavaScript', () => {
149
+ expect(() => new Function(buildExperienceExtractionScript())).not.toThrow();
150
+ expect(() => new Function(buildDialogExtractionScript())).not.toThrow();
151
+ });
152
+ });
@@ -0,0 +1,311 @@
1
+ import { cli, Strategy } from '@jackwener/opencli/registry';
2
+ import { CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
3
+ import {
4
+ assertLinkedInAuthenticated,
5
+ assertSafeLinkedinUrl,
6
+ normalizeHttpUrl,
7
+ normalizeWhitespace,
8
+ unwrapEvaluateResult,
9
+ } from './shared.js';
10
+
11
+ function normalizeProfileUrl(value) {
12
+ const url = assertSafeLinkedinUrl(value || 'https://www.linkedin.com/in/me/', 'profile-url', '/in/me/');
13
+ const parsed = new URL(url);
14
+ if (!/^\/in\/[^/?#]+\/?$/.test(parsed.pathname)) {
15
+ throw new CommandExecutionError('LinkedIn profile-projects requires a /in/<handle>/ profile URL');
16
+ }
17
+ return parsed.toString();
18
+ }
19
+
20
+ function profileProjectsUrl(profileUrl) {
21
+ const url = assertSafeLinkedinUrl(profileUrl, 'profile-url');
22
+ const parsed = new URL(url);
23
+ if (!/^\/in\/[^/?#]+\/?$/.test(parsed.pathname) || parsed.pathname === '/in/me/') {
24
+ throw new CommandExecutionError('LinkedIn profile-projects requires a resolved /in/<handle>/ profile URL');
25
+ }
26
+ return new URL(`${parsed.pathname.replace(/\/?$/, '/') }details/projects/`, 'https://www.linkedin.com').toString();
27
+ }
28
+
29
+ function parseProjectText(rawText, profileUrl, index) {
30
+ const lines = String(rawText || '')
31
+ .split(/\n+/)
32
+ .map(normalizeWhitespace)
33
+ .filter(Boolean)
34
+ .filter((line) => !/^(show all|show less|edit|delete|add project|back to profile|projects)$/i.test(line));
35
+ const title = lines[0] || '';
36
+ const dateIndex = lines.findIndex((line) => /\b(?:jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec|\d{4}|present)\b/i.test(line));
37
+ const dateRange = dateIndex >= 0 ? lines[dateIndex] : '';
38
+ const associatedWith = lines.find((line) => /^associated with\b/i.test(line)) || '';
39
+ const skillLine = lines.find((line) => /\bskills?:/i.test(line)) || '';
40
+ const description = lines
41
+ .filter((line, lineIndex) => lineIndex !== 0)
42
+ .filter((line) => line !== dateRange && line !== associatedWith && line !== skillLine)
43
+ .join(' ');
44
+ return {
45
+ rank: index + 1,
46
+ title,
47
+ date_range: dateRange,
48
+ associated_with: associatedWith.replace(/^associated with\s*/i, ''),
49
+ description,
50
+ skills: skillLine.replace(/^skills?:\s*/i, ''),
51
+ media: '',
52
+ urls: '',
53
+ profile_url: profileUrl,
54
+ raw_text: lines.join(' | '),
55
+ };
56
+ }
57
+
58
+ function decodeLinkedInSafetyUrl(value) {
59
+ const url = normalizeWhitespace(value);
60
+ if (!url) return '';
61
+ try {
62
+ const parsed = new URL(url);
63
+ if (parsed.hostname.endsWith('linkedin.com') && parsed.pathname === '/safety/go/') {
64
+ return normalizeHttpUrl(parsed.searchParams.get('url') || '');
65
+ }
66
+ } catch {}
67
+ return normalizeHttpUrl(url);
68
+ }
69
+
70
+ function parseProjectsSectionText(rawText, profileUrl) {
71
+ const isDateLine = (line) => /\b(?:jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)\s+\d{4}\s*[–-]\s*(?:present|[a-z]{3,9}\s+\d{4})\b/i.test(line);
72
+ const stopLine = (line) => /^(who your viewers also viewed|people you may know|about|accessibility|talent solutions|community guidelines|careers|marketing solutions|privacy & terms)$/i.test(line);
73
+ const lines = String(rawText || '')
74
+ .split(/\n+/)
75
+ .map(normalizeWhitespace)
76
+ .filter(Boolean)
77
+ .filter((line) => !/^(show all|show less|edit|delete|add project|back to profile|projects|show project|←|\+)$/i.test(line));
78
+ const scoped = [];
79
+ for (const line of lines) {
80
+ if (stopLine(line)) break;
81
+ scoped.push(line);
82
+ }
83
+ const rows = [];
84
+ for (let i = 0; i < scoped.length - 1; i++) {
85
+ if (!isDateLine(scoped[i + 1])) continue;
86
+ let end = scoped.length;
87
+ for (let j = i + 2; j < scoped.length - 1; j++) {
88
+ if (isDateLine(scoped[j + 1])) {
89
+ end = j;
90
+ break;
91
+ }
92
+ }
93
+ const row = parseProjectText(scoped.slice(i, end).join('\n'), profileUrl, rows.length);
94
+ if (row.title) rows.push(row);
95
+ i = end - 1;
96
+ }
97
+ return rows;
98
+ }
99
+
100
+ function buildProjectsExtractionScript() {
101
+ return String.raw`(() => {
102
+ const clean = (s) => String(s || '').replace(/[\u00a0\u202f]+/g, ' ').replace(/\s+/g, ' ').trim();
103
+ const decodeLinkedInSafetyUrl = (value) => {
104
+ if (!value) return '';
105
+ try {
106
+ const parsed = new URL(value, location.origin);
107
+ if (parsed.hostname.endsWith('linkedin.com') && parsed.pathname === '/safety/go/') {
108
+ const decoded = parsed.searchParams.get('url') || '';
109
+ try {
110
+ const target = new URL(decoded, location.origin);
111
+ if (target.protocol === 'http:' || target.protocol === 'https:') return target.toString();
112
+ return '';
113
+ } catch {
114
+ return '';
115
+ }
116
+ }
117
+ if (parsed.protocol === 'http:' || parsed.protocol === 'https:') return parsed.toString();
118
+ return '';
119
+ } catch {
120
+ return '';
121
+ }
122
+ };
123
+ const splitLines = (text) => String(text || '').split(/\n+/).map(clean).filter(Boolean);
124
+ const isChromeLine = (line) => /^(show all|show less|edit|delete|add project|back to profile|projects|show project|←|\+)$/i.test(line);
125
+ const isDateLine = (line) => /\b(?:jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)\s+\d{4}\s*[–-]\s*(?:present|[a-z]{3,9}\s+\d{4})\b/i.test(line);
126
+ const parseRow = (root, index) => {
127
+ const raw = clean(root.innerText || root.textContent || '');
128
+ const lines = splitLines(root.innerText || root.textContent || '')
129
+ .filter((line) => !isChromeLine(line));
130
+ const title = lines[0] || '';
131
+ const dateLine = lines.find((line) => /\b(?:jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec|\d{4}|present)\b/i.test(line)) || '';
132
+ const associatedLine = lines.find((line) => /^associated with\b/i.test(line)) || '';
133
+ const skillLine = lines.find((line) => /\bskills?:/i.test(line)) || '';
134
+ const description = lines
135
+ .filter((line, lineIndex) => lineIndex !== 0)
136
+ .filter((line) => line !== dateLine && line !== associatedLine && line !== skillLine)
137
+ .join(' ');
138
+ const urls = Array.from(root.querySelectorAll('a[href]'))
139
+ .map((link) => new URL(link.href, location.origin).toString())
140
+ .filter((href) => !/linkedin\.com\/in\//i.test(href) && !/linkedin\.com\/search\//i.test(href))
141
+ .map(decodeLinkedInSafetyUrl)
142
+ .filter(Boolean)
143
+ .map((href) => /linkedin\.com/i.test(href) ? href.replace(/[?#].*$/, '') : href);
144
+ const media = Array.from(root.querySelectorAll('img[alt], video'))
145
+ .map((node) => node.tagName.toLowerCase() === 'video' ? 'video' : clean(node.getAttribute('alt') || ''))
146
+ .filter(Boolean)
147
+ .filter((value) => !/profile|photo of|emoji|reaction/i.test(value));
148
+ return {
149
+ rank: index + 1,
150
+ title,
151
+ date_range: dateLine,
152
+ associated_with: associatedLine.replace(/^associated with\s*/i, ''),
153
+ description,
154
+ skills: skillLine.replace(/^skills?:\s*/i, ''),
155
+ media: Array.from(new Set(media)).join(' | '),
156
+ urls: Array.from(new Set(urls)).join(' | '),
157
+ profile_url: location.href.replace(/\/details\/projects\/?.*$/i, '/'),
158
+ raw_text: raw,
159
+ };
160
+ };
161
+ const main = document.querySelector('main') || document.body;
162
+ const projectLinksByTitle = new Map(Array.from(main.querySelectorAll('a[href][aria-label^="Show "]'))
163
+ .map((link) => {
164
+ const label = clean(link.getAttribute('aria-label') || '');
165
+ const title = label.replace(/^Show\s+/i, '').replace(/\s+project$/i, '');
166
+ return [title, decodeLinkedInSafetyUrl(link.href)];
167
+ })
168
+ .filter(([title, href]) => title && href));
169
+ const candidates = Array.from(main.querySelectorAll('li, [role="listitem"], article'))
170
+ .filter((node) => {
171
+ const text = clean(node.innerText || node.textContent || '');
172
+ if (text.length < 8) return false;
173
+ if (/^(projects|show all|show less|edit|add project)$/i.test(text)) return false;
174
+ return /\b(?:associated with|skills?:|jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec|\d{4}|present)\b/i.test(text);
175
+ });
176
+ const projectRows = [];
177
+ const seen = new Set();
178
+ for (const candidate of candidates) {
179
+ const row = parseRow(candidate, projectRows.length);
180
+ row.urls = row.urls || projectLinksByTitle.get(row.title) || '';
181
+ const key = row.title + '::' + row.date_range + '::' + row.description.slice(0, 80);
182
+ if (!row.title || seen.has(key)) continue;
183
+ seen.add(key);
184
+ projectRows.push(row);
185
+ }
186
+ if (projectRows.length === 0) {
187
+ const section = Array.from(main.querySelectorAll('section'))
188
+ .find((node) => /^Projects\b/i.test(clean(node.innerText || node.textContent || '')));
189
+ const sectionLines = splitLines(section?.innerText || section?.textContent || '');
190
+ const startIndex = sectionLines.findIndex((line) => /^projects$/i.test(line));
191
+ const scopedLines = [];
192
+ for (const line of sectionLines.slice(startIndex >= 0 ? startIndex + 1 : 0)) {
193
+ if (/^(who your viewers also viewed|people you may know|about|accessibility|talent solutions|community guidelines|careers|marketing solutions|privacy & terms)$/i.test(line)) break;
194
+ if (isChromeLine(line)) continue;
195
+ scopedLines.push(line);
196
+ }
197
+ for (let i = 0; i < scopedLines.length - 1; i++) {
198
+ if (!isDateLine(scopedLines[i + 1])) continue;
199
+ let end = scopedLines.length;
200
+ for (let j = i + 2; j < scopedLines.length - 1; j++) {
201
+ if (isDateLine(scopedLines[j + 1])) {
202
+ end = j;
203
+ break;
204
+ }
205
+ }
206
+ const syntheticRoot = {
207
+ innerText: scopedLines.slice(i, end).join('\n'),
208
+ textContent: scopedLines.slice(i, end).join('\n'),
209
+ querySelectorAll: () => [],
210
+ };
211
+ const row = parseRow(syntheticRoot, projectRows.length);
212
+ row.urls = projectLinksByTitle.get(row.title) || row.urls;
213
+ const key = row.title + '::' + row.date_range + '::' + row.description.slice(0, 80);
214
+ if (row.title && !seen.has(key)) {
215
+ seen.add(key);
216
+ projectRows.push(row);
217
+ }
218
+ i = end - 1;
219
+ }
220
+ }
221
+ return { projectRows, pageHref: location.href, pageTitle: document.title || '' };
222
+ })()`;
223
+ }
224
+
225
+ function normalizeProject(row) {
226
+ if (!row || typeof row !== 'object') {
227
+ throw new CommandExecutionError('LinkedIn profile-projects returned malformed row');
228
+ }
229
+ const title = normalizeWhitespace(row.title);
230
+ if (!title) throw new CommandExecutionError('LinkedIn profile-projects returned a project without a title');
231
+ return {
232
+ rank: Number(row.rank) || 0,
233
+ title,
234
+ date_range: normalizeWhitespace(row.date_range),
235
+ associated_with: normalizeWhitespace(row.associated_with),
236
+ description: normalizeWhitespace(row.description),
237
+ skills: normalizeWhitespace(row.skills),
238
+ media: normalizeWhitespace(row.media),
239
+ urls: normalizeWhitespace(row.urls)
240
+ .split(/\s*\|\s*/)
241
+ .map((url) => normalizeHttpUrl(url))
242
+ .filter(Boolean)
243
+ .join(' | '),
244
+ profile_url: normalizeWhitespace(row.profile_url),
245
+ raw_text: normalizeWhitespace(row.raw_text),
246
+ };
247
+ }
248
+
249
+ cli({
250
+ site: 'linkedin',
251
+ name: 'profile-projects',
252
+ access: 'read',
253
+ description: 'Read visible LinkedIn profile projects with descriptions, dates, skills, media, and URLs',
254
+ domain: 'www.linkedin.com',
255
+ strategy: Strategy.COOKIE,
256
+ browser: true,
257
+ args: [
258
+ { name: 'profile-url', type: 'string', required: false, help: 'LinkedIn /in/<handle>/ profile URL. Defaults to /in/me/.' },
259
+ ],
260
+ columns: ['rank', 'title', 'date_range', 'associated_with', 'description', 'skills', 'media', 'urls', 'profile_url', 'raw_text'],
261
+ func: async (page, args) => {
262
+ if (!page) throw new CommandExecutionError('Browser session required for linkedin profile-projects');
263
+ const profileUrl = normalizeProfileUrl(args['profile-url']);
264
+ let projectsUrl;
265
+ if (!args['profile-url'] || new URL(profileUrl).pathname === '/in/me/') {
266
+ await page.goto(profileUrl);
267
+ await page.wait(4);
268
+ await assertLinkedInAuthenticated(page, 'LinkedIn profile-projects');
269
+ const resolvedProfileUrl = unwrapEvaluateResult(await page.evaluate(String.raw`(() => {
270
+ const current = new URL(location.href);
271
+ if (/^\/in\/[^/?#]+\/?$/.test(current.pathname) && current.pathname !== '/in/me/') return current.toString();
272
+ const ownProfileLink = Array.from(document.querySelectorAll('a[href^="/in/"]'))
273
+ .map((link) => new URL(link.href, location.origin))
274
+ .find((url) => /^\/in\/[^/?#]+\/?$/.test(url.pathname) && url.pathname !== '/in/me/');
275
+ return ownProfileLink ? ownProfileLink.toString() : '';
276
+ })()`));
277
+ if (!resolvedProfileUrl) {
278
+ throw new CommandExecutionError('LinkedIn profile-projects could not resolve /in/me/ to a profile URL');
279
+ }
280
+ projectsUrl = profileProjectsUrl(resolvedProfileUrl);
281
+ } else {
282
+ projectsUrl = profileProjectsUrl(profileUrl);
283
+ }
284
+ await page.goto(projectsUrl);
285
+ await page.wait(5);
286
+ await assertLinkedInAuthenticated(page, 'LinkedIn profile-projects');
287
+ try {
288
+ await page.wait({ text: 'Projects', timeout: 10000 });
289
+ } catch {}
290
+ await page.autoScroll({ times: 3, delayMs: 700 });
291
+ await page.wait(1);
292
+ const payload = unwrapEvaluateResult(await page.evaluate(buildProjectsExtractionScript()));
293
+ if (!payload || !Array.isArray(payload.projectRows)) {
294
+ throw new CommandExecutionError('LinkedIn profile-projects returned malformed extraction payload');
295
+ }
296
+ const rows = payload.projectRows.map(normalizeProject);
297
+ if (rows.length === 0) {
298
+ throw new EmptyResultError('linkedin profile-projects', 'No visible LinkedIn profile projects were found.');
299
+ }
300
+ return rows.map((row, index) => ({ ...row, rank: index + 1 }));
301
+ },
302
+ });
303
+
304
+ export const __test__ = {
305
+ normalizeProfileUrl,
306
+ profileProjectsUrl,
307
+ parseProjectText,
308
+ parseProjectsSectionText,
309
+ decodeLinkedInSafetyUrl,
310
+ normalizeProject,
311
+ };
@@ -0,0 +1,111 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { getRegistry } from '@jackwener/opencli/registry';
3
+ import { CommandExecutionError } from '@jackwener/opencli/errors';
4
+ import './profile-projects.js';
5
+
6
+ const { normalizeProfileUrl, profileProjectsUrl, parseProjectText, parseProjectsSectionText, decodeLinkedInSafetyUrl, normalizeProject } = await import('./profile-projects.js').then((m) => m.__test__);
7
+
8
+ describe('linkedin profile-projects adapter', () => {
9
+ const command = getRegistry().get('linkedin/profile-projects');
10
+
11
+ it('registers command shape', () => {
12
+ expect(command).toBeDefined();
13
+ expect(command.strategy).toBe('cookie');
14
+ expect(command.browser).toBe(true);
15
+ expect(command.columns).toEqual([
16
+ 'rank',
17
+ 'title',
18
+ 'date_range',
19
+ 'associated_with',
20
+ 'description',
21
+ 'skills',
22
+ 'media',
23
+ 'urls',
24
+ 'profile_url',
25
+ 'raw_text',
26
+ ]);
27
+ });
28
+
29
+ it('normalizes default and explicit profile URLs', () => {
30
+ expect(normalizeProfileUrl(undefined)).toBe('https://www.linkedin.com/in/me/');
31
+ expect(normalizeProfileUrl('https://www.linkedin.com/in/gauravsaxena1997?x=1')).toBe('https://www.linkedin.com/in/gauravsaxena1997?x=1');
32
+ expect(profileProjectsUrl('https://www.linkedin.com/in/gauravsaxena1997?x=1')).toBe('https://www.linkedin.com/in/gauravsaxena1997/details/projects/');
33
+ });
34
+
35
+ it('rejects non-profile URLs', () => {
36
+ expect(() => normalizeProfileUrl('https://www.linkedin.com/jobs/')).toThrow(CommandExecutionError);
37
+ expect(() => profileProjectsUrl('https://www.linkedin.com/in/me/')).toThrow(CommandExecutionError);
38
+ });
39
+
40
+ it('parses visible project text into fields', () => {
41
+ expect(parseProjectText(`OpenCLI Contributions
42
+ Jan 2026 - Present
43
+ Associated with Open Source
44
+ Browser automation and CLI adapter work
45
+ Skills: JavaScript, Browser Automation`, 'https://www.linkedin.com/in/me/', 0)).toMatchObject({
46
+ rank: 1,
47
+ title: 'OpenCLI Contributions',
48
+ date_range: 'Jan 2026 - Present',
49
+ associated_with: 'Open Source',
50
+ description: 'Browser automation and CLI adapter work',
51
+ skills: 'JavaScript, Browser Automation',
52
+ });
53
+ });
54
+
55
+ it('parses a LinkedIn projects section rendered as one section', () => {
56
+ const rows = parseProjectsSectionText(`Projects
57
+
58
+ Data Lake
59
+
60
+ May 2018 – Present
61
+
62
+ Associated with AdHoc Networks, Jaipur
63
+
64
+ Show project
65
+
66
+ Data Lake is a Data consolidation project.
67
+ - It provide HDFS services with Docker and VM.
68
+
69
+ Karyavahi
70
+
71
+ May 2017 – Present
72
+
73
+ Associated with Jaipur Engineering College & Research Center,jaipur
74
+
75
+ Show project
76
+
77
+ A social media for social issues.
78
+ Framework Used: Django , Bootstrap
79
+
80
+ Who your viewers also viewed`, 'https://www.linkedin.com/in/gauravsaxena1997/');
81
+
82
+ expect(rows).toHaveLength(2);
83
+ expect(rows[0]).toMatchObject({
84
+ title: 'Data Lake',
85
+ date_range: 'May 2018 – Present',
86
+ associated_with: 'AdHoc Networks, Jaipur',
87
+ description: 'Data Lake is a Data consolidation project. - It provide HDFS services with Docker and VM.',
88
+ });
89
+ expect(rows[1]).toMatchObject({
90
+ title: 'Karyavahi',
91
+ date_range: 'May 2017 – Present',
92
+ associated_with: 'Jaipur Engineering College & Research Center,jaipur',
93
+ description: 'A social media for social issues. Framework Used: Django , Bootstrap',
94
+ });
95
+ });
96
+
97
+ it('normalizes rows and requires a title', () => {
98
+ expect(() => normalizeProject({ title: '' })).toThrow(CommandExecutionError);
99
+ expect(normalizeProject({ rank: '2', title: ' Moniqo ', urls: ' https://example.com ' }))
100
+ .toMatchObject({ rank: 2, title: 'Moniqo', urls: 'https://example.com/' });
101
+ expect(normalizeProject({ title: ' Moniqo ', urls: 'javascript:alert(1) | https://example.com/demo' }))
102
+ .toMatchObject({ urls: 'https://example.com/demo' });
103
+ });
104
+
105
+ it('decodes LinkedIn safety redirect URLs', () => {
106
+ expect(decodeLinkedInSafetyUrl('https://www.linkedin.com/safety/go/?url=https%3A%2F%2Fgithub.com%2Fjackwener%2FOpenCLI&urlhash=x'))
107
+ .toBe('https://github.com/jackwener/OpenCLI');
108
+ expect(decodeLinkedInSafetyUrl('https://www.linkedin.com/safety/go/?url=javascript%3Aalert(1)&urlhash=x'))
109
+ .toBe('');
110
+ });
111
+ });