본문 바로가기
컴퓨터 인터넷 모바일

티스토리 XML 사이트맵을 활용한 워드프레스 마이그레이션 자동화 파이썬 프로그램

by 백색서무 2025. 4. 10.

목차

    티스토리 XML 사이트맵을 활용한 워드프레스 마이그레이션 자동화 파이썬 프로그램

    티스토리 블로그를 오랫동안 운영하시면서 수많은 포스팅이 쌓였을 것입니다. 그런데 이제 워드프레스의 다양한 커스터마이징 기능과 확장성을 누리고 싶으신가요? 이때 티스토리에서 추출한 XML 사이트맵 파일을 기반으로 웹 크롤링을 통해 실제 포스팅 내용을 가져와, 워드프레스 업로드용 XML 파일(WXR 파일)로 변환하는 파이썬 프로그램을 작성하면, 복잡한 마이그레이션 작업을 자동화할 수 있습니다.

    티스토리 XML 사이트맵을 활용한 워드프레스 마이그레이션 자동화 파이썬 프로그램 만들기

    본 포스팅에서는 티스토리 사이트맵 파일을 파싱하여 각 포스팅의 URL과 업데이트 정보를 얻고, 웹 크롤링으로 해당 페이지의 제목, 본문, 발행일 등을 추출한 후, 이를 워드프레스가 인식하는 XML 형식으로 변환하는 과정을 단계별로 설명합니다. 코드 예제와 함께 실무에 적용할 수 있는 방법을 자세히 안내해 드리겠습니다. (물론 “이런 번거로운 작업을 또 해야 하냐”며 웃음을 자아내는 개발자들의 고충도 함께 공감해 봅니다.)

    티스토리 XML 사이트맵을 활용한 워드프레스 마이그레이션 자동화  프로그램 개요 및 동작 방식

    이 프로그램은 크게 세 가지 주요 단계를 포함합니다.

    사이트맵 파일 파싱

    티스토리에서 제공하는 XML 사이트맵 파일에는 블로그의 모든 URL과 함께 업데이트 날짜(또는 수정일)가 포함되어 있습니다. 이를 파이썬의 XML 파싱 라이브러리를 이용해 읽어오면, 각 포스팅의 URL을 리스트로 정리할 수 있습니다. 이 단계에서는 XML 네임스페이스 처리와 함께 각 <url> 태그 내의 <loc>와 <lastmod> 값을 추출합니다.

    웹 크롤링 및 데이터 추출

    파싱한 URL 목록을 바탕으로, requests 라이브러리를 사용하여 각 포스팅 페이지에 HTTP GET 요청을 보내고, BeautifulSoup을 통해 HTML 콘텐츠를 파싱합니다. 일반적으로 티스토리 포스트는 <title> 태그나 특정 CSS 클래스(예: "post-content" 혹은 <article> 태그) 내에 실제 본문이 존재합니다. 이 프로그램에서는 제목, 본문 내용, 그리고 발행일(또는 수정일)을 추출하며, 만약 HTML 내에서 발행일 정보를 찾지 못하면 사이트맵 파일에 기록된 <lastmod> 값을 대체 데이터로 사용합니다.
    이 부분은 실제 블로그의 HTML 구조에 따라 수정해야 할 수도 있으므로, 사이트 구조에 맞춰 선택자를 변경하는 것이 필요합니다. 작업 도중 “이런 HTML 태그가 또 왜 이래?”라는 웃픈 상황이 발생할 수도 있지만, 한 걸음씩 진행하면 반드시 해결할 수 있습니다.

    워드프레스 WXR 파일 생성

    최종적으로 추출된 포스팅 데이터를 워드프레스에서 사용 가능한 XML 형식으로 변환합니다. 워드프레스의 WXR(WordPress eXtended RSS) 파일은 <rss> 루트 요소 아래에 <channel> 및 각 포스트를 나타내는 <item> 태그를 포함합니다. 각 <item> 태그에는 포스트의 제목, 링크, 발행일, 본문(HTML 형식), 고유 ID 및 기타 속성이 포함되어야 합니다.
    프로그램에서는 간단한 CDATA 처리를 통해 본문 내용을 XML 내에 안전하게 포함시키며, 기본적인 채널 정보(예: 사이트 제목, 링크, 설명 등)도 함께 작성하여 완성도 높은 WXR 파일을 생성합니다.

    파이썬 코드 구현

    아래 코드는 위에서 설명한 단계들을 구현한 파이썬 프로그램입니다. 코드 내 주석을 참고하여 각 기능을 이해할 수 있으며, 실제 블로그 구조에 맞게 수정 후 사용하시면 좋습니다.

    import requests
    import xml.etree.ElementTree as ET
    from bs4 import BeautifulSoup
    import time
    import re
    
    def cdata(text):
        """
        CDATA 형식으로 텍스트를 감싸주는 함수
        (ElementTree는 기본적으로 CDATA를 지원하지 않으므로, 문자열로 직접 작성)
        """
        return f"<![CDATA[{text}]]>"
    
    def parse_sitemap(sitemap_file):
        """
        티스토리 XML 사이트맵 파일을 파싱하여 URL과 lastmod 정보를 추출합니다.
        sitemap_file: XML 파일 경로
        반환값: [{'loc': URL, 'lastmod': 수정일}, ...]
        """
        tree = ET.parse(sitemap_file)
        root = tree.getroot()
        ns = {'ns': 'http://www.sitemaps.org/schemas/sitemap/0.9'}
        urls = []
        for url in root.findall('ns:url', ns):
            loc_elem = url.find('ns:loc', ns)
            if loc_elem is None:
                continue
            loc = loc_elem.text.strip()
            lastmod_elem = url.find('ns:lastmod', ns)
            lastmod = lastmod_elem.text.strip() if lastmod_elem is not None else None
            urls.append({'loc': loc, 'lastmod': lastmod})
        return urls
    
    def fetch_post(url, fallback_date=None):
        """
        주어진 URL에 대해 HTTP GET 요청을 보내고, BeautifulSoup을 사용해 포스트 정보를 추출합니다.
        fallback_date: HTML 내 발행일 정보를 찾지 못할 경우 사이트맵의 lastmod 값을 사용합니다.
        반환값: {'title': 제목, 'link': URL, 'pub_date': 발행일, 'content': 본문, 'slug': 간단한 슬러그}
        """
        print(f"크롤링 시작: {url}")
        try:
            response = requests.get(url)
        except Exception as e:
            print(f"URL 요청 실패: {url}, 에러: {e}")
            return None
        if response.status_code != 200:
            print(f"HTTP 에러 {response.status_code}: {url}")
            return None
    
        html = response.text
        soup = BeautifulSoup(html, 'html.parser')
    
        # 포스트 제목 추출: <title> 태그를 사용하거나, 필요시 다른 선택자 적용
        title_tag = soup.find('title')
        title = title_tag.text.strip() if title_tag else "Untitled"
    
        # 포스트 내용 추출: <article> 태그 또는 특정 클래스(예: "post-content")를 사용
        article = soup.find('article')
        if article:
            content = article.decode_contents()
        else:
            content_div = soup.find('div', class_='post-content')
            content = content_div.decode_contents() if content_div else html
    
        # 발행일 추출: <meta property="article:published_time"> 태그를 우선 사용, 없으면 fallback_date 사용
        pub_date = None
        meta_pub = soup.find('meta', property='article:published_time')
        if meta_pub and meta_pub.get('content'):
            pub_date = meta_pub['content']
        else:
            pub_date = fallback_date
    
        # 간단한 슬러그 생성: 제목을 소문자로 변환하고, 공백은 '-'로 치환
        slug = re.sub(r'[^a-z0-9\-]', '', re.sub(r'\s+', '-', title.lower()))
    
        return {
            'title': title,
            'link': url,
            'pub_date': pub_date,
            'content': content,
            'slug': slug,
        }
    
    def generate_wordpress_xml(posts, output_file):
        """
        추출한 포스트 데이터를 워드프레스 WXR 형식의 XML 파일로 생성합니다.
        posts: 포스트 정보 리스트
        output_file: 생성할 XML 파일 경로
        """
        # XML 네임스페이스 설정 및 <rss> 루트 요소 생성
        rss = ET.Element('rss', {
            'version': '2.0',
            'xmlns:excerpt': 'http://wordpress.org/export/1.2/excerpt/',
            'xmlns:content': 'http://purl.org/rss/1.0/modules/content/',
            'xmlns:wfw': 'http://wellformedweb.org/CommentAPI/',
            'xmlns:dc': 'http://purl.org/dc/elements/1.1/',
            'xmlns:wp': 'http://wordpress.org/export/1.2/'
        })
        channel = ET.SubElement(rss, 'channel')
    
        # 채널 기본 정보 (필요시 수정)
        ET.SubElement(channel, 'title').text = '티스토리 블로그'
        ET.SubElement(channel, 'link').text = 'https://yourwordpresssite.com'
        ET.SubElement(channel, 'description').text = '티스토리에서 워드프레스로 이전한 블로그 포스팅'
        ET.SubElement(channel, 'language').text = 'ko'
        ET.SubElement(channel, 'wp:wxr_version').text = '1.2'
        ET.SubElement(channel, 'wp:base_site_url').text = 'https://yourwordpresssite.com'
        ET.SubElement(channel, 'wp:base_blog_url').text = 'https://yourwordpresssite.com'
    
        post_id = 1
        for post in posts:
            item = ET.SubElement(channel, 'item')
            ET.SubElement(item, 'title').text = post['title']
            ET.SubElement(item, 'link').text = post['link']
            ET.SubElement(item, 'pubDate').text = post['pub_date'] if post['pub_date'] else ''
            ET.SubElement(item, 'dc:creator').text = 'admin'
            guid = ET.SubElement(item, 'guid', isPermaLink="false")
            guid.text = post['link']
            ET.SubElement(item, 'description').text = ''
    
            # 본문 내용은 CDATA로 감싸 XML에서 HTML 형식 유지
            content_encoded = ET.SubElement(item, 'content:encoded')
            content_encoded.text = cdata(post['content'])
    
            ET.SubElement(item, 'excerpt:encoded').text = cdata('')
            ET.SubElement(item, 'wp:post_id').text = str(post_id)
            ET.SubElement(item, 'wp:post_date').text = post['pub_date'] if post['pub_date'] else ''
            ET.SubElement(item, 'wp:post_date_gmt').text = post['pub_date'] if post['pub_date'] else ''
            ET.SubElement(item, 'wp:comment_status').text = 'closed'
            ET.SubElement(item, 'wp:ping_status').text = 'closed'
            ET.SubElement(item, 'wp:post_name').text = post['slug']
            ET.SubElement(item, 'wp:status').text = 'publish'
            ET.SubElement(item, 'wp:post_parent').text = '0'
            ET.SubElement(item, 'wp:menu_order').text = '0'
            ET.SubElement(item, 'wp:post_type').text = 'post'
            ET.SubElement(item, 'wp:post_password').text = ''
            ET.SubElement(item, 'wp:is_sticky').text = '0'
            post_id += 1
    
        # XML 트리 생성 후 파일로 저장
        tree = ET.ElementTree(rss)
        tree.write(output_file, encoding='utf-8', xml_declaration=True)
        print(f"워드프레스 XML 파일이 생성되었습니다: {output_file}")
    
    def main():
        # 티스토리 XML 사이트맵 파일 경로 (예: 'sitemap.xml')
        sitemap_file = 'sitemap.xml'
        output_file = 'wordpress_export.xml'
    
        print("사이트맵 파일 파싱 중...")
        urls_info = parse_sitemap(sitemap_file)
        print(f"총 {len(urls_info)}개의 URL을 추출했습니다.")
    
        posts = []
        for info in urls_info:
            url = info['loc']
            fallback_date = info['lastmod']
            post = fetch_post(url, fallback_date)
            if post:
                posts.append(post)
                # 서버에 부담을 주지 않도록 1초 딜레이 (너무 빠르면 차단될 수 있음)
                time.sleep(1)
    
        if posts:
            generate_wordpress_xml(posts, output_file)
        else:
            print("게시글 정보를 추출하지 못했습니다.")
    
    if __name__ == '__main__':
        main()

    주의사항 및 확장 가능성

    위 코드는 티스토리 사이트맵 파일을 기반으로 간단하게 웹 크롤링하여 워드프레스 XML 파일을 생성하는 예제입니다.

    • HTML 구조에 따른 커스터마이징: 실제 티스토리 포스트의 HTML 구조가 다를 수 있으므로, 제목이나 본문, 발행일 추출 부분의 선택자를 반드시 확인하고 수정해야 합니다.
    • 에러 처리 및 예외 상황: 네트워크 오류, 응답 지연, 예상치 못한 HTML 구조 등 다양한 상황에 대한 에러 처리 로직을 추가하는 것이 좋습니다.
    • CDATa 처리: 파이썬의 xml.etree.ElementTree는 CDATA 처리를 기본 지원하지 않으므로, 위와 같이 문자열로 직접 CDATA 섹션을 작성하는 방식으로 처리하였습니다.
    • 서버 부하 고려: 많은 URL을 한 번에 크롤링할 경우 서버 부하 및 IP 차단 이슈가 발생할 수 있으니, 적절한 딜레이와 사용자 에이전트 설정을 추가하는 것을 권장합니다.
    • 확장 기능: 추후 포스트의 카테고리, 태그, 이미지 첨부 파일 등 추가 정보를 추출하여 XML에 포함시키는 기능도 확장이 가능합니다. 개발자분들께서 필요에 따라 모듈화를 진행하고, 에러 로깅 시스템을 구축하는 것도 좋은 방법입니다.

    물론 “한 번에 다 해결될 리 없지 않느냐”며 코드를 보며 한숨짓는 순간도 있겠지만, 한 걸음씩 개선해 나가면 여러분의 마이그레이션 작업은 분명 성공할 것입니다.

    결론

    티스토리에서 워드프레스로 블로그를 이전하는 작업은 단순한 파일 복사 이상의 의미를 갖습니다. XML 사이트맵 파일을 활용하여 웹 크롤링으로 실제 포스팅 내용을 가져오고, 이를 워드프레스가 인식하는 WXR 형식의 XML 파일로 변환하는 파이썬 프로그램은 마이그레이션 자동화를 위한 강력한 도구입니다.
    이 프로그램은 개발자의 손길에 따라 세밀하게 수정 및 확장이 가능하며, 수작업으로 데이터를 이전하는 번거로움을 크게 줄여줍니다. 앞으로의 블로그 운영 및 콘텐츠 관리에 있어 새로운 도약의 디딤돌이 되기를 기대합니다.

    반응형