@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.
- package/README.md +16 -16
- package/README.zh-CN.md +28 -15
- package/cli-manifest.json +547 -10
- package/clis/bilibili/favorite.js +18 -13
- package/clis/binance/depth.js +3 -4
- package/clis/boss/utils.js +2 -3
- package/clis/chatgpt-app/ax.js +6 -3
- package/clis/douban/search.js +1 -0
- package/clis/douban/search.test.js +11 -0
- package/clis/douban/subject.js +20 -93
- package/clis/douban/subject.test.js +11 -0
- package/clis/douban/utils.js +250 -8
- package/clis/douban/utils.test.js +179 -4
- package/clis/doubao/utils.js +319 -130
- package/clis/doubao/utils.test.js +241 -2
- package/clis/eastmoney/hot-rank.js +50 -0
- package/clis/eastmoney/hot-rank.test.js +59 -0
- package/clis/grok/image.test.ts +107 -0
- package/clis/grok/image.ts +356 -0
- package/clis/tdx/hot-rank.js +47 -0
- package/clis/tdx/hot-rank.test.js +59 -0
- package/clis/ths/hot-rank.js +49 -0
- package/clis/ths/hot-rank.test.js +64 -0
- package/clis/twitter/bookmarks.js +2 -1
- package/clis/uiverse/_shared.js +368 -0
- package/clis/uiverse/_shared.test.js +55 -0
- package/clis/uiverse/code.js +47 -0
- package/clis/uiverse/preview.js +71 -0
- package/clis/xiaohongshu/comments.js +2 -2
- package/clis/xiaohongshu/comments.test.js +46 -25
- package/clis/xiaohongshu/download.js +6 -7
- package/clis/xiaohongshu/download.test.js +17 -5
- package/clis/xiaohongshu/note-helpers.js +46 -12
- package/clis/xiaohongshu/note.js +3 -5
- package/clis/xiaohongshu/note.test.js +52 -25
- package/clis/xiaoyuzhou/auth.js +303 -0
- package/clis/xiaoyuzhou/auth.test.js +124 -0
- package/clis/xiaoyuzhou/download.js +49 -0
- package/clis/xiaoyuzhou/download.test.js +125 -0
- package/clis/xiaoyuzhou/transcript.js +76 -0
- package/clis/xiaoyuzhou/transcript.test.js +195 -0
- package/clis/youtube/feed.js +120 -0
- package/clis/youtube/history.js +118 -0
- package/clis/youtube/like.js +62 -0
- package/clis/youtube/playlist.js +97 -0
- package/clis/youtube/subscribe.js +71 -0
- package/clis/youtube/subscriptions.js +57 -0
- package/clis/youtube/unlike.js +62 -0
- package/clis/youtube/unsubscribe.js +71 -0
- package/clis/youtube/utils.js +122 -0
- package/clis/youtube/utils.test.js +32 -1
- package/clis/youtube/watch-later.js +76 -0
- package/dist/src/browser/base-page.js +25 -5
- package/dist/src/browser/bridge.d.ts +2 -0
- package/dist/src/browser/bridge.js +51 -14
- package/dist/src/browser/cdp.js +1 -0
- package/dist/src/browser/daemon-client.d.ts +1 -0
- package/dist/src/browser/dom-snapshot.js +13 -1
- package/dist/src/browser/page.d.ts +4 -1
- package/dist/src/browser/page.js +48 -8
- package/dist/src/browser/page.test.js +61 -1
- package/dist/src/browser/target-errors.d.ts +23 -0
- package/dist/src/browser/target-errors.js +29 -0
- package/dist/src/browser/target-errors.test.d.ts +1 -0
- package/dist/src/browser/target-errors.test.js +61 -0
- package/dist/src/browser/target-resolver.d.ts +57 -0
- package/dist/src/browser/target-resolver.js +298 -0
- package/dist/src/browser/target-resolver.test.d.ts +1 -0
- package/dist/src/browser/target-resolver.test.js +43 -0
- package/dist/src/browser.test.js +38 -1
- package/dist/src/cli.js +45 -37
- package/dist/src/commands/daemon.d.ts +4 -2
- package/dist/src/commands/daemon.js +22 -2
- package/dist/src/commands/daemon.test.js +65 -2
- package/dist/src/daemon.js +2 -0
- package/dist/src/doctor.d.ts +1 -0
- package/dist/src/doctor.js +32 -9
- package/dist/src/doctor.test.js +28 -12
- package/dist/src/external-clis.yaml +2 -2
- package/dist/src/logger.d.ts +2 -2
- package/dist/src/logger.js +3 -3
- package/dist/src/output.js +1 -5
- package/dist/src/output.test.js +0 -21
- package/dist/src/pipeline/steps/transform.js +1 -1
- package/dist/src/pipeline/template.d.ts +1 -0
- package/dist/src/pipeline/template.js +11 -3
- package/dist/src/pipeline/template.test.js +3 -0
- package/dist/src/pipeline/transform.test.js +14 -0
- package/dist/src/plugin.d.ts +7 -1
- package/dist/src/plugin.js +23 -1
- package/dist/src/plugin.test.js +15 -1
- package/dist/src/types.d.ts +1 -1
- 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
|
+
});
|
package/clis/youtube/utils.js
CHANGED
|
@@ -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,
|
|
12
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
61
|
+
// Daemon running but no extension
|
|
61
62
|
if (health.state === 'no-extension') {
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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', '
|
|
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.
|
|
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;
|
package/dist/src/browser/cdp.js
CHANGED
|
@@ -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
|
})()
|