first commit

This commit is contained in:
2026-08-13 18:36:31 +09:00
commit cee36bf12d
48 changed files with 4212 additions and 0 deletions
+27
View File
@@ -0,0 +1,27 @@
export class AppError extends Error {
constructor(message, { statusCode = 500, code = 'INTERNAL_ERROR', cause } = {}) {
super(message, { cause });
this.name = 'AppError';
this.statusCode = statusCode;
this.code = code;
}
}
export class ValidationError extends AppError {
constructor(message, options = {}) {
super(message, { ...options, statusCode: 400, code: options.code ?? 'VALIDATION_ERROR' });
}
}
export class NotFoundError extends AppError {
constructor(message = '요청한 항목을 찾을 수 없습니다.') {
super(message, { statusCode: 404, code: 'NOT_FOUND' });
}
}
export class ConflictError extends AppError {
constructor(message = '이미 등록된 레시피입니다.') {
super(message, { statusCode: 409, code: 'DUPLICATE_RECIPE' });
}
}
+94
View File
@@ -0,0 +1,94 @@
import { ValidationError } from './errors.js';
const YOUTUBE_HOSTS = new Set([
'youtube.com',
'www.youtube.com',
'm.youtube.com',
'music.youtube.com',
'youtu.be',
'www.youtu.be',
]);
const INSTAGRAM_HOSTS = new Set([
'instagram.com',
'www.instagram.com',
'm.instagram.com',
]);
const YOUTUBE_ID_PATTERN = /^[A-Za-z0-9_-]{11}$/;
const INSTAGRAM_SHORTCODE_PATTERN = /^[A-Za-z0-9_-]+$/;
export function parseSourceUrl(value) {
let url;
try {
url = new URL(value);
} catch {
throw new ValidationError('올바른 Instagram 또는 YouTube URL을 입력해 주세요.');
}
if (url.protocol !== 'https:') {
throw new ValidationError('가져오기 URL은 HTTPS여야 합니다.');
}
url.hash = '';
return url;
}
export function detectPlatform(value) {
const url = parseSourceUrl(value);
if (YOUTUBE_HOSTS.has(url.hostname.toLowerCase())) return 'youtube';
if (INSTAGRAM_HOSTS.has(url.hostname.toLowerCase())) return 'instagram';
return 'unsupported';
}
export function extractYouTubeId(value) {
const url = parseSourceUrl(value);
if (!YOUTUBE_HOSTS.has(url.hostname.toLowerCase())) {
throw new ValidationError('지원하지 않는 YouTube URL입니다.');
}
const pathParts = url.pathname.split('/').filter(Boolean);
let videoId = null;
if (url.hostname.toLowerCase().endsWith('youtu.be')) {
[videoId] = pathParts;
} else if (url.pathname === '/watch') {
videoId = url.searchParams.get('v');
} else if (['shorts', 'embed', 'live'].includes(pathParts[0])) {
videoId = pathParts[1];
}
if (!videoId || !YOUTUBE_ID_PATTERN.test(videoId)) {
throw new ValidationError('YouTube 영상 ID를 확인할 수 없습니다.');
}
return videoId;
}
export function extractInstagramShortcode(value) {
const url = parseSourceUrl(value);
if (!INSTAGRAM_HOSTS.has(url.hostname.toLowerCase())) {
throw new ValidationError('지원하지 않는 Instagram URL입니다.');
}
const [kind, shortcode] = url.pathname.split('/').filter(Boolean);
if (!['p', 'reel', 'reels', 'tv'].includes(kind) || !INSTAGRAM_SHORTCODE_PATTERN.test(shortcode ?? '')) {
throw new ValidationError('Instagram 게시물 shortcode를 확인할 수 없습니다.');
}
return shortcode;
}
export function canonicalSourceUrl(value) {
const platform = detectPlatform(value);
if (platform === 'youtube') {
return `https://www.youtube.com/watch?v=${extractYouTubeId(value)}`;
}
if (platform === 'instagram') {
const url = parseSourceUrl(value);
const [kind] = url.pathname.split('/').filter(Boolean);
const normalizedKind = kind === 'reels' ? 'reel' : kind;
return `https://www.instagram.com/${normalizedKind}/${extractInstagramShortcode(value)}/`;
}
throw new ValidationError('지원하지 않는 URL입니다.');
}