feat: 인물 추적 — 피드에 '인물' 탭 추가
인물은 프로그램을 옮겨 다녀서 채널 시드로는 못 잡는다. 실측으로 확인했다:
이미 모은 롱폼 200건에서 제목에 인물명이 잡히는 건 4%뿐이다. 예능 클립 제목은
인물명 대신 상황을 쓰기 때문("혹시 젤리 가격 아세요"). 그래서 인물명으로
유튜브를 검색해 등장분을 찾는다.
- TrackedPerson 엔티티. 1명당 검색 101 units 라 하루 1회만 돌고 활성 인물
상한(기본 10명)을 둔다. 10명이면 1,010 units/일.
- 인물 탭은 source 가 아니라 matchedPerson 으로 조회한다. 소스 채널에서 이미
수집한 영상이 인물 검색에도 걸리면 소스 탭에서 사라지면 안 되기 때문이다.
- 새로 발견한 영상은 source=PERSON — 수집함/발굴 화이트리스트 밖이라 자동 격리.
- 시간당 조회수 하한(기본 20)으로 노이즈를 거른다. 인물명 검색에는 팬채널·
커버곡·개인 브이로그가 대량으로 딸려 오는데(실측 30회 미만), 생 조회수로
자르면 방금 올라온 영상까지 걸리므로 속도로 잰다. 적용 후 노이즈 6건이
전부 빠지고 29건 모두 실질 소재만 남았다.
실측: 윤경호 → KBS 청룡시리즈어워즈 남우조연상(31.6만), KBS 한국방송(18.2만),
SBS 스브스 Drama. 전부 채널 시드에 없는 곳이라 인물 추적으로만 잡힌다.
테스트 3건 추가(속도 필터 경계·신선한 업로드 통과·값 없을 때 미배제) — 총 92건 통과.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
e5205d9e9c
commit
7a954907d7
@ -82,6 +82,13 @@ public class ChannelVideo {
|
|||||||
@Column(name = "hashtags", columnDefinition = "TEXT")
|
@Column(name = "hashtags", columnDefinition = "TEXT")
|
||||||
private String hashtags;
|
private String hashtags;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 인물 추적으로 걸린 영상이면 그 인물명. 인물 탭은 source 가 아니라 이 값으로 조회하므로,
|
||||||
|
* 소스 채널에서 이미 수집한 영상이 인물 검색에도 걸리면 두 탭 모두에 나타난다.
|
||||||
|
*/
|
||||||
|
@Column(name = "matched_person", length = 100)
|
||||||
|
private String matchedPerson;
|
||||||
|
|
||||||
// --- 큐레이션(분류/관리) 필드 ---
|
// --- 큐레이션(분류/관리) 필드 ---
|
||||||
|
|
||||||
/** 분류 카테고리 ID (categories.id 참조, 느슨한 연결). */
|
/** 분류 카테고리 ID (categories.id 참조, 느슨한 연결). */
|
||||||
@ -165,6 +172,38 @@ public class ChannelVideo {
|
|||||||
this.hashtags = hashtags;
|
this.hashtags = hashtags;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 인물 추적으로 걸린 인물명을 기록한다. */
|
||||||
|
public void applyMatchedPerson(String matchedPerson) {
|
||||||
|
this.matchedPerson = matchedPerson;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 인물 검색으로 새로 발견한 영상을 만든다. 소스 채널 시드에 없는 채널까지 잡히므로
|
||||||
|
* 출처를 PERSON 으로 둬 수집함/발굴에서는 격리한다.
|
||||||
|
*/
|
||||||
|
public static ChannelVideo fromPersonSearch(String videoId, String title, String thumbnailUrl,
|
||||||
|
LocalDateTime publishedAt, Long viewCount,
|
||||||
|
String ytChannelId, String channelTitle,
|
||||||
|
Integer durationSec, BigDecimal viewsPerHour,
|
||||||
|
String hashtags, String matchedPerson) {
|
||||||
|
ChannelVideo v = new ChannelVideo();
|
||||||
|
v.videoId = videoId;
|
||||||
|
v.title = title;
|
||||||
|
v.thumbnailUrl = thumbnailUrl;
|
||||||
|
v.publishedAt = publishedAt;
|
||||||
|
v.viewCount = viewCount;
|
||||||
|
v.likeCount = 0L;
|
||||||
|
v.ytChannelId = ytChannelId;
|
||||||
|
v.channelTitle = channelTitle;
|
||||||
|
v.durationSec = durationSec;
|
||||||
|
v.isShorts = VideoMetrics.isShorts(durationSec);
|
||||||
|
v.viewsPerHour = viewsPerHour;
|
||||||
|
v.hashtags = hashtags;
|
||||||
|
v.matchedPerson = matchedPerson;
|
||||||
|
v.source = "PERSON";
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
/** 출처(source)를 바꾸지 않고 채널 정보/비율만 채운다. 백필 시 SEARCH 수집물용. */
|
/** 출처(source)를 바꾸지 않고 채널 정보/비율만 채운다. 백필 시 SEARCH 수집물용. */
|
||||||
public void applyChannelInfoKeepSource(String ytChannelId, String channelTitle, Long subscriberCount, BigDecimal viewsPerSubRatio) {
|
public void applyChannelInfoKeepSource(String ytChannelId, String channelTitle, Long subscriberCount, BigDecimal viewsPerSubRatio) {
|
||||||
this.ytChannelId = ytChannelId;
|
this.ytChannelId = ytChannelId;
|
||||||
|
|||||||
@ -48,6 +48,29 @@ public interface ChannelVideoRepository extends JpaRepository<ChannelVideo, Long
|
|||||||
@Param("hideWorked") boolean hideWorked,
|
@Param("hideWorked") boolean hideWorked,
|
||||||
org.springframework.data.domain.Pageable pageable);
|
org.springframework.data.domain.Pageable pageable);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 인물 탭 조회. source 가 아니라 matchedPerson 기준이라, 소스 채널에서 이미 수집한 영상이
|
||||||
|
* 인물 검색에도 걸리면 두 탭 모두에 나타난다. 롱폼만 본다.
|
||||||
|
*
|
||||||
|
* @param person null 이면 추적 중인 인물 전체
|
||||||
|
*/
|
||||||
|
@Query("select v from ChannelVideo v where v.matchedPerson is not null "
|
||||||
|
+ "and v.interestStatus <> 'EXCLUDED' "
|
||||||
|
+ "and (v.durationSec is null or v.durationSec > 65) "
|
||||||
|
+ "and (cast(:publishedAfter as timestamp) is null or v.publishedAt >= :publishedAfter) "
|
||||||
|
+ "and (:person is null or v.matchedPerson = :person) "
|
||||||
|
+ "and (:hideWorked = false or v.interestStatus = 'NEW') "
|
||||||
|
+ "order by v.publishedAt desc")
|
||||||
|
java.util.List<ChannelVideo> feedByPerson(@Param("publishedAfter") java.time.LocalDateTime publishedAfter,
|
||||||
|
@Param("person") String person,
|
||||||
|
@Param("hideWorked") boolean hideWorked,
|
||||||
|
org.springframework.data.domain.Pageable pageable);
|
||||||
|
|
||||||
|
/** 인물 탭 필터용: [인물명, 영상수]. 영상 많은 순. */
|
||||||
|
@Query("select v.matchedPerson, count(v) from ChannelVideo v "
|
||||||
|
+ "where v.matchedPerson is not null group by v.matchedPerson order by count(v) desc")
|
||||||
|
java.util.List<Object[]> feedPersons();
|
||||||
|
|
||||||
/** 피드 필터용 프로그램(채널) 목록: [ytChannelId, channelTitle, 영상수]. 영상 많은 순. */
|
/** 피드 필터용 프로그램(채널) 목록: [ytChannelId, channelTitle, 영상수]. 영상 많은 순. */
|
||||||
@Query("select v.ytChannelId, min(v.channelTitle), count(v) from ChannelVideo v "
|
@Query("select v.ytChannelId, min(v.channelTitle), count(v) from ChannelVideo v "
|
||||||
+ "where v.source = :source and v.ytChannelId is not null "
|
+ "where v.source = :source and v.ytChannelId is not null "
|
||||||
|
|||||||
@ -3,6 +3,7 @@ package com.hlab.yanalyst.domain.channel;
|
|||||||
import com.hlab.yanalyst.domain.channel.dto.FeedItemDto;
|
import com.hlab.yanalyst.domain.channel.dto.FeedItemDto;
|
||||||
import com.hlab.yanalyst.domain.channel.dto.FeedProgramDto;
|
import com.hlab.yanalyst.domain.channel.dto.FeedProgramDto;
|
||||||
import com.hlab.yanalyst.domain.channel.dto.FeedSeedDto;
|
import com.hlab.yanalyst.domain.channel.dto.FeedSeedDto;
|
||||||
|
import com.hlab.yanalyst.domain.channel.dto.TrackedPersonDto;
|
||||||
import com.hlab.yanalyst.global.common.ApiResponse;
|
import com.hlab.yanalyst.global.common.ApiResponse;
|
||||||
import io.swagger.v3.oas.annotations.Operation;
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
@ -22,15 +23,52 @@ public class FeedController {
|
|||||||
private final FeedService feedService;
|
private final FeedService feedService;
|
||||||
private final FeedCollectionService feedCollectionService;
|
private final FeedCollectionService feedCollectionService;
|
||||||
private final SeedSuggestService seedSuggestService;
|
private final SeedSuggestService seedSuggestService;
|
||||||
|
private final PersonCollectionService personCollectionService;
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
@Operation(summary = "피드 조회", description = "tab=SOURCE(롱폼 소재)|RIVAL(경쟁 쇼츠). 항상 최신순.")
|
@Operation(summary = "피드 조회",
|
||||||
|
description = "tab=SOURCE(롱폼 소재)|RIVAL(경쟁 쇼츠)|PERSON(인물 추적 롱폼). 항상 최신순.")
|
||||||
public ApiResponse<List<FeedItemDto>> feed(@RequestParam(defaultValue = "SOURCE") String tab,
|
public ApiResponse<List<FeedItemDto>> feed(@RequestParam(defaultValue = "SOURCE") String tab,
|
||||||
@RequestParam(required = false) Integer days,
|
@RequestParam(required = false) Integer days,
|
||||||
@RequestParam(required = false) String channelId,
|
@RequestParam(required = false) String channelId,
|
||||||
@RequestParam(required = false) String lengthBucket,
|
@RequestParam(required = false) String lengthBucket,
|
||||||
@RequestParam(defaultValue = "false") boolean hideWorked) {
|
@RequestParam(defaultValue = "false") boolean hideWorked,
|
||||||
return ApiResponse.ok(feedService.feed(tab, days, channelId, lengthBucket, hideWorked));
|
@RequestParam(required = false) String person) {
|
||||||
|
return ApiResponse.ok(feedService.feed(tab, days, channelId, lengthBucket, hideWorked, person));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 인물 추적 ---
|
||||||
|
|
||||||
|
@GetMapping("/persons")
|
||||||
|
@Operation(summary = "추적 인물 목록", description = "인물별 담긴 영상 수와 마지막 수집 결과 포함")
|
||||||
|
public ApiResponse<List<TrackedPersonDto>> persons() {
|
||||||
|
return ApiResponse.ok(feedService.persons());
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/persons")
|
||||||
|
@Operation(summary = "추적 인물 추가", description = "1명당 검색 100 units 라 활성 인원에 상한이 있다")
|
||||||
|
public ApiResponse<TrackedPersonDto> addPerson(@RequestBody Map<String, String> body) {
|
||||||
|
return ApiResponse.created(feedService.addPerson(body.get("name")));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/persons/{id}/toggle")
|
||||||
|
@Operation(summary = "추적 on/off", description = "끄면 수집 대상에서 빠지고 이미 담은 영상은 남는다")
|
||||||
|
public ApiResponse<Void> togglePerson(@PathVariable Long id, @RequestParam boolean enabled) {
|
||||||
|
feedService.togglePerson(id, enabled);
|
||||||
|
return ApiResponse.ok(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/persons/{id}")
|
||||||
|
@Operation(summary = "추적 해제", description = "인물만 목록에서 지우고 담긴 영상은 남긴다")
|
||||||
|
public ApiResponse<Void> removePerson(@PathVariable Long id) {
|
||||||
|
feedService.removePerson(id);
|
||||||
|
return ApiResponse.ok(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/persons/collect")
|
||||||
|
@Operation(summary = "인물 수집 실행", description = "활성 인물 전체를 즉시 1회 수집. 1명당 101 units.")
|
||||||
|
public ApiResponse<Map<String, Object>> collectPersons() {
|
||||||
|
return ApiResponse.ok(personCollectionService.collectAll());
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/programs")
|
@GetMapping("/programs")
|
||||||
|
|||||||
@ -3,6 +3,7 @@ package com.hlab.yanalyst.domain.channel;
|
|||||||
import com.hlab.yanalyst.domain.channel.dto.FeedItemDto;
|
import com.hlab.yanalyst.domain.channel.dto.FeedItemDto;
|
||||||
import com.hlab.yanalyst.domain.channel.dto.FeedProgramDto;
|
import com.hlab.yanalyst.domain.channel.dto.FeedProgramDto;
|
||||||
import com.hlab.yanalyst.domain.channel.dto.FeedSeedDto;
|
import com.hlab.yanalyst.domain.channel.dto.FeedSeedDto;
|
||||||
|
import com.hlab.yanalyst.domain.channel.dto.TrackedPersonDto;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.data.domain.PageRequest;
|
import org.springframework.data.domain.PageRequest;
|
||||||
@ -11,9 +12,11 @@ import org.springframework.transaction.annotation.Transactional;
|
|||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
/** 소재 발굴 피드 조회 + 시드(소스/경쟁 채널) 관리. */
|
/** 소재 발굴 피드 조회 + 시드(소스/경쟁 채널) · 추적 인물 관리. */
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@Service
|
@Service
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@ -23,9 +26,16 @@ public class FeedService {
|
|||||||
/** 한 번에 내려줄 카드 수 상한. */
|
/** 한 번에 내려줄 카드 수 상한. */
|
||||||
private static final int MAX_ITEMS = 200;
|
private static final int MAX_ITEMS = 200;
|
||||||
|
|
||||||
|
/** 인물 탭 식별자. SOURCE/RIVAL 과 달리 채널 역할이 아니라 조회 방식이 다르다. */
|
||||||
|
public static final String TAB_PERSON = "PERSON";
|
||||||
|
|
||||||
private final ChannelRepository channelRepository;
|
private final ChannelRepository channelRepository;
|
||||||
private final ChannelVideoRepository channelVideoRepository;
|
private final ChannelVideoRepository channelVideoRepository;
|
||||||
private final ChannelService channelService;
|
private final ChannelService channelService;
|
||||||
|
private final TrackedPersonRepository trackedPersonRepository;
|
||||||
|
|
||||||
|
@org.springframework.beans.factory.annotation.Value("${hlab.feed.person.max-people:10}")
|
||||||
|
private int maxPeople;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 피드 카드 목록(항상 최신순).
|
* 피드 카드 목록(항상 최신순).
|
||||||
@ -38,11 +48,28 @@ public class FeedService {
|
|||||||
*/
|
*/
|
||||||
public List<FeedItemDto> feed(String tab, Integer days, String ytChannelId,
|
public List<FeedItemDto> feed(String tab, Integer days, String ytChannelId,
|
||||||
String lengthBucket, boolean hideWorked) {
|
String lengthBucket, boolean hideWorked) {
|
||||||
|
return feed(tab, days, ytChannelId, lengthBucket, hideWorked, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param person 인물 탭에서 특정 인물만 (null/빈값이면 전체)
|
||||||
|
*/
|
||||||
|
public List<FeedItemDto> feed(String tab, Integer days, String ytChannelId,
|
||||||
|
String lengthBucket, boolean hideWorked, String person) {
|
||||||
String source = normalizeTab(tab);
|
String source = normalizeTab(tab);
|
||||||
LocalDateTime now = LocalDateTime.now();
|
LocalDateTime now = LocalDateTime.now();
|
||||||
LocalDateTime publishedAfter = (days == null || days <= 0) ? null : now.minusDays(days);
|
LocalDateTime publishedAfter = (days == null || days <= 0) ? null : now.minusDays(days);
|
||||||
String channelFilter = (ytChannelId == null || ytChannelId.isBlank()) ? null : ytChannelId;
|
String channelFilter = (ytChannelId == null || ytChannelId.isBlank()) ? null : ytChannelId;
|
||||||
|
|
||||||
|
if (TAB_PERSON.equals(source)) {
|
||||||
|
String personFilter = (person == null || person.isBlank()) ? null : person;
|
||||||
|
List<ChannelVideo> rows = channelVideoRepository.feedByPerson(
|
||||||
|
publishedAfter, personFilter, hideWorked, PageRequest.of(0, MAX_ITEMS));
|
||||||
|
List<FeedItemDto> out = new ArrayList<>(rows.size());
|
||||||
|
for (ChannelVideo v : rows) out.add(FeedItemDto.from(v, now));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
Integer minSec = null, maxSec = null;
|
Integer minSec = null, maxSec = null;
|
||||||
if (lengthBucket != null && !lengthBucket.isBlank()) {
|
if (lengthBucket != null && !lengthBucket.isBlank()) {
|
||||||
switch (lengthBucket.trim().toUpperCase()) {
|
switch (lengthBucket.trim().toUpperCase()) {
|
||||||
@ -121,6 +148,61 @@ public class FeedService {
|
|||||||
|
|
||||||
private String normalizeTab(String tab) {
|
private String normalizeTab(String tab) {
|
||||||
String t = tab == null ? "" : tab.trim().toUpperCase();
|
String t = tab == null ? "" : tab.trim().toUpperCase();
|
||||||
return ChannelRole.RIVAL.equals(t) ? ChannelRole.RIVAL : ChannelRole.SOURCE;
|
if (ChannelRole.RIVAL.equals(t)) return ChannelRole.RIVAL;
|
||||||
|
if (TAB_PERSON.equals(t)) return TAB_PERSON;
|
||||||
|
return ChannelRole.SOURCE;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 인물 추적 ---
|
||||||
|
|
||||||
|
/** 인물 탭 필터용 목록: 추적 중인 인물 + 지금까지 담긴 영상 수. */
|
||||||
|
public List<TrackedPersonDto> persons() {
|
||||||
|
Map<String, Long> counts = new LinkedHashMap<>();
|
||||||
|
for (Object[] row : channelVideoRepository.feedPersons()) {
|
||||||
|
counts.put((String) row[0], (Long) row[1]);
|
||||||
|
}
|
||||||
|
List<TrackedPersonDto> out = new ArrayList<>();
|
||||||
|
for (TrackedPerson p : trackedPersonRepository.findAllByOrderByIdAsc()) {
|
||||||
|
out.add(new TrackedPersonDto(p.getId(), p.getName(), p.isEnabled(),
|
||||||
|
counts.getOrDefault(p.getName(), 0L), p.getLastCollectedAt(), p.getLastFound()));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 추적 인물 추가. 활성 인원 상한을 넘으면 거절한다(1명당 검색 100 units). */
|
||||||
|
@Transactional
|
||||||
|
public TrackedPersonDto addPerson(String name) {
|
||||||
|
String trimmed = name == null ? "" : name.trim();
|
||||||
|
if (trimmed.isEmpty()) throw new IllegalArgumentException("인물 이름이 필요합니다.");
|
||||||
|
if (trackedPersonRepository.existsByName(trimmed)) {
|
||||||
|
throw new IllegalArgumentException("이미 추적 중인 인물입니다: " + trimmed);
|
||||||
|
}
|
||||||
|
if (trackedPersonRepository.countByEnabledTrue() >= maxPeople) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"추적 인물은 최대 " + maxPeople + "명입니다. 1명당 검색 100 units 를 쓰기 때문입니다. "
|
||||||
|
+ "기존 인물을 끄거나 삭제한 뒤 추가하세요.");
|
||||||
|
}
|
||||||
|
TrackedPerson saved = trackedPersonRepository.save(new TrackedPerson(trimmed));
|
||||||
|
return new TrackedPersonDto(saved.getId(), saved.getName(), true, 0L, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 추적 on/off. 끄면 수집 대상에서 빠지지만 이미 담은 영상은 남는다. */
|
||||||
|
@Transactional
|
||||||
|
public void togglePerson(Long id, boolean enabled) {
|
||||||
|
TrackedPerson p = trackedPersonRepository.findById(id)
|
||||||
|
.orElseThrow(() -> new IllegalArgumentException("추적 인물을 찾을 수 없습니다: " + id));
|
||||||
|
if (enabled && !p.isEnabled() && trackedPersonRepository.countByEnabledTrue() >= maxPeople) {
|
||||||
|
throw new IllegalArgumentException("활성 인물이 이미 " + maxPeople + "명입니다.");
|
||||||
|
}
|
||||||
|
p.setEnabled(enabled);
|
||||||
|
trackedPersonRepository.save(p);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 추적 해제. 그 인물로 담은 영상의 표시만 지우고 영상 자체는 남긴다. */
|
||||||
|
@Transactional
|
||||||
|
public void removePerson(Long id) {
|
||||||
|
TrackedPerson p = trackedPersonRepository.findById(id)
|
||||||
|
.orElseThrow(() -> new IllegalArgumentException("추적 인물을 찾을 수 없습니다: " + id));
|
||||||
|
trackedPersonRepository.delete(p);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,214 @@
|
|||||||
|
package com.hlab.yanalyst.domain.channel;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.hlab.yanalyst.global.schedule.YoutubeQuotaGuard;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.scheduling.annotation.Scheduled;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
import org.springframework.web.client.RestTemplate;
|
||||||
|
import org.springframework.web.util.UriComponentsBuilder;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.net.URI;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.ZoneOffset;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 인물 추적 수집기.
|
||||||
|
*
|
||||||
|
* <p>인물은 프로그램을 옮겨 다닌다. 채널 시드만으로는 "윤경호가 KBS 시상식에 나온 영상"을
|
||||||
|
* 절대 못 잡는데, 인물명으로 검색하면 잡힌다. 다만 1명당 검색 1회(100 units)라 비싸서
|
||||||
|
* 하루 1회만 돌고 활성 인원을 제한한다.
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class PersonCollectionService {
|
||||||
|
|
||||||
|
/** search.list 1회 추정 쿼터. videos.list 1회(1 unit)가 더해진다. */
|
||||||
|
private static final long SEARCH_QUOTA = 100;
|
||||||
|
|
||||||
|
/** 검색 1회에 받아올 후보 수. 같은 쿼터라면 많이 받는 게 이득(대부분 쇼츠라 걸러진다). */
|
||||||
|
private static final int SAMPLE = 50;
|
||||||
|
|
||||||
|
private final TrackedPersonRepository personRepository;
|
||||||
|
private final ChannelVideoRepository channelVideoRepository;
|
||||||
|
private final ChannelRepository channelRepository;
|
||||||
|
private final YoutubeQuotaGuard quotaGuard;
|
||||||
|
private final RestTemplate restTemplate;
|
||||||
|
|
||||||
|
@Value("${youtube.api.key}")
|
||||||
|
private String youtubeApiKey;
|
||||||
|
|
||||||
|
@Value("${hlab.feed.person.enabled:true}")
|
||||||
|
private boolean enabled;
|
||||||
|
|
||||||
|
@Value("${hlab.feed.person.period-days:14}")
|
||||||
|
private int periodDays;
|
||||||
|
|
||||||
|
@Value("${hlab.feed.person.max-people:10}")
|
||||||
|
private int maxPeople;
|
||||||
|
|
||||||
|
/** 시간당 조회수 하한 — 팬채널·커버곡 같은 노이즈를 거른다. */
|
||||||
|
@Value("${hlab.feed.person.min-views-per-hour:20}")
|
||||||
|
private double minViewsPerHour;
|
||||||
|
|
||||||
|
@Scheduled(cron = "${hlab.feed.person.cron:0 45 5 * * *}")
|
||||||
|
public void scheduledCollect() {
|
||||||
|
if (!enabled) {
|
||||||
|
log.info("[Person] 인물 추적 비활성화됨 (hlab.feed.person.enabled=false)");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
log.info("[Person] 자동 수집 완료: {}", collectAll());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 활성 인물 전체를 수집한다. */
|
||||||
|
public Map<String, Object> collectAll() {
|
||||||
|
List<TrackedPerson> people = personRepository.findByEnabledTrueOrderByIdAsc();
|
||||||
|
if (people.size() > maxPeople) people = people.subList(0, maxPeople);
|
||||||
|
|
||||||
|
LocalDateTime publishedAfter = LocalDateTime.now().minusDays(periodDays);
|
||||||
|
Set<String> exclude = excludedChannelIds();
|
||||||
|
|
||||||
|
int searched = 0, saved = 0, skippedByQuota = 0, failed = 0;
|
||||||
|
List<String> names = new ArrayList<>();
|
||||||
|
|
||||||
|
for (TrackedPerson p : people) {
|
||||||
|
if (!quotaGuard.tryConsume(SEARCH_QUOTA + 1)) {
|
||||||
|
skippedByQuota++;
|
||||||
|
log.warn("[Person] 쿼터 예산 소진 — '{}' 이후 중단 (잔여 {})", p.getName(), quotaGuard.remaining());
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
int n = collectOne(p.getName(), exclude, publishedAfter);
|
||||||
|
saved += n;
|
||||||
|
searched++;
|
||||||
|
names.add(p.getName());
|
||||||
|
recordCollection(p.getId(), n);
|
||||||
|
} catch (Exception e) {
|
||||||
|
failed++;
|
||||||
|
log.error("[Person] '{}' 수집 실패 — 건너뜀", p.getName(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, Object> summary = new LinkedHashMap<>();
|
||||||
|
summary.put("people", names);
|
||||||
|
summary.put("searched", searched);
|
||||||
|
summary.put("savedVideos", saved);
|
||||||
|
summary.put("failed", failed);
|
||||||
|
summary.put("skippedByQuota", skippedByQuota);
|
||||||
|
summary.put("quotaRemaining", quotaGuard.remaining());
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 인물 1명 수집. @return 새로 담거나 갱신한 롱폼 수 */
|
||||||
|
@Transactional
|
||||||
|
public int collectOne(String person, Set<String> exclude, LocalDateTime publishedAfter) {
|
||||||
|
String after = publishedAfter.atOffset(ZoneOffset.UTC).format(DateTimeFormatter.ISO_INSTANT);
|
||||||
|
|
||||||
|
URI searchUri = UriComponentsBuilder.fromHttpUrl("https://www.googleapis.com/youtube/v3/search")
|
||||||
|
.queryParam("part", "snippet")
|
||||||
|
.queryParam("type", "video")
|
||||||
|
.queryParam("q", person)
|
||||||
|
.queryParam("order", "date") // 최신순 — 선점이 목적
|
||||||
|
.queryParam("publishedAfter", after)
|
||||||
|
.queryParam("regionCode", "KR")
|
||||||
|
.queryParam("relevanceLanguage", "ko")
|
||||||
|
.queryParam("maxResults", SAMPLE)
|
||||||
|
.queryParam("key", youtubeApiKey)
|
||||||
|
.build().encode().toUri(); // String 으로 넘기면 이중 인코딩된다
|
||||||
|
|
||||||
|
JsonNode root = restTemplate.getForObject(searchUri, JsonNode.class);
|
||||||
|
if (root == null) return 0;
|
||||||
|
|
||||||
|
List<String> videoIds = new ArrayList<>();
|
||||||
|
for (JsonNode item : root.path("items")) {
|
||||||
|
String id = item.path("id").path("videoId").asText(null);
|
||||||
|
if (id != null && !id.isBlank()) videoIds.add(id);
|
||||||
|
}
|
||||||
|
if (videoIds.isEmpty()) return 0;
|
||||||
|
|
||||||
|
List<PersonPicks.Found> found = fetchDetails(videoIds);
|
||||||
|
List<PersonPicks.Found> picks = PersonPicks.keep(found, exclude, publishedAfter, minViewsPerHour);
|
||||||
|
|
||||||
|
int saved = 0;
|
||||||
|
for (PersonPicks.Found f : picks) {
|
||||||
|
BigDecimal vph = VideoMetrics.viewsPerHour(f.viewCount(), f.publishedAt());
|
||||||
|
channelVideoRepository.findByVideoId(f.videoId())
|
||||||
|
.ifPresentOrElse(v -> {
|
||||||
|
// 이미 있는 영상이면 출처는 건드리지 않는다. 소스 탭에서 사라지면 안 되므로
|
||||||
|
// 인물명만 덧붙여 두 탭 모두에 나타나게 한다.
|
||||||
|
v.update(f.title(), v.getThumbnailUrl(), f.viewCount(), v.getLikeCount());
|
||||||
|
v.applyMetrics(f.durationSec(), VideoMetrics.isShorts(f.durationSec()), vph);
|
||||||
|
v.applyMatchedPerson(person);
|
||||||
|
channelVideoRepository.save(v);
|
||||||
|
}, () -> channelVideoRepository.save(ChannelVideo.fromPersonSearch(
|
||||||
|
f.videoId(), f.title(), thumbnailOf(f.videoId()), f.publishedAt(), f.viewCount(),
|
||||||
|
f.ytChannelId(), f.channelTitle(), f.durationSec(), vph, null, person)));
|
||||||
|
saved++;
|
||||||
|
}
|
||||||
|
return saved;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** videos.list 로 길이·조회수·업로드일을 채운다(검색 결과에는 길이가 없다). */
|
||||||
|
private List<PersonPicks.Found> fetchDetails(List<String> videoIds) {
|
||||||
|
URI uri = UriComponentsBuilder.fromHttpUrl("https://www.googleapis.com/youtube/v3/videos")
|
||||||
|
.queryParam("part", "snippet,contentDetails,statistics")
|
||||||
|
.queryParam("id", String.join(",", videoIds))
|
||||||
|
.queryParam("key", youtubeApiKey)
|
||||||
|
.build().encode().toUri();
|
||||||
|
|
||||||
|
JsonNode root = restTemplate.getForObject(uri, JsonNode.class);
|
||||||
|
List<PersonPicks.Found> out = new ArrayList<>();
|
||||||
|
if (root == null) return out;
|
||||||
|
|
||||||
|
for (JsonNode item : root.path("items")) {
|
||||||
|
try {
|
||||||
|
JsonNode snippet = item.path("snippet");
|
||||||
|
Integer durationSec = VideoMetrics.parseDurationSec(
|
||||||
|
item.path("contentDetails").path("duration").asText(null));
|
||||||
|
LocalDateTime publishedAt = LocalDateTime.parse(
|
||||||
|
snippet.path("publishedAt").asText(), DateTimeFormatter.ISO_DATE_TIME);
|
||||||
|
long views = item.path("statistics").path("viewCount").asLong(0);
|
||||||
|
out.add(new PersonPicks.Found(
|
||||||
|
item.path("id").asText(), snippet.path("title").asText(""),
|
||||||
|
snippet.path("channelId").asText(null), snippet.path("channelTitle").asText(""),
|
||||||
|
durationSec, publishedAt, views));
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.debug("[Person] 영상 상세 파싱 실패 — 건너뜀", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 내 채널(OWN)과 경쟁 채널(RIVAL)의 영상은 소재가 아니므로 인물 결과에서 뺀다. */
|
||||||
|
private Set<String> excludedChannelIds() {
|
||||||
|
Set<String> out = new HashSet<>();
|
||||||
|
for (Channel c : channelRepository.findOwnChannels()) out.add(c.getChannelId());
|
||||||
|
for (Channel c : channelRepository.findByRole(ChannelRole.RIVAL)) out.add(c.getChannelId());
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 검색 스니펫 썸네일 대신 표준 URL 을 쓴다(해상도 일관성). */
|
||||||
|
private String thumbnailOf(String videoId) {
|
||||||
|
return "https://i.ytimg.com/vi/" + videoId + "/hqdefault.jpg";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 같은 빈 안에서 호출되므로 @Transactional 프록시가 안 걸린다 — 명시적으로 save 한다. */
|
||||||
|
private void recordCollection(Long personId, int found) {
|
||||||
|
personRepository.findById(personId).ifPresent(p -> {
|
||||||
|
p.recordCollection(found);
|
||||||
|
personRepository.save(p);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,63 @@
|
|||||||
|
package com.hlab.yanalyst.domain.channel;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 인물 검색 결과에서 실제로 담을 영상을 고르는 순수 로직.
|
||||||
|
*
|
||||||
|
* <p>검색은 최신순 50건을 주는데 대부분이 쇼츠다. 롱폼만 남기고, 내 채널·경쟁 채널 것은
|
||||||
|
* 소재가 아니므로 뺀다.
|
||||||
|
*/
|
||||||
|
public final class PersonPicks {
|
||||||
|
|
||||||
|
private PersonPicks() {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 검색 결과 1건.
|
||||||
|
*
|
||||||
|
* @param ytChannelId 업로드 채널. 내 채널/경쟁 채널 제외 판정에 쓴다
|
||||||
|
*/
|
||||||
|
public record Found(String videoId, String title, String ytChannelId, String channelTitle,
|
||||||
|
Integer durationSec, LocalDateTime publishedAt, Long viewCount) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 담을 영상만 고른다.
|
||||||
|
*
|
||||||
|
* @param found 검색 결과
|
||||||
|
* @param excludeChannels 제외할 채널 ID(내 채널 OWN, 경쟁 채널 RIVAL)
|
||||||
|
* @param publishedAfter 이 시각 이전 업로드는 제외(null 이면 제한 없음)
|
||||||
|
* @param minViewsPerHour 시간당 조회수 하한. 인물명 검색에는 팬채널·커버곡·개인 브이로그가
|
||||||
|
* 대량으로 딸려 오는데(실측 조회수 30회 미만), 생 조회수로 자르면 방금 올라온
|
||||||
|
* 영상까지 걸리므로 시간당 조회수로 거른다. 0 이하면 미적용
|
||||||
|
* @return 롱폼이면서 제외 대상이 아닌 것들 (입력 순서 유지)
|
||||||
|
*/
|
||||||
|
public static List<Found> keep(List<Found> found, Set<String> excludeChannels,
|
||||||
|
LocalDateTime publishedAfter, double minViewsPerHour) {
|
||||||
|
List<Found> out = new ArrayList<>();
|
||||||
|
if (found == null) return out;
|
||||||
|
|
||||||
|
for (Found f : found) {
|
||||||
|
if (f == null || f.videoId() == null || f.videoId().isBlank()) continue;
|
||||||
|
// 쇼츠 제외 — 인물 추적의 목적은 자를 원본(롱폼)을 찾는 것
|
||||||
|
if (f.durationSec() == null || VideoMetrics.isShorts(f.durationSec())) continue;
|
||||||
|
if (f.ytChannelId() != null && excludeChannels != null
|
||||||
|
&& excludeChannels.contains(f.ytChannelId())) continue;
|
||||||
|
if (publishedAfter != null && f.publishedAt() != null
|
||||||
|
&& f.publishedAt().isBefore(publishedAfter)) continue;
|
||||||
|
if (minViewsPerHour > 0 && !meetsVelocity(f, minViewsPerHour)) continue;
|
||||||
|
out.add(f);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 시간당 조회수가 하한 이상인가. 계산에 필요한 값이 없으면 통과시킨다(과도한 배제 방지). */
|
||||||
|
private static boolean meetsVelocity(Found f, double minViewsPerHour) {
|
||||||
|
if (f.viewCount() == null || f.publishedAt() == null) return true;
|
||||||
|
java.math.BigDecimal vph = VideoMetrics.viewsPerHour(f.viewCount(), f.publishedAt());
|
||||||
|
if (vph == null) return true;
|
||||||
|
return vph.doubleValue() >= minViewsPerHour;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,60 @@
|
|||||||
|
package com.hlab.yanalyst.domain.channel;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
import org.hibernate.annotations.CreationTimestamp;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 추적 대상 인물. 인물은 프로그램을 옮겨 다니므로 채널만 쫓으면 놓치는 소재를 잡아낸다.
|
||||||
|
*
|
||||||
|
* <p>1명당 검색 1회(100 units)가 들어 하루 1회만 수집한다. 활성 인원은
|
||||||
|
* {@code hlab.feed.person.max-people} 로 제한한다.
|
||||||
|
*/
|
||||||
|
@Entity
|
||||||
|
@Table(name = "tracked_persons", indexes = @Index(name = "idx_tp_name", columnList = "name", unique = true))
|
||||||
|
@Getter
|
||||||
|
@NoArgsConstructor
|
||||||
|
public class TrackedPerson {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@Column(nullable = false, unique = true, length = 100)
|
||||||
|
private String name;
|
||||||
|
|
||||||
|
/** 꺼두면 수집 대상에서 빠진다(쿼터를 아끼면서 목록은 유지). */
|
||||||
|
@Column(nullable = false)
|
||||||
|
private Boolean enabled = true;
|
||||||
|
|
||||||
|
/** 마지막 수집 시각. */
|
||||||
|
private LocalDateTime lastCollectedAt;
|
||||||
|
|
||||||
|
/** 마지막 수집에서 새로 담은 롱폼 수. */
|
||||||
|
private Integer lastFound;
|
||||||
|
|
||||||
|
@CreationTimestamp
|
||||||
|
@Column(updatable = false)
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
public TrackedPerson(String name) {
|
||||||
|
this.name = name;
|
||||||
|
this.enabled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isEnabled() {
|
||||||
|
return Boolean.TRUE.equals(this.enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setEnabled(boolean enabled) {
|
||||||
|
this.enabled = enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void recordCollection(int found) {
|
||||||
|
this.lastCollectedAt = LocalDateTime.now();
|
||||||
|
this.lastFound = found;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,14 @@
|
|||||||
|
package com.hlab.yanalyst.domain.channel;
|
||||||
|
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
public interface TrackedPersonRepository extends JpaRepository<TrackedPerson, Long> {
|
||||||
|
Optional<TrackedPerson> findByName(String name);
|
||||||
|
boolean existsByName(String name);
|
||||||
|
List<TrackedPerson> findByEnabledTrueOrderByIdAsc();
|
||||||
|
List<TrackedPerson> findAllByOrderByIdAsc();
|
||||||
|
long countByEnabledTrue();
|
||||||
|
}
|
||||||
@ -24,6 +24,8 @@ public record FeedItemDto(
|
|||||||
String interestStatus,
|
String interestStatus,
|
||||||
boolean bookmarked,
|
boolean bookmarked,
|
||||||
String source,
|
String source,
|
||||||
|
/** 인물 추적으로 걸렸다면 그 인물명. 인물 탭 카드에 배지로 표시한다. */
|
||||||
|
String matchedPerson,
|
||||||
/** 업로드 24시간 이내 — 선점 골든타임. */
|
/** 업로드 24시간 이내 — 선점 골든타임. */
|
||||||
boolean goldenTime,
|
boolean goldenTime,
|
||||||
/** SHORTS | CLIP | FULL | UNKNOWN */
|
/** SHORTS | CLIP | FULL | UNKNOWN */
|
||||||
@ -48,6 +50,7 @@ public record FeedItemDto(
|
|||||||
v.getInterestStatus(),
|
v.getInterestStatus(),
|
||||||
v.isBookmarked(),
|
v.isBookmarked(),
|
||||||
v.getSource(),
|
v.getSource(),
|
||||||
|
v.getMatchedPerson(),
|
||||||
FeedBadges.isGoldenTime(v.getPublishedAt(), now),
|
FeedBadges.isGoldenTime(v.getPublishedAt(), now),
|
||||||
FeedBadges.lengthBucket(v.getDurationSec()),
|
FeedBadges.lengthBucket(v.getDurationSec()),
|
||||||
FeedBadges.isRising(v.getViewsPerHour()),
|
FeedBadges.isRising(v.getViewsPerHour()),
|
||||||
|
|||||||
@ -0,0 +1,13 @@
|
|||||||
|
package com.hlab.yanalyst.domain.channel.dto;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 추적 인물 1건.
|
||||||
|
*
|
||||||
|
* @param videoCount 지금까지 이 인물로 담긴 영상 수
|
||||||
|
* @param lastCollectedAt 마지막 수집 시각
|
||||||
|
* @param lastFound 마지막 수집에서 담은 롱폼 수
|
||||||
|
*/
|
||||||
|
public record TrackedPersonDto(Long id, String name, boolean enabled, long videoCount,
|
||||||
|
LocalDateTime lastCollectedAt, Integer lastFound) {}
|
||||||
@ -108,6 +108,15 @@ hlab:
|
|||||||
seed:
|
seed:
|
||||||
max-keywords: ${FEED_SEED_MAX_KEYWORDS:8} # 시드 자동 발굴 시 사용할 상위 해시태그 수(1개당 100 units)
|
max-keywords: ${FEED_SEED_MAX_KEYWORDS:8} # 시드 자동 발굴 시 사용할 상위 해시태그 수(1개당 100 units)
|
||||||
min-tag-count: ${FEED_SEED_MIN_TAG_COUNT:2} # 이 횟수 미만 해시태그는 프로그램명으로 보지 않음
|
min-tag-count: ${FEED_SEED_MIN_TAG_COUNT:2} # 이 횟수 미만 해시태그는 프로그램명으로 보지 않음
|
||||||
|
# 인물 추적: 인물명으로 검색해 롱폼 등장분을 찾는다. 1명당 101 units 라 하루 1회만 돈다.
|
||||||
|
person:
|
||||||
|
enabled: ${FEED_PERSON_ENABLED:true}
|
||||||
|
cron: ${FEED_PERSON_CRON:0 45 5 * * *} # 매일 05:45 (채널 수집·발굴 뒤)
|
||||||
|
max-people: ${FEED_PERSON_MAX:10} # 활성 인물 상한 — 10명이면 1,010 units/일
|
||||||
|
period-days: ${FEED_PERSON_PERIOD_DAYS:14}
|
||||||
|
# 인물명 검색엔 팬채널·커버곡·개인 브이로그가 대량으로 딸려 온다(실측 30회 미만).
|
||||||
|
# 생 조회수로 자르면 방금 올라온 영상까지 걸리므로 시간당 조회수로 거른다.
|
||||||
|
min-views-per-hour: ${FEED_PERSON_MIN_VPH:20}
|
||||||
|
|
||||||
# 텔레그램 아침 추천: 발굴 직후 상위 추천채널 다이제스트를 발송. 토큰/챗ID 없으면 자동 no-op.
|
# 텔레그램 아침 추천: 발굴 직후 상위 추천채널 다이제스트를 발송. 토큰/챗ID 없으면 자동 no-op.
|
||||||
notify:
|
notify:
|
||||||
|
|||||||
@ -14,6 +14,9 @@
|
|||||||
<p class="sub">소재 원본(웹예능 롱폼)과 경쟁 쇼츠의 신규 업로드를 최신순으로 봅니다.</p>
|
<p class="sub">소재 원본(웹예능 롱폼)과 경쟁 쇼츠의 신규 업로드를 최신순으로 봅니다.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="actions">
|
<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()">
|
<button class="btn btn-secondary" onclick="openSeeds()">
|
||||||
<i data-lucide="list-plus" style="width:15px;"></i> 시드 관리
|
<i data-lucide="list-plus" style="width:15px;"></i> 시드 관리
|
||||||
</button>
|
</button>
|
||||||
@ -37,16 +40,27 @@
|
|||||||
<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"
|
||||||
|
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>
|
||||||
|
|
||||||
<!-- 필터 -->
|
<!-- 필터 -->
|
||||||
<div class="card mb-4">
|
<div class="card mb-4">
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<label class="field" for="fProgram">프로그램
|
<label class="field" for="fProgram" id="programField">프로그램
|
||||||
<select id="fProgram" onchange="applyFilters()">
|
<select id="fProgram" onchange="applyFilters()">
|
||||||
<option value="">전체</option>
|
<option value="">전체</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</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">길이
|
<label class="field" for="fLength" id="lengthField">길이
|
||||||
<select id="fLength" onchange="applyFilters()">
|
<select id="fLength" onchange="applyFilters()">
|
||||||
<option value="">전체</option>
|
<option value="">전체</option>
|
||||||
@ -119,6 +133,42 @@
|
|||||||
</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="닫기">×</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="toast" class="toast" role="status" aria-live="polite"></div>
|
<div id="toast" class="toast" role="status" aria-live="polite"></div>
|
||||||
|
|
||||||
@ -331,28 +381,37 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------- 탭 ----------
|
// ---------- 탭 ----------
|
||||||
function switchTab(tab){
|
const TABS = ['SOURCE', 'RIVAL', 'PERSON'];
|
||||||
if(tab === currentTab) return;
|
|
||||||
currentTab = 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;
|
||||||
btn.setAttribute('aria-selected', on ? 'true' : 'false');
|
btn.setAttribute('aria-selected', on ? 'true' : 'false');
|
||||||
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';
|
||||||
const lf = document.getElementById('lengthField');
|
document.getElementById('programField').style.display = (tab === 'PERSON') ? 'none' : '';
|
||||||
lf.style.display = (tab === 'RIVAL') ? 'none' : '';
|
document.getElementById('personField').style.display = (tab === 'PERSON') ? '' : 'none';
|
||||||
if(tab === 'RIVAL') document.getElementById('fLength').value = '';
|
if(tab !== 'SOURCE') document.getElementById('fLength').value = '';
|
||||||
// 탭마다 프로그램 목록이 다르므로 선택을 비우고 다시 채운다
|
}
|
||||||
|
|
||||||
|
function switchTab(tab){
|
||||||
|
if(tab === currentTab) return;
|
||||||
|
currentTab = tab;
|
||||||
|
applyTabChrome(tab);
|
||||||
|
// 탭마다 필터 목록이 다르므로 선택을 비우고 다시 채운다
|
||||||
document.getElementById('fProgram').value = '';
|
document.getElementById('fProgram').value = '';
|
||||||
loadPrograms().then(applyFilters);
|
document.getElementById('fPerson').value = '';
|
||||||
|
loadFilterOptions().then(applyFilters);
|
||||||
}
|
}
|
||||||
|
|
||||||
document.querySelector('.feed-tabs').addEventListener('keydown', e => {
|
document.querySelector('.feed-tabs').addEventListener('keydown', e => {
|
||||||
if(e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return;
|
if(e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const next = currentTab === 'SOURCE' ? 'RIVAL' : 'SOURCE';
|
const i = TABS.indexOf(currentTab);
|
||||||
|
const next = TABS[(i + (e.key === 'ArrowRight' ? 1 : TABS.length - 1)) % TABS.length];
|
||||||
switchTab(next);
|
switchTab(next);
|
||||||
document.getElementById('tab-' + next).focus();
|
document.getElementById('tab-' + next).focus();
|
||||||
});
|
});
|
||||||
@ -362,6 +421,7 @@
|
|||||||
return {
|
return {
|
||||||
tab: currentTab,
|
tab: currentTab,
|
||||||
channelId: document.getElementById('fProgram').value,
|
channelId: document.getElementById('fProgram').value,
|
||||||
|
person: document.getElementById('fPerson').value,
|
||||||
lengthBucket: document.getElementById('fLength').value,
|
lengthBucket: document.getElementById('fLength').value,
|
||||||
days: document.getElementById('fDays').value,
|
days: document.getElementById('fDays').value,
|
||||||
hideWorked: document.getElementById('fHideWorked').checked
|
hideWorked: document.getElementById('fHideWorked').checked
|
||||||
@ -373,7 +433,8 @@
|
|||||||
// 새로고침·뒤로가기에서 필터가 보존되도록 URL 에 반영
|
// 새로고침·뒤로가기에서 필터가 보존되도록 URL 에 반영
|
||||||
const q = new URLSearchParams();
|
const q = new URLSearchParams();
|
||||||
q.set('tab', f.tab);
|
q.set('tab', f.tab);
|
||||||
if(f.channelId) q.set('channelId', f.channelId);
|
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.lengthBucket) q.set('lengthBucket', f.lengthBucket);
|
||||||
if(f.days !== '7') q.set('days', f.days);
|
if(f.days !== '7') q.set('days', f.days);
|
||||||
if(f.hideWorked) q.set('hideWorked', 'true');
|
if(f.hideWorked) q.set('hideWorked', 'true');
|
||||||
@ -384,22 +445,21 @@
|
|||||||
function restoreFilters(){
|
function restoreFilters(){
|
||||||
const q = new URLSearchParams(location.search);
|
const q = new URLSearchParams(location.search);
|
||||||
const tab = q.get('tab');
|
const tab = q.get('tab');
|
||||||
if(tab === 'RIVAL'){
|
if(TABS.includes(tab) && tab !== 'SOURCE') currentTab = tab;
|
||||||
currentTab = 'RIVAL';
|
applyTabChrome(currentTab);
|
||||||
document.querySelectorAll('.feed-tab').forEach(btn => {
|
|
||||||
const on = btn.dataset.tab === 'RIVAL';
|
|
||||||
btn.setAttribute('aria-selected', on ? 'true' : 'false');
|
|
||||||
btn.tabIndex = on ? 0 : -1;
|
|
||||||
});
|
|
||||||
document.getElementById('lengthField').style.display = 'none';
|
|
||||||
}
|
|
||||||
if(q.get('days') != null) document.getElementById('fDays').value = q.get('days');
|
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('lengthBucket')) document.getElementById('fLength').value = q.get('lengthBucket');
|
||||||
if(q.get('hideWorked') === 'true') document.getElementById('fHideWorked').checked = true;
|
if(q.get('hideWorked') === 'true') document.getElementById('fHideWorked').checked = true;
|
||||||
return q.get('channelId'); // 프로그램 목록 로드 후 적용
|
// 목록을 로드한 뒤 적용해야 하므로 값만 돌려준다
|
||||||
|
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){
|
async function loadPrograms(preselect){
|
||||||
const sel = document.getElementById('fProgram');
|
const sel = document.getElementById('fProgram');
|
||||||
const keep = preselect || sel.value;
|
const keep = preselect || sel.value;
|
||||||
@ -410,13 +470,24 @@
|
|||||||
if(keep && [...sel.options].some(o => o.value === keep)) sel.value = keep;
|
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(){
|
async function loadFeed(){
|
||||||
showSkeleton();
|
showSkeleton();
|
||||||
const f = readFilters();
|
const f = readFilters();
|
||||||
const p = new URLSearchParams();
|
const p = new URLSearchParams();
|
||||||
p.set('tab', f.tab);
|
p.set('tab', f.tab);
|
||||||
if(f.channelId) p.set('channelId', f.channelId);
|
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.lengthBucket) p.set('lengthBucket', f.lengthBucket);
|
||||||
if(f.days && f.days !== '0') p.set('days', f.days);
|
if(f.days && f.days !== '0') p.set('days', f.days);
|
||||||
if(f.hideWorked) p.set('hideWorked', 'true');
|
if(f.hideWorked) p.set('hideWorked', 'true');
|
||||||
@ -434,11 +505,14 @@
|
|||||||
|
|
||||||
function badgeHtml(it){
|
function badgeHtml(it){
|
||||||
const out = [];
|
const out = [];
|
||||||
if(currentTab === 'SOURCE'){
|
if(currentTab === 'RIVAL'){
|
||||||
if(it.lengthBucket === 'CLIP') out.push('<span class="badge badge-primary">공식클립</span>');
|
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>');
|
else if(it.lengthBucket === 'FULL') out.push('<span class="badge badge-muted">풀에피</span>');
|
||||||
} else if(it.rising){
|
|
||||||
out.push('<span class="badge badge-warning"><i data-lucide="trending-up" style="width:11px;"></i>떡상중</span>');
|
|
||||||
}
|
}
|
||||||
if(it.worked) out.push(`<span class="badge badge-success">작업함</span>`);
|
if(it.worked) out.push(`<span class="badge badge-success">작업함</span>`);
|
||||||
return out.join('');
|
return out.join('');
|
||||||
@ -452,12 +526,13 @@
|
|||||||
const vph = (currentTab === 'RIVAL' && it.viewsPerHour != null)
|
const vph = (currentTab === 'RIVAL' && it.viewsPerHour != null)
|
||||||
? `<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 primary = currentTab === 'SOURCE'
|
// (남의 쇼츠는 재가공 대상이 아님)
|
||||||
? `<a class="btn btn-primary grow" href="/rework/${it.id}">
|
const primary = currentTab === 'RIVAL'
|
||||||
<i data-lucide="wand-2" style="width:14px;"></i> 작업 시작</a>`
|
? `<a class="btn btn-secondary grow" href="${url}" target="_blank" rel="noopener">
|
||||||
: `<a class="btn btn-secondary grow" href="${url}" target="_blank" rel="noopener">
|
<i data-lucide="external-link" style="width:14px;"></i> 원본 보기</a>`
|
||||||
<i data-lucide="external-link" style="width:14px;"></i> 원본 보기</a>`;
|
: `<a class="btn btn-primary grow" href="/rework/${it.id}">
|
||||||
|
<i data-lucide="wand-2" style="width:14px;"></i> 작업 시작</a>`;
|
||||||
|
|
||||||
return `<article class="fcard${it.worked ? ' is-worked' : ''}" data-id="${it.id}">
|
return `<article class="fcard${it.worked ? ' is-worked' : ''}" data-id="${it.id}">
|
||||||
<a class="fthumb" href="${url}" target="_blank" rel="noopener"
|
<a class="fthumb" href="${url}" target="_blank" rel="noopener"
|
||||||
@ -496,13 +571,21 @@
|
|||||||
function render(){
|
function render(){
|
||||||
document.getElementById('resultCount').textContent = items.length + '건';
|
document.getElementById('resultCount').textContent = items.length + '건';
|
||||||
if(items.length === 0){
|
if(items.length === 0){
|
||||||
const isSource = currentTab === 'SOURCE';
|
if(currentTab === 'PERSON'){
|
||||||
showState('inbox', '아직 소재가 없습니다',
|
showState('user-search', '추적 중인 인물이 없습니다',
|
||||||
isSource
|
'인물은 프로그램을 옮겨 다녀서 채널 시드로는 못 잡습니다. 이름을 등록하면 등장한 롱폼을 찾아옵니다.',
|
||||||
? '소재 원본 채널(유퀴즈·핑계고 같은 웹예능 공식채널)을 시드로 등록한 뒤 수집하세요.'
|
`<button class="btn btn-primary" onclick="openPersons()">인물 등록하기</button>`);
|
||||||
: '경쟁 예능짤 쇼츠 채널을 시드로 등록한 뒤 수집하세요.',
|
} else if(currentTab === 'SOURCE'){
|
||||||
`<button class="btn btn-primary" onclick="openSeeds()">시드 등록하기</button>
|
showState('inbox', '아직 소재가 없습니다',
|
||||||
<button class="btn btn-secondary" onclick="runCollect()">지금 수집</button>`);
|
'소재 원본 채널(유퀴즈·핑계고 같은 웹예능 공식채널)을 시드로 등록한 뒤 수집하세요.',
|
||||||
|
`<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;
|
return;
|
||||||
}
|
}
|
||||||
hideState();
|
hideState();
|
||||||
@ -674,12 +757,118 @@
|
|||||||
} catch(e){ toast('발굴 실패: ' + e.message, true); }
|
} catch(e){ toast('발굴 실패: ' + e.message, true); }
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('keydown', e => { if(e.key === 'Escape') closeSeeds(); });
|
// ---------- 인물 관리 ----------
|
||||||
|
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'){ closeSeeds(); closePersons(); }
|
||||||
|
});
|
||||||
|
|
||||||
// ---------- init ----------
|
// ---------- init ----------
|
||||||
(async () => {
|
(async () => {
|
||||||
const preselect = restoreFilters();
|
const preselect = restoreFilters();
|
||||||
await loadPrograms(preselect);
|
await loadFilterOptions(preselect);
|
||||||
await loadFeed();
|
await loadFeed();
|
||||||
})();
|
})();
|
||||||
/*]]>*/
|
/*]]>*/
|
||||||
|
|||||||
@ -0,0 +1,104 @@
|
|||||||
|
package com.hlab.yanalyst.domain.channel;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
class PersonPicksTest {
|
||||||
|
|
||||||
|
private static final LocalDateTime NOW = LocalDateTime.of(2026, 8, 2, 12, 0);
|
||||||
|
|
||||||
|
private PersonPicks.Found f(String id, Integer durationSec, String channelId, LocalDateTime published) {
|
||||||
|
return new PersonPicks.Found(id, "t_" + id, channelId, "ch_" + channelId, durationSec, published, 1000L);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void 쇼츠는_버리고_롱폼만_남긴다() {
|
||||||
|
List<PersonPicks.Found> found = List.of(
|
||||||
|
f("a", 30, "C1", NOW), // 쇼츠
|
||||||
|
f("b", 65, "C1", NOW), // 경계값 65초 = 쇼츠
|
||||||
|
f("c", 66, "C1", NOW), // 롱폼
|
||||||
|
f("d", 4300, "C1", NOW)); // 롱폼
|
||||||
|
|
||||||
|
List<PersonPicks.Found> kept = PersonPicks.keep(found, Set.of(), null, 0);
|
||||||
|
|
||||||
|
assertThat(kept).extracting(PersonPicks.Found::videoId).containsExactly("c", "d");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void 길이를_모르면_버린다() {
|
||||||
|
// 상세 조회 실패로 길이를 못 채운 건 롱폼인지 알 수 없으므로 담지 않는다
|
||||||
|
List<PersonPicks.Found> kept = PersonPicks.keep(List.of(f("a", null, "C1", NOW)), Set.of(), null, 0);
|
||||||
|
|
||||||
|
assertThat(kept).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void 내채널과_경쟁채널_영상은_제외() {
|
||||||
|
List<PersonPicks.Found> found = List.of(
|
||||||
|
f("a", 300, "OWN_CH", NOW),
|
||||||
|
f("b", 300, "RIVAL_CH", NOW),
|
||||||
|
f("c", 300, "OTHER", NOW));
|
||||||
|
|
||||||
|
List<PersonPicks.Found> kept = PersonPicks.keep(found, Set.of("OWN_CH", "RIVAL_CH"), null, 0);
|
||||||
|
|
||||||
|
assertThat(kept).extracting(PersonPicks.Found::videoId).containsExactly("c");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void 기간_이전_업로드는_제외() {
|
||||||
|
LocalDateTime cutoff = NOW.minusDays(14);
|
||||||
|
List<PersonPicks.Found> found = List.of(
|
||||||
|
f("old", 300, "C1", cutoff.minusDays(1)),
|
||||||
|
f("new", 300, "C1", cutoff.plusDays(1)));
|
||||||
|
|
||||||
|
List<PersonPicks.Found> kept = PersonPicks.keep(found, Set.of(), cutoff, 0);
|
||||||
|
|
||||||
|
assertThat(kept).extracting(PersonPicks.Found::videoId).containsExactly("new");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void 시간당_조회수_하한으로_팬채널_노이즈를_거른다() {
|
||||||
|
// 실측: 팬채널·커버곡은 하루에 조회수 30회 미만, 방송사 클립은 시간당 수백~수천
|
||||||
|
LocalDateTime dayAgo = LocalDateTime.now().minusHours(24);
|
||||||
|
PersonPicks.Found noise = new PersonPicks.Found("n", "팬캠", "C1", "흥미딘딘", 300, dayAgo, 16L);
|
||||||
|
PersonPicks.Found signal = new PersonPicks.Found("s", "방송클립", "C2", "SBS", 300, dayAgo, 3000L);
|
||||||
|
|
||||||
|
List<PersonPicks.Found> kept = PersonPicks.keep(List.of(noise, signal), Set.of(), null, 20);
|
||||||
|
|
||||||
|
assertThat(kept).extracting(PersonPicks.Found::videoId).containsExactly("s");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void 방금_올라온_영상은_조회수가_적어도_통과한다() {
|
||||||
|
// 생 조회수로 자르면 선점 대상인 신선한 업로드를 놓친다 — 시간당으로 재기 때문에 통과해야 한다
|
||||||
|
LocalDateTime justNow = LocalDateTime.now().minusMinutes(30);
|
||||||
|
PersonPicks.Found fresh = new PersonPicks.Found("f", "방금 올라옴", "C1", "KBS", 300, justNow, 50L);
|
||||||
|
|
||||||
|
assertThat(PersonPicks.keep(List.of(fresh), Set.of(), null, 20))
|
||||||
|
.extracting(PersonPicks.Found::videoId).containsExactly("f");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void 조회수나_업로드일을_모르면_배제하지_않는다() {
|
||||||
|
PersonPicks.Found unknown = new PersonPicks.Found("u", "t", "C1", "ch", 300, null, null);
|
||||||
|
|
||||||
|
assertThat(PersonPicks.keep(List.of(unknown), Set.of(), null, 20)).hasSize(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void null_입력과_빈_videoId_방어() {
|
||||||
|
List<PersonPicks.Found> found = new java.util.ArrayList<>();
|
||||||
|
found.add(null);
|
||||||
|
found.add(f("", 300, "C1", NOW));
|
||||||
|
found.add(f("ok", 300, "C1", NOW));
|
||||||
|
|
||||||
|
assertThat(PersonPicks.keep(found, Set.of(), null, 0))
|
||||||
|
.extracting(PersonPicks.Found::videoId).containsExactly("ok");
|
||||||
|
assertThat(PersonPicks.keep(null, Set.of(), null, 0)).isEmpty();
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user