diff --git a/src/zh/noyacg/AndroidManifest.xml b/src/zh/noyacg/AndroidManifest.xml new file mode 100644 index 000000000..30deb7f79 --- /dev/null +++ b/src/zh/noyacg/AndroidManifest.xml @@ -0,0 +1,2 @@ + + diff --git a/src/zh/noyacg/build.gradle b/src/zh/noyacg/build.gradle new file mode 100644 index 000000000..729022e92 --- /dev/null +++ b/src/zh/noyacg/build.gradle @@ -0,0 +1,13 @@ +apply plugin: 'com.android.application' +apply plugin: 'kotlin-android' +apply plugin: 'kotlinx-serialization' + +ext { + extName = 'NoyAcg' + pkgNameSuffix = 'zh.noyacg' + extClass = '.NoyAcg' + extVersionCode = 1 + isNsfw = true +} + +apply from: "$rootDir/common.gradle" diff --git a/src/zh/noyacg/res/mipmap-hdpi/ic_launcher.png b/src/zh/noyacg/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 000000000..cf1d301ef Binary files /dev/null and b/src/zh/noyacg/res/mipmap-hdpi/ic_launcher.png differ diff --git a/src/zh/noyacg/res/mipmap-mdpi/ic_launcher.png b/src/zh/noyacg/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 000000000..ea74f01d9 Binary files /dev/null and b/src/zh/noyacg/res/mipmap-mdpi/ic_launcher.png differ diff --git a/src/zh/noyacg/res/mipmap-xhdpi/ic_launcher.png b/src/zh/noyacg/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 000000000..e61747267 Binary files /dev/null and b/src/zh/noyacg/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/src/zh/noyacg/res/mipmap-xxhdpi/ic_launcher.png b/src/zh/noyacg/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 000000000..d9cb2470e Binary files /dev/null and b/src/zh/noyacg/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/src/zh/noyacg/res/mipmap-xxxhdpi/ic_launcher.png b/src/zh/noyacg/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 000000000..7292edb3a Binary files /dev/null and b/src/zh/noyacg/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/src/zh/noyacg/res/web_hi_res_512.png b/src/zh/noyacg/res/web_hi_res_512.png new file mode 100644 index 000000000..5c487a686 Binary files /dev/null and b/src/zh/noyacg/res/web_hi_res_512.png differ diff --git a/src/zh/noyacg/src/eu/kanade/tachiyomi/extension/zh/noyacg/Dto.kt b/src/zh/noyacg/src/eu/kanade/tachiyomi/extension/zh/noyacg/Dto.kt new file mode 100644 index 000000000..fcbd23b8c --- /dev/null +++ b/src/zh/noyacg/src/eu/kanade/tachiyomi/extension/zh/noyacg/Dto.kt @@ -0,0 +1,57 @@ +package eu.kanade.tachiyomi.extension.zh.noyacg + +import eu.kanade.tachiyomi.source.model.SManga +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import java.text.SimpleDateFormat +import java.util.Locale + +const val LISTING_PAGE_SIZE = 20 + +@Serializable +class MangaDto( + @SerialName("Bid") private val id: Int, + @SerialName("Bookname") private val title: String, + @SerialName("Author") private val author: String, + @SerialName("Pname") private val character: String, + @SerialName("Ptag") private val genres: String, + @SerialName("Otag") private val parody: String, + @SerialName("Time") private val timestamp: Long, + @SerialName("Len") private val pageCount: Int, +) { + fun toSManga(imageCdn: String) = SManga.create().also { + it.url = id.toString() + it.title = title + it.author = author.formatNames() + it.description = "时间:${mangaDateFormat.format(timestamp * 1000)}\n" + + "页数:$pageCount\n" + + "原作:${parody.formatNames()}\n" + + "角色:${character.formatNames()}" + it.genre = genres.replace(" ", ", ") + it.status = SManga.COMPLETED + it.thumbnail_url = "$imageCdn/$id/m1.webp" + it.initialized = pageCount > 0 + } +} + +fun SManga.field(index: Int): String = + description!!.split("\n")[index].substringAfter(':') + +val SManga.timestamp: Long get() = dateFormat.parse(field(0))!!.time +val SManga.pageCount: Int get() = field(1).toInt() + +val dateFormat get() = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.ENGLISH) +private val mangaDateFormat = dateFormat + +fun String.formatNames() = split(" ").joinToString { name -> + name.split("-").joinToString(" ") { word -> word.replaceFirstChar { it.uppercaseChar() } } +} + +@Serializable +class ListingPageDto( + private val info: List? = null, + private val Info: List? = null, + val len: Int, +) { + val entries get() = info ?: Info ?: emptyList() +} diff --git a/src/zh/noyacg/src/eu/kanade/tachiyomi/extension/zh/noyacg/Filters.kt b/src/zh/noyacg/src/eu/kanade/tachiyomi/extension/zh/noyacg/Filters.kt new file mode 100644 index 000000000..6b29d9c25 --- /dev/null +++ b/src/zh/noyacg/src/eu/kanade/tachiyomi/extension/zh/noyacg/Filters.kt @@ -0,0 +1,43 @@ +package eu.kanade.tachiyomi.extension.zh.noyacg + +import eu.kanade.tachiyomi.source.model.Filter +import eu.kanade.tachiyomi.source.model.FilterList +import okhttp3.FormBody + +fun getFilterListInternal() = FilterList( + Filter.Header("搜索选项"), + SearchTypeFilter(), + SortFilter(), + Filter.Separator(), + Filter.Header("排行榜(搜索文本时无效)"), + RankingFilter(), + RankingRangeFilter(), +) + +interface ListingFilter { + fun addTo(builder: FormBody.Builder) +} + +interface SearchFilter : ListingFilter + +class SearchTypeFilter : SearchFilter, Filter.Select("搜索范围", arrayOf("综合", "标签", "作者")) { + override fun addTo(builder: FormBody.Builder) { + builder.addEncoded("type", arrayOf("de", "tag", "author")[state]) + } +} + +class SortFilter : SearchFilter, Filter.Select("排序", arrayOf("时间", "阅读量", "收藏")) { + override fun addTo(builder: FormBody.Builder) { + builder.addEncoded("sort", arrayOf("bid", "views", "favorites")[state]) + } +} + +class RankingFilter : Filter.Select("排行榜", arrayOf("阅读榜", "收藏榜", "高质量榜")) { + val path get() = arrayOf("readLeaderboard", "favLeaderboard", "proportion")[state] +} + +class RankingRangeFilter : ListingFilter, Filter.Select("阅读/收藏榜范围", arrayOf("日榜", "周榜", "月榜")) { + override fun addTo(builder: FormBody.Builder) { + builder.addEncoded("type", arrayOf("day", "week", "moon")[state]) + } +} diff --git a/src/zh/noyacg/src/eu/kanade/tachiyomi/extension/zh/noyacg/NoyAcg.kt b/src/zh/noyacg/src/eu/kanade/tachiyomi/extension/zh/noyacg/NoyAcg.kt new file mode 100644 index 000000000..e322c8cd3 --- /dev/null +++ b/src/zh/noyacg/src/eu/kanade/tachiyomi/extension/zh/noyacg/NoyAcg.kt @@ -0,0 +1,146 @@ +package eu.kanade.tachiyomi.extension.zh.noyacg + +import android.app.Application +import androidx.preference.PreferenceScreen +import eu.kanade.tachiyomi.network.GET +import eu.kanade.tachiyomi.network.POST +import eu.kanade.tachiyomi.network.asObservableSuccess +import eu.kanade.tachiyomi.source.ConfigurableSource +import eu.kanade.tachiyomi.source.model.FilterList +import eu.kanade.tachiyomi.source.model.MangasPage +import eu.kanade.tachiyomi.source.model.Page +import eu.kanade.tachiyomi.source.model.SChapter +import eu.kanade.tachiyomi.source.model.SManga +import eu.kanade.tachiyomi.source.online.HttpSource +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.decodeFromStream +import okhttp3.FormBody +import okhttp3.Request +import okhttp3.Response +import rx.Observable +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get +import uy.kohesive.injekt.injectLazy + +class NoyAcg : HttpSource(), ConfigurableSource { + override val name get() = "NoyAcg" + override val lang get() = "zh" + override val supportsLatest get() = true + override val baseUrl get() = "https://app.noy.asia" + + private val imageCdn by lazy { + Injekt.get().getSharedPreferences("source_$id", 0x0000).imageCdn + } + + override fun headersBuilder() = super.headersBuilder() + .add("Referer", "$baseUrl/") + + override fun popularMangaRequest(page: Int): Request { + val body = FormBody.Builder() + .addEncoded("page", page.toString()) + .addEncoded("type", "day") + .build() + return POST("$baseUrl/api/readLeaderboard", headers, body) + } + + override fun popularMangaParse(response: Response): MangasPage { + val page = (response.request.body as FormBody).encodedValue(0).toInt() + val imageCdn = imageCdn + val listingPage: ListingPageDto = response.parseAs() + val entries = listingPage.entries.map { it.toSManga(imageCdn) } + val hasNextPage = page * LISTING_PAGE_SIZE < listingPage.len + return MangasPage(entries, hasNextPage) + } + + override fun latestUpdatesRequest(page: Int): Request { + val body = FormBody.Builder() + .addEncoded("page", page.toString()) + .build() + return POST("$baseUrl/api/booklist_v2", headers, body) + } + + override fun latestUpdatesParse(response: Response) = popularMangaParse(response) + + override fun getFilterList() = getFilterListInternal() + + override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request { + val filters = filters.ifEmpty { getFilterListInternal() } + val builder = FormBody.Builder() + .addEncoded("page", page.toString()) + return if (query.isNotBlank()) { + builder.add("info", query) + for (filter in filters) if (filter is SearchFilter) filter.addTo(builder) + POST("$baseUrl/api/search_v2", headers, builder.build()) + } else { + var path: String? = null + for (filter in filters) when (filter) { + is RankingFilter -> path = filter.path + is RankingRangeFilter -> filter.addTo(builder) + else -> {} + } + POST("$baseUrl/api/${path!!}", headers, builder.build()) + } + } + + override fun searchMangaParse(response: Response) = popularMangaParse(response) + + // for WebView + override fun mangaDetailsRequest(manga: SManga) = GET("$baseUrl/#/book/${manga.url}") + + override fun mangaDetailsParse(response: Response) = throw UnsupportedOperationException() + + override fun fetchMangaDetails(manga: SManga): Observable { + val body = FormBody.Builder() + .addEncoded("bid", manga.url) + .build() + val request = POST("$baseUrl/api/getbookinfo", headers, body) + return client.newCall(request).asObservableSuccess().map { + it.parseAs().toSManga(imageCdn) + } + } + + override fun chapterListParse(response: Response) = throw UnsupportedOperationException() + + override fun fetchChapterList(manga: SManga): Observable> { + val pageCount = manga.pageCount + if (pageCount <= 0) return Observable.just(emptyList()) + val chapter = SChapter.create().apply { + url = "${manga.url}#$pageCount" + name = "单章节" + date_upload = manga.timestamp + chapter_number = -2f + } + return Observable.just(listOf(chapter)) + } + + // for WebView + override fun pageListRequest(chapter: SChapter) = GET("$baseUrl/#/read/" + chapter.url.substringBefore('#')) + + override fun pageListParse(response: Response) = throw UnsupportedOperationException() + + override fun fetchPageList(chapter: SChapter): Observable> { + val mangaId = chapter.url.substringBefore('#') + val pageCount = chapter.url.substringAfter('#').toInt() + val imageCdn = imageCdn + val pageList = List(pageCount) { + Page(it, imageUrl = "$imageCdn/$mangaId/${it + 1}.webp") + } + return Observable.just(pageList) + } + + override fun imageUrlParse(response: Response) = throw UnsupportedOperationException() + + private val json: Json by injectLazy() + + private inline fun Response.parseAs(): T = try { + json.decodeFromStream(body!!.byteStream()) + } catch (e: Throwable) { + throw Exception("请在 WebView 中登录") + } finally { + close() + } + + override fun setupPreferenceScreen(screen: PreferenceScreen) { + getPreferencesInternal(screen.context).forEach(screen::addPreference) + } +} diff --git a/src/zh/noyacg/src/eu/kanade/tachiyomi/extension/zh/noyacg/Preferences.kt b/src/zh/noyacg/src/eu/kanade/tachiyomi/extension/zh/noyacg/Preferences.kt new file mode 100644 index 000000000..02609fb94 --- /dev/null +++ b/src/zh/noyacg/src/eu/kanade/tachiyomi/extension/zh/noyacg/Preferences.kt @@ -0,0 +1,31 @@ +package eu.kanade.tachiyomi.extension.zh.noyacg + +import android.content.Context +import android.content.SharedPreferences +import androidx.preference.ListPreference +import kotlin.random.Random + +fun getPreferencesInternal(context: Context) = arrayOf( + ListPreference(context).apply { + val count = IMAGE_CDN.size + key = IMAGE_CDN_PREF + title = "图片分流(重启生效)" + summary = "%s" + entries = Array(count) { "分流 ${it + 1}" } + entryValues = Array(count) { "$it" } + }, +) + +val SharedPreferences.imageCdn: String + get() { + val imageCdn = IMAGE_CDN + var index = getString(IMAGE_CDN_PREF, "-1")!!.toInt() + if (index !in imageCdn.indices) { + index = Random.nextInt(0, imageCdn.size) + edit().putString(IMAGE_CDN_PREF, index.toString()).apply() + } + return "https://" + imageCdn[index] + } + +const val IMAGE_CDN_PREF = "IMAGE_CDN" +val IMAGE_CDN get() = arrayOf("img.noy.asia", "img.noyteam.online", "img.457475.xyz")