feat: apinex translator CLI (scripts/translate.mjs) + npm translate script
- scripts/translate.mjs: wraps apinex.bond /v1/tools/translator (free)
- protects {{tokens}} and <code> from translation in HTML mode
- supports --text/--file/--batch/--list-languages, auto HTML detection
- npm run translate alias
This commit is contained in:
1 parent
a0dda98b83
commit
86ea6731fa
2 files changed
+196
No files matched your search
@@ -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"
|
||||
|
||||
@@ -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 или авто) шаблонные токены {{...}} и содержимое <code> защищаются
|
||||
* 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 + ")");
|
||||
}
|
||||
|
||||
// ---- защита токенов и <code> для HTML ----
|
||||
function protect(html) {
|
||||
const store = [];
|
||||
let out = html.replace(/\{\{[\s\S]*?\}\}/g, (m) => {
|
||||
store.push(m);
|
||||
return `<!--TPL${store.length - 1}-->`;
|
||||
});
|
||||
out = out.replace(/<code>([\s\S]*?)<\/code>/g, (_m, inner) => {
|
||||
store.push(inner);
|
||||
return `<code><!--CPL${store.length - 1}--></code>`;
|
||||
});
|
||||
return { out, store };
|
||||
}
|
||||
function restore(html, store) {
|
||||
return html
|
||||
.replace(/<!--TPL(\d+)-->/g, (_m, i) => store[+i])
|
||||
.replace(/<code><!--CPL(\d+)--><\/code>/g, (_m, i) => `<code>${store[+i]}</code>`);
|
||||
}
|
||||
|
||||
// ---- сам перевод ----
|
||||
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);
|
||||
});
|
||||
Reference in new issue
Block a user