youtube_crawler.js 5.98 KB
/**
 * YouTube 热门视频爬虫
 * 支持 API 和 yt-dlp 降级方案
 */

const { execSync } = require('child_process');

/**
 * 使用 YouTube Data API v3 获取热门视频
 */
async function getTrendingVideosAPI({ regionCode = 'US', maxResults = 10, videoCategoryId = null, apiKey }) {
  if (!apiKey) {
    throw new Error('API key is required');
  }

  const url = 'https://www.googleapis.com/youtube/v3/videos';
  const params = new URLSearchParams({
    part: 'snippet,statistics',
    chart: 'mostPopular',
    regionCode,
    maxResults: Math.min(Math.max(1, maxResults), 50).toString(),
    key: apiKey
  });

  if (videoCategoryId) {
    params.append('videoCategoryId', videoCategoryId);
  }

  const response = await fetch(`${url}?${params}`);

  if (!response.ok) {
    const error = await response.json().catch(() => ({}));
    throw new Error(`API error ${response.status}: ${error.error?.message || response.statusText}`);
  }

  const data = await response.json();

  return data.items.map(item => ({
    videoId: item.id,
    url: `https://youtube.com/watch?v=${item.id}`,
    title: item.snippet.title,
    channelTitle: item.snippet.channelTitle,
    viewCount: parseInt(item.statistics.viewCount, 10) || 0,
    thumbnail: item.snippet.thumbnails?.high?.url
      || item.snippet.thumbnails?.medium?.url
      || item.snippet.thumbnails?.default?.url,
    publishedAt: item.snippet.publishedAt
  }));
}

/**
 * 使用 yt-dlp 获取热门视频 (降级方案)
 */
async function getTrendingVideosYTDLP({ regionCode = 'US', maxResults = 10 }) {
  const url = regionCode === 'US'
    ? 'https://www.youtube.com/feed/trending'
    : `https://www.youtube.com/feed/trending?gl=${regionCode}`;

  const cmd = `yt-dlp --flat-playlist --playlist-end ${maxResults} -J "${url}" 2>/dev/null`;

  try {
    const output = execSync(cmd, { encoding: 'utf-8', timeout: 30000 });
    const data = JSON.parse(output);

    if (!data.entries || !Array.isArray(data.entries)) {
      throw new Error('Invalid yt-dlp response format');
    }

    return data.entries.map(entry => ({
      videoId: entry.id,
      url: entry.webpage_url || `https://youtube.com/watch?v=${entry.id}`,
      title: entry.title || 'Unknown',
      channelTitle: entry.uploader || entry.channel || 'Unknown',
      viewCount: entry.view_count || 0,
      thumbnail: entry.thumbnail || null,
      publishedAt: entry.upload_date
        ? `${entry.upload_date.slice(0, 4)}-${entry.upload_date.slice(4, 6)}-${entry.upload_date.slice(6, 8)}T00:00:00Z`
        : null
    }));
  } catch (error) {
    throw new Error(`yt-dlp failed: ${error.message}`);
  }
}

/**
 * 验证 regionCode 格式
 */
function validateRegionCode(code) {
  if (!code || typeof code !== 'string') return false;
  return /^[A-Z]{2}$/.test(code.toUpperCase());
}

/**
 * 生成 Mock 数据(用于测试和演示)
 */
function getMockTrendingVideos({ regionCode = 'US', maxResults = 10 } = {}) {
  const baseVideos = [
    {
      videoId: 'mock_001',
      url: 'https://youtube.com/watch?v=mock_001',
      title: 'Mock Trending Video: Global Hits 2024',
      channelTitle: 'Mock Music Channel',
      viewCount: 12500000,
      thumbnail: 'https://img.youtube.com/vi/mock_001/hqdefault.jpg',
      publishedAt: '2024-03-15T10:00:00Z'
    },
    {
      videoId: 'mock_002',
      url: 'https://youtube.com/watch?v=mock_002',
      title: 'Mock Gaming: Speedrun World Record',
      channelTitle: 'Mock Gaming',
      viewCount: 3200000,
      thumbnail: 'https://img.youtube.com/vi/mock_002/hqdefault.jpg',
      publishedAt: '2024-03-14T08:30:00Z'
    },
    {
      videoId: 'mock_003',
      url: 'https://youtube.com/watch?v=mock_003',
      title: 'Mock Tech Review: Latest Smartphone',
      channelTitle: 'Mock Tech',
      viewCount: 1800000,
      thumbnail: 'https://img.youtube.com/vi/mock_003/hqdefault.jpg',
      publishedAt: '2024-03-13T14:00:00Z'
    },
    {
      videoId: 'mock_004',
      url: 'https://youtube.com/watch?v=mock_004',
      title: 'Mock Comedy: Stand-up Special',
      channelTitle: 'Mock Comedy Central',
      viewCount: 5400000,
      thumbnail: 'https://img.youtube.com/vi/mock_004/hqdefault.jpg',
      publishedAt: '2024-03-12T20:00:00Z'
    },
    {
      videoId: 'mock_005',
      url: 'https://youtube.com/watch?v=mock_005',
      title: 'Mock Education: How Quantum Computers Work',
      channelTitle: 'Mock Education',
      viewCount: 890000,
      thumbnail: 'https://img.youtube.com/vi/mock_005/hqdefault.jpg',
      publishedAt: '2024-03-11T09:00:00Z'
    }
  ];

  return baseVideos.slice(0, maxResults).map(v => ({
    ...v,
    _mockRegion: regionCode.toUpperCase()
  }));
}

/**
 * 统一入口 - 获取 YouTube 热门视频
 */
async function getTrendingVideos(options = {}) {
  const {
    regionCode = 'US',
    maxResults = 10,
    videoCategoryId = null,
    useApi = true,
    apiKey = process.env.YOUTUBE_API_KEY,
    mock = false
  } = options;

  // 输入验证
  if (!validateRegionCode(regionCode)) {
    throw new Error(`Invalid regionCode: ${regionCode}. Must be 2-letter ISO code.`);
  }

  if (maxResults < 1 || maxResults > 50) {
    throw new Error(`Invalid maxResults: ${maxResults}. Must be between 1 and 50.`);
  }

  const normalizedRegion = regionCode.toUpperCase();

  // Mock 模式(用于测试和开发演示)
  if (mock || process.env.YOUTUBE_CRAWLER_MOCK === '1') {
    return getMockTrendingVideos({ regionCode: normalizedRegion, maxResults });
  }

  // 优先尝试 API
  if (useApi && apiKey) {
    try {
      return await getTrendingVideosAPI({
        regionCode: normalizedRegion,
        maxResults,
        videoCategoryId,
        apiKey
      });
    } catch (error) {
      console.warn(`[youtube-crawler] API failed: ${error.message}`);
      console.warn('[youtube-crawler] Falling back to yt-dlp...');
    }
  }

  // 降级到 yt-dlp
  return await getTrendingVideosYTDLP({ regionCode: normalizedRegion, maxResults });
}

module.exports = {
  getTrendingVideos,
  getTrendingVideosAPI,
  getTrendingVideosYTDLP,
  getMockTrendingVideos,
  validateRegionCode
};