Files
variet-agent/tools/title_matcher.py

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}"