From d3402dba6025135465d0762997743bd47c5d0472 Mon Sep 17 00:00:00 2001 From: dnjin Date: Sat, 29 Aug 2026 21:41:44 +0800 Subject: [PATCH] fix(core): fix DeepL error (#64) --- apps/vscode-extension/src/tools/deepl.ts | 254 +++++++++++++++-------- 1 file changed, 171 insertions(+), 83 deletions(-) diff --git a/apps/vscode-extension/src/tools/deepl.ts b/apps/vscode-extension/src/tools/deepl.ts index 5f80550..222e5b8 100644 --- a/apps/vscode-extension/src/tools/deepl.ts +++ b/apps/vscode-extension/src/tools/deepl.ts @@ -1,10 +1,20 @@ import * as https from "https"; +import * as http from "http"; +import * as stream from "stream"; +import * as zlib from "zlib"; + +const MAX_ATTEMPTS = 3; +const RATE_LIMIT_MARKERS = ["slow down please", "page load error", "too many requests"]; let freeDeeplID = 1; +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + function getDeepLTimestamp(text: string): number { const ts = Date.now(), count = text.split('i').length; - if (count > 0) return ts - (ts % count) + count; //从油猴插件上搬的 + if (count > 0) return ts - (ts % count) + count; return ts; } @@ -15,6 +25,113 @@ function formatDeeplJson(date: object, id: number): string { return json; } +interface RawResponse { + statusCode?: number; + contentType?: string; + body: string; +} + +function readResponse(res: http.IncomingMessage): Promise { + const rawEncoding = res.headers["content-encoding"]; + const encoding = Array.isArray(rawEncoding) ? rawEncoding.join(",") : (rawEncoding ?? ""); + let readable: stream.Readable = res; + if (encoding.includes("br")) { + readable = res.pipe(zlib.createBrotliDecompress()); + } else if (encoding.includes("gzip")) { + readable = res.pipe(zlib.createGunzip()); + } else if (encoding.includes("deflate")) { + readable = res.pipe(zlib.createInflate()); + } + + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + readable.on("data", (chunk: Buffer) => chunks.push(chunk)); + readable.on("end", () => resolve(Buffer.concat(chunks).toString("utf8"))); + readable.on("error", reject); + }); +} + +function requestDeepL(options: https.RequestOptions, postData: string): Promise { + return new Promise((resolve, reject) => { + let settled = false; + const settle = (fn: () => void) => { if (!settled) { settled = true; fn(); } }; + + const req = https.request(options, (res) => { + readResponse(res).then( + (body) => settle(() => resolve({ statusCode: res.statusCode, contentType: res.headers["content-type"], body })), + (err) => settle(() => reject(err)), + ); + }); + + req.setTimeout(20000, () => req.destroy(new Error("翻译请求超时"))); + req.on("error", (err: Error) => settle(() => reject(err))); + req.write(postData); + req.end(); + }); +} + +async function requestWithRetry(options: https.RequestOptions, postData: string): Promise { + let last: RawResponse = { statusCode: 429, body: "" }; + for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { + const res = await requestDeepL(options, postData); + last = res; + if (res.statusCode === 429 && attempt < MAX_ATTEMPTS) { + const delay = 1000 * 2 ** (attempt - 1); + console.log(`[deepl] 限流 429(第 ${attempt}/${MAX_ATTEMPTS} 次),${delay}ms 后重试`); + await sleep(delay); + continue; + } + return res; + } + return last; +} + +function stripBom(text: string): string { + return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text; +} + +function parseTranslateJson(body: string): Record | null { + try { + const json: unknown = JSON.parse(stripBom(body)); + return json && typeof json === "object" ? (json as Record) : null; + } catch { + return null; + } +} + +function getPath(obj: unknown, path: string): unknown { + return path.split(".").reduce((acc, key) => { + if (acc && typeof acc === "object" && key in (acc as Record)) { + return (acc as Record)[key]; + } + return undefined; + }, obj); +} + +function preview(body: string, max = 120): string { + return body.replace(/\s+/g, " ").substring(0, max); +} + +function isRateLimitHtml(body: string): boolean { + const lower = body.toLowerCase(); + return RATE_LIMIT_MARKERS.some((marker) => lower.includes(marker)); +} + +function logUnexpectedResponse(prefix: string, res: RawResponse): void { + console.log(`[deepl] ${prefix} status=${res.statusCode} type=${res.contentType} body=${preview(res.body, 200)}`); +} + +function buildTranslateError(statusCode: number | undefined, body: string): Error { + if (statusCode === 429 || isRateLimitHtml(body)) { + return new Error("DeepL 翻译被限流(HTTP 429),请稍后再试"); + } + const json = parseTranslateJson(body); + if (json && typeof json.message === "string") { + return new Error(json.message); + } + return new Error(`翻译接口返回异常(HTTP ${statusCode ?? "?"},响应非 JSON:${preview(body)})`); +} + export async function translateTextFree(text: string, lang: string): Promise { const id = freeDeeplID++; const postData = formatDeeplJson({ @@ -32,90 +149,61 @@ export async function translateTextFree(text: string, lang: string): Promise { - let settled = false; - const settle = (fn: () => void) => { if (!settled) { settled = true; fn(); } }; - - const req = https.request( - { - hostname: "www2.deepl.com", - path: "/jsonrpc", - method: "POST", - headers: { - "Content-Type": "application/json", - "Content-Length": Buffer.byteLength(postData), - "Host": "www2.deepl.com", - "Origin": "https://www.deepl.com", - "Referer": "https://www.deepl.com/", - "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36", - }, + const res = await requestWithRetry( + { + hostname: "www2.deepl.com", + path: "/jsonrpc", + method: "POST", + headers: { + "Content-Type": "application/json", + "Content-Length": Buffer.byteLength(postData), + "Host": "www2.deepl.com", + "Origin": "https://www.deepl.com", + "Referer": "https://www.deepl.com/", + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36", }, - (res) => { - let data = ""; - res.on("data", (chunk) => (data += chunk)); - res.on("end", () => { - settle(() => { - if (res.statusCode && res.statusCode >= 400) { - reject(new Error(res.statusCode === 429 ? "翻译请求过于频繁,请稍后再试" : `翻译接口错误 (${res.statusCode})`)); - return; - } - try { - const json = JSON.parse(data); - if (json?.result?.texts?.[0]?.text) { - resolve(json.result.texts[0].text); - } else { - reject(new Error("翻译接口返回异常")); - } - } catch { - reject(new Error("翻译接口返回异常")); - } - }); - }); - } - ); + }, + postData, + ); - req.setTimeout(20000, () => req.destroy(new Error("翻译请求超时"))); - req.on("error", (err: Error) => - settle(() => reject(new Error(err.message === "翻译请求超时" ? "翻译请求超时" : `翻译请求失败: ${err.message}`))) - ); - req.write(postData); - req.end(); - }); + if (res.statusCode && res.statusCode >= 400) { + logUnexpectedResponse("免费翻译请求失败", res); + throw buildTranslateError(res.statusCode, res.body); + } + const json = parseTranslateJson(res.body); + const translated = json ? getPath(json, "result.texts.0.text") : undefined; + if (typeof translated === "string" && translated) { + return translated; + } + logUnexpectedResponse("免费翻译响应结构异常", res); + throw new Error(`翻译接口返回异常:响应结构无法识别(${preview(res.body)})`); } -export function translateTextRaw(text: string, targetLang: string, apiKey: string): Promise { - return new Promise((resolve, reject) => { - const params = new URLSearchParams({ text, target_lang: targetLang }); - const host = apiKey.endsWith(":fx") ? "api-free.deepl.com" : "api.deepl.com"; - const req = https.request( - { - hostname: host, - path: "/v2/translate", - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - Authorization: `DeepL-Auth-Key ${apiKey}`, - }, +export async function translateTextRaw(text: string, targetLang: string, apiKey: string): Promise { + const params = new URLSearchParams({ text, target_lang: targetLang }); + const host = apiKey.endsWith(":fx") ? "api-free.deepl.com" : "api.deepl.com"; + const res = await requestWithRetry( + { + hostname: host, + path: "/v2/translate", + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Authorization: `DeepL-Auth-Key ${apiKey}`, }, - (res) => { - let data = ""; - res.on("data", (chunk) => (data += chunk)); - res.on("end", () => { - try { - const json = JSON.parse(data); - if (res.statusCode && res.statusCode >= 400) { - reject(new Error(json.message || `翻译接口错误 (${res.statusCode})`)); - return; - } - resolve(json.translations?.[0]?.text ?? text); - } catch { - reject(new Error("翻译接口返回异常")); - } - }); - } - ); - req.on("error", () => reject(new Error("翻译请求失败"))); - req.write(params.toString()); - req.end(); - }); -} \ No newline at end of file + }, + params.toString(), + ); + + if (res.statusCode && res.statusCode >= 400) { + logUnexpectedResponse("API 请求失败", res); + throw buildTranslateError(res.statusCode, res.body); + } + const json = parseTranslateJson(res.body); + const translated = json ? getPath(json, "translations.0.text") : undefined; + if (typeof translated === "string") { + return translated; + } + logUnexpectedResponse("API 响应结构异常", res); + throw new Error(`翻译接口返回异常:响应结构无法识别(${preview(res.body)})`); +}