@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,148 @@
1
+ import { cli, Strategy } from '@jackwener/opencli/registry';
2
+ import { CommandExecutionError } from '@jackwener/opencli/errors';
3
+ import {
4
+ assertLinkedInAuthenticated,
5
+ assertSafeLinkedinUrl,
6
+ compactRepeatedText,
7
+ normalizeWhitespace,
8
+ unwrapEvaluateResult,
9
+ } from './shared.js';
10
+
11
+ function normalizeProfileReadUrl(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-read requires a /in/<handle>/ profile URL');
16
+ }
17
+ return parsed.toString();
18
+ }
19
+
20
+ function buildProfileExtractionScript() {
21
+ return String.raw`(() => {
22
+ const clean = (s) => String(s || '').replace(/[\u00a0\u202f]+/g, ' ').replace(/\s+/g, ' ').trim();
23
+ const compact = (s) => {
24
+ const text = clean(s);
25
+ if (!text) return '';
26
+ if (text.length % 2 === 0 && text.slice(0, text.length / 2) === text.slice(text.length / 2)) {
27
+ return text.slice(0, text.length / 2);
28
+ }
29
+ return text;
30
+ };
31
+ const readSection = (headingPattern) => {
32
+ const headings = Array.from(document.querySelectorAll('section h2, section h3, h2, h3'));
33
+ const heading = headings.find((el) => headingPattern.test(clean(el.innerText || el.textContent || '')));
34
+ const section = heading?.closest('section');
35
+ if (!section) return '';
36
+ const text = clean(section.innerText || section.textContent || '');
37
+ return clean(text.replace(headingPattern, '').replace(/\bShow all.*$/i, '').replace(/\bSee more.*$/i, ''));
38
+ };
39
+ const nameHeading = document.querySelector('main h1, main h2');
40
+ const intro = nameHeading?.closest('section') || document.querySelector('main section') || document.body;
41
+ const lines = (intro.innerText || intro.textContent || '').split(/\n+/).map(clean).filter(Boolean);
42
+ const name = compact(clean(nameHeading?.innerText || nameHeading?.textContent || lines[0] || ''));
43
+ const skipIntro = (line) => !line
44
+ || line === name
45
+ || /^(1st|2nd|3rd|contact info|message|more|follow|connect|open to|add section|enhance profile|resources|self employed)$/i.test(line)
46
+ || /^\d[\d,]*\s+(followers|connections)/i.test(line)
47
+ || line === '·';
48
+ const headline = compact(lines.find((line) => !skipIntro(line) && line.length > 20) || '');
49
+ const locationText = lines.find((line) => /(area|india|jaipur|bangalore|bengaluru|delhi|mumbai|hyderabad|pune)/i.test(line) && line.length < 120) || '';
50
+ const about = readSection(/^About$/i);
51
+ const experience = readSection(/^Experience$/i);
52
+ const education = readSection(/^Education$/i);
53
+ const featured = readSection(/^Featured$/i);
54
+ const services = readSection(/^Services$/i) || readSection(/^Providing services$/i);
55
+ return {
56
+ profile_url: window.location.href,
57
+ name,
58
+ headline,
59
+ location: locationText,
60
+ about,
61
+ experience,
62
+ education,
63
+ services,
64
+ featured,
65
+ };
66
+ })()`;
67
+ }
68
+
69
+ function buildAboutEditExtractionScript() {
70
+ return String.raw`(() => {
71
+ const clean = (s) => String(s || '').replace(/[\u00a0\u202f]+/g, ' ').replace(/\s+/g, ' ').trim();
72
+ const dialog = document.querySelector('dialog') || document;
73
+ const editor = dialog.querySelector('[contenteditable="true"]');
74
+ const about = Array.from(editor?.querySelectorAll('p') || [])
75
+ .map((p) => clean(p.innerText || p.textContent || ''))
76
+ .join('\n')
77
+ .trim();
78
+ const about_skills = Array.from(dialog.querySelectorAll('[role="listitem"][aria-label]'))
79
+ .map((el) => clean(el.getAttribute('aria-label') || ''))
80
+ .filter(Boolean);
81
+ const about_character_count = Array.from(dialog.querySelectorAll('span, p'))
82
+ .map((el) => clean(el.innerText || el.textContent || ''))
83
+ .find((text) => /^\d[\d,]*\/2,600$/.test(text)) || '';
84
+ return { about, about_skills, about_character_count };
85
+ })()`;
86
+ }
87
+
88
+ function normalizeProfile(row) {
89
+ if (!row || typeof row !== 'object') {
90
+ throw new CommandExecutionError('LinkedIn profile-read returned malformed extraction payload');
91
+ }
92
+ const name = compactRepeatedText(row.name);
93
+ if (!name) throw new CommandExecutionError('LinkedIn profile-read could not find a profile name');
94
+ return {
95
+ profile_url: normalizeWhitespace(row.profile_url),
96
+ name,
97
+ headline: compactRepeatedText(row.headline),
98
+ location: normalizeWhitespace(row.location),
99
+ about: normalizeWhitespace(row.about),
100
+ about_character_count: normalizeWhitespace(row.about_character_count),
101
+ about_skills: Array.isArray(row.about_skills) ? row.about_skills.map(normalizeWhitespace).filter(Boolean).join('; ') : '',
102
+ experience: normalizeWhitespace(row.experience),
103
+ education: normalizeWhitespace(row.education),
104
+ services: normalizeWhitespace(row.services),
105
+ featured: normalizeWhitespace(row.featured),
106
+ };
107
+ }
108
+
109
+ cli({
110
+ site: 'linkedin',
111
+ name: 'profile-read',
112
+ access: 'read',
113
+ description: 'Read visible LinkedIn profile sections: headline, About, experience, education, services, and featured sections',
114
+ domain: 'www.linkedin.com',
115
+ strategy: Strategy.COOKIE,
116
+ browser: true,
117
+ args: [
118
+ { name: 'profile-url', type: 'string', required: false, help: 'LinkedIn /in/<handle>/ profile URL. Defaults to /in/me/.' },
119
+ ],
120
+ columns: ['profile_url', 'name', 'headline', 'location', 'about', 'about_character_count', 'about_skills', 'experience', 'education', 'services', 'featured'],
121
+ func: async (page, args) => {
122
+ if (!page) throw new CommandExecutionError('Browser session required for linkedin profile-read');
123
+ const profileUrl = normalizeProfileReadUrl(args['profile-url']);
124
+ const shouldReadEditor = !normalizeWhitespace(args['profile-url']);
125
+ await page.goto(profileUrl);
126
+ await page.wait(5);
127
+ await assertLinkedInAuthenticated(page, 'LinkedIn profile-read');
128
+ await page.autoScroll({ times: 4, delayMs: 700 });
129
+ await page.wait(1);
130
+ const row = unwrapEvaluateResult(await page.evaluate(buildProfileExtractionScript()));
131
+ let aboutEdit = {};
132
+ if (shouldReadEditor) {
133
+ const currentProfileUrl = normalizeWhitespace(row?.profile_url) || profileUrl;
134
+ const profilePath = new URL(currentProfileUrl).pathname.replace(/\/?$/, '/');
135
+ const aboutEditUrl = new URL(`${profilePath}edit/forms/summary/new/`, 'https://www.linkedin.com').toString();
136
+ await page.goto(aboutEditUrl);
137
+ await page.wait(4);
138
+ await assertLinkedInAuthenticated(page, 'LinkedIn profile-read about editor');
139
+ aboutEdit = unwrapEvaluateResult(await page.evaluate(buildAboutEditExtractionScript()));
140
+ }
141
+ return [normalizeProfile({ ...row, ...aboutEdit })];
142
+ },
143
+ });
144
+
145
+ export const __test__ = {
146
+ normalizeProfileReadUrl,
147
+ normalizeProfile,
148
+ };
@@ -0,0 +1,77 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { getRegistry } from '@jackwener/opencli/registry';
3
+ import { CommandExecutionError } from '@jackwener/opencli/errors';
4
+ import './profile-read.js';
5
+
6
+ const { normalizeProfileReadUrl, normalizeProfile } = await import('./profile-read.js').then((m) => m.__test__);
7
+
8
+ describe('linkedin profile-read adapter', () => {
9
+ const command = getRegistry().get('linkedin/profile-read');
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
+ 'profile_url',
17
+ 'name',
18
+ 'headline',
19
+ 'location',
20
+ 'about',
21
+ 'about_character_count',
22
+ 'about_skills',
23
+ 'experience',
24
+ 'education',
25
+ 'services',
26
+ 'featured',
27
+ ]);
28
+ });
29
+
30
+ it('normalizes profile url default and explicit /in URL', () => {
31
+ expect(normalizeProfileReadUrl(undefined)).toBe('https://www.linkedin.com/in/me/');
32
+ expect(normalizeProfileReadUrl('https://www.linkedin.com/in/gauravsaxena1997?x=1')).toBe('https://www.linkedin.com/in/gauravsaxena1997?x=1');
33
+ });
34
+
35
+ it('rejects non-profile URLs', () => {
36
+ expect(() => normalizeProfileReadUrl('https://www.linkedin.com/jobs/')).toThrow(CommandExecutionError);
37
+ });
38
+
39
+ it('normalizes duplicated profile text and requires a name', () => {
40
+ expect(() => normalizeProfile({ name: '' })).toThrow(CommandExecutionError);
41
+ expect(normalizeProfile({
42
+ name: 'AliceAlice',
43
+ headline: 'EngineerEngineer',
44
+ about: ' Builds AI ',
45
+ about_character_count: '1,100/2,600',
46
+ about_skills: ['AI', 'TypeScript'],
47
+ })).toMatchObject({
48
+ name: 'Alice',
49
+ headline: 'Engineer',
50
+ about: 'Builds AI',
51
+ about_character_count: '1,100/2,600',
52
+ about_skills: 'AI; TypeScript',
53
+ });
54
+ });
55
+
56
+ it('does not require edit access when reading an explicit profile URL', async () => {
57
+ const page = {
58
+ goto: vi.fn(async () => {}),
59
+ wait: vi.fn(async () => {}),
60
+ autoScroll: vi.fn(async () => {}),
61
+ evaluate: vi.fn()
62
+ .mockResolvedValueOnce(false)
63
+ .mockResolvedValueOnce({
64
+ profile_url: 'https://www.linkedin.com/in/alice/',
65
+ name: 'Alice',
66
+ headline: 'Engineer',
67
+ about: 'Builds products',
68
+ }),
69
+ };
70
+
71
+ await expect(command.func(page, { 'profile-url': 'https://www.linkedin.com/in/alice/' }))
72
+ .resolves.toMatchObject([{ name: 'Alice', about: 'Builds products' }]);
73
+ expect(page.goto).toHaveBeenCalledTimes(1);
74
+ expect(page.goto).toHaveBeenCalledWith('https://www.linkedin.com/in/alice/');
75
+ expect(page.goto.mock.calls.some(([url]) => String(url).includes('/edit/forms/'))).toBe(false);
76
+ });
77
+ });
@@ -0,0 +1,213 @@
1
+ import { cli, Strategy } from '@jackwener/opencli/registry';
2
+ import { CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
3
+ import {
4
+ assertLinkedInAuthenticated,
5
+ assertSafeLinkedinUrl,
6
+ normalizeWhitespace,
7
+ unwrapEvaluateResult,
8
+ } from './shared.js';
9
+
10
+ function normalizeProfileUrl(value) {
11
+ const url = assertSafeLinkedinUrl(value || 'https://www.linkedin.com/in/me/', 'profile-url', '/in/me/');
12
+ const parsed = new URL(url);
13
+ if (!/^\/in\/[^/?#]+\/?$/.test(parsed.pathname)) {
14
+ throw new CommandExecutionError('LinkedIn services-read requires a /in/<handle>/ profile URL');
15
+ }
16
+ return parsed.toString();
17
+ }
18
+
19
+ function normalizeServicesUrl(value) {
20
+ const url = assertSafeLinkedinUrl(value, 'services-url', '/services/page/');
21
+ const parsed = new URL(url);
22
+ if (!/^\/services\/page\/[^/?#]+\/?$/.test(parsed.pathname)) {
23
+ throw new CommandExecutionError('LinkedIn services-read requires a /services/page/<id>/ URL');
24
+ }
25
+ return parsed.toString();
26
+ }
27
+
28
+ function buildFindServicesUrlScript() {
29
+ return String.raw`(() => {
30
+ const link = Array.from(document.querySelectorAll('a[href*="/services/page/"]'))
31
+ .map((a) => a.href || '')
32
+ .find(Boolean);
33
+ return { services_url: link || '' };
34
+ })()`;
35
+ }
36
+
37
+ function buildServicesPageScript() {
38
+ return String.raw`(() => {
39
+ const clean = (s) => String(s || '').replace(/[\u00a0\u202f]+/g, ' ').replace(/\s+/g, ' ').trim();
40
+ const lines = (document.body?.innerText || '').split(/\n+/).map(clean).filter(Boolean);
41
+ const unique = (items) => Array.from(new Set(items.filter(Boolean)));
42
+ const collectAfter = (label, stops) => {
43
+ const index = lines.findIndex((line) => line === label);
44
+ if (index < 0) return [];
45
+ const out = [];
46
+ for (let i = index + 1; i < lines.length; i++) {
47
+ if (stops.includes(lines[i])) break;
48
+ if (lines[i] !== label) out.push(lines[i]);
49
+ }
50
+ return unique(out);
51
+ };
52
+ return {
53
+ service_url: location.href,
54
+ page_title: clean(document.querySelector('main h1, h1')?.innerText || document.title || ''),
55
+ overview: collectAfter('Overview', ['Availability', 'Pricing', 'Services provided', 'Media', 'Reviews']).join('\n'),
56
+ availability: collectAfter('Availability', ['Pricing', 'Services provided', 'Media', 'Reviews']).join('; '),
57
+ pricing: collectAfter('Pricing', ['Services provided', 'Media', 'Reviews']).join('; '),
58
+ services_provided: collectAfter('Services provided', ['Media', 'Reviews', 'Pricing', 'Availability', 'Overview']),
59
+ };
60
+ })()`;
61
+ }
62
+
63
+ function buildMediaPageScript() {
64
+ return String.raw`(() => {
65
+ const clean = (s) => String(s || '').replace(/[\u00a0\u202f]+/g, ' ').replace(/\s+/g, ' ').trim();
66
+ const lines = (document.body?.innerText || '').split(/\n+/).map(clean).filter(Boolean);
67
+ const start = lines.findIndex((line) => line === 'Add media');
68
+ const end = lines.findIndex((line, index) => index > start && line === 'Done');
69
+ const media_lines = start >= 0 && end > start ? lines.slice(start + 1, end) : [];
70
+ return { media_lines };
71
+ })()`;
72
+ }
73
+
74
+ function buildServicesEditScript() {
75
+ return String.raw`(() => {
76
+ const clean = (s) => String(s || '').replace(/[\u00a0\u202f]+/g, ' ').replace(/\s+/g, ' ').trim();
77
+ const dialog = document.querySelector('dialog') || document;
78
+ const overview = dialog.querySelector('textarea')?.value || '';
79
+ const checked = Array.from(dialog.querySelectorAll('[role="checkbox"], [role="switch"], input[type="checkbox"]'))
80
+ .map((el) => ({
81
+ label: clean(el.getAttribute('aria-label') || el.innerText || el.closest('div')?.innerText || el.parentElement?.innerText || ''),
82
+ checked: el.getAttribute('aria-checked') === 'true' || el.checked === true,
83
+ }));
84
+ const radios = Array.from(dialog.querySelectorAll('[role="radio"], input[type="radio"]'))
85
+ .map((el) => ({
86
+ label: clean(el.getAttribute('aria-label') || el.innerText || el.closest('div')?.innerText || el.parentElement?.innerText || ''),
87
+ checked: el.getAttribute('aria-checked') === 'true' || el.checked === true,
88
+ }));
89
+ return {
90
+ overview,
91
+ work_locations: checked.filter((item) => item.checked && !/message|linkedin members|reviews?/i.test(item.label)).map((item) => item.label),
92
+ messages: checked.find((item) => /message|open profile/i.test(item.label))?.checked ? 'on' : 'off',
93
+ reviews_visibility: checked.find((item) => /all linkedin members/i.test(item.label))?.checked ? 'on' : 'off',
94
+ pricing: radios.find((item) => item.checked)?.label || '',
95
+ };
96
+ })()`;
97
+ }
98
+
99
+ function pairsToMedia(items) {
100
+ const lines = Array.isArray(items) ? items.map(normalizeWhitespace).filter(Boolean) : [];
101
+ const pairs = [];
102
+ for (let i = 0; i < lines.length; i += 2) {
103
+ const title = lines[i] || '';
104
+ const description = lines[i + 1] || '';
105
+ if (title) pairs.push(description ? `${title} — ${description}` : title);
106
+ }
107
+ return pairs;
108
+ }
109
+
110
+ function normalizeServices(row) {
111
+ if (!row || typeof row !== 'object') {
112
+ throw new CommandExecutionError('LinkedIn services-read returned malformed extraction payload');
113
+ }
114
+ const services = Array.isArray(row.services_provided) ? row.services_provided.map(normalizeWhitespace).filter(Boolean) : [];
115
+ const mediaItems = pairsToMedia(row.media_lines);
116
+ const publicMedia = [];
117
+ const serviceUrl = normalizeWhitespace(row.service_url);
118
+ const pageTitle = normalizeWhitespace(row.page_title);
119
+ const overview = normalizeWhitespace(row.overview);
120
+ const availability = normalizeWhitespace(row.availability);
121
+ if (!serviceUrl || (!pageTitle && !overview && services.length === 0)) {
122
+ throw new CommandExecutionError('LinkedIn services-read could not find stable Services page content');
123
+ }
124
+ return {
125
+ service_url: serviceUrl,
126
+ page_title: pageTitle,
127
+ overview,
128
+ availability,
129
+ work_locations: Array.isArray(row.work_locations) ? row.work_locations.map((item) => {
130
+ const text = normalizeWhitespace(item);
131
+ const words = text.split(' ');
132
+ if (words.length % 2 === 0) {
133
+ const half = words.length / 2;
134
+ const left = words.slice(0, half).join(' ');
135
+ if (left === words.slice(half).join(' ')) return left;
136
+ }
137
+ return text;
138
+ }).filter(Boolean).join('; ') : '',
139
+ pricing: normalizeWhitespace(row.pricing).replace(/^Pricing,\s*Select one option,\s*/i, '').replace(/,\s*required$/i, ''),
140
+ services_provided: services.join('; '),
141
+ services_count: String(services.length),
142
+ media: (mediaItems.length > 0 ? mediaItems : publicMedia).join('\n'),
143
+ media_count: String(mediaItems.length || publicMedia.length),
144
+ messages: normalizeWhitespace(row.messages),
145
+ reviews_visibility: normalizeWhitespace(row.reviews_visibility),
146
+ };
147
+ }
148
+
149
+ async function readOwnerOnlyServicesEdit(page, servicesUrl) {
150
+ const editUrl = new URL(servicesUrl);
151
+ editUrl.pathname = editUrl.pathname.replace(/\/?$/, '/edit/');
152
+ await page.goto(editUrl.toString());
153
+ await page.wait(4);
154
+ await assertLinkedInAuthenticated(page, 'LinkedIn services-read edit');
155
+ return unwrapEvaluateResult(await page.evaluate(buildServicesEditScript()));
156
+ }
157
+
158
+ cli({
159
+ site: 'linkedin',
160
+ name: 'services-read',
161
+ access: 'read',
162
+ description: 'Read LinkedIn Services page details including services, overview, availability, pricing, and media titles/descriptions',
163
+ domain: 'www.linkedin.com',
164
+ strategy: Strategy.COOKIE,
165
+ browser: true,
166
+ args: [
167
+ { name: 'profile-url', type: 'string', required: false, help: 'LinkedIn /in/<handle>/ profile URL. Defaults to /in/me/.' },
168
+ { name: 'services-url', type: 'string', required: false, help: 'LinkedIn /services/page/<id>/ URL. If omitted, it is discovered from the profile.' },
169
+ ],
170
+ columns: ['service_url', 'page_title', 'overview', 'availability', 'work_locations', 'pricing', 'services_provided', 'services_count', 'media', 'media_count', 'messages', 'reviews_visibility'],
171
+ func: async (page, args) => {
172
+ if (!page) throw new CommandExecutionError('Browser session required for linkedin services-read');
173
+ let servicesUrl = normalizeWhitespace(args['services-url']);
174
+ const shouldReadOwnerEdit = !servicesUrl && !normalizeWhitespace(args['profile-url']);
175
+ if (servicesUrl) {
176
+ servicesUrl = normalizeServicesUrl(servicesUrl);
177
+ } else {
178
+ await page.goto(normalizeProfileUrl(args['profile-url']));
179
+ await page.wait(5);
180
+ await assertLinkedInAuthenticated(page, 'LinkedIn services-read profile');
181
+ const found = unwrapEvaluateResult(await page.evaluate(buildFindServicesUrlScript()));
182
+ servicesUrl = normalizeWhitespace(found?.services_url);
183
+ if (!servicesUrl) throw new EmptyResultError('linkedin services-read', 'No LinkedIn Services page link was found on the profile.');
184
+ servicesUrl = normalizeServicesUrl(servicesUrl);
185
+ }
186
+
187
+ await page.goto(servicesUrl);
188
+ await page.wait(5);
189
+ await assertLinkedInAuthenticated(page, 'LinkedIn services-read');
190
+ const services = unwrapEvaluateResult(await page.evaluate(buildServicesPageScript()));
191
+
192
+ const edit = shouldReadOwnerEdit ? await readOwnerOnlyServicesEdit(page, servicesUrl) : {};
193
+
194
+ let media = {};
195
+ if (shouldReadOwnerEdit) {
196
+ const mediaUrl = new URL(servicesUrl);
197
+ mediaUrl.pathname = mediaUrl.pathname.replace(/\/?$/, '/media/');
198
+ await page.goto(mediaUrl.toString());
199
+ await page.wait(4);
200
+ await assertLinkedInAuthenticated(page, 'LinkedIn services-read media');
201
+ media = unwrapEvaluateResult(await page.evaluate(buildMediaPageScript()));
202
+ }
203
+
204
+ return [normalizeServices({ ...services, ...edit, ...media })];
205
+ },
206
+ });
207
+
208
+ export const __test__ = {
209
+ normalizeProfileUrl,
210
+ normalizeServicesUrl,
211
+ pairsToMedia,
212
+ normalizeServices,
213
+ };
@@ -0,0 +1,105 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { getRegistry } from '@jackwener/opencli/registry';
3
+ import { CommandExecutionError } from '@jackwener/opencli/errors';
4
+ import './services-read.js';
5
+
6
+ const {
7
+ normalizeProfileUrl,
8
+ normalizeServicesUrl,
9
+ normalizeServices,
10
+ pairsToMedia,
11
+ } = await import('./services-read.js').then((m) => m.__test__);
12
+
13
+ describe('linkedin services-read adapter', () => {
14
+ const command = getRegistry().get('linkedin/services-read');
15
+
16
+ it('registers command shape', () => {
17
+ expect(command).toBeDefined();
18
+ expect(command.strategy).toBe('cookie');
19
+ expect(command.browser).toBe(true);
20
+ expect(command.columns).toEqual([
21
+ 'service_url',
22
+ 'page_title',
23
+ 'overview',
24
+ 'availability',
25
+ 'work_locations',
26
+ 'pricing',
27
+ 'services_provided',
28
+ 'services_count',
29
+ 'media',
30
+ 'media_count',
31
+ 'messages',
32
+ 'reviews_visibility',
33
+ ]);
34
+ });
35
+
36
+ it('normalizes profile and services URLs', () => {
37
+ expect(normalizeProfileUrl(undefined)).toBe('https://www.linkedin.com/in/me/');
38
+ expect(normalizeProfileUrl('https://www.linkedin.com/in/gauravsaxena1997/')).toBe('https://www.linkedin.com/in/gauravsaxena1997/');
39
+ expect(normalizeServicesUrl('https://www.linkedin.com/services/page/854507342066b51989/')).toBe('https://www.linkedin.com/services/page/854507342066b51989/');
40
+ });
41
+
42
+ it('rejects invalid URL shapes', () => {
43
+ expect(() => normalizeProfileUrl('https://www.linkedin.com/jobs/')).toThrow(CommandExecutionError);
44
+ expect(() => normalizeServicesUrl('https://www.linkedin.com/in/gauravsaxena1997/')).toThrow(CommandExecutionError);
45
+ });
46
+
47
+ it('pairs media title and description lines', () => {
48
+ expect(pairsToMedia(['Portfolio', 'Builds AI products', 'GitHub', 'Open source work']))
49
+ .toEqual(['Portfolio — Builds AI products', 'GitHub — Open source work']);
50
+ });
51
+
52
+ it('normalizes services payload into command columns', () => {
53
+ expect(normalizeServices({
54
+ service_url: 'https://www.linkedin.com/services/page/abc/',
55
+ page_title: 'Gaurav Services',
56
+ overview: ' Builds AI ',
57
+ availability: 'Remote',
58
+ work_locations: ['Greater Jaipur Area', 'I am available to work remotely'],
59
+ pricing: 'Pricing, Select one option, Contact for pricing, required',
60
+ services_provided: ['Web Development', 'SaaS Development'],
61
+ media_lines: ['Portfolio', 'AI products'],
62
+ messages: 'on',
63
+ reviews_visibility: 'off',
64
+ })).toEqual({
65
+ service_url: 'https://www.linkedin.com/services/page/abc/',
66
+ page_title: 'Gaurav Services',
67
+ overview: 'Builds AI',
68
+ availability: 'Remote',
69
+ work_locations: 'Greater Jaipur Area; I am available to work remotely',
70
+ pricing: 'Contact for pricing',
71
+ services_provided: 'Web Development; SaaS Development',
72
+ services_count: '2',
73
+ media: 'Portfolio — AI products',
74
+ media_count: '1',
75
+ messages: 'on',
76
+ reviews_visibility: 'off',
77
+ });
78
+ });
79
+
80
+ it('fails closed when a services page payload has no stable content', () => {
81
+ expect(() => normalizeServices({ service_url: 'https://www.linkedin.com/services/page/abc/' }))
82
+ .toThrow(CommandExecutionError);
83
+ expect(() => normalizeServices({ page_title: 'Alice Services' }))
84
+ .toThrow(CommandExecutionError);
85
+ });
86
+
87
+ it('does not require edit access when reading an explicit services URL', async () => {
88
+ const page = {
89
+ goto: vi.fn(async () => {}),
90
+ wait: vi.fn(async () => {}),
91
+ evaluate: vi.fn()
92
+ .mockResolvedValueOnce(false)
93
+ .mockResolvedValueOnce({
94
+ service_url: 'https://www.linkedin.com/services/page/abc/',
95
+ page_title: 'Alice Services',
96
+ overview: 'Builds AI',
97
+ services_provided: ['AI Consulting'],
98
+ }),
99
+ };
100
+
101
+ await expect(command.func(page, { 'services-url': 'https://www.linkedin.com/services/page/abc/' }))
102
+ .resolves.toMatchObject([{ page_title: 'Alice Services', services_count: '1' }]);
103
+ expect(page.goto.mock.calls.map(([url]) => String(url))).toEqual(['https://www.linkedin.com/services/page/abc/']);
104
+ });
105
+ });
@@ -0,0 +1,124 @@
1
+ import { ArgumentError, AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
2
+
3
+ export const LINKEDIN_DOMAIN = 'www.linkedin.com';
4
+
5
+ export function unwrapEvaluateResult(payload) {
6
+ if (payload && typeof payload === 'object' && 'data' in payload && 'session' in payload) return payload.data;
7
+ return payload;
8
+ }
9
+
10
+ export function normalizeWhitespace(value) {
11
+ return String(value ?? '').replace(/[\u00a0\u202f]+/g, ' ').replace(/\s+/g, ' ').trim();
12
+ }
13
+
14
+ export function normalizeHttpUrl(value, base) {
15
+ const raw = normalizeWhitespace(value);
16
+ if (!raw) return '';
17
+ try {
18
+ const parsed = base ? new URL(raw, base) : new URL(raw);
19
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return '';
20
+ if (parsed.username || parsed.password) return '';
21
+ return parsed.toString();
22
+ } catch {
23
+ return '';
24
+ }
25
+ }
26
+
27
+ export function compactRepeatedText(value) {
28
+ const text = normalizeWhitespace(value);
29
+ if (!text) return '';
30
+ if (text.length % 2 === 0) {
31
+ const half = text.length / 2;
32
+ const left = text.slice(0, half);
33
+ if (left === text.slice(half)) return left;
34
+ }
35
+ const words = text.split(' ');
36
+ if (words.length % 2 === 0) {
37
+ const half = words.length / 2;
38
+ const left = words.slice(0, half).join(' ');
39
+ if (left === words.slice(half).join(' ')) return left;
40
+ }
41
+ return text;
42
+ }
43
+
44
+ export function looksLinkedInAuthWall(value) {
45
+ const text = normalizeWhitespace(value).toLowerCase();
46
+ if (!text) return false;
47
+ return /linkedin\.com\/(?:login|checkpoint|authwall|uas)/i.test(text)
48
+ || /\b(sign in|log in|join linkedin|captcha|verification required)\b/i.test(text)
49
+ || /(请登录|登录领英|安全验证)/.test(text);
50
+ }
51
+
52
+ export function assertSafeLinkedinUrl(value, label, fallbackPath = '/') {
53
+ const raw = normalizeWhitespace(value || `https://www.linkedin.com${fallbackPath}`);
54
+ let parsed;
55
+ try {
56
+ parsed = new URL(raw, 'https://www.linkedin.com');
57
+ } catch {
58
+ throw new ArgumentError(`${label} must be a LinkedIn URL`);
59
+ }
60
+ const host = parsed.hostname.toLowerCase();
61
+ if (parsed.protocol !== 'https:' || parsed.username || parsed.password || parsed.port) {
62
+ throw new ArgumentError(`${label} must be an https LinkedIn URL without credentials or port`);
63
+ }
64
+ if (host !== 'linkedin.com' && host !== 'www.linkedin.com') {
65
+ throw new ArgumentError(`${label} must point to linkedin.com`);
66
+ }
67
+ return parsed.toString();
68
+ }
69
+
70
+ export function requireStringArg(args, key, label = key) {
71
+ const value = normalizeWhitespace(args?.[key]);
72
+ if (!value) throw new ArgumentError(`${label} is required`);
73
+ return value;
74
+ }
75
+
76
+ export function parseLimit(value, fallback, max) {
77
+ if (value === undefined || value === null || value === '') return fallback;
78
+ const parsed = Number(value);
79
+ if (!Number.isInteger(parsed) || parsed < 1 || parsed > max) {
80
+ throw new ArgumentError(`--limit must be an integer between 1 and ${max}`);
81
+ }
82
+ return parsed;
83
+ }
84
+
85
+ export async function requireLinkedInCookie(page, context) {
86
+ let cookies;
87
+ try {
88
+ cookies = await page.getCookies({ url: 'https://www.linkedin.com' });
89
+ } catch (error) {
90
+ throw new CommandExecutionError(`LinkedIn cookie lookup failed: ${error?.message || error}`);
91
+ }
92
+ if (!Array.isArray(cookies)) {
93
+ throw new CommandExecutionError('LinkedIn cookie lookup returned malformed payload');
94
+ }
95
+ const jsession = cookies.find((c) => c.name === 'JSESSIONID')?.value;
96
+ if (!jsession) {
97
+ throw new AuthRequiredError(LINKEDIN_DOMAIN, `${context} requires an active signed-in LinkedIn browser session.`);
98
+ }
99
+ return jsession.replace(/^"|"$/g, '');
100
+ }
101
+
102
+ export function buildAuthProbeScript() {
103
+ return String.raw`(() => {
104
+ const text = [
105
+ window.location.href || '',
106
+ document.title || '',
107
+ document.body ? (document.body.innerText || '').slice(0, 4000) : '',
108
+ ].join('\n');
109
+ return /linkedin\.com\/(?:login|checkpoint|authwall|uas)/i.test(text)
110
+ || /\b(sign in|log in|join linkedin|captcha|verification required)\b/i.test(text)
111
+ || /(请登录|登录领英|安全验证)/.test(text);
112
+ })()`;
113
+ }
114
+
115
+ export async function assertLinkedInAuthenticated(page, context) {
116
+ const authRequired = unwrapEvaluateResult(await page.evaluate(buildAuthProbeScript()));
117
+ if (authRequired) {
118
+ throw new AuthRequiredError(LINKEDIN_DOMAIN, `${context} requires an active signed-in LinkedIn browser session.`);
119
+ }
120
+ }
121
+
122
+ export function splitVisibleLines(text) {
123
+ return String(text || '').split(/\n+/).map(normalizeWhitespace).filter(Boolean);
124
+ }