/ clis / youtube / channel.js
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  });