channel.js
1 /** 2 * YouTube channel — get channel info and recent videos via InnerTube API. 3 */ 4 import { cli, Strategy } from '@jackwener/opencli/registry'; 5 import { CommandExecutionError } from '@jackwener/opencli/errors'; 6 cli({ 7 site: 'youtube', 8 name: 'channel', 9 description: 'Get YouTube channel info and recent videos', 10 domain: 'www.youtube.com', 11 strategy: Strategy.COOKIE, 12 args: [ 13 { name: 'id', required: true, positional: true, help: 'Channel ID (UCxxxx) or handle (@name)' }, 14 { name: 'limit', type: 'int', default: 10, help: 'Max recent videos (max 30)' }, 15 ], 16 columns: ['field', 'value'], 17 func: async (page, kwargs) => { 18 const channelId = String(kwargs.id); 19 const limit = Math.min(kwargs.limit || 10, 30); 20 await page.goto('https://www.youtube.com'); 21 await page.wait(2); 22 const data = await page.evaluate(` 23 (async () => { 24 const channelId = ${JSON.stringify(channelId)}; 25 const limit = ${limit}; 26 const cfg = window.ytcfg?.data_ || {}; 27 const apiKey = cfg.INNERTUBE_API_KEY; 28 const context = cfg.INNERTUBE_CONTEXT; 29 if (!apiKey || !context) return {error: 'YouTube config not found'}; 30 31 // Resolve handle to browseId if needed 32 let browseId = channelId; 33 if (channelId.startsWith('@')) { 34 const resolveResp = await fetch('/youtubei/v1/navigation/resolve_url?key=' + apiKey + '&prettyPrint=false', { 35 method: 'POST', credentials: 'include', 36 headers: {'Content-Type': 'application/json'}, 37 body: JSON.stringify({context, url: 'https://www.youtube.com/' + channelId}) 38 }); 39 if (resolveResp.ok) { 40 const resolveData = await resolveResp.json(); 41 browseId = resolveData.endpoint?.browseEndpoint?.browseId || channelId; 42 } 43 } 44 45 // Fetch channel data 46 const resp = await fetch('/youtubei/v1/browse?key=' + apiKey + '&prettyPrint=false', { 47 method: 'POST', credentials: 'include', 48 headers: {'Content-Type': 'application/json'}, 49 body: JSON.stringify({context, browseId}) 50 }); 51 if (!resp.ok) return {error: 'Channel API returned HTTP ' + resp.status}; 52 const data = await resp.json(); 53 54 // Channel metadata 55 const metadata = data.metadata?.channelMetadataRenderer || {}; 56 const header = data.header?.pageHeaderRenderer || data.header?.c4TabbedHeaderRenderer || {}; 57 58 // Subscriber count from header 59 let subscriberCount = ''; 60 try { 61 const rows = header.content?.pageHeaderViewModel?.metadata?.contentMetadataViewModel?.metadataRows || []; 62 for (const row of rows) { 63 for (const part of (row.metadataParts || [])) { 64 const text = part.text?.content || ''; 65 if (text.includes('subscriber')) subscriberCount = text; 66 } 67 } 68 } catch {} 69 // Fallback for old c4TabbedHeaderRenderer format 70 if (!subscriberCount && header.subscriberCountText?.simpleText) { 71 subscriberCount = header.subscriberCountText.simpleText; 72 } 73 74 // Extract recent videos from Home tab 75 const tabs = data.contents?.twoColumnBrowseResultsRenderer?.tabs || []; 76 const homeTab = tabs.find(t => t.tabRenderer?.selected); 77 const recentVideos = []; 78 79 if (homeTab) { 80 const sections = homeTab.tabRenderer?.content?.sectionListRenderer?.contents || []; 81 for (const section of sections) { 82 for (const shelf of (section.itemSectionRenderer?.contents || [])) { 83 for (const item of (shelf.shelfRenderer?.content?.horizontalListRenderer?.items || [])) { 84 // New lockupViewModel format 85 const lvm = item.lockupViewModel; 86 if (lvm && lvm.contentType === 'LOCKUP_CONTENT_TYPE_VIDEO' && recentVideos.length < limit) { 87 const meta = lvm.metadata?.lockupMetadataViewModel; 88 const rows = meta?.metadata?.contentMetadataViewModel?.metadataRows || []; 89 const viewsAndTime = (rows[0]?.metadataParts || []).map(p => p.text?.content).filter(Boolean).join(' | '); 90 let duration = ''; 91 for (const ov of (lvm.contentImage?.thumbnailViewModel?.overlays || [])) { 92 for (const b of (ov.thumbnailBottomOverlayViewModel?.badges || [])) { 93 if (b.thumbnailBadgeViewModel?.text) duration = b.thumbnailBadgeViewModel.text; 94 } 95 } 96 recentVideos.push({ 97 title: meta?.title?.content || '', 98 duration, 99 views: viewsAndTime, 100 url: 'https://www.youtube.com/watch?v=' + lvm.contentId, 101 }); 102 } 103 // Legacy gridVideoRenderer format 104 if (item.gridVideoRenderer && recentVideos.length < limit) { 105 const v = item.gridVideoRenderer; 106 recentVideos.push({ 107 title: v.title?.runs?.[0]?.text || v.title?.simpleText || '', 108 duration: v.thumbnailOverlays?.[0]?.thumbnailOverlayTimeStatusRenderer?.text?.simpleText || '', 109 views: (v.shortViewCountText?.simpleText || '') + (v.publishedTimeText?.simpleText ? ' | ' + v.publishedTimeText.simpleText : ''), 110 url: 'https://www.youtube.com/watch?v=' + v.videoId, 111 }); 112 } 113 } 114 } 115 } 116 } 117 118 // If Home tab has no videos, try Videos tab 119 if (recentVideos.length === 0) { 120 const videosTab = tabs.find(t => { 121 const tab = t.tabRenderer; 122 const url = tab?.endpoint?.commandMetadata?.webCommandMetadata?.url || ''; 123 return tab?.tabIdentifier === 'VIDEOS' 124 || url.endsWith('/videos') 125 || tab?.title === 'Videos'; 126 }); 127 const videosTabParams = videosTab?.tabRenderer?.endpoint?.browseEndpoint?.params; 128 if (videosTabParams) { 129 const videosResp = await fetch('/youtubei/v1/browse?key=' + apiKey + '&prettyPrint=false', { 130 method: 'POST', credentials: 'include', 131 headers: {'Content-Type': 'application/json'}, 132 body: JSON.stringify({context, browseId, params: videosTabParams}) 133 }); 134 if (videosResp.ok) { 135 const videosData = await videosResp.json(); 136 const richGrid = videosData.contents?.twoColumnBrowseResultsRenderer?.tabs?.[0]?.tabRenderer?.content?.richGridRenderer?.contents || []; 137 for (const item of richGrid) { 138 if (recentVideos.length >= limit) break; 139 const v = item.richItemRenderer?.content?.videoRenderer; 140 if (v) { 141 recentVideos.push({ 142 title: v.title?.runs?.[0]?.text || '', 143 duration: v.lengthText?.simpleText || '', 144 views: (v.shortViewCountText?.simpleText || '') + (v.publishedTimeText?.simpleText ? ' | ' + v.publishedTimeText.simpleText : ''), 145 url: 'https://www.youtube.com/watch?v=' + v.videoId, 146 }); 147 } 148 } 149 } 150 } 151 } 152 153 return { 154 name: metadata.title || '', 155 channelId: metadata.externalId || browseId, 156 handle: metadata.vanityChannelUrl?.split('/').pop() || '', 157 description: (metadata.description || '').substring(0, 500), 158 subscribers: subscriberCount, 159 url: metadata.channelUrl || 'https://www.youtube.com/channel/' + browseId, 160 keywords: metadata.keywords || '', 161 recentVideos, 162 }; 163 })() 164 `); 165 if (!data || typeof data !== 'object') 166 throw new CommandExecutionError('Failed to fetch channel data'); 167 if (data.error) 168 throw new CommandExecutionError(String(data.error)); 169 const result = data; 170 const videos = result.recentVideos; 171 delete result.recentVideos; 172 // Channel info as field/value pairs + recent videos as table 173 const rows = Object.entries(result).map(([field, value]) => ({ 174 field, 175 value: String(value), 176 })); 177 if (videos && videos.length > 0) { 178 rows.push({ field: '---', value: '--- Recent Videos ---' }); 179 for (const v of videos) { 180 rows.push({ field: v.title, value: `${v.duration} | ${v.views} | ${v.url}` }); 181 } 182 } 183 return rows; 184 }, 185 });