diff --git a/lib/bangumi-scraper/build.gradle.kts b/lib/bangumi-scraper/build.gradle.kts new file mode 100644 index 00000000..c49026e4 --- /dev/null +++ b/lib/bangumi-scraper/build.gradle.kts @@ -0,0 +1,7 @@ +plugins { + id("lib-android") +} + +dependencies { + compileOnly(libs.aniyomi.lib) +} \ No newline at end of file diff --git a/lib/bangumi-scraper/src/main/java/eu/kanade/tachiyomi/lib/bangumiscraper/BangumiDTO.kt b/lib/bangumi-scraper/src/main/java/eu/kanade/tachiyomi/lib/bangumiscraper/BangumiDTO.kt new file mode 100644 index 00000000..1f130ad2 --- /dev/null +++ b/lib/bangumi-scraper/src/main/java/eu/kanade/tachiyomi/lib/bangumiscraper/BangumiDTO.kt @@ -0,0 +1,83 @@ +@file:UseSerializers(BoxItemSerializer::class) +package eu.kanade.tachiyomi.lib.bangumiscraper + +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.KSerializer +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.Serializer +import kotlinx.serialization.UseSerializers +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive + +@Serializable +internal data class Images( + val large: String, + val common: String, + val medium: String, + val small: String, +) + +@Serializable +internal data class BoxItem( + val key: String, + val value: String, +) + +@OptIn(ExperimentalSerializationApi::class) +@Serializer(forClass = BoxItem::class) +internal object BoxItemSerializer : KSerializer { + override fun deserialize(decoder: Decoder): BoxItem { + val item = (decoder as JsonDecoder).decodeJsonElement().jsonObject + val key = item["key"]!!.jsonPrimitive.content + val value = (item["value"] as? JsonPrimitive)?.contentOrNull ?: "" + return BoxItem(key, value) + } +} + +@Serializable +internal data class Subject( + val name: String, + @SerialName("name_cn") + val nameCN: String, + val summary: String, + val images: Images, + @SerialName("meta_tags") + val metaTags: List, + @SerialName("infobox") + val infoBox: List, +) { + fun findAuthor(): String? { + return findInfo("导演", "原作") + } + + fun findArtist(): String? { + return findInfo("美术监督", "总作画监督", "动画制作") + } + + fun findInfo(vararg keys: String): String? { + keys.forEach { key -> + return infoBox.find { item -> + item.key == key + }?.value ?: return@forEach + } + return null + } +} + +@Serializable +internal data class SearchItem( + val id: Int, + val name: String, + @SerialName("name_cn") + val nameCN: String, + val summary: String, + val images: Images, +) + +@Serializable +internal data class SearchResponse(val results: Int, val list: List) diff --git a/lib/bangumi-scraper/src/main/java/eu/kanade/tachiyomi/lib/bangumiscraper/BangumiScraper.kt b/lib/bangumi-scraper/src/main/java/eu/kanade/tachiyomi/lib/bangumiscraper/BangumiScraper.kt new file mode 100644 index 00000000..3653314e --- /dev/null +++ b/lib/bangumi-scraper/src/main/java/eu/kanade/tachiyomi/lib/bangumiscraper/BangumiScraper.kt @@ -0,0 +1,126 @@ +package eu.kanade.tachiyomi.lib.bangumiscraper + +import eu.kanade.tachiyomi.animesource.model.SAnime +import eu.kanade.tachiyomi.network.GET +import eu.kanade.tachiyomi.network.awaitSuccess +import eu.kanade.tachiyomi.util.parseAs +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response + +enum class BangumiSubjectType(val value: Int) { + BOOK(1), + ANIME(2), + MUSIC(3), + GAME(4), + REAL(6), +} + +enum class BangumiFetchType { + /** + * Give cover and summary info. + */ + SHORT, + + /** + * Give all require info include genre and author info. + */ + ALL, +} + +/** + * A helper class to fetch anime details from Bangumi + */ +object BangumiScraper { + private const val SEARCH_URL = "https://api.bgm.tv/search/subject" + private const val SUBJECTS_URL = "https://api.bgm.tv/v0/subjects" + + /** + * Fetch anime details info from Bangumi + * @param fetchType check [BangumiFetchType] to get detail + * @param subjectType check [BangumiSubjectType] to get detail + * @param requestProducer used to custom request + */ + suspend fun fetchDetail( + client: OkHttpClient, + keyword: String, + fetchType: BangumiFetchType = BangumiFetchType.SHORT, + subjectType: BangumiSubjectType = BangumiSubjectType.ANIME, + requestProducer: (url: HttpUrl) -> Request = { url -> GET(url) }, + ): SAnime { + val httpUrl = SEARCH_URL.toHttpUrl().newBuilder() + .addPathSegment(keyword) + .addQueryParameter( + "responseGroup", + if (fetchType == BangumiFetchType.ALL) { + "small" + } else { + "medium" + }, + ) + .addQueryParameter("type", "${subjectType.value}") + .addQueryParameter("start", "0") + .addQueryParameter("max_results", "1") + .build() + val searchResponse = client.newCall(requestProducer(httpUrl)).awaitSuccess() + .checkErrorMessage().parseAs() + return if (searchResponse.list.isEmpty()) { + SAnime.create() + } else { + val item = searchResponse.list[0] + if (fetchType == BangumiFetchType.ALL) { + fetchSubject(client, "${item.id}", requestProducer) + } else { + SAnime.create().apply { + thumbnail_url = item.images.large + description = item.summary + } + } + } + } + + private suspend fun fetchSubject( + client: OkHttpClient, + id: String, + requestProducer: (url: HttpUrl) -> Request, + ): SAnime { + val httpUrl = SUBJECTS_URL.toHttpUrl().newBuilder().addPathSegment(id).build() + val subject = client.newCall(requestProducer(httpUrl)).awaitSuccess() + .checkErrorMessage().parseAs() + return SAnime.create().apply { + thumbnail_url = subject.images.large + description = subject.summary + genre = buildList { + addAll(subject.metaTags) + subject.findInfo("动画制作")?.let { add(it) } + subject.findInfo("放送开始")?.let { add(it) } + }.joinToString() + author = subject.findAuthor() + artist = subject.findArtist() + if (subject.findInfo("播放结束") != null) { + status = SAnime.COMPLETED + } else if (subject.findInfo("放送开始") != null) { + status = SAnime.ONGOING + } + } + } + + private fun Response.checkErrorMessage(): String { + val responseStr = body.string() + val errorMessage = + responseStr.parseAs().jsonObject["error"]?.jsonPrimitive?.contentOrNull + if (errorMessage != null) { + throw BangumiScraperException(errorMessage) + } + return responseStr + } +} + + + diff --git a/lib/bangumi-scraper/src/main/java/eu/kanade/tachiyomi/lib/bangumiscraper/BangumiScraperException.kt b/lib/bangumi-scraper/src/main/java/eu/kanade/tachiyomi/lib/bangumiscraper/BangumiScraperException.kt new file mode 100644 index 00000000..08d1e308 --- /dev/null +++ b/lib/bangumi-scraper/src/main/java/eu/kanade/tachiyomi/lib/bangumiscraper/BangumiScraperException.kt @@ -0,0 +1,3 @@ +package eu.kanade.tachiyomi.lib.bangumiscraper + +class BangumiScraperException(message: String) : Exception(message) diff --git a/src/zh/anime1/build.gradle b/src/zh/anime1/build.gradle index ce26ab4b..9f792cf7 100644 --- a/src/zh/anime1/build.gradle +++ b/src/zh/anime1/build.gradle @@ -1,7 +1,13 @@ ext { extName = 'Anime1.me' extClass = '.Anime1' - extVersionCode = 1 + extVersionCode = 2 } apply from: "$rootDir/common.gradle" + +dependencies { + implementation(project(":lib:bangumi-scraper")) + //noinspection UseTomlInstead + implementation "com.github.houbb:opencc4j:1.8.1" +} diff --git a/src/zh/anime1/src/eu/kanade/tachiyomi/animeextension/zh/anime1/Anime1.kt b/src/zh/anime1/src/eu/kanade/tachiyomi/animeextension/zh/anime1/Anime1.kt index 7608d038..59014492 100644 --- a/src/zh/anime1/src/eu/kanade/tachiyomi/animeextension/zh/anime1/Anime1.kt +++ b/src/zh/anime1/src/eu/kanade/tachiyomi/animeextension/zh/anime1/Anime1.kt @@ -1,12 +1,21 @@ package eu.kanade.tachiyomi.animeextension.zh.anime1 +import android.app.Application +import android.content.SharedPreferences import android.webkit.CookieManager +import androidx.preference.CheckBoxPreference +import androidx.preference.ListPreference +import androidx.preference.PreferenceScreen +import com.github.houbb.opencc4j.util.ZhTwConverterUtil +import eu.kanade.tachiyomi.animesource.ConfigurableAnimeSource import eu.kanade.tachiyomi.animesource.model.AnimeFilterList import eu.kanade.tachiyomi.animesource.model.AnimesPage import eu.kanade.tachiyomi.animesource.model.SAnime import eu.kanade.tachiyomi.animesource.model.SEpisode import eu.kanade.tachiyomi.animesource.model.Video import eu.kanade.tachiyomi.animesource.online.AnimeHttpSource +import eu.kanade.tachiyomi.lib.bangumiscraper.BangumiFetchType +import eu.kanade.tachiyomi.lib.bangumiscraper.BangumiScraper import eu.kanade.tachiyomi.network.GET import eu.kanade.tachiyomi.network.POST import eu.kanade.tachiyomi.network.awaitSuccess @@ -24,10 +33,12 @@ import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.Response import org.jsoup.Jsoup import org.jsoup.nodes.Document +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get import java.text.SimpleDateFormat import java.util.Locale -class Anime1 : AnimeHttpSource() { +class Anime1 : AnimeHttpSource(), ConfigurableAnimeSource { override val baseUrl: String get() = "https://anime1.me" override val lang: String @@ -47,11 +58,22 @@ class Anime1 : AnimeHttpSource() { private lateinit var data: JsonArray private val cookieManager get() = CookieManager.getInstance() + private val preferences: SharedPreferences by lazy { + Injekt.get().getSharedPreferences("source_$id", 0x0000) + } override fun animeDetailsParse(response: Response) = throw UnsupportedOperationException() + override suspend fun getAnimeDetails(anime: SAnime): SAnime { - return SAnime.create().apply { - thumbnail_url = FIX_COVER + return if (bangumiEnable) { + BangumiScraper.fetchDetail( + client, + ZhTwConverterUtil.toSimple(anime.title.removeSuffixMark()), + fetchType = bangumiFetchType, + ) + } else { + anime.thumbnail_url = FIX_COVER + anime } } @@ -168,13 +190,78 @@ class Anime1 : AnimeHttpSource() { return GET(url.build()) } + override fun setupPreferenceScreen(screen: PreferenceScreen) { + val bangumiScraper = CheckBoxPreference(screen.context).apply { + key = PREF_KEY_BANGUMI + title = "啟用Bangumi刮削" + } + val bangumiFetchType = ListPreference(screen.context).apply { + key = PREF_KEY_BANGUMI_FETCH_TYPE + title = "詳情拉取設置" + setVisible(bangumiEnable) + entries = arrayOf("拉取部分數據", "拉取完整數據") + entryValues = arrayOf(BangumiFetchType.SHORT.name, BangumiFetchType.ALL.name) + setDefaultValue(entryValues[0]) + summary = when (bangumiFetchType) { + BangumiFetchType.SHORT -> entries[0] + BangumiFetchType.ALL -> entries[1] + else -> entries[0] + } + setOnPreferenceChangeListener { _, value -> + summary = when (value) { + BangumiFetchType.SHORT.name -> entries[0] + BangumiFetchType.ALL.name -> entries[1] + else -> entries[0] + } + true + } + } + bangumiScraper.setOnPreferenceChangeListener { _, value -> + bangumiFetchType.setVisible(value as Boolean) + true + } + screen.apply { + addPreference(bangumiScraper) + addPreference(bangumiFetchType) + } + } + + private val bangumiEnable: Boolean + get() = preferences.getBoolean(PREF_KEY_BANGUMI, false) + private val bangumiFetchType: BangumiFetchType + get() { + val fetchTypeName = + preferences.getString(PREF_KEY_BANGUMI_FETCH_TYPE, BangumiFetchType.SHORT.name) + return when (fetchTypeName) { + BangumiFetchType.SHORT.name -> BangumiFetchType.SHORT + BangumiFetchType.ALL.name -> BangumiFetchType.ALL + else -> BangumiFetchType.SHORT + } + } + private fun JsonArray.getContent(index: Int): String? { return getOrNull(index)?.jsonPrimitive?.contentOrNull } + private fun String.removeSuffixMark(): String { + return removeBracket("(", ")").removeBracket("[", "]").trim() + } + + private fun String.removeBracket(start: String, end: String): String { + val seasonStart = indexOf(start) + val seasonEnd = indexOf(end) + if (seasonEnd > seasonStart) { + return removeRange(seasonStart, seasonEnd + 1) + } + return this + } + companion object { const val PAGE_SIZE = 20 const val FIX_COVER = "https://sta.anicdn.com/playerImg/8.jpg" + + const val PREF_KEY_BANGUMI = "PREF_KEY_BANGUMI" + const val PREF_KEY_BANGUMI_FETCH_TYPE = "PREF_KEY_BANGUMI_FETCH_TYPE" } }