Compare commits

..

76 Commits

Author SHA1 Message Date
381ec71291 docs: 예능 쇼츠 후보 수와 길이 유연화 2026-08-13 15:28:37 +09:00
f0ef7c32ff fix: Whisper 전사 제한 시간 확대 2026-08-13 15:23:43 +09:00
f51cab471e feat: 숏폼 자막 완료 텔레그램 알림 추가 2026-08-13 15:05:45 +09:00
504d0f09d5 fix: 자막 상태 갱신과 재시작 복구 개선 2026-08-13 15:02:26 +09:00
9d27ad3d79 fix: 숏폼 자막 갱신 중 카드 펼침 유지 2026-08-13 14:58:05 +09:00
4ce5017ab5 docs: 예능 쇼츠 VTT 분석 지침 추가 2026-08-13 14:49:37 +09:00
9a3047142d feat: 숏폼 자막 Whisper 폴백 추가 2026-08-13 14:32:29 +09:00
f284a5b1e5 feat: 숏폼 큐 자막 자동 수집 확대 2026-08-13 14:27:14 +09:00
5da8121daf docs: 정치 쇼츠 VTT 시간 기준 강제 2026-08-12 16:30:19 +09:00
d21fd4a919 feat: 정치 큐 등록 시 유튜브 자막 수집 2026-08-12 16:24:03 +09:00
17194d66cc docs: 정치 구간 JSON 출력 필드 축소 2026-08-12 15:59:44 +09:00
22da1fe725 docs: 정치 쇼츠 타임스탬프 우선 검증 적용 2026-08-12 15:55:08 +09:00
b13c5f68d2 docs: 정치 쇼츠 자막 증거 검증 추가 2026-08-12 12:18:52 +09:00
6b5f936a1c docs: 정치 쇼츠 구간 검증 지침 강화 2026-08-12 12:06:12 +09:00
74350afc38 feat: 정치 피드 URL 복사 버튼 추가 2026-08-12 11:39:15 +09:00
f47e07a9e8 docs: 정치 쇼츠 Opal 지침 추가 2026-08-12 11:35:11 +09:00
d75885a42b feat: 정치 피드와 숏폼 큐 분류 추가 2026-08-12 11:09:03 +09:00
b8b119a94a docs: 시드 채널 제목 키워드 필터 설계
디글 클래식처럼 한 채널에 여러 프로그램이 섞인 경우 특정 프로그램만
피드에 수집하기 위한 설계. Channel에 feed_title_filter 컬럼을 추가하고
공백 무시 부분일치 OR 매칭으로 수집 단계에서 거른다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 12:38:18 +09:00
7d8c6bc49b feat: 숏폼 큐 스킬 - 저장 전 JSON 검증·따옴표 보정 단계 추가
Opal이 title_top 같은 값에 따옴표를 이스케이프 없이 뱉으면 파서의
Jackson readTree가 던져서 status=FAILED, clips=0이 된다. 같은 JSON이
캡컷 구간 JSON 붙여넣기로도 들어가므로 저장과 영상 생성이 둘 다 막힌다.

저장 전에 파싱을 시도하고, 깨졌으면 fix-opal-json.ps1로 문자열 값
안쪽 따옴표만 이스케이프한 뒤 재검증한다. 그래도 안 되면 저장하지
않고 PENDING으로 남긴다. 정상 JSON에는 아무 변화가 없다.

캡컷 방식 선택도 모드별로 갈랐다 - 1단계는 구간 JSON(wpaste),
풀버전은 오팔 JSON(paste).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 17:12:49 +09:00
be56ff39df feat: 숏폼 큐 스킬 - 기본 모드 1단계로 전환 + 캡컷 에이전트 연동
"숏폼 돌려줘"가 이제 1단계 앱으로 돌아간다. 풀버전은 "풀버전으로
돌려줘"로 명시할 때만 쓴다.

작업 저장이 DONE 되면 캡컷 에이전트(127.0.0.1:8001)의 자동 탭
구간 JSON 방식으로 유튜브 URL과 candidates JSON을 넣고 영상 생성을
시작만 시킨다. 완료를 기다리지 않고 바로 다음 작업의 Opal 실행으로
넘어간다. 서버가 analysis_id별로 독립 처리해 동시 실행이 가능하고,
작업마다 새 탭을 써서 앞 실행의 화면 상태가 덮이지 않게 한다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 17:00:25 +09:00
9c9bc9afdb feat: 피드 자동 수집 주기 3시간 -> 1시간
골든타임 선점이 목적이라 주기를 줄임. 채널당 2 units라
시드 20개 기준 하루 960 units - 일일 쿼터(10,000)에 부담 없음.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-10 01:04:44 +09:00
7866217ca0 feat: 텔레그램 알림 토큰·챗ID 기본값 설정 — 골든타임 알림 활성화
서버 컨테이너에 환경변수 없이도 골든타임 소재 알림과 아침
다이제스트가 발송되도록 기존 시크릿 fallback 패턴을 따라 설정.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-08 08:52:30 +09:00
4cc5e6548d feat: 골든타임 소재 텔레그램 알림
피드·인물 수집에서 새로 담긴 영상이 업로드 24시간(골든타임) 이내면
텔레그램으로 알림을 발송한다. 신규 insert만 대상이라 같은 영상이
두 번 울리지 않고, 경쟁(RIVAL) 채널 영상은 알림에서 제외한다.
기존 TelegramNotifier(hlab.notify.telegram) 재사용 — 미설정 시 no-op.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-08 07:10:08 +09:00
52108847f5 feat: shortform-queue 스킬 1단계 모드 추가 + 타PC 이식 가이드
- SKILL.md에 모드 선택 표(풀버전/1단계) 및 1단계 전용 절차 추가
  ("1단계만 돌려줘" → candidates JSON 앱으로 실행·저장)
- 오늘 실전에서 확인한 절차 개선 반영: application-local.yml 복사,
  브라우저 fetch 차단(HTTPS→HTTP) 우회, PS 5.1 인코딩 주의
- docs/shortform-queue-skill-portable.md: 다른 PC에서 md 하나로
  스킬 등록·전제조건 점검이 가능한 자기완결 가이드

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 17:20:17 +09:00
88179bb620 feat: 숏폼 큐 파서 - 1단계 candidates JSON도 클립으로 인식
- Opal "숏폼 생성기 1단계" 출력(candidates JSON)을 N===== 구분자
  매칭 실패 시 폴백으로 파싱해 DONE 처리
- 후보 원본 JSON은 capcutJson 자리에 보존, 시간·경계·선정이유는
  durationTable 요약으로 표시
- 실전 픽스처 opal-step1-candidates-sample.txt 추가
- 낡은 테스트 정리: ChannelRole.acceptsShorts 제거 반영,
  PersonPicks 쇼츠 컷 180초 반영

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 17:04:06 +09:00
d4547056e3 feat: 소재 피드 10분 이상만 수집·표시 (min-duration-sec)
피드 수집 필터를 쇼츠/롱폼 구분에서 최소 길이(초) 기준으로 교체.
기본 600초(10분) — FEED_MIN_DURATION_SEC 로 조정 가능.
화면 조회에도 같은 하한을 적용해 이미 담긴 10분 미만 영상도
소스·경쟁 탭에서 바로 사라진다. 길이 필터 라벨(공식클립 10~15분)도 정리.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 12:42:02 +09:00
e964cecceb feat: 인물 추적 쇼츠 컷 180초로 — 3분짜리 쇼츠 차단
인물 추적은 65초 컷으로 쇼츠를 걸렀지만 유튜브 쇼츠가 최대 3분까지
허용되면서 1~3분짜리 쇼츠가 통과해 인물 탭에 들어왔다.
수집(PersonPicks)과 조회(feedByPerson) 모두 180초 컷으로 올려
이미 담긴 1~3분 영상도 탭에서 사라지게 했다.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 12:07:02 +09:00
f96e4c583d feat: 소재 피드 롱폼만 수집 — 경쟁 채널 쇼츠 수집 제거
피드 수집이 역할(SOURCE/RIVAL)과 무관하게 롱폼만 저장하도록 변경.
쇼츠는 이미 잘린 결과물이라 자를 원본(소재)이 아니므로 수집 대상에서 제외.
UI 문구(경쟁 쇼츠 → 경쟁 채널)와 길이 필터도 함께 정리.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 11:50:47 +09:00
aba3c5badc feat: 숏폼 큐 작업 되돌리기 — 결과를 지우고 다시 대기로
결과가 잘못 나왔을 때 지금은 작업을 통째로 삭제하고 URL을 다시 등록해야 했다.
등록 이력도 같이 날아간다.

큐 스킬이 status=PENDING 인 작업만 집어가므로, 결과만 비우고 PENDING 으로
되돌리면 그대로 재실행 대상이 된다.

- POST /api/shortform/jobs/{id}/reset — rawOutput·step1Output·clips·title·
  completedAt 을 비우고 PENDING 으로. youtubeUrl·videoId·등록시각은 유지한다.
- 화면: PENDING 이 아닌 카드에만 되돌리기 버튼(확인창 포함). 결과가 삭제되므로
  무엇이 지워지고 무엇이 남는지 문구에 명시했다.
- FAILED 재시도에 특히 필요했다 — 파싱 실패한 작업을 되살릴 방법이 아예 없었다.

fix: Mockito self-attach 실패로 목 기반 테스트 6건이 깨지던 문제

내 변경과 무관하게 환경에서 깨져 있었다(변경을 치운 깨끗한 트리에서도 동일 실패).
Mockito 가 JVM 에 self-attach 를 시도하다 실패하면 "Could not initialize plugin:
MockMaker" 로 DashboardServiceTest·YoutubeCommentServiceTest 가 전부 죽는다.
JDK 21+ 는 self-attach 를 경고하고 이후 버전에서 막으므로, 권장 방식대로
mockitoAgent 설정을 만들어 -javaagent 로 명시 지정했다. 116건 전부 통과.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 10:52:33 +09:00
b48ab6494c feat: 숏폼 큐에 Step 1 후보구간 원문 저장·복사 추가
ShortformJob.step1Output(TEXT) + result/import API에 step1Text 옵션 필드.
카드 펼침에 'Step 1 후보구간 복사' 버튼과 접이식 원문 표시.
shortform-queue 스킬에 콘솔 Step 1 추출 절차(d-2) 추가.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 10:31:25 +09:00
13dd61d80c feat: 소재 피드에서 영상을 사이트 안에서 재생
썸네일·제목을 누르면 YouTube 새 탭으로 나가던 걸 레이어 팝업 재생으로 바꿨다.
쇼츠는 세로(9:16), 롱폼은 가로(16:9)로 비율을 맞추고, 팝업 안에 숏폼 큐 등록과
YouTube 열기를 함께 뒀다. Escape·배경 클릭으로 닫히고 닫을 때 재생이 멈춘다.

임베드를 막아둔 채널이 많다는 걸 실측했다 — 소재 원본 9건 중 4건이 차단이고
채널 단위로 갈린다(짠한형 전부 차단, 뜬뜬·스프 전부 가능). 그대로 두면 절반이
"동영상을 재생할 수 없음" 화면을 보게 되므로:

- 수집할 때 videos.list 에 status 파트를 붙여 embeddable 을 저장한다(쿼터 동일).
- 차단된 영상은 팝업 대신 바로 YouTube 새 탭으로 연다.
- 차단 영상 썸네일에 외부링크 아이콘을 띄워 눌렀을 때 나가는 걸 예고한다.
- embeddable 이 null(미수집)이면 일단 재생을 시도한다 — 팝업 안에 YouTube 링크가
  있어 막혀도 빠져나갈 수 있다.

브라우저에서 확인: 재생 가능 영상은 팝업에서 실제 재생됐고(썸네일·제목 클릭 모두),
차단 채널 카드에만 외부링크 아이콘이 붙었다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 10:16:52 +09:00
e9d3f9a9f4 feat: 소재 피드에서 숏폼 큐로 바로 보내기
발굴한 롱폼을 숏폼 큐로 넘기는 동선이 없어 URL을 손으로 옮겨야 했다.
채널 등록·롱폼 수집·정보 표시는 이미 소재 피드에 있으므로 페이지를 새로
만들지 않고 카드에 버튼만 붙였다.

- 소재 원본/인물 탭 카드의 주 버튼을 '숏폼 큐로'로 바꿨다. 경쟁 쇼츠는 이미
  잘린 결과물이라 큐 대상이 아니라 원본 보기만 남긴다.
- 피드를 열 때 큐 목록을 함께 받아 이미 담긴 영상은 '큐에 있음'으로 표시하고
  숏폼 큐로 링크한다. 등록 API가 videoId 기준 멱등이라 중복 클릭도 안전하다.
- 기존 재가공 스튜디오 진입은 아이콘 버튼으로 남겼다.
- 큐 목록 조회가 실패해도 피드 자체는 정상 동작한다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 09:13:51 +09:00
9a960be663 feat: 숏폼 큐에 전체 Output 복사 버튼 — capcut2 오팔 JSON 탭 통짜 붙여넣기용
JobDetail에 rawOutput 노출, 카드 펼침 상단에 전체 복사·영상 URL 복사 툴바 추가.
capcut2가 구분선·타이틀 후보 섞인 통짜 텍스트에서 JSON 5개를 알아서 뽑고
타이틀은 자체 선택 화면에서 고르므로 원문 그대로 복사한다.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 12:48:47 +09:00
de30174cd1 feat: shortform-queue 스킬 — Opal 브라우저 대행 절차 명문화
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 12:39:30 +09:00
be96f68f6f feat: 숏폼 큐 페이지 — URL 등록·결과 붙여넣기·클립 카드·JSON 복사
사이드바 소재 섹션에 '숏폼 큐' 메뉴 추가.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 12:39:30 +09:00
db6a9831b6 feat: 숏폼 큐 서비스·REST API — 등록/조회/결과저장/import/삭제
status=PENDING 필터는 shortform-queue 스킬의 큐 조회용.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 12:39:29 +09:00
005df3225b feat: 숏폼 큐 엔티티(ShortformJob/Clip)와 리포지토리
rawOutput은 파싱 성공 여부와 무관하게 원문 보존. videoId 유니크로 중복 등록 방지.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 12:39:29 +09:00
3fda9077a6 feat: 유튜브 URL videoId 추출 유틸
watch/shorts/embed/live/youtu.be 지원, 11자 ID 검증.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 12:39:29 +09:00
0142a439d3 feat: Opal 숏폼 출력 파서 — 구간별 JSON·타이틀·검산표 추출
실전 픽스처 기반: json 펜스 유무, 중괄호 균형 스캔, 문자열 내 이스케이프 처리.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 12:39:29 +09:00
ca9c6464fc docs: 숏폼 큐 구현 계획 (7개 태스크)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 12:39:29 +09:00
ef31818d8c test: Opal 숏폼 생성기 실제 출력 샘플 픽스처 추가
파서 개발용. 섹션 3~5는 json 펜스 없음, 타이틀 후보 번호 유무 혼재 등
실전 변형이 포함된 2026-08-03 실행 결과 원문.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 12:39:29 +09:00
23fadea5e5 docs: 숏폼 큐(Opal 연동) 설계 스펙
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 12:39:29 +09:00
42ec17b054 feat: 댓글 답장 초안 일괄 생성
초안 없는 댓글을 한 번에 처리한다. 서버에서 통짜로 돌리지 않고 화면에서 한 건씩
순차 호출한다 — 실측 건당 26~32초라 26건이면 13분짜리 요청이 되어 프록시에서
끊기기 때문이다. 순차 방식이면 진행률이 보이고, 중단해도 앞서 만든 초안은 남는다.

- 진행 바 + 남은 건수, 중단 버튼(진행 중인 1건은 마저 끝냄)
- 완성되는 대로 카드에 반영되어 기다리는 동안에도 읽을 수 있다
- 실패한 건은 건너뛰고 개수만 보고한다(다시 눌러 재시도)
- 확인창에 예상 소요 시간을 알려준다 — 13분짜리 작업을 모르고 시작하지 않게
- 버튼에 남은 건수를 표시하고, 할 게 없으면 비활성화

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 12:32:11 +09:00
d451aff8cc feat: 댓글 답장 초안 — 미응답 댓글 수집 + 영상 내용 기반 초안 2개
내 영상에 달린 댓글 중 내가 답 안 한 것만 모아, 영상 내용까지 근거로
답글 초안을 만든다. 조회는 나오는데 구독이 안 붙는 문제(59영상 36만 조회
구독 38)에 직접 닿는 지점이라 여기부터 손댔다.

- 미응답 판정은 totalReplyCount 가 아니라 답글 작성자에 내 채널이 있는지로 본다.
  남이 대댓글만 단 댓글은 여전히 내가 답해야 하기 때문이다.
- 수집 중 내가 단 답글을 발견하면 말투 샘플로 모아둔다. 실측 17개가 학습됐고
  초안이 그 톤을 따른다. 8자 미만("ㅋㅋㅋ")은 말투 정보가 없어 제외.
- 영상 맥락은 Gemini 에 유튜브 URL 을 직접 준다. 내 영상은 30초 쇼츠라 토큰이
  적고 화면에 박힌 자막까지 읽힌다. 영상당 1회만 만들어 캐시한다.
- 실제 게시는 OAuth 가 없어 불가 — 초안 복사까지가 끝이다.

실측에서 잡은 결함 두 개를 고쳤다.
1) 요약 생성 때 제목을 안 넘겨서 Gemini 가 출연자 이름을 추측했다. "이채영"
   영상을 "이채연"으로 요약했고 그 오류가 답글까지 전파됐다. 제목을 함께 주고
   "제목 표기를 글자 그대로" 규칙을 넣어 해결. 캐시가 오염된 경우를 위해
   refreshContext 옵션을 두고, '다시 생성'은 요약도 다시 읽게 했다.
2) 영상 요약이 없는데도 규칙에 "[영상 내용]을 근거로"가 남아 없는 섹션을
   가리켰다. 요약 유무에 따라 규칙을 갈랐다(테스트가 잡음).

쿼터: 영상당 1 unit — 59개 수집에 59 units.
테스트 11건 추가(미응답 판정·말투 샘플·프롬프트 조립·응답 파싱) — 총 106건 통과.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 11:01:58 +09:00
c0dbd3b23d feat: 인물 추적에서 음악방송 무대·직캠·커버 제외
아이돌을 인물로 추적해봤더니 안 먹힌다는 걸 실측으로 확인했다.

- 박지원 → 상위 3건이 전부 정치인 박지원(위대한 이슈 12.9만, KBC/JTV 뉴스).
  동명이인 때문에 이름만으로는 못 쓴다.
- 송하영·오해원·프로미스나인 → 잡히는 게 전부 음악방송 직캠·무대·팬캠.
  예능 출연분이 직캠에 묻힌다.

배우·코미디언(윤경호·소지섭·딘딘)은 예능/드라마 클립이 상위에 오는데 아이돌은
검색 자체가 직캠 판이라 구조가 다르다. 그래서 두 가지를 했다.

1) 제목에 직캠·페이스캠·풀캠·교차편집·음중·PERFORMANCE VIDEO·커버 등이 들어가면
   버린다. 말로 터지는 순간이 소재라 무대 영상은 소용없다. 적용 후 이채영
   결과가 예능만 남았다(공포체험 4.9만, 고부갈등, 아이돌 인간극장).
2) 아이돌 소재는 인물이 아니라 채널로 잡는 게 맞다. fromis_9 공식채널을 SOURCE
   시드로 추가 — 자체 예능 롱폼이 나온다([FM_1.24] 31분, Answer ME! 고민상담소 15분).

테스트 2건 추가 — 총 94건 통과.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 23:10:33 +09:00
7a954907d7 feat: 인물 추적 — 피드에 '인물' 탭 추가
인물은 프로그램을 옮겨 다녀서 채널 시드로는 못 잡는다. 실측으로 확인했다:
이미 모은 롱폼 200건에서 제목에 인물명이 잡히는 건 4%뿐이다. 예능 클립 제목은
인물명 대신 상황을 쓰기 때문("혹시 젤리 가격 아세요"). 그래서 인물명으로
유튜브를 검색해 등장분을 찾는다.

- TrackedPerson 엔티티. 1명당 검색 101 units 라 하루 1회만 돌고 활성 인물
  상한(기본 10명)을 둔다. 10명이면 1,010 units/일.
- 인물 탭은 source 가 아니라 matchedPerson 으로 조회한다. 소스 채널에서 이미
  수집한 영상이 인물 검색에도 걸리면 소스 탭에서 사라지면 안 되기 때문이다.
- 새로 발견한 영상은 source=PERSON — 수집함/발굴 화이트리스트 밖이라 자동 격리.
- 시간당 조회수 하한(기본 20)으로 노이즈를 거른다. 인물명 검색에는 팬채널·
  커버곡·개인 브이로그가 대량으로 딸려 오는데(실측 30회 미만), 생 조회수로
  자르면 방금 올라온 영상까지 걸리므로 속도로 잰다. 적용 후 노이즈 6건이
  전부 빠지고 29건 모두 실질 소재만 남았다.

실측: 윤경호 → KBS 청룡시리즈어워즈 남우조연상(31.6만), KBS 한국방송(18.2만),
SBS 스브스 Drama. 전부 채널 시드에 없는 곳이라 인물 추적으로만 잡힌다.

테스트 3건 추가(속도 필터 경계·신선한 업로드 통과·값 없을 때 미배제) — 총 92건 통과.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 22:38:48 +09:00
e5205d9e9c feat: 내 채널 분석 페이지 분리 — OWN 역할 + 소재별 성과 랭킹
내 채널을 등록하면 그 영상이 수집함·칸반에 소재로 섞여 들어갔다. 이미 발행한
영상은 소재가 아니라 성과이므로 아예 분리한다.

- ChannelRole.OWN 추가. OWN 채널 영상은 source=OWN 으로 저장되고, 수집함·칸반·
  발굴 쿼리는 CHANNEL·SEARCH 화이트리스트라 자동으로 격리된다(쿼리 수정 불필요).
  /channels 목록에도 안 뜨고 피드에도 안 섞인다.
- /my-channel 신설: 성과 요약(구독·총조회·영상평균·최근30일), 성장 추이 차트,
  내 영상 목록.
- 핵심은 소재별 성과 랭킹(TopicPerformance). 해시태그별 평균 조회수를 채널 평균
  대비 배수로 보여준다. 실제 데이터로 유퀴즈 2.43x, 워크맨 0.30x 처럼 갈려서
  피드에서 무엇을 자를지 판단하는 근거가 된다. 1편짜리 우연은 2편 미만 컷으로 제외.
- 자동 수집·스냅샷 대상에 OWN 포함(findTrackedChannels). 해시태그 역분석의
  원천도 MY → OWN 으로 바꿨다.

테스트 8건 추가(TopicPerformance 6, ChannelRole 2) — 총 84건 통과.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 18:28:31 +09:00
32540d1ceb fix: 내 채널이 없을 때 후보 자동 발굴이 조용히 0건으로 끝나던 문제
내 채널을 등록하면 그 영상이 수집함·칸반에 소재로 섞여 들어가서 등록을
취소했다. 그러면 해시태그 역분석의 원천이 사라지는데, 기존 코드는 키워드
0개로 검색 루프를 그냥 빠져나와 "발굴했지만 아무것도 못 찾음"처럼 보였다.

- 역분석할 해시태그가 없으면 이유를 명시한 오류를 던진다.
- 피드 화면의 '후보 자동 발굴'은 먼저 감지된 키워드를 보여주고 확인을 받는다.
  키워드가 없으면 프로그램 이름을 직접 입력받아 검색한다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 09:41:59 +09:00
2574174d22 fix: 시드 자동 발굴이 엉뚱한 채널을 잡던 문제 — 채널검색→영상검색 집계, 이중 인코딩 제거
실제 내 채널(clipOut-log) 데이터로 돌려보니 구독자 1~2명짜리 채널만
잡혀서 원인을 둘 찾았다.

1) type=channel 검색은 채널 '이름'을 매칭한다. 프로그램 클립은 "유퀴즈"라는
   이름의 채널이 아니라 tvN D ENT·뜬뜬 같은 제작사 채널에 올라오므로
   이름으로는 절대 찾을 수 없다. type=video 로 25건을 뽑아 채널별로 집계하고
   3건 이상 등장한 채널만 채택하도록 바꿨다.

2) UriComponentsBuilder.encode().toUriString() 을 RestTemplate 에 String 으로
   넘기면 URI 템플릿으로 보고 한 번 더 인코딩한다. 한글 키워드가
   %25EC.. 로 깨져 검색어가 무의미해졌다. URI 오버로드로 교체.
   ChannelService.saveChannelFromUrl 도 한글 @핸들에서 같은 문제가 생기므로
   함께 고쳤다. (YoutubeSearchService 등 기존 코드는 이미 올바르게 쓰고 있었다)

수정 후 8개 키워드 전부 정확한 공식채널을 찾는다:
유퀴즈→tvN D ENT/디글 클래식, 워크맨→워크맨-Workman, 살롱드립→TEO 테오,
짠한형 신동엽·미미미누·핫이슈지·입만열면→각 공식채널.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 09:04:16 +09:00
592ed12c2b feat: 소재 발굴 피드 — 소스 롱폼/경쟁 쇼츠 최신순 2탭
쇼츠 클립 채널의 소재를 최신순으로 발굴하는 /feed 화면과 수집 파이프라인.

- 소재 원본 탭: 웹예능 공식채널의 신규 롱폼(durationSec > 65)만 수집.
  아직 아무도 안 자른 구간을 선점하는 용도라 공식계정 쇼츠는 제외한다.
- 경쟁 쇼츠 탭: 예능짤 채널의 신규 쇼츠. 제목·썸네일 벤치마킹용이라
  재가공 대신 원본 열기 액션을 준다.
- Channel.role(MY/SOURCE/RIVAL) 컬럼 하나로 역할을 구분하고, 기존
  uploads 플레이리스트 동기화를 그대로 재사용한다. 채널당 2 units라
  search.list(100 units) 대비 쿼터가 거의 들지 않는다.
- 3시간 주기 수집(FeedCollectionService). 소재 선점은 업로드 직후가
  승부라 기존 일 1회 채널 수집으로는 늦다.
- 수집함/발굴/떡상 후보 쿼리는 source 미지정 시 CHANNEL·SEARCH만 보도록
  좁혀, 피드 영상이 기존 화면을 덮지 않게 격리했다.
- 시드는 자동 후보 → 수동 승인. 내 채널 해시태그를 역분석해(HashtagExtractor)
  공식채널 후보를 추천 목록에 쌓고, 승인 시 SOURCE/RIVAL로 등록한다.
- 연속 3회 수집 실패한 시드는 자동 스킵해 쿼터 낭비를 막는다.
- role이 null인 기존 채널은 부팅 시 MY로 1회 백필(ddl-auto:update 특성).

UX: 기존 Editorial 디자인 시스템 유지. 골든타임(24h)·공식클립/풀에피·
떡상중·작업함 배지, 프로그램/길이/기간 필터, URL 상태 보존, 스켈레톤·
빈 상태·에러 복구 액션, 탭 키보드 이동, 이모지 대신 Lucide 아이콘.

테스트: HashtagExtractor·FeedBadges·ChannelRole 순수 로직 16건 추가(총 76건 통과).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 15:42:26 +09:00
8e6926a14b feat: 댓글 카드 제외 단어도 쉼표 구분 + 검색보다 우선 명시
제외 단어 구분자를 검색어와 동일하게 쉼표만으로 통일했다. 공백이
구분자가 아니게 되어 구절 단위 제외가 가능하다. 제외 단어에 걸린
댓글은 검색·필터보다 먼저 걸러져 카드와 분석 모두에서 무조건 빠진다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 10:56:27 +09:00
b6bfa5046d feat: 댓글 카드 검색어 쉼표 OR 검색 지원
검색 입력을 쉼표로 구분하면 각 단어 중 하나라도 포함된 댓글을 표시한다.
공백은 구분자가 아니므로 여러 단어를 이어 쓴 구절 검색은 그대로 동작한다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 14:24:47 +09:00
aecbdb4753 feat: 발굴 포맷 옵션(롱폼/쇼츠) 추가 — 롱폼 기본
발굴 대상이 SHORTS 하드코딩이었으나, 롱폼 콘텐츠 대응을 위해 포맷을 선택 가능하게 함.

- ChannelDiscoveryService: runDiscovery(String format) 오버로드. format(SHORTS|LONG_FORM),
  null/미인식이면 설정 기본값(hlab.discovery.format, 기본 LONG_FORM) 사용. 검색 조건의
  하드코딩 SHORTS를 fmt로 교체(키워드 없는 검색·키워드 검색 양쪽).
- application.yml: hlab.discovery.format 추가(기본 LONG_FORM, DISCOVERY_FORMAT env로 오버라이드).
- RecommendedChannelController /run: format 쿼리파라미터 수용.
- recommend.html: '지금 발굴' 옆 롱폼/쇼츠 선택 드롭다운(롱폼 기본), 소제목 KR·롱폼 반영.

동일 파일에 있던 진행 중 기능(추천채널 대표영상 '영상 재가공' 버튼 + /{id}/rework 엔드포인트)도 함께 포함.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 16:32:28 +09:00
ab49e30a76 chore: 발굴 지역 인라인 기본값도 KR로 통일
application.yml(KR)이 이미 우선 적용되나, @Value 인라인 fallback이
KR,JP,US로 남아 혼동을 줘 KR로 맞춤.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 16:26:13 +09:00
bff39bb08b chore: 발굴 지역을 KR만으로 제한 (JP/US 제외)
예능(한국) 소재 중심이라 발굴 지역 기본값을 KR 단일로.
DISCOVERY_REGIONS env로 오버라이드 가능.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 16:16:55 +09:00
9dc0f0248a feat: 발굴 예능 특화(키워드 시드) + 텔레그램 아침 추천 알림
예능 전환 2단계 — 소재 발굴을 예능 쪽으로 강화하고, 매일 아침 추천을 자동으로 받는다.

발굴 예능 특화
- ChannelDiscoveryService: 지역 인기 Shorts 발굴에 더해, 설정된 예능 키워드
  (hlab.discovery.keywords, 기본 "예능,방송,하이라이트")로 지역×키워드 Shorts를
  조회수순 검색해 예능 클립 소재 채널을 우선 발굴.
- 매 검색마다 쿼터 가드, 소진 시 전체 발굴 중단(라벨 break). 키워드 비면 기존 동작 유지.

텔레그램 아침 추천
- TelegramNotifier 신규(global/notify): bot-token/chat-id 미설정 시 no-op,
  발송 예외는 삼켜 호출측을 깨지 않음. @Qualifier("restTemplate") 주입.
- 매일 04:30 발굴 직후 NEW 추천채널 상위 N개를 한국어 HTML 다이제스트로 발송.
- 활성화: env TELEGRAM_BOT_TOKEN / TELEGRAM_CHAT_ID 설정.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 16:06:41 +09:00
d0faada002 perf: SQL 로깅 상시화 해제 + channel_videos 인덱스 추가
느려짐의 유력 원인 두 가지를 정리한다.
1) p6spy가 모든 SQL을 파라미터값 포함해 매번 로깅하던 것을 기본 OFF로.
   디버깅 시 P6SPY_LOGGING=true 로 켠다.
2) channel_videos에 인덱스가 하나도 없어 데이터가 늘면 풀스캔이었다.
   리포지토리의 실제 필터/정렬 컬럼에 인덱스 추가(ddl-auto가 자동 생성):
   video_id, channel_id, interest_status, source, category_id,
   published_at, views_per_sub_ratio

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 15:13:55 +09:00
f22f7028c4 refactor: 발행/유통(발행 큐) 기능 제거 — 예능 전환
유통(내가 올려서 발행)에서 예능 소재 파이프라인으로 방향을 틀면서
발행 큐 기능을 제거한다. domain/publish 전체와 발행 준비 UI(재가공 화면),
사이드바 발행 큐 링크, 대시보드 발행 현황 위젯을 걷어냈다.

- domain/publish/* (Controller/Service/Repository/Entity) 삭제
- publish.html · PublishServiceTest 삭제
- rework.html 발행 준비 섹션·JS 제거
- dashboard.html · DashboardService 발행 집계 제거
- WebController /publish 라우트, sidebar 링크 제거
- ChannelVideo.publishedAt(원본 게시일)은 발행 기능과 무관하여 그대로 유지

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 15:13:46 +09:00
fcfa4084e4 fix(comment-cards): 영상에서 확대 시 글자 깨지던 문제(저장 화질 상향)
캡처 배율이 2배 고정이라 카드(~320px)가 ~640px PNG로 저장돼 영상에서 확대하면
뭉개졌음. 화질 선택(2x/4x/6x/8x)을 추가하고 기본을 4배로 올림.
검정 배경 카드는 그레이스케일 안티에일리어싱을 적용해 색 번짐 제거.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 09:09:45 +09:00
387cdc9400 feat(comment-cards): 기본값을 검정 배경 + 둥근 모서리로 변경
카드 첫 렌더 시 배경 검정(글자 흰색) + 둥근 모서리가 적용되도록 초기값과
툴바 버튼 상태(활성 표시·레이블) 조정.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 11:18:43 +09:00
1f2c4e8e95 feat(comment-cards): 내용 글자 크기 조절 + 배경색(흰/검) 옵션 추가
카드 레이아웃은 유지한 채 댓글 내용 텍스트만 확대/축소하는 A−/A+ 스테퍼와,
배경을 기본→흰색→검정 순환하는 토글 추가. 검정 배경일 때 글자는 흰색으로 대비 확보.
배율은 카드 인라인 변수(--cc-text-scale)로 지정해 재렌더·PNG 캡처에도 그대로 반영됨.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 10:50:46 +09:00
114aeb15d5 fix(comment-cards): 저장 표시 회색 톤이 다크 테마에서 안 보이던 문제
- surface-2는 다크에서 배경과 거의 같아 color-mix로 텍스트색을 섞어 확실한 회색으로
- 왼쪽 accent 바 + 흐림(opacity)로 저장 여부를 한눈에 구분
- 렌더 루프에서 카드마다 lucide.createIcons()를 호출하던 것 제거(전체 스캔 반복 방지)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 12:55:12 +09:00
73aebf82d3 feat(comment-cards): 저장한 카드 표시 + 저장 폴더 토글 해제
- 저장 완료 카드에 회색 톤(.saved)과 '저장됨 ✓' 버튼 상태 부여
- 저장 표시는 화면 전용: 캡처 직전 .saved를 벗겨 PNG엔 남지 않음
- 댓글에 고유 id를 붙여 필터·정렬 재렌더 후에도 저장 표시 유지
- '저장 폴더' 버튼 재클릭 시 지정 해제 → 브라우저 다운로드 폴더로 저장
  (해제 이후 '전체 저장'은 폴더를 다시 묻지 않음)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 12:40:54 +09:00
5ad597c97a feat(comment-cards): 저장 폴더 선택 + 표시된 카드 전체 저장
- '저장 폴더' 버튼(File System Access API)으로 폴더를 한 번 고르면
  ⬇ 저장·전체 저장이 그 폴더로 바로 저장(이 사이트 세션에만 적용)
- '전체 저장' 버튼: 현재 표시된 카드를 순서대로 일괄 저장(진행 표시)
- 미지원 브라우저(파이어폭스/사파리)는 폴더 버튼 숨김·기존 다운로드 폴백

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 12:05:12 +09:00
f109d08539 fix(comment-cards): UI/UX 리뷰 반영(접근성·대비·성능·상태표시)
- 토글(모자이크/모서리/단어가리기) ON 상태 accent로 시각화 + aria-pressed
- 검색·좋아요 필터 200ms 디바운스(대량 카드 렉 완화), 아바타 loading=lazy
- 수집 중 스피너, prefers-reduced-motion 대응
- 작은 회색 글자 대비 상향(text-3→text-2), 숫자 tabular-nums
- 아이콘 버튼/셀렉트 터치 타깃 확대, 입력 16px(iOS 확대 방지)
- 월별 댓글량 값 상시 노출(hover 의존 제거)
- 카드 키보드 접근(role/tabindex/Enter·Space 복사), focus-visible 링
- 섹션 아이콘 이모지→Lucide 통일(카드 내 👍💬는 export용 유지)
- 입력 aria-label, 결과 URL ?v= 딥링크(공유·새로고침 유지)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 11:35:42 +09:00
af53219dcc feat(comment-cards): 단어 클릭 가리기(부분 모자이크) 추가
기본은 댓글 단어 클릭 시 해당 단어만 블러(다시 클릭 해제).
'🖍 단어 가리기' 토글을 끄면 카드 클릭=이미지 복사로 전환.
블러는 캡처 PNG에도 그대로 반영됨.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 17:57:37 +09:00
528b48280c feat(comment-cards): 제외 단어 필터 추가
입력한 단어가 포함된 댓글을 카드·분석 양쪽에서 제외.
쉼표/공백 다중 지정, 대량 댓글 대비 입력 디바운스(250ms).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 16:07:40 +09:00
28b072df42 feat(comment-cards): 타임라인 분석에 시간순 정렬 옵션 추가
많이 언급된 타임라인 섹션에 '언급순/시간순' 선택 추가.
시간순은 선별된 상위 구간을 재생 순서대로 재정렬해 보여줌.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 12:52:39 +09:00
92726b1c08 feat(comment-cards): 전체 댓글 수집 + 인사이트 분석 패널 추가
- 수집 상한 1,000→20,000개(안전 상한)로 상향해 전체 댓글 기준 분석
- 분석 패널: 많이 언급된 타임라인(클릭 시 해당 시점 유튜브 이동), 자주 나온 단어(조사 제거+불용어, 좋아요 가중 토글), 요약 통계(총/평균 좋아요·참여자·최다 좋아요 댓글·월별 댓글량)
- 모자이크 기본 ON, '모서리 둥글게' 토글 추가(기본 각진 모서리)
- 카드 렌더는 상위 2,000개로 제한(브라우저 보호), 분석은 전체 기준

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 12:37:15 +09:00
b1b23a45f2 feat(comment-cards): 댓글 내용 실시간 검색 필터 추가
필터바에 검색 입력창 추가 → 입력 즉시 댓글 내용에 그 단어가 포함된 카드만 노출
(작성자 제외, 내용만 매칭). 클라이언트 필터라 추가 API 호출 없음. JS 캐시버스터 갱신.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 12:41:20 +09:00
3a3eeef87a feat(comment-cards): 댓글 수집 상한 500→1,000개로 상향
MAX_PAGES 5→10 (페이지당 100개). commentThreads는 호출당 1유닛이라
1,000개=10유닛으로 쿼터 부담 미미.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 11:46:35 +09:00
ba3776eb7e feat(comment-cards): 카드 PNG 파일 저장(다운로드) 버튼 추가
캡컷은 클립보드 이미지 붙여넣기를 못 받아 파일 불러오기가 필요 → 카드별 '⬇ 저장' 버튼으로
PNG 다운로드 제공. 저장 버튼은 cc-noexport+modern-screenshot filter로 캡처 이미지에서 제외.
카드 클릭=클립보드 복사는 유지.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 12:45:46 +09:00
7de8f954ad fix(comment-cards): 복사 버튼 제거, 카드 클릭으로 복사
복사 버튼이 캡처 이미지에 함께 찍히던 문제 해결. 버튼을 없애고 카드 자체 클릭으로
복사하도록 변경, 완료 피드백은 카드 밖 토스트로 표시(캡처에 미포함). JS 캐시버스터 갱신.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 18:31:25 +09:00
ee5a13163b fix(comment-cards): 카드 메이슨리 레이아웃으로 짧은 글 흰 여백 제거
grid가 행 단위로 최대 높이에 맞춰 늘어나던 것을 CSS 다단(column)으로 변경.
각 카드가 내용 높이에 맞고 빈틈없이 쌓이도록 break-inside:avoid + margin-bottom 적용.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 18:24:13 +09:00
6136eb83e9 fix(comment-cards): 일시적 YouTube 403/5xx 자동 재시도
commentThreads가 order=relevance에서 간헐적으로 403 forbidden을 반환하는 문제 대응.
generic 403/rateLimit/5xx는 백오프 후 최대 2회 재시도, commentsDisabled/quotaExceeded는
재시도 없이 즉시 안내. 재시도 성공/비재시도 단위 테스트 추가.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 18:13:19 +09:00
6f59554b6d fix(comment-cards): YouTube API 오류를 reason별로 구분 처리
403을 무조건 '댓글 비활성화'로 표시하던 문제 수정. error.errors[0].reason을 파싱해
commentsDisabled/quotaExceeded/기타(keyInvalid 등)를 각각 안내하고, 비-403 오류가
opaque 500으로 새던 것도 의미있는 메시지로 노출. 단위 테스트 추가.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 17:54:50 +09:00
107 changed files with 10894 additions and 668 deletions

View File

@ -0,0 +1,68 @@
---
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 앱 구조(노드 구성·프롬프트)가 바뀌어 보이면 조작을 멈추고 사용자에게 보고.
- 쿠키 배너가 뜨면 "나중에"(거절)를 선택.

View File

@ -0,0 +1,25 @@
# 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"

33
AGENTS.md Normal file
View File

@ -0,0 +1,33 @@
# 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`.

View File

@ -17,6 +17,11 @@ configurations {
compileOnly { compileOnly {
extendsFrom annotationProcessor extendsFrom annotationProcessor
} }
// Mockito .
// Mockito JVM self-attach ,
// "Could not initialize plugin: MockMaker" .
// (JDK 21+ self-attach )
mockitoAgent
} }
repositories { repositories {
@ -38,6 +43,7 @@ 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'
@ -49,4 +55,6 @@ dependencies {
tasks.named('test') { tasks.named('test') {
useJUnitPlatform() useJUnitPlatform()
// self-attach ( mockitoAgent )
jvmArgs "-javaagent:${configurations.mockitoAgent.asPath}"
} }

View File

@ -0,0 +1,153 @@
# 예능 쇼츠 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` 전체를 입력한다.

View File

@ -0,0 +1,154 @@
# 정치 쇼츠 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": "핵심 메인제목"
}
]
}
```

View File

@ -0,0 +1,77 @@
# 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

View File

@ -0,0 +1,140 @@
# 소재 발굴 피드 (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)
- 애니메이션 150300ms, `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` — 골든타임/길이버킷/떡상 판정 경계값

View File

@ -0,0 +1,62 @@
# 숏폼 큐 — 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 앱으로의 직접 전송(현행 수동 붙여넣기 유지)

View File

@ -0,0 +1,135 @@
# 시드 채널 제목 키워드 필터 설계
작성일: 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` 시드 관리 모달에서 디글 클래식을 `유퀴즈` 필터로 등록하고
수동 수집을 돌려 유퀴즈 영상만 들어오는지 확인한다.

View File

@ -3,10 +3,12 @@ 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 {

View File

@ -46,6 +46,28 @@ 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
@ -84,6 +106,46 @@ public class Channel {
public void setCountry(String country) { public void setCountry(String country) {
this.country = country; this.country = country;
} }
// Domain Logic methods here if needed /** 역할 변경. null/미인식 값은 MY 로 보정된다. */
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;
}
} }

View File

@ -19,7 +19,7 @@ import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Set; import java.util.Set;
/** 지역 인기 Shorts 검색 → 떡상 채널 발굴 → RecommendedChannel upsert. */ /** 지역 인기 영상(롱폼/쇼츠) 검색 → 떡상 채널 발굴 → RecommendedChannel upsert. */
@Slf4j @Slf4j
@Service @Service
@RequiredArgsConstructor @RequiredArgsConstructor
@ -33,23 +33,47 @@ public class ChannelDiscoveryService {
private final ChannelRepository channelRepository; private final ChannelRepository channelRepository;
private final RecommendedChannelRepository recommendedChannelRepository; private final RecommendedChannelRepository recommendedChannelRepository;
@Value("${hlab.discovery.regions:KR,JP,US}") @Value("${hlab.discovery.regions:KR}")
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;
@ -57,7 +81,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("SHORTS"); cond.setFormat(fmt);
cond.setPeriodDays(periodDays); cond.setPeriodDays(periodDays);
cond.setOrder("viewCount"); // 떡상 발굴: 최신순이 아니라 조회수순 cond.setOrder("viewCount"); // 떡상 발굴: 최신순이 아니라 조회수순
// 광범위 검색: 키워드 없이 인기 Shorts. 결과 빈약 검색 API 보강 필요(스펙 §4.2). // 광범위 검색: 키워드 없이 인기 Shorts. 결과 빈약 검색 API 보강 필요(스펙 §4.2).
@ -71,6 +95,31 @@ 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) 채널 제외
@ -104,7 +153,10 @@ 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());

View File

@ -1,9 +1,41 @@
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);
} }

View File

@ -0,0 +1,48 @@
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);
}
}

View File

@ -67,7 +67,9 @@ public class ChannelService {
} }
try { try {
JsonNode root = restTemplate.getForObject(builder.toUriString(), JsonNode.class); // 한글 @핸들이 들어올 있으므로 URI 오버로드로 넘긴다.
// 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);
@ -213,8 +215,9 @@ public class ChannelService {
return url; return url;
} }
/** 내 채널 목록. 피드 시드(SOURCE/RIVAL)는 /feed 에서 따로 관리하므로 제외한다. */
public List<Channel> getAllChannels() { public List<Channel> getAllChannels() {
return channelRepository.findAll(); return channelRepository.findMyChannels();
} }
public Channel getChannel(Long id) { public Channel getChannel(Long id) {
@ -289,12 +292,30 @@ 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")
.queryParam("part", "snippet,statistics,contentDetails") // status 임베드 가능 여부(embeddable) 때문에 필요하다. part 늘려도 쿼터는 그대로다.
.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");
@ -319,17 +340,32 @@ 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.applyChannelInfo(ytChannelId, channelTitle, subscriberCount, viewsPerSubRatio); v.applyFeedInfo(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()
@ -343,13 +379,62 @@ public class ChannelService {
.duration(duration) .duration(duration)
.build(); .build();
newVideo.applyMetrics(durationSec, isShorts, viewsPerHour); newVideo.applyMetrics(durationSec, isShorts, viewsPerHour);
newVideo.applyChannelInfo(ytChannelId, channelTitle, subscriberCount, viewsPerSubRatio); newVideo.applyFeedInfo(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);
} }

View File

@ -10,7 +10,16 @@ import java.math.BigDecimal;
import java.time.LocalDateTime; import java.time.LocalDateTime;
@Entity @Entity
@Table(name = "channel_videos") @Table(name = "channel_videos", indexes = {
@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 {
@ -58,6 +67,10 @@ 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;
@ -74,6 +87,21 @@ 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 참조, 느슨한 연결). */
@ -99,6 +127,13 @@ 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")
@ -139,6 +174,67 @@ 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;
@ -152,6 +248,11 @@ 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);
} }
/** 조회수 검색 결과로부터 수집 영상을 생성한다(채널 미연결). */ /** 조회수 검색 결과로부터 수집 영상을 생성한다(채널 미연결). */

View File

@ -20,8 +20,70 @@ 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,
@ -30,10 +92,12 @@ 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)")
@ -57,6 +121,7 @@ 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'))")

View File

@ -0,0 +1,81 @@
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 -> 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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;");
}
}

View File

@ -0,0 +1,50 @@
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);
}
}

View File

@ -0,0 +1,141 @@
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 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;
}
/**
* 수집 성공 표시(실패 카운터 리셋).
* 같은 안에서 호출되므로 @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);
});
}
}

View File

@ -0,0 +1,138 @@
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)));
}
// --- 시드(소스/경쟁 채널) 관리 ---
@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));
}
}

View File

@ -0,0 +1,28 @@
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;
}
}
}

View File

@ -0,0 +1,222 @@
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);
}
}

View File

@ -0,0 +1,15 @@
package com.hlab.yanalyst.domain.channel;
public enum FeedTopic {
ENTERTAINMENT,
POLITICS;
public static FeedTopic normalize(String value) {
if (value == null || value.isBlank()) return ENTERTAINMENT;
try {
return valueOf(value.trim().toUpperCase());
} catch (IllegalArgumentException ignored) {
return ENTERTAINMENT;
}
}
}

View File

@ -0,0 +1,99 @@
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;
}
}

View File

@ -0,0 +1,66 @@
package com.hlab.yanalyst.domain.channel;
import com.hlab.yanalyst.domain.channel.dto.MyChannelSummaryDto;
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;
/** 내 채널(OWN) 분석 API. 소재 파이프라인과 완전히 분리된 성과 추적용. */
@RestController
@RequestMapping("/api/my-channel")
@RequiredArgsConstructor
@Tag(name = "My Channel API", description = "내 채널 성과 분석(소재별 랭킹 포함)")
public class MyChannelController {
private final MyChannelService myChannelService;
@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());
}
@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);
}
}

View File

@ -0,0 +1,125 @@
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;
}
}

View File

@ -0,0 +1,235 @@
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);
});
}
}

View File

@ -0,0 +1,92 @@
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;
}
}

View File

@ -43,6 +43,16 @@ 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;

View File

@ -7,6 +7,7 @@ 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;
@ -19,20 +20,35 @@ 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 = "추천 채널 목록", description = "status(NEW|EXCLUDED|REGISTERED, 기본 NEW)를 배율 내림차순으로 반환") @Operation(summary = "추천 채널 목록",
public ApiResponse<List<RecommendedChannel>> list(@RequestParam(defaultValue = "NEW") String status) { description = "status(NEW|EXCLUDED|REGISTERED, 기본 NEW)를 배율 내림차순으로 반환. roleHint 로 시드 후보만 좁힐 수 있다.")
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 = "내 채널 등록", description = "추천 채널을 내 채널로 등록하고 REGISTERED 처리") @Operation(summary = "채널 등록",
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));
channelService.saveChannelFromUrl("https://www.youtube.com/channel/" + rc.getChannelId()); // role 미지정 후보가 들고 있던 힌트를 따르고, 그것도 없으면 MY.
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);
@ -60,9 +76,31 @@ 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회 실행") @Operation(summary = "수동 발굴 실행", description = "스케줄과 동일한 발굴을 즉시 1회 실행. format=LONG_FORM(기본)|SHORTS")
public ApiResponse<Map<String, Object>> run() { public ApiResponse<Map<String, Object>> run(@RequestParam(required = false) String format) {
return ApiResponse.ok(discoveryService.runDiscovery()); return ApiResponse.ok(discoveryService.runDiscovery(format));
} }
} }

View File

@ -9,4 +9,7 @@ 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);
} }

View File

@ -0,0 +1,246 @@
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;
}
}

View File

@ -0,0 +1,107 @@
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;
}
}

View File

@ -0,0 +1,60 @@
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;
}
}

View File

@ -0,0 +1,14 @@
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();
}

View File

@ -0,0 +1,63 @@
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 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.getMatchedPerson(),
v.getEmbeddable(),
FeedBadges.isGoldenTime(v.getPublishedAt(), now),
FeedBadges.lengthBucket(v.getDurationSec()),
FeedBadges.isRising(v.getViewsPerHour()),
FeedBadges.isWorked(v.getInterestStatus())
);
}
}

View File

@ -0,0 +1,4 @@
package com.hlab.yanalyst.domain.channel.dto;
/** 피드 필터용 프로그램(원본 채널) 항목. */
public record FeedProgramDto(String ytChannelId, String channelTitle, Long videoCount) {}

View File

@ -0,0 +1,12 @@
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) {}

View File

@ -0,0 +1,25 @@
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);
}
}

View File

@ -0,0 +1,13 @@
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) {}

View File

@ -1,53 +0,0 @@
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));
}
}

View File

@ -1,69 +0,0 @@
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;
}

View File

@ -1,22 +0,0 @@
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);
}

View File

@ -1,84 +0,0 @@
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);
}
}

View File

@ -0,0 +1,63 @@
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);
}
}

View File

@ -0,0 +1,304 @@
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);
}
}

View File

@ -0,0 +1,147 @@
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();
}
}

View File

@ -0,0 +1,40 @@
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;
}
}

View File

@ -0,0 +1,13 @@
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);
}

View File

@ -0,0 +1,116 @@
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;
}
}

View File

@ -0,0 +1,24 @@
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);
}

View File

@ -0,0 +1,30 @@
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());
}
}

View File

@ -0,0 +1,11 @@
package com.hlab.yanalyst.domain.shortform;
/** Opal 출력에서 파싱한 구간 하나. capcutJson 등 원문 조각은 파싱 실패해도 보존한다. */
public record ParsedClip(
int clipNo,
String capcutJson,
String titleCandidates,
String durationTable,
String titleTop,
String titleMain
) {}

View File

@ -0,0 +1,10 @@
package com.hlab.yanalyst.domain.shortform;
public enum ShortformCategory {
ENTERTAINMENT,
POLITICS;
public static ShortformCategory normalize(ShortformCategory value) {
return value == null ? ENTERTAINMENT : value;
}
}

View File

@ -0,0 +1,51 @@
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;
}
}

View File

@ -0,0 +1,64 @@
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}/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);
}
}

View File

@ -0,0 +1,159 @@
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;
@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 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·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;
}
}

View File

@ -0,0 +1,25 @@
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);
@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);
}

View File

@ -0,0 +1,3 @@
package com.hlab.yanalyst.domain.shortform;
public enum ShortformJobStatus { PENDING, DONE, FAILED }

View File

@ -0,0 +1,154 @@
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;
}
}

View File

@ -0,0 +1,133 @@
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;
/** 배포·재시작으로 중단된 비동기 자막 수집을 애플리케이션 기동 후 다시 시작한다. */
@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()));
}
/** 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.subtitleStatusOrDefault() != SubtitleStatus.READY
&& job.subtitleStatusOrDefault() != SubtitleStatus.COLLECTING) {
job.markSubtitleCollecting("자막 수집 대기 중");
scheduleSubtitleAfterCommit(job.getId());
}
return JobDetail.from(job);
}
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 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);
}
}

View File

@ -0,0 +1,206 @@
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) {
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) {
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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;");
}
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) {
// 프로세스 종료 스트림이 닫히는 것은 정상이다.
}
}
}

View File

@ -0,0 +1,187 @@
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) {
// 프로세스 종료 스트림이 닫힐 있다.
}
}
}

View File

@ -0,0 +1,9 @@
package com.hlab.yanalyst.domain.shortform;
public enum SubtitleStatus {
NOT_REQUESTED,
COLLECTING,
READY,
UNAVAILABLE,
FAILED
}

View File

@ -0,0 +1,21 @@
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();
}
}

View File

@ -0,0 +1,51 @@
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 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.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 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.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());
}
}
}

View File

@ -1,6 +1,7 @@
package com.hlab.yanalyst.global.config; package com.hlab.yanalyst.global.config;
import org.springframework.boot.web.client.RestTemplateBuilder; import org.springframework.boot.web.client.RestTemplateBuilder;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.web.client.RestTemplate; import org.springframework.web.client.RestTemplate;
@ -28,10 +29,13 @@ public class RestTemplateConfig {
* (주입 이름 {@code pythonRestTemplate} 구분 다른 곳의 restTemplate 주입에는 영향 없음) * (주입 이름 {@code pythonRestTemplate} 구분 다른 곳의 restTemplate 주입에는 영향 없음)
*/ */
@Bean @Bean
public RestTemplate pythonRestTemplate(RestTemplateBuilder builder) { public RestTemplate pythonRestTemplate(
RestTemplateBuilder builder,
@Value("${python.connect-timeout-seconds:20}") long connectTimeoutSeconds,
@Value("${python.read-timeout-minutes:30}") long readTimeoutMinutes) {
return builder return builder
.connectTimeout(Duration.ofSeconds(20)) .connectTimeout(Duration.ofSeconds(connectTimeoutSeconds))
.readTimeout(Duration.ofMinutes(10)) .readTimeout(Duration.ofMinutes(readTimeoutMinutes))
.build(); .build();
} }

View File

@ -0,0 +1,81 @@
package com.hlab.yanalyst.global.notify;
import lombok.extern.slf4j.Slf4j;
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.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import org.springframework.web.client.RestTemplate;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* 텔레그램 봇으로 알림 메시지를 발송한다.
* bot-token / chat-id 설정되지 않았으면 조용히 no-op 하며,
* 발송 예외는 삼켜서 호출측(스케줄러 ) 깨뜨리지 않는다.
*/
@Slf4j
@Service
public class TelegramNotifier {
/** 프로젝트에 RestTemplate 빈이 3개 있으므로 반드시 한정자로 기본 빈을 지정한다. */
private final RestTemplate restTemplate;
@Value("${hlab.notify.telegram.bot-token:}")
private String botToken;
@Value("${hlab.notify.telegram.chat-id:}")
private String chatId;
public TelegramNotifier(@Qualifier("restTemplate") RestTemplate restTemplate) {
this.restTemplate = restTemplate;
}
/** bot-token 과 chat-id 가 모두 설정되어 있으면 활성 상태. */
public boolean isEnabled() {
return StringUtils.hasText(botToken) && StringUtils.hasText(chatId);
}
/**
* 텔레그램으로 메시지를 발송한다. parse_mode=HTML 사용하므로
* text 사용자 데이터가 들어갈 경우 호출측에서 HTML 특수문자를 이스케이프해야 한다.
*
* @return 발송 성공 true, 미설정/실패 false
*/
public boolean sendMessage(String text) {
if (!isEnabled()) {
log.debug("[Telegram] 미설정 — 발송 생략");
return false;
}
try {
String url = "https://api.telegram.org/bot" + botToken + "/sendMessage";
Map<String, Object> body = new LinkedHashMap<>();
body.put("chat_id", chatId);
body.put("text", text);
body.put("parse_mode", "HTML");
body.put("disable_web_page_preview", true);
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
HttpEntity<Map<String, Object>> entity = new HttpEntity<>(body, headers);
ResponseEntity<String> response = restTemplate.postForEntity(url, entity, String.class);
if (response.getStatusCode().is2xxSuccessful()) {
log.info("[Telegram] 발송 성공 (status={})", response.getStatusCode());
return true;
}
log.warn("[Telegram] 발송 실패 (status={})", response.getStatusCode());
return false;
} catch (Exception e) {
// 알림 실패가 호출측 로직을 깨지 않도록 예외를 삼킨다.
log.warn("[Telegram] 발송 실패", e);
return false;
}
}
}

View File

@ -3,6 +3,8 @@ package com.hlab.yanalyst.global.schedule;
import com.hlab.yanalyst.domain.channel.Channel; import com.hlab.yanalyst.domain.channel.Channel;
import com.hlab.yanalyst.domain.channel.ChannelRepository; import com.hlab.yanalyst.domain.channel.ChannelRepository;
import com.hlab.yanalyst.domain.channel.ChannelService; import com.hlab.yanalyst.domain.channel.ChannelService;
import com.hlab.yanalyst.domain.channel.RecommendedChannel;
import com.hlab.yanalyst.global.notify.TelegramNotifier;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
@ -26,10 +28,15 @@ public class ScheduledCollectionService {
private final ChannelService channelService; private final ChannelService channelService;
private final YoutubeQuotaGuard quotaGuard; private final YoutubeQuotaGuard quotaGuard;
private final com.hlab.yanalyst.domain.channel.ChannelDiscoveryService channelDiscoveryService; private final com.hlab.yanalyst.domain.channel.ChannelDiscoveryService channelDiscoveryService;
private final TelegramNotifier telegramNotifier;
private final com.hlab.yanalyst.domain.channel.RecommendedChannelRepository recommendedChannelRepository;
@Value("${hlab.scheduler.channel-collection.enabled:true}") @Value("${hlab.scheduler.channel-collection.enabled:true}")
private boolean enabled; private boolean enabled;
@Value("${hlab.notify.telegram.morning-digest-top-n:5}")
private int digestTopN;
@Value("${hlab.scheduler.channel-snapshot.enabled:true}") @Value("${hlab.scheduler.channel-snapshot.enabled:true}")
private boolean snapshotEnabled; private boolean snapshotEnabled;
@ -45,6 +52,58 @@ public class ScheduledCollectionService {
} }
Map<String, Object> result = channelDiscoveryService.runDiscovery(); Map<String, Object> result = channelDiscoveryService.runDiscovery();
log.info("[Scheduler] 추천 채널 발굴 완료: {}", result); log.info("[Scheduler] 추천 채널 발굴 완료: {}", result);
// 발굴 직후 텔레그램 아침 다이제스트 발송(설정된 경우에만).
// 알림 실패가 발굴 스케줄을 깨지 않도록 전체를 try/catch 감싼다.
if (telegramNotifier.isEnabled()) {
try {
sendMorningDigest();
} catch (Exception e) {
log.warn("[Scheduler] 아침 다이제스트 발송 중 오류(무시)", e);
}
}
}
/** NEW 추천 채널 상위 N개를 한국어 HTML 다이제스트로 만들어 텔레그램으로 발송. */
private void sendMorningDigest() {
List<RecommendedChannel> all = recommendedChannelRepository.findByStatusOrderByRatioDesc("NEW");
List<RecommendedChannel> top = all.size() > digestTopN ? all.subList(0, digestTopN) : all;
if (top.isEmpty()) {
log.info("[Scheduler] 아침 다이제스트: NEW 추천 채널 없음 — 발송 생략");
return;
}
StringBuilder sb = new StringBuilder();
sb.append("🎬 <b>오늘의 발굴 추천 채널 TOP ").append(top.size()).append("</b>");
int i = 1;
for (RecommendedChannel rc : top) {
String title = esc(rc.getChannelTitle());
String subs = rc.getSubscriberCount() == null ? "-" : String.format("%,d", rc.getSubscriberCount());
String ratioStr = rc.getRatio() == null ? "-" : String.format("%.0f", rc.getRatio());
String videoId = rc.getTopVideoId();
sb.append("\n\n").append(i).append(". <b>").append(title).append("</b> (구독 ").append(subs).append(")");
sb.append("\n 배율 ").append(ratioStr).append("×");
if (videoId != null && !videoId.isBlank()) {
sb.append(" · <a href=\"https://www.youtube.com/watch?v=")
.append(esc(videoId))
.append("\">대표영상</a>");
}
i++;
}
boolean sent = telegramNotifier.sendMessage(sb.toString());
log.info("[Scheduler] 아침 다이제스트 발송 결과: sent={}, count={}", sent, top.size());
}
/** HTML parse_mode 용 특수문자 이스케이프. null 은 "-" 로 방어. */
private String esc(String s) {
if (s == null) {
return "-";
}
return s.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;");
} }
/** 채널 1개 수집의 추정 쿼터 소비량(playlistItems + videos.list, 대략치). */ /** 채널 1개 수집의 추정 쿼터 소비량(playlistItems + videos.list, 대략치). */
@ -65,7 +124,8 @@ public class ScheduledCollectionService {
/** 수동/스케줄 공용. 모든 등록 채널을 쿼터 한도 내에서 수집하고 결과 요약을 반환. */ /** 수동/스케줄 공용. 모든 등록 채널을 쿼터 한도 내에서 수집하고 결과 요약을 반환. */
public Map<String, Object> runChannelCollection() { public Map<String, Object> runChannelCollection() {
List<Channel> channels = channelRepository.findAll(); // 벤치마킹 채널 + 채널. 피드 시드(SOURCE/RIVAL) FeedCollectionService 3시간마다 따로 수집한다.
List<Channel> channels = channelRepository.findTrackedChannels();
int ok = 0, failed = 0, skippedByQuota = 0; int ok = 0, failed = 0, skippedByQuota = 0;
for (Channel c : channels) { for (Channel c : channels) {
@ -104,7 +164,8 @@ public class ScheduledCollectionService {
/** 모든 채널의 통계를 갱신하며 일별 성장 스냅샷을 기록. */ /** 모든 채널의 통계를 갱신하며 일별 성장 스냅샷을 기록. */
public Map<String, Object> runChannelSnapshot() { public Map<String, Object> runChannelSnapshot() {
List<Channel> channels = channelRepository.findAll(); // 벤치마킹 채널 + 채널. 피드 시드(SOURCE/RIVAL) FeedCollectionService 3시간마다 따로 수집한다.
List<Channel> channels = channelRepository.findTrackedChannels();
int ok = 0, failed = 0, skippedByQuota = 0; int ok = 0, failed = 0, skippedByQuota = 0;
for (Channel c : channels) { for (Channel c : channels) {

View File

@ -3,7 +3,6 @@ package com.hlab.yanalyst.service;
import com.hlab.yanalyst.domain.category.CategoryService; import com.hlab.yanalyst.domain.category.CategoryService;
import com.hlab.yanalyst.domain.channel.ChannelVideo; import com.hlab.yanalyst.domain.channel.ChannelVideo;
import com.hlab.yanalyst.domain.channel.ChannelVideoCurationService; import com.hlab.yanalyst.domain.channel.ChannelVideoCurationService;
import com.hlab.yanalyst.domain.publish.PublishService;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
@ -14,7 +13,7 @@ import java.util.List;
import java.util.Map; import java.util.Map;
/** /**
* 대시보드용 단일 집계 파이프라인(수집큐레이션발행) 현황을 번에 묶어 반환한다. * 대시보드용 단일 집계 파이프라인(수집큐레이션재가공) 현황을 번에 묶어 반환한다.
* 도메인 서비스의 집계를 조합만 한다(리포지토리 직접 접근 없음). * 도메인 서비스의 집계를 조합만 한다(리포지토리 직접 접근 없음).
*/ */
@Service @Service
@ -24,13 +23,11 @@ public class DashboardService {
private final ChannelVideoCurationService curationService; private final ChannelVideoCurationService curationService;
private final CategoryService categoryService; private final CategoryService categoryService;
private final PublishService publishService;
public Map<String, Object> summary() { public Map<String, Object> summary() {
Map<String, Object> result = new LinkedHashMap<>(); Map<String, Object> result = new LinkedHashMap<>();
result.put("pipeline", curationService.pipelineStats()); // total, byStatus, bySource, shorts, longForm result.put("pipeline", curationService.pipelineStats()); // total, byStatus, bySource, shorts, longForm
result.put("categories", categoryService.distribution()); // categories[], uncategorized result.put("categories", categoryService.distribution()); // categories[], uncategorized
result.put("publish", publishService.dashboardSummary()); // byStatus, total, recent
List<ChannelVideo> outperformers = curationService.findOutperformers(5, BigDecimal.ONE); List<ChannelVideo> outperformers = curationService.findOutperformers(5, BigDecimal.ONE);
result.put("outperformers", outperformers); result.put("outperformers", outperformers);
return result; return result;

View File

@ -1,12 +1,13 @@
package com.hlab.yanalyst.service; package com.hlab.yanalyst.service;
import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.hlab.yanalyst.global.schedule.YoutubeQuotaGuard; import com.hlab.yanalyst.global.schedule.YoutubeQuotaGuard;
import com.hlab.yanalyst.web.dto.CommentCardDto; import com.hlab.yanalyst.web.dto.CommentCardDto;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.web.client.HttpClientErrorException; import org.springframework.web.client.RestClientResponseException;
import org.springframework.web.client.RestTemplate; import org.springframework.web.client.RestTemplate;
import org.springframework.web.util.UriComponentsBuilder; import org.springframework.web.util.UriComponentsBuilder;
@ -27,8 +28,17 @@ public class YoutubeCommentService {
@Value("${youtube.api.key}") @Value("${youtube.api.key}")
private String youtubeApiKey; private String youtubeApiKey;
/** 응답 크기/쿼터 보호용 최대 페이지 수 (페이지당 최대 100개). */ /**
private static final int MAX_PAGES = 5; * 전체 댓글을 수집하되, 폭주(초대형 영상)·쿼터 보호용 안전 상한.
* 페이지당 최대 100개 최대 20,000개. 대부분 영상은 이전에 nextPageToken이 끝나 전체 수집됨.
*/
private static final int MAX_PAGES = 200;
private static final ObjectMapper MAPPER = new ObjectMapper();
/** 일시적 오류(transient 403/5xx) 재시도 횟수와 백오프(ms). 테스트에서 0으로 낮춤. */
private static final int MAX_RETRIES = 2;
private long retryBackoffMs = 600;
public List<CommentCardDto> fetchComments(String urlOrId) { public List<CommentCardDto> fetchComments(String urlOrId) {
String videoId = YoutubeVideoIdParser.parse(urlOrId); String videoId = YoutubeVideoIdParser.parse(urlOrId);
@ -55,12 +65,11 @@ public class YoutubeCommentService {
JsonNode root; JsonNode root;
try { try {
root = restTemplate.getForObject(b.build().encode().toUri(), JsonNode.class); root = fetchPageWithRetry(b.build().encode().toUri());
} catch (HttpClientErrorException.Forbidden e) { } catch (RestClientResponseException e) {
if (result.isEmpty()) { // 일부라도 모았으면 그걸로 반환(다음 페이지 실패는 무시)
throw new IllegalArgumentException("이 영상은 댓글이 비활성화되어 있거나 접근할 수 없습니다."); if (!result.isEmpty()) break;
} throw new IllegalArgumentException(messageFor(extractReason(e.getResponseBodyAsString())));
break;
} }
if (root == null || !root.has("items")) break; if (root == null || !root.has("items")) break;
@ -84,4 +93,67 @@ public class YoutubeCommentService {
return result; return result;
} }
/**
* 페이지 1개를 호출하되, 일시적 오류(generic 403 forbidden, rateLimitExceeded, 5xx ) 재시도한다.
* 회복 불가능한 오류(commentsDisabled/quotaExceeded ) 즉시 전파해 불필요한 대기를 막는다.
*/
private JsonNode fetchPageWithRetry(java.net.URI uri) {
RestClientResponseException last = null;
for (int attempt = 0; attempt <= MAX_RETRIES; attempt++) {
try {
return restTemplate.getForObject(uri, JsonNode.class);
} catch (RestClientResponseException e) {
if (isNonRetryable(extractReason(e.getResponseBodyAsString()))) {
throw e;
}
last = e;
if (attempt < MAX_RETRIES) {
sleepQuietly(retryBackoffMs * (attempt + 1));
}
}
}
throw last;
}
/** 재시도해도 회복되지 않는 reason(영구적 거부). */
private static boolean isNonRetryable(String reason) {
return "commentsDisabled".equals(reason)
|| "quotaExceeded".equals(reason)
|| "dailyLimitExceeded".equals(reason);
}
private static void sleepQuietly(long ms) {
try {
Thread.sleep(ms);
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
}
}
/** YouTube 오류 응답 본문에서 error.errors[0].reason 추출. 실패 시 null. */
private static String extractReason(String body) {
if (body == null || body.isBlank()) return null;
try {
JsonNode errs = MAPPER.readTree(body).path("error").path("errors");
if (errs.isArray() && errs.size() > 0) {
String reason = errs.get(0).path("reason").asText(null);
if (reason != null && !reason.isBlank()) return reason;
}
} catch (Exception ignore) {
// 본문이 JSON이 아니면 reason 없음
}
return null;
}
/** YouTube 오류 reason → 사용자용 안내 메시지. */
private static String messageFor(String reason) {
if ("commentsDisabled".equals(reason)) {
return "이 영상은 댓글이 비활성화되어 있습니다.";
}
if ("quotaExceeded".equals(reason) || "dailyLimitExceeded".equals(reason) || "rateLimitExceeded".equals(reason)) {
return "YouTube API 일일 쿼터를 초과했습니다. 잠시 후 다시 시도해주세요.";
}
return "YouTube API 호출에 실패했습니다" + (reason != null ? " (" + reason + ")" : "") + ".";
}
} }

View File

@ -21,7 +21,7 @@ public class DashboardApiController {
@GetMapping("/summary") @GetMapping("/summary")
@Operation(summary = "대시보드 요약", @Operation(summary = "대시보드 요약",
description = "파이프라인(수집/상태/출처/포맷) + 카테고리 분포 + 발행 현황 + 떡상 후보 TOP5 를 한 번에 반환.") description = "파이프라인(수집/상태/출처/포맷) + 카테고리 분포 + 떡상 후보 TOP5 를 한 번에 반환.")
public ApiResponse<Map<String, Object>> summary() { public ApiResponse<Map<String, Object>> summary() {
return ApiResponse.ok(dashboardService.summary()); return ApiResponse.ok(dashboardService.summary());
} }

View File

@ -52,18 +52,40 @@ public class WebController {
return "discover"; return "discover";
} }
@GetMapping("/my-channel")
public String myChannel(Model model) {
model.addAttribute("currentPage", "my-channel");
return "my_channel";
}
@GetMapping("/feed")
public String feed(Model model) {
model.addAttribute("currentPage", "feed");
model.addAttribute("feedTopic", "ENTERTAINMENT");
model.addAttribute("feedTitle", "소재 피드");
return "feed";
}
@GetMapping("/politics-feed")
public String politicsFeed(Model model) {
model.addAttribute("currentPage", "politics-feed");
model.addAttribute("feedTopic", "POLITICS");
model.addAttribute("feedTitle", "정치 피드");
return "feed";
}
@GetMapping("/shortform")
public String shortform(Model model) {
model.addAttribute("currentPage", "shortform");
return "shortform";
}
@GetMapping("/recommend") @GetMapping("/recommend")
public String recommend(Model model) { public String recommend(Model model) {
model.addAttribute("currentPage", "recommend"); model.addAttribute("currentPage", "recommend");
return "recommend"; return "recommend";
} }
@GetMapping("/publish")
public String publish(Model model) {
model.addAttribute("currentPage", "publish");
return "publish";
}
@GetMapping("/rework/{id}") @GetMapping("/rework/{id}")
public String rework(@org.springframework.web.bind.annotation.PathVariable Long id, Model model) { public String rework(@org.springframework.web.bind.annotation.PathVariable Long id, Model model) {
model.addAttribute("currentPage", "collection"); model.addAttribute("currentPage", "collection");
@ -71,6 +93,12 @@ public class WebController {
return "rework"; return "rework";
} }
@GetMapping("/reply")
public String reply(Model model) {
model.addAttribute("currentPage", "reply");
return "reply";
}
@GetMapping("/comment-cards") @GetMapping("/comment-cards")
public String commentCards(Model model) { public String commentCards(Model model) {
model.addAttribute("currentPage", "comment-cards"); model.addAttribute("currentPage", "comment-cards");

View File

@ -26,7 +26,8 @@ spring:
decorator: decorator:
datasource: datasource:
p6spy: p6spy:
enable-logging: true # 운영 기본 OFF — 모든 SQL 로깅은 순수 오버헤드. 디버깅 시 P6SPY_LOGGING=true 로 켠다.
enable-logging: ${P6SPY_LOGGING:false}
multiline: true multiline: true
logging: slf4j logging: slf4j
log-message-format: com.hlab.yanalyst.global.config.P6SpyFormatter log-message-format: com.hlab.yanalyst.global.config.P6SpyFormatter
@ -53,6 +54,8 @@ youtube:
# Python 마이크로서비스(자막 /transcript, Whisper 전사 /transcribe). 동일 호스트면 http://localhost:8000 로 오버라이드. # Python 마이크로서비스(자막 /transcript, Whisper 전사 /transcribe). 동일 호스트면 http://localhost:8000 로 오버라이드.
python: python:
base-url: ${PYTHON_BASE_URL:http://h-python.tolag.shop} base-url: ${PYTHON_BASE_URL:http://h-python.tolag.shop}
connect-timeout-seconds: ${PYTHON_CONNECT_TIMEOUT_SECONDS:20}
read-timeout-minutes: ${PYTHON_READ_TIMEOUT_MINUTES:30} # 긴 영상 Whisper 전사·렌더 응답 대기
# yt-dlp 원본 다운로드(로컬 ProcessBuilder 실행). 재가공 '원본 다운로드' 버튼이 사용. # yt-dlp 원본 다운로드(로컬 ProcessBuilder 실행). 재가공 '원본 다운로드' 버튼이 사용.
ytdlp: ytdlp:
@ -61,7 +64,7 @@ ytdlp:
max-height: ${YTDLP_MAX_HEIGHT:1080} # bv*[height<=N]+ba/b, mp4 머지 max-height: ${YTDLP_MAX_HEIGHT:1080} # bv*[height<=N]+ba/b, mp4 머지
timeout-seconds: ${YTDLP_TIMEOUT_SECONDS:600} timeout-seconds: ${YTDLP_TIMEOUT_SECONDS:600}
download: download:
dir: ${DOWNLOAD_DIR:downloads} # 다운로드 캐시 폴더(작업 디렉토리 기준 상대경로 가능) dir: ${DOWNLOAD_DIR:downloads} # 원본 캐시 + 정치 큐 자막(subtitles/) 저장 폴더
# 자가호스팅 LibreTranslate(원본 스크립트 번역 → 재가공 초안). POST /translate {q,source,target}. # 자가호스팅 LibreTranslate(원본 스크립트 번역 → 재가공 초안). POST /translate {q,source,target}.
translate: translate:
@ -88,8 +91,40 @@ hlab:
daily-quota: ${YOUTUBE_DAILY_QUOTA:10000} # 자동 수집이 소비할 수 있는 일일 쿼터 상한(추정) daily-quota: ${YOUTUBE_DAILY_QUOTA:10000} # 자동 수집이 소비할 수 있는 일일 쿼터 상한(추정)
# 추천 채널 발굴: 지역 인기 Shorts 에서 작은구독자·고배율 떡상 채널 발굴 # 추천 채널 발굴: 지역 인기 Shorts 에서 작은구독자·고배율 떡상 채널 발굴
discovery: discovery:
regions: ${DISCOVERY_REGIONS:KR,JP,US} regions: ${DISCOVERY_REGIONS:KR}
max-subscribers: ${DISCOVERY_MAX_SUBS:100000} # 작은 채널 상한 max-subscribers: ${DISCOVERY_MAX_SUBS:100000} # 작은 채널 상한
min-ratio: ${DISCOVERY_MIN_RATIO:5.0} # 떡상 배율 하한 min-ratio: ${DISCOVERY_MIN_RATIO:5.0} # 떡상 배율 하한
period-days: ${DISCOVERY_PERIOD_DAYS:14} # 최근 N일 영상 대상 period-days: ${DISCOVERY_PERIOD_DAYS:14} # 최근 N일 영상 대상
top-n: ${DISCOVERY_TOP_N:30} top-n: ${DISCOVERY_TOP_N:30}
# 예능 특화 시드 키워드(쉼표구분). 각 키워드×지역으로 Shorts 검색해 예능 클립 소재를 우선 발굴.
# 빈 값이면 키워드 없는 기존 '지역 인기 Shorts' 발굴로 동작.
keywords: ${DISCOVERY_KEYWORDS:예능,방송,하이라이트}
# 발굴 대상 포맷 기본값: LONG_FORM(롱폼) | SHORTS. UI '지금 발굴'에서 건별 선택 가능.
format: ${DISCOVERY_FORMAT:LONG_FORM}
# 소재 발굴 피드: 소스(웹예능 공식채널)·경쟁(클립 채널)의 신규 롱폼만 최신순으로 수집 (10분 미만 제외)
feed:
enabled: ${FEED_ENABLED:true}
cron: ${FEED_CRON:0 15 * * * *} # 1시간마다 (소재 선점은 업로드 후 몇 시간이 승부 — 채널당 2 units라 부담 없음)
period-days: ${FEED_PERIOD_DAYS:14} # 이보다 오래된 업로드는 수집하지 않음
min-duration-sec: ${FEED_MIN_DURATION_SEC:600} # 이보다 짧은 영상은 수집·표시하지 않음 (기본 10분)
seed:
max-keywords: ${FEED_SEED_MAX_KEYWORDS:8} # 시드 자동 발굴 시 사용할 상위 해시태그 수(1개당 100 units)
min-tag-count: ${FEED_SEED_MIN_TAG_COUNT:2} # 이 횟수 미만 해시태그는 프로그램명으로 보지 않음
# 인물 추적: 인물명으로 검색해 롱폼 등장분을 찾는다. 1명당 101 units 라 하루 1회만 돈다.
person:
enabled: ${FEED_PERSON_ENABLED:true}
cron: ${FEED_PERSON_CRON:0 45 5 * * *} # 매일 05:45 (채널 수집·발굴 뒤)
max-people: ${FEED_PERSON_MAX:10} # 활성 인물 상한 — 10명이면 1,010 units/일
period-days: ${FEED_PERSON_PERIOD_DAYS:14}
# 인물명 검색엔 팬채널·커버곡·개인 브이로그가 대량으로 딸려 온다(실측 30회 미만).
# 생 조회수로 자르면 방금 올라온 영상까지 걸리므로 시간당 조회수로 거른다.
min-views-per-hour: ${FEED_PERSON_MIN_VPH:20}
# 텔레그램 알림: 아침 추천 다이제스트 + 피드 골든타임 소재(신규 수집분이 업로드 24h 이내일 때).
# 토큰/챗ID 없으면 자동 no-op.
notify:
telegram:
bot-token: ${TELEGRAM_BOT_TOKEN:8996580856:AAH3DhjiAZikcvyZP10I7fEXFb1spFhr7Hw}
chat-id: ${TELEGRAM_CHAT_ID:1864593285}
morning-digest-top-n: ${TELEGRAM_DIGEST_TOP_N:5}

View File

@ -1,9 +1,27 @@
(function () { (function () {
window.__cards = []; window.__all = []; // 전체 수집 댓글(분석 기준)
let mosaicOn = false; window.__cards = []; // 호환용 별칭
window.__videoId = null; // 타임라인 링크용
let mosaicOn = true; // 프로필·아이디 모자이크 기본 ON
let roundedOn = true; // 모서리 둥글게 토글(기본: 둥근 모서리)
let wordMaskOn = true; // 단어 가리기 기본 ON(단어 클릭=가림). 끄면 카드 클릭=복사.
let textScale = 1; // 내용 글자 크기 배율(카드 크기는 유지, .cc-text만 확대/축소)
let bgMode = 'black'; // 카드 배경 기본값: default|white|black (black이면 글자 흰색)
let exportScale = 4; // PNG 저장 배율(카드 CSS 픽셀 × 배율). 영상에서 확대해도 안 깨지게 기본 4배.
let dirHandle = null; // 저장 폴더(File System Access API). null이면 브라우저 다운로드 폴백.
let dirOptOut = false; // 사용자가 폴더 지정을 직접 해제 → '전체 저장' 시 다시 묻지 않음
const savedIds = new Set(); // 이미 저장한 댓글 id(재렌더·필터 변경에도 표시 유지)
const DISPLAY_CAP = 2000; // 카드 DOM 렌더 상한(브라우저 보호). 분석은 전체 기준.
const $ = (id) => document.getElementById(id); const $ = (id) => document.getElementById(id);
function esc(s) {
const d = document.createElement('div');
d.textContent = String(s == null ? '' : s);
return d.innerHTML;
}
function timeAgo(iso) { function timeAgo(iso) {
if (!iso) return ''; if (!iso) return '';
const then = new Date(iso).getTime(); const then = new Date(iso).getTime();
@ -24,11 +42,73 @@
return tmp.textContent || ''; return tmp.textContent || '';
} }
// URL/ID → videoId (백엔드 YoutubeVideoIdParser 미러)
function parseVideoId(s) {
s = (s || '').trim();
if (/^[A-Za-z0-9_-]{11}$/.test(s)) return s;
const pats = [
/[?&]v=([A-Za-z0-9_-]{11})/,
/youtu\.be\/([A-Za-z0-9_-]{11})/,
/\/shorts\/([A-Za-z0-9_-]{11})/,
/\/embed\/([A-Za-z0-9_-]{11})/,
];
for (const p of pats) { const m = s.match(p); if (m) return m[1]; }
return null;
}
// 제외 단어: 쉼표로 구분(검색어와 동일 규칙). 하나라도 포함되면 검색어와 무관하게 카드·분석 모두에서 제외.
// 공백은 구분자가 아니므로 "정말 재밌"처럼 구절 단위 제외도 가능.
function getExcludeTerms() {
const raw = ($('ccExclude') && $('ccExclude').value) || '';
return raw.split(',').map(s => s.trim().toLowerCase()).filter(Boolean);
}
// 검색어: 쉼표로 구분하면 OR 검색(하나라도 포함되면 표시). 공백은 구분자가 아님 → 구절 검색 유지.
function getSearchTerms() {
const raw = ($('ccSearch') && $('ccSearch').value) || '';
return raw.split(',').map(s => s.trim().toLowerCase()).filter(Boolean);
}
function excludeComments(list, terms) {
if (!terms || !terms.length) return list;
return list.filter(c => {
const t = toPlainText(c.text).toLowerCase();
return !terms.some(term => t.includes(term));
});
}
function debounce(fn, ms) {
let h = null;
return function () { clearTimeout(h); h = setTimeout(fn, ms); };
}
// 댓글 텍스트를 단어 span으로 분해(공백/줄바꿈 보존). 단어 가리기 ON일 때 클릭하면 해당 단어만 블러.
function buildWords(el, plain) {
el.textContent = '';
for (const p of plain.split(/(\s+)/)) {
if (!p) continue;
if (/^\s+$/.test(p)) { el.appendChild(document.createTextNode(p)); continue; }
const s = document.createElement('span');
s.className = 'cc-word';
s.textContent = p;
s.addEventListener('click', (e) => {
if (!wordMaskOn) return; // 꺼짐 모드: 무시 → 이벤트 버블 → 카드 클릭=복사
e.stopPropagation();
s.classList.toggle('masked');
});
el.appendChild(s);
}
}
function applyFilterSort(cards) { function applyFilterSort(cards) {
const sort = $('ccSort').value; const sort = $('ccSort').value;
const minLikes = parseInt($('ccMinLikes').value, 10) || 0; const minLikes = parseInt($('ccMinLikes').value, 10) || 0;
const repliesOnly = $('ccRepliesOnly').checked; const repliesOnly = $('ccRepliesOnly').checked;
let list = cards.filter(c => (c.likeCount || 0) >= minLikes && (!repliesOnly || (c.replyCount || 0) > 0)); const terms = getSearchTerms();
let list = cards.filter(c => {
if ((c.likeCount || 0) < minLikes) return false;
if (repliesOnly && (c.replyCount || 0) <= 0) return false;
if (!terms.length) return true;
const t = toPlainText(c.text).toLowerCase(); // 내용에만 매칭
return terms.some(term => t.includes(term)); // 쉼표 구분 = OR
});
if (sort === 'likes') list.sort((a, b) => (b.likeCount || 0) - (a.likeCount || 0)); if (sort === 'likes') list.sort((a, b) => (b.likeCount || 0) - (a.likeCount || 0));
else if (sort === 'replies') list.sort((a, b) => (b.replyCount || 0) - (a.replyCount || 0)); else if (sort === 'replies') list.sort((a, b) => (b.replyCount || 0) - (a.replyCount || 0));
else if (sort === 'latest') list.sort((a, b) => new Date(b.publishedAt) - new Date(a.publishedAt)); else if (sort === 'latest') list.sort((a, b) => new Date(b.publishedAt) - new Date(a.publishedAt));
@ -37,13 +117,25 @@
window.renderCards = function () { window.renderCards = function () {
const grid = $('cardGrid'); const grid = $('cardGrid');
const list = applyFilterSort(window.__cards); const base = excludeComments(window.__all, getExcludeTerms());
$('ccCount').textContent = list.length + '개'; let list = applyFilterSort(base);
const total = list.length;
const capped = total > DISPLAY_CAP;
if (capped) list = list.slice(0, DISPLAY_CAP);
$('ccCount').textContent = total.toLocaleString() + '개'
+ (capped ? ' (상위 ' + DISPLAY_CAP.toLocaleString() + '개 표시)' : '');
grid.classList.toggle('wordmask', wordMaskOn);
grid.innerHTML = ''; grid.innerHTML = '';
list.forEach((c, i) => { list.forEach((c, i) => {
const card = document.createElement('div'); const card = document.createElement('div');
card.className = 'comment-card' + (mosaicOn ? ' mosaic' : ''); card.className = 'comment-card'
+ (mosaicOn ? ' mosaic' : '')
+ (roundedOn ? ' rounded' : '')
+ (bgMode === 'white' ? ' bg-white' : bgMode === 'black' ? ' bg-black' : '')
+ (savedIds.has(c.__id) ? ' saved' : ''); // 이미 저장한 카드는 회색 톤 유지
if (textScale !== 1) card.style.setProperty('--cc-text-scale', String(textScale));
card.dataset.index = i; card.dataset.index = i;
card.dataset.cid = c.__id;
const avatarSrc = c.profileImageUrl const avatarSrc = c.profileImageUrl
? '/api/comment-cards/avatar?url=' + encodeURIComponent(c.profileImageUrl) ? '/api/comment-cards/avatar?url=' + encodeURIComponent(c.profileImageUrl)
@ -52,7 +144,7 @@
const head = document.createElement('div'); const head = document.createElement('div');
head.className = 'cc-head'; head.className = 'cc-head';
head.innerHTML = head.innerHTML =
'<img class="cc-avatar" crossorigin="anonymous" alt="" src="' + avatarSrc + '">' + '<img class="cc-avatar" crossorigin="anonymous" loading="lazy" decoding="async" alt="" src="' + avatarSrc + '">' +
'<div class="cc-meta">' + '<div class="cc-meta">' +
'<div><span class="cc-author"></span><span class="cc-time"></span></div>' + '<div><span class="cc-author"></span><span class="cc-time"></span></div>' +
'<div class="cc-text"></div>' + '<div class="cc-text"></div>' +
@ -61,26 +153,274 @@
'</div>'; '</div>';
head.querySelector('.cc-author').textContent = c.authorName || ''; head.querySelector('.cc-author').textContent = c.authorName || '';
head.querySelector('.cc-time').textContent = timeAgo(c.publishedAt); head.querySelector('.cc-time').textContent = timeAgo(c.publishedAt);
head.querySelector('.cc-text').textContent = toPlainText(c.text); buildWords(head.querySelector('.cc-text'), toPlainText(c.text));
const copyBtn = document.createElement('button'); // 단어 가리기 ON: 카드 클릭 복사 잠금(단어 클릭으로 가림). OFF: 카드 클릭=복사.
copyBtn.className = 'cc-btn secondary cc-copy'; card.title = '단어 클릭 = 가리기 · 토글을 끄면 카드 클릭 = 복사';
copyBtn.textContent = '복사'; card.setAttribute('role', 'button');
copyBtn.addEventListener('click', () => window.copyCard(card, copyBtn)); // Task 5에서 정의 card.setAttribute('tabindex', '0');
card.setAttribute('aria-label', (c.authorName || '댓글') + ' 카드 · 복사');
card.addEventListener('click', () => { if (wordMaskOn) return; window.copyCard(card); });
card.addEventListener('keydown', (e) => { // 키보드 접근: Enter/Space로 복사
if (wordMaskOn) return;
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); window.copyCard(card); }
});
// 저장(다운로드) 버튼 — cc-noexport라 캡처 이미지엔 안 찍힘. 캡컷엔 이 파일을 불러오기.
const actions = document.createElement('div');
actions.className = 'cc-actions cc-noexport';
const saveBtn = document.createElement('button');
saveBtn.type = 'button';
saveBtn.className = 'cc-iconbtn';
saveBtn.addEventListener('click', (e) => { e.stopPropagation(); window.downloadCard(card, c); });
setSaveBtn(saveBtn, savedIds.has(c.__id), false);
actions.appendChild(saveBtn);
card.appendChild(head); card.appendChild(head);
card.appendChild(copyBtn); card.appendChild(actions);
grid.appendChild(card); grid.appendChild(card);
}); });
if (window.lucide) window.lucide.createIcons(); if (window.lucide) window.lucide.createIcons();
}; };
/* ---------------------------------------------------------------------
* 분석 (전부 클라이언트 계산 추가 API 쿼터 0)
* ------------------------------------------------------------------- */
const KO_STOP = new Set(('그리고 그런데 그래서 하지만 그러나 그냥 정말 진짜 너무 완전 이거 저거 그거 이건 저건 그건 근데 ' +
'이렇게 저렇게 그렇게 하는 하고 해서 있는 있고 없는 같아요 같은 같다 되게 무슨 어떤 이런 저런 그런 여기 저기 거기 ' +
'우리 저는 제가 요즘 지금 오늘 내가 니가 네가 저도 나도 이제 그리 그럼 아니 아마 많이 조금 그때 이때 대한 위해 ' +
'부분 사람 생각 때문 정도 그게 이게 저게 그건데 뭔가 거의 계속 진짜로 그거는').split(/\s+/));
const EN_STOP = new Set(('the a an and or but if is are was were be been to of in on for it this that with you your my ' +
'me we he she they i so just not no yes at as by from about into out up down all can will do does did have has had ' +
'im its lol').split(/\s+/));
// 조사 꼬리 제거(휴리스틱) — 긴 것부터
const JOSA = ['으로써', '으로서', '에서는', '에게서', '이라는', '이라고', '으로', '로써', '로서', '에서', '에게', '한테',
'까지', '부터', '조차', '마저', '밖에', '처럼', '만큼', '보다', '이나', '이랑', '라는', '라고', '다는', '에는', '에도',
'든지', '이며', '이고', '은', '는', '이', '가', '을', '를', '에', '의', '도', '로', '과', '와', '만', '랑', '님', '들']
.sort((a, b) => b.length - a.length);
function stripJosa(w) {
for (const j of JOSA) {
if (w.length > j.length + 1 && w.endsWith(j)) return w.slice(0, -j.length);
}
return w;
}
function tokenize(text) {
const t = toPlainText(text).toLowerCase().replace(/https?:\/\/\S+/g, ' ');
const raw = t.replace(/[^\p{L}\p{N}\sㄱ-ㅎㅏ-ㅣ]/gu, ' ').split(/\s+/);
const out = [];
for (let w of raw) {
if (!w) continue;
if (/^[0-9]+$/.test(w)) continue; // 순수 숫자 제외
if (/[가-힣]/.test(w)) w = stripJosa(w); // 한글 어절이면 조사 제거
if (w.length < 2) continue;
if (KO_STOP.has(w) || EN_STOP.has(w)) continue;
out.push(w);
}
return out;
}
function computeWords(all, weighted) {
const map = new Map();
for (const c of all) {
const wt = weighted ? (1 + (c.likeCount || 0)) : 1;
for (const w of tokenize(c.text)) map.set(w, (map.get(w) || 0) + wt);
}
return [...map.entries()].sort((a, b) => b[1] - a[1]).slice(0, 20);
}
// 댓글 속 타임스탬프(mm:ss / h:mm:ss) 추출
const TS_RE = /(?<!\d)(\d{1,2}):([0-5]\d)(?::([0-5]\d))?(?!\d)/g;
function fmtTime(s) {
const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), ss = s % 60;
return (h ? h + ':' + String(m).padStart(2, '0') : m) + ':' + String(ss).padStart(2, '0');
}
function computeTimeline(all) {
const map = new Map();
for (const c of all) {
const txt = toPlainText(c.text);
let m; TS_RE.lastIndex = 0;
const seen = new Set(); // 한 댓글 내 같은 시각 중복 제거
while ((m = TS_RE.exec(txt))) {
const sec = m[3] !== undefined
? (+m[1]) * 3600 + (+m[2]) * 60 + (+m[3])
: (+m[1]) * 60 + (+m[2]);
if (seen.has(sec)) continue; seen.add(sec);
map.set(sec, (map.get(sec) || 0) + 1);
}
}
return [...map.entries()]
.sort((a, b) => b[1] - a[1]).slice(0, 15)
.map(([sec, cnt]) => ({ sec, cnt, label: fmtTime(sec) }));
}
function computeStats(all) {
let likes = 0, max = null;
const byAuthor = new Map(), byMonth = new Map();
for (const c of all) {
likes += c.likeCount || 0;
if (!max || (c.likeCount || 0) > (max.likeCount || 0)) max = c;
byAuthor.set(c.authorName, (byAuthor.get(c.authorName) || 0) + 1);
const mo = (c.publishedAt || '').slice(0, 7);
if (mo) byMonth.set(mo, (byMonth.get(mo) || 0) + 1);
}
const topAuthor = [...byAuthor.entries()].sort((a, b) => b[1] - a[1])[0];
const months = [...byMonth.entries()].sort((a, b) => a[0].localeCompare(b[0]));
return {
count: all.length, likes, avg: all.length ? likes / all.length : 0,
max, authors: byAuthor.size, topAuthor, months,
};
}
function tile(l, v) {
return '<div class="cc-tile"><div class="cc-tile-v">' + esc(v) + '</div><div class="cc-tile-l">' + esc(l) + '</div></div>';
}
function bar(label, val, max, suffix) {
const pct = max ? Math.max(4, Math.round(val / max * 100)) : 0;
return '<div class="cc-bar-row">'
+ '<span class="cc-bar-label">' + esc(label) + '</span>'
+ '<span class="cc-bar-track"><span class="cc-bar-fill" style="width:' + pct + '%"></span></span>'
+ '<span class="cc-bar-val">' + esc(val.toLocaleString()) + (suffix || '') + '</span></div>';
}
function renderAnalytics(all) {
const box = $('ccAnalytics');
const exTerms = getExcludeTerms();
all = excludeComments(all || [], exTerms);
if (!all.length) { box.style.display = 'none'; box.innerHTML = ''; return; }
const weighted = !!($('ccWeighted') && $('ccWeighted').checked);
const tlSort = ($('ccTlSort') && $('ccTlSort').value) || 'count';
const stats = computeStats(all);
let tl = computeTimeline(all); // 언급 많은 순 top-N 선별
if (tlSort === 'time') tl = tl.slice().sort((a, b) => a.sec - b.sec); // 선별본을 시간순 재정렬
const words = computeWords(all, weighted);
const vid = window.__videoId;
let html = '<div class="cc-an-head"><b><i data-lucide="bar-chart-3"></i> 분석</b> '
+ '<span class="cc-an-sub">' + all.length.toLocaleString() + '개 댓글 기준'
+ (exTerms.length ? ' · 제외어 적용' : '') + '</span>'
+ '<button type="button" class="cc-iconbtn" id="ccAnToggle" aria-expanded="true">접기</button></div>'
+ '<div id="ccAnBody">';
html += '<div class="cc-tiles">'
+ tile('총 댓글', stats.count.toLocaleString())
+ tile('총 좋아요', stats.likes.toLocaleString())
+ tile('평균 좋아요', stats.avg.toFixed(1))
+ tile('참여자', stats.authors.toLocaleString() + '명')
+ '</div>';
// 타임라인
html += '<div class="cc-an-sec"><div class="cc-an-sechead"><h4><i data-lucide="clock"></i> 많이 언급된 타임라인</h4>'
+ '<select id="ccTlSort" class="cc-mini-select">'
+ '<option value="count"' + (tlSort === 'count' ? ' selected' : '') + '>언급순</option>'
+ '<option value="time"' + (tlSort === 'time' ? ' selected' : '') + '>시간순</option>'
+ '</select></div>';
if (tl.length) {
const mx = tl[0].cnt;
html += '<div class="cc-bars">' + tl.map(t => {
const inner = bar(t.label, t.cnt, mx, '회');
return vid
? '<a class="cc-tl-link" href="https://www.youtube.com/watch?v=' + encodeURIComponent(vid) + '&t=' + t.sec + 's" target="_blank" rel="noopener">' + inner + '</a>'
: inner;
}).join('') + '</div>';
} else {
html += '<p class="cc-an-empty">댓글에 타임스탬프 언급이 없습니다.</p>';
}
html += '</div>';
// 단어
html += '<div class="cc-an-sec"><div class="cc-an-sechead"><h4><i data-lucide="type"></i> 자주 나온 단어</h4>'
+ '<label class="cc-wlabel"><input type="checkbox" id="ccWeighted"' + (weighted ? ' checked' : '') + '> 좋아요 가중</label></div>';
if (words.length) {
const mx = words[0][1];
html += '<div class="cc-bars">' + words.map(([w, n]) => bar(w, Math.round(n), mx)).join('') + '</div>';
} else {
html += '<p class="cc-an-empty">분석할 단어가 부족합니다.</p>';
}
html += '</div>';
// 최다 좋아요 댓글
if (stats.max) {
html += '<div class="cc-an-sec"><h4><i data-lucide="award"></i> 최다 좋아요 댓글</h4>'
+ '<div class="cc-top-comment"><b>' + esc(stats.max.authorName) + '</b> · 👍 '
+ (stats.max.likeCount || 0).toLocaleString()
+ '<div class="cc-top-text">' + esc(toPlainText(stats.max.text).slice(0, 200)) + '</div></div></div>';
}
// 월별 댓글량
if (stats.months.length > 1) {
const mx = Math.max(...stats.months.map(m => m[1]));
html += '<div class="cc-an-sec"><h4><i data-lucide="calendar"></i> 월별 댓글량</h4><div class="cc-months">'
+ stats.months.slice(-18).map(([mo, n]) =>
'<div class="cc-mo" title="' + esc(mo) + ': ' + n + '">'
+ '<span class="cc-mo-val">' + n.toLocaleString() + '</span>' // 값 상시 노출
+ '<span class="cc-mo-bar" style="height:' + Math.max(6, Math.round(n / mx * 70)) + 'px"></span>'
+ '<span class="cc-mo-lb">' + esc(mo.slice(2)) + '</span></div>').join('')
+ '</div></div>';
}
html += '</div>'; // #ccAnBody
box.innerHTML = html;
box.style.display = 'block';
if (window.lucide) window.lucide.createIcons(); // 섹션 헤더 아이콘(이모지 대체) 렌더
const tgl = $('ccAnToggle');
if (tgl) tgl.addEventListener('click', () => {
const b = $('ccAnBody');
const hidden = b.style.display === 'none';
b.style.display = hidden ? '' : 'none';
tgl.textContent = hidden ? '접기' : '펼치기';
tgl.setAttribute('aria-expanded', String(hidden));
});
const wt = $('ccWeighted');
if (wt) wt.addEventListener('change', () => renderAnalytics(window.__all));
const tls = $('ccTlSort');
if (tls) tls.addEventListener('change', () => renderAnalytics(window.__all));
}
/* ------------------------- 캡처/복사/저장 ------------------------- */
// 카드 저장 버튼의 저장 전/후 모습.
// renderCards 루프에서는 renderIcons=false — 아이콘은 렌더 끝에 한 번만 그린다(카드 수만큼 전체 스캔 방지).
function setSaveBtn(btn, saved, renderIcons) {
btn.classList.toggle('saved', saved);
btn.innerHTML = saved
? '<i data-lucide="check" style="width:13px;height:13px;"></i> 저장됨'
: '<i data-lucide="download" style="width:13px;height:13px;"></i> 저장';
btn.setAttribute('aria-label', saved ? '저장 완료 (다시 저장하려면 클릭)' : 'PNG 파일로 저장');
btn.title = saved ? '이미 저장한 카드 · 다시 저장하려면 클릭' : 'PNG 파일로 저장 (캡컷에서 불러오기)';
if (renderIcons && window.lucide) window.lucide.createIcons();
}
// 저장 완료 표시: 카드 회색 톤(.saved) + 버튼 '저장됨'. 캡처 이미지엔 반영 안 됨(captureBlob에서 제외).
function markSaved(cardEl) {
const cid = cardEl.dataset.cid;
if (cid !== undefined) savedIds.add(Number(cid));
cardEl.classList.add('saved');
const btn = cardEl.querySelector('.cc-actions .cc-iconbtn');
if (btn) setSaveBtn(btn, true, true);
}
async function captureBlob(cardEl) { async function captureBlob(cardEl) {
// modern-screenshot UMD 전역: window.modernScreenshot
const ms = window.modernScreenshot; const ms = window.modernScreenshot;
if (!ms || !ms.domToBlob) throw new Error('캡처 라이브러리 로드 실패'); if (!ms || !ms.domToBlob) throw new Error('캡처 라이브러리 로드 실패');
// backgroundColor:null → 카드 바깥 투명. scale 2 → 또렷하게. // 저장 표시(회색 톤)는 화면 전용 → 캡처 동안만 벗겨서 원본 그대로 찍는다
return await ms.domToBlob(cardEl, { backgroundColor: null, scale: 2 }); const wasSaved = cardEl.classList.contains('saved');
if (wasSaved) cardEl.classList.remove('saved');
try {
// scale = 저장 배율. 카드 폭이 ~320px라 2배로는 영상에서 확대 시 글자가 뭉개짐 → 기본 4배.
return await ms.domToBlob(cardEl, {
backgroundColor: null,
scale: exportScale,
filter: (node) => !(node instanceof Element && node.classList.contains('cc-noexport')),
});
} finally {
if (wasSaved) cardEl.classList.add('saved');
}
} }
function downloadBlob(blob) { function downloadBlob(blob) {
@ -91,38 +431,151 @@
URL.revokeObjectURL(a.href); URL.revokeObjectURL(a.href);
} }
window.copyCard = async function (cardEl, btnEl) { let toastTimer = null;
const original = btnEl.textContent; function showToast(msg) {
btnEl.disabled = true; btnEl.textContent = '복사 중…'; let t = document.getElementById('ccToast');
if (!t) {
t = document.createElement('div');
t.id = 'ccToast';
t.className = 'cc-toast';
document.body.appendChild(t);
}
t.textContent = msg;
t.classList.add('show');
if (toastTimer) clearTimeout(toastTimer);
toastTimer = setTimeout(() => t.classList.remove('show'), 1500);
}
function buildFileName(c) {
var who = (c && c.authorName) ? c.authorName.replace(/[^\w가-힣]+/g, '').slice(0, 20) : 'card';
return 'comment-' + (who || 'card') + '.png';
}
// 선택한 폴더에 blob을 파일로 씀(File System Access API)
async function writeToDir(blob, filename) {
const fh = await dirHandle.getFileHandle(filename, { create: true });
const w = await fh.createWritable();
await w.write(blob);
await w.close();
}
// 폴더가 선택돼 있으면 그 폴더로, 아니면 브라우저 다운로드 폴더로 저장
async function saveBlob(blob, filename) {
if (dirHandle) { await writeToDir(blob, filename); return true; }
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = filename;
a.click();
URL.revokeObjectURL(a.href);
return false;
}
// 폴더 지정 해제 → 이후 저장은 브라우저 기본 다운로드 폴더로
function clearDir() {
dirHandle = null;
dirOptOut = true;
$('ccPickDirName').textContent = '저장 폴더';
setToggle($('ccPickDir'), false);
showToast('폴더 지정 해제 → 다운로드 폴더로 저장');
}
// '저장 폴더' 버튼 — 지정돼 있으면 해제(다운로드 폴더), 아니면 폴더 선택
// 선택한 폴더는 세션 동안 이 사이트에만 적용(크롬 전역 다운로드 설정과 무관)
async function pickDir() {
if (dirHandle) { clearDir(); return; }
if (!window.showDirectoryPicker) {
showToast('이 브라우저는 폴더 저장 미지원 (크롬/엣지 권장)');
return;
}
try {
dirHandle = await window.showDirectoryPicker({ id: 'cc-save', mode: 'readwrite' });
dirOptOut = false;
$('ccPickDirName').textContent = dirHandle.name;
setToggle($('ccPickDir'), true);
showToast('저장 폴더: ' + dirHandle.name);
} catch (e) { /* 사용자가 취소 → 무시 */ }
}
// '전체 저장' — 현재 표시된 카드를 순서대로 저장(폴더 선택 시 그 폴더로 일괄)
async function saveAllVisible() {
const cards = Array.from(document.querySelectorAll('#cardGrid .comment-card'));
if (!cards.length) { showToast('저장할 카드가 없어요'); return; }
// 폴더 미선택 상태면 먼저 폴더를 고르게 한다. 단, 사용자가 직접 해제했으면 묻지 않고 다운로드 폴더로.
if (!dirHandle && !dirOptOut && window.showDirectoryPicker) {
await pickDir();
if (!dirHandle) return; // 취소
}
if (!dirHandle && !confirm(cards.length + '개 파일이 각각 다운로드됩니다. 계속할까요?')) return;
if (cards.length > 50 && !confirm(cards.length + '개를 저장할까요? 시간이 걸릴 수 있어요.')) return;
const btn = $('ccSaveAll');
btn.disabled = true;
const pad = String(cards.length).length;
let ok = 0, fail = 0;
for (let i = 0; i < cards.length; i++) {
btn.textContent = '저장 중… ' + (i + 1) + '/' + cards.length;
try {
const blob = await captureBlob(cards[i]);
await saveBlob(blob, 'comment-' + String(i + 1).padStart(pad, '0') + '.png');
markSaved(cards[i]);
ok++;
} catch (e) { fail++; }
}
btn.disabled = false;
btn.textContent = '전체 저장';
showToast('완료: ' + ok + '개 저장' + (fail ? ', ' + fail + ' 실패' : '')
+ (dirHandle ? ' → ' + dirHandle.name : ' (다운로드 폴더)'));
}
window.downloadCard = async function (cardEl, c) {
if (cardEl.dataset.busy === '1') return;
cardEl.dataset.busy = '1';
try {
const blob = await captureBlob(cardEl);
const toDir = await saveBlob(blob, buildFileName(c));
markSaved(cardEl);
showToast(toDir ? '저장됨 → ' + dirHandle.name : '저장됨 (다운로드 폴더)');
} catch (e) {
showToast('저장 실패');
} finally {
setTimeout(() => { delete cardEl.dataset.busy; }, 600);
}
};
window.copyCard = async function (cardEl) {
if (cardEl.dataset.copying === '1') return; // 중복 클릭 방지
cardEl.dataset.copying = '1';
try { try {
const blob = await captureBlob(cardEl); const blob = await captureBlob(cardEl);
if (navigator.clipboard && window.ClipboardItem) { if (navigator.clipboard && window.ClipboardItem) {
await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]); await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]);
btnEl.textContent = '복사됨 ✓'; showToast('복사됨 ✓');
} else { } else {
downloadBlob(blob); downloadBlob(blob);
btnEl.textContent = '다운로드됨'; showToast('다운로드됨');
} }
} catch (e) { } catch (e) {
// 클립보드 권한 거부 등 → 다운로드 폴백 // 클립보드 권한 거부 등 → 다운로드 폴백
try { try {
const blob = await captureBlob(cardEl); const blob = await captureBlob(cardEl);
downloadBlob(blob); downloadBlob(blob);
btnEl.textContent = '다운로드됨'; showToast('다운로드됨');
} catch (e2) { } catch (e2) {
btnEl.textContent = '실패'; showToast('복사 실패');
} }
} finally { } finally {
setTimeout(() => { btnEl.disabled = false; btnEl.textContent = original; }, 1500); setTimeout(() => { delete cardEl.dataset.copying; }, 600);
} }
}; };
async function fetchComments() { async function fetchComments() {
const url = $('ccUrl').value.trim(); const url = $('ccUrl').value.trim();
if (!url) return; if (!url) return;
window.__videoId = parseVideoId(url);
const btn = $('ccFetch'); const btn = $('ccFetch');
btn.disabled = true; btn.textContent = '가져오는 중…'; btn.disabled = true; btn.textContent = '수집 중…';
$('ccEmpty').textContent = ''; $('ccEmpty').innerHTML = '<span class="cc-spinner" role="status" aria-label="수집 중"></span>'
+ '전체 댓글을 모으는 중입니다. 댓글이 많으면 시간이 걸릴 수 있어요…';
try { try {
const res = await fetch('/api/comment-cards/fetch', { const res = await fetch('/api/comment-cards/fetch', {
method: 'POST', method: 'POST',
@ -131,12 +584,20 @@
}); });
const json = await res.json(); const json = await res.json();
if (!json.success) throw new Error(json.message || '가져오기 실패'); if (!json.success) throw new Error(json.message || '가져오기 실패');
window.__cards = json.data || []; window.__all = json.data || [];
$('ccFilters').style.display = window.__cards.length ? 'flex' : 'none'; // 재렌더(필터·정렬)에도 유지되는 고유 id 부여 → 저장 표시가 카드에 붙어 다님
$('ccEmpty').textContent = window.__cards.length ? '' : '댓글이 없습니다.'; window.__all.forEach((c, i) => { c.__id = i; });
savedIds.clear(); // 새로 수집한 영상이므로 저장 표시 초기화
window.__cards = window.__all;
// 딥링크: 공유·새로고침 시 결과 유지되도록 URL에 videoId 반영
if (window.__videoId) history.replaceState(null, '', '?v=' + encodeURIComponent(window.__videoId));
$('ccFilters').style.display = window.__all.length ? 'flex' : 'none';
$('ccEmpty').textContent = window.__all.length ? '' : '댓글이 없습니다.';
renderAnalytics(window.__all);
window.renderCards(); window.renderCards();
} catch (e) { } catch (e) {
$('ccFilters').style.display = 'none'; $('ccFilters').style.display = 'none';
$('ccAnalytics').style.display = 'none';
$('cardGrid').innerHTML = ''; $('cardGrid').innerHTML = '';
$('ccEmpty').textContent = '⚠️ ' + e.message; $('ccEmpty').textContent = '⚠️ ' + e.message;
} finally { } finally {
@ -144,15 +605,86 @@
} }
} }
// 토글 버튼 공통: 상태 클래스(.active)·aria-pressed 갱신
function setToggle(btn, on) {
btn.classList.toggle('active', on);
btn.setAttribute('aria-pressed', String(on));
}
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
$('ccFetch').addEventListener('click', fetchComments); $('ccFetch').addEventListener('click', fetchComments);
$('ccUrl').addEventListener('keydown', (e) => { if (e.key === 'Enter') fetchComments(); }); $('ccUrl').addEventListener('keydown', (e) => { if (e.key === 'Enter') fetchComments(); });
['ccSort', 'ccMinLikes', 'ccRepliesOnly'].forEach(id => // 즉시 반영해도 가벼운 것(정렬·체크박스)
['ccSort', 'ccRepliesOnly'].forEach(id =>
$(id).addEventListener('input', () => window.renderCards())); $(id).addEventListener('input', () => window.renderCards()));
// 대량 카드(최대 2,000장) 대비 검색·좋아요 필터는 디바운스
const debouncedRender = debounce(() => window.renderCards(), 200);
['ccSearch', 'ccMinLikes'].forEach(id => $(id).addEventListener('input', debouncedRender));
// 제외 단어는 카드+분석 둘 다 갱신.
$('ccExclude').addEventListener('input', debounce(() => {
window.renderCards();
renderAnalytics(window.__all);
}, 250));
$('ccMosaic').addEventListener('click', () => { $('ccMosaic').addEventListener('click', () => {
mosaicOn = !mosaicOn; mosaicOn = !mosaicOn;
$('ccMosaic').textContent = mosaicOn ? '모자이크 해제' : '전체 모자이크'; $('ccMosaic').textContent = mosaicOn ? '모자이크 해제' : '전체 모자이크';
setToggle($('ccMosaic'), mosaicOn);
document.querySelectorAll('.comment-card').forEach(el => el.classList.toggle('mosaic', mosaicOn)); document.querySelectorAll('.comment-card').forEach(el => el.classList.toggle('mosaic', mosaicOn));
}); });
$('ccRounded').addEventListener('click', () => {
roundedOn = !roundedOn;
$('ccRounded').textContent = roundedOn ? '모서리 각지게' : '모서리 둥글게';
setToggle($('ccRounded'), roundedOn);
document.querySelectorAll('.comment-card').forEach(el => el.classList.toggle('rounded', roundedOn));
});
$('ccWordMask').addEventListener('click', () => {
wordMaskOn = !wordMaskOn;
$('ccWordMask').textContent = wordMaskOn ? '단어 가리기: 켜짐' : '단어 가리기: 꺼짐(복사)';
setToggle($('ccWordMask'), wordMaskOn);
$('cardGrid').classList.toggle('wordmask', wordMaskOn);
});
// 내용 글자 크기: 카드는 그대로, .cc-text만 배율 조정(재렌더·캡처에도 유지되도록 카드 인라인 변수)
function applyTextScale() {
$('ccFontLabel').textContent = Math.round(textScale * 100) + '%';
document.querySelectorAll('#cardGrid .comment-card').forEach(el =>
el.style.setProperty('--cc-text-scale', String(textScale)));
}
$('ccFontDown').addEventListener('click', () => {
textScale = Math.max(0.6, +(textScale - 0.1).toFixed(2));
applyTextScale();
});
$('ccFontUp').addEventListener('click', () => {
textScale = Math.min(2, +(textScale + 0.1).toFixed(2));
applyTextScale();
});
// 저장 화질(배율) — 캡처 시에만 쓰이므로 재렌더 불필요
$('ccScale').addEventListener('change', () => {
exportScale = parseInt($('ccScale').value, 10) || 4;
});
// 배경색: 기본 → 흰색 → 검정 순환. 검정이면 글자는 흰색(대비).
const BG_MODES = ['default', 'white', 'black'];
const BG_LABEL = { default: '배경: 기본', white: '배경: 흰색', black: '배경: 검정' };
$('ccBg').addEventListener('click', () => {
bgMode = BG_MODES[(BG_MODES.indexOf(bgMode) + 1) % BG_MODES.length];
$('ccBg').textContent = BG_LABEL[bgMode];
setToggle($('ccBg'), bgMode !== 'default');
document.querySelectorAll('#cardGrid .comment-card').forEach(el => {
el.classList.toggle('bg-white', bgMode === 'white');
el.classList.toggle('bg-black', bgMode === 'black');
});
});
$('ccPickDir').addEventListener('click', pickDir);
$('ccSaveAll').addEventListener('click', saveAllVisible);
// 폴더 저장 미지원 브라우저(파이어폭스/사파리)면 폴더 버튼 숨김 → 전체 저장은 다운로드 폴백
if (!window.showDirectoryPicker) $('ccPickDir').style.display = 'none';
// 딥링크: URL ?v=<id>가 있으면 자동 조회(공유·새로고침 시 결과 유지)
const params = new URLSearchParams(location.search);
const v = params.get('v');
if (v) { $('ccUrl').value = v; fetchComments(); }
}); });
})(); })();

View File

@ -7,56 +7,176 @@
.cc-toolbar { display:flex; flex-wrap:wrap; gap:.6rem; align-items:center; margin-bottom:1rem; } .cc-toolbar { display:flex; flex-wrap:wrap; gap:.6rem; align-items:center; margin-bottom:1rem; }
.cc-toolbar input[type=text], .cc-toolbar select, .cc-toolbar input[type=number] { .cc-toolbar input[type=text], .cc-toolbar select, .cc-toolbar input[type=number] {
background:var(--surface-2); color:var(--text); border:1px solid var(--border); background:var(--surface-2); color:var(--text); border:1px solid var(--border);
border-radius:8px; padding:.5rem .7rem; font-size:14px; border-radius:8px; padding:.5rem .7rem; font-size:16px; /* 16px: iOS 포커스 자동확대 방지 */
} }
.cc-url { flex:1; min-width:240px; } .cc-url { flex:1; min-width:240px; }
.cc-btn { background:var(--primary-gradient); color:#fff; border:none; border-radius:8px; .cc-btn { background:var(--primary-gradient); color:#fff; border:none; border-radius:8px;
padding:.55rem 1rem; font-weight:600; cursor:pointer; font-size:14px; } padding:.55rem 1rem; font-weight:600; cursor:pointer; font-size:14px; min-height:40px; }
.cc-btn.secondary { background:var(--surface-2); color:var(--text); border:1px solid var(--border); } .cc-btn.secondary { background:var(--surface-2); color:var(--text); border:1px solid var(--border); }
.cc-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(320px,1fr)); gap:1rem; } .cc-btn [data-lucide] { width:15px; height:15px; vertical-align:-3px; }
/* 토글 ON 상태: accent로 구분 */
.cc-btn.secondary.active { background:var(--accent-soft); border-color:var(--primary); color:var(--primary); }
/* 키보드 포커스 링(모든 상호작용 요소) */
.cc-btn:focus-visible, .cc-iconbtn:focus-visible, .cc-mini-select:focus-visible,
.cc-toolbar input:focus-visible, .comment-card:focus-visible, .cc-tl-link:focus-visible {
outline:2px solid var(--primary); outline-offset:2px; }
/* 수집 중 스피너 */
.cc-spinner { display:inline-block; width:14px; height:14px; border:2px solid var(--border);
border-top-color:var(--primary); border-radius:50%; animation:cc-spin .7s linear infinite;
vertical-align:-2px; margin-right:.45rem; }
@keyframes cc-spin { to { transform:rotate(360deg); } }
@media (prefers-reduced-motion: reduce) {
.cc-spinner { animation:none; } .cc-toast { transition:none; }
}
/* 메이슨리(다단): 카드가 내용 높이에 딱 맞게 쌓여 짧은 글 아래 흰 여백이 안 생김 */
.cc-grid { column-width:320px; column-gap:1rem; }
/* 카드: 영상 위에 얹을 소스 → 투명 캡처 기준. 미리보기는 surface 배경. */ /* 카드: 영상 위에 얹을 소스 → 투명 캡처 기준. 미리보기는 surface 배경. */
.comment-card { background:var(--surface); border:1px solid var(--border); border-radius:12px; /* 기본은 각진 모서리, '모서리 둥글게' 토글 시 .rounded 로 살짝 라운드 */
padding:1rem; position:relative; } .comment-card { background:var(--surface); border:1px solid var(--border); border-radius:4px;
padding:1rem; position:relative; break-inside:avoid; -webkit-column-break-inside:avoid;
margin-bottom:1rem; }
.comment-card.rounded { border-radius:16px; }
.cc-head { display:flex; gap:.6rem; align-items:flex-start; } .cc-head { display:flex; gap:.6rem; align-items:flex-start; }
.cc-avatar { width:40px; height:40px; border-radius:50%; flex-shrink:0; object-fit:cover; background:var(--surface-2); } .cc-avatar { width:40px; height:40px; border-radius:50%; flex-shrink:0; object-fit:cover; background:var(--surface-2); }
.cc-meta { flex:1; min-width:0; } .cc-meta { flex:1; min-width:0; }
.cc-author { font-weight:600; font-size:13.5px; color:var(--text); } .cc-author { font-weight:600; font-size:13.5px; color:var(--text); }
.cc-time { font-size:11.5px; color:var(--text-3); margin-left:.4rem; } .cc-time { font-size:11.5px; color:var(--text-2); margin-left:.4rem; }
.cc-text { font-size:14px; color:var(--text); margin-top:.35rem; white-space:pre-wrap; word-break:break-word; line-height:1.45; } /* 내용 글자 크기: 카드 레이아웃은 그대로 두고 텍스트만 확대/축소(--cc-text-scale, 카드 인라인에 지정) */
.cc-stats { font-size:12px; color:var(--text-3); margin-top:.5rem; display:flex; gap:1rem; } .cc-text { font-size:calc(14px * var(--cc-text-scale, 1)); color:var(--text); margin-top:.35rem; white-space:pre-wrap; word-break:break-word; line-height:1.5; }
.cc-copy { position:absolute; top:.6rem; right:.6rem; } /* 배경색 옵션: 흰색/검정. 검정이면 글자는 흰색 계열로 대비 확보 */
.comment-card.bg-white { background:#ffffff; border-color:#e5e5e5; }
.comment-card.bg-white .cc-author, .comment-card.bg-white .cc-text { color:#0f0f0f; }
.comment-card.bg-white .cc-time, .comment-card.bg-white .cc-stats { color:#606060; }
/* 어두운 배경 위 밝은 글자는 서브픽셀 AA에서 색 번짐이 생김 → 그레이스케일 AA로 또렷하게 */
.comment-card.bg-black { background:#000000; border-color:#333333;
-webkit-font-smoothing:antialiased; -moz-osx-font-smoothing:grayscale; }
.comment-card.bg-black .cc-author, .comment-card.bg-black .cc-text { color:#ffffff; }
.comment-card.bg-black .cc-time, .comment-card.bg-black .cc-stats { color:#aaaaaa; }
/* 글자 크기 스테퍼 */
.cc-fontsize { display:inline-flex; align-items:center; gap:.35rem; }
.cc-fontsize .cc-btn { min-width:38px; padding:.55rem .6rem; }
.cc-fontsize #ccFontLabel { font-size:12px; color:var(--text-2); min-width:3.2ch; text-align:center; font-variant-numeric:tabular-nums; }
.cc-stats { font-size:12px; color:var(--text-2); margin-top:.5rem; display:flex; gap:1rem; font-variant-numeric:tabular-nums; }
.comment-card { cursor:pointer; }
/* 저장 버튼 — cc-noexport라 캡처 이미지에서 제외됨 */
.cc-actions { position:absolute; top:.5rem; right:.5rem; display:flex; gap:.3rem; }
.cc-iconbtn { background:var(--surface-2); color:var(--text-2); border:1px solid var(--border);
border-radius:6px; font-size:12px; font-weight:600; padding:.4rem .6rem; cursor:pointer;
line-height:1; min-height:32px; display:inline-flex; align-items:center; gap:.25rem; }
.cc-iconbtn:hover { color:var(--text); border-color:var(--primary, #6366f1); }
/* 저장 완료 표시 — 화면 전용(캡처 시 .saved를 벗겨서 찍으므로 PNG엔 안 남음)
surface-2는 다크 테마에서 차이가 거의 없어, 텍스트 색을 섞어 두 테마 모두에서 확실히 회색이 되게 함 */
.comment-card.saved { background:var(--surface-2);
background:color-mix(in srgb, var(--text) 14%, var(--surface));
border-color:var(--text-3); box-shadow:inset 4px 0 0 var(--primary); opacity:.72; }
.comment-card.saved:hover { opacity:.9; }
.cc-iconbtn.saved { background:var(--surface); color:var(--text-3); border-color:var(--text-3); }
/* 복사 완료 토스트 — 카드 밖(body)에 떠서 캡처 이미지에 안 찍힘 */
.cc-toast { position:fixed; left:50%; bottom:2rem; transform:translateX(-50%) translateY(1rem);
background:var(--text); color:var(--surface); padding:.6rem 1.1rem; border-radius:999px;
font-size:14px; font-weight:600; opacity:0; pointer-events:none; transition:opacity .15s, transform .15s; z-index:9999; }
.cc-toast.show { opacity:1; transform:translateX(-50%) translateY(0); }
/* 모자이크 */ /* 모자이크 */
.comment-card.mosaic .cc-avatar { filter: blur(6px); } .comment-card.mosaic .cc-avatar { filter: blur(6px); }
.comment-card.mosaic .cc-author { filter: blur(5px); } .comment-card.mosaic .cc-author { filter: blur(5px); }
/* 단어 가리기: '단어 가리기 켜짐'일 때 단어에 포인터, 클릭하면 .masked로 블러 */
.cc-word { cursor: default; }
.cc-grid.wordmask .cc-word { cursor: pointer; }
.cc-grid.wordmask .comment-card { cursor: default; }
.cc-word.masked { filter: blur(4px); }
.cc-empty { color:var(--text-3); padding:2rem 0; } .cc-empty { color:var(--text-3); padding:2rem 0; }
/* ---- 분석 패널 ---- */
.cc-analytics { background:var(--surface); border:1px solid var(--border); border-radius:12px;
padding:1rem 1.1rem; margin-bottom:1.2rem; }
.cc-an-head { display:flex; align-items:center; gap:.6rem; margin-bottom:.9rem; font-size:15px; }
.cc-an-sub { color:var(--text-2); font-size:12.5px; font-weight:400; }
.cc-an-head .cc-iconbtn { margin-left:auto; }
.cc-an-head [data-lucide], .cc-an-sec h4 [data-lucide] { width:16px; height:16px; vertical-align:-3px; }
.cc-tiles { display:grid; grid-template-columns:repeat(auto-fit,minmax(120px,1fr)); gap:.6rem; margin-bottom:1rem; }
.cc-tile { background:var(--surface-2); border:1px solid var(--border); border-radius:10px; padding:.6rem .8rem; }
.cc-tile-v { font-size:1.25rem; font-weight:700; color:var(--text); line-height:1.2; font-variant-numeric:tabular-nums; }
.cc-tile-l { font-size:12px; color:var(--text-2); margin-top:.15rem; }
.cc-an-sec { margin-top:1rem; }
.cc-an-sec h4 { font-size:13.5px; font-weight:600; color:var(--text-2); margin:0 0 .5rem; }
.cc-an-sechead { display:flex; align-items:center; justify-content:space-between; gap:.6rem; }
.cc-wlabel { font-size:12px; color:var(--text-2); display:flex; align-items:center; gap:.3rem; cursor:pointer; }
.cc-mini-select { background:var(--surface-2); color:var(--text); border:1px solid var(--border);
border-radius:8px; padding:.35rem .6rem; font-size:13px; cursor:pointer; min-height:32px; }
.cc-an-empty { color:var(--text-3); font-size:13px; margin:.2rem 0; }
.cc-bars { display:flex; flex-direction:column; gap:.35rem; }
.cc-bar-row { display:grid; grid-template-columns:76px 1fr 52px; align-items:center; gap:.5rem; font-size:12.5px; }
.cc-bar-label { color:var(--text); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.cc-bar-track { background:var(--surface-2); border-radius:999px; height:9px; overflow:hidden; }
.cc-bar-fill { display:block; height:100%; background:var(--primary-gradient); border-radius:999px; }
.cc-bar-val { color:var(--text-2); text-align:right; font-variant-numeric:tabular-nums; }
a.cc-tl-link { text-decoration:none; }
a.cc-tl-link:hover .cc-bar-label { text-decoration:underline; color:var(--primary,#6366f1); }
.cc-top-comment { background:var(--surface-2); border:1px solid var(--border); border-radius:10px;
padding:.6rem .8rem; font-size:12.5px; color:var(--text-2); }
.cc-top-text { color:var(--text); margin-top:.3rem; white-space:pre-wrap; word-break:break-word; }
.cc-months { display:flex; align-items:flex-end; gap:.35rem; height:108px; overflow-x:auto; padding-top:.3rem; }
.cc-mo { display:flex; flex-direction:column; align-items:center; justify-content:flex-end; gap:.2rem; min-width:28px; }
.cc-mo-val { font-size:10px; color:var(--text-2); font-variant-numeric:tabular-nums; } /* 값 상시 노출(hover 의존 X) */
.cc-mo-bar { display:block; width:16px; background:var(--primary-gradient); border-radius:4px 4px 0 0; }
.cc-mo-lb { font-size:11px; color:var(--text-2); white-space:nowrap; }
</style> </style>
</head> </head>
<body> <body>
<div layout:fragment="content"> <div layout:fragment="content">
<h1 style="font-size:1.5rem;font-weight:700;margin-bottom:.3rem;">댓글 카드</h1> <h1 style="font-size:1.5rem;font-weight:700;margin-bottom:.3rem;">댓글 카드</h1>
<p style="color:var(--text-3);margin-bottom:1.2rem;font-size:14px;"> <p style="color:var(--text-3);margin-bottom:1.2rem;font-size:14px;">
유튜브 링크의 댓글을 가져와 카드로 만들고, 프로필·아이디를 모자이크해 영상 소스로 복사하세요. 유튜브 링크의 <b>전체 댓글</b>을 가져와 분석(많이 언급된 타임라인·자주 나온 단어·요약 통계)하고 카드로 만듭니다.
모자이크는 기본 켜짐, <b>모서리 둥글게</b>로 각진/라운드 전환. 기본은 <b>단어 클릭=가리기</b>(블러), <b>단어 가리기</b> 토글을 끄면 <b>카드 클릭=이미지 복사</b>. <b>저장 버튼=PNG 파일 다운로드</b>(캡컷은 붙여넣기가 안 되니 저장한 파일을 "불러오기" 하세요). <b>저장 폴더</b>를 고르면 저장·<b>전체 저장</b>이 그 폴더로 바로 갑니다(크롬/엣지).
영상에서 카드를 크게 확대해 쓸 거면 <b>화질</b>을 4x 이상으로 두세요(배율이 높을수록 저장이 느리고 파일이 큽니다).
</p> </p>
<div class="cc-toolbar"> <div class="cc-toolbar">
<input type="text" id="ccUrl" class="cc-url" placeholder="유튜브 링크 또는 영상 ID 붙여넣기" /> <input type="text" id="ccUrl" class="cc-url" placeholder="유튜브 링크 또는 영상 ID 붙여넣기"
aria-label="유튜브 링크 또는 영상 ID" />
<button class="cc-btn" id="ccFetch">가져오기</button> <button class="cc-btn" id="ccFetch">가져오기</button>
</div> </div>
<div class="cc-toolbar" id="ccFilters" style="display:none;"> <div class="cc-toolbar" id="ccFilters" style="display:none;">
<select id="ccSort"> <input type="text" id="ccSearch" placeholder="댓글 내용 검색 (쉼표로 여러 개 = OR)"
aria-label="댓글 내용 검색 (쉼표로 구분하면 하나라도 포함된 댓글 표시)" title="쉼표로 구분하면 OR 검색 (예: 웃김, 재밌, 대박)" style="min-width:220px;" />
<input type="text" id="ccExclude" placeholder="제외 단어 (쉼표로 여러 개, 검색보다 우선)"
aria-label="제외 단어 (쉼표로 구분, 하나라도 포함되면 검색과 무관하게 제외)" title="쉼표로 구분 · 하나라도 포함된 댓글은 검색어와 무관하게 무조건 제외 (예: 광고, 구독하고, ㅋㅋㅋㅋ)" style="min-width:220px;" />
<select id="ccSort" aria-label="정렬 기준">
<option value="likes">좋아요순</option> <option value="likes">좋아요순</option>
<option value="replies">답글순</option> <option value="replies">답글순</option>
<option value="latest">최신순</option> <option value="latest">최신순</option>
</select> </select>
<label style="font-size:13px;color:var(--text-2);">좋아요 <label style="font-size:13px;color:var(--text-2);">좋아요
<input type="number" id="ccMinLikes" value="0" min="0" style="width:90px;" /> 이상</label> <input type="number" id="ccMinLikes" value="0" min="0" style="width:90px;" aria-label="최소 좋아요 수" /> 이상</label>
<label style="font-size:13px;color:var(--text-2);display:flex;align-items:center;gap:.3rem;"> <label style="font-size:13px;color:var(--text-2);display:flex;align-items:center;gap:.3rem;">
<input type="checkbox" id="ccRepliesOnly" /> 답글 있는 것만</label> <input type="checkbox" id="ccRepliesOnly" /> 답글 있는 것만</label>
<button class="cc-btn secondary" id="ccMosaic">전체 모자이크</button> <button class="cc-btn secondary active" id="ccMosaic" aria-pressed="true">모자이크 해제</button>
<span id="ccCount" style="font-size:12px;color:var(--text-3);"></span> <button class="cc-btn secondary active" id="ccRounded" aria-pressed="true">모서리 각지게</button>
<button class="cc-btn secondary active" id="ccWordMask" aria-pressed="true">단어 가리기: 켜짐</button>
<span class="cc-fontsize" role="group" aria-label="내용 글자 크기">
<button class="cc-btn secondary" id="ccFontDown" aria-label="글자 작게" title="내용 글자 작게">A</button>
<span id="ccFontLabel">100%</span>
<button class="cc-btn secondary" id="ccFontUp" aria-label="글자 크게" title="내용 글자 크게">A+</button>
</span>
<button class="cc-btn secondary active" id="ccBg" aria-pressed="true" title="배경색: 기본 → 흰색 → 검정 순환">배경: 검정</button>
<label style="font-size:13px;color:var(--text-2);display:flex;align-items:center;gap:.3rem;"
title="저장·복사되는 PNG의 배율. 영상에서 크게 확대할수록 높게">화질
<select id="ccScale" aria-label="저장 화질(배율)">
<option value="2">2x (가벼움)</option>
<option value="4" selected>4x (권장)</option>
<option value="6">6x</option>
<option value="8">8x (최고)</option>
</select>
</label>
<button class="cc-btn secondary" id="ccPickDir" aria-pressed="false"
title="클릭: 저장 폴더 지정 · 다시 클릭: 해제(브라우저 다운로드 폴더로 저장)"><i data-lucide="folder"></i> <span id="ccPickDirName">저장 폴더</span></button>
<button class="cc-btn secondary" id="ccSaveAll"><i data-lucide="download"></i> 전체 저장</button>
<span id="ccCount" style="font-size:12px;color:var(--text-2);"></span>
</div> </div>
<div class="cc-analytics" id="ccAnalytics" style="display:none;"></div>
<div class="cc-grid" id="cardGrid"></div> <div class="cc-grid" id="cardGrid"></div>
<div class="cc-empty" id="ccEmpty"></div> <div class="cc-empty" id="ccEmpty"></div>
</div> </div>
@ -64,7 +184,7 @@
<th:block layout:fragment="script"> <th:block layout:fragment="script">
<!-- DOM→이미지 캡처 (Task 5에서 사용). blur 필터 지원 위해 modern-screenshot 사용. --> <!-- DOM→이미지 캡처 (Task 5에서 사용). blur 필터 지원 위해 modern-screenshot 사용. -->
<script src="https://cdn.jsdelivr.net/npm/modern-screenshot@4/dist/index.js"></script> <script src="https://cdn.jsdelivr.net/npm/modern-screenshot@4/dist/index.js"></script>
<script th:src="@{/js/comment-cards.js(v=20260629b)}"></script> <script th:src="@{/js/comment-cards.js(v=20260722)}"></script>
</th:block> </th:block>
</body> </body>
</html> </html>

View File

@ -12,7 +12,7 @@
<div class="fd-masthead"> <div class="fd-masthead">
<div> <div>
<div class="fd-tick" style="margin-bottom:8px;">지금 할 일 중심 · 수집 → 큐레이션 → 재가공 → 발행</div> <div class="fd-tick" style="margin-bottom:8px;">지금 할 일 중심 · 수집 → 큐레이션 → 재가공</div>
<h1>대시보드</h1> <h1>대시보드</h1>
</div> </div>
<div class="fd-meta"> <div class="fd-meta">
@ -63,8 +63,6 @@
<div class="fd-pb" id="funnel"><div class="fd-loading">불러오는 중…</div></div> <div class="fd-pb" id="funnel"><div class="fd-loading">불러오는 중…</div></div>
<div class="fd-ph" style="border-top:1.5px solid var(--fd-rule-strong)"><h3 style="font-size:13px">출처 · 포맷</h3><a class="fd-tick" th:href="@{/collection}">수집함 →</a></div> <div class="fd-ph" style="border-top:1.5px solid var(--fd-rule-strong)"><h3 style="font-size:13px">출처 · 포맷</h3><a class="fd-tick" th:href="@{/collection}">수집함 →</a></div>
<div class="fd-pb" id="srcFmt"></div> <div class="fd-pb" id="srcFmt"></div>
<div class="fd-ph" style="border-top:1.5px solid var(--fd-rule-strong)"><h3 style="font-size:13px">발행 현황</h3><a class="fd-tick" th:href="@{/publish}">발행 큐 →</a></div>
<div class="fd-pb" id="pubBox"></div>
</div> </div>
</section> </section>
@ -223,7 +221,6 @@
const pipe = d.pipeline||{}, bs = pipe.byStatus||{}, src = pipe.bySource||{}; const pipe = d.pipeline||{}, bs = pipe.byStatus||{}, src = pipe.bySource||{};
const total = Number(pipe.total||0); const total = Number(pipe.total||0);
const pub = d.publish||{}, pbs = pub.byStatus||{};
const op = d.outperformers||[]; const op = d.outperformers||[];
// ----- 액션 바 ----- // ----- 액션 바 -----
@ -261,7 +258,7 @@
document.getElementById('pipeTotal').textContent = '총 ' + fmt(total); document.getElementById('pipeTotal').textContent = '총 ' + fmt(total);
const stages = [ const stages = [
['미검토', bs.NEW], ['검토중', bs.REVIEWING], ['작업대상', bs.TARGET], ['미검토', bs.NEW], ['검토중', bs.REVIEWING], ['작업대상', bs.TARGET],
['완료', bs.DONE], ['발행완료', pbs.PUBLISHED] ['완료', bs.DONE]
]; ];
let bottleneck = stages[0]; let bottleneck = stages[0];
for(const s of stages){ if(Number(s[1]||0) > Number(bottleneck[1]||0)) bottleneck = s; } for(const s of stages){ if(Number(s[1]||0) > Number(bottleneck[1]||0)) bottleneck = s; }
@ -270,7 +267,6 @@
fbar('검토중', bs.REVIEWING, total) + fbar('검토중', bs.REVIEWING, total) +
fbar('작업대상', bs.TARGET, total) + fbar('작업대상', bs.TARGET, total) +
fbar('완료', bs.DONE, total) + fbar('완료', bs.DONE, total) +
fbar('발행완료', pbs.PUBLISHED, total) +
`<div class="fd-bottleneck"><div class="msg">병목: <b>${esc(bottleneck[0])} ${fmt(bottleneck[1])}</b> — 다음 단계로 옮겨보세요</div><a class="fd-btn fd-sm fd-sig" href="/board">칸반 보드</a></div>`; `<div class="fd-bottleneck"><div class="msg">병목: <b>${esc(bottleneck[0])} ${fmt(bottleneck[1])}</b> — 다음 단계로 옮겨보세요</div><a class="fd-btn fd-sm fd-sig" href="/board">칸반 보드</a></div>`;
// ----- 출처/포맷 ----- // ----- 출처/포맷 -----
@ -279,14 +275,6 @@
`<div class="fd-row"><span>검색 수집</span><span class="v"><b>${fmt(src.SEARCH)}</b> · ${pct(src.SEARCH,total)}%</span></div>` + `<div class="fd-row"><span>검색 수집</span><span class="v"><b>${fmt(src.SEARCH)}</b> · ${pct(src.SEARCH,total)}%</span></div>` +
`<div class="fd-row"><span>Shorts</span><span class="v"><b>${fmt(pipe.shorts)}</b> · ${pct(pipe.shorts,total)}%</span></div>`; `<div class="fd-row"><span>Shorts</span><span class="v"><b>${fmt(pipe.shorts)}</b> · ${pct(pipe.shorts,total)}%</span></div>`;
// ----- 발행 -----
const ptotal = Number(pub.total||0);
document.getElementById('pubBox').innerHTML = ptotal===0
? '<div class="fd-empty">발행 패키지 없음 — 재가공 화면에서 발행안을 저장하세요.</div>'
: `<div class="fd-row"><span>작성중</span><span class="v"><b>${fmt(pbs.DRAFT)}</b></span></div>`+
`<div class="fd-row"><span>발행대기</span><span class="v"><b>${fmt(pbs.READY)}</b></span></div>`+
`<div class="fd-row"><span>발행완료</span><span class="v"><b>${fmt(pbs.PUBLISHED)}</b></span></div>`;
if(window.lucide) lucide.createIcons(); if(window.lucide) lucide.createIcons();
} }

File diff suppressed because it is too large Load Diff

View File

@ -20,10 +20,24 @@
</div> </div>
<nav style="flex:1;padding:0.5rem 0.75rem;overflow-y:auto;"> <nav style="flex:1;padding:0.5rem 0.75rem;overflow-y:auto;">
<div class="nav-section">소재</div>
<a th:href="@{/feed}" class="nav-item" th:classappend="${currentPage == 'feed'} ? 'active'">
<i data-lucide="rss" class="nav-icon"></i><span class="nav-text">소재 피드</span>
</a>
<a th:href="@{/politics-feed}" class="nav-item" th:classappend="${currentPage == 'politics-feed'} ? 'active'">
<i data-lucide="landmark" class="nav-icon"></i><span class="nav-text">정치 피드</span>
</a>
<a th:href="@{/shortform}" class="nav-item" th:classappend="${currentPage == 'shortform'} ? 'active'">
<i data-lucide="clapperboard" class="nav-icon"></i><span class="nav-text">숏폼 큐</span>
</a>
<div class="nav-section">분석</div> <div class="nav-section">분석</div>
<a th:href="@{/}" class="nav-item" th:classappend="${currentPage == 'dashboard'} ? 'active'"> <a th:href="@{/}" class="nav-item" th:classappend="${currentPage == 'dashboard'} ? 'active'">
<i data-lucide="layout-dashboard" class="nav-icon"></i><span class="nav-text">대시보드</span> <i data-lucide="layout-dashboard" class="nav-icon"></i><span class="nav-text">대시보드</span>
</a> </a>
<a th:href="@{/my-channel}" class="nav-item" th:classappend="${currentPage == 'my-channel'} ? 'active'">
<i data-lucide="trending-up" class="nav-icon"></i><span class="nav-text">내 채널</span>
</a>
<a th:href="@{/discover}" class="nav-item" th:classappend="${currentPage == 'discover'} ? 'active'"> <a th:href="@{/discover}" class="nav-item" th:classappend="${currentPage == 'discover'} ? 'active'">
<i data-lucide="radar" class="nav-icon"></i><span class="nav-text">발굴</span> <i data-lucide="radar" class="nav-icon"></i><span class="nav-text">발굴</span>
</a> </a>
@ -41,14 +55,14 @@
<a th:href="@{/board}" class="nav-item" th:classappend="${currentPage == 'board'} ? 'active'"> <a th:href="@{/board}" class="nav-item" th:classappend="${currentPage == 'board'} ? 'active'">
<i data-lucide="kanban-square" class="nav-icon"></i><span class="nav-text">칸반 보드</span> <i data-lucide="kanban-square" class="nav-icon"></i><span class="nav-text">칸반 보드</span>
</a> </a>
<a th:href="@{/publish}" class="nav-item" th:classappend="${currentPage == 'publish'} ? 'active'">
<i data-lucide="send" class="nav-icon"></i><span class="nav-text">발행 큐</span>
</a>
<div class="nav-section">제작</div> <div class="nav-section">제작</div>
<a th:href="@{/production}" class="nav-item" th:classappend="${currentPage == 'production'} ? 'active'"> <a th:href="@{/production}" class="nav-item" th:classappend="${currentPage == 'production'} ? 'active'">
<i data-lucide="clapperboard" class="nav-icon"></i><span class="nav-text">프로덕션</span> <i data-lucide="clapperboard" class="nav-icon"></i><span class="nav-text">프로덕션</span>
</a> </a>
<a th:href="@{/reply}" class="nav-item" th:classappend="${currentPage == 'reply'} ? 'active'">
<i data-lucide="reply" class="nav-icon"></i><span class="nav-text">댓글 답장</span>
</a>
<a th:href="@{/comment-cards}" class="nav-item" th:classappend="${currentPage == 'comment-cards'} ? 'active'"> <a th:href="@{/comment-cards}" class="nav-item" th:classappend="${currentPage == 'comment-cards'} ? 'active'">
<i data-lucide="message-square-quote" class="nav-icon"></i><span class="nav-text">댓글 카드</span> <i data-lucide="message-square-quote" class="nav-icon"></i><span class="nav-text">댓글 카드</span>
</a> </a>

View File

@ -0,0 +1,384 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{layout/base}">
<head>
<title>h-lab - 내 채널</title>
</head>
<body>
<div layout:fragment="content">
<div class="page-header">
<div>
<h1>내 채널</h1>
<p class="sub">내가 발행한 영상의 성과와 어떤 소재가 잘 먹히는지 봅니다. 소재 파이프라인과는 분리돼 있습니다.</p>
</div>
<div class="actions" id="headerActions"></div>
</div>
<!-- 미등록 상태 -->
<div id="setup" class="card hidden" style="max-width:640px;">
<div class="flex items-center gap-3 mb-3">
<div class="setup-ic"><i data-lucide="trending-up"></i></div>
<div>
<div class="font-semibold">내 채널을 등록하세요</div>
<div class="text-sm text-muted" style="margin-top:2px;">
여기 등록한 채널의 영상은 수집함·칸반·피드에 섞이지 않습니다.
</div>
</div>
</div>
<div class="seed-add">
<label class="sr-only" for="ownUrl">채널 URL</label>
<input id="ownUrl" type="text" placeholder="https://www.youtube.com/@mychannel">
<button class="btn btn-primary" id="registerBtn" onclick="registerOwn()">등록</button>
</div>
<p class="text-xs text-muted mt-2" id="setupHelp">
등록하면 영상을 바로 수집합니다 (쿼터 약 12 units).
</p>
</div>
<!-- 등록 상태 -->
<div id="main" class="hidden">
<!-- 요약 -->
<div class="card mb-4" id="summaryCard"></div>
<div class="mc-grid">
<!-- 소재별 성과 -->
<div class="card">
<div class="flex items-center justify-between mb-3" style="flex-wrap:wrap; gap:0.5rem;">
<div>
<h3 class="text-lg">소재별 성과</h3>
<p class="text-xs text-muted" style="margin-top:2px;">
해시태그 기준 · 2편 이상만 · 배수는 채널 평균 대비
</p>
</div>
<span id="avgBadge" class="badge badge-muted"></span>
</div>
<div id="topics"></div>
</div>
<!-- 성장 추이 -->
<div class="card">
<h3 class="text-lg mb-3">성장 추이</h3>
<div id="growthWrap" style="position:relative; height:260px;">
<canvas id="growthChart"></canvas>
</div>
<div id="growthEmpty" class="text-sm text-muted text-center hidden" style="padding:2.5rem 0;">
아직 스냅샷이 없습니다. 매일 새벽 3시에 기록되며 이틀치가 쌓이면 그래프가 그려집니다.
</div>
</div>
</div>
<!-- 내 영상 -->
<div class="card mt-4">
<div class="flex items-center justify-between mb-3">
<h3 class="text-lg">내 영상</h3>
<span id="videoCount" class="badge badge-muted"></span>
</div>
<div id="videos" class="mc-videos"></div>
</div>
</div>
<div id="toast" class="toast" role="status" aria-live="polite"></div>
<style>
.mc-grid { display:grid; grid-template-columns:1.15fr 1fr; gap:1rem; align-items:start; }
@media (max-width: 960px) { .mc-grid { grid-template-columns:1fr; } }
.setup-ic {
width:38px; height:38px; border-radius:10px; flex-shrink:0;
background:var(--accent-soft); color:var(--accent);
display:flex; align-items:center; justify-content:center;
}
.seed-add { display:flex; gap:0.5rem; flex-wrap:wrap; }
.seed-add input { flex:1; min-width:190px; }
/* 요약 */
.mc-head { display:flex; align-items:center; gap:1rem; flex-wrap:wrap; }
.mc-head img { width:56px; height:56px; border-radius:50%; object-fit:cover; background:var(--inset); flex-shrink:0; }
.mc-stats { display:flex; gap:1.75rem; flex-wrap:wrap; margin-left:auto; }
.mc-stat .k { font-size:0.72rem; color:var(--text-3); font-weight:600; }
.mc-stat .v {
font-size:1.15rem; font-weight:700; font-family:var(--font-mono);
font-variant-numeric:tabular-nums; margin-top:2px;
}
@media (max-width: 700px) { .mc-stats { margin-left:0; gap:1.1rem; } }
/* 소재 랭킹 */
.topic-row {
display:flex; align-items:center; gap:0.7rem;
padding:0.6rem 0; border-bottom:1px solid var(--border);
}
.topic-row:last-child { border-bottom:none; }
.topic-name { min-width:0; flex:0 0 30%; }
.topic-name b { display:block; font-size:0.85rem; font-weight:600; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.topic-name span { font-size:0.7rem; color:var(--text-3); }
.topic-bar { flex:1; min-width:60px; }
.topic-bar .track { height:7px; background:var(--inset); border-radius:var(--radius-full); overflow:hidden; }
.topic-bar .fill { height:100%; border-radius:var(--radius-full); transition:width .5s cubic-bezier(.22,1,.36,1); }
.topic-num {
flex:0 0 auto; text-align:right; font-family:var(--font-mono);
font-variant-numeric:tabular-nums; font-size:0.8rem; min-width:66px;
}
.topic-idx { flex:0 0 auto; min-width:52px; text-align:right; }
/* 영상 목록 */
.mc-videos { display:grid; grid-template-columns:repeat(auto-fill, minmax(210px, 1fr)); gap:0.85rem; }
.mcv { display:block; }
.mcv .th { position:relative; aspect-ratio:16/9; border-radius:var(--r-sm); overflow:hidden; background:var(--inset); }
.mcv .th img { width:100%; height:100%; object-fit:cover; display:block; }
.mcv:focus-visible .th { outline:2px solid var(--accent); outline-offset:2px; }
.mcv .t {
font-size:0.8rem; font-weight:600; line-height:1.4; margin-top:0.4rem; color:var(--text);
display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical; overflow:hidden;
}
.mcv:hover .t { text-decoration:underline; }
.mcv .m { font-size:0.72rem; color:var(--text-3); margin-top:2px; font-variant-numeric:tabular-nums; }
.toast {
position:fixed; left:50%; bottom:24px; transform:translate(-50%, 12px);
background:var(--text); color:var(--bg);
padding:0.65rem 1.1rem; border-radius:var(--r-sm); font-size:0.85rem; font-weight:600;
box-shadow:var(--shadow-lg); z-index:2000;
opacity:0; pointer-events:none; transition:opacity .2s ease, transform .2s ease;
max-width:88vw; text-align:center;
}
.toast.show { opacity:1; transform:translate(-50%, 0); }
.toast.err { background:var(--danger); color:#fff; }
.sr-only {
position:absolute; width:1px; height:1px; padding:0; margin:-1px;
overflow:hidden; clip:rect(0,0,0,0); white-space:nowrap; border:0;
}
@media (prefers-reduced-motion: reduce) {
.topic-bar .fill, .toast { transition:none; }
}
</style>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
<script th:inline="javascript">
/*<![CDATA[*/
const API = '/api/my-channel';
let growthChart = null;
function esc(s){ return (s==null?'':String(s)).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
function fmtNum(n){
if(n==null) return '-';
const v = Number(n);
if(v >= 100000000) return (v/100000000).toFixed(1).replace(/\.0$/,'') + '억';
if(v >= 10000) return (v/10000).toFixed(1).replace(/\.0$/,'') + '만';
return v.toLocaleString();
}
function fmtDate(iso){
if(!iso) return '-';
const d = new Date(iso);
return isNaN(d) ? '-' : `${d.getFullYear()}.${String(d.getMonth()+1).padStart(2,'0')}.${String(d.getDate()).padStart(2,'0')}`;
}
let toastTimer = null;
function toast(msg, isError){
const el = document.getElementById('toast');
el.textContent = msg;
el.classList.toggle('err', !!isError);
el.classList.add('show');
clearTimeout(toastTimer);
toastTimer = setTimeout(()=> el.classList.remove('show'), 3500);
}
async function api(url, opts){
const res = await fetch(url, opts);
const json = await res.json().catch(()=>({}));
if(!res.ok || (json && json.success===false)) throw new Error((json && json.message) || ('HTTP '+res.status));
return json.data;
}
// ---------- 요약 ----------
function renderSummary(s){
document.getElementById('summaryCard').innerHTML = `
<div class="mc-head">
<img src="${esc(s.thumbnailUrl)}" alt="" loading="lazy">
<div style="min-width:0;">
<div class="text-lg font-bold">${esc(s.title)}</div>
<a href="https://www.youtube.com/channel/${esc(s.channelId)}" target="_blank" rel="noopener"
class="text-xs text-muted hover:underline">YouTube 에서 보기</a>
</div>
<div class="mc-stats">
<div class="mc-stat"><div class="k">구독자</div><div class="v">${fmtNum(s.subscriberCount)}</div></div>
<div class="mc-stat"><div class="k">총 조회수</div><div class="v">${fmtNum(s.viewCount)}</div></div>
<div class="mc-stat"><div class="k">영상 평균</div><div class="v">${fmtNum(s.avgViews)}</div></div>
<div class="mc-stat"><div class="k">최근 30일</div><div class="v">${fmtNum(s.last30Views)}</div></div>
</div>
</div>`;
document.getElementById('headerActions').innerHTML = `
<button class="btn btn-secondary" id="syncBtn" onclick="syncOwn()">
<i data-lucide="refresh-cw" style="width:15px;"></i> 동기화
</button>
<button class="btn btn-secondary" onclick="unregisterOwn()" title="등록 해제">
<i data-lucide="unlink" style="width:15px;"></i>
</button>`;
if(window.lucide) lucide.createIcons();
}
// ---------- 소재별 성과 ----------
function renderTopics(list, avg){
document.getElementById('avgBadge').textContent = '채널 평균 ' + fmtNum(avg);
const box = document.getElementById('topics');
if(!list || list.length === 0){
box.innerHTML = `<div class="text-sm text-muted text-center" style="padding:2.5rem 0; line-height:1.6;">
아직 집계할 소재가 없습니다.<br>영상 설명의 해시태그(#유퀴즈 #핑계고)로 묶기 때문에,
같은 소재가 2편 이상 쌓이면 나타납니다.</div>`;
return;
}
const max = Math.max(...list.map(t => t.avgViews), 1);
box.innerHTML = list.map(t => {
// 평균 이상은 강조색, 이하는 중립색 — 색만으로 전달하지 않도록 배수 텍스트를 함께 둔다
const strong = t.index >= 1;
const color = strong ? 'var(--accent)' : 'var(--border-strong)';
const idxCls = t.index >= 1.5 ? 'badge-success' : (strong ? 'badge-primary' : 'badge-muted');
const w = Math.max(2, Math.round(t.avgViews / max * 100));
return `<div class="topic-row">
<div class="topic-name">
<b title="${esc(t.topic)}">#${esc(t.topic)}</b>
<span>${t.videoCount}편 · 최고 ${fmtNum(t.maxViews)}</span>
</div>
<div class="topic-bar"><div class="track"><div class="fill" style="width:${w}%; background:${color};"></div></div></div>
<div class="topic-num">${fmtNum(t.avgViews)}</div>
<div class="topic-idx"><span class="badge ${idxCls}">${t.index.toFixed(1)}x</span></div>
</div>`;
}).join('');
}
// ---------- 성장 추이 ----------
function renderGrowth(rows){
const wrap = document.getElementById('growthWrap');
const empty = document.getElementById('growthEmpty');
if(!rows || rows.length < 2){
wrap.classList.add('hidden'); empty.classList.remove('hidden');
return;
}
wrap.classList.remove('hidden'); empty.classList.add('hidden');
const css = getComputedStyle(document.documentElement);
const accent = css.getPropertyValue('--accent').trim() || '#ff4d23';
const text3 = css.getPropertyValue('--text-3').trim() || '#999';
const border = css.getPropertyValue('--border').trim() || '#ddd';
if(growthChart) growthChart.destroy();
growthChart = new Chart(document.getElementById('growthChart').getContext('2d'), {
type: 'line',
data: {
labels: rows.map(r => fmtDate(r.snapshotDate)),
datasets: [{
label: '구독자',
data: rows.map(r => r.subscriberCount),
borderColor: accent,
backgroundColor: accent + '22',
fill: true, tension: 0.3, pointRadius: 2, borderWidth: 2
}]
},
options: {
responsive: true, maintainAspectRatio: false,
animation: window.matchMedia('(prefers-reduced-motion: reduce)').matches ? false : undefined,
plugins: { legend: { display: false } },
scales: {
x: { grid: { color: border }, ticks: { color: text3, maxTicksLimit: 6 } },
y: { grid: { color: border }, ticks: { color: text3 } }
}
}
});
}
// ---------- 영상 ----------
function renderVideos(list){
document.getElementById('videoCount').textContent = (list?.length || 0) + '건';
const box = document.getElementById('videos');
if(!list || list.length === 0){
box.innerHTML = '<div class="text-sm text-muted p-4">수집된 영상이 없습니다. 동기화를 눌러보세요.</div>';
return;
}
box.innerHTML = list.map(v => `
<a class="mcv" href="https://www.youtube.com/watch?v=${encodeURIComponent(v.videoId)}"
target="_blank" rel="noopener">
<div class="th"><img src="${esc(v.thumbnailUrl)}" alt="" loading="lazy" width="320" height="180"></div>
<div class="t">${esc(v.title)}</div>
<div class="m">${fmtNum(v.viewCount)}회 · ${fmtDate(v.publishedAt)}</div>
</a>`).join('');
}
// ---------- 액션 ----------
async function registerOwn(){
const url = document.getElementById('ownUrl').value.trim();
const help = document.getElementById('setupHelp');
if(!url){
help.textContent = '채널 URL 을 입력하세요.';
help.style.color = 'var(--danger)';
document.getElementById('ownUrl').focus();
return;
}
const btn = document.getElementById('registerBtn');
btn.disabled = true;
const orig = btn.textContent;
btn.textContent = '등록 중...';
try {
await api(API, { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({url}) });
toast('등록하고 영상을 수집했습니다');
await load();
} catch(e){
help.textContent = '등록 실패: ' + e.message;
help.style.color = 'var(--danger)';
} finally { btn.disabled = false; btn.textContent = orig; }
}
async function syncOwn(){
const btn = document.getElementById('syncBtn');
btn.disabled = true;
try {
await api(API + '/sync', { method:'POST' });
toast('동기화했습니다');
await load();
} catch(e){ toast('동기화 실패: ' + e.message, true); }
finally { const b = document.getElementById('syncBtn'); if(b) b.disabled = false; }
}
async function unregisterOwn(){
if(!confirm('내 채널 등록을 해제할까요?\n수집된 영상과 소재별 성과 기록도 함께 삭제됩니다.')) return;
try {
await api(API, { method:'DELETE' });
toast('등록을 해제했습니다');
await load();
} catch(e){ toast('해제 실패: ' + e.message, true); }
}
// ---------- init ----------
async function load(){
let s;
try { s = await api(API); }
catch(e){ toast('불러오기 실패: ' + e.message, true); return; }
if(!s || !s.registered){
document.getElementById('setup').classList.remove('hidden');
document.getElementById('main').classList.add('hidden');
document.getElementById('headerActions').innerHTML = '';
if(window.lucide) lucide.createIcons();
return;
}
document.getElementById('setup').classList.add('hidden');
document.getElementById('main').classList.remove('hidden');
renderSummary(s);
const [topics, videos, growth] = await Promise.all([
api(API + '/topics').catch(()=>[]),
api(API + '/videos').catch(()=>[]),
api(API + '/growth').catch(()=>[])
]);
renderTopics(topics, s.avgViews);
renderVideos(videos);
renderGrowth(growth);
}
load();
/*]]>*/
</script>
</div>
</body>
</html>

View File

@ -1,136 +0,0 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{layout/base}">
<head>
<title>h-lab - 발행</title>
</head>
<body>
<div layout:fragment="content">
<div class="page-header">
<div>
<h1>발행 큐</h1>
<p class="sub">재가공한 영상의 발행 패키지를 단계별로 관리합니다. (실제 업로드는 수동 — 여기서 준비·추적)</p>
</div>
<div class="actions">
<button class="btn btn-secondary" onclick="openHelp()"><i data-lucide="help-circle" style="width:15px;"></i> 사용법</button>
</div>
</div>
<!-- 사용법 모달 -->
<div id="helpModal" class="modal-overlay" onclick="if(event.target===this) closeHelp()">
<div class="modal-card">
<div class="modal-head"><h3>📖 발행 큐 사용법</h3><button class="modal-close" onclick="closeHelp()">&times;</button></div>
<div class="modal-body">
<div class="help-item">
<div class="hi-ic"><i data-lucide="list-checks"></i></div>
<div><div class="hi-t">상태 탭</div><div class="hi-d"><b>작성중 → 발행대기 → 발행완료</b>로 발행 준비 단계를 거릅니다. 탭으로 상태별로 필터합니다.</div></div>
</div>
<div class="help-item">
<div class="hi-ic"><i data-lucide="pencil"></i></div>
<div><div class="hi-t">발행안 만들기</div><div class="hi-d">발행 패키지는 <b>재가공 화면 하단</b>에서 제목·설명·해시태그·플랫폼·예약을 저장하면 생성됩니다. 표의 <b>✏️</b>로 다시 편집합니다.</div></div>
</div>
<div class="help-item">
<div class="hi-ic"><i data-lucide="upload-cloud"></i></div>
<div><div class="hi-t">업로드 & 기록</div><div class="hi-d">실제 업로드는 <b>플랫폼에서 수동</b>으로 합니다. 업로드 후 URL을 기록하면 ‘발행완료’로 추적됩니다. <b>📋</b>로 설명+해시태그를 복사하세요.</div></div>
</div>
</div>
</div>
</div>
<div class="flex gap-2 mb-4" id="tabs" style="flex-wrap:wrap; align-items:center;">
<button class="btn btn-secondary tab active" data-status="" onclick="setTab(this,'')">전체</button>
<button class="btn btn-secondary tab" data-status="DRAFT" onclick="setTab(this,'DRAFT')">작성중</button>
<button class="btn btn-secondary tab" data-status="READY" onclick="setTab(this,'READY')">발행대기</button>
<button class="btn btn-secondary tab" data-status="PUBLISHED" onclick="setTab(this,'PUBLISHED')">발행완료</button>
<span id="cnt" class="badge badge-muted" style="margin-left:auto;"></span>
</div>
<div class="card p-0" style="overflow-x:auto;">
<table class="w-full" style="border-collapse:collapse; text-align:left;">
<thead>
<tr>
<th>상태</th>
<th>플랫폼</th>
<th>제목</th>
<th>예약</th>
<th>발행 URL</th>
<th>관리</th>
</tr>
</thead>
<tbody id="body">
<tr><td colspan="6" class="p-8 text-center text-muted">로딩 중...</td></tr>
</tbody>
</table>
</div>
<style>
.tab { padding:0.5rem 1rem; }
.tab.active { background:var(--accent-soft); border-color:var(--accent); color:var(--accent); }
</style>
<script th:inline="javascript">
/*<![CDATA[*/
let curStatus = '';
const ST = { DRAFT:{t:'작성중',cls:'badge-muted'}, READY:{t:'발행대기',cls:'badge-warning'}, PUBLISHED:{t:'발행완료',cls:'badge-success'} };
function esc(s){ return (s==null?'':String(s)).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
async function api(url, opts){
const res = await fetch(url, opts);
const json = await res.json().catch(()=>({}));
if(!res.ok || (json && json.success===false)) throw new Error((json && json.message)||('HTTP '+res.status));
return json.data;
}
function setTab(btn, status){
curStatus = status;
document.querySelectorAll('.tab').forEach(t=>t.classList.remove('active'));
btn.classList.add('active');
load();
}
async function load(){
const body = document.getElementById('body');
body.innerHTML = '<tr><td colspan="6" class="p-8 text-center text-muted">로딩 중...</td></tr>';
let data;
try { data = await api('/api/v1/publish' + (curStatus?('?status='+curStatus):'')) || []; }
catch(e){ body.innerHTML = '<tr><td colspan="6" class="p-8 text-center text-danger">불러오기 실패: '+esc(e.message)+'</td></tr>'; return; }
document.getElementById('cnt').textContent = data.length + '건';
if(data.length===0){ body.innerHTML = '<tr><td colspan="6" class="p-8 text-center text-muted">발행 패키지가 없습니다. 재가공 화면에서 발행안을 저장하세요.</td></tr>'; return; }
body.innerHTML = data.map(p=>{
const st = ST[p.status] || {t:p.status,cls:'badge-muted'};
const sched = p.scheduledAt ? String(p.scheduledAt).substring(0,16).replace('T',' ') : '-';
const urlCell = p.publishedUrl ? `<a href="${esc(p.publishedUrl)}" target="_blank" class="hover:underline" style="color:var(--accent);">열기</a>` : '-';
return `<tr style="border-bottom:1px solid var(--glass-border);">
<td class="p-3"><span class="badge ${st.cls}">${st.t}</span></td>
<td class="p-3 text-sm">${esc(p.platform||'')}</td>
<td class="p-3 text-sm" style="max-width:360px;"><div style="display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;">${esc(p.title||'(제목 없음)')}</div></td>
<td class="p-3 text-sm text-muted">${sched}</td>
<td class="p-3 text-sm">${urlCell}</td>
<td class="p-3">
<div class="flex items-center gap-1">
<a class="btn btn-secondary p-2" title="재가공/편집" href="/rework/${p.channelVideoId}"><i data-lucide="pencil" style="width:15px;"></i></a>
<button class="btn btn-secondary p-2" title="설명 복사" onclick='copyDesc(${JSON.stringify((p.description||"")+ "\n\n" + (p.hashtags||""))})'><i data-lucide="copy" style="width:15px;"></i></button>
</div>
</td>
</tr>`;
}).join('');
if(window.lucide) lucide.createIcons();
}
function copyDesc(text){
navigator.clipboard.writeText(text).then(()=>alert('설명+해시태그가 복사되었습니다.'));
}
function openHelp(){ document.getElementById('helpModal').classList.add('open'); }
function closeHelp(){ document.getElementById('helpModal').classList.remove('open'); }
document.addEventListener('keydown', e => { if(e.key === 'Escape') closeHelp(); });
load();
/*]]>*/
</script>
</div>
</body>
</html>

View File

@ -7,10 +7,14 @@
<div class="page-header"> <div class="page-header">
<div> <div>
<h1>추천 채널</h1> <h1>추천 채널</h1>
<p class="sub">지역(KR·JP·US) 인기 Shorts에서 자동 발굴한 떡상 채널 — 구독자는 적은데 조회수가 터진 채널</p> <p class="sub">KR 인기 영상(롱폼/쇼츠)에서 자동 발굴한 떡상 채널 — 구독자는 적은데 조회수가 터진 채널</p>
</div> </div>
<div class="actions"> <div class="actions">
<button class="btn btn-secondary" id="viewToggle" onclick="toggleView()"><i data-lucide="archive" style="width:15px;"></i> 제외 목록</button> <button class="btn btn-secondary" id="viewToggle" onclick="toggleView()"><i data-lucide="archive" style="width:15px;"></i> 제외 목록</button>
<select id="discFormat" class="btn btn-secondary" style="padding:0.4rem 0.7rem;" title="발굴 대상 포맷">
<option value="LONG_FORM" selected>롱폼</option>
<option value="SHORTS">쇼츠</option>
</select>
<button class="btn btn-secondary" onclick="runDiscovery()"><i data-lucide="refresh-cw" style="width:15px;"></i> 지금 발굴</button> <button class="btn btn-secondary" onclick="runDiscovery()"><i data-lucide="refresh-cw" style="width:15px;"></i> 지금 발굴</button>
</div> </div>
</div> </div>
@ -91,6 +95,7 @@
const actions = excludedView const actions = excludedView
? '<button class="btn btn-primary px-3 py-2" onclick="restore('+c.id+')"><i data-lucide="rotate-ccw" style="width:14px;"></i> 복원</button>' + moveBtn ? '<button class="btn btn-primary px-3 py-2" onclick="restore('+c.id+')"><i data-lucide="rotate-ccw" style="width:14px;"></i> 복원</button>' + moveBtn
: '<button class="btn btn-primary px-3 py-2" onclick="register('+c.id+')"><i data-lucide="user-plus" style="width:14px;"></i> 내 채널 등록</button>' + moveBtn : '<button class="btn btn-primary px-3 py-2" onclick="register('+c.id+')"><i data-lucide="user-plus" style="width:14px;"></i> 내 채널 등록</button>' + moveBtn
+ '<button class="btn btn-secondary px-3 py-2" onclick="rework('+c.id+',this)"'+(c.topVideoId?'':' disabled')+'><i data-lucide="wand-2" style="width:14px;"></i> 영상 재가공</button>'
+ '<button class="btn btn-secondary px-3 py-2" onclick="exclude('+c.id+')"><i data-lucide="x" style="width:14px;"></i> 제외</button>'; + '<button class="btn btn-secondary px-3 py-2" onclick="exclude('+c.id+')"><i data-lucide="x" style="width:14px;"></i> 제외</button>';
return '<div class="rc-card" id="rc-'+c.id+'">' return '<div class="rc-card" id="rc-'+c.id+'">'
+ '<img class="rc-thumb" style="cursor:pointer;" src="'+esc(c.thumbnailUrl)+'" loading="lazy" data-vid="'+esc(c.topVideoId)+'" data-title="'+esc(c.topVideoTitle||c.channelTitle||'').replace(/"/g,'&quot;')+'" onclick="openVideoModal(this)">' + '<img class="rc-thumb" style="cursor:pointer;" src="'+esc(c.thumbnailUrl)+'" loading="lazy" data-vid="'+esc(c.topVideoId)+'" data-title="'+esc(c.topVideoTitle||c.channelTitle||'').replace(/"/g,'&quot;')+'" onclick="openVideoModal(this)">'
@ -107,6 +112,11 @@
async function register(id){ try{ await api(API+'/'+id+'/register',{method:'POST'}); ALL_RECS=ALL_RECS.filter(c=>c.id!==id); renderGrid(); }catch(e){ alert('등록 실패: '+e.message); } } async function register(id){ try{ await api(API+'/'+id+'/register',{method:'POST'}); ALL_RECS=ALL_RECS.filter(c=>c.id!==id); renderGrid(); }catch(e){ alert('등록 실패: '+e.message); } }
async function exclude(id){ try{ await api(API+'/'+id+'/exclude',{method:'POST'}); ALL_RECS=ALL_RECS.filter(c=>c.id!==id); renderGrid(); }catch(e){ alert('제외 실패: '+e.message); } } async function exclude(id){ try{ await api(API+'/'+id+'/exclude',{method:'POST'}); ALL_RECS=ALL_RECS.filter(c=>c.id!==id); renderGrid(); }catch(e){ alert('제외 실패: '+e.message); } }
async function restore(id){ try{ await api(API+'/'+id+'/restore',{method:'POST'}); ALL_RECS=ALL_RECS.filter(c=>c.id!==id); renderGrid(); }catch(e){ alert('복원 실패: '+e.message); } } async function restore(id){ try{ await api(API+'/'+id+'/restore',{method:'POST'}); ALL_RECS=ALL_RECS.filter(c=>c.id!==id); renderGrid(); }catch(e){ alert('복원 실패: '+e.message); } }
async function rework(id, btn){
if(btn){ btn.disabled=true; btn.innerHTML='<i data-lucide="loader" style="width:14px;"></i> 준비 중…'; if(window.lucide) lucide.createIcons(); }
try{ const d=await api(API+'/'+id+'/rework',{method:'POST'}); location.href='/rework/'+d.videoId; }
catch(e){ alert('재가공 준비 실패: '+e.message); if(btn){ btn.disabled=false; btn.innerHTML='<i data-lucide="wand-2" style="width:14px;"></i> 영상 재가공'; if(window.lucide) lucide.createIcons(); } }
}
function openVideoModal(el){ function openVideoModal(el){
const vid = el.dataset.vid || ''; const vid = el.dataset.vid || '';
document.getElementById('modalTitle').textContent = el.dataset.title || ''; document.getElementById('modalTitle').textContent = el.dataset.title || '';
@ -120,8 +130,10 @@
document.addEventListener('keydown', e => { if(e.key === 'Escape') closeVideoModal(); }); document.addEventListener('keydown', e => { if(e.key === 'Escape') closeVideoModal(); });
async function runDiscovery(){ async function runDiscovery(){
const s=document.getElementById('status'); s.style.display='block'; s.style.color='#facc15'; s.textContent='발굴 중… (지역별 Shorts 검색, 잠시 걸립니다)'; const fmt=(document.getElementById('discFormat')||{}).value||'LONG_FORM';
try{ const r=await api(API+'/run',{method:'POST'}); s.style.color='#4ade80'; s.textContent='발굴 완료 · 신규 '+(r.saved??0)+'개 · 지역 '+((r.regions||[]).join(',')); await load(); } const fmtKo=(fmt==='SHORTS')?'쇼츠':'롱폼';
const s=document.getElementById('status'); s.style.display='block'; s.style.color='#facc15'; s.textContent='발굴 중… ('+fmtKo+' 검색, 잠시 걸립니다)';
try{ const r=await api(API+'/run?format='+encodeURIComponent(fmt),{method:'POST'}); s.style.color='#4ade80'; s.textContent='발굴 완료 · '+((r.format==='SHORTS')?'쇼츠':'롱폼')+' · 신규 '+(r.saved??0)+'개 · 지역 '+((r.regions||[]).join(',')); await load(); }
catch(e){ s.style.color='#f87171'; s.textContent='발굴 실패: '+e.message; } catch(e){ s.style.color='#f87171'; s.textContent='발굴 실패: '+e.message; }
} }
load(); load();

View File

@ -0,0 +1,485 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{layout/base}">
<head>
<title>h-lab - 댓글 답장</title>
</head>
<body>
<div layout:fragment="content">
<div class="page-header">
<div>
<h1>댓글 답장</h1>
<p class="sub">내 영상에 달린 댓글 중 아직 답 안 한 것만 모아, 영상 내용까지 보고 초안을 씁니다.</p>
</div>
<div class="actions">
<button class="btn btn-secondary" id="batchBtn" onclick="draftAll()">
<i data-lucide="wand-sparkles" style="width:15px;"></i> 전체 초안 생성
</button>
<button class="btn btn-primary" id="collectBtn" onclick="collect()">
<i data-lucide="download" style="width:15px;"></i> 댓글 수집
</button>
</div>
</div>
<!-- 현황 + 필터 -->
<div class="card mb-4">
<div class="toolbar">
<div class="rp-stats" id="stats"></div>
<span class="spacer"></span>
<label class="field" for="fStatus">보기
<select id="fStatus" onchange="load()">
<option value="">답장 대기 (미응답+초안)</option>
<option value="NEW">미응답만</option>
<option value="DRAFTED">초안 있는 것만</option>
<option value="DONE">완료</option>
</select>
</label>
</div>
</div>
<!-- 일괄 생성 진행 -->
<div id="batchBar" class="card mb-4 hidden" role="status" aria-live="polite">
<div class="flex items-center gap-3" style="flex-wrap:wrap;">
<i data-lucide="loader-2" class="animate-spin" style="width:16px; color:var(--accent);"></i>
<span class="text-sm font-semibold" id="batchLabel">초안 생성 중...</span>
<div class="bar-track" style="flex:1; min-width:140px;">
<div class="bar-fill" id="batchFill" style="width:0%; background:var(--accent);"></div>
</div>
<span class="badge badge-muted" id="batchCount">0 / 0</span>
<button class="btn btn-secondary" style="padding:0.4rem 0.7rem;" onclick="cancelBatch()">중단</button>
</div>
<p class="text-xs text-muted mt-2" id="batchNote">
영상을 처음 읽는 건은 시간이 더 걸립니다. 만든 초안은 그때그때 저장되니 중단해도 남습니다.
</p>
</div>
<div id="list" class="rp-list"></div>
<div id="state" class="rp-state hidden"></div>
<div id="toast" class="toast" role="status" aria-live="polite"></div>
<style>
/* 현황 */
.rp-stats { display:flex; gap:1.25rem; flex-wrap:wrap; }
.rp-stat { display:flex; align-items:baseline; gap:0.35rem; font-size:0.78rem; color:var(--text-3); }
.rp-stat b { font-size:1rem; font-weight:700; color:var(--text); font-family:var(--font-mono); font-variant-numeric:tabular-nums; }
/* 목록 */
.rp-list { display:flex; flex-direction:column; gap:0.85rem; }
.rp-card {
display:grid; grid-template-columns:132px 1fr; gap:1rem;
background:var(--surface); border:1px solid var(--border); border-radius:var(--r);
padding:0.95rem 1.1rem;
transition:border-color .18s ease, box-shadow .18s ease;
}
.rp-card:hover { border-color:var(--border-strong); box-shadow:var(--shadow); }
.rp-card.is-done { opacity:.55; }
.rp-card.is-done:hover { opacity:1; }
@media (max-width: 720px) { .rp-card { grid-template-columns:1fr; } }
/* 영상 */
.rp-vid { min-width:0; }
.rp-vid .th { display:block; aspect-ratio:9/16; max-height:150px; border-radius:var(--r-sm);
overflow:hidden; background:var(--inset); }
.rp-vid .th img { width:100%; height:100%; object-fit:cover; display:block; }
.rp-vid .th:focus-visible { outline:2px solid var(--accent); outline-offset:2px; }
.rp-vid .t {
font-size:0.72rem; color:var(--text-3); margin-top:0.35rem; line-height:1.4;
display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical; overflow:hidden;
}
/* 댓글 */
.rp-body { min-width:0; display:flex; flex-direction:column; gap:0.6rem; }
.rp-who { display:flex; align-items:center; gap:0.5rem; flex-wrap:wrap; }
.rp-who img { width:26px; height:26px; border-radius:50%; background:var(--inset); flex-shrink:0; }
.rp-who b { font-size:0.83rem; font-weight:600; }
.rp-who span { font-size:0.72rem; color:var(--text-3); }
.rp-who .num { font-family:var(--font-mono); font-variant-numeric:tabular-nums; }
.rp-text {
font-size:0.9rem; line-height:1.65; color:var(--text);
white-space:pre-wrap; word-break:break-word; max-width:70ch;
}
/* 초안 */
.rp-drafts { display:flex; flex-direction:column; gap:0.5rem; }
.rp-draft {
display:flex; align-items:flex-start; gap:0.6rem;
background:var(--surface-2); border:1px solid var(--border); border-radius:var(--r-sm);
padding:0.6rem 0.7rem;
}
.rp-draft .k {
flex:0 0 auto; font-size:0.68rem; font-weight:700; color:var(--text-3);
border:1px solid var(--border-strong); border-radius:4px; padding:1px 5px; margin-top:2px;
}
.rp-draft .v { flex:1; font-size:0.86rem; line-height:1.6; word-break:break-word; }
.rp-draft button {
flex:0 0 auto; display:inline-flex; align-items:center; justify-content:center; gap:4px;
min-height:34px; padding:0 0.6rem;
background:var(--surface); border:1px solid var(--border-strong); border-radius:var(--r-sm);
color:var(--text-2); font-size:0.76rem; font-weight:600; font-family:inherit; cursor:pointer;
transition:all .18s ease;
}
.rp-draft button:hover { background:var(--hover); color:var(--text); }
.rp-draft button.ok { color:var(--success); border-color:var(--success); }
.rp-actions { display:flex; align-items:center; gap:0.5rem; flex-wrap:wrap; }
.rp-actions .btn { min-height:38px; padding:0.5rem 0.85rem; font-size:0.8rem; }
/* 상태 */
.rp-state { padding:3rem 1rem; text-align:center; color:var(--text-2); }
.rp-state .ic {
width:44px; height:44px; margin:0 auto 0.9rem; border-radius:12px;
background:var(--surface-2); color:var(--text-3);
display:flex; align-items:center; justify-content:center;
}
.rp-state .t { font-weight:600; color:var(--text); margin-bottom:0.35rem; }
.rp-state .d { font-size:0.85rem; line-height:1.6; }
.rp-state .a { margin-top:1rem; display:flex; gap:0.5rem; justify-content:center; flex-wrap:wrap; }
.skel { border:1px solid var(--border); border-radius:var(--r); background:var(--surface);
display:grid; grid-template-columns:132px 1fr; gap:1rem; padding:0.95rem 1.1rem; }
.skel .a1 { aspect-ratio:9/16; max-height:150px; border-radius:var(--r-sm); }
.skel .b1 { height:12px; margin-bottom:0.6rem; border-radius:4px; }
.skel .b2 { height:12px; width:70%; border-radius:4px; }
.toast {
position:fixed; left:50%; bottom:24px; transform:translate(-50%, 12px);
background:var(--text); color:var(--bg);
padding:0.65rem 1.1rem; border-radius:var(--r-sm); font-size:0.85rem; font-weight:600;
box-shadow:var(--shadow-lg); z-index:2000;
opacity:0; pointer-events:none; transition:opacity .2s ease, transform .2s ease;
max-width:88vw; text-align:center;
}
.toast.show { opacity:1; transform:translate(-50%, 0); }
.toast.err { background:var(--danger); color:#fff; }
@media (prefers-reduced-motion: reduce) {
.rp-card, .rp-draft button, .toast { transition:none; }
}
</style>
<script th:inline="javascript">
/*<![CDATA[*/
const API = '/api/replies';
let items = [];
function esc(s){ return (s==null?'':String(s)).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
function fmtNum(n){ return n==null ? '0' : Number(n).toLocaleString(); }
function fmtAgo(iso){
if(!iso) return '';
const t = new Date(iso).getTime();
if(isNaN(t)) return '';
const m = Math.max(0, Math.floor((Date.now()-t)/60000));
if(m < 60) return `${m} `;
const h = Math.floor(m/60);
if(h < 24) return `${h}시간 `;
const d = Math.floor(h/24);
return d < 30 ? `${d} ` : new Date(t).toLocaleDateString('ko-KR');
}
let toastTimer = null;
function toast(msg, isErr){
const el = document.getElementById('toast');
el.textContent = msg;
el.classList.toggle('err', !!isErr);
el.classList.add('show');
clearTimeout(toastTimer);
toastTimer = setTimeout(()=> el.classList.remove('show'), 3500);
}
async function api(url, opts){
const res = await fetch(url, opts);
const json = await res.json().catch(()=>({}));
if(!res.ok || (json && json.success===false)) throw new Error((json && json.message) || ('HTTP '+res.status));
return json.data;
}
function showState(icon, title, desc, actions){
document.getElementById('list').innerHTML = '';
const el = document.getElementById('state');
el.classList.remove('hidden');
el.innerHTML = `<div class="ic"><i data-lucide="${icon}"></i></div>
<div class="t">${esc(title)}</div><div class="d">${desc}</div>
${actions ? `<div class="a">${actions}</div>` : ''}`;
if(window.lucide) lucide.createIcons();
}
function hideState(){ document.getElementById('state').classList.add('hidden'); }
function showSkeleton(){
hideState();
const one = `<div class="skel"><div class="a1 skeleton"></div>
<div><div class="b1 skeleton"></div><div class="b2 skeleton"></div></div></div>`;
document.getElementById('list').innerHTML = one.repeat(4);
}
// ---------- 현황 ----------
async function loadStats(){
let s;
try { s = await api(API + '/stats'); } catch(e){ return; }
document.getElementById('stats').innerHTML = `
<div class="rp-stat"><b>${s.pending}</b> 미응답</div>
<div class="rp-stat"><b>${s.drafted}</b> 초안 있음</div>
<div class="rp-stat"><b>${s.done}</b> 완료</div>
<div class="rp-stat" title="내가 예전에 단 답글에서 말투를 학습합니다"><b>${s.styleSamples}</b> 말투 샘플</div>`;
}
// ---------- 목록 ----------
async function load(){
showSkeleton();
const st = document.getElementById('fStatus').value;
try {
items = await api(API + (st ? '?status='+st : '')) || [];
} catch(e){
showState('alert-triangle', '불러오지 못했습니다', esc(e.message),
'<button class="btn btn-secondary" onclick="load()">다시 시도</button>');
return;
}
render();
loadStats();
}
function draftRow(key, label, text, id){
if(!text) return '';
return `<div class="rp-draft">
<span class="k">${label}</span>
<div class="v">${esc(text)}</div>
<button onclick="copyDraft(this, ${id}, '${key}')" aria-label="${label} 초안 복사">
<i data-lucide="copy" style="width:13px;"></i> 복사</button>
</div>`;
}
function cardHtml(c){
const url = 'https://www.youtube.com/watch?v=' + encodeURIComponent(c.videoId);
const done = c.status === 'DONE';
const hasDraft = !!(c.draftA || c.draftB);
const drafts = hasDraft
? `<div class="rp-drafts">
${draftRow('a','A', c.draftA, c.id)}
${draftRow('b','B', c.draftB, c.id)}
</div>` : '';
const others = c.replyCount > 0
? `<span>· 대댓글 <span class="num">${c.replyCount}</span></span>` : '';
return `<article class="rp-card${done ? ' is-done' : ''}">
<div class="rp-vid">
<a class="th" href="${url}" target="_blank" rel="noopener"
aria-label="${esc(c.videoTitle)} — YouTube 에서 열기">
<img src="${esc(c.videoThumbnailUrl)}" alt="" loading="lazy" width="180" height="320">
</a>
<div class="t">${esc(c.videoTitle)}</div>
</div>
<div class="rp-body">
<div class="rp-who">
<img src="${esc(c.authorImageUrl)}" alt="" loading="lazy">
<b>${esc(c.authorName)}</b>
<span>${fmtAgo(c.publishedAt)}</span>
<span>· 좋아요 <span class="num">${fmtNum(c.likeCount)}</span></span>
${others}
${done ? '<span class="badge badge-success">완료</span>' : ''}
</div>
<div class="rp-text">${esc(c.text)}</div>
${drafts}
<div class="rp-actions">
<button class="btn ${hasDraft ? 'btn-secondary' : 'btn-primary'}"
id="draftBtn-${c.id}" onclick="makeDraft(${c.id}, ${hasDraft})"
title="${hasDraft ? '영상을 다시 읽고 새로 씁니다' : '영상 내용을 근거로 초안 2개를 만듭니다'}">
<i data-lucide="sparkles" style="width:14px;"></i>
${hasDraft ? '다시 생성' : '초안 생성'}
</button>
<a class="btn btn-secondary" href="${url}" target="_blank" rel="noopener">
<i data-lucide="external-link" style="width:14px;"></i> 영상에서 답글 달기</a>
${done
? `<button class="btn btn-secondary" onclick="undo(${c.id})">
<i data-lucide="rotate-ccw" style="width:14px;"></i> 완료 취소</button>`
: `<button class="btn btn-secondary" onclick="markDone(${c.id})">
<i data-lucide="check" style="width:14px;"></i> 답글 달았음</button>`}
</div>
</div>
</article>`;
}
function render(){
if(items.length === 0){
const st = document.getElementById('fStatus').value;
if(st === 'DONE'){
showState('inbox', '완료한 댓글이 없습니다', '답글을 달고 <b>답글 달았음</b>을 누르면 여기로 옵니다.');
} else {
showState('message-square', '답장할 댓글이 없습니다',
'내 채널이 등록돼 있어야 합니다. 등록·동기화 후 <b>댓글 수집</b>을 누르세요.',
`<a class="btn btn-secondary" href="/my-channel">내 채널로</a>
<button class="btn btn-primary" onclick="collect()">댓글 수집</button>`);
}
return;
}
hideState();
document.getElementById('list').innerHTML = items.map(cardHtml).join('');
updateBatchBtn();
if(window.lucide) lucide.createIcons();
}
/** 일괄 생성 버튼에 남은 건수를 붙이고, 할 게 없으면 끈다. */
function updateBatchBtn(){
const btn = document.getElementById('batchBtn');
if(!btn || btn.disabled) return; // 실행 중이면 건드리지 않는다
const n = items.filter(c => c.status !== 'DONE' && !c.draftA && !c.draftB).length;
btn.innerHTML = `<i data-lucide="wand-sparkles" style="width:15px;"></i> 전체 초안 생성${n ? ` (${n})` : ''}`;
btn.classList.toggle('btn-secondary', true);
btn.style.opacity = n ? '' : '.5';
btn.style.pointerEvents = n ? '' : 'none';
}
// ---------- 액션 ----------
async function collect(){
const btn = document.getElementById('collectBtn');
btn.disabled = true;
const orig = btn.innerHTML;
btn.innerHTML = '<i data-lucide="loader-2" style="width:15px;" class="animate-spin"></i> 수집 중...';
if(window.lucide) lucide.createIcons();
try {
const r = await api(API + '/collect', { method:'POST' });
toast(`영상 ${r.videos}개에서 미응답 ${r.pending}건 · 이미 답한 것 ${r.alreadyReplied}건`);
await load();
} catch(e){
toast(e.message, true);
} finally {
btn.disabled = false; btn.innerHTML = orig;
if(window.lucide) lucide.createIcons();
}
}
// 다시 생성이면 캐시된 영상 요약도 버리고 다시 읽는다 — 요약이 틀려서 다시 누르는 경우가 많다
async function makeDraft(id, refresh){
const btn = document.getElementById('draftBtn-' + id);
if(btn){
btn.disabled = true;
btn.innerHTML = '<i data-lucide="loader-2" style="width:14px;" class="animate-spin"></i> 생성 중...';
if(window.lucide) lucide.createIcons();
}
try {
const updated = await api(`${API}/${id}/draft?refreshContext=${refresh ? 'true' : 'false'}`,
{ method:'POST' });
const i = items.findIndex(v => v.id === id);
if(i >= 0) items[i] = updated;
render();
loadStats();
toast('초안 2개를 만들었습니다');
} catch(e){
toast('생성 실패: ' + e.message, true);
render();
}
}
// ---------- 일괄 생성 ----------
// 서버에서 통짜로 돌리면 몇 분짜리 요청이 되어 프록시에서 끊긴다.
// 한 건씩 순차 호출해 진행률을 보여주고, 중단해도 앞서 만든 초안은 남게 한다.
let batchAbort = false;
function setBatchUI(on, done, total, label){
const bar = document.getElementById('batchBar');
bar.classList.toggle('hidden', !on);
document.getElementById('batchBtn').disabled = on;
document.getElementById('collectBtn').disabled = on;
if(on){
document.getElementById('batchCount').textContent = `${done} / ${total}`;
document.getElementById('batchFill').style.width = total ? (done/total*100)+'%' : '0%';
if(label) document.getElementById('batchLabel').textContent = label;
if(window.lucide) lucide.createIcons();
}
}
function cancelBatch(){
batchAbort = true;
document.getElementById('batchLabel').textContent = '중단하는 중... (진행 중인 1건은 끝냅니다)';
}
async function draftAll(){
const targets = items.filter(c => c.status !== 'DONE' && !c.draftA && !c.draftB);
if(targets.length === 0){
toast('초안이 없는 댓글이 없습니다');
return;
}
// 실측 건당 26~32초(영상을 처음 읽는 비용). 예상 시간을 알려주고 시작한다.
const mins = Math.max(1, Math.round(targets.length * 30 / 60));
if(!confirm(`초안 없는 댓글 ${targets.length}건에 초안을 만듭니다.\n`
+ `건당 30초 안팎이라 약 ${mins}분 걸립니다.\n\n`
+ `창을 켜둔 채로 두시면 되고, 만든 초안은 그때그때 저장됩니다.\n진행할까요?`)) return;
batchAbort = false;
let done = 0, failed = 0;
setBatchUI(true, 0, targets.length, '초안 생성 중...');
for(const c of targets){
if(batchAbort) break;
try {
const updated = await api(`${API}/${c.id}/draft?refreshContext=false`, { method:'POST' });
const i = items.findIndex(v => v.id === c.id);
if(i >= 0) items[i] = updated;
} catch(e){
failed++;
console.warn('초안 실패', c.id, e.message);
}
done++;
setBatchUI(true, done, targets.length);
render(); // 완성되는 대로 카드에 반영
}
setBatchUI(false);
loadStats();
const msg = batchAbort
? `중단했습니다 — ${done - failed}건 생성`
: `${done - failed}건 생성 완료`;
toast(failed ? `${msg} · ${failed}건 실패` : msg, failed > 0);
}
async function copyDraft(btn, id, key){
const c = items.find(v => v.id === id);
if(!c) return;
const text = key === 'a' ? c.draftA : c.draftB;
try {
await navigator.clipboard.writeText(text);
} catch(e){
// 클립보드 권한이 없을 때의 폴백
const ta = document.createElement('textarea');
ta.value = text; document.body.appendChild(ta); ta.select();
try { document.execCommand('copy'); } finally { document.body.removeChild(ta); }
}
const orig = btn.innerHTML;
btn.classList.add('ok');
btn.innerHTML = '<i data-lucide="check" style="width:13px;"></i> 복사됨';
if(window.lucide) lucide.createIcons();
setTimeout(()=>{
btn.classList.remove('ok');
btn.innerHTML = orig;
if(window.lucide) lucide.createIcons();
}, 1600);
}
async function markDone(id){
try {
await api(`${API}/${id}/done`, { method:'POST' });
items = items.filter(v => v.id !== id);
render(); loadStats();
toast('완료 처리했습니다');
} catch(e){ toast('실패: ' + e.message, true); }
}
async function undo(id){
try {
await api(`${API}/${id}/undo`, { method:'POST' });
await load();
toast('미응답으로 되돌렸습니다');
} catch(e){ toast('실패: ' + e.message, true); }
}
load();
/*]]>*/
</script>
</div>
</body>
</html>

View File

@ -85,7 +85,7 @@
</div> </div>
<div class="help-item"> <div class="help-item">
<div class="hi-ic"><i data-lucide="send"></i></div> <div class="hi-ic"><i data-lucide="send"></i></div>
<div><div class="hi-t">재작성 · 발행</div><div class="hi-d">‘재작성(내 버전)’에 각색해 저장하면 상태가 <b>TARGET</b>으로 바뀝니다. 하단 <b>발행 준비</b>에서 제목·해시태그를 저장하면 발행 큐로 들어갑니다.</div></div> <div><div class="hi-t">재작성</div><div class="hi-d">‘재작성(내 버전)’에 각색해 저장하면 상태가 <b>TARGET</b>으로 바뀝니다.</div></div>
</div> </div>
</div> </div>
</div> </div>
@ -121,7 +121,10 @@
<div class="font-bold" id="vTitle" style="line-height:1.4;"></div> <div class="font-bold" id="vTitle" style="line-height:1.4;"></div>
<div class="text-muted" id="vChannel"></div> <div class="text-muted" id="vChannel"></div>
<div class="flex gap-3 mt-2 text-muted" id="vStats"></div> <div class="flex gap-3 mt-2 text-muted" id="vStats"></div>
<a id="vLink" href="#" target="_blank" class="text-sm hover:underline mt-2" style="color:#60a5fa;">YouTube에서 열기 →</a> <div class="flex items-center gap-3 mt-2" style="flex-wrap:wrap;">
<a id="vLink" href="#" target="_blank" class="text-sm hover:underline" style="color:#60a5fa;">YouTube에서 열기 →</a>
<button id="vCopyBtn" type="button" onclick="copyVideoUrl(this)" class="text-sm hover:underline" style="color:#60a5fa; background:none; border:none; cursor:pointer; padding:0;">URL 복사</button>
</div>
</div> </div>
</div> </div>
@ -265,66 +268,10 @@
style="width:100%; min-height:280px; resize:vertical; padding:12px; background:var(--surface-2); border:1px solid var(--glass-border); border-radius:var(--radius-md); color:var(--text); outline:none; font-size:0.95rem; line-height:1.7;"></textarea> style="width:100%; min-height:280px; resize:vertical; padding:12px; background:var(--surface-2); border:1px solid var(--glass-border); border-radius:var(--radius-md); color:var(--text); outline:none; font-size:0.95rem; line-height:1.7;"></textarea>
<div class="text-sm text-muted mt-2" id="saveInfo"></div> <div class="text-sm text-muted mt-2" id="saveInfo"></div>
</div> </div>
<!-- 발행 준비 -->
<div class="card">
<div class="flex items-center justify-between mb-3">
<h3 class="text-lg font-bold">📤 발행 준비</h3>
<span id="pubStatusBadge" class="text-sm text-muted"></span>
</div>
<div class="flex flex-col gap-3">
<div>
<label class="text-sm text-muted">발행 제목</label>
<input id="pubTitle" type="text" class="pub-in" placeholder="업로드할 새 제목">
</div>
<div>
<label class="text-sm text-muted">설명</label>
<textarea id="pubDesc" class="pub-in" style="min-height:80px; resize:vertical;" placeholder="영상 설명"></textarea>
</div>
<div>
<label class="text-sm text-muted">해시태그</label>
<input id="pubTags" type="text" class="pub-in" placeholder="#태그1 #태그2">
</div>
<div class="flex gap-3" style="flex-wrap:wrap;">
<div style="flex:1; min-width:120px;">
<label class="text-sm text-muted">플랫폼</label>
<select id="pubPlatform" class="pub-in">
<option value="YOUTUBE">YouTube</option>
<option value="TIKTOK">TikTok</option>
<option value="REELS">Instagram Reels</option>
</select>
</div>
<div style="flex:1; min-width:160px;">
<label class="text-sm text-muted">예약 일시</label>
<input id="pubSchedule" type="datetime-local" class="pub-in">
</div>
<div style="flex:1; min-width:120px;">
<label class="text-sm text-muted">상태</label>
<select id="pubStatus" class="pub-in">
<option value="DRAFT">작성중</option>
<option value="READY">발행대기</option>
<option value="PUBLISHED">발행완료</option>
</select>
</div>
</div>
<div class="flex gap-2 items-center">
<button class="btn btn-primary px-4 py-2 flex items-center gap-1" onclick="savePublish()" id="pubSaveBtn">
<i data-lucide="save" style="width:15px;"></i> 발행안 저장
</button>
<button class="btn btn-secondary px-4 py-2 flex items-center gap-1" onclick="markPublished()" id="pubDoneBtn">
<i data-lucide="check-circle" style="width:15px;"></i> 발행 완료(URL 기록)
</button>
<a th:href="@{/publish}" class="text-sm text-muted hover:text-white" style="margin-left:auto;">발행 큐 →</a>
</div>
<div id="pubInfo" class="text-sm text-muted"></div>
</div>
</div>
</div> </div>
</div> </div>
<style> <style>
.pub-in { width:100%; padding:9px; background:var(--surface-2); border:1px solid var(--glass-border); border-radius:var(--radius-md); color:var(--text); outline:none; font-size:0.9rem; }
.pub-in option { background:var(--surface); color:var(--text); }
</style> </style>
<script th:inline="javascript"> <script th:inline="javascript">
@ -334,6 +281,15 @@
function esc(s){ return (s==null?'':String(s)).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); } function esc(s){ return (s==null?'':String(s)).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
function fmt(n){ return (n==null)?'-':Number(n).toLocaleString(); } function fmt(n){ return (n==null)?'-':Number(n).toLocaleString(); }
async function copyVideoUrl(btn){
const url = document.getElementById('vLink').href;
if(!url || url.endsWith('#')){ return; }
try {
if(navigator.clipboard && window.isSecureContext){ await navigator.clipboard.writeText(url); }
else { const ta=document.createElement('textarea'); ta.value=url; ta.style.position='fixed'; ta.style.opacity='0'; document.body.appendChild(ta); ta.select(); document.execCommand('copy'); document.body.removeChild(ta); }
const prev = btn.textContent; btn.textContent='복사됨!'; setTimeout(()=>{ btn.textContent=prev; }, 1500);
} catch(e){ alert('복사 실패: '+e.message); }
}
async function api(url, opts){ async function api(url, opts){
const res = await fetch(url, opts); const res = await fetch(url, opts);
const json = await res.json().catch(()=>({})); const json = await res.json().catch(()=>({}));
@ -806,64 +762,6 @@
finally { btn.disabled = false; btn.innerHTML = orig; if(window.lucide) lucide.createIcons(); } finally { btn.disabled = false; btn.innerHTML = orig; if(window.lucide) lucide.createIcons(); }
} }
// ===== 발행 준비 =====
let pubId = null;
async function loadPublish(videoTitle){
let p = null;
try { p = await api('/api/v1/publish/by-video/' + VIDEO_ID); } catch(e){}
if(p){
pubId = p.id;
document.getElementById('pubTitle').value = p.title || '';
document.getElementById('pubDesc').value = p.description || '';
document.getElementById('pubTags').value = p.hashtags || '';
document.getElementById('pubPlatform').value = p.platform || 'YOUTUBE';
document.getElementById('pubStatus').value = p.status || 'DRAFT';
if(p.scheduledAt) document.getElementById('pubSchedule').value = String(p.scheduledAt).substring(0,16);
document.getElementById('pubStatusBadge').textContent = '상태: ' + (p.status||'DRAFT') + (p.publishedUrl?(' · '+p.publishedUrl):'');
} else {
// 신규: 제목 기본값으로 원본 제목 제안
document.getElementById('pubTitle').value = videoTitle || '';
document.getElementById('pubStatusBadge').textContent = '미작성';
}
}
async function savePublish(){
const btn = document.getElementById('pubSaveBtn');
const orig = btn.innerHTML; btn.disabled = true; btn.innerHTML = '저장 중...';
try {
const p = await api('/api/v1/publish/by-video/' + VIDEO_ID, {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({
title: document.getElementById('pubTitle').value,
description: document.getElementById('pubDesc').value,
hashtags: document.getElementById('pubTags').value,
platform: document.getElementById('pubPlatform').value,
scheduledAt: document.getElementById('pubSchedule').value,
status: document.getElementById('pubStatus').value
})
});
pubId = p.id;
document.getElementById('pubStatusBadge').textContent = '상태: ' + (p.status||'DRAFT');
document.getElementById('pubInfo').textContent = '저장됨 · ' + new Date().toLocaleTimeString();
} catch(e){ alert('저장 실패: ' + e.message); }
finally { btn.disabled = false; btn.innerHTML = orig; if(window.lucide) lucide.createIcons(); }
}
async function markPublished(){
if(!pubId){ alert('먼저 발행안을 저장하세요.'); return; }
const url = prompt('업로드한 영상 URL을 입력하세요:');
if(url === null) return;
try {
const p = await api('/api/v1/publish/' + pubId + '/published', {
method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ url })
});
document.getElementById('pubStatus').value = 'PUBLISHED';
document.getElementById('pubStatusBadge').textContent = '상태: PUBLISHED · ' + (p.publishedUrl||'');
document.getElementById('pubInfo').textContent = '발행 완료 기록됨';
} catch(e){ alert('발행 완료 처리 실패: ' + e.message); }
}
function openHelp(){ document.getElementById('helpModal').classList.add('open'); } function openHelp(){ document.getElementById('helpModal').classList.add('open'); }
function closeHelp(){ document.getElementById('helpModal').classList.remove('open'); } function closeHelp(){ document.getElementById('helpModal').classList.remove('open'); }
document.addEventListener('keydown', e => { if(e.key === 'Escape') closeHelp(); }); document.addEventListener('keydown', e => { if(e.key === 'Escape') closeHelp(); });
@ -871,7 +769,7 @@
// ?help=1 딥링크로 들어오면 사용법을 바로 펼친다(공유/안내용). // ?help=1 딥링크로 들어오면 사용법을 바로 펼친다(공유/안내용).
if (new URLSearchParams(location.search).get('help') === '1') openHelp(); if (new URLSearchParams(location.search).get('help') === '1') openHelp();
(async ()=>{ await load(); await loadPublish(document.getElementById('vTitle').textContent); })(); (async ()=>{ await load(); })();
/*]]>*/ /*]]>*/
</script> </script>
</div> </div>

View File

@ -0,0 +1,320 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{layout/base}">
<head>
<title>h-lab - 숏폼 큐</title>
<style>
.sf-form { display: flex; gap: 0.5rem; margin-bottom: 1rem; }
.sf-form input { flex: 1; }
.sf-tabs { display:flex; gap:.35rem; border-bottom:1px solid var(--border); margin-bottom:1rem; }
.sf-tab { border:0; border-bottom:2px solid transparent; background:transparent; color:var(--text-2); padding:.7rem 1rem; font:600 .9rem/1 inherit; cursor:pointer; }
.sf-tab.active { color:var(--accent); border-bottom-color:var(--accent); }
.sf-tab:focus-visible { outline:2px solid var(--accent); outline-offset:-2px; border-radius:var(--r-sm); }
.sf-category { font-size:.68rem; padding:.18rem .45rem; border-radius:99px; background:var(--surface-2); color:var(--text-2); font-weight:700; }
.sf-category.POLITICS { background:rgba(68, 94, 160, .12); color:#445ea0; }
.sf-paste { display: none; margin-bottom: 1rem; }
.sf-paste.open { display: block; }
.sf-paste textarea { width: 100%; min-height: 160px; font-family: monospace; font-size: 0.8rem; }
.sf-job { border: 1px solid var(--border); border-radius: 10px; margin-bottom: 0.75rem; overflow: hidden; }
.sf-job-head { display: flex; align-items: center; gap: 0.75rem; padding: 0.6rem 0.9rem; cursor: pointer; }
.sf-job-head img { width: 96px; border-radius: 6px; flex-shrink: 0; }
.sf-job-title { flex: 1; min-width: 0; }
.sf-job-title .t { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.sf-job-title .u { font-size: 0.75rem; color: var(--text-secondary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.sf-badge { font-size: 0.7rem; padding: 0.15rem 0.5rem; border-radius: 99px; font-weight: 600; flex-shrink: 0; }
.sf-badge.PENDING { background: rgba(196, 122, 18, 0.15); color: var(--warning); }
.sf-badge.DONE { background: rgba(var(--success-rgb), 0.15); color: var(--success); }
.sf-badge.FAILED { background: rgba(var(--danger-rgb), 0.15); color: var(--danger); }
.sf-subtitle { font-size:.7rem; color:var(--text-2); white-space:nowrap; }
.sf-subtitle.READY { color:var(--success); }
.sf-subtitle.FAILED, .sf-subtitle.UNAVAILABLE { color:var(--danger); }
.sf-clips { display: none; padding: 0.75rem 0.9rem; border-top: 1px solid var(--border); }
.sf-job.open .sf-clips { display: block; }
.sf-clip { border: 1px solid var(--border); border-radius: 8px; padding: 0.6rem 0.8rem; margin-bottom: 0.6rem; }
.sf-clip-head { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.4rem; flex-wrap: wrap; }
.sf-clip-head .no { font-weight: 700; }
.sf-clip pre { font-size: 0.72rem; white-space: pre-wrap; background: rgba(127, 127, 127, 0.08); border-radius: 6px; padding: 0.5rem; margin: 0.35rem 0; max-height: 180px; overflow-y: auto; }
</style>
</head>
<body>
<div layout:fragment="content">
<div class="page-header">
<div>
<h1>숏폼 큐</h1>
<p class="sub">유튜브 URL을 등록하면 Opal 숏폼 생성기 결과를 구간별로 저장·조회합니다.</p>
</div>
<div class="actions">
<button class="btn btn-secondary" onclick="togglePaste()">
<i data-lucide="clipboard-paste" style="width:15px;"></i> 결과 붙여넣기
</button>
</div>
</div>
<form class="sf-form" onsubmit="registerJob(event)">
<input type="text" id="urlInput" placeholder="https://www.youtube.com/watch?v=..." required>
<button class="btn btn-primary" type="submit">큐에 등록</button>
</form>
<div class="sf-tabs" role="tablist" aria-label="콘텐츠 분류">
<button class="sf-tab active" id="sfTab-ENTERTAINMENT" role="tab" aria-selected="true"
onclick="switchCategory('ENTERTAINMENT')">예능</button>
<button class="sf-tab" id="sfTab-POLITICS" role="tab" aria-selected="false"
onclick="switchCategory('POLITICS')">정치</button>
</div>
<div class="sf-paste" id="pasteBox">
<input type="text" id="pasteUrl" placeholder="영상 URL" style="margin-bottom:0.5rem;width:100%;">
<textarea id="pasteRaw" placeholder="Opal Output 전체를 붙여넣으세요 (1===== ... 5===== 전부)"></textarea>
<button class="btn btn-primary" style="margin-top:0.5rem;" onclick="importResult()">파싱해서 저장</button>
</div>
<div id="jobList"></div>
</div>
<th:block layout:fragment="script">
<script>
let currentCategory = new URLSearchParams(location.search).get('category') === 'POLITICS'
? 'POLITICS' : 'ENTERTAINMENT';
let subtitlePollTimer = null;
const openJobIds = new Set();
async function api(path, opts) {
const res = await fetch(path, Object.assign({ headers: { 'Content-Type': 'application/json' } }, opts));
const body = await res.json();
if (!body.success) throw new Error(body.message);
return body.data;
}
function togglePaste() {
document.getElementById('pasteBox').classList.toggle('open');
}
function switchCategory(category) {
currentCategory = category;
document.querySelectorAll('.sf-tab').forEach(tab => {
const active = tab.id === 'sfTab-' + category;
tab.classList.toggle('active', active);
tab.setAttribute('aria-selected', active ? 'true' : 'false');
});
const q = new URLSearchParams(location.search);
q.set('category', category);
history.replaceState(null, '', location.pathname + '?' + q.toString());
loadJobs();
}
async function registerJob(e) {
e.preventDefault();
const youtubeUrl = document.getElementById('urlInput').value.trim();
try {
await api('/api/shortform/jobs', { method: 'POST',
body: JSON.stringify({ youtubeUrl, category: currentCategory }) });
document.getElementById('urlInput').value = '';
loadJobs();
} catch (err) { alert(err.message); }
}
async function importResult() {
const youtubeUrl = document.getElementById('pasteUrl').value.trim();
const rawText = document.getElementById('pasteRaw').value;
if (!youtubeUrl || !rawText.trim()) { alert('URL과 결과 원문을 모두 입력하세요'); return; }
try {
await api('/api/shortform/import', { method: 'POST',
body: JSON.stringify({ youtubeUrl, rawText, category: currentCategory }) });
document.getElementById('pasteUrl').value = '';
document.getElementById('pasteRaw').value = '';
togglePaste();
loadJobs();
} catch (err) { alert(err.message); }
}
async function deleteJob(id, e) {
e.stopPropagation();
if (!confirm('이 작업을 삭제할까요?')) return;
await api('/api/shortform/jobs/' + id, { method: 'DELETE' });
openJobIds.delete(id);
loadJobs();
}
// 결과만 지우고 대기로 되돌린다. 등록 URL은 남으므로 큐를 다시 돌리면 그대로 재실행된다.
async function resetJob(id, e) {
e.stopPropagation();
if (!confirm('결과를 지우고 다시 대기 상태로 되돌릴까요?\n'
+ '클립과 Output 원문이 삭제됩니다. 등록된 영상은 그대로 남습니다.')) return;
try {
await api('/api/shortform/jobs/' + id + '/reset', { method: 'POST' });
loadJobs();
} catch (err) { alert('되돌리기 실패: ' + err.message); }
}
async function toggleJob(id, el, restoreOpen = false) {
if (el.classList.contains('open')) {
if (!restoreOpen) {
el.classList.remove('open');
openJobIds.delete(id);
}
return;
}
const detail = await api('/api/shortform/jobs/' + id);
const toolbar = `<div class="sf-clip-head" style="margin-bottom:0.6rem;">
<button class="btn btn-primary" data-copy="${encodeURIComponent(detail.rawOutput || '')}"
data-label="전체 Output 복사 (capcut2 오팔 JSON 탭용)"
${detail.rawOutput ? '' : 'disabled'}>전체 Output 복사 (capcut2 오팔 JSON 탭용)</button>
<button class="btn btn-secondary" data-copy="${encodeURIComponent(detail.step1Output || '')}"
data-label="Step 1 후보구간 복사"
${detail.step1Output ? '' : 'disabled'}>Step 1 후보구간 복사</button>
<button class="btn btn-secondary" data-copy="${encodeURIComponent(detail.youtubeUrl || '')}"
data-label="영상 URL 복사">영상 URL 복사</button>
<button class="btn btn-secondary" data-subtitle-copy
data-label="타임스탬프 자막 복사" ${detail.subtitleVtt ? '' : 'disabled'}>타임스탬프 자막 복사</button>
${detail.subtitleStatus === 'READY' || detail.subtitleStatus === 'COLLECTING' ? '' : `
<button class="btn btn-secondary" onclick="retrySubtitles(${detail.id}, event)">자막 다시 수집</button>`}
</div>`;
const step1Block = detail.step1Output
? `<details class="sf-clip" style="margin-bottom:0.6rem;">
<summary style="cursor:pointer;font-weight:600;">Step 1: 하이라이트 후보 구간 (선정 이유 포함)</summary>
<pre>${esc(detail.step1Output)}</pre>
</details>`
: '';
el.querySelector('.sf-clips').innerHTML = toolbar + step1Block + (detail.clips.length
? detail.clips.map(clipHtml).join('')
: '<p style="color:var(--text-3);">저장된 클립이 없습니다. 결과 붙여넣기 또는 "숏폼 큐 돌려줘"로 채우세요.</p>');
el.classList.add('open');
openJobIds.add(id);
el.querySelectorAll('[data-copy]').forEach(btn => {
const label = btn.dataset.label || 'capcut2 JSON 복사';
btn.onclick = (e) => {
e.stopPropagation();
navigator.clipboard.writeText(decodeURIComponent(btn.dataset.copy));
btn.textContent = '복사됨!';
setTimeout(() => btn.textContent = label, 1200);
};
});
const subtitleButton = el.querySelector('[data-subtitle-copy]');
if (subtitleButton && detail.subtitleVtt) {
subtitleButton.onclick = (e) => {
e.stopPropagation();
navigator.clipboard.writeText(detail.subtitleVtt);
subtitleButton.textContent = '복사됨!';
setTimeout(() => subtitleButton.textContent = '타임스탬프 자막 복사', 1200);
};
}
}
function esc(s) {
return (s || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
function clipHtml(c) {
return `<div class="sf-clip">
<div class="sf-clip-head">
<span class="no">ID ${c.clipNo}</span>
<span>${esc(c.titleTop || '')} / <b>${esc(c.titleMain || '(타이틀 파싱 실패)')}</b></span>
<span style="flex:1"></span>
<button class="btn btn-secondary" data-copy="${encodeURIComponent(c.capcutJson || '')}"
${c.capcutJson ? '' : 'disabled'}>capcut2 JSON 복사</button>
</div>
${c.titleCandidates ? `<pre>${esc(c.titleCandidates)}</pre>` : ''}
${c.durationTable ? `<pre>${esc(c.durationTable)}</pre>` : ''}
</div>`;
}
async function retrySubtitles(id, e) {
e.stopPropagation();
try {
await api('/api/shortform/jobs/' + id + '/subtitles/retry', { method: 'POST' });
loadJobs();
} catch (err) { alert('자막 수집 요청 실패: ' + err.message); }
}
async function loadJobs() {
const jobs = await api('/api/shortform/jobs?category=' + currentCategory);
const list = document.getElementById('jobList');
list.innerHTML = jobs.length ? '' : '<p style="color:var(--text-3);">등록된 작업이 없습니다.</p>';
jobs.forEach(j => {
const el = document.createElement('div');
el.className = 'sf-job';
el.dataset.jobId = j.id;
el.innerHTML = `<div class="sf-job-head">
<img src="https://img.youtube.com/vi/${j.videoId}/mqdefault.jpg" alt="">
<div class="sf-job-title">
<div class="t">${esc(j.title || j.videoId)}</div>
<div class="u">${esc(j.youtubeUrl)} · 클립 ${j.clipCount}개</div>
<div class="sf-subtitle ${j.subtitleStatus}" data-subtitle-status="${j.subtitleStatus}"
title="${esc(j.subtitleError || '')}">자막 ${subtitleLabel(j)}</div>
</div>
<span class="sf-category ${j.category}">${j.category === 'POLITICS' ? '정치' : '예능'}</span>
<span class="sf-badge ${j.status}">${j.status}</span>
${j.status === 'PENDING' ? '' : `
<button class="btn btn-secondary" onclick="resetJob(${j.id}, event)"
title="결과를 지우고 다시 대기로 — 큐를 돌리면 재실행됩니다">
<i data-lucide="rotate-ccw" style="width:14px;"></i></button>`}
<button class="btn btn-secondary" onclick="deleteJob(${j.id}, event)"
title="작업 삭제">
<i data-lucide="trash-2" style="width:14px;"></i></button>
</div>
<div class="sf-clips"></div>`;
el.querySelector('.sf-job-head').onclick = () => toggleJob(j.id, el);
list.appendChild(el);
if (openJobIds.has(j.id)) {
toggleJob(j.id, el, true).catch(() => openJobIds.delete(j.id));
}
});
if (window.lucide) lucide.createIcons();
scheduleSubtitlePoll(jobs);
}
function scheduleSubtitlePoll(jobs) {
clearTimeout(subtitlePollTimer);
if (jobs.some(j => j.subtitleStatus === 'COLLECTING')) {
subtitlePollTimer = setTimeout(pollSubtitleStatuses, 2000);
}
}
async function pollSubtitleStatuses() {
try {
const jobs = await api('/api/shortform/jobs?category=' + currentCategory);
jobs.forEach(j => {
const card = document.querySelector(`.sf-job[data-job-id="${j.id}"]`);
const status = card && card.querySelector('[data-subtitle-status]');
if (!status) return;
const previous = status.dataset.subtitleStatus;
status.dataset.subtitleStatus = j.subtitleStatus;
status.className = 'sf-subtitle ' + j.subtitleStatus;
status.title = j.subtitleError || '';
status.textContent = '자막 ' + subtitleLabel(j);
if (previous === 'COLLECTING' && j.subtitleStatus === 'READY' && card.classList.contains('open')) {
refreshSubtitleCopy(j.id, card);
}
});
scheduleSubtitlePoll(jobs);
} catch (err) {
subtitlePollTimer = setTimeout(pollSubtitleStatuses, 5000);
}
}
async function refreshSubtitleCopy(id, card) {
const detail = await api('/api/shortform/jobs/' + id);
const button = card.querySelector('[data-subtitle-copy]');
if (!button || !detail.subtitleVtt) return;
button.disabled = false;
button.onclick = (e) => {
e.stopPropagation();
navigator.clipboard.writeText(detail.subtitleVtt);
button.textContent = '복사됨!';
setTimeout(() => button.textContent = '타임스탬프 자막 복사', 1200);
};
}
function subtitleLabel(job) {
if (job.subtitleStatus === 'COLLECTING' && job.subtitleError) return job.subtitleError;
return ({NOT_REQUESTED:'대기', COLLECTING:'수집 중…', READY:'준비 완료',
UNAVAILABLE:'없음', FAILED:'실패'})[job.subtitleStatus] || '대기';
}
switchCategory(currentCategory);
</script>
</th:block>
</body>
</html>

View File

@ -0,0 +1,44 @@
package com.hlab.yanalyst.domain.channel;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class ChannelRoleTest {
@Test
void normalize_null과_미인식값은_MY() {
// ddl-auto:update 컬럼이 추가된 기존 행은 role null 이다
assertThat(ChannelRole.normalize(null)).isEqualTo(ChannelRole.MY);
assertThat(ChannelRole.normalize(" ")).isEqualTo(ChannelRole.MY);
assertThat(ChannelRole.normalize("garbage")).isEqualTo(ChannelRole.MY);
}
@Test
void normalize_대소문자와_공백_보정() {
assertThat(ChannelRole.normalize(" source ")).isEqualTo(ChannelRole.SOURCE);
assertThat(ChannelRole.normalize("rival")).isEqualTo(ChannelRole.RIVAL);
}
@Test
void isFeed는_SOURCE와_RIVAL만() {
assertThat(ChannelRole.isFeed(ChannelRole.SOURCE)).isTrue();
assertThat(ChannelRole.isFeed(ChannelRole.RIVAL)).isTrue();
assertThat(ChannelRole.isFeed(ChannelRole.MY)).isFalse();
assertThat(ChannelRole.isFeed(null)).isFalse();
}
@Test
void OWN은_별도_역할이며_isFeed가_아니다() {
assertThat(ChannelRole.normalize("own")).isEqualTo(ChannelRole.OWN);
assertThat(ChannelRole.isFeed(ChannelRole.OWN)).isFalse();
}
@Test
void videoSource_내채널만_OWN_나머지는_CHANNEL() {
// 수집함/발굴 쿼리는 CHANNEL·SEARCH 보므로 OWN 이걸로 격리된다
assertThat(ChannelRole.videoSource(ChannelRole.OWN)).isEqualTo("OWN");
assertThat(ChannelRole.videoSource(ChannelRole.MY)).isEqualTo("CHANNEL");
assertThat(ChannelRole.videoSource(null)).isEqualTo("CHANNEL");
}
}

View File

@ -0,0 +1,52 @@
package com.hlab.yanalyst.domain.channel;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import static org.assertj.core.api.Assertions.assertThat;
class FeedBadgesTest {
private static final LocalDateTime NOW = LocalDateTime.of(2026, 7, 31, 12, 0);
@Test
void 골든타임은_24시간_미만() {
assertThat(FeedBadges.isGoldenTime(NOW.minusHours(23), NOW)).isTrue();
assertThat(FeedBadges.isGoldenTime(NOW.minusHours(24), NOW)).isFalse(); // 경계: 24시간 정각은 제외
assertThat(FeedBadges.isGoldenTime(NOW.minusDays(3), NOW)).isFalse();
}
@Test
void 골든타임_null과_미래시각_방어() {
assertThat(FeedBadges.isGoldenTime(null, NOW)).isFalse();
assertThat(FeedBadges.isGoldenTime(NOW, null)).isFalse();
assertThat(FeedBadges.isGoldenTime(NOW.plusMinutes(5), NOW)).isTrue(); // 시계 오차는 최신으로 취급
}
@Test
void 길이버킷_경계값() {
assertThat(FeedBadges.lengthBucket(65)).isEqualTo(FeedBadges.BUCKET_SHORTS); // 65초 이하 = 쇼츠
assertThat(FeedBadges.lengthBucket(66)).isEqualTo(FeedBadges.BUCKET_CLIP);
assertThat(FeedBadges.lengthBucket(900)).isEqualTo(FeedBadges.BUCKET_CLIP); // 15분 = 공식클립
assertThat(FeedBadges.lengthBucket(901)).isEqualTo(FeedBadges.BUCKET_FULL);
assertThat(FeedBadges.lengthBucket(null)).isEqualTo(FeedBadges.BUCKET_UNKNOWN);
assertThat(FeedBadges.lengthBucket(0)).isEqualTo(FeedBadges.BUCKET_UNKNOWN);
}
@Test
void 떡상_판정은_시간당_조회수_1000_이상() {
assertThat(FeedBadges.isRising(BigDecimal.valueOf(1000))).isTrue();
assertThat(FeedBadges.isRising(BigDecimal.valueOf(999.99))).isFalse();
assertThat(FeedBadges.isRising(null)).isFalse();
}
@Test
void 작업함은_NEW가_아닌_모든_상태() {
assertThat(FeedBadges.isWorked("NEW")).isFalse();
assertThat(FeedBadges.isWorked(null)).isFalse();
assertThat(FeedBadges.isWorked("TARGET")).isTrue();
assertThat(FeedBadges.isWorked("DONE")).isTrue();
}
}

View File

@ -0,0 +1,26 @@
package com.hlab.yanalyst.domain.channel;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class FeedClassificationTest {
@Test
void legacyValuesDefaultToEntertainmentLongForm() {
Channel channel = Channel.builder().channelId("UC-test").title("test").build();
assertThat(channel.feedTopicOrDefault()).isEqualTo(FeedTopic.ENTERTAINMENT);
assertThat(channel.feedFormatOrDefault()).isEqualTo(FeedFormat.LONG_FORM);
}
@Test
void politicsSeedCanCollectRegularVideosWithoutShorts() {
Channel channel = Channel.builder().channelId("UC-politics").title("politics").build();
channel.configureFeed("POLITICS", "VIDEO");
assertThat(channel.feedTopicOrDefault()).isEqualTo(FeedTopic.POLITICS);
assertThat(channel.feedFormatOrDefault().minDurationSec()).isEqualTo(66);
assertThat(channel.feedFormatOrDefault().maxDurationSec()).isNull();
}
}

View File

@ -0,0 +1,73 @@
package com.hlab.yanalyst.domain.channel;
import org.junit.jupiter.api.Test;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
class HashtagExtractorTest {
@Test
void extract_한글영문_해시태그_추출_중복제거() {
String desc = "맛있는 명장면만 #유퀴즈 #윤경호 #유퀴즈 #shorts";
assertThat(HashtagExtractor.extract(desc)).containsExactly("유퀴즈", "윤경호");
}
@Test
void extract_범용태그_불용어_제외() {
String desc = "#shorts #유튜브쇼츠 #숏폼 #명장면 #짤 #예능 #핑계고";
// 프로그램명 후보만 남는다
assertThat(HashtagExtractor.extract(desc)).containsExactly("핑계고");
}
@Test
void extract_한글자태그와_빈입력_방어() {
assertThat(HashtagExtractor.extract("#1 #가 #아는형님")).containsExactly("아는형님");
assertThat(HashtagExtractor.extract(null)).isEmpty();
assertThat(HashtagExtractor.extract(" ")).isEmpty();
}
@Test
void extract_구두점에서_끊긴다() {
assertThat(HashtagExtractor.extract("#살롱드립,#미미미누 #워크맨."))
.containsExactly("살롱드립", "미미미누", "워크맨");
}
@Test
void countAll_빈도내림차순_그리고_minCount_미만_제거() {
List<String> texts = List.of(
"#유퀴즈 #윤경호",
"#유퀴즈 #소지섭",
"#유퀴즈 #핑계고",
"#핑계고",
"#아는형님" // 1회 minCount 2 미달
);
Map<String, Integer> counts = HashtagExtractor.countAll(texts, 2);
assertThat(counts).containsExactly(
Map.entry("유퀴즈", 3),
Map.entry("핑계고", 2));
}
@Test
void countAll_대소문자_무시하고_첫_표기를_유지() {
Map<String, Integer> counts = HashtagExtractor.countAll(List.of("#Netflix", "#netflix"), 1);
assertThat(counts).containsExactly(Map.entry("Netflix", 2));
}
@Test
void join_split_왕복() {
String joined = HashtagExtractor.join(List.of("유퀴즈", "윤경호"));
assertThat(joined).isEqualTo("유퀴즈,윤경호");
assertThat(HashtagExtractor.split(joined)).containsExactly("유퀴즈", "윤경호");
assertThat(HashtagExtractor.join(List.of())).isNull();
assertThat(HashtagExtractor.split(null)).isEmpty();
}
}

View File

@ -0,0 +1,130 @@
package com.hlab.yanalyst.domain.channel;
import org.junit.jupiter.api.Test;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
class PersonPicksTest {
private static final LocalDateTime NOW = LocalDateTime.of(2026, 8, 2, 12, 0);
private PersonPicks.Found f(String id, Integer durationSec, String channelId, LocalDateTime published) {
return new PersonPicks.Found(id, "t_" + id, channelId, "ch_" + channelId, durationSec, published, 1000L);
}
@Test
void 쇼츠는_버리고_롱폼만_남긴다() {
List<PersonPicks.Found> found = List.of(
f("a", 30, "C1", NOW), // 쇼츠
f("b", 180, "C1", NOW), // 경계값 180초(유튜브 쇼츠 최대) = 쇼츠
f("c", 181, "C1", NOW), // 롱폼
f("d", 4300, "C1", NOW)); // 롱폼
List<PersonPicks.Found> kept = PersonPicks.keep(found, Set.of(), null, 0);
assertThat(kept).extracting(PersonPicks.Found::videoId).containsExactly("c", "d");
}
@Test
void 길이를_모르면_버린다() {
// 상세 조회 실패로 길이를 채운 롱폼인지 없으므로 담지 않는다
List<PersonPicks.Found> kept = PersonPicks.keep(List.of(f("a", null, "C1", NOW)), Set.of(), null, 0);
assertThat(kept).isEmpty();
}
@Test
void 내채널과_경쟁채널_영상은_제외() {
List<PersonPicks.Found> found = List.of(
f("a", 300, "OWN_CH", NOW),
f("b", 300, "RIVAL_CH", NOW),
f("c", 300, "OTHER", NOW));
List<PersonPicks.Found> kept = PersonPicks.keep(found, Set.of("OWN_CH", "RIVAL_CH"), null, 0);
assertThat(kept).extracting(PersonPicks.Found::videoId).containsExactly("c");
}
@Test
void 기간_이전_업로드는_제외() {
LocalDateTime cutoff = NOW.minusDays(14);
List<PersonPicks.Found> found = List.of(
f("old", 300, "C1", cutoff.minusDays(1)),
f("new", 300, "C1", cutoff.plusDays(1)));
List<PersonPicks.Found> kept = PersonPicks.keep(found, Set.of(), cutoff, 0);
assertThat(kept).extracting(PersonPicks.Found::videoId).containsExactly("new");
}
@Test
void 시간당_조회수_하한으로_팬채널_노이즈를_거른다() {
// 실측: 팬채널·커버곡은 하루에 조회수 30회 미만, 방송사 클립은 시간당 수백~수천
LocalDateTime dayAgo = LocalDateTime.now().minusHours(24);
PersonPicks.Found noise = new PersonPicks.Found("n", "팬캠", "C1", "흥미딘딘", 300, dayAgo, 16L);
PersonPicks.Found signal = new PersonPicks.Found("s", "방송클립", "C2", "SBS", 300, dayAgo, 3000L);
List<PersonPicks.Found> kept = PersonPicks.keep(List.of(noise, signal), Set.of(), null, 20);
assertThat(kept).extracting(PersonPicks.Found::videoId).containsExactly("s");
}
@Test
void 방금_올라온_영상은_조회수가_적어도_통과한다() {
// 조회수로 자르면 선점 대상인 신선한 업로드를 놓친다 시간당으로 재기 때문에 통과해야 한다
LocalDateTime justNow = LocalDateTime.now().minusMinutes(30);
PersonPicks.Found fresh = new PersonPicks.Found("f", "방금 올라옴", "C1", "KBS", 300, justNow, 50L);
assertThat(PersonPicks.keep(List.of(fresh), Set.of(), null, 20))
.extracting(PersonPicks.Found::videoId).containsExactly("f");
}
@Test
void 조회수나_업로드일을_모르면_배제하지_않는다() {
PersonPicks.Found unknown = new PersonPicks.Found("u", "t", "C1", "ch", 300, null, null);
assertThat(PersonPicks.keep(List.of(unknown), Set.of(), null, 20)).hasSize(1);
}
@Test
void 음악방송_무대_직캠_커버는_버린다() {
// 아이돌 이름으로 검색하면 직캠·무대가 결과를 뒤덮는다. 말로 터지는 순간이 소재라 무대는 소용없다
assertThat(PersonPicks.isStageVideo("[#음중직캠] fromis_9 LEE CHAEYOUNG Vitamin ME")).isTrue();
assertThat(PersonPicks.isStageVideo("[페이스캠4K] 프로미스나인 이채영")).isTrue();
assertThat(PersonPicks.isStageVideo("fromis_9 'Vitamin ME' PERFORMANCE VIDEO")).isTrue();
assertThat(PersonPicks.isStageVideo("[cover] 딘딘 - 태양은 가득히")).isTrue();
assertThat(PersonPicks.isStageVideo("교차편집 stage mix")).isTrue();
// 예능·드라마 클립은 남아야 한다
assertThat(PersonPicks.isStageVideo("이채영 임슬옹 공포체험 촬영 중단!?")).isFalse();
assertThat(PersonPicks.isStageVideo("남우조연상 - 윤경호 [제5회 청룡시리즈어워즈]")).isFalse();
assertThat(PersonPicks.isStageVideo(null)).isFalse();
}
@Test
void keep은_무대영상을_걸러낸다() {
PersonPicks.Found stage = new PersonPicks.Found(
"s", "[#음중직캠] 프로미스나인 이채영", "C1", "MBCkpop", 200, NOW, 50_000L);
PersonPicks.Found variety = new PersonPicks.Found(
"v", "이채영 임슬옹 공포체험 촬영 중단!?", "C2", "OngStyle", 200, NOW, 50_000L);
assertThat(PersonPicks.keep(List.of(stage, variety), Set.of(), null, 0))
.extracting(PersonPicks.Found::videoId).containsExactly("v");
}
@Test
void null_입력과_빈_videoId_방어() {
List<PersonPicks.Found> found = new java.util.ArrayList<>();
found.add(null);
found.add(f("", 300, "C1", NOW));
found.add(f("ok", 300, "C1", NOW));
assertThat(PersonPicks.keep(found, Set.of(), null, 0))
.extracting(PersonPicks.Found::videoId).containsExactly("ok");
assertThat(PersonPicks.keep(null, Set.of(), null, 0)).isEmpty();
}
}

View File

@ -0,0 +1,93 @@
package com.hlab.yanalyst.domain.channel;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
class TopicPerformanceTest {
private TopicPerformance.Entry e(String tags, Long views, String title) {
return new TopicPerformance.Entry(tags, views, title, "vid_" + title);
}
@Test
void aggregate_소재별_평균조회수_내림차순() {
List<TopicPerformance.Entry> entries = List.of(
e("유퀴즈,윤경호", 100_000L, "a"),
e("유퀴즈", 140_000L, "b"),
e("핑계고", 20_000L, "c"),
e("핑계고,윤경호", 40_000L, "d"));
List<TopicPerformance.Topic> out = TopicPerformance.aggregate(entries, 2);
assertThat(out).extracting(TopicPerformance.Topic::topic)
.containsExactly("유퀴즈", "윤경호", "핑계고");
assertThat(out.get(0).avgViews()).isEqualTo(120_000); // (100000+140000)/2
assertThat(out.get(0).videoCount()).isEqualTo(2);
assertThat(out.get(0).maxViews()).isEqualTo(140_000);
}
@Test
void aggregate_최소편수_미만은_제외() {
List<TopicPerformance.Entry> entries = List.of(
e("유퀴즈", 100_000L, "a"),
e("유퀴즈", 100_000L, "b"),
e("어쩌다사장", 500_000L, "c")); // 1편뿐 조회수가 높아도 랭킹에서 뺀다
List<TopicPerformance.Topic> out = TopicPerformance.aggregate(entries, 2);
assertThat(out).extracting(TopicPerformance.Topic::topic).containsExactly("유퀴즈");
}
@Test
void index는_채널평균_대비_배수() {
// 채널 평균 = (200000 + 200000 + 40000 + 40000) / 4 = 120000
List<TopicPerformance.Entry> entries = List.of(
e("유퀴즈", 200_000L, "a"),
e("유퀴즈", 200_000L, "b"),
e("핑계고", 40_000L, "c"),
e("핑계고", 40_000L, "d"));
List<TopicPerformance.Topic> out = TopicPerformance.aggregate(entries, 2);
assertThat(TopicPerformance.channelAverage(entries)).isEqualTo(120_000);
assertThat(out.get(0).index()).isEqualTo(1.67); // 200000/120000
assertThat(out.get(1).index()).isEqualTo(0.33); // 40000/120000
}
@Test
void 최고조회수_영상의_제목을_들고온다() {
List<TopicPerformance.Entry> entries = List.of(
e("유퀴즈", 10_000L, "낮은편"),
e("유퀴즈", 90_000L, "터진편"));
List<TopicPerformance.Topic> out = TopicPerformance.aggregate(entries, 2);
assertThat(out.get(0).topTitle()).isEqualTo("터진편");
assertThat(out.get(0).topVideoId()).isEqualTo("vid_터진편");
}
@Test
void 해시태그_없거나_조회수_null_이어도_깨지지_않는다() {
List<TopicPerformance.Entry> entries = List.of(
e(null, 50_000L, "태그없음"),
e("", 30_000L, "빈태그"),
e("유퀴즈", null, "조회수없음"),
e("유퀴즈", 60_000L, "정상"));
List<TopicPerformance.Topic> out = TopicPerformance.aggregate(entries, 2);
assertThat(out).hasSize(1);
assertThat(out.get(0).topic()).isEqualTo("유퀴즈");
assertThat(out.get(0).avgViews()).isEqualTo(30_000); // (0 + 60000) / 2
}
@Test
void 빈_입력은_빈_결과() {
assertThat(TopicPerformance.aggregate(List.of(), 2)).isEmpty();
assertThat(TopicPerformance.aggregate(null, 2)).isEmpty();
assertThat(TopicPerformance.channelAverage(List.of())).isZero();
}
}

View File

@ -1,67 +0,0 @@
package com.hlab.yanalyst.domain.publish;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.data.domain.Sort;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
/**
* 발행 대시보드 요약: 상태별 카운트 + 최근 5건(전체는 많아도 5개로 잘림).
*/
@ExtendWith(MockitoExtension.class)
class PublishServiceTest {
@Mock PublishPackageRepository repository;
@InjectMocks PublishService publishService;
@Test
void dashboardSummary_countsByStatusAndTakesFirstFiveAsRecent() {
when(repository.countByStatus("DRAFT")).thenReturn(2L);
when(repository.countByStatus("READY")).thenReturn(3L);
when(repository.countByStatus("PUBLISHED")).thenReturn(4L);
// list(null) -> repository.findAllSorted(sort); return 7 so recent should cap at 5
List<PublishPackage> seven = Collections.nCopies(7, mock(PublishPackage.class));
when(repository.findAllSorted(any(Sort.class))).thenReturn(seven);
Map<String, Object> summary = publishService.dashboardSummary();
@SuppressWarnings("unchecked")
Map<String, Long> byStatus = (Map<String, Long>) summary.get("byStatus");
assertThat(byStatus)
.containsEntry("DRAFT", 2L)
.containsEntry("READY", 3L)
.containsEntry("PUBLISHED", 4L);
assertThat(summary.get("total")).isEqualTo(7L);
@SuppressWarnings("unchecked")
List<PublishPackage> recent = (List<PublishPackage>) summary.get("recent");
assertThat(recent).hasSize(5);
}
@Test
void dashboardSummary_recentKeepsAllWhenFiveOrFewer() {
when(repository.countByStatus(any())).thenReturn(0L);
when(repository.findAllSorted(any(Sort.class)))
.thenReturn(Collections.nCopies(3, mock(PublishPackage.class)));
Map<String, Object> summary = publishService.dashboardSummary();
assertThat(summary.get("total")).isEqualTo(3L);
@SuppressWarnings("unchecked")
List<PublishPackage> recent = (List<PublishPackage>) summary.get("recent");
assertThat(recent).hasSize(3);
}
}

View File

@ -0,0 +1,125 @@
package com.hlab.yanalyst.domain.reply;
import org.junit.jupiter.api.Test;
import java.util.ArrayList;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class ReplyDraftingTest {
@Test
void 내가_답글을_달았는지는_작성자로_판정한다() {
// 남이 대댓글만 댓글은 여전히 미응답 totalReplyCount 판정하면 된다
assertThat(ReplyDrafting.repliedByMe(List.of("OTHER1", "OTHER2"), "ME")).isFalse();
assertThat(ReplyDrafting.repliedByMe(List.of("OTHER1", "ME"), "ME")).isTrue();
assertThat(ReplyDrafting.repliedByMe(List.of(), "ME")).isFalse();
}
@Test
void 답글판정_null_방어() {
assertThat(ReplyDrafting.repliedByMe(null, "ME")).isFalse();
assertThat(ReplyDrafting.repliedByMe(List.of("ME"), null)).isFalse();
assertThat(ReplyDrafting.repliedByMe(List.of("ME"), " ")).isFalse();
}
@Test
void 말투샘플은_짧은_답글을_빼고_최대_5개() {
List<String> replies = new ArrayList<>(List.of(
"ㅋㅋㅋ", // 너무 짧음
"감사합니다", // 너무 짧음
"이 장면 진짜 레전드죠 저도 편집하면서 웃었어요",
"다음 편도 곧 올라갑니다 기다려주세요!",
"맞아요 그 부분이 제일 웃겼습니다",
"말씀해주신 장면 찾아서 올려볼게요",
"봐주셔서 감사합니다 다음에 또 뵈어요",
"여섯 번째라 잘려야 하는 답글입니다"));
List<String> picked = ReplyDrafting.pickStyleSamples(replies);
assertThat(picked).hasSize(5);
assertThat(picked).doesNotContain("ㅋㅋㅋ", "감사합니다");
assertThat(picked.get(0)).startsWith("이 장면");
}
@Test
void 말투샘플_null과_빈입력() {
assertThat(ReplyDrafting.pickStyleSamples(null)).isEmpty();
assertThat(ReplyDrafting.pickStyleSamples(List.of())).isEmpty();
}
@Test
void 프롬프트에_영상내용과_댓글이_들어간다() {
String p = ReplyDrafting.buildPrompt("윤경호 유퀴즈 명장면", "윤경호가 무당 얘기를 한다",
"이거 몇 화인가요?", List.of());
assertThat(p).contains("윤경호 유퀴즈 명장면");
assertThat(p).contains("윤경호가 무당 얘기를 한다");
assertThat(p).contains("이거 몇 화인가요?");
assertThat(p).contains("[말투]"); // 샘플 없으면 기본 지시
}
@Test
void 말투샘플이_있으면_기본톤_대신_샘플을_넣는다() {
String p = ReplyDrafting.buildPrompt("제목", null, "댓글", List.of("이 장면 진짜 레전드죠"));
assertThat(p).contains("이 장면 진짜 레전드죠");
assertThat(p).doesNotContain("[말투]");
assertThat(p).doesNotContain("[영상 내용]"); // 요약 없으면 섹션 자체를 빼서 헷갈리지 않게
}
@Test
void 이름을_바꿔쓰지_말라는_규칙이_들어간다() {
// 실측에서 "이채영" "채연"으로 바뀌어 나왔다 이름 오류는 그대로 쓰면 사고다
String p = ReplyDrafting.buildPrompt("이채영 풀세팅", null, "댓글", List.of());
assertThat(p).contains("글자 그대로");
}
@Test
void 초안_JSON_파싱() {
ReplyDrafting.Drafts d = ReplyDrafting.parseDrafts("{\"a\": \"첫 번째\", \"b\": \"두 번째\"}");
assertThat(d.a()).isEqualTo("첫 번째");
assertThat(d.b()).isEqualTo("두 번째");
}
@Test
void 코드펜스와_잡소리가_섞여도_JSON만_골라낸다() {
String raw = "네 알겠습니다!\n```json\n{\"a\": \"답글 하나\", \"b\": \"답글 둘\"}\n```\n도움이 되었길";
ReplyDrafting.Drafts d = ReplyDrafting.parseDrafts(raw);
assertThat(d.a()).isEqualTo("답글 하나");
assertThat(d.b()).isEqualTo("답글 둘");
}
@Test
void 이스케이프된_따옴표와_줄바꿈_처리() {
ReplyDrafting.Drafts d = ReplyDrafting.parseDrafts(
"{\"a\": \"\\\"장면\\\" 맞아요\", \"b\": \"\\n바꿈\"}");
assertThat(d.a()).isEqualTo("\"장면\" 맞아요");
assertThat(d.b()).isEqualTo("\n바꿈");
}
@Test
void 하나만_나오면_둘_다_그걸로_채운다() {
ReplyDrafting.Drafts d = ReplyDrafting.parseDrafts("{\"a\": \"하나뿐\"}");
assertThat(d.a()).isEqualTo("하나뿐");
assertThat(d.b()).isEqualTo("하나뿐");
}
@Test
void 초안을_못뽑으면_예외() {
assertThatThrownBy(() -> ReplyDrafting.parseDrafts("죄송합니다 답변할 수 없습니다"))
.isInstanceOf(IllegalStateException.class);
assertThatThrownBy(() -> ReplyDrafting.parseDrafts(""))
.isInstanceOf(IllegalStateException.class);
assertThatThrownBy(() -> ReplyDrafting.parseDrafts(null))
.isInstanceOf(IllegalStateException.class);
}
}

View File

@ -0,0 +1,23 @@
package com.hlab.yanalyst.domain.shortform;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class ShortformCategoryTest {
@Test
void legacyRegistrationDefaultsToEntertainment() {
ShortformJob job = ShortformJob.create("https://youtu.be/abc12345678", "abc12345678");
assertThat(job.categoryOrDefault()).isEqualTo(ShortformCategory.ENTERTAINMENT);
}
@Test
void politicsRegistrationKeepsCategory() {
ShortformJob job = ShortformJob.create(
"https://youtu.be/abc12345678", "abc12345678", ShortformCategory.POLITICS);
assertThat(job.categoryOrDefault()).isEqualTo(ShortformCategory.POLITICS);
}
}

View File

@ -0,0 +1,72 @@
package com.hlab.yanalyst.domain.shortform;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
class ShortformJobResetTest {
private ParsedClip clip(int no, String titleMain) {
return new ParsedClip(no, "{\"capcut\":\"json\"}", "제목 후보들", "구간표", "상단 제목", titleMain);
}
private ShortformJob done() {
ShortformJob job = ShortformJob.create("https://youtu.be/abc12345678", "abc12345678");
job.attachStep1("스텝1 원문");
job.applyResult("전체 Output 원문", List.of(clip(1, "첫 클립"), clip(2, "둘째 클립")));
return job;
}
@Test
void 완료된_작업을_되돌리면_결과가_비고_대기로_간다() {
ShortformJob job = done();
assertThat(job.getStatus()).isEqualTo(ShortformJobStatus.DONE);
job.reset();
assertThat(job.getStatus()).isEqualTo(ShortformJobStatus.PENDING);
assertThat(job.getRawOutput()).isNull();
assertThat(job.getStep1Output()).isNull();
assertThat(job.getTitle()).isNull();
assertThat(job.getCompletedAt()).isNull();
assertThat(job.getClips()).isEmpty();
}
@Test
void 되돌려도_등록정보는_유지된다() {
// 삭제 재등록이 필요 없어야 하므로 URL·videoId 남아야 한다
ShortformJob job = done();
job.reset();
assertThat(job.getYoutubeUrl()).isEqualTo("https://youtu.be/abc12345678");
assertThat(job.getVideoId()).isEqualTo("abc12345678");
}
@Test
void 실패한_작업도_되돌려_재시도할_수_있다() {
ShortformJob job = ShortformJob.create("https://youtu.be/abc12345678", "abc12345678");
job.markFailed("파싱 안 되는 원문");
assertThat(job.getStatus()).isEqualTo(ShortformJobStatus.FAILED);
job.reset();
assertThat(job.getStatus()).isEqualTo(ShortformJobStatus.PENDING);
assertThat(job.getRawOutput()).isNull();
}
@Test
void 되돌린_뒤_다시_결과를_넣을_수_있다() {
ShortformJob job = done();
job.reset();
job.applyResult("새 Output", List.of(clip(1, "새 클립")));
assertThat(job.getStatus()).isEqualTo(ShortformJobStatus.DONE);
assertThat(job.getClips()).hasSize(1);
assertThat(job.getTitle()).isEqualTo("새 클립");
assertThat(job.getRawOutput()).isEqualTo("새 Output");
}
}

View File

@ -0,0 +1,82 @@
package com.hlab.yanalyst.domain.shortform;
import org.junit.jupiter.api.Test;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
class ShortformOutputParserTest {
private final ShortformOutputParser parser = new ShortformOutputParser();
private String sample() throws Exception {
return Files.readString(Path.of("src/test/resources/shortform/opal-output-sample.txt"),
StandardCharsets.UTF_8);
}
@Test
void 실전_샘플에서_5개_섹션을_모두_파싱한다() throws Exception {
List<ParsedClip> clips = parser.parse(sample());
assertEquals(5, clips.size());
for (ParsedClip c : clips) {
assertNotNull(c.capcutJson(), "clip " + c.clipNo() + " json");
assertTrue(c.capcutJson().contains("\"cuts\""));
assertNotNull(c.titleCandidates(), "clip " + c.clipNo() + " titles");
assertNotNull(c.durationTable(), "clip " + c.clipNo() + " table");
}
assertEquals(1, clips.get(0).clipNo());
assertEquals(5, clips.get(4).clipNo());
}
@Test
void json펜스가_있는_섹션과_없는_섹션_모두_타이틀을_추출한다() throws Exception {
List<ParsedClip> clips = parser.parse(sample());
// 섹션 1: ```json 펜스 있음
assertEquals("프나 신보 감상평", clips.get(0).titleTop());
assertEquals("극락 가는 앨범입니다", clips.get(0).titleMain());
// 섹션 3: 펜스 없이 JSON
assertEquals("프나 유치찬란 상황극", clips.get(2).titleTop());
// 섹션 5: 펜스 없음 + url에 한글 플레이스홀더
assertEquals("맑은 눈의 광기", clips.get(4).titleTop());
}
@Test
void 검산표는_총길이_줄까지_포함한다() throws Exception {
List<ParsedClip> clips = parser.parse(sample());
assertTrue(clips.get(0).durationTable().contains("총 13컷 / 총 길이: 46.5초"));
}
/** "숏폼 생성기 1단계" 앱 출력: N===== 구분자 없이 candidates JSON 하나 (실전 픽스처). */
private String step1Sample() throws Exception {
return Files.readString(Path.of("src/test/resources/shortform/opal-step1-candidates-sample.txt"),
StandardCharsets.UTF_8);
}
@Test
void candidates_JSON_출력은_후보를_클립으로_파싱한다() throws Exception {
List<ParsedClip> clips = parser.parse(step1Sample());
assertEquals(5, clips.size());
ParsedClip first = clips.get(0);
assertEquals(1, first.clipNo());
assertEquals("냉동창고 알바생들의 현실 꼼수", first.titleTop());
assertEquals("극한직업 뷔페 알바 첫 미션", first.titleMain());
assertTrue(first.capcutJson().contains("\"start_time\""), "후보 원본 JSON 보존");
assertTrue(first.durationTable().contains("02:35"));
assertTrue(first.durationTable().contains("05:07"));
assertEquals(5, clips.get(4).clipNo());
}
@Test
void 빈_입력은_빈_리스트를_반환한다() {
assertTrue(parser.parse(null).isEmpty());
assertTrue(parser.parse(" ").isEmpty());
assertTrue(parser.parse("구분자가 없는 텍스트").isEmpty());
}
}

View File

@ -0,0 +1,27 @@
package com.hlab.yanalyst.domain.shortform;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class ShortformServiceTest {
@Test
void resumesCollectingSubtitleJobsAfterRestart() {
ShortformJobRepository repository = mock(ShortformJobRepository.class);
ShortformSubtitleService subtitleService = mock(ShortformSubtitleService.class);
ShortformJob job = mock(ShortformJob.class);
when(job.getId()).thenReturn(34L);
when(repository.findBySubtitleStatus(SubtitleStatus.COLLECTING)).thenReturn(List.of(job));
ShortformService service = new ShortformService(
repository, mock(ShortformOutputParser.class), subtitleService);
service.recoverInterruptedSubtitleCollections();
verify(subtitleService).collectAsync(34L);
}
}

Some files were not shown because too many files have changed in this diff Show More