h-lab/docs/superpowers/plans/2026-06-29-comment-cards.md
hehihoho3@gmail.com 4776905745 docs(comment-cards): 구현 계획서 추가
5개 태스크(videoId 파서·수집서비스·API·페이지/렌더·PNG복사) 단계별 TDD/수동검증 계획

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 16:48:05 +09:00

32 KiB

댓글 카드(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<T>로 감싼다 (ApiResponse.ok(...)).
  • 에러는 IllegalArgumentException을 던지면 GlobalExceptionHandler가 400 + ApiResponse.error(message)로 처리한다. (프론트는 success=falsemessage를 표시)
  • 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) <noreply@anthropic.com>.
  • 빌드: .\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:

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:

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
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) <noreply@anthropic.com>"

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<CommentCardDto> (잘못된 링크/댓글 비활성 시 IllegalArgumentException)
  • Step 1: Create the DTO

src/main/java/com/hlab/yanalyst/web/dto/CommentCardDto.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:

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<CommentCardDto> fetchComments(String urlOrId) {
        String videoId = YoutubeVideoIdParser.parse(urlOrId);
        if (videoId == null) {
            throw new IllegalArgumentException("유효한 유튜브 링크가 아닙니다.");
        }

        List<CommentCardDto> 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
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) <noreply@anthropic.com>"

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<List<CommentCardDto>>
    • GET /api/comment-cards/avatar?url=<프로필이미지URL> → 이미지 바이트(동일 출처 프록시), 화이트리스트 외 400
  • Step 1: Create the controller

src/main/java/com/hlab/yanalyst/web/CommentCardApiController.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<List<CommentCardDto>> fetch(@RequestBody FetchRequest req) {
        return ApiResponse.ok(commentService.fetchComments(req.getUrl()));
    }

    /** 프로필 이미지를 동일 출처로 프록시(canvas taint 회피). 구글 도메인만 허용(SSRF 방지). */
    @GetMapping("/avatar")
    public ResponseEntity<byte[]> avatar(@RequestParam("url") String url) {
        if (!isAllowed(url)) {
            return ResponseEntity.badRequest().build();
        }
        ResponseEntity<byte[]> 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 실행 후 다른 터미널에서:

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":...}, ...]} (댓글 다수)

잘못된 링크 확인:

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 인코딩 후:

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에 이미지 저장됨.

화이트리스트 외 차단:

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
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) <noreply@anthropic.com>"

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.javaproduction 메서드 위(아무 위치)에 추가:

    @GetMapping("/comment-cards")
    public String commentCards(Model model) {
        model.addAttribute("currentPage", "comment-cards");
        return "comment-cards";
    }
  • Step 2: Add sidebar menu

layout/sidebar.html의 "제작" 그룹(프로덕션 <a> 다음, 같은 nav 안)에 추가:

            <a th:href="@{/comment-cards}" class="nav-item" th:classappend="${currentPage == 'comment-cards'} ? 'active'">
                <i data-lucide="message-square-quote" class="nav-icon"></i><span class="nav-text">댓글 카드</span>
            </a>
  • Step 3: Create the template

src/main/resources/templates/comment-cards.html:

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
      layout:decorate="~{layout/base}">
<head>
    <title>h-lab - 댓글 카드</title>
    <style>
        .cc-toolbar { display:flex; flex-wrap:wrap; gap:.6rem; align-items:center; margin-bottom:1rem; }
        .cc-toolbar input[type=text], .cc-toolbar select, .cc-toolbar input[type=number] {
            background:var(--surface-2); color:var(--text); border:1px solid var(--border);
            border-radius:8px; padding:.5rem .7rem; font-size:14px;
        }
        .cc-url { flex:1; min-width:240px; }
        .cc-btn { background:var(--primary-gradient); color:#fff; border:none; border-radius:8px;
            padding:.55rem 1rem; font-weight:600; cursor:pointer; font-size:14px; }
        .cc-btn.secondary { background:var(--surface-2); color:var(--text); border:1px solid var(--border); }
        .cc-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(320px,1fr)); gap:1rem; }
        /* 카드: 영상 위에 얹을 소스 → 투명 캡처 기준. 미리보기는 surface 배경. */
        .comment-card { background:var(--surface); border:1px solid var(--border); border-radius:12px;
            padding:1rem; position:relative; }
        .cc-head { display:flex; gap:.6rem; align-items:flex-start; }
        .cc-avatar { width:40px; height:40px; border-radius:50%; flex-shrink:0; object-fit:cover; background:var(--surface-2); }
        .cc-meta { flex:1; min-width:0; }
        .cc-author { font-weight:600; font-size:13.5px; color:var(--text); }
        .cc-time { font-size:11.5px; color:var(--text-3); margin-left:.4rem; }
        .cc-text { font-size:14px; color:var(--text); margin-top:.35rem; white-space:pre-wrap; word-break:break-word; line-height:1.45; }
        .cc-stats { font-size:12px; color:var(--text-3); margin-top:.5rem; display:flex; gap:1rem; }
        .cc-copy { position:absolute; top:.6rem; right:.6rem; }
        /* 모자이크 */
        .comment-card.mosaic .cc-avatar { filter: blur(6px); }
        .comment-card.mosaic .cc-author { filter: blur(5px); }
        .cc-empty { color:var(--text-3); padding:2rem 0; }
    </style>
</head>
<body>
<div layout:fragment="content">
    <h1 style="font-size:1.5rem;font-weight:700;margin-bottom:.3rem;">댓글 카드</h1>
    <p style="color:var(--text-3);margin-bottom:1.2rem;font-size:14px;">
        유튜브 링크의 댓글을 가져와 카드로 만들고, 프로필·아이디를 모자이크해 영상 소스로 복사하세요.
    </p>

    <div class="cc-toolbar">
        <input type="text" id="ccUrl" class="cc-url" placeholder="유튜브 링크 또는 영상 ID 붙여넣기" />
        <button class="cc-btn" id="ccFetch">가져오기</button>
    </div>

    <div class="cc-toolbar" id="ccFilters" style="display:none;">
        <select id="ccSort">
            <option value="likes">좋아요순</option>
            <option value="replies">답글순</option>
            <option value="latest">최신순</option>
        </select>
        <label style="font-size:13px;color:var(--text-2);">좋아요
            <input type="number" id="ccMinLikes" value="0" min="0" style="width:90px;" /> 이상</label>
        <label style="font-size:13px;color:var(--text-2);display:flex;align-items:center;gap:.3rem;">
            <input type="checkbox" id="ccRepliesOnly" /> 답글 있는 것만</label>
        <button class="cc-btn secondary" id="ccMosaic">전체 모자이크</button>
        <span id="ccCount" style="font-size:12px;color:var(--text-3);"></span>
    </div>

    <div class="cc-grid" id="cardGrid"></div>
    <div class="cc-empty" id="ccEmpty"></div>
</div>

<th:block layout:fragment="script">
    <!-- DOM→이미지 캡처 (Task 5에서 사용). blur 필터 지원 위해 modern-screenshot 사용. -->
    <script src="https://cdn.jsdelivr.net/npm/modern-screenshot@4/dist/index.js"></script>
    <script th:src="@{/js/comment-cards.js(v=20260629)}"></script>
</th:block>
</body>
</html>
  • Step 4: Create the JS (fetch + render + sort/filter)

src/main/resources/static/js/comment-cards.js:

(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) → 안전한 평문 (태그 제거, <br>→줄바꿈)
  function toPlainText(html) {
    const tmp = document.createElement('div');
    tmp.innerHTML = String(html).replace(/<br\s*\/?>/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 =
        '<img class="cc-avatar" crossorigin="anonymous" alt="" src="' + avatarSrc + '">' +
        '<div class="cc-meta">' +
          '<div><span class="cc-author"></span><span class="cc-time"></span></div>' +
          '<div class="cc-text"></div>' +
          '<div class="cc-stats"><span>👍 ' + (c.likeCount || 0).toLocaleString() + '</span>' +
          '<span>💬 ' + (c.replyCount || 0).toLocaleString() + '</span></div>' +
        '</div>';
      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

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) <noreply@anthropic.com>"

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 정의 아래)에 추가:

  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 bootRunhttp://localhost:8088/comment-cards (크롬/엣지)

  • 댓글 가져오기 → "전체 모자이크" ON

  • 한 카드의 [복사] 클릭 → "복사됨 ✓" 표시

  • 그림판(또는 캡컷)에 Ctrl+V → 카드 이미지가 붙여넣어지고, 프로필+아이디가 블러된 상태로 캡처됨, 카드 바깥은 투명

  • 모자이크 OFF 상태에서 복사 → 블러 없이 캡처됨

  • (선택) 비-HTTPS/미지원 환경 시 PNG 다운로드 폴백 확인

  • Step 4: Commit

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) <noreply@anthropic.com>"

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<CommentCardDto>, DTO 게터명(getLikeCount 등), JS window.__cards/renderCards/copyCard(cardEl, btnEl) 일치 ✓