diff --git a/go.mod b/go.mod index 1fa02c8..35c1a94 100644 --- a/go.mod +++ b/go.mod @@ -3,8 +3,6 @@ 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/gin-contrib/cors v1.7.6 github.com/gin-gonic/gin v1.11.0 github.com/imroc/req/v3 v3.57.0 @@ -12,6 +10,7 @@ require ( ) require ( + github.com/andybalholm/brotli v1.2.0 // indirect 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 diff --git a/go.sum b/go.sum index 5a5fd83..38f0672 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,3 @@ -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= diff --git a/translate/translate.go b/translate/translate.go index 151c94c..c9e3dc2 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 @@ -14,100 +14,104 @@ package translate import ( "bytes" - "compress/flate" - "compress/gzip" + "encoding/json" "fmt" - "io" "net/http" "net/url" "strings" + "time" - "github.com/abadojack/whatlanggo" "github.com/imroc/req/v3" - - "github.com/andybalholm/brotli" "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 web frontend retired LMT_handle_jobs/LMT_handle_texts on www2.deepl.com +// for the interactive translator (now a SignalR/WebSocket channel). The browser +// extension and iOS app still use a stateless REST endpoint called "oneshot", +// which is what we target here. It accepts anonymous traffic with a literal +// `Authorization: None` header and lives on a separate rate-limit pool from +// the JSON-RPC backends, so it is far less prone to "Too many requests" 429s. +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() +// oneshot uses lowercase, BCP-47-ish language codes (de, en-US, zh-Hans). +// Callers historically pass DeepL's uppercase codes (DE, EN, ZH) — translate +// them here. Unknown codes fall through lowercased so future additions still +// work without a code change. +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", +} - // 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"}, +func toOneshotLang(code string) string { + if v, ok := langCodeToOneshot[strings.ToUpper(code)]; ok { + return v } + return strings.ToLower(code) +} - if dlSession != "" { - headers.Set("Cookie", "dl_session="+dlSession) - } +// oneshotResponse is the JSON shape returned by /v1/translate. +type oneshotResponse struct { + Translations []struct { + DetectedSourceLanguage string `json:"detected_source_language"` + Text string `json:"text"` + } `json:"translations"` +} - // Set proxy if provided +// callOneshot POSTs the prepared body and returns the parsed JSON. +// `bearerToken` is empty for anonymous (free) requests, in which case the +// extension sends the literal string "None" — replicate that exactly, because +// omitting the header changes the server's auth-handling branch. +func callOneshot(endpoint string, body []byte, bearerToken, proxyURL string) (gjson.Result, int, error) { + client := req.C().ImpersonateChrome() if proxyURL != "" { proxy, err := url.Parse(proxyURL) if err != nil { - return gjson.Result{}, err + return gjson.Result{}, 0, err } client.SetProxyURL(proxy.String()) } - // Make the request - r := client.R() - r.Headers = headers - resp, err := r. - SetBody(bytes.NewReader([]byte(postStr))). - Post(urlFull) + authValue := "None" + if bearerToken != "" { + authValue = "Bearer " + bearerToken + } + resp, err := client.R(). + SetHeader("Content-Type", "application/json"). + SetHeader("Accept", "*/*"). + SetHeader("Authorization", authValue). + SetHeader("Origin", "https://www.deepl.com"). + SetHeader("Referer", "https://www.deepl.com/"). + SetHeader("Sec-Fetch-Site", "same-site"). + SetHeader("Sec-Fetch-Mode", "cors"). + SetHeader("Sec-Fetch-Dest", "empty"). + SetBody(bytes.NewReader(body)). + Post(endpoint) if err != nil { - return gjson.Result{}, err + return gjson.Result{}, 0, 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) + raw, err := resp.ToBytes() if err != nil { - return gjson.Result{}, fmt.Errorf("failed to read response body: %w", err) + return gjson.Result{}, resp.StatusCode, fmt.Errorf("failed to read response body: %w", err) } - return gjson.ParseBytes(body), nil + return gjson.ParseBytes(raw), resp.StatusCode, nil } -// TranslateByDeepLX performs translation using DeepL API +// TranslateByDeepLX performs translation via DeepL's oneshot endpoint. +// Passing dlSession switches to the Pro endpoint; the value is sent verbatim +// as the Bearer token, so callers must supply an OAuth access token (not the +// legacy `dl_session` cookie) when using Pro. func TranslateByDeepLX(sourceLang, targetLang, text string, tagHandling string, proxyURL string, dlSession string) (DeepLXTranslationResult, error) { if text == "" { return DeepLXTranslationResult{ @@ -116,86 +120,76 @@ 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()) + reqBody := map[string]any{ + "text": []string{text}, + "target_lang": toOneshotLang(targetLang), + } + if sourceLang != "" && !strings.EqualFold(sourceLang, "auto") { + reqBody["source_lang"] = toOneshotLang(sourceLang) + } + bodyBytes, _ := json.Marshal(reqBody) + + endpoint := oneshotFreeEndpoint + if dlSession != "" { + endpoint = oneshotProEndpoint } - // 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, - }, - } - - // Format and apply body manipulation method like TypeScript - postStr := formatPostString(postData) - postStr = handlerBodyMethod(id, postStr) - - // 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 + detected := translations[0].Get("detected_source_language").String() + if detected != "" { + // Normalize back to DeepL-style uppercase for response continuity. + 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) -}