활용 팁 / 블로그 운영 / Python · 자동화
약 2,300자
블로그를 200편 넘게 운영하다 보면 사람이 직접 글을 검수해도 반드시 빠뜨리는 게 생깁니다. 마크다운 잔재(굵게가 그대로 노출), 이모지 화이트리스트 위반, 출처 누락, 빈 표, 박스 스타일 잔재 같은 것들이죠. 그래서 우리는 글이 블로그 API 로 넘어가기 직전에 자동으로 점검하고 고치는 단계를 따로 만들었습니다.
이 글은 그 자동 QC 시스템을 어떤 의도로 만들었고, 어떻게 작동하고, 실제 어떤 효과를 봤고, 어떻게 검증했는지까지 풀어 둡니다. 같은 고민을 하는 운영자가 코드 한 페이지로 따라 만들 수 있도록 핵심만 추렸습니다.
만든 이유
처음 1년 동안 우리는 두 종류의 사고를 자주 봤습니다.
첫째는 모델 출력 잔재. LLM 으로 본문을 뽑으면 굵게, ## 소제목, --- 같은 마크다운 토큰이 종종 HTML 으로 변환 안 된 채 남았습니다. 라이브에서 별표가 그대로 보였습니다.
둘째는 작성 직후엔 멀쩡한데 발행 직전 어딘가의 hook 이 망가뜨리는 케이스. 어떤 함수가 본문에 검문소는 두 단계로 구성합니다. 1단계 sanitize — 무조건 고치는 작업 HTML 을 받아서 다음을 일률 적용합니다. 이 단계는 사람의 판단이 필요 없는 기계적 작업입니다. 어떤 글이든 같은 결과가 나오게 만들었습니다. 2단계 quality gate — 통과 실패 시 발행 차단 사람이 보면 알아챘을 누락을 자동으로 점검합니다. 통과 못 하면 발행 자체가 거부됩니다. 도입 후 6개월간의 결과: 차단된 38편은 글 손실이 아닙니다. 단지 작성자가 미리 인지하고 다듬은 후 다시 시도해서 모두 정상 발행됐습니다. 차단된 사유 분포는 출처 누락 41% / 글자 부족 26% / 이미지 0장 21% / 기타 12% 였습니다. 검문소를 만들고 나서 우리가 한 검증입니다. 골든 셋 회귀 테스트 — 과거에 사고가 났던 글 41편의 원본을 모아 골든 셋으로 만들었습니다. sanitize + quality gate 를 통과시켰을 때 41편 모두 사고 패턴이 사라지는지 자동 비교했습니다. 39/41 통과. 실패한 2편을 보고 정규식을 보강해 41/41 통과까지 끌어올렸습니다. 라이브 spot-check — 새 sanitize 를 적용한 첫 주에 발행된 18편 중 8편을 무작위로 골라 라이브 페이지를 직접 fetch 했습니다. desktop 1280px, mobile 360px 두 너비에서 가로 스크롤이 발생하는지, 글자가 컨테이너 밖으로 나가는지, 이미지가 깨지는지 점검했습니다. 8/8 정상. 더블 패스 idempotency — sanitize 를 한 번 통과한 결과물에 sanitize 를 한 번 더 통과시켰을 때 출력이 동일한지 확인했습니다. 이건 publish hook chain 이 두 번 도는 케이스를 막기 위한 검증입니다. 100/100 동일. 전체 코드를 베끼는 것보다 핵심 한두 가지만 본인 환경에 맞게 가져가시면 됩니다. 이 두 함수를 발행 직전 단 한 군데에서 호출하면 됩니다. quality_gate 가 fail 을 반환하면 발행을 막고 사유를 사람에게 돌려주세요. sanitize 는 출력 HTML 을 받아서 그대로 발행 API 에 넘기면 됩니다. 요약하면, "발행 전 검문소 한 곳에서 모든 사고를 자동으로 막는다" 한 줄입니다. 사람이 매번 검수하는 데 쓰던 시간이 통째로 사라집니다. Category Coverage Notice This article follows our label-specific editorial criteria. Details:
작동 원리
width:800px, margin-left:-30px, position:absolute 등) 태그의 고정 width/height 속성 제거 → 반응형 보존X → X, 임의 --- → )border, box-shadow, padding>20px 의 max-width:100%, overflow-wrap:anywhere)
가 3개 미만 → fail (가이드/비교 글)
실제 효과
검증 방법
따라 만드는 법
import re
def sanitize_pre_publish(html: str) -> tuple[str, list[str]]:
fixes = []
# 위험 inline width 제거
html, n = re.subn(r'width\s*:\s*(?:[4-9]\d{2}|[1-9]\d{3,})px\s*;?', '', html)
if n: fixes.append('strip_wide_width')
# 마크다운 잔재 → HTML
html, n = re.subn(r'\*\*(.+?)\*\*', r'<strong>\1</strong>', html)
if n: fixes.append('md_bold')
# 이모지 strip (필요시)
html, n = re.subn(r'[\U0001F300-\U0001FAFF]', '', html)
if n: fixes.append('strip_emoji')
return html, fixes
def quality_gate(html: str, post_type: str) -> tuple[bool, list[str]]:
fails = []
text = re.sub(r'<[^>]+>', '', html)
if len(text.replace(' ', '')) < 600: fails.append('too_short')
if html.count('<h2') < 3 and post_type in ('howto', 'compare'): fails.append('few_h2')
if '<img' not in html: fails.append('no_image')
if 'TODO' in html or 'REDACTED' in html: fails.append('placeholder')
return (len(fails) == 0), fails