From 0142a439d3b5ca4b53bc79d3ee2b85fabb86c33c Mon Sep 17 00:00:00 2001 From: "hehihoho3@gmail.com" Date: Mon, 3 Aug 2026 11:57:14 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20Opal=20=EC=88=8F=ED=8F=BC=20=EC=B6=9C?= =?UTF-8?q?=EB=A0=A5=20=ED=8C=8C=EC=84=9C=20=E2=80=94=20=EA=B5=AC=EA=B0=84?= =?UTF-8?q?=EB=B3=84=20JSON=C2=B7=ED=83=80=EC=9D=B4=ED=8B=80=C2=B7?= =?UTF-8?q?=EA=B2=80=EC=82=B0=ED=91=9C=20=EC=B6=94=EC=B6=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 실전 픽스처 기반: json 펜스 유무, 중괄호 균형 스캔, 문자열 내 이스케이프 처리. Co-Authored-By: Claude Fable 5 --- .../yanalyst/domain/shortform/ParsedClip.java | 11 ++ .../shortform/ShortformOutputParser.java | 114 ++++++++++++++++++ .../shortform/ShortformOutputParserTest.java | 61 ++++++++++ 3 files changed, 186 insertions(+) create mode 100644 src/main/java/com/hlab/yanalyst/domain/shortform/ParsedClip.java create mode 100644 src/main/java/com/hlab/yanalyst/domain/shortform/ShortformOutputParser.java create mode 100644 src/test/java/com/hlab/yanalyst/domain/shortform/ShortformOutputParserTest.java 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()); + } +}