liaohch3--claude-tap
f3d80b4628
Auto Release / auto-release (push) Failing after 1s
CI / lint (push) Failing after 0s
CI / screenshot-quality (push) Failing after 3s
CI / pr-policy (push) Has been skipped
CI / test (3.11) (push) Failing after 0s
CI / test (3.12) (push) Failing after 0s
CI / test (3.13) (push) Failing after 3s
CI / coverage (push) Failing after 1s
Legibility / legibility (push) Failing after 3s
450 行
16 KiB
Python
可执行文件
450 行
16 KiB
Python
可执行文件
#!/usr/bin/env python3
|
||
"""Fill missing i18n translations using OpenRouter."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import json
|
||
import os
|
||
import re
|
||
import sys
|
||
from dataclasses import dataclass
|
||
from pathlib import Path
|
||
from urllib.error import HTTPError, URLError
|
||
from urllib.request import Request, urlopen
|
||
|
||
OPENROUTER_ENDPOINT = "https://openrouter.ai/api/v1/chat/completions"
|
||
DEFAULT_MODEL = "google/gemini-2.5-flash"
|
||
LANG_ORDER = ["ja", "ko", "fr", "ar", "de", "ru"]
|
||
|
||
TARGET_CONFIG = {
|
||
"viewer": {"file": "claude_tap/viewer_i18n.json", "object_name": "I18N"},
|
||
# Future scope: if CLI gains i18n object, this target can be used directly.
|
||
"cli": {"file": "claude_tap/cli.py", "object_name": "I18N"},
|
||
}
|
||
|
||
|
||
@dataclass
|
||
class ObjectBlock:
|
||
source: str
|
||
start: int
|
||
end: int
|
||
prefix: str
|
||
body: str
|
||
suffix: str
|
||
|
||
|
||
@dataclass
|
||
class LangBlock:
|
||
lang: str
|
||
span_start: int
|
||
span_end: int
|
||
body_start: int
|
||
body_end: int
|
||
body: str
|
||
|
||
|
||
def extract_object_block(source: str, object_name: str) -> ObjectBlock:
|
||
pattern = re.compile(
|
||
rf"(?P<prefix>(?:const|let|var)?\s*{re.escape(object_name)}\s*=\s*\{{)"
|
||
rf"(?P<body>[\s\S]*?)"
|
||
rf"(?P<suffix>^\s*\}};?\s*$)",
|
||
re.MULTILINE,
|
||
)
|
||
match = pattern.search(source)
|
||
if not match:
|
||
raise ValueError(f"Could not locate object '{object_name}' in source file.")
|
||
return ObjectBlock(
|
||
source=source,
|
||
start=match.start(),
|
||
end=match.end(),
|
||
prefix=match.group("prefix"),
|
||
body=match.group("body"),
|
||
suffix=match.group("suffix"),
|
||
)
|
||
|
||
|
||
def parse_lang_blocks(object_body: str) -> dict[str, LangBlock]:
|
||
pattern = re.compile(
|
||
r"^\s*(?P<name>\"[^\"]+\"|[A-Za-z0-9_-]+)\s*:\s*\{"
|
||
r"(?P<body>[\s\S]*?)"
|
||
r"^\s*\},\s*$",
|
||
re.MULTILINE,
|
||
)
|
||
blocks: dict[str, LangBlock] = {}
|
||
for match in pattern.finditer(object_body):
|
||
raw_name = match.group("name")
|
||
lang = raw_name[1:-1] if raw_name.startswith('"') and raw_name.endswith('"') else raw_name
|
||
blocks[lang] = LangBlock(
|
||
lang=lang,
|
||
span_start=match.start(),
|
||
span_end=match.end(),
|
||
body_start=match.start("body"),
|
||
body_end=match.end("body"),
|
||
body=match.group("body"),
|
||
)
|
||
return blocks
|
||
|
||
|
||
def parse_lang_entries(lang_body: str) -> dict[str, str]:
|
||
entries: dict[str, str] = {}
|
||
entry_pattern = re.compile(r"(?P<key>[A-Za-z0-9_]+)\s*:\s*\"(?P<value>(?:\\.|[^\"\\])*)\"")
|
||
for match in entry_pattern.finditer(lang_body):
|
||
key = match.group("key")
|
||
raw_val = match.group("value")
|
||
entries[key] = json.loads(f'"{raw_val}"')
|
||
return entries
|
||
|
||
|
||
def collect_i18n_data(
|
||
source: str, object_name: str
|
||
) -> tuple[ObjectBlock, dict[str, LangBlock], dict[str, dict[str, str]]]:
|
||
object_block = extract_object_block(source, object_name)
|
||
lang_blocks = parse_lang_blocks(object_block.body)
|
||
lang_entries = {lang: parse_lang_entries(block.body) for lang, block in lang_blocks.items()}
|
||
return object_block, lang_blocks, lang_entries
|
||
|
||
|
||
def validate_i18n_json(data: object) -> dict[str, dict[str, str]]:
|
||
if not isinstance(data, dict):
|
||
raise ValueError("I18N JSON must contain an object.")
|
||
|
||
entries: dict[str, dict[str, str]] = {}
|
||
for lang, values in data.items():
|
||
if not isinstance(lang, str) or not isinstance(values, dict):
|
||
raise ValueError("I18N JSON must map language codes to string maps.")
|
||
lang_entries: dict[str, str] = {}
|
||
for key, value in values.items():
|
||
if not isinstance(key, str) or not isinstance(value, str):
|
||
raise ValueError("I18N JSON language maps must contain string keys and values.")
|
||
lang_entries[key] = value
|
||
entries[lang] = lang_entries
|
||
return entries
|
||
|
||
|
||
def load_i18n_json(path: Path) -> dict[str, dict[str, str]]:
|
||
return validate_i18n_json(json.loads(path.read_text(encoding="utf-8")))
|
||
|
||
|
||
def find_missing_keys(lang_entries: dict[str, dict[str, str]], target_languages: list[str]) -> dict[str, list[str]]:
|
||
if "en" not in lang_entries or "zh-CN" not in lang_entries:
|
||
raise ValueError("Source i18n object must include both 'en' and 'zh-CN'.")
|
||
|
||
en_keys = list(lang_entries["en"])
|
||
zh_keys = set(lang_entries["zh-CN"])
|
||
source_keys = [key for key in en_keys if key in zh_keys]
|
||
missing: dict[str, list[str]] = {}
|
||
for lang in target_languages:
|
||
if lang not in lang_entries:
|
||
continue
|
||
lang_keys = set(lang_entries[lang])
|
||
keys = [key for key in source_keys if key not in lang_keys]
|
||
if keys:
|
||
missing[lang] = keys
|
||
return missing
|
||
|
||
|
||
def parse_json_response(text: str) -> dict[str, str]:
|
||
cleaned = text.strip()
|
||
if cleaned.startswith("```"):
|
||
cleaned = re.sub(r"^```(?:json)?\n", "", cleaned)
|
||
cleaned = re.sub(r"\n```$", "", cleaned)
|
||
data = json.loads(cleaned)
|
||
if not isinstance(data, dict):
|
||
raise ValueError("Model response must be a JSON object.")
|
||
output: dict[str, str] = {}
|
||
for key, value in data.items():
|
||
if not isinstance(key, str) or not isinstance(value, str):
|
||
raise ValueError("Model response JSON must map string keys to string values.")
|
||
output[key] = value
|
||
return output
|
||
|
||
|
||
def request_openrouter_translation(
|
||
api_key: str,
|
||
model: str,
|
||
lang: str,
|
||
keys: list[str],
|
||
en_map: dict[str, str],
|
||
zh_map: dict[str, str],
|
||
existing_lang_map: dict[str, str],
|
||
) -> dict[str, str]:
|
||
request_items = [
|
||
{
|
||
"key": key,
|
||
"en": en_map[key],
|
||
"zh-CN": zh_map[key],
|
||
}
|
||
for key in keys
|
||
]
|
||
|
||
fullwidth_examples: dict[str, dict[str, str]] = {}
|
||
existing_examples = {
|
||
key: value for key, value in existing_lang_map.items() if any(symbol in value for symbol in (":", "!", "?"))
|
||
}
|
||
zh_examples = {key: value for key, value in zh_map.items() if any(symbol in value for symbol in (":", "!", "?"))}
|
||
if existing_examples:
|
||
fullwidth_examples["target_existing"] = existing_examples
|
||
if zh_examples:
|
||
fullwidth_examples["zh-CN_reference"] = zh_examples
|
||
|
||
prompt = {
|
||
"task": "Translate missing UI i18n strings.",
|
||
"context": "This is a developer tool (trace viewer) for inspecting LLM API calls.",
|
||
"target_language": lang,
|
||
"requirements": [
|
||
"Return strict JSON object: key -> translated string.",
|
||
"Do not include markdown or explanations.",
|
||
"Preserve placeholders and symbols exactly (e.g., #, %s, {name}, ellipsis).",
|
||
"Keep short UI label style and terminology consistent.",
|
||
],
|
||
"existing_translations_for_consistency": existing_lang_map,
|
||
"items_to_translate": request_items,
|
||
}
|
||
if lang in {"ja", "ko", "zh-CN"}:
|
||
prompt["requirements"].append(
|
||
"For ja/ko/zh-CN, preserve fullwidth punctuation style (e.g., :!?) to match existing translations."
|
||
)
|
||
prompt["requirements"].append(
|
||
"If zh-CN reference uses fullwidth punctuation for a key, mirror that punctuation width in translation."
|
||
)
|
||
if fullwidth_examples:
|
||
prompt["fullwidth_punctuation_examples"] = fullwidth_examples
|
||
|
||
payload = {
|
||
"model": model,
|
||
"temperature": 0,
|
||
"messages": [
|
||
{
|
||
"role": "system",
|
||
"content": "You are a precise software UI localization assistant. Output JSON only.",
|
||
},
|
||
{
|
||
"role": "user",
|
||
"content": json.dumps(prompt, ensure_ascii=False),
|
||
},
|
||
],
|
||
}
|
||
|
||
request = Request(
|
||
OPENROUTER_ENDPOINT,
|
||
data=json.dumps(payload).encode("utf-8"),
|
||
headers={
|
||
"Authorization": f"Bearer {api_key}",
|
||
"Content-Type": "application/json",
|
||
"HTTP-Referer": "https://github.com/liaohch3/claude-tap",
|
||
"X-Title": "claude-tap i18n helper",
|
||
},
|
||
method="POST",
|
||
)
|
||
|
||
try:
|
||
with urlopen(request, timeout=90) as response:
|
||
response_data = json.loads(response.read().decode("utf-8"))
|
||
except HTTPError as exc:
|
||
detail = exc.read().decode("utf-8", errors="ignore")
|
||
raise RuntimeError(f"OpenRouter request failed ({exc.code}): {detail}") from exc
|
||
except URLError as exc:
|
||
raise RuntimeError(f"OpenRouter request failed: {exc.reason}") from exc
|
||
|
||
try:
|
||
content = response_data["choices"][0]["message"]["content"]
|
||
except (KeyError, IndexError, TypeError) as exc:
|
||
raise RuntimeError(f"Unexpected OpenRouter response shape: {response_data}") from exc
|
||
|
||
translations = parse_json_response(content)
|
||
missing = [key for key in keys if key not in translations]
|
||
if missing:
|
||
raise RuntimeError(f"Model response missing keys for {lang}: {', '.join(missing)}")
|
||
|
||
ordered = {key: translations[key] for key in keys}
|
||
return normalize_punctuation_style(lang=lang, translations=ordered, zh_map=zh_map)
|
||
|
||
|
||
def normalize_punctuation_style(
|
||
lang: str,
|
||
translations: dict[str, str],
|
||
zh_map: dict[str, str],
|
||
) -> dict[str, str]:
|
||
if lang not in {"ja", "ko", "zh-CN"}:
|
||
return translations
|
||
|
||
replacements = {":": ":", "!": "!", "?": "?"}
|
||
normalized: dict[str, str] = {}
|
||
for key, value in translations.items():
|
||
target = value
|
||
zh_value = zh_map.get(key, "")
|
||
for ascii_punc, fullwidth_punc in replacements.items():
|
||
if fullwidth_punc in zh_value:
|
||
target = target.replace(ascii_punc, fullwidth_punc)
|
||
normalized[key] = target
|
||
return normalized
|
||
|
||
|
||
def apply_translations_to_source(
|
||
source: str,
|
||
object_name: str,
|
||
updates: dict[str, dict[str, str]],
|
||
) -> str:
|
||
object_block, lang_blocks, _ = collect_i18n_data(source, object_name)
|
||
body = object_block.body
|
||
|
||
replacements: list[tuple[int, int, str]] = []
|
||
for lang, translations in updates.items():
|
||
if not translations:
|
||
continue
|
||
lang_block = lang_blocks.get(lang)
|
||
if not lang_block:
|
||
continue
|
||
updated_lang_body = build_updated_lang_body(lang_block.body, translations)
|
||
if updated_lang_body != lang_block.body:
|
||
replacements.append((lang_block.body_start, lang_block.body_end, updated_lang_body))
|
||
|
||
if not replacements:
|
||
return source
|
||
|
||
updated_body = body
|
||
for start, end, replacement in sorted(replacements, key=lambda item: item[0], reverse=True):
|
||
updated_body = updated_body[:start] + replacement + updated_body[end:]
|
||
|
||
updated_block = f"{object_block.prefix}{updated_body}{object_block.suffix}"
|
||
return source[: object_block.start] + updated_block + source[object_block.end :]
|
||
|
||
|
||
def apply_translations_to_json_entries(
|
||
lang_entries: dict[str, dict[str, str]],
|
||
updates: dict[str, dict[str, str]],
|
||
) -> dict[str, dict[str, str]]:
|
||
updated = {lang: dict(entries) for lang, entries in lang_entries.items()}
|
||
for lang, translations in updates.items():
|
||
if lang not in updated or not translations:
|
||
continue
|
||
updated[lang].update(translations)
|
||
return updated
|
||
|
||
|
||
def build_updated_lang_body(lang_body: str, translations: dict[str, str]) -> str:
|
||
lines = lang_body.splitlines(keepends=True)
|
||
key_line_indices = [i for i, line in enumerate(lines) if re.search(r"[A-Za-z0-9_]+\s*:\s*\"", line)]
|
||
if not key_line_indices:
|
||
return lang_body
|
||
|
||
packed_style = any(len(re.findall(r"[A-Za-z0-9_]+\s*:\s*\"", lines[i])) > 1 for i in key_line_indices)
|
||
last_key_idx = key_line_indices[-1]
|
||
last_key_line = lines[last_key_idx]
|
||
indent_match = re.match(r"(\s*)", last_key_line)
|
||
indent = indent_match.group(1) if indent_match else " "
|
||
entries = [f"{key}: {json.dumps(value, ensure_ascii=False)}," for key, value in translations.items()]
|
||
line_ending = "\r\n" if last_key_line.endswith("\r\n") else "\n" if last_key_line.endswith("\n") else ""
|
||
|
||
if packed_style:
|
||
inserted_line = f"{indent}{' '.join(entries)}{line_ending}"
|
||
insert_at = last_key_idx + 1
|
||
return "".join(lines[:insert_at] + [inserted_line] + lines[insert_at:])
|
||
|
||
inserted_lines = "".join(f"{indent}{entry}{line_ending}" for entry in entries)
|
||
insert_at = last_key_idx + 1
|
||
return "".join(lines[:insert_at] + [inserted_lines] + lines[insert_at:])
|
||
|
||
|
||
def resolve_target(args: argparse.Namespace) -> tuple[Path, str]:
|
||
if args.file:
|
||
target_path = Path(args.file)
|
||
else:
|
||
target_path = Path(TARGET_CONFIG[args.target]["file"])
|
||
|
||
object_name = args.object_name or TARGET_CONFIG[args.target]["object_name"]
|
||
return target_path, object_name
|
||
|
||
|
||
def make_arg_parser() -> argparse.ArgumentParser:
|
||
parser = argparse.ArgumentParser(description="Translate missing i18n keys with OpenRouter")
|
||
parser.add_argument("--target", choices=sorted(TARGET_CONFIG), default="viewer", help="Translation target preset")
|
||
parser.add_argument("--file", help="Override target file path")
|
||
parser.add_argument("--object-name", help="Override JS/Python i18n object name")
|
||
parser.add_argument("--model", default=DEFAULT_MODEL, help="OpenRouter model")
|
||
parser.add_argument("--dry-run", action="store_true", help="Show missing keys without writing file")
|
||
return parser
|
||
|
||
|
||
def print_summary(missing: dict[str, list[str]], translated: dict[str, list[str]], dry_run: bool) -> None:
|
||
if not missing:
|
||
print("No missing translations found.")
|
||
return
|
||
|
||
if dry_run:
|
||
print("Dry run: missing keys that would be translated")
|
||
else:
|
||
print("Translation summary")
|
||
|
||
for lang in LANG_ORDER:
|
||
keys = missing.get(lang, [])
|
||
if not keys:
|
||
continue
|
||
done = translated.get(lang, [])
|
||
status = "planned" if dry_run else "translated"
|
||
print(f"- {lang}: {status} {len(done or keys)} key(s)")
|
||
for key in done or keys:
|
||
print(f" - {key}")
|
||
|
||
|
||
def main(argv: list[str] | None = None) -> int:
|
||
parser = make_arg_parser()
|
||
args = parser.parse_args(argv)
|
||
|
||
target_path, object_name = resolve_target(args)
|
||
if not target_path.exists():
|
||
parser.error(f"Target file not found: {target_path}")
|
||
|
||
is_json_target = target_path.suffix.lower() == ".json"
|
||
if is_json_target:
|
||
lang_entries = load_i18n_json(target_path)
|
||
source = ""
|
||
else:
|
||
source = target_path.read_text(encoding="utf-8")
|
||
_, _, lang_entries = collect_i18n_data(source, object_name)
|
||
missing = find_missing_keys(lang_entries, LANG_ORDER)
|
||
|
||
if args.dry_run or not missing:
|
||
print_summary(missing, {}, dry_run=True)
|
||
return 0
|
||
|
||
api_key = os.getenv("OPENROUTER_API_KEY", "").strip()
|
||
if not api_key:
|
||
parser.error("OPENROUTER_API_KEY is required unless --dry-run is used")
|
||
|
||
updates: dict[str, dict[str, str]] = {}
|
||
translated_summary: dict[str, list[str]] = {}
|
||
|
||
for lang in LANG_ORDER:
|
||
keys = missing.get(lang, [])
|
||
if not keys:
|
||
continue
|
||
print(f"Translating {len(keys)} key(s) for {lang}...")
|
||
lang_update = request_openrouter_translation(
|
||
api_key=api_key,
|
||
model=args.model,
|
||
lang=lang,
|
||
keys=keys,
|
||
en_map=lang_entries["en"],
|
||
zh_map=lang_entries["zh-CN"],
|
||
existing_lang_map=lang_entries[lang],
|
||
)
|
||
updates[lang] = lang_update
|
||
translated_summary[lang] = list(lang_update)
|
||
|
||
if is_json_target:
|
||
updated_entries = apply_translations_to_json_entries(lang_entries, updates)
|
||
target_path.write_text(json.dumps(updated_entries, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||
else:
|
||
updated_source = apply_translations_to_source(source, object_name, updates)
|
||
target_path.write_text(updated_source, encoding="utf-8")
|
||
|
||
print_summary(missing, translated_summary, dry_run=False)
|
||
print(f"Updated file: {target_path}")
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|