feat(tools): 애니메이션 자동화 파이프라인 구현
- tools/anissia_client.py: Anissia API 클라이언트 (편성표/자막) - tools/nyaa_client.py: Nyaa.si RSS 토렌트 검색 - tools/qbit_client.py: qBittorrent Web API 클라이언트 - tools/subtitle_downloader.py: Google Drive/Tistory/Naver 자막 파서 - tools/title_matcher.py: 제목 매칭 + NAS 폴더명 생성 - tools/anime_pipeline.py: 전체 파이프라인 오케스트레이터 - tools/nas_scanner.py: NAS 폴더/파일 스캔 - prompts/unified.md: anime 모드 추가 (AI 평문 의도 분류) - api/discord_bot.py: AI 평문 anime 핸들러 + /anime 슬래시 커맨드 - config.py: qBittorrent/NAS 설정 추가 - .agents/: agent_guide 워크플로우 통합 - docs/devlog: 세션 기록
This commit is contained in:
212
tools/title_matcher.py
Normal file
212
tools/title_matcher.py
Normal file
@@ -0,0 +1,212 @@
|
||||
"""제목 매칭 + 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)
|
||||
if 0x30A0 <= cp <= 0x30FF: # 카타카나 범위
|
||||
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):
|
||||
# 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
|
||||
elif text[i] == 'ー': # 장음
|
||||
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}"
|
||||
Reference in New Issue
Block a user