diff --git a/docs/superpowers/plans/2026-06-29-comment-cards.md b/docs/superpowers/plans/2026-06-29-comment-cards.md new file mode 100644 index 0000000..3d8ff1d --- /dev/null +++ b/docs/superpowers/plans/2026-06-29-comment-cards.md @@ -0,0 +1,784 @@ +# 댓글 카드(Comment Cards) 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:** 유튜브 링크의 댓글을 가져와 유튜브 스타일 카드로 보여주고, 프로필·아이디 모자이크 후 각 카드를 PNG로 클립보드에 복사하는 독립 페이지 `/comment-cards`를 만든다. + +**Architecture:** 기존 `web/` + `service/` 레이어 패턴(YoutubeSearchService/Controller와 동일)을 따른다. 백엔드는 YouTube Data API `commentThreads.list`로 댓글을 수집(DB 저장 없음)하고, 프로필 이미지는 canvas 오염 회피를 위해 서버 프록시로 동일 출처화한다. 프론트는 SSR 템플릿 + 정적 JS로 카드를 렌더하고, `modern-screenshot`로 DOM→PNG 캡처 후 클립보드에 복사한다. + +**Tech Stack:** Spring Boot 3.4 / Java 21, RestTemplate, Jackson, Thymeleaf(layout-dialect), 정적 JS, modern-screenshot(CDN), Clipboard API. + +## Global Constraints + +- 서버 포트 8088. YouTube API 키는 `${youtube.api.key}` (env `YOUTUBE_API_KEY`). +- JSON 응답은 `global/common/ApiResponse`로 감싼다 (`ApiResponse.ok(...)`). +- 에러는 `IllegalArgumentException`을 던지면 `GlobalExceptionHandler`가 400 + `ApiResponse.error(message)`로 처리한다. (프론트는 `success=false`의 `message`를 표시) +- YouTube 호출은 `global/schedule/YoutubeQuotaGuard.tryConsume(units)`로 쿼터 가드. `commentThreads.list`는 호출(페이지)당 1유닛. +- Thymeleaf 템플릿은 `layout/base.html` 사용, 다크모드 디자인시스템(`variables.css`) 준수, 페이지 컨트롤러는 `currentPage` 설정. +- 커밋 메시지: 타입 접두사(feat/docs 등)는 영문, 제목·본문은 한글. 끝에 `Co-Authored-By: Claude Opus 4.8 (1M context) `. +- 빌드: `.\gradlew.bat build`, 테스트: `.\gradlew.bat test`, 실행: `.\gradlew.bat bootRun`. +- 이 프로젝트는 외부 API/브라우저 의존 코드는 자동 테스트가 없다. 순수 로직(videoId 파서)만 JUnit으로 TDD하고, 서비스/컨트롤러/프론트는 bootRun + curl/브라우저 수동 검증한다. + +--- + +## File Structure + +- `src/main/java/com/hlab/yanalyst/service/YoutubeVideoIdParser.java` (신규) — URL/ID → videoId 추출 순수 유틸 +- `src/test/java/com/hlab/yanalyst/service/YoutubeVideoIdParserTest.java` (신규) — 파서 단위 테스트 +- `src/main/java/com/hlab/yanalyst/web/dto/CommentCardDto.java` (신규) — 댓글 카드 DTO +- `src/main/java/com/hlab/yanalyst/service/YoutubeCommentService.java` (신규) — commentThreads 수집 +- `src/main/java/com/hlab/yanalyst/web/CommentCardApiController.java` (신규) — `/api/comment-cards/fetch`, `/api/comment-cards/avatar` +- `src/main/java/com/hlab/yanalyst/web/WebController.java` (수정) — `/comment-cards` 라우트 추가 +- `src/main/resources/templates/layout/sidebar.html` (수정) — "댓글 카드" 메뉴 추가 +- `src/main/resources/templates/comment-cards.html` (신규) — 페이지 셸 +- `src/main/resources/static/js/comment-cards.js` (신규) — fetch/렌더/정렬/필터/모자이크/복사 + +--- + +## Task 1: videoId 파서 (TDD) + +**Files:** +- Create: `src/main/java/com/hlab/yanalyst/service/YoutubeVideoIdParser.java` +- Test: `src/test/java/com/hlab/yanalyst/service/YoutubeVideoIdParserTest.java` + +**Interfaces:** +- Produces: `YoutubeVideoIdParser.parse(String input)` → `String` videoId(11자) 또는 `null`(추출 실패) + +- [ ] **Step 1: Write the failing test** + +`src/test/java/com/hlab/yanalyst/service/YoutubeVideoIdParserTest.java`: +```java +package com.hlab.yanalyst.service; + +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +class YoutubeVideoIdParserTest { + + @Test + void parsesWatchUrl() { + assertEquals("dQw4w9WgXcQ", + YoutubeVideoIdParser.parse("https://www.youtube.com/watch?v=dQw4w9WgXcQ")); + } + + @Test + void parsesWatchUrlWithExtraParams() { + assertEquals("dQw4w9WgXcQ", + YoutubeVideoIdParser.parse("https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=42s&list=ABC")); + } + + @Test + void parsesShortLink() { + assertEquals("dQw4w9WgXcQ", + YoutubeVideoIdParser.parse("https://youtu.be/dQw4w9WgXcQ?si=xyz")); + } + + @Test + void parsesShortsLink() { + assertEquals("abc12345678", + YoutubeVideoIdParser.parse("https://www.youtube.com/shorts/abc12345678")); + } + + @Test + void parsesBareId() { + assertEquals("dQw4w9WgXcQ", YoutubeVideoIdParser.parse("dQw4w9WgXcQ")); + } + + @Test + void returnsNullForGarbage() { + assertNull(YoutubeVideoIdParser.parse("not a youtube link")); + assertNull(YoutubeVideoIdParser.parse("")); + assertNull(YoutubeVideoIdParser.parse(null)); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `.\gradlew.bat test --tests "com.hlab.yanalyst.service.YoutubeVideoIdParserTest"` +Expected: 컴파일 실패 또는 FAIL (`YoutubeVideoIdParser` 미존재) + +- [ ] **Step 3: Write minimal implementation** + +`src/main/java/com/hlab/yanalyst/service/YoutubeVideoIdParser.java`: +```java +package com.hlab.yanalyst.service; + +import java.util.regex.Pattern; +import java.util.regex.Matcher; + +/** 유튜브 URL 또는 11자리 영상 ID에서 videoId를 추출한다. 실패 시 null. */ +public final class YoutubeVideoIdParser { + + private static final Pattern BARE = Pattern.compile("^[A-Za-z0-9_-]{11}$"); + private static final Pattern[] PATTERNS = { + Pattern.compile("[?&]v=([A-Za-z0-9_-]{11})"), + Pattern.compile("youtu\\.be/([A-Za-z0-9_-]{11})"), + Pattern.compile("/shorts/([A-Za-z0-9_-]{11})"), + Pattern.compile("/embed/([A-Za-z0-9_-]{11})"), + }; + + private YoutubeVideoIdParser() {} + + public static String parse(String input) { + if (input == null) return null; + String s = input.trim(); + if (s.isEmpty()) return null; + if (BARE.matcher(s).matches()) return s; + for (Pattern p : PATTERNS) { + Matcher m = p.matcher(s); + if (m.find()) return m.group(1); + } + return null; + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `.\gradlew.bat test --tests "com.hlab.yanalyst.service.YoutubeVideoIdParserTest"` +Expected: PASS (6 tests) + +- [ ] **Step 5: Commit** + +```bash +git add src/main/java/com/hlab/yanalyst/service/YoutubeVideoIdParser.java src/test/java/com/hlab/yanalyst/service/YoutubeVideoIdParserTest.java +git commit -m "feat(comment-cards): 유튜브 videoId 파서 추가 + +watch/youtu.be/shorts/embed/순수ID 추출, 단위 테스트 포함 + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task 2: CommentCardDto + YoutubeCommentService + +**Files:** +- Create: `src/main/java/com/hlab/yanalyst/web/dto/CommentCardDto.java` +- Create: `src/main/java/com/hlab/yanalyst/service/YoutubeCommentService.java` + +**Interfaces:** +- Consumes: `YoutubeVideoIdParser.parse(String)`, `YoutubeQuotaGuard.tryConsume(long)` +- Produces: + - `CommentCardDto` 필드(게터): `String getAuthorName()`, `String getProfileImageUrl()`, `String getText()`, `long getLikeCount()`, `long getReplyCount()`, `String getPublishedAt()` + - `YoutubeCommentService.fetchComments(String urlOrId)` → `List` (잘못된 링크/댓글 비활성 시 `IllegalArgumentException`) + +- [ ] **Step 1: Create the DTO** + +`src/main/java/com/hlab/yanalyst/web/dto/CommentCardDto.java`: +```java +package com.hlab.yanalyst.web.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class CommentCardDto { + private String authorName; + private String profileImageUrl; + private String text; // YouTube textDisplay (HTML 포함 가능 — 프론트에서 안전 렌더) + private long likeCount; + private long replyCount; + private String publishedAt; // ISO-8601 문자열 +} +``` + +- [ ] **Step 2: Create the service** + +`src/main/java/com/hlab/yanalyst/service/YoutubeCommentService.java`: +```java +package com.hlab.yanalyst.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.hlab.yanalyst.global.schedule.YoutubeQuotaGuard; +import com.hlab.yanalyst.web.dto.CommentCardDto; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.util.UriComponentsBuilder; + +import java.util.ArrayList; +import java.util.List; + +/** + * YouTube Data API commentThreads.list 로 영상의 최상위 댓글을 수집한다. + * DB 저장 없이 일회성 조회. 댓글 카드(/comment-cards) 페이지에서 사용. + */ +@Service +@RequiredArgsConstructor +public class YoutubeCommentService { + + private final RestTemplate restTemplate; + private final YoutubeQuotaGuard quotaGuard; + + @Value("${youtube.api.key}") + private String youtubeApiKey; + + /** 응답 크기/쿼터 보호용 최대 페이지 수 (페이지당 최대 100개). */ + private static final int MAX_PAGES = 5; + + public List fetchComments(String urlOrId) { + String videoId = YoutubeVideoIdParser.parse(urlOrId); + if (videoId == null) { + throw new IllegalArgumentException("유효한 유튜브 링크가 아닙니다."); + } + + List result = new ArrayList<>(); + String apiUrl = "https://www.googleapis.com/youtube/v3/commentThreads"; + String pageToken = null; + + for (int page = 0; page < MAX_PAGES; page++) { + if (!quotaGuard.tryConsume(1)) break; // 쿼터 소진 시 모은 만큼 반환 + + UriComponentsBuilder b = UriComponentsBuilder.fromHttpUrl(apiUrl) + .queryParam("part", "snippet") + .queryParam("videoId", videoId) + .queryParam("order", "relevance") + .queryParam("maxResults", 100) + .queryParam("key", youtubeApiKey); + if (pageToken != null) { + b.queryParam("pageToken", pageToken); + } + + JsonNode root; + try { + root = restTemplate.getForObject(b.build().encode().toUri(), JsonNode.class); + } catch (HttpClientErrorException.Forbidden e) { + if (result.isEmpty()) { + throw new IllegalArgumentException("이 영상은 댓글이 비활성화되어 있거나 접근할 수 없습니다."); + } + break; + } + + if (root == null || !root.has("items")) break; + + for (JsonNode item : root.get("items")) { + JsonNode top = item.path("snippet").path("topLevelComment").path("snippet"); + CommentCardDto dto = CommentCardDto.builder() + .authorName(top.path("authorDisplayName").asText("")) + .profileImageUrl(top.path("authorProfileImageUrl").asText("")) + .text(top.path("textDisplay").asText("")) + .likeCount(top.path("likeCount").asLong(0)) + .replyCount(item.path("snippet").path("totalReplyCount").asLong(0)) + .publishedAt(top.path("publishedAt").asText("")) + .build(); + result.add(dto); + } + + if (!root.has("nextPageToken")) break; + pageToken = root.get("nextPageToken").asText(); + } + + return result; + } +} +``` + +- [ ] **Step 3: Verify it compiles** + +Run: `.\gradlew.bat compileJava` +Expected: BUILD SUCCESSFUL + +- [ ] **Step 4: Commit** + +```bash +git add src/main/java/com/hlab/yanalyst/web/dto/CommentCardDto.java src/main/java/com/hlab/yanalyst/service/YoutubeCommentService.java +git commit -m "feat(comment-cards): commentThreads 댓글 수집 서비스 추가 + +CommentCardDto + YoutubeCommentService(최대 5페이지, 쿼터가드, 댓글비활성 처리) + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task 3: CommentCardApiController (fetch + avatar 프록시) + +**Files:** +- Create: `src/main/java/com/hlab/yanalyst/web/CommentCardApiController.java` + +**Interfaces:** +- Consumes: `YoutubeCommentService.fetchComments(String)`, `RestTemplate`(기존 빈), `ApiResponse` +- Produces (HTTP): + - `POST /api/comment-cards/fetch` body `{ "url": "<유튜브링크>" }` → `ApiResponse>` + - `GET /api/comment-cards/avatar?url=<프로필이미지URL>` → 이미지 바이트(동일 출처 프록시), 화이트리스트 외 400 + +- [ ] **Step 1: Create the controller** + +`src/main/java/com/hlab/yanalyst/web/CommentCardApiController.java`: +```java +package com.hlab.yanalyst.web; + +import com.hlab.yanalyst.global.common.ApiResponse; +import com.hlab.yanalyst.service.YoutubeCommentService; +import com.hlab.yanalyst.web.dto.CommentCardDto; +import lombok.Data; +import lombok.RequiredArgsConstructor; +import org.springframework.http.CacheControl; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.client.RestTemplate; + +import java.net.URI; +import java.time.Duration; +import java.util.List; + +@RestController +@RequestMapping("/api/comment-cards") +@RequiredArgsConstructor +public class CommentCardApiController { + + private final YoutubeCommentService commentService; + private final RestTemplate restTemplate; + + @PostMapping("/fetch") + public ApiResponse> fetch(@RequestBody FetchRequest req) { + return ApiResponse.ok(commentService.fetchComments(req.getUrl())); + } + + /** 프로필 이미지를 동일 출처로 프록시(canvas taint 회피). 구글 도메인만 허용(SSRF 방지). */ + @GetMapping("/avatar") + public ResponseEntity avatar(@RequestParam("url") String url) { + if (!isAllowed(url)) { + return ResponseEntity.badRequest().build(); + } + ResponseEntity resp = restTemplate.getForEntity(URI.create(url), byte[].class); + MediaType ct = resp.getHeaders().getContentType(); + return ResponseEntity.ok() + .contentType(ct != null ? ct : MediaType.IMAGE_JPEG) + .cacheControl(CacheControl.maxAge(Duration.ofHours(6)).cachePublic()) + .body(resp.getBody()); + } + + private boolean isAllowed(String url) { + try { + String host = URI.create(url).getHost(); + if (host == null) return false; + host = host.toLowerCase(); + return host.endsWith("ggpht.com") || host.endsWith("googleusercontent.com"); + } catch (Exception e) { + return false; + } + } + + @Data + public static class FetchRequest { + private String url; + } +} +``` + +- [ ] **Step 2: Compile** + +Run: `.\gradlew.bat compileJava` +Expected: BUILD SUCCESSFUL + +- [ ] **Step 3: Manual verify — fetch** + +`.\gradlew.bat bootRun` 실행 후 다른 터미널에서: +```bash +curl -s -X POST http://localhost:8088/api/comment-cards/fetch \ + -H "Content-Type: application/json" \ + -d '{"url":"https://www.youtube.com/watch?v=dQw4w9WgXcQ"}' +``` +Expected: `{"success":true,...,"data":[{"authorName":...,"profileImageUrl":"https://...ggpht.com...","text":...,"likeCount":...,"replyCount":...,"publishedAt":...}, ...]}` (댓글 다수) + +잘못된 링크 확인: +```bash +curl -s -X POST http://localhost:8088/api/comment-cards/fetch -H "Content-Type: application/json" -d '{"url":"garbage"}' +``` +Expected: HTTP 400, `{"success":false,"message":"유효한 유튜브 링크가 아닙니다.",...}` + +- [ ] **Step 4: Manual verify — avatar 프록시** + +위 fetch 응답의 `profileImageUrl` 하나를 복사해 URL 인코딩 후: +```bash +curl -s -o /tmp/avatar.jpg "http://localhost:8088/api/comment-cards/avatar?url=<인코딩된_profileImageUrl>" -w "%{http_code} %{content_type}\n" +``` +Expected: `200 image/jpeg`(또는 image/webp 등), `/tmp/avatar.jpg`에 이미지 저장됨. + +화이트리스트 외 차단: +```bash +curl -s "http://localhost:8088/api/comment-cards/avatar?url=https://example.com/x.jpg" -w "%{http_code}\n" -o /dev/null +``` +Expected: `400` + +- [ ] **Step 5: Commit** + +```bash +git add src/main/java/com/hlab/yanalyst/web/CommentCardApiController.java +git commit -m "feat(comment-cards): 댓글 fetch + 프로필 프록시 API 추가 + +POST /api/comment-cards/fetch, GET /api/comment-cards/avatar(구글 도메인 화이트리스트) + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task 4: 페이지 라우트 + 사이드바 + 템플릿 + 렌더/정렬/필터 JS + +**Files:** +- Modify: `src/main/java/com/hlab/yanalyst/web/WebController.java` (라우트 추가) +- Modify: `src/main/resources/templates/layout/sidebar.html` ("제작" 그룹에 메뉴) +- Create: `src/main/resources/templates/comment-cards.html` +- Create: `src/main/resources/static/js/comment-cards.js` + +**Interfaces:** +- Consumes(HTTP): `POST /api/comment-cards/fetch`, `GET /api/comment-cards/avatar?url=` +- Produces(JS, Task 5에서 사용): 전역에서 호출 가능한 상태 — `window.__cards` (현재 카드 DTO 배열), 함수 `renderCards()`(현재 정렬/필터 기준으로 `#cardGrid` 다시 그림). 각 카드 DOM은 `.comment-card` 클래스 + `data-index` 속성을 가진다. 카드 내부 구조: `.cc-avatar`(프로필 img), `.cc-author`(아이디), `.cc-copy`(복사 버튼). + +- [ ] **Step 1: Add page route in WebController** + +`WebController.java`의 `production` 메서드 위(아무 위치)에 추가: +```java + @GetMapping("/comment-cards") + public String commentCards(Model model) { + model.addAttribute("currentPage", "comment-cards"); + return "comment-cards"; + } +``` + +- [ ] **Step 2: Add sidebar menu** + +`layout/sidebar.html`의 "제작" 그룹(프로덕션 `` 다음, 같은 nav 안)에 추가: +```html + + 댓글 카드 + +``` + +- [ ] **Step 3: Create the template** + +`src/main/resources/templates/comment-cards.html`: +```html + + + + h-lab - 댓글 카드 + + + +
+

댓글 카드

+

+ 유튜브 링크의 댓글을 가져와 카드로 만들고, 프로필·아이디를 모자이크해 영상 소스로 복사하세요. +

+ +
+ + +
+ + + +
+
+
+ + + + + + + + +``` + +- [ ] **Step 4: Create the JS (fetch + render + sort/filter)** + +`src/main/resources/static/js/comment-cards.js`: +```javascript +(function () { + window.__cards = []; + let mosaicOn = false; + + const $ = (id) => document.getElementById(id); + + function timeAgo(iso) { + if (!iso) return ''; + const then = new Date(iso).getTime(); + if (isNaN(then)) return ''; + const sec = Math.floor((Date.now() - then) / 1000); + const units = [['년',31536000],['개월',2592000],['일',86400],['시간',3600],['분',60]]; + for (const [label, s] of units) { + const v = Math.floor(sec / s); + if (v >= 1) return v + label + ' 전'; + } + return '방금 전'; + } + + // YouTube textDisplay(HTML) → 안전한 평문 (태그 제거,
→줄바꿈) + function toPlainText(html) { + const tmp = document.createElement('div'); + tmp.innerHTML = String(html).replace(//gi, '\n'); + return tmp.textContent || ''; + } + + function applyFilterSort(cards) { + const sort = $('ccSort').value; + const minLikes = parseInt($('ccMinLikes').value, 10) || 0; + const repliesOnly = $('ccRepliesOnly').checked; + let list = cards.filter(c => (c.likeCount || 0) >= minLikes && (!repliesOnly || (c.replyCount || 0) > 0)); + if (sort === 'likes') list.sort((a, b) => (b.likeCount || 0) - (a.likeCount || 0)); + else if (sort === 'replies') list.sort((a, b) => (b.replyCount || 0) - (a.replyCount || 0)); + else if (sort === 'latest') list.sort((a, b) => new Date(b.publishedAt) - new Date(a.publishedAt)); + return list; + } + + window.renderCards = function () { + const grid = $('cardGrid'); + const list = applyFilterSort(window.__cards); + $('ccCount').textContent = list.length + '개'; + grid.innerHTML = ''; + list.forEach((c, i) => { + const card = document.createElement('div'); + card.className = 'comment-card' + (mosaicOn ? ' mosaic' : ''); + card.dataset.index = i; + + const avatarSrc = c.profileImageUrl + ? '/api/comment-cards/avatar?url=' + encodeURIComponent(c.profileImageUrl) + : ''; + + const head = document.createElement('div'); + head.className = 'cc-head'; + head.innerHTML = + '' + + '
' + + '
' + + '
' + + '
👍 ' + (c.likeCount || 0).toLocaleString() + '' + + '💬 ' + (c.replyCount || 0).toLocaleString() + '
' + + '
'; + head.querySelector('.cc-author').textContent = c.authorName || ''; + head.querySelector('.cc-time').textContent = timeAgo(c.publishedAt); + head.querySelector('.cc-text').textContent = toPlainText(c.text); + + const copyBtn = document.createElement('button'); + copyBtn.className = 'cc-btn secondary cc-copy'; + copyBtn.textContent = '복사'; + copyBtn.addEventListener('click', () => window.copyCard(card, copyBtn)); // Task 5에서 정의 + + card.appendChild(head); + card.appendChild(copyBtn); + grid.appendChild(card); + }); + if (window.lucide) window.lucide.createIcons(); + }; + + async function fetchComments() { + const url = $('ccUrl').value.trim(); + if (!url) return; + const btn = $('ccFetch'); + btn.disabled = true; btn.textContent = '가져오는 중…'; + $('ccEmpty').textContent = ''; + try { + const res = await fetch('/api/comment-cards/fetch', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ url }) + }); + const json = await res.json(); + if (!json.success) throw new Error(json.message || '가져오기 실패'); + window.__cards = json.data || []; + $('ccFilters').style.display = window.__cards.length ? 'flex' : 'none'; + $('ccEmpty').textContent = window.__cards.length ? '' : '댓글이 없습니다.'; + window.renderCards(); + } catch (e) { + $('ccFilters').style.display = 'none'; + $('cardGrid').innerHTML = ''; + $('ccEmpty').textContent = '⚠️ ' + e.message; + } finally { + btn.disabled = false; btn.textContent = '가져오기'; + } + } + + document.addEventListener('DOMContentLoaded', () => { + $('ccFetch').addEventListener('click', fetchComments); + $('ccUrl').addEventListener('keydown', (e) => { if (e.key === 'Enter') fetchComments(); }); + ['ccSort', 'ccMinLikes', 'ccRepliesOnly'].forEach(id => + $(id).addEventListener('input', () => window.renderCards())); + $('ccMosaic').addEventListener('click', () => { + mosaicOn = !mosaicOn; + $('ccMosaic').textContent = mosaicOn ? '모자이크 해제' : '전체 모자이크'; + document.querySelectorAll('.comment-card').forEach(el => el.classList.toggle('mosaic', mosaicOn)); + }); + }); +})(); +``` + +- [ ] **Step 5: Manual verify in browser** + +`.\gradlew.bat bootRun` → 브라우저 `http://localhost:8088/comment-cards` +- 사이드바 "제작"에 "댓글 카드" 메뉴 보이고 active 표시됨 +- 링크 붙여넣고 가져오기 → 카드들이 프로필(프록시 로드)·아이디·시간·댓글·👍·💬와 함께 렌더 +- 정렬 변경(좋아요/답글/최신), 좋아요 임계값, "답글 있는 것만" 토글 → 목록 즉시 갱신, 개수 표시 +- "전체 모자이크" 클릭 → 모든 프로필+아이디 블러, 버튼 라벨 토글 +- 잘못된 링크 → "⚠️ 유효한 유튜브 링크가 아닙니다." +- (복사 버튼은 아직 동작 안 함 — Task 5) + +- [ ] **Step 6: Commit** + +```bash +git add src/main/java/com/hlab/yanalyst/web/WebController.java src/main/resources/templates/layout/sidebar.html src/main/resources/templates/comment-cards.html src/main/resources/static/js/comment-cards.js +git commit -m "feat(comment-cards): 댓글 카드 페이지·사이드바·렌더/정렬/필터 추가 + +/comment-cards 라우트, 유튜브 스타일 카드 렌더, 정렬/임계값 필터, 전체 모자이크 토글 + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task 5: 카드 PNG 클립보드 복사 + +**Files:** +- Modify: `src/main/resources/static/js/comment-cards.js` (`window.copyCard` 추가) + +**Interfaces:** +- Consumes: 전역 `modernScreenshot.domToBlob`(CDN UMD), 카드 DOM(`.comment-card`) +- Produces: `window.copyCard(cardEl, btnEl)` — 카드를 투명배경 PNG로 캡처해 클립보드에 복사. 클립보드 미지원 시 PNG 다운로드 폴백. + +- [ ] **Step 1: Implement copyCard** + +`comment-cards.js`의 IIFE 내부(예: `window.renderCards` 정의 아래)에 추가: +```javascript + async function captureBlob(cardEl) { + // modern-screenshot UMD 전역: window.modernScreenshot + const ms = window.modernScreenshot; + if (!ms || !ms.domToBlob) throw new Error('캡처 라이브러리 로드 실패'); + // backgroundColor:null → 카드 바깥 투명. scale 2 → 또렷하게. + return await ms.domToBlob(cardEl, { backgroundColor: null, scale: 2 }); + } + + function downloadBlob(blob) { + const a = document.createElement('a'); + a.href = URL.createObjectURL(blob); + a.download = 'comment-card.png'; + a.click(); + URL.revokeObjectURL(a.href); + } + + window.copyCard = async function (cardEl, btnEl) { + const original = btnEl.textContent; + btnEl.disabled = true; btnEl.textContent = '복사 중…'; + try { + const blob = await captureBlob(cardEl); + if (navigator.clipboard && window.ClipboardItem) { + await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]); + btnEl.textContent = '복사됨 ✓'; + } else { + downloadBlob(blob); + btnEl.textContent = '다운로드됨'; + } + } catch (e) { + // 클립보드 권한 거부 등 → 다운로드 폴백 + try { + const blob = await captureBlob(cardEl); + downloadBlob(blob); + btnEl.textContent = '다운로드됨'; + } catch (e2) { + btnEl.textContent = '실패'; + } + } finally { + setTimeout(() => { btnEl.disabled = false; btnEl.textContent = original; }, 1500); + } + }; +``` + +- [ ] **Step 2: Bump cache-buster** + +`comment-cards.html`에서 `comment-cards.js(v=20260629)` → `v=20260629b`로 변경(브라우저 캐시 무효화). + +- [ ] **Step 3: Manual verify in browser** + +`.\gradlew.bat bootRun` → `http://localhost:8088/comment-cards` (크롬/엣지) +- 댓글 가져오기 → "전체 모자이크" ON +- 한 카드의 [복사] 클릭 → "복사됨 ✓" 표시 +- 그림판(또는 캡컷)에 Ctrl+V → 카드 이미지가 붙여넣어지고, **프로필+아이디가 블러된 상태로** 캡처됨, 카드 바깥은 투명 +- 모자이크 OFF 상태에서 복사 → 블러 없이 캡처됨 +- (선택) 비-HTTPS/미지원 환경 시 PNG 다운로드 폴백 확인 + +- [ ] **Step 4: Commit** + +```bash +git add src/main/resources/static/js/comment-cards.js src/main/resources/templates/comment-cards.html +git commit -m "feat(comment-cards): 카드 PNG 클립보드 복사 추가 + +modern-screenshot로 카드 캡처→클립보드(투명배경, 모자이크 반영), 미지원 시 다운로드 폴백 + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Self-Review 결과 + +**Spec coverage:** +- 독립 페이지/사이드바 메뉴 → Task 4 ✓ +- 링크 입력→댓글 수집(commentThreads, 페이지네이션, 쿼터) → Task 2 ✓ +- 정렬(좋아요/답글/최신)·필터(임계값/답글만) → Task 4 ✓ +- 유튜브 스타일 카드(프로필·아이디·시간·댓글·좋아요·답글) → Task 4 ✓ +- 전체 모자이크 → Task 4(토글/CSS) + Task 5(캡처 반영) ✓ +- 카드별 복사(PNG→클립보드, 폴백) → Task 5 ✓ +- 프로필 프록시(CORS/SSRF) → Task 3 ✓ +- 에러 처리(잘못된 링크/댓글 비활성) → Task 2(throw) + Task 3/4(검증/표시) ✓ + +**Placeholder scan:** 모든 step에 실제 코드/명령/기대결과 포함, "TBD"·"적절히 처리" 없음 ✓ + +**Type consistency:** `fetchComments(String)→List`, DTO 게터명(`getLikeCount` 등), JS `window.__cards`/`renderCards`/`copyCard(cardEl, btnEl)` 일치 ✓