피드·인물 수집에서 새로 담긴 영상이 업로드 24시간(골든타임) 이내면 텔레그램으로 알림을 발송한다. 신규 insert만 대상이라 같은 영상이 두 번 울리지 않고, 경쟁(RIVAL) 채널 영상은 알림에서 제외한다. 기존 TelegramNotifier(hlab.notify.telegram) 재사용 — 미설정 시 no-op. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
236 lines
10 KiB
Java
236 lines
10 KiB
Java
package com.hlab.yanalyst.domain.channel;
|
|
|
|
import com.fasterxml.jackson.databind.JsonNode;
|
|
import com.hlab.yanalyst.global.schedule.YoutubeQuotaGuard;
|
|
import lombok.RequiredArgsConstructor;
|
|
import lombok.extern.slf4j.Slf4j;
|
|
import org.springframework.beans.factory.annotation.Value;
|
|
import org.springframework.scheduling.annotation.Scheduled;
|
|
import org.springframework.stereotype.Service;
|
|
import org.springframework.transaction.annotation.Transactional;
|
|
import org.springframework.web.client.RestTemplate;
|
|
import org.springframework.web.util.UriComponentsBuilder;
|
|
|
|
import java.math.BigDecimal;
|
|
import java.net.URI;
|
|
import java.time.LocalDateTime;
|
|
import java.time.ZoneOffset;
|
|
import java.time.format.DateTimeFormatter;
|
|
import java.util.ArrayList;
|
|
import java.util.HashSet;
|
|
import java.util.LinkedHashMap;
|
|
import java.util.List;
|
|
import java.util.Map;
|
|
import java.util.Set;
|
|
|
|
/**
|
|
* 인물 추적 수집기.
|
|
*
|
|
* <p>인물은 프로그램을 옮겨 다닌다. 채널 시드만으로는 "윤경호가 KBS 시상식에 나온 영상"을
|
|
* 절대 못 잡는데, 인물명으로 검색하면 잡힌다. 다만 1명당 검색 1회(100 units)라 비싸서
|
|
* 하루 1회만 돌고 활성 인원을 제한한다.
|
|
*/
|
|
@Slf4j
|
|
@Service
|
|
@RequiredArgsConstructor
|
|
public class PersonCollectionService {
|
|
|
|
/** search.list 1회 추정 쿼터. videos.list 1회(1 unit)가 더해진다. */
|
|
private static final long SEARCH_QUOTA = 100;
|
|
|
|
/** 검색 1회에 받아올 후보 수. 같은 쿼터라면 많이 받는 게 이득(대부분 쇼츠라 걸러진다). */
|
|
private static final int SAMPLE = 50;
|
|
|
|
private final TrackedPersonRepository personRepository;
|
|
private final ChannelVideoRepository channelVideoRepository;
|
|
private final ChannelRepository channelRepository;
|
|
private final YoutubeQuotaGuard quotaGuard;
|
|
private final RestTemplate restTemplate;
|
|
private final FeedAlertService feedAlertService;
|
|
|
|
@Value("${youtube.api.key}")
|
|
private String youtubeApiKey;
|
|
|
|
@Value("${hlab.feed.person.enabled:true}")
|
|
private boolean enabled;
|
|
|
|
@Value("${hlab.feed.person.period-days:14}")
|
|
private int periodDays;
|
|
|
|
@Value("${hlab.feed.person.max-people:10}")
|
|
private int maxPeople;
|
|
|
|
/** 시간당 조회수 하한 — 팬채널·커버곡 같은 노이즈를 거른다. */
|
|
@Value("${hlab.feed.person.min-views-per-hour:20}")
|
|
private double minViewsPerHour;
|
|
|
|
@Scheduled(cron = "${hlab.feed.person.cron:0 45 5 * * *}")
|
|
public void scheduledCollect() {
|
|
if (!enabled) {
|
|
log.info("[Person] 인물 추적 비활성화됨 (hlab.feed.person.enabled=false)");
|
|
return;
|
|
}
|
|
log.info("[Person] 자동 수집 완료: {}", collectAll());
|
|
}
|
|
|
|
/** 활성 인물 전체를 수집한다. */
|
|
public Map<String, Object> collectAll() {
|
|
List<TrackedPerson> people = personRepository.findByEnabledTrueOrderByIdAsc();
|
|
if (people.size() > maxPeople) people = people.subList(0, maxPeople);
|
|
|
|
LocalDateTime publishedAfter = LocalDateTime.now().minusDays(periodDays);
|
|
Set<String> exclude = excludedChannelIds();
|
|
|
|
int searched = 0, saved = 0, skippedByQuota = 0, failed = 0;
|
|
List<String> names = new ArrayList<>();
|
|
List<ChannelVideo> newInserts = new ArrayList<>();
|
|
|
|
for (TrackedPerson p : people) {
|
|
if (!quotaGuard.tryConsume(SEARCH_QUOTA + 1)) {
|
|
skippedByQuota++;
|
|
log.warn("[Person] 쿼터 예산 소진 — '{}' 이후 중단 (잔여 {})", p.getName(), quotaGuard.remaining());
|
|
break;
|
|
}
|
|
try {
|
|
int n = collectOne(p.getName(), exclude, publishedAfter, newInserts);
|
|
saved += n;
|
|
searched++;
|
|
names.add(p.getName());
|
|
recordCollection(p.getId(), n);
|
|
} catch (Exception e) {
|
|
failed++;
|
|
log.error("[Person] '{}' 수집 실패 — 건너뜀", p.getName(), e);
|
|
}
|
|
}
|
|
|
|
int goldenAlerted = feedAlertService.notifyGoldenTime("인물 추적", newInserts);
|
|
|
|
Map<String, Object> 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명 수집.
|
|
*
|
|
* @param newInsertsOut null 이 아니면 새로 insert 된 영상을 여기 담아준다(골든타임 알림용)
|
|
* @return 새로 담거나 갱신한 롱폼 수
|
|
*/
|
|
@Transactional
|
|
public int collectOne(String person, Set<String> exclude, LocalDateTime publishedAfter,
|
|
List<ChannelVideo> newInsertsOut) {
|
|
String after = publishedAfter.atOffset(ZoneOffset.UTC).format(DateTimeFormatter.ISO_INSTANT);
|
|
|
|
URI searchUri = UriComponentsBuilder.fromHttpUrl("https://www.googleapis.com/youtube/v3/search")
|
|
.queryParam("part", "snippet")
|
|
.queryParam("type", "video")
|
|
.queryParam("q", person)
|
|
.queryParam("order", "date") // 최신순 — 선점이 목적
|
|
.queryParam("publishedAfter", after)
|
|
.queryParam("regionCode", "KR")
|
|
.queryParam("relevanceLanguage", "ko")
|
|
.queryParam("maxResults", SAMPLE)
|
|
.queryParam("key", youtubeApiKey)
|
|
.build().encode().toUri(); // String 으로 넘기면 이중 인코딩된다
|
|
|
|
JsonNode root = restTemplate.getForObject(searchUri, JsonNode.class);
|
|
if (root == null) return 0;
|
|
|
|
List<String> videoIds = new ArrayList<>();
|
|
for (JsonNode item : root.path("items")) {
|
|
String id = item.path("id").path("videoId").asText(null);
|
|
if (id != null && !id.isBlank()) videoIds.add(id);
|
|
}
|
|
if (videoIds.isEmpty()) return 0;
|
|
|
|
Map<String, Boolean> embeddable = new LinkedHashMap<>();
|
|
List<PersonPicks.Found> found = fetchDetails(videoIds, embeddable);
|
|
List<PersonPicks.Found> picks = PersonPicks.keep(found, exclude, publishedAfter, minViewsPerHour);
|
|
|
|
int saved = 0;
|
|
for (PersonPicks.Found f : picks) {
|
|
BigDecimal vph = VideoMetrics.viewsPerHour(f.viewCount(), f.publishedAt());
|
|
channelVideoRepository.findByVideoId(f.videoId())
|
|
.ifPresentOrElse(v -> {
|
|
// 이미 있는 영상이면 출처는 건드리지 않는다. 소스 탭에서 사라지면 안 되므로
|
|
// 인물명만 덧붙여 두 탭 모두에 나타나게 한다.
|
|
v.update(f.title(), v.getThumbnailUrl(), f.viewCount(), v.getLikeCount());
|
|
v.applyMetrics(f.durationSec(), VideoMetrics.isShorts(f.durationSec()), vph);
|
|
v.applyMatchedPerson(person);
|
|
v.applyEmbeddable(embeddable.get(f.videoId()));
|
|
channelVideoRepository.save(v);
|
|
}, () -> {
|
|
ChannelVideo nv = ChannelVideo.fromPersonSearch(
|
|
f.videoId(), f.title(), thumbnailOf(f.videoId()), f.publishedAt(), f.viewCount(),
|
|
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++;
|
|
}
|
|
return saved;
|
|
}
|
|
|
|
/** videos.list 로 길이·조회수·업로드일을 채운다(검색 결과에는 길이가 없다). */
|
|
private List<PersonPicks.Found> fetchDetails(List<String> videoIds, Map<String, Boolean> embeddableOut) {
|
|
URI uri = UriComponentsBuilder.fromHttpUrl("https://www.googleapis.com/youtube/v3/videos")
|
|
.queryParam("part", "snippet,contentDetails,statistics,status")
|
|
.queryParam("id", String.join(",", videoIds))
|
|
.queryParam("key", youtubeApiKey)
|
|
.build().encode().toUri();
|
|
|
|
JsonNode root = restTemplate.getForObject(uri, JsonNode.class);
|
|
List<PersonPicks.Found> out = new ArrayList<>();
|
|
if (root == null) return out;
|
|
|
|
for (JsonNode item : root.path("items")) {
|
|
try {
|
|
JsonNode snippet = item.path("snippet");
|
|
Integer durationSec = VideoMetrics.parseDurationSec(
|
|
item.path("contentDetails").path("duration").asText(null));
|
|
LocalDateTime publishedAt = LocalDateTime.parse(
|
|
snippet.path("publishedAt").asText(), DateTimeFormatter.ISO_DATE_TIME);
|
|
long views = item.path("statistics").path("viewCount").asLong(0);
|
|
if (item.path("status").has("embeddable")) {
|
|
embeddableOut.put(item.path("id").asText(), item.path("status").path("embeddable").asBoolean());
|
|
}
|
|
out.add(new PersonPicks.Found(
|
|
item.path("id").asText(), snippet.path("title").asText(""),
|
|
snippet.path("channelId").asText(null), snippet.path("channelTitle").asText(""),
|
|
durationSec, publishedAt, views));
|
|
} catch (Exception e) {
|
|
log.debug("[Person] 영상 상세 파싱 실패 — 건너뜀", e);
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/** 내 채널(OWN)과 경쟁 채널(RIVAL)의 영상은 소재가 아니므로 인물 결과에서 뺀다. */
|
|
private Set<String> excludedChannelIds() {
|
|
Set<String> out = new HashSet<>();
|
|
for (Channel c : channelRepository.findOwnChannels()) out.add(c.getChannelId());
|
|
for (Channel c : channelRepository.findByRole(ChannelRole.RIVAL)) out.add(c.getChannelId());
|
|
return out;
|
|
}
|
|
|
|
/** 검색 스니펫 썸네일 대신 표준 URL 을 쓴다(해상도 일관성). */
|
|
private String thumbnailOf(String videoId) {
|
|
return "https://i.ytimg.com/vi/" + videoId + "/hqdefault.jpg";
|
|
}
|
|
|
|
/** 같은 빈 안에서 호출되므로 @Transactional 프록시가 안 걸린다 — 명시적으로 save 한다. */
|
|
private void recordCollection(Long personId, int found) {
|
|
personRepository.findById(personId).ifPresent(p -> {
|
|
p.recordCollection(found);
|
|
personRepository.save(p);
|
|
});
|
|
}
|
|
}
|