diff --git a/src/main/java/com/hlab/yanalyst/domain/channel/ChannelService.java b/src/main/java/com/hlab/yanalyst/domain/channel/ChannelService.java index 7d50042..b4aafeb 100644 --- a/src/main/java/com/hlab/yanalyst/domain/channel/ChannelService.java +++ b/src/main/java/com/hlab/yanalyst/domain/channel/ChannelService.java @@ -293,7 +293,7 @@ public class ChannelService { private void processVideos(Channel channel, List videoIds) { // 내 채널(OWN)의 영상은 소재가 아니라 성과라 출처를 달리해 수집함/발굴에서 격리한다. - upsertVideos(channel, videoIds, ChannelRole.videoSource(channel.roleOrDefault()), null, null); + upsertVideos(channel, videoIds, ChannelRole.videoSource(channel.roleOrDefault()), null, null, null); } /** @@ -302,10 +302,12 @@ public class ChannelService { * @param source 저장할 출처. CHANNEL(등록 채널) / SOURCE(소재 원본) / RIVAL(경쟁 채널) * @param minDurationSec 이 길이(초) 미만이거나 길이를 모르는 영상은 건너뜀(null 이면 전체) * @param publishedAfter 이 시각 이전 업로드는 건너뜀(null 이면 제한 없음) + * @param newInsertsOut null 이 아니면 갱신이 아니라 새로 insert 된 영상을 여기 담아준다(골든타임 알림용) * @return 저장·갱신한 영상 수 */ private int upsertVideos(Channel channel, List videoIds, String source, - Integer minDurationSec, LocalDateTime publishedAfter) { + Integer minDurationSec, LocalDateTime publishedAfter, + List newInsertsOut) { String apiUrl = UriComponentsBuilder.fromHttpUrl("https://www.googleapis.com/youtube/v3/videos") // status 는 임베드 가능 여부(embeddable) 때문에 필요하다. part 를 늘려도 쿼터는 그대로다. .queryParam("part", "snippet,statistics,contentDetails,status") @@ -379,6 +381,7 @@ public class ChannelService { newVideo.applyHashtags(hashtags); newVideo.applyEmbeddable(embeddable); channelVideoRepository.save(newVideo); + if (newInsertsOut != null) newInsertsOut.add(newVideo); }); saved++; } @@ -395,11 +398,13 @@ public class ChannelService { * @param channel SOURCE 또는 RIVAL 역할의 채널 * @param publishedAfter 이 시각 이후 업로드만 수집 * @param minDurationSec 이 길이(초) 미만은 수집하지 않음 + * @param newInsertsOut null 이 아니면 새로 insert 된 영상을 여기 담아준다(골든타임 알림용) * @return 저장·갱신된 영상 수 * @throws IllegalStateException uploads 플레이리스트를 찾을 수 없을 때 */ @Transactional - public int collectFeedVideos(Channel channel, LocalDateTime publishedAfter, int minDurationSec) { + public int collectFeedVideos(Channel channel, LocalDateTime publishedAfter, int minDurationSec, + List newInsertsOut) { String role = channel.roleOrDefault(); String uploadsPlaylistId = channel.getUploadsPlaylistId(); if (uploadsPlaylistId == null || uploadsPlaylistId.isBlank()) { @@ -424,7 +429,7 @@ public class ChannelService { if (videoIds.isEmpty()) return 0; String source = ChannelRole.RIVAL.equals(role) ? ChannelRole.RIVAL : ChannelRole.SOURCE; - return upsertVideos(channel, videoIds, source, minDurationSec, publishedAfter); + return upsertVideos(channel, videoIds, source, minDurationSec, publishedAfter, newInsertsOut); } diff --git a/src/main/java/com/hlab/yanalyst/domain/channel/FeedAlertService.java b/src/main/java/com/hlab/yanalyst/domain/channel/FeedAlertService.java new file mode 100644 index 0000000..2a53f3a --- /dev/null +++ b/src/main/java/com/hlab/yanalyst/domain/channel/FeedAlertService.java @@ -0,0 +1,81 @@ +package com.hlab.yanalyst.domain.channel; + +import com.hlab.yanalyst.global.notify.TelegramNotifier; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.Comparator; +import java.util.List; + +/** + * 골든타임 소재 텔레그램 알림. + * + *

수집에서 새로 담긴 영상 중 업로드 {@link FeedBadges#GOLDEN_HOURS}시간 이내인 것만 골라 + * 한 번에 알린다. 신규 insert 만 대상이라 같은 영상이 두 번 울리지 않는다. + * 알림 실패는 수집을 깨지 않는다({@link TelegramNotifier}가 예외를 삼킨다). + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class FeedAlertService { + + /** 한 메시지에 나열할 최대 영상 수(텔레그램 4096자 제한 여유). */ + private static final int MAX_LINES = 8; + + private final TelegramNotifier telegramNotifier; + + /** + * @param header 메시지 제목 앞머리(예: "소재 피드", "인물 추적") + * @param newVideos 이번 수집에서 새로 insert 된 영상들 + * @return 알림에 포함된 영상 수(텔레그램 미설정·해당 없음·발송 실패면 0) + */ + public int notifyGoldenTime(String header, List newVideos) { + if (newVideos == null || newVideos.isEmpty() || !telegramNotifier.isEnabled()) return 0; + + LocalDateTime now = LocalDateTime.now(); + List golden = newVideos.stream() + .filter(v -> FeedBadges.isGoldenTime(v.getPublishedAt(), now)) + .sorted(Comparator.comparing(ChannelVideo::getPublishedAt).reversed()) + .toList(); + if (golden.isEmpty()) return 0; + + StringBuilder sb = new StringBuilder(); + sb.append("🔥 ").append(esc(header)).append(" 골든타임 ").append(golden.size()) + .append("건 — 아직 아무도 안 잘랐을 확률이 높아요"); + + int shown = Math.min(golden.size(), MAX_LINES); + for (int i = 0; i < shown; i++) { + ChannelVideo v = golden.get(i); + sb.append("\n\n• ").append(esc(v.getChannelTitle())).append(" · ") + .append(ago(v.getPublishedAt(), now)) + .append("\n ").append(esc(clip(v.getTitle()))).append(""); + } + if (golden.size() > shown) sb.append("\n\n…외 ").append(golden.size() - shown).append("건"); + sb.append("\n\nhttps://h-lab.tolag.shop/feed"); + + boolean sent = telegramNotifier.sendMessage(sb.toString()); + log.info("[Feed] 골든타임 알림: sent={}, count={}", sent, golden.size()); + return sent ? golden.size() : 0; + } + + private static String ago(LocalDateTime publishedAt, LocalDateTime now) { + if (publishedAt == null) return "-"; + long hours = Duration.between(publishedAt, now).toHours(); + return hours < 1 ? "방금 업로드" : hours + "시간 전"; + } + + private static String clip(String s) { + if (s == null || s.isBlank()) return "(제목 없음)"; + return s.length() <= 70 ? s : s.substring(0, 69) + "…"; + } + + /** HTML parse_mode 용 특수문자 이스케이프. */ + private static String esc(String s) { + if (s == null) return "-"; + return s.replace("&", "&").replace("<", "<").replace(">", ">"); + } +} diff --git a/src/main/java/com/hlab/yanalyst/domain/channel/FeedCollectionService.java b/src/main/java/com/hlab/yanalyst/domain/channel/FeedCollectionService.java index 499bcf1..7973acb 100644 --- a/src/main/java/com/hlab/yanalyst/domain/channel/FeedCollectionService.java +++ b/src/main/java/com/hlab/yanalyst/domain/channel/FeedCollectionService.java @@ -33,6 +33,7 @@ public class FeedCollectionService { private final ChannelRepository channelRepository; private final ChannelService channelService; private final YoutubeQuotaGuard quotaGuard; + private final FeedAlertService feedAlertService; @Value("${hlab.feed.enabled:true}") private boolean enabled; @@ -71,6 +72,7 @@ public class FeedCollectionService { LocalDateTime publishedAfter = LocalDateTime.now().minusDays(periodDays); int ok = 0, failed = 0, skippedByQuota = 0, disabled = 0, saved = 0; + List goldenCandidates = new java.util.ArrayList<>(); for (Channel c : seeds) { if (c.isFeedDisabled()) { @@ -83,7 +85,10 @@ public class FeedCollectionService { continue; } try { - saved += channelService.collectFeedVideos(c, publishedAfter, minDurationSec); + List newOnes = new java.util.ArrayList<>(); + saved += channelService.collectFeedVideos(c, publishedAfter, minDurationSec, newOnes); + // 경쟁(RIVAL) 채널의 신규 영상은 경쟁자의 결과물이라 골든타임 알림 대상이 아니다 + if (ChannelRole.SOURCE.equals(c.roleOrDefault())) goldenCandidates.addAll(newOnes); markSuccess(c.getId()); ok++; } catch (Exception e) { @@ -93,6 +98,8 @@ public class FeedCollectionService { } } + int goldenAlerted = feedAlertService.notifyGoldenTime("소재 피드", goldenCandidates); + Map summary = new LinkedHashMap<>(); summary.put("seeds", seeds.size()); summary.put("collected", ok); @@ -100,6 +107,7 @@ public class FeedCollectionService { summary.put("failed", failed); summary.put("autoDisabled", disabled); summary.put("skippedByQuota", skippedByQuota); + summary.put("goldenAlerted", goldenAlerted); summary.put("quotaRemaining", quotaGuard.remaining()); return summary; } diff --git a/src/main/java/com/hlab/yanalyst/domain/channel/PersonCollectionService.java b/src/main/java/com/hlab/yanalyst/domain/channel/PersonCollectionService.java index 472ea65..f6c9042 100644 --- a/src/main/java/com/hlab/yanalyst/domain/channel/PersonCollectionService.java +++ b/src/main/java/com/hlab/yanalyst/domain/channel/PersonCollectionService.java @@ -46,6 +46,7 @@ public class PersonCollectionService { private final ChannelRepository channelRepository; private final YoutubeQuotaGuard quotaGuard; private final RestTemplate restTemplate; + private final FeedAlertService feedAlertService; @Value("${youtube.api.key}") private String youtubeApiKey; @@ -82,6 +83,7 @@ public class PersonCollectionService { int searched = 0, saved = 0, skippedByQuota = 0, failed = 0; List names = new ArrayList<>(); + List newInserts = new ArrayList<>(); for (TrackedPerson p : people) { if (!quotaGuard.tryConsume(SEARCH_QUOTA + 1)) { @@ -90,7 +92,7 @@ public class PersonCollectionService { break; } try { - int n = collectOne(p.getName(), exclude, publishedAfter); + int n = collectOne(p.getName(), exclude, publishedAfter, newInserts); saved += n; searched++; names.add(p.getName()); @@ -101,19 +103,28 @@ public class PersonCollectionService { } } + int goldenAlerted = feedAlertService.notifyGoldenTime("인물 추적", newInserts); + Map summary = new LinkedHashMap<>(); summary.put("people", names); summary.put("searched", searched); summary.put("savedVideos", saved); summary.put("failed", failed); summary.put("skippedByQuota", skippedByQuota); + summary.put("goldenAlerted", goldenAlerted); summary.put("quotaRemaining", quotaGuard.remaining()); return summary; } - /** 인물 1명 수집. @return 새로 담거나 갱신한 롱폼 수 */ + /** + * 인물 1명 수집. + * + * @param newInsertsOut null 이 아니면 새로 insert 된 영상을 여기 담아준다(골든타임 알림용) + * @return 새로 담거나 갱신한 롱폼 수 + */ @Transactional - public int collectOne(String person, Set exclude, LocalDateTime publishedAfter) { + public int collectOne(String person, Set exclude, LocalDateTime publishedAfter, + List newInsertsOut) { String after = publishedAfter.atOffset(ZoneOffset.UTC).format(DateTimeFormatter.ISO_INSTANT); URI searchUri = UriComponentsBuilder.fromHttpUrl("https://www.googleapis.com/youtube/v3/search") @@ -160,6 +171,7 @@ public class PersonCollectionService { f.ytChannelId(), f.channelTitle(), f.durationSec(), vph, null, person); nv.applyEmbeddable(embeddable.get(f.videoId())); channelVideoRepository.save(nv); + if (newInsertsOut != null) newInsertsOut.add(nv); }); saved++; } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 281b251..f9d46f1 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -119,7 +119,8 @@ hlab: # 생 조회수로 자르면 방금 올라온 영상까지 걸리므로 시간당 조회수로 거른다. min-views-per-hour: ${FEED_PERSON_MIN_VPH:20} - # 텔레그램 아침 추천: 발굴 직후 상위 추천채널 다이제스트를 발송. 토큰/챗ID 없으면 자동 no-op. + # 텔레그램 알림: 아침 추천 다이제스트 + 피드 골든타임 소재(신규 수집분이 업로드 24h 이내일 때). + # 토큰/챗ID 없으면 자동 no-op. notify: telegram: bot-token: ${TELEGRAM_BOT_TOKEN:}