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:
parent
d0faada002
commit
9dc0f0248a
@ -35,6 +35,8 @@ public class ChannelDiscoveryService {
|
|||||||
|
|
||||||
@Value("${hlab.discovery.regions:KR,JP,US}")
|
@Value("${hlab.discovery.regions:KR,JP,US}")
|
||||||
private String regionsCsv;
|
private String regionsCsv;
|
||||||
|
@Value("${hlab.discovery.keywords:}")
|
||||||
|
private String keywordsCsv;
|
||||||
@Value("${hlab.discovery.max-subscribers:100000}")
|
@Value("${hlab.discovery.max-subscribers:100000}")
|
||||||
private long maxSubscribers;
|
private long maxSubscribers;
|
||||||
@Value("${hlab.discovery.min-ratio:5.0}")
|
@Value("${hlab.discovery.min-ratio:5.0}")
|
||||||
@ -46,10 +48,17 @@ public class ChannelDiscoveryService {
|
|||||||
public Map<String, Object> runDiscovery() {
|
public Map<String, Object> runDiscovery() {
|
||||||
List<String> regions = Arrays.stream(regionsCsv.split(","))
|
List<String> regions = Arrays.stream(regionsCsv.split(","))
|
||||||
.map(String::trim).filter(s -> !s.isBlank()).toList();
|
.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<YoutubeSearchResultDto> all = new ArrayList<>();
|
||||||
List<String> searchedRegions = new ArrayList<>();
|
List<String> searchedRegions = new ArrayList<>();
|
||||||
|
int keywordSearches = 0; // 참고용: 실제 수행한 키워드 검색 횟수
|
||||||
|
// 쿼터 가드: 지역×(1+키워드수) 만큼 검색이 일어나므로, 하나라도 소진되면 전체 발굴 루프를 중단.
|
||||||
|
regionLoop:
|
||||||
for (String region : regions) {
|
for (String region : regions) {
|
||||||
|
// 1) 키워드 없는 인기 Shorts 검색 (기존 동작)
|
||||||
if (!quotaGuard.tryConsume(SEARCH_QUOTA)) {
|
if (!quotaGuard.tryConsume(SEARCH_QUOTA)) {
|
||||||
log.warn("[Discovery] 쿼터 예산 소진 — 지역 {} 이후 건너뜀 (잔여 {})", region, quotaGuard.remaining());
|
log.warn("[Discovery] 쿼터 예산 소진 — 지역 {} 이후 건너뜀 (잔여 {})", region, quotaGuard.remaining());
|
||||||
break;
|
break;
|
||||||
@ -71,6 +80,31 @@ public class ChannelDiscoveryService {
|
|||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("[Discovery] 지역 {} 검색 실패 — 건너뜀", region, 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)한 채널 제외
|
// 이미 제외(EXCLUDED)한 채널 제외
|
||||||
@ -105,6 +139,8 @@ public class ChannelDiscoveryService {
|
|||||||
|
|
||||||
Map<String, Object> summary = new LinkedHashMap<>();
|
Map<String, Object> summary = new LinkedHashMap<>();
|
||||||
summary.put("regions", searchedRegions);
|
summary.put("regions", searchedRegions);
|
||||||
|
summary.put("keywords", keywords);
|
||||||
|
summary.put("keywordSearches", keywordSearches);
|
||||||
summary.put("candidates", ranked.size());
|
summary.put("candidates", ranked.size());
|
||||||
summary.put("saved", saved);
|
summary.put("saved", saved);
|
||||||
summary.put("quotaRemaining", quotaGuard.remaining());
|
summary.put("quotaRemaining", quotaGuard.remaining());
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -3,6 +3,8 @@ package com.hlab.yanalyst.global.schedule;
|
|||||||
import com.hlab.yanalyst.domain.channel.Channel;
|
import com.hlab.yanalyst.domain.channel.Channel;
|
||||||
import com.hlab.yanalyst.domain.channel.ChannelRepository;
|
import com.hlab.yanalyst.domain.channel.ChannelRepository;
|
||||||
import com.hlab.yanalyst.domain.channel.ChannelService;
|
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.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
@ -26,10 +28,15 @@ public class ScheduledCollectionService {
|
|||||||
private final ChannelService channelService;
|
private final ChannelService channelService;
|
||||||
private final YoutubeQuotaGuard quotaGuard;
|
private final YoutubeQuotaGuard quotaGuard;
|
||||||
private final com.hlab.yanalyst.domain.channel.ChannelDiscoveryService channelDiscoveryService;
|
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}")
|
@Value("${hlab.scheduler.channel-collection.enabled:true}")
|
||||||
private boolean enabled;
|
private boolean enabled;
|
||||||
|
|
||||||
|
@Value("${hlab.notify.telegram.morning-digest-top-n:5}")
|
||||||
|
private int digestTopN;
|
||||||
|
|
||||||
@Value("${hlab.scheduler.channel-snapshot.enabled:true}")
|
@Value("${hlab.scheduler.channel-snapshot.enabled:true}")
|
||||||
private boolean snapshotEnabled;
|
private boolean snapshotEnabled;
|
||||||
|
|
||||||
@ -45,6 +52,58 @@ public class ScheduledCollectionService {
|
|||||||
}
|
}
|
||||||
Map<String, Object> result = channelDiscoveryService.runDiscovery();
|
Map<String, Object> result = channelDiscoveryService.runDiscovery();
|
||||||
log.info("[Scheduler] 추천 채널 발굴 완료: {}", result);
|
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("&", "&").replace("<", "<").replace(">", ">");
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 채널 1개 수집의 추정 쿼터 소비량(playlistItems + videos.list, 대략치). */
|
/** 채널 1개 수집의 추정 쿼터 소비량(playlistItems + videos.list, 대략치). */
|
||||||
|
|||||||
@ -94,3 +94,13 @@ hlab:
|
|||||||
min-ratio: ${DISCOVERY_MIN_RATIO:5.0} # 떡상 배율 하한
|
min-ratio: ${DISCOVERY_MIN_RATIO:5.0} # 떡상 배율 하한
|
||||||
period-days: ${DISCOVERY_PERIOD_DAYS:14} # 최근 N일 영상 대상
|
period-days: ${DISCOVERY_PERIOD_DAYS:14} # 최근 N일 영상 대상
|
||||||
top-n: ${DISCOVERY_TOP_N:30}
|
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}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user