From 4cde775809f235a1995b80c131bb3d01d3bc7756 Mon Sep 17 00:00:00 2001 From: joungmin Date: Thu, 9 Apr 2026 21:01:49 +0000 Subject: [PATCH] Switch to user Chrome CDP for YouTube transcript, fix auth and ads - Replace Playwright standalone browser with CDP connection to user Chrome (bypasses YouTube bot detection by using logged-in Chrome session) - Add video playback, ad detection/skip, and play confirmation before transcript extraction - Extract transcript JS to separate resource files (fix SyntaxError in evaluate) - Add ytInitialPlayerResponse-based transcript extraction as primary method - Fix token refresh: retry on network error during backend restart - Fix null userId logout, CLOB type hint for structured_content - Disable XFCE screen lock/screensaver - Add troubleshooting entries (#10-12) and YouTube transcript guide Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/troubleshooting.md | 97 ++++ docs/youtube-transcript-guide.md | 207 +++++++++ .../controller/KnowledgeController.java | 2 +- .../repository/KnowledgeRepository.java | 3 +- .../java/com/sundol/service/AuthService.java | 4 + .../service/PlaywrightBrowserService.java | 127 ++---- .../service/YouTubeTranscriptService.java | 430 ++++++++++++++---- .../resources/youtube-transcript-extract.js | 33 ++ .../resources/youtube-transcript-from-page.js | 72 +++ sundol-frontend/src/lib/api.ts | 18 +- 10 files changed, 818 insertions(+), 175 deletions(-) create mode 100644 docs/youtube-transcript-guide.md create mode 100644 sundol-backend/src/main/resources/youtube-transcript-extract.js create mode 100644 sundol-backend/src/main/resources/youtube-transcript-from-page.js diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index f3281e7..1c95a56 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -210,3 +210,100 @@ sudo firewall-cmd --reload ``` 자세한 내용은 [setup-xwindow.md](setup-xwindow.md) 참조. + +--- + +## 10. VNC 접속 시 스크린 락(화면 잠금) 걸림 + +### 증상 +- VNC로 접속하면 검은 화면에 로그인 다이얼로그가 뜸 +- 잠시 자리를 비운 뒤 접속하면 사용자 비밀번호를 요구 + +### 원인 +XFCE 스크린세이버가 기본 활성화되어 있어, 일정 시간 비활성 후 화면을 잠금. + +### 해결 + +```bash +export DISPLAY=:1 + +# 스크린세이버 비활성화 +xfconf-query -c xfce4-screensaver -p /saver/enabled --create -t bool -s false + +# 화면 잠금 비활성화 +xfconf-query -c xfce4-screensaver -p /lock/enabled --create -t bool -s false +xfconf-query -c xfce4-screensaver -p /lock/saver-activation/enabled --create -t bool -s false + +# 모니터 절전(DPMS) 비활성화 +xfconf-query -c xfce4-power-manager -p /xfce4-power-manager/dpms-enabled --create -t bool -s false +xfconf-query -c xfce4-power-manager -p /xfce4-power-manager/lock-screen-suspend-hibernate --create -t bool -s false + +# 잠금 명령 제거 +xfconf-query -c xfce4-session -p /general/LockCommand --create -t string -s "" + +# 실행 중인 screensaver 프로세스 종료 +ps aux | grep -E 'xfce4-screensaver|screensaver-dialog' | grep -v grep | awk '{print $2}' | xargs kill 2>/dev/null +``` + +--- + +## 11. YouTube 자막 추출 시 광고 때문에 실패 + +### 증상 +- Playwright로 YouTube 페이지 로드 후 자막 패널이 열리지 않음 +- VNC에서 보면 광고가 재생 중 + +### 원인 +YouTube 동영상 앞에 프리롤 광고가 삽입되면 본 영상 UI(자막 패널 등)가 활성화되지 않음. + +### 해결 +코드에 `waitForAdsToFinish()` 로직이 포함되어 있음: +- 최대 60초간 2초 간격으로 광고 상태 확인 +- 스킵 버튼(`.ytp-skip-ad-button` 등)이 나타나면 자동 클릭 +- 광고가 끝나면 자막 추출 진행 + +수동으로 확인하려면 VNC에서 Playwright 브라우저 창을 직접 관찰. + +--- + +## 12. 백엔드 재시작 시 로그인이 풀림 + +### 증상 +- 백엔드(`pm2 restart sundol-backend`) 후 프론트엔드에서 로그인 화면으로 튕김 +- 로그에 `User logged out: null` 표시 + +### 원인 +1. 백엔드 재시작 중 프론트엔드가 API 호출 → 연결 실패(네트워크 에러) +2. axios 인터셉터가 401로 인식 → refresh 시도 → 서버 아직 안 떠서 실패 +3. `onRefreshFailed` 콜백 → 자동 로그아웃 실행 + +### 해결 +프론트엔드 axios 인터셉터에서 refresh 실패 시 네트워크 에러이면 3초 후 최대 2회 재시도: + +```typescript +const attemptRefresh = async (retryCount: number): Promise => { + try { + const res = await api.post("/api/auth/refresh"); + return res.data.accessToken; + } catch (err) { + const isNetworkError = !((err as AxiosError).response); + if (isNetworkError && retryCount < 2) { + await new Promise((r) => setTimeout(r, 3000)); + return attemptRefresh(retryCount + 1); + } + throw err; + } +}; +``` + +백엔드 logout에도 userId null 체크 추가: +```java +if (userId == null) { + log.warn("Logout called with null userId, ignoring"); + return; +} +``` + +### 예방 +- 백엔드 재시작 시 기동 완료까지 약 10~25초 소요 +- 프론트엔드는 네트워크 에러 시 최대 9초(3초 × 3회) 대기 후 재시도 diff --git a/docs/youtube-transcript-guide.md b/docs/youtube-transcript-guide.md new file mode 100644 index 0000000..2683f41 --- /dev/null +++ b/docs/youtube-transcript-guide.md @@ -0,0 +1,207 @@ +# YouTube 트랜스크립트 추출 가이드 + +## 아키텍처 + +``` +┌──────────────────────────────────────────────────┐ +│ 사용자 Chrome 브라우저 │ +│ (VNC에서 직접 YouTube 로그인된 상태) │ +│ --remote-debugging-port=9222 │ +│ --remote-allow-origins=* │ +└──────────────┬───────────────────────────────────┘ + │ CDP (Chrome DevTools Protocol) +┌──────────────▼───────────────────────────────────┐ +│ PlaywrightBrowserService │ +│ playwright.chromium().connectOverCDP() │ +│ → 사용자 Chrome의 세션/쿠키를 그대로 사용 │ +└──────────────┬───────────────────────────────────┘ + │ +┌──────────────▼───────────────────────────────────┐ +│ YouTubeTranscriptService │ +│ 1차: youtube-transcript-api 라이브러리 │ +│ 2차: Playwright → 사용자 Chrome 탭으로 추출 │ +└──────────────────────────────────────────────────┘ +``` + +## 핵심: 왜 사용자 Chrome인가? + +### Playwright 자체 브라우저의 한계 +- Playwright가 실행하는 Chromium에는 `--enable-automation` 플래그가 붙음 +- YouTube가 이를 감지하여 **봇으로 판정** → 자막 API 차단 +- 쿠키를 로드해도, 직접 로그인을 시도해도 차단됨 +- Google 로그인 자체가 "안전하지 않은 브라우저"로 거부됨 + +### 사용자 Chrome + CDP 방식 +- VNC에서 일반 Chrome으로 YouTube에 로그인 +- 백엔드가 CDP로 해당 Chrome에 연결하여 새 탭을 열어 작업 +- **로그인 세션이 그대로 유지**되므로 봇 판정 우회 +- Chrome 자체는 종료하지 않고 연결만 관리 + +## Chrome 실행 방법 + +### 필수 옵션 +```bash +DISPLAY=:1 google-chrome \ + --remote-debugging-port=9222 \ + --remote-allow-origins=* \ + --user-data-dir=/tmp/chrome-debug-profile \ + --no-default-browser-check \ + "https://www.youtube.com" +``` + +| 옵션 | 설명 | +|------|------| +| `--remote-debugging-port=9222` | CDP 접속 포트 | +| `--remote-allow-origins=*` | CDP WebSocket 연결 허용 | +| `--user-data-dir` | 프로필 디렉토리 (기본과 다른 경로 필요) | + +### Chrome 프로필 복사 (기존 로그인 유지) +```bash +cp -r /home/opc/.config/google-chrome /tmp/chrome-debug-profile +``` + +### CDP 연결 확인 +```bash +curl -s http://localhost:9222/json/version | python3 -m json.tool +``` + +## YouTube 로그인 + +1. VNC로 접속 (`vnc://공인IP:5901`) +2. Chrome에서 YouTube가 열려있는지 확인 +3. YouTube에 Google 계정으로 로그인 +4. 로그인 상태 유지 — Chrome을 닫지 않음 + +> Chrome을 닫으면 CDP 연결이 끊기고 백엔드가 재연결을 시도함. +> Chrome을 다시 시작하면 다시 로그인 필요. + +## 트랜스크립트 추출 흐름 + +### 1차: youtube-transcript-api 라이브러리 +``` +videoId → TranscriptApi.listTranscripts() + → 수동 자막(ko, en) 우선 + → 자동 생성 자막 fallback +``` +- 서버 IP가 봇으로 판정되면 실패 (대부분 실패) + +### 2차: Playwright + 사용자 Chrome +``` +1. 사용자 Chrome에 새 탭 열기 (DOMCONTENTLOADED, 60초 타임아웃) +2. DOM 렌더링 대기 (3초) +3. 동영상 재생 시작 (playVideo) + - 쿠키 동의 팝업 닫기 + - 마우스 호버 → 재생 버튼 클릭 +4. 광고 대기 (waitForAdsToFinish, 최대 60초) + - 스킵 버튼 자동 클릭 + - 광고 끝날 때까지 반복 확인 +5. 실제 영상 재생 확인 (waitForVideoPlaying, 최대 15초) + - video.currentTime > 0.5 확인 + - 일시정지면 재시도 +6. ytInitialPlayerResponse에서 자막 URL 추출 + fetch (fetchTranscriptFromPageJs) +7. 실패 시 자막 패널 열기 (extractTranscriptFromPanel) + - 'Show transcript' 버튼 클릭 + - 자막 세그먼트 DOM에서 텍스트 추출 + - 최대 30초 반복 확인 +8. 완료 후 about:blank로 이동 (탭 유지) +``` + +## 광고 처리 + +### 감지 방법 +- `#movie_player.ad-showing` 클래스 확인 (가장 신뢰할 수 있음) +- `.ytp-ad-player-overlay` 오버레이 확인 +- 광고 텍스트 배지 확인 (`.ytp-ad-text`) + +### 스킵 버튼 셀렉터 +```css +.ytp-skip-ad-button, +.ytp-ad-skip-button, +.ytp-ad-skip-button-modern, +.ytp-ad-skip-button-container button +``` + +### 주의사항 +- 광고 스킵 후 두 번째 광고가 나올 수 있음 → `no_ad` 상태 확인까지 반복 +- 광고 중에는 `NETWORKIDLE`에 도달하지 못함 → `DOMCONTENTLOADED` 사용 + +## 쿠키 관리 + +### CDP 방식에서는 쿠키 파일 불필요 +- 사용자 Chrome에 직접 연결하므로 `cookies.txt` 불필요 +- Chrome 세션의 쿠키가 자동으로 사용됨 + +### Chrome 쿠키 수동 export (참고용) +Chrome을 `--remote-debugging-port=9222`로 실행 후: +```python +import json, websocket, urllib.request + +tabs = json.loads(urllib.request.urlopen("http://localhost:9222/json").read()) +ws_url = tabs[0]["webSocketDebuggerUrl"] +ws = websocket.create_connection(ws_url) +ws.send(json.dumps({"id": 1, "method": "Network.getAllCookies"})) +result = json.loads(ws.recv()) +cookies = result["result"]["cookies"] +ws.close() +``` + +> Chrome의 쿠키 DB(`~/.config/google-chrome/Default/Cookies`)는 암호화되어 있어 직접 읽기 어려움. +> CDP를 통해 복호화된 쿠키를 가져오는 것이 가장 확실한 방법. + +## PM2 + Chrome 자동 시작 + +Chrome이 서버 재부팅 시에도 자동 시작되도록: +```bash +# pm2로 Chrome 관리하지 않음 (GUI 앱이라 적합하지 않음) +# 대신 VNC 시작 시 자동 실행하도록 ~/.vnc/xstartup에 추가: +``` + +`~/.vnc/xstartup`: +```bash +#!/bin/bash +exec startxfce4 & + +# Chrome을 CDP 모드로 자동 시작 +sleep 5 +google-chrome --remote-debugging-port=9222 \ + --remote-allow-origins=* \ + --user-data-dir=/tmp/chrome-debug-profile \ + --no-default-browser-check \ + "https://www.youtube.com" & +``` + +## 트러블슈팅 + +### Chrome CDP 연결 실패 +``` +Chrome CDP 연결 실패: Chrome이 --remote-debugging-port=9222로 실행 중인지 확인하세요. +``` +→ Chrome 재시작: +```bash +DISPLAY=:1 google-chrome --remote-debugging-port=9222 --remote-allow-origins=* \ + --user-data-dir=/tmp/chrome-debug-profile --no-default-browser-check "https://www.youtube.com" & +``` + +### 자막 패널이 ghost-cards만 표시 +- YouTube가 자막 데이터 로드를 차단한 상태 +- Chrome에서 YouTube 로그인이 풀렸는지 VNC에서 확인 +- 로그인 상태라면 Chrome 재시작 후 다시 로그인 + +### "안전하지 않은 브라우저" 로그인 거부 +- Playwright 자체 브라우저에서 발생 (정상) +- **사용자 Chrome**에서만 로그인 가능 +- CDP 방식으로 전환하면 해결 + +### 광고 중에 페이지가 닫힘 +- YouTube 페이지 로드 시 `NETWORKIDLE` 사용하면 광고 때문에 타임아웃 +- **`DOMCONTENTLOADED`** + 60초 타임아웃으로 변경하여 해결 + +## 관련 파일 + +| 파일 | 역할 | +|------|------| +| `PlaywrightBrowserService.java` | 사용자 Chrome에 CDP 연결, 탭 관리 | +| `YouTubeTranscriptService.java` | 자막 추출 로직 (API → Playwright fallback) | +| `youtube-transcript-extract.js` | 자막 패널 DOM 추출 JS | +| `youtube-transcript-from-page.js` | ytInitialPlayerResponse 기반 자막 추출 JS | +| `~/.vnc/xstartup` | VNC 시작 시 Chrome 자동 실행 | diff --git a/sundol-backend/src/main/java/com/sundol/controller/KnowledgeController.java b/sundol-backend/src/main/java/com/sundol/controller/KnowledgeController.java index d93f703..caebd7e 100644 --- a/sundol-backend/src/main/java/com/sundol/controller/KnowledgeController.java +++ b/sundol-backend/src/main/java/com/sundol/controller/KnowledgeController.java @@ -96,7 +96,7 @@ public class KnowledgeController { .map(ResponseEntity::ok); } - @GetMapping("/{id}/chunks") +@GetMapping("/{id}/chunks") public Mono>>> getChunks( @AuthenticationPrincipal String userId, @PathVariable String id) { diff --git a/sundol-backend/src/main/java/com/sundol/repository/KnowledgeRepository.java b/sundol-backend/src/main/java/com/sundol/repository/KnowledgeRepository.java index a9709a4..71488aa 100644 --- a/sundol-backend/src/main/java/com/sundol/repository/KnowledgeRepository.java +++ b/sundol-backend/src/main/java/com/sundol/repository/KnowledgeRepository.java @@ -88,7 +88,8 @@ public class KnowledgeRepository { public void updateStructuredContent(String id, String structuredContent) { jdbcTemplate.update( "UPDATE knowledge_items SET structured_content = ?, updated_at = SYSTIMESTAMP WHERE RAWTOHEX(id) = ?", - structuredContent, id + new Object[]{ structuredContent, id }, + new int[]{ java.sql.Types.CLOB, java.sql.Types.VARCHAR } ); } diff --git a/sundol-backend/src/main/java/com/sundol/service/AuthService.java b/sundol-backend/src/main/java/com/sundol/service/AuthService.java index 9ffc2da..f60d701 100644 --- a/sundol-backend/src/main/java/com/sundol/service/AuthService.java +++ b/sundol-backend/src/main/java/com/sundol/service/AuthService.java @@ -130,6 +130,10 @@ public class AuthService { public Mono logout(String userId) { return Mono.fromRunnable(() -> { + if (userId == null) { + log.warn("Logout called with null userId, ignoring"); + return; + } userRepository.updateRefreshToken(userId, null); log.info("User logged out: {}", userId); }).subscribeOn(Schedulers.boundedElastic()).then(); diff --git a/sundol-backend/src/main/java/com/sundol/service/PlaywrightBrowserService.java b/sundol-backend/src/main/java/com/sundol/service/PlaywrightBrowserService.java index 36ea452..9b3b75f 100644 --- a/sundol-backend/src/main/java/com/sundol/service/PlaywrightBrowserService.java +++ b/sundol-backend/src/main/java/com/sundol/service/PlaywrightBrowserService.java @@ -9,72 +9,74 @@ 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)으로 처리한다. + * 사용자 Chrome 브라우저에 CDP(Chrome DevTools Protocol)로 연결하여 사용. + * Chrome은 --remote-debugging-port=9222로 실행되어 있어야 한다. + * VNC에서 사용자가 직접 로그인한 세션을 그대로 사용하므로 봇 판정을 우회할 수 있다. */ @Service public class PlaywrightBrowserService { private static final Logger log = LoggerFactory.getLogger(PlaywrightBrowserService.class); + private static final String CDP_URL = "http://localhost:9222"; 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 브라우저 싱글톤 초기화 완료"); + connectToChrome(); + log.info("사용자 Chrome에 CDP 연결 완료"); } catch (Exception e) { - log.error("Playwright 브라우저 초기화 실패: {}", e.getMessage(), e); + log.error("Chrome CDP 연결 실패: {}. Chrome이 --remote-debugging-port=9222로 실행 중인지 확인하세요.", e.getMessage()); } } + private void connectToChrome() { + browser = playwright.chromium().connectOverCDP(CDP_URL); + log.info("CDP 연결: {} contexts, {} pages", + browser.contexts().size(), + browser.contexts().stream().mapToInt(c -> c.pages().size()).sum()); + } + @PreDestroy public void destroy() { try { - if (context != null) context.close(); + // CDP 연결만 끊음 (Chrome 자체는 종료하지 않음) if (browser != null) browser.close(); if (playwright != null) playwright.close(); - log.info("Playwright 브라우저 종료 완료"); + log.info("Chrome CDP 연결 해제 완료"); } catch (Exception e) { - log.warn("Playwright 브라우저 종료 중 오류: {}", e.getMessage()); + log.warn("CDP 연결 해제 중 오류: {}", e.getMessage()); } } /** - * 새 탭을 열고 URL로 이동한다. 호출자가 사용 후 page.close()를 해야 한다. + * 사용자 Chrome의 기본 컨텍스트를 가져온다. */ - public Page openPage(String url, int timeoutMs) throws IOException { + private BrowserContext getDefaultContext() throws IOException { ensureBrowserAlive(); - Page page = context.newPage(); + List contexts = browser.contexts(); + if (contexts.isEmpty()) { + throw new IOException("Chrome에 활성 컨텍스트가 없습니다."); + } + return contexts.get(0); + } + + /** + * 새 탭을 열고 URL로 이동한다. + */ + public Page openPage(String url, int timeoutMs, WaitUntilState waitUntil) throws IOException { + BrowserContext ctx = getDefaultContext(); + Page page = ctx.newPage(); try { page.navigate(url, new Page.NavigateOptions() .setTimeout(timeoutMs) - .setWaitUntil(WaitUntilState.NETWORKIDLE)); + .setWaitUntil(waitUntil)); return page; } catch (Exception e) { page.close(); @@ -82,16 +84,16 @@ public class PlaywrightBrowserService { } } - /** - * 새 탭을 열고 URL로 이동한다 (기본 30초 타임아웃). - */ + public Page openPage(String url, int timeoutMs) throws IOException { + return openPage(url, timeoutMs, WaitUntilState.NETWORKIDLE); + } + 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 { @@ -107,62 +109,21 @@ public class PlaywrightBrowserService { } /** - * 브라우저가 죽었으면 재시작한다. + * Chrome 연결이 끊겼으면 재연결한다. */ private synchronized void ensureBrowserAlive() throws IOException { if (browser != null && browser.isConnected()) { return; } - log.warn("Playwright 브라우저가 죽어있습니다. 재시작합니다."); - destroy(); + log.warn("Chrome CDP 연결이 끊겼습니다. 재연결합니다."); 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()); + if (browser != null) { + try { browser.close(); } catch (Exception ignored) {} } + connectToChrome(); + log.info("Chrome CDP 재연결 완료"); } catch (Exception e) { - log.warn("Failed to load cookies: {}", e.getMessage()); + throw new IOException("Chrome CDP 재연결 실패. Chrome이 실행 중인지 확인하세요: " + e.getMessage(), e); } } } 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 d796736..d67d78e 100644 --- a/sundol-backend/src/main/java/com/sundol/service/YouTubeTranscriptService.java +++ b/sundol-backend/src/main/java/com/sundol/service/YouTubeTranscriptService.java @@ -1,6 +1,7 @@ package com.sundol.service; import com.microsoft.playwright.Page; +import com.microsoft.playwright.options.WaitUntilState; import io.github.thoroldvix.api.TranscriptApiFactory; import io.github.thoroldvix.api.TranscriptContent; import io.github.thoroldvix.api.TranscriptList; @@ -11,6 +12,7 @@ import org.slf4j.LoggerFactory; import org.springframework.stereotype.Service; import java.io.IOException; +import java.io.InputStream; import java.net.URLDecoder; import java.nio.charset.StandardCharsets; import java.util.regex.Matcher; @@ -35,9 +37,22 @@ public class YouTubeTranscriptService { private final YoutubeTranscriptApi transcriptApi = TranscriptApiFactory.createDefault(); private final PlaywrightBrowserService browserService; + private final String extractTranscriptJs; + private final String transcriptFromPageJs; public YouTubeTranscriptService(PlaywrightBrowserService browserService) { this.browserService = browserService; + this.extractTranscriptJs = loadResource("youtube-transcript-extract.js"); + this.transcriptFromPageJs = loadResource("youtube-transcript-from-page.js"); + } + + private String loadResource(String name) { + try (InputStream is = getClass().getClassLoader().getResourceAsStream(name)) { + if (is == null) throw new RuntimeException("Resource not found: " + name); + return new String(is.readAllBytes(), StandardCharsets.UTF_8); + } catch (IOException e) { + throw new RuntimeException("Failed to load resource: " + name, e); + } } public String fetchTranscript(String youtubeUrl) throws IOException { @@ -107,51 +122,38 @@ public class YouTubeTranscriptService { private String fetchWithPlaywright(String videoId) throws IOException { String watchUrl = "https://www.youtube.com/watch?v=" + videoId; - Page page = browserService.openPage(watchUrl); + // YouTube는 광고 때문에 NETWORKIDLE에 도달하지 못할 수 있으므로 DOMCONTENTLOADED 사용 + Page page = browserService.openPage(watchUrl, 60_000, WaitUntilState.DOMCONTENTLOADED); try { log.info("Playwright fetched YouTube page for videoId: {}", videoId); + // DOM 로드 후 추가 대기 (JS 렌더링) + page.waitForTimeout(3000); - // 방법 1: YouTube 페이지 내 JS로 자막 패널 열어서 텍스트 추출 - String transcript = extractTranscriptFromPanel(page); + // Step 0: 동영상 재생 시작 (봇 판정 우회) + playVideo(page); + + // Step 1: 광고 대기 (최대 60초) + waitForAdsToFinish(page); + + // Step 2: 실제 영상 재생 확인 (최대 15초) + waitForVideoPlaying(page); + + // Step 3: 페이지 내 JS에서 ytInitialPlayerResponse로 자막 데이터 직접 추출 + String transcript = fetchTranscriptFromPageJs(page); + if (transcript != null && !transcript.isBlank()) { + log.info("Successfully fetched transcript via page JS: {} chars", transcript.length()); + return transcript; + } + + // Step 4: 자막 패널 열어서 텍스트 추출 (확인 루프) + transcript = extractTranscriptFromPanel(page); if (transcript != null && !transcript.isBlank()) { log.info("Successfully fetched transcript via panel: {} chars", transcript.length()); return transcript; } - // 방법 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) { @@ -160,74 +162,326 @@ public class YouTubeTranscriptService { } } + /** + * 페이지 내 ytInitialPlayerResponse에서 자막 데이터를 직접 추출한다. + * 브라우저 JS 컨텍스트에서 fetch하므로 쿠키/세션 유지. + */ + private String fetchTranscriptFromPageJs(Page page) { + try { + Object result = page.evaluate(transcriptFromPageJs); + if (result == null) return null; + + String resultStr = result.toString(); + log.info("Page JS transcript result: {} chars", resultStr.length()); + + if (resultStr.startsWith("{")) { + // JSON 응답 파싱 + var jsonNode = new com.fasterxml.jackson.databind.ObjectMapper().readTree(resultStr); + if (jsonNode.has("error")) { + log.warn("Page JS transcript error: {}", jsonNode.get("error").asText()); + return null; + } + String type = jsonNode.has("type") ? jsonNode.get("type").asText() : ""; + String data = jsonNode.has("data") ? jsonNode.get("data").asText() : ""; + String lang = jsonNode.has("lang") ? jsonNode.get("lang").asText() : ""; + log.info("Page JS transcript type={}, lang={}, data={} chars", type, lang, data.length()); + + if (data.isEmpty()) return null; + + if ("json3".equals(type)) { + return parseTranscriptJson(data); + } else if ("xml".equals(type)) { + return parseTranscriptXml(data); + } + return null; + } + // 문자열 직접 반환된 경우 + return resultStr.isBlank() ? null : resultStr; + } catch (Exception e) { + log.warn("fetchTranscriptFromPageJs failed: {}", e.getMessage()); + return null; + } + } + + /** + * 동영상 재생을 시작한다. 실제 사용자처럼 행동하여 봇 판정을 우회. + */ + private void playVideo(Page page) { + try { + // 쿠키 동의 팝업 닫기 + page.evaluate( + "() => { " + + " var agreeBtn = document.querySelector('button[aria-label*=\"Accept\"], button[aria-label*=\"동의\"], tp-yt-paper-button[aria-label*=\"Agree\"]'); " + + " if (agreeBtn) agreeBtn.click(); " + + "}" + ); + page.waitForTimeout(1000); + + // 마우스를 플레이어 위로 이동 (호버 효과) + page.evaluate( + "() => { " + + " var player = document.querySelector('#movie_player, .html5-video-player'); " + + " if (player) player.dispatchEvent(new MouseEvent('mouseover', {bubbles: true})); " + + "}" + ); + page.waitForTimeout(500); + + // 재생 버튼 클릭 또는 영상 영역 클릭 + page.evaluate( + "() => { " + + " var playBtn = document.querySelector('.ytp-play-button, .ytp-large-play-button'); " + + " if (playBtn) { playBtn.click(); return 'play_clicked'; } " + + " var video = document.querySelector('video'); " + + " if (video) { video.play(); return 'video_play'; } " + + " var player = document.querySelector('#movie_player'); " + + " if (player) { player.click(); return 'player_clicked'; } " + + " return 'nothing'; " + + "}" + ); + log.info("Video play initiated"); + page.waitForTimeout(2000); + } catch (Exception e) { + log.warn("playVideo error (non-fatal): {}", e.getMessage()); + } + } + + /** + * 실제 영상이 재생 중인지 확인한다. (최대 15초) + * 재생 시간이 변화하면 재생 중으로 판단. + */ + private void waitForVideoPlaying(Page page) { + log.info("Waiting for video to start playing..."); + try { + for (int i = 0; i < 5; i++) { + Object result = page.evaluate( + "() => { " + + " var video = document.querySelector('video'); " + + " if (!video) return 'no_video'; " + + " if (video.paused) return 'paused:' + video.currentTime; " + + " return 'playing:' + video.currentTime; " + + "}" + ); + String status = result != null ? result.toString() : "unknown"; + log.info("Video status: {}", status); + + if (status.startsWith("playing:")) { + // 재생 시간이 0보다 크면 실제 재생 중 + String timeStr = status.substring(8); + try { + double time = Double.parseDouble(timeStr); + if (time > 0.5) { + log.info("Video is playing at {}s", time); + return; + } + } catch (NumberFormatException ignored) {} + } + + if (status.startsWith("paused")) { + // 일시정지 상태면 다시 재생 시도 + page.evaluate( + "() => { " + + " var video = document.querySelector('video'); " + + " if (video) video.play(); " + + "}" + ); + } + + page.waitForTimeout(3000); + } + log.info("Video play wait finished"); + } catch (Exception e) { + log.warn("waitForVideoPlaying error (non-fatal): {}", e.getMessage()); + } + } + + /** + * YouTube 광고가 재생 중이면 끝날 때까지 대기한다. (최대 60초) + * 스킵 버튼이 나타나면 클릭한다. + */ + private void waitForAdsToFinish(Page page) { + log.info("Checking for YouTube ads..."); + for (int i = 0; i < 30; i++) { // 최대 60초 (2초 간격) + try { + Object adResult = page.evaluate( + "() => { " + + " // 스킵 버튼 찾기 (여러 버전 대응) " + + " var skipBtns = document.querySelectorAll('.ytp-skip-ad-button, .ytp-ad-skip-button, .ytp-ad-skip-button-modern, .ytp-ad-skip-button-container button, [class*=\"skip\"][class*=\"ad\"] button'); " + + " for (var i = 0; i < skipBtns.length; i++) { " + + " if (skipBtns[i].offsetParent !== null) { skipBtns[i].click(); return 'skipped'; } " + + " } " + + " // 광고 재생 중 확인: #movie_player에 ad-showing 클래스 " + + " var player = document.querySelector('#movie_player'); " + + " if (player && player.classList.contains('ad-showing')) return 'ad_playing'; " + + " // 광고 오버레이 확인 " + + " var adOverlay = document.querySelector('.ytp-ad-player-overlay'); " + + " if (adOverlay && adOverlay.offsetHeight > 0) return 'ad_playing'; " + + " // video duration으로 확인 (광고는 보통 짧음) " + + " var video = document.querySelector('video'); " + + " if (video && video.duration > 0 && video.duration < 120 && !video.paused) { " + + " var adText = document.querySelector('.ytp-ad-text, .ytp-ad-preview-text, .ytp-ad-simple-ad-badge'); " + + " if (adText) return 'ad_playing'; " + + " } " + + " return 'no_ad'; " + + "}" + ); + String status = adResult != null ? adResult.toString() : "no_ad"; + + if ("skipped".equals(status)) { + log.info("Ad skipped at attempt {}", i + 1); + page.waitForTimeout(3000); + continue; + } + if ("no_ad".equals(status)) { + if (i == 0) { + log.info("No ads detected"); + } else { + log.info("Ads finished after {} attempts", i + 1); + } + return; + } + if (i % 5 == 0) log.info("Ad still playing, waiting... ({}s)", i * 2); + page.waitForTimeout(2000); + } catch (Exception e) { + log.warn("Ad check error: {}", e.getMessage()); + return; + } + } + log.warn("Ad wait timeout (60s), proceeding anyway"); + } + /** * 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';" + - "}"); + // Step 1: 설명란 펼치기 + page.evaluate( + "() => { " + + " var exp = document.querySelector('#expand, tp-yt-paper-button#expand, #description-inline-expander #expand'); " + + " if (exp) exp.click(); " + + "}" + ); + page.waitForTimeout(1500); + // Step 2: '스크립트 표시' / 'Show transcript' 버튼 찾아 클릭 + Object result = page.evaluate( + "() => { " + + " var keywords = ['Show transcript', 'transcript', '스크립트 표시', '자막 보기', '자막']; " + + " var allBtns = document.querySelectorAll('button, ytd-button-renderer, tp-yt-paper-button'); " + + " for (var i = 0; i < allBtns.length; i++) { " + + " var t = (allBtns[i].innerText || allBtns[i].textContent || '').trim().toLowerCase(); " + + " for (var j = 0; j < keywords.length; j++) { " + + " if (t.indexOf(keywords[j].toLowerCase()) !== -1) { " + + " allBtns[i].click(); " + + " return 'clicked:' + t; " + + " } " + + " } " + + " } " + + " 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); + // 패널이 HIDDEN 상태일 수 있으므로 직접 visibility를 변경하고 데이터 로드 트리거 + page.waitForTimeout(1500); + page.evaluate( + "() => { " + + " var panel = document.querySelector('ytd-engagement-panel-section-list-renderer[target-id=\"engagement-panel-searchable-transcript\"]'); " + + " if (panel) { " + + " panel.setAttribute('visibility', 'ENGAGEMENT_PANEL_VISIBILITY_EXPANDED'); " + + " panel.removeAttribute('hidden'); " + + " panel.style.display = ''; " + + " } " + + "}" + ); + 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';" + - "}"); + if (result != null && result.toString().startsWith("not_found")) { + // 대안: 동영상 메뉴(...) 버튼 → 스크립트 열기 + page.evaluate( + "() => { " + + " var menuBtn = document.querySelector('ytd-menu-renderer yt-icon-button button, #menu button'); " + + " if (menuBtn) menuBtn.click(); " + + "}" + ); + page.waitForTimeout(1000); + page.evaluate( + "() => { " + + " var items = document.querySelectorAll('tp-yt-paper-item, ytd-menu-service-item-renderer'); " + + " for (var i = 0; i < items.length; i++) { " + + " var t = (items[i].innerText || '').toLowerCase(); " + + " if (t.indexOf('transcript') !== -1 || t.indexOf('스크립트') !== -1) { " + + " items[i].click(); " + + " return 'menu_clicked'; " + + " } " + + " } " + + " return 'not_found'; " + + "}" + ); } - // 자막 패널이 로드될 때까지 대기 - page.waitForTimeout(3000); + // Step 3: 자막 텍스트가 나타날 때까지 확인 루프 (최대 30초) + String transcript = null; + for (int attempt = 0; attempt < 15; attempt++) { + page.waitForTimeout(2000); - // 자막 텍스트 추출 - 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 '';" + - "}"); + // 디버그: 트랜스크립트 패널 DOM 상태 확인 + if (attempt == 0 || attempt == 3) { + Object debugInfo = page.evaluate( + "() => { " + + " var panel = document.querySelector('ytd-engagement-panel-section-list-renderer[target-id=\"engagement-panel-searchable-transcript\"]'); " + + " if (!panel) return 'panel:NOT_FOUND'; " + + " var vis = panel.getAttribute('visibility'); " + + " var segs = panel.querySelectorAll('[class*=\"segment\"], [class*=\"cue\"], yt-formatted-string'); " + + " var body = panel.querySelector('#body, #content'); " + + " var bodyHtml = body ? body.innerHTML.substring(0, 500) : 'no_body'; " + + " return 'panel:' + vis + ' segs:' + segs.length + ' html:' + bodyHtml; " + + "}" + ); + log.info("Debug transcript panel (attempt {}): {}", attempt + 1, debugInfo); + } - String transcript = transcriptObj != null ? transcriptObj.toString() : ""; - log.info("Transcript from panel: {} chars", transcript.length()); - return transcript.isBlank() ? null : transcript; + Object transcriptObj = page.evaluate(extractTranscriptJs); + + transcript = transcriptObj != null ? transcriptObj.toString() : ""; + if (!transcript.isBlank()) { + log.info("Transcript from panel: {} chars (attempt {})", transcript.length(), attempt + 1); + return transcript; + } + + // 패널이 아직 안 열렸으면 다시 시도 + if (attempt == 2 || attempt == 5 || attempt == 9) { + log.info("Retrying panel open at attempt {}", attempt + 1); + // 패널 visibility 강제 변경 + Show transcript 버튼 재클릭 + page.evaluate( + "() => { " + + " var panel = document.querySelector('ytd-engagement-panel-section-list-renderer[target-id=\"engagement-panel-searchable-transcript\"]'); " + + " if (panel) { " + + " panel.setAttribute('visibility', 'ENGAGEMENT_PANEL_VISIBILITY_EXPANDED'); " + + " panel.removeAttribute('hidden'); " + + " panel.style.display = ''; " + + " var cont = panel.querySelector('ytd-continuation-item-renderer'); " + + " if (cont) cont.click(); " + + " } " + + " var keywords = ['Show transcript', 'transcript', '스크립트 표시', '자막 보기']; " + + " var allBtns = document.querySelectorAll('button, ytd-button-renderer, tp-yt-paper-button'); " + + " for (var i = 0; i < allBtns.length; i++) { " + + " var t = (allBtns[i].innerText || '').trim().toLowerCase(); " + + " for (var j = 0; j < keywords.length; j++) { " + + " if (t.indexOf(keywords[j].toLowerCase()) !== -1) { " + + " allBtns[i].click(); return 'retried'; " + + " } " + + " } " + + " } " + + "}" + ); + } + } + + log.info("Transcript panel extraction timed out after 30s"); + return null; } catch (Exception e) { log.warn("Failed to extract transcript from panel: {}", e.getMessage()); return null; diff --git a/sundol-backend/src/main/resources/youtube-transcript-extract.js b/sundol-backend/src/main/resources/youtube-transcript-extract.js new file mode 100644 index 0000000..648391c --- /dev/null +++ b/sundol-backend/src/main/resources/youtube-transcript-extract.js @@ -0,0 +1,33 @@ +() => { + var selectors = [ + 'ytd-transcript-segment-renderer .segment-text', + 'yt-formatted-string.segment-text', + '#segments-container yt-formatted-string', + 'ytd-transcript-segment-list-renderer .segment-text', + 'ytd-transcript-segment-renderer yt-formatted-string', + 'ytd-engagement-panel-section-list-renderer yt-formatted-string.ytd-transcript-segment-renderer' + ]; + for (var s = 0; s < selectors.length; s++) { + try { + var segs = document.querySelectorAll(selectors[s]); + if (segs.length > 0) { + var texts = []; + for (var i = 0; i < segs.length; i++) { + var txt = segs[i].textContent.trim(); + if (txt.length > 0) texts.push(txt); + } + if (texts.length > 0) return texts.join(' '); + } + } catch(e) {} + } + // 최후 수단: engagement panel 내 모든 텍스트 + var panel = document.querySelector('ytd-engagement-panel-section-list-renderer[target-id="engagement-panel-searchable-transcript"]'); + if (panel) { + var body = panel.querySelector('#body, #content'); + if (body) { + var allText = body.innerText.trim(); + if (allText.length > 100) return allText; + } + } + return ''; +} diff --git a/sundol-backend/src/main/resources/youtube-transcript-from-page.js b/sundol-backend/src/main/resources/youtube-transcript-from-page.js new file mode 100644 index 0000000..1260a18 --- /dev/null +++ b/sundol-backend/src/main/resources/youtube-transcript-from-page.js @@ -0,0 +1,72 @@ +() => { + try { + // 방법 1: ytInitialPlayerResponse에서 직접 자막 데이터 추출 + var playerResponse = null; + if (typeof ytInitialPlayerResponse !== 'undefined') { + playerResponse = ytInitialPlayerResponse; + } + if (!playerResponse) { + // 페이지 소스에서 추출 시도 + var scripts = document.querySelectorAll('script'); + for (var i = 0; i < scripts.length; i++) { + var text = scripts[i].textContent; + if (text && text.indexOf('ytInitialPlayerResponse') !== -1) { + var match = text.match(/ytInitialPlayerResponse\s*=\s*(\{.*?\});/s); + if (match) { + try { playerResponse = JSON.parse(match[1]); } catch(e) {} + } + } + } + } + + if (!playerResponse) return JSON.stringify({error: 'no_player_response'}); + + // captionTracks 추출 + var captions = playerResponse.captions; + if (!captions) return JSON.stringify({error: 'no_captions'}); + + var renderer = captions.playerCaptionsTracklistRenderer; + if (!renderer) return JSON.stringify({error: 'no_renderer'}); + + var tracks = renderer.captionTracks; + if (!tracks || tracks.length === 0) return JSON.stringify({error: 'no_tracks'}); + + // 언어 우선순위: ko > en > 첫 번째 + var selectedTrack = null; + for (var t = 0; t < tracks.length; t++) { + if (tracks[t].languageCode === 'ko') { selectedTrack = tracks[t]; break; } + } + if (!selectedTrack) { + for (var t = 0; t < tracks.length; t++) { + if (tracks[t].languageCode === 'en') { selectedTrack = tracks[t]; break; } + } + } + if (!selectedTrack) selectedTrack = tracks[0]; + + var baseUrl = selectedTrack.baseUrl; + if (!baseUrl) return JSON.stringify({error: 'no_base_url'}); + + // fmt=json3 추가 + if (baseUrl.indexOf('fmt=') === -1) { + baseUrl += '&fmt=json3'; + } + + // 브라우저 내에서 fetch로 자막 데이터 가져오기 + return fetch(baseUrl, {credentials: 'include'}) + .then(function(res) { return res.text(); }) + .then(function(text) { + if (!text || text.length === 0) { + // fmt=json3 실패 시 fmt 없이 재시도 (XML) + var xmlUrl = baseUrl.replace('&fmt=json3', ''); + return fetch(xmlUrl, {credentials: 'include'}) + .then(function(res2) { return res2.text(); }) + .then(function(xmlText) { + return JSON.stringify({type: 'xml', data: xmlText, lang: selectedTrack.languageCode}); + }); + } + return JSON.stringify({type: 'json3', data: text, lang: selectedTrack.languageCode}); + }); + } catch(e) { + return JSON.stringify({error: e.message}); + } +} diff --git a/sundol-frontend/src/lib/api.ts b/sundol-frontend/src/lib/api.ts index 80d4c10..fea7e7a 100644 --- a/sundol-frontend/src/lib/api.ts +++ b/sundol-frontend/src/lib/api.ts @@ -68,9 +68,23 @@ api.interceptors.response.use( isRefreshing = true; originalRequest._retry = true; + const attemptRefresh = async (retryCount: number): Promise => { + try { + const res = await api.post("/api/auth/refresh"); + return res.data.accessToken; + } catch (err) { + const isNetworkError = !((err as AxiosError).response); + if (isNetworkError && retryCount < 2) { + // 네트워크 에러(서버 재시작 등)면 3초 후 재시도 + await new Promise((r) => setTimeout(r, 3000)); + return attemptRefresh(retryCount + 1); + } + throw err; + } + }; + try { - const res = await api.post("/api/auth/refresh"); - const newToken = res.data.accessToken; + const newToken = await attemptRefresh(0); api.defaults.headers.common["Authorization"] = `Bearer ${newToken}`; onTokenRefreshed?.(newToken);