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
This commit is contained in:
Chopper 2024-12-17 02:42:06 -03:00 committed by Draff
parent f228ad572d
commit c432620356
No known key found for this signature in database
GPG Key ID: E8A89F3211677653
18 changed files with 378 additions and 57 deletions

View File

@ -1,7 +1,7 @@
ext {
extName = 'Snow Machine Translations'
extClass = '.Snowmtl'
extVersionCode = 3
extClass = '.SnowmltFactory'
extVersionCode = 4
isNsfw = true
}

View File

Before

Width:  |  Height:  |  Size: 7.4 KiB

After

Width:  |  Height:  |  Size: 7.4 KiB

View File

Before

Width:  |  Height:  |  Size: 3.7 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

View File

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

View File

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 24 KiB

View File

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 39 KiB

View File

@ -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"),
)

View File

@ -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

View File

@ -1,7 +1,11 @@
package eu.kanade.tachiyomi.extension.en.snowmtl
package eu.kanade.tachiyomi.extension.all.snowmtl
import android.os.Build
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.interceptor.rateLimit
import eu.kanade.tachiyomi.source.model.Filter
@ -23,22 +27,33 @@ import uy.kohesive.injekt.injectLazy
import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Locale
import java.util.concurrent.TimeUnit
@RequiresApi(Build.VERSION_CODES.M)
class Snowmtl : ParsedHttpSource() {
@RequiresApi(Build.VERSION_CODES.O)
class Snowmtl(
source: Source,
) : ParsedHttpSource() {
override val name = "Snow Machine Translations"
override val baseUrl = "https://snowmtl.ru"
override val lang = "en"
override val lang = source.lang
override val supportsLatest = true
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()
.rateLimit(2)
.readTimeout(2, TimeUnit.MINUTES)
.addInterceptor(TranslationInterceptor(source, translator))
.addInterceptor(ComposedImageInterceptor(baseUrl, super.client))
.build()
@ -158,7 +173,9 @@ class Snowmtl : ParsedHttpSource() {
dto.imageUrl.startsWith("http") -> 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")
}
}
@ -203,6 +220,7 @@ class Snowmtl : ParsedHttpSource() {
}
companion object {
val PAGE_REGEX = Regex(".*?\\.(webp|png|jpg|jpeg)#\\[.*?]", RegexOption.IGNORE_CASE)
const val PREFIX_SEARCH = "id:"
private val dateFormat: SimpleDateFormat = SimpleDateFormat("dd MMMM yyyy", Locale.US)
}

View File

@ -1,4 +1,4 @@
package eu.kanade.tachiyomi.extension.en.snowmtl
package eu.kanade.tachiyomi.extension.all.snowmtl
import android.graphics.Color
import android.os.Build
@ -18,13 +18,15 @@ import kotlinx.serialization.json.put
class PageDto(
@SerialName("img_url")
val imageUrl: String,
@Serializable(with = TranslationsListSerializer::class)
val translations: List<Translation> = emptyList(),
@SerialName("translations")
@Serializable(with = DialogListSerializer::class)
val dialogues: List<Dialog> = emptyList(),
)
@Serializable
@RequiresApi(Build.VERSION_CODES.O)
class Translation(
data class Dialog(
val x1: Float,
val y1: Float,
val x2: Float,
@ -55,12 +57,12 @@ class Translation(
}
}
private object TranslationsListSerializer :
JsonTransformingSerializer<List<Translation>>(ListSerializer(Translation.serializer())) {
private object DialogListSerializer :
JsonTransformingSerializer<List<Dialog>>(ListSerializer(Dialog.serializer())) {
override fun transformDeserialize(element: JsonElement): JsonElement {
return JsonArray(
element.jsonArray.map { jsonElement ->
val (coordinates, text) = getCoordinatesAndCaption(jsonElement)
val (coordinates, text) = getCoordinatesAndDialog(jsonElement)
buildJsonObject {
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 {
val arr = element.jsonArray
arr[0].jsonArray to arr[1]

View File

@ -1,4 +1,4 @@
package eu.kanade.tachiyomi.extension.en.snowmtl
package eu.kanade.tachiyomi.extension.all.snowmtl
import android.app.Activity
import android.content.ActivityNotFoundException

View File

@ -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.BitmapFactory
@ -12,6 +12,8 @@ import android.text.Layout
import android.text.StaticLayout
import android.text.TextPaint
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 kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
@ -29,7 +31,7 @@ import java.io.InputStream
import kotlin.math.pow
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)
class ComposedImageInterceptor(
baseUrl: String,
@ -44,22 +46,16 @@ class ComposedImageInterceptor(
"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 {
val request = chain.request()
val url = request.url.toString()
val isPageImageUrl = imageRegex.containsMatchIn(url)
if (isPageImageUrl.not()) {
if (PAGE_REGEX.containsMatchIn(url).not()) {
return chain.proceed(request)
}
val translation = request.url.fragment?.parseAs<List<Translation>>()
?: throw IOException("Translation not found")
val dialogues = request.url.fragment?.parseAs<List<Dialog>>()
?: throw IOException("Dialogues not found")
val imageRequest = request.newBuilder()
.url(url)
@ -78,14 +74,12 @@ class ComposedImageInterceptor(
val canvas = Canvas(bitmap)
translation
.filter { it.text.isNotBlank() }
.forEach { caption ->
val textPaint = createTextPaint(selectFontFamily(caption.type))
val dialogBox = createDialogBox(caption, textPaint, bitmap)
val y = getYAxis(textPaint, caption, dialogBox)
canvas.draw(dialogBox, caption, caption.x1, y)
}
dialogues.forEach { dialog ->
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)
}
val output = ByteArrayOutputStream()
@ -189,49 +183,49 @@ class ComposedImageInterceptor(
/**
* 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 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 {
dialogBox.lineCount < dialogBoxLineCount -> caption.centerY - dialogBox.lineCount / 2f * fontHeight
else -> caption.y1
dialogBox.lineCount < dialogBoxLineCount -> dialog.centerY - dialogBox.lineCount / 2f * fontHeight
else -> dialog.y1
}
}
private fun createDialogBox(caption: Translation, textPaint: TextPaint, bitmap: Bitmap): StaticLayout {
var dialogBox = createBoxLayout(caption, textPaint)
private fun createDialogBox(dialog: Dialog, textPaint: TextPaint, bitmap: Bitmap): StaticLayout {
var dialogBox = createBoxLayout(dialog, textPaint)
/**
* 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
dialogBox = createBoxLayout(caption, textPaint)
dialogBox = createBoxLayout(dialog, textPaint)
}
// Use source setup
if (caption.isNewApi) {
textPaint.color = caption.foregroundColor
textPaint.bgColor = caption.backgroundColor
textPaint.style = if (caption.isBold) Paint.Style.FILL_AND_STROKE else Paint.Style.FILL
if (dialog.isNewApi) {
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(caption, bitmap)
textPaint.adjustTextColor(dialog, bitmap)
return dialogBox
}
private fun createBoxLayout(caption: Translation, textPaint: TextPaint) =
StaticLayout.Builder.obtain(caption.text, 0, caption.text.length, textPaint, caption.width.toInt()).apply {
private fun createBoxLayout(dialog: Dialog, textPaint: TextPaint) =
StaticLayout.Builder.obtain(dialog.text, 0, dialog.text.length, textPaint, dialog.width.toInt()).apply {
setAlignment(Layout.Alignment.ALIGN_CENTER)
setIncludePad(false)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
@ -240,12 +234,12 @@ class ComposedImageInterceptor(
}.build()
// Invert color in black dialog box.
private fun TextPaint.adjustTextColor(caption: Translation, bitmap: Bitmap) {
val pixelColor = bitmap.getPixel(caption.centerX.toInt(), caption.centerY.toInt())
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, caption.foregroundColor) > minDistance) {
if (colorDistance(pixelColor, dialog.foregroundColor) > minDistance) {
return
}
color = inverseColor
@ -255,10 +249,10 @@ class ComposedImageInterceptor(
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()
translate(x, y)
rotate(caption.angle)
rotate(dialog.angle)
layout.draw(this)
restore()
}

View File

@ -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,
)

View File

@ -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,
)

View File

@ -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
}