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:
parent
ab49e30a76
commit
aecbdb4753
@ -19,7 +19,7 @@ import java.util.List;
|
|||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
|
||||||
/** 지역 인기 Shorts 검색 → 떡상 채널 발굴 → RecommendedChannel upsert. */
|
/** 지역 인기 영상(롱폼/쇼츠) 검색 → 떡상 채널 발굴 → RecommendedChannel upsert. */
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@Service
|
@Service
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@ -43,9 +43,24 @@ public class ChannelDiscoveryService {
|
|||||||
private double minRatio;
|
private double minRatio;
|
||||||
@Value("${hlab.discovery.period-days:14}")
|
@Value("${hlab.discovery.period-days:14}")
|
||||||
private int periodDays;
|
private int periodDays;
|
||||||
|
/** 발굴 대상 포맷 기본값: LONG_FORM(롱폼) | SHORTS. */
|
||||||
|
@Value("${hlab.discovery.format:LONG_FORM}")
|
||||||
|
private String defaultFormat;
|
||||||
|
|
||||||
|
/** 스케줄러 등 기본 호출 — 설정된 기본 포맷(hlab.discovery.format)으로 발굴. */
|
||||||
@Transactional
|
@Transactional
|
||||||
public Map<String, Object> runDiscovery() {
|
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(","))
|
List<String> regions = Arrays.stream(regionsCsv.split(","))
|
||||||
.map(String::trim).filter(s -> !s.isBlank()).toList();
|
.map(String::trim).filter(s -> !s.isBlank()).toList();
|
||||||
// 예능 특화 발굴용 키워드 시드. 비어있으면(설정 미지정) 추가 검색 없이 기존 동작과 동일.
|
// 예능 특화 발굴용 키워드 시드. 비어있으면(설정 미지정) 추가 검색 없이 기존 동작과 동일.
|
||||||
@ -66,7 +81,7 @@ public class ChannelDiscoveryService {
|
|||||||
try {
|
try {
|
||||||
YoutubeSearchCondition cond = new YoutubeSearchCondition();
|
YoutubeSearchCondition cond = new YoutubeSearchCondition();
|
||||||
cond.setRegions(List.of(region));
|
cond.setRegions(List.of(region));
|
||||||
cond.setFormat("SHORTS");
|
cond.setFormat(fmt);
|
||||||
cond.setPeriodDays(periodDays);
|
cond.setPeriodDays(periodDays);
|
||||||
cond.setOrder("viewCount"); // 떡상 발굴: 최신순이 아니라 조회수순
|
cond.setOrder("viewCount"); // 떡상 발굴: 최신순이 아니라 조회수순
|
||||||
// 광범위 검색: 키워드 없이 인기 Shorts. 결과 빈약 시 검색 API 보강 필요(스펙 §4.2).
|
// 광범위 검색: 키워드 없이 인기 Shorts. 결과 빈약 시 검색 API 보강 필요(스펙 §4.2).
|
||||||
@ -92,7 +107,7 @@ public class ChannelDiscoveryService {
|
|||||||
YoutubeSearchCondition cond = new YoutubeSearchCondition();
|
YoutubeSearchCondition cond = new YoutubeSearchCondition();
|
||||||
cond.setKeyword(kw);
|
cond.setKeyword(kw);
|
||||||
cond.setRegions(List.of(region));
|
cond.setRegions(List.of(region));
|
||||||
cond.setFormat("SHORTS");
|
cond.setFormat(fmt);
|
||||||
cond.setPeriodDays(periodDays);
|
cond.setPeriodDays(periodDays);
|
||||||
cond.setOrder("viewCount"); // 떡상 발굴: 조회수순
|
cond.setOrder("viewCount"); // 떡상 발굴: 조회수순
|
||||||
YoutubeSearchPageDto page = youtubeSearchService.searchYoutubeVideos(cond);
|
YoutubeSearchPageDto page = youtubeSearchService.searchYoutubeVideos(cond);
|
||||||
@ -138,6 +153,7 @@ public class ChannelDiscoveryService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Map<String, Object> summary = new LinkedHashMap<>();
|
Map<String, Object> summary = new LinkedHashMap<>();
|
||||||
|
summary.put("format", fmt);
|
||||||
summary.put("regions", searchedRegions);
|
summary.put("regions", searchedRegions);
|
||||||
summary.put("keywords", keywords);
|
summary.put("keywords", keywords);
|
||||||
summary.put("keywordSearches", keywordSearches);
|
summary.put("keywordSearches", keywordSearches);
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import lombok.RequiredArgsConstructor;
|
|||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
@ -19,6 +20,7 @@ public class RecommendedChannelController {
|
|||||||
private final RecommendedChannelRepository repository;
|
private final RecommendedChannelRepository repository;
|
||||||
private final ChannelDiscoveryService discoveryService;
|
private final ChannelDiscoveryService discoveryService;
|
||||||
private final ChannelService channelService;
|
private final ChannelService channelService;
|
||||||
|
private final ChannelVideoRepository channelVideoRepository;
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
@Operation(summary = "추천 채널 목록", description = "status(NEW|EXCLUDED|REGISTERED, 기본 NEW)를 배율 내림차순으로 반환")
|
@Operation(summary = "추천 채널 목록", description = "status(NEW|EXCLUDED|REGISTERED, 기본 NEW)를 배율 내림차순으로 반환")
|
||||||
@ -60,9 +62,31 @@ public class RecommendedChannelController {
|
|||||||
return ApiResponse.ok(null);
|
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")
|
@PostMapping("/run")
|
||||||
@Operation(summary = "수동 발굴 실행", description = "스케줄과 동일한 발굴을 즉시 1회 실행")
|
@Operation(summary = "수동 발굴 실행", description = "스케줄과 동일한 발굴을 즉시 1회 실행. format=LONG_FORM(기본)|SHORTS")
|
||||||
public ApiResponse<Map<String, Object>> run() {
|
public ApiResponse<Map<String, Object>> run(@RequestParam(required = false) String format) {
|
||||||
return ApiResponse.ok(discoveryService.runDiscovery());
|
return ApiResponse.ok(discoveryService.runDiscovery(format));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -97,6 +97,8 @@ hlab:
|
|||||||
# 예능 특화 시드 키워드(쉼표구분). 각 키워드×지역으로 Shorts 검색해 예능 클립 소재를 우선 발굴.
|
# 예능 특화 시드 키워드(쉼표구분). 각 키워드×지역으로 Shorts 검색해 예능 클립 소재를 우선 발굴.
|
||||||
# 빈 값이면 키워드 없는 기존 '지역 인기 Shorts' 발굴로 동작.
|
# 빈 값이면 키워드 없는 기존 '지역 인기 Shorts' 발굴로 동작.
|
||||||
keywords: ${DISCOVERY_KEYWORDS:예능,방송,하이라이트}
|
keywords: ${DISCOVERY_KEYWORDS:예능,방송,하이라이트}
|
||||||
|
# 발굴 대상 포맷 기본값: LONG_FORM(롱폼) | SHORTS. UI '지금 발굴'에서 건별 선택 가능.
|
||||||
|
format: ${DISCOVERY_FORMAT:LONG_FORM}
|
||||||
|
|
||||||
# 텔레그램 아침 추천: 발굴 직후 상위 추천채널 다이제스트를 발송. 토큰/챗ID 없으면 자동 no-op.
|
# 텔레그램 아침 추천: 발굴 직후 상위 추천채널 다이제스트를 발송. 토큰/챗ID 없으면 자동 no-op.
|
||||||
notify:
|
notify:
|
||||||
|
|||||||
@ -7,10 +7,14 @@
|
|||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<div>
|
<div>
|
||||||
<h1>추천 채널</h1>
|
<h1>추천 채널</h1>
|
||||||
<p class="sub">지역(KR·JP·US) 인기 Shorts에서 자동 발굴한 떡상 채널 — 구독자는 적은데 조회수가 터진 채널</p>
|
<p class="sub">KR 인기 영상(롱폼/쇼츠)에서 자동 발굴한 떡상 채널 — 구독자는 적은데 조회수가 터진 채널</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button class="btn btn-secondary" id="viewToggle" onclick="toggleView()"><i data-lucide="archive" style="width:15px;"></i> 제외 목록</button>
|
<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>
|
<button class="btn btn-secondary" onclick="runDiscovery()"><i data-lucide="refresh-cw" style="width:15px;"></i> 지금 발굴</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -91,6 +95,7 @@
|
|||||||
const actions = excludedView
|
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="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-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>';
|
+ '<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+'">'
|
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,'"')+'" onclick="openVideoModal(this)">'
|
+ '<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,'"')+'" 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 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 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 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){
|
function openVideoModal(el){
|
||||||
const vid = el.dataset.vid || '';
|
const vid = el.dataset.vid || '';
|
||||||
document.getElementById('modalTitle').textContent = el.dataset.title || '';
|
document.getElementById('modalTitle').textContent = el.dataset.title || '';
|
||||||
@ -120,8 +130,10 @@
|
|||||||
document.addEventListener('keydown', e => { if(e.key === 'Escape') closeVideoModal(); });
|
document.addEventListener('keydown', e => { if(e.key === 'Escape') closeVideoModal(); });
|
||||||
|
|
||||||
async function runDiscovery(){
|
async function runDiscovery(){
|
||||||
const s=document.getElementById('status'); s.style.display='block'; s.style.color='#facc15'; s.textContent='발굴 중… (지역별 Shorts 검색, 잠시 걸립니다)';
|
const fmt=(document.getElementById('discFormat')||{}).value||'LONG_FORM';
|
||||||
try{ const r=await api(API+'/run',{method:'POST'}); s.style.color='#4ade80'; s.textContent='발굴 완료 · 신규 '+(r.saved??0)+'개 · 지역 '+((r.regions||[]).join(',')); await load(); }
|
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; }
|
catch(e){ s.style.color='#f87171'; s.textContent='발굴 실패: '+e.message; }
|
||||||
}
|
}
|
||||||
load();
|
load();
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user