feat: [contracts] 계약서 버전 관리 시스템 구축
- DOCX 4종 → Markdown 미러링 체계 구축 (Git diff 추적) - DOCX에 개정이력 테이블 삽입 (Pretendard 9pt, 파란 헤더) - 자동화 스크립트 3종 (추출/삽입/동기화 검증) - revisions.json, CHANGELOG.md, INDEX.md 업데이트 - .gitignore에 contracts 경로 allowlist 추가
This commit is contained in:
334
sam/docs/contracts/scripts/extract_to_markdown.py
Normal file
334
sam/docs/contracts/scripts/extract_to_markdown.py
Normal file
@@ -0,0 +1,334 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
DOCX → Markdown 추출 스크립트
|
||||
|
||||
4개 전자계약 DOCX 파일을 Markdown으로 변환한다.
|
||||
- 서비스이용계약서: Heading 스타일 기반 매핑
|
||||
- 나머지 3개: Bold 런 + 패턴 매칭으로 구조 유추
|
||||
"""
|
||||
|
||||
import re
|
||||
import sys
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
|
||||
from docx import Document
|
||||
|
||||
# 경로 설정
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
DOCX_DIR = BASE_DIR / "docx"
|
||||
MD_DIR = BASE_DIR / "markdown"
|
||||
|
||||
# DOCX → Markdown 매핑
|
||||
FILE_MAP = {
|
||||
"01_고객_서비스이용계약서_v4_0_전자서명용.docx": {
|
||||
"output": "01-service-agreement.md",
|
||||
"title": "고객사 서비스 이용계약서",
|
||||
"type": "styled",
|
||||
},
|
||||
"비밀유지서약서.docx": {
|
||||
"output": "02-nda.md",
|
||||
"title": "비밀유지서약서 (NDA)",
|
||||
"type": "pattern",
|
||||
},
|
||||
"영업파트너 위촉계약서.docx": {
|
||||
"output": "03-partner-agreement.md",
|
||||
"title": "영업파트너 위촉계약서",
|
||||
"type": "pattern",
|
||||
},
|
||||
"영업파트너 위촉계약서(단체용).docx": {
|
||||
"output": "04-partner-agreement-group.md",
|
||||
"title": "영업파트너 위촉계약서 (단체용)",
|
||||
"type": "pattern",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def table_to_markdown(table):
|
||||
"""DOCX 테이블을 Markdown 테이블로 변환"""
|
||||
rows = []
|
||||
for row in table.rows:
|
||||
cells = [cell.text.strip().replace("\n", " ") for cell in row.cells]
|
||||
rows.append(cells)
|
||||
|
||||
if not rows:
|
||||
return ""
|
||||
|
||||
lines = []
|
||||
# 헤더
|
||||
lines.append("| " + " | ".join(rows[0]) + " |")
|
||||
lines.append("| " + " | ".join(["---"] * len(rows[0])) + " |")
|
||||
# 본문
|
||||
for row in rows[1:]:
|
||||
# 셀 개수 맞추기
|
||||
while len(row) < len(rows[0]):
|
||||
row.append("")
|
||||
lines.append("| " + " | ".join(row[: len(rows[0])]) + " |")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def get_paragraph_heading_level_styled(para):
|
||||
"""스타일 기반 문서의 헤딩 레벨 판별 (서비스이용계약서)"""
|
||||
style = para.style.name if para.style else ""
|
||||
|
||||
if style == "Heading 1":
|
||||
return 1
|
||||
elif style == "Heading 2":
|
||||
return 2
|
||||
elif style == "Heading 3":
|
||||
return 3
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
def get_paragraph_heading_level_pattern(para):
|
||||
"""패턴 매칭 기반 문서의 헤딩 레벨 판별 (비밀유지서약서, 영업파트너 위촉계약서)"""
|
||||
text = para.text.strip()
|
||||
has_bold = any(r.bold for r in para.runs if r.bold)
|
||||
|
||||
if not text or not has_bold:
|
||||
return 0
|
||||
|
||||
# "제X조" 패턴 → ## (h2)
|
||||
if re.match(r"^<?[ ]*제\d+조", text):
|
||||
return 2
|
||||
|
||||
# "X.X " 패턴 (소제목) → ### (h3)
|
||||
if re.match(r"^\d+\.\d+\s", text):
|
||||
return 3
|
||||
|
||||
# 문서 제목 (첫 번째 bold 텍스트)
|
||||
if re.match(r"^<?\s*(영업파트너|비밀유지서약서|Sales Partner)", text):
|
||||
return 1
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
def is_list_item(para, doc_type):
|
||||
"""리스트 아이템인지 판별"""
|
||||
text = para.text.strip()
|
||||
if not text:
|
||||
return False
|
||||
|
||||
if doc_type == "styled":
|
||||
style = para.style.name if para.style else ""
|
||||
return style == "Compact"
|
||||
|
||||
# pattern 기반: bold가 아닌 일반 텍스트이면서 제X조나 X.X 패턴이 아닌 것
|
||||
has_bold = any(r.bold for r in para.runs if r.bold)
|
||||
if not has_bold and not re.match(r"^(제\d+조|<?|계약 당사자|\[)", text):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def extract_styled_doc(doc, file_info):
|
||||
"""스타일 기반 문서 추출 (서비스이용계약서)"""
|
||||
lines = []
|
||||
table_positions = {}
|
||||
|
||||
# 테이블 위치 매핑: 문단 인덱스 기준으로 테이블이 어디에 삽입되는지 추적
|
||||
body = doc.element.body
|
||||
table_idx = 0
|
||||
para_idx = 0
|
||||
for child in body:
|
||||
tag = child.tag.split("}")[-1] if "}" in child.tag else child.tag
|
||||
if tag == "p":
|
||||
para_idx += 1
|
||||
elif tag == "tbl":
|
||||
table_positions[para_idx] = table_idx
|
||||
table_idx += 1
|
||||
|
||||
para_idx = 0
|
||||
for child in body:
|
||||
tag = child.tag.split("}")[-1] if "}" in child.tag else child.tag
|
||||
|
||||
if tag == "p":
|
||||
para = doc.paragraphs[para_idx]
|
||||
para_idx += 1
|
||||
text = para.text.strip()
|
||||
|
||||
if not text:
|
||||
lines.append("")
|
||||
continue
|
||||
|
||||
style = para.style.name if para.style else ""
|
||||
level = get_paragraph_heading_level_styled(para)
|
||||
|
||||
if level > 0:
|
||||
lines.append("")
|
||||
lines.append(f"{'#' * level} {text}")
|
||||
lines.append("")
|
||||
elif style == "Compact":
|
||||
# Bold 런이 있으면 강조 리스트
|
||||
has_bold = any(r.bold for r in para.runs if r.bold)
|
||||
if has_bold:
|
||||
# Bold 부분과 일반 부분 분리
|
||||
parts = []
|
||||
for run in para.runs:
|
||||
if run.bold:
|
||||
parts.append(f"**{run.text}**")
|
||||
else:
|
||||
parts.append(run.text)
|
||||
combined = "".join(parts)
|
||||
lines.append(f"- {combined}")
|
||||
else:
|
||||
# 들여쓰기된 하위 항목
|
||||
lines.append(f" - {text}")
|
||||
elif style in ("Body Text", "First Paragraph"):
|
||||
# 본문 텍스트
|
||||
if text.startswith("⚠️") or text.startswith("✅") or text.startswith("❌"):
|
||||
lines.append("")
|
||||
lines.append(f"> {text}")
|
||||
lines.append("")
|
||||
else:
|
||||
lines.append(text)
|
||||
else:
|
||||
lines.append(text)
|
||||
|
||||
elif tag == "tbl":
|
||||
if table_idx <= len(doc.tables):
|
||||
current_table_idx = sum(
|
||||
1
|
||||
for c in list(body)[: list(body).index(child)]
|
||||
if (c.tag.split("}")[-1] if "}" in c.tag else c.tag) == "tbl"
|
||||
)
|
||||
if current_table_idx < len(doc.tables):
|
||||
lines.append("")
|
||||
lines.append(table_to_markdown(doc.tables[current_table_idx]))
|
||||
lines.append("")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def extract_pattern_doc(doc, file_info):
|
||||
"""패턴 매칭 기반 문서 추출 (비밀유지서약서, 영업파트너 위촉계약서)"""
|
||||
lines = []
|
||||
|
||||
body = doc.element.body
|
||||
para_idx = 0
|
||||
|
||||
for child in body:
|
||||
tag = child.tag.split("}")[-1] if "}" in child.tag else child.tag
|
||||
|
||||
if tag == "p":
|
||||
para = doc.paragraphs[para_idx]
|
||||
para_idx += 1
|
||||
text = para.text.strip()
|
||||
|
||||
if not text:
|
||||
lines.append("")
|
||||
continue
|
||||
|
||||
level = get_paragraph_heading_level_pattern(para)
|
||||
has_bold = any(r.bold for r in para.runs if r.bold)
|
||||
|
||||
if level > 0:
|
||||
lines.append("")
|
||||
# 제목에서 < > 제거
|
||||
clean_text = re.sub(r"^<\s*|\s*>$", "", text).strip()
|
||||
lines.append(f"{'#' * level} {clean_text}")
|
||||
lines.append("")
|
||||
elif has_bold:
|
||||
# Bold 텍스트는 강조 처리
|
||||
parts = []
|
||||
for run in para.runs:
|
||||
if run.bold:
|
||||
parts.append(f"**{run.text}**")
|
||||
else:
|
||||
parts.append(run.text)
|
||||
combined = "".join(parts)
|
||||
|
||||
# (1), (2) 같은 번호 패턴
|
||||
if re.match(r"^\*\*\(\d+\)", combined):
|
||||
lines.append(f"- {combined}")
|
||||
# "예시 N:", "Phase N:" 같은 패턴
|
||||
elif re.match(r"^\*\*(예시|Phase|별첨)\s", combined):
|
||||
lines.append("")
|
||||
lines.append(f"#### {text}")
|
||||
lines.append("")
|
||||
else:
|
||||
lines.append(f"- {combined}")
|
||||
else:
|
||||
# 일반 텍스트
|
||||
# 빈칸 양식 (___) 유지
|
||||
if "___" in text:
|
||||
lines.append(text)
|
||||
elif re.match(r"^(이메일|전화|주소|상호|대표|사업자|주민|연락처|날짜):", text):
|
||||
lines.append(f"- {text}")
|
||||
else:
|
||||
lines.append(f" - {text}")
|
||||
|
||||
elif tag == "tbl":
|
||||
current_table_idx = sum(
|
||||
1
|
||||
for c in list(body)[: list(body).index(child)]
|
||||
if (c.tag.split("}")[-1] if "}" in c.tag else c.tag) == "tbl"
|
||||
)
|
||||
if current_table_idx < len(doc.tables):
|
||||
lines.append("")
|
||||
lines.append(table_to_markdown(doc.tables[current_table_idx]))
|
||||
lines.append("")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def add_frontmatter(content, file_info, docx_name):
|
||||
"""YAML 프론트매터 추가"""
|
||||
frontmatter = f"""---
|
||||
title: "{file_info['title']}"
|
||||
version: "v4.0"
|
||||
date: "{date.today().isoformat()}"
|
||||
docx_file: "{docx_name}"
|
||||
---
|
||||
"""
|
||||
return frontmatter + "\n" + content
|
||||
|
||||
|
||||
def extract_file(docx_name, file_info):
|
||||
"""단일 DOCX 파일 추출"""
|
||||
docx_path = DOCX_DIR / docx_name
|
||||
if not docx_path.exists():
|
||||
print(f" [SKIP] {docx_name} - 파일 없음")
|
||||
return False
|
||||
|
||||
doc = Document(str(docx_path))
|
||||
|
||||
if file_info["type"] == "styled":
|
||||
content = extract_styled_doc(doc, file_info)
|
||||
else:
|
||||
content = extract_pattern_doc(doc, file_info)
|
||||
|
||||
# 프론트매터 추가
|
||||
content = add_frontmatter(content, file_info, docx_name)
|
||||
|
||||
# 연속 빈 줄 정리 (3줄 이상 → 2줄로)
|
||||
content = re.sub(r"\n{3,}", "\n\n", content)
|
||||
|
||||
# 파일 저장
|
||||
output_path = MD_DIR / file_info["output"]
|
||||
output_path.write_text(content, encoding="utf-8")
|
||||
print(f" [OK] {docx_name} → {file_info['output']}")
|
||||
return True
|
||||
|
||||
|
||||
def main():
|
||||
print("DOCX → Markdown 추출 시작")
|
||||
print(f" DOCX 디렉토리: {DOCX_DIR}")
|
||||
print(f" 출력 디렉토리: {MD_DIR}")
|
||||
print()
|
||||
|
||||
MD_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
success = 0
|
||||
for docx_name, file_info in FILE_MAP.items():
|
||||
if extract_file(docx_name, file_info):
|
||||
success += 1
|
||||
|
||||
print(f"\n완료: {success}/{len(FILE_MAP)} 파일 변환됨")
|
||||
return 0 if success == len(FILE_MAP) else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user