diff --git a/src/pt/huntersscans/AndroidManifest.xml b/src/pt/huntersscans/AndroidManifest.xml
new file mode 100644
index 000000000..3329b789a
--- /dev/null
+++ b/src/pt/huntersscans/AndroidManifest.xml
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/pt/huntersscans/build.gradle b/src/pt/huntersscans/build.gradle
index 6ba36d656..d802c6a6f 100644
--- a/src/pt/huntersscans/build.gradle
+++ b/src/pt/huntersscans/build.gradle
@@ -1,10 +1,11 @@
ext {
extName = 'Hunters Scans'
extClass = '.HuntersScans'
- themePkg = 'madara'
- baseUrl = 'https://huntersscan.xyz'
- overrideVersionCode = 0
- isNsfw = true
+ extVersionCode = 36
}
apply from: "$rootDir/common.gradle"
+
+dependencies {
+ implementation(project(':lib:randomua'))
+}
diff --git a/src/pt/huntersscans/src/eu/kanade/tachiyomi/extension/pt/huntersscans/HuntersScans.kt b/src/pt/huntersscans/src/eu/kanade/tachiyomi/extension/pt/huntersscans/HuntersScans.kt
index a787b0da5..8c02a5aef 100644
--- a/src/pt/huntersscans/src/eu/kanade/tachiyomi/extension/pt/huntersscans/HuntersScans.kt
+++ b/src/pt/huntersscans/src/eu/kanade/tachiyomi/extension/pt/huntersscans/HuntersScans.kt
@@ -1,23 +1,253 @@
package eu.kanade.tachiyomi.extension.pt.huntersscans
-import eu.kanade.tachiyomi.multisrc.madara.Madara
-import eu.kanade.tachiyomi.network.interceptor.rateLimit
+import android.app.Application
+import android.content.SharedPreferences
+import android.util.Log
+import androidx.preference.PreferenceScreen
+import eu.kanade.tachiyomi.lib.randomua.addRandomUAPreferenceToScreen
+import eu.kanade.tachiyomi.lib.randomua.getPrefCustomUA
+import eu.kanade.tachiyomi.lib.randomua.getPrefUAType
+import eu.kanade.tachiyomi.lib.randomua.setRandomUserAgent
+import eu.kanade.tachiyomi.network.GET
+import eu.kanade.tachiyomi.network.asObservableSuccess
+import eu.kanade.tachiyomi.network.interceptor.rateLimitHost
+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.ParsedHttpSource
+import eu.kanade.tachiyomi.util.asJsoup
+import okhttp3.HttpUrl
+import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.OkHttpClient
-import java.text.SimpleDateFormat
+import okhttp3.Request
+import okhttp3.Response
+import org.jsoup.nodes.Document
+import org.jsoup.nodes.Element
+import rx.Observable
+import uy.kohesive.injekt.Injekt
+import uy.kohesive.injekt.api.get
import java.util.Locale
import java.util.concurrent.TimeUnit
-class HuntersScans : Madara(
- "Hunters Scan",
- "https://huntersscan.xyz/",
- "pt-BR",
- SimpleDateFormat("dd 'de' MMMMM 'de' yyyy", Locale("pt", "BR")),
-) {
+class HuntersScans : ParsedHttpSource(), ConfigurableSource {
+ override val name = "Hunters Scans"
- override val client: OkHttpClient = super.client.newBuilder()
- .rateLimit(1, 2, TimeUnit.SECONDS)
- .build()
+ override val lang = "pt-BR"
- override val useNewChapterEndpoint = true
- override val useLoadMoreRequest = LoadMoreStrategy.Always
+ override val supportsLatest = true
+
+ override val baseUrl = "https://huntersscan.xyz"
+
+ override val versionId = 2
+
+ private val preferences: SharedPreferences =
+ Injekt.get().getSharedPreferences("source_$id", 0x0000)
+
+ override val client: OkHttpClient =
+ network.cloudflareClient.newBuilder()
+ .setRandomUserAgent(
+ preferences.getPrefUAType(),
+ preferences.getPrefCustomUA(),
+ )
+ .rateLimitHost(baseUrl.toHttpUrl(), 1, 2, TimeUnit.SECONDS)
+ .build()
+
+ override fun setupPreferenceScreen(screen: PreferenceScreen) {
+ addRandomUAPreferenceToScreen(screen)
+ }
+
+ override fun chapterFromElement(element: Element) = throw UnsupportedOperationException()
+
+ private fun fetchChapterList(url: HttpUrl, page: Int): List {
+ return try {
+ val mangaPaged = url.newBuilder()
+ .addQueryParameter("page", "$page")
+ .build()
+ chapterListParseFromJS(client.newCall(GET(mangaPaged, headers)).execute())
+ } catch (e: Exception) {
+ Log.e("HuntersScans", e.toString())
+ emptyList()
+ }
+ }
+
+ private fun chapterListParseFromJS(response: Response): List {
+ val jScript = response.asJsoup().select(chapterListSelector())
+ .map { element -> element.html() }
+ .filter { element -> element.isNotEmpty() }
+ .first { chapterRegex.find(it) != null }
+
+ val chaptersLinks = chapterRegex.findAll(jScript)
+ .flatMap { result -> result.groups.mapNotNull { it?.value } }
+ .toSet()
+
+ return chaptersLinks.map { chapterLink ->
+ SChapter.create().apply {
+ name = chapterLink.toChapterName()
+ setUrlWithoutDomain(chapterLink.toChapterAbsUrl())
+ }
+ }
+ }
+
+ private fun containsDuplicate(chapters: List): Boolean {
+ return chapters.size != chapters.distinctBy { it.name }.size
+ }
+
+ override fun chapterListParse(response: Response): List {
+ val chapters = mutableListOf()
+ val alwaysVisibleChapters = mutableSetOf()
+
+ val origin = response.request.url
+ var currentPage = 1
+
+ do {
+ val chapterList = fetchChapterList(origin, currentPage)
+ if (chapterList.size <= 2) {
+ chapters += chapterList
+ break
+ }
+
+ chapters += chapterList.sortedBy { it.name.toFloat() }
+ alwaysVisibleChapters += chapters.removeFirst()
+ alwaysVisibleChapters += chapters.removeLast()
+
+ currentPage++
+ }
+ while (!containsDuplicate(chapters))
+
+ chapters += alwaysVisibleChapters
+
+ return chapters
+ .distinctBy { it.name }
+ .sortedBy { it.name.toFloat() }
+ .reversed()
+ }
+
+ override fun chapterListSelector() = "script"
+
+ override fun imageUrlParse(document: Document) = ""
+
+ override fun latestUpdatesFromElement(element: Element) =
+ SManga.create().apply {
+ val type = element.selectFirst("span")!!.ownText().toCapitalize()
+ title = "${element.selectFirst("h3")!!.ownText()} - $type"
+ thumbnail_url = element.selectFirst("img")?.absUrl("src")
+ setUrlWithoutDomain(element.selectFirst("a")!!.absUrl("href"))
+ }
+
+ override fun latestUpdatesNextPageSelector() = popularMangaNextPageSelector()
+
+ override fun latestUpdatesParse(response: Response) =
+ super.latestUpdatesParse(response).removeNovels()
+
+ override fun latestUpdatesRequest(page: Int): Request {
+ val url = "$baseUrl/ultimas-atualizacoes".toHttpUrl().newBuilder()
+ .addQueryParameter("page", "$page")
+ .build()
+ return GET(url, headers)
+ }
+
+ override fun latestUpdatesSelector() = "main > div div:nth-child(2) > div.relative"
+
+ override fun mangaDetailsParse(document: Document) =
+ SManga.create().apply {
+ val container = document.selectFirst("div.container")!!
+ val type = container.selectFirst("ul > li:nth-child(1) p")!!.ownText().toCapitalize()
+ title = "${container.selectFirst("h2")!!.ownText()} - $type"
+ thumbnail_url = container.selectFirst("img")?.absUrl("src")
+ genre = container.select("ul > li:nth-child(5) p").joinToString { it.ownText() }
+
+ val statusLabel = container.selectFirst("ul > li:nth-child(3) p")?.ownText()
+ status = when (statusLabel) {
+ "ongoing" -> SManga.ONGOING
+ else -> SManga.UNKNOWN
+ }
+ description = document.selectFirst("main > div > div")?.text()
+ }
+
+ override fun pageListParse(document: Document) =
+ document.select("main.container img")
+ .mapIndexed { i, element -> Page(i, imageUrl = element.absUrl("src")) }
+
+ override fun popularMangaFromElement(element: Element) = SManga.create().apply {
+ val type = element.selectFirst("span")!!.ownText().toCapitalize()
+ title = "${element.selectFirst("h2")!!.ownText()} - $type"
+ thumbnail_url = element.selectFirst("img")?.absUrl("src")
+ setUrlWithoutDomain(element.absUrl("href"))
+ }
+
+ override fun popularMangaNextPageSelector() = "li[aria-label='next page button']:not([aria-disabled])"
+
+ override fun popularMangaParse(response: Response) =
+ super.popularMangaParse(response).removeNovels()
+
+ override fun popularMangaRequest(page: Int): Request {
+ val url = "$baseUrl/manga".toHttpUrl().newBuilder()
+ .addQueryParameter("page", "$page")
+ .build()
+ return GET(url, headers)
+ }
+
+ override fun popularMangaSelector() = "main > div a"
+
+ override fun searchMangaFromElement(element: Element) = popularMangaFromElement(element)
+
+ override fun searchMangaNextPageSelector() = popularMangaNextPageSelector()
+
+ override fun searchMangaParse(response: Response) =
+ super.searchMangaParse(response).removeNovels()
+
+ override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
+ val url = "$baseUrl/manga".toHttpUrl().newBuilder()
+ .addQueryParameter("q", query)
+ .addQueryParameter("page", "$page")
+ .build()
+ return GET(url, headers)
+ }
+
+ override fun searchMangaSelector() = popularMangaSelector()
+
+ override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable {
+ if (query.startsWith(slugPrefix)) {
+ val mangaUrl = "/manga/${query.substringAfter(slugPrefix)}"
+ return client.newCall(GET("$baseUrl$mangaUrl", headers))
+ .asObservableSuccess().map { response ->
+ val manga = mangaDetailsParse(response).apply {
+ url = mangaUrl
+ }
+ MangasPage(listOf(manga), false)
+ }
+ }
+
+ return super.fetchSearchManga(page, query, filters)
+ }
+
+ private fun MangasPage.removeNovels(): MangasPage {
+ return MangasPage(
+ mangas = this.mangas.filter { !it.title.lowercase(Locale.ROOT).contains("novel") },
+ hasNextPage = this.hasNextPage,
+ )
+ }
+
+ private fun String.toCapitalize() =
+ trim().replaceFirstChar { if (it.isLowerCase()) it.titlecase(Locale.getDefault()) else it.toString() }
+
+ private fun String.toChapterName(): String {
+ return try {
+ val matches = chapterNameRegex.find(trim())?.groupValues ?: emptyList()
+ matches.last()
+ .replace(" ", "")
+ .replace("-", ".")
+ } catch (e: Exception) { "0" }
+ }
+
+ private fun String.toChapterAbsUrl() = "$baseUrl${trim()}"
+
+ companion object {
+ val chapterRegex = """/ler/[\w+-]+-capitulo-[\d.-]+""".toRegex()
+ val chapterNameRegex = """capitulo-([\d-.]+)""".toRegex()
+ val slugPrefix = "slug:"
+ }
}
diff --git a/src/pt/huntersscans/src/eu/kanade/tachiyomi/extension/pt/huntersscans/HuntersScansUrlActivity.kt b/src/pt/huntersscans/src/eu/kanade/tachiyomi/extension/pt/huntersscans/HuntersScansUrlActivity.kt
new file mode 100644
index 000000000..e68b229ab
--- /dev/null
+++ b/src/pt/huntersscans/src/eu/kanade/tachiyomi/extension/pt/huntersscans/HuntersScansUrlActivity.kt
@@ -0,0 +1,36 @@
+package eu.kanade.tachiyomi.extension.pt.huntersscans
+
+import android.app.Activity
+import android.content.ActivityNotFoundException
+import android.content.Intent
+import android.os.Bundle
+import android.util.Log
+import kotlin.system.exitProcess
+
+class HuntersScansUrlActivity : Activity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ val pathSegments = intent?.data?.pathSegments
+
+ if (pathSegments != null && pathSegments.size > 2) {
+ val intent = Intent().apply {
+ action = "eu.kanade.tachiyomi.SEARCH"
+ putExtra("query", slug(pathSegments))
+ putExtra("filter", packageName)
+ }
+
+ try {
+ startActivity(intent)
+ } catch (e: ActivityNotFoundException) {
+ Log.e("HuntersScansUrlActivity", e.toString())
+ }
+ } else {
+ Log.e("HuntersScansUrlActivity", "Could not parse URI from intent $intent")
+ }
+
+ finish()
+ exitProcess(0)
+ }
+
+ private fun slug(pathSegments: List) = "${HuntersScans.slugPrefix}${pathSegments.last()}"
+}