From d7280f7fbbee8c6324abd04bc55c2f54117d3f2a Mon Sep 17 00:00:00 2001 From: zuoban <1061669148@qq.com> Date: Sat, 14 Sep 2024 05:15:00 +0800 Subject: [PATCH] =?UTF-8?q?feat(workers):=20=E6=B7=BB=E5=8A=A0=20workers?= =?UTF-8?q?=20=E6=BA=90=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- workers/package.json | 13 +++ workers/src/index.js | 226 ++++++++++++++++++++++++++++++++++++++++++ workers/wrangler.toml | 5 + 3 files changed, 244 insertions(+) create mode 100644 workers/package.json create mode 100644 workers/src/index.js create mode 100644 workers/wrangler.toml diff --git a/workers/package.json b/workers/package.json new file mode 100644 index 0000000..529c066 --- /dev/null +++ b/workers/package.json @@ -0,0 +1,13 @@ +{ + "name": "wstrans", + "version": "0.0.0", + "private": true, + "scripts": { + "deploy": "wrangler deploy", + "dev": "wrangler dev", + "start": "wrangler dev" + }, + "devDependencies": { + "wrangler": "^3.0.0" + } +} \ No newline at end of file diff --git a/workers/src/index.js b/workers/src/index.js new file mode 100644 index 0000000..54c9ec6 --- /dev/null +++ b/workers/src/index.js @@ -0,0 +1,226 @@ +const encoder = new TextEncoder(); +let expiredAt = null; +let endpoint = null; +let clientId = '76a75279-2ffa-4c3d-8db8-7b47252aa41c'; + +async function handleRequest(request) { + const requestUrl = new URL(request.url); + const path = requestUrl.pathname; + + if (path === '/tts') { + const text = requestUrl.searchParams.get('t') || ''; + const voiceName = requestUrl.searchParams.get('v') || 'zh-CN-XiaoxiaoMultilingualNeural'; + const rate = Number(requestUrl.searchParams.get('r')) || 0; + const pitch = Number(requestUrl.searchParams.get('p')) || 0; + const outputFormat = requestUrl.searchParams.get('o') || 'audio-24khz-48kbitrate-mono-mp3'; + const download = requestUrl.searchParams.get('d') || false; + const response = await getVoice(text, voiceName, rate, pitch, outputFormat, download); + return response; + } + + if(path === '/voices') { + const l = (requestUrl.searchParams.get('l') || '').toLowerCase(); + const f = requestUrl.searchParams.get('f'); + let response = await voiceList(); + + if(l.length > 0) { + response = response.filter(item => item.Locale.toLowerCase().includes(l)); + } + + if(f === "0") { + response = response.map(item => { + return ` +- !!org.nobody.multitts.tts.speaker.Speaker + avatar: '' + code: ${item.ShortName} + desc: '' + extendUI: '' + gender:${item.Gender === 'Female' ? '0' : '1'} + name: ${item.LocalName} + note: 'wpm: ${item.WordsPerMinute||''}' + param: '' + sampleRate: ${item.SampleRateHertz|| '24000'} + speed: 1.5 + type: 1 + volume: 1` + }) + return new Response(response.join('\n'), headers={ + 'Content-Type': 'application/html; charset=utf-8' + }); + }else if(f === "1"){ + const map = new Map(response.map(item => [item.ShortName, item.LocalName])) + return new Response(JSON.stringify(Object.fromEntries(map)), { + headers: { + 'Content-Type': 'application/json; charset=utf-8' + } + }); + }else { + return new Response(JSON.stringify(response), { + headers:{ + 'Content-Type': 'application/json; charset=utf-8' + } + }); + } + } + + const baseUrl = request.url.split('://')[0] + "://" +requestUrl.host; + return new Response(` +
    +
  1. /tts?t=[text]&v=[voice]&r=[rate]&p=[pitch]&o=[outputFormat] try
  2. +
  3. /voices?l=[locate, like zh|zh-CN]&f=[format, 0/1/empty 0(TTS-Server)|1(MultiTTS)] try
  4. +
+ `, { status: 200, headers: { 'Content-Type': 'text/html; charset=utf-8' }}); +} + +addEventListener('fetch', event => { + event.respondWith(handleRequest(event.request)); +}); + +async function getEndpoint() { + const endpointUrl = 'https://dev.microsofttranslator.com/apps/endpoint?api-version=1.0'; + const headers = { + 'Accept-Language': 'zh-Hans', + 'X-ClientVersion': '4.0.530a 5fe1dc6c', + 'X-UserId': '0f04d16a175c411e', + 'X-HomeGeographicRegion': 'zh-Hans-CN', + 'X-ClientTraceId': clientId, + + 'X-MT-Signature': await sign(endpointUrl), + 'User-Agent': 'okhttp/4.5.0', + 'Content-Type': 'application/json; charset=utf-8', + 'Content-Length': '0', + 'Accept-Encoding': 'gzip' + }; + + return fetch(endpointUrl, { + method: 'POST', + headers: headers + }).then(res => res.json()); +} + +async function sign(urlStr) { + const url = urlStr.split('://')[1]; + const encodedUrl = encodeURIComponent(url); + const uuidStr = uuid(); + const formattedDate = dateFormat(); + const bytesToSign = `MSTranslatorAndroidApp${encodedUrl}${formattedDate}${uuidStr}`.toLowerCase(); + const decode = await base64ToBytes('oik6PdDdMnOXemTbwvMn9de/h9lFnfBaCWbGMMZqqoSaQaqUOqjVGm5NqsmjcBI1x+sS9ugjB55HEJWRiFXYFw=='); + const signData = await hmacSha256(decode, bytesToSign); + const signBase64 = await bytesToBase64(signData); + return `MSTranslatorAndroidApp::${signBase64}::${formattedDate}::${uuidStr}`; +} + +function dateFormat() { + const formattedDate = new Date().toUTCString().replace(/GMT/, '').trim() + 'GMT'; + return formattedDate.toLowerCase(); +} + +async function getVoice(text, voiceName = 'zh-CN-XiaoxiaoMultilingualNeural', rate = 0, pitch = 0, outputFormat='audio-24khz-48kbitrate-mono-mp3', download=false) { + // get expiredAt from endpoint.t (jwt token) + if (!expiredAt || Date.now() / 1000 > expiredAt - 60) { + endpoint = await getEndpoint(); + const jwt = endpoint.t.split('.')[1]; + const decodedJwt = JSON.parse(atob(jwt)); + expiredAt = decodedJwt.exp; + const seconds = (expiredAt - Date.now() / 1000); + clientId = uuid(); + console.log('getEndpoint, expiredAt:' + (seconds/ 60) + 'm left') + } else { + const seconds = (expiredAt - Date.now() / 1000); + console.log('expiredAt:' + (seconds/ 60) + 'm left') + } + + const url = `https://${endpoint.r}.tts.speech.microsoft.com/cognitiveservices/v1`; + const headers = { + 'Authorization': endpoint.t, + 'Content-Type': 'application/ssml+xml', + 'User-Agent': 'okhttp/4.5.0', + 'X-Microsoft-OutputFormat': outputFormat + }; + const ssml = getSsml(text, voiceName, rate, pitch); + + const response = await fetch(url, { + method: 'POST', + headers: headers, + body: ssml + }); + if(response.ok) { + if (!download) { + return response; + } + resp = new Response(response.body,response) + resp.headers.set('Content-Disposition', `attachment; filename="${uuid()}.mp3"`); + return resp; + }else { + return new Response(response.statusText, { status: response.status }); + } +} + +function escapeXml(unsafe) { + return unsafe.replace(/[<>&'"]/g, function (c) { + switch (c) { + case '<': return '<'; + case '>': return '>'; + case '&': return '&'; + case '\'': return '''; + case '"': return '"'; + } + }); +} + +function getSsml(text, voiceName, rate, pitch) { + text = escapeXml(text); + return ` ${text} `; +} + +function voiceList() { + const headers = { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36 Edg/107.0.1418.26', + 'X-Ms-Useragent': 'SpeechStudio/2021.05.001', + 'Content-Type': 'application/json', + 'Origin': 'https://azure.microsoft.com', + 'Referer': 'https://azure.microsoft.com' + }; + + return fetch('https://eastus.api.speech.microsoft.com/cognitiveservices/voices/list', { + headers: headers, + cf: { + // Always cache this fetch regardless of content type + // for a max of 5 seconds before revalidating the resource + cacheTtl: 600, + cacheEverything: true, + cacheKey: "mstrans-voice-list", + }, + }).then(res => res.json()); +} + +async function hmacSha256(key, data) { + const cryptoKey = await crypto.subtle.importKey( + 'raw', + key, + { name: 'HMAC', hash: { name: 'SHA-256' } }, + false, + ['sign'] + ); + const signature = await crypto.subtle.sign('HMAC', cryptoKey, new TextEncoder().encode(data)); +return new Uint8Array(signature); +} + +async function base64ToBytes(base64) { + const binaryString = atob(base64); + const bytes = new Uint8Array(binaryString.length); + for (let i = 0; i < binaryString.length; i++) { + bytes[i] = binaryString.charCodeAt(i); + } + return bytes; +} + +async function bytesToBase64(bytes) { + const base64 = btoa(String.fromCharCode.apply(null, bytes)); + return base64; +} + + +function uuid(){ + return crypto.randomUUID().replace(/-/g, '') +} \ No newline at end of file diff --git a/workers/wrangler.toml b/workers/wrangler.toml new file mode 100644 index 0000000..9d2768d --- /dev/null +++ b/workers/wrangler.toml @@ -0,0 +1,5 @@ +name = "wstrans" +main = "src/index.js" +compatibility_date = "2024-04-15" +workers_dev = true +node_compat = true