diff --git a/README.md b/README.md index c0886c7..12ae68c 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,7 @@ Google Translate TUI (Originally) Supported Translator: [`ApertiumTranslate`](https://www.apertium.org/), [`ArgosTranslate`](https://translate.argosopentech.com/), +[`BingTranslate`](https://www.bing.com/translator), [`GoogleTranslate`](https://translate.google.com/), [`ReversoTranslate`](https://www.reverso.net/text-translation) @@ -48,6 +49,7 @@ See available languages on: - [Apertium Translate](https://www.apertium.org/) for `ApertiumTranslate` - [argosopentech/argos-translate](https://github.com/argosopentech/argos-translate#supported-languages) for `ArgosTranslate` +- [Bing language-support](https://learn.microsoft.com/en-us/azure/cognitive-services/translator/language-support#translation) for `BingTranslate` - [Google Language support](https://cloud.google.com/translate/docs/languages) for `GoogleTranslate` - [Reverso Translation](https://www.reverso.net/text-translation) for `ReversoTranslate` diff --git a/config.go b/config.go index e7b7276..a284137 100644 --- a/config.go +++ b/config.go @@ -22,6 +22,8 @@ func configInit() { "destination.language.apertiumtranslate": "English", "source.language.argostranslate": "English", "destination.language.argostranslate": "English", + "source.language.bingtranslate": "English", + "destination.language.bingtranslate": "English", "source.language.googletranslate": "English", "destination.language.googletranslate": "English", "source.language.reversotranslate": "English", diff --git a/internal/translate/bingtranslate/lang.go b/internal/translate/bingtranslate/lang.go new file mode 100644 index 0000000..9ca806d --- /dev/null +++ b/internal/translate/bingtranslate/lang.go @@ -0,0 +1,320 @@ +package bingtranslate + +var ( + lang = []string{ + "Afrikaans", + "Albanian", + "Amharic", + "Auto", + "Arabic", + "Armenian", + "Assamese", + "Azerbaijani", + "Bangla", + "Bashkir", + "Basque", + "Bosnian", + "Bulgarian", + "Cantonese (Traditional)", + "Catalan", + "Chinese (Literary)", + "Chinese Simplified", + "Chinese Traditional", + "Croatian", + "Czech", + "Danish", + "Dari", + "Divehi", + "Dutch", + "English", + "Estonian", + "Faroese", + "Fijian", + "Filipino", + "Finnish", + "French", + "French (Canada)", + "Galician", + "Ganda", + "Georgian", + "German", + "Greek", + "Gujarati", + "Haitian Creole", + "Hausa", + "Hebrew", + "Hindi", + "Hmong Daw", + "Hungarian", + "Icelandic", + "Igbo", + "Indonesian", + "Inuinnaqtun", + "Inuktitut", + "Inuktitut (Latin)", + "Irish", + "Italian", + "Japanese", + "Kannada", + "Kazakh", + "Khmer", + "Kinyarwanda", + "Klingon (Latin)", + "Korean", + "Kurdish (Central)", + "Kurdish (Northern)", + "Kyrgyz", + "Lao", + "Latvian", + "Lingala", + "Lithuanian", + "Macedonian", + "Malagasy", + "Malay", + "Malayalam", + "Maltese", + "Marathi", + "Mongolian (Cyrillic)", + "Mongolian (Traditional)", + "Myanmar (Burmese)", + "Māori", + "Nepali", + "Norwegian", + "Nyanja", + "Odia", + "Pashto", + "Persian", + "Polish", + "Portuguese (Brazil)", + "Portuguese (Portugal)", + "Punjabi", + "Querétaro Otomi", + "Romanian", + "Rundi", + "Russian", + "Samoan", + "Serbian (Cyrillic)", + "Serbian (Latin)", + "Sesotho", + "Sesotho sa Leboa", + "Setswana", + "Shona", + "Slovak", + "Slovenian", + "Somali", + "Spanish", + "Swahili", + "Swedish", + "Tahitian", + "Tamil", + "Tatar", + "Telugu", + "Thai", + "Tibetan", + "Tigrinya", + "Tongan", + "Turkish", + "Turkmen", + "Ukrainian", + "Urdu", + "Uyghur", + "Uzbek (Latin)", + "Vietnamese", + "Welsh", + "Xhosa", + "Yoruba", + "Yucatec Maya", + "Zulu", + } + langCode = map[string]string{ + "Afrikaans": "af", + "Albanian": "sq", + "Amharic": "am", + "Auto": "auto-detect", + "Arabic": "ar", + "Armenian": "hy", + "Assamese": "as", + "Azerbaijani": "az", + "Bangla": "bn", + "Bashkir": "ba", + "Basque": "eu", + "Bosnian": "bs", + "Bulgarian": "bg", + "Cantonese (Traditional)": "yue", + "Catalan": "ca", + "Chinese (Literary)": "lzh", + "Chinese Simplified": "zh-Hans", + "Chinese Traditional": "zh-Hant", + "Croatian": "hr", + "Czech": "cs", + "Danish": "da", + "Dari": "prs", + "Divehi": "dv", + "Dutch": "nl", + "English": "en", + "Estonian": "et", + "Faroese": "fo", + "Fijian": "fj", + "Filipino": "fil", + "Finnish": "fi", + "French": "fr", + "French (Canada)": "fr-CA", + "Galician": "gl", + "Ganda": "lug", + "Georgian": "ka", + "German": "de", + "Greek": "el", + "Gujarati": "gu", + "Haitian Creole": "ht", + "Hausa": "ha", + "Hebrew": "he", + "Hindi": "hi", + "Hmong Daw": "mww", + "Hungarian": "hu", + "Icelandic": "is", + "Igbo": "ig", + "Indonesian": "id", + "Inuinnaqtun": "ikt", + "Inuktitut": "iu", + "Inuktitut (Latin)": "iu-Latn", + "Irish": "ga", + "Italian": "it", + "Japanese": "ja", + "Kannada": "kn", + "Kazakh": "kk", + "Khmer": "km", + "Kinyarwanda": "rw", + "Klingon (Latin)": "tlh-Latn", + "Korean": "ko", + "Kurdish (Central)": "ku", + "Kurdish (Northern)": "kmr", + "Kyrgyz": "ky", + "Lao": "lo", + "Latvian": "lv", + "Lingala": "ln", + "Lithuanian": "lt", + "Macedonian": "mk", + "Malagasy": "mg", + "Malay": "ms", + "Malayalam": "ml", + "Maltese": "mt", + "Marathi": "mr", + "Mongolian (Cyrillic)": "mn-Cyrl", + "Mongolian (Traditional)": "mn-Mong", + "Myanmar (Burmese)": "my", + "Māori": "mi", + "Nepali": "ne", + "Norwegian": "nb", + "Nyanja": "nya", + "Odia": "or", + "Pashto": "ps", + "Persian": "fa", + "Polish": "pl", + "Portuguese (Brazil)": "pt", + "Portuguese (Portugal)": "pt-PT", + "Punjabi": "pa", + "Querétaro Otomi": "otq", + "Romanian": "ro", + "Rundi": "run", + "Russian": "ru", + "Samoan": "sm", + "Serbian (Cyrillic)": "sr-Cyrl", + "Serbian (Latin)": "sr-Latn", + "Sesotho": "st", + "Sesotho sa Leboa": "nso", + "Setswana": "tn", + "Shona": "sn", + "Slovak": "sk", + "Slovenian": "sl", + "Somali": "so", + "Spanish": "es", + "Swahili": "sw", + "Swedish": "sv", + "Tahitian": "ty", + "Tamil": "ta", + "Tatar": "tt", + "Telugu": "te", + "Thai": "th", + "Tibetan": "bo", + "Tigrinya": "ti", + "Tongan": "to", + "Turkish": "tr", + "Turkmen": "tk", + "Ukrainian": "uk", + "Urdu": "ur", + "Uyghur": "ug", + "Uzbek (Latin)": "uz", + "Vietnamese": "vi", + "Welsh": "cy", + "Xhosa": "xh", + "Yoruba": "yo", + "Yucatec Maya": "yua", + "Zulu": "zu", + } + voiceName = map[string]string{ + "Afrikaans": "af-ZA-AdriNeural", + "Amharic": "am-ET-MekdesNeural", + "Arabic": "ar-SA-HamedNeural", + "Bangla": "bn-IN-TanishaaNeural", + "Bulgarian": "bg-BG-BorislavNeural", + "Cantonese (Traditional)": "zh-HK-HiuGaaiNeural", + "Catalan": "ca-ES-JoanaNeural", + "Chinese Simplified": "zh-CN-XiaoxiaoNeural", + "Chinese Traditional": "zh-CN-XiaoxiaoNeural", + "Croatian": "hr-HR-SreckoNeural", + "Czech": "cs-CZ-AntoninNeural", + "Danish": "da-DK-ChristelNeural", + "Dutch": "nl-NL-ColetteNeural", + "English": "en-US-AriaNeural", + "Estonian": "et-EE-AnuNeural", + "Finnish": "fi-FI-NooraNeural", + "French": "fr-FR-DeniseNeural", + "French (Canada)": "fr-CA-SylvieNeural", + "German": "de-DE-KatjaNeural", + "Greek": "el-GR-NestorasNeural", + "Gujarati": "gu-IN-DhwaniNeural", + "Hebrew": "he-IL-AvriNeural", + "Hindi": "hi-IN-SwaraNeural", + "Hungarian": "hu-HU-TamasNeural", + "Icelandic": "is-IS-GudrunNeural", + "Indonesian": "id-ID-ArdiNeural", + "Irish": "ga-IE-OrlaNeural", + "Italian": "it-IT-DiegoNeural", + "Japanese": "ja-JP-NanamiNeural", + "Kannada": "kn-IN-SapnaNeural", + "Kazakh": "kk-KZ-AigulNeural", + "Khmer": "km-KH-SreymomNeural", + "Korean": "ko-KR-SunHiNeural", + "Lao": "lo-LA-KeomanyNeural", + "Latvian": "lv-LV-EveritaNeural", + "Lithuanian": "lt-LT-OnaNeural", + "Macedonian": "mk-MK-MarijaNeural", + "Malay": "ms-MY-OsmanNeural", + "Malayalam": "ml-IN-SobhanaNeural", + "Maltese": "mt-MT-GraceNeural", + "Marathi": "mr-IN-AarohiNeural", + "Myanmar (Burmese)": "my-MM-NilarNeural", + "Norwegian": "nb-NO-PernilleNeural", + "Pashto": "ps-AF-LatifaNeural", + "Persian": "fa-IR-DilaraNeural", + "Polish": "pl-PL-ZofiaNeural", + "Portuguese (Brazil)": "pt-BR-FranciscaNeural", + "Portuguese (Portugal)": "pt-PT-FernandaNeural", + "Romanian": "ro-RO-EmilNeural", + "Russian": "ru-RU-DariyaNeural", + "Serbian (Cyrillic)": "sr-RS-SophieNeural", + "Slovak": "sk-SK-LukasNeural", + "Slovenian": "sl-SI-RokNeural", + "Spanish": "es-ES-ElviraNeural", + "Swedish": "sv-SE-SofieNeural", + "Tamil": "ta-IN-PallaviNeural", + "Telugu": "te-IN-ShrutiNeural", + "Thai": "th-TH-NiwatNeural", + "Turkish": "tr-TR-EmelNeural", + "Ukrainian": "uk-UA-PolinaNeural", + "Urdu": "ur-IN-GulNeural", + "Uzbek (Latin)": "uz-UZ-MadinaNeural", + "Vietnamese": "vi-VN-NamMinhNeural", + "Welsh": "cy-GB-NiaNeural", + } +) diff --git a/internal/translate/bingtranslate/translator.go b/internal/translate/bingtranslate/translator.go new file mode 100644 index 0000000..82d35bd --- /dev/null +++ b/internal/translate/bingtranslate/translator.go @@ -0,0 +1,214 @@ +package bingtranslate + +import ( + "encoding/json" + "errors" + "fmt" + "io/ioutil" + "net/http" + "net/url" + "regexp" + "strings" + "time" + + "github.com/eeeXun/gtt/internal/translate/core" + "github.com/hajimehoshi/go-mp3" + "github.com/hajimehoshi/oto/v2" +) + +const ( + setUpURL = "https://www.bing.com/translator" + textURL = "https://www.bing.com/ttranslatev3?IG=%s&IID=%s" + posURL = "https://www.bing.com/tlookupv3?IG=%s&IID=%s" + ttsURL = "https://www.bing.com/tfettts?IG=%s&IID=%s" + ttsSSML = "%s" +) + +type BingTranslate struct { + *core.Language + *core.TTSLock + core.EngineName +} + +type setUpData struct { + ig string + iid string + key string + token string +} + +func NewBingTranslate() *BingTranslate { + return &BingTranslate{ + Language: core.NewLanguage(), + TTSLock: core.NewTTSLock(), + EngineName: core.NewEngineName("BingTranslate"), + } +} + +func (t *BingTranslate) GetAllLang() []string { + return lang +} + +func (t *BingTranslate) setUp() (*setUpData, error) { + var data setUpData + + res, err := http.Get(setUpURL) + if err != nil { + return nil, err + } + body, err := ioutil.ReadAll(res.Body) + if err != nil { + return nil, err + } + bodyStr := string(body) + igData := regexp.MustCompile(`IG:"([^"]+)"`).FindStringSubmatch(bodyStr) + if len(igData) < 2 { + return nil, errors.New(t.GetEngineName() + " IG not found") + } + data.ig = igData[1] + iidData := regexp.MustCompile(`data-iid="([^"]+)`).FindStringSubmatch(bodyStr) + if len(iidData) < 2 { + return nil, errors.New(t.GetEngineName() + " IID not found") + } + data.iid = iidData[1] + params := regexp.MustCompile(`params_AbusePreventionHelper = ([^;]+);`).FindStringSubmatch(bodyStr) + if len(params) < 2 { + return nil, errors.New(t.GetEngineName() + " Key and Token not found") + } + paramsStr := strings.Split(params[1][1:len(params[1])-1], ",") + data.key = paramsStr[0] + data.token = paramsStr[1][1 : len(paramsStr[1])-1] + + return &data, nil +} + +func (t *BingTranslate) Translate(message string) (translation, definition, partOfSpeech string, err error) { + var data []interface{} + + initData, err := t.setUp() + if err != nil { + return "", "", "", err + } + userData := url.Values{ + "fromLang": {langCode[t.GetSrcLang()]}, + "to": {langCode[t.GetDstLang()]}, + "text": {message}, + "key": {initData.key}, + "token": {initData.token}, + } + req, err := http.NewRequest("POST", + fmt.Sprintf(textURL, initData.ig, initData.iid), + strings.NewReader(userData.Encode()), + ) + req.Header.Add("Content-Type", "application/x-www-form-urlencoded") + req.Header.Add("User-Agent", core.UserAgent) + res, err := http.DefaultClient.Do(req) + if err != nil { + return "", "", "", err + } + body, err := ioutil.ReadAll(res.Body) + if err != nil { + return "", "", "", err + } + if err = json.Unmarshal(body, &data); err != nil { + return "", "", "", err + } + + if len(data) <= 0 { + return "", "", "", errors.New("Translation not found") + } + + // translation + translation = fmt.Sprintf("%v", + data[0].(map[string]interface{})["translations"].([]interface{})[0].(map[string]interface{})["text"]) + + // request part of speech + userData.Del("fromLang") + userData.Add("from", langCode[t.GetSrcLang()]) + req, err = http.NewRequest("POST", + fmt.Sprintf(posURL, initData.ig, initData.iid), + strings.NewReader(userData.Encode()), + ) + req.Header.Add("Content-Type", "application/x-www-form-urlencoded") + req.Header.Add("User-Agent", core.UserAgent) + res, err = http.DefaultClient.Do(req) + if err != nil { + return "", "", "", err + } + body, err = ioutil.ReadAll(res.Body) + if err != nil { + return "", "", "", err + } + // Bing will return the request with list when success. + // Otherwises, it would return map. Then the following err would not be nil. + if err = json.Unmarshal(body, &data); err == nil { + poses := make(posSet) + for _, pos := range data[0].(map[string]interface{})["translations"].([]interface{}) { + pos := pos.(map[string]interface{}) + var words posWords + + words.target = pos["displayTarget"].(string) + for _, backTranslation := range pos["backTranslations"].([]interface{}) { + backTranslation := backTranslation.(map[string]interface{}) + words.add(backTranslation["displayText"].(string)) + } + poses.add(pos["posTag"].(string), words) + } + partOfSpeech = poses.format() + } + + return translation, definition, partOfSpeech, nil +} + +func (t *BingTranslate) PlayTTS(lang, message string) error { + defer t.ReleaseLock() + + name, ok := voiceName[lang] + if !ok { + return errors.New(t.GetEngineName() + " does not support text to speech of " + lang) + } + initData, err := t.setUp() + if err != nil { + return err + } + userData := url.Values{ + // lang='%s' in ssml should be xx-XX, e.g. en-US + // But xx also works, e.g. en + // So don't do extra work to get xx-XX + "ssml": {fmt.Sprintf(ttsSSML, langCode[lang], name, message)}, + "key": {initData.key}, + "token": {initData.token}, + } + req, err := http.NewRequest("POST", + fmt.Sprintf(ttsURL, initData.ig, initData.iid), + strings.NewReader(userData.Encode()), + ) + req.Header.Add("Content-Type", "application/x-www-form-urlencoded") + req.Header.Add("User-Agent", core.UserAgent) + res, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + decoder, err := mp3.NewDecoder(res.Body) + if err != nil { + return err + } + otoCtx, readyChan, err := oto.NewContext(decoder.SampleRate(), 2, 2) + if err != nil { + return err + } + <-readyChan + player := otoCtx.NewPlayer(decoder) + player.Play() + for player.IsPlaying() { + if t.IsStopped() { + return nil + } + time.Sleep(time.Millisecond) + } + if err = player.Close(); err != nil { + return err + } + + return nil +} diff --git a/internal/translate/bingtranslate/utils.go b/internal/translate/bingtranslate/utils.go new file mode 100644 index 0000000..44c4521 --- /dev/null +++ b/internal/translate/bingtranslate/utils.go @@ -0,0 +1,40 @@ +package bingtranslate + +import ( + "fmt" +) + +type posWords struct { + target string + backTargets []string +} + +func (t *posWords) add(s string) { + t.backTargets = append(t.backTargets, s) +} + +type posSet map[string][]posWords + +func (set posSet) add(tag string, words posWords) { + set[tag] = append(set[tag], words) +} + +func (set posSet) format() (s string) { + for tag := range set { + s += fmt.Sprintf("[%s]\n", tag) + for _, words := range set[tag] { + s += fmt.Sprintf("\t%s:", words.target) + firstWord := true + for _, backTarget := range words.backTargets { + if firstWord { + s += fmt.Sprintf(" %s", backTarget) + firstWord = false + } else { + s += fmt.Sprintf(", %s", backTarget) + } + } + s += "\n" + } + } + return s +} diff --git a/internal/translate/core/utils.go b/internal/translate/core/utils.go new file mode 100644 index 0000000..28dfdac --- /dev/null +++ b/internal/translate/core/utils.go @@ -0,0 +1,5 @@ +package core + +const ( + UserAgent = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/112.0.0.0 Safari/537.36" +) diff --git a/internal/translate/reversotranslate/translator.go b/internal/translate/reversotranslate/translator.go index 9d969af..15a122e 100644 --- a/internal/translate/reversotranslate/translator.go +++ b/internal/translate/reversotranslate/translator.go @@ -19,7 +19,6 @@ import ( const ( textURL = "https://api.reverso.net/translate/v1/translation" ttsURL = "https://voice.reverso.net/RestPronunciation.svc/v1/output=json/GetVoiceStream/voiceName=%s?voiceSpeed=80&inputText=%s" - userAgent = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/110.0.0.0 Safari/537.36" ) type ReversoTranslate struct { @@ -65,7 +64,7 @@ func (t *ReversoTranslate) Translate(message string) (translation, definition, p textURL, bytes.NewBuffer([]byte(userData))) req.Header.Add("Content-Type", "application/json") - req.Header.Add("User-Agent", userAgent) + req.Header.Add("User-Agent", core.UserAgent) res, err := http.DefaultClient.Do(req) if err != nil { return "", "", "", err @@ -124,7 +123,7 @@ func (t *ReversoTranslate) PlayTTS(lang, message string) error { base64.StdEncoding.EncodeToString([]byte(message)), ) req, _ := http.NewRequest("GET", urlStr, nil) - req.Header.Add("User-Agent", userAgent) + req.Header.Add("User-Agent", core.UserAgent) res, err := http.DefaultClient.Do(req) if err != nil { return err diff --git a/internal/translate/translator.go b/internal/translate/translator.go index 36f5621..3a942dc 100644 --- a/internal/translate/translator.go +++ b/internal/translate/translator.go @@ -3,12 +3,19 @@ package translate import ( "github.com/eeeXun/gtt/internal/translate/apertiumtranslate" "github.com/eeeXun/gtt/internal/translate/argostranslate" + "github.com/eeeXun/gtt/internal/translate/bingtranslate" "github.com/eeeXun/gtt/internal/translate/googletranslate" "github.com/eeeXun/gtt/internal/translate/reversotranslate" ) var ( - AllTranslator = []string{"ApertiumTranslate", "ArgosTranslate", "GoogleTranslate", "ReversoTranslate"} + AllTranslator = []string{ + "ApertiumTranslate", + "BingTranslate", + "ArgosTranslate", + "GoogleTranslate", + "ReversoTranslate", + } ) type Translator interface { @@ -57,6 +64,8 @@ func NewTranslator(name string) Translator { translator = apertiumtranslate.NewApertiumTranslate() case "ArgosTranslate": translator = argostranslate.NewArgosTranslate() + case "BingTranslate": + translator = bingtranslate.NewBingTranslate() case "GoogleTranslate": translator = googletranslate.NewGoogleTranslate() case "ReversoTranslate":