FMReader Fixes: ManhuaScan, HeroScan, etc... (#7047)
* FMReader: Added to chapterDateSelector * FMReader: Add fetchPageListEncrypted + Overrides for heroscan + manhuascan * change manhwasmut to manhwahot + override Co-authored-by: OncePunchedMan <64155117+OncePunchedMan@users.noreply.github.com>
@ -1,6 +1,7 @@
 | 
			
		||||
package eu.kanade.tachiyomi.extension.en.heroscan
 | 
			
		||||
 | 
			
		||||
import eu.kanade.tachiyomi.multisrc.fmreader.FMReader
 | 
			
		||||
import eu.kanade.tachiyomi.source.model.SChapter
 | 
			
		||||
import okhttp3.OkHttpClient
 | 
			
		||||
 | 
			
		||||
class HeroScan : FMReader("HeroScan", "https://heroscan.com", "en") {
 | 
			
		||||
@ -17,4 +18,5 @@ class HeroScan : FMReader("HeroScan", "https://heroscan.com", "en") {
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
            .build()
 | 
			
		||||
    override fun fetchPageList(chapter: SChapter) = fetchPageListEncrypted(chapter)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										12
									
								
								multisrc/overrides/fmreader/manhuascan/src/ManhuaScan.kt
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,12 @@
 | 
			
		||||
package eu.kanade.tachiyomi.extension.en.manhuascan
 | 
			
		||||
 | 
			
		||||
import eu.kanade.tachiyomi.multisrc.fmreader.FMReader
 | 
			
		||||
import eu.kanade.tachiyomi.source.model.SChapter
 | 
			
		||||
 | 
			
		||||
import eu.kanade.tachiyomi.annotations.Nsfw
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@Nsfw
 | 
			
		||||
class ManhuaScan : FMReader("ManhuaScan", "https://manhuascan.com", "en") {
 | 
			
		||||
    override fun fetchPageList(chapter: SChapter) = fetchPageListEncrypted(chapter)
 | 
			
		||||
}
 | 
			
		||||
| 
		 Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 3.7 KiB  | 
| 
		 Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 2.3 KiB  | 
| 
		 Before Width: | Height: | Size: 4.7 KiB After Width: | Height: | Size: 4.7 KiB  | 
| 
		 Before Width: | Height: | Size: 8.2 KiB After Width: | Height: | Size: 8.2 KiB  | 
| 
		 Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB  | 
| 
		 Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 44 KiB  | 
							
								
								
									
										13
									
								
								multisrc/overrides/fmreader/manhwahot/src/ManhwaHot.kt
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,13 @@
 | 
			
		||||
package eu.kanade.tachiyomi.extension.en.manhwahot
 | 
			
		||||
 | 
			
		||||
import eu.kanade.tachiyomi.annotations.Nsfw
 | 
			
		||||
import eu.kanade.tachiyomi.multisrc.fmreader.FMReader
 | 
			
		||||
import eu.kanade.tachiyomi.network.GET
 | 
			
		||||
import eu.kanade.tachiyomi.source.model.Page
 | 
			
		||||
import okhttp3.Request
 | 
			
		||||
import eu.kanade.tachiyomi.source.model.SChapter
 | 
			
		||||
 | 
			
		||||
@Nsfw
 | 
			
		||||
class ManhwaHot : FMReader("ManhwaHot", "https://manhwahot.com", "en") {
 | 
			
		||||
    override fun fetchPageList(chapter: SChapter) = fetchPageListEncrypted(chapter)
 | 
			
		||||
}
 | 
			
		||||
@ -1,13 +0,0 @@
 | 
			
		||||
package eu.kanade.tachiyomi.extension.en.manhwasmut
 | 
			
		||||
 | 
			
		||||
import eu.kanade.tachiyomi.annotations.Nsfw
 | 
			
		||||
import eu.kanade.tachiyomi.multisrc.fmreader.FMReader
 | 
			
		||||
import eu.kanade.tachiyomi.network.GET
 | 
			
		||||
import eu.kanade.tachiyomi.source.model.Page
 | 
			
		||||
import okhttp3.Request
 | 
			
		||||
 | 
			
		||||
@Nsfw
 | 
			
		||||
class ManhwaSmut : FMReader("ManhwaSmut", "https://manhwasmut.com", "en") {
 | 
			
		||||
    private val noReferer = headersBuilder().removeAll("Referer").build()
 | 
			
		||||
    override fun imageRequest(page: Page): Request = GET(page.imageUrl!!, if (page.imageUrl!!.contains("toonily")) noReferer else headers)
 | 
			
		||||
}
 | 
			
		||||
@ -1,7 +1,10 @@
 | 
			
		||||
package eu.kanade.tachiyomi.multisrc.fmreader
 | 
			
		||||
 | 
			
		||||
import android.util.Base64
 | 
			
		||||
import com.google.gson.JsonParser
 | 
			
		||||
import eu.kanade.tachiyomi.network.GET
 | 
			
		||||
import eu.kanade.tachiyomi.network.POST
 | 
			
		||||
import eu.kanade.tachiyomi.network.asObservableSuccess
 | 
			
		||||
import eu.kanade.tachiyomi.source.model.Filter
 | 
			
		||||
import eu.kanade.tachiyomi.source.model.FilterList
 | 
			
		||||
import eu.kanade.tachiyomi.source.model.MangasPage
 | 
			
		||||
@ -12,14 +15,24 @@ import eu.kanade.tachiyomi.source.online.ParsedHttpSource
 | 
			
		||||
import eu.kanade.tachiyomi.util.asJsoup
 | 
			
		||||
import okhttp3.Headers
 | 
			
		||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
 | 
			
		||||
import okhttp3.MediaType.Companion.toMediaTypeOrNull
 | 
			
		||||
import okhttp3.OkHttpClient
 | 
			
		||||
import okhttp3.Request
 | 
			
		||||
import okhttp3.RequestBody.Companion.toRequestBody
 | 
			
		||||
import okhttp3.Response
 | 
			
		||||
import okio.ByteString
 | 
			
		||||
import okio.ByteString.Companion.decodeBase64
 | 
			
		||||
import okio.ByteString.Companion.decodeHex
 | 
			
		||||
import org.jsoup.nodes.Document
 | 
			
		||||
import org.jsoup.nodes.Element
 | 
			
		||||
import org.jsoup.select.Elements
 | 
			
		||||
import rx.Observable
 | 
			
		||||
import java.nio.charset.Charset
 | 
			
		||||
import java.security.MessageDigest
 | 
			
		||||
import java.util.Calendar
 | 
			
		||||
import javax.crypto.Cipher
 | 
			
		||||
import javax.crypto.spec.IvParameterSpec
 | 
			
		||||
import javax.crypto.spec.SecretKeySpec
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * For sites based on the Flat-Manga CMS
 | 
			
		||||
@ -209,7 +222,7 @@ abstract class FMReader(
 | 
			
		||||
 | 
			
		||||
    open val chapterUrlSelector = "a"
 | 
			
		||||
 | 
			
		||||
    open val chapterTimeSelector = "time, .chapter-time"
 | 
			
		||||
    open val chapterTimeSelector = "time, .chapter-time, .publishedDate"
 | 
			
		||||
 | 
			
		||||
    open val chapterNameAttrSelector = "title"
 | 
			
		||||
 | 
			
		||||
@ -314,6 +327,36 @@ abstract class FMReader(
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
        /**
 | 
			
		||||
     * fetches the pageList by decrypting and parsing the json response of a "chapter server"
 | 
			
		||||
     * Meant to be used by relevant subclasses to replace their implementation of fetchPageList as needed
 | 
			
		||||
     * e.g ManhuaScan, HeroScan
 | 
			
		||||
     */
 | 
			
		||||
    protected fun fetchPageListEncrypted(chapter: SChapter): Observable<List<Page>> {
 | 
			
		||||
        fun stringAssignment(varname: String, script: String) = Regex("""var\s+$varname\s*=\s*"([^"]*)"""").find(script)?.groups?.get(1)?.value
 | 
			
		||||
        fun pageList(s: String) = Regex("https.+?(?=https|\"$)").findAll(s).map { it.groups[0]!!.value.replace("\\/", "/") }
 | 
			
		||||
        fun pageListRequest(id: String, server: Int = 1) = POST("$baseUrl/app/manga/controllers/cont.chapterServer$server.php", headers, "id=$id".toRequestBody("application/x-www-form-urlencoded; charset=UTF-8".toMediaTypeOrNull()))
 | 
			
		||||
        return client.newCall(GET("$baseUrl${chapter.url}", headers)).asObservableSuccess().concatMap { htmlResponse ->
 | 
			
		||||
            val soup = htmlResponse.asJsoup()
 | 
			
		||||
            soup.selectFirst("head > script[type='text/javascript']")?.data()?.let { params ->
 | 
			
		||||
                val chapterId = stringAssignment("chapter_id", params)
 | 
			
		||||
                val csrfToken = stringAssignment("csrf_token", params)
 | 
			
		||||
                if (chapterId == null || csrfToken == null)
 | 
			
		||||
                    null
 | 
			
		||||
                else
 | 
			
		||||
                    client.newCall(pageListRequest(chapterId)).asObservableSuccess()
 | 
			
		||||
                        .map { jsonResponse ->
 | 
			
		||||
                            pageList(
 | 
			
		||||
                                crypto.aes_decrypt(
 | 
			
		||||
                                    jsonResponse.body!!.string(),
 | 
			
		||||
                                    crypto.md5("$csrfToken$csrfToken").toByteArray()
 | 
			
		||||
                                )
 | 
			
		||||
                            ).mapIndexed { i, imgUrl -> Page(i, "", imgUrl) }.toList()
 | 
			
		||||
                        }
 | 
			
		||||
            } ?: Observable.just(emptyList())
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    override fun imageUrlParse(document: Document): String = throw UnsupportedOperationException("Not used")
 | 
			
		||||
 | 
			
		||||
    private class TextField(name: String, val key: String) : Filter.Text(name)
 | 
			
		||||
@ -471,4 +514,75 @@ abstract class FMReader(
 | 
			
		||||
        Genre("Western"),
 | 
			
		||||
        Genre("Zombies")
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    companion object {
 | 
			
		||||
        interface Crypto {
 | 
			
		||||
            fun md5(s: String): String;
 | 
			
		||||
            fun aes_decrypt(context: String, key: ByteArray): String;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        /* cryptography utilities leveraged by fetchPageListEncrypted */
 | 
			
		||||
        val crypto = object : Crypto {
 | 
			
		||||
            private fun parseCryptoCT(s: String): Map<String, ByteString> {
 | 
			
		||||
                // https://cryptojs.gitbook.io/docs/
 | 
			
		||||
                val jsonObj = JsonParser.parseString(s).asJsonObject
 | 
			
		||||
                val cipherParams = mutableMapOf("ciphertext" to jsonObj["ct"].asString.decodeBase64()!!)
 | 
			
		||||
                jsonObj["iv"]?.let { cipherParams.put("iv", it.asString.decodeHex()) }
 | 
			
		||||
                jsonObj["s"]?.let { cipherParams.put("salt", it.asString.decodeHex()) }
 | 
			
		||||
                return cipherParams
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            private fun decryptAES(encrypted: ByteString, password: ByteArray, iv: ByteString, salt: ByteString): String {
 | 
			
		||||
                // https://stackoverflow.com/questions/29151211/29152379#29152379
 | 
			
		||||
                val (key, _iv) = EvpKDF(password, 256, 128, salt.toByteArray(), 1, "MD5")
 | 
			
		||||
                val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
 | 
			
		||||
                cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(key, "AES"), IvParameterSpec(_iv))
 | 
			
		||||
                return String(cipher.doFinal(encrypted.toByteArray()), Charset.forName("UTF-8"))
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            private fun EvpKDF(password: ByteArray, _keySize: Int, _ivSize: Int, salt: ByteArray, iterations: Int, hashAlgorithm: String): Pair<ByteArray, ByteArray> {
 | 
			
		||||
                // Key Derivation Function
 | 
			
		||||
                val keySize = _keySize / 32
 | 
			
		||||
                val ivSize = _ivSize / 32
 | 
			
		||||
                val targetKeySize = keySize + ivSize
 | 
			
		||||
                val derivedBytes = ByteArray(targetKeySize * 4)
 | 
			
		||||
                var numberOfDerivedWords = 0
 | 
			
		||||
                var block = ByteArray(0)
 | 
			
		||||
                val hasher = MessageDigest.getInstance(hashAlgorithm)
 | 
			
		||||
                val key = ByteArray(keySize * 4)
 | 
			
		||||
                val iv = ByteArray(ivSize * 4)
 | 
			
		||||
                while (numberOfDerivedWords < targetKeySize) {
 | 
			
		||||
                    if (block != null) {
 | 
			
		||||
                        hasher.update(block)
 | 
			
		||||
                    }
 | 
			
		||||
                    hasher.update(password)
 | 
			
		||||
                    block = hasher.digest(salt)
 | 
			
		||||
                    hasher.reset()
 | 
			
		||||
 | 
			
		||||
                    // Iterations
 | 
			
		||||
                    for (i in 1 until iterations) {
 | 
			
		||||
                        block = hasher.digest(block)
 | 
			
		||||
                        hasher.reset()
 | 
			
		||||
                    }
 | 
			
		||||
 | 
			
		||||
                    System.arraycopy(block, 0, derivedBytes, numberOfDerivedWords * 4,
 | 
			
		||||
                        block.size.coerceAtMost((targetKeySize - numberOfDerivedWords) * 4))
 | 
			
		||||
 | 
			
		||||
                    numberOfDerivedWords += block.size / 4
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                System.arraycopy(derivedBytes, 0, key, 0, key.size)
 | 
			
		||||
                System.arraycopy(derivedBytes, key.size, iv, 0, iv.size)
 | 
			
		||||
 | 
			
		||||
                return key to iv // key + iv
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            override fun md5(s: String): String = MessageDigest.getInstance("MD5").digest(s.toByteArray()).joinToString("") { String.format("%02x", it) }
 | 
			
		||||
 | 
			
		||||
            override fun aes_decrypt(context: String, key: ByteArray): String {
 | 
			
		||||
                val cipherParams = parseCryptoCT(JsonParser.parseString(context).asJsonObject["content"].asString)
 | 
			
		||||
                return decryptAES(cipherParams["ciphertext"]!!, key, cipherParams["iv"]!!, cipherParams["salt"]!!)
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -10,24 +10,24 @@ class FMReaderGenerator : ThemeSourceGenerator {
 | 
			
		||||
 | 
			
		||||
    override val themeClass = "FMReader"
 | 
			
		||||
 | 
			
		||||
    override val baseVersionCode: Int = 3
 | 
			
		||||
    override val baseVersionCode: Int = 4
 | 
			
		||||
 | 
			
		||||
    /** For future sources: when testing and popularMangaRequest() returns a Jsoup error instead of results
 | 
			
		||||
     *  most likely the fix is to override popularMangaNextPageSelector()   */
 | 
			
		||||
 | 
			
		||||
    override val sources = listOf(
 | 
			
		||||
        SingleLang("Epik Manga", "https://www.epikmanga.com", "tr"),
 | 
			
		||||
        SingleLang("HeroScan", "https://heroscan.com", "en"),
 | 
			
		||||
        SingleLang("HeroScan", "https://heroscan.com", "en", overrideVersionCode = 1),
 | 
			
		||||
        SingleLang("KissLove", "https://kissaway.net", "ja"),
 | 
			
		||||
        SingleLang("LHTranslation", "https://lhtranslation.net", "en", overrideVersionCode = 1),
 | 
			
		||||
        SingleLang("Manga-TR", "https://manga-tr.com", "tr", className = "MangaTR"),
 | 
			
		||||
        SingleLang("ManhuaScan", "https://manhuascan.com", "en", isNsfw = true, overrideVersionCode = 1),
 | 
			
		||||
        SingleLang("ManhuaScan", "https://manhuascan.com", "en", isNsfw = true, overrideVersionCode = 2),
 | 
			
		||||
        SingleLang("Manhwa18", "https://manhwa18.com", "en", isNsfw = true),
 | 
			
		||||
        MultiLang("Manhwa18.net", "https://manhwa18.net", listOf("en", "ko"), className = "Manhwa18NetFactory", isNsfw = true),
 | 
			
		||||
        SingleLang("ManhwaSmut", "https://manhwasmut.com", "en", isNsfw = true, overrideVersionCode = 2),
 | 
			
		||||
        SingleLang("RawLH", "https://lovehug.net", "ja"),
 | 
			
		||||
        SingleLang("Say Truyen", "https://saytruyen.com", "vi"),
 | 
			
		||||
        SingleLang("KSGroupScans", "https://ksgroupscans.com", "en"),
 | 
			
		||||
        SingleLang("ManhwaHot", "https://manhwahot.com", "en", isNsfw = true),
 | 
			
		||||
        // Sites that went down
 | 
			
		||||
        //SingleLang("18LHPlus", "https://18lhplus.com", "en", className = "EighteenLHPlus"),
 | 
			
		||||
        //SingleLang("HanaScan (RawQQ)", "https://hanascan.com", "ja", className = "HanaScanRawQQ"),
 | 
			
		||||
 | 
			
		||||