Batoto: Custom regex for cleaning title & fix duplicate manga (#11164)

* Batoto: config custom regex to be removed from title

* fix(BatoTo): add original (uncleaned) title to description

* verify regex

* add real-time validation for custom regex input & update summary on change

* Also clean title while browsing/searching

* Batoto: Fix duplicate manga due to name changed

Close #11037
This commit is contained in:
Cuong-Tran 2025-10-22 12:28:17 +07:00 committed by Draff
parent 6824183cf1
commit 8b0be67685
Signed by: Draff
GPG Key ID: E8A89F3211677653
2 changed files with 91 additions and 18 deletions

View File

@ -1,7 +1,7 @@
ext {
extName = 'Bato.to'
extClass = '.BatoToFactory'
extVersionCode = 55
extVersionCode = 56
isNsfw = true
}

View File

@ -2,7 +2,12 @@ package eu.kanade.tachiyomi.extension.all.batoto
import android.app.Application
import android.content.SharedPreferences
import android.text.Editable
import android.text.TextWatcher
import android.widget.Button
import android.widget.Toast
import androidx.preference.CheckBoxPreference
import androidx.preference.EditTextPreference
import androidx.preference.ListPreference
import androidx.preference.PreferenceScreen
import eu.kanade.tachiyomi.extension.BuildConfig
@ -87,9 +92,49 @@ open class BatoTo(
"You might also want to clear the database in advanced settings."
setDefaultValue(false)
}
val removeCustomPref = EditTextPreference(screen.context).apply {
key = "${REMOVE_TITLE_CUSTOM_PREF}_$lang"
title = "Custom regex to be removed from title"
summary = customRemoveTitle()
setDefaultValue("")
val validate = { str: String ->
runCatching { Regex(str) }
.map { true to "" }
.getOrElse { false to it.message }
}
setOnBindEditTextListener { editText ->
editText.addTextChangedListener(
object : TextWatcher {
override fun beforeTextChanged(p0: CharSequence?, p1: Int, p2: Int, p3: Int) {}
override fun onTextChanged(p0: CharSequence?, p1: Int, p2: Int, p3: Int) {}
override fun afterTextChanged(editable: Editable?) {
editable ?: return
val text = editable.toString()
val valid = validate(text)
editText.error = if (!valid.first) valid.second else null
editText.rootView.findViewById<Button>(android.R.id.button1)?.isEnabled = editText.error == null
}
},
)
}
setOnPreferenceChangeListener { _, newValue ->
val (isValid, message) = validate(newValue as String)
if (isValid) {
summary = newValue
} else {
Toast.makeText(screen.context, message, Toast.LENGTH_LONG).show()
}
isValid
}
}
screen.addPreference(mirrorPref)
screen.addPreference(altChapterListPref)
screen.addPreference(removeOfficialPref)
screen.addPreference(removeCustomPref)
}
private fun getMirrorPref(): String {
@ -120,12 +165,14 @@ open class BatoTo(
private fun isRemoveTitleVersion(): Boolean {
return preferences.getBoolean("${REMOVE_TITLE_VERSION_PREF}_$lang", false)
}
private fun customRemoveTitle(): String =
preferences.getString("${REMOVE_TITLE_CUSTOM_PREF}_$lang", "")!!
private fun SharedPreferences.migrateMirrorPref() {
val selectedMirror = getString("${MIRROR_PREF_KEY}_$lang", MIRROR_PREF_DEFAULT_VALUE)!!
if (selectedMirror in DEPRECATED_MIRRORS) {
edit().putString("${MIRROR_PREF_KEY}_$lang", MIRROR_PREF_DEFAULT_VALUE).commit()
edit().putString("${MIRROR_PREF_KEY}_$lang", MIRROR_PREF_DEFAULT_VALUE).apply()
}
}
@ -149,8 +196,9 @@ open class BatoTo(
val manga = SManga.create()
val item = element.select("a.item-cover")
val imgurl = item.select("img").attr("abs:src")
manga.setUrlWithoutDomain(item.attr("href"))
manga.setUrlWithoutDomain(stripSeriesUrl(item.attr("href")))
manga.title = element.select("a.item-title").text().removeEntities()
.cleanTitleIfNeeded()
manga.thumbnail_url = imgurl
return manga
}
@ -275,9 +323,10 @@ open class BatoTo(
val infoElement = document.select("div#mainer div.container-fluid")
val manga = SManga.create()
manga.title = infoElement.select("h3").text().removeEntities()
.cleanTitleIfNeeded()
manga.thumbnail_url = document.select("div.attr-cover img")
.attr("abs:src")
manga.setUrlWithoutDomain(infoElement.select("h3 a").attr("abs:href"))
manga.setUrlWithoutDomain(stripSeriesUrl(infoElement.select("h3 a").attr("abs:href")))
return MangasPage(listOf(manga), false)
}
@ -308,16 +357,18 @@ open class BatoTo(
private fun searchUtilsFromElement(element: Element): SManga {
val manga = SManga.create()
manga.setUrlWithoutDomain(element.select("td a").attr("href"))
manga.setUrlWithoutDomain(stripSeriesUrl(element.select("td a").attr("href")))
manga.title = element.select("td a").text()
.cleanTitleIfNeeded()
manga.thumbnail_url = element.select("img").attr("abs:src")
return manga
}
private fun searchHistoryFromElement(element: Element): SManga {
val manga = SManga.create()
manga.setUrlWithoutDomain(element.select(".position-relative a").attr("href"))
manga.setUrlWithoutDomain(stripSeriesUrl(element.select(".position-relative a").attr("href")))
manga.title = element.select(".position-relative a").text()
.cleanTitleIfNeeded()
manga.thumbnail_url = element.select("img").attr("abs:src")
return manga
}
@ -346,8 +397,6 @@ open class BatoTo(
}
return super.mangaDetailsRequest(manga)
}
private var titleRegex: Regex =
Regex("\\([^()]*\\)|\\{[^{}]*\\}|\\[(?:(?!]).)*]|«[^»]*»|〘[^〙]*〙|「[^」]*」|『[^』]*』|≪[^≫]*≫|﹛[^﹜]*﹜|〖[^〖〗]*〗|𖤍.+?𖤍|《[^》]*》|⌜.+?⌝|⟨[^⟩]*⟩|\\/Official|\\/ Official", RegexOption.IGNORE_CASE)
override fun mangaDetailsParse(document: Document): SManga {
val infoElement = document.selectFirst("div#mainer div.container-fluid")!!
@ -368,19 +417,19 @@ open class BatoTo(
append(it.text().split('/').joinToString("\n") { "${it.trim()}" })
}
}.trim()
val cleanedTitle = if (isRemoveTitleVersion()) {
originalTitle.replace(titleRegex, "").trim()
} else {
originalTitle
}
val cleanedTitle = originalTitle.cleanTitleIfNeeded()
manga.title = cleanedTitle
manga.author = infoElement.select("div.attr-item:contains(author) span").text()
manga.artist = infoElement.select("div.attr-item:contains(artist) span").text()
manga.status = parseStatus(workStatus, uploadStatus)
manga.genre = infoElement.select(".attr-item b:contains(genres) + span ").joinToString { it.text() }
manga.description = description
manga.description = if (originalTitle.trim() != cleanedTitle) {
listOf(originalTitle, description)
.joinToString("\n\n")
} else {
description
}
manga.thumbnail_url = document.select("div.attr-cover img").attr("abs:src")
return manga
}
@ -424,9 +473,9 @@ open class BatoTo(
}
override fun chapterListRequest(manga: SManga): Request {
return if (getAltChapterListPref()) {
val id = manga.url.substringBeforeLast("/").substringAfterLast("/").trim()
val id = seriesIdRegex.find(manga.url)
?.groups?.get(1)?.value?.trim()
return if (getAltChapterListPref() && !id.isNullOrBlank()) {
GET("$baseUrl/rss/series/$id.xml", headers)
} else if (manga.url.startsWith("http")) {
// Check if trying to use a deprecated mirror, force current mirror
@ -571,6 +620,19 @@ open class BatoTo(
private fun String.removeEntities(): String = Parser.unescapeEntities(this, true)
private fun String.cleanTitleIfNeeded(): String {
var tempTitle = this
customRemoveTitle().takeIf { it.isNotEmpty() }?.let { customRegex ->
runCatching {
tempTitle = tempTitle.replace(Regex(customRegex), "")
}
}
if (isRemoveTitleVersion()) {
tempTitle = tempTitle.replace(titleRegex, "")
}
return tempTitle.trim()
}
override fun getFilterList() = FilterList(
LetterFilter(getLetterFilter(), 0),
Filter.Separator(),
@ -1026,10 +1088,18 @@ open class BatoTo(
CheckboxFilterOption("pt-PT", "Portuguese (Portugal)"),
).filterNot { it.value == siteLang }
private fun stripSeriesUrl(url: String): String {
val matchResult = seriesUrlRegex.find(url)
return matchResult?.groups?.get(1)?.value ?: url
}
companion object {
private val seriesUrlRegex = Regex("""(.*/series/\d+)/.*""")
private val seriesIdRegex = Regex("""series/(\d+)""")
private const val MIRROR_PREF_KEY = "MIRROR"
private const val MIRROR_PREF_TITLE = "Mirror"
private const val REMOVE_TITLE_VERSION_PREF = "REMOVE_TITLE_VERSION"
private const val REMOVE_TITLE_CUSTOM_PREF = "REMOVE_TITLE_CUSTOM"
private val MIRROR_PREF_ENTRIES = arrayOf(
"Auto",
// https://batotomirrors.pages.dev/
@ -1104,5 +1174,8 @@ open class BatoTo(
private const val ALT_CHAPTER_LIST_PREF_TITLE = "Alternative Chapter List"
private const val ALT_CHAPTER_LIST_PREF_SUMMARY = "If checked, uses an alternate chapter list"
private const val ALT_CHAPTER_LIST_PREF_DEFAULT_VALUE = false
private val titleRegex: Regex =
Regex("\\([^()]*\\)|\\{[^{}]*\\}|\\[(?:(?!]).)*]|«[^»]*»|〘[^〙]*〙|「[^」]*」|『[^』]*』|≪[^≫]*≫|﹛[^﹜]*﹜|〖[^〖〗]*〗|\uD81A\uDD0D.+?\uD81A\uDD0D|《[^》]*》|⌜.+?⌝|⟨[^⟩]*⟩|/Official|/ Official", RegexOption.IGNORE_CASE)
}
}