266 lines
5.1 KiB
Python
266 lines
5.1 KiB
Python
#!/usr/bin/env python3
|
||
# -*- coding: utf-8 -*-
|
||
|
||
"""
|
||
目的:
|
||
VRChat表示名とTOML guest名を fuzzy matching する
|
||
|
||
特徴:
|
||
- 記号除去
|
||
- 全角半角統一
|
||
- 小文字化
|
||
- 日本語 -> ローマ字変換
|
||
- rapidfuzz による類似度
|
||
- ambiguous 判定
|
||
- テストケース付き
|
||
|
||
前提:
|
||
pip install rapidfuzz pykakasi
|
||
|
||
期待結果:
|
||
- 類似候補を score 順に表示
|
||
- 危険な曖昧ケースを検知
|
||
"""
|
||
|
||
import re
|
||
import sys
|
||
import unicodedata
|
||
from typing import List, Tuple
|
||
|
||
try:
|
||
from rapidfuzz import fuzz
|
||
from rapidfuzz import process
|
||
except Exception as e:
|
||
print(f"[FATAL] rapidfuzz import failed: {e}")
|
||
input("Press Enter to exit...")
|
||
sys.exit(1)
|
||
|
||
try:
|
||
from pykakasi import kakasi
|
||
except Exception as e:
|
||
print(f"[FATAL] pykakasi import failed: {e}")
|
||
input("Press Enter to exit...")
|
||
sys.exit(1)
|
||
|
||
|
||
# =========================
|
||
# 設定
|
||
# =========================
|
||
|
||
AUTO_LINK_SCORE = 95
|
||
AMBIGUOUS_DIFF = 10
|
||
|
||
TEST_GUESTS = [
|
||
"okada",
|
||
"tanaka",
|
||
"yamada",
|
||
"messykenty",
|
||
]
|
||
|
||
TEST_CURRENT_USERS = [
|
||
"岡田",
|
||
"ikada",
|
||
"okada_",
|
||
"OKADA",
|
||
"tanaka!!!",
|
||
"田中",
|
||
"やまだ",
|
||
"messy_kenty",
|
||
"messy.kenty",
|
||
"MESSYKENTY",
|
||
"ma",
|
||
"めっしーけんてぃ",
|
||
"山田",
|
||
]
|
||
|
||
|
||
# =========================
|
||
# ローマ字変換
|
||
# =========================
|
||
|
||
kks = kakasi()
|
||
|
||
|
||
def jp_to_romaji(text: str) -> str:
|
||
try:
|
||
converted = kks.convert(text)
|
||
|
||
result = []
|
||
for item in converted:
|
||
result.append(item["hepburn"])
|
||
|
||
return "".join(result)
|
||
|
||
except Exception as e:
|
||
print(f"[ERROR] romaji convert failed: {text} : {e}")
|
||
return text
|
||
|
||
|
||
# =========================
|
||
# 正規化
|
||
# =========================
|
||
|
||
def normalize_name(name: str) -> str:
|
||
"""
|
||
正規化内容:
|
||
- 全角半角統一
|
||
- 小文字化
|
||
- 日本語→ローマ字
|
||
- 記号除去
|
||
"""
|
||
|
||
# Unicode normalize
|
||
name = unicodedata.normalize("NFKC", name)
|
||
|
||
# 小文字化
|
||
name = name.lower()
|
||
|
||
# 日本語→ローマ字
|
||
name = jp_to_romaji(name)
|
||
|
||
# 記号除去
|
||
name = re.sub(r"[^a-z0-9]", "", name)
|
||
|
||
return name.strip()
|
||
|
||
|
||
# =========================
|
||
# 類似候補検索
|
||
# =========================
|
||
|
||
def find_candidates(
|
||
target_guest: str,
|
||
current_users: List[str],
|
||
) -> List[Tuple[str, float]]:
|
||
|
||
normalized_target = normalize_name(target_guest)
|
||
|
||
normalized_map = {}
|
||
|
||
for user in current_users:
|
||
normalized_map[user] = normalize_name(user)
|
||
|
||
results = []
|
||
|
||
for original_name, normalized_name in normalized_map.items():
|
||
|
||
score = fuzz.ratio(
|
||
normalized_target,
|
||
normalized_name,
|
||
)
|
||
|
||
results.append((original_name, score))
|
||
|
||
results.sort(key=lambda x: x[1], reverse=True)
|
||
|
||
return results
|
||
|
||
|
||
# =========================
|
||
# 判定
|
||
# =========================
|
||
|
||
def evaluate_guest_match(
|
||
guest_name: str,
|
||
current_users: List[str],
|
||
):
|
||
|
||
print("")
|
||
print("=" * 60)
|
||
print(f"[CHECK] guest={guest_name}")
|
||
|
||
normalized_guest = normalize_name(guest_name)
|
||
|
||
print(f"[INFO] normalized_guest={normalized_guest}")
|
||
|
||
candidates = find_candidates(
|
||
guest_name,
|
||
current_users,
|
||
)
|
||
|
||
if not candidates:
|
||
print("[ERROR] no candidates")
|
||
return
|
||
|
||
print("")
|
||
print("[CHECK] candidate list")
|
||
|
||
for idx, (name, score) in enumerate(candidates[:5], start=1):
|
||
print(f" {idx}. {name:<20} score={score:.2f}")
|
||
|
||
top_name, top_score = candidates[0]
|
||
|
||
second_score = 0
|
||
|
||
if len(candidates) >= 2:
|
||
second_score = candidates[1][1]
|
||
|
||
diff = top_score - second_score
|
||
|
||
print("")
|
||
print(f"[INFO] top_score={top_score:.2f}")
|
||
print(f"[INFO] second_score={second_score:.2f}")
|
||
print(f"[INFO] diff={diff:.2f}")
|
||
|
||
# 自動確定
|
||
if (
|
||
top_score >= AUTO_LINK_SCORE
|
||
and diff >= AMBIGUOUS_DIFF
|
||
):
|
||
print("")
|
||
print("[INFO] AUTO LINK SAFE")
|
||
print(f"[INFO] linked => {top_name}")
|
||
return
|
||
|
||
# 曖昧
|
||
print("")
|
||
print("[WARN] ambiguous match")
|
||
print("[WARN] auto link disabled")
|
||
|
||
print("")
|
||
print("[ACTION] manual review required")
|
||
|
||
|
||
# =========================
|
||
# main
|
||
# =========================
|
||
|
||
def main():
|
||
|
||
print("[CHECK] normalize preview")
|
||
|
||
for user in TEST_CURRENT_USERS:
|
||
print(
|
||
f"{user:<20}"
|
||
f" => "
|
||
f"{normalize_name(user)}"
|
||
)
|
||
|
||
print("")
|
||
print("[CHECK] guest matching test")
|
||
|
||
for guest in TEST_GUESTS:
|
||
evaluate_guest_match(
|
||
guest,
|
||
TEST_CURRENT_USERS,
|
||
)
|
||
|
||
print("")
|
||
print("[INFO] completed")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
|
||
print("[CHECK] start")
|
||
|
||
answer = input(
|
||
"[ACTION] run fuzzy test? (yes/no): "
|
||
).strip().lower()
|
||
|
||
if answer != "yes":
|
||
print("[FATAL] cancelled")
|
||
input("Press Enter to exit...")
|
||
sys.exit(1)
|
||
|
||
main()
|