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:
Evrey 2024-07-25 17:50:34 +02:00 committed by Draff
parent ae10943664
commit f8a94f9717
No known key found for this signature in database
GPG Key ID: E8A89F3211677653
7 changed files with 254 additions and 0 deletions

View File

@ -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

View File

@ -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:
// Im sorry if this extension causes too much traffic for your site.
// Unfortunately we cant just download and use your Zip downloads.
// Shall problems arise, well 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 books
// 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 doesnt 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 its 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 itll 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 books future
// archive name.
// ◦ This is especially apparent with the »Solo« book, whichs
// 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 books page
// feed to be empty. Even worse, they may find it starting anew with
// different pages. A manual refresh *should* change the books `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 thats
// even worse in terms of user experience. Maybe one day well have new
// extension APIs for dealing with unique webcomic weirdnesses. cause
// trust me, theres 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 cant 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 were 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 its `div.center>p>img`, except for pages released on
// Aprils Fools day, when its `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 Aprils 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)
}
}