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