@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,74 @@
1
+ import { cli, Strategy } from '@jackwener/opencli/registry';
2
+ import { CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
3
+ import { parseLimit } from './shared.js';
4
+ import { collectPosts } from './posts-core.js';
5
+
6
+ const DEFAULT_LIMIT = 30;
7
+ const MAX_LIMIT = 100;
8
+
9
+ function sum(posts, field) {
10
+ return posts.reduce((total, post) => total + (Number(post[field]) || 0), 0);
11
+ }
12
+
13
+ function summarize(posts) {
14
+ if (!Array.isArray(posts)) {
15
+ throw new CommandExecutionError('LinkedIn post analytics expected an array of posts');
16
+ }
17
+ if (posts.length === 0) {
18
+ throw new EmptyResultError('linkedin post-analytics', 'No posts were available for analytics.');
19
+ }
20
+ const latest = posts[0] || {};
21
+ return {
22
+ posts_analyzed: posts.length,
23
+ total_reactions: sum(posts, 'reactions'),
24
+ total_comments: sum(posts, 'comments'),
25
+ total_reposts: sum(posts, 'reposts'),
26
+ total_impressions: sum(posts, 'impressions'),
27
+ posts_with_media: posts.filter((post) => post.media).length,
28
+ posts_with_urls: posts.filter((post) => post.url).length,
29
+ latest_posted_at: latest.posted_at || '',
30
+ latest_reactions: Number(latest.reactions) || 0,
31
+ latest_comments: Number(latest.comments) || 0,
32
+ latest_reposts: Number(latest.reposts) || 0,
33
+ latest_impressions: Number(latest.impressions) || 0,
34
+ latest_url: latest.url || '',
35
+ };
36
+ }
37
+
38
+ cli({
39
+ site: 'linkedin',
40
+ name: 'post-analytics',
41
+ access: 'read',
42
+ description: 'Summarize raw visible LinkedIn post counters without custom scoring or classification',
43
+ domain: 'www.linkedin.com',
44
+ strategy: Strategy.COOKIE,
45
+ browser: true,
46
+ args: [
47
+ { name: 'profile-url', type: 'string', required: false, help: 'LinkedIn /in/<handle>/ profile URL. Defaults to /in/me/.' },
48
+ { name: 'limit', type: 'int', default: DEFAULT_LIMIT, help: 'Maximum posts to summarize (1-100)' },
49
+ ],
50
+ columns: [
51
+ 'posts_analyzed',
52
+ 'total_reactions',
53
+ 'total_comments',
54
+ 'total_reposts',
55
+ 'total_impressions',
56
+ 'posts_with_media',
57
+ 'posts_with_urls',
58
+ 'latest_posted_at',
59
+ 'latest_reactions',
60
+ 'latest_comments',
61
+ 'latest_reposts',
62
+ 'latest_impressions',
63
+ 'latest_url',
64
+ ],
65
+ func: async (page, args) => {
66
+ const limit = parseLimit(args.limit, DEFAULT_LIMIT, MAX_LIMIT);
67
+ const posts = await collectPosts(page, { ...args, limit });
68
+ return [summarize(posts)];
69
+ },
70
+ });
71
+
72
+ export const __test__ = {
73
+ summarize,
74
+ };
@@ -0,0 +1,40 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { getRegistry } from '@jackwener/opencli/registry';
3
+ import { EmptyResultError } from '@jackwener/opencli/errors';
4
+ import './post-analytics.js';
5
+
6
+ const { summarize } = await import('./post-analytics.js').then((m) => m.__test__);
7
+
8
+ describe('linkedin post-analytics adapter', () => {
9
+ const command = getRegistry().get('linkedin/post-analytics');
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).toContain('total_reactions');
16
+ expect(command.columns).not.toContain('total_engagement_score');
17
+ });
18
+
19
+ it('summarizes raw counters without custom scoring', () => {
20
+ const out = summarize([
21
+ { body: 'Latest post', reactions: 10, comments: 3, reposts: 1, impressions: 50, media: 'image', url: 'u1', posted_at: '1d' },
22
+ { body: 'Older post', reactions: 2, comments: 0, reposts: 0, impressions: 20, media: '', url: '', posted_at: '2d' },
23
+ ]);
24
+ expect(out).toMatchObject({
25
+ posts_analyzed: 2,
26
+ total_reactions: 12,
27
+ total_comments: 3,
28
+ total_reposts: 1,
29
+ total_impressions: 70,
30
+ posts_with_media: 1,
31
+ posts_with_urls: 1,
32
+ latest_posted_at: '1d',
33
+ latest_url: 'u1',
34
+ });
35
+ });
36
+
37
+ it('rejects empty analytics input', () => {
38
+ expect(() => summarize([])).toThrow(EmptyResultError);
39
+ });
40
+ });
@@ -0,0 +1,241 @@
1
+ import { CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
2
+ import {
3
+ assertLinkedInAuthenticated,
4
+ assertSafeLinkedinUrl,
5
+ compactRepeatedText,
6
+ normalizeHttpUrl,
7
+ normalizeWhitespace,
8
+ parseLimit,
9
+ unwrapEvaluateResult,
10
+ } from './shared.js';
11
+
12
+ export const DEFAULT_POSTS_LIMIT = 20;
13
+ export const MAX_POSTS_LIMIT = 100;
14
+
15
+ export function activityUrl(profileUrl) {
16
+ const url = assertSafeLinkedinUrl(profileUrl || 'https://www.linkedin.com/in/me/', 'profile-url', '/in/me/');
17
+ const parsed = new URL(url);
18
+ if (!/^\/in\/[^/?#]+\/?$/.test(parsed.pathname)) {
19
+ throw new CommandExecutionError('linkedin posts requires a /in/<handle>/ profile URL');
20
+ }
21
+ return `https://www.linkedin.com${parsed.pathname.replace(/\/?$/, '/') }recent-activity/all/`;
22
+ }
23
+
24
+ export function parseMetric(value) {
25
+ const raw = normalizeWhitespace(value).toLowerCase().replace(/,/g, '');
26
+ const match = raw.match(/(\d+(?:\.\d+)?)(k|m)?/i);
27
+ if (!match) return 0;
28
+ const base = Number(match[1]);
29
+ if (match[2]?.toLowerCase() === 'k') return Math.round(base * 1000);
30
+ if (match[2]?.toLowerCase() === 'm') return Math.round(base * 1000000);
31
+ return Math.round(base);
32
+ }
33
+
34
+ export function parseReactionText(value) {
35
+ const text = normalizeWhitespace(value);
36
+ const explicit = text.match(/(\d[\d,.]*\s*(?:k|m)?\s+reactions?)/i);
37
+ if (explicit) return parseMetric(explicit[1]);
38
+ const namedOthers = text.match(/(?:^|\s)(\d[\d,.]*\s*(?:k|m)?)\s+[A-Z][^.!?\n]{0,100}\s+and\s+\d[\d,.]*\s+others/i);
39
+ if (namedOthers) return parseMetric(namedOthers[1]);
40
+ const beforeComments = text.match(/(?:^|\s)(\d[\d,.]*\s*(?:k|m)?)\s+(?:\d[\d,.]*\s+comments?|\d[\d,.]*\s+reposts?)/i);
41
+ if (beforeComments) return parseMetric(beforeComments[1]);
42
+ const trailingNumber = text.match(/(?:^|\s)(\d[\d,.]*\s*(?:k|m)?)\s*$/i);
43
+ return trailingNumber ? parseMetric(trailingNumber[1]) : 0;
44
+ }
45
+
46
+ export function normalizePost(row) {
47
+ if (!row || typeof row !== 'object') {
48
+ throw new CommandExecutionError('LinkedIn posts returned malformed row');
49
+ }
50
+ const body = normalizeWhitespace(row.body);
51
+ const url = normalizeHttpUrl(row.url);
52
+ if (!body && !url) throw new CommandExecutionError('LinkedIn posts returned a row without body or URL');
53
+ return {
54
+ author: compactRepeatedText(row.author),
55
+ posted_at: normalizeWhitespace(row.posted_at),
56
+ body,
57
+ reactions: Number(row.reactions) || 0,
58
+ comments: Number(row.comments) || 0,
59
+ reposts: Number(row.reposts) || 0,
60
+ impressions: Number(row.impressions) || 0,
61
+ media: normalizeWhitespace(row.media),
62
+ media_urls: normalizeWhitespace(row.media_urls)
63
+ .split(/\s*\|\s*/)
64
+ .map((url) => normalizeHttpUrl(url))
65
+ .filter(Boolean)
66
+ .join(' | '),
67
+ url,
68
+ raw_text: normalizeWhitespace(row.raw_text),
69
+ };
70
+ }
71
+
72
+ export function buildPostsScript() {
73
+ return String.raw`(() => {
74
+ const clean = (s) => String(s || '').replace(/[\u00a0\u202f]+/g, ' ').replace(/\s+/g, ' ').trim();
75
+ const parseMetric = (s) => {
76
+ const raw = clean(s).toLowerCase().replace(/,/g, '');
77
+ const m = raw.match(/(\d+(?:\.\d+)?)(k|m)?/i);
78
+ if (!m) return 0;
79
+ const n = Number(m[1]);
80
+ if ((m[2] || '').toLowerCase() === 'k') return Math.round(n * 1000);
81
+ if ((m[2] || '').toLowerCase() === 'm') return Math.round(n * 1000000);
82
+ return Math.round(n);
83
+ };
84
+ const parseReactionText = (value) => {
85
+ const text = clean(value);
86
+ const explicit = text.match(/(\d[\d,.]*\s*(?:k|m)?\s+reactions?)/i);
87
+ if (explicit) return parseMetric(explicit[1]);
88
+ const namedOthers = text.match(/(?:^|\s)(\d[\d,.]*\s*(?:k|m)?)\s+[A-Z][^.!?\n]{0,100}\s+and\s+\d[\d,.]*\s+others/i);
89
+ if (namedOthers) return parseMetric(namedOthers[1]);
90
+ const beforeComments = text.match(/(?:^|\s)(\d[\d,.]*\s*(?:k|m)?)\s+(?:\d[\d,.]*\s+comments?|\d[\d,.]*\s+reposts?)/i);
91
+ if (beforeComments) return parseMetric(beforeComments[1]);
92
+ const trailingNumber = text.match(/(?:^|\s)(\d[\d,.]*\s*(?:k|m)?)\s*$/i);
93
+ return trailingNumber ? parseMetric(trailingNumber[1]) : 0;
94
+ };
95
+ const expanders = Array.from(document.querySelectorAll('button, a'))
96
+ .filter((el) => /\b(see more|show more|more)\b|…more/i.test(clean(el.innerText || el.textContent || el.getAttribute('aria-label') || '')));
97
+ for (const expander of expanders.slice(0, 20)) {
98
+ try { expander.click(); } catch {}
99
+ }
100
+ const cards = Array.from(document.querySelectorAll('article, [role="article"], .feed-shared-update-v2, .occludable-update'))
101
+ .filter((card) => clean(card.innerText || card.textContent || '').length > 60);
102
+ const rows = [];
103
+ const seen = new Set();
104
+ const stopLine = (line) => /^(like|comment|repost|send|share|copy link|follow|following|connect|message|activate to view|promoted|show more|see more)$/i.test(line)
105
+ || /^\d[\d,.]*\s*(?:k|m)?\s+(?:reactions?|comments?|reposts?|shares?|impressions?)$/i.test(line)
106
+ || /^[A-Z][A-Za-z ]+\s+and\s+\d[\d,.]*\s+others$/i.test(line);
107
+ const readBody = (root, lines) => {
108
+ const selectors = [
109
+ '.feed-shared-update-v2__description',
110
+ '.update-components-text',
111
+ '.feed-shared-text',
112
+ '[data-test-id*="main-feed-activity-card"] [dir="ltr"]',
113
+ '[class*="update-components-text"]'
114
+ ];
115
+ for (const selector of selectors) {
116
+ const node = root.querySelector(selector);
117
+ const value = clean(node?.innerText || node?.textContent || '');
118
+ if (value && value.length > 8) return value.replace(/…more$/i, '').trim();
119
+ }
120
+ const timestampIndex = lines.findIndex((line) => /^\d+\s*(?:s|m|h|d|w|mo|yr|min)\b/i.test(line));
121
+ const start = timestampIndex >= 0 ? timestampIndex + 1 : Math.min(3, lines.length);
122
+ const body = [];
123
+ for (const line of lines.slice(start)) {
124
+ if (stopLine(line)) break;
125
+ if (/^(visible to anyone|edited|author|view .* profile)$/i.test(line)) continue;
126
+ body.push(line);
127
+ }
128
+ return clean(body.join(' ')).replace(/…more$/i, '').trim();
129
+ };
130
+ const readMedia = (root) => {
131
+ const media = [];
132
+ const mediaUrls = [];
133
+ const isDecorativeImageUrl = (src) => /profile-displayphoto|profile-framedphoto|company-logo|emoji|reaction|ghost-person|100_100/i.test(src || '');
134
+ const safeHttpUrl = (value) => {
135
+ try {
136
+ const parsed = new URL(value, location.origin);
137
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return '';
138
+ if (parsed.username || parsed.password) return '';
139
+ return parsed.toString();
140
+ } catch {
141
+ return '';
142
+ }
143
+ };
144
+ const images = Array.from(root.querySelectorAll('img[alt]'))
145
+ .map((img) => ({ alt: clean(img.getAttribute('alt')), src: img.currentSrc || img.src || '' }))
146
+ .filter((img) => img.alt
147
+ && !/profile|photo of|emoji|reaction|^like$|^love$|^celebrate$|^support$|^funny$|^insightful$|^curious$/i.test(img.alt));
148
+ for (const image of images) {
149
+ media.push('image: ' + image.alt);
150
+ const imageUrl = safeHttpUrl(image.src);
151
+ if (imageUrl && !isDecorativeImageUrl(imageUrl)) mediaUrls.push(imageUrl);
152
+ }
153
+ const videos = Array.from(root.querySelectorAll('video'));
154
+ for (const video of videos) {
155
+ media.push('video');
156
+ const src = video.currentSrc || video.src || video.querySelector('source')?.src || '';
157
+ const videoUrl = safeHttpUrl(src);
158
+ if (videoUrl) mediaUrls.push(videoUrl);
159
+ }
160
+ const externalLinks = Array.from(root.querySelectorAll('a[href]'))
161
+ .map((link) => ({ href: link.href, label: clean(link.innerText || link.textContent || '') }))
162
+ .map((link) => ({ ...link, href: safeHttpUrl(link.href) }))
163
+ .filter((link) => link.href && !/linkedin\.com/.test(link.href))
164
+ .slice(0, 5);
165
+ for (const link of externalLinks) {
166
+ media.push(clean('link: ' + (link.label || link.href) + ' ' + link.href));
167
+ mediaUrls.push(link.href);
168
+ }
169
+ return {
170
+ labels: Array.from(new Set(media)).join(' | '),
171
+ urls: Array.from(new Set(mediaUrls)).join(' | '),
172
+ };
173
+ };
174
+ for (const card of cards) {
175
+ const root = card.closest('article, [role="article"], .feed-shared-update-v2, .occludable-update') || card;
176
+ if (!root || seen.has(root)) continue;
177
+ seen.add(root);
178
+ const rawFullText = String(root.innerText || root.textContent || '');
179
+ const rawText = clean(rawFullText);
180
+ if (!rawText || rawText.length < 20) continue;
181
+ const permalink = root.querySelector('a[href*="/feed/update/"], a[href*="/posts/"], a[href*="/pulse/"]');
182
+ const url = permalink?.href ? new URL(permalink.href, location.origin).toString() : '';
183
+ const authorLink = root.querySelector('a[href*="/in/"], a[href*="/company/"]');
184
+ const lines = rawFullText.split(/\n+/).map(clean).filter(Boolean);
185
+ const author = clean(authorLink?.innerText || authorLink?.textContent || '')
186
+ || lines.find((line) => line && !/^feed post/i.test(line) && !/verified|you|senior|engineer|developer/i.test(line)) || '';
187
+ const timestamp = (rawText.match(/\b\d+\s*(?:s|m|h|d|w|mo|yr|min)\b/i) || [''])[0];
188
+ const reactions = parseReactionText(rawText);
189
+ const comments = parseMetric((rawText.match(/(\d[\d,.]*\s*(?:k|m)?\s+comments?)/i) || [''])[0]);
190
+ const reposts = parseMetric((rawText.match(/(\d[\d,.]*\s*(?:k|m)?\s+reposts?)/i) || [''])[0]);
191
+ const impressions = parseMetric((rawText.match(/(\d[\d,.]*\s*(?:k|m)?\s+impressions?)/i) || [''])[0]);
192
+ const body = readBody(root, lines);
193
+ const mediaData = readMedia(root);
194
+ if (!body && !url) continue;
195
+ rows.push({
196
+ author,
197
+ posted_at: timestamp,
198
+ body,
199
+ reactions,
200
+ comments,
201
+ reposts,
202
+ impressions,
203
+ media: mediaData.labels,
204
+ media_urls: mediaData.urls,
205
+ url,
206
+ raw_text: rawText,
207
+ });
208
+ }
209
+ return { rows, url: location.href, title: document.title || '' };
210
+ })()`;
211
+ }
212
+
213
+ export async function collectPosts(page, args) {
214
+ if (!page) throw new CommandExecutionError('Browser session required for linkedin posts');
215
+ const limit = parseLimit(args.limit, DEFAULT_POSTS_LIMIT, MAX_POSTS_LIMIT);
216
+ await page.goto(activityUrl(args['profile-url']));
217
+ await page.wait(5);
218
+ await assertLinkedInAuthenticated(page, 'LinkedIn posts');
219
+ let rows = [];
220
+ for (let i = 0; i < 6 && rows.length < limit; i++) {
221
+ const payload = unwrapEvaluateResult(await page.evaluate(buildPostsScript()));
222
+ if (!payload || !Array.isArray(payload.rows)) {
223
+ throw new CommandExecutionError('LinkedIn posts returned malformed extraction payload');
224
+ }
225
+ rows = rows.concat(payload.rows.map(normalizePost));
226
+ const seen = new Set();
227
+ rows = rows.filter((row) => {
228
+ const key = row.url || `${row.author}::${row.posted_at}::${row.body.slice(0, 80)}`;
229
+ if (seen.has(key)) return false;
230
+ seen.add(key);
231
+ return true;
232
+ });
233
+ if (rows.length >= limit) break;
234
+ await page.autoScroll({ times: 1, delayMs: 900 });
235
+ await page.wait(1);
236
+ }
237
+ if (rows.length === 0) {
238
+ throw new EmptyResultError('linkedin posts', 'No visible posts were found on the LinkedIn activity page.');
239
+ }
240
+ return rows.slice(0, limit).map((row, index) => ({ rank: index + 1, ...row }));
241
+ }
@@ -0,0 +1,22 @@
1
+ import { cli, Strategy } from '@jackwener/opencli/registry';
2
+ import {
3
+ collectPosts,
4
+ DEFAULT_POSTS_LIMIT,
5
+ MAX_POSTS_LIMIT,
6
+ } from './posts-core.js';
7
+
8
+ cli({
9
+ site: 'linkedin',
10
+ name: 'posts',
11
+ access: 'read',
12
+ description: 'Export visible posts from a LinkedIn profile activity page with engagement metrics',
13
+ domain: 'www.linkedin.com',
14
+ strategy: Strategy.COOKIE,
15
+ browser: true,
16
+ args: [
17
+ { name: 'profile-url', type: 'string', required: false, help: 'LinkedIn /in/<handle>/ profile URL. Defaults to /in/me/.' },
18
+ { name: 'limit', type: 'int', default: DEFAULT_POSTS_LIMIT, help: `Maximum posts to return (1-${MAX_POSTS_LIMIT})` },
19
+ ],
20
+ columns: ['rank', 'author', 'posted_at', 'body', 'reactions', 'comments', 'reposts', 'impressions', 'media', 'media_urls', 'url', 'raw_text'],
21
+ func: async (page, args) => collectPosts(page, args),
22
+ });
@@ -0,0 +1,40 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { getRegistry } from '@jackwener/opencli/registry';
3
+ import { CommandExecutionError } from '@jackwener/opencli/errors';
4
+ import './posts.js';
5
+
6
+ const { activityUrl, parseMetric, parseReactionText, normalizePost } = await import('./posts-core.js');
7
+
8
+ describe('linkedin posts adapter', () => {
9
+ const command = getRegistry().get('linkedin/posts');
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).toContain('reactions');
16
+ expect(command.columns).toContain('media_urls');
17
+ expect(command.columns).toContain('raw_text');
18
+ });
19
+
20
+ it('builds profile activity URL', () => {
21
+ expect(activityUrl('https://www.linkedin.com/in/gauravsaxena1997/')).toBe('https://www.linkedin.com/in/gauravsaxena1997/recent-activity/all/');
22
+ });
23
+
24
+ it('parses compact metrics', () => {
25
+ expect(parseMetric('1,200 reactions')).toBe(1200);
26
+ expect(parseMetric('2.5k comments')).toBe(2500);
27
+ expect(parseReactionText('19 Divyang Bhargava and 18 others 12 comments')).toBe(19);
28
+ expect(parseReactionText('32 5 comments')).toBe(32);
29
+ });
30
+
31
+ it('normalizes post rows and rejects identity-free rows', () => {
32
+ expect(() => normalizePost({ text: '', url: '' })).toThrow(CommandExecutionError);
33
+ expect(normalizePost({ author: 'AliceAlice', body: 'Post body', reactions: '3', impressions: '12', media: 'image: demo', media_urls: 'https://example.com/a.png' }))
34
+ .toMatchObject({ author: 'Alice', body: 'Post body', reactions: 3, impressions: 12, media: 'image: demo', media_urls: 'https://example.com/a.png' });
35
+ expect(normalizePost({ body: 'Post body', media_urls: 'javascript:alert(1) | https://example.com/a.png' }))
36
+ .toMatchObject({ media_urls: 'https://example.com/a.png' });
37
+ expect(normalizePost({ body: 'Post body', url: 'javascript:alert(1)' }))
38
+ .toMatchObject({ url: '' });
39
+ });
40
+ });
@@ -0,0 +1,104 @@
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 normalizeProfileAnalyticsUrl(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 profile-analytics requires a /in/<handle>/ profile URL');
15
+ }
16
+ return parsed.toString();
17
+ }
18
+
19
+ function parseMetric(value) {
20
+ const raw = normalizeWhitespace(value).toLowerCase().replace(/,/g, '');
21
+ const match = raw.match(/(\d+(?:\.\d+)?)(k|m)?/i);
22
+ if (!match) return '';
23
+ const base = Number(match[1]);
24
+ if (match[2]?.toLowerCase() === 'k') return String(Math.round(base * 1000));
25
+ if (match[2]?.toLowerCase() === 'm') return String(Math.round(base * 1000000));
26
+ return String(Math.round(base));
27
+ }
28
+
29
+ function firstMetric(text, patterns) {
30
+ for (const pattern of patterns) {
31
+ const match = normalizeWhitespace(text).match(pattern);
32
+ if (match) return parseMetric(match[1]);
33
+ }
34
+ return '';
35
+ }
36
+
37
+ function parseDashboardMetrics(text) {
38
+ const normalized = normalizeWhitespace(text);
39
+ return {
40
+ profile_views: firstMetric(normalized, [/(\d[\d,.]*\s*(?:k|m)?)\s+profile views?/i, /profile views?\s+(\d[\d,.]*\s*(?:k|m)?)/i]),
41
+ post_impressions: firstMetric(normalized, [/(\d[\d,.]*\s*(?:k|m)?)\s+post impressions?/i, /post impressions?\s+(\d[\d,.]*\s*(?:k|m)?)/i]),
42
+ search_appearances: firstMetric(normalized, [/(\d[\d,.]*\s*(?:k|m)?)\s+search appearances?/i, /search appearances?\s+(\d[\d,.]*\s*(?:k|m)?)/i]),
43
+ followers: firstMetric(normalized, [/(\d[\d,.]*\s*(?:k|m)?)\s+followers?/i]),
44
+ connections: firstMetric(normalized, [/(\d[\d,.]*\s*(?:k|m)?)\s+connections?/i]),
45
+ };
46
+ }
47
+
48
+ function buildProfileAnalyticsScript() {
49
+ return String.raw`(() => {
50
+ const clean = (s) => String(s || '').replace(/[\u00a0\u202f]+/g, ' ').replace(/\s+/g, ' ').trim();
51
+ const text = clean(document.body?.innerText || document.body?.textContent || '');
52
+ const matches = [
53
+ ...text.matchAll(/\d[\d,.]*\s*(?:k|m)?\s+(?:profile views?|post impressions?|search appearances?|followers?|connections?)/gi),
54
+ ].map((match) => clean(match[0]));
55
+ return {
56
+ profile_url: window.location.href,
57
+ raw_analytics: matches.join(' | '),
58
+ };
59
+ })()`;
60
+ }
61
+
62
+ function normalizeAnalytics(row) {
63
+ if (!row || typeof row !== 'object') {
64
+ throw new CommandExecutionError('LinkedIn profile-analytics returned malformed extraction payload');
65
+ }
66
+ const metrics = parseDashboardMetrics(row.raw_analytics);
67
+ if (!Object.values(metrics).some(Boolean)) {
68
+ throw new EmptyResultError('linkedin profile-analytics', 'No visible LinkedIn profile analytics counters were found.');
69
+ }
70
+ return {
71
+ profile_url: normalizeWhitespace(row.profile_url),
72
+ ...metrics,
73
+ raw_analytics: normalizeWhitespace(row.raw_analytics),
74
+ };
75
+ }
76
+
77
+ cli({
78
+ site: 'linkedin',
79
+ name: 'profile-analytics',
80
+ access: 'read',
81
+ description: 'Read visible LinkedIn profile dashboard metrics such as profile views, post impressions, and search appearances',
82
+ domain: 'www.linkedin.com',
83
+ strategy: Strategy.COOKIE,
84
+ browser: true,
85
+ args: [
86
+ { name: 'profile-url', type: 'string', required: false, help: 'LinkedIn /in/<handle>/ profile URL. Defaults to /in/me/.' },
87
+ ],
88
+ columns: ['profile_url', 'profile_views', 'post_impressions', 'search_appearances', 'followers', 'connections', 'raw_analytics'],
89
+ func: async (page, args) => {
90
+ if (!page) throw new CommandExecutionError('Browser session required for linkedin profile-analytics');
91
+ await page.goto(normalizeProfileAnalyticsUrl(args['profile-url']));
92
+ await page.wait(5);
93
+ await assertLinkedInAuthenticated(page, 'LinkedIn profile-analytics');
94
+ const row = unwrapEvaluateResult(await page.evaluate(buildProfileAnalyticsScript()));
95
+ return [normalizeAnalytics(row)];
96
+ },
97
+ });
98
+
99
+ export const __test__ = {
100
+ normalizeProfileAnalyticsUrl,
101
+ parseMetric,
102
+ parseDashboardMetrics,
103
+ normalizeAnalytics,
104
+ };
@@ -0,0 +1,67 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { getRegistry } from '@jackwener/opencli/registry';
3
+ import { CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
4
+ import './profile-analytics.js';
5
+
6
+ const {
7
+ normalizeProfileAnalyticsUrl,
8
+ parseMetric,
9
+ parseDashboardMetrics,
10
+ normalizeAnalytics,
11
+ } = await import('./profile-analytics.js').then((m) => m.__test__);
12
+
13
+ describe('linkedin profile-analytics adapter', () => {
14
+ const command = getRegistry().get('linkedin/profile-analytics');
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
+ 'profile_url',
22
+ 'profile_views',
23
+ 'post_impressions',
24
+ 'search_appearances',
25
+ 'followers',
26
+ 'connections',
27
+ 'raw_analytics',
28
+ ]);
29
+ });
30
+
31
+ it('normalizes profile url default and explicit /in URL', () => {
32
+ expect(normalizeProfileAnalyticsUrl(undefined)).toBe('https://www.linkedin.com/in/me/');
33
+ expect(normalizeProfileAnalyticsUrl('https://www.linkedin.com/in/gauravsaxena1997/')).toBe('https://www.linkedin.com/in/gauravsaxena1997/');
34
+ });
35
+
36
+ it('rejects non-profile URLs', () => {
37
+ expect(() => normalizeProfileAnalyticsUrl('https://www.linkedin.com/jobs/')).toThrow(CommandExecutionError);
38
+ });
39
+
40
+ it('parses compact dashboard metrics', () => {
41
+ expect(parseMetric('1.2K')).toBe('1200');
42
+ expect(parseDashboardMetrics('31 post impressions 23 search appearances 32 profile views 1,234 followers 500 connections'))
43
+ .toEqual({
44
+ profile_views: '32',
45
+ post_impressions: '31',
46
+ search_appearances: '23',
47
+ followers: '1234',
48
+ connections: '500',
49
+ });
50
+ });
51
+
52
+ it('normalizes browser payload into columns', () => {
53
+ expect(normalizeAnalytics({
54
+ profile_url: 'https://www.linkedin.com/in/me/',
55
+ raw_analytics: '31 post impressions | 23 search appearances | 32 profile views',
56
+ })).toMatchObject({
57
+ profile_views: '32',
58
+ post_impressions: '31',
59
+ search_appearances: '23',
60
+ });
61
+ });
62
+
63
+ it('does not emit an all-empty analytics row', () => {
64
+ expect(() => normalizeAnalytics({ profile_url: 'https://www.linkedin.com/in/me/', raw_analytics: '' }))
65
+ .toThrow(EmptyResultError);
66
+ });
67
+ });