Snowmtl: Add support to Google Translator (#7558)
* Add support to Google Translator * Remove Request.Builder * Remove unused code * Refatoring * Replace the hashmap with the when statement * Improves readability * Remove site color and add text outline
This commit is contained in:
parent
58941d9440
commit
11d6ca37c3
|
@ -27,8 +27,6 @@ import java.io.ByteArrayOutputStream
|
|||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.io.InputStream
|
||||
import kotlin.math.pow
|
||||
import kotlin.math.sqrt
|
||||
|
||||
// The Interceptor joins the dialogues and pages of the manga.
|
||||
@RequiresApi(Build.VERSION_CODES.O)
|
||||
|
@ -81,7 +79,7 @@ class ComposedImageInterceptor(
|
|||
val textPaint = createTextPaint(selectFontFamily(dialog.type))
|
||||
val dialogBox = createDialogBox(dialog, textPaint, bitmap)
|
||||
val y = getYAxis(textPaint, dialog, dialogBox)
|
||||
canvas.draw(dialogBox, dialog, dialog.x1, y)
|
||||
canvas.draw(textPaint, dialogBox, dialog, dialog.x1, y)
|
||||
}
|
||||
|
||||
val output = ByteArrayOutputStream()
|
||||
|
@ -222,18 +220,9 @@ class ComposedImageInterceptor(
|
|||
dialogBox = createBoxLayout(dialog, textPaint)
|
||||
}
|
||||
|
||||
// Use source setup
|
||||
if (dialog.isNewApi && language.disableSourceSettings.not()) {
|
||||
textPaint.color = dialog.foregroundColor
|
||||
textPaint.bgColor = dialog.backgroundColor
|
||||
textPaint.style = if (dialog.isBold) Paint.Style.FILL_AND_STROKE else Paint.Style.FILL
|
||||
}
|
||||
|
||||
/**
|
||||
* Forces font color correction if the background color of the dialog box and the font color are too similar.
|
||||
* It's a source configuration problem.
|
||||
*/
|
||||
textPaint.adjustTextColor(dialog, bitmap)
|
||||
textPaint.color = Color.BLACK
|
||||
textPaint.bgColor = Color.WHITE
|
||||
textPaint.strokeWidth = 2F
|
||||
|
||||
return dialogBox
|
||||
}
|
||||
|
@ -246,59 +235,45 @@ class ComposedImageInterceptor(
|
|||
setIncludePad(false)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
setBreakStrategy(LineBreaker.BREAK_STRATEGY_BALANCED)
|
||||
setHyphenationFrequency(Layout.HYPHENATION_FREQUENCY_FULL)
|
||||
}
|
||||
}.build()
|
||||
}
|
||||
|
||||
// Invert color in black dialog box.
|
||||
private fun TextPaint.adjustTextColor(dialog: Dialog, bitmap: Bitmap) {
|
||||
val pixelColor = bitmap.getPixel(dialog.centerX.toInt(), dialog.centerY.toInt())
|
||||
val inverseColor = (Color.WHITE - pixelColor) or Color.BLACK
|
||||
|
||||
val minDistance = 80f // arbitrary
|
||||
if (colorDistance(pixelColor, dialog.foregroundColor) > minDistance) {
|
||||
return
|
||||
}
|
||||
color = inverseColor
|
||||
}
|
||||
|
||||
private inline fun <reified T> String.parseAs(): T {
|
||||
return json.decodeFromString(this)
|
||||
}
|
||||
|
||||
private fun Canvas.draw(layout: StaticLayout, dialog: Dialog, x: Float, y: Float) {
|
||||
private fun Canvas.draw(textPaint: TextPaint, layout: StaticLayout, dialog: Dialog, x: Float, y: Float) {
|
||||
save()
|
||||
translate(x, y)
|
||||
rotate(dialog.angle)
|
||||
layout.draw(this)
|
||||
drawTextOutline(textPaint, layout)
|
||||
drawText(textPaint, layout)
|
||||
restore()
|
||||
}
|
||||
|
||||
private fun Canvas.drawText(textPaint: TextPaint, layout: StaticLayout) {
|
||||
textPaint.style = Paint.Style.FILL
|
||||
layout.draw(this)
|
||||
}
|
||||
|
||||
private fun Canvas.drawTextOutline(textPaint: TextPaint, layout: StaticLayout) {
|
||||
val foregroundColor = textPaint.color
|
||||
val style = textPaint.style
|
||||
|
||||
textPaint.color = textPaint.bgColor
|
||||
textPaint.style = Paint.Style.FILL_AND_STROKE
|
||||
|
||||
layout.draw(this)
|
||||
|
||||
textPaint.color = foregroundColor
|
||||
textPaint.style = style
|
||||
}
|
||||
|
||||
// https://pixelsconverter.com/pt-to-px
|
||||
private val Int.pt: Float get() = this / SCALED_DENSITY
|
||||
|
||||
// ============================= Utils ======================================
|
||||
|
||||
/**
|
||||
* Calculates the Euclidean distance between two colors in RGB space.
|
||||
*
|
||||
* This function takes two integer values representing hexadecimal colors,
|
||||
* converts them to their RGB components, and calculates the Euclidean distance
|
||||
* between the two colors. The distance provides a measure of how similar or
|
||||
* different the two colors are.
|
||||
*
|
||||
*/
|
||||
private fun colorDistance(colorA: Int, colorB: Int): Double {
|
||||
val a = Color.valueOf(colorA)
|
||||
val b = Color.valueOf(colorB)
|
||||
|
||||
return sqrt(
|
||||
(b.red() - a.red()).toDouble().pow(2) +
|
||||
(b.green() - a.green()).toDouble().pow(2) +
|
||||
(b.blue() - a.blue()).toDouble().pow(2),
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
// w3: Absolute Lengths [...](https://www.w3.org/TR/css3-values/#absolute-lengths)
|
||||
const val SCALED_DENSITY = 0.75f // 1px = 0.75pt
|
||||
|
|
|
@ -3,7 +3,7 @@ ext {
|
|||
extClass = '.SnowmtlFactory'
|
||||
themePkg = 'machinetranslations'
|
||||
baseUrl = 'https://snowmtl.ru'
|
||||
overrideVersionCode = 8
|
||||
overrideVersionCode = 9
|
||||
isNsfw = true
|
||||
}
|
||||
|
||||
|
|
|
@ -1,15 +1,18 @@
|
|||
package eu.kanade.tachiyomi.extension.all.snowmtl
|
||||
|
||||
import android.os.Build
|
||||
import android.widget.Toast
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.preference.ListPreference
|
||||
import androidx.preference.PreferenceScreen
|
||||
import androidx.preference.SwitchPreferenceCompat
|
||||
import eu.kanade.tachiyomi.extension.all.snowmtl.interceptors.TranslationInterceptor
|
||||
import eu.kanade.tachiyomi.extension.all.snowmtl.translator.BingTranslator
|
||||
import eu.kanade.tachiyomi.extension.all.snowmtl.translator.TranslatorEngine
|
||||
import eu.kanade.tachiyomi.extension.all.snowmtl.translator.bing.BingTranslator
|
||||
import eu.kanade.tachiyomi.extension.all.snowmtl.translator.google.GoogleTranslator
|
||||
import eu.kanade.tachiyomi.multisrc.machinetranslations.MachineTranslations
|
||||
import eu.kanade.tachiyomi.multisrc.machinetranslations.interceptors.ComposedImageInterceptor
|
||||
import eu.kanade.tachiyomi.network.interceptor.rateLimit
|
||||
import okhttp3.OkHttpClient
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.O)
|
||||
|
@ -22,32 +25,36 @@ class Snowmtl(
|
|||
) {
|
||||
override val lang = language.lang
|
||||
|
||||
private var disableTranslationOptimization: Boolean
|
||||
get() = preferences.getBoolean(DISABLE_TRANSLATION_OPTIM_PREF, language.disableTranslationOptimization)
|
||||
set(value) = preferences.edit().putBoolean(DISABLE_TRANSLATION_OPTIM_PREF, value).apply()
|
||||
private val translators = arrayOf(
|
||||
"Bing",
|
||||
"Google",
|
||||
)
|
||||
|
||||
private val settings: LanguageSetting get() = language.copy(
|
||||
fontSize = this@Snowmtl.fontSize,
|
||||
disableTranslationOptimization = this@Snowmtl.disableTranslationOptimization,
|
||||
disableSourceSettings = this@Snowmtl.disableSourceSettings,
|
||||
)
|
||||
|
||||
private val provider: String get() =
|
||||
preferences.getString(TRANSLATOR_PROVIDER_PREF, translators.first())!!
|
||||
|
||||
private val clientUtils = network.cloudflareClient.newBuilder()
|
||||
.rateLimit(3, 2, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
private val translator: TranslatorEngine = BingTranslator(clientUtils, headers)
|
||||
|
||||
// Keeps object state
|
||||
private val composeInterceptor = ComposedImageInterceptor(baseUrl, settings)
|
||||
private val translatorInterceptor = TranslationInterceptor(settings, translator)
|
||||
|
||||
override val useDefaultComposedImageInterceptor = false
|
||||
|
||||
override fun clientBuilder() = super.clientBuilder()
|
||||
.rateLimit(3)
|
||||
.addInterceptor(translatorInterceptor.apply { language = this@Snowmtl.settings })
|
||||
.addInterceptor(composeInterceptor.apply { language = this@Snowmtl.settings })
|
||||
override fun clientBuilder(): OkHttpClient.Builder {
|
||||
val translator: TranslatorEngine = when (provider) {
|
||||
"Google" -> GoogleTranslator(clientUtils, headers)
|
||||
else -> BingTranslator(clientUtils, headers)
|
||||
}
|
||||
|
||||
return super.clientBuilder()
|
||||
.rateLimit(3)
|
||||
.addInterceptor(TranslationInterceptor(settings, translator))
|
||||
.addInterceptor(ComposedImageInterceptor(baseUrl, settings))
|
||||
}
|
||||
|
||||
override fun setupPreferenceScreen(screen: PreferenceScreen) {
|
||||
super.setupPreferenceScreen(screen)
|
||||
|
@ -56,25 +63,35 @@ class Snowmtl(
|
|||
return
|
||||
}
|
||||
|
||||
if (language.disableTranslationOptimization.not()) {
|
||||
SwitchPreferenceCompat(screen.context).apply {
|
||||
key = DISABLE_TRANSLATION_OPTIM_PREF
|
||||
title = "⚠ Disable translation optimization"
|
||||
summary = buildString {
|
||||
append("Allows dialog boxes to be translated sequentially. ")
|
||||
append("Avoids problems when loading some translated pages caused by the translator's text formatting. ")
|
||||
append("Pages will load more slowly.")
|
||||
}
|
||||
setDefaultValue(false)
|
||||
setOnPreferenceChange { _, newValue ->
|
||||
disableTranslationOptimization = newValue as Boolean
|
||||
true
|
||||
}
|
||||
}.also(screen::addPreference)
|
||||
}
|
||||
ListPreference(screen.context).apply {
|
||||
key = TRANSLATOR_PROVIDER_PREF
|
||||
title = "Translator"
|
||||
entries = translators
|
||||
entryValues = translators
|
||||
summary = buildString {
|
||||
appendLine("Engine used to translate dialog boxes")
|
||||
append("\t* %s")
|
||||
}
|
||||
|
||||
setDefaultValue(translators.first())
|
||||
|
||||
setOnPreferenceChange { _, newValue ->
|
||||
val selected = newValue as String
|
||||
val index = this.findIndexOfValue(selected)
|
||||
val entry = entries[index] as String
|
||||
|
||||
Toast.makeText(
|
||||
screen.context,
|
||||
"The translator has been changed to '$entry'",
|
||||
Toast.LENGTH_LONG,
|
||||
).show()
|
||||
|
||||
true
|
||||
}
|
||||
}.also(screen::addPreference)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val DISABLE_TRANSLATION_OPTIM_PREF = "disableTranslationOptimizationPref"
|
||||
private const val TRANSLATOR_PROVIDER_PREF = "translatorProviderPref"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -11,7 +11,7 @@ class SnowmtlFactory : SourceFactory {
|
|||
}
|
||||
|
||||
private val languageList = listOf(
|
||||
LanguageSetting("ar", disableSourceSettings = true, disableTranslationOptimization = true),
|
||||
LanguageSetting("ar", disableSourceSettings = true),
|
||||
LanguageSetting("en"),
|
||||
LanguageSetting("es"),
|
||||
LanguageSetting("id"),
|
||||
|
@ -25,5 +25,4 @@ data class LanguageSetting(
|
|||
override val origin: String = "en",
|
||||
override var fontSize: Int = 24,
|
||||
override var disableSourceSettings: Boolean = false,
|
||||
val disableTranslationOptimization: Boolean = false,
|
||||
) : Language
|
||||
|
|
|
@ -6,6 +6,10 @@ import eu.kanade.tachiyomi.extension.all.snowmtl.LanguageSetting
|
|||
import eu.kanade.tachiyomi.extension.all.snowmtl.translator.TranslatorEngine
|
||||
import eu.kanade.tachiyomi.multisrc.machinetranslations.Dialog
|
||||
import eu.kanade.tachiyomi.multisrc.machinetranslations.MachineTranslations.Companion.PAGE_REGEX
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
|
@ -32,11 +36,14 @@ class TranslationInterceptor(
|
|||
val dialogues = request.url.fragment?.parseAs<List<Dialog>>()
|
||||
?: return chain.proceed(request)
|
||||
|
||||
val translated = when {
|
||||
language.disableTranslationOptimization -> dialogues.map {
|
||||
it.replaceText(translator.translate(language.origin, language.target, it.text))
|
||||
}
|
||||
else -> translationOptimized(dialogues)
|
||||
val translated = runBlocking(Dispatchers.IO) {
|
||||
dialogues.map { dialog ->
|
||||
async {
|
||||
dialog.replaceText(
|
||||
translator.translate(language.origin, language.target, dialog.text),
|
||||
)
|
||||
}
|
||||
}.awaitAll()
|
||||
}
|
||||
|
||||
val newRequest = request.newBuilder()
|
||||
|
@ -46,124 +53,13 @@ class TranslationInterceptor(
|
|||
return chain.proceed(newRequest)
|
||||
}
|
||||
|
||||
/**
|
||||
* Optimizes the translation of a list of dialogues.
|
||||
* This reduces the number of requests to the translator per page.
|
||||
*
|
||||
* @param dialogues List of Dialog objects to be translated.
|
||||
* @return List of translated Dialog objects.
|
||||
*/
|
||||
private fun translationOptimized(dialogues: List<Dialog>): List<Dialog> {
|
||||
val mapping = buildMap(dialogues)
|
||||
|
||||
val tokens = tokenizeAssociatedDialog(mapping).flatMap { token ->
|
||||
translator.translate(language.origin, language.target, token).split(delimiter)
|
||||
}
|
||||
|
||||
return replaceDialoguesWithTranslations(tokens, mapping)
|
||||
}
|
||||
|
||||
private fun replaceDialoguesWithTranslations(
|
||||
tokens: List<String>,
|
||||
mapping: Map<String, Pair<String, AssociatedDialog>>,
|
||||
) = tokens.mapNotNull { token ->
|
||||
val list = try {
|
||||
token.decode().parseAs<List<String>>()
|
||||
} catch (_: Exception) {
|
||||
// The translator may return an invalid JSON, but it keeps the pattern sent.
|
||||
TRANSLATOR_EXTRACT_REGEX.findAll(token).map {
|
||||
listOf(
|
||||
it.groups[1]?.value!!.encode(),
|
||||
it.groups[3]?.value!!.let { dialog ->
|
||||
dialog.takeIf { it.startsWith("\"") } ?: dialog.encode()
|
||||
},
|
||||
)
|
||||
}.toList().flatten()
|
||||
}
|
||||
|
||||
val key = list.first()
|
||||
val text = list.last()
|
||||
|
||||
mapping[key]?.second?.dialog?.replaceText(text)
|
||||
}
|
||||
|
||||
private fun Dialog.replaceText(value: String) = this.copy(
|
||||
textByLanguage = mutableMapOf(
|
||||
"text" to value,
|
||||
),
|
||||
)
|
||||
|
||||
/**
|
||||
* Tokenizes the associated dialogues.
|
||||
*
|
||||
* @param mapping Map of associated dialogues.
|
||||
* @return List of tokens.
|
||||
*/
|
||||
private fun tokenizeAssociatedDialog(mapping: Map<String, Pair<String, AssociatedDialog>>) =
|
||||
tokenizeText(mapping.map { it.value.second.content })
|
||||
|
||||
/**
|
||||
* Builds a map of dialogues associated with their identifiers.
|
||||
* I couldn't associate the translated dialog box with the zip method,
|
||||
* because some dialog boxes aren't associated correctly
|
||||
*
|
||||
* @param dialogues List of Dialog objects to be mapped.
|
||||
* @return Map where the key is the dialog identifier and the value is a pair containing the identifier and the associated dialog.
|
||||
*/
|
||||
private fun buildMap(dialogues: List<Dialog>): Map<String, Pair<String, AssociatedDialog>> {
|
||||
return dialogues.map {
|
||||
val payload = json.encodeToString<List<String>>(listOf(it.hashCode().toString(), it.text))
|
||||
.encode()
|
||||
it.hashCode().toString() to AssociatedDialog(it, payload)
|
||||
}.associateBy { it.first }
|
||||
}
|
||||
|
||||
// Prevents the translator's response from removing quotation marks from some texts
|
||||
private fun String.encode() = "\"${this}\""
|
||||
private fun String.decode() = this.substringAfter("\"").substringBeforeLast("\"")
|
||||
|
||||
private val delimiter: String = "¦"
|
||||
|
||||
/**
|
||||
* Tokenizes a list of texts based on the translator's character capacity per request
|
||||
*
|
||||
* @param texts List of texts to be tokenized.
|
||||
* @return List of tokens.
|
||||
*/
|
||||
private fun tokenizeText(texts: List<String>): List<String> {
|
||||
val tokenized = mutableListOf<String>()
|
||||
|
||||
val remainingText = buildString(translator.capacity) {
|
||||
texts.forEach { text ->
|
||||
if (length + text.length + delimiter.length > capacity()) {
|
||||
tokenized += toString()
|
||||
clear()
|
||||
}
|
||||
|
||||
if (isNotEmpty()) {
|
||||
append(delimiter)
|
||||
}
|
||||
|
||||
append(text)
|
||||
}
|
||||
}
|
||||
|
||||
if (remainingText.isNotEmpty()) {
|
||||
tokenized += remainingText
|
||||
}
|
||||
return tokenized
|
||||
}
|
||||
|
||||
private inline fun <reified T> String.parseAs(): T {
|
||||
return json.decodeFromString(this)
|
||||
}
|
||||
|
||||
companion object {
|
||||
val TRANSLATOR_EXTRACT_REGEX = """"?(-?\d+)(\\?")?,((\\?")?([^(\])]+))""".toRegex()
|
||||
}
|
||||
}
|
||||
|
||||
private class AssociatedDialog(
|
||||
val dialog: Dialog,
|
||||
val content: String,
|
||||
)
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
package eu.kanade.tachiyomi.extension.all.snowmtl.translator
|
||||
package eu.kanade.tachiyomi.extension.all.snowmtl.translator.bing
|
||||
|
||||
import eu.kanade.tachiyomi.extension.all.snowmtl.translator.TranslatorEngine
|
||||
import eu.kanade.tachiyomi.network.GET
|
||||
import eu.kanade.tachiyomi.network.POST
|
||||
import eu.kanade.tachiyomi.util.asJsoup
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.decodeFromStream
|
||||
import okhttp3.FormBody
|
||||
|
@ -15,7 +15,8 @@ import okhttp3.Response
|
|||
import org.jsoup.nodes.Element
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
|
||||
class BingTranslator(private val client: OkHttpClient, private val headers: Headers) : TranslatorEngine {
|
||||
class BingTranslator(private val client: OkHttpClient, private val headers: Headers) :
|
||||
TranslatorEngine {
|
||||
|
||||
private val baseUrl = "https://www.bing.com"
|
||||
|
||||
|
@ -25,7 +26,7 @@ class BingTranslator(private val client: OkHttpClient, private val headers: Head
|
|||
|
||||
private var tokens: TokenGroup = TokenGroup()
|
||||
|
||||
override val capacity: Int = MAX_CHARS_ALLOW
|
||||
override val capacity: Int = 1000
|
||||
|
||||
private val attempts = 3
|
||||
|
||||
|
@ -33,15 +34,10 @@ class BingTranslator(private val client: OkHttpClient, private val headers: Head
|
|||
if (tokens.isNotValid() && refreshTokens().not()) {
|
||||
return text
|
||||
}
|
||||
|
||||
val request = translatorRequest(from, to, text)
|
||||
repeat(attempts) {
|
||||
try {
|
||||
val dto = client
|
||||
.newCall(translatorRequest(from, to, text))
|
||||
.execute()
|
||||
.parseAs<List<TranslateDto>>()
|
||||
|
||||
return dto.firstOrNull()?.text ?: text
|
||||
return fetchTranslatedText(request)
|
||||
} catch (e: Exception) {
|
||||
refreshTokens()
|
||||
}
|
||||
|
@ -49,6 +45,11 @@ class BingTranslator(private val client: OkHttpClient, private val headers: Head
|
|||
return text
|
||||
}
|
||||
|
||||
private fun fetchTranslatedText(request: Request): String {
|
||||
return client.newCall(request).execute().parseAs<List<TranslateDto>>()
|
||||
.firstOrNull()!!.text
|
||||
}
|
||||
|
||||
private fun refreshTokens(): Boolean {
|
||||
tokens = loadTokens()
|
||||
return tokens.isValid()
|
||||
|
@ -111,29 +112,5 @@ class BingTranslator(private val client: OkHttpClient, private val headers: Head
|
|||
companion object {
|
||||
val TOKENS_REGEX = """params_AbusePreventionHelper(\s+)?=(\s+)?[^\[]\[(\d+),"([^"]+)""".toRegex()
|
||||
val IG_PARAM_REGEX = """IG:"([^"]+)""".toRegex()
|
||||
const val MAX_CHARS_ALLOW = 1000
|
||||
}
|
||||
}
|
||||
|
||||
private class TokenGroup(
|
||||
val token: String = "",
|
||||
val key: String = "",
|
||||
val iid: String = "",
|
||||
val ig: String = "",
|
||||
) {
|
||||
fun isNotValid() = listOf(token, key, iid, ig).any(String::isBlank)
|
||||
|
||||
fun isValid() = isNotValid().not()
|
||||
}
|
||||
|
||||
@Serializable
|
||||
private class TranslateDto(
|
||||
val translations: List<TextTranslated>,
|
||||
) {
|
||||
val text = translations.firstOrNull()?.text ?: ""
|
||||
}
|
||||
|
||||
@Serializable
|
||||
private class TextTranslated(
|
||||
val text: String,
|
||||
)
|
|
@ -0,0 +1,28 @@
|
|||
package eu.kanade.tachiyomi.extension.all.snowmtl.translator.bing
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
class BingTranslatorDto
|
||||
|
||||
class TokenGroup(
|
||||
val token: String = "",
|
||||
val key: String = "",
|
||||
val iid: String = "",
|
||||
val ig: String = "",
|
||||
) {
|
||||
fun isNotValid() = listOf(token, key, iid, ig).any(String::isBlank)
|
||||
|
||||
fun isValid() = isNotValid().not()
|
||||
}
|
||||
|
||||
@Serializable
|
||||
class TranslateDto(
|
||||
val translations: List<TextTranslated>,
|
||||
) {
|
||||
val text = translations.firstOrNull()?.text ?: ""
|
||||
}
|
||||
|
||||
@Serializable
|
||||
class TextTranslated(
|
||||
val text: String,
|
||||
)
|
|
@ -0,0 +1,84 @@
|
|||
package eu.kanade.tachiyomi.extension.all.snowmtl.translator.google
|
||||
|
||||
import eu.kanade.tachiyomi.extension.all.snowmtl.translator.TranslatorEngine
|
||||
import eu.kanade.tachiyomi.network.GET
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import okhttp3.Headers
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
import java.io.IOException
|
||||
|
||||
/**
|
||||
* This client is an adaptation of the following python repository: https://github.com/ssut/py-googletrans.
|
||||
*/
|
||||
class GoogleTranslator(private val client: OkHttpClient, private val headers: Headers) : TranslatorEngine {
|
||||
|
||||
private val baseUrl: String = "https://translate.googleapis.com"
|
||||
|
||||
private val webpage: String = "https://translate.google.com"
|
||||
|
||||
private val translatorUrl = "$baseUrl/translate_a/single"
|
||||
|
||||
override val capacity: Int = 5000
|
||||
|
||||
private val json: Json by injectLazy()
|
||||
|
||||
override fun translate(from: String, to: String, text: String): String {
|
||||
val request = translateRequest(text, from, to)
|
||||
return try { fetchTranslatedText(request) } catch (_: Exception) { text }
|
||||
}
|
||||
|
||||
private fun translateRequest(text: String, from: String, to: String): Request {
|
||||
return GET(clientUrlBuilder(text, from, to).build(), headersBuilder().build())
|
||||
}
|
||||
|
||||
private fun headersBuilder(): Headers.Builder = headers.newBuilder()
|
||||
.set("Origin", webpage)
|
||||
.set("Alt-Used", webpage.substringAfterLast("/"))
|
||||
.set("Referer", "$webpage/")
|
||||
|
||||
private fun clientUrlBuilder(text: String, src: String, dest: String, token: String = "xxxx"): HttpUrl.Builder {
|
||||
return translatorUrl.toHttpUrl().newBuilder()
|
||||
.setQueryParameter("client", "gtx")
|
||||
.setQueryParameter("sl", src)
|
||||
.setQueryParameter("tl", dest)
|
||||
.setQueryParameter("hl", dest)
|
||||
.setQueryParameter("ie", Charsets.UTF_8.toString())
|
||||
.setQueryParameter("oe", Charsets.UTF_8.toString())
|
||||
.setQueryParameter("otf", "1")
|
||||
.setQueryParameter("ssel", "0")
|
||||
.setQueryParameter("tsel", "0")
|
||||
.setQueryParameter("tk", token)
|
||||
.setQueryParameter("q", text)
|
||||
.apply {
|
||||
arrayOf("at", "bd", "ex", "ld", "md", "qca", "rw", "rm", "ss", "t").forEach {
|
||||
addQueryParameter("dt", it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun fetchTranslatedText(request: Request): String {
|
||||
val response = client.newCall(request).execute()
|
||||
|
||||
if (response.isSuccessful.not()) {
|
||||
throw IOException("Request failed: ${response.code}")
|
||||
}
|
||||
|
||||
return response.parseJson().let(::extractTranslatedText)
|
||||
}
|
||||
|
||||
private fun Response.parseJson(): JsonElement = json.parseToJsonElement(this.body.string())
|
||||
|
||||
private fun extractTranslatedText(data: JsonElement): String {
|
||||
return data.jsonArray[0].jsonArray.joinToString("") {
|
||||
it.jsonArray[0].jsonPrimitive.content
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,15 @@
|
|||
package eu.kanade.tachiyomi.extension.all.snowmtl.translator.google
|
||||
|
||||
import okhttp3.Response
|
||||
|
||||
class GoogleTranslatorDto
|
||||
|
||||
data class Translated(
|
||||
val from: String,
|
||||
val to: String,
|
||||
val origin: String,
|
||||
val text: String,
|
||||
val pronunciation: String,
|
||||
val extraData: Map<String, Any?>,
|
||||
val response: Response,
|
||||
)
|
Loading…
Reference in New Issue