336 lines
13 KiB
Kotlin
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))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|