feat: 발굴 포맷 옵션(롱폼/쇼츠) 추가 — 롱폼 기본

발굴 대상이 SHORTS 하드코딩이었으나, 롱폼 콘텐츠 대응을 위해 포맷을 선택 가능하게 함.

- ChannelDiscoveryService: runDiscovery(String format) 오버로드. format(SHORTS|LONG_FORM),
  null/미인식이면 설정 기본값(hlab.discovery.format, 기본 LONG_FORM) 사용. 검색 조건의
  하드코딩 SHORTS를 fmt로 교체(키워드 없는 검색·키워드 검색 양쪽).
- application.yml: hlab.discovery.format 추가(기본 LONG_FORM, DISCOVERY_FORMAT env로 오버라이드).
- RecommendedChannelController /run: format 쿼리파라미터 수용.
- recommend.html: '지금 발굴' 옆 롱폼/쇼츠 선택 드롭다운(롱폼 기본), 소제목 KR·롱폼 반영.

동일 파일에 있던 진행 중 기능(추천채널 대표영상 '영상 재가공' 버튼 + /{id}/rework 엔드포인트)도 함께 포함.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
hehihoho3@gmail.com 2026-07-23 16:32:28 +09:00
parent ab49e30a76
commit aecbdb4753
4 changed files with 63 additions and 9 deletions

View File

@ -19,7 +19,7 @@ import java.util.List;
import java.util.Map;
import java.util.Set;
/** 지역 인기 Shorts 검색 → 떡상 채널 발굴 → RecommendedChannel upsert. */
/** 지역 인기 영상(롱폼/쇼츠) 검색 → 떡상 채널 발굴 → RecommendedChannel upsert. */
@Slf4j
@Service
@RequiredArgsConstructor
@ -43,9 +43,24 @@ public class ChannelDiscoveryService {
private double minRatio;
@Value("${hlab.discovery.period-days:14}")
private int periodDays;
/** 발굴 대상 포맷 기본값: LONG_FORM(롱폼) | SHORTS. */
@Value("${hlab.discovery.format:LONG_FORM}")
private String defaultFormat;
/** 스케줄러 등 기본 호출 — 설정된 기본 포맷(hlab.discovery.format)으로 발굴. */
@Transactional
public Map<String, Object> runDiscovery() {
return runDiscovery(null);
}
/**
* 발굴 실행.
* @param format "SHORTS" 또는 "LONG_FORM". null/blank/미인식이면 설정 기본값(defaultFormat) 사용.
*/
@Transactional
public Map<String, Object> runDiscovery(String format) {
String fmt = (format == null || format.isBlank()) ? defaultFormat : format.trim().toUpperCase();
if (!"SHORTS".equals(fmt) && !"LONG_FORM".equals(fmt)) fmt = defaultFormat;
List<String> regions = Arrays.stream(regionsCsv.split(","))
.map(String::trim).filter(s -> !s.isBlank()).toList();
// 예능 특화 발굴용 키워드 시드. 비어있으면(설정 미지정) 추가 검색 없이 기존 동작과 동일.
@ -66,7 +81,7 @@ public class ChannelDiscoveryService {
try {
YoutubeSearchCondition cond = new YoutubeSearchCondition();
cond.setRegions(List.of(region));
cond.setFormat("SHORTS");
cond.setFormat(fmt);
cond.setPeriodDays(periodDays);
cond.setOrder("viewCount"); // 떡상 발굴: 최신순이 아니라 조회수순
// 광범위 검색: 키워드 없이 인기 Shorts. 결과 빈약 검색 API 보강 필요(스펙 §4.2).
@ -92,7 +107,7 @@ public class ChannelDiscoveryService {
YoutubeSearchCondition cond = new YoutubeSearchCondition();
cond.setKeyword(kw);
cond.setRegions(List.of(region));
cond.setFormat("SHORTS");
cond.setFormat(fmt);
cond.setPeriodDays(periodDays);
cond.setOrder("viewCount"); // 떡상 발굴: 조회수순
YoutubeSearchPageDto page = youtubeSearchService.searchYoutubeVideos(cond);
@ -138,6 +153,7 @@ public class ChannelDiscoveryService {
}
Map<String, Object> summary = new LinkedHashMap<>();
summary.put("format", fmt);
summary.put("regions", searchedRegions);
summary.put("keywords", keywords);
summary.put("keywordSearches", keywordSearches);

View File

@ -7,6 +7,7 @@ import lombok.RequiredArgsConstructor;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.*;
import java.math.BigDecimal;
import java.util.List;
import java.util.Map;
@ -19,6 +20,7 @@ public class RecommendedChannelController {
private final RecommendedChannelRepository repository;
private final ChannelDiscoveryService discoveryService;
private final ChannelService channelService;
private final ChannelVideoRepository channelVideoRepository;
@GetMapping
@Operation(summary = "추천 채널 목록", description = "status(NEW|EXCLUDED|REGISTERED, 기본 NEW)를 배율 내림차순으로 반환")
@ -60,9 +62,31 @@ public class RecommendedChannelController {
return ApiResponse.ok(null);
}
@PostMapping("/{id}/rework")
@Operation(summary = "대표 영상 재가공", description = "추천 채널의 대표(떡상) 영상을 수집함(ChannelVideo)에 적재하고 재가공 작업공간으로 보낼 id를 반환")
@Transactional
public ApiResponse<Map<String, Object>> rework(@PathVariable Long id) {
RecommendedChannel rc = repository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("추천 채널을 찾을 수 없습니다: " + id));
String videoId = rc.getTopVideoId();
if (videoId == null || videoId.isBlank()) {
throw new IllegalArgumentException("대표 영상이 없어 재가공할 수 없습니다: " + id);
}
ChannelVideo video = channelVideoRepository.findByVideoId(videoId)
.orElseGet(() -> {
BigDecimal ratio = rc.getRatio() == null ? null : BigDecimal.valueOf(rc.getRatio());
ChannelVideo v = ChannelVideo.fromSearch(
videoId, rc.getTopVideoTitle(), rc.getThumbnailUrl(), null,
rc.getTopVideoViewCount(), rc.getChannelId(), rc.getChannelTitle(),
rc.getSubscriberCount(), null, null, ratio, null);
return channelVideoRepository.save(v);
});
return ApiResponse.ok(Map.of("videoId", video.getId()));
}
@PostMapping("/run")
@Operation(summary = "수동 발굴 실행", description = "스케줄과 동일한 발굴을 즉시 1회 실행")
public ApiResponse<Map<String, Object>> run() {
return ApiResponse.ok(discoveryService.runDiscovery());
@Operation(summary = "수동 발굴 실행", description = "스케줄과 동일한 발굴을 즉시 1회 실행. format=LONG_FORM(기본)|SHORTS")
public ApiResponse<Map<String, Object>> run(@RequestParam(required = false) String format) {
return ApiResponse.ok(discoveryService.runDiscovery(format));
}
}

View File

@ -97,6 +97,8 @@ hlab:
# 예능 특화 시드 키워드(쉼표구분). 각 키워드×지역으로 Shorts 검색해 예능 클립 소재를 우선 발굴.
# 빈 값이면 키워드 없는 기존 '지역 인기 Shorts' 발굴로 동작.
keywords: ${DISCOVERY_KEYWORDS:예능,방송,하이라이트}
# 발굴 대상 포맷 기본값: LONG_FORM(롱폼) | SHORTS. UI '지금 발굴'에서 건별 선택 가능.
format: ${DISCOVERY_FORMAT:LONG_FORM}
# 텔레그램 아침 추천: 발굴 직후 상위 추천채널 다이제스트를 발송. 토큰/챗ID 없으면 자동 no-op.
notify:

View File

@ -7,10 +7,14 @@
<div class="page-header">
<div>
<h1>추천 채널</h1>
<p class="sub">지역(KR·JP·US) 인기 Shorts에서 자동 발굴한 떡상 채널 — 구독자는 적은데 조회수가 터진 채널</p>
<p class="sub">KR 인기 영상(롱폼/쇼츠)에서 자동 발굴한 떡상 채널 — 구독자는 적은데 조회수가 터진 채널</p>
</div>
<div class="actions">
<button class="btn btn-secondary" id="viewToggle" onclick="toggleView()"><i data-lucide="archive" style="width:15px;"></i> 제외 목록</button>
<select id="discFormat" class="btn btn-secondary" style="padding:0.4rem 0.7rem;" title="발굴 대상 포맷">
<option value="LONG_FORM" selected>롱폼</option>
<option value="SHORTS">쇼츠</option>
</select>
<button class="btn btn-secondary" onclick="runDiscovery()"><i data-lucide="refresh-cw" style="width:15px;"></i> 지금 발굴</button>
</div>
</div>
@ -91,6 +95,7 @@
const actions = excludedView
? '<button class="btn btn-primary px-3 py-2" onclick="restore('+c.id+')"><i data-lucide="rotate-ccw" style="width:14px;"></i> 복원</button>' + moveBtn
: '<button class="btn btn-primary px-3 py-2" onclick="register('+c.id+')"><i data-lucide="user-plus" style="width:14px;"></i> 내 채널 등록</button>' + moveBtn
+ '<button class="btn btn-secondary px-3 py-2" onclick="rework('+c.id+',this)"'+(c.topVideoId?'':' disabled')+'><i data-lucide="wand-2" 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>';
return '<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)">'
@ -107,6 +112,11 @@
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); } }
async function restore(id){ try{ await api(API+'/'+id+'/restore',{method:'POST'}); ALL_RECS=ALL_RECS.filter(c=>c.id!==id); renderGrid(); }catch(e){ alert('복원 실패: '+e.message); } }
async function rework(id, btn){
if(btn){ btn.disabled=true; btn.innerHTML='<i data-lucide="loader" style="width:14px;"></i> 준비 중…'; if(window.lucide) lucide.createIcons(); }
try{ const d=await api(API+'/'+id+'/rework',{method:'POST'}); location.href='/rework/'+d.videoId; }
catch(e){ alert('재가공 준비 실패: '+e.message); if(btn){ btn.disabled=false; btn.innerHTML='<i data-lucide="wand-2" style="width:14px;"></i> 영상 재가공'; if(window.lucide) lucide.createIcons(); } }
}
function openVideoModal(el){
const vid = el.dataset.vid || '';
document.getElementById('modalTitle').textContent = el.dataset.title || '';
@ -120,8 +130,10 @@
document.addEventListener('keydown', e => { if(e.key === 'Escape') closeVideoModal(); });
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(); }
const fmt=(document.getElementById('discFormat')||{}).value||'LONG_FORM';
const fmtKo=(fmt==='SHORTS')?'쇼츠':'롱폼';
const s=document.getElementById('status'); s.style.display='block'; s.style.color='#facc15'; s.textContent='발굴 중… ('+fmtKo+' 검색, 잠시 걸립니다)';
try{ const r=await api(API+'/run?format='+encodeURIComponent(fmt),{method:'POST'}); s.style.color='#4ade80'; s.textContent='발굴 완료 · '+((r.format==='SHORTS')?'쇼츠':'롱폼')+' · 신규 '+(r.saved??0)+'개 · 지역 '+((r.regions||[]).join(',')); await load(); }
catch(e){ s.style.color='#f87171'; s.textContent='발굴 실패: '+e.message; }
}
load();