feat(discover): 추천 채널 전용 페이지 /recommend + 사이드바 + 발굴 조회수순 보강

- recommend.html(카드: 썸네일·구독자·배율·대표영상, 등록/제외) + WebController 라우트 + 사이드바 메뉴
- 발굴 검색 order=viewCount 강제(키워드 없을 때 최신순→조회수순) — 떡상 채널이 잡히게
  (YoutubeSearchCondition.order 추가, YoutubeSearchService 반영)

검증: /run → KR,JP,US 26개 떡상 채널 발굴(구독 76~7천, 배율 1500~9만x).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
hehihoho3@gmail.com 2026-06-26 10:49:42 +09:00
parent 35944a8fd6
commit e9b192eabb
6 changed files with 82 additions and 1 deletions

View File

@ -59,6 +59,7 @@ public class ChannelDiscoveryService {
cond.setRegions(List.of(region));
cond.setFormat("SHORTS");
cond.setPeriodDays(periodDays);
cond.setOrder("viewCount"); // 떡상 발굴: 최신순이 아니라 조회수순
// 광범위 검색: 키워드 없이 인기 Shorts. 결과 빈약 검색 API 보강 필요(스펙 §4.2).
YoutubeSearchPageDto page = youtubeSearchService.searchYoutubeVideos(cond);
if (page != null && page.getItems() != null) all.addAll(page.getItems());

View File

@ -59,7 +59,9 @@ public class YoutubeSearchService {
List<JsonNode> allItems = new ArrayList<>();
for (String target : searchTargets) {
String order = StringUtils.hasText(condition.getKeyword()) ? "relevance" : "date";
String order = StringUtils.hasText(condition.getOrder())
? condition.getOrder()
: (StringUtils.hasText(condition.getKeyword()) ? "relevance" : "date");
UriComponentsBuilder builder = UriComponentsBuilder.fromHttpUrl(searchApiUrl)
.queryParam("part", "snippet")

View File

@ -52,6 +52,12 @@ public class WebController {
return "discover";
}
@GetMapping("/recommend")
public String recommend(Model model) {
model.addAttribute("currentPage", "recommend");
return "recommend";
}
@GetMapping("/publish")
public String publish(Model model) {
model.addAttribute("currentPage", "publish");

View File

@ -11,4 +11,5 @@ public class YoutubeSearchCondition {
private String format; // "SHORTS" or "LONG_FORM"
private java.util.Map<String, String> pageTokens;
private List<String> channelIds; // e.g. ["UC...", "UC..."]
private String order; // search.list order 강제(: "viewCount"). 비우면 키워드 유무로 자동.
}

View File

@ -27,6 +27,9 @@
<a th:href="@{/discover}" class="nav-item" th:classappend="${currentPage == 'discover'} ? 'active'">
<i data-lucide="radar" class="nav-icon"></i><span class="nav-text">발굴</span>
</a>
<a th:href="@{/recommend}" class="nav-item" th:classappend="${currentPage == 'recommend'} ? 'active'">
<i data-lucide="sparkles" class="nav-icon"></i><span class="nav-text">추천 채널</span>
</a>
<a th:href="@{/channels}" class="nav-item" th:classappend="${currentPage == 'channels'} ? 'active'">
<i data-lucide="users" class="nav-icon"></i><span class="nav-text">채널</span>
</a>

View File

@ -0,0 +1,68 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{layout/base}">
<head><title>h-lab - 추천 채널</title></head>
<body>
<div layout:fragment="content">
<div class="page-header">
<div>
<h1>추천 채널</h1>
<p class="sub">지역(KR·JP·US) 인기 Shorts에서 자동 발굴한 떡상 채널 — 구독자는 적은데 조회수가 터진 채널</p>
</div>
<div class="actions">
<button class="btn btn-secondary" onclick="runDiscovery()"><i data-lucide="refresh-cw" style="width:15px;"></i> 지금 발굴</button>
</div>
</div>
<div id="status" class="text-sm mb-3" style="display:none;"></div>
<div id="grid" style="display:grid; grid-template-columns:repeat(auto-fill,minmax(280px,1fr)); gap:1rem;"></div>
<style>
.rc-card { background:var(--surface); border:1px solid var(--glass-border); border-radius:var(--radius-lg); overflow:hidden; }
.rc-thumb { width:100%; aspect-ratio:16/9; object-fit:cover; background:#000; display:block; }
.rc-body { padding:12px; }
.rc-ratio { display:inline-block; background:#ef4444; color:#fff; font-weight:800; font-size:0.8rem; padding:2px 8px; border-radius:999px; }
.rc-actions { display:flex; gap:8px; margin-top:10px; }
</style>
<script th:inline="javascript">
/*<![CDATA[*/
const API = '/api/recommended-channels';
async function api(url, opts){ const r=await fetch(url,opts); const j=await r.json().catch(()=>({})); if(!r.ok||(j&&j.success===false)) throw new Error((j&&j.message)||('HTTP '+r.status)); return j.data; }
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(); }
async function load(){
const grid=document.getElementById('grid');
try {
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+'">'
+ '<a href="https://www.youtube.com/watch?v='+esc(c.topVideoId)+'" target="_blank"><img class="rc-thumb" src="'+esc(c.thumbnailUrl)+'" loading="lazy"></a>'
+ '<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); } }
async function exclude(id){ try{ await api(API+'/'+id+'/exclude',{method:'POST'}); document.getElementById('rc-'+id)?.remove(); }catch(e){ alert('제외 실패: '+e.message); } }
async function runDiscovery(){
const s=document.getElementById('status'); s.style.display='block'; s.style.color='#facc15'; s.textContent='발굴 중… (지역별 Shorts 검색, 잠시 걸립니다)';
try{ const r=await api(API+'/run',{method:'POST'}); s.style.color='#4ade80'; s.textContent='발굴 완료 · 신규 '+(r.saved??0)+'개 · 지역 '+((r.regions||[]).join(',')); await load(); }
catch(e){ s.style.color='#f87171'; s.textContent='발굴 실패: '+e.message; }
}
load();
/*]]>*/
</script>
</div>
</body>
</html>