atrade/src/main/kotlin/service/DynamicNewsScraper.kt

322 lines
12 KiB
Kotlin
Raw Normal View History

2026-01-23 17:05:09 +09:00
package service
2026-03-13 16:46:33 +09:00
import com.microsoft.playwright.Browser
2026-01-23 17:05:09 +09:00
import com.microsoft.playwright.Playwright
import com.microsoft.playwright.Page
2026-02-04 14:52:09 +09:00
import com.microsoft.playwright.options.LoadState
2026-02-05 15:37:11 +09:00
import com.microsoft.playwright.options.WaitUntilState
2026-01-23 17:05:09 +09:00
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
2026-02-09 15:32:31 +09:00
import kotlinx.coroutines.sync.Mutex
2026-01-23 17:05:09 +09:00
import kotlinx.coroutines.sync.Semaphore
2026-02-09 15:32:31 +09:00
import kotlinx.coroutines.sync.withLock
2026-01-23 17:05:09 +09:00
import kotlinx.coroutines.sync.withPermit
2026-02-06 17:53:17 +09:00
import kotlinx.coroutines.withTimeout
2026-01-23 17:05:09 +09:00
import model.NewsItem
import network.CorpInfo
2026-03-17 10:50:13 +09:00
import network.RagService
2026-03-27 13:38:05 +09:00
import util.HardwareDetector
2026-02-05 14:26:02 +09:00
import java.net.URL
2026-01-23 17:05:09 +09:00
import kotlin.random.Random
2026-02-09 15:32:31 +09:00
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() // 동시 접근 제어용 뮤텍스
2026-03-13 16:46:33 +09:00
suspend fun getBrowser(): Browser {
2026-02-09 15:32:31 +09:00
return mutex.withLock {
2026-03-13 16:46:33 +09:00
lastAccessTime = System.currentTimeMillis() // 접근 시간 갱신
2026-02-09 15:32:31 +09:00
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 {
2026-03-13 16:46:33 +09:00
if (util.MarketUtil.isKoreanMarketOpen()) {
startNewBrowser()
}
2026-02-09 15:32:31 +09:00
failCount = 0
}
2026-01-23 17:05:09 +09:00
}
2026-02-09 15:32:31 +09:00
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}")
}
}
2026-03-13 16:46:33 +09:00
private var lastAccessTime = System.currentTimeMillis()
// 대기 모드일 때 호출하여 메모리 해제
suspend fun closeIfIdle(idleTimeoutMs: Long = 60_000) {
mutex.withLock {
if (_browser != null && System.currentTimeMillis() - lastAccessTime > idleTimeoutMs) {
println("♻️ [SafeScraper] 장시간 대기로 브라우저 자원을 해제합니다.")
2026-03-26 18:12:08 +09:00
try {
_browser?.close()
playwright?.close()
_browser = null
playwright = null
}catch (e: Exception) {
}
2026-03-13 16:46:33 +09:00
}
}
}
2026-02-09 15:32:31 +09:00
}
object DynamicNewsScraper {
// private val playwright by lazy { Playwright.create() }
// private val browser by lazy {
// playwright.chromium().launch(BrowserType.LaunchOptions().setHeadless(true))
// }
2026-01-23 17:05:09 +09:00
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
}
2026-02-09 15:32:31 +09:00
// private fun getBrowser(): com.microsoft.playwright.Browser {
// return if (!browser.isConnected) {
// println("🔄 브라우저 연결 끊김 확인, 재시작 시도...")
// // 기존 lazy browser 대신 새 인스턴스를 할당하는 로직 필요
// // (단순 object singleton 보다는 관리형 클래스가 유리)
// browser // 예시를 위해 유지
// } else {
// browser
// }
// }
var failCountMap = mutableMapOf<String, Int>()
2026-01-23 17:05:09 +09:00
suspend fun fetchFullContent(url: String): String {
2026-02-09 15:32:31 +09:00
val domain = try { URL(url).host } catch (e: Exception) { return "" }
if ((failCountMap[domain] ?: 0) > 2) return ""
2026-01-23 17:05:09 +09:00
return try {
2026-02-09 15:32:31 +09:00
// 브라우저 인스턴스를 뮤텍스 보호 하에 가져옴
val browser = BrowserManager.getBrowser()
// Context/Page 생성 시점부터 에러 감시
val context = browser.newContext()
2026-02-04 14:52:09 +09:00
context.use { ctx ->
ctx.newPage().use { page ->
2026-02-09 15:32:31 +09:00
page.setDefaultNavigationTimeout(15000.0)
2026-01-23 17:05:09 +09:00
2026-02-09 15:32:31 +09:00
// Route 설정 시 예외 방어
// try {
// blockUnnecessaryResources(page)
// } catch (e: Exception) { /* 브라우저 상태 이상 감지 시 catch로 이동 */ }
2026-01-23 17:05:09 +09:00
2026-02-05 15:37:11 +09:00
page.navigate(url, Page.NavigateOptions().setWaitUntil(WaitUntilState.DOMCONTENTLOADED))
2026-02-04 14:52:09 +09:00
page.waitForLoadState(LoadState.LOAD)
val content = cleanText(extractSmartContentWithLineFilter(page))
2026-02-09 15:32:31 +09:00
// 명시적으로 route 해제 (TargetClosed 에러 방지)
// try { page.unroute("**/*") } catch (e: Exception) {}
2026-02-04 14:52:09 +09:00
2026-02-09 15:32:31 +09:00
BrowserManager.notifySuccess()
2026-02-04 14:52:09 +09:00
content
}
}
2026-01-23 17:05:09 +09:00
} catch (e: Exception) {
2026-02-09 15:32:31 +09:00
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
// 불필요한 스택트레이스 출력을 줄이기 위해 메시지만 출력
2026-02-12 13:11:07 +09:00
// println("❌ [Playwright] 실패 (${url.take(30)}...): ${e.localizedMessage}")
2026-01-23 17:05:09 +09:00
""
}
}
private fun blockUnnecessaryResources(page: Page) {
2026-02-04 14:52:09 +09:00
page.route("**/*") { route ->
try {
2026-02-09 15:32:31 +09:00
// 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()
2026-02-04 14:52:09 +09:00
} else {
route.resume()
}
} catch (e: Exception) {
2026-02-09 15:32:31 +09:00
// 브라우저 종료 시 발생하는 에러는 여기서 조용히 처리
2026-02-04 14:52:09 +09:00
}
2026-01-23 17:05:09 +09:00
}
}
private fun cleanText(text: String): String {
return text.replace(Regex("(?m)^.*기자.*$"), "") // 기자 정보 제거
.replace(Regex("(?m)^.*무단 전재.*$"), "") // 저작권 문구 제거
.trim()
}
}
2026-02-09 15:32:31 +09:00
2026-01-23 17:05:09 +09:00
object SafeScraper {
2026-03-27 13:38:05 +09:00
private val totalRam = HardwareDetector.getTotalRamGb()
// RAM 8GB당 1개 수준으로 설정하되, 최대 10~12개로 제한 (CPU 부하 방지)
private val maxParallel = when {
totalRam >= 128 -> 8
totalRam >= 64 -> 6
totalRam >= 32 -> 4
totalRam >= 16 -> 2
else -> 1
}
2026-02-09 15:32:31 +09:00
// 동시 처리를 1개로 줄여서 안정성을 극대화 (추천)
// Playwright는 여러 페이지를 띄울 때 CPU/메모리 점유율이 매우 높습니다.
2026-03-27 13:38:05 +09:00
private val semaphore = Semaphore(maxParallel)
2026-02-06 17:53:17 +09:00
suspend fun scrapeParallel(corpInfo: CorpInfo, urls: List<NewsItem>) = coroutineScope {
2026-02-09 15:32:31 +09:00
urls.forEach { item -> // map + awaitAll 대신 순차 처리가 현재 상황에선 더 안정적입니다.
if (UrlCacheManager.isAlreadyProcessed(item.originallink)) {
2026-02-12 13:11:07 +09:00
// println("✅ [학습완료 데이터 스킵] ${item.originallink}")
2026-02-09 15:32:31 +09:00
return@forEach
}
2026-02-06 17:53:17 +09:00
2026-02-09 15:32:31 +09:00
semaphore.withPermit {
2026-02-06 17:53:17 +09:00
try {
2026-02-09 15:32:31 +09:00
withTimeout(25000L) { // 타임아웃 약간 증가
val content = DynamicNewsScraper.fetchFullContent(item.originallink)
2026-02-12 13:11:07 +09:00
if (content.isNotBlank() && content.length > 100) {
2026-02-09 15:32:31 +09:00
RagService.ingestWithChunking(
text = content,
newsLink = item.originallink,
pubDate = item.pubDate,
stockCode = corpInfo.stockCode,
corpName = corpInfo.cName,
corpCode = corpInfo.cCode,
stcokName = corpInfo.stockName
)
2026-02-12 13:11:07 +09:00
// println("✅ [학습완료] ${item.originallink}")
2026-02-04 14:52:09 +09:00
}
2026-01-23 17:05:09 +09:00
}
2026-02-06 17:53:17 +09:00
} catch (e: Exception) {
2026-02-09 15:32:31 +09:00
println("❌ [스크래핑 실패] ${item.originallink}: ${e.localizedMessage}")
2026-01-23 17:05:09 +09:00
}
2026-02-09 15:32:31 +09:00
// 기사 사이의 짧은 휴식 (차단 방지 및 브라우저 안정화)
delay(Random.nextLong(500, 1500))
2026-01-23 17:05:09 +09:00
}
2026-02-09 15:32:31 +09:00
}
2026-01-23 17:05:09 +09:00
}
2026-02-09 15:32:31 +09:00
}