h-lab/src/main/resources/templates/feed.html
hehihoho3@gmail.com f96e4c583d feat: 소재 피드 롱폼만 수집 — 경쟁 채널 쇼츠 수집 제거
피드 수집이 역할(SOURCE/RIVAL)과 무관하게 롱폼만 저장하도록 변경.
쇼츠는 이미 잘린 결과물이라 자를 원본(소재)이 아니므로 수집 대상에서 제외.
UI 문구(경쟁 쇼츠 → 경쟁 채널)와 길이 필터도 함께 정리.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 11:50:47 +09:00

1030 lines
56 KiB
HTML

<!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">소재 원본(웹예능)과 경쟁 채널의 신규 롱폼 업로드를 최신순으로 봅니다.</p>
</div>
<div class="actions">
<button class="btn btn-secondary" onclick="openPersons()">
<i data-lucide="user-search" style="width:15px;"></i> 인물 관리
</button>
<button class="btn btn-secondary" onclick="openSeeds()">
<i data-lucide="list-plus" style="width:15px;"></i> 시드 관리
</button>
<button class="btn btn-primary" id="collectBtn" onclick="runCollect()">
<i data-lucide="refresh-cw" style="width:15px;"></i> 지금 수집
</button>
</div>
</div>
<!---->
<div class="feed-tabs" role="tablist" aria-label="피드 종류">
<button class="feed-tab" role="tab" id="tab-SOURCE" data-tab="SOURCE"
aria-selected="true" aria-controls="feedGrid" onclick="switchTab('SOURCE')">
<i data-lucide="film" style="width:15px;"></i>
<span>소재 원본</span>
<span class="feed-tab-hint">웹예능 롱폼</span>
</button>
<button class="feed-tab" role="tab" id="tab-RIVAL" data-tab="RIVAL"
aria-selected="false" aria-controls="feedGrid" tabindex="-1" onclick="switchTab('RIVAL')">
<i data-lucide="swords" style="width:15px;"></i>
<span>경쟁 채널</span>
<span class="feed-tab-hint">예능짤 채널</span>
</button>
<button class="feed-tab" role="tab" id="tab-PERSON" data-tab="PERSON"
aria-selected="false" aria-controls="feedGrid" tabindex="-1" onclick="switchTab('PERSON')">
<i data-lucide="user-search" style="width:15px;"></i>
<span>인물</span>
<span class="feed-tab-hint">출연자 추적</span>
</button>
</div>
<!-- 필터 -->
<div class="card mb-4">
<div class="toolbar">
<label class="field" for="fProgram" id="programField">프로그램
<select id="fProgram" onchange="applyFilters()">
<option value="">전체</option>
</select>
</label>
<label class="field" for="fPerson" id="personField" style="display:none;">인물
<select id="fPerson" onchange="applyFilters()">
<option value="">전체</option>
</select>
</label>
<label class="field" for="fLength" id="lengthField">길이
<select id="fLength" onchange="applyFilters()">
<option value="">전체</option>
<option value="CLIP">공식클립 (~15분)</option>
<option value="FULL">풀에피 (15분+)</option>
</select>
</label>
<label class="field" for="fDays">기간
<select id="fDays" onchange="applyFilters()">
<option value="1">최근 24시간</option>
<option value="3">최근 3일</option>
<option value="7" selected>최근 7일</option>
<option value="14">최근 14일</option>
<option value="0">전체</option>
</select>
</label>
<label class="chip">
<input type="checkbox" id="fHideWorked" onchange="applyFilters()">
<i data-lucide="eye-off"></i> 작업한 것 숨기기
</label>
<span class="spacer"></span>
<span id="resultCount" class="badge badge-muted" aria-live="polite"></span>
</div>
</div>
<!-- 결과 -->
<div id="feedGrid" class="feed-grid" role="tabpanel" aria-labelledby="tab-SOURCE"></div>
<div id="feedState" class="feed-state hidden"></div>
<!-- 시드 관리 모달 -->
<div id="seedModal" class="modal-overlay" onclick="if(event.target===this) closeSeeds()">
<div class="modal-card" role="dialog" aria-modal="true" aria-labelledby="seedModalTitle">
<div class="modal-head">
<h3 id="seedModalTitle">시드 관리</h3>
<button class="modal-close" onclick="closeSeeds()" aria-label="닫기">&times;</button>
</div>
<div class="modal-body">
<p class="text-sm text-muted mb-3" style="line-height:1.6;">
<b>소재 원본</b>은 유퀴즈·핑계고 같은 웹예능 공식채널,
<b>경쟁 채널</b>은 같은 소재를 다루는 예능짤 채널입니다. 두 역할 모두 롱폼만 수집합니다.
</p>
<div class="seed-add">
<label class="sr-only" for="seedUrl">채널 URL</label>
<input id="seedUrl" type="text" placeholder="https://www.youtube.com/@channel">
<label class="sr-only" for="seedRole">역할</label>
<select id="seedRole">
<option value="SOURCE">소재 원본</option>
<option value="RIVAL">경쟁 채널</option>
</select>
<button class="btn btn-primary" id="seedAddBtn" onclick="addSeed()">추가</button>
</div>
<p class="text-xs text-muted mt-2" id="seedAddHelp">
채널 URL 또는 @핸들 주소를 붙여넣으세요.
</p>
<div class="seed-divider"></div>
<div class="flex items-center justify-between mb-2">
<span class="text-sm font-semibold">등록된 시드</span>
<button class="btn btn-secondary" style="padding:0.35rem 0.6rem;" onclick="suggestSeeds()"
title="내 채널 해시태그를 역분석해 소재 원본 후보를 찾습니다 (키워드당 100 units)">
<i data-lucide="wand-sparkles" style="width:14px;"></i> 후보 자동 발굴
</button>
</div>
<div id="seedList" class="seed-list"><div class="text-sm text-muted p-4">로딩 중...</div></div>
</div>
</div>
</div>
<!-- 인물 관리 모달 -->
<div id="personModal" class="modal-overlay" onclick="if(event.target===this) closePersons()">
<div class="modal-card" role="dialog" aria-modal="true" aria-labelledby="personModalTitle">
<div class="modal-head">
<h3 id="personModalTitle">인물 관리</h3>
<button class="modal-close" onclick="closePersons()" aria-label="닫기">&times;</button>
</div>
<div class="modal-body">
<p class="text-sm text-muted mb-3" style="line-height:1.6;">
인물은 프로그램을 옮겨 다닙니다. 채널 시드로는 못 잡는 등장분을 이름으로 검색해 찾습니다.
<b>1명당 검색 101 units</b> 라 하루 1회만 돌고, 활성 인물에 상한이 있습니다.
</p>
<div class="seed-add">
<label class="sr-only" for="personName">인물 이름</label>
<input id="personName" type="text" placeholder="예: 윤경호">
<button class="btn btn-primary" id="personAddBtn" onclick="addPerson()">추가</button>
</div>
<p class="text-xs text-muted mt-2" id="personAddHelp">
내 채널 성과가 좋은 인물부터 넣으세요.
</p>
<div class="seed-divider"></div>
<div class="flex items-center justify-between mb-2">
<span class="text-sm font-semibold">추적 중인 인물</span>
<button class="btn btn-secondary" style="padding:0.35rem 0.6rem;" id="personCollectBtn"
onclick="collectPersons()" title="활성 인물 전체를 지금 수집합니다">
<i data-lucide="search" style="width:14px;"></i> 지금 수집
</button>
</div>
<div id="personList" class="seed-list"><div class="text-sm text-muted p-4">로딩 중...</div></div>
</div>
</div>
</div>
<!-- 영상 미리보기 -->
<div id="videoModal" class="modal-overlay" onclick="if(event.target===this) closeVideo()">
<div class="modal-card modal-wide" role="dialog" aria-modal="true" aria-labelledby="vmTitle">
<div class="modal-head">
<h3 id="vmTitle" class="truncate" style="padding-right:0.5rem;">영상</h3>
<button class="modal-close" id="vmClose" onclick="closeVideo()" aria-label="닫기">&times;</button>
</div>
<div class="modal-body">
<div id="vmFrame" class="vm-frame"></div>
<p class="text-xs text-muted mt-2" id="vmMeta"></p>
<div class="vm-actions" id="vmActions"></div>
</div>
</div>
</div>
<!-- 토스트 -->
<div id="toast" class="toast" role="status" aria-live="polite"></div>
<style>
/* ===== 탭 ===== */
.feed-tabs { display:flex; gap:0.4rem; margin-bottom:1rem; border-bottom:1px solid var(--border); }
.feed-tab {
display:inline-flex; align-items:center; gap:0.45rem;
padding:0.7rem 1rem; min-height:44px;
background:transparent; border:none; border-bottom:2px solid transparent;
color:var(--text-2); font-size:0.9rem; font-weight:600; font-family:inherit;
cursor:pointer; transition:color .18s ease, border-color .18s ease;
}
.feed-tab:hover { color:var(--text); }
.feed-tab[aria-selected="true"] { color:var(--accent); border-bottom-color:var(--accent); }
.feed-tab:focus-visible { outline:2px solid var(--accent); outline-offset:-2px; border-radius:var(--r-sm); }
.feed-tab-hint { font-size:0.72rem; font-weight:500; color:var(--text-3); }
.feed-tab[aria-selected="true"] .feed-tab-hint { color:var(--accent); opacity:.75; }
/* ===== 카드 그리드 ===== */
.feed-grid { display:grid; grid-template-columns:repeat(auto-fill, minmax(268px, 1fr)); gap:1rem; }
@media (max-width: 520px) { .feed-grid { grid-template-columns:1fr; } }
.fcard {
display:flex; flex-direction:column;
background:var(--surface); border:1px solid var(--border); border-radius:var(--r);
overflow:hidden; transition:border-color .18s ease, box-shadow .18s ease;
}
.fcard:hover { border-color:var(--border-strong); box-shadow:var(--shadow); }
.fcard.is-worked { opacity:.55; }
.fcard.is-worked:hover { opacity:1; }
/* 썸네일·제목은 버튼이지만 링크처럼 보여야 하므로 기본 버튼 스타일을 지운다 */
.fthumb {
position:relative; display:block; background:var(--inset); aspect-ratio:16/9;
width:100%; padding:0; border:0; cursor:pointer;
}
.fthumb img { width:100%; height:100%; object-fit:cover; display:block; }
.fthumb:focus-visible { outline:2px solid var(--accent); outline-offset:-2px; }
.fdur {
position:absolute; right:6px; bottom:6px;
background:rgba(0,0,0,.78); color:#fff;
font-size:0.7rem; font-weight:600; font-family:var(--font-mono);
padding:1px 5px; border-radius:4px; line-height:1.5;
}
.fgolden {
position:absolute; left:6px; top:6px;
display:inline-flex; align-items:center; gap:3px;
background:var(--accent); color:#fff;
font-size:0.68rem; font-weight:700; padding:2px 6px; border-radius:4px; line-height:1.5;
}
.fgolden svg { width:11px; height:11px; }
/* 외부 재생 표시 — 눌렀을 때 YouTube 로 나간다는 예고 */
.fext {
position:absolute; right:6px; top:6px;
display:inline-flex; align-items:center; justify-content:center;
width:22px; height:22px; border-radius:5px;
background:rgba(0,0,0,.72); color:#fff;
}
.fext svg { width:12px; height:12px; }
.fbody { padding:0.7rem 0.8rem 0.55rem; display:flex; flex-direction:column; gap:0.35rem; flex:1; }
.fmeta { display:flex; align-items:center; gap:0.35rem; font-size:0.72rem; color:var(--text-3); min-width:0; }
.fmeta .fprog { color:var(--text-2); font-weight:600; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.ftitle {
font-size:0.87rem; font-weight:600; line-height:1.4; color:var(--text);
display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical; overflow:hidden;
width:100%; padding:0; border:0; background:none; text-align:left;
font-family:inherit; cursor:pointer;
}
.ftitle:hover { text-decoration:underline; }
.ftitle:focus-visible { outline:2px solid var(--accent); outline-offset:2px; border-radius:2px; }
.fstats { display:flex; align-items:center; gap:0.4rem; flex-wrap:wrap; font-size:0.72rem; color:var(--text-3); }
.fstats .num { font-family:var(--font-mono); font-variant-numeric:tabular-nums; }
.factions {
display:flex; align-items:center; gap:0.4rem;
padding:0.5rem 0.8rem 0.7rem;
}
.factions .btn { padding:0.5rem 0.7rem; min-height:38px; font-size:0.78rem; }
.factions .icon-btn {
display:inline-flex; align-items:center; justify-content:center;
width:38px; height:38px; flex-shrink:0;
background:var(--surface); border:1px solid var(--border-strong); border-radius:var(--r-sm);
color:var(--text-2); cursor:pointer; transition:all .18s ease;
}
.factions .icon-btn:hover { background:var(--surface-2); color:var(--text); }
.factions .icon-btn.on { color:var(--warning); border-color:var(--warning); }
.factions .grow { flex:1; }
/* ===== 상태(로딩/빈/에러) ===== */
.feed-state { padding:3rem 1rem; text-align:center; color:var(--text-2); }
.feed-state .fs-ic {
width:44px; height:44px; margin:0 auto 0.9rem; border-radius:12px;
background:var(--surface-2); color:var(--text-3);
display:flex; align-items:center; justify-content:center;
}
.feed-state .fs-t { font-weight:600; color:var(--text); margin-bottom:0.35rem; }
.feed-state .fs-d { font-size:0.85rem; line-height:1.6; }
.feed-state .fs-a { margin-top:1rem; display:flex; gap:0.5rem; justify-content:center; flex-wrap:wrap; }
.skel-card { border:1px solid var(--border); border-radius:var(--r); overflow:hidden; background:var(--surface); }
.skel-card .sk-thumb { aspect-ratio:16/9; }
.skel-card .sk-line { height:11px; margin:0.6rem 0.8rem; border-radius:4px; }
.skel-card .sk-line.short { width:45%; }
/* ===== 시드 모달 ===== */
.seed-add { display:flex; gap:0.5rem; flex-wrap:wrap; }
.seed-add input { flex:1; min-width:170px; }
.seed-divider { height:1px; background:var(--border); margin:1.1rem 0 0.9rem; }
.seed-list { display:flex; flex-direction:column; gap:0.45rem; max-height:340px; overflow-y:auto; }
.seed-row {
display:flex; align-items:center; gap:0.6rem;
padding:0.55rem 0.7rem; border:1px solid var(--border); border-radius:var(--r-sm); background:var(--surface);
}
.seed-row img { width:32px; height:32px; border-radius:50%; object-fit:cover; flex-shrink:0; background:var(--inset); }
.seed-row .sname { flex:1; min-width:0; }
.seed-row .sname b { display:block; font-size:0.83rem; font-weight:600; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.seed-row .sname span { font-size:0.72rem; color:var(--text-3); }
.seed-row button {
display:inline-flex; align-items:center; justify-content:center;
width:34px; height:34px; flex-shrink:0;
background:transparent; border:1px solid var(--border); border-radius:var(--r-sm);
color:var(--text-3); cursor:pointer; transition:all .18s ease;
}
.seed-row button:hover { color:var(--danger); border-color:var(--danger); }
.seed-row button.warn:hover { color:var(--warning); border-color:var(--warning); }
/* ===== 토스트 ===== */
.toast {
position:fixed; left:50%; bottom:24px; transform:translate(-50%, 12px);
background:var(--text); color:var(--bg);
padding:0.65rem 1.1rem; border-radius:var(--r-sm); font-size:0.85rem; font-weight:600;
box-shadow:var(--shadow-lg); z-index:2000;
opacity:0; pointer-events:none; transition:opacity .2s ease, transform .2s ease;
max-width:88vw; text-align:center;
}
.toast.show { opacity:1; transform:translate(-50%, 0); }
.toast.err { background:var(--danger); color:#fff; }
.sr-only {
position:absolute; width:1px; height:1px; padding:0; margin:-1px;
overflow:hidden; clip:rect(0,0,0,0); white-space:nowrap; border:0;
}
/* ===== 영상 미리보기 ===== */
.modal-wide { max-width:900px; }
.vm-frame {
width:100%; aspect-ratio:16/9; background:#000;
border-radius:var(--r-sm); overflow:hidden;
}
/* 쇼츠는 세로라 화면을 다 먹지 않게 높이를 묶는다 */
.vm-frame.portrait { aspect-ratio:9/16; max-height:70vh; width:auto; margin:0 auto; }
.vm-frame iframe { width:100%; height:100%; border:0; display:block; }
.vm-actions { display:flex; gap:0.5rem; flex-wrap:wrap; margin-top:0.8rem; }
.vm-actions .btn { min-height:40px; }
@media (prefers-reduced-motion: reduce) {
.fcard, .feed-tab, .toast, .factions .icon-btn, .seed-row button { transition:none; }
}
</style>
<script th:inline="javascript">
/*<![CDATA[*/
const API = '/api/feed';
const CV_API = '/api/v1/channel-videos';
const SF_API = '/api/shortform';
let currentTab = 'SOURCE';
let items = [];
/** 숏폼 큐에 이미 담긴 videoId — 중복 등록을 막고 카드에 표시한다. */
let queued = new Set();
// ---------- 공통 ----------
function esc(s){ return (s==null?'':String(s)).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
function fmtNum(n){
if(n==null) return '-';
const v = Number(n);
if(v >= 100000000) return (v/100000000).toFixed(1).replace(/\.0$/,'') + '억';
if(v >= 10000) return (v/10000).toFixed(1).replace(/\.0$/,'') + '만';
return v.toLocaleString();
}
function fmtDur(sec){
if(sec==null || sec<=0) return '';
const h = Math.floor(sec/3600), m = Math.floor((sec%3600)/60), s = sec%60;
const pad = v => String(v).padStart(2,'0');
return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${m}:${pad(s)}`;
}
function fmtAgo(iso){
if(!iso) return '-';
const then = new Date(iso).getTime();
if(isNaN(then)) return '-';
const mins = Math.max(0, Math.floor((Date.now() - then) / 60000));
if(mins < 60) return `${mins}분 전`;
const hrs = Math.floor(mins/60);
if(hrs < 24) return `${hrs}시간 전`;
const days = Math.floor(hrs/24);
if(days < 30) return `${days}일 전`;
return new Date(then).toLocaleDateString('ko-KR');
}
let toastTimer = null;
function toast(msg, isError){
const el = document.getElementById('toast');
el.textContent = msg;
el.classList.toggle('err', !!isError);
el.classList.add('show');
clearTimeout(toastTimer);
toastTimer = setTimeout(()=> el.classList.remove('show'), 3500);
}
async function api(url, opts){
const res = await fetch(url, opts);
const json = await res.json().catch(()=>({}));
if(!res.ok || (json && json.success===false)) throw new Error((json && json.message) || ('HTTP '+res.status));
return json.data;
}
// ---------- 상태 표시 ----------
function showState(icon, title, desc, actionsHtml){
document.getElementById('feedGrid').innerHTML = '';
const el = document.getElementById('feedState');
el.classList.remove('hidden');
el.innerHTML = `<div class="fs-ic"><i data-lucide="${icon}"></i></div>
<div class="fs-t">${esc(title)}</div>
<div class="fs-d">${desc}</div>
${actionsHtml ? `<div class="fs-a">${actionsHtml}</div>` : ''}`;
if(window.lucide) lucide.createIcons();
}
function hideState(){ document.getElementById('feedState').classList.add('hidden'); }
function showSkeleton(){
hideState();
const one = `<div class="skel-card">
<div class="sk-thumb skeleton"></div>
<div class="sk-line skeleton"></div>
<div class="sk-line short skeleton"></div>
</div>`;
document.getElementById('feedGrid').innerHTML = one.repeat(8);
}
// ---------- 탭 ----------
const TABS = ['SOURCE', 'RIVAL', 'PERSON'];
// 탭별로 의미 있는 필터만 남긴다 (소스·경쟁은 롱폼 채널 기준, 인물은 채널이 아니라 사람 기준)
function applyTabChrome(tab){
document.querySelectorAll('.feed-tab').forEach(btn => {
const on = btn.dataset.tab === tab;
btn.setAttribute('aria-selected', on ? 'true' : 'false');
btn.tabIndex = on ? 0 : -1;
});
document.getElementById('feedGrid').setAttribute('aria-labelledby', 'tab-' + tab);
document.getElementById('lengthField').style.display = (tab === 'PERSON') ? 'none' : '';
document.getElementById('programField').style.display = (tab === 'PERSON') ? 'none' : '';
document.getElementById('personField').style.display = (tab === 'PERSON') ? '' : 'none';
if(tab === 'PERSON') document.getElementById('fLength').value = '';
}
function switchTab(tab){
if(tab === currentTab) return;
currentTab = tab;
applyTabChrome(tab);
// 탭마다 필터 목록이 다르므로 선택을 비우고 다시 채운다
document.getElementById('fProgram').value = '';
document.getElementById('fPerson').value = '';
loadFilterOptions().then(applyFilters);
}
document.querySelector('.feed-tabs').addEventListener('keydown', e => {
if(e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return;
e.preventDefault();
const i = TABS.indexOf(currentTab);
const next = TABS[(i + (e.key === 'ArrowRight' ? 1 : TABS.length - 1)) % TABS.length];
switchTab(next);
document.getElementById('tab-' + next).focus();
});
// ---------- 필터 ----------
function readFilters(){
return {
tab: currentTab,
channelId: document.getElementById('fProgram').value,
person: document.getElementById('fPerson').value,
lengthBucket: document.getElementById('fLength').value,
days: document.getElementById('fDays').value,
hideWorked: document.getElementById('fHideWorked').checked
};
}
function applyFilters(){
const f = readFilters();
// 새로고침·뒤로가기에서 필터가 보존되도록 URL 에 반영
const q = new URLSearchParams();
q.set('tab', f.tab);
if(f.tab === 'PERSON'){ if(f.person) q.set('person', f.person); }
else if(f.channelId) q.set('channelId', f.channelId);
if(f.lengthBucket) q.set('lengthBucket', f.lengthBucket);
if(f.days !== '7') q.set('days', f.days);
if(f.hideWorked) q.set('hideWorked', 'true');
history.replaceState(null, '', location.pathname + (q.toString() ? '?'+q.toString() : ''));
loadFeed();
}
function restoreFilters(){
const q = new URLSearchParams(location.search);
const tab = q.get('tab');
if(TABS.includes(tab) && tab !== 'SOURCE') currentTab = tab;
applyTabChrome(currentTab);
if(q.get('days') != null) document.getElementById('fDays').value = q.get('days');
if(q.get('lengthBucket')) document.getElementById('fLength').value = q.get('lengthBucket');
if(q.get('hideWorked') === 'true') document.getElementById('fHideWorked').checked = true;
// 목록을 로드한 뒤 적용해야 하므로 값만 돌려준다
return { channelId: q.get('channelId'), person: q.get('person') };
}
// ---------- 필터 목록(프로그램 / 인물) ----------
async function loadFilterOptions(preselect){
if(currentTab === 'PERSON') return loadPersonOptions(preselect && preselect.person);
return loadPrograms(preselect && preselect.channelId);
}
async function loadPrograms(preselect){
const sel = document.getElementById('fProgram');
const keep = preselect || sel.value;
let list = [];
try { list = await api(`${API}/programs?tab=${currentTab}`) || []; } catch(e){ list = []; }
sel.innerHTML = '<option value="">전체</option>' + list.map(p =>
`<option value="${esc(p.ytChannelId)}">${esc(p.channelTitle||'(이름 없음)')} (${p.videoCount})</option>`).join('');
if(keep && [...sel.options].some(o => o.value === keep)) sel.value = keep;
}
async function loadPersonOptions(preselect){
const sel = document.getElementById('fPerson');
const keep = preselect || sel.value;
let list = [];
try { list = await api(API + '/persons') || []; } catch(e){ list = []; }
sel.innerHTML = '<option value="">전체</option>' + list.map(p =>
`<option value="${esc(p.name)}">${esc(p.name)} (${p.videoCount})</option>`).join('');
if(keep && [...sel.options].some(o => o.value === keep)) sel.value = keep;
}
// ---------- 피드 ----------
async function loadFeed(){
showSkeleton();
const f = readFilters();
const p = new URLSearchParams();
p.set('tab', f.tab);
if(f.tab === 'PERSON'){ if(f.person) p.set('person', f.person); }
else if(f.channelId) p.set('channelId', f.channelId);
if(f.lengthBucket) p.set('lengthBucket', f.lengthBucket);
if(f.days && f.days !== '0') p.set('days', f.days);
if(f.hideWorked) p.set('hideWorked', 'true');
try {
items = await api(API + '?' + p.toString()) || [];
} catch(e){
document.getElementById('resultCount').textContent = '—';
showState('alert-triangle', '불러오지 못했습니다', esc(e.message),
'<button class="btn btn-secondary" onclick="loadFeed()">다시 시도</button>');
return;
}
render();
}
function badgeHtml(it){
const out = [];
if(currentTab === 'RIVAL'){
if(it.rising) out.push('<span class="badge badge-warning"><i data-lucide="trending-up" style="width:11px;"></i>떡상중</span>');
} else {
// 인물 탭에서는 누구 때문에 걸렸는지가 제일 중요한 정보다
if(currentTab === 'PERSON' && it.matchedPerson)
out.push(`<span class="badge badge-primary">${esc(it.matchedPerson)}</span>`);
if(it.lengthBucket === 'CLIP') out.push('<span class="badge badge-muted">공식클립</span>');
else if(it.lengthBucket === 'FULL') out.push('<span class="badge badge-muted">풀에피</span>');
}
if(it.worked) out.push(`<span class="badge badge-success">작업함</span>`);
return out.join('');
}
function cardHtml(it){
const url = 'https://www.youtube.com/watch?v=' + encodeURIComponent(it.videoId);
const dur = fmtDur(it.durationSec);
const golden = it.goldenTime
? '<span class="fgolden"><i data-lucide="zap"></i>골든타임</span>' : '';
const vph = (currentTab === 'RIVAL' && it.viewsPerHour != null)
? `<span>·</span><span class="num">${fmtNum(Math.round(it.viewsPerHour))}/h</span>` : '';
// 롱폼 소재(소스·인물)는 숏폼 큐로 넘기는 게 주 동선이다.
// 경쟁 채널 영상은 경쟁자의 결과물(벤치마크)이라 큐 대상이 아니고 원본 보기만 준다.
const inQueue = queued.has(it.videoId);
const primary = currentTab === 'RIVAL'
? `<a class="btn btn-secondary grow" href="${url}" target="_blank" rel="noopener">
<i data-lucide="external-link" style="width:14px;"></i> 원본 보기</a>`
: (inQueue
? `<a class="btn btn-secondary grow" href="/shortform">
<i data-lucide="check" style="width:14px;"></i> 큐에 있음</a>`
: `<button class="btn btn-primary grow" id="sfBtn-${it.id}"
onclick="sendToQueue(${it.id})">
<i data-lucide="clapperboard" style="width:14px;"></i> 숏폼 큐로</button>`);
// 임베드 차단 영상은 눌렀을 때 YouTube 로 나가므로 미리 알려준다
const blocked = it.embeddable === false;
const playHint = blocked
? `<span class="fext" title="이 채널은 외부 재생을 막아둬서 YouTube 로 열립니다">
<i data-lucide="external-link"></i></span>`
: '';
return `<article class="fcard${it.worked ? ' is-worked' : ''}" data-id="${it.id}">
<button class="fthumb" type="button" onclick="openVideo(${it.id})"
aria-label="${esc(it.title)}${blocked ? 'YouTube 에서 열기' : '여기서 재생'}">
<img src="${esc(it.thumbnailUrl)}" alt="" loading="lazy" width="480" height="270">
${golden}
${playHint}
${dur ? `<span class="fdur">${dur}</span>` : ''}
</button>
<div class="fbody">
<div class="fmeta">
<span class="fprog">${esc(it.channelTitle || '-')}</span>
<span>·</span>
<span>${fmtAgo(it.publishedAt)}</span>
</div>
<button class="ftitle" type="button" onclick="openVideo(${it.id})">${esc(it.title)}</button>
<div class="fstats">
<span class="num">${fmtNum(it.viewCount)}회</span>${vph}
${badgeHtml(it)}
</div>
</div>
<div class="factions">
${primary}
${currentTab === 'RIVAL' ? '' : `
<a class="icon-btn" href="/rework/${it.id}" title="재가공 스튜디오"
aria-label="재가공 스튜디오에서 열기">
<i data-lucide="wand-2" style="width:16px;"></i>
</a>`}
<button class="icon-btn${it.bookmarked ? ' on' : ''}" title="북마크"
aria-label="북마크" aria-pressed="${it.bookmarked}"
onclick="toggleBookmark(${it.id}, ${!!it.bookmarked})">
<i data-lucide="star" style="width:16px;"></i>
</button>
<button class="icon-btn" title="숨김" aria-label="이 소재 숨기기"
onclick="hideItem(${it.id})">
<i data-lucide="eye-off" style="width:16px;"></i>
</button>
</div>
</article>`;
}
function render(){
document.getElementById('resultCount').textContent = items.length + '건';
if(items.length === 0){
if(currentTab === 'PERSON'){
showState('user-search', '추적 중인 인물이 없습니다',
'인물은 프로그램을 옮겨 다녀서 채널 시드로는 못 잡습니다. 이름을 등록하면 등장한 롱폼을 찾아옵니다.',
`<button class="btn btn-primary" onclick="openPersons()">인물 등록하기</button>`);
} else if(currentTab === 'SOURCE'){
showState('inbox', '아직 소재가 없습니다',
'소재 원본 채널(유퀴즈·핑계고 같은 웹예능 공식채널)을 시드로 등록한 뒤 수집하세요.',
`<button class="btn btn-primary" onclick="openSeeds()">시드 등록하기</button>
<button class="btn btn-secondary" onclick="runCollect()">지금 수집</button>`);
} else {
showState('inbox', '아직 소재가 없습니다',
'경쟁 예능짤 채널을 시드로 등록한 뒤 수집하세요. (롱폼만 수집됩니다)',
`<button class="btn btn-primary" onclick="openSeeds()">시드 등록하기</button>
<button class="btn btn-secondary" onclick="runCollect()">지금 수집</button>`);
}
return;
}
hideState();
document.getElementById('feedGrid').innerHTML = items.map(cardHtml).join('');
if(window.lucide) lucide.createIcons();
}
// ---------- 영상 미리보기 ----------
let lastFocused = null;
function openVideo(id){
const it = items.find(v => v.id === id);
if(!it) return;
const url = 'https://www.youtube.com/watch?v=' + encodeURIComponent(it.videoId);
// 임베드를 막아둔 채널이 많다(실측 9건 중 4건). 깨진 플레이어를 보여주느니
// 바로 YouTube 로 보낸다. embeddable 이 null 이면 아직 모르니 일단 시도한다.
if(it.embeddable === false){
window.open(url, '_blank', 'noopener');
return;
}
lastFocused = document.activeElement;
document.getElementById('vmTitle').textContent = it.title;
// 쇼츠는 세로, 나머지는 가로
const frame = document.getElementById('vmFrame');
frame.classList.toggle('portrait', it.lengthBucket === 'SHORTS');
frame.innerHTML = `<iframe src="https://www.youtube.com/embed/${encodeURIComponent(it.videoId)}?autoplay=1&rel=0"
title="${esc(it.title)}" allow="autoplay; encrypted-media; picture-in-picture; fullscreen"
allowfullscreen></iframe>`;
const dur = fmtDur(it.durationSec);
document.getElementById('vmMeta').textContent =
`${it.channelTitle || '-'} · ${fmtAgo(it.publishedAt)} · ${fmtNum(it.viewCount)}${dur ? ' · ' + dur : ''}`;
// 임베드가 막힌 영상이 있어 YouTube 로 여는 길을 항상 열어둔다
const inQueue = queued.has(it.videoId);
const queueBtn = currentTab === 'RIVAL' ? '' : (inQueue
? `<a class="btn btn-secondary" href="/shortform">
<i data-lucide="check" style="width:14px;"></i> 큐에 있음</a>`
: `<button class="btn btn-primary" onclick="sendToQueue(${it.id}); closeVideo();">
<i data-lucide="clapperboard" style="width:14px;"></i> 숏폼 큐로</button>`);
document.getElementById('vmActions').innerHTML = `
${queueBtn}
<a class="btn btn-secondary" href="${url}" target="_blank" rel="noopener">
<i data-lucide="external-link" style="width:14px;"></i> YouTube 에서 열기</a>`;
document.getElementById('videoModal').classList.add('open');
if(window.lucide) lucide.createIcons();
document.getElementById('vmClose').focus();
}
function closeVideo(){
const modal = document.getElementById('videoModal');
if(!modal.classList.contains('open')) return;
modal.classList.remove('open');
document.getElementById('vmFrame').innerHTML = ''; // 재생 중지
if(lastFocused && lastFocused.focus) lastFocused.focus();
lastFocused = null;
}
// ---------- 숏폼 큐 ----------
/** 큐에 이미 담긴 videoId 를 받아둔다. 실패해도 피드 자체는 보여야 하므로 조용히 넘긴다. */
async function loadQueued(){
try {
const jobs = await api(SF_API + '/jobs') || [];
queued = new Set(jobs.map(j => j.videoId).filter(Boolean));
} catch(e){ /* 큐를 못 읽어도 피드는 정상 동작 */ }
}
async function sendToQueue(id){
const it = items.find(v => v.id === id);
if(!it) return;
const btn = document.getElementById('sfBtn-' + id);
if(btn){
btn.disabled = true;
btn.innerHTML = '<i data-lucide="loader-2" style="width:14px;" class="animate-spin"></i> 보내는 중...';
if(window.lucide) lucide.createIcons();
}
try {
await api(SF_API + '/jobs', {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({ youtubeUrl: 'https://www.youtube.com/watch?v=' + it.videoId })
});
queued.add(it.videoId);
render();
toast('숏폼 큐에 담았습니다 — 숏폼 큐에서 Opal 실행하세요');
} catch(e){
toast('숏폼 큐 등록 실패: ' + e.message, true);
render();
}
}
// ---------- 카드 액션 ----------
async function toggleBookmark(id, current){
try {
await api(`${CV_API}/${id}/bookmark`, {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({ bookmarked: !current })
});
const it = items.find(v => v.id === id);
if(it) it.bookmarked = !current;
render();
toast(!current ? '북마크에 담았습니다' : '북마크를 해제했습니다');
} catch(e){ toast('북마크 실패: ' + e.message, true); }
}
async function hideItem(id){
try {
await api(`${CV_API}/${id}/status`, {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({ status:'EXCLUDED' })
});
items = items.filter(v => v.id !== id);
render();
toast('숨겼습니다 — 되돌리려면 수집함에서 상태를 바꾸세요');
} catch(e){ toast('숨김 실패: ' + e.message, true); }
}
// ---------- 수집 ----------
async function runCollect(){
const btn = document.getElementById('collectBtn');
btn.disabled = true;
const original = btn.innerHTML;
btn.innerHTML = '<i data-lucide="loader-2" style="width:15px;" class="animate-spin"></i> 수집 중...';
if(window.lucide) lucide.createIcons();
try {
const r = await api(API + '/collect', { method:'POST' });
toast(`수집 완료 — 시드 ${r.seeds}개 중 ${r.collected}개, 영상 ${r.savedVideos}`);
await loadPrograms();
await loadFeed();
} catch(e){
toast('수집 실패: ' + e.message, true);
} finally {
btn.disabled = false;
btn.innerHTML = original;
if(window.lucide) lucide.createIcons();
}
}
// ---------- 시드 관리 ----------
function openSeeds(){
document.getElementById('seedModal').classList.add('open');
loadSeeds();
setTimeout(()=> document.getElementById('seedUrl').focus(), 50);
}
function closeSeeds(){ document.getElementById('seedModal').classList.remove('open'); }
async function loadSeeds(){
const box = document.getElementById('seedList');
box.innerHTML = '<div class="text-sm text-muted p-4">로딩 중...</div>';
let list = [];
try { list = await api(API + '/seeds') || []; }
catch(e){ box.innerHTML = `<div class="text-sm text-danger p-4">불러오기 실패: ${esc(e.message)}</div>`; return; }
if(list.length === 0){
box.innerHTML = '<div class="text-sm text-muted p-4">등록된 시드가 없습니다. 위에서 채널 URL 을 추가하세요.</div>';
return;
}
box.innerHTML = list.map(s => {
const roleLabel = s.role === 'SOURCE' ? '소재 원본' : '경쟁 채널';
const roleCls = s.role === 'SOURCE' ? 'badge-primary' : 'badge-muted';
const warn = s.disabled
? `<button class="warn" title="수집 실패 ${s.failCount}회로 중단됨 — 다시 활성화"
aria-label="시드 다시 활성화" onclick="resetSeed(${s.id})">
<i data-lucide="rotate-ccw" style="width:15px;"></i></button>`
: '';
return `<div class="seed-row">
<img src="${esc(s.thumbnailUrl)}" alt="" loading="lazy">
<div class="sname">
<b>${esc(s.title)}</b>
<span>구독 ${fmtNum(s.subscriberCount)}${s.disabled ? ' · 수집 중단됨' : ''}</span>
</div>
<span class="badge ${roleCls}">${roleLabel}</span>
${warn}
<button title="시드 해제" aria-label="시드 해제" onclick="removeSeed(${s.id}, '${esc(s.title)}')">
<i data-lucide="trash-2" style="width:15px;"></i></button>
</div>`;
}).join('');
if(window.lucide) lucide.createIcons();
}
async function addSeed(){
const url = document.getElementById('seedUrl').value.trim();
const help = document.getElementById('seedAddHelp');
if(!url){
help.textContent = '채널 URL 을 입력하세요.';
help.style.color = 'var(--danger)';
document.getElementById('seedUrl').focus();
return;
}
help.textContent = '채널 URL 또는 @핸들 주소를 붙여넣으세요.';
help.style.color = '';
const btn = document.getElementById('seedAddBtn');
btn.disabled = true;
try {
const role = document.getElementById('seedRole').value;
const s = await api(API + '/seeds', {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({ url, role })
});
document.getElementById('seedUrl').value = '';
toast(`${s.title} 을(를) 시드로 등록했습니다`);
await loadSeeds();
} catch(e){
help.textContent = '등록 실패: ' + e.message;
help.style.color = 'var(--danger)';
} finally { btn.disabled = false; }
}
async function removeSeed(id, title){
if(!confirm(`"${title}" 시드를 해제할까요?\n이 채널에서 수집한 피드 영상도 함께 삭제됩니다.`)) return;
try {
await api(`${API}/seeds/${id}`, { method:'DELETE' });
toast('시드를 해제했습니다');
await loadSeeds();
await loadPrograms();
await loadFeed();
} catch(e){ toast('해제 실패: ' + e.message, true); }
}
async function resetSeed(id){
try {
await api(`${API}/seeds/${id}/reset-failure`, { method:'POST' });
toast('다시 활성화했습니다');
await loadSeeds();
} catch(e){ toast('실패: ' + e.message, true); }
}
async function suggestSeeds(){
// 내 채널이 등록돼 있으면 해시태그를 역분석하고, 없으면 키워드를 직접 받는다.
let detected = {};
try { detected = await api(API + '/seeds/keywords') || {}; } catch(e){ detected = {}; }
const tags = Object.keys(detected);
let keywords = null;
if(tags.length > 0){
const preview = tags.slice(0, 8).map(t => `#${t}(${detected[t]})`).join(' ');
if(!confirm(`내 채널에서 찾은 키워드로 소재 원본 채널을 검색합니다.\n\n${preview}\n\n키워드 1개당 쿼터 100 units. 진행할까요?`)) return;
} else {
const input = prompt('내 채널이 등록돼 있지 않아 자동 역분석을 못 합니다.\n프로그램 이름을 쉼표로 구분해 입력하세요. (키워드당 쿼터 100 units)',
'유퀴즈, 핑계고, 살롱드립');
if(input == null) return;
keywords = input.split(',').map(s => s.trim()).filter(Boolean);
if(keywords.length === 0) return;
}
try {
const r = await api(API + '/seeds/suggest', {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify(keywords ? { keywords } : {})
});
toast(`후보 ${r.saved}개를 추천 채널에 담았습니다 — 추천 채널 화면에서 승인하세요`);
} catch(e){ toast('발굴 실패: ' + e.message, true); }
}
// ---------- 인물 관리 ----------
function openPersons(){
document.getElementById('personModal').classList.add('open');
loadPersonList();
setTimeout(()=> document.getElementById('personName').focus(), 50);
}
function closePersons(){ document.getElementById('personModal').classList.remove('open'); }
async function loadPersonList(){
const box = document.getElementById('personList');
box.innerHTML = '<div class="text-sm text-muted p-4">로딩 중...</div>';
let list = [];
try { list = await api(API + '/persons') || []; }
catch(e){ box.innerHTML = `<div class="text-sm text-danger p-4">불러오기 실패: ${esc(e.message)}</div>`; return; }
if(list.length === 0){
box.innerHTML = '<div class="text-sm text-muted p-4">추적 중인 인물이 없습니다. 위에서 이름을 추가하세요.</div>';
return;
}
box.innerHTML = list.map(p => {
const last = p.lastCollectedAt
? `${fmtAgo(p.lastCollectedAt)} 수집 · ${p.lastFound ?? 0}`
: '아직 수집 전';
return `<div class="seed-row">
<div class="sname">
<b>${esc(p.name)}</b>
<span>${p.videoCount}건 담김 · ${last}</span>
</div>
<span class="badge ${p.enabled ? 'badge-primary' : 'badge-muted'}">${p.enabled ? '추적중' : '중지'}</span>
<button class="warn" title="${p.enabled ? '추적 중지' : '추적 재개'}"
aria-label="${p.enabled ? '추적 중지' : '추적 재개'}"
onclick="togglePerson(${p.id}, ${!p.enabled})">
<i data-lucide="${p.enabled ? 'pause' : 'play'}" style="width:15px;"></i></button>
<button title="추적 해제" aria-label="추적 해제" onclick="removePerson(${p.id}, '${esc(p.name)}')">
<i data-lucide="trash-2" style="width:15px;"></i></button>
</div>`;
}).join('');
if(window.lucide) lucide.createIcons();
}
async function addPerson(){
const name = document.getElementById('personName').value.trim();
const help = document.getElementById('personAddHelp');
if(!name){
help.textContent = '인물 이름을 입력하세요.';
help.style.color = 'var(--danger)';
document.getElementById('personName').focus();
return;
}
help.textContent = '내 채널 성과가 좋은 인물부터 넣으세요.';
help.style.color = '';
const btn = document.getElementById('personAddBtn');
btn.disabled = true;
try {
await api(API + '/persons', {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({ name })
});
document.getElementById('personName').value = '';
toast(`${name} 추적을 시작합니다`);
await loadPersonList();
} catch(e){
help.textContent = e.message;
help.style.color = 'var(--danger)';
} finally { btn.disabled = false; }
}
async function togglePerson(id, enabled){
try {
await api(`${API}/persons/${id}/toggle?enabled=${enabled}`, { method:'POST' });
await loadPersonList();
} catch(e){ toast(e.message, true); }
}
async function removePerson(id, name){
if(!confirm(`"${name}" 추적을 해제할까요?\n이미 담긴 영상은 그대로 남습니다.`)) return;
try {
await api(`${API}/persons/${id}`, { method:'DELETE' });
toast('추적을 해제했습니다');
await loadPersonList();
} catch(e){ toast('해제 실패: ' + e.message, true); }
}
async function collectPersons(){
if(!confirm('활성 인물 전체를 지금 수집합니다.\n1명당 YouTube 쿼터 101 units 를 씁니다. 진행할까요?')) return;
const btn = document.getElementById('personCollectBtn');
btn.disabled = true;
const orig = btn.innerHTML;
btn.innerHTML = '<i data-lucide="loader-2" style="width:14px;" class="animate-spin"></i> 수집 중...';
if(window.lucide) lucide.createIcons();
try {
const r = await api(API + '/persons/collect', { method:'POST' });
toast(`인물 ${r.searched}명에서 롱폼 ${r.savedVideos}건을 담았습니다`);
await loadPersonList();
if(currentTab === 'PERSON'){ await loadPersonOptions(); await loadFeed(); }
} catch(e){
toast('수집 실패: ' + e.message, true);
} finally {
btn.disabled = false; btn.innerHTML = orig;
if(window.lucide) lucide.createIcons();
}
}
document.addEventListener('keydown', e => {
if(e.key === 'Escape'){ closeVideo(); closeSeeds(); closePersons(); }
});
// ---------- init ----------
(async () => {
const preselect = restoreFilters();
await Promise.all([loadFilterOptions(preselect), loadQueued()]);
await loadFeed();
})();
/*]]>*/
</script>
</div>
</body>
</html>