h-lab/docs/superpowers/plans/2026-08-03-shortform-queue.md
hehihoho3@gmail.com ca9c6464fc docs: 숏폼 큐 구현 계획 (7개 태스크)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 12:39:29 +09:00

42 KiB

숏폼 큐(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: 실패하는 테스트 작성

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:

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:

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: 커밋
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: 실패하는 테스트 작성

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: 구현
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: 커밋
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:

package com.hlab.yanalyst.domain.shortform;

public enum ShortformJobStatus { PENDING, DONE, FAILED }

ShortformJob.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:

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:

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: 커밋
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/jobsApiResponse<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 묶음 — 파일 하나로 응집):

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:

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:

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: 커밋
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 매핑 추가

    @GetMapping("/shortform")
    public String shortform(Model model) {
        model.addAttribute("currentPage", "shortform");
        return "shortform";
    }
  • Step 2: 사이드바 메뉴 추가소재 피드 <a> 태그 바로 아래에:
            <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 작성
<!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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
            }

            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: 커밋
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 작성

---
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: 커밋
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로 픽스처 저장
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 커밋