505 lines
16 KiB
TypeScript
505 lines
16 KiB
TypeScript
/**
|
|
* Core translation query functionality for DeepLX API
|
|
* Handles communication with DeepL API endpoints with retry logic and rate limiting
|
|
*/
|
|
|
|
import {
|
|
DEFAULT_RETRY_CONFIG,
|
|
PAYLOAD_LIMITS,
|
|
REQUEST_TIMEOUT,
|
|
} from "./config";
|
|
import { API_URL, REQUEST_ALTERNATIVES } from "./const";
|
|
import { createErrorResponse } from "./errorHandler";
|
|
import { generateBrowserFingerprint, selectProxy } from "./proxyManager";
|
|
import { checkCombinedRateLimit } from "./rateLimit";
|
|
import { isRetryableError, RetryOptions, retryWithBackoff } from "./retryLogic";
|
|
import {
|
|
Config,
|
|
createStandardResponse,
|
|
RawResponseParams,
|
|
RequestParams,
|
|
ResponseParams,
|
|
} from "./types";
|
|
|
|
/**
|
|
* Ensure language code is in uppercase format for consistent output
|
|
* @param langCode - Language code to convert
|
|
* @returns Language code in uppercase
|
|
* @private
|
|
*/
|
|
function ensureUppercaseLanguageCode(langCode: string): string {
|
|
if (!langCode || langCode.toLowerCase() === "auto") {
|
|
return "auto";
|
|
}
|
|
return langCode.toUpperCase();
|
|
}
|
|
|
|
/**
|
|
* Normalize language code to ensure compatibility with DeepL API
|
|
* Accepts both lowercase and uppercase input, always returns uppercase
|
|
* @param langCode - Language code to normalize
|
|
* @returns Normalized language code in uppercase format for DeepL API
|
|
* @private
|
|
*/
|
|
function normalizeLanguageCode(langCode: string): string {
|
|
if (!langCode || langCode.toLowerCase() === "auto") {
|
|
return "auto";
|
|
}
|
|
|
|
const normalized = langCode.toLowerCase();
|
|
|
|
// Map common language variations to DeepL-supported codes
|
|
const languageMap: Record<string, string> = {
|
|
chinese: "ZH",
|
|
english: "EN",
|
|
spanish: "ES",
|
|
french: "FR",
|
|
german: "DE",
|
|
italian: "IT",
|
|
japanese: "JA",
|
|
portuguese: "PT",
|
|
russian: "RU",
|
|
dutch: "NL",
|
|
polish: "PL",
|
|
swedish: "SV",
|
|
danish: "DA",
|
|
norwegian: "NB",
|
|
finnish: "FI",
|
|
czech: "CS",
|
|
slovak: "SK",
|
|
slovenian: "SL",
|
|
estonian: "ET",
|
|
latvian: "LV",
|
|
lithuanian: "LT",
|
|
hungarian: "HU",
|
|
romanian: "RO",
|
|
bulgarian: "BG",
|
|
greek: "EL",
|
|
turkish: "TR",
|
|
ukrainian: "UK",
|
|
korean: "KO",
|
|
indonesian: "ID",
|
|
};
|
|
|
|
// Check if it's a mapped language name
|
|
if (languageMap[normalized]) {
|
|
return languageMap[normalized]; // Return uppercase for DeepL API
|
|
}
|
|
|
|
// Convert to uppercase for DeepL API
|
|
return normalized.toUpperCase();
|
|
}
|
|
|
|
/**
|
|
* Build request parameters for DeepL API
|
|
* Creates the structured request object with language settings
|
|
* @param sourceLang - Source language code (defaults to "auto")
|
|
* @param targetLang - Target language code (defaults to "en")
|
|
* @returns Structured request parameters object
|
|
* @private
|
|
*/
|
|
|
|
function buildRequestParams(sourceLang = "auto", targetLang = "en") {
|
|
// Generate a more reliable ID using a simpler approach
|
|
const timestamp = Date.now();
|
|
const randomComponent = Math.floor(Math.random() * 1000000);
|
|
// Ensure ID is within a safe range (8-9 digits)
|
|
const requestId = Math.floor(
|
|
(timestamp % 100000000) + (randomComponent % 100000000)
|
|
);
|
|
|
|
// Normalize language codes
|
|
const normalizedSourceLang = normalizeLanguageCode(sourceLang || "auto");
|
|
const normalizedTargetLang = normalizeLanguageCode(targetLang || "en");
|
|
|
|
return {
|
|
jsonrpc: "2.0",
|
|
method: "LMT_handle_texts",
|
|
id: requestId,
|
|
params: {
|
|
texts: [{ text: "", requestAlternatives: REQUEST_ALTERNATIVES }],
|
|
timestamp: 0,
|
|
splitting: "newlines",
|
|
lang: {
|
|
source_lang_user_selected: normalizedSourceLang,
|
|
target_lang: normalizedTargetLang,
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Count occurrences of letter 'i' in text
|
|
* Used for generating request timestamps
|
|
* @param translateText - The text to analyze
|
|
* @returns Number of 'i' characters in the text
|
|
* @private
|
|
*/
|
|
function countLetterI(translateText: string) {
|
|
return (translateText || "").split("i").length - 1;
|
|
}
|
|
|
|
/**
|
|
* Generate timestamp based on letter count with improved stability
|
|
* Creates a modified timestamp for request uniqueness that follows DeepL's expected format
|
|
* @param letterCount Count of specific letters in text
|
|
* @returns Modified timestamp value
|
|
* @private
|
|
*/
|
|
function getTimestamp(letterCount: number) {
|
|
const timestamp = Date.now();
|
|
|
|
// Ensure we have a valid letter count
|
|
const safeLetterCount = Math.max(0, letterCount || 0);
|
|
|
|
// If no letter count, return current timestamp
|
|
if (safeLetterCount === 0) {
|
|
return timestamp;
|
|
}
|
|
|
|
// Apply DeepL's timestamp modification formula more carefully
|
|
const modValue = safeLetterCount + 1;
|
|
|
|
// Ensure modValue is reasonable to prevent overflow
|
|
if (modValue <= 0 || modValue > 1000) {
|
|
return timestamp;
|
|
}
|
|
|
|
try {
|
|
const modifiedTimestamp = timestamp - (timestamp % modValue) + modValue;
|
|
|
|
// Validate the result is reasonable
|
|
if (
|
|
modifiedTimestamp > 0 &&
|
|
modifiedTimestamp <= Number.MAX_SAFE_INTEGER &&
|
|
modifiedTimestamp >= timestamp - 1000
|
|
) {
|
|
// Should be close to original timestamp
|
|
return modifiedTimestamp;
|
|
}
|
|
} catch (error) {
|
|
// If any calculation fails, return original timestamp
|
|
console.warn("Timestamp calculation failed, using original:", error);
|
|
}
|
|
|
|
return timestamp;
|
|
}
|
|
|
|
/**
|
|
* Build complete request body for DeepL API with enhanced validation
|
|
* Assembles the full request with text, parameters, and timestamp
|
|
* @param data Request parameters containing text and languages
|
|
* @returns JSON string ready for API submission
|
|
* @private
|
|
*/
|
|
function buildRequestBody(data: RequestParams) {
|
|
// Validate input parameters
|
|
if (!data || !data.text || typeof data.text !== "string") {
|
|
throw new Error(
|
|
"Invalid request parameters: text is required and must be a string"
|
|
);
|
|
}
|
|
|
|
// Ensure text is not empty
|
|
const requestText = data.text;
|
|
if (!requestText.trim()) {
|
|
throw new Error("Invalid request parameters: text cannot be empty");
|
|
}
|
|
|
|
// Apply more conservative text length limits to prevent 413 errors
|
|
const maxTextLength = PAYLOAD_LIMITS.MAX_TEXT_LENGTH;
|
|
if (requestText.length > maxTextLength) {
|
|
throw new Error(
|
|
`Text too long. Maximum length is ${maxTextLength} characters to prevent payload size errors.`
|
|
);
|
|
}
|
|
|
|
// Validate and normalize language codes before building request
|
|
const sourceLang = data.source_lang || "auto";
|
|
const targetLang = data.target_lang || "en";
|
|
|
|
// Log language normalization for debugging (only in debug mode)
|
|
const debugGlobal = globalThis as typeof globalThis & {
|
|
DEBUG_MODE?: boolean;
|
|
};
|
|
if (typeof globalThis !== "undefined" && debugGlobal.DEBUG_MODE) {
|
|
if (sourceLang !== "auto") {
|
|
console.debug(`Source language normalized: ${sourceLang}`);
|
|
}
|
|
console.debug(`Target language normalized: ${targetLang}`);
|
|
}
|
|
|
|
const requestData = buildRequestParams(sourceLang, targetLang);
|
|
requestData.params.texts = [
|
|
{ text: requestText, requestAlternatives: REQUEST_ALTERNATIVES },
|
|
];
|
|
|
|
const letterICount = countLetterI(requestText);
|
|
const timestamp = getTimestamp(letterICount);
|
|
|
|
// Validate timestamp before assigning
|
|
if (timestamp <= 0 || !Number.isFinite(timestamp)) {
|
|
throw new Error("Invalid timestamp generated");
|
|
}
|
|
|
|
requestData.params.timestamp = timestamp;
|
|
|
|
// Build the request string with proper formatting
|
|
let requestString = JSON.stringify(requestData);
|
|
|
|
// Apply DeepL's specific formatting requirements
|
|
const requestId = requestData.id;
|
|
|
|
// Use DeepL's method spacing logic - simplified and more reliable
|
|
if ((requestId + 5) % 29 === 0 || (requestId + 3) % 13 === 0) {
|
|
requestString = requestString.replace('"method":"', '"method" : "');
|
|
} else {
|
|
requestString = requestString.replace('"method":"', '"method": "');
|
|
}
|
|
|
|
// Validate final payload size to prevent 413 errors
|
|
const payloadSize = new TextEncoder().encode(requestString).length;
|
|
if (payloadSize > PAYLOAD_LIMITS.MAX_REQUEST_SIZE) {
|
|
throw new Error(
|
|
`Request payload too large (${payloadSize} bytes). Maximum allowed is ${PAYLOAD_LIMITS.MAX_REQUEST_SIZE} bytes. Please reduce text length.`
|
|
);
|
|
}
|
|
|
|
return requestString;
|
|
}
|
|
|
|
/**
|
|
* Main translation query function
|
|
* Handles the complete translation workflow with retry logic, rate limiting, and error handling
|
|
* @param params Translation request parameters (text, source language, target language)
|
|
* @param config Optional configuration including proxy settings and environment
|
|
* @returns Promise<ResponseParams> - Standardized response with translation result
|
|
*/
|
|
async function query(
|
|
params: RequestParams,
|
|
config?: Config & { env?: any }
|
|
): Promise<ResponseParams> {
|
|
if (!params?.text) {
|
|
return createStandardResponse(
|
|
400,
|
|
null,
|
|
undefined,
|
|
normalizeLanguageCode(params?.source_lang || "auto"),
|
|
normalizeLanguageCode(params?.target_lang || "en")
|
|
);
|
|
}
|
|
|
|
const retryOptions: RetryOptions = {
|
|
...DEFAULT_RETRY_CONFIG,
|
|
isRetryable: isRetryableError,
|
|
};
|
|
|
|
try {
|
|
return await retryWithBackoff(async () => {
|
|
const proxy = config?.env ? await selectProxy(config.env) : null;
|
|
const endpoint = config?.proxyEndpoint ?? proxy?.url ?? API_URL;
|
|
|
|
// Use comprehensive rate limit check - includes client and proxy backend limits
|
|
if (config?.env) {
|
|
const clientIP = config?.clientIP || "unknown";
|
|
const rateLimitResult = await checkCombinedRateLimit(
|
|
clientIP,
|
|
endpoint,
|
|
config.env
|
|
);
|
|
if (!rateLimitResult.allowed) {
|
|
const error = new Error(
|
|
rateLimitResult.reason || "Rate limit exceeded"
|
|
);
|
|
(error as any).code = 429;
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
const fingerprint = generateBrowserFingerprint();
|
|
|
|
const makeRequest = async () => {
|
|
const controller = new AbortController();
|
|
const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT);
|
|
|
|
try {
|
|
const requestBody = buildRequestBody(params);
|
|
|
|
const response = await fetch(endpoint, {
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
Accept: "*/*",
|
|
"Accept-Language": "en-US,en;q=0.9",
|
|
"Accept-Encoding": "gzip, deflate, br, zstd",
|
|
Origin: "https://www.deepl.com",
|
|
Connection: "keep-alive",
|
|
Referer: "https://www.deepl.com/",
|
|
"Sec-Fetch-Dest": "empty",
|
|
"Sec-Fetch-Mode": "cors",
|
|
"Sec-Fetch-Site": "same-site",
|
|
"User-Agent":
|
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36 Edg/141.0.0.0",
|
|
// ...fingerprint,
|
|
...config?.customHeader,
|
|
},
|
|
method: "POST",
|
|
body: requestBody,
|
|
signal: controller.signal,
|
|
});
|
|
|
|
clearTimeout(timeoutId);
|
|
|
|
// If we get a 400 error, log the request body for debugging
|
|
if (!response.ok && response.status === 400) {
|
|
console.error(`400 error received. Request body was:`, requestBody);
|
|
}
|
|
|
|
// Check for rate limit error specifically
|
|
if (!response.ok && response.status === 429) {
|
|
const error = new Error(
|
|
"Too many requests, your IP has been blocked by DeepL temporarily, please don't request it frequently in a short time"
|
|
);
|
|
(error as any).status = 429;
|
|
throw error;
|
|
}
|
|
|
|
// Check for other error status codes (general non-200 check)
|
|
if (!response.ok) {
|
|
let errorMessage = `Request failed with status code: ${response.status}`;
|
|
try {
|
|
const errorText = await response.text();
|
|
if (errorText) {
|
|
errorMessage += ` - ${errorText}`;
|
|
}
|
|
} catch {
|
|
// Ignore error when reading response body
|
|
}
|
|
|
|
const error = new Error(errorMessage);
|
|
(error as any).status = response.status;
|
|
throw error;
|
|
}
|
|
|
|
return response;
|
|
} catch (error) {
|
|
clearTimeout(timeoutId);
|
|
if (error instanceof Error && error.name === "AbortError") {
|
|
const timeoutError = new Error(
|
|
`Request timeout after ${
|
|
REQUEST_TIMEOUT / 1000
|
|
} seconds to ${endpoint}`
|
|
);
|
|
(timeoutError as any).status = 408;
|
|
throw timeoutError;
|
|
}
|
|
throw error;
|
|
}
|
|
};
|
|
|
|
const response = await makeRequest();
|
|
|
|
if (!response.ok) {
|
|
let errorMessage = `Request failed with status ${response.status}`;
|
|
try {
|
|
const errorText = await response.text();
|
|
if (errorText) {
|
|
errorMessage += `: ${errorText}`;
|
|
}
|
|
} catch {
|
|
// Ignore error when reading response body
|
|
}
|
|
|
|
const error = new Error(errorMessage);
|
|
(error as any).status = response.status;
|
|
throw error;
|
|
}
|
|
|
|
let result: RawResponseParams;
|
|
try {
|
|
result = (await response.json()) as RawResponseParams;
|
|
} catch (parseError) {
|
|
const error = new Error(
|
|
`Failed to parse JSON response from ${endpoint}: ${
|
|
parseError instanceof Error
|
|
? parseError.message
|
|
: String(parseError)
|
|
}`
|
|
);
|
|
(error as any).code = 500;
|
|
throw error;
|
|
}
|
|
if ("error" in result && result.error) {
|
|
const errorCode = (result.error as any).code;
|
|
const errorMessage =
|
|
(result.error as any).message || "Unknown DeepL API error";
|
|
|
|
// Handle specific DeepL error codes
|
|
let enhancedMessage = errorMessage;
|
|
|
|
switch (errorCode) {
|
|
case 1156049:
|
|
enhancedMessage =
|
|
"Invalid request format detected. This may be caused by: " +
|
|
"1) Incorrect JSON-RPC structure, " +
|
|
"2) Invalid request ID format, " +
|
|
"3) Malformed timestamp, or " +
|
|
"4) Unsupported language codes. " +
|
|
"Please verify your request parameters and try again.";
|
|
break;
|
|
case 1042912:
|
|
enhancedMessage = "Too many requests. Please try again later.";
|
|
break;
|
|
case 1042513:
|
|
enhancedMessage = "Request quota exceeded. Please try again later.";
|
|
break;
|
|
case 1042003:
|
|
enhancedMessage =
|
|
"Invalid authentication. Please check your API configuration.";
|
|
break;
|
|
default:
|
|
enhancedMessage = `DeepL API error: ${errorMessage} (Code: ${errorCode})`;
|
|
}
|
|
|
|
const error = new Error(enhancedMessage);
|
|
(error as any).code = errorCode;
|
|
(error as any).originalMessage = errorMessage;
|
|
throw error;
|
|
}
|
|
|
|
if (
|
|
!result.result ||
|
|
!result.result.texts ||
|
|
!result.result.texts.length
|
|
) {
|
|
const error = new Error("Invalid response structure from DeepL API");
|
|
(error as any).code = 500;
|
|
throw error;
|
|
}
|
|
|
|
const translatedText = result.result.texts[0].text;
|
|
|
|
return createStandardResponse(
|
|
200,
|
|
translatedText,
|
|
result.id,
|
|
(result.result.lang as string).toUpperCase(),
|
|
normalizeLanguageCode(params.target_lang)
|
|
);
|
|
}, retryOptions);
|
|
} catch (error: any) {
|
|
const errorDetails = createErrorResponse(error, {
|
|
endpoint: config?.proxyEndpoint,
|
|
});
|
|
|
|
return createStandardResponse(
|
|
errorDetails.httpStatus,
|
|
null,
|
|
undefined,
|
|
normalizeLanguageCode(params.source_lang || "auto"),
|
|
normalizeLanguageCode(params.target_lang || "en")
|
|
);
|
|
}
|
|
}
|
|
|
|
export { buildRequestBody, normalizeLanguageCode, query };
|