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

@ -76,7 +76,10 @@ filter.category.option.others-ai-photos=Otras: AI Photos
config.domain.title=Configuración de dominio
config.domain.summary=Si el dominio actual está bloqueado, puedes cambiarlo manualmente aquí. Se aplicará después de reiniciar la aplicación.
config.domain.dialog.title=Configuración de dominio
config.domain.dialog.message=Dominio predeterminado:
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-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

@ -76,7 +76,10 @@ filter.category.option.others-ai-photos=기타: AI 사진
config.domain.title=도메인 설정
config.domain.summary=현재 도메인이 차단된 경우, 여기에서 수동으로 변경할 수 있습니다. 앱을 재시작하면 적용됩니다.
config.domain.dialog.title=도메인 설정
config.domain.dialog.message=기본 도메인:
config.domain.dialog.message=기본 도메인:
config.domain.toast.changed-success=도메인이 변경되었습니다. 앱을 재시작하면 적용됩니다.
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-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-failed=無效的網域!
config.language.title= 語言
config.language.summary= 設定擴充功能使用的語言。重啓應用後生效。
config.language.changed-success=語言修改成功。重啓應用後生效。

View File

@ -1,7 +1,7 @@
ext {
extName = 'YellowNote'
extClass = '.YellowNoteSourceFactory'
extVersionCode = 1
extClass = '.YellowNote'
extVersionCode = 2
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 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.language
import eu.kanade.tachiyomi.extension.all.yellownote.YellowNotePreferences.preferenceMigration
import eu.kanade.tachiyomi.lib.i18n.Intl
import eu.kanade.tachiyomi.network.GET
@ -25,12 +26,12 @@ import org.jsoup.nodes.Element
import java.text.SimpleDateFormat
import java.util.Locale
class YellowNote(
override val lang: String,
private val subdomain: String? = null,
) : SimpleParsedHttpSource(), ConfigurableSource {
class YellowNote : 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 = "小黄书"
@ -44,77 +45,109 @@ class YellowNote(
.add("Referer", "$baseUrl/")
private val intl = Intl(
language = lang,
baseLanguage = YellowNoteSourceFactory.BASE_LANGUAGE,
availableLanguages = YellowNoteSourceFactory.SUPPORT_LANGUAGES,
language = preferences.language(),
baseLanguage = LanguageUtils.baseLocale.language,
availableLanguages = LanguageUtils.supportedLocaleTags.toSet(),
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) {
YellowNotePreferences.buildPreferences(screen.context, intl)
.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 {
if (element.hasClass("amateur")) {
return simpleMangaFromElementByAmateur(element)
}
override fun simpleMangaFromElement(element: Element) = SManga.create().apply {
val mangaEl = element.selectFirst("a")!!
setUrlWithoutDomain(mangaEl.absUrl("href"))
return SManga.create().apply {
val imgEl = element.selectFirst("img")!!
val titleAppend = element.selectFirst("div.tag > div")?.text()?.let { "($it)" }.orEmpty()
title = "${imgEl.attr("alt")}$titleAppend"
val formatMediaCount = element.select("div.tags > div")
.map { it.text() }
.firstOrNull { mediaCountRegex.matches(it) }
?.let { "($it)" }
.orEmpty()
title = "${mangaEl.attr("title")}$formatMediaCount"
thumbnail_url = imgEl.absUrl("src")
update_strategy = UpdateStrategy.ONLY_FETCH_ONCE
setUrlWithoutDomain(element.selectFirst("a")!!.absUrl("href"))
}
thumbnail_url = parseUrlFormStyle(mangaEl.selectFirst("div.img"))
update_strategy = UpdateStrategy.ONLY_FETCH_ONCE
}
// /amateurs
private fun simpleMangaFromElementByAmateur(element: Element) = SManga.create().apply {
val titleAppend = element.selectFirst("div.tag > div")?.text()?.let { "($it)" }.orEmpty()
title = "${element.selectFirst("div:nth-child(3)")!!.text()}$titleAppend"
thumbnail_url = element.selectFirst(".img")?.attr("style")
fun parseUrlFormStyle(element: Element?): String? {
return element
?.attr("style")
?.let { styleUrlRegex.find(it) }
?.groupValues
?.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 mangaDetailsParse(document: Document) = SManga.create().apply {
val tabEl = document.selectFirst("div#tab_1")!!
val titleAppend = tabEl.selectFirst("i.fa.fa-picture-o")?.parentText()?.let { "($it)" }.orEmpty()
title = "${tabEl.selectFirst("i.fa.fa-address-card-o")!!.parentText()!!}$titleAppend"
val infoCardElement = document.selectFirst("div.info-card.photo-detail")!!
val name = parseInfoByIcon(infoCardElement, "i.fa-address-card")!!
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() }
genre = tabEl.select("div.contentTag").joinToString { it.text() }
title = "$name$no($mediaCount)"
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
description = tabEl.selectFirst("i.fa.fa-calendar")?.text()
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 chapterListParse(response: Response): List<SChapter> {
val doc = response.asJsoup()
val dateUploadStr = doc.selectFirst("i.fa.fa-calendar")?.text()
val dateUpload = dateFormat.tryParse(dateUploadStr)
val maxPage = doc.select("div.pager:first-of-type a:not([class])").last()?.text()?.toInt() ?: 1
val infoCardElement = doc.selectFirst("div.info-card.photo-detail")!!
val uploadAt = parseInfoByIcon(infoCardElement, "i.fa-calendar-days")
?.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()
.removeSuffix(".html")
return (maxPage downTo 1).map { page ->
@ -122,14 +155,28 @@ class YellowNote(
chapter_number = 0F
setUrlWithoutDomain("$basePageUrl/$page.html")
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> {
return document.select("div.article.mask .photos img.cr_only")
.mapIndexed { i, imgEl -> Page(i, imageUrl = imgEl!!.absUrl("src")) }
return document.select(imageSelector)
.mapIndexed { i, imageElement ->
val url = parseUrlFormStyle(imageElement.selectFirst("div.img"))!!
Page(i, imageUrl = url)
}
}
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.widget.Toast
import androidx.preference.EditTextPreference
import androidx.preference.ListPreference
import androidx.preference.Preference
import eu.kanade.tachiyomi.lib.i18n.Intl
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_DEFAULT = "$PS_KEY_DOMAIN::DEFAULT"
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 fun SharedPreferences.getStringOrDefaultIfBlank(key: String, defValue: String): String {
val value = getString(key, defValue)!!
return value.ifBlank { defValue }
}
internal fun SharedPreferences.preferenceMigration() {
// 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) {
edit()
.putString(PS_KEY_DOMAIN_DEFAULT, DEFAULT_DOMAIN)
@ -32,9 +30,10 @@ object YellowNotePreferences {
}
}
internal fun SharedPreferences.baseUrl(subdomain: String?): String {
val httpUrl = getStringOrDefaultIfBlank(PS_KEY_DOMAIN_OVERRIDE, DEFAULT_DOMAIN).toHttpUrl()
internal fun SharedPreferences.baseUrl(): String {
val lang = language()
val subdomain = LanguageUtils.getSubdomainByLanguage(lang)
val httpUrl = getString(PS_KEY_DOMAIN_OVERRIDE, DEFAULT_DOMAIN)!!.toHttpUrl()
val newHost = when {
httpUrl.host.split('.').size > 2 || subdomain == null -> httpUrl.host
else -> "$subdomain.${httpUrl.host}"
@ -48,28 +47,64 @@ object YellowNotePreferences {
.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(
EditTextPreference(context).apply {
key = PS_KEY_DOMAIN_OVERRIDE
title = intl["config.domain.title"]
summary = intl["config.domain.summary"]
dialogTitle = intl["config.domain.dialog.title"]
dialogMessage = "${intl["config.domain.dialog.message"]}$DEFAULT_DOMAIN"
setDefaultValue(DEFAULT_DOMAIN)
setOnPreferenceChangeListener { _, newValue ->
try {
(newValue as String).toHttpUrl()
} catch (e: IllegalArgumentException) {
Toast.makeText(context, intl["config.domain.toast.changed-failed"], Toast.LENGTH_LONG).show()
return@setOnPreferenceChangeListener false
}
Toast.makeText(context, intl["config.domain.toast.changed-success"], Toast.LENGTH_LONG).show()
true
}
},
buildDomainPreference(context, intl),
buildLanguagePreference(context, intl),
)
}
internal fun buildDomainPreference(context: Context, intl: Intl): Preference {
return EditTextPreference(context).apply {
key = PS_KEY_DOMAIN_OVERRIDE
title = intl["config.domain.title"]
summary = intl["config.domain.summary"]
dialogTitle = intl["config.domain.dialog.title"]
dialogMessage = "${intl["config.domain.dialog.message"]}$DEFAULT_DOMAIN"
setDefaultValue(DEFAULT_DOMAIN)
setOnPreferenceChangeListener { _, newValue ->
try {
(newValue as String).toHttpUrl()
} catch (_: IllegalArgumentException) {
Toast.makeText(
context,
intl["config.domain.toast.changed-failed"],
Toast.LENGTH_LONG,
).show()
return@setOnPreferenceChangeListener false
}
Toast.makeText(
context,
intl["config.domain.toast.changed-success"],
Toast.LENGTH_LONG,
).show()
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"),
)
}