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;
}
}