- 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>
224 lines
8.9 KiB
Markdown
224 lines
8.9 KiB
Markdown
# 웹 크롤링 & 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 환경변수 설정 |
|