feat: 골든타임 소재 텔레그램 알림
피드·인물 수집에서 새로 담긴 영상이 업로드 24시간(골든타임) 이내면 텔레그램으로 알림을 발송한다. 신규 insert만 대상이라 같은 영상이 두 번 울리지 않고, 경쟁(RIVAL) 채널 영상은 알림에서 제외한다. 기존 TelegramNotifier(hlab.notify.telegram) 재사용 — 미설정 시 no-op. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
52108847f5
commit
4cc5e6548d
@ -293,7 +293,7 @@ public class ChannelService {
|
|||||||
|
|
||||||
private void processVideos(Channel channel, List<String> videoIds) {
|
private void processVideos(Channel channel, List<String> videoIds) {
|
||||||
// 내 채널(OWN)의 영상은 소재가 아니라 성과라 출처를 달리해 수집함/발굴에서 격리한다.
|
// 내 채널(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 source 저장할 출처. CHANNEL(등록 채널) / SOURCE(소재 원본) / RIVAL(경쟁 채널)
|
||||||
* @param minDurationSec 이 길이(초) 미만이거나 길이를 모르는 영상은 건너뜀(null 이면 전체)
|
* @param minDurationSec 이 길이(초) 미만이거나 길이를 모르는 영상은 건너뜀(null 이면 전체)
|
||||||
* @param publishedAfter 이 시각 이전 업로드는 건너뜀(null 이면 제한 없음)
|
* @param publishedAfter 이 시각 이전 업로드는 건너뜀(null 이면 제한 없음)
|
||||||
|
* @param newInsertsOut null 이 아니면 갱신이 아니라 새로 insert 된 영상을 여기 담아준다(골든타임 알림용)
|
||||||
* @return 저장·갱신한 영상 수
|
* @return 저장·갱신한 영상 수
|
||||||
*/
|
*/
|
||||||
private int upsertVideos(Channel channel, List<String> videoIds, String source,
|
private int upsertVideos(Channel channel, List<String> videoIds, String source,
|
||||||
Integer minDurationSec, LocalDateTime publishedAfter) {
|
Integer minDurationSec, LocalDateTime publishedAfter,
|
||||||
|
List<ChannelVideo> newInsertsOut) {
|
||||||
String apiUrl = UriComponentsBuilder.fromHttpUrl("https://www.googleapis.com/youtube/v3/videos")
|
String apiUrl = UriComponentsBuilder.fromHttpUrl("https://www.googleapis.com/youtube/v3/videos")
|
||||||
// status 는 임베드 가능 여부(embeddable) 때문에 필요하다. part 를 늘려도 쿼터는 그대로다.
|
// status 는 임베드 가능 여부(embeddable) 때문에 필요하다. part 를 늘려도 쿼터는 그대로다.
|
||||||
.queryParam("part", "snippet,statistics,contentDetails,status")
|
.queryParam("part", "snippet,statistics,contentDetails,status")
|
||||||
@ -379,6 +381,7 @@ public class ChannelService {
|
|||||||
newVideo.applyHashtags(hashtags);
|
newVideo.applyHashtags(hashtags);
|
||||||
newVideo.applyEmbeddable(embeddable);
|
newVideo.applyEmbeddable(embeddable);
|
||||||
channelVideoRepository.save(newVideo);
|
channelVideoRepository.save(newVideo);
|
||||||
|
if (newInsertsOut != null) newInsertsOut.add(newVideo);
|
||||||
});
|
});
|
||||||
saved++;
|
saved++;
|
||||||
}
|
}
|
||||||
@ -395,11 +398,13 @@ public class ChannelService {
|
|||||||
* @param channel SOURCE 또는 RIVAL 역할의 채널
|
* @param channel SOURCE 또는 RIVAL 역할의 채널
|
||||||
* @param publishedAfter 이 시각 이후 업로드만 수집
|
* @param publishedAfter 이 시각 이후 업로드만 수집
|
||||||
* @param minDurationSec 이 길이(초) 미만은 수집하지 않음
|
* @param minDurationSec 이 길이(초) 미만은 수집하지 않음
|
||||||
|
* @param newInsertsOut null 이 아니면 새로 insert 된 영상을 여기 담아준다(골든타임 알림용)
|
||||||
* @return 저장·갱신된 영상 수
|
* @return 저장·갱신된 영상 수
|
||||||
* @throws IllegalStateException uploads 플레이리스트를 찾을 수 없을 때
|
* @throws IllegalStateException uploads 플레이리스트를 찾을 수 없을 때
|
||||||
*/
|
*/
|
||||||
@Transactional
|
@Transactional
|
||||||
public int collectFeedVideos(Channel channel, LocalDateTime publishedAfter, int minDurationSec) {
|
public int collectFeedVideos(Channel channel, LocalDateTime publishedAfter, int minDurationSec,
|
||||||
|
List<ChannelVideo> newInsertsOut) {
|
||||||
String role = channel.roleOrDefault();
|
String role = channel.roleOrDefault();
|
||||||
String uploadsPlaylistId = channel.getUploadsPlaylistId();
|
String uploadsPlaylistId = channel.getUploadsPlaylistId();
|
||||||
if (uploadsPlaylistId == null || uploadsPlaylistId.isBlank()) {
|
if (uploadsPlaylistId == null || uploadsPlaylistId.isBlank()) {
|
||||||
@ -424,7 +429,7 @@ public class ChannelService {
|
|||||||
if (videoIds.isEmpty()) return 0;
|
if (videoIds.isEmpty()) return 0;
|
||||||
|
|
||||||
String source = ChannelRole.RIVAL.equals(role) ? ChannelRole.RIVAL : ChannelRole.SOURCE;
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 골든타임 소재 텔레그램 알림.
|
||||||
|
*
|
||||||
|
* <p>수집에서 <b>새로 담긴</b> 영상 중 업로드 {@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<ChannelVideo> newVideos) {
|
||||||
|
if (newVideos == null || newVideos.isEmpty() || !telegramNotifier.isEnabled()) return 0;
|
||||||
|
|
||||||
|
LocalDateTime now = LocalDateTime.now();
|
||||||
|
List<ChannelVideo> 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("🔥 <b>").append(esc(header)).append(" 골든타임 ").append(golden.size())
|
||||||
|
.append("건</b> — 아직 아무도 안 잘랐을 확률이 높아요");
|
||||||
|
|
||||||
|
int shown = Math.min(golden.size(), MAX_LINES);
|
||||||
|
for (int i = 0; i < shown; i++) {
|
||||||
|
ChannelVideo v = golden.get(i);
|
||||||
|
sb.append("\n\n• <b>").append(esc(v.getChannelTitle())).append("</b> · ")
|
||||||
|
.append(ago(v.getPublishedAt(), now))
|
||||||
|
.append("\n <a href=\"https://www.youtube.com/watch?v=").append(esc(v.getVideoId()))
|
||||||
|
.append("\">").append(esc(clip(v.getTitle()))).append("</a>");
|
||||||
|
}
|
||||||
|
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(">", ">");
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -33,6 +33,7 @@ public class FeedCollectionService {
|
|||||||
private final ChannelRepository channelRepository;
|
private final ChannelRepository channelRepository;
|
||||||
private final ChannelService channelService;
|
private final ChannelService channelService;
|
||||||
private final YoutubeQuotaGuard quotaGuard;
|
private final YoutubeQuotaGuard quotaGuard;
|
||||||
|
private final FeedAlertService feedAlertService;
|
||||||
|
|
||||||
@Value("${hlab.feed.enabled:true}")
|
@Value("${hlab.feed.enabled:true}")
|
||||||
private boolean enabled;
|
private boolean enabled;
|
||||||
@ -71,6 +72,7 @@ public class FeedCollectionService {
|
|||||||
LocalDateTime publishedAfter = LocalDateTime.now().minusDays(periodDays);
|
LocalDateTime publishedAfter = LocalDateTime.now().minusDays(periodDays);
|
||||||
|
|
||||||
int ok = 0, failed = 0, skippedByQuota = 0, disabled = 0, saved = 0;
|
int ok = 0, failed = 0, skippedByQuota = 0, disabled = 0, saved = 0;
|
||||||
|
List<ChannelVideo> goldenCandidates = new java.util.ArrayList<>();
|
||||||
|
|
||||||
for (Channel c : seeds) {
|
for (Channel c : seeds) {
|
||||||
if (c.isFeedDisabled()) {
|
if (c.isFeedDisabled()) {
|
||||||
@ -83,7 +85,10 @@ public class FeedCollectionService {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
saved += channelService.collectFeedVideos(c, publishedAfter, minDurationSec);
|
List<ChannelVideo> 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());
|
markSuccess(c.getId());
|
||||||
ok++;
|
ok++;
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
@ -93,6 +98,8 @@ public class FeedCollectionService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
int goldenAlerted = feedAlertService.notifyGoldenTime("소재 피드", goldenCandidates);
|
||||||
|
|
||||||
Map<String, Object> summary = new LinkedHashMap<>();
|
Map<String, Object> summary = new LinkedHashMap<>();
|
||||||
summary.put("seeds", seeds.size());
|
summary.put("seeds", seeds.size());
|
||||||
summary.put("collected", ok);
|
summary.put("collected", ok);
|
||||||
@ -100,6 +107,7 @@ public class FeedCollectionService {
|
|||||||
summary.put("failed", failed);
|
summary.put("failed", failed);
|
||||||
summary.put("autoDisabled", disabled);
|
summary.put("autoDisabled", disabled);
|
||||||
summary.put("skippedByQuota", skippedByQuota);
|
summary.put("skippedByQuota", skippedByQuota);
|
||||||
|
summary.put("goldenAlerted", goldenAlerted);
|
||||||
summary.put("quotaRemaining", quotaGuard.remaining());
|
summary.put("quotaRemaining", quotaGuard.remaining());
|
||||||
return summary;
|
return summary;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -46,6 +46,7 @@ public class PersonCollectionService {
|
|||||||
private final ChannelRepository channelRepository;
|
private final ChannelRepository channelRepository;
|
||||||
private final YoutubeQuotaGuard quotaGuard;
|
private final YoutubeQuotaGuard quotaGuard;
|
||||||
private final RestTemplate restTemplate;
|
private final RestTemplate restTemplate;
|
||||||
|
private final FeedAlertService feedAlertService;
|
||||||
|
|
||||||
@Value("${youtube.api.key}")
|
@Value("${youtube.api.key}")
|
||||||
private String youtubeApiKey;
|
private String youtubeApiKey;
|
||||||
@ -82,6 +83,7 @@ public class PersonCollectionService {
|
|||||||
|
|
||||||
int searched = 0, saved = 0, skippedByQuota = 0, failed = 0;
|
int searched = 0, saved = 0, skippedByQuota = 0, failed = 0;
|
||||||
List<String> names = new ArrayList<>();
|
List<String> names = new ArrayList<>();
|
||||||
|
List<ChannelVideo> newInserts = new ArrayList<>();
|
||||||
|
|
||||||
for (TrackedPerson p : people) {
|
for (TrackedPerson p : people) {
|
||||||
if (!quotaGuard.tryConsume(SEARCH_QUOTA + 1)) {
|
if (!quotaGuard.tryConsume(SEARCH_QUOTA + 1)) {
|
||||||
@ -90,7 +92,7 @@ public class PersonCollectionService {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
int n = collectOne(p.getName(), exclude, publishedAfter);
|
int n = collectOne(p.getName(), exclude, publishedAfter, newInserts);
|
||||||
saved += n;
|
saved += n;
|
||||||
searched++;
|
searched++;
|
||||||
names.add(p.getName());
|
names.add(p.getName());
|
||||||
@ -101,19 +103,28 @@ public class PersonCollectionService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
int goldenAlerted = feedAlertService.notifyGoldenTime("인물 추적", newInserts);
|
||||||
|
|
||||||
Map<String, Object> summary = new LinkedHashMap<>();
|
Map<String, Object> summary = new LinkedHashMap<>();
|
||||||
summary.put("people", names);
|
summary.put("people", names);
|
||||||
summary.put("searched", searched);
|
summary.put("searched", searched);
|
||||||
summary.put("savedVideos", saved);
|
summary.put("savedVideos", saved);
|
||||||
summary.put("failed", failed);
|
summary.put("failed", failed);
|
||||||
summary.put("skippedByQuota", skippedByQuota);
|
summary.put("skippedByQuota", skippedByQuota);
|
||||||
|
summary.put("goldenAlerted", goldenAlerted);
|
||||||
summary.put("quotaRemaining", quotaGuard.remaining());
|
summary.put("quotaRemaining", quotaGuard.remaining());
|
||||||
return summary;
|
return summary;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 인물 1명 수집. @return 새로 담거나 갱신한 롱폼 수 */
|
/**
|
||||||
|
* 인물 1명 수집.
|
||||||
|
*
|
||||||
|
* @param newInsertsOut null 이 아니면 새로 insert 된 영상을 여기 담아준다(골든타임 알림용)
|
||||||
|
* @return 새로 담거나 갱신한 롱폼 수
|
||||||
|
*/
|
||||||
@Transactional
|
@Transactional
|
||||||
public int collectOne(String person, Set<String> exclude, LocalDateTime publishedAfter) {
|
public int collectOne(String person, Set<String> exclude, LocalDateTime publishedAfter,
|
||||||
|
List<ChannelVideo> newInsertsOut) {
|
||||||
String after = publishedAfter.atOffset(ZoneOffset.UTC).format(DateTimeFormatter.ISO_INSTANT);
|
String after = publishedAfter.atOffset(ZoneOffset.UTC).format(DateTimeFormatter.ISO_INSTANT);
|
||||||
|
|
||||||
URI searchUri = UriComponentsBuilder.fromHttpUrl("https://www.googleapis.com/youtube/v3/search")
|
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);
|
f.ytChannelId(), f.channelTitle(), f.durationSec(), vph, null, person);
|
||||||
nv.applyEmbeddable(embeddable.get(f.videoId()));
|
nv.applyEmbeddable(embeddable.get(f.videoId()));
|
||||||
channelVideoRepository.save(nv);
|
channelVideoRepository.save(nv);
|
||||||
|
if (newInsertsOut != null) newInsertsOut.add(nv);
|
||||||
});
|
});
|
||||||
saved++;
|
saved++;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -119,7 +119,8 @@ hlab:
|
|||||||
# 생 조회수로 자르면 방금 올라온 영상까지 걸리므로 시간당 조회수로 거른다.
|
# 생 조회수로 자르면 방금 올라온 영상까지 걸리므로 시간당 조회수로 거른다.
|
||||||
min-views-per-hour: ${FEED_PERSON_MIN_VPH:20}
|
min-views-per-hour: ${FEED_PERSON_MIN_VPH:20}
|
||||||
|
|
||||||
# 텔레그램 아침 추천: 발굴 직후 상위 추천채널 다이제스트를 발송. 토큰/챗ID 없으면 자동 no-op.
|
# 텔레그램 알림: 아침 추천 다이제스트 + 피드 골든타임 소재(신규 수집분이 업로드 24h 이내일 때).
|
||||||
|
# 토큰/챗ID 없으면 자동 no-op.
|
||||||
notify:
|
notify:
|
||||||
telegram:
|
telegram:
|
||||||
bot-token: ${TELEGRAM_BOT_TOKEN:}
|
bot-token: ${TELEGRAM_BOT_TOKEN:}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user