개인의 기록
  • 소개
  • 프로젝트
  • 글
  • 링크

© 2026 newgirok

← YouTube Shorts 자동화

스크립트 품질 — 종결어 중복 방지와 TTS 롤백

2026년 7월 26일
script-workerschedulertts-worker품질

이 날은 유독 왔다갔다가 많았다.

script-worker — 연속 종결어 중복 방지

기승전결 4박자 구조에서 [기]와 [전]이 둘 다 "~다고 함" 계열로 끝나거나, [승]과 [전]이 둘 다 "~라고" 계열로 끝나는 경우가 있었다. 같은 계열이 바로 옆에 붙으면 문장이 단조로워진다. parseOutput에 계열 분류기를 추가했다.

// A 계열: ~[다라]고 함  (있다고 함 / 했다고 함 / 이라고 함 등)
// B 계열: ~라고          (상황이라고 / 이라고 — 단 '하는데'·'함'이 뒤에 없는 직접 종결)
const classify = (s: string): 'A' | 'B' | 'other' => {
  if (/[다라]고 함$/.test(s)) return 'A';
  if (/라고$/.test(s)) return 'B';
  return 'other';
};

for (let i = 0; i < sentences.length - 1; i++) {
  const curr = classify(sentences[i]);
  const next = classify(sentences[i + 1]);
  if (curr !== 'other' && curr === next) {
    throw new Error('SCRIPT_CONSECUTIVE_ENDING');
  }
}

isRetryable에도 추가해서 위반하면 자동 재시도한다.

script-worker — 관형절 어미 뒤 쉼표

"~다는, 상황이라고" 같은 문장이 나왔다. "~다는" 뒤에 쉼표를 찍으면 한국어로 어색하다. 정규식으로 걸렀다.

if (parsed.script && /(다는|이라는|있다는|없다는|한다는|됐다는),/.test(parsed.script)) {
  throw new Error('SCRIPT_AWKWARD_COMMA');
}

scheduler — RSS 정치 키워드 필터

카테고리에서 politics를 없앤 지 한 달이 넘었는데, 다른 카테고리(top 등) 뉴스에도 정당명이나 정치인 이름이 섞여 들어오는 경우가 있었다. RSS 후보를 20개까지 넉넉히 받아서 정치 키워드로 거른 다음 필요한 개수만 슬라이스하도록 바꿨다.

const BLOCK_KEYWORDS = [
  '민주당', '국민의힘', '정의당', '개혁신당', '조국혁신당',
  '이재명', '한동훈', '윤석열',
  '여당', '야당',
  '총선', '대선', '지방선거',
  '당대표', '원내대표', '국회의원',
  '탄핵', '특검',
];

export async function fetchNewsTopics(category: NewsCategory, count: number): Promise<NewsItem[]> {
  const xml = await fetchUrl(CATEGORY_URLS[category]);
  const candidates = parseRssItems(xml, CANDIDATES_PER_FETCH);
  return candidates.filter((item) => !isPolitical(item.title)).slice(0, count);
}

tts-worker — 보이스 교체, 롤백, 재적용

여기부터가 오늘의 하이라이트다.

13:47에 ko-KR-SunHiNeural(여성)에서 ko-KR-InJoonNeural(남성)로 바꿔서 배포했다.

await tts.setMetadata('ko-KR-InJoonNeural', OUTPUT_FORMAT.AUDIO_24KHZ_48KBITRATE_MONO_MP3);

근데 msedge-tts 라이브러리가 이 보이스를 제대로 지원하는지 실제로 확인을 안 한 채로 올렸다는 걸 뒤늦게 깨달았다. Edge TTS 호환성이 검증 안 된 상태로 프로덕션에 나가 있는 게 찜찜해서, 17분 만에 그냥 SunHiNeural로 되돌렸다.

-'ko-KR-InJoonNeural',
+'ko-KR-SunHiNeural',

그 사이에 dlq-notifier와 CI Slack 알림 작업을 잠깐 하고, 확인이 끝나자 다시 InJoonNeural로 돌려놨다. 결국 최종 상태는 처음 교체하려던 것과 같다. 급하게 올렸다가 찜찜해서 되돌리고, 확인 후 재적용하는 흐름이었다.

같은 타이밍에 dlq-notifier가 비정상 JSON 메시지를 받았을 때 jobId/channelId를 못 뽑아내던 것도 고쳤다. JSON.parse가 실패하면 정규식으로라도 건져내게 했다.

} catch {
  jobId = record.body.match(/jobId[:"' ]*([^,}\s'"]+)/)?.[1] ?? '알 수 없음';
  channelId = record.body.match(/channelId[:"' ]*([^,}\s'"]+)/)?.[1] ?? '알 수 없음';
}

그리고 배포 완료·실패를 Slack으로 알려주는 것도 이날 처음 붙였다. deploy-api, deploy-web, deploy-workers 세 워크플로우 전부에.

- name: Slack 배포 결과 알림
  if: always()
  run: |
    if [ "$STATUS" = "success" ]; then ICON="✅"; else ICON="❌"; fi
    curl -s -X POST "${{ secrets.SLACK_WEBHOOK_URL }}" \
      -H "Content-Type: application/json" \
      -d "{\"text\":\"$ICON *Deploy API* \`$STATUS\`\n• Ref: \`$REF\` @ \`${SHA::7}\`\"}"

이게 다음 날부터 Slack 알림을 계속 다듬게 되는 시작점이 됐다.

← 이전 글성능 개선 — SSR prefetch와 썸네일 단순화
다음 글 →CI 알림 — Slack Block Kit 도입과 path filter 정리