Claude Code + Playwright로 네이버 블로그 자동 업로드 시스템 만들기
AI가 글을 쓰고, 이미지를 처리하고, 에디터를 조작해서 블로그 포스팅까지 자동화한 과정을 기록합니다.
목차
프로젝트 개요
블로그를 운영하다 보면 반복적인 작업이 많다. 사진 정리, 글 작성, 에디터에 하나하나 붙여넣기, 이미지 업로드, 태그 입력... 특히 맛집 리뷰 같은 정형화된 글은 패턴이 비슷한데 매번 같은 노동을 반복해야 한다.
이 프로젝트는 사진만 바탕화면에 넣어두면, AI가 질문하고 글을 쓰고 네이버 블로그에 업로드까지 해주는 시스템이다.
전체 워크플로우
사진 저장 → Claude Code 실행 → 질문 응답 → AI 글 생성 → 이미지 처리 → 네이버 에디터 자동 조작 → 임시저장
한 번 실행하면 약 2~3분 안에 텍스트와 이미지가 교차 배치된 블로그 글이 임시저장된다.
시스템 아키텍처
┌─────────────────────────────────────────────────────┐
│ Claude Code CLI │
│ ┌─────────────┐ ┌──────────────┐ ┌────────────┐ │
│ │ /blog-post │→ │ 톤 프로파일 │→ │ AI 글 생성 │ │
│ │ (Skill) │ │ 로드 │ │ (blocks) │ │
│ └─────────────┘ └──────────────┘ └────────────┘ │
│ │ │ │
│ ┌──────▼──────┐ ┌──────▼─────┐ │
│ │ 이미지 스캔 │ │ JSON 저장 │ │
│ │ + AI 분석 │ │ (post) │ │
│ └─────────────┘ └────────────┘ │
└──────────────────────────────┬──────────────────────┘
│
┌──────────▼──────────┐
│ upload_naver.py │
│ (Playwright 자동화) │
├─────────────────────┤
│ • 세션 관리 │
│ • 에디터 열기 │
│ • 장소 위젯 삽입 │
│ • 텍스트/이미지 교차│
│ • 대표이미지 설정 │
│ • 임시저장/발행 │
└─────────────────────┘
│
┌──────────▼──────────┐
│ image_utils.py │
├─────────────────────┤
│ • HEIC → JPEG 변환 │
│ • EXIF 회전 보정 │
│ • 가로 이미지 합성 │
│ • 리사이즈 │
└─────────────────────┘
기술 스택
| 기술 | 용도 |
|---|---|
| Python 3.11+ | 메인 언어 |
| Playwright | 네이버 SmartEditor ONE 브라우저 자동화 |
| playwright-stealth | 봇 탐지 우회 |
| Pillow + pillow-heif | 이미지 처리 (HEIC 변환, 합성, EXIF) |
| BeautifulSoup4 | 기존 블로그 글 크롤링 |
| Claude Code | AI 글 생성 + 전체 워크플로우 오케스트레이션 |
| pyperclip | 클립보드를 통한 한글 텍스트 입력 |
핵심 구현 상세
1단계: 블로그 톤 프로파일링
블로그마다 고유한 어투가 있다. AI가 글을 쓰려면 기존 글의 스타일을 먼저 학습해야 한다.
크롤링 (crawl_blog.py)
네이버 블로그의 모바일 버전(m.blog.naver.com)에서 최근 글을 가져온다. 네이버 블로그는 SSR(Server-Side Rendering) 데이터를 __clipContent 변수에 JSON으로 내려주기 때문에, HTML 파싱과 JSON 추출 두 가지 방법을 병행한다.
# SSR JSON 추출 (HTML 파싱 실패 시 폴백)
script_tags = soup.find_all("script")
for tag in script_tags:
if "__clipContent" in str(tag.string or ""):
match = re.search(r'__clipContent\s*=\s*({.*?});', tag.string, re.DOTALL)
if match:
data = json.loads(match.group(1))
크롤링한 글은 cache/crawled_posts/에 JSON으로 캐싱해서, 매번 네트워크 요청을 보내지 않도록 했다.
어투 분석 (extract_tone.py)
크롤링한 글에서 통계 기반 + AI 기반 이중 분석을 수행한다.
통계 분석 (Python):
- 문장 평균 길이, 단락 평균 길이
- 문장 끝 패턴 분류 (반말체/해요체/합쇼체/명사형/감탄형)
- 이모지 사용 빈도
- 특수문자(ㅋㅎㅠ 등) 사용 빈도
# 문장 끝 패턴 정규식으로 어투 분류
ENDING_PATTERNS = {
"해요체": re.compile(r'(해요|에요|이에요|예요|네요|세요|죠|거든요|는데요)[\.\!\?]?\s*$'),
"합쇼체": re.compile(r'(합니다|입니다|습니다|됩니다|겠습니다)[\.\!\?]?\s*$'),
"반말체": re.compile(r'(이다|한다|된다|있다|없다|같다|좋다|싶다)[\.\!\?]?\s*$'),
...
}
AI 분석 (Claude):
대표 발췌문 5개를 Claude에게 전달해서 정성적 분석을 받는다. 이 분석 결과가 tone_profile.json에 저장되고, 이후 글 생성 시 프롬프트에 주입된다.
{
"claude_analysis": {
"tone": "친근하고 솔직한 반말체",
"sentence_style": "짧고 간결한 문장 위주",
"vocabulary": "구어체 위주 (딱, 진짜, 꽤, 정말 등)",
"structure": "식당이름 → 기본정보 → 방문계기 → 분위기 → 메뉴 → 상세리뷰 → 조합추천 → 총평",
"unique_traits": [
"항상 영업시간/브레이크타임/주차 정보를 글 시작 부분에 배치",
"음식 조합 추천이 특징적",
"솔직한 평가 (아쉬운 점도 언급)"
]
}
}
분석 결과 예시: 반말체 87.5%, 해요체 12.5%, 이모지 0개, 평균 문장 34.4자.
2단계: AI 글 생성 파이프라인
Claude Code의 Custom Slash Command(/blog-post) 기능을 활용해서 대화형 글 생성 파이프라인을 구축했다.
카테고리별 4단계 질문
맛집 리뷰 기준으로 질문 흐름:
1차 (기본 정보) → 식당 이름, 위치, 메뉴
2차 (경험 상세) → 방문 계기, 웨이팅, 분위기
3차 (맛 상세) → 메뉴별 맛/식감/비주얼, 최고 메뉴
4차 (마무리) → 가성비, 재방문 의사, 참고 정보
사용자가 모르는 정보(영업시간, 주차 등)는 웹 검색으로 자동 보충한다.
이미지 AI 분석
바탕화면에서 최근 48시간 이내 사진을 스캔한 후, Claude의 멀티모달 기능으로 각 이미지를 직접 확인한다. "이건 메뉴판 사진", "이건 수육 클로즈업", "이건 전체 상차림" 같은 판단을 AI가 해서 텍스트와 관련된 위치에 자동 배치한다.
Blocks 형식 출력
글은 텍스트와 이미지가 교차되는 blocks 형식으로 생성된다:
{
"title": "[김화칼국수 대전] 대전역 수육 비빔국수 가성비 맛집",
"category": "맛집일지",
"tags": ["김화칼국수", "대전맛집", "대전역맛집", ...],
"thumbnail": "/path/to/representative_image.jpeg",
"place": "김화칼국수 대전",
"blocks": [
{"type": "text", "content": "김화칼국수\n\n영업시간 09:00 - 20:00..."},
{"type": "image", "paths": ["/path/to/img1.jpeg", "/path/to/img2.jpeg"]},
{"type": "text", "content": "대전 여행 중에 방문한 김화칼국수..."},
{"type": "image", "paths": ["/path/to/img3.jpeg"]},
...
]
}
이 JSON이 업로드 스크립트의 입력이 된다.
3단계: 이미지 처리 파이프라인
HEIC → JPEG 변환
아이폰으로 찍은 사진은 HEIC 포맷이다. pillow-heif로 자동 변환한다.
import pillow_heif
pillow_heif.register_heif_opener()
img = Image.open(heic_path)
img = img.convert("RGB")
img.save(output_path, "JPEG", quality=85)
EXIF 회전 보정
스마트폰 사진은 EXIF 메타데이터에 회전 정보가 들어있다. 이걸 무시하고 이미지를 처리하면 사진이 90도 회전되어 합성된다.
from PIL import ImageOps
img = Image.open(path)
img = ImageOps.exif_transpose(img) # EXIF 회전 정보를 실제 픽셀에 적용
이 한 줄을 빠뜨려서 합성된 이미지가 전부 90도 돌아가는 버그가 있었다. ImageOps.exif_transpose()는 이미지 처리 시 반드시 초기에 호출해야 한다.
가로 이미지 합성 (Stitching)
블로그에서 2장의 사진을 나란히 보여주고 싶을 때, 별도 이미지로 올리면 위아래로 배치된다. 그래서 2장 이상의 이미지를 가로로 합쳐서 한 장으로 만드는 기능을 구현했다.
def stitch_images_horizontally(image_paths, output_path, gap=10, max_height=800):
images = []
for p in image_paths:
img = Image.open(p)
img = ImageOps.exif_transpose(img)
img = img.convert("RGB")
images.append(img)
# 가장 작은 높이 기준으로 통일 (비율 유지)
target_h = min(img.height for img in images)
target_h = min(target_h, max_height)
resized = []
for img in images:
ratio = target_h / img.height
new_w = int(img.width * ratio)
resized.append(img.resize((new_w, target_h), Image.LANCZOS))
# 캔버스에 이미지 나란히 배치
total_w = sum(img.width for img in resized) + gap * (len(resized) - 1)
canvas = Image.new("RGB", (total_w, target_h), (255, 255, 255))
x_offset = 0
for img in resized:
canvas.paste(img, (x_offset, 0))
x_offset += img.width + gap
canvas.save(output_path, "JPEG", quality=85)
핵심 포인트:
- 높이를 통일해서 비율을 맞춤 (가장 작은 높이 기준)
- 이미지 사이에 10px 흰색 간격 추가
- LANCZOS 리샘플링으로 품질 유지
4단계: Playwright 브라우저 자동화
이 프로젝트에서 가장 어려운 부분이다. 네이버 SmartEditor ONE은 SPA(Single Page Application) 기반의 리치 에디터로, 일반적인 Playwright locator.click()으로는 동작하지 않는 요소가 많다.
세션 관리 (Persistent Context)
Playwright의 launch_persistent_context를 사용해서 브라우저 세션을 유지한다. 한 번 로그인하면 다음 실행 시에는 세션이 살아있어서 재로그인이 필요 없다.
context = await playwright.chromium.launch_persistent_context(
user_data_dir=str(BROWSER_STATE_DIR), # cache/browser_state/
headless=False,
viewport={"width": 1280, "height": 900},
locale="ko-KR",
timezone_id="Asia/Seoul",
user_agent="Mozilla/5.0 ...",
args=["--disable-blink-features=AutomationControlled"],
)
playwright-stealth도 적용해서 네이버의 봇 탐지를 우회한다.
텍스트 입력: 클립보드 방식
SmartEditor ONE에서 page.fill()이나 page.type()으로는 한글이 제대로 입력되지 않는다. 클립보드를 통한 붙여넣기가 가장 안정적이다.
import pyperclip
pyperclip.copy(text)
await page.keyboard.press("Meta+v") # macOS: Cmd+V
단락 구분은 Enter 키로 처리한다.
이미지 업로드: file input 주입
에디터의 사진 버튼을 클릭하면 file input이 생성된다. Playwright의 set_input_files()로 파일 경로를 주입한다.
photo_btn = page.locator("button.se-image-toolbar-button")
await photo_btn.first.click()
await asyncio.sleep(1)
file_input = page.locator("input[type='file']")
await file_input.first.set_input_files(img_path)
await asyncio.sleep(3)
# 확인 버튼 클릭
insert_btn = page.locator("button.se-popup-button-confirm")
await insert_btn.first.click()
삽질 기록: SmartEditor ONE과의 전쟁
네이버 SmartEditor ONE 자동화에서 겪은 문제들과 해결 과정을 기록한다. 이 부분이 이 프로젝트에서 가장 많은 시간을 쓴 곳이다.
문제 1: 장소 위젯의 숨겨진 "추가" 버튼
네이버 지도 와이드형 위젯을 본문에 삽입하는 기능을 구현할 때, 검색 결과에서 장소를 선택하는 과정이 가장 어려웠다.
증상: 장소를 검색하면 결과가 나오는데, 결과를 클릭해도 "확인" 버튼이 활성화되지 않음.
디버깅 과정:
1단계 - 셀렉터가 틀렸다:
// 이건 국내/해외 드롭다운 옵션이었다 (rect: 0,0,0,0)
document.querySelector('li.se-map-place-item')
// 실제 검색 결과는 이 클래스였다
document.querySelector('li.se-place-map-search-result-item')
evaluate()로 팝업 내부 DOM 구조를 덤프해서 발견했다.
2단계 - 결과 항목 클릭이 안 됨:
// 검색 결과 내부 구조 디버깅
{
"children": [
{"tag": "STRONG", "text": "김화칼국수", "rect": {"x": 250, "y": 241, "w": 210, "h": 17}},
{"tag": "SPAN", "text": "대전광역시 동구...", "rect": {"x": 250, "y": 261, ...}},
{"tag": "SPAN", "class": "se-place-map-search-add-button-text",
"text": "추가", "rect": {"x": 0, "y": 0, "w": 0, "h": 0}} // 숨겨져 있다!
]
}
"추가" 버튼이 rect: (0, 0, 0, 0) — hover 시에만 나타나는 버튼이었다.
3단계 - 해결: hover → click 2단계 접근:
# 1. 검색 결과 항목 좌표를 JS로 가져옴
item_rect = await page.evaluate("""() => {
const item = document.querySelector('li.se-place-map-search-result-item');
if (!item) return null;
const r = item.getBoundingClientRect();
return r.width > 0 ? {x: r.x, y: r.y, w: r.width, h: r.height} : null;
}""")
# 2. 마우스를 결과 항목 위로 이동 (hover)
await page.mouse.move(cx, cy)
await asyncio.sleep(1) # hover 애니메이션 대기
# 3. hover 후 "추가" 버튼 좌표를 다시 가져옴 (이제 보인다)
add_rect = await page.evaluate("""() => {
const btn = document.querySelector('.se-place-map-search-add-button-text');
const parent = btn.closest('button') || btn;
const r = parent.getBoundingClientRect();
return r.width > 0 ? {x: r.x, y: r.y, w: r.width, h: r.height} : null;
}""")
# 4. "추가" 버튼 클릭
await page.mouse.click(ax, ay)
핵심 교훈: Playwright의 locator.click()이 안 되는 경우, page.evaluate()로 getBoundingClientRect()를 가져온 후 page.mouse.move() + page.mouse.click()으로 실제 브라우저 마우스 이벤트를 보내야 한다. dispatchEvent()로 합성 이벤트를 보내는 건 네이버 에디터의 내부 이벤트 핸들러를 트리거하지 못했다.
문제 2: 대표이미지 설정 시 잘못된 버튼 클릭
증상: 대표이미지 설정이 "완료"라고 출력되지만, 실제로는 항상 첫 번째 이미지가 대표로 설정됨.
원인: 에디터에는 이미지마다 "대표" 버튼이 하나씩 붙어있다. 전체 DOM에서 button:has-text('대표')로 검색하면 모든 이미지의 대표 버튼이 매칭된다.
// 디버깅으로 발견한 두 개의 "대표" 버튼
[
{text: '대표', class: 'se-set-rep-image-button se-is-selected',
rect: {x: 205, y: -2179, w: 59, h: 28}}, // 1번째 이미지 (화면 밖, 이미 선택됨)
{text: '대표', class: 'se-set-rep-image-button',
rect: {x: 205, y: 235, w: 59, h: 28}} // 3번째 이미지 (화면 안, 우리가 원하는 것)
]
.first가 DOM 순서상 첫 번째(y=-2179, 화면 밖) 버튼을 선택하고 있었다.
해결: 뷰포트 내에 보이는 버튼만 JS로 필터링:
repr_rect = await page.evaluate("""() => {
const buttons = document.querySelectorAll('button.se-set-rep-image-button');
for (const btn of buttons) {
const r = btn.getBoundingClientRect();
// 뷰포트 내에 있는 버튼만 (y > 0 && y < innerHeight)
if (r.width > 0 && r.y > 0 && r.y < window.innerHeight) {
return {x: r.x, y: r.y, w: r.width, h: r.height};
}
}
return null;
}""")
await page.mouse.click(
repr_rect["x"] + repr_rect["w"] / 2,
repr_rect["y"] + repr_rect["h"] / 2,
)
핵심 교훈: SPA 에디터에서 동일한 텍스트/클래스를 가진 버튼이 여러 개 존재할 수 있다. DOM 순서 대신 뷰포트 좌표 기반으로 원하는 요소를 필터링해야 한다.
문제 3: EXIF 회전 정보 무시
증상: 세로로 찍은 사진 2장을 가로로 합성하면, 합성된 이미지가 90도 회전되어 저장됨.
원인: 스마트폰은 가속도 센서로 촬영 방향을 감지하고, 실제 픽셀 데이터는 그대로 두고 EXIF 메타데이터의 Orientation 태그에 회전 정보를 기록한다. 갤러리 앱에서는 이 태그를 읽어서 자동 회전해주지만, Image.open()으로 열면 원본 픽셀 데이터가 그대로 나온다.
해결:
from PIL import ImageOps
img = Image.open(path)
img = ImageOps.exif_transpose(img) # 이 한 줄이 핵심
ImageOps.exif_transpose()는 EXIF Orientation 태그를 읽고, 실제 픽셀 데이터에 회전/반전을 적용한 후, Orientation 태그를 1(정상)로 리셋한다.
문제 4: 팝업 오버레이가 다음 동작을 차단
증상: 장소 위젯 삽입 후 이미지 업로드 버튼을 클릭하면 se-popup-dim이 pointer events를 가로챔.
<div class="se-popup-dim se-popup-dim-transparent"></div> from <div>…</div>
subtree intercepts pointer events
원인: 장소 팝업이 정상적으로 닫히지 않으면 반투명 dim 레이어가 남아서 모든 클릭을 가로챈다.
해결: 팝업 닫기 실패 시 JS로 강제 제거:
async def _close_place_popup(page):
# 방법 1: 닫기 버튼
close_btn = page.locator("div.se-popup-placesMap button.se-popup-close-button")
if await close_btn.count() > 0:
await close_btn.first.click(force=True)
return
# 방법 2: JS로 팝업 + dim 레이어 강제 제거
await page.evaluate("""() => {
const popup = document.querySelector('div.se-popup-placesMap');
if (popup) popup.remove();
document.querySelectorAll('.se-popup-dim').forEach(el => el.remove());
}""")
프로젝트 구조
blog-auto-upload-system/
├── config/
│ ├── .env # 네이버 계정 정보 (NAVER_ID, NAVER_PW, BLOG_ID)
│ └── categories.json # 블로그 카테고리 목록
│
├── scripts/
│ ├── crawl_blog.py # 기존 블로그 글 크롤링 (톤 학습용)
│ ├── extract_tone.py # 어투 분석 (통계 + AI)
│ ├── scan_images.py # 바탕화면 이미지 스캔
│ ├── upload_naver.py # 네이버 블로그 업로드 (Playwright)
│ └── utils/
│ ├── image_utils.py # 이미지 처리 (HEIC, 합성, EXIF)
│ └── naver_auth.py # 로그인 + 세션 관리
│
├── templates/
│ └── blog_prompt.md # AI 글 생성 프롬프트 템플릿
│
├── cache/
│ ├── tone_profile.json # 학습된 어투 프로파일
│ ├── crawled_posts/ # 크롤링된 글 캐시
│ └── browser_state/ # Playwright 세션 데이터
│
├── output/
│ └── latest_post.json # 최근 생성된 글 (업로드용)
│
└── requirements.txt
주요 의존성
playwright>=1.40.0
playwright-stealth>=1.0.6
Pillow>=10.0.0
pillow-heif>=0.13.0
beautifulsoup4>=4.12.0
python-dotenv>=1.0.0
pyperclip>=1.8.2
실행 흐름 요약
# 1회성 초기 설정
pip install -r requirements.txt
playwright install chromium
# 톤 프로파일 생성 (최초 1회)
python3 scripts/crawl_blog.py --count 5
python3 scripts/extract_tone.py
# 블로그 글 작성 + 업로드 (매번)
# Claude Code에서:
/blog-post 바탕화면 사진으로 맛집 리뷰 작성
업로드 프로세스 (upload_naver.py)
1. ensure_login() → 세션 확인, 필요시 자동 로그인
2. open_editor() → 에디터 페이지 열기, 임시저장 팝업 처리
3. set_title() → 클립보드로 제목 입력
4. insert_place_widget() → 네이버 지도 와이드형 위젯 삽입
5. set_content_with_images() → 텍스트/이미지 교차 입력
├── _type_text_block() → 클립보드로 텍스트 입력
├── stitch_images() → 2장 이상이면 가로 합성
└── _insert_image_block() → 사진 버튼 → file input → 확인
6. set_category() → 카테고리 선택
7. set_thumbnail() → 대표이미지 설정 (뷰포트 기반 버튼 탐색)
8. save_draft() → 임시저장 (기본) / publish() → 발행
한계와 개선 방향
현재 한계
| 항목 | 설명 |
|---|---|
| 태그 자동 입력 | 임시저장 모드에서는 태그 패널에 접근 불가. 발행 모드에서만 동작 |
| 캡차/2FA | 자동 처리 불가. 브라우저에서 수동 완료 필요 |
| 이미지 순서 | 바탕화면 스캔 시 파일명 기준 정렬. AI가 내용 분석 후 재배치 |
| 셀렉터 안정성 | 네이버 에디터 업데이트 시 셀렉터가 변경될 수 있음 |
개선 방향
- Headless 모드: 현재 GUI 브라우저로 동작. headless로 전환하면 서버에서도 실행 가능
- 다중 블로그 지원: 환경변수를 프로파일로 관리하면 여러 블로그 운영 가능
- 스케줄링: cron이나 n8n으로 예약 발행 구현
- 네이버 공식 API: Open API로 전환하면 Playwright 의존성 제거 가능 (단, SmartEditor ONE의 위젯 기능은 API에서 지원하지 않음)
마무리
이 프로젝트의 핵심은 "AI가 글을 쓰는 것"보다 "네이버 SmartEditor ONE을 프로그래밍적으로 제어하는 것"이 훨씬 어려웠다는 점이다.
Playwright의 locator.click()이 안 되면 page.evaluate()로 DOM을 직접 조사하고, getBoundingClientRect()로 좌표를 가져와서 page.mouse로 실제 마우스 이벤트를 보내야 한다. 특히 hover 시에만 나타나는 요소, 뷰포트 밖에 있는 동일 클래스 요소, EXIF 회전 같은 "보이지 않는" 문제들이 대부분의 디버깅 시간을 차지했다.
결과적으로 사진을 바탕화면에 넣고 3분 정도 질문에 답하면 블로그 글이 자동으로 올라가는 시스템이 완성됐다. 코드는 GitHub에 공개되어 있다.
Tech Stack: Python 3.11 / Playwright / Pillow / Claude Code / BeautifulSoup4
Repository: https://github.com/min-yeong/blog-auto-upload-system