From 02fa5be25b011544ff0277ac5bf4176050ede0ba Mon Sep 17 00:00:00 2001 From: "hehihoho3@gmail.com" Date: Fri, 26 Jun 2026 10:43:24 +0900 Subject: [PATCH] =?UTF-8?q?feat(discover):=20ChannelDiscoveryService(?= =?UTF-8?q?=EC=A7=80=EC=97=AD=20Shorts=20=EA=B2=80=EC=83=89=E2=86=92?= =?UTF-8?q?=EB=96=A1=EC=83=81=20=EC=B1=84=EB=84=90=20upsert)+=EC=84=A4?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .../channel/ChannelDiscoveryService.java | 109 ++++++++++++++++++ src/main/resources/application.yml | 10 ++ 2 files changed, 119 insertions(+) create mode 100644 src/main/java/com/hlab/yanalyst/domain/channel/ChannelDiscoveryService.java diff --git a/src/main/java/com/hlab/yanalyst/domain/channel/ChannelDiscoveryService.java b/src/main/java/com/hlab/yanalyst/domain/channel/ChannelDiscoveryService.java new file mode 100644 index 0000000..f72fd38 --- /dev/null +++ b/src/main/java/com/hlab/yanalyst/domain/channel/ChannelDiscoveryService.java @@ -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 runDiscovery() { + List regions = Arrays.stream(regionsCsv.split(",")) + .map(String::trim).filter(s -> !s.isBlank()).toList(); + + List all = new ArrayList<>(); + List 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 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("regions", searchedRegions); + summary.put("candidates", ranked.size()); + summary.put("saved", saved); + summary.put("quotaRemaining", quotaGuard.remaining()); + log.info("[Discovery] 완료: {}", summary); + return summary; + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 2344df6..20c2db4 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -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}