feat: Opal 숏폼 출력 파서 — 구간별 JSON·타이틀·검산표 추출
실전 픽스처 기반: json 펜스 유무, 중괄호 균형 스캔, 문자열 내 이스케이프 처리. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
ca9c6464fc
commit
0142a439d3
@ -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
|
||||||
|
) {}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user