From aecbdb4753bf174f742c03e262a1eead9a5629d3 Mon Sep 17 00:00:00 2001 From: "hehihoho3@gmail.com" Date: Thu, 23 Jul 2026 16:32:28 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EB=B0=9C=EA=B5=B4=20=ED=8F=AC=EB=A7=B7?= =?UTF-8?q?=20=EC=98=B5=EC=85=98(=EB=A1=B1=ED=8F=BC/=EC=87=BC=EC=B8=A0)=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20=E2=80=94=20=EB=A1=B1=ED=8F=BC=20=EA=B8=B0?= =?UTF-8?q?=EB=B3=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 발굴 대상이 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) --- .../channel/ChannelDiscoveryService.java | 22 ++++++++++++-- .../channel/RecommendedChannelController.java | 30 +++++++++++++++++-- src/main/resources/application.yml | 2 ++ src/main/resources/templates/recommend.html | 18 +++++++++-- 4 files changed, 63 insertions(+), 9 deletions(-) diff --git a/src/main/java/com/hlab/yanalyst/domain/channel/ChannelDiscoveryService.java b/src/main/java/com/hlab/yanalyst/domain/channel/ChannelDiscoveryService.java index 06bf9ea..ea0a04a 100644 --- a/src/main/java/com/hlab/yanalyst/domain/channel/ChannelDiscoveryService.java +++ b/src/main/java/com/hlab/yanalyst/domain/channel/ChannelDiscoveryService.java @@ -19,7 +19,7 @@ import java.util.List; import java.util.Map; import java.util.Set; -/** 지역 인기 Shorts 검색 → 떡상 채널 발굴 → RecommendedChannel upsert. */ +/** 지역 인기 영상(롱폼/쇼츠) 검색 → 떡상 채널 발굴 → RecommendedChannel upsert. */ @Slf4j @Service @RequiredArgsConstructor @@ -43,9 +43,24 @@ public class ChannelDiscoveryService { private double minRatio; @Value("${hlab.discovery.period-days:14}") private int periodDays; + /** 발굴 대상 포맷 기본값: LONG_FORM(롱폼) | SHORTS. */ + @Value("${hlab.discovery.format:LONG_FORM}") + private String defaultFormat; + /** 스케줄러 등 기본 호출 — 설정된 기본 포맷(hlab.discovery.format)으로 발굴. */ @Transactional public Map runDiscovery() { + return runDiscovery(null); + } + + /** + * 발굴 실행. + * @param format "SHORTS" 또는 "LONG_FORM". null/blank/미인식이면 설정 기본값(defaultFormat)을 사용. + */ + @Transactional + public Map runDiscovery(String format) { + String fmt = (format == null || format.isBlank()) ? defaultFormat : format.trim().toUpperCase(); + if (!"SHORTS".equals(fmt) && !"LONG_FORM".equals(fmt)) fmt = defaultFormat; List regions = Arrays.stream(regionsCsv.split(",")) .map(String::trim).filter(s -> !s.isBlank()).toList(); // 예능 특화 발굴용 키워드 시드. 비어있으면(설정 미지정) 추가 검색 없이 기존 동작과 동일. @@ -66,7 +81,7 @@ public class ChannelDiscoveryService { try { YoutubeSearchCondition cond = new YoutubeSearchCondition(); cond.setRegions(List.of(region)); - cond.setFormat("SHORTS"); + cond.setFormat(fmt); cond.setPeriodDays(periodDays); cond.setOrder("viewCount"); // 떡상 발굴: 최신순이 아니라 조회수순 // 광범위 검색: 키워드 없이 인기 Shorts. 결과 빈약 시 검색 API 보강 필요(스펙 §4.2). @@ -92,7 +107,7 @@ public class ChannelDiscoveryService { YoutubeSearchCondition cond = new YoutubeSearchCondition(); cond.setKeyword(kw); cond.setRegions(List.of(region)); - cond.setFormat("SHORTS"); + cond.setFormat(fmt); cond.setPeriodDays(periodDays); cond.setOrder("viewCount"); // 떡상 발굴: 조회수순 YoutubeSearchPageDto page = youtubeSearchService.searchYoutubeVideos(cond); @@ -138,6 +153,7 @@ public class ChannelDiscoveryService { } Map summary = new LinkedHashMap<>(); + summary.put("format", fmt); summary.put("regions", searchedRegions); summary.put("keywords", keywords); summary.put("keywordSearches", keywordSearches); diff --git a/src/main/java/com/hlab/yanalyst/domain/channel/RecommendedChannelController.java b/src/main/java/com/hlab/yanalyst/domain/channel/RecommendedChannelController.java index 2f5cd0e..afa167d 100644 --- a/src/main/java/com/hlab/yanalyst/domain/channel/RecommendedChannelController.java +++ b/src/main/java/com/hlab/yanalyst/domain/channel/RecommendedChannelController.java @@ -7,6 +7,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.bind.annotation.*; +import java.math.BigDecimal; import java.util.List; import java.util.Map; @@ -19,6 +20,7 @@ public class RecommendedChannelController { private final RecommendedChannelRepository repository; private final ChannelDiscoveryService discoveryService; private final ChannelService channelService; + private final ChannelVideoRepository channelVideoRepository; @GetMapping @Operation(summary = "추천 채널 목록", description = "status(NEW|EXCLUDED|REGISTERED, 기본 NEW)를 배율 내림차순으로 반환") @@ -60,9 +62,31 @@ public class RecommendedChannelController { return ApiResponse.ok(null); } + @PostMapping("/{id}/rework") + @Operation(summary = "대표 영상 재가공", description = "추천 채널의 대표(떡상) 영상을 수집함(ChannelVideo)에 적재하고 재가공 작업공간으로 보낼 id를 반환") + @Transactional + public ApiResponse> 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") - @Operation(summary = "수동 발굴 실행", description = "스케줄과 동일한 발굴을 즉시 1회 실행") - public ApiResponse> run() { - return ApiResponse.ok(discoveryService.runDiscovery()); + @Operation(summary = "수동 발굴 실행", description = "스케줄과 동일한 발굴을 즉시 1회 실행. format=LONG_FORM(기본)|SHORTS") + public ApiResponse> run(@RequestParam(required = false) String format) { + return ApiResponse.ok(discoveryService.runDiscovery(format)); } } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 42ff788..2bbfd3e 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -97,6 +97,8 @@ hlab: # 예능 특화 시드 키워드(쉼표구분). 각 키워드×지역으로 Shorts 검색해 예능 클립 소재를 우선 발굴. # 빈 값이면 키워드 없는 기존 '지역 인기 Shorts' 발굴로 동작. keywords: ${DISCOVERY_KEYWORDS:예능,방송,하이라이트} + # 발굴 대상 포맷 기본값: LONG_FORM(롱폼) | SHORTS. UI '지금 발굴'에서 건별 선택 가능. + format: ${DISCOVERY_FORMAT:LONG_FORM} # 텔레그램 아침 추천: 발굴 직후 상위 추천채널 다이제스트를 발송. 토큰/챗ID 없으면 자동 no-op. notify: diff --git a/src/main/resources/templates/recommend.html b/src/main/resources/templates/recommend.html index 98e36c2..0eb6f35 100644 --- a/src/main/resources/templates/recommend.html +++ b/src/main/resources/templates/recommend.html @@ -7,10 +7,14 @@ @@ -91,6 +95,7 @@ const actions = excludedView ? '' + moveBtn : '' + moveBtn + + '' + ''; return '
' + '' @@ -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 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 rework(id, btn){ + if(btn){ btn.disabled=true; btn.innerHTML=' 준비 중…'; 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=' 영상 재가공'; if(window.lucide) lucide.createIcons(); } } + } function openVideoModal(el){ const vid = el.dataset.vid || ''; document.getElementById('modalTitle').textContent = el.dataset.title || ''; @@ -120,8 +130,10 @@ document.addEventListener('keydown', e => { if(e.key === 'Escape') closeVideoModal(); }); async function runDiscovery(){ - const s=document.getElementById('status'); s.style.display='block'; s.style.color='#facc15'; s.textContent='발굴 중… (지역별 Shorts 검색, 잠시 걸립니다)'; - try{ const r=await api(API+'/run',{method:'POST'}); s.style.color='#4ade80'; s.textContent='발굴 완료 · 신규 '+(r.saved??0)+'개 · 지역 '+((r.regions||[]).join(',')); await load(); } + const fmt=(document.getElementById('discFormat')||{}).value||'LONG_FORM'; + 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; } } load();