DeepLX/translate/translate.go
Vincent Young 5cd1667f7f
fix(translate): report iOS 27.0 in oneshot client profile
Pin app_information.os_version, x-app-os-version, and User-Agent to
iOS 27.0 for a consistent iOS client fingerprint.
2026-08-04 20:29:38 +08:00

510 lines
17 KiB
Go

/*
* @Author: Vincent Young
* @Date: 2024-09-16 11:59:24
* @LastEditors: Vincent Yang
* @LastEditTime: 2026-08-04 00:00:00
* @FilePath: /DLX/translate/translate.go
* @Telegram: https://t.me/missuo
* @GitHub: https://github.com/missuo
*
* Copyright © 2024 by Vincent, All Rights Reserved.
*/
package translate
import (
"compress/flate"
"compress/gzip"
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/http/cookiejar"
"net/url"
"sort"
"strings"
"sync"
"time"
"unicode/utf8"
"github.com/andybalholm/brotli"
"github.com/imroc/req/v3"
"github.com/tidwall/gjson"
)
// DeepL's interactive clients (web, Chrome extension, and the official iOS
// app) all share the same stateless "oneshot" translate endpoint. The
// legacy LMT_handle_texts backend on www2.deepl.com rate-limits anonymous
// traffic hard; oneshot lives on a separate pool and accepts the literal
// header `Authorization: None` for free requests.
//
// Request shape below is reverse-engineered from DeepL iOS 26.42
// (build 5443737, bundle com.linguee.DeepLMobileTranslator):
// - Free URL → https://oneshot-free.www.deepl.com/v1/translate
// - Pro URL → https://oneshot-pro.www.deepl.com/v1/translate
// (iOS also constructs https://oneshot. + .pro.deepl.com/v1/translate)
// - Body → OneShotTranslator + ItaClient.AppInformation
// - Headers → ClientInfos.appHeaders (x-app-*) + Authorization
// - usage_type → ItaClient.OneShotUsageType.translate
const (
oneshotFreeEndpoint = "https://oneshot-free.www.deepl.com/v1/translate"
oneshotProEndpoint = "https://oneshot-pro.www.deepl.com/v1/translate"
// Pinned to DeepL iOS IPA (Info.plist CFBundleShortVersionString /
// CFBundleVersion). Keep app_information in lockstep with the TLS
// fingerprint and User-Agent so the request tells one consistent story.
iosAppVersion = "26.42"
iosAppBuild = "5443737"
iosBundleID = "com.linguee.DeepLMobileTranslator"
// Stable OS version reported in app_information + x-app-os-version.
// HelloIOS_Auto does not pin a specific iOS minor; pin a fixed value
// so UA / x-app-os-version / app_information stay consistent.
iosOSVersion = "27.0"
// oneshot enforces a 1500-character hard cap on the total length of
// the `text` array for anonymous traffic (same limit the Chrome
// extension documents as G.notLoggedIn). Bail early to spare the
// upstream and give the caller a faster error.
maxFreeTextLength = 1500
// oneshotTimeout caps how long we wait on a single translate request.
oneshotTimeout = 20 * time.Second
// warmupTimeout caps the initial GET to www.deepl.com that seeds the
// cookie jar. Cookies are best-effort; skip a slow warmup rather than
// block the first translation.
warmupTimeout = 5 * time.Second
)
// instanceID mirrors the UUID the iOS app persists for analytics /
// app_information.instance_id and x-app-instance-id: stable for the life
// of the process, reused on every request. Rotating per-request is a
// stronger bot signal than reusing one.
var instanceID = newInstanceID()
// sessionID is sent as x-app-session-id (ClientInfos.appHeaders). Stable
// for the process lifetime, independent of instanceID.
var sessionID = newInstanceID()
// A real iOS URLSession inherits whatever cookies the app has on
// .deepl.com. A cold visit to www.deepl.com sets userCountry=<iso2> and
// verifiedBot=false. Share a process-wide jar so every oneshot POST
// carries whatever the warmup GET picked up.
var (
cookieJar http.CookieJar
cookieJarOnce sync.Once
cookieWarmer sync.Once
)
// oneshotClients caches one req.Client per proxy URL so all translate
// calls share the underlying TCP / TLS / HTTP/2 connection pool.
var oneshotClients sync.Map // map[string]*req.Client
func sharedCookieJar() http.CookieJar {
cookieJarOnce.Do(func() {
j, _ := cookiejar.New(nil)
cookieJar = j
})
return cookieJar
}
// warmCookies primes the shared jar by GETting www.deepl.com once.
// The Set-Cookie response lands on .deepl.com (eTLD+1 of oneshot-free),
// so subsequent POSTs carry those cookies automatically.
func warmCookies(client *req.Client) {
cookieWarmer.Do(func() {
ctx, cancel := context.WithTimeout(context.Background(), warmupTimeout)
defer cancel()
_, _ = client.R().SetContext(ctx).Get("https://www.deepl.com/translator")
})
}
func newInstanceID() string {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return "00000000-0000-4000-8000-000000000000"
}
b[6] = (b[6] & 0x0f) | 0x40 // RFC 4122 v4
b[8] = (b[8] & 0x3f) | 0x80
s := hex.EncodeToString(b)
return fmt.Sprintf("%s-%s-%s-%s-%s", s[0:8], s[8:12], s[12:16], s[16:20], s[20:32])
}
// Language code tables mirror ItaClient.OutputLanguage / InputLanguage
// (regional cases enUs, enGb, frCa, deCh, ptPt, ptBr, es419, zhHant, …)
// plus the full target-capable set the oneshot endpoint accepts.
//
// Keys are the uppercase forms callers pass; values are the lowercase
// BCP-47-ish forms oneshot expects ("de", "en-US", "zh-Hans", ...).
//
// EN and PT are intentionally absent as bare target codes — DeepL
// deprecated them in favour of EN-US/EN-GB and PT-BR/PT-PT. We accept
// EN/PT as a backward-compat convenience and resolve them to the
// regional default (en-US, pt-BR).
var targetLangMap = map[string]string{
"AR": "ar", "BG": "bg", "CS": "cs", "DA": "da", "DE": "de", "DE-CH": "de-CH",
"EL": "el",
"EN-GB": "en-GB", "EN-US": "en-US",
"ES": "es", "ES-419": "es-419", "ET": "et", "FI": "fi", "FR": "fr", "FR-CA": "fr-CA",
"HE": "he", "HU": "hu", "ID": "id", "IT": "it", "JA": "ja", "KO": "ko",
"LT": "lt", "LV": "lv", "NB": "nb", "NL": "nl", "PL": "pl",
"PT-BR": "pt-BR", "PT-PT": "pt-PT",
"RO": "ro", "RU": "ru", "SK": "sk", "SL": "sl", "SV": "sv",
"TR": "tr", "UK": "uk", "VI": "vi",
"ZH": "zh-Hans", "ZH-HANS": "zh-Hans", "ZH-HANT": "zh-Hant",
// Convenience aliases for legacy callers.
"EN": "en-US",
"PT": "pt-BR",
}
// sourceLangMap is what the API accepts as `source_lang`. It is a
// superset of targetLangMap: EN and PT are first-class source codes
// mapping to the generic "en"/"pt".
var sourceLangMap = func() map[string]string {
m := make(map[string]string, len(targetLangMap)+2)
for k, v := range targetLangMap {
m[k] = v
}
m["EN"] = "en"
m["PT"] = "pt"
return m
}()
// resolveTargetLang validates and normalizes a user-supplied target
// language code. Returns "" and a non-nil error if the code is empty,
// "auto", or otherwise not in the supported set.
func resolveTargetLang(code string) (string, error) {
if code == "" {
return "", fmt.Errorf("target_lang is required")
}
if strings.EqualFold(code, "auto") {
return "", fmt.Errorf("target_lang cannot be \"auto\"; pick one of: %s", supportedTargetLangsList())
}
if v, ok := targetLangMap[strings.ToUpper(code)]; ok {
return v, nil
}
return "", fmt.Errorf("unsupported target_lang %q; valid codes: %s", code, supportedTargetLangsList())
}
// resolveSourceLang validates and normalizes a user-supplied source
// language code. An empty string or "auto" is allowed and returns
// ("", nil) so the caller omits source_lang and lets the server
// autodetect.
func resolveSourceLang(code string) (string, error) {
if code == "" || strings.EqualFold(code, "auto") {
return "", nil
}
if v, ok := sourceLangMap[strings.ToUpper(code)]; ok {
return v, nil
}
return "", fmt.Errorf("unsupported source_lang %q; valid codes: %s (or \"auto\")", code, supportedSourceLangsList())
}
// supportedTargetLangsList / supportedSourceLangsList return a sorted,
// comma-separated rendering of the supported codes for use in error
// messages. Cached at first call.
var (
targetLangsListOnce sync.Once
targetLangsList string
sourceLangsListOnce sync.Once
sourceLangsList string
)
func supportedTargetLangsList() string {
targetLangsListOnce.Do(func() {
targetLangsList = sortedKeys(targetLangMap)
})
return targetLangsList
}
func supportedSourceLangsList() string {
sourceLangsListOnce.Do(func() {
sourceLangsList = sortedKeys(sourceLangMap)
})
return sourceLangsList
}
func sortedKeys(m map[string]string) string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
return strings.Join(keys, ", ")
}
// appInformation matches ItaClient.AppInformation (os, os_version,
// app_version, app_build, instance_id) as serialized by the iOS client.
type appInformation struct {
OS string `json:"os"`
OSVersion string `json:"os_version"`
AppVersion string `json:"app_version"`
AppBuild string `json:"app_build"`
InstanceID string `json:"instance_id"`
}
// oneshotRequest mirrors the body assembled by the iOS OneShotTranslator
// / ItaClient oneshot path. Field order matches the app's serialization
// so the JSON is byte-stable (encoding/json honours struct field order).
type oneshotRequest struct {
Text []string `json:"text"`
TargetLang string `json:"target_lang"`
SourceLang string `json:"source_lang,omitempty"`
UsageType string `json:"usage_type"`
AppInformation appInformation `json:"app_information"`
}
// getOneshotClient returns a process-wide cached client for the given
// proxy URL, creating it on first use. Sharing the client across
// requests keeps the TLS / HTTP/2 connection in the pool.
func getOneshotClient(proxyURL string) (*req.Client, error) {
if c, ok := oneshotClients.Load(proxyURL); ok {
return c.(*req.Client), nil
}
c, err := newOneshotClient(proxyURL)
if err != nil {
return nil, err
}
if actual, loaded := oneshotClients.LoadOrStore(proxyURL, c); loaded {
return actual.(*req.Client), nil
}
go warmCookies(c)
return c, nil
}
func newOneshotClient(proxyURL string) (*req.Client, error) {
// iOS TLS ClientHello via utls HelloIOS_Auto. Headers are set per
// request in callOneshot to match ClientInfos.appHeaders rather than
// a browser navigation profile.
client := req.C().
SetTLSFingerprintIOS().
SetCookieJar(sharedCookieJar()).
SetTimeout(oneshotTimeout).
SetUserAgent(iosUserAgent()).
SetCommonHeader("Accept-Encoding", "gzip, deflate, br").
SetCommonHeader("Accept", "*/*").
SetCommonHeader("Accept-Language", "en-US,en;q=0.9")
if proxyURL != "" {
u, err := url.Parse(proxyURL)
if err != nil {
return nil, err
}
client.SetProxyURL(u.String())
}
return client, nil
}
// iosUserAgent approximates the CFNetwork-style UA the DeepL iOS app
// advertises via ClientInfos.userAgent.
func iosUserAgent() string {
return fmt.Sprintf(
"DeepL/%s (%s; build:%s; iOS %s)",
iosAppVersion, iosBundleID, iosAppBuild, iosOSVersion,
)
}
// callOneshot POSTs to the oneshot endpoint and returns the parsed JSON.
// For anonymous traffic bearerToken is empty and we send the literal
// header `Authorization: None` — matching ItaClient.LoginNone. Omitting
// that header puts the request on a different server-side auth branch.
func callOneshot(endpoint string, body []byte, bearerToken, proxyURL string) (gjson.Result, int, error) {
client, err := getOneshotClient(proxyURL)
if err != nil {
return gjson.Result{}, 0, err
}
authValue := "None"
if bearerToken != "" {
authValue = "Bearer " + bearerToken
}
resp, err := client.R().
DisableAutoReadResponse().
SetHeader("Content-Type", "application/json").
SetHeader("Authorization", authValue).
// ClientInfos.appHeaders from DeepL iOS (Util/ClientInfos.swift).
SetHeader("x-app-os-version", iosOSVersion).
SetHeader("x-app-instance-id", instanceID).
SetHeader("x-app-session-id", sessionID).
SetBodyBytes(body). // pins Content-Length; an io.Reader would
// force Transfer-Encoding: chunked, which URLSession JSON bodies
// never emit.
Post(endpoint)
if err != nil {
return gjson.Result{}, 0, err
}
defer resp.Body.Close()
// Once we set Accept-Encoding ourselves, Go's HTTP stack stops
// transparently decompressing, so handle gzip/deflate/br by hand.
var reader io.Reader = resp.Body
switch strings.ToLower(resp.Header.Get("Content-Encoding")) {
case "gzip":
gr, err := gzip.NewReader(resp.Body)
if err != nil {
return gjson.Result{}, resp.StatusCode, fmt.Errorf("gzip reader: %w", err)
}
defer gr.Close()
reader = gr
case "deflate":
reader = flate.NewReader(resp.Body)
case "br":
reader = brotli.NewReader(resp.Body)
}
raw, err := io.ReadAll(reader)
if err != nil {
return gjson.Result{}, resp.StatusCode, fmt.Errorf("read response body: %w", err)
}
return gjson.ParseBytes(raw), resp.StatusCode, nil
}
// TranslateByDLX performs translation via the DeepL oneshot endpoint.
// Passing dlSession switches to the Pro endpoint; the value is sent
// verbatim as the Bearer token (i.e. it must be an OAuth access token,
// not the legacy dl_session cookie).
func TranslateByDLX(sourceLang, targetLang, text string, tagHandling string, proxyURL string, dlSession string) (DLXTranslationResult, error) {
if text == "" {
return DLXTranslationResult{
Code: http.StatusNotFound,
Message: "No text to translate",
}, nil
}
resolvedTarget, err := resolveTargetLang(targetLang)
if err != nil {
return DLXTranslationResult{
Code: http.StatusBadRequest,
Message: err.Error(),
}, nil
}
resolvedSource, err := resolveSourceLang(sourceLang)
if err != nil {
return DLXTranslationResult{
Code: http.StatusBadRequest,
Message: err.Error(),
}, nil
}
if n := utf8.RuneCountInString(text); n > maxFreeTextLength {
return DLXTranslationResult{
Code: http.StatusRequestEntityTooLarge,
Message: fmt.Sprintf("text exceeds maximum length: %d characters (anonymous oneshot limit is %d)", n, maxFreeTextLength),
}, nil
}
// tagHandling is accepted by the public DLX API for compatibility
// but oneshot does not expose html/xml tag handling the way the
// official v2 API does — ignored upstream.
_ = tagHandling
reqStruct := oneshotRequest{
Text: []string{text},
TargetLang: resolvedTarget,
SourceLang: resolvedSource, // empty = autodetect; omitempty drops the field
// ItaClient.OneShotUsageType.translate (also: ocr, voiceforconversations)
UsageType: "translate",
AppInformation: appInformation{
OS: "iOS",
OSVersion: iosOSVersion,
AppVersion: iosAppVersion,
AppBuild: iosAppBuild,
InstanceID: instanceID,
},
}
bodyBytes, _ := json.Marshal(reqStruct)
endpoint := oneshotFreeEndpoint
if dlSession != "" {
endpoint = oneshotProEndpoint
}
id := time.Now().UnixMilli()
result, status, err := callOneshot(endpoint, bodyBytes, dlSession, proxyURL)
if err != nil {
// Map upstream timeouts to 504 so callers can distinguish "DeepL
// took too long" from other 503 failure modes (DNS, TLS, etc.).
var ue *url.Error
if errors.Is(err, context.DeadlineExceeded) || (errors.As(err, &ue) && ue.Timeout()) {
return DLXTranslationResult{
ID: id,
Code: http.StatusGatewayTimeout,
Message: fmt.Sprintf("upstream DeepL request timed out after %s", oneshotTimeout),
}, nil
}
return DLXTranslationResult{
ID: id,
Code: http.StatusServiceUnavailable,
Message: err.Error(),
}, nil
}
switch status {
case http.StatusOK:
// fall through to body parsing
case http.StatusTooManyRequests:
return DLXTranslationResult{
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
case http.StatusForbidden:
// iOS surfaces this as OneShot: Forbidden / AuthenticationFailed /
// OutdatedClient / UserBlocked depending on body; collapse to 403.
msg := result.Get("title").String()
if msg == "" {
msg = result.Get("message").String()
}
if msg == "" {
msg = "request forbidden by DeepL (auth failed, outdated client, or blocked)"
}
return DLXTranslationResult{
ID: id,
Code: http.StatusForbidden,
Message: msg,
}, nil
default:
return DLXTranslationResult{
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 DLXTranslationResult{
ID: id,
Code: http.StatusServiceUnavailable,
Message: "Translation failed",
}, nil
}
mainText := translations[0].Get("text").String()
if mainText == "" {
return DLXTranslationResult{
ID: id,
Code: http.StatusServiceUnavailable,
Message: "Translation failed",
}, nil
}
if detected := translations[0].Get("detected_source_language").String(); detected != "" {
sourceLang = strings.ToUpper(detected)
}
return DLXTranslationResult{
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
}