// 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, 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): 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): List { 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, // List을 인자로 받음 contentSourcePromptPart: String, commentPromptPart: String, mainTopicPromptPart: String, imagePromptPart: String ): String { // 동적 요청사항과 사용자 정의 요청사항을 합쳐서 최종 리스트 생성 val finalInstructions = mutableListOf() 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)}...'" } }