You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
206 lines
5.4 KiB
206 lines
5.4 KiB
package translate
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"time"
|
|
|
|
"golang.org/x/oauth2"
|
|
"golang.org/x/oauth2/google"
|
|
"golang.org/x/oauth2/jwt"
|
|
)
|
|
|
|
// Google Cloud Translation v3
|
|
// Docs: https://cloud.google.com/translate/docs/reference/rest/v3/projects.locations/translateText
|
|
|
|
// 单次请求字符上限(v3 REST 单请求 30K 字符)。预留 10% 余量。
|
|
const (
|
|
maxCharsPerBatch = 27000
|
|
maxTextsPerBatch = 1024
|
|
scope = "https://www.googleapis.com/auth/cloud-translation"
|
|
)
|
|
|
|
type TranslateSys struct {
|
|
options Options
|
|
client *http.Client
|
|
tokenSrc oauth2.TokenSource
|
|
projectId string
|
|
}
|
|
|
|
func newSys(options Options) (sys *TranslateSys, err error) {
|
|
data := options.JsonContent
|
|
if len(data) == 0 {
|
|
if options.JsonPath == "" {
|
|
return nil, fmt.Errorf("google translate: JsonPath or JsonContent required")
|
|
}
|
|
data, err = os.ReadFile(options.JsonPath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read service account: %w", err)
|
|
}
|
|
}
|
|
|
|
// 解析出 project_id(如未显式配置)
|
|
projectId := options.ProjectId
|
|
if projectId == "" {
|
|
var sa struct {
|
|
ProjectId string `json:"project_id"`
|
|
}
|
|
if err = json.Unmarshal(data, &sa); err != nil {
|
|
return nil, fmt.Errorf("parse service account: %w", err)
|
|
}
|
|
projectId = sa.ProjectId
|
|
}
|
|
if projectId == "" {
|
|
return nil, fmt.Errorf("google translate: ProjectId is empty")
|
|
}
|
|
|
|
var cfg *jwt.Config
|
|
cfg, err = google.JWTConfigFromJSON(data, scope)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("jwt config: %w", err)
|
|
}
|
|
|
|
sys = &TranslateSys{
|
|
options: options,
|
|
client: &http.Client{Timeout: time.Duration(options.Timeout) * time.Second},
|
|
tokenSrc: cfg.TokenSource(context.Background()),
|
|
projectId: projectId,
|
|
}
|
|
return
|
|
}
|
|
|
|
type translateTextRequest struct {
|
|
SourceLanguageCode string `json:"sourceLanguageCode,omitempty"`
|
|
TargetLanguageCode string `json:"targetLanguageCode"`
|
|
Contents []string `json:"contents"`
|
|
MimeType string `json:"mimeType,omitempty"`
|
|
}
|
|
|
|
type translateTextResponse struct {
|
|
Translations []struct {
|
|
TranslatedText string `json:"translatedText"`
|
|
DetectedLanguageCode string `json:"detectedLanguageCode,omitempty"`
|
|
} `json:"translations"`
|
|
Error *struct {
|
|
Code int `json:"code"`
|
|
Message string `json:"message"`
|
|
Status string `json:"status"`
|
|
} `json:"error,omitempty"`
|
|
}
|
|
|
|
func (this *TranslateSys) endpoint() string {
|
|
return fmt.Sprintf("https://translation.googleapis.com/v3/projects/%s/locations/%s:translateText",
|
|
this.projectId, this.options.Location)
|
|
}
|
|
|
|
func (this *TranslateSys) Translate(ctx context.Context, from, to string, texts []string) (results []string, err error) {
|
|
if len(texts) == 0 {
|
|
return []string{}, nil
|
|
}
|
|
results = make([]string, 0, len(texts))
|
|
|
|
batch := make([]string, 0, maxTextsPerBatch)
|
|
batchChars := 0
|
|
flush := func() error {
|
|
if len(batch) == 0 {
|
|
return nil
|
|
}
|
|
part, e := this.translateBatch(ctx, from, to, batch)
|
|
if e != nil {
|
|
return e
|
|
}
|
|
results = append(results, part...)
|
|
batch = batch[:0]
|
|
batchChars = 0
|
|
return nil
|
|
}
|
|
|
|
for _, text := range texts {
|
|
tl := len([]rune(text))
|
|
if tl >= maxCharsPerBatch {
|
|
if err = flush(); err != nil {
|
|
return nil, err
|
|
}
|
|
part, e := this.translateBatch(ctx, from, to, []string{text})
|
|
if e != nil {
|
|
return nil, e
|
|
}
|
|
results = append(results, part...)
|
|
continue
|
|
}
|
|
if (batchChars+tl > maxCharsPerBatch || len(batch) >= maxTextsPerBatch) && len(batch) > 0 {
|
|
if err = flush(); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
batch = append(batch, text)
|
|
batchChars += tl
|
|
}
|
|
if err = flush(); err != nil {
|
|
return nil, err
|
|
}
|
|
return
|
|
}
|
|
|
|
func (this *TranslateSys) translateBatch(ctx context.Context, from, to string, texts []string) ([]string, error) {
|
|
token, err := this.tokenSrc.Token()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("get access token: %w", err)
|
|
}
|
|
|
|
body := translateTextRequest{
|
|
SourceLanguageCode: from,
|
|
TargetLanguageCode: to,
|
|
Contents: texts,
|
|
MimeType: "text/plain",
|
|
}
|
|
if from == "auto" {
|
|
body.SourceLanguageCode = ""
|
|
}
|
|
data, err := json.Marshal(body)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("marshal request: %w", err)
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, this.endpoint(), bytes.NewReader(data))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("new request: %w", err)
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("Authorization", "Bearer "+token.AccessToken)
|
|
|
|
resp, err := this.client.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("do request: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
raw, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read response: %w", err)
|
|
}
|
|
if resp.StatusCode/100 != 2 {
|
|
return nil, fmt.Errorf("google translate failed: status=%d body=%s", resp.StatusCode, string(raw))
|
|
}
|
|
|
|
var out translateTextResponse
|
|
if err = json.Unmarshal(raw, &out); err != nil {
|
|
return nil, fmt.Errorf("unmarshal response: %w body=%s", err, string(raw))
|
|
}
|
|
if out.Error != nil {
|
|
return nil, fmt.Errorf("google translate error: code=%d status=%s msg=%s", out.Error.Code, out.Error.Status, out.Error.Message)
|
|
}
|
|
results := make([]string, 0, len(out.Translations))
|
|
for _, t := range out.Translations {
|
|
results = append(results, t.TranslatedText)
|
|
}
|
|
if len(results) != len(texts) {
|
|
return nil, fmt.Errorf("google translate length mismatch: in=%d out=%d", len(texts), len(results))
|
|
}
|
|
return results, nil
|
|
}
|
|
|