@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,167 @@
|
|
|
1
|
+
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
2
|
+
import { ArgumentError, CommandExecutionError } from '@jackwener/opencli/errors';
|
|
3
|
+
import {
|
|
4
|
+
assertLinkedInAuthenticated,
|
|
5
|
+
assertSafeLinkedinUrl,
|
|
6
|
+
normalizeHttpUrl,
|
|
7
|
+
normalizeWhitespace,
|
|
8
|
+
unwrapEvaluateResult,
|
|
9
|
+
} from './shared.js';
|
|
10
|
+
|
|
11
|
+
function normalizeJobUrl(value) {
|
|
12
|
+
const url = assertSafeLinkedinUrl(value, 'job-url');
|
|
13
|
+
const parsed = new URL(url);
|
|
14
|
+
const match = parsed.pathname.match(/^\/jobs\/view\/(\d+)/) || parsed.search.match(/[?&]currentJobId=(\d+)/);
|
|
15
|
+
if (!match) throw new ArgumentError('job-url must be a https://www.linkedin.com/jobs/view/<id> URL');
|
|
16
|
+
return `https://www.linkedin.com/jobs/search/?currentJobId=${match[1]}`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function decodeLinkedinRedirect(url) {
|
|
20
|
+
if (!url) return '';
|
|
21
|
+
try {
|
|
22
|
+
const parsed = new URL(url);
|
|
23
|
+
if (parsed.pathname === '/redir/redirect/') return normalizeHttpUrl(parsed.searchParams.get('url') || '');
|
|
24
|
+
} catch {}
|
|
25
|
+
return normalizeHttpUrl(url);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function buildExtractionScript() {
|
|
29
|
+
return String.raw`(() => {
|
|
30
|
+
const clean = (s) => String(s || '').replace(/[\u00a0\u202f]+/g, ' ').replace(/\s+/g, ' ').trim();
|
|
31
|
+
const readRenderedDescription = () => {
|
|
32
|
+
const expanders = Array.from(document.querySelectorAll('button, a'))
|
|
33
|
+
.filter((el) => /\b(show more|see more|more)\b/i.test(clean(el.innerText || el.textContent || el.getAttribute('aria-label') || '')));
|
|
34
|
+
for (const expander of expanders.slice(0, 3)) {
|
|
35
|
+
try { expander.click(); } catch {}
|
|
36
|
+
}
|
|
37
|
+
const aboutHeading = Array.from(document.querySelectorAll('h1,h2,h3,h4'))
|
|
38
|
+
.find((el) => /^about the job$/i.test(clean(el.innerText || el.textContent || '')));
|
|
39
|
+
const aboutRoot = aboutHeading?.closest('section, article, div');
|
|
40
|
+
if (aboutRoot) {
|
|
41
|
+
const value = clean((aboutRoot.innerText || aboutRoot.textContent || '').replace(/^About the job\s*/i, ''));
|
|
42
|
+
if (value && !/^show more$/i.test(value)) return value;
|
|
43
|
+
}
|
|
44
|
+
const candidates = Array.from(document.querySelectorAll(
|
|
45
|
+
'.jobs-description-content__text, .jobs-box__html-content, .jobs-description__content, [class*="jobs-description"], [class*="description-content"]'
|
|
46
|
+
));
|
|
47
|
+
for (const candidate of candidates) {
|
|
48
|
+
const value = clean(candidate.innerText || candidate.textContent || '');
|
|
49
|
+
if (value && value.length > 40 && !/^show more$/i.test(value)) return value.replace(/^About the job\s*/i, '');
|
|
50
|
+
}
|
|
51
|
+
return '';
|
|
52
|
+
};
|
|
53
|
+
const parseInlineJobData = () => {
|
|
54
|
+
const codes = Array.from(document.querySelectorAll('code[id^="bpr-guid-"]'));
|
|
55
|
+
for (const code of codes) {
|
|
56
|
+
let payload;
|
|
57
|
+
try { payload = JSON.parse(code.textContent || '{}'); } catch { continue; }
|
|
58
|
+
const included = Array.isArray(payload.included) ? payload.included : [];
|
|
59
|
+
const topCard = included.find((item) => item && (item.jobPostingTitle || item.primaryDescription || item.tertiaryDescription));
|
|
60
|
+
if (!topCard) continue;
|
|
61
|
+
const apply = included.find((item) => item && item.companyApplyUrl);
|
|
62
|
+
const workplace = included.find((item) => item && (item.workplaceTypeEnum || item.localizedName));
|
|
63
|
+
const company = included.find((item) => item && item.name && /company/i.test(String(item.entityUrn || item.$type || '')));
|
|
64
|
+
return {
|
|
65
|
+
url: location.href,
|
|
66
|
+
title: clean(topCard.jobPostingTitle || topCard.title?.text || topCard.title?.accessibilityText || ''),
|
|
67
|
+
company: clean(topCard.primaryDescription?.text || company?.name || ''),
|
|
68
|
+
company_url: clean(topCard.primaryDescription?.attributesV2?.[0]?.detailData?.hyperlink || topCard.logo?.actionTarget || ''),
|
|
69
|
+
location: clean(topCard.navigationBarSubtitle || topCard.secondaryDescription?.text || ''),
|
|
70
|
+
workplace_type: clean(workplace?.localizedName || workplace?.workplaceTypeEnum || ''),
|
|
71
|
+
job_type: clean((topCard.jobInsightsV2ResolutionResults || []).flatMap((x) => x?.jobInsightViewModel?.description || []).map((x) => x?.text?.text || '').find((x) => /full-time|part-time|contract|internship/i.test(x)) || ''),
|
|
72
|
+
applicants: clean((topCard.tertiaryDescription?.text || '').match(/Over\s+\d+|\d[\d,]*\s+people clicked apply|\d[\d,]*\s+applicants?/i)?.[0] || ''),
|
|
73
|
+
listed: clean((topCard.tertiaryDescription?.text || '').match(/\d+\s+(?:hour|hours|day|days|week|weeks|month|months)\s+ago/i)?.[0] || ''),
|
|
74
|
+
apply_url: clean(apply?.companyApplyUrl || ''),
|
|
75
|
+
description: '',
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
return null;
|
|
79
|
+
};
|
|
80
|
+
const inline = parseInlineJobData();
|
|
81
|
+
const renderedDescription = readRenderedDescription();
|
|
82
|
+
if (inline && inline.title) return { ...inline, description: clean(inline.description || renderedDescription) };
|
|
83
|
+
const text = document.body ? document.body.innerText || '' : '';
|
|
84
|
+
const lines = text.split(/\n+/).map(clean).filter(Boolean);
|
|
85
|
+
const h1 = clean(document.querySelector('h1')?.innerText || document.querySelector('h1')?.textContent || document.querySelector('.job-details-jobs-unified-top-card__job-title, [class*="job-title"]')?.textContent || '');
|
|
86
|
+
const companyLink = document.querySelector('a[href*="/company/"]');
|
|
87
|
+
const company = clean(companyLink?.innerText || companyLink?.textContent || '');
|
|
88
|
+
const company_url = companyLink?.href ? new URL(companyLink.href, location.origin).toString().replace(/[?#].*$/, '') : '';
|
|
89
|
+
const description = renderedDescription;
|
|
90
|
+
const applyLink = Array.from(document.querySelectorAll('a[href], button')).find((el) => {
|
|
91
|
+
const label = clean(el.innerText || el.textContent || el.getAttribute('aria-label') || '');
|
|
92
|
+
return /\b(apply|easy apply)\b/i.test(label);
|
|
93
|
+
});
|
|
94
|
+
const apply_url = applyLink?.href ? new URL(applyLink.href, location.origin).toString() : '';
|
|
95
|
+
const fullText = lines.join(' ');
|
|
96
|
+
const workplaceMatch = fullText.match(/\b(Remote|Hybrid|On-site|Onsite)\b/i);
|
|
97
|
+
const jobTypeMatch = fullText.match(/\b(Full-time|Part-time|Contract|Temporary|Internship|Volunteer)\b/i);
|
|
98
|
+
const applicantsMatch = fullText.match(/(\d[\d,]*)\s+applicants?/i);
|
|
99
|
+
const listedMatch = fullText.match(/(?:Reposted|Posted|Listed)\s+(\d+\s+(?:hour|hours|day|days|week|weeks|month|months)\s+ago)/i);
|
|
100
|
+
const locationLine = lines.find((line) => /\b(Remote|Hybrid|On-site|Onsite)\b/i.test(line) && line.length < 180)
|
|
101
|
+
|| lines.find((line) => /,\s*[A-Z][A-Za-z\s]+/.test(line) && line.length < 120)
|
|
102
|
+
|| '';
|
|
103
|
+
return {
|
|
104
|
+
url: location.href,
|
|
105
|
+
title: h1,
|
|
106
|
+
company,
|
|
107
|
+
company_url,
|
|
108
|
+
location: locationLine,
|
|
109
|
+
workplace_type: workplaceMatch ? workplaceMatch[1] : '',
|
|
110
|
+
job_type: jobTypeMatch ? jobTypeMatch[1] : '',
|
|
111
|
+
applicants: applicantsMatch ? applicantsMatch[1] : '',
|
|
112
|
+
listed: listedMatch ? listedMatch[1] : '',
|
|
113
|
+
apply_url,
|
|
114
|
+
description,
|
|
115
|
+
};
|
|
116
|
+
})()`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function normalizeDetail(row) {
|
|
120
|
+
if (!row || typeof row !== 'object') {
|
|
121
|
+
throw new CommandExecutionError('LinkedIn job detail returned malformed extraction payload');
|
|
122
|
+
}
|
|
123
|
+
const title = normalizeWhitespace(row.title);
|
|
124
|
+
if (!title) throw new CommandExecutionError('LinkedIn job detail could not find a job title');
|
|
125
|
+
return {
|
|
126
|
+
title,
|
|
127
|
+
company: normalizeWhitespace(row.company),
|
|
128
|
+
location: normalizeWhitespace(row.location),
|
|
129
|
+
workplace_type: normalizeWhitespace(row.workplace_type),
|
|
130
|
+
job_type: normalizeWhitespace(row.job_type),
|
|
131
|
+
applicants: normalizeWhitespace(row.applicants),
|
|
132
|
+
listed: normalizeWhitespace(row.listed),
|
|
133
|
+
apply_url: decodeLinkedinRedirect(normalizeWhitespace(row.apply_url)),
|
|
134
|
+
company_url: normalizeHttpUrl(row.company_url),
|
|
135
|
+
url: normalizeHttpUrl(row.url),
|
|
136
|
+
description: normalizeWhitespace(row.description),
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
cli({
|
|
141
|
+
site: 'linkedin',
|
|
142
|
+
name: 'job-detail',
|
|
143
|
+
access: 'read',
|
|
144
|
+
description: 'Read one LinkedIn job page with description, apply URL, workplace type, applicants, and company metadata',
|
|
145
|
+
domain: 'www.linkedin.com',
|
|
146
|
+
strategy: Strategy.COOKIE,
|
|
147
|
+
browser: true,
|
|
148
|
+
args: [
|
|
149
|
+
{ name: 'job-url', type: 'string', required: true, positional: true, help: 'Exact LinkedIn job URL, e.g. https://www.linkedin.com/jobs/view/123/' },
|
|
150
|
+
],
|
|
151
|
+
columns: ['title', 'company', 'location', 'workplace_type', 'job_type', 'applicants', 'listed', 'apply_url', 'company_url', 'url', 'description'],
|
|
152
|
+
func: async (page, args) => {
|
|
153
|
+
if (!page) throw new CommandExecutionError('Browser session required for linkedin job-detail');
|
|
154
|
+
const jobUrl = normalizeJobUrl(args['job-url']);
|
|
155
|
+
await page.goto(jobUrl);
|
|
156
|
+
await page.wait(4);
|
|
157
|
+
await assertLinkedInAuthenticated(page, 'LinkedIn job-detail');
|
|
158
|
+
const row = unwrapEvaluateResult(await page.evaluate(buildExtractionScript()));
|
|
159
|
+
return [normalizeDetail(row)];
|
|
160
|
+
},
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
export const __test__ = {
|
|
164
|
+
normalizeJobUrl,
|
|
165
|
+
decodeLinkedinRedirect,
|
|
166
|
+
normalizeDetail,
|
|
167
|
+
};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { getRegistry } from '@jackwener/opencli/registry';
|
|
3
|
+
import { ArgumentError, CommandExecutionError } from '@jackwener/opencli/errors';
|
|
4
|
+
import './job-detail.js';
|
|
5
|
+
|
|
6
|
+
const { normalizeJobUrl, decodeLinkedinRedirect, normalizeDetail } = await import('./job-detail.js').then((m) => m.__test__);
|
|
7
|
+
|
|
8
|
+
describe('linkedin job-detail adapter', () => {
|
|
9
|
+
const command = getRegistry().get('linkedin/job-detail');
|
|
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('description');
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('normalizes exact LinkedIn job urls', () => {
|
|
19
|
+
expect(normalizeJobUrl('https://www.linkedin.com/jobs/view/123456?x=1')).toBe('https://www.linkedin.com/jobs/search/?currentJobId=123456');
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('rejects non-job URLs', () => {
|
|
23
|
+
expect(() => normalizeJobUrl('https://www.linkedin.com/feed/')).toThrow(ArgumentError);
|
|
24
|
+
expect(() => normalizeJobUrl('https://example.com/jobs/view/1')).toThrow(ArgumentError);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('decodes LinkedIn redirect apply urls', () => {
|
|
28
|
+
expect(decodeLinkedinRedirect('https://www.linkedin.com/redir/redirect/?url=https%3A%2F%2Fexample.com%2Fapply')).toBe('https://example.com/apply');
|
|
29
|
+
expect(decodeLinkedinRedirect('https://www.linkedin.com/redir/redirect/?url=javascript%3Aalert(1)')).toBe('');
|
|
30
|
+
expect(decodeLinkedinRedirect('javascript:alert(1)')).toBe('');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('requires stable title in extracted detail', () => {
|
|
34
|
+
expect(() => normalizeDetail({ title: '' })).toThrow(CommandExecutionError);
|
|
35
|
+
expect(normalizeDetail({ title: 'Senior Engineer', company: 'Acme', description: 'Build things' }))
|
|
36
|
+
.toMatchObject({ title: 'Senior Engineer', company: 'Acme', description: 'Build things' });
|
|
37
|
+
});
|
|
38
|
+
});
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
2
|
+
import { CommandExecutionError } from '@jackwener/opencli/errors';
|
|
3
|
+
import {
|
|
4
|
+
assertLinkedInAuthenticated,
|
|
5
|
+
normalizeWhitespace,
|
|
6
|
+
unwrapEvaluateResult,
|
|
7
|
+
} from './shared.js';
|
|
8
|
+
|
|
9
|
+
const PREFERENCES_URL = 'https://www.linkedin.com/jobs/preferences/';
|
|
10
|
+
const ALERTS_URL = 'https://www.linkedin.com/jobs/alerts/';
|
|
11
|
+
|
|
12
|
+
function inferOpenToWork(text) {
|
|
13
|
+
const normalized = normalizeWhitespace(text).toLowerCase();
|
|
14
|
+
if (/\bopen to work\b.{0,80}\b(on|status on|visible to recruiters|job preferences visible)\b/.test(normalized)) return 'on';
|
|
15
|
+
if (/\bopen to work\b.{0,80}\b(off|status off|not visible|turned off|inactive)\b/.test(normalized)) return 'off';
|
|
16
|
+
if (/\bopen to work\b/.test(normalized) && /\b(off|not visible|turned off|inactive)\b/.test(normalized)) return 'off';
|
|
17
|
+
if (/\bopen to work\b/.test(normalized) && /\b(on|visible|actively|turned on)\b/.test(normalized)) return 'on';
|
|
18
|
+
if (/\bopen to work\b/.test(normalized)) return 'visible';
|
|
19
|
+
return 'unknown';
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function buildPreferencesScript() {
|
|
23
|
+
return String.raw`(() => {
|
|
24
|
+
const clean = (s) => String(s || '').replace(/[\u00a0\u202f]+/g, ' ').replace(/\s+/g, ' ').trim();
|
|
25
|
+
const text = document.body ? document.body.innerText || '' : '';
|
|
26
|
+
const preferencesText = text.split(/Top job picks for you|Recommended jobs|Similar jobs|Explore jobs/i)[0] || text;
|
|
27
|
+
const lines = preferencesText.split(/\n+/).map(clean).filter(Boolean);
|
|
28
|
+
const jobTitles = [];
|
|
29
|
+
const locations = [];
|
|
30
|
+
for (const line of lines) {
|
|
31
|
+
if (/senior|engineer|developer|architect|manager|designer|analyst|product/i.test(line) && line.length < 90) jobTitles.push(line);
|
|
32
|
+
if (/(remote|india|bangalore|bengaluru|delhi|mumbai|hyderabad|pune|jaipur|within\s+\d+\s+miles?)/i.test(line) && line.length < 120) locations.push(line);
|
|
33
|
+
}
|
|
34
|
+
return {
|
|
35
|
+
preferences_url: location.href,
|
|
36
|
+
raw_preferences: clean(text).slice(0, 3000),
|
|
37
|
+
job_titles: Array.from(new Set(jobTitles)).slice(0, 12),
|
|
38
|
+
locations: Array.from(new Set(locations)).slice(0, 12),
|
|
39
|
+
};
|
|
40
|
+
})()`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function buildAlertsScript() {
|
|
44
|
+
return String.raw`(() => {
|
|
45
|
+
const clean = (s) => String(s || '').replace(/[\u00a0\u202f]+/g, ' ').replace(/\s+/g, ' ').trim();
|
|
46
|
+
const text = document.body ? document.body.innerText || '' : '';
|
|
47
|
+
const lines = text.split(/\n+/).map(clean).filter(Boolean);
|
|
48
|
+
const alerts = [];
|
|
49
|
+
for (let i = 0; i < lines.length; i++) {
|
|
50
|
+
const line = lines[i];
|
|
51
|
+
if (/alert/i.test(line) && line.length < 160) {
|
|
52
|
+
alerts.push([line, lines[i + 1], lines[i + 2]].filter(Boolean).join(' | '));
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return {
|
|
56
|
+
alerts_url: location.href,
|
|
57
|
+
job_alerts: Array.from(new Set(alerts)).slice(0, 20),
|
|
58
|
+
raw_preferences: clean(text).slice(0, 3000),
|
|
59
|
+
};
|
|
60
|
+
})()`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function normalizePreferences(preferences, alerts) {
|
|
64
|
+
if (!preferences || typeof preferences !== 'object') {
|
|
65
|
+
throw new CommandExecutionError('LinkedIn jobs preferences returned malformed preferences payload');
|
|
66
|
+
}
|
|
67
|
+
if (!alerts || typeof alerts !== 'object') {
|
|
68
|
+
throw new CommandExecutionError('LinkedIn jobs preferences returned malformed alerts payload');
|
|
69
|
+
}
|
|
70
|
+
const preferenceText = normalizeWhitespace(preferences.raw_preferences);
|
|
71
|
+
const alertText = normalizeWhitespace(alerts.raw_preferences);
|
|
72
|
+
if (!preferenceText && !alertText) {
|
|
73
|
+
throw new CommandExecutionError('LinkedIn jobs preferences could not find stable preferences content');
|
|
74
|
+
}
|
|
75
|
+
return {
|
|
76
|
+
open_to_work: inferOpenToWork(`${preferenceText} ${alertText}`),
|
|
77
|
+
job_titles: Array.isArray(preferences.job_titles) ? preferences.job_titles.map(normalizeWhitespace).filter(Boolean).join('; ') : '',
|
|
78
|
+
locations: Array.isArray(preferences.locations) ? preferences.locations.map(normalizeWhitespace).filter(Boolean).join('; ') : '',
|
|
79
|
+
job_alerts: Array.isArray(alerts.job_alerts) ? alerts.job_alerts.map(normalizeWhitespace).filter(Boolean).join('; ') : '',
|
|
80
|
+
preferences_url: normalizeWhitespace(preferences.preferences_url),
|
|
81
|
+
alerts_url: normalizeWhitespace(alerts.alerts_url),
|
|
82
|
+
raw_preferences: preferenceText.slice(0, 1200),
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
cli({
|
|
87
|
+
site: 'linkedin',
|
|
88
|
+
name: 'jobs-preferences',
|
|
89
|
+
access: 'read',
|
|
90
|
+
description: 'Read visible LinkedIn Jobs preferences and alert settings without changing them',
|
|
91
|
+
domain: 'www.linkedin.com',
|
|
92
|
+
strategy: Strategy.COOKIE,
|
|
93
|
+
browser: true,
|
|
94
|
+
args: [],
|
|
95
|
+
columns: ['open_to_work', 'job_titles', 'locations', 'job_alerts', 'preferences_url', 'alerts_url', 'raw_preferences'],
|
|
96
|
+
func: async (page) => {
|
|
97
|
+
if (!page) throw new CommandExecutionError('Browser session required for linkedin jobs-preferences');
|
|
98
|
+
await page.goto(PREFERENCES_URL);
|
|
99
|
+
await page.wait(5);
|
|
100
|
+
await assertLinkedInAuthenticated(page, 'LinkedIn jobs-preferences');
|
|
101
|
+
const preferences = unwrapEvaluateResult(await page.evaluate(buildPreferencesScript()));
|
|
102
|
+
await page.goto(ALERTS_URL);
|
|
103
|
+
await page.wait(5);
|
|
104
|
+
await assertLinkedInAuthenticated(page, 'LinkedIn jobs-preferences alerts');
|
|
105
|
+
const alerts = unwrapEvaluateResult(await page.evaluate(buildAlertsScript()));
|
|
106
|
+
return [normalizePreferences(preferences, alerts)];
|
|
107
|
+
},
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
export const __test__ = {
|
|
111
|
+
inferOpenToWork,
|
|
112
|
+
normalizePreferences,
|
|
113
|
+
};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { getRegistry } from '@jackwener/opencli/registry';
|
|
3
|
+
import { CommandExecutionError } from '@jackwener/opencli/errors';
|
|
4
|
+
import './jobs-preferences.js';
|
|
5
|
+
|
|
6
|
+
const { inferOpenToWork, normalizePreferences } = await import('./jobs-preferences.js').then((m) => m.__test__);
|
|
7
|
+
|
|
8
|
+
describe('linkedin jobs-preferences adapter', () => {
|
|
9
|
+
const command = getRegistry().get('linkedin/jobs-preferences');
|
|
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('open_to_work');
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('infers Open to Work visible states conservatively', () => {
|
|
19
|
+
expect(inferOpenToWork('Open to work turned off')).toBe('off');
|
|
20
|
+
expect(inferOpenToWork('Open to work turned on visible to recruiters')).toBe('on');
|
|
21
|
+
expect(inferOpenToWork('Open to work')).toBe('visible');
|
|
22
|
+
expect(inferOpenToWork('Job preferences')).toBe('unknown');
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('normalizes preferences and alerts', () => {
|
|
26
|
+
const out = normalizePreferences(
|
|
27
|
+
{ raw_preferences: 'Open to work turned off', job_titles: ['Senior Software Engineer'], locations: ['Bangalore within 20 miles'], preferences_url: 'https://www.linkedin.com/jobs/preferences/' },
|
|
28
|
+
{ raw_preferences: 'Job alert', job_alerts: ['Senior Software Engineer | Bangalore'], alerts_url: 'https://www.linkedin.com/jobs/alerts/' },
|
|
29
|
+
);
|
|
30
|
+
expect(out).toMatchObject({
|
|
31
|
+
open_to_work: 'off',
|
|
32
|
+
job_titles: 'Senior Software Engineer',
|
|
33
|
+
locations: 'Bangalore within 20 miles',
|
|
34
|
+
job_alerts: 'Senior Software Engineer | Bangalore',
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('rejects malformed payloads', () => {
|
|
39
|
+
expect(() => normalizePreferences(null, {})).toThrow(CommandExecutionError);
|
|
40
|
+
expect(() => normalizePreferences({}, null)).toThrow(CommandExecutionError);
|
|
41
|
+
expect(() => normalizePreferences({ raw_preferences: '' }, { raw_preferences: '' })).toThrow(CommandExecutionError);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LinkedIn people-search via SSR DOM text-slice. Voyager people-search
|
|
3
|
+
* REST returns HTTP 500 from a web context; LinkedIn renders results
|
|
4
|
+
* server-side now. One navigation per call consumes one CUL query.
|
|
5
|
+
*/
|
|
6
|
+
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
7
|
+
import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
|
|
8
|
+
|
|
9
|
+
const LINKEDIN_DOMAIN = 'www.linkedin.com';
|
|
10
|
+
const SEARCH_URL_BASE = 'https://www.linkedin.com/search/results/people/';
|
|
11
|
+
const MAX_LIMIT = 10;
|
|
12
|
+
|
|
13
|
+
function normalizeWhitespace(value) {
|
|
14
|
+
return String(value ?? '').replace(/[ ]/g, ' ').replace(/\s+/g, ' ').trim();
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function requireStringArg(args, key, label = key) {
|
|
18
|
+
const value = normalizeWhitespace(args[key]);
|
|
19
|
+
if (!value) throw new ArgumentError(`${label} is required`);
|
|
20
|
+
return value;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function parseLimit(value) {
|
|
24
|
+
if (value === undefined || value === null || value === '') return 5;
|
|
25
|
+
const limit = Number(value);
|
|
26
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > MAX_LIMIT) {
|
|
27
|
+
throw new ArgumentError(`--limit must be an integer between 1 and ${MAX_LIMIT}`);
|
|
28
|
+
}
|
|
29
|
+
return limit;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function unwrapEvaluateResult(payload) {
|
|
33
|
+
if (payload && typeof payload === 'object' && 'data' in payload && 'session' in payload) return payload.data;
|
|
34
|
+
return payload;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function buildSearchUrl(keywords) {
|
|
38
|
+
return SEARCH_URL_BASE + '?keywords=' + encodeURIComponent(keywords);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function looksLinkedInAuthWall(value) {
|
|
42
|
+
const text = normalizeWhitespace(value).toLowerCase();
|
|
43
|
+
if (!text) return false;
|
|
44
|
+
return /linkedin\.com\/(?:login|checkpoint|authwall|uas)/i.test(text)
|
|
45
|
+
|| /\b(sign in|log in|join linkedin|captcha|verification required)\b/i.test(text)
|
|
46
|
+
|| /(请登录|登录领英|安全验证)/.test(text);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function normalizeProfileUrl(value) {
|
|
50
|
+
const raw = normalizeWhitespace(value);
|
|
51
|
+
if (!raw) return '';
|
|
52
|
+
try {
|
|
53
|
+
const parsed = new URL(raw);
|
|
54
|
+
const host = parsed.hostname.toLowerCase();
|
|
55
|
+
if (parsed.protocol !== 'https:' || parsed.username || parsed.password || parsed.port) return '';
|
|
56
|
+
if (host !== 'linkedin.com' && host !== 'www.linkedin.com') return '';
|
|
57
|
+
const match = parsed.pathname.match(/^\/in\/([^/?#]+)\/?$/);
|
|
58
|
+
if (!match || !match[1]) return '';
|
|
59
|
+
return `https://www.linkedin.com/in/${match[1]}/`;
|
|
60
|
+
} catch {
|
|
61
|
+
return '';
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function normalizePeopleRows(rows) {
|
|
66
|
+
if (!Array.isArray(rows)) {
|
|
67
|
+
throw new CommandExecutionError('LinkedIn people search returned malformed extraction payload: missing rows array');
|
|
68
|
+
}
|
|
69
|
+
return rows.map((row, index) => {
|
|
70
|
+
if (!row || typeof row !== 'object') {
|
|
71
|
+
throw new CommandExecutionError(`LinkedIn people search returned malformed row at index ${index}`);
|
|
72
|
+
}
|
|
73
|
+
const name = normalizeWhitespace(row.name);
|
|
74
|
+
const profileUrl = normalizeProfileUrl(row.profile_url);
|
|
75
|
+
if (!name || !profileUrl) {
|
|
76
|
+
throw new CommandExecutionError(`LinkedIn people search returned row without stable profile identity at index ${index}`);
|
|
77
|
+
}
|
|
78
|
+
return {
|
|
79
|
+
name,
|
|
80
|
+
headline: normalizeWhitespace(row.headline),
|
|
81
|
+
location: normalizeWhitespace(row.location),
|
|
82
|
+
profile_url: profileUrl,
|
|
83
|
+
};
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function parseNonNegativeCount(value, label) {
|
|
88
|
+
const count = Number(value);
|
|
89
|
+
if (!Number.isInteger(count) || count < 0) {
|
|
90
|
+
throw new CommandExecutionError(`LinkedIn people search returned malformed extraction payload: invalid ${label}`);
|
|
91
|
+
}
|
|
92
|
+
return count;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function extractionScript() {
|
|
96
|
+
// Class-based selectors are dead (LinkedIn rotates hashed class
|
|
97
|
+
// names on every deploy) and display:contents flattens the DOM
|
|
98
|
+
// tree so per-card containers don't exist. Read main.innerText
|
|
99
|
+
// and slice between consecutive person-name lines instead.
|
|
100
|
+
return String.raw`(() => {
|
|
101
|
+
if (!/search\/results\/people/.test(window.location.href)) {
|
|
102
|
+
return { error: 'not on people search page', url: window.location.href };
|
|
103
|
+
}
|
|
104
|
+
const main = document.querySelector('main') || document.body;
|
|
105
|
+
const normalize = (s) => String(s || '').replace(/[\s\u00a0\u202f]+/g, ' ').trim();
|
|
106
|
+
const skip = (l) => !l
|
|
107
|
+
|| /^Status is/.test(l)
|
|
108
|
+
|| /^(Message|Connect|Follow|View profile|Pending|Remove)$/i.test(l)
|
|
109
|
+
|| /^[•·]\s*(?:1st|2nd|3rd\+?|degree)/i.test(l)
|
|
110
|
+
|| /^[•·]/.test(l)
|
|
111
|
+
|| l.includes('mutual connection')
|
|
112
|
+
|| l.includes('shared connection')
|
|
113
|
+
|| /^Summary:/i.test(l)
|
|
114
|
+
|| /^About this profile/i.test(l);
|
|
115
|
+
|
|
116
|
+
const anchors = Array.from(main.querySelectorAll('a[href*="/in/"]'));
|
|
117
|
+
const personEntries = [];
|
|
118
|
+
const seenHandles = new Set();
|
|
119
|
+
for (const a of anchors) {
|
|
120
|
+
const m = (a.getAttribute('href') || '').match(/\/in\/([^/?#]+)/);
|
|
121
|
+
if (!m || !m[1]) continue;
|
|
122
|
+
const profileHandle = m[1];
|
|
123
|
+
if (seenHandles.has(profileHandle)) continue;
|
|
124
|
+
const aria = a.querySelector('span[aria-hidden="true"]');
|
|
125
|
+
let name = normalize(aria ? aria.textContent : a.textContent);
|
|
126
|
+
name = name.replace(/^Status is (online|offline)\.?\s*/i, '')
|
|
127
|
+
.replace(/'?s profile$/i, '')
|
|
128
|
+
.replace(/\s*[•·].*$/, '').trim();
|
|
129
|
+
if (!name) continue;
|
|
130
|
+
seenHandles.add(profileHandle);
|
|
131
|
+
personEntries.push({ profileHandle, displayName: name });
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const lines = (main.innerText || '').split(/\n+/).map(normalize).filter(Boolean);
|
|
135
|
+
|
|
136
|
+
// skip() rejects mutual-connection lines, so candidates that only
|
|
137
|
+
// appear as mutual-connection links inside another card's row
|
|
138
|
+
// never resolve a name index and get filtered out below.
|
|
139
|
+
const nameToIndex = new Map();
|
|
140
|
+
for (const { displayName } of personEntries) {
|
|
141
|
+
if (nameToIndex.has(displayName)) continue;
|
|
142
|
+
const match = lines.findIndex((l) =>
|
|
143
|
+
!skip(l) && (
|
|
144
|
+
l === displayName
|
|
145
|
+
|| l.startsWith(displayName + ' ')
|
|
146
|
+
|| l.startsWith(displayName + ',')
|
|
147
|
+
|| l.startsWith(displayName + "'")
|
|
148
|
+
)
|
|
149
|
+
);
|
|
150
|
+
if (match >= 0) nameToIndex.set(displayName, match);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const resolved = personEntries.filter((p) => nameToIndex.has(p.displayName));
|
|
154
|
+
const rows = [];
|
|
155
|
+
for (let i = 0; i < resolved.length; i++) {
|
|
156
|
+
const { profileHandle, displayName } = resolved[i];
|
|
157
|
+
const startIdx = nameToIndex.get(displayName);
|
|
158
|
+
let stopIdx = lines.length;
|
|
159
|
+
for (let j = i + 1; j < resolved.length; j++) {
|
|
160
|
+
const otherStart = nameToIndex.get(resolved[j].displayName);
|
|
161
|
+
if (otherStart != null && otherStart > startIdx) {
|
|
162
|
+
stopIdx = otherStart;
|
|
163
|
+
break;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
const slice = lines.slice(startIdx + 1, stopIdx).filter((l) => l !== displayName && !skip(l));
|
|
167
|
+
rows.push({
|
|
168
|
+
name: displayName,
|
|
169
|
+
headline: slice[0] || '',
|
|
170
|
+
location: slice[1] || '',
|
|
171
|
+
profile_url: 'https://www.linkedin.com/in/' + profileHandle + '/',
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
return {
|
|
175
|
+
rows,
|
|
176
|
+
candidate_count: personEntries.length,
|
|
177
|
+
person_entries_count: personEntries.length,
|
|
178
|
+
resolved_count: resolved.length,
|
|
179
|
+
};
|
|
180
|
+
})()`;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
cli({
|
|
184
|
+
site: 'linkedin',
|
|
185
|
+
name: 'people-search',
|
|
186
|
+
access: 'read',
|
|
187
|
+
description: 'Search standard LinkedIn (not Sales Navigator) for people by keyword. Each invocation consumes against LinkedIn\'s monthly Commercial Use Limit on people search; throttle accordingly.',
|
|
188
|
+
domain: LINKEDIN_DOMAIN,
|
|
189
|
+
strategy: Strategy.COOKIE,
|
|
190
|
+
browser: true,
|
|
191
|
+
args: [
|
|
192
|
+
{ name: 'keywords', type: 'string', required: true, positional: true, help: 'People search keywords, e.g. "site reliability engineer berlin"' },
|
|
193
|
+
{ name: 'limit', type: 'int', default: 5, help: `Maximum people to return (1-${MAX_LIMIT}); each query counts toward LinkedIn's monthly CUL` },
|
|
194
|
+
],
|
|
195
|
+
columns: ['rank', 'name', 'headline', 'location', 'profile_url'],
|
|
196
|
+
func: async (page, args) => {
|
|
197
|
+
if (!page) throw new CommandExecutionError('Browser session required for linkedin people-search');
|
|
198
|
+
const keywords = requireStringArg(args, 'keywords', '--keywords');
|
|
199
|
+
const limit = parseLimit(args.limit);
|
|
200
|
+
|
|
201
|
+
try {
|
|
202
|
+
await page.goto(buildSearchUrl(keywords));
|
|
203
|
+
await page.wait(6);
|
|
204
|
+
} catch (error) {
|
|
205
|
+
throw new CommandExecutionError(`LinkedIn people search navigation failed: ${error?.message || error}`);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
let cookies;
|
|
209
|
+
try {
|
|
210
|
+
cookies = await page.getCookies({ url: 'https://www.linkedin.com' });
|
|
211
|
+
} catch (error) {
|
|
212
|
+
throw new CommandExecutionError(`LinkedIn cookie lookup failed: ${error?.message || error}`);
|
|
213
|
+
}
|
|
214
|
+
if (!Array.isArray(cookies)) {
|
|
215
|
+
throw new CommandExecutionError('LinkedIn cookie lookup returned malformed payload');
|
|
216
|
+
}
|
|
217
|
+
const jsession = cookies.find((c) => c.name === 'JSESSIONID')?.value;
|
|
218
|
+
if (!jsession) {
|
|
219
|
+
throw new AuthRequiredError(LINKEDIN_DOMAIN, 'LinkedIn JSESSIONID cookie not found. Please sign in to LinkedIn in the browser.');
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
let result;
|
|
223
|
+
try {
|
|
224
|
+
result = unwrapEvaluateResult(await page.evaluate(extractionScript()));
|
|
225
|
+
} catch (error) {
|
|
226
|
+
throw new CommandExecutionError(`LinkedIn people search extraction failed: ${error?.message || error}`);
|
|
227
|
+
}
|
|
228
|
+
if (result?.error) {
|
|
229
|
+
if (looksLinkedInAuthWall(`${result.url || ''} ${result.error || ''}`)) {
|
|
230
|
+
throw new AuthRequiredError(LINKEDIN_DOMAIN, 'LinkedIn people search requires an active signed-in browser session.');
|
|
231
|
+
}
|
|
232
|
+
// If LinkedIn redirected away from the search page that
|
|
233
|
+
// usually means CUL was reached or the account is gated.
|
|
234
|
+
throw new CommandExecutionError(`LinkedIn redirected away from the search page (${result.error}). Likely Commercial Use Limit reached - the limit resets on the 1st of next month.`);
|
|
235
|
+
}
|
|
236
|
+
if (!result || typeof result !== 'object') {
|
|
237
|
+
throw new CommandExecutionError('LinkedIn people search returned malformed extraction payload');
|
|
238
|
+
}
|
|
239
|
+
const candidateCount = parseNonNegativeCount(result.candidate_count, 'candidate_count');
|
|
240
|
+
parseNonNegativeCount(result.person_entries_count, 'person_entries_count');
|
|
241
|
+
const resolvedCount = parseNonNegativeCount(result.resolved_count, 'resolved_count');
|
|
242
|
+
const rows = normalizePeopleRows(result.rows);
|
|
243
|
+
if (rows.length === 0 && (candidateCount > 0 || resolvedCount > 0)) {
|
|
244
|
+
throw new CommandExecutionError('LinkedIn people search found profile candidates but could not parse stable result rows');
|
|
245
|
+
}
|
|
246
|
+
if (rows.length === 0) {
|
|
247
|
+
throw new EmptyResultError(`No people found on the rendered page for "${keywords}". The search may have returned zero results, or the DOM markup may have changed.`);
|
|
248
|
+
}
|
|
249
|
+
return rows.slice(0, limit).map((p, i) => ({ rank: i + 1, ...p }));
|
|
250
|
+
},
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
export const __test__ = {
|
|
254
|
+
normalizeWhitespace,
|
|
255
|
+
parseLimit,
|
|
256
|
+
buildSearchUrl,
|
|
257
|
+
looksLinkedInAuthWall,
|
|
258
|
+
normalizeProfileUrl,
|
|
259
|
+
normalizePeopleRows,
|
|
260
|
+
parseNonNegativeCount,
|
|
261
|
+
extractionScript,
|
|
262
|
+
};
|