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; /** * 인물 추적 수집기. * *

인물은 프로그램을 옮겨 다닌다. 채널 시드만으로는 "윤경호가 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 collectAll() { List people = personRepository.findByEnabledTrueOrderByIdAsc(); if (people.size() > maxPeople) people = people.subList(0, maxPeople); LocalDateTime publishedAfter = LocalDateTime.now().minusDays(periodDays); Set exclude = excludedChannelIds(); 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)) { 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 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 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") .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 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 embeddable = new LinkedHashMap<>(); List found = fetchDetails(videoIds, embeddable); List 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 fetchDetails(List videoIds, Map 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 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 excludedChannelIds() { Set 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); }); } }