Today's

길을 나서지 않으면 그 길에서 만날 수 있는 사람을 만날 수 없다

파이썬 스크립트

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

Billcorea 2026. 2. 9. 22:34

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

블로그예시

 

티스토리에서 글을 읽어오고(제목/본문/이미지), 네이버 블로그 글쓰기 화면에 옮겨 적는 과정을 자동화하면서 겪었던 시행착오를 정리했습니다. 결론부터 말하면 “완전 자동”보다는 “반자동(로그인/최종 검수는 사람이)”이 현실적인 접근이었습니다.

기술 스택: Python, requests, BeautifulSoup4, lxml, Playwright(synchronous)
핵심 파일: naver_blog/tistory_scrape.py, naver_blog/260208_naverBot.py, naver_blog/tistory_to_naver.db
키워드: iframe / contenteditable / insertHTML / sanitize / 이미지 다운로드+업로드 / sqlite 체크포인트

1) 기획

목표는 단순했습니다. 티스토리 글을 네이버 블로그로 옮기되, 사람이 반복적으로 하는 작업(복사·붙여넣기, 이미지 저장/업로드, 처리 상태 관리)을 최대한 줄이는 것이었습니다.

요구사항(최소 기능)

  • 티스토리: 글 URL로부터 제목/본문/이미지 URL을 추출한다.
  • 이미지: 외부 링크를 그대로 두지 않고, 로컬에 내려받아 네이버 에디터에 업로드한다.
  • 네이버: 글쓰기 화면에서 본문과 제목을 자동 입력한다.
  • 진행상태 관리: 이미 처리한 글은 스킵하고 다음 글을 시도한다(SQLite).

흐름(아키텍처)

  1. readTistory(url)로 포스팅 파싱
  2. sanitize_for_naver(html)로 입력용 HTML 정리
  3. Playwright로 네이버 글쓰기 진입(로그인은 사람)
  4. 본문 입력: paste / insertHTML / range 삽입 등 여러 전략으로 “한 번에 넣기”
  5. 이미지: out_dir에 다운로드 → file input에 set_input_files로 일괄 업로드
  6. 성공 시 SQLite에 tistory_post_id + naver_written_at 저장
실제 운영 관점에선 “로그인 자동화”는 캡차/보안정책 때문에 리스크가 큽니다. 그래서 로그인은 수동(Enter로 진행)으로 두고, 그 이후만 자동화하는 방향이 가장 튼튼했습니다.

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) 향후 계획

  1. 업로드 완료 대기: 업로드 진행/완료를 UI 요소(진행바, 알림 토스트, 첨부 리스트)로 감지해서 신뢰성 있게 대기하기
  2. 이미지 위치 매핑: 티스토리 본문 내 이미지 위치를 block 단위로 분해해, 업로드된 이미지가 같은 위치로 들어가도록 개선하기
  3. 스키마 확장: post_map에 네이버 글 URL, 실패 사유, 재시도 횟수 등을 저장해 운영 관점의 로그로 발전시키기
  4. sanitize 고도화: 표/인용/코드블럭 등 복잡한 레이아웃을 네이버가 선호하는 구조로 변환하는 룰 추가
  5. 안전장치: “이번 실행에서 다운받은 이미지”만 업로드하도록 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})

이 문서는 자동화 스크립트 작성 과정에서 실제로 겪은 이슈를 기반으로 작성한 회고입니다. 코드/페이지 구조는 네이버/티스토리 측 변경에 따라 달라질 수 있습니다.

반응형