fix(YellowNote): adapt to web page structure changes (#10235)

* fix(YellowNote): adapt to web page structure changes

* feat(YellowNote): make adjustments according to the reviewer's suggestions

- use stable value to pase date string
- inline selector
- combine two operations into one using mapIndexed()

* fix(YellowNote): correct image selector

* fix(YellowNote): correct data parse

* fix(YellowNote): correct data parse

* fix(YellowNote): properly adapt to new languages

- Implement correct language adaptation
- Add settings for language selection, defaulting to system language if unset
- Use English for unsupported languages
- Fix incorrect formatMediaCount extraction

* fix(YellowNote): update date parsing logic from version info

* chore(YellowNote): remove log

* chore(YellowNote): remove unused multilingual content

* fix(YellowNote): optimize Chinese language tag logic

- Simplify Chinese language tag conditions
- Add support for Simplified Chinese in Singapore (SG) region
- Fix potential incorrect default language tagging

* fix(YellowNote): override id

* feat(YellowNote): add language switch notification and optimize config

- Add success notification for language switching
- Remove redundant getStringOrDefault implementation

* fix(YellowNote): use tryParse
This commit is contained in:
marioplus 2025-09-27 20:22:34 +08:00 committed by Draff
parent 2fd2613bd0
commit 33f4d5f8c0
Signed by: Draff
GPG Key ID: E8A89F3211677653
10 changed files with 235 additions and 105 deletions

View File

@ -78,3 +78,7 @@ config.domain.dialog.message=Default domain:
config.domain.toast.changed-success=Domain changed successfully. It will take effect after restarting the app. config.domain.toast.changed-success=Domain changed successfully. It will take effect after restarting the app.
config.domain.toast.changed-failed=Invalid domain! config.domain.toast.changed-failed=Invalid domain!
config.language.title=Language
config.language.summary=Set extension language. Effective after restart.
config.language.changed-success=Language changed successfully. It will take effect after restarting the app.
config.language.changed-success=Language changed successfully. It will take effect after restarting the app.

View File

@ -80,3 +80,6 @@ config.domain.dialog.message=Dominio predeterminado:
config.domain.toast.changed-success=Dominio cambiado correctamente. Se aplicará tras reiniciar la aplicación. config.domain.toast.changed-success=Dominio cambiado correctamente. Se aplicará tras reiniciar la aplicación.
config.domain.toast.changed-failed=¡Dominio no válido! config.domain.toast.changed-failed=¡Dominio no válido!
config.language.title=Idioma
config.language.summary=Configurar idioma de la extensión. Efectivo tras reiniciar.
config.language.changed-success=Idioma cambiado correctamente. Se aplicará al reiniciar la aplicación.

View File

@ -80,3 +80,6 @@ config.domain.dialog.message=기본 도메인:
config.domain.toast.changed-success=도메인이 변경되었습니다. 앱을 재시작하면 적용됩니다. config.domain.toast.changed-success=도메인이 변경되었습니다. 앱을 재시작하면 적용됩니다.
config.domain.toast.changed-failed=유효하지 않은 도메인입니다! config.domain.toast.changed-failed=유효하지 않은 도메인입니다!
config.language.title=언어
config.language.summary=확장 프로그램 사용 언어 설정. 재실행 후 적용됩니다.
config.language.changed-success=언어 변경 성공. 앱 재시작 후 적용됩니다.

View File

@ -80,3 +80,6 @@ config.domain.dialog.message=默认域名:
config.domain.toast.changed-success=已更换域名,重启应用后生效。 config.domain.toast.changed-success=已更换域名,重启应用后生效。
config.domain.toast.changed-failed=无效域名! config.domain.toast.changed-failed=无效域名!
config.language.title=语言
config.language.summary=设置拓展使用的语言。重启应用后生效。
config.language.changed-success=语言修改成功。重启应用后生效。

View File

@ -78,3 +78,6 @@ config.domain.dialog.message=預設網域:
config.domain.toast.changed-success=網域已更換,重新啟動應用後生效。 config.domain.toast.changed-success=網域已更換,重新啟動應用後生效。
config.domain.toast.changed-failed=無效的網域! config.domain.toast.changed-failed=無效的網域!
config.language.title= 語言
config.language.summary= 設定擴充功能使用的語言。重啓應用後生效。
config.language.changed-success=語言修改成功。重啓應用後生效。

View File

@ -1,7 +1,7 @@
ext { ext {
extName = 'YellowNote' extName = 'YellowNote'
extClass = '.YellowNoteSourceFactory' extClass = '.YellowNote'
extVersionCode = 1 extVersionCode = 2
isNsfw = true isNsfw = true
} }

View File

@ -0,0 +1,57 @@
package eu.kanade.tachiyomi.extension.all.yellownote
import android.os.Build
import android.os.LocaleList
import java.util.Locale
object LanguageUtils {
val baseLocale = Locale.ENGLISH
private val languageToSubdomainMap = mapOf(
"en" to "en",
"es" to "es",
"ko" to "kr",
"zh-Hans" to null,
"zh-Hant" to "tw",
)
private val languageToDisplayNameMap = mapOf(
"en" to "English",
"es" to "Español",
"ko" to "한국어",
"zh-Hans" to "简体中文",
"zh-Hant" to "繁體中文",
)
val supportedLocaleTags = languageToSubdomainMap.keys.toTypedArray()
fun getDefaultLanguage(): String {
val defaultLocale =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
LocaleList.getDefault().getFirstMatch(supportedLocaleTags) ?: baseLocale
} else {
Locale.getDefault()
}
return when {
defaultLocale.script == "Hant" -> "zh-Hant"
defaultLocale.script == "Hans" -> "zh-Hans"
defaultLocale.country in listOf("TW", "HK", "MO") -> "zh-Hant"
defaultLocale.country in listOf("CN", "SG") -> "zh-Hans"
else -> baseLocale.language
}
}
fun getSubdomainByLanguage(lang: String): String? {
return languageToSubdomainMap[lang]
}
fun getSupportedLanguageKeys(): Array<String> {
return languageToDisplayNameMap.keys.toTypedArray()
}
fun getSupportedLanguageDisplayNames(): Array<String> {
return languageToDisplayNameMap.values.toTypedArray()
}
}

View File

@ -3,6 +3,7 @@ package eu.kanade.tachiyomi.extension.all.yellownote
import androidx.preference.PreferenceScreen import androidx.preference.PreferenceScreen
import eu.kanade.tachiyomi.extension.all.yellownote.YellowNoteFilters.SortSelector import eu.kanade.tachiyomi.extension.all.yellownote.YellowNoteFilters.SortSelector
import eu.kanade.tachiyomi.extension.all.yellownote.YellowNotePreferences.baseUrl import eu.kanade.tachiyomi.extension.all.yellownote.YellowNotePreferences.baseUrl
import eu.kanade.tachiyomi.extension.all.yellownote.YellowNotePreferences.language
import eu.kanade.tachiyomi.extension.all.yellownote.YellowNotePreferences.preferenceMigration import eu.kanade.tachiyomi.extension.all.yellownote.YellowNotePreferences.preferenceMigration
import eu.kanade.tachiyomi.lib.i18n.Intl import eu.kanade.tachiyomi.lib.i18n.Intl
import eu.kanade.tachiyomi.network.GET import eu.kanade.tachiyomi.network.GET
@ -25,12 +26,12 @@ import org.jsoup.nodes.Element
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Locale import java.util.Locale
class YellowNote( class YellowNote : SimpleParsedHttpSource(), ConfigurableSource {
override val lang: String,
private val subdomain: String? = null,
) : SimpleParsedHttpSource(), ConfigurableSource {
override val baseUrl by lazy { preferences.baseUrl(subdomain) } override val id get() = 170542391855030753
override val lang = "all"
override val baseUrl by lazy { preferences.baseUrl() }
override val name = "小黄书" override val name = "小黄书"
@ -44,77 +45,109 @@ class YellowNote(
.add("Referer", "$baseUrl/") .add("Referer", "$baseUrl/")
private val intl = Intl( private val intl = Intl(
language = lang, language = preferences.language(),
baseLanguage = YellowNoteSourceFactory.BASE_LANGUAGE, baseLanguage = LanguageUtils.baseLocale.language,
availableLanguages = YellowNoteSourceFactory.SUPPORT_LANGUAGES, availableLanguages = LanguageUtils.supportedLocaleTags.toSet(),
classLoader = this::class.java.classLoader!!, classLoader = this::class.java.classLoader!!,
) )
private val dateFormat = SimpleDateFormat("yyyy-MM-dd", Locale.US) private val dateFormat = SimpleDateFormat("yyyy.MM.dd", Locale.US)
private val styleUrlRegex = """url\(['"]?([^'"]+)['"]?\)""".toRegex() // yyyy.MM.dd
private val dateRegex = """\d{4}\.\d{2}\.\d{2}""".toRegex()
// <div role="img" class="img" style="background-image:url('https://img.xchina.io/photos/641aea8f589cb/0068_600x0.webp');"></div>
private val styleUrlRegex = """background-image\s*:\s*url\('([^']+)'\)""".toRegex()
// 100P + 2V
private val mediaCountRegex = """\d+P( \+ \d+V)?""".toRegex()
override fun setupPreferenceScreen(screen: PreferenceScreen) { override fun setupPreferenceScreen(screen: PreferenceScreen) {
YellowNotePreferences.buildPreferences(screen.context, intl) YellowNotePreferences.buildPreferences(screen.context, intl)
.forEach(screen::addPreference) .forEach(screen::addPreference)
} }
override fun simpleMangaSelector() = "div.article > div.list > div.item:not([class*=item exoclick_300x500])" override fun simpleMangaSelector() =
"div.list.photo-list > div.item.photo, div.list.amateur-list > div.item.amateur"
override fun simpleMangaFromElement(element: Element): SManga { override fun simpleMangaFromElement(element: Element) = SManga.create().apply {
if (element.hasClass("amateur")) { val mangaEl = element.selectFirst("a")!!
return simpleMangaFromElementByAmateur(element) setUrlWithoutDomain(mangaEl.absUrl("href"))
}
return SManga.create().apply { val formatMediaCount = element.select("div.tags > div")
val imgEl = element.selectFirst("img")!! .map { it.text() }
val titleAppend = element.selectFirst("div.tag > div")?.text()?.let { "($it)" }.orEmpty() .firstOrNull { mediaCountRegex.matches(it) }
title = "${imgEl.attr("alt")}$titleAppend" ?.let { "($it)" }
.orEmpty()
title = "${mangaEl.attr("title")}$formatMediaCount"
thumbnail_url = parseUrlFormStyle(mangaEl.selectFirst("div.img"))
thumbnail_url = imgEl.absUrl("src")
update_strategy = UpdateStrategy.ONLY_FETCH_ONCE update_strategy = UpdateStrategy.ONLY_FETCH_ONCE
setUrlWithoutDomain(element.selectFirst("a")!!.absUrl("href"))
}
} }
// /amateurs fun parseUrlFormStyle(element: Element?): String? {
private fun simpleMangaFromElementByAmateur(element: Element) = SManga.create().apply { return element
val titleAppend = element.selectFirst("div.tag > div")?.text()?.let { "($it)" }.orEmpty() ?.attr("style")
title = "${element.selectFirst("div:nth-child(3)")!!.text()}$titleAppend"
thumbnail_url = element.selectFirst(".img")?.attr("style")
?.let { styleUrlRegex.find(it) } ?.let { styleUrlRegex.find(it) }
?.groupValues ?.groupValues
?.get(1) ?.get(1)
update_strategy = UpdateStrategy.ONLY_FETCH_ONCE
setUrlWithoutDomain(element.selectFirst("a[href]")!!.absUrl("href"))
} }
override fun simpleNextPageSelector() = "div.pager:first-of-type a[current] + a[href]" override fun simpleNextPageSelector() =
"div.pager:first-of-type > span.pager-num.current + a.pager-num"
override fun popularMangaRequest(page: Int) = GET("$baseUrl/photos/sort-hot/$page.html", headers) override fun popularMangaRequest(page: Int) =
GET("$baseUrl/photos/sort-hot/$page.html", headers)
override fun latestUpdatesRequest(page: Int) = GET("$baseUrl/photos/$page.html", headers) override fun latestUpdatesRequest(page: Int) = GET("$baseUrl/photos/$page.html", headers)
override fun mangaDetailsParse(document: Document) = SManga.create().apply { override fun mangaDetailsParse(document: Document) = SManga.create().apply {
val tabEl = document.selectFirst("div#tab_1")!! val infoCardElement = document.selectFirst("div.info-card.photo-detail")!!
val titleAppend = tabEl.selectFirst("i.fa.fa-picture-o")?.parentText()?.let { "($it)" }.orEmpty() val name = parseInfoByIcon(infoCardElement, "i.fa-address-card")!!
title = "${tabEl.selectFirst("i.fa.fa-address-card-o")!!.parentText()!!}$titleAppend" val mediaCount = parseInfoByIcon(infoCardElement, "i.fa-image")!!
val no = parseInfoByIcon(infoCardElement, "i.fa-file")?.let { " $it" }.orEmpty()
val categories =
parseInfosByIcon(infoCardElement, "i.fa-video-camera")?.filter { it != "-" }
val filters = parseInfosByIcon(infoCardElement, "i.fa-filter")
val tags = parseInfosByIcon(infoCardElement, "i.fa-tags")
author = tabEl.select("div.models > a").joinToString { it.text() } title = "$name$no($mediaCount)"
genre = tabEl.select("div.contentTag").joinToString { it.text() } author = infoCardElement.selectFirst("div.item.floating")
?.text()
?: parseInfoByIcon(infoCardElement, "i.fa-circle-user")
genre = listOfNotNull(categories, filters, tags)
.flatten()
.takeIf { it.isNotEmpty() }
?.joinToString(", ")
status = SManga.COMPLETED status = SManga.COMPLETED
description = tabEl.selectFirst("i.fa.fa-calendar")?.text()
update_strategy = UpdateStrategy.ONLY_FETCH_ONCE update_strategy = UpdateStrategy.ONLY_FETCH_ONCE
} }
private fun parseInfosByIcon(infoCardElement: Element, iconClass: String): List<String>? {
return infoCardElement
.selectFirst("div.item:has(.icon > $iconClass)")
?.selectFirst("div.text")
?.children()
?.map { it.text() }
}
private fun parseInfoByIcon(infoCardElement: Element, iconClass: String): String? {
return infoCardElement
.selectFirst("div.item:has(.icon > $iconClass)")
?.selectFirst("div.text")
?.text()
}
override fun chapterFromElement(element: Element) = throw UnsupportedOperationException() override fun chapterFromElement(element: Element) = throw UnsupportedOperationException()
override fun chapterListParse(response: Response): List<SChapter> { override fun chapterListParse(response: Response): List<SChapter> {
val doc = response.asJsoup() val doc = response.asJsoup()
val dateUploadStr = doc.selectFirst("i.fa.fa-calendar")?.text() val infoCardElement = doc.selectFirst("div.info-card.photo-detail")!!
val dateUpload = dateFormat.tryParse(dateUploadStr) val uploadAt = parseInfoByIcon(infoCardElement, "i.fa-calendar-days")
val maxPage = doc.select("div.pager:first-of-type a:not([class])").last()?.text()?.toInt() ?: 1 ?.let { dateFormat.tryParse(it) }
?: parseUploadDateFromVersionInfo(doc)
?: 0L
val maxPage = doc.select("div.pager:first-of-type a.pager-num").last()?.text()?.toInt() ?: 1
val basePageUrl = response.request.url.toString() val basePageUrl = response.request.url.toString()
.removeSuffix(".html") .removeSuffix(".html")
return (maxPage downTo 1).map { page -> return (maxPage downTo 1).map { page ->
@ -122,14 +155,28 @@ class YellowNote(
chapter_number = 0F chapter_number = 0F
setUrlWithoutDomain("$basePageUrl/$page.html") setUrlWithoutDomain("$basePageUrl/$page.html")
name = "Page $page" name = "Page $page"
date_upload = dateUpload date_upload = uploadAt
} }
} }
} }
private fun parseUploadDateFromVersionInfo(doc: Document): Long? {
for (info in doc.select("div.tab-content > div.info-card div.text")) {
val date = dateRegex.find(info.text()) ?: continue
return dateFormat.tryParse(date.value)
}
return null
}
private val imageSelector =
"div.list.photo-items > div.item.photo-image, div.list.amateur-items > div.item.amateur-image"
override fun pageListParse(document: Document): List<Page> { override fun pageListParse(document: Document): List<Page> {
return document.select("div.article.mask .photos img.cr_only") return document.select(imageSelector)
.mapIndexed { i, imgEl -> Page(i, imageUrl = imgEl!!.absUrl("src")) } .mapIndexed { i, imageElement ->
val url = parseUrlFormStyle(imageElement.selectFirst("div.img"))!!
Page(i, imageUrl = url)
}
} }
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request { override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {

View File

@ -4,6 +4,8 @@ import android.content.Context
import android.content.SharedPreferences import android.content.SharedPreferences
import android.widget.Toast import android.widget.Toast
import androidx.preference.EditTextPreference import androidx.preference.EditTextPreference
import androidx.preference.ListPreference
import androidx.preference.Preference
import eu.kanade.tachiyomi.lib.i18n.Intl import eu.kanade.tachiyomi.lib.i18n.Intl
import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.HttpUrl.Companion.toHttpUrl
@ -13,17 +15,13 @@ object YellowNotePreferences {
private const val PS_KEY_DOMAIN = "$PS_KEY_ROOT::DOMAIN" private const val PS_KEY_DOMAIN = "$PS_KEY_ROOT::DOMAIN"
private const val PS_KEY_DOMAIN_DEFAULT = "$PS_KEY_DOMAIN::DEFAULT" private const val PS_KEY_DOMAIN_DEFAULT = "$PS_KEY_DOMAIN::DEFAULT"
private const val PS_KEY_DOMAIN_OVERRIDE = "$PS_KEY_DOMAIN::OVERRIDE" private const val PS_KEY_DOMAIN_OVERRIDE = "$PS_KEY_DOMAIN::OVERRIDE"
private const val PS_KEY_LANGUAGE = "$PS_KEY_ROOT::LANGUAGE"
private const val DEFAULT_DOMAIN = "https://xchina.co" private const val DEFAULT_DOMAIN = "https://xchina.co"
private fun SharedPreferences.getStringOrDefaultIfBlank(key: String, defValue: String): String {
val value = getString(key, defValue)!!
return value.ifBlank { defValue }
}
internal fun SharedPreferences.preferenceMigration() { internal fun SharedPreferences.preferenceMigration() {
// refresh when DEFAULT_DOMAIN update // refresh when DEFAULT_DOMAIN update
val defaultDomain = getStringOrDefaultIfBlank(PS_KEY_DOMAIN_DEFAULT, DEFAULT_DOMAIN) val defaultDomain = getString(PS_KEY_DOMAIN_DEFAULT, DEFAULT_DOMAIN)!!
if (DEFAULT_DOMAIN != defaultDomain) { if (DEFAULT_DOMAIN != defaultDomain) {
edit() edit()
.putString(PS_KEY_DOMAIN_DEFAULT, DEFAULT_DOMAIN) .putString(PS_KEY_DOMAIN_DEFAULT, DEFAULT_DOMAIN)
@ -32,9 +30,10 @@ object YellowNotePreferences {
} }
} }
internal fun SharedPreferences.baseUrl(subdomain: String?): String { internal fun SharedPreferences.baseUrl(): String {
val httpUrl = getStringOrDefaultIfBlank(PS_KEY_DOMAIN_OVERRIDE, DEFAULT_DOMAIN).toHttpUrl() val lang = language()
val subdomain = LanguageUtils.getSubdomainByLanguage(lang)
val httpUrl = getString(PS_KEY_DOMAIN_OVERRIDE, DEFAULT_DOMAIN)!!.toHttpUrl()
val newHost = when { val newHost = when {
httpUrl.host.split('.').size > 2 || subdomain == null -> httpUrl.host httpUrl.host.split('.').size > 2 || subdomain == null -> httpUrl.host
else -> "$subdomain.${httpUrl.host}" else -> "$subdomain.${httpUrl.host}"
@ -48,9 +47,19 @@ object YellowNotePreferences {
.removeSuffix("/") .removeSuffix("/")
} }
internal fun buildPreferences(context: Context, intl: Intl): List<EditTextPreference> { internal fun SharedPreferences.language(): String {
return getString(PS_KEY_LANGUAGE, "")!!.ifBlank { LanguageUtils.getDefaultLanguage() }
}
internal fun buildPreferences(context: Context, intl: Intl): List<Preference> {
return listOf( return listOf(
EditTextPreference(context).apply { buildDomainPreference(context, intl),
buildLanguagePreference(context, intl),
)
}
internal fun buildDomainPreference(context: Context, intl: Intl): Preference {
return EditTextPreference(context).apply {
key = PS_KEY_DOMAIN_OVERRIDE key = PS_KEY_DOMAIN_OVERRIDE
title = intl["config.domain.title"] title = intl["config.domain.title"]
summary = intl["config.domain.summary"] summary = intl["config.domain.summary"]
@ -61,15 +70,41 @@ object YellowNotePreferences {
setOnPreferenceChangeListener { _, newValue -> setOnPreferenceChangeListener { _, newValue ->
try { try {
(newValue as String).toHttpUrl() (newValue as String).toHttpUrl()
} catch (e: IllegalArgumentException) { } catch (_: IllegalArgumentException) {
Toast.makeText(context, intl["config.domain.toast.changed-failed"], Toast.LENGTH_LONG).show() Toast.makeText(
context,
intl["config.domain.toast.changed-failed"],
Toast.LENGTH_LONG,
).show()
return@setOnPreferenceChangeListener false return@setOnPreferenceChangeListener false
} }
Toast.makeText(context, intl["config.domain.toast.changed-success"], Toast.LENGTH_LONG).show() Toast.makeText(
context,
intl["config.domain.toast.changed-success"],
Toast.LENGTH_LONG,
).show()
true true
} }
}, }
) }
internal fun buildLanguagePreference(context: Context, intl: Intl): Preference {
return ListPreference(context).apply {
key = PS_KEY_LANGUAGE
title = intl["config.language.title"]
summary = intl["config.language.summary"]
entries = LanguageUtils.getSupportedLanguageDisplayNames()
entryValues = LanguageUtils.getSupportedLanguageKeys()
setDefaultValue("")
setOnPreferenceChangeListener { _, newValue ->
Toast.makeText(
context,
intl["config.language.changed-success"],
Toast.LENGTH_LONG,
).show()
true
}
}
} }
} }

View File

@ -1,25 +0,0 @@
package eu.kanade.tachiyomi.extension.all.yellownote
import eu.kanade.tachiyomi.source.SourceFactory
class YellowNoteSourceFactory : SourceFactory {
companion object {
const val BASE_LANGUAGE = "en"
val SUPPORT_LANGUAGES = setOf(
"en",
"es",
"ko",
"zh-Hans",
"zh-Hant",
)
}
override fun createSources() = listOf(
YellowNote("en", "en"),
YellowNote("es", "es"),
YellowNote("ko", "kr"),
YellowNote("zh-Hans"),
YellowNote("zh-Hant", "tw"),
)
}