diff --git a/docs/crawling-guide.md b/docs/crawling-guide.md new file mode 100644 index 0000000..92884ca --- /dev/null +++ b/docs/crawling-guide.md @@ -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 환경변수 설정 | diff --git a/docs/operation-manual.md b/docs/operation-manual.md new file mode 100644 index 0000000..39f1120 --- /dev/null +++ b/docs/operation-manual.md @@ -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 +``` diff --git a/docs/setup-xwindow.md b/docs/setup-xwindow.md new file mode 100644 index 0000000..e3e8336 --- /dev/null +++ b/docs/setup-xwindow.md @@ -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 +``` diff --git a/start-backend.sh b/start-backend.sh index de2138b..0a51ca8 100755 --- a/start-backend.sh +++ b/start-backend.sh @@ -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 diff --git a/sundol-backend/src/main/java/com/sundol/service/PlaywrightBrowserService.java b/sundol-backend/src/main/java/com/sundol/service/PlaywrightBrowserService.java new file mode 100644 index 0000000..36ea452 --- /dev/null +++ b/sundol-backend/src/main/java/com/sundol/service/PlaywrightBrowserService.java @@ -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 lines = Files.readAllLines(cookieFile); + List 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()); + } + } +} diff --git a/sundol-backend/src/main/java/com/sundol/service/WebCrawlerService.java b/sundol-backend/src/main/java/com/sundol/service/WebCrawlerService.java index 5ebb40a..eeb2078 100644 --- a/sundol-backend/src/main/java/com/sundol/service/WebCrawlerService.java +++ b/sundol-backend/src/main/java/com/sundol/service/WebCrawlerService.java @@ -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); } } } diff --git a/sundol-backend/src/main/java/com/sundol/service/YouTubeTranscriptService.java b/sundol-backend/src/main/java/com/sundol/service/YouTubeTranscriptService.java index a792e45..d796736 100644 --- a/sundol-backend/src/main/java/com/sundol/service/YouTubeTranscriptService.java +++ b/sundol-backend/src/main/java/com/sundol/service/YouTubeTranscriptService.java @@ -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("]*>(.*?)", 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 lines = Files.readAllLines(cookieFile); - List 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 {