feat: Opal 숏폼 출력 파서 — 구간별 JSON·타이틀·검산표 추출

실전 픽스처 기반: json 펜스 유무, 중괄호 균형 스캔, 문자열 내 이스케이프 처리.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
hehihoho3@gmail.com 2026-08-03 11:57:14 +09:00
parent ca9c6464fc
commit 0142a439d3
3 changed files with 186 additions and 0 deletions

View File

@ -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
) {}

View File

@ -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)별로 파싱한다.
*
* <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;
}
}

View File

@ -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<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());
}
}