package service import com.microsoft.playwright.Browser import com.microsoft.playwright.Playwright import com.microsoft.playwright.BrowserType import com.microsoft.playwright.Page import com.microsoft.playwright.options.LoadState import com.microsoft.playwright.options.WaitUntilState import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.withTimeout import model.NewsItem import network.CorpInfo import java.net.URL import kotlin.random.Random object BrowserManager { private var playwright: Playwright? = null private var _browser: com.microsoft.playwright.Browser? = null private var failCount = 0 private const val MAX_TOTAL_FAILURES = 3 private val mutex = Mutex() // 동시 접근 제어용 뮤텍스 suspend fun getBrowser(): Browser { return mutex.withLock { lastAccessTime = System.currentTimeMillis() // 접근 시간 갱신 if (_browser == null || !_browser!!.isConnected) { startNewBrowser() } _browser!! } } suspend fun notifyFailure() { mutex.withLock { failCount++ if (failCount >= MAX_TOTAL_FAILURES) { restart() } } } suspend fun notifySuccess() { mutex.withLock { if (failCount > 0) failCount-- } } private suspend fun restart() { // suspend 추가 println("♻️ 브라우저 엔진을 완전히 재시작합니다...") try { // null 처리를 먼저 하여 다른 스레드가 getBrowser() 호출 시 대기하게 함 val oldBrowser = _browser val oldPlaywright = playwright _browser = null playwright = null oldBrowser?.close() oldPlaywright?.close() } catch (e: Exception) { // 종료 에러 무시 } finally { if (util.MarketUtil.isKoreanMarketOpen()) { startNewBrowser() } failCount = 0 } } private fun startNewBrowser() { try { playwright = Playwright.create() _browser = playwright!!.chromium().launch( com.microsoft.playwright.BrowserType.LaunchOptions() .setHeadless(true) .setArgs(listOf("--no-sandbox", "--disable-dev-shm-usage")) // 리소스 부족 방지 ) } catch (e: Exception) { println("🚨 브라우저 엔진 시작 실패: ${e.message}") } } private var lastAccessTime = System.currentTimeMillis() // 대기 모드일 때 호출하여 메모리 해제 suspend fun closeIfIdle(idleTimeoutMs: Long = 60_000) { mutex.withLock { if (_browser != null && System.currentTimeMillis() - lastAccessTime > idleTimeoutMs) { println("♻️ [SafeScraper] 장시간 대기로 브라우저 자원을 해제합니다.") _browser?.close() playwright?.close() _browser = null playwright = null } } } } object DynamicNewsScraper { // private val playwright by lazy { Playwright.create() } // private val browser by lazy { // playwright.chromium().launch(BrowserType.LaunchOptions().setHeadless(true)) // } fun extractSmartContentWithLineFilter(page: Page): String { val script = """ () => { // 1. 선제적 노이즈 제거: 분석에 방해되는 태그들을 DOM에서 아예 삭제 const junkTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'IFRAME', 'SVG', 'HEADER', 'FOOTER', 'NAV']; document.querySelectorAll(junkTags.join(',')).forEach(el => el.remove()); const MIN_LINE_LENGTH = 10; const MIN_TOTAL_LENGTH = 100; const CONSECUTIVE_THRESHOLD = 2; // 2. 라인별 정제 함수 (짧은 라인 연속 시 예외 처리) const getRefinedText = (el) => { // 실제 텍스트만 추출하여 라인별로 분리 const lines = el.innerText.split('\n').map(l => l.trim()).filter(l => l.length > 0); let resultLines = []; let tempBuffer = []; let consecutiveShort = 0; lines.forEach(line => { if (line.length <= MIN_LINE_LENGTH) { consecutiveShort++; tempBuffer.push(line); } else { // 짧은 줄이 연속되지 않았을 때만 버퍼를 결과에 합침 if (consecutiveShort < CONSECUTIVE_THRESHOLD) { resultLines = resultLines.concat(tempBuffer); } resultLines.push(line); tempBuffer = []; consecutiveShort = 0; } }); // 마지막 남은 버퍼 처리 (본문 끝에 짧은 정보가 있을 경우 대비) if (consecutiveShort < CONSECUTIVE_THRESHOLD) { resultLines = resultLines.concat(tempBuffer); } return resultLines.join('\n'); }; // 3. 후보 블록 탐색 및 텍스트 밀도 기반 분석 const candidates = Array.from(document.querySelectorAll('div, section, article, p, main, td')) .map(el => ({ el: el, refinedText: getRefinedText(el) })) .filter(item => { if (item.refinedText.length < MIN_TOTAL_LENGTH) return false; // 링크 밀도 체크: 기사 본문은 보통 링크보다 텍스트 비중이 훨씬 높음 const linkLength = Array.from(item.el.querySelectorAll('a')) .reduce((acc, a) => acc + (a.innerText || "").length, 0); return (linkLength / item.refinedText.length) < 0.3; }); // 4. 가장 최적의(가장 깊은 계층의) 본문 컨테이너 선정 const best = candidates.find(parent => !candidates.some(child => parent.el !== child.el && parent.el.contains(child.el) && child.refinedText.length > parent.refinedText.length * 0.8 ) ); return best ? best.refinedText : (candidates.sort((a,b) => b.refinedText.length - a.refinedText.length)[0]?.refinedText || ""); } """.trimIndent() return page.evaluate(script) as String } // private fun getBrowser(): com.microsoft.playwright.Browser { // return if (!browser.isConnected) { // println("🔄 브라우저 연결 끊김 확인, 재시작 시도...") // // 기존 lazy browser 대신 새 인스턴스를 할당하는 로직 필요 // // (단순 object singleton 보다는 관리형 클래스가 유리) // browser // 예시를 위해 유지 // } else { // browser // } // } var failCountMap = mutableMapOf() suspend fun fetchFullContent(url: String): String { val domain = try { URL(url).host } catch (e: Exception) { return "" } if ((failCountMap[domain] ?: 0) > 2) return "" return try { // 브라우저 인스턴스를 뮤텍스 보호 하에 가져옴 val browser = BrowserManager.getBrowser() // Context/Page 생성 시점부터 에러 감시 val context = browser.newContext() context.use { ctx -> ctx.newPage().use { page -> page.setDefaultNavigationTimeout(15000.0) // Route 설정 시 예외 방어 // try { // blockUnnecessaryResources(page) // } catch (e: Exception) { /* 브라우저 상태 이상 감지 시 catch로 이동 */ } page.navigate(url, Page.NavigateOptions().setWaitUntil(WaitUntilState.DOMCONTENTLOADED)) page.waitForLoadState(LoadState.LOAD) val content = cleanText(extractSmartContentWithLineFilter(page)) // 명시적으로 route 해제 (TargetClosed 에러 방지) // try { page.unroute("**/*") } catch (e: Exception) {} BrowserManager.notifySuccess() content } } } catch (e: Exception) { val msg = e.message ?: "" // 통신 단절 관련 핵심 에러 키워드 체크 if (msg.contains("adopt") || msg.contains("closed") || msg.contains("exist") || msg.contains("respond")) { BrowserManager.notifyFailure() } failCountMap[domain] = (failCountMap[domain] ?: 0) + 1 // 불필요한 스택트레이스 출력을 줄이기 위해 메시지만 출력 // println("❌ [Playwright] 실패 (${url.take(30)}...): ${e.localizedMessage}") "" } } private fun blockUnnecessaryResources(page: Page) { page.route("**/*") { route -> try { // route나 request가 이미 파기되었는지 확인 val req = runCatching { route.request() }.getOrNull() if (req == null) { // 이미 브라우저가 닫히는 중이라면 무시 return@route } val type = req.resourceType() if (type == "image" || type == "font" || type == "stylesheet") { route.abort() } else { route.resume() } } catch (e: Exception) { // 브라우저 종료 시 발생하는 에러는 여기서 조용히 처리 } } } private fun cleanText(text: String): String { return text.replace(Regex("(?m)^.*기자.*$"), "") // 기자 정보 제거 .replace(Regex("(?m)^.*무단 전재.*$"), "") // 저작권 문구 제거 .trim() } } object SafeScraper { // 동시 처리를 1개로 줄여서 안정성을 극대화 (추천) // Playwright는 여러 페이지를 띄울 때 CPU/메모리 점유율이 매우 높습니다. private val semaphore = Semaphore(2) suspend fun scrapeParallel(corpInfo: CorpInfo, urls: List) = coroutineScope { urls.forEach { item -> // map + awaitAll 대신 순차 처리가 현재 상황에선 더 안정적입니다. if (UrlCacheManager.isAlreadyProcessed(item.originallink)) { // println("✅ [학습완료 데이터 스킵] ${item.originallink}") return@forEach } semaphore.withPermit { try { withTimeout(25000L) { // 타임아웃 약간 증가 val content = DynamicNewsScraper.fetchFullContent(item.originallink) if (content.isNotBlank() && content.length > 100) { RagService.ingestWithChunking( text = content, newsLink = item.originallink, pubDate = item.pubDate, stockCode = corpInfo.stockCode, corpName = corpInfo.cName, corpCode = corpInfo.cCode, stcokName = corpInfo.stockName ) // println("✅ [학습완료] ${item.originallink}") } } } catch (e: Exception) { println("❌ [스크래핑 실패] ${item.originallink}: ${e.localizedMessage}") } // 기사 사이의 짧은 휴식 (차단 방지 및 브라우저 안정화) delay(Random.nextLong(500, 1500)) } } } }