From 98918dd9f6af0c255cbb19a10b7de4c212c68cda Mon Sep 17 00:00:00 2001 From: Vincent Yang Date: Fri, 22 May 2026 12:04:44 +0800 Subject: [PATCH] feat(translate): migrate to oneshot endpoint (#217) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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. * fix(translate): align oneshot request bytes with the real extension After capturing the exact bytes the Chrome extension's service-worker fetch() emits (via an offline echo server pointed at deeplx in place of oneshot-free.www.deepl.com) and diffing them against what we were sending, several distinguishable signals remained. Close them all. Headers ------- - Origin: chrome-extension://cofdbpoegempjloogbagkncekinflcnj (was https://www.deepl.com — a request from www.deepl.com itself never lands on the oneshot endpoint, so that origin is unusual. The extension ID is the canonical sender.) - Sec-Fetch-Site: cross-site (was same-site — wrong; chrome-extension -> www.deepl.com IS cross-site) - Drop Referer entirely (extension SW fetch sends none) - Drop Pragma / Cache-Control / Upgrade-Insecure-Requests / Sec-Fetch-User (req.ImpersonateChrome() sets these for top-level navigation; a fetch() never sends them — leaving them in is a strong nav-vs-XHR tell) - Accept-Encoding: gzip, deflate, br (was just gzip, Go stdlib default — Chrome 120's fetch() sends all three; zstd only landed as a default in Chrome 123+ so leave it off) Body ---- - Add usage_type: "Translate" and the full app_information object (os/os_version/app_version/app_build/instance_id) so the JSON the server sees is structurally identical to what background.js IN() assembles. Field order in oneshotRequest matches the extension's object-literal order so encoding/json produces byte-identical output. - instance_id is a v4 UUID generated once at process start and reused, mirroring the extension's chrome.storage-pinned ID rather than rotating per-request (rotation would be a far stronger signal). - All version strings (TLS handshake, User-Agent, sec-ch-ua, app_information.os_version) are pinned to Chrome 120 so they tell one consistent story. Transport --------- - SetBodyBytes instead of bytes.NewReader so Content-Length is set (an io.Reader body forces Transfer-Encoding: chunked, which a fetch() with JSON.stringify body never emits) - Once we set Accept-Encoding manually, the Go stdlib disables its transparent decompression and req hands us raw compressed bytes. Handle gzip / deflate / br by hand from Content-Encoding. - DisableAutoReadResponse so we own the body stream end-to-end. The Chrome 120 TLS ClientHello, HTTP/2 SETTINGS frame, pseudo-header order and sec-ch-ua claim continue to come from ImpersonateChrome() unchanged. Verified end-to-end: - Outbound bytes (against a local echo server) diff-match the extension's observed profile on every header and on body JSON order. - Live oneshot-free.www.deepl.com calls: 4 language pairs OK, /v2/translate official-API compat OK, 10x burst 10/10 200. * chore(deps): upgrade to latest compatible versions Run `go get -u ./...` + `go mod tidy`. Direct upgrades: - github.com/andybalholm/brotli 1.2.0 → 1.2.1 - github.com/tidwall/gjson 1.18.0 → 1.19.0 Indirect (notable): - github.com/bytedance/sonic 1.15.0 → 1.15.1 - github.com/bytedance/sonic/loader 0.5.0 → 0.5.1 - github.com/bytedance/gopkg 0.1.3 → 0.1.4 - github.com/cloudwego/base64x 0.1.6 → 0.1.7 - github.com/gin-contrib/sse 1.1.0 → 1.1.1 - github.com/go-playground/validator/v10 10.30.1 → 10.30.2 - github.com/goccy/go-json 0.10.5 → 0.10.6 - github.com/klauspost/compress 1.18.4 → 1.18.6 - github.com/mattn/go-isatty 0.0.20 → 0.0.22 - github.com/pelletier/go-toml/v2 2.2.4 → 2.3.1 - golang.org/x/arch 0.24.0 → 0.27.0 - golang.org/x/crypto 0.48.0 → 0.52.0 - golang.org/x/net 0.51.0 → 0.55.0 - golang.org/x/sys 0.41.0 → 0.45.0 - golang.org/x/text 0.34.0 → 0.37.0 github.com/imroc/req/v3 (the HTTP client we depend on for Chrome impersonation) is already on its latest tag v3.57.0 and pins github.com/quic-go/quic-go to <= v0.57.x — newer quic-go removed ConnectionTracingID/ConnectionTracingKey, which req's internal/http3 still references. That constraint also holds gin-gonic/gin at v1.11.0 and gin-contrib/cors at v1.7.6 (their later versions pull quic-go ≥ 0.58 transitively). Pin quic-go to v0.57.1 to keep the build green; revisit when req publishes a release compatible with quic-go ≥ 0.58. Build + live oneshot end-to-end: 4 language pairs OK, /v2/translate official-API compat OK, 8x burst 8/8 200. * fix(translate): seed cookie jar from www.deepl.com on first call A real chrome-extension fetch() to oneshot-free.www.deepl.com inherits whatever cookies the browser has on .deepl.com — at minimum `userCountry=` and `verifiedBot=false`, both of which the deepl.com server sets on any page load. Our outbound bytes were otherwise extension-identical but went out cookieless, which is a distinguishable signal. Wire a process-wide net/http/cookiejar onto the req.Client and trigger a single warmup GET to https://www.deepl.com/translator on the first translate call (sync.Once). The Set-Cookie response (userCountry, verifiedBot) lands on .deepl.com, which the jar then automatically echoes back on every subsequent POST to oneshot-free.www.deepl.com (cookies set on .deepl.com match any *.deepl.com subdomain). Verified outbound: Cookie: userCountry=JP; verifiedBot=false Latency cost: first call after process start pays one extra HTTP GET (~1s warmup); subsequent calls are unaffected (sync.Once + connection keep-alive). Note: we cannot replicate the _ga / _ga_ cookies a real user would also carry — those are set client-side by GA's JS, which a non-browser HTTP client can't execute. The userCountry+verifiedBot pair already matches the "first-time visitor with JS disabled" profile, which is the closest plausible non-browser approximation. --- go.mod | 35 ++-- go.sum | 73 ++++---- translate/translate.go | 387 +++++++++++++++++++++++++++-------------- translate/types.go | 121 +------------ translate/utils.go | 59 ------- 5 files changed, 317 insertions(+), 358 deletions(-) delete mode 100644 translate/utils.go diff --git a/go.mod b/go.mod index 1fa02c8..7de87bb 100644 --- a/go.mod +++ b/go.mod @@ -3,36 +3,35 @@ module github.com/OwO-Network/DeepLX go 1.25.0 require ( - github.com/abadojack/whatlanggo v1.0.1 - github.com/andybalholm/brotli v1.2.0 + github.com/andybalholm/brotli v1.2.1 github.com/gin-contrib/cors v1.7.6 github.com/gin-gonic/gin v1.11.0 github.com/imroc/req/v3 v3.57.0 - github.com/tidwall/gjson v1.18.0 + github.com/tidwall/gjson v1.19.0 ) require ( - github.com/bytedance/gopkg v0.1.3 // indirect - github.com/bytedance/sonic v1.15.0 // indirect - github.com/bytedance/sonic/loader v0.5.0 // indirect - github.com/cloudwego/base64x v0.1.6 // indirect + github.com/bytedance/gopkg v0.1.4 // indirect + github.com/bytedance/sonic v1.15.1 // indirect + github.com/bytedance/sonic/loader v0.5.1 // indirect + github.com/cloudwego/base64x v0.1.7 // indirect github.com/gabriel-vasile/mimetype v1.4.13 // indirect - github.com/gin-contrib/sse v1.1.0 // indirect + github.com/gin-contrib/sse v1.1.1 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect - github.com/go-playground/validator/v10 v10.30.1 // indirect - github.com/goccy/go-json v0.10.5 // indirect + github.com/go-playground/validator/v10 v10.30.2 // indirect + github.com/goccy/go-json v0.10.6 // indirect github.com/goccy/go-yaml v1.19.2 // indirect github.com/google/go-querystring v1.2.0 // indirect github.com/icholy/digest v1.1.0 // indirect github.com/json-iterator/go v1.1.12 // indirect - github.com/klauspost/compress v1.18.4 // indirect + github.com/klauspost/compress v1.18.6 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-isatty v0.0.22 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect - github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/pelletier/go-toml/v2 v2.3.1 // indirect github.com/quic-go/qpack v0.6.0 // indirect github.com/quic-go/quic-go v0.57.1 // indirect github.com/refraction-networking/utls v1.8.2 // indirect @@ -40,10 +39,10 @@ require ( github.com/tidwall/pretty v1.2.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.3.1 // indirect - golang.org/x/arch v0.24.0 // indirect - golang.org/x/crypto v0.48.0 // indirect - golang.org/x/net v0.51.0 // indirect - golang.org/x/sys v0.41.0 // indirect - golang.org/x/text v0.34.0 // indirect + golang.org/x/arch v0.27.0 // indirect + golang.org/x/crypto v0.52.0 // indirect + golang.org/x/net v0.55.0 // indirect + golang.org/x/sys v0.45.0 // indirect + golang.org/x/text v0.37.0 // indirect google.golang.org/protobuf v1.36.11 // indirect ) diff --git a/go.sum b/go.sum index 5a5fd83..7517464 100644 --- a/go.sum +++ b/go.sum @@ -1,15 +1,13 @@ -github.com/abadojack/whatlanggo v1.0.1 h1:19N6YogDnf71CTHm3Mp2qhYfkRdyvbgwWdd2EPxJRG4= -github.com/abadojack/whatlanggo v1.0.1/go.mod h1:66WiQbSbJBIlOZMsvbKe5m6pzQovxCH9B/K8tQB2uoc= -github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= -github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= -github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= -github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= -github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE= -github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k= -github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE= -github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= -github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= -github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= +github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro= +github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= +github.com/bytedance/gopkg v0.1.4 h1:oZnQwnX82KAIWb7033bEwtxvTqXcYMxDBaQxo5JJHWM= +github.com/bytedance/gopkg v0.1.4/go.mod h1:v1zWfPm21Fb+OsyXN2VAHdL6TBb2L88anLQgdyje6R4= +github.com/bytedance/sonic v1.15.1 h1:nJD5PmM0vY7J8CT6MxoqbVAAMhkSmV2HgRAUrrpLoOw= +github.com/bytedance/sonic v1.15.1/go.mod h1:mT2NbXunuaEbnZ+mRIX/vYqKISmgEuHFDI4UzmKx2SA= +github.com/bytedance/sonic/loader v0.5.1 h1:Ygpfa9zwRCCKSlrp5bBP/b/Xzc3VxsAW+5NIYXrOOpI= +github.com/bytedance/sonic/loader v0.5.1/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= +github.com/cloudwego/base64x v0.1.7 h1:NppS+Fgzg5ovhn4NkUXaDT3x9jldgH5ToMCqzBSi2zI= +github.com/cloudwego/base64x v0.1.7/go.mod h1:Cu1PV9zfrSf7ET2tIbWbbEy7jO7HHJ13q4X2SQ8aWYg= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -17,8 +15,8 @@ github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9 github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/gin-contrib/cors v1.7.6 h1:3gQ8GMzs1Ylpf70y8bMw4fVpycXIeX1ZemuSQIsnQQY= github.com/gin-contrib/cors v1.7.6/go.mod h1:Ulcl+xN4jel9t1Ry8vqph23a60FwH9xVLd+3ykmTjOk= -github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= -github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= +github.com/gin-contrib/sse v1.1.1 h1:uGYpNwTacv5R68bSGMapo62iLTRa9l5zxGCps4hK6ko= +github.com/gin-contrib/sse v1.1.1/go.mod h1:QXzuVkA0YO7o/gun03UI1Q+FTI8ZV/n5t03kIQAI89s= github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk= github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= @@ -27,10 +25,10 @@ github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/o github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= -github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w= -github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM= -github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= -github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/go-playground/validator/v10 v10.30.2 h1:JiFIMtSSHb2/XBUbWM4i/MpeQm9ZK2xqPNk8vgvu5JQ= +github.com/go-playground/validator/v10 v10.30.2/go.mod h1:mAf2pIOVXjTEBrwUMGKkCWKKPs9NheYGabeB04txQSc= +github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU= +github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= @@ -45,21 +43,21 @@ github.com/imroc/req/v3 v3.57.0 h1:LMTUjNRUybUkTPn8oJDq8Kg3JRBOBTcnDhKu7mzupKI= github.com/imroc/req/v3 v3.57.0/go.mod h1:JL62ey1nvSLq81HORNcosvlf7SxZStONNqOprg0Pz00= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= -github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c= -github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= +github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao= +github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= +github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= -github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= -github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pelletier/go-toml/v2 v2.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7olPtrEc= +github.com/pelletier/go-toml/v2 v2.3.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= @@ -79,12 +77,10 @@ github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXl github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= -github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= -github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/gjson v1.19.0 h1:xwxm7n691Uf3u5OFjzngavjGTh55KX5q/9w9xHW88JU= +github.com/tidwall/gjson v1.19.0/go.mod h1:V37/opeE/JbLUOfH0QTXiNez2l0RUjYUhpT4szFQAfc= github.com/tidwall/match v1.2.0 h1:0pt8FlkOwjN2fPt4bIl4BoNxb98gGHN2ObFEDkrfZnM= github.com/tidwall/match v1.2.0/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= -github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= @@ -95,17 +91,16 @@ github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZ github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= -golang.org/x/arch v0.24.0 h1:qlJ3M9upxvFfwRM51tTg3Yl+8CP9vCC1E7vlFpgv99Y= -golang.org/x/arch v0.24.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= -golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= -golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= -golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= -golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= -golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= -golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/arch v0.27.0 h1:0WNVcR8u9yFz8j5FvdHpgwNp3FS5U4guYdzHwEiGjoU= +golang.org/x/arch v0.27.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8= +golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= +golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= +golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= +golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= diff --git a/translate/translate.go b/translate/translate.go index 151c94c..99a8af0 100644 --- a/translate/translate.go +++ b/translate/translate.go @@ -2,7 +2,7 @@ * @Author: Vincent Young * @Date: 2024-09-16 11:59:24 * @LastEditors: Vincent Yang - * @LastEditTime: 2025-07-13 23:09:49 + * @LastEditTime: 2026-05-22 00:00:00 * @FilePath: /DeepLX/translate/translate.go * @Telegram: https://t.me/missuo * @GitHub: https://github.com/missuo @@ -13,101 +13,236 @@ package translate import ( - "bytes" "compress/flate" "compress/gzip" + "crypto/rand" + "encoding/hex" + "encoding/json" "fmt" "io" "net/http" + "net/http/cookiejar" "net/url" "strings" - - "github.com/abadojack/whatlanggo" - "github.com/imroc/req/v3" + "sync" + "time" "github.com/andybalholm/brotli" + "github.com/imroc/req/v3" "github.com/tidwall/gjson" ) -// makeRequestWithBody makes an HTTP request with pre-formatted body using minimal headers -func makeRequestWithBody(postStr string, proxyURL string, dlSession string) (gjson.Result, error) { - urlFull := "https://www2.deepl.com/jsonrpc" +// DeepL's interactive web translator migrated to a SignalR/WebSocket +// channel and the legacy LMT_handle_texts backend on www2.deepl.com now +// 429s anonymous traffic within a handful of calls. The official Chrome +// extension instead POSTs to a stateless "oneshot" endpoint that lives +// on a separate rate-limit pool and accepts the literal header +// `Authorization: None` for anonymous requests — that is what we target. +// +// The request we send is reverse-engineered from the extension's +// background.js (Chrome Web Store ID cofdbpoegempjloogbagkncekinflcnj): +// - URL builder → mN() at ~offset 529948 +// - body builder → IN() at ~offset 531200 +// - fetch wrapper → JO() at ~offset 508659 +// - app metadata → Wo() at ~offset 16500 +const ( + oneshotFreeEndpoint = "https://oneshot-free.www.deepl.com/v1/translate" + oneshotProEndpoint = "https://oneshot-pro.www.deepl.com/v1/translate" - // Create a new req client - client := req.C().SetTLSFingerprintRandomized() + // Pinned to the Chrome version utls bundles into req v3 (HelloChrome_120). + // Keep this in lockstep with the user-agent and app_information.os_version + // so the TLS handshake, UA, and self-reported browser version all agree — + // a mismatch on any one of those is a cheap signal for the WAF. + impersonatedChromeMajor = "120" + chromeExtensionVersion = "1.86.0" + chromeExtensionID = "cofdbpoegempjloogbagkncekinflcnj" +) - // Set headers to simulate browser request - headers := http.Header{ - "Content-Type": []string{"application/json"}, - "Accept": []string{"*/*"}, - "Accept-Language": []string{"en-US,en;q=0.9"}, - "Accept-Encoding": []string{"gzip, deflate, br, zstd"}, - "Origin": []string{"https://www.deepl.com"}, - "Referer": []string{"https://www.deepl.com/"}, - "Sec-Fetch-Dest": []string{"empty"}, - "Sec-Fetch-Mode": []string{"cors"}, - "Sec-Fetch-Site": []string{"same-site"}, - "User-Agent": []string{"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"}, - } +// instanceID mirrors the UUID the extension persists in chrome.storage on +// install: stable for the life of the process, reused on every request. +// Rotating it per-request would be a far stronger signal than reusing one. +var instanceID = newInstanceID() - if dlSession != "" { - headers.Set("Cookie", "dl_session="+dlSession) - } +// A real extension fetch() inherits whatever cookies the browser has +// accumulated on .deepl.com. A cold visit to www.deepl.com sets +// userCountry= and verifiedBot=false; users who have ever opened +// the site additionally have _ga / _ga_ from analytics JS. We share +// a process-wide cookie jar so every oneshot POST automatically carries +// whatever the warmup GET picked up. +var ( + cookieJar http.CookieJar + cookieJarOnce sync.Once + cookieWarmer sync.Once +) - // Set proxy if provided - if proxyURL != "" { - proxy, err := url.Parse(proxyURL) - if err != nil { - return gjson.Result{}, err - } - client.SetProxyURL(proxy.String()) - } - - // Make the request - r := client.R() - r.Headers = headers - resp, err := r. - SetBody(bytes.NewReader([]byte(postStr))). - Post(urlFull) - - if err != nil { - return gjson.Result{}, err - } - - // Check for blocked status like TypeScript version - if resp.StatusCode == 429 { - return gjson.Result{}, fmt.Errorf("too many requests, your IP has been blocked by DeepL temporarily, please don't request it frequently in a short time") - } - - // Check for other error status codes - if resp.StatusCode != 200 { - return gjson.Result{}, fmt.Errorf("request failed with status code: %d", resp.StatusCode) - } - - var bodyReader io.Reader - contentEncoding := resp.Header.Get("Content-Encoding") - switch contentEncoding { - case "br": - bodyReader = brotli.NewReader(resp.Body) - case "gzip": - bodyReader, err = gzip.NewReader(resp.Body) - if err != nil { - return gjson.Result{}, fmt.Errorf("failed to create gzip reader: %w", err) - } - case "deflate": - bodyReader = flate.NewReader(resp.Body) - default: - bodyReader = resp.Body - } - - body, err := io.ReadAll(bodyReader) - if err != nil { - return gjson.Result{}, fmt.Errorf("failed to read response body: %w", err) - } - return gjson.ParseBytes(body), nil +func sharedCookieJar() http.CookieJar { + cookieJarOnce.Do(func() { + j, _ := cookiejar.New(nil) + cookieJar = j + }) + return cookieJar } -// TranslateByDeepLX performs translation using DeepL API +// warmCookies primes the shared jar by GETting www.deepl.com once. +// The Set-Cookie response (userCountry / verifiedBot) lands on .deepl.com, +// which is the eTLD+1 of oneshot-free.www.deepl.com, so subsequent POSTs +// to the oneshot endpoint will carry those cookies automatically. +func warmCookies(client *req.Client) { + cookieWarmer.Do(func() { + _, _ = client.R().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]) +} + +// langCodeToOneshot translates DeepL's uppercase codes (DE, EN, ZH, ...) +// to the lowercase BCP-47-ish codes the oneshot endpoint requires (de, +// en-US, zh-Hans, ...). Unknown codes fall through lowercased. +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) +} + +// appInformation matches the snake_case shape produced by background.js +// Wo({isSnakeCase: true}). Values are pinned to the same Chrome version +// as the TLS handshake so the request tells one consistent story. +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 in background.js IN(...). +// Field order matches the extension's object literal so the serialized +// JSON is byte-identical (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"` +} + +// 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. +func newOneshotClient(proxyURL string) (*req.Client, error) { + client := req.C().ImpersonateChrome().SetCookieJar(sharedCookieJar()) + for _, h := range []string{ + "Pragma", + "Cache-Control", + "Upgrade-Insecure-Requests", + "Sec-Fetch-User", + } { + client.Headers.Del(h) + } + // Chrome 120 fetch() advertises gzip/deflate/br (zstd only appeared + // as a default in Chrome 123+). req's default of just "gzip" is a + // distinguishable signal — match Chrome explicitly. + client.SetCommonHeader("Accept-Encoding", "gzip, deflate, br") + + if proxyURL != "" { + u, err := url.Parse(proxyURL) + if err != nil { + return nil, err + } + client.SetProxyURL(u.String()) + } + return client, nil +} + +// 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` — replicating the extension's JO() wrapper +// exactly. Omitting that header instead would put the request on a +// different server-side auth branch. +func callOneshot(endpoint string, body []byte, bearerToken, proxyURL string) (gjson.Result, int, error) { + client, err := newOneshotClient(proxyURL) + if err != nil { + return gjson.Result{}, 0, err + } + warmCookies(client) // no-op after the first translation in the process + + authValue := "None" + if bearerToken != "" { + authValue = "Bearer " + bearerToken + } + + resp, err := client.R(). + DisableAutoReadResponse(). + SetHeader("Content-Type", "application/json"). + SetHeader("Accept", "*/*"). + SetHeader("Authorization", authValue). + SetHeader("Origin", "chrome-extension://"+chromeExtensionID). + SetHeader("Sec-Fetch-Site", "cross-site"). + SetHeader("Sec-Fetch-Mode", "cors"). + SetHeader("Sec-Fetch-Dest", "empty"). + SetBodyBytes(body). // SetBodyBytes pins Content-Length; using an + // io.Reader instead forces Transfer-Encoding: chunked, which a + // real fetch() with JSON.stringify body never emits. + 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 +} + +// TranslateByDeepLX 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 TranslateByDeepLX(sourceLang, targetLang, text string, tagHandling string, proxyURL string, dlSession string) (DeepLXTranslationResult, error) { if text == "" { return DeepLXTranslationResult{ @@ -116,86 +251,82 @@ func TranslateByDeepLX(sourceLang, targetLang, text string, tagHandling string, }, nil } - // Get detected language if source language is auto - if sourceLang == "auto" || sourceLang == "" { - sourceLang = strings.ToUpper(whatlanggo.DetectLang(text).Iso6391()) - } - - // Prepare translation request using new LMT_handle_texts method - id := getRandomNumber() - iCount := getICount(text) - timestamp := getTimeStamp(iCount) - - postData := &PostData{ - Jsonrpc: "2.0", - Method: "LMT_handle_texts", - ID: id, - Params: Params{ - Splitting: "newlines", - Lang: Lang{ - SourceLangUserSelected: sourceLang, - TargetLang: targetLang, - }, - Texts: []TextItem{{ - Text: text, - RequestAlternatives: 3, - }}, - Timestamp: timestamp, + reqStruct := oneshotRequest{ + Text: []string{text}, + TargetLang: toOneshotLang(targetLang), + UsageType: "Translate", + AppInformation: appInformation{ + OS: "brex_macOS", + OSVersion: "brex_chrome_" + impersonatedChromeMajor + ".0.0.0", + AppVersion: chromeExtensionVersion, + AppBuild: "chrome_web_store", + InstanceID: instanceID, }, } + if sourceLang != "" && !strings.EqualFold(sourceLang, "auto") { + reqStruct.SourceLang = toOneshotLang(sourceLang) + } + bodyBytes, _ := json.Marshal(reqStruct) - // Format and apply body manipulation method like TypeScript - postStr := formatPostString(postData) - postStr = handlerBodyMethod(id, postStr) + endpoint := oneshotFreeEndpoint + if dlSession != "" { + endpoint = oneshotProEndpoint + } - // Make translation request - result, err := makeRequestWithBody(postStr, proxyURL, dlSession) + 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 } - // Process translation results using new format - textsArray := result.Get("result.texts").Array() - if len(textsArray) == 0 { + 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 } - // Get main translation - mainText := textsArray[0].Get("text").String() + mainText := translations[0].Get("text").String() if mainText == "" { return DeepLXTranslationResult{ + ID: id, Code: http.StatusServiceUnavailable, Message: "Translation failed", }, nil } - // Get alternatives - var alternatives []string - alternativesArray := textsArray[0].Get("alternatives").Array() - for _, alt := range alternativesArray { - altText := alt.Get("text").String() - if altText != "" { - alternatives = append(alternatives, altText) - } - } - - // Get detected source language from response - detectedLang := result.Get("result.lang").String() - if detectedLang != "" { - sourceLang = detectedLang + if detected := translations[0].Get("detected_source_language").String(); detected != "" { + sourceLang = strings.ToUpper(detected) } return DeepLXTranslationResult{ Code: http.StatusOK, ID: id, Data: mainText, - Alternatives: alternatives, + Alternatives: nil, // oneshot does not return alternatives SourceLang: sourceLang, TargetLang: targetLang, Method: map[bool]string{true: "Pro", false: "Free"}[dlSession != ""], diff --git a/translate/types.go b/translate/types.go index b653933..7b20801 100644 --- a/translate/types.go +++ b/translate/types.go @@ -2,7 +2,7 @@ * @Author: Vincent Young * @Date: 2024-09-16 11:59:24 * @LastEditors: Vincent Yang - * @LastEditTime: 2025-03-01 04:16:07 + * @LastEditTime: 2026-05-22 00:00:00 * @FilePath: /DeepLX/translate/types.go * @Telegram: https://t.me/missuo * @GitHub: https://github.com/missuo @@ -12,123 +12,16 @@ package translate -// Lang represents the language settings for translation -type Lang struct { - SourceLangUserSelected string `json:"source_lang_user_selected"` // Can be "auto" - TargetLang string `json:"target_lang"` - SourceLangComputed string `json:"source_lang_computed,omitempty"` -} - -// CommonJobParams represents common parameters for translation jobs -type CommonJobParams struct { - Formality string `json:"formality"` // Can be "undefined" - TranscribeAs string `json:"transcribe_as"` - Mode string `json:"mode"` - WasSpoken bool `json:"wasSpoken"` - AdvancedMode bool `json:"advancedMode"` - TextType string `json:"textType"` - RegionalVariant string `json:"regionalVariant,omitempty"` -} - -// Sentence represents a sentence in the translation request -type Sentence struct { - Prefix string `json:"prefix"` - Text string `json:"text"` - ID int `json:"id"` -} - -// Job represents a translation job -type Job struct { - Kind string `json:"kind"` - PreferredNumBeams int `json:"preferred_num_beams"` - RawEnContextBefore []string `json:"raw_en_context_before"` - RawEnContextAfter []string `json:"raw_en_context_after"` - Sentences []Sentence `json:"sentences"` -} - -// TextItem represents a text item for translation -type TextItem struct { - Text string `json:"text"` - RequestAlternatives int `json:"requestAlternatives"` -} - -// Params represents parameters for translation requests -type Params struct { - Splitting string `json:"splitting"` - Lang Lang `json:"lang"` - Texts []TextItem `json:"texts"` - Timestamp int64 `json:"timestamp"` -} - -// LegacyParams represents the old parameters structure for jobs (kept for compatibility) -type LegacyParams struct { - CommonJobParams CommonJobParams `json:"commonJobParams"` - Lang Lang `json:"lang"` - Jobs []Job `json:"jobs"` - Timestamp int64 `json:"timestamp"` -} - -// PostData represents the complete translation request -type PostData struct { - Jsonrpc string `json:"jsonrpc"` - Method string `json:"method"` - ID int64 `json:"id"` - Params Params `json:"params"` -} - -// TextResponse represents a single text response -type TextResponse struct { - Text string `json:"text"` - Alternatives []struct { - Text string `json:"text"` - } `json:"alternatives"` -} - -// TranslationResponse represents the response from LMT_handle_texts -type TranslationResponse struct { - Jsonrpc string `json:"jsonrpc"` - ID int64 `json:"id"` - Result struct { - Lang string `json:"lang"` - Texts []TextResponse `json:"texts"` - } `json:"result"` -} - -// LegacyTranslationResponse represents the old response format (kept for compatibility) -type LegacyTranslationResponse struct { - Jsonrpc string `json:"jsonrpc"` - ID int64 `json:"id"` - Result struct { - Translations []struct { - Beams []struct { - Sentences []SentenceResponse `json:"sentences"` - NumSymbols int `json:"num_symbols"` - RephraseVariant struct { // Added rephrase_variant - Name string `json:"name"` - } `json:"rephrase_variant"` - } `json:"beams"` - Quality string `json:"quality"` // Added quality - } `json:"translations"` - TargetLang string `json:"target_lang"` - SourceLang string `json:"source_lang"` - SourceLangIsConfident bool `json:"source_lang_is_confident"` - DetectedLanguages map[string]interface{} `json:"detectedLanguages"` // Use interface{} for now - } `json:"result"` -} - -// SentenceResponse is a helper struct for the response sentences -type SentenceResponse struct { - Text string `json:"text"` - IDS []int `json:"ids"` // Added IDS -} - -// DeepLXTranslationResult represents the final translation result +// DeepLXTranslationResult is the public response shape consumed by the HTTP +// handlers in the service package. The structure predates the migration to +// the oneshot endpoint; Alternatives is now always empty because oneshot does +// not return alternative translations, and ID is synthesized from time. type DeepLXTranslationResult struct { Code int `json:"code"` ID int64 `json:"id"` Message string `json:"message,omitempty"` - Data string `json:"data"` // The primary translated text - Alternatives []string `json:"alternatives"` // Other possible translations + Data string `json:"data"` + Alternatives []string `json:"alternatives"` SourceLang string `json:"source_lang"` TargetLang string `json:"target_lang"` Method string `json:"method"` diff --git a/translate/utils.go b/translate/utils.go deleted file mode 100644 index 69b6143..0000000 --- a/translate/utils.go +++ /dev/null @@ -1,59 +0,0 @@ -/* - * @Author: Vincent Young - * @Date: 2024-09-16 11:59:24 - * @LastEditors: Vincent Yang - * @LastEditTime: 2025-04-08 14:27:21 - * @FilePath: /DeepLX/translate/utils.go - * @Telegram: https://t.me/missuo - * @GitHub: https://github.com/missuo - * - * Copyright © 2024 by Vincent, All Rights Reserved. - */ - -package translate - -import ( - "encoding/json" - "math/rand" - "strings" - "time" -) - -// getICount returns the number of 'i' characters in the text -func getICount(translateText string) int64 { - return int64(strings.Count(translateText, "i")) -} - -// getRandomNumber generates a random number for request ID -func getRandomNumber() int64 { - src := rand.NewSource(time.Now().UnixNano()) - rng := rand.New(src) - num := rng.Int63n(99999) + 100000 - return num * 1000 -} - -// getTimeStamp generates timestamp for request based on i count -func getTimeStamp(iCount int64) int64 { - ts := time.Now().UnixMilli() - if iCount != 0 { - iCount = iCount + 1 - return ts - (ts % iCount) + iCount - } - return ts -} - -// formatPostString formats the request JSON string with specific spacing rules -func formatPostString(postData *PostData) string { - postBytes, _ := json.Marshal(postData) - postStr := string(postBytes) - return postStr -} - -// handlerBodyMethod manipulates the request body based on random number calculation -func handlerBodyMethod(random int64, body string) string { - calc := (random+5)%29 == 0 || (random+3)%13 == 0 - if calc { - return strings.Replace(body, `"method":"`, `"method" : "`, 1) - } - return strings.Replace(body, `"method":"`, `"method": "`, 1) -}