h-lab/docs/superpowers/specs/2026-07-31-source-feed-design.md
hehihoho3@gmail.com 592ed12c2b 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>
2026-07-31 15:42:26 +09:00

141 lines
7.3 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 소재 발굴 피드 (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)
- 애니메이션 150300ms, `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` — 골든타임/길이버킷/떡상 판정 경계값