@jackwener/opencli 1.7.3 → 1.7.5
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 +81 -59
- package/README.zh-CN.md +93 -67
- package/cli-manifest.json +5015 -2975
- package/clis/antigravity/serve.js +71 -25
- package/clis/baidu-scholar/search.js +87 -0
- package/clis/baidu-scholar/search.test.js +23 -0
- 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/deepseek/ask.js +74 -0
- package/clis/deepseek/history.js +25 -0
- package/clis/deepseek/new.js +20 -0
- package/clis/deepseek/read.js +22 -0
- package/clis/deepseek/status.js +24 -0
- package/clis/deepseek/utils.js +208 -0
- 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/_secid.js +78 -0
- package/clis/eastmoney/announcement.js +52 -0
- package/clis/eastmoney/convertible.js +73 -0
- package/clis/eastmoney/etf.js +65 -0
- package/clis/eastmoney/holders.js +78 -0
- package/clis/eastmoney/hot-rank.js +50 -0
- package/clis/eastmoney/hot-rank.test.js +59 -0
- package/clis/eastmoney/index-board.js +96 -0
- package/clis/eastmoney/kline.js +87 -0
- package/clis/eastmoney/kuaixun.js +54 -0
- package/clis/eastmoney/longhu.js +67 -0
- package/clis/eastmoney/money-flow.js +78 -0
- package/clis/eastmoney/northbound.js +57 -0
- package/clis/eastmoney/quote.js +107 -0
- package/clis/eastmoney/rank.js +94 -0
- package/clis/eastmoney/sectors.js +76 -0
- package/clis/google-scholar/search.js +58 -0
- package/clis/google-scholar/search.test.js +23 -0
- package/clis/gov-law/commands.test.js +39 -0
- package/clis/gov-law/recent.js +22 -0
- package/clis/gov-law/search.js +41 -0
- package/clis/gov-law/shared.js +51 -0
- package/clis/gov-policy/commands.test.js +27 -0
- package/clis/gov-policy/recent.js +47 -0
- package/clis/gov-policy/search.js +48 -0
- package/clis/grok/image.test.ts +107 -0
- package/clis/grok/image.ts +356 -0
- package/clis/nowcoder/companies.js +23 -0
- package/clis/nowcoder/creators.js +27 -0
- package/clis/nowcoder/detail.js +61 -0
- package/clis/nowcoder/experience.js +36 -0
- package/clis/nowcoder/hot.js +24 -0
- package/clis/nowcoder/jobs.js +21 -0
- package/clis/nowcoder/notifications.js +29 -0
- package/clis/nowcoder/papers.js +40 -0
- package/clis/nowcoder/practice.js +37 -0
- package/clis/nowcoder/recommend.js +30 -0
- package/clis/nowcoder/referral.js +39 -0
- package/clis/nowcoder/salary.js +40 -0
- package/clis/nowcoder/search.js +49 -0
- package/clis/nowcoder/suggest.js +33 -0
- package/clis/nowcoder/topics.js +27 -0
- package/clis/nowcoder/trending.js +25 -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/twitter/list-add.js +337 -0
- package/clis/twitter/list-add.test.js +15 -0
- package/clis/twitter/list-remove.js +297 -0
- package/clis/twitter/list-remove.test.js +14 -0
- package/clis/twitter/list-tweets.js +185 -0
- package/clis/twitter/list-tweets.test.js +108 -0
- package/clis/twitter/lists.js +134 -47
- package/clis/twitter/lists.test.js +105 -38
- 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/wanfang/search.js +66 -0
- package/clis/wanfang/search.test.js +23 -0
- package/clis/web/read.js +1 -1
- package/clis/weixin/download.js +3 -2
- 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/xiaohongshu/publish.js +149 -28
- package/clis/xiaohongshu/publish.test.js +319 -6
- package/clis/xiaoyuzhou/auth.js +303 -0
- package/clis/xiaoyuzhou/auth.test.js +124 -0
- package/clis/xiaoyuzhou/download.js +53 -0
- package/clis/xiaoyuzhou/download.test.js +135 -0
- package/clis/xiaoyuzhou/episode.js +9 -4
- package/clis/xiaoyuzhou/podcast-episodes.js +15 -11
- package/clis/xiaoyuzhou/podcast.js +9 -4
- package/clis/xiaoyuzhou/transcript.js +76 -0
- package/clis/xiaoyuzhou/transcript.test.js +195 -0
- package/clis/xiaoyuzhou/utils.js +0 -40
- package/clis/xiaoyuzhou/utils.test.js +15 -75
- 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/clis/zsxq/dynamics.js +1 -1
- package/clis/zsxq/utils.js +6 -3
- package/clis/zsxq/utils.test.js +31 -0
- package/dist/src/browser/base-page.d.ts +1 -1
- package/dist/src/browser/base-page.js +25 -5
- package/dist/src/browser/bridge.d.ts +3 -0
- package/dist/src/browser/bridge.js +52 -15
- package/dist/src/browser/cdp.js +2 -1
- package/dist/src/browser/daemon-client.d.ts +7 -4
- package/dist/src/browser/daemon-client.js +6 -1
- package/dist/src/browser/daemon-client.test.js +40 -1
- package/dist/src/browser/dom-snapshot.js +20 -3
- package/dist/src/browser/page.d.ts +18 -5
- package/dist/src/browser/page.js +96 -15
- package/dist/src/browser/page.test.js +158 -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.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.js +43 -0
- package/dist/src/browser.test.js +38 -1
- package/dist/src/cli.js +272 -187
- package/dist/src/cli.test.js +167 -90
- package/dist/src/commanderAdapter.d.ts +0 -1
- package/dist/src/commanderAdapter.js +2 -16
- package/dist/src/commanderAdapter.test.js +1 -1
- 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/completion-shared.js +2 -5
- package/dist/src/daemon.js +10 -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/download/article-download.d.ts +1 -0
- package/dist/src/download/article-download.js +3 -0
- package/dist/src/download/article-download.test.js +39 -0
- 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 +8 -9
- package/dist/src/plugin.js +24 -28
- package/dist/src/plugin.test.js +16 -60
- package/dist/src/registry.d.ts +1 -0
- package/dist/src/registry.js +3 -2
- package/dist/src/registry.test.js +22 -0
- package/dist/src/types.d.ts +15 -6
- package/package.json +1 -1
- package/clis/twitter/lists-parser.js +0 -77
- package/clis/twitter/lists.d.ts +0 -5
- package/dist/src/cascade.d.ts +0 -46
- package/dist/src/cascade.js +0 -135
- package/dist/src/explore.d.ts +0 -99
- package/dist/src/explore.js +0 -402
- package/dist/src/generate-verified.d.ts +0 -105
- package/dist/src/generate-verified.js +0 -696
- package/dist/src/generate-verified.test.js +0 -925
- package/dist/src/generate.d.ts +0 -46
- package/dist/src/generate.js +0 -117
- package/dist/src/record.d.ts +0 -96
- package/dist/src/record.js +0 -657
- package/dist/src/record.test.js +0 -293
- package/dist/src/skill-generate.d.ts +0 -30
- package/dist/src/skill-generate.js +0 -75
- package/dist/src/skill-generate.test.js +0 -173
- package/dist/src/synthesize.d.ts +0 -97
- package/dist/src/synthesize.js +0 -208
- /package/dist/src/{generate-verified.test.d.ts → browser/target-errors.test.d.ts} +0 -0
- /package/dist/src/{record.test.d.ts → browser/target-resolver.test.d.ts} +0 -0
- /package/dist/src/{skill-generate.test.d.ts → download/article-download.test.d.ts} +0 -0
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* YouTube history — watch history via InnerTube browse API (FEhistory).
|
|
3
|
+
*/
|
|
4
|
+
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
5
|
+
import { CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
|
|
6
|
+
|
|
7
|
+
cli({
|
|
8
|
+
site: 'youtube',
|
|
9
|
+
name: 'history',
|
|
10
|
+
description: 'Get YouTube watch history',
|
|
11
|
+
domain: 'www.youtube.com',
|
|
12
|
+
strategy: Strategy.COOKIE,
|
|
13
|
+
args: [
|
|
14
|
+
{ name: 'limit', type: 'int', default: 30, help: 'Max videos to return (default 30, max 200)' },
|
|
15
|
+
],
|
|
16
|
+
columns: ['rank', 'title', 'channel', 'views', 'duration', 'url'],
|
|
17
|
+
func: async (page, kwargs) => {
|
|
18
|
+
const limit = Math.min(kwargs.limit || 30, 200);
|
|
19
|
+
await page.goto('https://www.youtube.com/feed/history');
|
|
20
|
+
await page.wait(3);
|
|
21
|
+
await page.autoScroll({ times: Math.min(Math.max(Math.ceil(limit / 20), 1), 8), delayMs: 1200 });
|
|
22
|
+
const data = await page.evaluate(`
|
|
23
|
+
(async () => {
|
|
24
|
+
const limit = ${limit};
|
|
25
|
+
|
|
26
|
+
const videos = [];
|
|
27
|
+
const seen = new Set();
|
|
28
|
+
const root = document.querySelector('ytd-two-column-browse-results-renderer #primary ytd-section-list-renderer');
|
|
29
|
+
if (!root) return { error: 'YouTube history list not found' };
|
|
30
|
+
|
|
31
|
+
function text(el) {
|
|
32
|
+
return (el?.textContent || '').replace(/\\s+/g, ' ').trim();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function push(entry) {
|
|
36
|
+
if (!entry?.url || seen.has(entry.url) || videos.length >= limit) return;
|
|
37
|
+
seen.add(entry.url);
|
|
38
|
+
videos.push({ rank: videos.length + 1, ...entry });
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
for (const section of root.querySelectorAll('ytd-item-section-renderer')) {
|
|
42
|
+
if (videos.length >= limit) break;
|
|
43
|
+
|
|
44
|
+
for (const renderer of section.querySelectorAll('yt-lockup-view-model, ytd-video-renderer, ytd-rich-item-renderer, ytd-grid-video-renderer, ytd-compact-video-renderer')) {
|
|
45
|
+
if (videos.length >= limit) break;
|
|
46
|
+
const link = renderer.querySelector('a[href^="/watch?v="]');
|
|
47
|
+
const href = link?.getAttribute('href') || '';
|
|
48
|
+
if (!href) continue;
|
|
49
|
+
const title =
|
|
50
|
+
link?.getAttribute('title')
|
|
51
|
+
|| text(renderer.querySelector('#video-title'))
|
|
52
|
+
|| text(renderer.querySelector('h3 a'))
|
|
53
|
+
|| text(renderer.querySelector('h3'))
|
|
54
|
+
|| text(link);
|
|
55
|
+
const channel =
|
|
56
|
+
text(renderer.querySelector('#channel-name a'))
|
|
57
|
+
|| text(renderer.querySelector('[aria-label^="前往频道:"]'))
|
|
58
|
+
|| text(renderer.querySelector('[aria-label^="Go to channel:"]'))
|
|
59
|
+
|| text(renderer.querySelector('ytd-channel-name'))
|
|
60
|
+
|| text(renderer.querySelector('#metadata #byline-container'))
|
|
61
|
+
|| '';
|
|
62
|
+
const metadata = Array.from(renderer.querySelectorAll('#metadata-line span, #metadata span, .metadata span'))
|
|
63
|
+
.map(node => text(node))
|
|
64
|
+
.filter(Boolean);
|
|
65
|
+
const lockupMetadata = Array.from(renderer.querySelectorAll('yt-content-metadata-view-model span, yt-lockup-metadata-view-model span'))
|
|
66
|
+
.map(node => text(node))
|
|
67
|
+
.filter(Boolean);
|
|
68
|
+
const combinedMetadata = (metadata.length ? metadata : lockupMetadata)
|
|
69
|
+
.filter(value => value && value !== title && value !== '•');
|
|
70
|
+
const inferredChannel = channel || combinedMetadata.find(value => !/观看|views|前|前に|ago|次观看|次查看|stream/i.test(value)) || '';
|
|
71
|
+
const inferredViews = combinedMetadata.find(value => /观看|views/i.test(value)) || '';
|
|
72
|
+
const inferredPublished = combinedMetadata.find(value => value !== inferredChannel && value !== inferredViews) || '';
|
|
73
|
+
const duration =
|
|
74
|
+
text(renderer.querySelector('ytd-thumbnail-overlay-time-status-renderer'))
|
|
75
|
+
|| text(renderer.querySelector('yt-thumbnail-badge-view-model'))
|
|
76
|
+
|| text(renderer.querySelector('badge-shape'))
|
|
77
|
+
|| '';
|
|
78
|
+
push({
|
|
79
|
+
title,
|
|
80
|
+
channel: inferredChannel,
|
|
81
|
+
views: inferredViews,
|
|
82
|
+
duration,
|
|
83
|
+
published: inferredPublished,
|
|
84
|
+
url: href.startsWith('http') ? href : 'https://www.youtube.com' + href,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
for (const shortLink of section.querySelectorAll('a[href^="/shorts/"]')) {
|
|
89
|
+
if (videos.length >= limit) break;
|
|
90
|
+
const card = shortLink.closest('ytm-shorts-lockup-view-model-v2, ytm-shorts-lockup-view-model, ytd-reel-item-renderer') || shortLink.parentElement;
|
|
91
|
+
const href = shortLink.getAttribute('href') || '';
|
|
92
|
+
if (!href) continue;
|
|
93
|
+
const title = shortLink.getAttribute('title') || text(card?.querySelector('h3')) || text(shortLink);
|
|
94
|
+
const stats = Array.from(card?.querySelectorAll('span') || []).map(node => text(node)).filter(Boolean);
|
|
95
|
+
push({
|
|
96
|
+
title,
|
|
97
|
+
channel: 'Shorts',
|
|
98
|
+
views: stats.find(value => /观看|views/i.test(value)) || '',
|
|
99
|
+
duration: 'SHORT',
|
|
100
|
+
published: '',
|
|
101
|
+
url: href.startsWith('http') ? href : 'https://www.youtube.com' + href,
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return videos.length ? videos : { error: 'No watch history items found on youtube.com/feed/history' };
|
|
107
|
+
})()
|
|
108
|
+
`);
|
|
109
|
+
if (!Array.isArray(data)) {
|
|
110
|
+
const errMsg = data && typeof data === 'object' ? String(data.error || '') : '';
|
|
111
|
+
throw new CommandExecutionError(errMsg || 'Failed to fetch watch history — make sure you are logged into YouTube');
|
|
112
|
+
}
|
|
113
|
+
if (data.length === 0) {
|
|
114
|
+
throw new EmptyResultError('youtube history');
|
|
115
|
+
}
|
|
116
|
+
return data;
|
|
117
|
+
},
|
|
118
|
+
});
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* YouTube like — like a video via InnerTube like API (requires SAPISIDHASH auth).
|
|
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: 'like',
|
|
11
|
+
description: 'Like 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/like?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 like video');
|
|
59
|
+
}
|
|
60
|
+
return [{ status: 'success', message: 'Liked: ' + videoId }];
|
|
61
|
+
},
|
|
62
|
+
});
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* YouTube playlist — get playlist info and video list via InnerTube browse API.
|
|
3
|
+
*/
|
|
4
|
+
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
5
|
+
import { prepareYoutubeApiPage, FETCH_BROWSE_FN, extractPlaylistVideos } from './utils.js';
|
|
6
|
+
import { CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Parse a playlist ID from a URL or bare ID string.
|
|
10
|
+
*/
|
|
11
|
+
function parsePlaylistId(input) {
|
|
12
|
+
if (!input.startsWith('http'))
|
|
13
|
+
return input;
|
|
14
|
+
try {
|
|
15
|
+
const url = new URL(input);
|
|
16
|
+
return url.searchParams.get('list') || input;
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return input;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
cli({
|
|
24
|
+
site: 'youtube',
|
|
25
|
+
name: 'playlist',
|
|
26
|
+
description: 'Get YouTube playlist info and video list',
|
|
27
|
+
domain: 'www.youtube.com',
|
|
28
|
+
strategy: Strategy.COOKIE,
|
|
29
|
+
args: [
|
|
30
|
+
{ name: 'id', required: true, positional: true, help: 'Playlist URL or playlist ID (PLxxxxxx)' },
|
|
31
|
+
{ name: 'limit', type: 'int', default: 50, help: 'Max videos to return (default 50, max 200)' },
|
|
32
|
+
],
|
|
33
|
+
columns: ['rank', 'title', 'channel', 'duration', 'views', 'published', 'url'],
|
|
34
|
+
func: async (page, kwargs) => {
|
|
35
|
+
const playlistId = parsePlaylistId(String(kwargs.id));
|
|
36
|
+
const limit = Math.min(kwargs.limit || 50, 200);
|
|
37
|
+
await prepareYoutubeApiPage(page);
|
|
38
|
+
const data = await page.evaluate(`
|
|
39
|
+
(async () => {
|
|
40
|
+
const cfg = window.ytcfg?.data_ || {};
|
|
41
|
+
const apiKey = cfg.INNERTUBE_API_KEY;
|
|
42
|
+
const context = cfg.INNERTUBE_CONTEXT;
|
|
43
|
+
if (!apiKey || !context) return { error: 'YouTube config not found' };
|
|
44
|
+
|
|
45
|
+
const browseId = 'VL' + ${JSON.stringify(playlistId)};
|
|
46
|
+
const limit = ${limit};
|
|
47
|
+
|
|
48
|
+
${FETCH_BROWSE_FN}
|
|
49
|
+
|
|
50
|
+
const data = await fetchBrowse(apiKey, { context, browseId });
|
|
51
|
+
if (data.error) return data;
|
|
52
|
+
|
|
53
|
+
const header = data.header?.pageHeaderRenderer;
|
|
54
|
+
const title = header?.pageTitle || '';
|
|
55
|
+
const metaRows = header?.content?.pageHeaderViewModel?.metadata?.contentMetadataViewModel?.metadataRows || [];
|
|
56
|
+
const stats = metaRows.flatMap(r => (r.metadataParts || []).map(p => p.text?.content || '').filter(Boolean));
|
|
57
|
+
|
|
58
|
+
const sidebarItems = data.sidebar?.playlistSidebarRenderer?.items || [];
|
|
59
|
+
const secondaryInfo = sidebarItems.find(i => i.playlistSidebarSecondaryInfoRenderer)?.playlistSidebarSecondaryInfoRenderer;
|
|
60
|
+
const channelName = secondaryInfo?.videoOwner?.videoOwnerRenderer?.title?.runs?.[0]?.text || '';
|
|
61
|
+
|
|
62
|
+
const tabs = data.contents?.twoColumnBrowseResultsRenderer?.tabs || [];
|
|
63
|
+
let listContents = tabs[0]?.tabRenderer?.content?.sectionListRenderer?.contents?.[0]?.itemSectionRenderer?.contents?.[0]?.playlistVideoListRenderer?.contents || [];
|
|
64
|
+
|
|
65
|
+
const extractVideos = ${extractPlaylistVideos.toString()};
|
|
66
|
+
|
|
67
|
+
let videos = extractVideos(listContents);
|
|
68
|
+
|
|
69
|
+
let contItem = listContents[listContents.length - 1];
|
|
70
|
+
while (videos.length < limit && contItem?.continuationItemRenderer) {
|
|
71
|
+
const token = contItem.continuationItemRenderer?.continuationEndpoint?.continuationCommand?.token;
|
|
72
|
+
if (!token) break;
|
|
73
|
+
const contData = await fetchBrowse(apiKey, { context, continuation: token });
|
|
74
|
+
if (contData.error) break;
|
|
75
|
+
const newItems = contData.onResponseReceivedActions?.[0]?.appendContinuationItemsAction?.continuationItems || [];
|
|
76
|
+
if (!newItems.length) break;
|
|
77
|
+
videos = videos.concat(extractVideos(newItems));
|
|
78
|
+
contItem = newItems[newItems.length - 1];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return { title, channelName, stats, videos: videos.slice(0, limit) };
|
|
82
|
+
})()
|
|
83
|
+
`);
|
|
84
|
+
if (!data || typeof data !== 'object') {
|
|
85
|
+
throw new CommandExecutionError('Failed to fetch playlist data');
|
|
86
|
+
}
|
|
87
|
+
if (data.error) {
|
|
88
|
+
throw new CommandExecutionError(String(data.error));
|
|
89
|
+
}
|
|
90
|
+
if (!data.videos?.length) {
|
|
91
|
+
throw new EmptyResultError('youtube playlist');
|
|
92
|
+
}
|
|
93
|
+
const statsStr = (data.stats || []).join(' | ');
|
|
94
|
+
process.stderr.write(`${data.title} [${data.channelName}] ${statsStr}\n`);
|
|
95
|
+
return data.videos;
|
|
96
|
+
},
|
|
97
|
+
});
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* YouTube subscribe — subscribe to 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: 'subscribe',
|
|
11
|
+
description: 'Subscribe to 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/subscribe?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 subscribe');
|
|
68
|
+
}
|
|
69
|
+
return [{ status: 'success', message: 'Subscribed to: ' + (result.channelId || channelInput) }];
|
|
70
|
+
},
|
|
71
|
+
});
|
|
@@ -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
|
+
`;
|