atrade/src/main/kotlin/service/DynamicNewsScraper.kt
2026-04-29 15:32:09 +09:00

336 lines
13 KiB
Kotlin

package service
import com.microsoft.playwright.Browser
import com.microsoft.playwright.Playwright
import com.microsoft.playwright.Page
import com.microsoft.playwright.options.LoadState
import com.microsoft.playwright.options.WaitUntilState
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 network.RagService
import util.HardwareDetector
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] 장시간 대기로 브라우저 자원을 해제합니다.")
try {
_browser?.close()
playwright?.close()
_browser = null
playwright = null
}catch (e: Exception) {
}
}
}
}
}
object DynamicNewsScraper {
fun extractSmartContentWithLineFilter(page: Page): String {
val script = """
() => {
// 1. 선제적 노이즈 제거: ASIDE(사이드바/광고) 태그 추가
const junkTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'IFRAME', 'SVG', 'HEADER', 'FOOTER', 'NAV', 'ASIDE'];
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) => {
if (!el || !el.innerText) return "";
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
)
);
let finalResult = best ? best.refinedText : (candidates.sort((a,b) => b.refinedText.length - a.refinedText.length)[0]?.refinedText || "");
// ⭐ 5. Fallback 로직: 스마트 추출에 실패했거나 너무 짧으면, 노이즈가 제거된 body 원문을 통째로 반환
if (!finalResult || finalResult.length < MIN_TOTAL_LENGTH) {
finalResult = getRefinedText(document.body);
}
return finalResult;
}
""".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<String, Int>()
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 {
// 1. 제거할 불필요한 노이즈 키워드 목록
val noiseKeywords = listOf(
"기자", "무단 전재", "재배포 금지", "Copyright", "",
"ADVERTISEMENT", "돌아가기", "topstarnews",
"구독하기", "제보하기", "광고", "Sponsored"
)
var cleanedText = text
// 2. 키워드가 포함된 '한 줄 전체'를 삭제
for (keyword in noiseKeywords) {
// (?im): 대소문자 무시(i) 및 다중행 모드(m)
// 해당 키워드가 포함된 라인을 찾아서 빈 문자열로 치환
val regex = Regex("(?im)^.*${Regex.escape(keyword)}.*\$")
cleanedText = cleanedText.replace(regex, "")
}
// 3. 연속된 빈 줄(엔터)들을 하나의 줄바꿈으로 압축하여 RAG 청크 품질 향상
return cleanedText
.replace(Regex("\\n{2,}"), "\n")
.trim()
}
}
object SafeScraper {
private val totalRam = HardwareDetector.getTotalRamGb()
// RAM 8GB당 1개 수준으로 설정하되, 최대 10~12개로 제한 (CPU 부하 방지)
private val maxParallel = totalRam.div(6).toInt()
// 동시 처리를 1개로 줄여서 안정성을 극대화 (추천)
// Playwright는 여러 페이지를 띄울 때 CPU/메모리 점유율이 매우 높습니다.
private val semaphore = Semaphore(maxParallel)
suspend fun scrapeParallel(corpInfo: CorpInfo, urls: List<NewsItem>) = 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)
println("📝 [News Raw Text Length] ${item.originallink.take(30)}... : ${content.length}자 추출됨")
if (content.isNotBlank() && content.length > 100) {
UrlCacheManager.addToCache(item.originallink)
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(100, 600))
}
}
}
}