@jackwener/opencli 1.7.22 → 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.
- package/README.md +35 -194
- package/README.zh-CN.md +42 -260
- package/cli-manifest.json +8160 -4392
- package/clis/12306/me.js +73 -0
- package/clis/12306/orders.js +96 -0
- package/clis/12306/passengers.js +90 -0
- package/clis/12306/price.js +166 -0
- package/clis/12306/stations.js +66 -0
- package/clis/12306/train.js +91 -0
- package/clis/12306/trains.js +119 -0
- package/clis/12306/utils.js +272 -0
- package/clis/12306/utils.test.js +331 -0
- package/clis/36kr/article.js +6 -3
- package/clis/36kr/article.test.js +46 -0
- package/clis/_atlassian/shared.js +577 -0
- package/clis/_atlassian/shared.test.js +170 -0
- package/clis/apple-podcasts/commands.test.js +20 -0
- package/clis/apple-podcasts/search.js +2 -2
- package/clis/barchart/greeks.js +144 -56
- package/clis/barchart/greeks.test.js +138 -0
- package/clis/bilibili/comment.js +125 -0
- package/clis/bilibili/comment.test.js +153 -0
- package/clis/bilibili/comments.js +116 -21
- package/clis/bilibili/comments.test.js +77 -18
- package/clis/bilibili/subtitle.js +76 -31
- package/clis/bilibili/subtitle.test.js +156 -9
- package/clis/bilibili/summary.js +167 -0
- package/clis/bilibili/summary.test.js +210 -0
- package/clis/bilibili/utils.js +63 -5
- package/clis/bilibili/utils.test.js +45 -1
- package/clis/booking/booking.test.js +356 -0
- package/clis/booking/search.js +351 -0
- package/clis/chatgpt/envelope.test.js +108 -0
- package/clis/chatgpt/image.js +2 -2
- package/clis/chatgpt/image.test.js +6 -0
- package/clis/chatgpt/utils.js +148 -41
- package/clis/chatgpt/utils.test.js +92 -2
- package/clis/chess/analyze.js +35 -0
- package/clis/chess/analyze.test.js +79 -0
- package/clis/chess/game.js +114 -0
- package/clis/chess/game.test.js +178 -0
- package/clis/chess/games.js +67 -0
- package/clis/chess/games.test.js +164 -0
- package/clis/chess/stats.js +32 -0
- package/clis/chess/stats.test.js +79 -0
- package/clis/chess/utils.js +170 -0
- package/clis/chess/utils.test.js +230 -0
- package/clis/confluence/commands.test.js +195 -0
- package/clis/confluence/create.js +39 -0
- package/clis/confluence/page.js +23 -0
- package/clis/confluence/search.js +34 -0
- package/clis/confluence/shared.js +173 -0
- package/clis/confluence/update.js +38 -0
- package/clis/douyin/_shared/browser-fetch.js +44 -20
- package/clis/douyin/_shared/browser-fetch.test.js +22 -1
- package/clis/douyin/_shared/evaluate-result.js +16 -0
- package/clis/douyin/_shared/tos-upload.js +105 -69
- package/clis/douyin/_shared/vod-upload.js +212 -0
- package/clis/douyin/_shared/vod-upload.test.js +38 -0
- package/clis/douyin/delete.js +137 -4
- package/clis/douyin/delete.test.js +90 -1
- package/clis/douyin/hashtag.js +84 -23
- package/clis/douyin/hashtag.test.js +113 -0
- package/clis/douyin/publish-upload-id.test.js +170 -0
- package/clis/douyin/publish.js +88 -42
- package/clis/douyin/user-videos.js +9 -2
- package/clis/douyin/user-videos.test.js +43 -0
- package/clis/flomo/memos.js +228 -0
- package/clis/flomo/memos.test.js +144 -0
- package/clis/geogebra/add-circle.js +46 -0
- package/clis/geogebra/add-line.js +35 -0
- package/clis/geogebra/add-point.js +27 -0
- package/clis/geogebra/add-polygon.js +25 -0
- package/clis/geogebra/eval.js +35 -0
- package/clis/geogebra/geogebra.test.js +175 -0
- package/clis/geogebra/hexagon.js +62 -0
- package/clis/geogebra/info.js +72 -0
- package/clis/geogebra/list.js +35 -0
- package/clis/geogebra/triangle.js +60 -0
- package/clis/geogebra/utils.js +271 -0
- package/clis/gitee/search.js +2 -2
- package/clis/gitee/search.test.js +65 -0
- package/clis/jike/post.js +27 -17
- package/clis/jike/read.test.js +86 -0
- package/clis/jike/topic.js +32 -19
- package/clis/jike/user.js +33 -20
- package/clis/jira/attachments.js +28 -0
- package/clis/jira/commands.test.js +287 -0
- package/clis/jira/comments.js +28 -0
- package/clis/jira/issue.js +28 -0
- package/clis/jira/links.js +28 -0
- package/clis/jira/search.js +47 -0
- package/clis/jira/shared.js +256 -0
- package/clis/lesswrong/comments.js +1 -1
- package/clis/lesswrong/curated.js +1 -1
- package/clis/lesswrong/frontpage.js +1 -1
- package/clis/lesswrong/frontpage.test.js +37 -0
- package/clis/lesswrong/new.js +1 -1
- package/clis/lesswrong/read.js +1 -1
- package/clis/lesswrong/sequences.js +1 -1
- package/clis/lesswrong/shortform.js +1 -1
- package/clis/lesswrong/tag.js +1 -1
- package/clis/lesswrong/top-month.js +1 -1
- package/clis/lesswrong/top-week.js +1 -1
- package/clis/lesswrong/top-year.js +1 -1
- package/clis/lesswrong/top.js +1 -1
- package/clis/linkedin/connect.js +401 -0
- package/clis/linkedin/connect.test.js +213 -0
- package/clis/linkedin/inbox.js +234 -0
- package/clis/linkedin/inbox.test.js +152 -0
- package/clis/linkedin/job-detail.js +167 -0
- package/clis/linkedin/job-detail.test.js +38 -0
- package/clis/linkedin/jobs-preferences.js +113 -0
- package/clis/linkedin/jobs-preferences.test.js +43 -0
- package/clis/linkedin/people-search.js +262 -0
- package/clis/linkedin/people-search.test.js +216 -0
- package/clis/linkedin/post-analytics.js +74 -0
- package/clis/linkedin/post-analytics.test.js +40 -0
- package/clis/linkedin/posts-core.js +241 -0
- package/clis/linkedin/posts.js +22 -0
- package/clis/linkedin/posts.test.js +40 -0
- package/clis/linkedin/profile-analytics.js +104 -0
- package/clis/linkedin/profile-analytics.test.js +67 -0
- package/clis/linkedin/profile-experience.js +671 -0
- package/clis/linkedin/profile-experience.test.js +152 -0
- package/clis/linkedin/profile-projects.js +311 -0
- package/clis/linkedin/profile-projects.test.js +111 -0
- package/clis/linkedin/profile-read.js +148 -0
- package/clis/linkedin/profile-read.test.js +77 -0
- package/clis/linkedin/safe-send.js +357 -0
- package/clis/linkedin/safe-send.test.js +204 -0
- package/clis/linkedin/salesnav-inbox.js +210 -0
- package/clis/linkedin/salesnav-inbox.test.js +113 -0
- package/clis/linkedin/salesnav-message.js +360 -0
- package/clis/linkedin/salesnav-message.test.js +172 -0
- package/clis/linkedin/salesnav-search.js +186 -0
- package/clis/linkedin/salesnav-search.test.js +76 -0
- package/clis/linkedin/salesnav-thread.js +212 -0
- package/clis/linkedin/salesnav-thread.test.js +79 -0
- package/clis/linkedin/sent-invitations.js +92 -0
- package/clis/linkedin/sent-invitations.test.js +62 -0
- package/clis/linkedin/services-read.js +213 -0
- package/clis/linkedin/services-read.test.js +105 -0
- package/clis/linkedin/shared.js +124 -0
- package/clis/linkedin/thread-snapshot.js +214 -0
- package/clis/linkedin/thread-snapshot.test.js +89 -0
- package/clis/linkedin/timeline.js +14 -7
- package/clis/linkedin-learning/course.js +138 -0
- package/clis/linkedin-learning/course.test.js +114 -0
- package/clis/linkedin-learning/search.js +155 -0
- package/clis/linkedin-learning/search.test.js +144 -0
- package/clis/linkedin-learning/trending.js +133 -0
- package/clis/linkedin-learning/trending.test.js +123 -0
- package/clis/notebooklm/add-source.js +269 -0
- package/clis/notebooklm/add-source.test.js +97 -0
- package/clis/notebooklm/create.js +76 -0
- package/clis/notebooklm/create.test.js +58 -0
- package/clis/notebooklm/generate-audio.js +91 -0
- package/clis/notebooklm/generate-audio.test.js +63 -0
- package/clis/notebooklm/generate-slides.js +106 -0
- package/clis/notebooklm/generate-slides.test.js +75 -0
- package/clis/notebooklm/open.test.js +10 -10
- package/clis/notebooklm/rpc.js +20 -6
- package/clis/notebooklm/rpc.test.js +27 -1
- package/clis/notebooklm/utils.js +100 -24
- package/clis/notebooklm/utils.test.js +60 -1
- package/clis/notebooklm/write-note.js +103 -0
- package/clis/notebooklm/write-note.test.js +70 -0
- package/clis/pixiv/detail.js +41 -34
- package/clis/pixiv/detail.test.js +93 -0
- package/clis/pixiv/user.js +36 -31
- package/clis/pixiv/user.test.js +100 -0
- package/clis/pixiv/utils.js +56 -7
- package/clis/powerchina/search.js +3 -3
- package/clis/powerchina/search.test.js +27 -1
- package/clis/reddit/extract-media.test.js +149 -0
- package/clis/reddit/frontpage.js +47 -9
- package/clis/reddit/frontpage.test.js +34 -0
- package/clis/reddit/home.js +31 -1
- package/clis/reddit/home.test.js +46 -3
- package/clis/reddit/hot.js +32 -1
- package/clis/reddit/hot.test.js +15 -1
- package/clis/reddit/popular.js +39 -1
- package/clis/reddit/popular.test.js +26 -0
- package/clis/reddit/saved.js +1 -1
- package/clis/reddit/search.js +38 -1
- package/clis/reddit/search.test.js +26 -0
- package/clis/reddit/subreddit.js +52 -7
- package/clis/reddit/subreddit.test.js +31 -0
- package/clis/reddit/subscribed.js +165 -0
- package/clis/reddit/subscribed.test.js +168 -0
- package/clis/reddit/upvoted.js +1 -1
- package/clis/suno/commands.test.js +188 -0
- package/clis/suno/download.js +140 -0
- package/clis/suno/download.test.js +151 -0
- package/clis/suno/generate.js +231 -0
- package/clis/suno/generate.test.js +252 -0
- package/clis/suno/list.js +79 -0
- package/clis/suno/status.js +63 -0
- package/clis/suno/utils.js +549 -0
- package/clis/suno/utils.test.js +329 -0
- package/clis/twitter/device-follow.js +193 -0
- package/clis/twitter/device-follow.test.js +287 -0
- package/clis/twitter/download.js +443 -73
- package/clis/twitter/download.test.js +457 -0
- package/clis/twitter/followers.js +6 -2
- package/clis/twitter/followers.test.js +19 -1
- package/clis/twitter/following.js +14 -5
- package/clis/twitter/following.test.js +29 -0
- package/clis/twitter/likes.js +12 -4
- package/clis/twitter/likes.test.js +26 -1
- package/clis/twitter/list-add.js +1 -1
- package/clis/twitter/list-create.js +155 -0
- package/clis/twitter/list-create.test.js +169 -0
- package/clis/twitter/list-remove.js +13 -6
- package/clis/twitter/list-remove.test.js +74 -0
- package/clis/twitter/list-tweets.js +6 -2
- package/clis/twitter/list-tweets.test.js +41 -1
- package/clis/twitter/lists.js +31 -4
- package/clis/twitter/lists.test.js +152 -16
- package/clis/twitter/notifications.js +4 -4
- package/clis/twitter/post.js +62 -4
- package/clis/twitter/post.test.js +35 -3
- package/clis/twitter/profile.js +81 -28
- package/clis/twitter/profile.test.js +113 -2
- package/clis/twitter/quote.js +9 -4
- package/clis/twitter/reply.js +13 -10
- package/clis/twitter/reply.test.js +41 -0
- package/clis/twitter/search.js +7 -3
- package/clis/twitter/search.test.js +41 -0
- package/clis/twitter/shared.js +155 -0
- package/clis/twitter/shared.test.js +465 -1
- package/clis/twitter/thread.js +10 -2
- package/clis/twitter/thread.test.js +58 -0
- package/clis/twitter/timeline.js +6 -2
- package/clis/twitter/timeline.test.js +2 -0
- package/clis/twitter/tweets.js +3 -2
- package/clis/twitter/tweets.test.js +1 -1
- package/clis/twitter/utils.js +53 -16
- package/clis/upwork/detail.js +132 -0
- package/clis/upwork/feed.js +109 -0
- package/clis/upwork/search.js +115 -0
- package/clis/upwork/upwork.test.js +566 -0
- package/clis/upwork/utils.js +323 -0
- package/clis/weibo/delete.js +172 -0
- package/clis/weibo/delete.test.js +94 -0
- package/clis/weibo/publish.js +37 -14
- package/clis/weibo/publish.test.js +14 -5
- package/clis/weibo/user-posts.js +234 -0
- package/clis/weibo/user-posts.test.js +92 -0
- package/clis/weread/book-search.js +438 -0
- package/clis/weread/book-search.test.js +242 -0
- package/clis/weread/search-regression.test.js +98 -11
- package/clis/weread/search.js +32 -9
- package/clis/weread-official/book.js +135 -0
- package/clis/weread-official/commands.test.js +385 -0
- package/clis/weread-official/discover.js +107 -0
- package/clis/weread-official/list-apis.js +95 -0
- package/clis/weread-official/notes.js +171 -0
- package/clis/weread-official/readdata.js +158 -0
- package/clis/weread-official/review.js +93 -0
- package/clis/weread-official/search.js +106 -0
- package/clis/weread-official/shelf.js +97 -0
- package/clis/weread-official/utils.js +293 -0
- package/clis/weread-official/utils.test.js +242 -0
- package/clis/wikipedia/trending.js +7 -3
- package/clis/wikipedia/trending.test.js +57 -0
- package/clis/xianyu/chat.js +24 -109
- package/clis/xianyu/chat.test.js +5 -0
- package/clis/xianyu/im.js +322 -0
- package/clis/xianyu/im.test.js +253 -0
- package/clis/xianyu/inbox.js +96 -0
- package/clis/xianyu/messages.js +91 -0
- package/clis/xianyu/reply.js +82 -0
- package/clis/xiaohongshu/creator-note-detail.js +166 -28
- package/clis/xiaohongshu/creator-note-detail.test.js +196 -36
- package/clis/xiaohongshu/creator-notes-summary.js +2 -1
- package/clis/xiaohongshu/creator-notes-summary.test.js +7 -0
- package/clis/xiaohongshu/creator-notes.js +252 -2
- package/clis/xiaohongshu/creator-notes.test.js +90 -1
- package/clis/xiaohongshu/creator-stats.js +2 -1
- package/clis/xiaohongshu/creator-stats.test.js +24 -0
- package/clis/xiaohongshu/delete-note.js +260 -0
- package/clis/xiaohongshu/delete-note.test.js +172 -0
- package/clis/xiaohongshu/download.js +97 -39
- package/clis/xiaohongshu/download.test.js +201 -0
- package/clis/xiaohongshu/publish.js +48 -8
- package/clis/xiaohongshu/publish.test.js +65 -10
- package/clis/xiaohongshu/user-helpers.test.js +41 -0
- package/clis/xiaohongshu/user.js +27 -4
- package/clis/xiaoyuzhou/download.js +1 -1
- package/clis/xiaoyuzhou/transcript.js +1 -1
- package/clis/youdao/note.js +258 -0
- package/clis/youdao/note.test.js +99 -0
- package/clis/youtube/transcript.js +397 -24
- package/clis/youtube/transcript.test.js +196 -6
- package/clis/zhihu/answer-comments.js +280 -0
- package/clis/zhihu/answer-comments.test.js +287 -0
- package/clis/zhihu/answer-detail.js +2 -19
- package/clis/zhihu/answer-detail.test.js +8 -0
- package/clis/zhihu/collection.js +17 -16
- package/clis/zhihu/collection.test.js +50 -3
- package/clis/zhihu/download.js +1 -1
- package/clis/zhihu/question.js +42 -17
- package/clis/zhihu/question.test.js +113 -11
- package/clis/zhihu/search.js +195 -43
- package/clis/zhihu/search.test.js +198 -0
- package/clis/zhihu/text.js +29 -0
- package/clis/zhihu/text.test.js +24 -0
- package/dist/src/browser/errors.js +4 -2
- package/dist/src/browser/errors.test.js +6 -0
- package/dist/src/browser/network-cache.js +13 -1
- package/dist/src/browser/network-cache.test.js +17 -0
- package/dist/src/browser/page.js +30 -4
- package/dist/src/browser/page.test.js +42 -0
- package/dist/src/browser/utils.d.ts +1 -1
- package/dist/src/cli-argv-preprocess.d.ts +26 -0
- package/dist/src/cli-argv-preprocess.js +138 -0
- package/dist/src/cli-argv-preprocess.test.js +79 -0
- package/dist/src/convention-audit.js +15 -8
- package/dist/src/convention-audit.test.js +21 -0
- package/dist/src/download/index.js +13 -1
- package/dist/src/download/index.test.js +23 -1
- package/dist/src/download/media-download.js +15 -2
- package/dist/src/download/media-download.test.d.ts +1 -0
- package/dist/src/download/media-download.test.js +112 -0
- package/dist/src/download/progress.js +2 -2
- package/dist/src/download/progress.test.js +12 -1
- package/dist/src/electron-apps.js +1 -1
- package/dist/src/electron-apps.test.js +7 -2
- package/dist/src/errors.d.ts +17 -0
- package/dist/src/errors.js +22 -0
- package/dist/src/external-clis.yaml +8 -0
- package/dist/src/main.js +14 -2
- package/dist/src/output.js +11 -1
- package/dist/src/output.test.js +6 -0
- package/dist/src/registry.js +1 -0
- package/dist/src/registry.test.js +11 -0
- package/dist/src/utils.d.ts +43 -0
- package/dist/src/utils.js +97 -0
- package/dist/src/utils.test.d.ts +1 -0
- package/dist/src/utils.test.js +155 -0
- package/package.json +8 -2
- package/scripts/silent-column-drop-baseline.json +0 -52
- package/scripts/typed-error-lint-baseline.json +28 -380
- package/clis/slock/_utils.js +0 -12
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { getRegistry } from '@jackwener/opencli/registry';
|
|
3
|
+
import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
|
|
4
|
+
import './people-search.js';
|
|
5
|
+
|
|
6
|
+
const {
|
|
7
|
+
parseLimit,
|
|
8
|
+
buildSearchUrl,
|
|
9
|
+
looksLinkedInAuthWall,
|
|
10
|
+
normalizeProfileUrl,
|
|
11
|
+
normalizePeopleRows,
|
|
12
|
+
parseNonNegativeCount,
|
|
13
|
+
extractionScript,
|
|
14
|
+
} = await import('./people-search.js').then((m) => m.__test__);
|
|
15
|
+
|
|
16
|
+
function extractionResult(rows, counts = {}) {
|
|
17
|
+
return {
|
|
18
|
+
rows,
|
|
19
|
+
candidate_count: counts.candidate_count ?? rows.length,
|
|
20
|
+
person_entries_count: counts.person_entries_count ?? counts.candidate_count ?? rows.length,
|
|
21
|
+
resolved_count: counts.resolved_count ?? rows.length,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function makePage({
|
|
26
|
+
evaluateResult,
|
|
27
|
+
evaluateReject,
|
|
28
|
+
gotoReject,
|
|
29
|
+
cookies = [{ name: 'JSESSIONID', value: '"ajax:1234567890"' }],
|
|
30
|
+
} = {}) {
|
|
31
|
+
return {
|
|
32
|
+
goto: vi.fn().mockImplementation(() => gotoReject ? Promise.reject(gotoReject) : Promise.resolve(undefined)),
|
|
33
|
+
wait: vi.fn().mockResolvedValue(undefined),
|
|
34
|
+
getCookies: vi.fn().mockResolvedValue(cookies),
|
|
35
|
+
evaluate: vi.fn().mockImplementation(() => evaluateReject ? Promise.reject(evaluateReject) : Promise.resolve(evaluateResult)),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe('linkedin people-search command', () => {
|
|
40
|
+
it('builds the canonical SSR search URL with encoded keywords', () => {
|
|
41
|
+
expect(buildSearchUrl('site reliability engineer'))
|
|
42
|
+
.toBe('https://www.linkedin.com/search/results/people/?keywords=site%20reliability%20engineer');
|
|
43
|
+
expect(buildSearchUrl('hello/world & stuff'))
|
|
44
|
+
.toBe('https://www.linkedin.com/search/results/people/?keywords=hello%2Fworld%20%26%20stuff');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('validates --limit without silent clamping', () => {
|
|
48
|
+
expect(parseLimit(undefined)).toBe(5);
|
|
49
|
+
expect(parseLimit(1)).toBe(1);
|
|
50
|
+
expect(parseLimit(10)).toBe(10);
|
|
51
|
+
expect(() => parseLimit(0)).toThrow(ArgumentError);
|
|
52
|
+
expect(() => parseLimit(11)).toThrow(ArgumentError);
|
|
53
|
+
expect(() => parseLimit(-1)).toThrow(ArgumentError);
|
|
54
|
+
expect(() => parseLimit('abc')).toThrow(ArgumentError);
|
|
55
|
+
expect(() => parseLimit(1.5)).toThrow(ArgumentError);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('extraction script slices main.innerText by person-name boundaries', () => {
|
|
59
|
+
const s = extractionScript();
|
|
60
|
+
// Anchor enumeration finds /in/<handle>.
|
|
61
|
+
expect(s).toContain('a[href*="/in/"]');
|
|
62
|
+
expect(s).toContain('\\/in\\/([^/?#]+)');
|
|
63
|
+
// Text-slice approach: split main.innerText and locate names.
|
|
64
|
+
expect(s).toContain('main.innerText');
|
|
65
|
+
expect(s).toContain('lines.findIndex');
|
|
66
|
+
// Mutual-connection anchors are filtered out via the skip()
|
|
67
|
+
// predicate on the name-line match.
|
|
68
|
+
expect(s).toContain('mutual connection');
|
|
69
|
+
// Names dedup'd by handle.
|
|
70
|
+
expect(s).toContain('seenHandles');
|
|
71
|
+
// Aria-hidden span as canonical name source.
|
|
72
|
+
expect(s).toContain('span[aria-hidden="true"]');
|
|
73
|
+
// Only operates on the people-search page.
|
|
74
|
+
expect(s).toContain('search\\/results\\/people');
|
|
75
|
+
expect(s).toContain('candidate_count');
|
|
76
|
+
expect(s).toContain('resolved_count');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('normalizes only stable LinkedIn profile identities', () => {
|
|
80
|
+
expect(normalizeProfileUrl('https://www.linkedin.com/in/alice-engineer/?mini=true'))
|
|
81
|
+
.toBe('https://www.linkedin.com/in/alice-engineer/');
|
|
82
|
+
expect(normalizeProfileUrl('https://linkedin.com/in/bob-builder')).toBe('https://www.linkedin.com/in/bob-builder/');
|
|
83
|
+
expect(normalizeProfileUrl('https://evil-linkedin.com/in/bob-builder')).toBe('');
|
|
84
|
+
expect(normalizeProfileUrl('http://www.linkedin.com/in/bob-builder')).toBe('');
|
|
85
|
+
expect(normalizeProfileUrl('https://www.linkedin.com/company/opencli')).toBe('');
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('detects LinkedIn auth-wall URLs separately from CUL redirects', () => {
|
|
89
|
+
expect(looksLinkedInAuthWall('https://www.linkedin.com/authwall Sign in to continue')).toBe(true);
|
|
90
|
+
expect(looksLinkedInAuthWall('https://www.linkedin.com/checkpoint/challenge security verification required')).toBe(true);
|
|
91
|
+
expect(looksLinkedInAuthWall('https://www.linkedin.com/feed/')).toBe(false);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('rejects malformed extraction rows instead of fabricating success rows', () => {
|
|
95
|
+
expect(() => normalizePeopleRows({})).toThrow(CommandExecutionError);
|
|
96
|
+
expect(() => normalizePeopleRows([null])).toThrow(CommandExecutionError);
|
|
97
|
+
expect(() => normalizePeopleRows([{ name: 'No URL', headline: 'h', location: 'l', profile_url: '' }]))
|
|
98
|
+
.toThrow(CommandExecutionError);
|
|
99
|
+
expect(() => normalizePeopleRows([{ name: '', headline: 'h', location: 'l', profile_url: 'https://www.linkedin.com/in/no-name/' }]))
|
|
100
|
+
.toThrow(CommandExecutionError);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('validates extraction evidence counters', () => {
|
|
104
|
+
expect(parseNonNegativeCount(0, 'candidate_count')).toBe(0);
|
|
105
|
+
expect(parseNonNegativeCount(2, 'candidate_count')).toBe(2);
|
|
106
|
+
expect(() => parseNonNegativeCount(undefined, 'candidate_count')).toThrow(CommandExecutionError);
|
|
107
|
+
expect(() => parseNonNegativeCount(-1, 'candidate_count')).toThrow(CommandExecutionError);
|
|
108
|
+
expect(() => parseNonNegativeCount(1.2, 'candidate_count')).toThrow(CommandExecutionError);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('returns ranked rows when the page yields people', async () => {
|
|
112
|
+
const cmd = getRegistry().get('linkedin/people-search');
|
|
113
|
+
expect(cmd?.func).toBeTypeOf('function');
|
|
114
|
+
const page = makePage({
|
|
115
|
+
evaluateResult: extractionResult([
|
|
116
|
+
{ name: 'Alice Engineer', headline: 'Staff SWE at Acme', location: 'Berlin', profile_url: 'https://www.linkedin.com/in/alice-engineer/' },
|
|
117
|
+
{ name: 'Bob Builder', headline: 'CTO at Globex', location: 'Remote', profile_url: 'https://www.linkedin.com/in/bob-builder/' },
|
|
118
|
+
]),
|
|
119
|
+
});
|
|
120
|
+
const result = await cmd.func(page, { keywords: 'reinforcement learning', limit: 5 });
|
|
121
|
+
expect(page.goto).toHaveBeenCalledWith('https://www.linkedin.com/search/results/people/?keywords=reinforcement%20learning');
|
|
122
|
+
expect(result).toEqual([
|
|
123
|
+
{ rank: 1, name: 'Alice Engineer', headline: 'Staff SWE at Acme', location: 'Berlin', profile_url: 'https://www.linkedin.com/in/alice-engineer/' },
|
|
124
|
+
{ rank: 2, name: 'Bob Builder', headline: 'CTO at Globex', location: 'Remote', profile_url: 'https://www.linkedin.com/in/bob-builder/' },
|
|
125
|
+
]);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('slices to --limit when more rows are extracted than requested', async () => {
|
|
129
|
+
const cmd = getRegistry().get('linkedin/people-search');
|
|
130
|
+
const page = makePage({
|
|
131
|
+
evaluateResult: extractionResult(Array.from({ length: 8 }, (_, i) => ({
|
|
132
|
+
name: `Person ${i}`, headline: 'h', location: 'l', profile_url: `https://www.linkedin.com/in/p${i}/`,
|
|
133
|
+
}))),
|
|
134
|
+
});
|
|
135
|
+
const result = await cmd.func(page, { keywords: 'x', limit: 3 });
|
|
136
|
+
expect(result).toHaveLength(3);
|
|
137
|
+
expect(result.map((r) => r.rank)).toEqual([1, 2, 3]);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('throws AuthRequiredError when JSESSIONID cookie is missing', async () => {
|
|
141
|
+
const cmd = getRegistry().get('linkedin/people-search');
|
|
142
|
+
const page = makePage({ cookies: [], evaluateResult: extractionResult([]) });
|
|
143
|
+
await expect(cmd.func(page, { keywords: 'x', limit: 5 })).rejects.toBeInstanceOf(AuthRequiredError);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('treats malformed cookie lookup results as CommandExecutionError', async () => {
|
|
147
|
+
const cmd = getRegistry().get('linkedin/people-search');
|
|
148
|
+
const page = makePage({ cookies: null, evaluateResult: extractionResult([]) });
|
|
149
|
+
await expect(cmd.func(page, { keywords: 'x', limit: 5 })).rejects.toBeInstanceOf(CommandExecutionError);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('treats LinkedIn redirect away from search page as a CUL-flavoured CommandExecutionError', async () => {
|
|
153
|
+
const cmd = getRegistry().get('linkedin/people-search');
|
|
154
|
+
const page = makePage({ evaluateResult: { error: 'not on people search page', url: 'https://www.linkedin.com/' } });
|
|
155
|
+
await expect(cmd.func(page, { keywords: 'x', limit: 5 })).rejects.toBeInstanceOf(CommandExecutionError);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('treats LinkedIn auth-wall redirects as AuthRequiredError', async () => {
|
|
159
|
+
const cmd = getRegistry().get('linkedin/people-search');
|
|
160
|
+
const page = makePage({ evaluateResult: { error: 'not on people search page', url: 'https://www.linkedin.com/authwall?trk=people_search' } });
|
|
161
|
+
await expect(cmd.func(page, { keywords: 'x', limit: 5 })).rejects.toBeInstanceOf(AuthRequiredError);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('wraps browser extraction exceptions as CommandExecutionError', async () => {
|
|
165
|
+
const cmd = getRegistry().get('linkedin/people-search');
|
|
166
|
+
const page = makePage({ evaluateReject: new SyntaxError('Unexpected token <') });
|
|
167
|
+
await expect(cmd.func(page, { keywords: 'x', limit: 5 })).rejects.toBeInstanceOf(CommandExecutionError);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('wraps browser navigation exceptions as CommandExecutionError', async () => {
|
|
171
|
+
const cmd = getRegistry().get('linkedin/people-search');
|
|
172
|
+
const page = makePage({ gotoReject: new Error('navigation failed') });
|
|
173
|
+
await expect(cmd.func(page, { keywords: 'x', limit: 5 })).rejects.toBeInstanceOf(CommandExecutionError);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('throws EmptyResultError when the page rendered zero rows', async () => {
|
|
177
|
+
const cmd = getRegistry().get('linkedin/people-search');
|
|
178
|
+
const page = makePage({ evaluateResult: extractionResult([]) });
|
|
179
|
+
await expect(cmd.func(page, { keywords: 'x', limit: 5 })).rejects.toBeInstanceOf(EmptyResultError);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('treats profile candidates without stable parsed rows as parser drift', async () => {
|
|
183
|
+
const cmd = getRegistry().get('linkedin/people-search');
|
|
184
|
+
const page = makePage({
|
|
185
|
+
evaluateResult: extractionResult([], {
|
|
186
|
+
candidate_count: 1,
|
|
187
|
+
person_entries_count: 1,
|
|
188
|
+
resolved_count: 0,
|
|
189
|
+
}),
|
|
190
|
+
});
|
|
191
|
+
await expect(cmd.func(page, { keywords: 'x', limit: 5 })).rejects.toBeInstanceOf(CommandExecutionError);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('treats missing rows array as parser drift, not empty results', async () => {
|
|
195
|
+
const cmd = getRegistry().get('linkedin/people-search');
|
|
196
|
+
const page = makePage({ evaluateResult: {} });
|
|
197
|
+
await expect(cmd.func(page, { keywords: 'x', limit: 5 })).rejects.toBeInstanceOf(CommandExecutionError);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('rejects empty keywords with ArgumentError before navigation', async () => {
|
|
201
|
+
const cmd = getRegistry().get('linkedin/people-search');
|
|
202
|
+
const page = makePage({ evaluateResult: extractionResult([]) });
|
|
203
|
+
await expect(cmd.func(page, { keywords: ' ', limit: 5 })).rejects.toBeInstanceOf(ArgumentError);
|
|
204
|
+
expect(page.goto).not.toHaveBeenCalled();
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it('registers with the expected columns and arg shape', () => {
|
|
208
|
+
const cmd = getRegistry().get('linkedin/people-search');
|
|
209
|
+
expect(cmd?.columns).toEqual(['rank', 'name', 'headline', 'location', 'profile_url']);
|
|
210
|
+
expect(cmd?.access).toBe('read');
|
|
211
|
+
expect(cmd?.browser).toBe(true);
|
|
212
|
+
const keywordsArg = cmd?.args?.find((a) => a.name === 'keywords');
|
|
213
|
+
expect(keywordsArg?.positional).toBe(true);
|
|
214
|
+
expect(keywordsArg?.required).toBe(true);
|
|
215
|
+
});
|
|
216
|
+
});
|
|
@@ -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
|
+
});
|