This commit is contained in:
2026-02-09 15:32:31 +09:00
parent aa1f3817bb
commit ce976a895d
7 changed files with 390 additions and 183 deletions
+25 -6
View File
@@ -91,16 +91,26 @@ object AutoTradingManager {
val myHoldings = balance?.holdings?.filter { it.quantity.toInt() > 0 }?.map { it.code }?.toSet() ?: emptySet()
val pendingStocks = DatabaseFactory.findAllMonitoringTrades().map { it.code }
// [프로세스 2] 후보군 수집
val candidates = fetchCandidates(tradeService)
val candidates = fetchCandidates(tradeService).apply {
println("후보군 총 개수 : $size")
}
.filter { (it.prdy_ctrt.toDoubleOrNull() ?: 0.0) in MIN_RISE_RATE..MAX_RISE_RATE }
.filter { it.code !in myHoldings && it.code !in pendingStocks }
.distinctBy { it.code }
.apply {
println("후보군 조건 충족 총 개수 : $size")
}
// [프로세스 3] 종목별 순회 분석
candidates.forEach { stock ->
lastTickTime.set(System.currentTimeMillis()) // 종목별로도 생존 신고
processSingleStock(stock, myCash, tradeService, callback)
delay(300)
try {
lastTickTime.set(System.currentTimeMillis()) // 종목별로도 생존 신고
processSingleStock(stock, myCash, tradeService, callback)
} catch (e: Exception) {
}finally {
delay(300)
}
}
println("⏱️ [Cycle End] ${LocalTime.now()}")
@@ -163,14 +173,23 @@ object AutoTradingManager {
}
private suspend fun fetchCandidates(tradeService: KisTradeService): List<RankingStock> = coroutineScope {
listOf(
// async { tradeService.fetchMarketRanking(RankingType.VOLUME1, true).getOrDefault(emptyList()) },
// async { tradeService.fetchMarketRanking(RankingType.VOLUME0, true).getOrDefault(emptyList()) },
async { tradeService.fetchMarketRanking(RankingType.VOLUME, true).getOrDefault(emptyList()) },
async { tradeService.fetchMarketRanking(RankingType.RISE, true).getOrDefault(emptyList()) },
async { tradeService.fetchMarketRanking(RankingType.FALL, true).getOrDefault(emptyList()) },
// async { tradeService.fetchMarketRanking(RankingType.RISE2, true).getOrDefault(emptyList()) },
// async { tradeService.fetchMarketRanking(RankingType.FALL2, true).getOrDefault(emptyList()) },
async { tradeService.fetchMarketRanking(RankingType.VALUE, true).getOrDefault(emptyList()) },
async { tradeService.fetchMarketRanking(RankingType.VOLUME_POWER, true).getOrDefault(emptyList()) },
// async { tradeService.fetchMarketRanking(RankingType.EXPECTED_RISE, true).getOrDefault(emptyList()) },
// async { tradeService.fetchMarketRanking(RankingType.COMPANY_TRADE, true).getOrDefault(emptyList()) }
async { tradeService.fetchMarketRanking(RankingType.NEW_HIGH, true).getOrDefault(emptyList()) },
async { tradeService.fetchMarketRanking(RankingType.COMPANY_TRADE, true).getOrDefault(emptyList()) },
async { tradeService.fetchMarketRanking(RankingType.FINANCE, true).getOrDefault(emptyList()) },
async { tradeService.fetchMarketRanking(RankingType.MARKET_VALUE, true).getOrDefault(emptyList()) },
async { tradeService.fetchMarketRanking(RankingType.SHORT_SALE, true).getOrDefault(emptyList()) },
).awaitAll().flatten()
}
+147 -72
View File
@@ -10,7 +10,9 @@ 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
@@ -18,12 +20,75 @@ import network.CorpInfo
import java.net.URL
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))
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(): com.microsoft.playwright.Browser {
return mutex.withLock {
// 브라우저가 없거나 연결이 끊겼다면 새로 생성
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 {
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}")
}
}
}
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 = """
() => {
@@ -95,66 +160,81 @@ object DynamicNewsScraper {
return page.evaluate(script) as String
}
var failDomainList = arrayListOf<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 {
// browser.newContext().use { ... } 대신 직접 변수를 선언하고 제어합니다.
val domain = URL(url).host
val context = browser.newContext()
if(failDomainList.contains(domain)) {
println("실패한 도메인 스크래핑 종료 $domain ")
return ""
}
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(8000.0)
delay(Random.nextInt(2000).toLong())
page.setDefaultNavigationTimeout(15000.0)
// 1. 리스너 설정 시 예외 처리 강화
blockUnnecessaryResources(page)
// Route 설정 시 예외 방어
// try {
// blockUnnecessaryResources(page)
// } catch (e: Exception) { /* 브라우저 상태 이상 감지 시 catch로 이동 */ }
// 2. 타임아웃을 설정하여 무한 대기 방지
page.navigate(url, Page.NavigateOptions().setWaitUntil(WaitUntilState.DOMCONTENTLOADED))
// 3. 페이지가 완전히 닫히기 전에 모든 대기 중인 이벤트를 해제하기 위해 LOAD 상태 대기
page.waitForLoadState(LoadState.LOAD)
val content = cleanText(extractSmartContentWithLineFilter(page))
// 4. 명시적으로 route 해제하여 close 시 발생할 수 있는 리스너 충돌 방지
page.unroute("**/*")
// 명시적으로 route 해제 (TargetClosed 에러 방지)
// try { page.unroute("**/*") } catch (e: Exception) {}
BrowserManager.notifySuccess()
content
}
}
} catch (e: Exception) {
failDomainList.add(domain)
println("❌ [Playwright] 스크래핑 실패 ($url): ${e.message}")
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}")
""
} 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()
}
// 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 {
// request가 이미 null이면 처리를 포기
route.resume()
}
} catch (e: Exception) {
// 브라우저 종료 시 발생하는 에러는 여기서 조용히 처리
}
}
}
@@ -165,49 +245,44 @@ object DynamicNewsScraper {
.trim()
}
}
object SafeScraper {
// 세마포어2개로 유지하되, 작업당 타임아웃을 반드시 설정해야 합니다.
private val semaphore = Semaphore(4)
// 동시 처리1개로 줄여서 안정성을 극대화 (추천)
// Playwright는 여러 페이지를 띄울 때 CPU/메모리 점유율이 매우 높습니다.
private val semaphore = Semaphore(2)
suspend fun scrapeParallel(corpInfo: CorpInfo, urls: List<NewsItem>) = coroutineScope {
val query = "${corpInfo.cName} ${corpInfo.cCode} ${corpInfo.stockCode}"
urls.map { item ->
async {
if (UrlCacheManager.isAlreadyProcessed(item.originallink)) {
// println("📰 '${query}' 관련 뉴스 기 학습 데이터 스킵")
return@async
}
urls.forEach { item -> // map + awaitAll 대신 순차 처리가 현재 상황에선 더 안정적입니다.
if (UrlCacheManager.isAlreadyProcessed(item.originallink)) {
println("✅ [학습완료 데이터 스킵] ${item.originallink}")
return@forEach
}
semaphore.withPermit {
try {
// 세마포어 획득 시도에 타임아웃을 걸어 대기열 정체 방지
semaphore.withPermit {
// 개별 뉴스 스크래핑에 최대 30~60초 제한 설정 (무한 대기 방지 핵심)
withTimeout(10000L) {
val content = DynamicNewsScraper.fetchFullContent(item.originallink)
if (content.isNotBlank()) {
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}")
}
withTimeout(25000L) { // 타임아웃 약간 증가
val content = DynamicNewsScraper.fetchFullContent(item.originallink)
if (content.isNotBlank()) {
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: TimeoutCancellationException) {
println("⏳ [타임아웃] 뉴스 읽기 시간 초과: ${item.originallink}")
} catch (e: Exception) {
println("❌ [스크래핑 에러] ${item.originallink}: ${e.localizedMessage}")
println("❌ [스크래핑 실패] ${item.originallink}: ${e.localizedMessage}")
}
// 기사 사이의 짧은 휴식 (차단 방지 및 브라우저 안정화)
delay(Random.nextLong(500, 1500))
}
}.awaitAll()
println("🏁 $query 관련 뉴스 ${urls.size}개 처리 시도 완료")
}
println("🏁 뉴스 처리 완료")
}
}
}