- 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>
8.9 KiB
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) 시도
- 언어 우선순위:
ko→en - 한계: YouTube가 서버 IP를 봇으로 판정하면 실패
2차: Playwright Fallback
방법 A: 자막 패널 DOM 추출 (우선)
YouTube 페이지에서 '스크립트 표시' 버튼을 클릭하여 자막 패널을 열고 DOM에서 텍스트를 직접 추출한다.
흐름:
- YouTube 동영상 페이지 로드 (NETWORKIDLE 대기)
- 2초 대기 후 '스크립트 표시' / 'Show transcript' 버튼 탐색 및 클릭
- 버튼 못 찾으면 설명란 '더보기' 펼치기 → 다시 탐색
- 3초 대기 (자막 패널 로드)
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 추출 과정:
page.content()로 HTML 획득"captionTracks":\s*\[(.*?)]정규식으로 JSON 추출- 언어 우선순위에 따라 URL 선택 (ko → en → 첫 번째)
\u0026→&디코딩&fmt=json3추가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 자막이 안 가져와질 때
- 로그 확인:
pm2 logs sundol-backend --lines 50 - "봇 판정": youtube-transcript-api 실패 → Playwright fallback 정상 동작하는지 확인
- 쿠키 만료:
cookies.txt의 쿠키가 오래되면 YouTube가 차단. 브라우저에서 새 쿠키 export 필요 - 자막 패널 버튼 변경: YouTube UI 업데이트로 버튼 텍스트/셀렉터가 바뀌면
extractTranscriptFromPanel()수정 필요
Playwright 브라우저가 죽었을 때
ensureBrowserAlive()가 자동 재시작- 로그에서
Playwright 브라우저가 죽어있습니다. 재시작합니다.확인 - 수동 재시작:
pm2 restart sundol-backend
크롤링이 빈 결과를 반환할 때
- Jsoup: JavaScript 렌더링이 필요한 SPA 사이트인지 확인
- Jina Reader: API 키 유효한지, rate limit 걸렸는지 확인
- Playwright: 해당 사이트가 Cloudflare 등 봇 차단을 하는지 확인
6. 관련 파일
| 파일 | 역할 |
|---|---|
PlaywrightBrowserService.java |
싱글톤 브라우저 관리 |
WebCrawlerService.java |
일반 웹 크롤링 (3단계 fallback) |
YouTubeTranscriptService.java |
YouTube 자막 추출 (2단계 fallback) |
cookies.txt |
YouTube/Google 쿠키 (봇 차단 우회) |
start-backend.sh |
DISPLAY 환경변수 설정 |