# naver_playwright.py 전면 리팩토링 V2: connectOverCDP + 클립보드 붙여넣기

## 작업 개요
기존 naver_playwright.py를 전면 리팩토링한다.
`chromium.launch()` → `chromium.connectOverCDP()`, `page.type()` → 클립보드 Ctrl+V, 단락별 복붙 + 스크롤 + 대기 방식으로 변경.

## 핵심 원칙
**사람이 실제로 블로그 글을 쓰는 행동 패턴을 100% 재현한다.**
글을 미리 작성해놓고, 단락별로 복붙하면서 읽어보고, 마지막에 살짝 수정하고 발행하는 행위.

## 산출물
`/home/jay/projects/BlogAuto/publisher/naver_playwright.py` (기존 파일 전면 수정)
`/home/jay/projects/BlogAuto/tests/test_naver_playwright.py` (테스트 수정)

## 인프라 (이미 세팅 완료)
- Chromium이 systemd 서비스로 상시 실행 중 (포트 9222, persistent profile)
- 가상 디스플레이: Xvfb :99 (1920x1080)
- 프로필 경로: `/home/jay/projects/BlogAuto/naver-chrome-profile`
- 1회 수동 로그인으로 세션이 프로필에 저장됨

## 구현 요구사항

### 1. 브라우저 연결: connectOverCDP (launch 금지!)
```python
from playwright.sync_api import sync_playwright

with sync_playwright() as p:
    browser = p.chromium.connect_over_cdp('http://127.0.0.1:9222')
    context = browser.contexts[0]  # 기존 프로필 사용
    page = context.new_page()
    
    # ... 작업 수행 ...
    
    page.close()  # 탭만 닫기
    browser.close()  # ← 이건 disconnect 역할, 브라우저 자체는 안 꺼짐
```
**절대 `chromium.launch()` 사용 금지.** 항상 connect_over_cdp.

### 2. 세션 검증
```python
page.goto('https://blog.naver.com/MyBlog.naver')
if 'nid.naver.com' in page.url:
    # 세션 만료 → 텔레그램 알림 발송 후 중단
    raise SessionExpiredError("세션 만료. 수동 재로그인 필요.")
```

### 3. "작성 중인 글" 모달 처리
블로그 글쓰기 페이지 진입 시 "작성 중인 글이 있습니다" 모달이 뜰 수 있음.
→ **"취소" 버튼 클릭** (새 글 작성)
```python
cancel_btn = page.locator('button:has-text("취소")')
if cancel_btn.is_visible(timeout=3000):
    cancel_btn.click()
    _random_delay(1, 2)
```

### 4. 콘텐츠 전처리: 마크다운 → HTML → 단락 배열
입력: `/home/jay/workspace/output/blog/naver/content-*.md`

```python
def prepare_content(md_path):
    """마크다운을 단락별 HTML 배열로 변환한다."""
    # frontmatter 제거
    # [quotation_line]...[/quotation_line] → <blockquote class="line">...</blockquote>
    # [quotation_postit]...[/quotation_postit] → <blockquote class="box">...</blockquote>
    # [quotation_corner]...[/quotation_corner] → <blockquote class="bracket">...</blockquote>
    # --- → <hr>
    # [이미지: ...] → 이미지 삽입 마커
    # 단락 분리: 빈 줄 기준으로 분리
    
    return {
        "title": "보험설계사 GA 이직, 인카금융 선택 전...",
        "blocks": [
            {"type": "text", "html": "<p>GA로 이직을 고민하고...</p>"},
            {"type": "image", "path": "/path/to/thumbnail.png"},
            {"type": "text", "html": "<blockquote class='line'>GA 보험대리점이란</blockquote><p>GA는...</p>"},
            {"type": "image", "path": "/path/to/body-consultation.png"},
            {"type": "text", "html": "<p>이직을 결정할 때...</p>"},
            # ...
        ],
        "tags": ["인카금융", "보험대리점", ...]
    }
```

### 5. 핵심: 단락별 클립보드 붙여넣기 (★★★)

**통째로 복붙 금지!** 단락(블록)별로 나눠서 붙여넣기.

```python
for i, block in enumerate(blocks):
    if block["type"] == "text":
        # 1. HTML을 클립보드에 복사
        page.evaluate(f"""
            const data = new DataTransfer();
            data.setData('text/html', `{block["html"]}`);
            data.setData('text/plain', `{strip_html(block["html"])}`);
            document.dispatchEvent(new ClipboardEvent('paste', {{
                clipboardData: data,
                bubbles: true,
                cancelable: true
            }}));
        """)
        # 또는: xclip으로 가상 클립보드에 복사 후 Ctrl+V
        
        # 2. Ctrl+V 붙여넣기
        page.keyboard.press('Control+V')
        _random_delay(1, 3)
        
        # 3. Enter 2번 (단락 간격)
        page.keyboard.press('Enter')
        _random_delay(0.3, 0.8)
        page.keyboard.press('Enter')
        
    elif block["type"] == "image":
        # 이미지 삽입 (해당 위치에!)
        # 이미지 버튼 클릭 → 파일 선택 → 업로드 대기
        _insert_image(page, block["path"])
    
    # 4. 스크롤 + 대기 (읽어보는 척)
    page.mouse.wheel(0, random.randint(100, 300))
    _random_delay(10, 20)  # 10~20초 대기
```

### 6. 이미지 삽입 — 본문 중간 위치에!
이미지는 맨 위나 맨 아래에 몰아넣지 않는다.
마크다운의 `[이미지: ...]` 위치에 맞춰서 해당 단락 사이에 삽입.

```python
def _insert_image(page, image_path):
    """현재 커서 위치에 이미지를 삽입한다."""
    # SE 에디터의 이미지 버튼 찾기 + 클릭
    img_btn = page.locator('[data-name="image"]')  # 또는 적절한 셀렉터
    img_btn.click()
    _random_delay(1, 2)
    
    # 파일 선택 다이얼로그 → set_input_files
    file_input = page.locator('input[type="file"]')
    file_input.set_input_files(image_path)
    _random_delay(3, 5)  # 업로드 대기
    
    # 삽입 완료 버튼 클릭 (있으면)
    # _random_delay(2, 4)
```

### 7. 마무리 수정 시뮬레이션
모든 단락 붙여넣기 완료 후:
1. 맨 위로 스크롤
2. 제목 클릭 → 단어 1~2개 삭제 후 재입력 (직접 타이핑)
3. 본문에서 랜덤 위치 클릭 → 쉼표나 조사 1개 수정
4. 전체 훑어보기 스크롤 (위→아래, 5~10초)

```python
def _simulate_review(page, title):
    """사람이 글을 최종 검토하는 것처럼 시뮬레이션."""
    # 맨 위로 스크롤
    page.keyboard.press('Control+Home')
    _random_delay(2, 4)
    
    # 제목의 마지막 단어 수정 (삭제 후 재입력)
    title_area = page.locator('.se-title-text')  # 셀렉터 확인 필요
    title_area.click()
    for _ in range(random.randint(2, 5)):
        page.keyboard.press('Backspace')
        _random_delay(0.1, 0.3)
    last_word = title.split()[-1][:random.randint(2, 5)]
    for ch in last_word:
        page.keyboard.type(ch, delay=random.randint(100, 250))
    
    _random_delay(3, 5)
    
    # 전체 훑어보기 스크롤
    for _ in range(random.randint(3, 6)):
        page.mouse.wheel(0, random.randint(200, 500))
        _random_delay(1, 3)
```

### 8. 태그 입력
```python
def _add_tags(page, tags):
    tag_input = page.locator('.se-tag-input')  # 셀렉터 확인 필요
    for tag in tags:
        tag_input.click()
        for ch in tag:
            page.keyboard.type(ch, delay=random.randint(80, 200))
        page.keyboard.press('Enter')
        _random_delay(0.5, 1.5)
```

### 9. 예약 발행
```python
def _publish(page, schedule_time=None):
    """발행 또는 예약 발행."""
    publish_btn = page.locator('button:has-text("발행")')
    publish_btn.click()
    _random_delay(2, 3)
    
    if schedule_time:
        # 예약 발행 옵션 선택
        schedule_option = page.locator('[data-name="schedule"]')  # 셀렉터 확인
        schedule_option.click()
        # 시간 설정 로직
    
    # 최종 확인 버튼
    confirm_btn = page.locator('button:has-text("발행")')  # 확인 다이얼로그
    confirm_btn.click()
```

### 10. 전체 플로우
```python
def publish(md_path, images_dir, tags, visibility='public', schedule_time=None):
    content = prepare_content(md_path)
    
    with sync_playwright() as p:
        browser = p.chromium.connect_over_cdp('http://127.0.0.1:9222')
        context = browser.contexts[0]
        page = context.new_page()
        
        try:
            # 세션 검증
            _verify_session(page)
            
            # 글쓰기 페이지 이동
            _navigate_to_write(page)
            
            # "작성 중인 글" 모달 → 취소
            _handle_draft_modal(page)
            
            # 제목 입력 (직접 타이핑)
            _type_title(page, content["title"])
            
            # 단락별 붙여넣기 + 이미지 삽입
            for block in content["blocks"]:
                if block["type"] == "text":
                    _paste_text_block(page, block["html"])
                elif block["type"] == "image":
                    _insert_image(page, block["path"])
                # 스크롤 + 대기
                _scroll_and_wait(page)
            
            # 마무리 수정 시뮬레이션
            _simulate_review(page, content["title"])
            
            # 태그 입력
            _add_tags(page, tags)
            
            # 발행
            _publish(page, schedule_time)
            
        finally:
            page.close()
            browser.close()  # disconnect만 됨
```

### 11. CLI
```bash
# 발행
python3 -m publisher.naver_playwright publish \
  --content /path/to/content.md \
  --images /path/to/images/ \
  --tags "인카금융,보험대리점" \
  --schedule "2026-04-09 09:00"  # 예약 발행 (선택)

# 세션 체크만
python3 -m publisher.naver_playwright check-session
```

### 12. 테스트
- prepare_content 단위 테스트 (마크다운 파싱, 단락 분리, 이미지 위치)
- 세션 검증 테스트 (mock page)
- 단락 붙여넣기 순서 테스트
- _random_delay는 time.sleep mock 필수 (이전 버그 재발 방지!)
- 마무리 수정 시뮬레이션 테스트
- 실제 발행 테스트: 임시저장(draft)으로 먼저 시도

### 13. SE 에디터 셀렉터 탐색
SE 에디터의 DOM 구조는 iframe 안에 있을 수 있다. Playwright에서:
```python
# iframe 안이면
frame = page.frame_locator('.se-editor iframe')
frame.locator('.se-text-paragraph').click()

# 또는 직접 접근 가능하면
page.locator('.se-text-paragraph').click()
```
셀렉터가 정확하지 않을 수 있으므로, 실제 SE 에디터 DOM을 탐색하여 정확한 셀렉터를 찾아야 한다.
**반드시 실제 페이지에서 page.content()로 DOM 구조를 확인하고 셀렉터를 결정할 것.**

### 14. 클립보드 HTML 붙여넣기 구현 방법
가상 디스플레이(Xvfb)에서 클립보드를 사용하려면 xclip 또는 xsel이 필요할 수 있다.
```bash
# xclip 설치 확인
which xclip || sudo apt install -y xclip
```
또는 Playwright의 `page.evaluate()`로 JavaScript에서 직접 ClipboardEvent를 발생시키는 방법 사용.

### 15. 참조 파일
- 기존 naver_playwright.py: `/home/jay/projects/BlogAuto/publisher/naver_playwright.py` (1048줄, 참고용)
- 기존 naver_login.py: `/home/jay/projects/BlogAuto/publisher/naver_login.py` (human_type 함수)
- 발행 콘텐츠: `/home/jay/workspace/output/blog/naver/content-20260408-인카금융.md`
- 이미지: `/home/jay/workspace/output/blog/naver/images/` (3장)
- .env.keys: `/home/jay/projects/BlogAuto/.env.keys`

## 주의사항
- `chromium.launch()` 사용 절대 금지 → connect_over_cdp만 사용
- `browser.close()`는 disconnect만 됨 (브라우저 꺼지지 않음) → 안전
- 타이핑은 제목만 직접 + 마무리 수정만 직접. 본문은 클립보드 붙여넣기
- time.sleep 테스트 시 반드시 mock!
- pyright 에러 0건

## 완료 기준
1. connectOverCDP로 9222 브라우저에 연결 동작
2. 단락별 클립보드 붙여넣기 동작
3. 이미지가 본문 중간 적절한 위치에 삽입
4. 마무리 수정 시뮬레이션 동작
5. 테스트 전체 PASS (10초 이내)
6. 실제 임시저장(draft) 테스트 시도 + 결과 보고

## [추가 요구사항] 계정 양생 + 돌발 상황 모니터링

### 16. 계정 양생 (Warm-up 루틴) — publish 실행 전 필수!
글쓰기 페이지로 직행하면 봇 판정 위험. 발행 전 "정상적인 행동"을 시뮬레이션:

```python
def _warmup_routine(page):
    """발행 전 계정 양생. 정상적인 사용자 행동 시뮬레이션."""
    # 1. 네이버 메인 접속
    page.goto('https://www.naver.com')
    _random_delay(2, 4)
    
    # 2. 뉴스 기사 1개 클릭 → 30초 스크롤
    news_links = page.locator('a[href*="news.naver.com"]')
    if news_links.count() > 0:
        news_links.nth(random.randint(0, min(5, news_links.count()-1))).click()
        _random_delay(3, 5)
        for _ in range(random.randint(3, 6)):
            page.mouse.wheel(0, random.randint(200, 500))
            _random_delay(3, 8)  # 읽는 척
    
    # 3. 이웃 블로그 방문 (내 블로그의 이웃 목록에서)
    page.goto('https://blog.naver.com/incar_top')
    _random_delay(3, 5)
    for _ in range(random.randint(2, 4)):
        page.mouse.wheel(0, random.randint(200, 400))
        _random_delay(2, 5)
    
    # 4. 네이버 검색 (보험 관련)
    search_keywords = ['보험설계사 이직', 'GA 보험대리점', '보험 트렌드', '보험 영업 노하우']
    page.goto('https://www.naver.com')
    _random_delay(1, 2)
    search_input = page.locator('#query')
    search_input.click()
    keyword = random.choice(search_keywords)
    for ch in keyword:
        page.keyboard.type(ch, delay=random.randint(80, 200))
    page.keyboard.press('Enter')
    _random_delay(3, 5)
    for _ in range(random.randint(2, 4)):
        page.mouse.wheel(0, random.randint(200, 400))
        _random_delay(2, 4)
    
    # 5. 이제 글쓰기 페이지로 이동
    _random_delay(2, 4)
```

### 17. 돌발 상황 모니터링 — 스크린샷 + 텔레그램 알림
예상치 못한 팝업/캡차/에러 발생 시 즉시 화면 캡처 → 텔레그램 전송.

```python
import subprocess
from datetime import datetime

def _send_error_screenshot(page, error_msg):
    """에러 발생 시 스크린샷을 캡처하여 텔레그램으로 전송."""
    timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
    screenshot_path = f"/tmp/naver-error-{timestamp}.png"
    page.screenshot(path=screenshot_path)
    
    # 텔레그램으로 스크린샷 전송
    subprocess.run([
        "/usr/local/bin/cokacdir", "--sendfile", screenshot_path,
        "--chat", "6937032012", "--key", "c119085addb0f8b7"
    ], check=False)
    
    # 에러 메시지도 전송
    subprocess.run([
        "/usr/local/bin/cokacdir", "--cron",
        f"네이버 블로그 자동 발행 에러: {error_msg}. 스크린샷 전송됨. 수동 확인 필요.",
        "--at", "1m",
        "--chat", "6937032012", "--key", "c119085addb0f8b7", "--once"
    ], check=False)
```

전체 publish 플로우에 try-except 래핑:
```python
def publish(md_path, images_dir, tags, ...):
    with sync_playwright() as p:
        browser = p.chromium.connect_over_cdp('http://127.0.0.1:9222')
        context = browser.contexts[0]
        page = context.new_page()
        
        try:
            _verify_session(page)
            _warmup_routine(page)      # ★ 계정 양생
            _navigate_to_write(page)
            _handle_draft_modal(page)   # "취소" 클릭
            _type_title(page, title)
            # ... 단락별 붙여넣기 ...
            _simulate_review(page, title)
            _add_tags(page, tags)
            _publish(page, schedule_time)
            
        except SessionExpiredError:
            _send_error_screenshot(page, "세션 만료")
            raise
        except Exception as e:
            _send_error_screenshot(page, str(e))  # ★ 돌발 상황 캡처
            raise
        finally:
            page.close()
            browser.close()
```
