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) <noreply@anthropic.com>
This commit is contained in:
@@ -96,7 +96,7 @@ public class KnowledgeController {
|
||||
.map(ResponseEntity::ok);
|
||||
}
|
||||
|
||||
@GetMapping("/{id}/chunks")
|
||||
@GetMapping("/{id}/chunks")
|
||||
public Mono<ResponseEntity<List<Map<String, Object>>>> getChunks(
|
||||
@AuthenticationPrincipal String userId,
|
||||
@PathVariable String id) {
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -130,6 +130,10 @@ public class AuthService {
|
||||
|
||||
public Mono<Void> 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();
|
||||
|
||||
@@ -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<BrowserContext> 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<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());
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 '';
|
||||
}
|
||||
@@ -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});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user