diff --git a/.github/workflows/issue_moderator.yml b/.github/workflows/issue_moderator.yml index 5b7ec3fbd..f134409f0 100644 --- a/.github/workflows/issue_moderator.yml +++ b/.github/workflows/issue_moderator.yml @@ -37,7 +37,7 @@ jobs: }, { "type": "both", - "regex": ".*(mangafox|hq\\s*dragon|manga\\s*host|supermangas|superhentais|union\\s*mangas|yes\\s*mangas|manhuascan|manhwahot|leitor\\.?net|manga\\s*livre|tsuki\\s*mangas|manga\\s*yabu|mangas\\.in|mangas\\.pw|hentaikai|toptoon\\+?|cocomanga|hitomi\\.la|copymanga|neox|1manga\\.co|mangafox\\.fun|mangahere\\.onl|mangakakalot\\.fun|manganel(?!o)|mangaonline\\.fun|mangatoday|manga\\.town|onemanga\\.info|koushoku).*", + "regex": ".*(hq\\s*dragon|manga\\s*host|supermangas|superhentais|union\\s*mangas|yes\\s*mangas|manhuascan|manhwahot|leitor\\.?net|manga\\s*livre|tsuki\\s*mangas|manga\\s*yabu|mangas\\.in|mangas\\.pw|hentaikai|toptoon\\+?|cocomanga|hitomi\\.la|copymanga|neox|1manga\\.co|mangafox\\.fun|mangahere\\.onl|mangakakalot\\.fun|manganel(?!o)|mangaonline\\.fun|mangatoday|manga\\.town|onemanga\\.info|koushoku).*", "ignoreCase": true, "message": "{match} will not be added back as it is too difficult to maintain. Read #3475 for more information." }, diff --git a/REMOVED_SOURCES.md b/REMOVED_SOURCES.md index d75addb24..00ca6d7cf 100644 --- a/REMOVED_SOURCES.md +++ b/REMOVED_SOURCES.md @@ -4,7 +4,6 @@ - CocoManga (COCO漫画) https://github.com/tachiyomiorg/tachiyomi-extensions/pull/11445 - CopyManga (拷贝漫画) https://github.com/tachiyomiorg/tachiyomi-extensions/pull/12376 -- fanfox.net (MangaFox) https://github.com/tachiyomiorg/tachiyomi-extensions/issues/988 - Hitomi.la https://github.com/tachiyomiorg/tachiyomi-extensions/pull/11613 - HQ Dragon https://github.com/tachiyomiorg/tachiyomi-extensions/pull/7065 - Koushoku https://github.com/tachiyomiorg/tachiyomi-extensions/pull/13329 diff --git a/src/en/mangafox/AndroidManifest.xml b/src/en/mangafox/AndroidManifest.xml new file mode 100644 index 000000000..b4571bfa8 --- /dev/null +++ b/src/en/mangafox/AndroidManifest.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/src/en/mangafox/build.gradle b/src/en/mangafox/build.gradle new file mode 100644 index 000000000..ed52d140e --- /dev/null +++ b/src/en/mangafox/build.gradle @@ -0,0 +1,12 @@ +apply plugin: 'com.android.application' +apply plugin: 'kotlin-android' +apply plugin: 'kotlinx-serialization' + +ext { + extName = 'MangaFox' + pkgNameSuffix = 'en.mangafox' + extClass = '.MangaFox' + extVersionCode = 5 +} + +apply from: "$rootDir/common.gradle" diff --git a/src/en/mangafox/res/mipmap-hdpi/ic_launcher.png b/src/en/mangafox/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 000000000..f2b26e65b Binary files /dev/null and b/src/en/mangafox/res/mipmap-hdpi/ic_launcher.png differ diff --git a/src/en/mangafox/res/mipmap-mdpi/ic_launcher.png b/src/en/mangafox/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 000000000..ec97ff9ff Binary files /dev/null and b/src/en/mangafox/res/mipmap-mdpi/ic_launcher.png differ diff --git a/src/en/mangafox/res/mipmap-xhdpi/ic_launcher.png b/src/en/mangafox/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 000000000..629ac98da Binary files /dev/null and b/src/en/mangafox/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/src/en/mangafox/res/mipmap-xxhdpi/ic_launcher.png b/src/en/mangafox/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 000000000..73b1eb472 Binary files /dev/null and b/src/en/mangafox/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/src/en/mangafox/res/mipmap-xxxhdpi/ic_launcher.png b/src/en/mangafox/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 000000000..5ea69c7ba Binary files /dev/null and b/src/en/mangafox/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/src/en/mangafox/res/web_hi_res_512.png b/src/en/mangafox/res/web_hi_res_512.png new file mode 100644 index 000000000..ee61fab76 Binary files /dev/null and b/src/en/mangafox/res/web_hi_res_512.png differ diff --git a/src/en/mangafox/src/eu/kanade/tachiyomi/extension/en/mangafox/MangaFox.kt b/src/en/mangafox/src/eu/kanade/tachiyomi/extension/en/mangafox/MangaFox.kt new file mode 100644 index 000000000..6f671862b --- /dev/null +++ b/src/en/mangafox/src/eu/kanade/tachiyomi/extension/en/mangafox/MangaFox.kt @@ -0,0 +1,322 @@ +package eu.kanade.tachiyomi.extension.en.mangafox + +import eu.kanade.tachiyomi.network.GET +import eu.kanade.tachiyomi.network.interceptor.rateLimit +import eu.kanade.tachiyomi.source.model.Filter +import eu.kanade.tachiyomi.source.model.FilterList +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.ParsedHttpSource +import okhttp3.Headers +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.OkHttpClient +import okhttp3.Request +import org.jsoup.nodes.Document +import org.jsoup.nodes.Element +import java.text.SimpleDateFormat +import java.util.Calendar +import java.util.Locale + +class MangaFox : ParsedHttpSource() { + + override val name: String = "MangaFox" + + override val baseUrl: String = "https://fanfox.net" + + private val mobileUrl: String = "https://m.fanfox.net" + + override val lang: String = "en" + + override val supportsLatest: Boolean = true + + override val client: OkHttpClient = network.cloudflareClient.newBuilder() + .rateLimit(1, 1) + .build() + + override fun headersBuilder(): Headers.Builder = super.headersBuilder().add("Referer", "$baseUrl/") + + override fun popularMangaRequest(page: Int): Request { + val pageStr = if (page != 1) "$page.html" else "" + return GET("$baseUrl/directory/$pageStr", headers) + } + + override fun popularMangaSelector(): String = "ul.manga-list-1-list li" + + override fun popularMangaFromElement(element: Element): SManga = SManga.create().apply { + element.select("a").first().let { + setUrlWithoutDomain(it.attr("href")) + title = it.attr("title") + thumbnail_url = it.select("img").attr("abs:src") + } + } + + override fun popularMangaNextPageSelector(): String = ".pager-list-left a.active + a + a" + + override fun latestUpdatesRequest(page: Int): Request { + val pageStr = if (page != 1) "$page.html" else "" + return GET("$baseUrl/directory/$pageStr?latest", headers) + } + + override fun latestUpdatesSelector(): String = popularMangaSelector() + + override fun latestUpdatesFromElement(element: Element): SManga = popularMangaFromElement(element) + + override fun latestUpdatesNextPageSelector(): String = popularMangaNextPageSelector() + + override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request { + val genres = mutableListOf() + val genresEx = mutableListOf() + val url = baseUrl.toHttpUrl().newBuilder().apply { + addPathSegment("search") + addQueryParameter("title", query) + (if (filters.isEmpty()) getFilterList() else filters).forEach { filter -> + when (filter) { + is UriPartFilter -> addQueryParameter(filter.query, filter.toUriPart()) + is GenreFilter -> filter.state.forEach { + when (it.state) { + Filter.TriState.STATE_INCLUDE -> genres.add(it.id) + Filter.TriState.STATE_EXCLUDE -> genresEx.add(it.id) + else -> {} + } + } + is FilterWithMethodAndText -> { + val method = filter.state[0] as UriPartFilter + val text = filter.state[1] as TextSearchFilter + addQueryParameter(method.query, method.toUriPart()) + addQueryParameter(text.query, text.state) + } + is RatingFilter -> filter.state.forEach { + addQueryParameter(it.query, it.toUriPart()) + } + is TextSearchFilter -> addQueryParameter(filter.query, filter.state) + else -> {} + } + } + addQueryParameter("genres", genres.joinToString(",")) + addQueryParameter("nogenres", genresEx.joinToString(",")) + addQueryParameter("sort", "") + addQueryParameter("stype", "1") + }.build().toString() + return GET(url, headers) + } + + override fun searchMangaSelector(): String = "ul.manga-list-4-list li" + + override fun searchMangaFromElement(element: Element): SManga = popularMangaFromElement(element) + + override fun searchMangaNextPageSelector(): String = popularMangaNextPageSelector() + + override fun mangaDetailsParse(document: Document): SManga = SManga.create().apply { + document.select(".detail-info-right").first().let { + author = it.select(".detail-info-right-say a").joinToString(", ") { it.text() } + genre = it.select(".detail-info-right-tag-list a").joinToString(", ") { it.text() } + description = it.select("p.fullcontent").first()?.text() + status = it.select(".detail-info-right-title-tip").first()?.text().orEmpty().let { parseStatus(it) } + thumbnail_url = document.select(".detail-info-cover-img").first()?.attr("abs:src") + } + } + + override fun chapterListSelector() = "ul.detail-main-list li a" + + override fun chapterFromElement(element: Element): SChapter = SChapter.create().apply { + setUrlWithoutDomain(element.attr("href")) + name = element.select(".detail-main-list-main p").first()?.text().orEmpty() + date_upload = element.select(".detail-main-list-main p").last()?.text()?.let { parseChapterDate(it) } ?: 0 + } + + private fun parseChapterDate(date: String): Long { + return if ("Today" in date || " ago" in date) { + Calendar.getInstance().apply { + set(Calendar.HOUR_OF_DAY, 0) + set(Calendar.MINUTE, 0) + set(Calendar.SECOND, 0) + set(Calendar.MILLISECOND, 0) + }.timeInMillis + } else if ("Yesterday" in date) { + Calendar.getInstance().apply { + add(Calendar.DATE, -1) + set(Calendar.HOUR_OF_DAY, 0) + set(Calendar.MINUTE, 0) + set(Calendar.SECOND, 0) + set(Calendar.MILLISECOND, 0) + }.timeInMillis + } else { + kotlin.runCatching { + SimpleDateFormat("MMM d,yyyy", Locale.ENGLISH).parse(date)?.time + }.getOrNull() ?: 0L + } + } + + override fun pageListRequest(chapter: SChapter): Request { + val mobilePath = chapter.url.replace("/manga/", "/roll_manga/") + + val headers = headersBuilder().set("Referer", "$mobileUrl/").build() + + return GET("$mobileUrl$mobilePath", headers) + } + + override fun pageListParse(document: Document): List = + document.select("#viewer img").mapIndexed { idx, it -> + Page(idx, imageUrl = it.attr("abs:data-original")) + } + + override fun imageUrlParse(document: Document): String = throw UnsupportedOperationException("Not used") + + private fun parseStatus(status: String) = when { + status.contains("Ongoing") -> SManga.ONGOING + status.contains("Completed") -> SManga.COMPLETED + else -> SManga.UNKNOWN + } + + override fun getFilterList(): FilterList = FilterList( + NameFilter(), + EntryTypeFilter(), + CompletedFilter(), + AuthorFilter(), + ArtistFilter(), + RatingFilter(), + YearFilter(), + GenreFilter(getGenreList()), + ) + + open class UriPartFilter( + name: String, + val query: String, + private val vals: Array>, + state: Int = 0 + ) : Filter.Select(name, vals.map { it.first }.toTypedArray(), state) { + fun toUriPart() = vals[state].second + } + + open class TextSearchMethodFilter(name: String, query: String) : + UriPartFilter(name, query, arrayOf(Pair("contain", "cw"), Pair("begin", "bw"), Pair("end", "ew"))) + + open class TextSearchFilter(name: String, val query: String) : Filter.Text(name) + + open class FilterWithMethodAndText(name: String, state: List>) : + Filter.Group>(name, state) + + private class NameFilter : TextSearchFilter("Name", "name") + + private class EntryTypeFilter : UriPartFilter( + "Type", + "type", + arrayOf( + Pair("Any", "0"), + Pair("Japanese Manga", "1"), + Pair("Korean Manhwa", "2"), + Pair("Chinese Manhua", "3"), + Pair("European Manga", "4"), + Pair("American Manga", "5"), + Pair("HongKong Manga", "6"), + Pair("Other Manga", "7"), + ) + ) + + private class AuthorMethodFilter : TextSearchMethodFilter("Method", "author_method") + + private class AuthorTextFilter : TextSearchFilter("Author", "author") + + private class AuthorFilter : FilterWithMethodAndText("Author", listOf(AuthorMethodFilter(), AuthorTextFilter())) + + private class ArtistMethodFilter : TextSearchMethodFilter("Method", "artist_method") + + private class ArtistTextFilter : TextSearchFilter("Artist", "artist") + + private class ArtistFilter : FilterWithMethodAndText("Artist", listOf(ArtistMethodFilter(), ArtistTextFilter())) + + private class RatingMethodFilter : UriPartFilter( + "Method", + "rating_method", + arrayOf( + Pair("is", "eq"), + Pair("less than", "lt"), + Pair("more than", "gt"), + ) + ) + + private class RatingValueFilter : UriPartFilter( + "Rating", + "rating", + arrayOf( + Pair("any star", ""), + Pair("no star", "0"), + Pair("1 star", "1"), + Pair("2 stars", "2"), + Pair("3 stars", "3"), + Pair("4 stars", "4"), + Pair("5 stars", "5"), + ) + ) + + private class RatingFilter : Filter.Group("Rating", listOf(RatingMethodFilter(), RatingValueFilter())) + + private class YearMethodFilter : UriPartFilter( + "Method", + "released_method", + arrayOf( + Pair("on", "eq"), + Pair("before", "lt"), + Pair("after", "gt"), + ) + ) + + private class YearTextFilter : TextSearchFilter("Release year", "released") + + private class YearFilter : FilterWithMethodAndText("Release year", listOf(YearMethodFilter(), YearTextFilter())) + + private class CompletedFilter : UriPartFilter( + "Completed Series", + "st", + arrayOf( + Pair("Either", "0"), + Pair("Yes", "2"), + Pair("No", "1"), + ) + ) + + private class Genre(name: String, val id: Int) : Filter.TriState(name) + private class GenreFilter(genres: List) : Filter.Group("Genre", genres) + + // console.log([...document.querySelectorAll(".tag-box a")].map(e => `Genre("${e.innerHTML}", ${e.dataset.val})`).join(",\n")) + private fun getGenreList() = listOf( + Genre("Action", 1), + Genre("Adventure", 2), + Genre("Comedy", 3), + Genre("Drama", 4), + Genre("Fantasy", 5), + Genre("Martial Arts", 6), + Genre("Shounen", 7), + Genre("Horror", 8), + Genre("Supernatural", 9), + Genre("Harem", 10), + Genre("Psychological", 11), + Genre("Romance", 12), + Genre("School Life", 13), + Genre("Shoujo", 14), + Genre("Mystery", 15), + Genre("Sci-fi", 16), + Genre("Seinen", 17), + Genre("Tragedy", 18), + Genre("Ecchi", 19), + Genre("Sports", 20), + Genre("Slice of Life", 21), + Genre("Mature", 22), + Genre("Shoujo Ai", 23), + Genre("Webtoons", 24), + Genre("Doujinshi", 25), + Genre("One Shot", 26), + Genre("Smut", 27), + Genre("Yaoi", 28), + Genre("Josei", 29), + Genre("Historical", 30), + Genre("Shounen Ai", 31), + Genre("Gender Bender", 32), + Genre("Adult", 33), + Genre("Yuri", 34), + Genre("Mecha", 35), + Genre("Lolicon", 36), + Genre("Shotacon", 37) + ) +}