feat(discover): ChannelDiscoveryService(지역 Shorts 검색→떡상 채널 upsert)+설정

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
hehihoho3@gmail.com 2026-06-26 10:43:24 +09:00
parent 507ae7c760
commit 02fa5be25b
2 changed files with 119 additions and 0 deletions

View File

@ -0,0 +1,109 @@
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;
/** 지역 인기 Shorts 검색 → 떡상 채널 발굴 → 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,JP,US}")
private String regionsCsv;
@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;
@Transactional
public Map<String, Object> runDiscovery() {
List<String> regions = Arrays.stream(regionsCsv.split(","))
.map(String::trim).filter(s -> !s.isBlank()).toList();
List<YoutubeSearchResultDto> all = new ArrayList<>();
List<String> searchedRegions = new ArrayList<>();
for (String region : regions) {
if (!quotaGuard.tryConsume(SEARCH_QUOTA)) {
log.warn("[Discovery] 쿼터 예산 소진 — 지역 {} 이후 건너뜀 (잔여 {})", region, quotaGuard.remaining());
break;
}
try {
YoutubeSearchCondition cond = new YoutubeSearchCondition();
cond.setRegions(List.of(region));
cond.setFormat("SHORTS");
cond.setPeriodDays(periodDays);
// 광범위 검색: 키워드 없이 인기 Shorts. 결과 빈약 검색 API 보강 필요(스펙 §4.2).
YoutubeSearchPageDto page = youtubeSearchService.searchYoutubeVideos(cond);
if (page != null && page.getItems() != null) all.addAll(page.getItems());
searchedRegions.add(region);
} catch (Exception e) {
log.error("[Discovery] 지역 {} 검색 실패 — 건너뜀", region, e);
}
}
// 이미 제외(EXCLUDED) 채널 제외
Set<String> exclude = new HashSet<>();
for (RecommendedChannel rc : recommendedChannelRepository.findByStatusOrderByRatioDesc("EXCLUDED")) {
exclude.add(rc.getChannelId());
}
List<DiscoveryRanker.Candidate> 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<String, Object> summary = new LinkedHashMap<>();
summary.put("regions", searchedRegions);
summary.put("candidates", ranked.size());
summary.put("saved", saved);
summary.put("quotaRemaining", quotaGuard.remaining());
log.info("[Discovery] 완료: {}", summary);
return summary;
}
}

View File

@ -81,5 +81,15 @@ hlab:
channel-snapshot:
enabled: ${CHANNEL_SNAPSHOT_ENABLED:true}
cron: ${CHANNEL_SNAPSHOT_CRON:0 0 3 * * *} # 매일 03:00 (성장 추이 기록)
channel-discovery:
enabled: ${CHANNEL_DISCOVERY_ENABLED:true}
cron: ${CHANNEL_DISCOVERY_CRON:0 30 4 * * *} # 매일 04:30 (추천 채널 발굴)
youtube:
daily-quota: ${YOUTUBE_DAILY_QUOTA:10000} # 자동 수집이 소비할 수 있는 일일 쿼터 상한(추정)
# 추천 채널 발굴: 지역 인기 Shorts 에서 작은구독자·고배율 떡상 채널 발굴
discovery:
regions: ${DISCOVERY_REGIONS:KR,JP,US}
max-subscribers: ${DISCOVERY_MAX_SUBS:100000} # 작은 채널 상한
min-ratio: ${DISCOVERY_MIN_RATIO:5.0} # 떡상 배율 하한
period-days: ${DISCOVERY_PERIOD_DAYS:14} # 최근 N일 영상 대상
top-n: ${DISCOVERY_TOP_N:30}