#!/usr/bin/env node /** * translate.mjs — CLI-обёртка над apinex.bond /v1/tools/translator * * Использование: * node scripts/translate.mjs --target EN --text "Привет" * node scripts/translate.mjs --target EN --source RU --file page.html --out page.en.html * cat strings.txt | node scripts/translate.mjs --target EN --batch * node scripts/translate.mjs --list-languages * * Ключ берётся из HERMES_CUSTOM_APINEX_API_KEY / APINEX_API_KEY, иначе из /root/.hermes/.env. * * Для HTML (--html или авто) шаблонные токены {{...}} и содержимое защищаются * HTML-комментариями, чтобы переводчик их не трогал (они восстанавливаются после). */ import { readFileSync, writeFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { dirname, resolve } from "node:path"; const __dirname = dirname(fileURLToPath(import.meta.url)); const API_URL = "https://api.apinex.bond/v1/tools/translator"; const LANG_URL = "https://api.apinex.bond/v1/tools/translator/languages"; const ENV_PATH = "/root/.hermes/.env"; // ---- аргументы ---- function parseArgs(argv) { const a = { target: null, source: null, html: null, batch: false, file: null, out: null, text: null, list: false, }; for (let i = 0; i < argv.length; i++) { const arg = argv[i]; const next = () => argv[++i]; switch (arg) { case "--target": case "-t": a.target = next(); break; case "--source": case "-s": a.source = next(); break; case "--html": a.html = true; break; case "--no-html": a.html = false; break; case "--batch": a.batch = true; break; case "--file": case "-f": a.file = next(); break; case "--out": case "-o": a.out = next(); break; case "--text": a.text = next(); break; case "--list-languages": case "--list": a.list = true; break; default: if (!a.text && !arg.startsWith("-")) a.text = arg; } } return a; } // ---- ключ ---- function getKey() { const fromEnv = process.env.HERMES_CUSTOM_APINEX_API_KEY || process.env.APINEX_API_KEY; if (fromEnv) return fromEnv; try { const txt = readFileSync(ENV_PATH, "utf8"); const m = txt.match(/^\s*HERMES_CUSTOM_APINEX_API_KEY\s*=\s*"?([^"\n]+)"?/m) || txt.match(/^\s*APINEX_API_KEY\s*=\s*"?([^"\n]+)"?/m); if (m) return m[1].trim(); } catch {} throw new Error("API key not found (HERMES_CUSTOM_APINEX_API_KEY / APINEX_API_KEY / " + ENV_PATH + ")"); } // ---- защита токенов и для HTML ---- function protect(html) { const store = []; let out = html.replace(/\{\{[\s\S]*?\}\}/g, (m) => { store.push(m); return ``; }); out = out.replace(/([\s\S]*?)<\/code>/g, (_m, inner) => { store.push(inner); return ``; }); return { out, store }; } function restore(html, store) { return html .replace(//g, (_m, i) => store[+i]) .replace(/<\/code>/g, (_m, i) => `${store[+i]}`); } // ---- сам перевод ---- async function translateTexts(texts, { target, source, html }) { const body = { text: texts, target_lang: target, preserve_formatting: true, }; if (source) body.source_lang = source; if (html) body.tag_handling = "html"; const res = await fetch(API_URL, { method: "POST", headers: { Authorization: "Bearer " + getKey(), "Content-Type": "application/json", }, body: JSON.stringify(body), }); if (!res.ok) { const err = await res.text(); throw new Error(`Translator HTTP ${res.status}: ${err}`); } const data = await res.json(); return data.translations.map((t) => t.text); } async function listLanguages() { const res = await fetch(LANG_URL, { headers: { Authorization: "Bearer " + getKey() } }); if (!res.ok) throw new Error(`Languages HTTP ${res.status}`); return res.json(); } // ---- main ---- async function main() { const a = parseArgs(process.argv.slice(2)); if (a.list) { const langs = await listLanguages(); console.log(JSON.stringify(langs, null, 2)); return; } if (!a.target) { console.error("Usage: translate.mjs --target EN [--source RU] [--html] [--file|-f path] [--text|-t str] [--batch] [--out|-o path]"); process.exit(2); } let raw; if (a.file) raw = readFileSync(resolve(__dirname, "..", a.file), "utf8"); else if (a.text) raw = a.text; else raw = readFileSync(0, "utf8"); // stdin const useHtml = a.html ?? (raw.includes("<") && raw.includes(">")); let texts; if (a.batch) { texts = raw.split(/\r?\n/).filter((l) => l.trim().length); } else { texts = [raw]; } let results; if (useHtml) { const protectedTexts = texts.map((t) => protect(t)); const translated = await translateTexts(protectedTexts.map((p) => p.out), { target: a.target, source: a.source, html: true, }); results = translated.map((t, i) => restore(t, protectedTexts[i].store)); } else { results = await translateTexts(texts, { target: a.target, source: a.source, html: false }); } const output = a.batch ? results.join("\n") : results[0]; if (a.out) writeFileSync(resolve(__dirname, "..", a.out), output, "utf8"); else process.stdout.write(output + (a.batch ? "" : "")); if (!a.out) process.stdout.write("\n"); } main().catch((e) => { console.error("ERROR:", e.message); process.exit(1); });