package com.hlab.yanalyst.domain.channel; import com.hlab.yanalyst.global.schedule.YoutubeQuotaGuard; import com.hlab.yanalyst.service.YoutubeSearchService; import com.hlab.yanalyst.web.dto.YoutubeSearchCondition; import com.hlab.yanalyst.web.dto.YoutubeSearchPageDto; import com.hlab.yanalyst.web.dto.YoutubeSearchResultDto; 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 java.util.ArrayList; import java.util.Arrays; import java.util.HashSet; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Set; /** 지역 인기 영상(롱폼/쇼츠) 검색 → 떡상 채널 발굴 → RecommendedChannel upsert. */ @Slf4j @Service @RequiredArgsConstructor public class ChannelDiscoveryService { /** search.list 1회 추정 쿼터(units). */ private static final long SEARCH_QUOTA = 100; private final YoutubeSearchService youtubeSearchService; private final YoutubeQuotaGuard quotaGuard; private final ChannelRepository channelRepository; private final RecommendedChannelRepository recommendedChannelRepository; @Value("${hlab.discovery.regions:KR}") private String regionsCsv; @Value("${hlab.discovery.keywords:}") private String keywordsCsv; @Value("${hlab.discovery.max-subscribers:100000}") private long maxSubscribers; @Value("${hlab.discovery.min-ratio:5.0}") 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(); // 예능 특화 발굴용 키워드 시드. 비어있으면(설정 미지정) 추가 검색 없이 기존 동작과 동일. List keywords = Arrays.stream(keywordsCsv.split(",")) .map(String::trim).filter(s -> !s.isBlank()).toList(); List all = new ArrayList<>(); List searchedRegions = new ArrayList<>(); int keywordSearches = 0; // 참고용: 실제 수행한 키워드 검색 횟수 // 쿼터 가드: 지역×(1+키워드수) 만큼 검색이 일어나므로, 하나라도 소진되면 전체 발굴 루프를 중단. regionLoop: for (String region : regions) { // 1) 키워드 없는 인기 Shorts 검색 (기존 동작) if (!quotaGuard.tryConsume(SEARCH_QUOTA)) { log.warn("[Discovery] 쿼터 예산 소진 — 지역 {} 이후 건너뜀 (잔여 {})", region, quotaGuard.remaining()); break; } try { YoutubeSearchCondition cond = new YoutubeSearchCondition(); cond.setRegions(List.of(region)); cond.setFormat(fmt); cond.setPeriodDays(periodDays); cond.setOrder("viewCount"); // 떡상 발굴: 최신순이 아니라 조회수순 // 광범위 검색: 키워드 없이 인기 Shorts. 결과 빈약 시 검색 API 보강 필요(스펙 §4.2). YoutubeSearchPageDto page = youtubeSearchService.searchYoutubeVideos(cond); if (page != null && page.getItems() != null) { // 발견 지역(검색 regionCode)으로 태깅 — 채널 국가가 아닌 '어느 지역 검색에서 떴나' for (YoutubeSearchResultDto it : page.getItems()) it.setChannelCountry(region); all.addAll(page.getItems()); } searchedRegions.add(region); } catch (Exception 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)한 채널 제외 Set exclude = new HashSet<>(); for (RecommendedChannel rc : recommendedChannelRepository.findByStatusOrderByRatioDesc("EXCLUDED")) { exclude.add(rc.getChannelId()); } List ranked = DiscoveryRanker.rank(all, maxSubscribers, minRatio, exclude); int saved = 0; for (DiscoveryRanker.Candidate c : ranked) { if (channelRepository.existsByChannelId(c.channelId())) continue; // 이미 내 채널 RecommendedChannel rc = recommendedChannelRepository.findByChannelId(c.channelId()) .orElseGet(RecommendedChannel::new); if ("EXCLUDED".equals(rc.getStatus()) || "REGISTERED".equals(rc.getStatus())) continue; // 기존 NEW 항목이면 더 낮은 배율일 때만 건너뜀(같거나 높으면 갱신 — 지역 등 메타 최신화) if (rc.getId() != null && rc.getRatio() != null && c.ratio() < rc.getRatio()) continue; rc.setChannelId(c.channelId()); rc.setChannelTitle(c.channelTitle()); rc.setThumbnailUrl(c.thumbnailUrl()); rc.setSubscriberCount(c.subscriberCount()); rc.setStatus("NEW"); rc.setTopVideoId(c.topVideoId()); rc.setTopVideoTitle(c.topVideoTitle()); rc.setTopVideoViewCount(c.topVideoViewCount()); rc.setRatio(c.ratio()); rc.setRegion(c.region()); recommendedChannelRepository.save(rc); saved++; } Map summary = new LinkedHashMap<>(); summary.put("format", fmt); summary.put("regions", searchedRegions); summary.put("keywords", keywords); summary.put("keywordSearches", keywordSearches); summary.put("candidates", ranked.size()); summary.put("saved", saved); summary.put("quotaRemaining", quotaGuard.remaining()); log.info("[Discovery] 완료: {}", summary); return summary; } }