@jackwener/opencli 1.7.15 → 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 +161 -31
- 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/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 +1 -0
- package/clis/reddit/subreddit.js +1 -0
- 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 +1 -0
- package/clis/reddit/user-posts.js +1 -0
- package/clis/reddit/user.js +1 -0
- package/clis/twitter/article.js +7 -4
- package/clis/twitter/bookmark-folder.js +3 -5
- package/clis/twitter/bookmark-folder.test.js +5 -2
- package/clis/twitter/bookmark-folders.js +3 -5
- package/clis/twitter/bookmark-folders.test.js +3 -1
- package/clis/twitter/bookmarks.js +3 -5
- package/clis/twitter/download.js +1 -0
- package/clis/twitter/followers.js +1 -0
- package/clis/twitter/following.js +3 -6
- package/clis/twitter/following.test.js +2 -1
- package/clis/twitter/likes.js +3 -5
- package/clis/twitter/list-add.js +4 -3
- package/clis/twitter/list-add.test.js +23 -1
- package/clis/twitter/list-remove.js +4 -3
- package/clis/twitter/list-remove.test.js +23 -1
- package/clis/twitter/list-tweets.js +3 -5
- package/clis/twitter/lists.js +3 -5
- package/clis/twitter/notifications.js +1 -0
- package/clis/twitter/profile.js +7 -4
- package/clis/twitter/search.js +1 -0
- package/clis/twitter/thread.js +5 -7
- package/clis/twitter/timeline.js +5 -7
- package/clis/twitter/trending.js +4 -4
- package/clis/twitter/tweets.js +3 -6
- 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/bridge.d.ts +1 -0
- package/dist/src/browser/bridge.js +1 -1
- package/dist/src/browser/cdp.d.ts +1 -0
- package/dist/src/browser/daemon-client.d.ts +2 -2
- package/dist/src/browser/daemon-client.js +6 -3
- package/dist/src/browser/daemon-client.test.js +10 -0
- package/dist/src/browser/page.d.ts +2 -1
- package/dist/src/browser/page.js +5 -1
- package/dist/src/cli.js +70 -2
- package/dist/src/cli.test.js +139 -7
- 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 +1 -0
- package/dist/src/help.js +29 -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 +1 -1
- package/package.json +1 -1
|
@@ -92,11 +92,12 @@ cli({
|
|
|
92
92
|
}
|
|
93
93
|
if (!username) throw new CommandExecutionError('Username is required');
|
|
94
94
|
|
|
95
|
+
// Strategy.UI does not get a domain URL pre-nav from the framework.
|
|
96
|
+
// This page context is load-bearing for pre-target GraphQL calls below.
|
|
95
97
|
await page.goto('https://x.com');
|
|
96
98
|
await page.wait(3);
|
|
97
|
-
const
|
|
98
|
-
|
|
99
|
-
}`);
|
|
99
|
+
const cookies = await page.getCookies({ url: 'https://x.com' });
|
|
100
|
+
const ct0 = cookies.find((c) => c.name === 'ct0')?.value || null;
|
|
100
101
|
if (!ct0) throw new AuthRequiredError('x.com', 'Not logged into x.com (no ct0 cookie)');
|
|
101
102
|
|
|
102
103
|
const userByScreenNameQueryId = await resolveTwitterQueryId(page, 'UserByScreenName', USER_BY_SCREEN_NAME_QUERY_ID);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, expect, it } from 'vitest';
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
2
|
import { getRegistry } from '@jackwener/opencli/registry';
|
|
3
3
|
import './list-remove.js';
|
|
4
4
|
|
|
@@ -11,4 +11,26 @@ describe('twitter list-remove registration', () => {
|
|
|
11
11
|
expect(listIdArg).toBeTruthy();
|
|
12
12
|
expect(listIdArg?.required).toBe(true);
|
|
13
13
|
});
|
|
14
|
+
|
|
15
|
+
it('keeps the x.com root navigation before pre-target GraphQL calls', async () => {
|
|
16
|
+
const cmd = getRegistry().get('twitter/list-remove');
|
|
17
|
+
const page = {
|
|
18
|
+
goto: vi.fn().mockResolvedValue(undefined),
|
|
19
|
+
wait: vi.fn().mockResolvedValue(undefined),
|
|
20
|
+
getCookies: vi.fn().mockResolvedValue([{ name: 'ct0', value: 'token' }]),
|
|
21
|
+
evaluate: vi.fn()
|
|
22
|
+
.mockResolvedValueOnce(null) // UserByScreenName queryId fallback
|
|
23
|
+
.mockResolvedValueOnce('user-1')
|
|
24
|
+
.mockResolvedValueOnce(null) // ListsManagement queryId fallback
|
|
25
|
+
.mockResolvedValueOnce({}),
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
await expect(cmd.func(page, { listId: '123', username: 'alice' }))
|
|
29
|
+
.rejects
|
|
30
|
+
.toThrow(/List 123 not found/);
|
|
31
|
+
expect(page.goto).toHaveBeenCalledWith('https://x.com');
|
|
32
|
+
expect(page.goto).toHaveBeenCalledTimes(1);
|
|
33
|
+
expect(page.wait).toHaveBeenCalledWith(3);
|
|
34
|
+
expect(page.getCookies).toHaveBeenCalledWith({ url: 'https://x.com' });
|
|
35
|
+
});
|
|
14
36
|
});
|
|
@@ -112,6 +112,7 @@ cli({
|
|
|
112
112
|
domain: 'x.com',
|
|
113
113
|
strategy: Strategy.COOKIE,
|
|
114
114
|
browser: true,
|
|
115
|
+
browserSession: { reuse: 'site' },
|
|
115
116
|
args: [
|
|
116
117
|
{ name: 'listId', positional: true, type: 'string', required: true, help: 'Numeric ID of a Twitter/X list (e.g. from `opencli twitter lists`)' },
|
|
117
118
|
{ name: 'limit', type: 'int', default: 50 },
|
|
@@ -124,11 +125,8 @@ cli({
|
|
|
124
125
|
throw new CommandExecutionError(`Invalid listId: ${JSON.stringify(kwargs.listId)}. Expected a numeric ID (see \`opencli twitter lists\`).`);
|
|
125
126
|
}
|
|
126
127
|
const limit = kwargs.limit || 50;
|
|
127
|
-
await page.
|
|
128
|
-
|
|
129
|
-
const ct0 = await page.evaluate(`() => {
|
|
130
|
-
return document.cookie.split(';').map(c => c.trim()).find(c => c.startsWith('ct0='))?.split('=')[1] || null;
|
|
131
|
-
}`);
|
|
128
|
+
const cookies = await page.getCookies({ url: 'https://x.com' });
|
|
129
|
+
const ct0 = cookies.find((c) => c.name === 'ct0')?.value || null;
|
|
132
130
|
if (!ct0)
|
|
133
131
|
throw new AuthRequiredError('x.com', 'Not logged into x.com (no ct0 cookie)');
|
|
134
132
|
const queryId = await page.evaluate(`async () => {
|
package/clis/twitter/lists.js
CHANGED
|
@@ -92,17 +92,15 @@ export const command = cli({
|
|
|
92
92
|
domain: 'x.com',
|
|
93
93
|
strategy: Strategy.COOKIE,
|
|
94
94
|
browser: true,
|
|
95
|
+
browserSession: { reuse: 'site' },
|
|
95
96
|
args: [
|
|
96
97
|
{ name: 'limit', type: 'int', default: 50, help: 'Maximum number of lists to return (default 50).' },
|
|
97
98
|
],
|
|
98
99
|
columns: ['id', 'name', 'members', 'followers', 'mode'],
|
|
99
100
|
func: async (page, kwargs) => {
|
|
100
101
|
const limit = kwargs.limit || 50;
|
|
101
|
-
await page.
|
|
102
|
-
|
|
103
|
-
const ct0 = await page.evaluate(`() => {
|
|
104
|
-
return document.cookie.split(';').map(c => c.trim()).find(c => c.startsWith('ct0='))?.split('=')[1] || null;
|
|
105
|
-
}`);
|
|
102
|
+
const cookies = await page.getCookies({ url: 'https://x.com' });
|
|
103
|
+
const ct0 = cookies.find((c) => c.name === 'ct0')?.value || null;
|
|
106
104
|
if (!ct0)
|
|
107
105
|
throw new AuthRequiredError('x.com', 'Not logged into x.com (no ct0 cookie)');
|
|
108
106
|
const queryId = await page.evaluate(`async () => {
|
package/clis/twitter/profile.js
CHANGED
|
@@ -11,6 +11,7 @@ cli({
|
|
|
11
11
|
domain: 'x.com',
|
|
12
12
|
strategy: Strategy.COOKIE,
|
|
13
13
|
browser: true,
|
|
14
|
+
browserSession: { reuse: 'site' },
|
|
14
15
|
args: [
|
|
15
16
|
{ name: 'username', type: 'string', positional: true, help: 'Twitter screen name (with or without @). Defaults to the logged-in user when omitted.' },
|
|
16
17
|
],
|
|
@@ -32,12 +33,16 @@ cli({
|
|
|
32
33
|
// Navigate directly to the user's profile page (gives us cookie context)
|
|
33
34
|
await page.goto(`https://x.com/${username}`);
|
|
34
35
|
await page.wait(3);
|
|
36
|
+
// Read CSRF token directly from the cookie store via CDP — zero page.evaluate round-trip
|
|
37
|
+
const cookies = await page.getCookies({ url: 'https://x.com' });
|
|
38
|
+
const ct0 = cookies.find((c) => c.name === 'ct0')?.value || null;
|
|
39
|
+
if (!ct0)
|
|
40
|
+
throw new AuthRequiredError('x.com', 'Not logged into x.com (no ct0 cookie)');
|
|
35
41
|
const queryId = await resolveTwitterQueryId(page, 'UserByScreenName', USER_BY_SCREEN_NAME_QUERY_ID);
|
|
36
42
|
const result = await page.evaluate(`
|
|
37
43
|
async () => {
|
|
38
44
|
const screenName = "${username}";
|
|
39
|
-
const ct0 =
|
|
40
|
-
if (!ct0) return {error: 'No ct0 cookie — not logged into x.com'};
|
|
45
|
+
const ct0 = ${JSON.stringify(ct0)};
|
|
41
46
|
|
|
42
47
|
const bearer = ${JSON.stringify(TWITTER_BEARER_TOKEN)};
|
|
43
48
|
const headers = {
|
|
@@ -96,8 +101,6 @@ cli({
|
|
|
96
101
|
}
|
|
97
102
|
`);
|
|
98
103
|
if (result?.error) {
|
|
99
|
-
if (String(result.error).includes('No ct0 cookie'))
|
|
100
|
-
throw new AuthRequiredError('x.com', result.error);
|
|
101
104
|
throw new CommandExecutionError(result.error + (result.hint ? ` (${result.hint})` : ''));
|
|
102
105
|
}
|
|
103
106
|
return result || [];
|
package/clis/twitter/search.js
CHANGED
|
@@ -228,6 +228,7 @@ cli({
|
|
|
228
228
|
domain: 'x.com',
|
|
229
229
|
strategy: Strategy.INTERCEPT, // Use intercept strategy
|
|
230
230
|
browser: true,
|
|
231
|
+
browserSession: { reuse: 'site' },
|
|
231
232
|
args: [
|
|
232
233
|
{ name: 'query', type: 'string', required: true, positional: true, help: 'Search query. Raw X operators (e.g. "exact phrase", #tag, OR, lang:en, since:YYYY-MM-DD, from:, since:) are passed through unchanged.' },
|
|
233
234
|
{ name: 'filter', type: 'string', default: 'top', choices: ['top', 'live'], help: 'Legacy alias for --product. Kept for backwards compatibility; if --product is set it wins.' },
|
package/clis/twitter/thread.js
CHANGED
|
@@ -100,6 +100,7 @@ cli({
|
|
|
100
100
|
domain: 'x.com',
|
|
101
101
|
strategy: Strategy.COOKIE,
|
|
102
102
|
browser: true,
|
|
103
|
+
browserSession: { reuse: 'site' },
|
|
103
104
|
args: [
|
|
104
105
|
{ name: 'tweet-id', positional: true, type: 'string', required: true, help: 'Tweet numeric ID (e.g. 1234567890) or full status URL' },
|
|
105
106
|
{ name: 'limit', type: 'int', default: 50 },
|
|
@@ -111,13 +112,10 @@ cli({
|
|
|
111
112
|
const urlMatch = tweetId.match(/\/status\/(\d+)/);
|
|
112
113
|
if (urlMatch)
|
|
113
114
|
tweetId = urlMatch[1];
|
|
114
|
-
//
|
|
115
|
-
|
|
116
|
-
await page.
|
|
117
|
-
|
|
118
|
-
const ct0 = await page.evaluate(`() => {
|
|
119
|
-
return document.cookie.split(';').map(c=>c.trim()).find(c=>c.startsWith('ct0='))?.split('=')[1] || null;
|
|
120
|
-
}`);
|
|
115
|
+
// Cookie context auto-established by framework pre-nav (Strategy.COOKIE + domain).
|
|
116
|
+
// Read CSRF token directly from the cookie store via CDP — zero page.evaluate round-trip.
|
|
117
|
+
const cookies = await page.getCookies({ url: 'https://x.com' });
|
|
118
|
+
const ct0 = cookies.find((c) => c.name === 'ct0')?.value || null;
|
|
121
119
|
if (!ct0)
|
|
122
120
|
throw new AuthRequiredError('x.com', 'Not logged into x.com (no ct0 cookie)');
|
|
123
121
|
// Build auth headers in TypeScript
|
package/clis/twitter/timeline.js
CHANGED
|
@@ -141,6 +141,7 @@ cli({
|
|
|
141
141
|
domain: 'x.com',
|
|
142
142
|
strategy: Strategy.COOKIE,
|
|
143
143
|
browser: true,
|
|
144
|
+
browserSession: { reuse: 'site' },
|
|
144
145
|
args: [
|
|
145
146
|
{
|
|
146
147
|
name: 'type',
|
|
@@ -156,13 +157,10 @@ cli({
|
|
|
156
157
|
const limit = kwargs.limit || 20;
|
|
157
158
|
const timelineType = kwargs.type === 'following' ? 'following' : 'for-you';
|
|
158
159
|
const { endpoint, method, fallbackQueryId } = TIMELINE_ENDPOINTS[timelineType];
|
|
159
|
-
//
|
|
160
|
-
|
|
161
|
-
await page.
|
|
162
|
-
|
|
163
|
-
const ct0 = await page.evaluate(`() => {
|
|
164
|
-
return document.cookie.split(';').map(c=>c.trim()).find(c=>c.startsWith('ct0='))?.split('=')[1] || null;
|
|
165
|
-
}`);
|
|
160
|
+
// Cookie context auto-established by framework pre-nav (Strategy.COOKIE + domain).
|
|
161
|
+
// Read CSRF token directly from the cookie store via CDP — zero page.evaluate round-trip.
|
|
162
|
+
const cookies = await page.getCookies({ url: 'https://x.com' });
|
|
163
|
+
const ct0 = cookies.find((c) => c.name === 'ct0')?.value || null;
|
|
166
164
|
if (!ct0)
|
|
167
165
|
throw new AuthRequiredError('x.com', 'Not logged into x.com (no ct0 cookie)');
|
|
168
166
|
// Dynamically resolve queryId for the selected endpoint
|
package/clis/twitter/trending.js
CHANGED
|
@@ -17,6 +17,7 @@ cli({
|
|
|
17
17
|
domain: 'x.com',
|
|
18
18
|
strategy: Strategy.COOKIE,
|
|
19
19
|
browser: true,
|
|
20
|
+
browserSession: { reuse: 'site' },
|
|
20
21
|
args: [
|
|
21
22
|
{ name: 'limit', type: 'int', default: 20, help: 'Number of trends to show' },
|
|
22
23
|
],
|
|
@@ -26,10 +27,9 @@ cli({
|
|
|
26
27
|
// Navigate to trending page
|
|
27
28
|
await page.goto('https://x.com/explore/tabs/trending');
|
|
28
29
|
await page.wait(3);
|
|
29
|
-
// Verify login via CSRF cookie
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
})()`);
|
|
30
|
+
// Verify login via CSRF cookie (read directly from cookie store via CDP)
|
|
31
|
+
const cookies = await page.getCookies({ url: 'https://x.com' });
|
|
32
|
+
const ct0 = cookies.find((c) => c.name === 'ct0')?.value || null;
|
|
33
33
|
if (!ct0)
|
|
34
34
|
throw new AuthRequiredError('x.com', 'Not logged into x.com (no ct0 cookie)');
|
|
35
35
|
await page.wait(2);
|
package/clis/twitter/tweets.js
CHANGED
|
@@ -149,6 +149,7 @@ cli({
|
|
|
149
149
|
domain: 'x.com',
|
|
150
150
|
strategy: Strategy.COOKIE,
|
|
151
151
|
browser: true,
|
|
152
|
+
browserSession: { reuse: 'site' },
|
|
152
153
|
args: [
|
|
153
154
|
{ name: 'username', type: 'string', positional: true, required: true, help: 'Twitter screen name (with or without @)' },
|
|
154
155
|
{ name: 'limit', type: 'int', default: 20, help: 'Max tweets to return' },
|
|
@@ -160,12 +161,8 @@ cli({
|
|
|
160
161
|
const username = String(kwargs.username || '').replace(/^@/, '').trim();
|
|
161
162
|
if (!username) throw new CommandExecutionError('username is required');
|
|
162
163
|
|
|
163
|
-
await page.
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
const ct0 = await page.evaluate(`() => {
|
|
167
|
-
return document.cookie.split(';').map(c => c.trim()).find(c => c.startsWith('ct0='))?.split('=')[1] || null;
|
|
168
|
-
}`);
|
|
164
|
+
const cookies = await page.getCookies({ url: 'https://x.com' });
|
|
165
|
+
const ct0 = cookies.find((c) => c.name === 'ct0')?.value || null;
|
|
169
166
|
if (!ct0) throw new AuthRequiredError('x.com', 'Not logged into x.com (no ct0 cookie)');
|
|
170
167
|
|
|
171
168
|
const userTweetsQueryId = await resolveTwitterQueryId(page, 'UserTweets', USER_TWEETS_QUERY_ID);
|
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' },
|
|
@@ -32,7 +32,7 @@ export class BrowserBridge {
|
|
|
32
32
|
try {
|
|
33
33
|
const contextId = opts.contextId ?? resolveProfileContextId();
|
|
34
34
|
await this._ensureDaemon(opts.timeout, contextId);
|
|
35
|
-
this._page = new Page(opts.workspace, opts.idleTimeout, contextId);
|
|
35
|
+
this._page = new Page(opts.workspace, opts.idleTimeout, contextId, opts.windowMode);
|
|
36
36
|
this._state = 'connected';
|
|
37
37
|
return this._page;
|
|
38
38
|
}
|
|
@@ -27,6 +27,7 @@ export declare class CDPBridge implements IBrowserFactory {
|
|
|
27
27
|
cdpEndpoint?: string;
|
|
28
28
|
contextId?: string;
|
|
29
29
|
idleTimeout?: number;
|
|
30
|
+
windowMode?: 'foreground' | 'background';
|
|
30
31
|
}): Promise<IPage>;
|
|
31
32
|
close(): Promise<void>;
|
|
32
33
|
send(method: string, params?: Record<string, unknown>, timeoutMs?: number): Promise<unknown>;
|
|
@@ -36,8 +36,8 @@ export interface DaemonCommand {
|
|
|
36
36
|
timeoutMs?: number;
|
|
37
37
|
cdpMethod?: string;
|
|
38
38
|
cdpParams?: Record<string, unknown>;
|
|
39
|
-
/**
|
|
40
|
-
|
|
39
|
+
/** Window foreground/background policy for owned Browser Bridge containers. */
|
|
40
|
+
windowMode?: 'foreground' | 'background';
|
|
41
41
|
/** Custom idle timeout in seconds for this workspace session. Overrides the default. */
|
|
42
42
|
idleTimeout?: number;
|
|
43
43
|
/** Explicitly allow navigation inside a borrowed bound tab. */
|
|
@@ -89,10 +89,13 @@ async function sendCommandRaw(action, params) {
|
|
|
89
89
|
const maxRetries = 4;
|
|
90
90
|
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
91
91
|
const id = generateId();
|
|
92
|
-
const
|
|
93
|
-
const
|
|
92
|
+
const rawWindowMode = process.env.OPENCLI_WINDOW;
|
|
93
|
+
const envWindowMode = rawWindowMode === 'foreground' || rawWindowMode === 'background'
|
|
94
|
+
? rawWindowMode
|
|
95
|
+
: undefined;
|
|
94
96
|
const contextId = params.contextId ?? resolveProfileContextId();
|
|
95
|
-
const
|
|
97
|
+
const windowMode = params.windowMode ?? envWindowMode;
|
|
98
|
+
const command = { id, action, ...params, ...(contextId && { contextId }), ...(windowMode && { windowMode }) };
|
|
96
99
|
try {
|
|
97
100
|
const res = await requestDaemon('/command', {
|
|
98
101
|
method: 'POST',
|
|
@@ -144,6 +144,16 @@ describe('daemon-client', () => {
|
|
|
144
144
|
const body = JSON.parse(String(vi.mocked(fetch).mock.calls[0][1]?.body));
|
|
145
145
|
expect(body.contextId).toBe('work');
|
|
146
146
|
});
|
|
147
|
+
it('sendCommand uses explicit windowMode before OPENCLI_WINDOW env fallback', async () => {
|
|
148
|
+
vi.stubEnv('OPENCLI_WINDOW', 'foreground');
|
|
149
|
+
vi.mocked(fetch).mockResolvedValue({
|
|
150
|
+
status: 200,
|
|
151
|
+
json: () => Promise.resolve({ id: 'server', ok: true, data: 'ok' }),
|
|
152
|
+
});
|
|
153
|
+
await sendCommand('exec', { code: '1 + 1', windowMode: 'background' });
|
|
154
|
+
const body = JSON.parse(String(vi.mocked(fetch).mock.calls[0][1]?.body));
|
|
155
|
+
expect(body.windowMode).toBe('background');
|
|
156
|
+
});
|
|
147
157
|
it('sendCommand retries with a new id when daemon reports a duplicate pending id', async () => {
|
|
148
158
|
vi.spyOn(Date, 'now').mockReturnValue(1_763_000_000_123);
|
|
149
159
|
const fetchMock = vi.mocked(fetch);
|
|
@@ -16,8 +16,9 @@ import { BasePage } from './base-page.js';
|
|
|
16
16
|
export declare class Page extends BasePage {
|
|
17
17
|
private readonly workspace;
|
|
18
18
|
readonly contextId?: string | undefined;
|
|
19
|
+
private readonly windowMode?;
|
|
19
20
|
private readonly _idleTimeout;
|
|
20
|
-
constructor(workspace?: string, idleTimeout?: number, contextId?: string | undefined);
|
|
21
|
+
constructor(workspace?: string, idleTimeout?: number, contextId?: string | undefined, windowMode?: "foreground" | "background" | undefined);
|
|
21
22
|
/** Active page identity (targetId), set after navigate and used in all subsequent commands */
|
|
22
23
|
private _page;
|
|
23
24
|
private _networkCaptureUnsupported;
|
package/dist/src/browser/page.js
CHANGED
|
@@ -28,11 +28,13 @@ function isUnsupportedNetworkCaptureError(err) {
|
|
|
28
28
|
export class Page extends BasePage {
|
|
29
29
|
workspace;
|
|
30
30
|
contextId;
|
|
31
|
+
windowMode;
|
|
31
32
|
_idleTimeout;
|
|
32
|
-
constructor(workspace = 'default', idleTimeout, contextId) {
|
|
33
|
+
constructor(workspace = 'default', idleTimeout, contextId, windowMode) {
|
|
33
34
|
super();
|
|
34
35
|
this.workspace = workspace;
|
|
35
36
|
this.contextId = contextId;
|
|
37
|
+
this.windowMode = windowMode;
|
|
36
38
|
this._idleTimeout = idleTimeout;
|
|
37
39
|
}
|
|
38
40
|
/** Active page identity (targetId), set after navigate and used in all subsequent commands */
|
|
@@ -45,6 +47,7 @@ export class Page extends BasePage {
|
|
|
45
47
|
workspace: this.workspace,
|
|
46
48
|
...(this.contextId && { contextId: this.contextId }),
|
|
47
49
|
...(this._idleTimeout != null && { idleTimeout: this._idleTimeout }),
|
|
50
|
+
...(this.windowMode && { windowMode: this.windowMode }),
|
|
48
51
|
};
|
|
49
52
|
}
|
|
50
53
|
/** Helper: spread workspace + page identity into command params */
|
|
@@ -54,6 +57,7 @@ export class Page extends BasePage {
|
|
|
54
57
|
...(this.contextId && { contextId: this.contextId }),
|
|
55
58
|
...(this._page !== undefined && { page: this._page }),
|
|
56
59
|
...(this._idleTimeout != null && { idleTimeout: this._idleTimeout }),
|
|
60
|
+
...(this.windowMode && { windowMode: this.windowMode }),
|
|
57
61
|
};
|
|
58
62
|
}
|
|
59
63
|
async goto(url, options) {
|