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:
parent
35944a8fd6
commit
e9b192eabb
@ -59,6 +59,7 @@ public class ChannelDiscoveryService {
|
|||||||
cond.setRegions(List.of(region));
|
cond.setRegions(List.of(region));
|
||||||
cond.setFormat("SHORTS");
|
cond.setFormat("SHORTS");
|
||||||
cond.setPeriodDays(periodDays);
|
cond.setPeriodDays(periodDays);
|
||||||
|
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) all.addAll(page.getItems());
|
||||||
|
|||||||
@ -59,7 +59,9 @@ public class YoutubeSearchService {
|
|||||||
List<JsonNode> allItems = new ArrayList<>();
|
List<JsonNode> allItems = new ArrayList<>();
|
||||||
|
|
||||||
for (String target : searchTargets) {
|
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)
|
UriComponentsBuilder builder = UriComponentsBuilder.fromHttpUrl(searchApiUrl)
|
||||||
.queryParam("part", "snippet")
|
.queryParam("part", "snippet")
|
||||||
|
|||||||
@ -52,6 +52,12 @@ public class WebController {
|
|||||||
return "discover";
|
return "discover";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GetMapping("/recommend")
|
||||||
|
public String recommend(Model model) {
|
||||||
|
model.addAttribute("currentPage", "recommend");
|
||||||
|
return "recommend";
|
||||||
|
}
|
||||||
|
|
||||||
@GetMapping("/publish")
|
@GetMapping("/publish")
|
||||||
public String publish(Model model) {
|
public String publish(Model model) {
|
||||||
model.addAttribute("currentPage", "publish");
|
model.addAttribute("currentPage", "publish");
|
||||||
|
|||||||
@ -11,4 +11,5 @@ public class YoutubeSearchCondition {
|
|||||||
private String format; // "SHORTS" or "LONG_FORM"
|
private String format; // "SHORTS" or "LONG_FORM"
|
||||||
private java.util.Map<String, String> pageTokens;
|
private java.util.Map<String, String> pageTokens;
|
||||||
private List<String> channelIds; // e.g. ["UC...", "UC..."]
|
private List<String> channelIds; // e.g. ["UC...", "UC..."]
|
||||||
|
private String order; // search.list order 강제(예: "viewCount"). 비우면 키워드 유무로 자동.
|
||||||
}
|
}
|
||||||
|
|||||||
@ -27,6 +27,9 @@
|
|||||||
<a th:href="@{/discover}" class="nav-item" th:classappend="${currentPage == 'discover'} ? 'active'">
|
<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>
|
<i data-lucide="radar" class="nav-icon"></i><span class="nav-text">발굴</span>
|
||||||
</a>
|
</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'">
|
<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>
|
<i data-lucide="users" class="nav-icon"></i><span class="nav-text">채널</span>
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
68
src/main/resources/templates/recommend.html
Normal file
68
src/main/resources/templates/recommend.html
Normal 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,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
||||||
|
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>
|
||||||
Loading…
Reference in New Issue
Block a user