Disaster Scans (#16696)
* remove from madara * DisasterScans: partial implementation * use updated cdnUrl for existing thumbnails * url intent
| @ -1,23 +0,0 @@ | ||||
| package eu.kanade.tachiyomi.extension.en.disasterscans | ||||
| 
 | ||||
| import eu.kanade.tachiyomi.multisrc.madara.Madara | ||||
| import eu.kanade.tachiyomi.source.model.SManga | ||||
| import org.jsoup.nodes.Document | ||||
| 
 | ||||
| class DisasterScans : Madara("Disaster Scans", "https://disasterscans.com", "en") { | ||||
|     override val popularMangaUrlSelector = "div.post-title a:last-child" | ||||
| 
 | ||||
|     override fun mangaDetailsParse(document: Document): SManga { | ||||
|         val manga = super.mangaDetailsParse(document) | ||||
| 
 | ||||
|         with(document) { | ||||
|             select("div.post-title h1").first()?.let { | ||||
|                 manga.title = it.ownText() | ||||
|             } | ||||
|         } | ||||
| 
 | ||||
|         return manga | ||||
|     } | ||||
| 
 | ||||
|     override val useNewChapterEndpoint: Boolean = true | ||||
| } | ||||
| @ -78,7 +78,6 @@ class MadaraGenerator : ThemeSourceGenerator { | ||||
|         SingleLang("Dark Scans", "https://darkscans.com", "en"), | ||||
|         SingleLang("Decadence Scans", "https://reader.decadencescans.com", "en", isNsfw = true, overrideVersionCode = 2), | ||||
|         SingleLang("DiamondFansub", "https://diamondfansub.com", "tr", overrideVersionCode = 1), | ||||
|         SingleLang("Disaster Scans", "https://disasterscans.com", "en", overrideVersionCode = 2), | ||||
|         SingleLang("DokkoManga", "https://dokkomanga.com", "es", overrideVersionCode = 1), | ||||
|         SingleLang("Doodmanga", "https://www.doodmanga.com", "th"), | ||||
|         SingleLang("DoujinHentai", "https://doujinhentai.net", "es", isNsfw = true, overrideVersionCode = 1), | ||||
|  | ||||
							
								
								
									
										24
									
								
								src/en/disasterscans/AndroidManifest.xml
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @ -0,0 +1,24 @@ | ||||
| <?xml version="1.0" encoding="utf-8"?> | ||||
| <manifest xmlns:android="http://schemas.android.com/apk/res/android" | ||||
|     package="eu.kanade.tachiyomi.extension"> | ||||
| 
 | ||||
|     <application> | ||||
|         <activity | ||||
|             android:name=".en.disasterscans.DisasterScansUrlActivity" | ||||
|             android:excludeFromRecents="true" | ||||
|             android:exported="true" | ||||
|             android:theme="@android:style/Theme.NoDisplay"> | ||||
|             <intent-filter> | ||||
|                 <action android:name="android.intent.action.VIEW" /> | ||||
| 
 | ||||
|                 <category android:name="android.intent.category.DEFAULT" /> | ||||
|                 <category android:name="android.intent.category.BROWSABLE" /> | ||||
| 
 | ||||
|                 <data android:host="disasterscans.com" /> | ||||
|                 <data android:scheme="https" /> | ||||
| 
 | ||||
|                 <data android:pathPattern="/comics/..*" /> | ||||
|             </intent-filter> | ||||
|         </activity> | ||||
|     </application> | ||||
| </manifest> | ||||
							
								
								
									
										12
									
								
								src/en/disasterscans/build.gradle
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @ -0,0 +1,12 @@ | ||||
| apply plugin: 'com.android.application' | ||||
| apply plugin: 'kotlin-android' | ||||
| apply plugin: 'kotlinx-serialization' | ||||
| 
 | ||||
| ext { | ||||
|     extName = 'Disaster Scans' | ||||
|     pkgNameSuffix = 'en.disasterscans' | ||||
|     extClass = '.DisasterScans' | ||||
|     extVersionCode = 32 | ||||
| } | ||||
| 
 | ||||
| apply from: "$rootDir/common.gradle" | ||||
| Before Width: | Height: | Size: 7.2 KiB After Width: | Height: | Size: 7.2 KiB | 
| Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 3.7 KiB | 
| Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB | 
| Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 24 KiB | 
| Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 39 KiB | 
| Before Width: | Height: | Size: 316 KiB After Width: | Height: | Size: 316 KiB | 
| @ -0,0 +1,209 @@ | ||||
| package eu.kanade.tachiyomi.extension.en.disasterscans | ||||
| 
 | ||||
| import android.app.Application | ||||
| import android.content.SharedPreferences | ||||
| import eu.kanade.tachiyomi.network.GET | ||||
| import eu.kanade.tachiyomi.network.asObservableSuccess | ||||
| import eu.kanade.tachiyomi.network.interceptor.rateLimit | ||||
| 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 eu.kanade.tachiyomi.util.asJsoup | ||||
| import kotlinx.serialization.decodeFromString | ||||
| import kotlinx.serialization.json.Json | ||||
| import okhttp3.OkHttpClient | ||||
| import okhttp3.Request | ||||
| import okhttp3.Response | ||||
| import org.jsoup.nodes.Document | ||||
| import rx.Observable | ||||
| import uy.kohesive.injekt.Injekt | ||||
| import uy.kohesive.injekt.api.get | ||||
| import uy.kohesive.injekt.injectLazy | ||||
| import java.text.SimpleDateFormat | ||||
| import java.util.Locale | ||||
| 
 | ||||
| class DisasterScans : HttpSource() { | ||||
| 
 | ||||
|     override val name = "Disaster Scans" | ||||
| 
 | ||||
|     override val lang = "en" | ||||
| 
 | ||||
|     override val versionId = 2 | ||||
| 
 | ||||
|     override val baseUrl = "https://disasterscans.com" | ||||
| 
 | ||||
|     private val apiUrl = "https://api.disasterscans.com" | ||||
| 
 | ||||
|     override val supportsLatest = false | ||||
| 
 | ||||
|     override val client: OkHttpClient = network.cloudflareClient.newBuilder() | ||||
|         .addInterceptor { chain -> | ||||
|             val request = chain.request() | ||||
|             val url = request.url | ||||
|             if (url.fragment == "thumbnail") { | ||||
|                 val cdnUrl = preferences.getCdnUrl() | ||||
|                 val requestUrl = url.toString().substringBefore("=") + "=" | ||||
|                 if (cdnUrl != requestUrl) { | ||||
|                     val fileId = url.queryParameterValues("fileId").first() | ||||
|                     return@addInterceptor chain.proceed( | ||||
|                         request.newBuilder() | ||||
|                             .url("$cdnUrl$fileId") | ||||
|                             .build(), | ||||
|                     ) | ||||
|                 } | ||||
|             } | ||||
|             return@addInterceptor chain.proceed(request) | ||||
|         } | ||||
|         .rateLimit(1) | ||||
|         .build() | ||||
| 
 | ||||
|     private val json: Json by injectLazy() | ||||
| 
 | ||||
|     private val preferences: SharedPreferences by lazy { | ||||
|         Injekt.get<Application>().getSharedPreferences("source_$id", 0x0000) | ||||
|     } | ||||
| 
 | ||||
|     override fun popularMangaRequest(page: Int): Request { | ||||
|         return GET("$apiUrl/comics/search/comics", headers) | ||||
|     } | ||||
| 
 | ||||
|     override fun popularMangaParse(response: Response): MangasPage { | ||||
|         val comics = response.parseAs<List<ApiSearchComic>>() | ||||
| 
 | ||||
|         val cdnUrl = preferences.getCdnUrl() | ||||
| 
 | ||||
|         return MangasPage(comics.map { it.toSManga(cdnUrl) }, false) | ||||
|     } | ||||
| 
 | ||||
|     override fun fetchSearchManga( | ||||
|         page: Int, | ||||
|         query: String, | ||||
|         filters: FilterList, | ||||
|     ): Observable<MangasPage> { | ||||
|         return if (query.startsWith(PREFIX_SLUG)) { | ||||
|             val url = "/comics/${query.substringAfter(PREFIX_SLUG)}" | ||||
|             val manga = SManga.create().apply { this.url = url } | ||||
|             client.newCall(mangaDetailsRequest(manga)) | ||||
|                 .asObservableSuccess() | ||||
|                 .map { mangaDetailsParse(it).apply { this.url = url } } | ||||
|                 .map { MangasPage(listOf(it), false) } | ||||
|         } else { | ||||
|             client.newCall(searchMangaRequest(page, query, filters)) | ||||
|                 .asObservableSuccess() | ||||
|                 .map { searchMangaParse(it, query) } | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     override fun searchMangaRequest(page: Int, query: String, filters: FilterList) = popularMangaRequest(page) | ||||
| 
 | ||||
|     private fun searchMangaParse(response: Response, query: String): MangasPage { | ||||
|         val comics = response.parseAs<List<ApiSearchComic>>() | ||||
| 
 | ||||
|         val cdnUrl = preferences.getCdnUrl() | ||||
| 
 | ||||
|         return comics | ||||
|             .filter { it.ComicTitle.contains(query, true) } | ||||
|             .map { it.toSManga(cdnUrl) } | ||||
|             .let { MangasPage(it, false) } | ||||
|     } | ||||
| 
 | ||||
|     override fun mangaDetailsRequest(manga: SManga): Request { | ||||
|         return GET("$apiUrl${manga.url}", headers) | ||||
|     } | ||||
| 
 | ||||
|     override fun mangaDetailsParse(response: Response): SManga { | ||||
|         val comic = response.parseAs<ApiComic>() | ||||
| 
 | ||||
|         return comic.toSManga(json, preferences.getCdnUrl()) | ||||
|     } | ||||
| 
 | ||||
|     override fun getMangaUrl(manga: SManga) = "$baseUrl${manga.url}" | ||||
| 
 | ||||
|     override fun chapterListRequest(manga: SManga): Request { | ||||
|         val url = "$apiUrl${manga.url.replace("comics", "chapters")}" | ||||
|         return GET(url, headers) | ||||
|     } | ||||
| 
 | ||||
|     override fun chapterListParse(response: Response): List<SChapter> { | ||||
|         val chapters = response.parseAs<List<ApiChapter>>() | ||||
| 
 | ||||
|         val mangaUrl = response.request.url.toString() | ||||
|             .substringAfter(apiUrl) | ||||
|             .replace("chapters", "comics") | ||||
| 
 | ||||
|         return chapters.map { it.toSChapter(mangaUrl) } | ||||
|     } | ||||
| 
 | ||||
|     override fun pageListParse(response: Response): List<Page> { | ||||
|         val document = response.asJsoup() | ||||
| 
 | ||||
|         val chapterPages = document.select("#__NEXT_DATA__").html() | ||||
|             .parseAs<NextData<ApiChapterPages>>() | ||||
|             .props.pageProps.chapter.pages | ||||
| 
 | ||||
|         val pages = chapterPages.parseAs<List<String>>() | ||||
| 
 | ||||
|         val cdnUrl = updatedCdnUrl(document) | ||||
| 
 | ||||
|         return pages.mapIndexed { idx, image -> | ||||
|             Page(idx, "", "$cdnUrl$image") | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     private fun updatedCdnUrl(document: Document): String { | ||||
|         val cdnUrlFromPage = document.selectFirst("main div.maxWidth img") | ||||
|             ?.attr("src") | ||||
|             ?.substringBefore("?") | ||||
|             ?.let { "$it?fileId=" } | ||||
| 
 | ||||
|         return preferences.getCdnUrl() | ||||
|             .let { | ||||
|                 if (it != cdnUrlFromPage && cdnUrlFromPage != null) { | ||||
|                     preferences.putCdnUrl(cdnUrlFromPage) | ||||
|                     cdnUrlFromPage | ||||
|                 } else { | ||||
|                     it | ||||
|                 } | ||||
|             } | ||||
|     } | ||||
| 
 | ||||
|     private inline fun <reified T> String.parseAs(): T = | ||||
|         json.decodeFromString(this) | ||||
| 
 | ||||
|     private inline fun <reified T> Response.parseAs(): T = | ||||
|         body.string().parseAs() | ||||
| 
 | ||||
|     private fun SharedPreferences.getCdnUrl(): String { | ||||
|         return getString(cdnPref, fallbackCdnUrl) ?: fallbackCdnUrl | ||||
|     } | ||||
| 
 | ||||
|     private fun SharedPreferences.putCdnUrl(url: String) { | ||||
|         edit().putString(cdnPref, url).commit() | ||||
|     } | ||||
| 
 | ||||
|     companion object { | ||||
|         private const val fallbackCdnUrl = "https://f005.backblazeb2.com/b2api/v1/b2_download_file_by_id?fileId=" | ||||
|         private const val cdnPref = "cdn_pref" | ||||
|         val titleSpecialCharactersRegex = "[^a-z0-9]+".toRegex() | ||||
|         val trailingHyphenRegex = "-+$".toRegex() | ||||
|         val dateFormat by lazy { | ||||
|             SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.ENGLISH) | ||||
|         } | ||||
|         const val PREFIX_SLUG = "slug:" | ||||
|     } | ||||
| 
 | ||||
|     override fun searchMangaParse(response: Response) = | ||||
|         throw UnsupportedOperationException("Not Used") | ||||
| 
 | ||||
|     override fun imageUrlParse(response: Response) = | ||||
|         throw UnsupportedOperationException("Not Used") | ||||
| 
 | ||||
|     override fun latestUpdatesParse(response: Response) = | ||||
|         throw UnsupportedOperationException("Not Implemented") | ||||
| 
 | ||||
|     override fun latestUpdatesRequest(page: Int) = | ||||
|         throw UnsupportedOperationException("Not Implemented") | ||||
| } | ||||
| @ -0,0 +1,96 @@ | ||||
| package eu.kanade.tachiyomi.extension.en.disasterscans | ||||
| 
 | ||||
| import eu.kanade.tachiyomi.source.model.SChapter | ||||
| import eu.kanade.tachiyomi.source.model.SManga | ||||
| import kotlinx.serialization.Serializable | ||||
| import kotlinx.serialization.decodeFromString | ||||
| import kotlinx.serialization.json.Json | ||||
| 
 | ||||
| @Serializable | ||||
| data class ApiSearchComic( | ||||
|     val id: String, | ||||
|     val ComicTitle: String, | ||||
|     val CoverImage: String, | ||||
| ) { | ||||
|     fun toSManga(cdnUrl: String) = SManga.create().apply { | ||||
|         title = ComicTitle | ||||
|         thumbnail_url = "$cdnUrl$CoverImage#thumbnail" | ||||
|         url = "/comics/$id-${ComicTitle.titleToSlug()}" | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| @Serializable | ||||
| data class ApiComic( | ||||
|     val id: String, | ||||
|     val ComicTitle: String, | ||||
|     val Description: String, | ||||
|     val CoverImage: String, | ||||
|     val Status: String, | ||||
|     val Genres: String, | ||||
|     val Author: String, | ||||
|     val Artist: String, | ||||
| ) { | ||||
|     fun toSManga(json: Json, cdnUrl: String) = SManga.create().apply { | ||||
|         title = ComicTitle | ||||
|         thumbnail_url = "$cdnUrl$CoverImage#thumbnail" | ||||
|         url = "/comics/$id-${ComicTitle.titleToSlug()}" | ||||
|         description = Description | ||||
|         author = Author | ||||
|         artist = Artist | ||||
|         genre = json.decodeFromString<List<String>>(Genres).joinToString() | ||||
|         status = Status.parseStatus() | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| @Serializable | ||||
| data class ApiChapter( | ||||
|     val chapterID: Int, | ||||
|     val chapterNumber: String, | ||||
|     val ChapterName: String, | ||||
|     val chapterDate: String, | ||||
| ) { | ||||
|     fun toSChapter(mangaUrl: String) = SChapter.create().apply { | ||||
|         url = "$mangaUrl/$chapterID-chapter-$chapterNumber" | ||||
|         chapter_number = chapterNumber.toFloat() | ||||
|         name = "Chapter $chapterNumber" | ||||
|         if (ChapterName.isNotEmpty()) { | ||||
|             name += ": $ChapterName" | ||||
|         } | ||||
|         date_upload = chapterDate.parseDate() | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| @Serializable | ||||
| data class NextData<T>( | ||||
|     val props: Props<T>, | ||||
| ) { | ||||
|     @Serializable | ||||
|     data class Props<T>(val pageProps: T) | ||||
| } | ||||
| 
 | ||||
| @Serializable | ||||
| data class ApiChapterPages( | ||||
|     val chapter: ApiPages, | ||||
| ) { | ||||
|     @Serializable | ||||
|     data class ApiPages(val pages: String) | ||||
| } | ||||
| 
 | ||||
| private fun String.titleToSlug() = this.trim() | ||||
|     .lowercase() | ||||
|     .replace(DisasterScans.titleSpecialCharactersRegex, "-") | ||||
|     .replace(DisasterScans.trailingHyphenRegex, "") | ||||
| 
 | ||||
| private fun String.parseDate(): Long { | ||||
|     return runCatching { | ||||
|         DisasterScans.dateFormat.parse(this)!!.time | ||||
|     }.getOrDefault(0L) | ||||
| } | ||||
| 
 | ||||
| private fun String.parseStatus(): Int { | ||||
|     return when { | ||||
|         contains("ongoing", true) -> SManga.ONGOING | ||||
|         contains("completed", true) -> SManga.COMPLETED | ||||
|         else -> SManga.UNKNOWN | ||||
|     } | ||||
| } | ||||
| @ -0,0 +1,34 @@ | ||||
| package eu.kanade.tachiyomi.extension.en.disasterscans | ||||
| 
 | ||||
| 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 DisasterScansUrlActivity : Activity() { | ||||
|     override fun onCreate(savedInstanceState: Bundle?) { | ||||
|         super.onCreate(savedInstanceState) | ||||
|         val pathSegments = intent?.data?.pathSegments | ||||
|         if (pathSegments != null && pathSegments.size > 1) { | ||||
|             val slug = pathSegments[1] | ||||
|             val mainIntent = Intent().apply { | ||||
|                 action = "eu.kanade.tachiyomi.SEARCH" | ||||
|                 putExtra("query", "${DisasterScans.PREFIX_SLUG}$slug") | ||||
|                 putExtra("filter", packageName) | ||||
|             } | ||||
| 
 | ||||
|             try { | ||||
|                 startActivity(mainIntent) | ||||
|             } catch (e: ActivityNotFoundException) { | ||||
|                 Log.e("DisasterScansUrl", e.toString()) | ||||
|             } | ||||
|         } else { | ||||
|             Log.e("DisasterScansUrl", "could not parse uri from intent $intent") | ||||
|         } | ||||
| 
 | ||||
|         finish() | ||||
|         exitProcess(0) | ||||
|     } | ||||
| } | ||||
 AwkwardPeak7
						AwkwardPeak7