Compare commits

..

No commits in common. "main" and "v1.2.0" have entirely different histories.
main ... v1.2.0

6 changed files with 36 additions and 300 deletions

View File

@ -5,28 +5,11 @@ on:
pull_request: pull_request:
name: Release name: Release
jobs: jobs:
Build: Build:
if: startsWith(github.ref, 'refs/tags/v') if: startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- { goos: darwin, goarch: amd64 }
- { goos: darwin, goarch: arm64 }
- { goos: linux, goarch: '386' }
- { goos: linux, goarch: amd64 }
- { goos: linux, goarch: arm64 }
- { goos: linux, goarch: mips }
- { goos: openbsd, goarch: amd64 }
- { goos: openbsd, goarch: arm64 }
- { goos: freebsd, goarch: amd64 }
- { goos: freebsd, goarch: arm64 }
- { goos: windows, goarch: '386' }
- { goos: windows, goarch: amd64 }
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@ -34,40 +17,12 @@ jobs:
with: with:
go-version: "1.24.2" go-version: "1.24.2"
- name: Build - run: bash .cross_compile.sh
env:
GOOS: ${{ matrix.goos }}
GOARCH: ${{ matrix.goarch }}
CGO_ENABLED: "0"
run: |
mkdir -p dist
BINARY="dist/deeplx_${GOOS}_${GOARCH}"
if [ "${GOOS}" = "windows" ]; then
BINARY="${BINARY}.exe"
fi
go build -trimpath -ldflags "-w -s" -o "${BINARY}" .
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: deeplx_${{ matrix.goos }}_${{ matrix.goarch }}
path: dist/*
if-no-files-found: error
Release:
needs: Build
if: startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
steps:
- name: Download artifacts
uses: actions/download-artifact@v4
with:
path: dist
merge-multiple: true
- name: Release - name: Release
uses: softprops/action-gh-release@v1 uses: softprops/action-gh-release@v1
with: with:
draft: false draft: false
generate_release_notes: true generate_release_notes: true
files: dist/* files: |
dist/*

3
.gitignore vendored
View File

@ -1,4 +1 @@
DeepLX DeepLX
# macOS Finder metadata
.DS_Store

View File

@ -6,8 +6,5 @@ RUN CGO_ENABLED=0 go build -a -installsuffix cgo -o deeplx .
FROM alpine:latest FROM alpine:latest
WORKDIR /app WORKDIR /app
RUN addgroup -S deeplx && adduser -h /app -G deeplx -SH deeplx COPY --from=builder /go/src/github.com/OwO-Network/DeepLX/deeplx /app/deeplx
USER deeplx:deeplx CMD ["/app/deeplx"]
COPY --from=builder --chown=deeplx:deeplx /go/src/github.com/OwO-Network/DeepLX/deeplx /app/deeplx
EXPOSE 1188
ENTRYPOINT ["/app/deeplx"]

View File

@ -16,20 +16,7 @@ install_deeplx(){
exit 1 exit 1
fi fi
echo -e "DeepLX latest version: ${last_version}, Start install..." echo -e "DeepLX latest version: ${last_version}, Start install..."
wget -q -N --no-check-certificate -O /usr/bin/deeplx https://github.com/OwO-Network/DeepLX/releases/download/${last_version}/deeplx_linux_amd64
arch=$(uname -m)
case "${arch}" in
x86_64 | amd64) file_arch="amd64" ;;
aarch64 | arm64) file_arch="arm64" ;;
i386 | i686) file_arch="386" ;;
mips) file_arch="mips" ;;
*)
echo -e "${red}Unsupported architecture: ${arch}${plain}"
exit 1
;;
esac
wget -q -N --no-check-certificate -O /usr/bin/deeplx https://github.com/OwO-Network/DeepLX/releases/download/${last_version}/deeplx_linux_${file_arch}
chmod +x /usr/bin/deeplx chmod +x /usr/bin/deeplx
wget -q -N --no-check-certificate -O /etc/systemd/system/deeplx.service https://raw.githubusercontent.com/OwO-Network/DeepLX/main/deeplx.service wget -q -N --no-check-certificate -O /etc/systemd/system/deeplx.service https://raw.githubusercontent.com/OwO-Network/DeepLX/main/deeplx.service

View File

@ -108,13 +108,7 @@ func Router(cfg *Config) *gin.Engine {
// Free API endpoint, No Pro Account required // Free API endpoint, No Pro Account required
r.POST("/translate", authMiddleware(cfg), func(c *gin.Context) { r.POST("/translate", authMiddleware(cfg), func(c *gin.Context) {
req := PayloadFree{} req := PayloadFree{}
if err := c.BindJSON(&req); err != nil { c.BindJSON(&req)
c.JSON(http.StatusBadRequest, gin.H{
"code": http.StatusBadRequest,
"message": "Invalid request payload",
})
return
}
sourceLang := req.SourceLang sourceLang := req.SourceLang
targetLang := req.TargetLang targetLang := req.TargetLang
@ -133,12 +127,7 @@ func Router(cfg *Config) *gin.Engine {
result, err := translate.TranslateByDeepLX(sourceLang, targetLang, translateText, tagHandling, proxyURL, "") result, err := translate.TranslateByDeepLX(sourceLang, targetLang, translateText, tagHandling, proxyURL, "")
if err != nil { if err != nil {
log.Printf("Translation failed: %s", err) log.Fatalf("Translation failed: %s", err)
c.JSON(http.StatusInternalServerError, gin.H{
"code": http.StatusInternalServerError,
"message": "Translation failed",
})
return
} }
if result.Code == http.StatusOK { if result.Code == http.StatusOK {
@ -163,13 +152,7 @@ func Router(cfg *Config) *gin.Engine {
// Pro API endpoint, Pro Account required // Pro API endpoint, Pro Account required
r.POST("/v1/translate", authMiddleware(cfg), func(c *gin.Context) { r.POST("/v1/translate", authMiddleware(cfg), func(c *gin.Context) {
req := PayloadFree{} req := PayloadFree{}
if err := c.BindJSON(&req); err != nil { c.BindJSON(&req)
c.JSON(http.StatusBadRequest, gin.H{
"code": http.StatusBadRequest,
"message": "Invalid request payload",
})
return
}
sourceLang := req.SourceLang sourceLang := req.SourceLang
targetLang := req.TargetLang targetLang := req.TargetLang
@ -208,12 +191,7 @@ func Router(cfg *Config) *gin.Engine {
result, err := translate.TranslateByDeepLX(sourceLang, targetLang, translateText, tagHandling, proxyURL, dlSession) result, err := translate.TranslateByDeepLX(sourceLang, targetLang, translateText, tagHandling, proxyURL, dlSession)
if err != nil { if err != nil {
log.Printf("Translation failed: %s", err) log.Fatalf("Translation failed: %s", err)
c.JSON(http.StatusInternalServerError, gin.H{
"code": http.StatusInternalServerError,
"message": "Translation failed",
})
return
} }
if result.Code == http.StatusOK { if result.Code == http.StatusOK {
@ -265,12 +243,7 @@ func Router(cfg *Config) *gin.Engine {
result, err := translate.TranslateByDeepLX("", targetLang, translateText, "", proxyURL, "") result, err := translate.TranslateByDeepLX("", targetLang, translateText, "", proxyURL, "")
if err != nil { if err != nil {
log.Printf("Translation failed: %s", err) log.Fatalf("Translation failed: %s", err)
c.JSON(http.StatusInternalServerError, gin.H{
"code": http.StatusInternalServerError,
"message": "Translation failed",
})
return
} }
if result.Code == http.StatusOK { if result.Code == http.StatusOK {

View File

@ -15,21 +15,17 @@ package translate
import ( import (
"compress/flate" "compress/flate"
"compress/gzip" "compress/gzip"
"context"
"crypto/rand" "crypto/rand"
"encoding/hex" "encoding/hex"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"net/http/cookiejar" "net/http/cookiejar"
"net/url" "net/url"
"sort"
"strings" "strings"
"sync" "sync"
"time" "time"
"unicode/utf8"
"github.com/andybalholm/brotli" "github.com/andybalholm/brotli"
"github.com/imroc/req/v3" "github.com/imroc/req/v3"
@ -60,26 +56,6 @@ const (
impersonatedChromeMajor = "120" impersonatedChromeMajor = "120"
chromeExtensionVersion = "1.86.0" chromeExtensionVersion = "1.86.0"
chromeExtensionID = "cofdbpoegempjloogbagkncekinflcnj" chromeExtensionID = "cofdbpoegempjloogbagkncekinflcnj"
// oneshot enforces a 1500-character hard cap on the total length of
// the `text` array (sum across all items). Source: the extension's
// own `G.notLoggedIn = 1500` constant in background.js. The server
// returns 400 `{"errors":{"text":["text exceeds maximum length"]}}`
// past this; bail early to spare the upstream and give the caller a
// faster, less ambiguous error.
maxFreeTextLength = 1500
// 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
// warmupTimeout caps the initial GET to www.deepl.com that seeds the
// cookie jar. Shorter than oneshotTimeout because warmup typically
// completes in well under a second; we'd rather skip a slow warmup
// (cookies are best-effort anyway) than block the first translation.
warmupTimeout = 5 * time.Second
) )
// instanceID mirrors the UUID the extension persists in chrome.storage on // instanceID mirrors the UUID the extension persists in chrome.storage on
@ -99,14 +75,6 @@ var (
cookieWarmer sync.Once cookieWarmer sync.Once
) )
// oneshotClients caches one req.Client per proxy URL so all translate
// 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
func sharedCookieJar() http.CookieJar { func sharedCookieJar() http.CookieJar {
cookieJarOnce.Do(func() { cookieJarOnce.Do(func() {
j, _ := cookiejar.New(nil) j, _ := cookiejar.New(nil)
@ -118,15 +86,10 @@ 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 (userCountry / verifiedBot) lands on .deepl.com,
// which is the eTLD+1 of oneshot-free.www.deepl.com, so subsequent POSTs // which is the eTLD+1 of oneshot-free.www.deepl.com, so subsequent POSTs
// to the oneshot endpoint will carry those cookies automatically. The // to the oneshot endpoint will carry those cookies automatically.
// 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) _, _ = client.R().Get("https://www.deepl.com/translator")
defer cancel()
_, _ = client.R().SetContext(ctx).Get("https://www.deepl.com/translator")
}) })
} }
@ -141,108 +104,26 @@ 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 // langCodeToOneshot translates DeepL's uppercase codes (DE, EN, ZH, ...)
// background.js (arrays `y` ~offset 6000 for the full target-capable // to the lowercase BCP-47-ish codes the oneshot endpoint requires (de,
// set, `A` for source-only aliases). Keys are the uppercase forms // en-US, zh-Hans, ...). Unknown codes fall through lowercased.
// callers pass; values are the lowercase BCP-47-ish forms the oneshot var langCodeToOneshot = map[string]string{
// endpoint expects ("de", "en-US", "zh-Hans", ...).
//
// targetLangMap is what the API accepts as `target_lang`. EN and PT
// are intentionally absent — DeepL deprecated them as target codes in
// 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
// resolve them to the regional default (en-US, pt-BR).
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", "EL": "el",
"EN-GB": "en-GB", "EN-US": "en-US", "EN": "en-US", "EN-GB": "en-GB", "EN-US": "en-US",
"ES": "es", "ES-419": "es-419", "ET": "et", "FI": "fi", "FR": "fr", "ES": "es", "ET": "et", "FI": "fi", "FR": "fr", "HU": "hu",
"HE": "he", "HU": "hu", "ID": "id", "IT": "it", "JA": "ja", "KO": "ko", "ID": "id", "IT": "it", "JA": "ja", "KO": "ko", "LT": "lt", "LV": "lv",
"LT": "lt", "LV": "lv", "NB": "nb", "NL": "nl", "PL": "pl", "NB": "nb", "NL": "nl", "PL": "pl",
"PT-BR": "pt-BR", "PT-PT": "pt-PT", "PT": "pt-BR", "PT-BR": "pt-BR", "PT-PT": "pt-PT",
"RO": "ro", "RU": "ru", "SK": "sk", "SL": "sl", "SV": "sv", "RO": "ro", "RU": "ru", "SK": "sk", "SL": "sl", "SV": "sv",
"TR": "tr", "UK": "uk", "VI": "vi", "TR": "tr", "UK": "uk",
"ZH": "zh-Hans", "ZH-HANS": "zh-Hans", "ZH-HANT": "zh-Hant", "ZH": "zh-Hans", "ZH-HANS": "zh-Hans", "ZH-HANT": "zh-Hant",
// Convenience aliases for legacy callers.
"EN": "en-US",
"PT": "pt-BR",
} }
// sourceLangMap is what the API accepts as `source_lang`. It is a func toOneshotLang(code string) string {
// superset of targetLangMap: EN and PT are first-class source codes if v, ok := langCodeToOneshot[strings.ToUpper(code)]; ok {
// (extension array `A`) mapping to the generic "en"/"pt" — used when return v
// the caller knows the input is English/Portuguese but does not want
// to commit to a regional variant.
var sourceLangMap = func() map[string]string {
m := make(map[string]string, len(targetLangMap)+2)
for k, v := range targetLangMap {
m[k] = v
} }
m["EN"] = "en" return strings.ToLower(code)
m["PT"] = "pt"
return m
}()
// resolveTargetLang validates and normalizes a user-supplied target
// language code. Returns "" and a non-nil error if the code is empty,
// "auto", or otherwise not in the supported set.
func resolveTargetLang(code string) (string, error) {
if code == "" {
return "", fmt.Errorf("target_lang is required")
}
if strings.EqualFold(code, "auto") {
return "", fmt.Errorf("target_lang cannot be \"auto\"; pick one of: %s", supportedTargetLangsList())
}
if v, ok := targetLangMap[strings.ToUpper(code)]; ok {
return v, nil
}
return "", fmt.Errorf("unsupported target_lang %q; valid codes: %s", code, supportedTargetLangsList())
}
// resolveSourceLang validates and normalizes a user-supplied source
// language code. An empty string or "auto" is allowed and returns
// ("", nil) so the caller omits source_lang and lets the server
// autodetect.
func resolveSourceLang(code string) (string, error) {
if code == "" || strings.EqualFold(code, "auto") {
return "", nil
}
if v, ok := sourceLangMap[strings.ToUpper(code)]; ok {
return v, nil
}
return "", fmt.Errorf("unsupported source_lang %q; valid codes: %s (or \"auto\")", code, supportedSourceLangsList())
}
// supportedTargetLangsList / supportedSourceLangsList return a sorted,
// comma-separated rendering of the supported codes for use in error
// messages. Cached at first call.
var (
targetLangsListOnce sync.Once
targetLangsList string
sourceLangsListOnce sync.Once
sourceLangsList string
)
func supportedTargetLangsList() string {
targetLangsListOnce.Do(func() {
targetLangsList = sortedKeys(targetLangMap)
})
return targetLangsList
}
func supportedSourceLangsList() string {
sourceLangsListOnce.Do(func() {
sourceLangsList = sortedKeys(sourceLangMap)
})
return sourceLangsList
}
func sortedKeys(m map[string]string) string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
return strings.Join(keys, ", ")
} }
// appInformation matches the snake_case shape produced by background.js // appInformation matches the snake_case shape produced by background.js
@ -275,33 +156,8 @@ type oneshotRequest struct {
// headers (pragma, cache-control, upgrade-insecure-requests, sec-fetch-user) // headers (pragma, cache-control, upgrade-insecure-requests, sec-fetch-user)
// that a fetch() never emits — wipe those so the WAF cannot tell us apart // that a fetch() never emits — wipe those so the WAF cannot tell us apart
// on that axis. // on that axis.
// getOneshotClient returns a process-wide cached client for the given
// proxy URL, creating it on first use. Sharing the client across
// requests is the single biggest latency win we have on the warm path:
// 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) {
if c, ok := oneshotClients.Load(proxyURL); ok {
return c.(*req.Client), nil
}
c, err := newOneshotClient(proxyURL)
if err != nil {
return nil, err
}
if actual, loaded := oneshotClients.LoadOrStore(proxyURL, c); loaded {
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)
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) client := req.C().ImpersonateChrome().SetCookieJar(sharedCookieJar())
for _, h := range []string{ for _, h := range []string{
"Pragma", "Pragma",
"Cache-Control", "Cache-Control",
@ -331,10 +187,11 @@ func newOneshotClient(proxyURL string) (*req.Client, error) {
// exactly. Omitting that header instead would put the request on a // exactly. Omitting that header instead would put 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 := newOneshotClient(proxyURL)
if err != nil { if err != nil {
return gjson.Result{}, 0, err return gjson.Result{}, 0, err
} }
warmCookies(client) // no-op after the first translation in the process
authValue := "None" authValue := "None"
if bearerToken != "" { if bearerToken != "" {
@ -394,32 +251,9 @@ func TranslateByDeepLX(sourceLang, targetLang, text string, tagHandling string,
}, nil }, nil
} }
resolvedTarget, err := resolveTargetLang(targetLang)
if err != nil {
return DeepLXTranslationResult{
Code: http.StatusBadRequest,
Message: err.Error(),
}, nil
}
resolvedSource, err := resolveSourceLang(sourceLang)
if err != nil {
return DeepLXTranslationResult{
Code: http.StatusBadRequest,
Message: err.Error(),
}, nil
}
if n := utf8.RuneCountInString(text); n > maxFreeTextLength {
return DeepLXTranslationResult{
Code: http.StatusRequestEntityTooLarge,
Message: fmt.Sprintf("text exceeds maximum length: %d characters (anonymous oneshot limit is %d)", n, maxFreeTextLength),
}, nil
}
reqStruct := oneshotRequest{ reqStruct := oneshotRequest{
Text: []string{text}, Text: []string{text},
TargetLang: resolvedTarget, TargetLang: toOneshotLang(targetLang),
SourceLang: resolvedSource, // empty = autodetect; omitempty drops the field
UsageType: "Translate", UsageType: "Translate",
AppInformation: appInformation{ AppInformation: appInformation{
OS: "brex_macOS", OS: "brex_macOS",
@ -429,6 +263,9 @@ func TranslateByDeepLX(sourceLang, targetLang, text string, tagHandling string,
InstanceID: instanceID, InstanceID: instanceID,
}, },
} }
if sourceLang != "" && !strings.EqualFold(sourceLang, "auto") {
reqStruct.SourceLang = toOneshotLang(sourceLang)
}
bodyBytes, _ := json.Marshal(reqStruct) bodyBytes, _ := json.Marshal(reqStruct)
endpoint := oneshotFreeEndpoint endpoint := oneshotFreeEndpoint
@ -439,16 +276,6 @@ func TranslateByDeepLX(sourceLang, targetLang, text string, tagHandling string,
id := time.Now().UnixMilli() id := time.Now().UnixMilli()
result, status, err := callOneshot(endpoint, bodyBytes, dlSession, proxyURL) result, status, err := callOneshot(endpoint, bodyBytes, dlSession, proxyURL)
if err != nil { if err != nil {
// Map upstream timeouts to 504 so callers can distinguish "DeepL
// took too long" from other 503 failure modes (DNS, TLS, etc.).
var ue *url.Error
if errors.Is(err, context.DeadlineExceeded) || (errors.As(err, &ue) && ue.Timeout()) {
return DeepLXTranslationResult{
ID: id,
Code: http.StatusGatewayTimeout,
Message: fmt.Sprintf("upstream DeepL request timed out after %s", oneshotTimeout),
}, nil
}
return DeepLXTranslationResult{ return DeepLXTranslationResult{
ID: id, ID: id,
Code: http.StatusServiceUnavailable, Code: http.StatusServiceUnavailable,