217 lines
7.7 KiB
Python
217 lines
7.7 KiB
Python
"""제목 매칭 + 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}"
|