feat: 발굴 예능 특화(키워드 시드) + 텔레그램 아침 추천 알림

예능 전환 2단계 — 소재 발굴을 예능 쪽으로 강화하고, 매일 아침 추천을 자동으로 받는다.

발굴 예능 특화
- ChannelDiscoveryService: 지역 인기 Shorts 발굴에 더해, 설정된 예능 키워드
  (hlab.discovery.keywords, 기본 "예능,방송,하이라이트")로 지역×키워드 Shorts를
  조회수순 검색해 예능 클립 소재 채널을 우선 발굴.
- 매 검색마다 쿼터 가드, 소진 시 전체 발굴 중단(라벨 break). 키워드 비면 기존 동작 유지.

텔레그램 아침 추천
- TelegramNotifier 신규(global/notify): bot-token/chat-id 미설정 시 no-op,
  발송 예외는 삼켜 호출측을 깨지 않음. @Qualifier("restTemplate") 주입.
- 매일 04:30 발굴 직후 NEW 추천채널 상위 N개를 한국어 HTML 다이제스트로 발송.
- 활성화: env TELEGRAM_BOT_TOKEN / TELEGRAM_CHAT_ID 설정.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
hehihoho3@gmail.com 2026-07-23 16:06:41 +09:00
parent d0faada002
commit 9dc0f0248a
4 changed files with 186 additions and 0 deletions

View File

@ -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<String, Object> runDiscovery() {
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;
@ -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<String, Object> 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());

View File

@ -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<String, Object> 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<Map<String, Object>> entity = new HttpEntity<>(body, headers);
ResponseEntity<String> 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;
}
}
}

View File

@ -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<String, Object> 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<RecommendedChannel> all = recommendedChannelRepository.findByStatusOrderByRatioDesc("NEW");
List<RecommendedChannel> top = all.size() > digestTopN ? all.subList(0, digestTopN) : all;
if (top.isEmpty()) {
log.info("[Scheduler] 아침 다이제스트: NEW 추천 채널 없음 — 발송 생략");
return;
}
StringBuilder sb = new StringBuilder();
sb.append("🎬 <b>오늘의 발굴 추천 채널 TOP ").append(top.size()).append("</b>");
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(". <b>").append(title).append("</b> (구독 ").append(subs).append(")");
sb.append("\n 배율 ").append(ratioStr).append("×");
if (videoId != null && !videoId.isBlank()) {
sb.append(" · <a href=\"https://www.youtube.com/watch?v=")
.append(esc(videoId))
.append("\">대표영상</a>");
}
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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;");
}
/** 채널 1개 수집의 추정 쿼터 소비량(playlistItems + videos.list, 대략치). */

View File

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