feat: 소재 피드 롱폼만 수집 — 경쟁 채널 쇼츠 수집 제거

피드 수집이 역할(SOURCE/RIVAL)과 무관하게 롱폼만 저장하도록 변경.
쇼츠는 이미 잘린 결과물이라 자를 원본(소재)이 아니므로 수집 대상에서 제외.
UI 문구(경쟁 쇼츠 → 경쟁 채널)와 길이 필터도 함께 정리.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
hehihoho3@gmail.com 2026-08-07 11:50:47 +09:00
parent aba3c5badc
commit f96e4c583d
5 changed files with 17 additions and 23 deletions

View File

@ -8,7 +8,7 @@ package com.hlab.yanalyst.domain.channel;
* <li>{@link #OWN} 내가 운영하는 채널. 이미 발행한 결과물이라 소재가 아니므로 * <li>{@link #OWN} 내가 운영하는 채널. 이미 발행한 결과물이라 소재가 아니므로
* 수집함·칸반·발굴·피드 어디에도 섞이지 않고 채널 분석 화면에서만 쓰인다.</li> * 수집함·칸반·발굴·피드 어디에도 섞이지 않고 채널 분석 화면에서만 쓰인다.</li>
* <li>{@link #SOURCE} 소재 원본 채널(웹예능 공식채널). 롱폼만 피드에 수집한다.</li> * <li>{@link #SOURCE} 소재 원본 채널(웹예능 공식채널). 롱폼만 피드에 수집한다.</li>
* <li>{@link #RIVAL} 경쟁 쇼츠 채널(같은 소재를 다루는 클립 채널). 쇼츠 피드에 수집한다.</li> * <li>{@link #RIVAL} 경쟁 채널(같은 소재를 다루는 클립 채널). 역시 롱폼 피드에 수집한다.</li>
* </ul> * </ul>
* *
* ddl-auto:update 컬럼이 추가되므로 기존 행은 role null 이다. null 항상 {@link #MY} 취급한다. * ddl-auto:update 컬럼이 추가되므로 기존 행은 role null 이다. null 항상 {@link #MY} 취급한다.
@ -45,9 +45,4 @@ public final class ChannelRole {
String r = normalize(role); String r = normalize(role);
return SOURCE.equals(r) || RIVAL.equals(r); return SOURCE.equals(r) || RIVAL.equals(r);
} }
/** 해당 역할이 수집할 영상 포맷 — SOURCE 는 롱폼만, RIVAL 은 쇼츠만. */
public static boolean acceptsShorts(String role) {
return RIVAL.equals(normalize(role));
}
} }

View File

@ -390,7 +390,7 @@ public class ChannelService {
/** /**
* 피드 시드 채널의 최근 업로드를 1페이지(최대 50건) 수집한다. * 피드 시드 채널의 최근 업로드를 1페이지(최대 50건) 수집한다.
* 소스 채널은 롱폼만, 경쟁 채널은 쇼츠만 저장한. * 역할과 무관하게 롱폼만 저장한다 쇼츠는 이미 잘린 결과물이라 소재가 아니.
* *
* @param channel SOURCE 또는 RIVAL 역할의 채널 * @param channel SOURCE 또는 RIVAL 역할의 채널
* @param publishedAfter 시각 이후 업로드만 수집 * @param publishedAfter 시각 이후 업로드만 수집
@ -422,10 +422,9 @@ public class ChannelService {
} }
if (videoIds.isEmpty()) return 0; if (videoIds.isEmpty()) return 0;
// SOURCE = 롱폼만(공식계정 쇼츠는 이미 잘린 결과물), RIVAL = 쇼츠만 // 피드는 롱폼만 수집한다 (쇼츠는 이미 잘린 결과물 자를 원본이 아니다)
Boolean shortsOnly = ChannelRole.acceptsShorts(role);
String source = ChannelRole.RIVAL.equals(role) ? ChannelRole.RIVAL : ChannelRole.SOURCE; String source = ChannelRole.RIVAL.equals(role) ? ChannelRole.RIVAL : ChannelRole.SOURCE;
return upsertVideos(channel, videoIds, source, shortsOnly, publishedAfter); return upsertVideos(channel, videoIds, source, Boolean.FALSE, publishedAfter);
} }

View File

@ -18,7 +18,7 @@ import java.util.Map;
/** /**
* 소재 발굴 피드 수집기. * 소재 발굴 피드 수집기.
* *
* <p>SOURCE(웹예능 공식채널) 신규 롱폼과 RIVAL(경쟁 예능짤 채널) 신규 쇼츠를 주기적으로 받아온다. * <p>SOURCE(웹예능 공식채널) RIVAL(경쟁 클립 채널) 신규 롱폼을 주기적으로 받아온다(쇼츠는 수집하지 않는다).
* uploads 플레이리스트 1페이지만 조회하므로 채널당 2 units 검색(search.list 100 units)보다 훨씬 싸다. * uploads 플레이리스트 1페이지만 조회하므로 채널당 2 units 검색(search.list 100 units)보다 훨씬 싸다.
*/ */
@Slf4j @Slf4j

View File

@ -100,7 +100,7 @@ hlab:
# 발굴 대상 포맷 기본값: LONG_FORM(롱폼) | SHORTS. UI '지금 발굴'에서 건별 선택 가능. # 발굴 대상 포맷 기본값: LONG_FORM(롱폼) | SHORTS. UI '지금 발굴'에서 건별 선택 가능.
format: ${DISCOVERY_FORMAT:LONG_FORM} format: ${DISCOVERY_FORMAT:LONG_FORM}
# 소재 발굴 피드: 소스(웹예능 공식채널) 신규 롱폼 + 경쟁(예능짤) 신규 쇼츠를 최신순으로 수집 # 소재 발굴 피드: 소스(웹예능 공식채널)·경쟁(클립 채널)의 신규 롱폼만 최신순으로 수집 (쇼츠 제외)
feed: feed:
enabled: ${FEED_ENABLED:true} enabled: ${FEED_ENABLED:true}
cron: ${FEED_CRON:0 15 */3 * * *} # 3시간마다 (소재 선점은 업로드 후 몇 시간이 승부) cron: ${FEED_CRON:0 15 */3 * * *} # 3시간마다 (소재 선점은 업로드 후 몇 시간이 승부)

View File

@ -11,7 +11,7 @@
<div class="page-header"> <div class="page-header">
<div> <div>
<h1>소재 피드</h1> <h1>소재 피드</h1>
<p class="sub">소재 원본(웹예능 롱폼)과 경쟁 쇼츠의 신규 업로드를 최신순으로 봅니다.</p> <p class="sub">소재 원본(웹예능)과 경쟁 채널의 신규 롱폼 업로드를 최신순으로 봅니다.</p>
</div> </div>
<div class="actions"> <div class="actions">
<button class="btn btn-secondary" onclick="openPersons()"> <button class="btn btn-secondary" onclick="openPersons()">
@ -37,7 +37,7 @@
<button class="feed-tab" role="tab" id="tab-RIVAL" data-tab="RIVAL" <button class="feed-tab" role="tab" id="tab-RIVAL" data-tab="RIVAL"
aria-selected="false" aria-controls="feedGrid" tabindex="-1" onclick="switchTab('RIVAL')"> aria-selected="false" aria-controls="feedGrid" tabindex="-1" onclick="switchTab('RIVAL')">
<i data-lucide="swords" style="width:15px;"></i> <i data-lucide="swords" style="width:15px;"></i>
<span>경쟁 쇼츠</span> <span>경쟁 채널</span>
<span class="feed-tab-hint">예능짤 채널</span> <span class="feed-tab-hint">예능짤 채널</span>
</button> </button>
<button class="feed-tab" role="tab" id="tab-PERSON" data-tab="PERSON" <button class="feed-tab" role="tab" id="tab-PERSON" data-tab="PERSON"
@ -101,8 +101,8 @@
</div> </div>
<div class="modal-body"> <div class="modal-body">
<p class="text-sm text-muted mb-3" style="line-height:1.6;"> <p class="text-sm text-muted mb-3" style="line-height:1.6;">
<b>소재 원본</b>은 유퀴즈·핑계고 같은 웹예능 공식채널(롱폼만 수집), <b>소재 원본</b>은 유퀴즈·핑계고 같은 웹예능 공식채널,
<b>경쟁 쇼츠</b>는 같은 소재를 다루는 예능짤 채널(쇼츠만 수집)입니다. <b>경쟁 채널</b>은 같은 소재를 다루는 예능짤 채널입니다. 두 역할 모두 롱폼만 수집합니다.
</p> </p>
<div class="seed-add"> <div class="seed-add">
@ -111,7 +111,7 @@
<label class="sr-only" for="seedRole">역할</label> <label class="sr-only" for="seedRole">역할</label>
<select id="seedRole"> <select id="seedRole">
<option value="SOURCE">소재 원본</option> <option value="SOURCE">소재 원본</option>
<option value="RIVAL">경쟁 쇼츠</option> <option value="RIVAL">경쟁 채널</option>
</select> </select>
<button class="btn btn-primary" id="seedAddBtn" onclick="addSeed()">추가</button> <button class="btn btn-primary" id="seedAddBtn" onclick="addSeed()">추가</button>
</div> </div>
@ -428,7 +428,7 @@
// ---------- 탭 ---------- // ---------- 탭 ----------
const TABS = ['SOURCE', 'RIVAL', 'PERSON']; const TABS = ['SOURCE', 'RIVAL', 'PERSON'];
// 탭별로 의미 있는 필터만 남긴다 (경쟁은 전부 쇼츠, 인물은 채널이 아니라 사람 기준) // 탭별로 의미 있는 필터만 남긴다 (소스·경쟁은 롱폼 채널 기준, 인물은 채널이 아니라 사람 기준)
function applyTabChrome(tab){ function applyTabChrome(tab){
document.querySelectorAll('.feed-tab').forEach(btn => { document.querySelectorAll('.feed-tab').forEach(btn => {
const on = btn.dataset.tab === tab; const on = btn.dataset.tab === tab;
@ -436,10 +436,10 @@
btn.tabIndex = on ? 0 : -1; btn.tabIndex = on ? 0 : -1;
}); });
document.getElementById('feedGrid').setAttribute('aria-labelledby', 'tab-' + tab); document.getElementById('feedGrid').setAttribute('aria-labelledby', 'tab-' + tab);
document.getElementById('lengthField').style.display = (tab === 'SOURCE') ? '' : 'none'; document.getElementById('lengthField').style.display = (tab === 'PERSON') ? 'none' : '';
document.getElementById('programField').style.display = (tab === 'PERSON') ? 'none' : ''; document.getElementById('programField').style.display = (tab === 'PERSON') ? 'none' : '';
document.getElementById('personField').style.display = (tab === 'PERSON') ? '' : 'none'; document.getElementById('personField').style.display = (tab === 'PERSON') ? '' : 'none';
if(tab !== 'SOURCE') document.getElementById('fLength').value = ''; if(tab === 'PERSON') document.getElementById('fLength').value = '';
} }
function switchTab(tab){ function switchTab(tab){
@ -572,7 +572,7 @@
? `<span>·</span><span class="num">${fmtNum(Math.round(it.viewsPerHour))}/h</span>` : ''; ? `<span>·</span><span class="num">${fmtNum(Math.round(it.viewsPerHour))}/h</span>` : '';
// 롱폼 소재(소스·인물)는 숏폼 큐로 넘기는 게 주 동선이다. // 롱폼 소재(소스·인물)는 숏폼 큐로 넘기는 게 주 동선이다.
// 경쟁 쇼츠는 이미 잘린 결과물이라 큐 대상이 아니고 원본 보기만 준다. // 경쟁 채널 영상은 경쟁자의 결과물(벤치마크)이라 큐 대상이 아니고 원본 보기만 준다.
const inQueue = queued.has(it.videoId); const inQueue = queued.has(it.videoId);
const primary = currentTab === 'RIVAL' const primary = currentTab === 'RIVAL'
? `<a class="btn btn-secondary grow" href="${url}" target="_blank" rel="noopener"> ? `<a class="btn btn-secondary grow" href="${url}" target="_blank" rel="noopener">
@ -645,7 +645,7 @@
<button class="btn btn-secondary" onclick="runCollect()">지금 수집</button>`); <button class="btn btn-secondary" onclick="runCollect()">지금 수집</button>`);
} else { } else {
showState('inbox', '아직 소재가 없습니다', showState('inbox', '아직 소재가 없습니다',
'경쟁 예능짤 쇼츠 채널을 시드로 등록한 뒤 수집하세요.', '경쟁 예능짤 채널을 시드로 등록한 뒤 수집하세요. (롱폼만 수집됩니다)',
`<button class="btn btn-primary" onclick="openSeeds()">시드 등록하기</button> `<button class="btn btn-primary" onclick="openSeeds()">시드 등록하기</button>
<button class="btn btn-secondary" onclick="runCollect()">지금 수집</button>`); <button class="btn btn-secondary" onclick="runCollect()">지금 수집</button>`);
} }
@ -810,7 +810,7 @@
return; return;
} }
box.innerHTML = list.map(s => { box.innerHTML = list.map(s => {
const roleLabel = s.role === 'SOURCE' ? '소재 원본' : '경쟁 쇼츠'; const roleLabel = s.role === 'SOURCE' ? '소재 원본' : '경쟁 채널';
const roleCls = s.role === 'SOURCE' ? 'badge-primary' : 'badge-muted'; const roleCls = s.role === 'SOURCE' ? 'badge-primary' : 'badge-muted';
const warn = s.disabled const warn = s.disabled
? `<button class="warn" title="수집 실패 ${s.failCount}회로 중단됨 — 다시 활성화" ? `<button class="warn" title="수집 실패 ${s.failCount}회로 중단됨 — 다시 활성화"