Files
variet-agent/tools/title_matcher.py

295 lines
10 KiB
Python

"""제목 매칭 + NAS 폴더명 생성.
Anissia(한글) ↔ Nyaa(영어/로마자) 제목을 매칭하고,
NAS 저장 폴더명을 [yy_q분기]제목 형식으로 생성합니다.
"""
import re
import logging
import unicodedata
from difflib import SequenceMatcher
from typing import Optional
import httpx
logger = logging.getLogger("variet.tools.matcher")
# ──────────────────────────────────────────────
# 영어 제목 조회 (Jikan API / MyAnimeList)
# ──────────────────────────────────────────────
async def fetch_english_title(japanese_title: str) -> dict[str, str]:
"""Jikan API로 일본어 원제의 영어/로마자 제목 조회.
Returns:
{"default": "Sousou no Frieren 2nd Season",
"english": "Frieren: Beyond Journey's End Season 2",
"synonyms": ["Frieren at the Funeral Season 2"]}
실패 시 빈 dict.
"""
try:
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.get(
"https://api.jikan.moe/v4/anime",
params={"q": japanese_title, "limit": 5},
)
resp.raise_for_status()
data = resp.json()
items = data.get("data", [])
if not items:
return {}
# 원제와 가장 잘 매칭되는 항목 선택
best = None
best_score = 0.0
for item in items:
jp = item.get("title_japanese", "")
score = SequenceMatcher(None, japanese_title, jp).ratio()
if score > best_score:
best_score = score
best = item
if not best or best_score < 0.5:
return {}
result = {
"default": best.get("title", ""),
"english": best.get("title_english") or "",
"synonyms": [],
}
for t in best.get("titles", []):
if t["type"] == "Synonym":
result["synonyms"].append(t["title"])
logger.info(
f"Jikan 영어 제목 조회: {japanese_title}"
f"default={result['default']}, english={result['english']}"
)
return result
except Exception as e:
logger.warning(f"Jikan API 조회 실패: {e}")
return {}
# ──────────────────────────────────────────────
# 일어 → 로마자 변환 테이블 (히라가나/카타카나)
# ──────────────────────────────────────────────
_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:
"""일본어 텍스트를 로마자로 변환 (pykakasi 기반, fallback: 카나 테이블)."""
try:
import pykakasi
kks = pykakasi.kakasi()
result_items = kks.convert(text)
romaji = " ".join(item["hepburn"] for item in result_items)
# 연속 공백 정리
romaji = re.sub(r'\s+', ' ', romaji).strip()
return romaji
except ImportError:
logger.warning("pykakasi 미설치 — 카나 테이블 fallback 사용")
return _japanese_to_romaji_fallback(text)
def _japanese_to_romaji_fallback(text: str) -> str:
"""일본어→로마자 fallback (카나만 변환, 한자는 그대로)."""
text = _kata_to_hira(text)
result = []
i = 0
while i < len(text):
if text[i] == '\u30FC': # ー
i += 1
continue
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)
# ASW HEVC 릴리스는 threshold 면제 (약칭으로 올라와도 포함)
title_upper = result.title.upper()
is_preferred = "[ASW]" in result.title and ("HEVC" in title_upper or "X265" in title_upper)
if best_sim >= threshold or is_preferred:
# ASW HEVC 릴리스는 유사도 보너스 (+0.5) → 정렬 시 상위 배치
effective_sim = best_sim + 0.5 if is_preferred else best_sim
scored.append((effective_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}"