2025-10-13 16:18:55 +09:00

302 lines
18 KiB
Kotlin
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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)}...'"
}
}