@jackwener/opencli 1.7.3 → 1.7.4

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 (93) hide show
  1. package/README.md +16 -16
  2. package/README.zh-CN.md +28 -15
  3. package/cli-manifest.json +547 -10
  4. package/clis/bilibili/favorite.js +18 -13
  5. package/clis/binance/depth.js +3 -4
  6. package/clis/boss/utils.js +2 -3
  7. package/clis/chatgpt-app/ax.js +6 -3
  8. package/clis/douban/search.js +1 -0
  9. package/clis/douban/search.test.js +11 -0
  10. package/clis/douban/subject.js +20 -93
  11. package/clis/douban/subject.test.js +11 -0
  12. package/clis/douban/utils.js +250 -8
  13. package/clis/douban/utils.test.js +179 -4
  14. package/clis/doubao/utils.js +319 -130
  15. package/clis/doubao/utils.test.js +241 -2
  16. package/clis/eastmoney/hot-rank.js +50 -0
  17. package/clis/eastmoney/hot-rank.test.js +59 -0
  18. package/clis/grok/image.test.ts +107 -0
  19. package/clis/grok/image.ts +356 -0
  20. package/clis/tdx/hot-rank.js +47 -0
  21. package/clis/tdx/hot-rank.test.js +59 -0
  22. package/clis/ths/hot-rank.js +49 -0
  23. package/clis/ths/hot-rank.test.js +64 -0
  24. package/clis/twitter/bookmarks.js +2 -1
  25. package/clis/uiverse/_shared.js +368 -0
  26. package/clis/uiverse/_shared.test.js +55 -0
  27. package/clis/uiverse/code.js +47 -0
  28. package/clis/uiverse/preview.js +71 -0
  29. package/clis/xiaohongshu/comments.js +2 -2
  30. package/clis/xiaohongshu/comments.test.js +46 -25
  31. package/clis/xiaohongshu/download.js +6 -7
  32. package/clis/xiaohongshu/download.test.js +17 -5
  33. package/clis/xiaohongshu/note-helpers.js +46 -12
  34. package/clis/xiaohongshu/note.js +3 -5
  35. package/clis/xiaohongshu/note.test.js +52 -25
  36. package/clis/xiaoyuzhou/auth.js +303 -0
  37. package/clis/xiaoyuzhou/auth.test.js +124 -0
  38. package/clis/xiaoyuzhou/download.js +49 -0
  39. package/clis/xiaoyuzhou/download.test.js +125 -0
  40. package/clis/xiaoyuzhou/transcript.js +76 -0
  41. package/clis/xiaoyuzhou/transcript.test.js +195 -0
  42. package/clis/youtube/feed.js +120 -0
  43. package/clis/youtube/history.js +118 -0
  44. package/clis/youtube/like.js +62 -0
  45. package/clis/youtube/playlist.js +97 -0
  46. package/clis/youtube/subscribe.js +71 -0
  47. package/clis/youtube/subscriptions.js +57 -0
  48. package/clis/youtube/unlike.js +62 -0
  49. package/clis/youtube/unsubscribe.js +71 -0
  50. package/clis/youtube/utils.js +122 -0
  51. package/clis/youtube/utils.test.js +32 -1
  52. package/clis/youtube/watch-later.js +76 -0
  53. package/dist/src/browser/base-page.js +25 -5
  54. package/dist/src/browser/bridge.d.ts +2 -0
  55. package/dist/src/browser/bridge.js +51 -14
  56. package/dist/src/browser/cdp.js +1 -0
  57. package/dist/src/browser/daemon-client.d.ts +1 -0
  58. package/dist/src/browser/dom-snapshot.js +13 -1
  59. package/dist/src/browser/page.d.ts +4 -1
  60. package/dist/src/browser/page.js +48 -8
  61. package/dist/src/browser/page.test.js +61 -1
  62. package/dist/src/browser/target-errors.d.ts +23 -0
  63. package/dist/src/browser/target-errors.js +29 -0
  64. package/dist/src/browser/target-errors.test.d.ts +1 -0
  65. package/dist/src/browser/target-errors.test.js +61 -0
  66. package/dist/src/browser/target-resolver.d.ts +57 -0
  67. package/dist/src/browser/target-resolver.js +298 -0
  68. package/dist/src/browser/target-resolver.test.d.ts +1 -0
  69. package/dist/src/browser/target-resolver.test.js +43 -0
  70. package/dist/src/browser.test.js +38 -1
  71. package/dist/src/cli.js +45 -37
  72. package/dist/src/commands/daemon.d.ts +4 -2
  73. package/dist/src/commands/daemon.js +22 -2
  74. package/dist/src/commands/daemon.test.js +65 -2
  75. package/dist/src/daemon.js +2 -0
  76. package/dist/src/doctor.d.ts +1 -0
  77. package/dist/src/doctor.js +32 -9
  78. package/dist/src/doctor.test.js +28 -12
  79. package/dist/src/external-clis.yaml +2 -2
  80. package/dist/src/logger.d.ts +2 -2
  81. package/dist/src/logger.js +3 -3
  82. package/dist/src/output.js +1 -5
  83. package/dist/src/output.test.js +0 -21
  84. package/dist/src/pipeline/steps/transform.js +1 -1
  85. package/dist/src/pipeline/template.d.ts +1 -0
  86. package/dist/src/pipeline/template.js +11 -3
  87. package/dist/src/pipeline/template.test.js +3 -0
  88. package/dist/src/pipeline/transform.test.js +14 -0
  89. package/dist/src/plugin.d.ts +7 -1
  90. package/dist/src/plugin.js +23 -1
  91. package/dist/src/plugin.test.js +15 -1
  92. package/dist/src/types.d.ts +1 -1
  93. package/package.json +1 -1
@@ -0,0 +1,57 @@
1
+ /**
2
+ * YouTube subscriptions — list of subscribed channels from /feed/channels.
3
+ */
4
+ import { cli, Strategy } from '@jackwener/opencli/registry';
5
+ import { CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
6
+ import { extractSubscriptionChannel } from './utils.js';
7
+
8
+ cli({
9
+ site: 'youtube',
10
+ name: 'subscriptions',
11
+ description: 'List subscribed YouTube channels',
12
+ domain: 'www.youtube.com',
13
+ strategy: Strategy.COOKIE,
14
+ args: [
15
+ { name: 'limit', type: 'int', default: 50, help: 'Max channels to return (default 50)' },
16
+ ],
17
+ columns: ['rank', 'name', 'handle', 'subscribers', 'url'],
18
+ func: async (page, kwargs) => {
19
+ const limit = Math.min(kwargs.limit || 50, 1000);
20
+ await page.goto('https://www.youtube.com/feed/channels');
21
+ await page.wait(3);
22
+ const data = await page.evaluate(`
23
+ (async () => {
24
+ const d = window.ytInitialData;
25
+ if (!d) return { error: 'YouTube data not found — are you logged in?' };
26
+
27
+ const limit = ${limit};
28
+
29
+ const items = d.contents?.twoColumnBrowseResultsRenderer
30
+ ?.tabs?.[0]?.tabRenderer?.content
31
+ ?.sectionListRenderer?.contents?.[0]
32
+ ?.itemSectionRenderer?.contents?.[0]
33
+ ?.shelfRenderer?.content
34
+ ?.expandedShelfContentsRenderer?.items || [];
35
+
36
+ const extractChannel = ${extractSubscriptionChannel.toString()};
37
+
38
+ const channels = [];
39
+ for (const item of items) {
40
+ if (channels.length >= limit) break;
41
+ const ch = extractChannel(item.channelRenderer);
42
+ if (ch?.name) channels.push(ch);
43
+ }
44
+
45
+ return channels;
46
+ })()
47
+ `);
48
+ if (!Array.isArray(data)) {
49
+ const errMsg = data && typeof data === 'object' ? String(data.error || '') : '';
50
+ throw new CommandExecutionError(errMsg || 'Failed to fetch subscriptions — make sure you are logged into YouTube');
51
+ }
52
+ if (data.length === 0) {
53
+ throw new EmptyResultError('youtube subscriptions');
54
+ }
55
+ return data.map((ch, i) => ({ rank: i + 1, ...ch }));
56
+ },
57
+ });
@@ -0,0 +1,62 @@
1
+ /**
2
+ * YouTube unlike — remove like from a video via InnerTube like API.
3
+ */
4
+ import { cli, Strategy } from '@jackwener/opencli/registry';
5
+ import { parseVideoId, prepareYoutubeApiPage, SAPISID_HASH_FN } from './utils.js';
6
+ import { CommandExecutionError, AuthRequiredError } from '@jackwener/opencli/errors';
7
+
8
+ cli({
9
+ site: 'youtube',
10
+ name: 'unlike',
11
+ description: 'Remove like from a YouTube video',
12
+ domain: 'www.youtube.com',
13
+ strategy: Strategy.COOKIE,
14
+ args: [
15
+ { name: 'url', required: true, positional: true, help: 'YouTube video URL or video ID' },
16
+ ],
17
+ columns: ['status', 'message'],
18
+ func: async (page, kwargs) => {
19
+ const videoId = parseVideoId(String(kwargs.url));
20
+ await prepareYoutubeApiPage(page);
21
+ const result = await page.evaluate(`
22
+ (async () => {
23
+ ${SAPISID_HASH_FN}
24
+
25
+ const cfg = window.ytcfg?.data_ || {};
26
+ const apiKey = cfg.INNERTUBE_API_KEY;
27
+ const context = cfg.INNERTUBE_CONTEXT;
28
+ if (!apiKey || !context) return { error: 'config', message: 'YouTube config not found' };
29
+
30
+ const authHash = await getSapisidHash('https://www.youtube.com');
31
+ if (!authHash) return { error: 'auth', message: 'Not logged in (SAPISID cookie missing)' };
32
+
33
+ const resp = await fetch('/youtubei/v1/like/removelike?key=' + apiKey + '&prettyPrint=false', {
34
+ method: 'POST',
35
+ credentials: 'include',
36
+ headers: {
37
+ 'Content-Type': 'application/json',
38
+ 'Authorization': authHash,
39
+ 'X-Origin': 'https://www.youtube.com',
40
+ },
41
+ body: JSON.stringify({ context, target: { videoId: ${JSON.stringify(videoId)} } }),
42
+ });
43
+
44
+ if (resp.status === 401 || resp.status === 403) return { error: 'auth', message: 'Not logged in' };
45
+ if (!resp.ok) {
46
+ const body = await resp.json().catch(() => ({}));
47
+ const errStatus = body?.error?.status || '';
48
+ if (errStatus === 'UNAUTHENTICATED') return { error: 'auth', message: 'Not logged in' };
49
+ return { error: 'http', message: 'HTTP ' + resp.status + (errStatus ? ' ' + errStatus : '') };
50
+ }
51
+ return { ok: true };
52
+ })()
53
+ `);
54
+ if (result?.error === 'auth') {
55
+ throw new AuthRequiredError('www.youtube.com');
56
+ }
57
+ if (result?.error) {
58
+ throw new CommandExecutionError(result.message || 'Failed to remove like');
59
+ }
60
+ return [{ status: 'success', message: 'Unliked: ' + videoId }];
61
+ },
62
+ });
@@ -0,0 +1,71 @@
1
+ /**
2
+ * YouTube unsubscribe — unsubscribe from a channel via InnerTube subscription API.
3
+ */
4
+ import { cli, Strategy } from '@jackwener/opencli/registry';
5
+ import { prepareYoutubeApiPage, SAPISID_HASH_FN, RESOLVE_CHANNEL_HANDLE_FN } from './utils.js';
6
+ import { CommandExecutionError, AuthRequiredError } from '@jackwener/opencli/errors';
7
+
8
+ cli({
9
+ site: 'youtube',
10
+ name: 'unsubscribe',
11
+ description: 'Unsubscribe from a YouTube channel',
12
+ domain: 'www.youtube.com',
13
+ strategy: Strategy.COOKIE,
14
+ args: [
15
+ { name: 'channel', required: true, positional: true, help: 'Channel ID (UCxxxx) or handle (@name)' },
16
+ ],
17
+ columns: ['status', 'message'],
18
+ func: async (page, kwargs) => {
19
+ const channelInput = String(kwargs.channel);
20
+ await prepareYoutubeApiPage(page);
21
+ const result = await page.evaluate(`
22
+ (async () => {
23
+ ${SAPISID_HASH_FN}
24
+
25
+ const cfg = window.ytcfg?.data_ || {};
26
+ const apiKey = cfg.INNERTUBE_API_KEY;
27
+ const context = cfg.INNERTUBE_CONTEXT;
28
+ if (!apiKey || !context) return { error: 'config', message: 'YouTube config not found' };
29
+
30
+ const authHash = await getSapisidHash('https://www.youtube.com');
31
+ if (!authHash) return { error: 'auth', message: 'Not logged in (SAPISID cookie missing)' };
32
+
33
+ ${RESOLVE_CHANNEL_HANDLE_FN}
34
+
35
+ let channelId = ${JSON.stringify(channelInput)};
36
+ channelId = await resolveChannelHandle(channelId, apiKey, context);
37
+
38
+ if (!channelId.startsWith('UC')) {
39
+ return { error: 'arg', message: 'Could not resolve channel ID from: ' + ${JSON.stringify(channelInput)} };
40
+ }
41
+
42
+ const resp = await fetch('/youtubei/v1/subscription/unsubscribe?key=' + apiKey + '&prettyPrint=false', {
43
+ method: 'POST',
44
+ credentials: 'include',
45
+ headers: {
46
+ 'Content-Type': 'application/json',
47
+ 'Authorization': authHash,
48
+ 'X-Origin': 'https://www.youtube.com',
49
+ },
50
+ body: JSON.stringify({ context, channelIds: [channelId] }),
51
+ });
52
+
53
+ if (resp.status === 401 || resp.status === 403) return { error: 'auth', message: 'Not logged in' };
54
+ if (!resp.ok) {
55
+ const body = await resp.json().catch(() => ({}));
56
+ const errStatus = body?.error?.status || '';
57
+ if (errStatus === 'UNAUTHENTICATED') return { error: 'auth', message: 'Not logged in' };
58
+ return { error: 'http', message: 'HTTP ' + resp.status + (errStatus ? ' ' + errStatus : '') };
59
+ }
60
+ return { ok: true, channelId };
61
+ })()
62
+ `);
63
+ if (result?.error === 'auth') {
64
+ throw new AuthRequiredError('www.youtube.com');
65
+ }
66
+ if (result?.error) {
67
+ throw new CommandExecutionError(result.message || 'Failed to unsubscribe');
68
+ }
69
+ return [{ status: 'success', message: 'Unsubscribed from: ' + (result.channelId || channelInput) }];
70
+ },
71
+ });
@@ -90,3 +90,125 @@ export async function prepareYoutubeApiPage(page) {
90
90
  await page.goto('https://www.youtube.com', { waitUntil: 'none' });
91
91
  await page.wait(2);
92
92
  }
93
+ /**
94
+ * Inline InnerTube browse API helper for use inside page.evaluate() strings.
95
+ * Inject via FETCH_BROWSE_FN, then call: fetchBrowse(apiKey, body)
96
+ */
97
+ export const FETCH_BROWSE_FN = `
98
+ async function fetchBrowse(apiKey, body) {
99
+ const resp = await fetch('/youtubei/v1/browse?key=' + apiKey + '&prettyPrint=false', {
100
+ method: 'POST',
101
+ credentials: 'include',
102
+ headers: { 'Content-Type': 'application/json' },
103
+ body: JSON.stringify(body),
104
+ });
105
+ if (!resp.ok) return { error: 'InnerTube browse API returned HTTP ' + resp.status };
106
+ return resp.json();
107
+ }
108
+ `;
109
+ /**
110
+ * Extract video objects from playlistVideoRenderer items (playlists, watch-later).
111
+ * Pure function — inject into page.evaluate() via: extractPlaylistVideos.toString()
112
+ */
113
+ export function extractPlaylistVideos(items) {
114
+ return items
115
+ .filter(i => i.playlistVideoRenderer)
116
+ .map(i => {
117
+ const v = i.playlistVideoRenderer;
118
+ const infoRuns = v.videoInfo?.runs || [];
119
+ return {
120
+ rank: parseInt(v.index?.simpleText || '0', 10),
121
+ title: v.title?.runs?.[0]?.text || '',
122
+ channel: v.shortBylineText?.runs?.[0]?.text || '',
123
+ duration: v.lengthText?.simpleText || '',
124
+ views: infoRuns[0]?.text || '',
125
+ published: infoRuns[2]?.text || '',
126
+ url: 'https://www.youtube.com/watch?v=' + v.videoId,
127
+ };
128
+ });
129
+ }
130
+ /**
131
+ * Normalize a subscribed channel entry from YouTube's channelRenderer payload.
132
+ * Different surfaces/locales may expose the handle in channelHandleText, canonicalBaseUrl,
133
+ * or, in some variants, overload one of the count fields with an @handle string.
134
+ */
135
+ export function extractSubscriptionChannel(channelRenderer) {
136
+ const readText = (value) => {
137
+ if (!value)
138
+ return '';
139
+ if (typeof value.simpleText === 'string')
140
+ return value.simpleText.trim();
141
+ if (Array.isArray(value.runs)) {
142
+ return value.runs
143
+ .map((run) => run?.text || '')
144
+ .join('')
145
+ .trim();
146
+ }
147
+ return '';
148
+ };
149
+ const ch = channelRenderer || {};
150
+ const name = readText(ch.title);
151
+ const baseUrl = ch.navigationEndpoint?.browseEndpoint?.canonicalBaseUrl || '';
152
+ const channelId = ch.channelId || ch.navigationEndpoint?.browseEndpoint?.browseId || '';
153
+ const subscriberCountText = readText(ch.subscriberCountText);
154
+ const videoCountText = readText(ch.videoCountText);
155
+ const handle = [
156
+ readText(ch.channelHandleText),
157
+ baseUrl.startsWith('/@') ? baseUrl.slice(1) : '',
158
+ subscriberCountText.startsWith('@') ? subscriberCountText : '',
159
+ videoCountText.startsWith('@') ? videoCountText : '',
160
+ ].find(Boolean) || '';
161
+ const subscribers = [
162
+ !subscriberCountText.startsWith('@') ? subscriberCountText : '',
163
+ !videoCountText.startsWith('@') ? videoCountText : '',
164
+ ].find(Boolean) || '';
165
+ const url = baseUrl
166
+ ? 'https://www.youtube.com' + baseUrl
167
+ : channelId ? 'https://www.youtube.com/channel/' + channelId : '';
168
+ return { name, handle, subscribers, url };
169
+ }
170
+ /**
171
+ * Inline @handle → channelId resolver for use inside page.evaluate() strings.
172
+ * Inject via RESOLVE_CHANNEL_HANDLE_FN, then call: resolveChannelHandle(input, apiKey, context)
173
+ */
174
+ export const RESOLVE_CHANNEL_HANDLE_FN = `
175
+ async function resolveChannelHandle(input, apiKey, context) {
176
+ if (!input.startsWith('@')) return input;
177
+ const resp = await fetch('/youtubei/v1/navigation/resolve_url?key=' + apiKey + '&prettyPrint=false', {
178
+ method: 'POST',
179
+ credentials: 'include',
180
+ headers: { 'Content-Type': 'application/json' },
181
+ body: JSON.stringify({ context, url: 'https://www.youtube.com/' + input }),
182
+ });
183
+ if (!resp.ok) return input;
184
+ const data = await resp.json().catch(() => ({}));
185
+ return data.endpoint?.browseEndpoint?.browseId || input;
186
+ }
187
+ `;
188
+ /**
189
+ * Inline SAPISIDHASH helper for use inside page.evaluate() strings.
190
+ * YouTube write APIs (like, subscribe) require:
191
+ * Authorization: SAPISIDHASH {time}_{SHA1(time + " " + SAPISID + " " + origin)}
192
+ */
193
+ 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
+ }
207
+ if (!sapisid) return null;
208
+ const time = Math.floor(Date.now() / 1000);
209
+ const msgBuffer = new TextEncoder().encode(time + ' ' + sapisid + ' ' + origin);
210
+ const hashBuffer = await crypto.subtle.digest('SHA-1', msgBuffer);
211
+ const hashHex = Array.from(new Uint8Array(hashBuffer)).map(b => b.toString(16).padStart(2, '0')).join('');
212
+ return 'SAPISIDHASH ' + time + '_' + hashHex;
213
+ }
214
+ `;
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it, vi } from 'vitest';
2
- import { extractJsonAssignmentFromHtml, prepareYoutubeApiPage } from './utils.js';
2
+ import { extractJsonAssignmentFromHtml, extractSubscriptionChannel, prepareYoutubeApiPage } 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,4 +34,35 @@ 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('extractSubscriptionChannel prefers explicit handle and subscriber count fields', () => {
38
+ expect(extractSubscriptionChannel({
39
+ title: { simpleText: 'OpenAI' },
40
+ channelHandleText: { runs: [{ text: '@openai' }] },
41
+ subscriberCountText: { simpleText: '1.23M subscribers' },
42
+ videoCountText: { simpleText: '123 videos' },
43
+ navigationEndpoint: { browseEndpoint: { canonicalBaseUrl: '/channel/UC123' } },
44
+ channelId: 'UC123',
45
+ })).toEqual({
46
+ name: 'OpenAI',
47
+ handle: '@openai',
48
+ subscribers: '1.23M subscribers',
49
+ url: 'https://www.youtube.com/channel/UC123',
50
+ });
51
+ });
52
+ it('extractSubscriptionChannel falls back when handle/count fields are overloaded', () => {
53
+ expect(extractSubscriptionChannel({
54
+ title: {
55
+ runs: [{ text: 'OpenAI' }],
56
+ },
57
+ subscriberCountText: { simpleText: '@openai' },
58
+ videoCountText: { simpleText: '1.23M subscribers' },
59
+ navigationEndpoint: { browseEndpoint: { canonicalBaseUrl: '/@openai' } },
60
+ channelId: 'UC123',
61
+ })).toEqual({
62
+ name: 'OpenAI',
63
+ handle: '@openai',
64
+ subscribers: '1.23M subscribers',
65
+ url: 'https://www.youtube.com/@openai',
66
+ });
67
+ });
37
68
  });
@@ -0,0 +1,76 @@
1
+ /**
2
+ * YouTube watch-later — the user's Watch Later queue.
3
+ * Navigates to /playlist?list=WL and reads ytInitialData directly.
4
+ */
5
+ import { cli, Strategy } from '@jackwener/opencli/registry';
6
+ import { FETCH_BROWSE_FN, extractPlaylistVideos } from './utils.js';
7
+ import { CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
8
+
9
+ cli({
10
+ site: 'youtube',
11
+ name: 'watch-later',
12
+ description: 'Get your YouTube Watch Later queue',
13
+ domain: 'www.youtube.com',
14
+ strategy: Strategy.COOKIE,
15
+ args: [
16
+ { name: 'limit', type: 'int', default: 50, help: 'Max videos to return (default 50, max 200)' },
17
+ ],
18
+ columns: ['rank', 'title', 'channel', 'duration', 'views', 'published', 'url'],
19
+ func: async (page, kwargs) => {
20
+ const limit = Math.min(kwargs.limit || 50, 200);
21
+ await page.goto('https://www.youtube.com/playlist?list=WL');
22
+ await page.wait(3);
23
+ const data = await page.evaluate(`
24
+ (async () => {
25
+ const d = window.ytInitialData;
26
+ if (!d) return { error: 'YouTube data not found — are you logged in?' };
27
+
28
+ const limit = ${limit};
29
+ const cfg = window.ytcfg?.data_ || {};
30
+ const apiKey = cfg.INNERTUBE_API_KEY;
31
+ const context = cfg.INNERTUBE_CONTEXT;
32
+
33
+ const header = d.header?.playlistHeaderRenderer;
34
+ const title = header?.title?.simpleText || 'Watch Later';
35
+ const stats = (header?.stats || [])
36
+ .map(s => s.runs?.map(r => r.text)?.join('') || s.simpleText || '')
37
+ .filter(Boolean);
38
+
39
+ const tabs = d.contents?.twoColumnBrowseResultsRenderer?.tabs || [];
40
+ let listContents = tabs[0]?.tabRenderer?.content?.sectionListRenderer?.contents?.[0]?.itemSectionRenderer?.contents?.[0]?.playlistVideoListRenderer?.contents || [];
41
+
42
+ ${FETCH_BROWSE_FN}
43
+
44
+ const extractVideos = ${extractPlaylistVideos.toString()};
45
+
46
+ let videos = extractVideos(listContents);
47
+
48
+ let contItem = listContents[listContents.length - 1];
49
+ while (videos.length < limit && contItem?.continuationItemRenderer && apiKey && context) {
50
+ const token = contItem.continuationItemRenderer?.continuationEndpoint?.continuationCommand?.token;
51
+ if (!token) break;
52
+ const contData = await fetchBrowse(apiKey, { context, continuation: token });
53
+ if (contData.error) break;
54
+ const newItems = contData.onResponseReceivedActions?.[0]?.appendContinuationItemsAction?.continuationItems || [];
55
+ if (!newItems.length) break;
56
+ videos = videos.concat(extractVideos(newItems));
57
+ contItem = newItems[newItems.length - 1];
58
+ }
59
+
60
+ return { title, stats, videos: videos.slice(0, limit) };
61
+ })()
62
+ `);
63
+ if (!data || typeof data !== 'object') {
64
+ throw new CommandExecutionError('Failed to fetch Watch Later — make sure you are logged into YouTube');
65
+ }
66
+ if (data.error) {
67
+ throw new CommandExecutionError(String(data.error));
68
+ }
69
+ if (!data.videos?.length) {
70
+ throw new EmptyResultError('youtube watch-later');
71
+ }
72
+ const statsStr = (data.stats || []).join(' | ');
73
+ process.stderr.write(`${data.title} ${statsStr}\n`);
74
+ return data.videos;
75
+ },
76
+ });
@@ -8,8 +8,10 @@
8
8
  * Subclasses implement the transport-specific methods: goto, evaluate,
9
9
  * getCookies, screenshot, tabs, etc.
10
10
  */
11
- import { generateSnapshotJs, scrollToRefJs, getFormStateJs } from './dom-snapshot.js';
12
- import { clickJs, typeTextJs, pressKeyJs, waitForTextJs, waitForCaptureJs, waitForSelectorJs, scrollJs, autoScrollJs, networkRequestsJs, waitForDomStableJs, } from './dom-helpers.js';
11
+ import { generateSnapshotJs, getFormStateJs } from './dom-snapshot.js';
12
+ import { pressKeyJs, waitForTextJs, waitForCaptureJs, waitForSelectorJs, scrollJs, autoScrollJs, networkRequestsJs, waitForDomStableJs, } from './dom-helpers.js';
13
+ import { resolveTargetJs, clickResolvedJs, typeResolvedJs, scrollResolvedJs } from './target-resolver.js';
14
+ import { TargetError } from './target-errors.js';
13
15
  import { formatSnapshot } from '../snapshotFormatter.js';
14
16
  export class BasePage {
15
17
  _lastUrl = null;
@@ -36,7 +38,13 @@ export class BasePage {
36
38
  }
37
39
  // ── Shared DOM helper implementations ──
38
40
  async click(ref) {
39
- const result = await this.evaluate(clickJs(ref));
41
+ // Phase 1: Resolve target with fingerprint verification
42
+ const resolution = await this.evaluate(resolveTargetJs(ref));
43
+ if (!resolution.ok) {
44
+ throw new TargetError(resolution);
45
+ }
46
+ // Phase 2: Execute click on resolved element
47
+ const result = await this.evaluate(clickResolvedJs());
40
48
  // Backwards compat: old format returned 'clicked' string
41
49
  if (typeof result === 'string' || result == null)
42
50
  return;
@@ -56,13 +64,25 @@ export class BasePage {
56
64
  return false;
57
65
  }
58
66
  async typeText(ref, text) {
59
- await this.evaluate(typeTextJs(ref, text));
67
+ // Phase 1: Resolve target with fingerprint verification
68
+ const resolution = await this.evaluate(resolveTargetJs(ref));
69
+ if (!resolution.ok) {
70
+ throw new TargetError(resolution);
71
+ }
72
+ // Phase 2: Execute type on resolved element
73
+ await this.evaluate(typeResolvedJs(text));
60
74
  }
61
75
  async pressKey(key) {
62
76
  await this.evaluate(pressKeyJs(key));
63
77
  }
64
78
  async scrollTo(ref) {
65
- return this.evaluate(scrollToRefJs(ref));
79
+ // Phase 1: Resolve target with fingerprint verification
80
+ const resolution = await this.evaluate(resolveTargetJs(ref));
81
+ if (!resolution.ok) {
82
+ throw new TargetError(resolution);
83
+ }
84
+ // Phase 2: Scroll to resolved element
85
+ return this.evaluate(scrollResolvedJs());
66
86
  }
67
87
  async getFormState() {
68
88
  return (await this.evaluate(getFormStateJs()));
@@ -18,6 +18,8 @@ export declare class BrowserBridge implements IBrowserFactory {
18
18
  }): Promise<IPage>;
19
19
  close(): Promise<void>;
20
20
  private _ensureDaemon;
21
+ /** Poll until daemon is fully stopped (port released). */
22
+ private _waitForDaemonStop;
21
23
  /** Poll getDaemonHealth() until state is 'ready' or deadline is reached. */
22
24
  private _pollUntilReady;
23
25
  }
@@ -6,9 +6,10 @@ import { fileURLToPath } from 'node:url';
6
6
  import * as path from 'node:path';
7
7
  import * as fs from 'node:fs';
8
8
  import { Page } from './page.js';
9
- import { getDaemonHealth } from './daemon-client.js';
9
+ import { getDaemonHealth, requestDaemonShutdown } from './daemon-client.js';
10
10
  import { DEFAULT_DAEMON_PORT } from '../constants.js';
11
11
  import { BrowserConnectError } from '../errors.js';
12
+ import { PKG_VERSION } from '../version.js';
12
13
  const DAEMON_SPAWN_TIMEOUT = 10000; // 10s to wait for daemon + extension
13
14
  /**
14
15
  * Browser factory: manages daemon lifecycle and provides IPage instances.
@@ -57,18 +58,42 @@ export class BrowserBridge {
57
58
  // Fast path: everything ready
58
59
  if (health.state === 'ready')
59
60
  return;
60
- // Daemon running but no extension — wait for extension with progress
61
+ // Daemon running but no extension
61
62
  if (health.state === 'no-extension') {
62
- if (process.env.OPENCLI_VERBOSE || process.stderr.isTTY) {
63
- process.stderr.write('⏳ Waiting for Chrome/Chromium extension to connect...\n');
64
- process.stderr.write(' Make sure Chrome or Chromium is open and the OpenCLI extension is enabled.\n');
63
+ // Detect stale daemon: version mismatch OR missing daemonVersion (pre-version daemon)
64
+ const daemonVersion = health.status?.daemonVersion;
65
+ const isStale = !daemonVersion || daemonVersion !== PKG_VERSION;
66
+ if (isStale) {
67
+ // Stale daemon — restart it so extension gets a fresh WebSocket endpoint
68
+ const reason = daemonVersion
69
+ ? `v${daemonVersion} ≠ v${PKG_VERSION}`
70
+ : `pre-version daemon, CLI is v${PKG_VERSION}`;
71
+ if (process.env.OPENCLI_VERBOSE || process.stderr.isTTY) {
72
+ process.stderr.write(`⚠️ Stale daemon detected (${reason}). Restarting...\n`);
73
+ }
74
+ const shutdownAccepted = await requestDaemonShutdown();
75
+ const portReleased = shutdownAccepted && await this._waitForDaemonStop(3000);
76
+ if (!portReleased) {
77
+ // Stale daemon replacement failed — don't blindly spawn on an occupied port
78
+ throw new BrowserConnectError('Stale daemon could not be replaced', `A stale daemon (${reason}) is running but did not shut down.\n` +
79
+ ' Run manually: opencli daemon stop && opencli doctor', 'daemon-not-running');
80
+ }
81
+ // Port released — fall through to spawn a fresh daemon
82
+ }
83
+ else {
84
+ // Same version — wait for extension to connect
85
+ if (process.env.OPENCLI_VERBOSE || process.stderr.isTTY) {
86
+ process.stderr.write('⏳ Waiting for Chrome/Chromium extension to connect...\n');
87
+ process.stderr.write(' Make sure Chrome or Chromium is open and the OpenCLI extension is enabled.\n');
88
+ }
89
+ if (await this._pollUntilReady(timeoutMs))
90
+ return;
91
+ throw new BrowserConnectError('Browser Bridge extension not connected', 'Make sure Chrome/Chromium is open and the extension is enabled.\n' +
92
+ 'If the extension is installed, try: opencli daemon stop && opencli doctor\n' +
93
+ 'If not installed:\n' +
94
+ ' 1. Download: https://github.com/jackwener/opencli/releases\n' +
95
+ ' 2. Open chrome://extensions → Developer Mode → Load unpacked', 'extension-not-connected');
65
96
  }
66
- if (await this._pollUntilReady(timeoutMs))
67
- return;
68
- throw new BrowserConnectError('Browser Bridge extension not connected', 'Install the Browser Bridge:\n' +
69
- ' 1. Download: https://github.com/jackwener/opencli/releases\n' +
70
- ' 2. In Chrome or Chromium, open chrome://extensions → Developer Mode → Load unpacked\n' +
71
- ' Then run: opencli doctor', 'extension-not-connected');
72
97
  }
73
98
  // No daemon — spawn one
74
99
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -94,13 +119,25 @@ export class BrowserBridge {
94
119
  return;
95
120
  const finalHealth = await getDaemonHealth();
96
121
  if (finalHealth.state === 'no-extension') {
97
- throw new BrowserConnectError('Browser Bridge extension not connected', 'Install the Browser Bridge:\n' +
122
+ throw new BrowserConnectError('Browser Bridge extension not connected', 'Make sure Chrome/Chromium is open and the extension is enabled.\n' +
123
+ 'If the extension is installed, try: opencli daemon stop && opencli doctor\n' +
124
+ 'If not installed:\n' +
98
125
  ' 1. Download: https://github.com/jackwener/opencli/releases\n' +
99
- ' 2. In Chrome or Chromium, open chrome://extensions → Developer Mode → Load unpacked\n' +
100
- ' Then run: opencli doctor', 'extension-not-connected');
126
+ ' 2. Open chrome://extensions → Developer Mode → Load unpacked', 'extension-not-connected');
101
127
  }
102
128
  throw new BrowserConnectError('Failed to start opencli daemon', `Try running manually:\n node ${daemonPath}\nMake sure port ${DEFAULT_DAEMON_PORT} is available.`, 'daemon-not-running');
103
129
  }
130
+ /** Poll until daemon is fully stopped (port released). */
131
+ async _waitForDaemonStop(timeoutMs) {
132
+ const deadline = Date.now() + timeoutMs;
133
+ while (Date.now() < deadline) {
134
+ await new Promise(resolve => setTimeout(resolve, 200));
135
+ const h = await getDaemonHealth();
136
+ if (h.state === 'stopped')
137
+ return true;
138
+ }
139
+ return false;
140
+ }
104
141
  /** Poll getDaemonHealth() until state is 'ready' or deadline is reached. */
105
142
  async _pollUntilReady(timeoutMs) {
106
143
  const deadline = Date.now() + timeoutMs;
@@ -255,6 +255,7 @@ class CDPPage extends BasePage {
255
255
  });
256
256
  this._networkCapturing = true;
257
257
  }
258
+ return true;
258
259
  }
259
260
  async readNetworkCapture() {
260
261
  // Await all in-flight body fetches so entries have responsePreview populated
@@ -47,6 +47,7 @@ export interface DaemonStatus {
47
47
  ok: boolean;
48
48
  pid: number;
49
49
  uptime: number;
50
+ daemonVersion?: string;
50
51
  extensionConnected: boolean;
51
52
  extensionVersion?: string;
52
53
  extensionCompatRange?: string;
@@ -575,6 +575,7 @@ export function generateSnapshotJs(opts = {}) {
575
575
  const lines = [];
576
576
  const hiddenInteractives = [];
577
577
  const currentHashes = [];
578
+ const refIdentity = {};
578
579
  let iframeCount = 0;
579
580
 
580
581
  function walk(el, depth, parentPropagatingRect) {
@@ -709,11 +710,20 @@ export function generateSnapshotJs(opts = {}) {
709
710
  // Scroll marker
710
711
  if (isScrollable && !interactive) line += '|scroll|';
711
712
 
712
- // Interactive index + data-ref
713
+ // Interactive index + data-ref + fingerprint
713
714
  if (interactive) {
714
715
  interactiveIndex++;
715
716
  if (ANNOTATE_REFS) el.setAttribute('data-opencli-ref', '' + interactiveIndex);
716
717
  line += isScrollable ? '|scroll[' + interactiveIndex + ']|' : '[' + interactiveIndex + ']';
718
+ // Store fingerprint for stale-ref detection
719
+ refIdentity['' + interactiveIndex] = {
720
+ tag: tag,
721
+ role: el.getAttribute('role') || '',
722
+ text: (el.textContent || '').trim().slice(0, 30),
723
+ ariaLabel: el.getAttribute('aria-label') || '',
724
+ id: el.id || '',
725
+ testId: el.getAttribute('data-testid') || el.getAttribute('data-test') || '',
726
+ };
717
727
  }
718
728
 
719
729
  // Tag + attributes
@@ -797,6 +807,8 @@ export function generateSnapshotJs(opts = {}) {
797
807
 
798
808
  // Store hashes on window for next diff snapshot
799
809
  try { window.__opencli_prev_hashes = JSON.stringify(currentHashes); } catch {}
810
+ // Store ref identity map for stale-ref detection by target resolver
811
+ try { window.__opencli_ref_identity = refIdentity; } catch {}
800
812
 
801
813
  return lines.join('\\n');
802
814
  })()