89 lines
2.7 KiB
JavaScript
89 lines
2.7 KiB
JavaScript
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,
|
|
},
|
|
};
|
|
}
|
|
}
|
|
|