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)
+ )
+}