feat: 소재 발굴 피드 — 소스 롱폼/경쟁 쇼츠 최신순 2탭
쇼츠 클립 채널의 소재를 최신순으로 발굴하는 /feed 화면과 수집 파이프라인. - 소재 원본 탭: 웹예능 공식채널의 신규 롱폼(durationSec > 65)만 수집. 아직 아무도 안 자른 구간을 선점하는 용도라 공식계정 쇼츠는 제외한다. - 경쟁 쇼츠 탭: 예능짤 채널의 신규 쇼츠. 제목·썸네일 벤치마킹용이라 재가공 대신 원본 열기 액션을 준다. - Channel.role(MY/SOURCE/RIVAL) 컬럼 하나로 역할을 구분하고, 기존 uploads 플레이리스트 동기화를 그대로 재사용한다. 채널당 2 units라 search.list(100 units) 대비 쿼터가 거의 들지 않는다. - 3시간 주기 수집(FeedCollectionService). 소재 선점은 업로드 직후가 승부라 기존 일 1회 채널 수집으로는 늦다. - 수집함/발굴/떡상 후보 쿼리는 source 미지정 시 CHANNEL·SEARCH만 보도록 좁혀, 피드 영상이 기존 화면을 덮지 않게 격리했다. - 시드는 자동 후보 → 수동 승인. 내 채널 해시태그를 역분석해(HashtagExtractor) 공식채널 후보를 추천 목록에 쌓고, 승인 시 SOURCE/RIVAL로 등록한다. - 연속 3회 수집 실패한 시드는 자동 스킵해 쿼터 낭비를 막는다. - role이 null인 기존 채널은 부팅 시 MY로 1회 백필(ddl-auto:update 특성). UX: 기존 Editorial 디자인 시스템 유지. 골든타임(24h)·공식클립/풀에피· 떡상중·작업함 배지, 프로그램/길이/기간 필터, URL 상태 보존, 스켈레톤· 빈 상태·에러 복구 액션, 탭 키보드 이동, 이모지 대신 Lucide 아이콘. 테스트: HashtagExtractor·FeedBadges·ChannelRole 순수 로직 16건 추가(총 76건 통과). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
8e6926a14b
commit
592ed12c2b
140
docs/superpowers/specs/2026-07-31-source-feed-design.md
Normal file
140
docs/superpowers/specs/2026-07-31-source-feed-design.md
Normal file
@ -0,0 +1,140 @@
|
||||
# 소재 발굴 피드 (Source Feed) 설계
|
||||
|
||||
작성일: 2026-07-31
|
||||
|
||||
## 배경
|
||||
|
||||
내 채널 `clipOut-log`(UCVJZ3_z7dqpUvaEgaWRltuA)는 한국 웹예능·토크쇼의 명장면을 잘라 올리는
|
||||
쇼츠 전문 채널이다. 최근 50개 업로드 분석 결과 소재의 단위는 **"웹예능 에피소드 안의 한 순간"**이며,
|
||||
반복 등장하는 소스는 유퀴즈·핑계고·미미미누·아는형님·살롱드립·짠한형 신동엽·요정식탁·어쩌다사장·
|
||||
워크맨·입만열면·차린건쥐뿔도없지만이다.
|
||||
|
||||
기존 발굴(`/discover`, `/recommend`)은 **조회수순 떡상 채널 발굴**이라 목적이 다르다.
|
||||
이 문서는 **"이런 소재를 최신순으로"** 를 위한 별도 피드를 정의한다.
|
||||
|
||||
## 목표
|
||||
|
||||
1. **선점** — 소스(웹예능 공식채널)의 신규 롱폼 업로드를 최신순으로 받아, 아직 아무도 안 자른 구간을 먼저 클립화
|
||||
2. **추격** — 경쟁 예능짤 쇼츠 채널의 신규 쇼츠를 최신순으로 보며 반응 검증된 소재·제목·썸네일 벤치마킹
|
||||
|
||||
범위 밖(후속): 자막 기반 "터지는 구간" 자동 제안(2단계).
|
||||
|
||||
## 결정 사항
|
||||
|
||||
| 항목 | 결정 | 이유 |
|
||||
|---|---|---|
|
||||
| 탭 구성 | 소재 원본 / 경쟁 쇼츠 2탭 | 선점과 추격을 같이 보되 성격이 달라 액션이 다름 |
|
||||
| 소스 탭 대상 | **롱폼만** (`durationSec > 65`) | 공식계정이 올린 쇼츠는 이미 잘린 결과물이라 소재 가치 없음 |
|
||||
| 경쟁 탭 대상 | 쇼츠만 (`isShorts = true`) | 벤치마킹 대상 |
|
||||
| 시드 확보 | 자동 후보 → 수동 승인(하이브리드) | 오탐 방지 + 자동 편재 |
|
||||
| 피드 깊이 | 1단계(영상 목록)까지 | YAGNI. 구간 제안은 별도 과제 |
|
||||
| 구현 방식 | 기존 채널 동기화 파이프라인 재사용 | 쿼터 60배 저렴, 재가공 스튜디오와 즉시 연결 |
|
||||
|
||||
## 아키텍처
|
||||
|
||||
### 데이터 모델
|
||||
|
||||
`Channel`에 역할 컬럼 하나만 추가한다.
|
||||
|
||||
```java
|
||||
@Column(length = 10)
|
||||
private String role = "MY"; // MY | SOURCE | RIVAL
|
||||
```
|
||||
|
||||
`ddl-auto: update`는 기존 행을 NULL로 남기므로, 조회는 전부 `COALESCE(role,'MY')` 의미로 처리하고
|
||||
앱 부팅 시 `role IS NULL → 'MY'` 1회 백필을 수행한다(프로젝트에 마이그레이션 파일이 없는 관례에 맞춤).
|
||||
|
||||
`ChannelVideo`는 컬럼 추가 없음. 기존 컬럼을 그대로 쓴다.
|
||||
|
||||
| 용도 | 기존 컬럼 |
|
||||
|---|---|
|
||||
| 피드 구분 | `source` — 기존 `CHANNEL`/`SEARCH` 에 `SOURCE`/`RIVAL` 값 추가 |
|
||||
| 최신순 정렬 | `publishedAt` |
|
||||
| 롱폼/쇼츠 구분 | `durationSec`, `isShorts` (`VideoMetrics.isShorts` = 65초 이하) |
|
||||
| 경쟁 떡상 배지 | `viewsPerHour` |
|
||||
| 이미 손댄 소재 | `interestStatus`, `bookmarked` |
|
||||
| 시드 역분석 재료 | `hashtags` |
|
||||
|
||||
`RecommendedChannel`에 `roleHint`(SOURCE/RIVAL), `seedKeyword` 추가 — 승인 UI를 재사용하기 위함.
|
||||
|
||||
### 수집
|
||||
|
||||
`FeedCollectionService` 신설. 기존 `ChannelService`의 uploads 플레이리스트 동기화를 재사용한다.
|
||||
|
||||
- 주기 **3시간** (`hlab.feed.cron`). 소재 선점은 업로드 후 몇 시간이 승부라 일 1회로는 늦음
|
||||
- 채널당 uploads **첫 페이지 50개**만 조회, `publishedAt`이 최근 N일(기본 14) 이내인 것만 upsert
|
||||
- 소스 채널 → 롱폼만 저장(`source='SOURCE'`), 경쟁 채널 → 쇼츠만 저장(`source='RIVAL'`)
|
||||
- `YoutubeQuotaGuard.tryConsume` 로 채널 단위 가드. 채널당 추정 2 units(playlistItems 1 + videos 1)
|
||||
- 연속 3회 실패한 채널은 `feedFailCount >= 3` 로 자동 스킵(UI에서 초기화 가능)
|
||||
|
||||
기존 일일 채널 수집(`ScheduledCollectionService.runChannelCollection`)은 **role=MY 만** 대상으로
|
||||
좁힌다. 백필 후 기존 채널은 전부 MY이므로 동작 변화 없음.
|
||||
|
||||
**수집함 격리** — `/collection`, `/discover`, 떡상 후보 쿼리는 `source` 를 명시하지 않은 경우
|
||||
`CHANNEL`/`SEARCH` 만 대상으로 한다. 피드 영상이 기존 화면을 덮지 않는다.
|
||||
|
||||
### 화면 `/feed`
|
||||
|
||||
정렬은 `publishedAt DESC` 고정(정렬 셀렉터 없음).
|
||||
|
||||
**[소재 원본] 탭** — `source='SOURCE'`
|
||||
- 카드: 썸네일 / 프로그램명 / 제목 / 상대시간 / 재생시간 / 조회수
|
||||
- 배지: `골든타임`(24시간 이내), `공식클립`(65초~15분) / `풀에피`(15분+), `작업함`(interestStatus != NEW)
|
||||
- 액션: 재가공(`/rework/{id}`), 숨김(EXCLUDED), 북마크
|
||||
|
||||
**[경쟁 쇼츠] 탭** — `source='RIVAL'`
|
||||
- 배지: `떡상중`(viewsPerHour 상위), `골든타임`
|
||||
- 액션: YouTube 열기, 북마크, 숨김 (남의 쇼츠는 재가공 대상이 아님)
|
||||
|
||||
공통 필터: 프로그램(채널) 선택 / 길이 / 기간(24h·3일·7일·14일) / 작업한 것 숨기기
|
||||
|
||||
### 시드 발굴
|
||||
|
||||
**소스 시드** — `SeedSuggestService`
|
||||
1. `role=MY` 영상의 `hashtags`·제목에서 해시태그 빈도 집계(`HashtagExtractor`, 순수 로직)
|
||||
2. 상위 키워드마다 `search.list type=channel&q=<키워드>` 조회 (100 units/건이라 **수동 실행 버튼**)
|
||||
3. `RecommendedChannel(roleHint=SOURCE, status=NEW)` upsert → `/recommend`에서 승인
|
||||
|
||||
**경쟁 시드** — 기존 `ChannelDiscoveryService` 결과를 `roleHint=RIVAL` 로 승인 등록
|
||||
|
||||
**수동 등록** — `POST /api/feed/seeds {url, role}`
|
||||
|
||||
### API
|
||||
|
||||
| 메서드 | 경로 | 설명 |
|
||||
|---|---|---|
|
||||
| GET | `/api/feed` | `tab=SOURCE\|RIVAL`, `days`, `channelId`, `lengthBucket`, `hideWorked` |
|
||||
| GET | `/api/feed/programs` | 필터용 채널 목록(피드에 영상이 있는 채널) |
|
||||
| POST | `/api/feed/collect` | 수동 수집 1회 |
|
||||
| GET | `/api/feed/seeds` | 등록된 시드 채널 목록 |
|
||||
| POST | `/api/feed/seeds` | 수동 시드 등록 `{url, role}` |
|
||||
| DELETE | `/api/feed/seeds/{id}` | 시드 해제(role → MY 로 되돌리지 않고 채널 삭제) |
|
||||
| POST | `/api/feed/seeds/{id}/reset-failure` | 실패 카운터 초기화 |
|
||||
| POST | `/api/feed/seeds/suggest` | 해시태그 역분석 → 소스 후보 발굴 |
|
||||
|
||||
### UX 원칙 (ui-ux-pro-max 적용)
|
||||
|
||||
기존 Editorial(almanac) 디자인 시스템을 그대로 따른다. 스킬이 제안한 OLED/핑크 팔레트는
|
||||
**일관성 규칙(§4 consistency)에 따라 채택하지 않는다.** 적용하는 것은 규칙 쪽이다.
|
||||
|
||||
- 아이콘은 Lucide SVG만 사용(§4 no-emoji-icons) — 배지에 이모지 금지
|
||||
- 터치 타겟 ≥44px, 카드 액션 간 8px 이상 간격(§2)
|
||||
- 썸네일에 `aspect-ratio` + `width/height` 지정으로 CLS 방지, `loading="lazy"`(§3)
|
||||
- 로딩은 스켈레톤(기존 `.skeleton` 재사용), 빈 상태·에러 상태 각각 안내 문구 + 복구 액션(§8)
|
||||
- 애니메이션 150–300ms, `prefers-reduced-motion` 존중(§7)
|
||||
- 탭은 `role="tablist"` + 키보드 좌우 이동, 현재 탭 `aria-selected`(§1, §9)
|
||||
- 상태를 색으로만 전달하지 않음 — 배지에 텍스트 라벨 병기(§1 color-not-only)
|
||||
- 필터 상태는 URL 쿼리에 반영해 새로고침·뒤로가기에서 보존(§9 state-preservation)
|
||||
|
||||
### 실패 처리
|
||||
|
||||
- 쿼터 소진 → 남은 채널 스킵, 요약에 `skippedByQuota` 보고
|
||||
- 채널 uploads 플레이리스트 없음/삭제 → 로그 후 스킵, `feedFailCount++`
|
||||
- 개별 영상 파싱 실패 → 해당 영상만 건너뜀
|
||||
|
||||
### 테스트
|
||||
|
||||
`src/test` 의 `DiscoveryRankerTest` 선례에 맞춰 **순수 로직만** 단위 테스트한다.
|
||||
|
||||
- `HashtagExtractorTest` — 해시태그 파싱·빈도 집계·불용어 제외
|
||||
- `FeedBadgesTest` — 골든타임/길이버킷/떡상 판정 경계값
|
||||
@ -46,6 +46,20 @@ public class Channel {
|
||||
@Column(length = 8)
|
||||
private String country; // YouTube snippet.country (ISO-3166 alpha-2, 예: KR/JP/US). 없으면 null
|
||||
|
||||
/**
|
||||
* 채널 역할: MY(내 채널) / SOURCE(소재 원본 웹예능) / RIVAL(경쟁 쇼츠).
|
||||
* ddl-auto:update 특성상 기존 행은 null 이므로 조회 시 null 은 MY 로 간주한다({@link ChannelRole#normalize}).
|
||||
*/
|
||||
@Column(length = 10)
|
||||
private String role = ChannelRole.MY;
|
||||
|
||||
/**
|
||||
* 피드 수집 연속 실패 횟수. 3회 이상이면 자동 스킵해 쿼터 낭비를 막는다.
|
||||
* 성공하면 0으로 리셋된다.
|
||||
*/
|
||||
@Column(name = "feed_fail_count")
|
||||
private Integer feedFailCount = 0;
|
||||
|
||||
private LocalDateTime publishedAt;
|
||||
|
||||
@CreatedDate
|
||||
@ -85,5 +99,32 @@ public class Channel {
|
||||
this.country = country;
|
||||
}
|
||||
|
||||
// Domain Logic methods here if needed
|
||||
/** 역할 변경. null/미인식 값은 MY 로 보정된다. */
|
||||
public void changeRole(String role) {
|
||||
this.role = ChannelRole.normalize(role);
|
||||
}
|
||||
|
||||
/** null 을 MY 로 보정한 실제 역할. */
|
||||
public String roleOrDefault() {
|
||||
return ChannelRole.normalize(this.role);
|
||||
}
|
||||
|
||||
public int feedFailCountOrZero() {
|
||||
return this.feedFailCount == null ? 0 : this.feedFailCount;
|
||||
}
|
||||
|
||||
/** 피드 수집 실패 누적. */
|
||||
public void recordFeedFailure() {
|
||||
this.feedFailCount = feedFailCountOrZero() + 1;
|
||||
}
|
||||
|
||||
/** 피드 수집 성공 — 실패 카운터 리셋. */
|
||||
public void resetFeedFailure() {
|
||||
this.feedFailCount = 0;
|
||||
}
|
||||
|
||||
/** 연속 실패로 자동 비활성화된 상태인지. */
|
||||
public boolean isFeedDisabled() {
|
||||
return feedFailCountOrZero() >= 3;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,9 +1,33 @@
|
||||
package com.hlab.yanalyst.domain.channel;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Modifying;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface ChannelRepository extends JpaRepository<Channel, Long> {
|
||||
Optional<Channel> findByChannelId(String channelId);
|
||||
boolean existsByChannelId(String channelId);
|
||||
|
||||
/** 내 채널(MY). role 이 null 인 레거시 행도 MY 로 취급한다. */
|
||||
@Query("select c from Channel c where c.role is null or c.role = 'MY'")
|
||||
List<Channel> findMyChannels();
|
||||
|
||||
/** 특정 역할의 채널. SOURCE/RIVAL 조회용. */
|
||||
List<Channel> findByRole(String role);
|
||||
|
||||
/** 피드 시드(SOURCE + RIVAL) 전체. */
|
||||
@Query("select c from Channel c where c.role in ('SOURCE','RIVAL') order by c.role asc, c.title asc")
|
||||
List<Channel> findFeedSeeds();
|
||||
|
||||
/** role 이 null 인 레거시 행을 MY 로 백필한다. @return 갱신된 행 수 */
|
||||
@Modifying
|
||||
@Query("update Channel c set c.role = 'MY' where c.role is null")
|
||||
int backfillNullRoles();
|
||||
|
||||
@Query("select c from Channel c where c.channelId in :channelIds")
|
||||
List<Channel> findByChannelIdIn(@Param("channelIds") List<String> channelIds);
|
||||
}
|
||||
|
||||
@ -0,0 +1,42 @@
|
||||
package com.hlab.yanalyst.domain.channel;
|
||||
|
||||
/**
|
||||
* 등록 채널의 역할.
|
||||
*
|
||||
* <ul>
|
||||
* <li>{@link #MY} — 내 채널(성과 추적 대상). 기존 등록 채널은 전부 여기에 해당한다.</li>
|
||||
* <li>{@link #SOURCE} — 소재 원본 채널(웹예능 공식채널). 롱폼만 피드에 수집한다.</li>
|
||||
* <li>{@link #RIVAL} — 경쟁 쇼츠 채널(같은 소재를 다루는 클립 채널). 쇼츠만 피드에 수집한다.</li>
|
||||
* </ul>
|
||||
*
|
||||
* ddl-auto:update 로 컬럼이 추가되므로 기존 행은 role 이 null 이다. null 은 항상 {@link #MY} 로 취급한다.
|
||||
*/
|
||||
public final class ChannelRole {
|
||||
|
||||
public static final String MY = "MY";
|
||||
public static final String SOURCE = "SOURCE";
|
||||
public static final String RIVAL = "RIVAL";
|
||||
|
||||
private ChannelRole() {}
|
||||
|
||||
/** null/공백/미인식 값을 MY 로 보정하고 대문자로 정규화한다. */
|
||||
public static String normalize(String role) {
|
||||
if (role == null || role.isBlank()) return MY;
|
||||
String r = role.trim().toUpperCase();
|
||||
return switch (r) {
|
||||
case SOURCE, RIVAL, MY -> r;
|
||||
default -> MY;
|
||||
};
|
||||
}
|
||||
|
||||
/** 피드(소재 원본/경쟁) 역할인지. */
|
||||
public static boolean isFeed(String role) {
|
||||
String r = normalize(role);
|
||||
return SOURCE.equals(r) || RIVAL.equals(r);
|
||||
}
|
||||
|
||||
/** 해당 역할이 수집할 영상 포맷 — SOURCE 는 롱폼만, RIVAL 은 쇼츠만. */
|
||||
public static boolean acceptsShorts(String role) {
|
||||
return RIVAL.equals(normalize(role));
|
||||
}
|
||||
}
|
||||
@ -213,8 +213,9 @@ public class ChannelService {
|
||||
return url;
|
||||
}
|
||||
|
||||
/** 내 채널 목록. 피드 시드(SOURCE/RIVAL)는 /feed 에서 따로 관리하므로 제외한다. */
|
||||
public List<Channel> getAllChannels() {
|
||||
return channelRepository.findAll();
|
||||
return channelRepository.findMyChannels();
|
||||
}
|
||||
|
||||
public Channel getChannel(Long id) {
|
||||
@ -289,12 +290,26 @@ public class ChannelService {
|
||||
}
|
||||
|
||||
private void processVideos(Channel channel, List<String> videoIds) {
|
||||
upsertVideos(channel, videoIds, "CHANNEL", null, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* videos.list 로 상세를 받아 ChannelVideo 를 upsert 한다.
|
||||
*
|
||||
* @param source 저장할 출처. CHANNEL(등록 채널) / SOURCE(소재 원본) / RIVAL(경쟁 쇼츠)
|
||||
* @param shortsOnly null 이면 전체, TRUE 면 Shorts 만, FALSE 면 롱폼만 저장
|
||||
* @param publishedAfter 이 시각 이전 업로드는 건너뜀(null 이면 제한 없음)
|
||||
* @return 저장·갱신한 영상 수
|
||||
*/
|
||||
private int upsertVideos(Channel channel, List<String> videoIds, String source,
|
||||
Boolean shortsOnly, LocalDateTime publishedAfter) {
|
||||
String apiUrl = UriComponentsBuilder.fromHttpUrl("https://www.googleapis.com/youtube/v3/videos")
|
||||
.queryParam("part", "snippet,statistics,contentDetails")
|
||||
.queryParam("id", String.join(",", videoIds))
|
||||
.queryParam("key", youtubeApiKey)
|
||||
.toUriString();
|
||||
|
||||
int saved = 0;
|
||||
try {
|
||||
JsonNode root = restTemplate.getForObject(apiUrl, JsonNode.class);
|
||||
JsonNode items = root.path("items");
|
||||
@ -319,17 +334,26 @@ public class ChannelService {
|
||||
// --- 파생 분석 지표 계산 ---
|
||||
Integer durationSec = VideoMetrics.parseDurationSec(duration);
|
||||
Boolean isShorts = VideoMetrics.isShorts(durationSec);
|
||||
|
||||
// 포맷/기간 필터 — 피드 수집에서만 사용(일반 채널 수집은 둘 다 null)
|
||||
if (shortsOnly != null && shortsOnly != isShorts) continue;
|
||||
if (publishedAfter != null && publishedAt.isBefore(publishedAfter)) continue;
|
||||
|
||||
java.math.BigDecimal viewsPerHour = VideoMetrics.viewsPerHour(viewCount, publishedAt);
|
||||
java.math.BigDecimal viewsPerSubRatio = VideoMetrics.viewsPerSubRatio(viewCount, channel.getSubscriberCount());
|
||||
String ytChannelId = channel.getChannelId();
|
||||
String channelTitle = channel.getTitle();
|
||||
Long subscriberCount = channel.getSubscriberCount();
|
||||
// 해시태그는 시드 역분석(내 채널 → 소재 원본 채널 후보)의 재료가 된다.
|
||||
String hashtags = HashtagExtractor.join(
|
||||
HashtagExtractor.extract(snippet.path("description").asText("") + " " + title));
|
||||
|
||||
channelVideoRepository.findByVideoId(videoId)
|
||||
.ifPresentOrElse(v -> {
|
||||
v.update(title, thumbnailUrl, viewCount, likeCount);
|
||||
v.applyMetrics(durationSec, isShorts, viewsPerHour);
|
||||
v.applyChannelInfo(ytChannelId, channelTitle, subscriberCount, viewsPerSubRatio);
|
||||
v.applyFeedInfo(ytChannelId, channelTitle, subscriberCount, viewsPerSubRatio, source);
|
||||
v.applyHashtags(hashtags);
|
||||
channelVideoRepository.save(v);
|
||||
}, () -> {
|
||||
ChannelVideo newVideo = ChannelVideo.builder()
|
||||
@ -343,13 +367,56 @@ public class ChannelService {
|
||||
.duration(duration)
|
||||
.build();
|
||||
newVideo.applyMetrics(durationSec, isShorts, viewsPerHour);
|
||||
newVideo.applyChannelInfo(ytChannelId, channelTitle, subscriberCount, viewsPerSubRatio);
|
||||
newVideo.applyFeedInfo(ytChannelId, channelTitle, subscriberCount, viewsPerSubRatio, source);
|
||||
newVideo.applyHashtags(hashtags);
|
||||
channelVideoRepository.save(newVideo);
|
||||
});
|
||||
saved++;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("Error fetching video details", e);
|
||||
}
|
||||
return saved;
|
||||
}
|
||||
|
||||
/**
|
||||
* 피드 시드 채널의 최근 업로드를 1페이지(최대 50건)만 수집한다.
|
||||
* 소스 채널은 롱폼만, 경쟁 채널은 쇼츠만 저장한다.
|
||||
*
|
||||
* @param channel SOURCE 또는 RIVAL 역할의 채널
|
||||
* @param publishedAfter 이 시각 이후 업로드만 수집
|
||||
* @return 저장·갱신된 영상 수
|
||||
* @throws IllegalStateException uploads 플레이리스트를 찾을 수 없을 때
|
||||
*/
|
||||
@Transactional
|
||||
public int collectFeedVideos(Channel channel, LocalDateTime publishedAfter) {
|
||||
String role = channel.roleOrDefault();
|
||||
String uploadsPlaylistId = channel.getUploadsPlaylistId();
|
||||
if (uploadsPlaylistId == null || uploadsPlaylistId.isBlank()) {
|
||||
throw new IllegalStateException("uploads 플레이리스트가 없습니다: " + channel.getChannelId());
|
||||
}
|
||||
|
||||
String apiUrl = UriComponentsBuilder.fromHttpUrl("https://www.googleapis.com/youtube/v3/playlistItems")
|
||||
.queryParam("part", "contentDetails")
|
||||
.queryParam("playlistId", uploadsPlaylistId)
|
||||
.queryParam("maxResults", 50)
|
||||
.queryParam("key", youtubeApiKey)
|
||||
.toUriString();
|
||||
|
||||
JsonNode root = restTemplate.getForObject(apiUrl, JsonNode.class);
|
||||
if (root == null) throw new IllegalStateException("playlistItems 응답이 비어있습니다: " + channel.getChannelId());
|
||||
|
||||
List<String> videoIds = new ArrayList<>();
|
||||
for (JsonNode item : root.path("items")) {
|
||||
String videoId = item.path("contentDetails").path("videoId").asText(null);
|
||||
if (videoId != null && !videoId.isBlank()) videoIds.add(videoId);
|
||||
}
|
||||
if (videoIds.isEmpty()) return 0;
|
||||
|
||||
// SOURCE = 롱폼만(공식계정 쇼츠는 이미 잘린 결과물), RIVAL = 쇼츠만
|
||||
Boolean shortsOnly = ChannelRole.acceptsShorts(role);
|
||||
String source = ChannelRole.RIVAL.equals(role) ? ChannelRole.RIVAL : ChannelRole.SOURCE;
|
||||
return upsertVideos(channel, videoIds, source, shortsOnly, publishedAfter);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -147,6 +147,24 @@ public class ChannelVideo {
|
||||
this.source = "CHANNEL";
|
||||
}
|
||||
|
||||
/**
|
||||
* 피드(소재 원본/경쟁) 수집 시 원본 채널 정보와 출처를 함께 적용한다.
|
||||
* @param source SOURCE(소재 원본 롱폼) | RIVAL(경쟁 쇼츠)
|
||||
*/
|
||||
public void applyFeedInfo(String ytChannelId, String channelTitle, Long subscriberCount,
|
||||
BigDecimal viewsPerSubRatio, String source) {
|
||||
this.ytChannelId = ytChannelId;
|
||||
this.channelTitle = channelTitle;
|
||||
this.subscriberCount = subscriberCount;
|
||||
this.viewsPerSubRatio = viewsPerSubRatio;
|
||||
this.source = source;
|
||||
}
|
||||
|
||||
/** 설명에서 추출한 해시태그(쉼표 구분)를 채운다. */
|
||||
public void applyHashtags(String hashtags) {
|
||||
this.hashtags = hashtags;
|
||||
}
|
||||
|
||||
/** 출처(source)를 바꾸지 않고 채널 정보/비율만 채운다. 백필 시 SEARCH 수집물용. */
|
||||
public void applyChannelInfoKeepSource(String ytChannelId, String channelTitle, Long subscriberCount, BigDecimal viewsPerSubRatio) {
|
||||
this.ytChannelId = ytChannelId;
|
||||
|
||||
@ -20,8 +20,43 @@ public interface ChannelVideoRepository extends JpaRepository<ChannelVideo, Long
|
||||
long countByIsShortsTrue();
|
||||
long countByCategoryIdIsNull();
|
||||
|
||||
/** 떡상 후보: 구독자 대비 조회수 비율이 높은 Shorts (제외 처리된 것은 빼고). */
|
||||
// --- 소재 발굴 피드 ---
|
||||
|
||||
/**
|
||||
* 피드 조회. 정렬은 항상 최신순(publishedAt DESC)이며 EXCLUDED 는 제외한다.
|
||||
*
|
||||
* @param source SOURCE(소재 원본 롱폼) | RIVAL(경쟁 쇼츠)
|
||||
* @param publishedAfter 이 시각 이후 업로드만 (null 이면 전체)
|
||||
* @param ytChannelId 특정 프로그램(채널)만 (null 이면 전체)
|
||||
* @param minDurationSec 길이 하한(포함, null 이면 미적용)
|
||||
* @param maxDurationSec 길이 상한(포함, null 이면 미적용)
|
||||
* @param hideWorked true 면 아직 손대지 않은(NEW) 것만
|
||||
*/
|
||||
@Query("select v from ChannelVideo v where v.source = :source "
|
||||
+ "and v.interestStatus <> 'EXCLUDED' "
|
||||
+ "and (cast(:publishedAfter as timestamp) is null or v.publishedAt >= :publishedAfter) "
|
||||
+ "and (:ytChannelId is null or v.ytChannelId = :ytChannelId) "
|
||||
+ "and (:minDurationSec is null or v.durationSec >= :minDurationSec) "
|
||||
+ "and (:maxDurationSec is null or v.durationSec <= :maxDurationSec) "
|
||||
+ "and (:hideWorked = false or v.interestStatus = 'NEW') "
|
||||
+ "order by v.publishedAt desc")
|
||||
java.util.List<ChannelVideo> feed(@Param("source") String source,
|
||||
@Param("publishedAfter") java.time.LocalDateTime publishedAfter,
|
||||
@Param("ytChannelId") String ytChannelId,
|
||||
@Param("minDurationSec") Integer minDurationSec,
|
||||
@Param("maxDurationSec") Integer maxDurationSec,
|
||||
@Param("hideWorked") boolean hideWorked,
|
||||
org.springframework.data.domain.Pageable pageable);
|
||||
|
||||
/** 피드 필터용 프로그램(채널) 목록: [ytChannelId, channelTitle, 영상수]. 영상 많은 순. */
|
||||
@Query("select v.ytChannelId, min(v.channelTitle), count(v) from ChannelVideo v "
|
||||
+ "where v.source = :source and v.ytChannelId is not null "
|
||||
+ "group by v.ytChannelId order by count(v) desc")
|
||||
java.util.List<Object[]> feedPrograms(@Param("source") String source);
|
||||
|
||||
/** 떡상 후보: 구독자 대비 조회수 비율이 높은 Shorts (제외 처리된 것은 빼고). 피드 수집물은 제외. */
|
||||
@Query("select v from ChannelVideo v where v.isShorts = true "
|
||||
+ "and (v.source is null or v.source in ('CHANNEL','SEARCH')) "
|
||||
+ "and v.viewsPerSubRatio >= :minRatio and v.interestStatus <> 'EXCLUDED' "
|
||||
+ "order by v.viewsPerSubRatio desc")
|
||||
java.util.List<ChannelVideo> findOutperformers(@Param("minRatio") java.math.BigDecimal minRatio,
|
||||
@ -30,10 +65,12 @@ public interface ChannelVideoRepository extends JpaRepository<ChannelVideo, Long
|
||||
/**
|
||||
* 큐레이션 필터링 + 조회수/업로드일/시간당조회수 정렬은 호출부의 Sort 로 처리.
|
||||
* null 인 조건은 무시한다.
|
||||
* source 를 지정하지 않으면 피드 수집물(SOURCE/RIVAL)은 제외해 수집함이 오염되지 않게 한다.
|
||||
*/
|
||||
@Query("select v from ChannelVideo v where "
|
||||
+ "(:categoryId is null or v.categoryId = :categoryId) and "
|
||||
+ "(:status is null or v.interestStatus = :status) and "
|
||||
+ "(:source is not null or v.source is null or v.source in ('CHANNEL','SEARCH')) and "
|
||||
+ "(:source is null or v.source = :source) and "
|
||||
+ "(:shortsOnly = false or v.isShorts = true) and "
|
||||
+ "(:bookmarkedOnly = false or v.bookmarked = true)")
|
||||
@ -57,6 +94,7 @@ public interface ChannelVideoRepository extends JpaRepository<ChannelVideo, Long
|
||||
+ "v.interestStatus <> 'EXCLUDED' and "
|
||||
+ "(cast(:publishedAfter as timestamp) is null or v.publishedAt >= :publishedAfter) and "
|
||||
+ "(cast(:minRatio as big_decimal) is null or v.viewsPerSubRatio >= :minRatio) and "
|
||||
+ "(:source is not null or v.source is null or v.source in ('CHANNEL','SEARCH')) and "
|
||||
+ "(:source is null or v.source = :source) and "
|
||||
+ "(:shortsOnly = false or v.isShorts = true) and "
|
||||
+ "(:unprocessedOnly = false or v.interestStatus in ('NEW','REVIEWING'))")
|
||||
|
||||
@ -0,0 +1,50 @@
|
||||
package com.hlab.yanalyst.domain.channel;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Duration;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/** 피드 카드 배지 판정(순수 로직). 화면과 테스트가 같은 규칙을 공유한다. */
|
||||
public final class FeedBadges {
|
||||
|
||||
/** 소재 선점 골든타임 — 업로드 후 이 시간 이내면 아직 아무도 안 잘랐을 가능성이 높다. */
|
||||
public static final int GOLDEN_HOURS = 24;
|
||||
|
||||
/** 공식클립과 풀에피를 가르는 길이(초). 15분. */
|
||||
public static final int CLIP_MAX_SEC = 15 * 60;
|
||||
|
||||
/** 경쟁 쇼츠 "떡상중" 판정 하한 — 시간당 조회수. */
|
||||
public static final BigDecimal RISING_VIEWS_PER_HOUR = BigDecimal.valueOf(1000);
|
||||
|
||||
/** 길이 버킷. SHORTS(65초 이하) / CLIP(공식클립) / FULL(풀에피) / UNKNOWN. */
|
||||
public static final String BUCKET_SHORTS = "SHORTS";
|
||||
public static final String BUCKET_CLIP = "CLIP";
|
||||
public static final String BUCKET_FULL = "FULL";
|
||||
public static final String BUCKET_UNKNOWN = "UNKNOWN";
|
||||
|
||||
private FeedBadges() {}
|
||||
|
||||
/** 업로드 후 GOLDEN_HOURS 이내인가. publishedAt/now 가 null 이면 false. */
|
||||
public static boolean isGoldenTime(LocalDateTime publishedAt, LocalDateTime now) {
|
||||
if (publishedAt == null || now == null) return false;
|
||||
if (publishedAt.isAfter(now)) return true; // 시계 오차로 미래로 찍힌 경우도 최신으로 취급
|
||||
return Duration.between(publishedAt, now).toHours() < GOLDEN_HOURS;
|
||||
}
|
||||
|
||||
/** 길이 버킷 판정. */
|
||||
public static String lengthBucket(Integer durationSec) {
|
||||
if (durationSec == null || durationSec <= 0) return BUCKET_UNKNOWN;
|
||||
if (VideoMetrics.isShorts(durationSec)) return BUCKET_SHORTS;
|
||||
return durationSec <= CLIP_MAX_SEC ? BUCKET_CLIP : BUCKET_FULL;
|
||||
}
|
||||
|
||||
/** 경쟁 쇼츠가 지금 터지는 중인가(시간당 조회수 기준). */
|
||||
public static boolean isRising(BigDecimal viewsPerHour) {
|
||||
return viewsPerHour != null && viewsPerHour.compareTo(RISING_VIEWS_PER_HOUR) >= 0;
|
||||
}
|
||||
|
||||
/** 이미 손을 댄 소재인가(NEW 가 아니면 작업 이력이 있다고 본다). */
|
||||
public static boolean isWorked(String interestStatus) {
|
||||
return interestStatus != null && !"NEW".equals(interestStatus);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,121 @@
|
||||
package com.hlab.yanalyst.domain.channel;
|
||||
|
||||
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.boot.context.event.ApplicationReadyEvent;
|
||||
import org.springframework.context.event.EventListener;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 소재 발굴 피드 수집기.
|
||||
*
|
||||
* <p>SOURCE(웹예능 공식채널)의 신규 롱폼과 RIVAL(경쟁 예능짤 채널)의 신규 쇼츠를 주기적으로 받아온다.
|
||||
* uploads 플레이리스트 1페이지만 조회하므로 채널당 약 2 units — 검색(search.list 100 units)보다 훨씬 싸다.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class FeedCollectionService {
|
||||
|
||||
/** 채널 1개 수집 추정 쿼터: playlistItems(1) + videos.list(1). */
|
||||
private static final long EST_UNITS_PER_CHANNEL = 2;
|
||||
|
||||
private final ChannelRepository channelRepository;
|
||||
private final ChannelService channelService;
|
||||
private final YoutubeQuotaGuard quotaGuard;
|
||||
|
||||
@Value("${hlab.feed.enabled:true}")
|
||||
private boolean enabled;
|
||||
|
||||
/** 이 일수보다 오래된 업로드는 수집하지 않는다(과거 전체를 끌어오지 않기 위함). */
|
||||
@Value("${hlab.feed.period-days:14}")
|
||||
private int periodDays;
|
||||
|
||||
/**
|
||||
* role 컬럼이 없던 시절의 기존 채널은 role 이 null 이다. 부팅 시 1회 MY 로 백필한다.
|
||||
* (ddl-auto:update 는 DEFAULT 를 채워주지 않으므로 애플리케이션에서 처리)
|
||||
*/
|
||||
@EventListener(ApplicationReadyEvent.class)
|
||||
@Transactional
|
||||
public void backfillChannelRoles() {
|
||||
int updated = channelRepository.backfillNullRoles();
|
||||
if (updated > 0) log.info("[Feed] 채널 role 백필: {}건 → MY", updated);
|
||||
}
|
||||
|
||||
@Scheduled(cron = "${hlab.feed.cron:0 15 */3 * * *}")
|
||||
public void scheduledCollect() {
|
||||
if (!enabled) {
|
||||
log.info("[Feed] 피드 자동 수집 비활성화됨 (hlab.feed.enabled=false)");
|
||||
return;
|
||||
}
|
||||
log.info("[Feed] 자동 수집 완료: {}", collectAll());
|
||||
}
|
||||
|
||||
/** 수동/스케줄 공용. 모든 피드 시드를 쿼터 한도 안에서 수집하고 요약을 반환한다. */
|
||||
public Map<String, Object> collectAll() {
|
||||
List<Channel> seeds = channelRepository.findFeedSeeds();
|
||||
LocalDateTime publishedAfter = LocalDateTime.now().minusDays(periodDays);
|
||||
|
||||
int ok = 0, failed = 0, skippedByQuota = 0, disabled = 0, saved = 0;
|
||||
|
||||
for (Channel c : seeds) {
|
||||
if (c.isFeedDisabled()) {
|
||||
disabled++;
|
||||
continue;
|
||||
}
|
||||
if (!quotaGuard.tryConsume(EST_UNITS_PER_CHANNEL)) {
|
||||
skippedByQuota++;
|
||||
log.warn("[Feed] 쿼터 예산 소진 — 채널 {} 이후 건너뜀 (잔여 {} units)", c.getTitle(), quotaGuard.remaining());
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
saved += channelService.collectFeedVideos(c, publishedAfter);
|
||||
markSuccess(c.getId());
|
||||
ok++;
|
||||
} catch (Exception e) {
|
||||
failed++;
|
||||
markFailure(c.getId());
|
||||
log.error("[Feed] 채널 {}({}) 수집 실패", c.getTitle(), c.getChannelId(), e);
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, Object> summary = new LinkedHashMap<>();
|
||||
summary.put("seeds", seeds.size());
|
||||
summary.put("collected", ok);
|
||||
summary.put("savedVideos", saved);
|
||||
summary.put("failed", failed);
|
||||
summary.put("autoDisabled", disabled);
|
||||
summary.put("skippedByQuota", skippedByQuota);
|
||||
summary.put("quotaRemaining", quotaGuard.remaining());
|
||||
return summary;
|
||||
}
|
||||
|
||||
/**
|
||||
* 수집 성공 표시(실패 카운터 리셋).
|
||||
* 같은 빈 안에서 호출되므로 @Transactional 프록시가 적용되지 않는다 — 명시적으로 save 한다.
|
||||
*/
|
||||
private void markSuccess(Long channelId) {
|
||||
channelRepository.findById(channelId).ifPresent(c -> {
|
||||
if (c.feedFailCountOrZero() == 0) return;
|
||||
c.resetFeedFailure();
|
||||
channelRepository.save(c);
|
||||
});
|
||||
}
|
||||
|
||||
/** 수집 실패 누적. 3회 연속이면 다음 수집부터 자동 스킵된다. */
|
||||
private void markFailure(Long channelId) {
|
||||
channelRepository.findById(channelId).ifPresent(c -> {
|
||||
c.recordFeedFailure();
|
||||
channelRepository.save(c);
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,94 @@
|
||||
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.global.common.ApiResponse;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/** 소재 발굴 피드 API. 소스(웹예능 롱폼) / 경쟁(예능짤 쇼츠) 신규 업로드를 최신순으로 제공한다. */
|
||||
@RestController
|
||||
@RequestMapping("/api/feed")
|
||||
@RequiredArgsConstructor
|
||||
@Tag(name = "Source Feed API", description = "소재 원본·경쟁 채널의 신규 업로드 피드")
|
||||
public class FeedController {
|
||||
|
||||
private final FeedService feedService;
|
||||
private final FeedCollectionService feedCollectionService;
|
||||
private final SeedSuggestService seedSuggestService;
|
||||
|
||||
@GetMapping
|
||||
@Operation(summary = "피드 조회", description = "tab=SOURCE(롱폼 소재)|RIVAL(경쟁 쇼츠). 항상 최신순.")
|
||||
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));
|
||||
}
|
||||
|
||||
@GetMapping("/programs")
|
||||
@Operation(summary = "프로그램 목록", description = "피드 필터용 원본 채널 목록(영상 많은 순)")
|
||||
public ApiResponse<List<FeedProgramDto>> programs(@RequestParam(defaultValue = "SOURCE") String tab) {
|
||||
return ApiResponse.ok(feedService.programs(tab));
|
||||
}
|
||||
|
||||
@PostMapping("/collect")
|
||||
@Operation(summary = "수동 수집", description = "스케줄과 동일한 피드 수집을 즉시 1회 실행")
|
||||
public ApiResponse<Map<String, Object>> collect() {
|
||||
return ApiResponse.ok(feedCollectionService.collectAll());
|
||||
}
|
||||
|
||||
// --- 시드(소스/경쟁 채널) 관리 ---
|
||||
|
||||
@GetMapping("/seeds")
|
||||
@Operation(summary = "시드 목록", description = "등록된 소재 원본·경쟁 채널")
|
||||
public ApiResponse<List<FeedSeedDto>> seeds() {
|
||||
return ApiResponse.ok(feedService.seeds());
|
||||
}
|
||||
|
||||
@PostMapping("/seeds")
|
||||
@Operation(summary = "시드 등록", description = "채널 URL 을 소재 원본(SOURCE) 또는 경쟁(RIVAL) 시드로 등록")
|
||||
public ApiResponse<FeedSeedDto> addSeed(@RequestBody Map<String, String> body) {
|
||||
String url = body.get("url");
|
||||
if (url == null || url.isBlank()) throw new IllegalArgumentException("채널 URL 이 필요합니다.");
|
||||
return ApiResponse.created(feedService.addSeed(url, body.getOrDefault("role", ChannelRole.SOURCE)));
|
||||
}
|
||||
|
||||
@DeleteMapping("/seeds/{id}")
|
||||
@Operation(summary = "시드 해제", description = "채널과 그 채널에서 수집한 피드 영상을 함께 제거")
|
||||
public ApiResponse<Void> removeSeed(@PathVariable Long id) {
|
||||
feedService.removeSeed(id);
|
||||
return ApiResponse.ok(null);
|
||||
}
|
||||
|
||||
@PostMapping("/seeds/{id}/reset-failure")
|
||||
@Operation(summary = "실패 카운터 초기화", description = "연속 실패로 자동 스킵 중인 시드를 다시 활성화")
|
||||
public ApiResponse<Void> resetFailure(@PathVariable Long id) {
|
||||
feedService.resetSeedFailure(id);
|
||||
return ApiResponse.ok(null);
|
||||
}
|
||||
|
||||
// --- 시드 자동 발굴(내 채널 역분석) ---
|
||||
|
||||
@GetMapping("/seeds/keywords")
|
||||
@Operation(summary = "해시태그 분석", description = "내 채널 영상의 해시태그 빈도(쿼터 소비 없음)")
|
||||
public ApiResponse<Map<String, Integer>> keywords() {
|
||||
return ApiResponse.ok(seedSuggestService.analyzeKeywords());
|
||||
}
|
||||
|
||||
@PostMapping("/seeds/suggest")
|
||||
@Operation(summary = "소재 원본 후보 발굴",
|
||||
description = "해시태그 상위 키워드로 공식채널을 검색해 추천 목록(roleHint=SOURCE)에 쌓는다. 키워드당 100 units 소비.")
|
||||
public ApiResponse<Map<String, Object>> suggest(@RequestBody(required = false) Map<String, Object> body) {
|
||||
@SuppressWarnings("unchecked")
|
||||
List<String> keywords = body == null ? null : (List<String>) body.get("keywords");
|
||||
return ApiResponse.ok(seedSuggestService.suggestSourceSeeds(keywords));
|
||||
}
|
||||
}
|
||||
126
src/main/java/com/hlab/yanalyst/domain/channel/FeedService.java
Normal file
126
src/main/java/com/hlab/yanalyst/domain/channel/FeedService.java
Normal file
@ -0,0 +1,126 @@
|
||||
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 lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/** 소재 발굴 피드 조회 + 시드(소스/경쟁 채널) 관리. */
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Transactional(readOnly = true)
|
||||
public class FeedService {
|
||||
|
||||
/** 한 번에 내려줄 카드 수 상한. */
|
||||
private static final int MAX_ITEMS = 200;
|
||||
|
||||
private final ChannelRepository channelRepository;
|
||||
private final ChannelVideoRepository channelVideoRepository;
|
||||
private final ChannelService channelService;
|
||||
|
||||
/**
|
||||
* 피드 카드 목록(항상 최신순).
|
||||
*
|
||||
* @param tab SOURCE | RIVAL
|
||||
* @param days 최근 N일 (null 이면 제한 없음)
|
||||
* @param ytChannelId 특정 프로그램만 (null/빈값이면 전체)
|
||||
* @param lengthBucket CLIP | FULL | SHORTS (null 이면 전체)
|
||||
* @param hideWorked 이미 손댄 소재 숨기기
|
||||
*/
|
||||
public List<FeedItemDto> feed(String tab, Integer days, String ytChannelId,
|
||||
String lengthBucket, boolean hideWorked) {
|
||||
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;
|
||||
|
||||
Integer minSec = null, maxSec = null;
|
||||
if (lengthBucket != null && !lengthBucket.isBlank()) {
|
||||
switch (lengthBucket.trim().toUpperCase()) {
|
||||
case FeedBadges.BUCKET_SHORTS -> maxSec = 65;
|
||||
case FeedBadges.BUCKET_CLIP -> { minSec = 66; maxSec = FeedBadges.CLIP_MAX_SEC; }
|
||||
case FeedBadges.BUCKET_FULL -> minSec = FeedBadges.CLIP_MAX_SEC + 1;
|
||||
default -> { /* 미인식 값은 필터 없음 */ }
|
||||
}
|
||||
}
|
||||
|
||||
List<ChannelVideo> rows = channelVideoRepository.feed(
|
||||
source, publishedAfter, channelFilter, minSec, maxSec, 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;
|
||||
}
|
||||
|
||||
/** 필터 드롭다운용 프로그램(채널) 목록. */
|
||||
public List<FeedProgramDto> programs(String tab) {
|
||||
String source = normalizeTab(tab);
|
||||
List<FeedProgramDto> out = new ArrayList<>();
|
||||
for (Object[] row : channelVideoRepository.feedPrograms(source)) {
|
||||
out.add(new FeedProgramDto((String) row[0], (String) row[1], (Long) row[2]));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 등록된 피드 시드 목록(소스 + 경쟁). */
|
||||
public List<FeedSeedDto> seeds() {
|
||||
List<FeedSeedDto> out = new ArrayList<>();
|
||||
for (Channel c : channelRepository.findFeedSeeds()) {
|
||||
out.add(new FeedSeedDto(c.getId(), c.getChannelId(), c.getTitle(), c.getThumbnailUrl(),
|
||||
c.getSubscriberCount(), c.roleOrDefault(), c.feedFailCountOrZero(), c.isFeedDisabled()));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* URL 로 시드 채널을 등록한다. 이미 등록된 채널이면 역할만 바꾼다.
|
||||
*
|
||||
* @param role SOURCE | RIVAL
|
||||
*/
|
||||
@Transactional
|
||||
public FeedSeedDto addSeed(String url, String role) {
|
||||
String normalized = ChannelRole.normalize(role);
|
||||
if (!ChannelRole.isFeed(normalized)) {
|
||||
throw new IllegalArgumentException("시드 역할은 SOURCE 또는 RIVAL 이어야 합니다: " + role);
|
||||
}
|
||||
Channel channel = channelService.saveChannelFromUrl(url);
|
||||
channel.changeRole(normalized);
|
||||
channel.resetFeedFailure();
|
||||
channelRepository.save(channel);
|
||||
return new FeedSeedDto(channel.getId(), channel.getChannelId(), channel.getTitle(),
|
||||
channel.getThumbnailUrl(), channel.getSubscriberCount(), normalized, 0, false);
|
||||
}
|
||||
|
||||
/** 시드 해제 — 채널과 그 채널에서 수집한 피드 영상을 함께 제거한다. */
|
||||
@Transactional
|
||||
public void removeSeed(Long channelId) {
|
||||
Channel c = channelService.getChannel(channelId);
|
||||
if (!ChannelRole.isFeed(c.roleOrDefault())) {
|
||||
throw new IllegalArgumentException("피드 시드가 아닙니다: " + channelId);
|
||||
}
|
||||
channelService.deleteChannel(channelId);
|
||||
}
|
||||
|
||||
/** 연속 실패로 자동 비활성화된 시드를 다시 활성화한다. */
|
||||
@Transactional
|
||||
public void resetSeedFailure(Long channelId) {
|
||||
Channel c = channelService.getChannel(channelId);
|
||||
c.resetFeedFailure();
|
||||
channelRepository.save(c);
|
||||
}
|
||||
|
||||
private String normalizeTab(String tab) {
|
||||
String t = tab == null ? "" : tab.trim().toUpperCase();
|
||||
return ChannelRole.RIVAL.equals(t) ? ChannelRole.RIVAL : ChannelRole.SOURCE;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,99 @@
|
||||
package com.hlab.yanalyst.domain.channel;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* 영상 설명/제목에서 해시태그를 뽑고 빈도를 집계하는 순수 로직.
|
||||
*
|
||||
* <p>내 채널 업로드의 해시태그(#유퀴즈 #핑계고 …)를 역분석해 소재 원본 채널 후보를 찾는 데 쓴다.
|
||||
* 프로그램명이 아닌 범용 태그(#shorts, #쇼츠 …)는 불용어로 걸러낸다.
|
||||
*/
|
||||
public final class HashtagExtractor {
|
||||
|
||||
/** 한글/영문/숫자/밑줄로 이루어진 해시태그. 구두점·공백에서 끊는다. */
|
||||
private static final Pattern TAG = Pattern.compile("#([\\p{L}\\p{N}_]+)");
|
||||
|
||||
/** 프로그램명 판별에 방해가 되는 범용 태그. 소문자 비교. */
|
||||
private static final Set<String> STOP_WORDS = Set.of(
|
||||
"shorts", "short", "youtubeshorts", "유튜브쇼츠", "쇼츠", "숏폼", "숏츠",
|
||||
"명장면", "짤", "짤방", "레전드", "reels", "릴스", "틱톡", "tiktok",
|
||||
"예능", "드라마", "영화", "도파민", "웃긴동영상", "시간순삭", "fyp", "viral", "recommended"
|
||||
);
|
||||
|
||||
private HashtagExtractor() {}
|
||||
|
||||
/** 단일 텍스트에서 해시태그를 추출한다(원문 표기 유지, 중복 제거, 불용어 제외). */
|
||||
public static List<String> extract(String text) {
|
||||
List<String> out = new ArrayList<>();
|
||||
if (text == null || text.isBlank()) return out;
|
||||
|
||||
Matcher m = TAG.matcher(text);
|
||||
Set<String> seen = new java.util.HashSet<>();
|
||||
while (m.find()) {
|
||||
String tag = m.group(1);
|
||||
if (tag.length() < 2) continue; // "#1" 같은 잡음 제외
|
||||
String key = tag.toLowerCase(Locale.ROOT);
|
||||
if (STOP_WORDS.contains(key)) continue;
|
||||
if (seen.add(key)) out.add(tag);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 해시태그 목록을 저장용 문자열(쉼표 구분)로 만든다. 없으면 null. */
|
||||
public static String join(List<String> tags) {
|
||||
if (tags == null || tags.isEmpty()) return null;
|
||||
return String.join(",", tags);
|
||||
}
|
||||
|
||||
/** 저장된 쉼표 구분 문자열을 다시 목록으로. */
|
||||
public static List<String> split(String joined) {
|
||||
List<String> out = new ArrayList<>();
|
||||
if (joined == null || joined.isBlank()) return out;
|
||||
for (String s : joined.split(",")) {
|
||||
String t = s.trim();
|
||||
if (!t.isEmpty()) out.add(t);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 여러 텍스트에 걸친 해시태그 빈도를 집계한다.
|
||||
*
|
||||
* @param texts 영상 설명/제목 등. null 원소는 무시
|
||||
* @param minCount 이 횟수 미만은 버린다(1회성 태그 제거)
|
||||
* @return 빈도 내림차순(동률이면 태그 오름차순) 맵. 키는 처음 등장한 원문 표기
|
||||
*/
|
||||
public static Map<String, Integer> countAll(List<String> texts, int minCount) {
|
||||
Map<String, Integer> counts = new LinkedHashMap<>(); // 소문자키 → 횟수
|
||||
Map<String, String> display = new LinkedHashMap<>(); // 소문자키 → 원문 표기
|
||||
|
||||
if (texts != null) {
|
||||
for (String text : texts) {
|
||||
for (String tag : extract(text)) {
|
||||
String key = tag.toLowerCase(Locale.ROOT);
|
||||
counts.merge(key, 1, Integer::sum);
|
||||
display.putIfAbsent(key, tag);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
List<Map.Entry<String, Integer>> entries = new ArrayList<>(counts.entrySet());
|
||||
entries.sort(Comparator.<Map.Entry<String, Integer>>comparingInt(Map.Entry::getValue).reversed()
|
||||
.thenComparing(Map.Entry::getKey));
|
||||
|
||||
Map<String, Integer> out = new LinkedHashMap<>();
|
||||
for (Map.Entry<String, Integer> e : entries) {
|
||||
if (e.getValue() < minCount) continue;
|
||||
out.put(display.get(e.getKey()), e.getValue());
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
@ -43,6 +43,16 @@ public class RecommendedChannel {
|
||||
|
||||
private String region; // 발견 지역(KR/JP/US)
|
||||
|
||||
/**
|
||||
* 승인 시 부여할 역할 힌트: SOURCE(소재 원본 웹예능) | RIVAL(경쟁 쇼츠) | null(기존 떡상 발굴 = 내 채널 후보).
|
||||
* 시드 역분석으로 찾은 후보인지, 기존 떡상 발굴로 찾은 후보인지 구분한다.
|
||||
*/
|
||||
@Column(length = 10)
|
||||
private String roleHint;
|
||||
|
||||
/** 이 후보를 찾아낸 근거 키워드(내 채널 해시태그). */
|
||||
private String seedKeyword;
|
||||
|
||||
@CreationTimestamp
|
||||
@Column(updatable = false)
|
||||
private LocalDateTime discoveredAt;
|
||||
|
||||
@ -20,21 +20,35 @@ public class RecommendedChannelController {
|
||||
private final RecommendedChannelRepository repository;
|
||||
private final ChannelDiscoveryService discoveryService;
|
||||
private final ChannelService channelService;
|
||||
private final ChannelRepository channelRepository;
|
||||
private final ChannelVideoRepository channelVideoRepository;
|
||||
|
||||
@GetMapping
|
||||
@Operation(summary = "추천 채널 목록", description = "status(NEW|EXCLUDED|REGISTERED, 기본 NEW)를 배율 내림차순으로 반환")
|
||||
public ApiResponse<List<RecommendedChannel>> list(@RequestParam(defaultValue = "NEW") String status) {
|
||||
@Operation(summary = "추천 채널 목록",
|
||||
description = "status(NEW|EXCLUDED|REGISTERED, 기본 NEW)를 배율 내림차순으로 반환. roleHint 로 시드 후보만 좁힐 수 있다.")
|
||||
public ApiResponse<List<RecommendedChannel>> list(@RequestParam(defaultValue = "NEW") String status,
|
||||
@RequestParam(required = false) String roleHint) {
|
||||
if (roleHint != null && !roleHint.isBlank()) {
|
||||
return ApiResponse.ok(repository.findByStatusAndRoleHintOrderBySubscriberCountDesc(
|
||||
status, ChannelRole.normalize(roleHint)));
|
||||
}
|
||||
return ApiResponse.ok(repository.findByStatusOrderByRatioDesc(status));
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/register")
|
||||
@Operation(summary = "내 채널 등록", description = "추천 채널을 내 채널로 등록하고 REGISTERED 처리")
|
||||
@Operation(summary = "채널 등록",
|
||||
description = "추천 채널을 등록한다. role=MY(기본)|SOURCE(소재 원본 시드)|RIVAL(경쟁 시드). 이후 REGISTERED 처리")
|
||||
@Transactional
|
||||
public ApiResponse<Void> register(@PathVariable Long id) {
|
||||
public ApiResponse<Void> register(@PathVariable Long id,
|
||||
@RequestParam(required = false) String role) {
|
||||
RecommendedChannel rc = repository.findById(id)
|
||||
.orElseThrow(() -> new IllegalArgumentException("추천 채널을 찾을 수 없습니다: " + id));
|
||||
channelService.saveChannelFromUrl("https://www.youtube.com/channel/" + rc.getChannelId());
|
||||
// role 미지정 시 후보가 들고 있던 힌트를 따르고, 그것도 없으면 MY.
|
||||
String target = ChannelRole.normalize(role != null && !role.isBlank() ? role : rc.getRoleHint());
|
||||
Channel channel = channelService.saveChannelFromUrl("https://www.youtube.com/channel/" + rc.getChannelId());
|
||||
channel.changeRole(target);
|
||||
channel.resetFeedFailure();
|
||||
channelRepository.save(channel);
|
||||
rc.setStatus("REGISTERED");
|
||||
repository.save(rc);
|
||||
return ApiResponse.ok(null);
|
||||
|
||||
@ -9,4 +9,7 @@ public interface RecommendedChannelRepository extends JpaRepository<RecommendedC
|
||||
Optional<RecommendedChannel> findByChannelId(String channelId);
|
||||
List<RecommendedChannel> findByStatusOrderByRatioDesc(String status);
|
||||
boolean existsByChannelIdAndStatus(String channelId, String status);
|
||||
|
||||
/** 역할 힌트별 추천 목록(시드 후보). roleHint = SOURCE | RIVAL. */
|
||||
List<RecommendedChannel> findByStatusAndRoleHintOrderBySubscriberCountDesc(String status, String roleHint);
|
||||
}
|
||||
|
||||
@ -0,0 +1,206 @@
|
||||
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.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
import org.springframework.web.util.UriComponentsBuilder;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 내 채널을 역분석해 소재 원본(SOURCE) 채널 후보를 찾는다.
|
||||
*
|
||||
* <p>내 업로드의 해시태그(#유퀴즈 #핑계고 …)를 빈도순으로 집계한 뒤, 상위 키워드마다
|
||||
* search.list(type=channel)로 공식채널을 조회해 {@link RecommendedChannel}(roleHint=SOURCE)로 쌓는다.
|
||||
* 승인은 사람이 한다.
|
||||
*
|
||||
* <p>search.list 는 1회 100 units 라 스케줄이 아니라 <b>수동 실행</b> 전용이다.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class SeedSuggestService {
|
||||
|
||||
/** search.list 1회 추정 쿼터(units). */
|
||||
private static final long SEARCH_QUOTA = 100;
|
||||
|
||||
/** 키워드당 가져올 채널 후보 수. */
|
||||
private static final int PER_KEYWORD = 3;
|
||||
|
||||
private final ChannelRepository channelRepository;
|
||||
private final ChannelVideoRepository channelVideoRepository;
|
||||
private final RecommendedChannelRepository recommendedChannelRepository;
|
||||
private final YoutubeQuotaGuard quotaGuard;
|
||||
private final RestTemplate restTemplate;
|
||||
|
||||
@Value("${youtube.api.key}")
|
||||
private String youtubeApiKey;
|
||||
|
||||
/** 후보 탐색에 쓸 상위 키워드 개수. 키워드 1개당 100 units 라 기본값을 보수적으로 잡는다. */
|
||||
@Value("${hlab.feed.seed.max-keywords:8}")
|
||||
private int maxKeywords;
|
||||
|
||||
/** 이 횟수 미만으로 등장한 해시태그는 프로그램명으로 보지 않는다. */
|
||||
@Value("${hlab.feed.seed.min-tag-count:2}")
|
||||
private int minTagCount;
|
||||
|
||||
/**
|
||||
* 내 채널 영상의 해시태그 빈도 (탐색 전 미리보기용, 쿼터 소비 없음).
|
||||
* @return 태그 → 등장 횟수 (빈도 내림차순)
|
||||
*/
|
||||
public Map<String, Integer> analyzeKeywords() {
|
||||
List<Channel> mine = channelRepository.findMyChannels();
|
||||
List<String> texts = new ArrayList<>();
|
||||
for (Channel c : mine) {
|
||||
for (ChannelVideo v : channelVideoRepository.findByChannelId(c.getId())) {
|
||||
// 수집 시 저장해둔 해시태그가 있으면 그걸, 없으면 제목에서라도 뽑는다.
|
||||
if (v.getHashtags() != null && !v.getHashtags().isBlank()) {
|
||||
for (String tag : HashtagExtractor.split(v.getHashtags())) texts.add("#" + tag);
|
||||
} else {
|
||||
texts.add(v.getTitle());
|
||||
}
|
||||
}
|
||||
}
|
||||
return HashtagExtractor.countAll(texts, minTagCount);
|
||||
}
|
||||
|
||||
/**
|
||||
* 해시태그 상위 키워드로 소재 원본 채널 후보를 발굴해 추천 목록에 쌓는다.
|
||||
*
|
||||
* @param keywords 직접 지정할 키워드(비어있으면 {@link #analyzeKeywords()} 결과 상위 N개 사용)
|
||||
* @return 실행 요약
|
||||
*/
|
||||
@Transactional
|
||||
public Map<String, Object> suggestSourceSeeds(List<String> keywords) {
|
||||
List<String> targets = new ArrayList<>();
|
||||
if (keywords != null && !keywords.isEmpty()) {
|
||||
for (String k : keywords) {
|
||||
String t = k == null ? "" : k.trim();
|
||||
if (!t.isEmpty()) targets.add(t);
|
||||
}
|
||||
} else {
|
||||
for (String k : analyzeKeywords().keySet()) {
|
||||
if (targets.size() >= maxKeywords) break;
|
||||
targets.add(k);
|
||||
}
|
||||
}
|
||||
|
||||
int searched = 0, found = 0, saved = 0, skippedByQuota = 0;
|
||||
for (String keyword : targets) {
|
||||
if (!quotaGuard.tryConsume(SEARCH_QUOTA)) {
|
||||
skippedByQuota = targets.size() - searched;
|
||||
log.warn("[Seed] 쿼터 예산 소진 — 키워드 '{}' 이후 중단 (잔여 {})", keyword, quotaGuard.remaining());
|
||||
break;
|
||||
}
|
||||
try {
|
||||
List<Candidate> candidates = searchChannels(keyword);
|
||||
searched++;
|
||||
found += candidates.size();
|
||||
for (Candidate c : candidates) {
|
||||
if (upsertCandidate(c, keyword)) saved++;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("[Seed] 키워드 '{}' 채널 검색 실패 — 건너뜀", keyword, e);
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, Object> summary = new LinkedHashMap<>();
|
||||
summary.put("keywords", targets);
|
||||
summary.put("searched", searched);
|
||||
summary.put("found", found);
|
||||
summary.put("saved", saved);
|
||||
summary.put("skippedByQuota", skippedByQuota);
|
||||
summary.put("quotaRemaining", quotaGuard.remaining());
|
||||
log.info("[Seed] 소재 원본 후보 발굴 완료: {}", summary);
|
||||
return summary;
|
||||
}
|
||||
|
||||
/** 이미 시드로 등록됐거나 제외/등록 처리된 채널은 건너뛴다. @return 새로 저장했으면 true */
|
||||
private boolean upsertCandidate(Candidate c, String keyword) {
|
||||
if (channelRepository.existsByChannelId(c.channelId)) return false; // 이미 등록된 채널
|
||||
|
||||
RecommendedChannel rc = recommendedChannelRepository.findByChannelId(c.channelId).orElse(null);
|
||||
if (rc != null && !"NEW".equals(rc.getStatus())) return false; // EXCLUDED/REGISTERED 는 존중
|
||||
if (rc == null) rc = new RecommendedChannel();
|
||||
|
||||
rc.setChannelId(c.channelId);
|
||||
rc.setChannelTitle(c.title);
|
||||
rc.setThumbnailUrl(c.thumbnailUrl);
|
||||
rc.setSubscriberCount(c.subscriberCount);
|
||||
rc.setStatus("NEW");
|
||||
rc.setRoleHint(ChannelRole.SOURCE);
|
||||
rc.setSeedKeyword(keyword);
|
||||
rc.setRegion("KR");
|
||||
recommendedChannelRepository.save(rc);
|
||||
return true;
|
||||
}
|
||||
|
||||
private record Candidate(String channelId, String title, String thumbnailUrl, Long subscriberCount) {}
|
||||
|
||||
/** search.list(type=channel) + channels.list(구독자 수) 조회. */
|
||||
private List<Candidate> searchChannels(String keyword) {
|
||||
String searchUrl = UriComponentsBuilder.fromHttpUrl("https://www.googleapis.com/youtube/v3/search")
|
||||
.queryParam("part", "snippet")
|
||||
.queryParam("type", "channel")
|
||||
.queryParam("q", keyword)
|
||||
.queryParam("regionCode", "KR")
|
||||
.queryParam("relevanceLanguage", "ko")
|
||||
.queryParam("maxResults", PER_KEYWORD)
|
||||
.queryParam("key", youtubeApiKey)
|
||||
.encode()
|
||||
.toUriString();
|
||||
|
||||
JsonNode root = restTemplate.getForObject(searchUrl, JsonNode.class);
|
||||
if (root == null) return List.of();
|
||||
|
||||
Map<String, String[]> basics = new LinkedHashMap<>(); // channelId → [title, thumbnailUrl]
|
||||
for (JsonNode item : root.path("items")) {
|
||||
String channelId = item.path("snippet").path("channelId").asText(null);
|
||||
if (channelId == null || channelId.isBlank()) continue;
|
||||
String title = item.path("snippet").path("title").asText("");
|
||||
String thumb = item.path("snippet").path("thumbnails").path("high").path("url")
|
||||
.asText(item.path("snippet").path("thumbnails").path("default").path("url").asText(null));
|
||||
basics.put(channelId, new String[]{title, thumb});
|
||||
}
|
||||
if (basics.isEmpty()) return List.of();
|
||||
|
||||
Map<String, Long> subs = fetchSubscriberCounts(basics.keySet());
|
||||
|
||||
List<Candidate> out = new ArrayList<>();
|
||||
for (Map.Entry<String, String[]> e : basics.entrySet()) {
|
||||
out.add(new Candidate(e.getKey(), e.getValue()[0], e.getValue()[1], subs.get(e.getKey())));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** channels.list 1회(1 unit)로 구독자 수를 채운다. 실패해도 후보 발굴 자체는 계속한다. */
|
||||
private Map<String, Long> fetchSubscriberCounts(Iterable<String> channelIds) {
|
||||
Map<String, Long> out = new LinkedHashMap<>();
|
||||
try {
|
||||
String ids = String.join(",", channelIds);
|
||||
String url = UriComponentsBuilder.fromHttpUrl("https://www.googleapis.com/youtube/v3/channels")
|
||||
.queryParam("part", "statistics")
|
||||
.queryParam("id", ids)
|
||||
.queryParam("key", youtubeApiKey)
|
||||
.toUriString();
|
||||
JsonNode root = restTemplate.getForObject(url, JsonNode.class);
|
||||
if (root == null) return out;
|
||||
for (JsonNode item : root.path("items")) {
|
||||
String id = item.path("id").asText(null);
|
||||
String subs = item.path("statistics").path("subscriberCount").asText(null);
|
||||
if (id != null && subs != null) out.put(id, Long.parseLong(subs));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("[Seed] 구독자 수 조회 실패(무시)", e);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,57 @@
|
||||
package com.hlab.yanalyst.domain.channel.dto;
|
||||
|
||||
import com.hlab.yanalyst.domain.channel.ChannelVideo;
|
||||
import com.hlab.yanalyst.domain.channel.FeedBadges;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 피드 카드 1건. 배지 판정은 서버에서 끝내고 화면은 그리기만 한다
|
||||
* (같은 규칙이 JS 로 중복 구현되는 걸 막는다).
|
||||
*/
|
||||
public record FeedItemDto(
|
||||
Long id,
|
||||
String videoId,
|
||||
String title,
|
||||
String thumbnailUrl,
|
||||
LocalDateTime publishedAt,
|
||||
String channelTitle,
|
||||
String ytChannelId,
|
||||
Long viewCount,
|
||||
BigDecimal viewsPerHour,
|
||||
Integer durationSec,
|
||||
String interestStatus,
|
||||
boolean bookmarked,
|
||||
String source,
|
||||
/** 업로드 24시간 이내 — 선점 골든타임. */
|
||||
boolean goldenTime,
|
||||
/** SHORTS | CLIP | FULL | UNKNOWN */
|
||||
String lengthBucket,
|
||||
/** 경쟁 쇼츠가 지금 터지는 중. */
|
||||
boolean rising,
|
||||
/** 이미 손댄 소재(상태가 NEW 가 아님). */
|
||||
boolean worked
|
||||
) {
|
||||
public static FeedItemDto from(ChannelVideo v, LocalDateTime now) {
|
||||
return new FeedItemDto(
|
||||
v.getId(),
|
||||
v.getVideoId(),
|
||||
v.getTitle(),
|
||||
v.getThumbnailUrl(),
|
||||
v.getPublishedAt(),
|
||||
v.getChannelTitle(),
|
||||
v.getYtChannelId(),
|
||||
v.getViewCount(),
|
||||
v.getViewsPerHour(),
|
||||
v.getDurationSec(),
|
||||
v.getInterestStatus(),
|
||||
v.isBookmarked(),
|
||||
v.getSource(),
|
||||
FeedBadges.isGoldenTime(v.getPublishedAt(), now),
|
||||
FeedBadges.lengthBucket(v.getDurationSec()),
|
||||
FeedBadges.isRising(v.getViewsPerHour()),
|
||||
FeedBadges.isWorked(v.getInterestStatus())
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,4 @@
|
||||
package com.hlab.yanalyst.domain.channel.dto;
|
||||
|
||||
/** 피드 필터용 프로그램(원본 채널) 항목. */
|
||||
public record FeedProgramDto(String ytChannelId, String channelTitle, Long videoCount) {}
|
||||
@ -0,0 +1,11 @@
|
||||
package com.hlab.yanalyst.domain.channel.dto;
|
||||
|
||||
/**
|
||||
* 피드 시드 채널 1건.
|
||||
*
|
||||
* @param role SOURCE(소재 원본) | RIVAL(경쟁 쇼츠)
|
||||
* @param failCount 연속 수집 실패 횟수
|
||||
* @param disabled 연속 실패 3회로 자동 스킵 중인지
|
||||
*/
|
||||
public record FeedSeedDto(Long id, String channelId, String title, String thumbnailUrl,
|
||||
Long subscriberCount, String role, int failCount, boolean disabled) {}
|
||||
@ -124,7 +124,8 @@ public class ScheduledCollectionService {
|
||||
|
||||
/** 수동/스케줄 공용. 모든 등록 채널을 쿼터 한도 내에서 수집하고 결과 요약을 반환. */
|
||||
public Map<String, Object> runChannelCollection() {
|
||||
List<Channel> channels = channelRepository.findAll();
|
||||
// 피드 시드(SOURCE/RIVAL)는 FeedCollectionService 가 3시간마다 따로 수집한다.
|
||||
List<Channel> channels = channelRepository.findMyChannels();
|
||||
int ok = 0, failed = 0, skippedByQuota = 0;
|
||||
|
||||
for (Channel c : channels) {
|
||||
@ -163,7 +164,8 @@ public class ScheduledCollectionService {
|
||||
|
||||
/** 모든 채널의 통계를 갱신하며 일별 성장 스냅샷을 기록. */
|
||||
public Map<String, Object> runChannelSnapshot() {
|
||||
List<Channel> channels = channelRepository.findAll();
|
||||
// 피드 시드(SOURCE/RIVAL)는 FeedCollectionService 가 3시간마다 따로 수집한다.
|
||||
List<Channel> channels = channelRepository.findMyChannels();
|
||||
int ok = 0, failed = 0, skippedByQuota = 0;
|
||||
|
||||
for (Channel c : channels) {
|
||||
|
||||
@ -52,6 +52,12 @@ public class WebController {
|
||||
return "discover";
|
||||
}
|
||||
|
||||
@GetMapping("/feed")
|
||||
public String feed(Model model) {
|
||||
model.addAttribute("currentPage", "feed");
|
||||
return "feed";
|
||||
}
|
||||
|
||||
@GetMapping("/recommend")
|
||||
public String recommend(Model model) {
|
||||
model.addAttribute("currentPage", "recommend");
|
||||
|
||||
@ -100,6 +100,15 @@ hlab:
|
||||
# 발굴 대상 포맷 기본값: LONG_FORM(롱폼) | SHORTS. UI '지금 발굴'에서 건별 선택 가능.
|
||||
format: ${DISCOVERY_FORMAT:LONG_FORM}
|
||||
|
||||
# 소재 발굴 피드: 소스(웹예능 공식채널) 신규 롱폼 + 경쟁(예능짤) 신규 쇼츠를 최신순으로 수집
|
||||
feed:
|
||||
enabled: ${FEED_ENABLED:true}
|
||||
cron: ${FEED_CRON:0 15 */3 * * *} # 3시간마다 (소재 선점은 업로드 후 몇 시간이 승부)
|
||||
period-days: ${FEED_PERIOD_DAYS:14} # 이보다 오래된 업로드는 수집하지 않음
|
||||
seed:
|
||||
max-keywords: ${FEED_SEED_MAX_KEYWORDS:8} # 시드 자동 발굴 시 사용할 상위 해시태그 수(1개당 100 units)
|
||||
min-tag-count: ${FEED_SEED_MIN_TAG_COUNT:2} # 이 횟수 미만 해시태그는 프로그램명으로 보지 않음
|
||||
|
||||
# 텔레그램 아침 추천: 발굴 직후 상위 추천채널 다이제스트를 발송. 토큰/챗ID 없으면 자동 no-op.
|
||||
notify:
|
||||
telegram:
|
||||
|
||||
673
src/main/resources/templates/feed.html
Normal file
673
src/main/resources/templates/feed.html
Normal file
@ -0,0 +1,673 @@
|
||||
<!DOCTYPE html>
|
||||
<html xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
|
||||
layout:decorate="~{layout/base}">
|
||||
|
||||
<head>
|
||||
<title>h-lab - 소재 피드</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div layout:fragment="content">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1>소재 피드</h1>
|
||||
<p class="sub">소재 원본(웹예능 롱폼)과 경쟁 쇼츠의 신규 업로드를 최신순으로 봅니다.</p>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="btn btn-secondary" onclick="openSeeds()">
|
||||
<i data-lucide="list-plus" style="width:15px;"></i> 시드 관리
|
||||
</button>
|
||||
<button class="btn btn-primary" id="collectBtn" onclick="runCollect()">
|
||||
<i data-lucide="refresh-cw" style="width:15px;"></i> 지금 수집
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 탭 -->
|
||||
<div class="feed-tabs" role="tablist" aria-label="피드 종류">
|
||||
<button class="feed-tab" role="tab" id="tab-SOURCE" data-tab="SOURCE"
|
||||
aria-selected="true" aria-controls="feedGrid" onclick="switchTab('SOURCE')">
|
||||
<i data-lucide="film" style="width:15px;"></i>
|
||||
<span>소재 원본</span>
|
||||
<span class="feed-tab-hint">웹예능 롱폼</span>
|
||||
</button>
|
||||
<button class="feed-tab" role="tab" id="tab-RIVAL" data-tab="RIVAL"
|
||||
aria-selected="false" aria-controls="feedGrid" tabindex="-1" onclick="switchTab('RIVAL')">
|
||||
<i data-lucide="swords" 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">프로그램
|
||||
<select id="fProgram" onchange="applyFilters()">
|
||||
<option value="">전체</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field" for="fLength" id="lengthField">길이
|
||||
<select id="fLength" onchange="applyFilters()">
|
||||
<option value="">전체</option>
|
||||
<option value="CLIP">공식클립 (~15분)</option>
|
||||
<option value="FULL">풀에피 (15분+)</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field" for="fDays">기간
|
||||
<select id="fDays" onchange="applyFilters()">
|
||||
<option value="1">최근 24시간</option>
|
||||
<option value="3">최근 3일</option>
|
||||
<option value="7" selected>최근 7일</option>
|
||||
<option value="14">최근 14일</option>
|
||||
<option value="0">전체</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="chip">
|
||||
<input type="checkbox" id="fHideWorked" onchange="applyFilters()">
|
||||
<i data-lucide="eye-off"></i> 작업한 것 숨기기
|
||||
</label>
|
||||
|
||||
<span class="spacer"></span>
|
||||
<span id="resultCount" class="badge badge-muted" aria-live="polite">—</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 결과 -->
|
||||
<div id="feedGrid" class="feed-grid" role="tabpanel" aria-labelledby="tab-SOURCE"></div>
|
||||
<div id="feedState" class="feed-state hidden"></div>
|
||||
|
||||
<!-- 시드 관리 모달 -->
|
||||
<div id="seedModal" class="modal-overlay" onclick="if(event.target===this) closeSeeds()">
|
||||
<div class="modal-card" role="dialog" aria-modal="true" aria-labelledby="seedModalTitle">
|
||||
<div class="modal-head">
|
||||
<h3 id="seedModalTitle">시드 관리</h3>
|
||||
<button class="modal-close" onclick="closeSeeds()" aria-label="닫기">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p class="text-sm text-muted mb-3" style="line-height:1.6;">
|
||||
<b>소재 원본</b>은 유퀴즈·핑계고 같은 웹예능 공식채널(롱폼만 수집),
|
||||
<b>경쟁 쇼츠</b>는 같은 소재를 다루는 예능짤 채널(쇼츠만 수집)입니다.
|
||||
</p>
|
||||
|
||||
<div class="seed-add">
|
||||
<label class="sr-only" for="seedUrl">채널 URL</label>
|
||||
<input id="seedUrl" type="text" placeholder="https://www.youtube.com/@channel">
|
||||
<label class="sr-only" for="seedRole">역할</label>
|
||||
<select id="seedRole">
|
||||
<option value="SOURCE">소재 원본</option>
|
||||
<option value="RIVAL">경쟁 쇼츠</option>
|
||||
</select>
|
||||
<button class="btn btn-primary" id="seedAddBtn" onclick="addSeed()">추가</button>
|
||||
</div>
|
||||
<p class="text-xs text-muted mt-2" id="seedAddHelp">
|
||||
채널 URL 또는 @핸들 주소를 붙여넣으세요.
|
||||
</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;" onclick="suggestSeeds()"
|
||||
title="내 채널 해시태그를 역분석해 소재 원본 후보를 찾습니다 (키워드당 100 units)">
|
||||
<i data-lucide="wand-sparkles" style="width:14px;"></i> 후보 자동 발굴
|
||||
</button>
|
||||
</div>
|
||||
<div id="seedList" 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>
|
||||
|
||||
<style>
|
||||
/* ===== 탭 ===== */
|
||||
.feed-tabs { display:flex; gap:0.4rem; margin-bottom:1rem; border-bottom:1px solid var(--border); }
|
||||
.feed-tab {
|
||||
display:inline-flex; align-items:center; gap:0.45rem;
|
||||
padding:0.7rem 1rem; min-height:44px;
|
||||
background:transparent; border:none; border-bottom:2px solid transparent;
|
||||
color:var(--text-2); font-size:0.9rem; font-weight:600; font-family:inherit;
|
||||
cursor:pointer; transition:color .18s ease, border-color .18s ease;
|
||||
}
|
||||
.feed-tab:hover { color:var(--text); }
|
||||
.feed-tab[aria-selected="true"] { color:var(--accent); border-bottom-color:var(--accent); }
|
||||
.feed-tab:focus-visible { outline:2px solid var(--accent); outline-offset:-2px; border-radius:var(--r-sm); }
|
||||
.feed-tab-hint { font-size:0.72rem; font-weight:500; color:var(--text-3); }
|
||||
.feed-tab[aria-selected="true"] .feed-tab-hint { color:var(--accent); opacity:.75; }
|
||||
|
||||
/* ===== 카드 그리드 ===== */
|
||||
.feed-grid { display:grid; grid-template-columns:repeat(auto-fill, minmax(268px, 1fr)); gap:1rem; }
|
||||
@media (max-width: 520px) { .feed-grid { grid-template-columns:1fr; } }
|
||||
|
||||
.fcard {
|
||||
display:flex; flex-direction:column;
|
||||
background:var(--surface); border:1px solid var(--border); border-radius:var(--r);
|
||||
overflow:hidden; transition:border-color .18s ease, box-shadow .18s ease;
|
||||
}
|
||||
.fcard:hover { border-color:var(--border-strong); box-shadow:var(--shadow); }
|
||||
.fcard.is-worked { opacity:.55; }
|
||||
.fcard.is-worked:hover { opacity:1; }
|
||||
|
||||
.fthumb { position:relative; display:block; background:var(--inset); aspect-ratio:16/9; }
|
||||
.fthumb img { width:100%; height:100%; object-fit:cover; display:block; }
|
||||
.fthumb:focus-visible { outline:2px solid var(--accent); outline-offset:-2px; }
|
||||
.fdur {
|
||||
position:absolute; right:6px; bottom:6px;
|
||||
background:rgba(0,0,0,.78); color:#fff;
|
||||
font-size:0.7rem; font-weight:600; font-family:var(--font-mono);
|
||||
padding:1px 5px; border-radius:4px; line-height:1.5;
|
||||
}
|
||||
.fgolden {
|
||||
position:absolute; left:6px; top:6px;
|
||||
display:inline-flex; align-items:center; gap:3px;
|
||||
background:var(--accent); color:#fff;
|
||||
font-size:0.68rem; font-weight:700; padding:2px 6px; border-radius:4px; line-height:1.5;
|
||||
}
|
||||
.fgolden svg { width:11px; height:11px; }
|
||||
|
||||
.fbody { padding:0.7rem 0.8rem 0.55rem; display:flex; flex-direction:column; gap:0.35rem; flex:1; }
|
||||
.fmeta { display:flex; align-items:center; gap:0.35rem; font-size:0.72rem; color:var(--text-3); min-width:0; }
|
||||
.fmeta .fprog { color:var(--text-2); font-weight:600; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||
.ftitle {
|
||||
font-size:0.87rem; font-weight:600; line-height:1.4; color:var(--text);
|
||||
display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical; overflow:hidden;
|
||||
}
|
||||
.ftitle:hover { text-decoration:underline; }
|
||||
.fstats { display:flex; align-items:center; gap:0.4rem; flex-wrap:wrap; font-size:0.72rem; color:var(--text-3); }
|
||||
.fstats .num { font-family:var(--font-mono); font-variant-numeric:tabular-nums; }
|
||||
|
||||
.factions {
|
||||
display:flex; align-items:center; gap:0.4rem;
|
||||
padding:0.5rem 0.8rem 0.7rem;
|
||||
}
|
||||
.factions .btn { padding:0.5rem 0.7rem; min-height:38px; font-size:0.78rem; }
|
||||
.factions .icon-btn {
|
||||
display:inline-flex; align-items:center; justify-content:center;
|
||||
width:38px; height:38px; flex-shrink:0;
|
||||
background:var(--surface); border:1px solid var(--border-strong); border-radius:var(--r-sm);
|
||||
color:var(--text-2); cursor:pointer; transition:all .18s ease;
|
||||
}
|
||||
.factions .icon-btn:hover { background:var(--surface-2); color:var(--text); }
|
||||
.factions .icon-btn.on { color:var(--warning); border-color:var(--warning); }
|
||||
.factions .grow { flex:1; }
|
||||
|
||||
/* ===== 상태(로딩/빈/에러) ===== */
|
||||
.feed-state { padding:3rem 1rem; text-align:center; color:var(--text-2); }
|
||||
.feed-state .fs-ic {
|
||||
width:44px; height:44px; margin:0 auto 0.9rem; border-radius:12px;
|
||||
background:var(--surface-2); color:var(--text-3);
|
||||
display:flex; align-items:center; justify-content:center;
|
||||
}
|
||||
.feed-state .fs-t { font-weight:600; color:var(--text); margin-bottom:0.35rem; }
|
||||
.feed-state .fs-d { font-size:0.85rem; line-height:1.6; }
|
||||
.feed-state .fs-a { margin-top:1rem; display:flex; gap:0.5rem; justify-content:center; flex-wrap:wrap; }
|
||||
|
||||
.skel-card { border:1px solid var(--border); border-radius:var(--r); overflow:hidden; background:var(--surface); }
|
||||
.skel-card .sk-thumb { aspect-ratio:16/9; }
|
||||
.skel-card .sk-line { height:11px; margin:0.6rem 0.8rem; border-radius:4px; }
|
||||
.skel-card .sk-line.short { width:45%; }
|
||||
|
||||
/* ===== 시드 모달 ===== */
|
||||
.seed-add { display:flex; gap:0.5rem; flex-wrap:wrap; }
|
||||
.seed-add input { flex:1; min-width:170px; }
|
||||
.seed-divider { height:1px; background:var(--border); margin:1.1rem 0 0.9rem; }
|
||||
.seed-list { display:flex; flex-direction:column; gap:0.45rem; max-height:340px; overflow-y:auto; }
|
||||
.seed-row {
|
||||
display:flex; align-items:center; gap:0.6rem;
|
||||
padding:0.55rem 0.7rem; border:1px solid var(--border); border-radius:var(--r-sm); background:var(--surface);
|
||||
}
|
||||
.seed-row img { width:32px; height:32px; border-radius:50%; object-fit:cover; flex-shrink:0; background:var(--inset); }
|
||||
.seed-row .sname { flex:1; min-width:0; }
|
||||
.seed-row .sname b { display:block; font-size:0.83rem; font-weight:600; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||
.seed-row .sname span { font-size:0.72rem; color:var(--text-3); }
|
||||
.seed-row button {
|
||||
display:inline-flex; align-items:center; justify-content:center;
|
||||
width:34px; height:34px; flex-shrink:0;
|
||||
background:transparent; border:1px solid var(--border); border-radius:var(--r-sm);
|
||||
color:var(--text-3); cursor:pointer; transition:all .18s ease;
|
||||
}
|
||||
.seed-row button:hover { color:var(--danger); border-color:var(--danger); }
|
||||
.seed-row button.warn:hover { color:var(--warning); border-color:var(--warning); }
|
||||
|
||||
/* ===== 토스트 ===== */
|
||||
.toast {
|
||||
position:fixed; left:50%; bottom:24px; transform:translate(-50%, 12px);
|
||||
background:var(--text); color:var(--bg);
|
||||
padding:0.65rem 1.1rem; border-radius:var(--r-sm); font-size:0.85rem; font-weight:600;
|
||||
box-shadow:var(--shadow-lg); z-index:2000;
|
||||
opacity:0; pointer-events:none; transition:opacity .2s ease, transform .2s ease;
|
||||
max-width:88vw; text-align:center;
|
||||
}
|
||||
.toast.show { opacity:1; transform:translate(-50%, 0); }
|
||||
.toast.err { background:var(--danger); color:#fff; }
|
||||
|
||||
.sr-only {
|
||||
position:absolute; width:1px; height:1px; padding:0; margin:-1px;
|
||||
overflow:hidden; clip:rect(0,0,0,0); white-space:nowrap; border:0;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.fcard, .feed-tab, .toast, .factions .icon-btn, .seed-row button { transition:none; }
|
||||
}
|
||||
</style>
|
||||
|
||||
<script th:inline="javascript">
|
||||
/*<![CDATA[*/
|
||||
const API = '/api/feed';
|
||||
const CV_API = '/api/v1/channel-videos';
|
||||
|
||||
let currentTab = 'SOURCE';
|
||||
let items = [];
|
||||
|
||||
// ---------- 공통 ----------
|
||||
function esc(s){ return (s==null?'':String(s)).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
||||
function fmtNum(n){
|
||||
if(n==null) return '-';
|
||||
const v = Number(n);
|
||||
if(v >= 100000000) return (v/100000000).toFixed(1).replace(/\.0$/,'') + '억';
|
||||
if(v >= 10000) return (v/10000).toFixed(1).replace(/\.0$/,'') + '만';
|
||||
return v.toLocaleString();
|
||||
}
|
||||
function fmtDur(sec){
|
||||
if(sec==null || sec<=0) return '';
|
||||
const h = Math.floor(sec/3600), m = Math.floor((sec%3600)/60), s = sec%60;
|
||||
const pad = v => String(v).padStart(2,'0');
|
||||
return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${m}:${pad(s)}`;
|
||||
}
|
||||
function fmtAgo(iso){
|
||||
if(!iso) return '-';
|
||||
const then = new Date(iso).getTime();
|
||||
if(isNaN(then)) return '-';
|
||||
const mins = Math.max(0, Math.floor((Date.now() - then) / 60000));
|
||||
if(mins < 60) return `${mins}분 전`;
|
||||
const hrs = Math.floor(mins/60);
|
||||
if(hrs < 24) return `${hrs}시간 전`;
|
||||
const days = Math.floor(hrs/24);
|
||||
if(days < 30) return `${days}일 전`;
|
||||
return new Date(then).toLocaleDateString('ko-KR');
|
||||
}
|
||||
|
||||
let toastTimer = null;
|
||||
function toast(msg, isError){
|
||||
const el = document.getElementById('toast');
|
||||
el.textContent = msg;
|
||||
el.classList.toggle('err', !!isError);
|
||||
el.classList.add('show');
|
||||
clearTimeout(toastTimer);
|
||||
toastTimer = setTimeout(()=> el.classList.remove('show'), 3500);
|
||||
}
|
||||
|
||||
async function api(url, opts){
|
||||
const res = await fetch(url, opts);
|
||||
const json = await res.json().catch(()=>({}));
|
||||
if(!res.ok || (json && json.success===false)) throw new Error((json && json.message) || ('HTTP '+res.status));
|
||||
return json.data;
|
||||
}
|
||||
|
||||
// ---------- 상태 표시 ----------
|
||||
function showState(icon, title, desc, actionsHtml){
|
||||
document.getElementById('feedGrid').innerHTML = '';
|
||||
const el = document.getElementById('feedState');
|
||||
el.classList.remove('hidden');
|
||||
el.innerHTML = `<div class="fs-ic"><i data-lucide="${icon}"></i></div>
|
||||
<div class="fs-t">${esc(title)}</div>
|
||||
<div class="fs-d">${desc}</div>
|
||||
${actionsHtml ? `<div class="fs-a">${actionsHtml}</div>` : ''}`;
|
||||
if(window.lucide) lucide.createIcons();
|
||||
}
|
||||
function hideState(){ document.getElementById('feedState').classList.add('hidden'); }
|
||||
|
||||
function showSkeleton(){
|
||||
hideState();
|
||||
const one = `<div class="skel-card">
|
||||
<div class="sk-thumb skeleton"></div>
|
||||
<div class="sk-line skeleton"></div>
|
||||
<div class="sk-line short skeleton"></div>
|
||||
</div>`;
|
||||
document.getElementById('feedGrid').innerHTML = one.repeat(8);
|
||||
}
|
||||
|
||||
// ---------- 탭 ----------
|
||||
function switchTab(tab){
|
||||
if(tab === currentTab) return;
|
||||
currentTab = 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('fProgram').value = '';
|
||||
loadPrograms().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';
|
||||
switchTab(next);
|
||||
document.getElementById('tab-' + next).focus();
|
||||
});
|
||||
|
||||
// ---------- 필터 ----------
|
||||
function readFilters(){
|
||||
return {
|
||||
tab: currentTab,
|
||||
channelId: document.getElementById('fProgram').value,
|
||||
lengthBucket: document.getElementById('fLength').value,
|
||||
days: document.getElementById('fDays').value,
|
||||
hideWorked: document.getElementById('fHideWorked').checked
|
||||
};
|
||||
}
|
||||
|
||||
function applyFilters(){
|
||||
const f = readFilters();
|
||||
// 새로고침·뒤로가기에서 필터가 보존되도록 URL 에 반영
|
||||
const q = new URLSearchParams();
|
||||
q.set('tab', f.tab);
|
||||
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');
|
||||
history.replaceState(null, '', location.pathname + (q.toString() ? '?'+q.toString() : ''));
|
||||
loadFeed();
|
||||
}
|
||||
|
||||
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(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'); // 프로그램 목록 로드 후 적용
|
||||
}
|
||||
|
||||
// ---------- 프로그램 목록 ----------
|
||||
async function loadPrograms(preselect){
|
||||
const sel = document.getElementById('fProgram');
|
||||
const keep = preselect || sel.value;
|
||||
let list = [];
|
||||
try { list = await api(`${API}/programs?tab=${currentTab}`) || []; } catch(e){ list = []; }
|
||||
sel.innerHTML = '<option value="">전체</option>' + list.map(p =>
|
||||
`<option value="${esc(p.ytChannelId)}">${esc(p.channelTitle||'(이름 없음)')} (${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.lengthBucket) p.set('lengthBucket', f.lengthBucket);
|
||||
if(f.days && f.days !== '0') p.set('days', f.days);
|
||||
if(f.hideWorked) p.set('hideWorked', 'true');
|
||||
|
||||
try {
|
||||
items = await api(API + '?' + p.toString()) || [];
|
||||
} catch(e){
|
||||
document.getElementById('resultCount').textContent = '—';
|
||||
showState('alert-triangle', '불러오지 못했습니다', esc(e.message),
|
||||
'<button class="btn btn-secondary" onclick="loadFeed()">다시 시도</button>');
|
||||
return;
|
||||
}
|
||||
render();
|
||||
}
|
||||
|
||||
function badgeHtml(it){
|
||||
const out = [];
|
||||
if(currentTab === 'SOURCE'){
|
||||
if(it.lengthBucket === 'CLIP') out.push('<span class="badge badge-primary">공식클립</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('');
|
||||
}
|
||||
|
||||
function cardHtml(it){
|
||||
const url = 'https://www.youtube.com/watch?v=' + encodeURIComponent(it.videoId);
|
||||
const dur = fmtDur(it.durationSec);
|
||||
const golden = it.goldenTime
|
||||
? '<span class="fgolden"><i data-lucide="zap"></i>골든타임</span>' : '';
|
||||
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>`;
|
||||
|
||||
return `<article class="fcard${it.worked ? ' is-worked' : ''}" data-id="${it.id}">
|
||||
<a class="fthumb" href="${url}" target="_blank" rel="noopener"
|
||||
aria-label="${esc(it.title)} — YouTube 에서 열기">
|
||||
<img src="${esc(it.thumbnailUrl)}" alt="" loading="lazy" width="480" height="270">
|
||||
${golden}
|
||||
${dur ? `<span class="fdur">${dur}</span>` : ''}
|
||||
</a>
|
||||
<div class="fbody">
|
||||
<div class="fmeta">
|
||||
<span class="fprog">${esc(it.channelTitle || '-')}</span>
|
||||
<span>·</span>
|
||||
<span>${fmtAgo(it.publishedAt)}</span>
|
||||
</div>
|
||||
<a class="ftitle" href="${url}" target="_blank" rel="noopener">${esc(it.title)}</a>
|
||||
<div class="fstats">
|
||||
<span class="num">${fmtNum(it.viewCount)}회</span>${vph}
|
||||
${badgeHtml(it)}
|
||||
</div>
|
||||
</div>
|
||||
<div class="factions">
|
||||
${primary}
|
||||
<button class="icon-btn${it.bookmarked ? ' on' : ''}" title="북마크"
|
||||
aria-label="북마크" aria-pressed="${it.bookmarked}"
|
||||
onclick="toggleBookmark(${it.id}, ${!!it.bookmarked})">
|
||||
<i data-lucide="star" style="width:16px;"></i>
|
||||
</button>
|
||||
<button class="icon-btn" title="숨김" aria-label="이 소재 숨기기"
|
||||
onclick="hideItem(${it.id})">
|
||||
<i data-lucide="eye-off" style="width:16px;"></i>
|
||||
</button>
|
||||
</div>
|
||||
</article>`;
|
||||
}
|
||||
|
||||
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>`);
|
||||
return;
|
||||
}
|
||||
hideState();
|
||||
document.getElementById('feedGrid').innerHTML = items.map(cardHtml).join('');
|
||||
if(window.lucide) lucide.createIcons();
|
||||
}
|
||||
|
||||
// ---------- 카드 액션 ----------
|
||||
async function toggleBookmark(id, current){
|
||||
try {
|
||||
await api(`${CV_API}/${id}/bookmark`, {
|
||||
method:'POST', headers:{'Content-Type':'application/json'},
|
||||
body: JSON.stringify({ bookmarked: !current })
|
||||
});
|
||||
const it = items.find(v => v.id === id);
|
||||
if(it) it.bookmarked = !current;
|
||||
render();
|
||||
toast(!current ? '북마크에 담았습니다' : '북마크를 해제했습니다');
|
||||
} catch(e){ toast('북마크 실패: ' + e.message, true); }
|
||||
}
|
||||
|
||||
async function hideItem(id){
|
||||
try {
|
||||
await api(`${CV_API}/${id}/status`, {
|
||||
method:'POST', headers:{'Content-Type':'application/json'},
|
||||
body: JSON.stringify({ status:'EXCLUDED' })
|
||||
});
|
||||
items = items.filter(v => v.id !== id);
|
||||
render();
|
||||
toast('숨겼습니다 — 되돌리려면 수집함에서 상태를 바꾸세요');
|
||||
} catch(e){ toast('숨김 실패: ' + e.message, true); }
|
||||
}
|
||||
|
||||
// ---------- 수집 ----------
|
||||
async function runCollect(){
|
||||
const btn = document.getElementById('collectBtn');
|
||||
btn.disabled = true;
|
||||
const original = btn.innerHTML;
|
||||
btn.innerHTML = '<i data-lucide="loader-2" style="width:15px;" class="animate-spin"></i> 수집 중...';
|
||||
if(window.lucide) lucide.createIcons();
|
||||
try {
|
||||
const r = await api(API + '/collect', { method:'POST' });
|
||||
toast(`수집 완료 — 시드 ${r.seeds}개 중 ${r.collected}개, 영상 ${r.savedVideos}건`);
|
||||
await loadPrograms();
|
||||
await loadFeed();
|
||||
} catch(e){
|
||||
toast('수집 실패: ' + e.message, true);
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = original;
|
||||
if(window.lucide) lucide.createIcons();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 시드 관리 ----------
|
||||
function openSeeds(){
|
||||
document.getElementById('seedModal').classList.add('open');
|
||||
loadSeeds();
|
||||
setTimeout(()=> document.getElementById('seedUrl').focus(), 50);
|
||||
}
|
||||
function closeSeeds(){ document.getElementById('seedModal').classList.remove('open'); }
|
||||
|
||||
async function loadSeeds(){
|
||||
const box = document.getElementById('seedList');
|
||||
box.innerHTML = '<div class="text-sm text-muted p-4">로딩 중...</div>';
|
||||
let list = [];
|
||||
try { list = await api(API + '/seeds') || []; }
|
||||
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">등록된 시드가 없습니다. 위에서 채널 URL 을 추가하세요.</div>';
|
||||
return;
|
||||
}
|
||||
box.innerHTML = list.map(s => {
|
||||
const roleLabel = s.role === 'SOURCE' ? '소재 원본' : '경쟁 쇼츠';
|
||||
const roleCls = s.role === 'SOURCE' ? 'badge-primary' : 'badge-muted';
|
||||
const warn = s.disabled
|
||||
? `<button class="warn" title="수집 실패 ${s.failCount}회로 중단됨 — 다시 활성화"
|
||||
aria-label="시드 다시 활성화" onclick="resetSeed(${s.id})">
|
||||
<i data-lucide="rotate-ccw" style="width:15px;"></i></button>`
|
||||
: '';
|
||||
return `<div class="seed-row">
|
||||
<img src="${esc(s.thumbnailUrl)}" alt="" loading="lazy">
|
||||
<div class="sname">
|
||||
<b>${esc(s.title)}</b>
|
||||
<span>구독 ${fmtNum(s.subscriberCount)}${s.disabled ? ' · 수집 중단됨' : ''}</span>
|
||||
</div>
|
||||
<span class="badge ${roleCls}">${roleLabel}</span>
|
||||
${warn}
|
||||
<button title="시드 해제" aria-label="시드 해제" onclick="removeSeed(${s.id}, '${esc(s.title)}')">
|
||||
<i data-lucide="trash-2" style="width:15px;"></i></button>
|
||||
</div>`;
|
||||
}).join('');
|
||||
if(window.lucide) lucide.createIcons();
|
||||
}
|
||||
|
||||
async function addSeed(){
|
||||
const url = document.getElementById('seedUrl').value.trim();
|
||||
const help = document.getElementById('seedAddHelp');
|
||||
if(!url){
|
||||
help.textContent = '채널 URL 을 입력하세요.';
|
||||
help.style.color = 'var(--danger)';
|
||||
document.getElementById('seedUrl').focus();
|
||||
return;
|
||||
}
|
||||
help.textContent = '채널 URL 또는 @핸들 주소를 붙여넣으세요.';
|
||||
help.style.color = '';
|
||||
|
||||
const btn = document.getElementById('seedAddBtn');
|
||||
btn.disabled = true;
|
||||
try {
|
||||
const role = document.getElementById('seedRole').value;
|
||||
const s = await api(API + '/seeds', {
|
||||
method:'POST', headers:{'Content-Type':'application/json'},
|
||||
body: JSON.stringify({ url, role })
|
||||
});
|
||||
document.getElementById('seedUrl').value = '';
|
||||
toast(`${s.title} 을(를) 시드로 등록했습니다`);
|
||||
await loadSeeds();
|
||||
} catch(e){
|
||||
help.textContent = '등록 실패: ' + e.message;
|
||||
help.style.color = 'var(--danger)';
|
||||
} finally { btn.disabled = false; }
|
||||
}
|
||||
|
||||
async function removeSeed(id, title){
|
||||
if(!confirm(`"${title}" 시드를 해제할까요?\n이 채널에서 수집한 피드 영상도 함께 삭제됩니다.`)) return;
|
||||
try {
|
||||
await api(`${API}/seeds/${id}`, { method:'DELETE' });
|
||||
toast('시드를 해제했습니다');
|
||||
await loadSeeds();
|
||||
await loadPrograms();
|
||||
await loadFeed();
|
||||
} catch(e){ toast('해제 실패: ' + e.message, true); }
|
||||
}
|
||||
|
||||
async function resetSeed(id){
|
||||
try {
|
||||
await api(`${API}/seeds/${id}/reset-failure`, { method:'POST' });
|
||||
toast('다시 활성화했습니다');
|
||||
await loadSeeds();
|
||||
} catch(e){ toast('실패: ' + e.message, true); }
|
||||
}
|
||||
|
||||
async function suggestSeeds(){
|
||||
if(!confirm('내 채널 해시태그를 역분석해 소재 원본 후보를 찾습니다.\n키워드 1개당 YouTube 쿼터 100 units 를 씁니다. 진행할까요?')) return;
|
||||
try {
|
||||
const r = await api(API + '/seeds/suggest', {
|
||||
method:'POST', headers:{'Content-Type':'application/json'}, body:'{}'
|
||||
});
|
||||
toast(`후보 ${r.saved}개를 추천 채널에 담았습니다 — 추천 채널 화면에서 승인하세요`);
|
||||
} catch(e){ toast('발굴 실패: ' + e.message, true); }
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', e => { if(e.key === 'Escape') closeSeeds(); });
|
||||
|
||||
// ---------- init ----------
|
||||
(async () => {
|
||||
const preselect = restoreFilters();
|
||||
await loadPrograms(preselect);
|
||||
await loadFeed();
|
||||
})();
|
||||
/*]]>*/
|
||||
</script>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@ -20,6 +20,11 @@
|
||||
</div>
|
||||
|
||||
<nav style="flex:1;padding:0.5rem 0.75rem;overflow-y:auto;">
|
||||
<div class="nav-section">소재</div>
|
||||
<a th:href="@{/feed}" class="nav-item" th:classappend="${currentPage == 'feed'} ? 'active'">
|
||||
<i data-lucide="rss" class="nav-icon"></i><span class="nav-text">소재 피드</span>
|
||||
</a>
|
||||
|
||||
<div class="nav-section">분석</div>
|
||||
<a th:href="@{/}" class="nav-item" th:classappend="${currentPage == 'dashboard'} ? 'active'">
|
||||
<i data-lucide="layout-dashboard" class="nav-icon"></i><span class="nav-text">대시보드</span>
|
||||
|
||||
@ -0,0 +1,36 @@
|
||||
package com.hlab.yanalyst.domain.channel;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class ChannelRoleTest {
|
||||
|
||||
@Test
|
||||
void normalize_null과_미인식값은_MY() {
|
||||
// ddl-auto:update 로 컬럼이 추가된 기존 행은 role 이 null 이다
|
||||
assertThat(ChannelRole.normalize(null)).isEqualTo(ChannelRole.MY);
|
||||
assertThat(ChannelRole.normalize(" ")).isEqualTo(ChannelRole.MY);
|
||||
assertThat(ChannelRole.normalize("garbage")).isEqualTo(ChannelRole.MY);
|
||||
}
|
||||
|
||||
@Test
|
||||
void normalize_대소문자와_공백_보정() {
|
||||
assertThat(ChannelRole.normalize(" source ")).isEqualTo(ChannelRole.SOURCE);
|
||||
assertThat(ChannelRole.normalize("rival")).isEqualTo(ChannelRole.RIVAL);
|
||||
}
|
||||
|
||||
@Test
|
||||
void isFeed는_SOURCE와_RIVAL만() {
|
||||
assertThat(ChannelRole.isFeed(ChannelRole.SOURCE)).isTrue();
|
||||
assertThat(ChannelRole.isFeed(ChannelRole.RIVAL)).isTrue();
|
||||
assertThat(ChannelRole.isFeed(ChannelRole.MY)).isFalse();
|
||||
assertThat(ChannelRole.isFeed(null)).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void acceptsShorts_경쟁채널만_쇼츠_소스채널은_롱폼() {
|
||||
assertThat(ChannelRole.acceptsShorts(ChannelRole.RIVAL)).isTrue();
|
||||
assertThat(ChannelRole.acceptsShorts(ChannelRole.SOURCE)).isFalse();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,52 @@
|
||||
package com.hlab.yanalyst.domain.channel;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class FeedBadgesTest {
|
||||
|
||||
private static final LocalDateTime NOW = LocalDateTime.of(2026, 7, 31, 12, 0);
|
||||
|
||||
@Test
|
||||
void 골든타임은_24시간_미만() {
|
||||
assertThat(FeedBadges.isGoldenTime(NOW.minusHours(23), NOW)).isTrue();
|
||||
assertThat(FeedBadges.isGoldenTime(NOW.minusHours(24), NOW)).isFalse(); // 경계: 24시간 정각은 제외
|
||||
assertThat(FeedBadges.isGoldenTime(NOW.minusDays(3), NOW)).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void 골든타임_null과_미래시각_방어() {
|
||||
assertThat(FeedBadges.isGoldenTime(null, NOW)).isFalse();
|
||||
assertThat(FeedBadges.isGoldenTime(NOW, null)).isFalse();
|
||||
assertThat(FeedBadges.isGoldenTime(NOW.plusMinutes(5), NOW)).isTrue(); // 시계 오차는 최신으로 취급
|
||||
}
|
||||
|
||||
@Test
|
||||
void 길이버킷_경계값() {
|
||||
assertThat(FeedBadges.lengthBucket(65)).isEqualTo(FeedBadges.BUCKET_SHORTS); // 65초 이하 = 쇼츠
|
||||
assertThat(FeedBadges.lengthBucket(66)).isEqualTo(FeedBadges.BUCKET_CLIP);
|
||||
assertThat(FeedBadges.lengthBucket(900)).isEqualTo(FeedBadges.BUCKET_CLIP); // 15분 = 공식클립
|
||||
assertThat(FeedBadges.lengthBucket(901)).isEqualTo(FeedBadges.BUCKET_FULL);
|
||||
assertThat(FeedBadges.lengthBucket(null)).isEqualTo(FeedBadges.BUCKET_UNKNOWN);
|
||||
assertThat(FeedBadges.lengthBucket(0)).isEqualTo(FeedBadges.BUCKET_UNKNOWN);
|
||||
}
|
||||
|
||||
@Test
|
||||
void 떡상_판정은_시간당_조회수_1000_이상() {
|
||||
assertThat(FeedBadges.isRising(BigDecimal.valueOf(1000))).isTrue();
|
||||
assertThat(FeedBadges.isRising(BigDecimal.valueOf(999.99))).isFalse();
|
||||
assertThat(FeedBadges.isRising(null)).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void 작업함은_NEW가_아닌_모든_상태() {
|
||||
assertThat(FeedBadges.isWorked("NEW")).isFalse();
|
||||
assertThat(FeedBadges.isWorked(null)).isFalse();
|
||||
assertThat(FeedBadges.isWorked("TARGET")).isTrue();
|
||||
assertThat(FeedBadges.isWorked("DONE")).isTrue();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,73 @@
|
||||
package com.hlab.yanalyst.domain.channel;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class HashtagExtractorTest {
|
||||
|
||||
@Test
|
||||
void extract_한글영문_해시태그_추출_중복제거() {
|
||||
String desc = "맛있는 명장면만 #유퀴즈 #윤경호 #유퀴즈 #shorts";
|
||||
|
||||
assertThat(HashtagExtractor.extract(desc)).containsExactly("유퀴즈", "윤경호");
|
||||
}
|
||||
|
||||
@Test
|
||||
void extract_범용태그_불용어_제외() {
|
||||
String desc = "#shorts #유튜브쇼츠 #숏폼 #명장면 #짤 #예능 #핑계고";
|
||||
|
||||
// 프로그램명 후보만 남는다
|
||||
assertThat(HashtagExtractor.extract(desc)).containsExactly("핑계고");
|
||||
}
|
||||
|
||||
@Test
|
||||
void extract_한글자태그와_빈입력_방어() {
|
||||
assertThat(HashtagExtractor.extract("#1 #가 #아는형님")).containsExactly("아는형님");
|
||||
assertThat(HashtagExtractor.extract(null)).isEmpty();
|
||||
assertThat(HashtagExtractor.extract(" ")).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void extract_구두점에서_끊긴다() {
|
||||
assertThat(HashtagExtractor.extract("#살롱드립,#미미미누 #워크맨."))
|
||||
.containsExactly("살롱드립", "미미미누", "워크맨");
|
||||
}
|
||||
|
||||
@Test
|
||||
void countAll_빈도내림차순_그리고_minCount_미만_제거() {
|
||||
List<String> texts = List.of(
|
||||
"#유퀴즈 #윤경호",
|
||||
"#유퀴즈 #소지섭",
|
||||
"#유퀴즈 #핑계고",
|
||||
"#핑계고",
|
||||
"#아는형님" // 1회 → minCount 2 미달
|
||||
);
|
||||
|
||||
Map<String, Integer> counts = HashtagExtractor.countAll(texts, 2);
|
||||
|
||||
assertThat(counts).containsExactly(
|
||||
Map.entry("유퀴즈", 3),
|
||||
Map.entry("핑계고", 2));
|
||||
}
|
||||
|
||||
@Test
|
||||
void countAll_대소문자_무시하고_첫_표기를_유지() {
|
||||
Map<String, Integer> counts = HashtagExtractor.countAll(List.of("#Netflix", "#netflix"), 1);
|
||||
|
||||
assertThat(counts).containsExactly(Map.entry("Netflix", 2));
|
||||
}
|
||||
|
||||
@Test
|
||||
void join_split_왕복() {
|
||||
String joined = HashtagExtractor.join(List.of("유퀴즈", "윤경호"));
|
||||
|
||||
assertThat(joined).isEqualTo("유퀴즈,윤경호");
|
||||
assertThat(HashtagExtractor.split(joined)).containsExactly("유퀴즈", "윤경호");
|
||||
assertThat(HashtagExtractor.join(List.of())).isNull();
|
||||
assertThat(HashtagExtractor.split(null)).isEmpty();
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user