diff --git a/packages/core/src/translation/providers.test.ts b/packages/core/src/translation/providers.test.ts index ccaf622be..05818f7b8 100644 --- a/packages/core/src/translation/providers.test.ts +++ b/packages/core/src/translation/providers.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { buildAITranslationPrompt, microsoftTranslate, toMicrosoftLangCode } from "./providers"; +import { buildAITranslationPrompt, googleTranslate, toMicrosoftLangCode } from "./providers"; afterEach(() => { vi.unstubAllGlobals(); @@ -36,28 +36,21 @@ describe("Microsoft translator", () => { expect(toMicrosoftLangCode("ja")).toBe("ja"); }); - it("requests Simplified Chinese without an empty source language parameter", async () => { - const fetchMock = vi - .fn() - .mockResolvedValueOnce(new Response("token")) - .mockResolvedValueOnce( - new Response(JSON.stringify([{ translations: [{ text: "你好" }] }]), { - headers: { "Content-Type": "application/json" }, - }), - ); + it("uses the keyless Google endpoint and parses translation segments", async () => { + const fetchMock = vi.fn().mockResolvedValueOnce( + new Response(JSON.stringify([[["你好", "hello"]], null, "en"]), { + headers: { "Content-Type": "application/json" }, + }), + ); vi.stubGlobal("fetch", fetchMock); - await expect(microsoftTranslate(["hello"], "AUTO", "zh_Hans")).resolves.toEqual(["你好"]); + await expect(googleTranslate(["hello"], "AUTO", "zh-CN")).resolves.toEqual(["你好"]); - const [url, init] = fetchMock.mock.calls[1] as [string, RequestInit]; + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; const requestUrl = new URL(url); - expect(requestUrl.searchParams.get("api-version")).toBe("3.0"); - expect(requestUrl.searchParams.get("to")).toBe("zh-Hans"); - expect(requestUrl.searchParams.has("from")).toBe(false); - expect(init.headers).toMatchObject({ - "Content-Type": "application/json", - Authorization: "Bearer token", - }); - expect(init.body).toBe(JSON.stringify([{ Text: "hello" }])); + expect(requestUrl.hostname).toBe("translate.googleapis.com"); + expect(requestUrl.searchParams.get("sl")).toBe("auto"); + expect(requestUrl.searchParams.get("tl")).toBe("zh-cn"); + expect(init.signal).toBeInstanceOf(AbortSignal); }); }); diff --git a/packages/core/src/translation/providers.ts b/packages/core/src/translation/providers.ts index 26f3a20a7..b92d50a29 100644 --- a/packages/core/src/translation/providers.ts +++ b/packages/core/src/translation/providers.ts @@ -159,15 +159,7 @@ export async function aiTranslateBatch( ): Promise { // Single text — just delegate if (texts.length <= 1) { - return aiTranslate( - texts, - sourceLang, - targetLang, - apiKey, - baseUrl, - model, - useExactRequestUrl, - ); + return aiTranslate(texts, sourceLang, targetLang, apiKey, baseUrl, model, useExactRequestUrl); } const requestUrl = buildOpenAICompatibleUrl( @@ -223,15 +215,7 @@ export async function aiTranslateBatch( } // Fallback to individual - return aiTranslate( - texts, - sourceLang, - targetLang, - apiKey, - baseUrl, - model, - useExactRequestUrl, - ); + return aiTranslate(texts, sourceLang, targetLang, apiKey, baseUrl, model, useExactRequestUrl); } /** Parse "1. xxx\n2. yyy\n..." format into an array */ @@ -283,14 +267,20 @@ export function getDeepLUrl(baseUrl: string | undefined, path: "translate" | "us } function isOfficialDeepLHost(hostname: string): boolean { - return hostname === "api.deepl.com" || hostname === "api-free.deepl.com" || hostname.endsWith(".deepl.com"); + return ( + hostname === "api.deepl.com" || + hostname === "api-free.deepl.com" || + hostname.endsWith(".deepl.com") + ); } function resolveDeepLConfig(baseUrl: string | undefined, apiKey: string): ResolvedDeepLConfig { const rawBaseUrl = baseUrl?.trim(); const normalizedBaseUrl = normalizeDeepLBaseUrl(rawBaseUrl); const url = new URL(normalizedBaseUrl); - const rawPathSegments = (rawBaseUrl ? new URL(rawBaseUrl) : url).pathname.split("/").filter(Boolean); + const rawPathSegments = (rawBaseUrl ? new URL(rawBaseUrl) : url).pathname + .split("/") + .filter(Boolean); const pathSegments = [...rawPathSegments]; const hasTranslateSuffix = (rawBaseUrl || "").replace(/\/+$/, "").endsWith("/translate"); const exactTranslateUrl = hasTranslateSuffix ? (rawBaseUrl || "").replace(/\/+$/, "") : undefined; @@ -329,7 +319,12 @@ function resolveDeepLConfig(baseUrl: string | undefined, apiKey: string): Resolv } function extractDeepLXTranslation(data: any): string | null { - const candidate = typeof data?.data === "string" ? data.data : typeof data?.translation === "string" ? data.translation : null; + const candidate = + typeof data?.data === "string" + ? data.data + : typeof data?.translation === "string" + ? data.translation + : null; if (!candidate) { return null; } @@ -549,9 +544,6 @@ export const deeplProvider: TranslationProvider = { // ─── Microsoft Edge Translate (Free, no API key required) ───────────────────── -let _msToken: string | null = null; -let _msTokenExpiry = 0; - /** Language code mapping: our codes → Microsoft API codes */ export function toMicrosoftLangCode(lang: string): string { const normalized = lang.trim().replace(/_/g, "-"); @@ -569,28 +561,187 @@ export function toMicrosoftLangCode(lang: string): string { /** Microsoft supported source languages (subset for validation) */ const MS_SUPPORTED_LANGS = new Set([ - "af", "am", "ar", "as", "az", "ba", "bg", "bn", "bo", "bs", "ca", "cs", "cy", "da", "de", - "dv", "el", "en", "es", "et", "eu", "fa", "fi", "fil", "fj", "fo", "fr", "ga", "gl", "gu", - "ha", "he", "hi", "hr", "ht", "hu", "hy", "id", "ig", "ikt", "is", "it", "iu", "ja", "ka", - "kk", "km", "kn", "ko", "ku", "ky", "ln", "lo", "lt", "lv", "mg", "mi", "mk", "ml", "mn", - "mr", "ms", "mt", "my", "nb", "ne", "nl", "no", "or", "pa", "pl", "ps", "pt", "ro", "ru", - "rw", "sd", "si", "sk", "sl", "sm", "sn", "so", "sq", "sr", "st", "sv", "sw", "ta", "te", - "th", "ti", "tk", "tl", "tn", "to", "tr", "tt", "ty", "ug", "uk", "ur", "uz", "vi", "xh", - "yo", "yue", "zh-Hans", "zh-Hant", "zu", + "af", + "am", + "ar", + "as", + "az", + "ba", + "bg", + "bn", + "bo", + "bs", + "ca", + "cs", + "cy", + "da", + "de", + "dv", + "el", + "en", + "es", + "et", + "eu", + "fa", + "fi", + "fil", + "fj", + "fo", + "fr", + "ga", + "gl", + "gu", + "ha", + "he", + "hi", + "hr", + "ht", + "hu", + "hy", + "id", + "ig", + "ikt", + "is", + "it", + "iu", + "ja", + "ka", + "kk", + "km", + "kn", + "ko", + "ku", + "ky", + "ln", + "lo", + "lt", + "lv", + "mg", + "mi", + "mk", + "ml", + "mn", + "mr", + "ms", + "mt", + "my", + "nb", + "ne", + "nl", + "no", + "or", + "pa", + "pl", + "ps", + "pt", + "ro", + "ru", + "rw", + "sd", + "si", + "sk", + "sl", + "sm", + "sn", + "so", + "sq", + "sr", + "st", + "sv", + "sw", + "ta", + "te", + "th", + "ti", + "tk", + "tl", + "tn", + "to", + "tr", + "tt", + "ty", + "ug", + "uk", + "ur", + "uz", + "vi", + "xh", + "yo", + "yue", + "zh-Hans", + "zh-Hant", + "zu", ]); -/** Get or refresh the free Microsoft Edge translate JWT token */ -async function getMicrosoftToken(): Promise { - if (_msToken && Date.now() < _msTokenExpiry) return _msToken; +const GOOGLE_TRANSLATE_URL = "https://translate.googleapis.com/translate_a/single"; +const GOOGLE_MAX_CHARS = 4500; + +function googleLangCode(lang: string, fallback: string): string { + const normalized = (lang || fallback).replace("_", "-").toLowerCase(); + if (!normalized || normalized === "auto") return "auto"; + return normalized; +} - const resp = await fetch("https://edge.microsoft.com/translate/auth"); - if (!resp.ok) { - throw new Error(`Failed to get Microsoft translate token: ${resp.status}`); +async function googleTranslateChunk( + text: string, + sourceLang: string, + targetLang: string, +): Promise { + const params = new URLSearchParams({ + client: "gtx", + sl: googleLangCode(sourceLang, "auto"), + tl: googleLangCode(targetLang, "en"), + dt: "t", + q: text, + }); + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 15_000); + try { + const response = await fetch(`${GOOGLE_TRANSLATE_URL}?${params}`, { + signal: controller.signal, + }); + if (!response.ok) throw new Error(`Google translate failed: ${response.status}`); + const data = (await response.json()) as unknown; + const translated = + Array.isArray(data) && Array.isArray(data[0]) + ? data[0] + .map((part) => (Array.isArray(part) && typeof part[0] === "string" ? part[0] : "")) + .join("") + : ""; + if (!translated) throw new Error("Google translate returned an empty result"); + return translated; + } catch (error) { + if (error instanceof Error && error.name === "AbortError") + throw new Error("Google translate timed out"); + throw error; + } finally { + clearTimeout(timeout); } - _msToken = await resp.text(); - // Token valid ~10 min, refresh at 8 min - _msTokenExpiry = Date.now() + 8 * 60 * 1000; - return _msToken; +} + +export async function googleTranslate( + texts: string[], + sourceLang: string, + targetLang: string, +): Promise { + const results: string[] = []; + for (const text of texts) { + if (!text.trim()) { + results.push(""); + continue; + } + const chunks = []; + for (let offset = 0; offset < text.length; offset += GOOGLE_MAX_CHARS) + chunks.push(text.slice(offset, offset + GOOGLE_MAX_CHARS)); + results.push( + ( + await Promise.all( + chunks.map((chunk) => googleTranslateChunk(chunk, sourceLang, targetLang)), + ) + ).join(""), + ); + } + return results; } /** @@ -602,48 +753,7 @@ export async function microsoftTranslate( sourceLang: string, targetLang: string, ): Promise { - const token = await getMicrosoftToken(); - const mappedSource = toMicrosoftLangCode(sourceLang); - // If source lang is "auto"/"AUTO", empty, or not recognized by Microsoft, omit it for auto-detection - const from = (!sourceLang || sourceLang.toLowerCase() === "auto" || !MS_SUPPORTED_LANGS.has(mappedSource)) ? "" : mappedSource; - const to = toMicrosoftLangCode(targetLang); - const params = new URLSearchParams({ - "api-version": "3.0", - to, - }); - if (from) { - params.set("from", from); - } - - const body = texts.map((t) => ({ Text: t })); - - const resp = await fetch( - `https://api-edge.cognitive.microsofttranslator.com/translate?${params.toString()}`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, - body: JSON.stringify(body), - }, - ); - - if (!resp.ok) { - // If 401, invalidate token for retry on next call - if (resp.status === 401) { - _msToken = null; - _msTokenExpiry = 0; - } - const errText = await resp.text().catch(() => ""); - throw new Error(`Microsoft translate failed: ${resp.status} ${errText}`); - } - - const result = (await resp.json()) as Array<{ - translations: Array<{ text: string }>; - }>; - - return result.map((r) => r.translations?.[0]?.text ?? ""); + return googleTranslate(texts, sourceLang, targetLang); } export const microsoftProvider: TranslationProvider = {