내 채널을 등록하면 그 영상이 수집함·칸반에 소재로 섞여 들어갔다. 이미 발행한 영상은 소재가 아니라 성과이므로 아예 분리한다. - ChannelRole.OWN 추가. OWN 채널 영상은 source=OWN 으로 저장되고, 수집함·칸반· 발굴 쿼리는 CHANNEL·SEARCH 화이트리스트라 자동으로 격리된다(쿼리 수정 불필요). /channels 목록에도 안 뜨고 피드에도 안 섞인다. - /my-channel 신설: 성과 요약(구독·총조회·영상평균·최근30일), 성장 추이 차트, 내 영상 목록. - 핵심은 소재별 성과 랭킹(TopicPerformance). 해시태그별 평균 조회수를 채널 평균 대비 배수로 보여준다. 실제 데이터로 유퀴즈 2.43x, 워크맨 0.30x 처럼 갈려서 피드에서 무엇을 자를지 판단하는 근거가 된다. 1편짜리 우연은 2편 미만 컷으로 제외. - 자동 수집·스냅샷 대상에 OWN 포함(findTrackedChannels). 해시태그 역분석의 원천도 MY → OWN 으로 바꿨다. 테스트 8건 추가(TopicPerformance 6, ChannelRole 2) — 총 84건 통과. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
247 lines
11 KiB
Java
247 lines
11 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.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) 채널 후보를 찾는다.
|
|
*
|
|
* <p>내 업로드의 해시태그(#유퀴즈 #핑계고 …)를 빈도순으로 집계한 뒤, 상위 키워드마다
|
|
* search.list(type=channel)로 공식채널을 조회해 {@link RecommendedChannel}(roleHint=SOURCE)로 쌓는다.
|
|
* 승인은 사람이 한다.
|
|
*
|
|
* <p>search.list 는 1회 100 units 라 스케줄이 아니라 <b>수동 실행</b> 전용이다.
|
|
*/
|
|
@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<String, Integer> analyzeKeywords() {
|
|
List<Channel> mine = channelRepository.findOwnChannels();
|
|
List<String> 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<String, Object> suggestSourceSeeds(List<String> keywords) {
|
|
List<String> 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<Candidate> 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<String, Object> 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) {}
|
|
|
|
/**
|
|
* 키워드로 <b>영상</b>을 검색해 채널별로 집계한다.
|
|
*
|
|
* <p>type=channel 검색은 채널 <i>이름</i>을 매칭하기 때문에 쓸 수 없다. 프로그램 클립은
|
|
* "유퀴즈"라는 이름의 채널이 아니라 tvN D ENT·뜬뜬 같은 제작사 채널에 올라오므로,
|
|
* 이름으로 찾으면 구독자 한 자릿수 짜리 동명 채널만 잡힌다.
|
|
* 대신 영상 검색 결과에서 같은 채널이 몇 번 나오는지를 세면 실제 제작사 채널이 드러난다.
|
|
*/
|
|
private List<Candidate> 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<String, Integer> 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<Map.Entry<String, Integer>> ranked = new ArrayList<>(hits.entrySet());
|
|
ranked.sort(Map.Entry.<String, Integer>comparingByValue().reversed());
|
|
|
|
List<String> picked = new ArrayList<>();
|
|
for (Map.Entry<String, Integer> 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<String, String[]> meta = fetchChannelMeta(picked); // channelId → [title, thumb, subs]
|
|
|
|
List<Candidate> 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<String, String[]> fetchChannelMeta(List<String> channelIds) {
|
|
Map<String, String[]> 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;
|
|
}
|
|
}
|