.nsx 파일은 HTML 태그가 뒤엉킨 독자 포맷이라 그대로 AI에게 주면 오답만 쏟아집니다. 이 글에서는 .nsx 파일의 구조를 완전히 분해하고, 수천 개 노트를 AI 최적화 마크다운으로 변환하는 Python 완전 자동화 스크립트를 단계별로 제공합니다.
홈랩 AI 서버 완전 구축 — 5편 마스터 시리즈
글 3에서 다루는 내용
.nsx 파일 구조 완전 해부
Note Station에서 내보내기를 하면 생성되는 .nsx 파일의 정체는 사실 ZIP 압축 아카이브입니다. 확장자만 다를 뿐 ZIP 도구로 완전히 풀 수 있습니다. 내부 구조를 먼저 파악해야 파싱 전략을 올바르게 수립할 수 있습니다.
📦 Step 1 — .nsx 압축 해제
# 방법 1: cp + unzip (Linux/macOS) cp backup.nsx backup.zip unzip backup.zip -d nsx_extracted/ # 방법 2: Python으로 직접 추출 (OS 무관) python3 -c " import zipfile with zipfile.ZipFile('backup.nsx', 'r') as z: z.extractall('nsx_extracted/') print('추출 완료:', len(z.namelist()), '개 파일') "
🗂️ 압축 해제 후 내부 구조
nsx_extracted/ ├── config.json # 노트북 목록 및 전체 메타데이터 ├── [uuid1].json # 노트 1 내용 (HTML 본문 포함) ├── [uuid2].json # 노트 2 내용 ├── ... └── [image_uuid]/ # 첨부 이미지 폴더 (uuid명) ├── [img_hash].jpg ├── [img_hash].png └── width # 이미지 표시 너비 정보
🔍 개별 노트 JSON 스키마 분석
{
"title": "홈랩 Docker 네트워크 설정 메모",
"content": "<div style=\"font-family:Arial;\">
<strong>핵심 체크:</strong> 브릿지 네트워크 우선 사용.<br>
<ul><li>서브넷: 172.20.0.0/16</li></ul>
<img src=\"[image_uuid]/abc123.png\" />
</div>",
"ctime": 1715642400, # 생성 시각 (Unix timestamp)
"mtime": 1715653200, # 수정 시각
"tag": ["홈랩", "Docker", "네트워크"],
"parent_id": "[notebook_uuid]", # 소속 노트북
"attachment": {
"[image_uuid]": {
"md5": "abc123...",
"mime": "image/png",
"filename": "network_diagram.png"
}
}
}실제 Note Station 백업에서는 content 필드에 인라인 스타일, 폰트 지정, 테이블 구조 HTML까지 뒤섞여 있습니다. 텍스트 본문 100자에 HTML 코드 200자가 함께 있는 경우가 흔합니다. 이대로 임베딩하면 토큰의 60~70%가 무의미한 HTML에 낭비됩니다.
Python 완전 자동화 변환 스크립트
📦 필수 라이브러리 설치
pip install markdownify beautifulsoup4 lxml tqdm
🐍 완전 자동화 변환 스크립트 (nsx_to_markdown.py)
#!/usr/bin/env python3
"""
시놀로지 Note Station .nsx → Markdown 완전 자동화 변환기
사용법: python3 nsx_to_markdown.py --nsx backup.nsx --out ./output
"""
import json, os, re, shutil, zipfile, argparse
from datetime import datetime
from pathlib import Path
from bs4 import BeautifulSoup
from markdownify import markdownify as md
from tqdm import tqdm
def clean_html_to_markdown(html_content: str) -> str:
"""HTML 태그 제거 및 깨끗한 마크다운으로 변환"""
if not html_content:
return ""
# BeautifulSoup로 1차 파싱 (잘못된 HTML 교정)
soup = BeautifulSoup(html_content, "lxml")
# 불필요한 스타일/스크립트 태그 완전 제거
for tag in soup.find_all(["style", "script", "meta"]):
tag.decompose()
# markdownify로 마크다운 변환
result = md(str(soup), heading_style="ATX", bullets="-")
# 연속 빈줄 정리 (3줄 이상 → 2줄)
result = re.sub(r'\n{3,}', '\n\n', result)
return result.strip()
def epoch_to_date(timestamp: int) -> str:
"""Unix timestamp → YYYY-MM-DD 변환"""
return datetime.fromtimestamp(timestamp).strftime('%Y-%m-%d')
def build_frontmatter(note: dict, notebook_name: str) -> str:
"""YAML Frontmatter 생성"""
title = note.get("title", "Untitled").replace('"', "'")
created = epoch_to_date(note.get("ctime", 0))
updated = epoch_to_date(note.get("mtime", note.get("ctime", 0)))
tags = note.get("tag", [])
tags_str = "[" + ", ".join(f'"{t}"' for t in tags) + "]"
return f"""---
title: "{title}"
date: {created}
updated: {updated}
tags: {tags_str}
notebook: "{notebook_name}"
source: synology_note_station
---"""
def process_images(note: dict, src_dir: Path, out_dir: Path) -> dict:
"""첨부 이미지를 출력 폴더로 복사하고 경로 매핑 반환"""
path_map = {}
attachments = note.get("attachment", {})
if not attachments:
return path_map
img_out = out_dir / "images"
img_out.mkdir(exist_ok=True)
for att_id, att_info in attachments.items():
src_img = src_dir / att_id
if src_img.is_dir():
for img_file in src_img.iterdir():
if img_file.name != "width":
dest = img_out / img_file.name
shutil.copy2(img_file, dest)
# HTML의 원본 경로 → 상대 경로 매핑
path_map[att_id] = f"images/{img_file.name}"
return path_map
def convert_nsx(nsx_path: str, output_dir: str):
nsx_path = Path(nsx_path)
output_dir = Path(output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
# 1. .nsx 압축 해제
tmp_dir = Path("/tmp/nsx_tmp")
if tmp_dir.exists():
shutil.rmtree(tmp_dir)
with zipfile.ZipFile(nsx_path, 'r') as z:
z.extractall(tmp_dir)
print(f"✅ 압축 해제 완료: {len(list(tmp_dir.iterdir()))}개 파일")
# 2. config.json에서 노트북 이름 매핑
config_path = tmp_dir / "config.json"
notebook_map = {}
if config_path.exists():
config = json.loads(config_path.read_text(encoding="utf-8"))
for nb in config.get("notebook", []):
notebook_map[nb.get("noteBookId", "")] = nb.get("title", "Unknown")
# 3. 모든 노트 JSON 처리
note_files = [f for f in tmp_dir.iterdir()
if f.suffix == ".json" and f.name != "config.json"]
print(f"📝 처리할 노트 수: {len(note_files)}개")
success, failed = 0, 0
for note_file in tqdm(note_files, desc="변환 중"):
try:
note = json.loads(note_file.read_text(encoding="utf-8"))
title = note.get("title", "Untitled")
notebook_id = note.get("parent_id", "")
notebook_name = notebook_map.get(notebook_id, "Unsorted")
# 노트북별 서브폴더 생성
safe_nb = re.sub(r'[^\w\s-]', '', notebook_name).strip()
nb_dir = output_dir / safe_nb
nb_dir.mkdir(exist_ok=True)
# 이미지 복사 및 경로 매핑
path_map = process_images(note, tmp_dir, nb_dir)
# HTML → 마크다운 변환
content = clean_html_to_markdown(note.get("content", ""))
# 이미지 경로 치환
for att_id, rel_path in path_map.items():
content = content.replace(att_id, rel_path)
# YAML Frontmatter 결합
frontmatter = build_frontmatter(note, notebook_name)
final_md = f"{frontmatter}\n\n# {title}\n\n{content}"
# 파일명 안전 처리 후 저장
safe_title = re.sub(r'[^\w\s-]', '', title)[:60].strip()
created = epoch_to_date(note.get("ctime", 0))
filename = f"{created}_{safe_title}.md"
(nb_dir / filename).write_text(final_md, encoding="utf-8")
success += 1
except Exception as e:
print(f"\n❌ 변환 실패: {note_file.name} — {e}")
failed += 1
print(f"\n✅ 변환 완료: {success}개 성공 / {failed}개 실패")
shutil.rmtree(tmp_dir)
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("--nsx", required=True, help=".nsx 파일 경로")
parser.add_argument("--out", required=True, help="출력 폴더 경로")
args = parser.parse_args()
convert_nsx(args.nsx, args.out)▶️ 실행 방법
# 기본 실행 python3 nsx_to_markdown.py --nsx ~/Downloads/backup.nsx --out ~/AI_Knowledge_Base/ # 실행 결과 예시 # ✅ 압축 해제 완료: 847개 파일 # 📝 처리할 노트 수: 823개 # 변환 중: 100%|████████████| 823/823 [02:34<00:00, 5.3it/s] # ✅ 변환 완료: 819개 성공 / 4개 실패
YAML Frontmatter 설계 전략 — RAG 품질을 결정하는 핵심
YAML Frontmatter는 마크다운 파일 최상단에 삽입하는 구조화 메타데이터입니다. RAG 시스템이 이를 활용하면 단순 벡터 유사도 검색을 넘어 메타데이터 필터링까지 결합한 하이브리드 검색이 가능해집니다. 잘 설계된 Frontmatter는 RAG 검색 정확도를 30~50% 향상시킵니다.
📋 필드별 설계 가이드
--- # ── 기본 식별 정보 ───────────────────────── title: "KOSPI 2023 약세장 대응 원칙" date: 2023-10-15 # 작성일 (필터링 핵심) updated: 2024-02-20 # 최종 수정일 # ── 분류 체계 ────────────────────────────── notebook: "투자" # 원본 노트북 이름 category: "investment" # 영문 카테고리 (필터 쿼리용) tags: ["KOSPI", "리스크관리", "원칙", "2023"] # ── RAG 필터링용 커스텀 필드 ─────────────── domain: "finance" # 지식 도메인 importance: "high" # high / medium / low market_condition: "bear" # 작성 당시 시장 상황 review_status: "reviewed" # draft / reviewed / archived language: "ko" # ── 원본 추적 정보 ───────────────────────── source: "synology_note_station" original_ctime: 1715642400 # 원본 타임스탬프 보존 ---
| 필드 | 용도 | RAG 활용 예시 |
|---|---|---|
date | 시간 기반 필터 | “2023년 이후 투자 관련 노트만 참조해서” |
category | 도메인 분리 검색 | Dify Knowledge 분리 시 카테고리별 컬렉션 |
importance: high | 가중치 검색 | 중요 문서 우선 반환 설정 |
market_condition | 컨텍스트 필터 | “약세장 조건의 노트만” 조건 검색 |
review_status | 품질 관리 | draft 상태 문서는 검색에서 제외 |
language: ko | 언어별 임베딩 모델 분리 | 한국어 전용 임베딩 모델 적용 |
이미지 처리 & 멀티모달 RAG 대비 전략
Note Station 내 첨부 이미지는 UUID 기반 폴더명으로 저장됩니다. 변환 과정에서 이미지 경로를 올바르게 재매핑하지 않으면 마크다운 파일에서 이미지가 깨집니다. 더 나아가 향후 멀티모달 RAG(이미지를 AI가 직접 분석)를 도입할 때를 대비해 체계적인 이미지 관리 구조를 지금부터 잡아야 합니다.
🖼️ 이미지 Alt 텍스트 자동 생성 (Vision API 활용)
import base64, httpx
def generate_alt_text(image_path: str, ollama_url: str = "http://localhost:11434") -> str:
"""Ollama Vision 모델로 이미지 Alt 텍스트 자동 생성"""
with open(image_path, "rb") as f:
img_b64 = base64.b64encode(f.read()).decode()
response = httpx.post(f"{ollama_url}/api/generate", json={
"model": "llava:13b", # 또는 gemma3:12b
"prompt": "이 이미지를 한국어로 50자 이내로 간결하게 설명해줘. "
"다이어그램이면 구조를, 스크린샷이면 화면 내용을 중심으로.",
"images": [img_b64],
"stream": False
})
return response.json().get("response", "").strip()
# 마크다운의 이미지 태그에 Alt 텍스트 삽입
# 변환 전: 
# 변환 후: - 이미지 파일명 의미화: UUID 해시명 →
YYYY-MM-DD_노트제목_001.png형식으로 변환 - Alt 텍스트 삽입: Vision 모델로 자동 설명 생성 → 텍스트 검색으로도 이미지 발견 가능
- 이미지 전용 폴더 분리:
노트북폴더/images/하위에 통합 관리 - 원본 보존: 변환 후에도 원본 .nsx 파일은 별도 백업 폴더에 보존 (롤백용)
변환 품질 검증 & 최종 폴더 정리
🔍 변환 품질 자동 검증 스크립트
import os, re
from pathlib import Path
def validate_markdown_quality(output_dir: str):
"""변환된 마크다운 파일 품질 일괄 검증"""
issues = []
md_files = list(Path(output_dir).rglob("*.md"))
print(f"검증 대상: {len(md_files)}개 파일")
for md_file in md_files:
content = md_file.read_text(encoding="utf-8")
# 1. HTML 태그 잔존 여부 확인
html_tags = re.findall(r'<\w+[^>]*>', content)
if html_tags:
issues.append(f"⚠️ HTML 잔존: {md_file.name} ({len(html_tags)}개 태그)")
# 2. YAML Frontmatter 존재 여부
if not content.startswith("---"):
issues.append(f"❌ Frontmatter 없음: {md_file.name}")
# 3. 내용이 너무 짧은 파일 (HTML만 있었던 경우)
body = content.split("---", 2)[-1] if "---" in content else content
if len(body.strip()) < 50:
issues.append(f"⚠️ 내용 부족 (50자 미만): {md_file.name}")
# 4. 깨진 이미지 링크 확인
img_links = re.findall(r'!\[.*?\]\((.*?)\)', content)
for link in img_links:
if not (md_file.parent / link).exists():
issues.append(f"🖼️ 이미지 경로 깨짐: {md_file.name} → {link}")
if issues:
print(f"\n⚠️ 발견된 문제: {len(issues)}건")
for issue in issues[:20]: # 최대 20건 출력
print(f" {issue}")
else:
print("✅ 모든 파일 품질 검증 통과!")
validate_markdown_quality("./AI_Knowledge_Base")📁 변환 완료 후 최종 폴더 구조
/volume1/AI_Knowledge_Base/
├── 투자/
│ ├── images/
│ │ └── 2023-10-15_chart.png
│ ├── 2023-10-15_KOSPI_2023_약세장_대응_원칙.md
│ └── 2024-01-20_포트폴리오_리밸런싱_기록.md
├── 홈랩/
│ ├── images/
│ ├── 2024-03-01_Docker_네트워크_설정.md
│ └── 2024-05-10_시놀로지_NAS_설정_메모.md
├── 일기/
│ └── 2024/
│ ├── 2024-01-01_신년_다짐.md
│ └── ...
└── _backup/
└── backup_original.nsx # 원본 보존- 중복 노트 제거: Note Station에서 같은 내용을 여러 노트북에 복사한 경우 중복 발생. 파일 내용 해시(MD5) 기반 중복 체크 권장.
- 빈 파일 삭제: 제목만 있고 내용 없는 노트는 RAG 검색에 노이즈. 50자 미만 파일 일괄 검토 후 처리.
- 인코딩 문제: 특수 문자나 이모지가 포함된 파일명은 일부 시스템에서 깨질 수 있음. 파일명은 영숫자+한글만 사용 권장.
