h-lab/src/main/java/com/hlab/yanalyst/domain/channel/ChannelDiscoveryService.java
hehihoho3@gmail.com aecbdb4753 feat: 발굴 포맷 옵션(롱폼/쇼츠) 추가 — 롱폼 기본
발굴 대상이 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>
2026-07-23 16:32:28 +09:00

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;
}
}