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.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.client.RestTemplate; import org.springframework.web.util.UriComponentsBuilder; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; /** * 내 채널을 역분석해 소재 원본(SOURCE) 채널 후보를 찾는다. * *

내 업로드의 해시태그(#유퀴즈 #핑계고 …)를 빈도순으로 집계한 뒤, 상위 키워드마다 * search.list(type=channel)로 공식채널을 조회해 {@link RecommendedChannel}(roleHint=SOURCE)로 쌓는다. * 승인은 사람이 한다. * *

search.list 는 1회 100 units 라 스케줄이 아니라 수동 실행 전용이다. */ @Slf4j @Service @RequiredArgsConstructor public class SeedSuggestService { /** search.list 1회 추정 쿼터(units). */ private static final long SEARCH_QUOTA = 100; /** 키워드당 살펴볼 영상 수. 이 표본 안에서 채널 분포를 본다. */ private static final int SAMPLE_VIDEOS = 25; /** 표본에서 이 건수 미만으로 등장한 채널은 우연으로 보고 버린다. */ private static final int MIN_HITS = 3; /** 키워드당 채택할 상위 채널 수. */ private static final int PER_KEYWORD = 2; private final ChannelRepository channelRepository; private final ChannelVideoRepository channelVideoRepository; private final RecommendedChannelRepository recommendedChannelRepository; private final YoutubeQuotaGuard quotaGuard; private final RestTemplate restTemplate; @Value("${youtube.api.key}") private String youtubeApiKey; /** 후보 탐색에 쓸 상위 키워드 개수. 키워드 1개당 100 units 라 기본값을 보수적으로 잡는다. */ @Value("${hlab.feed.seed.max-keywords:8}") private int maxKeywords; /** 이 횟수 미만으로 등장한 해시태그는 프로그램명으로 보지 않는다. */ @Value("${hlab.feed.seed.min-tag-count:2}") private int minTagCount; /** * 내 채널 영상의 해시태그 빈도 (탐색 전 미리보기용, 쿼터 소비 없음). * @return 태그 → 등장 횟수 (빈도 내림차순) */ public Map analyzeKeywords() { List mine = channelRepository.findOwnChannels(); List texts = new ArrayList<>(); for (Channel c : mine) { for (ChannelVideo v : channelVideoRepository.findByChannelId(c.getId())) { // 수집 시 저장해둔 해시태그가 있으면 그걸, 없으면 제목에서라도 뽑는다. if (v.getHashtags() != null && !v.getHashtags().isBlank()) { for (String tag : HashtagExtractor.split(v.getHashtags())) texts.add("#" + tag); } else { texts.add(v.getTitle()); } } } return HashtagExtractor.countAll(texts, minTagCount); } /** * 해시태그 상위 키워드로 소재 원본 채널 후보를 발굴해 추천 목록에 쌓는다. * * @param keywords 직접 지정할 키워드(비어있으면 {@link #analyzeKeywords()} 결과 상위 N개 사용) * @return 실행 요약 */ @Transactional public Map suggestSourceSeeds(List keywords) { List targets = new ArrayList<>(); if (keywords != null && !keywords.isEmpty()) { for (String k : keywords) { String t = k == null ? "" : k.trim(); if (!t.isEmpty()) targets.add(t); } } else { for (String k : analyzeKeywords().keySet()) { if (targets.size() >= maxKeywords) break; targets.add(k); } // 내 채널이 등록돼 있지 않으면 역분석할 해시태그가 없다. 조용히 0건으로 끝내면 // 사용자는 "발굴했는데 아무것도 못 찾았다"로 오해하므로 이유를 분명히 말한다. if (targets.isEmpty()) { throw new IllegalArgumentException( "역분석할 해시태그가 없습니다. 내 채널이 등록돼 있지 않거나 수집된 영상에 해시태그가 없습니다. " + "키워드를 직접 입력해 발굴하세요."); } } int searched = 0, found = 0, saved = 0, skippedByQuota = 0; for (String keyword : targets) { if (!quotaGuard.tryConsume(SEARCH_QUOTA)) { skippedByQuota = targets.size() - searched; log.warn("[Seed] 쿼터 예산 소진 — 키워드 '{}' 이후 중단 (잔여 {})", keyword, quotaGuard.remaining()); break; } try { List candidates = searchChannels(keyword); searched++; found += candidates.size(); for (Candidate c : candidates) { if (upsertCandidate(c, keyword)) saved++; } } catch (Exception e) { log.error("[Seed] 키워드 '{}' 채널 검색 실패 — 건너뜀", keyword, e); } } Map summary = new LinkedHashMap<>(); summary.put("keywords", targets); summary.put("searched", searched); summary.put("found", found); summary.put("saved", saved); summary.put("skippedByQuota", skippedByQuota); summary.put("quotaRemaining", quotaGuard.remaining()); log.info("[Seed] 소재 원본 후보 발굴 완료: {}", summary); return summary; } /** 이미 시드로 등록됐거나 제외/등록 처리된 채널은 건너뛴다. @return 새로 저장했으면 true */ private boolean upsertCandidate(Candidate c, String keyword) { if (channelRepository.existsByChannelId(c.channelId)) return false; // 이미 등록된 채널 RecommendedChannel rc = recommendedChannelRepository.findByChannelId(c.channelId).orElse(null); if (rc != null && !"NEW".equals(rc.getStatus())) return false; // EXCLUDED/REGISTERED 는 존중 if (rc == null) rc = new RecommendedChannel(); rc.setChannelId(c.channelId); rc.setChannelTitle(c.title); rc.setThumbnailUrl(c.thumbnailUrl); rc.setSubscriberCount(c.subscriberCount); rc.setStatus("NEW"); rc.setRoleHint(ChannelRole.SOURCE); rc.setSeedKeyword(keyword); rc.setRegion("KR"); recommendedChannelRepository.save(rc); return true; } private record Candidate(String channelId, String title, String thumbnailUrl, Long subscriberCount, int hits) {} /** * 키워드로 영상을 검색해 채널별로 집계한다. * *

type=channel 검색은 채널 이름을 매칭하기 때문에 쓸 수 없다. 프로그램 클립은 * "유퀴즈"라는 이름의 채널이 아니라 tvN D ENT·뜬뜬 같은 제작사 채널에 올라오므로, * 이름으로 찾으면 구독자 한 자릿수 짜리 동명 채널만 잡힌다. * 대신 영상 검색 결과에서 같은 채널이 몇 번 나오는지를 세면 실제 제작사 채널이 드러난다. */ private List searchChannels(String keyword) { // toUriString() 을 넘기면 RestTemplate 이 URI 템플릿으로 보고 한 번 더 인코딩한다 // (한글 키워드가 %25EC.. 로 깨져 엉뚱한 채널이 잡힘). URI 오버로드로 넘겨야 한다. java.net.URI searchUri = UriComponentsBuilder.fromHttpUrl("https://www.googleapis.com/youtube/v3/search") .queryParam("part", "snippet") .queryParam("type", "video") .queryParam("q", keyword) .queryParam("regionCode", "KR") .queryParam("relevanceLanguage", "ko") .queryParam("maxResults", SAMPLE_VIDEOS) .queryParam("key", youtubeApiKey) .build() .encode() .toUri(); JsonNode root = restTemplate.getForObject(searchUri, JsonNode.class); if (root == null) return List.of(); // channelId → 표본 내 등장 횟수 Map hits = new LinkedHashMap<>(); for (JsonNode item : root.path("items")) { String channelId = item.path("snippet").path("channelId").asText(null); if (channelId == null || channelId.isBlank()) continue; hits.merge(channelId, 1, Integer::sum); } List> ranked = new ArrayList<>(hits.entrySet()); ranked.sort(Map.Entry.comparingByValue().reversed()); List picked = new ArrayList<>(); for (Map.Entry e : ranked) { if (e.getValue() < MIN_HITS) break; // 정렬돼 있으므로 이후는 볼 필요 없다 if (picked.size() >= PER_KEYWORD) break; picked.add(e.getKey()); } if (picked.isEmpty()) return List.of(); Map meta = fetchChannelMeta(picked); // channelId → [title, thumb, subs] List out = new ArrayList<>(); for (String id : picked) { String[] m = meta.get(id); if (m == null) continue; Long subs = m[2] == null ? null : Long.parseLong(m[2]); out.add(new Candidate(id, m[0], m[1], subs, hits.get(id))); } return out; } /** channels.list 1회(1 unit)로 이름/썸네일/구독자 수를 채운다. 실패하면 해당 키워드는 건너뛴다. */ private Map fetchChannelMeta(List channelIds) { Map out = new LinkedHashMap<>(); try { java.net.URI uri = UriComponentsBuilder.fromHttpUrl("https://www.googleapis.com/youtube/v3/channels") .queryParam("part", "snippet,statistics") .queryParam("id", String.join(",", channelIds)) .queryParam("key", youtubeApiKey) .build() .encode() .toUri(); JsonNode root = restTemplate.getForObject(uri, JsonNode.class); if (root == null) return out; for (JsonNode item : root.path("items")) { String id = item.path("id").asText(null); if (id == null) continue; JsonNode snippet = item.path("snippet"); String title = snippet.path("title").asText(""); String thumb = snippet.path("thumbnails").path("high").path("url") .asText(snippet.path("thumbnails").path("default").path("url").asText(null)); String subs = item.path("statistics").path("subscriberCount").asText(null); out.put(id, new String[]{title, thumb, subs}); } } catch (Exception e) { log.warn("[Seed] 채널 정보 조회 실패(무시)", e); } return out; } }