feat(translate): sync oneshot request with DeepL iOS client

Replace the Chrome extension fingerprint with the iOS app profile from
DeepL 26.42 (build 5443737): iOS TLS, app_information, usage_type,
x-app-* headers, and FR-CA/DE-CH language codes.
This commit is contained in:
Vincent Young 2026-08-04 20:19:24 +08:00
parent dcdb0f2b6d
commit d44660c9d0
No known key found for this signature in database
GPG Key ID: 070D9CD629BC1AAE
2 changed files with 121 additions and 119 deletions

View File

@ -2,7 +2,7 @@
* @Author: Vincent Young * @Author: Vincent Young
* @Date: 2024-09-16 11:59:24 * @Date: 2024-09-16 11:59:24
* @LastEditors: Vincent Yang * @LastEditors: Vincent Yang
* @LastEditTime: 2026-05-22 00:00:00 * @LastEditTime: 2026-08-04 00:00:00
* @FilePath: /DLX/translate/translate.go * @FilePath: /DLX/translate/translate.go
* @Telegram: https://t.me/missuo * @Telegram: https://t.me/missuo
* @GitHub: https://github.com/missuo * @GitHub: https://github.com/missuo
@ -36,63 +36,64 @@ import (
"github.com/tidwall/gjson" "github.com/tidwall/gjson"
) )
// DeepL's interactive web translator migrated to a SignalR/WebSocket // DeepL's interactive clients (web, Chrome extension, and the official iOS
// channel and the legacy LMT_handle_texts backend on www2.deepl.com now // app) all share the same stateless "oneshot" translate endpoint. The
// 429s anonymous traffic within a handful of calls. The official Chrome // legacy LMT_handle_texts backend on www2.deepl.com rate-limits anonymous
// extension instead POSTs to a stateless "oneshot" endpoint that lives // traffic hard; oneshot lives on a separate pool and accepts the literal
// on a separate rate-limit pool and accepts the literal header // header `Authorization: None` for free requests.
// `Authorization: None` for anonymous requests — that is what we target.
// //
// The request we send is reverse-engineered from the extension's // Request shape below is reverse-engineered from DeepL iOS 26.42
// background.js (Chrome Web Store ID cofdbpoegempjloogbagkncekinflcnj): // (build 5443737, bundle com.linguee.DeepLMobileTranslator):
// - URL builder → mN() at ~offset 529948 // - Free URL → https://oneshot-free.www.deepl.com/v1/translate
// - body builder → IN() at ~offset 531200 // - Pro URL → https://oneshot-pro.www.deepl.com/v1/translate
// - fetch wrapper → JO() at ~offset 508659 // (iOS also constructs https://oneshot. + .pro.deepl.com/v1/translate)
// - app metadata → Wo() at ~offset 16500 // - Body → OneShotTranslator + ItaClient.AppInformation
// - Headers → ClientInfos.appHeaders (x-app-*) + Authorization
// - usage_type → ItaClient.OneShotUsageType.translate
const ( const (
oneshotFreeEndpoint = "https://oneshot-free.www.deepl.com/v1/translate" oneshotFreeEndpoint = "https://oneshot-free.www.deepl.com/v1/translate"
oneshotProEndpoint = "https://oneshot-pro.www.deepl.com/v1/translate" oneshotProEndpoint = "https://oneshot-pro.www.deepl.com/v1/translate"
// Pinned to the Chrome version utls bundles into req v3 (HelloChrome_120). // Pinned to DeepL iOS IPA (Info.plist CFBundleShortVersionString /
// Keep this in lockstep with the user-agent and app_information.os_version // CFBundleVersion). Keep app_information in lockstep with the TLS
// so the TLS handshake, UA, and self-reported browser version all agree — // fingerprint and User-Agent so the request tells one consistent story.
// a mismatch on any one of those is a cheap signal for the WAF. iosAppVersion = "26.42"
impersonatedChromeMajor = "120" iosAppBuild = "5443737"
chromeExtensionVersion = "1.86.0" iosBundleID = "com.linguee.DeepLMobileTranslator"
chromeExtensionID = "cofdbpoegempjloogbagkncekinflcnj" // Stable OS version reported in app_information + x-app-os-version.
// HelloIOS_Auto does not pin a specific iOS minor; 18.5 is a current
// shipping major that matches MinimumOSVersion 17.0+ of the IPA.
iosOSVersion = "18.5"
// oneshot enforces a 1500-character hard cap on the total length of // oneshot enforces a 1500-character hard cap on the total length of
// the `text` array (sum across all items). Source: the extension's // the `text` array for anonymous traffic (same limit the Chrome
// own `G.notLoggedIn = 1500` constant in background.js. The server // extension documents as G.notLoggedIn). Bail early to spare the
// returns 400 `{"errors":{"text":["text exceeds maximum length"]}}` // upstream and give the caller a faster error.
// past this; bail early to spare the upstream and give the caller a
// faster, less ambiguous error.
maxFreeTextLength = 1500 maxFreeTextLength = 1500
// oneshotTimeout caps how long we wait on a single translate request. // oneshotTimeout caps how long we wait on a single translate request.
// Without an explicit timeout, a hung upstream connection would
// dangle indefinitely and the caller (e.g. browser extension) would
// sit on a spinner forever — observed in the field.
oneshotTimeout = 20 * time.Second oneshotTimeout = 20 * time.Second
// warmupTimeout caps the initial GET to www.deepl.com that seeds the // warmupTimeout caps the initial GET to www.deepl.com that seeds the
// cookie jar. Shorter than oneshotTimeout because warmup typically // cookie jar. Cookies are best-effort; skip a slow warmup rather than
// completes in well under a second; we'd rather skip a slow warmup // block the first translation.
// (cookies are best-effort anyway) than block the first translation.
warmupTimeout = 5 * time.Second warmupTimeout = 5 * time.Second
) )
// instanceID mirrors the UUID the extension persists in chrome.storage on // instanceID mirrors the UUID the iOS app persists for analytics /
// install: stable for the life of the process, reused on every request. // app_information.instance_id and x-app-instance-id: stable for the life
// Rotating it per-request would be a far stronger signal than reusing one. // of the process, reused on every request. Rotating per-request is a
// stronger bot signal than reusing one.
var instanceID = newInstanceID() var instanceID = newInstanceID()
// A real extension fetch() inherits whatever cookies the browser has // sessionID is sent as x-app-session-id (ClientInfos.appHeaders). Stable
// accumulated on .deepl.com. A cold visit to www.deepl.com sets // for the process lifetime, independent of instanceID.
// userCountry=<iso2> and verifiedBot=false; users who have ever opened var sessionID = newInstanceID()
// the site additionally have _ga / _ga_<id> from analytics JS. We share
// a process-wide cookie jar so every oneshot POST automatically carries // A real iOS URLSession inherits whatever cookies the app has on
// whatever the warmup GET picked up. // .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 ( var (
cookieJar http.CookieJar cookieJar http.CookieJar
cookieJarOnce sync.Once cookieJarOnce sync.Once
@ -101,10 +102,6 @@ var (
// oneshotClients caches one req.Client per proxy URL so all translate // oneshotClients caches one req.Client per proxy URL so all translate
// calls share the underlying TCP / TLS / HTTP/2 connection pool. // calls share the underlying TCP / TLS / HTTP/2 connection pool.
// Creating a fresh req.Client per request meant a brand-new TLS
// handshake every time (~200-400ms of overhead on top of DeepL's own
// ~1.5s processing latency). Reusing the client lets keep-alive +
// session tickets cut that to near zero on the warm path.
var oneshotClients sync.Map // map[string]*req.Client var oneshotClients sync.Map // map[string]*req.Client
func sharedCookieJar() http.CookieJar { func sharedCookieJar() http.CookieJar {
@ -116,12 +113,8 @@ func sharedCookieJar() http.CookieJar {
} }
// warmCookies primes the shared jar by GETting www.deepl.com once. // warmCookies primes the shared jar by GETting www.deepl.com once.
// The Set-Cookie response (userCountry / verifiedBot) lands on .deepl.com, // The Set-Cookie response lands on .deepl.com (eTLD+1 of oneshot-free),
// which is the eTLD+1 of oneshot-free.www.deepl.com, so subsequent POSTs // so subsequent POSTs carry those cookies automatically.
// to the oneshot endpoint will carry those cookies automatically. The
// same request doubles as a TLS-handshake warmup: it leaves a live
// HTTP/2 connection to www.deepl.com in the client pool, which the
// first oneshot POST then resumes via TLS session tickets.
func warmCookies(client *req.Client) { func warmCookies(client *req.Client) {
cookieWarmer.Do(func() { cookieWarmer.Do(func() {
ctx, cancel := context.WithTimeout(context.Background(), warmupTimeout) ctx, cancel := context.WithTimeout(context.Background(), warmupTimeout)
@ -141,21 +134,22 @@ func newInstanceID() string {
return fmt.Sprintf("%s-%s-%s-%s-%s", s[0:8], s[8:12], s[12:16], s[16:20], s[20:32]) 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 the bundled list in the extension's // Language code tables mirror ItaClient.OutputLanguage / InputLanguage
// background.js (arrays `y` ~offset 6000 for the full target-capable // (regional cases enUs, enGb, frCa, deCh, ptPt, ptBr, es419, zhHant, …)
// set, `A` for source-only aliases). Keys are the uppercase forms // plus the full target-capable set the oneshot endpoint accepts.
// callers pass; values are the lowercase BCP-47-ish forms the oneshot
// endpoint expects ("de", "en-US", "zh-Hans", ...).
// //
// targetLangMap is what the API accepts as `target_lang`. EN and PT // Keys are the uppercase forms callers pass; values are the lowercase
// are intentionally absent — DeepL deprecated them as target codes in // BCP-47-ish forms oneshot expects ("de", "en-US", "zh-Hans", ...).
// favour of EN-US/EN-GB and PT-BR/PT-PT, and the extension's y array //
// reflects that. We accept EN/PT as a backward-compat convenience and // EN and PT are intentionally absent as bare target codes — DeepL
// resolve them to the regional default (en-US, pt-BR). // 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{ var targetLangMap = map[string]string{
"AR": "ar", "BG": "bg", "CS": "cs", "DA": "da", "DE": "de", "EL": "el", "AR": "ar", "BG": "bg", "CS": "cs", "DA": "da", "DE": "de", "DE-CH": "de-CH",
"EL": "el",
"EN-GB": "en-GB", "EN-US": "en-US", "EN-GB": "en-GB", "EN-US": "en-US",
"ES": "es", "ES-419": "es-419", "ET": "et", "FI": "fi", "FR": "fr", "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", "HE": "he", "HU": "hu", "ID": "id", "IT": "it", "JA": "ja", "KO": "ko",
"LT": "lt", "LV": "lv", "NB": "nb", "NL": "nl", "PL": "pl", "LT": "lt", "LV": "lv", "NB": "nb", "NL": "nl", "PL": "pl",
"PT-BR": "pt-BR", "PT-PT": "pt-PT", "PT-BR": "pt-BR", "PT-PT": "pt-PT",
@ -169,9 +163,7 @@ var targetLangMap = map[string]string{
// sourceLangMap is what the API accepts as `source_lang`. It is a // sourceLangMap is what the API accepts as `source_lang`. It is a
// superset of targetLangMap: EN and PT are first-class source codes // superset of targetLangMap: EN and PT are first-class source codes
// (extension array `A`) mapping to the generic "en"/"pt" — used when // mapping to the generic "en"/"pt".
// the caller knows the input is English/Portuguese but does not want
// to commit to a regional variant.
var sourceLangMap = func() map[string]string { var sourceLangMap = func() map[string]string {
m := make(map[string]string, len(targetLangMap)+2) m := make(map[string]string, len(targetLangMap)+2)
for k, v := range targetLangMap { for k, v := range targetLangMap {
@ -245,9 +237,8 @@ func sortedKeys(m map[string]string) string {
return strings.Join(keys, ", ") return strings.Join(keys, ", ")
} }
// appInformation matches the snake_case shape produced by background.js // appInformation matches ItaClient.AppInformation (os, os_version,
// Wo({isSnakeCase: true}). Values are pinned to the same Chrome version // app_version, app_build, instance_id) as serialized by the iOS client.
// as the TLS handshake so the request tells one consistent story.
type appInformation struct { type appInformation struct {
OS string `json:"os"` OS string `json:"os"`
OSVersion string `json:"os_version"` OSVersion string `json:"os_version"`
@ -256,9 +247,9 @@ type appInformation struct {
InstanceID string `json:"instance_id"` InstanceID string `json:"instance_id"`
} }
// oneshotRequest mirrors the body assembled in background.js IN(...). // oneshotRequest mirrors the body assembled by the iOS OneShotTranslator
// Field order matches the extension's object literal so the serialized // / ItaClient oneshot path. Field order matches the app's serialization
// JSON is byte-identical (encoding/json honours struct field order). // so the JSON is byte-stable (encoding/json honours struct field order).
type oneshotRequest struct { type oneshotRequest struct {
Text []string `json:"text"` Text []string `json:"text"`
TargetLang string `json:"target_lang"` TargetLang string `json:"target_lang"`
@ -267,21 +258,9 @@ type oneshotRequest struct {
AppInformation appInformation `json:"app_information"` AppInformation appInformation `json:"app_information"`
} }
// newOneshotClient configures a req.Client whose outbound profile matches
// a chrome-extension service-worker fetch() byte-for-byte where it can.
// ImpersonateChrome gives us the Chrome 120 TLS ClientHello, HTTP/2
// SETTINGS, pseudo/header order, and a sec-ch-ua/user-agent set tied to
// the same version. It also installs a navigation-flavoured set of common
// headers (pragma, cache-control, upgrade-insecure-requests, sec-fetch-user)
// that a fetch() never emits — wipe those so the WAF cannot tell us apart
// on that axis.
// getOneshotClient returns a process-wide cached client for the given // getOneshotClient returns a process-wide cached client for the given
// proxy URL, creating it on first use. Sharing the client across // proxy URL, creating it on first use. Sharing the client across
// requests is the single biggest latency win we have on the warm path: // requests keeps the TLS / HTTP/2 connection in the pool.
// it keeps the TLS / HTTP/2 connection in the pool so subsequent
// requests skip the handshake entirely. Kicks off cookie-jar warmup
// in the background on first creation so that the first real translate
// call lands on an already-established connection.
func getOneshotClient(proxyURL string) (*req.Client, error) { func getOneshotClient(proxyURL string) (*req.Client, error) {
if c, ok := oneshotClients.Load(proxyURL); ok { if c, ok := oneshotClients.Load(proxyURL); ok {
return c.(*req.Client), nil return c.(*req.Client), nil
@ -293,27 +272,22 @@ func getOneshotClient(proxyURL string) (*req.Client, error) {
if actual, loaded := oneshotClients.LoadOrStore(proxyURL, c); loaded { if actual, loaded := oneshotClients.LoadOrStore(proxyURL, c); loaded {
return actual.(*req.Client), nil return actual.(*req.Client), nil
} }
// First time we've seen this proxy. Kick warmup off in the
// background so the very first translate call can run in parallel
// with the TLS handshake to www.deepl.com.
go warmCookies(c) go warmCookies(c)
return c, nil return c, nil
} }
func newOneshotClient(proxyURL string) (*req.Client, error) { func newOneshotClient(proxyURL string) (*req.Client, error) {
client := req.C().ImpersonateChrome().SetCookieJar(sharedCookieJar()).SetTimeout(oneshotTimeout) // iOS TLS ClientHello via utls HelloIOS_Auto. Headers are set per
for _, h := range []string{ // request in callOneshot to match ClientInfos.appHeaders rather than
"Pragma", // a browser navigation profile.
"Cache-Control", client := req.C().
"Upgrade-Insecure-Requests", SetTLSFingerprintIOS().
"Sec-Fetch-User", SetCookieJar(sharedCookieJar()).
} { SetTimeout(oneshotTimeout).
client.Headers.Del(h) SetUserAgent(iosUserAgent()).
} SetCommonHeader("Accept-Encoding", "gzip, deflate, br").
// Chrome 120 fetch() advertises gzip/deflate/br (zstd only appeared SetCommonHeader("Accept", "*/*").
// as a default in Chrome 123+). req's default of just "gzip" is a SetCommonHeader("Accept-Language", "en-US,en;q=0.9")
// distinguishable signal — match Chrome explicitly.
client.SetCommonHeader("Accept-Encoding", "gzip, deflate, br")
if proxyURL != "" { if proxyURL != "" {
u, err := url.Parse(proxyURL) u, err := url.Parse(proxyURL)
@ -325,11 +299,19 @@ func newOneshotClient(proxyURL string) (*req.Client, error) {
return client, nil 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. // callOneshot POSTs to the oneshot endpoint and returns the parsed JSON.
// For anonymous traffic bearerToken is empty and we send the literal // For anonymous traffic bearerToken is empty and we send the literal
// header `Authorization: None` — replicating the extension's JO() wrapper // header `Authorization: None` — matching ItaClient.LoginNone. Omitting
// exactly. Omitting that header instead would put the request on a // that header puts the request on a different server-side auth branch.
// different server-side auth branch.
func callOneshot(endpoint string, body []byte, bearerToken, proxyURL string) (gjson.Result, int, error) { func callOneshot(endpoint string, body []byte, bearerToken, proxyURL string) (gjson.Result, int, error) {
client, err := getOneshotClient(proxyURL) client, err := getOneshotClient(proxyURL)
if err != nil { if err != nil {
@ -344,15 +326,14 @@ func callOneshot(endpoint string, body []byte, bearerToken, proxyURL string) (gj
resp, err := client.R(). resp, err := client.R().
DisableAutoReadResponse(). DisableAutoReadResponse().
SetHeader("Content-Type", "application/json"). SetHeader("Content-Type", "application/json").
SetHeader("Accept", "*/*").
SetHeader("Authorization", authValue). SetHeader("Authorization", authValue).
SetHeader("Origin", "chrome-extension://"+chromeExtensionID). // ClientInfos.appHeaders from DeepL iOS (Util/ClientInfos.swift).
SetHeader("Sec-Fetch-Site", "cross-site"). SetHeader("x-app-os-version", iosOSVersion).
SetHeader("Sec-Fetch-Mode", "cors"). SetHeader("x-app-instance-id", instanceID).
SetHeader("Sec-Fetch-Dest", "empty"). SetHeader("x-app-session-id", sessionID).
SetBodyBytes(body). // SetBodyBytes pins Content-Length; using an SetBodyBytes(body). // pins Content-Length; an io.Reader would
// io.Reader instead forces Transfer-Encoding: chunked, which a // force Transfer-Encoding: chunked, which URLSession JSON bodies
// real fetch() with JSON.stringify body never emits. // never emit.
Post(endpoint) Post(endpoint)
if err != nil { if err != nil {
return gjson.Result{}, 0, err return gjson.Result{}, 0, err
@ -416,16 +397,22 @@ func TranslateByDLX(sourceLang, targetLang, text string, tagHandling string, pro
}, nil }, 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{ reqStruct := oneshotRequest{
Text: []string{text}, Text: []string{text},
TargetLang: resolvedTarget, TargetLang: resolvedTarget,
SourceLang: resolvedSource, // empty = autodetect; omitempty drops the field SourceLang: resolvedSource, // empty = autodetect; omitempty drops the field
UsageType: "Translate", // ItaClient.OneShotUsageType.translate (also: ocr, voiceforconversations)
UsageType: "translate",
AppInformation: appInformation{ AppInformation: appInformation{
OS: "brex_macOS", OS: "iOS",
OSVersion: "brex_chrome_" + impersonatedChromeMajor + ".0.0.0", OSVersion: iosOSVersion,
AppVersion: chromeExtensionVersion, AppVersion: iosAppVersion,
AppBuild: "chrome_web_store", AppBuild: iosAppBuild,
InstanceID: instanceID, InstanceID: instanceID,
}, },
} }
@ -465,6 +452,21 @@ func TranslateByDLX(sourceLang, targetLang, text string, tagHandling string, pro
Code: http.StatusTooManyRequests, 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", Message: "too many requests, your IP has been blocked by DeepL temporarily, please don't request it frequently in a short time",
}, nil }, 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: default:
return DLXTranslationResult{ return DLXTranslationResult{
ID: id, ID: id,

View File

@ -2,7 +2,7 @@
* @Author: Vincent Young * @Author: Vincent Young
* @Date: 2024-09-16 11:59:24 * @Date: 2024-09-16 11:59:24
* @LastEditors: Vincent Yang * @LastEditors: Vincent Yang
* @LastEditTime: 2026-05-22 00:00:00 * @LastEditTime: 2026-08-04 00:00:00
* @FilePath: /DLX/translate/types.go * @FilePath: /DLX/translate/types.go
* @Telegram: https://t.me/missuo * @Telegram: https://t.me/missuo
* @GitHub: https://github.com/missuo * @GitHub: https://github.com/missuo
@ -14,8 +14,8 @@ package translate
// DLXTranslationResult is the public response shape consumed by the HTTP // DLXTranslationResult is the public response shape consumed by the HTTP
// handlers in the service package. The structure predates the migration to // handlers in the service package. The structure predates the migration to
// the oneshot endpoint; Alternatives is now always empty because oneshot does // the iOS oneshot endpoint; Alternatives is now always empty because oneshot
// not return alternative translations, and ID is synthesized from time. // does not return alternative translations, and ID is synthesized from time.
type DLXTranslationResult struct { type DLXTranslationResult struct {
Code int `json:"code"` Code int `json:"code"`
ID int64 `json:"id"` ID int64 `json:"id"`