This commit is contained in:
2025-11-28 15:58:57 +09:00
commit 860a81b86c
94 changed files with 8160 additions and 0 deletions

View File

@@ -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": []
}
}

0
.gitkeep Normal file
View File

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

Binary file not shown.

View File

@@ -0,0 +1,2 @@
#Thu Nov 06 15:53:40 KST 2025
gradle.version=8.1.1

Binary file not shown.

BIN
.gradle/file-system.probe Normal file

Binary file not shown.

View File

1
.idea/.name generated Normal file
View File

@@ -0,0 +1 @@
video-url-analyzer

6
.idea/compiler.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="CompilerConfiguration">
<bytecodeTargetLevel target="17" />
</component>
</project>

16
.idea/gradle.xml generated Normal file
View File

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GradleMigrationSettings" migrationVersion="1" />
<component name="GradleSettings">
<option name="linkedExternalProjectsSettings">
<GradleProjectSettings>
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$" />
</set>
</option>
</GradleProjectSettings>
</option>
</component>
</project>

8
.idea/misc.xml generated Normal file
View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="FrameworkDetectionExcludesConfiguration">
<file type="web" url="file://$PROJECT_DIR$" />
</component>
<component name="ProjectRootManager" version="2" languageLevel="JDK_17" default="true" project-jdk-name="17" project-jdk-type="JavaSDK" />
</project>

6
.idea/vcs.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$/.." vcs="Git" />
</component>
</project>

48
CLAUDE.md Normal file
View File

@@ -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. 테스트 가능한 구조로 설계

1245
CODE_STRUCTURE.md Normal file

File diff suppressed because it is too large Load Diff

299
CONTRIBUTING.md Normal file
View File

@@ -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)
---
다시 한번 기여해주셔서 감사드립니다! 🎉

193
README.md Normal file
View File

@@ -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
<dependency>
<groupId>com.caliverse.analyzer</groupId>
<artifactId>video-url-analyzer</artifactId>
<version>1.0.0</version>
</dependency>
```
#### 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 호환성 체크

View File

@@ -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 분석 기능을 제공하는 메인 클래스입니다.
*
* <p>이 클래스는 비디오 URL을 분석하여 다음 사항을 확인합니다:
* <ul>
* <li>DRM 보호 URL 여부</li>
* <li>3D 비디오 여부</li>
* <li>AVPlayer 재생 가능 여부</li>
* </ul>
*
* <p>지원하는 플랫폼:
* <ul>
* <li>네이버TV (VOD, Clip, Live)</li>
* <li>일반 비디오 URL (mp4, m3u8, mov 등)</li>
* </ul>
*
* <h3>사용 예제</h3>
* <pre>{@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());
* }
* }</pre>
*
* @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 인스턴스를 생성합니다.
*
* <p>생성 시 내부적으로 분석 서비스와 플랫폼별 분석기를 초기화합니다.
*/
public VideoAnalyzer() {
this.analyzerService = new VideoAnalyzerService();
this.platformFactory = new PlatformAnalyzerFactory(analyzerService);
}
/**
* 비디오 URL을 분석하여 재생 가능 여부를 판단합니다.
*
* <p>특정 플랫폼(네이버TV 등)의 URL인 경우 해당 플랫폼에 맞는 분석을 수행하며,
* 일반 URL인 경우 표준 비디오 분석을 수행합니다.
*
* <p>분석 항목:
* <ul>
* <li>보호된 URL 여부 (DRM, 서명된 URL 등)</li>
* <li>3D 비디오 여부</li>
* <li>AVPlayer 재생 가능 여부 (포맷 체크)</li>
* </ul>
*
* <p>성공 조건: 모든 항목이 재생 가능한 경우<br>
* 실패 조건: 하나라도 재생 불가능한 경우 (상세 이유 제공)
*
* @param url 분석할 비디오 URL (http:// 또는 https:// 형식)
* @return 분석 결과를 담은 {@link VideoAnalysisResult} 객체
* @see VideoAnalysisResult#isSuccess()
* @see VideoAnalysisResult#getReason()
*
* &#064;example
* <pre>{@code
* // 네이버TV URL 분석
* VideoAnalysisResult result1 = analyzer.analyzeUrl("https://tv.naver.com/v/84373511");
*
* // 일반 비디오 URL 분석
* VideoAnalysisResult result2 = analyzer.analyzeUrl("https://example.com/video.mp4");
* }</pre>
*/
public VideoAnalysisResult analyzeUrl(String url) {
// 플랫폼별 분석기 찾기
PlatformAnalyzer analyzer = platformFactory.findAnalyzer(url);
if (analyzer != null) {
return analyzer.analyze(url);
} else {
// 일반 비디오 URL로 처리
return analyzerService.analyzeVideo(url);
}
}
/**
* 현재 지원하는 플랫폼 목록을 반환합니다.
*
* <p>각 플랫폼별로 특화된 URL 분석 로직이 제공됩니다.
*
* @return 지원하는 플랫폼 이름 배열 (예: ["네이버TV"])
* <p>
* &#064;example
* <pre>{@code
* String[] platforms = analyzer.getSupportedPlatforms();
* System.out.println("지원 플랫폼: " + String.join(", ", platforms));
* // 출력: 지원 플랫폼: 네이버TV
* }</pre>
*/
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 <video-url>");
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);
}
}

View File

@@ -0,0 +1,11 @@
package com.caliverse.video.analyzer.entity;
public enum UrlType {
YOUTUBE,
BLOB,
STREAMING,
DIRECT_FILE,
NAVER_TV,
UNKNOWN
}

View File

@@ -0,0 +1,116 @@
package com.caliverse.video.analyzer.model;
import com.caliverse.video.global.common.Messages;
/**
* 비디오 URL 분석 결과를 담는 모델 클래스입니다.
*
* <p>이 클래스는 비디오 URL 분석의 최종 결과를 나타냅니다.
* 모든 검사 항목이 통과하면 {@code success = true}, 하나라도 실패하면
* {@code success = false}와 함께 상세한 실패 이유를 제공합니다.
*
* <h3>분석 항목</h3>
* <ul>
* <li>보호된 URL 여부: DRM, 서명된 URL 감지</li>
* <li>3D 비디오 여부: 3D 포맷 감지</li>
* <li>AVPlayer 재생 가능 여부: 지원 포맷 확인</li>
* </ul>
*
* <h3>사용 예제</h3>
* <pre>{@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 비디오 헤더 감지
* }
* }</pre>
*
* @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;
}
/**
* 성공 결과 객체를 생성합니다.
*
* <p>모든 검사 항목이 통과했을 때 사용되며, reason은 "모든 검사 통과"로 설정됩니다.
*
* @return 성공 결과 객체
*/
public static VideoAnalysisResult success() {
return new VideoAnalysisResult(true, Messages.SUCCESS_ALL_CHECKS_PASSED);
}
/**
* 실패 결과 객체를 생성합니다.
*
* <p>하나 이상의 검사 항목이 실패했을 때 사용되며,
* 상세한 실패 이유를 reason에 포함합니다.
*
* @param reason 실패 이유 (개행 문자로 구분된 상세 설명 가능)
* @return 실패 결과 객체
*/
public static VideoAnalysisResult failure(String reason) {
return new VideoAnalysisResult(false, reason);
}
/**
* 분석 결과가 성공인지 여부를 반환합니다.
*
* <p>성공 조건:
* <ul>
* <li>보호되지 않은 URL</li>
* <li>2D 비디오</li>
* <li>AVPlayer에서 재생 가능</li>
* </ul>
*
* @return 모든 검사가 통과하면 {@code true}, 하나라도 실패하면 {@code false}
*/
public boolean isSuccess() {
return success;
}
/**
* 분석 결과의 이유를 반환합니다.
*
* <p>성공한 경우: "모든 검사 통과"<br>
* 실패한 경우: 상세한 실패 이유 (각 항목별로 구분)
*
* <p>실패 이유 예시:
* <pre>
* 재생 불가 사유:
* - 보호된 URL: DRM 보호 키워드 감지: widevine
* - 재생 불가: 지원되지 않는 포맷 또는 Content-Type: video/x-flv
* </pre>
*
* @return 분석 결과 이유 (null이 아닌 문자열)
*/
public String getReason() {
return reason;
}
@Override
public String toString() {
if (success) {
return "VideoAnalysisResult{ 성공: " + reason + " }";
} else {
return "VideoAnalysisResult{ 실패: " + reason + " }";
}
}
}

View File

@@ -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);
}

View File

@@ -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<PlatformAnalyzer> 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<PlatformAnalyzer> getAllAnalyzers() {
return new ArrayList<>(analyzers);
}
/**
* 새로운 플랫폼 분석기를 추가합니다.
*
* @param analyzer 추가할 분석기
*/
public void addAnalyzer(PlatformAnalyzer analyzer) {
analyzers.add(analyzer);
}
}

View File

@@ -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);
}
}
}

View File

@@ -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<String> SUPPORTED_FORMATS = Arrays.asList(
"mp4", "mov", "m4v", "m3u8", "hls"
);
// DRM 관련 키워드
private static final List<String> 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();
}
}
}

View File

@@ -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("<script id=\"__NEXT_DATA__\" type=\"application/json\">(.+?)</script>");
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/";
}

View File

@@ -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);
}
}

View File

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

View File

@@ -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를 활용하여 영상 메타데이터를 가져오는 유틸리티 클래스입니다.
*
* <p>지원 플랫폼:
* <ul>
* <li>네이버TV</li>
* <li>YouTube</li>
* <li>Vimeo</li>
* </ul>
*/
public class OEmbedFetcher {
private static final Logger logger = LoggerFactory.getLogger(OEmbedFetcher.class);
private final ObjectMapper objectMapper;
// oEmbed 엔드포인트 매핑
private static final Map<String, String> 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<String, String> 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 = "<iframe width='544' height='306' src='" + embedUrl +
"' frameborder='0' scrolling='no' allowfullscreen></iframe>";
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 {
// <meta property="og:xxx" content="value"/> 패턴 매칭
String pattern = "<meta\\s+property=\"" + property + "\"\\s+content=\"([^\"]+)\"";
java.util.regex.Pattern regexPattern = java.util.regex.Pattern.compile(pattern);
java.util.regex.Matcher matcher = regexPattern.matcher(htmlContent);
if (matcher.find()) {
return matcher.group(1);
}
return null;
} catch (Exception e) {
logger.warn("meta 태그 추출 실패: {}", e.getMessage());
return null;
}
}
/**
* 네이버TV 쇼츠 URL의 HTML을 가져옵니다.
*/
private String fetchHtmlContent(String url) throws IOException {
try (CloseableHttpClient httpClient = HttpClients.createDefault()) {
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)) {
if (response.getStatusLine().getStatusCode() != 200) {
throw new IOException("HTML 페이지 가져오기 실패");
}
return EntityUtils.toString(response.getEntity(), StandardCharsets.UTF_8);
}
}
}
/**
* 쇼츠 응답 JSON을 생성합니다.
*/
private String buildShortResponseJson(OEmbedResponse response, String embedUrl) {
StringBuilder json = new StringBuilder();
json.append("{");
json.append("\"type\":\"video\",");
json.append("\"version\":\"1.0\",");
json.append("\"provider_name\":\"NAVERTV\",");
json.append("\"provider_url\":\"https://tv.naver.com\",");
if (response.getTitle() != null) {
json.append("\"title\":\"").append(escapeJson(response.getTitle())).append("\",");
}
if (response.getAuthorName() != null) {
json.append("\"author_name\":\"").append(escapeJson(response.getAuthorName())).append("\",");
}
if (response.getThumbnailUrl() != null) {
json.append("\"thumbnail_url\":\"").append(escapeJson(response.getThumbnailUrl())).append("\",");
json.append("\"thumbnail_width\":").append(response.getThumbnailWidth()).append(",");
json.append("\"thumbnail_height\":").append(response.getThumbnailHeight()).append(",");
}
json.append("\"width\":544,");
json.append("\"height\":306,");
json.append("\"html\":\"").append(escapeJson(response.getHtml())).append("\",");
json.append("\"videoType\":\"short\",");
json.append("\"playerUrl\":\"").append(embedUrl).append("\"");
json.append("}");
return json.toString();
}
/**
* JSON 문자열을 이스케이프 처리합니다.
*/
private String escapeJson(String text) {
if (text == null) return "";
return text.replace("\\", "\\\\")
.replace("\"", "\\\"")
.replace("\n", "\\n")
.replace("\r", "\\r")
.replace("\t", "\\t");
}
/**
* 네이버TV URL에서 비디오 ID를 추출합니다.
* 예: https://tv.naver.com/h/87349660 -> 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 +
'}';
}
}
}

View File

@@ -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<String> SIGNATURE_PARAMS = Arrays.asList(
"token", "signature", "sig", "expires", "exp", "auth",
"key", "access_token", "api_key", "hmac", "hash"
);
// 스트리밍 서비스의 URL 패턴 목록
private static final List<String> 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");
}
}

View File

@@ -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();
}
}
}

View File

@@ -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);
}
}

View File

@@ -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"));
}
}

86
build.gradle Normal file
View File

@@ -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
}
}
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,5 @@
Manifest-Version: 1.0
Implementation-Title: Video URL Analyzer
Implementation-Version: 1.0.0
Main-Class: com.caliverse.analyzer.VideoAnalyzer

BIN
gradle/wrapper/gradle-wrapper.jar vendored Normal file

Binary file not shown.

View File

@@ -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

245
gradlew vendored Normal file
View File

@@ -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" "$@"

92
gradlew.bat vendored Normal file
View File

@@ -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

1
settings.gradle Normal file
View File

@@ -0,0 +1 @@
rootProject.name = 'video-url-analyzer'

View File

@@ -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 분석 기능을 제공하는 메인 클래스입니다.
*
* <p>이 클래스는 비디오 URL을 분석하여 다음 사항을 확인합니다:
* <ul>
* <li>DRM 보호 URL 여부</li>
* <li>3D 비디오 여부</li>
* <li>AVPlayer 재생 가능 여부</li>
* </ul>
*
* <p>지원하는 플랫폼:
* <ul>
* <li>네이버TV (VOD, Clip, Live)</li>
* <li>일반 비디오 URL (mp4, m3u8, mov 등)</li>
* </ul>
*
* <h3>사용 예제</h3>
* <pre>{@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());
* }
* }</pre>
*
* @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 인스턴스를 생성합니다.
*
* <p>생성 시 내부적으로 분석 서비스와 플랫폼별 분석기를 초기화합니다.
*/
public VideoAnalyzer() {
this.analyzerService = new VideoAnalyzerService();
this.platformFactory = new PlatformAnalyzerFactory();
}
/**
* 비디오 URL을 분석하여 재생 가능 여부를 판단합니다.
*
* <p>특정 플랫폼(네이버TV 등)의 URL인 경우 해당 플랫폼에 맞는 분석을 수행하며,
* 일반 URL인 경우 표준 비디오 분석을 수행합니다.
*
* <p>분석 항목:
* <ul>
* <li>보호된 URL 여부 (DRM, 서명된 URL 등)</li>
* <li>3D 비디오 여부</li>
* <li>AVPlayer 재생 가능 여부 (포맷 체크)</li>
* </ul>
*
* <p>성공 조건: 모든 항목이 재생 가능한 경우<br>
* 실패 조건: 하나라도 재생 불가능한 경우 (상세 이유 제공)
*
* @param url 분석할 비디오 URL (http:// 또는 https:// 형식)
* @return 분석 결과를 담은 {@link VideoAnalysisResult} 객체
* @see VideoAnalysisResult#isSuccess()
* @see VideoAnalysisResult#getReason()
*
* &#064;example
* <pre>{@code
* // 네이버TV URL 분석
* VideoAnalysisResult result1 = analyzer.analyzeUrl("https://tv.naver.com/v/84373511");
*
* // 일반 비디오 URL 분석
* VideoAnalysisResult result2 = analyzer.analyzeUrl("https://example.com/video.mp4");
* }</pre>
*/
public VideoAnalysisResult analyzeUrl(String url) {
// 플랫폼별 분석기 찾기
PlatformAnalyzer analyzer = platformFactory.findAnalyzer(url);
if (analyzer != null) {
return analyzer.analyze(url);
} else {
// 일반 비디오 URL로 처리
return analyzerService.analyzeVideo(url);
}
}
/**
* 현재 지원하는 플랫폼 목록을 반환합니다.
*
* <p>각 플랫폼별로 특화된 URL 분석 로직이 제공됩니다.
*
* @return 지원하는 플랫폼 이름 배열 (예: ["네이버TV"])
* <p>
* &#064;example
* <pre>{@code
* String[] platforms = analyzer.getSupportedPlatforms();
* System.out.println("지원 플랫폼: " + String.join(", ", platforms));
* // 출력: 지원 플랫폼: 네이버TV
* }</pre>
*/
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 <video-url>");
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);
}
}

View File

@@ -0,0 +1,11 @@
package com.caliverse.analyzer.entity;
public enum UrlType {
YOUTUBE,
BLOB,
STREAMING,
DIRECT_FILE,
NAVER_TV,
UNKNOWN
}

View File

@@ -0,0 +1,116 @@
package com.caliverse.analyzer.model;
import com.caliverse.global.common.Messages;
/**
* 비디오 URL 분석 결과를 담는 모델 클래스입니다.
*
* <p>이 클래스는 비디오 URL 분석의 최종 결과를 나타냅니다.
* 모든 검사 항목이 통과하면 {@code success = true}, 하나라도 실패하면
* {@code success = false}와 함께 상세한 실패 이유를 제공합니다.
*
* <h3>분석 항목</h3>
* <ul>
* <li>보호된 URL 여부: DRM, 서명된 URL 감지</li>
* <li>3D 비디오 여부: 3D 포맷 감지</li>
* <li>AVPlayer 재생 가능 여부: 지원 포맷 확인</li>
* </ul>
*
* <h3>사용 예제</h3>
* <pre>{@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 비디오 헤더 감지
* }
* }</pre>
*
* @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;
}
/**
* 성공 결과 객체를 생성합니다.
*
* <p>모든 검사 항목이 통과했을 때 사용되며, reason은 "모든 검사 통과"로 설정됩니다.
*
* @return 성공 결과 객체
*/
public static VideoAnalysisResult success() {
return new VideoAnalysisResult(true, Messages.SUCCESS_ALL_CHECKS_PASSED);
}
/**
* 실패 결과 객체를 생성합니다.
*
* <p>하나 이상의 검사 항목이 실패했을 때 사용되며,
* 상세한 실패 이유를 reason에 포함합니다.
*
* @param reason 실패 이유 (개행 문자로 구분된 상세 설명 가능)
* @return 실패 결과 객체
*/
public static VideoAnalysisResult failure(String reason) {
return new VideoAnalysisResult(false, reason);
}
/**
* 분석 결과가 성공인지 여부를 반환합니다.
*
* <p>성공 조건:
* <ul>
* <li>보호되지 않은 URL</li>
* <li>2D 비디오</li>
* <li>AVPlayer에서 재생 가능</li>
* </ul>
*
* @return 모든 검사가 통과하면 {@code true}, 하나라도 실패하면 {@code false}
*/
public boolean isSuccess() {
return success;
}
/**
* 분석 결과의 이유를 반환합니다.
*
* <p>성공한 경우: "모든 검사 통과"<br>
* 실패한 경우: 상세한 실패 이유 (각 항목별로 구분)
*
* <p>실패 이유 예시:
* <pre>
* 재생 불가 사유:
* - 보호된 URL: DRM 보호 키워드 감지: widevine
* - 재생 불가: 지원되지 않는 포맷 또는 Content-Type: video/x-flv
* </pre>
*
* @return 분석 결과 이유 (null이 아닌 문자열)
*/
public String getReason() {
return reason;
}
@Override
public String toString() {
if (success) {
return "VideoAnalysisResult{ 성공: " + reason + " }";
} else {
return "VideoAnalysisResult{ 실패: " + reason + " }";
}
}
}

View File

@@ -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);
}

View File

@@ -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<PlatformAnalyzer> 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<PlatformAnalyzer> getAllAnalyzers() {
return new ArrayList<>(analyzers);
}
/**
* 새로운 플랫폼 분석기를 추가합니다.
*
* @param analyzer 추가할 분석기
*/
public void addAnalyzer(PlatformAnalyzer analyzer) {
analyzers.add(analyzer);
}
}

View File

@@ -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 분석기입니다.
*
* <p>mp4, m3u8 등 직접 비디오 파일 URL의 유효성을 검증합니다.
*
* <p>지원 포맷:
* <ul>
* <li>MP4 (.mp4)</li>
* <li>HLS (.m3u8)</li>
* <li>DASH (.mpd)</li>
* <li>WebM (.webm)</li>
* <li>MOV (.mov)</li>
* <li>AVI (.avi)</li>
* <li>MKV (.mkv)</li>
* </ul>
*
* <p>검증 항목:
* <ul>
* <li>HTTP 접근 가능 여부</li>
* <li>CORS 제한 확인</li>
* <li>DRM 보호 여부</li>
* <li>인증 필요 여부 (Signed URL)</li>
* </ul>
*/
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;
}
}
}

View File

@@ -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 요청 기반 플랫폼 분석기의 추상 클래스입니다.
*
* <p>이 클래스는 HTTP 요청/응답 처리를 담당하며,
* 실제 응답 파싱은 각 플랫폼에서 구현합니다.
*
* <p>지원 방식:
* <ul>
* <li>JSON 파싱 (oEmbed, REST API 등)</li>
* <li>HTML 파싱 (embed 코드 추출)</li>
* <li>커스텀 파싱 (플랫폼별 고유 방식)</li>
* </ul>
*/
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;
}
}

View File

@@ -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 플랫폼 분석기입니다.
*
* <p>oEmbed API를 사용하여 카카오TV 영상의 유효성을 검증합니다.
*
* <p>지원 URL 형식:
* <ul>
* <li>https://tv.kakao.com/channel/{channel_id}/cliplink/{clip_id}</li>
* <li>https://tv.kakao.com/v/{video_id}</li>
* </ul>
*
* <p>oEmbed 엔드포인트: https://tv.kakao.com/oembed
*
* <p>응답 예시 (JSON):
* <pre>
* {
* "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": "<iframe ...>",
* "width": 640,
* "height": 360
* }
* </pre>
*/
public class KakaoTvPlatformAnalyzer extends HttpBasedPlatformAnalyzer {
private static final Logger logger = LoggerFactory.getLogger(KakaoTvPlatformAnalyzer.class);
private final ObjectMapper objectMapper;
public KakaoTvPlatformAnalyzer() {
this.objectMapper = new ObjectMapper();
}
@Override
protected String getApiEndpoint() {
return CommonConstants.API_ENDPOINT_KAKAO_TV;
}
@Override
protected String getPlatformDomain() {
return CommonConstants.DOMAIN_KAKAO_TV;
}
@Override
public String getPlatformName() {
return CommonConstants.PLATFORM_NAME_KAKAO_TV;
}
/**
* JSON 응답을 파싱하여 비디오 검증 결과를 반환합니다.
*
* <p>카카오TV는 oEmbed JSON 형식으로 응답하며,
* type, title, html 필드를 검증합니다.
*/
@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");
String html = getStringValue(rootNode, "html");
String providerName = getStringValue(rootNode, "provider_name");
// provider_name 검증
if (providerName != null && !CommonConstants.PROVIDER_KAKAO_TV.equals(providerName)) {
logger.warn(Messages.LOG_UNEXPECTED_PROVIDER, providerName, CommonConstants.PROVIDER_KAKAO_TV);
}
// 유효성 검증
if (type == null || (title == null && html == null)) {
String errorMsg = Messages.KAKAO_TV_OEMBED_INVALID;
logger.warn(errorMsg);
return VideoAnalysisResult.failure(errorMsg);
}
// 디버그 로깅
String authorName = getStringValue(rootNode, "author_name");
String authorUrl = getStringValue(rootNode, "author_url");
if (authorName != null) {
logger.debug(Messages.LOG_CHANNEL_INFO, CommonConstants.PLATFORM_NAME_KAKAO_TV, authorName, authorUrl);
}
String thumbnailUrl = getStringValue(rootNode, "thumbnail_url");
Integer thumbnailWidth = getIntValue(rootNode, "thumbnail_width");
Integer thumbnailHeight = getIntValue(rootNode, "thumbnail_height");
if (thumbnailUrl != null) {
logger.debug(Messages.LOG_THUMBNAIL_INFO, thumbnailWidth, thumbnailHeight, thumbnailUrl);
}
logger.debug(Messages.LOG_OEMBED_VALIDATION_SUCCESS, CommonConstants.PLATFORM_NAME_KAKAO_TV, title);
return VideoAnalysisResult.success();
} catch (Exception e) {
throw new IOException(Messages.format(Messages.KAKAO_TV_PARSING_FAILED, e.getMessage()), e);
}
}
}

View File

@@ -0,0 +1,323 @@
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;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* 네이버TV 플랫폼 분석기입니다.
*
* <p>oEmbed API를 우선 사용하며, 실패 시 HTML 파싱으로 fallback합니다.
*
* <p>지원 URL 형식:
* <ul>
* <li>https://tv.naver.com/v/{video_id} (VOD)</li>
* <li>https://tv.naver.com/l/{video_id} (Live)</li>
* <li>https://tv.naver.com/h/{video_id} (Shorts - oEmbed 미지원)</li>
* <li>https://naver.me/{short_code} (단축 URL - 자동 리다이렉트)</li>
* </ul>
*
* <p>oEmbed 엔드포인트: https://tv.naver.com/oembed
*
* <p>응답 예시 (JSON):
* <pre>
* {
* "provider_name": "NAVERTV",
* "provider_url": "https://tv.naver.com",
* "version": "1.0",
* "type": "video",
* "height": 306,
* "width": 544,
* "author_url": "https://tv.naver.com/studych204",
* "author_name": "스터디채널",
* "title": "환경직공무원 2",
* "thumbnail_url": "https://...",
* "html": "<iframe ...>",
* "videoType": "vod",
* "playerUrl": "https://tv.naver.com/embed/40687083?autoPlay=true"
* }
* </pre>
*
* <p>Shorts(h/)의 경우 oEmbed 응답이 무효 (width=0, height=0)하므로
* URL에서 videoId를 추출하여 embed URL을 구성합니다.
*
* <p>단축 URL(naver.me)의 경우 HttpClient가 자동으로 리다이렉트를 따라갑니다.
*/
public class NaverTvPlatformAnalyzer extends HttpBasedPlatformAnalyzer {
private static final Logger logger = LoggerFactory.getLogger(NaverTvPlatformAnalyzer.class);
// URL 패턴 (v/, l/, h/ - VOD, Live, Shorts)
private static final Pattern VIDEO_ID_PATTERN = Pattern.compile("tv\\.naver\\.com/(?:v|l|h)/(\\d+)");
private final ObjectMapper objectMapper;
public NaverTvPlatformAnalyzer() {
this.objectMapper = new ObjectMapper();
}
@Override
protected String getApiEndpoint() {
return CommonConstants.API_ENDPOINT_NAVER_TV;
}
@Override
protected String getPlatformDomain() {
return CommonConstants.DOMAIN_NAVER_TV;
}
@Override
public String getPlatformName() {
return CommonConstants.PLATFORM_NAME_NAVER_TV;
}
@Override
public boolean canHandle(String url) {
if (url == null) return false;
String lowerUrl = url.toLowerCase();
// tv.naver.com 또는 naver.me (단축 URL)
return lowerUrl.contains(CommonConstants.DOMAIN_NAVER_TV) || lowerUrl.contains(CommonConstants.DOMAIN_NAVER_SHORT);
}
/**
* JSON 응답을 파싱하여 비디오 검증 결과를 반환합니다.
*
* <p>네이버TV는 oEmbed JSON 형식으로 응답하며,
* VOD/Live의 경우 정상 응답하지만 Shorts는 width=0, height=0로 응답합니다.
*
* <p>oEmbed 실패 시 URL에서 videoId를 추출하여 embed URL을 구성합니다.
*/
@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");
String html = getStringValue(rootNode, "html");
Integer width = getIntValue(rootNode, "width");
Integer height = getIntValue(rootNode, "height");
String providerName = getStringValue(rootNode, "provider_name");
// provider_name 검증
if (providerName != null && !CommonConstants.PROVIDER_NAVER_TV.equals(providerName)) {
logger.warn(Messages.LOG_UNEXPECTED_PROVIDER, providerName, CommonConstants.PROVIDER_NAVER_TV);
}
// oEmbed 응답 유효성 검증
// width=0, height=0인 경우 Shorts로 판단하고 fallback
if (width == null || height == null || width == 0 || height == 0) {
logger.debug(Messages.LOG_NAVER_TV_OEMBED_INVALID_FALLBACK, width, height);
return fallbackToVideoIdExtraction(originalUrl);
}
// 유효한 oEmbed 응답
if (type == null || (title == null && html == null)) {
String errorMsg = Messages.NAVER_TV_OEMBED_INVALID;
logger.warn(errorMsg);
return VideoAnalysisResult.failure(errorMsg);
}
// 디버그 로깅
String authorName = getStringValue(rootNode, "author_name");
String playerUrl = getStringValue(rootNode, "playerUrl");
String videoType = getStringValue(rootNode, "videoType");
if (authorName != null) {
logger.debug(Messages.LOG_NAVER_TV_CHANNEL, authorName);
}
if (playerUrl != null) {
logger.debug(Messages.LOG_NAVER_TV_PLAYER_URL, playerUrl);
}
if (videoType != null) {
logger.debug(Messages.LOG_NAVER_TV_VIDEO_TYPE, videoType);
}
logger.debug(Messages.LOG_OEMBED_VALIDATION_SUCCESS, CommonConstants.PLATFORM_NAME_NAVER_TV, title);
return VideoAnalysisResult.success();
} catch (Exception e) {
logger.warn(Messages.NAVER_TV_OEMBED_PARSING_FAILED, e.getMessage());
return fallbackToVideoIdExtraction(originalUrl);
}
}
/**
* URL에서 videoId를 추출하거나 HTML을 파싱하여 메타데이터를 구성합니다.
* Shorts 등 oEmbed가 지원되지 않는 경우 사용합니다.
*
* @param url 원본 URL
* @return 검증 결과
*/
private VideoAnalysisResult fallbackToVideoIdExtraction(String url) {
try {
// 1. URL에서 videoId 추출
Matcher matcher = VIDEO_ID_PATTERN.matcher(url);
if (!matcher.find()) {
// 단축 URL인 경우 HTML 파싱 시도
return fallbackToHtmlParsing(url);
}
String videoId = matcher.group(1);
// 2. HTML 파싱으로 메타데이터 추출
return parseHtmlMetadata(url, videoId);
} catch (Exception e) {
String errorMsg = Messages.format(Messages.NAVER_TV_FALLBACK_FAILED, e.getMessage());
logger.error(errorMsg, e);
return VideoAnalysisResult.failure(errorMsg);
}
}
/**
* HTML을 파싱하여 메타데이터를 추출합니다.
*
* @param url 원본 URL
* @param videoId 비디오 ID
* @return 검증 결과
*/
private VideoAnalysisResult parseHtmlMetadata(String url, String videoId) throws IOException {
try (org.apache.http.impl.client.CloseableHttpClient httpClient =
org.apache.http.impl.client.HttpClients.createDefault()) {
org.apache.http.client.methods.HttpGet request = new org.apache.http.client.methods.HttpGet(url);
request.setHeader(CommonConstants.HEADER_USER_AGENT, CommonConstants.USER_AGENT_STANDARD);
try (org.apache.http.client.methods.CloseableHttpResponse response = httpClient.execute(request)) {
int statusCode = response.getStatusLine().getStatusCode();
if (statusCode != 200) {
logger.warn(Messages.NAVER_TV_HTML_FETCH_FAILED, statusCode);
// 메타데이터 없이도 embed URL은 구성 가능
return buildMinimalMetadata(videoId);
}
String htmlContent = org.apache.http.util.EntityUtils.toString(
response.getEntity(), java.nio.charset.StandardCharsets.UTF_8);
// __NEXT_DATA__ 파싱
return extractNextDataMetadata(htmlContent, videoId);
}
}
}
/**
* HTML에서 __NEXT_DATA__ 스크립트를 파싱하여 메타데이터를 추출합니다.
*
* @param htmlContent HTML 내용
* @param videoId 비디오 ID
* @return 검증 결과
*/
private VideoAnalysisResult extractNextDataMetadata(String htmlContent, String videoId) {
try {
// __NEXT_DATA__ 스크립트 태그 찾기
Pattern nextDataPattern = Pattern.compile(
"<script id=\"__NEXT_DATA__\" type=\"application/json\">(.+?)</script>",
Pattern.DOTALL
);
Matcher matcher = nextDataPattern.matcher(htmlContent);
if (!matcher.find()) {
logger.warn(Messages.NAVER_TV_NEXT_DATA_NOT_FOUND);
return buildMinimalMetadata(videoId);
}
String jsonData = matcher.group(1);
JsonNode rootNode = objectMapper.readTree(jsonData);
// props.pageProps.clipInfo 경로로 접근
JsonNode pageProps = rootNode.path("props").path("pageProps");
JsonNode clipInfo = pageProps.path("clipInfo");
if (clipInfo.isMissingNode()) {
logger.debug(Messages.NAVER_TV_CLIP_INFO_NOT_FOUND);
return buildMinimalMetadata(videoId);
}
// 메타데이터 추출
String title = getStringValue(clipInfo, "clipTitle");
String channelName = clipInfo.path("channel").path("channelName").asText(null);
String thumbnailUrl = clipInfo.path("thumbnailImageUrl").asText(null);
// 로깅
logger.debug(Messages.LOG_NAVER_TV_SHORTS_METADATA_SUCCESS);
logger.debug(Messages.LOG_NAVER_TV_TITLE, title);
logger.debug(Messages.LOG_NAVER_TV_CHANNEL_NAME, channelName);
logger.debug(Messages.LOG_NAVER_TV_EMBED_URL, Messages.format(CommonConstants.NAVER_TV_EMBED_URL_FORMAT, videoId));
if (thumbnailUrl != null) {
logger.debug(Messages.LOG_NAVER_TV_THUMBNAIL, thumbnailUrl);
}
return VideoAnalysisResult.success();
} catch (Exception e) {
logger.warn(Messages.NAVER_TV_NEXT_DATA_PARSING_FAILED, e.getMessage());
return buildMinimalMetadata(videoId);
}
}
/**
* 최소한의 메타데이터로 결과를 구성합니다.
*
* @param videoId 비디오 ID
* @return 검증 결과
*/
private VideoAnalysisResult buildMinimalMetadata(String videoId) {
String embedUrl = Messages.format(CommonConstants.NAVER_TV_EMBED_URL_FORMAT, videoId);
logger.debug(Messages.LOG_NAVER_TV_MINIMAL_METADATA);
logger.debug(Messages.LOG_NAVER_TV_TYPE);
logger.debug(Messages.LOG_NAVER_TV_PROVIDER);
logger.debug(Messages.LOG_NAVER_TV_VIDEO_TYPE_SHORTS);
logger.debug(Messages.LOG_NAVER_TV_EMBED_URL, embedUrl);
return VideoAnalysisResult.success();
}
/**
* 단축 URL의 경우 HTML을 파싱하여 처리합니다.
*
* @param url 단축 URL
* @return 검증 결과
*/
private VideoAnalysisResult fallbackToHtmlParsing(String url) {
try (org.apache.http.impl.client.CloseableHttpClient httpClient =
org.apache.http.impl.client.HttpClients.createDefault()) {
org.apache.http.client.methods.HttpGet request = new org.apache.http.client.methods.HttpGet(url);
request.setHeader(CommonConstants.HEADER_USER_AGENT, CommonConstants.USER_AGENT_STANDARD);
try (org.apache.http.client.methods.CloseableHttpResponse response = httpClient.execute(request)) {
// 리다이렉트된 최종 URL에서 videoId 추출
String finalUrl = request.getURI().toString();
Matcher matcher = VIDEO_ID_PATTERN.matcher(finalUrl);
if (!matcher.find()) {
String errorMsg = Messages.NAVER_TV_SHORT_URL_VIDEO_ID_NOT_FOUND;
logger.warn(errorMsg);
return VideoAnalysisResult.failure(errorMsg);
}
String videoId = matcher.group(1);
String htmlContent = org.apache.http.util.EntityUtils.toString(
response.getEntity(), java.nio.charset.StandardCharsets.UTF_8);
return extractNextDataMetadata(htmlContent, videoId);
}
} catch (Exception e) {
String errorMsg = Messages.format(Messages.NAVER_TV_SHORT_URL_PROCESSING_FAILED, e.getMessage());
logger.error(errorMsg, e);
return VideoAnalysisResult.failure(errorMsg);
}
}
}

View File

@@ -0,0 +1,120 @@
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;
/**
* TikTok 플랫폼 분석기입니다.
*
* <p>oEmbed API를 사용하여 TikTok 영상의 유효성을 검증합니다.
*
* <p>지원 URL 형식:
* <ul>
* <li>https://www.tiktok.com/@{username}/video/{video_id}</li>
* <li>https://vm.tiktok.com/{short_code} (단축 URL)</li>
* </ul>
*
* <p>oEmbed 엔드포인트: https://www.tiktok.com/oembed
*
* <p>응답 예시 (JSON):
* <pre>
* {
* "version": "1.0",
* "type": "video",
* "title": "#Ai #earthzoomout #capcut #😱",
* "author_url": "https://www.tiktok.com/@iasinfiltro20000",
* "author_name": "AI sin flitro",
* "width": "100%",
* "height": "100%",
* "html": "<blockquote class=\"tiktok-embed\" ...>"
* }
* </pre>
*/
public class TikTokPlatformAnalyzer extends HttpBasedPlatformAnalyzer {
private static final Logger logger = LoggerFactory.getLogger(TikTokPlatformAnalyzer.class);
private final ObjectMapper objectMapper;
public TikTokPlatformAnalyzer() {
this.objectMapper = new ObjectMapper();
}
@Override
protected String getApiEndpoint() {
return CommonConstants.API_ENDPOINT_TIKTOK;
}
@Override
protected String getPlatformDomain() {
return CommonConstants.DOMAIN_TIKTOK;
}
@Override
public boolean canHandle(String url) {
if (url == null) return false;
String lowerUrl = url.toLowerCase();
return lowerUrl.contains(CommonConstants.DOMAIN_TIKTOK) || lowerUrl.contains(CommonConstants.DOMAIN_TIKTOK_SHORT);
}
@Override
public String getPlatformName() {
return CommonConstants.PLATFORM_NAME_TIKTOK;
}
/**
* JSON 응답을 파싱하여 비디오 검증 결과를 반환합니다.
*
* <p>TikTok은 oEmbed JSON 형식으로 응답하며,
* type, title, html 필드를 검증합니다.
*/
@Override
protected VideoAnalysisResult parseResponse(String response, String originalUrl) throws IOException {
try {
JsonNode rootNode = objectMapper.readTree(response);
// 필수 필드 추출
String version = getStringValue(rootNode, "version");
String type = getStringValue(rootNode, "type");
String title = getStringValue(rootNode, "title");
String html = getStringValue(rootNode, "html");
String authorName = getStringValue(rootNode, "author_name");
String authorUrl = getStringValue(rootNode, "author_url");
// type 검증
if (!CommonConstants.CONTENT_TYPE_VIDEO.equals(type)) {
String errorMsg = Messages.format(Messages.TIKTOK_OEMBED_TYPE_INVALID, type);
logger.warn(errorMsg);
return VideoAnalysisResult.failure(errorMsg);
}
// 유효성 검증
if (title == null && html == null) {
String errorMsg = Messages.TIKTOK_OEMBED_INVALID;
logger.warn(errorMsg);
return VideoAnalysisResult.failure(errorMsg);
}
// 디버그 로깅
if (authorName != null) {
logger.debug(Messages.LOG_CHANNEL_INFO, CommonConstants.PLATFORM_NAME_TIKTOK, authorName, authorUrl);
}
if (version != null) {
logger.debug(Messages.LOG_OEMBED_VERSION, CommonConstants.PLATFORM_NAME_TIKTOK, version);
}
logger.debug(Messages.LOG_OEMBED_VALIDATION_SUCCESS, CommonConstants.PLATFORM_NAME_TIKTOK, title);
return VideoAnalysisResult.success();
} catch (Exception e) {
throw new IOException(Messages.format(Messages.TIKTOK_PARSING_FAILED, e.getMessage()), e);
}
}
}

View File

@@ -0,0 +1,132 @@
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;
/**
* Twitter/X 플랫폼 분석기입니다.
*
* <p>oEmbed API를 사용하여 Twitter 영상 트윗의 유효성을 검증합니다.
*
* <p>지원 URL 형식:
* <ul>
* <li>https://twitter.com/{username}/status/{tweet_id}</li>
* <li>https://x.com/{username}/status/{tweet_id}</li>
* </ul>
*
* <p>oEmbed 엔드포인트: https://publish.twitter.com/oembed
*
* <p>응답 예시 (JSON):
* <pre>
* {
* "url": "https://twitter.com/...",
* "author_name": "...",
* "author_url": "https://twitter.com/...",
* "html": "<blockquote class=\"twitter-tweet\">...",
* "width": 550,
* "height": null,
* "type": "rich",
* "provider_name": "Twitter",
* "provider_url": "https://twitter.com",
* "version": "1.0"
* }
* </pre>
*/
public class TwitterPlatformAnalyzer extends HttpBasedPlatformAnalyzer {
private static final Logger logger = LoggerFactory.getLogger(TwitterPlatformAnalyzer.class);
private final ObjectMapper objectMapper;
public TwitterPlatformAnalyzer() {
this.objectMapper = new ObjectMapper();
}
@Override
protected String getApiEndpoint() {
return CommonConstants.API_ENDPOINT_TWITTER;
}
@Override
protected String getPlatformDomain() {
return CommonConstants.DOMAIN_TWITTER;
}
@Override
public boolean canHandle(String url) {
if (url == null) return false;
String lowerUrl = url.toLowerCase();
// twitter.com 또는 x.com
return lowerUrl.contains(CommonConstants.DOMAIN_TWITTER) || lowerUrl.contains(CommonConstants.DOMAIN_X);
}
@Override
public String getPlatformName() {
return CommonConstants.PLATFORM_NAME_TWITTER;
}
/**
* JSON 응답을 파싱하여 비디오 검증 결과를 반환합니다.
*
* <p>Twitter는 oEmbed JSON 형식으로 응답하며,
* type(rich 또는 video), html 필드를 검증합니다.
*/
@Override
protected VideoAnalysisResult parseResponse(String response, String originalUrl) throws IOException {
try {
JsonNode rootNode = objectMapper.readTree(response);
// 필수 필드 추출
String version = getStringValue(rootNode, "version");
String type = getStringValue(rootNode, "type");
String html = getStringValue(rootNode, "html");
String authorName = getStringValue(rootNode, "author_name");
String authorUrl = getStringValue(rootNode, "author_url");
String providerName = getStringValue(rootNode, "provider_name");
String url = getStringValue(rootNode, "url");
// provider_name 검증
if (providerName != null && !CommonConstants.PROVIDER_TWITTER.equals(providerName)) {
logger.warn(Messages.LOG_UNEXPECTED_PROVIDER, providerName, CommonConstants.PROVIDER_TWITTER);
}
// type 검증 (Twitter는 "rich" 또는 "video" 타입 사용)
if (type == null || (!type.equals(CommonConstants.CONTENT_TYPE_RICH) && !type.equals(CommonConstants.CONTENT_TYPE_VIDEO))) {
String errorMsg = Messages.format(Messages.TWITTER_OEMBED_TYPE_INVALID, type);
logger.warn(errorMsg);
return VideoAnalysisResult.failure(errorMsg);
}
// 유효성 검증
if (html == null || html.isEmpty()) {
String errorMsg = Messages.TWITTER_OEMBED_INVALID;
logger.warn(errorMsg);
return VideoAnalysisResult.failure(errorMsg);
}
// 디버그 로깅
if (authorName != null) {
logger.debug(Messages.LOG_ACCOUNT_INFO, CommonConstants.PLATFORM_NAME_TWITTER, authorName, authorUrl);
}
if (version != null) {
logger.debug(Messages.LOG_OEMBED_VERSION, CommonConstants.PLATFORM_NAME_TWITTER, version);
}
if (url != null) {
logger.debug(Messages.LOG_TWEET_URL, url);
}
logger.debug(Messages.LOG_OEMBED_VALIDATION_SUCCESS_NO_TITLE, CommonConstants.PLATFORM_NAME_TWITTER);
return VideoAnalysisResult.success();
} catch (Exception e) {
throw new IOException(Messages.format(Messages.TWITTER_PARSING_FAILED, e.getMessage()), e);
}
}
}

View File

@@ -0,0 +1,103 @@
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;
/**
* Vimeo 플랫폼 분석기입니다.
*
* <p>oEmbed API를 사용하여 Vimeo 영상의 유효성을 검증합니다.
*
* <p>지원 URL 형식:
* <ul>
* <li>https://vimeo.com/{video_id}</li>
* </ul>
*
* <p>oEmbed 엔드포인트: https://vimeo.com/api/oembed.json
*
* <p>응답 예시 (JSON):
* <pre>
* {
* "type": "video",
* "version": "1.0",
* "provider_name": "Vimeo",
* "provider_url": "https://vimeo.com/",
* "title": "Video Title",
* "author_name": "Author Name",
* "author_url": "https://vimeo.com/user",
* "width": 640,
* "height": 360,
* "html": "<iframe ...>"
* }
* </pre>
*/
public class VimeoPlatformAnalyzer extends HttpBasedPlatformAnalyzer {
private static final Logger logger = LoggerFactory.getLogger(VimeoPlatformAnalyzer.class);
private final ObjectMapper objectMapper;
public VimeoPlatformAnalyzer() {
this.objectMapper = new ObjectMapper();
}
@Override
protected String getApiEndpoint() {
return CommonConstants.API_ENDPOINT_VIMEO;
}
@Override
protected String getPlatformDomain() {
return CommonConstants.DOMAIN_VIMEO;
}
@Override
public String getPlatformName() {
return CommonConstants.PLATFORM_NAME_VIMEO;
}
/**
* JSON 응답을 파싱하여 비디오 검증 결과를 반환합니다.
*
* <p>Vimeo는 oEmbed JSON 형식으로 응답하며,
* type, title, html 필드를 검증합니다.
*/
@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");
String html = getStringValue(rootNode, "html");
// 유효성 검증
if (type == null || (title == null && html == null)) {
String errorMsg = Messages.VIMEO_OEMBED_INVALID;
logger.warn(errorMsg);
return VideoAnalysisResult.failure(errorMsg);
}
// 디버그 로깅
String authorName = getStringValue(rootNode, "author_name");
String authorUrl = getStringValue(rootNode, "author_url");
if (authorName != null) {
logger.debug(Messages.LOG_CHANNEL_INFO, CommonConstants.PLATFORM_NAME_VIMEO, authorName, authorUrl);
}
logger.debug(Messages.LOG_OEMBED_VALIDATION_SUCCESS, CommonConstants.PLATFORM_NAME_VIMEO, title);
return VideoAnalysisResult.success();
} catch (Exception e) {
throw new IOException(Messages.format(Messages.VIMEO_PARSING_FAILED, e.getMessage()), e);
}
}
}

View File

@@ -0,0 +1,316 @@
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 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.nio.charset.StandardCharsets;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* 웨이보(Weibo) 플랫폼 분석기입니다.
* 현재 메타데이터 분석 안됨
* <p>HTML 파싱 방식으로 웨이보 비디오의 유효성을 검증합니다.
* 웨이보는 공식 oEmbed API를 제공하지 않아 HTML에서 메타데이터를 추출합니다.
*
* <p>지원 URL 형식:
* <ul>
* <li>https://weibo.com/tv/show/{video_id}</li>
* <li>https://video.weibo.com/show?fid={video_id}</li>
* <li>https://m.weibo.cn/status/{status_id}</li>
* <li>https://weibo.com/{user_id}/{status_id}</li>
* </ul>
*
* <p>파싱 방식:
* <ul>
* <li>HTML에서 og:video 메타태그 추출</li>
* <li>$render_data 스크립트에서 JSON 파싱</li>
* <li>비디오 URL 유효성 확인</li>
* </ul>
*/
public class WeiboPlatformAnalyzer implements PlatformAnalyzer {
private static final Logger logger = LoggerFactory.getLogger(WeiboPlatformAnalyzer.class);
// URL 패턴
private static final Pattern VIDEO_URL_PATTERN = Pattern.compile(
"(?:weibo\\.com/tv/show/|video\\.weibo\\.com/show\\?fid=)([0-9:]+)"
);
private static final Pattern STATUS_URL_PATTERN = Pattern.compile(
"(?:m\\.weibo\\.cn/status/|weibo\\.com/\\d+/)([a-zA-Z0-9]+)"
);
// HTML 메타태그 패턴
private static final Pattern OG_VIDEO_PATTERN = Pattern.compile(
"<meta\\s+property=\"og:video\"\\s+content=\"([^\"]+)\"",
Pattern.CASE_INSENSITIVE
);
private static final Pattern OG_TITLE_PATTERN = Pattern.compile(
"<meta\\s+property=\"og:title\"\\s+content=\"([^\"]+)\"",
Pattern.CASE_INSENSITIVE
);
private static final Pattern OG_IMAGE_PATTERN = Pattern.compile(
"<meta\\s+property=\"og:image\"\\s+content=\"([^\"]+)\"",
Pattern.CASE_INSENSITIVE
);
// $render_data 스크립트 패턴 (웨이보의 동적 데이터)
private static final Pattern RENDER_DATA_PATTERN = Pattern.compile(
"\\$render_data\\s*=\\s*(\\[.*?\\])\\[0\\]",
Pattern.DOTALL
);
private final ObjectMapper objectMapper;
public WeiboPlatformAnalyzer() {
this.objectMapper = new ObjectMapper();
}
@Override
public String getPlatformName() {
return CommonConstants.PLATFORM_NAME_WEIBO;
}
@Override
public boolean canHandle(String url) {
if (url == null) return false;
String lowerUrl = url.toLowerCase();
return lowerUrl.contains(CommonConstants.DOMAIN_WEIBO) || lowerUrl.contains(CommonConstants.DOMAIN_WEIBO_CN);
}
@Override
public VideoAnalysisResult analyze(String url) {
try {
logger.debug(Messages.LOG_ANALYSIS_START, CommonConstants.PLATFORM_NAME_WEIBO, url);
// HTML 가져오기
String htmlContent = fetchHtmlContent(url);
// 비디오 메타데이터 추출
VideoAnalysisResult result = extractVideoMetadata(htmlContent, url);
if (result.isSuccess()) {
logger.debug(Messages.LOG_VALIDATION_SUCCESS, CommonConstants.PLATFORM_NAME_WEIBO);
} else {
logger.warn(Messages.LOG_VALIDATION_FAILED, CommonConstants.PLATFORM_NAME_WEIBO, result.getReason());
}
return result;
} catch (IOException e) {
String errorMsg = Messages.format(Messages.WEIBO_HTML_FETCH_FAILED, CommonConstants.PLATFORM_NAME_WEIBO, e.getMessage());
logger.warn(errorMsg, e);
return VideoAnalysisResult.failure(errorMsg);
} catch (Exception e) {
String errorMsg = Messages.format(Messages.WEIBO_ANALYSIS_ERROR, CommonConstants.PLATFORM_NAME_WEIBO, e.getMessage());
logger.error(errorMsg, e);
return VideoAnalysisResult.failure(errorMsg);
}
}
/**
* HTML 콘텐츠를 가져옵니다.
*
* @param url 웨이보 URL
* @return HTML 콘텐츠
* @throws IOException HTTP 요청 실패 시
*/
private String fetchHtmlContent(String url) throws IOException {
try (CloseableHttpClient httpClient = HttpClients.createDefault()) {
HttpGet request = new HttpGet(url);
// 웨이보는 User-Agent를 엄격히 확인하므로 실제 브라우저처럼 설정
request.setHeader(CommonConstants.HEADER_USER_AGENT, CommonConstants.USER_AGENT_WEIBO);
request.setHeader(CommonConstants.HEADER_ACCEPT, CommonConstants.ACCEPT_HTML);
request.setHeader(CommonConstants.HEADER_ACCEPT_LANGUAGE, CommonConstants.ACCEPT_LANGUAGE_ZH_CN);
request.setHeader(CommonConstants.HEADER_REFERER, CommonConstants.REFERER_WEIBO);
try (CloseableHttpResponse response = httpClient.execute(request)) {
int statusCode = response.getStatusLine().getStatusCode();
if (statusCode != 200) {
throw new IOException(Messages.format(Messages.WEIBO_HTTP_REQUEST_FAILED, statusCode));
}
return EntityUtils.toString(response.getEntity(), StandardCharsets.UTF_8);
}
}
}
/**
* HTML에서 비디오 메타데이터를 추출합니다.
*
* @param htmlContent HTML 콘텐츠
* @param originalUrl 원본 URL
* @return 검증 결과
*/
private VideoAnalysisResult extractVideoMetadata(String htmlContent, String originalUrl) {
try {
// 1. Open Graph 메타태그에서 비디오 URL 추출
Matcher videoMatcher = OG_VIDEO_PATTERN.matcher(htmlContent);
String videoUrl = null;
if (videoMatcher.find()) {
videoUrl = videoMatcher.group(1);
logger.debug(Messages.LOG_OG_VIDEO_FOUND, videoUrl);
}
// 2. 제목 추출
Matcher titleMatcher = OG_TITLE_PATTERN.matcher(htmlContent);
String title = null;
if (titleMatcher.find()) {
title = titleMatcher.group(1);
logger.debug(Messages.LOG_OG_TITLE_FOUND, title);
}
// 3. 썸네일 추출
Matcher imageMatcher = OG_IMAGE_PATTERN.matcher(htmlContent);
String thumbnailUrl = null;
if (imageMatcher.find()) {
thumbnailUrl = imageMatcher.group(1);
logger.debug(Messages.LOG_OG_IMAGE_FOUND, thumbnailUrl);
}
// 4. $render_data에서 추가 정보 시도
VideoMetadata renderData = extractRenderData(htmlContent);
if (renderData != null) {
if (title == null) title = renderData.title;
if (videoUrl == null) videoUrl = renderData.videoUrl;
if (thumbnailUrl == null) thumbnailUrl = renderData.thumbnailUrl;
}
// 5. 비디오 URL이 있으면 성공
if (videoUrl != null && !videoUrl.isEmpty()) {
logger.debug(Messages.LOG_WEIBO_VIDEO_VALIDATION_SUCCESS);
logger.debug(Messages.LOG_WEIBO_TITLE, title);
logger.debug(Messages.LOG_WEIBO_VIDEO_URL, videoUrl);
if (thumbnailUrl != null) {
logger.debug(Messages.LOG_WEIBO_THUMBNAIL, thumbnailUrl);
}
return VideoAnalysisResult.success();
}
// 6. 비디오 URL이 없으면 URL 패턴으로 최소 검증
if (isValidWeiboVideoUrl(originalUrl)) {
logger.debug(Messages.LOG_URL_PATTERN_VALIDATION_SUCCESS, CommonConstants.PLATFORM_NAME_WEIBO);
return VideoAnalysisResult.success();
}
String errorMsg = Messages.WEIBO_METADATA_NOT_FOUND;
logger.warn(errorMsg);
return VideoAnalysisResult.failure(errorMsg);
} catch (Exception e) {
String errorMsg = Messages.format(Messages.WEIBO_METADATA_EXTRACTION_FAILED, e.getMessage());
logger.error(errorMsg, e);
return VideoAnalysisResult.failure(errorMsg);
}
}
/**
* $render_data 스크립트에서 비디오 정보를 추출합니다.
*
* @param htmlContent HTML 콘텐츠
* @return 비디오 메타데이터 (없으면 null)
*/
private VideoMetadata extractRenderData(String htmlContent) {
try {
Matcher matcher = RENDER_DATA_PATTERN.matcher(htmlContent);
if (!matcher.find()) {
logger.debug(Messages.WEIBO_RENDER_DATA_NOT_FOUND);
return null;
}
String jsonData = matcher.group(1);
JsonNode rootNode = objectMapper.readTree(jsonData);
// 데이터 구조: [{ status: { page_info: { ... } } }]
if (!rootNode.isArray() || rootNode.size() == 0) {
return null;
}
JsonNode firstItem = rootNode.get(0);
JsonNode status = firstItem.path("status");
JsonNode pageInfo = status.path("page_info");
if (pageInfo.isMissingNode()) {
return null;
}
VideoMetadata metadata = new VideoMetadata();
metadata.title = getStringValue(pageInfo, "title");
metadata.videoUrl = getStringValue(pageInfo, "media_info");
JsonNode urls = pageInfo.path("urls");
if (!urls.isMissingNode() && urls.isObject()) {
// urls 객체에서 첫 번째 값 추출
urls.fields().forEachRemaining(entry -> {
if (metadata.videoUrl == null) {
metadata.videoUrl = entry.getValue().asText(null);
}
});
}
metadata.thumbnailUrl = getStringValue(pageInfo, "page_pic");
logger.debug(Messages.LOG_RENDER_DATA_PARSING_SUCCESS, metadata.title);
return metadata;
} catch (Exception e) {
logger.debug(Messages.format(Messages.WEIBO_RENDER_DATA_PARSING_FAILED, e.getMessage()));
return null;
}
}
/**
* URL 패턴으로 웨이보 비디오 URL 유효성을 확인합니다.
*
* @param url 검증할 URL
* @return 유효한 웨이보 비디오 URL이면 true
*/
private boolean isValidWeiboVideoUrl(String url) {
if (url == null) return false;
Matcher videoMatcher = VIDEO_URL_PATTERN.matcher(url);
if (videoMatcher.find()) {
return true;
}
Matcher statusMatcher = STATUS_URL_PATTERN.matcher(url);
return statusMatcher.find();
}
/**
* JsonNode에서 String 값을 안전하게 추출합니다.
*
* @param node JSON 노드
* @param fieldName 필드명
* @return 필드 값 (없으면 null)
*/
private String getStringValue(JsonNode node, String fieldName) {
if (node == null) return null;
JsonNode fieldNode = node.get(fieldName);
return fieldNode != null && !fieldNode.isNull() ? fieldNode.asText() : null;
}
/**
* 비디오 메타데이터를 담는 내부 클래스
*/
private static class VideoMetadata {
String title;
String videoUrl;
String thumbnailUrl;
}
}

View File

@@ -0,0 +1,134 @@
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;
/**
* YouTube 플랫폼 분석기입니다.
* oembed는 가능하지만 youtube 전용 player에서만 재생가능하다.
*
* <p>oEmbed API를 사용하여 YouTube 영상의 유효성을 검증합니다.
*
* <p>지원 URL 형식:
* <ul>
* <li>https://www.youtube.com/watch?v={video_id}</li>
* <li>https://youtu.be/{video_id}</li>
* </ul>
*
* <p>oEmbed 엔드포인트: https://www.youtube.com/oembed
*
* <p>응답 예시 (JSON):
* <pre>
* {
* "title": "왜 운동하는 사람들은 이 '스마트워치'만 쓸까? 궁금해서 써본 가민 피닉스8 2주 사용기!",
* "author_name": "ITSub잇섭",
* "author_url": "https://www.youtube.com/@ITSUB",
* "type": "video",
* "height": 113,
* "width": 200,
* "version": "1.0",
* "provider_name": "YouTube",
* "provider_url": "https://www.youtube.com/",
* "thumbnail_height": 360,
* "thumbnail_width": 480,
* "thumbnail_url": "https://i.ytimg.com/vi/4dpreJ9wUs8/hqdefault.jpg",
* "html": "<iframe ...>"
* }
* </pre>
*/
public class YouTubePlatformAnalyzer extends HttpBasedPlatformAnalyzer {
private static final Logger logger = LoggerFactory.getLogger(YouTubePlatformAnalyzer.class);
private final ObjectMapper objectMapper;
public YouTubePlatformAnalyzer() {
this.objectMapper = new ObjectMapper();
}
@Override
protected String getApiEndpoint() {
return CommonConstants.API_ENDPOINT_YOUTUBE;
}
@Override
protected String getPlatformDomain() {
return CommonConstants.DOMAIN_YOUTUBE;
}
@Override
public boolean canHandle(String url) {
if (url == null) return false;
String lowerUrl = url.toLowerCase();
return lowerUrl.contains(CommonConstants.DOMAIN_YOUTUBE) || lowerUrl.contains(CommonConstants.DOMAIN_YOUTUBE_SHORT);
}
@Override
public String getPlatformName() {
return CommonConstants.PLATFORM_NAME_YOUTUBE;
}
/**
* JSON 응답을 파싱하여 비디오 검증 결과를 반환합니다.
*
* <p>YouTube는 oEmbed JSON 형식으로 응답하며,
* type, title, html 필드를 검증합니다.
*/
@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");
String html = getStringValue(rootNode, "html");
String providerName = getStringValue(rootNode, "provider_name");
String version = getStringValue(rootNode, "version");
// provider_name 검증
if (providerName != null && !CommonConstants.PROVIDER_YOUTUBE.equals(providerName)) {
logger.warn(Messages.LOG_UNEXPECTED_PROVIDER, providerName, CommonConstants.PROVIDER_YOUTUBE);
}
// version 검증
if (version != null && !CommonConstants.VERSION_OEMBED_1_0.equals(version)) {
logger.debug(Messages.LOG_OEMBED_VERSION_MISMATCH, CommonConstants.PLATFORM_NAME_YOUTUBE, version, CommonConstants.VERSION_OEMBED_1_0);
}
// 유효성 검증
if (type == null || (title == null && html == null)) {
String errorMsg = Messages.YOUTUBE_OEMBED_INVALID;
logger.warn(errorMsg);
return VideoAnalysisResult.failure(errorMsg);
}
// 디버그 로깅
String authorName = getStringValue(rootNode, "author_name");
String authorUrl = getStringValue(rootNode, "author_url");
if (authorName != null) {
logger.debug(Messages.LOG_CHANNEL_INFO, CommonConstants.PLATFORM_NAME_YOUTUBE, authorName, authorUrl);
}
String thumbnailUrl = getStringValue(rootNode, "thumbnail_url");
Integer thumbnailWidth = getIntValue(rootNode, "thumbnail_width");
Integer thumbnailHeight = getIntValue(rootNode, "thumbnail_height");
if (thumbnailUrl != null) {
logger.debug(Messages.LOG_THUMBNAIL_INFO, thumbnailWidth, thumbnailHeight, thumbnailUrl);
}
logger.debug(Messages.LOG_OEMBED_VALIDATION_SUCCESS, CommonConstants.PLATFORM_NAME_YOUTUBE, title);
return VideoAnalysisResult.success();
} catch (Exception e) {
throw new IOException(Messages.format(Messages.YOUTUBE_PARSING_FAILED, e.getMessage()), e);
}
}
}

View File

@@ -0,0 +1,293 @@
package com.caliverse.analyzer.service;
import com.caliverse.analyzer.entity.UrlType;
import com.caliverse.analyzer.model.VideoAnalysisResult;
import com.caliverse.global.common.Messages;
import com.caliverse.global.util.HttpUtils;
import com.caliverse.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<String> SUPPORTED_FORMATS = Arrays.asList(
"mp4", "mov", "m4v", "m3u8", "hls"
);
// DRM 관련 키워드
private static final List<String> 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();
}
}
}

View File

@@ -0,0 +1,119 @@
package com.caliverse.global.common;
import java.util.regex.Pattern;
public class CommonConstants {
// ========== 플랫폼 이름 ==========
public static final String PLATFORM_NAME_NAVER_TV = "네이버TV";
public static final String PLATFORM_NAME_KAKAO_TV = "카카오TV";
public static final String PLATFORM_NAME_TIKTOK = "TikTok";
public static final String PLATFORM_NAME_TWITTER = "Twitter/X";
public static final String PLATFORM_NAME_VIMEO = "Vimeo";
public static final String PLATFORM_NAME_WEIBO = "웨이보";
public static final String PLATFORM_NAME_YOUTUBE = "YouTube";
public static final String PLATFORM_NAME_DIRECT_URL = "직접 URL";
// ========== 플랫폼 도메인 ==========
// 네이버TV
public static final String DOMAIN_NAVER_TV = "tv.naver.com";
public static final String DOMAIN_NAVER_SHORT = "naver.me";
// 카카오TV
public static final String DOMAIN_KAKAO_TV = "kakao.com";
// TikTok
public static final String DOMAIN_TIKTOK = "tiktok.com";
public static final String DOMAIN_TIKTOK_SHORT = "vm.tiktok.com";
// Twitter/X
public static final String DOMAIN_TWITTER = "twitter.com";
public static final String DOMAIN_X = "x.com";
// Vimeo
public static final String DOMAIN_VIMEO = "vimeo.com";
// 웨이보
public static final String DOMAIN_WEIBO = "weibo.com";
public static final String DOMAIN_WEIBO_CN = "weibo.cn";
// YouTube
public static final String DOMAIN_YOUTUBE = "youtube.com";
public static final String DOMAIN_YOUTUBE_SHORT = "youtu.be";
// ========== 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";
public static final String API_ENDPOINT_TIKTOK = "https://www.tiktok.com/oembed";
public static final String API_ENDPOINT_TWITTER = "https://publish.twitter.com/oembed";
public static final String API_ENDPOINT_VIMEO = "https://vimeo.com/api/oembed.json";
public static final String API_ENDPOINT_YOUTUBE = "https://www.youtube.com/oembed";
public static final String API_ENDPOINT_NAVER_VOD = "https://apis.naver.com/neonplayer/vodplay/v3/playback/%s?key=%s";
public static final String API_ENDPOINT_NAVER_SHORT = "https://api-videohub.naver.com/shortformhub/feeds/v7/card?serviceType=NTV&seedMediaId=%s&mediaType=VOD";
// ========== 플랫폼 제공자명 (Provider) ==========
public static final String PROVIDER_NAVER_TV = "NAVERTV";
public static final String PROVIDER_KAKAO_TV = "kakaoTV";
public static final String PROVIDER_TWITTER = "Twitter";
public static final String PROVIDER_YOUTUBE = "YouTube";
// ========== 플랫폼 버전 ==========
public static final String VERSION_OEMBED_1_0 = "1.0";
// ========== 컨텐츠 타입 ==========
public static final String CONTENT_TYPE_VIDEO = "video";
public static final String CONTENT_TYPE_RICH = "rich";
// ========== 비디오 확장자 ==========
public static final String[] VIDEO_EXTENSIONS = {".mp4", ".m3u8", ".mpd", ".webm", ".mov", ".avi", ".mkv"};
// ========== HTTP 타임아웃 (밀리초) ==========
public static final int HTTP_CONNECTION_TIMEOUT = 5000;
public static final int HTTP_SOCKET_TIMEOUT = 5000;
// ========== HTTP 헤더 ==========
public static final String HEADER_USER_AGENT = "User-Agent";
public static final String HEADER_REFERER = "Referer";
public static final String HEADER_ACCEPT = "Accept";
public static final String HEADER_ACCEPT_LANGUAGE = "Accept-Language";
public static final String HEADER_ORIGIN = "Origin";
public static final String HEADER_ACCESS_CONTROL_ALLOW_ORIGIN = "Access-Control-Allow-Origin";
public static final String HEADER_CONTENT_TYPE = "Content-Type";
// ========== 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 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";
// ========== Referer URL ==========
public static final String REFERER_NAVER_TV = "https://tv.naver.com/";
public static final String REFERER_WEIBO = "https://weibo.com";
// ========== Origin URL ==========
public static final String ORIGIN_LOCALHOST = "http://localhost";
// ========== Accept 헤더 값 ==========
public static final String ACCEPT_ALL = "*/*";
public static final String ACCEPT_JSON_HTML = "application/json, text/html";
public static final String ACCEPT_HTML = "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8";
// ========== Accept-Language 헤더 값 ==========
public static final String ACCEPT_LANGUAGE_ZH_CN = "zh-CN,zh;q=0.9,en;q=0.8";
// ========== 정규표현식 패턴 ==========
public static final Pattern NAVER_NEXT_DATA_PATTERN = Pattern.compile("<script id=\"__NEXT_DATA__\" type=\"application/json\">(.+?)</script>");
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*\"([^\"]+)\"");
// ========== URL 파라미터 키워드 ==========
public static final String URL_PARAM_SIGNATURE = "signature=";
public static final String URL_PARAM_TOKEN = "token=";
public static final String URL_PARAM_KEY = "key=";
// ========== 네이버TV Embed URL ==========
public static final String NAVER_TV_EMBED_URL_FORMAT = "https://tv.naver.com/embed/%s?autoPlay=true";
// Private constructor to prevent instantiation
private CommonConstants() {
throw new UnsupportedOperationException("This is a utility class and cannot be instantiated");
}
}

View File

@@ -0,0 +1,181 @@
package com.caliverse.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";
public static final String NAVER_TV_OEMBED_INVALID = "네이버TV oEmbed 응답이 유효하지 않습니다";
public static final String NAVER_TV_OEMBED_PARSING_FAILED = "네이버TV oEmbed 파싱 실패. Fallback 시도: %s";
public static final String NAVER_TV_FALLBACK_FAILED = "네이버TV fallback 처리 실패: %s";
public static final String NAVER_TV_HTML_FETCH_FAILED = "HTML 가져오기 실패. 상태 코드: %s";
public static final String NAVER_TV_NEXT_DATA_NOT_FOUND = "__NEXT_DATA__ 스크립트를 찾을 수 없습니다";
public static final String NAVER_TV_NEXT_DATA_PARSING_FAILED = "__NEXT_DATA__ 파싱 실패: %s. 최소 메타데이터로 처리";
public static final String NAVER_TV_CLIP_INFO_NOT_FOUND = "clipInfo를 찾을 수 없습니다";
public static final String NAVER_TV_SHORT_URL_VIDEO_ID_NOT_FOUND = "단축 URL에서 video ID를 추출할 수 없습니다";
public static final String NAVER_TV_SHORT_URL_PROCESSING_FAILED = "단축 URL 처리 실패: %s";
// ========== 카카오TV 관련 메시지 ==========
public static final String KAKAO_TV_OEMBED_INVALID = "카카오TV oEmbed 응답이 유효하지 않습니다";
public static final String KAKAO_TV_PARSING_FAILED = "카카오TV 응답 파싱 실패: %s";
// ========== TikTok 관련 메시지 ==========
public static final String TIKTOK_OEMBED_TYPE_INVALID = "TikTok oEmbed 응답의 type이 유효하지 않습니다: %s";
public static final String TIKTOK_OEMBED_INVALID = "TikTok oEmbed 응답이 유효하지 않습니다";
public static final String TIKTOK_PARSING_FAILED = "TikTok 응답 파싱 실패: %s";
// ========== Twitter/X 관련 메시지 ==========
public static final String TWITTER_OEMBED_TYPE_INVALID = "Twitter oEmbed 응답의 type이 유효하지 않습니다: %s";
public static final String TWITTER_OEMBED_INVALID = "Twitter oEmbed 응답이 유효하지 않습니다";
public static final String TWITTER_PARSING_FAILED = "Twitter 응답 파싱 실패: %s";
// ========== Vimeo 관련 메시지 ==========
public static final String VIMEO_OEMBED_INVALID = "Vimeo oEmbed 응답이 유효하지 않습니다";
public static final String VIMEO_PARSING_FAILED = "Vimeo 응답 파싱 실패: %s";
// ========== YouTube 관련 메시지 ==========
public static final String YOUTUBE_OEMBED_INVALID = "YouTube oEmbed 응답이 유효하지 않습니다";
public static final String YOUTUBE_PARSING_FAILED = "YouTube 응답 파싱 실패: %s";
// ========== 웨이보 관련 메시지 ==========
public static final String WEIBO_HTML_FETCH_FAILED = "%s: HTML 가져오기 실패 - %s";
public static final String WEIBO_ANALYSIS_ERROR = "%s: 분석 중 오류 발생 - %s";
public static final String WEIBO_HTTP_REQUEST_FAILED = "HTTP 요청 실패. 상태 코드: %s";
public static final String WEIBO_METADATA_NOT_FOUND = "웨이보 비디오 메타데이터를 찾을 수 없습니다";
public static final String WEIBO_METADATA_EXTRACTION_FAILED = "웨이보 메타데이터 추출 실패: %s";
public static final String WEIBO_RENDER_DATA_NOT_FOUND = "$render_data 스크립트를 찾을 수 없습니다";
public static final String WEIBO_RENDER_DATA_PARSING_FAILED = "$render_data 파싱 실패: %s";
// ========== 직접 URL 관련 메시지 ==========
public static final String DIRECT_URL_VALIDATION_FAILED = "직접 URL 검증 실패";
public static final String DIRECT_URL_ANALYSIS_ERROR = "직접 URL 분석 중 오류 발생: %s";
public static final String DIRECT_URL_UNSUPPORTED_FORMAT = "지원하지 않는 비디오 포맷입니다";
public static final String DIRECT_URL_MALFORMED = "잘못된 URL 형식입니다: %s";
public static final String DIRECT_URL_ACCESS_FAILED = "URL 접근 실패: %s";
public static final String DIRECT_URL_DRM_DETECTED = "DRM이 적용된 콘텐츠입니다: %s";
public static final String DIRECT_URL_AUTH_REQUIRED = "인증이 필요한 콘텐츠입니다 (Signed URL 또는 토큰 필요)";
public static final String DIRECT_URL_ACCESS_DENIED = "접근 거부 (403) - signed URL, DRM 또는 인증 필요";
public static final String DIRECT_URL_NOT_FOUND = "비디오를 찾을 수 없음 (404)";
public static final String DIRECT_URL_CLIENT_ERROR = "클라이언트 오류 (%s)";
public static final String DIRECT_URL_SERVER_ERROR = "서버 오류 (%s)";
public static final String DIRECT_URL_REDIRECT_REQUIRED = "리다이렉트 필요 (%s)";
public static final String DIRECT_URL_DRM_HEADER_DETECTED = "DRM 관련 헤더 감지: %s";
// ========== HTTP 기반 플랫폼 공통 메시지 ==========
public static final String HTTP_UNSUPPORTED_URL_FORMAT = "%s: 지원하지 않는 URL 형식";
public static final String HTTP_API_CALL_FAILED = "%s: API 호출 실패 - %s";
public static final String HTTP_ANALYSIS_ERROR = "%s: 분석 중 오류 발생 - %s";
public static final String HTTP_API_CALL_FAILED_STATUS_CODE = "API 호출 실패. 상태 코드: %s";
public static final String HTTP_URL_ENCODING_FAILED = "URL 인코딩 실패";
// ========== 공통 로그 메시지 ==========
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";
public static final String LOG_OEMBED_VALIDATION_SUCCESS_NO_TITLE = "%s oEmbed 검증 성공";
public static final String LOG_UNEXPECTED_PROVIDER = "예상하지 못한 provider_name: %s. 기대값: %s";
public static final String LOG_OEMBED_VERSION = "%s oEmbed version: %s";
public static final String LOG_OEMBED_VERSION_MISMATCH = "%s oEmbed version: %s (예상: %s)";
public static final String LOG_CHANNEL_INFO = "%s 채널: %s (%s)";
public static final String LOG_CHANNEL_INFO_NO_URL = "%s 채널: %s";
public static final String LOG_ACCOUNT_INFO = "%s 계정: %s (%s)";
public static final String LOG_THUMBNAIL_INFO = "썸네일: %sx%s - %s";
public static final String LOG_API_URL = "API URL: %s";
public static final String LOG_VIDEO_RESPONSE_CODE = "비디오 URL 응답 코드: %s for %s";
public static final String LOG_CORS_ENABLED = "CORS 활성화됨: %s";
public static final String LOG_CORS_DISABLED = "CORS 헤더 없음 - CORS 제한 가능성";
public static final String LOG_CORS_DISABLED_WARNING = "CORS가 활성화되지 않음 - 브라우저에서 재생 시 제한이 있을 수 있음";
public static final String LOG_CONTENT_TYPE = "Content-Type: %s";
public static final String LOG_DRM_HEADER_FOUND = "DRM 헤더 발견: %s = %s";
public static final String LOG_AUTH_HEADER_FOUND = "인증 관련 헤더 발견: %s";
public static final String LOG_AUTH_PARAM_IN_URL = "URL에 인증 파라미터 포함";
public static final String LOG_VIDEO_ACCESS_FAILED = "비디오 URL 접근 실패: %s";
public static final String LOG_OG_VIDEO_FOUND = "og:video 발견: %s";
public static final String LOG_OG_TITLE_FOUND = "og:title 발견: %s";
public static final String LOG_OG_IMAGE_FOUND = "og:image 발견: %s";
public static final String LOG_RENDER_DATA_PARSING_SUCCESS = "$render_data 파싱 성공: %s";
public static final String LOG_URL_PATTERN_VALIDATION_SUCCESS = "%s URL 패턴 검증 성공 (메타데이터 제한적)";
public static final String LOG_VIDEO_VALIDATION_SUCCESS = "%s 비디오 검증 성공";
public static final String LOG_METADATA_EXTRACTION_SUCCESS = "%s 메타데이터 추출 성공";
public static final String LOG_TWEET_URL = "Tweet URL: %s";
// ========== 네이버TV 디버그 로그 메시지 ==========
public static final String LOG_NAVER_TV_CHANNEL = "네이버TV 채널: %s";
public static final String LOG_NAVER_TV_PLAYER_URL = "Player URL: %s";
public static final String LOG_NAVER_TV_VIDEO_TYPE = "Video Type: %s";
public static final String LOG_NAVER_TV_OEMBED_INVALID_FALLBACK = "네이버TV oEmbed 응답 무효 (width=%s, height=%s). Fallback 시도";
public static final String LOG_NAVER_TV_SHORTS_METADATA_SUCCESS = "네이버TV Shorts 메타데이터 추출 성공";
public static final String LOG_NAVER_TV_MINIMAL_METADATA = "네이버TV 최소 메타데이터 구성";
public static final String LOG_NAVER_TV_TYPE = " - Type: video";
public static final String LOG_NAVER_TV_PROVIDER = " - Provider: NAVERTV";
public static final String LOG_NAVER_TV_VIDEO_TYPE_SHORTS = " - Video Type: shorts";
public static final String LOG_NAVER_TV_EMBED_URL = " - Embed URL: %s";
public static final String LOG_NAVER_TV_TITLE = " - 제목: %s";
public static final String LOG_NAVER_TV_CHANNEL_NAME = " - 채널: %s";
public static final String LOG_NAVER_TV_THUMBNAIL = " - 썸네일: %s";
public static final String LOG_NAVER_TV_VIDEO_URL = " - 비디오 URL: %s";
// ========== 웨이보 디버그 로그 메시지 ==========
public static final String LOG_WEIBO_VIDEO_VALIDATION_SUCCESS = "웨이보 비디오 검증 성공";
public static final String LOG_WEIBO_TITLE = " - 제목: %s";
public static final String LOG_WEIBO_VIDEO_URL = " - 비디오 URL: %s";
public static final String LOG_WEIBO_THUMBNAIL = " - 썸네일: %s";
// ========== 직접 URL 디버그 로그 메시지 ==========
public static final String LOG_DIRECT_URL_ANALYSIS_START = "직접 URL 분석 시작: %s";
public static final String LOG_DIRECT_URL_VALIDATION_FAILED_DETAIL = "직접 URL 검증 실패: %s";
public static final String LOG_DIRECT_URL_VALIDATION_SUCCESS = "직접 URL 검증 성공: %s";
private Messages() {
// 인스턴스화 방지
}
/**
* 포맷 문자열을 사용하는 메시지를 생성합니다.
*
* @param format 포맷 문자열
* @param args 포맷 인자
* @return 포맷팅된 메시지
*/
public static String format(String format, Object... args) {
return String.format(format, args);
}
}

View File

@@ -0,0 +1,165 @@
package com.caliverse.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;
}
}
}

View File

@@ -0,0 +1,107 @@
package com.caliverse.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<String> SIGNATURE_PARAMS = Arrays.asList(
"token", "signature", "sig", "expires", "exp", "auth",
"key", "access_token", "api_key", "hmac", "hash"
);
// 스트리밍 서비스의 URL 패턴 목록
private static final List<String> 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");
}
}

View File

@@ -0,0 +1,154 @@
package com.caliverse.analyzer;
import com.caliverse.analyzer.model.VideoAnalysisResult;
import org.junit.Before;
import org.junit.Test;
import static org.junit.Assert.*;
/**
* VideoAnalyzer 테스트 클래스입니다.
* 각 플랫폼별 URL 분석 및 기본 기능을 테스트합니다.
*/
public class VideoAnalyzerTest {
private VideoAnalyzer analyzer;
@Before
public void setUp() {
analyzer = new VideoAnalyzer();
}
// ========== oEmbed 기반 플랫폼 테스트 ==========
@Test
public void testAnalyze_KakaoTV() {
String kakaoUrl = "https://tv.kakao.com/channel/10210483/cliplink/459147379";
VideoAnalysisResult result = analyzer.analyzeUrl(kakaoUrl);
assertNotNull("결과가 null이 아니어야 합니다", result);
assertTrue("카카오TV URL은 재생 가능해야 합니다", result.isSuccess());
System.out.println("카카오TV: " + result);
}
@Test
public void testAnalyze_YouTube() {
String youtubeUrl = "https://www.youtube.com/watch?v=4dpreJ9wUs8";
VideoAnalysisResult result = analyzer.analyzeUrl(youtubeUrl);
assertNotNull("결과가 null이 아니어야 합니다", result);
assertTrue("YouTube URL은 재생 가능해야 합니다", result.isSuccess());
System.out.println("YouTube: " + result);
}
@Test
public void testAnalyze_Vimeo() {
String vimeoUrl = "https://vimeo.com/76979871";
VideoAnalysisResult result = analyzer.analyzeUrl(vimeoUrl);
assertNotNull("결과가 null이 아니어야 합니다", result);
System.out.println("Vimeo: " + result);
}
// ========== 네이버TV (전용 분석기) ==========
@Test
public void testAnalyze_NaverTV() {
String naverUrl = "https://tv.naver.com/v/40687083";
VideoAnalysisResult result = analyzer.analyzeUrl(naverUrl);
assertNotNull("결과가 null이 아니어야 합니다", result);
System.out.println("네이버TV vod: " + result);
}
@Test
public void testAnalyze_NaverClip() {
String naverUrl = "https://tv.naver.com/h/87740629";
VideoAnalysisResult result = analyzer.analyzeUrl(naverUrl);
assertNotNull("결과가 null이 아니어야 합니다", result);
System.out.println("네이버TV 클립: " + result);
}
@Test
public void testAnalyze_NaverLive() {
String naverUrl = "https://tv.naver.com/l/176935";
VideoAnalysisResult result = analyzer.analyzeUrl(naverUrl);
assertNotNull("결과가 null이 아니어야 합니다", result);
System.out.println("네이버TV 라이브: " + result);
}
// ========== 웨이보 ==========
@Test
public void testAnalyze_Weibo_TvShow() {
String weiboUrl = "https://weibo.com/tv/show/1034:4872947210436638";
VideoAnalysisResult result = analyzer.analyzeUrl(weiboUrl);
assertNotNull("결과가 null이 아니어야 합니다", result);
System.out.println("웨이보 TV Show: " + result);
}
@Test
public void testAnalyze_Weibo_VideoShow() {
String weiboUrl = "https://weibo.com/tv/show/1034:5228140880920589?mid=5228140990566042";
VideoAnalysisResult result = analyzer.analyzeUrl(weiboUrl);
assertNotNull("결과가 null이 아니어야 합니다", result);
System.out.println("웨이보 Video: " + result);
}
// ========== 직접 URL ==========
@Test
public void testAnalyze_DirectVideoUrl_Mp4() {
String mp4Url = "https://www.w3schools.com/html/mov_bbb.mp4";
VideoAnalysisResult result = analyzer.analyzeUrl(mp4Url);
assertNotNull("결과가 null이 아니어야 합니다", result);
System.out.println("MP4: " + result);
}
@Test
public void testAnalyze_DirectVideoUrl_M3u8() {
String m3u8Url = "https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8";
VideoAnalysisResult result = analyzer.analyzeUrl(m3u8Url);
assertNotNull("결과가 null이 아니어야 합니다", result);
System.out.println("M3U8: " + result);
}
// ========== 기본 기능 테스트 ==========
@Test
public void testSupportedPlatforms() {
String[] platforms = analyzer.getSupportedPlatforms();
assertNotNull("플랫폼 목록은 null이 아니어야 합니다", platforms);
assertTrue("최소 1개 이상의 플랫폼을 지원해야 합니다", platforms.length > 0);
System.out.println("지원 플랫폼: " + String.join(", ", platforms));
}
@Test
public void testAnalyze_InvalidUrl() {
String invalidUrl = "https://example.com/page.html";
VideoAnalysisResult result = analyzer.analyzeUrl(invalidUrl);
assertNotNull("결과가 null이 아니어야 합니다", result);
System.out.println("지원하지 않는 URL: " + result);
}
}