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