From 87cb914c842cda5ddfbc6a2f28d9bbda3879eb90 Mon Sep 17 00:00:00 2001 From: Ben Foster Date: Sun, 16 Aug 2026 10:24:36 +0300 Subject: [PATCH] =?UTF-8?q?fix(translate):=20Google=20gtx=20fallback=20?= =?UTF-8?q?=D0=BF=D1=80=D0=B8=20502=20MyMemory/AI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Биндинг AI на Pages часто не активен — без него остаётся только MyMemory и снова 502. Добавлен gtx до MyMemory; bump v16.5. Co-authored-by: Cursor --- docs/API.md | 17 +++--- functions/api/translate.js | 100 ++++++++++++++++++++++++++++-------- js/core/version.ts | 4 +- sw.js | 2 +- tests/api-translate.test.js | 51 ++++++++++++++---- 5 files changed, 131 insertions(+), 43 deletions(-) diff --git a/docs/API.md b/docs/API.md index cbf6d16..e65f306 100644 --- a/docs/API.md +++ b/docs/API.md @@ -63,19 +63,16 @@ Limits: ~120 req/hour/subject; anonymous IP ceiling still applies (~300/hour). Same-origin proxy for card-editor «Перевести» (CSP-safe). -**Upstream (prod):** Cloudflare Workers AI — -1. Llama (`@cf/meta/llama-3.1-8b-instruct`) for meaning (avoids transliteration like onion → «Онеон») -2. m2m100 with language **names** `english`/`russian` (ISO `en`/`ru` often transliterates on CF) -3. MyMemory only as last fallback / local `npm run dev` without `AI` binding +**Upstream (prod):** -**Body (JSON):** +1. Workers AI Llama (if `AI` binding is set in Pages) +2. Workers AI m2m100 with language names `english`/`russian` +3. **Google Translate gtx** — reliable from Cloudflare edge without a key (MyMemory often returns 502 from CF IPs) +4. MyMemory last -| Field | Required | Description | -| ------ | -------- | ---------------------------------------- | -| `text` | yes | Word/phrase to translate (max 500 chars) | -| `dir` | no | `ru-en` (default) or `en-ru` | +**Success:** `{ text, dir, provider?: "workers-ai-llm" \| "workers-ai-m2m" \| "gtx" \| "mymemory" }` -**Success:** `{ text: string, dir: string, provider?: "workers-ai-llm" \| "workers-ai-m2m" \| "mymemory" }` +If translate keeps returning 502 after deploy, add Pages → Settings → Bindings → Workers AI (`AI`), then redeploy. Limits: ~120 req/hour/subject. diff --git a/functions/api/translate.js b/functions/api/translate.js index 8a988ce..60ff228 100644 --- a/functions/api/translate.js +++ b/functions/api/translate.js @@ -3,13 +3,15 @@ // // Клиент → same-origin /api/translate (CSP). // -// Прод (Workers AI binding): -// 1) Llama — смысловой перевод для карточек (не транслит) -// 2) m2m100 с именами языков english/russian (ISO-коды en/ru -// у m2m100 на CF часто дают транслит вроде onion → «Онеон») -// Fallback без AI / при сбое: MyMemory (локальный npm run dev). +// Порядок провайдеров: +// 1) Workers AI Llama — смысловой перевод (нужен биндинг AI) +// 2) Workers AI m2m100 с именами english/russian +// 3) Google Translate gtx — работает с edge CF без ключа/биндинга +// (MyMemory с IP Cloudflare часто 502) +// 4) MyMemory — последний шанс / локальный dev const MYMEMORY = "https://api.mymemory.translated.net/get" +const GTX = "https://translate.googleapis.com/translate_a/single" const LLM_MODEL = "@cf/meta/llama-3.1-8b-instruct" const MT_MODEL = "@cf/meta/m2m100-1.2b" const UPSTREAM_TIMEOUT_MS = 12_000 @@ -197,6 +199,41 @@ async function translateWithMyMemory(text, from, to) { return out } +/** Неофициальный Google Translate endpoint — обычно доступен с Workers. */ +async function translateWithGtx(text, from, to) { + const url = new URL(GTX) + url.searchParams.set("client", "gtx") + url.searchParams.set("sl", from) + url.searchParams.set("tl", to) + url.searchParams.set("dt", "t") + url.searchParams.set("q", text) + const res = await fetch(url.toString(), { + signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS), + headers: { accept: "application/json" } + }) + if (!res.ok) { + const err = new Error(`gtx status ${res.status}`) + err.code = "upstream" + throw err + } + const data = await res.json() + const chunks = Array.isArray(data?.[0]) ? data[0] : [] + const out = chunks + .map((part) => (Array.isArray(part) ? String(part[0] || "") : "")) + .join("") + .trim() + if (!out) { + const err = new Error("empty") + err.code = "upstream" + throw err + } + return out +} + +function acceptTranslation(source, out, dir) { + return !!out && !looksLikeTransliteration(source, out, dir) +} + async function handler(req, env = {}, subject = "") { if (req.method !== "POST") return json({ error: "bad-request", message: "Ожидается POST" }, 405) @@ -214,31 +251,47 @@ async function handler(req, env = {}, subject = "") { const dir = payload?.dir === "en-ru" ? "en-ru" : "ru-en" const { from, to } = parseDir(dir) + const hasAi = !!(env?.AI && typeof env.AI.run === "function") + if (!hasAi) { + console.warn("[translate] AI binding missing — using HTTP fallbacks", { subject }) + } - try { - const viaLlm = await translateWithLlm(env, text, from, to) - if (viaLlm && !looksLikeTransliteration(text, viaLlm, dir)) { - return json({ text: viaLlm, dir, provider: "workers-ai-llm" }) + if (hasAi) { + try { + const viaLlm = await translateWithLlm(env, text, from, to) + if (acceptTranslation(text, viaLlm, dir)) { + return json({ text: viaLlm, dir, provider: "workers-ai-llm" }) + } + } catch (e) { + console.error("[translate] llm failed", String(e?.message || e), { subject }) + } + + try { + const viaMt = await translateWithM2m(env, text, from, to) + if (acceptTranslation(text, viaMt, dir)) { + return json({ text: viaMt, dir, provider: "workers-ai-m2m" }) + } + if (viaMt) { + console.warn("[translate] m2m transliteration rejected", { text, viaMt, dir, subject }) + } + } catch (e) { + console.error("[translate] m2m failed", String(e?.message || e), { subject }) } - } catch (e) { - console.error("[translate] llm failed", String(e?.message || e), { subject }) } try { - const viaMt = await translateWithM2m(env, text, from, to) - if (viaMt && !looksLikeTransliteration(text, viaMt, dir)) { - return json({ text: viaMt, dir, provider: "workers-ai-m2m" }) - } - if (viaMt) { - console.warn("[translate] m2m transliteration rejected", { text, viaMt, dir, subject }) + const viaGtx = await translateWithGtx(text, from, to) + if (acceptTranslation(text, viaGtx, dir)) { + return json({ text: viaGtx, dir, provider: "gtx" }) } + console.warn("[translate] gtx transliteration rejected", { text, viaGtx, dir, subject }) } catch (e) { - console.error("[translate] m2m failed", String(e?.message || e), { subject }) + console.error("[translate] gtx failed", String(e?.message || e), { subject }) } try { const viaMm = await translateWithMyMemory(text, from, to) - if (looksLikeTransliteration(text, viaMm, dir)) { + if (!acceptTranslation(text, viaMm, dir)) { return json( { error: "bad-translation", @@ -253,7 +306,14 @@ async function handler(req, env = {}, subject = "") { if (e?.code === "quota") { return json({ error: "quota", message: "Лимит перевода исчерпан, попробуйте позже" }, 429) } - return json({ error: "upstream", message: "Сервис перевода недоступен — попробуй позже" }, 502) + return json( + { + error: "upstream", + message: "Сервис перевода недоступен — попробуй позже", + detail: hasAi ? "ai+gtx+mymemory-failed" : "no-ai+gtx+mymemory-failed" + }, + 502 + ) } } diff --git a/js/core/version.ts b/js/core/version.ts index fc13d04..03cf30a 100644 --- a/js/core/version.ts +++ b/js/core/version.ts @@ -1,6 +1,6 @@ /** Версия сборки. Dev SW (`sw.js`) = APP_VERSION; prod SW (`dist/sw.js`) = APP_VERSION + "-bundle". */ -export const APP_VERSION = "kar-v16.4" -export const APP_VERSION_SHORT = "16.4" +export const APP_VERSION = "kar-v16.5" +export const APP_VERSION_SHORT = "16.5" /** Публичный репозиторий проекта. */ export const APP_GITHUB_URL = "https://github.com/PaperPit/kar-dots" diff --git a/sw.js b/sw.js index a10dbf2..d619896 100644 --- a/sw.js +++ b/sw.js @@ -1,4 +1,4 @@ -const VERSION = 'kar-v16.4'; +const VERSION = 'kar-v16.5'; /** AUTO-GENERATED CORE_FILES — node scripts/generate-sw-files.js */ const CORE_FILES = [ diff --git a/tests/api-translate.test.js b/tests/api-translate.test.js index 5d47fa5..bf0a07e 100644 --- a/tests/api-translate.test.js +++ b/tests/api-translate.test.js @@ -48,7 +48,7 @@ describe("api/translate", () => { expect(fetchSpy).not.toHaveBeenCalled() }) - it("отбрасывает транслит m2m и берёт нормальный ответ MyMemory", async () => { + it("отбрасывает транслит m2m и берёт gtx", async () => { const env = { AI: { run: vi.fn(async (model, input) => { @@ -61,13 +61,13 @@ describe("api/translate", () => { } vi.stubGlobal( "fetch", - vi.fn(async () => ({ - ok: true, - json: async () => ({ - responseStatus: 200, - responseData: { translatedText: "лук" } - }) - })) + vi.fn(async (url) => { + expect(String(url)).toContain("translate.googleapis.com") + return { + ok: true, + json: async () => [[["лук", "onion", null, null, 10]]] + } + }) ) const req = new Request("http://localhost/api/translate", { @@ -80,14 +80,45 @@ describe("api/translate", () => { expect(await res.json()).toEqual({ text: "лук", dir: "en-ru", - provider: "mymemory" + provider: "gtx" + }) + }) + + it("без AI берёт Google gtx", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async (url) => { + expect(String(url)).toContain("translate.googleapis.com") + expect(String(url)).toContain("sl=en") + expect(String(url)).toContain("tl=ru") + return { + ok: true, + json: async () => [[["лук", "onion", null, null, 10]]] + } + }) + ) + + const req = new Request("http://localhost/api/translate", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ text: "onion", dir: "en-ru" }) + }) + const res = await handler(req, {}, "test") + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ + text: "лук", + dir: "en-ru", + provider: "gtx" }) }) - it("без AI откатывается на MyMemory", async () => { + it("без AI откатывается на MyMemory если gtx упал", async () => { vi.stubGlobal( "fetch", vi.fn(async (url) => { + if (String(url).includes("translate.googleapis.com")) { + return { ok: false, status: 503, json: async () => ({}) } + } expect(String(url)).toContain("langpair=ru|en") expect(String(url)).toContain(encodeURIComponent("икра")) return {