Compare commits
No commits in common. "master" and "feature/comment-cards" have entirely different histories.
master
...
feature/co
@ -1,68 +0,0 @@
|
|||||||
---
|
|
||||||
name: shortform-queue
|
|
||||||
description: h-lab 숏폼 큐의 대기 작업을 구글 Opal "숏폼 생성기"로 실행하고, 결과를 DB에 저장한 뒤 캡컷 에이전트(127.0.0.1:8001)로 영상 생성까지 넘긴다. "숏폼 돌려줘", "숏폼 큐 돌려줘", "/shortform-queue", "숏폼 1단계", "풀버전으로 돌려줘" 요청 시 사용.
|
|
||||||
---
|
|
||||||
|
|
||||||
# 숏폼 큐 실행 (Opal 브라우저 대행)
|
|
||||||
|
|
||||||
h-lab(localhost:8088)의 PENDING 작업을 Opal로 실행해 결과를 저장한다.
|
|
||||||
h-lab 서버가 꺼져 있으면 먼저 `.\gradlew.bat bootRun`(JAVA_HOME=D:\Development\app\JDK\jdk-21.0.5)으로 띄운다.
|
|
||||||
서버 시크릿은 git-ignored `application-local.yml` — 이 워크트리에 없으면 메인 워크트리(`D:\Development\InteliJ_repository\h-lab\src\main\resources\`)에서 복사.
|
|
||||||
|
|
||||||
## 모드 선택
|
|
||||||
|
|
||||||
| 요청 | 앱 | URL | 출력 형식 |
|
|
||||||
|------|-----|-----|----------|
|
|
||||||
| **"숏폼 돌려줘", "숏폼 큐 돌려줘" (기본)** | 숏폼 생성기 1단계 | `https://opal.google/edit/1Z9zXUiBAjKKOJ08zXxAmE9Neliq86SEw` | `{"candidates":[...]}` JSON 하나 |
|
|
||||||
| "풀버전으로 돌려줘", "전체 돌려줘" | 숏폼 생성기 (풀버전) | `https://opal.google/edit/1w3mThvSpI3_skt4p4OAN3-M6-tnqz0HN` | `N=====` 구간 5개 + cuts JSON |
|
|
||||||
|
|
||||||
사용자가 Opal URL을 직접 주면 그 URL 우선. 파서(ShortformOutputParser)는 두 형식 모두 인식한다(88179bb) — 어느 모드든 저장하면 DONE + 클립 5개.
|
|
||||||
|
|
||||||
## 절차 (공통)
|
|
||||||
|
|
||||||
1. **큐 조회**: `GET http://localhost:8088/api/shortform/jobs?status=PENDING` → 비어있으면 "대기 작업 없음" 보고 후 종료.
|
|
||||||
2. **Opal 접속**: 크롬 자동화로 모드에 맞는 편집 화면 접속. 로그인 풀려 있으면 사용자에게 로그인 요청.
|
|
||||||
3. 각 PENDING 작업마다:
|
|
||||||
a. 캔버스의 **YouTube Video 노드 클릭** → 우측 패널 URL 입력창을 비우고(전체선택 ctrl+a 후 덮어쓰기) 작업의 youtubeUrl 입력 → **Apply** (썸네일이 해당 영상으로 바뀌는지 확인).
|
|
||||||
b. Preview 패널의 새로고침(리셋) 아이콘 → **Start** 클릭.
|
|
||||||
c. **완료 폴링** 및 **추출**: 모드별 절차(아래) 참조.
|
|
||||||
d. **JSON 검증·보정** (1단계 모드에서 저장 전 필수): 추출 텍스트를 `ConvertFrom-Json`으로 파싱해본다. 실패하면 스킬 폴더의 `fix-opal-json.ps1`로 보정하고 다시 검증한다 — 통과하면 보정본으로 저장·전달하고, 여전히 깨지면 저장하지 말고(PENDING 유지) 원문과 파싱 오류를 보고한다. 보정 후 candidates가 5개인지도 확인. (풀버전은 `N=====` 구간 5개라 통짜 파싱 대상이 아님 — 저장 후 응답 clips 개수로 확인한다.)
|
|
||||||
e. **저장**: 브라우저 fetch는 HTTPS→HTTP라 차단됨 — 추출 텍스트를 파일로 옮겨 셸에서 `POST http://localhost:8088/api/shortform/jobs/{id}/result` body `{"rawText": "<Output 원문>", "step1Text": "<Step 1 원문>"}`. PowerShell은 UTF-8 바이트로 전송(`[Text.Encoding]::UTF8.GetBytes`), 응답 한글이 깨져 보이는 건 PS 5.1 표시 문제이므로 검증은 curl로. 응답의 clips가 5개 미만이면 원문과 함께 보고.
|
|
||||||
f. **capcut 에이전트 넘기기**: 저장이 DONE이면 아래 "capcut 에이전트 연동" 절차로 영상 생성을 **시작만** 하고, 완료를 기다리지 않고 즉시 다음 작업의 Opal 실행으로 넘어간다.
|
|
||||||
4. **에러 처리**: 특정 Step 노드가 빨간 표시로 실패하면 Preview 리셋 → Start로 1회 재실행. 재실패 시 해당 작업은 건너뛰고 사유를 보고(저장하지 않음 — PENDING 유지). 프롬프트 노드 끝에 중복 YouTube Video 칩이 생겼는지 확인(2026-08-03에 이 원인으로 ID 3이 항상 실패했음).
|
|
||||||
5. **보고**: 처리한 작업 수, 성공/실패 목록, capcut으로 넘긴 작업과 진행 중인 탭 수, h-lab `/shortform` 링크로 요약.
|
|
||||||
|
|
||||||
## 풀버전 모드
|
|
||||||
|
|
||||||
- **완료 폴링**: 40~60초 간격 스크린샷. "Thinking... Step N" 진행 표시가 사라지고 Output(1===== 형식)이 렌더링되면 완료. 전체 4~5분 소요(Step 3 다섯 개가 각 ~90초 병렬).
|
|
||||||
- **Output 추출**: 앱 UI는 접근성 트리에 안 잡힌다. javascript_tool로 `document.getElementById('opal-app').contentDocument`에서 shadow DOM을 재귀 관통해 `"cuts"`를 포함한 **가장 긴** innerText를 찾는다. 텍스트가 크면 window 변수에 저장 후 900자 단위로 나눠 회수한다.
|
|
||||||
- **Step 1 결과 추출**: Console 탭에서 "Step 1: 하이라이트 후보 구간 추출" 항목을 UI 클릭으로 펼친 뒤(lazy 렌더라 JS로 details.open만 하면 내용이 안 붙을 수 있음 — 안 되면 화면 좌표 클릭), shadow DOM 관통으로 `"candidates"`를 포함한 **가장 짧은** innerText(Model Response 부분)를 회수한다.
|
|
||||||
|
|
||||||
## 1단계 모드 (기본)
|
|
||||||
|
|
||||||
- 노드 3개뿐: YouTube Video → Step 1(하이라이트 후보 5개) → Generate HTML. 전체 **~1분**이면 끝난다(10초 간격 폴링, "Thinking... Step 1: N%"가 사라지고 candidates JSON이 렌더링되면 완료).
|
|
||||||
- **추출**: shadow DOM 관통으로 `"candidates"`를 포함한 **가장 긴** innerText 하나만 회수(~2KB, 통짜 회수 가능). Console 추출 불필요 — Output 자체가 Step 1 결과다.
|
|
||||||
- **저장**: rawText와 step1Text에 **같은 텍스트**를 넣는다.
|
|
||||||
|
|
||||||
## capcut 에이전트 연동 (저장 직후 · 시작만)
|
|
||||||
|
|
||||||
작업 저장이 DONE 되면 그 작업의 영상 생성을 캡컷 에이전트에 바로 넘긴다. **완료를 기다리지 않는다** — 시작된 것만 확인하고 다음 작업의 Opal 실행으로 넘어간다. 여러 작업이 동시에 돌아도 서버는 `analysis_id`별로 독립 처리하므로(`server/app.py`의 `ANALYSES` 딕셔너리, 전역 락 없음) 충돌하지 않는다.
|
|
||||||
|
|
||||||
1. **생존 확인**: `GET http://127.0.0.1:8001` — 200이 아니면 이 단계 전체를 건너뛰고 "캡컷 에이전트 꺼져 있어 넘기지 않음"으로 보고한다(작업 상태는 DONE 유지).
|
|
||||||
2. **작업마다 새 탭**을 연다(`http://127.0.0.1:8001`). 한 탭에서 두 번 실행하면 `analyze()`가 이전 실행의 화면 상태(`A`/`P`/`AUTO_AID`)를 초기화해 먼저 돌던 작업의 진행 화면이 깨진다.
|
|
||||||
3. 탭에서 순서대로:
|
|
||||||
- `#tab-auto` 클릭 (🤖 자동 탭)
|
|
||||||
- 방식 선택 — 1단계 모드(기본)는 **📐 구간 JSON** `input[name="amode"][value="wpaste"]`, 풀버전은 **📋 오팔 JSON** `value="paste"`
|
|
||||||
- `#autoUrl` ← 작업의 `youtubeUrl` (구간 JSON에는 URL이 없어 필수. 오팔 JSON 모드에서는 선택)
|
|
||||||
- `#apaste` ← 저장에 쓴 원문 그대로 (1단계=candidates JSON, 풀버전=Output 통짜)
|
|
||||||
- `#autoAutoRun`(끝까지 진행)이 **체크되어 있는지 확인**, 아니면 체크한다. 이게 켜져야 버튼이 "댓글 매칭 시작 → 영상까지 자동"이 되고 검토 화면에서 안 멈춘다.
|
|
||||||
- `#autoRmsilence`(무음 제거)는 **사용자가 둔 상태 그대로 두고 건드리지 않는다**.
|
|
||||||
- `#autoGo` 클릭
|
|
||||||
4. **시작 확인**: 버튼이 "분석 중…"으로 바뀌고 진행 로그가 붙는지 스크린샷으로 확인한다. `alert`가 뜨면(URL 없음·JSON 없음) 입력이 빠진 것이므로 채우고 다시 클릭.
|
|
||||||
5. **실행 중인 탭은 닫지 않는다** — 진행이 SSE(`/auto/stream/{aid}`)로 흐르므로 탭을 닫으면 중단된다. 작업 종료 보고 시 "탭 N개에서 영상 생성 진행 중"이라고 남기고 사용자가 닫도록 둔다.
|
|
||||||
|
|
||||||
## 주의
|
|
||||||
|
|
||||||
- Opal은 무료 실험 서비스 — 일일 사용량 제한이 있으니 큐가 많으면 사용자에게 몇 개까지 돌릴지 확인.
|
|
||||||
- Opal 앱 구조(노드 구성·프롬프트)가 바뀌어 보이면 조작을 멈추고 사용자에게 보고.
|
|
||||||
- 쿠키 배너가 뜨면 "나중에"(거절)를 선택.
|
|
||||||
@ -1,25 +0,0 @@
|
|||||||
# Opal 출력 JSON 보정 — 문자열 값 안의 미이스케이프 따옴표를 \" 로 바꾼다.
|
|
||||||
#
|
|
||||||
# Opal이 title_top 같은 값에 따옴표를 그대로 뱉는 일이 있다(예: "title_top": ""나 진짜 방장하기 싫어...""").
|
|
||||||
# 그대로 저장하면 ShortformOutputParser의 Jackson readTree가 던져서 status=FAILED, clips=0이 되고,
|
|
||||||
# 캡컷 에이전트의 구간 JSON 붙여넣기도 같이 막힌다.
|
|
||||||
#
|
|
||||||
# 한 줄에 필드 하나씩 나오는 Opal 출력 형태를 전제로, `"key": "value"` 꼴 줄의
|
|
||||||
# value 안쪽 따옴표만 이스케이프한다. 이미 정상인 JSON에는 아무 변화가 없다.
|
|
||||||
#
|
|
||||||
# 사용:
|
|
||||||
# $fixed = & .\fix-opal-json.ps1 -Text $raw
|
|
||||||
param([Parameter(Mandatory = $true)][AllowEmptyString()][string]$Text)
|
|
||||||
|
|
||||||
$lines = $Text -split "`r`n|`n|`r"
|
|
||||||
$out = foreach ($ln in $lines) {
|
|
||||||
if ($ln -match '^(\s*"[A-Za-z0-9_]+"\s*:\s*)"(.*)"(\s*,?)\s*$') {
|
|
||||||
$pre = $Matches[1]; $val = $Matches[2]; $tail = $Matches[3]
|
|
||||||
$val = [regex]::Replace($val, '(?<!\\)"', '\"')
|
|
||||||
$pre + '"' + $val + '"' + $tail
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
$ln
|
|
||||||
}
|
|
||||||
}
|
|
||||||
$out -join "`r`n"
|
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
@ -12,7 +12,6 @@ out/
|
|||||||
# Secrets (DO NOT COMMIT)
|
# Secrets (DO NOT COMMIT)
|
||||||
src/main/resources/credentials.json
|
src/main/resources/credentials.json
|
||||||
src/main/resources/application-local.yml
|
src/main/resources/application-local.yml
|
||||||
config/youtube-oauth.properties
|
|
||||||
tokens/
|
tokens/
|
||||||
*.log
|
*.log
|
||||||
|
|
||||||
|
|||||||
33
AGENTS.md
33
AGENTS.md
@ -1,33 +0,0 @@
|
|||||||
# Repository Guidelines
|
|
||||||
|
|
||||||
## Project Structure & Module Organization
|
|
||||||
|
|
||||||
This is a Java 21, Spring Boot 3.4 Gradle application. Production code lives under `src/main/java/com/hlab/yanalyst`. Keep feature entities, repositories, services, controllers, and DTOs in `domain/<feature>/`; shared configuration, scheduling, errors, and response types belong in `global/`. Cross-feature integrations are in `service/`, while page controllers and general APIs are in `web/`.
|
|
||||||
|
|
||||||
Templates are in `src/main/resources/templates`, browser assets in `src/main/resources/static`, and application configuration in `src/main/resources/application*.yml`. Tests mirror production packages under `src/test/java`; fixtures belong in `src/test/resources`. Design notes and implementation plans live in `docs/superpowers/`.
|
|
||||||
|
|
||||||
## Build, Test, and Development Commands
|
|
||||||
|
|
||||||
Use the checked-in Gradle wrapper; on Windows run:
|
|
||||||
|
|
||||||
- `.\gradlew.bat bootRun`: start the UI and API at `http://localhost:8088`.
|
|
||||||
- `.\gradlew.bat test`: run the JUnit 5 test suite.
|
|
||||||
- `.\gradlew.bat test --tests "com.hlab.yanalyst.domain.channel.SrtFormatterTest"`: run one test class.
|
|
||||||
- `.\gradlew.bat build`: compile, test, and create the application artifact.
|
|
||||||
- `.\gradlew.bat clean build`: rebuild from clean output.
|
|
||||||
|
|
||||||
## Coding Style & Naming Conventions
|
|
||||||
|
|
||||||
Use four-space indentation and standard Java conventions: `PascalCase` types, `camelCase` members, and lowercase packages. Name Spring components by responsibility (`ChannelService`, `FeedController`, `ChannelRepository`) and DTOs with a `Dto` suffix. Use existing `ApiResponse<T>` wrappers for JSON endpoints. Keep Thymeleaf pages consistent with `layout/base.html` and reuse `static/css/variables.css`. No formatter is enforced, so match nearby code.
|
|
||||||
|
|
||||||
## Testing Guidelines
|
|
||||||
|
|
||||||
Tests use JUnit 5, Spring Boot Test, and Mockito. Name test classes `<Subject>Test` and test methods for observable behavior, such as `parsesShortLink`. Add unit tests for parsing and domain logic; use Spring context tests only when integration behavior requires them. There is no explicit coverage threshold, but changed logic should include regression coverage.
|
|
||||||
|
|
||||||
## Commit & Pull Request Guidelines
|
|
||||||
|
|
||||||
Recent commits use short prefixes such as `feat:` and `docs:`, followed by a concise Korean description. Keep each commit scoped to one change. Pull requests should explain the purpose, summarize implementation and verification, link relevant issues or design documents, and include screenshots for Thymeleaf/CSS changes. Call out configuration or database-schema effects explicitly.
|
|
||||||
|
|
||||||
## Security & Configuration
|
|
||||||
|
|
||||||
Copy `application-local.yml.example` to the ignored `application-local.yml` for local settings. Never commit database credentials, API keys, Google OAuth files, `tokens/`, logs, downloads, or generated media. Use environment variables such as `DB_URL`, `DB_USERNAME`, `DB_PASSWORD`, and `YOUTUBE_API_KEY`.
|
|
||||||
@ -17,11 +17,6 @@ configurations {
|
|||||||
compileOnly {
|
compileOnly {
|
||||||
extendsFrom annotationProcessor
|
extendsFrom annotationProcessor
|
||||||
}
|
}
|
||||||
// Mockito 를 자바 에이전트로 명시 지정하기 위한 설정.
|
|
||||||
// 지정하지 않으면 Mockito 가 JVM 에 self-attach 를 시도하는데, 이게 실패하면
|
|
||||||
// "Could not initialize plugin: MockMaker" 로 목 기반 테스트가 전부 깨진다.
|
|
||||||
// (JDK 21+ 는 self-attach 를 경고하고 이후 버전에서 막는다)
|
|
||||||
mockitoAgent
|
|
||||||
}
|
}
|
||||||
|
|
||||||
repositories {
|
repositories {
|
||||||
@ -34,7 +29,6 @@ dependencies {
|
|||||||
implementation 'org.springframework.boot:spring-boot-starter-validation'
|
implementation 'org.springframework.boot:spring-boot-starter-validation'
|
||||||
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
|
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
|
||||||
implementation 'nz.net.ultraq.thymeleaf:thymeleaf-layout-dialect'
|
implementation 'nz.net.ultraq.thymeleaf:thymeleaf-layout-dialect'
|
||||||
implementation 'org.jsoup:jsoup:1.23.2'
|
|
||||||
|
|
||||||
// Swagger (SpringDoc) — 2.7.0+ is required for Spring Boot 3.4 (2.3.0 throws NoSuchMethodError on /v3/api-docs)
|
// Swagger (SpringDoc) — 2.7.0+ is required for Spring Boot 3.4 (2.3.0 throws NoSuchMethodError on /v3/api-docs)
|
||||||
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.7.0'
|
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.7.0'
|
||||||
@ -44,7 +38,6 @@ dependencies {
|
|||||||
annotationProcessor 'org.projectlombok:lombok'
|
annotationProcessor 'org.projectlombok:lombok'
|
||||||
testImplementation 'org.springframework.boot:spring-boot-starter-test'
|
testImplementation 'org.springframework.boot:spring-boot-starter-test'
|
||||||
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
|
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
|
||||||
mockitoAgent('org.mockito:mockito-core') { transitive = false }
|
|
||||||
implementation 'io.hypersistence:hypersistence-utils-hibernate-63:3.9.0'
|
implementation 'io.hypersistence:hypersistence-utils-hibernate-63:3.9.0'
|
||||||
implementation 'com.github.gavlyukovskiy:p6spy-spring-boot-starter:1.9.2'
|
implementation 'com.github.gavlyukovskiy:p6spy-spring-boot-starter:1.9.2'
|
||||||
|
|
||||||
@ -56,6 +49,4 @@ dependencies {
|
|||||||
|
|
||||||
tasks.named('test') {
|
tasks.named('test') {
|
||||||
useJUnitPlatform()
|
useJUnitPlatform()
|
||||||
// self-attach 대신 에이전트를 직접 붙인다 (위 mockitoAgent 설정 참고)
|
|
||||||
jvmArgs "-javaagent:${configurations.mockitoAgent.asPath}"
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,153 +0,0 @@
|
|||||||
# 예능 쇼츠 Opal 하이라이트 추출 지침
|
|
||||||
|
|
||||||
아래 내용을 Opal의 예능 영상 분석 지침으로 사용한다. Opal 분석 노드에는 `YouTube Video`와 `Timestamped VTT Transcript`를 함께 연결한다.
|
|
||||||
|
|
||||||
```text
|
|
||||||
당신은 유튜브 영상에서 쇼츠(Shorts)로 제작했을 때 가장 터질 만한 구간을 찾아내는 바이럴 분석가입니다.
|
|
||||||
|
|
||||||
입력으로 다음 두 자료가 함께 제공됩니다.
|
|
||||||
|
|
||||||
- YouTube Video: 화자, 표정, 장면과 전체 맥락 확인용
|
|
||||||
- Timestamped VTT Transcript: 구간 탐색과 모든 시간 결정용
|
|
||||||
|
|
||||||
두 입력을 분석하여 실제로 재미있고 쇼츠 원본 가치가 높은 하이라이트 후보만 선정하세요. 후보 수는 영상 내용에 따라 3~10개로 정하며, 수량을 맞추기 위해 평범한 구간을 넣지 마세요. 실제 자막의 시간과 내용이 일치하는 정확성이 자극적인 주제보다 중요합니다.
|
|
||||||
|
|
||||||
[입력 자료 우선순위 — 최우선 규칙]
|
|
||||||
|
|
||||||
1. start_time, end_time, hook_point는 반드시 Timestamped VTT Transcript에 실제로 적힌 타임스탬프만 사용하세요.
|
|
||||||
2. YouTube Video의 재생 위치, 진행 막대 또는 영상 이해 결과로 시간을 추정하지 마세요.
|
|
||||||
3. VTT에 존재하지 않는 시간을 만들거나 임의로 반올림하지 마세요.
|
|
||||||
4. 영상과 VTT의 시간 정보가 충돌하면 VTT를 우선하세요.
|
|
||||||
5. YouTube Video는 VTT의 화자, 표정, 장면과 전체 맥락을 보완하는 용도로만 사용하세요.
|
|
||||||
6. Timestamped VTT Transcript가 입력되지 않았거나 비어 있거나 읽을 수 없으면 시간을 추정해 후보를 만들지 말고 다음 JSON만 출력하세요.
|
|
||||||
|
|
||||||
{"error":"TIMESTAMPED_VTT_REQUIRED","candidates":[]}
|
|
||||||
|
|
||||||
[제외 대상]
|
|
||||||
|
|
||||||
|
|
||||||
[절대 작업 순서]
|
|
||||||
|
|
||||||
1. 제목이나 주제를 먼저 만들지 말고 VTT의 실제 자막 행과 타임스탬프부터 확인하세요.
|
|
||||||
2. VTT에서 새 문장, 질문, 행동 또는 화제가 시작되는 자막 행을 start_time으로 선택하세요.
|
|
||||||
3. VTT 자막을 시간순으로 따라가 펀치라인, 반전 또는 결론이 완전히 끝나는 마지막 자막 행의 종료 시각을 end_time으로 선택하세요.
|
|
||||||
4. 핵심 후킹 요소가 시작되는 실제 VTT 자막 행의 시작 시각을 hook_point로 선택하세요.
|
|
||||||
5. start_time, end_time, hook_point가 모두 VTT에 실제로 존재하는지 다시 확인하세요.
|
|
||||||
6. 선택 구간 안의 실제 내용만 사용하여 reason, title_top, title_main을 마지막에 작성하세요.
|
|
||||||
|
|
||||||
영상의 다른 부분에서 발견한 인물, 사건, 대사, 장면 또는 자막을 현재 구간의 타임스탬프와 결합하지 마세요.
|
|
||||||
|
|
||||||
[구간 선정 규칙]
|
|
||||||
|
|
||||||
1. 각 구간은 최소 60초 이상이어야 합니다. 2분, 3분 또는 그 이상이어도 재미있는 흐름과 결말이 이어진다면 허용하세요. 정해진 최대 길이에 맞추려고 장면을 억지로 자르지 마세요.
|
|
||||||
2. duration_sec에는 VTT의 end_time에서 start_time을 직접 뺀 초 단위 값을 기입하세요.
|
|
||||||
3. start_time은 첫 자막 행의 시작 시각, end_time은 마지막 자막 행의 종료 시각을 사용하세요.
|
|
||||||
4. 시작은 문장이나 화제의 자연스러운 시작점이어야 합니다. 문장 중간에서 시작하지 마세요.
|
|
||||||
5. 종료는 핵심 발언, 펀치라인, 반전 또는 결론이 완전히 끝난 직후여야 합니다. 발언 중간에서 자르지 마세요.
|
|
||||||
6. 핵심 장면 직전의 질문, 상황 설명 또는 빌드업을 구간 안에 포함하세요.
|
|
||||||
7. 핵심 후킹 요소는 start_time 이후 15초 이내에 등장해야 하며 그 실제 VTT 자막 행의 시작 시각을 hook_point에 기입하세요.
|
|
||||||
8. 시선을 끌 수 있는 갈등, 웃음, 반전 또는 핵심 정보가 포함된 구간을 우선하세요.
|
|
||||||
9. 후보들은 서로 겹치지 않아야 하며 같은 장면이나 논점을 반복하지 마세요.
|
|
||||||
10. 가능하면 영상의 초반, 중반, 후반에 골고루 분산하세요.
|
|
||||||
11. 먼저 영상 전체에서 재미 강도가 높은 구간을 넓게 찾은 뒤 품질순으로 정렬하세요. 강한 구간이 3개뿐이면 3개만, 충분하면 최대 10개까지 출력하세요.
|
|
||||||
12. 단순 정보 전달, 반복 대화, 긴 침묵, 맥락 없이는 이해되지 않는 장면은 후보 수를 채우기 위해 포함하지 마세요.
|
|
||||||
|
|
||||||
[타임스탬프 내부 검증]
|
|
||||||
|
|
||||||
이 검증 결과는 JSON에 출력하지 말고 후보를 확정하기 전에 내부적으로 수행하세요.
|
|
||||||
|
|
||||||
- start_time이 새 문장이나 화제의 첫 자막 행 시작 시각과 일치하는가?
|
|
||||||
- end_time이 핵심 발언 또는 상황이 완결된 마지막 자막 행의 종료 시각인가?
|
|
||||||
- hook_point가 실제 후킹 자막 행의 시작 시각인가?
|
|
||||||
- start_time, end_time, hook_point가 모두 VTT에 실제로 적혀 있는가?
|
|
||||||
- YouTube 영상 재생 위치를 근거로 시간을 추정하지 않았는가?
|
|
||||||
- duration_sec가 end_time - start_time과 일치하며 최소 60초 이상인가?
|
|
||||||
- hook_point가 start_time 이후 15초 이내인가?
|
|
||||||
- 모든 후보가 서로 겹치지 않는가?
|
|
||||||
- reason과 제목에 사용한 인물, 사건, 행동, 대사가 해당 구간 안에 실제로 존재하는가?
|
|
||||||
|
|
||||||
하나라도 확인되지 않으면 해당 후보를 폐기하고 실제 VTT에서 다른 구간을 찾으세요. 후보 수를 맞추려고 다른 구간의 내용이나 자막을 가져오지 마세요.
|
|
||||||
|
|
||||||
[시간 형식]
|
|
||||||
|
|
||||||
- 영상이 60분 미만이면 MM:SS 형식을 사용하세요.
|
|
||||||
- 영상이 60분 이상이면 HH:MM:SS 형식을 사용하세요.
|
|
||||||
- 60분 이상 영상에서 "34:48"은 잘못된 형식이고 "00:34:48"이 올바른 형식입니다.
|
|
||||||
- VTT 시간을 임의로 반올림하거나 추정하지 마세요.
|
|
||||||
|
|
||||||
[제목 규칙]
|
|
||||||
|
|
||||||
- title_top은 공백 포함 최대 12자입니다.
|
|
||||||
- title_main은 공백 포함 최대 14자입니다.
|
|
||||||
- 한글, 숫자, 영문, 기호, 공백은 각각 1자로 계산하세요.
|
|
||||||
- 두 제목은 같은 내용을 반복하지 말고 이어 읽었을 때 의미가 완성되게 하세요.
|
|
||||||
- 개행 문자, 마침표, 불필요한 따옴표를 넣지 마세요.
|
|
||||||
- 제목은 해당 구간에서 직접 확인되는 내용만 사용하세요.
|
|
||||||
- 작성 후 글자 수를 직접 세고 제한을 넘으면 조사와 수식어를 제거해 다시 작성하세요.
|
|
||||||
|
|
||||||
[최종 출력 규칙]
|
|
||||||
|
|
||||||
유효한 JSON 객체 하나만 출력하세요. 설명, 마크다운, 주석, 검증 과정 또는 추가 필드를 출력하지 마세요.
|
|
||||||
|
|
||||||
candidates에는 품질 기준을 통과한 후보를 3~10개 넣으세요. id는 후보 수에 맞춰 반드시 1부터 빠짐없이 순서대로 작성하세요. 후보 수를 맞추기 위해 재미가 약한 구간을 추가하지 마세요.
|
|
||||||
|
|
||||||
각 후보에는 다음 9개 필드만 사용하세요.
|
|
||||||
|
|
||||||
- id
|
|
||||||
- start_time
|
|
||||||
- end_time
|
|
||||||
- duration_sec
|
|
||||||
- hook_point
|
|
||||||
- boundary_check
|
|
||||||
- reason
|
|
||||||
- title_top
|
|
||||||
- title_main
|
|
||||||
|
|
||||||
{
|
|
||||||
"candidates": [
|
|
||||||
{
|
|
||||||
"id": 1,
|
|
||||||
"start_time": "MM:SS",
|
|
||||||
"end_time": "MM:SS",
|
|
||||||
"duration_sec": 185,
|
|
||||||
"hook_point": "MM:SS",
|
|
||||||
"boundary_check": "질문 문장 시작 지점에서 시작, 답변 완결 후 종료",
|
|
||||||
"reason": "구간 선정 이유 요약",
|
|
||||||
"title_top": "짧은 상단 제목",
|
|
||||||
"title_main": "핵심 메인 제목"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 2,
|
|
||||||
"start_time": "MM:SS",
|
|
||||||
"end_time": "MM:SS",
|
|
||||||
"duration_sec": 120,
|
|
||||||
"hook_point": "MM:SS",
|
|
||||||
"boundary_check": "문장 시작과 완결 지점 설명",
|
|
||||||
"reason": "구간 선정 이유 요약",
|
|
||||||
"title_top": "짧은 상단 제목",
|
|
||||||
"title_main": "핵심 메인 제목"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 3,
|
|
||||||
"start_time": "MM:SS",
|
|
||||||
"end_time": "MM:SS",
|
|
||||||
"duration_sec": 120,
|
|
||||||
"hook_point": "MM:SS",
|
|
||||||
"boundary_check": "문장 시작과 완결 지점 설명",
|
|
||||||
"reason": "구간 선정 이유 요약",
|
|
||||||
"title_top": "짧은 상단 제목",
|
|
||||||
"title_main": "핵심 메인 제목"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Opal 연결 구조
|
|
||||||
|
|
||||||
```text
|
|
||||||
YouTube Video ─────────────┐
|
|
||||||
├─ 하이라이트 분석 프롬프트
|
|
||||||
Timestamped VTT Transcript ┘
|
|
||||||
```
|
|
||||||
|
|
||||||
`Timestamped VTT Transcript`에는 숏폼 큐의 `타임스탬프 자막 복사`로 얻은 `WEBVTT` 전체를 입력한다.
|
|
||||||
@ -1,154 +0,0 @@
|
|||||||
# 정치 쇼츠 Opal 하이라이트 추출 지침
|
|
||||||
|
|
||||||
아래 내용을 Opal의 영상 분석 지침으로 사용한다. 결과는 `capcut2`의 **정치 구간 JSON** 입력란에 바로 붙여 넣을 수 있는 최소 형식으로 출력한다.
|
|
||||||
|
|
||||||
```text
|
|
||||||
당신은 정치·시사 유튜브 영상에서 쇼츠 원본으로 사용할 하이라이트 구간을 찾는 분석가입니다.
|
|
||||||
|
|
||||||
입력으로 다음 두 자료가 함께 제공됩니다.
|
|
||||||
|
|
||||||
- YouTube Video: 화자, 표정, 화면 자료와 전체 맥락 확인용
|
|
||||||
- Timestamped VTT Transcript: 구간 탐색과 start_time·end_time 결정용
|
|
||||||
|
|
||||||
두 입력을 분석하여 정확히 5개의 서로 겹치지 않는 후보를 선정하세요. 실제 자막의 시간과 내용이 일치하는 정확성이 자극적인 주제보다 중요합니다.
|
|
||||||
|
|
||||||
[입력 자료 우선순위 — 최우선 규칙]
|
|
||||||
|
|
||||||
1. start_time과 end_time은 반드시 Timestamped VTT Transcript에 적힌 타임스탬프만 사용하세요.
|
|
||||||
2. YouTube Video의 재생 위치, 진행 막대 또는 영상 이해 결과로 시간을 추정하지 마세요.
|
|
||||||
3. VTT에 존재하지 않는 시간을 만들거나 임의로 반올림하지 마세요.
|
|
||||||
4. 영상과 VTT의 시간 정보가 충돌하면 VTT를 우선하세요.
|
|
||||||
5. YouTube Video는 VTT의 화자·표정·화면 맥락을 보완하는 용도로만 사용하세요.
|
|
||||||
6. Timestamped VTT Transcript가 입력되지 않았거나 내용을 읽을 수 없으면 시간을 추정해 JSON을 만들지 말고 다음 JSON만 출력하세요.
|
|
||||||
|
|
||||||
{"error":"TIMESTAMPED_VTT_REQUIRED","candidates":[]}
|
|
||||||
|
|
||||||
[제외 대상]
|
|
||||||
|
|
||||||
|
|
||||||
[절대 작업 순서]
|
|
||||||
|
|
||||||
1. 주제나 제목을 먼저 정하지 말고 VTT의 실제 자막 행과 타임스탬프부터 찾으세요.
|
|
||||||
2. VTT에서 새 질문·주장·화제가 시작되는 자막 행을 시작점으로 선택하세요.
|
|
||||||
3. VTT 자막을 시간순으로 따라가 핵심 결론이 완전히 끝나는 자막 행을 종료점으로 선택하세요.
|
|
||||||
4. 선택 구간 전체를 다시 읽고 speaker와 target이 실제 내용과 맞는지 확인하세요.
|
|
||||||
5. 구간 안의 실제 내용만 사용하여 title_top과 title_main을 마지막에 작성하세요.
|
|
||||||
|
|
||||||
영상의 다른 부분에서 발견한 인물·사건·수치·자막을 현재 구간의 타임스탬프와 결합하지 마세요.
|
|
||||||
|
|
||||||
[구간 선정 규칙]
|
|
||||||
|
|
||||||
1. 각 구간은 90~180초여야 합니다.
|
|
||||||
2. start_time은 VTT 시작 자막 행의 시작 시각, end_time은 마지막 자막 행의 종료 시각을 사용하세요.
|
|
||||||
3. 시작은 문장이나 화제의 자연스러운 시작점이어야 합니다.
|
|
||||||
4. 종료는 핵심 발언과 결론이 완전히 끝난 직후여야 합니다. 발언 중간에서 자르지 마세요.
|
|
||||||
5. 핵심 질문·수치·반박·폭로는 시작 후 15초 이내에 나와야 합니다.
|
|
||||||
6. 질문과 답변을 분리하거나 앞뒤 맥락을 제거해 발언 의미를 바꾸지 마세요.
|
|
||||||
7. 후보 5개는 서로 겹치지 않아야 하며 같은 논점을 반복하지 마세요.
|
|
||||||
8. 가능하면 영상의 초반·중반·후반에 분산하세요.
|
|
||||||
|
|
||||||
[타임스탬프 내부 검증]
|
|
||||||
|
|
||||||
이 검증 결과는 JSON에 출력하지 말고 후보를 확정하기 전에 내부적으로 수행하세요.
|
|
||||||
|
|
||||||
- start_time 자막이 실제로 구간의 첫 문장과 일치하는가?
|
|
||||||
- end_time 직전 자막에서 핵심 문장과 결론이 끝나는가?
|
|
||||||
- start_time과 end_time이 모두 VTT에 실제로 적혀 있는가?
|
|
||||||
- YouTube 영상 재생 위치를 근거로 시간을 추정하지 않았는가?
|
|
||||||
- 해당 구간 안에 speaker와 target의 근거가 실제로 있는가?
|
|
||||||
- 제목에 사용한 인명·기관·사건·행위·수치가 해당 구간 안에 실제로 있는가?
|
|
||||||
- end_time - start_time이 90~180초인가?
|
|
||||||
- 핵심 후킹 발언이 start_time 이후 15초 이내인가?
|
|
||||||
|
|
||||||
하나라도 확인되지 않으면 그 후보를 폐기하고 실제 자막에서 다른 구간을 찾으세요. 후보 수를 맞추려고 다른 구간의 내용이나 자막을 가져오지 마세요.
|
|
||||||
|
|
||||||
[정치 콘텐츠 표현 규칙]
|
|
||||||
|
|
||||||
1. 확정된 사실, 발언자의 주장, 언론 보도, 의혹, 가정과 인용을 구분하세요.
|
|
||||||
2. 범죄·불법·외압·은폐·갑질 등의 의혹을 확인된 사실처럼 제목에 쓰지 마세요.
|
|
||||||
3. 자동 자막 때문에 화자나 고유명사가 불명확하면 화면과 음성을 확인하고, 확인할 수 없으면 해당 구간을 제외하세요.
|
|
||||||
4. 제목에는 "배신", "황당", "꼼수", "끝판왕", "파티", "펑펑", "자백", "초토화"처럼 편집자의 조롱이나 과장 표현을 사용하지 마세요.
|
|
||||||
5. 의혹이나 인용 보도는 필요하면 "의혹", "주장", "보도", "?"를 사용해 사실 상태를 보존하세요.
|
|
||||||
|
|
||||||
[시간 형식]
|
|
||||||
|
|
||||||
- 영상이 60분 미만이면 MM:SS 형식을 사용하세요.
|
|
||||||
- 영상이 60분 이상이면 HH:MM:SS 형식을 사용하세요.
|
|
||||||
- 60분 이상 영상에서 "34:48"은 잘못된 형식이고 "00:34:48"이 올바른 형식입니다.
|
|
||||||
- VTT 시간을 임의로 반올림하거나 추정하지 마세요.
|
|
||||||
|
|
||||||
[제목 규칙]
|
|
||||||
|
|
||||||
- title_top: 공백 포함 최대 9자, 권장 6~9자
|
|
||||||
- title_main: 공백 포함 최대 12자, 권장 9~12자
|
|
||||||
- 한글, 숫자, 영문, 기호, 공백은 각각 1자로 계산하세요.
|
|
||||||
- 두 제목은 같은 내용을 반복하지 말고 이어 읽었을 때 의미가 완성되게 하세요.
|
|
||||||
- 개행, 마침표, 불필요한 따옴표를 넣지 마세요.
|
|
||||||
- 제목은 해당 구간에서 직접 확인되는 내용만 사용하세요.
|
|
||||||
|
|
||||||
[최종 출력 규칙]
|
|
||||||
|
|
||||||
유효한 JSON 객체 하나만 출력하세요. 설명, 마크다운, 주석, 검증 과정 또는 추가 필드를 출력하지 마세요.
|
|
||||||
|
|
||||||
각 후보에는 아래 7개 필드만 사용하세요.
|
|
||||||
|
|
||||||
- id
|
|
||||||
- start_time
|
|
||||||
- end_time
|
|
||||||
- speaker
|
|
||||||
- target
|
|
||||||
- title_top
|
|
||||||
- title_main
|
|
||||||
|
|
||||||
candidates에는 정확히 5개를 넣고 id는 반드시 1, 2, 3, 4, 5 순서로 작성하세요.
|
|
||||||
|
|
||||||
{
|
|
||||||
"candidates": [
|
|
||||||
{
|
|
||||||
"id": 1,
|
|
||||||
"start_time": "HH:MM:SS",
|
|
||||||
"end_time": "HH:MM:SS",
|
|
||||||
"speaker": "발언자 이름",
|
|
||||||
"target": "질의 또는 비판 대상",
|
|
||||||
"title_top": "짧은 보조제목",
|
|
||||||
"title_main": "핵심 메인제목"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 2,
|
|
||||||
"start_time": "HH:MM:SS",
|
|
||||||
"end_time": "HH:MM:SS",
|
|
||||||
"speaker": "발언자 이름",
|
|
||||||
"target": "질의 또는 비판 대상",
|
|
||||||
"title_top": "짧은 보조제목",
|
|
||||||
"title_main": "핵심 메인제목"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 3,
|
|
||||||
"start_time": "HH:MM:SS",
|
|
||||||
"end_time": "HH:MM:SS",
|
|
||||||
"speaker": "발언자 이름",
|
|
||||||
"target": "질의 또는 비판 대상",
|
|
||||||
"title_top": "짧은 보조제목",
|
|
||||||
"title_main": "핵심 메인제목"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 4,
|
|
||||||
"start_time": "HH:MM:SS",
|
|
||||||
"end_time": "HH:MM:SS",
|
|
||||||
"speaker": "발언자 이름",
|
|
||||||
"target": "질의 또는 비판 대상",
|
|
||||||
"title_top": "짧은 보조제목",
|
|
||||||
"title_main": "핵심 메인제목"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 5,
|
|
||||||
"start_time": "HH:MM:SS",
|
|
||||||
"end_time": "HH:MM:SS",
|
|
||||||
"speaker": "발언자 이름",
|
|
||||||
"target": "질의 또는 비판 대상",
|
|
||||||
"title_top": "짧은 보조제목",
|
|
||||||
"title_main": "핵심 메인제목"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
@ -1,77 +0,0 @@
|
|||||||
# shortform-queue 스킬 이식 가이드 (다른 PC용)
|
|
||||||
|
|
||||||
이 파일 하나만 가져가면 된다. 새 PC의 Claude Code에게 이렇게 말하면 끝:
|
|
||||||
|
|
||||||
> 이 md 읽고 shortform-queue 스킬 등록해줘
|
|
||||||
|
|
||||||
## Claude에게: 설치 절차
|
|
||||||
|
|
||||||
1. 아래 "SKILL.md 전문"을 **그대로** 스킬 파일로 저장한다.
|
|
||||||
- h-lab 저장소가 있는 PC면(권장): `<h-lab 루트>/.claude/skills/shortform-queue/SKILL.md` — 사실 repo에 이미 포함되어 있으니 clone/pull만 됐다면 이 단계는 불필요.
|
|
||||||
- repo 없이 전역으로 쓰려면: `~/.claude/skills/shortform-queue/SKILL.md`
|
|
||||||
2. 저장 후 사용자에게 "스킬 등록 완료, '숏폼 큐 돌려줘' 또는 '1단계만 돌려줘'로 실행 가능"이라고 알린다.
|
|
||||||
3. 아래 "PC별 확인사항"을 점검하고, 경로가 다르면 SKILL.md 안의 경로를 그 PC에 맞게 고친다.
|
|
||||||
|
|
||||||
## PC별 확인사항 (스킬이 돌아가기 위한 전제)
|
|
||||||
|
|
||||||
| 항목 | 내용 |
|
|
||||||
|------|------|
|
|
||||||
| h-lab 저장소 | `git clone https://h-git.tolag.shop/hehihoho3/h-lab.git` — 스킬이 localhost:8088 서버를 띄워야 함 |
|
|
||||||
| JDK 21 | SKILL.md의 `JAVA_HOME=D:\Development\app\JDK\jdk-21.0.5`는 원래 PC 경로. 새 PC의 JDK 21 경로로 수정 |
|
|
||||||
| DB 시크릿 | git-ignored `src/main/resources/application-local.yml` 필요 — 원래 PC에서 복사해올 것 (없으면 서버 기동 실패: "Failed to determine suitable jdbc url") |
|
|
||||||
| 크롬 + Claude in Chrome 확장 | 브라우저 자동화(Opal 조작)에 필수. opal.google 사이트 권한 허용 |
|
|
||||||
| 구글 로그인 | Opal은 구글 계정 로그인 필요 — 크롬에 로그인돼 있어야 함 |
|
|
||||||
|
|
||||||
## SKILL.md 전문
|
|
||||||
|
|
||||||
````markdown
|
|
||||||
---
|
|
||||||
name: shortform-queue
|
|
||||||
description: h-lab 숏폼 큐의 대기 작업을 구글 Opal "숏폼 생성기"로 실행하고 결과를 DB에 저장. "숏폼 큐 돌려줘", "/shortform-queue", "숏폼 돌려줘", "1단계만 돌려줘" 요청 시 사용.
|
|
||||||
---
|
|
||||||
|
|
||||||
# 숏폼 큐 실행 (Opal 브라우저 대행)
|
|
||||||
|
|
||||||
h-lab(localhost:8088)의 PENDING 작업을 Opal로 실행해 결과를 저장한다.
|
|
||||||
h-lab 서버가 꺼져 있으면 먼저 `.\gradlew.bat bootRun`(JAVA_HOME=D:\Development\app\JDK\jdk-21.0.5)으로 띄운다.
|
|
||||||
서버 시크릿은 git-ignored `application-local.yml` — 이 워크트리에 없으면 메인 워크트리(`D:\Development\InteliJ_repository\h-lab\src\main\resources\`)에서 복사.
|
|
||||||
|
|
||||||
## 모드 선택
|
|
||||||
|
|
||||||
| 요청 | 앱 | URL | 출력 형식 |
|
|
||||||
|------|-----|-----|----------|
|
|
||||||
| "숏폼 큐 돌려줘" (기본) | 숏폼 생성기 (풀버전) | `https://opal.google/edit/1w3mThvSpI3_skt4p4OAN3-M6-tnqz0HN` | `N=====` 구간 5개 + cuts JSON |
|
|
||||||
| "1단계만 돌려줘", "숏폼 1단계" | 숏폼 생성기 1단계 | `https://opal.google/edit/1Z9zXUiBAjKKOJ08zXxAmE9Neliq86SEw` | `{"candidates":[...]}` JSON 하나 |
|
|
||||||
|
|
||||||
사용자가 Opal URL을 직접 주면 그 URL 우선. 파서(ShortformOutputParser)는 두 형식 모두 인식한다(88179bb) — 어느 모드든 저장하면 DONE + 클립 5개.
|
|
||||||
|
|
||||||
## 절차 (공통)
|
|
||||||
|
|
||||||
1. **큐 조회**: `GET http://localhost:8088/api/shortform/jobs?status=PENDING` → 비어있으면 "대기 작업 없음" 보고 후 종료.
|
|
||||||
2. **Opal 접속**: 크롬 자동화로 모드에 맞는 편집 화면 접속. 로그인 풀려 있으면 사용자에게 로그인 요청.
|
|
||||||
3. 각 PENDING 작업마다:
|
|
||||||
a. 캔버스의 **YouTube Video 노드 클릭** → 우측 패널 URL 입력창을 비우고(전체선택 ctrl+a 후 덮어쓰기) 작업의 youtubeUrl 입력 → **Apply** (썸네일이 해당 영상으로 바뀌는지 확인).
|
|
||||||
b. Preview 패널의 새로고침(리셋) 아이콘 → **Start** 클릭.
|
|
||||||
c. **완료 폴링** 및 **추출**: 모드별 절차(아래) 참조.
|
|
||||||
e. **저장**: 브라우저 fetch는 HTTPS→HTTP라 차단됨 — 추출 텍스트를 파일로 옮겨 셸에서 `POST http://localhost:8088/api/shortform/jobs/{id}/result` body `{"rawText": "<Output 원문>", "step1Text": "<Step 1 원문>"}`. PowerShell은 UTF-8 바이트로 전송(`[Text.Encoding]::UTF8.GetBytes`), 응답 한글이 깨져 보이는 건 PS 5.1 표시 문제이므로 검증은 curl로. 응답의 clips가 5개 미만이면 원문과 함께 보고.
|
|
||||||
4. **에러 처리**: 특정 Step 노드가 빨간 표시로 실패하면 Preview 리셋 → Start로 1회 재실행. 재실패 시 해당 작업은 건너뛰고 사유를 보고(저장하지 않음 — PENDING 유지). 프롬프트 노드 끝에 중복 YouTube Video 칩이 생겼는지 확인(2026-08-03에 이 원인으로 ID 3이 항상 실패했음).
|
|
||||||
5. **보고**: 처리한 작업 수, 성공/실패 목록, h-lab `/shortform` 링크로 요약.
|
|
||||||
|
|
||||||
## 풀버전 모드 (기본)
|
|
||||||
|
|
||||||
- **완료 폴링**: 40~60초 간격 스크린샷. "Thinking... Step N" 진행 표시가 사라지고 Output(1===== 형식)이 렌더링되면 완료. 전체 4~5분 소요(Step 3 다섯 개가 각 ~90초 병렬).
|
|
||||||
- **Output 추출**: 앱 UI는 접근성 트리에 안 잡힌다. javascript_tool로 `document.getElementById('opal-app').contentDocument`에서 shadow DOM을 재귀 관통해 `"cuts"`를 포함한 **가장 긴** innerText를 찾는다. 텍스트가 크면 window 변수에 저장 후 900자 단위로 나눠 회수한다.
|
|
||||||
- **Step 1 결과 추출**: Console 탭에서 "Step 1: 하이라이트 후보 구간 추출" 항목을 UI 클릭으로 펼친 뒤(lazy 렌더라 JS로 details.open만 하면 내용이 안 붙을 수 있음 — 안 되면 화면 좌표 클릭), shadow DOM 관통으로 `"candidates"`를 포함한 **가장 짧은** innerText(Model Response 부분)를 회수한다.
|
|
||||||
|
|
||||||
## 1단계 모드
|
|
||||||
|
|
||||||
- 노드 3개뿐: YouTube Video → Step 1(하이라이트 후보 5개) → Generate HTML. 전체 **~1분**이면 끝난다(10초 간격 폴링, "Thinking... Step 1: N%"가 사라지고 candidates JSON이 렌더링되면 완료).
|
|
||||||
- **추출**: shadow DOM 관통으로 `"candidates"`를 포함한 **가장 긴** innerText 하나만 회수(~2KB, 통짜 회수 가능). Console 추출 불필요 — Output 자체가 Step 1 결과다.
|
|
||||||
- **저장**: rawText와 step1Text에 **같은 텍스트**를 넣는다.
|
|
||||||
|
|
||||||
## 주의
|
|
||||||
|
|
||||||
- Opal은 무료 실험 서비스 — 일일 사용량 제한이 있으니 큐가 많으면 사용자에게 몇 개까지 돌릴지 확인.
|
|
||||||
- Opal 앱 구조(노드 구성·프롬프트)가 바뀌어 보이면 조작을 멈추고 사용자에게 보고.
|
|
||||||
- 쿠키 배너가 뜨면 "나중에"(거절)를 선택.
|
|
||||||
````
|
|
||||||
File diff suppressed because it is too large
Load Diff
@ -1,140 +0,0 @@
|
|||||||
# 소재 발굴 피드 (Source Feed) 설계
|
|
||||||
|
|
||||||
작성일: 2026-07-31
|
|
||||||
|
|
||||||
## 배경
|
|
||||||
|
|
||||||
내 채널 `clipOut-log`(UCVJZ3_z7dqpUvaEgaWRltuA)는 한국 웹예능·토크쇼의 명장면을 잘라 올리는
|
|
||||||
쇼츠 전문 채널이다. 최근 50개 업로드 분석 결과 소재의 단위는 **"웹예능 에피소드 안의 한 순간"**이며,
|
|
||||||
반복 등장하는 소스는 유퀴즈·핑계고·미미미누·아는형님·살롱드립·짠한형 신동엽·요정식탁·어쩌다사장·
|
|
||||||
워크맨·입만열면·차린건쥐뿔도없지만이다.
|
|
||||||
|
|
||||||
기존 발굴(`/discover`, `/recommend`)은 **조회수순 떡상 채널 발굴**이라 목적이 다르다.
|
|
||||||
이 문서는 **"이런 소재를 최신순으로"** 를 위한 별도 피드를 정의한다.
|
|
||||||
|
|
||||||
## 목표
|
|
||||||
|
|
||||||
1. **선점** — 소스(웹예능 공식채널)의 신규 롱폼 업로드를 최신순으로 받아, 아직 아무도 안 자른 구간을 먼저 클립화
|
|
||||||
2. **추격** — 경쟁 예능짤 쇼츠 채널의 신규 쇼츠를 최신순으로 보며 반응 검증된 소재·제목·썸네일 벤치마킹
|
|
||||||
|
|
||||||
범위 밖(후속): 자막 기반 "터지는 구간" 자동 제안(2단계).
|
|
||||||
|
|
||||||
## 결정 사항
|
|
||||||
|
|
||||||
| 항목 | 결정 | 이유 |
|
|
||||||
|---|---|---|
|
|
||||||
| 탭 구성 | 소재 원본 / 경쟁 쇼츠 2탭 | 선점과 추격을 같이 보되 성격이 달라 액션이 다름 |
|
|
||||||
| 소스 탭 대상 | **롱폼만** (`durationSec > 65`) | 공식계정이 올린 쇼츠는 이미 잘린 결과물이라 소재 가치 없음 |
|
|
||||||
| 경쟁 탭 대상 | 쇼츠만 (`isShorts = true`) | 벤치마킹 대상 |
|
|
||||||
| 시드 확보 | 자동 후보 → 수동 승인(하이브리드) | 오탐 방지 + 자동 편재 |
|
|
||||||
| 피드 깊이 | 1단계(영상 목록)까지 | YAGNI. 구간 제안은 별도 과제 |
|
|
||||||
| 구현 방식 | 기존 채널 동기화 파이프라인 재사용 | 쿼터 60배 저렴, 재가공 스튜디오와 즉시 연결 |
|
|
||||||
|
|
||||||
## 아키텍처
|
|
||||||
|
|
||||||
### 데이터 모델
|
|
||||||
|
|
||||||
`Channel`에 역할 컬럼 하나만 추가한다.
|
|
||||||
|
|
||||||
```java
|
|
||||||
@Column(length = 10)
|
|
||||||
private String role = "MY"; // MY | SOURCE | RIVAL
|
|
||||||
```
|
|
||||||
|
|
||||||
`ddl-auto: update`는 기존 행을 NULL로 남기므로, 조회는 전부 `COALESCE(role,'MY')` 의미로 처리하고
|
|
||||||
앱 부팅 시 `role IS NULL → 'MY'` 1회 백필을 수행한다(프로젝트에 마이그레이션 파일이 없는 관례에 맞춤).
|
|
||||||
|
|
||||||
`ChannelVideo`는 컬럼 추가 없음. 기존 컬럼을 그대로 쓴다.
|
|
||||||
|
|
||||||
| 용도 | 기존 컬럼 |
|
|
||||||
|---|---|
|
|
||||||
| 피드 구분 | `source` — 기존 `CHANNEL`/`SEARCH` 에 `SOURCE`/`RIVAL` 값 추가 |
|
|
||||||
| 최신순 정렬 | `publishedAt` |
|
|
||||||
| 롱폼/쇼츠 구분 | `durationSec`, `isShorts` (`VideoMetrics.isShorts` = 65초 이하) |
|
|
||||||
| 경쟁 떡상 배지 | `viewsPerHour` |
|
|
||||||
| 이미 손댄 소재 | `interestStatus`, `bookmarked` |
|
|
||||||
| 시드 역분석 재료 | `hashtags` |
|
|
||||||
|
|
||||||
`RecommendedChannel`에 `roleHint`(SOURCE/RIVAL), `seedKeyword` 추가 — 승인 UI를 재사용하기 위함.
|
|
||||||
|
|
||||||
### 수집
|
|
||||||
|
|
||||||
`FeedCollectionService` 신설. 기존 `ChannelService`의 uploads 플레이리스트 동기화를 재사용한다.
|
|
||||||
|
|
||||||
- 주기 **3시간** (`hlab.feed.cron`). 소재 선점은 업로드 후 몇 시간이 승부라 일 1회로는 늦음
|
|
||||||
- 채널당 uploads **첫 페이지 50개**만 조회, `publishedAt`이 최근 N일(기본 14) 이내인 것만 upsert
|
|
||||||
- 소스 채널 → 롱폼만 저장(`source='SOURCE'`), 경쟁 채널 → 쇼츠만 저장(`source='RIVAL'`)
|
|
||||||
- `YoutubeQuotaGuard.tryConsume` 로 채널 단위 가드. 채널당 추정 2 units(playlistItems 1 + videos 1)
|
|
||||||
- 연속 3회 실패한 채널은 `feedFailCount >= 3` 로 자동 스킵(UI에서 초기화 가능)
|
|
||||||
|
|
||||||
기존 일일 채널 수집(`ScheduledCollectionService.runChannelCollection`)은 **role=MY 만** 대상으로
|
|
||||||
좁힌다. 백필 후 기존 채널은 전부 MY이므로 동작 변화 없음.
|
|
||||||
|
|
||||||
**수집함 격리** — `/collection`, `/discover`, 떡상 후보 쿼리는 `source` 를 명시하지 않은 경우
|
|
||||||
`CHANNEL`/`SEARCH` 만 대상으로 한다. 피드 영상이 기존 화면을 덮지 않는다.
|
|
||||||
|
|
||||||
### 화면 `/feed`
|
|
||||||
|
|
||||||
정렬은 `publishedAt DESC` 고정(정렬 셀렉터 없음).
|
|
||||||
|
|
||||||
**[소재 원본] 탭** — `source='SOURCE'`
|
|
||||||
- 카드: 썸네일 / 프로그램명 / 제목 / 상대시간 / 재생시간 / 조회수
|
|
||||||
- 배지: `골든타임`(24시간 이내), `공식클립`(65초~15분) / `풀에피`(15분+), `작업함`(interestStatus != NEW)
|
|
||||||
- 액션: 재가공(`/rework/{id}`), 숨김(EXCLUDED), 북마크
|
|
||||||
|
|
||||||
**[경쟁 쇼츠] 탭** — `source='RIVAL'`
|
|
||||||
- 배지: `떡상중`(viewsPerHour 상위), `골든타임`
|
|
||||||
- 액션: YouTube 열기, 북마크, 숨김 (남의 쇼츠는 재가공 대상이 아님)
|
|
||||||
|
|
||||||
공통 필터: 프로그램(채널) 선택 / 길이 / 기간(24h·3일·7일·14일) / 작업한 것 숨기기
|
|
||||||
|
|
||||||
### 시드 발굴
|
|
||||||
|
|
||||||
**소스 시드** — `SeedSuggestService`
|
|
||||||
1. `role=MY` 영상의 `hashtags`·제목에서 해시태그 빈도 집계(`HashtagExtractor`, 순수 로직)
|
|
||||||
2. 상위 키워드마다 `search.list type=channel&q=<키워드>` 조회 (100 units/건이라 **수동 실행 버튼**)
|
|
||||||
3. `RecommendedChannel(roleHint=SOURCE, status=NEW)` upsert → `/recommend`에서 승인
|
|
||||||
|
|
||||||
**경쟁 시드** — 기존 `ChannelDiscoveryService` 결과를 `roleHint=RIVAL` 로 승인 등록
|
|
||||||
|
|
||||||
**수동 등록** — `POST /api/feed/seeds {url, role}`
|
|
||||||
|
|
||||||
### API
|
|
||||||
|
|
||||||
| 메서드 | 경로 | 설명 |
|
|
||||||
|---|---|---|
|
|
||||||
| GET | `/api/feed` | `tab=SOURCE\|RIVAL`, `days`, `channelId`, `lengthBucket`, `hideWorked` |
|
|
||||||
| GET | `/api/feed/programs` | 필터용 채널 목록(피드에 영상이 있는 채널) |
|
|
||||||
| POST | `/api/feed/collect` | 수동 수집 1회 |
|
|
||||||
| GET | `/api/feed/seeds` | 등록된 시드 채널 목록 |
|
|
||||||
| POST | `/api/feed/seeds` | 수동 시드 등록 `{url, role}` |
|
|
||||||
| DELETE | `/api/feed/seeds/{id}` | 시드 해제(role → MY 로 되돌리지 않고 채널 삭제) |
|
|
||||||
| POST | `/api/feed/seeds/{id}/reset-failure` | 실패 카운터 초기화 |
|
|
||||||
| POST | `/api/feed/seeds/suggest` | 해시태그 역분석 → 소스 후보 발굴 |
|
|
||||||
|
|
||||||
### UX 원칙 (ui-ux-pro-max 적용)
|
|
||||||
|
|
||||||
기존 Editorial(almanac) 디자인 시스템을 그대로 따른다. 스킬이 제안한 OLED/핑크 팔레트는
|
|
||||||
**일관성 규칙(§4 consistency)에 따라 채택하지 않는다.** 적용하는 것은 규칙 쪽이다.
|
|
||||||
|
|
||||||
- 아이콘은 Lucide SVG만 사용(§4 no-emoji-icons) — 배지에 이모지 금지
|
|
||||||
- 터치 타겟 ≥44px, 카드 액션 간 8px 이상 간격(§2)
|
|
||||||
- 썸네일에 `aspect-ratio` + `width/height` 지정으로 CLS 방지, `loading="lazy"`(§3)
|
|
||||||
- 로딩은 스켈레톤(기존 `.skeleton` 재사용), 빈 상태·에러 상태 각각 안내 문구 + 복구 액션(§8)
|
|
||||||
- 애니메이션 150–300ms, `prefers-reduced-motion` 존중(§7)
|
|
||||||
- 탭은 `role="tablist"` + 키보드 좌우 이동, 현재 탭 `aria-selected`(§1, §9)
|
|
||||||
- 상태를 색으로만 전달하지 않음 — 배지에 텍스트 라벨 병기(§1 color-not-only)
|
|
||||||
- 필터 상태는 URL 쿼리에 반영해 새로고침·뒤로가기에서 보존(§9 state-preservation)
|
|
||||||
|
|
||||||
### 실패 처리
|
|
||||||
|
|
||||||
- 쿼터 소진 → 남은 채널 스킵, 요약에 `skippedByQuota` 보고
|
|
||||||
- 채널 uploads 플레이리스트 없음/삭제 → 로그 후 스킵, `feedFailCount++`
|
|
||||||
- 개별 영상 파싱 실패 → 해당 영상만 건너뜀
|
|
||||||
|
|
||||||
### 테스트
|
|
||||||
|
|
||||||
`src/test` 의 `DiscoveryRankerTest` 선례에 맞춰 **순수 로직만** 단위 테스트한다.
|
|
||||||
|
|
||||||
- `HashtagExtractorTest` — 해시태그 파싱·빈도 집계·불용어 제외
|
|
||||||
- `FeedBadgesTest` — 골든타임/길이버킷/떡상 판정 경계값
|
|
||||||
@ -1,62 +0,0 @@
|
|||||||
# 숏폼 큐 — Opal 연동 작업 큐/결과 저장 설계
|
|
||||||
|
|
||||||
2026-08-03. 유튜브 URL을 h-lab에 등록하면, 구글 Opal "숏폼 생성기" 앱(무료, Gemini Pro 영상분석)을 실행 엔진으로 삼아 결과를 DB에 구조화 저장하고 조회하는 기능.
|
|
||||||
|
|
||||||
## 배경·제약
|
|
||||||
|
|
||||||
- Opal은 외부 API가 없다. 실행은 (A) 클로드가 브라우저 자동화로 대행하거나 (B) 사용자가 수동 실행 후 결과를 붙여넣는다. 두 경로 모두 동일한 저장 API로 수렴한다.
|
|
||||||
- Gemini API 직접 호출은 Pro가 유료(2026-04부터 무료 티어 제외)라 배제. 단, 실행 엔진 교체가 쉽도록 h-lab은 "결과 텍스트를 받아 파싱·저장"만 담당한다.
|
|
||||||
- Opal 출력 형식(프롬프트 v13.x): 구간(ID 1~5)별로 `N=====` 구분자 + ```json 블록(capcut2 붙여넣기용) + 📌 타이틀 후보 5선 + ⏱️ 컷 길이 검산표.
|
|
||||||
|
|
||||||
## 구성요소
|
|
||||||
|
|
||||||
### 1. 백엔드 — `domain/shortform/` (DDD 컨벤션)
|
|
||||||
|
|
||||||
**엔티티**
|
|
||||||
- `ShortformJob` (테이블 `shortform_job`): id, youtubeUrl, videoId(URL에서 추출), title(nullable, 결과 JSON의 title_main 첫 값 또는 null), status(PENDING/DONE/FAILED), rawOutput(TEXT, 파싱 실패 대비 원문 보존), createdAt, completedAt
|
|
||||||
- `ShortformClip` (테이블 `shortform_clip`): id, job FK, clipNo(1~5), capcutJson(TEXT), titleCandidates(TEXT), durationTable(TEXT), titleTop/titleMain(파싱값, nullable)
|
|
||||||
|
|
||||||
**API** (`ApiResponse<T>` 래핑, `/api/shortform/...`, web/ 스타일 경로)
|
|
||||||
- `POST /api/shortform/jobs` {youtubeUrl} → 큐 등록(PENDING). 중복 URL 등록 시 기존 작업 반환
|
|
||||||
- `GET /api/shortform/jobs` → 목록(최신순)
|
|
||||||
- `GET /api/shortform/jobs/{id}` → 상세(클립 포함)
|
|
||||||
- `POST /api/shortform/jobs/{id}/result` {rawText} → 파싱·저장, status 갱신
|
|
||||||
- `POST /api/shortform/import` {youtubeUrl, rawText} → 등록+결과 저장 한 번에 (수동 실행 후 붙여넣기용)
|
|
||||||
- `DELETE /api/shortform/jobs/{id}`
|
|
||||||
|
|
||||||
**파서** (`ShortformOutputParser`)
|
|
||||||
- `N=====` 형태 구분자로 섹션 분리 → 섹션마다 ```json 코드블록, "타이틀 후보" 블록, "검산표" 블록 추출
|
|
||||||
- JSON 파싱 성공 시 titleTop/titleMain 추출. 어떤 단계든 실패하면 해당 클립은 원문 조각만 저장하고 job은 DONE 유지(rawOutput이 항상 원본 보존) — 파싱 실패로 데이터를 잃지 않는다
|
|
||||||
- 클립이 0개 추출되면 FAILED + rawOutput 보존
|
|
||||||
|
|
||||||
### 2. 프론트 — `/shortform` 페이지 (Thymeleaf, layout/base)
|
|
||||||
|
|
||||||
- 사이드바에 "숏폼" 메뉴 추가 (`currentPage=shortform`)
|
|
||||||
- 상단: URL 입력 폼(등록) + "결과 붙여넣기" 토글(URL+원문 textarea → import API)
|
|
||||||
- 목록: 작업 카드(썸네일 `img.youtube.com/vi/{videoId}/mqdefault.jpg`, 상태 뱃지, 등록시각)
|
|
||||||
- 상세(카드 펼침 또는 별도 영역): 클립 1~5 카드 — 타이틀(top/main), 타이틀 후보 5선, 검산표, **capcut2 JSON 복사 버튼**(클립보드 API)
|
|
||||||
|
|
||||||
### 3. 스킬 — `.claude/skills/shortform-queue/SKILL.md`
|
|
||||||
|
|
||||||
트리거: `/shortform-queue`, "숏폼 큐 돌려줘" 류 요청.
|
|
||||||
절차(오늘 세션에서 검증된 노하우를 명문화):
|
|
||||||
1. `GET /api/shortform/jobs`에서 PENDING 목록 조회 (h-lab은 localhost:8088)
|
|
||||||
2. 크롬 자동화로 opal.google → 숏폼 생성기 앱(edit URL 고정) 열기
|
|
||||||
3. YouTube Video 노드 클릭 → URL 입력 → Apply → Preview의 Start
|
|
||||||
4. 완료 폴링(전체 4~5분, Step 3 각 ~90초). 앱 UI는 접근성 트리에 안 잡히므로 스크린샷 + `iframe#opal-app` contentDocument에 JS 주입(shadow DOM 관통)으로 상태 확인
|
|
||||||
5. Output 전문 추출(JS, `"cuts"` 포함 최대 텍스트) → `POST /api/shortform/jobs/{id}/result`
|
|
||||||
6. 노드 에러 시 1회 재실행, 재실패면 FAILED 보고 후 다음 작업 진행. 큐 소진까지 반복, 요약 보고
|
|
||||||
주의사항: Opal 프롬프트 끝 중복 칩 이슈(2026-08-03 해결) 같은 앱 구조 변경 감지 시 사용자에게 보고.
|
|
||||||
|
|
||||||
## 순서·범위
|
|
||||||
|
|
||||||
1. 백엔드(엔티티→파서→API) — 파서는 오늘 실행에서 얻은 실제 Output 샘플로 단위 테스트
|
|
||||||
2. 프론트 페이지
|
|
||||||
3. 스킬 파일
|
|
||||||
빌드·커밋은 변경 단위별. 실제 Opal 실행 검증은 스킬 완성 후 사용자 요청 시.
|
|
||||||
|
|
||||||
## 하지 않는 것 (YAGNI)
|
|
||||||
|
|
||||||
- Gemini API 직접 호출(비용) — 파서/저장 구조만 호환되게 유지
|
|
||||||
- n8n 연동, 스케줄 자동 실행(Opal이 로그인 세션 필요, 무인 실행 불가)
|
|
||||||
- capcut2 앱으로의 직접 전송(현행 수동 붙여넣기 유지)
|
|
||||||
@ -1,135 +0,0 @@
|
|||||||
# 시드 채널 제목 키워드 필터 설계
|
|
||||||
|
|
||||||
작성일: 2026-08-11
|
|
||||||
|
|
||||||
## 배경
|
|
||||||
|
|
||||||
소재 피드(`/feed`)의 시드는 **채널 단위**다(`FeedService.addSeed` → `Channel.role = SOURCE|RIVAL`).
|
|
||||||
디글 클래식(`@DiggleClassic`)처럼 한 채널이 여러 프로그램을 올리는 경우, 시드로 등록하면
|
|
||||||
유 퀴즈 온 더 블럭뿐 아니라 놀면 뭐하니·코미디빅리그까지 전부 피드에 들어온다.
|
|
||||||
|
|
||||||
피드 화면의 "프로그램" 드롭다운도 도움이 안 된다. `ChannelVideoRepository.feedPrograms`가
|
|
||||||
`ytChannelId`로 그룹핑하기 때문에 드롭다운에는 "디글 클래식" 한 줄만 뜨고,
|
|
||||||
채널 **안에서** 특정 프로그램만 골라내는 수단이 없다.
|
|
||||||
|
|
||||||
## 목표
|
|
||||||
|
|
||||||
시드 채널에 제목 키워드를 걸어, 그 키워드에 걸리는 영상만 피드에 수집한다.
|
|
||||||
|
|
||||||
## 비목표
|
|
||||||
|
|
||||||
- 이미 수집된 영상의 소급 정리 (자동 삭제하지 않는다 — 아래 "알려진 동작" 참고)
|
|
||||||
- 프로그램 단위 그룹핑/드롭다운 개편 (채널 단위 그룹핑을 유지한다)
|
|
||||||
- 인물 추적(`PersonCollectionService`) 경로 변경
|
|
||||||
|
|
||||||
## 설계
|
|
||||||
|
|
||||||
### 1. 데이터
|
|
||||||
|
|
||||||
`Channel` 엔티티에 컬럼 하나를 추가한다.
|
|
||||||
|
|
||||||
```java
|
|
||||||
/** 피드 수집 시 제목에 걸어둘 키워드(콤마 구분). 비어있으면 필터 없음 = 전부 수집. */
|
|
||||||
@Column(name = "feed_title_filter", length = 255)
|
|
||||||
private String feedTitleFilter;
|
|
||||||
```
|
|
||||||
|
|
||||||
- `ddl-auto: update`라 기존 행은 `null`이 되고, `null`/공백은 "필터 없음"으로 해석된다.
|
|
||||||
따라서 **기존 시드의 동작은 그대로**다.
|
|
||||||
- 변경 메서드: `Channel.changeFeedTitleFilter(String)` — 공백 문자열은 `null`로 정규화한다.
|
|
||||||
|
|
||||||
### 2. 매칭 로직 — `FeedTitleFilter`
|
|
||||||
|
|
||||||
외부 의존이 없는 순수 클래스로 분리한다(`domain/channel/FeedTitleFilter.java`).
|
|
||||||
`PersonPicks`, `VideoMetrics`와 같은 계열의 정적 유틸이다.
|
|
||||||
|
|
||||||
```java
|
|
||||||
public static boolean matches(String filter, String title)
|
|
||||||
```
|
|
||||||
|
|
||||||
규칙:
|
|
||||||
|
|
||||||
1. `filter`가 `null`/공백이면 **항상 `true`** (필터 없음).
|
|
||||||
2. `filter`를 콤마로 나눈다. 빈 조각은 버린다. 유효 키워드가 하나도 없으면 `true`.
|
|
||||||
3. 키워드와 제목 양쪽을 정규화한다: **소문자화 + 모든 공백류 제거**.
|
|
||||||
4. 정규화된 제목이 정규화된 키워드 중 **하나라도** 포함하면 `true` (OR).
|
|
||||||
5. `title`이 `null`/공백이면 `false` (필터가 있는 상태에서 제목을 못 읽으면 통과시키지 않는다).
|
|
||||||
|
|
||||||
공백 제거가 핵심이다. 실제 유튜브 제목 표기가 `[유퀴즈 온 더 블럭]`, `유 퀴즈 온 더 블럭`,
|
|
||||||
`유퀴즈`로 흔들리는데, 공백을 지우면 키워드 `유퀴즈` 하나로 세 표기를 모두 잡는다.
|
|
||||||
|
|
||||||
### 3. 수집 적용점
|
|
||||||
|
|
||||||
`ChannelService.upsertVideos`에는 이미 길이·기간 필터가 `continue`로 붙어 있다
|
|
||||||
(피드 경로에서만 값이 들어오고 일반 채널 동기화는 `null`을 넘긴다). 같은 자리에 한 줄을 더한다.
|
|
||||||
|
|
||||||
```java
|
|
||||||
if (minDurationSec != null && (durationSec == null || durationSec < minDurationSec)) continue;
|
|
||||||
if (publishedAfter != null && publishedAt.isBefore(publishedAfter)) continue;
|
|
||||||
if (!FeedTitleFilter.matches(titleFilter, title)) continue; // 추가
|
|
||||||
```
|
|
||||||
|
|
||||||
- `upsertVideos` 시그니처에 `String titleFilter` 파라미터를 추가한다.
|
|
||||||
- `collectFeedVideos`가 `channel.getFeedTitleFilter()`를 꺼내 넘긴다.
|
|
||||||
- 일반 채널 동기화 호출부는 `null`을 넘겨 **영향받지 않는다**.
|
|
||||||
|
|
||||||
**쿼터 영향 없음.** playlistItems 1페이지와 videos.list는 어차피 통째로 받고
|
|
||||||
저장 단계에서만 거르는 구조라 채널당 2 units 그대로다.
|
|
||||||
|
|
||||||
### 4. API
|
|
||||||
|
|
||||||
| 메서드 | 경로 | 설명 |
|
|
||||||
|---|---|---|
|
|
||||||
| `POST` | `/api/feed/seeds` | 기존. body에 `titleFilter`(선택) 추가 |
|
|
||||||
| `PATCH` | `/api/feed/seeds/{id}/filter` | 신설. body `{"titleFilter": "유퀴즈"}` — 빈 값이면 필터 해제 |
|
|
||||||
|
|
||||||
- `FeedService.addSeed(url, role, titleFilter)` — 기존 2-인자 오버로드는 `titleFilter=null`로 위임한다.
|
|
||||||
- `FeedService.updateSeedFilter(id, titleFilter)` — 피드 시드가 아니면 `IllegalArgumentException`
|
|
||||||
(`removeSeed`/`resetSeedFailure`와 동일한 방어).
|
|
||||||
- `FeedSeedDto`에 `titleFilter` 필드를 추가해 목록 조회에서 현재 값이 보이게 한다.
|
|
||||||
|
|
||||||
### 5. UI (`templates/feed.html` 시드 관리 모달)
|
|
||||||
|
|
||||||
- 시드 추가 영역: URL·역할 아래에 `제목 키워드 (선택, 콤마 구분)` 입력칸 추가.
|
|
||||||
도움말: "비워두면 채널의 롱폼을 전부 수집합니다. 예: `유퀴즈` — 한 채널에 여러 프로그램이 섞여 있을 때 사용하세요."
|
|
||||||
- 시드 목록의 각 행: 필터가 있으면 채널명 아래 보조 줄에 `필터: 유퀴즈`를 표시한다.
|
|
||||||
- 각 행에 필터 수정 버튼(연필 아이콘)을 추가한다. 클릭 시 `prompt()`로 현재 값을 채워 띄우고,
|
|
||||||
확인하면 `PATCH`를 호출한다. 기존 코드가 `confirm()`을 쓰고 있어 일관된다.
|
|
||||||
|
|
||||||
### 6. 알려진 동작
|
|
||||||
|
|
||||||
필터를 **나중에** 건 경우, 이미 저장된 비매칭 영상은 삭제되지 않는다. 갱신 대상에서만 빠지고
|
|
||||||
피드에는 계속 보인다. 재가공 텍스트·스크립트가 딸려 있을 수 있어 자동 삭제는 하지 않는다.
|
|
||||||
완전히 밀려면 시드를 해제(연결된 영상 함께 삭제)한 뒤 필터를 지정해 재등록한다.
|
|
||||||
|
|
||||||
이 동작은 UI 도움말에 한 줄로 명시한다.
|
|
||||||
|
|
||||||
## 테스트
|
|
||||||
|
|
||||||
현재 `src/test` 디렉터리가 없다. 이번에 만든다.
|
|
||||||
|
|
||||||
`src/test/java/com/hlab/yanalyst/domain/channel/FeedTitleFilterTest.java` — 외부 API를 타지 않는
|
|
||||||
순수 로직이라 테스트 가치가 가장 높은 지점이다. TDD로 이 클래스부터 작성한다.
|
|
||||||
|
|
||||||
케이스:
|
|
||||||
|
|
||||||
- 필터가 `null`/빈 문자열/콤마뿐이면 어떤 제목이든 통과
|
|
||||||
- 공백 흔들림: 키워드 `유퀴즈` ↔ 제목 `[유 퀴즈 온 더 블럭] 123화`
|
|
||||||
- 대소문자 무시: 키워드 `showcase` ↔ 제목 `SHOWCASE`
|
|
||||||
- 콤마 OR: 키워드 `유퀴즈,핑계고` — 둘 중 하나만 걸려도 통과
|
|
||||||
- 비매칭: 키워드 `유퀴즈` ↔ 제목 `놀면 뭐하니 190화` → `false`
|
|
||||||
- 제목이 `null`이고 필터가 있으면 `false`
|
|
||||||
- 키워드 조각의 앞뒤 공백은 무시 (`유퀴즈, 핑계고`)
|
|
||||||
|
|
||||||
`build.gradle`에 테스트 의존성(`spring-boot-starter-test`)이 이미 있는지 확인하고, 없으면 추가한다.
|
|
||||||
|
|
||||||
## 검증
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
$env:JAVA_HOME = "D:\Development\app\JDK\jdk-21.0.5"
|
|
||||||
.\gradlew.bat test --tests "com.hlab.yanalyst.domain.channel.FeedTitleFilterTest"
|
|
||||||
.\gradlew.bat build
|
|
||||||
```
|
|
||||||
|
|
||||||
빌드 통과 후 앱을 띄워 `/feed` 시드 관리 모달에서 디글 클래식을 `유퀴즈` 필터로 등록하고
|
|
||||||
수동 수집을 돌려 유퀴즈 영상만 들어오는지 확인한다.
|
|
||||||
@ -3,12 +3,10 @@ package com.hlab.yanalyst;
|
|||||||
import org.springframework.boot.SpringApplication;
|
import org.springframework.boot.SpringApplication;
|
||||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
|
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
|
||||||
import org.springframework.scheduling.annotation.EnableAsync;
|
|
||||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||||
|
|
||||||
@SpringBootApplication
|
@SpringBootApplication
|
||||||
@EnableJpaAuditing
|
@EnableJpaAuditing
|
||||||
@EnableAsync
|
|
||||||
@EnableScheduling
|
@EnableScheduling
|
||||||
public class HLabApplication {
|
public class HLabApplication {
|
||||||
|
|
||||||
|
|||||||
@ -46,28 +46,6 @@ public class Channel {
|
|||||||
@Column(length = 8)
|
@Column(length = 8)
|
||||||
private String country; // YouTube snippet.country (ISO-3166 alpha-2, 예: KR/JP/US). 없으면 null
|
private String country; // YouTube snippet.country (ISO-3166 alpha-2, 예: KR/JP/US). 없으면 null
|
||||||
|
|
||||||
/**
|
|
||||||
* 채널 역할: MY(내 채널) / SOURCE(소재 원본 웹예능) / RIVAL(경쟁 쇼츠).
|
|
||||||
* ddl-auto:update 특성상 기존 행은 null 이므로 조회 시 null 은 MY 로 간주한다({@link ChannelRole#normalize}).
|
|
||||||
*/
|
|
||||||
@Column(length = 10)
|
|
||||||
private String role = ChannelRole.MY;
|
|
||||||
|
|
||||||
/** 피드 분류. 기존 null 행은 예능으로 간주한다. */
|
|
||||||
@Column(name = "feed_topic", length = 20)
|
|
||||||
private String feedTopic = FeedTopic.ENTERTAINMENT.name();
|
|
||||||
|
|
||||||
/** 시드별 수집 길이 규칙. 기존 null 행은 10분 이상 롱폼으로 간주한다. */
|
|
||||||
@Column(name = "feed_format", length = 20)
|
|
||||||
private String feedFormat = FeedFormat.LONG_FORM.name();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 피드 수집 연속 실패 횟수. 3회 이상이면 자동 스킵해 쿼터 낭비를 막는다.
|
|
||||||
* 성공하면 0으로 리셋된다.
|
|
||||||
*/
|
|
||||||
@Column(name = "feed_fail_count")
|
|
||||||
private Integer feedFailCount = 0;
|
|
||||||
|
|
||||||
private LocalDateTime publishedAt;
|
private LocalDateTime publishedAt;
|
||||||
|
|
||||||
@CreatedDate
|
@CreatedDate
|
||||||
@ -107,45 +85,5 @@ public class Channel {
|
|||||||
this.country = country;
|
this.country = country;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 역할 변경. null/미인식 값은 MY 로 보정된다. */
|
// Domain Logic methods here if needed
|
||||||
public void changeRole(String role) {
|
|
||||||
this.role = ChannelRole.normalize(role);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** null 을 MY 로 보정한 실제 역할. */
|
|
||||||
public String roleOrDefault() {
|
|
||||||
return ChannelRole.normalize(this.role);
|
|
||||||
}
|
|
||||||
|
|
||||||
public void configureFeed(String topic, String format) {
|
|
||||||
this.feedTopic = FeedTopic.normalize(topic).name();
|
|
||||||
this.feedFormat = FeedFormat.normalize(format).name();
|
|
||||||
}
|
|
||||||
|
|
||||||
public FeedTopic feedTopicOrDefault() {
|
|
||||||
return FeedTopic.normalize(this.feedTopic);
|
|
||||||
}
|
|
||||||
|
|
||||||
public FeedFormat feedFormatOrDefault() {
|
|
||||||
return FeedFormat.normalize(this.feedFormat);
|
|
||||||
}
|
|
||||||
|
|
||||||
public int feedFailCountOrZero() {
|
|
||||||
return this.feedFailCount == null ? 0 : this.feedFailCount;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 피드 수집 실패 누적. */
|
|
||||||
public void recordFeedFailure() {
|
|
||||||
this.feedFailCount = feedFailCountOrZero() + 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 피드 수집 성공 — 실패 카운터 리셋. */
|
|
||||||
public void resetFeedFailure() {
|
|
||||||
this.feedFailCount = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 연속 실패로 자동 비활성화된 상태인지. */
|
|
||||||
public boolean isFeedDisabled() {
|
|
||||||
return feedFailCountOrZero() >= 3;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -19,7 +19,7 @@ import java.util.List;
|
|||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
|
||||||
/** 지역 인기 영상(롱폼/쇼츠) 검색 → 떡상 채널 발굴 → RecommendedChannel upsert. */
|
/** 지역 인기 Shorts 검색 → 떡상 채널 발굴 → RecommendedChannel upsert. */
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@Service
|
@Service
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@ -33,47 +33,23 @@ public class ChannelDiscoveryService {
|
|||||||
private final ChannelRepository channelRepository;
|
private final ChannelRepository channelRepository;
|
||||||
private final RecommendedChannelRepository recommendedChannelRepository;
|
private final RecommendedChannelRepository recommendedChannelRepository;
|
||||||
|
|
||||||
@Value("${hlab.discovery.regions:KR}")
|
@Value("${hlab.discovery.regions:KR,JP,US}")
|
||||||
private String regionsCsv;
|
private String regionsCsv;
|
||||||
@Value("${hlab.discovery.keywords:}")
|
|
||||||
private String keywordsCsv;
|
|
||||||
@Value("${hlab.discovery.max-subscribers:100000}")
|
@Value("${hlab.discovery.max-subscribers:100000}")
|
||||||
private long maxSubscribers;
|
private long maxSubscribers;
|
||||||
@Value("${hlab.discovery.min-ratio:5.0}")
|
@Value("${hlab.discovery.min-ratio:5.0}")
|
||||||
private double minRatio;
|
private double minRatio;
|
||||||
@Value("${hlab.discovery.period-days:14}")
|
@Value("${hlab.discovery.period-days:14}")
|
||||||
private int periodDays;
|
private int periodDays;
|
||||||
/** 발굴 대상 포맷 기본값: LONG_FORM(롱폼) | SHORTS. */
|
|
||||||
@Value("${hlab.discovery.format:LONG_FORM}")
|
|
||||||
private String defaultFormat;
|
|
||||||
|
|
||||||
/** 스케줄러 등 기본 호출 — 설정된 기본 포맷(hlab.discovery.format)으로 발굴. */
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public Map<String, Object> runDiscovery() {
|
public Map<String, Object> runDiscovery() {
|
||||||
return runDiscovery(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 발굴 실행.
|
|
||||||
* @param format "SHORTS" 또는 "LONG_FORM". null/blank/미인식이면 설정 기본값(defaultFormat)을 사용.
|
|
||||||
*/
|
|
||||||
@Transactional
|
|
||||||
public Map<String, Object> runDiscovery(String format) {
|
|
||||||
String fmt = (format == null || format.isBlank()) ? defaultFormat : format.trim().toUpperCase();
|
|
||||||
if (!"SHORTS".equals(fmt) && !"LONG_FORM".equals(fmt)) fmt = defaultFormat;
|
|
||||||
List<String> regions = Arrays.stream(regionsCsv.split(","))
|
List<String> regions = Arrays.stream(regionsCsv.split(","))
|
||||||
.map(String::trim).filter(s -> !s.isBlank()).toList();
|
.map(String::trim).filter(s -> !s.isBlank()).toList();
|
||||||
// 예능 특화 발굴용 키워드 시드. 비어있으면(설정 미지정) 추가 검색 없이 기존 동작과 동일.
|
|
||||||
List<String> keywords = Arrays.stream(keywordsCsv.split(","))
|
|
||||||
.map(String::trim).filter(s -> !s.isBlank()).toList();
|
|
||||||
|
|
||||||
List<YoutubeSearchResultDto> all = new ArrayList<>();
|
List<YoutubeSearchResultDto> all = new ArrayList<>();
|
||||||
List<String> searchedRegions = new ArrayList<>();
|
List<String> searchedRegions = new ArrayList<>();
|
||||||
int keywordSearches = 0; // 참고용: 실제 수행한 키워드 검색 횟수
|
|
||||||
// 쿼터 가드: 지역×(1+키워드수) 만큼 검색이 일어나므로, 하나라도 소진되면 전체 발굴 루프를 중단.
|
|
||||||
regionLoop:
|
|
||||||
for (String region : regions) {
|
for (String region : regions) {
|
||||||
// 1) 키워드 없는 인기 Shorts 검색 (기존 동작)
|
|
||||||
if (!quotaGuard.tryConsume(SEARCH_QUOTA)) {
|
if (!quotaGuard.tryConsume(SEARCH_QUOTA)) {
|
||||||
log.warn("[Discovery] 쿼터 예산 소진 — 지역 {} 이후 건너뜀 (잔여 {})", region, quotaGuard.remaining());
|
log.warn("[Discovery] 쿼터 예산 소진 — 지역 {} 이후 건너뜀 (잔여 {})", region, quotaGuard.remaining());
|
||||||
break;
|
break;
|
||||||
@ -81,7 +57,7 @@ public class ChannelDiscoveryService {
|
|||||||
try {
|
try {
|
||||||
YoutubeSearchCondition cond = new YoutubeSearchCondition();
|
YoutubeSearchCondition cond = new YoutubeSearchCondition();
|
||||||
cond.setRegions(List.of(region));
|
cond.setRegions(List.of(region));
|
||||||
cond.setFormat(fmt);
|
cond.setFormat("SHORTS");
|
||||||
cond.setPeriodDays(periodDays);
|
cond.setPeriodDays(periodDays);
|
||||||
cond.setOrder("viewCount"); // 떡상 발굴: 최신순이 아니라 조회수순
|
cond.setOrder("viewCount"); // 떡상 발굴: 최신순이 아니라 조회수순
|
||||||
// 광범위 검색: 키워드 없이 인기 Shorts. 결과 빈약 시 검색 API 보강 필요(스펙 §4.2).
|
// 광범위 검색: 키워드 없이 인기 Shorts. 결과 빈약 시 검색 API 보강 필요(스펙 §4.2).
|
||||||
@ -95,31 +71,6 @@ public class ChannelDiscoveryService {
|
|||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("[Discovery] 지역 {} 검색 실패 — 건너뜀", region, e);
|
log.error("[Discovery] 지역 {} 검색 실패 — 건너뜀", region, e);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2) 예능 키워드 시드 검색 (추가) — 예능 클립 소재 채널 우선 발굴
|
|
||||||
for (String kw : keywords) {
|
|
||||||
if (!quotaGuard.tryConsume(SEARCH_QUOTA)) {
|
|
||||||
log.warn("[Discovery] 쿼터 예산 소진 — 지역 {} 키워드 '{}' 이후 발굴 중단 (잔여 {})",
|
|
||||||
region, kw, quotaGuard.remaining());
|
|
||||||
break regionLoop;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
YoutubeSearchCondition cond = new YoutubeSearchCondition();
|
|
||||||
cond.setKeyword(kw);
|
|
||||||
cond.setRegions(List.of(region));
|
|
||||||
cond.setFormat(fmt);
|
|
||||||
cond.setPeriodDays(periodDays);
|
|
||||||
cond.setOrder("viewCount"); // 떡상 발굴: 조회수순
|
|
||||||
YoutubeSearchPageDto page = youtubeSearchService.searchYoutubeVideos(cond);
|
|
||||||
if (page != null && page.getItems() != null) {
|
|
||||||
for (YoutubeSearchResultDto it : page.getItems()) it.setChannelCountry(region);
|
|
||||||
all.addAll(page.getItems());
|
|
||||||
}
|
|
||||||
keywordSearches++;
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.error("[Discovery] 지역 {} 키워드 '{}' 검색 실패 — 건너뜀", region, kw, e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 이미 제외(EXCLUDED)한 채널 제외
|
// 이미 제외(EXCLUDED)한 채널 제외
|
||||||
@ -153,10 +104,7 @@ public class ChannelDiscoveryService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Map<String, Object> summary = new LinkedHashMap<>();
|
Map<String, Object> summary = new LinkedHashMap<>();
|
||||||
summary.put("format", fmt);
|
|
||||||
summary.put("regions", searchedRegions);
|
summary.put("regions", searchedRegions);
|
||||||
summary.put("keywords", keywords);
|
|
||||||
summary.put("keywordSearches", keywordSearches);
|
|
||||||
summary.put("candidates", ranked.size());
|
summary.put("candidates", ranked.size());
|
||||||
summary.put("saved", saved);
|
summary.put("saved", saved);
|
||||||
summary.put("quotaRemaining", quotaGuard.remaining());
|
summary.put("quotaRemaining", quotaGuard.remaining());
|
||||||
|
|||||||
@ -1,41 +1,9 @@
|
|||||||
package com.hlab.yanalyst.domain.channel;
|
package com.hlab.yanalyst.domain.channel;
|
||||||
|
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
import org.springframework.data.jpa.repository.Modifying;
|
|
||||||
import org.springframework.data.jpa.repository.Query;
|
|
||||||
import org.springframework.data.repository.query.Param;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
public interface ChannelRepository extends JpaRepository<Channel, Long> {
|
public interface ChannelRepository extends JpaRepository<Channel, Long> {
|
||||||
Optional<Channel> findByChannelId(String channelId);
|
Optional<Channel> findByChannelId(String channelId);
|
||||||
boolean existsByChannelId(String channelId);
|
boolean existsByChannelId(String channelId);
|
||||||
|
|
||||||
/** 벤치마킹 등록 채널(MY). role 이 null 인 레거시 행도 MY 로 취급한다. */
|
|
||||||
@Query("select c from Channel c where c.role is null or c.role = 'MY'")
|
|
||||||
List<Channel> findMyChannels();
|
|
||||||
|
|
||||||
/** 내가 운영하는 채널(OWN). 한 개만 두는 것을 전제로 하지만 목록으로 반환한다. */
|
|
||||||
@Query("select c from Channel c where c.role = 'OWN' order by c.id asc")
|
|
||||||
List<Channel> findOwnChannels();
|
|
||||||
|
|
||||||
/** 자동 수집·스냅샷 대상: 벤치마킹 채널 + 내 채널 (피드 시드는 FeedCollectionService 담당). */
|
|
||||||
@Query("select c from Channel c where c.role is null or c.role in ('MY','OWN')")
|
|
||||||
List<Channel> findTrackedChannels();
|
|
||||||
|
|
||||||
/** 특정 역할의 채널. SOURCE/RIVAL 조회용. */
|
|
||||||
List<Channel> findByRole(String role);
|
|
||||||
|
|
||||||
/** 피드 시드(SOURCE + RIVAL) 전체. */
|
|
||||||
@Query("select c from Channel c where c.role in ('SOURCE','RIVAL') order by c.role asc, c.title asc")
|
|
||||||
List<Channel> findFeedSeeds();
|
|
||||||
|
|
||||||
/** role 이 null 인 레거시 행을 MY 로 백필한다. @return 갱신된 행 수 */
|
|
||||||
@Modifying
|
|
||||||
@Query("update Channel c set c.role = 'MY' where c.role is null")
|
|
||||||
int backfillNullRoles();
|
|
||||||
|
|
||||||
@Query("select c from Channel c where c.channelId in :channelIds")
|
|
||||||
List<Channel> findByChannelIdIn(@Param("channelIds") List<String> channelIds);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,48 +0,0 @@
|
|||||||
package com.hlab.yanalyst.domain.channel;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 등록 채널의 역할.
|
|
||||||
*
|
|
||||||
* <ul>
|
|
||||||
* <li>{@link #MY} — 벤치마킹용 등록 채널. 영상이 수집함으로 들어가 소재 후보가 된다.</li>
|
|
||||||
* <li>{@link #OWN} — 내가 운영하는 채널. 이미 발행한 결과물이라 소재가 아니므로
|
|
||||||
* 수집함·칸반·발굴·피드 어디에도 섞이지 않고 내 채널 분석 화면에서만 쓰인다.</li>
|
|
||||||
* <li>{@link #SOURCE} — 소재 원본 채널(웹예능 공식채널). 롱폼만 피드에 수집한다.</li>
|
|
||||||
* <li>{@link #RIVAL} — 경쟁 채널(같은 소재를 다루는 클립 채널). 역시 롱폼만 피드에 수집한다.</li>
|
|
||||||
* </ul>
|
|
||||||
*
|
|
||||||
* ddl-auto:update 로 컬럼이 추가되므로 기존 행은 role 이 null 이다. null 은 항상 {@link #MY} 로 취급한다.
|
|
||||||
*/
|
|
||||||
public final class ChannelRole {
|
|
||||||
|
|
||||||
public static final String MY = "MY";
|
|
||||||
public static final String OWN = "OWN";
|
|
||||||
public static final String SOURCE = "SOURCE";
|
|
||||||
public static final String RIVAL = "RIVAL";
|
|
||||||
|
|
||||||
private ChannelRole() {}
|
|
||||||
|
|
||||||
/** null/공백/미인식 값을 MY 로 보정하고 대문자로 정규화한다. */
|
|
||||||
public static String normalize(String role) {
|
|
||||||
if (role == null || role.isBlank()) return MY;
|
|
||||||
String r = role.trim().toUpperCase();
|
|
||||||
return switch (r) {
|
|
||||||
case SOURCE, RIVAL, OWN, MY -> r;
|
|
||||||
default -> MY;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 이 역할의 채널에서 수집한 영상에 붙일 출처(ChannelVideo.source).
|
|
||||||
* 수집함/발굴 쿼리는 CHANNEL·SEARCH 만 보므로, OWN 은 자동으로 격리된다.
|
|
||||||
*/
|
|
||||||
public static String videoSource(String role) {
|
|
||||||
return OWN.equals(normalize(role)) ? OWN : "CHANNEL";
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 피드(소재 원본/경쟁) 역할인지. */
|
|
||||||
public static boolean isFeed(String role) {
|
|
||||||
String r = normalize(role);
|
|
||||||
return SOURCE.equals(r) || RIVAL.equals(r);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -27,7 +27,6 @@ import java.time.format.DateTimeFormatter;
|
|||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
|
||||||
|
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@Service
|
@Service
|
||||||
@ -51,41 +50,6 @@ public class ChannelService {
|
|||||||
@Value("${python.base-url:http://h-python.tolag.shop}") // Python 마이크로서비스(자막/전사) 베이스 URL
|
@Value("${python.base-url:http://h-python.tolag.shop}") // Python 마이크로서비스(자막/전사) 베이스 URL
|
||||||
private String pythonBaseUrl;
|
private String pythonBaseUrl;
|
||||||
|
|
||||||
/** 저장된 피드 영상의 조회수·좋아요와 파생 지표를 videos.list 배치 응답으로 현행화한다. */
|
|
||||||
@Transactional
|
|
||||||
public int refreshVideoStats(List<ChannelVideo> videos) {
|
|
||||||
if (videos == null || videos.isEmpty()) return 0;
|
|
||||||
Map<String, ChannelVideo> byVideoId = videos.stream().collect(java.util.stream.Collectors.toMap(
|
|
||||||
ChannelVideo::getVideoId, v -> v, (left, right) -> left));
|
|
||||||
int updated = 0;
|
|
||||||
List<String> ids = new ArrayList<>(byVideoId.keySet());
|
|
||||||
for (int from = 0; from < ids.size(); from += 50) {
|
|
||||||
List<String> batch = ids.subList(from, Math.min(from + 50, ids.size()));
|
|
||||||
String apiUrl = UriComponentsBuilder.fromHttpUrl("https://www.googleapis.com/youtube/v3/videos")
|
|
||||||
.queryParam("part", "statistics")
|
|
||||||
.queryParam("id", String.join(",", batch))
|
|
||||||
.queryParam("key", youtubeApiKey)
|
|
||||||
.toUriString();
|
|
||||||
JsonNode root = restTemplate.getForObject(apiUrl, JsonNode.class);
|
|
||||||
for (JsonNode item : root.path("items")) {
|
|
||||||
ChannelVideo video = byVideoId.get(item.path("id").asText());
|
|
||||||
if (video == null) continue;
|
|
||||||
JsonNode statistics = item.path("statistics");
|
|
||||||
long views = statistics.path("viewCount").asLong(video.getViewCount() == null ? 0 : video.getViewCount());
|
|
||||||
long likes = statistics.path("likeCount").asLong(video.getLikeCount() == null ? 0 : video.getLikeCount());
|
|
||||||
video.update(video.getTitle(), video.getThumbnailUrl(), views, likes);
|
|
||||||
video.applyMetrics(video.getDurationSec(), video.getIsShorts(),
|
|
||||||
VideoMetrics.viewsPerHour(views, video.getPublishedAt()));
|
|
||||||
video.applyFeedInfo(video.getYtChannelId(), video.getChannelTitle(), video.getSubscriberCount(),
|
|
||||||
VideoMetrics.viewsPerSubRatio(views, video.getSubscriberCount()),
|
|
||||||
video.getSource(), video.feedTopicOrDefault());
|
|
||||||
channelVideoRepository.save(video);
|
|
||||||
updated++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return updated;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public Channel saveChannelFromUrl(String url) {
|
public Channel saveChannelFromUrl(String url) {
|
||||||
String identifier = extractIdentifier(url);
|
String identifier = extractIdentifier(url);
|
||||||
@ -103,9 +67,7 @@ public class ChannelService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 한글 @핸들이 들어올 수 있으므로 URI 오버로드로 넘긴다.
|
JsonNode root = restTemplate.getForObject(builder.toUriString(), JsonNode.class);
|
||||||
// toUriString() 을 넘기면 RestTemplate 이 URI 템플릿으로 보고 이중 인코딩한다.
|
|
||||||
JsonNode root = restTemplate.getForObject(builder.build().encode().toUri(), JsonNode.class);
|
|
||||||
JsonNode items = root.path("items");
|
JsonNode items = root.path("items");
|
||||||
if (items.isEmpty()) {
|
if (items.isEmpty()) {
|
||||||
throw new IllegalArgumentException("Channel not found for identifier: " + identifier);
|
throw new IllegalArgumentException("Channel not found for identifier: " + identifier);
|
||||||
@ -251,9 +213,8 @@ public class ChannelService {
|
|||||||
return url;
|
return url;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 내 채널 목록. 피드 시드(SOURCE/RIVAL)는 /feed 에서 따로 관리하므로 제외한다. */
|
|
||||||
public List<Channel> getAllChannels() {
|
public List<Channel> getAllChannels() {
|
||||||
return channelRepository.findMyChannels();
|
return channelRepository.findAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
public Channel getChannel(Long id) {
|
public Channel getChannel(Long id) {
|
||||||
@ -328,30 +289,12 @@ public class ChannelService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void processVideos(Channel channel, List<String> videoIds) {
|
private void processVideos(Channel channel, List<String> videoIds) {
|
||||||
// 내 채널(OWN)의 영상은 소재가 아니라 성과라 출처를 달리해 수집함/발굴에서 격리한다.
|
|
||||||
upsertVideos(channel, videoIds, ChannelRole.videoSource(channel.roleOrDefault()), null, null, null, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* videos.list 로 상세를 받아 ChannelVideo 를 upsert 한다.
|
|
||||||
*
|
|
||||||
* @param source 저장할 출처. CHANNEL(등록 채널) / SOURCE(소재 원본) / RIVAL(경쟁 채널)
|
|
||||||
* @param minDurationSec 이 길이(초) 미만이거나 길이를 모르는 영상은 건너뜀(null 이면 전체)
|
|
||||||
* @param publishedAfter 이 시각 이전 업로드는 건너뜀(null 이면 제한 없음)
|
|
||||||
* @param newInsertsOut null 이 아니면 갱신이 아니라 새로 insert 된 영상을 여기 담아준다(골든타임 알림용)
|
|
||||||
* @return 저장·갱신한 영상 수
|
|
||||||
*/
|
|
||||||
private int upsertVideos(Channel channel, List<String> videoIds, String source,
|
|
||||||
Integer minDurationSec, Integer maxDurationSec, LocalDateTime publishedAfter,
|
|
||||||
List<ChannelVideo> newInsertsOut) {
|
|
||||||
String apiUrl = UriComponentsBuilder.fromHttpUrl("https://www.googleapis.com/youtube/v3/videos")
|
String apiUrl = UriComponentsBuilder.fromHttpUrl("https://www.googleapis.com/youtube/v3/videos")
|
||||||
// status 는 임베드 가능 여부(embeddable) 때문에 필요하다. part 를 늘려도 쿼터는 그대로다.
|
.queryParam("part", "snippet,statistics,contentDetails")
|
||||||
.queryParam("part", "snippet,statistics,contentDetails,status")
|
|
||||||
.queryParam("id", String.join(",", videoIds))
|
.queryParam("id", String.join(",", videoIds))
|
||||||
.queryParam("key", youtubeApiKey)
|
.queryParam("key", youtubeApiKey)
|
||||||
.toUriString();
|
.toUriString();
|
||||||
|
|
||||||
int saved = 0;
|
|
||||||
try {
|
try {
|
||||||
JsonNode root = restTemplate.getForObject(apiUrl, JsonNode.class);
|
JsonNode root = restTemplate.getForObject(apiUrl, JsonNode.class);
|
||||||
JsonNode items = root.path("items");
|
JsonNode items = root.path("items");
|
||||||
@ -376,32 +319,17 @@ public class ChannelService {
|
|||||||
// --- 파생 분석 지표 계산 ---
|
// --- 파생 분석 지표 계산 ---
|
||||||
Integer durationSec = VideoMetrics.parseDurationSec(duration);
|
Integer durationSec = VideoMetrics.parseDurationSec(duration);
|
||||||
Boolean isShorts = VideoMetrics.isShorts(durationSec);
|
Boolean isShorts = VideoMetrics.isShorts(durationSec);
|
||||||
|
|
||||||
// 길이/기간 필터 — 피드 수집에서만 사용(일반 채널 수집은 둘 다 null)
|
|
||||||
if (minDurationSec != null && (durationSec == null || durationSec < minDurationSec)) continue;
|
|
||||||
if (maxDurationSec != null && (durationSec == null || durationSec > maxDurationSec)) continue;
|
|
||||||
if (publishedAfter != null && publishedAt.isBefore(publishedAfter)) continue;
|
|
||||||
|
|
||||||
java.math.BigDecimal viewsPerHour = VideoMetrics.viewsPerHour(viewCount, publishedAt);
|
java.math.BigDecimal viewsPerHour = VideoMetrics.viewsPerHour(viewCount, publishedAt);
|
||||||
java.math.BigDecimal viewsPerSubRatio = VideoMetrics.viewsPerSubRatio(viewCount, channel.getSubscriberCount());
|
java.math.BigDecimal viewsPerSubRatio = VideoMetrics.viewsPerSubRatio(viewCount, channel.getSubscriberCount());
|
||||||
String ytChannelId = channel.getChannelId();
|
String ytChannelId = channel.getChannelId();
|
||||||
String channelTitle = channel.getTitle();
|
String channelTitle = channel.getTitle();
|
||||||
Long subscriberCount = channel.getSubscriberCount();
|
Long subscriberCount = channel.getSubscriberCount();
|
||||||
// 해시태그는 시드 역분석(내 채널 → 소재 원본 채널 후보)의 재료가 된다.
|
|
||||||
String hashtags = HashtagExtractor.join(
|
|
||||||
HashtagExtractor.extract(snippet.path("description").asText("") + " " + title));
|
|
||||||
// 임베드 차단 채널이 많아(방송사·연예 채널) 미리 알아둬야 헛클릭을 막는다
|
|
||||||
final Boolean embeddable = item.path("status").has("embeddable")
|
|
||||||
? item.path("status").path("embeddable").asBoolean() : null;
|
|
||||||
|
|
||||||
channelVideoRepository.findByVideoId(videoId)
|
channelVideoRepository.findByVideoId(videoId)
|
||||||
.ifPresentOrElse(v -> {
|
.ifPresentOrElse(v -> {
|
||||||
v.update(title, thumbnailUrl, viewCount, likeCount);
|
v.update(title, thumbnailUrl, viewCount, likeCount);
|
||||||
v.applyMetrics(durationSec, isShorts, viewsPerHour);
|
v.applyMetrics(durationSec, isShorts, viewsPerHour);
|
||||||
v.applyFeedInfo(ytChannelId, channelTitle, subscriberCount, viewsPerSubRatio,
|
v.applyChannelInfo(ytChannelId, channelTitle, subscriberCount, viewsPerSubRatio);
|
||||||
source, channel.feedTopicOrDefault());
|
|
||||||
v.applyHashtags(hashtags);
|
|
||||||
v.applyEmbeddable(embeddable);
|
|
||||||
channelVideoRepository.save(v);
|
channelVideoRepository.save(v);
|
||||||
}, () -> {
|
}, () -> {
|
||||||
ChannelVideo newVideo = ChannelVideo.builder()
|
ChannelVideo newVideo = ChannelVideo.builder()
|
||||||
@ -415,62 +343,13 @@ public class ChannelService {
|
|||||||
.duration(duration)
|
.duration(duration)
|
||||||
.build();
|
.build();
|
||||||
newVideo.applyMetrics(durationSec, isShorts, viewsPerHour);
|
newVideo.applyMetrics(durationSec, isShorts, viewsPerHour);
|
||||||
newVideo.applyFeedInfo(ytChannelId, channelTitle, subscriberCount, viewsPerSubRatio,
|
newVideo.applyChannelInfo(ytChannelId, channelTitle, subscriberCount, viewsPerSubRatio);
|
||||||
source, channel.feedTopicOrDefault());
|
|
||||||
newVideo.applyHashtags(hashtags);
|
|
||||||
newVideo.applyEmbeddable(embeddable);
|
|
||||||
channelVideoRepository.save(newVideo);
|
channelVideoRepository.save(newVideo);
|
||||||
if (newInsertsOut != null) newInsertsOut.add(newVideo);
|
|
||||||
});
|
});
|
||||||
saved++;
|
|
||||||
}
|
}
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("Error fetching video details", e);
|
log.error("Error fetching video details", e);
|
||||||
}
|
}
|
||||||
return saved;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 피드 시드 채널의 최근 업로드를 1페이지(최대 50건)만 수집한다.
|
|
||||||
* 역할과 무관하게 {@code minDurationSec} 이상 롱폼만 저장한다 — 짧은 클립·쇼츠는 자를 원본이 못 된다.
|
|
||||||
*
|
|
||||||
* @param channel SOURCE 또는 RIVAL 역할의 채널
|
|
||||||
* @param publishedAfter 이 시각 이후 업로드만 수집
|
|
||||||
* @param minDurationSec 이 길이(초) 미만은 수집하지 않음
|
|
||||||
* @param newInsertsOut null 이 아니면 새로 insert 된 영상을 여기 담아준다(골든타임 알림용)
|
|
||||||
* @return 저장·갱신된 영상 수
|
|
||||||
* @throws IllegalStateException uploads 플레이리스트를 찾을 수 없을 때
|
|
||||||
*/
|
|
||||||
@Transactional
|
|
||||||
public int collectFeedVideos(Channel channel, LocalDateTime publishedAfter,
|
|
||||||
List<ChannelVideo> newInsertsOut) {
|
|
||||||
String role = channel.roleOrDefault();
|
|
||||||
String uploadsPlaylistId = channel.getUploadsPlaylistId();
|
|
||||||
if (uploadsPlaylistId == null || uploadsPlaylistId.isBlank()) {
|
|
||||||
throw new IllegalStateException("uploads 플레이리스트가 없습니다: " + channel.getChannelId());
|
|
||||||
}
|
|
||||||
|
|
||||||
String apiUrl = UriComponentsBuilder.fromHttpUrl("https://www.googleapis.com/youtube/v3/playlistItems")
|
|
||||||
.queryParam("part", "contentDetails")
|
|
||||||
.queryParam("playlistId", uploadsPlaylistId)
|
|
||||||
.queryParam("maxResults", 50)
|
|
||||||
.queryParam("key", youtubeApiKey)
|
|
||||||
.toUriString();
|
|
||||||
|
|
||||||
JsonNode root = restTemplate.getForObject(apiUrl, JsonNode.class);
|
|
||||||
if (root == null) throw new IllegalStateException("playlistItems 응답이 비어있습니다: " + channel.getChannelId());
|
|
||||||
|
|
||||||
List<String> videoIds = new ArrayList<>();
|
|
||||||
for (JsonNode item : root.path("items")) {
|
|
||||||
String videoId = item.path("contentDetails").path("videoId").asText(null);
|
|
||||||
if (videoId != null && !videoId.isBlank()) videoIds.add(videoId);
|
|
||||||
}
|
|
||||||
if (videoIds.isEmpty()) return 0;
|
|
||||||
|
|
||||||
String source = ChannelRole.RIVAL.equals(role) ? ChannelRole.RIVAL : ChannelRole.SOURCE;
|
|
||||||
FeedFormat format = channel.feedFormatOrDefault();
|
|
||||||
return upsertVideos(channel, videoIds, source, format.minDurationSec(), format.maxDurationSec(),
|
|
||||||
publishedAfter, newInsertsOut);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -10,16 +10,7 @@ import java.math.BigDecimal;
|
|||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
@Entity
|
@Entity
|
||||||
@Table(name = "channel_videos", indexes = {
|
@Table(name = "channel_videos")
|
||||||
@Index(name = "idx_cv_video_id", columnList = "video_id"), // 조회/중복스킵 lookup
|
|
||||||
@Index(name = "idx_cv_channel_id", columnList = "channel_id"), // 채널별 영상
|
|
||||||
@Index(name = "idx_cv_interest_status", columnList = "interest_status"), // 큐레이션/칸반 필터·집계
|
|
||||||
@Index(name = "idx_cv_source", columnList = "source"), // 출처 필터·집계
|
|
||||||
@Index(name = "idx_cv_feed_topic", columnList = "feed_topic"), // 예능/정치 피드 분리
|
|
||||||
@Index(name = "idx_cv_category_id", columnList = "category_id"), // 카테고리 필터·집계
|
|
||||||
@Index(name = "idx_cv_published_at", columnList = "published_at"), // 게시일 정렬·발굴 필터
|
|
||||||
@Index(name = "idx_cv_views_per_sub_ratio", columnList = "views_per_sub_ratio") // 떡상 배율 정렬(발굴/후보)
|
|
||||||
})
|
|
||||||
@Getter
|
@Getter
|
||||||
@NoArgsConstructor(access = AccessLevel.PROTECTED)
|
@NoArgsConstructor(access = AccessLevel.PROTECTED)
|
||||||
public class ChannelVideo {
|
public class ChannelVideo {
|
||||||
@ -67,10 +58,6 @@ public class ChannelVideo {
|
|||||||
@Column(name = "source", length = 20)
|
@Column(name = "source", length = 20)
|
||||||
private String source = "CHANNEL";
|
private String source = "CHANNEL";
|
||||||
|
|
||||||
/** 피드 주제: ENTERTAINMENT | POLITICS. 기존 null 행은 예능으로 간주한다. */
|
|
||||||
@Column(name = "feed_topic", length = 20)
|
|
||||||
private String feedTopic = FeedTopic.ENTERTAINMENT.name();
|
|
||||||
|
|
||||||
/** 원본 YouTube 채널 ID(문자열). FK Channel 과 별개로 항상 보관. */
|
/** 원본 YouTube 채널 ID(문자열). FK Channel 과 별개로 항상 보관. */
|
||||||
@Column(name = "yt_channel_id")
|
@Column(name = "yt_channel_id")
|
||||||
private String ytChannelId;
|
private String ytChannelId;
|
||||||
@ -87,21 +74,6 @@ public class ChannelVideo {
|
|||||||
@Column(name = "hashtags", columnDefinition = "TEXT")
|
@Column(name = "hashtags", columnDefinition = "TEXT")
|
||||||
private String hashtags;
|
private String hashtags;
|
||||||
|
|
||||||
/**
|
|
||||||
* 다른 사이트에 임베드할 수 있는가(YouTube status.embeddable).
|
|
||||||
* 방송사·연예 채널은 막아둔 경우가 많아(실측 9건 중 4건) 미리 알아야 헛클릭을 막는다.
|
|
||||||
* null 이면 아직 모름 — 일단 재생을 시도한다.
|
|
||||||
*/
|
|
||||||
@Column(name = "embeddable")
|
|
||||||
private Boolean embeddable;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 인물 추적으로 걸린 영상이면 그 인물명. 인물 탭은 source 가 아니라 이 값으로 조회하므로,
|
|
||||||
* 소스 채널에서 이미 수집한 영상이 인물 검색에도 걸리면 두 탭 모두에 나타난다.
|
|
||||||
*/
|
|
||||||
@Column(name = "matched_person", length = 100)
|
|
||||||
private String matchedPerson;
|
|
||||||
|
|
||||||
// --- 큐레이션(분류/관리) 필드 ---
|
// --- 큐레이션(분류/관리) 필드 ---
|
||||||
|
|
||||||
/** 분류 카테고리 ID (categories.id 참조, 느슨한 연결). */
|
/** 분류 카테고리 ID (categories.id 참조, 느슨한 연결). */
|
||||||
@ -127,13 +99,6 @@ public class ChannelVideo {
|
|||||||
@Column(name = "has_script")
|
@Column(name = "has_script")
|
||||||
private Boolean hasScript = false;
|
private Boolean hasScript = false;
|
||||||
|
|
||||||
/**
|
|
||||||
* 영상 내용 요약(Gemini가 영상을 직접 보고 만든 것). 댓글 답글 초안의 근거로 쓴다.
|
|
||||||
* 영상당 1회만 만들고 재사용한다 — 같은 영상 댓글마다 다시 분석하면 낭비다.
|
|
||||||
*/
|
|
||||||
@Column(name = "context_summary", columnDefinition = "TEXT")
|
|
||||||
private String contextSummary;
|
|
||||||
|
|
||||||
@com.fasterxml.jackson.annotation.JsonIgnore
|
@com.fasterxml.jackson.annotation.JsonIgnore
|
||||||
@ManyToOne(fetch = FetchType.LAZY)
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
@JoinColumn(name = "channel_id")
|
@JoinColumn(name = "channel_id")
|
||||||
@ -174,67 +139,6 @@ public class ChannelVideo {
|
|||||||
this.source = "CHANNEL";
|
this.source = "CHANNEL";
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 피드(소재 원본/경쟁) 수집 시 원본 채널 정보와 출처를 함께 적용한다.
|
|
||||||
* @param source SOURCE(소재 원본 롱폼) | RIVAL(경쟁 쇼츠)
|
|
||||||
*/
|
|
||||||
public void applyFeedInfo(String ytChannelId, String channelTitle, Long subscriberCount,
|
|
||||||
BigDecimal viewsPerSubRatio, String source, FeedTopic feedTopic) {
|
|
||||||
this.ytChannelId = ytChannelId;
|
|
||||||
this.channelTitle = channelTitle;
|
|
||||||
this.subscriberCount = subscriberCount;
|
|
||||||
this.viewsPerSubRatio = viewsPerSubRatio;
|
|
||||||
this.source = source;
|
|
||||||
this.feedTopic = feedTopic == null ? FeedTopic.ENTERTAINMENT.name() : feedTopic.name();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 설명에서 추출한 해시태그(쉼표 구분)를 채운다. */
|
|
||||||
public void applyHashtags(String hashtags) {
|
|
||||||
this.hashtags = hashtags;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 임베드 가능 여부를 기록한다. */
|
|
||||||
public void applyEmbeddable(Boolean embeddable) {
|
|
||||||
this.embeddable = embeddable;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Gemini 가 영상을 보고 만든 내용 요약을 저장한다(영상당 1회). */
|
|
||||||
public void applyContextSummary(String contextSummary) {
|
|
||||||
this.contextSummary = contextSummary;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 인물 추적으로 걸린 인물명을 기록한다. */
|
|
||||||
public void applyMatchedPerson(String matchedPerson) {
|
|
||||||
this.matchedPerson = matchedPerson;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 인물 검색으로 새로 발견한 영상을 만든다. 소스 채널 시드에 없는 채널까지 잡히므로
|
|
||||||
* 출처를 PERSON 으로 둬 수집함/발굴에서는 격리한다.
|
|
||||||
*/
|
|
||||||
public static ChannelVideo fromPersonSearch(String videoId, String title, String thumbnailUrl,
|
|
||||||
LocalDateTime publishedAt, Long viewCount,
|
|
||||||
String ytChannelId, String channelTitle,
|
|
||||||
Integer durationSec, BigDecimal viewsPerHour,
|
|
||||||
String hashtags, String matchedPerson) {
|
|
||||||
ChannelVideo v = new ChannelVideo();
|
|
||||||
v.videoId = videoId;
|
|
||||||
v.title = title;
|
|
||||||
v.thumbnailUrl = thumbnailUrl;
|
|
||||||
v.publishedAt = publishedAt;
|
|
||||||
v.viewCount = viewCount;
|
|
||||||
v.likeCount = 0L;
|
|
||||||
v.ytChannelId = ytChannelId;
|
|
||||||
v.channelTitle = channelTitle;
|
|
||||||
v.durationSec = durationSec;
|
|
||||||
v.isShorts = VideoMetrics.isShorts(durationSec);
|
|
||||||
v.viewsPerHour = viewsPerHour;
|
|
||||||
v.hashtags = hashtags;
|
|
||||||
v.matchedPerson = matchedPerson;
|
|
||||||
v.source = "PERSON";
|
|
||||||
return v;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 출처(source)를 바꾸지 않고 채널 정보/비율만 채운다. 백필 시 SEARCH 수집물용. */
|
/** 출처(source)를 바꾸지 않고 채널 정보/비율만 채운다. 백필 시 SEARCH 수집물용. */
|
||||||
public void applyChannelInfoKeepSource(String ytChannelId, String channelTitle, Long subscriberCount, BigDecimal viewsPerSubRatio) {
|
public void applyChannelInfoKeepSource(String ytChannelId, String channelTitle, Long subscriberCount, BigDecimal viewsPerSubRatio) {
|
||||||
this.ytChannelId = ytChannelId;
|
this.ytChannelId = ytChannelId;
|
||||||
@ -248,11 +152,6 @@ public class ChannelVideo {
|
|||||||
if (this.source == null) this.source = "CHANNEL";
|
if (this.source == null) this.source = "CHANNEL";
|
||||||
if (this.interestStatus == null) this.interestStatus = "NEW";
|
if (this.interestStatus == null) this.interestStatus = "NEW";
|
||||||
if (this.bookmarked == null) this.bookmarked = false;
|
if (this.bookmarked == null) this.bookmarked = false;
|
||||||
if (this.feedTopic == null) this.feedTopic = FeedTopic.ENTERTAINMENT.name();
|
|
||||||
}
|
|
||||||
|
|
||||||
public FeedTopic feedTopicOrDefault() {
|
|
||||||
return FeedTopic.normalize(this.feedTopic);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 조회수 검색 결과로부터 수집 영상을 생성한다(채널 미연결). */
|
/** 조회수 검색 결과로부터 수집 영상을 생성한다(채널 미연결). */
|
||||||
|
|||||||
@ -12,10 +12,6 @@ public interface ChannelVideoRepository extends JpaRepository<ChannelVideo, Long
|
|||||||
java.util.List<ChannelVideo> findByChannelId(Long channelId);
|
java.util.List<ChannelVideo> findByChannelId(Long channelId);
|
||||||
java.util.List<ChannelVideo> findByChannelIdInOrderByPublishedAtDesc(java.util.List<Long> channelIds);
|
java.util.List<ChannelVideo> findByChannelIdInOrderByPublishedAtDesc(java.util.List<Long> channelIds);
|
||||||
|
|
||||||
@Query("select v from ChannelVideo v where coalesce(v.feedTopic, 'ENTERTAINMENT') = :topic "
|
|
||||||
+ "and v.source in ('SOURCE','RIVAL') order by v.publishedAt desc")
|
|
||||||
java.util.List<ChannelVideo> findFeedVideosForRefresh(@Param("topic") String topic);
|
|
||||||
|
|
||||||
// --- 큐레이션/분석 조회 ---
|
// --- 큐레이션/분석 조회 ---
|
||||||
java.util.List<ChannelVideo> findByCategoryId(Long categoryId);
|
java.util.List<ChannelVideo> findByCategoryId(Long categoryId);
|
||||||
long countByCategoryId(Long categoryId);
|
long countByCategoryId(Long categoryId);
|
||||||
@ -24,70 +20,8 @@ public interface ChannelVideoRepository extends JpaRepository<ChannelVideo, Long
|
|||||||
long countByIsShortsTrue();
|
long countByIsShortsTrue();
|
||||||
long countByCategoryIdIsNull();
|
long countByCategoryIdIsNull();
|
||||||
|
|
||||||
// --- 소재 발굴 피드 ---
|
/** 떡상 후보: 구독자 대비 조회수 비율이 높은 Shorts (제외 처리된 것은 빼고). */
|
||||||
|
|
||||||
/**
|
|
||||||
* 피드 조회. 정렬은 항상 최신순(publishedAt DESC)이며 EXCLUDED 는 제외한다.
|
|
||||||
*
|
|
||||||
* @param source SOURCE(소재 원본 롱폼) | RIVAL(경쟁 쇼츠)
|
|
||||||
* @param publishedAfter 이 시각 이후 업로드만 (null 이면 전체)
|
|
||||||
* @param ytChannelId 특정 프로그램(채널)만 (null 이면 전체)
|
|
||||||
* @param minDurationSec 길이 하한(포함, null 이면 미적용)
|
|
||||||
* @param maxDurationSec 길이 상한(포함, null 이면 미적용)
|
|
||||||
* @param hideWorked true 면 아직 손대지 않은(NEW) 것만
|
|
||||||
*/
|
|
||||||
@Query("select v from ChannelVideo v where v.source = :source "
|
|
||||||
+ "and coalesce(v.feedTopic, 'ENTERTAINMENT') = :topic "
|
|
||||||
+ "and v.interestStatus <> 'EXCLUDED' "
|
|
||||||
+ "and (cast(:publishedAfter as timestamp) is null or v.publishedAt >= :publishedAfter) "
|
|
||||||
+ "and (:ytChannelId is null or v.ytChannelId = :ytChannelId) "
|
|
||||||
+ "and (:minDurationSec is null or v.durationSec >= :minDurationSec) "
|
|
||||||
+ "and (:maxDurationSec is null or v.durationSec <= :maxDurationSec) "
|
|
||||||
+ "and (:hideWorked = false or v.interestStatus = 'NEW') "
|
|
||||||
+ "order by v.publishedAt desc")
|
|
||||||
java.util.List<ChannelVideo> feed(@Param("source") String source,
|
|
||||||
@Param("topic") String topic,
|
|
||||||
@Param("publishedAfter") java.time.LocalDateTime publishedAfter,
|
|
||||||
@Param("ytChannelId") String ytChannelId,
|
|
||||||
@Param("minDurationSec") Integer minDurationSec,
|
|
||||||
@Param("maxDurationSec") Integer maxDurationSec,
|
|
||||||
@Param("hideWorked") boolean hideWorked,
|
|
||||||
org.springframework.data.domain.Pageable pageable);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 인물 탭 조회. source 가 아니라 matchedPerson 기준이라, 소스 채널에서 이미 수집한 영상이
|
|
||||||
* 인물 검색에도 걸리면 두 탭 모두에 나타난다. 롱폼만 본다
|
|
||||||
* (180초 컷 = {@link PersonPicks#SHORTS_MAX_SEC} — 3분짜리 쇼츠까지 걸러낸다).
|
|
||||||
*
|
|
||||||
* @param person null 이면 추적 중인 인물 전체
|
|
||||||
*/
|
|
||||||
@Query("select v from ChannelVideo v where v.matchedPerson is not null "
|
|
||||||
+ "and v.interestStatus <> 'EXCLUDED' "
|
|
||||||
+ "and (v.durationSec is null or v.durationSec > 180) "
|
|
||||||
+ "and (cast(:publishedAfter as timestamp) is null or v.publishedAt >= :publishedAfter) "
|
|
||||||
+ "and (:person is null or v.matchedPerson = :person) "
|
|
||||||
+ "and (:hideWorked = false or v.interestStatus = 'NEW') "
|
|
||||||
+ "order by v.publishedAt desc")
|
|
||||||
java.util.List<ChannelVideo> feedByPerson(@Param("publishedAfter") java.time.LocalDateTime publishedAfter,
|
|
||||||
@Param("person") String person,
|
|
||||||
@Param("hideWorked") boolean hideWorked,
|
|
||||||
org.springframework.data.domain.Pageable pageable);
|
|
||||||
|
|
||||||
/** 인물 탭 필터용: [인물명, 영상수]. 영상 많은 순. */
|
|
||||||
@Query("select v.matchedPerson, count(v) from ChannelVideo v "
|
|
||||||
+ "where v.matchedPerson is not null group by v.matchedPerson order by count(v) desc")
|
|
||||||
java.util.List<Object[]> feedPersons();
|
|
||||||
|
|
||||||
/** 피드 필터용 프로그램(채널) 목록: [ytChannelId, channelTitle, 영상수]. 영상 많은 순. */
|
|
||||||
@Query("select v.ytChannelId, min(v.channelTitle), count(v) from ChannelVideo v "
|
|
||||||
+ "where v.source = :source and coalesce(v.feedTopic, 'ENTERTAINMENT') = :topic "
|
|
||||||
+ "and v.ytChannelId is not null "
|
|
||||||
+ "group by v.ytChannelId order by count(v) desc")
|
|
||||||
java.util.List<Object[]> feedPrograms(@Param("source") String source, @Param("topic") String topic);
|
|
||||||
|
|
||||||
/** 떡상 후보: 구독자 대비 조회수 비율이 높은 Shorts (제외 처리된 것은 빼고). 피드 수집물은 제외. */
|
|
||||||
@Query("select v from ChannelVideo v where v.isShorts = true "
|
@Query("select v from ChannelVideo v where v.isShorts = true "
|
||||||
+ "and (v.source is null or v.source in ('CHANNEL','SEARCH')) "
|
|
||||||
+ "and v.viewsPerSubRatio >= :minRatio and v.interestStatus <> 'EXCLUDED' "
|
+ "and v.viewsPerSubRatio >= :minRatio and v.interestStatus <> 'EXCLUDED' "
|
||||||
+ "order by v.viewsPerSubRatio desc")
|
+ "order by v.viewsPerSubRatio desc")
|
||||||
java.util.List<ChannelVideo> findOutperformers(@Param("minRatio") java.math.BigDecimal minRatio,
|
java.util.List<ChannelVideo> findOutperformers(@Param("minRatio") java.math.BigDecimal minRatio,
|
||||||
@ -96,12 +30,10 @@ public interface ChannelVideoRepository extends JpaRepository<ChannelVideo, Long
|
|||||||
/**
|
/**
|
||||||
* 큐레이션 필터링 + 조회수/업로드일/시간당조회수 정렬은 호출부의 Sort 로 처리.
|
* 큐레이션 필터링 + 조회수/업로드일/시간당조회수 정렬은 호출부의 Sort 로 처리.
|
||||||
* null 인 조건은 무시한다.
|
* null 인 조건은 무시한다.
|
||||||
* source 를 지정하지 않으면 피드 수집물(SOURCE/RIVAL)은 제외해 수집함이 오염되지 않게 한다.
|
|
||||||
*/
|
*/
|
||||||
@Query("select v from ChannelVideo v where "
|
@Query("select v from ChannelVideo v where "
|
||||||
+ "(:categoryId is null or v.categoryId = :categoryId) and "
|
+ "(:categoryId is null or v.categoryId = :categoryId) and "
|
||||||
+ "(:status is null or v.interestStatus = :status) and "
|
+ "(:status is null or v.interestStatus = :status) and "
|
||||||
+ "(:source is not null or v.source is null or v.source in ('CHANNEL','SEARCH')) and "
|
|
||||||
+ "(:source is null or v.source = :source) and "
|
+ "(:source is null or v.source = :source) and "
|
||||||
+ "(:shortsOnly = false or v.isShorts = true) and "
|
+ "(:shortsOnly = false or v.isShorts = true) and "
|
||||||
+ "(:bookmarkedOnly = false or v.bookmarked = true)")
|
+ "(:bookmarkedOnly = false or v.bookmarked = true)")
|
||||||
@ -125,7 +57,6 @@ public interface ChannelVideoRepository extends JpaRepository<ChannelVideo, Long
|
|||||||
+ "v.interestStatus <> 'EXCLUDED' and "
|
+ "v.interestStatus <> 'EXCLUDED' and "
|
||||||
+ "(cast(:publishedAfter as timestamp) is null or v.publishedAt >= :publishedAfter) and "
|
+ "(cast(:publishedAfter as timestamp) is null or v.publishedAt >= :publishedAfter) and "
|
||||||
+ "(cast(:minRatio as big_decimal) is null or v.viewsPerSubRatio >= :minRatio) and "
|
+ "(cast(:minRatio as big_decimal) is null or v.viewsPerSubRatio >= :minRatio) and "
|
||||||
+ "(:source is not null or v.source is null or v.source in ('CHANNEL','SEARCH')) and "
|
|
||||||
+ "(:source is null or v.source = :source) and "
|
+ "(:source is null or v.source = :source) and "
|
||||||
+ "(:shortsOnly = false or v.isShorts = true) and "
|
+ "(:shortsOnly = false or v.isShorts = true) and "
|
||||||
+ "(:unprocessedOnly = false or v.interestStatus in ('NEW','REVIEWING'))")
|
+ "(:unprocessedOnly = false or v.interestStatus in ('NEW','REVIEWING'))")
|
||||||
|
|||||||
@ -1,83 +0,0 @@
|
|||||||
package com.hlab.yanalyst.domain.channel;
|
|
||||||
|
|
||||||
import com.hlab.yanalyst.global.notify.TelegramNotifier;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
|
||||||
import org.springframework.stereotype.Service;
|
|
||||||
|
|
||||||
import java.time.Duration;
|
|
||||||
import java.time.LocalDateTime;
|
|
||||||
import java.util.Comparator;
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 골든타임 소재 텔레그램 알림.
|
|
||||||
*
|
|
||||||
* <p>수집에서 <b>새로 담긴</b> 영상 중 업로드 {@link FeedBadges#GOLDEN_HOURS}시간 이내인 것만 골라
|
|
||||||
* 한 번에 알린다. 신규 insert 만 대상이라 같은 영상이 두 번 울리지 않는다.
|
|
||||||
* 알림 실패는 수집을 깨지 않는다({@link TelegramNotifier}가 예외를 삼킨다).
|
|
||||||
*/
|
|
||||||
@Slf4j
|
|
||||||
@Service
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
public class FeedAlertService {
|
|
||||||
|
|
||||||
/** 한 메시지에 나열할 최대 영상 수(텔레그램 4096자 제한 여유). */
|
|
||||||
private static final int MAX_LINES = 8;
|
|
||||||
|
|
||||||
private final TelegramNotifier telegramNotifier;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param header 메시지 제목 앞머리(예: "소재 피드", "인물 추적")
|
|
||||||
* @param newVideos 이번 수집에서 새로 insert 된 영상들
|
|
||||||
* @return 알림에 포함된 영상 수(텔레그램 미설정·해당 없음·발송 실패면 0)
|
|
||||||
*/
|
|
||||||
public int notifyGoldenTime(String header, List<ChannelVideo> newVideos) {
|
|
||||||
if (newVideos == null || newVideos.isEmpty() || !telegramNotifier.isEnabled()) return 0;
|
|
||||||
|
|
||||||
LocalDateTime now = LocalDateTime.now();
|
|
||||||
List<ChannelVideo> golden = newVideos.stream()
|
|
||||||
// 정치 피드는 수집·화면 표시만 유지하고 텔레그램에서는 제외한다.
|
|
||||||
.filter(v -> v.feedTopicOrDefault() != FeedTopic.POLITICS)
|
|
||||||
.filter(v -> FeedBadges.isGoldenTime(v.getPublishedAt(), now))
|
|
||||||
.sorted(Comparator.comparing(ChannelVideo::getPublishedAt).reversed())
|
|
||||||
.toList();
|
|
||||||
if (golden.isEmpty()) return 0;
|
|
||||||
|
|
||||||
StringBuilder sb = new StringBuilder();
|
|
||||||
sb.append("🔥 <b>").append(esc(header)).append(" 골든타임 ").append(golden.size())
|
|
||||||
.append("건</b> — 아직 아무도 안 잘랐을 확률이 높아요");
|
|
||||||
|
|
||||||
int shown = Math.min(golden.size(), MAX_LINES);
|
|
||||||
for (int i = 0; i < shown; i++) {
|
|
||||||
ChannelVideo v = golden.get(i);
|
|
||||||
sb.append("\n\n• <b>").append(esc(v.getChannelTitle())).append("</b> · ")
|
|
||||||
.append(ago(v.getPublishedAt(), now))
|
|
||||||
.append("\n <a href=\"https://www.youtube.com/watch?v=").append(esc(v.getVideoId()))
|
|
||||||
.append("\">").append(esc(clip(v.getTitle()))).append("</a>");
|
|
||||||
}
|
|
||||||
if (golden.size() > shown) sb.append("\n\n…외 ").append(golden.size() - shown).append("건");
|
|
||||||
sb.append("\n\nhttps://h-lab.tolag.shop/feed");
|
|
||||||
|
|
||||||
boolean sent = telegramNotifier.sendMessage(sb.toString());
|
|
||||||
log.info("[Feed] 골든타임 알림: sent={}, count={}", sent, golden.size());
|
|
||||||
return sent ? golden.size() : 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String ago(LocalDateTime publishedAt, LocalDateTime now) {
|
|
||||||
if (publishedAt == null) return "-";
|
|
||||||
long hours = Duration.between(publishedAt, now).toHours();
|
|
||||||
return hours < 1 ? "방금 업로드" : hours + "시간 전";
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String clip(String s) {
|
|
||||||
if (s == null || s.isBlank()) return "(제목 없음)";
|
|
||||||
return s.length() <= 70 ? s : s.substring(0, 69) + "…";
|
|
||||||
}
|
|
||||||
|
|
||||||
/** HTML parse_mode 용 특수문자 이스케이프. */
|
|
||||||
private static String esc(String s) {
|
|
||||||
if (s == null) return "-";
|
|
||||||
return s.replace("&", "&").replace("<", "<").replace(">", ">");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,50 +0,0 @@
|
|||||||
package com.hlab.yanalyst.domain.channel;
|
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
|
||||||
import java.time.Duration;
|
|
||||||
import java.time.LocalDateTime;
|
|
||||||
|
|
||||||
/** 피드 카드 배지 판정(순수 로직). 화면과 테스트가 같은 규칙을 공유한다. */
|
|
||||||
public final class FeedBadges {
|
|
||||||
|
|
||||||
/** 소재 선점 골든타임 — 업로드 후 이 시간 이내면 아직 아무도 안 잘랐을 가능성이 높다. */
|
|
||||||
public static final int GOLDEN_HOURS = 24;
|
|
||||||
|
|
||||||
/** 공식클립과 풀에피를 가르는 길이(초). 15분. */
|
|
||||||
public static final int CLIP_MAX_SEC = 15 * 60;
|
|
||||||
|
|
||||||
/** 경쟁 쇼츠 "떡상중" 판정 하한 — 시간당 조회수. */
|
|
||||||
public static final BigDecimal RISING_VIEWS_PER_HOUR = BigDecimal.valueOf(1000);
|
|
||||||
|
|
||||||
/** 길이 버킷. SHORTS(65초 이하) / CLIP(공식클립) / FULL(풀에피) / UNKNOWN. */
|
|
||||||
public static final String BUCKET_SHORTS = "SHORTS";
|
|
||||||
public static final String BUCKET_CLIP = "CLIP";
|
|
||||||
public static final String BUCKET_FULL = "FULL";
|
|
||||||
public static final String BUCKET_UNKNOWN = "UNKNOWN";
|
|
||||||
|
|
||||||
private FeedBadges() {}
|
|
||||||
|
|
||||||
/** 업로드 후 GOLDEN_HOURS 이내인가. publishedAt/now 가 null 이면 false. */
|
|
||||||
public static boolean isGoldenTime(LocalDateTime publishedAt, LocalDateTime now) {
|
|
||||||
if (publishedAt == null || now == null) return false;
|
|
||||||
if (publishedAt.isAfter(now)) return true; // 시계 오차로 미래로 찍힌 경우도 최신으로 취급
|
|
||||||
return Duration.between(publishedAt, now).toHours() < GOLDEN_HOURS;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 길이 버킷 판정. */
|
|
||||||
public static String lengthBucket(Integer durationSec) {
|
|
||||||
if (durationSec == null || durationSec <= 0) return BUCKET_UNKNOWN;
|
|
||||||
if (VideoMetrics.isShorts(durationSec)) return BUCKET_SHORTS;
|
|
||||||
return durationSec <= CLIP_MAX_SEC ? BUCKET_CLIP : BUCKET_FULL;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 경쟁 쇼츠가 지금 터지는 중인가(시간당 조회수 기준). */
|
|
||||||
public static boolean isRising(BigDecimal viewsPerHour) {
|
|
||||||
return viewsPerHour != null && viewsPerHour.compareTo(RISING_VIEWS_PER_HOUR) >= 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 이미 손을 댄 소재인가(NEW 가 아니면 작업 이력이 있다고 본다). */
|
|
||||||
public static boolean isWorked(String interestStatus) {
|
|
||||||
return interestStatus != null && !"NEW".equals(interestStatus);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,161 +0,0 @@
|
|||||||
package com.hlab.yanalyst.domain.channel;
|
|
||||||
|
|
||||||
import com.hlab.yanalyst.global.schedule.YoutubeQuotaGuard;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
|
||||||
import org.springframework.boot.context.event.ApplicationReadyEvent;
|
|
||||||
import org.springframework.context.event.EventListener;
|
|
||||||
import org.springframework.scheduling.annotation.Scheduled;
|
|
||||||
import org.springframework.stereotype.Service;
|
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
|
||||||
import java.util.LinkedHashMap;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 소재 발굴 피드 수집기.
|
|
||||||
*
|
|
||||||
* <p>SOURCE(웹예능 공식채널)와 RIVAL(경쟁 클립 채널)의 신규 롱폼을 주기적으로 받아온다
|
|
||||||
* (min-duration-sec 미만의 짧은 클립·쇼츠는 수집하지 않는다).
|
|
||||||
* uploads 플레이리스트 1페이지만 조회하므로 채널당 약 2 units — 검색(search.list 100 units)보다 훨씬 싸다.
|
|
||||||
*/
|
|
||||||
@Slf4j
|
|
||||||
@Service
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
public class FeedCollectionService {
|
|
||||||
|
|
||||||
/** 채널 1개 수집 추정 쿼터: playlistItems(1) + videos.list(1). */
|
|
||||||
private static final long EST_UNITS_PER_CHANNEL = 2;
|
|
||||||
|
|
||||||
private final ChannelRepository channelRepository;
|
|
||||||
private final ChannelVideoRepository channelVideoRepository;
|
|
||||||
private final ChannelService channelService;
|
|
||||||
private final YoutubeQuotaGuard quotaGuard;
|
|
||||||
private final FeedAlertService feedAlertService;
|
|
||||||
|
|
||||||
@Value("${hlab.feed.enabled:true}")
|
|
||||||
private boolean enabled;
|
|
||||||
|
|
||||||
/** 이 일수보다 오래된 업로드는 수집하지 않는다(과거 전체를 끌어오지 않기 위함). */
|
|
||||||
@Value("${hlab.feed.period-days:14}")
|
|
||||||
private int periodDays;
|
|
||||||
|
|
||||||
/** 이 길이(초) 미만은 수집하지 않는다 — 자를 원본이 못 되는 짧은 클립·쇼츠 차단. 기본 10분. */
|
|
||||||
@Value("${hlab.feed.min-duration-sec:600}")
|
|
||||||
private int minDurationSec;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* role 컬럼이 없던 시절의 기존 채널은 role 이 null 이다. 부팅 시 1회 MY 로 백필한다.
|
|
||||||
* (ddl-auto:update 는 DEFAULT 를 채워주지 않으므로 애플리케이션에서 처리)
|
|
||||||
*/
|
|
||||||
@EventListener(ApplicationReadyEvent.class)
|
|
||||||
@Transactional
|
|
||||||
public void backfillChannelRoles() {
|
|
||||||
int updated = channelRepository.backfillNullRoles();
|
|
||||||
if (updated > 0) log.info("[Feed] 채널 role 백필: {}건 → MY", updated);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Scheduled(cron = "${hlab.feed.cron:0 15 */3 * * *}")
|
|
||||||
public void scheduledCollect() {
|
|
||||||
if (!enabled) {
|
|
||||||
log.info("[Feed] 피드 자동 수집 비활성화됨 (hlab.feed.enabled=false)");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
log.info("[Feed] 자동 수집 완료: {}", collectAll());
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 수동/스케줄 공용. 모든 피드 시드를 쿼터 한도 안에서 수집하고 요약을 반환한다. */
|
|
||||||
public Map<String, Object> collectAll() {
|
|
||||||
return collect(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 특정 주제만 즉시 수집한다. null은 스케줄러용 전체 수집이다. */
|
|
||||||
public Map<String, Object> collect(FeedTopic topic) {
|
|
||||||
List<Channel> seeds = channelRepository.findFeedSeeds().stream()
|
|
||||||
.filter(c -> topic == null || c.feedTopicOrDefault() == topic)
|
|
||||||
.toList();
|
|
||||||
LocalDateTime publishedAfter = LocalDateTime.now().minusDays(periodDays);
|
|
||||||
|
|
||||||
int ok = 0, failed = 0, skippedByQuota = 0, disabled = 0, saved = 0;
|
|
||||||
List<ChannelVideo> goldenCandidates = new java.util.ArrayList<>();
|
|
||||||
|
|
||||||
for (Channel c : seeds) {
|
|
||||||
if (c.isFeedDisabled()) {
|
|
||||||
disabled++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (!quotaGuard.tryConsume(EST_UNITS_PER_CHANNEL)) {
|
|
||||||
skippedByQuota++;
|
|
||||||
log.warn("[Feed] 쿼터 예산 소진 — 채널 {} 이후 건너뜀 (잔여 {} units)", c.getTitle(), quotaGuard.remaining());
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
List<ChannelVideo> newOnes = new java.util.ArrayList<>();
|
|
||||||
saved += channelService.collectFeedVideos(c, publishedAfter, newOnes);
|
|
||||||
// 경쟁(RIVAL) 채널의 신규 영상은 경쟁자의 결과물이라 골든타임 알림 대상이 아니다
|
|
||||||
if (ChannelRole.SOURCE.equals(c.roleOrDefault())) goldenCandidates.addAll(newOnes);
|
|
||||||
markSuccess(c.getId());
|
|
||||||
ok++;
|
|
||||||
} catch (Exception e) {
|
|
||||||
failed++;
|
|
||||||
markFailure(c.getId());
|
|
||||||
log.error("[Feed] 채널 {}({}) 수집 실패", c.getTitle(), c.getChannelId(), e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
int goldenAlerted = feedAlertService.notifyGoldenTime("소재 피드", goldenCandidates);
|
|
||||||
|
|
||||||
Map<String, Object> summary = new LinkedHashMap<>();
|
|
||||||
summary.put("seeds", seeds.size());
|
|
||||||
summary.put("collected", ok);
|
|
||||||
summary.put("savedVideos", saved);
|
|
||||||
summary.put("failed", failed);
|
|
||||||
summary.put("autoDisabled", disabled);
|
|
||||||
summary.put("skippedByQuota", skippedByQuota);
|
|
||||||
summary.put("goldenAlerted", goldenAlerted);
|
|
||||||
summary.put("quotaRemaining", quotaGuard.remaining());
|
|
||||||
return summary;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 저장된 소재 피드 영상의 조회수만 저비용 videos.list 배치로 현행화한다. */
|
|
||||||
public Map<String, Object> refreshViews(FeedTopic topic) {
|
|
||||||
FeedTopic normalized = topic == null ? FeedTopic.ENTERTAINMENT : topic;
|
|
||||||
List<ChannelVideo> videos = channelVideoRepository.findFeedVideosForRefresh(normalized.name());
|
|
||||||
int batches = (videos.size() + 49) / 50;
|
|
||||||
if (!quotaGuard.tryConsume(batches)) {
|
|
||||||
throw new IllegalStateException("YouTube API 쿼터가 부족합니다. 필요 " + batches
|
|
||||||
+ " units · 잔여 " + quotaGuard.remaining() + " units");
|
|
||||||
}
|
|
||||||
int updated = channelService.refreshVideoStats(videos);
|
|
||||||
Map<String, Object> result = new LinkedHashMap<>();
|
|
||||||
result.put("requested", videos.size());
|
|
||||||
result.put("updated", updated);
|
|
||||||
result.put("quotaUsed", batches);
|
|
||||||
result.put("quotaRemaining", quotaGuard.remaining());
|
|
||||||
result.put("refreshedAt", LocalDateTime.now());
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 수집 성공 표시(실패 카운터 리셋).
|
|
||||||
* 같은 빈 안에서 호출되므로 @Transactional 프록시가 적용되지 않는다 — 명시적으로 save 한다.
|
|
||||||
*/
|
|
||||||
private void markSuccess(Long channelId) {
|
|
||||||
channelRepository.findById(channelId).ifPresent(c -> {
|
|
||||||
if (c.feedFailCountOrZero() == 0) return;
|
|
||||||
c.resetFeedFailure();
|
|
||||||
channelRepository.save(c);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 수집 실패 누적. 3회 연속이면 다음 수집부터 자동 스킵된다. */
|
|
||||||
private void markFailure(Long channelId) {
|
|
||||||
channelRepository.findById(channelId).ifPresent(c -> {
|
|
||||||
c.recordFeedFailure();
|
|
||||||
channelRepository.save(c);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,145 +0,0 @@
|
|||||||
package com.hlab.yanalyst.domain.channel;
|
|
||||||
|
|
||||||
import com.hlab.yanalyst.domain.channel.dto.FeedItemDto;
|
|
||||||
import com.hlab.yanalyst.domain.channel.dto.FeedProgramDto;
|
|
||||||
import com.hlab.yanalyst.domain.channel.dto.FeedSeedDto;
|
|
||||||
import com.hlab.yanalyst.domain.channel.dto.TrackedPersonDto;
|
|
||||||
import com.hlab.yanalyst.global.common.ApiResponse;
|
|
||||||
import io.swagger.v3.oas.annotations.Operation;
|
|
||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import org.springframework.web.bind.annotation.*;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
|
|
||||||
/** 소재 발굴 피드 API. 소스(웹예능 롱폼) / 경쟁(예능짤 쇼츠) 신규 업로드를 최신순으로 제공한다. */
|
|
||||||
@RestController
|
|
||||||
@RequestMapping("/api/feed")
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
@Tag(name = "Source Feed API", description = "소재 원본·경쟁 채널의 신규 업로드 피드")
|
|
||||||
public class FeedController {
|
|
||||||
|
|
||||||
private final FeedService feedService;
|
|
||||||
private final FeedCollectionService feedCollectionService;
|
|
||||||
private final SeedSuggestService seedSuggestService;
|
|
||||||
private final PersonCollectionService personCollectionService;
|
|
||||||
|
|
||||||
@GetMapping
|
|
||||||
@Operation(summary = "피드 조회",
|
|
||||||
description = "tab=SOURCE(롱폼 소재)|RIVAL(경쟁 쇼츠)|PERSON(인물 추적 롱폼). 항상 최신순.")
|
|
||||||
public ApiResponse<List<FeedItemDto>> feed(@RequestParam(defaultValue = "SOURCE") String tab,
|
|
||||||
@RequestParam(defaultValue = "ENTERTAINMENT") String topic,
|
|
||||||
@RequestParam(required = false) Integer days,
|
|
||||||
@RequestParam(required = false) String channelId,
|
|
||||||
@RequestParam(required = false) String lengthBucket,
|
|
||||||
@RequestParam(defaultValue = "false") boolean hideWorked,
|
|
||||||
@RequestParam(required = false) String person) {
|
|
||||||
return ApiResponse.ok(feedService.feed(tab, topic, days, channelId, lengthBucket, hideWorked, person));
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- 인물 추적 ---
|
|
||||||
|
|
||||||
@GetMapping("/persons")
|
|
||||||
@Operation(summary = "추적 인물 목록", description = "인물별 담긴 영상 수와 마지막 수집 결과 포함")
|
|
||||||
public ApiResponse<List<TrackedPersonDto>> persons() {
|
|
||||||
return ApiResponse.ok(feedService.persons());
|
|
||||||
}
|
|
||||||
|
|
||||||
@PostMapping("/persons")
|
|
||||||
@Operation(summary = "추적 인물 추가", description = "1명당 검색 100 units 라 활성 인원에 상한이 있다")
|
|
||||||
public ApiResponse<TrackedPersonDto> addPerson(@RequestBody Map<String, String> body) {
|
|
||||||
return ApiResponse.created(feedService.addPerson(body.get("name")));
|
|
||||||
}
|
|
||||||
|
|
||||||
@PostMapping("/persons/{id}/toggle")
|
|
||||||
@Operation(summary = "추적 on/off", description = "끄면 수집 대상에서 빠지고 이미 담은 영상은 남는다")
|
|
||||||
public ApiResponse<Void> togglePerson(@PathVariable Long id, @RequestParam boolean enabled) {
|
|
||||||
feedService.togglePerson(id, enabled);
|
|
||||||
return ApiResponse.ok(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
@DeleteMapping("/persons/{id}")
|
|
||||||
@Operation(summary = "추적 해제", description = "인물만 목록에서 지우고 담긴 영상은 남긴다")
|
|
||||||
public ApiResponse<Void> removePerson(@PathVariable Long id) {
|
|
||||||
feedService.removePerson(id);
|
|
||||||
return ApiResponse.ok(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
@PostMapping("/persons/collect")
|
|
||||||
@Operation(summary = "인물 수집 실행", description = "활성 인물 전체를 즉시 1회 수집. 1명당 101 units.")
|
|
||||||
public ApiResponse<Map<String, Object>> collectPersons() {
|
|
||||||
return ApiResponse.ok(personCollectionService.collectAll());
|
|
||||||
}
|
|
||||||
|
|
||||||
@GetMapping("/programs")
|
|
||||||
@Operation(summary = "프로그램 목록", description = "피드 필터용 원본 채널 목록(영상 많은 순)")
|
|
||||||
public ApiResponse<List<FeedProgramDto>> programs(@RequestParam(defaultValue = "SOURCE") String tab,
|
|
||||||
@RequestParam(defaultValue = "ENTERTAINMENT") String topic) {
|
|
||||||
return ApiResponse.ok(feedService.programs(tab, topic));
|
|
||||||
}
|
|
||||||
|
|
||||||
@PostMapping("/collect")
|
|
||||||
@Operation(summary = "수동 수집", description = "스케줄과 동일한 피드 수집을 즉시 1회 실행")
|
|
||||||
public ApiResponse<Map<String, Object>> collect(
|
|
||||||
@RequestParam(defaultValue = "ENTERTAINMENT") String topic) {
|
|
||||||
return ApiResponse.ok(feedCollectionService.collect(FeedTopic.normalize(topic)));
|
|
||||||
}
|
|
||||||
|
|
||||||
@PostMapping("/refresh-views")
|
|
||||||
@Operation(summary = "소재 조회수 현행화", description = "저장된 피드 영상 조회수를 videos.list 50개 배치로 갱신한다")
|
|
||||||
public ApiResponse<Map<String, Object>> refreshViews(
|
|
||||||
@RequestParam(defaultValue = "ENTERTAINMENT") String topic) {
|
|
||||||
return ApiResponse.ok(feedCollectionService.refreshViews(FeedTopic.normalize(topic)));
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- 시드(소스/경쟁 채널) 관리 ---
|
|
||||||
|
|
||||||
@GetMapping("/seeds")
|
|
||||||
@Operation(summary = "시드 목록", description = "등록된 소재 원본·경쟁 채널")
|
|
||||||
public ApiResponse<List<FeedSeedDto>> seeds(
|
|
||||||
@RequestParam(defaultValue = "ENTERTAINMENT") String topic) {
|
|
||||||
return ApiResponse.ok(feedService.seeds(topic));
|
|
||||||
}
|
|
||||||
|
|
||||||
@PostMapping("/seeds")
|
|
||||||
@Operation(summary = "시드 등록", description = "채널 URL 을 소재 원본(SOURCE) 또는 경쟁(RIVAL) 시드로 등록")
|
|
||||||
public ApiResponse<FeedSeedDto> addSeed(@RequestBody Map<String, String> body) {
|
|
||||||
String url = body.get("url");
|
|
||||||
if (url == null || url.isBlank()) throw new IllegalArgumentException("채널 URL 이 필요합니다.");
|
|
||||||
return ApiResponse.created(feedService.addSeed(url, body.getOrDefault("role", ChannelRole.SOURCE),
|
|
||||||
body.getOrDefault("topic", FeedTopic.ENTERTAINMENT.name()),
|
|
||||||
body.getOrDefault("format", FeedFormat.LONG_FORM.name())));
|
|
||||||
}
|
|
||||||
|
|
||||||
@DeleteMapping("/seeds/{id}")
|
|
||||||
@Operation(summary = "시드 해제", description = "채널과 그 채널에서 수집한 피드 영상을 함께 제거")
|
|
||||||
public ApiResponse<Void> removeSeed(@PathVariable Long id) {
|
|
||||||
feedService.removeSeed(id);
|
|
||||||
return ApiResponse.ok(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
@PostMapping("/seeds/{id}/reset-failure")
|
|
||||||
@Operation(summary = "실패 카운터 초기화", description = "연속 실패로 자동 스킵 중인 시드를 다시 활성화")
|
|
||||||
public ApiResponse<Void> resetFailure(@PathVariable Long id) {
|
|
||||||
feedService.resetSeedFailure(id);
|
|
||||||
return ApiResponse.ok(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- 시드 자동 발굴(내 채널 역분석) ---
|
|
||||||
|
|
||||||
@GetMapping("/seeds/keywords")
|
|
||||||
@Operation(summary = "해시태그 분석", description = "내 채널 영상의 해시태그 빈도(쿼터 소비 없음)")
|
|
||||||
public ApiResponse<Map<String, Integer>> keywords() {
|
|
||||||
return ApiResponse.ok(seedSuggestService.analyzeKeywords());
|
|
||||||
}
|
|
||||||
|
|
||||||
@PostMapping("/seeds/suggest")
|
|
||||||
@Operation(summary = "소재 원본 후보 발굴",
|
|
||||||
description = "해시태그 상위 키워드로 공식채널을 검색해 추천 목록(roleHint=SOURCE)에 쌓는다. 키워드당 100 units 소비.")
|
|
||||||
public ApiResponse<Map<String, Object>> suggest(@RequestBody(required = false) Map<String, Object> body) {
|
|
||||||
@SuppressWarnings("unchecked")
|
|
||||||
List<String> keywords = body == null ? null : (List<String>) body.get("keywords");
|
|
||||||
return ApiResponse.ok(seedSuggestService.suggestSourceSeeds(keywords));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,28 +0,0 @@
|
|||||||
package com.hlab.yanalyst.domain.channel;
|
|
||||||
|
|
||||||
public enum FeedFormat {
|
|
||||||
LONG_FORM(600, null),
|
|
||||||
VIDEO(66, null),
|
|
||||||
SHORTS(null, 65),
|
|
||||||
ALL(null, null);
|
|
||||||
|
|
||||||
private final Integer minDurationSec;
|
|
||||||
private final Integer maxDurationSec;
|
|
||||||
|
|
||||||
FeedFormat(Integer minDurationSec, Integer maxDurationSec) {
|
|
||||||
this.minDurationSec = minDurationSec;
|
|
||||||
this.maxDurationSec = maxDurationSec;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Integer minDurationSec() { return minDurationSec; }
|
|
||||||
public Integer maxDurationSec() { return maxDurationSec; }
|
|
||||||
|
|
||||||
public static FeedFormat normalize(String value) {
|
|
||||||
if (value == null || value.isBlank()) return LONG_FORM;
|
|
||||||
try {
|
|
||||||
return valueOf(value.trim().toUpperCase());
|
|
||||||
} catch (IllegalArgumentException ignored) {
|
|
||||||
return LONG_FORM;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,222 +0,0 @@
|
|||||||
package com.hlab.yanalyst.domain.channel;
|
|
||||||
|
|
||||||
import com.hlab.yanalyst.domain.channel.dto.FeedItemDto;
|
|
||||||
import com.hlab.yanalyst.domain.channel.dto.FeedProgramDto;
|
|
||||||
import com.hlab.yanalyst.domain.channel.dto.FeedSeedDto;
|
|
||||||
import com.hlab.yanalyst.domain.channel.dto.TrackedPersonDto;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
|
||||||
import org.springframework.data.domain.PageRequest;
|
|
||||||
import org.springframework.stereotype.Service;
|
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.LinkedHashMap;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
|
|
||||||
/** 소재 발굴 피드 조회 + 시드(소스/경쟁 채널) · 추적 인물 관리. */
|
|
||||||
@Slf4j
|
|
||||||
@Service
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
@Transactional(readOnly = true)
|
|
||||||
public class FeedService {
|
|
||||||
|
|
||||||
/** 한 번에 내려줄 카드 수 상한. */
|
|
||||||
private static final int MAX_ITEMS = 200;
|
|
||||||
|
|
||||||
/** 인물 탭 식별자. SOURCE/RIVAL 과 달리 채널 역할이 아니라 조회 방식이 다르다. */
|
|
||||||
public static final String TAB_PERSON = "PERSON";
|
|
||||||
|
|
||||||
private final ChannelRepository channelRepository;
|
|
||||||
private final ChannelVideoRepository channelVideoRepository;
|
|
||||||
private final ChannelService channelService;
|
|
||||||
private final TrackedPersonRepository trackedPersonRepository;
|
|
||||||
|
|
||||||
@org.springframework.beans.factory.annotation.Value("${hlab.feed.person.max-people:10}")
|
|
||||||
private int maxPeople;
|
|
||||||
|
|
||||||
/** 수집 하한과 같은 값 — 이미 담겨 있는 더 짧은 영상도 화면에서 걸러 일관되게 보인다. */
|
|
||||||
@org.springframework.beans.factory.annotation.Value("${hlab.feed.min-duration-sec:600}")
|
|
||||||
private int minDurationSec;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 피드 카드 목록(항상 최신순).
|
|
||||||
*
|
|
||||||
* @param tab SOURCE | RIVAL
|
|
||||||
* @param days 최근 N일 (null 이면 제한 없음)
|
|
||||||
* @param ytChannelId 특정 프로그램만 (null/빈값이면 전체)
|
|
||||||
* @param lengthBucket CLIP | FULL | SHORTS (null 이면 전체)
|
|
||||||
* @param hideWorked 이미 손댄 소재 숨기기
|
|
||||||
*/
|
|
||||||
public List<FeedItemDto> feed(String tab, Integer days, String ytChannelId,
|
|
||||||
String lengthBucket, boolean hideWorked) {
|
|
||||||
return feed(tab, FeedTopic.ENTERTAINMENT.name(), days, ytChannelId, lengthBucket, hideWorked, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param person 인물 탭에서 특정 인물만 (null/빈값이면 전체)
|
|
||||||
*/
|
|
||||||
public List<FeedItemDto> feed(String tab, String topic, Integer days, String ytChannelId,
|
|
||||||
String lengthBucket, boolean hideWorked, String person) {
|
|
||||||
String source = normalizeTab(tab);
|
|
||||||
String normalizedTopic = FeedTopic.normalize(topic).name();
|
|
||||||
LocalDateTime now = LocalDateTime.now();
|
|
||||||
LocalDateTime publishedAfter = (days == null || days <= 0) ? null : now.minusDays(days);
|
|
||||||
String channelFilter = (ytChannelId == null || ytChannelId.isBlank()) ? null : ytChannelId;
|
|
||||||
|
|
||||||
if (TAB_PERSON.equals(source)) {
|
|
||||||
String personFilter = (person == null || person.isBlank()) ? null : person;
|
|
||||||
List<ChannelVideo> rows = channelVideoRepository.feedByPerson(
|
|
||||||
publishedAfter, personFilter, hideWorked, PageRequest.of(0, MAX_ITEMS));
|
|
||||||
List<FeedItemDto> out = new ArrayList<>(rows.size());
|
|
||||||
for (ChannelVideo v : rows) out.add(FeedItemDto.from(v, now));
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
Integer minSec = null, maxSec = null;
|
|
||||||
if (lengthBucket != null && !lengthBucket.isBlank()) {
|
|
||||||
switch (lengthBucket.trim().toUpperCase()) {
|
|
||||||
case FeedBadges.BUCKET_SHORTS -> maxSec = 65;
|
|
||||||
case FeedBadges.BUCKET_CLIP -> { minSec = 66; maxSec = FeedBadges.CLIP_MAX_SEC; }
|
|
||||||
case FeedBadges.BUCKET_FULL -> minSec = FeedBadges.CLIP_MAX_SEC + 1;
|
|
||||||
default -> { /* 미인식 값은 필터 없음 */ }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 수집 하한을 화면에도 적용 — 하한을 올렸을 때 과거에 담긴 짧은 영상이 남아 보이지 않게
|
|
||||||
int topicFloor = FeedTopic.POLITICS.name().equals(normalizedTopic) ? 66 : minDurationSec;
|
|
||||||
minSec = (minSec == null) ? Integer.valueOf(topicFloor) : Integer.valueOf(Math.max(minSec, topicFloor));
|
|
||||||
|
|
||||||
List<ChannelVideo> rows = channelVideoRepository.feed(
|
|
||||||
source, normalizedTopic, publishedAfter, channelFilter, minSec, maxSec, hideWorked,
|
|
||||||
PageRequest.of(0, MAX_ITEMS));
|
|
||||||
|
|
||||||
List<FeedItemDto> out = new ArrayList<>(rows.size());
|
|
||||||
for (ChannelVideo v : rows) out.add(FeedItemDto.from(v, now));
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 필터 드롭다운용 프로그램(채널) 목록. */
|
|
||||||
public List<FeedProgramDto> programs(String tab, String topic) {
|
|
||||||
String source = normalizeTab(tab);
|
|
||||||
String normalizedTopic = FeedTopic.normalize(topic).name();
|
|
||||||
List<FeedProgramDto> out = new ArrayList<>();
|
|
||||||
for (Object[] row : channelVideoRepository.feedPrograms(source, normalizedTopic)) {
|
|
||||||
out.add(new FeedProgramDto((String) row[0], (String) row[1], (Long) row[2]));
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 등록된 피드 시드 목록(소스 + 경쟁). */
|
|
||||||
public List<FeedSeedDto> seeds(String topic) {
|
|
||||||
FeedTopic normalizedTopic = FeedTopic.normalize(topic);
|
|
||||||
List<FeedSeedDto> out = new ArrayList<>();
|
|
||||||
for (Channel c : channelRepository.findFeedSeeds()) {
|
|
||||||
if (c.feedTopicOrDefault() != normalizedTopic) continue;
|
|
||||||
out.add(new FeedSeedDto(c.getId(), c.getChannelId(), c.getTitle(), c.getThumbnailUrl(),
|
|
||||||
c.getSubscriberCount(), c.roleOrDefault(), c.feedTopicOrDefault().name(),
|
|
||||||
c.feedFormatOrDefault().name(), c.feedFailCountOrZero(), c.isFeedDisabled()));
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* URL 로 시드 채널을 등록한다. 이미 등록된 채널이면 역할만 바꾼다.
|
|
||||||
*
|
|
||||||
* @param role SOURCE | RIVAL
|
|
||||||
*/
|
|
||||||
@Transactional
|
|
||||||
public FeedSeedDto addSeed(String url, String role, String topic, String format) {
|
|
||||||
String normalized = ChannelRole.normalize(role);
|
|
||||||
if (!ChannelRole.isFeed(normalized)) {
|
|
||||||
throw new IllegalArgumentException("시드 역할은 SOURCE 또는 RIVAL 이어야 합니다: " + role);
|
|
||||||
}
|
|
||||||
Channel channel = channelService.saveChannelFromUrl(url);
|
|
||||||
channel.changeRole(normalized);
|
|
||||||
channel.configureFeed(topic, format);
|
|
||||||
channel.resetFeedFailure();
|
|
||||||
channelRepository.save(channel);
|
|
||||||
return new FeedSeedDto(channel.getId(), channel.getChannelId(), channel.getTitle(),
|
|
||||||
channel.getThumbnailUrl(), channel.getSubscriberCount(), normalized,
|
|
||||||
channel.feedTopicOrDefault().name(), channel.feedFormatOrDefault().name(), 0, false);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 시드 해제 — 채널과 그 채널에서 수집한 피드 영상을 함께 제거한다. */
|
|
||||||
@Transactional
|
|
||||||
public void removeSeed(Long channelId) {
|
|
||||||
Channel c = channelService.getChannel(channelId);
|
|
||||||
if (!ChannelRole.isFeed(c.roleOrDefault())) {
|
|
||||||
throw new IllegalArgumentException("피드 시드가 아닙니다: " + channelId);
|
|
||||||
}
|
|
||||||
channelService.deleteChannel(channelId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 연속 실패로 자동 비활성화된 시드를 다시 활성화한다. */
|
|
||||||
@Transactional
|
|
||||||
public void resetSeedFailure(Long channelId) {
|
|
||||||
Channel c = channelService.getChannel(channelId);
|
|
||||||
c.resetFeedFailure();
|
|
||||||
channelRepository.save(c);
|
|
||||||
}
|
|
||||||
|
|
||||||
private String normalizeTab(String tab) {
|
|
||||||
String t = tab == null ? "" : tab.trim().toUpperCase();
|
|
||||||
if (ChannelRole.RIVAL.equals(t)) return ChannelRole.RIVAL;
|
|
||||||
if (TAB_PERSON.equals(t)) return TAB_PERSON;
|
|
||||||
return ChannelRole.SOURCE;
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- 인물 추적 ---
|
|
||||||
|
|
||||||
/** 인물 탭 필터용 목록: 추적 중인 인물 + 지금까지 담긴 영상 수. */
|
|
||||||
public List<TrackedPersonDto> persons() {
|
|
||||||
Map<String, Long> counts = new LinkedHashMap<>();
|
|
||||||
for (Object[] row : channelVideoRepository.feedPersons()) {
|
|
||||||
counts.put((String) row[0], (Long) row[1]);
|
|
||||||
}
|
|
||||||
List<TrackedPersonDto> out = new ArrayList<>();
|
|
||||||
for (TrackedPerson p : trackedPersonRepository.findAllByOrderByIdAsc()) {
|
|
||||||
out.add(new TrackedPersonDto(p.getId(), p.getName(), p.isEnabled(),
|
|
||||||
counts.getOrDefault(p.getName(), 0L), p.getLastCollectedAt(), p.getLastFound()));
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 추적 인물 추가. 활성 인원 상한을 넘으면 거절한다(1명당 검색 100 units). */
|
|
||||||
@Transactional
|
|
||||||
public TrackedPersonDto addPerson(String name) {
|
|
||||||
String trimmed = name == null ? "" : name.trim();
|
|
||||||
if (trimmed.isEmpty()) throw new IllegalArgumentException("인물 이름이 필요합니다.");
|
|
||||||
if (trackedPersonRepository.existsByName(trimmed)) {
|
|
||||||
throw new IllegalArgumentException("이미 추적 중인 인물입니다: " + trimmed);
|
|
||||||
}
|
|
||||||
if (trackedPersonRepository.countByEnabledTrue() >= maxPeople) {
|
|
||||||
throw new IllegalArgumentException(
|
|
||||||
"추적 인물은 최대 " + maxPeople + "명입니다. 1명당 검색 100 units 를 쓰기 때문입니다. "
|
|
||||||
+ "기존 인물을 끄거나 삭제한 뒤 추가하세요.");
|
|
||||||
}
|
|
||||||
TrackedPerson saved = trackedPersonRepository.save(new TrackedPerson(trimmed));
|
|
||||||
return new TrackedPersonDto(saved.getId(), saved.getName(), true, 0L, null, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 추적 on/off. 끄면 수집 대상에서 빠지지만 이미 담은 영상은 남는다. */
|
|
||||||
@Transactional
|
|
||||||
public void togglePerson(Long id, boolean enabled) {
|
|
||||||
TrackedPerson p = trackedPersonRepository.findById(id)
|
|
||||||
.orElseThrow(() -> new IllegalArgumentException("추적 인물을 찾을 수 없습니다: " + id));
|
|
||||||
if (enabled && !p.isEnabled() && trackedPersonRepository.countByEnabledTrue() >= maxPeople) {
|
|
||||||
throw new IllegalArgumentException("활성 인물이 이미 " + maxPeople + "명입니다.");
|
|
||||||
}
|
|
||||||
p.setEnabled(enabled);
|
|
||||||
trackedPersonRepository.save(p);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 추적 해제. 그 인물로 담은 영상의 표시만 지우고 영상 자체는 남긴다. */
|
|
||||||
@Transactional
|
|
||||||
public void removePerson(Long id) {
|
|
||||||
TrackedPerson p = trackedPersonRepository.findById(id)
|
|
||||||
.orElseThrow(() -> new IllegalArgumentException("추적 인물을 찾을 수 없습니다: " + id));
|
|
||||||
trackedPersonRepository.delete(p);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,16 +0,0 @@
|
|||||||
package com.hlab.yanalyst.domain.channel;
|
|
||||||
|
|
||||||
public enum FeedTopic {
|
|
||||||
ENTERTAINMENT,
|
|
||||||
POLITICS,
|
|
||||||
DISTRIBUTION;
|
|
||||||
|
|
||||||
public static FeedTopic normalize(String value) {
|
|
||||||
if (value == null || value.isBlank()) return ENTERTAINMENT;
|
|
||||||
try {
|
|
||||||
return valueOf(value.trim().toUpperCase());
|
|
||||||
} catch (IllegalArgumentException ignored) {
|
|
||||||
return ENTERTAINMENT;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,99 +0,0 @@
|
|||||||
package com.hlab.yanalyst.domain.channel;
|
|
||||||
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.Comparator;
|
|
||||||
import java.util.LinkedHashMap;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Locale;
|
|
||||||
import java.util.Map;
|
|
||||||
import java.util.Set;
|
|
||||||
import java.util.regex.Matcher;
|
|
||||||
import java.util.regex.Pattern;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 영상 설명/제목에서 해시태그를 뽑고 빈도를 집계하는 순수 로직.
|
|
||||||
*
|
|
||||||
* <p>내 채널 업로드의 해시태그(#유퀴즈 #핑계고 …)를 역분석해 소재 원본 채널 후보를 찾는 데 쓴다.
|
|
||||||
* 프로그램명이 아닌 범용 태그(#shorts, #쇼츠 …)는 불용어로 걸러낸다.
|
|
||||||
*/
|
|
||||||
public final class HashtagExtractor {
|
|
||||||
|
|
||||||
/** 한글/영문/숫자/밑줄로 이루어진 해시태그. 구두점·공백에서 끊는다. */
|
|
||||||
private static final Pattern TAG = Pattern.compile("#([\\p{L}\\p{N}_]+)");
|
|
||||||
|
|
||||||
/** 프로그램명 판별에 방해가 되는 범용 태그. 소문자 비교. */
|
|
||||||
private static final Set<String> STOP_WORDS = Set.of(
|
|
||||||
"shorts", "short", "youtubeshorts", "유튜브쇼츠", "쇼츠", "숏폼", "숏츠",
|
|
||||||
"명장면", "짤", "짤방", "레전드", "reels", "릴스", "틱톡", "tiktok",
|
|
||||||
"예능", "드라마", "영화", "도파민", "웃긴동영상", "시간순삭", "fyp", "viral", "recommended"
|
|
||||||
);
|
|
||||||
|
|
||||||
private HashtagExtractor() {}
|
|
||||||
|
|
||||||
/** 단일 텍스트에서 해시태그를 추출한다(원문 표기 유지, 중복 제거, 불용어 제외). */
|
|
||||||
public static List<String> extract(String text) {
|
|
||||||
List<String> out = new ArrayList<>();
|
|
||||||
if (text == null || text.isBlank()) return out;
|
|
||||||
|
|
||||||
Matcher m = TAG.matcher(text);
|
|
||||||
Set<String> seen = new java.util.HashSet<>();
|
|
||||||
while (m.find()) {
|
|
||||||
String tag = m.group(1);
|
|
||||||
if (tag.length() < 2) continue; // "#1" 같은 잡음 제외
|
|
||||||
String key = tag.toLowerCase(Locale.ROOT);
|
|
||||||
if (STOP_WORDS.contains(key)) continue;
|
|
||||||
if (seen.add(key)) out.add(tag);
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 해시태그 목록을 저장용 문자열(쉼표 구분)로 만든다. 없으면 null. */
|
|
||||||
public static String join(List<String> tags) {
|
|
||||||
if (tags == null || tags.isEmpty()) return null;
|
|
||||||
return String.join(",", tags);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 저장된 쉼표 구분 문자열을 다시 목록으로. */
|
|
||||||
public static List<String> split(String joined) {
|
|
||||||
List<String> out = new ArrayList<>();
|
|
||||||
if (joined == null || joined.isBlank()) return out;
|
|
||||||
for (String s : joined.split(",")) {
|
|
||||||
String t = s.trim();
|
|
||||||
if (!t.isEmpty()) out.add(t);
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 여러 텍스트에 걸친 해시태그 빈도를 집계한다.
|
|
||||||
*
|
|
||||||
* @param texts 영상 설명/제목 등. null 원소는 무시
|
|
||||||
* @param minCount 이 횟수 미만은 버린다(1회성 태그 제거)
|
|
||||||
* @return 빈도 내림차순(동률이면 태그 오름차순) 맵. 키는 처음 등장한 원문 표기
|
|
||||||
*/
|
|
||||||
public static Map<String, Integer> countAll(List<String> texts, int minCount) {
|
|
||||||
Map<String, Integer> counts = new LinkedHashMap<>(); // 소문자키 → 횟수
|
|
||||||
Map<String, String> display = new LinkedHashMap<>(); // 소문자키 → 원문 표기
|
|
||||||
|
|
||||||
if (texts != null) {
|
|
||||||
for (String text : texts) {
|
|
||||||
for (String tag : extract(text)) {
|
|
||||||
String key = tag.toLowerCase(Locale.ROOT);
|
|
||||||
counts.merge(key, 1, Integer::sum);
|
|
||||||
display.putIfAbsent(key, tag);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
List<Map.Entry<String, Integer>> entries = new ArrayList<>(counts.entrySet());
|
|
||||||
entries.sort(Comparator.<Map.Entry<String, Integer>>comparingInt(Map.Entry::getValue).reversed()
|
|
||||||
.thenComparing(Map.Entry::getKey));
|
|
||||||
|
|
||||||
Map<String, Integer> out = new LinkedHashMap<>();
|
|
||||||
for (Map.Entry<String, Integer> e : entries) {
|
|
||||||
if (e.getValue() < minCount) continue;
|
|
||||||
out.put(display.get(e.getKey()), e.getValue());
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,88 +0,0 @@
|
|||||||
package com.hlab.yanalyst.domain.channel;
|
|
||||||
|
|
||||||
import com.hlab.yanalyst.domain.channel.dto.MyChannelSummaryDto;
|
|
||||||
import com.hlab.yanalyst.domain.channel.dto.YoutubeChannelAnalyticsDto;
|
|
||||||
import com.hlab.yanalyst.global.common.ApiResponse;
|
|
||||||
import io.swagger.v3.oas.annotations.Operation;
|
|
||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import org.springframework.web.bind.annotation.*;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
import java.time.LocalDate;
|
|
||||||
|
|
||||||
/** 내 채널(OWN) 분석 API. 소재 파이프라인과 완전히 분리된 성과 추적용. */
|
|
||||||
@RestController
|
|
||||||
@RequestMapping("/api/my-channel")
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
@Tag(name = "My Channel API", description = "내 채널 성과 분석(소재별 랭킹 포함)")
|
|
||||||
public class MyChannelController {
|
|
||||||
|
|
||||||
private final MyChannelService myChannelService;
|
|
||||||
private final YoutubeChannelAnalyticsService youtubeChannelAnalyticsService;
|
|
||||||
|
|
||||||
@GetMapping
|
|
||||||
@Operation(summary = "내 채널 요약", description = "등록 안 돼 있으면 registered=false")
|
|
||||||
public ApiResponse<MyChannelSummaryDto> summary() {
|
|
||||||
return ApiResponse.ok(myChannelService.summary());
|
|
||||||
}
|
|
||||||
|
|
||||||
@GetMapping("/topics")
|
|
||||||
@Operation(summary = "소재별 성과", description = "해시태그별 평균 조회수 랭킹(2편 이상). index=채널 평균 대비 배수")
|
|
||||||
public ApiResponse<List<TopicPerformance.Topic>> topics() {
|
|
||||||
return ApiResponse.ok(myChannelService.topics());
|
|
||||||
}
|
|
||||||
|
|
||||||
@GetMapping("/videos")
|
|
||||||
@Operation(summary = "내 영상 목록", description = "업로드 최신순")
|
|
||||||
public ApiResponse<List<ChannelVideo>> videos() {
|
|
||||||
return ApiResponse.ok(myChannelService.videos());
|
|
||||||
}
|
|
||||||
|
|
||||||
@GetMapping("/growth")
|
|
||||||
@Operation(summary = "성장 추이", description = "일별 구독자/조회수 스냅샷(오래된 순)")
|
|
||||||
public ApiResponse<List<ChannelSnapshot>> growth() {
|
|
||||||
return ApiResponse.ok(myChannelService.growth());
|
|
||||||
}
|
|
||||||
|
|
||||||
@GetMapping("/analytics")
|
|
||||||
@Operation(summary = "OAuth 내 채널 기간 분석", description = "연결한 채널의 일별 추이, 영상별 성과, 업로드 리듬을 반환한다.")
|
|
||||||
public ApiResponse<YoutubeChannelAnalyticsDto> analytics(
|
|
||||||
@RequestParam Long connectionId,
|
|
||||||
@RequestParam LocalDate startDate,
|
|
||||||
@RequestParam LocalDate endDate) {
|
|
||||||
return ApiResponse.ok(youtubeChannelAnalyticsService.analyze(connectionId, startDate, endDate));
|
|
||||||
}
|
|
||||||
|
|
||||||
@GetMapping("/analytics/videos/{videoId}/timeline")
|
|
||||||
@Operation(summary = "내 영상 일별 추이", description = "선택 영상의 기간 내 일별 조회수와 구독자 증감 추이를 반환한다.")
|
|
||||||
public ApiResponse<List<YoutubeChannelAnalyticsDto.DailyDto>> videoTimeline(
|
|
||||||
@RequestParam Long connectionId,
|
|
||||||
@PathVariable String videoId,
|
|
||||||
@RequestParam LocalDate startDate,
|
|
||||||
@RequestParam LocalDate endDate) {
|
|
||||||
return ApiResponse.ok(youtubeChannelAnalyticsService.videoTimeline(connectionId, videoId, startDate, endDate));
|
|
||||||
}
|
|
||||||
|
|
||||||
@PostMapping
|
|
||||||
@Operation(summary = "내 채널 등록", description = "채널 URL 로 등록하고 즉시 영상을 수집한다. 이미 있으면 교체.")
|
|
||||||
public ApiResponse<MyChannelSummaryDto> register(@RequestBody Map<String, String> body) {
|
|
||||||
String url = body.get("url");
|
|
||||||
if (url == null || url.isBlank()) throw new IllegalArgumentException("채널 URL 이 필요합니다.");
|
|
||||||
return ApiResponse.created(myChannelService.register(url));
|
|
||||||
}
|
|
||||||
|
|
||||||
@PostMapping("/sync")
|
|
||||||
@Operation(summary = "영상 재동기화", description = "내 채널 영상과 조회수를 다시 받아온다")
|
|
||||||
public ApiResponse<MyChannelSummaryDto> sync() {
|
|
||||||
return ApiResponse.ok(myChannelService.sync());
|
|
||||||
}
|
|
||||||
|
|
||||||
@DeleteMapping
|
|
||||||
@Operation(summary = "등록 해제", description = "내 채널과 수집된 영상을 함께 제거")
|
|
||||||
public ApiResponse<Void> unregister() {
|
|
||||||
myChannelService.unregister();
|
|
||||||
return ApiResponse.ok(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,125 +0,0 @@
|
|||||||
package com.hlab.yanalyst.domain.channel;
|
|
||||||
|
|
||||||
import com.hlab.yanalyst.domain.channel.dto.MyChannelSummaryDto;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
|
||||||
import org.springframework.stereotype.Service;
|
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.Comparator;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Optional;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 내 채널(OWN) 분석.
|
|
||||||
*
|
|
||||||
* <p>내가 이미 발행한 영상은 소재가 아니라 성과다. 수집함·칸반·발굴·피드 어디에도 섞이지 않도록
|
|
||||||
* 출처를 OWN 으로 분리해 두고(자세한 건 {@link ChannelRole}), 이 화면에서만 다룬다.
|
|
||||||
*/
|
|
||||||
@Slf4j
|
|
||||||
@Service
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
@Transactional(readOnly = true)
|
|
||||||
public class MyChannelService {
|
|
||||||
|
|
||||||
/** 랭킹에 올릴 소재의 최소 편수. 1편짜리 우연을 상위에 띄우지 않기 위함. */
|
|
||||||
private static final int MIN_TOPIC_VIDEOS = 2;
|
|
||||||
|
|
||||||
private final ChannelRepository channelRepository;
|
|
||||||
private final ChannelVideoRepository channelVideoRepository;
|
|
||||||
private final ChannelSnapshotRepository channelSnapshotRepository;
|
|
||||||
private final ChannelService channelService;
|
|
||||||
|
|
||||||
/** 등록된 내 채널. 없으면 empty. */
|
|
||||||
public Optional<Channel> findOwnChannel() {
|
|
||||||
List<Channel> own = channelRepository.findOwnChannels();
|
|
||||||
return own.isEmpty() ? Optional.empty() : Optional.of(own.get(0));
|
|
||||||
}
|
|
||||||
|
|
||||||
public MyChannelSummaryDto summary() {
|
|
||||||
Channel c = findOwnChannel().orElse(null);
|
|
||||||
if (c == null) return MyChannelSummaryDto.empty();
|
|
||||||
|
|
||||||
List<ChannelVideo> videos = channelVideoRepository.findByChannelId(c.getId());
|
|
||||||
long avg = TopicPerformance.channelAverage(toEntries(videos));
|
|
||||||
|
|
||||||
LocalDateTime since = LocalDateTime.now().minusDays(30);
|
|
||||||
long last30 = videos.stream()
|
|
||||||
.filter(v -> v.getPublishedAt() != null && v.getPublishedAt().isAfter(since))
|
|
||||||
.mapToLong(v -> v.getViewCount() == null ? 0 : v.getViewCount())
|
|
||||||
.sum();
|
|
||||||
|
|
||||||
return new MyChannelSummaryDto(true, c.getId(), c.getChannelId(), c.getTitle(), c.getThumbnailUrl(),
|
|
||||||
c.getSubscriberCount(), c.getViewCount(), c.getVideoCount(), videos.size(), avg, last30);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 해시태그(소재)별 성과 랭킹. */
|
|
||||||
public List<TopicPerformance.Topic> topics() {
|
|
||||||
Channel c = findOwnChannel().orElse(null);
|
|
||||||
if (c == null) return List.of();
|
|
||||||
return TopicPerformance.aggregate(toEntries(channelVideoRepository.findByChannelId(c.getId())),
|
|
||||||
MIN_TOPIC_VIDEOS);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 내 영상 목록(최신순). */
|
|
||||||
public List<ChannelVideo> videos() {
|
|
||||||
Channel c = findOwnChannel().orElse(null);
|
|
||||||
if (c == null) return List.of();
|
|
||||||
List<ChannelVideo> list = new ArrayList<>(channelVideoRepository.findByChannelId(c.getId()));
|
|
||||||
list.sort(Comparator.comparing(ChannelVideo::getPublishedAt,
|
|
||||||
Comparator.nullsLast(Comparator.reverseOrder())));
|
|
||||||
return list;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 성장 추이(일별 스냅샷, 오래된 순). */
|
|
||||||
public List<ChannelSnapshot> growth() {
|
|
||||||
Channel c = findOwnChannel().orElse(null);
|
|
||||||
if (c == null) return List.of();
|
|
||||||
return channelSnapshotRepository.findByChannelIdOrderBySnapshotDateAsc(c.getId());
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 내 채널을 등록한다. 이미 등록돼 있으면 교체한다(내 채널은 하나만 둔다).
|
|
||||||
* 등록 즉시 영상을 수집해 분석할 데이터를 채운다.
|
|
||||||
*/
|
|
||||||
@Transactional
|
|
||||||
public MyChannelSummaryDto register(String url) {
|
|
||||||
findOwnChannel().ifPresent(prev -> {
|
|
||||||
log.info("[MyChannel] 기존 내 채널 {} 을(를) 교체합니다", prev.getTitle());
|
|
||||||
channelService.deleteChannel(prev.getId());
|
|
||||||
});
|
|
||||||
|
|
||||||
Channel channel = channelService.saveChannelFromUrl(url);
|
|
||||||
channel.changeRole(ChannelRole.OWN);
|
|
||||||
channelRepository.save(channel);
|
|
||||||
channelService.collectChannelVideos(channel.getId());
|
|
||||||
return summary();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 내 채널 영상을 다시 동기화한다. */
|
|
||||||
@Transactional
|
|
||||||
public MyChannelSummaryDto sync() {
|
|
||||||
Channel c = findOwnChannel()
|
|
||||||
.orElseThrow(() -> new IllegalArgumentException("등록된 내 채널이 없습니다."));
|
|
||||||
channelService.collectChannelVideos(c.getId());
|
|
||||||
return summary();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 내 채널 등록을 해제하고 수집된 영상도 함께 지운다. */
|
|
||||||
@Transactional
|
|
||||||
public void unregister() {
|
|
||||||
Channel c = findOwnChannel()
|
|
||||||
.orElseThrow(() -> new IllegalArgumentException("등록된 내 채널이 없습니다."));
|
|
||||||
channelService.deleteChannel(c.getId());
|
|
||||||
}
|
|
||||||
|
|
||||||
private List<TopicPerformance.Entry> toEntries(List<ChannelVideo> videos) {
|
|
||||||
List<TopicPerformance.Entry> out = new ArrayList<>(videos.size());
|
|
||||||
for (ChannelVideo v : videos) {
|
|
||||||
out.add(new TopicPerformance.Entry(v.getHashtags(), v.getViewCount(), v.getTitle(), v.getVideoId()));
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,235 +0,0 @@
|
|||||||
package com.hlab.yanalyst.domain.channel;
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.JsonNode;
|
|
||||||
import com.hlab.yanalyst.global.schedule.YoutubeQuotaGuard;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
|
||||||
import org.springframework.scheduling.annotation.Scheduled;
|
|
||||||
import org.springframework.stereotype.Service;
|
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
|
||||||
import org.springframework.web.client.RestTemplate;
|
|
||||||
import org.springframework.web.util.UriComponentsBuilder;
|
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
|
||||||
import java.net.URI;
|
|
||||||
import java.time.LocalDateTime;
|
|
||||||
import java.time.ZoneOffset;
|
|
||||||
import java.time.format.DateTimeFormatter;
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.HashSet;
|
|
||||||
import java.util.LinkedHashMap;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
import java.util.Set;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 인물 추적 수집기.
|
|
||||||
*
|
|
||||||
* <p>인물은 프로그램을 옮겨 다닌다. 채널 시드만으로는 "윤경호가 KBS 시상식에 나온 영상"을
|
|
||||||
* 절대 못 잡는데, 인물명으로 검색하면 잡힌다. 다만 1명당 검색 1회(100 units)라 비싸서
|
|
||||||
* 하루 1회만 돌고 활성 인원을 제한한다.
|
|
||||||
*/
|
|
||||||
@Slf4j
|
|
||||||
@Service
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
public class PersonCollectionService {
|
|
||||||
|
|
||||||
/** search.list 1회 추정 쿼터. videos.list 1회(1 unit)가 더해진다. */
|
|
||||||
private static final long SEARCH_QUOTA = 100;
|
|
||||||
|
|
||||||
/** 검색 1회에 받아올 후보 수. 같은 쿼터라면 많이 받는 게 이득(대부분 쇼츠라 걸러진다). */
|
|
||||||
private static final int SAMPLE = 50;
|
|
||||||
|
|
||||||
private final TrackedPersonRepository personRepository;
|
|
||||||
private final ChannelVideoRepository channelVideoRepository;
|
|
||||||
private final ChannelRepository channelRepository;
|
|
||||||
private final YoutubeQuotaGuard quotaGuard;
|
|
||||||
private final RestTemplate restTemplate;
|
|
||||||
private final FeedAlertService feedAlertService;
|
|
||||||
|
|
||||||
@Value("${youtube.api.key}")
|
|
||||||
private String youtubeApiKey;
|
|
||||||
|
|
||||||
@Value("${hlab.feed.person.enabled:true}")
|
|
||||||
private boolean enabled;
|
|
||||||
|
|
||||||
@Value("${hlab.feed.person.period-days:14}")
|
|
||||||
private int periodDays;
|
|
||||||
|
|
||||||
@Value("${hlab.feed.person.max-people:10}")
|
|
||||||
private int maxPeople;
|
|
||||||
|
|
||||||
/** 시간당 조회수 하한 — 팬채널·커버곡 같은 노이즈를 거른다. */
|
|
||||||
@Value("${hlab.feed.person.min-views-per-hour:20}")
|
|
||||||
private double minViewsPerHour;
|
|
||||||
|
|
||||||
@Scheduled(cron = "${hlab.feed.person.cron:0 45 5 * * *}")
|
|
||||||
public void scheduledCollect() {
|
|
||||||
if (!enabled) {
|
|
||||||
log.info("[Person] 인물 추적 비활성화됨 (hlab.feed.person.enabled=false)");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
log.info("[Person] 자동 수집 완료: {}", collectAll());
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 활성 인물 전체를 수집한다. */
|
|
||||||
public Map<String, Object> collectAll() {
|
|
||||||
List<TrackedPerson> people = personRepository.findByEnabledTrueOrderByIdAsc();
|
|
||||||
if (people.size() > maxPeople) people = people.subList(0, maxPeople);
|
|
||||||
|
|
||||||
LocalDateTime publishedAfter = LocalDateTime.now().minusDays(periodDays);
|
|
||||||
Set<String> exclude = excludedChannelIds();
|
|
||||||
|
|
||||||
int searched = 0, saved = 0, skippedByQuota = 0, failed = 0;
|
|
||||||
List<String> names = new ArrayList<>();
|
|
||||||
List<ChannelVideo> newInserts = new ArrayList<>();
|
|
||||||
|
|
||||||
for (TrackedPerson p : people) {
|
|
||||||
if (!quotaGuard.tryConsume(SEARCH_QUOTA + 1)) {
|
|
||||||
skippedByQuota++;
|
|
||||||
log.warn("[Person] 쿼터 예산 소진 — '{}' 이후 중단 (잔여 {})", p.getName(), quotaGuard.remaining());
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
int n = collectOne(p.getName(), exclude, publishedAfter, newInserts);
|
|
||||||
saved += n;
|
|
||||||
searched++;
|
|
||||||
names.add(p.getName());
|
|
||||||
recordCollection(p.getId(), n);
|
|
||||||
} catch (Exception e) {
|
|
||||||
failed++;
|
|
||||||
log.error("[Person] '{}' 수집 실패 — 건너뜀", p.getName(), e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
int goldenAlerted = feedAlertService.notifyGoldenTime("인물 추적", newInserts);
|
|
||||||
|
|
||||||
Map<String, Object> summary = new LinkedHashMap<>();
|
|
||||||
summary.put("people", names);
|
|
||||||
summary.put("searched", searched);
|
|
||||||
summary.put("savedVideos", saved);
|
|
||||||
summary.put("failed", failed);
|
|
||||||
summary.put("skippedByQuota", skippedByQuota);
|
|
||||||
summary.put("goldenAlerted", goldenAlerted);
|
|
||||||
summary.put("quotaRemaining", quotaGuard.remaining());
|
|
||||||
return summary;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 인물 1명 수집.
|
|
||||||
*
|
|
||||||
* @param newInsertsOut null 이 아니면 새로 insert 된 영상을 여기 담아준다(골든타임 알림용)
|
|
||||||
* @return 새로 담거나 갱신한 롱폼 수
|
|
||||||
*/
|
|
||||||
@Transactional
|
|
||||||
public int collectOne(String person, Set<String> exclude, LocalDateTime publishedAfter,
|
|
||||||
List<ChannelVideo> newInsertsOut) {
|
|
||||||
String after = publishedAfter.atOffset(ZoneOffset.UTC).format(DateTimeFormatter.ISO_INSTANT);
|
|
||||||
|
|
||||||
URI searchUri = UriComponentsBuilder.fromHttpUrl("https://www.googleapis.com/youtube/v3/search")
|
|
||||||
.queryParam("part", "snippet")
|
|
||||||
.queryParam("type", "video")
|
|
||||||
.queryParam("q", person)
|
|
||||||
.queryParam("order", "date") // 최신순 — 선점이 목적
|
|
||||||
.queryParam("publishedAfter", after)
|
|
||||||
.queryParam("regionCode", "KR")
|
|
||||||
.queryParam("relevanceLanguage", "ko")
|
|
||||||
.queryParam("maxResults", SAMPLE)
|
|
||||||
.queryParam("key", youtubeApiKey)
|
|
||||||
.build().encode().toUri(); // String 으로 넘기면 이중 인코딩된다
|
|
||||||
|
|
||||||
JsonNode root = restTemplate.getForObject(searchUri, JsonNode.class);
|
|
||||||
if (root == null) return 0;
|
|
||||||
|
|
||||||
List<String> videoIds = new ArrayList<>();
|
|
||||||
for (JsonNode item : root.path("items")) {
|
|
||||||
String id = item.path("id").path("videoId").asText(null);
|
|
||||||
if (id != null && !id.isBlank()) videoIds.add(id);
|
|
||||||
}
|
|
||||||
if (videoIds.isEmpty()) return 0;
|
|
||||||
|
|
||||||
Map<String, Boolean> embeddable = new LinkedHashMap<>();
|
|
||||||
List<PersonPicks.Found> found = fetchDetails(videoIds, embeddable);
|
|
||||||
List<PersonPicks.Found> picks = PersonPicks.keep(found, exclude, publishedAfter, minViewsPerHour);
|
|
||||||
|
|
||||||
int saved = 0;
|
|
||||||
for (PersonPicks.Found f : picks) {
|
|
||||||
BigDecimal vph = VideoMetrics.viewsPerHour(f.viewCount(), f.publishedAt());
|
|
||||||
channelVideoRepository.findByVideoId(f.videoId())
|
|
||||||
.ifPresentOrElse(v -> {
|
|
||||||
// 이미 있는 영상이면 출처는 건드리지 않는다. 소스 탭에서 사라지면 안 되므로
|
|
||||||
// 인물명만 덧붙여 두 탭 모두에 나타나게 한다.
|
|
||||||
v.update(f.title(), v.getThumbnailUrl(), f.viewCount(), v.getLikeCount());
|
|
||||||
v.applyMetrics(f.durationSec(), VideoMetrics.isShorts(f.durationSec()), vph);
|
|
||||||
v.applyMatchedPerson(person);
|
|
||||||
v.applyEmbeddable(embeddable.get(f.videoId()));
|
|
||||||
channelVideoRepository.save(v);
|
|
||||||
}, () -> {
|
|
||||||
ChannelVideo nv = ChannelVideo.fromPersonSearch(
|
|
||||||
f.videoId(), f.title(), thumbnailOf(f.videoId()), f.publishedAt(), f.viewCount(),
|
|
||||||
f.ytChannelId(), f.channelTitle(), f.durationSec(), vph, null, person);
|
|
||||||
nv.applyEmbeddable(embeddable.get(f.videoId()));
|
|
||||||
channelVideoRepository.save(nv);
|
|
||||||
if (newInsertsOut != null) newInsertsOut.add(nv);
|
|
||||||
});
|
|
||||||
saved++;
|
|
||||||
}
|
|
||||||
return saved;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** videos.list 로 길이·조회수·업로드일을 채운다(검색 결과에는 길이가 없다). */
|
|
||||||
private List<PersonPicks.Found> fetchDetails(List<String> videoIds, Map<String, Boolean> embeddableOut) {
|
|
||||||
URI uri = UriComponentsBuilder.fromHttpUrl("https://www.googleapis.com/youtube/v3/videos")
|
|
||||||
.queryParam("part", "snippet,contentDetails,statistics,status")
|
|
||||||
.queryParam("id", String.join(",", videoIds))
|
|
||||||
.queryParam("key", youtubeApiKey)
|
|
||||||
.build().encode().toUri();
|
|
||||||
|
|
||||||
JsonNode root = restTemplate.getForObject(uri, JsonNode.class);
|
|
||||||
List<PersonPicks.Found> out = new ArrayList<>();
|
|
||||||
if (root == null) return out;
|
|
||||||
|
|
||||||
for (JsonNode item : root.path("items")) {
|
|
||||||
try {
|
|
||||||
JsonNode snippet = item.path("snippet");
|
|
||||||
Integer durationSec = VideoMetrics.parseDurationSec(
|
|
||||||
item.path("contentDetails").path("duration").asText(null));
|
|
||||||
LocalDateTime publishedAt = LocalDateTime.parse(
|
|
||||||
snippet.path("publishedAt").asText(), DateTimeFormatter.ISO_DATE_TIME);
|
|
||||||
long views = item.path("statistics").path("viewCount").asLong(0);
|
|
||||||
if (item.path("status").has("embeddable")) {
|
|
||||||
embeddableOut.put(item.path("id").asText(), item.path("status").path("embeddable").asBoolean());
|
|
||||||
}
|
|
||||||
out.add(new PersonPicks.Found(
|
|
||||||
item.path("id").asText(), snippet.path("title").asText(""),
|
|
||||||
snippet.path("channelId").asText(null), snippet.path("channelTitle").asText(""),
|
|
||||||
durationSec, publishedAt, views));
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.debug("[Person] 영상 상세 파싱 실패 — 건너뜀", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 내 채널(OWN)과 경쟁 채널(RIVAL)의 영상은 소재가 아니므로 인물 결과에서 뺀다. */
|
|
||||||
private Set<String> excludedChannelIds() {
|
|
||||||
Set<String> out = new HashSet<>();
|
|
||||||
for (Channel c : channelRepository.findOwnChannels()) out.add(c.getChannelId());
|
|
||||||
for (Channel c : channelRepository.findByRole(ChannelRole.RIVAL)) out.add(c.getChannelId());
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 검색 스니펫 썸네일 대신 표준 URL 을 쓴다(해상도 일관성). */
|
|
||||||
private String thumbnailOf(String videoId) {
|
|
||||||
return "https://i.ytimg.com/vi/" + videoId + "/hqdefault.jpg";
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 같은 빈 안에서 호출되므로 @Transactional 프록시가 안 걸린다 — 명시적으로 save 한다. */
|
|
||||||
private void recordCollection(Long personId, int found) {
|
|
||||||
personRepository.findById(personId).ifPresent(p -> {
|
|
||||||
p.recordCollection(found);
|
|
||||||
personRepository.save(p);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,92 +0,0 @@
|
|||||||
package com.hlab.yanalyst.domain.channel;
|
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Set;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 인물 검색 결과에서 실제로 담을 영상을 고르는 순수 로직.
|
|
||||||
*
|
|
||||||
* <p>검색은 최신순 50건을 주는데 대부분이 쇼츠다. 롱폼만 남기고, 내 채널·경쟁 채널 것은
|
|
||||||
* 소재가 아니므로 뺀다.
|
|
||||||
*/
|
|
||||||
public final class PersonPicks {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 제목에 이게 들어가면 버린다. 아이돌 이름으로 검색하면 음악방송 직캠·무대·교차편집이
|
|
||||||
* 결과를 뒤덮어 정작 예능 출연분이 묻힌다. 말로 터지는 순간이 소재이므로 무대 영상은 소용없다.
|
|
||||||
*/
|
|
||||||
private static final List<String> STAGE_MARKERS = List.of(
|
|
||||||
"직캠", "페이스캠", "풀캠", "fancam", "focus cam",
|
|
||||||
"교차편집", "stage mix", "stagemix", "무대교차",
|
|
||||||
"음중", "쇼챔", "엠카", "뮤직뱅크", "인기가요", "music bank", "show champion",
|
|
||||||
"performance video", "mv", "m/v", "lyric video", "cover", "커버"
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 이 길이 이하는 쇼츠로 보고 버린다. 유튜브 쇼츠가 최대 3분(180초)까지 허용되어
|
|
||||||
* {@link VideoMetrics#isShorts}의 65초 컷으로는 1~3분짜리 쇼츠가 통과한다.
|
|
||||||
* 어차피 3분 이하는 자를 원본으로도 부족하다.
|
|
||||||
*/
|
|
||||||
public static final int SHORTS_MAX_SEC = 180;
|
|
||||||
|
|
||||||
private PersonPicks() {}
|
|
||||||
|
|
||||||
/** 음악방송 무대·직캠·커버 영상인가(제목 기준). */
|
|
||||||
public static boolean isStageVideo(String title) {
|
|
||||||
if (title == null || title.isBlank()) return false;
|
|
||||||
String t = title.toLowerCase(java.util.Locale.ROOT);
|
|
||||||
for (String marker : STAGE_MARKERS) {
|
|
||||||
if (t.contains(marker)) return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 검색 결과 1건.
|
|
||||||
*
|
|
||||||
* @param ytChannelId 업로드 채널. 내 채널/경쟁 채널 제외 판정에 쓴다
|
|
||||||
*/
|
|
||||||
public record Found(String videoId, String title, String ytChannelId, String channelTitle,
|
|
||||||
Integer durationSec, LocalDateTime publishedAt, Long viewCount) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 담을 영상만 고른다.
|
|
||||||
*
|
|
||||||
* @param found 검색 결과
|
|
||||||
* @param excludeChannels 제외할 채널 ID(내 채널 OWN, 경쟁 채널 RIVAL)
|
|
||||||
* @param publishedAfter 이 시각 이전 업로드는 제외(null 이면 제한 없음)
|
|
||||||
* @param minViewsPerHour 시간당 조회수 하한. 인물명 검색에는 팬채널·커버곡·개인 브이로그가
|
|
||||||
* 대량으로 딸려 오는데(실측 조회수 30회 미만), 생 조회수로 자르면 방금 올라온
|
|
||||||
* 영상까지 걸리므로 시간당 조회수로 거른다. 0 이하면 미적용
|
|
||||||
* @return 롱폼이면서 무대 영상이 아니고 제외 대상도 아닌 것들 (입력 순서 유지)
|
|
||||||
*/
|
|
||||||
public static List<Found> keep(List<Found> found, Set<String> excludeChannels,
|
|
||||||
LocalDateTime publishedAfter, double minViewsPerHour) {
|
|
||||||
List<Found> out = new ArrayList<>();
|
|
||||||
if (found == null) return out;
|
|
||||||
|
|
||||||
for (Found f : found) {
|
|
||||||
if (f == null || f.videoId() == null || f.videoId().isBlank()) continue;
|
|
||||||
// 쇼츠 제외 — 인물 추적의 목적은 자를 원본(롱폼)을 찾는 것
|
|
||||||
if (f.durationSec() == null || f.durationSec() <= SHORTS_MAX_SEC) continue;
|
|
||||||
if (f.ytChannelId() != null && excludeChannels != null
|
|
||||||
&& excludeChannels.contains(f.ytChannelId())) continue;
|
|
||||||
if (publishedAfter != null && f.publishedAt() != null
|
|
||||||
&& f.publishedAt().isBefore(publishedAfter)) continue;
|
|
||||||
if (isStageVideo(f.title())) continue;
|
|
||||||
if (minViewsPerHour > 0 && !meetsVelocity(f, minViewsPerHour)) continue;
|
|
||||||
out.add(f);
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 시간당 조회수가 하한 이상인가. 계산에 필요한 값이 없으면 통과시킨다(과도한 배제 방지). */
|
|
||||||
private static boolean meetsVelocity(Found f, double minViewsPerHour) {
|
|
||||||
if (f.viewCount() == null || f.publishedAt() == null) return true;
|
|
||||||
java.math.BigDecimal vph = VideoMetrics.viewsPerHour(f.viewCount(), f.publishedAt());
|
|
||||||
if (vph == null) return true;
|
|
||||||
return vph.doubleValue() >= minViewsPerHour;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -43,16 +43,6 @@ public class RecommendedChannel {
|
|||||||
|
|
||||||
private String region; // 발견 지역(KR/JP/US)
|
private String region; // 발견 지역(KR/JP/US)
|
||||||
|
|
||||||
/**
|
|
||||||
* 승인 시 부여할 역할 힌트: SOURCE(소재 원본 웹예능) | RIVAL(경쟁 쇼츠) | null(기존 떡상 발굴 = 내 채널 후보).
|
|
||||||
* 시드 역분석으로 찾은 후보인지, 기존 떡상 발굴로 찾은 후보인지 구분한다.
|
|
||||||
*/
|
|
||||||
@Column(length = 10)
|
|
||||||
private String roleHint;
|
|
||||||
|
|
||||||
/** 이 후보를 찾아낸 근거 키워드(내 채널 해시태그). */
|
|
||||||
private String seedKeyword;
|
|
||||||
|
|
||||||
@CreationTimestamp
|
@CreationTimestamp
|
||||||
@Column(updatable = false)
|
@Column(updatable = false)
|
||||||
private LocalDateTime discoveredAt;
|
private LocalDateTime discoveredAt;
|
||||||
|
|||||||
@ -7,7 +7,6 @@ import lombok.RequiredArgsConstructor;
|
|||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
@ -20,35 +19,20 @@ public class RecommendedChannelController {
|
|||||||
private final RecommendedChannelRepository repository;
|
private final RecommendedChannelRepository repository;
|
||||||
private final ChannelDiscoveryService discoveryService;
|
private final ChannelDiscoveryService discoveryService;
|
||||||
private final ChannelService channelService;
|
private final ChannelService channelService;
|
||||||
private final ChannelRepository channelRepository;
|
|
||||||
private final ChannelVideoRepository channelVideoRepository;
|
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
@Operation(summary = "추천 채널 목록",
|
@Operation(summary = "추천 채널 목록", description = "status(NEW|EXCLUDED|REGISTERED, 기본 NEW)를 배율 내림차순으로 반환")
|
||||||
description = "status(NEW|EXCLUDED|REGISTERED, 기본 NEW)를 배율 내림차순으로 반환. roleHint 로 시드 후보만 좁힐 수 있다.")
|
public ApiResponse<List<RecommendedChannel>> list(@RequestParam(defaultValue = "NEW") String status) {
|
||||||
public ApiResponse<List<RecommendedChannel>> list(@RequestParam(defaultValue = "NEW") String status,
|
|
||||||
@RequestParam(required = false) String roleHint) {
|
|
||||||
if (roleHint != null && !roleHint.isBlank()) {
|
|
||||||
return ApiResponse.ok(repository.findByStatusAndRoleHintOrderBySubscriberCountDesc(
|
|
||||||
status, ChannelRole.normalize(roleHint)));
|
|
||||||
}
|
|
||||||
return ApiResponse.ok(repository.findByStatusOrderByRatioDesc(status));
|
return ApiResponse.ok(repository.findByStatusOrderByRatioDesc(status));
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/{id}/register")
|
@PostMapping("/{id}/register")
|
||||||
@Operation(summary = "채널 등록",
|
@Operation(summary = "내 채널 등록", description = "추천 채널을 내 채널로 등록하고 REGISTERED 처리")
|
||||||
description = "추천 채널을 등록한다. role=MY(기본)|SOURCE(소재 원본 시드)|RIVAL(경쟁 시드). 이후 REGISTERED 처리")
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public ApiResponse<Void> register(@PathVariable Long id,
|
public ApiResponse<Void> register(@PathVariable Long id) {
|
||||||
@RequestParam(required = false) String role) {
|
|
||||||
RecommendedChannel rc = repository.findById(id)
|
RecommendedChannel rc = repository.findById(id)
|
||||||
.orElseThrow(() -> new IllegalArgumentException("추천 채널을 찾을 수 없습니다: " + id));
|
.orElseThrow(() -> new IllegalArgumentException("추천 채널을 찾을 수 없습니다: " + id));
|
||||||
// role 미지정 시 후보가 들고 있던 힌트를 따르고, 그것도 없으면 MY.
|
channelService.saveChannelFromUrl("https://www.youtube.com/channel/" + rc.getChannelId());
|
||||||
String target = ChannelRole.normalize(role != null && !role.isBlank() ? role : rc.getRoleHint());
|
|
||||||
Channel channel = channelService.saveChannelFromUrl("https://www.youtube.com/channel/" + rc.getChannelId());
|
|
||||||
channel.changeRole(target);
|
|
||||||
channel.resetFeedFailure();
|
|
||||||
channelRepository.save(channel);
|
|
||||||
rc.setStatus("REGISTERED");
|
rc.setStatus("REGISTERED");
|
||||||
repository.save(rc);
|
repository.save(rc);
|
||||||
return ApiResponse.ok(null);
|
return ApiResponse.ok(null);
|
||||||
@ -76,31 +60,9 @@ public class RecommendedChannelController {
|
|||||||
return ApiResponse.ok(null);
|
return ApiResponse.ok(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/{id}/rework")
|
|
||||||
@Operation(summary = "대표 영상 재가공", description = "추천 채널의 대표(떡상) 영상을 수집함(ChannelVideo)에 적재하고 재가공 작업공간으로 보낼 id를 반환")
|
|
||||||
@Transactional
|
|
||||||
public ApiResponse<Map<String, Object>> rework(@PathVariable Long id) {
|
|
||||||
RecommendedChannel rc = repository.findById(id)
|
|
||||||
.orElseThrow(() -> new IllegalArgumentException("추천 채널을 찾을 수 없습니다: " + id));
|
|
||||||
String videoId = rc.getTopVideoId();
|
|
||||||
if (videoId == null || videoId.isBlank()) {
|
|
||||||
throw new IllegalArgumentException("대표 영상이 없어 재가공할 수 없습니다: " + id);
|
|
||||||
}
|
|
||||||
ChannelVideo video = channelVideoRepository.findByVideoId(videoId)
|
|
||||||
.orElseGet(() -> {
|
|
||||||
BigDecimal ratio = rc.getRatio() == null ? null : BigDecimal.valueOf(rc.getRatio());
|
|
||||||
ChannelVideo v = ChannelVideo.fromSearch(
|
|
||||||
videoId, rc.getTopVideoTitle(), rc.getThumbnailUrl(), null,
|
|
||||||
rc.getTopVideoViewCount(), rc.getChannelId(), rc.getChannelTitle(),
|
|
||||||
rc.getSubscriberCount(), null, null, ratio, null);
|
|
||||||
return channelVideoRepository.save(v);
|
|
||||||
});
|
|
||||||
return ApiResponse.ok(Map.of("videoId", video.getId()));
|
|
||||||
}
|
|
||||||
|
|
||||||
@PostMapping("/run")
|
@PostMapping("/run")
|
||||||
@Operation(summary = "수동 발굴 실행", description = "스케줄과 동일한 발굴을 즉시 1회 실행. format=LONG_FORM(기본)|SHORTS")
|
@Operation(summary = "수동 발굴 실행", description = "스케줄과 동일한 발굴을 즉시 1회 실행")
|
||||||
public ApiResponse<Map<String, Object>> run(@RequestParam(required = false) String format) {
|
public ApiResponse<Map<String, Object>> run() {
|
||||||
return ApiResponse.ok(discoveryService.runDiscovery(format));
|
return ApiResponse.ok(discoveryService.runDiscovery());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,7 +9,4 @@ public interface RecommendedChannelRepository extends JpaRepository<RecommendedC
|
|||||||
Optional<RecommendedChannel> findByChannelId(String channelId);
|
Optional<RecommendedChannel> findByChannelId(String channelId);
|
||||||
List<RecommendedChannel> findByStatusOrderByRatioDesc(String status);
|
List<RecommendedChannel> findByStatusOrderByRatioDesc(String status);
|
||||||
boolean existsByChannelIdAndStatus(String channelId, String status);
|
boolean existsByChannelIdAndStatus(String channelId, String status);
|
||||||
|
|
||||||
/** 역할 힌트별 추천 목록(시드 후보). roleHint = SOURCE | RIVAL. */
|
|
||||||
List<RecommendedChannel> findByStatusAndRoleHintOrderBySubscriberCountDesc(String status, String roleHint);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,246 +0,0 @@
|
|||||||
package com.hlab.yanalyst.domain.channel;
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.JsonNode;
|
|
||||||
import com.hlab.yanalyst.global.schedule.YoutubeQuotaGuard;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
|
||||||
import org.springframework.stereotype.Service;
|
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
|
||||||
import org.springframework.web.client.RestTemplate;
|
|
||||||
import org.springframework.web.util.UriComponentsBuilder;
|
|
||||||
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.LinkedHashMap;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 내 채널을 역분석해 소재 원본(SOURCE) 채널 후보를 찾는다.
|
|
||||||
*
|
|
||||||
* <p>내 업로드의 해시태그(#유퀴즈 #핑계고 …)를 빈도순으로 집계한 뒤, 상위 키워드마다
|
|
||||||
* search.list(type=channel)로 공식채널을 조회해 {@link RecommendedChannel}(roleHint=SOURCE)로 쌓는다.
|
|
||||||
* 승인은 사람이 한다.
|
|
||||||
*
|
|
||||||
* <p>search.list 는 1회 100 units 라 스케줄이 아니라 <b>수동 실행</b> 전용이다.
|
|
||||||
*/
|
|
||||||
@Slf4j
|
|
||||||
@Service
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
public class SeedSuggestService {
|
|
||||||
|
|
||||||
/** search.list 1회 추정 쿼터(units). */
|
|
||||||
private static final long SEARCH_QUOTA = 100;
|
|
||||||
|
|
||||||
/** 키워드당 살펴볼 영상 수. 이 표본 안에서 채널 분포를 본다. */
|
|
||||||
private static final int SAMPLE_VIDEOS = 25;
|
|
||||||
|
|
||||||
/** 표본에서 이 건수 미만으로 등장한 채널은 우연으로 보고 버린다. */
|
|
||||||
private static final int MIN_HITS = 3;
|
|
||||||
|
|
||||||
/** 키워드당 채택할 상위 채널 수. */
|
|
||||||
private static final int PER_KEYWORD = 2;
|
|
||||||
|
|
||||||
private final ChannelRepository channelRepository;
|
|
||||||
private final ChannelVideoRepository channelVideoRepository;
|
|
||||||
private final RecommendedChannelRepository recommendedChannelRepository;
|
|
||||||
private final YoutubeQuotaGuard quotaGuard;
|
|
||||||
private final RestTemplate restTemplate;
|
|
||||||
|
|
||||||
@Value("${youtube.api.key}")
|
|
||||||
private String youtubeApiKey;
|
|
||||||
|
|
||||||
/** 후보 탐색에 쓸 상위 키워드 개수. 키워드 1개당 100 units 라 기본값을 보수적으로 잡는다. */
|
|
||||||
@Value("${hlab.feed.seed.max-keywords:8}")
|
|
||||||
private int maxKeywords;
|
|
||||||
|
|
||||||
/** 이 횟수 미만으로 등장한 해시태그는 프로그램명으로 보지 않는다. */
|
|
||||||
@Value("${hlab.feed.seed.min-tag-count:2}")
|
|
||||||
private int minTagCount;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 내 채널 영상의 해시태그 빈도 (탐색 전 미리보기용, 쿼터 소비 없음).
|
|
||||||
* @return 태그 → 등장 횟수 (빈도 내림차순)
|
|
||||||
*/
|
|
||||||
public Map<String, Integer> analyzeKeywords() {
|
|
||||||
List<Channel> mine = channelRepository.findOwnChannels();
|
|
||||||
List<String> texts = new ArrayList<>();
|
|
||||||
for (Channel c : mine) {
|
|
||||||
for (ChannelVideo v : channelVideoRepository.findByChannelId(c.getId())) {
|
|
||||||
// 수집 시 저장해둔 해시태그가 있으면 그걸, 없으면 제목에서라도 뽑는다.
|
|
||||||
if (v.getHashtags() != null && !v.getHashtags().isBlank()) {
|
|
||||||
for (String tag : HashtagExtractor.split(v.getHashtags())) texts.add("#" + tag);
|
|
||||||
} else {
|
|
||||||
texts.add(v.getTitle());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return HashtagExtractor.countAll(texts, minTagCount);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 해시태그 상위 키워드로 소재 원본 채널 후보를 발굴해 추천 목록에 쌓는다.
|
|
||||||
*
|
|
||||||
* @param keywords 직접 지정할 키워드(비어있으면 {@link #analyzeKeywords()} 결과 상위 N개 사용)
|
|
||||||
* @return 실행 요약
|
|
||||||
*/
|
|
||||||
@Transactional
|
|
||||||
public Map<String, Object> suggestSourceSeeds(List<String> keywords) {
|
|
||||||
List<String> targets = new ArrayList<>();
|
|
||||||
if (keywords != null && !keywords.isEmpty()) {
|
|
||||||
for (String k : keywords) {
|
|
||||||
String t = k == null ? "" : k.trim();
|
|
||||||
if (!t.isEmpty()) targets.add(t);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
for (String k : analyzeKeywords().keySet()) {
|
|
||||||
if (targets.size() >= maxKeywords) break;
|
|
||||||
targets.add(k);
|
|
||||||
}
|
|
||||||
// 내 채널이 등록돼 있지 않으면 역분석할 해시태그가 없다. 조용히 0건으로 끝내면
|
|
||||||
// 사용자는 "발굴했는데 아무것도 못 찾았다"로 오해하므로 이유를 분명히 말한다.
|
|
||||||
if (targets.isEmpty()) {
|
|
||||||
throw new IllegalArgumentException(
|
|
||||||
"역분석할 해시태그가 없습니다. 내 채널이 등록돼 있지 않거나 수집된 영상에 해시태그가 없습니다. "
|
|
||||||
+ "키워드를 직접 입력해 발굴하세요.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
int searched = 0, found = 0, saved = 0, skippedByQuota = 0;
|
|
||||||
for (String keyword : targets) {
|
|
||||||
if (!quotaGuard.tryConsume(SEARCH_QUOTA)) {
|
|
||||||
skippedByQuota = targets.size() - searched;
|
|
||||||
log.warn("[Seed] 쿼터 예산 소진 — 키워드 '{}' 이후 중단 (잔여 {})", keyword, quotaGuard.remaining());
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
List<Candidate> candidates = searchChannels(keyword);
|
|
||||||
searched++;
|
|
||||||
found += candidates.size();
|
|
||||||
for (Candidate c : candidates) {
|
|
||||||
if (upsertCandidate(c, keyword)) saved++;
|
|
||||||
}
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.error("[Seed] 키워드 '{}' 채널 검색 실패 — 건너뜀", keyword, e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Map<String, Object> summary = new LinkedHashMap<>();
|
|
||||||
summary.put("keywords", targets);
|
|
||||||
summary.put("searched", searched);
|
|
||||||
summary.put("found", found);
|
|
||||||
summary.put("saved", saved);
|
|
||||||
summary.put("skippedByQuota", skippedByQuota);
|
|
||||||
summary.put("quotaRemaining", quotaGuard.remaining());
|
|
||||||
log.info("[Seed] 소재 원본 후보 발굴 완료: {}", summary);
|
|
||||||
return summary;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 이미 시드로 등록됐거나 제외/등록 처리된 채널은 건너뛴다. @return 새로 저장했으면 true */
|
|
||||||
private boolean upsertCandidate(Candidate c, String keyword) {
|
|
||||||
if (channelRepository.existsByChannelId(c.channelId)) return false; // 이미 등록된 채널
|
|
||||||
|
|
||||||
RecommendedChannel rc = recommendedChannelRepository.findByChannelId(c.channelId).orElse(null);
|
|
||||||
if (rc != null && !"NEW".equals(rc.getStatus())) return false; // EXCLUDED/REGISTERED 는 존중
|
|
||||||
if (rc == null) rc = new RecommendedChannel();
|
|
||||||
|
|
||||||
rc.setChannelId(c.channelId);
|
|
||||||
rc.setChannelTitle(c.title);
|
|
||||||
rc.setThumbnailUrl(c.thumbnailUrl);
|
|
||||||
rc.setSubscriberCount(c.subscriberCount);
|
|
||||||
rc.setStatus("NEW");
|
|
||||||
rc.setRoleHint(ChannelRole.SOURCE);
|
|
||||||
rc.setSeedKeyword(keyword);
|
|
||||||
rc.setRegion("KR");
|
|
||||||
recommendedChannelRepository.save(rc);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private record Candidate(String channelId, String title, String thumbnailUrl, Long subscriberCount, int hits) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 키워드로 <b>영상</b>을 검색해 채널별로 집계한다.
|
|
||||||
*
|
|
||||||
* <p>type=channel 검색은 채널 <i>이름</i>을 매칭하기 때문에 쓸 수 없다. 프로그램 클립은
|
|
||||||
* "유퀴즈"라는 이름의 채널이 아니라 tvN D ENT·뜬뜬 같은 제작사 채널에 올라오므로,
|
|
||||||
* 이름으로 찾으면 구독자 한 자릿수 짜리 동명 채널만 잡힌다.
|
|
||||||
* 대신 영상 검색 결과에서 같은 채널이 몇 번 나오는지를 세면 실제 제작사 채널이 드러난다.
|
|
||||||
*/
|
|
||||||
private List<Candidate> searchChannels(String keyword) {
|
|
||||||
// toUriString() 을 넘기면 RestTemplate 이 URI 템플릿으로 보고 한 번 더 인코딩한다
|
|
||||||
// (한글 키워드가 %25EC.. 로 깨져 엉뚱한 채널이 잡힘). URI 오버로드로 넘겨야 한다.
|
|
||||||
java.net.URI searchUri = UriComponentsBuilder.fromHttpUrl("https://www.googleapis.com/youtube/v3/search")
|
|
||||||
.queryParam("part", "snippet")
|
|
||||||
.queryParam("type", "video")
|
|
||||||
.queryParam("q", keyword)
|
|
||||||
.queryParam("regionCode", "KR")
|
|
||||||
.queryParam("relevanceLanguage", "ko")
|
|
||||||
.queryParam("maxResults", SAMPLE_VIDEOS)
|
|
||||||
.queryParam("key", youtubeApiKey)
|
|
||||||
.build()
|
|
||||||
.encode()
|
|
||||||
.toUri();
|
|
||||||
|
|
||||||
JsonNode root = restTemplate.getForObject(searchUri, JsonNode.class);
|
|
||||||
if (root == null) return List.of();
|
|
||||||
|
|
||||||
// channelId → 표본 내 등장 횟수
|
|
||||||
Map<String, Integer> hits = new LinkedHashMap<>();
|
|
||||||
for (JsonNode item : root.path("items")) {
|
|
||||||
String channelId = item.path("snippet").path("channelId").asText(null);
|
|
||||||
if (channelId == null || channelId.isBlank()) continue;
|
|
||||||
hits.merge(channelId, 1, Integer::sum);
|
|
||||||
}
|
|
||||||
|
|
||||||
List<Map.Entry<String, Integer>> ranked = new ArrayList<>(hits.entrySet());
|
|
||||||
ranked.sort(Map.Entry.<String, Integer>comparingByValue().reversed());
|
|
||||||
|
|
||||||
List<String> picked = new ArrayList<>();
|
|
||||||
for (Map.Entry<String, Integer> e : ranked) {
|
|
||||||
if (e.getValue() < MIN_HITS) break; // 정렬돼 있으므로 이후는 볼 필요 없다
|
|
||||||
if (picked.size() >= PER_KEYWORD) break;
|
|
||||||
picked.add(e.getKey());
|
|
||||||
}
|
|
||||||
if (picked.isEmpty()) return List.of();
|
|
||||||
|
|
||||||
Map<String, String[]> meta = fetchChannelMeta(picked); // channelId → [title, thumb, subs]
|
|
||||||
|
|
||||||
List<Candidate> out = new ArrayList<>();
|
|
||||||
for (String id : picked) {
|
|
||||||
String[] m = meta.get(id);
|
|
||||||
if (m == null) continue;
|
|
||||||
Long subs = m[2] == null ? null : Long.parseLong(m[2]);
|
|
||||||
out.add(new Candidate(id, m[0], m[1], subs, hits.get(id)));
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** channels.list 1회(1 unit)로 이름/썸네일/구독자 수를 채운다. 실패하면 해당 키워드는 건너뛴다. */
|
|
||||||
private Map<String, String[]> fetchChannelMeta(List<String> channelIds) {
|
|
||||||
Map<String, String[]> out = new LinkedHashMap<>();
|
|
||||||
try {
|
|
||||||
java.net.URI uri = UriComponentsBuilder.fromHttpUrl("https://www.googleapis.com/youtube/v3/channels")
|
|
||||||
.queryParam("part", "snippet,statistics")
|
|
||||||
.queryParam("id", String.join(",", channelIds))
|
|
||||||
.queryParam("key", youtubeApiKey)
|
|
||||||
.build()
|
|
||||||
.encode()
|
|
||||||
.toUri();
|
|
||||||
JsonNode root = restTemplate.getForObject(uri, JsonNode.class);
|
|
||||||
if (root == null) return out;
|
|
||||||
for (JsonNode item : root.path("items")) {
|
|
||||||
String id = item.path("id").asText(null);
|
|
||||||
if (id == null) continue;
|
|
||||||
JsonNode snippet = item.path("snippet");
|
|
||||||
String title = snippet.path("title").asText("");
|
|
||||||
String thumb = snippet.path("thumbnails").path("high").path("url")
|
|
||||||
.asText(snippet.path("thumbnails").path("default").path("url").asText(null));
|
|
||||||
String subs = item.path("statistics").path("subscriberCount").asText(null);
|
|
||||||
out.put(id, new String[]{title, thumb, subs});
|
|
||||||
}
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.warn("[Seed] 채널 정보 조회 실패(무시)", e);
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,107 +0,0 @@
|
|||||||
package com.hlab.yanalyst.domain.channel;
|
|
||||||
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.Comparator;
|
|
||||||
import java.util.LinkedHashMap;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Locale;
|
|
||||||
import java.util.Map;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 내 영상을 해시태그(소재)별로 묶어 성과를 집계하는 순수 로직.
|
|
||||||
*
|
|
||||||
* <p>"유퀴즈 클립은 평균 12만, 핑계고는 3만" 같은 답을 만들어, 피드에서 무엇을 자를지
|
|
||||||
* 결정하는 근거로 쓴다. 채널 평균 대비 배수(index)로 표시해 절대 조회수가 아니라
|
|
||||||
* <b>상대적으로 잘 먹히는 소재</b>가 드러나게 한다.
|
|
||||||
*/
|
|
||||||
public final class TopicPerformance {
|
|
||||||
|
|
||||||
private TopicPerformance() {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 소재 한 줄.
|
|
||||||
*
|
|
||||||
* @param topic 해시태그(프로그램명·인물명)
|
|
||||||
* @param videoCount 이 소재로 만든 영상 수
|
|
||||||
* @param avgViews 평균 조회수
|
|
||||||
* @param maxViews 최고 조회수
|
|
||||||
* @param index 채널 평균 대비 배수. 1.0 이면 평균, 2.0 이면 평균의 두 배
|
|
||||||
* @param topTitle 이 소재에서 가장 잘 된 영상 제목
|
|
||||||
* @param topVideoId 그 영상의 YouTube ID
|
|
||||||
*/
|
|
||||||
public record Topic(String topic, int videoCount, long avgViews, long maxViews,
|
|
||||||
double index, String topTitle, String topVideoId) {}
|
|
||||||
|
|
||||||
/** 집계 입력. 엔티티에 직접 의존하지 않도록 필요한 값만 받는다. */
|
|
||||||
public record Entry(String hashtags, Long viewCount, String title, String videoId) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 해시태그별 성과를 집계한다.
|
|
||||||
*
|
|
||||||
* @param entries 내 영상들
|
|
||||||
* @param minCount 이 편수 미만인 소재는 버린다(1편짜리 우연을 랭킹에 올리지 않기 위함)
|
|
||||||
* @return 평균 조회수 내림차순 목록
|
|
||||||
*/
|
|
||||||
public static List<Topic> aggregate(List<Entry> entries, int minCount) {
|
|
||||||
if (entries == null || entries.isEmpty()) return List.of();
|
|
||||||
|
|
||||||
// 채널 평균 = 소재 유무와 무관하게 전체 영상 기준
|
|
||||||
long total = 0;
|
|
||||||
int counted = 0;
|
|
||||||
for (Entry e : entries) {
|
|
||||||
if (e.viewCount() == null) continue;
|
|
||||||
total += e.viewCount();
|
|
||||||
counted++;
|
|
||||||
}
|
|
||||||
double channelAvg = counted == 0 ? 0 : (double) total / counted;
|
|
||||||
|
|
||||||
// 소문자키 → 누적치
|
|
||||||
Map<String, long[]> sums = new LinkedHashMap<>(); // [합계, 편수, 최고조회수]
|
|
||||||
Map<String, String> display = new LinkedHashMap<>(); // 원문 표기
|
|
||||||
Map<String, String[]> best = new LinkedHashMap<>(); // [제목, videoId]
|
|
||||||
|
|
||||||
for (Entry e : entries) {
|
|
||||||
long views = e.viewCount() == null ? 0 : e.viewCount();
|
|
||||||
for (String tag : HashtagExtractor.split(e.hashtags())) {
|
|
||||||
String key = tag.toLowerCase(Locale.ROOT);
|
|
||||||
long[] acc = sums.computeIfAbsent(key, k -> new long[3]);
|
|
||||||
acc[0] += views;
|
|
||||||
acc[1] += 1;
|
|
||||||
if (views >= acc[2]) {
|
|
||||||
acc[2] = views;
|
|
||||||
best.put(key, new String[]{e.title(), e.videoId()});
|
|
||||||
}
|
|
||||||
display.putIfAbsent(key, tag);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
List<Topic> out = new ArrayList<>();
|
|
||||||
for (Map.Entry<String, long[]> en : sums.entrySet()) {
|
|
||||||
long[] acc = en.getValue();
|
|
||||||
int count = (int) acc[1];
|
|
||||||
if (count < minCount) continue;
|
|
||||||
long avg = acc[0] / count;
|
|
||||||
double index = channelAvg == 0 ? 0 : avg / channelAvg;
|
|
||||||
String[] b = best.getOrDefault(en.getKey(), new String[]{null, null});
|
|
||||||
out.add(new Topic(display.get(en.getKey()), count, avg, acc[2],
|
|
||||||
Math.round(index * 100) / 100.0, b[0], b[1]));
|
|
||||||
}
|
|
||||||
|
|
||||||
out.sort(Comparator.comparingLong(Topic::avgViews).reversed()
|
|
||||||
.thenComparing(Topic::topic));
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 전체 영상의 평균 조회수. 비교 기준선으로 화면에 함께 보여준다. */
|
|
||||||
public static long channelAverage(List<Entry> entries) {
|
|
||||||
if (entries == null || entries.isEmpty()) return 0;
|
|
||||||
long total = 0;
|
|
||||||
int counted = 0;
|
|
||||||
for (Entry e : entries) {
|
|
||||||
if (e.viewCount() == null) continue;
|
|
||||||
total += e.viewCount();
|
|
||||||
counted++;
|
|
||||||
}
|
|
||||||
return counted == 0 ? 0 : total / counted;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,60 +0,0 @@
|
|||||||
package com.hlab.yanalyst.domain.channel;
|
|
||||||
|
|
||||||
import jakarta.persistence.*;
|
|
||||||
import lombok.Getter;
|
|
||||||
import lombok.NoArgsConstructor;
|
|
||||||
import org.hibernate.annotations.CreationTimestamp;
|
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 추적 대상 인물. 인물은 프로그램을 옮겨 다니므로 채널만 쫓으면 놓치는 소재를 잡아낸다.
|
|
||||||
*
|
|
||||||
* <p>1명당 검색 1회(100 units)가 들어 하루 1회만 수집한다. 활성 인원은
|
|
||||||
* {@code hlab.feed.person.max-people} 로 제한한다.
|
|
||||||
*/
|
|
||||||
@Entity
|
|
||||||
@Table(name = "tracked_persons", indexes = @Index(name = "idx_tp_name", columnList = "name", unique = true))
|
|
||||||
@Getter
|
|
||||||
@NoArgsConstructor
|
|
||||||
public class TrackedPerson {
|
|
||||||
|
|
||||||
@Id
|
|
||||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
|
||||||
private Long id;
|
|
||||||
|
|
||||||
@Column(nullable = false, unique = true, length = 100)
|
|
||||||
private String name;
|
|
||||||
|
|
||||||
/** 꺼두면 수집 대상에서 빠진다(쿼터를 아끼면서 목록은 유지). */
|
|
||||||
@Column(nullable = false)
|
|
||||||
private Boolean enabled = true;
|
|
||||||
|
|
||||||
/** 마지막 수집 시각. */
|
|
||||||
private LocalDateTime lastCollectedAt;
|
|
||||||
|
|
||||||
/** 마지막 수집에서 새로 담은 롱폼 수. */
|
|
||||||
private Integer lastFound;
|
|
||||||
|
|
||||||
@CreationTimestamp
|
|
||||||
@Column(updatable = false)
|
|
||||||
private LocalDateTime createdAt;
|
|
||||||
|
|
||||||
public TrackedPerson(String name) {
|
|
||||||
this.name = name;
|
|
||||||
this.enabled = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
public boolean isEnabled() {
|
|
||||||
return Boolean.TRUE.equals(this.enabled);
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setEnabled(boolean enabled) {
|
|
||||||
this.enabled = enabled;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void recordCollection(int found) {
|
|
||||||
this.lastCollectedAt = LocalDateTime.now();
|
|
||||||
this.lastFound = found;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,14 +0,0 @@
|
|||||||
package com.hlab.yanalyst.domain.channel;
|
|
||||||
|
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Optional;
|
|
||||||
|
|
||||||
public interface TrackedPersonRepository extends JpaRepository<TrackedPerson, Long> {
|
|
||||||
Optional<TrackedPerson> findByName(String name);
|
|
||||||
boolean existsByName(String name);
|
|
||||||
List<TrackedPerson> findByEnabledTrueOrderByIdAsc();
|
|
||||||
List<TrackedPerson> findAllByOrderByIdAsc();
|
|
||||||
long countByEnabledTrue();
|
|
||||||
}
|
|
||||||
@ -1,279 +0,0 @@
|
|||||||
package com.hlab.yanalyst.domain.channel;
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.JsonNode;
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
||||||
import com.hlab.yanalyst.domain.channel.dto.YoutubeChannelAnalyticsDto;
|
|
||||||
import com.hlab.yanalyst.domain.upload.YoutubeConnection;
|
|
||||||
import com.hlab.yanalyst.domain.upload.YoutubeConnectionRepository;
|
|
||||||
import com.hlab.yanalyst.domain.upload.YoutubeOAuthService;
|
|
||||||
import org.springframework.beans.factory.annotation.Qualifier;
|
|
||||||
import org.springframework.http.HttpEntity;
|
|
||||||
import org.springframework.http.HttpHeaders;
|
|
||||||
import org.springframework.http.HttpMethod;
|
|
||||||
import org.springframework.stereotype.Service;
|
|
||||||
import org.springframework.web.client.HttpStatusCodeException;
|
|
||||||
import org.springframework.web.client.RestTemplate;
|
|
||||||
import org.springframework.web.util.UriComponentsBuilder;
|
|
||||||
|
|
||||||
import java.net.URI;
|
|
||||||
import java.time.Duration;
|
|
||||||
import java.time.Instant;
|
|
||||||
import java.time.LocalDate;
|
|
||||||
import java.time.ZoneId;
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.HashMap;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
|
|
||||||
/** YouTube Analytics API와 Data API를 조합해 내 채널 분석용 보고서를 만든다. */
|
|
||||||
@Service
|
|
||||||
public class YoutubeChannelAnalyticsService {
|
|
||||||
private static final String ANALYTICS_URL = "https://youtubeanalytics.googleapis.com/v2/reports";
|
|
||||||
private static final String CHANNELS_URL = "https://www.googleapis.com/youtube/v3/channels";
|
|
||||||
private static final String PLAYLIST_ITEMS_URL = "https://www.googleapis.com/youtube/v3/playlistItems";
|
|
||||||
private static final String VIDEOS_URL = "https://www.googleapis.com/youtube/v3/videos";
|
|
||||||
private static final String METRICS = "views,estimatedMinutesWatched,averageViewDuration,subscribersGained,subscribersLost,likes,comments";
|
|
||||||
private static final ZoneId KOREA = ZoneId.of("Asia/Seoul");
|
|
||||||
private static final int MAX_RANGE_DAYS = 730;
|
|
||||||
private static final int MAX_UPLOADS = 500;
|
|
||||||
|
|
||||||
private final RestTemplate restTemplate;
|
|
||||||
private final ObjectMapper objectMapper;
|
|
||||||
private final YoutubeOAuthService oauthService;
|
|
||||||
private final YoutubeConnectionRepository connectionRepository;
|
|
||||||
|
|
||||||
public YoutubeChannelAnalyticsService(@Qualifier("restTemplate") RestTemplate restTemplate,
|
|
||||||
ObjectMapper objectMapper,
|
|
||||||
YoutubeOAuthService oauthService,
|
|
||||||
YoutubeConnectionRepository connectionRepository) {
|
|
||||||
this.restTemplate = restTemplate;
|
|
||||||
this.objectMapper = objectMapper;
|
|
||||||
this.oauthService = oauthService;
|
|
||||||
this.connectionRepository = connectionRepository;
|
|
||||||
}
|
|
||||||
|
|
||||||
public YoutubeChannelAnalyticsDto analyze(Long connectionId, LocalDate startDate, LocalDate endDate) {
|
|
||||||
validatePeriod(startDate, endDate);
|
|
||||||
YoutubeConnection connection = connectionRepository.findById(connectionId)
|
|
||||||
.orElseThrow(() -> new IllegalArgumentException("연결된 YouTube 채널을 찾을 수 없습니다."));
|
|
||||||
String accessToken = oauthService.accessToken(connectionId);
|
|
||||||
|
|
||||||
List<YoutubeChannelAnalyticsDto.DailyDto> daily = queryDaily(accessToken, startDate, endDate);
|
|
||||||
List<RawVideoMetric> videoMetrics = queryVideoMetrics(accessToken, startDate, endDate);
|
|
||||||
Map<String, VideoMetadata> metadata = loadVideoMetadata(accessToken,
|
|
||||||
videoMetrics.stream().map(RawVideoMetric::videoId).toList());
|
|
||||||
List<YoutubeChannelAnalyticsDto.VideoDto> videos = videoMetrics.stream()
|
|
||||||
.map(metric -> toVideoDto(metric, metadata.get(metric.videoId())))
|
|
||||||
.toList();
|
|
||||||
|
|
||||||
List<Upload> uploads = loadUploads(accessToken, connection.getChannelId()).stream()
|
|
||||||
.filter(upload -> !upload.publishedAt().atZone(KOREA).toLocalDate().isBefore(startDate))
|
|
||||||
.filter(upload -> !upload.publishedAt().atZone(KOREA).toLocalDate().isAfter(endDate))
|
|
||||||
.toList();
|
|
||||||
|
|
||||||
long views = daily.stream().mapToLong(YoutubeChannelAnalyticsDto.DailyDto::views).sum();
|
|
||||||
long watchMinutes = daily.stream().mapToLong(YoutubeChannelAnalyticsDto.DailyDto::watchMinutes).sum();
|
|
||||||
long subscribersGained = daily.stream().mapToLong(YoutubeChannelAnalyticsDto.DailyDto::subscribersGained).sum();
|
|
||||||
long subscribersLost = daily.stream().mapToLong(YoutubeChannelAnalyticsDto.DailyDto::subscribersLost).sum();
|
|
||||||
long likes = daily.stream().mapToLong(YoutubeChannelAnalyticsDto.DailyDto::likes).sum();
|
|
||||||
long comments = daily.stream().mapToLong(YoutubeChannelAnalyticsDto.DailyDto::comments).sum();
|
|
||||||
double averageViewDuration = weightedAverageDuration(daily);
|
|
||||||
|
|
||||||
return new YoutubeChannelAnalyticsDto(
|
|
||||||
new YoutubeChannelAnalyticsDto.ChannelDto(connection.getId(), connection.getChannelId(),
|
|
||||||
connection.getChannelTitle(), connection.getChannelHandle(), connection.getThumbnailUrl(),
|
|
||||||
connection.getLastSyncedAt() == null ? null : connection.getLastSyncedAt().atZone(KOREA).toInstant()),
|
|
||||||
new YoutubeChannelAnalyticsDto.PeriodDto(startDate, endDate,
|
|
||||||
Duration.between(startDate.atStartOfDay(KOREA), endDate.plusDays(1).atStartOfDay(KOREA)).toDays()),
|
|
||||||
new YoutubeChannelAnalyticsDto.SummaryDto(views, watchMinutes, averageViewDuration,
|
|
||||||
subscribersGained, subscribersLost, subscribersGained - subscribersLost, likes, comments,
|
|
||||||
uploads.size(), uploads.isEmpty() ? 0 : Math.round((double) views / uploads.size())),
|
|
||||||
daily,
|
|
||||||
videos,
|
|
||||||
uploadRhythm(uploads)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public List<YoutubeChannelAnalyticsDto.DailyDto> videoTimeline(Long connectionId, String videoId,
|
|
||||||
LocalDate startDate, LocalDate endDate) {
|
|
||||||
if (videoId == null || videoId.isBlank()) throw new IllegalArgumentException("영상 ID가 필요합니다.");
|
|
||||||
validatePeriod(startDate, endDate);
|
|
||||||
String accessToken = oauthService.accessToken(connectionId);
|
|
||||||
return queryDaily(accessToken, startDate, endDate, "video==" + videoId);
|
|
||||||
}
|
|
||||||
|
|
||||||
private List<YoutubeChannelAnalyticsDto.DailyDto> queryDaily(String accessToken, LocalDate startDate,
|
|
||||||
LocalDate endDate) {
|
|
||||||
return queryDaily(accessToken, startDate, endDate, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
private List<YoutubeChannelAnalyticsDto.DailyDto> queryDaily(String accessToken, LocalDate startDate,
|
|
||||||
LocalDate endDate, String filters) {
|
|
||||||
JsonNode report = queryReport(accessToken, startDate, endDate, "day", METRICS, filters, "day", null);
|
|
||||||
List<YoutubeChannelAnalyticsDto.DailyDto> result = new ArrayList<>();
|
|
||||||
for (JsonNode row : report.path("rows")) {
|
|
||||||
if (!row.isArray() || row.size() < 8) continue;
|
|
||||||
result.add(new YoutubeChannelAnalyticsDto.DailyDto(
|
|
||||||
LocalDate.parse(row.path(0).asText()), row.path(1).asLong(), row.path(2).asLong(),
|
|
||||||
row.path(3).asDouble(), row.path(4).asLong(), row.path(5).asLong(),
|
|
||||||
row.path(6).asLong(), row.path(7).asLong()));
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
private List<RawVideoMetric> queryVideoMetrics(String accessToken, LocalDate startDate, LocalDate endDate) {
|
|
||||||
JsonNode report = queryReport(accessToken, startDate, endDate, "video", METRICS, null, "-views", 50);
|
|
||||||
List<RawVideoMetric> result = new ArrayList<>();
|
|
||||||
for (JsonNode row : report.path("rows")) {
|
|
||||||
if (!row.isArray() || row.size() < 8) continue;
|
|
||||||
result.add(new RawVideoMetric(row.path(0).asText(), row.path(1).asLong(), row.path(2).asLong(),
|
|
||||||
row.path(3).asDouble(), row.path(4).asLong(), row.path(5).asLong(), row.path(6).asLong(),
|
|
||||||
row.path(7).asLong()));
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
private JsonNode queryReport(String accessToken, LocalDate startDate, LocalDate endDate,
|
|
||||||
String dimensions, String metrics, String filters, String sort, Integer maxResults) {
|
|
||||||
UriComponentsBuilder builder = UriComponentsBuilder.fromUriString(ANALYTICS_URL)
|
|
||||||
.queryParam("ids", "channel==MINE")
|
|
||||||
.queryParam("startDate", startDate)
|
|
||||||
.queryParam("endDate", endDate)
|
|
||||||
.queryParam("dimensions", dimensions)
|
|
||||||
.queryParam("metrics", metrics)
|
|
||||||
.queryParam("sort", sort);
|
|
||||||
if (filters != null) builder.queryParam("filters", filters);
|
|
||||||
if (maxResults != null) builder.queryParam("maxResults", maxResults);
|
|
||||||
return getJson(builder.build().encode().toUri(), accessToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
private Map<String, VideoMetadata> loadVideoMetadata(String accessToken, List<String> ids) {
|
|
||||||
Map<String, VideoMetadata> result = new HashMap<>();
|
|
||||||
for (int index = 0; index < ids.size(); index += 50) {
|
|
||||||
List<String> batch = ids.subList(index, Math.min(index + 50, ids.size()));
|
|
||||||
if (batch.isEmpty()) continue;
|
|
||||||
URI uri = UriComponentsBuilder.fromUriString(VIDEOS_URL)
|
|
||||||
.queryParam("part", "snippet,contentDetails")
|
|
||||||
.queryParam("id", String.join(",", batch)).build().encode().toUri();
|
|
||||||
for (JsonNode item : getJson(uri, accessToken).path("items")) {
|
|
||||||
JsonNode snippet = item.path("snippet");
|
|
||||||
String publishedAt = snippet.path("publishedAt").asText("");
|
|
||||||
result.put(item.path("id").asText(), new VideoMetadata(
|
|
||||||
snippet.path("title").asText("제목을 불러올 수 없는 영상"),
|
|
||||||
thumbnailUrl(snippet), publishedAt.isBlank() ? null : Instant.parse(publishedAt)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
private List<Upload> loadUploads(String accessToken, String channelId) {
|
|
||||||
URI channelUri = UriComponentsBuilder.fromUriString(CHANNELS_URL)
|
|
||||||
.queryParam("part", "contentDetails").queryParam("id", channelId).build().encode().toUri();
|
|
||||||
String playlistId = getJson(channelUri, accessToken).path("items").path(0)
|
|
||||||
.path("contentDetails").path("relatedPlaylists").path("uploads").asText("");
|
|
||||||
if (playlistId.isBlank()) return List.of();
|
|
||||||
|
|
||||||
List<Upload> uploads = new ArrayList<>();
|
|
||||||
String pageToken = null;
|
|
||||||
while (uploads.size() < MAX_UPLOADS) {
|
|
||||||
UriComponentsBuilder builder = UriComponentsBuilder.fromUriString(PLAYLIST_ITEMS_URL)
|
|
||||||
.queryParam("part", "contentDetails").queryParam("playlistId", playlistId).queryParam("maxResults", 50);
|
|
||||||
if (pageToken != null) builder.queryParam("pageToken", pageToken);
|
|
||||||
JsonNode page = getJson(builder.build().encode().toUri(), accessToken);
|
|
||||||
for (JsonNode item : page.path("items")) {
|
|
||||||
String publishedAt = item.path("contentDetails").path("videoPublishedAt").asText("");
|
|
||||||
if (!publishedAt.isBlank()) uploads.add(new Upload(Instant.parse(publishedAt)));
|
|
||||||
if (uploads.size() >= MAX_UPLOADS) break;
|
|
||||||
}
|
|
||||||
pageToken = page.path("nextPageToken").asText("");
|
|
||||||
if (pageToken.isBlank()) break;
|
|
||||||
}
|
|
||||||
return uploads;
|
|
||||||
}
|
|
||||||
|
|
||||||
private YoutubeChannelAnalyticsDto.UploadRhythmDto uploadRhythm(List<Upload> uploads) {
|
|
||||||
long[] weekdays = new long[7];
|
|
||||||
long[] hours = new long[24];
|
|
||||||
List<Instant> published = uploads.stream().map(Upload::publishedAt).sorted().toList();
|
|
||||||
for (Instant instant : published) {
|
|
||||||
var dateTime = instant.atZone(KOREA);
|
|
||||||
weekdays[dateTime.getDayOfWeek().getValue() - 1]++;
|
|
||||||
hours[dateTime.getHour()]++;
|
|
||||||
}
|
|
||||||
List<YoutubeChannelAnalyticsDto.BucketDto> weekdayCounts = new ArrayList<>();
|
|
||||||
List<YoutubeChannelAnalyticsDto.BucketDto> hourCounts = new ArrayList<>();
|
|
||||||
for (int i = 0; i < weekdays.length; i++) weekdayCounts.add(new YoutubeChannelAnalyticsDto.BucketDto(i + 1, weekdays[i]));
|
|
||||||
for (int i = 0; i < hours.length; i++) hourCounts.add(new YoutubeChannelAnalyticsDto.BucketDto(i, hours[i]));
|
|
||||||
return new YoutubeChannelAnalyticsDto.UploadRhythmDto(uploads.size(), averageGap(published),
|
|
||||||
mostActiveIndex(weekdays) + 1, mostActiveIndex(hours), weekdayCounts, hourCounts);
|
|
||||||
}
|
|
||||||
|
|
||||||
private double averageGap(List<Instant> published) {
|
|
||||||
if (published.size() < 2) return 0;
|
|
||||||
long minutes = 0;
|
|
||||||
for (int i = 1; i < published.size(); i++) minutes += Duration.between(published.get(i - 1), published.get(i)).toMinutes();
|
|
||||||
return Math.round((minutes / 1440.0 / (published.size() - 1)) * 10) / 10.0;
|
|
||||||
}
|
|
||||||
|
|
||||||
private int mostActiveIndex(long[] counts) {
|
|
||||||
int best = 0;
|
|
||||||
for (int i = 1; i < counts.length; i++) if (counts[i] > counts[best]) best = i;
|
|
||||||
return best;
|
|
||||||
}
|
|
||||||
|
|
||||||
private YoutubeChannelAnalyticsDto.VideoDto toVideoDto(RawVideoMetric metric, VideoMetadata metadata) {
|
|
||||||
return new YoutubeChannelAnalyticsDto.VideoDto(metric.videoId(),
|
|
||||||
metadata == null ? "제목을 불러올 수 없는 영상" : metadata.title(),
|
|
||||||
metadata == null ? "" : metadata.thumbnailUrl(), metadata == null ? null : metadata.publishedAt(),
|
|
||||||
metric.views(), metric.watchMinutes(), metric.averageViewDurationSeconds(), metric.subscribersGained(),
|
|
||||||
metric.subscribersLost(), metric.subscribersGained() - metric.subscribersLost(), metric.likes(), metric.comments());
|
|
||||||
}
|
|
||||||
|
|
||||||
private double weightedAverageDuration(List<YoutubeChannelAnalyticsDto.DailyDto> daily) {
|
|
||||||
long totalViews = daily.stream().mapToLong(YoutubeChannelAnalyticsDto.DailyDto::views).sum();
|
|
||||||
if (totalViews == 0) return 0;
|
|
||||||
double totalDuration = daily.stream().mapToDouble(day -> day.averageViewDurationSeconds() * day.views()).sum();
|
|
||||||
return Math.round(totalDuration / totalViews * 10) / 10.0;
|
|
||||||
}
|
|
||||||
|
|
||||||
private JsonNode getJson(URI uri, String accessToken) {
|
|
||||||
HttpHeaders headers = new HttpHeaders();
|
|
||||||
headers.setBearerAuth(accessToken);
|
|
||||||
String body;
|
|
||||||
try {
|
|
||||||
body = restTemplate.exchange(uri, HttpMethod.GET, new HttpEntity<>(headers), String.class).getBody();
|
|
||||||
} catch (HttpStatusCodeException e) {
|
|
||||||
if (e.getStatusCode().value() == 401 || e.getStatusCode().value() == 403) {
|
|
||||||
throw new IllegalStateException("YouTube 분석 권한이 만료되었거나 부족합니다. 채널을 다시 연결해 주세요.", e);
|
|
||||||
}
|
|
||||||
throw new IllegalStateException("YouTube 분석 데이터를 불러오지 못했습니다.", e);
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
return objectMapper.readTree(body);
|
|
||||||
} catch (Exception e) {
|
|
||||||
throw new IllegalStateException("YouTube 응답을 해석하지 못했습니다.", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private String thumbnailUrl(JsonNode snippet) {
|
|
||||||
JsonNode thumbnails = snippet.path("thumbnails");
|
|
||||||
String high = thumbnails.path("high").path("url").asText("");
|
|
||||||
return high.isBlank() ? thumbnails.path("medium").path("url").asText("") : high;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void validatePeriod(LocalDate startDate, LocalDate endDate) {
|
|
||||||
if (startDate == null || endDate == null) throw new IllegalArgumentException("시작일과 종료일을 모두 선택하세요.");
|
|
||||||
if (startDate.isAfter(endDate)) throw new IllegalArgumentException("시작일은 종료일보다 앞서야 합니다.");
|
|
||||||
if (endDate.isAfter(LocalDate.now(KOREA))) throw new IllegalArgumentException("종료일은 오늘 이후로 선택할 수 없습니다.");
|
|
||||||
if (startDate.plusDays(MAX_RANGE_DAYS - 1L).isBefore(endDate)) {
|
|
||||||
throw new IllegalArgumentException("분석 기간은 최대 " + MAX_RANGE_DAYS + "일입니다.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private record RawVideoMetric(String videoId, long views, long watchMinutes, double averageViewDurationSeconds,
|
|
||||||
long subscribersGained, long subscribersLost, long likes, long comments) { }
|
|
||||||
|
|
||||||
private record VideoMetadata(String title, String thumbnailUrl, Instant publishedAt) { }
|
|
||||||
|
|
||||||
private record Upload(Instant publishedAt) { }
|
|
||||||
}
|
|
||||||
@ -1,65 +0,0 @@
|
|||||||
package com.hlab.yanalyst.domain.channel.dto;
|
|
||||||
|
|
||||||
import com.hlab.yanalyst.domain.channel.ChannelVideo;
|
|
||||||
import com.hlab.yanalyst.domain.channel.FeedBadges;
|
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
|
||||||
import java.time.LocalDateTime;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 피드 카드 1건. 배지 판정은 서버에서 끝내고 화면은 그리기만 한다
|
|
||||||
* (같은 규칙이 JS 로 중복 구현되는 걸 막는다).
|
|
||||||
*/
|
|
||||||
public record FeedItemDto(
|
|
||||||
Long id,
|
|
||||||
String videoId,
|
|
||||||
String title,
|
|
||||||
String thumbnailUrl,
|
|
||||||
LocalDateTime publishedAt,
|
|
||||||
String channelTitle,
|
|
||||||
String ytChannelId,
|
|
||||||
Long viewCount,
|
|
||||||
BigDecimal viewsPerHour,
|
|
||||||
Integer durationSec,
|
|
||||||
String interestStatus,
|
|
||||||
boolean bookmarked,
|
|
||||||
String source,
|
|
||||||
String topic,
|
|
||||||
/** 인물 추적으로 걸렸다면 그 인물명. 인물 탭 카드에 배지로 표시한다. */
|
|
||||||
String matchedPerson,
|
|
||||||
/** 사이트 안에서 재생 가능한가. null 이면 아직 모름 — 일단 재생을 시도한다. */
|
|
||||||
Boolean embeddable,
|
|
||||||
/** 업로드 24시간 이내 — 선점 골든타임. */
|
|
||||||
boolean goldenTime,
|
|
||||||
/** SHORTS | CLIP | FULL | UNKNOWN */
|
|
||||||
String lengthBucket,
|
|
||||||
/** 경쟁 쇼츠가 지금 터지는 중. */
|
|
||||||
boolean rising,
|
|
||||||
/** 이미 손댄 소재(상태가 NEW 가 아님). */
|
|
||||||
boolean worked
|
|
||||||
) {
|
|
||||||
public static FeedItemDto from(ChannelVideo v, LocalDateTime now) {
|
|
||||||
return new FeedItemDto(
|
|
||||||
v.getId(),
|
|
||||||
v.getVideoId(),
|
|
||||||
v.getTitle(),
|
|
||||||
v.getThumbnailUrl(),
|
|
||||||
v.getPublishedAt(),
|
|
||||||
v.getChannelTitle(),
|
|
||||||
v.getYtChannelId(),
|
|
||||||
v.getViewCount(),
|
|
||||||
v.getViewsPerHour(),
|
|
||||||
v.getDurationSec(),
|
|
||||||
v.getInterestStatus(),
|
|
||||||
v.isBookmarked(),
|
|
||||||
v.getSource(),
|
|
||||||
v.feedTopicOrDefault().name(),
|
|
||||||
v.getMatchedPerson(),
|
|
||||||
v.getEmbeddable(),
|
|
||||||
FeedBadges.isGoldenTime(v.getPublishedAt(), now),
|
|
||||||
FeedBadges.lengthBucket(v.getDurationSec()),
|
|
||||||
FeedBadges.isRising(v.getViewsPerHour()),
|
|
||||||
FeedBadges.isWorked(v.getInterestStatus())
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,4 +0,0 @@
|
|||||||
package com.hlab.yanalyst.domain.channel.dto;
|
|
||||||
|
|
||||||
/** 피드 필터용 프로그램(원본 채널) 항목. */
|
|
||||||
public record FeedProgramDto(String ytChannelId, String channelTitle, Long videoCount) {}
|
|
||||||
@ -1,12 +0,0 @@
|
|||||||
package com.hlab.yanalyst.domain.channel.dto;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 피드 시드 채널 1건.
|
|
||||||
*
|
|
||||||
* @param role SOURCE(소재 원본) | RIVAL(경쟁 쇼츠)
|
|
||||||
* @param failCount 연속 수집 실패 횟수
|
|
||||||
* @param disabled 연속 실패 3회로 자동 스킵 중인지
|
|
||||||
*/
|
|
||||||
public record FeedSeedDto(Long id, String channelId, String title, String thumbnailUrl,
|
|
||||||
Long subscriberCount, String role, String topic, String format,
|
|
||||||
int failCount, boolean disabled) {}
|
|
||||||
@ -1,25 +0,0 @@
|
|||||||
package com.hlab.yanalyst.domain.channel.dto;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 내 채널 요약. 채널이 등록돼 있지 않으면 registered=false 만 채워 보낸다.
|
|
||||||
*
|
|
||||||
* @param avgViews 영상 평균 조회수(소재별 랭킹의 비교 기준선)
|
|
||||||
* @param last30Views 최근 30일 업로드분의 조회수 합
|
|
||||||
*/
|
|
||||||
public record MyChannelSummaryDto(
|
|
||||||
boolean registered,
|
|
||||||
Long id,
|
|
||||||
String channelId,
|
|
||||||
String title,
|
|
||||||
String thumbnailUrl,
|
|
||||||
Long subscriberCount,
|
|
||||||
Long viewCount,
|
|
||||||
Long videoCount,
|
|
||||||
int collectedVideos,
|
|
||||||
long avgViews,
|
|
||||||
long last30Views
|
|
||||||
) {
|
|
||||||
public static MyChannelSummaryDto empty() {
|
|
||||||
return new MyChannelSummaryDto(false, null, null, null, null, null, null, null, 0, 0, 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,13 +0,0 @@
|
|||||||
package com.hlab.yanalyst.domain.channel.dto;
|
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 추적 인물 1건.
|
|
||||||
*
|
|
||||||
* @param videoCount 지금까지 이 인물로 담긴 영상 수
|
|
||||||
* @param lastCollectedAt 마지막 수집 시각
|
|
||||||
* @param lastFound 마지막 수집에서 담은 롱폼 수
|
|
||||||
*/
|
|
||||||
public record TrackedPersonDto(Long id, String name, boolean enabled, long videoCount,
|
|
||||||
LocalDateTime lastCollectedAt, Integer lastFound) {}
|
|
||||||
@ -1,39 +0,0 @@
|
|||||||
package com.hlab.yanalyst.domain.channel.dto;
|
|
||||||
|
|
||||||
import java.time.Instant;
|
|
||||||
import java.time.LocalDate;
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
/** OAuth로 연결된 내 YouTube 채널의 기간별 분석 응답. */
|
|
||||||
public record YoutubeChannelAnalyticsDto(
|
|
||||||
ChannelDto channel,
|
|
||||||
PeriodDto period,
|
|
||||||
SummaryDto summary,
|
|
||||||
List<DailyDto> daily,
|
|
||||||
List<VideoDto> videos,
|
|
||||||
UploadRhythmDto uploadRhythm
|
|
||||||
) {
|
|
||||||
public record ChannelDto(Long connectionId, String channelId, String title, String handle,
|
|
||||||
String thumbnailUrl, Instant lastSyncedAt) { }
|
|
||||||
|
|
||||||
public record PeriodDto(LocalDate startDate, LocalDate endDate, long days) { }
|
|
||||||
|
|
||||||
public record SummaryDto(long views, long watchMinutes, double averageViewDurationSeconds,
|
|
||||||
long subscribersGained, long subscribersLost, long netSubscribers,
|
|
||||||
long likes, long comments, long uploads, double viewsPerUpload) { }
|
|
||||||
|
|
||||||
public record DailyDto(LocalDate date, long views, long watchMinutes,
|
|
||||||
double averageViewDurationSeconds, long subscribersGained,
|
|
||||||
long subscribersLost, long likes, long comments) { }
|
|
||||||
|
|
||||||
public record VideoDto(String videoId, String title, String thumbnailUrl, Instant publishedAt,
|
|
||||||
long views, long watchMinutes, double averageViewDurationSeconds,
|
|
||||||
long subscribersGained, long subscribersLost, long netSubscribers,
|
|
||||||
long likes, long comments) { }
|
|
||||||
|
|
||||||
public record UploadRhythmDto(long uploads, double averageDaysBetweenUploads,
|
|
||||||
int mostActiveWeekday, int mostActiveHour,
|
|
||||||
List<BucketDto> weekdayCounts, List<BucketDto> hourCounts) { }
|
|
||||||
|
|
||||||
public record BucketDto(int value, long count) { }
|
|
||||||
}
|
|
||||||
@ -1,43 +0,0 @@
|
|||||||
package com.hlab.yanalyst.domain.facebook;
|
|
||||||
|
|
||||||
import com.hlab.yanalyst.global.common.ApiResponse;
|
|
||||||
import jakarta.validation.Valid;
|
|
||||||
import jakarta.validation.constraints.NotBlank;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import org.springframework.core.io.Resource;
|
|
||||||
import org.springframework.http.*;
|
|
||||||
import org.springframework.web.bind.annotation.*;
|
|
||||||
|
|
||||||
import java.nio.charset.StandardCharsets;
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
@RestController
|
|
||||||
@RequestMapping("/api/facebook-downloads")
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
public class FacebookDownloadController {
|
|
||||||
private final FacebookDownloadService service;
|
|
||||||
|
|
||||||
public record CreateRequest(@NotBlank(message = "페이스북 URL을 입력하세요.") String url) { }
|
|
||||||
|
|
||||||
@GetMapping public ApiResponse<List<FacebookDownloadJob>> list() { return ApiResponse.ok(service.list()); }
|
|
||||||
|
|
||||||
@PostMapping public ResponseEntity<ApiResponse<FacebookDownloadJob>> create(@Valid @RequestBody CreateRequest request) {
|
|
||||||
return ResponseEntity.status(HttpStatus.CREATED).body(ApiResponse.created(service.create(request.url())));
|
|
||||||
}
|
|
||||||
|
|
||||||
@GetMapping("/{id}/file")
|
|
||||||
public ResponseEntity<Resource> file(@PathVariable Long id) {
|
|
||||||
FacebookDownloadJob job = service.get(id);
|
|
||||||
Resource resource = service.file(id);
|
|
||||||
ContentDisposition disposition = ContentDisposition.attachment()
|
|
||||||
.filename(job.getFileName(), StandardCharsets.UTF_8).build();
|
|
||||||
return ResponseEntity.ok().contentType(MediaType.APPLICATION_OCTET_STREAM)
|
|
||||||
.header(HttpHeaders.CONTENT_DISPOSITION, disposition.toString())
|
|
||||||
.contentLength(job.getFileSize()).body(resource);
|
|
||||||
}
|
|
||||||
|
|
||||||
@DeleteMapping("/{id}") public ApiResponse<Void> delete(@PathVariable Long id) {
|
|
||||||
service.delete(id);
|
|
||||||
return ApiResponse.ok(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,54 +0,0 @@
|
|||||||
package com.hlab.yanalyst.domain.facebook;
|
|
||||||
|
|
||||||
import jakarta.persistence.*;
|
|
||||||
import lombok.AccessLevel;
|
|
||||||
import lombok.Getter;
|
|
||||||
import lombok.NoArgsConstructor;
|
|
||||||
import org.springframework.data.annotation.CreatedDate;
|
|
||||||
import org.springframework.data.annotation.LastModifiedDate;
|
|
||||||
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
|
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
|
||||||
|
|
||||||
@Entity
|
|
||||||
@Table(name = "facebook_download_jobs")
|
|
||||||
@Getter
|
|
||||||
@NoArgsConstructor(access = AccessLevel.PROTECTED)
|
|
||||||
@EntityListeners(AuditingEntityListener.class)
|
|
||||||
public class FacebookDownloadJob {
|
|
||||||
public enum Status { QUEUED, DOWNLOADING, READY, FAILED }
|
|
||||||
|
|
||||||
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
|
|
||||||
private Long id;
|
|
||||||
@Column(nullable = false, length = 2000) private String sourceUrl;
|
|
||||||
@Enumerated(EnumType.STRING) @Column(nullable = false, length = 20) private Status status;
|
|
||||||
@Column(length = 500) private String fileName;
|
|
||||||
private long fileSize;
|
|
||||||
@Column(columnDefinition = "TEXT") private String errorMessage;
|
|
||||||
@CreatedDate @Column(updatable = false) private LocalDateTime createdAt;
|
|
||||||
@LastModifiedDate private LocalDateTime updatedAt;
|
|
||||||
|
|
||||||
public static FacebookDownloadJob queued(String sourceUrl) {
|
|
||||||
FacebookDownloadJob job = new FacebookDownloadJob();
|
|
||||||
job.sourceUrl = sourceUrl;
|
|
||||||
job.status = Status.QUEUED;
|
|
||||||
return job;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void start() {
|
|
||||||
status = Status.DOWNLOADING;
|
|
||||||
errorMessage = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void complete(String fileName, long fileSize) {
|
|
||||||
this.fileName = fileName;
|
|
||||||
this.fileSize = fileSize;
|
|
||||||
this.status = Status.READY;
|
|
||||||
this.errorMessage = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void fail(String message) {
|
|
||||||
status = Status.FAILED;
|
|
||||||
errorMessage = message == null || message.isBlank() ? "영상 다운로드에 실패했습니다." : message;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,9 +0,0 @@
|
|||||||
package com.hlab.yanalyst.domain.facebook;
|
|
||||||
|
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
public interface FacebookDownloadJobRepository extends JpaRepository<FacebookDownloadJob, Long> {
|
|
||||||
List<FacebookDownloadJob> findAllByOrderByCreatedAtDesc();
|
|
||||||
}
|
|
||||||
@ -1,98 +0,0 @@
|
|||||||
package com.hlab.yanalyst.domain.facebook;
|
|
||||||
|
|
||||||
import jakarta.persistence.EntityNotFoundException;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
|
||||||
import org.springframework.core.io.FileSystemResource;
|
|
||||||
import org.springframework.core.io.Resource;
|
|
||||||
import org.springframework.stereotype.Service;
|
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
|
||||||
|
|
||||||
import java.net.URI;
|
|
||||||
import java.nio.file.Files;
|
|
||||||
import java.nio.file.Path;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Locale;
|
|
||||||
import java.util.Set;
|
|
||||||
|
|
||||||
@Service
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
public class FacebookDownloadService {
|
|
||||||
private static final Set<String> ALLOWED_HOSTS = Set.of("facebook.com", "fb.watch");
|
|
||||||
|
|
||||||
private final FacebookDownloadJobRepository repository;
|
|
||||||
private final FacebookDownloadWorker worker;
|
|
||||||
|
|
||||||
@Value("${facebook-download.dir:downloads/facebook}")
|
|
||||||
private String downloadDir;
|
|
||||||
|
|
||||||
public FacebookDownloadJob create(String sourceUrl) {
|
|
||||||
String normalized = validateUrl(sourceUrl);
|
|
||||||
FacebookDownloadJob job = repository.save(FacebookDownloadJob.queued(normalized));
|
|
||||||
worker.download(job.getId());
|
|
||||||
return job;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
|
||||||
public List<FacebookDownloadJob> list() {
|
|
||||||
return repository.findAllByOrderByCreatedAtDesc();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
|
||||||
public FacebookDownloadJob get(Long id) {
|
|
||||||
return repository.findById(id)
|
|
||||||
.orElseThrow(() -> new EntityNotFoundException("다운로드 작업을 찾을 수 없습니다: " + id));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
|
||||||
public Resource file(Long id) {
|
|
||||||
FacebookDownloadJob job = get(id);
|
|
||||||
if (job.getStatus() != FacebookDownloadJob.Status.READY || job.getFileName() == null) {
|
|
||||||
throw new IllegalStateException("아직 다운로드할 수 있는 파일이 없습니다.");
|
|
||||||
}
|
|
||||||
Path file = safeFile(job.getFileName());
|
|
||||||
if (!Files.isRegularFile(file)) {
|
|
||||||
throw new EntityNotFoundException("다운로드 파일이 없습니다.");
|
|
||||||
}
|
|
||||||
return new FileSystemResource(file);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional
|
|
||||||
public void delete(Long id) {
|
|
||||||
FacebookDownloadJob job = get(id);
|
|
||||||
if (job.getStatus() == FacebookDownloadJob.Status.QUEUED
|
|
||||||
|| job.getStatus() == FacebookDownloadJob.Status.DOWNLOADING) {
|
|
||||||
throw new IllegalArgumentException("진행 중인 다운로드는 삭제할 수 없습니다.");
|
|
||||||
}
|
|
||||||
if (job.getFileName() != null) {
|
|
||||||
try { Files.deleteIfExists(safeFile(job.getFileName())); }
|
|
||||||
catch (Exception e) { throw new IllegalStateException("파일을 삭제하지 못했습니다: " + e.getMessage()); }
|
|
||||||
}
|
|
||||||
repository.delete(job);
|
|
||||||
}
|
|
||||||
|
|
||||||
private Path safeFile(String fileName) {
|
|
||||||
Path dir = Path.of(downloadDir).toAbsolutePath().normalize();
|
|
||||||
Path file = dir.resolve(fileName).normalize();
|
|
||||||
if (!file.getParent().equals(dir)) throw new IllegalStateException("잘못된 파일 경로입니다.");
|
|
||||||
return file;
|
|
||||||
}
|
|
||||||
|
|
||||||
static String validateUrl(String value) {
|
|
||||||
if (value == null || value.isBlank()) throw new IllegalArgumentException("페이스북 URL을 입력하세요.");
|
|
||||||
if (value.trim().length() > 2000) throw new IllegalArgumentException("URL은 2,000자 이하여야 합니다.");
|
|
||||||
try {
|
|
||||||
URI uri = URI.create(value.trim());
|
|
||||||
String scheme = uri.getScheme() == null ? "" : uri.getScheme().toLowerCase(Locale.ROOT);
|
|
||||||
String host = uri.getHost() == null ? "" : uri.getHost().toLowerCase(Locale.ROOT);
|
|
||||||
boolean allowedHost = ALLOWED_HOSTS.stream().anyMatch(h -> host.equals(h) || host.endsWith("." + h));
|
|
||||||
if (!(scheme.equals("http") || scheme.equals("https")) || !allowedHost || uri.getUserInfo() != null) {
|
|
||||||
throw new IllegalArgumentException("facebook.com 또는 fb.watch의 HTTP(S) URL만 입력할 수 있습니다.");
|
|
||||||
}
|
|
||||||
return uri.toString();
|
|
||||||
} catch (IllegalArgumentException e) {
|
|
||||||
if (e.getMessage() != null && e.getMessage().contains("HTTP(S)")) throw e;
|
|
||||||
throw new IllegalArgumentException("올바른 페이스북 URL을 입력하세요.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,96 +0,0 @@
|
|||||||
package com.hlab.yanalyst.domain.facebook;
|
|
||||||
|
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
|
||||||
import org.springframework.scheduling.annotation.Async;
|
|
||||||
import org.springframework.stereotype.Service;
|
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
|
||||||
|
|
||||||
import java.io.BufferedReader;
|
|
||||||
import java.io.InputStreamReader;
|
|
||||||
import java.nio.charset.StandardCharsets;
|
|
||||||
import java.nio.file.Files;
|
|
||||||
import java.nio.file.Path;
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.Comparator;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.concurrent.TimeUnit;
|
|
||||||
|
|
||||||
@Slf4j
|
|
||||||
@Service
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
public class FacebookDownloadWorker {
|
|
||||||
private final FacebookDownloadJobRepository repository;
|
|
||||||
|
|
||||||
@Value("${ytdlp.bin:yt-dlp}") private String ytdlpBin;
|
|
||||||
@Value("${ytdlp.ffmpeg-location:}") private String ffmpegLocation;
|
|
||||||
@Value("${ytdlp.timeout-seconds:600}") private long timeoutSeconds;
|
|
||||||
@Value("${facebook-download.dir:downloads/facebook}") private String downloadDir;
|
|
||||||
|
|
||||||
@Async
|
|
||||||
public void download(Long id) {
|
|
||||||
FacebookDownloadJob job = repository.findById(id).orElse(null);
|
|
||||||
if (job == null) return;
|
|
||||||
markStarted(job);
|
|
||||||
try {
|
|
||||||
Path dir = Path.of(downloadDir).toAbsolutePath().normalize();
|
|
||||||
Files.createDirectories(dir);
|
|
||||||
Path outputTemplate = dir.resolve(id + ".%(ext)s");
|
|
||||||
List<String> command = buildCommand(ytdlpBin, ffmpegLocation, job.getSourceUrl(), outputTemplate);
|
|
||||||
Process process = new ProcessBuilder(command).redirectErrorStream(true).start();
|
|
||||||
StringBuilder output = new StringBuilder();
|
|
||||||
Thread drainer = Thread.ofPlatform().daemon().start(() -> {
|
|
||||||
try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8))) {
|
|
||||||
reader.lines().forEach(line -> output.append(line).append('\n'));
|
|
||||||
} catch (Exception ignored) { }
|
|
||||||
});
|
|
||||||
if (!process.waitFor(timeoutSeconds, TimeUnit.SECONDS)) {
|
|
||||||
process.destroyForcibly();
|
|
||||||
throw new IllegalStateException("다운로드 제한 시간(" + timeoutSeconds + "초)을 초과했습니다.");
|
|
||||||
}
|
|
||||||
drainer.join(2000);
|
|
||||||
if (process.exitValue() != 0) throw new IllegalStateException(errorTail(output.toString()));
|
|
||||||
Path file = findOutput(dir, id);
|
|
||||||
markComplete(id, file.getFileName().toString(), Files.size(file));
|
|
||||||
} catch (Exception e) {
|
|
||||||
if (e instanceof InterruptedException) Thread.currentThread().interrupt();
|
|
||||||
log.warn("페이스북 다운로드 실패: id={}", id, e);
|
|
||||||
markFailed(id, friendlyMessage(e));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static List<String> buildCommand(String bin, String ffmpeg, String url, Path outputTemplate) {
|
|
||||||
FacebookDownloadService.validateUrl(url);
|
|
||||||
List<String> command = new ArrayList<>(List.of(bin, "--no-playlist", "--no-part", "--restrict-filenames",
|
|
||||||
"-f", "bv*+ba/b", "--merge-output-format", "mp4"));
|
|
||||||
if (ffmpeg != null && !ffmpeg.isBlank()) command.addAll(List.of("--ffmpeg-location", ffmpeg.trim()));
|
|
||||||
command.addAll(List.of("-o", outputTemplate.toString(), url));
|
|
||||||
return command;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Path findOutput(Path dir, Long id) throws Exception {
|
|
||||||
try (var files = Files.list(dir)) {
|
|
||||||
return files.filter(Files::isRegularFile)
|
|
||||||
.filter(path -> path.getFileName().toString().startsWith(id + "."))
|
|
||||||
.max(Comparator.comparingLong(path -> path.toFile().lastModified()))
|
|
||||||
.orElseThrow(() -> new IllegalStateException("다운로드 결과 파일을 찾을 수 없습니다."));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String errorTail(String output) {
|
|
||||||
String clean = output == null ? "" : output.strip();
|
|
||||||
if (clean.length() > 1000) clean = clean.substring(clean.length() - 1000);
|
|
||||||
return clean.isBlank() ? "yt-dlp가 영상을 받지 못했습니다." : clean;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String friendlyMessage(Exception e) {
|
|
||||||
String message = e.getMessage();
|
|
||||||
if (message == null || message.isBlank()) return "영상 다운로드에 실패했습니다.";
|
|
||||||
return message.length() > 1500 ? message.substring(message.length() - 1500) : message;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional protected void markStarted(FacebookDownloadJob job) { job.start(); repository.save(job); }
|
|
||||||
@Transactional protected void markComplete(Long id, String name, long size) { repository.findById(id).ifPresent(job -> { job.complete(name, size); repository.save(job); }); }
|
|
||||||
@Transactional protected void markFailed(Long id, String message) { repository.findById(id).ifPresent(job -> { job.fail(message); repository.save(job); }); }
|
|
||||||
}
|
|
||||||
@ -0,0 +1,53 @@
|
|||||||
|
package com.hlab.yanalyst.domain.publish;
|
||||||
|
|
||||||
|
import com.hlab.yanalyst.global.common.ApiResponse;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1/publish")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Tag(name = "Publish API", description = "발행(배포) 패키지 준비/추적")
|
||||||
|
public class PublishController {
|
||||||
|
|
||||||
|
private final PublishService publishService;
|
||||||
|
|
||||||
|
@GetMapping("/by-video/{channelVideoId}")
|
||||||
|
@Operation(summary = "영상별 발행 패키지 조회", description = "없으면 data=null.")
|
||||||
|
public ApiResponse<PublishPackage> getByVideo(@PathVariable Long channelVideoId) {
|
||||||
|
return ApiResponse.ok(publishService.getByVideo(channelVideoId));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/by-video/{channelVideoId}")
|
||||||
|
@Operation(summary = "발행 패키지 저장(upsert)",
|
||||||
|
description = "body: {title, description, hashtags, platform, scheduledAt(ISO), status(DRAFT|READY|PUBLISHED)}")
|
||||||
|
public ApiResponse<PublishPackage> upsert(@PathVariable Long channelVideoId, @RequestBody Map<String, String> body) {
|
||||||
|
LocalDateTime scheduledAt = null;
|
||||||
|
String s = body.get("scheduledAt");
|
||||||
|
if (s != null && !s.isBlank()) {
|
||||||
|
scheduledAt = LocalDateTime.parse(s.length() == 16 ? s + ":00" : s);
|
||||||
|
}
|
||||||
|
PublishPackage p = publishService.upsert(channelVideoId,
|
||||||
|
body.get("title"), body.get("description"), body.get("hashtags"),
|
||||||
|
body.get("platform"), scheduledAt, body.get("status"));
|
||||||
|
return ApiResponse.ok(p);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/{id}/published")
|
||||||
|
@Operation(summary = "발행 완료 처리", description = "body: {url} — 상태를 PUBLISHED 로 바꾸고 URL/시각 기록.")
|
||||||
|
public ApiResponse<PublishPackage> markPublished(@PathVariable Long id, @RequestBody Map<String, String> body) {
|
||||||
|
return ApiResponse.ok(publishService.markPublished(id, body.get("url")));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
@Operation(summary = "발행 큐 조회", description = "status(DRAFT|READY|PUBLISHED) 필터, 예약일 순.")
|
||||||
|
public ApiResponse<List<PublishPackage>> list(@RequestParam(required = false) String status) {
|
||||||
|
return ApiResponse.ok(publishService.list(status));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,69 @@
|
|||||||
|
package com.hlab.yanalyst.domain.publish;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
import lombok.Setter;
|
||||||
|
import org.springframework.data.annotation.CreatedDate;
|
||||||
|
import org.springframework.data.annotation.LastModifiedDate;
|
||||||
|
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 발행(배포) 패키지 — 재가공한 영상을 어디에/언제/어떤 메타데이터로 올릴지 준비하고,
|
||||||
|
* 발행 결과(URL)를 기록한다. 실제 업로드는 수동(또는 추후 플랫폼 API 연동) — 여기는 준비·추적 단계.
|
||||||
|
* ChannelVideo 와 1:1.
|
||||||
|
*/
|
||||||
|
@Entity
|
||||||
|
@Table(name = "publish_packages",
|
||||||
|
uniqueConstraints = @UniqueConstraint(columnNames = "channel_video_id"))
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@NoArgsConstructor
|
||||||
|
@EntityListeners(AuditingEntityListener.class)
|
||||||
|
public class PublishPackage {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@Column(name = "channel_video_id", nullable = false)
|
||||||
|
private Long channelVideoId;
|
||||||
|
|
||||||
|
@Column(columnDefinition = "TEXT")
|
||||||
|
private String title;
|
||||||
|
|
||||||
|
@Column(columnDefinition = "TEXT")
|
||||||
|
private String description;
|
||||||
|
|
||||||
|
@Column(columnDefinition = "TEXT")
|
||||||
|
private String hashtags;
|
||||||
|
|
||||||
|
/** 대상 플랫폼: YOUTUBE / TIKTOK / REELS 등. */
|
||||||
|
@Column(length = 100)
|
||||||
|
private String platform = "YOUTUBE";
|
||||||
|
|
||||||
|
/** 발행 예약 일시(선택). */
|
||||||
|
@Column(name = "scheduled_at")
|
||||||
|
private LocalDateTime scheduledAt;
|
||||||
|
|
||||||
|
/** DRAFT(작성중) / READY(발행대기) / PUBLISHED(발행완료). */
|
||||||
|
@Column(length = 20)
|
||||||
|
private String status = "DRAFT";
|
||||||
|
|
||||||
|
/** 발행 완료 시 실제 업로드된 URL(수동 기록). */
|
||||||
|
@Column(name = "published_url", columnDefinition = "TEXT")
|
||||||
|
private String publishedUrl;
|
||||||
|
|
||||||
|
@Column(name = "published_at")
|
||||||
|
private LocalDateTime publishedAt;
|
||||||
|
|
||||||
|
@CreatedDate
|
||||||
|
@Column(name = "created_at", updatable = false)
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
@LastModifiedDate
|
||||||
|
@Column(name = "updated_at")
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
}
|
||||||
@ -0,0 +1,22 @@
|
|||||||
|
package com.hlab.yanalyst.domain.publish;
|
||||||
|
|
||||||
|
import org.springframework.data.domain.Sort;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.Query;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
public interface PublishPackageRepository extends JpaRepository<PublishPackage, Long> {
|
||||||
|
Optional<PublishPackage> findByChannelVideoId(Long channelVideoId);
|
||||||
|
List<PublishPackage> findByStatus(String status, Sort sort);
|
||||||
|
long countByStatus(String status);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 전체 조회(정렬 포함). {@code findAll(Sort)}는 Criteria 기반이라 nullsLast/First 같은
|
||||||
|
* null precedence를 지원하지 않으므로(UnsupportedOperationException), Sort를 HQL ORDER BY로
|
||||||
|
* 붙이는 @Query 방식을 사용한다.
|
||||||
|
*/
|
||||||
|
@Query("select p from PublishPackage p")
|
||||||
|
List<PublishPackage> findAllSorted(Sort sort);
|
||||||
|
}
|
||||||
@ -0,0 +1,84 @@
|
|||||||
|
package com.hlab.yanalyst.domain.publish;
|
||||||
|
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.data.domain.Sort;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
import org.springframework.util.StringUtils;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public class PublishService {
|
||||||
|
|
||||||
|
private final PublishPackageRepository repository;
|
||||||
|
|
||||||
|
private static final Set<String> ALLOWED_STATUS = Set.of("DRAFT", "READY", "PUBLISHED");
|
||||||
|
/** 대시보드 표시 순서가 고정되도록 명시적 순서 보장(Set.of 는 반복 순서 미정의). */
|
||||||
|
private static final List<String> STATUS_ORDER = List.of("DRAFT", "READY", "PUBLISHED");
|
||||||
|
|
||||||
|
public PublishPackage getByVideo(Long channelVideoId) {
|
||||||
|
return repository.findByChannelVideoId(channelVideoId).orElse(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 영상별 발행 패키지 upsert. */
|
||||||
|
@Transactional
|
||||||
|
public PublishPackage upsert(Long channelVideoId, String title, String description, String hashtags,
|
||||||
|
String platform, LocalDateTime scheduledAt, String status) {
|
||||||
|
if (status != null && !ALLOWED_STATUS.contains(status)) {
|
||||||
|
throw new IllegalArgumentException("허용되지 않은 상태값: " + status + " (가능: " + ALLOWED_STATUS + ")");
|
||||||
|
}
|
||||||
|
PublishPackage p = repository.findByChannelVideoId(channelVideoId).orElseGet(() -> {
|
||||||
|
PublishPackage np = new PublishPackage();
|
||||||
|
np.setChannelVideoId(channelVideoId);
|
||||||
|
return np;
|
||||||
|
});
|
||||||
|
p.setTitle(title);
|
||||||
|
p.setDescription(description);
|
||||||
|
p.setHashtags(hashtags);
|
||||||
|
if (StringUtils.hasText(platform)) p.setPlatform(platform);
|
||||||
|
p.setScheduledAt(scheduledAt);
|
||||||
|
if (StringUtils.hasText(status)) p.setStatus(status);
|
||||||
|
return repository.save(p);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 발행 완료 처리(실제 업로드 URL 기록). */
|
||||||
|
@Transactional
|
||||||
|
public PublishPackage markPublished(Long id, String url) {
|
||||||
|
PublishPackage p = repository.findById(id)
|
||||||
|
.orElseThrow(() -> new IllegalArgumentException("Publish package not found: " + id));
|
||||||
|
p.setStatus("PUBLISHED");
|
||||||
|
p.setPublishedUrl(url);
|
||||||
|
p.setPublishedAt(LocalDateTime.now());
|
||||||
|
return repository.save(p);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 대시보드용 발행 요약: 상태별 카운트 + 최근 수정 5건. */
|
||||||
|
public java.util.Map<String, Object> dashboardSummary() {
|
||||||
|
java.util.Map<String, Long> byStatus = new java.util.LinkedHashMap<>();
|
||||||
|
for (String s : STATUS_ORDER) {
|
||||||
|
byStatus.put(s, repository.countByStatus(s));
|
||||||
|
}
|
||||||
|
// 최근 수정순으로 정렬해 상위 5건을 "최근" 으로 노출 (예약일 오름차순인 큐 정렬과 구분).
|
||||||
|
List<PublishPackage> all = repository.findAllSorted(Sort.by(Sort.Order.desc("updatedAt")));
|
||||||
|
List<PublishPackage> recent = all.size() > 5 ? List.copyOf(all.subList(0, 5)) : all;
|
||||||
|
java.util.Map<String, Object> result = new java.util.LinkedHashMap<>();
|
||||||
|
result.put("byStatus", byStatus);
|
||||||
|
result.put("total", (long) all.size());
|
||||||
|
result.put("recent", recent);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 발행 큐: 상태(null이면 전체)로 필터, 예약일 → 수정일 순. */
|
||||||
|
public List<PublishPackage> list(String status) {
|
||||||
|
Sort sort = Sort.by(Sort.Order.asc("scheduledAt").nullsLast(), Sort.Order.desc("updatedAt"));
|
||||||
|
if (StringUtils.hasText(status)) {
|
||||||
|
return repository.findByStatus(status, sort);
|
||||||
|
}
|
||||||
|
return repository.findAllSorted(sort);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,63 +0,0 @@
|
|||||||
package com.hlab.yanalyst.domain.reply;
|
|
||||||
|
|
||||||
import com.hlab.yanalyst.domain.reply.dto.ReplyCommentDto;
|
|
||||||
import com.hlab.yanalyst.global.common.ApiResponse;
|
|
||||||
import io.swagger.v3.oas.annotations.Operation;
|
|
||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import org.springframework.web.bind.annotation.*;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
|
|
||||||
/** 내 채널 댓글 답장 초안 API. 실제 게시는 OAuth 가 없어 불가 — 초안까지만. */
|
|
||||||
@RestController
|
|
||||||
@RequestMapping("/api/replies")
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
@Tag(name = "Comment Reply API", description = "내 채널 미응답 댓글 수집 + 답글 초안 생성")
|
|
||||||
public class CommentReplyController {
|
|
||||||
|
|
||||||
private final CommentReplyService service;
|
|
||||||
|
|
||||||
@GetMapping
|
|
||||||
@Operation(summary = "답장 대기 목록", description = "status 미지정이면 완료 제외 전체. 좋아요 많은 순.")
|
|
||||||
public ApiResponse<List<ReplyCommentDto>> queue(@RequestParam(required = false) String status) {
|
|
||||||
return ApiResponse.ok(service.queue(status));
|
|
||||||
}
|
|
||||||
|
|
||||||
@GetMapping("/stats")
|
|
||||||
@Operation(summary = "현황", description = "미응답/초안있음/완료 건수와 학습된 말투 샘플 수")
|
|
||||||
public ApiResponse<Map<String, Object>> stats() {
|
|
||||||
return ApiResponse.ok(service.stats());
|
|
||||||
}
|
|
||||||
|
|
||||||
@PostMapping("/collect")
|
|
||||||
@Operation(summary = "댓글 수집",
|
|
||||||
description = "내 채널 영상 전체를 돌며 내가 답글 안 단 댓글을 모은다. 영상당 1 unit.")
|
|
||||||
public ApiResponse<Map<String, Object>> collect() {
|
|
||||||
return ApiResponse.ok(service.collectAll());
|
|
||||||
}
|
|
||||||
|
|
||||||
@PostMapping("/{id}/draft")
|
|
||||||
@Operation(summary = "초안 생성",
|
|
||||||
description = "영상 내용 + 댓글을 근거로 서로 다른 초안 2개 생성. "
|
|
||||||
+ "refreshContext=true 면 캐시된 영상 요약을 버리고 다시 만든다(요약이 틀렸을 때).")
|
|
||||||
public ApiResponse<ReplyCommentDto> draft(@PathVariable Long id,
|
|
||||||
@RequestParam(defaultValue = "false") boolean refreshContext) {
|
|
||||||
return ApiResponse.ok(service.generateDrafts(id, refreshContext));
|
|
||||||
}
|
|
||||||
|
|
||||||
@PostMapping("/{id}/done")
|
|
||||||
@Operation(summary = "완료 표시", description = "실제로 답글을 달았을 때 목록에서 내린다")
|
|
||||||
public ApiResponse<Void> done(@PathVariable Long id) {
|
|
||||||
service.markDone(id);
|
|
||||||
return ApiResponse.ok(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
@PostMapping("/{id}/undo")
|
|
||||||
@Operation(summary = "완료 취소", description = "다시 미응답으로 되돌린다")
|
|
||||||
public ApiResponse<Void> undo(@PathVariable Long id) {
|
|
||||||
service.markPending(id);
|
|
||||||
return ApiResponse.ok(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,304 +0,0 @@
|
|||||||
package com.hlab.yanalyst.domain.reply;
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.JsonNode;
|
|
||||||
import com.hlab.yanalyst.domain.channel.Channel;
|
|
||||||
import com.hlab.yanalyst.domain.channel.ChannelRepository;
|
|
||||||
import com.hlab.yanalyst.domain.channel.ChannelVideo;
|
|
||||||
import com.hlab.yanalyst.domain.channel.ChannelVideoRepository;
|
|
||||||
import com.hlab.yanalyst.domain.reply.dto.ReplyCommentDto;
|
|
||||||
import com.hlab.yanalyst.global.schedule.YoutubeQuotaGuard;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
|
||||||
import org.springframework.data.domain.PageRequest;
|
|
||||||
import org.springframework.stereotype.Service;
|
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
|
||||||
import org.springframework.web.client.RestTemplate;
|
|
||||||
import org.springframework.web.util.UriComponentsBuilder;
|
|
||||||
|
|
||||||
import java.net.URI;
|
|
||||||
import java.time.LocalDateTime;
|
|
||||||
import java.time.format.DateTimeFormatter;
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.LinkedHashMap;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 내 채널 댓글을 모아 답글 초안을 만든다.
|
|
||||||
*
|
|
||||||
* <p>실제 답글 등록은 OAuth 가 필요해 불가능하다. 여기서는 초안까지만 만들고 복사해서 붙여넣는다.
|
|
||||||
*/
|
|
||||||
@Slf4j
|
|
||||||
@Service
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
public class CommentReplyService {
|
|
||||||
|
|
||||||
/** commentThreads.list 1회 추정 쿼터. */
|
|
||||||
private static final long COMMENT_QUOTA = 1;
|
|
||||||
|
|
||||||
private final ChannelRepository channelRepository;
|
|
||||||
private final ChannelVideoRepository channelVideoRepository;
|
|
||||||
private final VideoCommentRepository commentRepository;
|
|
||||||
private final ReplyStyleSampleRepository styleRepository;
|
|
||||||
private final YoutubeQuotaGuard quotaGuard;
|
|
||||||
private final RestTemplate restTemplate;
|
|
||||||
|
|
||||||
@Value("${youtube.api.key}")
|
|
||||||
private String youtubeApiKey;
|
|
||||||
|
|
||||||
@Value("${gemini.api-key:}")
|
|
||||||
private String geminiApiKey;
|
|
||||||
|
|
||||||
@Value("${gemini.model:gemini-2.5-flash}")
|
|
||||||
private String geminiModel;
|
|
||||||
|
|
||||||
// ---------- 수집 ----------
|
|
||||||
|
|
||||||
/** 내 채널(OWN) 영상 전체를 돌며 내가 답글 안 단 댓글을 모은다. */
|
|
||||||
public Map<String, Object> collectAll() {
|
|
||||||
List<Channel> own = channelRepository.findOwnChannels();
|
|
||||||
if (own.isEmpty()) {
|
|
||||||
throw new IllegalStateException("내 채널이 등록돼 있지 않습니다. '내 채널' 화면에서 먼저 등록하세요.");
|
|
||||||
}
|
|
||||||
Channel me = own.get(0);
|
|
||||||
List<ChannelVideo> videos = channelVideoRepository.findByChannelId(me.getId());
|
|
||||||
if (videos.isEmpty()) {
|
|
||||||
throw new IllegalStateException("수집된 내 영상이 없습니다. '내 채널' 화면에서 동기화를 먼저 하세요.");
|
|
||||||
}
|
|
||||||
|
|
||||||
int scanned = 0, saved = 0, alreadyReplied = 0, styleLearned = 0, skippedByQuota = 0, failed = 0;
|
|
||||||
|
|
||||||
for (ChannelVideo v : videos) {
|
|
||||||
if (!quotaGuard.tryConsume(COMMENT_QUOTA)) {
|
|
||||||
skippedByQuota++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
Counts c = collectForVideo(me.getChannelId(), v);
|
|
||||||
scanned += c.scanned;
|
|
||||||
saved += c.saved;
|
|
||||||
alreadyReplied += c.alreadyReplied;
|
|
||||||
styleLearned += c.styleLearned;
|
|
||||||
} catch (Exception e) {
|
|
||||||
failed++;
|
|
||||||
log.warn("[Reply] 영상 {} 댓글 수집 실패 — 건너뜀: {}", v.getVideoId(), e.getMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Map<String, Object> summary = new LinkedHashMap<>();
|
|
||||||
summary.put("videos", videos.size());
|
|
||||||
summary.put("scannedComments", scanned);
|
|
||||||
summary.put("pending", saved);
|
|
||||||
summary.put("alreadyReplied", alreadyReplied);
|
|
||||||
summary.put("styleLearned", styleLearned);
|
|
||||||
summary.put("failed", failed);
|
|
||||||
summary.put("skippedByQuota", skippedByQuota);
|
|
||||||
summary.put("quotaRemaining", quotaGuard.remaining());
|
|
||||||
log.info("[Reply] 댓글 수집 완료: {}", summary);
|
|
||||||
return summary;
|
|
||||||
}
|
|
||||||
|
|
||||||
private record Counts(int scanned, int saved, int alreadyReplied, int styleLearned) {}
|
|
||||||
|
|
||||||
/** 같은 빈에서 호출되므로 @Transactional 프록시가 안 걸린다 — 저장은 모두 명시적으로 한다. */
|
|
||||||
private Counts collectForVideo(String myChannelId, ChannelVideo video) {
|
|
||||||
URI uri = UriComponentsBuilder.fromHttpUrl("https://www.googleapis.com/youtube/v3/commentThreads")
|
|
||||||
.queryParam("part", "snippet,replies") // replies 가 있어야 내가 답했는지 안다
|
|
||||||
.queryParam("videoId", video.getVideoId())
|
|
||||||
.queryParam("maxResults", 100)
|
|
||||||
.queryParam("order", "relevance")
|
|
||||||
.queryParam("textFormat", "plainText")
|
|
||||||
.queryParam("key", youtubeApiKey)
|
|
||||||
.build().encode().toUri();
|
|
||||||
|
|
||||||
JsonNode root = restTemplate.getForObject(uri, JsonNode.class);
|
|
||||||
if (root == null) return new Counts(0, 0, 0, 0);
|
|
||||||
|
|
||||||
int scanned = 0, saved = 0, replied = 0, learned = 0;
|
|
||||||
|
|
||||||
for (JsonNode thread : root.path("items")) {
|
|
||||||
scanned++;
|
|
||||||
JsonNode top = thread.path("snippet").path("topLevelComment");
|
|
||||||
JsonNode s = top.path("snippet");
|
|
||||||
|
|
||||||
// 답글 작성자 수집 + 내 답글이면 말투 샘플로 학습
|
|
||||||
List<String> replyAuthors = new ArrayList<>();
|
|
||||||
for (JsonNode r : thread.path("replies").path("comments")) {
|
|
||||||
JsonNode rs = r.path("snippet");
|
|
||||||
String authorId = rs.path("authorChannelId").path("value").asText(null);
|
|
||||||
replyAuthors.add(authorId);
|
|
||||||
if (myChannelId.equals(authorId)) {
|
|
||||||
String replyId = r.path("id").asText(null);
|
|
||||||
String text = rs.path("textOriginal").asText("");
|
|
||||||
if (replyId != null && !text.isBlank() && !styleRepository.existsByReplyId(replyId)) {
|
|
||||||
styleRepository.save(new ReplyStyleSample(replyId, text));
|
|
||||||
learned++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ReplyDrafting.repliedByMe(replyAuthors, myChannelId)) {
|
|
||||||
replied++;
|
|
||||||
continue; // 이미 답한 건 대기열에 넣지 않는다
|
|
||||||
}
|
|
||||||
|
|
||||||
// 내가 쓴 최상위 댓글(고정댓글 등)은 답장 대상이 아니다
|
|
||||||
if (myChannelId.equals(s.path("authorChannelId").path("value").asText(null))) continue;
|
|
||||||
|
|
||||||
String commentId = top.path("id").asText(null);
|
|
||||||
if (commentId == null || commentId.isBlank()) continue;
|
|
||||||
|
|
||||||
String text = s.path("textOriginal").asText("");
|
|
||||||
Long likes = s.path("likeCount").asLong(0);
|
|
||||||
Integer replyCount = thread.path("snippet").path("totalReplyCount").asInt(0);
|
|
||||||
LocalDateTime published = parseTime(s.path("publishedAt").asText(null));
|
|
||||||
|
|
||||||
final int[] delta = {0};
|
|
||||||
commentRepository.findByCommentId(commentId)
|
|
||||||
.ifPresentOrElse(existing -> {
|
|
||||||
existing.refresh(likes, replyCount);
|
|
||||||
commentRepository.save(existing);
|
|
||||||
},
|
|
||||||
() -> {
|
|
||||||
commentRepository.save(new VideoComment(
|
|
||||||
commentId, video.getVideoId(), video.getTitle(), video.getThumbnailUrl(),
|
|
||||||
s.path("authorDisplayName").asText(""),
|
|
||||||
s.path("authorProfileImageUrl").asText(null),
|
|
||||||
text, likes, published, replyCount));
|
|
||||||
delta[0] = 1;
|
|
||||||
});
|
|
||||||
saved += delta[0];
|
|
||||||
}
|
|
||||||
return new Counts(scanned, saved, replied, learned);
|
|
||||||
}
|
|
||||||
|
|
||||||
private LocalDateTime parseTime(String iso) {
|
|
||||||
if (iso == null || iso.isBlank()) return null;
|
|
||||||
try {
|
|
||||||
return LocalDateTime.parse(iso, DateTimeFormatter.ISO_DATE_TIME);
|
|
||||||
} catch (Exception e) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- 조회 ----------
|
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
|
||||||
public List<ReplyCommentDto> queue(String status) {
|
|
||||||
String filter = (status == null || status.isBlank()) ? null : status.trim().toUpperCase();
|
|
||||||
List<ReplyCommentDto> out = new ArrayList<>();
|
|
||||||
for (VideoComment c : commentRepository.findQueue(filter)) out.add(ReplyCommentDto.from(c));
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
|
||||||
public Map<String, Object> stats() {
|
|
||||||
Map<String, Object> m = new LinkedHashMap<>();
|
|
||||||
m.put("pending", commentRepository.countByStatus(VideoComment.NEW));
|
|
||||||
m.put("drafted", commentRepository.countByStatus(VideoComment.DRAFTED));
|
|
||||||
m.put("done", commentRepository.countByStatus(VideoComment.DONE));
|
|
||||||
m.put("styleSamples", styleRepository.count());
|
|
||||||
return m;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- 초안 생성 ----------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 댓글 1건의 초안 2개를 만든다. 영상 내용 요약이 없으면 먼저 만들어 캐시한다.
|
|
||||||
*
|
|
||||||
* @param refreshContext 캐시된 요약이 틀렸을 때 다시 만들게 한다
|
|
||||||
*/
|
|
||||||
@Transactional
|
|
||||||
public ReplyCommentDto generateDrafts(Long id, boolean refreshContext) {
|
|
||||||
if (geminiApiKey == null || geminiApiKey.isBlank()) {
|
|
||||||
throw new IllegalStateException("Gemini API 키가 없습니다. gemini.api-key 를 설정하세요.");
|
|
||||||
}
|
|
||||||
VideoComment c = commentRepository.findById(id)
|
|
||||||
.orElseThrow(() -> new IllegalArgumentException("댓글을 찾을 수 없습니다: " + id));
|
|
||||||
|
|
||||||
String context = videoContext(c.getVideoId(), c.getVideoTitle(), refreshContext);
|
|
||||||
List<String> samples = ReplyDrafting.pickStyleSamples(
|
|
||||||
styleRepository.findAllByOrderByIdDesc(PageRequest.of(0, ReplyDrafting.MAX_STYLE_SAMPLES))
|
|
||||||
.stream().map(ReplyStyleSample::getText).toList());
|
|
||||||
|
|
||||||
String prompt = ReplyDrafting.buildPrompt(c.getVideoTitle(), context, c.getText(), samples);
|
|
||||||
ReplyDrafting.Drafts d = ReplyDrafting.parseDrafts(callGemini(prompt, null));
|
|
||||||
c.applyDrafts(d.a(), d.b());
|
|
||||||
commentRepository.save(c);
|
|
||||||
return ReplyCommentDto.from(c);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 영상 내용 요약. 없으면 Gemini에 유튜브 URL을 직접 줘서 만들고 저장한다.
|
|
||||||
* 님 영상은 30초 쇼츠라 토큰이 적고, 화면에 박힌 자막까지 읽힌다.
|
|
||||||
*/
|
|
||||||
private String videoContext(String videoId, String videoTitle, boolean force) {
|
|
||||||
ChannelVideo v = channelVideoRepository.findByVideoId(videoId).orElse(null);
|
|
||||||
if (v == null) return null;
|
|
||||||
if (!force && v.getContextSummary() != null && !v.getContextSummary().isBlank()) {
|
|
||||||
return v.getContextSummary();
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 제목을 반드시 함께 준다. 제목 없이 영상만 보여주면 출연자 이름을 추측해 틀린다
|
|
||||||
// (실측: "이채영" 영상을 "이채연"으로 요약했고, 그 오류가 답글까지 전파됐다).
|
|
||||||
String prompt = "이 영상의 내용을 한국어로 요약하세요.\n"
|
|
||||||
+ "영상 제목은 \"" + (videoTitle == null ? "" : videoTitle) + "\" 입니다.\n"
|
|
||||||
+ "누가 나오는지, 무슨 상황인지, 화면에 박힌 자막의 핵심 대사를 포함하세요.\n"
|
|
||||||
+ "사람 이름은 제목에 적힌 표기를 그대로 쓰고, 제목에 없으면 화면 자막에 적힌 표기를 그대로 쓰세요. "
|
|
||||||
+ "비슷한 이름으로 바꾸거나 추측해서 쓰지 마세요.\n"
|
|
||||||
+ "댓글에 답할 때 근거로 쓸 것이므로 사실만 적고 추측은 적지 마세요. 5문장 이내.";
|
|
||||||
String summary = callGemini(prompt, "https://www.youtube.com/watch?v=" + videoId);
|
|
||||||
if (summary != null && !summary.isBlank()) {
|
|
||||||
v.applyContextSummary(summary.trim());
|
|
||||||
channelVideoRepository.save(v);
|
|
||||||
return summary.trim();
|
|
||||||
}
|
|
||||||
} catch (Exception e) {
|
|
||||||
// 요약 실패해도 제목만으로 답글은 쓸 수 있다 — 초안 생성 자체를 막지 않는다
|
|
||||||
log.warn("[Reply] 영상 {} 요약 실패(제목만으로 진행): {}", videoId, e.getMessage());
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @param youtubeUrl 영상을 함께 보낼 때만 지정. null 이면 텍스트만 보낸다. */
|
|
||||||
private String callGemini(String prompt, String youtubeUrl) {
|
|
||||||
String url = "https://generativelanguage.googleapis.com/v1beta/models/"
|
|
||||||
+ geminiModel + ":generateContent?key=" + geminiApiKey;
|
|
||||||
|
|
||||||
List<Map<String, Object>> parts = new ArrayList<>();
|
|
||||||
parts.add(Map.of("text", prompt));
|
|
||||||
if (youtubeUrl != null) parts.add(Map.of("file_data", Map.of("file_uri", youtubeUrl)));
|
|
||||||
|
|
||||||
Map<String, Object> body = Map.of("contents", List.of(Map.of("parts", parts)));
|
|
||||||
JsonNode res = restTemplate.postForObject(url, body, JsonNode.class);
|
|
||||||
if (res == null) throw new IllegalStateException("Gemini 응답이 비어있습니다.");
|
|
||||||
if (res.has("error")) {
|
|
||||||
throw new IllegalStateException("Gemini 오류: " + res.path("error").path("message").asText(""));
|
|
||||||
}
|
|
||||||
StringBuilder sb = new StringBuilder();
|
|
||||||
for (JsonNode p : res.path("candidates").path(0).path("content").path("parts")) {
|
|
||||||
sb.append(p.path("text").asText(""));
|
|
||||||
}
|
|
||||||
return sb.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- 상태 ----------
|
|
||||||
|
|
||||||
@Transactional
|
|
||||||
public void markDone(Long id) {
|
|
||||||
VideoComment c = commentRepository.findById(id)
|
|
||||||
.orElseThrow(() -> new IllegalArgumentException("댓글을 찾을 수 없습니다: " + id));
|
|
||||||
c.markDone();
|
|
||||||
commentRepository.save(c);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional
|
|
||||||
public void markPending(Long id) {
|
|
||||||
VideoComment c = commentRepository.findById(id)
|
|
||||||
.orElseThrow(() -> new IllegalArgumentException("댓글을 찾을 수 없습니다: " + id));
|
|
||||||
c.markNew();
|
|
||||||
commentRepository.save(c);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,147 +0,0 @@
|
|||||||
package com.hlab.yanalyst.domain.reply;
|
|
||||||
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 답글 초안 생성의 순수 로직 — 미응답 판정, 말투 샘플 정리, 프롬프트 조립, 응답 파싱.
|
|
||||||
*
|
|
||||||
* <p>네트워크·DB에 의존하지 않아 그대로 테스트한다.
|
|
||||||
*/
|
|
||||||
public final class ReplyDrafting {
|
|
||||||
|
|
||||||
/** 말투 학습에 쓸 과거 답글 최대 개수. 너무 많으면 프롬프트만 길어진다. */
|
|
||||||
public static final int MAX_STYLE_SAMPLES = 5;
|
|
||||||
|
|
||||||
private ReplyDrafting() {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 내가 이미 답글을 달았는가.
|
|
||||||
*
|
|
||||||
* <p>totalReplyCount 로 판정하면 안 된다 — 남이 대댓글만 단 댓글은 여전히 미응답이다.
|
|
||||||
*
|
|
||||||
* @param replyAuthorChannelIds 이 댓글에 달린 답글 작성자들의 채널 ID
|
|
||||||
* @param myChannelId 내 채널 ID
|
|
||||||
*/
|
|
||||||
public static boolean repliedByMe(List<String> replyAuthorChannelIds, String myChannelId) {
|
|
||||||
if (myChannelId == null || myChannelId.isBlank()) return false;
|
|
||||||
if (replyAuthorChannelIds == null) return false;
|
|
||||||
for (String id : replyAuthorChannelIds) {
|
|
||||||
if (myChannelId.equals(id)) return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 말투 학습용 샘플을 고른다. 너무 짧은 것(ㅋㅋ, 감사합니다)은 말투 정보가 없어 뺀다.
|
|
||||||
*
|
|
||||||
* @param myReplies 과거에 내가 단 답글들
|
|
||||||
*/
|
|
||||||
public static List<String> pickStyleSamples(List<String> myReplies) {
|
|
||||||
List<String> out = new ArrayList<>();
|
|
||||||
if (myReplies == null) return out;
|
|
||||||
for (String r : myReplies) {
|
|
||||||
if (r == null) continue;
|
|
||||||
String t = r.trim();
|
|
||||||
if (t.length() < 8) continue; // "ㅋㅋㅋ", "감사합니다" 류 제외
|
|
||||||
out.add(t);
|
|
||||||
if (out.size() >= MAX_STYLE_SAMPLES) break;
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gemini 프롬프트를 조립한다.
|
|
||||||
*
|
|
||||||
* @param videoTitle 댓글이 달린 영상 제목
|
|
||||||
* @param videoContext 영상 내용 요약(자막·화면자막 기반). 없으면 null
|
|
||||||
* @param comment 댓글 본문
|
|
||||||
* @param styleSamples 내 과거 답글(말투 학습). 비어 있으면 기본 톤 지시를 쓴다
|
|
||||||
*/
|
|
||||||
public static String buildPrompt(String videoTitle, String videoContext, String comment,
|
|
||||||
List<String> styleSamples) {
|
|
||||||
StringBuilder sb = new StringBuilder();
|
|
||||||
sb.append("너는 예능·드라마 명장면을 잘라 올리는 유튜브 쇼츠 채널의 운영자다. ")
|
|
||||||
.append("아래 내 영상에 달린 댓글에 답글을 쓴다.\n\n");
|
|
||||||
|
|
||||||
boolean hasContext = videoContext != null && !videoContext.isBlank();
|
|
||||||
|
|
||||||
sb.append("[영상 제목]\n").append(nz(videoTitle)).append("\n\n");
|
|
||||||
if (hasContext) {
|
|
||||||
sb.append("[영상 내용]\n").append(videoContext.trim()).append("\n\n");
|
|
||||||
}
|
|
||||||
sb.append("[댓글]\n").append(nz(comment)).append("\n\n");
|
|
||||||
|
|
||||||
if (styleSamples != null && !styleSamples.isEmpty()) {
|
|
||||||
sb.append("[내가 예전에 쓴 답글 — 이 말투를 그대로 따라라]\n");
|
|
||||||
for (String s : styleSamples) sb.append("- ").append(s).append("\n");
|
|
||||||
sb.append("\n");
|
|
||||||
} else {
|
|
||||||
sb.append("[말투]\n")
|
|
||||||
.append("- 친근한 반말체는 쓰지 말고, 가볍고 유쾌한 존댓말로 쓴다\n")
|
|
||||||
.append("- 이모지는 최대 1개, 안 써도 된다\n\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
sb.append("[규칙]\n")
|
|
||||||
.append("- 1~2문장, 60자 이내로 짧게. 긴 답글은 읽히지 않는다\n")
|
|
||||||
.append("- 댓글 내용에 실제로 반응해라. \"시청 감사합니다\" 같은 복붙 답글은 금지\n");
|
|
||||||
// 요약이 없는데 "영상 내용을 근거로" 라고 하면 모델에게 없는 섹션을 가리키게 된다
|
|
||||||
if (hasContext) {
|
|
||||||
sb.append("- 영상 내용을 묻는 댓글이면 위 영상 내용을 근거로 정확히 답한다\n");
|
|
||||||
} else {
|
|
||||||
sb.append("- 영상 안에서 무슨 일이 있었는지는 모른다. 내용을 묻는 댓글이면 단정하지 말고 넘어간다\n");
|
|
||||||
}
|
|
||||||
// 실측에서 "이채영"을 "채연"으로 바꿔 쓰는 사고가 났다. 이름은 창작 대상이 아니다.
|
|
||||||
sb.append("- 사람 이름은 위에 적힌 표기를 글자 그대로 써라. 비슷한 이름으로 바꾸거나 줄이지 마라\n")
|
|
||||||
.append("- 위에 이름이 안 나왔으면 이름을 쓰지 말고 지칭을 피해라\n")
|
|
||||||
.append("- 모르는 사실을 지어내지 마라. 확실하지 않으면 단정하지 않는다\n")
|
|
||||||
.append("- 시비조·악의적인 댓글이면 맞받지 말고 담백하게 넘긴다\n")
|
|
||||||
.append("- 서로 다른 결의 답글 2개를 준다\n\n")
|
|
||||||
.append("[출력 형식] 다른 말 없이 아래 JSON만 출력한다\n")
|
|
||||||
.append("{\"a\": \"첫 번째 답글\", \"b\": \"두 번째 답글\"}");
|
|
||||||
|
|
||||||
return sb.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 초안 2개. */
|
|
||||||
public record Drafts(String a, String b) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 모델 응답에서 초안 2개를 뽑는다. 코드펜스·앞뒤 잡소리가 섞여도 JSON 부분만 골라낸다.
|
|
||||||
*
|
|
||||||
* @throws IllegalStateException 초안을 하나도 못 뽑았을 때
|
|
||||||
*/
|
|
||||||
public static Drafts parseDrafts(String raw) {
|
|
||||||
if (raw == null || raw.isBlank()) {
|
|
||||||
throw new IllegalStateException("모델이 빈 응답을 반환했습니다.");
|
|
||||||
}
|
|
||||||
String s = raw.trim();
|
|
||||||
int start = s.indexOf('{');
|
|
||||||
int end = s.lastIndexOf('}');
|
|
||||||
if (start >= 0 && end > start) s = s.substring(start, end + 1);
|
|
||||||
|
|
||||||
String a = extract(s, "a");
|
|
||||||
String b = extract(s, "b");
|
|
||||||
if (a == null && b == null) {
|
|
||||||
throw new IllegalStateException("응답에서 초안을 찾지 못했습니다: " + raw.substring(0, Math.min(120, raw.length())));
|
|
||||||
}
|
|
||||||
if (a == null) a = b;
|
|
||||||
if (b == null) b = a;
|
|
||||||
return new Drafts(a, b);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** "key": "value" 에서 value 를 꺼낸다(이스케이프된 따옴표 허용). */
|
|
||||||
private static String extract(String json, String key) {
|
|
||||||
java.util.regex.Matcher m = java.util.regex.Pattern
|
|
||||||
.compile("\"" + key + "\"\\s*:\\s*\"((?:\\\\.|[^\"\\\\])*)\"")
|
|
||||||
.matcher(json);
|
|
||||||
if (!m.find()) return null;
|
|
||||||
String v = m.group(1)
|
|
||||||
.replace("\\n", "\n").replace("\\\"", "\"").replace("\\\\", "\\");
|
|
||||||
return v.isBlank() ? null : v.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String nz(String s) {
|
|
||||||
return s == null ? "" : s.trim();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,40 +0,0 @@
|
|||||||
package com.hlab.yanalyst.domain.reply;
|
|
||||||
|
|
||||||
import jakarta.persistence.*;
|
|
||||||
import lombok.Getter;
|
|
||||||
import lombok.NoArgsConstructor;
|
|
||||||
import org.hibernate.annotations.CreationTimestamp;
|
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 내가 예전에 직접 단 답글. 초안의 말투를 여기서 학습한다.
|
|
||||||
* 댓글 수집 중에 내 채널이 작성한 답글을 발견하면 모아둔다.
|
|
||||||
*/
|
|
||||||
@Entity
|
|
||||||
@Table(name = "reply_style_samples",
|
|
||||||
indexes = @Index(name = "idx_rss_reply_id", columnList = "reply_id", unique = true))
|
|
||||||
@Getter
|
|
||||||
@NoArgsConstructor
|
|
||||||
public class ReplyStyleSample {
|
|
||||||
|
|
||||||
@Id
|
|
||||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
|
||||||
private Long id;
|
|
||||||
|
|
||||||
/** YouTube 답글 ID — 중복 저장 방지용. */
|
|
||||||
@Column(name = "reply_id", nullable = false, unique = true, length = 120)
|
|
||||||
private String replyId;
|
|
||||||
|
|
||||||
@Column(columnDefinition = "TEXT")
|
|
||||||
private String text;
|
|
||||||
|
|
||||||
@CreationTimestamp
|
|
||||||
@Column(updatable = false)
|
|
||||||
private LocalDateTime createdAt;
|
|
||||||
|
|
||||||
public ReplyStyleSample(String replyId, String text) {
|
|
||||||
this.replyId = replyId;
|
|
||||||
this.text = text;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,13 +0,0 @@
|
|||||||
package com.hlab.yanalyst.domain.reply;
|
|
||||||
|
|
||||||
import org.springframework.data.domain.Pageable;
|
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
public interface ReplyStyleSampleRepository extends JpaRepository<ReplyStyleSample, Long> {
|
|
||||||
boolean existsByReplyId(String replyId);
|
|
||||||
|
|
||||||
/** 최근에 쓴 답글부터. 말투는 최신 것이 지금 톤에 가깝다. */
|
|
||||||
List<ReplyStyleSample> findAllByOrderByIdDesc(Pageable pageable);
|
|
||||||
}
|
|
||||||
@ -1,116 +0,0 @@
|
|||||||
package com.hlab.yanalyst.domain.reply;
|
|
||||||
|
|
||||||
import jakarta.persistence.*;
|
|
||||||
import lombok.Getter;
|
|
||||||
import lombok.NoArgsConstructor;
|
|
||||||
import org.hibernate.annotations.CreationTimestamp;
|
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 내 채널 영상에 달린 최상위 댓글 중 <b>내가 아직 답글을 달지 않은</b> 것.
|
|
||||||
*
|
|
||||||
* <p>남이 대댓글을 단 건 여전히 미응답이므로, totalReplyCount 가 아니라
|
|
||||||
* 답글 작성자에 내 채널이 있는지로 판정한다({@link ReplyDrafting#repliedByMe}).
|
|
||||||
*/
|
|
||||||
@Entity
|
|
||||||
@Table(name = "video_comments", indexes = {
|
|
||||||
@Index(name = "idx_vc_comment_id", columnList = "comment_id", unique = true),
|
|
||||||
@Index(name = "idx_vc_status", columnList = "status"),
|
|
||||||
@Index(name = "idx_vc_video_id", columnList = "video_id")
|
|
||||||
})
|
|
||||||
@Getter
|
|
||||||
@NoArgsConstructor
|
|
||||||
public class VideoComment {
|
|
||||||
|
|
||||||
/** 미응답 / 초안있음 / 완료. */
|
|
||||||
public static final String NEW = "NEW";
|
|
||||||
public static final String DRAFTED = "DRAFTED";
|
|
||||||
public static final String DONE = "DONE";
|
|
||||||
|
|
||||||
@Id
|
|
||||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
|
||||||
private Long id;
|
|
||||||
|
|
||||||
@Column(name = "comment_id", nullable = false, unique = true, length = 120)
|
|
||||||
private String commentId;
|
|
||||||
|
|
||||||
/** 댓글이 달린 YouTube 영상 ID. */
|
|
||||||
@Column(name = "video_id", nullable = false, length = 40)
|
|
||||||
private String videoId;
|
|
||||||
|
|
||||||
@Column(name = "video_title", length = 500)
|
|
||||||
private String videoTitle;
|
|
||||||
|
|
||||||
@Column(name = "video_thumbnail_url", length = 2083)
|
|
||||||
private String videoThumbnailUrl;
|
|
||||||
|
|
||||||
@Column(name = "author_name")
|
|
||||||
private String authorName;
|
|
||||||
|
|
||||||
@Column(name = "author_image_url", length = 2083)
|
|
||||||
private String authorImageUrl;
|
|
||||||
|
|
||||||
@Column(columnDefinition = "TEXT")
|
|
||||||
private String text;
|
|
||||||
|
|
||||||
@Column(name = "like_count")
|
|
||||||
private Long likeCount;
|
|
||||||
|
|
||||||
@Column(name = "published_at")
|
|
||||||
private LocalDateTime publishedAt;
|
|
||||||
|
|
||||||
/** 다른 사람이 단 대댓글 수(참고용). 내 답글 여부와는 별개다. */
|
|
||||||
@Column(name = "reply_count")
|
|
||||||
private Integer replyCount;
|
|
||||||
|
|
||||||
@Column(nullable = false, length = 20)
|
|
||||||
private String status = NEW;
|
|
||||||
|
|
||||||
@Column(name = "draft_a", columnDefinition = "TEXT")
|
|
||||||
private String draftA;
|
|
||||||
|
|
||||||
@Column(name = "draft_b", columnDefinition = "TEXT")
|
|
||||||
private String draftB;
|
|
||||||
|
|
||||||
@CreationTimestamp
|
|
||||||
@Column(name = "collected_at", updatable = false)
|
|
||||||
private LocalDateTime collectedAt;
|
|
||||||
|
|
||||||
public VideoComment(String commentId, String videoId, String videoTitle, String videoThumbnailUrl,
|
|
||||||
String authorName, String authorImageUrl, String text, Long likeCount,
|
|
||||||
LocalDateTime publishedAt, Integer replyCount) {
|
|
||||||
this.commentId = commentId;
|
|
||||||
this.videoId = videoId;
|
|
||||||
this.videoTitle = videoTitle;
|
|
||||||
this.videoThumbnailUrl = videoThumbnailUrl;
|
|
||||||
this.authorName = authorName;
|
|
||||||
this.authorImageUrl = authorImageUrl;
|
|
||||||
this.text = text;
|
|
||||||
this.likeCount = likeCount;
|
|
||||||
this.publishedAt = publishedAt;
|
|
||||||
this.replyCount = replyCount;
|
|
||||||
this.status = NEW;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 수집 때마다 바뀔 수 있는 값만 갱신한다(초안·상태는 보존). */
|
|
||||||
public void refresh(Long likeCount, Integer replyCount) {
|
|
||||||
this.likeCount = likeCount;
|
|
||||||
this.replyCount = replyCount;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void applyDrafts(String draftA, String draftB) {
|
|
||||||
this.draftA = draftA;
|
|
||||||
this.draftB = draftB;
|
|
||||||
this.status = DRAFTED;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 답글을 실제로 달았다고 표시. */
|
|
||||||
public void markDone() {
|
|
||||||
this.status = DONE;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void markNew() {
|
|
||||||
this.status = NEW;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,24 +0,0 @@
|
|||||||
package com.hlab.yanalyst.domain.reply;
|
|
||||||
|
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
|
||||||
import org.springframework.data.jpa.repository.Query;
|
|
||||||
import org.springframework.data.repository.query.Param;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Optional;
|
|
||||||
|
|
||||||
public interface VideoCommentRepository extends JpaRepository<VideoComment, Long> {
|
|
||||||
|
|
||||||
Optional<VideoComment> findByCommentId(String commentId);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 답장 대기 목록. 상태 필터가 null 이면 완료를 제외한 전체.
|
|
||||||
* 좋아요 많은 순 → 최신순. 반응 큰 댓글부터 답하는 게 효율이 높다.
|
|
||||||
*/
|
|
||||||
@Query("select c from VideoComment c where "
|
|
||||||
+ "(:status is null and c.status <> 'DONE' or c.status = :status) "
|
|
||||||
+ "order by c.likeCount desc nulls last, c.publishedAt desc")
|
|
||||||
List<VideoComment> findQueue(@Param("status") String status);
|
|
||||||
|
|
||||||
long countByStatus(String status);
|
|
||||||
}
|
|
||||||
@ -1,30 +0,0 @@
|
|||||||
package com.hlab.yanalyst.domain.reply.dto;
|
|
||||||
|
|
||||||
import com.hlab.yanalyst.domain.reply.VideoComment;
|
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
|
||||||
|
|
||||||
/** 답장 대기 카드 1건. */
|
|
||||||
public record ReplyCommentDto(
|
|
||||||
Long id,
|
|
||||||
String commentId,
|
|
||||||
String videoId,
|
|
||||||
String videoTitle,
|
|
||||||
String videoThumbnailUrl,
|
|
||||||
String authorName,
|
|
||||||
String authorImageUrl,
|
|
||||||
String text,
|
|
||||||
Long likeCount,
|
|
||||||
LocalDateTime publishedAt,
|
|
||||||
Integer replyCount,
|
|
||||||
String status,
|
|
||||||
String draftA,
|
|
||||||
String draftB
|
|
||||||
) {
|
|
||||||
public static ReplyCommentDto from(VideoComment c) {
|
|
||||||
return new ReplyCommentDto(c.getId(), c.getCommentId(), c.getVideoId(), c.getVideoTitle(),
|
|
||||||
c.getVideoThumbnailUrl(), c.getAuthorName(), c.getAuthorImageUrl(), c.getText(),
|
|
||||||
c.getLikeCount(), c.getPublishedAt(), c.getReplyCount(), c.getStatus(),
|
|
||||||
c.getDraftA(), c.getDraftB());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,11 +0,0 @@
|
|||||||
package com.hlab.yanalyst.domain.shortform;
|
|
||||||
|
|
||||||
/** Opal 출력에서 파싱한 구간 하나. capcutJson 등 원문 조각은 파싱 실패해도 보존한다. */
|
|
||||||
public record ParsedClip(
|
|
||||||
int clipNo,
|
|
||||||
String capcutJson,
|
|
||||||
String titleCandidates,
|
|
||||||
String durationTable,
|
|
||||||
String titleTop,
|
|
||||||
String titleMain
|
|
||||||
) {}
|
|
||||||
@ -1,10 +0,0 @@
|
|||||||
package com.hlab.yanalyst.domain.shortform;
|
|
||||||
|
|
||||||
public enum ShortformCategory {
|
|
||||||
ENTERTAINMENT,
|
|
||||||
POLITICS;
|
|
||||||
|
|
||||||
public static ShortformCategory normalize(ShortformCategory value) {
|
|
||||||
return value == null ? ENTERTAINMENT : value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,51 +0,0 @@
|
|||||||
package com.hlab.yanalyst.domain.shortform;
|
|
||||||
|
|
||||||
import jakarta.persistence.*;
|
|
||||||
import lombok.Getter;
|
|
||||||
import lombok.NoArgsConstructor;
|
|
||||||
|
|
||||||
/** 작업 하나에서 나온 구간(ID 1~5) 하나. capcutJson이 실제 편집 프로그램에 붙여넣는 값. */
|
|
||||||
@Entity
|
|
||||||
@Table(name = "shortform_clip")
|
|
||||||
@Getter
|
|
||||||
@NoArgsConstructor
|
|
||||||
public class ShortformClip {
|
|
||||||
|
|
||||||
@Id
|
|
||||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
|
||||||
private Long id;
|
|
||||||
|
|
||||||
@ManyToOne(fetch = FetchType.LAZY, optional = false)
|
|
||||||
@JoinColumn(name = "job_id")
|
|
||||||
private ShortformJob job;
|
|
||||||
|
|
||||||
@Column(nullable = false)
|
|
||||||
private Integer clipNo;
|
|
||||||
|
|
||||||
@Column(length = 100)
|
|
||||||
private String titleTop;
|
|
||||||
|
|
||||||
@Column(length = 200)
|
|
||||||
private String titleMain;
|
|
||||||
|
|
||||||
@Column(columnDefinition = "TEXT")
|
|
||||||
private String capcutJson;
|
|
||||||
|
|
||||||
@Column(columnDefinition = "TEXT")
|
|
||||||
private String titleCandidates;
|
|
||||||
|
|
||||||
@Column(columnDefinition = "TEXT")
|
|
||||||
private String durationTable;
|
|
||||||
|
|
||||||
static ShortformClip of(ShortformJob job, ParsedClip p) {
|
|
||||||
ShortformClip clip = new ShortformClip();
|
|
||||||
clip.job = job;
|
|
||||||
clip.clipNo = p.clipNo();
|
|
||||||
clip.titleTop = p.titleTop();
|
|
||||||
clip.titleMain = p.titleMain();
|
|
||||||
clip.capcutJson = p.capcutJson();
|
|
||||||
clip.titleCandidates = p.titleCandidates();
|
|
||||||
clip.durationTable = p.durationTable();
|
|
||||||
return clip;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,69 +0,0 @@
|
|||||||
package com.hlab.yanalyst.domain.shortform;
|
|
||||||
|
|
||||||
import com.hlab.yanalyst.domain.shortform.dto.ShortformDtos.ImportRequest;
|
|
||||||
import com.hlab.yanalyst.domain.shortform.dto.ShortformDtos.JobDetail;
|
|
||||||
import com.hlab.yanalyst.domain.shortform.dto.ShortformDtos.JobSummary;
|
|
||||||
import com.hlab.yanalyst.domain.shortform.dto.ShortformDtos.RegisterRequest;
|
|
||||||
import com.hlab.yanalyst.domain.shortform.dto.ShortformDtos.ResultRequest;
|
|
||||||
import com.hlab.yanalyst.global.common.ApiResponse;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import org.springframework.web.bind.annotation.*;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
@RestController
|
|
||||||
@RequestMapping("/api/shortform")
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
public class ShortformController {
|
|
||||||
|
|
||||||
private final ShortformService shortformService;
|
|
||||||
|
|
||||||
@PostMapping("/jobs")
|
|
||||||
public ApiResponse<JobDetail> register(@RequestBody RegisterRequest request) {
|
|
||||||
return ApiResponse.created(shortformService.register(request.youtubeUrl(), request.category()));
|
|
||||||
}
|
|
||||||
|
|
||||||
@GetMapping("/jobs")
|
|
||||||
public ApiResponse<List<JobSummary>> list(@RequestParam(required = false) ShortformJobStatus status,
|
|
||||||
@RequestParam(required = false) ShortformCategory category) {
|
|
||||||
return ApiResponse.ok(shortformService.list(status, category));
|
|
||||||
}
|
|
||||||
|
|
||||||
@GetMapping("/jobs/{id}")
|
|
||||||
public ApiResponse<JobDetail> detail(@PathVariable Long id) {
|
|
||||||
return ApiResponse.ok(shortformService.detail(id));
|
|
||||||
}
|
|
||||||
|
|
||||||
@PostMapping("/jobs/{id}/result")
|
|
||||||
public ApiResponse<JobDetail> saveResult(@PathVariable Long id, @RequestBody ResultRequest request) {
|
|
||||||
return ApiResponse.ok(shortformService.saveResult(id, request.rawText(), request.step1Text()));
|
|
||||||
}
|
|
||||||
|
|
||||||
@PostMapping("/import")
|
|
||||||
public ApiResponse<JobDetail> importResult(@RequestBody ImportRequest request) {
|
|
||||||
return ApiResponse.ok(shortformService.importResult(request.youtubeUrl(), request.rawText(),
|
|
||||||
request.step1Text(), request.category()));
|
|
||||||
}
|
|
||||||
|
|
||||||
@PostMapping("/jobs/{id}/reset")
|
|
||||||
public ApiResponse<JobDetail> reset(@PathVariable Long id) {
|
|
||||||
return ApiResponse.ok(shortformService.reset(id));
|
|
||||||
}
|
|
||||||
|
|
||||||
@PostMapping("/jobs/{id}/complete")
|
|
||||||
public ApiResponse<JobDetail> complete(@PathVariable Long id) {
|
|
||||||
return ApiResponse.ok(shortformService.completeCopyWorkflow(id));
|
|
||||||
}
|
|
||||||
|
|
||||||
@PostMapping("/jobs/{id}/subtitles/retry")
|
|
||||||
public ApiResponse<Long> retrySubtitles(@PathVariable Long id) {
|
|
||||||
shortformService.retrySubtitle(id);
|
|
||||||
return ApiResponse.ok(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
@DeleteMapping("/jobs/{id}")
|
|
||||||
public ApiResponse<Long> delete(@PathVariable Long id) {
|
|
||||||
shortformService.delete(id);
|
|
||||||
return ApiResponse.ok(id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,177 +0,0 @@
|
|||||||
package com.hlab.yanalyst.domain.shortform;
|
|
||||||
|
|
||||||
import jakarta.persistence.*;
|
|
||||||
import lombok.Getter;
|
|
||||||
import lombok.NoArgsConstructor;
|
|
||||||
import org.hibernate.annotations.CreationTimestamp;
|
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 숏폼 큐 작업 하나 = 유튜브 영상 하나. Opal "숏폼 생성기" 실행 결과를 담는다.
|
|
||||||
*
|
|
||||||
* <p>rawOutput은 파싱 성공 여부와 무관하게 항상 원문을 보존한다 — 파서가 놓친 정보는
|
|
||||||
* 여기서 복구할 수 있다.
|
|
||||||
*/
|
|
||||||
@Entity
|
|
||||||
@Table(name = "shortform_job",
|
|
||||||
indexes = @Index(name = "idx_sfj_video_id", columnList = "videoId", unique = true))
|
|
||||||
@Getter
|
|
||||||
@NoArgsConstructor
|
|
||||||
public class ShortformJob {
|
|
||||||
|
|
||||||
@Id
|
|
||||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
|
||||||
private Long id;
|
|
||||||
|
|
||||||
@Column(nullable = false, length = 500)
|
|
||||||
private String youtubeUrl;
|
|
||||||
|
|
||||||
@Column(nullable = false, length = 20)
|
|
||||||
private String videoId;
|
|
||||||
|
|
||||||
/** YouTube 원본 영상 제목. 기존 행은 시작 시 자동으로 채운다. */
|
|
||||||
@Column(length = 500)
|
|
||||||
private String videoTitle;
|
|
||||||
|
|
||||||
@Enumerated(EnumType.STRING)
|
|
||||||
@Column(name = "content_category", length = 20)
|
|
||||||
private ShortformCategory category = ShortformCategory.ENTERTAINMENT;
|
|
||||||
|
|
||||||
/** 결과의 첫 클립 title_main. 목록 표시에 쓴다. */
|
|
||||||
@Column(length = 300)
|
|
||||||
private String title;
|
|
||||||
|
|
||||||
@Enumerated(EnumType.STRING)
|
|
||||||
@Column(nullable = false, length = 20)
|
|
||||||
private ShortformJobStatus status = ShortformJobStatus.PENDING;
|
|
||||||
|
|
||||||
@Column(columnDefinition = "TEXT")
|
|
||||||
private String rawOutput;
|
|
||||||
|
|
||||||
/** Step 1(하이라이트 후보 구간 추출) 원문 — 구간 선정 이유가 담겨 최종 Output과 별개로 보존한다. */
|
|
||||||
@Column(columnDefinition = "TEXT")
|
|
||||||
private String step1Output;
|
|
||||||
|
|
||||||
@CreationTimestamp
|
|
||||||
@Column(updatable = false)
|
|
||||||
private LocalDateTime createdAt;
|
|
||||||
|
|
||||||
private LocalDateTime completedAt;
|
|
||||||
|
|
||||||
@Enumerated(EnumType.STRING)
|
|
||||||
@Column(name = "subtitle_status", length = 20)
|
|
||||||
private SubtitleStatus subtitleStatus = SubtitleStatus.NOT_REQUESTED;
|
|
||||||
|
|
||||||
@Column(name = "subtitle_vtt", columnDefinition = "TEXT")
|
|
||||||
private String subtitleVtt;
|
|
||||||
|
|
||||||
@Column(name = "subtitle_error", length = 500)
|
|
||||||
private String subtitleError;
|
|
||||||
|
|
||||||
@OneToMany(mappedBy = "job", cascade = CascadeType.ALL, orphanRemoval = true)
|
|
||||||
@OrderBy("clipNo ASC")
|
|
||||||
private List<ShortformClip> clips = new ArrayList<>();
|
|
||||||
|
|
||||||
public static ShortformJob create(String youtubeUrl, String videoId) {
|
|
||||||
return create(youtubeUrl, videoId, ShortformCategory.ENTERTAINMENT);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static ShortformJob create(String youtubeUrl, String videoId, ShortformCategory category) {
|
|
||||||
ShortformJob job = new ShortformJob();
|
|
||||||
job.youtubeUrl = youtubeUrl;
|
|
||||||
job.videoId = videoId;
|
|
||||||
job.category = ShortformCategory.normalize(category);
|
|
||||||
return job;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void changeCategory(ShortformCategory category) {
|
|
||||||
this.category = ShortformCategory.normalize(category);
|
|
||||||
}
|
|
||||||
|
|
||||||
public void updateVideoTitle(String videoTitle) {
|
|
||||||
if (videoTitle != null && !videoTitle.isBlank()) {
|
|
||||||
this.videoTitle = videoTitle.trim();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public ShortformCategory categoryOrDefault() {
|
|
||||||
return ShortformCategory.normalize(this.category);
|
|
||||||
}
|
|
||||||
|
|
||||||
public SubtitleStatus subtitleStatusOrDefault() {
|
|
||||||
return subtitleStatus == null ? SubtitleStatus.NOT_REQUESTED : subtitleStatus;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void markSubtitleCollecting() {
|
|
||||||
markSubtitleCollecting(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
public void markSubtitleCollecting(String message) {
|
|
||||||
subtitleStatus = SubtitleStatus.COLLECTING;
|
|
||||||
subtitleError = message;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void storeSubtitle(String vtt) {
|
|
||||||
subtitleVtt = vtt;
|
|
||||||
subtitleStatus = SubtitleStatus.READY;
|
|
||||||
subtitleError = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void markSubtitleUnavailable(String message) {
|
|
||||||
subtitleStatus = SubtitleStatus.UNAVAILABLE;
|
|
||||||
subtitleError = message;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void markSubtitleFailed(String message) {
|
|
||||||
subtitleStatus = SubtitleStatus.FAILED;
|
|
||||||
String detail = message == null ? "자막 수집 실패" : message;
|
|
||||||
subtitleError = detail.substring(0, Math.min(detail.length(), 500));
|
|
||||||
}
|
|
||||||
|
|
||||||
public void applyResult(String raw, List<ParsedClip> parsed) {
|
|
||||||
this.rawOutput = raw;
|
|
||||||
this.clips.clear();
|
|
||||||
for (ParsedClip p : parsed) {
|
|
||||||
this.clips.add(ShortformClip.of(this, p));
|
|
||||||
}
|
|
||||||
this.title = parsed.isEmpty() ? this.title : parsed.get(0).titleMain();
|
|
||||||
this.status = ShortformJobStatus.DONE;
|
|
||||||
this.completedAt = LocalDateTime.now();
|
|
||||||
}
|
|
||||||
|
|
||||||
public void attachStep1(String step1Text) {
|
|
||||||
if (step1Text != null && !step1Text.isBlank()) {
|
|
||||||
this.step1Output = step1Text;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public void markFailed(String raw) {
|
|
||||||
this.rawOutput = raw;
|
|
||||||
this.status = ShortformJobStatus.FAILED;
|
|
||||||
this.completedAt = LocalDateTime.now();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** URL 또는 자막을 외부 작업 도구로 복사한 큐 작업을 완료 처리한다. */
|
|
||||||
public void completeCopyWorkflow() {
|
|
||||||
this.status = ShortformJobStatus.DONE;
|
|
||||||
if (this.completedAt == null) {
|
|
||||||
this.completedAt = LocalDateTime.now();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 결과를 비우고 다시 대기 상태로 되돌린다. 등록 정보(URL·videoId·등록시각)는 유지하므로
|
|
||||||
* 삭제 후 재등록할 필요가 없고, 큐 스킬이 PENDING 만 집어가므로 그대로 다시 실행된다.
|
|
||||||
*/
|
|
||||||
public void reset() {
|
|
||||||
this.rawOutput = null;
|
|
||||||
this.step1Output = null;
|
|
||||||
this.title = null;
|
|
||||||
this.completedAt = null;
|
|
||||||
this.clips.clear(); // orphanRemoval 로 클립 행도 함께 지워진다
|
|
||||||
this.status = ShortformJobStatus.PENDING;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,27 +0,0 @@
|
|||||||
package com.hlab.yanalyst.domain.shortform;
|
|
||||||
|
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
|
||||||
import org.springframework.data.jpa.repository.Query;
|
|
||||||
import org.springframework.data.repository.query.Param;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Optional;
|
|
||||||
|
|
||||||
public interface ShortformJobRepository extends JpaRepository<ShortformJob, Long> {
|
|
||||||
Optional<ShortformJob> findByVideoId(String videoId);
|
|
||||||
List<ShortformJob> findAllByOrderByCreatedAtDesc();
|
|
||||||
List<ShortformJob> findByStatusOrderByCreatedAtAsc(ShortformJobStatus status);
|
|
||||||
List<ShortformJob> findBySubtitleStatus(SubtitleStatus subtitleStatus);
|
|
||||||
|
|
||||||
List<ShortformJob> findByVideoTitleIsNull();
|
|
||||||
@Query("select j from ShortformJob j where coalesce(j.category, "
|
|
||||||
+ "com.hlab.yanalyst.domain.shortform.ShortformCategory.ENTERTAINMENT) = :category "
|
|
||||||
+ "order by j.createdAt desc")
|
|
||||||
List<ShortformJob> findByCategoryOrderByCreatedAtDesc(@Param("category") ShortformCategory category);
|
|
||||||
|
|
||||||
@Query("select j from ShortformJob j where coalesce(j.category, "
|
|
||||||
+ "com.hlab.yanalyst.domain.shortform.ShortformCategory.ENTERTAINMENT) = :category "
|
|
||||||
+ "and j.status = :status order by j.createdAt asc")
|
|
||||||
List<ShortformJob> findByCategoryAndStatusOrderByCreatedAtAsc(
|
|
||||||
@Param("category") ShortformCategory category, @Param("status") ShortformJobStatus status);
|
|
||||||
}
|
|
||||||
@ -1,3 +0,0 @@
|
|||||||
package com.hlab.yanalyst.domain.shortform;
|
|
||||||
|
|
||||||
public enum ShortformJobStatus { PENDING, DONE, FAILED }
|
|
||||||
@ -1,154 +0,0 @@
|
|||||||
package com.hlab.yanalyst.domain.shortform;
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.JsonNode;
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
||||||
import org.springframework.stereotype.Component;
|
|
||||||
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.regex.Matcher;
|
|
||||||
import java.util.regex.Pattern;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Opal "숏폼 생성기" Output 원문을 구간(ID N)별로 파싱한다.
|
|
||||||
*
|
|
||||||
* <p>실전 출력 변형에 대응한다: 섹션 구분자 {@code N=====}(= 5개 이상), ```json 펜스가
|
|
||||||
* 있기도 없기도 함, 타이틀 후보 목록의 번호 유무 혼재. 어떤 조각이 깨져도 원문은
|
|
||||||
* ShortformJob.rawOutput에 보존되므로 여기서는 최선 추출만 한다.
|
|
||||||
*/
|
|
||||||
@Component
|
|
||||||
public class ShortformOutputParser {
|
|
||||||
|
|
||||||
private static final Pattern SECTION = Pattern.compile("(?m)^\\s*(\\d+)={5,}");
|
|
||||||
private static final ObjectMapper OM = new ObjectMapper();
|
|
||||||
|
|
||||||
public List<ParsedClip> parse(String raw) {
|
|
||||||
List<ParsedClip> result = new ArrayList<>();
|
|
||||||
if (raw == null || raw.isBlank()) return result;
|
|
||||||
|
|
||||||
Matcher m = SECTION.matcher(raw);
|
|
||||||
List<Integer> starts = new ArrayList<>();
|
|
||||||
List<Integer> ends = new ArrayList<>();
|
|
||||||
List<Integer> nos = new ArrayList<>();
|
|
||||||
while (m.find()) {
|
|
||||||
starts.add(m.end());
|
|
||||||
ends.add(m.start());
|
|
||||||
nos.add(Integer.parseInt(m.group(1)));
|
|
||||||
}
|
|
||||||
for (int i = 0; i < starts.size(); i++) {
|
|
||||||
int to = (i + 1 < starts.size()) ? ends.get(i + 1) : raw.length();
|
|
||||||
ParsedClip clip = parseSection(nos.get(i), raw.substring(starts.get(i), to));
|
|
||||||
if (clip != null) result.add(clip);
|
|
||||||
}
|
|
||||||
if (result.isEmpty()) result.addAll(parseCandidates(raw));
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* "숏폼 생성기 1단계" 앱 출력: N===== 구분자 없이 {"candidates":[...]} JSON 하나.
|
|
||||||
* 후보마다 원본 JSON을 capcutJson 자리에 보존하고, 시간 정보는 durationTable 로 요약한다.
|
|
||||||
*/
|
|
||||||
private List<ParsedClip> parseCandidates(String raw) {
|
|
||||||
List<ParsedClip> result = new ArrayList<>();
|
|
||||||
String json = extractJson(raw);
|
|
||||||
if (json == null) return result;
|
|
||||||
try {
|
|
||||||
JsonNode arr = OM.readTree(json).path("candidates");
|
|
||||||
int fallbackNo = 0;
|
|
||||||
for (JsonNode c : arr) {
|
|
||||||
fallbackNo++;
|
|
||||||
String table = candidateTable(c);
|
|
||||||
result.add(new ParsedClip(
|
|
||||||
c.path("id").asInt(fallbackNo),
|
|
||||||
OM.writerWithDefaultPrettyPrinter().writeValueAsString(c),
|
|
||||||
null,
|
|
||||||
table,
|
|
||||||
c.path("title_top").asText(null),
|
|
||||||
c.path("title_main").asText(null)));
|
|
||||||
}
|
|
||||||
} catch (Exception ignored) {
|
|
||||||
// candidates 형식이 아니면 빈 리스트 — 원문은 rawOutput에 보존된다
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
private String candidateTable(JsonNode c) {
|
|
||||||
StringBuilder sb = new StringBuilder();
|
|
||||||
sb.append("구간 ").append(c.path("start_time").asText("?"))
|
|
||||||
.append(" ~ ").append(c.path("end_time").asText("?"))
|
|
||||||
.append(" (").append(c.path("duration_sec").asText("?")).append("초)")
|
|
||||||
.append(" · 훅 ").append(c.path("hook_point").asText("?"));
|
|
||||||
if (c.hasNonNull("boundary_check")) sb.append("\n경계: ").append(c.path("boundary_check").asText());
|
|
||||||
if (c.hasNonNull("reason")) sb.append("\n선정 이유: ").append(c.path("reason").asText());
|
|
||||||
return sb.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
private ParsedClip parseSection(int no, String body) {
|
|
||||||
String json = extractJson(body);
|
|
||||||
String titles = extractBetween(body, "타이틀 후보", "검산표");
|
|
||||||
String table = extractFromLine(body, "검산표");
|
|
||||||
if (json == null && titles == null && table == null) return null;
|
|
||||||
|
|
||||||
String top = null;
|
|
||||||
String main = null;
|
|
||||||
if (json != null) {
|
|
||||||
try {
|
|
||||||
JsonNode node = OM.readTree(json);
|
|
||||||
top = node.path("title_top").asText(null);
|
|
||||||
main = node.path("title_main").asText(null);
|
|
||||||
} catch (Exception ignored) {
|
|
||||||
// JSON이 깨져도 원문 조각은 보존한다
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return new ParsedClip(no, json, titles, table, top, main);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** ```json 펜스가 있으면 그 내부, 없으면 첫 '{'부터 중괄호 균형이 맞는 지점까지. */
|
|
||||||
private String extractJson(String body) {
|
|
||||||
int fence = body.indexOf("```json");
|
|
||||||
String scope = body;
|
|
||||||
if (fence >= 0) {
|
|
||||||
int from = fence + "```json".length();
|
|
||||||
int close = body.indexOf("```", from);
|
|
||||||
scope = close > from ? body.substring(from, close) : body.substring(from);
|
|
||||||
}
|
|
||||||
int open = scope.indexOf('{');
|
|
||||||
if (open < 0) return null;
|
|
||||||
int depth = 0;
|
|
||||||
boolean inString = false;
|
|
||||||
for (int i = open; i < scope.length(); i++) {
|
|
||||||
char c = scope.charAt(i);
|
|
||||||
if (inString) {
|
|
||||||
if (c == '\\') i++;
|
|
||||||
else if (c == '"') inString = false;
|
|
||||||
} else if (c == '"') {
|
|
||||||
inString = true;
|
|
||||||
} else if (c == '{') {
|
|
||||||
depth++;
|
|
||||||
} else if (c == '}') {
|
|
||||||
depth--;
|
|
||||||
if (depth == 0) return scope.substring(open, i + 1).trim();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** startKey가 있는 줄부터 endKey가 있는 줄 직전까지. 없으면 null. */
|
|
||||||
private String extractBetween(String body, String startKey, String endKey) {
|
|
||||||
int s = body.indexOf(startKey);
|
|
||||||
if (s < 0) return null;
|
|
||||||
int lineStart = body.lastIndexOf('\n', s) + 1;
|
|
||||||
int e = body.indexOf(endKey, s);
|
|
||||||
int end = e < 0 ? body.length() : body.lastIndexOf('\n', e) + 1;
|
|
||||||
String block = body.substring(lineStart, end).trim();
|
|
||||||
return block.isEmpty() ? null : block;
|
|
||||||
}
|
|
||||||
|
|
||||||
private String extractFromLine(String body, String key) {
|
|
||||||
int s = body.indexOf(key);
|
|
||||||
if (s < 0) return null;
|
|
||||||
int lineStart = body.lastIndexOf('\n', s) + 1;
|
|
||||||
String block = body.substring(lineStart).trim();
|
|
||||||
return block.isEmpty() ? null : block;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,160 +0,0 @@
|
|||||||
package com.hlab.yanalyst.domain.shortform;
|
|
||||||
|
|
||||||
import com.hlab.yanalyst.domain.shortform.dto.ShortformDtos.JobDetail;
|
|
||||||
import com.hlab.yanalyst.domain.shortform.dto.ShortformDtos.JobSummary;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
|
||||||
import org.springframework.boot.context.event.ApplicationReadyEvent;
|
|
||||||
import org.springframework.context.event.EventListener;
|
|
||||||
import org.springframework.stereotype.Service;
|
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
|
||||||
import org.springframework.transaction.support.TransactionSynchronization;
|
|
||||||
import org.springframework.transaction.support.TransactionSynchronizationManager;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
@Service
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
@Slf4j
|
|
||||||
public class ShortformService {
|
|
||||||
|
|
||||||
private final ShortformJobRepository jobRepository;
|
|
||||||
private final ShortformOutputParser parser;
|
|
||||||
private final ShortformSubtitleService subtitleService;
|
|
||||||
private final ShortformVideoTitleClient titleClient;
|
|
||||||
|
|
||||||
/** 배포·재시작으로 중단된 비동기 자막 수집을 애플리케이션 기동 후 다시 시작한다. */
|
|
||||||
@EventListener(ApplicationReadyEvent.class)
|
|
||||||
public void recoverInterruptedSubtitleCollections() {
|
|
||||||
List<ShortformJob> interrupted = jobRepository.findBySubtitleStatus(SubtitleStatus.COLLECTING);
|
|
||||||
if (!interrupted.isEmpty()) {
|
|
||||||
log.info("중단된 숏폼 자막 수집 {}건 재개", interrupted.size());
|
|
||||||
}
|
|
||||||
interrupted.forEach(job -> subtitleService.collectAsync(job.getId()));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** ddl-auto로 추가된 원본 제목 컬럼을 기존 작업에도 채운다. */
|
|
||||||
@EventListener(ApplicationReadyEvent.class)
|
|
||||||
@Transactional
|
|
||||||
public void fillMissingVideoTitles() {
|
|
||||||
List<ShortformJob> jobs = jobRepository.findByVideoTitleIsNull();
|
|
||||||
jobs.forEach(this::fillVideoTitle);
|
|
||||||
if (!jobs.isEmpty()) {
|
|
||||||
log.info("기존 숏폼 작업 원본 영상 제목 갱신 시도: {}건", jobs.size());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** URL 등록. 같은 videoId가 이미 있으면 그 작업을 그대로 반환한다(중복 방지). */
|
|
||||||
@Transactional
|
|
||||||
public JobDetail register(String youtubeUrl) {
|
|
||||||
return register(youtubeUrl, ShortformCategory.ENTERTAINMENT);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional
|
|
||||||
public JobDetail register(String youtubeUrl, ShortformCategory category) {
|
|
||||||
String videoId = YoutubeVideoId.from(youtubeUrl)
|
|
||||||
.orElseThrow(() -> new IllegalArgumentException("유튜브 URL이 아닙니다: " + youtubeUrl));
|
|
||||||
ShortformJob job = jobRepository.findByVideoId(videoId)
|
|
||||||
.orElseGet(() -> jobRepository.save(ShortformJob.create(youtubeUrl.trim(), videoId, category)));
|
|
||||||
job.changeCategory(category);
|
|
||||||
if (job.getVideoTitle() == null) {
|
|
||||||
fillVideoTitle(job);
|
|
||||||
}
|
|
||||||
if (job.subtitleStatusOrDefault() != SubtitleStatus.READY
|
|
||||||
&& job.subtitleStatusOrDefault() != SubtitleStatus.COLLECTING) {
|
|
||||||
job.markSubtitleCollecting("자막 수집 대기 중");
|
|
||||||
scheduleSubtitleAfterCommit(job.getId());
|
|
||||||
}
|
|
||||||
return JobDetail.from(job);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void fillVideoTitle(ShortformJob job) {
|
|
||||||
job.updateVideoTitle(titleClient.fetchTitle(job.getYoutubeUrl()));
|
|
||||||
}
|
|
||||||
|
|
||||||
private void scheduleSubtitleAfterCommit(Long jobId) {
|
|
||||||
if (TransactionSynchronizationManager.isSynchronizationActive()) {
|
|
||||||
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
|
|
||||||
@Override public void afterCommit() { subtitleService.collectAsync(jobId); }
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
subtitleService.collectAsync(jobId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional
|
|
||||||
public void retrySubtitle(Long jobId) {
|
|
||||||
ShortformJob job = jobRepository.findById(jobId)
|
|
||||||
.orElseThrow(() -> new IllegalArgumentException("작업이 없습니다: " + jobId));
|
|
||||||
job.markSubtitleCollecting("자막 재수집 대기 중");
|
|
||||||
scheduleSubtitleAfterCommit(jobId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Opal 출력 원문을 파싱해 저장. 클립이 하나도 안 나오면 FAILED로 남긴다. */
|
|
||||||
@Transactional
|
|
||||||
public JobDetail saveResult(Long jobId, String rawText, String step1Text) {
|
|
||||||
ShortformJob job = jobRepository.findById(jobId)
|
|
||||||
.orElseThrow(() -> new IllegalArgumentException("작업이 없습니다: " + jobId));
|
|
||||||
List<ParsedClip> parsed = parser.parse(rawText);
|
|
||||||
if (parsed.isEmpty()) {
|
|
||||||
job.markFailed(rawText);
|
|
||||||
} else {
|
|
||||||
job.applyResult(rawText, parsed);
|
|
||||||
}
|
|
||||||
job.attachStep1(step1Text);
|
|
||||||
return JobDetail.from(job);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 등록 + 결과 저장 한 번에 (Opal 수동 실행 후 붙여넣기 경로). */
|
|
||||||
@Transactional
|
|
||||||
public JobDetail importResult(String youtubeUrl, String rawText, String step1Text, ShortformCategory category) {
|
|
||||||
JobDetail registered = register(youtubeUrl, category);
|
|
||||||
return saveResult(registered.id(), rawText, step1Text);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
|
||||||
public List<JobSummary> list(ShortformJobStatus status, ShortformCategory category) {
|
|
||||||
List<ShortformJob> jobs;
|
|
||||||
if (category == null) {
|
|
||||||
jobs = (status == null)
|
|
||||||
? jobRepository.findAllByOrderByCreatedAtDesc()
|
|
||||||
: jobRepository.findByStatusOrderByCreatedAtAsc(status);
|
|
||||||
} else {
|
|
||||||
jobs = (status == null)
|
|
||||||
? jobRepository.findByCategoryOrderByCreatedAtDesc(category)
|
|
||||||
: jobRepository.findByCategoryAndStatusOrderByCreatedAtAsc(category, status);
|
|
||||||
}
|
|
||||||
return jobs.stream().map(JobSummary::from).toList();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
|
||||||
public JobDetail detail(Long jobId) {
|
|
||||||
return jobRepository.findById(jobId).map(JobDetail::from)
|
|
||||||
.orElseThrow(() -> new IllegalArgumentException("작업이 없습니다: " + jobId));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional
|
|
||||||
public JobDetail completeCopyWorkflow(Long jobId) {
|
|
||||||
ShortformJob job = jobRepository.findById(jobId)
|
|
||||||
.orElseThrow(() -> new IllegalArgumentException("작업이 없습니다: " + jobId));
|
|
||||||
job.completeCopyWorkflow();
|
|
||||||
return JobDetail.from(job);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional
|
|
||||||
public void delete(Long jobId) {
|
|
||||||
jobRepository.deleteById(jobId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 결과를 비우고 다시 대기(PENDING)로 되돌린다. 큐 스킬이 PENDING 만 집어가므로
|
|
||||||
* 이 호출만으로 재실행 대상이 된다 — 삭제 후 URL 재등록이 필요 없다.
|
|
||||||
*/
|
|
||||||
@Transactional
|
|
||||||
public JobDetail reset(Long jobId) {
|
|
||||||
ShortformJob job = jobRepository.findById(jobId)
|
|
||||||
.orElseThrow(() -> new IllegalArgumentException("작업이 없습니다: " + jobId));
|
|
||||||
job.reset();
|
|
||||||
return JobDetail.from(job);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,208 +0,0 @@
|
|||||||
package com.hlab.yanalyst.domain.shortform;
|
|
||||||
|
|
||||||
import com.hlab.yanalyst.global.notify.TelegramNotifier;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
|
||||||
import org.springframework.scheduling.annotation.Async;
|
|
||||||
import org.springframework.stereotype.Service;
|
|
||||||
|
|
||||||
import java.io.BufferedReader;
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.io.InputStreamReader;
|
|
||||||
import java.nio.charset.StandardCharsets;
|
|
||||||
import java.nio.file.Files;
|
|
||||||
import java.nio.file.Path;
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.Comparator;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.concurrent.TimeUnit;
|
|
||||||
import java.util.regex.Pattern;
|
|
||||||
|
|
||||||
/** 숏폼 큐 영상의 YouTube 자막만 yt-dlp로 수집한다. 영상 파일은 내려받지 않는다. */
|
|
||||||
@Slf4j
|
|
||||||
@Service
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
public class ShortformSubtitleService {
|
|
||||||
|
|
||||||
private static final Pattern VIDEO_ID = Pattern.compile("^[A-Za-z0-9_-]{11}$");
|
|
||||||
private static final int MAX_COLLECTION_ATTEMPTS = 2;
|
|
||||||
|
|
||||||
private final ShortformJobRepository jobRepository;
|
|
||||||
private final ShortformWhisperService whisperService;
|
|
||||||
private final TelegramNotifier telegramNotifier;
|
|
||||||
|
|
||||||
@Value("${ytdlp.bin:yt-dlp}")
|
|
||||||
private String ytdlpBin;
|
|
||||||
|
|
||||||
@Value("${ytdlp.timeout-seconds:600}")
|
|
||||||
private long timeoutSeconds;
|
|
||||||
|
|
||||||
@Value("${download.dir:downloads}")
|
|
||||||
private String downloadDir;
|
|
||||||
|
|
||||||
@Async
|
|
||||||
public void collectAsync(Long jobId) {
|
|
||||||
ShortformJob job = jobRepository.findById(jobId).orElse(null);
|
|
||||||
if (job == null || job.subtitleStatusOrDefault() == SubtitleStatus.READY) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
job.markSubtitleCollecting("YouTube 자막 확인 중");
|
|
||||||
jobRepository.save(job);
|
|
||||||
|
|
||||||
try {
|
|
||||||
String vtt = collectOrTranscribe(jobId, job.getVideoId());
|
|
||||||
ShortformJob current = jobRepository.findById(jobId).orElse(null);
|
|
||||||
if (current == null) return;
|
|
||||||
if (vtt == null) current.markSubtitleUnavailable("YouTube/Whisper 자막을 생성하지 못했습니다");
|
|
||||||
else current.storeSubtitle(vtt);
|
|
||||||
jobRepository.save(current);
|
|
||||||
if (vtt == null) notifyFailure(current, current.getSubtitleError());
|
|
||||||
else notifyReady(current);
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.warn("숏폼 큐 자막 수집 실패: jobId={}, videoId={}", jobId, job.getVideoId(), e);
|
|
||||||
jobRepository.findById(jobId).ifPresent(current -> {
|
|
||||||
current.markSubtitleFailed(e.getMessage());
|
|
||||||
jobRepository.save(current);
|
|
||||||
notifyFailure(current, current.getSubtitleError());
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void notifyReady(ShortformJob job) {
|
|
||||||
if (job.categoryOrDefault() == ShortformCategory.POLITICS) return;
|
|
||||||
telegramNotifier.sendMessage("✅ <b>숏폼 자막 생성 완료</b>\n"
|
|
||||||
+ "분류: " + categoryLabel(job) + "\n"
|
|
||||||
+ "영상: <code>" + job.getVideoId() + "</code>\n"
|
|
||||||
+ "<a href=\"https://h-lab.tolag.shop/shortform?category="
|
|
||||||
+ job.categoryOrDefault().name() + "\">숏폼 큐에서 확인</a>");
|
|
||||||
}
|
|
||||||
|
|
||||||
private void notifyFailure(ShortformJob job, String reason) {
|
|
||||||
if (job.categoryOrDefault() == ShortformCategory.POLITICS) return;
|
|
||||||
telegramNotifier.sendMessage("❌ <b>숏폼 자막 생성 실패</b>\n"
|
|
||||||
+ "분류: " + categoryLabel(job) + "\n"
|
|
||||||
+ "영상: <code>" + job.getVideoId() + "</code>\n"
|
|
||||||
+ "사유: " + escapeHtml(reason) + "\n"
|
|
||||||
+ "<a href=\"https://h-lab.tolag.shop/shortform?category="
|
|
||||||
+ job.categoryOrDefault().name() + "\">숏폼 큐에서 확인</a>");
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String categoryLabel(ShortformJob job) {
|
|
||||||
return job.categoryOrDefault() == ShortformCategory.POLITICS ? "정치" : "예능";
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String escapeHtml(String value) {
|
|
||||||
if (value == null || value.isBlank()) return "알 수 없는 오류";
|
|
||||||
return value.replace("&", "&").replace("<", "<").replace(">", ">");
|
|
||||||
}
|
|
||||||
|
|
||||||
String collectOrTranscribe(Long jobId, String videoId) throws IOException, InterruptedException {
|
|
||||||
try {
|
|
||||||
String vtt = collectWithRetry(videoId);
|
|
||||||
if (vtt != null) return vtt;
|
|
||||||
} catch (IOException | RuntimeException e) {
|
|
||||||
log.warn("YouTube VTT 수집 실패, Whisper 폴백 실행: videoId={}", videoId, e);
|
|
||||||
}
|
|
||||||
updateProgress(jobId, "YouTube 자막 없음 · Whisper 자막 생성 중");
|
|
||||||
return whisperService.transcribe(videoId);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void updateProgress(Long jobId, String message) {
|
|
||||||
jobRepository.findById(jobId).ifPresent(current -> {
|
|
||||||
current.markSubtitleCollecting(message);
|
|
||||||
jobRepository.save(current);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
String collectWithRetry(String videoId) throws IOException, InterruptedException {
|
|
||||||
Exception lastFailure = null;
|
|
||||||
for (int attempt = 1; attempt <= MAX_COLLECTION_ATTEMPTS; attempt++) {
|
|
||||||
try {
|
|
||||||
String vtt = collect(videoId);
|
|
||||||
if (vtt != null) return vtt;
|
|
||||||
log.info("숏폼 큐 자막 없음: videoId={}, attempt={}/{}",
|
|
||||||
videoId, attempt, MAX_COLLECTION_ATTEMPTS);
|
|
||||||
} catch (IOException | RuntimeException e) {
|
|
||||||
lastFailure = e;
|
|
||||||
log.warn("숏폼 큐 자막 수집 재시도: videoId={}, attempt={}/{}",
|
|
||||||
videoId, attempt, MAX_COLLECTION_ATTEMPTS, e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (lastFailure instanceof IOException ioException) throw ioException;
|
|
||||||
if (lastFailure instanceof RuntimeException runtimeException) throw runtimeException;
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
String collect(String videoId) throws IOException, InterruptedException {
|
|
||||||
if (videoId == null || !VIDEO_ID.matcher(videoId).matches()) {
|
|
||||||
throw new IllegalArgumentException("잘못된 videoId 형식입니다: " + videoId);
|
|
||||||
}
|
|
||||||
Path dir = Path.of(downloadDir, "subtitles", videoId);
|
|
||||||
Files.createDirectories(dir);
|
|
||||||
Path output = dir.resolve("%(id)s.%(ext)s");
|
|
||||||
List<String> command = buildCommand(ytdlpBin, videoId, output);
|
|
||||||
|
|
||||||
Process process = new ProcessBuilder(command).redirectErrorStream(true).start();
|
|
||||||
StringBuilder outputLog = new StringBuilder();
|
|
||||||
Thread drainer = new Thread(() -> drain(process, outputLog), "yt-dlp-subs-" + videoId);
|
|
||||||
drainer.setDaemon(true);
|
|
||||||
drainer.start();
|
|
||||||
boolean finished = process.waitFor(timeoutSeconds, TimeUnit.SECONDS);
|
|
||||||
if (!finished) {
|
|
||||||
process.destroyForcibly();
|
|
||||||
throw new RuntimeException("yt-dlp 자막 수집 타임아웃(" + timeoutSeconds + "s)");
|
|
||||||
}
|
|
||||||
drainer.join(2000);
|
|
||||||
if (process.exitValue() != 0) {
|
|
||||||
String logText = outputLog.toString();
|
|
||||||
throw new RuntimeException("yt-dlp 자막 수집 실패: "
|
|
||||||
+ logText.substring(Math.max(0, logText.length() - 500)).strip());
|
|
||||||
}
|
|
||||||
|
|
||||||
List<Path> files;
|
|
||||||
try (var stream = Files.list(dir)) {
|
|
||||||
files = stream.filter(p -> p.getFileName().toString().endsWith(".vtt"))
|
|
||||||
.sorted(Comparator.comparingInt(ShortformSubtitleService::subtitlePreference))
|
|
||||||
.toList();
|
|
||||||
}
|
|
||||||
if (files.isEmpty()) return null;
|
|
||||||
return Files.readString(files.get(0), StandardCharsets.UTF_8);
|
|
||||||
}
|
|
||||||
|
|
||||||
static List<String> buildCommand(String binary, String videoId, Path output) {
|
|
||||||
if (videoId == null || !VIDEO_ID.matcher(videoId).matches()) {
|
|
||||||
throw new IllegalArgumentException("잘못된 videoId 형식입니다: " + videoId);
|
|
||||||
}
|
|
||||||
List<String> command = new ArrayList<>();
|
|
||||||
command.add(binary);
|
|
||||||
command.add("--skip-download");
|
|
||||||
command.add("--write-subs");
|
|
||||||
command.add("--write-auto-subs");
|
|
||||||
command.add("--sub-langs");
|
|
||||||
command.add("ko,ko-orig");
|
|
||||||
command.add("--sub-format");
|
|
||||||
command.add("vtt");
|
|
||||||
command.add("--output");
|
|
||||||
command.add(output.toString());
|
|
||||||
command.add("https://www.youtube.com/watch?v=" + videoId);
|
|
||||||
return command;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static int subtitlePreference(Path path) {
|
|
||||||
String name = path.getFileName().toString();
|
|
||||||
if (name.endsWith(".ko.vtt")) return 0;
|
|
||||||
if (name.endsWith(".ko-orig.vtt")) return 1;
|
|
||||||
return 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void drain(Process process, StringBuilder target) {
|
|
||||||
try (BufferedReader reader = new BufferedReader(
|
|
||||||
new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8))) {
|
|
||||||
String line;
|
|
||||||
while ((line = reader.readLine()) != null) target.append(line).append('\n');
|
|
||||||
} catch (IOException ignored) {
|
|
||||||
// 프로세스 종료 시 스트림이 닫히는 것은 정상이다.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,36 +0,0 @@
|
|||||||
package com.hlab.yanalyst.domain.shortform;
|
|
||||||
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
|
||||||
import org.springframework.beans.factory.annotation.Qualifier;
|
|
||||||
import org.springframework.stereotype.Component;
|
|
||||||
import org.springframework.web.client.RestTemplate;
|
|
||||||
|
|
||||||
import java.net.URI;
|
|
||||||
import java.net.URLEncoder;
|
|
||||||
import java.nio.charset.StandardCharsets;
|
|
||||||
|
|
||||||
/** API 키가 필요 없는 YouTube oEmbed 응답에서 원본 영상 제목을 읽는다. */
|
|
||||||
@Component
|
|
||||||
@Slf4j
|
|
||||||
public class ShortformVideoTitleClient {
|
|
||||||
|
|
||||||
private final RestTemplate restTemplate;
|
|
||||||
|
|
||||||
public ShortformVideoTitleClient(@Qualifier("restTemplate") RestTemplate restTemplate) {
|
|
||||||
this.restTemplate = restTemplate;
|
|
||||||
}
|
|
||||||
|
|
||||||
public String fetchTitle(String youtubeUrl) {
|
|
||||||
try {
|
|
||||||
String encodedUrl = URLEncoder.encode(youtubeUrl, StandardCharsets.UTF_8);
|
|
||||||
URI uri = URI.create("https://www.youtube.com/oembed?url=" + encodedUrl + "&format=json");
|
|
||||||
OEmbedResponse response = restTemplate.getForObject(uri, OEmbedResponse.class);
|
|
||||||
return response == null ? null : response.title();
|
|
||||||
} catch (RuntimeException e) {
|
|
||||||
log.warn("YouTube 원본 영상 제목 조회 실패: url={}", youtubeUrl, e);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private record OEmbedResponse(String title) {}
|
|
||||||
}
|
|
||||||
@ -1,187 +0,0 @@
|
|||||||
package com.hlab.yanalyst.domain.shortform;
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
||||||
import com.hlab.yanalyst.domain.production.dto.ScriptResponseDto;
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
|
||||||
import org.springframework.beans.factory.annotation.Qualifier;
|
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
|
||||||
import org.springframework.core.io.FileSystemResource;
|
|
||||||
import org.springframework.http.HttpEntity;
|
|
||||||
import org.springframework.http.HttpHeaders;
|
|
||||||
import org.springframework.http.MediaType;
|
|
||||||
import org.springframework.http.ResponseEntity;
|
|
||||||
import org.springframework.stereotype.Service;
|
|
||||||
import org.springframework.util.LinkedMultiValueMap;
|
|
||||||
import org.springframework.util.MultiValueMap;
|
|
||||||
import org.springframework.web.client.RestTemplate;
|
|
||||||
|
|
||||||
import java.io.BufferedReader;
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.io.InputStreamReader;
|
|
||||||
import java.nio.charset.StandardCharsets;
|
|
||||||
import java.nio.file.Files;
|
|
||||||
import java.nio.file.Path;
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.Comparator;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.concurrent.TimeUnit;
|
|
||||||
import java.util.regex.Pattern;
|
|
||||||
|
|
||||||
/** YouTube 자막이 없는 숏폼 영상을 Whisper로 전사해 VTT를 생성한다. */
|
|
||||||
@Slf4j
|
|
||||||
@Service
|
|
||||||
public class ShortformWhisperService {
|
|
||||||
|
|
||||||
private static final Pattern VIDEO_ID = Pattern.compile("^[A-Za-z0-9_-]{11}$");
|
|
||||||
|
|
||||||
@Qualifier("pythonRestTemplate")
|
|
||||||
private final RestTemplate pythonRestTemplate;
|
|
||||||
private final ObjectMapper objectMapper;
|
|
||||||
|
|
||||||
public ShortformWhisperService(@Qualifier("pythonRestTemplate") RestTemplate pythonRestTemplate,
|
|
||||||
ObjectMapper objectMapper) {
|
|
||||||
this.pythonRestTemplate = pythonRestTemplate;
|
|
||||||
this.objectMapper = objectMapper;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Value("${python.base-url:http://h-python.tolag.shop}")
|
|
||||||
private String pythonBaseUrl;
|
|
||||||
|
|
||||||
@Value("${ytdlp.bin:yt-dlp}")
|
|
||||||
private String ytdlpBin;
|
|
||||||
|
|
||||||
@Value("${ytdlp.timeout-seconds:600}")
|
|
||||||
private long timeoutSeconds;
|
|
||||||
|
|
||||||
@Value("${download.dir:downloads}")
|
|
||||||
private String downloadDir;
|
|
||||||
|
|
||||||
public String transcribe(String videoId) throws IOException, InterruptedException {
|
|
||||||
if (videoId == null || !VIDEO_ID.matcher(videoId).matches()) {
|
|
||||||
throw new IllegalArgumentException("잘못된 videoId 형식입니다: " + videoId);
|
|
||||||
}
|
|
||||||
|
|
||||||
Path dir = Path.of(downloadDir, "subtitles", videoId, "whisper");
|
|
||||||
Files.createDirectories(dir);
|
|
||||||
try {
|
|
||||||
Path audio = downloadAudio(videoId, dir);
|
|
||||||
ScriptResponseDto response = requestTranscription(audio);
|
|
||||||
return toVtt(response == null ? null : response.getSegments());
|
|
||||||
} finally {
|
|
||||||
deleteFiles(dir);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private Path downloadAudio(String videoId, Path dir) throws IOException, InterruptedException {
|
|
||||||
List<String> command = buildDownloadCommand(ytdlpBin, videoId, dir.resolve("source.%(ext)s"));
|
|
||||||
Process process = new ProcessBuilder(command).redirectErrorStream(true).start();
|
|
||||||
StringBuilder output = new StringBuilder();
|
|
||||||
Thread drainer = new Thread(() -> drain(process, output), "yt-dlp-audio-" + videoId);
|
|
||||||
drainer.setDaemon(true);
|
|
||||||
drainer.start();
|
|
||||||
|
|
||||||
boolean finished = process.waitFor(timeoutSeconds, TimeUnit.SECONDS);
|
|
||||||
if (!finished) {
|
|
||||||
process.destroyForcibly();
|
|
||||||
throw new RuntimeException("yt-dlp 오디오 다운로드 타임아웃(" + timeoutSeconds + "s)");
|
|
||||||
}
|
|
||||||
drainer.join(2000);
|
|
||||||
if (process.exitValue() != 0) {
|
|
||||||
String logText = output.toString();
|
|
||||||
throw new RuntimeException("yt-dlp 오디오 다운로드 실패: "
|
|
||||||
+ logText.substring(Math.max(0, logText.length() - 500)).strip());
|
|
||||||
}
|
|
||||||
|
|
||||||
try (var files = Files.list(dir)) {
|
|
||||||
return files.filter(Files::isRegularFile)
|
|
||||||
.max(Comparator.comparingLong(ShortformWhisperService::fileSize))
|
|
||||||
.orElseThrow(() -> new RuntimeException("다운로드된 오디오 파일이 없습니다"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private ScriptResponseDto requestTranscription(Path audio) throws IOException {
|
|
||||||
HttpHeaders headers = new HttpHeaders();
|
|
||||||
headers.setContentType(MediaType.MULTIPART_FORM_DATA);
|
|
||||||
MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
|
|
||||||
body.add("file", new FileSystemResource(audio));
|
|
||||||
|
|
||||||
ResponseEntity<String> response = pythonRestTemplate.postForEntity(
|
|
||||||
pythonBaseUrl + "/transcribe", new HttpEntity<>(body, headers), String.class);
|
|
||||||
if (!response.getStatusCode().is2xxSuccessful() || response.getBody() == null) {
|
|
||||||
throw new RuntimeException("Whisper API 실패: " + response.getStatusCode());
|
|
||||||
}
|
|
||||||
return objectMapper.readValue(response.getBody(), ScriptResponseDto.class);
|
|
||||||
}
|
|
||||||
|
|
||||||
static List<String> buildDownloadCommand(String binary, String videoId, Path output) {
|
|
||||||
if (videoId == null || !VIDEO_ID.matcher(videoId).matches()) {
|
|
||||||
throw new IllegalArgumentException("잘못된 videoId 형식입니다: " + videoId);
|
|
||||||
}
|
|
||||||
List<String> command = new ArrayList<>();
|
|
||||||
command.add(binary);
|
|
||||||
command.add("--no-playlist");
|
|
||||||
command.add("--force-overwrites");
|
|
||||||
command.add("-f");
|
|
||||||
command.add("bestaudio/best");
|
|
||||||
command.add("-o");
|
|
||||||
command.add(output.toString());
|
|
||||||
command.add("https://www.youtube.com/watch?v=" + videoId);
|
|
||||||
return command;
|
|
||||||
}
|
|
||||||
|
|
||||||
static String toVtt(List<ScriptResponseDto.Segment> segments) {
|
|
||||||
if (segments == null || segments.isEmpty()) return null;
|
|
||||||
StringBuilder vtt = new StringBuilder("WEBVTT\n\n");
|
|
||||||
int cue = 1;
|
|
||||||
for (ScriptResponseDto.Segment segment : segments) {
|
|
||||||
if (segment == null || segment.getText() == null || segment.getText().isBlank()
|
|
||||||
|| segment.getEnd() <= segment.getStart()) continue;
|
|
||||||
vtt.append(cue++).append('\n')
|
|
||||||
.append(formatTimestamp(segment.getStart())).append(" --> ")
|
|
||||||
.append(formatTimestamp(segment.getEnd())).append('\n')
|
|
||||||
.append(segment.getText().strip()).append("\n\n");
|
|
||||||
}
|
|
||||||
return cue == 1 ? null : vtt.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
static String formatTimestamp(double seconds) {
|
|
||||||
long millis = Math.max(0, Math.round(seconds * 1000));
|
|
||||||
long hours = millis / 3_600_000;
|
|
||||||
long minutes = (millis % 3_600_000) / 60_000;
|
|
||||||
long secs = (millis % 60_000) / 1000;
|
|
||||||
long ms = millis % 1000;
|
|
||||||
return "%02d:%02d:%02d.%03d".formatted(hours, minutes, secs, ms);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static long fileSize(Path path) {
|
|
||||||
try {
|
|
||||||
return Files.size(path);
|
|
||||||
} catch (IOException ignored) {
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void deleteFiles(Path dir) {
|
|
||||||
try (var files = Files.list(dir)) {
|
|
||||||
files.filter(Files::isRegularFile).forEach(path -> {
|
|
||||||
try {
|
|
||||||
Files.deleteIfExists(path);
|
|
||||||
} catch (IOException e) {
|
|
||||||
log.warn("Whisper 임시 파일 삭제 실패: {}", path, e);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} catch (IOException e) {
|
|
||||||
log.warn("Whisper 임시 폴더 정리 실패: {}", dir, e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void drain(Process process, StringBuilder target) {
|
|
||||||
try (BufferedReader reader = new BufferedReader(
|
|
||||||
new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8))) {
|
|
||||||
String line;
|
|
||||||
while ((line = reader.readLine()) != null) target.append(line).append('\n');
|
|
||||||
} catch (IOException ignored) {
|
|
||||||
// 프로세스 종료 중 스트림이 닫힐 수 있다.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,9 +0,0 @@
|
|||||||
package com.hlab.yanalyst.domain.shortform;
|
|
||||||
|
|
||||||
public enum SubtitleStatus {
|
|
||||||
NOT_REQUESTED,
|
|
||||||
COLLECTING,
|
|
||||||
READY,
|
|
||||||
UNAVAILABLE,
|
|
||||||
FAILED
|
|
||||||
}
|
|
||||||
@ -1,21 +0,0 @@
|
|||||||
package com.hlab.yanalyst.domain.shortform;
|
|
||||||
|
|
||||||
import java.util.Optional;
|
|
||||||
import java.util.regex.Matcher;
|
|
||||||
import java.util.regex.Pattern;
|
|
||||||
|
|
||||||
/** 유튜브 URL에서 11자 videoId를 뽑는다. youtube.com / youtu.be 도메인만 인정. */
|
|
||||||
public final class YoutubeVideoId {
|
|
||||||
|
|
||||||
private static final Pattern PATTERN = Pattern.compile(
|
|
||||||
"^https?://(?:www\\.|m\\.)?(?:youtube\\.com/(?:watch\\?[^#]*\\bv=|shorts/|embed/|live/)|youtu\\.be/)"
|
|
||||||
+ "([A-Za-z0-9_-]{11})(?:[?&#/].*)?$");
|
|
||||||
|
|
||||||
private YoutubeVideoId() {}
|
|
||||||
|
|
||||||
public static Optional<String> from(String url) {
|
|
||||||
if (url == null || url.isBlank()) return Optional.empty();
|
|
||||||
Matcher m = PATTERN.matcher(url.trim());
|
|
||||||
return m.matches() ? Optional.of(m.group(1)) : Optional.empty();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,51 +0,0 @@
|
|||||||
package com.hlab.yanalyst.domain.shortform.dto;
|
|
||||||
|
|
||||||
import com.hlab.yanalyst.domain.shortform.ShortformClip;
|
|
||||||
import com.hlab.yanalyst.domain.shortform.ShortformJob;
|
|
||||||
import com.hlab.yanalyst.domain.shortform.ShortformCategory;
|
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
public final class ShortformDtos {
|
|
||||||
|
|
||||||
private ShortformDtos() {}
|
|
||||||
|
|
||||||
public record RegisterRequest(String youtubeUrl, ShortformCategory category) {}
|
|
||||||
|
|
||||||
public record ResultRequest(String rawText, String step1Text) {}
|
|
||||||
|
|
||||||
public record ImportRequest(String youtubeUrl, String rawText, String step1Text, ShortformCategory category) {}
|
|
||||||
|
|
||||||
public record JobSummary(Long id, String youtubeUrl, String videoId, String videoTitle, String title, String category,
|
|
||||||
String status, String subtitleStatus, String subtitleError, int clipCount,
|
|
||||||
LocalDateTime createdAt, LocalDateTime completedAt) {
|
|
||||||
public static JobSummary from(ShortformJob job) {
|
|
||||||
return new JobSummary(job.getId(), job.getYoutubeUrl(), job.getVideoId(), job.getVideoTitle(),
|
|
||||||
job.getTitle(), job.categoryOrDefault().name(), job.getStatus().name(),
|
|
||||||
job.subtitleStatusOrDefault().name(), job.getSubtitleError(), job.getClips().size(),
|
|
||||||
job.getCreatedAt(), job.getCompletedAt());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public record ClipDto(int clipNo, String titleTop, String titleMain,
|
|
||||||
String capcutJson, String titleCandidates, String durationTable) {
|
|
||||||
public static ClipDto from(ShortformClip clip) {
|
|
||||||
return new ClipDto(clip.getClipNo(), clip.getTitleTop(), clip.getTitleMain(),
|
|
||||||
clip.getCapcutJson(), clip.getTitleCandidates(), clip.getDurationTable());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public record JobDetail(Long id, String youtubeUrl, String videoId, String videoTitle, String title, String category,
|
|
||||||
String status, LocalDateTime createdAt, LocalDateTime completedAt,
|
|
||||||
String rawOutput, String step1Output, String subtitleStatus,
|
|
||||||
String subtitleVtt, String subtitleError, List<ClipDto> clips) {
|
|
||||||
public static JobDetail from(ShortformJob job) {
|
|
||||||
return new JobDetail(job.getId(), job.getYoutubeUrl(), job.getVideoId(), job.getVideoTitle(),
|
|
||||||
job.getTitle(), job.categoryOrDefault().name(), job.getStatus().name(), job.getCreatedAt(),
|
|
||||||
job.getCompletedAt(), job.getRawOutput(), job.getStep1Output(),
|
|
||||||
job.subtitleStatusOrDefault().name(), job.getSubtitleVtt(), job.getSubtitleError(),
|
|
||||||
job.getClips().stream().map(ClipDto::from).toList());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,269 +0,0 @@
|
|||||||
package com.hlab.yanalyst.domain.social;
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.JsonNode;
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import org.jsoup.Jsoup;
|
|
||||||
import org.jsoup.nodes.Document;
|
|
||||||
import org.jsoup.nodes.Element;
|
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
|
||||||
import org.springframework.http.*;
|
|
||||||
import org.springframework.stereotype.Component;
|
|
||||||
import org.springframework.web.client.RestTemplate;
|
|
||||||
import org.springframework.web.util.UriComponentsBuilder;
|
|
||||||
|
|
||||||
import java.net.URI;
|
|
||||||
import java.time.Instant;
|
|
||||||
import java.time.LocalDateTime;
|
|
||||||
import java.time.ZoneId;
|
|
||||||
import java.util.*;
|
|
||||||
import java.util.regex.Matcher;
|
|
||||||
import java.util.regex.Pattern;
|
|
||||||
|
|
||||||
/** Facebook Page Plugin의 공개 타임라인에서 릴 목록을 읽는다. 로그인·Graph API 토큰이 필요 없다. */
|
|
||||||
@Component
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
public class FacebookPageFeedClient implements SocialFeedClient {
|
|
||||||
private static final String PAGE_PLUGIN_APP_ID = "776730922422337";
|
|
||||||
private static final Pattern REEL_ID = Pattern.compile("/(?:reel|videos)/(\\d+)");
|
|
||||||
private static final Pattern READER_REEL = Pattern.compile(
|
|
||||||
"\\[!\\[[^]]*]\\((https?://[^)]+)\\)[^]]*]\\(https?://(?:www\\.)?facebook\\.com/reel/(\\d+)[^)]*\\)");
|
|
||||||
private static final Pattern READER_TITLE = Pattern.compile("(?m)^Title:\\s*(.+?)(?:\\s+Reels)?\\s*$");
|
|
||||||
private static final Pattern KOREAN_DURATION = Pattern.compile("길이:\\s*(?:(\\d+)분,?\\s*)?(\\d+)초");
|
|
||||||
private static final Pattern ENGLISH_DURATION = Pattern.compile(
|
|
||||||
"(?i)length:\\s*(?:(\\d+)\\s*minutes?,?\\s*)?(\\d+)\\s*seconds?");
|
|
||||||
/** Facebook가 데이터센터별로 빈 피드를 돌려주는 경우가 있어 공식 로케일 호스트를 순서대로 시도한다. */
|
|
||||||
private static final List<String> RENDERER_HOSTS = List.of(
|
|
||||||
"www.facebook.com", "ko-kr.facebook.com", "en-gb.facebook.com");
|
|
||||||
private static final long READER_RECENT_CACHE_SECONDS = 3_600;
|
|
||||||
private static final long READER_BASELINE_CACHE_SECONDS = 2_592_000;
|
|
||||||
|
|
||||||
private final RestTemplate restTemplate;
|
|
||||||
private final ObjectMapper objectMapper;
|
|
||||||
|
|
||||||
@Value("${hlab.social-feed.max-items:25}")
|
|
||||||
private int maxItems;
|
|
||||||
|
|
||||||
@Value("${hlab.social-feed.facebook-reader-enabled:true}")
|
|
||||||
private boolean readerFallbackEnabled;
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public boolean supports(SocialPlatform platform) {
|
|
||||||
return platform == SocialPlatform.FACEBOOK;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public SocialFeedBatch fetch(SocialChannel channel) {
|
|
||||||
IllegalStateException lastFailure = null;
|
|
||||||
for (String host : RENDERER_HOSTS) {
|
|
||||||
try {
|
|
||||||
SocialFeedBatch batch = fetchFromHost(channel, host);
|
|
||||||
if (!batch.videos().isEmpty()) return batch;
|
|
||||||
lastFailure = new IllegalStateException("Facebook 공개 피드에 릴이 없습니다: " + host);
|
|
||||||
} catch (IllegalStateException e) {
|
|
||||||
lastFailure = e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (readerFallbackEnabled) {
|
|
||||||
for (ReaderAttempt attempt : readerAttempts(channel.getLastCollectedAt() == null)) {
|
|
||||||
try {
|
|
||||||
SocialFeedBatch batch = fetchViaReader(channel, attempt);
|
|
||||||
if (!batch.videos().isEmpty()) return batch;
|
|
||||||
lastFailure = new IllegalStateException("Facebook Reader 공개 피드에 릴이 없습니다.");
|
|
||||||
} catch (IllegalStateException e) {
|
|
||||||
lastFailure = e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
throw new IllegalStateException("Facebook 공개 피드를 읽지 못했습니다: "
|
|
||||||
+ (lastFailure == null ? "빈 응답" : friendly(lastFailure)), lastFailure);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 운영 서버 IP가 Facebook에서 차단될 때 공개 페이지를 브라우저로 읽어 주는 Reader API를 사용한다. */
|
|
||||||
private SocialFeedBatch fetchViaReader(SocialChannel channel, ReaderAttempt attempt) {
|
|
||||||
URI uri = readerUri(channel.getSourceUrl(), attempt.sourceScheme());
|
|
||||||
HttpHeaders headers = new HttpHeaders();
|
|
||||||
headers.set(HttpHeaders.USER_AGENT, "Mozilla/5.0");
|
|
||||||
headers.setAccept(List.of(MediaType.TEXT_PLAIN));
|
|
||||||
headers.setAcceptLanguageAsLocales(List.of(Locale.ENGLISH));
|
|
||||||
headers.set("X-Engine", "browser");
|
|
||||||
if (attempt.noCache()) headers.set("X-No-Cache", "true");
|
|
||||||
else headers.set("X-Cache-Tolerance", Long.toString(attempt.cacheToleranceSeconds()));
|
|
||||||
try {
|
|
||||||
ResponseEntity<String> response = restTemplate.exchange(uri, HttpMethod.GET,
|
|
||||||
new HttpEntity<>(headers), String.class);
|
|
||||||
if (!response.getStatusCode().is2xxSuccessful() || response.getBody() == null) {
|
|
||||||
throw new IllegalStateException("Facebook Reader 응답이 비어 있습니다.");
|
|
||||||
}
|
|
||||||
return parseReader(response.getBody(), Math.max(1, maxItems));
|
|
||||||
} catch (Exception e) {
|
|
||||||
if (e instanceof IllegalStateException state) throw state;
|
|
||||||
throw new IllegalStateException("Facebook Reader 피드 오류: " + friendly(e), e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static URI readerUri(String channelUrl, String sourceScheme) {
|
|
||||||
String source = channelUrl.replaceFirst("^https?", sourceScheme).replaceAll("/+$", "") + "/reels/";
|
|
||||||
return URI.create("https://r.jina.ai/" + source);
|
|
||||||
}
|
|
||||||
|
|
||||||
static List<ReaderAttempt> readerAttempts(boolean baselineCollection) {
|
|
||||||
long fallbackTolerance = baselineCollection
|
|
||||||
? READER_BASELINE_CACHE_SECONDS
|
|
||||||
: READER_RECENT_CACHE_SECONDS;
|
|
||||||
return List.of(
|
|
||||||
new ReaderAttempt("https", true, 0),
|
|
||||||
new ReaderAttempt("http", true, 0),
|
|
||||||
new ReaderAttempt("https", false, READER_RECENT_CACHE_SECONDS),
|
|
||||||
new ReaderAttempt("http", false, fallbackTolerance));
|
|
||||||
}
|
|
||||||
|
|
||||||
record ReaderAttempt(String sourceScheme, boolean noCache, long cacheToleranceSeconds) { }
|
|
||||||
|
|
||||||
private SocialFeedBatch fetchFromHost(SocialChannel channel, String host) {
|
|
||||||
try {
|
|
||||||
Map<String, Object> config = new LinkedHashMap<>();
|
|
||||||
config.put("app_id", PAGE_PLUGIN_APP_ID);
|
|
||||||
config.put("href", channel.getSourceUrl());
|
|
||||||
config.put("width", 500);
|
|
||||||
config.put("height", 800);
|
|
||||||
config.put("has_cta", true);
|
|
||||||
config.put("has_small_header", false);
|
|
||||||
config.put("has_adapt_container_width", true);
|
|
||||||
config.put("has_cover", true);
|
|
||||||
config.put("has_posts", false);
|
|
||||||
config.put("tabs", "timeline");
|
|
||||||
config.put("can_personalize", false);
|
|
||||||
config.put("is_xfbml", false);
|
|
||||||
config.put("referer_uri", "");
|
|
||||||
|
|
||||||
URI uri = UriComponentsBuilder.fromHttpUrl("https://" + host + "/platform/plugin/tab/renderer/")
|
|
||||||
.queryParam("key", "timeline")
|
|
||||||
.queryParam("config_json", objectMapper.writeValueAsString(config))
|
|
||||||
.queryParam("__a", "1")
|
|
||||||
.build().encode().toUri();
|
|
||||||
HttpHeaders headers = new HttpHeaders();
|
|
||||||
headers.set(HttpHeaders.USER_AGENT, "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36");
|
|
||||||
headers.set(HttpHeaders.ACCEPT_LANGUAGE, "ko-KR,ko;q=0.9,en;q=0.8");
|
|
||||||
headers.set("X-Requested-With", "XMLHttpRequest");
|
|
||||||
ResponseEntity<String> response = restTemplate.exchange(uri, HttpMethod.GET,
|
|
||||||
new HttpEntity<>(headers), String.class);
|
|
||||||
if (!response.getStatusCode().is2xxSuccessful() || response.getBody() == null) {
|
|
||||||
throw new IllegalStateException("Facebook 공개 피드 응답이 비어 있습니다.");
|
|
||||||
}
|
|
||||||
return parseResponse(response.getBody(), Math.max(1, maxItems));
|
|
||||||
} catch (Exception e) {
|
|
||||||
if (e instanceof IllegalStateException state) throw state;
|
|
||||||
throw new IllegalStateException(host + " Facebook 피드 오류: " + friendly(e), e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
SocialFeedBatch parseResponse(String body, int limit) {
|
|
||||||
try {
|
|
||||||
String json = body == null ? "" : body.strip();
|
|
||||||
if (json.startsWith("for (;;);")) json = json.substring("for (;;);".length());
|
|
||||||
JsonNode root = objectMapper.readTree(json);
|
|
||||||
String markup = root.path("payload").path("content").path("markup").path("__html").asText("");
|
|
||||||
if (markup.isBlank()) throw new IllegalStateException("Facebook 공개 피드에 표시할 게시물이 없습니다.");
|
|
||||||
return parseMarkup(markup, limit);
|
|
||||||
} catch (IllegalStateException e) {
|
|
||||||
throw e;
|
|
||||||
} catch (Exception e) {
|
|
||||||
throw new IllegalStateException("Facebook 공개 피드 형식이 변경되었습니다.", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static SocialFeedBatch parseMarkup(String markup, int limit) {
|
|
||||||
Document document = Jsoup.parse(markup, "https://www.facebook.com");
|
|
||||||
LinkedHashMap<String, SocialVideoCandidate> unique = new LinkedHashMap<>();
|
|
||||||
String channelTitle = null;
|
|
||||||
|
|
||||||
for (Element post : document.select("div[data-fte=1][data-ftr=1]")) {
|
|
||||||
Element reelLink = post.selectFirst("a[href*=/reel/], a[href*=/videos/]");
|
|
||||||
if (reelLink == null) continue;
|
|
||||||
Matcher matcher = REEL_ID.matcher(reelLink.attr("href"));
|
|
||||||
if (!matcher.find()) continue;
|
|
||||||
String id = matcher.group(1);
|
|
||||||
if (unique.containsKey(id)) continue;
|
|
||||||
|
|
||||||
Element titleElement = post.selectFirst("[data-testid=post_message]");
|
|
||||||
String title = titleElement == null ? "(제목 없음)" : titleElement.text().strip();
|
|
||||||
Element timeElement = post.selectFirst("abbr[data-utime]");
|
|
||||||
LocalDateTime publishedAt = parseUnixTime(timeElement == null ? null : timeElement.attr("data-utime"));
|
|
||||||
String thumbnail = findThumbnail(post);
|
|
||||||
Integer durationSec = findDuration(post);
|
|
||||||
unique.put(id, new SocialVideoCandidate(id, "https://www.facebook.com/reel/" + id,
|
|
||||||
title.isBlank() ? "(제목 없음)" : title, thumbnail, publishedAt, durationSec));
|
|
||||||
|
|
||||||
if (channelTitle == null) {
|
|
||||||
Element titleNode = post.selectFirst("._2_79, a[title]");
|
|
||||||
if (titleNode != null && !titleNode.text().isBlank()) channelTitle = titleNode.text().strip();
|
|
||||||
if ((channelTitle == null || channelTitle.isBlank()) && titleNode != null) {
|
|
||||||
channelTitle = titleNode.attr("title").strip();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (unique.size() >= limit) break;
|
|
||||||
}
|
|
||||||
return new SocialFeedBatch(channelTitle, null, new ArrayList<>(unique.values()));
|
|
||||||
}
|
|
||||||
|
|
||||||
static SocialFeedBatch parseReader(String markdown, int limit) {
|
|
||||||
String channelTitle = null;
|
|
||||||
Matcher titleMatcher = READER_TITLE.matcher(markdown == null ? "" : markdown);
|
|
||||||
if (titleMatcher.find()) channelTitle = titleMatcher.group(1).strip();
|
|
||||||
LinkedHashMap<String, SocialVideoCandidate> unique = new LinkedHashMap<>();
|
|
||||||
Matcher reelMatcher = READER_REEL.matcher(markdown == null ? "" : markdown);
|
|
||||||
while (reelMatcher.find() && unique.size() < limit) {
|
|
||||||
String thumbnail = reelMatcher.group(1).replace("&", "&");
|
|
||||||
String id = reelMatcher.group(2);
|
|
||||||
String title = (channelTitle == null ? "Facebook" : channelTitle) + " · Reel " + id;
|
|
||||||
unique.putIfAbsent(id, new SocialVideoCandidate(id,
|
|
||||||
"https://www.facebook.com/reel/" + id, title, thumbnail, null, null));
|
|
||||||
}
|
|
||||||
return new SocialFeedBatch(channelTitle, null, new ArrayList<>(unique.values()));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String findThumbnail(Element post) {
|
|
||||||
for (Element image : post.select("img[src]")) {
|
|
||||||
String src = image.absUrl("src");
|
|
||||||
String classes = image.className();
|
|
||||||
if (!src.isBlank() && !src.contains("s50x50") && !classes.contains("_s0")) return src;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Integer findDuration(Element post) {
|
|
||||||
for (Element element : post.select("[aria-label]")) {
|
|
||||||
String label = element.attr("aria-label");
|
|
||||||
Integer seconds = parseDurationLabel(label, KOREAN_DURATION);
|
|
||||||
if (seconds != null) return seconds;
|
|
||||||
seconds = parseDurationLabel(label, ENGLISH_DURATION);
|
|
||||||
if (seconds != null) return seconds;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Integer parseDurationLabel(String label, Pattern pattern) {
|
|
||||||
Matcher matcher = pattern.matcher(label == null ? "" : label);
|
|
||||||
if (!matcher.find()) return null;
|
|
||||||
int minutes = matcher.group(1) == null ? 0 : Integer.parseInt(matcher.group(1));
|
|
||||||
int seconds = Integer.parseInt(matcher.group(2));
|
|
||||||
int total = minutes * 60 + seconds;
|
|
||||||
return total > 0 ? total : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static LocalDateTime parseUnixTime(String value) {
|
|
||||||
if (value == null || value.isBlank()) return null;
|
|
||||||
try {
|
|
||||||
return LocalDateTime.ofInstant(Instant.ofEpochSecond(Long.parseLong(value)), ZoneId.systemDefault());
|
|
||||||
} catch (NumberFormatException ignored) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String friendly(Exception e) {
|
|
||||||
String message = e.getMessage();
|
|
||||||
if (message == null || message.isBlank()) return e.getClass().getSimpleName();
|
|
||||||
return message.length() <= 500 ? message : message.substring(0, 500);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,97 +0,0 @@
|
|||||||
package com.hlab.yanalyst.domain.social;
|
|
||||||
|
|
||||||
import com.hlab.yanalyst.domain.channel.FeedTopic;
|
|
||||||
import jakarta.persistence.*;
|
|
||||||
import lombok.AccessLevel;
|
|
||||||
import lombok.Getter;
|
|
||||||
import lombok.NoArgsConstructor;
|
|
||||||
import org.springframework.data.annotation.CreatedDate;
|
|
||||||
import org.springframework.data.annotation.LastModifiedDate;
|
|
||||||
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
|
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
|
||||||
|
|
||||||
@Entity
|
|
||||||
@Table(name = "social_channels", uniqueConstraints =
|
|
||||||
@UniqueConstraint(name = "uk_social_channel_source_url", columnNames = "source_url"))
|
|
||||||
@Getter
|
|
||||||
@NoArgsConstructor(access = AccessLevel.PROTECTED)
|
|
||||||
@EntityListeners(AuditingEntityListener.class)
|
|
||||||
public class SocialChannel {
|
|
||||||
|
|
||||||
@Id
|
|
||||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
|
||||||
private Long id;
|
|
||||||
|
|
||||||
@Enumerated(EnumType.STRING)
|
|
||||||
@Column(nullable = false, length = 20)
|
|
||||||
private SocialPlatform platform;
|
|
||||||
|
|
||||||
@Column(name = "source_url", nullable = false, length = 2000)
|
|
||||||
private String sourceUrl;
|
|
||||||
|
|
||||||
@Column(name = "external_channel_id", length = 200)
|
|
||||||
private String externalChannelId;
|
|
||||||
|
|
||||||
@Column(nullable = false, length = 300)
|
|
||||||
private String title;
|
|
||||||
|
|
||||||
@Enumerated(EnumType.STRING)
|
|
||||||
@Column(nullable = false, length = 20)
|
|
||||||
private FeedTopic topic;
|
|
||||||
|
|
||||||
@Column(name = "notification_enabled", nullable = false)
|
|
||||||
private boolean notificationEnabled;
|
|
||||||
|
|
||||||
@Column(name = "last_collected_at")
|
|
||||||
private LocalDateTime lastCollectedAt;
|
|
||||||
|
|
||||||
@Column(name = "last_found", nullable = false)
|
|
||||||
private int lastFound;
|
|
||||||
|
|
||||||
@Column(name = "fail_count", nullable = false)
|
|
||||||
private int failCount;
|
|
||||||
|
|
||||||
@Column(name = "last_error", columnDefinition = "TEXT")
|
|
||||||
private String lastError;
|
|
||||||
|
|
||||||
@CreatedDate
|
|
||||||
@Column(updatable = false)
|
|
||||||
private LocalDateTime createdAt;
|
|
||||||
|
|
||||||
@LastModifiedDate
|
|
||||||
private LocalDateTime updatedAt;
|
|
||||||
|
|
||||||
public static SocialChannel register(String url, String topic, boolean notificationEnabled) {
|
|
||||||
String normalizedUrl = SocialPlatform.normalizeChannelUrl(url);
|
|
||||||
SocialChannel channel = new SocialChannel();
|
|
||||||
channel.platform = SocialPlatform.fromUrl(normalizedUrl);
|
|
||||||
channel.sourceUrl = normalizedUrl;
|
|
||||||
channel.title = SocialPlatform.displayNameFromUrl(normalizedUrl);
|
|
||||||
channel.topic = FeedTopic.normalize(topic);
|
|
||||||
channel.notificationEnabled = notificationEnabled;
|
|
||||||
return channel;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void updateMetadata(String title, String externalChannelId) {
|
|
||||||
if (title != null && !title.isBlank()) this.title = title.strip();
|
|
||||||
if (externalChannelId != null && !externalChannelId.isBlank()) this.externalChannelId = externalChannelId.strip();
|
|
||||||
}
|
|
||||||
|
|
||||||
public void configure(String topic, boolean notificationEnabled) {
|
|
||||||
this.topic = FeedTopic.normalize(topic);
|
|
||||||
this.notificationEnabled = notificationEnabled;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void collectionSucceeded(int found) {
|
|
||||||
this.lastCollectedAt = LocalDateTime.now();
|
|
||||||
this.lastFound = Math.max(0, found);
|
|
||||||
this.failCount = 0;
|
|
||||||
this.lastError = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void collectionFailed(String message) {
|
|
||||||
this.failCount++;
|
|
||||||
this.lastError = message == null || message.isBlank() ? "수집에 실패했습니다." : message;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,12 +0,0 @@
|
|||||||
package com.hlab.yanalyst.domain.social;
|
|
||||||
|
|
||||||
import com.hlab.yanalyst.domain.channel.FeedTopic;
|
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Optional;
|
|
||||||
|
|
||||||
public interface SocialChannelRepository extends JpaRepository<SocialChannel, Long> {
|
|
||||||
Optional<SocialChannel> findBySourceUrl(String sourceUrl);
|
|
||||||
List<SocialChannel> findAllByTopicOrderByIdAsc(FeedTopic topic);
|
|
||||||
}
|
|
||||||
@ -1,62 +0,0 @@
|
|||||||
package com.hlab.yanalyst.domain.social;
|
|
||||||
|
|
||||||
import com.hlab.yanalyst.domain.channel.FeedTopic;
|
|
||||||
import com.hlab.yanalyst.global.notify.TelegramNotifier;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
|
||||||
import org.springframework.stereotype.Service;
|
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
|
||||||
import java.time.format.DateTimeFormatter;
|
|
||||||
import java.util.Comparator;
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
@Slf4j
|
|
||||||
@Service
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
public class SocialFeedAlertService {
|
|
||||||
private static final int MAX_LINES = 8;
|
|
||||||
private static final DateTimeFormatter TIME = DateTimeFormatter.ofPattern("M/d HH:mm");
|
|
||||||
|
|
||||||
private final TelegramNotifier telegramNotifier;
|
|
||||||
|
|
||||||
public int notifyNewVideos(SocialChannel channel, List<SocialVideo> newVideos) {
|
|
||||||
if (channel == null || channel.getTopic() == FeedTopic.POLITICS || !channel.isNotificationEnabled()
|
|
||||||
|| newVideos == null || newVideos.isEmpty() || !telegramNotifier.isEnabled()) return 0;
|
|
||||||
|
|
||||||
List<SocialVideo> sorted = newVideos.stream()
|
|
||||||
.sorted(Comparator.comparing(SocialVideo::getPublishedAt,
|
|
||||||
Comparator.nullsLast(Comparator.reverseOrder())))
|
|
||||||
.toList();
|
|
||||||
String topicEmoji = channel.getTopic() == FeedTopic.DISTRIBUTION ? "📦" : "🎬";
|
|
||||||
String topicName = channel.getTopic() == FeedTopic.DISTRIBUTION ? "유통" : "예능";
|
|
||||||
StringBuilder message = new StringBuilder(topicEmoji).append(" <b>").append(topicName)
|
|
||||||
.append(" 새 영상 · ").append(esc(channel.getTitle())).append(" ")
|
|
||||||
.append(sorted.size()).append("건</b>");
|
|
||||||
|
|
||||||
int shown = Math.min(sorted.size(), MAX_LINES);
|
|
||||||
for (int i = 0; i < shown; i++) {
|
|
||||||
SocialVideo video = sorted.get(i);
|
|
||||||
message.append("\n\n• <a href=\"").append(esc(video.getUrl())).append("\">")
|
|
||||||
.append(esc(clip(video.getTitle()))).append("</a>");
|
|
||||||
if (video.getPublishedAt() != null) message.append(" · ").append(TIME.format(video.getPublishedAt()));
|
|
||||||
}
|
|
||||||
if (sorted.size() > shown) message.append("\n\n…외 ").append(sorted.size() - shown).append("건");
|
|
||||||
message.append("\n\nhttps://h-lab.tolag.shop/distribution-feed");
|
|
||||||
|
|
||||||
boolean sent = telegramNotifier.sendMessage(message.toString());
|
|
||||||
log.info("[SocialFeed] 텔레그램 알림: channel={}, sent={}, count={}", channel.getId(), sent, sorted.size());
|
|
||||||
return sent ? sorted.size() : 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String clip(String text) {
|
|
||||||
if (text == null || text.isBlank()) return "(제목 없음)";
|
|
||||||
return text.length() <= 90 ? text : text.substring(0, 89) + "…";
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String esc(String text) {
|
|
||||||
if (text == null) return "-";
|
|
||||||
return text.replace("&", "&").replace("<", "<").replace(">", ">")
|
|
||||||
.replace("\"", """);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,10 +0,0 @@
|
|||||||
package com.hlab.yanalyst.domain.social;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
public record SocialFeedBatch(String channelTitle, String externalChannelId,
|
|
||||||
List<SocialVideoCandidate> videos) {
|
|
||||||
public SocialFeedBatch {
|
|
||||||
videos = videos == null ? List.of() : List.copyOf(videos);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,6 +0,0 @@
|
|||||||
package com.hlab.yanalyst.domain.social;
|
|
||||||
|
|
||||||
public interface SocialFeedClient {
|
|
||||||
boolean supports(SocialPlatform platform);
|
|
||||||
SocialFeedBatch fetch(SocialChannel channel);
|
|
||||||
}
|
|
||||||
@ -1,52 +0,0 @@
|
|||||||
package com.hlab.yanalyst.domain.social;
|
|
||||||
|
|
||||||
import com.hlab.yanalyst.domain.channel.FeedTopic;
|
|
||||||
import com.hlab.yanalyst.global.common.ApiResponse;
|
|
||||||
import jakarta.validation.Valid;
|
|
||||||
import jakarta.validation.constraints.NotBlank;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import org.springframework.web.bind.annotation.*;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
|
|
||||||
@RestController
|
|
||||||
@RequestMapping("/api/social-feed")
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
public class SocialFeedController {
|
|
||||||
private final SocialFeedService service;
|
|
||||||
|
|
||||||
public record AddChannelRequest(@NotBlank(message = "채널 URL을 입력하세요.") String url,
|
|
||||||
String topic, Boolean notificationEnabled) { }
|
|
||||||
|
|
||||||
@GetMapping("/channels")
|
|
||||||
public ApiResponse<List<SocialFeedService.ChannelDto>> channels(
|
|
||||||
@RequestParam(defaultValue = "DISTRIBUTION") String topic) {
|
|
||||||
return ApiResponse.ok(service.channels(topic));
|
|
||||||
}
|
|
||||||
|
|
||||||
@PostMapping("/channels")
|
|
||||||
public ApiResponse<SocialFeedService.ChannelDto> addChannel(@Valid @RequestBody AddChannelRequest request) {
|
|
||||||
String topic = request.topic() == null ? FeedTopic.DISTRIBUTION.name() : request.topic();
|
|
||||||
return ApiResponse.created(service.addChannel(request.url(), topic,
|
|
||||||
request.notificationEnabled() == null || request.notificationEnabled()));
|
|
||||||
}
|
|
||||||
|
|
||||||
@DeleteMapping("/channels/{id}")
|
|
||||||
public ApiResponse<Void> removeChannel(@PathVariable Long id) {
|
|
||||||
service.removeChannel(id);
|
|
||||||
return ApiResponse.ok(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
@GetMapping("/videos")
|
|
||||||
public ApiResponse<List<SocialFeedService.VideoDto>> videos(
|
|
||||||
@RequestParam(defaultValue = "DISTRIBUTION") String topic) {
|
|
||||||
return ApiResponse.ok(service.videos(topic));
|
|
||||||
}
|
|
||||||
|
|
||||||
@PostMapping("/collect")
|
|
||||||
public ApiResponse<Map<String, Object>> collect(
|
|
||||||
@RequestParam(defaultValue = "DISTRIBUTION") String topic) {
|
|
||||||
return ApiResponse.ok(service.collect(topic));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,197 +0,0 @@
|
|||||||
package com.hlab.yanalyst.domain.social;
|
|
||||||
|
|
||||||
import com.hlab.yanalyst.domain.channel.FeedTopic;
|
|
||||||
import jakarta.persistence.EntityNotFoundException;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
|
||||||
import org.springframework.boot.context.event.ApplicationReadyEvent;
|
|
||||||
import org.springframework.context.event.EventListener;
|
|
||||||
import org.springframework.data.domain.PageRequest;
|
|
||||||
import org.springframework.scheduling.annotation.Scheduled;
|
|
||||||
import org.springframework.stereotype.Service;
|
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
|
||||||
import java.util.*;
|
|
||||||
|
|
||||||
@Slf4j
|
|
||||||
@Service
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
public class SocialFeedService {
|
|
||||||
private static final int MAX_FEED_ITEMS = 200;
|
|
||||||
|
|
||||||
private final SocialChannelRepository channelRepository;
|
|
||||||
private final SocialVideoRepository videoRepository;
|
|
||||||
private final List<SocialFeedClient> clients;
|
|
||||||
private final SocialFeedAlertService alertService;
|
|
||||||
private final SocialVideoMetadataResolver metadataResolver;
|
|
||||||
|
|
||||||
@Value("${hlab.social-feed.enabled:true}") private boolean enabled;
|
|
||||||
@Value("${hlab.social-feed.seed.phutunet-enabled:true}") private boolean phutunetSeedEnabled;
|
|
||||||
@Value("${hlab.social-feed.seed.phutunet-url:https://www.facebook.com/phutunet/reels/}") private String phutunetUrl;
|
|
||||||
|
|
||||||
public record ChannelDto(Long id, String platform, String sourceUrl, String externalChannelId,
|
|
||||||
String title, String topic, boolean notificationEnabled,
|
|
||||||
LocalDateTime lastCollectedAt, int lastFound, int failCount, String lastError) {
|
|
||||||
static ChannelDto from(SocialChannel channel) {
|
|
||||||
return new ChannelDto(channel.getId(), channel.getPlatform().name(), channel.getSourceUrl(),
|
|
||||||
channel.getExternalChannelId(), channel.getTitle(), channel.getTopic().name(),
|
|
||||||
channel.isNotificationEnabled(), channel.getLastCollectedAt(), channel.getLastFound(),
|
|
||||||
channel.getFailCount(), channel.getLastError());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public record VideoDto(Long id, String externalVideoId, String url, String title, String thumbnailUrl,
|
|
||||||
Integer durationSec,
|
|
||||||
LocalDateTime publishedAt, LocalDateTime collectedAt,
|
|
||||||
String channelTitle, String platform, String topic) {
|
|
||||||
static VideoDto from(SocialVideo video) {
|
|
||||||
SocialChannel channel = video.getChannel();
|
|
||||||
return new VideoDto(video.getId(), video.getExternalVideoId(), video.getUrl(), video.getTitle(),
|
|
||||||
video.getThumbnailUrl(), video.getDurationSec(), video.getPublishedAt(), video.getCollectedAt(),
|
|
||||||
channel.getTitle(), channel.getPlatform().name(), channel.getTopic().name());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@EventListener(ApplicationReadyEvent.class)
|
|
||||||
@Transactional
|
|
||||||
public void registerDefaultChannels() {
|
|
||||||
if (!phutunetSeedEnabled || phutunetUrl == null || phutunetUrl.isBlank()) return;
|
|
||||||
String normalized = SocialPlatform.normalizeChannelUrl(phutunetUrl);
|
|
||||||
if (channelRepository.findBySourceUrl(normalized).isPresent()) return;
|
|
||||||
SocialChannel channel = SocialChannel.register(normalized, FeedTopic.DISTRIBUTION.name(), true);
|
|
||||||
channel.updateMetadata("Phutu Crafts", "321133534899178");
|
|
||||||
channelRepository.save(channel);
|
|
||||||
log.info("[SocialFeed] 기본 Facebook 유통 채널 등록: {}", normalized);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Scheduled(cron = "${hlab.social-feed.cron:0 */15 * * * *}")
|
|
||||||
public void scheduledCollect() {
|
|
||||||
if (!enabled) return;
|
|
||||||
log.info("[SocialFeed] 자동 수집 완료: {}", collect(FeedTopic.DISTRIBUTION.name()));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
|
||||||
public List<ChannelDto> channels(String topic) {
|
|
||||||
return channelRepository.findAllByTopicOrderByIdAsc(FeedTopic.normalize(topic)).stream()
|
|
||||||
.map(ChannelDto::from).toList();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
|
||||||
public List<VideoDto> videos(String topic) {
|
|
||||||
return videoRepository.findFeed(FeedTopic.normalize(topic), PageRequest.of(0, MAX_FEED_ITEMS)).stream()
|
|
||||||
.map(VideoDto::from).toList();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional
|
|
||||||
public ChannelDto addChannel(String url, String topic, boolean notificationEnabled) {
|
|
||||||
String normalized = SocialPlatform.normalizeChannelUrl(url);
|
|
||||||
SocialChannel channel = channelRepository.findBySourceUrl(normalized)
|
|
||||||
.orElseGet(() -> SocialChannel.register(normalized, topic, notificationEnabled));
|
|
||||||
channel.configure(topic, notificationEnabled);
|
|
||||||
return ChannelDto.from(channelRepository.save(channel));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional
|
|
||||||
public void removeChannel(Long id) {
|
|
||||||
SocialChannel channel = getChannel(id);
|
|
||||||
videoRepository.deleteAllByChannelId(id);
|
|
||||||
channelRepository.delete(channel);
|
|
||||||
}
|
|
||||||
|
|
||||||
public Map<String, Object> collect(String topic) {
|
|
||||||
FeedTopic normalized = FeedTopic.normalize(topic);
|
|
||||||
List<SocialChannel> channels = channelRepository.findAllByTopicOrderByIdAsc(normalized);
|
|
||||||
int succeeded = 0, failed = 0, saved = 0, alerted = 0;
|
|
||||||
for (SocialChannel channel : channels) {
|
|
||||||
try {
|
|
||||||
CollectionResult result = collectOne(channel.getId());
|
|
||||||
succeeded++;
|
|
||||||
saved += result.saved();
|
|
||||||
alerted += result.alerted();
|
|
||||||
} catch (Exception e) {
|
|
||||||
failed++;
|
|
||||||
markFailure(channel.getId(), friendly(e));
|
|
||||||
log.warn("[SocialFeed] 채널 수집 실패: id={}, url={}", channel.getId(), channel.getSourceUrl(), e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Map<String, Object> summary = new LinkedHashMap<>();
|
|
||||||
summary.put("channels", channels.size());
|
|
||||||
summary.put("collected", succeeded);
|
|
||||||
summary.put("savedVideos", saved);
|
|
||||||
summary.put("alerted", alerted);
|
|
||||||
summary.put("failed", failed);
|
|
||||||
return summary;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional
|
|
||||||
public CollectionResult collectOne(Long channelId) {
|
|
||||||
SocialChannel channel = getChannel(channelId);
|
|
||||||
LocalDateTime previousCollection = channel.getLastCollectedAt();
|
|
||||||
boolean baselineCollection = previousCollection == null;
|
|
||||||
SocialFeedClient client = clients.stream().filter(c -> c.supports(channel.getPlatform())).findFirst()
|
|
||||||
.orElseThrow(() -> new IllegalStateException(channel.getPlatform() + " 수집기가 없습니다."));
|
|
||||||
SocialFeedBatch batch = client.fetch(channel);
|
|
||||||
channel.updateMetadata(batch.channelTitle(), batch.externalChannelId());
|
|
||||||
|
|
||||||
List<SocialVideo> inserted = new ArrayList<>();
|
|
||||||
for (SocialVideoCandidate original : batch.videos()) {
|
|
||||||
SocialVideoCandidate candidate = original;
|
|
||||||
if (candidate.externalVideoId() == null || candidate.externalVideoId().isBlank()
|
|
||||||
|| candidate.url() == null || candidate.url().isBlank()) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
Optional<SocialVideo> existing = videoRepository.findByChannelIdAndExternalVideoId(
|
|
||||||
channelId, candidate.externalVideoId());
|
|
||||||
if (existing.isPresent()) {
|
|
||||||
SocialVideo stored = existing.get();
|
|
||||||
if (stored.getDurationSec() == null) {
|
|
||||||
Integer durationSec = candidate.durationSec() == null
|
|
||||||
? metadataResolver.resolveDuration(candidate)
|
|
||||||
: candidate.durationSec();
|
|
||||||
stored.fillDuration(durationSec);
|
|
||||||
if (stored.getDurationSec() != null) videoRepository.save(stored);
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (candidate.durationSec() == null) {
|
|
||||||
candidate = candidate.withDurationSec(metadataResolver.resolveDuration(candidate));
|
|
||||||
}
|
|
||||||
inserted.add(videoRepository.save(SocialVideo.from(channel, candidate)));
|
|
||||||
}
|
|
||||||
channel.collectionSucceeded(inserted.size());
|
|
||||||
channelRepository.save(channel);
|
|
||||||
// 최초 수집은 현재 목록을 기준점으로만 저장한다. 다음 수집부터 생긴 항목만 알린다.
|
|
||||||
List<SocialVideo> alertCandidates = baselineCollection ? List.of() : inserted.stream()
|
|
||||||
// 플러그인 목록 순서가 바뀌며 뒤늦게 발견된 과거 영상을 신규로 오인하지 않는다.
|
|
||||||
.filter(video -> video.getPublishedAt() == null
|
|
||||||
|| !video.getPublishedAt().isBefore(previousCollection.minusMinutes(5)))
|
|
||||||
.toList();
|
|
||||||
int alerted = baselineCollection ? 0 : alertService.notifyNewVideos(channel, alertCandidates);
|
|
||||||
return new CollectionResult(inserted.size(), alerted, baselineCollection);
|
|
||||||
}
|
|
||||||
|
|
||||||
public record CollectionResult(int saved, int alerted, boolean baselineCollection) { }
|
|
||||||
|
|
||||||
@Transactional
|
|
||||||
protected void markFailure(Long channelId, String message) {
|
|
||||||
channelRepository.findById(channelId).ifPresent(channel -> {
|
|
||||||
channel.collectionFailed(message);
|
|
||||||
channelRepository.save(channel);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private SocialChannel getChannel(Long id) {
|
|
||||||
return channelRepository.findById(id)
|
|
||||||
.orElseThrow(() -> new EntityNotFoundException("소셜 채널을 찾을 수 없습니다: " + id));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String friendly(Exception e) {
|
|
||||||
Throwable cause = e;
|
|
||||||
while (cause.getCause() != null && cause.getMessage() == null) cause = cause.getCause();
|
|
||||||
String message = cause.getMessage();
|
|
||||||
if (message == null || message.isBlank()) return "수집에 실패했습니다.";
|
|
||||||
return message.length() <= 1200 ? message : message.substring(message.length() - 1200);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,85 +0,0 @@
|
|||||||
package com.hlab.yanalyst.domain.social;
|
|
||||||
|
|
||||||
import java.net.URI;
|
|
||||||
import java.net.URISyntaxException;
|
|
||||||
import java.util.Locale;
|
|
||||||
import java.util.Set;
|
|
||||||
|
|
||||||
/** 유통 소재 채널에서 지원하는 소셜 플랫폼과 URL 검증 규칙. */
|
|
||||||
public enum SocialPlatform {
|
|
||||||
FACEBOOK(Set.of("facebook.com", "fb.watch")),
|
|
||||||
INSTAGRAM(Set.of("instagram.com")),
|
|
||||||
TIKTOK(Set.of("tiktok.com")),
|
|
||||||
YOUTUBE(Set.of("youtube.com", "youtu.be"));
|
|
||||||
|
|
||||||
private final Set<String> hosts;
|
|
||||||
|
|
||||||
SocialPlatform(Set<String> hosts) {
|
|
||||||
this.hosts = hosts;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static SocialPlatform fromUrl(String value) {
|
|
||||||
URI uri = parse(value);
|
|
||||||
String host = uri.getHost().toLowerCase(Locale.ROOT);
|
|
||||||
for (SocialPlatform platform : values()) {
|
|
||||||
if (platform.hosts.stream().anyMatch(allowed -> host.equals(allowed) || host.endsWith("." + allowed))) {
|
|
||||||
return platform;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
throw new IllegalArgumentException("Facebook, Instagram, TikTok, YouTube 채널 URL만 등록할 수 있습니다.");
|
|
||||||
}
|
|
||||||
|
|
||||||
public static String normalizeChannelUrl(String value) {
|
|
||||||
URI uri = parse(value);
|
|
||||||
SocialPlatform platform = fromUrl(value);
|
|
||||||
String path = uri.getPath() == null || uri.getPath().isBlank() ? "/" : uri.getPath();
|
|
||||||
validateChannelPath(platform, path);
|
|
||||||
while (path.length() > 1 && path.endsWith("/")) path = path.substring(0, path.length() - 1);
|
|
||||||
// Facebook Page Plugin은 /페이지/reels 주소를 직접 받으면 빈 응답을 내므로 페이지 본주소로 맞춘다.
|
|
||||||
if (platform == FACEBOOK) path = path.replaceFirst("(?i)/(?:reels|videos)$", "");
|
|
||||||
try {
|
|
||||||
return new URI("https", null, uri.getHost().toLowerCase(Locale.ROOT), -1, path, null, null).toString();
|
|
||||||
} catch (URISyntaxException e) {
|
|
||||||
throw new IllegalArgumentException("올바른 채널 URL을 입력하세요.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static String displayNameFromUrl(String normalizedUrl) {
|
|
||||||
String path = URI.create(normalizedUrl).getPath();
|
|
||||||
String[] segments = path.split("/");
|
|
||||||
for (String segment : segments) {
|
|
||||||
if (!segment.isBlank() && !segment.equalsIgnoreCase("reels") && !segment.equalsIgnoreCase("videos")) {
|
|
||||||
return segment.startsWith("@") ? segment.substring(1) : segment;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return fromUrl(normalizedUrl).name();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static URI parse(String value) {
|
|
||||||
if (value == null || value.isBlank()) throw new IllegalArgumentException("채널 URL을 입력하세요.");
|
|
||||||
if (value.trim().length() > 2000) throw new IllegalArgumentException("URL은 2,000자 이하여야 합니다.");
|
|
||||||
try {
|
|
||||||
URI uri = URI.create(value.trim());
|
|
||||||
String scheme = uri.getScheme() == null ? "" : uri.getScheme().toLowerCase(Locale.ROOT);
|
|
||||||
if (!(scheme.equals("http") || scheme.equals("https")) || uri.getHost() == null || uri.getUserInfo() != null) {
|
|
||||||
throw new IllegalArgumentException("HTTP(S) 채널 URL을 입력하세요.");
|
|
||||||
}
|
|
||||||
return uri;
|
|
||||||
} catch (IllegalArgumentException e) {
|
|
||||||
if (e.getMessage() != null && (e.getMessage().contains("HTTP(S)") || e.getMessage().contains("2,000"))) throw e;
|
|
||||||
throw new IllegalArgumentException("올바른 채널 URL을 입력하세요.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void validateChannelPath(SocialPlatform platform, String path) {
|
|
||||||
String lower = path.toLowerCase(Locale.ROOT);
|
|
||||||
boolean individualContent = switch (platform) {
|
|
||||||
case FACEBOOK -> lower.matches(".*/(?:reel|watch)/.*") || lower.matches(".*/videos/\\d+.*");
|
|
||||||
case INSTAGRAM -> lower.matches("/(?:p|reel|stories)/.*");
|
|
||||||
case TIKTOK -> lower.contains("/video/");
|
|
||||||
case YOUTUBE -> lower.equals("/watch") || lower.startsWith("/shorts/") || lower.startsWith("/embed/")
|
|
||||||
|| lower.startsWith("/live/") || platform == YOUTUBE && path.equals("/");
|
|
||||||
};
|
|
||||||
if (individualContent) throw new IllegalArgumentException("개별 영상이 아닌 채널·프로필 URL을 입력하세요.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,67 +0,0 @@
|
|||||||
package com.hlab.yanalyst.domain.social;
|
|
||||||
|
|
||||||
import jakarta.persistence.*;
|
|
||||||
import lombok.AccessLevel;
|
|
||||||
import lombok.Getter;
|
|
||||||
import lombok.NoArgsConstructor;
|
|
||||||
import org.springframework.data.annotation.CreatedDate;
|
|
||||||
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
|
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
|
||||||
|
|
||||||
@Entity
|
|
||||||
@Table(name = "social_videos",
|
|
||||||
uniqueConstraints = @UniqueConstraint(name = "uk_social_video_channel_external",
|
|
||||||
columnNames = {"channel_id", "external_video_id"}),
|
|
||||||
indexes = @Index(name = "idx_social_video_published", columnList = "published_at"))
|
|
||||||
@Getter
|
|
||||||
@NoArgsConstructor(access = AccessLevel.PROTECTED)
|
|
||||||
@EntityListeners(AuditingEntityListener.class)
|
|
||||||
public class SocialVideo {
|
|
||||||
|
|
||||||
@Id
|
|
||||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
|
||||||
private Long id;
|
|
||||||
|
|
||||||
@ManyToOne(fetch = FetchType.LAZY, optional = false)
|
|
||||||
@JoinColumn(name = "channel_id", nullable = false)
|
|
||||||
private SocialChannel channel;
|
|
||||||
|
|
||||||
@Column(name = "external_video_id", nullable = false, length = 300)
|
|
||||||
private String externalVideoId;
|
|
||||||
|
|
||||||
@Column(nullable = false, length = 2000)
|
|
||||||
private String url;
|
|
||||||
|
|
||||||
@Column(nullable = false, columnDefinition = "TEXT")
|
|
||||||
private String title;
|
|
||||||
|
|
||||||
@Column(name = "thumbnail_url", length = 4000)
|
|
||||||
private String thumbnailUrl;
|
|
||||||
|
|
||||||
@Column(name = "published_at")
|
|
||||||
private LocalDateTime publishedAt;
|
|
||||||
|
|
||||||
@Column(name = "duration_sec")
|
|
||||||
private Integer durationSec;
|
|
||||||
|
|
||||||
@CreatedDate
|
|
||||||
@Column(name = "collected_at", updatable = false)
|
|
||||||
private LocalDateTime collectedAt;
|
|
||||||
|
|
||||||
public static SocialVideo from(SocialChannel channel, SocialVideoCandidate candidate) {
|
|
||||||
SocialVideo video = new SocialVideo();
|
|
||||||
video.channel = channel;
|
|
||||||
video.externalVideoId = candidate.externalVideoId();
|
|
||||||
video.url = candidate.url();
|
|
||||||
video.title = candidate.title() == null || candidate.title().isBlank() ? "(제목 없음)" : candidate.title();
|
|
||||||
video.thumbnailUrl = candidate.thumbnailUrl();
|
|
||||||
video.publishedAt = candidate.publishedAt();
|
|
||||||
video.durationSec = candidate.durationSec();
|
|
||||||
return video;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void fillDuration(Integer value) {
|
|
||||||
if (durationSec == null && value != null && value > 0) durationSec = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,16 +0,0 @@
|
|||||||
package com.hlab.yanalyst.domain.social;
|
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
|
||||||
|
|
||||||
public record SocialVideoCandidate(
|
|
||||||
String externalVideoId,
|
|
||||||
String url,
|
|
||||||
String title,
|
|
||||||
String thumbnailUrl,
|
|
||||||
LocalDateTime publishedAt,
|
|
||||||
Integer durationSec
|
|
||||||
) {
|
|
||||||
SocialVideoCandidate withDurationSec(Integer value) {
|
|
||||||
return new SocialVideoCandidate(externalVideoId, url, title, thumbnailUrl, publishedAt, value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,79 +0,0 @@
|
|||||||
package com.hlab.yanalyst.domain.social;
|
|
||||||
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
|
||||||
import org.springframework.stereotype.Component;
|
|
||||||
|
|
||||||
import java.io.ByteArrayOutputStream;
|
|
||||||
import java.nio.charset.StandardCharsets;
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.concurrent.TimeUnit;
|
|
||||||
import java.util.regex.Matcher;
|
|
||||||
import java.util.regex.Pattern;
|
|
||||||
|
|
||||||
/** 채널 목록 응답에 빠진 영상 길이를 개별 영상 메타데이터에서 보충한다. */
|
|
||||||
@Slf4j
|
|
||||||
@Component
|
|
||||||
public class SocialVideoMetadataResolver {
|
|
||||||
private static final Pattern DURATION_LINE = Pattern.compile("(?m)^([0-9]+(?:\\.[0-9]+)?)\\s*$");
|
|
||||||
|
|
||||||
@Value("${ytdlp.bin:yt-dlp}") private String ytdlpBin;
|
|
||||||
@Value("${hlab.social-feed.metadata-timeout-seconds:45}") private long timeoutSeconds;
|
|
||||||
@Value("${hlab.social-feed.cookies-file:}") private String cookiesFile;
|
|
||||||
|
|
||||||
public Integer resolveDuration(SocialVideoCandidate candidate) {
|
|
||||||
if (candidate.durationSec() != null && candidate.durationSec() > 0) return candidate.durationSec();
|
|
||||||
List<String> command = buildCommand(ytdlpBin, cookiesFile, candidate.url());
|
|
||||||
Process process = null;
|
|
||||||
try {
|
|
||||||
process = new ProcessBuilder(command).redirectErrorStream(true).start();
|
|
||||||
ByteArrayOutputStream output = new ByteArrayOutputStream();
|
|
||||||
Process running = process;
|
|
||||||
Thread drainer = Thread.ofVirtual().start(() -> {
|
|
||||||
try (var in = running.getInputStream()) { in.transferTo(output); }
|
|
||||||
catch (Exception ignored) { }
|
|
||||||
});
|
|
||||||
if (!process.waitFor(Math.max(1, timeoutSeconds), TimeUnit.SECONDS)) {
|
|
||||||
process.destroyForcibly();
|
|
||||||
log.warn("[SocialFeed] 영상 길이 조회 시간 초과: {}", candidate.url());
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
drainer.join(2_000);
|
|
||||||
String text = output.toString(StandardCharsets.UTF_8);
|
|
||||||
if (process.exitValue() != 0) {
|
|
||||||
log.warn("[SocialFeed] 영상 길이 조회 실패: url={}, output={}", candidate.url(), tail(text));
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return parseDurationSeconds(text);
|
|
||||||
} catch (InterruptedException e) {
|
|
||||||
Thread.currentThread().interrupt();
|
|
||||||
return null;
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.warn("[SocialFeed] 영상 길이 조회 실행 실패: url={}, error={}", candidate.url(), e.getMessage());
|
|
||||||
return null;
|
|
||||||
} finally {
|
|
||||||
if (process != null && process.isAlive()) process.destroyForcibly();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static List<String> buildCommand(String bin, String cookiesFile, String url) {
|
|
||||||
List<String> command = new ArrayList<>(List.of(bin, "--skip-download", "--no-playlist",
|
|
||||||
"--no-warnings", "--print", "%(duration)s"));
|
|
||||||
if (cookiesFile != null && !cookiesFile.isBlank()) command.addAll(List.of("--cookies", cookiesFile.trim()));
|
|
||||||
command.add(url);
|
|
||||||
return command;
|
|
||||||
}
|
|
||||||
|
|
||||||
static Integer parseDurationSeconds(String output) {
|
|
||||||
Matcher matcher = DURATION_LINE.matcher(output == null ? "" : output);
|
|
||||||
if (!matcher.find()) return null;
|
|
||||||
double seconds = Double.parseDouble(matcher.group(1));
|
|
||||||
return seconds > 0 ? Math.max(1, (int) Math.round(seconds)) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String tail(String output) {
|
|
||||||
String clean = output == null ? "" : output.strip();
|
|
||||||
return clean.length() <= 500 ? clean : clean.substring(clean.length() - 500);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,20 +0,0 @@
|
|||||||
package com.hlab.yanalyst.domain.social;
|
|
||||||
|
|
||||||
import com.hlab.yanalyst.domain.channel.FeedTopic;
|
|
||||||
import org.springframework.data.domain.Pageable;
|
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
|
||||||
import org.springframework.data.jpa.repository.Query;
|
|
||||||
import org.springframework.data.repository.query.Param;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Optional;
|
|
||||||
|
|
||||||
public interface SocialVideoRepository extends JpaRepository<SocialVideo, Long> {
|
|
||||||
Optional<SocialVideo> findByChannelIdAndExternalVideoId(Long channelId, String externalVideoId);
|
|
||||||
|
|
||||||
@Query("select v from SocialVideo v join fetch v.channel c where c.topic = :topic "
|
|
||||||
+ "order by coalesce(v.publishedAt, v.collectedAt) desc")
|
|
||||||
List<SocialVideo> findFeed(@Param("topic") FeedTopic topic, Pageable pageable);
|
|
||||||
|
|
||||||
void deleteAllByChannelId(Long channelId);
|
|
||||||
}
|
|
||||||
@ -1,136 +0,0 @@
|
|||||||
package com.hlab.yanalyst.domain.social;
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.JsonNode;
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
|
||||||
import org.springframework.stereotype.Component;
|
|
||||||
|
|
||||||
import java.io.ByteArrayOutputStream;
|
|
||||||
import java.nio.charset.StandardCharsets;
|
|
||||||
import java.time.*;
|
|
||||||
import java.time.format.DateTimeFormatter;
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.concurrent.TimeUnit;
|
|
||||||
|
|
||||||
/** yt-dlp의 채널/사용자 목록 추출을 이용한 YouTube·TikTok·Instagram 수집기. */
|
|
||||||
@Component
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
public class YtDlpSocialFeedClient implements SocialFeedClient {
|
|
||||||
private final ObjectMapper objectMapper;
|
|
||||||
|
|
||||||
@Value("${ytdlp.bin:yt-dlp}") private String ytdlpBin;
|
|
||||||
@Value("${hlab.social-feed.max-items:25}") private int maxItems;
|
|
||||||
@Value("${hlab.social-feed.timeout-seconds:90}") private long timeoutSeconds;
|
|
||||||
@Value("${hlab.social-feed.cookies-file:}") private String cookiesFile;
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public boolean supports(SocialPlatform platform) {
|
|
||||||
return platform != SocialPlatform.FACEBOOK;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public SocialFeedBatch fetch(SocialChannel channel) {
|
|
||||||
List<String> command = buildCommand(ytdlpBin, cookiesFile, maxItems, channel.getSourceUrl());
|
|
||||||
try {
|
|
||||||
Process process = new ProcessBuilder(command).redirectErrorStream(true).start();
|
|
||||||
ByteArrayOutputStream output = new ByteArrayOutputStream();
|
|
||||||
Thread drainer = Thread.ofVirtual().start(() -> {
|
|
||||||
try (var in = process.getInputStream()) { in.transferTo(output); }
|
|
||||||
catch (Exception ignored) { }
|
|
||||||
});
|
|
||||||
if (!process.waitFor(Math.max(1, timeoutSeconds), TimeUnit.SECONDS)) {
|
|
||||||
process.destroyForcibly();
|
|
||||||
throw new IllegalStateException("소셜 채널 수집 제한 시간을 초과했습니다.");
|
|
||||||
}
|
|
||||||
drainer.join(2000);
|
|
||||||
String text = output.toString(StandardCharsets.UTF_8);
|
|
||||||
if (process.exitValue() != 0) throw new IllegalStateException(errorTail(text));
|
|
||||||
return parse(text, channel.getPlatform(), Math.max(1, maxItems));
|
|
||||||
} catch (InterruptedException e) {
|
|
||||||
Thread.currentThread().interrupt();
|
|
||||||
throw new IllegalStateException("소셜 채널 수집이 중단되었습니다.", e);
|
|
||||||
} catch (Exception e) {
|
|
||||||
if (e instanceof IllegalStateException state) throw state;
|
|
||||||
throw new IllegalStateException("yt-dlp로 채널을 읽지 못했습니다: " + e.getMessage(), e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static List<String> buildCommand(String bin, String cookiesFile, int maxItems, String url) {
|
|
||||||
SocialPlatform.normalizeChannelUrl(url);
|
|
||||||
List<String> command = new ArrayList<>(List.of(bin, "--flat-playlist", "--dump-single-json",
|
|
||||||
"--playlist-end", String.valueOf(Math.max(1, maxItems)), "--skip-download", "--no-warnings"));
|
|
||||||
if (cookiesFile != null && !cookiesFile.isBlank()) command.addAll(List.of("--cookies", cookiesFile.trim()));
|
|
||||||
command.add(url);
|
|
||||||
return command;
|
|
||||||
}
|
|
||||||
|
|
||||||
SocialFeedBatch parse(String json, SocialPlatform platform, int limit) throws Exception {
|
|
||||||
JsonNode root = objectMapper.readTree(json);
|
|
||||||
String channelTitle = firstText(root, "channel", "uploader", "title");
|
|
||||||
String channelId = firstText(root, "channel_id", "uploader_id", "id");
|
|
||||||
List<SocialVideoCandidate> videos = new ArrayList<>();
|
|
||||||
for (JsonNode entry : root.path("entries")) {
|
|
||||||
if (entry == null || entry.isNull()) continue;
|
|
||||||
String id = firstText(entry, "id", "display_id");
|
|
||||||
if (id == null || id.isBlank()) continue;
|
|
||||||
String url = firstText(entry, "webpage_url", "url");
|
|
||||||
if (url == null || !url.startsWith("http")) url = canonicalUrl(platform, id);
|
|
||||||
String title = firstText(entry, "title", "description");
|
|
||||||
videos.add(new SocialVideoCandidate(id, url,
|
|
||||||
title == null || title.isBlank() ? "(제목 없음)" : title,
|
|
||||||
thumbnail(entry), publishedAt(entry), durationSec(entry)));
|
|
||||||
if (videos.size() >= limit) break;
|
|
||||||
}
|
|
||||||
return new SocialFeedBatch(channelTitle, channelId, videos);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String firstText(JsonNode node, String... fields) {
|
|
||||||
for (String field : fields) {
|
|
||||||
String value = node.path(field).asText(null);
|
|
||||||
if (value != null && !value.isBlank()) return value;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String thumbnail(JsonNode entry) {
|
|
||||||
String direct = entry.path("thumbnail").asText(null);
|
|
||||||
if (direct != null && !direct.isBlank()) return direct;
|
|
||||||
JsonNode thumbnails = entry.path("thumbnails");
|
|
||||||
if (thumbnails.isArray() && !thumbnails.isEmpty()) {
|
|
||||||
return thumbnails.get(thumbnails.size() - 1).path("url").asText(null);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static LocalDateTime publishedAt(JsonNode entry) {
|
|
||||||
long timestamp = entry.path("timestamp").asLong(entry.path("release_timestamp").asLong(0));
|
|
||||||
if (timestamp > 0) return LocalDateTime.ofInstant(Instant.ofEpochSecond(timestamp), ZoneId.systemDefault());
|
|
||||||
String uploadDate = entry.path("upload_date").asText(null);
|
|
||||||
if (uploadDate != null && uploadDate.matches("\\d{8}")) {
|
|
||||||
return LocalDate.parse(uploadDate, DateTimeFormatter.BASIC_ISO_DATE).atStartOfDay();
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Integer durationSec(JsonNode entry) {
|
|
||||||
double seconds = entry.path("duration").asDouble(0);
|
|
||||||
return seconds > 0 ? Math.max(1, (int) Math.round(seconds)) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String canonicalUrl(SocialPlatform platform, String id) {
|
|
||||||
return switch (platform) {
|
|
||||||
case YOUTUBE -> "https://www.youtube.com/watch?v=" + id;
|
|
||||||
case TIKTOK -> "https://www.tiktok.com/video/" + id;
|
|
||||||
case INSTAGRAM -> "https://www.instagram.com/p/" + id + "/";
|
|
||||||
case FACEBOOK -> "https://www.facebook.com/reel/" + id;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String errorTail(String output) {
|
|
||||||
String clean = output == null ? "" : output.strip();
|
|
||||||
if (clean.length() > 1200) clean = clean.substring(clean.length() - 1200);
|
|
||||||
return clean.isBlank() ? "yt-dlp가 채널 목록을 반환하지 않았습니다." : clean;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,77 +0,0 @@
|
|||||||
package com.hlab.yanalyst.domain.upload;
|
|
||||||
|
|
||||||
import jakarta.persistence.Column;
|
|
||||||
import jakarta.persistence.Entity;
|
|
||||||
import jakarta.persistence.GeneratedValue;
|
|
||||||
import jakarta.persistence.GenerationType;
|
|
||||||
import jakarta.persistence.Id;
|
|
||||||
import jakarta.persistence.Table;
|
|
||||||
import lombok.AccessLevel;
|
|
||||||
import lombok.Getter;
|
|
||||||
import lombok.NoArgsConstructor;
|
|
||||||
import org.springframework.data.annotation.CreatedDate;
|
|
||||||
import org.springframework.data.annotation.LastModifiedDate;
|
|
||||||
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
|
|
||||||
import jakarta.persistence.EntityListeners;
|
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
|
||||||
|
|
||||||
@Entity
|
|
||||||
@Table(name = "youtube_upload_connections")
|
|
||||||
@Getter
|
|
||||||
@NoArgsConstructor(access = AccessLevel.PROTECTED)
|
|
||||||
@EntityListeners(AuditingEntityListener.class)
|
|
||||||
public class YoutubeConnection {
|
|
||||||
@Id
|
|
||||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
|
||||||
private Long id;
|
|
||||||
|
|
||||||
@Column(nullable = false, unique = true, length = 64)
|
|
||||||
private String channelId;
|
|
||||||
|
|
||||||
@Column(nullable = false)
|
|
||||||
private String channelTitle;
|
|
||||||
|
|
||||||
private String channelHandle;
|
|
||||||
|
|
||||||
@Column(length = 2083)
|
|
||||||
private String thumbnailUrl;
|
|
||||||
|
|
||||||
@Column(nullable = false, columnDefinition = "TEXT")
|
|
||||||
private String encryptedRefreshToken;
|
|
||||||
|
|
||||||
@Column(nullable = false, length = 20)
|
|
||||||
private String tokenStatus = "ACTIVE";
|
|
||||||
|
|
||||||
private LocalDateTime lastSyncedAt;
|
|
||||||
|
|
||||||
@CreatedDate
|
|
||||||
@Column(updatable = false)
|
|
||||||
private LocalDateTime createdAt;
|
|
||||||
|
|
||||||
@LastModifiedDate
|
|
||||||
private LocalDateTime updatedAt;
|
|
||||||
|
|
||||||
public YoutubeConnection(String channelId, String channelTitle, String channelHandle,
|
|
||||||
String thumbnailUrl, String encryptedRefreshToken) {
|
|
||||||
this.channelId = channelId;
|
|
||||||
update(channelTitle, channelHandle, thumbnailUrl, encryptedRefreshToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
public void update(String channelTitle, String channelHandle, String thumbnailUrl,
|
|
||||||
String encryptedRefreshToken) {
|
|
||||||
this.channelTitle = channelTitle;
|
|
||||||
this.channelHandle = channelHandle;
|
|
||||||
this.thumbnailUrl = thumbnailUrl;
|
|
||||||
if (encryptedRefreshToken != null && !encryptedRefreshToken.isBlank()) {
|
|
||||||
this.encryptedRefreshToken = encryptedRefreshToken;
|
|
||||||
}
|
|
||||||
this.tokenStatus = "ACTIVE";
|
|
||||||
this.lastSyncedAt = LocalDateTime.now();
|
|
||||||
}
|
|
||||||
|
|
||||||
public void markSynced() {
|
|
||||||
this.lastSyncedAt = LocalDateTime.now();
|
|
||||||
this.tokenStatus = "ACTIVE";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,11 +0,0 @@
|
|||||||
package com.hlab.yanalyst.domain.upload;
|
|
||||||
|
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Optional;
|
|
||||||
|
|
||||||
public interface YoutubeConnectionRepository extends JpaRepository<YoutubeConnection, Long> {
|
|
||||||
Optional<YoutubeConnection> findByChannelId(String channelId);
|
|
||||||
List<YoutubeConnection> findAllByOrderByIdAsc();
|
|
||||||
}
|
|
||||||
@ -1,72 +0,0 @@
|
|||||||
package com.hlab.yanalyst.domain.upload;
|
|
||||||
|
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
|
||||||
import org.springframework.stereotype.Component;
|
|
||||||
|
|
||||||
import javax.crypto.Cipher;
|
|
||||||
import javax.crypto.spec.GCMParameterSpec;
|
|
||||||
import javax.crypto.spec.SecretKeySpec;
|
|
||||||
import java.nio.charset.StandardCharsets;
|
|
||||||
import java.security.GeneralSecurityException;
|
|
||||||
import java.security.SecureRandom;
|
|
||||||
import java.util.Base64;
|
|
||||||
|
|
||||||
@Component
|
|
||||||
public class YoutubeCredentialCipher {
|
|
||||||
private static final int IV_LENGTH = 12;
|
|
||||||
private static final int TAG_LENGTH = 128;
|
|
||||||
private volatile String encodedKey;
|
|
||||||
private final SecureRandom secureRandom = new SecureRandom();
|
|
||||||
|
|
||||||
public YoutubeCredentialCipher(@Value("${youtube.oauth.encryption-key:}") String encodedKey) {
|
|
||||||
this.encodedKey = encodedKey;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void configure(String encodedKey) {
|
|
||||||
this.encodedKey = encodedKey;
|
|
||||||
}
|
|
||||||
|
|
||||||
public String encrypt(String plaintext) {
|
|
||||||
try {
|
|
||||||
byte[] key = key();
|
|
||||||
byte[] iv = new byte[IV_LENGTH];
|
|
||||||
secureRandom.nextBytes(iv);
|
|
||||||
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
|
|
||||||
cipher.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(key, "AES"), new GCMParameterSpec(TAG_LENGTH, iv));
|
|
||||||
byte[] encrypted = cipher.doFinal(plaintext.getBytes(StandardCharsets.UTF_8));
|
|
||||||
byte[] payload = new byte[iv.length + encrypted.length];
|
|
||||||
System.arraycopy(iv, 0, payload, 0, iv.length);
|
|
||||||
System.arraycopy(encrypted, 0, payload, iv.length, encrypted.length);
|
|
||||||
return Base64.getEncoder().encodeToString(payload);
|
|
||||||
} catch (GeneralSecurityException | IllegalArgumentException e) {
|
|
||||||
throw new IllegalStateException("YouTube OAuth 토큰을 암호화하지 못했습니다.", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public String decrypt(String encodedPayload) {
|
|
||||||
try {
|
|
||||||
byte[] payload = Base64.getDecoder().decode(encodedPayload);
|
|
||||||
if (payload.length <= IV_LENGTH) throw new IllegalArgumentException("잘못된 암호문입니다.");
|
|
||||||
byte[] iv = new byte[IV_LENGTH];
|
|
||||||
byte[] encrypted = new byte[payload.length - IV_LENGTH];
|
|
||||||
System.arraycopy(payload, 0, iv, 0, IV_LENGTH);
|
|
||||||
System.arraycopy(payload, IV_LENGTH, encrypted, 0, encrypted.length);
|
|
||||||
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
|
|
||||||
cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(key(), "AES"), new GCMParameterSpec(TAG_LENGTH, iv));
|
|
||||||
return new String(cipher.doFinal(encrypted), StandardCharsets.UTF_8);
|
|
||||||
} catch (GeneralSecurityException | IllegalArgumentException e) {
|
|
||||||
throw new IllegalStateException("YouTube OAuth 토큰을 복호화하지 못했습니다.", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private byte[] key() {
|
|
||||||
if (encodedKey == null || encodedKey.isBlank()) {
|
|
||||||
throw new IllegalStateException("YOUTUBE_OAUTH_ENCRYPTION_KEY가 설정되지 않았습니다.");
|
|
||||||
}
|
|
||||||
byte[] decoded = Base64.getDecoder().decode(encodedKey);
|
|
||||||
if (decoded.length != 32) {
|
|
||||||
throw new IllegalStateException("YOUTUBE_OAUTH_ENCRYPTION_KEY는 Base64로 인코딩한 32바이트 키여야 합니다.");
|
|
||||||
}
|
|
||||||
return decoded;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,131 +0,0 @@
|
|||||||
package com.hlab.yanalyst.domain.upload;
|
|
||||||
|
|
||||||
import com.hlab.yanalyst.global.common.ApiResponse;
|
|
||||||
import jakarta.servlet.http.HttpSession;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import org.springframework.http.ResponseEntity;
|
|
||||||
import org.springframework.stereotype.Controller;
|
|
||||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
|
||||||
import org.springframework.web.bind.annotation.PathVariable;
|
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
|
||||||
import org.springframework.web.bind.annotation.RequestParam;
|
|
||||||
import org.springframework.web.bind.annotation.RequestBody;
|
|
||||||
import org.springframework.web.bind.annotation.PostMapping;
|
|
||||||
import org.springframework.web.bind.annotation.ResponseBody;
|
|
||||||
import org.springframework.web.bind.annotation.RequestPart;
|
|
||||||
import org.springframework.web.multipart.MultipartFile;
|
|
||||||
|
|
||||||
import java.net.URI;
|
|
||||||
import java.time.Instant;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
@Controller
|
|
||||||
@RequestMapping("/api/youtube")
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
public class YoutubeOAuthController {
|
|
||||||
private static final String STATE_SESSION_KEY = "youtube.oauth.state";
|
|
||||||
private static final String RETURN_TO_SESSION_KEY = "youtube.oauth.return-to";
|
|
||||||
private final YoutubeOAuthService service;
|
|
||||||
private final YoutubeOAuthLocalSettings localSettings;
|
|
||||||
private final YoutubeUploadService uploadService;
|
|
||||||
|
|
||||||
@GetMapping("/oauth/start")
|
|
||||||
public ResponseEntity<Void> start(@RequestParam(required = false) String returnTo, HttpSession session) {
|
|
||||||
String state = UUID.randomUUID().toString();
|
|
||||||
session.setAttribute(STATE_SESSION_KEY, state);
|
|
||||||
session.setAttribute(RETURN_TO_SESSION_KEY, safeReturnTo(returnTo));
|
|
||||||
return ResponseEntity.status(302).location(service.authorizationUri(state)).build();
|
|
||||||
}
|
|
||||||
|
|
||||||
@GetMapping("/oauth/callback")
|
|
||||||
public ResponseEntity<Void> callback(@RequestParam(required = false) String code,
|
|
||||||
@RequestParam(required = false) String state,
|
|
||||||
@RequestParam(required = false) String error,
|
|
||||||
HttpSession session) {
|
|
||||||
String expected = (String) session.getAttribute(STATE_SESSION_KEY);
|
|
||||||
String returnTo = (String) session.getAttribute(RETURN_TO_SESSION_KEY);
|
|
||||||
session.removeAttribute(STATE_SESSION_KEY);
|
|
||||||
session.removeAttribute(RETURN_TO_SESSION_KEY);
|
|
||||||
String target = returnTo == null ? "/upload-studio" : returnTo;
|
|
||||||
if (error != null) return redirect(target + "?youtube=denied");
|
|
||||||
if (expected == null || !expected.equals(state)) return redirect(target + "?youtube=invalid-state");
|
|
||||||
if (code == null || code.isBlank()) return redirect(target + "?youtube=missing-code");
|
|
||||||
try {
|
|
||||||
service.connect(code);
|
|
||||||
return redirect(target + "?youtube=connected");
|
|
||||||
} catch (RuntimeException e) {
|
|
||||||
return redirect(target + "?youtube=failed");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@GetMapping("/connections")
|
|
||||||
@ResponseBody
|
|
||||||
public ApiResponse<List<YoutubeOAuthService.ConnectionDto>> connections() {
|
|
||||||
return ApiResponse.ok(service.connections());
|
|
||||||
}
|
|
||||||
|
|
||||||
@GetMapping("/settings/status")
|
|
||||||
@ResponseBody
|
|
||||||
public ApiResponse<SettingsStatus> settingsStatus() {
|
|
||||||
YoutubeOAuthLocalSettings.Status status = localSettings.status();
|
|
||||||
return ApiResponse.ok(new SettingsStatus(status.configured(), status.redirectUri()));
|
|
||||||
}
|
|
||||||
|
|
||||||
@PostMapping("/settings")
|
|
||||||
@ResponseBody
|
|
||||||
public ApiResponse<YoutubeOAuthLocalSettings.Status> saveSettings(@RequestBody SettingsRequest body) {
|
|
||||||
if (service.hasConnections()) {
|
|
||||||
throw new IllegalArgumentException("연결된 채널이 있어 OAuth 설정을 변경할 수 없습니다.");
|
|
||||||
}
|
|
||||||
localSettings.save(body.clientId(), body.clientSecret(), body.redirectUri());
|
|
||||||
return ApiResponse.ok(localSettings.status());
|
|
||||||
}
|
|
||||||
|
|
||||||
@DeleteMapping("/connections/{id}")
|
|
||||||
@ResponseBody
|
|
||||||
public ApiResponse<Void> disconnect(@PathVariable Long id) {
|
|
||||||
service.disconnect(id);
|
|
||||||
return ApiResponse.ok(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
@PostMapping("/connections/{id}/sync")
|
|
||||||
@ResponseBody
|
|
||||||
public ApiResponse<YoutubeOAuthService.SyncDto> sync(@PathVariable Long id) {
|
|
||||||
return ApiResponse.ok(service.sync(id));
|
|
||||||
}
|
|
||||||
|
|
||||||
@GetMapping("/connections/{id}/uploads")
|
|
||||||
@ResponseBody
|
|
||||||
public ApiResponse<List<YoutubeUploadService.JobDto>> uploads(@PathVariable Long id) {
|
|
||||||
return ApiResponse.ok(uploadService.jobs(id));
|
|
||||||
}
|
|
||||||
|
|
||||||
@PostMapping(value = "/connections/{id}/uploads", consumes = "multipart/form-data")
|
|
||||||
@ResponseBody
|
|
||||||
public ApiResponse<YoutubeUploadService.JobDto> upload(@PathVariable Long id,
|
|
||||||
@RequestPart("file") MultipartFile file,
|
|
||||||
@RequestParam String title,
|
|
||||||
@RequestParam(defaultValue = "") String description,
|
|
||||||
@RequestParam(defaultValue = "public") String privacyStatus,
|
|
||||||
@RequestParam(required = false) Instant publishAt) {
|
|
||||||
return ApiResponse.created(uploadService.upload(id, file, title, description, privacyStatus, publishAt));
|
|
||||||
}
|
|
||||||
|
|
||||||
private ResponseEntity<Void> redirect(String location) {
|
|
||||||
return ResponseEntity.status(302).location(URI.create(location)).build();
|
|
||||||
}
|
|
||||||
|
|
||||||
private String safeReturnTo(String returnTo) {
|
|
||||||
if (returnTo == null || returnTo.isBlank()) return "/upload-studio";
|
|
||||||
if (!returnTo.startsWith("/") || returnTo.startsWith("//") || returnTo.contains("?") || returnTo.contains("#")
|
|
||||||
|| returnTo.contains("\r") || returnTo.contains("\n")) {
|
|
||||||
throw new IllegalArgumentException("허용되지 않은 OAuth 이동 경로입니다.");
|
|
||||||
}
|
|
||||||
return returnTo;
|
|
||||||
}
|
|
||||||
|
|
||||||
public record SettingsRequest(String clientId, String clientSecret, String redirectUri) { }
|
|
||||||
public record SettingsStatus(boolean configured, String redirectUri) { }
|
|
||||||
}
|
|
||||||
@ -1,93 +0,0 @@
|
|||||||
package com.hlab.yanalyst.domain.upload;
|
|
||||||
|
|
||||||
import jakarta.annotation.PostConstruct;
|
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
|
||||||
import org.springframework.stereotype.Component;
|
|
||||||
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.io.InputStream;
|
|
||||||
import java.io.OutputStream;
|
|
||||||
import java.nio.file.Files;
|
|
||||||
import java.nio.file.Path;
|
|
||||||
import java.nio.file.StandardOpenOption;
|
|
||||||
import java.security.SecureRandom;
|
|
||||||
import java.util.Base64;
|
|
||||||
import java.util.Properties;
|
|
||||||
|
|
||||||
@Component
|
|
||||||
public class YoutubeOAuthLocalSettings {
|
|
||||||
private final Path path;
|
|
||||||
private final YoutubeOAuthService oauthService;
|
|
||||||
private final YoutubeCredentialCipher cipher;
|
|
||||||
|
|
||||||
public YoutubeOAuthLocalSettings(
|
|
||||||
@Value("${youtube.oauth.settings-file:config/youtube-oauth.properties}") String settingsFile,
|
|
||||||
YoutubeOAuthService oauthService,
|
|
||||||
YoutubeCredentialCipher cipher) {
|
|
||||||
this.path = Path.of(settingsFile).toAbsolutePath().normalize();
|
|
||||||
this.oauthService = oauthService;
|
|
||||||
this.cipher = cipher;
|
|
||||||
}
|
|
||||||
|
|
||||||
@PostConstruct
|
|
||||||
void load() {
|
|
||||||
if (!Files.isRegularFile(path)) return;
|
|
||||||
Properties properties = new Properties();
|
|
||||||
try (InputStream input = Files.newInputStream(path)) {
|
|
||||||
properties.load(input);
|
|
||||||
apply(properties);
|
|
||||||
} catch (IOException e) {
|
|
||||||
throw new IllegalStateException("YouTube OAuth 로컬 설정을 읽지 못했습니다.", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public synchronized void save(String clientId, String clientSecret, String redirectUri) {
|
|
||||||
if (clientId == null || clientId.isBlank() || clientSecret == null || clientSecret.isBlank()) {
|
|
||||||
throw new IllegalArgumentException("Client ID와 Client Secret을 모두 입력해 주세요.");
|
|
||||||
}
|
|
||||||
if (redirectUri == null || !redirectUri.matches("https?://[^\\s]+/api/youtube/oauth/callback")) {
|
|
||||||
throw new IllegalArgumentException("올바른 OAuth 콜백 주소를 입력해 주세요.");
|
|
||||||
}
|
|
||||||
Properties properties = new Properties();
|
|
||||||
properties.setProperty("clientId", clientId.trim());
|
|
||||||
properties.setProperty("clientSecret", clientSecret.trim());
|
|
||||||
properties.setProperty("redirectUri", redirectUri.trim());
|
|
||||||
properties.setProperty("encryptionKey", existingOrNewKey());
|
|
||||||
try {
|
|
||||||
if (path.getParent() != null) Files.createDirectories(path.getParent());
|
|
||||||
try (OutputStream output = Files.newOutputStream(path, StandardOpenOption.CREATE,
|
|
||||||
StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.WRITE)) {
|
|
||||||
properties.store(output, "Local YouTube OAuth settings - never commit this file");
|
|
||||||
}
|
|
||||||
apply(properties);
|
|
||||||
} catch (IOException e) {
|
|
||||||
throw new IllegalStateException("YouTube OAuth 설정을 저장하지 못했습니다.", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public Status status() {
|
|
||||||
return new Status(oauthService.isConfigured(), oauthService.redirectUri());
|
|
||||||
}
|
|
||||||
|
|
||||||
private void apply(Properties properties) {
|
|
||||||
oauthService.configure(properties.getProperty("clientId", ""),
|
|
||||||
properties.getProperty("clientSecret", ""), properties.getProperty("redirectUri", ""));
|
|
||||||
cipher.configure(properties.getProperty("encryptionKey", ""));
|
|
||||||
}
|
|
||||||
|
|
||||||
private String existingOrNewKey() {
|
|
||||||
if (Files.isRegularFile(path)) {
|
|
||||||
Properties existing = new Properties();
|
|
||||||
try (InputStream input = Files.newInputStream(path)) {
|
|
||||||
existing.load(input);
|
|
||||||
String key = existing.getProperty("encryptionKey", "");
|
|
||||||
if (!key.isBlank()) return key;
|
|
||||||
} catch (IOException ignored) { }
|
|
||||||
}
|
|
||||||
byte[] key = new byte[32];
|
|
||||||
new SecureRandom().nextBytes(key);
|
|
||||||
return Base64.getEncoder().encodeToString(key);
|
|
||||||
}
|
|
||||||
|
|
||||||
public record Status(boolean configured, String redirectUri) { }
|
|
||||||
}
|
|
||||||
@ -1,294 +0,0 @@
|
|||||||
package com.hlab.yanalyst.domain.upload;
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.JsonNode;
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
||||||
import org.springframework.beans.factory.annotation.Qualifier;
|
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
|
||||||
import org.springframework.http.HttpEntity;
|
|
||||||
import org.springframework.http.HttpHeaders;
|
|
||||||
import org.springframework.http.MediaType;
|
|
||||||
import org.springframework.stereotype.Service;
|
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
|
||||||
import org.springframework.util.LinkedMultiValueMap;
|
|
||||||
import org.springframework.util.MultiValueMap;
|
|
||||||
import org.springframework.web.client.RestTemplate;
|
|
||||||
import org.springframework.web.client.HttpStatusCodeException;
|
|
||||||
import org.springframework.web.util.UriComponentsBuilder;
|
|
||||||
|
|
||||||
import java.net.URI;
|
|
||||||
import java.time.Instant;
|
|
||||||
import java.time.LocalDate;
|
|
||||||
import java.time.ZoneId;
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.LinkedHashMap;
|
|
||||||
import java.util.Map;
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
@Service
|
|
||||||
public class YoutubeOAuthService {
|
|
||||||
private static final String AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth";
|
|
||||||
private static final String TOKEN_URL = "https://oauth2.googleapis.com/token";
|
|
||||||
private static final String CHANNELS_URL = "https://www.googleapis.com/youtube/v3/channels";
|
|
||||||
private static final String SCOPE = "https://www.googleapis.com/auth/youtube.upload https://www.googleapis.com/auth/youtube.readonly https://www.googleapis.com/auth/yt-analytics.readonly";
|
|
||||||
|
|
||||||
@Qualifier("restTemplate")
|
|
||||||
private final RestTemplate restTemplate;
|
|
||||||
private final ObjectMapper objectMapper;
|
|
||||||
private final YoutubeConnectionRepository repository;
|
|
||||||
private final YoutubeCredentialCipher cipher;
|
|
||||||
|
|
||||||
@Value("${youtube.oauth.client-id:}") private volatile String clientId;
|
|
||||||
@Value("${youtube.oauth.client-secret:}") private volatile String clientSecret;
|
|
||||||
@Value("${youtube.oauth.redirect-uri:http://localhost:8088/api/youtube/oauth/callback}") private volatile String redirectUri;
|
|
||||||
|
|
||||||
public YoutubeOAuthService(@Qualifier("restTemplate") RestTemplate restTemplate,
|
|
||||||
ObjectMapper objectMapper,
|
|
||||||
YoutubeConnectionRepository repository,
|
|
||||||
YoutubeCredentialCipher cipher) {
|
|
||||||
this.restTemplate = restTemplate;
|
|
||||||
this.objectMapper = objectMapper;
|
|
||||||
this.repository = repository;
|
|
||||||
this.cipher = cipher;
|
|
||||||
}
|
|
||||||
|
|
||||||
public URI authorizationUri(String state) {
|
|
||||||
requireConfiguration();
|
|
||||||
return UriComponentsBuilder.fromUriString(AUTH_URL)
|
|
||||||
.queryParam("client_id", clientId)
|
|
||||||
.queryParam("redirect_uri", redirectUri)
|
|
||||||
.queryParam("response_type", "code")
|
|
||||||
.queryParam("scope", SCOPE)
|
|
||||||
.queryParam("access_type", "offline")
|
|
||||||
.queryParam("include_granted_scopes", "true")
|
|
||||||
.queryParam("prompt", "consent")
|
|
||||||
.queryParam("state", state)
|
|
||||||
.build().encode().toUri();
|
|
||||||
}
|
|
||||||
|
|
||||||
public void configure(String clientId, String clientSecret, String redirectUri) {
|
|
||||||
this.clientId = clientId;
|
|
||||||
this.clientSecret = clientSecret;
|
|
||||||
if (redirectUri != null && !redirectUri.isBlank()) this.redirectUri = redirectUri;
|
|
||||||
}
|
|
||||||
|
|
||||||
public boolean isConfigured() {
|
|
||||||
return clientId != null && !clientId.isBlank() && clientSecret != null && !clientSecret.isBlank();
|
|
||||||
}
|
|
||||||
|
|
||||||
public String redirectUri() {
|
|
||||||
return redirectUri;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional
|
|
||||||
public YoutubeConnection connect(String code) {
|
|
||||||
requireConfiguration();
|
|
||||||
JsonNode token = exchangeCode(code);
|
|
||||||
String accessToken = requiredText(token, "access_token", "Google access token이 없습니다.");
|
|
||||||
String refreshToken = requiredText(token, "refresh_token", "Google refresh token이 없습니다. 권한 동의를 다시 진행해 주세요.");
|
|
||||||
JsonNode channel = loadChannel(accessToken);
|
|
||||||
String channelId = requiredText(channel, "id", "연결된 YouTube 채널 ID가 없습니다.");
|
|
||||||
String title = channel.path("snippet").path("title").asText("YouTube 채널");
|
|
||||||
String handle = channel.path("snippet").path("customUrl").asText("");
|
|
||||||
String thumbnail = channel.path("snippet").path("thumbnails").path("medium").path("url").asText("");
|
|
||||||
String encryptedToken = cipher.encrypt(refreshToken);
|
|
||||||
|
|
||||||
YoutubeConnection connection = repository.findByChannelId(channelId)
|
|
||||||
.orElseGet(() -> new YoutubeConnection(channelId, title, handle, thumbnail, encryptedToken));
|
|
||||||
connection.update(title, handle, thumbnail, encryptedToken);
|
|
||||||
return repository.save(connection);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
|
||||||
public List<ConnectionDto> connections() {
|
|
||||||
return repository.findAllByOrderByIdAsc().stream().map(ConnectionDto::from).toList();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
|
||||||
public boolean hasConnections() {
|
|
||||||
return repository.count() > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional
|
|
||||||
public SyncDto sync(Long id) {
|
|
||||||
YoutubeConnection connection = repository.findById(id)
|
|
||||||
.orElseThrow(() -> new IllegalArgumentException("연결된 YouTube 채널을 찾지 못했습니다."));
|
|
||||||
String accessToken = refreshAccessToken(cipher.decrypt(connection.getEncryptedRefreshToken()));
|
|
||||||
JsonNode channel = loadChannel(accessToken);
|
|
||||||
String uploadsPlaylistId = channel.path("contentDetails").path("relatedPlaylists").path("uploads").asText("");
|
|
||||||
if (uploadsPlaylistId.isBlank()) throw new IllegalStateException("채널의 업로드 재생목록을 찾지 못했습니다.");
|
|
||||||
|
|
||||||
JsonNode items = loadRecentUploads(accessToken, uploadsPlaylistId);
|
|
||||||
ZoneId zone = ZoneId.of("Asia/Seoul");
|
|
||||||
LocalDate today = LocalDate.now(zone);
|
|
||||||
Map<LocalDate, Integer> counts = new LinkedHashMap<>();
|
|
||||||
for (int i = 6; i >= 0; i--) counts.put(today.minusDays(i), 0);
|
|
||||||
Map<LocalDate, Long> dailyViews = loadDailyViews(accessToken, today.minusDays(6), today);
|
|
||||||
List<RecentUploadDto> recent = new ArrayList<>();
|
|
||||||
List<String> recentVideoIds = new ArrayList<>();
|
|
||||||
for (JsonNode item : items) {
|
|
||||||
String publishedText = item.path("contentDetails").path("videoPublishedAt").asText(
|
|
||||||
item.path("snippet").path("publishedAt").asText(""));
|
|
||||||
if (publishedText.isBlank()) continue;
|
|
||||||
Instant publishedAt = Instant.parse(publishedText);
|
|
||||||
LocalDate date = publishedAt.atZone(zone).toLocalDate();
|
|
||||||
if (counts.containsKey(date)) {
|
|
||||||
counts.put(date, counts.get(date) + 1);
|
|
||||||
String videoId = item.path("contentDetails").path("videoId").asText("");
|
|
||||||
recentVideoIds.add(videoId);
|
|
||||||
recent.add(new RecentUploadDto(videoId, item.path("snippet").path("title").asText(""), publishedAt, 0));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Map<String, Long> videoViews = loadVideoViews(accessToken, recentVideoIds);
|
|
||||||
recent = recent.stream().map(video -> new RecentUploadDto(video.videoId(), video.title(),
|
|
||||||
video.publishedAt(), videoViews.getOrDefault(video.videoId(), 0L))).toList();
|
|
||||||
connection.markSynced();
|
|
||||||
int totalUploads = counts.values().stream().mapToInt(Integer::intValue).sum();
|
|
||||||
long totalViews = dailyViews.values().stream().mapToLong(Long::longValue).sum();
|
|
||||||
long recentVideoViews = recent.stream().mapToLong(RecentUploadDto::viewCount).sum();
|
|
||||||
return new SyncDto(totalUploads, totalViews, recentVideoViews,
|
|
||||||
totalUploads == 0 ? 0 : Math.round((double) totalViews / totalUploads),
|
|
||||||
counts.entrySet().stream().map(e -> new DailyCountDto(e.getKey(), e.getValue(),
|
|
||||||
dailyViews.getOrDefault(e.getKey(), 0L))).toList(), recent);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional
|
|
||||||
public void disconnect(Long id) {
|
|
||||||
repository.deleteById(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
public String accessToken(Long connectionId) {
|
|
||||||
YoutubeConnection connection = repository.findById(connectionId)
|
|
||||||
.orElseThrow(() -> new IllegalArgumentException("연결된 YouTube 채널을 찾지 못했습니다."));
|
|
||||||
return refreshAccessToken(cipher.decrypt(connection.getEncryptedRefreshToken()));
|
|
||||||
}
|
|
||||||
|
|
||||||
private JsonNode exchangeCode(String code) {
|
|
||||||
MultiValueMap<String, String> form = new LinkedMultiValueMap<>();
|
|
||||||
form.add("code", code);
|
|
||||||
form.add("client_id", clientId);
|
|
||||||
form.add("client_secret", clientSecret);
|
|
||||||
form.add("redirect_uri", redirectUri);
|
|
||||||
form.add("grant_type", "authorization_code");
|
|
||||||
HttpHeaders headers = new HttpHeaders();
|
|
||||||
headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
|
|
||||||
String body = restTemplate.postForObject(TOKEN_URL, new HttpEntity<>(form, headers), String.class);
|
|
||||||
return readJson(body);
|
|
||||||
}
|
|
||||||
|
|
||||||
private String refreshAccessToken(String refreshToken) {
|
|
||||||
MultiValueMap<String, String> form = new LinkedMultiValueMap<>();
|
|
||||||
form.add("client_id", clientId);
|
|
||||||
form.add("client_secret", clientSecret);
|
|
||||||
form.add("refresh_token", refreshToken);
|
|
||||||
form.add("grant_type", "refresh_token");
|
|
||||||
HttpHeaders headers = new HttpHeaders();
|
|
||||||
headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
|
|
||||||
String response;
|
|
||||||
try {
|
|
||||||
response = restTemplate.postForObject(TOKEN_URL, new HttpEntity<>(form, headers), String.class);
|
|
||||||
} catch (HttpStatusCodeException e) {
|
|
||||||
if (e.getStatusCode().value() == 400 || e.getStatusCode().value() == 401) {
|
|
||||||
throw new IllegalStateException("YouTube 채널 연결이 만료되었습니다. 채널을 다시 연결해 주세요.", e);
|
|
||||||
}
|
|
||||||
throw new IllegalStateException("YouTube 인증 정보를 갱신하지 못했습니다.", e);
|
|
||||||
}
|
|
||||||
JsonNode token = readJson(response);
|
|
||||||
return requiredText(token, "access_token", "YouTube access token을 갱신하지 못했습니다.");
|
|
||||||
}
|
|
||||||
|
|
||||||
private JsonNode loadRecentUploads(String accessToken, String playlistId) {
|
|
||||||
URI uri = UriComponentsBuilder.fromUriString("https://www.googleapis.com/youtube/v3/playlistItems")
|
|
||||||
.queryParam("part", "snippet,contentDetails")
|
|
||||||
.queryParam("playlistId", playlistId)
|
|
||||||
.queryParam("maxResults", 50).build().encode().toUri();
|
|
||||||
HttpHeaders headers = new HttpHeaders();
|
|
||||||
headers.setBearerAuth(accessToken);
|
|
||||||
String body = restTemplate.exchange(uri, org.springframework.http.HttpMethod.GET,
|
|
||||||
new HttpEntity<>(headers), String.class).getBody();
|
|
||||||
return readJson(body).path("items");
|
|
||||||
}
|
|
||||||
|
|
||||||
private Map<LocalDate, Long> loadDailyViews(String accessToken, LocalDate startDate, LocalDate endDate) {
|
|
||||||
URI uri = UriComponentsBuilder.fromUriString("https://youtubeanalytics.googleapis.com/v2/reports")
|
|
||||||
.queryParam("ids", "channel==MINE").queryParam("startDate", startDate)
|
|
||||||
.queryParam("endDate", endDate).queryParam("metrics", "views")
|
|
||||||
.queryParam("dimensions", "day").queryParam("sort", "day").build().encode().toUri();
|
|
||||||
HttpHeaders headers = new HttpHeaders();
|
|
||||||
headers.setBearerAuth(accessToken);
|
|
||||||
String body;
|
|
||||||
try {
|
|
||||||
body = restTemplate.exchange(uri, org.springframework.http.HttpMethod.GET,
|
|
||||||
new HttpEntity<>(headers), String.class).getBody();
|
|
||||||
} catch (HttpStatusCodeException e) {
|
|
||||||
if (e.getStatusCode().value() == 401 || e.getStatusCode().value() == 403) {
|
|
||||||
throw new IllegalStateException("일별 조회수 분석 권한이 없습니다. 채널 연결을 해제한 뒤 다시 연결해 주세요.", e);
|
|
||||||
}
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
Map<LocalDate, Long> views = new LinkedHashMap<>();
|
|
||||||
for (JsonNode row : readJson(body).path("rows")) {
|
|
||||||
if (row.isArray() && row.size() >= 2) views.put(LocalDate.parse(row.get(0).asText()), row.get(1).asLong());
|
|
||||||
}
|
|
||||||
return views;
|
|
||||||
}
|
|
||||||
|
|
||||||
private Map<String, Long> loadVideoViews(String accessToken, List<String> videoIds) {
|
|
||||||
if (videoIds.isEmpty()) return Map.of();
|
|
||||||
URI uri = UriComponentsBuilder.fromUriString("https://www.googleapis.com/youtube/v3/videos")
|
|
||||||
.queryParam("part", "statistics").queryParam("id", String.join(",", videoIds)).build().encode().toUri();
|
|
||||||
HttpHeaders headers = new HttpHeaders();
|
|
||||||
headers.setBearerAuth(accessToken);
|
|
||||||
String body = restTemplate.exchange(uri, org.springframework.http.HttpMethod.GET,
|
|
||||||
new HttpEntity<>(headers), String.class).getBody();
|
|
||||||
Map<String, Long> views = new LinkedHashMap<>();
|
|
||||||
for (JsonNode video : readJson(body).path("items")) {
|
|
||||||
views.put(video.path("id").asText(), video.path("statistics").path("viewCount").asLong());
|
|
||||||
}
|
|
||||||
return views;
|
|
||||||
}
|
|
||||||
|
|
||||||
private JsonNode loadChannel(String accessToken) {
|
|
||||||
URI uri = UriComponentsBuilder.fromUriString(CHANNELS_URL)
|
|
||||||
.queryParam("part", "snippet,contentDetails,statistics")
|
|
||||||
.queryParam("mine", "true").build().encode().toUri();
|
|
||||||
HttpHeaders headers = new HttpHeaders();
|
|
||||||
headers.setBearerAuth(accessToken);
|
|
||||||
String body = restTemplate.exchange(uri, org.springframework.http.HttpMethod.GET,
|
|
||||||
new HttpEntity<>(headers), String.class).getBody();
|
|
||||||
JsonNode items = readJson(body).path("items");
|
|
||||||
if (!items.isArray() || items.isEmpty()) {
|
|
||||||
throw new IllegalStateException("이 Google 계정에서 YouTube 채널을 찾지 못했습니다.");
|
|
||||||
}
|
|
||||||
return items.get(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
private JsonNode readJson(String body) {
|
|
||||||
try { return objectMapper.readTree(body); }
|
|
||||||
catch (Exception e) { throw new IllegalStateException("Google 응답을 해석하지 못했습니다.", e); }
|
|
||||||
}
|
|
||||||
|
|
||||||
private String requiredText(JsonNode node, String field, String message) {
|
|
||||||
String value = node.path(field).asText("");
|
|
||||||
if (value.isBlank()) throw new IllegalStateException(message);
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void requireConfiguration() {
|
|
||||||
if (!isConfigured()) {
|
|
||||||
throw new IllegalStateException("YOUTUBE_OAUTH_CLIENT_ID와 YOUTUBE_OAUTH_CLIENT_SECRET을 설정해 주세요.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public record ConnectionDto(Long id, String channelId, String title, String handle,
|
|
||||||
String thumbnailUrl, String tokenStatus, java.time.LocalDateTime lastSyncedAt) {
|
|
||||||
static ConnectionDto from(YoutubeConnection c) {
|
|
||||||
return new ConnectionDto(c.getId(), c.getChannelId(), c.getChannelTitle(), c.getChannelHandle(),
|
|
||||||
c.getThumbnailUrl(), c.getTokenStatus(), c.getLastSyncedAt());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
public record SyncDto(int recentSevenDayCount, long sevenDayViews, long recentVideoViews,
|
|
||||||
long viewsPerUpload, List<DailyCountDto> dailyCounts,
|
|
||||||
List<RecentUploadDto> recentUploads) { }
|
|
||||||
public record DailyCountDto(LocalDate date, int count, long views) { }
|
|
||||||
public record RecentUploadDto(String videoId, String title, Instant publishedAt, long viewCount) { }
|
|
||||||
}
|
|
||||||
@ -1,68 +0,0 @@
|
|||||||
package com.hlab.yanalyst.domain.upload;
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.JsonNode;
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
||||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
|
||||||
import org.springframework.beans.factory.annotation.Qualifier;
|
|
||||||
import org.springframework.http.*;
|
|
||||||
import org.springframework.stereotype.Component;
|
|
||||||
import org.springframework.web.client.RestTemplate;
|
|
||||||
import org.springframework.web.util.UriComponentsBuilder;
|
|
||||||
|
|
||||||
import java.net.URI;
|
|
||||||
import java.net.http.HttpClient;
|
|
||||||
import java.net.http.HttpRequest;
|
|
||||||
import java.net.http.HttpResponse;
|
|
||||||
import java.nio.file.Path;
|
|
||||||
import java.time.Duration;
|
|
||||||
import java.time.Instant;
|
|
||||||
|
|
||||||
@Component
|
|
||||||
public class YoutubeResumableUploadClient {
|
|
||||||
private static final String UPLOAD_URL = "https://www.googleapis.com/upload/youtube/v3/videos";
|
|
||||||
private final RestTemplate restTemplate;
|
|
||||||
private final ObjectMapper objectMapper;
|
|
||||||
private final HttpClient httpClient = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(30)).build();
|
|
||||||
|
|
||||||
public YoutubeResumableUploadClient(@Qualifier("restTemplate") RestTemplate restTemplate, ObjectMapper objectMapper) {
|
|
||||||
this.restTemplate = restTemplate;
|
|
||||||
this.objectMapper = objectMapper;
|
|
||||||
}
|
|
||||||
|
|
||||||
public String upload(String accessToken, Path file, long size, String contentType, String title,
|
|
||||||
String description, String privacyStatus, Instant publishAt) throws Exception {
|
|
||||||
ObjectNode metadata = objectMapper.createObjectNode();
|
|
||||||
metadata.putObject("snippet").put("title", title).put("description", description).put("categoryId", "22");
|
|
||||||
ObjectNode status = metadata.putObject("status");
|
|
||||||
status.put("privacyStatus", publishAt == null ? privacyStatus : "private");
|
|
||||||
status.put("selfDeclaredMadeForKids", false);
|
|
||||||
if (publishAt != null) status.put("publishAt", publishAt.toString());
|
|
||||||
|
|
||||||
URI initiateUri = UriComponentsBuilder.fromUriString(UPLOAD_URL)
|
|
||||||
.queryParam("uploadType", "resumable").queryParam("part", "snippet,status").build().toUri();
|
|
||||||
HttpHeaders headers = new HttpHeaders();
|
|
||||||
headers.setBearerAuth(accessToken);
|
|
||||||
headers.setContentType(MediaType.APPLICATION_JSON);
|
|
||||||
headers.set("X-Upload-Content-Type", contentType);
|
|
||||||
headers.set("X-Upload-Content-Length", Long.toString(size));
|
|
||||||
ResponseEntity<String> initiated = restTemplate.exchange(initiateUri, HttpMethod.POST,
|
|
||||||
new HttpEntity<>(objectMapper.writeValueAsString(metadata), headers), String.class);
|
|
||||||
URI sessionUri = initiated.getHeaders().getLocation();
|
|
||||||
if (sessionUri == null) throw new IllegalStateException("YouTube 업로드 세션 주소를 받지 못했습니다.");
|
|
||||||
|
|
||||||
HttpRequest request = HttpRequest.newBuilder(sessionUri).timeout(Duration.ofHours(2))
|
|
||||||
.header("Authorization", "Bearer " + accessToken)
|
|
||||||
.header("Content-Type", contentType)
|
|
||||||
.header("Content-Length", Long.toString(size))
|
|
||||||
.PUT(HttpRequest.BodyPublishers.ofFile(file)).build();
|
|
||||||
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
|
|
||||||
if (response.statusCode() < 200 || response.statusCode() >= 300) {
|
|
||||||
throw new IllegalStateException("YouTube 업로드 실패 (HTTP " + response.statusCode() + "): "
|
|
||||||
+ response.body().substring(0, Math.min(500, response.body().length())));
|
|
||||||
}
|
|
||||||
JsonNode result = objectMapper.readTree(response.body());
|
|
||||||
String videoId = result.path("id").asText("");
|
|
||||||
if (videoId.isBlank()) throw new IllegalStateException("YouTube 영상 ID를 받지 못했습니다.");
|
|
||||||
return videoId;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,59 +0,0 @@
|
|||||||
package com.hlab.yanalyst.domain.upload;
|
|
||||||
|
|
||||||
import jakarta.persistence.*;
|
|
||||||
import lombok.AccessLevel;
|
|
||||||
import lombok.Getter;
|
|
||||||
import lombok.NoArgsConstructor;
|
|
||||||
import org.springframework.data.annotation.CreatedDate;
|
|
||||||
import org.springframework.data.annotation.LastModifiedDate;
|
|
||||||
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
|
|
||||||
|
|
||||||
import java.time.Instant;
|
|
||||||
import java.time.LocalDateTime;
|
|
||||||
|
|
||||||
@Entity
|
|
||||||
@Table(name = "youtube_upload_jobs")
|
|
||||||
@Getter
|
|
||||||
@NoArgsConstructor(access = AccessLevel.PROTECTED)
|
|
||||||
@EntityListeners(AuditingEntityListener.class)
|
|
||||||
public class YoutubeUploadJob {
|
|
||||||
public enum Status { UPLOADING, SCHEDULED, PUBLISHED, FAILED }
|
|
||||||
|
|
||||||
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
|
|
||||||
private Long id;
|
|
||||||
@Column(nullable = false) private Long connectionId;
|
|
||||||
@Column(nullable = false) private String fileName;
|
|
||||||
@Column(nullable = false) private String title;
|
|
||||||
@Column(columnDefinition = "TEXT") private String description;
|
|
||||||
@Column(nullable = false, length = 20) private String privacyStatus;
|
|
||||||
private Instant publishAt;
|
|
||||||
@Enumerated(EnumType.STRING) @Column(nullable = false, length = 20) private Status status;
|
|
||||||
private String youtubeVideoId;
|
|
||||||
@Column(columnDefinition = "TEXT") private String errorMessage;
|
|
||||||
private long fileSize;
|
|
||||||
@CreatedDate @Column(updatable = false) private LocalDateTime createdAt;
|
|
||||||
@LastModifiedDate private LocalDateTime updatedAt;
|
|
||||||
|
|
||||||
public YoutubeUploadJob(Long connectionId, String fileName, String title, String description,
|
|
||||||
String privacyStatus, Instant publishAt, long fileSize) {
|
|
||||||
this.connectionId = connectionId;
|
|
||||||
this.fileName = fileName;
|
|
||||||
this.title = title;
|
|
||||||
this.description = description;
|
|
||||||
this.privacyStatus = privacyStatus;
|
|
||||||
this.publishAt = publishAt;
|
|
||||||
this.fileSize = fileSize;
|
|
||||||
this.status = Status.UPLOADING;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void complete(String videoId) {
|
|
||||||
this.youtubeVideoId = videoId;
|
|
||||||
this.status = publishAt == null ? Status.PUBLISHED : Status.SCHEDULED;
|
|
||||||
this.errorMessage = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void fail(String message) {
|
|
||||||
this.status = Status.FAILED;
|
|
||||||
this.errorMessage = message == null ? "YouTube 업로드에 실패했습니다." : message;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user