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>
This commit is contained in:
223
docs/crawling-guide.md
Normal file
223
docs/crawling-guide.md
Normal file
@@ -0,0 +1,223 @@
|
||||
# 웹 크롤링 & 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 환경변수 설정 |
|
||||
182
docs/operation-manual.md
Normal file
182
docs/operation-manual.md
Normal file
@@ -0,0 +1,182 @@
|
||||
# Sundol 운영 매뉴얼
|
||||
|
||||
## 프로세스 구성
|
||||
|
||||
pm2로 3개 프로세스를 관리한다.
|
||||
|
||||
| id | name | 설명 |
|
||||
|----|------|------|
|
||||
| 0 | pdf-ocr | PDF OCR 서비스 |
|
||||
| 1 | sundol-backend | Spring Boot 백엔드 |
|
||||
| 2 | sundol-frontend | Next.js 프론트엔드 |
|
||||
|
||||
## pm2 기본 명령어
|
||||
|
||||
```bash
|
||||
# 프로세스 목록 확인
|
||||
pm2 list
|
||||
|
||||
# 로그 확인
|
||||
pm2 logs sundol-backend
|
||||
pm2 logs sundol-frontend
|
||||
pm2 logs pdf-ocr
|
||||
|
||||
# 특정 프로세스 재시작
|
||||
pm2 restart sundol-backend
|
||||
pm2 restart sundol-frontend
|
||||
pm2 restart pdf-ocr
|
||||
|
||||
# 전체 재시작
|
||||
pm2 restart all
|
||||
|
||||
# 프로세스 중지
|
||||
pm2 stop sundol-backend
|
||||
|
||||
# 프로세스 삭제
|
||||
pm2 delete sundol-backend
|
||||
```
|
||||
|
||||
## 백엔드 시작 스크립트
|
||||
|
||||
파일: `start-backend.sh`
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
set -a
|
||||
source /home/opc/sundol/.env
|
||||
set +a
|
||||
|
||||
JAVA_HOME=${JAVA_HOME:-/usr/lib/jvm/java-21}
|
||||
export JAVA_HOME
|
||||
|
||||
# Playwright 브라우저 경로
|
||||
export PLAYWRIGHT_BROWSERS_PATH=/home/opc/.cache/ms-playwright
|
||||
export PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1
|
||||
|
||||
# Playwright head 모드용 디스플레이
|
||||
# :99 = Xvfb 가상 디스플레이 (브라우저 안 보임)
|
||||
# :1 = VNC 디스플레이 (VNC에서 브라우저 보임)
|
||||
export DISPLAY=:99
|
||||
|
||||
# Xvfb 가상 디스플레이 시작 (DISPLAY=:99일 때만 필요)
|
||||
if ! pgrep -x Xvfb > /dev/null; then
|
||||
Xvfb :99 -screen 0 1280x720x24 -nolisten tcp &
|
||||
sleep 1
|
||||
fi
|
||||
|
||||
BACKEND_DIR=/home/opc/sundol/sundol-backend
|
||||
exec $JAVA_HOME/bin/java -cp "$BACKEND_DIR/target/classes:$BACKEND_DIR/target/dependency/*" com.sundol.SundolApplication
|
||||
```
|
||||
|
||||
### DISPLAY 설정 가이드
|
||||
|
||||
| DISPLAY 값 | 용도 |
|
||||
|-------------|------|
|
||||
| `:99` | Xvfb 가상 디스플레이. 브라우저가 백그라운드에서 동작 (기본값) |
|
||||
| `:1` | VNC 디스플레이. VNC 접속 시 Playwright 브라우저가 화면에 보임 (디버깅용) |
|
||||
|
||||
VNC에서 Playwright 브라우저를 직접 보려면:
|
||||
1. `start-backend.sh`에서 `export DISPLAY=:1`로 변경
|
||||
2. `pm2 restart sundol-backend`
|
||||
|
||||
## 백엔드 빌드
|
||||
|
||||
```bash
|
||||
cd /home/opc/sundol/sundol-backend
|
||||
export JAVA_HOME=/usr/lib/jvm/java-21
|
||||
set -a && source /home/opc/sundol/.env && set +a
|
||||
mvn package -q -DskipTests
|
||||
```
|
||||
|
||||
> 컴파일만: `mvn compile` (위 환경변수 동일)
|
||||
|
||||
## 프론트엔드 빌드/배포
|
||||
|
||||
```bash
|
||||
cd /home/opc/sundol/sundol-frontend
|
||||
bash build.sh
|
||||
```
|
||||
|
||||
> `.env`에 `NEXT_PUBLIC_GOOGLE_CLIENT_ID`, `NEXT_PUBLIC_API_URL` 필수.
|
||||
|
||||
## 배포 절차
|
||||
|
||||
1. 코드 변경 및 빌드
|
||||
2. `pm2 restart sundol-backend` (또는 sundol-frontend)
|
||||
3. `pm2 logs sundol-backend` 로 정상 기동 확인
|
||||
4. `git push origin main`
|
||||
|
||||
## DB 접속 (Oracle Autonomous DB)
|
||||
|
||||
```bash
|
||||
set -a && source /home/opc/sundol/.env && set +a
|
||||
/home/opc/sqlcl/bin/sql ${ORACLE_USERNAME}/${ORACLE_PASSWORD}@${ORACLE_TNS_NAME}?TNS_ADMIN=${ORACLE_WALLET_PATH}
|
||||
```
|
||||
|
||||
## Playwright 관련
|
||||
|
||||
- 브라우저 경로: `/home/opc/.cache/ms-playwright`
|
||||
- WebCrawlerService: Playwright head 모드 (`setHeadless(false)`)
|
||||
- YouTubeTranscriptService: Playwright head 모드 (`setHeadless(false)`)
|
||||
- head 모드 사용 시 DISPLAY 환경변수 필수
|
||||
|
||||
## Playwright 의존 라이브러리
|
||||
|
||||
Playwright Chromium 실행에 필요한 시스템 라이브러리:
|
||||
|
||||
```bash
|
||||
sudo dnf install -y libicu woff2 harfbuzz-icu libjpeg-turbo libwebp enchant2 hyphen libffi
|
||||
```
|
||||
|
||||
> `libx264`는 WebKit 전용이므로 Chromium만 사용할 경우 불필요.
|
||||
> 로그에 `validateDependenciesLinux` 에러가 나오면 위 패키지 설치 확인.
|
||||
|
||||
### Xvfb vs VNC 디스플레이
|
||||
|
||||
| 모드 | DISPLAY | 설명 |
|
||||
|------|---------|------|
|
||||
| Xvfb (운영) | `:99` | 가상 프레임버퍼. 화면 없이 백그라운드 동작 |
|
||||
| VNC (디버깅) | `:1` | VNC 접속 시 브라우저가 화면에 보임 |
|
||||
|
||||
Xvfb 모드로 되돌리려면 `start-backend.sh`에서:
|
||||
```bash
|
||||
export DISPLAY=:99
|
||||
if ! pgrep -x Xvfb > /dev/null; then
|
||||
Xvfb :99 -screen 0 1280x720x24 -nolisten tcp &
|
||||
sleep 1
|
||||
fi
|
||||
```
|
||||
|
||||
## 트러블슈팅
|
||||
|
||||
### 백엔드가 안 뜰 때
|
||||
|
||||
```bash
|
||||
pm2 logs sundol-backend --lines 50
|
||||
```
|
||||
|
||||
### Playwright 브라우저가 안 열릴 때
|
||||
|
||||
```bash
|
||||
# DISPLAY 확인
|
||||
echo $DISPLAY
|
||||
|
||||
# Xvfb 실행 여부 확인
|
||||
pgrep -x Xvfb
|
||||
|
||||
# VNC 실행 여부 확인
|
||||
vncserver -list
|
||||
|
||||
# Playwright 브라우저 설치 확인
|
||||
ls /home/opc/.cache/ms-playwright/
|
||||
```
|
||||
|
||||
### pm2 프로세스가 계속 재시작될 때
|
||||
|
||||
```bash
|
||||
# 재시작 횟수 확인 (↺ 컬럼)
|
||||
pm2 list
|
||||
|
||||
# 에러 로그 확인
|
||||
pm2 logs sundol-backend --err --lines 100
|
||||
```
|
||||
174
docs/setup-xwindow.md
Normal file
174
docs/setup-xwindow.md
Normal file
@@ -0,0 +1,174 @@
|
||||
# X Window + XFCE + VNC 설치 매뉴얼
|
||||
|
||||
OCI(Oracle Cloud Infrastructure) Oracle Linux 9 환경 기준.
|
||||
|
||||
## 1. 사전 확인
|
||||
|
||||
```bash
|
||||
# Xorg 설치 여부 확인
|
||||
which Xorg
|
||||
rpm -q xorg-x11-server-Xorg
|
||||
|
||||
# DISPLAY 환경변수 확인 (비어있으면 X 서버 미실행 상태)
|
||||
echo $DISPLAY
|
||||
```
|
||||
|
||||
## 2. EPEL 저장소 설치
|
||||
|
||||
```bash
|
||||
sudo dnf install -y epel-release
|
||||
```
|
||||
|
||||
## 3. XFCE 데스크톱 환경 설치
|
||||
|
||||
```bash
|
||||
sudo dnf groupinstall -y "Xfce"
|
||||
```
|
||||
|
||||
> GNOME 대비 경량이라 서버 환경에 적합.
|
||||
|
||||
## 4. 기본 타겟을 GUI 모드로 변경
|
||||
|
||||
```bash
|
||||
sudo systemctl set-default graphical.target
|
||||
```
|
||||
|
||||
> 되돌리려면: `sudo systemctl set-default multi-user.target`
|
||||
|
||||
## 5. TigerVNC 서버 설치
|
||||
|
||||
SSH 원격 접속 환경에서는 VNC를 통해 GUI에 접근한다.
|
||||
|
||||
```bash
|
||||
sudo dnf install -y tigervnc-server
|
||||
```
|
||||
|
||||
## 6. VNC 비밀번호 설정
|
||||
|
||||
```bash
|
||||
vncpasswd
|
||||
```
|
||||
|
||||
> VNC 비밀번호는 최대 8자까지만 유효.
|
||||
|
||||
## 7. VNC 시작 스크립트 설정
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.vnc
|
||||
|
||||
cat > ~/.vnc/xstartup << 'EOF'
|
||||
#!/bin/bash
|
||||
exec startxfce4
|
||||
EOF
|
||||
|
||||
chmod +x ~/.vnc/xstartup
|
||||
```
|
||||
|
||||
## 8. VNC 서버 시작
|
||||
|
||||
```bash
|
||||
vncserver :1 -geometry 1920x1080 -depth 24
|
||||
```
|
||||
|
||||
- `:1` = 포트 5901
|
||||
- `:2` = 포트 5902 (추가 세션 필요 시)
|
||||
|
||||
### VNC 서버 중지
|
||||
|
||||
```bash
|
||||
vncserver -kill :1
|
||||
```
|
||||
|
||||
## 9. 방화벽 포트 개방
|
||||
|
||||
```bash
|
||||
sudo firewall-cmd --permanent --add-port=5901/tcp
|
||||
sudo firewall-cmd --reload
|
||||
```
|
||||
|
||||
### OCI 보안 목록 설정
|
||||
|
||||
OCI 콘솔에서 해당 서브넷의 Security List에 인바운드 규칙 추가:
|
||||
- **Source**: 접속할 IP (또는 0.0.0.0/0)
|
||||
- **Protocol**: TCP
|
||||
- **Destination Port**: 5901
|
||||
|
||||
## 10. 클라이언트에서 VNC 접속
|
||||
|
||||
### Mac
|
||||
|
||||
기본 내장 Screen Sharing 사용:
|
||||
|
||||
```bash
|
||||
# Finder에서 Cmd+K → 아래 주소 입력
|
||||
vnc://공인IP:5901
|
||||
|
||||
# 또는 터미널에서
|
||||
open vnc://공인IP:5901
|
||||
```
|
||||
|
||||
### Windows
|
||||
|
||||
- RealVNC Viewer, TigerVNC Viewer 등 VNC 클라이언트 설치
|
||||
- 주소: `공인IP:5901`
|
||||
|
||||
## 11. Chrome 브라우저 설치
|
||||
|
||||
```bash
|
||||
# Google Chrome 저장소 추가
|
||||
cat << 'EOF' | sudo tee /etc/yum.repos.d/google-chrome.repo
|
||||
[google-chrome]
|
||||
name=google-chrome
|
||||
baseurl=https://dl.google.com/linux/chrome/rpm/stable/x86_64
|
||||
enabled=1
|
||||
gpgcheck=1
|
||||
gpgkey=https://dl.google.com/linux/linux_signing_key.pub
|
||||
EOF
|
||||
|
||||
# 설치
|
||||
sudo dnf install -y google-chrome-stable
|
||||
```
|
||||
|
||||
## 12. 한글 폰트 설치
|
||||
|
||||
브라우저 등에서 한글이 깨지는 경우 CJK 폰트를 설치한다.
|
||||
|
||||
```bash
|
||||
sudo dnf install -y google-noto-sans-cjk-ttc-fonts google-noto-serif-cjk-ttc-fonts
|
||||
|
||||
# 폰트 캐시 갱신
|
||||
fc-cache -fv
|
||||
```
|
||||
|
||||
> 설치 후 Chrome 등 브라우저를 재시작해야 반영됨.
|
||||
|
||||
## 트러블슈팅
|
||||
|
||||
### VNC 접속이 안 될 때
|
||||
|
||||
```bash
|
||||
# VNC 서버 실행 확인
|
||||
vncserver -list
|
||||
|
||||
# 로그 확인
|
||||
cat ~/.vnc/*.log
|
||||
|
||||
# 방화벽 확인
|
||||
sudo firewall-cmd --list-ports
|
||||
```
|
||||
|
||||
### 화면이 회색/빈 화면일 때
|
||||
|
||||
`~/.vnc/xstartup` 파일 내용이 올바른지 확인:
|
||||
|
||||
```bash
|
||||
cat ~/.vnc/xstartup
|
||||
# exec startxfce4 가 있어야 함
|
||||
```
|
||||
|
||||
VNC 서버 재시작:
|
||||
|
||||
```bash
|
||||
vncserver -kill :1
|
||||
vncserver :1 -geometry 1920x1080 -depth 24
|
||||
```
|
||||
@@ -11,11 +11,8 @@ export PLAYWRIGHT_BROWSERS_PATH=/home/opc/.cache/ms-playwright
|
||||
export PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1
|
||||
|
||||
# Xvfb virtual display for Playwright head mode (YouTube transcript)
|
||||
export DISPLAY=:99
|
||||
if ! pgrep -x Xvfb > /dev/null; then
|
||||
Xvfb :99 -screen 0 1280x720x24 -nolisten tcp &
|
||||
sleep 1
|
||||
fi
|
||||
# :99 = Xvfb (백그라운드), :1 = VNC (디버깅용)
|
||||
export DISPLAY=:1
|
||||
|
||||
# Playwright driver-bundle requires exploded classpath (fat JAR extraction fails)
|
||||
BACKEND_DIR=/home/opc/sundol/sundol-backend
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
package com.sundol.service;
|
||||
|
||||
import com.microsoft.playwright.*;
|
||||
import com.microsoft.playwright.options.WaitUntilState;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import jakarta.annotation.PreDestroy;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Playwright 브라우저를 싱글톤으로 유지하는 서비스.
|
||||
* 앱 기동 시 브라우저를 한 번 띄우고, 각 작업은 새 탭(Page)으로 처리한다.
|
||||
*/
|
||||
@Service
|
||||
public class PlaywrightBrowserService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(PlaywrightBrowserService.class);
|
||||
|
||||
private Playwright playwright;
|
||||
private Browser browser;
|
||||
private BrowserContext context;
|
||||
|
||||
@PostConstruct
|
||||
public void init() {
|
||||
try {
|
||||
playwright = Playwright.create();
|
||||
BrowserType.LaunchOptions launchOptions = new BrowserType.LaunchOptions()
|
||||
.setHeadless(false)
|
||||
.setArgs(List.of(
|
||||
"--no-sandbox",
|
||||
"--disable-setuid-sandbox",
|
||||
"--disable-dev-shm-usage",
|
||||
"--disable-gpu"
|
||||
));
|
||||
|
||||
browser = playwright.chromium().launch(launchOptions);
|
||||
context = browser.newContext(new Browser.NewContextOptions()
|
||||
.setUserAgent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36")
|
||||
.setLocale("ko-KR"));
|
||||
|
||||
loadCookies(context);
|
||||
|
||||
log.info("Playwright 브라우저 싱글톤 초기화 완료");
|
||||
} catch (Exception e) {
|
||||
log.error("Playwright 브라우저 초기화 실패: {}", e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
@PreDestroy
|
||||
public void destroy() {
|
||||
try {
|
||||
if (context != null) context.close();
|
||||
if (browser != null) browser.close();
|
||||
if (playwright != null) playwright.close();
|
||||
log.info("Playwright 브라우저 종료 완료");
|
||||
} catch (Exception e) {
|
||||
log.warn("Playwright 브라우저 종료 중 오류: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 새 탭을 열고 URL로 이동한다. 호출자가 사용 후 page.close()를 해야 한다.
|
||||
*/
|
||||
public Page openPage(String url, int timeoutMs) throws IOException {
|
||||
ensureBrowserAlive();
|
||||
Page page = context.newPage();
|
||||
try {
|
||||
page.navigate(url, new Page.NavigateOptions()
|
||||
.setTimeout(timeoutMs)
|
||||
.setWaitUntil(WaitUntilState.NETWORKIDLE));
|
||||
return page;
|
||||
} catch (Exception e) {
|
||||
page.close();
|
||||
throw new IOException("페이지 로드 실패 (" + url + "): " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 새 탭을 열고 URL로 이동한다 (기본 30초 타임아웃).
|
||||
*/
|
||||
public Page openPage(String url) throws IOException {
|
||||
return openPage(url, 30_000);
|
||||
}
|
||||
|
||||
/**
|
||||
* 브라우저 페이지 내에서 JavaScript fetch로 URL 내용을 가져온다.
|
||||
* 브라우저의 쿠키/세션이 그대로 적용된다.
|
||||
*/
|
||||
public String fetchInPage(Page page, String url) throws IOException {
|
||||
try {
|
||||
Object result = page.evaluate("async (url) => {" +
|
||||
" const res = await fetch(url, { credentials: 'include' });" +
|
||||
" if (!res.ok) throw new Error('HTTP ' + res.status);" +
|
||||
" return await res.text();" +
|
||||
"}", url);
|
||||
return result != null ? result.toString() : "";
|
||||
} catch (Exception e) {
|
||||
throw new IOException("브라우저 내 fetch 실패 (" + url + "): " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 브라우저가 죽었으면 재시작한다.
|
||||
*/
|
||||
private synchronized void ensureBrowserAlive() throws IOException {
|
||||
if (browser != null && browser.isConnected()) {
|
||||
return;
|
||||
}
|
||||
log.warn("Playwright 브라우저가 죽어있습니다. 재시작합니다.");
|
||||
destroy();
|
||||
try {
|
||||
playwright = Playwright.create();
|
||||
browser = playwright.chromium().launch(new BrowserType.LaunchOptions()
|
||||
.setHeadless(false)
|
||||
.setArgs(List.of(
|
||||
"--no-sandbox",
|
||||
"--disable-setuid-sandbox",
|
||||
"--disable-dev-shm-usage",
|
||||
"--disable-gpu"
|
||||
)));
|
||||
context = browser.newContext(new Browser.NewContextOptions()
|
||||
.setUserAgent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36")
|
||||
.setLocale("ko-KR"));
|
||||
loadCookies(context);
|
||||
log.info("Playwright 브라우저 재시작 완료");
|
||||
} catch (Exception e) {
|
||||
throw new IOException("Playwright 브라우저 재시작 실패: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
private void loadCookies(BrowserContext ctx) {
|
||||
Path cookieFile = Path.of(System.getProperty("user.dir"), "cookies.txt");
|
||||
if (!Files.exists(cookieFile)) {
|
||||
log.warn("cookies.txt not found at: {}", cookieFile);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
List<String> lines = Files.readAllLines(cookieFile);
|
||||
List<com.microsoft.playwright.options.Cookie> cookies = new ArrayList<>();
|
||||
for (String line : lines) {
|
||||
if (line.startsWith("#") || line.isBlank()) continue;
|
||||
String[] parts = line.split("\t");
|
||||
if (parts.length < 7) continue;
|
||||
String domain = parts[0];
|
||||
if (!domain.contains("youtube") && !domain.contains("google")) continue;
|
||||
cookies.add(new com.microsoft.playwright.options.Cookie(parts[5], parts[6])
|
||||
.setDomain(domain)
|
||||
.setPath(parts[2])
|
||||
.setSecure("TRUE".equalsIgnoreCase(parts[3]))
|
||||
.setHttpOnly(false));
|
||||
}
|
||||
if (!cookies.isEmpty()) {
|
||||
ctx.addCookies(cookies);
|
||||
log.info("Loaded {} YouTube cookies", cookies.size());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to load cookies: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,6 @@
|
||||
package com.sundol.service;
|
||||
|
||||
import com.microsoft.playwright.Browser;
|
||||
import com.microsoft.playwright.BrowserType;
|
||||
import com.microsoft.playwright.Page;
|
||||
import com.microsoft.playwright.Playwright;
|
||||
import org.jsoup.Jsoup;
|
||||
import org.jsoup.nodes.Document;
|
||||
import org.jsoup.nodes.Element;
|
||||
@@ -31,11 +28,13 @@ public class WebCrawlerService {
|
||||
);
|
||||
|
||||
private final WebClient webClient;
|
||||
private final PlaywrightBrowserService browserService;
|
||||
|
||||
@Value("${jina.reader.api-key:}")
|
||||
private String jinaApiKey;
|
||||
|
||||
public WebCrawlerService() {
|
||||
public WebCrawlerService(PlaywrightBrowserService browserService) {
|
||||
this.browserService = browserService;
|
||||
this.webClient = WebClient.builder()
|
||||
.codecs(configurer -> configurer.defaultCodecs().maxInMemorySize(5 * 1024 * 1024))
|
||||
.build();
|
||||
@@ -66,7 +65,7 @@ public class WebCrawlerService {
|
||||
log.warn("Jina Reader failed for {}: {}, falling back to Playwright", url, e.getMessage());
|
||||
}
|
||||
|
||||
// 3차: Playwright headless browser (최후의 수단)
|
||||
// 3차: Playwright (싱글톤 브라우저 탭)
|
||||
String playwrightText = crawlWithPlaywright(url);
|
||||
if (!isValidContent(playwrightText)) {
|
||||
throw new IOException("All crawl methods failed for " + url + " (error page detected from all 3 sources)");
|
||||
@@ -141,47 +140,29 @@ public class WebCrawlerService {
|
||||
|
||||
private String crawlWithPlaywright(String url) throws IOException {
|
||||
log.info("Crawling with Playwright: {}", url);
|
||||
try (Playwright playwright = Playwright.create()) {
|
||||
BrowserType.LaunchOptions launchOptions = new BrowserType.LaunchOptions()
|
||||
.setHeadless(true)
|
||||
.setArgs(java.util.List.of(
|
||||
"--no-sandbox",
|
||||
"--disable-setuid-sandbox",
|
||||
"--disable-dev-shm-usage",
|
||||
"--disable-gpu"
|
||||
));
|
||||
Page page = browserService.openPage(url);
|
||||
try {
|
||||
// JS 실행으로 본문 텍스트 추출
|
||||
String text = page.evaluate("() => {" +
|
||||
" ['nav','footer','header','script','style','.ad','#cookie-banner','.sidebar','.comments']" +
|
||||
" .forEach(sel => document.querySelectorAll(sel).forEach(el => el.remove()));" +
|
||||
" const article = document.querySelector('article, main, .post-content, .article-body, .entry-content');" +
|
||||
" return (article || document.body).innerText;" +
|
||||
"}").toString();
|
||||
|
||||
try (Browser browser = playwright.chromium().launch(launchOptions)) {
|
||||
Browser.NewContextOptions contextOptions = new Browser.NewContextOptions()
|
||||
.setUserAgent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36");
|
||||
log.info("Playwright crawled {} - {} chars", url, text.length());
|
||||
|
||||
var context = browser.newContext(contextOptions);
|
||||
Page page = context.newPage();
|
||||
|
||||
page.navigate(url, new Page.NavigateOptions()
|
||||
.setTimeout(30_000)
|
||||
.setWaitUntil(com.microsoft.playwright.options.WaitUntilState.NETWORKIDLE));
|
||||
|
||||
// JS 실행으로 본문 텍스트 추출
|
||||
String text = page.evaluate("() => {" +
|
||||
" ['nav','footer','header','script','style','.ad','#cookie-banner','.sidebar','.comments']" +
|
||||
" .forEach(sel => document.querySelectorAll(sel).forEach(el => el.remove()));" +
|
||||
" const article = document.querySelector('article, main, .post-content, .article-body, .entry-content');" +
|
||||
" return (article || document.body).innerText;" +
|
||||
"}").toString();
|
||||
|
||||
log.info("Playwright crawled {} - {} chars", url, text.length());
|
||||
|
||||
if (text == null || text.isBlank()) {
|
||||
throw new IOException("Playwright returned empty content for: " + url);
|
||||
}
|
||||
|
||||
return text;
|
||||
if (text == null || text.isBlank()) {
|
||||
throw new IOException("Playwright returned empty content for: " + url);
|
||||
}
|
||||
|
||||
return text;
|
||||
} finally {
|
||||
try {
|
||||
page.navigate("about:blank");
|
||||
} catch (Exception ignored) {
|
||||
page.close();
|
||||
}
|
||||
} catch (IOException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
throw new IOException("Playwright failed for " + url + ": " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -205,17 +186,16 @@ public class WebCrawlerService {
|
||||
return firstLine.length() > 80 ? firstLine.substring(0, 77) + "..." : firstLine;
|
||||
} catch (Exception e2) {
|
||||
log.warn("Jina Reader title extraction also failed, trying Playwright", e2);
|
||||
// Playwright로 제목 추출
|
||||
try (Playwright playwright = Playwright.create()) {
|
||||
try (Browser browser = playwright.chromium().launch(
|
||||
new BrowserType.LaunchOptions().setHeadless(true)
|
||||
.setArgs(java.util.List.of("--no-sandbox", "--disable-setuid-sandbox")))) {
|
||||
Page page = browser.newPage();
|
||||
page.navigate(url, new Page.NavigateOptions().setTimeout(30_000));
|
||||
return page.title();
|
||||
// Playwright 싱글톤 브라우저로 제목 추출
|
||||
Page page = browserService.openPage(url);
|
||||
try {
|
||||
return page.title();
|
||||
} finally {
|
||||
try {
|
||||
page.navigate("about:blank");
|
||||
} catch (Exception ignored2) {
|
||||
page.close();
|
||||
}
|
||||
} catch (Exception e3) {
|
||||
throw new IOException("All title extraction methods failed for: " + url, e3);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package com.sundol.service;
|
||||
|
||||
import com.microsoft.playwright.*;
|
||||
import com.microsoft.playwright.options.WaitUntilState;
|
||||
import com.microsoft.playwright.Page;
|
||||
import io.github.thoroldvix.api.TranscriptApiFactory;
|
||||
import io.github.thoroldvix.api.TranscriptContent;
|
||||
import io.github.thoroldvix.api.TranscriptList;
|
||||
@@ -14,10 +13,6 @@ import org.springframework.stereotype.Service;
|
||||
import java.io.IOException;
|
||||
import java.net.URLDecoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.Collectors;
|
||||
@@ -39,6 +34,11 @@ public class YouTubeTranscriptService {
|
||||
Pattern.compile("<text[^>]*>(.*?)</text>", Pattern.DOTALL);
|
||||
|
||||
private final YoutubeTranscriptApi transcriptApi = TranscriptApiFactory.createDefault();
|
||||
private final PlaywrightBrowserService browserService;
|
||||
|
||||
public YouTubeTranscriptService(PlaywrightBrowserService browserService) {
|
||||
this.browserService = browserService;
|
||||
}
|
||||
|
||||
public String fetchTranscript(String youtubeUrl) throws IOException {
|
||||
String videoId = extractVideoId(youtubeUrl);
|
||||
@@ -59,7 +59,7 @@ public class YouTubeTranscriptService {
|
||||
log.warn("youtube-transcript-api failed for {}: {}", videoId, e.getMessage());
|
||||
}
|
||||
|
||||
// 2차 fallback: Playwright head 모드
|
||||
// 2차 fallback: Playwright head 모드 (싱글톤 브라우저 탭)
|
||||
log.info("Falling back to Playwright for videoId: {}", videoId);
|
||||
return fetchWithPlaywright(videoId);
|
||||
}
|
||||
@@ -107,85 +107,157 @@ public class YouTubeTranscriptService {
|
||||
private String fetchWithPlaywright(String videoId) throws IOException {
|
||||
String watchUrl = "https://www.youtube.com/watch?v=" + videoId;
|
||||
|
||||
String html;
|
||||
try (Playwright playwright = Playwright.create()) {
|
||||
BrowserType.LaunchOptions launchOptions = new BrowserType.LaunchOptions()
|
||||
.setHeadless(false)
|
||||
.setArgs(List.of(
|
||||
"--no-sandbox",
|
||||
"--disable-setuid-sandbox",
|
||||
"--disable-dev-shm-usage"
|
||||
));
|
||||
|
||||
try (Browser browser = playwright.chromium().launch(launchOptions)) {
|
||||
BrowserContext context = browser.newContext(new Browser.NewContextOptions()
|
||||
.setUserAgent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36")
|
||||
.setLocale("ko-KR"));
|
||||
|
||||
// YouTube 쿠키 로딩 (봇 차단 우회)
|
||||
loadCookies(context);
|
||||
|
||||
Page page = context.newPage();
|
||||
|
||||
page.navigate(watchUrl, new Page.NavigateOptions()
|
||||
.setTimeout(30_000)
|
||||
.setWaitUntil(WaitUntilState.NETWORKIDLE));
|
||||
|
||||
html = page.content();
|
||||
log.info("Playwright fetched YouTube page: {} chars", html.length());
|
||||
|
||||
context.close();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
throw new IOException("YouTube 페이지를 가져올 수 없습니다 (Playwright): " + e.getMessage(), e);
|
||||
}
|
||||
|
||||
// captionTracks JSON 추출
|
||||
Matcher captionMatcher = CAPTION_TRACK_PATTERN.matcher(html);
|
||||
if (!captionMatcher.find()) {
|
||||
throw new IOException("이 영상에는 자막(caption)이 없습니다.");
|
||||
}
|
||||
|
||||
String captionTracksJson = captionMatcher.group(1);
|
||||
String captionUrl = selectCaptionUrl(captionTracksJson);
|
||||
if (captionUrl == null) {
|
||||
throw new IOException("자막 트랙 URL을 추출할 수 없습니다.");
|
||||
}
|
||||
|
||||
captionUrl = captionUrl.replace("\\u0026", "&");
|
||||
log.info("Fetching caption XML from: {}", captionUrl);
|
||||
|
||||
// 자막 XML 가져오기
|
||||
log.info("Attempting to fetch caption XML...");
|
||||
String xml;
|
||||
Page page = browserService.openPage(watchUrl);
|
||||
try {
|
||||
var conn = (java.net.HttpURLConnection) new java.net.URI(captionUrl).toURL().openConnection();
|
||||
conn.setRequestProperty("User-Agent", "Mozilla/5.0");
|
||||
conn.setConnectTimeout(15_000);
|
||||
conn.setReadTimeout(15_000);
|
||||
int responseCode = conn.getResponseCode();
|
||||
log.info("Caption XML response code: {}", responseCode);
|
||||
if (responseCode != 200) {
|
||||
String errorBody = new String(conn.getErrorStream().readAllBytes(), StandardCharsets.UTF_8);
|
||||
log.error("Caption XML error response: {}", errorBody);
|
||||
throw new IOException("자막 XML 응답 코드: " + responseCode);
|
||||
log.info("Playwright fetched YouTube page for videoId: {}", videoId);
|
||||
|
||||
// 방법 1: YouTube 페이지 내 JS로 자막 패널 열어서 텍스트 추출
|
||||
String transcript = extractTranscriptFromPanel(page);
|
||||
if (transcript != null && !transcript.isBlank()) {
|
||||
log.info("Successfully fetched transcript via panel: {} chars", transcript.length());
|
||||
return transcript;
|
||||
}
|
||||
xml = new String(conn.getInputStream().readAllBytes(), StandardCharsets.UTF_8);
|
||||
log.info("Caption XML fetched: {} chars", xml.length());
|
||||
|
||||
// 방법 2: ytInitialPlayerResponse에서 caption URL 추출 후 fmt=json3로 시도
|
||||
String html = page.content();
|
||||
Matcher captionMatcher = CAPTION_TRACK_PATTERN.matcher(html);
|
||||
if (!captionMatcher.find()) {
|
||||
throw new IOException("이 영상에는 자막(caption)이 없습니다.");
|
||||
}
|
||||
|
||||
String captionTracksJson = captionMatcher.group(1);
|
||||
String captionUrl = selectCaptionUrl(captionTracksJson);
|
||||
if (captionUrl == null) {
|
||||
throw new IOException("자막 트랙 URL을 추출할 수 없습니다.");
|
||||
}
|
||||
|
||||
captionUrl = captionUrl.replace("\\u0026", "&");
|
||||
// fmt=json3 추가하여 JSON 형식으로 요청
|
||||
if (!captionUrl.contains("fmt=")) {
|
||||
captionUrl += "&fmt=json3";
|
||||
}
|
||||
log.info("Fetching caption JSON from: {}", captionUrl);
|
||||
|
||||
String json = browserService.fetchInPage(page, captionUrl);
|
||||
log.info("Caption JSON fetched: {} chars", json.length());
|
||||
|
||||
if (json.length() > 0) {
|
||||
transcript = parseTranscriptJson(json);
|
||||
if (transcript != null && !transcript.isBlank()) {
|
||||
log.info("Successfully fetched transcript via JSON: {} chars", transcript.length());
|
||||
return transcript;
|
||||
}
|
||||
}
|
||||
|
||||
throw new IOException("자막 텍스트를 가져올 수 없습니다.");
|
||||
} finally {
|
||||
// 탭을 닫지 않고 빈 페이지로 이동 (브라우저 창 유지)
|
||||
try {
|
||||
page.navigate("about:blank");
|
||||
} catch (Exception ignored) {
|
||||
page.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* YouTube 페이지에서 '자막 표시' 패널을 열고 텍스트를 추출한다.
|
||||
*/
|
||||
private String extractTranscriptFromPanel(Page page) {
|
||||
try {
|
||||
// '더보기' 또는 '...더보기' 버튼 클릭하여 설명 패널 열기
|
||||
page.waitForTimeout(2000);
|
||||
|
||||
// 자막 패널 열기: '스크립트 표시' 버튼 찾기
|
||||
Object result = page.evaluate("() => {" +
|
||||
" // 방법 1: 동영상 설명 아래 '스크립트 표시' 버튼 클릭" +
|
||||
" const buttons = document.querySelectorAll('button, ytd-button-renderer');" +
|
||||
" for (const btn of buttons) {" +
|
||||
" const text = btn.innerText || btn.textContent || '';" +
|
||||
" if (text.includes('스크립트 표시') || text.includes('Show transcript') || text.includes('자막')) {" +
|
||||
" btn.click();" +
|
||||
" return 'clicked';" +
|
||||
" }" +
|
||||
" }" +
|
||||
" // 방법 2: 메뉴에서 스크립트 열기" +
|
||||
" const menuBtn = document.querySelector('#button-shape button[aria-label=\"더보기\"], button.ytp-subtitles-button');" +
|
||||
" if (menuBtn) { menuBtn.click(); return 'menu_clicked'; }" +
|
||||
" return 'not_found';" +
|
||||
"}");
|
||||
|
||||
log.info("Transcript panel button result: {}", result);
|
||||
|
||||
if ("not_found".equals(result)) {
|
||||
// 대안: 설명란 펼치기 → 스크립트 표시
|
||||
page.evaluate("() => {" +
|
||||
" const expander = document.querySelector('#expand, tp-yt-paper-button#expand');" +
|
||||
" if (expander) expander.click();" +
|
||||
"}");
|
||||
page.waitForTimeout(1000);
|
||||
|
||||
page.evaluate("() => {" +
|
||||
" const buttons = document.querySelectorAll('button, ytd-button-renderer');" +
|
||||
" for (const btn of buttons) {" +
|
||||
" const text = btn.innerText || btn.textContent || '';" +
|
||||
" if (text.includes('스크립트 표시') || text.includes('Show transcript')) {" +
|
||||
" btn.click();" +
|
||||
" return 'clicked';" +
|
||||
" }" +
|
||||
" }" +
|
||||
" return 'not_found';" +
|
||||
"}");
|
||||
}
|
||||
|
||||
// 자막 패널이 로드될 때까지 대기
|
||||
page.waitForTimeout(3000);
|
||||
|
||||
// 자막 텍스트 추출
|
||||
Object transcriptObj = page.evaluate("() => {" +
|
||||
" // 스크립트 패널의 자막 세그먼트 추출" +
|
||||
" const segments = document.querySelectorAll(" +
|
||||
" 'ytd-transcript-segment-renderer .segment-text," +
|
||||
" yt-formatted-string.segment-text," +
|
||||
" #segments-container yt-formatted-string'" +
|
||||
" );" +
|
||||
" if (segments.length > 0) {" +
|
||||
" return Array.from(segments).map(s => s.textContent.trim()).filter(t => t.length > 0).join(' ');" +
|
||||
" }" +
|
||||
" return '';" +
|
||||
"}");
|
||||
|
||||
String transcript = transcriptObj != null ? transcriptObj.toString() : "";
|
||||
log.info("Transcript from panel: {} chars", transcript.length());
|
||||
return transcript.isBlank() ? null : transcript;
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to fetch caption XML: {}", e.getMessage(), e);
|
||||
throw new IOException("자막 XML을 가져올 수 없습니다: " + e.getMessage(), e);
|
||||
log.warn("Failed to extract transcript from panel: {}", e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
String transcript = parseTranscriptXml(xml);
|
||||
log.info("Parsed transcript: {} chars (blank={})", transcript.length(), transcript.isBlank());
|
||||
if (transcript.isBlank()) {
|
||||
log.error("Transcript XML content (first 500 chars): {}", xml.substring(0, Math.min(500, xml.length())));
|
||||
throw new IOException("자막 텍스트를 파싱할 수 없습니다.");
|
||||
/**
|
||||
* YouTube timedtext API의 fmt=json3 응답을 파싱한다.
|
||||
*/
|
||||
private String parseTranscriptJson(String json) {
|
||||
try {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
// json3 형식: {"events":[{"segs":[{"utf8":"text"}]},...]}
|
||||
Pattern segPattern = Pattern.compile("\"utf8\":\\s*\"(.*?)\"");
|
||||
Matcher matcher = segPattern.matcher(json);
|
||||
while (matcher.find()) {
|
||||
String text = matcher.group(1)
|
||||
.replace("\\n", " ")
|
||||
.replace("\\\"", "\"")
|
||||
.trim();
|
||||
if (!text.isEmpty() && !text.equals("\n")) {
|
||||
if (sb.length() > 0) sb.append(" ");
|
||||
sb.append(text);
|
||||
}
|
||||
}
|
||||
return sb.toString();
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to parse transcript JSON: {}", e.getMessage());
|
||||
return null;
|
||||
}
|
||||
|
||||
log.info("Successfully fetched transcript via Playwright: {} chars", transcript.length());
|
||||
return transcript;
|
||||
}
|
||||
|
||||
private String selectCaptionUrl(String captionTracksJson) {
|
||||
@@ -236,37 +308,6 @@ public class YouTubeTranscriptService {
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private void loadCookies(BrowserContext context) {
|
||||
Path cookieFile = Path.of(System.getProperty("user.dir"), "cookies.txt");
|
||||
if (!Files.exists(cookieFile)) {
|
||||
log.warn("cookies.txt not found at: {}", cookieFile);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
List<String> lines = Files.readAllLines(cookieFile);
|
||||
List<com.microsoft.playwright.options.Cookie> cookies = new ArrayList<>();
|
||||
for (String line : lines) {
|
||||
if (line.startsWith("#") || line.isBlank()) continue;
|
||||
String[] parts = line.split("\t");
|
||||
if (parts.length < 7) continue;
|
||||
String domain = parts[0];
|
||||
if (!domain.contains("youtube") && !domain.contains("google")) continue;
|
||||
cookies.add(new com.microsoft.playwright.options.Cookie(parts[5], parts[6])
|
||||
.setDomain(domain)
|
||||
.setPath(parts[2])
|
||||
.setSecure("TRUE".equalsIgnoreCase(parts[3]))
|
||||
.setHttpOnly(false));
|
||||
}
|
||||
if (!cookies.isEmpty()) {
|
||||
context.addCookies(cookies);
|
||||
log.info("Loaded {} YouTube cookies", cookies.size());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to load cookies: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private String extractVideoId(String url) {
|
||||
if (url == null || url.isBlank()) return null;
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user