package service import com.microsoft.playwright.Playwright import com.microsoft.playwright.BrowserType import com.microsoft.playwright.Page import com.microsoft.playwright.options.LoadState import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withPermit import model.NewsItem import network.CorpInfo import kotlin.random.Random 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 } suspend fun fetchFullContent(url: String): String { // browser.newContext().use { ... } 대신 직접 변수를 선언하고 제어합니다. val context = browser.newContext() return try { context.use { ctx -> ctx.newPage().use { page -> delay(Random.nextInt(1000).toLong()) // 1. 리스너 설정 시 예외 처리 강화 blockUnnecessaryResources(page) // 2. 타임아웃을 설정하여 무한 대기 방지 val options = Page.NavigateOptions().setTimeout(30000.0) page.navigate(url, options) // 3. 페이지가 완전히 닫히기 전에 모든 대기 중인 이벤트를 해제하기 위해 LOAD 상태 대기 page.waitForLoadState(LoadState.LOAD) val content = cleanText(extractSmartContentWithLineFilter(page)) // 4. 명시적으로 route를 해제하여 close 시 발생할 수 있는 리스너 충돌 방지 page.unroute("**/*") content } } } catch (e: Exception) { println("❌ [Playwright] 스크래핑 실패 ($url): ${e.message}") "" } finally { // use 블록이 자원을 닫으려 할 때 발생하는 오류는 내부적으로 처리되거나 무시되도록 유도 } } private fun blockUnnecessaryResources(page: Page) { // 이미지, 폰트, CSS 등 불필요한 요청 가로채서 중단 page.route("**/*") { route -> try { val req = route.request() if (req != null) { val type = req.resourceType() if (type == "image" || type == "font" || type == "stylesheet") { route.abort() } else { route.resume() } } else { // request가 이미 null이면 처리를 포기 route.resume() } } catch (e: Exception) { } } } private fun cleanText(text: String): String { return text.replace(Regex("(?m)^.*기자.*$"), "") // 기자 정보 제거 .replace(Regex("(?m)^.*무단 전재.*$"), "") // 저작권 문구 제거 .trim() } } object SafeScraper { // 동시 실행 브라우저 탭을 5개로 제한 (M3 Pro라면 10~20개도 여유롭습니다) private val semaphore = Semaphore(2) suspend fun scrapeParallel(corpInfo: CorpInfo,urls: List) = coroutineScope { var query = "${corpInfo.cName} ${corpInfo.cCode} ${corpInfo.stockCode}" urls.map { item -> async { if (UrlCacheManager.isAlreadyProcessed(item.originallink) == false) { try { semaphore.withPermit { try { RagService.ingestWithChunking( text = DynamicNewsScraper.fetchFullContent(item.originallink), newsLink = item.originallink, pubDate = item.pubDate, stockCode = corpInfo.stockCode, corpName = corpInfo.cName, corpCode = corpInfo.cCode, stcokName = corpInfo.stockName ) }catch (e: Exception) { println("${e.message}") } } }catch (e: Exception) { println("${e.message}") } println("📰 '${query}' 관련 뉴스 새로운 학습 데이터 게더링") } else { println("📰 '${query}' 관련 뉴스 기 학습 데이터 스킵") } } }.awaitAll() println("$query 관련 뉴스 ${urls.size}개 학습 완료") } }