add a Darths & Droids extension (#4157)
* add Darths & Droids, though lacking support for one book * refactors Darths&Droids to auto-detect available books * fix April’s Fools pages, fix thumbnail assignment and cache * fix page links for the Solo book * apply improvements suggested in #4157 * add rate limiting, better document `/archive.html`, apply more fixes suggested in #4157
This commit is contained in:
parent
ae10943664
commit
f8a94f9717
|
@ -0,0 +1,7 @@
|
||||||
|
ext {
|
||||||
|
extName = 'Darths & Droids'
|
||||||
|
extClass = '.DarthsDroids'
|
||||||
|
extVersionCode = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
apply from: "$rootDir/common.gradle"
|
Binary file not shown.
After Width: | Height: | Size: 9.5 KiB |
Binary file not shown.
After Width: | Height: | Size: 4.5 KiB |
Binary file not shown.
After Width: | Height: | Size: 15 KiB |
Binary file not shown.
After Width: | Height: | Size: 31 KiB |
Binary file not shown.
After Width: | Height: | Size: 51 KiB |
|
@ -0,0 +1,247 @@
|
||||||
|
package eu.kanade.tachiyomi.extension.en.darthsdroids
|
||||||
|
|
||||||
|
import eu.kanade.tachiyomi.network.GET
|
||||||
|
import eu.kanade.tachiyomi.network.interceptor.rateLimitHost
|
||||||
|
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.model.UpdateStrategy
|
||||||
|
import eu.kanade.tachiyomi.source.online.HttpSource
|
||||||
|
import eu.kanade.tachiyomi.util.asJsoup
|
||||||
|
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.Response
|
||||||
|
import rx.Observable
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Locale
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
// Dear Darths & Droids creators:
|
||||||
|
// I’m sorry if this extension causes too much traffic for your site.
|
||||||
|
// Unfortunately we can’t just download and use your Zip downloads.
|
||||||
|
// Shall problems arise, we’ll reduce the rate limit.
|
||||||
|
class DarthsDroids : HttpSource() {
|
||||||
|
override val name = "Darths & Droids"
|
||||||
|
override val baseUrl = "https://www.darthsanddroids.net"
|
||||||
|
override val lang = "en"
|
||||||
|
override val supportsLatest = false
|
||||||
|
override val client = network.cloudflareClient.newBuilder()
|
||||||
|
.rateLimitHost(baseUrl.toHttpUrl(), 10, 1, TimeUnit.SECONDS)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
// Picks a thumbnail from the profile pictures of the »cast« pages:
|
||||||
|
// https://www.darthsanddroids.net/cast/
|
||||||
|
//
|
||||||
|
// Where possible, pick a thumbnail from the corresponding book’s
|
||||||
|
// cast page. Try to avoid having a character appear more than once
|
||||||
|
// as thumbnail, giving all main characters equal amounts of spotlight.
|
||||||
|
// Pick a character people would intuïtively associate with the
|
||||||
|
// corresponding film, like Qui-Gon for Phantom Menace or Leia for
|
||||||
|
// A New Hope.
|
||||||
|
//
|
||||||
|
// If a book doesn’t have its own cast page, try source a fitting
|
||||||
|
// profile picture from a different page. Avoid sourcing thumbnails
|
||||||
|
// from a different website.
|
||||||
|
private fun dndThumbnailUrlForTitle(nthManga: Int): String = when (nthManga) {
|
||||||
|
// The numbers are assigned in order of appearance of a book on the archive page.
|
||||||
|
0 -> "$baseUrl/cast/QuiGon.jpg" // D&D1
|
||||||
|
1 -> "$baseUrl/cast/Anakin2.jpg" // D&D2
|
||||||
|
2 -> "$baseUrl/cast/ObiWan3.jpg" // D&D3
|
||||||
|
3 -> "$baseUrl/cast/JarJar2.jpg" // JJ
|
||||||
|
4 -> "$baseUrl/cast/Leia4.jpg" // D&D4
|
||||||
|
5 -> "$baseUrl/cast/Han5.jpg" // D&D5
|
||||||
|
6 -> "$baseUrl/cast/Luke6.jpg" // D&D6
|
||||||
|
7 -> "$baseUrl/cast/Cassian.jpg" // R1
|
||||||
|
8 -> "$baseUrl/cast/C3PO4.jpg" // Muppets
|
||||||
|
9 -> "$baseUrl/cast/Finn7.jpg" // D&D7
|
||||||
|
10 -> "$baseUrl/cast/Han4.jpg" // Solo
|
||||||
|
11 -> "$baseUrl/cast/Hux8.jpg" // D&D8
|
||||||
|
// Just some nonsense fallback that screams »Star Wars« but is also so recognisably
|
||||||
|
// OT that one can understand it’s a mere fallback. Better thumbnails require an
|
||||||
|
// extension update.
|
||||||
|
else -> "$baseUrl/cast/Vader4.jpg"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun dndManga(archiveUrl: String, mangaTitle: String, mangaStatus: Int, nthManga: Int): SManga = SManga.create().apply {
|
||||||
|
setUrlWithoutDomain(archiveUrl)
|
||||||
|
thumbnail_url = dndThumbnailUrlForTitle(nthManga)
|
||||||
|
title = mangaTitle
|
||||||
|
author = "David Morgan-Mar & Co."
|
||||||
|
artist = "David Morgan-Mar & Co."
|
||||||
|
description = """What if Star Wars as we know it didn't exist, but instead the
|
||||||
|
|plot of the movies was being made up on the spot by players of
|
||||||
|
|a Tabletop Game?
|
||||||
|
|
|
||||||
|
|Well, for one, the results might actually make a lot more sense,
|
||||||
|
|from an out-of-story point of view…
|
||||||
|
""".trimMargin()
|
||||||
|
genre = "Campaign Comic, Comedy, Space Opera, Science Fiction"
|
||||||
|
status = mangaStatus
|
||||||
|
update_strategy = when (mangaStatus) {
|
||||||
|
SManga.COMPLETED -> UpdateStrategy.ONLY_FETCH_ONCE
|
||||||
|
else -> UpdateStrategy.ALWAYS_UPDATE
|
||||||
|
}
|
||||||
|
initialized = true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun popularMangaRequest(page: Int): Request =
|
||||||
|
GET("$baseUrl/archive.html", headers)
|
||||||
|
|
||||||
|
// The book and page archive feeds are rather special for this webcomic.
|
||||||
|
// The main archive page `/archive.html` is a combined feed for both,
|
||||||
|
// all previous and finished books, as well as all pages of the book that
|
||||||
|
// is currently releasing. Every finished book gets its own archive page
|
||||||
|
// like `/archive4.html` or `/archiveJJ.html` into which all page links
|
||||||
|
// are moved. So whatever book is currently releasing in `/archive.html`
|
||||||
|
// will eventually be moved into its own archive, and it’ll instead
|
||||||
|
// appear as a book-archive link in `/archive.html`.
|
||||||
|
//
|
||||||
|
// This means a few things:
|
||||||
|
// • The currently releasing book eventually changes its `url`!
|
||||||
|
// • The URL of the currently releasing book will be taken over by
|
||||||
|
// whichever new book comes next.
|
||||||
|
// • There is no deterministic way of guessing a book’s future
|
||||||
|
// archive name.
|
||||||
|
// ◦ This is especially apparent with the »Solo« book, which’s
|
||||||
|
// archive page is `/solo/`, while all others are `/archiveX.html`.
|
||||||
|
//
|
||||||
|
// So eventually, Tachiyomi & Co. will glitch out once a currently
|
||||||
|
// releasing book finishes. People will find the current book’s page
|
||||||
|
// feed to be empty. Even worse, they may find it starting anew with
|
||||||
|
// different pages. A manual refresh *should* change the book’s `url`
|
||||||
|
// to its new archive page, and all reading progress should be preserved.
|
||||||
|
// Then the user will have to manually add the new book to their library.
|
||||||
|
//
|
||||||
|
// The alternative would be to have a pseudo book »<Title> (ongoing)«
|
||||||
|
// that just disappears, being replaced by »<Title>«. But i think that’s
|
||||||
|
// even worse in terms of user experience. Maybe one day we’ll have new
|
||||||
|
// extension APIs for dealing with unique webcomic weirdnesses. ’cause
|
||||||
|
// trust me, there’s worse.
|
||||||
|
override fun popularMangaParse(response: Response): MangasPage {
|
||||||
|
val mainArchive = response.asJsoup()
|
||||||
|
val archiveData = mainArchive.select("div.text > table.text > tbody > tr")
|
||||||
|
|
||||||
|
val mangas = mutableListOf<SManga>()
|
||||||
|
var nextMangaTitle = name
|
||||||
|
var nthManga = 0
|
||||||
|
|
||||||
|
run stop@{
|
||||||
|
archiveData.forEach {
|
||||||
|
val maybeTitle = it.selectFirst("th")?.text()
|
||||||
|
if (maybeTitle != null) {
|
||||||
|
nextMangaTitle = "$name $maybeTitle"
|
||||||
|
} else {
|
||||||
|
val maybeArchive = it.selectFirst("""td[colspan="3"] > a""")?.absUrl("href")
|
||||||
|
if (maybeArchive != null) {
|
||||||
|
mangas.add(dndManga(maybeArchive, nextMangaTitle, SManga.COMPLETED, nthManga++))
|
||||||
|
} else {
|
||||||
|
// We reached the end, assuming the page layout stays consistent beyond D&D8.
|
||||||
|
// Thus, we append our final manga with this current page as its archive.
|
||||||
|
// Unfortunately this means we will needlessly fetch this page twice.
|
||||||
|
mangas.add(dndManga("/archive.html", nextMangaTitle, SManga.ONGOING, nthManga))
|
||||||
|
return@stop
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return MangasPage(mangas, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not efficient, but the simplest way for me to refresh.
|
||||||
|
// We also can’t really use the `mangaDetailsRequest + mangaDetailsParse`
|
||||||
|
// approach, for we actually expect one of the books’ `url`s to change.
|
||||||
|
override fun fetchMangaDetails(manga: SManga): Observable<SManga> =
|
||||||
|
fetchPopularManga(0)
|
||||||
|
.map { mangasPage ->
|
||||||
|
mangasPage
|
||||||
|
.mangas
|
||||||
|
// Do not test for URL-equality, for the last book will always
|
||||||
|
// eventually migrate its archive page from `/archive.html` to
|
||||||
|
// its own page.
|
||||||
|
.first { it.title == manga.title }
|
||||||
|
}
|
||||||
|
|
||||||
|
// This implementation here is needlessly complicated, for it has to automatically detect
|
||||||
|
// whether we’re in a date-annotated archive, the main archive, or a dateless archive.
|
||||||
|
// All three are largely similar, there are just *some* (annoying) differences we have to
|
||||||
|
// deal with.
|
||||||
|
override fun chapterListParse(response: Response): List<SChapter> {
|
||||||
|
val archivePages = response.asJsoup()
|
||||||
|
|
||||||
|
// For books where all pages released the same day, there is no page date column,
|
||||||
|
// so instead we grab the release date of the archive page itself from its footer.
|
||||||
|
val pageDate = archivePages
|
||||||
|
.select("""br + i""")
|
||||||
|
.mapNotNull { EXTR_PAGE_DATE.find(it.text())?.groupValues?.getOrNull(1) }
|
||||||
|
.map { PAGE_DATE_FMT.parse(it)?.time }
|
||||||
|
.firstOrNull()
|
||||||
|
?: 0L
|
||||||
|
var i = 0
|
||||||
|
|
||||||
|
return archivePages
|
||||||
|
.select("""div.text > table.text > tbody > tr""")
|
||||||
|
.mapNotNull {
|
||||||
|
val pageData = it.select("""td""")
|
||||||
|
var pageAnchor = pageData.getOrNull(2)?.selectFirst("a")
|
||||||
|
// null for »Intermission«, main archive, dateless archive,…
|
||||||
|
if (pageAnchor != null) {
|
||||||
|
SChapter.create().apply {
|
||||||
|
name = pageAnchor!!.text()
|
||||||
|
chapter_number = (i++).toFloat()
|
||||||
|
date_upload = runCatching {
|
||||||
|
DATE_FMT.parse(pageData[0].text())!!.time
|
||||||
|
}.getOrDefault(0L)
|
||||||
|
setUrlWithoutDomain(pageAnchor!!.absUrl("href"))
|
||||||
|
}
|
||||||
|
} else if (!pageData.hasAttr("colspan")) {
|
||||||
|
// Are we in a dateless archive?
|
||||||
|
pageAnchor = pageData.getOrNull(0)?.selectFirst("a")
|
||||||
|
if (pageAnchor != null) {
|
||||||
|
SChapter.create().apply {
|
||||||
|
name = pageAnchor.text()
|
||||||
|
chapter_number = (i++).toFloat()
|
||||||
|
date_upload = pageDate
|
||||||
|
setUrlWithoutDomain(pageAnchor.absUrl("href"))
|
||||||
|
}
|
||||||
|
} else { null }
|
||||||
|
} else { null }
|
||||||
|
}
|
||||||
|
.reversed()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun pageListParse(response: Response): List<Page> =
|
||||||
|
// Careful. For almost all images it’s `div.center>p>img`, except for pages released on
|
||||||
|
// April’s Fools day, when it’s `div.center>p>a>img`. We could still add the `p` in
|
||||||
|
// between, but it was decided to leave it out, in case yet another *almost* same
|
||||||
|
// page layout pops up in the future.
|
||||||
|
//
|
||||||
|
// For example, this episode was released during April’s Fools day.
|
||||||
|
// https://www.darthsanddroids.net/episodes/0082.html
|
||||||
|
response
|
||||||
|
.asJsoup()
|
||||||
|
.select("""div.center img""")
|
||||||
|
.mapIndexed { i, img ->
|
||||||
|
Page(
|
||||||
|
index = i,
|
||||||
|
imageUrl = img.absUrl("src"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun mangaDetailsParse(response: Response): SManga = throw UnsupportedOperationException()
|
||||||
|
override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> = throw UnsupportedOperationException()
|
||||||
|
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request = throw UnsupportedOperationException()
|
||||||
|
override fun searchMangaParse(response: Response): MangasPage = throw UnsupportedOperationException()
|
||||||
|
override fun latestUpdatesRequest(page: Int): Request = throw UnsupportedOperationException()
|
||||||
|
override fun latestUpdatesParse(response: Response): MangasPage = throw UnsupportedOperationException()
|
||||||
|
override fun imageUrlRequest(page: Page): Request = throw UnsupportedOperationException()
|
||||||
|
override fun imageUrlParse(response: Response): String = throw UnsupportedOperationException()
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val DATE_FMT = SimpleDateFormat("EEE d MMM, yyyy", Locale.US)
|
||||||
|
private val EXTR_PAGE_DATE = """Published\:\s+(\w+,\s+\d+\s+\w+,\s+\d+\;\s+\d+\:\d+\:\d+\s+\w+)""".toRegex()
|
||||||
|
private val PAGE_DATE_FMT = SimpleDateFormat("EEEEE, d MMMMM, yyyy; HH:mm:ss zzz", Locale.US)
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue