---
name: tistory-publish
description: Automate Tistory blog publishing via OpenClaw Playwright CDP. Supports any post format — handles TinyMCE editor manipulation, OG card insertion, banner upload, tag registration, category setting, and representative image selection. Includes template presets (mk-review, daum-trends, simple-post). Works around Tistory's isTrusted event filtering.
runtime:
  - python3
  - playwright
  - node (optional, for banner generation)
credentials:
  - purpose: Kakao login for Tistory session recovery (used only by scripts/login.sh --cred-file)
    required: false
    format: '{"email": "...", "password": "..."}  or  email: ...\npassword: ...'
    note: Path is user-specified via --cred-file or TISTORY_CRED_FILE env var
---

# Tistory Publish

티스토리 블로그 범용 자동 발행 스킬. 어떤 형식의 글이든 자동 발행할 수 있습니다.

Tistory Open API 종료(2024.02) 이후 유일한 자동화 경로인 브라우저 자동화를 제공합니다.

## 전제 조건

- OpenClaw 브라우저 서비스 (Chrome CDP, 기본 port 18800)
- 티스토리 카카오 로그인 완료 (OpenClaw Chrome에서)
- Python 3 + Playwright (`pip install playwright`)
- Node.js 18+ (배너 생성 시, 선택)
- (선택) 카카오 자격증명 파일 — 로그인 세션 만료 시 복구용 (`scripts/login.sh --cred-file <경로>`)
  - JSON 형식: `{"email": "...", "password": "..."}`
  - 또는 key-value 형식: `email: ...\npassword: ...`
  - `publish.sh`는 자격증명을 읽지 않음 (로그인은 `login.sh`에서만 처리)
  - `publish-post.sh` 자동 복구는 현재 `--blog`와 `--cdp-port`를 `login.sh`로 전달함

## 구조

```
tistory-publish/
├── SKILL.md                     # 이 파일
├── scripts/
│   ├── tistory-publish.js       # 코어 — 에디터 조작 함수 모음
│   ├── publish.sh               # 범용 발행 스크립트
│   ├── seo_check.py             # 발행 전 SEO 정적 검사
│   └── login.sh                 # 카카오 로그인 세션 복구
└── templates/
    └── simple-post/             # 예시: 단순 글 발행
        └── RUNBOOK.md
```

## 빠른 시작

```bash
# 가장 단순한 발행
bash scripts/publish.sh \
  --title "글 제목" \
  --body-file body.html \
  --category "카테고리명" \
  --blog "your-blog.tistory.com"

# 매경 리뷰 (템플릿 사용)
bash scripts/publish.sh \
  --template mk-review \
  --article-title "기사 제목" \
  --body-file body.html \
  --banner /tmp/banner.jpg \
  --tags "매경,경제뉴스" \
  --blog "anthropic.tistory.com" \
  --category "재테크 이야기/경제신문 리뷰" \
  --cdp-port 18800

# 배너 + 태그 + 비공개
bash scripts/publish.sh \
  --title "글 제목" \
  --body-file body.html \
  --category "카테고리명" \
  --banner /tmp/banner.jpg \
  --tags "태그1,태그2,태그3" \
  --private
```

## 발행 스크립트 옵션 (`publish.sh`)

| 옵션 | 필수 | 설명 |
|------|------|------|
| `--title` | ✅ | 글 제목 |
| `--body-file` | ✅ | 본문 HTML 파일 경로 |
| `--category` | ✅ | 카테고리 이름 (에디터에 표시되는 이름 그대로) |
| `--template` | | 템플릿 preset (mk-review, daum-trends, simple-post) |
| `--article-title` | | mk-review용 기사 제목 (자동 날짜 접두사) |
| `--tags` | | 쉼표 구분 태그 목록 |
| `--banner` | | 배너 이미지 파일 경로 |
| `--banner-alt` | | 배너 이미지 alt 텍스트 (기본: 제목 — 빈 alt로 업로드되지 않도록) |
| `--blog` | | 블로그 도메인 (기본: tistory.com 첫 번째 블로그) |
| `--cdp-port` | | OpenClaw Chrome CDP 포트 (기본: `TISTORY_CDP_PORT` 또는 스크립트 기본값) |
| `--cdp-connect-timeout-ms` | | 실제 Playwright attach 제한: 기본 15000ms (`TISTORY_CDP_CONNECT_TIMEOUT_MS`), 허용 1000~60000ms. 성공 연결 재사용 |
| `--helper` | | tistory-publish.js 경로 (기본: scripts/ 내) |
| `--private` | | 비공개 발행 |
| `--seo-check` | | 발행 전 SEO 검사 모드: `off`(기본)/`warn`/`strict`. `strict`는 error 발견 시 발행 중단 |
| `--seo-keyword` | | SEO 핵심 키워드 (기본: 제목 첫 단어). 제목/도입부/소제목 내 키워드 배치 검사에 사용 |
| `--seo-min-body-chars` | `1000` | SEO 본문 최소 노출 글자수. `strict`에서는 미달 시 발행 중단 |

### 템플릿 preset

| 이름 | 카테고리 | 블로그 | 제목 형식 | 배너 |
|------|---------|--------|----------|------|
| `mk-review` | 재테크 이야기/경제신문 리뷰 | anthropic.tistory.com | `[매경] YYYY.MM.DD(요일) - 기사 제목` | 필수 |
| `simple-post` | (직접 지정) | (직접 지정) | (직접 지정) | 선택 |

> 자신만의 preset을 추가하려면 `templates/` 아래에 폴더를 만들고 `publish.sh --template <이름>` 으로 사용하세요.

### Daum Trends preset

Use `--template daum-trends` for Daum 실시간 트렌드 posts. This preset supplies content defaults such as tags only. The caller must still pass `--blog` and `--category` explicitly.

```bash
ALLOW_DIRECT_TISTORY_PUBLISH=1 bash scripts/publish-post.sh \
  --template daum-trends \
  --title "Daum 실시간 트렌드 ..." \
  --body-file body.html \
  --blog "$DAUM_TRENDS_TISTORY_BLOG" \
  --category "$DAUM_TRENDS_TISTORY_CATEGORY" \
  --cdp-port "$TISTORY_CDP_PORT"
```

세션 복구를 직접 실행할 때도 같은 포트를 명시한다:

```bash
bash scripts/login.sh \
  --cred-file "$TISTORY_LOGIN_CRED_FILE" \
  --blog "$DAUM_TRENDS_TISTORY_BLOG" \
  --cdp-port "$TISTORY_CDP_PORT"
```

## 자동 처리 항목

스크립트가 순서대로 처리:

0. SEO 검사 (`--seo-check warn|strict` 지정 시, 발행 전 정적 검사)
1. 새 글 페이지 열기
2. JS 헬퍼 함수 주입
3. 카테고리 선택 (ARIA combobox → Playwright click)
4. 제목 입력 (base64 디코딩으로 한글 처리)
5. 본문 HTML 삽입
6. inline/배너 이미지 업로드 (첨부→사진 메뉴 → file chooser 포착, 동적 file input fallback·제한 재시도) + alt 텍스트 설정
7. OG 카드 생성 (placeholder URL → Enter 키 → 카드 렌더링)
8. 대표이미지 설정 (`daum-trends`는 첫 non-comic 본문 첨부를 결정적으로 선택 — 대상 미발견 시 발행 중단, 다른 템플릿은 첫 이미지)
9. 태그 등록
10. 발행 (공개/비공개)
11. `daum-trends`는 발행 및 기존 post-publish 검증이 성공한 경우에만, 해당 실행이 만든 정확한 page target을 최대 5초 best-effort로 닫음 (다른 탭·실패 경로는 건드리지 않으며 종료 실패가 발행 결과를 바꾸지 않음)

## 본문 HTML 작성 규칙

- `<p data-ke-size="size16">` 태그 사용
- 단락 = 여러 문장 묶음 (`<p>` 하나에 2~4문장)
- OG 카드 위치: `<p data-og-placeholder="URL">&#8203;</p>`
- 뉴스 OG 카드 폴백 후보(선택): `<p data-og-placeholder="URL" data-og-fallback-urls="URL1 URL2">&#8203;</p>` — 같은 항목의 v.daum.net 기사 후보를 공백 구분으로 나열. scrap이 두 시도 모두 HTTP 500 + `code=40009`로 확정될 때만 첫 적격 후보를 정확히 1회 시도. 커뮤니티 카드에는 사용 금지
- **`daum-trends` 한정 plain-link 폴백**: 두 원본 시도가 모두 HTTP 500 + `code=40009`로 확정되고 적격 v.daum.net 후보가 전혀 없을 때만, 실패한 placeholder를 카드 대신 일반 출처 하이퍼링크(`data-og-plain-link` 마킹, `href`=원본 URL, `_blank`, `noopener noreferrer`)로 유지하고 최종 결과 JSON의 `ogPlainLinkFallbacks`에 URL/사유/phase를 보고. **깨진 OG는 절대 카드로 계산하지 않는다** — 카드 수 게이트는 `placeholder 수 − plain link 수`와 비교하고 마킹된 plain link 수도 별도 검증. 후보가 있으면 기존대로 후보 1회 시도 후 실패 시 fail-closed. **strict custom OG validator(`TISTORY_OG_VALIDATOR` 설정 시)가 켜져 있으면 이 폴백은 비활성화**되어 기존 엄격한 카드 의미론이 유지되며, 다른 템플릿(mk-review 등)에는 어떤 영향도 없다
- **`daum-trends` 한정 짝 소진(exhausted-pair) 40002 plain-link 폴백**: 엄격한 DCInside 짝이 계산되는 게시글 URL에서 제한된 3회 시퀀스(원본, 원본 재시도, 계산된 짝으로의 짝 폴백 정확히 1회)가 **전부 안전 연관된 HTTP 500 + `code=40002`로 확정** 실패한 경우에만 (마이너 갤러리 모바일 URL은 짝 helper가 regular `/board/view` 경로를 계산하지만 실제 canonical은 `/mgallery/board/view`라 짝도 같은 40002로 죽는 부류), 원본 URL(계산된 짝이 아님)을 위와 같은 일반 출처 하이퍼링크로 유지하고 `ogPlainLinkFallbacks`에 보고 (짝 시도가 짝 URL로 키잉해 둔 pending 문단은 변환 전에 `prepareOGRetry`로 원본 URL에 재바인딩 — 추가 Enter/scrap 시도 없음). **깨진 OG나 실패한 짝은 절대 카드로 계산하지 않는다**. 짝 시도 성공은 기존대로 카드로 유지·계산되고, 짝이 없거나 짝 시도에 도달하지 못한 40002 실패, 시퀀스 종류/순서/횟수 불일치, 짝 URL 불일치, 미관측/미파싱/타 상태/타 코드는 전부 기존 fail-closed abort. 40009 케이스와 같은 opt-in 게이트를 공유하므로 **custom OG validator가 켜져 있으면 이 폴백도 비활성화**되며, 다른 템플릿에는 영향 없다
- 구분선: `<hr contenteditable="false" data-ke-type="horizontalRule" data-ke-style="style1">`

### SEO 규칙 (검색 노출용 — `--seo-check`가 검사하는 항목)

Tistory는 본문 시작부를 meta description / og:description으로 사용하므로 본문 구조가 곧 검색 스니펫이다.

- **도입부 필수**: 첫 `<h2>` 이전에 80~150자 요약 문단 1개. 핵심 키워드를 첫 150자 안에 배치 (검색 결과 요약문으로 노출됨)
- **제목**: 핵심 키워드를 앞쪽(20자 이내)에 배치, 전체 60자 이하 (SERP에서 한글 30~35자만 노출)
- **소제목**: h2 2개 이상, 최소 1개 h2/h3에 핵심 키워드 포함
- **이미지 alt**: 본문 내 모든 `<img>`에 alt 필수, 배너는 `--banner-alt`로 지정
- **내부 링크**: 같은 블로그의 관련 글 2~3개 링크 (크롤링 경로 + 체류시간)
- **외부 출처**: 원문 링크 또는 OG 카드 1개 이상
- **본문 분량**: 노출 텍스트 1,000자 이상 (단순 발췌는 저품질 콘텐츠로 분류될 수 있음)
- **태그**: 5~10개, 중복 금지, 범용 키워드 + 롱테일 키워드 혼합

단독 실행:

```bash
python3 scripts/seo_check.py \
  --title "글 제목" --body-file body.html \
  --tags "태그1,태그2" --keyword "핵심키워드" \
  --blog "your-blog.tistory.com" --mode strict --min-body-chars 1000
```

## 템플릿 추가하기

`templates/` 디렉토리에 새 폴더를 만들어 자신만의 워크플로우를 추가할 수 있습니다:

```
templates/my-template/
├── RUNBOOK.md       # 발행 순서
├── TEMPLATE.md      # 원고 작성 템플릿
└── banner.js        # 배너 생성 스크립트 (선택)
```

## 주요 JS 함수 (`tistory-publish.js`)

### 콘텐츠
- `insertContent(html)` — TinyMCE에 HTML 삽입
- `buildBlogHTML({intro, articles})` — 구조화된 데이터 → HTML 변환

### OG 카드
- `getOGPlaceholders()` — placeholder URL 목록
- `getOGPlaceholderEntries()` — placeholder별 URL + ordered Daum 다음 기사 폴백 후보 목록 (`data-og-fallback-urls`, 없거나 파싱 불가 시 빈 후보로 fail-closed)
- `prepareOGPlaceholder(url)` — placeholder → URL 텍스트 교체
- `prepareOGRetry(fromUrl, toUrl)` — 실패한 시도의 pending 문단 재사용 (같은 URL 재시도 / 확정 40002 시 DCInside 짝 폴백 / 확정 500·40009 시 Daum 다음 기사 폴백)
- `dcinsidePairedOGUrl(url)` — 엄격한 DCInside 모바일↔데스크톱 게시글 짝 계산 (그 외 URL은 null)
- `verifyOGCard(url)` — 카드 렌더링 확인 (엄격한 DCInside 짝은 같은 글로 인정)
- `convertPendingToPlainLink(url)` — 확정 500/40009 + 후보 없음일 때 실패한 pending 문단을 일반 출처 링크로 변환 (`data-og-plain-link` 마킹, daum-trends 폴백 전용)
- `verifyOGPlainLink(url)` — plain-link 변환 결과 검증 (marker/href/`_blank`/`noopener noreferrer` 확인, 실패 시 발행 중단)

### 메타데이터
- `setTags(tags[])` — 태그 등록
- `setRepresentImageFromEditor(options?)` — 대표이미지 설정. `{targetFilename}` 지정 시 `data-filename`/`src` 매칭으로 해당 이미지만 선택하고, 없으면 클릭 없이 실패 반환 (기본: 첫 이미지)
- `setImageAlt(alt, index)` — 에디터 내 이미지 alt 텍스트 설정 (SEO)
- `setImageAltForUploadedImage(alt, filename, previousCount)` — 업로드 직후 새 이미지 alt 텍스트 설정 (기존 이미지 덮어쓰기 방지)

### 배너
- `verifyBannerUpload()` — 업로드 확인

## 알려진 제약

- `isTrusted=false` 이벤트 무시 → OG/태그에 우회 로직 필요
- 사진 업로드는 transient file chooser 이벤트를 먼저 포착하고 동적 DOM input을 fallback으로 사용하며, 3회 지수 백오프 재시도 후에도 둘 다 없으면 DOM/스크린샷을 기록하고 중단
- 카테고리가 ARIA combobox → Playwright click 필요
- 대표이미지 셀렉터가 Tistory 업데이트마다 변경 가능

## 변경 이력

[CHANGELOG.md](CHANGELOG.md) 참조

### CDP 연결 실패 진단 (현재 publish-post.sh)

발행 잠금 뒤 에디터 생성·로그인 전에 실제 연결을 확인합니다. 실패 시 최대 5초의 별도 프로세스에서 최대 8개 target을 target당 750ms 이내로 진단하고 `publish/cdp-attach-failed`로 중단합니다. 선택 `websocket-client`가 있으면 직접 page 채널의 상수 평가 응답으로 `responsive`를 확인하며, 연결 후 평가 시간 초과는 `unresponsive`, 모듈 부재·전송/평가 오류·지원 밖 종류·예산 소진은 `unknown`입니다. HTTP/target 목록 응답만으로 건강하다고 판정하지 않습니다.

단계·시간·제한된 id/type·상태만 기록하고 URL 전체(origin/path 포함), 제목, 원시 예외 등은 생략합니다. 기존 탭 자동 종료·reload·브라우저 재시작은 하지 않습니다. 기존 URL 기반 newpost 정리는 제거했고, 발행 잠금과 기존 게이트·실행 소유 target의 성공 후 정리는 유지합니다. 레거시 발행/로그인 경로는 변경하지 않습니다. 과거 장애 target은 확인되지 않았으며 진단 타임아웃으로 원인 탭을 확정하지 않습니다. 상세 제한은 README의 CDP 연결 preflight 절을 따릅니다.
