diff --git a/docs/superpowers/plans/2026-06-25-recommended-channel-discovery.md b/docs/superpowers/plans/2026-06-25-recommended-channel-discovery.md new file mode 100644 index 0000000..57cb188 --- /dev/null +++ b/docs/superpowers/plans/2026-06-25-recommended-channel-discovery.md @@ -0,0 +1,686 @@ +# 추천 채널 발굴 (떡상 Shorts) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 지역(KR,JP,US) 인기 Shorts 를 주기적으로 검색해 "작은 구독자·고배율" 떡상 채널을 발굴하고, `/recommend` 전용 페이지에서 등록/제외할 수 있게 한다. + +**Architecture:** 기존 `YoutubeSearchService`(지역별 Shorts 검색)와 `YoutubeQuotaGuard`(쿼터)를 재사용. 순수 랭킹 로직(`DiscoveryRanker`)으로 채널별 최고 배율 집계·필터 후 `RecommendedChannel` 에 upsert. 스케줄러가 일1회 실행, 전용 페이지 + REST 로 노출. + +**Tech Stack:** Spring Boot 3.4 / Java 21, JPA(PostgreSQL, ddl-auto:update), Lombok, Thymeleaf(SSR), 기존 `ApiResponse`/`@RestController` 패턴. + +## Global Constraints + +- 빌드/실행: `JAVA_HOME=D:/Development/app/JDK/jdk-21.0.5`, `./gradlew.bat`, 포트 8088. +- API 응답은 `global/common/ApiResponse`(`ApiResponse.ok(...)`)로 감싼다. +- 엔티티는 Lombok + `@CreationTimestamp`/`@UpdateTimestamp`. 스키마는 ddl-auto:update(마이그레이션 없음). +- 배율(ratio) 정의 = `viewCount / subscriberCount`(구독자 > 0 일 때만). +- 단위테스트는 `src/test/java/com/hlab/yanalyst/...`, JUnit5 + AssertJ(spring-boot-starter-test). + +--- + +### Task 1: `RecommendedChannel` 엔티티 + Repository + +**Files:** +- Create: `src/main/java/com/hlab/yanalyst/domain/channel/RecommendedChannel.java` +- Create: `src/main/java/com/hlab/yanalyst/domain/channel/RecommendedChannelRepository.java` + +**Interfaces:** +- Produces: + - `RecommendedChannel` 엔티티(필드: id, channelId, channelTitle, thumbnailUrl, subscriberCount, status, topVideoId, topVideoTitle, topVideoViewCount, ratio, region, discoveredAt, updatedAt). + - `RecommendedChannelRepository extends JpaRepository`: + `Optional findByChannelId(String channelId)`, + `List findByStatusOrderByRatioDesc(String status)`, + `boolean existsByChannelIdAndStatus(String channelId, String status)`. + +- [ ] **Step 1: 엔티티 작성** + +```java +package com.hlab.yanalyst.domain.channel; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.UpdateTimestamp; + +import java.time.LocalDateTime; + +/** 자동 발굴된 추천 채널(떡상 Shorts 기반). status: NEW|REGISTERED|EXCLUDED. */ +@Entity +@Table(name = "recommended_channels") +@Getter +@Setter +@NoArgsConstructor +public class RecommendedChannel { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, unique = true) + private String channelId; + + private String channelTitle; + + @Column(length = 2083) + private String thumbnailUrl; // 대표 떡상 영상의 썸네일 + + private Long subscriberCount; + + @Column(nullable = false) + private String status = "NEW"; + + // 대표(최고 배율) 영상 + private String topVideoId; + @Column(length = 500) + private String topVideoTitle; + private Long topVideoViewCount; + private Double ratio; // topVideo viewCount / subscriberCount + + private String region; // 발견 지역(KR/JP/US) + + @CreationTimestamp + @Column(updatable = false) + private LocalDateTime discoveredAt; + + @UpdateTimestamp + private LocalDateTime updatedAt; +} +``` + +- [ ] **Step 2: Repository 작성** + +```java +package com.hlab.yanalyst.domain.channel; + +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface RecommendedChannelRepository extends JpaRepository { + Optional findByChannelId(String channelId); + List findByStatusOrderByRatioDesc(String status); + boolean existsByChannelIdAndStatus(String channelId, String status); +} +``` + +- [ ] **Step 3: 컴파일 확인** + +Run: `JAVA_HOME="D:/Development/app/JDK/jdk-21.0.5" ./gradlew.bat compileJava --console=plain` +Expected: `BUILD SUCCESSFUL` + +- [ ] **Step 4: 커밋** + +```bash +git add src/main/java/com/hlab/yanalyst/domain/channel/RecommendedChannel.java \ + src/main/java/com/hlab/yanalyst/domain/channel/RecommendedChannelRepository.java +git commit -m "feat(discover): RecommendedChannel 엔티티+Repository 추가" +``` + +--- + +### Task 2: `DiscoveryRanker` 순수 랭킹 로직 (TDD) + +**Files:** +- Create: `src/main/java/com/hlab/yanalyst/domain/channel/DiscoveryRanker.java` +- Test: `src/test/java/com/hlab/yanalyst/domain/channel/DiscoveryRankerTest.java` + +**Interfaces:** +- Consumes: `com.hlab.yanalyst.web.dto.YoutubeSearchResultDto` + (getter: `getChannelId()`, `getChannelTitle()`, `getThumbnailUrl()`, `getSubscriberCount():Long`, + `getViewCount():Long`, `getVideoId()`, `getTitle()`, `getChannelCountry()`). +- Produces: + - `DiscoveryRanker.Candidate` record(channelId, channelTitle, thumbnailUrl, subscriberCount, topVideoId, topVideoTitle, topVideoViewCount, ratio(double), region). + - `static List rank(List items, long maxSubscribers, double minRatio, java.util.Set excludeChannelIds)`. + +- [ ] **Step 1: 실패 테스트 작성** + +```java +package com.hlab.yanalyst.domain.channel; + +import com.hlab.yanalyst.web.dto.YoutubeSearchResultDto; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; + +class DiscoveryRankerTest { + + private YoutubeSearchResultDto item(String ch, long subs, String vid, long views) { + YoutubeSearchResultDto d = new YoutubeSearchResultDto(); + d.setChannelId(ch); d.setChannelTitle(ch + "_title"); d.setThumbnailUrl("thumb_" + vid); + d.setSubscriberCount(subs); d.setVideoId(vid); d.setTitle("v_" + vid); d.setViewCount(views); + d.setChannelCountry("KR"); + return d; + } + + @Test + void rank_채널별_최고배율_선정_필터_정렬() { + List items = List.of( + item("A", 1000, "a1", 50000), // ratio 50 (작은채널·고배율) + item("A", 1000, "a2", 10000), // ratio 10 (A의 비대표) + item("B", 5_000_000, "b1", 60_000_000), // subs 과다 → 제외(maxSubs) + item("C", 2000, "c1", 4000), // ratio 2 → minRatio 미달 제외 + item("D", 1000, "d1", 30000) // ratio 30 + ); + + List out = + DiscoveryRanker.rank(items, 100_000, 5.0, Set.of()); + + // A(50), D(30) 만 남고 배율 내림차순. A는 최고배율 영상 a1 선택. + assertThat(out).extracting(DiscoveryRanker.Candidate::channelId).containsExactly("A", "D"); + assertThat(out.get(0).topVideoId()).isEqualTo("a1"); + assertThat(out.get(0).ratio()).isEqualTo(50.0); + } + + @Test + void rank_제외채널_및_구독자0_제거() { + List items = List.of( + item("A", 1000, "a1", 50000), // ratio 50 이지만 제외목록 + item("E", 0, "e1", 9999), // subs 0 → 배율 계산 불가 제외 + item("F", 1000, "f1", 20000) // ratio 20 + ); + + List out = + DiscoveryRanker.rank(items, 100_000, 5.0, Set.of("A")); + + assertThat(out).extracting(DiscoveryRanker.Candidate::channelId).containsExactly("F"); + } +} +``` + +- [ ] **Step 2: 테스트 실패 확인** + +Run: `JAVA_HOME="D:/Development/app/JDK/jdk-21.0.5" ./gradlew.bat test --tests "com.hlab.yanalyst.domain.channel.DiscoveryRankerTest" --console=plain` +Expected: FAIL — `DiscoveryRanker` 클래스 없음(cannot find symbol). + +- [ ] **Step 3: `DiscoveryRanker` 구현** + +```java +package com.hlab.yanalyst.domain.channel; + +import com.hlab.yanalyst.web.dto.YoutubeSearchResultDto; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** 검색된 Shorts 목록 → 채널별 최고 배율 후보 집계·필터·정렬(순수 로직). */ +public final class DiscoveryRanker { + + private DiscoveryRanker() {} + + public record Candidate(String channelId, String channelTitle, String thumbnailUrl, Long subscriberCount, + String topVideoId, String topVideoTitle, Long topVideoViewCount, + double ratio, String region) {} + + public static List rank(List items, long maxSubscribers, + double minRatio, Set excludeChannelIds) { + Map best = new LinkedHashMap<>(); + for (YoutubeSearchResultDto it : items) { + String ch = it.getChannelId(); + Long subs = it.getSubscriberCount(); + Long views = it.getViewCount(); + if (ch == null || subs == null || subs <= 0 || views == null) continue; + if (excludeChannelIds.contains(ch)) continue; + if (subs > maxSubscribers) continue; + + double ratio = (double) views / subs; + if (ratio < minRatio) continue; + + Candidate prev = best.get(ch); + if (prev == null || ratio > prev.ratio()) { + best.put(ch, new Candidate(ch, it.getChannelTitle(), it.getThumbnailUrl(), subs, + it.getVideoId(), it.getTitle(), views, ratio, it.getChannelCountry())); + } + } + List out = new ArrayList<>(best.values()); + out.sort(Comparator.comparingDouble(Candidate::ratio).reversed()); + return out; + } +} +``` + +- [ ] **Step 4: 테스트 통과 확인** + +Run: `JAVA_HOME="D:/Development/app/JDK/jdk-21.0.5" ./gradlew.bat test --tests "com.hlab.yanalyst.domain.channel.DiscoveryRankerTest" --console=plain` +Expected: PASS + +- [ ] **Step 5: 커밋** + +```bash +git add src/main/java/com/hlab/yanalyst/domain/channel/DiscoveryRanker.java \ + src/test/java/com/hlab/yanalyst/domain/channel/DiscoveryRankerTest.java +git commit -m "feat(discover): 채널 발굴 랭킹 로직 DiscoveryRanker + 단위테스트" +``` + +--- + +### Task 3: `ChannelDiscoveryService` (검색 재사용 + upsert) + 설정 + +**Files:** +- Create: `src/main/java/com/hlab/yanalyst/domain/channel/ChannelDiscoveryService.java` +- Modify: `src/main/resources/application.yml` (discovery 설정 추가) + +**Interfaces:** +- Consumes: + - `YoutubeSearchService.searchYoutubeVideos(YoutubeSearchCondition)` → `YoutubeSearchPageDto.getItems():List`. + `YoutubeSearchCondition` 필드: `setRegions(List)`, `setFormat("SHORTS")`, `setKeyword(String)`, `setPeriodDays(Integer)`. + - `YoutubeQuotaGuard.remaining():long`, `tryConsume(long):boolean`. + - `ChannelRepository`(기존) — 등록된 channelId 제외용. 메서드 `existsByChannelId(String)` 가 없으면 이 태스크에서 추가. + - `RecommendedChannelRepository`(Task 1), `DiscoveryRanker.rank(...)`(Task 2). +- Produces: + - `Map runDiscovery()` — 검색→랭킹→upsert 후 요약 반환. + +- [ ] **Step 1: `ChannelRepository` 에 존재여부 메서드 보강(없으면)** + +`src/main/java/com/hlab/yanalyst/domain/channel/ChannelRepository.java` 에 다음이 없으면 추가: + +```java + boolean existsByChannelId(String channelId); +``` + +- [ ] **Step 2: 설정 추가** — `application.yml` 의 `hlab:` 블록 아래에 병합 + +```yaml +hlab: + scheduler: + channel-discovery: + enabled: ${CHANNEL_DISCOVERY_ENABLED:true} + cron: ${CHANNEL_DISCOVERY_CRON:0 30 4 * * *} # 매일 04:30 + discovery: + regions: ${DISCOVERY_REGIONS:KR,JP,US} + max-subscribers: ${DISCOVERY_MAX_SUBS:100000} + min-ratio: ${DISCOVERY_MIN_RATIO:5.0} + period-days: ${DISCOVERY_PERIOD_DAYS:14} # 최근 N일 영상 대상 + top-n: ${DISCOVERY_TOP_N:30} +``` + +> 주의: 기존 `hlab.scheduler.*`, `hlab.youtube.*` 키와 같은 `hlab:` 트리에 병합할 것(중복 `hlab:` 매핑 금지). + +- [ ] **Step 3: 서비스 구현** + +```java +package com.hlab.yanalyst.domain.channel; + +import com.hlab.yanalyst.global.schedule.YoutubeQuotaGuard; +import com.hlab.yanalyst.service.YoutubeSearchService; +import com.hlab.yanalyst.web.dto.YoutubeSearchCondition; +import com.hlab.yanalyst.web.dto.YoutubeSearchResultDto; +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 java.util.*; + +/** 지역 인기 Shorts 검색 → 떡상 채널 발굴 → RecommendedChannel upsert. */ +@Slf4j +@Service +@RequiredArgsConstructor +public class ChannelDiscoveryService { + + /** search.list 1회 추정 쿼터(units). */ + private static final long SEARCH_QUOTA = 100; + + private final YoutubeSearchService youtubeSearchService; + private final YoutubeQuotaGuard quotaGuard; + private final ChannelRepository channelRepository; + private final RecommendedChannelRepository recommendedChannelRepository; + + @Value("${hlab.discovery.regions:KR,JP,US}") + private String regionsCsv; + @Value("${hlab.discovery.max-subscribers:100000}") + private long maxSubscribers; + @Value("${hlab.discovery.min-ratio:5.0}") + private double minRatio; + @Value("${hlab.discovery.period-days:14}") + private int periodDays; + + @Transactional + public Map runDiscovery() { + List regions = Arrays.stream(regionsCsv.split(",")) + .map(String::trim).filter(s -> !s.isBlank()).toList(); + + List all = new ArrayList<>(); + List searchedRegions = new ArrayList<>(); + for (String region : regions) { + if (!quotaGuard.tryConsume(SEARCH_QUOTA)) { + log.warn("[Discovery] 쿼터 예산 소진 — 지역 {} 이후 건너뜀 (잔여 {})", region, quotaGuard.remaining()); + break; + } + try { + YoutubeSearchCondition cond = new YoutubeSearchCondition(); + cond.setRegions(List.of(region)); + cond.setFormat("SHORTS"); + cond.setPeriodDays(periodDays); + // 광범위 검색: 키워드 없이 인기 Shorts. (구현 시 q 없이도 동작하는지 확인, + // 필요하면 region 인기영상으로 보강 — 스펙 §4.2) + var page = youtubeSearchService.searchYoutubeVideos(cond); + if (page != null && page.getItems() != null) all.addAll(page.getItems()); + searchedRegions.add(region); + } catch (Exception e) { + log.error("[Discovery] 지역 {} 검색 실패 — 건너뜀", region, e); + } + } + + // 이미 등록/제외한 채널 제외 + Set exclude = new HashSet<>(); + for (var rc : recommendedChannelRepository.findByStatusOrderByRatioDesc("EXCLUDED")) exclude.add(rc.getChannelId()); + + List ranked = DiscoveryRanker.rank(all, maxSubscribers, minRatio, exclude); + + int saved = 0; + for (DiscoveryRanker.Candidate c : ranked) { + if (channelRepository.existsByChannelId(c.channelId())) continue; // 이미 내 채널 + RecommendedChannel rc = recommendedChannelRepository.findByChannelId(c.channelId()) + .orElseGet(RecommendedChannel::new); + if ("EXCLUDED".equals(rc.getStatus()) || "REGISTERED".equals(rc.getStatus())) continue; + // 더 좋은(높은 배율) 메트릭일 때만 갱신 + if (rc.getId() != null && rc.getRatio() != null && c.ratio() <= rc.getRatio()) continue; + rc.setChannelId(c.channelId()); + rc.setChannelTitle(c.channelTitle()); + rc.setThumbnailUrl(c.thumbnailUrl()); + rc.setSubscriberCount(c.subscriberCount()); + rc.setStatus("NEW"); + rc.setTopVideoId(c.topVideoId()); + rc.setTopVideoTitle(c.topVideoTitle()); + rc.setTopVideoViewCount(c.topVideoViewCount()); + rc.setRatio(c.ratio()); + rc.setRegion(c.region()); + recommendedChannelRepository.save(rc); + saved++; + } + + Map summary = new LinkedHashMap<>(); + summary.put("regions", searchedRegions); + summary.put("candidates", ranked.size()); + summary.put("saved", saved); + summary.put("quotaRemaining", quotaGuard.remaining()); + log.info("[Discovery] 완료: {}", summary); + return summary; + } +} +``` + +- [ ] **Step 4: 컴파일 확인 + 기존 단위테스트 통과** + +Run: `JAVA_HOME="D:/Development/app/JDK/jdk-21.0.5" ./gradlew.bat test --tests "com.hlab.yanalyst.domain.channel.DiscoveryRankerTest" --console=plain` +Expected: `BUILD SUCCESSFUL`(컴파일 + 테스트). 컴파일 에러 시 `YoutubeSearchCondition` setter/`YoutubeSearchPageDto.getItems()` 시그니처 재확인. + +- [ ] **Step 5: 커밋** + +```bash +git add src/main/java/com/hlab/yanalyst/domain/channel/ChannelDiscoveryService.java \ + src/main/java/com/hlab/yanalyst/domain/channel/ChannelRepository.java \ + src/main/resources/application.yml +git commit -m "feat(discover): ChannelDiscoveryService(검색→떡상 채널 upsert) + 설정" +``` + +--- + +### Task 4: 스케줄러 연결 + +**Files:** +- Modify: `src/main/java/com/hlab/yanalyst/global/schedule/ScheduledCollectionService.java` + +**Interfaces:** +- Consumes: `ChannelDiscoveryService.runDiscovery()`(Task 3). + +- [ ] **Step 1: 의존성 주입 + 스케줄 메서드 추가** + +`ScheduledCollectionService` 의 final 필드에 추가(생성자는 @RequiredArgsConstructor): + +```java + private final com.hlab.yanalyst.domain.channel.ChannelDiscoveryService channelDiscoveryService; + + @org.springframework.beans.factory.annotation.Value("${hlab.scheduler.channel-discovery.enabled:true}") + private boolean discoveryEnabled; + + @org.springframework.scheduling.annotation.Scheduled(cron = "${hlab.scheduler.channel-discovery.cron:0 30 4 * * *}") + public void scheduledDiscovery() { + if (!discoveryEnabled) { + log.info("[Scheduler] 추천 채널 발굴 비활성화됨"); + return; + } + var result = channelDiscoveryService.runDiscovery(); + log.info("[Scheduler] 추천 채널 발굴 완료: {}", result); + } +``` + +- [ ] **Step 2: 컴파일 확인** + +Run: `JAVA_HOME="D:/Development/app/JDK/jdk-21.0.5" ./gradlew.bat compileJava --console=plain` +Expected: `BUILD SUCCESSFUL` + +- [ ] **Step 3: 커밋** + +```bash +git add src/main/java/com/hlab/yanalyst/global/schedule/ScheduledCollectionService.java +git commit -m "feat(discover): 추천 채널 발굴 일별 스케줄 연결" +``` + +--- + +### Task 5: `RecommendedChannelController` (목록/등록/제외/수동실행) + +**Files:** +- Create: `src/main/java/com/hlab/yanalyst/domain/channel/RecommendedChannelController.java` + +**Interfaces:** +- Consumes: `RecommendedChannelRepository`(Task1), `ChannelDiscoveryService.runDiscovery()`(Task3), + `ChannelService.saveChannelFromUrl(String)`(기존) — URL `https://www.youtube.com/channel/{channelId}`. +- Produces(REST): + - `GET /api/recommended-channels` → `ApiResponse>`(status=NEW, 배율 desc). + - `POST /api/recommended-channels/{id}/register` → `ApiResponse`(Channel 생성 + status=REGISTERED). + - `POST /api/recommended-channels/{id}/exclude` → `ApiResponse`(status=EXCLUDED). + - `POST /api/recommended-channels/run` → `ApiResponse>`(수동 발굴). + +- [ ] **Step 1: 컨트롤러 작성** + +```java +package com.hlab.yanalyst.domain.channel; + +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.transaction.annotation.Transactional; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/api/recommended-channels") +@RequiredArgsConstructor +@Tag(name = "Recommended Channel API", description = "자동 발굴된 추천 채널(떡상 Shorts)") +public class RecommendedChannelController { + + private final RecommendedChannelRepository repository; + private final ChannelDiscoveryService discoveryService; + private final ChannelService channelService; + + @GetMapping + @Operation(summary = "추천 채널 목록", description = "status=NEW 를 배율 내림차순으로 반환") + public ApiResponse> list() { + return ApiResponse.ok(repository.findByStatusOrderByRatioDesc("NEW")); + } + + @PostMapping("/{id}/register") + @Operation(summary = "내 채널 등록", description = "추천 채널을 내 채널로 등록하고 REGISTERED 처리") + @Transactional + public ApiResponse register(@PathVariable Long id) { + RecommendedChannel rc = repository.findById(id) + .orElseThrow(() -> new IllegalArgumentException("추천 채널을 찾을 수 없습니다: " + id)); + channelService.saveChannelFromUrl("https://www.youtube.com/channel/" + rc.getChannelId()); + rc.setStatus("REGISTERED"); + repository.save(rc); + return ApiResponse.ok(null); + } + + @PostMapping("/{id}/exclude") + @Operation(summary = "추천 제외", description = "다음 발굴에서 다시 뜨지 않게 EXCLUDED 처리") + @Transactional + public ApiResponse exclude(@PathVariable Long id) { + RecommendedChannel rc = repository.findById(id) + .orElseThrow(() -> new IllegalArgumentException("추천 채널을 찾을 수 없습니다: " + id)); + rc.setStatus("EXCLUDED"); + repository.save(rc); + return ApiResponse.ok(null); + } + + @PostMapping("/run") + @Operation(summary = "수동 발굴 실행", description = "스케줄과 동일한 발굴을 즉시 1회 실행") + public ApiResponse> run() { + return ApiResponse.ok(discoveryService.runDiscovery()); + } +} +``` + +- [ ] **Step 2: 컴파일 확인** + +Run: `JAVA_HOME="D:/Development/app/JDK/jdk-21.0.5" ./gradlew.bat compileJava --console=plain` +Expected: `BUILD SUCCESSFUL` + +- [ ] **Step 3: 커밋** + +```bash +git add src/main/java/com/hlab/yanalyst/domain/channel/RecommendedChannelController.java +git commit -m "feat(discover): 추천 채널 REST(목록/등록/제외/수동실행)" +``` + +--- + +### Task 6: `/recommend` 전용 페이지 + 라우트 + 사이드바 메뉴 + +**Files:** +- Create: `src/main/resources/templates/recommend.html` +- Modify: `src/main/java/com/hlab/yanalyst/web/WebController.java` (라우트 추가) +- Modify: `src/main/resources/templates/layout/sidebar.html` (메뉴 추가) + +**Interfaces:** +- Consumes(JS fetch): `GET /api/recommended-channels`, `POST /{id}/register`, `POST /{id}/exclude`, `POST /run`. + +- [ ] **Step 1: WebController 라우트 추가** (`discover()` 메서드 옆에 동일 패턴) + +```java + @GetMapping("/recommend") + public String recommend(org.springframework.ui.Model model) { + model.addAttribute("currentPage", "recommend"); + return "recommend"; + } +``` + +- [ ] **Step 2: 사이드바 메뉴 추가** — `sidebar.html` 의 발굴(`/discover`) 항목 바로 아래에 삽입 + +```html + + 추천 채널 + +``` + +- [ ] **Step 3: 페이지 작성** (기존 `discover.html` 의 layout/base 패턴 따름) + +```html + + +h-lab - 추천 채널 + +
+ + + +
+ + + + +
+ + +``` + +- [ ] **Step 4: 실행 검증** + +Run(앱 기동): +```bash +JAVA_HOME="D:/Development/app/JDK/jdk-21.0.5" ./gradlew.bat bootRun --console=plain # 백그라운드 +``` +- 브라우저/`curl`로 `http://localhost:8088/recommend` 200, 사이드바에 "추천 채널" 노출 확인. +- `curl -X POST http://localhost:8088/api/recommended-channels/run` → `{success:true, data:{regions,candidates,saved,...}}` 확인(쿼터/검색 동작). 결과가 빈약하면 스펙 §4.2(검색 API 보강) 적용. + +- [ ] **Step 5: 커밋** + +```bash +git add src/main/resources/templates/recommend.html \ + src/main/java/com/hlab/yanalyst/web/WebController.java \ + src/main/resources/templates/layout/sidebar.html +git commit -m "feat(discover): 추천 채널 전용 페이지 /recommend + 사이드바 메뉴" +``` + +--- + +## Self-Review + +- **Spec coverage:** §3 흐름→Task3/4, §4.1 엔티티→Task1, 랭킹/필터/dedup→Task2/3, §4.4 REST→Task5, §4.5 페이지/메뉴→Task6, §5 설정→Task3, §6 쿼터→Task3(quotaGuard), §8 테스트→Task2. 누락 없음. +- **검색 API 리스크(§4.2):** Task3/Task6 Step4 에 "결과 빈약 시 보강" 명시. 키워드 없는 search.list 가 빈 결과면 `YoutubeSearchService` 에 region 인기영상 경로 추가가 후속 필요(플랜 외 보강 포인트로 표시). +- **Type 일관성:** `DiscoveryRanker.Candidate` accessor(record) 명칭이 Task2 정의와 Task3 사용처 일치(channelId(), ratio() 등). `RecommendedChannel` setter 명칭이 Task1 필드와 일치. +- **Placeholder:** 모든 코드 step 실제 코드 포함. TBD 없음.