From f3a3c0e17e6838b56e4ad27af76646decd061f6f Mon Sep 17 00:00:00 2001 From: weiduan Date: Thu, 23 Apr 2026 23:18:14 -0400 Subject: [PATCH] feat(youtube): add --type shorts to channel command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `opencli youtube channel ` only reads the Home (and falls back to Videos) tab, so channels whose primary output lives in the Shorts tab return little or nothing. There is no opt-in to the Shorts tab today. This adds `--type shorts`. When set, the command: 1. Locates the Shorts tab in the initial channel page response. 2. Issues a second InnerTube `/youtubei/v1/browse` request using the tab's `browseEndpoint.params`. 3. Walks `richGridRenderer.contents`, picking the tab with `selected: true` from the response (the response includes all tabs). 4. Parses each `richItemRenderer.content.shortsLockupViewModel` for videoId, title, view count, emitting rows shaped like: { title, duration: 'SHORT', views, url: youtube.com/shorts/ } The default behaviour (no `--type`) is unchanged — Home tab + Videos fallback. Other `--type` values are silently ignored (reserved for future expansion mirroring `youtube search`). Reproducer: $ opencli youtube channel UCKz1652QrLR8mj2iF6ybCXg --type shorts --limit 8 → returns the 8 Shorts visible at https://www.youtube.com/channel/UCKz1652QrLR8mj2iF6ybCXg/shorts `npm run typecheck` clean. `npm test` passes (1952/1952). `cli-manifest.json` regenerated via `npm run build`. Co-Authored-By: Claude Opus 4.7 (1M context) --- cli-manifest.json | 7 ++++++ clis/youtube/channel.js | 50 +++++++++++++++++++++++++++++++++++++---- 2 files changed, 53 insertions(+), 4 deletions(-) diff --git a/cli-manifest.json b/cli-manifest.json index ff0daad0f..b8fd02309 100644 --- a/cli-manifest.json +++ b/cli-manifest.json @@ -19636,6 +19636,13 @@ "default": 10, "required": false, "help": "Max recent videos (max 30)" + }, + { + "name": "type", + "type": "str", + "default": "", + "required": false, + "help": "Tab to read: '' (default — Home tab + Videos fallback) or 'shorts' (Shorts tab)" } ], "columns": [ diff --git a/clis/youtube/channel.js b/clis/youtube/channel.js index a49a38a63..fb0172a3c 100644 --- a/clis/youtube/channel.js +++ b/clis/youtube/channel.js @@ -12,17 +12,20 @@ cli({ args: [ { name: 'id', required: true, positional: true, help: 'Channel ID (UCxxxx) or handle (@name)' }, { name: 'limit', type: 'int', default: 10, help: 'Max recent videos (max 30)' }, + { name: 'type', default: '', help: "Tab to read: '' (default — Home tab + Videos fallback) or 'shorts' (Shorts tab)" }, ], columns: ['field', 'value'], func: async (page, kwargs) => { const channelId = String(kwargs.id); const limit = Math.min(kwargs.limit || 10, 30); + const requestedType = String(kwargs.type || '').toLowerCase(); await page.goto('https://www.youtube.com'); await page.wait(2); const data = await page.evaluate(` (async () => { const channelId = ${JSON.stringify(channelId)}; const limit = ${limit}; + const requestedType = ${JSON.stringify(requestedType)}; const cfg = window.ytcfg?.data_ || {}; const apiKey = cfg.INNERTUBE_API_KEY; const context = cfg.INNERTUBE_CONTEXT; @@ -71,11 +74,50 @@ cli({ subscriberCount = header.subscriberCountText.simpleText; } - // Extract recent videos from Home tab const tabs = data.contents?.twoColumnBrowseResultsRenderer?.tabs || []; - const homeTab = tabs.find(t => t.tabRenderer?.selected); const recentVideos = []; + // --type shorts: hit the Shorts tab via InnerTube and parse + // shortsLockupViewModel items. Returns at most \`limit\` Shorts. + if (requestedType === 'shorts') { + const shortsTab = tabs.find(t => { + const tab = t.tabRenderer; + const url = tab?.endpoint?.commandMetadata?.webCommandMetadata?.url || ''; + return url.endsWith('/shorts') || tab?.title === 'Shorts'; + }); + const shortsTabParams = shortsTab?.tabRenderer?.endpoint?.browseEndpoint?.params; + if (shortsTabParams) { + const shortsResp = await fetch('/youtubei/v1/browse?key=' + apiKey + '&prettyPrint=false', { + method: 'POST', credentials: 'include', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({context, browseId, params: shortsTabParams}) + }); + if (shortsResp.ok) { + const shortsData = await shortsResp.json(); + const respTabs = shortsData.contents?.twoColumnBrowseResultsRenderer?.tabs || []; + const richGrid = respTabs.find(t => t.tabRenderer?.selected)?.tabRenderer?.content?.richGridRenderer?.contents || []; + for (const item of richGrid) { + if (recentVideos.length >= limit) break; + const lockup = item.richItemRenderer?.content?.shortsLockupViewModel; + if (!lockup) continue; + const videoId = lockup.onTap?.innertubeCommand?.reelWatchEndpoint?.videoId + || (lockup.entityId || '').replace(/^shorts-shelf-item-/, ''); + if (!videoId) continue; + const overlay = lockup.overlayMetadata || {}; + recentVideos.push({ + title: overlay.primaryText?.content || '', + duration: 'SHORT', + views: overlay.secondaryText?.content || '', + url: 'https://www.youtube.com/shorts/' + videoId, + }); + } + } + } + } + + // Extract recent videos from Home tab (default behaviour) + const homeTab = (requestedType === 'shorts') ? null : tabs.find(t => t.tabRenderer?.selected); + if (homeTab) { const sections = homeTab.tabRenderer?.content?.sectionListRenderer?.contents || []; for (const section of sections) { @@ -115,8 +157,8 @@ cli({ } } - // If Home tab has no videos, try Videos tab - if (recentVideos.length === 0) { + // If Home tab has no videos, try Videos tab (skip when caller asked for shorts) + if (recentVideos.length === 0 && requestedType !== 'shorts') { const videosTab = tabs.find(t => { const tab = t.tabRenderer; const url = tab?.endpoint?.commandMetadata?.webCommandMetadata?.url || '';