2025-10-13 13:26:39 +09:00
// utils/Global.kt
package utils
import io.ktor.client.*
import io.ktor.client.engine.cio.*
import io.ktor.client.plugins.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.serialization.kotlinx.json.*
2025-10-13 16:18:55 +09:00
import kotlinx.serialization.encodeToString
2025-10-13 13:26:39 +09:00
import kotlinx.serialization.json.Json
2025-10-13 16:18:55 +09:00
import models.ScrapedData
import org.openqa.selenium.WebDriverException
import org.openqa.selenium.chrome.ChromeDriver
import org.openqa.selenium.chrome.ChromeOptions
import java.io.File
2025-10-13 13:26:39 +09:00
import java.text.SimpleDateFormat
import java.util.*
import java.util.prefs.Preferences
object Global {
val httpClient = HttpClient ( CIO ) {
install ( ContentNegotiation ) { json ( Json { isLenient = true ; ignoreUnknownKeys = true ; prettyPrint = true } ) }
install ( HttpTimeout ) { requestTimeoutMillis = 900000 }
}
val jsonParser = Json { prettyPrint = true ; ignoreUnknownKeys = true ; encodeDefaults = true ; coerceInputValues = true }
val prefs : Preferences = Preferences . userNodeForPackage ( Unit :: class . java )
const val PREF _FOLDER _PATH = " folder_path "
const val PREF _API _KEY = " api_key "
const val PREF _WORKSPACE _SLUG = " workspace_slug "
const val PREF _RECEIPT _WORKSPACE _SLUG = " receipt_workspace_slug "
const val PREF _MODEL _NAME = " model_name "
2025-10-13 16:18:55 +09:00
// ⭐️ [추가] 프롬프트 저장을 위한 키
const val PREF _PROMPT _GENERATE = " prompt_generate "
const val PREF _PROMPT _REVISE = " prompt_revise "
const val PREF _PROMPT _RECEIPT = " prompt_receipt "
const val PREF _PROMPT _GENERATE _INSTRUCTIONS = " prompt_generate_instructions "
// ⭐️ [추가] 스크래핑 셀렉터 저장을 위한 키
const val PREF _ARTICLE _SELECTORS = " article_selectors "
2025-10-13 13:26:39 +09:00
}
fun logMessage ( logs : MutableList < String > , message : String ) {
val timestamp = SimpleDateFormat ( " HH:mm:ss " , Locale . getDefault ( ) ) . format ( Date ( ) )
logs . add ( 0 , " $timestamp : $message " )
2025-10-13 16:18:55 +09:00
}
object FileManager {
fun saveDataToJsonFile ( keyword : String , data : ScrapedData , folderPath : String , logs : MutableList < String > ) : File {
val directory = File ( folderPath ) . also { if ( ! it . exists ( ) ) it . mkdirs ( ) }
val sanitizedKeyword = keyword . replace ( Regex ( " [^A-Za-z0-9ㄱ-ㅎㅏ-ㅣ가-힣] " ) , " " )
val fileName = " ${sanitizedKeyword} _ ${System.currentTimeMillis()} .json "
val file = File ( directory , fileName )
file . writeText ( Global . jsonParser . encodeToString ( data ) )
logMessage ( logs , Strings . logSavingScrapData ( file . path ) )
return file
}
fun loadScrapedJsonFiles ( folderPath : String , logs : MutableList < String > ) : List < File > {
logMessage ( logs , Strings . logLoadingFiles ( folderPath ) )
return File ( folderPath ) . listFiles { _ , name -> name . endsWith ( " .json " ) }
?. sortedByDescending { it . lastModified ( ) }
. orEmpty ( )
. also { logMessage ( logs , Strings . logLoadedFiles ( it . size ) ) }
}
}
object BrowserManager {
private var driver : ChromeDriver ? = null
fun getChromeDriver ( options : ChromeOptions ) : ChromeDriver {
try {
driver ?. title // Check if the driver is still active
} catch ( e : WebDriverException ) {
driver = null // Driver is dead, so nullify it
}
if ( driver == null ) {
driver = ChromeDriver ( options )
}
return driver !!
}
fun quitChromeDriver ( ) {
driver ?. quit ( )
driver = null
}
}
/ * *
* 애플리케이션 전체에서 사용되는 하드코딩된 문자열을 관리하는 객체입니다 .
* /
object Strings {
// --- 기본 정보 ---
const val APP _TITLE = " 자동 블로그 포스팅 도우미 v1.0 "
// --- 기본값 ---
const val DEFAULT _SCRAPED _FOLDER = " scraped_articles "
const val DEFAULT _WORKSPACE _SLUG = " my-workspace "
const val DEFAULT _RECEIPT _WORKSPACE _SLUG = " receipts "
const val DEFAULT _MODEL _NAME = " Llama-3.1-8B-Vision "
const val DEFAULT _USER _PROMPT = " 개인 기록용으로 가볍게 남기는 스타일로 작성해줘. "
const val DEFAULT _USER _OWN _CONTENT = " 예시: 강릉으로 1박 2일 여행을 다녀왔습니다. "
// ⭐️ [추가] 스크래핑 셀렉터 기본값
val DEFAULT _ARTICLE _SELECTORS = listOf (
" #postListBody " ,
" #app " ,
" .app " ,
" article " ,
" .article-body " ,
" #article_body " ,
" .news-article-body-view "
)
// --- UI 텍스트: 공통 ---
val TABS = listOf ( " 스크랩 기반 포스팅 " , " 직접 포스팅 " , " 영수증 분석기 " , " 통신 로그 " , " 블로그 결과 " )
const val BUTTON _REFRESH = " 새로고침 "
const val LABEL _LLM _REQUEST = " LLM 요청사항 "
const val LABEL _USER _PROMPT = " 글 스타일, 톤앤매너 등을 지시하세요. "
const val PLACEHOLDER _API _KEY = " AnythingLLM API Key "
// --- UI 텍스트: App.kt (환경설정) ---
const val LABEL _BROWSER _VISIBLE = " 브라우저 화면 보기 "
const val LABEL _BROWSER _SESSION = " 브라우저 세션 유지 "
const val LABEL _SCRAP _FOLDER _PATH = " 스크랩 저장 폴더 경로 "
const val LABEL _BLOG _WORKSPACE _SLUG = " 블로그용 Workspace Slug "
const val LABEL _RECEIPT _WORKSPACE _SLUG = " 영수증 처리용 Workspace Slug "
const val LABEL _MODEL _NAME = " 사용 중인 LLM 모델 이름 "
// --- UI 텍스트: 스크랩 기반 포스팅 탭 ---
const val LABEL _KEYWORD _INPUT = " 키워드 직접 입력 "
const val BUTTON _SEARCH = " 검색 "
const val BUTTON _FETCH _TRENDS = " 트렌드 가져오기 "
const val TITLE _SEARCH _RESULTS = " 검색 결과 (클릭하여 스크랩) "
const val TITLE _SAVED _FILES = " 저장된 파일 "
const val TITLE _FILE _CONTENT = " 파일 내용 "
const val TITLE _SELECT _IMAGES = " 대표 이미지 선택 (다중 가능) "
const val BUTTON _SAVE _IMAGE _SELECTION = " 선택 이미지 저장 "
const val LABEL _MAIN _TOPIC = " 글의 핵심 주제 (예: 2025년 최신 IT 트렌드) "
const val LABEL _USER _COMMENT = " 작성자 코멘트 (스크랩한 내용에 대한 당신의 생각) "
fun buttonGeneratePost ( count : Int ) = " 선택한 파일( ${count} 개)로 글 생성 "
// --- UI 텍스트: 직접 포스팅 탭 ---
const val TITLE _DIRECT _POST = " 직접 작성 (여행 기록, 정보 공유 등) "
const val LABEL _DIRECT _POST _CONTENT = " 블로그에 올릴 내용을 직접 작성하세요. "
const val TITLE _IMAGE _UPLOAD = " 이미지 업로드 "
const val BUTTON _UPLOAD _IMAGE = " 내 PC에서 이미지 업로드 "
const val BUTTON _GENERATE _POST _DIRECT = " 작성한 내용으로 글 생성 "
// --- UI 텍스트: 영수증 분석기 탭 ---
const val TITLE _RECEIPT _ANALYZER = " 영수증 분석기 "
const val BUTTON _UPLOAD _RECEIPT = " 영수증 이미지 업로드 "
const val LABEL _RECEIPT _CONTEXT = " 추가 정보 입력 (예: 부산 출장 경비) "
const val PLACEHOLDER _RECEIPT _CONTEXT = " 추가 정보를 입력하면 더 정확하게 분석할 수 있습니다. "
const val BUTTON _CANCEL _ANALYSIS = " 분석 중단하기 "
const val LABEL _ANALYSIS _RESULT = " 분석 결과 (내용 복사하여 사용) "
fun buttonStartAnalysis ( count : Int ) = " 선택한 영수증 분석 시작 ( ${count} 개) "
const val ANALYSIS _STOPPED = " 분석이 중단되었습니다. "
fun analysisError ( message : String ? ) = " 오류 발생: $message "
// --- UI 텍스트: 결과 탭 ---
const val LABEL _BLOG _RESULT = " 블로그 글 결과 (LLM 생성 본문) "
const val LABEL _REVISION _REQUEST = " 추가 요청사항 "
const val PLACEHOLDER _REVISION _REQUEST = " 예: 문체를 좀 더 전문적으로 바꿔줘. 1번 항목을 더 자세히 설명해줘. "
const val BUTTON _REVISE _POST = " LLM으로 글 보완하기 "
const val BUTTON _COPY _TO _CLIPBOARD = " 전체 내용 클립보드에 복사 "
// --- UI 텍스트: 플레이스홀더 ---
const val PLACEHOLDER _BLOG _POST = " LLM으로부터 생성된 블로그 글이 여기에 표시됩니다. "
const val PLACEHOLDER _FILE _CONTENT = " 파일을 선택하면 내용이 여기에 표시됩니다. "
const val PLACEHOLDER _RECEIPT _ANALYSIS = " 영수증을 업로드하고 분석을 시작하세요. "
// --- UI 텍스트: FileDialog ---
const val FILE _DIALOG _TITLE = " 파일 선택 "
// --- 로그 메시지 ---
fun logImageFilesAdded ( count : Int ) = " ✅ 개인 이미지 파일 ${count} 개 추가됨. "
fun logReceiptsAdded ( count : Int ) = " 🧾 영수증 이미지 ${count} 개 추가됨. "
fun logReadFileError ( message : String ? ) = " ❌ 파일 읽기 오류: $message "
fun logSaveFileError ( fileName : String , message : String ? ) = " ❌ ' $fileName ' 저장 중 오류: $message "
fun logImageSelectionSaved ( fileName : String ) = " ✅ ' ${fileName} '의 선택 이미지 변경사항 저장 완료. "
const val LOG _WARN _NO _FILES _TO _SAVE = " ⚠️ 저장할 파일을 선택해주세요. "
const val LOG _WARN _MISSING _API _CONFIG = " ⚠️ API 키와 블로그용 Workspace Slug를 모두 입력해주세요. "
const val LOG _WARN _NO _FILES _SELECTED = " ⚠️ 글을 생성할 스크랩 파일을 1개 이상 선택해주세요. "
const val LOG _WARN _UPLOAD _FAILED = " ⚠️ 파일 업로드에 실패하여 글 생성을 중단합니다. "
const val LOG _WARN _DIRECT _POST _EMPTY = " ⚠️ 직접 포스팅을 하려면 내용과 이미지가 모두 필요합니다. "
const val LOG _INFO _ANALYSIS _CANCELLED = " ℹ ️ 영수증 분석이 사용자에 의해 중단되었습니다."
fun logAnalysisError ( message : String ? ) = " ❌ 영수증 분석 중 심각한 오류 발생: $message "
const val LOG _CLIPBOARD _COPY _SUCCESS = " ✅ 블로그 전체 내용(꼬리말 및 생성 정보 포함)이 클립보드에 복사되었습니다. "
fun logLoadingFiles ( path : String ) = " ' $path '에서 파일 목록 로딩... "
fun logLoadedFiles ( count : Int ) = " ✅ 파일 ${count} 개 로딩 완료. "
fun logSavingScrapData ( path : String ) = " ✅ ' ${path} '에 스크랩 데이터 저장 완료. "
const val LOG _TRENDS _START = " Google Trends 페이지 스크랩 시작... "
fun logTrendsSuccess ( count : Int ) = " ✅ Google Trends 키워드 ${count} 개 스크랩 완료. "
fun logTrendsError ( message : String ? ) = " ❌ Google Trends 스크랩 오류: $message "
fun logSearchStart ( keyword : String ) = " ' $keyword ' 키워드로 Google 검색 시작... "
fun logSearchSuccess ( keyword : String , count : Int ) = " ✅ ' $keyword ' 검색 결과 ${count} 개 수집 완료. "
fun logSearchError ( message : String ? ) = " ❌ Google 검색 중 오류: $message "
fun logScrapeUrlStart ( url : String ) = " URL 스크랩 시작: $url "
const val LOG _WARN _ARTICLE _BODY _NOT _FOUND = " ⚠️ 기사 본문을 찾을 수 없습니다. "
fun logScrapeUrlSuccess ( imageCount : Int ) = " ✅ URL 스크랩 완료. (이미지 ${imageCount} 개 발견) "
fun logScrapeUrlError ( message : String ? ) = " ❌ URL 스크랩 중 오류: $message "
fun logLlmUploadStarting ( count : Int ) = " LLM에 파일 ${count} 개 순차 업로드 시작... "
fun logLlmUploadingFile ( name : String ) = " - ' ${name} ' 업로드 중... "
fun logLlmUploadSuccess ( name : String , id : String ) = " ✅ ' ${name} ' 업로드 성공 (ID: $id ). "
const val LOG _LLM _UPLOAD _NO _DOC _INFO = " 업로드 응답에 문서 정보가 없습니다. "
fun logLlmUploadResponseError ( name : String , status : Any , body : String ) = " ❌ ' ${name} ' 업로드 실패: $status - $body "
fun logLlmUploadException ( name : String , message : String ? ) = " ❌ ' ${name} ' 업로드 중 오류: $message "
fun logLlmUploadFinished ( total : Int , success : Int ) = " 총 ${total} 개 중 ${success} 개 파일 업로드 완료. "
const val LOG _LLM _POST _GENERATION _START = " LLM 블로그 글 생성 요청... "
const val LOG _LLM _POST _GENERATION _SUCCESS = " ✅ LLM 블로그 글 생성 완료. "
fun logLlmApiError ( message : String ? ) = " ❌ LLM 호출 중 오류: $message "
const val LOG _LLM _REVISION _START = " LLM 블로그 글 수정 요청... "
const val LOG _LLM _REVISION _EMPTY = " ⚠️ 수정 요청사항이 비어있어 수정을 중단합니다. "
const val LOG _LLM _REVISION _SUCCESS = " ✅ LLM 블로그 글 수정 완료. "
fun logLlmCleanupStart ( count : Int ) = " LLM 워크스페이스 정리 시작 (파일 ${count} 개 삭제)... "
const val LOG _LLM _CLEANUP _SUCCESS = " ✅ LLM 워크스페이스 정리 완료. "
fun logLlmCleanupError ( status : Any , body : String ) = " ❌ LLM 워크스페이스 정리 실패: $status - $body "
fun logLlmCleanupException ( message : String ? ) = " ❌ LLM 워크스페이스 정리 중 오류: $message "
fun logReceiptAnalysisMissing ( missing : String ) = " ⚠️ 영수증 분석을 시작할 수 없습니다. ( $missing 누락) "
const val LOG _RECEIPT _ANALYSIS _START = " Starting receipt analysis with stream-chat mode... "
const val LOG _RECEIPT _STREAM _FINALIZED = " ✅ Stream finalized. "
fun logReceiptStreamMetrics ( tokens : Int , duration : Double ? ) = " - Metrics: total_tokens= ${tokens} , duration= ${duration} s "
fun logReceiptStreamAborted ( reason : String ? ) = " ⚠️ Stream aborted by server. Reason: $reason "
fun logReceiptChunkParsingError ( message : String ? , chunk : String ) = " ❌ Chunk parsing error: $message | Chunk: $chunk "
const val LOG _RECEIPT _STREAM _FINISHED = " Receipt analysis stream processing finished. "
fun logReceiptApiError ( status : Any , body : String ) = " ❌ Error during receipt analysis: $status - $body "
fun logReceiptException ( message : String ? ) = " ❌ Exception in receipt analysis: $message "
fun logImageSelectionSavedToFiles ( count : Int ) = " ✅ 선택된 ${count} 개 파일에 이미지 선택 변경사항 저장 완료. "
// --- API 프롬프트 ---
object Prompts {
// ⭐️ [수정] 프롬프트의 기본 지시사항(역할)을 상수로 분리
const val GENERATE _POST _PREFIX = " 당신은 최신 정보를 종합하여 SEO에 최적화된 블로그 글을 작성하는 전문 작가입니다. 아래 내용과 요청사항을 바탕으로, 독자들이 흥미를 느낄만한 멋진 블로그 글을 작성해주세요. "
const val REVISE _POST _PREFIX = " 당신은 주어진 글을 사용자의 요청에 맞게 수정하는 전문 편집자입니다. 아래의 원본 글을 수정 요청사항에 따라 개선해주세요. 원본의 주제와 주요 내용은 유지하되, 요청을 충실히 반영하여 더 나은 글로 만들어주세요. 마크다운 형식은 유지해주세요. "
const val RECEIPT _ANALYSIS _BASE = " 첨부된 영수증 이미지들을 분석해줘. "
// ⭐️ [수정] basePrompt를 인자로 받도록 변경
fun receiptAnalysisWithContext ( basePrompt : String , context : String ) = """ $basePrompt --- 추가 정보 --- $context --- 요청 사항 --- 위 추가 정보를 바탕으로 영수증을 분석하고 비용을 정리해줘. 예를 들어, '부산 출장'이라는 정보가 있다면 각 비용이 부산의 어느 곳에서 발생했는지 주목해서 정리해줘. 총 합계 금액도 요약해줘. """
val DEFAULT _GENERATE _POST _INSTRUCTIONS = listOf (
" 제공된 자료를 종합적으로 활용하여 하나의 완성된 글을 작성해주세요. " ,
" 글의 시작 부분에 독자의 시선을 사로잡을 만한 제목을 2~3개 추천해주세요. " ,
" 가능한 자연스럽고 유머러스하게 글을 작성해주세요. " ,
" 주제에 벗어나지 않고 해당 주제에 포커스를 맞춰서 글을 작성해주세요. "
)
// ⭐️ [수정] generateBlogPost 함수가 요청사항 리스트를 동적으로 조립하도록 변경
fun generateBlogPost (
basePrompt : String ,
userDirection : String ,
instructions : List < String > , // List<String>을 인자로 받음
contentSourcePromptPart : String ,
commentPromptPart : String ,
mainTopicPromptPart : String ,
imagePromptPart : String
) : String {
// 동적 요청사항과 사용자 정의 요청사항을 합쳐서 최종 리스트 생성
val finalInstructions = mutableListOf < String > ( )
finalInstructions . add ( " 사용자 요청: \" $userDirection \" " )
finalInstructions . addAll ( instructions )
if ( imagePromptPart . isNotBlank ( ) ) {
// 이미지 삽입 요청은 항상 마지막 부분에 추가되도록 조정
finalInstructions . add ( imagePromptPart . replaceFirst ( " 4. " , " " ) )
}
// 최종 리스트를 번호와 함께 문자열로 변환
val instructionsText = finalInstructions . mapIndexed { index , text -> " ${index + 1} . $text " } . joinToString ( " \n " )
return """
$ basePrompt
$ contentSourcePromptPart
$ commentPromptPart
$ mainTopicPromptPart
-- - 요청사항 -- -
$ instructionsText
""" .trimIndent()
}
}
// --- 포스트 꼬리말 ---
object Footer {
const val SEPARATOR = " \n \n --- "
const val ORIGINAL _SOURCE _TITLE = " - 원문 출처: "
const val USED _IMAGES _TITLE = " \n - 사용된 이미지: "
const val PROCESS _TITLE = " \n \n [이 글의 작성 과정] "
fun scrapBased ( contextSummary : String , modelName : String ) = " 이 포스팅은 ${contextSummary} 등의 정보를 바탕으로, 여러 참고 자료를 종합하여 ${modelName} AI 모델이 초안을 생성했습니다. 이후 작성자의 검토를 거쳐 수정 및 발행되었습니다. "
fun directPost ( userPrompt : String , modelName : String ) = " 이 포스팅은 작성자가 직접 입력한 내용을 바탕으로, ' ${userPrompt.take(30)} ...' 스타일로 문체와 구성을 다듬도록 요청하여, ${modelName} AI 모델의 도움을 받아 완성되었습니다. "
fun directPostNoPrompt ( modelName : String ) = " 이 포스팅은 작성자가 직접 입력한 내용을 바탕으로, ${modelName} AI 모델의 도움을 받아 완성되었습니다. "
fun contextTopic ( topic : String ) = " 주제: ' ${topic} ' "
fun contextComment ( comment : String ) = " 작성자 코멘트: ' ${comment.take(30)} ...' "
fun contextStyle ( prompt : String ) = " 요청 스타일: ' ${prompt.take(30)} ...' "
}
2025-10-13 13:26:39 +09:00
}