@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.
Files changed (94) hide show
  1. package/README.md +9 -6
  2. package/README.zh-CN.md +9 -6
  3. package/cli-manifest.json +161 -31
  4. package/clis/chatgpt/ask.js +2 -1
  5. package/clis/chatgpt/detail.js +6 -1
  6. package/clis/chatgpt/read.js +2 -1
  7. package/clis/chatgpt/send.js +2 -1
  8. package/clis/chatgpt/utils.js +54 -12
  9. package/clis/chatgpt/utils.test.js +36 -1
  10. package/clis/claude/ask.js +22 -7
  11. package/clis/claude/detail.js +9 -2
  12. package/clis/claude/new.js +8 -2
  13. package/clis/claude/read.js +2 -1
  14. package/clis/claude/send.js +8 -3
  15. package/clis/claude/utils.js +27 -4
  16. package/clis/deepseek/ask.js +21 -8
  17. package/clis/deepseek/detail.js +9 -1
  18. package/clis/deepseek/new.js +13 -2
  19. package/clis/deepseek/read.js +2 -1
  20. package/clis/deepseek/utils.js +8 -1
  21. package/clis/linkedin/search.js +8 -11
  22. package/clis/maimai/search-talents.js +10 -6
  23. package/clis/openreview/author.js +58 -0
  24. package/clis/openreview/openreview.test.js +83 -1
  25. package/clis/openreview/utils.js +14 -0
  26. package/clis/reddit/comment.js +1 -0
  27. package/clis/reddit/frontpage.js +1 -0
  28. package/clis/reddit/popular.js +1 -0
  29. package/clis/reddit/read.js +2 -0
  30. package/clis/reddit/read.test.js +4 -0
  31. package/clis/reddit/save.js +1 -0
  32. package/clis/reddit/saved.js +1 -0
  33. package/clis/reddit/search.js +1 -0
  34. package/clis/reddit/subreddit.js +1 -0
  35. package/clis/reddit/subscribe.js +1 -0
  36. package/clis/reddit/upvote.js +1 -0
  37. package/clis/reddit/upvoted.js +1 -0
  38. package/clis/reddit/user-comments.js +1 -0
  39. package/clis/reddit/user-posts.js +1 -0
  40. package/clis/reddit/user.js +1 -0
  41. package/clis/twitter/article.js +7 -4
  42. package/clis/twitter/bookmark-folder.js +3 -5
  43. package/clis/twitter/bookmark-folder.test.js +5 -2
  44. package/clis/twitter/bookmark-folders.js +3 -5
  45. package/clis/twitter/bookmark-folders.test.js +3 -1
  46. package/clis/twitter/bookmarks.js +3 -5
  47. package/clis/twitter/download.js +1 -0
  48. package/clis/twitter/followers.js +1 -0
  49. package/clis/twitter/following.js +3 -6
  50. package/clis/twitter/following.test.js +2 -1
  51. package/clis/twitter/likes.js +3 -5
  52. package/clis/twitter/list-add.js +4 -3
  53. package/clis/twitter/list-add.test.js +23 -1
  54. package/clis/twitter/list-remove.js +4 -3
  55. package/clis/twitter/list-remove.test.js +23 -1
  56. package/clis/twitter/list-tweets.js +3 -5
  57. package/clis/twitter/lists.js +3 -5
  58. package/clis/twitter/notifications.js +1 -0
  59. package/clis/twitter/profile.js +7 -4
  60. package/clis/twitter/search.js +1 -0
  61. package/clis/twitter/thread.js +5 -7
  62. package/clis/twitter/timeline.js +5 -7
  63. package/clis/twitter/trending.js +4 -4
  64. package/clis/twitter/tweets.js +3 -6
  65. package/clis/youtube/like.js +6 -2
  66. package/clis/youtube/subscribe.js +6 -2
  67. package/clis/youtube/unlike.js +6 -2
  68. package/clis/youtube/unsubscribe.js +6 -2
  69. package/clis/youtube/utils.js +19 -13
  70. package/clis/youtube/utils.test.js +17 -1
  71. package/dist/src/browser/bridge.d.ts +1 -0
  72. package/dist/src/browser/bridge.js +1 -1
  73. package/dist/src/browser/cdp.d.ts +1 -0
  74. package/dist/src/browser/daemon-client.d.ts +2 -2
  75. package/dist/src/browser/daemon-client.js +6 -3
  76. package/dist/src/browser/daemon-client.test.js +10 -0
  77. package/dist/src/browser/page.d.ts +2 -1
  78. package/dist/src/browser/page.js +5 -1
  79. package/dist/src/cli.js +70 -2
  80. package/dist/src/cli.test.js +139 -7
  81. package/dist/src/commanderAdapter.js +7 -0
  82. package/dist/src/doctor.js +2 -2
  83. package/dist/src/doctor.test.js +4 -4
  84. package/dist/src/execution.d.ts +2 -0
  85. package/dist/src/execution.js +31 -6
  86. package/dist/src/execution.test.js +43 -16
  87. package/dist/src/external-clis.yaml +24 -0
  88. package/dist/src/help.d.ts +1 -0
  89. package/dist/src/help.js +29 -0
  90. package/dist/src/main.js +4 -14
  91. package/dist/src/runtime.d.ts +3 -0
  92. package/dist/src/runtime.js +1 -0
  93. package/dist/src/types.d.ts +1 -1
  94. 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 ct0 = await page.evaluate(`() => {
98
- return document.cookie.split(';').map(c => c.trim()).find(c => c.startsWith('ct0='))?.split('=')[1] || null;
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.goto('https://x.com');
128
- await page.wait(3);
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 () => {
@@ -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.goto('https://x.com');
102
- await page.wait(3);
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 () => {
@@ -8,6 +8,7 @@ cli({
8
8
  domain: 'x.com',
9
9
  strategy: Strategy.INTERCEPT,
10
10
  browser: true,
11
+ browserSession: { reuse: 'site' },
11
12
  args: [
12
13
  { name: 'limit', type: 'int', default: 20, help: 'Maximum number of notifications to return (default 20).' },
13
14
  ],
@@ -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 = document.cookie.split(';').map(c=>c.trim()).find(c=>c.startsWith('ct0='))?.split('=')[1];
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 || [];
@@ -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.' },
@@ -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
- // Navigate to x.com for cookie context
115
- await page.goto('https://x.com');
116
- await page.wait(3);
117
- // Extract CSRF token the only thing we need from the browser
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
@@ -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
- // Navigate to x.com for cookie context
160
- await page.goto('https://x.com');
161
- await page.wait(3);
162
- // Extract CSRF token
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
@@ -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 ct0 = await page.evaluate(`(() => {
31
- return document.cookie.split(';').map(c=>c.trim()).find(c=>c.startsWith('ct0='))?.split('=')[1] || null;
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);
@@ -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.goto('https://x.com');
164
- await page.wait(3);
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);
@@ -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}
@@ -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}
@@ -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' },
@@ -17,6 +17,7 @@ export declare class BrowserBridge implements IBrowserFactory {
17
17
  workspace?: string;
18
18
  idleTimeout?: number;
19
19
  contextId?: string;
20
+ windowMode?: 'foreground' | 'background';
20
21
  }): Promise<IPage>;
21
22
  close(): Promise<void>;
22
23
  private _ensureDaemon;
@@ -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
- /** When true, the owned automation container is created in the foreground */
40
- windowFocused?: boolean;
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 wf = process.env.OPENCLI_WINDOW_FOCUSED;
93
- const windowFocused = (wf === '1' || wf === 'true') ? true : undefined;
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 command = { id, action, ...params, ...(contextId && { contextId }), ...(windowFocused && { windowFocused }) };
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;
@@ -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) {