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:
parent
592ed12c2b
commit
2574174d22
@ -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);
|
||||
|
||||
@ -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(구독자 수) 조회. */
|
||||
/**
|
||||
* 키워드로 <b>영상</b>을 검색해 채널별로 집계한다.
|
||||
*
|
||||
* <p>type=channel 검색은 채널 <i>이름</i>을 매칭하기 때문에 쓸 수 없다. 프로그램 클립은
|
||||
* "유퀴즈"라는 이름의 채널이 아니라 tvN D ENT·뜬뜬 같은 제작사 채널에 올라오므로,
|
||||
* 이름으로 찾으면 구독자 한 자릿수 짜리 동명 채널만 잡힌다.
|
||||
* 대신 영상 검색 결과에서 같은 채널이 몇 번 나오는지를 세면 실제 제작사 채널이 드러난다.
|
||||
*/
|
||||
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("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<String, String[]> basics = new LinkedHashMap<>(); // channelId → [title, thumbnailUrl]
|
||||
// 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;
|
||||
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<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<>();
|
||||
for (Map.Entry<String, String[]> 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<String, Long> fetchSubscriberCounts(Iterable<String> channelIds) {
|
||||
Map<String, Long> out = new LinkedHashMap<>();
|
||||
/** channels.list 1회(1 unit)로 이름/썸네일/구독자 수를 채운다. 실패하면 해당 키워드는 건너뛴다. */
|
||||
private Map<String, String[]> fetchChannelMeta(List<String> channelIds) {
|
||||
Map<String, String[]> 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;
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user