DeepLX/translate/translate.go
Vincent Young 10f33401e7
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.
2026-05-22 11:53:14 +08:00

302 lines
10 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 (
"compress/flate"
"compress/gzip"
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"github.com/andybalholm/brotli"
"github.com/imroc/req/v3"
"github.com/tidwall/gjson"
)
// 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"
// 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"
)
// 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()
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()
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
}
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{
Code: http.StatusNotFound,
Message: "No text to translate",
}, nil
}
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)
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
}
if detected := translations[0].Get("detected_source_language").String(); detected != "" {
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
}