From 592ed12c2bb405a56a6613b51609bad40df7b46a Mon Sep 17 00:00:00 2001 From: "hehihoho3@gmail.com" Date: Fri, 31 Jul 2026 15:42:26 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=86=8C=EC=9E=AC=20=EB=B0=9C=EA=B5=B4?= =?UTF-8?q?=20=ED=94=BC=EB=93=9C=20=E2=80=94=20=EC=86=8C=EC=8A=A4=20?= =?UTF-8?q?=EB=A1=B1=ED=8F=BC/=EA=B2=BD=EC=9F=81=20=EC=87=BC=EC=B8=A0=20?= =?UTF-8?q?=EC=B5=9C=EC=8B=A0=EC=88=9C=202=ED=83=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 쇼츠 클립 채널의 소재를 최신순으로 발굴하는 /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) --- .../specs/2026-07-31-source-feed-design.md | 140 ++++ .../hlab/yanalyst/domain/channel/Channel.java | 45 +- .../domain/channel/ChannelRepository.java | 24 + .../yanalyst/domain/channel/ChannelRole.java | 42 ++ .../domain/channel/ChannelService.java | 73 +- .../yanalyst/domain/channel/ChannelVideo.java | 18 + .../channel/ChannelVideoRepository.java | 40 +- .../yanalyst/domain/channel/FeedBadges.java | 50 ++ .../domain/channel/FeedCollectionService.java | 121 ++++ .../domain/channel/FeedController.java | 94 +++ .../yanalyst/domain/channel/FeedService.java | 126 ++++ .../domain/channel/HashtagExtractor.java | 99 +++ .../domain/channel/RecommendedChannel.java | 10 + .../channel/RecommendedChannelController.java | 24 +- .../channel/RecommendedChannelRepository.java | 3 + .../domain/channel/SeedSuggestService.java | 206 ++++++ .../domain/channel/dto/FeedItemDto.java | 57 ++ .../domain/channel/dto/FeedProgramDto.java | 4 + .../domain/channel/dto/FeedSeedDto.java | 11 + .../schedule/ScheduledCollectionService.java | 6 +- .../com/hlab/yanalyst/web/WebController.java | 6 + src/main/resources/application.yml | 9 + src/main/resources/templates/feed.html | 673 ++++++++++++++++++ .../resources/templates/layout/sidebar.html | 5 + .../domain/channel/ChannelRoleTest.java | 36 + .../domain/channel/FeedBadgesTest.java | 52 ++ .../domain/channel/HashtagExtractorTest.java | 73 ++ 27 files changed, 2034 insertions(+), 13 deletions(-) create mode 100644 docs/superpowers/specs/2026-07-31-source-feed-design.md create mode 100644 src/main/java/com/hlab/yanalyst/domain/channel/ChannelRole.java create mode 100644 src/main/java/com/hlab/yanalyst/domain/channel/FeedBadges.java create mode 100644 src/main/java/com/hlab/yanalyst/domain/channel/FeedCollectionService.java create mode 100644 src/main/java/com/hlab/yanalyst/domain/channel/FeedController.java create mode 100644 src/main/java/com/hlab/yanalyst/domain/channel/FeedService.java create mode 100644 src/main/java/com/hlab/yanalyst/domain/channel/HashtagExtractor.java create mode 100644 src/main/java/com/hlab/yanalyst/domain/channel/SeedSuggestService.java create mode 100644 src/main/java/com/hlab/yanalyst/domain/channel/dto/FeedItemDto.java create mode 100644 src/main/java/com/hlab/yanalyst/domain/channel/dto/FeedProgramDto.java create mode 100644 src/main/java/com/hlab/yanalyst/domain/channel/dto/FeedSeedDto.java create mode 100644 src/main/resources/templates/feed.html create mode 100644 src/test/java/com/hlab/yanalyst/domain/channel/ChannelRoleTest.java create mode 100644 src/test/java/com/hlab/yanalyst/domain/channel/FeedBadgesTest.java create mode 100644 src/test/java/com/hlab/yanalyst/domain/channel/HashtagExtractorTest.java diff --git a/docs/superpowers/specs/2026-07-31-source-feed-design.md b/docs/superpowers/specs/2026-07-31-source-feed-design.md new file mode 100644 index 0000000..8c10676 --- /dev/null +++ b/docs/superpowers/specs/2026-07-31-source-feed-design.md @@ -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` — 골든타임/길이버킷/떡상 판정 경계값 diff --git a/src/main/java/com/hlab/yanalyst/domain/channel/Channel.java b/src/main/java/com/hlab/yanalyst/domain/channel/Channel.java index 3972688..fcd9415 100644 --- a/src/main/java/com/hlab/yanalyst/domain/channel/Channel.java +++ b/src/main/java/com/hlab/yanalyst/domain/channel/Channel.java @@ -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 @@ -84,6 +98,33 @@ public class Channel { public void setCountry(String country) { 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; + } } diff --git a/src/main/java/com/hlab/yanalyst/domain/channel/ChannelRepository.java b/src/main/java/com/hlab/yanalyst/domain/channel/ChannelRepository.java index a41721e..22a542e 100644 --- a/src/main/java/com/hlab/yanalyst/domain/channel/ChannelRepository.java +++ b/src/main/java/com/hlab/yanalyst/domain/channel/ChannelRepository.java @@ -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 { Optional 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 findMyChannels(); + + /** 특정 역할의 채널. SOURCE/RIVAL 조회용. */ + List 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 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 findByChannelIdIn(@Param("channelIds") List channelIds); } diff --git a/src/main/java/com/hlab/yanalyst/domain/channel/ChannelRole.java b/src/main/java/com/hlab/yanalyst/domain/channel/ChannelRole.java new file mode 100644 index 0000000..c97e46d --- /dev/null +++ b/src/main/java/com/hlab/yanalyst/domain/channel/ChannelRole.java @@ -0,0 +1,42 @@ +package com.hlab.yanalyst.domain.channel; + +/** + * 등록 채널의 역할. + * + *
    + *
  • {@link #MY} — 내 채널(성과 추적 대상). 기존 등록 채널은 전부 여기에 해당한다.
  • + *
  • {@link #SOURCE} — 소재 원본 채널(웹예능 공식채널). 롱폼만 피드에 수집한다.
  • + *
  • {@link #RIVAL} — 경쟁 쇼츠 채널(같은 소재를 다루는 클립 채널). 쇼츠만 피드에 수집한다.
  • + *
+ * + * 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)); + } +} diff --git a/src/main/java/com/hlab/yanalyst/domain/channel/ChannelService.java b/src/main/java/com/hlab/yanalyst/domain/channel/ChannelService.java index 38a04e0..a6b07c9 100644 --- a/src/main/java/com/hlab/yanalyst/domain/channel/ChannelService.java +++ b/src/main/java/com/hlab/yanalyst/domain/channel/ChannelService.java @@ -213,8 +213,9 @@ public class ChannelService { return url; } + /** 내 채널 목록. 피드 시드(SOURCE/RIVAL)는 /feed 에서 따로 관리하므로 제외한다. */ public List 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 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 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 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); } diff --git a/src/main/java/com/hlab/yanalyst/domain/channel/ChannelVideo.java b/src/main/java/com/hlab/yanalyst/domain/channel/ChannelVideo.java index 986214e..135870c 100644 --- a/src/main/java/com/hlab/yanalyst/domain/channel/ChannelVideo.java +++ b/src/main/java/com/hlab/yanalyst/domain/channel/ChannelVideo.java @@ -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; diff --git a/src/main/java/com/hlab/yanalyst/domain/channel/ChannelVideoRepository.java b/src/main/java/com/hlab/yanalyst/domain/channel/ChannelVideoRepository.java index a7fea3b..a942d3e 100644 --- a/src/main/java/com/hlab/yanalyst/domain/channel/ChannelVideoRepository.java +++ b/src/main/java/com/hlab/yanalyst/domain/channel/ChannelVideoRepository.java @@ -20,8 +20,43 @@ public interface ChannelVideoRepository extends JpaRepository '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 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 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 findOutperformers(@Param("minRatio") java.math.BigDecimal minRatio, @@ -30,10 +65,12 @@ public interface ChannelVideoRepository extends JpaRepository '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'))") diff --git a/src/main/java/com/hlab/yanalyst/domain/channel/FeedBadges.java b/src/main/java/com/hlab/yanalyst/domain/channel/FeedBadges.java new file mode 100644 index 0000000..e65b9b4 --- /dev/null +++ b/src/main/java/com/hlab/yanalyst/domain/channel/FeedBadges.java @@ -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); + } +} diff --git a/src/main/java/com/hlab/yanalyst/domain/channel/FeedCollectionService.java b/src/main/java/com/hlab/yanalyst/domain/channel/FeedCollectionService.java new file mode 100644 index 0000000..696c25e --- /dev/null +++ b/src/main/java/com/hlab/yanalyst/domain/channel/FeedCollectionService.java @@ -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; + +/** + * 소재 발굴 피드 수집기. + * + *

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 collectAll() { + List 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 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); + }); + } +} diff --git a/src/main/java/com/hlab/yanalyst/domain/channel/FeedController.java b/src/main/java/com/hlab/yanalyst/domain/channel/FeedController.java new file mode 100644 index 0000000..94df4b0 --- /dev/null +++ b/src/main/java/com/hlab/yanalyst/domain/channel/FeedController.java @@ -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> 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> programs(@RequestParam(defaultValue = "SOURCE") String tab) { + return ApiResponse.ok(feedService.programs(tab)); + } + + @PostMapping("/collect") + @Operation(summary = "수동 수집", description = "스케줄과 동일한 피드 수집을 즉시 1회 실행") + public ApiResponse> collect() { + return ApiResponse.ok(feedCollectionService.collectAll()); + } + + // --- 시드(소스/경쟁 채널) 관리 --- + + @GetMapping("/seeds") + @Operation(summary = "시드 목록", description = "등록된 소재 원본·경쟁 채널") + public ApiResponse> seeds() { + return ApiResponse.ok(feedService.seeds()); + } + + @PostMapping("/seeds") + @Operation(summary = "시드 등록", description = "채널 URL 을 소재 원본(SOURCE) 또는 경쟁(RIVAL) 시드로 등록") + public ApiResponse addSeed(@RequestBody Map 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 removeSeed(@PathVariable Long id) { + feedService.removeSeed(id); + return ApiResponse.ok(null); + } + + @PostMapping("/seeds/{id}/reset-failure") + @Operation(summary = "실패 카운터 초기화", description = "연속 실패로 자동 스킵 중인 시드를 다시 활성화") + public ApiResponse resetFailure(@PathVariable Long id) { + feedService.resetSeedFailure(id); + return ApiResponse.ok(null); + } + + // --- 시드 자동 발굴(내 채널 역분석) --- + + @GetMapping("/seeds/keywords") + @Operation(summary = "해시태그 분석", description = "내 채널 영상의 해시태그 빈도(쿼터 소비 없음)") + public ApiResponse> keywords() { + return ApiResponse.ok(seedSuggestService.analyzeKeywords()); + } + + @PostMapping("/seeds/suggest") + @Operation(summary = "소재 원본 후보 발굴", + description = "해시태그 상위 키워드로 공식채널을 검색해 추천 목록(roleHint=SOURCE)에 쌓는다. 키워드당 100 units 소비.") + public ApiResponse> suggest(@RequestBody(required = false) Map body) { + @SuppressWarnings("unchecked") + List keywords = body == null ? null : (List) body.get("keywords"); + return ApiResponse.ok(seedSuggestService.suggestSourceSeeds(keywords)); + } +} diff --git a/src/main/java/com/hlab/yanalyst/domain/channel/FeedService.java b/src/main/java/com/hlab/yanalyst/domain/channel/FeedService.java new file mode 100644 index 0000000..5347e69 --- /dev/null +++ b/src/main/java/com/hlab/yanalyst/domain/channel/FeedService.java @@ -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 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 rows = channelVideoRepository.feed( + source, publishedAfter, channelFilter, minSec, maxSec, hideWorked, + PageRequest.of(0, MAX_ITEMS)); + + List out = new ArrayList<>(rows.size()); + for (ChannelVideo v : rows) out.add(FeedItemDto.from(v, now)); + return out; + } + + /** 필터 드롭다운용 프로그램(채널) 목록. */ + public List programs(String tab) { + String source = normalizeTab(tab); + List 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 seeds() { + List 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; + } +} diff --git a/src/main/java/com/hlab/yanalyst/domain/channel/HashtagExtractor.java b/src/main/java/com/hlab/yanalyst/domain/channel/HashtagExtractor.java new file mode 100644 index 0000000..1d6dc01 --- /dev/null +++ b/src/main/java/com/hlab/yanalyst/domain/channel/HashtagExtractor.java @@ -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; + +/** + * 영상 설명/제목에서 해시태그를 뽑고 빈도를 집계하는 순수 로직. + * + *

내 채널 업로드의 해시태그(#유퀴즈 #핑계고 …)를 역분석해 소재 원본 채널 후보를 찾는 데 쓴다. + * 프로그램명이 아닌 범용 태그(#shorts, #쇼츠 …)는 불용어로 걸러낸다. + */ +public final class HashtagExtractor { + + /** 한글/영문/숫자/밑줄로 이루어진 해시태그. 구두점·공백에서 끊는다. */ + private static final Pattern TAG = Pattern.compile("#([\\p{L}\\p{N}_]+)"); + + /** 프로그램명 판별에 방해가 되는 범용 태그. 소문자 비교. */ + private static final Set STOP_WORDS = Set.of( + "shorts", "short", "youtubeshorts", "유튜브쇼츠", "쇼츠", "숏폼", "숏츠", + "명장면", "짤", "짤방", "레전드", "reels", "릴스", "틱톡", "tiktok", + "예능", "드라마", "영화", "도파민", "웃긴동영상", "시간순삭", "fyp", "viral", "recommended" + ); + + private HashtagExtractor() {} + + /** 단일 텍스트에서 해시태그를 추출한다(원문 표기 유지, 중복 제거, 불용어 제외). */ + public static List extract(String text) { + List out = new ArrayList<>(); + if (text == null || text.isBlank()) return out; + + Matcher m = TAG.matcher(text); + Set 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 tags) { + if (tags == null || tags.isEmpty()) return null; + return String.join(",", tags); + } + + /** 저장된 쉼표 구분 문자열을 다시 목록으로. */ + public static List split(String joined) { + List 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 countAll(List texts, int minCount) { + Map counts = new LinkedHashMap<>(); // 소문자키 → 횟수 + Map 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> entries = new ArrayList<>(counts.entrySet()); + entries.sort(Comparator.>comparingInt(Map.Entry::getValue).reversed() + .thenComparing(Map.Entry::getKey)); + + Map out = new LinkedHashMap<>(); + for (Map.Entry e : entries) { + if (e.getValue() < minCount) continue; + out.put(display.get(e.getKey()), e.getValue()); + } + return out; + } +} diff --git a/src/main/java/com/hlab/yanalyst/domain/channel/RecommendedChannel.java b/src/main/java/com/hlab/yanalyst/domain/channel/RecommendedChannel.java index 3606e7f..add8ed2 100644 --- a/src/main/java/com/hlab/yanalyst/domain/channel/RecommendedChannel.java +++ b/src/main/java/com/hlab/yanalyst/domain/channel/RecommendedChannel.java @@ -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; diff --git a/src/main/java/com/hlab/yanalyst/domain/channel/RecommendedChannelController.java b/src/main/java/com/hlab/yanalyst/domain/channel/RecommendedChannelController.java index afa167d..855de4e 100644 --- a/src/main/java/com/hlab/yanalyst/domain/channel/RecommendedChannelController.java +++ b/src/main/java/com/hlab/yanalyst/domain/channel/RecommendedChannelController.java @@ -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(@RequestParam(defaultValue = "NEW") String status) { + @Operation(summary = "추천 채널 목록", + description = "status(NEW|EXCLUDED|REGISTERED, 기본 NEW)를 배율 내림차순으로 반환. roleHint 로 시드 후보만 좁힐 수 있다.") + public ApiResponse> 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 register(@PathVariable Long id) { + public ApiResponse 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); diff --git a/src/main/java/com/hlab/yanalyst/domain/channel/RecommendedChannelRepository.java b/src/main/java/com/hlab/yanalyst/domain/channel/RecommendedChannelRepository.java index 575e4d8..681ac71 100644 --- a/src/main/java/com/hlab/yanalyst/domain/channel/RecommendedChannelRepository.java +++ b/src/main/java/com/hlab/yanalyst/domain/channel/RecommendedChannelRepository.java @@ -9,4 +9,7 @@ public interface RecommendedChannelRepository extends JpaRepository findByChannelId(String channelId); List findByStatusOrderByRatioDesc(String status); boolean existsByChannelIdAndStatus(String channelId, String status); + + /** 역할 힌트별 추천 목록(시드 후보). roleHint = SOURCE | RIVAL. */ + List findByStatusAndRoleHintOrderBySubscriberCountDesc(String status, String roleHint); } diff --git a/src/main/java/com/hlab/yanalyst/domain/channel/SeedSuggestService.java b/src/main/java/com/hlab/yanalyst/domain/channel/SeedSuggestService.java new file mode 100644 index 0000000..eec45e8 --- /dev/null +++ b/src/main/java/com/hlab/yanalyst/domain/channel/SeedSuggestService.java @@ -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) 채널 후보를 찾는다. + * + *

내 업로드의 해시태그(#유퀴즈 #핑계고 …)를 빈도순으로 집계한 뒤, 상위 키워드마다 + * search.list(type=channel)로 공식채널을 조회해 {@link RecommendedChannel}(roleHint=SOURCE)로 쌓는다. + * 승인은 사람이 한다. + * + *

search.list 는 1회 100 units 라 스케줄이 아니라 수동 실행 전용이다. + */ +@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 analyzeKeywords() { + List mine = channelRepository.findMyChannels(); + List 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 suggestSourceSeeds(List keywords) { + List 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 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 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 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 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 subs = fetchSubscriberCounts(basics.keySet()); + + List out = new ArrayList<>(); + for (Map.Entry 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 fetchSubscriberCounts(Iterable channelIds) { + Map 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; + } +} diff --git a/src/main/java/com/hlab/yanalyst/domain/channel/dto/FeedItemDto.java b/src/main/java/com/hlab/yanalyst/domain/channel/dto/FeedItemDto.java new file mode 100644 index 0000000..f9f9ca6 --- /dev/null +++ b/src/main/java/com/hlab/yanalyst/domain/channel/dto/FeedItemDto.java @@ -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()) + ); + } +} diff --git a/src/main/java/com/hlab/yanalyst/domain/channel/dto/FeedProgramDto.java b/src/main/java/com/hlab/yanalyst/domain/channel/dto/FeedProgramDto.java new file mode 100644 index 0000000..cd778fb --- /dev/null +++ b/src/main/java/com/hlab/yanalyst/domain/channel/dto/FeedProgramDto.java @@ -0,0 +1,4 @@ +package com.hlab.yanalyst.domain.channel.dto; + +/** 피드 필터용 프로그램(원본 채널) 항목. */ +public record FeedProgramDto(String ytChannelId, String channelTitle, Long videoCount) {} diff --git a/src/main/java/com/hlab/yanalyst/domain/channel/dto/FeedSeedDto.java b/src/main/java/com/hlab/yanalyst/domain/channel/dto/FeedSeedDto.java new file mode 100644 index 0000000..b4d86bb --- /dev/null +++ b/src/main/java/com/hlab/yanalyst/domain/channel/dto/FeedSeedDto.java @@ -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) {} diff --git a/src/main/java/com/hlab/yanalyst/global/schedule/ScheduledCollectionService.java b/src/main/java/com/hlab/yanalyst/global/schedule/ScheduledCollectionService.java index 3c424a6..43d818d 100644 --- a/src/main/java/com/hlab/yanalyst/global/schedule/ScheduledCollectionService.java +++ b/src/main/java/com/hlab/yanalyst/global/schedule/ScheduledCollectionService.java @@ -124,7 +124,8 @@ public class ScheduledCollectionService { /** 수동/스케줄 공용. 모든 등록 채널을 쿼터 한도 내에서 수집하고 결과 요약을 반환. */ public Map runChannelCollection() { - List channels = channelRepository.findAll(); + // 피드 시드(SOURCE/RIVAL)는 FeedCollectionService 가 3시간마다 따로 수집한다. + List channels = channelRepository.findMyChannels(); int ok = 0, failed = 0, skippedByQuota = 0; for (Channel c : channels) { @@ -163,7 +164,8 @@ public class ScheduledCollectionService { /** 모든 채널의 통계를 갱신하며 일별 성장 스냅샷을 기록. */ public Map runChannelSnapshot() { - List channels = channelRepository.findAll(); + // 피드 시드(SOURCE/RIVAL)는 FeedCollectionService 가 3시간마다 따로 수집한다. + List channels = channelRepository.findMyChannels(); int ok = 0, failed = 0, skippedByQuota = 0; for (Channel c : channels) { diff --git a/src/main/java/com/hlab/yanalyst/web/WebController.java b/src/main/java/com/hlab/yanalyst/web/WebController.java index b122385..6a03f92 100644 --- a/src/main/java/com/hlab/yanalyst/web/WebController.java +++ b/src/main/java/com/hlab/yanalyst/web/WebController.java @@ -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"); diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 2bbfd3e..4db4118 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -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: diff --git a/src/main/resources/templates/feed.html b/src/main/resources/templates/feed.html new file mode 100644 index 0000000..73726bd --- /dev/null +++ b/src/main/resources/templates/feed.html @@ -0,0 +1,673 @@ + + + + + h-lab - 소재 피드 + + + +

+ + + +
+ + +
+ + +
+
+ + + + + + + + +
+
+ + +
+ + + + + + +
+ + + + +
+ + + diff --git a/src/main/resources/templates/layout/sidebar.html b/src/main/resources/templates/layout/sidebar.html index 6f8470d..c741036 100644 --- a/src/main/resources/templates/layout/sidebar.html +++ b/src/main/resources/templates/layout/sidebar.html @@ -20,6 +20,11 @@