Created
April 29, 2025 16:03
-
-
Save edp1096/c78b92d213954f59dad02226fa5cf3ca to your computer and use it in GitHub Desktop.
xpressengine 1.11.6 기준, 게시판 단위로 게시물 마크다운 파일로 내보내기
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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, | |
| ®dateStr, | |
| &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, | |
| ®dateStr, | |
| ) | |
| 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, " ", " ") | |
| content = strings.ReplaceAll(content, "<", "<") | |
| content = strings.ReplaceAll(content, ">", ">") | |
| content = strings.ReplaceAll(content, "&", "&") | |
| content = strings.ReplaceAll(content, """, "\"") | |
| 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