Snowmtl: Adds support for multiple languages (#6563)
* Move to src/all * Optimize translation * Fix image loading timeout and expired translator token * Fix extension initialization * Fix translator response
|
@ -1,7 +1,7 @@
|
||||||
ext {
|
ext {
|
||||||
extName = 'Snow Machine Translations'
|
extName = 'Snow Machine Translations'
|
||||||
extClass = '.Snowmtl'
|
extClass = '.SnowmltFactory'
|
||||||
extVersionCode = 3
|
extVersionCode = 4
|
||||||
isNsfw = true
|
isNsfw = true
|
||||||
}
|
}
|
||||||
|
|
Before Width: | Height: | Size: 7.4 KiB After Width: | Height: | Size: 7.4 KiB |
Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 3.7 KiB |
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB |
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 24 KiB |
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 39 KiB |
|
@ -0,0 +1,21 @@
|
||||||
|
package eu.kanade.tachiyomi.extension.all.snowmtl
|
||||||
|
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.annotation.RequiresApi
|
||||||
|
import eu.kanade.tachiyomi.source.Source
|
||||||
|
import eu.kanade.tachiyomi.source.SourceFactory
|
||||||
|
|
||||||
|
@RequiresApi(Build.VERSION_CODES.O)
|
||||||
|
class SnowmltFactory : SourceFactory {
|
||||||
|
override fun createSources(): List<Source> = languageList.map(::Snowmtl)
|
||||||
|
}
|
||||||
|
|
||||||
|
data class Source(val lang: String, val target: String = lang, val origin: String = "en")
|
||||||
|
|
||||||
|
private val languageList = listOf(
|
||||||
|
Source("en"),
|
||||||
|
Source("es"),
|
||||||
|
Source("id"),
|
||||||
|
Source("it"),
|
||||||
|
Source("pt-BR", "pt"),
|
||||||
|
)
|
|
@ -1,4 +1,4 @@
|
||||||
package eu.kanade.tachiyomi.extension.en.snowmtl
|
package eu.kanade.tachiyomi.extension.all.snowmtl
|
||||||
|
|
||||||
import eu.kanade.tachiyomi.source.model.Filter
|
import eu.kanade.tachiyomi.source.model.Filter
|
||||||
|
|
|
@ -1,7 +1,11 @@
|
||||||
package eu.kanade.tachiyomi.extension.en.snowmtl
|
package eu.kanade.tachiyomi.extension.all.snowmtl
|
||||||
|
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import androidx.annotation.RequiresApi
|
import androidx.annotation.RequiresApi
|
||||||
|
import eu.kanade.tachiyomi.extension.all.snowmtl.interceptors.ComposedImageInterceptor
|
||||||
|
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.network.GET
|
import eu.kanade.tachiyomi.network.GET
|
||||||
import eu.kanade.tachiyomi.network.interceptor.rateLimit
|
import eu.kanade.tachiyomi.network.interceptor.rateLimit
|
||||||
import eu.kanade.tachiyomi.source.model.Filter
|
import eu.kanade.tachiyomi.source.model.Filter
|
||||||
|
@ -23,22 +27,33 @@ import uy.kohesive.injekt.injectLazy
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
import java.util.Calendar
|
import java.util.Calendar
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
@RequiresApi(Build.VERSION_CODES.M)
|
@RequiresApi(Build.VERSION_CODES.O)
|
||||||
class Snowmtl : ParsedHttpSource() {
|
class Snowmtl(
|
||||||
|
source: Source,
|
||||||
|
) : ParsedHttpSource() {
|
||||||
|
|
||||||
override val name = "Snow Machine Translations"
|
override val name = "Snow Machine Translations"
|
||||||
|
|
||||||
override val baseUrl = "https://snowmtl.ru"
|
override val baseUrl = "https://snowmtl.ru"
|
||||||
|
|
||||||
override val lang = "en"
|
override val lang = source.lang
|
||||||
|
|
||||||
override val supportsLatest = true
|
override val supportsLatest = true
|
||||||
|
|
||||||
private val json: Json by injectLazy()
|
private val json: Json by injectLazy()
|
||||||
|
|
||||||
|
private val translatorClient = network.cloudflareClient.newBuilder()
|
||||||
|
.rateLimit(1, 3, TimeUnit.SECONDS)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
private val translator: TranslatorEngine = BingTranslator(translatorClient, headers)
|
||||||
|
|
||||||
override val client = network.cloudflareClient.newBuilder()
|
override val client = network.cloudflareClient.newBuilder()
|
||||||
.rateLimit(2)
|
.rateLimit(2)
|
||||||
|
.readTimeout(2, TimeUnit.MINUTES)
|
||||||
|
.addInterceptor(TranslationInterceptor(source, translator))
|
||||||
.addInterceptor(ComposedImageInterceptor(baseUrl, super.client))
|
.addInterceptor(ComposedImageInterceptor(baseUrl, super.client))
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
|
@ -158,7 +173,9 @@ class Snowmtl : ParsedHttpSource() {
|
||||||
dto.imageUrl.startsWith("http") -> dto.imageUrl
|
dto.imageUrl.startsWith("http") -> dto.imageUrl
|
||||||
else -> "https://${dto.imageUrl}"
|
else -> "https://${dto.imageUrl}"
|
||||||
}
|
}
|
||||||
val fragment = json.encodeToString<List<Translation>>(dto.translations)
|
val fragment = json.encodeToString<List<Dialog>>(
|
||||||
|
dto.dialogues.filter { it.text.isNotBlank() },
|
||||||
|
)
|
||||||
Page(index, imageUrl = "$imageUrl#$fragment")
|
Page(index, imageUrl = "$imageUrl#$fragment")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -203,6 +220,7 @@ class Snowmtl : ParsedHttpSource() {
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
val PAGE_REGEX = Regex(".*?\\.(webp|png|jpg|jpeg)#\\[.*?]", RegexOption.IGNORE_CASE)
|
||||||
const val PREFIX_SEARCH = "id:"
|
const val PREFIX_SEARCH = "id:"
|
||||||
private val dateFormat: SimpleDateFormat = SimpleDateFormat("dd MMMM yyyy", Locale.US)
|
private val dateFormat: SimpleDateFormat = SimpleDateFormat("dd MMMM yyyy", Locale.US)
|
||||||
}
|
}
|
|
@ -1,4 +1,4 @@
|
||||||
package eu.kanade.tachiyomi.extension.en.snowmtl
|
package eu.kanade.tachiyomi.extension.all.snowmtl
|
||||||
|
|
||||||
import android.graphics.Color
|
import android.graphics.Color
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
|
@ -18,13 +18,15 @@ import kotlinx.serialization.json.put
|
||||||
class PageDto(
|
class PageDto(
|
||||||
@SerialName("img_url")
|
@SerialName("img_url")
|
||||||
val imageUrl: String,
|
val imageUrl: String,
|
||||||
@Serializable(with = TranslationsListSerializer::class)
|
|
||||||
val translations: List<Translation> = emptyList(),
|
@SerialName("translations")
|
||||||
|
@Serializable(with = DialogListSerializer::class)
|
||||||
|
val dialogues: List<Dialog> = emptyList(),
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@RequiresApi(Build.VERSION_CODES.O)
|
@RequiresApi(Build.VERSION_CODES.O)
|
||||||
class Translation(
|
data class Dialog(
|
||||||
val x1: Float,
|
val x1: Float,
|
||||||
val y1: Float,
|
val y1: Float,
|
||||||
val x2: Float,
|
val x2: Float,
|
||||||
|
@ -55,12 +57,12 @@ class Translation(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private object TranslationsListSerializer :
|
private object DialogListSerializer :
|
||||||
JsonTransformingSerializer<List<Translation>>(ListSerializer(Translation.serializer())) {
|
JsonTransformingSerializer<List<Dialog>>(ListSerializer(Dialog.serializer())) {
|
||||||
override fun transformDeserialize(element: JsonElement): JsonElement {
|
override fun transformDeserialize(element: JsonElement): JsonElement {
|
||||||
return JsonArray(
|
return JsonArray(
|
||||||
element.jsonArray.map { jsonElement ->
|
element.jsonArray.map { jsonElement ->
|
||||||
val (coordinates, text) = getCoordinatesAndCaption(jsonElement)
|
val (coordinates, text) = getCoordinatesAndDialog(jsonElement)
|
||||||
|
|
||||||
buildJsonObject {
|
buildJsonObject {
|
||||||
put("x1", coordinates[0])
|
put("x1", coordinates[0])
|
||||||
|
@ -83,7 +85,7 @@ private object TranslationsListSerializer :
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getCoordinatesAndCaption(element: JsonElement): Pair<JsonArray, JsonElement> {
|
private fun getCoordinatesAndDialog(element: JsonElement): Pair<JsonArray, JsonElement> {
|
||||||
return try {
|
return try {
|
||||||
val arr = element.jsonArray
|
val arr = element.jsonArray
|
||||||
arr[0].jsonArray to arr[1]
|
arr[0].jsonArray to arr[1]
|
|
@ -1,4 +1,4 @@
|
||||||
package eu.kanade.tachiyomi.extension.en.snowmtl
|
package eu.kanade.tachiyomi.extension.all.snowmtl
|
||||||
|
|
||||||
import android.app.Activity
|
import android.app.Activity
|
||||||
import android.content.ActivityNotFoundException
|
import android.content.ActivityNotFoundException
|
|
@ -1,4 +1,4 @@
|
||||||
package eu.kanade.tachiyomi.extension.en.snowmtl
|
package eu.kanade.tachiyomi.extension.all.snowmtl.interceptors
|
||||||
|
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
import android.graphics.BitmapFactory
|
import android.graphics.BitmapFactory
|
||||||
|
@ -12,6 +12,8 @@ import android.text.Layout
|
||||||
import android.text.StaticLayout
|
import android.text.StaticLayout
|
||||||
import android.text.TextPaint
|
import android.text.TextPaint
|
||||||
import androidx.annotation.RequiresApi
|
import androidx.annotation.RequiresApi
|
||||||
|
import eu.kanade.tachiyomi.extension.all.snowmtl.Dialog
|
||||||
|
import eu.kanade.tachiyomi.extension.all.snowmtl.Snowmtl.Companion.PAGE_REGEX
|
||||||
import eu.kanade.tachiyomi.network.GET
|
import eu.kanade.tachiyomi.network.GET
|
||||||
import kotlinx.serialization.decodeFromString
|
import kotlinx.serialization.decodeFromString
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
|
@ -29,7 +31,7 @@ import java.io.InputStream
|
||||||
import kotlin.math.pow
|
import kotlin.math.pow
|
||||||
import kotlin.math.sqrt
|
import kotlin.math.sqrt
|
||||||
|
|
||||||
// The Interceptor joins the captions and pages of the manga.
|
// The Interceptor joins the dialogues and pages of the manga.
|
||||||
@RequiresApi(Build.VERSION_CODES.O)
|
@RequiresApi(Build.VERSION_CODES.O)
|
||||||
class ComposedImageInterceptor(
|
class ComposedImageInterceptor(
|
||||||
baseUrl: String,
|
baseUrl: String,
|
||||||
|
@ -44,22 +46,16 @@ class ComposedImageInterceptor(
|
||||||
"normal" to Pair<String, Typeface?>("$baseUrl/images/normal.ttf", null),
|
"normal" to Pair<String, Typeface?>("$baseUrl/images/normal.ttf", null),
|
||||||
)
|
)
|
||||||
|
|
||||||
private val imageRegex = Regex(
|
|
||||||
"$baseUrl.*?\\.(webp|png|jpg|jpeg)#\\[.*?]",
|
|
||||||
RegexOption.IGNORE_CASE,
|
|
||||||
)
|
|
||||||
|
|
||||||
override fun intercept(chain: Interceptor.Chain): Response {
|
override fun intercept(chain: Interceptor.Chain): Response {
|
||||||
val request = chain.request()
|
val request = chain.request()
|
||||||
val url = request.url.toString()
|
val url = request.url.toString()
|
||||||
|
|
||||||
val isPageImageUrl = imageRegex.containsMatchIn(url)
|
if (PAGE_REGEX.containsMatchIn(url).not()) {
|
||||||
if (isPageImageUrl.not()) {
|
|
||||||
return chain.proceed(request)
|
return chain.proceed(request)
|
||||||
}
|
}
|
||||||
|
|
||||||
val translation = request.url.fragment?.parseAs<List<Translation>>()
|
val dialogues = request.url.fragment?.parseAs<List<Dialog>>()
|
||||||
?: throw IOException("Translation not found")
|
?: throw IOException("Dialogues not found")
|
||||||
|
|
||||||
val imageRequest = request.newBuilder()
|
val imageRequest = request.newBuilder()
|
||||||
.url(url)
|
.url(url)
|
||||||
|
@ -78,14 +74,12 @@ class ComposedImageInterceptor(
|
||||||
|
|
||||||
val canvas = Canvas(bitmap)
|
val canvas = Canvas(bitmap)
|
||||||
|
|
||||||
translation
|
dialogues.forEach { dialog ->
|
||||||
.filter { it.text.isNotBlank() }
|
val textPaint = createTextPaint(selectFontFamily(dialog.type))
|
||||||
.forEach { caption ->
|
val dialogBox = createDialogBox(dialog, textPaint, bitmap)
|
||||||
val textPaint = createTextPaint(selectFontFamily(caption.type))
|
val y = getYAxis(textPaint, dialog, dialogBox)
|
||||||
val dialogBox = createDialogBox(caption, textPaint, bitmap)
|
canvas.draw(dialogBox, dialog, dialog.x1, y)
|
||||||
val y = getYAxis(textPaint, caption, dialogBox)
|
}
|
||||||
canvas.draw(dialogBox, caption, caption.x1, y)
|
|
||||||
}
|
|
||||||
|
|
||||||
val output = ByteArrayOutputStream()
|
val output = ByteArrayOutputStream()
|
||||||
|
|
||||||
|
@ -189,49 +183,49 @@ class ComposedImageInterceptor(
|
||||||
/**
|
/**
|
||||||
* Adjust the text to the center of the dialog box when feasible.
|
* Adjust the text to the center of the dialog box when feasible.
|
||||||
*/
|
*/
|
||||||
private fun getYAxis(textPaint: TextPaint, caption: Translation, dialogBox: StaticLayout): Float {
|
private fun getYAxis(textPaint: TextPaint, dialog: Dialog, dialogBox: StaticLayout): Float {
|
||||||
val fontHeight = textPaint.fontMetrics.let { it.bottom - it.top }
|
val fontHeight = textPaint.fontMetrics.let { it.bottom - it.top }
|
||||||
|
|
||||||
val dialogBoxLineCount = caption.height / fontHeight
|
val dialogBoxLineCount = dialog.height / fontHeight
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Centers text in y for captions smaller than the dialog box
|
* Centers text in y for dialogues smaller than the dialog box
|
||||||
*/
|
*/
|
||||||
return when {
|
return when {
|
||||||
dialogBox.lineCount < dialogBoxLineCount -> caption.centerY - dialogBox.lineCount / 2f * fontHeight
|
dialogBox.lineCount < dialogBoxLineCount -> dialog.centerY - dialogBox.lineCount / 2f * fontHeight
|
||||||
else -> caption.y1
|
else -> dialog.y1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun createDialogBox(caption: Translation, textPaint: TextPaint, bitmap: Bitmap): StaticLayout {
|
private fun createDialogBox(dialog: Dialog, textPaint: TextPaint, bitmap: Bitmap): StaticLayout {
|
||||||
var dialogBox = createBoxLayout(caption, textPaint)
|
var dialogBox = createBoxLayout(dialog, textPaint)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The best way I've found to adjust the text in the dialog box (Especially in long dialogues)
|
* The best way I've found to adjust the text in the dialog box (Especially in long dialogues)
|
||||||
*/
|
*/
|
||||||
while (dialogBox.height > caption.height) {
|
while (dialogBox.height > dialog.height) {
|
||||||
textPaint.textSize -= 0.5f
|
textPaint.textSize -= 0.5f
|
||||||
dialogBox = createBoxLayout(caption, textPaint)
|
dialogBox = createBoxLayout(dialog, textPaint)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use source setup
|
// Use source setup
|
||||||
if (caption.isNewApi) {
|
if (dialog.isNewApi) {
|
||||||
textPaint.color = caption.foregroundColor
|
textPaint.color = dialog.foregroundColor
|
||||||
textPaint.bgColor = caption.backgroundColor
|
textPaint.bgColor = dialog.backgroundColor
|
||||||
textPaint.style = if (caption.isBold) Paint.Style.FILL_AND_STROKE else Paint.Style.FILL
|
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.
|
* 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.
|
* It's a source configuration problem.
|
||||||
*/
|
*/
|
||||||
textPaint.adjustTextColor(caption, bitmap)
|
textPaint.adjustTextColor(dialog, bitmap)
|
||||||
|
|
||||||
return dialogBox
|
return dialogBox
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun createBoxLayout(caption: Translation, textPaint: TextPaint) =
|
private fun createBoxLayout(dialog: Dialog, textPaint: TextPaint) =
|
||||||
StaticLayout.Builder.obtain(caption.text, 0, caption.text.length, textPaint, caption.width.toInt()).apply {
|
StaticLayout.Builder.obtain(dialog.text, 0, dialog.text.length, textPaint, dialog.width.toInt()).apply {
|
||||||
setAlignment(Layout.Alignment.ALIGN_CENTER)
|
setAlignment(Layout.Alignment.ALIGN_CENTER)
|
||||||
setIncludePad(false)
|
setIncludePad(false)
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
|
@ -240,12 +234,12 @@ class ComposedImageInterceptor(
|
||||||
}.build()
|
}.build()
|
||||||
|
|
||||||
// Invert color in black dialog box.
|
// Invert color in black dialog box.
|
||||||
private fun TextPaint.adjustTextColor(caption: Translation, bitmap: Bitmap) {
|
private fun TextPaint.adjustTextColor(dialog: Dialog, bitmap: Bitmap) {
|
||||||
val pixelColor = bitmap.getPixel(caption.centerX.toInt(), caption.centerY.toInt())
|
val pixelColor = bitmap.getPixel(dialog.centerX.toInt(), dialog.centerY.toInt())
|
||||||
val inverseColor = (Color.WHITE - pixelColor) or Color.BLACK
|
val inverseColor = (Color.WHITE - pixelColor) or Color.BLACK
|
||||||
|
|
||||||
val minDistance = 80f // arbitrary
|
val minDistance = 80f // arbitrary
|
||||||
if (colorDistance(pixelColor, caption.foregroundColor) > minDistance) {
|
if (colorDistance(pixelColor, dialog.foregroundColor) > minDistance) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
color = inverseColor
|
color = inverseColor
|
||||||
|
@ -255,10 +249,10 @@ class ComposedImageInterceptor(
|
||||||
return json.decodeFromString(this)
|
return json.decodeFromString(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun Canvas.draw(layout: StaticLayout, caption: Translation, x: Float, y: Float) {
|
private fun Canvas.draw(layout: StaticLayout, dialog: Dialog, x: Float, y: Float) {
|
||||||
save()
|
save()
|
||||||
translate(x, y)
|
translate(x, y)
|
||||||
rotate(caption.angle)
|
rotate(dialog.angle)
|
||||||
layout.draw(this)
|
layout.draw(this)
|
||||||
restore()
|
restore()
|
||||||
}
|
}
|
|
@ -0,0 +1,141 @@
|
||||||
|
package eu.kanade.tachiyomi.extension.all.snowmtl.interceptors
|
||||||
|
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.annotation.RequiresApi
|
||||||
|
import eu.kanade.tachiyomi.extension.all.snowmtl.Dialog
|
||||||
|
import eu.kanade.tachiyomi.extension.all.snowmtl.Snowmtl.Companion.PAGE_REGEX
|
||||||
|
import eu.kanade.tachiyomi.extension.all.snowmtl.Source
|
||||||
|
import eu.kanade.tachiyomi.extension.all.snowmtl.translator.TranslatorEngine
|
||||||
|
import kotlinx.serialization.decodeFromString
|
||||||
|
import kotlinx.serialization.encodeToString
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import okhttp3.Interceptor
|
||||||
|
import okhttp3.Response
|
||||||
|
import uy.kohesive.injekt.injectLazy
|
||||||
|
|
||||||
|
@RequiresApi(Build.VERSION_CODES.O)
|
||||||
|
class TranslationInterceptor(
|
||||||
|
private val source: Source,
|
||||||
|
private val translator: TranslatorEngine,
|
||||||
|
) : Interceptor {
|
||||||
|
|
||||||
|
private val json: Json by injectLazy()
|
||||||
|
|
||||||
|
override fun intercept(chain: Interceptor.Chain): Response {
|
||||||
|
val request = chain.request()
|
||||||
|
val url = request.url.toString()
|
||||||
|
|
||||||
|
if (PAGE_REGEX.containsMatchIn(url).not() || source.target == source.origin) {
|
||||||
|
return chain.proceed(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
val dialogues = request.url.fragment?.parseAs<List<Dialog>>()
|
||||||
|
?: return chain.proceed(request)
|
||||||
|
|
||||||
|
val translated = translationOptimized(dialogues)
|
||||||
|
|
||||||
|
val newRequest = request.newBuilder()
|
||||||
|
.url("${url.substringBeforeLast("#")}#${json.encodeToString(translated)}")
|
||||||
|
.build()
|
||||||
|
|
||||||
|
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(source.origin, source.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 = token.decode().parseAs<List<String>>()
|
||||||
|
val key = list.first()
|
||||||
|
val text = list.last()
|
||||||
|
|
||||||
|
mapping[key]?.second?.dialog?.copy(text = text)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class AssociatedDialog(
|
||||||
|
val dialog: Dialog,
|
||||||
|
val content: String,
|
||||||
|
)
|
|
@ -0,0 +1,139 @@
|
||||||
|
package eu.kanade.tachiyomi.extension.all.snowmtl.translator
|
||||||
|
|
||||||
|
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
|
||||||
|
import okhttp3.Headers
|
||||||
|
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.Response
|
||||||
|
import org.jsoup.nodes.Element
|
||||||
|
import uy.kohesive.injekt.injectLazy
|
||||||
|
|
||||||
|
class BingTranslator(private val client: OkHttpClient, private val headers: Headers) : TranslatorEngine {
|
||||||
|
|
||||||
|
private val baseUrl = "https://www.bing.com"
|
||||||
|
|
||||||
|
private val translatorUrl = "$baseUrl/translator"
|
||||||
|
|
||||||
|
private val json: Json by injectLazy()
|
||||||
|
|
||||||
|
private var tokens: TokenGroup = TokenGroup()
|
||||||
|
|
||||||
|
override val capacity: Int = MAX_CHARS_ALLOW
|
||||||
|
|
||||||
|
private val attempts = 3
|
||||||
|
|
||||||
|
override fun translate(from: String, to: String, text: String): String {
|
||||||
|
if (tokens.isNotValid() && refreshTokens().not()) {
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
repeat(attempts) {
|
||||||
|
try {
|
||||||
|
val dto = client
|
||||||
|
.newCall(translatorRequest(from, to, text))
|
||||||
|
.execute()
|
||||||
|
.parseAs<List<TranslateDto>>()
|
||||||
|
|
||||||
|
return dto.firstOrNull()?.text ?: text
|
||||||
|
} catch (e: Exception) {
|
||||||
|
refreshTokens()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun refreshTokens(): Boolean {
|
||||||
|
tokens = loadTokens()
|
||||||
|
return tokens.isValid()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun translatorRequest(from: String, to: String, text: String): Request {
|
||||||
|
val url = "$baseUrl/ttranslatev3".toHttpUrl().newBuilder()
|
||||||
|
.addQueryParameter("isVertical", "1")
|
||||||
|
.addQueryParameter("", "") // Present in Bing URL
|
||||||
|
.addQueryParameter("IG", tokens.ig)
|
||||||
|
.addQueryParameter("IID", tokens.iid)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val headersApi = headers.newBuilder()
|
||||||
|
.set("Accept", "*/*")
|
||||||
|
.set("Origin", baseUrl)
|
||||||
|
.set("Referer", translatorUrl)
|
||||||
|
.set("Alt-Used", baseUrl)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val payload = FormBody.Builder()
|
||||||
|
.add("fromLang", from)
|
||||||
|
.add("to", to)
|
||||||
|
.add("text", text)
|
||||||
|
.add("tryFetchingGenderDebiasedTranslations", "true")
|
||||||
|
.add("token", tokens.token)
|
||||||
|
.add("key", tokens.key)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
return POST(url.toString(), headersApi, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadTokens(): TokenGroup {
|
||||||
|
val document = client.newCall(GET(translatorUrl, headers)).execute().asJsoup()
|
||||||
|
|
||||||
|
val scripts = document.select("script")
|
||||||
|
.map(Element::data)
|
||||||
|
|
||||||
|
val scriptOne: String = scripts.firstOrNull(TOKENS_REGEX::containsMatchIn)
|
||||||
|
?: return TokenGroup()
|
||||||
|
|
||||||
|
val scriptTwo: String = scripts.firstOrNull(IG_PARAM_REGEX::containsMatchIn)
|
||||||
|
?: return TokenGroup()
|
||||||
|
|
||||||
|
val matchOne = TOKENS_REGEX.find(scriptOne)?.groups
|
||||||
|
val matchTwo = IG_PARAM_REGEX.find(scriptTwo)?.groups
|
||||||
|
|
||||||
|
return TokenGroup(
|
||||||
|
token = matchOne?.get("token")?.value ?: "",
|
||||||
|
key = matchOne?.get("key")?.value ?: "",
|
||||||
|
ig = matchTwo?.get("ig")?.value ?: "",
|
||||||
|
iid = document.selectFirst("div[data-iid]:not([class])")?.attr("data-iid") ?: "",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private inline fun <reified T> Response.parseAs(): T {
|
||||||
|
return json.decodeFromStream(body.byteStream())
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
val TOKENS_REGEX = """params_AbusePreventionHelper(\s+)?=(\s+)?[^\[]\[(?<key>\d+),"(?<token>[^"]+)""".toRegex()
|
||||||
|
val IG_PARAM_REGEX = """IG:"(?<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,6 @@
|
||||||
|
package eu.kanade.tachiyomi.extension.all.snowmtl.translator
|
||||||
|
|
||||||
|
interface TranslatorEngine {
|
||||||
|
val capacity: Int
|
||||||
|
fun translate(from: String, to: String, text: String): String
|
||||||
|
}
|