@jackwener/opencli 1.7.2 → 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 (144) hide show
  1. package/README.md +18 -15
  2. package/README.zh-CN.md +31 -15
  3. package/cli-manifest.json +1265 -101
  4. package/clis/barchart/flow.js +1 -1
  5. package/clis/barchart/greeks.js +2 -2
  6. package/clis/barchart/options.js +2 -2
  7. package/clis/barchart/quote.js +1 -1
  8. package/clis/bilibili/favorite.js +18 -13
  9. package/clis/bilibili/feed.js +202 -48
  10. package/clis/binance/depth.js +3 -4
  11. package/clis/boss/utils.js +2 -2
  12. package/clis/chatgpt/image.js +97 -0
  13. package/clis/chatgpt/utils.js +297 -0
  14. package/clis/{chatgpt → chatgpt-app}/ask.js +1 -1
  15. package/clis/{chatgpt → chatgpt-app}/ax.js +6 -3
  16. package/clis/{chatgpt → chatgpt-app}/model.js +1 -1
  17. package/clis/{chatgpt → chatgpt-app}/new.js +1 -1
  18. package/clis/{chatgpt → chatgpt-app}/read.js +1 -1
  19. package/clis/{chatgpt → chatgpt-app}/send.js +1 -1
  20. package/clis/{chatgpt → chatgpt-app}/status.js +1 -1
  21. package/clis/discord-app/delete.js +114 -0
  22. package/clis/douban/search.js +1 -0
  23. package/clis/douban/search.test.js +11 -0
  24. package/clis/douban/subject.js +20 -93
  25. package/clis/douban/subject.test.js +11 -0
  26. package/clis/douban/utils.js +279 -10
  27. package/clis/douban/utils.test.js +296 -1
  28. package/clis/doubao/utils.js +319 -130
  29. package/clis/doubao/utils.test.js +241 -2
  30. package/clis/eastmoney/hot-rank.js +50 -0
  31. package/clis/eastmoney/hot-rank.test.js +59 -0
  32. package/clis/grok/image.test.ts +107 -0
  33. package/clis/grok/image.ts +356 -0
  34. package/clis/ke/chengjiao.js +77 -0
  35. package/clis/ke/ershoufang.js +100 -0
  36. package/clis/ke/utils.js +104 -0
  37. package/clis/ke/xiaoqu.js +77 -0
  38. package/clis/ke/zufang.js +94 -0
  39. package/clis/maimai/search-talents.js +172 -0
  40. package/clis/mubu/doc.js +40 -0
  41. package/clis/mubu/docs.js +43 -0
  42. package/clis/mubu/notes.js +244 -0
  43. package/clis/mubu/recent.js +27 -0
  44. package/clis/mubu/search.js +62 -0
  45. package/clis/mubu/utils.js +304 -0
  46. package/clis/reuters/search.js +1 -1
  47. package/clis/tdx/hot-rank.js +47 -0
  48. package/clis/tdx/hot-rank.test.js +59 -0
  49. package/clis/ths/hot-rank.js +49 -0
  50. package/clis/ths/hot-rank.test.js +64 -0
  51. package/clis/twitter/bookmarks.js +2 -1
  52. package/clis/uiverse/_shared.js +368 -0
  53. package/clis/uiverse/_shared.test.js +55 -0
  54. package/clis/uiverse/code.js +47 -0
  55. package/clis/uiverse/preview.js +71 -0
  56. package/clis/xiaohongshu/comments.js +20 -8
  57. package/clis/xiaohongshu/comments.test.js +69 -12
  58. package/clis/xiaohongshu/creator-note-detail.js +2 -0
  59. package/clis/xiaohongshu/creator-note-detail.test.js +32 -0
  60. package/clis/xiaohongshu/creator-notes-summary.js +4 -0
  61. package/clis/xiaohongshu/creator-notes-summary.test.js +39 -1
  62. package/clis/xiaohongshu/creator-notes.js +1 -0
  63. package/clis/xiaohongshu/creator-profile.js +1 -0
  64. package/clis/xiaohongshu/creator-stats.js +1 -0
  65. package/clis/xiaohongshu/download.js +18 -7
  66. package/clis/xiaohongshu/download.test.js +42 -0
  67. package/clis/xiaohongshu/navigation.test.js +34 -0
  68. package/clis/xiaohongshu/note-helpers.js +46 -12
  69. package/clis/xiaohongshu/note.js +17 -10
  70. package/clis/xiaohongshu/note.test.js +66 -11
  71. package/clis/xiaohongshu/publish.js +1 -0
  72. package/clis/xiaohongshu/search.js +1 -0
  73. package/clis/xiaohongshu/user.js +1 -0
  74. package/clis/xiaoyuzhou/auth.js +303 -0
  75. package/clis/xiaoyuzhou/auth.test.js +124 -0
  76. package/clis/xiaoyuzhou/download.js +49 -0
  77. package/clis/xiaoyuzhou/download.test.js +125 -0
  78. package/clis/xiaoyuzhou/transcript.js +76 -0
  79. package/clis/xiaoyuzhou/transcript.test.js +195 -0
  80. package/clis/yahoo-finance/quote.js +1 -1
  81. package/clis/youtube/feed.js +120 -0
  82. package/clis/youtube/history.js +118 -0
  83. package/clis/youtube/like.js +62 -0
  84. package/clis/youtube/playlist.js +97 -0
  85. package/clis/youtube/subscribe.js +71 -0
  86. package/clis/youtube/subscriptions.js +57 -0
  87. package/clis/youtube/unlike.js +62 -0
  88. package/clis/youtube/unsubscribe.js +71 -0
  89. package/clis/youtube/utils.js +122 -0
  90. package/clis/youtube/utils.test.js +32 -1
  91. package/clis/youtube/watch-later.js +76 -0
  92. package/dist/src/browser/base-page.d.ts +9 -0
  93. package/dist/src/browser/base-page.js +44 -5
  94. package/dist/src/browser/bridge.d.ts +2 -0
  95. package/dist/src/browser/bridge.js +51 -14
  96. package/dist/src/browser/cdp.js +11 -2
  97. package/dist/src/browser/daemon-client.d.ts +2 -0
  98. package/dist/src/browser/dom-snapshot.js +13 -1
  99. package/dist/src/browser/page.d.ts +4 -1
  100. package/dist/src/browser/page.js +48 -8
  101. package/dist/src/browser/page.test.js +61 -1
  102. package/dist/src/browser/target-errors.d.ts +23 -0
  103. package/dist/src/browser/target-errors.js +29 -0
  104. package/dist/src/browser/target-errors.test.d.ts +1 -0
  105. package/dist/src/browser/target-errors.test.js +61 -0
  106. package/dist/src/browser/target-resolver.d.ts +57 -0
  107. package/dist/src/browser/target-resolver.js +298 -0
  108. package/dist/src/browser/target-resolver.test.d.ts +1 -0
  109. package/dist/src/browser/target-resolver.test.js +43 -0
  110. package/dist/src/browser.test.js +38 -1
  111. package/dist/src/cli.js +45 -35
  112. package/dist/src/commands/daemon.d.ts +4 -2
  113. package/dist/src/commands/daemon.js +22 -2
  114. package/dist/src/commands/daemon.test.js +65 -2
  115. package/dist/src/daemon.js +7 -0
  116. package/dist/src/doctor.d.ts +2 -0
  117. package/dist/src/doctor.js +82 -10
  118. package/dist/src/doctor.test.js +28 -12
  119. package/dist/src/electron-apps.js +1 -1
  120. package/dist/src/errors.d.ts +1 -0
  121. package/dist/src/errors.js +13 -0
  122. package/dist/src/execution.js +36 -9
  123. package/dist/src/execution.test.js +23 -0
  124. package/dist/src/external-clis.yaml +2 -2
  125. package/dist/src/logger.d.ts +2 -2
  126. package/dist/src/logger.js +3 -8
  127. package/dist/src/output.js +1 -5
  128. package/dist/src/output.test.js +0 -21
  129. package/dist/src/pipeline/steps/transform.js +1 -1
  130. package/dist/src/pipeline/template.d.ts +1 -0
  131. package/dist/src/pipeline/template.js +11 -3
  132. package/dist/src/pipeline/template.test.js +3 -0
  133. package/dist/src/pipeline/transform.test.js +14 -0
  134. package/dist/src/plugin.d.ts +7 -1
  135. package/dist/src/plugin.js +23 -1
  136. package/dist/src/plugin.test.js +15 -1
  137. package/dist/src/registry.js +3 -4
  138. package/dist/src/types.d.ts +3 -1
  139. package/dist/src/update-check.d.ts +14 -0
  140. package/dist/src/update-check.js +48 -3
  141. package/dist/src/update-check.test.d.ts +1 -0
  142. package/dist/src/update-check.test.js +31 -0
  143. package/package.json +1 -1
  144. package/scripts/fetch-adapters.js +35 -8
@@ -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
+ });
@@ -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
+ });