Files
our_recipe_atlas/src/services/extractors/youtube.extractor.js
T
2026-08-13 18:36:31 +09:00

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,
},
};
}
}