Initial VRC OSC commit
This commit is contained in:
565
src/vrc_log/log_actions.py
Normal file
565
src/vrc_log/log_actions.py
Normal file
@@ -0,0 +1,565 @@
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime
|
||||
from datetime import timedelta
|
||||
from pathlib import Path
|
||||
|
||||
from common.config_loader import loadVrcLogConfig
|
||||
from common.config_loader import getSecretValue
|
||||
from common.runtime_log import appendRuntimeLog
|
||||
from discord_control.actions import setDiscordMuted
|
||||
|
||||
_monitor_thread = None
|
||||
_monitor_stop_event = threading.Event()
|
||||
_last_sent_output = None
|
||||
_self_monitor_thread = None
|
||||
_self_monitor_stop_event = threading.Event()
|
||||
_last_self_state = None
|
||||
REALTIME_IDLE_SECONDS = 0.2
|
||||
|
||||
JOIN_PATTERN = re.compile(
|
||||
r"OnPlayerJoined\s+(.+?)\s+\(usr_[0-9a-fA-F-]+\)"
|
||||
)
|
||||
LEFT_PATTERN = re.compile(
|
||||
r"OnPlayerLeft\s+(.+?)\s+\(usr_[0-9a-fA-F-]+\)"
|
||||
)
|
||||
WORLD_PATTERN = re.compile(
|
||||
r"(wrld_[0-9a-fA-F-]+(?::[^\s\]\)\"']+)?)"
|
||||
)
|
||||
ENTERING_ROOM_PATTERN = re.compile(
|
||||
r"\[Behaviour\]\s+(?:Entering Room|Joining or Creating Room):\s+(.+)$"
|
||||
)
|
||||
WORLD_NAME_PATTERN = re.compile(
|
||||
r"worldId=(wrld_[0-9a-fA-F-]+)(?::[^,}]*)?,\s*worldName=([^,}]+)"
|
||||
)
|
||||
TIME_PATTERN = re.compile(
|
||||
r"(\d{4}\.\d{2}\.\d{2}\s+\d{2}:\d{2}:\d{2})"
|
||||
)
|
||||
RELEVANT_LOG_PATTERN = re.compile(
|
||||
r"OnPlayerJoined|OnPlayerLeft|Entering Room|Joining or Creating Room|worldId=|wrld_"
|
||||
)
|
||||
|
||||
|
||||
def log(level, message):
|
||||
now = datetime.now().strftime("%H:%M:%S")
|
||||
print(f"[{level}] {now} {message}", flush=True)
|
||||
|
||||
|
||||
def getWebhookUrl():
|
||||
return getSecretValue("discord", "webhook_url")
|
||||
|
||||
|
||||
def sendWebhook(text):
|
||||
webhook_url = getWebhookUrl().strip()
|
||||
if not webhook_url:
|
||||
log("ERROR", "Discord webhook URL is not set config=config/secrets.toml")
|
||||
return False
|
||||
|
||||
message = str(text).strip()
|
||||
if not message:
|
||||
log("INFO", "Webhook送信内容が空です")
|
||||
return False
|
||||
|
||||
if len(message) > 1800:
|
||||
message = message[-1800:]
|
||||
|
||||
ps_script = r'''
|
||||
param(
|
||||
[string]$WebhookUrl,
|
||||
[string]$Message
|
||||
)
|
||||
|
||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
$Payload = @{ content = $Message } | ConvertTo-Json -Compress
|
||||
$Body = [System.Text.Encoding]::UTF8.GetBytes($Payload)
|
||||
Invoke-WebRequest -Uri $WebhookUrl -Method Post -ContentType 'application/json; charset=utf-8' -Body $Body -UseBasicParsing | Out-Null
|
||||
'''
|
||||
|
||||
ps_path = None
|
||||
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="w",
|
||||
suffix=".ps1",
|
||||
delete=False,
|
||||
encoding="utf-8-sig",
|
||||
) as tmp:
|
||||
tmp.write(ps_script)
|
||||
ps_path = tmp.name
|
||||
|
||||
result = subprocess.run(
|
||||
[
|
||||
"powershell",
|
||||
"-NoProfile",
|
||||
"-ExecutionPolicy",
|
||||
"Bypass",
|
||||
"-File",
|
||||
ps_path,
|
||||
webhook_url,
|
||||
message,
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
timeout=20,
|
||||
)
|
||||
except Exception as e:
|
||||
log("ERROR", f"Discord webhook PowerShell failed detail={e}")
|
||||
return False
|
||||
finally:
|
||||
if ps_path:
|
||||
try:
|
||||
Path(ps_path).unlink(missing_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if result.returncode != 0:
|
||||
stderr = (result.stderr or "").strip()
|
||||
stdout = (result.stdout or "").strip()
|
||||
log("ERROR", f"Discord webhook PowerShell failed code={result.returncode} stderr={stderr} stdout={stdout}")
|
||||
return False
|
||||
|
||||
log("INFO", "Discord webhook sent via PowerShell")
|
||||
return True
|
||||
|
||||
|
||||
def getVrchatLogDir():
|
||||
userprofile = os.environ.get("USERPROFILE")
|
||||
if not userprofile:
|
||||
raise RuntimeError("USERPROFILE が取得できません")
|
||||
return Path(userprofile) / "AppData" / "LocalLow" / "VRChat" / "VRChat"
|
||||
|
||||
|
||||
def findLatestVrchatLog():
|
||||
log_dir = getVrchatLogDir()
|
||||
if not log_dir.exists():
|
||||
raise RuntimeError(f"VRChatログディレクトリが存在しません: {log_dir}")
|
||||
|
||||
candidates = []
|
||||
for pattern in ("output_log_*.txt", "Player.log", "*.log", "*.txt"):
|
||||
candidates.extend(log_dir.glob(pattern))
|
||||
|
||||
candidates = [path for path in candidates if path.is_file()]
|
||||
if not candidates:
|
||||
raise RuntimeError(f"VRChatログファイルが見つかりません: {log_dir}")
|
||||
|
||||
candidates.sort(key=lambda path: path.stat().st_mtime, reverse=True)
|
||||
return candidates[0]
|
||||
|
||||
|
||||
def readTextSafe(path):
|
||||
for encoding in ("utf-8", "utf-8-sig", "cp932", "shift_jis"):
|
||||
try:
|
||||
return path.read_text(encoding=encoding, errors="replace")
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
raise RuntimeError(f"ログファイルを読めません: {path}")
|
||||
|
||||
|
||||
def parseLineTime(line):
|
||||
matched = TIME_PATTERN.search(line)
|
||||
if not matched:
|
||||
return None
|
||||
|
||||
return datetime.strptime(matched.group(1), "%Y.%m.%d %H:%M:%S")
|
||||
|
||||
|
||||
def formatClock(dt):
|
||||
return dt.strftime("%H:%M")
|
||||
|
||||
|
||||
def getLogStartTime(now):
|
||||
noon_today = now.replace(hour=12, minute=0, second=0, microsecond=0)
|
||||
if now >= noon_today:
|
||||
return noon_today
|
||||
return noon_today - timedelta(days=1)
|
||||
|
||||
|
||||
def splitWorldLocation(location):
|
||||
if ":" not in location:
|
||||
return location, ""
|
||||
|
||||
world_id, instance_id = location.split(":", 1)
|
||||
return world_id, instance_id
|
||||
|
||||
|
||||
def extractWorldNameMap(text):
|
||||
world_name_map = {}
|
||||
pending_world_name = ""
|
||||
|
||||
for raw_line in text.splitlines():
|
||||
room_match = ENTERING_ROOM_PATTERN.search(raw_line)
|
||||
if room_match:
|
||||
pending_world_name = room_match.group(1).strip()
|
||||
|
||||
match = WORLD_NAME_PATTERN.search(raw_line)
|
||||
if match:
|
||||
world_id = match.group(1).strip()
|
||||
world_name = match.group(2).strip()
|
||||
if world_id and world_name:
|
||||
world_name_map[world_id] = world_name
|
||||
continue
|
||||
|
||||
world_match = WORLD_PATTERN.search(raw_line)
|
||||
if world_match and pending_world_name:
|
||||
location = world_match.group(1).strip()
|
||||
world_id, _ = splitWorldLocation(location)
|
||||
if world_id:
|
||||
world_name_map[world_id] = pending_world_name
|
||||
pending_world_name = ""
|
||||
|
||||
return world_name_map
|
||||
|
||||
|
||||
def formatWorldLabel(world_id, world_name_map):
|
||||
world_name = world_name_map.get(world_id, "").strip()
|
||||
if not world_name:
|
||||
return "不明"
|
||||
return world_name
|
||||
|
||||
|
||||
def classifyUser(name, guest_names, staff_names):
|
||||
if name in staff_names:
|
||||
return "staff"
|
||||
if name in guest_names:
|
||||
return "guest"
|
||||
return "other"
|
||||
|
||||
|
||||
def isSelf(name, self_name):
|
||||
return bool(self_name) and name.strip() == self_name.strip()
|
||||
|
||||
|
||||
def buildNoticeLines(guest_names, guest_present_set, missing_count):
|
||||
total_guest = len(guest_names)
|
||||
current_guest = len(guest_present_set)
|
||||
threshold = total_guest - missing_count
|
||||
|
||||
if missing_count <= 0:
|
||||
return []
|
||||
if not (threshold <= current_guest < total_guest):
|
||||
return []
|
||||
|
||||
missing_guests = [name for name in guest_names if name not in guest_present_set]
|
||||
if not missing_guests:
|
||||
return []
|
||||
|
||||
lines = [f"現在Guest: {current_guest}/{total_guest}", "未入室Guest:"]
|
||||
lines.extend(f"- {name}" for name in missing_guests)
|
||||
return lines
|
||||
|
||||
|
||||
def appendNoticeIfNeeded(output_lines, state):
|
||||
notice_lines = buildNoticeLines(
|
||||
state["guest_names"],
|
||||
state["guest_present_set"],
|
||||
state["missing_count"],
|
||||
)
|
||||
notice_key = tuple(notice_lines)
|
||||
|
||||
if not notice_lines:
|
||||
state["last_notice_key"] = None
|
||||
return
|
||||
|
||||
if state["last_notice_key"] == notice_key:
|
||||
return
|
||||
|
||||
output_lines.extend(notice_lines)
|
||||
state["last_notice_key"] = notice_key
|
||||
|
||||
|
||||
def parseVrchatEvents(text, config):
|
||||
start_time = getLogStartTime(datetime.now())
|
||||
guest_names = config["guest_names"]
|
||||
staff_names = set(config["staff_names"])
|
||||
self_name = config.get("self_name", "")
|
||||
world_name_map = extractWorldNameMap(text)
|
||||
|
||||
state = {
|
||||
"location": "",
|
||||
"world_id": "",
|
||||
"instance_id": "",
|
||||
"guest_names": guest_names,
|
||||
"guest_name_set": set(guest_names),
|
||||
"staff_names": staff_names,
|
||||
"guest_present_set": set(),
|
||||
"staff_present_set": set(),
|
||||
"member_present_set": set(),
|
||||
"missing_count": config["missing_count"],
|
||||
"last_notice_key": None,
|
||||
}
|
||||
output_lines = []
|
||||
self_state = None
|
||||
|
||||
for raw_line in text.splitlines():
|
||||
line_time = parseLineTime(raw_line)
|
||||
if not line_time or line_time < start_time:
|
||||
continue
|
||||
|
||||
world_match = WORLD_PATTERN.search(raw_line)
|
||||
if world_match:
|
||||
location = world_match.group(1).strip()
|
||||
world_id, instance_id = splitWorldLocation(location)
|
||||
|
||||
if location != state["location"]:
|
||||
state["location"] = location
|
||||
state["world_id"] = world_id
|
||||
state["instance_id"] = instance_id
|
||||
state["guest_present_set"].clear()
|
||||
state["staff_present_set"].clear()
|
||||
state["member_present_set"].clear()
|
||||
state["last_notice_key"] = None
|
||||
output_lines = []
|
||||
output_lines.append(f"ワールド入室: {formatWorldLabel(world_id, world_name_map)}")
|
||||
output_lines.append(f"現在Guest: 0/{len(guest_names)}")
|
||||
|
||||
join_match = JOIN_PATTERN.search(raw_line)
|
||||
if join_match and state["world_id"]:
|
||||
name = join_match.group(1).strip()
|
||||
if isSelf(name, self_name):
|
||||
self_state = "joined"
|
||||
state["member_present_set"].add(name)
|
||||
output_lines.append(
|
||||
f'[{formatClock(line_time)}] [join] [self] {name} {len(state["member_present_set"])}/{len(guest_names)}'
|
||||
)
|
||||
continue
|
||||
|
||||
role = classifyUser(name, state["guest_name_set"], state["staff_names"])
|
||||
|
||||
if role == "guest":
|
||||
state["guest_present_set"].add(name)
|
||||
state["member_present_set"].add(name)
|
||||
output_lines.append(
|
||||
f'[{formatClock(line_time)}] [join] {name} {len(state["member_present_set"])}/{len(guest_names)}'
|
||||
)
|
||||
appendNoticeIfNeeded(output_lines, state)
|
||||
elif role == "staff":
|
||||
state["staff_present_set"].add(name)
|
||||
output_lines.append(
|
||||
f'[{formatClock(line_time)}] [join] [staff] {name} {len(state["member_present_set"])}/{len(guest_names)}'
|
||||
)
|
||||
else:
|
||||
state["member_present_set"].add(name)
|
||||
output_lines.append(
|
||||
f'[{formatClock(line_time)}] [join] {name} {len(state["member_present_set"])}/{len(guest_names)}'
|
||||
)
|
||||
continue
|
||||
|
||||
left_match = LEFT_PATTERN.search(raw_line)
|
||||
if left_match and state["world_id"]:
|
||||
name = left_match.group(1).strip()
|
||||
if isSelf(name, self_name):
|
||||
self_state = "left"
|
||||
state["member_present_set"].discard(name)
|
||||
output_lines.append(
|
||||
f'[{formatClock(line_time)}] [leave] [self] {name} {len(state["member_present_set"])}/{len(guest_names)}'
|
||||
)
|
||||
continue
|
||||
|
||||
role = classifyUser(name, state["guest_name_set"], state["staff_names"])
|
||||
|
||||
if role == "guest":
|
||||
state["guest_present_set"].discard(name)
|
||||
state["member_present_set"].discard(name)
|
||||
output_lines.append(
|
||||
f'[{formatClock(line_time)}] [leave] {name} {len(state["member_present_set"])}/{len(guest_names)}'
|
||||
)
|
||||
appendNoticeIfNeeded(output_lines, state)
|
||||
elif role == "staff":
|
||||
state["staff_present_set"].discard(name)
|
||||
output_lines.append(
|
||||
f'[{formatClock(line_time)}] [leave] [staff] {name} {len(state["member_present_set"])}/{len(guest_names)}'
|
||||
)
|
||||
else:
|
||||
state["member_present_set"].discard(name)
|
||||
output_lines.append(
|
||||
f'[{formatClock(line_time)}] [leave] {name} {len(state["member_present_set"])}/{len(guest_names)}'
|
||||
)
|
||||
|
||||
return output_lines, self_state
|
||||
|
||||
|
||||
def extractLatestSelfLogState(text, self_name):
|
||||
if not self_name:
|
||||
return None
|
||||
|
||||
latest_state = None
|
||||
|
||||
for raw_line in text.splitlines():
|
||||
join_match = JOIN_PATTERN.search(raw_line)
|
||||
if join_match and isSelf(join_match.group(1).strip(), self_name):
|
||||
latest_state = "joined"
|
||||
continue
|
||||
|
||||
left_match = LEFT_PATTERN.search(raw_line)
|
||||
if left_match and isSelf(left_match.group(1).strip(), self_name):
|
||||
latest_state = "left"
|
||||
|
||||
return latest_state
|
||||
|
||||
|
||||
def grepVrchatLog(pattern=None):
|
||||
return collectVrchatLog(pattern=pattern, notify_changed=False)
|
||||
|
||||
|
||||
def collectVrchatLog(pattern=None, notify_changed=True):
|
||||
config = loadVrcLogConfig()
|
||||
latest_log = findLatestVrchatLog()
|
||||
log("ACTION", f"parseVrchatLog file={latest_log}")
|
||||
log("INFO", f'event_file={config["event_file"]}')
|
||||
|
||||
text = readTextSafe(latest_log)
|
||||
lines, self_state = parseVrchatEvents(text, config)
|
||||
|
||||
output_text = "\n".join(lines) if lines else "[INFO] 対象イベントなし"
|
||||
output_path = appendRuntimeLog("VRC LOG", output_text)
|
||||
|
||||
if notify_changed:
|
||||
global _last_sent_output
|
||||
if output_text != _last_sent_output:
|
||||
try:
|
||||
ok = sendWebhook(output_text)
|
||||
if ok:
|
||||
log("INFO", "Discord webhook sent")
|
||||
_last_sent_output = output_text
|
||||
else:
|
||||
log("ERROR", "Discord webhook send failed")
|
||||
except Exception as e:
|
||||
log("ERROR", f"Discord webhook failed detail={e}")
|
||||
|
||||
log("INFO", f"result appended={output_path}")
|
||||
for line in lines or ["[INFO] 対象イベントなし"]:
|
||||
print(line, flush=True)
|
||||
|
||||
return output_path
|
||||
|
||||
|
||||
def _readNewText(path, offset):
|
||||
for encoding in ("utf-8", "utf-8-sig", "cp932", "shift_jis"):
|
||||
try:
|
||||
with path.open("r", encoding=encoding, errors="replace") as file:
|
||||
file.seek(offset)
|
||||
return file.read(), file.tell()
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
raise RuntimeError(f"ログファイルを読めません: {path}")
|
||||
|
||||
|
||||
def _watchVrchatLogChanges(stop_event, label, on_relevant_change):
|
||||
log("ACTION", f"{label} realtime watcher started")
|
||||
|
||||
current_log = None
|
||||
offset = 0
|
||||
|
||||
while not stop_event.is_set():
|
||||
try:
|
||||
latest_log = findLatestVrchatLog()
|
||||
latest_size = latest_log.stat().st_size
|
||||
|
||||
if current_log is None:
|
||||
current_log = latest_log
|
||||
offset = latest_size
|
||||
log("INFO", f"{label} watching file={current_log}")
|
||||
continue
|
||||
|
||||
if latest_log != current_log:
|
||||
current_log = latest_log
|
||||
offset = 0
|
||||
log("INFO", f"{label} watching new file={current_log}")
|
||||
|
||||
if latest_size < offset:
|
||||
offset = 0
|
||||
|
||||
if latest_size > offset:
|
||||
text, offset = _readNewText(latest_log, offset)
|
||||
|
||||
if RELEVANT_LOG_PATTERN.search(text):
|
||||
on_relevant_change(text)
|
||||
except Exception as e:
|
||||
log("ERROR", f"{label} realtime watcher failed detail={e}")
|
||||
|
||||
stop_event.wait(REALTIME_IDLE_SECONDS)
|
||||
|
||||
log("INFO", f"{label} realtime watcher stopped")
|
||||
|
||||
|
||||
def _monitor_loop():
|
||||
def on_relevant_change(_text):
|
||||
collectVrchatLog(notify_changed=True)
|
||||
|
||||
_watchVrchatLogChanges(
|
||||
_monitor_stop_event,
|
||||
"VRC log monitor",
|
||||
on_relevant_change,
|
||||
)
|
||||
|
||||
|
||||
def _self_monitor_loop():
|
||||
def on_relevant_change(text):
|
||||
try:
|
||||
config = loadVrcLogConfig()
|
||||
self_state = extractLatestSelfLogState(
|
||||
text,
|
||||
config.get("self_name", ""),
|
||||
)
|
||||
|
||||
global _last_self_state
|
||||
if self_state and self_state != _last_self_state:
|
||||
if self_state == "joined":
|
||||
setDiscordMuted(True)
|
||||
elif self_state == "left":
|
||||
setDiscordMuted(False)
|
||||
_last_self_state = self_state
|
||||
except Exception as e:
|
||||
log("ERROR", f"VRC self monitor failed detail={e}")
|
||||
|
||||
_watchVrchatLogChanges(
|
||||
_self_monitor_stop_event,
|
||||
"VRC self monitor",
|
||||
on_relevant_change,
|
||||
)
|
||||
|
||||
|
||||
def startVrcLogMonitor(_interval_seconds=None):
|
||||
global _monitor_thread
|
||||
|
||||
if _monitor_thread and _monitor_thread.is_alive():
|
||||
log("INFO", "VRC log monitor already running")
|
||||
return
|
||||
|
||||
_monitor_stop_event.clear()
|
||||
_monitor_thread = threading.Thread(
|
||||
target=_monitor_loop,
|
||||
daemon=True,
|
||||
)
|
||||
_monitor_thread.start()
|
||||
|
||||
|
||||
def startSelfMonitor(_interval_seconds=None):
|
||||
global _self_monitor_thread
|
||||
|
||||
if _self_monitor_thread and _self_monitor_thread.is_alive():
|
||||
log("INFO", "VRC self monitor already running")
|
||||
return
|
||||
|
||||
_self_monitor_stop_event.clear()
|
||||
_self_monitor_thread = threading.Thread(
|
||||
target=_self_monitor_loop,
|
||||
daemon=True,
|
||||
)
|
||||
_self_monitor_thread.start()
|
||||
|
||||
|
||||
def stopVrcLogMonitor():
|
||||
_monitor_stop_event.set()
|
||||
|
||||
|
||||
def stopSelfMonitor():
|
||||
_self_monitor_stop_event.set()
|
||||
623
src/vrc_log/member_name_matcher.py
Normal file
623
src/vrc_log/member_name_matcher.py
Normal file
@@ -0,0 +1,623 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
"""
|
||||
目的:
|
||||
VRChat表示名とTOML登録名を安全な順番で紐づける
|
||||
|
||||
特徴:
|
||||
- 完全一致を先に確定して候補から除外
|
||||
- 正規化一致を確定して候補から除外
|
||||
- 残った候補だけを high confidence fuzzy 判定
|
||||
- guest のみ low confidence fuzzy で再選別
|
||||
- pending / ambiguous / unmatched 判定
|
||||
- 記号除去
|
||||
- 全角半角統一
|
||||
- 小文字化
|
||||
- 日本語 -> ローマ字変換
|
||||
- rapidfuzz による類似度
|
||||
- テストケース付き
|
||||
|
||||
前提:
|
||||
pip install rapidfuzz pykakasi
|
||||
|
||||
期待結果:
|
||||
- 100%一致した名前を fuzzy 候補に残さない
|
||||
- 似た名前同士の誤リンクを減らす
|
||||
"""
|
||||
|
||||
import re
|
||||
import sys
|
||||
import unicodedata
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Dict, Iterable, List, Optional, Tuple
|
||||
|
||||
try:
|
||||
from rapidfuzz import fuzz
|
||||
except Exception as e:
|
||||
if __name__ == "__main__":
|
||||
print(f"[FATAL] rapidfuzz import failed: {e}")
|
||||
input("Press Enter to exit...")
|
||||
sys.exit(1)
|
||||
raise
|
||||
|
||||
try:
|
||||
from pykakasi import kakasi
|
||||
except Exception as e:
|
||||
if __name__ == "__main__":
|
||||
print(f"[FATAL] pykakasi import failed: {e}")
|
||||
input("Press Enter to exit...")
|
||||
sys.exit(1)
|
||||
raise
|
||||
|
||||
|
||||
# =========================
|
||||
# 設定
|
||||
# =========================
|
||||
|
||||
AUTO_LINK_SCORE = 95
|
||||
LOW_CONFIDENCE_SCORE = 80
|
||||
AMBIGUOUS_DIFF = 10
|
||||
LOW_CONFIDENCE_MAX_REMAINING = 3
|
||||
WEAK_MATCH_SCORE = 50
|
||||
|
||||
STATUS_MATCHED = "matched"
|
||||
STATUS_MATCHED_LOW_CONFIDENCE = "matched_low_confidence"
|
||||
STATUS_PENDING = "pending"
|
||||
STATUS_AMBIGUOUS = "ambiguous"
|
||||
STATUS_UNMATCHED = "unmatched"
|
||||
STATUS_UNKNOWN_MEMBER = "unknown_member"
|
||||
|
||||
ROLE_GUEST = "guest"
|
||||
ROLE_SELF = "self"
|
||||
ROLE_STAFF = "staff"
|
||||
|
||||
TEST_GUESTS = [
|
||||
"okada",
|
||||
"tanaka",
|
||||
"yamada",
|
||||
"messykenty",
|
||||
]
|
||||
|
||||
TEST_CURRENT_USERS = [
|
||||
"岡田",
|
||||
"ikada",
|
||||
"okada_",
|
||||
"OKADA",
|
||||
"tanaka!!!",
|
||||
"田中",
|
||||
"やまだ",
|
||||
"messy_kenty",
|
||||
"messy.kenty",
|
||||
"MESSYKENTY",
|
||||
"山田",
|
||||
]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TomlMember:
|
||||
name: str
|
||||
role: str = ROLE_GUEST
|
||||
|
||||
|
||||
@dataclass
|
||||
class MatchResult:
|
||||
toml_name: str
|
||||
role: str
|
||||
status: str
|
||||
vrchat_name: Optional[str] = None
|
||||
score: Optional[float] = None
|
||||
second_score: Optional[float] = None
|
||||
reason: str = ""
|
||||
candidates: List[Tuple[str, float]] = field(default_factory=list)
|
||||
|
||||
|
||||
# =========================
|
||||
# ローマ字変換
|
||||
# =========================
|
||||
|
||||
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 _to_toml_member(member) -> TomlMember:
|
||||
if isinstance(member, TomlMember):
|
||||
return member
|
||||
|
||||
if isinstance(member, str):
|
||||
return TomlMember(name=member)
|
||||
|
||||
if isinstance(member, dict):
|
||||
return TomlMember(
|
||||
name=str(member.get("name", "")),
|
||||
role=str(member.get("role", ROLE_GUEST)),
|
||||
)
|
||||
|
||||
name = getattr(member, "name", "")
|
||||
role = getattr(member, "role", ROLE_GUEST)
|
||||
|
||||
return TomlMember(
|
||||
name=str(name),
|
||||
role=str(role),
|
||||
)
|
||||
|
||||
|
||||
def _make_candidates(
|
||||
toml_name: str,
|
||||
current_users: Iterable[str],
|
||||
) -> List[Tuple[str, float]]:
|
||||
normalized_toml = normalize_name(toml_name)
|
||||
results = []
|
||||
|
||||
for user in current_users:
|
||||
score = fuzz.ratio(
|
||||
normalized_toml,
|
||||
normalize_name(user),
|
||||
)
|
||||
results.append((user, score))
|
||||
|
||||
results.sort(key=lambda x: x[1], reverse=True)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def _classify_remaining(
|
||||
member: TomlMember,
|
||||
current_users: Iterable[str],
|
||||
) -> MatchResult:
|
||||
candidates = _make_candidates(
|
||||
member.name,
|
||||
current_users,
|
||||
)
|
||||
|
||||
if not candidates:
|
||||
return MatchResult(
|
||||
toml_name=member.name,
|
||||
role=member.role,
|
||||
status=STATUS_UNMATCHED,
|
||||
reason="no_candidate",
|
||||
)
|
||||
|
||||
top_name, top_score = candidates[0]
|
||||
second_score = candidates[1][1] if len(candidates) >= 2 else 0
|
||||
diff = top_score - second_score
|
||||
|
||||
if top_score < WEAK_MATCH_SCORE:
|
||||
status = STATUS_UNMATCHED
|
||||
reason = "weak_candidate"
|
||||
elif diff < AMBIGUOUS_DIFF:
|
||||
status = STATUS_AMBIGUOUS
|
||||
reason = "close_candidates"
|
||||
else:
|
||||
status = STATUS_PENDING
|
||||
reason = "below_auto_link_threshold"
|
||||
|
||||
return MatchResult(
|
||||
toml_name=member.name,
|
||||
role=member.role,
|
||||
status=status,
|
||||
vrchat_name=top_name,
|
||||
score=top_score,
|
||||
second_score=second_score,
|
||||
reason=reason,
|
||||
candidates=candidates,
|
||||
)
|
||||
|
||||
|
||||
def _best_fuzzy_match(
|
||||
member: TomlMember,
|
||||
current_users: Iterable[str],
|
||||
min_score: float,
|
||||
) -> Optional[MatchResult]:
|
||||
candidates = _make_candidates(
|
||||
member.name,
|
||||
current_users,
|
||||
)
|
||||
|
||||
if not candidates:
|
||||
return None
|
||||
|
||||
top_name, top_score = candidates[0]
|
||||
second_score = candidates[1][1] if len(candidates) >= 2 else 0
|
||||
|
||||
if (
|
||||
top_score >= min_score
|
||||
and top_score - second_score >= AMBIGUOUS_DIFF
|
||||
):
|
||||
return MatchResult(
|
||||
toml_name=member.name,
|
||||
role=member.role,
|
||||
status=STATUS_MATCHED,
|
||||
vrchat_name=top_name,
|
||||
score=top_score,
|
||||
second_score=second_score,
|
||||
reason="fuzzy",
|
||||
candidates=candidates,
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _apply_fuzzy_round(
|
||||
remaining_members: Dict[str, TomlMember],
|
||||
remaining_users: set,
|
||||
min_score: float,
|
||||
status: str,
|
||||
reason: str,
|
||||
eligible_roles: Optional[set] = None,
|
||||
) -> List[MatchResult]:
|
||||
results = []
|
||||
|
||||
while True:
|
||||
possible_matches = []
|
||||
|
||||
for key, member in remaining_members.items():
|
||||
if (
|
||||
eligible_roles is not None
|
||||
and member.role not in eligible_roles
|
||||
):
|
||||
continue
|
||||
|
||||
match = _best_fuzzy_match(
|
||||
member,
|
||||
remaining_users,
|
||||
min_score,
|
||||
)
|
||||
|
||||
if match is None:
|
||||
continue
|
||||
|
||||
possible_matches.append((key, match))
|
||||
|
||||
if not possible_matches:
|
||||
break
|
||||
|
||||
possible_matches.sort(
|
||||
key=lambda x: (
|
||||
x[1].score if x[1].score is not None else 0,
|
||||
(x[1].score or 0) - (x[1].second_score or 0),
|
||||
),
|
||||
reverse=True,
|
||||
)
|
||||
|
||||
key, match = possible_matches[0]
|
||||
match.status = status
|
||||
match.reason = reason
|
||||
|
||||
results.append(match)
|
||||
remaining_members.pop(key)
|
||||
remaining_users.remove(match.vrchat_name)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def match_username_sets(
|
||||
toml_members,
|
||||
current_users: List[str],
|
||||
) -> Tuple[List[MatchResult], List[str]]:
|
||||
"""
|
||||
TOML登録名とVRChat現在ユーザーを段階的に1対1照合する。
|
||||
|
||||
戻り値:
|
||||
- TOML側の照合結果
|
||||
- TOMLに紐づかなかったVRChat名
|
||||
"""
|
||||
|
||||
members = [_to_toml_member(member) for member in toml_members]
|
||||
remaining_members = {
|
||||
f"{idx}:{member.name}": member
|
||||
for idx, member in enumerate(members)
|
||||
}
|
||||
remaining_users = set(current_users)
|
||||
results = []
|
||||
|
||||
# 1. 完全一致: fuzzy判定せず即確定して候補から除外
|
||||
for key, member in list(remaining_members.items()):
|
||||
if member.name in remaining_users:
|
||||
results.append(
|
||||
MatchResult(
|
||||
toml_name=member.name,
|
||||
role=member.role,
|
||||
status=STATUS_MATCHED,
|
||||
vrchat_name=member.name,
|
||||
score=100,
|
||||
second_score=None,
|
||||
reason="exact",
|
||||
)
|
||||
)
|
||||
remaining_members.pop(key)
|
||||
remaining_users.remove(member.name)
|
||||
|
||||
# 2. 正規化一致: 一意に対応できるものだけを確定して除外
|
||||
normalized_members: Dict[str, List[str]] = {}
|
||||
normalized_users: Dict[str, List[str]] = {}
|
||||
|
||||
for key, member in remaining_members.items():
|
||||
normalized_members.setdefault(
|
||||
normalize_name(member.name),
|
||||
[],
|
||||
).append(key)
|
||||
|
||||
for user in remaining_users:
|
||||
normalized_users.setdefault(
|
||||
normalize_name(user),
|
||||
[],
|
||||
).append(user)
|
||||
|
||||
for normalized_name, member_keys in normalized_members.items():
|
||||
users = normalized_users.get(normalized_name, [])
|
||||
|
||||
if len(member_keys) != 1 or len(users) != 1:
|
||||
continue
|
||||
|
||||
key = member_keys[0]
|
||||
user = users[0]
|
||||
member = remaining_members[key]
|
||||
|
||||
results.append(
|
||||
MatchResult(
|
||||
toml_name=member.name,
|
||||
role=member.role,
|
||||
status=STATUS_MATCHED,
|
||||
vrchat_name=user,
|
||||
score=100,
|
||||
second_score=None,
|
||||
reason="normalized",
|
||||
)
|
||||
)
|
||||
remaining_members.pop(key)
|
||||
remaining_users.remove(user)
|
||||
|
||||
# 3. 高信頼fuzzy: 残った候補だけで、確定ごとに候補集合を縮める
|
||||
results.extend(
|
||||
_apply_fuzzy_round(
|
||||
remaining_members,
|
||||
remaining_users,
|
||||
AUTO_LINK_SCORE,
|
||||
STATUS_MATCHED,
|
||||
"high_confidence_fuzzy",
|
||||
)
|
||||
)
|
||||
|
||||
# 4. 低信頼guest再選別: guestのみ、候補集合が少ない場合だけ
|
||||
if len(remaining_members) <= LOW_CONFIDENCE_MAX_REMAINING:
|
||||
results.extend(
|
||||
_apply_fuzzy_round(
|
||||
remaining_members,
|
||||
remaining_users,
|
||||
LOW_CONFIDENCE_SCORE,
|
||||
STATUS_MATCHED_LOW_CONFIDENCE,
|
||||
"guest_low_confidence_fuzzy",
|
||||
eligible_roles={ROLE_GUEST},
|
||||
)
|
||||
)
|
||||
|
||||
# 5. 残りは無理に確定しない
|
||||
for member in remaining_members.values():
|
||||
results.append(
|
||||
_classify_remaining(
|
||||
member,
|
||||
remaining_users,
|
||||
)
|
||||
)
|
||||
|
||||
unknown_users = sorted(remaining_users)
|
||||
|
||||
return results, unknown_users
|
||||
|
||||
|
||||
# =========================
|
||||
# 判定
|
||||
# =========================
|
||||
|
||||
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] username sync ordered matching test")
|
||||
|
||||
results, unknown_users = match_username_sets(
|
||||
TEST_GUESTS,
|
||||
TEST_CURRENT_USERS,
|
||||
)
|
||||
|
||||
for result in results:
|
||||
if result.vrchat_name is None:
|
||||
print(
|
||||
f"{result.toml_name:<20}"
|
||||
f" => "
|
||||
f"{result.status:<22}"
|
||||
f" reason={result.reason}"
|
||||
)
|
||||
continue
|
||||
|
||||
score_text = ""
|
||||
if result.score is not None:
|
||||
score_text = f" score={result.score:.2f}"
|
||||
|
||||
second_text = ""
|
||||
if result.second_score is not None:
|
||||
second_text = f" second={result.second_score:.2f}"
|
||||
|
||||
print(
|
||||
f"{result.toml_name:<20}"
|
||||
f" => "
|
||||
f"{result.vrchat_name:<20}"
|
||||
f" {result.status:<22}"
|
||||
f" reason={result.reason}"
|
||||
f"{score_text}"
|
||||
f"{second_text}"
|
||||
)
|
||||
|
||||
for user in unknown_users:
|
||||
print(
|
||||
f"{user:<20}"
|
||||
f" => "
|
||||
f"{STATUS_UNKNOWN_MEMBER}"
|
||||
)
|
||||
|
||||
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()
|
||||
Reference in New Issue
Block a user