Compare commits
10 Commits
2f5441ef20
...
a8f9423307
| Author | SHA1 | Date | |
|---|---|---|---|
|
a8f9423307
|
|||
| 7c871edbca | |||
|
|
f7d9579cee | ||
|
|
b7e6ca8a26 | ||
|
8852d1e22e
|
|||
|
fcaa0ea442
|
|||
|
8e7d7e0bde
|
|||
|
|
50c6c9f842 | ||
|
a2979753a9
|
|||
|
789e39b6cb
|
@@ -10,9 +10,13 @@ class MangaChapter(
|
||||
*/
|
||||
@JvmField val name: String,
|
||||
/**
|
||||
* Chapter number starting from 1
|
||||
* Chapter number starting from 1, 0 if unknown
|
||||
*/
|
||||
@JvmField val number: Int,
|
||||
@JvmField val number: Float,
|
||||
/**
|
||||
* Volume number starting from 1, 0 if unknown
|
||||
*/
|
||||
@JvmField val volume: Int,
|
||||
/**
|
||||
* Relative url to chapter (**without** a domain) or any other uri.
|
||||
* Used principally in parsers
|
||||
@@ -32,11 +36,29 @@ class MangaChapter(
|
||||
*/
|
||||
@JvmField val branch: String?,
|
||||
@JvmField val source: MangaSource,
|
||||
) : Comparable<MangaChapter> {
|
||||
) {
|
||||
|
||||
override fun compareTo(other: MangaChapter): Int {
|
||||
return number.compareTo(other.number)
|
||||
}
|
||||
@Deprecated(message = "Consider using constructor with volume value")
|
||||
constructor(
|
||||
id: Long,
|
||||
name: String,
|
||||
number: Int,
|
||||
url: String,
|
||||
scanlator: String?,
|
||||
uploadDate: Long,
|
||||
branch: String?,
|
||||
source: MangaSource,
|
||||
) : this(
|
||||
id = id,
|
||||
name = name,
|
||||
number = number.toFloat(),
|
||||
volume = 0,
|
||||
url = url,
|
||||
scanlator = scanlator,
|
||||
uploadDate = uploadDate,
|
||||
branch = branch,
|
||||
source = source,
|
||||
)
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
@@ -47,6 +69,7 @@ class MangaChapter(
|
||||
if (id != other.id) return false
|
||||
if (name != other.name) return false
|
||||
if (number != other.number) return false
|
||||
if (volume != other.volume) return false
|
||||
if (url != other.url) return false
|
||||
if (scanlator != other.scanlator) return false
|
||||
if (uploadDate != other.uploadDate) return false
|
||||
@@ -59,7 +82,8 @@ class MangaChapter(
|
||||
override fun hashCode(): Int {
|
||||
var result = id.hashCode()
|
||||
result = 31 * result + name.hashCode()
|
||||
result = 31 * result + number
|
||||
result = 31 * result + number.hashCode()
|
||||
result = 31 * result + volume
|
||||
result = 31 * result + url.hashCode()
|
||||
result = 31 * result + (scanlator?.hashCode() ?: 0)
|
||||
result = 31 * result + uploadDate.hashCode()
|
||||
@@ -71,4 +95,16 @@ class MangaChapter(
|
||||
override fun toString(): String {
|
||||
return "MangaChapter($id - #$number [$url] - $source)"
|
||||
}
|
||||
|
||||
internal fun copy(volume: Int, number: Float) = MangaChapter(
|
||||
id = id,
|
||||
name = name,
|
||||
number = number,
|
||||
volume = volume,
|
||||
url = url,
|
||||
scanlator = scanlator,
|
||||
uploadDate = uploadDate,
|
||||
branch = branch,
|
||||
source = source,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -417,6 +417,4 @@ internal class ExHentaiParser(
|
||||
}
|
||||
return joiner.complete().takeUnless { it.isEmpty() }
|
||||
}
|
||||
|
||||
private fun String.isNumeric() = all { c -> c.isDigit() }
|
||||
}
|
||||
|
||||
@@ -1,125 +0,0 @@
|
||||
package org.koitharu.kotatsu.parsers.site.foolslide.en
|
||||
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import org.jsoup.nodes.Document
|
||||
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
||||
import org.koitharu.kotatsu.parsers.MangaSourceParser
|
||||
import org.koitharu.kotatsu.parsers.model.*
|
||||
import org.koitharu.kotatsu.parsers.site.foolslide.FoolSlideParser
|
||||
import org.koitharu.kotatsu.parsers.util.*
|
||||
|
||||
@MangaSourceParser("ASSORTEDSCANS", "AssortedScans", "en")
|
||||
internal class AssortedScans(context: MangaLoaderContext) :
|
||||
FoolSlideParser(context, MangaSource.ASSORTEDSCANS, "assortedscans.com", 56) {
|
||||
|
||||
override val listUrl = "reader/"
|
||||
override val pagination = false
|
||||
override val selectInfo = "div.#series-info"
|
||||
|
||||
override suspend fun getListPage(page: Int, filter: MangaListFilter?): List<Manga> {
|
||||
if (page > 1) {
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
val url = buildString {
|
||||
append("https://")
|
||||
append(domain)
|
||||
append('/')
|
||||
when (filter) {
|
||||
is MangaListFilter.Search -> {
|
||||
append(searchUrl)
|
||||
append("?q=")
|
||||
append(filter.query.urlEncoded())
|
||||
}
|
||||
|
||||
is MangaListFilter.Advanced -> {
|
||||
append(listUrl)
|
||||
}
|
||||
|
||||
null -> append(listUrl)
|
||||
}
|
||||
}
|
||||
val doc = webClient.httpGet(url).parseHtml()
|
||||
return doc.select("section.series, tr.result").map { div ->
|
||||
val href = div.selectFirstOrThrow("a").attrAsRelativeUrl("href")
|
||||
Manga(
|
||||
id = generateUid(href),
|
||||
url = href,
|
||||
publicUrl = href.toAbsoluteUrl(div.host ?: domain),
|
||||
coverUrl = div.selectFirst("img")?.src().orEmpty(),// in search no img
|
||||
title = div.selectFirstOrThrow("a").text().orEmpty(),
|
||||
altTitle = null,
|
||||
rating = RATING_UNKNOWN,
|
||||
tags = emptySet(),
|
||||
author = null,
|
||||
state = null,
|
||||
source = source,
|
||||
isNsfw = isNsfwSource,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getDetails(manga: Manga): Manga = coroutineScope {
|
||||
val fullUrl = manga.url.toAbsoluteUrl(domain)
|
||||
val testAdultPage = webClient.httpGet(fullUrl).parseHtml()
|
||||
val doc = if (testAdultPage.selectFirst("div.info form") != null) {
|
||||
webClient.httpPost(fullUrl, "adult=true").parseHtml()
|
||||
} else {
|
||||
testAdultPage
|
||||
}
|
||||
val chapters = getChapters(doc)
|
||||
val desc = doc.getElementById("series-desc")?.selectFirst("div")?.html()
|
||||
val alt = doc.getElementById("series-aliases")?.selectFirst("div.alias")?.text()
|
||||
val author = doc.getElementById("series-authors")?.selectFirst("div.author")?.text()
|
||||
val state = doc.getElementById("series-status")?.selectFirst("span")?.text()
|
||||
manga.copy(
|
||||
coverUrl = doc.selectFirst(".cover")?.src() ?: manga.coverUrl,
|
||||
description = desc,
|
||||
altTitle = alt,
|
||||
author = author,
|
||||
state = when (state) {
|
||||
"Ongoing" -> MangaState.ONGOING
|
||||
"Completed" -> MangaState.FINISHED
|
||||
"Canceled" -> MangaState.ABANDONED
|
||||
else -> null
|
||||
},
|
||||
chapters = chapters,
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun getChapters(doc: Document): List<MangaChapter> {
|
||||
return doc.body().select("div.chapter").mapChapters(reversed = true) { i, div ->
|
||||
val a = div.selectFirstOrThrow("a")
|
||||
val href = a.attrAsRelativeUrl("href")
|
||||
MangaChapter(
|
||||
id = generateUid(href),
|
||||
name = a.text(),
|
||||
number = i + 1,
|
||||
url = href,
|
||||
uploadDate = 0,
|
||||
source = source,
|
||||
scanlator = null,
|
||||
branch = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
|
||||
val doc = webClient.httpGet(chapter.url.toAbsoluteUrl(domain)).parseHtml()
|
||||
return doc.body().select(".page-list .dropdown-list li a").map { a ->
|
||||
val url = a.attr("href").toRelativeUrl(domain)
|
||||
MangaPage(
|
||||
id = generateUid(url),
|
||||
url = url,
|
||||
preview = null,
|
||||
source = source,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getPageUrl(page: MangaPage): String {
|
||||
val doc = webClient.httpGet(page.url.toAbsoluteUrl(domain)).parseHtml()
|
||||
val root = doc.body()
|
||||
return root.requireElementById("page-image").attr("src") ?: doc.parseFailed("Page image not found")
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import java.util.*
|
||||
|
||||
@MangaSourceParser("SHINIGAMI", "Shinigami", "id")
|
||||
internal class Shinigami(context: MangaLoaderContext) :
|
||||
MadaraParser(context, MangaSource.SHINIGAMI, "shinigami.moe", 10) {
|
||||
MadaraParser(context, MangaSource.SHINIGAMI, "shinigamitoon.com", 10) {
|
||||
|
||||
private val userAgentKey = ConfigKey.UserAgent(UserAgents.CHROME_MOBILE)
|
||||
override val tagPrefix = "genre/"
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
package org.koitharu.kotatsu.parsers.site.madara.pt
|
||||
|
||||
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
||||
import org.koitharu.kotatsu.parsers.MangaSourceParser
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.site.madara.MadaraParser
|
||||
|
||||
@MangaSourceParser("BORUTOEXPLORER", "BorutoExplorer", "pt")
|
||||
internal class BorutoExplorer(context: MangaLoaderContext) :
|
||||
MadaraParser(context, MangaSource.BORUTOEXPLORER, "leitor.borutoexplorer.com.br", 10) {
|
||||
override val datePattern: String = "dd 'de' MMMMM 'de' yyyy"
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package org.koitharu.kotatsu.parsers.site.madara.pt
|
||||
|
||||
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
||||
import org.koitharu.kotatsu.parsers.MangaSourceParser
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.site.madara.MadaraParser
|
||||
|
||||
@MangaSourceParser("NEROXUS", "Neroxus", "pt")
|
||||
internal class Neroxus(context: MangaLoaderContext) :
|
||||
MadaraParser(context, MangaSource.NEROXUS, "neroxus.com.br", 10) {
|
||||
override val datePattern = "MMM d, yyyy"
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
package org.koitharu.kotatsu.parsers.site.mangadventure
|
||||
|
||||
import okhttp3.Headers
|
||||
import okhttp3.HttpUrl
|
||||
import org.json.JSONObject
|
||||
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
||||
import org.koitharu.kotatsu.parsers.PagedMangaParser
|
||||
import org.koitharu.kotatsu.parsers.config.ConfigKey
|
||||
import org.koitharu.kotatsu.parsers.exception.NotFoundException
|
||||
import org.koitharu.kotatsu.parsers.model.*
|
||||
import org.koitharu.kotatsu.parsers.network.UserAgents
|
||||
import org.koitharu.kotatsu.parsers.util.*
|
||||
import org.koitharu.kotatsu.parsers.util.json.*
|
||||
import java.util.*
|
||||
|
||||
internal abstract class MangAdventureParser(
|
||||
context: MangaLoaderContext,
|
||||
source: MangaSource,
|
||||
domain: String,
|
||||
pageSize: Int = 25,
|
||||
) : PagedMangaParser(context, source, pageSize) {
|
||||
override val configKeyDomain = ConfigKey.Domain(domain)
|
||||
|
||||
override val headers: Headers =
|
||||
Headers.Builder().add("User-Agent", UserAgents.KOTATSU).build()
|
||||
|
||||
override val availableStates: Set<MangaState> = EnumSet.of(
|
||||
MangaState.ONGOING,
|
||||
MangaState.FINISHED,
|
||||
MangaState.ABANDONED,
|
||||
MangaState.PAUSED,
|
||||
)
|
||||
|
||||
override val availableContentRating: Set<ContentRating> =
|
||||
EnumSet.of(ContentRating.SAFE)
|
||||
|
||||
override val availableSortOrders: Set<SortOrder> = EnumSet.of(
|
||||
SortOrder.ALPHABETICAL,
|
||||
SortOrder.ALPHABETICAL_DESC,
|
||||
SortOrder.UPDATED,
|
||||
SortOrder.POPULARITY,
|
||||
)
|
||||
|
||||
override val defaultSortOrder = SortOrder.ALPHABETICAL
|
||||
|
||||
override val isTagsExclusionSupported = true
|
||||
|
||||
override suspend fun getListPage(page: Int, filter: MangaListFilter?): List<Manga> {
|
||||
val url = apiUrl.addEncodedPathSegment("series")
|
||||
.addEncodedQueryParameter("limit", pageSize.toString())
|
||||
.addEncodedQueryParameter("page", page.toString())
|
||||
when (filter) {
|
||||
is MangaListFilter.Search -> {
|
||||
url.addQueryParameter("title", filter.query)
|
||||
}
|
||||
|
||||
is MangaListFilter.Advanced -> {
|
||||
url.addQueryParameter(
|
||||
"categories",
|
||||
buildString {
|
||||
if (filter.tags.isNotEmpty() && filter.tagsExclude.isNotEmpty()) {
|
||||
filter.tags.joinTo(this, ",", postfix = ",") { it.key }
|
||||
filter.tagsExclude.joinTo(this, ",") { "-" + it.key }
|
||||
} else if (filter.tags.isNotEmpty()) {
|
||||
filter.tags.joinTo(this, ",") { it.key }
|
||||
} else if (filter.tagsExclude.isNotEmpty()) {
|
||||
filter.tagsExclude.joinTo(this, ",") { "-" + it.key }
|
||||
}
|
||||
},
|
||||
)
|
||||
when (filter.states.oneOrThrowIfMany()) {
|
||||
null -> url.addEncodedQueryParameter("status", "any")
|
||||
MangaState.ONGOING -> url.addEncodedQueryParameter("status", "ongoing")
|
||||
MangaState.FINISHED -> url.addEncodedQueryParameter("status", "completed")
|
||||
MangaState.ABANDONED -> url.addEncodedQueryParameter("status", "canceled")
|
||||
MangaState.PAUSED -> url.addEncodedQueryParameter("status", "hiatus")
|
||||
else -> throw IllegalArgumentException(ERROR_UNSUPPORTED_STATE)
|
||||
}
|
||||
when (filter.sortOrder) {
|
||||
SortOrder.ALPHABETICAL -> url.addEncodedQueryParameter("sort", "title")
|
||||
SortOrder.ALPHABETICAL_DESC -> url.addEncodedQueryParameter("sort", "-title")
|
||||
SortOrder.UPDATED -> url.addEncodedQueryParameter("sort", "-latest_upload")
|
||||
SortOrder.POPULARITY -> url.addEncodedQueryParameter("sort", "-views")
|
||||
else -> throw IllegalArgumentException(ERROR_UNSUPPORTED_SORT_ORDER)
|
||||
}
|
||||
}
|
||||
|
||||
else -> {}
|
||||
}
|
||||
return runCatchingCancellable { getManga(url.get()) }.getOrElse {
|
||||
if (it is NotFoundException) emptyList() else throw it
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getDetails(manga: Manga): Manga {
|
||||
val url = apiUrl.addEncodedPathSegment("series").addPathSegment(manga.slug)
|
||||
val details = requireNotNull(url.get())
|
||||
val chapters = url.addEncodedPathSegment("chapters")
|
||||
.addEncodedQueryParameter("date_format", "timestamp").get()
|
||||
return manga.copy(
|
||||
description = details.getStringOrNull("description"),
|
||||
altTitle = details.getJSONArray("aliases").joinToString(),
|
||||
author = buildString {
|
||||
val authors = details.getJSONArray("authors")
|
||||
val artists = details.getJSONArray("artists")
|
||||
if (authors.length() > 0 && artists.length() > 0) {
|
||||
authors.joinTo(this, postfix = ", ")
|
||||
artists.joinTo(this)
|
||||
} else if (authors.length() > 0) {
|
||||
authors.joinTo(this)
|
||||
} else if (artists.length() > 0) {
|
||||
artists.joinTo(this)
|
||||
}
|
||||
},
|
||||
tags = details.getJSONArray("categories").mapTo(HashSet()) {
|
||||
val name = it as String
|
||||
MangaTag(name, name, source)
|
||||
},
|
||||
state = when (details.getString("status")) {
|
||||
"ongoing" -> MangaState.ONGOING
|
||||
"completed" -> MangaState.FINISHED
|
||||
"canceled" -> MangaState.ABANDONED
|
||||
"hiatus" -> MangaState.PAUSED
|
||||
else -> null
|
||||
},
|
||||
chapters = chapters?.optJSONArray("results")?.toJSONList()?.mapChapters { _, it ->
|
||||
MangaChapter(
|
||||
id = generateUid(it.getLong("id")),
|
||||
name = it.getString("full_title"),
|
||||
number = it.getFloatOrDefault("number", 0f),
|
||||
volume = it.getIntOrDefault("volume", 0),
|
||||
url = it.getString("url"),
|
||||
scanlator = it.getJSONArray("groups").joinToString(),
|
||||
uploadDate = it.getString("published").toLong(),
|
||||
branch = null,
|
||||
source = source,
|
||||
)
|
||||
}.orEmpty(),
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
|
||||
val url = apiUrl.addEncodedPathSegment("chapters")
|
||||
.addEncodedPathSegment(chapter.id.toString())
|
||||
.addEncodedPathSegment("pages")
|
||||
.addEncodedQueryParameter("track", "true")
|
||||
return url.get()?.optJSONArray("results")?.mapJSON {
|
||||
MangaPage(
|
||||
id = generateUid(it.getLong("id")),
|
||||
url = it.getString("image"),
|
||||
preview = null,
|
||||
source = source,
|
||||
)
|
||||
} ?: emptyList()
|
||||
}
|
||||
|
||||
override suspend fun getPageUrl(page: MangaPage) = page.url
|
||||
|
||||
override suspend fun getAvailableTags(): Set<MangaTag> {
|
||||
val url = apiUrl.addEncodedPathSegment("categories")
|
||||
return url.get()?.optJSONArray("results")?.mapJSONToSet {
|
||||
val name = it.getString("name")
|
||||
MangaTag(name, name, source)
|
||||
} ?: emptySet()
|
||||
}
|
||||
|
||||
// webp favicons are not supported
|
||||
override suspend fun getFavicons() =
|
||||
Favicons(listOf(Favicon("https://$domain/media/logo.png", 512, "")), domain)
|
||||
|
||||
/* Get a list of manga from the given [JSONObject]. */
|
||||
protected fun getManga(json: JSONObject?): List<Manga> {
|
||||
return json?.optJSONArray("results")?.mapJSONNotNull {
|
||||
// exclude licensed series
|
||||
if (it.opt("chapters") == JSONObject.NULL)
|
||||
return@mapJSONNotNull null
|
||||
val path = it.getString("url")
|
||||
val publicUrl = urlBuilder().addEncodedPathSegments(path).toString()
|
||||
Manga(
|
||||
id = generateUid(it.getString("slug")),
|
||||
title = it.getString("title"),
|
||||
altTitle = null,
|
||||
url = path,
|
||||
publicUrl = publicUrl,
|
||||
rating = RATING_UNKNOWN,
|
||||
isNsfw = false,
|
||||
coverUrl = it.getString("cover"),
|
||||
tags = emptySet(),
|
||||
state = null,
|
||||
author = null,
|
||||
source = source,
|
||||
)
|
||||
} ?: emptyList()
|
||||
}
|
||||
|
||||
protected val apiUrl: HttpUrl.Builder
|
||||
get() = urlBuilder().addEncodedPathSegments("api/v2")
|
||||
|
||||
// /reader/{slug}/
|
||||
private val Manga.slug: String
|
||||
get() = url.substring(8, url.length - 1)
|
||||
|
||||
protected suspend fun HttpUrl.Builder.get() =
|
||||
webClient.httpGet(build()).body?.string()?.let(::JSONObject)
|
||||
|
||||
private companion object {
|
||||
private const val ERROR_UNSUPPORTED_STATE =
|
||||
"The selected state is not supported by this source"
|
||||
|
||||
private const val ERROR_UNSUPPORTED_SORT_ORDER =
|
||||
"The selected sort order is not supported by this source"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package org.koitharu.kotatsu.parsers.site.mangadventure.en
|
||||
|
||||
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
||||
import org.koitharu.kotatsu.parsers.MangaSourceParser
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.site.mangadventure.MangAdventureParser
|
||||
import org.koitharu.kotatsu.parsers.util.urlEncoded
|
||||
|
||||
@MangaSourceParser("ARCRELIGHT", "Arc-Relight", "en")
|
||||
internal class ArcRelight(context: MangaLoaderContext) :
|
||||
MangAdventureParser(context, MangaSource.ARCRELIGHT, "arc-relight.com", pageSize = 10) {
|
||||
private val franchises = setOf("Jubilee", "Steins;Gate", "Robotics;Notes", "Chaos;Head")
|
||||
|
||||
override suspend fun getRelatedManga(seed: Manga): List<Manga> {
|
||||
val tags = franchises.filter { franchise ->
|
||||
seed.tags.find { it.key == franchise } != null
|
||||
}
|
||||
if (tags.isEmpty()) return emptyList()
|
||||
val url = apiUrl.addEncodedPathSegment("series")
|
||||
.addQueryParameter("categories", tags.joinToString(","))
|
||||
return getManga(url.get())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package org.koitharu.kotatsu.parsers.site.mangadventure.en
|
||||
|
||||
import androidx.collection.ArraySet
|
||||
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
||||
import org.koitharu.kotatsu.parsers.MangaSourceParser
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.model.MangaTag
|
||||
import org.koitharu.kotatsu.parsers.site.mangadventure.MangAdventureParser
|
||||
|
||||
@MangaSourceParser("ASSORTEDSCANS", "Assorted Scans", "en")
|
||||
internal class AssortedScans(context: MangaLoaderContext) :
|
||||
MangAdventureParser(context, MangaSource.ASSORTEDSCANS, "assortedscans.com") {
|
||||
// tags that don't have any series and make the tests fail
|
||||
private val emptyTags = setOf(
|
||||
"Doujinshi", "Harem", "Hentai", "Mecha",
|
||||
"Shoujo Ai", "Shounen Ai", "Smut", "Yaoi",
|
||||
)
|
||||
|
||||
override suspend fun getAvailableTags(): Set<MangaTag> {
|
||||
val tags = super.getAvailableTags()
|
||||
return tags.filterNotTo(ArraySet(tags.size)) { it.key in emptyTags }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package org.koitharu.kotatsu.parsers.site.mangareader.pt
|
||||
|
||||
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
||||
import org.koitharu.kotatsu.parsers.MangaSourceParser
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.site.mangareader.MangaReaderParser
|
||||
|
||||
@MangaSourceParser("FRANXXMANGAS", "FranxxMangas", "pt")
|
||||
internal class FranxxMangas(context: MangaLoaderContext) :
|
||||
MangaReaderParser(context, MangaSource.FRANXXMANGAS, "franxxmangas.net", pageSize = 10, searchPageSize = 10)
|
||||
@@ -271,7 +271,8 @@ internal class RemangaParser(
|
||||
}
|
||||
|
||||
private fun parsePage(jo: JSONObject) = MangaPage(
|
||||
id = generateUid(jo.getLong("id")),
|
||||
// id = generateUid(jo.getLong("id")), 19.01.2024 page id is gone
|
||||
id = generateUid(jo.getString("link")),
|
||||
url = jo.getString("link"),
|
||||
preview = null,
|
||||
source = source,
|
||||
|
||||
@@ -155,28 +155,13 @@ internal abstract class GroupleParser(
|
||||
val scripts = doc.select("script")
|
||||
for (script in scripts) {
|
||||
val data = script.html()
|
||||
val pos = data.indexOf("rm_h.readerInit( 0,")
|
||||
if (pos == -1) {
|
||||
continue
|
||||
var pos = data.indexOf("rm_h.readerDoInit(")
|
||||
if (pos != -1) {
|
||||
parsePagesNew(data, pos)?.let { return it }
|
||||
}
|
||||
val json = data.substring(pos).substringAfter('(').substringBefore('\n').substringBeforeLast(')')
|
||||
if (json.isEmpty()) {
|
||||
continue
|
||||
}
|
||||
val ja = JSONArray("[$json]")
|
||||
val pages = ja.getJSONArray(1)
|
||||
val servers = ja.getJSONArray(3).mapJSON { it.getString("path") }
|
||||
val serversStr = servers.joinToString("|")
|
||||
return (0 until pages.length()).map { i ->
|
||||
val page = pages.getJSONArray(i)
|
||||
val primaryServer = page.getString(0)
|
||||
val url = page.getString(2)
|
||||
MangaPage(
|
||||
id = generateUid(url),
|
||||
url = "$primaryServer|$serversStr|$url",
|
||||
preview = null,
|
||||
source = source,
|
||||
)
|
||||
pos = data.indexOf("rm_h.readerInit( 0,")
|
||||
if (pos != -1) {
|
||||
parsePagesOld(data, pos)?.let { return it }
|
||||
}
|
||||
}
|
||||
doc.parseFailed("Pages list not found at ${chapter.url}")
|
||||
@@ -373,4 +358,48 @@ internal abstract class GroupleParser(
|
||||
source = source,
|
||||
)
|
||||
}
|
||||
|
||||
private fun parsePagesNew(data: String, pos: Int): List<MangaPage>? {
|
||||
val json = data.substring(pos).substringAfter('(').substringBefore('\n').substringBeforeLast(')')
|
||||
if (json.isEmpty()) {
|
||||
return null
|
||||
}
|
||||
val ja = JSONArray("[$json]")
|
||||
val pages = ja.getJSONArray(0)
|
||||
val servers = ja.getJSONArray(2).mapJSON { it.getString("path") }
|
||||
val serversStr = servers.joinToString("|")
|
||||
return (0 until pages.length()).map { i ->
|
||||
val page = pages.getJSONArray(i)
|
||||
val primaryServer = page.getString(0)
|
||||
val url = page.getString(2)
|
||||
MangaPage(
|
||||
id = generateUid(url),
|
||||
url = "$primaryServer|$serversStr|$url",
|
||||
preview = null,
|
||||
source = source,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun parsePagesOld(data: String, pos: Int): List<MangaPage>? {
|
||||
val json = data.substring(pos).substringAfter('(').substringBefore('\n').substringBeforeLast(')')
|
||||
if (json.isEmpty()) {
|
||||
return null
|
||||
}
|
||||
val ja = JSONArray("[$json]")
|
||||
val pages = ja.getJSONArray(1)
|
||||
val servers = ja.getJSONArray(3).mapJSON { it.getString("path") }
|
||||
val serversStr = servers.joinToString("|")
|
||||
return (0 until pages.length()).map { i ->
|
||||
val page = pages.getJSONArray(i)
|
||||
val primaryServer = page.getString(0)
|
||||
val url = page.getString(2)
|
||||
MangaPage(
|
||||
id = generateUid(url),
|
||||
url = "$primaryServer|$serversStr|$url",
|
||||
preview = null,
|
||||
source = source,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,9 +11,8 @@ internal class MintMangaParser(
|
||||
) : GroupleParser(context, MangaSource.MINTMANGA, 2) {
|
||||
|
||||
override val configKeyDomain = ConfigKey.Domain(
|
||||
"23.mintmanga.live",
|
||||
"24.mintmanga.one",
|
||||
"mintmanga.live",
|
||||
"mintmanga.com",
|
||||
"m.mintmanga.live",
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package org.koitharu.kotatsu.parsers.site.ru.multichan
|
||||
|
||||
import okhttp3.HttpUrl
|
||||
import org.jsoup.internal.StringUtil
|
||||
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
||||
import org.koitharu.kotatsu.parsers.MangaParser
|
||||
import org.koitharu.kotatsu.parsers.MangaParserAuthProvider
|
||||
@@ -22,21 +23,17 @@ internal abstract class ChanParser(
|
||||
SortOrder.RATING,
|
||||
)
|
||||
|
||||
override val isTagsExclusionSupported: Boolean = true
|
||||
|
||||
override val authUrl: String
|
||||
get() = "https://${domain}"
|
||||
|
||||
override val isAuthorized: Boolean
|
||||
get() = context.cookieJar.getCookies(domain).any { it.name == "dle_user_id" }
|
||||
|
||||
override suspend fun getList(
|
||||
offset: Int,
|
||||
query: String?,
|
||||
tags: Set<MangaTag>?,
|
||||
tagsExclude: Set<MangaTag>?,
|
||||
sortOrder: SortOrder,
|
||||
): List<Manga> {
|
||||
override suspend fun getList(offset: Int, filter: MangaListFilter?): List<Manga> {
|
||||
val domain = domain
|
||||
val doc = webClient.httpGet(buildUrl(offset, query, tags, sortOrder)).parseHtml()
|
||||
val doc = webClient.httpGet(buildUrl(offset, filter)).parseHtml()
|
||||
val root = doc.body().selectFirst("div.main_fon")?.getElementById("content")
|
||||
?: doc.parseFailed("Cannot find root")
|
||||
return root.select("div.content_row").mapNotNull { row ->
|
||||
@@ -180,48 +177,56 @@ internal abstract class ChanParser(
|
||||
|
||||
protected open fun buildUrl(
|
||||
offset: Int,
|
||||
query: String?,
|
||||
tags: Set<MangaTag>?,
|
||||
sortOrder: SortOrder,
|
||||
filter: MangaListFilter?,
|
||||
): HttpUrl {
|
||||
val builder = urlBuilder()
|
||||
builder.addQueryParameter("offset", offset.toString())
|
||||
when {
|
||||
!query.isNullOrEmpty() -> {
|
||||
when (filter) {
|
||||
is MangaListFilter.Search -> {
|
||||
builder.addQueryParameter("do", "search")
|
||||
builder.addQueryParameter("subaction", "search")
|
||||
builder.addQueryParameter("search_start", ((offset / 40) + 1).toString())
|
||||
builder.addQueryParameter("full_search", "0")
|
||||
builder.addQueryParameter("result_from", (offset + 1).toString())
|
||||
builder.addQueryParameter("result_num", "40")
|
||||
builder.addQueryParameter("story", query)
|
||||
builder.addQueryParameter("story", filter.query)
|
||||
builder.addQueryParameter("need_sort_date", "false")
|
||||
}
|
||||
|
||||
!tags.isNullOrEmpty() -> {
|
||||
builder.addPathSegment("tags")
|
||||
builder.addPathSegment(tags.joinToString("+") { it.key })
|
||||
builder.addQueryParameter(
|
||||
"n",
|
||||
when (sortOrder) {
|
||||
SortOrder.RATING,
|
||||
SortOrder.POPULARITY,
|
||||
-> "favdesc"
|
||||
is MangaListFilter.Advanced -> {
|
||||
if (filter.tags.isNotEmpty() || filter.tagsExclude.isNotEmpty()) {
|
||||
builder.addPathSegment("tags")
|
||||
val joiner = StringUtil.StringJoiner("+")
|
||||
filter.tags.forEach { joiner.add(it.key) }
|
||||
filter.tagsExclude.forEach { joiner.add("-"); joiner.append(it.key) }
|
||||
builder.addPathSegment(joiner.complete())
|
||||
builder.addQueryParameter(
|
||||
"n",
|
||||
when (filter.sortOrder) {
|
||||
SortOrder.RATING,
|
||||
SortOrder.POPULARITY,
|
||||
-> "favdesc"
|
||||
|
||||
SortOrder.ALPHABETICAL -> "abcasc"
|
||||
else -> "" // SortOrder.NEWEST
|
||||
},
|
||||
)
|
||||
SortOrder.ALPHABETICAL -> "abcasc"
|
||||
else -> "" // SortOrder.NEWEST
|
||||
},
|
||||
)
|
||||
} else {
|
||||
when (filter.sortOrder) {
|
||||
SortOrder.POPULARITY -> builder.addPathSegment("mostviews")
|
||||
SortOrder.ALPHABETICAL -> builder.addPathSegment("catalog")
|
||||
SortOrder.RATING -> builder.addPathSegment("mostfavorites")
|
||||
else -> { // SortOrder.NEWEST
|
||||
builder.addPathSegment("manga")
|
||||
builder.addPathSegment("new")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
else -> when (sortOrder) {
|
||||
SortOrder.POPULARITY -> builder.addPathSegment("mostviews")
|
||||
SortOrder.ALPHABETICAL -> builder.addPathSegment("catalog")
|
||||
SortOrder.RATING -> builder.addPathSegment("mostfavorites")
|
||||
else -> { // SortOrder.NEWEST
|
||||
builder.addPathSegment("manga")
|
||||
builder.addPathSegment("new")
|
||||
}
|
||||
null -> {
|
||||
builder.addPathSegment("manga")
|
||||
builder.addPathSegment("new")
|
||||
}
|
||||
}
|
||||
return builder.build()
|
||||
|
||||
@@ -58,10 +58,10 @@ internal class HenChanParser(context: MangaLoaderContext) : ChanParser(context,
|
||||
)
|
||||
}
|
||||
|
||||
override fun buildUrl(offset: Int, query: String?, tags: Set<MangaTag>?, sortOrder: SortOrder): HttpUrl {
|
||||
if (query.isNullOrEmpty() && tags.isNullOrEmpty()) {
|
||||
override fun buildUrl(offset: Int, filter: MangaListFilter?): HttpUrl = when {
|
||||
filter is MangaListFilter.Advanced && filter.tags.isEmpty() && filter.tagsExclude.isEmpty() -> {
|
||||
val builder = urlBuilder().addQueryParameter("offset", offset.toString())
|
||||
when (sortOrder) {
|
||||
when (filter.sortOrder) {
|
||||
SortOrder.POPULARITY -> {
|
||||
builder.addPathSegment("mostviews")
|
||||
builder.addQueryParameter("sort", "manga")
|
||||
@@ -77,8 +77,18 @@ internal class HenChanParser(context: MangaLoaderContext) : ChanParser(context,
|
||||
builder.addPathSegment("newest")
|
||||
}
|
||||
}
|
||||
return builder.build()
|
||||
builder.build()
|
||||
}
|
||||
|
||||
filter == null -> {
|
||||
val builder = urlBuilder().addQueryParameter("offset", offset.toString())
|
||||
builder.addPathSegment("manga")
|
||||
builder.addPathSegment("newest")
|
||||
builder.build()
|
||||
}
|
||||
|
||||
else -> {
|
||||
super.buildUrl(offset, filter)
|
||||
}
|
||||
return super.buildUrl(offset, query, tags, sortOrder)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package org.koitharu.kotatsu.parsers.site.ru.rulib
|
||||
|
||||
import androidx.collection.ArrayMap
|
||||
import androidx.collection.ArraySet
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import okhttp3.Response
|
||||
@@ -108,31 +107,24 @@ internal open class MangaLibParser(
|
||||
val id = json.optJSONObject("user")?.getLong("id")?.toString() ?: "not"
|
||||
val total = list.length()
|
||||
chapters = ChaptersListBuilder(total)
|
||||
val counters = ArrayMap<Int, Int>(branches.size)
|
||||
for (i in (0 until total).reversed()) {
|
||||
val item = list.getJSONObject(i)
|
||||
val chapterId = item.getLong("chapter_id")
|
||||
val scanlator = item.getStringOrNull("username")
|
||||
val volume = item.getInt("chapter_volume")
|
||||
val number = item.getString("chapter_number")
|
||||
val url = buildString {
|
||||
append(manga.url)
|
||||
append("/v")
|
||||
append(volume)
|
||||
append("/c")
|
||||
append(number)
|
||||
if (isAuthorized) {
|
||||
append(manga.url)
|
||||
append("/v")
|
||||
append(item.getInt("chapter_volume"))
|
||||
append("/c")
|
||||
append(item.getString("chapter_number"))
|
||||
append("?ui=")
|
||||
append(id)
|
||||
} else {
|
||||
append(manga.url)
|
||||
append("/v")
|
||||
append(item.getInt("chapter_volume"))
|
||||
append("/c")
|
||||
append(item.getString("chapter_number"))
|
||||
}
|
||||
}
|
||||
val nameChapter = item.getStringOrNull("chapter_name")
|
||||
val volume = item.getInt("chapter_volume")
|
||||
val number = item.getString("chapter_number")
|
||||
val fullNameChapter = "Том $volume. Глава $number"
|
||||
val branchId = item.getIntOrDefault("branch_id", 0)
|
||||
chapters.add(
|
||||
@@ -140,7 +132,8 @@ internal open class MangaLibParser(
|
||||
id = generateUid(chapterId),
|
||||
url = url,
|
||||
source = source,
|
||||
number = counters.incrementAndGet(branchId),
|
||||
number = number.toFloatOrNull() ?: 0f,
|
||||
volume = volume,
|
||||
uploadDate = dateFormat.tryParse(
|
||||
item.getString("chapter_created_at").substringBefore(" "),
|
||||
),
|
||||
@@ -347,13 +340,6 @@ internal open class MangaLibParser(
|
||||
return isSuccessful && mimeType?.startsWith("image/") == true && headersContentLength() >= 1024L
|
||||
}
|
||||
|
||||
private fun MutableMap<Int, Int>.incrementAndGet(key: Int): Int {
|
||||
var v = getOrDefault(key, 0)
|
||||
v++
|
||||
put(key, v)
|
||||
return v
|
||||
}
|
||||
|
||||
@MangaSourceParser("MANGALIB", "MangaLib", "ru")
|
||||
class Impl(context: MangaLoaderContext) : MangaLibParser(context, MangaSource.MANGALIB)
|
||||
|
||||
|
||||
@@ -11,9 +11,10 @@ import org.koitharu.kotatsu.parsers.PagedMangaParser
|
||||
import org.koitharu.kotatsu.parsers.config.ConfigKey
|
||||
import org.koitharu.kotatsu.parsers.model.*
|
||||
import org.koitharu.kotatsu.parsers.util.*
|
||||
import org.koitharu.kotatsu.parsers.util.json.getFloatOrDefault
|
||||
import org.koitharu.kotatsu.parsers.util.json.getIntOrDefault
|
||||
import org.koitharu.kotatsu.parsers.util.json.getStringOrNull
|
||||
import org.koitharu.kotatsu.parsers.util.json.mapJSON
|
||||
import org.koitharu.kotatsu.parsers.util.json.mapJSONIndexed
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
|
||||
@@ -51,21 +52,24 @@ class HoneyMangaParser(context: MangaLoaderContext) : PagedMangaParser(context,
|
||||
body.put("sortOrder", "ASC")
|
||||
val chapterRequest = webClient.httpPost(chapterApi, body).parseJson()
|
||||
return manga.copy(
|
||||
chapters = chapterRequest.getJSONArray("data").mapJSONIndexed { i, jo ->
|
||||
chapters = chapterRequest.getJSONArray("data").mapJSON { jo ->
|
||||
val number = jo.getFloatOrDefault("chapterNum", 0f)
|
||||
val volume = jo.getIntOrDefault("volume", 0)
|
||||
MangaChapter(
|
||||
id = generateUid(jo.getString("id")),
|
||||
name = buildString {
|
||||
append("Том ")
|
||||
append(jo.optString("volume", "0"))
|
||||
append(volume)
|
||||
append(". ")
|
||||
append("Розділ ")
|
||||
append(jo.optString("chapterNum", "0"))
|
||||
append(number)
|
||||
if (jo.optString("title") != "Title") {
|
||||
append(" - ")
|
||||
append(jo.optString("title"))
|
||||
}
|
||||
},
|
||||
number = i + 1,
|
||||
number = number,
|
||||
volume = volume,
|
||||
url = jo.optString("chapterResourcesId"),
|
||||
scanlator = null,
|
||||
uploadDate = dateFormat.tryParse(jo.getString("lastUpdated")),
|
||||
|
||||
@@ -178,9 +178,11 @@ internal abstract class WpComicsParser(
|
||||
val tagItems = doc.select("div.genre-item")
|
||||
val result = ArrayMap<String, MangaTag>(tagItems.size)
|
||||
for (item in tagItems) {
|
||||
val title = item.text().trim()
|
||||
val title = item.text()
|
||||
val key = item.select("span[data-id]").attr("data-id")
|
||||
result[title] = MangaTag(title = title, key = key, source = source)
|
||||
if (key.isNotEmpty() && title.isNotEmpty()) {
|
||||
result[title] = MangaTag(title = title, key = key, source = source)
|
||||
}
|
||||
}
|
||||
tagCache = result
|
||||
result
|
||||
@@ -216,7 +218,7 @@ internal abstract class WpComicsParser(
|
||||
|
||||
|
||||
protected open val selectDate = "div.col-xs-4"
|
||||
protected open val selectChapter = "div#nt_listchapter li:not(.heading)"
|
||||
protected open val selectChapter = "div#nt_listchapter li .chapter"
|
||||
|
||||
protected open suspend fun getChapters(doc: Document): List<MangaChapter> {
|
||||
return doc.body().select(selectChapter).mapChapters(reversed = true) { i, li ->
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
package org.koitharu.kotatsu.parsers.site.wpcomics.en
|
||||
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import org.jsoup.nodes.Document
|
||||
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
||||
import org.koitharu.kotatsu.parsers.MangaSourceParser
|
||||
import org.koitharu.kotatsu.parsers.model.*
|
||||
import org.koitharu.kotatsu.parsers.site.wpcomics.WpComicsParser
|
||||
import org.koitharu.kotatsu.parsers.util.*
|
||||
import java.util.EnumSet
|
||||
import java.util.*
|
||||
|
||||
@MangaSourceParser("XOXOCOMICS", "XoxoComics", "en", ContentType.COMICS)
|
||||
internal class XoxoComics(context: MangaLoaderContext) :
|
||||
@@ -146,6 +148,29 @@ internal class XoxoComics(context: MangaLoaderContext) :
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun getChapters(doc: Document): List<MangaChapter> {
|
||||
val pages = doc.select("ul.pagination > li:not(.active)")
|
||||
return if (pages.size <= 1) {
|
||||
super.getChapters(doc)
|
||||
} else {
|
||||
val list = coroutineScope {
|
||||
pages.mapNotNull { page ->
|
||||
val a = page.selectFirst("a") ?: return@mapNotNull null
|
||||
if (a.text().isNumeric()) {
|
||||
val href = a.attrAsAbsoluteUrl("href")
|
||||
async {
|
||||
super.getChapters(webClient.httpGet(href).parseHtml()).asReversed()
|
||||
}
|
||||
} else {
|
||||
null // TODO support pagination with overflow
|
||||
}
|
||||
}.awaitAll().flattenTo(ArrayList())
|
||||
}
|
||||
list.addAll(super.getChapters(doc).asReversed())
|
||||
list.reverse()
|
||||
list.mapIndexed { i, x -> x.copy(volume = x.volume, number = (i + 1).toFloat()) }
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
|
||||
val fullUrl = chapter.url.toAbsoluteUrl(domain) + "/all"
|
||||
|
||||
@@ -10,6 +10,7 @@ import org.koitharu.kotatsu.parsers.site.wpcomics.WpComicsParser
|
||||
internal class NetTruyen(context: MangaLoaderContext) :
|
||||
WpComicsParser(context, MangaSource.NETTRUYEN, "www.nettruyenlive.com", 36) {
|
||||
override val configKeyDomain: ConfigKey.Domain = ConfigKey.Domain(
|
||||
"www.nettruyenaz.com",
|
||||
"www.nettruyenlive.com",
|
||||
"www.nettruyenio.com",
|
||||
"www.nettruyento.com",
|
||||
|
||||
@@ -7,4 +7,4 @@ import org.koitharu.kotatsu.parsers.site.wpcomics.WpComicsParser
|
||||
|
||||
@MangaSourceParser("NHATTRUYENMIN", "NhattruyenPlus", "vi")
|
||||
internal class Nhattruyenmin(context: MangaLoaderContext) :
|
||||
WpComicsParser(context, MangaSource.NHATTRUYENMIN, "nhattruyenplus.com")
|
||||
WpComicsParser(context, MangaSource.NHATTRUYENMIN, "nhattruyenmax.com")
|
||||
|
||||
@@ -34,6 +34,13 @@ internal class ChaptersListBuilder(initialSize: Int) {
|
||||
return chapter != null && ids.add(chapter.id) && list.add(chapter)
|
||||
}
|
||||
|
||||
fun addAll(chapters: Iterable<MangaChapter?>) {
|
||||
if (chapters is Collection<*>) {
|
||||
list.ensureCapacity(list.size + chapters.size)
|
||||
}
|
||||
chapters.forEach { add(it) }
|
||||
}
|
||||
|
||||
operator fun plusAssign(chapter: MangaChapter?) {
|
||||
add(chapter)
|
||||
}
|
||||
|
||||
@@ -240,3 +240,5 @@ inline fun <T> Appendable.appendAll(
|
||||
append(transform(item))
|
||||
}
|
||||
}
|
||||
|
||||
fun String.isNumeric() = all { c -> c.isDigit() }
|
||||
|
||||
@@ -40,7 +40,7 @@ internal class MangaParserTest {
|
||||
val page1 = parser.getList(0, filter = null)
|
||||
val page2 = parser.getList(page1.size, filter = null)
|
||||
if (parser is PagedMangaParser) {
|
||||
assert(parser.pageSize == page1.size) {
|
||||
assert(parser.pageSize >= page1.size) {
|
||||
"Page size is ${page1.size} but ${parser.pageSize} expected"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user