302 lines
18 KiB
Kotlin
302 lines
18 KiB
Kotlin
// 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.*
|
||
import kotlinx.serialization.encodeToString
|
||
import kotlinx.serialization.json.Json
|
||
import models.ScrapedData
|
||
import org.openqa.selenium.WebDriverException
|
||
import org.openqa.selenium.chrome.ChromeDriver
|
||
import org.openqa.selenium.chrome.ChromeOptions
|
||
import java.io.File
|
||
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"
|
||
|
||
// ⭐️ [추가] 프롬프트 저장을 위한 키
|
||
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"
|
||
|
||
|
||
}
|
||
|
||
fun logMessage(logs: MutableList<String>, message: String) {
|
||
val timestamp = SimpleDateFormat("HH:mm:ss", Locale.getDefault()).format(Date())
|
||
logs.add(0, "$timestamp: $message")
|
||
}
|
||
|
||
|
||
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)}...'"
|
||
}
|
||
} |