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:
hehihoho3@gmail.com 2026-08-02 22:38:48 +09:00
parent e5205d9e9c
commit 7a954907d7
13 changed files with 899 additions and 48 deletions

View File

@ -82,6 +82,13 @@ public class ChannelVideo {
@Column(name = "hashtags", columnDefinition = "TEXT")
private String hashtags;
/**
* 인물 추적으로 걸린 영상이면 인물명. 인물 탭은 source 아니라 값으로 조회하므로,
* 소스 채널에서 이미 수집한 영상이 인물 검색에도 걸리면 모두에 나타난다.
*/
@Column(name = "matched_person", length = 100)
private String matchedPerson;
// --- 큐레이션(분류/관리) 필드 ---
/** 분류 카테고리 ID (categories.id 참조, 느슨한 연결). */
@ -165,6 +172,38 @@ public class ChannelVideo {
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 수집물용. */
public void applyChannelInfoKeepSource(String ytChannelId, String channelTitle, Long subscriberCount, BigDecimal viewsPerSubRatio) {
this.ytChannelId = ytChannelId;

View File

@ -48,6 +48,29 @@ public interface ChannelVideoRepository extends JpaRepository<ChannelVideo, Long
@Param("hideWorked") boolean hideWorked,
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, 영상수]. 영상 많은 순. */
@Query("select v.ytChannelId, min(v.channelTitle), count(v) from ChannelVideo v "
+ "where v.source = :source and v.ytChannelId is not null "

View File

@ -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.FeedProgramDto;
import com.hlab.yanalyst.domain.channel.dto.FeedSeedDto;
import com.hlab.yanalyst.domain.channel.dto.TrackedPersonDto;
import com.hlab.yanalyst.global.common.ApiResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
@ -22,15 +23,52 @@ public class FeedController {
private final FeedService feedService;
private final FeedCollectionService feedCollectionService;
private final SeedSuggestService seedSuggestService;
private final PersonCollectionService personCollectionService;
@GetMapping
@Operation(summary = "피드 조회", description = "tab=SOURCE(롱폼 소재)|RIVAL(경쟁 쇼츠). 항상 최신순.")
@Operation(summary = "피드 조회",
description = "tab=SOURCE(롱폼 소재)|RIVAL(경쟁 쇼츠)|PERSON(인물 추적 롱폼). 항상 최신순.")
public ApiResponse<List<FeedItemDto>> feed(@RequestParam(defaultValue = "SOURCE") String tab,
@RequestParam(required = false) Integer days,
@RequestParam(required = false) String channelId,
@RequestParam(required = false) String lengthBucket,
@RequestParam(defaultValue = "false") boolean hideWorked) {
return ApiResponse.ok(feedService.feed(tab, days, channelId, lengthBucket, hideWorked));
@RequestParam(defaultValue = "false") boolean 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")

View File

@ -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.FeedProgramDto;
import com.hlab.yanalyst.domain.channel.dto.FeedSeedDto;
import com.hlab.yanalyst.domain.channel.dto.TrackedPersonDto;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.PageRequest;
@ -11,9 +12,11 @@ import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/** 소재 발굴 피드 조회 + 시드(소스/경쟁 채널) 관리. */
/** 소재 발굴 피드 조회 + 시드(소스/경쟁 채널) · 추적 인물 관리. */
@Slf4j
@Service
@RequiredArgsConstructor
@ -23,9 +26,16 @@ public class FeedService {
/** 한 번에 내려줄 카드 수 상한. */
private static final int MAX_ITEMS = 200;
/** 인물 탭 식별자. SOURCE/RIVAL 과 달리 채널 역할이 아니라 조회 방식이 다르다. */
public static final String TAB_PERSON = "PERSON";
private final ChannelRepository channelRepository;
private final ChannelVideoRepository channelVideoRepository;
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,
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);
LocalDateTime now = LocalDateTime.now();
LocalDateTime publishedAfter = (days == null || days <= 0) ? null : now.minusDays(days);
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;
if (lengthBucket != null && !lengthBucket.isBlank()) {
switch (lengthBucket.trim().toUpperCase()) {
@ -121,6 +148,61 @@ public class FeedService {
private String normalizeTab(String tab) {
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);
}
}

View File

@ -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);
});
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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();
}

View File

@ -24,6 +24,8 @@ public record FeedItemDto(
String interestStatus,
boolean bookmarked,
String source,
/** 인물 추적으로 걸렸다면 그 인물명. 인물 탭 카드에 배지로 표시한다. */
String matchedPerson,
/** 업로드 24시간 이내 — 선점 골든타임. */
boolean goldenTime,
/** SHORTS | CLIP | FULL | UNKNOWN */
@ -48,6 +50,7 @@ public record FeedItemDto(
v.getInterestStatus(),
v.isBookmarked(),
v.getSource(),
v.getMatchedPerson(),
FeedBadges.isGoldenTime(v.getPublishedAt(), now),
FeedBadges.lengthBucket(v.getDurationSec()),
FeedBadges.isRising(v.getViewsPerHour()),

View File

@ -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) {}

View File

@ -108,6 +108,15 @@ hlab:
seed:
max-keywords: ${FEED_SEED_MAX_KEYWORDS:8} # 시드 자동 발굴 시 사용할 상위 해시태그 수(1개당 100 units)
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.
notify:

View File

@ -14,6 +14,9 @@
<p class="sub">소재 원본(웹예능 롱폼)과 경쟁 쇼츠의 신규 업로드를 최신순으로 봅니다.</p>
</div>
<div class="actions">
<button class="btn btn-secondary" onclick="openPersons()">
<i data-lucide="user-search" style="width:15px;"></i> 인물 관리
</button>
<button class="btn btn-secondary" onclick="openSeeds()">
<i data-lucide="list-plus" style="width:15px;"></i> 시드 관리
</button>
@ -37,16 +40,27 @@
<span>경쟁 쇼츠</span>
<span class="feed-tab-hint">예능짤 채널</span>
</button>
<button class="feed-tab" role="tab" id="tab-PERSON" data-tab="PERSON"
aria-selected="false" aria-controls="feedGrid" tabindex="-1" onclick="switchTab('PERSON')">
<i data-lucide="user-search" style="width:15px;"></i>
<span>인물</span>
<span class="feed-tab-hint">출연자 추적</span>
</button>
</div>
<!-- 필터 -->
<div class="card mb-4">
<div class="toolbar">
<label class="field" for="fProgram">프로그램
<label class="field" for="fProgram" id="programField">프로그램
<select id="fProgram" onchange="applyFilters()">
<option value="">전체</option>
</select>
</label>
<label class="field" for="fPerson" id="personField" style="display:none;">인물
<select id="fPerson" onchange="applyFilters()">
<option value="">전체</option>
</select>
</label>
<label class="field" for="fLength" id="lengthField">길이
<select id="fLength" onchange="applyFilters()">
<option value="">전체</option>
@ -119,6 +133,42 @@
</div>
</div>
<!-- 인물 관리 모달 -->
<div id="personModal" class="modal-overlay" onclick="if(event.target===this) closePersons()">
<div class="modal-card" role="dialog" aria-modal="true" aria-labelledby="personModalTitle">
<div class="modal-head">
<h3 id="personModalTitle">인물 관리</h3>
<button class="modal-close" onclick="closePersons()" aria-label="닫기">&times;</button>
</div>
<div class="modal-body">
<p class="text-sm text-muted mb-3" style="line-height:1.6;">
인물은 프로그램을 옮겨 다닙니다. 채널 시드로는 못 잡는 등장분을 이름으로 검색해 찾습니다.
<b>1명당 검색 101 units</b> 라 하루 1회만 돌고, 활성 인물에 상한이 있습니다.
</p>
<div class="seed-add">
<label class="sr-only" for="personName">인물 이름</label>
<input id="personName" type="text" placeholder="예: 윤경호">
<button class="btn btn-primary" id="personAddBtn" onclick="addPerson()">추가</button>
</div>
<p class="text-xs text-muted mt-2" id="personAddHelp">
내 채널 성과가 좋은 인물부터 넣으세요.
</p>
<div class="seed-divider"></div>
<div class="flex items-center justify-between mb-2">
<span class="text-sm font-semibold">추적 중인 인물</span>
<button class="btn btn-secondary" style="padding:0.35rem 0.6rem;" id="personCollectBtn"
onclick="collectPersons()" title="활성 인물 전체를 지금 수집합니다">
<i data-lucide="search" style="width:14px;"></i> 지금 수집
</button>
</div>
<div id="personList" class="seed-list"><div class="text-sm text-muted p-4">로딩 중...</div></div>
</div>
</div>
</div>
<!-- 토스트 -->
<div id="toast" class="toast" role="status" aria-live="polite"></div>
@ -331,28 +381,37 @@
}
// ---------- 탭 ----------
function switchTab(tab){
if(tab === currentTab) return;
currentTab = tab;
const TABS = ['SOURCE', 'RIVAL', 'PERSON'];
// 탭별로 의미 있는 필터만 남긴다 (경쟁은 전부 쇼츠, 인물은 채널이 아니라 사람 기준)
function applyTabChrome(tab){
document.querySelectorAll('.feed-tab').forEach(btn => {
const on = btn.dataset.tab === tab;
btn.setAttribute('aria-selected', on ? 'true' : 'false');
btn.tabIndex = on ? 0 : -1;
});
document.getElementById('feedGrid').setAttribute('aria-labelledby', 'tab-' + tab);
// 경쟁 탭은 전부 쇼츠라 길이 필터가 의미 없다
const lf = document.getElementById('lengthField');
lf.style.display = (tab === 'RIVAL') ? 'none' : '';
if(tab === 'RIVAL') document.getElementById('fLength').value = '';
// 탭마다 프로그램 목록이 다르므로 선택을 비우고 다시 채운다
document.getElementById('lengthField').style.display = (tab === 'SOURCE') ? '' : 'none';
document.getElementById('programField').style.display = (tab === 'PERSON') ? 'none' : '';
document.getElementById('personField').style.display = (tab === 'PERSON') ? '' : 'none';
if(tab !== 'SOURCE') document.getElementById('fLength').value = '';
}
function switchTab(tab){
if(tab === currentTab) return;
currentTab = tab;
applyTabChrome(tab);
// 탭마다 필터 목록이 다르므로 선택을 비우고 다시 채운다
document.getElementById('fProgram').value = '';
loadPrograms().then(applyFilters);
document.getElementById('fPerson').value = '';
loadFilterOptions().then(applyFilters);
}
document.querySelector('.feed-tabs').addEventListener('keydown', e => {
if(e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return;
e.preventDefault();
const 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);
document.getElementById('tab-' + next).focus();
});
@ -362,6 +421,7 @@
return {
tab: currentTab,
channelId: document.getElementById('fProgram').value,
person: document.getElementById('fPerson').value,
lengthBucket: document.getElementById('fLength').value,
days: document.getElementById('fDays').value,
hideWorked: document.getElementById('fHideWorked').checked
@ -373,7 +433,8 @@
// 새로고침·뒤로가기에서 필터가 보존되도록 URL 에 반영
const q = new URLSearchParams();
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.days !== '7') q.set('days', f.days);
if(f.hideWorked) q.set('hideWorked', 'true');
@ -384,22 +445,21 @@
function restoreFilters(){
const q = new URLSearchParams(location.search);
const tab = q.get('tab');
if(tab === 'RIVAL'){
currentTab = 'RIVAL';
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(TABS.includes(tab) && tab !== 'SOURCE') currentTab = tab;
applyTabChrome(currentTab);
if(q.get('days') != null) document.getElementById('fDays').value = q.get('days');
if(q.get('lengthBucket')) document.getElementById('fLength').value = q.get('lengthBucket');
if(q.get('hideWorked') === 'true') document.getElementById('fHideWorked').checked = true;
return 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){
const sel = document.getElementById('fProgram');
const keep = preselect || sel.value;
@ -410,13 +470,24 @@
if(keep && [...sel.options].some(o => o.value === keep)) sel.value = keep;
}
async function loadPersonOptions(preselect){
const sel = document.getElementById('fPerson');
const keep = preselect || sel.value;
let list = [];
try { list = await api(API + '/persons') || []; } catch(e){ list = []; }
sel.innerHTML = '<option value="">전체</option>' + list.map(p =>
`<option value="${esc(p.name)}">${esc(p.name)} (${p.videoCount})</option>`).join('');
if(keep && [...sel.options].some(o => o.value === keep)) sel.value = keep;
}
// ---------- 피드 ----------
async function loadFeed(){
showSkeleton();
const f = readFilters();
const p = new URLSearchParams();
p.set('tab', f.tab);
if(f.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.days && f.days !== '0') p.set('days', f.days);
if(f.hideWorked) p.set('hideWorked', 'true');
@ -434,11 +505,14 @@
function badgeHtml(it){
const out = [];
if(currentTab === 'SOURCE'){
if(it.lengthBucket === 'CLIP') out.push('<span class="badge badge-primary">공식클립</span>');
if(currentTab === 'RIVAL'){
if(it.rising) out.push('<span class="badge badge-warning"><i data-lucide="trending-up" style="width:11px;"></i>떡상중</span>');
} else {
// 인물 탭에서는 누구 때문에 걸렸는지가 제일 중요한 정보다
if(currentTab === 'PERSON' && it.matchedPerson)
out.push(`<span class="badge badge-primary">${esc(it.matchedPerson)}</span>`);
if(it.lengthBucket === 'CLIP') out.push('<span class="badge badge-muted">공식클립</span>');
else if(it.lengthBucket === 'FULL') out.push('<span class="badge badge-muted">풀에피</span>');
} 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>`);
return out.join('');
@ -452,12 +526,13 @@
const vph = (currentTab === 'RIVAL' && it.viewsPerHour != null)
? `<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}">
<i data-lucide="wand-2" style="width:14px;"></i> 작업 시작</a>`
: `<a class="btn btn-secondary grow" href="${url}" target="_blank" rel="noopener">
<i data-lucide="external-link" style="width:14px;"></i> 원본 보기</a>`;
// 롱폼 소재(소스·인물)는 재가공 스튜디오로, 경쟁 쇼츠는 원본 보기로
// (남의 쇼츠는 재가공 대상이 아님)
const primary = currentTab === 'RIVAL'
? `<a class="btn btn-secondary grow" href="${url}" target="_blank" rel="noopener">
<i data-lucide="external-link" style="width:14px;"></i> 원본 보기</a>`
: `<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}">
<a class="fthumb" href="${url}" target="_blank" rel="noopener"
@ -496,13 +571,21 @@
function render(){
document.getElementById('resultCount').textContent = items.length + '건';
if(items.length === 0){
const isSource = currentTab === 'SOURCE';
showState('inbox', '아직 소재가 없습니다',
isSource
? '소재 원본 채널(유퀴즈·핑계고 같은 웹예능 공식채널)을 시드로 등록한 뒤 수집하세요.'
: '경쟁 예능짤 쇼츠 채널을 시드로 등록한 뒤 수집하세요.',
`<button class="btn btn-primary" onclick="openSeeds()">시드 등록하기</button>
<button class="btn btn-secondary" onclick="runCollect()">지금 수집</button>`);
if(currentTab === 'PERSON'){
showState('user-search', '추적 중인 인물이 없습니다',
'인물은 프로그램을 옮겨 다녀서 채널 시드로는 못 잡습니다. 이름을 등록하면 등장한 롱폼을 찾아옵니다.',
`<button class="btn btn-primary" onclick="openPersons()">인물 등록하기</button>`);
} else if(currentTab === 'SOURCE'){
showState('inbox', '아직 소재가 없습니다',
'소재 원본 채널(유퀴즈·핑계고 같은 웹예능 공식채널)을 시드로 등록한 뒤 수집하세요.',
`<button class="btn btn-primary" onclick="openSeeds()">시드 등록하기</button>
<button class="btn btn-secondary" onclick="runCollect()">지금 수집</button>`);
} else {
showState('inbox', '아직 소재가 없습니다',
'경쟁 예능짤 쇼츠 채널을 시드로 등록한 뒤 수집하세요.',
`<button class="btn btn-primary" onclick="openSeeds()">시드 등록하기</button>
<button class="btn btn-secondary" onclick="runCollect()">지금 수집</button>`);
}
return;
}
hideState();
@ -674,12 +757,118 @@
} 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 ----------
(async () => {
const preselect = restoreFilters();
await loadPrograms(preselect);
await loadFilterOptions(preselect);
await loadFeed();
})();
/*]]>*/

View File

@ -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();
}
}