feat(discover): ChannelDiscoveryService(지역 Shorts 검색→떡상 채널 upsert)+설정
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
507ae7c760
commit
02fa5be25b
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -81,5 +81,15 @@ hlab:
|
|||||||
channel-snapshot:
|
channel-snapshot:
|
||||||
enabled: ${CHANNEL_SNAPSHOT_ENABLED:true}
|
enabled: ${CHANNEL_SNAPSHOT_ENABLED:true}
|
||||||
cron: ${CHANNEL_SNAPSHOT_CRON:0 0 3 * * *} # 매일 03:00 (성장 추이 기록)
|
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:
|
youtube:
|
||||||
daily-quota: ${YOUTUBE_DAILY_QUOTA:10000} # 자동 수집이 소비할 수 있는 일일 쿼터 상한(추정)
|
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}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user