import { Innertube } from 'youtubei.js'; import { AppError } from '../../utils/errors.js'; import { canonicalSourceUrl, extractYouTubeId } from '../../utils/url.js'; function textValue(value) { if (value == null) return ''; return typeof value === 'string' ? value : value.toString(); } export function readTranscriptSegments(transcriptInfo) { const segments = transcriptInfo?.transcript?.content?.body?.initial_segments ?? []; return segments .filter((segment) => segment?.snippet && segment?.start_ms != null) .map((segment) => ({ startSec: Number(segment.start_ms) / 1000, endSec: Number(segment.end_ms) / 1000, text: textValue(segment.snippet).trim(), })) .filter((segment) => Number.isFinite(segment.startSec) && segment.text); } export class YouTubeExtractor { constructor({ clientFactory } = {}) { this.clientFactory = clientFactory ?? (() => Innertube.create({ lang: 'ko', location: 'KR', retrieve_player: false, })); } async extract(sourceUrl) { const sourceId = extractYouTubeId(sourceUrl); let info; try { const client = await this.clientFactory(); info = await client.getInfo(sourceId); } catch (error) { throw new AppError('YouTube 정보를 가져오지 못했습니다.', { statusCode: 502, code: 'YOUTUBE_EXTRACTION_FAILED', cause: error, }); } const basic = info.basic_info ?? {}; const description = basic.short_description?.trim() ?? ''; let transcriptSegments = []; try { transcriptSegments = readTranscriptSegments(await info.getTranscript()); } catch { // 자막이 비활성화된 영상은 설명만으로 계속 분석한다. } if (!description && transcriptSegments.length === 0) { throw new AppError('YouTube 자막과 설명을 찾을 수 없습니다.', { statusCode: 422, code: 'YOUTUBE_TEXT_NOT_FOUND', }); } const transcriptText = transcriptSegments .map((segment) => `[${segment.startSec}] ${segment.text}`) .join('\n'); const rawText = [ description && `[DESCRIPTION]\n${description}`, transcriptText && `[TRANSCRIPT]\n${transcriptText}`, ].filter(Boolean).join('\n\n'); const thumbnails = basic.thumbnail ?? []; const thumbnail = thumbnails.length > 0 ? thumbnails[thumbnails.length - 1] : null; return { platform: 'youtube', sourceUrl: canonicalSourceUrl(sourceUrl), sourceId, title: basic.title?.trim() || null, author: basic.author?.trim() || basic.channel?.name?.trim() || null, thumbnailUrl: thumbnail?.url ?? null, rawText, metadata: { durationSec: basic.duration ?? null, transcript: transcriptSegments, }, }; } }