Skip to content

Instantly share code, notes, and snippets.

@edp1096
Created April 29, 2025 16:03
Show Gist options
  • Save edp1096/c78b92d213954f59dad02226fa5cf3ca to your computer and use it in GitHub Desktop.
Save edp1096/c78b92d213954f59dad02226fa5cf3ca to your computer and use it in GitHub Desktop.
xpressengine 1.11.6 기준, 게시판 단위로 게시물 마크다운 파일로 내보내기
package main
import (
"database/sql"
"fmt"
"log"
"os"
"path/filepath"
"strings"
"time"
_ "github.com/go-sql-driver/mysql"
)
// 게시판 정보 구조체
type Board struct {
ModuleSRL string // 모듈 고유 번호
Mid string // 모듈 ID (게시판 ID)
BrowserTitle string // 게시판 제목
Posts []Post // 게시물 목록
PostCount int // 게시물 수
}
// 게시물 정보 구조체
type Post struct {
DocumentSRL int64 // 문서 고유 번호
Title string // 제목
Content string // 내용
MemberSRL int64 // 작성자 번호 (항상 1로 고정)
NickName string // 작성자 닉네임
ReadCount int // 조회수
VotedCount int // 추천수
CommentCount int // 댓글수
CategorySRL int64 // 카테고리 번호
CategoryTitle string // 카테고리 이름
Regdate time.Time // 작성일
LastUpdate time.Time // 수정일
Comments []Comment // 댓글 목록
Attachments []Attachment // 첨부파일 목록
}
// 댓글 정보 구조체
type Comment struct {
CommentSRL int64 // 댓글 고유 번호
Content string // 내용
MemberSRL int64 // 작성자 번호 (항상 1로 고정)
NickName string // 작성자 닉네임
ParentSRL int64 // 부모 댓글 번호 (대댓글일 경우)
Regdate time.Time // 작성일
Children []Comment // 자식 댓글들
}
// 첨부파일 정보 구조체
type Attachment struct {
FileSRL int64 // 파일 고유 번호
SourceFilename string // 원본 파일명
DownloadCount int // 다운로드 수
FileSize int64 // 파일 크기
UploadedFilename string // 업로드된 파일명
}
// XE 파서 구조체
type XEParser struct {
DB *sql.DB
OutputPath string
}
// 파서 초기화 함수
func NewXEParser(dbUser, dbPass, dbHost, dbName, outputPath string) (*XEParser, error) {
// 데이터베이스 연결 문자열 생성
dsn := fmt.Sprintf("%s:%s@tcp(%s)/%s?charset=utf8mb4&parseTime=true", dbUser, dbPass, dbHost, dbName)
db, err := sql.Open("mysql", dsn)
if err != nil {
return nil, fmt.Errorf("데이터베이스 연결 실패: %v", err)
}
// 연결 테스트
err = db.Ping()
if err != nil {
return nil, fmt.Errorf("데이터베이스 연결 테스트 실패: %v", err)
}
// 출력 디렉토리 생성
err = os.MkdirAll(outputPath, 0755)
if err != nil {
return nil, fmt.Errorf("출력 디렉토리 생성 실패: %v", err)
}
return &XEParser{
DB: db,
OutputPath: outputPath,
}, nil
}
// 데이터베이스 연결 종료
func (p *XEParser) Close() error {
return p.DB.Close()
}
// 게시판 목록 가져오기
func (p *XEParser) GetBoards() ([]Board, error) {
// 게시판 모듈 정보 조회 쿼리
query := `
SELECT module_srl, mid, browser_title
FROM xe_modules
WHERE module = 'board'
`
rows, err := p.DB.Query(query)
if err != nil {
return nil, fmt.Errorf("게시판 목록 조회 실패: %v", err)
}
defer rows.Close()
var boards []Board
for rows.Next() {
var board Board
err := rows.Scan(&board.ModuleSRL, &board.Mid, &board.BrowserTitle)
if err != nil {
return nil, fmt.Errorf("게시판 데이터 스캔 실패: %v", err)
}
// 게시물 수 조회
countQuery := "SELECT COUNT(*) FROM xe_documents WHERE module_srl = ?"
err = p.DB.QueryRow(countQuery, board.ModuleSRL).Scan(&board.PostCount)
if err != nil {
log.Printf("게시물 수 조회 실패 (게시판: %s): %v", board.Mid, err)
board.PostCount = 0
}
boards = append(boards, board)
log.Printf("게시판 발견: %s (%s) - 게시물 %d개", board.BrowserTitle, board.Mid, board.PostCount)
}
return boards, nil
}
// 게시판별 게시물 가져오기
func (p *XEParser) GetPosts(board *Board) error {
// 게시물 정보 조회 쿼리 (카테고리 정보 포함)
query := `
SELECT d.document_srl, d.title, d.content, d.member_srl, d.nick_name,
d.readed_count, d.voted_count, d.comment_count, d.category_srl,
d.regdate, d.last_update,
IFNULL(c.title, '') as category_title
FROM xe_documents d
LEFT JOIN xe_document_categories c ON d.category_srl = c.category_srl
WHERE d.module_srl = ?
ORDER BY d.document_srl DESC
`
rows, err := p.DB.Query(query, board.ModuleSRL)
if err != nil {
return fmt.Errorf("게시물 목록 조회 실패: %v", err)
}
defer rows.Close()
for rows.Next() {
var post Post
var regdateStr, lastUpdateStr string
var memberSRL sql.NullInt64
var nickName sql.NullString
var categorySRL sql.NullInt64
var categoryTitle sql.NullString
err := rows.Scan(
&post.DocumentSRL,
&post.Title,
&post.Content,
&memberSRL,
&nickName,
&post.ReadCount,
&post.VotedCount,
&post.CommentCount,
&categorySRL,
&regdateStr,
&lastUpdateStr,
&categoryTitle,
)
if err != nil {
return fmt.Errorf("게시물 데이터 스캔 실패: %v", err)
}
// 작성자 정보 설정 (모두 1로 통일)
post.MemberSRL = 1
if nickName.Valid {
post.NickName = nickName.String
} else {
post.NickName = "회원"
}
// 날짜 파싱
if regdateStr != "" {
// 제로보드XE는 YYYYMMDDHHMMSS 형식으로 날짜 저장
t, err := time.Parse("20060102150405", regdateStr)
if err == nil {
post.Regdate = t
} else {
log.Printf("작성일 파싱 실패: %v", err)
post.Regdate = time.Now()
}
}
if lastUpdateStr != "" {
t, err := time.Parse("20060102150405", lastUpdateStr)
if err == nil {
post.LastUpdate = t
} else {
log.Printf("수정일 파싱 실패: %v", err)
post.LastUpdate = post.Regdate
}
}
// 카테고리 정보 설정
if categorySRL.Valid {
post.CategorySRL = categorySRL.Int64
}
if categoryTitle.Valid {
post.CategoryTitle = categoryTitle.String
}
// 댓글 가져오기
err = p.GetComments(&post)
if err != nil {
log.Printf("댓글 조회 실패 (게시물 %d): %v", post.DocumentSRL, err)
}
// 첨부파일 가져오기
err = p.GetAttachments(&post)
if err != nil {
log.Printf("첨부파일 조회 실패 (게시물 %d): %v", post.DocumentSRL, err)
}
board.Posts = append(board.Posts, post)
}
log.Printf("게시판 '%s'에서 %d개 게시물을 가져왔습니다.", board.BrowserTitle, len(board.Posts))
return nil
}
// 게시물의 댓글 가져오기
func (p *XEParser) GetComments(post *Post) error {
// 댓글 정보 조회 쿼리
query := `
SELECT comment_srl, content, member_srl, nick_name, parent_srl, regdate
FROM xe_comments
WHERE document_srl = ?
ORDER BY comment_srl ASC
`
rows, err := p.DB.Query(query, post.DocumentSRL)
if err != nil {
return fmt.Errorf("댓글 목록 조회 실패: %v", err)
}
defer rows.Close()
var allComments []Comment
commentMap := make(map[int64]*Comment) // 댓글 트리 구성을 위한 맵
for rows.Next() {
var comment Comment
var regdateStr string
var memberSRL sql.NullInt64
var nickName sql.NullString
var parentSRL sql.NullInt64
err := rows.Scan(
&comment.CommentSRL,
&comment.Content,
&memberSRL,
&nickName,
&parentSRL,
&regdateStr,
)
if err != nil {
return fmt.Errorf("댓글 데이터 스캔 실패: %v", err)
}
// 작성자 정보 설정 (모두 1로 통일)
comment.MemberSRL = 1
if nickName.Valid {
comment.NickName = nickName.String
} else {
comment.NickName = "회원"
}
// 부모 댓글 설정
if parentSRL.Valid {
comment.ParentSRL = parentSRL.Int64
}
// 날짜 파싱
if regdateStr != "" {
t, err := time.Parse("20060102150405", regdateStr)
if err == nil {
comment.Regdate = t
} else {
log.Printf("댓글 작성일 파싱 실패: %v", err)
comment.Regdate = time.Now()
}
}
allComments = append(allComments, comment)
commentMap[comment.CommentSRL] = &allComments[len(allComments)-1]
}
// 댓글 트리 구성 (부모-자식 관계 설정)
var rootComments []Comment
for _, comment := range allComments {
if comment.ParentSRL == 0 {
// 최상위 댓글
rootComments = append(rootComments, comment)
} else {
// 대댓글
if parent, exists := commentMap[comment.ParentSRL]; exists {
parent.Children = append(parent.Children, comment)
} else {
// 부모가 없는 경우 최상위 댓글로 처리
rootComments = append(rootComments, comment)
}
}
}
post.Comments = rootComments
return nil
}
// 게시물의 첨부파일 가져오기
func (p *XEParser) GetAttachments(post *Post) error {
// 첨부파일 정보 조회 쿼리
query := `
SELECT file_srl, source_filename, download_count, file_size, uploaded_filename
FROM xe_files
WHERE upload_target_srl = ?
ORDER BY file_srl ASC
`
rows, err := p.DB.Query(query, post.DocumentSRL)
if err != nil {
return fmt.Errorf("첨부파일 목록 조회 실패: %v", err)
}
defer rows.Close()
for rows.Next() {
var attachment Attachment
err := rows.Scan(
&attachment.FileSRL,
&attachment.SourceFilename,
&attachment.DownloadCount,
&attachment.FileSize,
&attachment.UploadedFilename,
)
if err != nil {
return fmt.Errorf("첨부파일 데이터 스캔 실패: %v", err)
}
post.Attachments = append(post.Attachments, attachment)
}
return nil
}
// 게시판 데이터를 마크다운으로 저장
func (p *XEParser) SaveBoardToMarkdown(board Board) error {
// 파일명으로 사용할 수 있게 제목 처리
safeTitle := sanitizeFilename(board.BrowserTitle)
filename := filepath.Join(p.OutputPath, fmt.Sprintf("%s_%s.md", board.Mid, safeTitle))
file, err := os.Create(filename)
if err != nil {
return fmt.Errorf("마크다운 파일 생성 실패: %v", err)
}
defer file.Close()
// 게시판 정보 헤더
fmt.Fprintf(file, "# %s\n\n", board.BrowserTitle)
fmt.Fprintf(file, "- 게시판 ID: %s\n", board.Mid)
fmt.Fprintf(file, "- 모듈 번호: %s\n", board.ModuleSRL)
fmt.Fprintf(file, "- 게시물 수: %d\n\n", board.PostCount)
// 게시물 목록
for _, post := range board.Posts {
// 구분선
fmt.Fprintf(file, "---\n\n")
// 게시물 헤더
fmt.Fprintf(file, "## %s\n\n", post.Title)
// 게시물 메타데이터
if post.CategoryTitle != "" {
fmt.Fprintf(file, "- 카테고리: %s\n", post.CategoryTitle)
}
fmt.Fprintf(file, "- 문서 번호: %d\n", post.DocumentSRL)
fmt.Fprintf(file, "- 작성자: %s\n", post.NickName)
fmt.Fprintf(file, "- 작성일: %s\n", post.Regdate.Format("2006-01-02 15:04:05"))
fmt.Fprintf(file, "- 조회수: %d\n", post.ReadCount)
fmt.Fprintf(file, "- 추천수: %d\n", post.VotedCount)
fmt.Fprintf(file, "- 댓글수: %d\n\n", post.CommentCount)
// 게시물 내용
fmt.Fprintf(file, "### 본문\n\n%s\n\n", cleanHTML(post.Content))
// 첨부파일 목록
if len(post.Attachments) > 0 {
fmt.Fprintf(file, "### 첨부파일\n\n")
for _, attachment := range post.Attachments {
fmt.Fprintf(file, "- %s (%s, 다운로드: %d회)\n",
attachment.SourceFilename,
formatFileSize(attachment.FileSize),
attachment.DownloadCount)
}
fmt.Fprintf(file, "\n")
}
// 댓글 목록
if len(post.Comments) > 0 {
fmt.Fprintf(file, "### 댓글\n\n")
for _, comment := range post.Comments {
writeComment(file, comment, 0)
}
}
}
log.Printf("게시판 '%s' 데이터를 %s에 저장했습니다.", board.BrowserTitle, filename)
return nil
}
// 댓글을 마크다운으로 작성 (재귀적으로 대댓글 처리)
func writeComment(file *os.File, comment Comment, depth int) {
indent := strings.Repeat(" ", depth)
// 댓글 정보
fmt.Fprintf(file, "%s- **%s** (%s)\n",
indent,
comment.NickName,
comment.Regdate.Format("2006-01-02 15:04:05"))
// 댓글 내용
content := cleanHTML(comment.Content)
contentLines := strings.Split(content, "\n")
for _, line := range contentLines {
if line == "" {
continue
}
fmt.Fprintf(file, "%s %s\n", indent, line)
}
fmt.Fprintf(file, "\n")
// 대댓글 처리
for _, child := range comment.Children {
writeComment(file, child, depth+1)
}
}
// HTML 태그 제거 및 기본적인 마크다운 변환
func cleanHTML(html string) string {
// 간단한 HTML 변환
content := html
// 줄바꿈 처리
content = strings.ReplaceAll(content, "<br>", "\n")
content = strings.ReplaceAll(content, "<br/>", "\n")
content = strings.ReplaceAll(content, "<br />", "\n")
// 일반적인 태그 제거
content = strings.ReplaceAll(content, "<p>", "")
content = strings.ReplaceAll(content, "</p>", "\n\n")
content = strings.ReplaceAll(content, "<div>", "")
content = strings.ReplaceAll(content, "</div>", "\n")
// HTML 엔티티 처리
content = strings.ReplaceAll(content, "&nbsp;", " ")
content = strings.ReplaceAll(content, "&lt;", "<")
content = strings.ReplaceAll(content, "&gt;", ">")
content = strings.ReplaceAll(content, "&amp;", "&")
content = strings.ReplaceAll(content, "&quot;", "\"")
return content
}
// 파일 크기를 사람이 읽기 쉬운 형식으로 변환
func formatFileSize(bytes int64) string {
const (
KB = 1024
MB = 1024 * KB
GB = 1024 * MB
)
switch {
case bytes >= GB:
return fmt.Sprintf("%.2f GB", float64(bytes)/float64(GB))
case bytes >= MB:
return fmt.Sprintf("%.2f MB", float64(bytes)/float64(MB))
case bytes >= KB:
return fmt.Sprintf("%.2f KB", float64(bytes)/float64(KB))
default:
return fmt.Sprintf("%d bytes", bytes)
}
}
// 파일명으로 사용할 수 없는 문자 제거
func sanitizeFilename(name string) string {
// 파일명으로 사용할 수 없는 문자 대체
replacer := strings.NewReplacer(
"/", "_",
"\\", "_",
":", "_",
"*", "_",
"?", "_",
"\"", "_",
"<", "_",
">", "_",
"|", "_",
)
return replacer.Replace(name)
}
func main() {
// 데이터베이스 연결 정보
dbUser := "root" // 데이터베이스 사용자
dbPass := "password" // 데이터베이스 비밀번호
dbHost := "localhost:3306" // 데이터베이스 호스트
dbName := "website_xe" // 데이터베이스 이름
outputPath := "./xe_export" // 출력 디렉토리
// 환경 변수에서 설정값 가져오기 (선택 사항)
if os.Getenv("DB_USER") != "" {
dbUser = os.Getenv("DB_USER")
}
if os.Getenv("DB_PASS") != "" {
dbPass = os.Getenv("DB_PASS")
}
if os.Getenv("DB_HOST") != "" {
dbHost = os.Getenv("DB_HOST")
}
if os.Getenv("DB_NAME") != "" {
dbName = os.Getenv("DB_NAME")
}
if os.Getenv("OUTPUT_PATH") != "" {
outputPath = os.Getenv("OUTPUT_PATH")
}
// 파서 생성
parser, err := NewXEParser(dbUser, dbPass, dbHost, dbName, outputPath)
if err != nil {
log.Fatalf("파서 초기화 실패: %v", err)
}
defer parser.Close()
// 게시판 목록 가져오기
boards, err := parser.GetBoards()
if err != nil {
log.Fatalf("게시판 목록 가져오기 실패: %v", err)
}
log.Printf("총 %d개의 게시판을 발견했습니다.", len(boards))
// 각 게시판 처리
for i := range boards {
// 게시물 가져오기
err = parser.GetPosts(&boards[i])
if err != nil {
log.Printf("게시판 '%s'의 게시물 가져오기 실패: %v", boards[i].BrowserTitle, err)
continue
}
// 마크다운으로 저장
err = parser.SaveBoardToMarkdown(boards[i])
if err != nil {
log.Printf("게시판 '%s'의 마크다운 변환 실패: %v", boards[i].BrowserTitle, err)
continue
}
}
log.Printf("모든 데이터 처리가 완료되었습니다. 결과 디렉토리: %s", outputPath)
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment