diff --git a/package.json b/package.json index d2c122e..395e48c 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "build:pages": "node scripts/build-pages.mjs", "build": "npm run vendor:copy && npm run build:pages", "serve": "node scripts/serve-dev.mjs", + "translate": "node scripts/translate.mjs", "convert-videos": "node scripts/convert-videos.mjs", "optimize-images": "node scripts/optimize-images.mjs", "generate:resume-pdf": "node scripts/generate-resume-pdf.mjs http://localhost:4175/resume/ geologica" diff --git a/scripts/translate.mjs b/scripts/translate.mjs new file mode 100644 index 0000000..3ff7b99 --- /dev/null +++ b/scripts/translate.mjs @@ -0,0 +1,195 @@ +#!/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); +});