commit 860a81b86cf056847bcf7d471705520c481595a0 Author: bcjang Date: Fri Nov 28 15:58:57 2025 +0900 기본 diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..ed19bf6 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,18 @@ +{ + "permissions": { + "allow": [ + "Bash(./gradlew.bat clean test:*)", + "Bash(curl:*)", + "Bash(./gradlew.bat test:*)", + "Bash(./gradlew.bat:*)", + "Bash(find:*)", + "Bash(claude mcp:*)", + "WebFetch(domain:github.com)", + "mcp__serena__get_symbols_overview", + "mcp__serena__list_dir", + "mcp__serena__get_current_config" + ], + "deny": [], + "ask": [] + } +} diff --git a/.gitkeep b/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/.gradle/8.1.1/checksums/checksums.lock b/.gradle/8.1.1/checksums/checksums.lock new file mode 100644 index 0000000..a744523 Binary files /dev/null and b/.gradle/8.1.1/checksums/checksums.lock differ diff --git a/.gradle/8.1.1/dependencies-accessors/dependencies-accessors.lock b/.gradle/8.1.1/dependencies-accessors/dependencies-accessors.lock new file mode 100644 index 0000000..8debb61 Binary files /dev/null and b/.gradle/8.1.1/dependencies-accessors/dependencies-accessors.lock differ diff --git a/.gradle/8.1.1/dependencies-accessors/gc.properties b/.gradle/8.1.1/dependencies-accessors/gc.properties new file mode 100644 index 0000000..e69de29 diff --git a/.gradle/8.1.1/executionHistory/executionHistory.bin b/.gradle/8.1.1/executionHistory/executionHistory.bin new file mode 100644 index 0000000..d5cac28 Binary files /dev/null and b/.gradle/8.1.1/executionHistory/executionHistory.bin differ diff --git a/.gradle/8.1.1/executionHistory/executionHistory.lock b/.gradle/8.1.1/executionHistory/executionHistory.lock new file mode 100644 index 0000000..6f2a668 Binary files /dev/null and b/.gradle/8.1.1/executionHistory/executionHistory.lock differ diff --git a/.gradle/8.1.1/fileChanges/last-build.bin b/.gradle/8.1.1/fileChanges/last-build.bin new file mode 100644 index 0000000..f76dd23 Binary files /dev/null and b/.gradle/8.1.1/fileChanges/last-build.bin differ diff --git a/.gradle/8.1.1/fileHashes/fileHashes.bin b/.gradle/8.1.1/fileHashes/fileHashes.bin new file mode 100644 index 0000000..b1fe9c0 Binary files /dev/null and b/.gradle/8.1.1/fileHashes/fileHashes.bin differ diff --git a/.gradle/8.1.1/fileHashes/fileHashes.lock b/.gradle/8.1.1/fileHashes/fileHashes.lock new file mode 100644 index 0000000..b6c9750 Binary files /dev/null and b/.gradle/8.1.1/fileHashes/fileHashes.lock differ diff --git a/.gradle/8.1.1/fileHashes/resourceHashesCache.bin b/.gradle/8.1.1/fileHashes/resourceHashesCache.bin new file mode 100644 index 0000000..b0f826d Binary files /dev/null and b/.gradle/8.1.1/fileHashes/resourceHashesCache.bin differ diff --git a/.gradle/8.1.1/gc.properties b/.gradle/8.1.1/gc.properties new file mode 100644 index 0000000..e69de29 diff --git a/.gradle/buildOutputCleanup/buildOutputCleanup.lock b/.gradle/buildOutputCleanup/buildOutputCleanup.lock new file mode 100644 index 0000000..631f447 Binary files /dev/null and b/.gradle/buildOutputCleanup/buildOutputCleanup.lock differ diff --git a/.gradle/buildOutputCleanup/cache.properties b/.gradle/buildOutputCleanup/cache.properties new file mode 100644 index 0000000..27e6c46 --- /dev/null +++ b/.gradle/buildOutputCleanup/cache.properties @@ -0,0 +1,2 @@ +#Thu Nov 06 15:53:40 KST 2025 +gradle.version=8.1.1 diff --git a/.gradle/buildOutputCleanup/outputFiles.bin b/.gradle/buildOutputCleanup/outputFiles.bin new file mode 100644 index 0000000..0d50483 Binary files /dev/null and b/.gradle/buildOutputCleanup/outputFiles.bin differ diff --git a/.gradle/file-system.probe b/.gradle/file-system.probe new file mode 100644 index 0000000..2e9fd3b Binary files /dev/null and b/.gradle/file-system.probe differ diff --git a/.gradle/vcs-1/gc.properties b/.gradle/vcs-1/gc.properties new file mode 100644 index 0000000..e69de29 diff --git a/.idea/.name b/.idea/.name new file mode 100644 index 0000000..cf04665 --- /dev/null +++ b/.idea/.name @@ -0,0 +1 @@ +video-url-analyzer \ No newline at end of file diff --git a/.idea/compiler.xml b/.idea/compiler.xml new file mode 100644 index 0000000..b589d56 --- /dev/null +++ b/.idea/compiler.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/gradle.xml b/.idea/gradle.xml new file mode 100644 index 0000000..ce1c62c --- /dev/null +++ b/.idea/gradle.xml @@ -0,0 +1,16 @@ + + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..de0c428 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..6c0b863 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..85ef54e --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,48 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Language Preference + +**IMPORTANT**: Always communicate with the user in Korean (한국어) unless explicitly asked otherwise. + +## 기술 스택 +- **JAVA**: 17 +- Gradle + +## 개발 규칙 + +### 1. 코드 작성 규칙 +- **절대 모킹하지 않기**: 실제 동작하는 코드만 작성 +- **테스트 우선**: 테스트 커버리지 90% 이상 유지 +- **컴포넌트 네이밍**: PascalCase, 기능을 명확히 나타내는 이름 사용 +- **하드코딩 하지 않기** + +### 2. 패키지 버전 호환성 +- 새 패키지 추가 시 기존 의존성과 충돌 확인 + +### 3. 파일 구조 규칙 +- `CommonConstants` 분리 +- `util` 분리 + +### 4. 스크립트 명령어 + +## 특별 주의사항 +### 1. 절대 하지 말 것 +- Mock 데이터나 가짜 구현 사용 +- 타입 any 사용 +- 직접적인 DOM 조작 + +### 2. 권장사항 +- 실제 API 호출하는 코드 작성 +- 재사용 가능한 컴포넌트 설계 +- 접근성 고려 +- 성능 최적화 적용 + +### 3. 문제 해결 우선순위 +1. 실제 동작하는 해결책 찾기 +2. 기존 코드 패턴 분석 후 일관성 유지 +3. 타입 안정성 보장 +4. 테스트 가능한 구조로 설계 + + diff --git a/CODE_STRUCTURE.md b/CODE_STRUCTURE.md new file mode 100644 index 0000000..ed4e423 --- /dev/null +++ b/CODE_STRUCTURE.md @@ -0,0 +1,1245 @@ +# Video Analyzer 코드 구조 설명서 + +> 개인 학습 및 유지보수를 위한 상세 문서 + +## 📋 목차 + +1. [프로젝트 개요](#프로젝트-개요) +2. [전체 아키텍처](#전체-아키텍처) +3. [패키지 구조](#패키지-구조) +4. [핵심 컴포넌트](#핵심-컴포넌트) +5. [실행 흐름](#실행-흐름) +6. [플랫폼별 분석 방식](#플랫폼별-분석-방식) +7. [디자인 패턴](#디자인-패턴) +8. [확장 방법](#확장-방법) +9. [주요 상수 관리](#주요-상수-관리) + +--- + +## 프로젝트 개요 + +### 목적 +다양한 비디오 플랫폼의 URL을 분석하여 재생 가능 여부를 판단하는 라이브러리 + +### 주요 기능 +- ✅ 9개 플랫폼 지원 (네이버TV, 카카오TV, YouTube, TikTok, Twitter, Vimeo, 웨이보, 직접 URL) +- ✅ DRM 보호 여부 확인 +- ✅ 3D 비디오 여부 확인 +- ✅ AVPlayer 재생 가능 여부 확인 +- ✅ oEmbed API 활용 + +### 기술 스택 +- Java 17 +- Gradle +- Apache HttpClient +- Jackson (JSON 파싱) +- SLF4J (로깅) + +--- + +## 전체 아키텍처 + +``` +┌─────────────────────────────────────────────────────────┐ +│ VideoAnalyzer │ +│ (Entry Point) │ +└────────────────┬────────────────────────────────────────┘ + │ + ├──────────────────┬──────────────────────┐ + ▼ ▼ ▼ + ┌────────────────────┐ ┌─────────────────┐ ┌──────────────┐ + │ PlatformAnalyzer │ │ VideoAnalyzer │ │ Global │ + │ Factory │ │ Service │ │ (Constants) │ + └────────────────────┘ └─────────────────┘ └──────────────┘ + │ + ├─────────────────────────────────────┐ + │ │ + ▼ ▼ + ┌─────────────────┐ ┌──────────────────┐ + │ PlatformAnalyzer │ HttpBased │ + │ (Interface) │◄─────────────────│ PlatformAnalyzer │ + └─────────────────┘ │ (Abstract) │ + ▲ └──────────────────┘ + │ ▲ + │ │ + ┌────────┴─────────┬────────┬────────┬────────┴────────┐ + │ │ │ │ │ + ▼ ▼ ▼ ▼ ▼ +┌─────────┐ ┌──────────┐ ┌────┐ ┌────────┐ ┌─────────┐ +│ NaverTV │ │ KakaoTV │ │... │ │ Weibo │ │ Direct │ +│Analyzer │ │ Analyzer │ │ │ │Analyzer│ │ URL │ +└─────────┘ └──────────┘ └────┘ └────────┘ └─────────┘ +``` + +### 아키텍처 특징 + +1. **전략 패턴 (Strategy Pattern)** + - `PlatformAnalyzer` 인터페이스로 플랫폼별 분석 전략 정의 + - 각 플랫폼은 독립적인 분석 로직 구현 + +2. **팩토리 패턴 (Factory Pattern)** + - `PlatformAnalyzerFactory`가 URL에 맞는 분석기 선택 + - 새 플랫폼 추가 시 Factory에만 등록하면 됨 + +3. **템플릿 메서드 패턴 (Template Method Pattern)** + - `HttpBasedPlatformAnalyzer` 추상 클래스가 공통 로직 제공 + - 각 플랫폼은 응답 파싱 부분만 구현 + +--- + +## 패키지 구조 + +``` +com.caliverse +│ +├── analyzer +│ ├── VideoAnalyzer.java // 메인 진입점 +│ │ +│ ├── entity +│ │ └── UrlType.java // URL 타입 열거형 +│ │ +│ ├── model +│ │ └── VideoAnalysisResult.java // 분석 결과 DTO +│ │ +│ ├── platform +│ │ ├── PlatformAnalyzer.java // 플랫폼 분석기 인터페이스 +│ │ ├── PlatformAnalyzerFactory.java // 팩토리 클래스 +│ │ │ +│ │ └── impl +│ │ ├── HttpBasedPlatformAnalyzer.java // HTTP 기반 추상 클래스 +│ │ ├── NaverTvPlatformAnalyzer.java +│ │ ├── KakaoTvPlatformAnalyzer.java +│ │ ├── YouTubePlatformAnalyzer.java +│ │ ├── VimeoPlatformAnalyzer.java +│ │ ├── TikTokPlatformAnalyzer.java +│ │ ├── TwitterPlatformAnalyzer.java +│ │ ├── WeiboPlatformAnalyzer.java +│ │ └── DirectUrlPlatformAnalyzer.java +│ │ +│ └── service +│ └── VideoAnalyzerService.java // 일반 비디오 분석 서비스 +│ +└── global + ├── common + │ ├── CommonConstants.java // 공통 상수 (도메인, API, 헤더 등) + │ └── Messages.java // 메시지 상수 (로그, 에러) + │ + └── util + ├── HttpUtils.java // HTTP 유틸리티 + └── UrlParser.java // URL 파싱 유틸리티 +``` + +--- + +## 핵심 컴포넌트 + +### 1. VideoAnalyzer (메인 클래스) + +**역할**: 외부 API로 사용되는 진입점 + +**주요 메서드**: +```java +public VideoAnalysisResult analyzeUrl(String url) +public String[] getSupportedPlatforms() +``` + +**동작**: +1. `PlatformAnalyzerFactory`에서 URL에 맞는 분석기 찾기 +2. 플랫폼 분석기가 있으면 해당 분석기 사용 +3. 없으면 `VideoAnalyzerService`로 일반 비디오 분석 + +**사용 예시**: +```java +VideoAnalyzer analyzer = new VideoAnalyzer(); +VideoAnalysisResult result = analyzer.analyzeUrl("https://tv.naver.com/v/84373511"); + +if (result.isSuccess()) { + System.out.println("재생 가능: " + result.getReason()); +} else { + System.out.println("재생 불가: " + result.getReason()); +} +``` + +--- + +### 2. PlatformAnalyzer (인터페이스) + +**역할**: 모든 플랫폼 분석기가 구현해야 하는 계약 정의 + +**메서드**: +```java +boolean canHandle(String url) // 이 플랫폼 URL인지 확인 +String getPlatformName() // 플랫폼 이름 반환 +VideoAnalysisResult analyze(String url) // URL 분석 수행 +``` + +**구현 원칙**: +- `canHandle()`: 빠르게 판단 가능해야 함 (정규식, 도메인 체크) +- `analyze()`: 실제 HTTP 요청 및 분석 수행 +- Thread-safe 해야 함 (상태를 갖지 않음) + +--- + +### 3. PlatformAnalyzerFactory (팩토리) + +**역할**: URL에 맞는 분석기 선택 및 생성 + +**등록 순서** (중요!): +```java +analyzers.add(new NaverTvPlatformAnalyzer()); +analyzers.add(new KakaoTvPlatformAnalyzer()); +analyzers.add(new YouTubePlatformAnalyzer()); +analyzers.add(new VimeoPlatformAnalyzer()); +analyzers.add(new TikTokPlatformAnalyzer()); +analyzers.add(new TwitterPlatformAnalyzer()); +analyzers.add(new WeiboPlatformAnalyzer()); +analyzers.add(new DirectUrlPlatformAnalyzer()); // 가장 마지막 (fallback) +``` + +**왜 순서가 중요한가?** +- `DirectUrlPlatformAnalyzer`는 모든 비디오 확장자를 처리 +- 마지막에 배치하여 다른 플랫폼 우선 확인 + +**동작 방식**: +```java +public PlatformAnalyzer findAnalyzer(String url) { + for (PlatformAnalyzer analyzer : analyzers) { + if (analyzer.canHandle(url)) { + return analyzer; // 첫 번째로 매칭되는 분석기 반환 + } + } + return null; +} +``` + +--- + +### 4. HttpBasedPlatformAnalyzer (추상 클래스) + +**역할**: HTTP 기반 플랫폼의 공통 로직 제공 (Template Method Pattern) + +**템플릿 메서드**: +```java +@Override +public final VideoAnalysisResult analyze(String url) { + try { + // 1. API URL 구성 + String endpoint = getApiEndpoint(); // 추상 메서드 + + // 2. HTTP 요청 + String response = fetchApiResponse(endpoint, url); + + // 3. 응답 파싱 (각 플랫폼에서 구현) + VideoAnalysisResult result = parseResponse(response, url); // 추상 메서드 + + // 4. 로깅 및 결과 반환 + return result; + } catch (Exception e) { + // 공통 예외 처리 + } +} +``` + +**하위 클래스가 구현해야 할 메서드**: +```java +protected abstract String getApiEndpoint(); // API URL +protected abstract String getPlatformDomain(); // 도메인 +protected abstract VideoAnalysisResult parseResponse(...); // 응답 파싱 +``` + +**제공하는 유틸리티**: +```java +protected String buildApiUrl(String endpoint, String videoUrl) // API URL 생성 +protected String getStringValue(JsonNode node, String fieldName) // JSON 파싱 +protected Integer getIntValue(JsonNode node, String fieldName) // JSON 파싱 +``` + +--- + +### 5. VideoAnalysisResult (결과 DTO) + +**역할**: 분석 결과를 담는 불변 객체 + +**구조**: +```java +public class VideoAnalysisResult { + private final boolean success; // 성공 여부 + private final String reason; // 상세 이유 + + public static VideoAnalysisResult success() { ... } + public static VideoAnalysisResult failure(String reason) { ... } +} +``` + +**사용 패턴**: +```java +// 성공 +return VideoAnalysisResult.success(); + +// 실패 +return VideoAnalysisResult.failure("DRM이 적용된 콘텐츠입니다"); +``` + +--- + +## 실행 흐름 + +### 전체 흐름도 + +``` +[사용자] + │ + │ analyzeUrl("https://tv.naver.com/v/123") + ▼ +[VideoAnalyzer] + │ + │ findAnalyzer(url) + ▼ +[PlatformAnalyzerFactory] + │ + │ 순회: canHandle(url) 체크 + ▼ +[NaverTvPlatformAnalyzer] ✓ 매칭! + │ + │ analyze(url) + ▼ +[HttpBasedPlatformAnalyzer] (Template) + │ + ├─ getApiEndpoint() → "https://tv.naver.com/oembed" + ├─ buildApiUrl() → "https://tv.naver.com/oembed?url=..." + ├─ fetchApiResponse() → HTTP GET 요청 + │ + │ parseResponse(response) ← 하위 클래스 구현 + ▼ +[NaverTvPlatformAnalyzer] + │ + ├─ JSON 파싱 + ├─ 유효성 검증 + ├─ oEmbed 응답 확인 + │ └─ 실패 시 fallback (HTML 파싱) + │ + ▼ +[VideoAnalysisResult] + │ + │ return result + ▼ +[사용자] +``` + +### 상세 흐름 (네이버TV 예시) + +#### 1단계: URL 매칭 +```java +// PlatformAnalyzerFactory.findAnalyzer() +URL: "https://tv.naver.com/v/123" + +→ NaverTvPlatformAnalyzer.canHandle() + → lowerUrl.contains("tv.naver.com") || lowerUrl.contains("naver.me") + → ✓ true 반환 + +→ NaverTvPlatformAnalyzer 인스턴스 반환 +``` + +#### 2단계: 분석 실행 +```java +// NaverTvPlatformAnalyzer.analyze() → HttpBasedPlatformAnalyzer.analyze() + +1. API URL 생성 + getApiEndpoint() → "https://tv.naver.com/oembed" + buildApiUrl() → "https://tv.naver.com/oembed?url=https%3A%2F%2Ftv.naver.com%2Fv%2F123&format=json" + +2. HTTP 요청 + HttpClient.execute(GET) + └─ User-Agent: CommonConstants.USER_AGENT_STANDARD + └─ Accept: CommonConstants.ACCEPT_JSON_HTML + +3. 응답 수신 + { + "type": "video", + "title": "비디오 제목", + "provider_name": "NAVERTV", + "width": 640, + "height": 360, + ... + } +``` + +#### 3단계: 응답 파싱 +```java +// NaverTvPlatformAnalyzer.parseResponse() + +1. JSON 파싱 + JsonNode rootNode = objectMapper.readTree(response) + +2. 필드 추출 + type = rootNode.get("type") + title = rootNode.get("title") + width = rootNode.get("width") + height = rootNode.get("height") + providerName = rootNode.get("provider_name") + +3. 검증 + if (providerName != "NAVERTV") → 경고 로그 + if (width == 0 || height == 0) → Shorts로 판단, fallback + if (type == null || (title == null && html == null)) → 실패 + +4. 결과 반환 + → VideoAnalysisResult.success() +``` + +#### 4단계: Fallback 처리 (Shorts인 경우) + +```java +// fallbackToVideoIdExtraction() + +1. URL에서 videoId 추출 + Matcher matcher = VIDEO_ID_PATTERN.matcher(url) + videoId = "123" + +2. HTML 파싱 + - __NEXT_DATA__ 스크립트 태그 찾기 + - JSON 데이터 추출 + - props.pageProps.clipInfo 경로로 메타데이터 추출 + +3. 최소 메타데이터 구성 + embedUrl = "https://tv.naver.com/embed/123?autoPlay=true" + + → VideoAnalysisResult.success() +``` + +--- + +## 플랫폼별 분석 방식 + +### 1. 네이버TV (NaverTvPlatformAnalyzer) + +**지원 URL 형식**: +- VOD: `https://tv.naver.com/v/{video_id}` +- Live: `https://tv.naver.com/l/{video_id}` +- Shorts: `https://tv.naver.com/h/{video_id}` +- 단축 URL: `https://naver.me/{short_code}` + +**분석 방식**: +1. **oEmbed API 우선 시도** + - Endpoint: `https://tv.naver.com/oembed` + - VOD/Live는 정상 응답 + - Shorts는 width=0, height=0으로 응답 (무효) + +2. **Fallback: HTML 파싱** + - `__NEXT_DATA__` 스크립트 태그 파싱 + - `props.pageProps.clipInfo` 경로에서 메타데이터 추출 + - videoId로 embed URL 구성 + +3. **단축 URL 처리** + - HttpClient가 자동으로 리다이렉트 따라감 + - 최종 URL에서 videoId 추출 + +**특이사항**: +- Shorts는 oEmbed 미지원 → 반드시 fallback 필요 +- 단축 URL은 리다이렉트 후 처리 + +--- + +### 2. 카카오TV (KakaoTvPlatformAnalyzer) + +**지원 URL 형식**: +- `https://tv.kakao.com/channel/{channel_id}/cliplink/{clip_id}` +- `https://tv.kakao.com/v/{video_id}` + +**분석 방식**: +- oEmbed API: `https://tv.kakao.com/oembed` +- 표준 oEmbed JSON 응답 +- Provider: "kakaoTV" + +--- + +### 3. YouTube (YouTubePlatformAnalyzer) + +**지원 URL 형식**: +- `https://www.youtube.com/watch?v={video_id}` +- `https://youtu.be/{video_id}` + +**분석 방식**: +- oEmbed API: `https://www.youtube.com/oembed` +- Provider: "YouTube" +- Version: "1.0" + +**제한사항**: +- oEmbed는 가능하지만 YouTube 전용 player에서만 재생 가능 +- 직접 비디오 URL 추출은 불가능 (YouTube API 필요) + +--- + +### 4. TikTok (TikTokPlatformAnalyzer) + +**지원 URL 형식**: +- `https://www.tiktok.com/@{username}/video/{video_id}` +- `https://vm.tiktok.com/{short_code}` (단축 URL) + +**분석 방식**: +- oEmbed API: `https://www.tiktok.com/oembed` +- type 검증: 반드시 "video" + +--- + +### 5. Twitter/X (TwitterPlatformAnalyzer) + +**지원 URL 형식**: +- `https://twitter.com/{username}/status/{tweet_id}` +- `https://x.com/{username}/status/{tweet_id}` + +**분석 방식**: +- oEmbed API: `https://publish.twitter.com/oembed` +- type 검증: "rich" 또는 "video" +- Provider: "Twitter" + +--- + +### 6. Vimeo (VimeoPlatformAnalyzer) + +**지원 URL 형식**: +- `https://vimeo.com/{video_id}` + +**분석 방식**: +- oEmbed API: `https://vimeo.com/api/oembed.json` +- 표준 oEmbed 응답 + +--- + +### 7. 웨이보 (WeiboPlatformAnalyzer) + +**지원 URL 형식**: +- `https://weibo.com/tv/show/{video_id}` +- `https://video.weibo.com/show?fid={video_id}` +- `https://m.weibo.cn/status/{status_id}` +- `https://weibo.com/{user_id}/{status_id}` + +**분석 방식** (oEmbed 미지원): +1. **HTML 파싱** + - Open Graph 메타태그에서 비디오 URL 추출 + - `og:video`, `og:title`, `og:image` + +2. **$render_data 스크립트 파싱** + - 웨이보의 동적 데이터 스크립트 + - JSON 구조: `[{ status: { page_info: { ... } } }]` + +**특수 헤더 요구**: +```java +User-Agent: CommonConstants.USER_AGENT_WEIBO // 웨이보 전용 +Accept-Language: "zh-CN,zh;q=0.9,en;q=0.8" +Referer: "https://weibo.com" +``` + +**특이사항**: +- 웨이보는 User-Agent를 엄격히 확인 +- 공식 API 없음 → HTML 파싱 방식 사용 + +--- + +### 8. 직접 URL (DirectUrlPlatformAnalyzer) + +**지원 포맷**: +```java +{".mp4", ".m3u8", ".mpd", ".webm", ".mov", ".avi", ".mkv"} +``` + +**분석 방식**: +1. **확장자 체크** + - URL에서 쿼리 파라미터 제거 + - 확장자가 비디오 포맷인지 확인 + +2. **HTTP HEAD 요청** + - 접근 가능 여부 확인 + - 상태 코드 체크 (200 성공) + +3. **CORS 확인** + - `Access-Control-Allow-Origin` 헤더 확인 + - CORS 미설정 시 경고 (브라우저 재생 제한 가능성) + +4. **DRM 감지** + - 헤더에 "drm", "protection", "encryption" 키워드 포함 시 DRM 판정 + +5. **인증 확인** + - URL에 `signature=`, `token=`, `key=` 파라미터 있으면 인증 필요 + - 헤더에 "signature", "token" 있으면 인증 필요 + +**검증 항목**: +- ✅ HTTP 접근 가능 여부 +- ⚠️ CORS 제한 (경고만, 실패 아님) +- ❌ DRM 보호 (실패) +- ❌ 인증 필요 (실패) + +--- + +## 디자인 패턴 + +### 1. Strategy Pattern (전략 패턴) + +**적용 위치**: `PlatformAnalyzer` 인터페이스 + +**목적**: 플랫폼마다 다른 분석 알고리즘을 캡슐화 + +**장점**: +- 새 플랫폼 추가가 쉬움 (OCP 준수) +- 각 플랫폼이 독립적으로 변경 가능 +- 런타임에 전략 선택 가능 + +**구현**: +```java +// 전략 인터페이스 +public interface PlatformAnalyzer { + VideoAnalysisResult analyze(String url); +} + +// 구체적 전략들 +public class NaverTvPlatformAnalyzer implements PlatformAnalyzer { ... } +public class YouTubePlatformAnalyzer implements PlatformAnalyzer { ... } + +// 컨텍스트 +public class VideoAnalyzer { + public VideoAnalysisResult analyzeUrl(String url) { + PlatformAnalyzer analyzer = platformFactory.findAnalyzer(url); + return analyzer.analyze(url); // 전략 실행 + } +} +``` + +--- + +### 2. Factory Pattern (팩토리 패턴) + +**적용 위치**: `PlatformAnalyzerFactory` + +**목적**: URL에 맞는 분석기 생성 로직 캡슐화 + +**장점**: +- 객체 생성 로직을 한 곳에 집중 +- 클라이언트는 구체 클래스를 몰라도 됨 +- 새 플랫폼 추가 시 Factory만 수정 + +**구현**: +```java +public class PlatformAnalyzerFactory { + private final List analyzers; + + public PlatformAnalyzerFactory() { + // 팩토리에서 모든 분석기 생성 및 등록 + analyzers.add(new NaverTvPlatformAnalyzer()); + analyzers.add(new KakaoTvPlatformAnalyzer()); + // ... + } + + public PlatformAnalyzer findAnalyzer(String url) { + // URL에 맞는 분석기 찾기 + for (PlatformAnalyzer analyzer : analyzers) { + if (analyzer.canHandle(url)) { + return analyzer; + } + } + return null; + } +} +``` + +--- + +### 3. Template Method Pattern (템플릿 메서드 패턴) + +**적용 위치**: `HttpBasedPlatformAnalyzer` 추상 클래스 + +**목적**: HTTP 기반 플랫폼의 공통 알고리즘 정의, 세부 단계는 하위 클래스에서 구현 + +**장점**: +- 코드 중복 제거 +- 공통 로직 변경 시 한 곳만 수정 +- 하위 클래스는 차이점만 구현 + +**구현**: +```java +public abstract class HttpBasedPlatformAnalyzer implements PlatformAnalyzer { + + // 템플릿 메서드 (알고리즘 골격) + @Override + public VideoAnalysisResult analyze(String url) { + try { + logger.debug(Messages.LOG_ANALYSIS_START, getPlatformName(), url); + + // 1. API URL 가져오기 (하위 클래스 구현) + String endpoint = getApiEndpoint(); + + // 2. HTTP 요청 (공통 로직) + String response = fetchApiResponse(endpoint, url); + + // 3. 응답 파싱 (하위 클래스 구현) + VideoAnalysisResult result = parseResponse(response, url); + + // 4. 로깅 (공통 로직) + if (result.isSuccess()) { + logger.debug(Messages.LOG_VALIDATION_SUCCESS, getPlatformName()); + } + + return result; + } catch (Exception e) { + // 공통 예외 처리 + } + } + + // 추상 메서드들 (하위 클래스가 구현) + protected abstract String getApiEndpoint(); + protected abstract String getPlatformDomain(); + protected abstract VideoAnalysisResult parseResponse(String response, String originalUrl); + + // 공통 메서드들 + private String fetchApiResponse(String endpoint, String videoUrl) { ... } + protected String getStringValue(JsonNode node, String fieldName) { ... } +} +``` + +**하위 클래스 예시**: +```java +public class KakaoTvPlatformAnalyzer extends HttpBasedPlatformAnalyzer { + + @Override + protected String getApiEndpoint() { + return CommonConstants.API_ENDPOINT_KAKAO_TV; + } + + @Override + protected String getPlatformDomain() { + return CommonConstants.DOMAIN_KAKAO_TV; + } + + @Override + protected VideoAnalysisResult parseResponse(String response, String originalUrl) { + // 카카오TV 특화 파싱 로직 + JsonNode rootNode = objectMapper.readTree(response); + // ... + } +} +``` + +--- + +### 4. Immutable Object Pattern (불변 객체 패턴) + +**적용 위치**: `VideoAnalysisResult` + +**목적**: 결과 객체의 안전한 공유 및 Thread-safety 보장 + +**장점**: +- Thread-safe +- 예측 가능한 동작 +- 캐싱 가능 + +**구현**: +```java +public class VideoAnalysisResult { + private final boolean success; // final 필드 + private final String reason; // final 필드 + + // private 생성자 + private VideoAnalysisResult(boolean success, String reason) { + this.success = success; + this.reason = reason; + } + + // 정적 팩토리 메서드 + public static VideoAnalysisResult success() { + return new VideoAnalysisResult(true, Messages.SUCCESS_ALL_CHECKS_PASSED); + } + + public static VideoAnalysisResult failure(String reason) { + return new VideoAnalysisResult(false, reason); + } + + // Getter만 제공 (Setter 없음) + public boolean isSuccess() { return success; } + public String getReason() { return reason; } +} +``` + +--- + +## 확장 방법 + +### 새 플랫폼 추가하기 + +#### Step 1: Analyzer 클래스 생성 + +oEmbed를 지원하는 플랫폼의 경우: +```java +package com.caliverse.analyzer.platform.impl; + +import com.caliverse.global.common.CommonConstants; +import com.caliverse.global.common.Messages; + +public class NewPlatformAnalyzer extends HttpBasedPlatformAnalyzer { + + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Override + protected String getApiEndpoint() { + return CommonConstants.API_ENDPOINT_NEW_PLATFORM; // 1. 상수 추가 필요 + } + + @Override + protected String getPlatformDomain() { + return CommonConstants.DOMAIN_NEW_PLATFORM; // 2. 상수 추가 필요 + } + + @Override + public String getPlatformName() { + return CommonConstants.PLATFORM_NAME_NEW_PLATFORM; // 3. 상수 추가 필요 + } + + @Override + protected VideoAnalysisResult parseResponse(String response, String originalUrl) { + try { + JsonNode rootNode = objectMapper.readTree(response); + + // 플랫폼별 필드 추출 + String type = getStringValue(rootNode, "type"); + String title = getStringValue(rootNode, "title"); + + // 유효성 검증 + if (type == null || title == null) { + return VideoAnalysisResult.failure(Messages.NEW_PLATFORM_INVALID); // 4. 메시지 추가 필요 + } + + logger.debug(Messages.LOG_OEMBED_VALIDATION_SUCCESS, getPlatformName(), title); + return VideoAnalysisResult.success(); + + } catch (Exception e) { + throw new IOException(Messages.format(Messages.NEW_PLATFORM_PARSING_FAILED, e.getMessage())); + } + } +} +``` + +oEmbed를 지원하지 않는 플랫폼의 경우: +```java +public class NewPlatformAnalyzer implements PlatformAnalyzer { + + @Override + public boolean canHandle(String url) { + return url != null && url.toLowerCase().contains("newplatform.com"); + } + + @Override + public String getPlatformName() { + return CommonConstants.PLATFORM_NAME_NEW_PLATFORM; + } + + @Override + public VideoAnalysisResult analyze(String url) { + try { + // 커스텀 분석 로직 (HTML 파싱, API 호출 등) + String htmlContent = fetchHtmlContent(url); + return parseHtmlMetadata(htmlContent); + } catch (Exception e) { + return VideoAnalysisResult.failure(Messages.format(Messages.NEW_PLATFORM_ERROR, e.getMessage())); + } + } +} +``` + +--- + +#### Step 2: CommonConstants에 상수 추가 + +```java +// CommonConstants.java에 추가 + +// 플랫폼 이름 +public static final String PLATFORM_NAME_NEW_PLATFORM = "새플랫폼"; + +// 도메인 +public static final String DOMAIN_NEW_PLATFORM = "newplatform.com"; + +// API 엔드포인트 +public static final String API_ENDPOINT_NEW_PLATFORM = "https://newplatform.com/api/oembed"; + +// Provider (필요시) +public static final String PROVIDER_NEW_PLATFORM = "NewPlatform"; +``` + +--- + +#### Step 3: Messages에 메시지 추가 + +```java +// Messages.java에 추가 + +// 에러 메시지 +public static final String NEW_PLATFORM_INVALID = "새플랫폼 응답이 유효하지 않습니다"; +public static final String NEW_PLATFORM_PARSING_FAILED = "새플랫폼 응답 파싱 실패: %s"; +public static final String NEW_PLATFORM_ERROR = "새플랫폼 분석 중 오류 발생: %s"; +``` + +--- + +#### Step 4: Factory에 등록 + +```java +// PlatformAnalyzerFactory.java + +public PlatformAnalyzerFactory() { + this.analyzers = new ArrayList<>(); + + analyzers.add(new NaverTvPlatformAnalyzer()); + analyzers.add(new KakaoTvPlatformAnalyzer()); + // ... 기존 플랫폼들 + + analyzers.add(new NewPlatformAnalyzer()); // ← 추가 + + analyzers.add(new DirectUrlPlatformAnalyzer()); // 항상 마지막 +} +``` + +--- + +#### Step 5: 테스트 작성 + +```java +// NewPlatformAnalyzerTest.java + +@Test +void testNewPlatformUrl() { + NewPlatformAnalyzer analyzer = new NewPlatformAnalyzer(); + + // canHandle 테스트 + assertTrue(analyzer.canHandle("https://newplatform.com/video/123")); + assertFalse(analyzer.canHandle("https://other.com/video/123")); + + // analyze 테스트 + VideoAnalysisResult result = analyzer.analyze("https://newplatform.com/video/123"); + assertTrue(result.isSuccess()); +} +``` + +--- + +### 기존 플랫폼 수정하기 + +#### 1. 로직 변경 +- 각 Analyzer 클래스에서 직접 수정 +- 공통 로직은 `HttpBasedPlatformAnalyzer`에서 수정 + +#### 2. 상수 변경 +- `CommonConstants.java` 또는 `Messages.java`에서 수정 +- 변경하면 모든 사용처에 자동 반영 + +#### 3. API 엔드포인트 변경 +```java +// CommonConstants.java에서만 수정 +public static final String API_ENDPOINT_NAVER_TV = "https://new-api.naver.com/oembed"; +// → 모든 NaverTvPlatformAnalyzer에 자동 반영 +``` + +--- + +## 주요 상수 관리 + +### CommonConstants.java 구조 + +#### 1. 플랫폼 정보 +```java +// 플랫폼 이름 +public static final String PLATFORM_NAME_NAVER_TV = "네이버TV"; +public static final String PLATFORM_NAME_KAKAO_TV = "카카오TV"; +// ... + +// 도메인 +public static final String DOMAIN_NAVER_TV = "tv.naver.com"; +public static final String DOMAIN_NAVER_SHORT = "naver.me"; +// ... + +// API 엔드포인트 +public static final String API_ENDPOINT_NAVER_TV = "https://tv.naver.com/oembed"; +public static final String API_ENDPOINT_KAKAO_TV = "https://tv.kakao.com/oembed"; +// ... + +// Provider 이름 +public static final String PROVIDER_NAVER_TV = "NAVERTV"; +public static final String PROVIDER_KAKAO_TV = "kakaoTV"; +// ... +``` + +#### 2. HTTP 관련 +```java +// 헤더 이름 +public static final String HEADER_USER_AGENT = "User-Agent"; +public static final String HEADER_REFERER = "Referer"; +public static final String HEADER_ACCEPT = "Accept"; + +// User-Agent 값 +public static final String USER_AGENT_STANDARD = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"; +public static final String USER_AGENT_WEIBO = "Mozilla/5.0 ... Chrome/120.0.0.0 Safari/537.36"; + +// Accept 값 +public static final String ACCEPT_ALL = "*/*"; +public static final String ACCEPT_JSON_HTML = "application/json, text/html"; + +// 타임아웃 +public static final int HTTP_CONNECTION_TIMEOUT = 5000; // 5초 +public static final int HTTP_SOCKET_TIMEOUT = 5000; // 5초 +``` + +#### 3. 비디오 관련 +```java +// 확장자 +public static final String[] VIDEO_EXTENSIONS = {".mp4", ".m3u8", ".mpd", ".webm", ".mov", ".avi", ".mkv"}; + +// 컨텐츠 타입 +public static final String CONTENT_TYPE_VIDEO = "video"; +public static final String CONTENT_TYPE_RICH = "rich"; + +// 버전 +public static final String VERSION_OEMBED_1_0 = "1.0"; +``` + +--- + +### Messages.java 구조 + +#### 1. 일반 메시지 +```java +public static final String SUCCESS_ALL_CHECKS_PASSED = "모든 검사 통과"; +public static final String ERROR_INVALID_URL = "유효하지 않은 URL"; +``` + +#### 2. 플랫폼별 에러 메시지 +```java +// 네이버TV +public static final String NAVER_TV_OEMBED_INVALID = "네이버TV oEmbed 응답이 유효하지 않습니다"; +public static final String NAVER_TV_FALLBACK_FAILED = "네이버TV fallback 처리 실패: %s"; + +// 카카오TV +public static final String KAKAO_TV_OEMBED_INVALID = "카카오TV oEmbed 응답이 유효하지 않습니다"; +public static final String KAKAO_TV_PARSING_FAILED = "카카오TV 응답 파싱 실패: %s"; + +// ... 각 플랫폼별 메시지 +``` + +#### 3. 공통 로그 메시지 +```java +public static final String LOG_ANALYSIS_START = "%s URL 분석 시작: %s"; +public static final String LOG_VALIDATION_SUCCESS = "%s 검증 성공"; +public static final String LOG_VALIDATION_FAILED = "%s 검증 실패: %s"; +public static final String LOG_OEMBED_VALIDATION_SUCCESS = "%s oEmbed 검증 성공: %s"; +``` + +#### 4. 포맷 메서드 +```java +/** + * 포맷 문자열을 사용하는 메시지를 생성합니다. + */ +public static String format(String format, Object... args) { + return String.format(format, args); +} +``` + +**사용 예시**: +```java +// 방법 1: 직접 String.format +String errorMsg = String.format(Messages.NAVER_TV_FALLBACK_FAILED, e.getMessage()); + +// 방법 2: Messages.format() 사용 +String errorMsg = Messages.format(Messages.NAVER_TV_FALLBACK_FAILED, e.getMessage()); +``` + +--- + +### 상수 사용 원칙 + +#### 1. 하드코딩 금지 +❌ **나쁜 예**: +```java +logger.debug("네이버TV URL 분석 시작: {}", url); +return VideoAnalysisResult.failure("네이버TV oEmbed 응답이 유효하지 않습니다"); +``` + +✅ **좋은 예**: +```java +logger.debug(Messages.LOG_ANALYSIS_START, CommonConstants.PLATFORM_NAME_NAVER_TV, url); +return VideoAnalysisResult.failure(Messages.NAVER_TV_OEMBED_INVALID); +``` + +#### 2. 동적 메시지는 포맷 사용 +❌ **나쁜 예**: +```java +String errorMsg = "네이버TV fallback 처리 실패: " + e.getMessage(); +``` + +✅ **좋은 예**: +```java +String errorMsg = Messages.format(Messages.NAVER_TV_FALLBACK_FAILED, e.getMessage()); +``` + +#### 3. 플랫폼 정보는 상수 사용 +❌ **나쁜 예**: +```java +private static final String PLATFORM_NAME = "네이버TV"; +private static final String API_ENDPOINT = "https://tv.naver.com/oembed"; +``` + +✅ **좋은 예**: +```java +@Override +public String getPlatformName() { + return CommonConstants.PLATFORM_NAME_NAVER_TV; +} + +@Override +protected String getApiEndpoint() { + return CommonConstants.API_ENDPOINT_NAVER_TV; +} +``` + +--- + +## 유용한 팁 + +### 1. 디버깅 방법 + +**로그 레벨 설정**: +```properties +# logback.xml + +``` + +**주요 로그 포인트**: +- `LOG_ANALYSIS_START`: 분석 시작 +- `LOG_API_URL`: API 호출 URL +- `LOG_OEMBED_VALIDATION_SUCCESS`: 검증 성공 +- `LOG_VALIDATION_FAILED`: 검증 실패 + +### 2. 성능 최적화 + +**HttpClient 재사용**: +```java +// ❌ 나쁜 예: 매번 생성 +try (CloseableHttpClient httpClient = HttpClients.createDefault()) { + // ... +} + +// ✅ 좋은 예: 싱글톤 또는 필드로 재사용 +private static final CloseableHttpClient HTTP_CLIENT = HttpClients.createDefault(); +``` + +**타임아웃 설정**: +```java +RequestConfig requestConfig = RequestConfig.custom() + .setConnectTimeout(CommonConstants.HTTP_CONNECTION_TIMEOUT) + .setSocketTimeout(CommonConstants.HTTP_SOCKET_TIMEOUT) + .build(); +``` + +### 3. 테스트 전략 + +**단위 테스트**: +- 각 Analyzer의 `canHandle()` 테스트 +- `parseResponse()` 테스트 (Mock 응답 사용) + +**통합 테스트**: +- 실제 URL로 `analyze()` 테스트 +- 네트워크 의존성 있음 (주의) + +**테스트 URL 예시**: +```java +// 네이버TV VOD +"https://tv.naver.com/v/84373511" + +// 네이버TV Shorts +"https://tv.naver.com/h/40687083" + +// 카카오TV +"https://tv.kakao.com/v/423707209" + +// YouTube +"https://www.youtube.com/watch?v=dQw4w9WgXcQ" +``` + +--- + +## 문제 해결 가이드 + +### 1. oEmbed API 호출 실패 + +**증상**: API 호출 시 4xx/5xx 에러 + +**원인**: +- URL 인코딩 문제 +- User-Agent 차단 +- API 엔드포인트 변경 + +**해결**: +```java +// User-Agent 확인 +request.setHeader(CommonConstants.HEADER_USER_AGENT, CommonConstants.USER_AGENT_STANDARD); + +// URL 인코딩 확인 +String encodedUrl = URLEncoder.encode(videoUrl, StandardCharsets.UTF_8.toString()); + +// API URL 로그 확인 +logger.debug(Messages.LOG_API_URL, apiUrl); +``` + +--- + +### 2. 웨이보 분석 실패 + +**증상**: HTML 가져오기 실패 또는 빈 응답 + +**원인**: User-Agent 검증 실패 + +**해결**: +```java +// 반드시 웨이보 전용 User-Agent 사용 +request.setHeader(CommonConstants.HEADER_USER_AGENT, CommonConstants.USER_AGENT_WEIBO); +request.setHeader(CommonConstants.HEADER_ACCEPT_LANGUAGE, CommonConstants.ACCEPT_LANGUAGE_ZH_CN); +request.setHeader(CommonConstants.HEADER_REFERER, CommonConstants.REFERER_WEIBO); +``` + +--- + +### 3. 네이버TV Shorts 파싱 실패 + +**증상**: width=0, height=0으로 oEmbed 실패 + +**원인**: Shorts는 oEmbed 미지원 + +**해결**: Fallback 로직 확인 +```java +if (width == null || height == null || width == 0 || height == 0) { + logger.debug(Messages.LOG_NAVER_TV_OEMBED_INVALID_FALLBACK, width, height); + return fallbackToVideoIdExtraction(originalUrl); // HTML 파싱으로 처리 +} +``` + +--- + +## 마무리 + +이 문서는 Video Analyzer 프로젝트의 코드 구조를 상세히 설명합니다. + +### 핵심 요약 + +1. **전략 패턴**: 플랫폼마다 다른 분석 로직 +2. **팩토리 패턴**: URL에 맞는 분석기 자동 선택 +3. **템플릿 메서드**: HTTP 기반 플랫폼의 공통 로직 +4. **상수 중앙 관리**: CommonConstants, Messages +5. **확장 용이**: 새 플랫폼 추가 4단계 + +### 다음 단계 + +- [ ] 캐싱 전략 추가 (동일 URL 반복 조회 최적화) +- [ ] 비동기 처리 (CompletableFuture) +- [ ] 배치 분석 지원 (여러 URL 동시 분석) +- [ ] 메트릭 수집 (성공률, 응답 시간) + +--- + +**작성일**: 2025-11-12 +**버전**: 1.0.0 +**작성자**: 개인 학습용 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..c2be14e --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,299 @@ +# 🤝 기여 가이드 + +Video URL Analyzer 프로젝트에 기여해주셔서 감사합니다! + +## 🚀 시작하기 + +### 1. Repository Fork & Clone + +```bash +git clone https://github.com/your-username/video-url-analyzer.git +cd video-url-analyzer +``` + +### 2. 개발 환경 설정 + +#### 요구사항 +- **Java**: 17 이상 +- **Gradle**: 7.0 이상 + +#### 의존성 설치 +```bash +./gradlew build +``` + +## 🔧 빌드 및 테스트 + +### 빌드 +```bash +./gradlew clean build +``` + +### 테스트 실행 +```bash +./gradlew test +``` + +### Fat JAR 생성 (의존성 포함) +```bash +./gradlew fatJar +``` + +### 테스트 커버리지 확인 +```bash +./gradlew test jacocoTestReport +# 결과: build/reports/jacoco/test/html/index.html +``` + +## 🏗️ 프로젝트 구조 + +``` +video-url-analyzer/ +├── src/ +│ ├── main/java/com/caliverse/ +│ │ ├── analyzer/ +│ │ │ ├── VideoAnalyzer.java # 메인 클래스 +│ │ │ ├── entity/ +│ │ │ │ └── UrlType.java # URL 타입 Enum +│ │ │ ├── model/ +│ │ │ │ └── VideoAnalysisResult.java # 결과 모델 +│ │ │ ├── platform/ # 플랫폼별 분석기 +│ │ │ │ ├── PlatformAnalyzer.java +│ │ │ │ ├── PlatformAnalyzerFactory.java +│ │ │ │ └── impl/ +│ │ │ │ ├── HttpBasedPlatformAnalyzer.java # HTTP 기반 추상 클래스 +│ │ │ │ ├── NaverTvPlatformAnalyzer.java +│ │ │ │ ├── KakaoTvPlatformAnalyzer.java +│ │ │ │ ├── YouTubePlatformAnalyzer.java +│ │ │ │ ├── VimeoPlatformAnalyzer.java +│ │ │ │ ├── TikTokPlatformAnalyzer.java +│ │ │ │ ├── TwitterPlatformAnalyzer.java +│ │ │ │ ├── WeiboPlatformAnalyzer.java +│ │ │ │ └── DirectUrlPlatformAnalyzer.java +│ │ │ └── service/ +│ │ │ └── VideoAnalyzerService.java # 비디오 분석 로직 +│ │ └── global/ +│ │ ├── common/ +│ │ │ ├── CommonConstants.java # 상수 (도메인, API URL, 정규식) +│ │ │ └── Messages.java # 메시지 관리 +│ │ └── util/ # 유틸리티 +│ │ ├── HttpUtils.java # HTTP 요청 유틸 +│ │ └── UrlParser.java # URL 파싱 유틸 +│ └── test/java/com/caliverse/analyzer/ # 테스트 코드 +│ └── VideoAnalyzerTest.java +├── build.gradle # 빌드 설정 +├── README.md # 사용자 문서 +└── CONTRIBUTING.md # 이 파일 +``` + +### 주요 컴포넌트 + +#### 1. VideoAnalyzer (메인 API) +- 비디오 URL 분석의 진입점 +- 플랫폼별 분석기 또는 일반 비디오 분석 선택 + +#### 2. PlatformAnalyzer 인터페이스 +- 플랫폼별 특화 로직 구현을 위한 인터페이스 +- 새로운 플랫폼 추가 시 이 인터페이스를 구현하거나 HttpBasedPlatformAnalyzer 상속 + +#### 3. HttpBasedPlatformAnalyzer (추상 클래스) +- HTTP 요청 기반 플랫폼 분석기의 공통 로직 +- oEmbed API 호출 및 응답 처리 +- 각 플랫폼은 `parseResponse()` 메서드만 구현 + +#### 4. VideoAnalyzerService +- 일반 비디오 URL 분석 로직 +- 보호 검사, 3D 검사, AVPlayer 재생 가능 여부 검사 + +#### 5. CommonConstants & Messages +- `CommonConstants`: 도메인, API URL, 정규식 패턴 등 +- `Messages`: 모든 메시지 문자열 (국제화 준비) + +## 🧪 테스트 예제 + +### 기본 테스트 구조 + +```java +import org.junit.Test; +import static org.junit.Assert.*; + +public class VideoAnalyzerTest { + + @Test + public void testNaverTvUrl() { + VideoAnalyzer analyzer = new VideoAnalyzer(); + VideoAnalysisResult result = analyzer.analyzeUrl("https://tv.naver.com/v/84373511"); + + assertNotNull(result); + assertNotNull(result.isSuccess()); + assertNotNull(result.getReason()); + } + + @Test + public void testDrmProtectedUrl() { + VideoAnalyzer analyzer = new VideoAnalyzer(); + String drmUrl = "https://example.com/video.m3u8?drm=widevine"; + VideoAnalysisResult result = analyzer.analyzeUrl(drmUrl); + + assertFalse(result.isSuccess()); + assertTrue(result.getReason().contains("DRM")); + } +} +``` + +## 🌟 새로운 플랫폼 추가하기 + +### 방법 1: HttpBasedPlatformAnalyzer 상속 (권장 - oEmbed 지원 플랫폼) + +```java +public class InstagramPlatformAnalyzer extends HttpBasedPlatformAnalyzer { + + private static final String PLATFORM_NAME = "Instagram"; + private static final String PLATFORM_DOMAIN = "instagram.com"; + private static final String API_ENDPOINT = "https://api.instagram.com/oembed"; + + private final ObjectMapper objectMapper; + + public InstagramPlatformAnalyzer() { + this.objectMapper = new ObjectMapper(); + } + + @Override + protected String getApiEndpoint() { + return API_ENDPOINT; + } + + @Override + protected String getPlatformDomain() { + return PLATFORM_DOMAIN; + } + + @Override + public String getPlatformName() { + return PLATFORM_NAME; + } + + @Override + public boolean canHandle(String url) { + if (url == null) return false; + return url.toLowerCase().contains(PLATFORM_DOMAIN); + } + + @Override + protected VideoAnalysisResult parseResponse(String response, String originalUrl) throws IOException { + try { + JsonNode rootNode = objectMapper.readTree(response); + + // 필수 필드 추출 + String type = getStringValue(rootNode, "type"); + String title = getStringValue(rootNode, "title"); + + if (type == null || title == null) { + return VideoAnalysisResult.failure("Instagram oEmbed 응답이 유효하지 않습니다"); + } + + logger.debug("Instagram 검증 성공: {}", title); + return VideoAnalysisResult.success(); + + } catch (Exception e) { + throw new IOException("Instagram 응답 파싱 실패: " + e.getMessage(), e); + } + } +} +``` + +### 방법 2: PlatformAnalyzer 직접 구현 (HTML 파싱이 필요한 경우) + +```java +public class CustomPlatformAnalyzer implements PlatformAnalyzer { + + @Override + public String getPlatformName() { + return "CustomPlatform"; + } + + @Override + public boolean canHandle(String url) { + return url != null && url.contains("custom.com"); + } + + @Override + public VideoAnalysisResult analyze(String url) { + try { + // 커스텀 분석 로직 (HTML 파싱 등) + String htmlContent = fetchHtmlContent(url); + String videoUrl = extractVideoUrl(htmlContent); + + if (videoUrl != null) { + return VideoAnalysisResult.success(); + } else { + return VideoAnalysisResult.failure("비디오 URL을 찾을 수 없습니다"); + } + } catch (Exception e) { + return VideoAnalysisResult.failure("분석 실패: " + e.getMessage()); + } + } + + private String fetchHtmlContent(String url) throws IOException { + // HTTP 요청 구현 + } + + private String extractVideoUrl(String html) { + // HTML 파싱 구현 + } +} +``` + +### 3. Factory에 등록 + +```java +public class PlatformAnalyzerFactory { + public PlatformAnalyzerFactory() { + this.analyzers = new ArrayList<>(); + + // HTTP 기반 플랫폼 분석기들을 등록 + analyzers.add(new NaverTvPlatformAnalyzer()); + analyzers.add(new KakaoTvPlatformAnalyzer()); + analyzers.add(new YouTubePlatformAnalyzer()); + analyzers.add(new VimeoPlatformAnalyzer()); + analyzers.add(new TikTokPlatformAnalyzer()); + analyzers.add(new TwitterPlatformAnalyzer()); + analyzers.add(new WeiboPlatformAnalyzer()); + + // 새로운 분석기 추가 + analyzers.add(new InstagramPlatformAnalyzer()); + + // 직접 URL 분석기는 가장 마지막에 등록 (fallback) + analyzers.add(new DirectUrlPlatformAnalyzer()); + } +} +``` + +### 4. 테스트 작성 + +```java +@Test +public void testInstagramUrl() { + VideoAnalyzer analyzer = new VideoAnalyzer(); + VideoAnalysisResult result = analyzer.analyzeUrl("https://www.instagram.com/p/ABC123/"); + + assertNotNull("결과가 null이 아니어야 합니다", result); + assertTrue("Instagram URL은 재생 가능해야 합니다", result.isSuccess()); + + System.out.println("Instagram: " + result); +} +``` + +### 5. 실제 예제: 웨이보 플랫폼 + +웨이보는 oEmbed를 지원하지 않아 HTML 파싱 방식으로 구현되었습니다. +자세한 구현은 `WeiboPlatformAnalyzer.java`를 참고하세요. + +주요 특징: +- HTML에서 `og:video`, `og:title`, `og:image` 메타태그 추출 +- `$render_data` 스크립트에서 JSON 데이터 파싱 +- 여러 URL 패턴 지원 (tv/show, video/show, m.weibo.cn) + +--- + +다시 한번 기여해주셔서 감사드립니다! 🎉 diff --git a/README.md b/README.md new file mode 100644 index 0000000..906f7f7 --- /dev/null +++ b/README.md @@ -0,0 +1,193 @@ +# 📹 Video URL Analyzer + +비디오 URL의 재생 가능 여부를 분석하는 Java 라이브러리입니다. + +[![Java](https://img.shields.io/badge/Java-17-orange.svg)](https://www.oracle.com/java/) +[![License](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) + +## ✨ 주요 기능 + +- ✅ **다양한 플랫폼 지원**: 네이버TV, 카카오TV, YouTube, Vimeo, TikTok, Twitter, 웨이보 등 +- ✅ **결과**: URL 확인 결과 + +## 🚀 빠른 시작 + +### 설치 + +#### Gradle +```gradle +dependencies { + implementation 'com.caliverse.analyzer:video-url-analyzer:1.0.0' +} +``` + +#### Maven +```xml + + com.caliverse.analyzer + video-url-analyzer + 1.0.0 + +``` + +#### JAR 파일 직접 사용 +```bash +./gradlew clean build +# build/libs/video-url-analyzer-1.0.0.jar 생성됨 +``` + +### 기본 사용법 + +```java +import com.caliverse.analyzer.VideoAnalyzer; +import com.caliverse.analyzer.model.VideoAnalysisResult; + +// 1. VideoAnalyzer 인스턴스 생성 +VideoAnalyzer analyzer = new VideoAnalyzer(); + +// 2. URL 분석 +VideoAnalysisResult result = analyzer.analyzeUrl("https://tv.naver.com/v/84373511"); + +// 3. 결과 확인 +if (result.isSuccess()) { + System.out.println("✅ 재생 가능: " + result.getReason()); +} else { + System.out.println("❌ 재생 불가: " + result.getReason()); +} +``` + +## 📖 상세 사용 예제 + +### 1. 다양한 플랫폼 URL 분석 + +```java +VideoAnalyzer analyzer = new VideoAnalyzer(); + +// 네이버TV (VOD, Clip, Live) +VideoAnalysisResult result1 = analyzer.analyzeUrl("https://tv.naver.com/v/84373511"); +VideoAnalysisResult result2 = analyzer.analyzeUrl("https://tv.naver.com/h/87349660"); + +// 카카오TV +VideoAnalysisResult result3 = analyzer.analyzeUrl("https://tv.kakao.com/channel/10210483/cliplink/459147379"); + +// YouTube +VideoAnalysisResult result4 = analyzer.analyzeUrl("https://www.youtube.com/watch?v=4dpreJ9wUs8"); + +// 웨이보 +VideoAnalysisResult result5 = analyzer.analyzeUrl("https://weibo.com/tv/show/1034:4872947210436638"); + +``` + +### 2. 일반 비디오 URL 분석 + +```java +VideoAnalysisResult result = analyzer.analyzeUrl("https://example.com/video.mp4"); + +if (!result.isSuccess()) { + // 실패 이유 상세 확인 + System.out.println(result.getReason()); + + /* 출력 예시: + * 재생 불가 사유: + * - 보호된 URL: DRM 보호 키워드 감지: widevine + * - 3D 비디오: 3D 비디오 헤더 감지 + */ +} +``` + +### 3. 지원 플랫폼 확인 + +```java +VideoAnalyzer analyzer = new VideoAnalyzer(); +String[] platforms = analyzer.getSupportedPlatforms(); + +System.out.println("지원 플랫폼: " + String.join(", ", platforms)); +// 출력: 지원 플랫폼: 네이버TV, 카카오TV, YouTube, Vimeo, TikTok, Twitter, 웨이보, DirectURL +``` + +## 🔍 분석 기준 + +### ✅ 재생 가능 조건 (모두 충족 시) +1. **보호되지 않음**: DRM, 서명 없음 +2. **AVPlayer 지원**: mp4, m3u8, mov 등 지원 포맷 + +### ❌ 재생 불가 조건 (하나라도 해당 시) +- DRM으로 보호된 URL +- 서명된 URL (토큰/키 필요) +- 지원되지 않는 비디오 포맷 + +## 📊 지원 플랫폼 + +| 플랫폼 | 지원 여부 | 분석 방식 | 비고 | +|--------|--------|----------|------| +| 네이버TV | ✅ | VOD, Clip, Live 지원 | +| 카카오TV | ✅ | 클립 및 채널 지원 | +| YouTube | ✅ | 일반 동영상 및 Shorts | +| TikTok | ✅ | 동영상 공유 링크 | +| 웨이보 | ✅ | TV Show | +| 일반 URL | ✅ | mp4, m3u8, mov 등 | + +## 🛠️ API 문서 + +### VideoAnalyzer + +메인 클래스로 비디오 URL 분석 기능을 제공합니다. + +#### 메소드 + +```java +/** + * URL을 분석하여 재생 가능 여부를 판단합니다. + * + * @param url 분석할 비디오 URL + * @return VideoAnalysisResult 분석 결과 + */ +public VideoAnalysisResult analyzeUrl(String url) + +/** + * 지원하는 플랫폼 목록을 반환합니다. + * + * @return String[] 플랫폼 이름 배열 + */ +public String[] getSupportedPlatforms() +``` + +### VideoAnalysisResult + +분석 결과를 담는 클래스입니다. + +#### 메소드 + +```java +/** + * 전체 분석 결과가 성공인지 반환합니다. + * + * @return boolean 재생 가능 여부 + */ +public boolean isSuccess() + +/** + * 분석 결과의 이유를 반환합니다. + * 성공 시 "모든 검사 통과", 실패 시 상세 이유를 제공합니다. + * + * @return String 결과 이유 + */ +public String getReason() +``` + +## 📋 요구사항 + +- **Java**: 17 이상 +- **Gradle**: 7.0 이상 (빌드 시) + +## 🤝 기여하기 + +프로젝트 개선에 참여하고 싶으신가요? [CONTRIBUTING.md](CONTRIBUTING.md)에서 기여 방법을 확인하세요! + +## 📝 버전 히스토리 + +### v1.0.0 (2025-01-XX) +- ✨ 초기 릴리즈 +- ✅ 플랫폼 지원 (네이버TV, 카카오TV, YouTube, TikTok, 웨이보, DirectURL) +- ✅ DRM/보호 URL 감지 +- ✅ AVPlayer 호환성 체크 diff --git a/backup/src/main/java/com/caliverse/video/analyzer/VideoAnalyzer.java b/backup/src/main/java/com/caliverse/video/analyzer/VideoAnalyzer.java new file mode 100644 index 0000000..39cf49d --- /dev/null +++ b/backup/src/main/java/com/caliverse/video/analyzer/VideoAnalyzer.java @@ -0,0 +1,143 @@ +package com.caliverse.video.analyzer; + +import com.caliverse.video.analyzer.platform.PlatformAnalyzer; +import com.caliverse.video.analyzer.platform.PlatformAnalyzerFactory; +import com.caliverse.video.analyzer.service.VideoAnalyzerService; +import com.caliverse.video.analyzer.model.VideoAnalysisResult; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * 비디오 URL 분석 기능을 제공하는 메인 클래스입니다. + * + *

이 클래스는 비디오 URL을 분석하여 다음 사항을 확인합니다: + *

    + *
  • DRM 보호 URL 여부
  • + *
  • 3D 비디오 여부
  • + *
  • AVPlayer 재생 가능 여부
  • + *
+ * + *

지원하는 플랫폼: + *

    + *
  • 네이버TV (VOD, Clip, Live)
  • + *
  • 일반 비디오 URL (mp4, m3u8, mov 등)
  • + *
+ * + *

사용 예제

+ *
{@code
+ * VideoAnalyzer analyzer = new VideoAnalyzer();
+ * VideoAnalysisResult result = analyzer.analyzeUrl("https://tv.naver.com/v/84373511");
+ *
+ * if (result.isSuccess()) {
+ *     System.out.println("재생 가능: " + result.getReason());
+ * } else {
+ *     System.out.println("재생 불가: " + result.getReason());
+ * }
+ * }
+ * + * @since 1.0.0 + * @see VideoAnalysisResult + */ +public class VideoAnalyzer { + + private static final Logger logger = LoggerFactory.getLogger(VideoAnalyzer.class); + + private final VideoAnalyzerService analyzerService; + private final PlatformAnalyzerFactory platformFactory; + + /** + * VideoAnalyzer 인스턴스를 생성합니다. + * + *

생성 시 내부적으로 분석 서비스와 플랫폼별 분석기를 초기화합니다. + */ + public VideoAnalyzer() { + this.analyzerService = new VideoAnalyzerService(); + this.platformFactory = new PlatformAnalyzerFactory(analyzerService); + } + + /** + * 비디오 URL을 분석하여 재생 가능 여부를 판단합니다. + * + *

특정 플랫폼(네이버TV 등)의 URL인 경우 해당 플랫폼에 맞는 분석을 수행하며, + * 일반 URL인 경우 표준 비디오 분석을 수행합니다. + * + *

분석 항목: + *

    + *
  • 보호된 URL 여부 (DRM, 서명된 URL 등)
  • + *
  • 3D 비디오 여부
  • + *
  • AVPlayer 재생 가능 여부 (포맷 체크)
  • + *
+ * + *

성공 조건: 모든 항목이 재생 가능한 경우
+ * 실패 조건: 하나라도 재생 불가능한 경우 (상세 이유 제공) + * + * @param url 분석할 비디오 URL (http:// 또는 https:// 형식) + * @return 분석 결과를 담은 {@link VideoAnalysisResult} 객체 + * @see VideoAnalysisResult#isSuccess() + * @see VideoAnalysisResult#getReason() + * + * @example + *

{@code
+     * // 네이버TV URL 분석
+     * VideoAnalysisResult result1 = analyzer.analyzeUrl("https://tv.naver.com/v/84373511");
+     *
+     * // 일반 비디오 URL 분석
+     * VideoAnalysisResult result2 = analyzer.analyzeUrl("https://example.com/video.mp4");
+     * }
+ */ + public VideoAnalysisResult analyzeUrl(String url) { + // 플랫폼별 분석기 찾기 + PlatformAnalyzer analyzer = platformFactory.findAnalyzer(url); + + if (analyzer != null) { + return analyzer.analyze(url); + } else { + // 일반 비디오 URL로 처리 + return analyzerService.analyzeVideo(url); + } + } + + /** + * 현재 지원하는 플랫폼 목록을 반환합니다. + * + *

각 플랫폼별로 특화된 URL 분석 로직이 제공됩니다. + * + * @return 지원하는 플랫폼 이름 배열 (예: ["네이버TV"]) + *

+ * @example + *

{@code
+     * String[] platforms = analyzer.getSupportedPlatforms();
+     * System.out.println("지원 플랫폼: " + String.join(", ", platforms));
+     * // 출력: 지원 플랫폼: 네이버TV
+     * }
+ */ + public String[] getSupportedPlatforms() { + return platformFactory.getAllAnalyzers().stream() + .map(PlatformAnalyzer::getPlatformName) + .toArray(String[]::new); + } + + + /** + * 명령줄에서 테스트 실행을 위한 메인 메서드 + */ + public static void main(String[] args) { + if (args.length < 1) { + VideoAnalyzer analyzer = new VideoAnalyzer(); + logger.info("사용법: java -jar video-url-analyzer.jar "); + logger.info("지원하는 플랫폼: {}", String.join(", ", analyzer.getSupportedPlatforms())); + logger.info("예시:"); + logger.info(" 네이버 TV: https://tv.naver.com/v/85477455"); + logger.info(" 일반 비디오: https://example.com/video.mp4"); + System.exit(1); + } + + String url = args[0]; + VideoAnalyzer analyzer = new VideoAnalyzer(); + + logger.info("=== URL 분석 결과 ==="); + VideoAnalysisResult result = analyzer.analyzeUrl(url); + logger.info("{}", result); + } + +} diff --git a/backup/src/main/java/com/caliverse/video/analyzer/entity/UrlType.java b/backup/src/main/java/com/caliverse/video/analyzer/entity/UrlType.java new file mode 100644 index 0000000..370ccf2 --- /dev/null +++ b/backup/src/main/java/com/caliverse/video/analyzer/entity/UrlType.java @@ -0,0 +1,11 @@ +package com.caliverse.video.analyzer.entity; + +public enum UrlType { + YOUTUBE, + BLOB, + STREAMING, + DIRECT_FILE, + NAVER_TV, + UNKNOWN + +} diff --git a/backup/src/main/java/com/caliverse/video/analyzer/model/VideoAnalysisResult.java b/backup/src/main/java/com/caliverse/video/analyzer/model/VideoAnalysisResult.java new file mode 100644 index 0000000..999627e --- /dev/null +++ b/backup/src/main/java/com/caliverse/video/analyzer/model/VideoAnalysisResult.java @@ -0,0 +1,116 @@ +package com.caliverse.video.analyzer.model; + +import com.caliverse.video.global.common.Messages; + +/** + * 비디오 URL 분석 결과를 담는 모델 클래스입니다. + * + *

이 클래스는 비디오 URL 분석의 최종 결과를 나타냅니다. + * 모든 검사 항목이 통과하면 {@code success = true}, 하나라도 실패하면 + * {@code success = false}와 함께 상세한 실패 이유를 제공합니다. + * + *

분석 항목

+ *
    + *
  • 보호된 URL 여부: DRM, 서명된 URL 감지
  • + *
  • 3D 비디오 여부: 3D 포맷 감지
  • + *
  • AVPlayer 재생 가능 여부: 지원 포맷 확인
  • + *
+ * + *

사용 예제

+ *
{@code
+ * VideoAnalyzer analyzer = new VideoAnalyzer();
+ * VideoAnalysisResult result = analyzer.analyzeUrl(url);
+ *
+ * if (result.isSuccess()) {
+ *     System.out.println("재생 가능: " + result.getReason());
+ *     // 출력: "재생 가능: 모든 검사 통과"
+ * } else {
+ *     System.out.println("재생 불가");
+ *     System.out.println(result.getReason());
+ *     // 출력 예시:
+ *     // 재생 불가 사유:
+ *     // - 보호된 URL: DRM 보호 키워드 감지: widevine
+ *     // - 3D 비디오: 3D 비디오 헤더 감지
+ * }
+ * }
+ * + * @since 1.0.0 + * @see com.caliverse.video.analyzer.VideoAnalyzer#analyzeUrl(String) + */ +public class VideoAnalysisResult { + private final boolean success; + private final String reason; + + private VideoAnalysisResult(boolean success, String reason) { + this.success = success; + this.reason = reason; + } + + /** + * 성공 결과 객체를 생성합니다. + * + *

모든 검사 항목이 통과했을 때 사용되며, reason은 "모든 검사 통과"로 설정됩니다. + * + * @return 성공 결과 객체 + */ + public static VideoAnalysisResult success() { + return new VideoAnalysisResult(true, Messages.SUCCESS_ALL_CHECKS_PASSED); + } + + /** + * 실패 결과 객체를 생성합니다. + * + *

하나 이상의 검사 항목이 실패했을 때 사용되며, + * 상세한 실패 이유를 reason에 포함합니다. + * + * @param reason 실패 이유 (개행 문자로 구분된 상세 설명 가능) + * @return 실패 결과 객체 + */ + public static VideoAnalysisResult failure(String reason) { + return new VideoAnalysisResult(false, reason); + } + + /** + * 분석 결과가 성공인지 여부를 반환합니다. + * + *

성공 조건: + *

    + *
  • 보호되지 않은 URL
  • + *
  • 2D 비디오
  • + *
  • AVPlayer에서 재생 가능
  • + *
+ * + * @return 모든 검사가 통과하면 {@code true}, 하나라도 실패하면 {@code false} + */ + public boolean isSuccess() { + return success; + } + + /** + * 분석 결과의 이유를 반환합니다. + * + *

성공한 경우: "모든 검사 통과"
+ * 실패한 경우: 상세한 실패 이유 (각 항목별로 구분) + * + *

실패 이유 예시: + *

+     * 재생 불가 사유:
+     * - 보호된 URL: DRM 보호 키워드 감지: widevine
+     * - 재생 불가: 지원되지 않는 포맷 또는 Content-Type: video/x-flv
+     * 
+ * + * @return 분석 결과 이유 (null이 아닌 문자열) + */ + public String getReason() { + return reason; + } + + @Override + public String toString() { + if (success) { + return "VideoAnalysisResult{ 성공: " + reason + " }"; + } else { + return "VideoAnalysisResult{ 실패: " + reason + " }"; + } + } +} diff --git a/backup/src/main/java/com/caliverse/video/analyzer/platform/PlatformAnalyzer.java b/backup/src/main/java/com/caliverse/video/analyzer/platform/PlatformAnalyzer.java new file mode 100644 index 0000000..15e2a42 --- /dev/null +++ b/backup/src/main/java/com/caliverse/video/analyzer/platform/PlatformAnalyzer.java @@ -0,0 +1,29 @@ +package com.caliverse.video.analyzer.platform; + +import com.caliverse.video.analyzer.model.VideoAnalysisResult; + +public interface PlatformAnalyzer { + /** + * 해당 플랫폼의 URL인지 확인합니다. + * + * @param url 확인할 URL + * @return 해당 플랫폼 URL이면 true, 아니면 false + */ + boolean canHandle(String url); + + /** + * 플랫폼 이름을 반환합니다. + * + * @return 플랫폼 이름 + */ + String getPlatformName(); + + /** + * 플랫폼별 URL 분석을 수행합니다. + * + * @param url 분석할 URL + * @return 분석 결과 + */ + VideoAnalysisResult analyze(String url); + +} diff --git a/backup/src/main/java/com/caliverse/video/analyzer/platform/PlatformAnalyzerFactory.java b/backup/src/main/java/com/caliverse/video/analyzer/platform/PlatformAnalyzerFactory.java new file mode 100644 index 0000000..a6cd0b7 --- /dev/null +++ b/backup/src/main/java/com/caliverse/video/analyzer/platform/PlatformAnalyzerFactory.java @@ -0,0 +1,56 @@ +package com.caliverse.video.analyzer.platform; + +import com.caliverse.video.analyzer.platform.impl.NaverTvPlatformAnalyzer; +import com.caliverse.video.analyzer.service.VideoAnalyzerService; + +import java.util.ArrayList; +import java.util.List; + +public class PlatformAnalyzerFactory { + private final List analyzers; + + public PlatformAnalyzerFactory(VideoAnalyzerService videoAnalyzerService) { + this.analyzers = new ArrayList<>(); + + // 플랫폼 분석기들을 등록 + analyzers.add(new NaverTvPlatformAnalyzer(videoAnalyzerService)); + + // 향후 다른 플랫폼들도 여기에 추가 + // analyzers.add(new VimeoPlatformAnalyzer(videoAnalyzerService)); + // analyzers.add(new DaumTvPlatformAnalyzer(videoAnalyzerService)); + } + + /** + * URL에 맞는 플랫폼 분석기를 찾습니다. + * + * @param url 분석할 URL + * @return 해당 플랫폼 분석기, 없으면 null + */ + public PlatformAnalyzer findAnalyzer(String url) { + for (PlatformAnalyzer analyzer : analyzers) { + if (analyzer.canHandle(url)) { + return analyzer; + } + } + return null; + } + + /** + * 등록된 모든 플랫폼 분석기 목록을 반환합니다. + * + * @return 플랫폼 분석기 목록 + */ + public List getAllAnalyzers() { + return new ArrayList<>(analyzers); + } + + /** + * 새로운 플랫폼 분석기를 추가합니다. + * + * @param analyzer 추가할 분석기 + */ + public void addAnalyzer(PlatformAnalyzer analyzer) { + analyzers.add(analyzer); + } + +} diff --git a/backup/src/main/java/com/caliverse/video/analyzer/platform/impl/NaverTvPlatformAnalyzer.java b/backup/src/main/java/com/caliverse/video/analyzer/platform/impl/NaverTvPlatformAnalyzer.java new file mode 100644 index 0000000..89af613 --- /dev/null +++ b/backup/src/main/java/com/caliverse/video/analyzer/platform/impl/NaverTvPlatformAnalyzer.java @@ -0,0 +1,525 @@ +package com.caliverse.video.analyzer.platform.impl; + +import com.caliverse.video.analyzer.platform.PlatformAnalyzer; +import com.caliverse.video.analyzer.service.VideoAnalyzerService; +import com.caliverse.video.analyzer.model.VideoAnalysisResult; +import com.caliverse.video.global.common.CommonConstants; +import com.caliverse.video.global.common.Messages; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.apache.http.HttpEntity; +import org.apache.http.client.methods.CloseableHttpResponse; +import org.apache.http.client.methods.HttpGet; +import org.apache.http.impl.client.CloseableHttpClient; +import org.apache.http.impl.client.HttpClients; +import org.apache.http.util.EntityUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.regex.Matcher; + +public class NaverTvPlatformAnalyzer implements PlatformAnalyzer { + + private static final Logger logger = LoggerFactory.getLogger(NaverTvPlatformAnalyzer.class); + + private final ObjectMapper objectMapper; + private final VideoAnalyzerService videoAnalyzerService; + + public NaverTvPlatformAnalyzer(VideoAnalyzerService videoAnalyzerService) { + this.objectMapper = new ObjectMapper(); + this.videoAnalyzerService = videoAnalyzerService; + } + + @Override + public boolean canHandle(String url) { + if (url == null) return false; + String lowerUrl = url.toLowerCase(); + return lowerUrl.contains(CommonConstants.DOMAIN_NAVER_TV) || lowerUrl.contains(CommonConstants.DOMAIN_NAVER); + } + + @Override + public String getPlatformName() { + return CommonConstants.FLATFORM_NAME_NAVER_TV; + } + + @Override + public VideoAnalysisResult analyze(String url) { + try { + // 1. HTML 콘텐츠 가져오기 + String htmlContent = fetchHtmlContent(url); + + // 2. __NEXT_DATA__ 파싱 + JsonNode nextDataNode = extractNextData(htmlContent); + if (nextDataNode == null) { + return VideoAnalysisResult.failure(Messages.NAVER_TV_PARSING_FAILED); + } + + // 3. props.pageProps 접근 + JsonNode pageProps = nextDataNode.path("props").path("pageProps"); + if (pageProps.isMissingNode()) { + return VideoAnalysisResult.failure(Messages.NAVER_TV_PARSING_FAILED); + } + + // 4. 콘텐츠 타입별 처리 + String videoUrl = null; + + // 4-1. vodInfo 확인 + JsonNode vodInfo = pageProps.path("vodInfo"); + if (!vodInfo.isMissingNode()) { + videoUrl = handleVodInfo(vodInfo); + } + + // 4-2. clipInfo 확인 + if (videoUrl == null) { + JsonNode clipInfo = pageProps.path("clipInfo"); + if (!clipInfo.isMissingNode()) { + videoUrl = handleClipInfo(clipInfo); + } + } + + // 4-3. liveInfo 확인 + if (videoUrl == null) { + JsonNode liveInfo = pageProps.path("liveInfo"); + if (!liveInfo.isMissingNode()) { + videoUrl = handleLiveInfo(liveInfo); + } + } + + if (videoUrl == null) { + return VideoAnalysisResult.failure(Messages.NAVER_TV_API_PARSING_FAILED); + } + + // 5. 제한사항 확인 + String restrictionReason = checkRestrictions(videoUrl); + if (restrictionReason != null) { + return VideoAnalysisResult.failure(Messages.format(Messages.NAVER_TV_RESTRICTION, restrictionReason)); + } + + // 6. 비디오 분석 + return videoAnalyzerService.analyzeVideo(videoUrl); + + } catch (Exception e) { + return VideoAnalysisResult.failure(Messages.format(Messages.NAVER_TV_ANALYSIS_ERROR, e.getMessage())); + } + } + + private String fetchHtmlContent(String url) throws IOException { + try (CloseableHttpClient httpClient = HttpClients.createDefault()) { + HttpGet request = new HttpGet(url); + request.setHeader(CommonConstants.REQUEST_HEADER_NAME_USER_AGENT, CommonConstants.REQUEST_HEADER_USER_AGENT); + + try (CloseableHttpResponse response = httpClient.execute(request)) { + HttpEntity entity = response.getEntity(); + return EntityUtils.toString(entity, StandardCharsets.UTF_8); + } + } + } + + /** + * HTML에서 __NEXT_DATA__ 스크립트를 파싱합니다. + */ + private JsonNode extractNextData(String htmlContent) { + try { + Matcher matcher = CommonConstants.NAVER_NEXT_DATA_PATTERN.matcher(htmlContent); + if (matcher.find()) { + String jsonData = matcher.group(1); + return objectMapper.readTree(jsonData); + } + return null; + } catch (Exception e) { + logger.warn("__NEXT_DATA__ 파싱 실패: {}", e.getMessage()); + return null; + } + } + + /** + * vodInfo 처리: videoId + inKey로 VOD API 호출 + * 구조: vodInfo.clip.videoId, vodInfo.play.inKey + */ + private String handleVodInfo(JsonNode vodInfo) throws IOException { + String videoId = vodInfo.path("clip").path("videoId").asText(null); + String inKey = vodInfo.path("play").path("inKey").asText(null); + + if (videoId != null && inKey != null) { + String apiUrl = String.format(CommonConstants.NAVER_VOD_API_URL, + videoId, URLEncoder.encode(inKey, StandardCharsets.UTF_8)); + String apiResponse = fetchApiResponse(apiUrl); + return extractVideoUrl(apiResponse); + } + return null; + } + + /** + * clipInfo 처리: videoId만으로 Short API 호출 + */ + private String handleClipInfo(JsonNode clipInfo) throws IOException { + String videoId = clipInfo.path("videoId").asText(null); + + if (videoId != null) { + String apiUrl = String.format(CommonConstants.NAVER_SHORT_API_URL, videoId); + String apiResponse = fetchApiResponse(apiUrl); + return extractVideoUrlFromShortApi(apiResponse); + } + return null; + } + + /** + * liveInfo 처리: playbackBody에서 직접 m3u8 URL 추출 + */ + private String handleLiveInfo(JsonNode liveInfo) { + try { + String playbackBody = liveInfo.path("playbackBody").asText(null); + if (playbackBody == null) { + return null; + } + + // playbackBody는 JSON 문자열이므로 다시 파싱 + JsonNode playbackNode = objectMapper.readTree(playbackBody); + + // media 배열에서 HLS 프로토콜의 path 찾기 + JsonNode mediaArray = playbackNode.path("media"); + if (mediaArray.isArray()) { + for (JsonNode media : mediaArray) { + String protocol = media.path("protocol").asText(""); + if ("HLS".equals(protocol)) { + String path = media.path("path").asText(null); + if (path != null && !path.isEmpty()) { + // \\u 이스케이프 처리 + path = unescapeUnicode(path); + logger.debug("liveInfo에서 m3u8 URL 추출 성공"); + return path; + } + } + } + } + + return null; + } catch (Exception e) { + logger.warn("liveInfo playbackBody 파싱 실패: {}", e.getMessage()); + return null; + } + } + + /** + * 유니코드 이스케이프 시퀀스 처리 + */ + private String unescapeUnicode(String text) { + if (text == null) { + return null; + } + return text.replace("\\u003d", "=") + .replace("\\u0026", "&") + .replace("\\u003c", "<") + .replace("\\u003e", ">"); + } + + private String fetchApiResponse(String apiUrl) throws IOException { + try (CloseableHttpClient httpClient = HttpClients.createDefault()) { + HttpGet request = new HttpGet(apiUrl); + request.setHeader(CommonConstants.REQUEST_HEADER_NAME_USER_AGENT, CommonConstants.REQUEST_HEADER_USER_AGENT); + request.setHeader(CommonConstants.REQUEST_HEADER_NAME_REFERER, CommonConstants.REQUEST_HEADER_NAVER_REFERER); + + try (CloseableHttpResponse response = httpClient.execute(request)) { + if (response.getStatusLine().getStatusCode() != 200) { + throw new IOException("API 호출 실패"); + } + HttpEntity entity = response.getEntity(); + return EntityUtils.toString(entity, StandardCharsets.UTF_8); + } + } + } + + private String extractVideoUrl(String apiResponse) throws IOException { + try { + JsonNode rootNode = objectMapper.readTree(apiResponse); + + // 1. MPD 구조 탐색: MPD -> Period -> AdaptationSet -> Representation + JsonNode mpdNode = rootNode.path("MPD"); + if (mpdNode.isArray() && !mpdNode.isEmpty()) { + JsonNode firstMpd = mpdNode.get(0); + JsonNode periodNode = firstMpd.path("Period"); + + if (periodNode.isArray() && !periodNode.isEmpty()) { + JsonNode firstPeriod = periodNode.get(0); + JsonNode adaptationSetNode = firstPeriod.path("AdaptationSet"); + + if (adaptationSetNode.isArray() && !adaptationSetNode.isEmpty()) { + for (int i = 0; i < adaptationSetNode.size(); i++) { + JsonNode adaptationSet = adaptationSetNode.get(i); + JsonNode representationNode = adaptationSet.path("Representation"); + + if (representationNode.isArray() && !representationNode.isEmpty()) { + for (int j = 0; j < representationNode.size(); j++) { + JsonNode representation = representationNode.get(j); + String videoUrl = extractBaseUrlFromRepresentation(representation); + + if (videoUrl != null) { +// logger.debug("비디오 URL 추출 성공 (AdaptationSet[{}], Representation[{}])", i, j); + return videoUrl; + } + } + } + + String adaptationSetUrl = extractUrlFromAdaptationSet(adaptationSet); + if (adaptationSetUrl != null) { +// logger.debug("비디오 URL 추출 성공 (AdaptationSet[{}] 레벨)", i); + return adaptationSetUrl; + } + } + } + } + } + + // 2. 레거시 구조 확인 (이전 API 버전 호환성) + JsonNode legacyRepresentations = rootNode.path("Representation"); + if (legacyRepresentations.isArray() && legacyRepresentations.size() > 0) { + for (int i = 0; i < legacyRepresentations.size(); i++) { + JsonNode representation = legacyRepresentations.get(i); + String videoUrl = extractBaseUrlFromRepresentation(representation); + + if (videoUrl != null) { + logger.debug("비디오 URL 추출 성공 (레거시 Representation[{}])", i); + return videoUrl; + } + } + } + + // 3. 다른 가능한 경로들 확인 + String alternativeUrl = findAlternativeVideoUrl(rootNode); + if (alternativeUrl != null) { + logger.debug("비디오 URL 추출 성공 (대안 경로)"); + return alternativeUrl; + } + + logger.warn("비디오 URL을 찾을 수 없습니다. JSON 구조를 확인해주세요."); + return null; + + } catch (Exception e) { + throw new IOException("JSON 파싱 오류: " + e.getMessage(), e); + } + + } + + private String checkRestrictions(String videoUrl) { + try { + try (CloseableHttpClient httpClient = HttpClients.createDefault()) { + HttpGet request = new HttpGet(videoUrl); + request.setHeader(CommonConstants.REQUEST_HEADER_NAME_USER_AGENT, CommonConstants.REQUEST_HEADER_USER_AGENT); + request.setHeader(CommonConstants.REQUEST_HEADER_NAME_REFERER, CommonConstants.REQUEST_HEADER_NAVER_REFERER); + + try (CloseableHttpResponse response = httpClient.execute(request)) { + int statusCode = response.getStatusLine().getStatusCode(); + + if (statusCode == 403) return "접근 금지 (403) - CORS 또는 권한 제한"; + if (statusCode == 401) return "인 필요 (401)"; + if (statusCode >= 400) return "HTTP 오류 (" + statusCode + ")"; + + // CORS 헤더 확인 + if (response.containsHeader("Access-Control-Allow-Origin")) { + String allowOrigin = response.getFirstHeader("Access-Control-Allow-Origin").getValue(); + if (!allowOrigin.equals("*") && !allowOrigin.contains("tv.naver.com")) { + return "CORS 제한 - 특정 도메인에서만 접근 가능"; + } + } + + return null; // 제한 없음 + } + } + } catch (IOException e) { + return "네트워크 연결 오류: " + e.getMessage(); + } + } + + /** + * Representation 노드에서 BaseURL을 추출합니다. + */ + private String extractBaseUrlFromRepresentation(JsonNode representation) { + if (representation == null) return null; + + // BaseURL 배열 형태 + JsonNode baseUrls = representation.path("BaseURL"); + if (baseUrls.isArray() && !baseUrls.isEmpty()) { + String url = baseUrls.get(0).asText(); + if (url != null && !url.trim().isEmpty() && isValidVideoUrl(url)) { + return url; + } + } + + // BaseURL 단일 문자열 형태 + if (!baseUrls.isMissingNode() && baseUrls.isTextual()) { + String url = baseUrls.asText(); + if (url != null && !url.trim().isEmpty() && isValidVideoUrl(url)) { + return url; + } + } + + return null; + } + + /** + * AdaptationSet 레벨에서 URL을 찾습니다. + */ + private String extractUrlFromAdaptationSet(JsonNode adaptationSet) { + if (adaptationSet == null) return null; + + // AdaptationSet의 다양한 속성들 확인 + JsonNode baseUrl = adaptationSet.path("BaseURL"); + if (!baseUrl.isMissingNode()) { + if (baseUrl.isArray() && !baseUrl.isEmpty()) { + String url = baseUrl.get(0).asText(); + if (isValidVideoUrl(url)) return url; + } else if (baseUrl.isTextual()) { + String url = baseUrl.asText(); + if (isValidVideoUrl(url)) return url; + } + } + + // SegmentTemplate이나 다른 방식으로 URL 구성이 필요한 경우 + JsonNode segmentTemplate = adaptationSet.path("SegmentTemplate"); + if (!segmentTemplate.isMissingNode()) { + // SegmentTemplate 기반 URL 구성 로직 + return buildUrlFromSegmentTemplate(segmentTemplate); + } + + return null; + } + + /** + * 대안적인 비디오 URL 경로를 찾습니다. + */ + private String findAlternativeVideoUrl(JsonNode rootNode) { + // 1. 직접적인 URL 필드들 확인 + String[] possibleUrlFields = {"videoUrl", "streamUrl", "playbackUrl", "mediaUrl", "url"}; + + for (String field : possibleUrlFields) { + JsonNode urlNode = rootNode.path(field); + if (!urlNode.isMissingNode() && urlNode.isTextual()) { + String url = urlNode.asText(); + if (isValidVideoUrl(url)) return url; + } + } + + // 2. 중첩된 구조에서 URL 찾기 + JsonNode sources = rootNode.path("sources"); + if (sources.isArray() && !sources.isEmpty()) { + for (JsonNode source : sources) { + String url = source.path("src").asText(); + if (isValidVideoUrl(url)) return url; + } + } + + // 3. 다른 가능한 구조들... + // 필요시 추가 경로들을 여기에 구현 + + return null; + } + + /** + * SegmentTemplate에서 URL을 구성합니다. + */ + private String buildUrlFromSegmentTemplate(JsonNode segmentTemplate) { + // SegmentTemplate 방식은 복잡하므로 현재는 기본 구현만 + JsonNode media = segmentTemplate.path("@media"); +// JsonNode initialization = segmentTemplate.path("@initialization"); + + if (!media.isMissingNode() && media.isTextual()) { + String mediaTemplate = media.asText(); + // 템플릿 변수들을 실제 값으로 치환하는 로직 필요 + // 현재는 간단히 템플릿 문자열만 반환 + if (mediaTemplate.contains("http")) { + return mediaTemplate; + } + } + + return null; + } + + /** + * 유효한 비디오 URL인지 확인합니다. + */ + private boolean isValidVideoUrl(String url) { + if (url == null || url.trim().isEmpty()) { + return false; + } + + // 기본적인 URL 형식 확인 + if (!url.startsWith("http://") && !url.startsWith("https://")) { + return false; + } + + // 비디오 파일 확장자나 스트리밍 패턴 확인 + String lowerUrl = url.toLowerCase(); + return lowerUrl.contains(".mp4") || + lowerUrl.contains(".m3u8") || + lowerUrl.contains(".mov") || + lowerUrl.contains(".m4v") || + lowerUrl.contains("stream") || + lowerUrl.contains("video"); + } + + /** + * 짧은 URL API 응답에서 비디오 URL을 추출합니다. + * 응답 구조: {"card":{"content":{"vod":{"playback":{"videos":{"list":[{"source":"URL"}]}}}}}} + */ + private String extractVideoUrlFromShortApi(String apiResponse) throws IOException { + try { + JsonNode rootNode = objectMapper.readTree(apiResponse); + + // card.content.vod.playback.videos.list[0].source 경로로 접근 + JsonNode cardNode = rootNode.path("card"); + if (cardNode.isMissingNode()) { + logger.warn("짧은 URL API 응답에 'card' 노드가 없습니다."); + return null; + } + + JsonNode contentNode = cardNode.path("content"); + if (contentNode.isMissingNode()) { + logger.warn("짧은 URL API 응답에 'content' 노드가 없습니다."); + return null; + } + + JsonNode vodNode = contentNode.path("vod"); + if (vodNode.isMissingNode()) { + logger.warn("짧은 URL API 응답에 'vod' 노드가 없습니다."); + return null; + } + + JsonNode playbackNode = vodNode.path("playback"); + if (playbackNode.isMissingNode()) { + logger.warn("짧은 URL API 응답에 'playback' 노드가 없습니다."); + return null; + } + + JsonNode videosNode = playbackNode.path("videos"); + if (videosNode.isMissingNode()) { + logger.warn("짧은 URL API 응답에 'videos' 노드가 없습니다."); + return null; + } + + JsonNode listNode = videosNode.path("list"); + if (listNode.isArray() && !listNode.isEmpty()) { + JsonNode firstVideo = listNode.get(0); + JsonNode sourceNode = firstVideo.path("source"); + + if (!sourceNode.isMissingNode() && sourceNode.isTextual()) { + String videoUrl = sourceNode.asText(); + if (isValidVideoUrl(videoUrl)) { + logger.debug("짧은 URL API에서 비디오 URL 추출 성공"); + return videoUrl; + } + } + } + + logger.warn("짧은 URL API 응답에서 비디오 URL을 찾을 수 없습니다."); + return null; + + } catch (Exception e) { + throw new IOException("짧은 URL API JSON 파싱 오류: " + e.getMessage(), e); + } + } + + +} diff --git a/backup/src/main/java/com/caliverse/video/analyzer/service/VideoAnalyzerService.java b/backup/src/main/java/com/caliverse/video/analyzer/service/VideoAnalyzerService.java new file mode 100644 index 0000000..41bda1c --- /dev/null +++ b/backup/src/main/java/com/caliverse/video/analyzer/service/VideoAnalyzerService.java @@ -0,0 +1,293 @@ +package com.caliverse.video.analyzer.service; + +import com.caliverse.video.analyzer.entity.UrlType; +import com.caliverse.video.analyzer.model.VideoAnalysisResult; +import com.caliverse.video.global.common.Messages; +import com.caliverse.video.global.util.HttpUtils; +import com.caliverse.video.global.util.UrlParser; + +import java.io.IOException; +import java.util.Arrays; +import java.util.List; + +/** + * 비디오 URL을 분석하는 핵심 서비스 클래스입니다. + */ +public class VideoAnalyzerService { + + private final HttpUtils httpUtils; + private final UrlParser urlParser; + + /** + * 체크 결과를 담는 내부 클래스 + */ + private static class CheckResult { + final boolean result; + final String reason; + + CheckResult(boolean result, String reason) { + this.result = result; + this.reason = reason; + } + } + + // AVPlayer에서 지원하는 비디오 포맷 목록 + private static final List SUPPORTED_FORMATS = Arrays.asList( + "mp4", "mov", "m4v", "m3u8", "hls" + ); + + // DRM 관련 키워드 + private static final List DRM_KEYWORDS = Arrays.asList( + "drm", "widevine", "playready", "fairplay", "encrypted", "protection" + ); + + public VideoAnalyzerService() { + this.httpUtils = new HttpUtils(); + this.urlParser = new UrlParser(); + } + + /** + * URL이 보호된 URL인지 분석합니다 (DRM, CORS, Signed URL). + */ + private CheckResult checkProtection(String videoUrl) { + if (videoUrl == null || videoUrl.trim().isEmpty()) { + return new CheckResult(false, Messages.ERROR_INVALID_URL); + } + + // URL 유형별로 분기 처리 + UrlType urlType = determineUrlType(videoUrl); + + switch (urlType) { + case YOUTUBE: // 서명 정보가 계속 필요하기때문에 불가능 + return new CheckResult(false, Messages.PROTECTION_YOUTUBE_URL); + case NAVER_TV: + return new CheckResult(false, ""); + case BLOB: + return checkBlobProtection(videoUrl); + case STREAMING: + return checkStreamingProtection(videoUrl); + case DIRECT_FILE: + return checkDirectFileProtection(videoUrl); + default: + return checkGenericProtection(videoUrl); + } + } + + /** + * URL이 보호된 URL인지 분석합니다 (하위 호환성을 위한 메소드). + * @deprecated checkProtection 사용을 권장합니다 + */ + @Deprecated + public boolean isProtectedUrl(String videoUrl) { + return checkProtection(videoUrl).result; + } + + /** + * URL 유형을 판별합니다. + */ + private UrlType determineUrlType(String videoUrl) { + String lowerUrl = videoUrl.toLowerCase(); + + if (lowerUrl.contains("youtube.com") || lowerUrl.contains("youtu.be")) { + return UrlType.YOUTUBE; + } else if (lowerUrl.contains("naver.com") || lowerUrl.contains("naver")) { + return UrlType.NAVER_TV; + } else if (lowerUrl.startsWith("blob:")) { + return UrlType.BLOB; + } else if (lowerUrl.contains(".m3u8") || lowerUrl.contains("hls") || + lowerUrl.contains("dash") || lowerUrl.contains("stream")) { + return UrlType.STREAMING; + } else if (lowerUrl.matches(".*\\.(mp4|mov|m4v|avi|mkv|webm)($|\\?.*)")) { + return UrlType.DIRECT_FILE; + } else { + return UrlType.UNKNOWN; + } + } + + /** + * Blob URL의 보호 상태를 확인합니다. + */ + private CheckResult checkBlobProtection(String videoUrl) { + // Blob URL은 브라우저에서 생성되므로 일반적으로 보호되지 않음 + // 단, 원본 소스가 보호된 경우일 수 있음 + return new CheckResult(false, Messages.PROTECTION_BLOB_URL); + } + + /** + * 스트리밍 URL의 보호 상태를 확인합니다. + */ + private CheckResult checkStreamingProtection(String videoUrl) { + String lowerUrl = videoUrl.toLowerCase(); + + // 1. DRM 관련 키워드 확인 + for (String keyword : DRM_KEYWORDS) { + if (lowerUrl.contains(keyword)) { + return new CheckResult(true, Messages.format(Messages.PROTECTION_DRM_KEYWORD_DETECTED, keyword)); + } + } + + // 2. 토큰 기반 인증 확인 + if (urlParser.hasSignatureParameters(videoUrl)) { + return new CheckResult(true, Messages.PROTECTION_SIGNED_URL); + } + + // 3. HTTP 헤더 확인 + try { + if (httpUtils.hasSecurityHeaders(videoUrl)) { + return new CheckResult(true, Messages.PROTECTION_SECURITY_HEADER_DETECTED); + } + return new CheckResult(false, Messages.PROTECTION_NOT_PROTECTED_STREAMING); + } catch (IOException e) { + return new CheckResult(true, Messages.format(Messages.PROTECTION_CONNECTION_FAILED, e.getMessage())); + } + } + + /** + * 직접 파일 URL의 보호 상태를 확인합니다. + */ + private CheckResult checkDirectFileProtection(String videoUrl) { + String lowerUrl = videoUrl.toLowerCase(); + + // 1. DRM 관련 키워드 확인 + for (String keyword : DRM_KEYWORDS) { + if (lowerUrl.contains(keyword)) { + return new CheckResult(true, Messages.format(Messages.PROTECTION_DRM_KEYWORD_DETECTED, keyword)); + } + } + + // 2. Signed URL 검사 + if (urlParser.hasSignatureParameters(videoUrl)) { + return new CheckResult(true, Messages.PROTECTION_SIGNED_URL); + } + + // 3. HTTP 헤더를 통한 보안 검사 + try { + if (httpUtils.hasSecurityHeaders(videoUrl)) { + return new CheckResult(true, Messages.PROTECTION_SECURITY_HEADER_DETECTED); + } + return new CheckResult(false, Messages.PROTECTION_NOT_PROTECTED_DIRECT_FILE); + } catch (IOException e) { + return new CheckResult(true, Messages.format(Messages.PROTECTION_CONNECTION_FAILED, e.getMessage())); + } + } + + /** + * 일반적인 URL의 보호 상태를 확인합니다. + */ + private CheckResult checkGenericProtection(String videoUrl) { + // 기존의 일반적인 보호 URL 검사 로직 + return checkDirectFileProtection(videoUrl); + } + + /** + * URL이 3D 비디오인지 분석합니다. + */ + private CheckResult check3DVideo(String videoUrl) { + if (videoUrl == null || videoUrl.trim().isEmpty()) { + return new CheckResult(false, Messages.ERROR_INVALID_URL); + } + + // 필요시 헤더나 메타데이터 확인 + try { + if (httpUtils.checkFor3DHeaders(videoUrl)) { + return new CheckResult(true, Messages.THREE_D_HEADER_DETECTED); + } + return new CheckResult(false, Messages.THREE_D_NOT_DETECTED); + } catch (IOException e) { + return new CheckResult(false, Messages.format(Messages.THREE_D_CONNECTION_FAILED, e.getMessage())); + } + } + + /** + * URL이 3D 비디오인지 분석합니다 (하위 호환성을 위한 메소드). + * @deprecated check3DVideo 사용을 권장합니다 + */ + @Deprecated + public boolean is3DVideo(String videoUrl) { + return check3DVideo(videoUrl).result; + } + + /** + * URL이 AVPlayer에서 재생 가능한지 분석합니다. + */ + private CheckResult checkPlayability(String videoUrl) { + if (videoUrl == null || videoUrl.trim().isEmpty()) { + return new CheckResult(false, Messages.ERROR_INVALID_URL); + } + + // 1. 포맷 검사 (파일 확장자) + String extension = urlParser.getExtension(videoUrl); + if (extension != null && SUPPORTED_FORMATS.contains(extension.toLowerCase())) { + return new CheckResult(true, Messages.format(Messages.PLAYABILITY_SUPPORTED_FORMAT, extension)); + } + + // 2. 스트리밍 URL 패턴 검사 + if (urlParser.isStreamingUrl(videoUrl)) { + return new CheckResult(true, Messages.PLAYABILITY_SUPPORTED_STREAMING); + } + + // 3. 필요시 콘텐츠 타입 헤더 확인 + try { + String contentType = httpUtils.getContentType(videoUrl); + if (contentType != null && ( + contentType.contains("video/") || + contentType.contains("application/x-mpegURL") || + contentType.contains("application/vnd.apple.mpegurl") + )) { + return new CheckResult(true, Messages.format(Messages.PLAYABILITY_SUPPORTED_CONTENT_TYPE, contentType)); + } + return new CheckResult(false, Messages.format(Messages.PLAYABILITY_UNSUPPORTED_FORMAT, contentType)); + } catch (IOException e) { + return new CheckResult(false, Messages.format(Messages.PLAYABILITY_CONNECTION_FAILED, e.getMessage())); + } + } + + /** + * URL이 AVPlayer에서 재생 가능한지 분석합니다 (하위 호환성을 위한 메소드). + * @deprecated checkPlayability 사용을 권장합니다 + */ + @Deprecated + public boolean isPlayableOnAVPlayer(String videoUrl) { + return checkPlayability(videoUrl).result; + } + + /** + * 모든 분석을 한번에 수행하고 결과를 반환합니다. + * 보호되지 않고, 2D 비디오이고, AVPlayer에서 재생 가능하면 성공입니다. + */ + public VideoAnalysisResult analyzeVideo(String videoUrl) { + CheckResult protectionResult = checkProtection(videoUrl); + CheckResult threeDResult = check3DVideo(videoUrl); + CheckResult playabilityResult = checkPlayability(videoUrl); + + // 실패 이유 수집 + StringBuilder failureReasons = new StringBuilder(); + + // 보호된 URL이면 실패 + if (protectionResult.result) { + failureReasons.append(Messages.ANALYSIS_PROTECTED_URL_PREFIX) + .append(protectionResult.reason).append("\n"); + } + + // 3D 비디오이면 실패 +// if (threeDResult.result) { +// failureReasons.append(Messages.ANALYSIS_THREE_D_VIDEO_PREFIX) +// .append(threeDResult.reason).append("\n"); +// } + + // AVPlayer에서 재생 불가능하면 실패 + if (!playabilityResult.result) { + failureReasons.append(Messages.ANALYSIS_NOT_PLAYABLE_PREFIX) + .append(playabilityResult.reason).append("\n"); + } + + // 실패 이유가 있으면 실패, 없으면 성공 + if (!failureReasons.isEmpty()) { + return VideoAnalysisResult.failure( + Messages.ANALYSIS_FAILURE_HEADER + "\n" + failureReasons.toString().trim() + ); + } else { + return VideoAnalysisResult.success(); + } + } +} diff --git a/backup/src/main/java/com/caliverse/video/global/common/CommonConstants.java b/backup/src/main/java/com/caliverse/video/global/common/CommonConstants.java new file mode 100644 index 0000000..bd9c171 --- /dev/null +++ b/backup/src/main/java/com/caliverse/video/global/common/CommonConstants.java @@ -0,0 +1,26 @@ +package com.caliverse.video.global.common; + +import java.util.regex.Pattern; + +public class CommonConstants { + // 도메인 + public static final String DOMAIN_NAVER_TV = "naver.com"; + public static final String DOMAIN_NAVER = "naver.me"; + public static final String FLATFORM_NAME_NAVER_TV = "네이버TV"; + + // 정규표현식 + public static final Pattern NAVER_NEXT_DATA_PATTERN = Pattern.compile(""); + public static final Pattern NAVER_VIDEO_ID_PATTERN = Pattern.compile("\"videoId\"\\s*:\\s*\"([^\"]+)\""); + public static final Pattern NAVER_IN_KEY_PATTERN = Pattern.compile("\"inKey\"\\s*:\\s*\"([^\"]+)\""); + + // API URL + public static final String NAVER_VOD_API_URL = "https://apis.naver.com/neonplayer/vodplay/v3/playback/%s?key=%s"; + public static final String NAVER_SHORT_API_URL = "https://api-videohub.naver.com/shortformhub/feeds/v7/card?serviceType=NTV&seedMediaId=%s&mediaType=VOD"; + + //헤더 + public static final String REQUEST_HEADER_NAME_USER_AGENT = "User-Agent"; + public static final String REQUEST_HEADER_NAME_REFERER = "Referer"; + + public static final String REQUEST_HEADER_USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"; + public static final String REQUEST_HEADER_NAVER_REFERER = "https://tv.naver.com/"; +} diff --git a/backup/src/main/java/com/caliverse/video/global/common/Messages.java b/backup/src/main/java/com/caliverse/video/global/common/Messages.java new file mode 100644 index 0000000..b89f330 --- /dev/null +++ b/backup/src/main/java/com/caliverse/video/global/common/Messages.java @@ -0,0 +1,60 @@ +package com.caliverse.video.global.common; + +/** + * 분석 결과 메시지를 관리하는 상수 클래스입니다. + */ +public class Messages { + + // ========== 일반 메시지 ========== + public static final String SUCCESS_ALL_CHECKS_PASSED = "모든 검사 통과"; + public static final String ERROR_INVALID_URL = "유효하지 않은 URL"; + + // ========== 보호 URL 체크 메시지 ========== + public static final String PROTECTION_YOUTUBE_URL = "YouTube URL (서명 정보 필요)"; + public static final String PROTECTION_BLOB_URL = "Blob URL (브라우저에서 생성된 URL)"; + public static final String PROTECTION_DRM_KEYWORD_DETECTED = "DRM 보호 키워드 감지: %s"; + public static final String PROTECTION_SIGNED_URL = "서명된 URL (토큰 기반 인증)"; + public static final String PROTECTION_SECURITY_HEADER_DETECTED = "보안 헤더 감지"; + public static final String PROTECTION_NOT_PROTECTED_STREAMING = "보호되지 않은 스트리밍 URL"; + public static final String PROTECTION_NOT_PROTECTED_DIRECT_FILE = "보호되지 않은 직접 파일 URL"; + public static final String PROTECTION_CONNECTION_FAILED = "연결 실패 (보호된 것으로 간주): %s"; + + // ========== 3D 비디오 체크 메시지 ========== + public static final String THREE_D_HEADER_DETECTED = "3D 비디오 헤더 감지"; + public static final String THREE_D_NOT_DETECTED = "일반 2D 비디오"; + public static final String THREE_D_CONNECTION_FAILED = "연결 실패 (2D로 간주): %s"; + + // ========== 재생 가능성 체크 메시지 ========== + public static final String PLAYABILITY_SUPPORTED_FORMAT = "지원되는 포맷: %s"; + public static final String PLAYABILITY_SUPPORTED_STREAMING = "지원되는 스트리밍 URL"; + public static final String PLAYABILITY_SUPPORTED_CONTENT_TYPE = "지원되는 Content-Type: %s"; + public static final String PLAYABILITY_UNSUPPORTED_FORMAT = "지원되지 않는 포맷 또는 Content-Type: %s"; + public static final String PLAYABILITY_CONNECTION_FAILED = "연결 실패 (재생 불가능으로 간주): %s"; + + // ========== 전체 분석 결과 메시지 ========== + public static final String ANALYSIS_FAILURE_HEADER = "재생 불가 사유:"; + public static final String ANALYSIS_PROTECTED_URL_PREFIX = "- 보호된 URL: "; + public static final String ANALYSIS_THREE_D_VIDEO_PREFIX = "- 3D 비디오: "; + public static final String ANALYSIS_NOT_PLAYABLE_PREFIX = "- 재생 불가: "; + + // ========== 네이버TV 관련 메시지 ========== + public static final String NAVER_TV_PARSING_FAILED = "네이버TV URL 파싱 실패: videoId 또는 inKey를 찾을 수 없습니다."; + public static final String NAVER_TV_API_PARSING_FAILED = "네이버TV API 응답 파싱 실패: 비디오 URL을 찾을 수 없습니다."; + public static final String NAVER_TV_RESTRICTION = "네이버TV 제한사항: %s"; + public static final String NAVER_TV_ANALYSIS_ERROR = "네이버TV 분석 중 오류 발생: %s"; + + private Messages() { + // 인스턴스화 방지 + } + + /** + * 포맷 문자열을 사용하는 메시지를 생성합니다. + * + * @param format 포맷 문자열 + * @param args 포맷 인자 + * @return 포맷팅된 메시지 + */ + public static String format(String format, Object... args) { + return String.format(format, args); + } +} diff --git a/backup/src/main/java/com/caliverse/video/global/util/HttpUtils.java b/backup/src/main/java/com/caliverse/video/global/util/HttpUtils.java new file mode 100644 index 0000000..0be0b2b --- /dev/null +++ b/backup/src/main/java/com/caliverse/video/global/util/HttpUtils.java @@ -0,0 +1,165 @@ +package com.caliverse.video.global.util; + +import org.apache.http.Header; +import org.apache.http.HttpHeaders; +import org.apache.http.client.config.RequestConfig; +import org.apache.http.client.methods.CloseableHttpResponse; +import org.apache.http.client.methods.HttpHead; +import org.apache.http.impl.client.CloseableHttpClient; +import org.apache.http.impl.client.HttpClients; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; + +/** + * HTTP 요청 관련 유틸리티 클래스입니다. + */ +public class HttpUtils { + + private static final Logger logger = LoggerFactory.getLogger(HttpUtils.class); + private static final int CONNECTION_TIMEOUT = 5000; // 5초 + + /** + * URL의 HTTP 헤더에서 보안 관련 헤더가 있는지 확인합니다. + * + * @param url 확인할 URL + * @return 보안 헤더가 있으면 true, 없으면 false + * @throws IOException HTTP 요청 실패 시 + */ + public boolean hasSecurityHeaders(String url) throws IOException { + HttpHead request = new HttpHead(url); + RequestConfig config = RequestConfig.custom() + .setConnectTimeout(CONNECTION_TIMEOUT) + .setSocketTimeout(CONNECTION_TIMEOUT) + .build(); + request.setConfig(config); + + try (CloseableHttpClient httpClient = HttpClients.createDefault(); + CloseableHttpResponse response = httpClient.execute(request)) { + + // CORS 헤더 확인 + if (response.containsHeader("Access-Control-Allow-Origin")) { + return true; + } + + // 인증 관련 헤더 확인 + if (response.containsHeader("WWW-Authenticate") || + response.containsHeader("Authorization")) { + return true; + } + + // DRM 관련 헤더 확인 + if (response.containsHeader("X-DRM-Type") || + response.containsHeader("X-Content-Protection")) { + return true; + } + + // 기타 보안 헤더 확인 + for (Header header : response.getAllHeaders()) { + String headerName = header.getName().toLowerCase(); + String headerValue = header.getValue().toLowerCase(); + + if (headerName.contains("token") || + headerName.contains("auth") || + headerName.contains("drm") || + headerValue.contains("token") || + headerValue.contains("protection")) { + return true; + } + } + + return false; + } + } + + /** + * URL의 HTTP 헤더에서 3D 비디오 관련 정보가 있는지 확인합니다. + * + * @param url 확인할 URL + * @return 3D 비디오 헤더가 있으면 true, 없으면 false + * @throws IOException HTTP 요청 실패 시 + */ + public boolean checkFor3DHeaders(String url) throws IOException { + HttpHead request = new HttpHead(url); + // User-Agent 헤더 추가 (일반적인 브라우저로 위장) + request.setHeader("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36"); + + // 기타 일반적인 브라우저 헤더들 추가 + request.setHeader("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/json"); + request.setHeader("Accept-Language", "ko-KR,ko;q=0.9,en;q=0.8"); + request.setHeader("Accept-Encoding", "gzip, deflate, br"); + request.setHeader("Connection", "keep-alive"); + request.setHeader("Upgrade-Insecure-Requests", "1"); + request.setHeader("Referer", "https://tv.naver.com"); + + RequestConfig config = RequestConfig.custom() + .setConnectTimeout(CONNECTION_TIMEOUT) + .setSocketTimeout(CONNECTION_TIMEOUT) + .build(); + request.setConfig(config); + + try (CloseableHttpClient httpClient = HttpClients.createDefault(); + CloseableHttpResponse response = httpClient.execute(request)) { + + int statusCode = response.getStatusLine().getStatusCode(); + + if (statusCode >= 400) { + logger.warn("HTTP Error {} for URL: {}", statusCode, url); + return false; // 에러 발생 시 보안 헤더 없다고 간주 + } + + // 3D 관련 커스텀 헤더 확인 + if (response.containsHeader("X-Video-Type")) { + String videoType = response.getFirstHeader("X-Video-Type").getValue(); + if (videoType.toLowerCase().contains("3d") || + videoType.toLowerCase().contains("stereoscopic")) { + return true; + } + } + + // 기타 헤더에서 3D 관련 키워드 확인 + for (Header header : response.getAllHeaders()) { + String headerValue = header.getValue().toLowerCase(); + if (headerValue.contains("3d") || + headerValue.contains("stereoscopic")) { + return true; + } + } + + // 콘텐츠 타입 헤더 확인 + if (response.containsHeader(HttpHeaders.CONTENT_TYPE)) { + String contentType = response.getFirstHeader(HttpHeaders.CONTENT_TYPE).getValue(); + return contentType.contains("3d") || contentType.contains("stereoscopic"); + } + + return false; + } + } + + /** + * URL의 HTTP 헤더에서 Content-Type을 가져옵니다. + * + * @param url 확인할 URL + * @return Content-Type 문자열, 헤더가 없거나 요청 실패 시 null + * @throws IOException HTTP 요청 실패 시 + */ + public String getContentType(String url) throws IOException { + HttpHead request = new HttpHead(url); + RequestConfig config = RequestConfig.custom() + .setConnectTimeout(CONNECTION_TIMEOUT) + .setSocketTimeout(CONNECTION_TIMEOUT) + .build(); + request.setConfig(config); + + try (CloseableHttpClient httpClient = HttpClients.createDefault(); + CloseableHttpResponse response = httpClient.execute(request)) { + + if (response.containsHeader(HttpHeaders.CONTENT_TYPE)) { + return response.getFirstHeader(HttpHeaders.CONTENT_TYPE).getValue(); + } + + return null; + } + } +} diff --git a/backup/src/main/java/com/caliverse/video/global/util/OEmbedFetcher.java b/backup/src/main/java/com/caliverse/video/global/util/OEmbedFetcher.java new file mode 100644 index 0000000..c50e75b --- /dev/null +++ b/backup/src/main/java/com/caliverse/video/global/util/OEmbedFetcher.java @@ -0,0 +1,575 @@ +package com.caliverse.video.global.util; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.apache.http.client.methods.CloseableHttpResponse; +import org.apache.http.client.methods.HttpGet; +import org.apache.http.impl.client.CloseableHttpClient; +import org.apache.http.impl.client.HttpClients; +import org.apache.http.util.EntityUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.Map; + +/** + * oEmbed API를 활용하여 영상 메타데이터를 가져오는 유틸리티 클래스입니다. + * + *

지원 플랫폼: + *

    + *
  • 네이버TV
  • + *
  • YouTube
  • + *
  • Vimeo
  • + *
+ */ +public class OEmbedFetcher { + + private static final Logger logger = LoggerFactory.getLogger(OEmbedFetcher.class); + private final ObjectMapper objectMapper; + + // oEmbed 엔드포인트 매핑 + private static final Map OEMBED_ENDPOINTS = new HashMap<>(); + + static { + OEMBED_ENDPOINTS.put("naver.com", "https://tv.naver.com/oembed"); + OEMBED_ENDPOINTS.put("naver.me", "https://tv.naver.com/oembed"); + OEMBED_ENDPOINTS.put("youtube.com", "https://www.youtube.com/oembed"); + OEMBED_ENDPOINTS.put("youtu.be", "https://www.youtube.com/oembed"); + OEMBED_ENDPOINTS.put("vimeo.com", "https://vimeo.com/api/oembed.json"); + } + + public OEmbedFetcher() { + this.objectMapper = new ObjectMapper(); + } + + /** + * oEmbed API를 통해 영상 메타데이터를 가져옵니다. + * + * @param videoUrl 영상 URL + * @return oEmbed 응답 데이터 + * @throws IOException API 요청 실패 시 + */ + public OEmbedResponse fetchOEmbedData(String videoUrl) throws IOException { + // 단축 URL(naver.me)인 경우 실제 URL로 변환 + String resolvedUrl = resolveShortUrl(videoUrl); + logger.debug("원본 URL: {}, 해결된 URL: {}", videoUrl, resolvedUrl); + + String oembedEndpoint = getOEmbedEndpoint(resolvedUrl); + + if (oembedEndpoint == null) { + throw new IllegalArgumentException("지원하지 않는 플랫폼입니다: " + resolvedUrl); + } + + String oembedUrl = buildOEmbedUrl(oembedEndpoint, resolvedUrl); + logger.debug("oEmbed URL: {}", oembedUrl); + + String jsonResponse = fetchOEmbedJson(oembedUrl); + OEmbedResponse response = parseOEmbedResponse(jsonResponse); + // 네이버TV 쇼츠의 경우 oEmbed가 빈 응답을 반환하면 직접 메타데이터 구성 + if (isEmptyResponse(response) && isNaverTvShortUrl(resolvedUrl)) { + response = buildNaverTvShortResponse(resolvedUrl); + logger.debug("네이버TV 쇼츠 URL에 대한 fallback 응답 생성"); + } + + return response; + } + + /** + * 단축 URL을 실제 URL로 변환합니다. + * naver.me와 같은 단축 URL의 경우 HTTP 리다이렉트를 따라가서 최종 URL을 반환합니다. + * + * @param url 원본 URL + * @return 해결된 URL (단축 URL이 아니면 원본 URL 반환) + * @throws IOException 리다이렉트 처리 실패 시 + */ + private String resolveShortUrl(String url) throws IOException { + // naver.me 단축 URL인지 확인 + if (!url.toLowerCase().contains("naver.me")) { + return url; + } + + try (CloseableHttpClient httpClient = HttpClients.custom() + .disableRedirectHandling() // 자동 리다이렉트 비활성화 + .build()) { + + HttpGet request = new HttpGet(url); + request.setHeader("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"); + + try (CloseableHttpResponse response = httpClient.execute(request)) { + int statusCode = response.getStatusLine().getStatusCode(); + + // 리다이렉트 상태 코드 확인 (301, 302, 303, 307, 308) + if (statusCode >= 300 && statusCode < 400) { + String location = response.getFirstHeader("Location").getValue(); + if (location != null && !location.isEmpty()) { + logger.debug("단축 URL 리다이렉트: {} -> {}", url, location); + return location; + } + } + + // 리다이렉트가 없으면 원본 URL 반환 + return url; + } + } + } + + /** + * URL에서 적절한 oEmbed 엔드포인트를 찾습니다. + */ + private String getOEmbedEndpoint(String videoUrl) { + String lowerUrl = videoUrl.toLowerCase(); + + for (Map.Entry entry : OEMBED_ENDPOINTS.entrySet()) { + if (lowerUrl.contains(entry.getKey())) { + return entry.getValue(); + } + } + + return null; + } + + /** + * oEmbed API URL을 생성합니다. + */ + private String buildOEmbedUrl(String endpoint, String videoUrl) { + try { + String encodedUrl = URLEncoder.encode(videoUrl, StandardCharsets.UTF_8.toString()); + return endpoint + "?url=" + encodedUrl + "&format=json"; + } catch (Exception e) { + throw new RuntimeException("URL 인코딩 실패", e); + } + } + + /** + * oEmbed API를 호출하여 JSON 응답을 가져옵니다. + */ + private String fetchOEmbedJson(String oembedUrl) throws IOException { + try (CloseableHttpClient httpClient = HttpClients.createDefault()) { + HttpGet request = new HttpGet(oembedUrl); + request.setHeader("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"); + request.setHeader("Accept", "application/json"); + + try (CloseableHttpResponse response = httpClient.execute(request)) { + int statusCode = response.getStatusLine().getStatusCode(); + + if (statusCode != 200) { + throw new IOException("oEmbed API 호출 실패. 상태 코드: " + statusCode); + } + + return EntityUtils.toString(response.getEntity(), StandardCharsets.UTF_8); + } + } + } + + /** + * oEmbed JSON 응답을 파싱합니다. + */ + private OEmbedResponse parseOEmbedResponse(String jsonResponse) throws IOException { + try { + JsonNode rootNode = objectMapper.readTree(jsonResponse); + + OEmbedResponse response = new OEmbedResponse(); + response.setType(getStringValue(rootNode, "type")); + response.setVersion(getStringValue(rootNode, "version")); + response.setTitle(getStringValue(rootNode, "title")); + response.setAuthorName(getStringValue(rootNode, "author_name")); + response.setAuthorUrl(getStringValue(rootNode, "author_url")); + response.setProviderName(getStringValue(rootNode, "provider_name")); + response.setProviderUrl(getStringValue(rootNode, "provider_url")); + response.setThumbnailUrl(getStringValue(rootNode, "thumbnail_url")); + response.setThumbnailWidth(getIntValue(rootNode, "thumbnail_width")); + response.setThumbnailHeight(getIntValue(rootNode, "thumbnail_height")); + response.setWidth(getIntValue(rootNode, "width")); + response.setHeight(getIntValue(rootNode, "height")); + response.setHtml(getStringValue(rootNode, "html")); + + // 원본 JSON도 저장 + response.setRawJson(jsonResponse); + + return response; + } catch (Exception e) { + throw new IOException("oEmbed 응답 파싱 실패: " + e.getMessage(), e); + } + } + + private String getStringValue(JsonNode node, String fieldName) { + JsonNode fieldNode = node.get(fieldName); + return fieldNode != null && !fieldNode.isNull() ? fieldNode.asText() : null; + } + + private Integer getIntValue(JsonNode node, String fieldName) { + JsonNode fieldNode = node.get(fieldName); + return fieldNode != null && !fieldNode.isNull() ? fieldNode.asInt() : null; + } + + /** + * oEmbed 응답이 비어있는지 확인합니다. + */ + private boolean isEmptyResponse(OEmbedResponse response) { + return response != null && + response.getType() == null && + response.getTitle() == null && + (response.getWidth() == null || response.getWidth() == 0) && + (response.getHeight() == null || response.getHeight() == 0); + } + + /** + * 네이버TV 쇼츠 URL인지 확인합니다. + */ + private boolean isNaverTvShortUrl(String url) { + return url != null && url.toLowerCase().contains("tv.naver.com/h/"); + } + + /** + * 네이버TV 쇼츠 URL에서 ID를 추출하고 기본 메타데이터를 생성합니다. + */ + private OEmbedResponse buildNaverTvShortResponse(String url) { + try { + // URL에서 ID 추출: https://tv.naver.com/h/87349660 -> 87349660 + String id = extractNaverTvId(url); + if (id == null) { + return null; + } + + // HTML 페이지에서 메타데이터 추출 + MetaData metaData = extractMetaDataFromHtml(url); + + // embed URL 생성 + String embedUrl = "https://tv.naver.com/embed/" + id + "?autoPlay=true"; + String html = ""; + + OEmbedResponse response = new OEmbedResponse(); + response.setType("video"); + response.setVersion("1.0"); + response.setProviderName("NAVERTV"); + response.setProviderUrl("https://tv.naver.com"); + response.setWidth(544); + response.setHeight(306); + response.setHtml(html); + + // HTML에서 추출한 메타데이터 설정 + if (metaData != null) { + response.setTitle(metaData.title); + response.setAuthorName(metaData.authorName); + response.setThumbnailUrl(metaData.thumbnailUrl); + if (metaData.thumbnailUrl != null) { + // 네이버TV 썸네일은 일반적으로 16:9 비율 + response.setThumbnailWidth(720); + response.setThumbnailHeight(405); + } + } + + // JSON 구성 + String rawJson = buildShortResponseJson(response, embedUrl); + response.setRawJson(rawJson); + + return response; + } catch (Exception e) { + logger.warn("네이버TV 쇼츠 응답 생성 실패: {}", e.getMessage()); + return null; + } + } + + /** + * HTML 페이지에서 Open Graph 메타데이터를 추출합니다. + */ + private MetaData extractMetaDataFromHtml(String url) { + try { + String htmlContent = fetchHtmlContent(url); + MetaData metaData = new MetaData(); + + // og:description에서 제목 추출 + metaData.title = extractMetaTag(htmlContent, "og:description"); + + // og:title에서 작성자 이름 추출 + metaData.authorName = extractMetaTag(htmlContent, "og:title"); + + // og:image에서 썸네일 URL 추출 + metaData.thumbnailUrl = extractMetaTag(htmlContent, "og:image"); + + logger.debug("쇼츠 메타데이터 추출 - 제목: {}, 작성자: {}", metaData.title, metaData.authorName); + + return metaData; + } catch (Exception e) { + logger.warn("HTML 메타데이터 추출 실패: {}", e.getMessage()); + return null; + } + } + + /** + * HTML에서 특정 meta 태그의 content 값을 추출합니다. + */ + private String extractMetaTag(String htmlContent, String property) { + try { + // 패턴 매칭 + String pattern = " 87349660 + */ + private String extractNaverTvId(String url) { + try { + // /h/ 또는 /v/ 패턴에서 ID 추출 + String[] patterns = {"/h/", "/v/", "/l/"}; + + for (String pattern : patterns) { + int index = url.indexOf(pattern); + if (index != -1) { + String remaining = url.substring(index + pattern.length()); + // ? 또는 / 이전까지가 ID + int endIndex = remaining.indexOf('?'); + if (endIndex == -1) { + endIndex = remaining.indexOf('/'); + } + if (endIndex == -1) { + return remaining; + } else { + return remaining.substring(0, endIndex); + } + } + } + + return null; + } catch (Exception e) { + logger.warn("네이버TV ID 추출 실패: {}", e.getMessage()); + return null; + } + } + + /** + * 메타데이터를 담는 내부 클래스입니다. + */ + private static class MetaData { + String title; + String authorName; + String thumbnailUrl; + } + + /** + * oEmbed 응답 데이터를 담는 클래스입니다. + */ + public static class OEmbedResponse { + private String type; + private String version; + private String title; + private String authorName; + private String authorUrl; + private String providerName; + private String providerUrl; + private String thumbnailUrl; + private Integer thumbnailWidth; + private Integer thumbnailHeight; + private Integer width; + private Integer height; + private String html; + private String rawJson; + + // Getters and Setters + + public String getType() { + return type; + } + + public void setType(String type) { + this.type = type; + } + + public String getVersion() { + return version; + } + + public void setVersion(String version) { + this.version = version; + } + + public String getTitle() { + return title; + } + + public void setTitle(String title) { + this.title = title; + } + + public String getAuthorName() { + return authorName; + } + + public void setAuthorName(String authorName) { + this.authorName = authorName; + } + + public String getAuthorUrl() { + return authorUrl; + } + + public void setAuthorUrl(String authorUrl) { + this.authorUrl = authorUrl; + } + + public String getProviderName() { + return providerName; + } + + public void setProviderName(String providerName) { + this.providerName = providerName; + } + + public String getProviderUrl() { + return providerUrl; + } + + public void setProviderUrl(String providerUrl) { + this.providerUrl = providerUrl; + } + + public String getThumbnailUrl() { + return thumbnailUrl; + } + + public void setThumbnailUrl(String thumbnailUrl) { + this.thumbnailUrl = thumbnailUrl; + } + + public Integer getThumbnailWidth() { + return thumbnailWidth; + } + + public void setThumbnailWidth(Integer thumbnailWidth) { + this.thumbnailWidth = thumbnailWidth; + } + + public Integer getThumbnailHeight() { + return thumbnailHeight; + } + + public void setThumbnailHeight(Integer thumbnailHeight) { + this.thumbnailHeight = thumbnailHeight; + } + + public Integer getWidth() { + return width; + } + + public void setWidth(Integer width) { + this.width = width; + } + + public Integer getHeight() { + return height; + } + + public void setHeight(Integer height) { + this.height = height; + } + + public String getHtml() { + return html; + } + + public void setHtml(String html) { + this.html = html; + } + + public String getRawJson() { + return rawJson; + } + + public void setRawJson(String rawJson) { + this.rawJson = rawJson; + } + + @Override + public String toString() { + return "OEmbedResponse{" + + "type='" + type + '\'' + + ", version='" + version + '\'' + + ", title='" + title + '\'' + + ", authorName='" + authorName + '\'' + + ", providerName='" + providerName + '\'' + + ", thumbnailUrl='" + thumbnailUrl + '\'' + + ", width=" + width + + ", height=" + height + + '}'; + } + } +} diff --git a/backup/src/main/java/com/caliverse/video/global/util/UrlParser.java b/backup/src/main/java/com/caliverse/video/global/util/UrlParser.java new file mode 100644 index 0000000..5db802a --- /dev/null +++ b/backup/src/main/java/com/caliverse/video/global/util/UrlParser.java @@ -0,0 +1,107 @@ +package com.caliverse.video.global.util; + +import java.net.MalformedURLException; +import java.net.URL; +import java.util.Arrays; +import java.util.List; + +/** + * URL 분석을 위한 유틸리티 클래스입니다. + */ +public class UrlParser { + + // 서명된 URL에 포함될 수 있는 파라미터 목록 + private static final List SIGNATURE_PARAMS = Arrays.asList( + "token", "signature", "sig", "expires", "exp", "auth", + "key", "access_token", "api_key", "hmac", "hash" + ); + + // 스트리밍 서비스의 URL 패턴 목록 + private static final List STREAMING_DOMAINS = Arrays.asList( + "vimeo.com", "dailymotion.com", + "netflix.com", "hulu.com", "amazon.com/video", "player.vimeo.com", + "twitch.tv", "hls.", ".m3u8" + ); + + /** + * URL에서 파일 확장자를 추출합니다. + * + * @param url 분석할 URL 문자열 + * @return 파일 확장자, 없으면 null + */ + public String getExtension(String url) { + if (url == null || url.trim().isEmpty()) { + return null; + } + + try { + URL urlObj = new URL(url); + String path = urlObj.getPath(); + + if (path != null && path.contains(".")) { + int lastDotIndex = path.lastIndexOf("."); + int queryIndex = path.indexOf("?", lastDotIndex); + + if (queryIndex > 0) { + return path.substring(lastDotIndex + 1, queryIndex); + } else { + return path.substring(lastDotIndex + 1); + } + } + } catch (MalformedURLException e) { + // URL 파싱 실패 시 null 반환 + } + + return null; + + } + + /** + * URL이 서명된 URL인지 확인합니다. + * + * @param url 분석할 URL 문자열 + * @return 서명된 URL이면 true, 아니면 false + */ + public boolean hasSignatureParameters(String url) { + if (url == null || !url.contains("?")) { + return false; + } + + String lowerUrl = url.toLowerCase(); + String queryPart = lowerUrl.substring(lowerUrl.indexOf('?') + 1); + String[] params = queryPart.split("&"); + + for (String param : params) { + for (String sigParam : SIGNATURE_PARAMS) { + if (param.startsWith(sigParam + "=") || param.contains("=" + sigParam)) { + return true; + } + } + } + + return false; + } + + /** + * URL이 스트리밍 서비스 URL인지 확인합니다. + * + * @param url 분석할 URL 문자열 + * @return 스트리밍 URL이면 true, 아니면 false + */ + public boolean isStreamingUrl(String url) { + if (url == null || url.isEmpty()) { + return false; + } + + String lowerUrl = url.toLowerCase(); + + for (String domain : STREAMING_DOMAINS) { + if (lowerUrl.contains(domain)) { + return true; + } + } + + // HLS 스트리밍 확인 + return lowerUrl.contains("m3u8") || lowerUrl.contains("mpd") || lowerUrl.contains("manifest"); + } +} diff --git a/backup/src/test/java/com/video/analyzer/OEmbedFetcherTest.java b/backup/src/test/java/com/video/analyzer/OEmbedFetcherTest.java new file mode 100644 index 0000000..ac1698f --- /dev/null +++ b/backup/src/test/java/com/video/analyzer/OEmbedFetcherTest.java @@ -0,0 +1,349 @@ +package com.video.analyzer; + +import com.caliverse.video.global.util.OEmbedFetcher; +import org.junit.Before; +import org.junit.Test; + +import static org.junit.Assert.*; + +/** + * OEmbedFetcher 테스트 클래스입니다. + * + * 실제 API를 호출하여 oEmbed 데이터를 가져오는 테스트를 수행합니다. + */ +public class OEmbedFetcherTest { + + private OEmbedFetcher oembedFetcher; + + @Before + public void setUp() { + oembedFetcher = new OEmbedFetcher(); + } + + @Test + public void testFetchOEmbed_NaverTV() { + // 네이버TV URL 테스트 + String naverUrl = "https://tv.naver.com/v/40687083"; + + try { + OEmbedFetcher.OEmbedResponse response = oembedFetcher.fetchOEmbedData(naverUrl); + + assertNotNull("응답이 null이 아니어야 합니다", response); + assertNotNull("타입이 있어야 합니다", response.getType()); + assertNotNull("제목이 있어야 합니다", response.getTitle()); + assertNotNull("제공자 이름이 있어야 합니다", response.getProviderName()); + + // 결과 출력 + System.out.println("=== 네이버TV oEmbed 결과 ==="); + System.out.println("타입: " + response.getType()); + System.out.println("제목: " + response.getTitle()); + System.out.println("작성자: " + response.getAuthorName()); + System.out.println("제공자: " + response.getProviderName()); + System.out.println("썸네일 URL: " + response.getThumbnailUrl()); + System.out.println("썸네일 크기: " + response.getThumbnailWidth() + "x" + response.getThumbnailHeight()); + System.out.println("비디오 크기: " + response.getWidth() + "x" + response.getHeight()); + System.out.println("\n원본 JSON:"); + System.out.println(response.getRawJson()); + + } catch (Exception e) { + System.err.println("네이버TV oEmbed 테스트 실패: " + e.getMessage()); + e.printStackTrace(); + } + } + +// @Test +// public void testFetchOEmbed_NaverTV() { +// // 네이버TV URL 테스트 +// String naverUrl = "https://tv.naver.com/v/40687083"; +// +// try { +// OEmbedFetcher.OEmbedResponse response = oembedFetcher.fetchOEmbedData(naverUrl); +// +// assertNotNull("응답이 null이 아니어야 합니다", response); +// assertNotNull("타입이 있어야 합니다", response.getType()); +// assertNotNull("제목이 있어야 합니다", response.getTitle()); +// assertNotNull("제공자 이름이 있어야 합니다", response.getProviderName()); +// +// // 결과 출력 +// System.out.println("=== 네이버TV oEmbed 결과 ==="); +// System.out.println("타입: " + response.getType()); +// System.out.println("제목: " + response.getTitle()); +// System.out.println("작성자: " + response.getAuthorName()); +// System.out.println("제공자: " + response.getProviderName()); +// System.out.println("썸네일 URL: " + response.getThumbnailUrl()); +// System.out.println("썸네일 크기: " + response.getThumbnailWidth() + "x" + response.getThumbnailHeight()); +// System.out.println("비디오 크기: " + response.getWidth() + "x" + response.getHeight()); +// System.out.println("\n원본 JSON:"); +// System.out.println(response.getRawJson()); +// +// } catch (Exception e) { +// System.err.println("네이버TV oEmbed 테스트 실패: " + e.getMessage()); +// e.printStackTrace(); +// } +// } +// +// @Test +// public void testFetchOEmbed_NaverTVLive() { +// // 네이버TV URL 테스트 +// String naverUrl = "https://tv.naver.com/l/171651"; +// +// try { +// OEmbedFetcher.OEmbedResponse response = oembedFetcher.fetchOEmbedData(naverUrl); +// +// assertNotNull("응답이 null이 아니어야 합니다", response); +// assertNotNull("타입이 있어야 합니다", response.getType()); +// assertNotNull("제목이 있어야 합니다", response.getTitle()); +// assertNotNull("제공자 이름이 있어야 합니다", response.getProviderName()); +// +// // 결과 출력 +// System.out.println("=== 네이버TV 라이브 oEmbed 결과 ==="); +// System.out.println("타입: " + response.getType()); +// System.out.println("제목: " + response.getTitle()); +// System.out.println("작성자: " + response.getAuthorName()); +// System.out.println("제공자: " + response.getProviderName()); +// System.out.println("썸네일 URL: " + response.getThumbnailUrl()); +// System.out.println("썸네일 크기: " + response.getThumbnailWidth() + "x" + response.getThumbnailHeight()); +// System.out.println("비디오 크기: " + response.getWidth() + "x" + response.getHeight()); +// System.out.println("\n원본 JSON:"); +// System.out.println(response.getRawJson()); +// +// } catch (Exception e) { +// System.err.println("네이버TV 라이브 oEmbed 테스트 실패: " + e.getMessage()); +// e.printStackTrace(); +// } +// } +// +// @Test +// public void testFetchOEmbed_NaverTVShort() { +// // 네이버TV 쇼츠 URL 테스트 +// // oEmbed API가 빈 응답을 반환하면 fallback으로 embed URL을 생성합니다. +// String naverUrl = "https://tv.naver.com/h/87349660"; +// +// try { +// OEmbedFetcher.OEmbedResponse response = oembedFetcher.fetchOEmbedData(naverUrl); +// +// // fallback 응답이 생성되어야 함 +// assertNotNull("응답이 null이 아니어야 합니다", response); +// assertNotNull("타입이 있어야 합니다", response.getType()); +// assertNotNull("제공자 이름이 있어야 합니다", response.getProviderName()); +// assertEquals("타입은 video여야 합니다", "video", response.getType()); +// assertEquals("제공자는 NAVERTV여야 합니다", "NAVERTV", response.getProviderName()); +// +// // HTML에 embed URL이 포함되어 있어야 함 +// assertNotNull("HTML이 있어야 합니다", response.getHtml()); +// assertTrue("HTML에 embed URL이 포함되어야 합니다", +// response.getHtml().contains("https://tv.naver.com/embed/87349660")); +// +// // 결과 출력 +// System.out.println("=== 네이버TV 쇼츠 oEmbed 결과 (fallback) ==="); +// System.out.println("타입: " + response.getType()); +// System.out.println("제목: " + response.getTitle()); +// System.out.println("작성자: " + response.getAuthorName()); +// System.out.println("제공자: " + response.getProviderName()); +// System.out.println("썸네일 URL: " + response.getThumbnailUrl()); +// System.out.println("썸네일 크기: " + response.getThumbnailWidth() + "x" + response.getThumbnailHeight()); +// System.out.println("비디오 크기: " + response.getWidth() + "x" + response.getHeight()); +// System.out.println("HTML: " + response.getHtml()); +// System.out.println("\n원본 JSON:"); +// System.out.println(response.getRawJson()); +// +// } catch (Exception e) { +// System.err.println("네이버TV 쇼츠 oEmbed 테스트 실패: " + e.getMessage()); +// e.printStackTrace(); +// fail("예외가 발생하면 안됩니다: " + e.getMessage()); +// } +// } +// +// @Test +// public void testFetchOEmbed_NaverTVShare() { +// // 네이버TV 단축 URL 테스트 +// // naver.me URL은 쇼츠(/h/) 형식으로 리다이렉트되며, +// // fallback으로 embed URL을 생성합니다. +// String naverUrl = "https://naver.me/ID3ODX2j"; +// +// try { +// OEmbedFetcher.OEmbedResponse response = oembedFetcher.fetchOEmbedData(naverUrl); +// +// // fallback 응답이 생성되어야 함 +// assertNotNull("응답이 null이 아니어야 합니다", response); +// assertNotNull("타입이 있어야 합니다", response.getType()); +// assertNotNull("제공자 이름이 있어야 합니다", response.getProviderName()); +// assertEquals("타입은 video여야 합니다", "video", response.getType()); +// +// // 결과 출력 +// System.out.println("=== 네이버TV 공유 oEmbed 결과 ==="); +// System.out.println("타입: " + response.getType()); +// System.out.println("제목: " + response.getTitle()); +// System.out.println("작성자: " + response.getAuthorName()); +// System.out.println("제공자: " + response.getProviderName()); +// System.out.println("썸네일 URL: " + response.getThumbnailUrl()); +// System.out.println("썸네일 크기: " + response.getThumbnailWidth() + "x" + response.getThumbnailHeight()); +// System.out.println("비디오 크기: " + response.getWidth() + "x" + response.getHeight()); +// System.out.println("HTML: " + response.getHtml()); +// System.out.println("\n원본 JSON:"); +// System.out.println(response.getRawJson()); +// +// } catch (Exception e) { +// System.err.println("네이버TV 공유 oEmbed 테스트 실패: " + e.getMessage()); +// e.printStackTrace(); +// fail("예외가 발생하면 안됩니다: " + e.getMessage()); +// } +// } +// +// @Test +// public void testFetchOEmbed_YouTube() { +// // YouTube URL 테스트 +// String youtubeUrl = "https://www.youtube.com/watch?v=dQw4w9WgXcQ"; +// +// try { +// OEmbedFetcher.OEmbedResponse response = oembedFetcher.fetchOEmbedData(youtubeUrl); +// +// assertNotNull("응답이 null이 아니어야 합니다", response); +// assertNotNull("타입이 있어야 합니다", response.getType()); +// assertNotNull("제목이 있어야 합니다", response.getTitle()); +// +// // 결과 출력 +// System.out.println("\n=== YouTube oEmbed 결과 ==="); +// System.out.println("타입: " + response.getType()); +// System.out.println("제목: " + response.getTitle()); +// System.out.println("작성자: " + response.getAuthorName()); +// System.out.println("제공자: " + response.getProviderName()); +// System.out.println("썸네일 URL: " + response.getThumbnailUrl()); +// System.out.println("썸네일 크기: " + response.getThumbnailWidth() + "x" + response.getThumbnailHeight()); +// System.out.println("비디오 크기: " + response.getWidth() + "x" + response.getHeight()); +// System.out.println("\n원본 JSON:"); +// System.out.println(response.getRawJson()); +// +// } catch (Exception e) { +// System.err.println("YouTube oEmbed 테스트 실패: " + e.getMessage()); +// e.printStackTrace(); +// } +// } +// +// @Test +// public void testFetchOEmbed_YouTubeShortUrl() { +// // YouTube 단축 URL 테스트 +// String youtubeShortUrl = "https://youtu.be/dQw4w9WgXcQ"; +// +// try { +// OEmbedFetcher.OEmbedResponse response = oembedFetcher.fetchOEmbedData(youtubeShortUrl); +// +// assertNotNull("응답이 null이 아니어야 합니다", response); +// assertNotNull("타입이 있어야 합니다", response.getType()); +// +// // 결과 출력 +// System.out.println("\n=== YouTube 단축 URL oEmbed 결과 ==="); +// System.out.println(response); +// +// } catch (Exception e) { +// System.err.println("YouTube 단축 URL oEmbed 테스트 실패: " + e.getMessage()); +// e.printStackTrace(); +// } +// } +// +// @Test +// public void testFetchOEmbed_Vimeo() { +// // Vimeo URL 테스트 +// String vimeoUrl = "https://vimeo.com/76979871"; +// +// try { +// OEmbedFetcher.OEmbedResponse response = oembedFetcher.fetchOEmbedData(vimeoUrl); +// +// assertNotNull("응답이 null이 아니어야 합니다", response); +// assertNotNull("타입이 있어야 합니다", response.getType()); +// assertNotNull("제목이 있어야 합니다", response.getTitle()); +// +// // 결과 출력 +// System.out.println("\n=== Vimeo oEmbed 결과 ==="); +// System.out.println("타입: " + response.getType()); +// System.out.println("제목: " + response.getTitle()); +// System.out.println("작성자: " + response.getAuthorName()); +// System.out.println("제공자: " + response.getProviderName()); +// System.out.println("썸네일 URL: " + response.getThumbnailUrl()); +// System.out.println("썸네일 크기: " + response.getThumbnailWidth() + "x" + response.getThumbnailHeight()); +// System.out.println("비디오 크기: " + response.getWidth() + "x" + response.getHeight()); +// System.out.println("\n원본 JSON:"); +// System.out.println(response.getRawJson()); +// +// } catch (Exception e) { +// System.err.println("Vimeo oEmbed 테스트 실패: " + e.getMessage()); +// e.printStackTrace(); +// } +// } +// +// @Test +// public void testFetchOEmbed_UnsupportedPlatform() { +// // 지원하지 않는 플랫폼 테스트 +// String unsupportedUrl = "https://example.com/video"; +// +// try { +// oembedFetcher.fetchOEmbedData(unsupportedUrl); +// fail("지원하지 않는 플랫폼에 대해 예외가 발생해야 합니다"); +// } catch (IllegalArgumentException e) { +// // 예상된 예외 +// assertTrue("지원하지 않는 플랫폼 메시지를 포함해야 합니다", +// e.getMessage().contains("지원하지 않는 플랫폼")); +// System.out.println("\n지원하지 않는 플랫폼 예외 처리 확인: " + e.getMessage()); +// } catch (Exception e) { +// fail("IllegalArgumentException이 발생해야 합니다: " + e.getClass().getName()); +// } +// } +// +// @Test +// public void testFetchOEmbed_MultipleNaverTVVideos() { +// // 여러 네이버TV URL 테스트 +// String[] naverUrls = { +// "https://tv.naver.com/v/40687083", +// "https://tv.naver.com/v/84373511", +// "https://tv.naver.com/v/12345678" // 존재하지 않을 수 있는 URL +// }; +// +// System.out.println("\n=== 여러 네이버TV URL 테스트 ==="); +// for (String url : naverUrls) { +// try { +// OEmbedFetcher.OEmbedResponse response = oembedFetcher.fetchOEmbedData(url); +// System.out.println("\nURL: " + url); +// System.out.println("제목: " + response.getTitle()); +// System.out.println("작성자: " + response.getAuthorName()); +// System.out.println("썸네일: " + response.getThumbnailUrl()); +// System.out.println("성공!"); +// } catch (Exception e) { +// System.err.println("\nURL: " + url); +// System.err.println("실패: " + e.getMessage()); +// } +// } +// } + + @Test + public void testOEmbedResponse_AllFields() { + // 네이버TV URL로 모든 필드 확인 + String naverUrl = "https://tv.naver.com/v/40687083"; + + try { + OEmbedFetcher.OEmbedResponse response = oembedFetcher.fetchOEmbedData(naverUrl); + + System.out.println("\n=== oEmbed 응답 모든 필드 확인 ==="); + System.out.println("Type: " + response.getType()); + System.out.println("Version: " + response.getVersion()); + System.out.println("Title: " + response.getTitle()); + System.out.println("Author Name: " + response.getAuthorName()); + System.out.println("Author URL: " + response.getAuthorUrl()); + System.out.println("Provider Name: " + response.getProviderName()); + System.out.println("Provider URL: " + response.getProviderUrl()); + System.out.println("Thumbnail URL: " + response.getThumbnailUrl()); + System.out.println("Thumbnail Width: " + response.getThumbnailWidth()); + System.out.println("Thumbnail Height: " + response.getThumbnailHeight()); + System.out.println("Width: " + response.getWidth()); + System.out.println("Height: " + response.getHeight()); + System.out.println("HTML: " + (response.getHtml() != null ? response.getHtml().substring(0, Math.min(100, response.getHtml().length())) + "..." : "null")); + + // 필수 필드 확인 + assertNotNull("Title은 null이 아니어야 합니다", response.getTitle()); + assertNotNull("Provider Name은 null이 아니어야 합니다", response.getProviderName()); + + } catch (Exception e) { + System.err.println("테스트 실패: " + e.getMessage()); + e.printStackTrace(); + } + } +} diff --git a/backup/src/test/java/com/video/analyzer/PlatformAnalyzerTest.java b/backup/src/test/java/com/video/analyzer/PlatformAnalyzerTest.java new file mode 100644 index 0000000..13d90f8 --- /dev/null +++ b/backup/src/test/java/com/video/analyzer/PlatformAnalyzerTest.java @@ -0,0 +1,97 @@ +package com.video.analyzer; + +import com.caliverse.video.analyzer.model.VideoAnalysisResult; +import com.caliverse.video.analyzer.platform.PlatformAnalyzer; +import com.caliverse.video.analyzer.platform.PlatformAnalyzerFactory; +import com.caliverse.video.analyzer.platform.impl.NaverTvPlatformAnalyzer; +import com.caliverse.video.analyzer.service.VideoAnalyzerService; +import org.junit.Before; +import org.junit.Test; + +import static org.junit.Assert.*; + +/** + * PlatformAnalyzer와 관련된 테스트 클래스입니다. + */ +public class PlatformAnalyzerTest { + private VideoAnalyzerService videoAnalyzerService; + private NaverTvPlatformAnalyzer naverTvAnalyzer; + private PlatformAnalyzerFactory factory; + + @Before + public void setUp() { + videoAnalyzerService = new VideoAnalyzerService(); + naverTvAnalyzer = new NaverTvPlatformAnalyzer(videoAnalyzerService); + factory = new PlatformAnalyzerFactory(videoAnalyzerService); + } + + @Test + public void testNaverTvAnalyzer_CanHandle() { + // 네이버TV URL 처리 가능 여부 확인 + assertTrue("네이버TV URL을 처리할 수 있어야 합니다", + naverTvAnalyzer.canHandle("https://tv.naver.com/v/123")); + assertTrue("네이버 공유 URL을 처리할 수 있어야 합니다", + naverTvAnalyzer.canHandle("https://naver.me/FK09F4E7")); + assertTrue("대소문자 구분 없이 처리할 수 있어야 합니다", + naverTvAnalyzer.canHandle("https://TV.NAVER.COM/v/456")); + assertFalse("null URL은 처리할 수 없어야 합니다", + naverTvAnalyzer.canHandle(null)); + assertFalse("다른 도메인은 처리할 수 없어야 합니다", + naverTvAnalyzer.canHandle("https://youtube.com/watch?v=123")); + } + + @Test + public void testNaverTvAnalyzer_GetPlatformName() { + // 플랫폼 이름 확인 + assertEquals("플랫폼 이름은 '네이버TV'여야 합니다", + "네이버TV", naverTvAnalyzer.getPlatformName()); + } + + @Test + public void testNaverTvAnalyzer_Analyze_ReturnsResult() { + // 네이버TV URL 분석 시 VideoAnalysisResult 반환 확인 + String naverUrl = "https://tv.naver.com/v/84373511"; + + VideoAnalysisResult result = naverTvAnalyzer.analyze(naverUrl); + + assertNotNull("분석 결과는 null이 아니어야 합니다", result); + assertNotNull("success 값은 null이 아니어야 합니다", result.isSuccess()); + assertNotNull("reason은 null이 아니어야 합니다", result.getReason()); + + // 결과 출력 (디버깅용) + System.out.println("네이버TV 분석 결과: " + result); + } + + @Test + public void testFactory_FindAnalyzer_NaverTv() { + // 네이버TV URL에 대한 분석기 찾기 + PlatformAnalyzer naverAnalyzer = factory.findAnalyzer("https://tv.naver.com/v/123"); + + assertNotNull("네이버TV 분석기를 찾아야 합니다", naverAnalyzer); + assertEquals("플랫폼 이름은 '네이버TV'여야 합니다", + "네이버TV", naverAnalyzer.getPlatformName()); + } + + @Test + public void testFactory_FindAnalyzer_UnknownUrl() { + // 알 수 없는 URL에 대한 분석기 찾기 + PlatformAnalyzer unknownAnalyzer = factory.findAnalyzer("https://example.com/video.mp4"); + + assertNull("알 수 없는 URL은 null을 반환해야 합니다", unknownAnalyzer); + } + + @Test + public void testFactory_GetAllAnalyzers() { + var analyzers = factory.getAllAnalyzers(); + + assertNotNull("분석기 목록은 null이 아니어야 합니다", analyzers); + assertFalse("최소 1개 이상의 분석기가 있어야 합니다", analyzers.isEmpty()); + + // 네이버TV 분석기가 포함되어 있는지 확인 + boolean hasNaverTv = analyzers.stream() + .anyMatch(analyzer -> "네이버TV".equals(analyzer.getPlatformName())); + + assertTrue("네이버TV 분석기가 포함되어 있어야 합니다", hasNaverTv); + } + +} diff --git a/backup/src/test/java/com/video/analyzer/VideoAnalyzerTest.java b/backup/src/test/java/com/video/analyzer/VideoAnalyzerTest.java new file mode 100644 index 0000000..04d6030 --- /dev/null +++ b/backup/src/test/java/com/video/analyzer/VideoAnalyzerTest.java @@ -0,0 +1,130 @@ +package com.video.analyzer; + +import com.caliverse.video.analyzer.VideoAnalyzer; +import com.caliverse.video.analyzer.model.VideoAnalysisResult; +import org.junit.Before; +import org.junit.Test; + +import static org.junit.Assert.*; + +/** + * VideoAnalyzer의 통합 테스트 클래스입니다. + */ +public class VideoAnalyzerTest { + + private VideoAnalyzer analyzer; + + @Before + public void setUp() { + analyzer = new VideoAnalyzer(); + } + + @Test + public void testAnalyzeUrl_Success() { + // 재생 가능한 일반 비디오 URL (mp4) + String videoUrl = "https://example.com/video.mp4"; + + VideoAnalysisResult result = analyzer.analyzeUrl(videoUrl); + + assertNotNull("결과는 null이 아니어야 합니다", result); + assertNotNull("reason은 null이 아니어야 합니다", result.getReason()); + } + + @Test + public void testAnalyzeUrl_NaverTv() { + // 네이버TV URL + String naverUrl = "https://tv.naver.com/v/84373511"; + + VideoAnalysisResult result = analyzer.analyzeUrl(naverUrl); + + assertNotNull("결과는 null이 아니어야 합니다", result); + assertNotNull("reason은 null이 아니어야 합니다", result.getReason()); + + // 결과 출력 (디버깅용) + System.out.println("네이버TV 분석 결과: " + result); + } + + @Test + public void testAnalyzeUrl_Naver() { + // 네이버TV URL + String naverUrl = "https://naver.me/ID3ODX2j"; + + VideoAnalysisResult result = analyzer.analyzeUrl(naverUrl); + + assertNotNull("결과는 null이 아니어야 합니다", result); + assertNotNull("reason은 null이 아니어야 합니다", result.getReason()); + + // 결과 출력 (디버깅용) + System.out.println("네이버 공유 분석 결과: " + result); + } + + @Test + public void testAnalyzeUrl_NaverTv2() { + // 네이버TV URL + String naverUrl = "https://tv.naver.com/h/87349660"; + + VideoAnalysisResult result = analyzer.analyzeUrl(naverUrl); + + assertNotNull("결과는 null이 아니어야 합니다", result); + assertNotNull("reason은 null이 아니어야 합니다", result.getReason()); + + // 결과 출력 (디버깅용) + System.out.println("네이버 숏츠 분석 결과: " + result); + } + + @Test + public void testAnalyzeUrl_NaverTvLive() { + // 네이버TV URL + String naverUrl = "https://tv.naver.com/l/171651"; + + VideoAnalysisResult result = analyzer.analyzeUrl(naverUrl); + + assertNotNull("결과는 null이 아니어야 합니다", result); + assertNotNull("reason은 null이 아니어야 합니다", result.getReason()); + + // 결과 출력 (디버깅용) + System.out.println("네이버 라이브 분석 결과: " + result); + } + + @Test + public void testSupportedPlatforms() { + String[] platforms = analyzer.getSupportedPlatforms(); + + assertNotNull("플랫폼 목록은 null이 아니어야 합니다", platforms); + assertTrue("최소 1개 이상의 플랫폼을 지원해야 합니다", platforms.length > 0); + + // 기본 플랫폼 확인 + boolean hasNaverTv = false; + for (String platform : platforms) { + if ("네이버TV".equals(platform)) { + hasNaverTv = true; + break; + } + } + + assertTrue("네이버TV를 지원해야 합니다", hasNaverTv); + } + + @Test + public void testAnalyzeUrl_Result_HasProperties() { + // VideoAnalysisResult가 올바른 속성을 가지고 있는지 확인 + String testUrl = "https://example.com/test.mp4"; + + VideoAnalysisResult result = analyzer.analyzeUrl(testUrl); + + // result 객체가 올바른 메소드를 가지고 있는지 확인 + assertNotNull(result); + + String reason = result.getReason(); + + assertNotNull("reason은 항상 값이 있어야 합니다", reason); + assertFalse("reason은 빈 문자열이 아니어야 합니다", reason.trim().isEmpty()); + + // toString()이 정상 작동하는지 확인 + String resultString = result.toString(); + assertNotNull(resultString); + assertTrue("toString()은 'VideoAnalysisResult'를 포함해야 합니다", + resultString.contains("VideoAnalysisResult")); + } + +} diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..7a11b06 --- /dev/null +++ b/build.gradle @@ -0,0 +1,86 @@ +plugins { + id 'java' + id 'maven-publish' +} + +group = 'com.caliverse.analyzer' +version = '1.0.0' + +java { + sourceCompatibility = '17' + targetCompatibility = '17' +} + +compileJava { + options.encoding = 'UTF-8' +} + +compileTestJava { + options.encoding = 'UTF-8' +} + +repositories { + mavenCentral() +} + +dependencies { + // 로깅 (라이브러리이므로 API만 포함, 구현체는 사용자가 선택) + implementation 'org.slf4j:slf4j-api:2.0.9' + + implementation 'com.google.code.gson:gson:2.10.1' + + // HTTP 연결 및 요청을 위한 라이브러리 + implementation 'org.apache.httpcomponents:httpclient:4.5.14' + + // JSON 파싱 라이브러리 + implementation 'org.json:json:20250517' + implementation 'com.fasterxml.jackson.core:jackson-databind:2.15.2' + + // 테스트 라이브러리 + testImplementation 'junit:junit:4.13.2' + testImplementation 'ch.qos.logback:logback-classic:1.4.11' +} + +jar { + manifest { + attributes 'Implementation-Title': 'Video URL Analyzer', + 'Implementation-Version': version, + 'Main-Class': 'com.caliverse.analyzer.VideoAnalyzer' + } + + // 의존성을 포함한 Jar 파일 생성 (Fat JAR) + from { + configurations.runtimeClasspath.collect { it.isDirectory() ? it : zipTree(it) } + } + + // 중복 파일 처리 + duplicatesStrategy = DuplicatesStrategy.EXCLUDE +} + +// 테스트 설정 +test { + useJUnit() + testLogging { + events "passed", "skipped", "failed" + exceptionFormat "full" + } +} + +tasks.register('fatJar', Jar) { + archiveClassifier = 'all' + duplicatesStrategy = DuplicatesStrategy.EXCLUDE + manifest.attributes['Main-Class'] = 'com.caliverse.analyzer.VideoAnalyzer' + from { + configurations.runtimeClasspath.collect { it.isDirectory() ? it : zipTree(it) } + } + with jar +} + +// Maven 발행 설정 (선택사항) +publishing { + publications { + maven(MavenPublication) { + from components.java + } + } +} diff --git a/build/classes/java/main/com/caliverse/analyzer/VideoAnalyzer.class b/build/classes/java/main/com/caliverse/analyzer/VideoAnalyzer.class new file mode 100644 index 0000000..9d7af22 Binary files /dev/null and b/build/classes/java/main/com/caliverse/analyzer/VideoAnalyzer.class differ diff --git a/build/classes/java/main/com/caliverse/analyzer/entity/UrlType.class b/build/classes/java/main/com/caliverse/analyzer/entity/UrlType.class new file mode 100644 index 0000000..731a42d Binary files /dev/null and b/build/classes/java/main/com/caliverse/analyzer/entity/UrlType.class differ diff --git a/build/classes/java/main/com/caliverse/analyzer/model/VideoAnalysisResult.class b/build/classes/java/main/com/caliverse/analyzer/model/VideoAnalysisResult.class new file mode 100644 index 0000000..b9ad8e3 Binary files /dev/null and b/build/classes/java/main/com/caliverse/analyzer/model/VideoAnalysisResult.class differ diff --git a/build/classes/java/main/com/caliverse/analyzer/platform/PlatformAnalyzer.class b/build/classes/java/main/com/caliverse/analyzer/platform/PlatformAnalyzer.class new file mode 100644 index 0000000..0189ffc Binary files /dev/null and b/build/classes/java/main/com/caliverse/analyzer/platform/PlatformAnalyzer.class differ diff --git a/build/classes/java/main/com/caliverse/analyzer/platform/PlatformAnalyzerFactory.class b/build/classes/java/main/com/caliverse/analyzer/platform/PlatformAnalyzerFactory.class new file mode 100644 index 0000000..b7d9680 Binary files /dev/null and b/build/classes/java/main/com/caliverse/analyzer/platform/PlatformAnalyzerFactory.class differ diff --git a/build/classes/java/main/com/caliverse/analyzer/platform/impl/DirectUrlPlatformAnalyzer$ValidationResult.class b/build/classes/java/main/com/caliverse/analyzer/platform/impl/DirectUrlPlatformAnalyzer$ValidationResult.class new file mode 100644 index 0000000..6739989 Binary files /dev/null and b/build/classes/java/main/com/caliverse/analyzer/platform/impl/DirectUrlPlatformAnalyzer$ValidationResult.class differ diff --git a/build/classes/java/main/com/caliverse/analyzer/platform/impl/DirectUrlPlatformAnalyzer.class b/build/classes/java/main/com/caliverse/analyzer/platform/impl/DirectUrlPlatformAnalyzer.class new file mode 100644 index 0000000..c70db4e Binary files /dev/null and b/build/classes/java/main/com/caliverse/analyzer/platform/impl/DirectUrlPlatformAnalyzer.class differ diff --git a/build/classes/java/main/com/caliverse/analyzer/platform/impl/HttpBasedPlatformAnalyzer.class b/build/classes/java/main/com/caliverse/analyzer/platform/impl/HttpBasedPlatformAnalyzer.class new file mode 100644 index 0000000..67993a2 Binary files /dev/null and b/build/classes/java/main/com/caliverse/analyzer/platform/impl/HttpBasedPlatformAnalyzer.class differ diff --git a/build/classes/java/main/com/caliverse/analyzer/platform/impl/KakaoTvPlatformAnalyzer.class b/build/classes/java/main/com/caliverse/analyzer/platform/impl/KakaoTvPlatformAnalyzer.class new file mode 100644 index 0000000..908070f Binary files /dev/null and b/build/classes/java/main/com/caliverse/analyzer/platform/impl/KakaoTvPlatformAnalyzer.class differ diff --git a/build/classes/java/main/com/caliverse/analyzer/platform/impl/NaverTvPlatformAnalyzer.class b/build/classes/java/main/com/caliverse/analyzer/platform/impl/NaverTvPlatformAnalyzer.class new file mode 100644 index 0000000..17fe785 Binary files /dev/null and b/build/classes/java/main/com/caliverse/analyzer/platform/impl/NaverTvPlatformAnalyzer.class differ diff --git a/build/classes/java/main/com/caliverse/analyzer/platform/impl/TikTokPlatformAnalyzer.class b/build/classes/java/main/com/caliverse/analyzer/platform/impl/TikTokPlatformAnalyzer.class new file mode 100644 index 0000000..4039db1 Binary files /dev/null and b/build/classes/java/main/com/caliverse/analyzer/platform/impl/TikTokPlatformAnalyzer.class differ diff --git a/build/classes/java/main/com/caliverse/analyzer/platform/impl/TwitterPlatformAnalyzer.class b/build/classes/java/main/com/caliverse/analyzer/platform/impl/TwitterPlatformAnalyzer.class new file mode 100644 index 0000000..9ef30b3 Binary files /dev/null and b/build/classes/java/main/com/caliverse/analyzer/platform/impl/TwitterPlatformAnalyzer.class differ diff --git a/build/classes/java/main/com/caliverse/analyzer/platform/impl/VimeoPlatformAnalyzer.class b/build/classes/java/main/com/caliverse/analyzer/platform/impl/VimeoPlatformAnalyzer.class new file mode 100644 index 0000000..093420c Binary files /dev/null and b/build/classes/java/main/com/caliverse/analyzer/platform/impl/VimeoPlatformAnalyzer.class differ diff --git a/build/classes/java/main/com/caliverse/analyzer/platform/impl/WeiboPlatformAnalyzer$VideoMetadata.class b/build/classes/java/main/com/caliverse/analyzer/platform/impl/WeiboPlatformAnalyzer$VideoMetadata.class new file mode 100644 index 0000000..cc9ff7b Binary files /dev/null and b/build/classes/java/main/com/caliverse/analyzer/platform/impl/WeiboPlatformAnalyzer$VideoMetadata.class differ diff --git a/build/classes/java/main/com/caliverse/analyzer/platform/impl/WeiboPlatformAnalyzer.class b/build/classes/java/main/com/caliverse/analyzer/platform/impl/WeiboPlatformAnalyzer.class new file mode 100644 index 0000000..e8ca6a8 Binary files /dev/null and b/build/classes/java/main/com/caliverse/analyzer/platform/impl/WeiboPlatformAnalyzer.class differ diff --git a/build/classes/java/main/com/caliverse/analyzer/platform/impl/YouTubePlatformAnalyzer.class b/build/classes/java/main/com/caliverse/analyzer/platform/impl/YouTubePlatformAnalyzer.class new file mode 100644 index 0000000..6772f6b Binary files /dev/null and b/build/classes/java/main/com/caliverse/analyzer/platform/impl/YouTubePlatformAnalyzer.class differ diff --git a/build/classes/java/main/com/caliverse/analyzer/service/VideoAnalyzerService$1.class b/build/classes/java/main/com/caliverse/analyzer/service/VideoAnalyzerService$1.class new file mode 100644 index 0000000..8a07d6b Binary files /dev/null and b/build/classes/java/main/com/caliverse/analyzer/service/VideoAnalyzerService$1.class differ diff --git a/build/classes/java/main/com/caliverse/analyzer/service/VideoAnalyzerService$CheckResult.class b/build/classes/java/main/com/caliverse/analyzer/service/VideoAnalyzerService$CheckResult.class new file mode 100644 index 0000000..ebcaf77 Binary files /dev/null and b/build/classes/java/main/com/caliverse/analyzer/service/VideoAnalyzerService$CheckResult.class differ diff --git a/build/classes/java/main/com/caliverse/analyzer/service/VideoAnalyzerService.class b/build/classes/java/main/com/caliverse/analyzer/service/VideoAnalyzerService.class new file mode 100644 index 0000000..40aac3d Binary files /dev/null and b/build/classes/java/main/com/caliverse/analyzer/service/VideoAnalyzerService.class differ diff --git a/build/classes/java/main/com/caliverse/global/common/CommonConstants.class b/build/classes/java/main/com/caliverse/global/common/CommonConstants.class new file mode 100644 index 0000000..f230592 Binary files /dev/null and b/build/classes/java/main/com/caliverse/global/common/CommonConstants.class differ diff --git a/build/classes/java/main/com/caliverse/global/common/Messages.class b/build/classes/java/main/com/caliverse/global/common/Messages.class new file mode 100644 index 0000000..a365ae0 Binary files /dev/null and b/build/classes/java/main/com/caliverse/global/common/Messages.class differ diff --git a/build/classes/java/main/com/caliverse/global/util/HttpUtils.class b/build/classes/java/main/com/caliverse/global/util/HttpUtils.class new file mode 100644 index 0000000..88c9758 Binary files /dev/null and b/build/classes/java/main/com/caliverse/global/util/HttpUtils.class differ diff --git a/build/classes/java/main/com/caliverse/global/util/UrlParser.class b/build/classes/java/main/com/caliverse/global/util/UrlParser.class new file mode 100644 index 0000000..953a4a9 Binary files /dev/null and b/build/classes/java/main/com/caliverse/global/util/UrlParser.class differ diff --git a/build/libs/video-url-analyzer-1.0.0.jar b/build/libs/video-url-analyzer-1.0.0.jar new file mode 100644 index 0000000..8425594 Binary files /dev/null and b/build/libs/video-url-analyzer-1.0.0.jar differ diff --git a/build/tmp/.cache/expanded.lock b/build/tmp/.cache/expanded.lock new file mode 100644 index 0000000..f7af5ae Binary files /dev/null and b/build/tmp/.cache/expanded.lock differ diff --git a/build/tmp/compileJava/previous-compilation-data.bin b/build/tmp/compileJava/previous-compilation-data.bin new file mode 100644 index 0000000..06d9b9b Binary files /dev/null and b/build/tmp/compileJava/previous-compilation-data.bin differ diff --git a/build/tmp/jar/MANIFEST.MF b/build/tmp/jar/MANIFEST.MF new file mode 100644 index 0000000..de173a8 --- /dev/null +++ b/build/tmp/jar/MANIFEST.MF @@ -0,0 +1,5 @@ +Manifest-Version: 1.0 +Implementation-Title: Video URL Analyzer +Implementation-Version: 1.0.0 +Main-Class: com.caliverse.analyzer.VideoAnalyzer + diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..c1962a7 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..37aef8d --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.1.1-bin.zip +networkTimeout=10000 +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100644 index 0000000..8b1cf35 --- /dev/null +++ b/gradlew @@ -0,0 +1,245 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx4096m" "-Xms4096m"' + +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and variable substitutions, so put them in +# double quotes to make sure that they get re-expanded; and +# * put everything else in single quotes, so that it's not re-expanded. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..7aa8c67 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,92 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx4096m" "-Xms4096m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 0000000..f54591d --- /dev/null +++ b/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'video-url-analyzer' diff --git a/src/main/java/com/caliverse/analyzer/VideoAnalyzer.java b/src/main/java/com/caliverse/analyzer/VideoAnalyzer.java new file mode 100644 index 0000000..ef83948 --- /dev/null +++ b/src/main/java/com/caliverse/analyzer/VideoAnalyzer.java @@ -0,0 +1,143 @@ +package com.caliverse.analyzer; + +import com.caliverse.analyzer.platform.PlatformAnalyzer; +import com.caliverse.analyzer.platform.PlatformAnalyzerFactory; +import com.caliverse.analyzer.service.VideoAnalyzerService; +import com.caliverse.analyzer.model.VideoAnalysisResult; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * 비디오 URL 분석 기능을 제공하는 메인 클래스입니다. + * + *

이 클래스는 비디오 URL을 분석하여 다음 사항을 확인합니다: + *

    + *
  • DRM 보호 URL 여부
  • + *
  • 3D 비디오 여부
  • + *
  • AVPlayer 재생 가능 여부
  • + *
+ * + *

지원하는 플랫폼: + *

    + *
  • 네이버TV (VOD, Clip, Live)
  • + *
  • 일반 비디오 URL (mp4, m3u8, mov 등)
  • + *
+ * + *

사용 예제

+ *
{@code
+ * VideoAnalyzer analyzer = new VideoAnalyzer();
+ * VideoAnalysisResult result = analyzer.analyzeUrl("https://tv.naver.com/v/84373511");
+ *
+ * if (result.isSuccess()) {
+ *     System.out.println("재생 가능: " + result.getReason());
+ * } else {
+ *     System.out.println("재생 불가: " + result.getReason());
+ * }
+ * }
+ * + * @since 1.0.0 + * @see VideoAnalysisResult + */ +public class VideoAnalyzer { + + private static final Logger logger = LoggerFactory.getLogger(VideoAnalyzer.class); + + private final VideoAnalyzerService analyzerService; + private final PlatformAnalyzerFactory platformFactory; + + /** + * VideoAnalyzer 인스턴스를 생성합니다. + * + *

생성 시 내부적으로 분석 서비스와 플랫폼별 분석기를 초기화합니다. + */ + public VideoAnalyzer() { + this.analyzerService = new VideoAnalyzerService(); + this.platformFactory = new PlatformAnalyzerFactory(); + } + + /** + * 비디오 URL을 분석하여 재생 가능 여부를 판단합니다. + * + *

특정 플랫폼(네이버TV 등)의 URL인 경우 해당 플랫폼에 맞는 분석을 수행하며, + * 일반 URL인 경우 표준 비디오 분석을 수행합니다. + * + *

분석 항목: + *

    + *
  • 보호된 URL 여부 (DRM, 서명된 URL 등)
  • + *
  • 3D 비디오 여부
  • + *
  • AVPlayer 재생 가능 여부 (포맷 체크)
  • + *
+ * + *

성공 조건: 모든 항목이 재생 가능한 경우
+ * 실패 조건: 하나라도 재생 불가능한 경우 (상세 이유 제공) + * + * @param url 분석할 비디오 URL (http:// 또는 https:// 형식) + * @return 분석 결과를 담은 {@link VideoAnalysisResult} 객체 + * @see VideoAnalysisResult#isSuccess() + * @see VideoAnalysisResult#getReason() + * + * @example + *

{@code
+     * // 네이버TV URL 분석
+     * VideoAnalysisResult result1 = analyzer.analyzeUrl("https://tv.naver.com/v/84373511");
+     *
+     * // 일반 비디오 URL 분석
+     * VideoAnalysisResult result2 = analyzer.analyzeUrl("https://example.com/video.mp4");
+     * }
+ */ + public VideoAnalysisResult analyzeUrl(String url) { + // 플랫폼별 분석기 찾기 + PlatformAnalyzer analyzer = platformFactory.findAnalyzer(url); + + if (analyzer != null) { + return analyzer.analyze(url); + } else { + // 일반 비디오 URL로 처리 + return analyzerService.analyzeVideo(url); + } + } + + /** + * 현재 지원하는 플랫폼 목록을 반환합니다. + * + *

각 플랫폼별로 특화된 URL 분석 로직이 제공됩니다. + * + * @return 지원하는 플랫폼 이름 배열 (예: ["네이버TV"]) + *

+ * @example + *

{@code
+     * String[] platforms = analyzer.getSupportedPlatforms();
+     * System.out.println("지원 플랫폼: " + String.join(", ", platforms));
+     * // 출력: 지원 플랫폼: 네이버TV
+     * }
+ */ + public String[] getSupportedPlatforms() { + return platformFactory.getAllAnalyzers().stream() + .map(PlatformAnalyzer::getPlatformName) + .toArray(String[]::new); + } + + + /** + * 명령줄에서 테스트 실행을 위한 메인 메서드 + */ + public static void main(String[] args) { + if (args.length < 1) { + VideoAnalyzer analyzer = new VideoAnalyzer(); + logger.info("사용법: java -jar video-url-analyzer.jar "); + logger.info("지원하는 플랫폼: {}", String.join(", ", analyzer.getSupportedPlatforms())); + logger.info("예시:"); + logger.info(" 네이버 TV: https://tv.naver.com/v/85477455"); + logger.info(" 일반 비디오: https://example.com/video.mp4"); + System.exit(1); + } + + String url = args[0]; + VideoAnalyzer analyzer = new VideoAnalyzer(); + + logger.info("=== URL 분석 결과 ==="); + VideoAnalysisResult result = analyzer.analyzeUrl(url); + logger.info("{}", result); + } + +} diff --git a/src/main/java/com/caliverse/analyzer/entity/UrlType.java b/src/main/java/com/caliverse/analyzer/entity/UrlType.java new file mode 100644 index 0000000..c92e5b4 --- /dev/null +++ b/src/main/java/com/caliverse/analyzer/entity/UrlType.java @@ -0,0 +1,11 @@ +package com.caliverse.analyzer.entity; + +public enum UrlType { + YOUTUBE, + BLOB, + STREAMING, + DIRECT_FILE, + NAVER_TV, + UNKNOWN + +} diff --git a/src/main/java/com/caliverse/analyzer/model/VideoAnalysisResult.java b/src/main/java/com/caliverse/analyzer/model/VideoAnalysisResult.java new file mode 100644 index 0000000..e5a2d68 --- /dev/null +++ b/src/main/java/com/caliverse/analyzer/model/VideoAnalysisResult.java @@ -0,0 +1,116 @@ +package com.caliverse.analyzer.model; + +import com.caliverse.global.common.Messages; + +/** + * 비디오 URL 분석 결과를 담는 모델 클래스입니다. + * + *

이 클래스는 비디오 URL 분석의 최종 결과를 나타냅니다. + * 모든 검사 항목이 통과하면 {@code success = true}, 하나라도 실패하면 + * {@code success = false}와 함께 상세한 실패 이유를 제공합니다. + * + *

분석 항목

+ *
    + *
  • 보호된 URL 여부: DRM, 서명된 URL 감지
  • + *
  • 3D 비디오 여부: 3D 포맷 감지
  • + *
  • AVPlayer 재생 가능 여부: 지원 포맷 확인
  • + *
+ * + *

사용 예제

+ *
{@code
+ * VideoAnalyzer analyzer = new VideoAnalyzer();
+ * VideoAnalysisResult result = analyzer.analyzeUrl(url);
+ *
+ * if (result.isSuccess()) {
+ *     System.out.println("재생 가능: " + result.getReason());
+ *     // 출력: "재생 가능: 모든 검사 통과"
+ * } else {
+ *     System.out.println("재생 불가");
+ *     System.out.println(result.getReason());
+ *     // 출력 예시:
+ *     // 재생 불가 사유:
+ *     // - 보호된 URL: DRM 보호 키워드 감지: widevine
+ *     // - 3D 비디오: 3D 비디오 헤더 감지
+ * }
+ * }
+ * + * @since 1.0.0 + * @see com.caliverse.video.analyzer.VideoAnalyzer#analyzeUrl(String) + */ +public class VideoAnalysisResult { + private final boolean success; + private final String reason; + + private VideoAnalysisResult(boolean success, String reason) { + this.success = success; + this.reason = reason; + } + + /** + * 성공 결과 객체를 생성합니다. + * + *

모든 검사 항목이 통과했을 때 사용되며, reason은 "모든 검사 통과"로 설정됩니다. + * + * @return 성공 결과 객체 + */ + public static VideoAnalysisResult success() { + return new VideoAnalysisResult(true, Messages.SUCCESS_ALL_CHECKS_PASSED); + } + + /** + * 실패 결과 객체를 생성합니다. + * + *

하나 이상의 검사 항목이 실패했을 때 사용되며, + * 상세한 실패 이유를 reason에 포함합니다. + * + * @param reason 실패 이유 (개행 문자로 구분된 상세 설명 가능) + * @return 실패 결과 객체 + */ + public static VideoAnalysisResult failure(String reason) { + return new VideoAnalysisResult(false, reason); + } + + /** + * 분석 결과가 성공인지 여부를 반환합니다. + * + *

성공 조건: + *

    + *
  • 보호되지 않은 URL
  • + *
  • 2D 비디오
  • + *
  • AVPlayer에서 재생 가능
  • + *
+ * + * @return 모든 검사가 통과하면 {@code true}, 하나라도 실패하면 {@code false} + */ + public boolean isSuccess() { + return success; + } + + /** + * 분석 결과의 이유를 반환합니다. + * + *

성공한 경우: "모든 검사 통과"
+ * 실패한 경우: 상세한 실패 이유 (각 항목별로 구분) + * + *

실패 이유 예시: + *

+     * 재생 불가 사유:
+     * - 보호된 URL: DRM 보호 키워드 감지: widevine
+     * - 재생 불가: 지원되지 않는 포맷 또는 Content-Type: video/x-flv
+     * 
+ * + * @return 분석 결과 이유 (null이 아닌 문자열) + */ + public String getReason() { + return reason; + } + + @Override + public String toString() { + if (success) { + return "VideoAnalysisResult{ 성공: " + reason + " }"; + } else { + return "VideoAnalysisResult{ 실패: " + reason + " }"; + } + } +} diff --git a/src/main/java/com/caliverse/analyzer/platform/PlatformAnalyzer.java b/src/main/java/com/caliverse/analyzer/platform/PlatformAnalyzer.java new file mode 100644 index 0000000..d9558f8 --- /dev/null +++ b/src/main/java/com/caliverse/analyzer/platform/PlatformAnalyzer.java @@ -0,0 +1,29 @@ +package com.caliverse.analyzer.platform; + +import com.caliverse.analyzer.model.VideoAnalysisResult; + +public interface PlatformAnalyzer { + /** + * 해당 플랫폼의 URL인지 확인합니다. + * + * @param url 확인할 URL + * @return 해당 플랫폼 URL이면 true, 아니면 false + */ + boolean canHandle(String url); + + /** + * 플랫폼 이름을 반환합니다. + * + * @return 플랫폼 이름 + */ + String getPlatformName(); + + /** + * 플랫폼별 URL 분석을 수행합니다. + * + * @param url 분석할 URL + * @return 분석 결과 + */ + VideoAnalysisResult analyze(String url); + +} diff --git a/src/main/java/com/caliverse/analyzer/platform/PlatformAnalyzerFactory.java b/src/main/java/com/caliverse/analyzer/platform/PlatformAnalyzerFactory.java new file mode 100644 index 0000000..ec9c1c0 --- /dev/null +++ b/src/main/java/com/caliverse/analyzer/platform/PlatformAnalyzerFactory.java @@ -0,0 +1,60 @@ +package com.caliverse.analyzer.platform; + +import com.caliverse.analyzer.platform.impl.*; + +import java.util.ArrayList; +import java.util.List; + +public class PlatformAnalyzerFactory { + private final List analyzers; + + public PlatformAnalyzerFactory() { + this.analyzers = new ArrayList<>(); + + // HTTP 기반 플랫폼 분석기들을 등록 (순서 중요: 구체적인 것부터) + analyzers.add(new NaverTvPlatformAnalyzer()); + analyzers.add(new KakaoTvPlatformAnalyzer()); + analyzers.add(new YouTubePlatformAnalyzer()); + analyzers.add(new VimeoPlatformAnalyzer()); + analyzers.add(new TikTokPlatformAnalyzer()); + analyzers.add(new TwitterPlatformAnalyzer()); + analyzers.add(new WeiboPlatformAnalyzer()); + + // 직접 URL 분석기는 가장 마지막에 등록 (fallback) + analyzers.add(new DirectUrlPlatformAnalyzer()); + } + + /** + * URL에 맞는 플랫폼 분석기를 찾습니다. + * + * @param url 분석할 URL + * @return 해당 플랫폼 분석기, 없으면 null + */ + public PlatformAnalyzer findAnalyzer(String url) { + for (PlatformAnalyzer analyzer : analyzers) { + if (analyzer.canHandle(url)) { + return analyzer; + } + } + return null; + } + + /** + * 등록된 모든 플랫폼 분석기 목록을 반환합니다. + * + * @return 플랫폼 분석기 목록 + */ + public List getAllAnalyzers() { + return new ArrayList<>(analyzers); + } + + /** + * 새로운 플랫폼 분석기를 추가합니다. + * + * @param analyzer 추가할 분석기 + */ + public void addAnalyzer(PlatformAnalyzer analyzer) { + analyzers.add(analyzer); + } + +} diff --git a/src/main/java/com/caliverse/analyzer/platform/impl/DirectUrlPlatformAnalyzer.java b/src/main/java/com/caliverse/analyzer/platform/impl/DirectUrlPlatformAnalyzer.java new file mode 100644 index 0000000..e995187 --- /dev/null +++ b/src/main/java/com/caliverse/analyzer/platform/impl/DirectUrlPlatformAnalyzer.java @@ -0,0 +1,402 @@ +package com.caliverse.analyzer.platform.impl; + +import com.caliverse.analyzer.model.VideoAnalysisResult; +import com.caliverse.analyzer.platform.PlatformAnalyzer; +import com.caliverse.global.common.CommonConstants; +import com.caliverse.global.common.Messages; +import org.apache.http.Header; +import org.apache.http.client.config.RequestConfig; +import org.apache.http.client.methods.CloseableHttpResponse; +import org.apache.http.client.methods.HttpHead; +import org.apache.http.impl.client.CloseableHttpClient; +import org.apache.http.impl.client.HttpClients; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.net.MalformedURLException; +import java.net.URL; + +/** + * 직접 비디오 URL 분석기입니다. + * + *

mp4, m3u8 등 직접 비디오 파일 URL의 유효성을 검증합니다. + * + *

지원 포맷: + *

    + *
  • MP4 (.mp4)
  • + *
  • HLS (.m3u8)
  • + *
  • DASH (.mpd)
  • + *
  • WebM (.webm)
  • + *
  • MOV (.mov)
  • + *
  • AVI (.avi)
  • + *
  • MKV (.mkv)
  • + *
+ * + *

검증 항목: + *

    + *
  • HTTP 접근 가능 여부
  • + *
  • CORS 제한 확인
  • + *
  • DRM 보호 여부
  • + *
  • 인증 필요 여부 (Signed URL)
  • + *
+ */ +public class DirectUrlPlatformAnalyzer implements PlatformAnalyzer { + + private static final Logger logger = LoggerFactory.getLogger(DirectUrlPlatformAnalyzer.class); + + @Override + public boolean canHandle(String url) { + // 직접 비디오 URL인지 확인 + return isVideoUrl(url); + } + + @Override + public String getPlatformName() { + return CommonConstants.PLATFORM_NAME_DIRECT_URL; + } + + @Override + public VideoAnalysisResult analyze(String url) { + try { + logger.debug(Messages.LOG_DIRECT_URL_ANALYSIS_START, url); + + // URL 유효성 검증 + ValidationResult result = validateVideoUrl(url); + + if (!result.isValid()) { + // 실패 사유 반환 + String reason = buildFailureReason(result); + logger.warn(Messages.LOG_DIRECT_URL_VALIDATION_FAILED_DETAIL, reason); + return VideoAnalysisResult.failure(reason); + } + + // 추가 검사: CORS, DRM, 인증 등 + if (result.isHasDrm()) { + String reason = Messages.format(Messages.DIRECT_URL_DRM_DETECTED, result.getDrmInfo()); + logger.warn(reason); + return VideoAnalysisResult.failure(reason); + } + + if (result.isRequiresAuth()) { + String reason = Messages.DIRECT_URL_AUTH_REQUIRED; + logger.warn(reason); + return VideoAnalysisResult.failure(reason); + } + + // CORS 제한은 경고만 하고 성공으로 처리 (서버에서는 재생 가능할 수 있음) + if (!result.isCorsEnabled()) { + logger.debug(Messages.LOG_CORS_DISABLED_WARNING); + } + + logger.debug(Messages.LOG_DIRECT_URL_VALIDATION_SUCCESS, url); + return VideoAnalysisResult.success(); + + } catch (Exception e) { + String errorMsg = Messages.format(Messages.DIRECT_URL_ANALYSIS_ERROR, e.getMessage()); + logger.error(errorMsg, e); + return VideoAnalysisResult.failure(errorMsg); + } + } + + /** + * 실패 사유를 구성합니다. + */ + private String buildFailureReason(ValidationResult result) { + StringBuilder sb = new StringBuilder(); + sb.append(Messages.DIRECT_URL_VALIDATION_FAILED); + + if (result.getReason() != null) { + sb.append(": ").append(result.getReason()); + } + + if (result.getStatusCode() > 0) { + sb.append(" (HTTP ").append(result.getStatusCode()).append(")"); + } + + return sb.toString(); + } + + /** + * URL이 비디오 URL인지 확인합니다. + * + * @param url 검사할 URL + * @return 비디오 URL 여부 + */ + private boolean isVideoUrl(String url) { + if (url == null || url.trim().isEmpty()) { + return false; + } + + String lowerUrl = url.toLowerCase(); + + // URL에서 쿼리 파라미터 제거 후 확장자 확인 + String urlWithoutQuery = lowerUrl.split("\\?")[0]; + + for (String extension : CommonConstants.VIDEO_EXTENSIONS) { + if (urlWithoutQuery.endsWith(extension)) { + return true; + } + } + + return false; + } + + /** + * 비디오 URL의 접근 가능 여부를 검사합니다. + * CORS, signed URL, DRM 등을 체크합니다. + * + * @param videoUrl 검사할 비디오 URL + * @return 검사 결과 + */ + private ValidationResult validateVideoUrl(String videoUrl) { + ValidationResult result = new ValidationResult(); + result.setUrl(videoUrl); + + // 비디오 URL 형식 체크 + if (!isVideoUrl(videoUrl)) { + result.setValid(false); + result.setReason(Messages.DIRECT_URL_UNSUPPORTED_FORMAT); + return result; + } + + try { + // URL 유효성 검사 + new URL(videoUrl); + } catch (MalformedURLException e) { + result.setValid(false); + result.setReason(Messages.format(Messages.DIRECT_URL_MALFORMED, e.getMessage())); + return result; + } + + // HTTP HEAD 요청으로 접근성 검사 + try { + return checkUrlAccessibility(videoUrl); + } catch (IOException e) { + result.setValid(false); + result.setReason(Messages.format(Messages.DIRECT_URL_ACCESS_FAILED, e.getMessage())); + logger.warn(Messages.LOG_VIDEO_ACCESS_FAILED, videoUrl, e); + return result; + } + } + + /** + * HTTP HEAD 요청을 통해 URL 접근 가능 여부를 확인합니다. + * + * @param videoUrl 검사할 URL + * @return 검사 결과 + * @throws IOException HTTP 요청 실패 시 + */ + private ValidationResult checkUrlAccessibility(String videoUrl) throws IOException { + ValidationResult result = new ValidationResult(); + result.setUrl(videoUrl); + + RequestConfig requestConfig = RequestConfig.custom() + .setConnectTimeout(CommonConstants.HTTP_CONNECTION_TIMEOUT) + .setSocketTimeout(CommonConstants.HTTP_SOCKET_TIMEOUT) + .build(); + + try (CloseableHttpClient httpClient = HttpClients.custom() + .setDefaultRequestConfig(requestConfig) + .build()) { + + HttpHead headRequest = new HttpHead(videoUrl); + headRequest.setHeader(CommonConstants.HEADER_USER_AGENT, CommonConstants.USER_AGENT_STANDARD); + headRequest.setHeader(CommonConstants.HEADER_ACCEPT, CommonConstants.ACCEPT_ALL); + headRequest.setHeader(CommonConstants.HEADER_ORIGIN, CommonConstants.ORIGIN_LOCALHOST); // CORS 테스트용 + + try (CloseableHttpResponse response = httpClient.execute(headRequest)) { + int statusCode = response.getStatusLine().getStatusCode(); + + logger.debug(Messages.LOG_VIDEO_RESPONSE_CODE, statusCode, videoUrl); + + // 상태 코드별 처리 + if (statusCode >= 200 && statusCode < 300) { + // 성공 - 추가 검사 수행 + checkResponseHeaders(response, result); + result.setValid(true); + result.setStatusCode(statusCode); + + } else if (statusCode == 403) { + result.setValid(false); + result.setReason(Messages.DIRECT_URL_ACCESS_DENIED); + result.setStatusCode(statusCode); + result.setRequiresAuth(true); + + } else if (statusCode == 404) { + result.setValid(false); + result.setReason(Messages.DIRECT_URL_NOT_FOUND); + result.setStatusCode(statusCode); + + } else if (statusCode >= 400 && statusCode < 500) { + result.setValid(false); + result.setReason(Messages.format(Messages.DIRECT_URL_CLIENT_ERROR, statusCode)); + result.setStatusCode(statusCode); + + } else if (statusCode >= 500) { + result.setValid(false); + result.setReason(Messages.format(Messages.DIRECT_URL_SERVER_ERROR, statusCode)); + result.setStatusCode(statusCode); + + } else if (statusCode >= 300 && statusCode < 400) { + result.setValid(false); + result.setReason(Messages.format(Messages.DIRECT_URL_REDIRECT_REQUIRED, statusCode)); + result.setStatusCode(statusCode); + } + } + } + + return result; + } + + /** + * 응답 헤더를 분석하여 CORS, DRM 등을 확인합니다. + * + * @param response HTTP 응답 + * @param result 검사 결과 (업데이트됨) + */ + private void checkResponseHeaders(CloseableHttpResponse response, ValidationResult result) { + // CORS 헤더 확인 + Header corsHeader = response.getFirstHeader(CommonConstants.HEADER_ACCESS_CONTROL_ALLOW_ORIGIN); + if (corsHeader != null) { + result.setCorsEnabled(true); + result.setCorsOrigin(corsHeader.getValue()); + logger.debug(Messages.LOG_CORS_ENABLED, corsHeader.getValue()); + } else { + result.setCorsEnabled(false); + logger.debug(Messages.LOG_CORS_DISABLED); + } + + // Content-Type 확인 + Header contentTypeHeader = response.getFirstHeader(CommonConstants.HEADER_CONTENT_TYPE); + if (contentTypeHeader != null) { + result.setContentType(contentTypeHeader.getValue()); + logger.debug(Messages.LOG_CONTENT_TYPE, contentTypeHeader.getValue()); + } + + // DRM 관련 헤더 확인 (예: X-Content-Type-Options, Content-Protection 등) + Header[] allHeaders = response.getAllHeaders(); + for (Header header : allHeaders) { + String headerName = header.getName().toLowerCase(); + + // DRM 또는 보안 관련 헤더 감지 + if (headerName.contains("drm") || + headerName.contains("protection") || + headerName.contains("encryption")) { + result.setHasDrm(true); + result.setDrmInfo(Messages.format(Messages.DIRECT_URL_DRM_HEADER_DETECTED, header.getName())); + logger.debug(Messages.LOG_DRM_HEADER_FOUND, header.getName(), header.getValue()); + } + + // Signed URL 감지 (일반적으로 URL 파라미터로 존재하지만, 헤더로도 확인) + if (headerName.contains("signature") || headerName.contains("token")) { + result.setRequiresAuth(true); + logger.debug(Messages.LOG_AUTH_HEADER_FOUND, header.getName()); + } + } + + // URL에 signature 파라미터가 있는지 확인 + if (result.getUrl().contains(CommonConstants.URL_PARAM_SIGNATURE) || + result.getUrl().contains(CommonConstants.URL_PARAM_TOKEN) || + result.getUrl().contains(CommonConstants.URL_PARAM_KEY)) { + result.setRequiresAuth(true); + logger.debug(Messages.LOG_AUTH_PARAM_IN_URL); + } + } + + /** + * 비디오 URL 검증 결과를 담는 클래스입니다. + */ + private static class ValidationResult { + private String url; + private boolean valid; + private String reason; + private int statusCode; + private boolean corsEnabled; + private String corsOrigin; + private boolean hasDrm; + private String drmInfo; + private boolean requiresAuth; + private String contentType; + + public String getUrl() { + return url; + } + + public void setUrl(String url) { + this.url = url; + } + + public boolean isValid() { + return valid; + } + + public void setValid(boolean valid) { + this.valid = valid; + } + + public String getReason() { + return reason; + } + + public void setReason(String reason) { + this.reason = reason; + } + + public int getStatusCode() { + return statusCode; + } + + public void setStatusCode(int statusCode) { + this.statusCode = statusCode; + } + + public boolean isCorsEnabled() { + return corsEnabled; + } + + public void setCorsEnabled(boolean corsEnabled) { + this.corsEnabled = corsEnabled; + } + + public String getCorsOrigin() { + return corsOrigin; + } + + public void setCorsOrigin(String corsOrigin) { + this.corsOrigin = corsOrigin; + } + + public boolean isHasDrm() { + return hasDrm; + } + + public void setHasDrm(boolean hasDrm) { + this.hasDrm = hasDrm; + } + + public String getDrmInfo() { + return drmInfo; + } + + public void setDrmInfo(String drmInfo) { + this.drmInfo = drmInfo; + } + + public boolean isRequiresAuth() { + return requiresAuth; + } + + public void setRequiresAuth(boolean requiresAuth) { + this.requiresAuth = requiresAuth; + } + + public String getContentType() { + return contentType; + } + + public void setContentType(String contentType) { + this.contentType = contentType; + } + } +} diff --git a/src/main/java/com/caliverse/analyzer/platform/impl/HttpBasedPlatformAnalyzer.java b/src/main/java/com/caliverse/analyzer/platform/impl/HttpBasedPlatformAnalyzer.java new file mode 100644 index 0000000..d6d107a --- /dev/null +++ b/src/main/java/com/caliverse/analyzer/platform/impl/HttpBasedPlatformAnalyzer.java @@ -0,0 +1,174 @@ +package com.caliverse.analyzer.platform.impl; + +import com.caliverse.analyzer.model.VideoAnalysisResult; +import com.caliverse.analyzer.platform.PlatformAnalyzer; +import com.caliverse.global.common.CommonConstants; +import com.caliverse.global.common.Messages; +import com.fasterxml.jackson.databind.JsonNode; +import org.apache.http.client.methods.CloseableHttpResponse; +import org.apache.http.client.methods.HttpGet; +import org.apache.http.impl.client.CloseableHttpClient; +import org.apache.http.impl.client.HttpClients; +import org.apache.http.util.EntityUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; + +/** + * HTTP 요청 기반 플랫폼 분석기의 추상 클래스입니다. + * + *

이 클래스는 HTTP 요청/응답 처리를 담당하며, + * 실제 응답 파싱은 각 플랫폼에서 구현합니다. + * + *

지원 방식: + *

    + *
  • JSON 파싱 (oEmbed, REST API 등)
  • + *
  • HTML 파싱 (embed 코드 추출)
  • + *
  • 커스텀 파싱 (플랫폼별 고유 방식)
  • + *
+ */ +public abstract class HttpBasedPlatformAnalyzer implements PlatformAnalyzer { + + private static final Logger logger = LoggerFactory.getLogger(HttpBasedPlatformAnalyzer.class); + + /** + * API 엔드포인트 URL을 반환합니다. + * 각 플랫폼에서 구현해야 합니다. + * + * @return API 엔드포인트 URL + */ + protected abstract String getApiEndpoint(); + + /** + * 플랫폼 도메인을 반환합니다. + * canHandle 메서드에서 사용됩니다. + * + * @return 플랫폼 도메인 (예: "kakao.com") + */ + protected abstract String getPlatformDomain(); + + /** + * HTTP 응답을 파싱하여 비디오 검증 결과를 반환합니다. + * 각 플랫폼에서 JSON, HTML, 또는 커스텀 방식으로 구현합니다. + * + * @param response HTTP 응답 본문 (JSON, HTML 등) + * @param originalUrl 원본 비디오 URL + * @return 검증 결과 + * @throws IOException 파싱 실패 시 + */ + protected abstract VideoAnalysisResult parseResponse(String response, String originalUrl) throws IOException; + + /** + * API URL을 생성합니다. + * 기본 구현은 URL 인코딩을 수행하며, 필요시 오버라이드 가능합니다. + * + * @param endpoint API 엔드포인트 + * @param videoUrl 비디오 URL + * @return 완성된 API URL + */ + protected String buildApiUrl(String endpoint, String videoUrl) { + try { + String encodedUrl = URLEncoder.encode(videoUrl, StandardCharsets.UTF_8.toString()); + return endpoint + "?url=" + encodedUrl + "&format=json"; + } catch (Exception e) { + throw new RuntimeException(Messages.HTTP_URL_ENCODING_FAILED, e); + } + } + + @Override + public boolean canHandle(String url) { + if (url == null) return false; + String lowerUrl = url.toLowerCase(); + return lowerUrl.contains(getPlatformDomain()); + } + + @Override + public VideoAnalysisResult analyze(String url) { + try { + logger.debug(Messages.LOG_ANALYSIS_START, getPlatformName(), url); + + // API 호출 + String endpoint = getApiEndpoint(); + String response = fetchApiResponse(endpoint, url); + + // 응답 파싱 (각 플랫폼에서 구현) + VideoAnalysisResult result = parseResponse(response, url); + + if (result.isSuccess()) { + logger.debug(Messages.LOG_VALIDATION_SUCCESS, getPlatformName()); + } else { + logger.warn(Messages.LOG_VALIDATION_FAILED, getPlatformName(), result.getReason()); + } + + return result; + + } catch (IllegalArgumentException e) { + String errorMsg = Messages.format(Messages.HTTP_UNSUPPORTED_URL_FORMAT, getPlatformName()); + logger.warn(errorMsg, e); + return VideoAnalysisResult.failure(errorMsg); + + } catch (IOException e) { + String errorMsg = Messages.format(Messages.HTTP_API_CALL_FAILED, getPlatformName(), e.getMessage()); + logger.warn(errorMsg, e); + return VideoAnalysisResult.failure(errorMsg); + + } catch (Exception e) { + String errorMsg = Messages.format(Messages.HTTP_ANALYSIS_ERROR, getPlatformName(), e.getMessage()); + logger.error(errorMsg, e); + return VideoAnalysisResult.failure(errorMsg); + } + } + + /** + * API를 호출하여 응답을 가져옵니다. + */ + private String fetchApiResponse(String endpoint, String videoUrl) throws IOException { + String apiUrl = buildApiUrl(endpoint, videoUrl); + logger.debug(Messages.LOG_API_URL, apiUrl); + + try (CloseableHttpClient httpClient = HttpClients.createDefault()) { + HttpGet request = new HttpGet(apiUrl); + request.setHeader(CommonConstants.HEADER_USER_AGENT, CommonConstants.USER_AGENT_STANDARD); + request.setHeader(CommonConstants.HEADER_ACCEPT, CommonConstants.ACCEPT_JSON_HTML); + + try (CloseableHttpResponse response = httpClient.execute(request)) { + int statusCode = response.getStatusLine().getStatusCode(); + + if (statusCode != 200) { + throw new IOException(Messages.format(Messages.HTTP_API_CALL_FAILED_STATUS_CODE, statusCode)); + } + + return EntityUtils.toString(response.getEntity(), StandardCharsets.UTF_8); + } + } + } + + // ========== JSON 파싱 Helper 메서드 ========== + + /** + * JsonNode에서 String 값을 안전하게 추출합니다. + * + * @param node JSON 노드 + * @param fieldName 필드명 + * @return 필드 값 (없으면 null) + */ + protected String getStringValue(JsonNode node, String fieldName) { + JsonNode fieldNode = node.get(fieldName); + return fieldNode != null && !fieldNode.isNull() ? fieldNode.asText() : null; + } + + /** + * JsonNode에서 Integer 값을 안전하게 추출합니다. + * + * @param node JSON 노드 + * @param fieldName 필드명 + * @return 필드 값 (없으면 null) + */ + protected Integer getIntValue(JsonNode node, String fieldName) { + JsonNode fieldNode = node.get(fieldName); + return fieldNode != null && !fieldNode.isNull() ? fieldNode.asInt() : null; + } +} diff --git a/src/main/java/com/caliverse/analyzer/platform/impl/KakaoTvPlatformAnalyzer.java b/src/main/java/com/caliverse/analyzer/platform/impl/KakaoTvPlatformAnalyzer.java new file mode 100644 index 0000000..ae13156 --- /dev/null +++ b/src/main/java/com/caliverse/analyzer/platform/impl/KakaoTvPlatformAnalyzer.java @@ -0,0 +1,119 @@ +package com.caliverse.analyzer.platform.impl; + +import com.caliverse.analyzer.model.VideoAnalysisResult; +import com.caliverse.global.common.CommonConstants; +import com.caliverse.global.common.Messages; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; + +/** + * 카카오TV 플랫폼 분석기입니다. + * + *

oEmbed API를 사용하여 카카오TV 영상의 유효성을 검증합니다. + * + *

지원 URL 형식: + *

    + *
  • https://tv.kakao.com/channel/{channel_id}/cliplink/{clip_id}
  • + *
  • https://tv.kakao.com/v/{video_id}
  • + *
+ * + *

oEmbed 엔드포인트: https://tv.kakao.com/oembed + * + *

응답 예시 (JSON): + *

+ * {
+ *   "type": "video",
+ *   "title": "[28회 선공개] 윤현민과 신수현의 달달한 뜨개질 데이트♥ [화려한 날들] | KBS 방송",
+ *   "author_name": "화려한 날들",
+ *   "author_url": "https://tv.kakao.com/channel/10195069/info",
+ *   "provider_name": "kakaoTV",
+ *   "provider_url": "https://tv.kakao.com",
+ *   "thumbnail_url": "https://img1.kakaocdn.net/thumb/C640x360/?fname=...",
+ *   "thumbnail_width": 640,
+ *   "thumbnail_height": 360,
+ *   "html": "