h-lab/src/main/java/com/hlab/yanalyst/domain/channel/SeedSuggestService.java
hehihoho3@gmail.com 2574174d22 fix: 시드 자동 발굴이 엉뚱한 채널을 잡던 문제 — 채널검색→영상검색 집계, 이중 인코딩 제거
실제 내 채널(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>
2026-08-01 09:04:16 +09:00

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