---
name: site-capture
description: "웹사이트의 모든 페이지를 자동으로 캡처하는 스킬. Playwright를 메인 엔진으로 사용하여 텍스트 기반 셀렉터로 안정적으로 동작하며, 메인 네비게이션 → 서브탭 → 하위 메뉴를 재귀적으로 탐색하고 풀페이지 스크린샷을 저장한다. SPA, 아코디언, 드롭다운, 팝업 모두 지원. 사용자가 '사이트 캡처', '전체 화면 캡처', '스크린샷 전부 찍어', 'site capture', '페이지 캡처' 등을 요청하거나, 특정 URL의 모든 화면을 문서화/레퍼런스용으로 저장하고 싶을 때 사용한다."
---

# Site Capture — 웹사이트 전체 자동 캡처

Playwright를 메인 엔진으로 사용하여 웹사이트의 모든 탭, 서브탭, 하위 메뉴, 팝업을 **텍스트 기반 셀렉터**로 안정적으로 탐색하고 풀페이지 스크린샷을 저장한다.

browser-use의 인덱스 불안정 문제를 Playwright의 텍스트 로케이터(`getByText`, `getByRole`)로 완전히 해결.

## 사용법

```
/site-capture <url> [--output <경로>] [--full] [--profile <프로필명>] [--depth <최대깊이>]
```

### 인자

| 인자 | 필수 | 기본값 | 설명 |
|------|------|--------|------|
| `<url>` | 필수 | — | 캡처할 웹사이트 URL |
| `--output` | 선택 | `docs/screenshots/` | 스크린샷 저장 경로 |
| `--full` | 선택 | 활성 | 풀페이지 스크롤 캡처 (`--no-full`로 비활성) |
| `--profile` | 선택 | — | Chrome 프로필 (인증 필요 시) |
| `--depth` | 선택 | 3 | 최대 탐색 깊이 (메인=1, 서브=2, 하위=3) |

### 예시

```
/site-capture https://example.com
/site-capture https://myapp.com --output docs/app-screens/
/site-capture https://dashboard.io --depth 2 --no-full
```

## 실행 절차

### Phase 1: 초기화 + Playwright 스크립트 생성

1. 저장 디렉토리 생성: `mkdir -p <output>`
2. 기존 파일이 있으면 사용자에게 덮어쓰기 확인
3. `/tmp/site-capture-<timestamp>.cjs` 에 Playwright Node.js 스크립트를 동적 생성

Playwright를 사용하는 이유: browser-use의 state 인덱스는 SPA에서 클릭할 때마다 바뀌어서 서브탭/드롭다운 캡처가 불안정하다. Playwright는 `page.getByText('탭이름')`처럼 텍스트 기반으로 요소를 찾기 때문에 DOM이 바뀌어도 안정적이다.

### Phase 2: 사이트 구조 탐색 (browser-use 보조)

먼저 browser-use로 빠르게 사이트 구조를 파악한다:

```bash
browser-use open <url>
browser-use state
```

state 출력에서 다음을 수집:
- **메인 네비게이션 텍스트 목록** (예: 프로젝트, 채널/영상분석, 대본작성, ...)
- **사이드바 메뉴 텍스트** (있으면)
- **팝업 트리거 버튼 텍스트** (로그인, 도움말 등)

수집 후 `browser-use close`.

### Phase 3: Playwright로 정밀 캡처 (핵심)

수집된 텍스트 목록을 기반으로 Playwright Node.js 스크립트를 생성하고 실행한다.

**스크립트 생성 템플릿:**

```javascript
const { chromium } = require('playwright');

(async () => {
  const browser = await chromium.launch({ headless: true });
  const page = await browser.newPage({ viewport: { width: 1920, height: 1080 } });
  const OUTPUT = '<output_path>';
  let seq = 1;

  // 파일명 생성 헬퍼
  function fname(parts) {
    const name = parts.map(p => p.replace(/[\/\\:*?"<>|]/g, '').replace(/\s+/g, '_')).join('_');
    return `${OUTPUT}/${String(seq++).padStart(2, '0')}_${name}.png`;
  }

  // 안전한 클릭 + 캡처 헬퍼
  async function clickAndCapture(locator, nameParts, opts = {}) {
    try {
      await locator.click({ timeout: 5000 });
      await page.waitForTimeout(1000);
      if (opts.waitFor) await page.waitForSelector(opts.waitFor, { timeout: 5000 }).catch(() => {});
      const path = fname(nameParts);
      await page.screenshot({ path, fullPage: true });
      console.log(`OK ${path}`);
      return true;
    } catch (e) {
      console.log(`SKIP ${nameParts.join('_')}: ${e.message.substring(0, 80)}`);
      return false;
    }
  }

  // === 1. 메인 페이지 ===
  await page.goto('<url>');
  await page.waitForLoadState('networkidle');
  await page.waitForTimeout(2000);
  await page.screenshot({ path: fname(['메인']), fullPage: true });

  // === 2. 메인 탭 순회 ===
  const mainTabs = [/* Phase 2에서 수집한 텍스트 목록 */];

  for (const tab of mainTabs) {
    // 텍스트 기반 클릭 — 인덱스 불필요!
    const loc = page.getByRole('button', { name: tab }).or(page.getByText(tab, { exact: false })).first();
    await clickAndCapture(loc, [tab]);

    // === 3. 서브탭 탐색 ===
    // 클릭 후 새로 나타난 버튼들을 찾는다
    const subButtons = await page.locator('button:visible, [role="tab"]:visible').all();
    const subTexts = [];
    for (const btn of subButtons) {
      const text = (await btn.textContent())?.trim();
      if (text && text.length > 0 && text.length < 30 && !mainTabs.includes(text)) {
        const box = await btn.boundingBox();
        // 컨텐츠 영역 (y > 200)에 있고 메인 네비가 아닌 것만
        if (box && box.y > 200 && box.y < 600) {
          subTexts.push(text);
        }
      }
    }

    // 중복 제거
    const uniqueSubs = [...new Set(subTexts)];

    for (const sub of uniqueSubs) {
      // 메인 탭으로 먼저 복귀 (안정성)
      await page.getByRole('button', { name: tab }).or(page.getByText(tab, { exact: false })).first().click().catch(() => {});
      await page.waitForTimeout(500);

      // 서브탭 클릭
      const subLoc = page.getByRole('button', { name: sub }).or(page.getByText(sub, { exact: false })).first();
      await clickAndCapture(subLoc, [tab, sub]);
    }
  }

  // === 4. 드롭다운/아코디언 메뉴 ===
  // 도구모음 같은 접힌 메뉴를 열어서 하위 항목 캡처
  // Phase 2에서 감지된 아코디언 버튼이 있으면:
  const accordions = [/* '도구모음' 등 */];

  for (const acc of accordions) {
    // 아코디언 열기
    const accLoc = page.getByText(acc, { exact: false }).first();
    await accLoc.click().catch(() => {});
    await page.waitForTimeout(800);

    // 열린 후 나타난 서브메뉴 항목들
    const items = await page.locator(`text=${acc}`).locator('..').locator('button:visible, a:visible').all();
    // 또는 아코디언 아래쪽의 새 버튼들
    const accItems = await page.locator('button:visible').all();

    for (const item of accItems) {
      const text = (await item.textContent())?.trim();
      if (text && text.length > 2 && text.length < 30) {
        const box = await item.boundingBox();
        // 아코디언 영역 안에 있는 항목만
        if (box) {
          await clickAndCapture(item, [acc, text]);
          // 아코디언으로 복귀
          await accLoc.click().catch(() => {});
          await page.waitForTimeout(500);
        }
      }
    }
  }

  // === 5. 팝업/모달 ===
  const popups = [/* '로그인', '도움말' 등 */];

  for (const popup of popups) {
    const popLoc = page.getByText(popup, { exact: false }).first();
    if (await popLoc.isVisible().catch(() => false)) {
      await clickAndCapture(popLoc, ['팝업', popup]);
      // 팝업 닫기
      await page.keyboard.press('Escape');
      await page.waitForTimeout(500);
      // X 버튼도 시도
      await page.locator('button:has-text("×"), button:has-text("닫기"), [aria-label="Close"]')
        .first().click().catch(() => {});
      await page.waitForTimeout(500);
    }
  }

  // === 6. 스크롤 하단 콘텐츠 ===
  // 긴 페이지의 경우 fullPage: true가 처리하지만,
  // lazy-load 콘텐츠가 있으면 스크롤 후 재캡처
  await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
  await page.waitForTimeout(1000);

  await browser.close();
  console.log(`\n=== 캡처 완료: ${seq - 1}개 ===`);
})();
```

**스크립트 실행:**
```bash
node /tmp/site-capture-<timestamp>.cjs 2>&1
```

### Phase 4: 누락 검증 + 보충 캡처

스크립트 실행 결과에서 `SKIP`된 항목이 있으면:
1. browser-use를 보조 도구로 사용하여 해당 요소의 정확한 셀렉터를 재탐색
2. Playwright 셀렉터를 수정하여 재캡처
3. 또는 `browser-use eval`로 JS 직접 실행하여 클릭 → `browser-use screenshot`

이 단계는 **100% 캡처를 보장하기 위한 안전망**이다. 자동 스크립트로 90%를 잡고, 나머지 10%를 수동 보충으로 완성한다.

### Phase 5: 정리 및 보고

1. 파일 순번 재정렬 (빈 번호 없도록)
2. 결과 테이블 출력:

```
=== Site Capture 완료 ===
URL: https://example.com
저장: docs/screenshots/
총 N개 화면

 #  파일명                             크기     깊이
 1  01_메인.png                        234KB   L1
 2  02_채널영상분석.png                  304KB   L1
 3  03_채널영상분석_채널분석실.png         311KB   L2
 4  04_채널영상분석_영상분석실.png         304KB   L2
 ...
20  20_팝업_로그인.png                    66KB   popup
```

3. 각 스크린샷을 Read로 시각 확인하여 빈 화면/깨진 화면이 없는지 검증
4. 문제가 있으면 해당 화면만 재캡처

## 핵심 원칙

### Playwright 텍스트 기반 셀렉터 (인덱스 사용 금지)

browser-use의 `[123]<button>` 인덱스는 SPA에서 DOM 변경 시 무효화된다.
Playwright는 텍스트로 요소를 찾기 때문에 항상 안정적이다:

```javascript
// 나쁜 예 (인덱스 — 불안정)
browser-use click 1391

// 좋은 예 (텍스트 — 안정)
page.getByRole('button', { name: '썸네일 스튜디오' }).click()
page.getByText('썸네일 스튜디오').click()
page.locator('button:has-text("썸네일 스튜디오")').click()
```

### 서브탭 감지 전략 (3단계 폴백)

**전략 1 — Role 기반 탐색 (최우선)**
```javascript
const tabs = page.getByRole('tab').or(page.getByRole('button')).filter({ hasText: /.+/ });
```

**전략 2 — 영역 기반 필터링**
클릭 후 나타난 버튼 중 컨텐츠 영역(y > 200)에 있는 것만 서브탭으로 간주.

**전략 3 — DOM 직접 탐색 (fallback)**
```javascript
const subs = await page.evaluate(() => {
  return [...document.querySelectorAll('button, [role=tab]')]
    .filter(b => {
      const r = b.getBoundingClientRect();
      return r.y > 200 && r.y < 600 && b.textContent.trim().length > 0;
    })
    .map(b => b.textContent.trim());
});
```

### 아코디언/드롭다운 처리

아코디언 메뉴(도구모음 등)는 클릭하면 하위 항목이 나타나고, 다른 것을 클릭하면 사라진다.

처리 방법:
1. 아코디언 버튼 클릭하여 열기
2. 하위 항목 텍스트 목록 수집
3. 각 항목 클릭 → 캡처 → 아코디언 다시 열기 → 다음 항목
4. `page.waitForTimeout(800)` — 아코디언 애니메이션 대기

### 팝업/모달 처리

팝업은 메인 탐색 후 마지막에 처리한다:
1. 트리거 버튼 클릭 (로그인, 도움말 등)
2. 모달이 나타날 때까지 대기: `page.waitForSelector('[role=dialog], .modal', { timeout: 3000 })`
3. 풀페이지 스크린샷 (모달 포함)
4. ESC 또는 X 버튼으로 닫기
5. 닫힘 확인 후 다음 팝업

### 에러 복구

모든 클릭/캡처는 try-catch로 감싸서 하나의 실패가 전체를 중단시키지 않는다:
- 클릭 실패 → SKIP 로그 + 다음 항목 계속
- 스크린샷 실패 → 3회 재시도
- 페이지 크래시 → `page.goto(url)`로 초기 페이지 복귀 후 계속

### 중복 방지

캡처된 페이지 목록을 Set으로 관리하여 같은 화면을 두 번 캡처하지 않는다:
```javascript
const captured = new Set();
// 캡처 전 체크
const key = `${tabName}_${subName}`;
if (captured.has(key)) continue;
captured.add(key);
```

## 파일 네이밍 규칙

```
<순번2자리>_<메인탭>_<서브탭>_<하위>.png

01_메인.png
02_프로젝트.png
03_채널영상분석.png
04_채널영상분석_채널분석실.png
05_채널영상분석_영상분석실.png
06_대본작성.png
07_사운드스튜디오.png
08_사운드_나레이션.png
09_사운드_배경음악.png
10_이미지영상.png
11_편집실.png
12_편집실_자막.png
13_도구모음_썸네일스튜디오.png
14_도구모음_캐릭터비틀기.png
...
20_팝업_로그인.png
21_팝업_도움말.png
```

- 한글 이름 사용 (버튼 텍스트 기반)
- 특수문자 제거 (슬래시, 콜론, 이모지 등)
- 공백은 언더스코어로 치환

## 의존성

- **Playwright** (필수): `npx playwright install chromium`
- **browser-use** (보조, Phase 2 탐색용): `browser-use doctor`

Playwright가 없으면 `npm install playwright` 후 `npx playwright install chromium` 실행.
browser-use가 없어도 동작 가능 — Phase 2를 수동 텍스트 입력으로 대체.
