Confluence의 특정 상위 페이지(Parent Page)를 기준으로 하위 트리 구조를 로컬 디렉토리 계층 구조로 그대로 재현하고, 본문(Storage Format/XHTML)을 Markdown(.md)으로 변환하여 저장하는 완전 자동화 Python 스크립트입니다.
Confluence API 통신과 XHTML Markdown 변환을 위한 라이브러리를 설치합니다.
pip install requests markdownify python-slugify
이메일 + API 토큰 (Atlassian 계정 설정에서 발급)Personal Access Token (Bearer 토큰) 또는 사용자ID + 비밀번호export_confluence_tree.py)페이지 간 부모-자식 관계를 재귀(Recursive)적으로 탐색하여 하위 디렉토리를 생성하고, 페이지 제목을 파일명/폴더명으로 매핑합니다.
#!/usr/bin/env python3
import os
import sys
import re
import requests
from markdownify import markdownify as md
# ----------------- [1. 접속 설정] -----------------
# Cloud 예시: "https://your-domain.atlassian.net/wiki"
# On-Premise 예시: "https://confluence.internal.company.com"
CONFLUENCE_BASE_URL = os.environ.get("CONFLUENCE_URL", "https://your-confluence-domain.com")
# 인증 방식 선택 (Cloud는 BASIC_AUTH, On-Premise는 BEARER_TOKEN 주로 사용)
AUTH_TYPE = os.environ.get("AUTH_TYPE", "BEARER") # "BASIC" or "BEARER"
USERNAME = os.environ.get("CONFLUENCE_USER", "") # Cloud는 이메일, On-Premise는 사번/ID
API_TOKEN = os.environ.get("CONFLUENCE_TOKEN", "") # API Token 또는 PAT
# ----------------- [2. 헬퍼 함수] -----------------
def get_headers():
if AUTH_TYPE == "BEARER":
return {
"Authorization": f"Bearer {API_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json"
}
else:
# Basic Auth
import base64
cred = base64.b64encode(f"{USERNAME}:{API_TOKEN}".encode()).decode()
return {
"Authorization": f"Basic {cred}",
"Content-Type": "application/json",
"Accept": "application/json"
}
def sanitize_filename(name: str) -> str:
"""파일명/디렉토리명으로 사용할 수 없는 특수문자 제거"""
return re.sub(r'[\\/*?:"<>|]', "_", name).strip()
def get_page_details(page_id: str):
"""페이지 본문(storage format)과 메타데이터 조회"""
url = f"{CONFLUENCE_BASE_URL}/rest/api/content/{page_id}?expand=body.storage,version"
resp = requests.get(url, headers=get_headers())
if resp.status_code != 200:
raise Exception(f"Failed to fetch page {page_id}: {resp.text}")
return resp.json()
def get_child_pages(page_id: str):
"""특정 페이지 바로 아래의 자식 페이지 목록 페이징 조회"""
children = []
start = 0
limit = 50
while True:
url = f"{CONFLUENCE_BASE_URL}/rest/api/content/{page_id}/child/page?start={start}&limit={limit}"
resp = requests.get(url, headers=get_headers())
if resp.status_code != 200:
print(f"[Warning] Failed to fetch children for {page_id}: {resp.status_code}")
break
data = resp.json()
results = data.get("results", [])
children.extend(results)
if len(results) < limit:
break
start += limit
return children
# ----------------- [3. 재귀 내보내기 엔진] -----------------
def export_tree(page_id: str, current_dir: str):
"""
특정 page_id를 가져와 current_dir에 Markdown으로 저장하고,
하위 자식이 있다면 하위 디렉토리를 생성하여 재귀 호출
"""
page_data = get_page_details(page_id)
title = page_data.get("title", "Untitled")
safe_title = sanitize_filename(title)
body_html = page_data.get("body", {}).get("storage", {}).get("value", "")
# Confluence XHTML -> Markdown 변환
# 표(table), 코드 블록(pre/code), 볼드/이탤릭 등을 표준 마크다운으로 변환
markdown_content = md(
body_html,
heading_style="ATX",
strip=['script', 'style']
)
# 상단 FrontMatter 메타데이터 추가 (RAG 검색 및 Git 관리 용이)
header = f"""---
title: "{title}"
confluence_id: "{page_id}"
confluence_url: "{CONFLUENCE_BASE_URL}/pages/viewpage.action?pageId={page_id}"
exported_at: "$(date +%Y-%m-%d)"
---
# {title}
"""
full_markdown = header + markdown_content
# 하위 자식 페이지 조회
children = get_child_pages(page_id)
if children:
# 하위 페이지가 있는 경우: 제목으로 폴더를 만들고, 해당 폴더 안에 index.md 또는 README.md로 본문 저장
page_folder = os.path.join(current_dir, safe_title)
os.makedirs(page_folder, exist_ok=True)
md_file_path = os.path.join(page_folder, "README.md")
with open(md_file_path, "w", encoding="utf-8") as f:
f.write(full_markdown)
print(f"[Exported Tree Node] {safe_title}/README.md (하위 {len(children)}개)")
# 하위 자식들 재귀 처리
for child in children:
export_tree(child["id"], page_folder)
else:
# 말단(Leaf) 페이지인 경우: 바로 파일로 생성
md_file_path = os.path.join(current_dir, f"{safe_title}.md")
with open(md_file_path, "w", encoding="utf-8") as f:
f.write(full_markdown)
print(f"[Exported Leaf] {safe_title}.md")
# ----------------- [4. 실행부] -----------------
if __name__ == "__main__":
if len(sys.argv) < 2:
print("Usage: python export_confluence_tree.py <ROOT_PAGE_ID> [TARGET_DIR]")
print("Example: python export_confluence_tree.py 123456789 ./docs")
sys.exit(1)
root_page_id = sys.argv[1]
output_dir = sys.argv[2] if len(sys.argv) > 2 else "./confluence_exports"
os.makedirs(output_dir, exist_ok=True)
print(f"=== Confluence 계층 내보내기 시작 (Root ID: {root_page_id}) ===")
export_tree(root_page_id, output_dir)
print(f"=== 내보내기 완료: {output_dir} ===")
위 스크립트를 실행하면 Confluence 페이지 트리가 다음과 같은 Git 친화적 디렉토리 구조로 생성됩니다.
./docs/
├── 인프라_운영_매뉴얼/ <-- 자식이 있는 상위 페이지 (폴더 생성)
│ ├── README.md <-- 상위 페이지 본문
│ ├── K8s_트러블슈팅/ <-- 자식이 있는 중간 페이지
│ │ ├── README.md
│ │ ├── OOMKilled_대응_가이드.md <-- 자식이 없는 리프 페이지 (단일 md 파일)
│ │ └── CPU_Throttling_해결.md
│ └── StarRocks_운영/
│ ├── README.md
│ └── BE_Crash_원인_분석.md
환경 변수를 지정하고 스크립트를 실행합니다. Root Page ID는 Confluence 페이지 URL(예: ...pageId=987654321)에서 확인할 수 있습니다.
export CONFLUENCE_URL="https://wiki.your-company.com"
export AUTH_TYPE="BEARER" # On-Premise PAT 사용 시
export CONFLUENCE_TOKEN="your-pat-token"
# 실행: python export_confluence_tree.py <최상위_PAGE_ID> <저장_경로>
python export_confluence_tree.py 987654321 ./docs
정기적으로 Confluence 변경 사항을 Git 저장소에 커밋하는 배포 스크립트 예시입니다.
#!/usr/bin/env bash
set -e
REPO_DIR="/path/to/git-runbooks-repo"
cd "$REPO_DIR"
# 1. Confluence Export 실행
python3 /path/to/export_confluence_tree.py 987654321 ./runbooks
# 2. Git 변경 감지 및 Push
git add runbooks/
if git diff --staged --quiet; then
echo "변경된 문서가 없습니다."
else
git commit -m "docs: sync confluence runbooks tree ($(date +'%Y-%m-%d %H:%M'))"
git push origin main
echo "Git 동기화 완료."
fi
markdownify 라이브러리는 기본 테이블, <code>, <pre>를 깔끔하게 파싱합니다. Confluence 특유의 매크로 태그(<ac:structured-macro>)가 복잡하게 얽혀 있다면 정규식으로 앞뒤 래퍼를 걷어내는 전처리 코드를 body_html에 추가하여 더 정밀하게 다듬을 수 있습니다.export_tree 내부에서 /rest/api/content/{id}/child/attachment API를 호출해 해당 폴더의 images/ 하위 경로로 다운로드하도록 확장할 수 있습니다.==
Confluence에서 변환된 Markdown 문서들의 내용을 로컬 vLLM(GPU 노드)으로 분석하여, RAG 검색 적중률과 벡터 유사도 매칭을 극대화할 수 있도록 문서 상단에 YAML Frontmatter(핵심 요약, 장애 키워드, 대상 시스템, 조치 카테고리 등)를 자동으로 추출·삽입하는 배치 스크립트입니다.
pip install openai python-frontmatter pyyaml
enrich_markdown_metadata.py)기존 마크다운 파일의 내용을 읽고, vLLM에 JSON Schema 기반 구조화 출력을 요청한 뒤, 파일 상단의 YAML Frontmatter를 갱신합니다.
#!/usr/bin/env python3
import os
import sys
import glob
import json
import frontmatter
from openai import OpenAI
# 1. vLLM 로컬 엔드포인트 설정
client = OpenAI(
base_url="http://localhost:8000/v1",
api_key="EMPTY"
)
MODEL_NAME = "Qwen/Qwen2.5-7B-Instruct"
SYSTEM_PROMPT = """당신은 SRE/인프라 문서 지식 베이스를 관리하는 엔지니어입니다.
주어진 Markdown 문서를 정밀하게 분석하여 Vector DB 검색 및 Agent RAG에 최적화된 메타데이터를 반드시 지정된 JSON 포맷으로만 출력하세요.
JSON 출력 규격:
{
"summary": "문서의 핵심 목적과 내용을 기술적으로 1-2문장으로 압축 요약",
"target_systems": ["관련 시스템 명칭 (예: StarRocks, Kubernetes, PostgreSQL, Cilium, MinIO 등)"],
"category": "Troubleshooting | Architecture | Operation | Benchmark 중 택1",
"error_keywords": ["문서에서 다루는 에러 코드, 로그 패턴, 핵심 시그널 (예: OOMKilled, Throttling, ExitCode 137, Timeout)"],
"recommended_actions": ["문서에서 제시하는 핵심 해결 방법 키워드 (예: Quota 조정, 파라미터 튜닝, 재기동)"]
}
"""
def extract_metadata_with_vllm(content_text: str) -> dict:
"""vLLM을 호출하여 본문에서 구조화된 메타데이터 추출"""
# 문서가 너무 길 경우 토큰 절약을 위해 상위 4,000자(약 1,000~1,500 토큰) 발췌
truncated_content = content_text[:4000]
user_prompt = f"""다음 Markdown 문서를 읽고 지침에 따라 JSON 메타데이터를 추출하세요:
```markdown
{truncated_content}
"""
try:
response = client.chat.completions.create(
model=MODEL_NAME,
messages=[
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": user_prompt}
],
temperature=0.1,
max_tokens=500,
response_format={"type": "json_object"} # JSON 모드 강제
)
result_text = response.choices[0].message.content
return json.loads(result_text)
except Exception as e:
print(f" [Error] vLLM 호출/파싱 실패: {e}")
return {}
def process_markdown_file(file_path: str, overwrite: bool = False):
"""단일 마크다운 파일에 메타데이터 Frontmatter 주입"""
with open(file_path, "r", encoding="utf-8") as f:
post = frontmatter.load(f)
# 이미 메타데이터가 보강된 파일 스킵 처리 (중복 실행 방지)
if "auto_enriched" in post.metadata and not overwrite:
print(f"[Skip] 이미 처리됨: {file_path}")
return
print(f"[Processing] {file_path} ...")
# 순수 텍스트 본문 추출
body_text = post.content.strip()
if len(body_text) < 50:
print(f" [Skip] 본문 내용이 너무 짧음 ({len(body_text)}자)")
return
# vLLM 메타데이터 추출
extracted_meta = extract_metadata_with_vllm(body_text)
if not extracted_meta:
return
# 기존 Frontmatter 유지하면서 신규 메타데이터 병합
post.metadata["summary"] = extracted_meta.get("summary", "")
post.metadata["target_systems"] = extracted_meta.get("target_systems", [])
post.metadata["category"] = extracted_meta.get("category", "Operation")
post.metadata["error_keywords"] = extracted_meta.get("error_keywords", [])
post.metadata["recommended_actions"] = extracted_meta.get("recommended_actions", [])
post.metadata["auto_enriched"] = True
# 파일 덮어쓰기 저장
with open(file_path, "w", encoding="utf-8") as f:
f.write(frontmatter.dumps(post))
print(f" [Done] 메타데이터 갱신 완료")
def main():
if len(sys.argv) < 2:
print("Usage: python enrich_markdown_metadata.py <DOCS_DIR> [--overwrite]")
print("Example: python enrich_markdown_metadata.py ./runbooks")
sys.exit(1)
target_dir = sys.argv[1]
overwrite = "--overwrite" in sys.argv
# 하위 모든 md 파일 탐색
md_files = glob.glob(os.path.join(target_dir, "**/*.md"), recursive=True)
print(f"총 {len(md_files)}개의 Markdown 문서를 발견했습니다. 보강 작업을 시작합니다.")
for idx, md_file in enumerate(md_files, 1):
print(f"\n({idx}/{len(md_files)})", end=" ")
process_markdown_file(md_file, overwrite=overwrite)
print("\n=== 모든 마크다운 문서 메타데이터 보강 완료 ===")
if name == "main":
main()
---
### 3. 변환 전/후 결과 예시
#### 변환 전 (`docs/starrocks/be_oom_guide.md`)
```markdown
---
title: "StarRocks BE OOM 발생 시 조치 가이드"
confluence_id: "12345678"
---
# StarRocks BE OOM 발생 시 조치 가이드
StarRocks BE 노드가 OOMKilled(137)로 비정상 종료되는 현상이 잦을 경우...
---
auto_enriched: true
category: Troubleshooting
confluence_id: '12345678'
error_keywords:
- OOMKilled
- ExitCode 137
- QueryEngine Memory Allocation Failed
- cgroup memory limit
recommended_actions:
- query_mem_limit 하향 조정
- BE cgroup memory limit 확장
- 비정상 쿼리 세션 KILL
summary: StarRocks BE 노드가 쿼리 메모리 초과로 cgroup OOMKilled(137) 종료될 때 쿼리 제한 설정
및 파드 메모리 Quota를 조정하는 트러블슈팅 절차를 설명합니다.
target_systems:
- StarRocks
- Kubernetes
title: StarRocks BE OOM 발생 시 조치 가이드
---
# StarRocks BE OOM 발생 시 조치 가이드
StarRocks BE 노드가 OOMKilled(137)로 비정상 종료되는 현상이 잦을 경우...
target_systems: ["StarRocks"] 및 category: "Troubleshooting" 조건으로 사전 필터링(Pre-filtering)한 후 임베딩 검색을 돌릴 수 있어 무관한 인프라 문서가 걸려오는 오탐(False Positive)을 차단합니다.summary와 error_keywords를 청크 텍스트 앞에 포함시켜 임베딩하면, 에이전트가 던지는 장애 쿼리(StarRocks BE OOM 137)와의 시맨틱 유사도가 급격히 향상됩니다.