diff --git a/src/main/java/com/hlab/yanalyst/domain/shortform/ParsedClip.java b/src/main/java/com/hlab/yanalyst/domain/shortform/ParsedClip.java
new file mode 100644
index 0000000..60d4afe
--- /dev/null
+++ b/src/main/java/com/hlab/yanalyst/domain/shortform/ParsedClip.java
@@ -0,0 +1,11 @@
+package com.hlab.yanalyst.domain.shortform;
+
+/** Opal 출력에서 파싱한 구간 하나. capcutJson 등 원문 조각은 파싱 실패해도 보존한다. */
+public record ParsedClip(
+ int clipNo,
+ String capcutJson,
+ String titleCandidates,
+ String durationTable,
+ String titleTop,
+ String titleMain
+) {}
diff --git a/src/main/java/com/hlab/yanalyst/domain/shortform/ShortformOutputParser.java b/src/main/java/com/hlab/yanalyst/domain/shortform/ShortformOutputParser.java
new file mode 100644
index 0000000..49a75c4
--- /dev/null
+++ b/src/main/java/com/hlab/yanalyst/domain/shortform/ShortformOutputParser.java
@@ -0,0 +1,114 @@
+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)별로 파싱한다.
+ *
+ *
실전 출력 변형에 대응한다: 섹션 구분자 {@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 parse(String raw) {
+ List result = new ArrayList<>();
+ if (raw == null || raw.isBlank()) return result;
+
+ Matcher m = SECTION.matcher(raw);
+ List starts = new ArrayList<>();
+ List ends = new ArrayList<>();
+ List 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;
+ }
+}
diff --git a/src/test/java/com/hlab/yanalyst/domain/shortform/ShortformOutputParserTest.java b/src/test/java/com/hlab/yanalyst/domain/shortform/ShortformOutputParserTest.java
new file mode 100644
index 0000000..d1a1d3b
--- /dev/null
+++ b/src/test/java/com/hlab/yanalyst/domain/shortform/ShortformOutputParserTest.java
@@ -0,0 +1,61 @@
+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 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 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 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());
+ }
+}