@jackwener/opencli 1.7.14 → 1.7.16
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 +9 -6
- package/README.zh-CN.md +9 -6
- package/cli-manifest.json +374 -74
- package/clis/bilibili/subtitle.js +1 -1
- package/clis/chatgpt/ask.js +2 -1
- package/clis/chatgpt/detail.js +6 -1
- package/clis/chatgpt/read.js +2 -1
- package/clis/chatgpt/send.js +2 -1
- package/clis/chatgpt/utils.js +54 -12
- package/clis/chatgpt/utils.test.js +36 -1
- package/clis/claude/ask.js +22 -7
- package/clis/claude/detail.js +9 -2
- package/clis/claude/new.js +8 -2
- package/clis/claude/read.js +2 -1
- package/clis/claude/send.js +8 -3
- package/clis/claude/utils.js +27 -4
- package/clis/deepseek/ask.js +21 -8
- package/clis/deepseek/detail.js +9 -1
- package/clis/deepseek/new.js +13 -2
- package/clis/deepseek/read.js +2 -1
- package/clis/deepseek/utils.js +8 -1
- package/clis/dianping/cityResolver.js +185 -0
- package/clis/dianping/dianping.test.js +154 -0
- package/clis/dianping/search.js +6 -3
- package/clis/douyin/_shared/browser-fetch.js +14 -2
- package/clis/douyin/_shared/browser-fetch.test.js +13 -0
- package/clis/douyin/stats.js +1 -1
- package/clis/douyin/update.js +1 -1
- package/clis/jike/search.js +1 -1
- package/clis/linkedin/search.js +8 -11
- package/clis/maimai/search-talents.js +10 -6
- package/clis/openreview/author.js +58 -0
- package/clis/openreview/openreview.test.js +83 -1
- package/clis/openreview/utils.js +14 -0
- package/clis/reddit/comment.js +1 -0
- package/clis/reddit/frontpage.js +1 -0
- package/clis/reddit/popular.js +1 -0
- package/clis/reddit/read.js +2 -0
- package/clis/reddit/read.test.js +4 -0
- package/clis/reddit/save.js +1 -0
- package/clis/reddit/saved.js +1 -0
- package/clis/reddit/search.js +2 -1
- package/clis/reddit/subreddit.js +2 -1
- package/clis/reddit/subscribe.js +1 -0
- package/clis/reddit/upvote.js +1 -0
- package/clis/reddit/upvoted.js +1 -0
- package/clis/reddit/user-comments.js +2 -1
- package/clis/reddit/user-posts.js +2 -1
- package/clis/reddit/user.js +2 -1
- package/clis/twitter/article.js +9 -5
- package/clis/twitter/bookmark-folder.js +187 -0
- package/clis/twitter/bookmark-folder.test.js +337 -0
- package/clis/twitter/bookmark-folders.js +115 -0
- package/clis/twitter/bookmark-folders.test.js +152 -0
- package/clis/twitter/bookmark.js +15 -6
- package/clis/twitter/bookmark.test.js +74 -0
- package/clis/twitter/bookmarks.js +10 -10
- package/clis/twitter/delete.js +11 -35
- package/clis/twitter/delete.test.js +21 -9
- package/clis/twitter/download.js +6 -5
- package/clis/twitter/followers.js +10 -3
- package/clis/twitter/following.js +14 -11
- package/clis/twitter/following.test.js +2 -1
- package/clis/twitter/hide-reply.js +24 -5
- package/clis/twitter/hide-reply.test.js +76 -0
- package/clis/twitter/like.js +21 -11
- package/clis/twitter/like.test.js +73 -0
- package/clis/twitter/likes.js +11 -11
- package/clis/twitter/list-add.js +8 -7
- package/clis/twitter/list-add.test.js +23 -1
- package/clis/twitter/list-remove.js +8 -7
- package/clis/twitter/list-remove.test.js +23 -1
- package/clis/twitter/list-tweets.js +9 -9
- package/clis/twitter/lists.js +6 -8
- package/clis/twitter/notifications.js +3 -2
- package/clis/twitter/profile.js +11 -7
- package/clis/twitter/quote.js +60 -32
- package/clis/twitter/quote.test.js +96 -8
- package/clis/twitter/reply.js +24 -178
- package/clis/twitter/reply.test.js +29 -11
- package/clis/twitter/retweet.js +9 -14
- package/clis/twitter/retweet.test.js +5 -1
- package/clis/twitter/search.js +176 -23
- package/clis/twitter/search.test.js +266 -1
- package/clis/twitter/shared.js +43 -0
- package/clis/twitter/shared.test.js +107 -1
- package/clis/twitter/thread.js +11 -11
- package/clis/twitter/timeline.js +13 -13
- package/clis/twitter/trending.js +4 -4
- package/clis/twitter/tweets.js +8 -9
- package/clis/twitter/unbookmark.js +13 -6
- package/clis/twitter/unbookmark.test.js +73 -0
- package/clis/twitter/unlike.js +6 -13
- package/clis/twitter/unlike.test.js +5 -2
- package/clis/twitter/unretweet.js +9 -14
- package/clis/twitter/unretweet.test.js +5 -1
- package/clis/twitter/utils.js +286 -0
- package/clis/twitter/utils.test.js +169 -0
- package/clis/youtube/like.js +6 -2
- package/clis/youtube/subscribe.js +6 -2
- package/clis/youtube/unlike.js +6 -2
- package/clis/youtube/unsubscribe.js +6 -2
- package/clis/youtube/utils.js +19 -13
- package/clis/youtube/utils.test.js +17 -1
- package/dist/src/browser/ax-snapshot.d.ts +37 -0
- package/dist/src/browser/ax-snapshot.js +217 -0
- package/dist/src/browser/ax-snapshot.test.d.ts +1 -0
- package/dist/src/browser/ax-snapshot.test.js +91 -0
- package/dist/src/browser/base-page.d.ts +51 -0
- package/dist/src/browser/base-page.js +545 -2
- package/dist/src/browser/base-page.test.js +520 -4
- package/dist/src/browser/bridge.d.ts +1 -0
- package/dist/src/browser/bridge.js +1 -1
- package/dist/src/browser/cdp-click-fixture.test.d.ts +1 -0
- package/dist/src/browser/cdp-click-fixture.test.js +87 -0
- package/dist/src/browser/cdp.d.ts +1 -0
- package/dist/src/browser/cdp.js +5 -0
- package/dist/src/browser/cdp.test.js +1 -0
- package/dist/src/browser/daemon-client.d.ts +5 -3
- package/dist/src/browser/daemon-client.js +6 -3
- package/dist/src/browser/daemon-client.test.js +10 -0
- package/dist/src/browser/find.d.ts +9 -1
- package/dist/src/browser/find.js +219 -0
- package/dist/src/browser/find.test.js +61 -1
- package/dist/src/browser/page.d.ts +4 -2
- package/dist/src/browser/page.js +18 -1
- package/dist/src/browser/page.test.js +28 -0
- package/dist/src/browser/target-errors.d.ts +3 -1
- package/dist/src/browser/target-errors.js +2 -0
- package/dist/src/browser/target-resolver.d.ts +14 -0
- package/dist/src/browser/target-resolver.js +28 -0
- package/dist/src/browser/visual-refs.d.ts +11 -0
- package/dist/src/browser/visual-refs.js +108 -0
- package/dist/src/build-manifest.d.ts +23 -0
- package/dist/src/build-manifest.js +34 -0
- package/dist/src/build-manifest.test.js +108 -1
- package/dist/src/cli.js +630 -60
- package/dist/src/cli.test.js +731 -1
- package/dist/src/commanderAdapter.js +7 -0
- package/dist/src/doctor.js +2 -2
- package/dist/src/doctor.test.js +4 -4
- package/dist/src/execution.d.ts +2 -0
- package/dist/src/execution.js +31 -6
- package/dist/src/execution.test.js +43 -16
- package/dist/src/external-clis.yaml +24 -0
- package/dist/src/help.d.ts +33 -0
- package/dist/src/help.js +174 -0
- package/dist/src/main.js +4 -14
- package/dist/src/runtime.d.ts +3 -0
- package/dist/src/runtime.js +1 -0
- package/dist/src/types.d.ts +83 -1
- package/package.json +1 -1
- package/scripts/typed-error-lint-baseline.json +18 -18
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import * as os from 'node:os';
|
|
3
|
+
import * as path from 'node:path';
|
|
4
|
+
import { ArgumentError } from '@jackwener/opencli/errors';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Public read-only Twitter web bearer token used by the GraphQL endpoints we
|
|
8
|
+
* call from the page context. This is the same token the Twitter web app
|
|
9
|
+
* itself uses; centralising it here keeps the 12+ GraphQL adapters from
|
|
10
|
+
* drifting when X rotates the value.
|
|
11
|
+
*/
|
|
12
|
+
export const TWITTER_BEARER_TOKEN = 'AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA';
|
|
13
|
+
|
|
14
|
+
/** File-input selector used by the X /compose/post route for both posts and replies. */
|
|
15
|
+
export const COMPOSER_FILE_INPUT_SELECTOR = 'input[type="file"][data-testid="fileInput"]';
|
|
16
|
+
|
|
17
|
+
/** Image formats the X composer accepts. */
|
|
18
|
+
export const SUPPORTED_IMAGE_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp']);
|
|
19
|
+
|
|
20
|
+
/** 20 MB hard cap. Twitter allows ~5MB images / 15MB GIFs; 20MB is a safety net. */
|
|
21
|
+
export const MAX_IMAGE_SIZE_BYTES = 20 * 1024 * 1024;
|
|
22
|
+
|
|
23
|
+
const CONTENT_TYPE_TO_EXTENSION = {
|
|
24
|
+
'image/jpeg': '.jpg',
|
|
25
|
+
'image/jpg': '.jpg',
|
|
26
|
+
'image/png': '.png',
|
|
27
|
+
'image/gif': '.gif',
|
|
28
|
+
'image/webp': '.webp',
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Validate a single image path. Throws {@link ArgumentError} on bad input
|
|
33
|
+
* (typed input failure surfaces before any browser interaction).
|
|
34
|
+
*
|
|
35
|
+
* @param {string} imagePath - Local filesystem path, may be relative.
|
|
36
|
+
* @returns {string} Absolute resolved path.
|
|
37
|
+
*/
|
|
38
|
+
export function resolveImagePath(imagePath) {
|
|
39
|
+
const absPath = path.resolve(imagePath);
|
|
40
|
+
if (!fs.existsSync(absPath)) {
|
|
41
|
+
throw new ArgumentError(`Image file not found: ${absPath}`);
|
|
42
|
+
}
|
|
43
|
+
const ext = path.extname(absPath).toLowerCase();
|
|
44
|
+
if (!SUPPORTED_IMAGE_EXTENSIONS.has(ext)) {
|
|
45
|
+
throw new ArgumentError(`Unsupported image format "${ext}". Supported: jpg, jpeg, png, gif, webp`);
|
|
46
|
+
}
|
|
47
|
+
const stat = fs.statSync(absPath);
|
|
48
|
+
if (stat.size > MAX_IMAGE_SIZE_BYTES) {
|
|
49
|
+
throw new ArgumentError(`Image too large: ${(stat.size / 1024 / 1024).toFixed(1)} MB (max ${MAX_IMAGE_SIZE_BYTES / 1024 / 1024} MB)`);
|
|
50
|
+
}
|
|
51
|
+
return absPath;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Resolve the file extension to use when persisting a remote image: prefer
|
|
56
|
+
* Content-Type, fall back to URL pathname.
|
|
57
|
+
*/
|
|
58
|
+
export function resolveImageExtension(url, contentType) {
|
|
59
|
+
const normalizedContentType = (contentType || '').split(';')[0].trim().toLowerCase();
|
|
60
|
+
if (normalizedContentType && CONTENT_TYPE_TO_EXTENSION[normalizedContentType]) {
|
|
61
|
+
return CONTENT_TYPE_TO_EXTENSION[normalizedContentType];
|
|
62
|
+
}
|
|
63
|
+
try {
|
|
64
|
+
const pathname = new URL(url).pathname;
|
|
65
|
+
const ext = path.extname(pathname).toLowerCase();
|
|
66
|
+
if (SUPPORTED_IMAGE_EXTENSIONS.has(ext))
|
|
67
|
+
return ext;
|
|
68
|
+
} catch {
|
|
69
|
+
// Fall through to the final error below.
|
|
70
|
+
}
|
|
71
|
+
throw new ArgumentError(
|
|
72
|
+
`Unsupported remote image format "${normalizedContentType || 'unknown'}". Supported: jpg, jpeg, png, gif, webp`,
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Download a remote image to a per-call tmp directory. Returns the absolute
|
|
78
|
+
* path on success. Caller owns the tmp dir and must clean it up. Throws
|
|
79
|
+
* {@link ArgumentError} on bad input or download failure.
|
|
80
|
+
*
|
|
81
|
+
* @returns {Promise<{ absPath: string, cleanupDir: string }>}
|
|
82
|
+
*/
|
|
83
|
+
export async function downloadRemoteImage(imageUrl) {
|
|
84
|
+
let parsed;
|
|
85
|
+
try {
|
|
86
|
+
parsed = new URL(imageUrl);
|
|
87
|
+
} catch {
|
|
88
|
+
throw new ArgumentError(`Invalid image URL: ${imageUrl}`);
|
|
89
|
+
}
|
|
90
|
+
if (!/^https?:$/.test(parsed.protocol)) {
|
|
91
|
+
throw new ArgumentError(`Unsupported image URL protocol: ${parsed.protocol}`);
|
|
92
|
+
}
|
|
93
|
+
const response = await fetch(imageUrl);
|
|
94
|
+
if (!response.ok) {
|
|
95
|
+
throw new ArgumentError(`Image download failed: HTTP ${response.status}`);
|
|
96
|
+
}
|
|
97
|
+
const contentLength = Number(response.headers.get('content-length') || '0');
|
|
98
|
+
if (contentLength > MAX_IMAGE_SIZE_BYTES) {
|
|
99
|
+
throw new ArgumentError(`Image too large: ${(contentLength / 1024 / 1024).toFixed(1)} MB (max ${MAX_IMAGE_SIZE_BYTES / 1024 / 1024} MB)`);
|
|
100
|
+
}
|
|
101
|
+
const ext = resolveImageExtension(imageUrl, response.headers.get('content-type'));
|
|
102
|
+
const cleanupDir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-twitter-'));
|
|
103
|
+
const absPath = path.join(cleanupDir, `image${ext}`);
|
|
104
|
+
const buffer = Buffer.from(await response.arrayBuffer());
|
|
105
|
+
if (buffer.byteLength > MAX_IMAGE_SIZE_BYTES) {
|
|
106
|
+
fs.rmSync(cleanupDir, { recursive: true, force: true });
|
|
107
|
+
throw new ArgumentError(`Image too large: ${(buffer.byteLength / 1024 / 1024).toFixed(1)} MB (max ${MAX_IMAGE_SIZE_BYTES / 1024 / 1024} MB)`);
|
|
108
|
+
}
|
|
109
|
+
fs.writeFileSync(absPath, buffer);
|
|
110
|
+
return { absPath, cleanupDir };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Attach a single image to the current /compose/post composer. Tries the
|
|
115
|
+
* native CDP file-input bridge first; falls back to a base64 DataTransfer
|
|
116
|
+
* shim if the bridge is missing or rejects with "Unknown action" /
|
|
117
|
+
* "not supported". Throws on hard failures.
|
|
118
|
+
*
|
|
119
|
+
* After upload it polls the DOM briefly to confirm the preview thumbnail
|
|
120
|
+
* actually rendered — without this, a 200 from setFileInput could mask a
|
|
121
|
+
* silent-no-attachment post.
|
|
122
|
+
*
|
|
123
|
+
* @param {object} page - OpenCLI page handle.
|
|
124
|
+
* @param {string} absImagePath - Already-validated absolute path.
|
|
125
|
+
* @param {string} [fileInputSelector] - Override (post.js historically used
|
|
126
|
+
* the same selector; default matches the X composer route).
|
|
127
|
+
*/
|
|
128
|
+
export async function attachComposerImage(page, absImagePath, fileInputSelector = COMPOSER_FILE_INPUT_SELECTOR) {
|
|
129
|
+
let uploaded = false;
|
|
130
|
+
if (page.setFileInput) {
|
|
131
|
+
try {
|
|
132
|
+
await page.setFileInput([absImagePath], fileInputSelector);
|
|
133
|
+
uploaded = true;
|
|
134
|
+
} catch (err) {
|
|
135
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
136
|
+
if (!msg.includes('Unknown action') && !msg.includes('not supported')) {
|
|
137
|
+
throw new Error(`Image upload failed: ${msg}`);
|
|
138
|
+
}
|
|
139
|
+
// setFileInput not supported by extension — fall through to base64 fallback.
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
if (!uploaded) {
|
|
143
|
+
const ext = path.extname(absImagePath).toLowerCase();
|
|
144
|
+
const mimeType = ext === '.png'
|
|
145
|
+
? 'image/png'
|
|
146
|
+
: ext === '.gif'
|
|
147
|
+
? 'image/gif'
|
|
148
|
+
: ext === '.webp'
|
|
149
|
+
? 'image/webp'
|
|
150
|
+
: 'image/jpeg';
|
|
151
|
+
const base64 = fs.readFileSync(absImagePath).toString('base64');
|
|
152
|
+
if (base64.length > 500_000) {
|
|
153
|
+
console.warn(`[warn] Image base64 payload is ${(base64.length / 1024 / 1024).toFixed(1)}MB. ` +
|
|
154
|
+
'This may fail with the browser bridge. Update the extension to v1.6+ for CDP-based upload, ' +
|
|
155
|
+
'or compress the image before attaching.');
|
|
156
|
+
}
|
|
157
|
+
const upload = await page.evaluate(`
|
|
158
|
+
(() => {
|
|
159
|
+
const input = document.querySelector(${JSON.stringify(fileInputSelector)});
|
|
160
|
+
if (!input) return { ok: false, error: 'No file input found on page' };
|
|
161
|
+
|
|
162
|
+
const binary = atob(${JSON.stringify(base64)});
|
|
163
|
+
const bytes = new Uint8Array(binary.length);
|
|
164
|
+
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
|
165
|
+
|
|
166
|
+
const dt = new DataTransfer();
|
|
167
|
+
const blob = new Blob([bytes], { type: ${JSON.stringify(mimeType)} });
|
|
168
|
+
dt.items.add(new File([blob], ${JSON.stringify(path.basename(absImagePath))}, { type: ${JSON.stringify(mimeType)} }));
|
|
169
|
+
|
|
170
|
+
Object.defineProperty(input, 'files', { value: dt.files, writable: false });
|
|
171
|
+
input.dispatchEvent(new Event('change', { bubbles: true }));
|
|
172
|
+
input.dispatchEvent(new Event('input', { bubbles: true }));
|
|
173
|
+
return { ok: true };
|
|
174
|
+
})()
|
|
175
|
+
`);
|
|
176
|
+
if (!upload?.ok) {
|
|
177
|
+
throw new Error(`Image upload failed: ${upload?.error ?? 'unknown error'}`);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
await page.wait(2);
|
|
181
|
+
const uploadState = await page.evaluate(`
|
|
182
|
+
(() => {
|
|
183
|
+
const previewCount = document.querySelectorAll(
|
|
184
|
+
'[data-testid="attachments"] img, [data-testid="attachments"] video, [data-testid="tweetPhoto"]'
|
|
185
|
+
).length;
|
|
186
|
+
const hasMedia = previewCount > 0
|
|
187
|
+
|| !!document.querySelector('[data-testid="attachments"]')
|
|
188
|
+
|| !!Array.from(document.querySelectorAll('button,[role="button"]')).find((el) =>
|
|
189
|
+
/remove media|remove image|remove/i.test((el.getAttribute('aria-label') || '') + ' ' + (el.textContent || ''))
|
|
190
|
+
);
|
|
191
|
+
return { ok: hasMedia, previewCount };
|
|
192
|
+
})()
|
|
193
|
+
`);
|
|
194
|
+
if (!uploadState?.ok) {
|
|
195
|
+
throw new Error('Image upload failed: preview did not appear.');
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// ── Engagement scoring (P3) ────────────────────────────────────────────
|
|
200
|
+
//
|
|
201
|
+
// Used by tweet-shaped read commands (search / timeline / likes / bookmarks /
|
|
202
|
+
// list-tweets / tweets / thread). Lets callers ask for the top-N tweets by
|
|
203
|
+
// weighted engagement instead of chronological order, so an agent skimming a
|
|
204
|
+
// noisy timeline can surface the actually-interesting tweets first.
|
|
205
|
+
//
|
|
206
|
+
// The weights bias toward "active engagement": bookmarks > retweets > replies
|
|
207
|
+
// > likes > views. Views are log-dampened because they often dwarf all other
|
|
208
|
+
// signals by 2–4 orders of magnitude on viral tweets and would otherwise
|
|
209
|
+
// drown out the active signals.
|
|
210
|
+
//
|
|
211
|
+
// Pure synchronous — exported via __test__ for unit coverage. Missing fields
|
|
212
|
+
// (some adapters don't surface views/replies/bookmarks) coerce to 0 so the
|
|
213
|
+
// formula stays well-defined across every read command's row shape.
|
|
214
|
+
|
|
215
|
+
const ENGAGEMENT_WEIGHTS = Object.freeze({
|
|
216
|
+
likes: 1,
|
|
217
|
+
retweets: 3,
|
|
218
|
+
replies: 2,
|
|
219
|
+
bookmarks: 5,
|
|
220
|
+
viewsLog: 0.5,
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Compute the weighted engagement score for a tweet-shaped row.
|
|
225
|
+
*
|
|
226
|
+
* Formula: likes×1 + retweets×3 + replies×2 + bookmarks×5 + log10(views+1)×0.5
|
|
227
|
+
*
|
|
228
|
+
* - String fields (e.g. views: '12345') are coerced via Number(); non-numeric
|
|
229
|
+
* strings become 0 instead of NaN-poisoning the score.
|
|
230
|
+
* - log10(views+1) so views=0 maps to 0 (not -Infinity).
|
|
231
|
+
* - Missing fields default to 0 — search returns no `replies`/`bookmarks`,
|
|
232
|
+
* bookmarks returns no `views`/`replies`, etc.
|
|
233
|
+
*
|
|
234
|
+
* @param {Record<string, unknown>} row
|
|
235
|
+
* @returns {number} Score, rounded to 2 decimals for stable test fixtures.
|
|
236
|
+
*/
|
|
237
|
+
export function computeEngagementScore(row) {
|
|
238
|
+
if (!row || typeof row !== 'object') return 0;
|
|
239
|
+
const num = (key) => {
|
|
240
|
+
const raw = row[key];
|
|
241
|
+
if (raw === undefined || raw === null) return 0;
|
|
242
|
+
const n = Number(raw);
|
|
243
|
+
return Number.isFinite(n) ? Math.max(0, n) : 0;
|
|
244
|
+
};
|
|
245
|
+
const score
|
|
246
|
+
= num('likes') * ENGAGEMENT_WEIGHTS.likes
|
|
247
|
+
+ num('retweets') * ENGAGEMENT_WEIGHTS.retweets
|
|
248
|
+
+ num('replies') * ENGAGEMENT_WEIGHTS.replies
|
|
249
|
+
+ num('bookmarks') * ENGAGEMENT_WEIGHTS.bookmarks
|
|
250
|
+
+ Math.log10(num('views') + 1) * ENGAGEMENT_WEIGHTS.viewsLog;
|
|
251
|
+
return Math.round(score * 100) / 100;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Apply --top-by-engagement post-processing. When `topN > 0` the rows are
|
|
256
|
+
* sorted DESCENDING by computeEngagementScore() and trimmed to the top N.
|
|
257
|
+
* When `topN <= 0` (the default), rows are returned unchanged so adapters
|
|
258
|
+
* that don't pass the flag stay backward compatible.
|
|
259
|
+
*
|
|
260
|
+
* Stable for ties: rows with the same score retain their original order
|
|
261
|
+
* (Array.prototype.sort is guaranteed stable in V8 since 2018).
|
|
262
|
+
*
|
|
263
|
+
* @param {Array<Record<string, unknown>>} rows
|
|
264
|
+
* @param {number} topN
|
|
265
|
+
* @returns {Array<Record<string, unknown>>}
|
|
266
|
+
*/
|
|
267
|
+
export function applyTopByEngagement(rows, topN) {
|
|
268
|
+
if (!Array.isArray(rows) || rows.length === 0) return rows;
|
|
269
|
+
const n = Number(topN);
|
|
270
|
+
if (!Number.isFinite(n) || n <= 0) return rows;
|
|
271
|
+
return rows
|
|
272
|
+
.map((row, idx) => ({ row, idx, score: computeEngagementScore(row) }))
|
|
273
|
+
.sort((a, b) => b.score - a.score || a.idx - b.idx)
|
|
274
|
+
.slice(0, Math.floor(n))
|
|
275
|
+
.map(entry => entry.row);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export const __test__ = {
|
|
279
|
+
resolveImagePath,
|
|
280
|
+
resolveImageExtension,
|
|
281
|
+
downloadRemoteImage,
|
|
282
|
+
attachComposerImage,
|
|
283
|
+
computeEngagementScore,
|
|
284
|
+
applyTopByEngagement,
|
|
285
|
+
ENGAGEMENT_WEIGHTS,
|
|
286
|
+
};
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { __test__ } from './utils.js';
|
|
3
|
+
|
|
4
|
+
const { computeEngagementScore, applyTopByEngagement, ENGAGEMENT_WEIGHTS } = __test__;
|
|
5
|
+
|
|
6
|
+
describe('computeEngagementScore', () => {
|
|
7
|
+
it('returns 0 for empty / nullish rows', () => {
|
|
8
|
+
expect(computeEngagementScore(null)).toBe(0);
|
|
9
|
+
expect(computeEngagementScore(undefined)).toBe(0);
|
|
10
|
+
expect(computeEngagementScore({})).toBe(0);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('weights likes ×1', () => {
|
|
14
|
+
expect(computeEngagementScore({ likes: 10 })).toBe(10);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('weights retweets ×3', () => {
|
|
18
|
+
expect(computeEngagementScore({ retweets: 5 })).toBe(15);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('weights replies ×2', () => {
|
|
22
|
+
expect(computeEngagementScore({ replies: 4 })).toBe(8);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('weights bookmarks ×5', () => {
|
|
26
|
+
expect(computeEngagementScore({ bookmarks: 6 })).toBe(30);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('log-dampens views (log10(v+1) × 0.5)', () => {
|
|
30
|
+
// log10(99+1) * 0.5 = 1.0
|
|
31
|
+
expect(computeEngagementScore({ views: 99 })).toBeCloseTo(1.0, 2);
|
|
32
|
+
// log10(0+1) * 0.5 = 0
|
|
33
|
+
expect(computeEngagementScore({ views: 0 })).toBe(0);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('coerces string-typed views (search/timeline returns views as a string)', () => {
|
|
37
|
+
// log10(9999+1) * 0.5 = 2.0
|
|
38
|
+
expect(computeEngagementScore({ views: '9999' })).toBeCloseTo(2.0, 2);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('treats non-numeric strings as 0 instead of NaN-poisoning the score', () => {
|
|
42
|
+
expect(computeEngagementScore({ likes: 'abc', retweets: 5 })).toBe(15);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('clamps negative values at 0 (defensive against bogus payloads)', () => {
|
|
46
|
+
expect(computeEngagementScore({ likes: -100, retweets: 2 })).toBe(6);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('combines all signals additively', () => {
|
|
50
|
+
// likes×1 + retweets×3 + replies×2 + bookmarks×5 + log10(views+1)×0.5
|
|
51
|
+
// 10 + 30 + 8 + 25 + log10(1000)*0.5 = 73 + 1.5 = 74.5
|
|
52
|
+
const row = { likes: 10, retweets: 10, replies: 4, bookmarks: 5, views: 999 };
|
|
53
|
+
expect(computeEngagementScore(row)).toBeCloseTo(74.5, 2);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('rounds to 2 decimal places for stable test fixtures', () => {
|
|
57
|
+
// log10(1+1) * 0.5 = 0.5 * log10(2) ≈ 0.150515
|
|
58
|
+
const score = computeEngagementScore({ views: 1 });
|
|
59
|
+
expect(score).toBe(0.15);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('handles real search-row shape (no replies/bookmarks columns)', () => {
|
|
63
|
+
const searchRow = {
|
|
64
|
+
id: '123',
|
|
65
|
+
author: 'alice',
|
|
66
|
+
text: 'hi',
|
|
67
|
+
likes: 100,
|
|
68
|
+
views: '9999',
|
|
69
|
+
};
|
|
70
|
+
// 100 + log10(10000)*0.5 = 100 + 2.0 = 102.0
|
|
71
|
+
expect(computeEngagementScore(searchRow)).toBeCloseTo(102.0, 2);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('handles real bookmarks-row shape (no views/replies columns)', () => {
|
|
75
|
+
const bookmarkRow = {
|
|
76
|
+
id: '123',
|
|
77
|
+
author: 'alice',
|
|
78
|
+
text: 'hi',
|
|
79
|
+
likes: 50,
|
|
80
|
+
retweets: 10,
|
|
81
|
+
bookmarks: 3,
|
|
82
|
+
};
|
|
83
|
+
// 50 + 30 + 15 = 95
|
|
84
|
+
expect(computeEngagementScore(bookmarkRow)).toBe(95);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('exposes the documented weight table', () => {
|
|
88
|
+
expect(ENGAGEMENT_WEIGHTS).toEqual({
|
|
89
|
+
likes: 1,
|
|
90
|
+
retweets: 3,
|
|
91
|
+
replies: 2,
|
|
92
|
+
bookmarks: 5,
|
|
93
|
+
viewsLog: 0.5,
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe('applyTopByEngagement', () => {
|
|
99
|
+
const rows = [
|
|
100
|
+
{ id: 'a', likes: 10 },
|
|
101
|
+
{ id: 'b', likes: 50 },
|
|
102
|
+
{ id: 'c', likes: 30 },
|
|
103
|
+
{ id: 'd', likes: 100 },
|
|
104
|
+
{ id: 'e', likes: 5 },
|
|
105
|
+
];
|
|
106
|
+
|
|
107
|
+
it('returns rows unchanged when topN is 0 (default)', () => {
|
|
108
|
+
expect(applyTopByEngagement(rows, 0)).toBe(rows);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('returns rows unchanged when topN is negative', () => {
|
|
112
|
+
expect(applyTopByEngagement(rows, -3)).toBe(rows);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('returns rows unchanged when topN is non-numeric', () => {
|
|
116
|
+
expect(applyTopByEngagement(rows, 'foo')).toBe(rows);
|
|
117
|
+
expect(applyTopByEngagement(rows, null)).toBe(rows);
|
|
118
|
+
expect(applyTopByEngagement(rows, undefined)).toBe(rows);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('sorts descending by score and trims to top N when topN > 0', () => {
|
|
122
|
+
const result = applyTopByEngagement(rows, 3);
|
|
123
|
+
expect(result.map(r => r.id)).toEqual(['d', 'b', 'c']);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('returns all rows when topN exceeds row count', () => {
|
|
127
|
+
const result = applyTopByEngagement(rows, 99);
|
|
128
|
+
expect(result.map(r => r.id)).toEqual(['d', 'b', 'c', 'a', 'e']);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('floors fractional topN', () => {
|
|
132
|
+
const result = applyTopByEngagement(rows, 2.9);
|
|
133
|
+
expect(result.map(r => r.id)).toEqual(['d', 'b']);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('is stable for ties (preserves original order)', () => {
|
|
137
|
+
const tieRows = [
|
|
138
|
+
{ id: 'first', likes: 10 },
|
|
139
|
+
{ id: 'second', likes: 10 },
|
|
140
|
+
{ id: 'third', likes: 10 },
|
|
141
|
+
{ id: 'fourth', likes: 100 },
|
|
142
|
+
];
|
|
143
|
+
const result = applyTopByEngagement(tieRows, 4);
|
|
144
|
+
// 'fourth' first, then ties retain original order
|
|
145
|
+
expect(result.map(r => r.id)).toEqual(['fourth', 'first', 'second', 'third']);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('handles empty / non-array input gracefully', () => {
|
|
149
|
+
expect(applyTopByEngagement([], 5)).toEqual([]);
|
|
150
|
+
expect(applyTopByEngagement(null, 5)).toBeNull();
|
|
151
|
+
expect(applyTopByEngagement(undefined, 5)).toBeUndefined();
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('does not mutate the input array', () => {
|
|
155
|
+
const before = [...rows];
|
|
156
|
+
applyTopByEngagement(rows, 2);
|
|
157
|
+
expect(rows).toEqual(before);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('mixes signals correctly when ranking', () => {
|
|
161
|
+
// bookmark-heavy row should beat like-heavy row even if likes are higher
|
|
162
|
+
const mixed = [
|
|
163
|
+
{ id: 'likes-only', likes: 100 }, // score = 100
|
|
164
|
+
{ id: 'bookmark-heavy', likes: 30, bookmarks: 20 }, // score = 30 + 100 = 130
|
|
165
|
+
];
|
|
166
|
+
const result = applyTopByEngagement(mixed, 2);
|
|
167
|
+
expect(result.map(r => r.id)).toEqual(['bookmark-heavy', 'likes-only']);
|
|
168
|
+
});
|
|
169
|
+
});
|
package/clis/youtube/like.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* YouTube like — like a video via InnerTube like API (requires SAPISIDHASH auth).
|
|
3
3
|
*/
|
|
4
4
|
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
5
|
-
import { parseVideoId, prepareYoutubeApiPage, SAPISID_HASH_FN } from './utils.js';
|
|
5
|
+
import { parseVideoId, prepareYoutubeApiPage, readYoutubeSapisid, SAPISID_HASH_FN } from './utils.js';
|
|
6
6
|
import { CommandExecutionError, AuthRequiredError } from '@jackwener/opencli/errors';
|
|
7
7
|
|
|
8
8
|
cli({
|
|
@@ -19,6 +19,10 @@ cli({
|
|
|
19
19
|
func: async (page, kwargs) => {
|
|
20
20
|
const videoId = parseVideoId(String(kwargs.url));
|
|
21
21
|
await prepareYoutubeApiPage(page);
|
|
22
|
+
// Read SAPISID directly from the cookie store via CDP — zero document.cookie round-trip
|
|
23
|
+
const sapisid = await readYoutubeSapisid(page);
|
|
24
|
+
if (!sapisid)
|
|
25
|
+
throw new AuthRequiredError('www.youtube.com', 'Not logged in (SAPISID cookie missing)');
|
|
22
26
|
const result = await page.evaluate(`
|
|
23
27
|
(async () => {
|
|
24
28
|
${SAPISID_HASH_FN}
|
|
@@ -28,7 +32,7 @@ cli({
|
|
|
28
32
|
const context = cfg.INNERTUBE_CONTEXT;
|
|
29
33
|
if (!apiKey || !context) return { error: 'config', message: 'YouTube config not found' };
|
|
30
34
|
|
|
31
|
-
const authHash = await getSapisidHash('https://www.youtube.com');
|
|
35
|
+
const authHash = await getSapisidHash(${JSON.stringify(sapisid)}, 'https://www.youtube.com');
|
|
32
36
|
if (!authHash) return { error: 'auth', message: 'Not logged in (SAPISID cookie missing)' };
|
|
33
37
|
|
|
34
38
|
const resp = await fetch('/youtubei/v1/like/like?key=' + apiKey + '&prettyPrint=false', {
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* YouTube subscribe — subscribe to a channel via InnerTube subscription API.
|
|
3
3
|
*/
|
|
4
4
|
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
5
|
-
import { prepareYoutubeApiPage, SAPISID_HASH_FN, RESOLVE_CHANNEL_HANDLE_FN } from './utils.js';
|
|
5
|
+
import { prepareYoutubeApiPage, readYoutubeSapisid, SAPISID_HASH_FN, RESOLVE_CHANNEL_HANDLE_FN } from './utils.js';
|
|
6
6
|
import { CommandExecutionError, AuthRequiredError } from '@jackwener/opencli/errors';
|
|
7
7
|
|
|
8
8
|
cli({
|
|
@@ -19,6 +19,10 @@ cli({
|
|
|
19
19
|
func: async (page, kwargs) => {
|
|
20
20
|
const channelInput = String(kwargs.channel);
|
|
21
21
|
await prepareYoutubeApiPage(page);
|
|
22
|
+
// Read SAPISID directly from the cookie store via CDP — zero document.cookie round-trip
|
|
23
|
+
const sapisid = await readYoutubeSapisid(page);
|
|
24
|
+
if (!sapisid)
|
|
25
|
+
throw new AuthRequiredError('www.youtube.com', 'Not logged in (SAPISID cookie missing)');
|
|
22
26
|
const result = await page.evaluate(`
|
|
23
27
|
(async () => {
|
|
24
28
|
${SAPISID_HASH_FN}
|
|
@@ -28,7 +32,7 @@ cli({
|
|
|
28
32
|
const context = cfg.INNERTUBE_CONTEXT;
|
|
29
33
|
if (!apiKey || !context) return { error: 'config', message: 'YouTube config not found' };
|
|
30
34
|
|
|
31
|
-
const authHash = await getSapisidHash('https://www.youtube.com');
|
|
35
|
+
const authHash = await getSapisidHash(${JSON.stringify(sapisid)}, 'https://www.youtube.com');
|
|
32
36
|
if (!authHash) return { error: 'auth', message: 'Not logged in (SAPISID cookie missing)' };
|
|
33
37
|
|
|
34
38
|
${RESOLVE_CHANNEL_HANDLE_FN}
|
package/clis/youtube/unlike.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* YouTube unlike — remove like from a video via InnerTube like API.
|
|
3
3
|
*/
|
|
4
4
|
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
5
|
-
import { parseVideoId, prepareYoutubeApiPage, SAPISID_HASH_FN } from './utils.js';
|
|
5
|
+
import { parseVideoId, prepareYoutubeApiPage, readYoutubeSapisid, SAPISID_HASH_FN } from './utils.js';
|
|
6
6
|
import { CommandExecutionError, AuthRequiredError } from '@jackwener/opencli/errors';
|
|
7
7
|
|
|
8
8
|
cli({
|
|
@@ -19,6 +19,10 @@ cli({
|
|
|
19
19
|
func: async (page, kwargs) => {
|
|
20
20
|
const videoId = parseVideoId(String(kwargs.url));
|
|
21
21
|
await prepareYoutubeApiPage(page);
|
|
22
|
+
// Read SAPISID directly from the cookie store via CDP — zero document.cookie round-trip
|
|
23
|
+
const sapisid = await readYoutubeSapisid(page);
|
|
24
|
+
if (!sapisid)
|
|
25
|
+
throw new AuthRequiredError('www.youtube.com', 'Not logged in (SAPISID cookie missing)');
|
|
22
26
|
const result = await page.evaluate(`
|
|
23
27
|
(async () => {
|
|
24
28
|
${SAPISID_HASH_FN}
|
|
@@ -28,7 +32,7 @@ cli({
|
|
|
28
32
|
const context = cfg.INNERTUBE_CONTEXT;
|
|
29
33
|
if (!apiKey || !context) return { error: 'config', message: 'YouTube config not found' };
|
|
30
34
|
|
|
31
|
-
const authHash = await getSapisidHash('https://www.youtube.com');
|
|
35
|
+
const authHash = await getSapisidHash(${JSON.stringify(sapisid)}, 'https://www.youtube.com');
|
|
32
36
|
if (!authHash) return { error: 'auth', message: 'Not logged in (SAPISID cookie missing)' };
|
|
33
37
|
|
|
34
38
|
const resp = await fetch('/youtubei/v1/like/removelike?key=' + apiKey + '&prettyPrint=false', {
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* YouTube unsubscribe — unsubscribe from a channel via InnerTube subscription API.
|
|
3
3
|
*/
|
|
4
4
|
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
5
|
-
import { prepareYoutubeApiPage, SAPISID_HASH_FN, RESOLVE_CHANNEL_HANDLE_FN } from './utils.js';
|
|
5
|
+
import { prepareYoutubeApiPage, readYoutubeSapisid, SAPISID_HASH_FN, RESOLVE_CHANNEL_HANDLE_FN } from './utils.js';
|
|
6
6
|
import { CommandExecutionError, AuthRequiredError } from '@jackwener/opencli/errors';
|
|
7
7
|
|
|
8
8
|
cli({
|
|
@@ -19,6 +19,10 @@ cli({
|
|
|
19
19
|
func: async (page, kwargs) => {
|
|
20
20
|
const channelInput = String(kwargs.channel);
|
|
21
21
|
await prepareYoutubeApiPage(page);
|
|
22
|
+
// Read SAPISID directly from the cookie store via CDP — zero document.cookie round-trip
|
|
23
|
+
const sapisid = await readYoutubeSapisid(page);
|
|
24
|
+
if (!sapisid)
|
|
25
|
+
throw new AuthRequiredError('www.youtube.com', 'Not logged in (SAPISID cookie missing)');
|
|
22
26
|
const result = await page.evaluate(`
|
|
23
27
|
(async () => {
|
|
24
28
|
${SAPISID_HASH_FN}
|
|
@@ -28,7 +32,7 @@ cli({
|
|
|
28
32
|
const context = cfg.INNERTUBE_CONTEXT;
|
|
29
33
|
if (!apiKey || !context) return { error: 'config', message: 'YouTube config not found' };
|
|
30
34
|
|
|
31
|
-
const authHash = await getSapisidHash('https://www.youtube.com');
|
|
35
|
+
const authHash = await getSapisidHash(${JSON.stringify(sapisid)}, 'https://www.youtube.com');
|
|
32
36
|
if (!authHash) return { error: 'auth', message: 'Not logged in (SAPISID cookie missing)' };
|
|
33
37
|
|
|
34
38
|
${RESOLVE_CHANNEL_HANDLE_FN}
|
package/clis/youtube/utils.js
CHANGED
|
@@ -189,21 +189,13 @@ async function resolveChannelHandle(input, apiKey, context) {
|
|
|
189
189
|
* Inline SAPISIDHASH helper for use inside page.evaluate() strings.
|
|
190
190
|
* YouTube write APIs (like, subscribe) require:
|
|
191
191
|
* Authorization: SAPISIDHASH {time}_{SHA1(time + " " + SAPISID + " " + origin)}
|
|
192
|
+
*
|
|
193
|
+
* The SAPISID cookie value must be hoisted from the cookie store on the Node side
|
|
194
|
+
* (via `readYoutubeSapisid(page)`) and passed in here — keeps `crypto.subtle.digest`
|
|
195
|
+
* (browser Web Crypto) call site, but no `document.cookie` round-trip.
|
|
192
196
|
*/
|
|
193
197
|
export const SAPISID_HASH_FN = `
|
|
194
|
-
async function getSapisidHash(origin) {
|
|
195
|
-
const cookies = document.cookie.split('; ');
|
|
196
|
-
let sapisid = '';
|
|
197
|
-
for (const c of cookies) {
|
|
198
|
-
const eq = c.indexOf('=');
|
|
199
|
-
if (eq === -1) continue;
|
|
200
|
-
const name = c.slice(0, eq);
|
|
201
|
-
const val = c.slice(eq + 1);
|
|
202
|
-
if (name === '__Secure-3PAPISID' || name === 'SAPISID') {
|
|
203
|
-
sapisid = val;
|
|
204
|
-
if (name === '__Secure-3PAPISID') break;
|
|
205
|
-
}
|
|
206
|
-
}
|
|
198
|
+
async function getSapisidHash(sapisid, origin) {
|
|
207
199
|
if (!sapisid) return null;
|
|
208
200
|
const time = Math.floor(Date.now() / 1000);
|
|
209
201
|
const msgBuffer = new TextEncoder().encode(time + ' ' + sapisid + ' ' + origin);
|
|
@@ -212,3 +204,17 @@ async function getSapisidHash(origin) {
|
|
|
212
204
|
return 'SAPISIDHASH ' + time + '_' + hashHex;
|
|
213
205
|
}
|
|
214
206
|
`;
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Read the YouTube SAPISID cookie via CDP, preferring `__Secure-3PAPISID`
|
|
210
|
+
* (current first-party cookie) and falling back to the legacy `SAPISID` name.
|
|
211
|
+
* Returns the cookie value, or null if neither is present.
|
|
212
|
+
*/
|
|
213
|
+
export async function readYoutubeSapisid(page) {
|
|
214
|
+
const cookies = await page.getCookies({ url: 'https://www.youtube.com' });
|
|
215
|
+
return (
|
|
216
|
+
cookies.find((c) => c.name === '__Secure-3PAPISID')?.value
|
|
217
|
+
|| cookies.find((c) => c.name === 'SAPISID')?.value
|
|
218
|
+
|| null
|
|
219
|
+
);
|
|
220
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
-
import { extractJsonAssignmentFromHtml, extractSubscriptionChannel, prepareYoutubeApiPage } from './utils.js';
|
|
2
|
+
import { extractJsonAssignmentFromHtml, extractSubscriptionChannel, prepareYoutubeApiPage, readYoutubeSapisid } from './utils.js';
|
|
3
3
|
describe('youtube utils', () => {
|
|
4
4
|
it('extractJsonAssignmentFromHtml parses bootstrap objects with nested braces in strings', () => {
|
|
5
5
|
const html = `
|
|
@@ -34,6 +34,22 @@ describe('youtube utils', () => {
|
|
|
34
34
|
expect(page.goto).toHaveBeenCalledWith('https://www.youtube.com', { waitUntil: 'none' });
|
|
35
35
|
expect(page.wait).toHaveBeenCalledWith(2);
|
|
36
36
|
});
|
|
37
|
+
it('readYoutubeSapisid reads URL-scoped cookies and prefers secure SAPISID', async () => {
|
|
38
|
+
const page = {
|
|
39
|
+
getCookies: vi.fn().mockResolvedValue([
|
|
40
|
+
{ name: 'SAPISID', value: 'legacy' },
|
|
41
|
+
{ name: '__Secure-3PAPISID', value: 'secure' },
|
|
42
|
+
]),
|
|
43
|
+
};
|
|
44
|
+
await expect(readYoutubeSapisid(page)).resolves.toBe('secure');
|
|
45
|
+
expect(page.getCookies).toHaveBeenCalledWith({ url: 'https://www.youtube.com' });
|
|
46
|
+
});
|
|
47
|
+
it('readYoutubeSapisid falls back to legacy SAPISID', async () => {
|
|
48
|
+
const page = {
|
|
49
|
+
getCookies: vi.fn().mockResolvedValue([{ name: 'SAPISID', value: 'legacy' }]),
|
|
50
|
+
};
|
|
51
|
+
await expect(readYoutubeSapisid(page)).resolves.toBe('legacy');
|
|
52
|
+
});
|
|
37
53
|
it('extractSubscriptionChannel prefers explicit handle and subscriber count fields', () => {
|
|
38
54
|
expect(extractSubscriptionChannel({
|
|
39
55
|
title: { simpleText: 'OpenAI' },
|