1046 lines
42 KiB
Markdown
1046 lines
42 KiB
Markdown
# 숏폼 큐(Opal 연동) 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:** 유튜브 URL을 큐에 등록하고, Opal "숏폼 생성기" 출력 텍스트를 파싱해 구간별 capcut2 JSON으로 저장·조회하는 기능 + 클로드 브라우저 대행 스킬.
|
|
|
|
**Architecture:** `domain/shortform/` 패키지(DDD 스타일)에 Job/Clip 엔티티와 파서·서비스·REST API를 두고, Thymeleaf `/shortform` 페이지가 이를 소비한다. 실행 엔진(Opal)은 외부에 있고 h-lab은 "원문 텍스트 → 구조화 저장"만 담당한다. `.claude/skills/shortform-queue`가 브라우저 대행 절차를 명문화한다.
|
|
|
|
**Tech Stack:** Spring Boot 3.4 / Java 21 / JPA(ddl-auto: update, PostgreSQL) / Lombok / Jackson / Thymeleaf(layout-dialect) / JUnit5
|
|
|
|
## Global Constraints
|
|
|
|
- 서버 포트 8088. 빌드·테스트는 `.\gradlew.bat`(Windows), `JAVA_HOME=D:\Development\app\JDK\jdk-21.0.5`
|
|
- JSON API는 `ApiResponse<T>`로 감싼다 (`ApiResponse.ok/created/error`)
|
|
- 커밋 메시지: 타입 접두사(feat/fix/test/docs)는 영문, 제목·본문은 한글
|
|
- `src/test`가 현재 없음 — 테스트는 `src/test/java/com/hlab/yanalyst/` 아래 신규 작성. DB 필요 테스트 금지(H2 없음) — 순수 단위 테스트만
|
|
- 페이지 컨트롤러는 `currentPage` 모델 속성 필수 (사이드바 하이라이트)
|
|
- 텍스트 대용량 컬럼은 `@Column(columnDefinition = "TEXT")`
|
|
|
|
---
|
|
|
|
### Task 1: Opal 출력 파서 (TDD)
|
|
|
|
**Files:**
|
|
- Create: `src/main/java/com/hlab/yanalyst/domain/shortform/ParsedClip.java`
|
|
- Create: `src/main/java/com/hlab/yanalyst/domain/shortform/ShortformOutputParser.java`
|
|
- Test: `src/test/java/com/hlab/yanalyst/domain/shortform/ShortformOutputParserTest.java`
|
|
- 픽스처(이미 커밋됨): `src/test/resources/shortform/opal-output-sample.txt`
|
|
|
|
**Interfaces:**
|
|
- Produces: `record ParsedClip(int clipNo, String capcutJson, String titleCandidates, String durationTable, String titleTop, String titleMain)`
|
|
- Produces: `ShortformOutputParser.parse(String raw) : List<ParsedClip>` — 빈/null 입력이면 빈 리스트. 섹션 구분자는 `N=====`(= 5개 이상). ```json 펜스가 없어도 중괄호 스캔으로 JSON을 찾는다. JSON 파싱 실패 시 titleTop/titleMain만 null이고 원문 조각은 보존.
|
|
|
|
- [ ] **Step 1: 실패하는 테스트 작성**
|
|
|
|
```java
|
|
package com.hlab.yanalyst.domain.shortform;
|
|
|
|
import org.junit.jupiter.api.Test;
|
|
|
|
import java.nio.charset.StandardCharsets;
|
|
import java.nio.file.Files;
|
|
import java.nio.file.Path;
|
|
import java.util.List;
|
|
|
|
import static org.junit.jupiter.api.Assertions.*;
|
|
|
|
class ShortformOutputParserTest {
|
|
|
|
private final ShortformOutputParser parser = new ShortformOutputParser();
|
|
|
|
private String sample() throws Exception {
|
|
return Files.readString(Path.of("src/test/resources/shortform/opal-output-sample.txt"),
|
|
StandardCharsets.UTF_8);
|
|
}
|
|
|
|
@Test
|
|
void 실전_샘플에서_5개_섹션을_모두_파싱한다() throws Exception {
|
|
List<ParsedClip> clips = parser.parse(sample());
|
|
|
|
assertEquals(5, clips.size());
|
|
for (ParsedClip c : clips) {
|
|
assertNotNull(c.capcutJson(), "clip " + c.clipNo() + " json");
|
|
assertTrue(c.capcutJson().contains("\"cuts\""));
|
|
assertNotNull(c.titleCandidates(), "clip " + c.clipNo() + " titles");
|
|
assertNotNull(c.durationTable(), "clip " + c.clipNo() + " table");
|
|
}
|
|
assertEquals(1, clips.get(0).clipNo());
|
|
assertEquals(5, clips.get(4).clipNo());
|
|
}
|
|
|
|
@Test
|
|
void json펜스가_있는_섹션과_없는_섹션_모두_타이틀을_추출한다() throws Exception {
|
|
List<ParsedClip> clips = parser.parse(sample());
|
|
|
|
// 섹션 1: ```json 펜스 있음
|
|
assertEquals("프나 신보 감상평", clips.get(0).titleTop());
|
|
assertEquals("극락 가는 앨범입니다", clips.get(0).titleMain());
|
|
// 섹션 3: 펜스 없이 생 JSON
|
|
assertEquals("프나 유치찬란 상황극", clips.get(2).titleTop());
|
|
// 섹션 5: 펜스 없음 + url에 한글 플레이스홀더
|
|
assertEquals("맑은 눈의 광기", clips.get(4).titleTop());
|
|
}
|
|
|
|
@Test
|
|
void 검산표는_총길이_줄까지_포함한다() throws Exception {
|
|
List<ParsedClip> clips = parser.parse(sample());
|
|
assertTrue(clips.get(0).durationTable().contains("총 13컷 / 총 길이: 46.5초"));
|
|
}
|
|
|
|
@Test
|
|
void 빈_입력은_빈_리스트를_반환한다() {
|
|
assertTrue(parser.parse(null).isEmpty());
|
|
assertTrue(parser.parse(" ").isEmpty());
|
|
assertTrue(parser.parse("구분자가 없는 텍스트").isEmpty());
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: 테스트 실패 확인**
|
|
|
|
Run: `.\gradlew.bat test --tests "com.hlab.yanalyst.domain.shortform.ShortformOutputParserTest"`
|
|
Expected: 컴파일 실패 (ParsedClip/ShortformOutputParser 없음)
|
|
|
|
- [ ] **Step 3: 구현**
|
|
|
|
`ParsedClip.java`:
|
|
|
|
```java
|
|
package com.hlab.yanalyst.domain.shortform;
|
|
|
|
/** Opal 출력에서 파싱한 구간 하나. capcutJson 등 원문 조각은 파싱 실패해도 보존한다. */
|
|
public record ParsedClip(
|
|
int clipNo,
|
|
String capcutJson,
|
|
String titleCandidates,
|
|
String durationTable,
|
|
String titleTop,
|
|
String titleMain
|
|
) {}
|
|
```
|
|
|
|
`ShortformOutputParser.java`:
|
|
|
|
```java
|
|
package com.hlab.yanalyst.domain.shortform;
|
|
|
|
import com.fasterxml.jackson.databind.JsonNode;
|
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
import org.springframework.stereotype.Component;
|
|
|
|
import java.util.ArrayList;
|
|
import java.util.List;
|
|
import java.util.regex.Matcher;
|
|
import java.util.regex.Pattern;
|
|
|
|
/**
|
|
* Opal "숏폼 생성기" Output 원문을 구간(ID N)별로 파싱한다.
|
|
*
|
|
* <p>실전 출력 변형에 대응한다: 섹션 구분자 {@code N=====}(= 5개 이상), ```json 펜스가
|
|
* 있기도 없기도 함, 타이틀 후보 목록의 번호 유무 혼재. 어떤 조각이 깨져도 원문은
|
|
* ShortformJob.rawOutput에 보존되므로 여기서는 최선 추출만 한다.
|
|
*/
|
|
@Component
|
|
public class ShortformOutputParser {
|
|
|
|
private static final Pattern SECTION = Pattern.compile("(?m)^\\s*(\\d+)={5,}");
|
|
private static final ObjectMapper OM = new ObjectMapper();
|
|
|
|
public List<ParsedClip> parse(String raw) {
|
|
List<ParsedClip> result = new ArrayList<>();
|
|
if (raw == null || raw.isBlank()) return result;
|
|
|
|
Matcher m = SECTION.matcher(raw);
|
|
List<Integer> starts = new ArrayList<>();
|
|
List<Integer> ends = new ArrayList<>();
|
|
List<Integer> nos = new ArrayList<>();
|
|
while (m.find()) {
|
|
starts.add(m.end());
|
|
ends.add(m.start());
|
|
nos.add(Integer.parseInt(m.group(1)));
|
|
}
|
|
for (int i = 0; i < starts.size(); i++) {
|
|
int to = (i + 1 < starts.size()) ? ends.get(i + 1) : raw.length();
|
|
ParsedClip clip = parseSection(nos.get(i), raw.substring(starts.get(i), to));
|
|
if (clip != null) result.add(clip);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
private ParsedClip parseSection(int no, String body) {
|
|
String json = extractJson(body);
|
|
String titles = extractBetween(body, "타이틀 후보", "검산표");
|
|
String table = extractFromLine(body, "검산표");
|
|
if (json == null && titles == null && table == null) return null;
|
|
|
|
String top = null;
|
|
String main = null;
|
|
if (json != null) {
|
|
try {
|
|
JsonNode node = OM.readTree(json);
|
|
top = node.path("title_top").asText(null);
|
|
main = node.path("title_main").asText(null);
|
|
} catch (Exception ignored) {
|
|
// JSON이 깨져도 원문 조각은 보존한다
|
|
}
|
|
}
|
|
return new ParsedClip(no, json, titles, table, top, main);
|
|
}
|
|
|
|
/** ```json 펜스가 있으면 그 내부, 없으면 첫 '{'부터 중괄호 균형이 맞는 지점까지. */
|
|
private String extractJson(String body) {
|
|
int fence = body.indexOf("```json");
|
|
String scope = body;
|
|
if (fence >= 0) {
|
|
int from = fence + "```json".length();
|
|
int close = body.indexOf("```", from);
|
|
scope = close > from ? body.substring(from, close) : body.substring(from);
|
|
}
|
|
int open = scope.indexOf('{');
|
|
if (open < 0) return null;
|
|
int depth = 0;
|
|
boolean inString = false;
|
|
for (int i = open; i < scope.length(); i++) {
|
|
char c = scope.charAt(i);
|
|
if (inString) {
|
|
if (c == '\\') i++;
|
|
else if (c == '"') inString = false;
|
|
} else if (c == '"') {
|
|
inString = true;
|
|
} else if (c == '{') {
|
|
depth++;
|
|
} else if (c == '}') {
|
|
depth--;
|
|
if (depth == 0) return scope.substring(open, i + 1).trim();
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/** startKey가 있는 줄부터 endKey가 있는 줄 직전까지. 없으면 null. */
|
|
private String extractBetween(String body, String startKey, String endKey) {
|
|
int s = body.indexOf(startKey);
|
|
if (s < 0) return null;
|
|
int lineStart = body.lastIndexOf('\n', s) + 1;
|
|
int e = body.indexOf(endKey, s);
|
|
int end = e < 0 ? body.length() : body.lastIndexOf('\n', e) + 1;
|
|
String block = body.substring(lineStart, end).trim();
|
|
return block.isEmpty() ? null : block;
|
|
}
|
|
|
|
private String extractFromLine(String body, String key) {
|
|
int s = body.indexOf(key);
|
|
if (s < 0) return null;
|
|
int lineStart = body.lastIndexOf('\n', s) + 1;
|
|
String block = body.substring(lineStart).trim();
|
|
return block.isEmpty() ? null : block;
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: 테스트 통과 확인**
|
|
|
|
Run: `.\gradlew.bat test --tests "com.hlab.yanalyst.domain.shortform.ShortformOutputParserTest"`
|
|
Expected: 4개 모두 PASS. (주의: 픽스처의 검산표 끝에 후행 공백 줄이 있어도 trim으로 흡수됨. 섹션 1의 구분자 뒤 같은 줄에 ```json이 붙어 있는 케이스가 extractJson의 fence 경로로 커버되는지 확인)
|
|
|
|
- [ ] **Step 5: 커밋**
|
|
|
|
```bash
|
|
git add src/main/java/com/hlab/yanalyst/domain/shortform src/test/java/com/hlab/yanalyst/domain/shortform
|
|
git commit -m "feat: Opal 숏폼 출력 파서 — 구간별 JSON·타이틀·검산표 추출"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 2: 유튜브 videoId 추출 유틸 (TDD)
|
|
|
|
**Files:**
|
|
- Create: `src/main/java/com/hlab/yanalyst/domain/shortform/YoutubeVideoId.java`
|
|
- Test: `src/test/java/com/hlab/yanalyst/domain/shortform/YoutubeVideoIdTest.java`
|
|
|
|
**Interfaces:**
|
|
- Produces: `YoutubeVideoId.from(String url) : Optional<String>` — watch?v= / youtu.be/ / shorts/ / embed/ / live/ 지원, 11자 ID만 유효
|
|
|
|
- [ ] **Step 1: 실패하는 테스트 작성**
|
|
|
|
```java
|
|
package com.hlab.yanalyst.domain.shortform;
|
|
|
|
import org.junit.jupiter.api.Test;
|
|
|
|
import static org.junit.jupiter.api.Assertions.*;
|
|
|
|
class YoutubeVideoIdTest {
|
|
|
|
@Test
|
|
void 다양한_유튜브_url_형식에서_videoId를_추출한다() {
|
|
assertEquals("JW5mwZ8RsG8", YoutubeVideoId.from("https://www.youtube.com/watch?v=JW5mwZ8RsG8").orElseThrow());
|
|
assertEquals("JW5mwZ8RsG8", YoutubeVideoId.from("https://www.youtube.com/watch?v=JW5mwZ8RsG8&t=10s").orElseThrow());
|
|
assertEquals("JW5mwZ8RsG8", YoutubeVideoId.from("https://youtu.be/JW5mwZ8RsG8").orElseThrow());
|
|
assertEquals("JW5mwZ8RsG8", YoutubeVideoId.from("https://www.youtube.com/shorts/JW5mwZ8RsG8").orElseThrow());
|
|
assertEquals("JW5mwZ8RsG8", YoutubeVideoId.from("https://www.youtube.com/embed/JW5mwZ8RsG8").orElseThrow());
|
|
assertEquals("JW5mwZ8RsG8", YoutubeVideoId.from("https://www.youtube.com/live/JW5mwZ8RsG8?feature=share").orElseThrow());
|
|
}
|
|
|
|
@Test
|
|
void 유튜브가_아니거나_id가_없으면_empty() {
|
|
assertTrue(YoutubeVideoId.from(null).isEmpty());
|
|
assertTrue(YoutubeVideoId.from("").isEmpty());
|
|
assertTrue(YoutubeVideoId.from("https://example.com/watch?v=JW5mwZ8RsG8").isEmpty());
|
|
assertTrue(YoutubeVideoId.from("https://www.youtube.com/watch").isEmpty());
|
|
assertTrue(YoutubeVideoId.from("https://www.youtube.com/watch?v=short").isEmpty());
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: 테스트 실패 확인**
|
|
|
|
Run: `.\gradlew.bat test --tests "com.hlab.yanalyst.domain.shortform.YoutubeVideoIdTest"`
|
|
Expected: 컴파일 실패
|
|
|
|
- [ ] **Step 3: 구현**
|
|
|
|
```java
|
|
package com.hlab.yanalyst.domain.shortform;
|
|
|
|
import java.util.Optional;
|
|
import java.util.regex.Matcher;
|
|
import java.util.regex.Pattern;
|
|
|
|
/** 유튜브 URL에서 11자 videoId를 뽑는다. youtube.com / youtu.be 도메인만 인정. */
|
|
public final class YoutubeVideoId {
|
|
|
|
private static final Pattern PATTERN = Pattern.compile(
|
|
"^https?://(?:www\\.|m\\.)?(?:youtube\\.com/(?:watch\\?[^#]*\\bv=|shorts/|embed/|live/)|youtu\\.be/)"
|
|
+ "([A-Za-z0-9_-]{11})(?:[?&#/].*)?$");
|
|
|
|
private YoutubeVideoId() {}
|
|
|
|
public static Optional<String> from(String url) {
|
|
if (url == null || url.isBlank()) return Optional.empty();
|
|
Matcher m = PATTERN.matcher(url.trim());
|
|
return m.matches() ? Optional.of(m.group(1)) : Optional.empty();
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: 테스트 통과 확인**
|
|
|
|
Run: `.\gradlew.bat test --tests "com.hlab.yanalyst.domain.shortform.YoutubeVideoIdTest"`
|
|
Expected: PASS
|
|
|
|
- [ ] **Step 5: 커밋**
|
|
|
|
```bash
|
|
git add src/main/java/com/hlab/yanalyst/domain/shortform/YoutubeVideoId.java src/test/java/com/hlab/yanalyst/domain/shortform/YoutubeVideoIdTest.java
|
|
git commit -m "feat: 유튜브 URL videoId 추출 유틸"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 3: 엔티티 + 리포지토리
|
|
|
|
**Files:**
|
|
- Create: `src/main/java/com/hlab/yanalyst/domain/shortform/ShortformJobStatus.java`
|
|
- Create: `src/main/java/com/hlab/yanalyst/domain/shortform/ShortformJob.java`
|
|
- Create: `src/main/java/com/hlab/yanalyst/domain/shortform/ShortformClip.java`
|
|
- Create: `src/main/java/com/hlab/yanalyst/domain/shortform/ShortformJobRepository.java`
|
|
|
|
**Interfaces:**
|
|
- Consumes: `ParsedClip` (Task 1)
|
|
- Produces: `ShortformJob.create(String youtubeUrl, String videoId)`, `job.applyResult(String raw, List<ParsedClip>)`, `job.markFailed(String raw)`, `ShortformJobRepository.findByVideoId(String)`, `findAllByOrderByCreatedAtDesc()`, `findByStatusOrderByCreatedAtAsc(ShortformJobStatus)`
|
|
|
|
- [ ] **Step 1: 구현** (DB 필요 테스트는 불가 — 컴파일·빌드로 검증)
|
|
|
|
`ShortformJobStatus.java`:
|
|
|
|
```java
|
|
package com.hlab.yanalyst.domain.shortform;
|
|
|
|
public enum ShortformJobStatus { PENDING, DONE, FAILED }
|
|
```
|
|
|
|
`ShortformJob.java`:
|
|
|
|
```java
|
|
package com.hlab.yanalyst.domain.shortform;
|
|
|
|
import jakarta.persistence.*;
|
|
import lombok.Getter;
|
|
import lombok.NoArgsConstructor;
|
|
import org.hibernate.annotations.CreationTimestamp;
|
|
|
|
import java.time.LocalDateTime;
|
|
import java.util.ArrayList;
|
|
import java.util.List;
|
|
|
|
/**
|
|
* 숏폼 큐 작업 하나 = 유튜브 영상 하나. Opal "숏폼 생성기" 실행 결과를 담는다.
|
|
*
|
|
* <p>rawOutput은 파싱 성공 여부와 무관하게 항상 원문을 보존한다 — 파서가 놓친 정보는
|
|
* 여기서 복구할 수 있다.
|
|
*/
|
|
@Entity
|
|
@Table(name = "shortform_job",
|
|
indexes = @Index(name = "idx_sfj_video_id", columnList = "videoId", unique = true))
|
|
@Getter
|
|
@NoArgsConstructor
|
|
public class ShortformJob {
|
|
|
|
@Id
|
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
|
private Long id;
|
|
|
|
@Column(nullable = false, length = 500)
|
|
private String youtubeUrl;
|
|
|
|
@Column(nullable = false, length = 20)
|
|
private String videoId;
|
|
|
|
/** 결과의 첫 클립 title_main. 목록 표시에 쓴다. */
|
|
@Column(length = 300)
|
|
private String title;
|
|
|
|
@Enumerated(EnumType.STRING)
|
|
@Column(nullable = false, length = 20)
|
|
private ShortformJobStatus status = ShortformJobStatus.PENDING;
|
|
|
|
@Column(columnDefinition = "TEXT")
|
|
private String rawOutput;
|
|
|
|
@CreationTimestamp
|
|
@Column(updatable = false)
|
|
private LocalDateTime createdAt;
|
|
|
|
private LocalDateTime completedAt;
|
|
|
|
@OneToMany(mappedBy = "job", cascade = CascadeType.ALL, orphanRemoval = true)
|
|
@OrderBy("clipNo ASC")
|
|
private List<ShortformClip> clips = new ArrayList<>();
|
|
|
|
public static ShortformJob create(String youtubeUrl, String videoId) {
|
|
ShortformJob job = new ShortformJob();
|
|
job.youtubeUrl = youtubeUrl;
|
|
job.videoId = videoId;
|
|
return job;
|
|
}
|
|
|
|
public void applyResult(String raw, List<ParsedClip> parsed) {
|
|
this.rawOutput = raw;
|
|
this.clips.clear();
|
|
for (ParsedClip p : parsed) {
|
|
this.clips.add(ShortformClip.of(this, p));
|
|
}
|
|
this.title = parsed.isEmpty() ? this.title : parsed.get(0).titleMain();
|
|
this.status = ShortformJobStatus.DONE;
|
|
this.completedAt = LocalDateTime.now();
|
|
}
|
|
|
|
public void markFailed(String raw) {
|
|
this.rawOutput = raw;
|
|
this.status = ShortformJobStatus.FAILED;
|
|
this.completedAt = LocalDateTime.now();
|
|
}
|
|
}
|
|
```
|
|
|
|
`ShortformClip.java`:
|
|
|
|
```java
|
|
package com.hlab.yanalyst.domain.shortform;
|
|
|
|
import jakarta.persistence.*;
|
|
import lombok.Getter;
|
|
import lombok.NoArgsConstructor;
|
|
|
|
/** 작업 하나에서 나온 구간(ID 1~5) 하나. capcutJson이 실제 편집 프로그램에 붙여넣는 값. */
|
|
@Entity
|
|
@Table(name = "shortform_clip")
|
|
@Getter
|
|
@NoArgsConstructor
|
|
public class ShortformClip {
|
|
|
|
@Id
|
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
|
private Long id;
|
|
|
|
@ManyToOne(fetch = FetchType.LAZY, optional = false)
|
|
@JoinColumn(name = "job_id")
|
|
private ShortformJob job;
|
|
|
|
@Column(nullable = false)
|
|
private Integer clipNo;
|
|
|
|
@Column(length = 100)
|
|
private String titleTop;
|
|
|
|
@Column(length = 200)
|
|
private String titleMain;
|
|
|
|
@Column(columnDefinition = "TEXT")
|
|
private String capcutJson;
|
|
|
|
@Column(columnDefinition = "TEXT")
|
|
private String titleCandidates;
|
|
|
|
@Column(columnDefinition = "TEXT")
|
|
private String durationTable;
|
|
|
|
static ShortformClip of(ShortformJob job, ParsedClip p) {
|
|
ShortformClip clip = new ShortformClip();
|
|
clip.job = job;
|
|
clip.clipNo = p.clipNo();
|
|
clip.titleTop = p.titleTop();
|
|
clip.titleMain = p.titleMain();
|
|
clip.capcutJson = p.capcutJson();
|
|
clip.titleCandidates = p.titleCandidates();
|
|
clip.durationTable = p.durationTable();
|
|
return clip;
|
|
}
|
|
}
|
|
```
|
|
|
|
`ShortformJobRepository.java`:
|
|
|
|
```java
|
|
package com.hlab.yanalyst.domain.shortform;
|
|
|
|
import org.springframework.data.jpa.repository.JpaRepository;
|
|
|
|
import java.util.List;
|
|
import java.util.Optional;
|
|
|
|
public interface ShortformJobRepository extends JpaRepository<ShortformJob, Long> {
|
|
Optional<ShortformJob> findByVideoId(String videoId);
|
|
List<ShortformJob> findAllByOrderByCreatedAtDesc();
|
|
List<ShortformJob> findByStatusOrderByCreatedAtAsc(ShortformJobStatus status);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: 빌드 확인**
|
|
|
|
Run: `.\gradlew.bat compileJava`
|
|
Expected: BUILD SUCCESSFUL
|
|
|
|
- [ ] **Step 3: 커밋**
|
|
|
|
```bash
|
|
git add src/main/java/com/hlab/yanalyst/domain/shortform
|
|
git commit -m "feat: 숏폼 큐 엔티티(ShortformJob/Clip)와 리포지토리"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 4: 서비스 + REST API
|
|
|
|
**Files:**
|
|
- Create: `src/main/java/com/hlab/yanalyst/domain/shortform/ShortformService.java`
|
|
- Create: `src/main/java/com/hlab/yanalyst/domain/shortform/ShortformController.java`
|
|
- Create: `src/main/java/com/hlab/yanalyst/domain/shortform/dto/ShortformDtos.java`
|
|
|
|
**Interfaces:**
|
|
- Consumes: Task 1~3 전부 (`ShortformOutputParser.parse`, `YoutubeVideoId.from`, `ShortformJob.*`, `ShortformJobRepository.*`)
|
|
- Produces: REST API
|
|
- `POST /api/shortform/jobs` body `{"youtubeUrl": "..."}` → `ApiResponse<JobDetail>` (중복 videoId면 기존 작업 반환)
|
|
- `GET /api/shortform/jobs` → `ApiResponse<List<JobSummary>>`
|
|
- `GET /api/shortform/jobs?status=PENDING` → 상태 필터 (스킬이 큐 조회에 사용)
|
|
- `GET /api/shortform/jobs/{id}` → `ApiResponse<JobDetail>`
|
|
- `POST /api/shortform/jobs/{id}/result` body `{"rawText": "..."}` → `ApiResponse<JobDetail>`
|
|
- `POST /api/shortform/import` body `{"youtubeUrl": "...", "rawText": "..."}` → `ApiResponse<JobDetail>`
|
|
- `DELETE /api/shortform/jobs/{id}` → `ApiResponse<Long>`
|
|
|
|
- [ ] **Step 1: 구현**
|
|
|
|
`dto/ShortformDtos.java` (record 묶음 — 파일 하나로 응집):
|
|
|
|
```java
|
|
package com.hlab.yanalyst.domain.shortform.dto;
|
|
|
|
import com.hlab.yanalyst.domain.shortform.ShortformClip;
|
|
import com.hlab.yanalyst.domain.shortform.ShortformJob;
|
|
|
|
import java.time.LocalDateTime;
|
|
import java.util.List;
|
|
|
|
public final class ShortformDtos {
|
|
|
|
private ShortformDtos() {}
|
|
|
|
public record RegisterRequest(String youtubeUrl) {}
|
|
|
|
public record ResultRequest(String rawText) {}
|
|
|
|
public record ImportRequest(String youtubeUrl, String rawText) {}
|
|
|
|
public record JobSummary(Long id, String youtubeUrl, String videoId, String title,
|
|
String status, int clipCount,
|
|
LocalDateTime createdAt, LocalDateTime completedAt) {
|
|
public static JobSummary from(ShortformJob job) {
|
|
return new JobSummary(job.getId(), job.getYoutubeUrl(), job.getVideoId(),
|
|
job.getTitle(), job.getStatus().name(), job.getClips().size(),
|
|
job.getCreatedAt(), job.getCompletedAt());
|
|
}
|
|
}
|
|
|
|
public record ClipDto(int clipNo, String titleTop, String titleMain,
|
|
String capcutJson, String titleCandidates, String durationTable) {
|
|
public static ClipDto from(ShortformClip clip) {
|
|
return new ClipDto(clip.getClipNo(), clip.getTitleTop(), clip.getTitleMain(),
|
|
clip.getCapcutJson(), clip.getTitleCandidates(), clip.getDurationTable());
|
|
}
|
|
}
|
|
|
|
public record JobDetail(Long id, String youtubeUrl, String videoId, String title,
|
|
String status, LocalDateTime createdAt, LocalDateTime completedAt,
|
|
List<ClipDto> clips) {
|
|
public static JobDetail from(ShortformJob job) {
|
|
return new JobDetail(job.getId(), job.getYoutubeUrl(), job.getVideoId(),
|
|
job.getTitle(), job.getStatus().name(), job.getCreatedAt(),
|
|
job.getCompletedAt(), job.getClips().stream().map(ClipDto::from).toList());
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
`ShortformService.java`:
|
|
|
|
```java
|
|
package com.hlab.yanalyst.domain.shortform;
|
|
|
|
import com.hlab.yanalyst.domain.shortform.dto.ShortformDtos.JobDetail;
|
|
import com.hlab.yanalyst.domain.shortform.dto.ShortformDtos.JobSummary;
|
|
import lombok.RequiredArgsConstructor;
|
|
import org.springframework.stereotype.Service;
|
|
import org.springframework.transaction.annotation.Transactional;
|
|
|
|
import java.util.List;
|
|
|
|
@Service
|
|
@RequiredArgsConstructor
|
|
public class ShortformService {
|
|
|
|
private final ShortformJobRepository jobRepository;
|
|
private final ShortformOutputParser parser;
|
|
|
|
/** URL 등록. 같은 videoId가 이미 있으면 그 작업을 그대로 반환한다(중복 방지). */
|
|
@Transactional
|
|
public JobDetail register(String youtubeUrl) {
|
|
String videoId = YoutubeVideoId.from(youtubeUrl)
|
|
.orElseThrow(() -> new IllegalArgumentException("유튜브 URL이 아닙니다: " + youtubeUrl));
|
|
ShortformJob job = jobRepository.findByVideoId(videoId)
|
|
.orElseGet(() -> jobRepository.save(ShortformJob.create(youtubeUrl.trim(), videoId)));
|
|
return JobDetail.from(job);
|
|
}
|
|
|
|
/** Opal 출력 원문을 파싱해 저장. 클립이 하나도 안 나오면 FAILED로 남긴다. */
|
|
@Transactional
|
|
public JobDetail saveResult(Long jobId, String rawText) {
|
|
ShortformJob job = jobRepository.findById(jobId)
|
|
.orElseThrow(() -> new IllegalArgumentException("작업이 없습니다: " + jobId));
|
|
List<ParsedClip> parsed = parser.parse(rawText);
|
|
if (parsed.isEmpty()) {
|
|
job.markFailed(rawText);
|
|
} else {
|
|
job.applyResult(rawText, parsed);
|
|
}
|
|
return JobDetail.from(job);
|
|
}
|
|
|
|
/** 등록 + 결과 저장 한 번에 (Opal 수동 실행 후 붙여넣기 경로). */
|
|
@Transactional
|
|
public JobDetail importResult(String youtubeUrl, String rawText) {
|
|
JobDetail registered = register(youtubeUrl);
|
|
return saveResult(registered.id(), rawText);
|
|
}
|
|
|
|
@Transactional(readOnly = true)
|
|
public List<JobSummary> list(ShortformJobStatus status) {
|
|
List<ShortformJob> jobs = (status == null)
|
|
? jobRepository.findAllByOrderByCreatedAtDesc()
|
|
: jobRepository.findByStatusOrderByCreatedAtAsc(status);
|
|
return jobs.stream().map(JobSummary::from).toList();
|
|
}
|
|
|
|
@Transactional(readOnly = true)
|
|
public JobDetail detail(Long jobId) {
|
|
return jobRepository.findById(jobId).map(JobDetail::from)
|
|
.orElseThrow(() -> new IllegalArgumentException("작업이 없습니다: " + jobId));
|
|
}
|
|
|
|
@Transactional
|
|
public void delete(Long jobId) {
|
|
jobRepository.deleteById(jobId);
|
|
}
|
|
}
|
|
```
|
|
|
|
`ShortformController.java`:
|
|
|
|
```java
|
|
package com.hlab.yanalyst.domain.shortform;
|
|
|
|
import com.hlab.yanalyst.domain.shortform.dto.ShortformDtos.ImportRequest;
|
|
import com.hlab.yanalyst.domain.shortform.dto.ShortformDtos.JobDetail;
|
|
import com.hlab.yanalyst.domain.shortform.dto.ShortformDtos.JobSummary;
|
|
import com.hlab.yanalyst.domain.shortform.dto.ShortformDtos.RegisterRequest;
|
|
import com.hlab.yanalyst.domain.shortform.dto.ShortformDtos.ResultRequest;
|
|
import com.hlab.yanalyst.global.common.ApiResponse;
|
|
import lombok.RequiredArgsConstructor;
|
|
import org.springframework.web.bind.annotation.*;
|
|
|
|
import java.util.List;
|
|
|
|
@RestController
|
|
@RequestMapping("/api/shortform")
|
|
@RequiredArgsConstructor
|
|
public class ShortformController {
|
|
|
|
private final ShortformService shortformService;
|
|
|
|
@PostMapping("/jobs")
|
|
public ApiResponse<JobDetail> register(@RequestBody RegisterRequest request) {
|
|
return ApiResponse.created(shortformService.register(request.youtubeUrl()));
|
|
}
|
|
|
|
@GetMapping("/jobs")
|
|
public ApiResponse<List<JobSummary>> list(@RequestParam(required = false) ShortformJobStatus status) {
|
|
return ApiResponse.ok(shortformService.list(status));
|
|
}
|
|
|
|
@GetMapping("/jobs/{id}")
|
|
public ApiResponse<JobDetail> detail(@PathVariable Long id) {
|
|
return ApiResponse.ok(shortformService.detail(id));
|
|
}
|
|
|
|
@PostMapping("/jobs/{id}/result")
|
|
public ApiResponse<JobDetail> saveResult(@PathVariable Long id, @RequestBody ResultRequest request) {
|
|
return ApiResponse.ok(shortformService.saveResult(id, request.rawText()));
|
|
}
|
|
|
|
@PostMapping("/import")
|
|
public ApiResponse<JobDetail> importResult(@RequestBody ImportRequest request) {
|
|
return ApiResponse.ok(shortformService.importResult(request.youtubeUrl(), request.rawText()));
|
|
}
|
|
|
|
@DeleteMapping("/jobs/{id}")
|
|
public ApiResponse<Long> delete(@PathVariable Long id) {
|
|
shortformService.delete(id);
|
|
return ApiResponse.ok(id);
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: 빌드 + 전체 테스트**
|
|
|
|
Run: `.\gradlew.bat build`
|
|
Expected: BUILD SUCCESSFUL (Task 1·2 테스트 포함 전부 PASS)
|
|
|
|
- [ ] **Step 3: 커밋**
|
|
|
|
```bash
|
|
git add src/main/java/com/hlab/yanalyst/domain/shortform
|
|
git commit -m "feat: 숏폼 큐 서비스·REST API — 등록/조회/결과저장/import/삭제"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 5: /shortform 페이지 + 사이드바
|
|
|
|
**Files:**
|
|
- Modify: `src/main/java/com/hlab/yanalyst/web/WebController.java` (feed 매핑 아래에 추가)
|
|
- Modify: `src/main/resources/templates/layout/sidebar.html` (소재 섹션의 "소재 피드" 항목 아래)
|
|
- Create: `src/main/resources/templates/shortform.html`
|
|
|
|
**Interfaces:**
|
|
- Consumes: Task 4의 REST API 전부 (fetch로 호출)
|
|
|
|
- [ ] **Step 1: WebController 매핑 추가**
|
|
|
|
```java
|
|
@GetMapping("/shortform")
|
|
public String shortform(Model model) {
|
|
model.addAttribute("currentPage", "shortform");
|
|
return "shortform";
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: 사이드바 메뉴 추가** — `소재 피드` `<a>` 태그 바로 아래에:
|
|
|
|
```html
|
|
<a th:href="@{/shortform}" class="nav-item" th:classappend="${currentPage == 'shortform'} ? 'active'">
|
|
<i data-lucide="clapperboard" class="nav-icon"></i><span class="nav-text">숏폼 큐</span>
|
|
</a>
|
|
```
|
|
|
|
- [ ] **Step 3: shortform.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>
|
|
.sf-form { display: flex; gap: 0.5rem; margin-bottom: 1rem; }
|
|
.sf-form input[type="url"] { flex: 1; }
|
|
.sf-paste { display: none; margin-bottom: 1rem; }
|
|
.sf-paste.open { display: block; }
|
|
.sf-paste textarea { width: 100%; min-height: 160px; font-family: monospace; font-size: 0.8rem; }
|
|
.sf-job { border: 1px solid var(--border); border-radius: 10px; margin-bottom: 0.75rem; overflow: hidden; }
|
|
.sf-job-head { display: flex; align-items: center; gap: 0.75rem; padding: 0.6rem 0.9rem; cursor: pointer; }
|
|
.sf-job-head img { width: 96px; border-radius: 6px; flex-shrink: 0; }
|
|
.sf-job-title { flex: 1; min-width: 0; }
|
|
.sf-job-title .t { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
.sf-job-title .u { font-size: 0.75rem; color: var(--text-secondary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
.sf-badge { font-size: 0.7rem; padding: 0.15rem 0.5rem; border-radius: 99px; font-weight: 600; }
|
|
.sf-badge.PENDING { background: var(--warning-bg, #fef3c7); color: var(--warning-fg, #92400e); }
|
|
.sf-badge.DONE { background: var(--success-bg, #dcfce7); color: var(--success-fg, #166534); }
|
|
.sf-badge.FAILED { background: var(--danger-bg, #fee2e2); color: var(--danger-fg, #991b1b); }
|
|
.sf-clips { display: none; padding: 0.75rem 0.9rem; border-top: 1px solid var(--border); }
|
|
.sf-job.open .sf-clips { display: block; }
|
|
.sf-clip { border: 1px solid var(--border); border-radius: 8px; padding: 0.6rem 0.8rem; margin-bottom: 0.6rem; }
|
|
.sf-clip-head { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.4rem; }
|
|
.sf-clip-head .no { font-weight: 700; }
|
|
.sf-clip pre { font-size: 0.72rem; white-space: pre-wrap; background: var(--bg-secondary, rgba(0,0,0,0.04)); border-radius: 6px; padding: 0.5rem; margin: 0.35rem 0; max-height: 180px; overflow-y: auto; }
|
|
</style>
|
|
</head>
|
|
|
|
<body>
|
|
<div layout:fragment="content">
|
|
<div class="page-header">
|
|
<div>
|
|
<h1>숏폼 큐</h1>
|
|
<p class="sub">유튜브 URL을 등록하면 Opal 숏폼 생성기 결과를 구간별로 저장·조회합니다.</p>
|
|
</div>
|
|
<div class="actions">
|
|
<button class="btn btn-secondary" onclick="togglePaste()">
|
|
<i data-lucide="clipboard-paste" style="width:15px;"></i> 결과 붙여넣기
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<form class="sf-form" onsubmit="registerJob(event)">
|
|
<input type="url" id="urlInput" class="input" placeholder="https://www.youtube.com/watch?v=..." required>
|
|
<button class="btn btn-primary" type="submit">큐에 등록</button>
|
|
</form>
|
|
|
|
<div class="sf-paste" id="pasteBox">
|
|
<input type="url" id="pasteUrl" class="input" placeholder="영상 URL" style="margin-bottom:0.5rem;width:100%;">
|
|
<textarea id="pasteRaw" placeholder="Opal Output 전체를 붙여넣으세요 (1===== ... 5===== 전부)"></textarea>
|
|
<button class="btn btn-primary" style="margin-top:0.5rem;" onclick="importResult()">파싱해서 저장</button>
|
|
</div>
|
|
|
|
<div id="jobList"></div>
|
|
</div>
|
|
|
|
<th:block layout:fragment="script">
|
|
<script>
|
|
async function api(path, opts) {
|
|
const res = await fetch(path, Object.assign({ headers: { 'Content-Type': 'application/json' } }, opts));
|
|
const body = await res.json();
|
|
if (!body.success) throw new Error(body.message);
|
|
return body.data;
|
|
}
|
|
|
|
function togglePaste() {
|
|
document.getElementById('pasteBox').classList.toggle('open');
|
|
}
|
|
|
|
async function registerJob(e) {
|
|
e.preventDefault();
|
|
const youtubeUrl = document.getElementById('urlInput').value.trim();
|
|
try {
|
|
await api('/api/shortform/jobs', { method: 'POST', body: JSON.stringify({ youtubeUrl }) });
|
|
document.getElementById('urlInput').value = '';
|
|
loadJobs();
|
|
} catch (err) { alert(err.message); }
|
|
}
|
|
|
|
async function importResult() {
|
|
const youtubeUrl = document.getElementById('pasteUrl').value.trim();
|
|
const rawText = document.getElementById('pasteRaw').value;
|
|
if (!youtubeUrl || !rawText.trim()) { alert('URL과 결과 원문을 모두 입력하세요'); return; }
|
|
try {
|
|
await api('/api/shortform/import', { method: 'POST', body: JSON.stringify({ youtubeUrl, rawText }) });
|
|
document.getElementById('pasteUrl').value = '';
|
|
document.getElementById('pasteRaw').value = '';
|
|
togglePaste();
|
|
loadJobs();
|
|
} catch (err) { alert(err.message); }
|
|
}
|
|
|
|
async function deleteJob(id, e) {
|
|
e.stopPropagation();
|
|
if (!confirm('이 작업을 삭제할까요?')) return;
|
|
await api('/api/shortform/jobs/' + id, { method: 'DELETE' });
|
|
loadJobs();
|
|
}
|
|
|
|
async function toggleJob(id, el) {
|
|
if (el.classList.contains('open')) { el.classList.remove('open'); return; }
|
|
const detail = await api('/api/shortform/jobs/' + id);
|
|
el.querySelector('.sf-clips').innerHTML = detail.clips.length
|
|
? detail.clips.map(clipHtml).join('')
|
|
: '<p class="sub">저장된 클립이 없습니다. 결과 붙여넣기 또는 "숏폼 큐 돌려줘"로 채우세요.</p>';
|
|
el.classList.add('open');
|
|
if (window.lucide) lucide.createIcons();
|
|
el.querySelectorAll('[data-copy]').forEach(btn => {
|
|
btn.onclick = (e) => {
|
|
e.stopPropagation();
|
|
navigator.clipboard.writeText(decodeURIComponent(btn.dataset.copy));
|
|
btn.textContent = '복사됨!';
|
|
setTimeout(() => btn.textContent = 'capcut2 JSON 복사', 1200);
|
|
};
|
|
});
|
|
}
|
|
|
|
function esc(s) {
|
|
return (s || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
}
|
|
|
|
function clipHtml(c) {
|
|
return `<div class="sf-clip">
|
|
<div class="sf-clip-head">
|
|
<span class="no">ID ${c.clipNo}</span>
|
|
<span>${esc(c.titleTop || '')} / <b>${esc(c.titleMain || '(타이틀 파싱 실패)')}</b></span>
|
|
<span style="flex:1"></span>
|
|
<button class="btn btn-secondary" data-copy="${encodeURIComponent(c.capcutJson || '')}"
|
|
${c.capcutJson ? '' : 'disabled'}>capcut2 JSON 복사</button>
|
|
</div>
|
|
${c.titleCandidates ? `<pre>${esc(c.titleCandidates)}</pre>` : ''}
|
|
${c.durationTable ? `<pre>${esc(c.durationTable)}</pre>` : ''}
|
|
</div>`;
|
|
}
|
|
|
|
async function loadJobs() {
|
|
const jobs = await api('/api/shortform/jobs');
|
|
const list = document.getElementById('jobList');
|
|
list.innerHTML = jobs.length ? '' : '<p class="sub">등록된 작업이 없습니다.</p>';
|
|
jobs.forEach(j => {
|
|
const el = document.createElement('div');
|
|
el.className = 'sf-job';
|
|
el.innerHTML = `<div class="sf-job-head">
|
|
<img src="https://img.youtube.com/vi/${j.videoId}/mqdefault.jpg" alt="">
|
|
<div class="sf-job-title">
|
|
<div class="t">${esc(j.title || j.videoId)}</div>
|
|
<div class="u">${esc(j.youtubeUrl)} · 클립 ${j.clipCount}개</div>
|
|
</div>
|
|
<span class="sf-badge ${j.status}">${j.status}</span>
|
|
<button class="btn btn-secondary" onclick="deleteJob(${j.id}, event)">
|
|
<i data-lucide="trash-2" style="width:14px;"></i></button>
|
|
</div>
|
|
<div class="sf-clips"></div>`;
|
|
el.querySelector('.sf-job-head').onclick = () => toggleJob(j.id, el);
|
|
list.appendChild(el);
|
|
});
|
|
if (window.lucide) lucide.createIcons();
|
|
}
|
|
|
|
loadJobs();
|
|
</script>
|
|
</th:block>
|
|
</body>
|
|
</html>
|
|
```
|
|
|
|
주의: `layout:fragment="script"` 블록 이름은 `layout/base.html`이 실제 정의한 fragment 이름을 따라야 한다. 구현 시 base.html을 열어 확인하고, script fragment가 없으면 content fragment 안 맨 아래에 `<script>`를 인라인으로 옮긴다. CSS 변수(`--border`, `--text-secondary` 등)도 variables.css에 실제 존재하는 이름으로 맞춘다.
|
|
|
|
- [ ] **Step 4: 빌드 확인**
|
|
|
|
Run: `.\gradlew.bat build`
|
|
Expected: BUILD SUCCESSFUL
|
|
|
|
- [ ] **Step 5: 커밋**
|
|
|
|
```bash
|
|
git add src/main/java/com/hlab/yanalyst/web/WebController.java src/main/resources/templates/layout/sidebar.html src/main/resources/templates/shortform.html
|
|
git commit -m "feat: 숏폼 큐 페이지 — URL 등록·결과 붙여넣기·클립 카드·JSON 복사"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 6: shortform-queue 스킬
|
|
|
|
**Files:**
|
|
- Create: `.claude/skills/shortform-queue/SKILL.md`
|
|
|
|
**Interfaces:**
|
|
- Consumes: `GET /api/shortform/jobs?status=PENDING`, `POST /api/shortform/jobs/{id}/result` (Task 4)
|
|
|
|
- [ ] **Step 1: SKILL.md 작성**
|
|
|
|
````markdown
|
|
---
|
|
name: shortform-queue
|
|
description: h-lab 숏폼 큐의 대기 작업을 구글 Opal "숏폼 생성기"로 실행하고 결과를 DB에 저장. "숏폼 큐 돌려줘", "/shortform-queue", "숏폼 돌려줘" 요청 시 사용.
|
|
---
|
|
|
|
# 숏폼 큐 실행 (Opal 브라우저 대행)
|
|
|
|
h-lab(localhost:8088)의 PENDING 작업을 Opal로 실행해 결과를 저장한다.
|
|
h-lab 서버가 꺼져 있으면 먼저 `.\gradlew.bat bootRun`(JAVA_HOME=D:\Development\app\JDK\jdk-21.0.5)으로 띄운다.
|
|
|
|
## 절차
|
|
|
|
1. **큐 조회**: `GET http://localhost:8088/api/shortform/jobs?status=PENDING` → 비어있으면 "대기 작업 없음" 보고 후 종료.
|
|
2. **Opal 접속**: 크롬 자동화로 `https://opal.google/edit/1w3mThvSpI3_skt4p4OAN3-M6-tnqz0HN` (숏폼 생성기 편집 화면). 로그인 풀려 있으면 사용자에게 로그인 요청.
|
|
3. 각 PENDING 작업마다:
|
|
a. 캔버스의 **YouTube Video 노드 클릭** → 우측 패널 URL 입력창을 비우고 작업의 youtubeUrl 입력 → **Apply**.
|
|
b. Preview 패널의 새로고침(리셋) 아이콘 → **Start** 클릭.
|
|
c. **완료 폴링**: 40~60초 간격 스크린샷. "Thinking... Step N" 진행 표시가 사라지고 Output(1===== 형식)이 렌더링되면 완료. 전체 4~5분 소요(Step 3 다섯 개가 각 ~90초 병렬).
|
|
d. **Output 추출**: 앱 UI는 접근성 트리에 안 잡힌다. javascript_tool로 `document.getElementById('opal-app').contentDocument`에서 shadow DOM을 재귀 관통해 `"cuts"`를 포함한 **가장 긴** innerText를 찾는다. 텍스트가 크면 window 변수에 저장 후 900자 단위로 나눠 회수한다.
|
|
e. **저장**: `POST http://localhost:8088/api/shortform/jobs/{id}/result` body `{"rawText": "<추출 원문>"}`. 응답의 clips가 5개 미만이면 원문과 함께 보고.
|
|
4. **에러 처리**: 특정 Step 노드가 빨간 표시로 실패하면 Preview 리셋 → Start로 1회 재실행. 재실패 시 해당 작업은 건너뛰고 사유를 보고(저장하지 않음 — PENDING 유지). 프롬프트 노드 끝에 중복 YouTube Video 칩이 생겼는지 확인(2026-08-03에 이 원인으로 ID 3이 항상 실패했음).
|
|
5. **보고**: 처리한 작업 수, 성공/실패 목록, h-lab `/shortform` 링크로 요약.
|
|
|
|
## 주의
|
|
|
|
- Opal은 무료 실험 서비스 — 일일 사용량 제한이 있으니 큐가 많으면 사용자에게 몇 개까지 돌릴지 확인.
|
|
- Opal 앱 구조(노드 구성·프롬프트)가 바뀌어 보이면 조작을 멈추고 사용자에게 보고.
|
|
- 쿠키 배너가 뜨면 "나중에"(거절)를 선택.
|
|
````
|
|
|
|
- [ ] **Step 2: 커밋**
|
|
|
|
```bash
|
|
git add .claude/skills/shortform-queue/SKILL.md
|
|
git commit -m "feat: shortform-queue 스킬 — Opal 브라우저 대행 절차 명문화"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 7: 런타임 스모크 테스트
|
|
|
|
**Files:** 없음 (검증만)
|
|
|
|
- [ ] **Step 1: 서버 기동** (백그라운드)
|
|
|
|
Run: `$env:JAVA_HOME='D:\Development\app\JDK\jdk-21.0.5'; .\gradlew.bat bootRun` (run_in_background)
|
|
Expected: 8088 리슨. ddl-auto가 `shortform_job`/`shortform_clip` 테이블 생성.
|
|
|
|
- [ ] **Step 2: import API로 픽스처 저장**
|
|
|
|
```bash
|
|
python - <<'EOF'
|
|
import json, urllib.request
|
|
raw = open('src/test/resources/shortform/opal-output-sample.txt', encoding='utf-8').read()
|
|
body = json.dumps({'youtubeUrl': 'https://www.youtube.com/watch?v=JW5mwZ8RsG8', 'rawText': raw}).encode()
|
|
req = urllib.request.Request('http://localhost:8088/api/shortform/import', data=body,
|
|
headers={'Content-Type': 'application/json'})
|
|
print(urllib.request.urlopen(req).read().decode()[:500])
|
|
EOF
|
|
```
|
|
|
|
(python이 없으면 curl로 동일 요청. PowerShell이면 Invoke-RestMethod 사용)
|
|
Expected: `"success":true`, `"status":"DONE"`, clips 5개.
|
|
|
|
- [ ] **Step 3: 목록·상세·페이지 확인**
|
|
|
|
- `GET http://localhost:8088/api/shortform/jobs` → 작업 1개, clipCount 5
|
|
- 브라우저(또는 curl)로 `http://localhost:8088/shortform` 로드 → 페이지 렌더 + 사이드바 "숏폼 큐" 하이라이트
|
|
- 카드 펼침 → 클립 5개, "capcut2 JSON 복사" 동작
|
|
|
|
- [ ] **Step 4: 검증 결과 보고 후 서버 종료, 남은 수정 있으면 fix 커밋**
|