Files
sundol/docs/crawling-guide.md
joungmin afc9cdcde6 Refactor Playwright to singleton browser with tab-based crawling
- Add PlaywrightBrowserService: singleton Chromium browser with auto-recovery
- Refactor WebCrawlerService/YouTubeTranscriptService to use shared browser tabs
- Fix YouTube transcript: extract from DOM panel + fmt=json3 fallback
- Keep browser window alive (about:blank instead of page.close)
- Add docs: X Window setup, operation manual, crawling guide

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 19:18:33 +00:00

8.9 KiB

웹 크롤링 & YouTube 자막 추출 가이드

아키텍처 개요

┌─────────────────────────────────────────────────────┐
│                Spring Boot Application               │
│                                                      │
│  ┌──────────────────────────────────────────────┐   │
│  │         PlaywrightBrowserService              │   │
│  │  (싱글톤 Chromium 브라우저, 앱 기동 시 1회)    │   │
│  │  - @PostConstruct: 브라우저 launch            │   │
│  │  - @PreDestroy: 브라우저 close                │   │
│  │  - 탭(Page) 단위로 작업 수행                   │   │
│  │  - 브라우저 죽으면 자동 재시작                  │   │
│  └──────────┬───────────────┬────────────────────┘   │
│             │               │                        │
│  ┌──────────▼──────┐  ┌────▼──────────────────┐     │
│  │ WebCrawlerService│  │YouTubeTranscriptService│    │
│  │ (일반 웹페이지)   │  │ (YouTube 자막)         │    │
│  └─────────────────┘  └───────────────────────┘     │
└─────────────────────────────────────────────────────┘

1. PlaywrightBrowserService (싱글톤 브라우저)

핵심 설계

  • 싱글톤 패턴: 앱 기동 시 Chromium을 한 번 띄우고 종료 시까지 유지
  • 탭 기반 작업: 각 크롤링 요청마다 새 탭(Page)을 열고, 완료 후 about:blank로 이동 (브라우저 창 유지)
  • 자동 복구: ensureBrowserAlive()로 브라우저 crash 시 자동 재시작
  • 쿠키 로딩: cookies.txt에서 YouTube/Google 쿠키를 읽어 봇 차단 우회

왜 싱글톤인가?

항목 매번 브라우저 생성 싱글톤 브라우저
기동 시간 요청마다 2~3초 최초 1회만
메모리 매번 할당/해제 안정적 유지
쿠키/세션 매번 재로딩 유지됨
VNC 디버깅 창이 뜨고 사라짐 항상 떠있어 관찰 가능

주요 메서드

// 새 탭 열기 (호출자가 사용 후 about:blank 또는 close)
Page openPage(String url, int timeoutMs)
Page openPage(String url)  // 기본 30초

// 브라우저 페이지 내에서 JS fetch 실행 (쿠키/세션 유지)
String fetchInPage(Page page, String url)

탭 정리 방식

// page.close() 대신 about:blank로 이동 → 브라우저 창이 유지됨
try {
    page.navigate("about:blank");
} catch (Exception ignored) {
    page.close();  // about:blank 실패 시에만 탭 닫기
}

쿠키 파일 형식 (cookies.txt)

Netscape 쿠키 형식 (탭 구분):

.youtube.com	/	FALSE	TRUE	1780000000	COOKIE_NAME	COOKIE_VALUE

필드 순서: domain, path, hostOnly(무시), secure, expiry(무시), name, value

YouTube/Google 도메인 쿠키만 로드됨.

2. WebCrawlerService (일반 웹페이지 크롤링)

3단계 Fallback 전략

1차: Jsoup (경량 HTML 파서)
  ↓ 실패 또는 유효하지 않은 콘텐츠
2차: Jina Reader API (외부 서비스)
  ↓ 실패 또는 유효하지 않은 콘텐츠
3차: Playwright (싱글톤 브라우저 탭)

1차: Jsoup

  • Java 기반 HTML 파서, 가장 빠르고 가벼움
  • JavaScript 렌더링 불가 → SPA 사이트에서는 빈 콘텐츠 반환
  • nav, footer, header, script, style 등 불필요 요소 제거 후 본문 추출

2차: Jina Reader

  • https://r.jina.ai/{URL} 형태로 외부 서비스 호출
  • JavaScript 렌더링 지원, Markdown/텍스트 형식 반환
  • API 키 설정 시 인증 헤더 추가 가능

3차: Playwright

  • 싱글톤 브라우저에서 새 탭으로 페이지 로드
  • JavaScript 렌더링 완료 후 NETWORKIDLE 대기
  • page.evaluate()로 DOM에서 직접 본문 텍스트 추출:
() => {
  // 불필요 요소 제거
  ['nav','footer','header','script','style','.ad','#cookie-banner','.sidebar','.comments']
    .forEach(sel => document.querySelectorAll(sel).forEach(el => el.remove()));
  // 본문 영역 우선, 없으면 body 전체
  const article = document.querySelector('article, main, .post-content, .article-body, .entry-content');
  return (article || document.body).innerText;
}

유효성 검증

모든 단계에서 isValidContent() 검증:

  • 최소 100자 이상
  • 에러 페이지 패턴 감지 (403, captcha, "checking your browser" 등)

3. YouTubeTranscriptService (YouTube 자막 추출)

2단계 Fallback 전략

1차: youtube-transcript-api 라이브러리
  ↓ 실패 (봇 차단 등)
2차: Playwright (싱글톤 브라우저)
  ├─ 방법 A: 자막 패널 DOM 추출
  └─ 방법 B: Caption URL + fmt=json3

1차: youtube-transcript-api

  • io.github.thoroldvix 라이브러리 사용
  • 수동 자막(manual) 우선, 없으면 자동 생성(generated) 시도
  • 언어 우선순위: koen
  • 한계: YouTube가 서버 IP를 봇으로 판정하면 실패

2차: Playwright Fallback

방법 A: 자막 패널 DOM 추출 (우선)

YouTube 페이지에서 '스크립트 표시' 버튼을 클릭하여 자막 패널을 열고 DOM에서 텍스트를 직접 추출한다.

흐름:

  1. YouTube 동영상 페이지 로드 (NETWORKIDLE 대기)
  2. 2초 대기 후 '스크립트 표시' / 'Show transcript' 버튼 탐색 및 클릭
  3. 버튼 못 찾으면 설명란 '더보기' 펼치기 → 다시 탐색
  4. 3초 대기 (자막 패널 로드)
  5. ytd-transcript-segment-renderer .segment-text 등 CSS 셀렉터로 자막 텍스트 추출

장점: 브라우저의 쿠키/세션이 완전히 유지되어 봇 차단 우회 가능

방법 B: Caption URL + fmt=json3 (보조)

방법 A가 실패하면 HTML에서 captionTracks를 파싱하여 caption URL을 추출하고, &fmt=json3 파라미터를 추가하여 JSON 형식으로 자막을 요청한다.

왜 fmt=json3인가?

  • 기본 XML 형식(fmt 없음)은 서명된 URL이 서버 IP에 바인딩되어 빈 응답을 반환하는 경우가 있음
  • fmt=json3{"events":[{"segs":[{"utf8":"text"}]}]} 형식으로 반환

Caption URL 추출 과정:

  1. page.content()로 HTML 획득
  2. "captionTracks":\s*\[(.*?)] 정규식으로 JSON 추출
  3. 언어 우선순위에 따라 URL 선택 (ko → en → 첫 번째)
  4. \u0026& 디코딩
  5. &fmt=json3 추가
  6. page.evaluate(fetch()) — 브라우저 내 JS fetch로 요청 (쿠키 유지)

언어 선택 로직

captionTracks JSON에서:
  1. languageCode가 "ko"로 시작하는 트랙 → 최우선
  2. languageCode가 "en"으로 시작하는 트랙 → 차선
  3. 첫 번째 트랙 → 최후

4. DISPLAY 환경변수와 Head 모드

Playwright가 head 모드(setHeadless(false))로 실행되려면 X Window 디스플레이가 필요하다.

환경 DISPLAY 값 설명
VNC 디버깅 :1 VNC 접속 시 브라우저 보임
운영 (백그라운드) :99 Xvfb 가상 프레임버퍼

설정 위치: start-backend.shexport DISPLAY=:1 (또는 :99)

5. 트러블슈팅

YouTube 자막이 안 가져와질 때

  1. 로그 확인: pm2 logs sundol-backend --lines 50
  2. "봇 판정": youtube-transcript-api 실패 → Playwright fallback 정상 동작하는지 확인
  3. 쿠키 만료: cookies.txt의 쿠키가 오래되면 YouTube가 차단. 브라우저에서 새 쿠키 export 필요
  4. 자막 패널 버튼 변경: YouTube UI 업데이트로 버튼 텍스트/셀렉터가 바뀌면 extractTranscriptFromPanel() 수정 필요

Playwright 브라우저가 죽었을 때

  • ensureBrowserAlive()가 자동 재시작
  • 로그에서 Playwright 브라우저가 죽어있습니다. 재시작합니다. 확인
  • 수동 재시작: pm2 restart sundol-backend

크롤링이 빈 결과를 반환할 때

  1. Jsoup: JavaScript 렌더링이 필요한 SPA 사이트인지 확인
  2. Jina Reader: API 키 유효한지, rate limit 걸렸는지 확인
  3. Playwright: 해당 사이트가 Cloudflare 등 봇 차단을 하는지 확인

6. 관련 파일

파일 역할
PlaywrightBrowserService.java 싱글톤 브라우저 관리
WebCrawlerService.java 일반 웹 크롤링 (3단계 fallback)
YouTubeTranscriptService.java YouTube 자막 추출 (2단계 fallback)
cookies.txt YouTube/Google 쿠키 (봇 차단 우회)
start-backend.sh DISPLAY 환경변수 설정