@jackwener/opencli 1.7.22 → 1.8.0
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 +30 -148
- package/README.zh-CN.md +37 -211
- package/cli-manifest.json +6423 -4260
- 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/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/summary.js +167 -0
- package/clis/bilibili/summary.test.js +210 -0
- 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/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/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/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/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/people-search.js +262 -0
- package/clis/linkedin/people-search.test.js +216 -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/thread-snapshot.js +214 -0
- package/clis/linkedin/thread-snapshot.test.js +89 -0
- 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/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 +226 -0
- package/clis/suno/generate.test.js +243 -0
- package/clis/suno/list.js +79 -0
- package/clis/suno/status.js +62 -0
- package/clis/suno/utils.js +540 -0
- package/clis/suno/utils.test.js +223 -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/list-create.js +155 -0
- package/clis/twitter/list-create.test.js +169 -0
- package/clis/twitter/list-remove.js +12 -5
- 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/search.js +6 -2
- package/clis/twitter/search.test.js +6 -0
- package/clis/twitter/shared.js +144 -0
- package/clis/twitter/shared.test.js +429 -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/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/search-regression.test.js +18 -11
- package/clis/weread/search.js +15 -7
- 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 +2 -1
- package/clis/xiaohongshu/creator-note-detail.test.js +11 -0
- 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 +2 -1
- package/clis/xiaohongshu/creator-notes.test.js +12 -0
- 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/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 +299 -0
- package/clis/zhihu/answer-comments.test.js +287 -0
- package/clis/zhihu/answer-detail.js +12 -0
- package/clis/zhihu/answer-detail.test.js +8 -0
- package/clis/zhihu/collection.js +15 -2
- package/clis/zhihu/collection.test.js +46 -0
- package/clis/zhihu/download.js +1 -1
- package/clis/zhihu/question.js +42 -9
- package/clis/zhihu/question.test.js +111 -9
- package/clis/zhihu/search.js +206 -43
- package/clis/zhihu/search.test.js +198 -0
- package/dist/src/browser/errors.js +4 -2
- package/dist/src/browser/errors.test.js +6 -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/media-download.js +15 -2
- package/dist/src/download/media-download.test.d.ts +1 -0
- package/dist/src/download/media-download.test.js +110 -0
- 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/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,357 @@
|
|
|
1
|
+
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
2
|
+
import { ArgumentError, AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
|
|
5
|
+
const LINKEDIN_DOMAIN = 'www.linkedin.com';
|
|
6
|
+
|
|
7
|
+
function normalizeWhitespace(value) {
|
|
8
|
+
return String(value ?? '').replace(/[\u00a0\u202f]/g, ' ').replace(/\s+/g, ' ').trim();
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function unwrapEvaluateResult(payload) {
|
|
12
|
+
if (payload && typeof payload === 'object' && 'data' in payload && 'session' in payload) return payload.data;
|
|
13
|
+
return payload;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function normalizeName(value) {
|
|
17
|
+
return normalizeWhitespace(value)
|
|
18
|
+
.replace(/\s*[•·]\s*(?:1st|2nd|3rd\+?|degree connection).*$/i, '')
|
|
19
|
+
.replace(/\s+LinkedIn.*$/i, '')
|
|
20
|
+
.toLowerCase();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function isLinkedInHost(hostname) {
|
|
24
|
+
const host = String(hostname || '').toLowerCase();
|
|
25
|
+
return host === 'linkedin.com' || host.endsWith('.linkedin.com');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function canonicalizeLinkedInThreadUrl(value) {
|
|
29
|
+
const raw = normalizeWhitespace(value);
|
|
30
|
+
if (!raw) return '';
|
|
31
|
+
try {
|
|
32
|
+
const url = new URL(raw);
|
|
33
|
+
if (url.protocol !== 'https:' || url.username || url.password || url.port || !isLinkedInHost(url.hostname)) return '';
|
|
34
|
+
const match = url.pathname.match(/^\/messaging\/thread\/([^/]+)\/?$/i);
|
|
35
|
+
if (!match || !match[1]) return '';
|
|
36
|
+
url.hostname = 'www.linkedin.com';
|
|
37
|
+
url.hash = '';
|
|
38
|
+
url.search = '';
|
|
39
|
+
if (!url.pathname.endsWith('/')) url.pathname += '/';
|
|
40
|
+
return url.toString();
|
|
41
|
+
} catch {
|
|
42
|
+
return '';
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function hashText(value) {
|
|
47
|
+
return createHash('sha256').update(normalizeWhitespace(value)).digest('hex');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function textContainsNormalized(haystack, needle) {
|
|
51
|
+
const h = normalizeWhitespace(haystack).toLowerCase();
|
|
52
|
+
const n = normalizeWhitespace(needle).toLowerCase();
|
|
53
|
+
return !n || h.includes(n);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function selectBestHeaderName(headerNames, expectedName) {
|
|
57
|
+
const expected = normalizeName(expectedName);
|
|
58
|
+
const names = (Array.isArray(headerNames) ? headerNames : [])
|
|
59
|
+
.map(normalizeWhitespace)
|
|
60
|
+
.filter(Boolean);
|
|
61
|
+
return names.find((name) => normalizeName(name) === expected) || names[0] || '';
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function assessThreadSafety(probe, expected) {
|
|
65
|
+
const expectedName = normalizeWhitespace(expected.expectedName);
|
|
66
|
+
const actualName = selectBestHeaderName(probe?.headerNames, expectedName);
|
|
67
|
+
const expectedThreadUrl = canonicalizeLinkedInThreadUrl(expected.threadUrl);
|
|
68
|
+
const actualThreadUrl = canonicalizeLinkedInThreadUrl(probe?.url || '');
|
|
69
|
+
const bodyText = String(probe?.bodyText || '');
|
|
70
|
+
|
|
71
|
+
if (probe?.authRequired) {
|
|
72
|
+
return { ok: false, blockReason: 'auth_required', expectedValue: expectedName, actualValue: actualName, observedUrl: actualThreadUrl };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (probe?.searchFailure || /we didn't find anything|no results found|no results for/i.test(bodyText)) {
|
|
76
|
+
return { ok: false, blockReason: 'search_failure_visible', expectedValue: expectedName, actualValue: actualName, observedUrl: actualThreadUrl };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (expectedThreadUrl && actualThreadUrl && expectedThreadUrl !== actualThreadUrl) {
|
|
80
|
+
return { ok: false, blockReason: 'thread_url_mismatch', expectedValue: expectedThreadUrl, actualValue: actualThreadUrl, observedUrl: actualThreadUrl };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (!actualName || normalizeName(actualName) !== normalizeName(expectedName)) {
|
|
84
|
+
return { ok: false, blockReason: 'recipient_header_mismatch', expectedValue: expectedName, actualValue: actualName, observedUrl: actualThreadUrl };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (!probe?.composerFound) {
|
|
88
|
+
return { ok: false, blockReason: 'composer_not_found', expectedValue: expectedName, actualValue: actualName, observedUrl: actualThreadUrl };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const expectedLastHash = normalizeWhitespace(expected.expectedLastHash);
|
|
92
|
+
if (expectedLastHash && expectedLastHash !== probe?.latestMessageHash) {
|
|
93
|
+
return { ok: false, blockReason: 'latest_message_mismatch', expectedValue: expectedLastHash, actualValue: probe?.latestMessageHash || '', observedUrl: actualThreadUrl };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const expectedLastText = normalizeWhitespace(expected.expectedLastText);
|
|
97
|
+
if (expectedLastText && !textContainsNormalized(bodyText, expectedLastText)) {
|
|
98
|
+
return { ok: false, blockReason: 'latest_message_mismatch', expectedValue: expectedLastText, actualValue: '', observedUrl: actualThreadUrl };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return { ok: true, blockReason: 'verified', expectedValue: expectedName, actualValue: actualName, observedUrl: actualThreadUrl };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function requireStringArg(args, key, label = key) {
|
|
105
|
+
const value = normalizeWhitespace(args[key]);
|
|
106
|
+
if (!value) throw new ArgumentError(`${label} is required`);
|
|
107
|
+
return value;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function requireLinkedInThreadUrl(value, label) {
|
|
111
|
+
const url = canonicalizeLinkedInThreadUrl(value);
|
|
112
|
+
if (!url) throw new ArgumentError(`${label} must be an exact https://www.linkedin.com/messaging/thread/<id>/ URL`);
|
|
113
|
+
return url;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function buildThreadProbeScript() {
|
|
117
|
+
return String.raw`(() => {
|
|
118
|
+
const marker = '__OPENCLI_LINKEDIN_PROBE__';
|
|
119
|
+
void marker;
|
|
120
|
+
const clean = (s) => String(s || '').replace(/[\u00a0\u202f]/g, ' ').replace(/\s+/g, ' ').trim();
|
|
121
|
+
const text = document.body ? (document.body.innerText || '') : '';
|
|
122
|
+
const lower = text.toLowerCase();
|
|
123
|
+
const authRequired = /\b(sign in|log in|join linkedin)\b/i.test(text)
|
|
124
|
+
|| /linkedin\.com\/(login|checkpoint|authwall)/i.test(location.href)
|
|
125
|
+
|| /captcha|verification required/i.test(text);
|
|
126
|
+
const searchFailure = /we didn't find anything|no results found|no results for/i.test(text);
|
|
127
|
+
|
|
128
|
+
const headerCandidates = [];
|
|
129
|
+
const selectors = [
|
|
130
|
+
'.msg-thread__link-to-profile',
|
|
131
|
+
'.msg-thread__link-to-profile span[aria-hidden="true"]',
|
|
132
|
+
'.msg-entity-lockup__entity-title',
|
|
133
|
+
'.msg-conversation-card__participant-names',
|
|
134
|
+
'main h1',
|
|
135
|
+
'main h2',
|
|
136
|
+
'[data-anonymize="person-name"]',
|
|
137
|
+
'a[href*="/in/"] span[aria-hidden="true"]',
|
|
138
|
+
'a[href*="/in/"]'
|
|
139
|
+
];
|
|
140
|
+
for (const selector of selectors) {
|
|
141
|
+
for (const el of Array.from(document.querySelectorAll(selector)).slice(0, 8)) {
|
|
142
|
+
const value = clean(el.innerText || el.textContent || el.getAttribute('aria-label'));
|
|
143
|
+
if (value && value.length <= 120 && !/^(message|messaging|send|profile|view profile)$/i.test(value)) {
|
|
144
|
+
headerCandidates.push(value);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const composer = Array.from(document.querySelectorAll('[contenteditable="true"][role="textbox"], div.msg-form__contenteditable[contenteditable="true"], [aria-label*="Write a message" i]'))
|
|
150
|
+
.find((el) => !el.closest('[aria-hidden="true"]') && el.offsetParent !== null);
|
|
151
|
+
|
|
152
|
+
const messageText = Array.from(document.querySelectorAll('.msg-s-message-list__event, .msg-s-event-listitem, [data-event-urn], .msg-s-message-group__meta, .msg-s-message-list-content'))
|
|
153
|
+
.map((el) => clean(el.innerText || el.textContent))
|
|
154
|
+
.filter(Boolean)
|
|
155
|
+
.join('\n');
|
|
156
|
+
const sourceText = messageText || text;
|
|
157
|
+
const sourceLines = sourceText.split(/\n+/).map(clean).filter(Boolean);
|
|
158
|
+
const lastMeaningfulLine = [...sourceLines].reverse().find((line) => !/^(send|reply|write a message|press enter to send)$/i.test(line)) || '';
|
|
159
|
+
|
|
160
|
+
return {
|
|
161
|
+
url: location.href,
|
|
162
|
+
title: document.title || '',
|
|
163
|
+
headerNames: Array.from(new Set(headerCandidates)).slice(0, 10),
|
|
164
|
+
bodyText: text,
|
|
165
|
+
composerFound: Boolean(composer),
|
|
166
|
+
composerText: composer ? clean(composer.innerText || composer.textContent) : '',
|
|
167
|
+
authRequired,
|
|
168
|
+
searchFailure,
|
|
169
|
+
latestMessageText: lastMeaningfulLine,
|
|
170
|
+
latestMessageHash: '',
|
|
171
|
+
};
|
|
172
|
+
})()`;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function buildFocusComposerScript() {
|
|
176
|
+
return String.raw`(() => {
|
|
177
|
+
const marker = '__OPENCLI_LINKEDIN_FOCUS_COMPOSER__';
|
|
178
|
+
void marker;
|
|
179
|
+
const clean = (s) => String(s || '').replace(/[\u00a0\u202f]/g, ' ').replace(/\s+/g, ' ').trim();
|
|
180
|
+
const composer = Array.from(document.querySelectorAll('[contenteditable="true"][role="textbox"], div.msg-form__contenteditable[contenteditable="true"], [aria-label*="Write a message" i]'))
|
|
181
|
+
.find((el) => !el.closest('[aria-hidden="true"]') && el.offsetParent !== null);
|
|
182
|
+
if (!composer) return { ok: false, error: 'composer_not_found', composerText: '' };
|
|
183
|
+
composer.focus();
|
|
184
|
+
composer.innerHTML = '';
|
|
185
|
+
composer.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'deleteContentBackward', data: null }));
|
|
186
|
+
return { ok: true, composerText: clean(composer.innerText || composer.textContent) };
|
|
187
|
+
})()`;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function buildReadComposerScript() {
|
|
191
|
+
return String.raw`(() => {
|
|
192
|
+
const marker = '__OPENCLI_LINKEDIN_READ_COMPOSER__';
|
|
193
|
+
void marker;
|
|
194
|
+
const clean = (s) => String(s || '').replace(/[\u00a0\u202f]/g, ' ').replace(/\s+/g, ' ').trim();
|
|
195
|
+
const composer = Array.from(document.querySelectorAll('[contenteditable="true"][role="textbox"], div.msg-form__contenteditable[contenteditable="true"], [aria-label*="Write a message" i]'))
|
|
196
|
+
.find((el) => !el.closest('[aria-hidden="true"]') && el.offsetParent !== null);
|
|
197
|
+
return { ok: Boolean(composer), composerText: composer ? clean(composer.innerText || composer.textContent) : '' };
|
|
198
|
+
})()`;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function buildClickSendScript() {
|
|
202
|
+
return String.raw`(() => {
|
|
203
|
+
const marker = '__OPENCLI_LINKEDIN_CLICK_SEND__';
|
|
204
|
+
void marker;
|
|
205
|
+
const buttons = Array.from(document.querySelectorAll('button'));
|
|
206
|
+
const send = buttons.find((button) => {
|
|
207
|
+
const text = (button.innerText || button.textContent || button.getAttribute('aria-label') || '').trim().toLowerCase();
|
|
208
|
+
return text === 'send' || text === 'send message';
|
|
209
|
+
});
|
|
210
|
+
if (!send) return { ok: false, error: 'send_button_not_found', sent: false };
|
|
211
|
+
if (send.disabled || send.getAttribute('aria-disabled') === 'true') return { ok: false, error: 'send_button_disabled', sent: false };
|
|
212
|
+
send.click();
|
|
213
|
+
return { ok: true, sent: true };
|
|
214
|
+
})()`;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async function probeThread(page) {
|
|
218
|
+
const result = unwrapEvaluateResult(await page.evaluate(buildThreadProbeScript()));
|
|
219
|
+
const latestText = normalizeWhitespace(result?.latestMessageText || '');
|
|
220
|
+
return {
|
|
221
|
+
...(result || {}),
|
|
222
|
+
latestMessageText: latestText,
|
|
223
|
+
latestMessageHash: latestText ? hashText(latestText) : '',
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
cli({
|
|
228
|
+
site: 'linkedin',
|
|
229
|
+
name: 'safe-send',
|
|
230
|
+
access: 'write',
|
|
231
|
+
description: 'Fail-closed LinkedIn message sender that verifies exact thread, recipient, and latest message before filling/sending',
|
|
232
|
+
domain: LINKEDIN_DOMAIN,
|
|
233
|
+
strategy: Strategy.UI,
|
|
234
|
+
browser: true,
|
|
235
|
+
args: [
|
|
236
|
+
{ name: 'thread-url', required: true, help: 'Exact LinkedIn messaging thread URL to open and verify' },
|
|
237
|
+
{ name: 'expected-name', required: true, help: 'Expected visible recipient name in the active thread header' },
|
|
238
|
+
{ name: 'message', required: true, help: 'Message body to send or dry-run' },
|
|
239
|
+
{ name: 'expected-last-text', help: 'Substring expected in the currently visible latest conversation context' },
|
|
240
|
+
{ name: 'expected-last-hash', help: 'SHA-256 hash of expected latest visible message text' },
|
|
241
|
+
{ name: 'send', type: 'bool', default: false, help: 'Actually click Send. Default is dry-run verification only.' },
|
|
242
|
+
{ name: 'screenshot', type: 'bool', default: false, help: 'Capture a screenshot during verification' },
|
|
243
|
+
],
|
|
244
|
+
columns: ['status', 'recipient', 'reason', 'thread_url', 'message_chars', 'screenshot'],
|
|
245
|
+
func: async (page, args) => {
|
|
246
|
+
if (!page) throw new CommandExecutionError('Browser session required for linkedin safe-send');
|
|
247
|
+
|
|
248
|
+
const threadUrl = requireLinkedInThreadUrl(requireStringArg(args, 'thread-url', '--thread-url'), '--thread-url');
|
|
249
|
+
const expectedName = requireStringArg(args, 'expected-name', '--expected-name');
|
|
250
|
+
const message = requireStringArg(args, 'message', '--message');
|
|
251
|
+
|
|
252
|
+
await page.goto('https://www.linkedin.com/messaging/');
|
|
253
|
+
await page.wait(4);
|
|
254
|
+
await page.goto(threadUrl);
|
|
255
|
+
// LinkedIn messaging often renders the shell first and hydrates the active
|
|
256
|
+
// thread header/messages a few seconds later. Wait long enough for the
|
|
257
|
+
// recipient header to appear so we fail closed on a real mismatch, not on
|
|
258
|
+
// a premature blank DOM snapshot.
|
|
259
|
+
await page.wait(12);
|
|
260
|
+
|
|
261
|
+
let beforeProbe = await probeThread(page);
|
|
262
|
+
const expectedLastText = normalizeWhitespace(args['expected-last-text']);
|
|
263
|
+
for (let attempt = 0; expectedLastText && attempt < 6 && !textContainsNormalized(beforeProbe.bodyText, expectedLastText); attempt += 1) {
|
|
264
|
+
await page.wait(2);
|
|
265
|
+
beforeProbe = await probeThread(page);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const safety = assessThreadSafety(beforeProbe, {
|
|
269
|
+
expectedName,
|
|
270
|
+
threadUrl,
|
|
271
|
+
expectedLastText: args['expected-last-text'],
|
|
272
|
+
expectedLastHash: args['expected-last-hash'],
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
if (safety.blockReason === 'auth_required') {
|
|
276
|
+
throw new AuthRequiredError(LINKEDIN_DOMAIN, 'LinkedIn safe-send requires an active signed-in LinkedIn browser session.');
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
if (!safety.ok) {
|
|
280
|
+
const observed = [
|
|
281
|
+
`Expected ${safety.expectedValue}; actual ${safety.actualValue || 'not_visible'} at ${safety.observedUrl || 'url_not_available'}`,
|
|
282
|
+
`Observed headers: ${(beforeProbe.headerNames || []).join(' | ') || 'no_visible_headers'}`,
|
|
283
|
+
`Title: ${beforeProbe.title || 'title_not_available'}`,
|
|
284
|
+
`Body: ${normalizeWhitespace(beforeProbe.bodyText || '').slice(0, 500)}`,
|
|
285
|
+
].join('\n');
|
|
286
|
+
throw new CommandExecutionError(
|
|
287
|
+
`LinkedIn safe-send blocked: ${safety.blockReason}`,
|
|
288
|
+
observed,
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
let screenshot = '';
|
|
293
|
+
if (args.screenshot && typeof page.screenshot === 'function') {
|
|
294
|
+
screenshot = await page.screenshot({ fullPage: false });
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
if (!args.send) {
|
|
298
|
+
return [{
|
|
299
|
+
status: 'verified_dry_run',
|
|
300
|
+
recipient: safety.actualValue,
|
|
301
|
+
reason: safety.blockReason,
|
|
302
|
+
thread_url: safety.observedUrl,
|
|
303
|
+
message_chars: message.length,
|
|
304
|
+
screenshot: screenshot ? 'captured' : '',
|
|
305
|
+
}];
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const focus = unwrapEvaluateResult(await page.evaluate(buildFocusComposerScript()));
|
|
309
|
+
if (!focus?.ok) throw new CommandExecutionError(`LinkedIn safe-send blocked: ${focus?.error || 'composer_focus_failed'}`);
|
|
310
|
+
|
|
311
|
+
await page.insertText(message);
|
|
312
|
+
await page.wait(0.6 + Math.random() * 0.8);
|
|
313
|
+
|
|
314
|
+
const composer = unwrapEvaluateResult(await page.evaluate(buildReadComposerScript()));
|
|
315
|
+
if (!composer?.ok || normalizeWhitespace(composer.composerText) !== normalizeWhitespace(message)) {
|
|
316
|
+
throw new CommandExecutionError(
|
|
317
|
+
'LinkedIn safe-send blocked: composer_text_mismatch',
|
|
318
|
+
`Composer text did not exactly match intended message for ${expectedName}.`,
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const afterFillProbe = await probeThread(page);
|
|
323
|
+
const afterFillSafety = assessThreadSafety(afterFillProbe, {
|
|
324
|
+
expectedName,
|
|
325
|
+
threadUrl,
|
|
326
|
+
expectedLastText: args['expected-last-text'],
|
|
327
|
+
expectedLastHash: args['expected-last-hash'],
|
|
328
|
+
});
|
|
329
|
+
if (!afterFillSafety.ok) {
|
|
330
|
+
throw new CommandExecutionError(`LinkedIn safe-send blocked after fill: ${afterFillSafety.blockReason}`);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const sent = unwrapEvaluateResult(await page.evaluate(buildClickSendScript()));
|
|
334
|
+
if (!sent?.ok || !sent.sent) {
|
|
335
|
+
throw new CommandExecutionError(`LinkedIn safe-send blocked: ${sent?.error || 'send_click_failed'}`);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
await page.wait(0.8 + Math.random() * 1.2);
|
|
339
|
+
return [{
|
|
340
|
+
status: 'sent',
|
|
341
|
+
recipient: safety.actualValue,
|
|
342
|
+
reason: safety.blockReason,
|
|
343
|
+
thread_url: safety.observedUrl,
|
|
344
|
+
message_chars: message.length,
|
|
345
|
+
screenshot: screenshot ? 'captured' : '',
|
|
346
|
+
}];
|
|
347
|
+
},
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
export const __test__ = {
|
|
351
|
+
normalizeWhitespace,
|
|
352
|
+
unwrapEvaluateResult,
|
|
353
|
+
normalizeName,
|
|
354
|
+
canonicalizeLinkedInThreadUrl,
|
|
355
|
+
hashText,
|
|
356
|
+
assessThreadSafety,
|
|
357
|
+
};
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { getRegistry } from '@jackwener/opencli/registry';
|
|
3
|
+
import { ArgumentError, CommandExecutionError } from '@jackwener/opencli/errors';
|
|
4
|
+
import './safe-send.js';
|
|
5
|
+
|
|
6
|
+
const {
|
|
7
|
+
normalizeWhitespace,
|
|
8
|
+
normalizeName,
|
|
9
|
+
canonicalizeLinkedInThreadUrl,
|
|
10
|
+
hashText,
|
|
11
|
+
assessThreadSafety,
|
|
12
|
+
} = await import('./safe-send.js').then((m) => m.__test__);
|
|
13
|
+
|
|
14
|
+
function makeFakePage(probe) {
|
|
15
|
+
let composerText = probe.composerText || '';
|
|
16
|
+
return {
|
|
17
|
+
goto: vi.fn(async () => undefined),
|
|
18
|
+
wait: vi.fn(async () => undefined),
|
|
19
|
+
evaluate: vi.fn(async (script) => {
|
|
20
|
+
const text = String(script);
|
|
21
|
+
if (text.includes('__OPENCLI_LINKEDIN_PROBE__')) return probe;
|
|
22
|
+
if (text.includes('__OPENCLI_LINKEDIN_FOCUS_COMPOSER__')) return { ok: true, composerText: '' };
|
|
23
|
+
if (text.includes('__OPENCLI_LINKEDIN_READ_COMPOSER__')) return { ok: true, composerText };
|
|
24
|
+
if (text.includes('__OPENCLI_LINKEDIN_CLICK_SEND__')) return { ok: true, sent: true };
|
|
25
|
+
return undefined;
|
|
26
|
+
}),
|
|
27
|
+
insertText: vi.fn(async (text) => {
|
|
28
|
+
composerText = text;
|
|
29
|
+
}),
|
|
30
|
+
pressKey: vi.fn(async () => undefined),
|
|
31
|
+
screenshot: vi.fn(async () => 'base64-screenshot'),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
describe('linkedin safe-send helpers', () => {
|
|
36
|
+
it('normalizes whitespace and LinkedIn names for exact-ish comparisons', () => {
|
|
37
|
+
expect(normalizeWhitespace(' Lokesh\n\tRamesh ')).toBe('Lokesh Ramesh');
|
|
38
|
+
expect(normalizeName('Lokesh Ramesh • 1st')).toBe('lokesh ramesh');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('canonicalizes thread URLs while dropping query and hash noise', () => {
|
|
42
|
+
expect(canonicalizeLinkedInThreadUrl('https://www.linkedin.com/messaging/thread/abc/?foo=1#bar'))
|
|
43
|
+
.toBe('https://www.linkedin.com/messaging/thread/abc/');
|
|
44
|
+
expect(canonicalizeLinkedInThreadUrl('https://www.linkedin.com/messaging/thread/abc/extra')).toBe('');
|
|
45
|
+
expect(canonicalizeLinkedInThreadUrl('https://evil-linkedin.com/messaging/thread/abc/')).toBe('');
|
|
46
|
+
expect(canonicalizeLinkedInThreadUrl('http://www.linkedin.com/messaging/thread/abc/')).toBe('');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('fails closed when LinkedIn search produced no results even if a composer is visible', () => {
|
|
50
|
+
const result = assessThreadSafety({
|
|
51
|
+
url: 'https://www.linkedin.com/messaging/thread/bora/',
|
|
52
|
+
headerNames: ['Bora Nicholson'],
|
|
53
|
+
bodyText: "We didn't find anything for Victoria Munoz\nBora Nicholson",
|
|
54
|
+
searchFailure: true,
|
|
55
|
+
composerFound: true,
|
|
56
|
+
latestMessageHash: hashText('hello'),
|
|
57
|
+
}, {
|
|
58
|
+
expectedName: 'Victoria Munoz',
|
|
59
|
+
threadUrl: 'https://www.linkedin.com/messaging/thread/victoria/',
|
|
60
|
+
expectedLastText: 'hello',
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
expect(result.ok).toBe(false);
|
|
64
|
+
expect(result.blockReason).toBe('search_failure_visible');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('fails closed on recipient header mismatch', () => {
|
|
68
|
+
const result = assessThreadSafety({
|
|
69
|
+
url: 'https://www.linkedin.com/messaging/thread/bora/',
|
|
70
|
+
headerNames: ['Bora Nicholson'],
|
|
71
|
+
bodyText: 'Bora Nicholson\nhello',
|
|
72
|
+
composerFound: true,
|
|
73
|
+
latestMessageHash: hashText('hello'),
|
|
74
|
+
}, {
|
|
75
|
+
expectedName: 'Victoria Munoz',
|
|
76
|
+
expectedLastText: 'hello',
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
expect(result.ok).toBe(false);
|
|
80
|
+
expect(result.blockReason).toBe('recipient_header_mismatch');
|
|
81
|
+
expect(result.actualValue).toBe('Bora Nicholson');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('fails closed when the stored latest message is no longer visible', () => {
|
|
85
|
+
const result = assessThreadSafety({
|
|
86
|
+
url: 'https://www.linkedin.com/messaging/thread/lokesh/',
|
|
87
|
+
headerNames: ['Lokesh Ramesh'],
|
|
88
|
+
bodyText: 'Lokesh Ramesh\na newer inbound arrived',
|
|
89
|
+
composerFound: true,
|
|
90
|
+
latestMessageHash: hashText('a newer inbound arrived'),
|
|
91
|
+
}, {
|
|
92
|
+
expectedName: 'Lokesh Ramesh',
|
|
93
|
+
expectedLastText: 'old inbound text',
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
expect(result.ok).toBe(false);
|
|
97
|
+
expect(result.blockReason).toBe('latest_message_mismatch');
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('passes only when recipient, thread, latest text, and composer are all verified', () => {
|
|
101
|
+
const result = assessThreadSafety({
|
|
102
|
+
url: 'https://www.linkedin.com/messaging/thread/lokesh/?mini=true',
|
|
103
|
+
headerNames: ['Lokesh Ramesh'],
|
|
104
|
+
bodyText: 'Lokesh Ramesh\nI think outside help would fit best for provider doc follow ups',
|
|
105
|
+
composerFound: true,
|
|
106
|
+
latestMessageHash: hashText('I think outside help would fit best for provider doc follow ups'),
|
|
107
|
+
}, {
|
|
108
|
+
expectedName: 'Lokesh Ramesh',
|
|
109
|
+
threadUrl: 'https://www.linkedin.com/messaging/thread/lokesh/',
|
|
110
|
+
expectedLastText: 'provider doc follow ups',
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
expect(result.ok).toBe(true);
|
|
114
|
+
expect(result.blockReason).toBe('verified');
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
describe('linkedin safe-send command', () => {
|
|
119
|
+
it('registers as a write command with safe output columns', () => {
|
|
120
|
+
const command = getRegistry().get('linkedin/safe-send');
|
|
121
|
+
expect(command).toBeDefined();
|
|
122
|
+
expect(command.access).toBe('write');
|
|
123
|
+
expect(command.columns).toEqual(expect.arrayContaining(['status', 'recipient', 'reason']));
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('does not type or send when verification fails', async () => {
|
|
127
|
+
const command = getRegistry().get('linkedin/safe-send');
|
|
128
|
+
const page = makeFakePage({
|
|
129
|
+
url: 'https://www.linkedin.com/messaging/thread/bora/',
|
|
130
|
+
headerNames: ['Bora Nicholson'],
|
|
131
|
+
bodyText: 'Bora Nicholson',
|
|
132
|
+
composerFound: true,
|
|
133
|
+
searchFailure: false,
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
await expect(command.func(page, {
|
|
137
|
+
'thread-url': 'https://www.linkedin.com/messaging/thread/victoria/',
|
|
138
|
+
'expected-name': 'Victoria Munoz',
|
|
139
|
+
message: 'hello victoria',
|
|
140
|
+
send: true,
|
|
141
|
+
})).rejects.toBeInstanceOf(CommandExecutionError);
|
|
142
|
+
|
|
143
|
+
expect(page.insertText).not.toHaveBeenCalled();
|
|
144
|
+
expect(page.pressKey).not.toHaveBeenCalled();
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('rejects non-thread URLs before navigating or typing', async () => {
|
|
148
|
+
const command = getRegistry().get('linkedin/safe-send');
|
|
149
|
+
const page = makeFakePage({});
|
|
150
|
+
|
|
151
|
+
await expect(command.func(page, {
|
|
152
|
+
'thread-url': 'https://www.linkedin.com/feed/',
|
|
153
|
+
'expected-name': 'Victoria Munoz',
|
|
154
|
+
message: 'hello victoria',
|
|
155
|
+
send: true,
|
|
156
|
+
})).rejects.toBeInstanceOf(ArgumentError);
|
|
157
|
+
|
|
158
|
+
expect(page.goto).not.toHaveBeenCalled();
|
|
159
|
+
expect(page.insertText).not.toHaveBeenCalled();
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('dry-runs by default after verification without filling or sending', async () => {
|
|
163
|
+
const command = getRegistry().get('linkedin/safe-send');
|
|
164
|
+
const page = makeFakePage({
|
|
165
|
+
url: 'https://www.linkedin.com/messaging/thread/lokesh/',
|
|
166
|
+
headerNames: ['Lokesh Ramesh'],
|
|
167
|
+
bodyText: 'Lokesh Ramesh\nprovider doc follow ups',
|
|
168
|
+
composerFound: true,
|
|
169
|
+
searchFailure: false,
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
const rows = await command.func(page, {
|
|
173
|
+
'thread-url': 'https://www.linkedin.com/messaging/thread/lokesh/',
|
|
174
|
+
'expected-name': 'Lokesh Ramesh',
|
|
175
|
+
message: 'both, but starting hands on',
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
expect(rows[0]).toMatchObject({ status: 'verified_dry_run', recipient: 'Lokesh Ramesh', reason: 'verified' });
|
|
179
|
+
expect(page.insertText).not.toHaveBeenCalled();
|
|
180
|
+
expect(page.pressKey).not.toHaveBeenCalled();
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('fills and sends only when --send is explicitly true and post-fill verification matches exactly', async () => {
|
|
184
|
+
const command = getRegistry().get('linkedin/safe-send');
|
|
185
|
+
const page = makeFakePage({
|
|
186
|
+
url: 'https://www.linkedin.com/messaging/thread/lokesh/',
|
|
187
|
+
headerNames: ['Lokesh Ramesh'],
|
|
188
|
+
bodyText: 'Lokesh Ramesh\nprovider doc follow ups',
|
|
189
|
+
composerFound: true,
|
|
190
|
+
searchFailure: false,
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
const rows = await command.func(page, {
|
|
194
|
+
'thread-url': 'https://www.linkedin.com/messaging/thread/lokesh/',
|
|
195
|
+
'expected-name': 'Lokesh Ramesh',
|
|
196
|
+
message: 'both, but starting hands on',
|
|
197
|
+
send: true,
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
expect(rows[0]).toMatchObject({ status: 'sent', recipient: 'Lokesh Ramesh', reason: 'verified' });
|
|
201
|
+
expect(page.insertText).toHaveBeenCalledWith('both, but starting hands on');
|
|
202
|
+
expect(page.pressKey).not.toHaveBeenCalled();
|
|
203
|
+
});
|
|
204
|
+
});
|