diff --git a/src/main/java/com/hlab/yanalyst/domain/channel/ChannelService.java b/src/main/java/com/hlab/yanalyst/domain/channel/ChannelService.java index a6b07c9..d662903 100644 --- a/src/main/java/com/hlab/yanalyst/domain/channel/ChannelService.java +++ b/src/main/java/com/hlab/yanalyst/domain/channel/ChannelService.java @@ -67,7 +67,9 @@ public class ChannelService { } 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"); if (items.isEmpty()) { throw new IllegalArgumentException("Channel not found for identifier: " + identifier); diff --git a/src/main/java/com/hlab/yanalyst/domain/channel/SeedSuggestService.java b/src/main/java/com/hlab/yanalyst/domain/channel/SeedSuggestService.java index eec45e8..0da37ad 100644 --- a/src/main/java/com/hlab/yanalyst/domain/channel/SeedSuggestService.java +++ b/src/main/java/com/hlab/yanalyst/domain/channel/SeedSuggestService.java @@ -32,8 +32,14 @@ public class SeedSuggestService { /** search.list 1회 추정 쿼터(units). */ 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 ChannelVideoRepository channelVideoRepository; @@ -143,63 +149,90 @@ public class SeedSuggestService { 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(구독자 수) 조회. */ + /** + * 키워드로 영상을 검색해 채널별로 집계한다. + * + *

type=channel 검색은 채널 이름을 매칭하기 때문에 쓸 수 없다. 프로그램 클립은 + * "유퀴즈"라는 이름의 채널이 아니라 tvN D ENT·뜬뜬 같은 제작사 채널에 올라오므로, + * 이름으로 찾으면 구독자 한 자릿수 짜리 동명 채널만 잡힌다. + * 대신 영상 검색 결과에서 같은 채널이 몇 번 나오는지를 세면 실제 제작사 채널이 드러난다. + */ private List 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("type", "channel") + .queryParam("type", "video") .queryParam("q", keyword) .queryParam("regionCode", "KR") .queryParam("relevanceLanguage", "ko") - .queryParam("maxResults", PER_KEYWORD) + .queryParam("maxResults", SAMPLE_VIDEOS) .queryParam("key", youtubeApiKey) + .build() .encode() - .toUriString(); + .toUri(); - JsonNode root = restTemplate.getForObject(searchUrl, JsonNode.class); + JsonNode root = restTemplate.getForObject(searchUri, JsonNode.class); if (root == null) return List.of(); - Map basics = new LinkedHashMap<>(); // channelId → [title, thumbnailUrl] + // 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; - String title = item.path("snippet").path("title").asText(""); - 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}); + hits.merge(channelId, 1, Integer::sum); } - if (basics.isEmpty()) return List.of(); - Map subs = fetchSubscriberCounts(basics.keySet()); + 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 (Map.Entry e : basics.entrySet()) { - out.add(new Candidate(e.getKey(), e.getValue()[0], e.getValue()[1], subs.get(e.getKey()))); + 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 fetchSubscriberCounts(Iterable channelIds) { - Map out = new LinkedHashMap<>(); + /** channels.list 1회(1 unit)로 이름/썸네일/구독자 수를 채운다. 실패하면 해당 키워드는 건너뛴다. */ + private Map fetchChannelMeta(List channelIds) { + Map out = new LinkedHashMap<>(); try { - String ids = String.join(",", channelIds); - String url = UriComponentsBuilder.fromHttpUrl("https://www.googleapis.com/youtube/v3/channels") - .queryParam("part", "statistics") - .queryParam("id", ids) + 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) - .toUriString(); - JsonNode root = restTemplate.getForObject(url, JsonNode.class); + .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); - if (id != null && subs != null) out.put(id, Long.parseLong(subs)); + out.put(id, new String[]{title, thumb, subs}); } } catch (Exception e) { - log.warn("[Seed] 구독자 수 조회 실패(무시)", e); + log.warn("[Seed] 채널 정보 조회 실패(무시)", e); } return out; }