koneBeta
sub_icon

ainovel


정보

제가 쓰는 검수용 파이썬 스크립트.

kone1133
kone1133
2025-11-03 13:44:39
조회 862 · 좋아요 20

//C.py

import os
import sys
import regex as re
from collections import defaultdict
import google.generativeai as genai
from google.api_core import exceptions
import traceback
import math
import time

# --- Dependency Check ---
try:
import colorama
from colorama import Fore, Style
from tqdm import tqdm
colorama.init(autoreset=True)
except ImportError:
print("경고: 'colorama' 또는 'tqdm'을 찾을 수 없습니다. 더 나은 경험을 위해 'pip install colorama tqdm'을 실행해주세요.")
class DummyFore:
def __getattr__(self, name): return ""
class DummyStyle:
def __getattr__(self, name): return ""
Fore = DummyFore()
Style = DummyStyle()
# tqdm이 없는 경우 tqdm.write()가 호출될 때 오류가 발생하지 않도록 더미 구현 추가
def tqdm_dummy(iterable, **kwargs):
print(f"{kwargs.get('total', '')} {kwargs.get('unit', '개')} 처리 중...")
return iterable
tqdm_dummy.write = lambda msg, **kwargs: print(msg) # tqdm.write()를 위한 더미 메서드 추가

def tqdm(iterable, **kwargs):
return tqdm_dummy(iterable, **kwargs)
tqdm.write = tqdm_dummy.write # tqdm.write()가 정상적으로 작동하도록 연결


# --- Color Constants ---
C_INFO = Fore.BLUE
C_WARN = Fore.YELLOW
C_ERROR = Fore.RED
C_SUCCESS = Fore.GREEN
C_FILE = Fore.CYAN
C_ACTION = Fore.MAGENTA
C_RESET = Style.RESET_ALL

# --- Core Configuration & API Constants ---
try:
APP_FOLDER = os.path.dirname(os.path.abspath(__file__))
except NameError:
APP_FOLDER = os.getcwd()

API_KEY_FILE_PATH = r"D:\Python\apikey.txt" # 필요하다면 APP_FOLDER 내의 경로로 변경 고려
LAST_KEY_INDEX_FILE = os.path.join(APP_FOLDER, "last_api_key_index.txt")
GENERATION_CONFIG = { "temperature": 0.7, "top_p": 0.95, "top_k": 40, "max_output_tokens": 8192, "response_mime_type": "text/plain" }
SAFETY_SETTINGS = [ {"category": "HARM_CATEGORY_HARASSMENT", "threshold": "BLOCK_NONE"}, {"category": "HARM_CATEGORY_HATE_SPEECH", "threshold": "BLOCK_NONE"}, {"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", "threshold": "BLOCK_NONE"}, {"category": "HARM_CATEGORY_DANGEROUS_CONTENT", "threshold": "BLOCK_NONE"} ]
API_TIMEOUT_SECONDS = 120
PREFERRED_MODEL_NAMES = ("gemini-2.5-pro", "gemini-2.5-flash")
FIX_LOG_TRANSLATE_CHUNK_SIZE = 50
FIX_LOG_LINE_TOLERANCE = 5

# --- System Instruction for fix_log.txt Translation ---
SYSTEM_INSTRUCTION_FOR_FIX_LOG = """다음 미번역 문장을 한국어로 완전히 번역하라. 파일명과 숫자#{ttt}태그를 없애지말고 그대로 두어라.
예)
translated_146.txt
101#{ttt}“一直以来,我都以为这些传说背후의传说生物都是各大家族自己王婆卖瓜,自卖自夸,现在看来,我可能错了。”
101#{ttt}“지금껏 나는 이 전설들 뒤에 있는 전설 속 생물들이 모두 각 대가문이 벌이는 자화찬인 줄로만 알았는데, 지금 보니 내가 틀렸던 것 같다.”

63743#{ttt}그는 мимовольно 미소를 지었다. 이 어린 소녀, 꽤 интересная.
63743#{ttt}그는 무심코 미소를 지었다. 이 어린 소녀, 꽤 흥미롭다.

위와 같이 윗줄에는 원문을 그대로 표시하고 아래에는 번역문을 표시하시오.
번역 시에 괄호를 추가하여 설명하지 말고 모두 한국어로 번역하세요.
만약 번역이 어려운 한자가 있으면 최대한 의미가 근접한 한국어로 번역하세요.
"""

# --- Preprocessing Patterns ---
LINE_NUM_TAG = "#{ttt}"
LINE_PREFIX_PATTERN = re.compile(r'^(\d+)#\{ttt\}(?:\s*)')
BRACKET_CONTENT_PATTERN = re.compile(r'\([^)]*\)|\[[^\]]*\]')
CHINESE_PATTERN = re.compile(r'[\p{IsHan}]+')
JAPANESE_PATTERN = re.compile(r'[\p{IsKatakana}\p{IsHiragana}]+')
FORBIDDEN_SCRIPTS_PATTERN = re.compile(r'[\p{IsCyrillic}\p{IsArabic}\p{IsBengali}\p{IsGreek}\p{IsThai}\p{IsDevanagari}\p{IsHebrew}]+')
ENGLISH_WORD_PATTERN = re.compile(r'\b[a-zA-Z]{2,}\b')

# --- Global State ---
SKIP_WORDS = []
API_KEYS = []
CURRENT_KEY_INDEX = 0
MODEL_NAME = None

class AllKeysExhaustedException(Exception):
pass

# --- Utility Functions ---
def format_size(size_bytes):
if size_bytes < 1024: return f"{size_bytes} B"
elif size_bytes < 1024 * 1024: return f"{size_bytes / 1024:.1f} KB"
else: return f"{size_bytes / (1024 * 1024):.1f} MB"

def read_file_with_auto_encoding(file_path, default_encoding='utf-8', fallback_encodings=['utf-16', 'cp949', 'euc-kr']):
"""
파일을 여러 인코딩으로 읽으려고 시도합니다.
default_encoding을 먼저 시도하고, 실패하면 fallback_encodings를 순서대로 시도합니다.
"""
encodings_to_try = [default_encoding] + fallback_encodings
for encoding in encodings_to_try:
try:
with open(file_path, 'r', encoding=encoding) as f:
content = f.read()
# 파일 읽기 성공 시 메시지 출력 (tqdm과 충돌 방지를 위해 tqdm.write 사용)
tqdm.write(f"{C_INFO}파일 '{os.path.basename(file_path)}'을(를) '{encoding}' 인코딩으로 성공적으로 읽었습니다.{C_RESET}")
return content.splitlines()
except UnicodeDecodeError:
# 다음 인코딩 시도
continue
except Exception as e:
# 디코딩 오류 외의 다른 파일 I/O 오류 처리
raise IOError(f"파일 '{file_path}'을(를) 읽는 중 예기치 않은 오류 발생: {e}") from e

# 모든 인코딩 시도 실패 시
raise UnicodeDecodeError(
f"파일 '{os.path.basename(file_path)}'을(를) 지원되는 인코딩으로 읽을 수 없습니다. "
f"시도한 인코딩: {', '.join(encodings_to_try)}"
)

def load_skip_words(novel_folder):
"""
T 폴더의 상위 폴더(소설 폴더)에서 skip.txt를 로드합니다.
"""
skip_file_path = os.path.join(novel_folder, "skip.txt")
if not os.path.exists(skip_file_path): return []
try:
lines = read_file_with_auto_encoding(skip_file_path)
words = [line.strip() for line in lines if line.strip()]
if words: print(f"{C_SUCCESS}성공: '{skip_file_path}'에서 {len(words)}개의 제외 키워드를 불러왔습니다.")
return words
except (UnicodeDecodeError, IOError) as e:
print(f"{C_ERROR}skip.txt 로딩 오류: {e}")
return []

def swap_bracketed_words(line: str) -> tuple[str, list[str]]:
swap_logs = []
patterns_and_formats = [
(re.compile(r'(\p{IsHangul}+)(\p{IsHan}+)\(([^)]+)\)'), r'\1\3(\2)'),
(re.compile(r'(\p{IsHangul}+)(\p{IsHan}+)\[([^\]]+)\]'), r'\1\3[\2]'),
(re.compile(r'(\p{IsHan}+)\(([^)]+)\)'), r'\2(\1)'),
(re.compile(r'(\p{IsHan}+)\[([^\]]+)\]'), r'\2[\1]'),
(re.compile(r'([a-zA-Z][a-zA-Z\s]*)\((\p{IsHangul}[\p{IsHangul}\s]*)\)'), r'\2(\1)'),
(re.compile(r'([a-zA-Z][a-zA-Z\s]*)\[(\p{IsHangul}[\p{IsHangul}\s]*)\]'), r'\2[\1]')
]

new_line = line
for pattern, replacement_format in patterns_and_formats:
matches = list(pattern.finditer(new_line))
for match in reversed(matches):
is_hanzi_only_pattern = pattern.pattern in [r'(\p{IsHan}+)\(([^)]+)\)', r'(\p{IsHan}+)\[([^\]]+)\]']

if is_hanzi_only_pattern:
outer = match.group(1)
inner = match.group(2)
if inner in outer:
continue

start, end = match.span()
original_text = match.group(0)
swapped_text = re.sub(pattern, replacement_format, original_text, 1)

new_line = new_line[:start] + swapped_text + new_line[end:]
swap_logs.append(f"{original_text}->{swapped_text}")

return new_line, sorted(set(swap_logs))

def preprocess_line_for_check(line: str) -> str:
while True:
new_line = BRACKET_CONTENT_PATTERN.sub('', line)
if new_line == line:
break
line = new_line
return new_line

def find_problematic_words(line_content: str) -> list:
if not line_content: return []
sanitized_line = preprocess_line_for_check(line_content)
found_words = []
found_words.extend(CHINESE_PATTERN.findall(sanitized_line))
found_words.extend(JAPANESE_PATTERN.findall(sanitized_line))
found_words.extend(FORBIDDEN_SCRIPTS_PATTERN.findall(sanitized_line))
return sorted(list(set(found_words)), key=line_content.find)

def is_line_problematic(line: str) -> bool:
content_part = LINE_PREFIX_PATTERN.sub('', line).strip()
if not content_part: return False
if any(skip_word in content_part for skip_word in SKIP_WORDS):
return False
if "[번역 누락됨]" in content_part: return True
if find_problematic_words(content_part): return True
return False

# --- API Functions ---
def load_api_keys(file_path):
global API_KEYS
if not os.path.exists(file_path):
print(f"{C_ERROR}치명적 오류: API 키 파일을 다음 경로에서 찾을 수 없습니다: {file_path}"); return False
try:
with open(file_path, 'r', encoding='utf-8') as f:
API_KEYS = [line.strip() for line in f if line.strip()]
if not API_KEYS:
print(f"{C_ERROR}치명적 오류: {file_path}에서 API 키를 찾을 수 없습니다."); return False
print(f"{C_SUCCESS}성공: {len(API_KEYS)}개의 API 키를 불러왔습니다.")
return True
except Exception as e:
print(f"{C_ERROR}치명적 오류: API 키 파일을 읽는 중 오류 발생: {e}"); return False

def load_model_name(script_folder):
global MODEL_NAME
model_file_path = os.path.join(script_folder, "model.txt")
try:
with open(model_file_path, 'r', encoding='utf-8') as f: MODEL_NAME = f.readline().strip()
if not MODEL_NAME:
print(f"{C_ERROR}치명적 오류: 'model.txt' 파일이 비어있습니다."); return False
print(f"{C_SUCCESS}성공: 모델 이름 '{MODEL_NAME}'을(를) 불러왔습니다.")
return True
except Exception as e:
print(f"{C_ERROR}치명적 오류: 모델 이름을 읽는 중 오류 발생: {e}."); return False

def save_last_key_index(index: int):
try:
with open(LAST_KEY_INDEX_FILE, 'w', encoding='utf-8') as f: f.write(str(index))
except Exception as e:
tqdm.write(f"{C_WARN}경고: 마지막으로 사용한 API 키 인덱스를 저장할 수 없습니다: {e}")

def load_last_key_index() -> int:
if not os.path.exists(LAST_KEY_INDEX_FILE): return 0
try:
with open(LAST_KEY_INDEX_FILE, 'r', encoding='utf-8') as f:
index = int(f.read().strip())
if API_KEYS and index >= len(API_KEYS): index = index % len(API_KEYS)
return index
except Exception: return 0

def call_gemini_api(prompt_content, custom_system_instruction):
global CURRENT_KEY_INDEX

models_to_try = list(PREFERRED_MODEL_NAMES) + [MODEL_NAME]
models_to_try = list(dict.fromkeys(models_to_try))

for model_name_to_use in models_to_try:
tqdm.write(f"\n{C_INFO}정보: '{model_name_to_use}' 모델로 번역을 시도합니다...")

retry_count = 0
max_retries = len(API_KEYS) * 2

while retry_count < max_retries:
try:
genai.configure(api_key=API_KEYS[CURRENT_KEY_INDEX]) # 현재 키로 설정
model = genai.GenerativeModel(
model_name=model_name_to_use,
generation_config=GENERATION_CONFIG,
safety_settings=SAFETY_SETTINGS,
system_instruction=custom_system_instruction
)
response = model.generate_content(prompt_content, request_options={'timeout': API_TIMEOUT_SECONDS})

tqdm.write(f"{C_SUCCESS}성공: '{model_name_to_use}' 모델을 사용하여 응답을 받았습니다.")
return response.text

except exceptions.ResourceExhausted:
tqdm.write(f"\n{C_WARN}경고: API 키 #{CURRENT_KEY_INDEX + 1} ({model_name_to_use})의 할당량이 초과되었습니다. 다음 키로 전환합니다...")
CURRENT_KEY_INDEX = (CURRENT_KEY_INDEX + 1) % len(API_KEYS)
save_last_key_index(CURRENT_KEY_INDEX)
retry_count += 1
if retry_count > 0 and retry_count % len(API_KEYS) == 0:
tqdm.write(f"{C_INFO}모든 API 키를 시도했습니다. 1분 후 다시 시도합니다...{C_RESET}")
time.sleep(60)

except Exception as e:
error_str = str(e).lower()
if "not found" in error_str or "permission denied" in error_str or "invalid" in error_str:
tqdm.write(f"\n{C_ERROR}오류: '{model_name_to_use}' 모델을 사용할 수 없습니다({e}). 다음 모델로 넘어갑니다...{C_RESET}")
break

tqdm.write(f"\n{C_WARN}경고: API 오류가 발생했습니다 ({model_name_to_use}): {e}. 다음 키로 재시도합니다...{C_RESET}")
CURRENT_KEY_INDEX = (CURRENT_KEY_INDEX + 1) % len(API_KEYS)
save_last_key_index(CURRENT_KEY_INDEX)
retry_count += 1
time.sleep(5)

raise AllKeysExhaustedException(f"모든 모델({', '.join(models_to_try)})과 모든 API 키를 사용한 시도가 실패했습니다.")

# --- File Selection Helper ---
def select_file_from_folder(output_folder):
"""Helper function to list and select a .txt file from a folder."""
try:
txt_files = sorted([f for f in os.listdir(output_folder) if f.endswith('.txt')])
if not txt_files:
print(f"{C_WARN}'{output_folder}'에서 .txt 파일을 찾을 수 없습니다.")
return None
print(f"\n{C_ACTION}처리할 파일을 선택해주세요:")
for i, filename in enumerate(txt_files):
file_path = os.path.join(output_folder, filename)
try:
size_str = format_size(os.path.getsize(file_path))
print(f"{C_SUCCESS}{i+1}. {C_FILE}{filename}\n {C_INFO}크기: {size_str}")
except OSError:
print(f"{C_SUCCESS}{i+1}. {C_FILE}{filename}\n {C_ERROR}크기: (읽을 수 없음)")
while True:
try:
choice = input(f"\n{C_WARN}선택: {C_RESET}")
selected_filename = txt_files[int(choice) - 1]
print(f"\n{C_SUCCESS}{selected_filename} 파일이 선택되었습니다.")
return os.path.join(output_folder, selected_filename)
except (ValueError, IndexError):
print(f"{C_ERROR}잘못된 입력입니다. 1에서 {len(txt_files)} 사이의 숫자를 입력해주세요.")
except (KeyboardInterrupt, EOFError):
print("\n작업이 취소되었습니다.")
return None
except Exception as e:
print(f"{C_ERROR}파일 선택 중 오류가 발생했습니다: {e}")
return None

# --- Mode 1: Create fix_log.txt ---
def run_create_fix_log_mode(novel_folder, output_folder):
print(f"\n{C_ACTION}--- 모드 1: fix_log.txt 생성/업데이트 ---{C_RESET}")
try:
input_path = select_file_from_folder(output_folder)
if not input_path:
return

try:
lines = read_file_with_auto_encoding(input_path)
except (UnicodeDecodeError, IOError) as e:
print(f"{C_ERROR}입력 파일 '{os.path.basename(input_path)}'을(를) 읽는 중 오류 발생: {e}"); return

print(f"\n{C_ACTION}{len(lines)}개 라인에서 미번역 단어를 확인합니다...")
problematic_lines = []

for i, line in tqdm(enumerate(lines), total=len(lines), desc="파일 검사 중", unit="줄"):
if is_line_problematic(line):
problematic_lines.append(f"{i+1}{LINE_NUM_TAG}{line}")

if not problematic_lines:
print(f"{C_SUCCESS}문제가 있는 라인을 찾지 못했습니다."); return

fix_log_path = os.path.join(novel_folder, "fix_log.txt")
existing_log_entries = set()
if os.path.exists(fix_log_path):
try:
existing_log_lines = read_file_with_auto_encoding(fix_log_path)
# 기존 로그 항목을 읽을 때는 원본과 번역본 두 줄을 하나의 논리적 엔트리로 처리하여 중복 방지
i = 0
while i < len(existing_log_lines):
line = existing_log_lines[i].strip()
if LINE_PREFIX_PATTERN.match(line):
# 다음 줄이 유효한 번역 줄이거나 비어있는 번역 줄일 경우 (같은 태그)
if i + 1 < len(existing_log_lines) and \
LINE_PREFIX_PATTERN.match(existing_log_lines[i+1].strip()) and \
LINE_PREFIX_PATTERN.match(line).group(1) == LINE_PREFIX_PATTERN.match(existing_log_lines[i+1].strip()).group(1):
existing_log_entries.add(f"{line}\n{existing_log_lines[i+1].strip()}")
i += 2
else: # 번역 라인 없이 원본 라인만 있는 경우 (또는 잘못된 형식)
existing_log_entries.add(line)
i += 1
else:
i += 1
except (UnicodeDecodeError, IOError) as e:
print(f"{C_WARN}경고: 기존 fix_log.txt를 읽는 중 오류 발생: {e}. 새 로그 항목만 추가합니다.")

new_log_entries = []
for p_line in problematic_lines:
# 새로운 문제가 있는 라인에 대해 번역되지 않은 상태의 짝을 만들어 중복 확인
potential_new_entry = f"{p_line}\n{LINE_PREFIX_PATTERN.match(p_line).group(0).strip()}"
if potential_new_entry not in existing_log_entries:
new_log_entries.append(p_line)

if not new_log_entries:
print(f"{C_INFO}{len(problematic_lines)}개의 문제를 찾았지만, 이미 fix_log.txt에 존재합니다."); return

print(f"\n{C_WARN}총 {len(problematic_lines)}개의 문제를 발견했습니다. {len(new_log_entries)}개의 새로운 항목을 fix_log.txt에 추가합니다...")

log_content_to_add = []
for entry in new_log_entries:
log_content_to_add.append(f"{entry}\n")
log_content_to_add.append(f"{LINE_PREFIX_PATTERN.match(entry).group(0)}\n") # 빈 번역 줄 추가

file_header_exists = False
selected_filename = os.path.basename(input_path)
if os.path.exists(fix_log_path):
try:
log_file_content = "\n".join(read_file_with_auto_encoding(fix_log_path))
if selected_filename in log_file_content:
file_header_exists = True
except (UnicodeDecodeError, IOError):
pass

with open(fix_log_path, 'a', encoding='utf-8') as f:
if not file_header_exists:
f.write(f"\n{selected_filename}\n")
f.writelines(log_content_to_add)

print(f"{C_SUCCESS}fix_log.txt를 성공적으로 업데이트했습니다.")

except Exception as e:
print(f"{C_ERROR}오류가 발생했습니다: {e}")
traceback.print_exc()

# --- Mode 2: Applying bracket swaps ---
def run_bracket_swap_mode(output_folder):
print(f"\n{C_ACTION}--- 모드 2: 괄호 안 단어 교체 (한자/영어) ---{C_RESET}")
try:
input_path = select_file_from_folder(output_folder)
if not input_path:
return

try:
lines = read_file_with_auto_encoding(input_path)
except (UnicodeDecodeError, IOError) as e:
print(f"{C_ERROR}입력 파일 '{os.path.basename(input_path)}'을(를) 읽는 중 오류 발생: {e}"); return

print(f"\n{C_ACTION}괄호 안 단어 교체를 적용합니다...")
modified_lines = []
total_swap_logs = []
lines_changed_count = 0

for line in tqdm(lines, desc="교체 적용 중", unit="줄"):
new_line, single_line_logs = swap_bracketed_words(line)
if single_line_logs:
total_swap_logs.extend(single_line_logs)
lines_changed_count += 1
modified_lines.append(new_line)

unique_total_swap_logs = sorted(set(total_swap_logs))
if unique_total_swap_logs:
print(f"{C_SUCCESS}{lines_changed_count}개 라인에서 {len(unique_total_swap_logs)}개의 고유한 교체를 적용했습니다. 변경사항을 저장합니다...")
log_summary = "; ".join(unique_total_swap_logs[:5])
if len(unique_total_swap_logs) > 5: log_summary += "..."
print(f"{C_INFO}변경 내용: {log_summary}")
with open(input_path, 'w', encoding='utf-8') as f:
f.write("\n".join(modified_lines))
print(f"{C_SUCCESS}수정된 파일 '{os.path.basename(input_path)}'을(를) 저장했습니다.")
else:
print(f"{C_INFO}교체가 필요한 라인이 없습니다.")

except Exception as e:
print(f"{C_ERROR}괄호 교체 중 오류가 발생했습니다: {e}")
traceback.print_exc()

# --- Mode 3: Translate fix_log.txt ---
def run_translate_fix_log_mode(novel_folder):
print(f"\n{C_ACTION}--- 모드 3: fix_log.txt 번역 ---{C_RESET}")
fix_log_path = os.path.join(novel_folder, "fix_log.txt")
if not os.path.exists(fix_log_path):
print(f"{C_ERROR}fix_log.txt를 찾을 수 없습니다. 먼저 (모드 1)을 실행하여 생성해주세요."); return

global CURRENT_KEY_INDEX
if not (load_api_keys(API_KEY_FILE_PATH) and load_model_name(APP_FOLDER)):
return
CURRENT_KEY_INDEX = load_last_key_index()
genai.configure(api_key=API_KEYS[CURRENT_KEY_INDEX])

try:
lines = read_file_with_auto_encoding(fix_log_path)
except (UnicodeDecodeError, IOError) as e:
print(f"{C_ERROR}fix_log.txt를 읽는 중 오류 발생: {e}"); return

lines_to_translate_info = []
current_file_name = None

i = 0
while i < len(lines):
line = lines[i].strip()
if not line:
i += 1
continue

if line.endswith('.txt') and not LINE_PREFIX_PATTERN.match(line):
current_file_name = line
i += 1
continue

if current_file_name and i + 1 < len(lines):
original_line = lines[i]
placeholder_line = lines[i+1]
match1 = LINE_PREFIX_PATTERN.match(original_line)
match2 = LINE_PREFIX_PATTERN.match(placeholder_line)

if match1 and match2 and match1.group(1) == match2.group(1):
# 번역된 내용이 없거나, 태그만 있는 경우 (빈 줄) 번역 대상으로 간주
is_placeholder_empty = len(placeholder_line.strip()) <= len(match2.group(0).strip())
if is_placeholder_empty:
lines_to_translate_info.append((i, current_file_name, original_line))
i += 2
else:
i += 1
else:
i += 1

if not lines_to_translate_info:
print(f"{C_INFO}fix_log.txt에서 번역할 빈 줄을 찾지 못했습니다."); return

print(f"번역할 항목을 {len(lines_to_translate_info)}개 찾았습니다.")

try:
chunks = [lines_to_translate_info[i:i + FIX_LOG_TRANSLATE_CHUNK_SIZE] for i in range(0, len(lines_to_translate_info), FIX_LOG_TRANSLATE_CHUNK_SIZE)]
pbar = tqdm(total=len(lines_to_translate_info), desc="fix_log 번역 중", unit="항목")

for chunk in chunks:
prompt_list = []
for _, filename, original in chunk:
prompt_list.append(filename)
prompt_list.append(original)

prompt = "\n".join(prompt_list)
response_text = call_gemini_api(prompt, SYSTEM_INSTRUCTION_FOR_FIX_LOG)
translated_lines_raw = response_text.strip().split('\n')

translation_map = {}
j = 0
while j < len(translated_lines_raw):
line = translated_lines_raw[j].strip()
if line.endswith('.txt') or not LINE_PREFIX_PATTERN.match(line):
j += 1
continue
if j + 1 < len(translated_lines_raw):
original_key = line # 원본 라인 전체를 키로 사용
translated_value = translated_lines_raw[j+1] # 번역된 라인
translation_map[original_key] = translated_value
j += 2
else:
j += 1

for index, _, original_line in chunk:
original_stripped = original_line.strip()
if original_stripped in translation_map:
translated_line = translation_map[original_stripped]
# 번역된 라인도 문제가 있는지 다시 검사
if not is_line_problematic(translated_line):
lines[index + 1] = translated_line # 기존 번역 줄을 교체
else:
tqdm.write(f"{C_WARN}API가 다음 항목에 대해 번역되지 않은 텍스트를 반환했습니다: {original_stripped} -> {translated_line}{C_RESET}")
# 문제가 있는 번역이라도 원래 번역 줄이 비어있다면 업데이트 (AI의 응답을 그대로 반영)
lines[index + 1] = translated_line
else:
tqdm.write(f"{C_WARN}다음 라인에 대한 번역 결과를 찾을 수 없습니다: {original_stripped}{C_RESET}")
pbar.update(len(chunk))
pbar.close()

print("\n번역이 완료되었습니다. 업데이트된 fix_log.txt를 저장합니다...")
with open(fix_log_path, 'w', encoding='utf-8') as f:
f.write("\n".join(lines))
print(f"{C_SUCCESS}fix_log.txt가 번역 내용으로 성공적으로 업데이트되었습니다.")

except AllKeysExhaustedException as e:
print(f"{C_ERROR}API 키 소진 오류: {e}")
except Exception as e:
print(f"{C_ERROR}번역 중 오류가 발생했습니다: {e}")
traceback.print_exc()

# --- Mode 4: Apply fix log ---
def run_apply_fix_log_mode(novel_folder, output_folder):
print(f"\n{C_ACTION}--- 모드 4: fix_log.txt 적용 ---{C_RESET}")
fix_log_path = os.path.join(novel_folder, "fix_log.txt")
if not os.path.exists(fix_log_path):
print(f"{C_ERROR}fix_log.txt를 찾을 수 없습니다."); return

try:
log_lines = read_file_with_auto_encoding(fix_log_path)
except (UnicodeDecodeError, IOError) as e:
print(f"{C_ERROR}fix_log.txt 파싱 오류: {e}"); return

fixes_by_file = defaultdict(dict)
current_file = None
i = 0
while i < len(log_lines):
line = log_lines[i].strip()
if not line:
i += 1
continue
if line.endswith('.txt') and not LINE_PREFIX_PATTERN.match(line):
current_file = line
i += 1
continue
if current_file and i + 1 < len(log_lines):
original_line = log_lines[i]
corrected_line = log_lines[i+1]
match_orig = LINE_PREFIX_PATTERN.match(original_line)
match_corr = LINE_PREFIX_PATTERN.match(corrected_line)
if match_orig and match_corr and match_orig.group(1) == match_corr.group(1):
line_num = int(match_orig.group(1))
original_content = LINE_PREFIX_PATTERN.sub('', original_line)
corrected_content = LINE_PREFIX_PATTERN.sub('', corrected_line)
fixes_by_file[current_file][line_num] = (original_content, corrected_content)
i += 2
else:
i += 1
else:
i += 1

if not fixes_by_file:
print(f"{C_WARN}fix_log.txt에서 적용할 유효한 수정사항을 찾지 못했습니다."); return

applied_count_total = 0
unapplied_logs = []

for filename, corrections_dict in fixes_by_file.items():
target_path = os.path.join(output_folder, filename)

# 파일 발견 여부 로그 추가
if not os.path.exists(target_path):
tqdm.write(f"\n{C_ERROR}대상 파일 '{filename}'을(를) 찾을 수 없어 이 파일에 대한 수정을 건너뜜니다.{C_RESET}")
# 이 파일의 모든 수정사항을 적용되지 않은 로그로 추가
unapplied_logs.append(f"{filename}\n")
for ln, (orig, corr) in corrections_dict.items():
unapplied_logs.append(f"{ln}{LINE_NUM_TAG}{orig}\n")
unapplied_logs.append(f"{ln}{LINE_NUM_TAG}{corr}\n")
continue
else:
tqdm.write(f"\n{C_INFO}대상 파일 '{filename}'을(를) 찾았습니다. {len(corrections_dict)}개의 수정사항을 적용 시도합니다... (허용 오차: {FIX_LOG_LINE_TOLERANCE}줄){C_RESET}")


try:
file_lines = read_file_with_auto_encoding(target_path)
except (UnicodeDecodeError, IOError) as e:
tqdm.write(f"{C_ERROR}대상 파일 '{filename}'을(를) 읽는 중 오류 발생: {e}. 이 파일에 대한 수정을 건너뜜니다.{C_RESET}"); continue

# 수정사항을 줄 번호 역순으로 정렬하여 적용
sorted_corrections = sorted([(ln, orig, corr) for ln, (orig, corr) in corrections_dict.items()], key=lambda x: x[0], reverse=True)

applied_count_file = 0
file_unapplied = []

# 각 파일에 대한 진행률을 표시하기 위해 tqdm을 사용
for line_num, original, corrected in tqdm(sorted_corrections, desc=f" '{filename}' 적용 중", unit="수정"):
actual_idx_found = -1 # 찾은 실제 인덱스

# 1. 줄 번호 오차 범위 내에서 검색 시도
base_index = line_num - 1
search_start_idx = max(0, base_index - FIX_LOG_LINE_TOLERANCE)
search_end_idx = min(len(file_lines), base_index + FIX_LOG_LINE_TOLERANCE + 1)

for current_idx in range(search_start_idx, search_end_idx):
if current_idx < len(file_lines) and file_lines[current_idx].strip() == original.strip():
actual_idx_found = current_idx
tqdm.write(f"{C_INFO} [성공] 범위 내에서 찾음: 파일 '{filename}' 라인 {line_num} (실제위치: {actual_idx_found+1})에 수정 적용됩니다.{C_RESET}")
break

# 2. 줄 번호 오차 범위 내에서 못 찾았을 경우, 파일 전체 스캔
if actual_idx_found == -1:
for full_scan_idx, file_line_content in enumerate(file_lines):
if file_line_content.strip() == original.strip():
actual_idx_found = full_scan_idx
tqdm.write(f"{C_INFO} [성공] 전체 스캔으로 찾음: 파일 '{filename}' 라인 {line_num} (실제위치: {actual_idx_found+1})에 수정 적용됩니다.{C_RESET}")
break

if actual_idx_found != -1: # 성공적으로 찾았으면 수정 적용
file_lines[actual_idx_found] = corrected
applied_count_file += 1
else: # 찾지 못한 경우에만 로그 표시 및 미적용 목록에 추가
file_unapplied.append(f"{line_num}{LINE_NUM_TAG}{original}\n")
file_unapplied.append(f"{line_num}{LINE_NUM_TAG}{corrected}\n")
tqdm.write(f"{C_WARN} [실패] 수정사항 찾기 실패: 파일 '{filename}' 라인 {line_num} (원본: '{original.strip()}'){C_RESET}")

if applied_count_file > 0:
with open(target_path, 'w', encoding='utf-8') as f:
f.write("\n".join(file_lines))
tqdm.write(f"{C_SUCCESS}{applied_count_file}개의 수정사항을 '{filename}'에 성공적으로 적용하고 저장했습니다.{C_RESET}")
applied_count_total += applied_count_file

if file_unapplied:
unapplied_logs.append(f"{filename}\n")
unapplied_logs.extend(file_unapplied)
tqdm.write(f"{C_WARN}{len(file_unapplied)//2}개의 수정사항이 '{filename}'에 적용되지 않았습니다.{C_RESET}")

tqdm.write(f"\n{C_ACTION}fix_log.txt를 업데이트합니다...{C_RESET}")
if not unapplied_logs:
if os.path.exists(fix_log_path):
os.remove(fix_log_path)
tqdm.write(f"{C_SUCCESS}모든 수정사항이 적용되었습니다. fix_log.txt를 삭제합니다.{C_RESET}")
else:
with open(fix_log_path, 'w', encoding='utf-8') as f:
f.writelines(unapplied_logs)
tqdm.write(f"{C_WARN}일부 수정사항이 적용되지 않았습니다. fix_log.txt가 남은 문제들로 업데이트되었습니다.{C_RESET}")

# --- Main Dispatcher ---
def main():
print("=" * 55)
print(f"{C_SUCCESS} C.py - fix_log.txt 관리 도구{C_RESET}".center(65))
print("=" * 55)

if len(sys.argv) != 2:
print(f"{C_ERROR}오류: 인수가 잘못되었습니다. 사용법: python C.py [폴더 경로]"); return

novel_folder = sys.argv[1].rstrip('/\\"')
if not os.path.isdir(novel_folder):
print(f"{C_ERROR}오류: 폴더를 찾을 수 없습니다: '{novel_folder}'"); return

output_folder = os.path.join(novel_folder, "T")
if not os.path.isdir(output_folder):
print(f"{C_ERROR}오류: '{novel_folder}' 안에 'T' 폴더를 찾을 수 없습니다."); return

global SKIP_WORDS
SKIP_WORDS = load_skip_words(novel_folder)

while True:
print(f"\n{C_ACTION}실행할 작업을 선택해주세요:{C_RESET}")
print(f" {C_SUCCESS}1. fix_log.txt 생성/업데이트{C_RESET} (파일을 스캔하여 문제 찾기)")
print(f" {C_SUCCESS}2. 괄호 안 단어 교체{C_RESET} (한자/영어)")
print(f" {C_SUCCESS}3. fix_log.txt 번역{C_RESET} (AI를 사용하여 번역)")
print(f" {C_SUCCESS}4. fix_log.txt 적용{C_RESET} (파일에 변경사항 적용, 오차 {FIX_LOG_LINE_TOLERANCE}줄 허용 + 전체 스캔 시도)")
print(f" {C_WARN}'q'를 입력하여 종료{C_RESET}")

choice = input(f"\n{C_WARN}선택: {C_RESET}").strip()

if choice == '1':
run_create_fix_log_mode(novel_folder, output_folder)
elif choice == '2':
run_bracket_swap_mode(output_folder)
elif choice == '3':
run_translate_fix_log_mode(novel_folder)
elif choice == '4':
run_apply_fix_log_mode(novel_folder, output_folder)
elif choice.lower() == 'q':
print("프로그램을 종료합니다."); break
else:
print(f"{C_ERROR}잘못된 선택입니다. 1, 2, 3, 4, 또는 q를 입력해주세요.")

if __name__ == "__main__":
main()
input("\n엔터 키를 눌러 종료하세요...")



//C.bat

@echo off
rem --- 명령 프롬프트의 코드 페이지를 UTF-8로 변경하여 한글 깨짐 방지 ---
chcp 65001 > NUL

rem --- ================== 설정 부분 (수정됨) ================== ---
rem 파이썬이 "D:\Python" 폴더에 있다고 하셨으므로, python.exe의 경로는 아래와 같습니다.
set PYTHON_EXE="D:\Python\python.exe"

rem C.py 스크립트가 "D:\Python" 폴더에 있다고 하셨으므로, 스크립트의 경로는 아래와 같습니다.
set PYTHON_SCRIPT="D:\Python\C.py"
rem --- ======================================================= ---

echo.
echo =======================================================
echo C.py - Merged File Corrector and Checker
echo =======================================================
echo.

rem --- 대상 폴더 경로 설정 ---
rem 배치 파일에 폴더를 드래그 앤 드롭했는지 확인합니다.
if [%1] NEQ [] (
rem 드래그 앤 드롭된 폴더 경로를 사용합니다.
set TARGET_FOLDER=%1
echo - Target: Dragged folder (%TARGET_FOLDER%)
) else (
rem 드래그 앤 드롭이 없으면 배치 파일이 있는 폴더 경로를 사용합니다.
rem "D:\Python\소설" 폴더를 대상으로 지정하게 됩니다.
set TARGET_FOLDER="%~dp0"
echo - Target: Folder where this batch file is located.
)
echo.

rem --- 파이썬 스크립트 실행 ---
rem 실행 예: D:\Python\python.exe D:\Python\C.py "D:\Python\소설\"
%PYTHON_EXE% %PYTHON_SCRIPT% %TARGET_FOLDER%

rem --- 오류 확인 및 대기 ---
if %ERRORLEVEL% NEQ 0 (
echo.
echo =======================================================
echo ! WARNING: An error occurred during script execution.
echo Please check the error messages above.
echo =======================================================
) else (
echo.
echo =======================================================
echo Script finished successfully.
echo =======================================================
)

rem --- 사용자가 창을 닫기 전에 결과를 확인할 수 있도록 일시 중지 ---
echo.
pause


사용법:

  1. 파이썬 폴더에 C.py를 넣고 C.bat를 작성하는데 파이썬 폴더를 입력해줍니다.(위에는 D:\Python 으로 되어 있습니다)
  2. 검수할 소설 폴더를 만들고 C.bat를 넣습니다. 그 폴더에 T폴더를 만들고 소설 txt확장자 파일을 T폴더에 넣습니다.
  3. C.bat실행하면 메뉴가 뜹니다. fix_log.txt만들기 선택하면 T폴더에 있는 텍스트 파일들이 뜹니다. 검수할 텍스트 파일을 선택하면 fix_log.txt가 만들어 집니다.
  4. https://aistudio.google.com
    2.5 프로 선택하고 fix_log.txt내용을 입력하고 번역해 달라고 합니다. 사용할 프롬프트는 다음과 같습니다.
    다음 미번역 문장을 한국어로 완전히 번역하라. 파일명과 숫자#{ttt}태그를 없애지말고 그대로 두어라.
    예)
    translated_146.txt
    101#{ttt}“一直以来,我都以为这些传说背后的传说生物都是各大家族自己王婆卖瓜,自卖自夸,现在看来,我可能错了。”
    101#{ttt}“지금껏 나는 이 전설들 뒤에 있는 전설 속 생물들이 모두 각 대가문이 벌이는 자화자찬인 줄로만 알았는데, 지금 보니 내가 틀렸던 것 같다.”

    63743#{ttt}그는 мимовольно 미소를 지었다. 이 어린 소녀, 꽤 интересная.
    63743#{ttt}그는 무심코 미소를 지었다. 이 어린 소녀, 꽤 흥미롭다.

    위와 같이 윗줄에는 원문을 그대로 표시하고 아래에는 번역문을 표시하시오.
    번역 시에 괄호를 추가하여 설명하지 말고 모두 한국어로 번역하세요.
    {ttt}는 중요한 표시자입니다. 임의로 }를 >으로 변경하거나 하면 안됩니다.

    "[번역 누락됨] "이라는 태그가 보이면 그 태그는 제거하고 처리하세요.

    만약 번역이 어려운 한자가 있으면 최대한 의미가 근접한 한국어로 번역하세요.
    원문의 음차표기를 유지하지말고 무조건 영어를 제외한 외국어는 한글로 적으세요.

5. 그러면 주루룩하고 번역이 됩니다. copy mark down으로 복사한 후 fix_log.txt내용에 덮어씁니다.

6. C.bat에서 4번 fix_log.txt적용을 선택합니다. 미번역된 것들이 제대로된 번역으로 덮어씌워지고 fix_log.txt가 자동으로 삭제됩니다. 가끔 좀 번역이 어긋나거나 해서 fix_log.txt 내용이 일부 남는 경우도 있는데 보통 몆줄 안되니까 그건 보고 수정해서 적용해 줍시다.


...

이건 만들어서 쓰면서 매번 수정하는 프로그램이라서 에러가 있거나 기능이 부족할 수도 있는데 힘들게 검수하는 분들이 보여서 일단 올려봅니다. 부족한 부분은 ai에게 수정해달라고 합시다.


20

댓글 7

default_user_icon
0/500자

전체