diff --git a/src/main/java/com/hlab/yanalyst/domain/channel/ChannelDiscoveryService.java b/src/main/java/com/hlab/yanalyst/domain/channel/ChannelDiscoveryService.java index bd79ceb..68f8e09 100644 --- a/src/main/java/com/hlab/yanalyst/domain/channel/ChannelDiscoveryService.java +++ b/src/main/java/com/hlab/yanalyst/domain/channel/ChannelDiscoveryService.java @@ -35,6 +35,8 @@ public class ChannelDiscoveryService { @Value("${hlab.discovery.regions:KR,JP,US}") 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}") @@ -46,10 +48,17 @@ public class ChannelDiscoveryService { public Map runDiscovery() { List regions = Arrays.stream(regionsCsv.split(",")) .map(String::trim).filter(s -> !s.isBlank()).toList(); + // 예능 특화 발굴용 키워드 시드. 비어있으면(설정 미지정) 추가 검색 없이 기존 동작과 동일. + List keywords = Arrays.stream(keywordsCsv.split(",")) + .map(String::trim).filter(s -> !s.isBlank()).toList(); List all = new ArrayList<>(); List 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; @@ -71,6 +80,31 @@ public class ChannelDiscoveryService { } 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("SHORTS"); + 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)한 채널 제외 @@ -105,6 +139,8 @@ public class ChannelDiscoveryService { Map summary = new LinkedHashMap<>(); 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()); diff --git a/src/main/java/com/hlab/yanalyst/global/notify/TelegramNotifier.java b/src/main/java/com/hlab/yanalyst/global/notify/TelegramNotifier.java new file mode 100644 index 0000000..bdbf574 --- /dev/null +++ b/src/main/java/com/hlab/yanalyst/global/notify/TelegramNotifier.java @@ -0,0 +1,81 @@ +package com.hlab.yanalyst.global.notify; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; +import org.springframework.web.client.RestTemplate; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * 텔레그램 봇으로 알림 메시지를 발송한다. + * bot-token / chat-id 가 설정되지 않았으면 조용히 no-op 하며, + * 발송 중 예외는 삼켜서 호출측(스케줄러 등)을 깨뜨리지 않는다. + */ +@Slf4j +@Service +public class TelegramNotifier { + + /** 프로젝트에 RestTemplate 빈이 3개 있으므로 반드시 한정자로 기본 빈을 지정한다. */ + private final RestTemplate restTemplate; + + @Value("${hlab.notify.telegram.bot-token:}") + private String botToken; + + @Value("${hlab.notify.telegram.chat-id:}") + private String chatId; + + public TelegramNotifier(@Qualifier("restTemplate") RestTemplate restTemplate) { + this.restTemplate = restTemplate; + } + + /** bot-token 과 chat-id 가 모두 설정되어 있으면 활성 상태. */ + public boolean isEnabled() { + return StringUtils.hasText(botToken) && StringUtils.hasText(chatId); + } + + /** + * 텔레그램으로 메시지를 발송한다. parse_mode=HTML 을 사용하므로 + * text 에 사용자 데이터가 들어갈 경우 호출측에서 HTML 특수문자를 이스케이프해야 한다. + * + * @return 발송 성공 시 true, 미설정/실패 시 false + */ + public boolean sendMessage(String text) { + if (!isEnabled()) { + log.debug("[Telegram] 미설정 — 발송 생략"); + return false; + } + try { + String url = "https://api.telegram.org/bot" + botToken + "/sendMessage"; + + Map body = new LinkedHashMap<>(); + body.put("chat_id", chatId); + body.put("text", text); + body.put("parse_mode", "HTML"); + body.put("disable_web_page_preview", true); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + HttpEntity> entity = new HttpEntity<>(body, headers); + + ResponseEntity response = restTemplate.postForEntity(url, entity, String.class); + if (response.getStatusCode().is2xxSuccessful()) { + log.info("[Telegram] 발송 성공 (status={})", response.getStatusCode()); + return true; + } + log.warn("[Telegram] 발송 실패 (status={})", response.getStatusCode()); + return false; + } catch (Exception e) { + // 알림 실패가 호출측 로직을 깨지 않도록 예외를 삼킨다. + log.warn("[Telegram] 발송 실패", e); + return false; + } + } +} diff --git a/src/main/java/com/hlab/yanalyst/global/schedule/ScheduledCollectionService.java b/src/main/java/com/hlab/yanalyst/global/schedule/ScheduledCollectionService.java index 140be14..3c424a6 100644 --- a/src/main/java/com/hlab/yanalyst/global/schedule/ScheduledCollectionService.java +++ b/src/main/java/com/hlab/yanalyst/global/schedule/ScheduledCollectionService.java @@ -3,6 +3,8 @@ package com.hlab.yanalyst.global.schedule; import com.hlab.yanalyst.domain.channel.Channel; import com.hlab.yanalyst.domain.channel.ChannelRepository; import com.hlab.yanalyst.domain.channel.ChannelService; +import com.hlab.yanalyst.domain.channel.RecommendedChannel; +import com.hlab.yanalyst.global.notify.TelegramNotifier; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; @@ -26,10 +28,15 @@ public class ScheduledCollectionService { private final ChannelService channelService; private final YoutubeQuotaGuard quotaGuard; private final com.hlab.yanalyst.domain.channel.ChannelDiscoveryService channelDiscoveryService; + private final TelegramNotifier telegramNotifier; + private final com.hlab.yanalyst.domain.channel.RecommendedChannelRepository recommendedChannelRepository; @Value("${hlab.scheduler.channel-collection.enabled:true}") private boolean enabled; + @Value("${hlab.notify.telegram.morning-digest-top-n:5}") + private int digestTopN; + @Value("${hlab.scheduler.channel-snapshot.enabled:true}") private boolean snapshotEnabled; @@ -45,6 +52,58 @@ public class ScheduledCollectionService { } Map result = channelDiscoveryService.runDiscovery(); log.info("[Scheduler] 추천 채널 발굴 완료: {}", result); + + // 발굴 직후 텔레그램 아침 다이제스트 발송(설정된 경우에만). + // 알림 실패가 발굴 스케줄을 깨지 않도록 전체를 try/catch 로 감싼다. + if (telegramNotifier.isEnabled()) { + try { + sendMorningDigest(); + } catch (Exception e) { + log.warn("[Scheduler] 아침 다이제스트 발송 중 오류(무시)", e); + } + } + } + + /** NEW 추천 채널 상위 N개를 한국어 HTML 다이제스트로 만들어 텔레그램으로 발송. */ + private void sendMorningDigest() { + List all = recommendedChannelRepository.findByStatusOrderByRatioDesc("NEW"); + List top = all.size() > digestTopN ? all.subList(0, digestTopN) : all; + + if (top.isEmpty()) { + log.info("[Scheduler] 아침 다이제스트: NEW 추천 채널 없음 — 발송 생략"); + return; + } + + StringBuilder sb = new StringBuilder(); + sb.append("🎬 오늘의 발굴 추천 채널 TOP ").append(top.size()).append(""); + + int i = 1; + for (RecommendedChannel rc : top) { + String title = esc(rc.getChannelTitle()); + String subs = rc.getSubscriberCount() == null ? "-" : String.format("%,d", rc.getSubscriberCount()); + String ratioStr = rc.getRatio() == null ? "-" : String.format("%.0f", rc.getRatio()); + String videoId = rc.getTopVideoId(); + + sb.append("\n\n").append(i).append(". ").append(title).append(" (구독 ").append(subs).append(")"); + sb.append("\n 배율 ").append(ratioStr).append("×"); + if (videoId != null && !videoId.isBlank()) { + sb.append(" · 대표영상"); + } + i++; + } + + boolean sent = telegramNotifier.sendMessage(sb.toString()); + log.info("[Scheduler] 아침 다이제스트 발송 결과: sent={}, count={}", sent, top.size()); + } + + /** HTML parse_mode 용 특수문자 이스케이프. null 은 "-" 로 방어. */ + private String esc(String s) { + if (s == null) { + return "-"; + } + return s.replace("&", "&").replace("<", "<").replace(">", ">"); } /** 채널 1개 수집의 추정 쿼터 소비량(playlistItems + videos.list, 대략치). */ diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index a4feebb..728ce81 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -94,3 +94,13 @@ hlab: min-ratio: ${DISCOVERY_MIN_RATIO:5.0} # 떡상 배율 하한 period-days: ${DISCOVERY_PERIOD_DAYS:14} # 최근 N일 영상 대상 top-n: ${DISCOVERY_TOP_N:30} + # 예능 특화 시드 키워드(쉼표구분). 각 키워드×지역으로 Shorts 검색해 예능 클립 소재를 우선 발굴. + # 빈 값이면 키워드 없는 기존 '지역 인기 Shorts' 발굴로 동작. + keywords: ${DISCOVERY_KEYWORDS:예능,방송,하이라이트} + + # 텔레그램 아침 추천: 발굴 직후 상위 추천채널 다이제스트를 발송. 토큰/챗ID 없으면 자동 no-op. + notify: + telegram: + bot-token: ${TELEGRAM_BOT_TOKEN:} + chat-id: ${TELEGRAM_CHAT_ID:} + morning-digest-top-n: ${TELEGRAM_DIGEST_TOP_N:5}