26S14y

QK·2026년 9월 13일

Confluence의 특정 상위 페이지(Parent Page)를 기준으로 하위 트리 구조를 로컬 디렉토리 계층 구조로 그대로 재현하고, 본문(Storage Format/XHTML)을 Markdown(.md)으로 변환하여 저장하는 완전 자동화 Python 스크립트입니다.


1. 사전 준비 및 패키지 설치

Confluence API 통신과 XHTML →\rightarrow Markdown 변환을 위한 라이브러리를 설치합니다.

pip install requests markdownify python-slugify
  • API Token / Personal Access Token(PAT) 준비:
  • Confluence Cloud: 이메일 + API 토큰 (Atlassian 계정 설정에서 발급)
  • Confluence Server / Data Center (On-Premise): Personal Access Token (Bearer 토큰) 또는 사용자ID + 비밀번호

2. Confluence to Markdown 변환 스크립트 (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} ===")

3. 디렉토리 구조 생성 방식

위 스크립트를 실행하면 Confluence 페이지 트리가 다음과 같은 Git 친화적 디렉토리 구조로 생성됩니다.

./docs/
├── 인프라_운영_매뉴얼/               <-- 자식이 있는 상위 페이지 (폴더 생성)
│   ├── README.md                  <-- 상위 페이지 본문
│   ├── K8s_트러블슈팅/             <-- 자식이 있는 중간 페이지
│   │   ├── README.md
│   │   ├── OOMKilled_대응_가이드.md <-- 자식이 없는 리프 페이지 (단일 md 파일)
│   │   └── CPU_Throttling_해결.md
│   └── StarRocks_운영/
│       ├── README.md
│       └── BE_Crash_원인_분석.md

4. 실행 및 Git 자동 Push 연동

실행 방법

환경 변수를 지정하고 스크립트를 실행합니다. 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

Git 커밋 및 동기화 자동화 (Shell 스크립트)

정기적으로 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

5. 운영 팁

  • Confluence 매크로(코드 블록, 인포 박스):
    markdownify 라이브러리는 기본 테이블, <code>, <pre>를 깔끔하게 파싱합니다. Confluence 특유의 매크로 태그(<ac:structured-macro>)가 복잡하게 얽혀 있다면 정규식으로 앞뒤 래퍼를 걷어내는 전처리 코드를 body_html에 추가하여 더 정밀하게 다듬을 수 있습니다.
  • 이미지 첨부파일:
    텍스트 위주의 RAG/매뉴얼 색인이 목적이라면 이미지 변환은 스킵해도 되며, 이미지가 꼭 필요하다면 export_tree 내부에서 /rest/api/content/{id}/child/attachment API를 호출해 해당 폴더의 images/ 하위 경로로 다운로드하도록 확장할 수 있습니다.

==

Confluence에서 변환된 Markdown 문서들의 내용을 로컬 vLLM(GPU 노드)으로 분석하여, RAG 검색 적중률과 벡터 유사도 매칭을 극대화할 수 있도록 문서 상단에 YAML Frontmatter(핵심 요약, 장애 키워드, 대상 시스템, 조치 카테고리 등)를 자동으로 추출·삽입하는 배치 스크립트입니다.


1. 사전 준비

pip install openai python-frontmatter pyyaml

2. 메타데이터 자동 보강 스크립트 (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)로 비정상 종료되는 현상이 잦을 경우...

변환 후 (vLLM 실행 후 갱신된 파일)

---
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)로 비정상 종료되는 현상이 잦을 경우...

4. RAG 및 Vector DB 활용 시 이점

  • 하이브리드 필터링(Metadata Filtering):
    Vector DB 조회 시 target_systems: ["StarRocks"] 및 category: "Troubleshooting" 조건으로 사전 필터링(Pre-filtering)한 후 임베딩 검색을 돌릴 수 있어 무관한 인프라 문서가 걸려오는 오탐(False Positive)을 차단합니다.
  • 청크 앞단 헤더 삽입:
    ChromaDB 등에 임베딩할 때 본문뿐만 아니라 상단 summary와 error_keywords를 청크 텍스트 앞에 포함시켜 임베딩하면, 에이전트가 던지는 장애 쿼리(StarRocks BE OOM 137)와의 시맨틱 유사도가 급격히 향상됩니다.
profile
engineer

0개의 댓글