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>
This commit is contained in:
hehihoho3@gmail.com 2026-08-01 09:04:16 +09:00
parent 592ed12c2b
commit 2574174d22
2 changed files with 65 additions and 30 deletions

View File

@ -67,7 +67,9 @@ public class ChannelService {
} }
try { try {
JsonNode root = restTemplate.getForObject(builder.toUriString(), JsonNode.class); // 한글 @핸들이 들어올 있으므로 URI 오버로드로 넘긴다.
// toUriString() 넘기면 RestTemplate URI 템플릿으로 보고 이중 인코딩한다.
JsonNode root = restTemplate.getForObject(builder.build().encode().toUri(), JsonNode.class);
JsonNode items = root.path("items"); JsonNode items = root.path("items");
if (items.isEmpty()) { if (items.isEmpty()) {
throw new IllegalArgumentException("Channel not found for identifier: " + identifier); throw new IllegalArgumentException("Channel not found for identifier: " + identifier);

View File

@ -32,8 +32,14 @@ public class SeedSuggestService {
/** search.list 1회 추정 쿼터(units). */ /** search.list 1회 추정 쿼터(units). */
private static final long SEARCH_QUOTA = 100; private static final long SEARCH_QUOTA = 100;
/** 키워드당 가져올 채널 후보 수. */ /** 키워드당 살펴볼 영상 수. 이 표본 안에서 채널 분포를 본다. */
private static final int PER_KEYWORD = 3; 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 ChannelRepository channelRepository;
private final ChannelVideoRepository channelVideoRepository; private final ChannelVideoRepository channelVideoRepository;
@ -143,63 +149,90 @@ public class SeedSuggestService {
return true; return true;
} }
private record Candidate(String channelId, String title, String thumbnailUrl, Long subscriberCount) {} private record Candidate(String channelId, String title, String thumbnailUrl, Long subscriberCount, int hits) {}
/** search.list(type=channel) + channels.list(구독자 수) 조회. */ /**
* 키워드로 <b>영상</b> 검색해 채널별로 집계한다.
*
* <p>type=channel 검색은 채널 <i>이름</i> 매칭하기 때문에 없다. 프로그램 클립은
* "유퀴즈"라는 이름의 채널이 아니라 tvN D ENT·뜬뜬 같은 제작사 채널에 올라오므로,
* 이름으로 찾으면 구독자 자릿수 짜리 동명 채널만 잡힌다.
* 대신 영상 검색 결과에서 같은 채널이 나오는지를 세면 실제 제작사 채널이 드러난다.
*/
private List<Candidate> searchChannels(String keyword) { private List<Candidate> searchChannels(String keyword) {
String searchUrl = UriComponentsBuilder.fromHttpUrl("https://www.googleapis.com/youtube/v3/search") // toUriString() 넘기면 RestTemplate URI 템플릿으로 보고 인코딩한다
// (한글 키워드가 %25EC.. 깨져 엉뚱한 채널이 잡힘). URI 오버로드로 넘겨야 한다.
java.net.URI searchUri = UriComponentsBuilder.fromHttpUrl("https://www.googleapis.com/youtube/v3/search")
.queryParam("part", "snippet") .queryParam("part", "snippet")
.queryParam("type", "channel") .queryParam("type", "video")
.queryParam("q", keyword) .queryParam("q", keyword)
.queryParam("regionCode", "KR") .queryParam("regionCode", "KR")
.queryParam("relevanceLanguage", "ko") .queryParam("relevanceLanguage", "ko")
.queryParam("maxResults", PER_KEYWORD) .queryParam("maxResults", SAMPLE_VIDEOS)
.queryParam("key", youtubeApiKey) .queryParam("key", youtubeApiKey)
.build()
.encode() .encode()
.toUriString(); .toUri();
JsonNode root = restTemplate.getForObject(searchUrl, JsonNode.class); JsonNode root = restTemplate.getForObject(searchUri, JsonNode.class);
if (root == null) return List.of(); if (root == null) return List.of();
Map<String, String[]> basics = new LinkedHashMap<>(); // channelId [title, thumbnailUrl] // channelId 표본 등장 횟수
Map<String, Integer> hits = new LinkedHashMap<>();
for (JsonNode item : root.path("items")) { for (JsonNode item : root.path("items")) {
String channelId = item.path("snippet").path("channelId").asText(null); String channelId = item.path("snippet").path("channelId").asText(null);
if (channelId == null || channelId.isBlank()) continue; if (channelId == null || channelId.isBlank()) continue;
String title = item.path("snippet").path("title").asText(""); hits.merge(channelId, 1, Integer::sum);
String thumb = item.path("snippet").path("thumbnails").path("high").path("url")
.asText(item.path("snippet").path("thumbnails").path("default").path("url").asText(null));
basics.put(channelId, new String[]{title, thumb});
} }
if (basics.isEmpty()) return List.of();
Map<String, Long> subs = fetchSubscriberCounts(basics.keySet()); 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<>(); List<Candidate> out = new ArrayList<>();
for (Map.Entry<String, String[]> e : basics.entrySet()) { for (String id : picked) {
out.add(new Candidate(e.getKey(), e.getValue()[0], e.getValue()[1], subs.get(e.getKey()))); 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; return out;
} }
/** channels.list 1회(1 unit)로 구독자 수를 채운다. 실패해도 후보 발굴 자체는 계속한다. */ /** channels.list 1회(1 unit)로 이름/썸네일/구독자 수를 채운다. 실패하면 해당 키워드는 건너뛴다. */
private Map<String, Long> fetchSubscriberCounts(Iterable<String> channelIds) { private Map<String, String[]> fetchChannelMeta(List<String> channelIds) {
Map<String, Long> out = new LinkedHashMap<>(); Map<String, String[]> out = new LinkedHashMap<>();
try { try {
String ids = String.join(",", channelIds); java.net.URI uri = UriComponentsBuilder.fromHttpUrl("https://www.googleapis.com/youtube/v3/channels")
String url = UriComponentsBuilder.fromHttpUrl("https://www.googleapis.com/youtube/v3/channels") .queryParam("part", "snippet,statistics")
.queryParam("part", "statistics") .queryParam("id", String.join(",", channelIds))
.queryParam("id", ids)
.queryParam("key", youtubeApiKey) .queryParam("key", youtubeApiKey)
.toUriString(); .build()
JsonNode root = restTemplate.getForObject(url, JsonNode.class); .encode()
.toUri();
JsonNode root = restTemplate.getForObject(uri, JsonNode.class);
if (root == null) return out; if (root == null) return out;
for (JsonNode item : root.path("items")) { for (JsonNode item : root.path("items")) {
String id = item.path("id").asText(null); 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); String subs = item.path("statistics").path("subscriberCount").asText(null);
if (id != null && subs != null) out.put(id, Long.parseLong(subs)); out.put(id, new String[]{title, thumb, subs});
} }
} catch (Exception e) { } catch (Exception e) {
log.warn("[Seed] 구독자 수 조회 실패(무시)", e); log.warn("[Seed] 채널 정보 조회 실패(무시)", e);
} }
return out; return out;
} }