From 7a954907d7087818bfe7de7332d8661ddcff2d1c Mon Sep 17 00:00:00 2001 From: "hehihoho3@gmail.com" Date: Sun, 2 Aug 2026 22:38:48 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=9D=B8=EB=AC=BC=20=EC=B6=94=EC=A0=81?= =?UTF-8?q?=20=E2=80=94=20=ED=94=BC=EB=93=9C=EC=97=90=20'=EC=9D=B8?= =?UTF-8?q?=EB=AC=BC'=20=ED=83=AD=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 인물은 프로그램을 옮겨 다녀서 채널 시드로는 못 잡는다. 실측으로 확인했다: 이미 모은 롱폼 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) --- .../yanalyst/domain/channel/ChannelVideo.java | 39 +++ .../channel/ChannelVideoRepository.java | 23 ++ .../domain/channel/FeedController.java | 44 ++- .../yanalyst/domain/channel/FeedService.java | 86 +++++- .../channel/PersonCollectionService.java | 214 ++++++++++++++ .../yanalyst/domain/channel/PersonPicks.java | 63 ++++ .../domain/channel/TrackedPerson.java | 60 ++++ .../channel/TrackedPersonRepository.java | 14 + .../domain/channel/dto/FeedItemDto.java | 3 + .../domain/channel/dto/TrackedPersonDto.java | 13 + src/main/resources/application.yml | 9 + src/main/resources/templates/feed.html | 275 +++++++++++++++--- .../domain/channel/PersonPicksTest.java | 104 +++++++ 13 files changed, 899 insertions(+), 48 deletions(-) create mode 100644 src/main/java/com/hlab/yanalyst/domain/channel/PersonCollectionService.java create mode 100644 src/main/java/com/hlab/yanalyst/domain/channel/PersonPicks.java create mode 100644 src/main/java/com/hlab/yanalyst/domain/channel/TrackedPerson.java create mode 100644 src/main/java/com/hlab/yanalyst/domain/channel/TrackedPersonRepository.java create mode 100644 src/main/java/com/hlab/yanalyst/domain/channel/dto/TrackedPersonDto.java create mode 100644 src/test/java/com/hlab/yanalyst/domain/channel/PersonPicksTest.java diff --git a/src/main/java/com/hlab/yanalyst/domain/channel/ChannelVideo.java b/src/main/java/com/hlab/yanalyst/domain/channel/ChannelVideo.java index 135870c..0ddec2c 100644 --- a/src/main/java/com/hlab/yanalyst/domain/channel/ChannelVideo.java +++ b/src/main/java/com/hlab/yanalyst/domain/channel/ChannelVideo.java @@ -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; diff --git a/src/main/java/com/hlab/yanalyst/domain/channel/ChannelVideoRepository.java b/src/main/java/com/hlab/yanalyst/domain/channel/ChannelVideoRepository.java index a942d3e..70fbf35 100644 --- a/src/main/java/com/hlab/yanalyst/domain/channel/ChannelVideoRepository.java +++ b/src/main/java/com/hlab/yanalyst/domain/channel/ChannelVideoRepository.java @@ -48,6 +48,29 @@ public interface ChannelVideoRepository extends JpaRepository '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 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 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 " diff --git a/src/main/java/com/hlab/yanalyst/domain/channel/FeedController.java b/src/main/java/com/hlab/yanalyst/domain/channel/FeedController.java index 94df4b0..e59c35e 100644 --- a/src/main/java/com/hlab/yanalyst/domain/channel/FeedController.java +++ b/src/main/java/com/hlab/yanalyst/domain/channel/FeedController.java @@ -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> 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> persons() { + return ApiResponse.ok(feedService.persons()); + } + + @PostMapping("/persons") + @Operation(summary = "추적 인물 추가", description = "1명당 검색 100 units 라 활성 인원에 상한이 있다") + public ApiResponse addPerson(@RequestBody Map body) { + return ApiResponse.created(feedService.addPerson(body.get("name"))); + } + + @PostMapping("/persons/{id}/toggle") + @Operation(summary = "추적 on/off", description = "끄면 수집 대상에서 빠지고 이미 담은 영상은 남는다") + public ApiResponse togglePerson(@PathVariable Long id, @RequestParam boolean enabled) { + feedService.togglePerson(id, enabled); + return ApiResponse.ok(null); + } + + @DeleteMapping("/persons/{id}") + @Operation(summary = "추적 해제", description = "인물만 목록에서 지우고 담긴 영상은 남긴다") + public ApiResponse removePerson(@PathVariable Long id) { + feedService.removePerson(id); + return ApiResponse.ok(null); + } + + @PostMapping("/persons/collect") + @Operation(summary = "인물 수집 실행", description = "활성 인물 전체를 즉시 1회 수집. 1명당 101 units.") + public ApiResponse> collectPersons() { + return ApiResponse.ok(personCollectionService.collectAll()); } @GetMapping("/programs") diff --git a/src/main/java/com/hlab/yanalyst/domain/channel/FeedService.java b/src/main/java/com/hlab/yanalyst/domain/channel/FeedService.java index 5347e69..599f9dc 100644 --- a/src/main/java/com/hlab/yanalyst/domain/channel/FeedService.java +++ b/src/main/java/com/hlab/yanalyst/domain/channel/FeedService.java @@ -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 feed(String tab, Integer days, String ytChannelId, String lengthBucket, boolean hideWorked) { + return feed(tab, days, ytChannelId, lengthBucket, hideWorked, null); + } + + /** + * @param person 인물 탭에서 특정 인물만 (null/빈값이면 전체) + */ + public List 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 rows = channelVideoRepository.feedByPerson( + publishedAfter, personFilter, hideWorked, PageRequest.of(0, MAX_ITEMS)); + List 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 persons() { + Map counts = new LinkedHashMap<>(); + for (Object[] row : channelVideoRepository.feedPersons()) { + counts.put((String) row[0], (Long) row[1]); + } + List 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); } } diff --git a/src/main/java/com/hlab/yanalyst/domain/channel/PersonCollectionService.java b/src/main/java/com/hlab/yanalyst/domain/channel/PersonCollectionService.java new file mode 100644 index 0000000..fb3e8e7 --- /dev/null +++ b/src/main/java/com/hlab/yanalyst/domain/channel/PersonCollectionService.java @@ -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; + +/** + * 인물 추적 수집기. + * + *

인물은 프로그램을 옮겨 다닌다. 채널 시드만으로는 "윤경호가 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 collectAll() { + List people = personRepository.findByEnabledTrueOrderByIdAsc(); + if (people.size() > maxPeople) people = people.subList(0, maxPeople); + + LocalDateTime publishedAfter = LocalDateTime.now().minusDays(periodDays); + Set exclude = excludedChannelIds(); + + int searched = 0, saved = 0, skippedByQuota = 0, failed = 0; + List 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 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 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 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 found = fetchDetails(videoIds); + List 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 fetchDetails(List 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 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 excludedChannelIds() { + Set 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); + }); + } +} diff --git a/src/main/java/com/hlab/yanalyst/domain/channel/PersonPicks.java b/src/main/java/com/hlab/yanalyst/domain/channel/PersonPicks.java new file mode 100644 index 0000000..31b1fd1 --- /dev/null +++ b/src/main/java/com/hlab/yanalyst/domain/channel/PersonPicks.java @@ -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; + +/** + * 인물 검색 결과에서 실제로 담을 영상을 고르는 순수 로직. + * + *

검색은 최신순 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 keep(List found, Set excludeChannels, + LocalDateTime publishedAfter, double minViewsPerHour) { + List 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; + } +} diff --git a/src/main/java/com/hlab/yanalyst/domain/channel/TrackedPerson.java b/src/main/java/com/hlab/yanalyst/domain/channel/TrackedPerson.java new file mode 100644 index 0000000..4370b02 --- /dev/null +++ b/src/main/java/com/hlab/yanalyst/domain/channel/TrackedPerson.java @@ -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; + +/** + * 추적 대상 인물. 인물은 프로그램을 옮겨 다니므로 채널만 쫓으면 놓치는 소재를 잡아낸다. + * + *

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; + } +} diff --git a/src/main/java/com/hlab/yanalyst/domain/channel/TrackedPersonRepository.java b/src/main/java/com/hlab/yanalyst/domain/channel/TrackedPersonRepository.java new file mode 100644 index 0000000..57312e4 --- /dev/null +++ b/src/main/java/com/hlab/yanalyst/domain/channel/TrackedPersonRepository.java @@ -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 { + Optional findByName(String name); + boolean existsByName(String name); + List findByEnabledTrueOrderByIdAsc(); + List findAllByOrderByIdAsc(); + long countByEnabledTrue(); +} diff --git a/src/main/java/com/hlab/yanalyst/domain/channel/dto/FeedItemDto.java b/src/main/java/com/hlab/yanalyst/domain/channel/dto/FeedItemDto.java index f9f9ca6..4b73790 100644 --- a/src/main/java/com/hlab/yanalyst/domain/channel/dto/FeedItemDto.java +++ b/src/main/java/com/hlab/yanalyst/domain/channel/dto/FeedItemDto.java @@ -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()), diff --git a/src/main/java/com/hlab/yanalyst/domain/channel/dto/TrackedPersonDto.java b/src/main/java/com/hlab/yanalyst/domain/channel/dto/TrackedPersonDto.java new file mode 100644 index 0000000..e3b7724 --- /dev/null +++ b/src/main/java/com/hlab/yanalyst/domain/channel/dto/TrackedPersonDto.java @@ -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) {} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 4db4118..0f9cfca 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -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: diff --git a/src/main/resources/templates/feed.html b/src/main/resources/templates/feed.html index ce46bb9..7d91931 100644 --- a/src/main/resources/templates/feed.html +++ b/src/main/resources/templates/feed.html @@ -14,6 +14,9 @@

소재 원본(웹예능 롱폼)과 경쟁 쇼츠의 신규 업로드를 최신순으로 봅니다.

+ @@ -37,16 +40,27 @@ 경쟁 쇼츠 예능짤 채널 +
-
+

+ 내 채널 성과가 좋은 인물부터 넣으세요. +

+ +
+ +
+ 추적 중인 인물 + +
+
로딩 중...
+
+ + +
@@ -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 = '' + list.map(p => + ``).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('공식클립'); + if(currentTab === 'RIVAL'){ + if(it.rising) out.push('떡상중'); + } else { + // 인물 탭에서는 누구 때문에 걸렸는지가 제일 중요한 정보다 + if(currentTab === 'PERSON' && it.matchedPerson) + out.push(`${esc(it.matchedPerson)}`); + if(it.lengthBucket === 'CLIP') out.push('공식클립'); else if(it.lengthBucket === 'FULL') out.push('풀에피'); - } else if(it.rising){ - out.push('떡상중'); } if(it.worked) out.push(`작업함`); return out.join(''); @@ -452,12 +526,13 @@ const vph = (currentTab === 'RIVAL' && it.viewsPerHour != null) ? `·${fmtNum(Math.round(it.viewsPerHour))}/h` : ''; - // 소재 원본은 재가공 스튜디오로, 경쟁 쇼츠는 원본 보기로 (남의 쇼츠는 재가공 대상이 아님) - const primary = currentTab === 'SOURCE' - ? ` - 작업 시작` - : ` - 원본 보기`; + // 롱폼 소재(소스·인물)는 재가공 스튜디오로, 경쟁 쇼츠는 원본 보기로 + // (남의 쇼츠는 재가공 대상이 아님) + const primary = currentTab === 'RIVAL' + ? ` + 원본 보기` + : ` + 작업 시작`; return `
시드 등록하기 - `); + if(currentTab === 'PERSON'){ + showState('user-search', '추적 중인 인물이 없습니다', + '인물은 프로그램을 옮겨 다녀서 채널 시드로는 못 잡습니다. 이름을 등록하면 등장한 롱폼을 찾아옵니다.', + ``); + } else if(currentTab === 'SOURCE'){ + showState('inbox', '아직 소재가 없습니다', + '소재 원본 채널(유퀴즈·핑계고 같은 웹예능 공식채널)을 시드로 등록한 뒤 수집하세요.', + ` + `); + } else { + showState('inbox', '아직 소재가 없습니다', + '경쟁 예능짤 쇼츠 채널을 시드로 등록한 뒤 수집하세요.', + ` + `); + } 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 = '
로딩 중...
'; + let list = []; + try { list = await api(API + '/persons') || []; } + catch(e){ box.innerHTML = `
불러오기 실패: ${esc(e.message)}
`; return; } + + if(list.length === 0){ + box.innerHTML = '
추적 중인 인물이 없습니다. 위에서 이름을 추가하세요.
'; + return; + } + box.innerHTML = list.map(p => { + const last = p.lastCollectedAt + ? `${fmtAgo(p.lastCollectedAt)} 수집 · ${p.lastFound ?? 0}건` + : '아직 수집 전'; + return `
+
+ ${esc(p.name)} + ${p.videoCount}건 담김 · ${last} +
+ ${p.enabled ? '추적중' : '중지'} + + +
`; + }).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 = ' 수집 중...'; + 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(); })(); /*]]>*/ diff --git a/src/test/java/com/hlab/yanalyst/domain/channel/PersonPicksTest.java b/src/test/java/com/hlab/yanalyst/domain/channel/PersonPicksTest.java new file mode 100644 index 0000000..c6d3d05 --- /dev/null +++ b/src/test/java/com/hlab/yanalyst/domain/channel/PersonPicksTest.java @@ -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 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 kept = PersonPicks.keep(found, Set.of(), null, 0); + + assertThat(kept).extracting(PersonPicks.Found::videoId).containsExactly("c", "d"); + } + + @Test + void 길이를_모르면_버린다() { + // 상세 조회 실패로 길이를 못 채운 건 롱폼인지 알 수 없으므로 담지 않는다 + List kept = PersonPicks.keep(List.of(f("a", null, "C1", NOW)), Set.of(), null, 0); + + assertThat(kept).isEmpty(); + } + + @Test + void 내채널과_경쟁채널_영상은_제외() { + List found = List.of( + f("a", 300, "OWN_CH", NOW), + f("b", 300, "RIVAL_CH", NOW), + f("c", 300, "OTHER", NOW)); + + List 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 found = List.of( + f("old", 300, "C1", cutoff.minusDays(1)), + f("new", 300, "C1", cutoff.plusDays(1))); + + List 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 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 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(); + } +}