발굴 대상이 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) <noreply@anthropic.com>
167 lines
8.0 KiB
Java
167 lines
8.0 KiB
Java
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<String, Object> runDiscovery() {
|
|
return runDiscovery(null);
|
|
}
|
|
|
|
/**
|
|
* 발굴 실행.
|
|
* @param format "SHORTS" 또는 "LONG_FORM". null/blank/미인식이면 설정 기본값(defaultFormat)을 사용.
|
|
*/
|
|
@Transactional
|
|
public Map<String, Object> runDiscovery(String format) {
|
|
String fmt = (format == null || format.isBlank()) ? defaultFormat : format.trim().toUpperCase();
|
|
if (!"SHORTS".equals(fmt) && !"LONG_FORM".equals(fmt)) fmt = defaultFormat;
|
|
List<String> regions = Arrays.stream(regionsCsv.split(","))
|
|
.map(String::trim).filter(s -> !s.isBlank()).toList();
|
|
// 예능 특화 발굴용 키워드 시드. 비어있으면(설정 미지정) 추가 검색 없이 기존 동작과 동일.
|
|
List<String> keywords = Arrays.stream(keywordsCsv.split(","))
|
|
.map(String::trim).filter(s -> !s.isBlank()).toList();
|
|
|
|
List<YoutubeSearchResultDto> all = new ArrayList<>();
|
|
List<String> 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<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("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;
|
|
}
|
|
}
|