Compare commits
7 Commits
e2caf9a0ba
...
0ee343ceca
| Author | SHA1 | Date | |
|---|---|---|---|
| 0ee343ceca | |||
| 418242f47d | |||
| 0e84f5de08 | |||
| 15a655d7f9 | |||
| 39e2b42995 | |||
| 4776905745 | |||
| f309db46b6 |
784
docs/superpowers/plans/2026-06-29-comment-cards.md
Normal file
784
docs/superpowers/plans/2026-06-29-comment-cards.md
Normal file
@ -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<T>`로 감싼다 (`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) <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`:
|
||||||
|
```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) <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`:
|
||||||
|
```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<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**
|
||||||
|
|
||||||
|
```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) <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`:
|
||||||
|
```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` 실행 후 다른 터미널에서:
|
||||||
|
```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) <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.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`의 "제작" 그룹(프로덕션 `<a>` 다음, 같은 nav 안)에 추가:
|
||||||
|
```html
|
||||||
|
<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`:
|
||||||
|
```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`:
|
||||||
|
```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) → 안전한 평문 (태그 제거, <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**
|
||||||
|
|
||||||
|
```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) <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` 정의 아래)에 추가:
|
||||||
|
```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) <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)` 일치 ✓
|
||||||
125
docs/superpowers/specs/2026-06-29-comment-cards-design.md
Normal file
125
docs/superpowers/specs/2026-06-29-comment-cards-design.md
Normal file
@ -0,0 +1,125 @@
|
|||||||
|
# 댓글 카드(Comment Cards) 기능 설계
|
||||||
|
|
||||||
|
작성일: 2026-06-29
|
||||||
|
|
||||||
|
## 1. 목적
|
||||||
|
|
||||||
|
유튜브 영상 링크를 입력하면 해당 영상의 댓글을 가져와, 댓글마다 **유튜브 댓글 UI를 그대로 재현한 카드**로 보여준다. 사용자가 카드의 프로필 사진과 아이디를 **모자이크(블러)** 처리한 뒤, 각 카드를 **PNG 이미지로 클립보드에 복사**해 영상 편집툴(캡컷 등)에 바로 붙여넣어 영상 하단 소스로 쓰는 것이 목표다.
|
||||||
|
|
||||||
|
콘텐츠 제작용 보조 도구이며, DB 저장 없이 일회성 조회/생성으로 동작하는 독립 페이지다.
|
||||||
|
|
||||||
|
## 2. 사용 흐름
|
||||||
|
|
||||||
|
1. 사이드바 "제작" 그룹의 **댓글 카드** 메뉴 진입 (`/comment-cards`)
|
||||||
|
2. 유튜브 링크(또는 영상 ID) 입력 → **[가져오기]**
|
||||||
|
3. 서버가 YouTube Data API로 댓글을 여러 페이지에 걸쳐 수집해 한 번에 전달
|
||||||
|
4. 프론트에서 정렬/필터:
|
||||||
|
- 정렬: 좋아요순 / 답글순 / 최신순
|
||||||
|
- 필터: 좋아요 N개 이상, 답글 있는 것만
|
||||||
|
5. **[전체 모자이크]** 토글 → 모든 카드의 프로필 + 아이디 블러 처리
|
||||||
|
6. 각 카드의 **[복사]** 버튼 → 그 카드를 PNG 이미지로 만들어 클립보드에 복사
|
||||||
|
7. 캡컷 등에서 Ctrl+V로 붙여넣기
|
||||||
|
|
||||||
|
## 3. 아키텍처
|
||||||
|
|
||||||
|
기존 `web/` + `service/` 레이어 컨벤션을 따른다 (YoutubeSearchService / YoutubeSearchApiController와 동일 패턴). DB·엔티티는 추가하지 않는다.
|
||||||
|
|
||||||
|
### 3.1 백엔드
|
||||||
|
|
||||||
|
**`service/YoutubeCommentService`** (신규)
|
||||||
|
- `List<CommentCardDto> fetchComments(String videoIdOrUrl)`
|
||||||
|
- 입력에서 videoId 추출(정규식: `v=`, `youtu.be/`, `/shorts/`, 또는 11자리 ID 그대로)
|
||||||
|
- YouTube Data API `commentThreads.list` 호출:
|
||||||
|
- `part=snippet`
|
||||||
|
- `videoId={id}`
|
||||||
|
- `order=relevance`
|
||||||
|
- `maxResults=100`
|
||||||
|
- `key={youtube.api.key}`
|
||||||
|
- `pageToken`으로 페이지네이션 (최대 N페이지까지만 — 기본 5페이지 = 약 500개, 쿼터/응답크기 보호)
|
||||||
|
- 각 thread의 `snippet.topLevelComment.snippet`에서 추출:
|
||||||
|
- `authorDisplayName` → authorName
|
||||||
|
- `authorProfileImageUrl` → profileImageUrl
|
||||||
|
- `textDisplay` → text (HTML 포함 가능, 프론트에서 안전 렌더링)
|
||||||
|
- `likeCount` → likeCount
|
||||||
|
- `publishedAt` → publishedAt
|
||||||
|
- thread의 `snippet.totalReplyCount` → replyCount
|
||||||
|
- 댓글이 비활성화된 영상(403 commentsDisabled) 등은 빈 목록 + 사유 메시지로 처리
|
||||||
|
|
||||||
|
**`web/CommentCardApiController`** (신규, `/api/comment-cards`)
|
||||||
|
- `POST /api/comment-cards/fetch` — body `{ "url": "..." }`, 응답 `ApiResponse<List<CommentCardDto>>`
|
||||||
|
- `GET /api/comment-cards/avatar?url=...` — 프로필 이미지 **프록시**.
|
||||||
|
- 이유: 카드를 PNG로 캡처할 때 외부 도메인(yt3.ggpht.com 등) 이미지는 canvas를 오염(taint)시켜 클립보드 복사가 막힘. 동일 출처로 프록시해 회피.
|
||||||
|
- 화이트리스트: `*.ggpht.com`, `*.googleusercontent.com` 만 허용(SSRF 방지). 그 외 URL은 거부.
|
||||||
|
- 응답: 원본 바이트 + 적절한 Content-Type, 캐시 헤더.
|
||||||
|
|
||||||
|
**`web/dto/CommentCardDto`** (신규)
|
||||||
|
- `authorName, profileImageUrl, text, likeCount, replyCount, publishedAt`
|
||||||
|
|
||||||
|
**쿼터**: `commentThreads.list`는 호출당 1유닛. 기존 `YoutubeQuotaGuard.tryConsume(pageCount)` 적용 — 예산 초과 시 수집 중단하고 그때까지 모은 댓글 반환 + 안내.
|
||||||
|
|
||||||
|
### 3.2 프론트엔드
|
||||||
|
|
||||||
|
**페이지 라우트**: `WebController`에 `@GetMapping("/comment-cards")` 추가, `currentPage="comment-cards"`, 템플릿 `comment-cards.html` 반환.
|
||||||
|
|
||||||
|
**사이드바**: `layout/sidebar.html`의 "제작" 그룹에 메뉴 항목 추가 (아이콘 예: `message-square-quote`).
|
||||||
|
|
||||||
|
**템플릿 `templates/comment-cards.html`**: `layout/base.html` 사용, 다크모드 디자인시스템(variables.css) 준수.
|
||||||
|
- 상단 입력바: URL input + [가져오기]
|
||||||
|
- 필터바: 정렬 셀렉트 + 좋아요 임계값 input + "답글만" 체크 + [전체 모자이크] 토글 버튼
|
||||||
|
- 카드 그리드: 유튜브 댓글 스타일 카드
|
||||||
|
- 좌측 원형 프로필(프록시 경유 `/api/comment-cards/avatar?url=...`)
|
||||||
|
- 상단 아이디 + 작성시간(상대표기)
|
||||||
|
- 본문 댓글 텍스트
|
||||||
|
- 하단 좋아요수(👍) + 답글수
|
||||||
|
- 우상단/하단 [복사] 버튼
|
||||||
|
- 모자이크 상태일 때 프로필 + 아이디에 블러 적용
|
||||||
|
|
||||||
|
**정적 JS `static/js/comment-cards.js`**:
|
||||||
|
- fetch 호출 → 카드 렌더 → 클라이언트 정렬/필터
|
||||||
|
- 전체 모자이크 토글 → 카드에 `.mosaic` 클래스 토글
|
||||||
|
- 복사 → 카드 DOM을 PNG로 캡처 → 클립보드(`navigator.clipboard.write([new ClipboardItem({'image/png': blob})])`)
|
||||||
|
|
||||||
|
**DOM → 이미지 캡처 라이브러리**:
|
||||||
|
- `modern-screenshot`(또는 `snapdom`) 사용 — CSS `filter: blur()`를 SVG foreignObject로 충실히 렌더하므로 모자이크가 캡처 결과에 그대로 반영됨. (html2canvas는 blur 필터 미지원이라 부적합.)
|
||||||
|
- CDN `<script>`로 로드(기존 정적 자산 방식과 일관). 빌드 파이프라인 없음.
|
||||||
|
- 프로필 이미지는 프록시 동일출처라 taint 없이 캡처 가능.
|
||||||
|
|
||||||
|
**모자이크 방식**: 프로필 사진은 `filter: blur(6px)`(필요시 추가로 축소-확대 픽셀화), 아이디 텍스트는 `filter: blur(5px)`. 보내준 레퍼런스 이미지 수준의 식별 불가 처리.
|
||||||
|
|
||||||
|
## 4. 데이터 흐름
|
||||||
|
|
||||||
|
```
|
||||||
|
[브라우저] URL 입력
|
||||||
|
→ POST /api/comment-cards/fetch
|
||||||
|
[서버] videoId 추출 → commentThreads.list (페이지네이션, QuotaGuard)
|
||||||
|
→ List<CommentCardDto> (ApiResponse)
|
||||||
|
[브라우저] 카드 렌더 (프로필은 /api/comment-cards/avatar 프록시로 로드)
|
||||||
|
→ 정렬/필터 (클라이언트)
|
||||||
|
→ 전체 모자이크 토글 (CSS 블러)
|
||||||
|
→ [복사] → modern-screenshot로 카드 PNG 캡처 → 클립보드
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. 에러 처리
|
||||||
|
|
||||||
|
- 잘못된 URL/videoId 추출 실패 → 400, "유효한 유튜브 링크가 아닙니다"
|
||||||
|
- 댓글 비활성화 영상 → 빈 목록 + "이 영상은 댓글이 비활성화되어 있습니다"
|
||||||
|
- API 키 오류/쿼터 초과 → `GlobalExceptionHandler` 경유 에러 응답 + 프론트 토스트
|
||||||
|
- 프록시 화이트리스트 외 URL → 400 (SSRF 방지)
|
||||||
|
- 클립보드 복사 미지원 브라우저 → PNG 다운로드로 폴백 + 안내
|
||||||
|
|
||||||
|
## 6. 범위 밖 (YAGNI)
|
||||||
|
|
||||||
|
- 댓글 DB 저장/이력
|
||||||
|
- 영상 합성/편집 (사용자가 편집툴에서 직접)
|
||||||
|
- 답글(대댓글) 펼치기 — 최상위 댓글만
|
||||||
|
- 감정/키워드 분석
|
||||||
|
- 카드 디자인 커스터마이징(폰트/색상 옵션)
|
||||||
|
|
||||||
|
## 7. 검증
|
||||||
|
|
||||||
|
- 인기 영상 링크로 댓글 100+ 수집 확인
|
||||||
|
- 정렬(좋아요/답글/최신), 필터(임계값) 동작
|
||||||
|
- 전체 모자이크 토글 → 프로필+아이디 블러
|
||||||
|
- [복사] → 클립보드에 PNG 들어가고 캡컷/그림판에 붙여넣기 확인 (모자이크 반영 포함)
|
||||||
|
- 댓글 비활성화 영상/잘못된 링크 에러 처리
|
||||||
|
- 쿼터 가드 동작(페이지 상한)
|
||||||
@ -0,0 +1,87 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,30 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,65 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -71,6 +71,12 @@ public class WebController {
|
|||||||
return "rework";
|
return "rework";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GetMapping("/comment-cards")
|
||||||
|
public String commentCards(Model model) {
|
||||||
|
model.addAttribute("currentPage", "comment-cards");
|
||||||
|
return "comment-cards";
|
||||||
|
}
|
||||||
|
|
||||||
@GetMapping("/production")
|
@GetMapping("/production")
|
||||||
public String production(Model model) {
|
public String production(Model model) {
|
||||||
model.addAttribute("currentPage", "production");
|
model.addAttribute("currentPage", "production");
|
||||||
|
|||||||
21
src/main/java/com/hlab/yanalyst/web/dto/CommentCardDto.java
Normal file
21
src/main/java/com/hlab/yanalyst/web/dto/CommentCardDto.java
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
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 문자열
|
||||||
|
}
|
||||||
158
src/main/resources/static/js/comment-cards.js
Normal file
158
src/main/resources/static/js/comment-cards.js
Normal file
@ -0,0 +1,158 @@
|
|||||||
|
(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 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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})();
|
||||||
70
src/main/resources/templates/comment-cards.html
Normal file
70
src/main/resources/templates/comment-cards.html
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
<!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=20260629b)}"></script>
|
||||||
|
</th:block>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@ -49,6 +49,9 @@
|
|||||||
<a th:href="@{/production}" class="nav-item" th:classappend="${currentPage == 'production'} ? 'active'">
|
<a th:href="@{/production}" class="nav-item" th:classappend="${currentPage == 'production'} ? 'active'">
|
||||||
<i data-lucide="clapperboard" class="nav-icon"></i><span class="nav-text">프로덕션</span>
|
<i data-lucide="clapperboard" class="nav-icon"></i><span class="nav-text">프로덕션</span>
|
||||||
</a>
|
</a>
|
||||||
|
<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>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div class="user-profile-container">
|
<div class="user-profile-container">
|
||||||
|
|||||||
@ -0,0 +1,43 @@
|
|||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user