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