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 { ext {
extName = 'Bato.to' extName = 'Bato.to'
extClass = '.BatoToFactory' extClass = '.BatoToFactory'
extVersionCode = 55 extVersionCode = 56
isNsfw = true isNsfw = true
} }

View File

@ -2,7 +2,12 @@ package eu.kanade.tachiyomi.extension.all.batoto
import android.app.Application import android.app.Application
import android.content.SharedPreferences 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.CheckBoxPreference
import androidx.preference.EditTextPreference
import androidx.preference.ListPreference import androidx.preference.ListPreference
import androidx.preference.PreferenceScreen import androidx.preference.PreferenceScreen
import eu.kanade.tachiyomi.extension.BuildConfig import eu.kanade.tachiyomi.extension.BuildConfig
@ -87,9 +92,49 @@ open class BatoTo(
"You might also want to clear the database in advanced settings." "You might also want to clear the database in advanced settings."
setDefaultValue(false) 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(mirrorPref)
screen.addPreference(altChapterListPref) screen.addPreference(altChapterListPref)
screen.addPreference(removeOfficialPref) screen.addPreference(removeOfficialPref)
screen.addPreference(removeCustomPref)
} }
private fun getMirrorPref(): String { private fun getMirrorPref(): String {
@ -120,12 +165,14 @@ open class BatoTo(
private fun isRemoveTitleVersion(): Boolean { private fun isRemoveTitleVersion(): Boolean {
return preferences.getBoolean("${REMOVE_TITLE_VERSION_PREF}_$lang", false) return preferences.getBoolean("${REMOVE_TITLE_VERSION_PREF}_$lang", false)
} }
private fun customRemoveTitle(): String =
preferences.getString("${REMOVE_TITLE_CUSTOM_PREF}_$lang", "")!!
private fun SharedPreferences.migrateMirrorPref() { private fun SharedPreferences.migrateMirrorPref() {
val selectedMirror = getString("${MIRROR_PREF_KEY}_$lang", MIRROR_PREF_DEFAULT_VALUE)!! val selectedMirror = getString("${MIRROR_PREF_KEY}_$lang", MIRROR_PREF_DEFAULT_VALUE)!!
if (selectedMirror in DEPRECATED_MIRRORS) { 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 manga = SManga.create()
val item = element.select("a.item-cover") val item = element.select("a.item-cover")
val imgurl = item.select("img").attr("abs:src") 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() manga.title = element.select("a.item-title").text().removeEntities()
.cleanTitleIfNeeded()
manga.thumbnail_url = imgurl manga.thumbnail_url = imgurl
return manga return manga
} }
@ -275,9 +323,10 @@ open class BatoTo(
val infoElement = document.select("div#mainer div.container-fluid") val infoElement = document.select("div#mainer div.container-fluid")
val manga = SManga.create() val manga = SManga.create()
manga.title = infoElement.select("h3").text().removeEntities() manga.title = infoElement.select("h3").text().removeEntities()
.cleanTitleIfNeeded()
manga.thumbnail_url = document.select("div.attr-cover img") manga.thumbnail_url = document.select("div.attr-cover img")
.attr("abs:src") .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) return MangasPage(listOf(manga), false)
} }
@ -308,16 +357,18 @@ open class BatoTo(
private fun searchUtilsFromElement(element: Element): SManga { private fun searchUtilsFromElement(element: Element): SManga {
val manga = SManga.create() 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() manga.title = element.select("td a").text()
.cleanTitleIfNeeded()
manga.thumbnail_url = element.select("img").attr("abs:src") manga.thumbnail_url = element.select("img").attr("abs:src")
return manga return manga
} }
private fun searchHistoryFromElement(element: Element): SManga { private fun searchHistoryFromElement(element: Element): SManga {
val manga = SManga.create() 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() manga.title = element.select(".position-relative a").text()
.cleanTitleIfNeeded()
manga.thumbnail_url = element.select("img").attr("abs:src") manga.thumbnail_url = element.select("img").attr("abs:src")
return manga return manga
} }
@ -346,8 +397,6 @@ open class BatoTo(
} }
return super.mangaDetailsRequest(manga) return super.mangaDetailsRequest(manga)
} }
private var titleRegex: Regex =
Regex("\\([^()]*\\)|\\{[^{}]*\\}|\\[(?:(?!]).)*]|«[^»]*»|〘[^〙]*〙|「[^」]*」|『[^』]*』|≪[^≫]*≫|﹛[^﹜]*﹜|〖[^〖〗]*〗|𖤍.+?𖤍|《[^》]*》|⌜.+?⌝|⟨[^⟩]*⟩|\\/Official|\\/ Official", RegexOption.IGNORE_CASE)
override fun mangaDetailsParse(document: Document): SManga { override fun mangaDetailsParse(document: Document): SManga {
val infoElement = document.selectFirst("div#mainer div.container-fluid")!! val infoElement = document.selectFirst("div#mainer div.container-fluid")!!
@ -368,19 +417,19 @@ open class BatoTo(
append(it.text().split('/').joinToString("\n") { "${it.trim()}" }) append(it.text().split('/').joinToString("\n") { "${it.trim()}" })
} }
}.trim() }.trim()
val cleanedTitle = originalTitle.cleanTitleIfNeeded()
val cleanedTitle = if (isRemoveTitleVersion()) {
originalTitle.replace(titleRegex, "").trim()
} else {
originalTitle
}
manga.title = cleanedTitle manga.title = cleanedTitle
manga.author = infoElement.select("div.attr-item:contains(author) span").text() manga.author = infoElement.select("div.attr-item:contains(author) span").text()
manga.artist = infoElement.select("div.attr-item:contains(artist) span").text() manga.artist = infoElement.select("div.attr-item:contains(artist) span").text()
manga.status = parseStatus(workStatus, uploadStatus) manga.status = parseStatus(workStatus, uploadStatus)
manga.genre = infoElement.select(".attr-item b:contains(genres) + span ").joinToString { it.text() } 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") manga.thumbnail_url = document.select("div.attr-cover img").attr("abs:src")
return manga return manga
} }
@ -424,9 +473,9 @@ open class BatoTo(
} }
override fun chapterListRequest(manga: SManga): Request { override fun chapterListRequest(manga: SManga): Request {
return if (getAltChapterListPref()) { val id = seriesIdRegex.find(manga.url)
val id = manga.url.substringBeforeLast("/").substringAfterLast("/").trim() ?.groups?.get(1)?.value?.trim()
return if (getAltChapterListPref() && !id.isNullOrBlank()) {
GET("$baseUrl/rss/series/$id.xml", headers) GET("$baseUrl/rss/series/$id.xml", headers)
} else if (manga.url.startsWith("http")) { } else if (manga.url.startsWith("http")) {
// Check if trying to use a deprecated mirror, force current mirror // 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.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( override fun getFilterList() = FilterList(
LetterFilter(getLetterFilter(), 0), LetterFilter(getLetterFilter(), 0),
Filter.Separator(), Filter.Separator(),
@ -1026,10 +1088,18 @@ open class BatoTo(
CheckboxFilterOption("pt-PT", "Portuguese (Portugal)"), CheckboxFilterOption("pt-PT", "Portuguese (Portugal)"),
).filterNot { it.value == siteLang } ).filterNot { it.value == siteLang }
private fun stripSeriesUrl(url: String): String {
val matchResult = seriesUrlRegex.find(url)
return matchResult?.groups?.get(1)?.value ?: url
}
companion object { 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_KEY = "MIRROR"
private const val MIRROR_PREF_TITLE = "Mirror" private const val MIRROR_PREF_TITLE = "Mirror"
private const val REMOVE_TITLE_VERSION_PREF = "REMOVE_TITLE_VERSION" 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( private val MIRROR_PREF_ENTRIES = arrayOf(
"Auto", "Auto",
// https://batotomirrors.pages.dev/ // 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_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_SUMMARY = "If checked, uses an alternate chapter list"
private const val ALT_CHAPTER_LIST_PREF_DEFAULT_VALUE = false private const val ALT_CHAPTER_LIST_PREF_DEFAULT_VALUE = false
private val titleRegex: Regex =
Regex("\\([^()]*\\)|\\{[^{}]*\\}|\\[(?:(?!]).)*]|«[^»]*»|〘[^〙]*〙|「[^」]*」|『[^』]*』|≪[^≫]*≫|﹛[^﹜]*﹜|〖[^〖〗]*〗|\uD81A\uDD0D.+?\uD81A\uDD0D|《[^》]*》|⌜.+?⌝|⟨[^⟩]*⟩|/Official|/ Official", RegexOption.IGNORE_CASE)
} }
} }