티스토리 글을 네이버 블로그로 이전하기 (Playwright + Python 자동화 회고)

티스토리에서 글을 읽어오고(제목/본문/이미지), 네이버 블로그 글쓰기 화면에 옮겨 적는 과정을 자동화하면서 겪었던 시행착오를 정리했습니다. 결론부터 말하면 “완전 자동”보다는 “반자동(로그인/최종 검수는 사람이)”이 현실적인 접근이었습니다.
1) 기획
목표는 단순했습니다. 티스토리 글을 네이버 블로그로 옮기되, 사람이 반복적으로 하는 작업(복사·붙여넣기, 이미지 저장/업로드, 처리 상태 관리)을 최대한 줄이는 것이었습니다.
요구사항(최소 기능)
- 티스토리: 글 URL로부터 제목/본문/이미지 URL을 추출한다.
- 이미지: 외부 링크를 그대로 두지 않고, 로컬에 내려받아 네이버 에디터에 업로드한다.
- 네이버: 글쓰기 화면에서 본문과 제목을 자동 입력한다.
- 진행상태 관리: 이미 처리한 글은 스킵하고 다음 글을 시도한다(SQLite).
흐름(아키텍처)
readTistory(url)로 포스팅 파싱sanitize_for_naver(html)로 입력용 HTML 정리- Playwright로 네이버 글쓰기 진입(로그인은 사람)
- 본문 입력: paste /
insertHTML/ range 삽입 등 여러 전략으로 “한 번에 넣기” - 이미지: out_dir에 다운로드 → file input에
set_input_files로 일괄 업로드 - 성공 시 SQLite에
tistory_post_id+naver_written_at저장
2) 시도
2-1. 티스토리 파싱
티스토리 포스팅은 스킨/레이아웃에 따라 DOM이 달라질 수 있어서, “절대 경로 CSS 하나로 끝내기”는 쉽게 깨집니다. 그래서 제목/본문 후보를 여러 방식으로 찾고, 이미지도 src만 보지 말고 srcset, data-src 같은 속성을 우선순위로 탐색하는 쪽으로 접근했습니다.
2-1-1. 코드 예시: 제목/본문/이미지 추출(개념)
실제 구현은 스킨별 예외처리가 있어서 더 길지만, 핵심 아이디어(후보 탐색 + 이미지 속성 우선순위)는 아래처럼 정리할 수 있습니다.
import requests
from bs4 import BeautifulSoup
from urllib.parse import urljoin
def read_tistory(url: str):
html = requests.get(url, timeout=15).text
soup = BeautifulSoup(html, "lxml")
# 제목: h1/h2 등 후보에서 먼저 잡기(스킨마다 다름)
title_el = soup.select_one("h1, h2")
title = title_el.get_text(strip=True) if title_el else ""
# 본문: article/entry-content 등 후보
content_el = soup.select_one("article, .entry-content, .tt_article_useless_p_margin")
# 이미지: srcset/data-src/src 우선순위
images = []
if content_el:
for img in content_el.select("img"):
src = img.get("data-src") or img.get("srcset") or img.get("src") or ""
if not src:
continue
# 상대경로면 절대경로로 변환
images.append(urljoin(url, src.split()[0]))
content_html = str(content_el) if content_el else ""
return title, content_html, images
2-2. 네이버 에디터 본문 입력(가장 큰 난관)
네이버 글쓰기 페이지는 iframe 구조 + contenteditable 기반이라, “텍스트 박스에 값만 넣으면 끝”이 아니었습니다. 포커스가 실제 본문에 맞지 않으면 입력이 씹히거나 일부가 잘리는 현상이 발생했고, 프레임/노드 탐색을 단계적으로 강화해야 했습니다.
- 프레임 탐색:
mainFrame→ child frame 중 editor 후보 탐색 - 대상 선택:
[contenteditable='true']후보가 여러 개라, 화면에서 가장 큰(면적이 큰) 영역을 선택 - 삽입 전략 체인: paste →
document.execCommand('insertHTML')→ Range 삽입 순으로 시도 후 실패하면 텍스트 모드 폴백
2-2-1. 코드 예시: contenteditable에 insertHTML로 “한 번에” 넣기
문자 하나씩 타이핑하는 방식은 느리고(그리고 중간에 포커스가 튀면 잘리기 쉽습니다), 가능한 경우 insertHTML로 한 번에 넣는 게 훨씬 안정적이었습니다.
// playwright evaluate에 파라미터로 html을 전달(문자열 이스케이프 문제 방지)
(editorSelector, html) => {
const el = document.querySelector(editorSelector);
if (!el) return { ok: false, reason: "no-editor" };
el.scrollIntoView({ block: "center" });
el.focus();
// 기존 내용 정리(선택)
el.innerHTML = "";
// insertHTML 시도
const ok = document.execCommand("insertHTML", false, html);
// 이벤트로 변경 통지(일부 에디터는 필요)
el.dispatchEvent(new Event("input", { bubbles: true }));
el.dispatchEvent(new Event("change", { bubbles: true }));
return { ok, method: "insertHTML", textLen: el.innerText.length, htmlLen: el.innerHTML.length };
}
2-3. HTML sanitize 전략
네이버 에디터는 일부 태그/속성을 잘라내거나 무시할 수 있어서, 허용 태그를 최소화하고, 줄바꿈이 사라지지 않게 <p> 단위로 내용 블럭을 정리했습니다. (div/span은 풀고, 위험 태그는 제거)
2-3-1. 코드 예시: 네이버 입력용 sanitize(개념)
핵심은 “위험 태그 제거 + div/span 풀기 + 줄바꿈 보존(p/br 중심)”입니다.
from bs4 import BeautifulSoup
def sanitize_for_naver(raw_html: str) -> str:
soup = BeautifulSoup(raw_html, "lxml")
# 제거 대상
for bad in soup.select("script, style, iframe"):
bad.decompose()
# div/span은 풀어서 텍스트 흐름 유지
for tag in soup.select("div, span"):
tag.unwrap()
# a는 텍스트만(선택)
for a in soup.select("a"):
a.unwrap()
# 줄바꿈이 너무 뭉개지지 않도록: 빈 줄은 p로 유지하는 편이 안전
# (프로젝트에 맞게 규칙 확장 가능)
return str(soup)
2-4. 이미지: 외부 링크 대신 “다운로드 후 업로드”
처음엔 <img src="https://..."> 형태로 그대로 넣으려 했지만, 정책/보안/혼합콘텐츠 이슈 등으로 에디터에서 이미지가 안 보이거나 유실되는 경우가 있었습니다. 그래서 이미지를 로컬에 저장한 뒤 네이버 업로드 UI를 통해 올리는 방식으로 방향을 고정했습니다.
2-5. 처리 상태: SQLite로 체크포인트
자동화의 진짜 생산성은 “다음에 다시 돌렸을 때 어디부터 이어갈 수 있는가”에서 나옵니다. 처리 완료된 tistory_post_id를 기록해 스킵하고, 비정상 글(제목/본문이 비어있거나 로딩 실패)은 넘어가며, 정상 글을 찾은 다음에만 네이버 로그인을 진행하도록 순서를 바꿨습니다.
2-5-1. 코드 예시: 이미 처리된 글 스킵 + 성공 시 기록
import sqlite3
from datetime import datetime
def ensure_table(con):
con.execute("""
CREATE TABLE IF NOT EXISTS post_map(
id INTEGER PRIMARY KEY AUTOINCREMENT,
tistory_post_id TEXT NOT NULL,
naver_written_at TEXT NOT NULL,
tistory_url TEXT,
created_at TEXT
)""")
def is_done(con, post_id: str) -> bool:
row = con.execute("SELECT 1 FROM post_map WHERE tistory_post_id=? LIMIT 1", (post_id,)).fetchone()
return row is not None
def mark_done(con, post_id: str, url: str):
ts = datetime.now().strftime("%Y%m%d%H%M%S")
con.execute(
"INSERT INTO post_map(tistory_post_id, naver_written_at, tistory_url, created_at) VALUES (?,?,?,?)",
(post_id, ts, url, ts),
)
con = sqlite3.connect("tistory_to_naver.db")
ensure_table(con)
post_id = "9"
if is_done(con, post_id):
print("skip: already done")
else:
# ... 네이버 입력 성공 후 ...
with con:
mark_done(con, post_id, "https://billcorea.tistory.com/9")
오류 #1. “본문 contenteditable 없음”
- 증상:
Frame.evaluate에서 본문 노드를 찾지 못해 예외 발생 - 원인: 프레임이
about:blank이거나, 실제 본문 프레임이 다른 위치에 로드됨 - 대응: 프레임 URL 기반 필터 + page 전체 프레임을 훑는 fallback 추가
오류 #2. 클릭 타임아웃(요소가 viewport 밖)
- 증상:
Locator.click: Timeout, 로그에 “element is outside of the viewport” - 원인: contenteditable 후보 중 실제 편집 영역이 아닌(aria-hidden 등) 노드를 잡았거나, 스크롤/포커스가 안 맞음
- 대응: 후보 중 면적이 큰 요소를 선택하고,
scrollIntoView({block:'center'})로 중앙 정렬 후focus()
오류 #3. evaluate SyntaxError (Unexpected token / Invalid token)
- 증상:
Frame.evaluate: SyntaxError혹은ElementHandle.evaluate에서 토큰 에러 - 원인: JS 문자열/템플릿에 HTML이 직접 섞이거나, 이스케이프 처리가 부족할 때 발생
- 대응: HTML을 JS 코드에 끼워넣지 않고 evaluate 파라미터로 전달(
{html, selector})
오류 #4. insert 성공 로그인데 에디터가 비어 보임
- 증상: 로그는
method=insertHTML인데 화면엔 공백 - 원인: 사용자가 보는 편집 영역(가시 DOM)과 실제 입력 대상이 다를 수 있음 / 에디터 내부 상태 갱신이 비동기
- 대응: 삽입 전/후
innerText / innerHTML길이를 측정하고, 0ms 딜레이 후 재측정(비동기 갱신 대응). 그래도 실패하면 텍스트 모드로 폴백
오류 #5. 이미지 업로드가 “몇 개만 반영되고 멈춤”
- 증상:
set_input_files는 성공인데, UI에 업로드가 끝나기 전에 흐름이 꼬이거나 일부만 반영 - 원인: 네이버 업로드는 비동기이며, 업로드 완료 신호를 기다리지 않으면 다음 단계가 막힘
- 대응: 이번 단계의 목표를 “업로드 UI를 열고 파일 선택까지”로 좁혔다(최종 배치는 사람이 검수). 또한 실행 전 out_dir을 정리해 이전 이미지 혼입을 방지
4) 결론
- 네이버 글쓰기 자동화는 “프레임 + contenteditable + UI 비동기” 때문에 단순한 입력 자동화보다 변수가 많았습니다.
- 외부 이미지 링크는 정책/보안 이슈가 있어, 다운로드 후 업로드가 가장 재현성이 높았습니다.
- 완전 자동을 욕심내기보다, 로그인/최종 편집은 사람, 반복 작업은 자동화하는 “반자동”이 실용적입니다.
- SQLite로 처리 기록을 남기니, 중간에 실패해도 다음 실행에서 깔끔하게 이어갈 수 있었습니다.
5) 향후 계획
- 업로드 완료 대기: 업로드 진행/완료를 UI 요소(진행바, 알림 토스트, 첨부 리스트)로 감지해서 신뢰성 있게 대기하기
- 이미지 위치 매핑: 티스토리 본문 내 이미지 위치를 block 단위로 분해해, 업로드된 이미지가 같은 위치로 들어가도록 개선하기
- 스키마 확장:
post_map에 네이버 글 URL, 실패 사유, 재시도 횟수 등을 저장해 운영 관점의 로그로 발전시키기 - sanitize 고도화: 표/인용/코드블럭 등 복잡한 레이아웃을 네이버가 선호하는 구조로 변환하는 룰 추가
- 안전장치: “이번 실행에서 다운받은 이미지”만 업로드하도록 out_dir을 세션 단위로 분리(예: 실행 타임스탬프 폴더)
부록: DB에서 마지막 1건 삭제(테스트/롤백용)
작업 도중 잘못 기록된 마지막 1건을 되돌리고 싶을 때, “마지막 레코드 1건 삭제” 스크립트가 유용했습니다. (삭제 전 자동 백업 권장)
python naver_blog/260209_deleteOne.py --dry-run
python naver_blog/260209_deleteOne.py
부록: 이미지 다운로드(로컬 저장) 코드 예시
외부 이미지 링크는 에디터에서 누락되는 경우가 있어, 원본 URL 목록을 받아 로컬(out_dir)에 저장해 두고 업로드 입력으로 넘기는 방식이 가장 안전했습니다.
import os
import re
import hashlib
from pathlib import Path
from urllib.parse import urlparse
import requests
def _safe_filename_from_url(url: str, default_ext: str = ".jpg") -> str:
# URL path에서 파일명 추출, 없으면 해시 기반 이름
path = urlparse(url).path
name = os.path.basename(path) or ""
# 확장자 보정
if not re.search(r"\.(png|jpe?g|gif|webp|bmp)$", name, re.I):
name = name + default_ext
# 파일명 안전화
name = re.sub(r"[^0-9A-Za-z._-]+", "_", name)
if len(name) < 5:
h = hashlib.sha1(url.encode("utf-8")).hexdigest()[:12]
name = f"img_{h}{default_ext}"
return name
def download_images(image_urls: list[str], out_dir: str) -> list[str]:
out = Path(out_dir)
out.mkdir(parents=True, exist_ok=True)
saved_paths: list[str] = []
for url in image_urls:
try:
r = requests.get(url, stream=True, timeout=30)
r.raise_for_status()
fname = _safe_filename_from_url(url)
dst = out / fname
# 중복 방지: 같은 이름이 있으면 해시 붙이기
if dst.exists():
h = hashlib.sha1(url.encode("utf-8")).hexdigest()[:8]
stem, ext = os.path.splitext(fname)
dst = out / f"{stem}_{h}{ext}"
with open(dst, "wb") as f:
for chunk in r.iter_content(chunk_size=1024 * 64):
if chunk:
f.write(chunk)
saved_paths.append(str(dst))
except Exception as e:
print(f"[download] fail url={url} err={e}")
return saved_paths
부록: Playwright에서 에디터 프레임/편집영역 찾기(개념)
네이버 글쓰기 화면은 프레임 구조가 바뀌거나 contenteditable 후보가 여러 개 나올 수 있어서, “프레임 순회 + 가시 영역 후보 중 가장 큰 요소 선택”이 도움이 됐습니다.
from playwright.sync_api import Page, Frame
def find_editor_frame(page: Page) -> Frame:
# URL 힌트가 있으면 우선 사용(환경마다 다를 수 있어 fallback 필요)
for f in page.frames:
if "PostWriteForm" in (f.url or ""):
return f
return page.main_frame
def pick_best_contenteditable(frame: Frame, selector: str = "[contenteditable='true']") -> str:
# 여러 후보 중 화면에서 가장 큰(면적이 큰) 요소를 고르는 개념
return frame.evaluate("""
(sel) => {
const nodes = Array.from(document.querySelectorAll(sel));
if (!nodes.length) return null;
const cand = nodes
.map(n => {
const r = n.getBoundingClientRect();
return { n, area: Math.max(0, r.width) * Math.max(0, r.height), visible: r.width > 0 && r.height > 0 };
})
.filter(x => x.visible)
.sort((a,b) => b.area - a.area);
if (!cand.length) return null;
// 선택된 노드에 data-attr로 마킹해 재선택 가능하게 함
cand[0].n.setAttribute("data-picked-editor", "1");
return "[data-picked-editor='1']";
}
""", selector)
def focus_and_insert_html(frame: Frame, editor_selector: str, html: str):
return frame.evaluate("""
({sel, html}) => {
const el = document.querySelector(sel);
if (!el) return { ok:false, reason:"no-editor" };
el.scrollIntoView({ block: "center" });
el.focus();
const ok = document.execCommand("insertHTML", false, html);
el.dispatchEvent(new Event("input", { bubbles: true }));
return { ok, method:"insertHTML", textLen: el.innerText.length };
}
""", {"sel": editor_selector, "html": html})
이 문서는 자동화 스크립트 작성 과정에서 실제로 겪은 이슈를 기반으로 작성한 회고입니다. 코드/페이지 구조는 네이버/티스토리 측 변경에 따라 달라질 수 있습니다.
'파이썬 스크립트' 카테고리의 다른 글
| 🐍 Python | Hugging Face 모델, 왜 요약을 못할까? (Base vs. Instruct 모델, 버전 충돌 해결기) (1) | 2025.11.09 |
|---|---|
| - 🐍 Python | CamelCase를 snake_case로 변환하고 SQLite에서 단일 row 조회하기 --- (0) | 2025.11.07 |
| 🐍 Python | Raspberry Pi에서 오픈소스 LLM으로 뉴스 요약기 만들기 --- (1) | 2025.11.05 |
| 🐍 Python | 문자열 처리와 xlwings로 엑셀 데이터 다루기 --- (0) | 2025.11.01 |
| 오늘의 개발일지: 웹 스크래핑 삽질에서 모듈화까지(ft Python) (1) | 2025.10.28 |