블로그 글을 백업해야겠다고 마음먹고 오픈 API 문서부터 찾았는데, 그 문은 이미 닫혀 있었습니다. 예전에는 인증 토큰을 받아 글 목록과 본문을 정직하게 내려받을 수 있었지만 지금은 그 경로가 없습니다. 남은 것은 누구나 브라우저로 볼 수 있는 공개 경로뿐입니다.
처음에는 관리자 화면의 내보내기 기능을 쓰면 되지 않느냐고 생각했습니다. 실제로 그것도 방법이지만, 사람이 직접 눌러야 하는 일은 결국 하지 않게 됩니다. 저는 3년쯤 백업을 미루다가 계정 문제로 하루 동안 블로그에 접속하지 못한 적이 있는데, 그때 처음으로 "글이 내 손에 없다"는 사실이 실감났습니다.
이 글은 API 없이 공개 경로 세 가지만으로 백업 스크립트를 짠 기록입니다. 각 경로에서 무엇을 얻을 수 있고 무엇이 부족한지 비교하고, 이를 조합하는 순서와 저장 구조, 그리고 남의 서버를 두드릴 때 지켜야 할 최소한의 예의까지 정리했습니다.
쓸 수 있는 공개 경로는 세 개입니다
티스토리 블로그 주소 뒤에 붙일 수 있는 경로 중 백업에 쓸 만한 것은 다음 세 가지입니다. 셋 다 로그인 없이 열리고, 셋 다 혼자서는 부족합니다.
경로얻는 것부족한 것
| /rss | 최근 글의 제목, 링크, 발행일, 본문 HTML | 최근 몇 편으로 개수가 제한되고, 블로그 설정에 따라 본문이 아니라 요약만 들어오기도 합니다 |
| /sitemap.xml | 공개된 전체 글의 URL과 최종 수정일 | 제목도 본문도 없습니다. 주소 목록일 뿐입니다 |
| /archive/YYYYMM | 해당 월에 발행된 글의 제목과 링크 | 본문이 없고, 스킨에 따라 화면 구조가 달라 파싱이 깨질 수 있습니다 |
정리하면 RSS는 본문은 주지만 범위가 좁고, 사이트맵은 범위는 넓지만 내용이 없고, 아카이브는 그 사이를 메웁니다. 그래서 세 개를 각각 다른 역할로 씁니다.
- 사이트맵은 목록의 기준선: 내 블로그에 글이 총 몇 편 있고 주소가 무엇인지를 여기서 확정합니다. 백업이 끝난 뒤 빠진 글이 있는지 대조하는 기준으로도 씁니다.
- RSS는 증분 백업용: 매일 또는 매주 돌리면 새 글과 최근 수정된 글이 본문째로 들어옵니다. 평소 운영은 이것만으로 충분합니다.
- 아카이브는 최초 1회 전수 조사: 블로그를 시작한 달부터 이번 달까지 순회하면서 제목과 링크를 모읍니다. 사이트맵과 대조해 누락을 찾는 데 유용합니다.
사이트맵에 있는데 아직 본문을 못 받은 글은 개별 주소를 하나씩 방문해 채웁니다. 이 부분이 요청 수가 가장 많은 구간이라, 뒤에서 설명할 간격 조절이 여기에 걸립니다.
RSS부터 시작하는 최소 스크립트
가장 먼저 만들 것은 RSS 한 번을 읽어 날짜별 폴더에 저장하는 스크립트입니다. 외부 라이브러리 없이 표준 라이브러리만으로 됩니다.
import time, pathlib, urllib.request
import xml.etree.ElementTree as ET
BLOG = "https://example.tistory.com"
OUT = pathlib.Path("backup")
NS = {"content": "http://purl.org/rss/1.0/modules/content/"}
def fetch(url, delay=2.0):
req = urllib.request.Request(url, headers={"User-Agent": "my-backup/1.0"})
with urllib.request.urlopen(req, timeout=15) as res:
body = res.read()
time.sleep(delay) # 요청 사이 간격을 반드시 둡니다
return body
root = ET.fromstring(fetch(BLOG + "/rss"))
for item in root.iter("item"):
link = item.findtext("link", "").rstrip("/")
no = link.rsplit("/", 1)[-1]
date = item.findtext("pubDate", "")[5:16].replace(" ", "-")
folder = OUT / date
folder.mkdir(parents=True, exist_ok=True)
html = item.findtext("content:encoded", "", NS) or item.findtext("description", "")
(folder / f"{no}.html").write_text(html, encoding="utf-8")
print(no, item.findtext("title", ""))
여기서 주의할 점이 둘 있습니다. 하나는 본문이 content:encoded에 들어올 수도 있고 description에만 들어올 수도 있다는 것입니다. 그래서 앞의 값이 비면 뒤를 쓰도록 했습니다. 다른 하나는 fetch 함수 안에 대기 시간을 넣어둔 점입니다. 호출부에서 깜빡하는 일을 막기 위해 처음부터 함수 안쪽에 두는 편이 안전합니다.
사이트맵과 아카이브 처리는 같은 fetch를 재사용하면 됩니다. 사이트맵은 XML이라 위와 같은 방식으로 파싱되고, 아카이브는 HTML이라 파서가 하나 더 필요합니다. 다만 아카이브는 최초 한 번만 쓰는 용도라, 파싱이 조금 지저분해도 크게 상관없습니다.
저장 구조는 나중에 찾을 수 있게
백업 파일은 쌓아두는 게 목적이 아니라 필요할 때 찾는 게 목적입니다. 저는 이 구조로 정착했습니다.
- backup/YYYY-MM-DD/글번호.html: 발행일 기준 폴더에 글 번호로 저장합니다. 제목을 파일명으로 쓰면 특수문자와 길이 때문에 운영체제마다 사고가 납니다.
- backup/YYYY-MM-DD/글번호.json: 제목, 원본 URL, 발행일, 최종 수정일, 카테고리, 태그를 따로 담습니다. 본문 HTML과 메타데이터를 섞지 않는 편이 나중에 다루기 쉽습니다.
- backup/index.tsv: 글 번호, 발행일, 제목, URL, 수집 시각을 한 줄씩 쌓은 전체 목록입니다. 탭 구분이면 표 프로그램에서도 바로 열리고, 제목에 쉼표가 들어가도 깨지지 않습니다.
- backup/missing.txt: 사이트맵에는 있는데 아직 본문을 못 받은 주소를 남깁니다. 다음 실행 때 이 파일부터 처리하면 중단된 지점에서 이어갈 수 있습니다.
이미지까지 챙길지는 선택입니다. 본문 HTML 안의 이미지 주소를 뽑아 같은 폴더에 내려받으면 완전한 백업이 되지만, 용량과 요청 수가 크게 늘어납니다. 저는 처음 실행에서는 글만 받고, 이미지는 별도 스크립트로 천천히 돌립니다.
남의 서버를 두드릴 때의 최소한
백업 스크립트는 결국 내가 만든 프로그램이 남의 서버에 반복해서 요청을 보내는 일입니다. 내 글이라도 서버는 내 것이 아닙니다. 다음은 제가 지키는 선입니다.
- 요청 사이에 최소 1~2초를 둡니다: 200편이면 10분 안쪽입니다. 급하게 끝낼 이유가 없습니다. 병렬 요청은 아예 쓰지 않습니다.
- 이미 받은 글은 다시 받지 않습니다: 사이트맵의 최종 수정일과 로컬 파일의 수집 시각을 비교해 바뀐 것만 갱신합니다. 이 한 가지로 두 번째 실행부터 요청 수가 크게 줄어듭니다.
- 실행 주기를 낮춥니다: 하루에 한 번이면 충분하고, 주 1회여도 문제없습니다. 짧은 주기로 돌린다고 백업이 더 안전해지지는 않습니다.
- 오류가 나면 멈춥니다: 연속으로 실패하면 재시도를 반복하지 말고 종료합니다. 상대 서버가 힘들어하는 신호일 수 있습니다.
- 식별 가능한 User-Agent를 씁니다: 브라우저인 척하기보다 용도를 밝히는 편이 낫습니다. 문제가 생겼을 때 서버 쪽에서 판단할 근거가 됩니다.
마치며
사이트맵으로 전체 목록을 잡고, RSS로 최근 글의 본문을 받고, 아카이브로 과거 목록을 메웁니다. 날짜별 폴더에 글 번호로 저장하고, 인덱스 파일과 미처리 목록으로 이어서 실행할 수 있게 만듭니다. 요청 사이에는 간격을 둡니다.
오늘 할 수 있는 한 가지는 이겁니다. 브라우저 주소창에 내 블로그 주소 뒤에 /sitemap.xml을 붙여 열어보는 것입니다. 거기 나오는 주소 개수가 내가 지켜야 할 글의 수이고, 백업 스크립트가 도달해야 할 목표 숫자입니다.
'만드는 기록 > 작게 만든 도구들' 카테고리의 다른 글
| 플랫폼별 복붙 오류를 없애는 원고 서식 변환기 직접 만들기 (0) | 2026.08.28 |
|---|