DeepLX/translate/translate.go
Vincent Young 1fa6d7a2e3
feat(translate): migrate to oneshot endpoint to bypass www2 anti-bot
The www2.deepl.com/jsonrpc backends behind LMT_handle_texts /
LMT_handle_jobs now sit behind aggressive WAF + per-IP throttling that
returns HTTP 429 (code 1042911 "Too many requests") within a handful of
calls from any single host — making the free path effectively unusable.

The official DeepL browser extension and iOS app skip that backend
entirely for stateless single-shot translation and POST to a separate
"oneshot" endpoint on a different host pool with its own (much looser)
rate limit. It accepts anonymous traffic with a literal
`Authorization: None` header, returns plain JSON, and supports the same
language pairs.

Switch the free path to:

  POST https://oneshot-free.www.deepl.com/v1/translate
  Authorization: None
  {"text": ["..."], "target_lang": "de", "source_lang": "en"}

Pro users continue to hit oneshot-pro.www.deepl.com with their bearer
token (the `-s` flag now carries an OAuth access token rather than the
legacy dl_session cookie).

This removes:
  - the JSON-RPC envelope (jsonrpc/method/id/params/timestamp wrapper)
  - the `i`-count timestamp trick (getICount + getTimeStamp)
  - the random-id body-spacing trick (handlerBodyMethod)
  - the whatlanggo client-side detection (oneshot detects server-side)

The DeepLXTranslationResult contract is unchanged for service handlers;
Alternatives is now always nil because the oneshot endpoint does not
return alternative translations.

Verified against /translate, /v1/translate and /v2/translate routes
end-to-end (EN/DE/ZH/JA/FR pairs, multi-sentence input, autodetect, 10x
burst) — all 200 OK on an IP that was concurrently being 429'd by www2.
2026-05-22 11:34:55 +08:00

198 lines
6.2 KiB
Go

/*
* @Author: Vincent Young
* @Date: 2024-09-16 11:59:24
* @LastEditors: Vincent Yang
* @LastEditTime: 2026-05-22 00:00:00
* @FilePath: /DeepLX/translate/translate.go
* @Telegram: https://t.me/missuo
* @GitHub: https://github.com/missuo
*
* Copyright © 2024 by Vincent, All Rights Reserved.
*/
package translate
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"net/url"
"strings"
"time"
"github.com/imroc/req/v3"
"github.com/tidwall/gjson"
)
// DeepL's web frontend retired LMT_handle_jobs/LMT_handle_texts on www2.deepl.com
// for the interactive translator (now a SignalR/WebSocket channel). The browser
// extension and iOS app still use a stateless REST endpoint called "oneshot",
// which is what we target here. It accepts anonymous traffic with a literal
// `Authorization: None` header and lives on a separate rate-limit pool from
// the JSON-RPC backends, so it is far less prone to "Too many requests" 429s.
const (
oneshotFreeEndpoint = "https://oneshot-free.www.deepl.com/v1/translate"
oneshotProEndpoint = "https://oneshot-pro.www.deepl.com/v1/translate"
)
// oneshot uses lowercase, BCP-47-ish language codes (de, en-US, zh-Hans).
// Callers historically pass DeepL's uppercase codes (DE, EN, ZH) — translate
// them here. Unknown codes fall through lowercased so future additions still
// work without a code change.
var langCodeToOneshot = map[string]string{
"AR": "ar", "BG": "bg", "CS": "cs", "DA": "da", "DE": "de", "EL": "el",
"EN": "en-US", "EN-GB": "en-GB", "EN-US": "en-US",
"ES": "es", "ET": "et", "FI": "fi", "FR": "fr", "HU": "hu",
"ID": "id", "IT": "it", "JA": "ja", "KO": "ko", "LT": "lt", "LV": "lv",
"NB": "nb", "NL": "nl", "PL": "pl",
"PT": "pt-BR", "PT-BR": "pt-BR", "PT-PT": "pt-PT",
"RO": "ro", "RU": "ru", "SK": "sk", "SL": "sl", "SV": "sv",
"TR": "tr", "UK": "uk",
"ZH": "zh-Hans", "ZH-HANS": "zh-Hans", "ZH-HANT": "zh-Hant",
}
func toOneshotLang(code string) string {
if v, ok := langCodeToOneshot[strings.ToUpper(code)]; ok {
return v
}
return strings.ToLower(code)
}
// oneshotResponse is the JSON shape returned by /v1/translate.
type oneshotResponse struct {
Translations []struct {
DetectedSourceLanguage string `json:"detected_source_language"`
Text string `json:"text"`
} `json:"translations"`
}
// callOneshot POSTs the prepared body and returns the parsed JSON.
// `bearerToken` is empty for anonymous (free) requests, in which case the
// extension sends the literal string "None" — replicate that exactly, because
// omitting the header changes the server's auth-handling branch.
func callOneshot(endpoint string, body []byte, bearerToken, proxyURL string) (gjson.Result, int, error) {
client := req.C().ImpersonateChrome()
if proxyURL != "" {
proxy, err := url.Parse(proxyURL)
if err != nil {
return gjson.Result{}, 0, err
}
client.SetProxyURL(proxy.String())
}
authValue := "None"
if bearerToken != "" {
authValue = "Bearer " + bearerToken
}
resp, err := client.R().
SetHeader("Content-Type", "application/json").
SetHeader("Accept", "*/*").
SetHeader("Authorization", authValue).
SetHeader("Origin", "https://www.deepl.com").
SetHeader("Referer", "https://www.deepl.com/").
SetHeader("Sec-Fetch-Site", "same-site").
SetHeader("Sec-Fetch-Mode", "cors").
SetHeader("Sec-Fetch-Dest", "empty").
SetBody(bytes.NewReader(body)).
Post(endpoint)
if err != nil {
return gjson.Result{}, 0, err
}
raw, err := resp.ToBytes()
if err != nil {
return gjson.Result{}, resp.StatusCode, fmt.Errorf("failed to read response body: %w", err)
}
return gjson.ParseBytes(raw), resp.StatusCode, nil
}
// TranslateByDeepLX performs translation via DeepL's oneshot endpoint.
// Passing dlSession switches to the Pro endpoint; the value is sent verbatim
// as the Bearer token, so callers must supply an OAuth access token (not the
// legacy `dl_session` cookie) when using Pro.
func TranslateByDeepLX(sourceLang, targetLang, text string, tagHandling string, proxyURL string, dlSession string) (DeepLXTranslationResult, error) {
if text == "" {
return DeepLXTranslationResult{
Code: http.StatusNotFound,
Message: "No text to translate",
}, nil
}
reqBody := map[string]any{
"text": []string{text},
"target_lang": toOneshotLang(targetLang),
}
if sourceLang != "" && !strings.EqualFold(sourceLang, "auto") {
reqBody["source_lang"] = toOneshotLang(sourceLang)
}
bodyBytes, _ := json.Marshal(reqBody)
endpoint := oneshotFreeEndpoint
if dlSession != "" {
endpoint = oneshotProEndpoint
}
id := time.Now().UnixMilli()
result, status, err := callOneshot(endpoint, bodyBytes, dlSession, proxyURL)
if err != nil {
return DeepLXTranslationResult{
ID: id,
Code: http.StatusServiceUnavailable,
Message: err.Error(),
}, nil
}
switch status {
case http.StatusOK:
// fall through to body parsing
case http.StatusTooManyRequests:
return DeepLXTranslationResult{
ID: id,
Code: http.StatusTooManyRequests,
Message: "too many requests, your IP has been blocked by DeepL temporarily, please don't request it frequently in a short time",
}, nil
default:
return DeepLXTranslationResult{
ID: id,
Code: http.StatusServiceUnavailable,
Message: fmt.Sprintf("request failed with status code: %d", status),
}, nil
}
translations := result.Get("translations").Array()
if len(translations) == 0 {
return DeepLXTranslationResult{
ID: id,
Code: http.StatusServiceUnavailable,
Message: "Translation failed",
}, nil
}
mainText := translations[0].Get("text").String()
if mainText == "" {
return DeepLXTranslationResult{
ID: id,
Code: http.StatusServiceUnavailable,
Message: "Translation failed",
}, nil
}
detected := translations[0].Get("detected_source_language").String()
if detected != "" {
// Normalize back to DeepL-style uppercase for response continuity.
sourceLang = strings.ToUpper(detected)
}
return DeepLXTranslationResult{
Code: http.StatusOK,
ID: id,
Data: mainText,
Alternatives: nil, // oneshot does not return alternatives
SourceLang: sourceLang,
TargetLang: targetLang,
Method: map[bool]string{true: "Pro", false: "Free"}[dlSession != ""],
}, nil
}