fix(translate): align oneshot client with DeepL iOS URLSession profile

Replace the fabricated iOS 27.0 UA/os_version (which DeepL rate-limits and
can temp-ban) with a coherent iOS 26 profile matching the 26.42 IPA: CFNetwork
User-Agent, app_information, and ClientInfos x-app-* headers over HelloIOS TLS.
This commit is contained in:
Vincent Young 2026-08-06 10:18:52 +08:00
parent 5cd1667f7f
commit 66090baa7e
No known key found for this signature in database
GPG Key ID: 070D9CD629BC1AAE

View File

@ -42,28 +42,52 @@ import (
// traffic hard; oneshot lives on a separate pool and accepts the literal // traffic hard; oneshot lives on a separate pool and accepts the literal
// header `Authorization: None` for free requests. // header `Authorization: None` for free requests.
// //
// Request shape below is reverse-engineered from DeepL iOS 26.42 // Request shape reverse-engineered from DeepL iOS 26.42 (build 5443737,
// (build 5443737, bundle com.linguee.DeepLMobileTranslator): // bundle com.linguee.DeepLMobileTranslator, IPA Info.plist + ItaClient.framework):
// - Free URL → https://oneshot-free.www.deepl.com/v1/translate //
// - Pro URL → https://oneshot-pro.www.deepl.com/v1/translate // Transport
// (iOS also constructs https://oneshot. + .pro.deepl.com/v1/translate) // ItaClient oneshot uses Ktor Darwin engine
// - Body → OneShotTranslator + ItaClient.AppInformation // (io.ktor.client.engine.darwin.KtorNSURLSessionDelegate) → real
// - Headers → ClientInfos.appHeaders (x-app-*) + Authorization // URLSession TLS. We approximate that ClientHello with utls HelloIOS.
// - usage_type → ItaClient.OneShotUsageType.translate //
// Free URL → https://oneshot-free.www.deepl.com/v1/translate
// Pro URL → https://oneshot. + <cell> + .pro.deepl.com/v1/translate
// (we keep oneshot-pro.www as the free-tier Pro fallback)
//
// Headers (ClientInfos.appHeaders + LoginNone):
// Authorization: None (ItaClient.LoginNone)
// x-app-os-version (UIDevice.systemVersion)
// x-app-instance-id (stable install UUID)
// x-app-session-id (session UUID)
// User-Agent (URLSession / CFNetwork product form)
// Accept / Accept-Encoding (URLSession defaults)
//
// Body (ItaClient OneShotTranslationRequestDto + AppInformation):
// text[], target_lang, source_lang?, usage_type, app_information
// usage_type ∈ {translate, ocr, voiceforconversations}
//
// DeepL rate-limits / temporarily bans clients whose TLS + UA + app_information
// story is inconsistent (e.g. iOS TLS fingerprint + "iOS 27.0" in the UA when
// 27 is not a shipping OS). Keep every field on one coherent iOS profile.
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 DeepL iOS IPA (Info.plist CFBundleShortVersionString / // Pinned to DeepL iOS IPA (CFBundleShortVersionString / CFBundleVersion).
// CFBundleVersion). Keep app_information in lockstep with the TLS
// fingerprint and User-Agent so the request tells one consistent story.
iosAppVersion = "26.42" iosAppVersion = "26.42"
iosAppBuild = "5443737" iosAppBuild = "5443737"
iosBundleID = "com.linguee.DeepLMobileTranslator"
// Stable OS version reported in app_information + x-app-os-version. // Reported OS version for app_information.os_version + x-app-os-version.
// HelloIOS_Auto does not pin a specific iOS minor; pin a fixed value // IPA is built against iphoneos26.5 (DTPlatformVersion). Must be a real
// so UA / x-app-os-version / app_information stay consistent. // shipping major — a future value (e.g. 27.0) combined with an iOS TLS
iosOSVersion = "27.0" // fingerprint is rejected with HTTP 429 and can temp-ban the IP.
iosOSVersion = "26.0"
// CFNetwork / Darwin versions that accompany URLSession User-Agents on
// the same OS generation as DTPlatformVersion 26.x (build machine
// BuildMachineOSBuild 25E246 → Darwin 25).
iosCFNetworkVersion = "3826.600.41"
iosDarwinVersion = "25.0.0"
// 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 for anonymous traffic (same limit the Chrome // the `text` array for anonymous traffic (same limit the Chrome
@ -277,16 +301,21 @@ func getOneshotClient(proxyURL string) (*req.Client, error) {
} }
func newOneshotClient(proxyURL string) (*req.Client, error) { func newOneshotClient(proxyURL string) (*req.Client, error) {
// iOS TLS ClientHello via utls HelloIOS_Auto. Headers are set per // Match ItaClient's Ktor Darwin / URLSession profile:
// request in callOneshot to match ClientInfos.appHeaders rather than // - TLS ClientHello ≈ real iOS (utls HelloIOS_Auto)
// a browser navigation profile. // - Cookie jar shared like URLSession's HTTPCookieStorage
// - Common headers = what URLSession attaches by default
// Per-request headers (Authorization, x-app-*) are set in callOneshot.
client := req.C(). client := req.C().
SetTLSFingerprintIOS(). SetTLSFingerprintIOS().
SetCookieJar(sharedCookieJar()). SetCookieJar(sharedCookieJar()).
SetTimeout(oneshotTimeout). SetTimeout(oneshotTimeout).
SetUserAgent(iosUserAgent()). SetUserAgent(iosUserAgent()).
// URLSession default Accept-Encoding for data tasks.
SetCommonHeader("Accept-Encoding", "gzip, deflate, br"). SetCommonHeader("Accept-Encoding", "gzip, deflate, br").
SetCommonHeader("Accept", "*/*"). SetCommonHeader("Accept", "*/*").
// Preferred language list — mirrors a US-locale device; DeepL
// does not hard-require a specific value for free oneshot.
SetCommonHeader("Accept-Language", "en-US,en;q=0.9") SetCommonHeader("Accept-Language", "en-US,en;q=0.9")
if proxyURL != "" { if proxyURL != "" {
@ -299,12 +328,14 @@ func newOneshotClient(proxyURL string) (*req.Client, error) {
return client, nil return client, nil
} }
// iosUserAgent approximates the CFNetwork-style UA the DeepL iOS app // iosUserAgent is the URLSession product-style User-Agent the app's Ktor
// advertises via ClientInfos.userAgent. // Darwin stack emits (CFBundleName/CFBundleShortVersionString + CFNetwork
// + Darwin). Do NOT invent alternate formats (e.g. embedding the bundle
// ID) — mismatched UA + iOS TLS fingerprint is a cheap ban signal.
func iosUserAgent() string { func iosUserAgent() string {
return fmt.Sprintf( return fmt.Sprintf(
"DeepL/%s (%s; build:%s; iOS %s)", "DeepL/%s CFNetwork/%s Darwin/%s",
iosAppVersion, iosBundleID, iosAppBuild, iosOSVersion, iosAppVersion, iosCFNetworkVersion, iosDarwinVersion,
) )
} }
@ -318,6 +349,7 @@ func callOneshot(endpoint string, body []byte, bearerToken, proxyURL string) (gj
return gjson.Result{}, 0, err return gjson.Result{}, 0, err
} }
// LoginNone → literal "None"; LoginPro/Free → "Bearer <access_token>".
authValue := "None" authValue := "None"
if bearerToken != "" { if bearerToken != "" {
authValue = "Bearer " + bearerToken authValue = "Bearer " + bearerToken
@ -327,7 +359,8 @@ func callOneshot(endpoint string, body []byte, bearerToken, proxyURL string) (gj
DisableAutoReadResponse(). DisableAutoReadResponse().
SetHeader("Content-Type", "application/json"). SetHeader("Content-Type", "application/json").
SetHeader("Authorization", authValue). SetHeader("Authorization", authValue).
// ClientInfos.appHeaders from DeepL iOS (Util/ClientInfos.swift). // ClientInfos.appHeaders (Util/ClientInfos.swift) — only these three
// x-app-* keys exist in the iOS binary.
SetHeader("x-app-os-version", iosOSVersion). SetHeader("x-app-os-version", iosOSVersion).
SetHeader("x-app-instance-id", instanceID). SetHeader("x-app-instance-id", instanceID).
SetHeader("x-app-session-id", sessionID). SetHeader("x-app-session-id", sessionID).