"""제목 매칭 + NAS 폴더명 생성. Anissia(한글) ↔ Nyaa(영어/로마자) 제목을 매칭하고, NAS 저장 폴더명을 [yy_q분기]제목 형식으로 생성합니다. """ import re import logging import unicodedata from difflib import SequenceMatcher from typing import Optional logger = logging.getLogger("variet.tools.matcher") # ────────────────────────────────────────────── # 일어 → 로마자 변환 테이블 (히라가나/카타카나) # ────────────────────────────────────────────── _KANA_ROMAJI = { # 히라가나 'あ': 'a', 'い': 'i', 'う': 'u', 'え': 'e', 'お': 'o', 'か': 'ka', 'き': 'ki', 'く': 'ku', 'け': 'ke', 'こ': 'ko', 'さ': 'sa', 'し': 'shi', 'す': 'su', 'せ': 'se', 'そ': 'so', 'た': 'ta', 'ち': 'chi', 'つ': 'tsu', 'て': 'te', 'と': 'to', 'な': 'na', 'に': 'ni', 'ぬ': 'nu', 'ね': 'ne', 'の': 'no', 'は': 'ha', 'ひ': 'hi', 'ふ': 'fu', 'へ': 'he', 'ほ': 'ho', 'ま': 'ma', 'み': 'mi', 'む': 'mu', 'め': 'me', 'も': 'mo', 'や': 'ya', 'ゆ': 'yu', 'よ': 'yo', 'ら': 'ra', 'り': 'ri', 'る': 'ru', 'れ': 're', 'ろ': 'ro', 'わ': 'wa', 'を': 'wo', 'ん': 'n', 'が': 'ga', 'ぎ': 'gi', 'ぐ': 'gu', 'げ': 'ge', 'ご': 'go', 'ざ': 'za', 'じ': 'ji', 'ず': 'zu', 'ぜ': 'ze', 'ぞ': 'zo', 'だ': 'da', 'ぢ': 'di', 'づ': 'du', 'で': 'de', 'ど': 'do', 'ば': 'ba', 'び': 'bi', 'ぶ': 'bu', 'べ': 'be', 'ぼ': 'bo', 'ぱ': 'pa', 'ぴ': 'pi', 'ぷ': 'pu', 'ぺ': 'pe', 'ぽ': 'po', 'きゃ': 'kya', 'きゅ': 'kyu', 'きょ': 'kyo', 'しゃ': 'sha', 'しゅ': 'shu', 'しょ': 'sho', 'ちゃ': 'cha', 'ちゅ': 'chu', 'ちょ': 'cho', 'にゃ': 'nya', 'にゅ': 'nyu', 'にょ': 'nyo', 'ひゃ': 'hya', 'ひゅ': 'hyu', 'ひょ': 'hyo', 'みゃ': 'mya', 'みゅ': 'myu', 'みょ': 'myo', 'りゃ': 'rya', 'りゅ': 'ryu', 'りょ': 'ryo', 'ぎゃ': 'gya', 'ぎゅ': 'gyu', 'ぎょ': 'gyo', 'じゃ': 'ja', 'じゅ': 'ju', 'じょ': 'jo', 'びゃ': 'bya', 'びゅ': 'byu', 'びょ': 'byo', 'ぴゃ': 'pya', 'ぴゅ': 'pyu', 'ぴょ': 'pyo', 'っ': '', # 촉음 (다음 자음 반복) } # 카타카나 → 히라가나 변환 오프셋 _KATA_OFFSET = ord('ア') - ord('あ') def _kata_to_hira(text: str) -> str: """카타카나를 히라가나로 변환 (ー 장음기호는 유지).""" result = [] for ch in text: cp = ord(ch) # 카타카나 범위이되, ー(U+30FC), ・(U+30FB) 등 기호는 제외 if 0x30A1 <= cp <= 0x30F6: # ア~ヶ (실제 카타카나 문자만) result.append(chr(cp - _KATA_OFFSET)) else: result.append(ch) return "".join(result) def japanese_to_romaji(text: str) -> str: """일본어 텍스트를 로마자로 근사 변환.""" text = _kata_to_hira(text) result = [] i = 0 while i < len(text): # 장음 기호 (ー U+30FC, ー가 히라가나로 안 변환되므로 여기서 처리) if text[i] == '\u30FC': # ー # 장음: 이전 모음 반복 (간략화: 스킵) i += 1 continue # 2글자 매칭 우선 (きゃ 등) if i + 1 < len(text) and text[i:i+2] in _KANA_ROMAJI: result.append(_KANA_ROMAJI[text[i:i+2]]) i += 2 elif text[i] in _KANA_ROMAJI: romaji = _KANA_ROMAJI[text[i]] # 촉음(っ) 처리: 다음 자음 반복 if text[i] == 'っ' and i + 1 < len(text): next_romaji = _KANA_ROMAJI.get(text[i+1], "") if next_romaji: result.append(next_romaji[0]) else: result.append(romaji) i += 1 else: # 한자, 영어, 숫자 등 → 그대로 result.append(text[i]) i += 1 return "".join(result) def normalize_title(title: str) -> str: """제목 비교용 정규화: 소문자 + 특수문자 제거 + 공백 정리.""" title = title.lower().strip() # 기수 표기 정규화: 2nd → 2, S2 → 2 title = re.sub(r'\b(\d+)(?:st|nd|rd|th)\b', r'\1', title) title = re.sub(r'\bs(\d+)\b', r'\1', title) title = re.sub(r'season\s*(\d+)', r'\1', title) title = re.sub(r'(\d+)\s*기', r'\1', title) # 특수문자 제거 title = re.sub(r'[^\w\s]', ' ', title) title = re.sub(r'\s+', ' ', title).strip() return title def title_similarity(title_a: str, title_b: str) -> float: """두 제목 간 유사도 (0.0 ~ 1.0).""" a = normalize_title(title_a) b = normalize_title(title_b) return SequenceMatcher(None, a, b).ratio() def match_titles( korean_title: str, original_title: str, nyaa_results: list, threshold: float = 0.4, ) -> list: """Anissia 제목과 Nyaa 검색 결과 매칭. Args: korean_title: 한글 제목 (Anissia subject) original_title: 원어 제목 (Anissia originalSubject) nyaa_results: TorrentResult 리스트 threshold: 최소 유사도 Returns: 매칭된 TorrentResult 리스트 (유사도 내림차순) """ # 원제의 로마자 변환 romaji = japanese_to_romaji(original_title) scored = [] for result in nyaa_results: # Nyaa 제목에서 그룹태그 제거: [ASW] Title - 07 [...] → Title clean_title = re.sub(r'\[[^\]]*\]', '', result.title).strip() clean_title = re.sub(r'\s*-\s*\d+.*$', '', clean_title).strip() # 유사도 계산 (로마자 vs Nyaa 제목) sim_romaji = title_similarity(romaji, clean_title) # 한글 vs Nyaa (일부 자막 포함 릴리스인 경우) sim_korean = title_similarity(korean_title, clean_title) # 원제 그대로 vs Nyaa sim_original = title_similarity(original_title, clean_title) best_sim = max(sim_romaji, sim_korean, sim_original) if best_sim >= threshold: scored.append((best_sim, result)) # 유사도 내림차순 정렬 scored.sort(key=lambda x: x[0], reverse=True) return [r for _, r in scored] # ────────────────────────────────────────────── # NAS 폴더명 생성 # ────────────────────────────────────────────── def get_quarter(date_str: str) -> tuple[int, int]: """날짜 문자열에서 연도와 분기 추출. Args: date_str: "2026-01-11" 형식 Returns: (year, quarter): (26, 1) """ if not date_str: from datetime import date today = date.today() year = today.year % 100 quarter = (today.month - 1) // 3 + 1 return year, quarter parts = date_str.split("-") year = int(parts[0]) % 100 month = int(parts[1]) quarter = (month - 1) // 3 + 1 return year, quarter def make_nas_folder_name(title: str, start_date: str = "") -> str: """NAS 저장 폴더명 생성. 예: [26_1분기]장송의프리렌2기 """ year, quarter = get_quarter(start_date) # 제목에서 폴더명에 쓸 수 없는 문자 제거 safe_title = re.sub(r'[<>:"/\\|?*]', '', title) safe_title = safe_title.strip() return f"[{year:02d}_{quarter}분기]{safe_title}" def rename_subtitle_to_video( video_filename: str, subtitle_ext: str = ".ass", ) -> str: """영상 파일명에 맞게 자막 파일명 생성. 예: [ASW] Sousou no Frieren S2 - 07.mkv → [ASW] Sousou no Frieren S2 - 07.ass """ stem = re.sub(r'\.[^.]+$', '', video_filename) return f"{stem}{subtitle_ext}"