실제 내 채널(clipOut-log) 데이터로 돌려보니 구독자 1~2명짜리 채널만 잡혀서 원인을 둘 찾았다. 1) type=channel 검색은 채널 '이름'을 매칭한다. 프로그램 클립은 "유퀴즈"라는 이름의 채널이 아니라 tvN D ENT·뜬뜬 같은 제작사 채널에 올라오므로 이름으로는 절대 찾을 수 없다. type=video 로 25건을 뽑아 채널별로 집계하고 3건 이상 등장한 채널만 채택하도록 바꿨다. 2) UriComponentsBuilder.encode().toUriString() 을 RestTemplate 에 String 으로 넘기면 URI 템플릿으로 보고 한 번 더 인코딩한다. 한글 키워드가 %25EC.. 로 깨져 검색어가 무의미해졌다. URI 오버로드로 교체. ChannelService.saveChannelFromUrl 도 한글 @핸들에서 같은 문제가 생기므로 함께 고쳤다. (YoutubeSearchService 등 기존 코드는 이미 올바르게 쓰고 있었다) 수정 후 8개 키워드 전부 정확한 공식채널을 찾는다: 유퀴즈→tvN D ENT/디글 클래식, 워크맨→워크맨-Workman, 살롱드립→TEO 테오, 짠한형 신동엽·미미미누·핫이슈지·입만열면→각 공식채널. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
240 lines
11 KiB
Java
240 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.findMyChannels();
|
|
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);
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|