...
This commit is contained in:
parent
72b99769b5
commit
a700d54dfe
@ -86,7 +86,7 @@ fun getLlamaBinPath(): String {
|
|||||||
}
|
}
|
||||||
// Windows NUC
|
// Windows NUC
|
||||||
os.contains("win") -> {
|
os.contains("win") -> {
|
||||||
"$basePath/win-x64-n/llama-server.exe"
|
"$basePath/win-x64/llama-server.exe"
|
||||||
}
|
}
|
||||||
else -> "$basePath/llama-server"
|
else -> "$basePath/llama-server"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -46,16 +46,20 @@ import service.TradingDecisionCallback
|
|||||||
import service.UrlCacheManager
|
import service.UrlCacheManager
|
||||||
import java.nio.file.Paths
|
import java.nio.file.Paths
|
||||||
import java.time.Duration
|
import java.time.Duration
|
||||||
|
import java.time.ZonedDateTime
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
import java.time.temporal.ChronoUnit
|
||||||
|
import java.util.Locale
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
interface TradingAnalyst {
|
//interface TradingAnalyst {
|
||||||
@SystemMessage("""
|
// @SystemMessage("""
|
||||||
You are a Senior Stock Analyst.
|
// You are a Senior Stock Analyst.
|
||||||
Analyze the data and provide a decision in JSON format.
|
// Analyze the data and provide a decision in JSON format.
|
||||||
You must respond ONLY with a valid JSON object.
|
// You must respond ONLY with a valid JSON object.
|
||||||
""")
|
// """)
|
||||||
fun analyzeStock(@dev.langchain4j.service.UserMessage prompt: String): TradingDecision
|
// fun analyzeStock(@dev.langchain4j.service.UserMessage prompt: String): TradingDecision
|
||||||
}
|
//}
|
||||||
|
|
||||||
object RagService {
|
object RagService {
|
||||||
|
|
||||||
@ -76,9 +80,9 @@ object RagService {
|
|||||||
.responseFormat("json_object")
|
.responseFormat("json_object")
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
private val analyst = AiServices.builder(TradingAnalyst::class.java)
|
// private val analyst = AiServices.builder(TradingAnalyst::class.java)
|
||||||
.chatModel(chatModel)
|
// .chatModel(chatModel)
|
||||||
.build()
|
// .build()
|
||||||
|
|
||||||
private val embeddingStore: LuceneEmbeddingStore by lazy {
|
private val embeddingStore: LuceneEmbeddingStore by lazy {
|
||||||
val path = Paths.get("db/lucene_idx")
|
val path = Paths.get("db/lucene_idx")
|
||||||
@ -180,6 +184,28 @@ object RagService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun isRecentNews(dateStr: String?, maxDays: Long = 3): Boolean {
|
||||||
|
if (dateStr.isNullOrBlank()) return false
|
||||||
|
return try {
|
||||||
|
// 네이버 뉴스 OpenAPI 기본 포맷: "Mon, 06 Apr 2026 12:00:00 +0900"
|
||||||
|
val formatter = DateTimeFormatter.ofPattern("EEE, dd MMM yyyy HH:mm:ss Z", Locale.ENGLISH)
|
||||||
|
val pubDate = ZonedDateTime.parse(dateStr, formatter)
|
||||||
|
val now = ZonedDateTime.now()
|
||||||
|
|
||||||
|
// 뉴스가 미래로 표기된 경우도 대비하여 절대값 처리
|
||||||
|
Math.abs(ChronoUnit.DAYS.between(pubDate, now)) <= maxDays
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// 다른 날짜 포맷(예: "yyyy.MM.dd")으로 들어오는 경우를 위한 Fallback
|
||||||
|
try {
|
||||||
|
val fallbackFormatter = DateTimeFormatter.ofPattern("yyyy.MM.dd HH:mm", Locale.ENGLISH)
|
||||||
|
val pubDate = ZonedDateTime.parse("$dateStr 00:00 +0900", fallbackFormatter)
|
||||||
|
Math.abs(ChronoUnit.DAYS.between(pubDate, ZonedDateTime.now())) <= maxDays
|
||||||
|
} catch (e2: Exception) {
|
||||||
|
false // 날짜 파싱 실패 시 보수적으로 '오래된 뉴스'로 취급하여 스크래핑 유도
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun processStock(currentPrice: Double, technicalAnalyzer: TechnicalAnalyzer, stockName: String, stockCode: String, result: TradingDecisionCallback) {
|
suspend fun processStock(currentPrice: Double, technicalAnalyzer: TechnicalAnalyzer, stockName: String, stockCode: String, result: TradingDecisionCallback) {
|
||||||
val totalStartTime = System.currentTimeMillis() // 전체 시작 시간
|
val totalStartTime = System.currentTimeMillis() // 전체 시작 시간
|
||||||
|
|
||||||
@ -211,32 +237,68 @@ object RagService {
|
|||||||
val techDuration = System.currentTimeMillis() - techStartTime
|
val techDuration = System.currentTimeMillis() - techStartTime
|
||||||
println("⏱️ [$stockName] 기술적 지표 계산 소요: ${techDuration}ms")
|
println("⏱️ [$stockName] 기술적 지표 계산 소요: ${techDuration}ms")
|
||||||
val guideLine = KisSession.config.getValues(ConfigIndex.MIN_PURCHASE_SCORE_INDEX)
|
val guideLine = KisSession.config.getValues(ConfigIndex.MIN_PURCHASE_SCORE_INDEX)
|
||||||
if (scores.avg() > (guideLine.times(0.85))) {
|
if (scores.avg() > (guideLine.times(0.50))) {
|
||||||
// 2. 뉴스 스크래핑 및 학습 시간 측정
|
// 2. 뉴스 스크래핑 및 학습 시간 측정
|
||||||
|
val ragStartTime = System.currentTimeMillis()
|
||||||
|
val question = "${corpInfo?.cName} $stockName[$stockCode]의 향후 실적 전망과 관련된 핵심 뉴스"
|
||||||
|
val questionEmbedding = embeddingModel.embed(question).content()
|
||||||
|
|
||||||
|
// --- 💡 [수정됨] 2. 해당 주식의 최신 뉴스 존재 여부 확인 (최대 10개) ---
|
||||||
|
val preSearchResult = embeddingStore.search(
|
||||||
|
EmbeddingSearchRequest.builder()
|
||||||
|
.queryEmbedding(questionEmbedding)
|
||||||
|
.filter(MetadataFilterBuilder.metadataKey("stockCode").isEqualTo(stockCode)) // 해당 종목만 필터링
|
||||||
|
.maxResults(10)
|
||||||
|
.minScore(0.3) // 👈 0.70은 너무 엄격할 수 있으니 0.65로 하향 조정
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// 검색된 청크들 중 최근 3일 이내의 날짜를 가진 데이터가 하나라도 있는지 확인
|
||||||
|
val hasRecentData = preSearchResult.matches().any { match ->
|
||||||
|
val pubDate = match.embedded().metadata().getString("date")
|
||||||
|
isRecentNews(pubDate, maxDays = 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 💡 [수정됨] 3. 최신 데이터가 없을 때만 브라우저 스크래핑(Playwright) 실행 ---
|
||||||
val newsIngestStartTime = System.currentTimeMillis()
|
val newsIngestStartTime = System.currentTimeMillis()
|
||||||
corpInfo?.let {
|
if (!hasRecentData) {
|
||||||
try {
|
println("🌐 [$stockName] 최근 3일 내 뉴스가 없습니다. 새 뉴스를 스크래핑합니다.")
|
||||||
NewsService.fetchAndIngestNews(it)
|
corpInfo?.let {
|
||||||
} catch (e: Exception) {}
|
try {
|
||||||
|
NewsService.fetchAndIngestNews(it)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
println("❌ [$stockName] 뉴스 스크래핑 실패: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
println("✅ [$stockName] 최근 3일 내 뉴스가 DB에 존재하여 브라우저 스크래핑을 생략합니다.")
|
||||||
}
|
}
|
||||||
val newsIngestDuration = System.currentTimeMillis() - newsIngestStartTime
|
val newsIngestDuration = System.currentTimeMillis() - newsIngestStartTime
|
||||||
println("⏱️ [$stockName] 뉴스 수집/인덱싱 소요: ${newsIngestDuration}ms")
|
println("⏱️ [$stockName] 뉴스 수집/인덱싱 판단 소요: ${newsIngestDuration}ms")
|
||||||
|
|
||||||
result(tradingDecision, false)
|
result(tradingDecision, false)
|
||||||
tradingDecision.techSummary = technicalAnalyzer.generateComprehensiveReport()
|
tradingDecision.techSummary = technicalAnalyzer.generateComprehensiveReport()
|
||||||
result(tradingDecision, false)
|
result(tradingDecision, false)
|
||||||
|
|
||||||
// 4. RAG 뉴스 검색 및 임베딩 시간 측정
|
// --- 💡 [수정됨] 4. 최종 문맥(Context) 추출 ---
|
||||||
val ragStartTime = System.currentTimeMillis()
|
// (만약 위에서 스크래핑을 새로 했다면 최신 데이터가 포함되어 검색됩니다)
|
||||||
val question = "${corpInfo?.cName} $stockName[$stockCode]의 향후 실적 전망과 관련된 핵심 뉴스"
|
val finalSearchResult = embeddingStore.search(
|
||||||
val questionEmbedding = embeddingModel.embed(question).content()
|
|
||||||
val searchResult = embeddingStore.search(
|
|
||||||
EmbeddingSearchRequest.builder()
|
EmbeddingSearchRequest.builder()
|
||||||
.queryEmbedding(questionEmbedding)
|
.queryEmbedding(questionEmbedding)
|
||||||
|
.filter(MetadataFilterBuilder.metadataKey("stockCode").isEqualTo(stockCode)) // 교차 오염 방지를 위해 필터 필수
|
||||||
.maxResults(3)
|
.maxResults(3)
|
||||||
|
.minScore(0.3) // 👈 0.70은 너무 엄격할 수 있으니 0.65로 하향 조정
|
||||||
.build()
|
.build()
|
||||||
)
|
)
|
||||||
tradingDecision.newsContext = searchResult.matches().joinToString("\n") { it.embedded().text() }
|
|
||||||
|
println("🔎 [$stockName] RAG 검색된 문서 개수: ${finalSearchResult.matches().size}개")
|
||||||
|
finalSearchResult.matches().forEach { match ->
|
||||||
|
println("📊 [RAG Score: ${match.score()}] 본문: ${match.embedded().text().replace("\n", " ").take(50)}...")
|
||||||
|
}
|
||||||
|
|
||||||
|
tradingDecision.newsContext = finalSearchResult.matches().joinToString("\n") { it.embedded().text() }
|
||||||
val ragDuration = System.currentTimeMillis() - ragStartTime
|
val ragDuration = System.currentTimeMillis() - ragStartTime
|
||||||
println("⏱️ [$stockName] RAG 뉴스 검색 소요: ${ragDuration}ms")
|
println("⏱️ [$stockName] RAG 뉴스 검색 소요: ${ragDuration}ms")
|
||||||
|
|
||||||
|
|||||||
@ -374,8 +374,6 @@ object AutoTradingManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
suspend fun sellingAfterMarketOnePrice(tradeService: KisTradeService,balance : UnifiedBalance,marketCode : String = "Y") {
|
suspend fun sellingAfterMarketOnePrice(tradeService: KisTradeService,balance : UnifiedBalance,marketCode : String = "Y") {
|
||||||
|
|
||||||
// if (true) return
|
|
||||||
balance.holdings.forEach { holding ->
|
balance.holdings.forEach { holding ->
|
||||||
if (BLACKLISTEDSTOCKCODES.contains(holding.code)){
|
if (BLACKLISTEDSTOCKCODES.contains(holding.code)){
|
||||||
println("❌ 차단 처리된 주식 : ${holding.name}")
|
println("❌ 차단 처리된 주식 : ${holding.name}")
|
||||||
@ -641,19 +639,27 @@ object AutoTradingManager {
|
|||||||
if (AUTOSELL) balance?.let { resumePendingSellOrders(KisTradeService, it) }
|
if (AUTOSELL) balance?.let { resumePendingSellOrders(KisTradeService, it) }
|
||||||
return balance
|
return balance
|
||||||
} else {
|
} else {
|
||||||
// listOf<String>("Y","X").forEach { code ->
|
// val now = LocalTime.now()
|
||||||
|
// val currentMinute = now.minute
|
||||||
|
// if((now.hour == 16 || now.hour == 17) && (currentMinute % 10 == 3 || currentMinute % 10 == 9)) {
|
||||||
|
// if (lastForceCheckMinute != currentMinute) {
|
||||||
|
// listOf<String>("Y","X").forEach { code ->
|
||||||
// KisTradeService.fetchIntegratedBalance(code).getOrNull()?.let {
|
// KisTradeService.fetchIntegratedBalance(code).getOrNull()?.let {
|
||||||
// sellingAfterMarketOnePrice(KisTradeService, it, code)
|
// sellingAfterMarketOnePrice(KisTradeService, it, code)
|
||||||
// }
|
// }
|
||||||
// delay(1000)
|
// delay(1000)
|
||||||
// }
|
// }
|
||||||
|
// lastForceCheckMinute = currentMinute // 실행 완료 기록
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
//
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun executeMarketLoop() {
|
suspend fun executeMarketLoop() {
|
||||||
val balance = checkBalance()
|
val balance = checkBalance()
|
||||||
if (AUTOSELL) balance?.let { resumePendingSellOrders(KisTradeService, it) }
|
// if (AUTOSELL) balance?.let { resumePendingSellOrders(KisTradeService, it) }
|
||||||
|
|
||||||
val myCash = balance?.deposit?.replace(",", "")?.toLongOrNull() ?: 0L
|
val myCash = balance?.deposit?.replace(",", "")?.toLongOrNull() ?: 0L
|
||||||
val myHoldings = balance?.holdings?.filter { it.quantity.toInt() > 0 }?.map { it.code }?.toSet() ?: emptySet()
|
val myHoldings = balance?.holdings?.filter { it.quantity.toInt() > 0 }?.map { it.code }?.toSet() ?: emptySet()
|
||||||
@ -733,16 +739,20 @@ object AutoTradingManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
else if((now.hour == 16 || now.hour == 17) && (currentMinute % 10 == 3 || currentMinute % 10 == 9)) {
|
else if((now.hour == 16 || now.hour == 17) && (currentMinute % 10 == 3 || currentMinute % 10 == 9)) {
|
||||||
// if (lastForceCheckMinute != currentMinute) {
|
if (lastForceCheckMinute != currentMinute) {
|
||||||
// TradingLogStore.addAnalyzer(
|
TradingLogStore.addAnalyzer(
|
||||||
// " - ",
|
" - ",
|
||||||
// " - ",
|
" - ",
|
||||||
// "⏰ [강제 스케줄 실행] 오후 ${now.hour}시 ${currentMinute}분 - 보유주식 시간외 단일가 또는 대체마켓 체크를 시작합니다.",
|
"⏰ [강제 스케줄 실행] 오후 ${now.hour}시 ${currentMinute}분 - 보유주식 시간외 단일가 또는 대체마켓 체크를 시작합니다.",
|
||||||
// true
|
true
|
||||||
// )
|
)
|
||||||
// checkBalance(false)
|
listOf<String>("Y","X").forEach { code ->
|
||||||
// lastForceCheckMinute = currentMinute // 실행 완료 기록
|
KisTradeService.fetchIntegratedBalance(code).getOrNull()?.let {
|
||||||
// }
|
sellingAfterMarketOnePrice(KisTradeService, it, code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lastForceCheckMinute = currentMinute // 실행 완료 기록
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1155,7 +1165,7 @@ class TechnicalAnalyzer {
|
|||||||
val disparityDaily = (currentPrice / ma20Daily) * 100
|
val disparityDaily = (currentPrice / ma20Daily) * 100
|
||||||
|
|
||||||
if (disparityDaily > 115.0) { // 20일선보다 15% 이상 떠 있으면 감점 시작
|
if (disparityDaily > 115.0) { // 20일선보다 15% 이상 떠 있으면 감점 시작
|
||||||
val penalty = ((disparityDaily - 115.0) * 0.5).toInt() // 초과분 1%당 2점 감점
|
val penalty = ((disparityDaily - 115.0) * 0.3).toInt() // 초과분 1%당 2점 감점
|
||||||
short -= penalty
|
short -= penalty
|
||||||
ultra -= (penalty / 2) // 초단기에도 영향
|
ultra -= (penalty / 2) // 초단기에도 영향
|
||||||
println("⚠️ [과열 감점] 일봉 이격도(${String.format("%.1f", disparityDaily)}%): -${penalty}점")
|
println("⚠️ [과열 감점] 일봉 이격도(${String.format("%.1f", disparityDaily)}%): -${penalty}점")
|
||||||
@ -1166,8 +1176,8 @@ class TechnicalAnalyzer {
|
|||||||
if (weekly.size >= 3) {
|
if (weekly.size >= 3) {
|
||||||
val weeklyChange = calculateChange(weekly.takeLast(3))
|
val weeklyChange = calculateChange(weekly.takeLast(3))
|
||||||
if (weeklyChange > 30.0) { // 3주간 30% 이상 급등 시
|
if (weeklyChange > 30.0) { // 3주간 30% 이상 급등 시
|
||||||
mid -= 10
|
mid -= 6
|
||||||
short -= 5
|
short -= 3
|
||||||
println("⚠️ [과열 감점] 주봉 급등(${String.format("%.1f", weeklyChange)}%): -10점")
|
println("⚠️ [과열 감점] 주봉 급등(${String.format("%.1f", weeklyChange)}%): -10점")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -111,71 +111,75 @@ object DynamicNewsScraper {
|
|||||||
fun extractSmartContentWithLineFilter(page: Page): String {
|
fun extractSmartContentWithLineFilter(page: Page): String {
|
||||||
val script = """
|
val script = """
|
||||||
() => {
|
() => {
|
||||||
// 1. 선제적 노이즈 제거: 분석에 방해되는 태그들을 DOM에서 아예 삭제
|
// 1. 선제적 노이즈 제거: ASIDE(사이드바/광고) 태그 추가
|
||||||
const junkTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'IFRAME', 'SVG', 'HEADER', 'FOOTER', 'NAV'];
|
const junkTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'IFRAME', 'SVG', 'HEADER', 'FOOTER', 'NAV', 'ASIDE'];
|
||||||
document.querySelectorAll(junkTags.join(',')).forEach(el => el.remove());
|
document.querySelectorAll(junkTags.join(',')).forEach(el => el.remove());
|
||||||
|
|
||||||
const MIN_LINE_LENGTH = 10;
|
const MIN_LINE_LENGTH = 10;
|
||||||
const MIN_TOTAL_LENGTH = 100;
|
const MIN_TOTAL_LENGTH = 100;
|
||||||
const CONSECUTIVE_THRESHOLD = 2;
|
const CONSECUTIVE_THRESHOLD = 2;
|
||||||
|
|
||||||
// 2. 라인별 정제 함수 (짧은 라인 연속 시 예외 처리)
|
// 2. 라인별 정제 함수 (예외 처리 강화)
|
||||||
const getRefinedText = (el) => {
|
const getRefinedText = (el) => {
|
||||||
// 실제 텍스트만 추출하여 라인별로 분리
|
if (!el || !el.innerText) return "";
|
||||||
const lines = el.innerText.split('\n').map(l => l.trim()).filter(l => l.length > 0);
|
const lines = el.innerText.split('\n').map(l => l.trim()).filter(l => l.length > 0);
|
||||||
let resultLines = [];
|
let resultLines = [];
|
||||||
let tempBuffer = [];
|
let tempBuffer = [];
|
||||||
let consecutiveShort = 0;
|
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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
lines.forEach(line => {
|
|
||||||
if (line.length <= MIN_LINE_LENGTH) {
|
|
||||||
consecutiveShort++;
|
|
||||||
tempBuffer.push(line);
|
|
||||||
} else {
|
|
||||||
// 짧은 줄이 연속되지 않았을 때만 버퍼를 결과에 합침
|
|
||||||
if (consecutiveShort < CONSECUTIVE_THRESHOLD) {
|
if (consecutiveShort < CONSECUTIVE_THRESHOLD) {
|
||||||
resultLines = resultLines.concat(tempBuffer);
|
resultLines = resultLines.concat(tempBuffer);
|
||||||
}
|
}
|
||||||
resultLines.push(line);
|
return resultLines.join('\n');
|
||||||
tempBuffer = [];
|
};
|
||||||
consecutiveShort = 0;
|
|
||||||
|
// 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;
|
||||||
if (consecutiveShort < CONSECUTIVE_THRESHOLD) {
|
|
||||||
resultLines = resultLines.concat(tempBuffer);
|
|
||||||
}
|
}
|
||||||
return resultLines.join('\n');
|
""".trimIndent()
|
||||||
};
|
|
||||||
|
|
||||||
// 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
|
return page.evaluate(script) as String
|
||||||
}
|
}
|
||||||
@ -259,8 +263,26 @@ object DynamicNewsScraper {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun cleanText(text: String): String {
|
private fun cleanText(text: String): String {
|
||||||
return text.replace(Regex("(?m)^.*기자.*$"), "") // 기자 정보 제거
|
// 1. 제거할 불필요한 노이즈 키워드 목록
|
||||||
.replace(Regex("(?m)^.*무단 전재.*$"), "") // 저작권 문구 제거
|
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()
|
.trim()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -286,7 +308,9 @@ object SafeScraper {
|
|||||||
try {
|
try {
|
||||||
withTimeout(25000L) { // 타임아웃 약간 증가
|
withTimeout(25000L) { // 타임아웃 약간 증가
|
||||||
val content = DynamicNewsScraper.fetchFullContent(item.originallink)
|
val content = DynamicNewsScraper.fetchFullContent(item.originallink)
|
||||||
|
println("📝 [News Raw Text Length] ${item.originallink.take(30)}... : ${content.length}자 추출됨")
|
||||||
if (content.isNotBlank() && content.length > 100) {
|
if (content.isNotBlank() && content.length > 100) {
|
||||||
|
UrlCacheManager.addToCache(item.originallink)
|
||||||
RagService.ingestWithChunking(
|
RagService.ingestWithChunking(
|
||||||
text = content,
|
text = content,
|
||||||
newsLink = item.originallink,
|
newsLink = item.originallink,
|
||||||
|
|||||||
@ -114,7 +114,7 @@ object LlamaServerManager {
|
|||||||
val pb = ProcessBuilder(command)
|
val pb = ProcessBuilder(command)
|
||||||
|
|
||||||
// 2. 윈도우 Vulkan 환경 변수 설정
|
// 2. 윈도우 Vulkan 환경 변수 설정
|
||||||
if (isWin && binPath.endsWith("win-x64")) {
|
if (isWin ) {
|
||||||
val env = pb.environment()
|
val env = pb.environment()
|
||||||
// 특정 GPU 선택 (내장 GPU가 여러 개일 경우)
|
// 특정 GPU 선택 (내장 GPU가 여러 개일 경우)
|
||||||
env["GGML_VULKAN_DEVICE"] = "0"
|
env["GGML_VULKAN_DEVICE"] = "0"
|
||||||
@ -158,10 +158,11 @@ object LlamaServerManager {
|
|||||||
}
|
}
|
||||||
if (port == EMBEDDING_PORT){
|
if (port == EMBEDDING_PORT){
|
||||||
AutoTradingManager.llmNews = true
|
AutoTradingManager.llmNews = true
|
||||||
|
RagService.active()
|
||||||
}
|
}
|
||||||
if (processes.size > 1) {
|
if (processes.size > 1) {
|
||||||
println("[Cache] ${processes.size}")
|
println("[Cache] ${processes.size}")
|
||||||
RagService.active()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user