본문 바로가기

Tech

[Python] 노션(Notion)으로 티스토리 자동 포스팅 파이프라인 구축하기: GitHub Actions와 Playwright 삽질 기록

시작은 포기에서 시작됐다

글 쓰는 건 너무 편한데 티스토리로 옮길 때마다 미치는 줄 알았음
서식 다 깨지고, 이미지는 하나하나 다시 올려야 하고... 진짜 이거 할 바엔 차라리 티스토리에서 직접 쓰는 게 나을 것 같았음
그래서 찾아본 게 티스토리 API였는데...
티스토리는 글쓰기 API를 제공하지 않음 ㅋㅋㅋ
OAuth랑 글 목록 조회는 되는데 정작 글 작성/수정은 막혀있더라. 완전 포기 모드였음
근데 어느 날 브라우저 자동화라는 걸 알게 됐고, "이거면 되는 거 아니야?" 싶어서 Playwright 삽질을 시작함
결과적으로는 성공했고, 지금은 노션에서 상태만 바꾸면 자동으로 티스토리에 글이 올라감
이 과정에서 겪은 온갖 삽질들을 정리해봄

원하는 게 뭐였냐면

Pain Point:

  • 노션의 작성 경험은 포기 못함 (진짜 편함)
  • 근데 티스토리로 옮길 때마다 서식 깨지고 이미지 재업로드 지옥
  • 복붙 반복 작업으로 생산성 ㅈ됨

Goal:

  • One Source Multi Use: 노션에 글만 쓰고 상태값 변경하면 자동으로 티스토리 '공개 발행'
  • API 없으면 뚫어버리자: Playwright로 브라우저 자동화

Target:

  • 나같이 생산성 중시하는 개발자들
  • RPA나 브라우저 자동화 관심 있는 사람들

보스 0: 로그인부터 막혔다 (Authentication Hell)

본격적인 자동화 전에 제일 먼저 막힌 게 로그인이었음
"그냥 ID/PW 입력하면 되는 거 아냐?" 했는데... 카카오 로그인은 생각보다 훨씬 빡셌음
처음에 로그인시도하면 계속 타임아웃이 나길래 스크린샷을 떠보게 했더니 이렇게..창이 띄워진채로 기다리다가 그냥 끝난 것…

멈춰있는 나의 카카오 로그인..
멈춰있는 나의 카카오 로그인..

ID/PW 입력 방식이 실패한 이유

처음엔 당연히 page.fill()로 아이디랑 비번 넣게 했음. 로컬에서는 완벽하게 돌아감
근데 GitHub Actions에 올리자마자 지옥 시작됨
로컬 vs GitHub Actions 차이
GitHub Actions의 IP는 매번 바뀜 → 카카오 입장에선 완전 해킹 시도로 보임 → 2단계 인증(전화/이메일) 요구하거나 이미지 캡차 띄움 → 헤드리스 브라우저로는 절대 통과 불가

해결: 세션 주입(Session Injection)

정문(로그인 화면) 못 뚫으면 이미 통과한 사람의 출입증 빌려 쓰면 됨
핵심 원리:

  1. 내 로컬 PC(신뢰받는 기기)에서 정상 로그인
  1. 브라우저 인증 토큰(Cookie, LocalStorage) 덤프 떠서 state.json 저장
  1. 이 파일을 GitHub Actions에 전송
  1. 서버가 마치 내 PC인 것처럼 위장

간단해 보이지? 근데 이것도 삽질 3종 세트가 있었음

삽질 1: 하루만 살고 죽는 세션

현상:state.json 만들고 성공했는데 다음 날 아침이면 "로그인 풀렸습니다" 에러 뜸
원인: 로그인할 때 [로그인 상태 유지] 체크박스를 안 눌렀음 → 브라우저 닫으면 세션 파기되는 휘발성 쿠키만 생성됨
해결:

# get_session.py 실행 시 반드시 체크박스 활성화
# 최소 30일간 유효한 영구 토큰 발급받기

삽질 2: "넌 아까 그 사람 아니잖아?" (User-Agent Mismatch)

현상: 쿠키 완벽하게 복사했는데도 서버에서 강제 로그아웃 시킴
원인: 티스토리가 세션 발급받은 브라우저랑 현재 접속한 브라우저 지문(Fingerprint) 대조함 로컬 PC(Chrome)랑 GitHub Actions(Headless Chromium)의 User-Agent가 달라서 위조로 걸림
해결:

# 로컬과 서버 모두 동일한 User-Agent 사용 필수
context = browser.new_context(
    user_agent="Mozilla/5.0 (Windows NT 10.0; ...) Chrome/122.0.0.0 Safari/537.36"
)

로컬이랑 서버가 완전 똑같은 브라우저인 척 연기하게 만듦

삽질 3: 권한 부족 (400 Bad Request)

현상: 로그인은 됐는데 글쓰기 페이지 가면 "잘못된 요청입니다" 에러
원인:tistory.com 통합 로그인 쿠키만 챙기고 실제 내 블로그(myblog.tistory.com)의 관리자 권한 쿠키는 안 만들어진 상태로 저장함 도메인 스코프 문제였음
해결: 세션 저장하기 직전에 반드시 관리자 페이지까지 진입해야 함

# 서브 도메인 권한 쿠키까지 확실하게 구워진 상태로 저장
page.goto(f"https://{blog_name}.tistory.com/manage")
context.storage_state(path="state.json")

결론: 왜 세션 주입을 선택했나

자동화의 핵심은 **"사람처럼 행동하는 것"**이 아니라 "시스템이 사람이라고 착각하게 만드는 것"
세션 주입 덕분에 GitHub Actions라는 낯선 환경에서도 당당하게 주인 행세하며 글 발행 가능해짐

어떻게 동작하는지

CI/CD 파이프라인이랑 비슷한 흐름으로 만들었음

[Notion 작성]
    ↓ (Status='Ready')
[GitHub Actions 트리거]
    ↓
[Python Script 실행]
    ├─ 1. Notion API로 콘텐츠 파싱 & HTML 변환
    ├─ 2. WordPress API로 이미지 업로드 & CDN 주소 확보
    └─ 3. Playwright로 티스토리 자동 로그인 & 글쓰기
    ↓
[Tistory 발행 완료]
    ↓
[Notion Status='Published'로 업데이트]

핵심 프로세스 3단계

1. Extract (Notion)

  • 노션 API로 페이지 블록 데이터 가져옴
  • HTML로 변환

2. Transform (Media)

  • 노션 임시 이미지 URL은 시간 지나면 엑박됨
  • 워드프레스 미디어 서버로 이관해서 영구 URL 확보

3. Load (Tistory)

  • Playwright로 티스토리 관리자 페이지 접속
  • HTML 주입하고 발행 버튼 클릭

삽질의 연속이었던 4대 보스전

보스 1: 에디터 주소 리다이렉트 문제 (400 Bad Request)

현상: 구형 에디터 주소(/manage/post/write)로 접근하면 리다이렉트 과정에서 세션 꼬여서 400 에러 뜸
 
해결: 그냥 내가 로그인 해서 새 에디터 직접 주소 찾아냄
AI가 자꾸 구주소를 알려줘서 문제였다… 역시 사람의 손맛이 제맛 ㅎ
리다이렉트 없이 바로 진입하니까 해결됨

보스 2: 유령 글 현상 (Ghost Content)

현상:innerHTML로 본문 강제 주입했는데 티스토리가 변경 사항을 못 느낌 제목만 있고 본문 없는 유령 글이 저장됨 ㅋㅋ
해결: 더미 타이핑 기법 사용

# 에디터 깨우기
frame.type("body#tinymce", " ")  # 스페이스 입력
frame.press("body#tinymce", "Backspace")  # 바로 지우기

이렇게 하니까 에디터가 "오 뭔가 입력됐네?" 하고 인식함

보스 3: 불안정한 클릭과 팝업 (Flaky Clicks)

현상:

  • [완료] 버튼 클릭 시 설정 레이어 뜨기 전에 다음 로직 넘어감
  • 맞춤법 검사 팝업이 가로막음

해결:

# 팝업 자동 수락
page.on("dialog", lambda dialog: dialog.accept())

# 레이어 뜰 때까지 재시도
max_retries = 3
for i in range(max_retries):
    try:
        layer = page.wait_for_selector(".layer", timeout=2000)
        break
    except:
        if i == max_retries - 1:
            raise

보스 4: 동적 클래스명과 클릭 차단 (The Final Boss)

현상:

  • '공개 발행' 버튼이 눈에 보이는데 클릭이 안됨
  • CSS Selector 맞는데도 타임아웃 발생
  • 투명 레이어가 버튼을 가리고 있는 듯

해결: 무식하게 JS 강제 클릭으로 때려박음

page.evaluate("""
    () => {
        const buttons = Array.from(document.querySelectorAll('button'));
        const target = buttons.find(b => b.innerText.includes('공개 발행'));
        if (target) target.click();
        else throw new Error("버튼 못 찾음");
    }
""")

Selector 의존 버리고 텍스트로 찾아서 직접 .click() 날림

핵심 코드 (제일 삽질 많이 한 부분)

티스토리 발행 함수 최종 완성본:

def publish_to_tistory(title, html_content):
    # ... (브라우저 초기화 생략) ...

    try:
        # 1. 새 에디터 직접 접속 (리다이렉트 회피)
        write_url = f"https://{blog_name}.tistory.com/manage/newpost/?type=post&returnURL=%2Fmanage%2Fposts%2F"
        page.goto(write_url, wait_until="domcontentloaded")

        # 2. 본문 주입 및 더미 타이핑 (에디터 깨우기)
        frame = page.wait_for_selector("iframe#editor-tistory_ifr").content_frame()
        frame.evaluate(
            f'(html) => {{ document.querySelector("body#tinymce").innerHTML = html; }}',
            html_content
        )

        # [핵심] Change 이벤트 강제 발생
        frame.type("body#tinymce", " ")
        frame.press("body#tinymce", "Backspace")

        # 3. JS 필살기: '공개 발행' 버튼 강제 클릭
        page.evaluate("""
            () => {
                const buttons = Array.from(document.querySelectorAll('button'));
                const target = buttons.find(b => b.innerText.includes('공개 발행'));
                if (target) target.click();
                else throw new Error("버튼을 찾을 수 없습니다.");
            }
        """)

        # 4. 발행 완료까지 대기
        page.wait_for_url(lambda url: "entry" in url or "manage/posts" in url)
        print("발행 성공!")

    except Exception as e:
        page.screenshot(path="error.png")  # 디버깅용
        raise e

앞으로 해야 할 것들

1. 썸네일 자동 지정

  • 대표이미지를 자동으로 설정해주는 것도 필요함

2. 실패 알림 연동

  • GitHub Actions 실패하면 슬랙이나 카카오톡으로 알림
  • 지금은 실패해도 모르고 있음 ㅋㅋ

3. 태그 /카테고리 자동으로 넣어주기

  • Wordpress같은 경우에는 넣어주고 있는데, 여긴 아직 안했다.
  1. 세션 정기 유지
  • 일단 자동 로그인 유지로 한동안 세션은 유지되겠지만, 30일마다 state.json파일을 갱신해줘야한다. 주기적으로 알림을 주던.. 뭔가 조금 더 생명연장을 정기적으로 해줄 수 있는 과정을……

로그인까지 오기가 너무나 힘든 과정이었기에..
이부분은 차차 넣을 예정.
 
사실 이미 워드프레스는 API로 자동화 연결을 해놔서 어느정도 삽질을 통해 좀 정상화 시켰기 때문에 그나마 수월했다.
이놈도 이미지 사이즈 조정 등 곤란한 상황이 많았음…

배운 것들

이 프로젝트 하면서 깨달은 것:

  1. "보이는 게 전부가 아니다": UI 자동화는 생각보다 훨씬 복잡함. 클릭 가능해 보이는데 안 되는 경우 진짜 많음
  1. "안 되면 되게 하라": API 없으면 JS Injection으로 뚫어버리면 됨. 방법은 항상 있음
  1. "디버깅은 스크린샷": 에러 나면 스크린샷 찍어두는 게 진짜 도움 많이 됨. AI도 모든걸 알지는 못하기 때문에 리다이렉션 주소를 구 주소로 찾아오는 등 약간의 오류가 있기 때문에 이 부분을 바로 잡아주려면 내가 스크린샷 보고 어디서 막히는지 해보는게 더 빠를 수 있다.

 
브라우저 레벨에서 제어권 확보하는 과정은 고통스러웠지만 확실히 성장한 느낌
API 없다고 포기할 필요 없음. 브라우저 자동화라는 무기가 있으니까

이게 또 언제까지 가능할지 모르겠지만, 인간은 언제나 그렇듯 답을 찾을 것…..*

반응형