feat(discover): 추천 채널 지역(KR/JP/US) 탭 + 발견 지역 태깅

- region 을 채널 국가가 아닌 '검색 발견 지역(regionCode)'으로 태깅(UNKNOWN 해소)
- upsert: 같은 배율이면 갱신(지역 등 메타 최신화)
- recommend.html: 전체/KR/JP/US 탭으로 필터, 등록/제외 시 목록 갱신

검증: 재발굴 후 KR14·JP7·US1 로 분포, 탭 필터 동작.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
hehihoho3@gmail.com 2026-06-26 11:34:43 +09:00
parent 3bfb7a7495
commit 08d50230c4
2 changed files with 47 additions and 24 deletions

View File

@ -62,7 +62,11 @@ public class ChannelDiscoveryService {
cond.setOrder("viewCount"); // 떡상 발굴: 최신순이 아니라 조회수순 cond.setOrder("viewCount"); // 떡상 발굴: 최신순이 아니라 조회수순
// 광범위 검색: 키워드 없이 인기 Shorts. 결과 빈약 검색 API 보강 필요(스펙 §4.2). // 광범위 검색: 키워드 없이 인기 Shorts. 결과 빈약 검색 API 보강 필요(스펙 §4.2).
YoutubeSearchPageDto page = youtubeSearchService.searchYoutubeVideos(cond); YoutubeSearchPageDto page = youtubeSearchService.searchYoutubeVideos(cond);
if (page != null && page.getItems() != null) all.addAll(page.getItems()); if (page != null && page.getItems() != null) {
// 발견 지역(검색 regionCode)으로 태깅 채널 국가가 아닌 '어느 지역 검색에서 떴나'
for (YoutubeSearchResultDto it : page.getItems()) it.setChannelCountry(region);
all.addAll(page.getItems());
}
searchedRegions.add(region); searchedRegions.add(region);
} catch (Exception e) { } catch (Exception e) {
log.error("[Discovery] 지역 {} 검색 실패 — 건너뜀", region, e); log.error("[Discovery] 지역 {} 검색 실패 — 건너뜀", region, e);
@ -83,8 +87,8 @@ public class ChannelDiscoveryService {
RecommendedChannel rc = recommendedChannelRepository.findByChannelId(c.channelId()) RecommendedChannel rc = recommendedChannelRepository.findByChannelId(c.channelId())
.orElseGet(RecommendedChannel::new); .orElseGet(RecommendedChannel::new);
if ("EXCLUDED".equals(rc.getStatus()) || "REGISTERED".equals(rc.getStatus())) continue; if ("EXCLUDED".equals(rc.getStatus()) || "REGISTERED".equals(rc.getStatus())) continue;
// 기존 NEW 항목이면 배율일 때만 갱신 // 기존 NEW 항목이면 배율일 때만 건너뜀(같거나 높으면 갱신 지역 메타 최신화)
if (rc.getId() != null && rc.getRatio() != null && c.ratio() <= rc.getRatio()) continue; if (rc.getId() != null && rc.getRatio() != null && c.ratio() < rc.getRatio()) continue;
rc.setChannelId(c.channelId()); rc.setChannelId(c.channelId());
rc.setChannelTitle(c.channelTitle()); rc.setChannelTitle(c.channelTitle());
rc.setThumbnailUrl(c.thumbnailUrl()); rc.setThumbnailUrl(c.thumbnailUrl());

View File

@ -15,6 +15,15 @@
</div> </div>
<div id="status" class="text-sm mb-3" style="display:none;"></div> <div id="status" class="text-sm mb-3" style="display:none;"></div>
<!-- 지역 탭 -->
<div class="flex gap-2 mb-3" style="flex-wrap:wrap;">
<button class="btn btn-primary px-3 py-2 rc-tab" data-region="" onclick="setTab(this)">전체</button>
<button class="btn btn-secondary px-3 py-2 rc-tab" data-region="KR" onclick="setTab(this)">🇰🇷 KR</button>
<button class="btn btn-secondary px-3 py-2 rc-tab" data-region="JP" onclick="setTab(this)">🇯🇵 JP</button>
<button class="btn btn-secondary px-3 py-2 rc-tab" data-region="US" onclick="setTab(this)">🇺🇸 US</button>
</div>
<div id="grid" style="display:grid; grid-template-columns:repeat(auto-fill,minmax(280px,1fr)); gap:1rem;"></div> <div id="grid" style="display:grid; grid-template-columns:repeat(auto-fill,minmax(280px,1fr)); gap:1rem;"></div>
<!-- 영상 미리보기 모달 (다른 페이지와 동일 패턴) --> <!-- 영상 미리보기 모달 (다른 페이지와 동일 패턴) -->
@ -43,29 +52,39 @@
function esc(s){ return (s==null?'':String(s)).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); } function esc(s){ return (s==null?'':String(s)).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
function fmt(n){ return (n==null)?'-':Number(n).toLocaleString(); } function fmt(n){ return (n==null)?'-':Number(n).toLocaleString(); }
let ALL_RECS = [];
let CUR_REGION = ''; // ''=전체
async function load(){ async function load(){
const grid=document.getElementById('grid'); try { ALL_RECS = await api(API); renderGrid(); }
try { catch(e){ document.getElementById('grid').innerHTML='<div style="color:#f87171;">불러오기 실패: '+esc(e.message)+'</div>'; }
const list=await api(API);
if(!list.length){ grid.innerHTML='<div class="text-muted">아직 발굴된 추천 채널이 없습니다. ‘지금 발굴’을 눌러보세요.</div>'; return; }
grid.innerHTML=list.map(c=>
'<div class="rc-card" id="rc-'+c.id+'">'
+ '<img class="rc-thumb" style="cursor:pointer;" src="'+esc(c.thumbnailUrl)+'" loading="lazy" data-vid="'+esc(c.topVideoId)+'" data-title="'+esc(c.topVideoTitle||c.channelTitle||'').replace(/"/g,'&quot;')+'" onclick="openVideoModal(this)">'
+ '<div class="rc-body">'
+ '<div class="font-bold" style="line-height:1.4;">'+esc(c.channelTitle)+'</div>'
+ '<div class="text-sm text-muted">👤 '+fmt(c.subscriberCount)+'명 · '+esc(c.region||'')+'</div>'
+ '<div class="mt-2"><span class="rc-ratio">🔥 배율 '+(c.ratio?Number(c.ratio).toFixed(1):'-')+'x</span> <span class="text-sm text-muted">👁 '+fmt(c.topVideoViewCount)+'</span></div>'
+ '<div class="text-sm text-muted mt-1" style="line-height:1.4;">'+esc(c.topVideoTitle||'')+'</div>'
+ '<div class="rc-actions">'
+ '<button class="btn btn-primary px-3 py-2" onclick="register('+c.id+')"><i data-lucide="user-plus" style="width:14px;"></i> 내 채널 등록</button>'
+ '<button class="btn btn-secondary px-3 py-2" onclick="exclude('+c.id+')"><i data-lucide="x" style="width:14px;"></i> 제외</button>'
+ '</div>'
+ '</div></div>').join('');
if(window.lucide) lucide.createIcons();
} catch(e){ grid.innerHTML='<div style="color:#f87171;">불러오기 실패: '+esc(e.message)+'</div>'; }
} }
async function register(id){ try{ await api(API+'/'+id+'/register',{method:'POST'}); document.getElementById('rc-'+id)?.remove(); }catch(e){ alert('등록 실패: '+e.message); } } function setTab(btn){
async function exclude(id){ try{ await api(API+'/'+id+'/exclude',{method:'POST'}); document.getElementById('rc-'+id)?.remove(); }catch(e){ alert('제외 실패: '+e.message); } } CUR_REGION = btn.dataset.region || '';
document.querySelectorAll('.rc-tab').forEach(b=>{ const on=(b===btn); b.classList.toggle('btn-primary',on); b.classList.toggle('btn-secondary',!on); });
renderGrid();
}
function renderGrid(){
const grid=document.getElementById('grid');
const list = CUR_REGION ? ALL_RECS.filter(c=>c.region===CUR_REGION) : ALL_RECS;
if(!list.length){ grid.innerHTML='<div class="text-muted">'+(CUR_REGION ? (CUR_REGION+' 지역 추천 채널이 없습니다.') : '아직 발굴된 추천 채널이 없습니다. ‘지금 발굴’을 눌러보세요.')+'</div>'; return; }
grid.innerHTML=list.map(c=>
'<div class="rc-card" id="rc-'+c.id+'">'
+ '<img class="rc-thumb" style="cursor:pointer;" src="'+esc(c.thumbnailUrl)+'" loading="lazy" data-vid="'+esc(c.topVideoId)+'" data-title="'+esc(c.topVideoTitle||c.channelTitle||'').replace(/"/g,'&quot;')+'" onclick="openVideoModal(this)">'
+ '<div class="rc-body">'
+ '<div class="font-bold" style="line-height:1.4;">'+esc(c.channelTitle)+'</div>'
+ '<div class="text-sm text-muted">👤 '+fmt(c.subscriberCount)+'명 · '+esc(c.region||'')+'</div>'
+ '<div class="mt-2"><span class="rc-ratio">🔥 배율 '+(c.ratio?Number(c.ratio).toFixed(1):'-')+'x</span> <span class="text-sm text-muted">👁 '+fmt(c.topVideoViewCount)+'</span></div>'
+ '<div class="text-sm text-muted mt-1" style="line-height:1.4;">'+esc(c.topVideoTitle||'')+'</div>'
+ '<div class="rc-actions">'
+ '<button class="btn btn-primary px-3 py-2" onclick="register('+c.id+')"><i data-lucide="user-plus" style="width:14px;"></i> 내 채널 등록</button>'
+ '<button class="btn btn-secondary px-3 py-2" onclick="exclude('+c.id+')"><i data-lucide="x" style="width:14px;"></i> 제외</button>'
+ '</div>'
+ '</div></div>').join('');
if(window.lucide) lucide.createIcons();
}
async function register(id){ try{ await api(API+'/'+id+'/register',{method:'POST'}); ALL_RECS=ALL_RECS.filter(c=>c.id!==id); renderGrid(); }catch(e){ alert('등록 실패: '+e.message); } }
async function exclude(id){ try{ await api(API+'/'+id+'/exclude',{method:'POST'}); ALL_RECS=ALL_RECS.filter(c=>c.id!==id); renderGrid(); }catch(e){ alert('제외 실패: '+e.message); } }
function openVideoModal(el){ function openVideoModal(el){
const vid = el.dataset.vid || ''; const vid = el.dataset.vid || '';
document.getElementById('modalTitle').textContent = el.dataset.title || ''; document.getElementById('modalTitle').textContent = el.dataset.title || '';