# 웹 크롤링 & 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 디버깅 | 창이 뜨고 사라짐 | 항상 떠있어 관찰 가능 | ### 주요 메서드 ```java // 새 탭 열기 (호출자가 사용 후 about:blank 또는 close) Page openPage(String url, int timeoutMs) Page openPage(String url) // 기본 30초 // 브라우저 페이지 내에서 JS fetch 실행 (쿠키/세션 유지) String fetchInPage(Page page, String url) ``` ### 탭 정리 방식 ```java // 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에서 직접 본문 텍스트 추출: ```javascript () => { // 불필요 요소 제거 ['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) 시도 - 언어 우선순위: `ko` → `en` - **한계**: 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.sh`의 `export 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 환경변수 설정 |