Compare commits
1 Commits
source/neo
...
source/tmo
| Author | SHA1 | Date | |
|---|---|---|---|
| e1db2994d7 |
@@ -6,8 +6,8 @@ end_of_line = lf
|
|||||||
indent_style = tab
|
indent_style = tab
|
||||||
max_line_length = 120
|
max_line_length = 120
|
||||||
tab_width = 4
|
tab_width = 4
|
||||||
insert_final_newline = true
|
insert_final_newline = false
|
||||||
disabled_rules = no-wildcard-imports, no-unused-imports
|
disabled_rules=no-wildcard-imports,no-unused-imports
|
||||||
|
|
||||||
[{*.kt,*.kts}]
|
[{*.kt,*.kts}]
|
||||||
ij_kotlin_allow_trailing_comma = true
|
ij_kotlin_allow_trailing_comma = true
|
||||||
|
|||||||
4
.github/workflows/test-parsers.yml
vendored
4
.github/workflows/test-parsers.yml
vendored
@@ -4,7 +4,7 @@ on:
|
|||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
pull_request:
|
pull_request:
|
||||||
paths:
|
paths:
|
||||||
- 'src/main/kotlin/org/koitharu/kotatsu/parsers/site/**'
|
- 'src/main/kotlin/org/koitharu/kotatsu/parsers/site/*'
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
@@ -24,4 +24,4 @@ jobs:
|
|||||||
- uses: actions/upload-artifact@v3
|
- uses: actions/upload-artifact@v3
|
||||||
with:
|
with:
|
||||||
name: Report
|
name: Report
|
||||||
path: build/test-results-html/TEST-org.koitharu.kotatsu.parsers.MangaParserTest.htm
|
path: build/test-results-html/TEST-org.koitharu.kotatsu.parsers.MangaParserTest.htm
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -75,5 +75,4 @@ build/
|
|||||||
.idea/**/ktlint.xml
|
.idea/**/ktlint.xml
|
||||||
.idea/codeStyles/
|
.idea/codeStyles/
|
||||||
|
|
||||||
src/test/resources/cookies.txt
|
src/test/resources/cookies.txt
|
||||||
local.properties
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
# Contributing
|
|
||||||
|
|
||||||
The following is guide for creating a Kotatsu parsers. Thanks for taking the time to contribute!
|
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
|
|
||||||
Before you start, please note that the ability to use following technologies is **required**.
|
|
||||||
|
|
||||||
- Basic [Android development](https://developer.android.com/)
|
|
||||||
- [Kotlin](https://kotlinlang.org/)
|
|
||||||
- Web scraping ([JSoup](https://jsoup.org/)) or JSON API
|
|
||||||
|
|
||||||
### Tools
|
|
||||||
|
|
||||||
- [Android Studio](https://developer.android.com/studio)
|
|
||||||
- [IntelliJ IDEA](https://www.jetbrains.com/idea/) (Community edition is enough)
|
|
||||||
- Android device (or emulator)
|
|
||||||
|
|
||||||
Kotatsu parsers is not a part of Android application, but you can easily develop and test it directly inside an Android
|
|
||||||
application project and relocate it to the library project when done.
|
|
||||||
|
|
||||||
### Before you start
|
|
||||||
|
|
||||||
First, take a look at `kotatsu-parsers` project structure. Each parser is a single class that
|
|
||||||
extends `MangaParser` class and have a `MangaSourceParser` annotation.
|
|
||||||
Also pay attention on extensions in `util` package. For example, extensions from `Jsoup` file
|
|
||||||
should be used instead of existing JSoup functions because they have better nullability support
|
|
||||||
and improved error messages.
|
|
||||||
|
|
||||||
## Writing your parser
|
|
||||||
|
|
||||||
So, you want to create a parser, that will provide access to manga from a website.
|
|
||||||
First, you should explore a website for API availability.
|
|
||||||
If it does not contain any documentation about
|
|
||||||
API, [explore network requests](https://firefox-source-docs.mozilla.org/devtools-user/):
|
|
||||||
some websites use ajax.
|
|
||||||
|
|
||||||
- [Example](https://github.com/KotatsuApp/kotatsu-parsers/blob/master/src/main/kotlin/org/koitharu/kotatsu/parsers/site/DesuMeParser.kt)
|
|
||||||
of Json API usage.
|
|
||||||
- [Example](https://github.com/KotatsuApp/kotatsu-parsers/blob/master/src/main/kotlin/org/koitharu/kotatsu/parsers/site/AnibelParser.kt)
|
|
||||||
of GraphQL API usage
|
|
||||||
- [Example](https://github.com/KotatsuApp/kotatsu-parsers/blob/master/src/main/kotlin/org/koitharu/kotatsu/parsers/site/MangaTownParser.kt)
|
|
||||||
of pure HTML parsing.
|
|
||||||
|
|
||||||
If website is based on some engine it is rationally to use common base class for this one (for example, Madara wordress
|
|
||||||
theme
|
|
||||||
and the `MadaraParser` class)
|
|
||||||
|
|
||||||
### Parser class skeleton
|
|
||||||
|
|
||||||
Parser class must have exactly one primary constructor parameter of type `MangaLoaderContext` and have an
|
|
||||||
`MangaSourceParser` annotation that provides internal name, title and language of a manga source.
|
|
||||||
|
|
||||||
All functions in `MangaParser` class are documented. Pay attention to some peculiarities:
|
|
||||||
|
|
||||||
- Never hardcode domain. Specify default domain in `configKeyDomain` field and obtain an actual one using `getDomain()`.
|
|
||||||
- All ids must be unique and domain-independent. Use `generateUid` functions with relative url or some internal id which
|
|
||||||
is unique across the manga source.
|
|
||||||
- `sortOrders` set should not be empty. If your source is not support sorting, specify one most relevance value.
|
|
||||||
- If you cannot obtain direct links to pages images inside `getPages` method, it is ok to use an intermediate url
|
|
||||||
as `Page.url` and fetch a direct link at `getPageUrl` function.
|
|
||||||
- You can use _asserts_ to check some optional fields. For example. `Manga.author` field is not required, but if your
|
|
||||||
source provide such information, add `assert(it != null)`. This will not have any effect on production but help to
|
|
||||||
find issues during unit testing.
|
|
||||||
- If your source website (or it's api) uses pages for pagination instead of offset you should extend `PagedMangaParser`
|
|
||||||
instead of `MangaParser`.
|
|
||||||
- Your parser may also implement the `Interceptor` interface for additional manipulation of all network requests and/or
|
|
||||||
responses, including image loading.
|
|
||||||
|
|
||||||
## Development process
|
|
||||||
|
|
||||||
During the development it is recommended (but not necessary) to write it directly
|
|
||||||
in the Kotatsu android application project. You can use `core.parser.DummyParser` class as a sandbox. `Dummy` manga
|
|
||||||
source is available in debug Kotatsu build.
|
|
||||||
|
|
||||||
Once parser is ready you can relocate your code into `kotatsu-parsers` library project in a `site` package and create a
|
|
||||||
Pull Request.
|
|
||||||
|
|
||||||
### Testing
|
|
||||||
|
|
||||||
It is recommended to run unit tests before submitting a PR.
|
|
||||||
|
|
||||||
- Temporary modify the `MangaSources` annotation class: specify your parser(s) name(s) and change mode
|
|
||||||
to `EnumSource.Mode.INCLUDE`
|
|
||||||
- Run the `MangaParserTest` (`gradlew :test --tests "org.koitharu.kotatsu.parsers.MangaParserTest"`)
|
|
||||||
- Optionally, you can run the `generateTestsReport` gradle task to get a pretty readable html report from test results.
|
|
||||||
|
|
||||||
## Help
|
|
||||||
|
|
||||||
If you need a help or have some questions, ask a community in our [Telegram chat](https://t.me/kotatsuapp)
|
|
||||||
or [Discord server](https://discord.gg/NNJ5RgVBC5).
|
|
||||||
26
README.md
26
README.md
@@ -1,12 +1,12 @@
|
|||||||
# Kotatsu parsers
|
# Kotatsu parsers
|
||||||
|
|
||||||
This library provides manga sources.
|
Library that provides manga sources.
|
||||||
|
|
||||||
[](https://jitpack.io/#KotatsuApp/kotatsu-parsers)   [](https://t.me/kotatsuapp) [](https://discord.gg/NNJ5RgVBC5)
|
[](https://jitpack.io/#KotatsuApp/kotatsu-parsers)   [](https://discord.gg/NNJ5RgVBC5)
|
||||||
|
|
||||||
## Usage
|
### Usage
|
||||||
|
|
||||||
1. Add it to your root build.gradle at the end of repositories:
|
1. Add it in your root build.gradle at the end of repositories:
|
||||||
|
|
||||||
```groovy
|
```groovy
|
||||||
allprojects {
|
allprojects {
|
||||||
@@ -35,7 +35,7 @@ This library provides manga sources.
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Versions are available on [JitPack](https://jitpack.io/#KotatsuApp/kotatsu-parsers)
|
See for versions at [JitPack](https://jitpack.io/#KotatsuApp/kotatsu-parsers)
|
||||||
|
|
||||||
3. Usage in code
|
3. Usage in code
|
||||||
|
|
||||||
@@ -44,18 +44,8 @@ This library provides manga sources.
|
|||||||
```
|
```
|
||||||
|
|
||||||
`mangaLoaderContext` is an implementation of the `MangaLoaderContext` class.
|
`mangaLoaderContext` is an implementation of the `MangaLoaderContext` class.
|
||||||
See examples
|
See [Android](https://github.com/KotatsuApp/Kotatsu/blob/devel/app/src/main/java/org/koitharu/kotatsu/core/parser/MangaLoaderContextImpl.kt)
|
||||||
of [Android](https://github.com/KotatsuApp/Kotatsu/blob/devel/app/src/main/java/org/koitharu/kotatsu/core/parser/MangaLoaderContextImpl.kt)
|
|
||||||
and [Non-Android](https://github.com/KotatsuApp/kotatsu-dl/blob/master/src/main/kotlin/org/koitharu/kotatsu_dl/env/MangaLoaderContextImpl.kt)
|
and [Non-Android](https://github.com/KotatsuApp/kotatsu-dl/blob/master/src/main/kotlin/org/koitharu/kotatsu_dl/env/MangaLoaderContextImpl.kt)
|
||||||
implementation.
|
implementation examples.
|
||||||
|
|
||||||
Note that the `MangaSource.LOCAL` and `MangaSource.DUMMY` parsers cannot be instantiated.
|
Note that the `MangaSource.LOCAL` and `MangaSource.DUMMY` parsers cannot be instantiated.
|
||||||
|
|
||||||
## Contribution
|
|
||||||
|
|
||||||
See [CONTRIBUTING.md](./CONTRIBUTING.md) for the guidelines.
|
|
||||||
|
|
||||||
## DMCA disclaimer
|
|
||||||
|
|
||||||
The developers of this application have no affiliation with the content available in the app. It is collected from
|
|
||||||
sources freely available through any web browser.
|
|
||||||
10
build.gradle
10
build.gradle
@@ -20,7 +20,6 @@ compileKotlin {
|
|||||||
freeCompilerArgs += [
|
freeCompilerArgs += [
|
||||||
'-opt-in=kotlin.RequiresOptIn',
|
'-opt-in=kotlin.RequiresOptIn',
|
||||||
'-opt-in=kotlin.contracts.ExperimentalContracts',
|
'-opt-in=kotlin.contracts.ExperimentalContracts',
|
||||||
'-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi',
|
|
||||||
'-opt-in=org.koitharu.kotatsu.parsers.InternalParsersApi',
|
'-opt-in=org.koitharu.kotatsu.parsers.InternalParsersApi',
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -57,18 +56,17 @@ dependencies {
|
|||||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4'
|
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4'
|
||||||
implementation 'com.squareup.okhttp3:okhttp:4.10.0'
|
implementation 'com.squareup.okhttp3:okhttp:4.10.0'
|
||||||
implementation 'com.squareup.okio:okio:3.2.0'
|
implementation 'com.squareup.okio:okio:3.2.0'
|
||||||
api 'org.jsoup:jsoup:1.15.3'
|
api 'org.jsoup:jsoup:1.15.2'
|
||||||
implementation 'org.json:json:20220320'
|
implementation 'org.json:json:20220320'
|
||||||
implementation 'androidx.collection:collection-ktx:1.2.0'
|
implementation 'androidx.collection:collection-ktx:1.2.0'
|
||||||
|
|
||||||
ksp project(':kotatsu-parsers-ksp')
|
ksp project(':kotatsu-parsers-ksp')
|
||||||
|
|
||||||
testImplementation 'org.junit.jupiter:junit-jupiter-api:5.9.0'
|
testImplementation 'org.junit.jupiter:junit-jupiter-api:5.8.2'
|
||||||
testImplementation 'org.junit.jupiter:junit-jupiter-engine:5.9.0'
|
testImplementation 'org.junit.jupiter:junit-jupiter-engine:5.8.2'
|
||||||
testImplementation 'org.junit.jupiter:junit-jupiter-params:5.9.0'
|
testImplementation 'org.junit.jupiter:junit-jupiter-params:5.8.2'
|
||||||
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.4'
|
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.4'
|
||||||
testImplementation 'io.webfolder:quickjs:1.1.0'
|
testImplementation 'io.webfolder:quickjs:1.1.0'
|
||||||
}
|
}
|
||||||
|
|
||||||
//noinspection ConfigurationAvoidance
|
|
||||||
task generateTestsReport(type: ReportGenerateTask)
|
task generateTestsReport(type: ReportGenerateTask)
|
||||||
@@ -2,15 +2,13 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta content="width=device-width, initial-scale=1" name="viewport">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>{{ testSuite.name }}</title>
|
<title>{{ testSuite.name }}</title>
|
||||||
<!-- CSS only -->
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.2.0-beta1/dist/css/bootstrap.min.css" rel="stylesheet"
|
||||||
<link crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/bootstrap@5.2.2/dist/css/bootstrap.min.css"
|
integrity="sha384-0evHe/X+R7YkIZDRvuzKMRqM+OrBnVFBL6DOitfPri4tjfHxaWutUpFmBp4vmVor" crossorigin="anonymous">
|
||||||
integrity="sha384-Zenh87qX5JnK2Jl0vWa8Ck2rdkQ2Bzep5IDxbcnCeuOxjzrPF/et3URy9Bv1WTRi" rel="stylesheet">
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.2.0-beta1/dist/js/bootstrap.bundle.min.js"
|
||||||
<!-- JavaScript Bundle with Popper -->
|
integrity="sha384-pprn3073KE6tl6bjs2QrFaJGz5/SUsLqktiwsUTF55Jfv3qYSDhgCecCxMW52nD2"
|
||||||
<script crossorigin="anonymous"
|
crossorigin="anonymous"></script>
|
||||||
integrity="sha384-OERcA2EqjJCMA+/3y+gxIOqMEjwtxJY7qPCqsdltbNJuaOe923+mo//f6V8Qbsw3"
|
|
||||||
src="https://cdn.jsdelivr.net/npm/bootstrap@5.2.2/dist/js/bootstrap.bundle.min.js"></script>
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/feather-icons/dist/feather.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/feather-icons/dist/feather.min.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body class="py-4">
|
<body class="py-4">
|
||||||
@@ -27,103 +25,67 @@
|
|||||||
{{ testSuite.errors }} ({{ error_percent }}%)
|
{{ testSuite.errors }} ({{ error_percent }}%)
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<table class="table table-hover">
|
<div class="table-responsive mt-4">
|
||||||
<thead class="sticky-top bg-body">
|
<table class="table table-hover">
|
||||||
<tr>
|
<thead>
|
||||||
<th scope="col">Source</th>
|
<tr>
|
||||||
{% for test in tests %}
|
<th scope="col">Source</th>
|
||||||
<th class="text-center" scope="col" style="min-width: 5em;">{{ test }}</th>
|
{% for test in tests %}
|
||||||
{% endfor %}
|
<th scope="col" style="min-width: 5em;">{{ test }}</th>
|
||||||
</tr>
|
{% endfor %}
|
||||||
</thead>
|
</tr>
|
||||||
{% for name, cases in results %}
|
</thead>
|
||||||
<tr>
|
{% for name, cases in results %}
|
||||||
<th scope="row">{{ name }}</th>
|
<tr>
|
||||||
{% for test in tests %}
|
<th scope="row">{{ name }}</th>
|
||||||
{% set case = cases[test] %}
|
{% for test in tests %}
|
||||||
{% if case.failure == null %}
|
{% set case = cases[test] %}
|
||||||
<td class="table-success text-center">
|
{% if case.failure == null %}
|
||||||
<i data-feather="check"></i>
|
<td class="table-success text-center">
|
||||||
</td>
|
<i data-feather="check"></i>
|
||||||
{% else %}
|
</td>
|
||||||
{% if case.failure.type == 'java.lang.AssertionError' %}
|
{% else %}
|
||||||
<td class="table-warning text-center" data-bs-target="#failure_{{ case.hashCode }}"
|
{% if case.failure.type == 'java.lang.AssertionError' %}
|
||||||
data-bs-toggle="modal" style="cursor: pointer;">
|
<td class="table-warning text-center" style="cursor: pointer;"
|
||||||
<i data-feather="alert-triangle"></i>
|
data-bs-toggle="modal" data-bs-target="#failure_{{ case.hashCode }}">
|
||||||
</td>
|
<i data-feather="alert-triangle"></i>
|
||||||
{% elseif case.failure.type == 'java.net.SocketTimeoutException' or case.failure.type ==
|
</td>
|
||||||
'java.net.UnknownHostException' %}
|
{% else %}
|
||||||
<td class="table-secondary text-center" data-bs-target="#failure_{{ case.hashCode }}"
|
<td class="table-danger text-center" style="cursor: pointer;"
|
||||||
data-bs-toggle="modal" style="cursor: pointer;">
|
data-bs-toggle="modal" data-bs-target="#failure_{{ case.hashCode }}">
|
||||||
<i data-feather="power"></i>
|
<i data-feather="x"></i>
|
||||||
</td>
|
</td>
|
||||||
{% elseif case.failure.type == 'org.koitharu.kotatsu.parsers.exception.CloudFlareProtectedException' %}
|
{% endif %}
|
||||||
<td class="table-secondary text-center" data-bs-target="#failure_{{ case.hashCode }}"
|
<!--suppress HtmlUnknownTag -->
|
||||||
data-bs-toggle="modal" style="cursor: pointer;">
|
<div class="modal fade" id="failure_{{ case.hashCode }}" tabindex="-1">
|
||||||
<i data-feather="shield"></i>
|
<div class="modal-dialog modal-lg modal-dialog-scrollable">
|
||||||
</td>
|
<div class="modal-content">
|
||||||
{% elseif case.failure.type == 'org.koitharu.kotatsu.parsers.exception.AuthRequiredException' %}
|
<div class="modal-header">
|
||||||
<td class="table-secondary text-center" data-bs-target="#failure_{{ case.hashCode }}"
|
<h5 class="modal-title">{{ case.testName }} failed</h5>
|
||||||
data-bs-toggle="modal" style="cursor: pointer;">
|
<button type="button" class="btn-close" data-bs-dismiss="modal"
|
||||||
<i data-feather="user-x"></i>
|
aria-label="Close"></button>
|
||||||
</td>
|
</div>
|
||||||
{% else %}
|
<div class="modal-body font-monospace lh-sm bg-light" style="font-size: 0.7em;">
|
||||||
<td class="table-danger text-center" data-bs-target="#failure_{{ case.hashCode }}"
|
{{ case.failure.textHtml()|raw }}
|
||||||
data-bs-toggle="modal" style="cursor: pointer;">
|
</div>
|
||||||
<i data-feather="x"></i>
|
<div class="modal-footer">
|
||||||
</td>
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||||
{% endif %}
|
</div>
|
||||||
<!--suppress HtmlUnknownTag -->
|
|
||||||
<div class="modal fade" id="failure_{{ case.hashCode }}" tabindex="-1">
|
|
||||||
<div class="modal-dialog modal-lg modal-dialog-scrollable">
|
|
||||||
<div class="modal-content">
|
|
||||||
<div class="modal-header">
|
|
||||||
<h5 class="modal-title">{{ case.testName }} failed</h5>
|
|
||||||
<button aria-label="Close" class="btn-close" data-bs-dismiss="modal"
|
|
||||||
type="button"></button>
|
|
||||||
</div>
|
|
||||||
<div class="modal-body font-monospace lh-sm bg-light" style="font-size: 0.7em;">
|
|
||||||
{{ case.failure.textHtml()|raw }}
|
|
||||||
</div>
|
|
||||||
<div class="modal-footer">
|
|
||||||
<button class="btn btn-secondary" data-bs-dismiss="modal" type="button">Close</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{% endif %}
|
||||||
{% endif %}
|
{% endfor %}
|
||||||
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tr>
|
</table>
|
||||||
{% endfor %}
|
</div>
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
<script>
|
<script>
|
||||||
feather.replace()
|
feather.replace()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -69,7 +69,6 @@ class ParserProcessor(
|
|||||||
|
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
|
|
||||||
@Suppress("DEPRECATION")
|
|
||||||
fun MangaSource.newParser(context: MangaLoaderContext): MangaParser = when (this) {
|
fun MangaSource.newParser(context: MangaLoaderContext): MangaParser = when (this) {
|
||||||
|
|
||||||
""".trimIndent(),
|
""".trimIndent(),
|
||||||
@@ -122,7 +121,6 @@ class ParserProcessor(
|
|||||||
logger.error("Only non-abstract can be annotated with @MangaSourceParser", classDeclaration)
|
logger.error("Only non-abstract can be annotated with @MangaSourceParser", classDeclaration)
|
||||||
}
|
}
|
||||||
val annotation = classDeclaration.annotations.single { it.shortName.asString() == "MangaSourceParser" }
|
val annotation = classDeclaration.annotations.single { it.shortName.asString() == "MangaSourceParser" }
|
||||||
val deprecation = classDeclaration.annotations.singleOrNull { it.shortName.asString() == "Deprecated" }
|
|
||||||
val name = annotation.arguments.single { it.name?.asString() == "name" }.value as String
|
val name = annotation.arguments.single { it.name?.asString() == "name" }.value as String
|
||||||
val title = annotation.arguments.single { it.name?.asString() == "title" }.value as String
|
val title = annotation.arguments.single { it.name?.asString() == "title" }.value as String
|
||||||
val locale = annotation.arguments.single { it.name?.asString() == "locale" }.value as String
|
val locale = annotation.arguments.single { it.name?.asString() == "locale" }.value as String
|
||||||
@@ -144,15 +142,11 @@ class ParserProcessor(
|
|||||||
classDeclaration,
|
classDeclaration,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
val className = classDeclaration.qualifiedName?.asString()
|
val className = classDeclaration.qualifiedName?.asString()
|
||||||
factoryWriter?.write("\tMangaSource.$name -> $className(context)\n")
|
factoryWriter?.write("\tMangaSource.$name -> $className(context)\n")
|
||||||
val deprecationString = if (deprecation != null) {
|
|
||||||
val reason = deprecation.arguments
|
|
||||||
.find { it.name?.asString() == "message" }?.value?.toString() ?: "Unknown reason"
|
|
||||||
"@Deprecated(\"$reason\") "
|
|
||||||
} else ""
|
|
||||||
val localeComment = localeTitle?.toTitleCase(localeObj)?.let { " /* $it */" }.orEmpty()
|
val localeComment = localeTitle?.toTitleCase(localeObj)?.let { " /* $it */" }.orEmpty()
|
||||||
sourcesWriter?.write("\t$deprecationString$name(\"$title\", $localeString$localeComment),\n")
|
sourcesWriter?.write("\t$name(\"$title\", $localeString$localeComment),\n")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,29 +1,166 @@
|
|||||||
package org.koitharu.kotatsu.parsers
|
package org.koitharu.kotatsu.parsers
|
||||||
|
|
||||||
import okhttp3.CookieJar
|
import okhttp3.*
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||||
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
|
import org.json.JSONObject
|
||||||
|
import org.jsoup.HttpStatusException
|
||||||
import org.koitharu.kotatsu.parsers.config.MangaSourceConfig
|
import org.koitharu.kotatsu.parsers.config.MangaSourceConfig
|
||||||
|
import org.koitharu.kotatsu.parsers.exception.GraphQLException
|
||||||
|
import org.koitharu.kotatsu.parsers.exception.NotFoundException
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
|
import org.koitharu.kotatsu.parsers.util.await
|
||||||
|
import org.koitharu.kotatsu.parsers.util.parseJson
|
||||||
import java.util.*
|
import java.util.*
|
||||||
|
|
||||||
abstract class MangaLoaderContext {
|
abstract class MangaLoaderContext {
|
||||||
|
|
||||||
abstract val httpClient: OkHttpClient
|
protected abstract val httpClient: OkHttpClient
|
||||||
|
|
||||||
abstract val cookieJar: CookieJar
|
abstract val cookieJar: CookieJar
|
||||||
|
|
||||||
open fun encodeBase64(data: ByteArray): String = Base64.getEncoder().encodeToString(data)
|
/**
|
||||||
|
* Do a GET http request to specific url
|
||||||
|
* @param url
|
||||||
|
* @param headers an additional headers for request, may be null
|
||||||
|
*/
|
||||||
|
suspend fun httpGet(url: HttpUrl, headers: Headers? = null): Response {
|
||||||
|
val request = Request.Builder()
|
||||||
|
.get()
|
||||||
|
.url(url)
|
||||||
|
if (headers != null) {
|
||||||
|
request.headers(headers)
|
||||||
|
}
|
||||||
|
return httpClient.newCall(request.build()).await().ensureSuccess()
|
||||||
|
}
|
||||||
|
|
||||||
open fun decodeBase64(data: String): ByteArray = Base64.getDecoder().decode(data)
|
suspend fun httpGet(url: String, headers: Headers? = null): Response {
|
||||||
|
return httpGet(url.toHttpUrl(), headers)
|
||||||
|
}
|
||||||
|
|
||||||
open fun getPreferredLocales(): List<Locale> = listOf(Locale.getDefault())
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Execute JavaScript code and return result
|
* Do a HEAD http request to specific url
|
||||||
* @param script JavaScript source code
|
* @param url
|
||||||
* @return execution result as string, may be null
|
* @param headers an additional headers for request, may be null
|
||||||
*/
|
*/
|
||||||
abstract suspend fun evaluateJs(script: String): String?
|
suspend fun httpHead(url: String, headers: Headers? = null): Response {
|
||||||
|
val request = Request.Builder()
|
||||||
|
.head()
|
||||||
|
.url(url)
|
||||||
|
if (headers != null) {
|
||||||
|
request.headers(headers)
|
||||||
|
}
|
||||||
|
return httpClient.newCall(request.build()).await().ensureSuccess()
|
||||||
|
}
|
||||||
|
|
||||||
abstract fun getConfig(source: MangaSource): MangaSourceConfig
|
/**
|
||||||
}
|
* Do a POST http request to specific url with `multipart/form-data` payload
|
||||||
|
* @param url
|
||||||
|
* @param form payload as key=>value map
|
||||||
|
* @param headers an additional headers for request, may be null
|
||||||
|
*/
|
||||||
|
suspend fun httpPost(
|
||||||
|
url: String,
|
||||||
|
form: Map<String, String>,
|
||||||
|
headers: Headers? = null,
|
||||||
|
): Response {
|
||||||
|
val body = FormBody.Builder()
|
||||||
|
form.forEach { (k, v) ->
|
||||||
|
body.addEncoded(k, v)
|
||||||
|
}
|
||||||
|
val request = Request.Builder()
|
||||||
|
.post(body.build())
|
||||||
|
.url(url)
|
||||||
|
if (headers != null) {
|
||||||
|
request.headers(headers)
|
||||||
|
}
|
||||||
|
return httpClient.newCall(request.build()).await().ensureSuccess()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Do a POST http request to specific url with `multipart/form-data` payload
|
||||||
|
* @param url
|
||||||
|
* @param payload payload as `key=value` string with `&` separator
|
||||||
|
* @param headers an additional headers for request, may be null
|
||||||
|
*/
|
||||||
|
suspend fun httpPost(
|
||||||
|
url: String,
|
||||||
|
payload: String,
|
||||||
|
headers: Headers?,
|
||||||
|
): Response {
|
||||||
|
val body = FormBody.Builder()
|
||||||
|
payload.split('&').forEach {
|
||||||
|
val pos = it.indexOf('=')
|
||||||
|
if (pos != -1) {
|
||||||
|
val k = it.substring(0, pos)
|
||||||
|
val v = it.substring(pos + 1)
|
||||||
|
body.addEncoded(k, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val request = Request.Builder()
|
||||||
|
.post(body.build())
|
||||||
|
.url(url)
|
||||||
|
if (headers != null) {
|
||||||
|
request.headers(headers)
|
||||||
|
}
|
||||||
|
return httpClient.newCall(request.build()).await().ensureSuccess()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Do a GraphQL request to specific url
|
||||||
|
* @param endpoint an url
|
||||||
|
* @param query GraphQL request payload
|
||||||
|
*/
|
||||||
|
suspend fun graphQLQuery(endpoint: String, query: String): JSONObject {
|
||||||
|
val body = JSONObject()
|
||||||
|
body.put("operationName", null as Any?)
|
||||||
|
body.put("variables", JSONObject())
|
||||||
|
body.put("query", "{$query}")
|
||||||
|
val mediaType = "application/json; charset=utf-8".toMediaType()
|
||||||
|
val requestBody = body.toString().toRequestBody(mediaType)
|
||||||
|
val request = Request.Builder()
|
||||||
|
.post(requestBody)
|
||||||
|
.url(endpoint)
|
||||||
|
val json = httpClient.newCall(request.build()).await().ensureSuccess().parseJson()
|
||||||
|
json.optJSONArray("errors")?.let {
|
||||||
|
if (it.length() != 0) {
|
||||||
|
throw GraphQLException(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return json
|
||||||
|
}
|
||||||
|
|
||||||
|
open fun encodeBase64(data: ByteArray): String = Base64.getEncoder().encodeToString(data)
|
||||||
|
|
||||||
|
open fun decodeBase64(data: String): ByteArray = Base64.getDecoder().decode(data)
|
||||||
|
|
||||||
|
open fun getPreferredLocales(): List<Locale> = listOf(Locale.getDefault())
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute JavaScript code and return result
|
||||||
|
* @param script JavaScript source code
|
||||||
|
* @return execution result as string, may be null
|
||||||
|
*/
|
||||||
|
abstract suspend fun evaluateJs(script: String): String?
|
||||||
|
|
||||||
|
abstract fun getConfig(source: MangaSource): MangaSourceConfig
|
||||||
|
|
||||||
|
private fun Response.ensureSuccess(): Response {
|
||||||
|
val exception: Exception? = when (code) { // Catch some error codes, not all
|
||||||
|
404 -> NotFoundException(message, request.url.toString())
|
||||||
|
in 500..599 -> HttpStatusException(message, code, request.url.toString())
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
if (exception != null) {
|
||||||
|
runCatching {
|
||||||
|
close()
|
||||||
|
}.onFailure {
|
||||||
|
exception.addSuppressed(it)
|
||||||
|
}
|
||||||
|
throw exception
|
||||||
|
}
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,20 +1,20 @@
|
|||||||
package org.koitharu.kotatsu.parsers
|
package org.koitharu.kotatsu.parsers
|
||||||
|
|
||||||
import androidx.annotation.CallSuper
|
import androidx.annotation.CallSuper
|
||||||
|
import androidx.annotation.VisibleForTesting
|
||||||
import okhttp3.Headers
|
import okhttp3.Headers
|
||||||
|
import okhttp3.HttpUrl
|
||||||
|
import org.jsoup.nodes.Element
|
||||||
import org.koitharu.kotatsu.parsers.config.ConfigKey
|
import org.koitharu.kotatsu.parsers.config.ConfigKey
|
||||||
|
import org.koitharu.kotatsu.parsers.exception.ParseException
|
||||||
import org.koitharu.kotatsu.parsers.model.*
|
import org.koitharu.kotatsu.parsers.model.*
|
||||||
import org.koitharu.kotatsu.parsers.network.OkHttpWebClient
|
|
||||||
import org.koitharu.kotatsu.parsers.network.WebClient
|
|
||||||
import org.koitharu.kotatsu.parsers.util.FaviconParser
|
import org.koitharu.kotatsu.parsers.util.FaviconParser
|
||||||
import org.koitharu.kotatsu.parsers.util.domain
|
|
||||||
import org.koitharu.kotatsu.parsers.util.toAbsoluteUrl
|
import org.koitharu.kotatsu.parsers.util.toAbsoluteUrl
|
||||||
import java.util.*
|
import java.util.*
|
||||||
|
|
||||||
abstract class MangaParser @InternalParsersApi constructor(
|
abstract class MangaParser @InternalParsersApi constructor(val source: MangaSource) {
|
||||||
@property:InternalParsersApi val context: MangaLoaderContext,
|
|
||||||
val source: MangaSource,
|
protected abstract val context: MangaLoaderContext
|
||||||
) {
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Supported [SortOrder] variants. Must not be empty.
|
* Supported [SortOrder] variants. Must not be empty.
|
||||||
@@ -25,18 +25,18 @@ abstract class MangaParser @InternalParsersApi constructor(
|
|||||||
|
|
||||||
val config by lazy { context.getConfig(source) }
|
val config by lazy { context.getConfig(source) }
|
||||||
|
|
||||||
open val sourceLocale: Locale
|
val sourceLocale: Locale?
|
||||||
get() = source.locale?.let { Locale(it) } ?: Locale.ROOT
|
get() = source.locale?.let { Locale(it) }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Provide default domain and available alternatives, if any.
|
* Provide default domain and available alternatives, if any.
|
||||||
*
|
*
|
||||||
* Never hardcode domain in requests, use [getDomain] instead.
|
* Never hardcode domain in requests, use [getDomain] instead.
|
||||||
*/
|
*/
|
||||||
@InternalParsersApi
|
protected abstract val configKeyDomain: ConfigKey.Domain
|
||||||
abstract val configKeyDomain: ConfigKey.Domain
|
|
||||||
|
|
||||||
open val headers: Headers? = null
|
@VisibleForTesting(otherwise = VisibleForTesting.PROTECTED)
|
||||||
|
internal open val headers: Headers? = null
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Used as fallback if value of `sortOrder` passed to [getList] is null
|
* Used as fallback if value of `sortOrder` passed to [getList] is null
|
||||||
@@ -47,8 +47,6 @@ abstract class MangaParser @InternalParsersApi constructor(
|
|||||||
return SortOrder.values().first { it in supported }
|
return SortOrder.values().first { it in supported }
|
||||||
}
|
}
|
||||||
|
|
||||||
protected val webClient: WebClient = OkHttpWebClient(context.httpClient, source)
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse list of manga by specified criteria
|
* Parse list of manga by specified criteria
|
||||||
*
|
*
|
||||||
@@ -105,22 +103,98 @@ abstract class MangaParser @InternalParsersApi constructor(
|
|||||||
/**
|
/**
|
||||||
* Fetch direct link to the page image.
|
* Fetch direct link to the page image.
|
||||||
*/
|
*/
|
||||||
open suspend fun getPageUrl(page: MangaPage): String = page.url.toAbsoluteUrl(domain)
|
open suspend fun getPageUrl(page: MangaPage): String = page.url.toAbsoluteUrl(getDomain())
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch available tags (genres) for source
|
* Fetch available tags (genres) for source
|
||||||
*/
|
*/
|
||||||
abstract suspend fun getTags(): Set<MangaTag>
|
abstract suspend fun getTags(): Set<MangaTag>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns direct link to the website favicon
|
||||||
|
*/
|
||||||
|
@Deprecated(
|
||||||
|
message = "Use parseFavicons() to get multiple favicons with different size",
|
||||||
|
replaceWith = ReplaceWith("parseFavicons()"),
|
||||||
|
)
|
||||||
|
open fun getFaviconUrl() = "https://${getDomain()}/favicon.ico"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse favicons from the main page of the source`s website
|
* Parse favicons from the main page of the source`s website
|
||||||
*/
|
*/
|
||||||
open suspend fun getFavicons(): Favicons {
|
open suspend fun getFavicons(): Favicons {
|
||||||
return FaviconParser(webClient, domain).parseFavicons()
|
return FaviconParser(context, getDomain(), headers).parseFavicons()
|
||||||
}
|
}
|
||||||
|
|
||||||
@CallSuper
|
@CallSuper
|
||||||
open fun onCreateConfig(keys: MutableCollection<ConfigKey<*>>) {
|
open fun onCreateConfig(keys: MutableCollection<ConfigKey<*>>) {
|
||||||
keys.add(configKeyDomain)
|
keys.add(configKeyDomain)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
/* Utils */
|
||||||
|
|
||||||
|
fun getDomain(): String {
|
||||||
|
return config[configKeyDomain]
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getDomain(subdomain: String): String {
|
||||||
|
val domain = getDomain()
|
||||||
|
return subdomain + "." + domain.removePrefix("www.")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun urlBuilder(): HttpUrl.Builder {
|
||||||
|
return HttpUrl.Builder()
|
||||||
|
.scheme("https")
|
||||||
|
.host(getDomain())
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a unique id for [Manga]/[MangaChapter]/[MangaPage].
|
||||||
|
* @param url must be relative url, without a domain
|
||||||
|
* @see [Manga.id]
|
||||||
|
* @see [MangaChapter.id]
|
||||||
|
* @see [MangaPage.id]
|
||||||
|
*/
|
||||||
|
@InternalParsersApi
|
||||||
|
protected fun generateUid(url: String): Long {
|
||||||
|
var h = 1125899906842597L
|
||||||
|
source.name.forEach { c ->
|
||||||
|
h = 31 * h + c.code
|
||||||
|
}
|
||||||
|
url.forEach { c ->
|
||||||
|
h = 31 * h + c.code
|
||||||
|
}
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a unique id for [Manga]/[MangaChapter]/[MangaPage].
|
||||||
|
* @param id an internal identifier
|
||||||
|
* @see [Manga.id]
|
||||||
|
* @see [MangaChapter.id]
|
||||||
|
* @see [MangaPage.id]
|
||||||
|
*/
|
||||||
|
@InternalParsersApi
|
||||||
|
protected fun generateUid(id: Long): Long {
|
||||||
|
var h = 1125899906842597L
|
||||||
|
source.name.forEach { c ->
|
||||||
|
h = 31 * h + c.code
|
||||||
|
}
|
||||||
|
h = 31 * h + id
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
|
||||||
|
@InternalParsersApi
|
||||||
|
protected fun Element.parseFailed(message: String? = null): Nothing {
|
||||||
|
throw ParseException(message, ownerDocument()?.location() ?: baseUri(), null)
|
||||||
|
}
|
||||||
|
|
||||||
|
@InternalParsersApi
|
||||||
|
protected fun Set<MangaTag>?.oneOrThrowIfMany(): MangaTag? {
|
||||||
|
return when {
|
||||||
|
isNullOrEmpty() -> null
|
||||||
|
size == 1 -> first()
|
||||||
|
else -> throw IllegalArgumentException("Multiple genres are not supported by this source")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,44 +8,43 @@ import org.koitharu.kotatsu.parsers.util.Paginator
|
|||||||
|
|
||||||
@InternalParsersApi
|
@InternalParsersApi
|
||||||
abstract class PagedMangaParser(
|
abstract class PagedMangaParser(
|
||||||
context: MangaLoaderContext,
|
source: MangaSource,
|
||||||
source: MangaSource,
|
pageSize: Int,
|
||||||
pageSize: Int,
|
searchPageSize: Int = pageSize,
|
||||||
searchPageSize: Int = pageSize,
|
) : MangaParser(source) {
|
||||||
) : MangaParser(context, source) {
|
|
||||||
|
|
||||||
protected val paginator = Paginator(pageSize)
|
protected val paginator = Paginator(pageSize)
|
||||||
protected val searchPaginator = Paginator(searchPageSize)
|
protected val searchPaginator = Paginator(searchPageSize)
|
||||||
|
|
||||||
override suspend fun getList(offset: Int, query: String): List<Manga> {
|
override suspend fun getList(offset: Int, query: String): List<Manga> {
|
||||||
return getList(searchPaginator, offset, query, null, defaultSortOrder)
|
return getList(searchPaginator, offset, query, null, defaultSortOrder)
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getList(offset: Int, tags: Set<MangaTag>?, sortOrder: SortOrder?): List<Manga> {
|
override suspend fun getList(offset: Int, tags: Set<MangaTag>?, sortOrder: SortOrder?): List<Manga> {
|
||||||
return getList(paginator, offset, null, tags, sortOrder ?: defaultSortOrder)
|
return getList(paginator, offset, null, tags, sortOrder ?: defaultSortOrder)
|
||||||
}
|
}
|
||||||
|
|
||||||
@InternalParsersApi
|
@InternalParsersApi
|
||||||
@Deprecated("You should use getListPage for PagedMangaParser", level = DeprecationLevel.HIDDEN)
|
@Deprecated("You should use getListPage for PagedMangaParser", level = DeprecationLevel.HIDDEN)
|
||||||
final override suspend fun getList(
|
final override suspend fun getList(
|
||||||
offset: Int,
|
offset: Int,
|
||||||
query: String?,
|
query: String?,
|
||||||
tags: Set<MangaTag>?,
|
tags: Set<MangaTag>?,
|
||||||
sortOrder: SortOrder,
|
sortOrder: SortOrder,
|
||||||
): List<Manga> = throw UnsupportedOperationException("You should use getListPage for PagedMangaParser")
|
): List<Manga> = throw UnsupportedOperationException("You should use getListPage for PagedMangaParser")
|
||||||
|
|
||||||
abstract suspend fun getListPage(page: Int, query: String?, tags: Set<MangaTag>?, sortOrder: SortOrder): List<Manga>
|
abstract suspend fun getListPage(page: Int, query: String?, tags: Set<MangaTag>?, sortOrder: SortOrder): List<Manga>
|
||||||
|
|
||||||
private suspend fun getList(
|
private suspend fun getList(
|
||||||
paginator: Paginator,
|
paginator: Paginator,
|
||||||
offset: Int,
|
offset: Int,
|
||||||
query: String?,
|
query: String?,
|
||||||
tags: Set<MangaTag>?,
|
tags: Set<MangaTag>?,
|
||||||
sortOrder: SortOrder,
|
sortOrder: SortOrder,
|
||||||
): List<Manga> {
|
): List<Manga> {
|
||||||
val page = paginator.getPage(offset)
|
val page = paginator.getPage(offset)
|
||||||
val list = getListPage(page, query, tags, sortOrder)
|
val list = getListPage(page, query, tags, sortOrder)
|
||||||
paginator.onListReceived(offset, page, list.size)
|
paginator.onListReceived(offset, page, list.size)
|
||||||
return list
|
return list
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,21 +1,13 @@
|
|||||||
package org.koitharu.kotatsu.parsers.config
|
package org.koitharu.kotatsu.parsers.config
|
||||||
|
|
||||||
sealed class ConfigKey<T>(
|
sealed class ConfigKey<T>(
|
||||||
@JvmField val key: String,
|
val key: String,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
abstract val defaultValue: T
|
abstract val defaultValue: T
|
||||||
|
|
||||||
class Domain(
|
class Domain(
|
||||||
override val defaultValue: String,
|
override val defaultValue: String,
|
||||||
@JvmField val presetValues: Array<String>?,
|
val presetValues: Array<String>?,
|
||||||
) : ConfigKey<String>("domain")
|
) : ConfigKey<String>("domain")
|
||||||
|
}
|
||||||
class ShowSuspiciousContent(
|
|
||||||
override val defaultValue: Boolean,
|
|
||||||
) : ConfigKey<Boolean>("show_suspicious")
|
|
||||||
|
|
||||||
class UserAgent(
|
|
||||||
override val defaultValue: String,
|
|
||||||
) : ConfigKey<String>("user_agent")
|
|
||||||
}
|
|
||||||
@@ -1,9 +1,7 @@
|
|||||||
package org.koitharu.kotatsu.parsers.exception
|
package org.koitharu.kotatsu.parsers.exception
|
||||||
|
|
||||||
import okhttp3.Headers
|
|
||||||
import okio.IOException
|
import okio.IOException
|
||||||
|
|
||||||
class CloudFlareProtectedException(
|
class CloudFlareProtectedException(
|
||||||
val url: String,
|
val url: String,
|
||||||
val headers: Headers,
|
|
||||||
) : IOException("Protected by CloudFlare: $url")
|
) : IOException("Protected by CloudFlare: $url")
|
||||||
@@ -3,12 +3,11 @@ package org.koitharu.kotatsu.parsers.model
|
|||||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||||
|
|
||||||
class Favicon internal constructor(
|
class Favicon internal constructor(
|
||||||
@JvmField val url: String,
|
val url: String,
|
||||||
@JvmField val size: Int,
|
val size: Int,
|
||||||
@JvmField internal val rel: String?,
|
internal val rel: String?,
|
||||||
) : Comparable<Favicon> {
|
) : Comparable<Favicon> {
|
||||||
|
|
||||||
@JvmField
|
|
||||||
val type: String = url.toHttpUrl().pathSegments.last()
|
val type: String = url.toHttpUrl().pathSegments.last()
|
||||||
.substringAfterLast('.', "").lowercase()
|
.substringAfterLast('.', "").lowercase()
|
||||||
|
|
||||||
@@ -49,4 +48,4 @@ class Favicon internal constructor(
|
|||||||
"mask-icon" -> -1
|
"mask-icon" -> -1
|
||||||
else -> 0
|
else -> 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2,7 +2,7 @@ package org.koitharu.kotatsu.parsers.model
|
|||||||
|
|
||||||
class Favicons internal constructor(
|
class Favicons internal constructor(
|
||||||
favicons: Collection<Favicon>,
|
favicons: Collection<Favicon>,
|
||||||
@JvmField val referer: String,
|
val referer: String,
|
||||||
) : Collection<Favicon> {
|
) : Collection<Favicon> {
|
||||||
|
|
||||||
private val icons = favicons.sortedDescending()
|
private val icons = favicons.sortedDescending()
|
||||||
@@ -42,4 +42,4 @@ class Favicons internal constructor(
|
|||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -6,67 +6,67 @@ class Manga(
|
|||||||
/**
|
/**
|
||||||
* Unique identifier for manga
|
* Unique identifier for manga
|
||||||
*/
|
*/
|
||||||
@JvmField val id: Long,
|
val id: Long,
|
||||||
/**
|
/**
|
||||||
* Manga title, human-readable
|
* Manga title, human-readable
|
||||||
*/
|
*/
|
||||||
@JvmField val title: String,
|
val title: String,
|
||||||
/**
|
/**
|
||||||
* Alternative title (for example on other language), may be null
|
* Alternative title (for example on other language), may be null
|
||||||
*/
|
*/
|
||||||
@JvmField val altTitle: String?,
|
val altTitle: String?,
|
||||||
/**
|
/**
|
||||||
* Relative url to manga (**without** a domain) or any other uri.
|
* Relative url to manga (**without** a domain) or any other uri.
|
||||||
* Used principally in parsers
|
* Used principally in parsers
|
||||||
*/
|
*/
|
||||||
@JvmField val url: String,
|
val url: String,
|
||||||
/**
|
/**
|
||||||
* Absolute url to manga, must be ready to open in browser
|
* Absolute url to manga, must be ready to open in browser
|
||||||
*/
|
*/
|
||||||
@JvmField val publicUrl: String,
|
val publicUrl: String,
|
||||||
/**
|
/**
|
||||||
* Normalized manga rating, must be in range of 0..1 or [RATING_UNKNOWN] if rating s unknown
|
* Normalized manga rating, must be in range of 0..1 or [RATING_UNKNOWN] if rating s unknown
|
||||||
* @see hasRating
|
* @see hasRating
|
||||||
*/
|
*/
|
||||||
@JvmField val rating: Float,
|
val rating: Float,
|
||||||
/**
|
/**
|
||||||
* Indicates that manga may contain sensitive information (18+, NSFW)
|
* Indicates that manga may contain sensitive information (18+, NSFW)
|
||||||
*/
|
*/
|
||||||
@JvmField val isNsfw: Boolean,
|
val isNsfw: Boolean,
|
||||||
/**
|
/**
|
||||||
* Absolute link to the cover
|
* Absolute link to the cover
|
||||||
* @see largeCoverUrl
|
* @see largeCoverUrl
|
||||||
*/
|
*/
|
||||||
@JvmField val coverUrl: String,
|
val coverUrl: String,
|
||||||
/**
|
/**
|
||||||
* Tags (genres) of the manga
|
* Tags (genres) of the manga
|
||||||
*/
|
*/
|
||||||
@JvmField val tags: Set<MangaTag>,
|
val tags: Set<MangaTag>,
|
||||||
/**
|
/**
|
||||||
* Manga status (ongoing, finished) or null if unknown
|
* Manga status (ongoing, finished) or null if unknown
|
||||||
*/
|
*/
|
||||||
@JvmField val state: MangaState?,
|
val state: MangaState?,
|
||||||
/**
|
/**
|
||||||
* Author of the manga, may be null
|
* Author of the manga, may be null
|
||||||
*/
|
*/
|
||||||
@JvmField val author: String?,
|
val author: String?,
|
||||||
/**
|
/**
|
||||||
* Large cover url (absolute), null if is no large cover
|
* Large cover url (absolute), null if is no large cover
|
||||||
* @see coverUrl
|
* @see coverUrl
|
||||||
*/
|
*/
|
||||||
@JvmField val largeCoverUrl: String? = null,
|
val largeCoverUrl: String? = null,
|
||||||
/**
|
/**
|
||||||
* Manga description, may be html or null
|
* Manga description, may be html or null
|
||||||
*/
|
*/
|
||||||
@JvmField val description: String? = null,
|
val description: String? = null,
|
||||||
/**
|
/**
|
||||||
* List of chapters
|
* List of chapters
|
||||||
*/
|
*/
|
||||||
@JvmField val chapters: List<MangaChapter>? = null,
|
val chapters: List<MangaChapter>? = null,
|
||||||
/**
|
/**
|
||||||
* Manga source
|
* Manga source
|
||||||
*/
|
*/
|
||||||
@JvmField val source: MangaSource,
|
val source: MangaSource,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -155,8 +155,4 @@ class Manga(
|
|||||||
result = 31 * result + source.hashCode()
|
result = 31 * result + source.hashCode()
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
}
|
||||||
override fun toString(): String {
|
|
||||||
return "Manga($id - \"$title\" [$url] - $source)"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -4,34 +4,34 @@ class MangaChapter(
|
|||||||
/**
|
/**
|
||||||
* An unique id of chapter
|
* An unique id of chapter
|
||||||
*/
|
*/
|
||||||
@JvmField val id: Long,
|
val id: Long,
|
||||||
/**
|
/**
|
||||||
* User-readable name of chapter
|
* User-readable name of chapter
|
||||||
*/
|
*/
|
||||||
@JvmField val name: String,
|
val name: String,
|
||||||
/**
|
/**
|
||||||
* Chapter number starting from 1
|
* Chapter number starting from 1
|
||||||
*/
|
*/
|
||||||
@JvmField val number: Int,
|
val number: Int,
|
||||||
/**
|
/**
|
||||||
* Relative url to chapter (**without** a domain) or any other uri.
|
* Relative url to chapter (**without** a domain) or any other uri.
|
||||||
* Used principally in parsers
|
* Used principally in parsers
|
||||||
*/
|
*/
|
||||||
@JvmField val url: String,
|
val url: String,
|
||||||
/**
|
/**
|
||||||
* User-readable name of scanlator (releaser) or null if unknown
|
* User-readable name of scanlator (releaser) or null if unknown
|
||||||
*/
|
*/
|
||||||
@JvmField val scanlator: String?,
|
val scanlator: String?,
|
||||||
/**
|
/**
|
||||||
* Chapter upload date in milliseconds
|
* Chapter upload date in milliseconds
|
||||||
*/
|
*/
|
||||||
@JvmField val uploadDate: Long,
|
val uploadDate: Long,
|
||||||
/**
|
/**
|
||||||
* User-readable name of branch.
|
* User-readable name of branch.
|
||||||
* A branch is a group of chapters that overlap (e.g. different languages)
|
* A branch is a group of chapters that overlap (e.g. different languages)
|
||||||
*/
|
*/
|
||||||
@JvmField val branch: String?,
|
val branch: String?,
|
||||||
@JvmField val source: MangaSource,
|
val source: MangaSource,
|
||||||
) : Comparable<MangaChapter> {
|
) : Comparable<MangaChapter> {
|
||||||
|
|
||||||
override fun compareTo(other: MangaChapter): Int {
|
override fun compareTo(other: MangaChapter): Int {
|
||||||
@@ -67,8 +67,4 @@ class MangaChapter(
|
|||||||
result = 31 * result + source.hashCode()
|
result = 31 * result + source.hashCode()
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
}
|
||||||
override fun toString(): String {
|
|
||||||
return "MangaChapter($id - #$number [$url] - $source)"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -6,19 +6,24 @@ class MangaPage(
|
|||||||
/**
|
/**
|
||||||
* Unique identifier for manga
|
* Unique identifier for manga
|
||||||
*/
|
*/
|
||||||
@JvmField val id: Long,
|
val id: Long,
|
||||||
/**
|
/**
|
||||||
* Relative url to page (**without** a domain) or any other uri.
|
* Relative url to page (**without** a domain) or any other uri.
|
||||||
* Used principally in parsers.
|
* Used principally in parsers.
|
||||||
* May contain link to image or html page.
|
* May contain link to image or html page.
|
||||||
* @see MangaParser.getPageUrl
|
* @see MangaParser.getPageUrl
|
||||||
*/
|
*/
|
||||||
@JvmField val url: String,
|
val url: String,
|
||||||
|
/**
|
||||||
|
* Absolute link to the chapter or website home page.
|
||||||
|
* Used in Referer header
|
||||||
|
*/
|
||||||
|
val referer: String,
|
||||||
/**
|
/**
|
||||||
* Absolute url of the small page image if exists, null otherwise
|
* Absolute url of the small page image if exists, null otherwise
|
||||||
*/
|
*/
|
||||||
@JvmField val preview: String?,
|
val preview: String?,
|
||||||
@JvmField val source: MangaSource,
|
val source: MangaSource,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
override fun equals(other: Any?): Boolean {
|
override fun equals(other: Any?): Boolean {
|
||||||
@@ -29,19 +34,19 @@ class MangaPage(
|
|||||||
|
|
||||||
if (id != other.id) return false
|
if (id != other.id) return false
|
||||||
if (url != other.url) return false
|
if (url != other.url) return false
|
||||||
|
if (referer != other.referer) return false
|
||||||
if (preview != other.preview) return false
|
if (preview != other.preview) return false
|
||||||
return source == other.source
|
if (source != other.source) return false
|
||||||
|
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun hashCode(): Int {
|
override fun hashCode(): Int {
|
||||||
var result = id.hashCode()
|
var result = id.hashCode()
|
||||||
result = 31 * result + url.hashCode()
|
result = 31 * result + url.hashCode()
|
||||||
|
result = 31 * result + referer.hashCode()
|
||||||
result = 31 * result + (preview?.hashCode() ?: 0)
|
result = 31 * result + (preview?.hashCode() ?: 0)
|
||||||
result = 31 * result + source.hashCode()
|
result = 31 * result + source.hashCode()
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
}
|
||||||
override fun toString(): String {
|
|
||||||
return "MangaPage($id [$url] - $source)"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -6,13 +6,13 @@ class MangaTag(
|
|||||||
/**
|
/**
|
||||||
* User-readable tag title, should be in Title case
|
* User-readable tag title, should be in Title case
|
||||||
*/
|
*/
|
||||||
@JvmField val title: String,
|
val title: String,
|
||||||
/**
|
/**
|
||||||
* Identifier of a tag, must be unique among the source.
|
* Identifier of a tag, must be unique among the source.
|
||||||
* @see MangaParser.getList
|
* @see MangaParser.getList
|
||||||
*/
|
*/
|
||||||
@JvmField val key: String,
|
val key: String,
|
||||||
@JvmField val source: MangaSource,
|
val source: MangaSource,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
override fun equals(other: Any?): Boolean {
|
override fun equals(other: Any?): Boolean {
|
||||||
@@ -34,8 +34,4 @@ class MangaTag(
|
|||||||
result = 31 * result + source.hashCode()
|
result = 31 * result + source.hashCode()
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
}
|
||||||
override fun toString(): String {
|
|
||||||
return "MangaTag($key \"$title\" - $source)"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,118 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.parsers.network
|
|
||||||
|
|
||||||
import okhttp3.*
|
|
||||||
import okhttp3.MediaType.Companion.toMediaType
|
|
||||||
import okhttp3.RequestBody.Companion.toRequestBody
|
|
||||||
import org.json.JSONObject
|
|
||||||
import org.jsoup.HttpStatusException
|
|
||||||
import org.koitharu.kotatsu.parsers.exception.AuthRequiredException
|
|
||||||
import org.koitharu.kotatsu.parsers.exception.GraphQLException
|
|
||||||
import org.koitharu.kotatsu.parsers.exception.NotFoundException
|
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
|
||||||
import org.koitharu.kotatsu.parsers.util.await
|
|
||||||
import org.koitharu.kotatsu.parsers.util.parseJson
|
|
||||||
import java.net.HttpURLConnection
|
|
||||||
|
|
||||||
class OkHttpWebClient(
|
|
||||||
private val httpClient: OkHttpClient,
|
|
||||||
private val mangaSource: MangaSource,
|
|
||||||
) : WebClient {
|
|
||||||
|
|
||||||
override suspend fun httpGet(url: HttpUrl, extraHeaders: Headers?): Response {
|
|
||||||
val request = Request.Builder()
|
|
||||||
.get()
|
|
||||||
.url(url)
|
|
||||||
.addTags()
|
|
||||||
if (extraHeaders != null) {
|
|
||||||
request.headers(extraHeaders)
|
|
||||||
}
|
|
||||||
return httpClient.newCall(request.build()).await().ensureSuccess()
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun httpHead(url: HttpUrl): Response {
|
|
||||||
val request = Request.Builder()
|
|
||||||
.head()
|
|
||||||
.url(url)
|
|
||||||
.addTags()
|
|
||||||
return httpClient.newCall(request.build()).await().ensureSuccess()
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun httpPost(url: HttpUrl, form: Map<String, String>): Response {
|
|
||||||
val body = FormBody.Builder()
|
|
||||||
form.forEach { (k, v) ->
|
|
||||||
body.addEncoded(k, v)
|
|
||||||
}
|
|
||||||
val request = Request.Builder()
|
|
||||||
.post(body.build())
|
|
||||||
.url(url)
|
|
||||||
.addTags()
|
|
||||||
return httpClient.newCall(request.build()).await().ensureSuccess()
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun httpPost(url: HttpUrl, payload: String): Response {
|
|
||||||
val body = FormBody.Builder()
|
|
||||||
payload.split('&').forEach {
|
|
||||||
val pos = it.indexOf('=')
|
|
||||||
if (pos != -1) {
|
|
||||||
val k = it.substring(0, pos)
|
|
||||||
val v = it.substring(pos + 1)
|
|
||||||
body.addEncoded(k, v)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val request = Request.Builder()
|
|
||||||
.post(body.build())
|
|
||||||
.url(url)
|
|
||||||
.addTags()
|
|
||||||
return httpClient.newCall(request.build()).await().ensureSuccess()
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun httpPost(url: HttpUrl, body: JSONObject): Response {
|
|
||||||
val mediaType = "application/json; charset=utf-8".toMediaType()
|
|
||||||
val requestBody = body.toString().toRequestBody(mediaType)
|
|
||||||
val request = Request.Builder()
|
|
||||||
.post(requestBody)
|
|
||||||
.url(url)
|
|
||||||
.addTags()
|
|
||||||
return httpClient.newCall(request.build()).await().ensureSuccess()
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun graphQLQuery(endpoint: String, query: String): JSONObject {
|
|
||||||
val body = JSONObject()
|
|
||||||
body.put("operationName", null as Any?)
|
|
||||||
body.put("variables", JSONObject())
|
|
||||||
body.put("query", "{$query}")
|
|
||||||
val json = httpPost(endpoint, body).parseJson()
|
|
||||||
json.optJSONArray("errors")?.let {
|
|
||||||
if (it.length() != 0) {
|
|
||||||
throw GraphQLException(it)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return json
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun Request.Builder.addTags(): Request.Builder {
|
|
||||||
tag(MangaSource::class.java, mangaSource)
|
|
||||||
return this
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun Response.ensureSuccess(): Response {
|
|
||||||
val exception: Exception? = when (code) { // Catch some error codes, not all
|
|
||||||
HttpURLConnection.HTTP_NOT_FOUND -> NotFoundException(message, request.url.toString())
|
|
||||||
HttpURLConnection.HTTP_UNAUTHORIZED -> request.tag(MangaSource::class.java)?.let {
|
|
||||||
AuthRequiredException(it)
|
|
||||||
} ?: HttpStatusException(message, code, request.url.toString())
|
|
||||||
|
|
||||||
in 400..599 -> HttpStatusException(message, code, request.url.toString())
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
if (exception != null) {
|
|
||||||
runCatching {
|
|
||||||
close()
|
|
||||||
}.onFailure {
|
|
||||||
exception.addSuppressed(it)
|
|
||||||
}
|
|
||||||
throw exception
|
|
||||||
}
|
|
||||||
return this
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.parsers.network
|
|
||||||
|
|
||||||
import okhttp3.Headers
|
|
||||||
import okhttp3.HttpUrl
|
|
||||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
|
||||||
import okhttp3.Response
|
|
||||||
import org.json.JSONObject
|
|
||||||
|
|
||||||
interface WebClient {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Do a GET http request to specific url
|
|
||||||
* @param url
|
|
||||||
*/
|
|
||||||
suspend fun httpGet(url: String): Response = httpGet(url.toHttpUrl())
|
|
||||||
|
|
||||||
suspend fun httpGet(url: String, extraHeaders: Headers?): Response = httpGet(url.toHttpUrl(), extraHeaders)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Do a GET http request to specific url
|
|
||||||
* @param url
|
|
||||||
*/
|
|
||||||
suspend fun httpGet(url: HttpUrl): Response = httpGet(url, null)
|
|
||||||
|
|
||||||
suspend fun httpGet(url: HttpUrl, extraHeaders: Headers?): Response
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Do a HEAD http request to specific url
|
|
||||||
* @param url
|
|
||||||
*/
|
|
||||||
suspend fun httpHead(url: String): Response = httpHead(url.toHttpUrl())
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Do a HEAD http request to specific url
|
|
||||||
* @param url
|
|
||||||
*/
|
|
||||||
suspend fun httpHead(url: HttpUrl): Response
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Do a POST http request to specific url with `multipart/form-data` payload
|
|
||||||
* @param url
|
|
||||||
* @param form payload as key=>value map
|
|
||||||
*/
|
|
||||||
suspend fun httpPost(url: String, form: Map<String, String>): Response = httpPost(url.toHttpUrl(), form)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Do a POST http request to specific url with `multipart/form-data` payload
|
|
||||||
* @param url
|
|
||||||
* @param form payload as key=>value map
|
|
||||||
*/
|
|
||||||
suspend fun httpPost(url: HttpUrl, form: Map<String, String>): Response
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Do a POST http request to specific url with `multipart/form-data` payload
|
|
||||||
* @param url
|
|
||||||
* @param payload payload as `key=value` string with `&` separator
|
|
||||||
*/
|
|
||||||
suspend fun httpPost(url: String, payload: String): Response = httpPost(url.toHttpUrl(), payload)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Do a POST http request to specific url with `multipart/form-data` payload
|
|
||||||
* @param url
|
|
||||||
* @param payload payload as `key=value` string with `&` separator
|
|
||||||
*/
|
|
||||||
suspend fun httpPost(url: HttpUrl, payload: String): Response
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Do a POST http request to specific url with json payload
|
|
||||||
* @param url
|
|
||||||
* @param body
|
|
||||||
*/
|
|
||||||
suspend fun httpPost(url: String, body: JSONObject): Response = httpPost(url.toHttpUrl(), body)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Do a POST http request to specific url with json payload
|
|
||||||
* @param url
|
|
||||||
* @param body
|
|
||||||
*/
|
|
||||||
suspend fun httpPost(url: HttpUrl, body: JSONObject): Response
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Do a GraphQL request to specific url
|
|
||||||
* @param endpoint an url
|
|
||||||
* @param query GraphQL request payload
|
|
||||||
*/
|
|
||||||
suspend fun graphQLQuery(endpoint: String, query: String): JSONObject
|
|
||||||
}
|
|
||||||
@@ -8,9 +8,6 @@ import org.koitharu.kotatsu.parsers.MangaParser
|
|||||||
import org.koitharu.kotatsu.parsers.MangaSourceParser
|
import org.koitharu.kotatsu.parsers.MangaSourceParser
|
||||||
import org.koitharu.kotatsu.parsers.config.ConfigKey
|
import org.koitharu.kotatsu.parsers.config.ConfigKey
|
||||||
import org.koitharu.kotatsu.parsers.model.*
|
import org.koitharu.kotatsu.parsers.model.*
|
||||||
import org.koitharu.kotatsu.parsers.util.domain
|
|
||||||
import org.koitharu.kotatsu.parsers.util.generateUid
|
|
||||||
import org.koitharu.kotatsu.parsers.util.getDomain
|
|
||||||
import org.koitharu.kotatsu.parsers.util.json.mapJSON
|
import org.koitharu.kotatsu.parsers.util.json.mapJSON
|
||||||
import org.koitharu.kotatsu.parsers.util.json.mapJSONIndexed
|
import org.koitharu.kotatsu.parsers.util.json.mapJSONIndexed
|
||||||
import org.koitharu.kotatsu.parsers.util.json.stringIterator
|
import org.koitharu.kotatsu.parsers.util.json.stringIterator
|
||||||
@@ -18,7 +15,7 @@ import org.koitharu.kotatsu.parsers.util.toAbsoluteUrl
|
|||||||
import java.util.*
|
import java.util.*
|
||||||
|
|
||||||
@MangaSourceParser("ANIBEL", "Anibel", "be")
|
@MangaSourceParser("ANIBEL", "Anibel", "be")
|
||||||
internal class AnibelParser(context: MangaLoaderContext) : MangaParser(context, MangaSource.ANIBEL) {
|
internal class AnibelParser(override val context: MangaLoaderContext) : MangaParser(MangaSource.ANIBEL) {
|
||||||
|
|
||||||
override val configKeyDomain = ConfigKey.Domain("anibel.net", null)
|
override val configKeyDomain = ConfigKey.Domain("anibel.net", null)
|
||||||
|
|
||||||
@@ -26,6 +23,10 @@ internal class AnibelParser(context: MangaLoaderContext) : MangaParser(context,
|
|||||||
SortOrder.NEWEST,
|
SortOrder.NEWEST,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
override fun getFaviconUrl(): String {
|
||||||
|
return "https://cdn.${getDomain()}/favicons/favicon.png"
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun getList(
|
override suspend fun getList(
|
||||||
offset: Int,
|
offset: Int,
|
||||||
query: String?,
|
query: String?,
|
||||||
@@ -77,7 +78,7 @@ internal class AnibelParser(context: MangaLoaderContext) : MangaParser(context,
|
|||||||
isNsfw = false,
|
isNsfw = false,
|
||||||
rating = jo.getDouble("rating").toFloat() / 10f,
|
rating = jo.getDouble("rating").toFloat() / 10f,
|
||||||
url = href,
|
url = href,
|
||||||
publicUrl = "https://${domain}/$href",
|
publicUrl = "https://${getDomain()}/$href",
|
||||||
tags = jo.getJSONArray("genres").mapToTags(),
|
tags = jo.getJSONArray("genres").mapToTags(),
|
||||||
state = when (jo.getString("status")) {
|
state = when (jo.getString("status")) {
|
||||||
"ongoing" -> MangaState.ONGOING
|
"ongoing" -> MangaState.ONGOING
|
||||||
@@ -164,11 +165,12 @@ internal class AnibelParser(context: MangaLoaderContext) : MangaParser(context,
|
|||||||
""".trimIndent(),
|
""".trimIndent(),
|
||||||
).getJSONObject("chapter")
|
).getJSONObject("chapter")
|
||||||
val pages = chapterJson.getJSONArray("images")
|
val pages = chapterJson.getJSONArray("images")
|
||||||
val chapterUrl = "https://${domain}/${chapter.url}"
|
val chapterUrl = "https://${getDomain()}/${chapter.url}"
|
||||||
return pages.mapJSONIndexed { i, jo ->
|
return pages.mapJSONIndexed { i, jo ->
|
||||||
MangaPage(
|
MangaPage(
|
||||||
id = generateUid("${chapter.url}/$i"),
|
id = generateUid("${chapter.url}/$i"),
|
||||||
url = jo.getString("large"),
|
url = jo.getString("large"),
|
||||||
|
referer = chapterUrl,
|
||||||
preview = jo.getString("thumbnail"),
|
preview = jo.getString("thumbnail"),
|
||||||
source = source,
|
source = source,
|
||||||
)
|
)
|
||||||
@@ -217,7 +219,7 @@ internal class AnibelParser(context: MangaLoaderContext) : MangaParser(context,
|
|||||||
isNsfw = false,
|
isNsfw = false,
|
||||||
rating = RATING_UNKNOWN,
|
rating = RATING_UNKNOWN,
|
||||||
url = href,
|
url = href,
|
||||||
publicUrl = "https://${domain}/$href",
|
publicUrl = "https://${getDomain()}/$href",
|
||||||
tags = emptySet(),
|
tags = emptySet(),
|
||||||
state = null,
|
state = null,
|
||||||
source = source,
|
source = source,
|
||||||
@@ -226,7 +228,7 @@ internal class AnibelParser(context: MangaLoaderContext) : MangaParser(context,
|
|||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun apiCall(request: String): JSONObject {
|
private suspend fun apiCall(request: String): JSONObject {
|
||||||
return webClient.graphQLQuery("https://api.${domain}/graphql", request)
|
return context.graphQLQuery("https://api.${getDomain()}/graphql", request)
|
||||||
.getJSONObject("data")
|
.getJSONObject("data")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -240,7 +242,6 @@ internal class AnibelParser(context: MangaLoaderContext) : MangaParser(context,
|
|||||||
c == '-' -> {
|
c == '-' -> {
|
||||||
builder.setCharAt(i, ' ')
|
builder.setCharAt(i, ' ')
|
||||||
}
|
}
|
||||||
|
|
||||||
capitalize -> {
|
capitalize -> {
|
||||||
builder.setCharAt(i, c.uppercaseChar())
|
builder.setCharAt(i, c.uppercaseChar())
|
||||||
capitalize = false
|
capitalize = false
|
||||||
@@ -262,4 +263,4 @@ internal class AnibelParser(context: MangaLoaderContext) : MangaParser(context,
|
|||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -19,8 +19,7 @@ import javax.crypto.spec.IvParameterSpec
|
|||||||
import javax.crypto.spec.SecretKeySpec
|
import javax.crypto.spec.SecretKeySpec
|
||||||
|
|
||||||
@MangaSourceParser("BATOTO", "Bato.To")
|
@MangaSourceParser("BATOTO", "Bato.To")
|
||||||
internal class BatoToParser(context: MangaLoaderContext) : PagedMangaParser(
|
internal class BatoToParser(override val context: MangaLoaderContext) : PagedMangaParser(
|
||||||
context = context,
|
|
||||||
source = MangaSource.BATOTO,
|
source = MangaSource.BATOTO,
|
||||||
pageSize = 60,
|
pageSize = 60,
|
||||||
searchPageSize = 20,
|
searchPageSize = 20,
|
||||||
@@ -35,16 +34,7 @@ internal class BatoToParser(context: MangaLoaderContext) : PagedMangaParser(
|
|||||||
|
|
||||||
override val configKeyDomain = ConfigKey.Domain(
|
override val configKeyDomain = ConfigKey.Domain(
|
||||||
"bato.to",
|
"bato.to",
|
||||||
arrayOf(
|
arrayOf("bato.to", "mto.to", "mangatoto.com", "battwo.com", "batotwo.com", "comiko.net", "batotoo.com"),
|
||||||
"bato.to",
|
|
||||||
"mto.to",
|
|
||||||
"hto.to",
|
|
||||||
"mangatoto.com",
|
|
||||||
"battwo.com",
|
|
||||||
"batotwo.com",
|
|
||||||
"comiko.net",
|
|
||||||
"batotoo.com",
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
override suspend fun getListPage(
|
override suspend fun getListPage(
|
||||||
@@ -59,7 +49,7 @@ internal class BatoToParser(context: MangaLoaderContext) : PagedMangaParser(
|
|||||||
@Suppress("NON_EXHAUSTIVE_WHEN_STATEMENT")
|
@Suppress("NON_EXHAUSTIVE_WHEN_STATEMENT")
|
||||||
val url = buildString {
|
val url = buildString {
|
||||||
append("https://")
|
append("https://")
|
||||||
append(domain)
|
append(getDomain())
|
||||||
append("/browse?sort=")
|
append("/browse?sort=")
|
||||||
when (sortOrder) {
|
when (sortOrder) {
|
||||||
SortOrder.UPDATED,
|
SortOrder.UPDATED,
|
||||||
@@ -80,7 +70,7 @@ internal class BatoToParser(context: MangaLoaderContext) : PagedMangaParser(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getDetails(manga: Manga): Manga {
|
override suspend fun getDetails(manga: Manga): Manga {
|
||||||
val root = webClient.httpGet(manga.url.toAbsoluteUrl(domain)).parseHtml()
|
val root = context.httpGet(manga.url.toAbsoluteUrl(getDomain())).parseHtml()
|
||||||
.requireElementById("mainer")
|
.requireElementById("mainer")
|
||||||
val details = root.selectFirstOrThrow(".detail-set")
|
val details = root.selectFirstOrThrow(".detail-set")
|
||||||
val attrs = details.selectFirst(".attr-main")?.select(".attr-item")?.associate {
|
val attrs = details.selectFirst(".attr-main")?.select(".attr-item")?.associate {
|
||||||
@@ -103,15 +93,16 @@ internal class BatoToParser(context: MangaLoaderContext) : PagedMangaParser(
|
|||||||
chapters = root.selectFirst(".episode-list")
|
chapters = root.selectFirst(".episode-list")
|
||||||
?.selectFirst(".main")
|
?.selectFirst(".main")
|
||||||
?.children()
|
?.children()
|
||||||
?.mapChapters(reversed = true) { i, div ->
|
?.reversed()
|
||||||
|
?.mapChapters { i, div ->
|
||||||
div.parseChapter(i)
|
div.parseChapter(i)
|
||||||
}.orEmpty(),
|
}.orEmpty(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
|
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
|
||||||
val fullUrl = chapter.url.toAbsoluteUrl(domain)
|
val fullUrl = chapter.url.toAbsoluteUrl(getDomain())
|
||||||
val scripts = webClient.httpGet(fullUrl).parseHtml().select("script")
|
val scripts = context.httpGet(fullUrl).parseHtml().select("script")
|
||||||
for (script in scripts) {
|
for (script in scripts) {
|
||||||
val scriptSrc = script.html()
|
val scriptSrc = script.html()
|
||||||
val p = scriptSrc.indexOf("const imgHttpLis =")
|
val p = scriptSrc.indexOf("const imgHttpLis =")
|
||||||
@@ -135,6 +126,7 @@ internal class BatoToParser(context: MangaLoaderContext) : PagedMangaParser(
|
|||||||
result += MangaPage(
|
result += MangaPage(
|
||||||
id = generateUid(url),
|
id = generateUid(url),
|
||||||
url = url + "?" + args.getString(i),
|
url = url + "?" + args.getString(i),
|
||||||
|
referer = fullUrl,
|
||||||
preview = null,
|
preview = null,
|
||||||
source = source,
|
source = source,
|
||||||
)
|
)
|
||||||
@@ -145,8 +137,8 @@ internal class BatoToParser(context: MangaLoaderContext) : PagedMangaParser(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getTags(): Set<MangaTag> {
|
override suspend fun getTags(): Set<MangaTag> {
|
||||||
val scripts = webClient.httpGet(
|
val scripts = context.httpGet(
|
||||||
"https://${domain}/browse",
|
"https://${getDomain()}/browse",
|
||||||
).parseHtml().selectOrThrow("script")
|
).parseHtml().selectOrThrow("script")
|
||||||
for (script in scripts) {
|
for (script in scripts) {
|
||||||
val genres = script.html().substringBetweenFirst("const _genres =", ";") ?: continue
|
val genres = script.html().substringBetweenFirst("const _genres =", ";") ?: continue
|
||||||
@@ -165,10 +157,12 @@ internal class BatoToParser(context: MangaLoaderContext) : PagedMangaParser(
|
|||||||
throw ParseException("Cannot find gernes list", scripts[0].baseUri())
|
throw ParseException("Cannot find gernes list", scripts[0].baseUri())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun getFaviconUrl(): String = "https://styles.amarkcdn.com/img/batoto/favicon.ico?v0"
|
||||||
|
|
||||||
private suspend fun search(page: Int, query: String): List<Manga> {
|
private suspend fun search(page: Int, query: String): List<Manga> {
|
||||||
val url = buildString {
|
val url = buildString {
|
||||||
append("https://")
|
append("https://")
|
||||||
append(domain)
|
append(getDomain())
|
||||||
append("/search?word=")
|
append("/search?word=")
|
||||||
append(query.replace(' ', '+'))
|
append(query.replace(' ', '+'))
|
||||||
append("&page=")
|
append("&page=")
|
||||||
@@ -183,7 +177,7 @@ internal class BatoToParser(context: MangaLoaderContext) : PagedMangaParser(
|
|||||||
?.toIntOrNull() ?: body.parseFailed("Cannot determine current page")
|
?.toIntOrNull() ?: body.parseFailed("Cannot determine current page")
|
||||||
|
|
||||||
private suspend fun parseList(url: String, page: Int): List<Manga> {
|
private suspend fun parseList(url: String, page: Int): List<Manga> {
|
||||||
val body = webClient.httpGet(url).parseHtml().body()
|
val body = context.httpGet(url).parseHtml().body()
|
||||||
if (body.selectFirst(".browse-no-matches") != null) {
|
if (body.selectFirst(".browse-no-matches") != null) {
|
||||||
return emptyList()
|
return emptyList()
|
||||||
}
|
}
|
||||||
@@ -313,4 +307,4 @@ internal class BatoToParser(context: MangaLoaderContext) : PagedMangaParser(
|
|||||||
} else byteArrayOf(),
|
} else byteArrayOf(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,240 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.parsers.site
|
|
||||||
|
|
||||||
import kotlinx.coroutines.async
|
|
||||||
import kotlinx.coroutines.awaitAll
|
|
||||||
import kotlinx.coroutines.coroutineScope
|
|
||||||
import okhttp3.Headers
|
|
||||||
import org.jsoup.nodes.Element
|
|
||||||
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
|
||||||
import org.koitharu.kotatsu.parsers.MangaSourceParser
|
|
||||||
import org.koitharu.kotatsu.parsers.PagedMangaParser
|
|
||||||
import org.koitharu.kotatsu.parsers.config.ConfigKey
|
|
||||||
import org.koitharu.kotatsu.parsers.exception.ParseException
|
|
||||||
import org.koitharu.kotatsu.parsers.model.*
|
|
||||||
import org.koitharu.kotatsu.parsers.util.*
|
|
||||||
import org.koitharu.kotatsu.parsers.util.json.getIntOrDefault
|
|
||||||
import java.util.*
|
|
||||||
|
|
||||||
@MangaSourceParser("BENTOMANGA", "Bentomanga", "fr")
|
|
||||||
internal class BentomangaParser(context: MangaLoaderContext) : PagedMangaParser(context, MangaSource.BENTOMANGA, 10) {
|
|
||||||
|
|
||||||
override val sortOrders: Set<SortOrder> = EnumSet.of(
|
|
||||||
SortOrder.UPDATED,
|
|
||||||
SortOrder.POPULARITY,
|
|
||||||
SortOrder.RATING,
|
|
||||||
SortOrder.NEWEST,
|
|
||||||
SortOrder.ALPHABETICAL,
|
|
||||||
)
|
|
||||||
|
|
||||||
override val configKeyDomain = ConfigKey.Domain("www.bentomanga.com", null)
|
|
||||||
|
|
||||||
override val headers: Headers = Headers.Builder()
|
|
||||||
.add("User-Agent", "Mozilla/5.0 (X11; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/113.0")
|
|
||||||
.build()
|
|
||||||
|
|
||||||
init {
|
|
||||||
paginator.firstPage = 0
|
|
||||||
searchPaginator.firstPage = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun getListPage(
|
|
||||||
page: Int,
|
|
||||||
query: String?,
|
|
||||||
tags: Set<MangaTag>?,
|
|
||||||
sortOrder: SortOrder,
|
|
||||||
): List<Manga> {
|
|
||||||
val url = urlBuilder()
|
|
||||||
.addPathSegment("manga_list")
|
|
||||||
.addQueryParameter("limit", page.toString())
|
|
||||||
.addQueryParameter(
|
|
||||||
"order_by",
|
|
||||||
when (sortOrder) {
|
|
||||||
SortOrder.UPDATED -> "update"
|
|
||||||
SortOrder.POPULARITY -> "views"
|
|
||||||
SortOrder.RATING -> "top"
|
|
||||||
SortOrder.NEWEST -> "create"
|
|
||||||
SortOrder.ALPHABETICAL -> "name"
|
|
||||||
},
|
|
||||||
)
|
|
||||||
if (!tags.isNullOrEmpty()) {
|
|
||||||
url.addQueryParameter("withCategories", tags.joinToString(",") { it.key })
|
|
||||||
}
|
|
||||||
if (!query.isNullOrEmpty()) {
|
|
||||||
url.addQueryParameter("search", query)
|
|
||||||
}
|
|
||||||
val root = webClient.httpGet(url.build()).parseHtml().requireElementById("mangas_content")
|
|
||||||
return root.select(".manga[data-manga]").map { div ->
|
|
||||||
val header = div.selectFirstOrThrow(".manga_header")
|
|
||||||
val href = header.selectFirstOrThrow("a").attrAsRelativeUrl("href")
|
|
||||||
Manga(
|
|
||||||
id = generateUid(href),
|
|
||||||
title = div.selectFirstOrThrow("h1").text(),
|
|
||||||
altTitle = null,
|
|
||||||
url = href,
|
|
||||||
publicUrl = href.toAbsoluteUrl(domain),
|
|
||||||
rating = div.getElementsByAttributeValue("data-icon", "avg_rate")
|
|
||||||
.firstOrNull()
|
|
||||||
?.textOrNull()
|
|
||||||
?.toFloatOrNull()
|
|
||||||
?.div(10f)
|
|
||||||
?: RATING_UNKNOWN,
|
|
||||||
isNsfw = div.selectFirst(".badge-adult_content") != null,
|
|
||||||
coverUrl = div.selectFirstOrThrow("img").src(),
|
|
||||||
tags = div.selectFirst(".component-manga-categories")
|
|
||||||
.assertNotNull("tags")
|
|
||||||
?.select("a")
|
|
||||||
?.mapToSet { a ->
|
|
||||||
MangaTag(
|
|
||||||
title = a.text().toTitleCase(sourceLocale),
|
|
||||||
key = a.attr("href").substringAfterLast('='),
|
|
||||||
source = source,
|
|
||||||
)
|
|
||||||
}.orEmpty(),
|
|
||||||
state = null,
|
|
||||||
author = null,
|
|
||||||
description = div.selectFirst(".manga_synopsis")?.html().assertNotNull("description"),
|
|
||||||
source = source,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun getDetails(manga: Manga): Manga {
|
|
||||||
val mangaUrl = manga.url.toAbsoluteUrl(domain)
|
|
||||||
val root = webClient.httpGet(mangaUrl).parseHtml()
|
|
||||||
.requireElementById("container_manga_show")
|
|
||||||
return manga.copy(
|
|
||||||
altTitle = root.selectFirst(".component-manga-title_alt")?.textOrNull().assertNotNull("altTitle"),
|
|
||||||
description = root.selectFirst(".datas_synopsis")?.html().assertNotNull("description")
|
|
||||||
?: manga.description,
|
|
||||||
state = when (root.selectFirst(".datas_more-status-data")?.textOrNull().assertNotNull("status")) {
|
|
||||||
"En cours" -> MangaState.ONGOING
|
|
||||||
else -> null
|
|
||||||
},
|
|
||||||
author = root.selectFirst(".datas_more-authors-people")?.textOrNull().assertNotNull("author"),
|
|
||||||
chapters = run {
|
|
||||||
val input = root.selectFirst("input[name=\"limit\"]") ?: return@run parseChapters(root)
|
|
||||||
val max = input.attr("max").toInt()
|
|
||||||
if (max <= 1) {
|
|
||||||
parseChapters(root)
|
|
||||||
} else {
|
|
||||||
coroutineScope {
|
|
||||||
val result = ArrayList<MangaChapter>(parseChapters(root))
|
|
||||||
result.ensureCapacity(result.size * max)
|
|
||||||
(2..max).map { i ->
|
|
||||||
async {
|
|
||||||
loadChapters(mangaUrl, i)
|
|
||||||
}
|
|
||||||
}.awaitAll()
|
|
||||||
.flattenTo(result)
|
|
||||||
result
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}.reversed(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
|
|
||||||
val chapterUrl = chapter.url.toAbsoluteUrl(domain)
|
|
||||||
val doc = webClient.httpGet(chapterUrl).parseHtml()
|
|
||||||
val chapterId = doc.head().getElementsByAttribute("data-chapter-id").first()!!.attr("data-chapter-id")
|
|
||||||
val json = webClient.httpGet(
|
|
||||||
"https://$domain/api/?id=$chapterId&type=chapter",
|
|
||||||
Headers.headersOf(
|
|
||||||
"Referer", chapterUrl,
|
|
||||||
"x-requested-with", "XMLHttpRequest",
|
|
||||||
),
|
|
||||||
).parseJson()
|
|
||||||
if (json.getIntOrDefault("type", 1) == 2) {
|
|
||||||
throw ParseException("Light Novels are not supported", chapterUrl)
|
|
||||||
}
|
|
||||||
val baseUrl = json.getString("baseImagesUrl")
|
|
||||||
val pages = json.getJSONArray("page_array")
|
|
||||||
return (0 until pages.length()).map { i ->
|
|
||||||
val url = concatUrl(baseUrl, pages.getString(i))
|
|
||||||
MangaPage(
|
|
||||||
id = generateUid(url),
|
|
||||||
url = url.toAbsoluteUrl(domain),
|
|
||||||
preview = null,
|
|
||||||
source = source,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun getTags(): Set<MangaTag> {
|
|
||||||
val root = webClient.httpGet(urlBuilder().addPathSegment("manga_list").build())
|
|
||||||
.parseHtml()
|
|
||||||
.requireElementById("search_options-form")
|
|
||||||
return root.getElementsByAttributeValue("name", "categories[]")
|
|
||||||
.mapToSet { input ->
|
|
||||||
val div = input.parents().first()!!
|
|
||||||
MangaTag(
|
|
||||||
title = div.text().toTitleCase(sourceLocale),
|
|
||||||
key = input.attr("value"),
|
|
||||||
source = source,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun loadChapters(baseUrl: String, page: Int): List<MangaChapter> {
|
|
||||||
return parseChapters(webClient.httpGet("$baseUrl?limit=$page").parseHtml().body())
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun parseChapters(root: Element): List<MangaChapter> {
|
|
||||||
return root.requireElementById("chapters_content")
|
|
||||||
.select(".component-chapter").map { div ->
|
|
||||||
val a = div.selectFirstOrThrow("a")
|
|
||||||
val href = a.attrAsRelativeUrl("href")
|
|
||||||
val title = div.selectFirstOrThrow(".chapter_volume").text()
|
|
||||||
val name = div.selectFirst(".chapter_title")?.textOrNull()
|
|
||||||
MangaChapter(
|
|
||||||
id = generateUid(href),
|
|
||||||
name = if (name != null && name != title) "$title: $name" else title,
|
|
||||||
number = href.substringAfterLast('/').toIntOrNull() ?: 0,
|
|
||||||
url = href,
|
|
||||||
scanlator = div.selectFirst(".team_link-name")?.textOrNull(),
|
|
||||||
uploadDate = div.selectFirst(".component-chapter-date")
|
|
||||||
?.ownTextOrNull()
|
|
||||||
.parseDate(),
|
|
||||||
branch = null,
|
|
||||||
source = source,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun String?.parseDate(): Long {
|
|
||||||
if (this == null) {
|
|
||||||
assert(false) { "Date is null" }
|
|
||||||
return 0L
|
|
||||||
}
|
|
||||||
val parts = split(' ')
|
|
||||||
assert(parts.size == 2) { "Wrong date $this" }
|
|
||||||
val count = parts.getOrNull(0)?.toIntOrNull() ?: return 0L
|
|
||||||
val unit = parts.getOrNull(1) ?: return 0L
|
|
||||||
val calendarUnit = when (unit) {
|
|
||||||
"s" -> Calendar.SECOND
|
|
||||||
"min" -> Calendar.MINUTE
|
|
||||||
"h" -> Calendar.HOUR
|
|
||||||
"j" -> Calendar.DAY_OF_YEAR
|
|
||||||
"sem." -> Calendar.WEEK_OF_YEAR
|
|
||||||
"mois" -> Calendar.MONTH
|
|
||||||
"ans", "an" -> Calendar.YEAR
|
|
||||||
else -> {
|
|
||||||
assert(false) { "Unknown time unit $unit" }
|
|
||||||
return 0L
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val calendar = Calendar.getInstance()
|
|
||||||
calendar.add(calendarUnit, -count)
|
|
||||||
return calendar.timeInMillis
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun Element.src(): String {
|
|
||||||
return attrAsAbsoluteUrlOrNull("data-cfsrc")
|
|
||||||
?: attrAsAbsoluteUrlOrNull("src")
|
|
||||||
?: attrAsAbsoluteUrlOrNull("data-src")
|
|
||||||
?: run {
|
|
||||||
assert(false) { "Image src not found" }
|
|
||||||
""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,252 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.parsers.site
|
|
||||||
|
|
||||||
import androidx.collection.ArrayMap
|
|
||||||
import kotlinx.coroutines.sync.Mutex
|
|
||||||
import kotlinx.coroutines.sync.withLock
|
|
||||||
import org.json.JSONArray
|
|
||||||
import org.jsoup.nodes.Document
|
|
||||||
import org.jsoup.nodes.Element
|
|
||||||
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
|
||||||
import org.koitharu.kotatsu.parsers.MangaSourceParser
|
|
||||||
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 java.text.SimpleDateFormat
|
|
||||||
import java.util.*
|
|
||||||
|
|
||||||
@MangaSourceParser("BLOGTRUYEN", "BlogTruyen", "vi")
|
|
||||||
class BlogTruyenParser(context: MangaLoaderContext) :
|
|
||||||
PagedMangaParser(context, MangaSource.BLOGTRUYEN, pageSize = 20) {
|
|
||||||
|
|
||||||
override val configKeyDomain: ConfigKey.Domain
|
|
||||||
get() = ConfigKey.Domain("blogtruyen.vn", null)
|
|
||||||
|
|
||||||
override val sortOrders: Set<SortOrder>
|
|
||||||
get() = EnumSet.of(SortOrder.UPDATED)
|
|
||||||
|
|
||||||
private val mutex = Mutex()
|
|
||||||
private val dateFormat = SimpleDateFormat("dd/MM/yyyy HH:mm", Locale.US)
|
|
||||||
private var cacheTags: ArrayMap<String, MangaTag>? = null
|
|
||||||
|
|
||||||
override suspend fun getDetails(manga: Manga): Manga {
|
|
||||||
val doc = webClient.httpGet(manga.url.toAbsoluteUrl(domain)).parseHtml()
|
|
||||||
val descriptionElement = doc.selectFirstOrThrow("div.description")
|
|
||||||
val statusText = descriptionElement
|
|
||||||
.selectFirst("p:contains(Trạng thái) > span.color-red")
|
|
||||||
?.text()
|
|
||||||
|
|
||||||
val state = when (statusText) {
|
|
||||||
"Đang tiến hành" -> MangaState.ONGOING
|
|
||||||
"Đã hoàn thành" -> MangaState.FINISHED
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
|
|
||||||
val rating = doc.selectFirst("span.total-vote")?.attr("ng-init")?.let { text ->
|
|
||||||
val like = text.substringAfter("TotalLike=")
|
|
||||||
.substringBefore(';')
|
|
||||||
.toIntOrNull() ?: return@let RATING_UNKNOWN
|
|
||||||
val dislike = text.substringAfter("TotalDisLike=")
|
|
||||||
.toIntOrNull() ?: return@let RATING_UNKNOWN
|
|
||||||
|
|
||||||
when {
|
|
||||||
like == 0 && dislike == 0 -> RATING_UNKNOWN
|
|
||||||
else -> like.toFloat() / (like + dislike)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val tagMap = getOrCreateTagMap()
|
|
||||||
val tags = descriptionElement.select("p > span.category").mapNotNullToSet {
|
|
||||||
val tagName = it.selectFirst("a")?.text()?.trim() ?: return@mapNotNullToSet null
|
|
||||||
tagMap[tagName]
|
|
||||||
}
|
|
||||||
|
|
||||||
return manga.copy(
|
|
||||||
tags = tags,
|
|
||||||
author = descriptionElement.selectFirst("p:contains(Tác giả) > a")?.text(),
|
|
||||||
description = doc.selectFirst(".detail .content")?.html(),
|
|
||||||
chapters = parseChapterList(doc),
|
|
||||||
largeCoverUrl = doc.selectLast("div.thumbnail > img")?.imageUrl(),
|
|
||||||
state = state,
|
|
||||||
rating = rating ?: RATING_UNKNOWN,
|
|
||||||
isNsfw = doc.getElementById("warningCategory") != null,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun parseChapterList(doc: Document): List<MangaChapter> {
|
|
||||||
val chapterList = doc.select("#list-chapters > p")
|
|
||||||
return chapterList.mapChapters(reversed = true) { index, element ->
|
|
||||||
val titleElement = element.selectFirst("span.title > a") ?: return@mapChapters null
|
|
||||||
val name = titleElement.text()
|
|
||||||
val relativeUrl = titleElement.attrAsRelativeUrl("href")
|
|
||||||
val id = relativeUrl.substringAfter('/').substringBefore('/')
|
|
||||||
val uploadDate = dateFormat.tryParse(element.select("span.publishedDate").text())
|
|
||||||
MangaChapter(
|
|
||||||
id = generateUid(id),
|
|
||||||
name = name,
|
|
||||||
number = index + 1,
|
|
||||||
url = relativeUrl,
|
|
||||||
scanlator = null,
|
|
||||||
uploadDate = uploadDate,
|
|
||||||
branch = null,
|
|
||||||
source = source,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun getListPage(
|
|
||||||
page: Int,
|
|
||||||
query: String?,
|
|
||||||
tags: Set<MangaTag>?,
|
|
||||||
sortOrder: SortOrder,
|
|
||||||
): List<Manga> {
|
|
||||||
return when {
|
|
||||||
!query.isNullOrEmpty() -> {
|
|
||||||
val searchUrl = "https://${domain}/timkiem/nangcao/1/0/-1/-1?txt=${query.urlEncoded()}&p=$page"
|
|
||||||
val searchContent = webClient.httpGet(searchUrl).parseHtml()
|
|
||||||
.selectFirst("section.list-manga-bycate > div.list")
|
|
||||||
parseMangaList(searchContent)
|
|
||||||
}
|
|
||||||
|
|
||||||
!tags.isNullOrEmpty() -> {
|
|
||||||
val tag = tags.oneOrThrowIfMany()!!
|
|
||||||
val categoryAjax =
|
|
||||||
"https://${domain}/ajax/Category/AjaxLoadMangaByCategory?id=${tag.key}&orderBy=5&p=$page"
|
|
||||||
val listContent = webClient.httpGet(categoryAjax).parseHtml().selectFirst("div.list")
|
|
||||||
parseMangaList(listContent)
|
|
||||||
}
|
|
||||||
|
|
||||||
else -> getNormalList(page)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun getNormalList(page: Int): List<Manga> {
|
|
||||||
val pageLink = "https://${domain}/page-$page"
|
|
||||||
val doc = webClient.httpGet(pageLink).parseHtml()
|
|
||||||
val listElements = doc.selectFirstOrThrow("section.list-mainpage.listview")
|
|
||||||
.select("div.bg-white.storyitem")
|
|
||||||
|
|
||||||
return listElements.mapNotNull {
|
|
||||||
val linkTag = it.selectFirst("div.fl-l > a") ?: return@mapNotNull null
|
|
||||||
val relativeUrl = linkTag.attrAsRelativeUrl("href")
|
|
||||||
val tagMap = getOrCreateTagMap()
|
|
||||||
val tags = it.select("footer > div.category > a").mapNotNullToSet { a ->
|
|
||||||
tagMap[a.text()]
|
|
||||||
}
|
|
||||||
|
|
||||||
Manga(
|
|
||||||
id = generateUid(relativeUrl),
|
|
||||||
title = linkTag.attr("title"),
|
|
||||||
altTitle = null,
|
|
||||||
description = it.selectFirst("p.al-j.break.line-height-15")?.text(),
|
|
||||||
url = relativeUrl,
|
|
||||||
publicUrl = relativeUrl.toAbsoluteUrl(domain),
|
|
||||||
coverUrl = linkTag.selectLast("img")?.imageUrl().orEmpty(),
|
|
||||||
source = source,
|
|
||||||
tags = tags,
|
|
||||||
isNsfw = false,
|
|
||||||
rating = RATING_UNKNOWN,
|
|
||||||
author = null,
|
|
||||||
state = null,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun parseMangaList(listElement: Element?): List<Manga> {
|
|
||||||
listElement ?: return emptyList()
|
|
||||||
|
|
||||||
return listElement.select("span.tiptip[data-tiptip]").mapNotNull {
|
|
||||||
val mangaInfo = listElement.getElementById(it.attr("data-tiptip")) ?: return@mapNotNull null
|
|
||||||
val a = it.selectFirst("a") ?: return@mapNotNull null
|
|
||||||
val relativeUrl = a.attrAsRelativeUrl("href")
|
|
||||||
Manga(
|
|
||||||
id = generateUid(relativeUrl),
|
|
||||||
title = a.text(),
|
|
||||||
altTitle = null,
|
|
||||||
description = mangaInfo.select("div.al-j.fs-12").text(),
|
|
||||||
url = relativeUrl,
|
|
||||||
publicUrl = relativeUrl.toAbsoluteUrl(domain),
|
|
||||||
coverUrl = mangaInfo.selectFirst("div > img.img")?.imageUrl().orEmpty(),
|
|
||||||
isNsfw = false,
|
|
||||||
rating = RATING_UNKNOWN,
|
|
||||||
tags = emptySet(),
|
|
||||||
author = null,
|
|
||||||
state = null,
|
|
||||||
source = source,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
|
|
||||||
fun generateImageId(index: Int) = generateUid("${chapter.url}/$index")
|
|
||||||
|
|
||||||
val doc = webClient.httpGet(chapter.url.toAbsoluteUrl(domain)).parseHtml()
|
|
||||||
val pages = ArrayList<MangaPage>()
|
|
||||||
val referer = chapter.url.toAbsoluteUrl(domain)
|
|
||||||
doc.select("#content > img").forEach { img ->
|
|
||||||
pages.add(
|
|
||||||
MangaPage(
|
|
||||||
id = generateImageId(pages.size),
|
|
||||||
url = img.imageUrl(),
|
|
||||||
preview = null,
|
|
||||||
source = source,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Some chapters use js script to render images
|
|
||||||
val script = doc.selectLast("#content > script")
|
|
||||||
if (script != null && script.data().contains("listImageCaption")) {
|
|
||||||
val imagesStr = script.data().substringBefore(';').substringAfterLast('=').trim()
|
|
||||||
val imageArr = JSONArray(imagesStr)
|
|
||||||
for (i in 0 until imageArr.length()) {
|
|
||||||
val imageUrl = imageArr.getJSONObject(i).getString("url")
|
|
||||||
pages.add(
|
|
||||||
MangaPage(
|
|
||||||
id = generateImageId(pages.size),
|
|
||||||
url = imageUrl,
|
|
||||||
preview = null,
|
|
||||||
source = source,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return pages
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun getTags(): Set<MangaTag> {
|
|
||||||
val map = getOrCreateTagMap()
|
|
||||||
val tags = HashSet<MangaTag>(map.size)
|
|
||||||
for (entry in map) {
|
|
||||||
tags.add(entry.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
return tags
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
private suspend fun getOrCreateTagMap(): ArrayMap<String, MangaTag> = mutex.withLock {
|
|
||||||
cacheTags?.let { return@withLock it }
|
|
||||||
val doc = webClient.httpGet("/timkiem/nangcao".toAbsoluteUrl(domain)).parseHtml()
|
|
||||||
val tagItems = doc.select("li[data-id]")
|
|
||||||
val tagMap = ArrayMap<String, MangaTag>(tagItems.size)
|
|
||||||
for (tag in tagItems) {
|
|
||||||
val title = tag.text().trim()
|
|
||||||
tagMap[tag.text().trim()] = MangaTag(
|
|
||||||
title = title,
|
|
||||||
key = tag.attr("data-id"),
|
|
||||||
source = source,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
cacheTags = tagMap
|
|
||||||
tagMap
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun Element.imageUrl(): String {
|
|
||||||
return attrAsAbsoluteUrlOrNull("src")
|
|
||||||
?: attrAsAbsoluteUrlOrNull("data-cfsrc")
|
|
||||||
?: ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,100 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.parsers.site
|
|
||||||
|
|
||||||
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
|
||||||
import org.koitharu.kotatsu.parsers.MangaSourceParser
|
|
||||||
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 java.util.*
|
|
||||||
|
|
||||||
@MangaSourceParser("CLONEMANGA", "CloneManga", "en")
|
|
||||||
internal class CloneMangaParser(context: MangaLoaderContext) : PagedMangaParser(
|
|
||||||
context,
|
|
||||||
MangaSource.CLONEMANGA,
|
|
||||||
pageSize = 1,
|
|
||||||
) {
|
|
||||||
|
|
||||||
override val sortOrders: Set<SortOrder> = Collections.singleton(
|
|
||||||
SortOrder.POPULARITY,
|
|
||||||
)
|
|
||||||
|
|
||||||
override val configKeyDomain = ConfigKey.Domain("manga.clone-army.org", null)
|
|
||||||
|
|
||||||
override suspend fun getListPage(
|
|
||||||
page: Int,
|
|
||||||
query: String?,
|
|
||||||
tags: Set<MangaTag>?,
|
|
||||||
sortOrder: SortOrder,
|
|
||||||
): List<Manga> {
|
|
||||||
if (query != null || page > 1) {
|
|
||||||
return emptyList()
|
|
||||||
}
|
|
||||||
val link = "https://${domain}/viewer_landing.php"
|
|
||||||
val doc = webClient.httpGet(link).parseHtml()
|
|
||||||
val mangas = doc.getElementsByClass("comicPreviewContainer")
|
|
||||||
return mangas.mapNotNull { item ->
|
|
||||||
val background = item.selectFirstOrThrow(".comicPreview").styleValueOrNull("background")
|
|
||||||
val href = item.selectFirst("a")?.attrAsAbsoluteUrl("href") ?: return@mapNotNull null
|
|
||||||
val cover = background?.substring(background.indexOf("site/themes"), background.indexOf(")"))
|
|
||||||
Manga(
|
|
||||||
id = generateUid(href),
|
|
||||||
title = item.selectFirst("h3")?.text() ?: return@mapNotNull null,
|
|
||||||
coverUrl = "https://${domain}/$cover",
|
|
||||||
altTitle = null,
|
|
||||||
author = "Dan Kim",
|
|
||||||
rating = RATING_UNKNOWN,
|
|
||||||
url = href,
|
|
||||||
isNsfw = false,
|
|
||||||
tags = emptySet(),
|
|
||||||
state = null,
|
|
||||||
publicUrl = href.toAbsoluteUrl(domain),
|
|
||||||
source = source,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun getDetails(manga: Manga): Manga {
|
|
||||||
val doc = webClient.httpGet(manga.publicUrl).parseHtml()
|
|
||||||
val series = doc.location()
|
|
||||||
val numChapters = Regex(
|
|
||||||
pattern = "&page=(.*)&lang=",
|
|
||||||
).findAll(
|
|
||||||
input = doc.getElementsByTag("script")[3].toString(),
|
|
||||||
)
|
|
||||||
.elementAt(3).destructured.component1()
|
|
||||||
.toInt()
|
|
||||||
val chapters = ArrayList<MangaChapter>()
|
|
||||||
for (i in 0..numChapters) {
|
|
||||||
val chapter = MangaChapter(
|
|
||||||
id = generateUid("$series&page=$i"),
|
|
||||||
name = "Chapter ${i + 1}",
|
|
||||||
number = i + 1,
|
|
||||||
url = "$series&page=$i",
|
|
||||||
scanlator = null,
|
|
||||||
branch = null,
|
|
||||||
uploadDate = 0L,
|
|
||||||
source = source,
|
|
||||||
)
|
|
||||||
chapters.add(chapter)
|
|
||||||
}
|
|
||||||
return manga.copy(chapters = chapters)
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
|
|
||||||
val doc = webClient.httpGet(chapter.url.toAbsoluteUrl(domain)).parseHtml()
|
|
||||||
val imgUrl = doc.getElementsByClass("subsectionContainer")[0]
|
|
||||||
.selectFirst("img")
|
|
||||||
?.attrAsAbsoluteUrlOrNull("src") ?: doc.parseFailed("Something broken")
|
|
||||||
return listOf(
|
|
||||||
MangaPage(
|
|
||||||
id = generateUid(imgUrl),
|
|
||||||
url = imgUrl,
|
|
||||||
preview = null,
|
|
||||||
source = source,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun getTags(): Set<MangaTag> = emptySet()
|
|
||||||
}
|
|
||||||
@@ -22,9 +22,9 @@ private const val PAGE_SIZE = 20
|
|||||||
private const val CHAPTERS_LIMIT = 99999
|
private const val CHAPTERS_LIMIT = 99999
|
||||||
|
|
||||||
@MangaSourceParser("COMICK_FUN", "ComicK")
|
@MangaSourceParser("COMICK_FUN", "ComicK")
|
||||||
internal class ComickFunParser(context: MangaLoaderContext) : MangaParser(context, MangaSource.COMICK_FUN) {
|
internal class ComickFunParser(override val context: MangaLoaderContext) : MangaParser(MangaSource.COMICK_FUN) {
|
||||||
|
|
||||||
override val configKeyDomain = ConfigKey.Domain("comick.app", null)
|
override val configKeyDomain = ConfigKey.Domain("comick.fun", null)
|
||||||
|
|
||||||
override val sortOrders: Set<SortOrder> = EnumSet.of(
|
override val sortOrders: Set<SortOrder> = EnumSet.of(
|
||||||
SortOrder.POPULARITY,
|
SortOrder.POPULARITY,
|
||||||
@@ -41,11 +41,11 @@ internal class ComickFunParser(context: MangaLoaderContext) : MangaParser(contex
|
|||||||
tags: Set<MangaTag>?,
|
tags: Set<MangaTag>?,
|
||||||
sortOrder: SortOrder,
|
sortOrder: SortOrder,
|
||||||
): List<Manga> {
|
): List<Manga> {
|
||||||
val domain = domain
|
val domain = getDomain()
|
||||||
val url = buildString {
|
val url = buildString {
|
||||||
append("https://api.")
|
append("https://api.")
|
||||||
append(domain)
|
append(domain)
|
||||||
append("/v1.0/search?tachiyomi=true")
|
append("/search?tachiyomi=true")
|
||||||
if (!query.isNullOrEmpty()) {
|
if (!query.isNullOrEmpty()) {
|
||||||
if (offset > 0) {
|
if (offset > 0) {
|
||||||
return emptyList()
|
return emptyList()
|
||||||
@@ -71,7 +71,7 @@ internal class ComickFunParser(context: MangaLoaderContext) : MangaParser(contex
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val ja = webClient.httpGet(url).parseJsonArray()
|
val ja = context.httpGet(url).parseJsonArray()
|
||||||
val tagsMap = cachedTags ?: loadTags()
|
val tagsMap = cachedTags ?: loadTags()
|
||||||
return ja.mapJSON { jo ->
|
return ja.mapJSON { jo ->
|
||||||
val slug = jo.getString("slug")
|
val slug = jo.getString("slug")
|
||||||
@@ -101,9 +101,9 @@ internal class ComickFunParser(context: MangaLoaderContext) : MangaParser(contex
|
|||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getDetails(manga: Manga): Manga {
|
override suspend fun getDetails(manga: Manga): Manga {
|
||||||
val domain = domain
|
val domain = getDomain()
|
||||||
val url = "https://api.$domain/comic/${manga.url}?tachiyomi=true"
|
val url = "https://api.$domain/comic/${manga.url}?tachiyomi=true"
|
||||||
val jo = webClient.httpGet(url).parseJson()
|
val jo = context.httpGet(url).parseJson()
|
||||||
val comic = jo.getJSONObject("comic")
|
val comic = jo.getJSONObject("comic")
|
||||||
return manga.copy(
|
return manga.copy(
|
||||||
title = comic.getString("title"),
|
title = comic.getString("title"),
|
||||||
@@ -118,20 +118,21 @@ internal class ComickFunParser(context: MangaLoaderContext) : MangaParser(contex
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
author = jo.getJSONArray("artists").optJSONObject(0)?.getString("name"),
|
author = jo.getJSONArray("artists").optJSONObject(0)?.getString("name"),
|
||||||
chapters = getChapters(comic.getString("hid")),
|
chapters = getChapters(comic.getLong("id")),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
|
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
|
||||||
val jo = webClient.httpGet(
|
val jo = context.httpGet(
|
||||||
"https://api.${domain}/chapter/${chapter.url}?tachiyomi=true",
|
"https://api.${getDomain()}/chapter/${chapter.url}?tachiyomi=true",
|
||||||
).parseJson().getJSONObject("chapter")
|
).parseJson().getJSONObject("chapter")
|
||||||
val referer = "https://${domain}/"
|
val referer = "https://${getDomain()}/"
|
||||||
return jo.getJSONArray("images").mapJSON {
|
return jo.getJSONArray("images").mapJSON {
|
||||||
val url = it.getString("url")
|
val url = it.getString("url")
|
||||||
MangaPage(
|
MangaPage(
|
||||||
id = generateUid(url),
|
id = generateUid(url),
|
||||||
url = url,
|
url = url,
|
||||||
|
referer = referer,
|
||||||
preview = null,
|
preview = null,
|
||||||
source = source,
|
source = source,
|
||||||
)
|
)
|
||||||
@@ -148,7 +149,7 @@ internal class ComickFunParser(context: MangaLoaderContext) : MangaParser(contex
|
|||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun loadTags(): SparseArrayCompat<MangaTag> {
|
private suspend fun loadTags(): SparseArrayCompat<MangaTag> {
|
||||||
val ja = webClient.httpGet("https://api.${domain}/genre").parseJsonArray()
|
val ja = context.httpGet("https://api.${getDomain()}/genre").parseJsonArray()
|
||||||
val tags = SparseArrayCompat<MangaTag>(ja.length())
|
val tags = SparseArrayCompat<MangaTag>(ja.length())
|
||||||
for (jo in ja.JSONIterator()) {
|
for (jo in ja.JSONIterator()) {
|
||||||
tags.append(
|
tags.append(
|
||||||
@@ -164,9 +165,9 @@ internal class ComickFunParser(context: MangaLoaderContext) : MangaParser(contex
|
|||||||
return tags
|
return tags
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun getChapters(hid: String): List<MangaChapter> {
|
private suspend fun getChapters(id: Long): List<MangaChapter> {
|
||||||
val ja = webClient.httpGet(
|
val ja = context.httpGet(
|
||||||
url = "https://api.${domain}/comic/$hid/chapters?limit=$CHAPTERS_LIMIT",
|
url = "https://api.${getDomain()}/comic/$id/chapter?tachiyomi=true&limit=$CHAPTERS_LIMIT",
|
||||||
).parseJson().getJSONArray("chapters")
|
).parseJson().getJSONArray("chapters")
|
||||||
val dateFormat = SimpleDateFormat("yyyy-MM-dd")
|
val dateFormat = SimpleDateFormat("yyyy-MM-dd")
|
||||||
val counters = HashMap<Locale, Int>()
|
val counters = HashMap<Locale, Int>()
|
||||||
@@ -212,4 +213,4 @@ internal class ComickFunParser(context: MangaLoaderContext) : MangaParser(contex
|
|||||||
}
|
}
|
||||||
return res
|
return res
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
package org.koitharu.kotatsu.parsers.site
|
package org.koitharu.kotatsu.parsers.site
|
||||||
|
|
||||||
import androidx.collection.ArrayMap
|
|
||||||
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
||||||
import org.koitharu.kotatsu.parsers.MangaSourceParser
|
import org.koitharu.kotatsu.parsers.MangaSourceParser
|
||||||
import org.koitharu.kotatsu.parsers.PagedMangaParser
|
import org.koitharu.kotatsu.parsers.PagedMangaParser
|
||||||
@@ -14,7 +13,7 @@ import org.koitharu.kotatsu.parsers.util.json.mapJSONToSet
|
|||||||
import java.util.*
|
import java.util.*
|
||||||
|
|
||||||
@MangaSourceParser("DESUME", "Desu.me", "ru")
|
@MangaSourceParser("DESUME", "Desu.me", "ru")
|
||||||
internal class DesuMeParser(context: MangaLoaderContext) : PagedMangaParser(context, MangaSource.DESUME, 20) {
|
internal class DesuMeParser(override val context: MangaLoaderContext) : PagedMangaParser(MangaSource.DESUME, 20) {
|
||||||
|
|
||||||
override val configKeyDomain = ConfigKey.Domain("desu.me", null)
|
override val configKeyDomain = ConfigKey.Domain("desu.me", null)
|
||||||
|
|
||||||
@@ -25,8 +24,6 @@ internal class DesuMeParser(context: MangaLoaderContext) : PagedMangaParser(cont
|
|||||||
SortOrder.ALPHABETICAL,
|
SortOrder.ALPHABETICAL,
|
||||||
)
|
)
|
||||||
|
|
||||||
private val tagsCache = SuspendLazy(::fetchTags)
|
|
||||||
|
|
||||||
override suspend fun getListPage(
|
override suspend fun getListPage(
|
||||||
page: Int,
|
page: Int,
|
||||||
query: String?,
|
query: String?,
|
||||||
@@ -36,7 +33,7 @@ internal class DesuMeParser(context: MangaLoaderContext) : PagedMangaParser(cont
|
|||||||
if (query != null && page != searchPaginator.firstPage) {
|
if (query != null && page != searchPaginator.firstPage) {
|
||||||
return emptyList()
|
return emptyList()
|
||||||
}
|
}
|
||||||
val domain = domain
|
val domain = getDomain()
|
||||||
val url = buildString {
|
val url = buildString {
|
||||||
append("https://")
|
append("https://")
|
||||||
append(domain)
|
append(domain)
|
||||||
@@ -53,16 +50,14 @@ internal class DesuMeParser(context: MangaLoaderContext) : PagedMangaParser(cont
|
|||||||
append(query)
|
append(query)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val json = webClient.httpGet(url).parseJson().getJSONArray("response")
|
val json = context.httpGet(url).parseJson().getJSONArray("response")
|
||||||
?: throw ParseException("Invalid response", url)
|
?: throw ParseException("Invalid response", url)
|
||||||
val total = json.length()
|
val total = json.length()
|
||||||
val list = ArrayList<Manga>(total)
|
val list = ArrayList<Manga>(total)
|
||||||
val tagsMap = tagsCache.tryGet().getOrNull()
|
|
||||||
for (i in 0 until total) {
|
for (i in 0 until total) {
|
||||||
val jo = json.getJSONObject(i)
|
val jo = json.getJSONObject(i)
|
||||||
val cover = jo.getJSONObject("image")
|
val cover = jo.getJSONObject("image")
|
||||||
val id = jo.getLong("id")
|
val id = jo.getLong("id")
|
||||||
val genres = jo.getString("genres").split(',')
|
|
||||||
list += Manga(
|
list += Manga(
|
||||||
url = "/manga/api/$id",
|
url = "/manga/api/$id",
|
||||||
publicUrl = jo.getString("url"),
|
publicUrl = jo.getString("url"),
|
||||||
@@ -78,13 +73,7 @@ internal class DesuMeParser(context: MangaLoaderContext) : PagedMangaParser(cont
|
|||||||
rating = jo.getDouble("score").toFloat().coerceIn(0f, 1f),
|
rating = jo.getDouble("score").toFloat().coerceIn(0f, 1f),
|
||||||
id = generateUid(id),
|
id = generateUid(id),
|
||||||
isNsfw = false,
|
isNsfw = false,
|
||||||
tags = if (!tagsMap.isNullOrEmpty()) {
|
tags = emptySet(),
|
||||||
genres.mapNotNullToSet { g ->
|
|
||||||
tagsMap[g.trim().toTitleCase()]
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
emptySet()
|
|
||||||
},
|
|
||||||
author = null,
|
author = null,
|
||||||
description = jo.getString("description"),
|
description = jo.getString("description"),
|
||||||
)
|
)
|
||||||
@@ -93,8 +82,8 @@ internal class DesuMeParser(context: MangaLoaderContext) : PagedMangaParser(cont
|
|||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getDetails(manga: Manga): Manga {
|
override suspend fun getDetails(manga: Manga): Manga {
|
||||||
val url = manga.url.toAbsoluteUrl(domain)
|
val url = manga.url.toAbsoluteUrl(getDomain())
|
||||||
val json = webClient.httpGet(url).parseJson().getJSONObject("response")
|
val json = context.httpGet(url).parseJson().getJSONObject("response")
|
||||||
?: throw ParseException("Invalid response", url)
|
?: throw ParseException("Invalid response", url)
|
||||||
val baseChapterUrl = manga.url + "/chapter/"
|
val baseChapterUrl = manga.url + "/chapter/"
|
||||||
val chaptersList = json.getJSONObject("chapters").getJSONArray("list")
|
val chaptersList = json.getJSONObject("chapters").getJSONArray("list")
|
||||||
@@ -128,13 +117,14 @@ internal class DesuMeParser(context: MangaLoaderContext) : PagedMangaParser(cont
|
|||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
|
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
|
||||||
val fullUrl = chapter.url.toAbsoluteUrl(domain)
|
val fullUrl = chapter.url.toAbsoluteUrl(getDomain())
|
||||||
val json = webClient.httpGet(fullUrl)
|
val json = context.httpGet(fullUrl)
|
||||||
.parseJson()
|
.parseJson()
|
||||||
.getJSONObject("response") ?: throw ParseException("Invalid response", fullUrl)
|
.getJSONObject("response") ?: throw ParseException("Invalid response", fullUrl)
|
||||||
return json.getJSONObject("pages").getJSONArray("list").mapJSON { jo ->
|
return json.getJSONObject("pages").getJSONArray("list").mapJSON { jo ->
|
||||||
MangaPage(
|
MangaPage(
|
||||||
id = generateUid(jo.getLong("id")),
|
id = generateUid(jo.getLong("id")),
|
||||||
|
referer = fullUrl,
|
||||||
preview = null,
|
preview = null,
|
||||||
source = chapter.source,
|
source = chapter.source,
|
||||||
url = jo.getString("img"),
|
url = jo.getString("img"),
|
||||||
@@ -143,7 +133,21 @@ internal class DesuMeParser(context: MangaLoaderContext) : PagedMangaParser(cont
|
|||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getTags(): Set<MangaTag> {
|
override suspend fun getTags(): Set<MangaTag> {
|
||||||
return tagsCache.get().values.toSet()
|
val doc = context.httpGet("https://${getDomain()}/manga/").parseHtml()
|
||||||
|
val root = doc.body().requireElementById("animeFilter")
|
||||||
|
.selectFirstOrThrow(".catalog-genres")
|
||||||
|
return root.select("li").mapToSet {
|
||||||
|
val input = it.selectFirstOrThrow("input")
|
||||||
|
MangaTag(
|
||||||
|
source = source,
|
||||||
|
key = input.attr("data-genre-slug").ifEmpty {
|
||||||
|
it.parseFailed("data-genre-slug is empty")
|
||||||
|
},
|
||||||
|
title = input.attr("data-genre-name").toTitleCase().ifEmpty {
|
||||||
|
it.parseFailed("data-genre-name is empty")
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getSortKey(sortOrder: SortOrder) =
|
private fun getSortKey(sortOrder: SortOrder) =
|
||||||
@@ -154,26 +158,4 @@ internal class DesuMeParser(context: MangaLoaderContext) : PagedMangaParser(cont
|
|||||||
SortOrder.NEWEST -> "id"
|
SortOrder.NEWEST -> "id"
|
||||||
else -> "updated"
|
else -> "updated"
|
||||||
}
|
}
|
||||||
|
}
|
||||||
private suspend fun fetchTags(): Map<String, MangaTag> {
|
|
||||||
val doc = webClient.httpGet("https://${domain}/manga/").parseHtml()
|
|
||||||
val root = doc.body().requireElementById("animeFilter")
|
|
||||||
.selectFirstOrThrow(".catalog-genres")
|
|
||||||
val li = root.select("li")
|
|
||||||
val result = ArrayMap<String, MangaTag>(li.size)
|
|
||||||
li.forEach {
|
|
||||||
val input = it.selectFirstOrThrow("input")
|
|
||||||
val tag = MangaTag(
|
|
||||||
source = source,
|
|
||||||
key = input.attr("data-genre-slug").ifEmpty {
|
|
||||||
it.parseFailed("data-genre-slug is empty")
|
|
||||||
},
|
|
||||||
title = input.attr("data-genre-name").toTitleCase().ifEmpty {
|
|
||||||
it.parseFailed("data-genre-name is empty")
|
|
||||||
},
|
|
||||||
)
|
|
||||||
result[tag.title] = tag
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,141 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.parsers.site
|
|
||||||
|
|
||||||
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
|
||||||
import org.koitharu.kotatsu.parsers.MangaSourceParser
|
|
||||||
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 java.text.SimpleDateFormat
|
|
||||||
import java.util.*
|
|
||||||
|
|
||||||
@MangaSourceParser("DOUJINDESU", "DoujinDesu", "id")
|
|
||||||
class DoujinDesuParser(context: MangaLoaderContext) : PagedMangaParser(context, MangaSource.DOUJINDESU, pageSize = 18) {
|
|
||||||
|
|
||||||
override val configKeyDomain: ConfigKey.Domain
|
|
||||||
get() = ConfigKey.Domain("212.32.226.234", null)
|
|
||||||
|
|
||||||
override val sortOrders: Set<SortOrder>
|
|
||||||
get() = EnumSet.of(SortOrder.UPDATED, SortOrder.NEWEST, SortOrder.ALPHABETICAL, SortOrder.POPULARITY)
|
|
||||||
|
|
||||||
override suspend fun getDetails(manga: Manga): Manga {
|
|
||||||
val docs = webClient.httpGet(manga.url.toAbsoluteUrl(domain)).parseHtml().selectFirstOrThrow("#archive")
|
|
||||||
val chapterDateFormat = SimpleDateFormat("EEEE, dd MMMM yyyy", sourceLocale)
|
|
||||||
val metadataEl = docs.selectFirst(".wrapper > .metadata tbody")
|
|
||||||
val state = when (metadataEl?.selectFirst("tr:contains(Status)")?.selectLast("td")?.text()) {
|
|
||||||
"Finished" -> MangaState.FINISHED
|
|
||||||
"Publishing" -> MangaState.ONGOING
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
return manga.copy(
|
|
||||||
author = metadataEl?.selectFirst("tr:contains(Author)")?.selectLast("td")?.text(),
|
|
||||||
description = docs.selectFirst(".wrapper > .metadata > .pb-2")?.selectFirst("p")?.html(),
|
|
||||||
state = state,
|
|
||||||
rating = metadataEl?.selectFirst(".rating-prc")?.ownText()?.toFloatOrNull()?.div(10f) ?: RATING_UNKNOWN,
|
|
||||||
tags = docs.select(".tags > a").mapToSet {
|
|
||||||
MangaTag(
|
|
||||||
key = it.attr("title"),
|
|
||||||
title = it.text(),
|
|
||||||
source = source,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
chapters = docs.requireElementById("chapter_list")
|
|
||||||
.select("ul > li")
|
|
||||||
.mapChapters(reversed = true) { index, element ->
|
|
||||||
val titleTag = element.selectFirstOrThrow(".epsleft > .lchx > a")
|
|
||||||
val url = titleTag.attrAsRelativeUrl("href")
|
|
||||||
MangaChapter(
|
|
||||||
id = generateUid(url),
|
|
||||||
name = titleTag.text(),
|
|
||||||
number = index + 1,
|
|
||||||
url = url,
|
|
||||||
scanlator = null,
|
|
||||||
uploadDate = chapterDateFormat.tryParse(element.select(".epsleft > .date").text()),
|
|
||||||
branch = null,
|
|
||||||
source = source,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun getListPage(
|
|
||||||
page: Int,
|
|
||||||
query: String?,
|
|
||||||
tags: Set<MangaTag>?,
|
|
||||||
sortOrder: SortOrder,
|
|
||||||
): List<Manga> {
|
|
||||||
val url = urlBuilder().apply {
|
|
||||||
addPathSegment("manga")
|
|
||||||
addPathSegment("page")
|
|
||||||
addPathSegment("$page/")
|
|
||||||
val order = when (sortOrder) {
|
|
||||||
SortOrder.UPDATED -> "update"
|
|
||||||
SortOrder.POPULARITY -> "popular"
|
|
||||||
SortOrder.ALPHABETICAL -> "title"
|
|
||||||
SortOrder.NEWEST -> "latest"
|
|
||||||
else -> throw IllegalArgumentException("Sort order not supported")
|
|
||||||
}
|
|
||||||
addQueryParameter("order", order)
|
|
||||||
addQueryParameter("title", query.orEmpty())
|
|
||||||
tags?.forEach {
|
|
||||||
addEncodedQueryParameter("genre[]".urlEncoded(), it.key.urlEncoded())
|
|
||||||
}
|
|
||||||
}.build()
|
|
||||||
|
|
||||||
return webClient.httpGet(url).parseHtml()
|
|
||||||
.requireElementById("archives")
|
|
||||||
.selectFirstOrThrow("div.entries")
|
|
||||||
.select(".entry")
|
|
||||||
.map {
|
|
||||||
val titleTag = it.selectFirstOrThrow(".metadata > a")
|
|
||||||
val relativeUrl = titleTag.attrAsRelativeUrl("href")
|
|
||||||
Manga(
|
|
||||||
id = generateUid(relativeUrl),
|
|
||||||
title = titleTag.attr("title"),
|
|
||||||
altTitle = null,
|
|
||||||
url = relativeUrl,
|
|
||||||
publicUrl = relativeUrl.toAbsoluteUrl(domain),
|
|
||||||
rating = RATING_UNKNOWN,
|
|
||||||
isNsfw = true,
|
|
||||||
coverUrl = it.selectFirst(".thumbnail > img")?.attrAsAbsoluteUrl("src").orEmpty(),
|
|
||||||
tags = emptySet(),
|
|
||||||
state = null,
|
|
||||||
author = null,
|
|
||||||
largeCoverUrl = null,
|
|
||||||
description = null,
|
|
||||||
source = source,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
|
|
||||||
val id = webClient.httpGet(chapter.url.toAbsoluteUrl(domain)).parseHtml()
|
|
||||||
.requireElementById("reader")
|
|
||||||
.attr("data-id")
|
|
||||||
return webClient.httpPost("/themes/ajax/ch.php".toAbsoluteUrl(domain), "id=$id").parseHtml()
|
|
||||||
.select("img")
|
|
||||||
.map {
|
|
||||||
val url = it.attrAsRelativeUrl("src")
|
|
||||||
MangaPage(
|
|
||||||
id = generateUid(url),
|
|
||||||
url = url,
|
|
||||||
preview = null,
|
|
||||||
source = source,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun getTags(): Set<MangaTag> {
|
|
||||||
return webClient.httpGet("/genre/".toAbsoluteUrl(domain)).parseHtml()
|
|
||||||
.requireElementById("taxonomy")
|
|
||||||
.selectFirstOrThrow(".entries")
|
|
||||||
.select(".entry > a")
|
|
||||||
.mapToSet {
|
|
||||||
MangaTag(
|
|
||||||
key = it.attr("title"),
|
|
||||||
title = it.attr("title"),
|
|
||||||
source = source,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,8 +1,5 @@
|
|||||||
package org.koitharu.kotatsu.parsers.site
|
package org.koitharu.kotatsu.parsers.site
|
||||||
|
|
||||||
import androidx.collection.SparseArrayCompat
|
|
||||||
import androidx.collection.set
|
|
||||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
|
||||||
import org.jsoup.nodes.Element
|
import org.jsoup.nodes.Element
|
||||||
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
||||||
import org.koitharu.kotatsu.parsers.MangaParserAuthProvider
|
import org.koitharu.kotatsu.parsers.MangaParserAuthProvider
|
||||||
@@ -20,8 +17,8 @@ private const val DOMAIN_AUTHORIZED = "exhentai.org"
|
|||||||
|
|
||||||
@MangaSourceParser("EXHENTAI", "ExHentai")
|
@MangaSourceParser("EXHENTAI", "ExHentai")
|
||||||
internal class ExHentaiParser(
|
internal class ExHentaiParser(
|
||||||
context: MangaLoaderContext,
|
override val context: MangaLoaderContext,
|
||||||
) : PagedMangaParser(context, MangaSource.EXHENTAI, pageSize = 25), MangaParserAuthProvider {
|
) : PagedMangaParser(MangaSource.EXHENTAI, pageSize = 25), MangaParserAuthProvider {
|
||||||
|
|
||||||
override val sortOrders: Set<SortOrder> = Collections.singleton(
|
override val sortOrders: Set<SortOrder> = Collections.singleton(
|
||||||
SortOrder.NEWEST,
|
SortOrder.NEWEST,
|
||||||
@@ -31,13 +28,11 @@ internal class ExHentaiParser(
|
|||||||
get() = ConfigKey.Domain(if (isAuthorized) DOMAIN_AUTHORIZED else DOMAIN_UNAUTHORIZED, null)
|
get() = ConfigKey.Domain(if (isAuthorized) DOMAIN_AUTHORIZED else DOMAIN_UNAUTHORIZED, null)
|
||||||
|
|
||||||
override val authUrl: String
|
override val authUrl: String
|
||||||
get() = "https://${domain}/bounce_login.php"
|
get() = "https://${getDomain()}/bounce_login.php"
|
||||||
|
|
||||||
private val ratingPattern = Regex("-?[0-9]+px")
|
private val ratingPattern = Regex("-?[0-9]+px")
|
||||||
private val authCookies = arrayOf("ipb_member_id", "ipb_pass_hash")
|
private val authCookies = arrayOf("ipb_member_id", "ipb_pass_hash")
|
||||||
private var updateDm = false
|
private var updateDm = false
|
||||||
private val nextPages = SparseArrayCompat<Long>()
|
|
||||||
private val suspiciousContentKey = ConfigKey.ShowSuspiciousContent(true)
|
|
||||||
|
|
||||||
override val isAuthorized: Boolean
|
override val isAuthorized: Boolean
|
||||||
get() {
|
get() {
|
||||||
@@ -59,7 +54,6 @@ internal class ExHentaiParser(
|
|||||||
init {
|
init {
|
||||||
context.cookieJar.insertCookies(DOMAIN_AUTHORIZED, "nw=1", "sl=dm_2")
|
context.cookieJar.insertCookies(DOMAIN_AUTHORIZED, "nw=1", "sl=dm_2")
|
||||||
context.cookieJar.insertCookies(DOMAIN_UNAUTHORIZED, "nw=1", "sl=dm_2")
|
context.cookieJar.insertCookies(DOMAIN_UNAUTHORIZED, "nw=1", "sl=dm_2")
|
||||||
paginator.firstPage = 0
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getListPage(
|
override suspend fun getListPage(
|
||||||
@@ -69,16 +63,11 @@ internal class ExHentaiParser(
|
|||||||
sortOrder: SortOrder,
|
sortOrder: SortOrder,
|
||||||
): List<Manga> {
|
): List<Manga> {
|
||||||
var search = query?.urlEncoded().orEmpty()
|
var search = query?.urlEncoded().orEmpty()
|
||||||
val next = nextPages.get(page, 0L)
|
|
||||||
if (page > 0 && next == 0L) {
|
|
||||||
assert(false) { "Page timestamp not found" }
|
|
||||||
return emptyList()
|
|
||||||
}
|
|
||||||
val url = buildString {
|
val url = buildString {
|
||||||
append("https://")
|
append("https://")
|
||||||
append(domain)
|
append(getDomain())
|
||||||
append("/?next=")
|
append("/?page=")
|
||||||
append(next)
|
append(page)
|
||||||
if (!tags.isNullOrEmpty()) {
|
if (!tags.isNullOrEmpty()) {
|
||||||
var fCats = 0
|
var fCats = 0
|
||||||
for (tag in tags) {
|
for (tag in tags) {
|
||||||
@@ -99,12 +88,8 @@ internal class ExHentaiParser(
|
|||||||
if (updateDm) {
|
if (updateDm) {
|
||||||
append("&inline_set=dm_e")
|
append("&inline_set=dm_e")
|
||||||
}
|
}
|
||||||
append("&advsearch=1")
|
|
||||||
if (config[suspiciousContentKey]) {
|
|
||||||
append("&f_sh=on")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
val body = webClient.httpGet(url).parseHtml().body()
|
val body = context.httpGet(url).parseHtml().body()
|
||||||
val root = body.selectFirst("table.itg")
|
val root = body.selectFirst("table.itg")
|
||||||
?.selectFirst("tbody")
|
?.selectFirst("tbody")
|
||||||
?: if (updateDm) {
|
?: if (updateDm) {
|
||||||
@@ -114,7 +99,6 @@ internal class ExHentaiParser(
|
|||||||
return getListPage(page, query, tags, sortOrder)
|
return getListPage(page, query, tags, sortOrder)
|
||||||
}
|
}
|
||||||
updateDm = false
|
updateDm = false
|
||||||
nextPages[page + 1] = getNextTimestamp(body)
|
|
||||||
return root.children().mapNotNull { tr ->
|
return root.children().mapNotNull { tr ->
|
||||||
if (tr.childrenSize() != 2) return@mapNotNull null
|
if (tr.childrenSize() != 2) return@mapNotNull null
|
||||||
val (td1, td2) = tr.children()
|
val (td1, td2) = tr.children()
|
||||||
@@ -148,7 +132,7 @@ internal class ExHentaiParser(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getDetails(manga: Manga): Manga {
|
override suspend fun getDetails(manga: Manga): Manga {
|
||||||
val doc = webClient.httpGet(manga.url.toAbsoluteUrl(domain)).parseHtml()
|
val doc = context.httpGet(manga.url.toAbsoluteUrl(getDomain())).parseHtml()
|
||||||
val root = doc.body().selectFirstOrThrow("div.gm")
|
val root = doc.body().selectFirstOrThrow("div.gm")
|
||||||
val cover = root.getElementById("gd1")?.children()?.first()
|
val cover = root.getElementById("gd1")?.children()?.first()
|
||||||
val title = root.getElementById("gd2")
|
val title = root.getElementById("gd2")
|
||||||
@@ -192,13 +176,14 @@ internal class ExHentaiParser(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
|
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
|
||||||
val doc = webClient.httpGet(chapter.url.toAbsoluteUrl(domain)).parseHtml()
|
val doc = context.httpGet(chapter.url.toAbsoluteUrl(getDomain())).parseHtml()
|
||||||
val root = doc.body().requireElementById("gdt")
|
val root = doc.body().requireElementById("gdt")
|
||||||
return root.select("a").map { a ->
|
return root.select("a").map { a ->
|
||||||
val url = a.attrAsRelativeUrl("href")
|
val url = a.attrAsRelativeUrl("href")
|
||||||
MangaPage(
|
MangaPage(
|
||||||
id = generateUid(url),
|
id = generateUid(url),
|
||||||
url = url,
|
url = url,
|
||||||
|
referer = a.absUrl("href"),
|
||||||
preview = null,
|
preview = null,
|
||||||
source = source,
|
source = source,
|
||||||
)
|
)
|
||||||
@@ -206,12 +191,12 @@ internal class ExHentaiParser(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getPageUrl(page: MangaPage): String {
|
override suspend fun getPageUrl(page: MangaPage): String {
|
||||||
val doc = webClient.httpGet(page.url.toAbsoluteUrl(domain)).parseHtml()
|
val doc = context.httpGet(page.url.toAbsoluteUrl(getDomain())).parseHtml()
|
||||||
return doc.body().requireElementById("img").attrAsAbsoluteUrl("src")
|
return doc.body().requireElementById("img").attrAsAbsoluteUrl("src")
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getTags(): Set<MangaTag> {
|
override suspend fun getTags(): Set<MangaTag> {
|
||||||
val doc = webClient.httpGet("https://${domain}").parseHtml()
|
val doc = context.httpGet("https://${getDomain()}").parseHtml()
|
||||||
val root = doc.body().requireElementById("searchbox").selectFirstOrThrow("table")
|
val root = doc.body().requireElementById("searchbox").selectFirstOrThrow("table")
|
||||||
return root.select("div.cs").mapNotNullToSet { div ->
|
return root.select("div.cs").mapNotNullToSet { div ->
|
||||||
val id = div.id().substringAfterLast('_').toIntOrNull()
|
val id = div.id().substringAfterLast('_').toIntOrNull()
|
||||||
@@ -225,7 +210,7 @@ internal class ExHentaiParser(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getUsername(): String {
|
override suspend fun getUsername(): String {
|
||||||
val doc = webClient.httpGet("https://forums.$DOMAIN_UNAUTHORIZED/").parseHtml().body()
|
val doc = context.httpGet("https://forums.$DOMAIN_UNAUTHORIZED/").parseHtml().body()
|
||||||
val username = doc.getElementById("userlinks")
|
val username = doc.getElementById("userlinks")
|
||||||
?.getElementsByAttributeValueContaining("href", "showuser=")
|
?.getElementsByAttributeValueContaining("href", "showuser=")
|
||||||
?.firstOrNull()
|
?.firstOrNull()
|
||||||
@@ -238,11 +223,6 @@ internal class ExHentaiParser(
|
|||||||
return username
|
return username
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCreateConfig(keys: MutableCollection<ConfigKey<*>>) {
|
|
||||||
super.onCreateConfig(keys)
|
|
||||||
keys.add(suspiciousContentKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun isAuthorized(domain: String): Boolean {
|
private fun isAuthorized(domain: String): Boolean {
|
||||||
val cookies = context.cookieJar.getCookies(domain).mapToSet { x -> x.name }
|
val cookies = context.cookieJar.getCookies(domain).mapToSet { x -> x.name }
|
||||||
return authCookies.all { it in cookies }
|
return authCookies.all { it in cookies }
|
||||||
@@ -296,12 +276,4 @@ internal class ExHentaiParser(
|
|||||||
val num = className.drop(2).toIntOrNull(16) ?: return null
|
val num = className.drop(2).toIntOrNull(16) ?: return null
|
||||||
return 2.0.pow(num).toInt().toString()
|
return 2.0.pow(num).toInt().toString()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
private fun getNextTimestamp(root: Element): Long {
|
|
||||||
return root.getElementById("unext")
|
|
||||||
?.attrAsAbsoluteUrlOrNull("href")
|
|
||||||
?.toHttpUrlOrNull()
|
|
||||||
?.queryParameter("next")
|
|
||||||
?.toLongOrNull() ?: 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,223 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.parsers.site
|
|
||||||
|
|
||||||
import androidx.collection.ArraySet
|
|
||||||
import okhttp3.Headers
|
|
||||||
import okhttp3.Interceptor
|
|
||||||
import okhttp3.Response
|
|
||||||
import org.json.JSONArray
|
|
||||||
import org.json.JSONObject
|
|
||||||
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
|
||||||
import org.koitharu.kotatsu.parsers.MangaSourceParser
|
|
||||||
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.getStringOrNull
|
|
||||||
import org.koitharu.kotatsu.parsers.util.json.mapJSON
|
|
||||||
import org.koitharu.kotatsu.parsers.util.json.mapJSONIndexed
|
|
||||||
import java.text.SimpleDateFormat
|
|
||||||
import java.util.*
|
|
||||||
|
|
||||||
|
|
||||||
private const val PAGE_SIZE = 20
|
|
||||||
private const val INFINITE = 999999
|
|
||||||
private const val HEADER_ENCODING = "Content-Encoding"
|
|
||||||
|
|
||||||
@MangaSourceParser("HONEYMANGA", "Honey Manga", "uk")
|
|
||||||
class HoneyMangaParser(context: MangaLoaderContext) : PagedMangaParser(context, MangaSource.HONEYMANGA, PAGE_SIZE),
|
|
||||||
Interceptor {
|
|
||||||
|
|
||||||
private val urlApi get() = "https://data.api.$domain"
|
|
||||||
private val mangaApi get() = "$urlApi/v2/manga/cursor-list"
|
|
||||||
private val chapterApi get() = "$urlApi/v2/chapter/cursor-list"
|
|
||||||
private val genresListApi get() = "$urlApi/genres-tags/genres-list"
|
|
||||||
private val framesApi get() = "$urlApi/chapter/frames"
|
|
||||||
private val searchApi get() = "https://search.api.$domain/api/v1/title/search-matching?query="
|
|
||||||
|
|
||||||
private val imageStorageUrl = "https://manga-storage.fra1.digitaloceanspaces.com/public-resources"
|
|
||||||
|
|
||||||
override val headers
|
|
||||||
get() = Headers.Builder()
|
|
||||||
.add("User-Agent", "Mozilla/5.0 (Android 13; Mobile; rv:68.0) Gecko/68.0 Firefox/109.0")
|
|
||||||
.build()
|
|
||||||
|
|
||||||
override val configKeyDomain: ConfigKey.Domain
|
|
||||||
get() = ConfigKey.Domain("honey-manga.com.ua", null)
|
|
||||||
|
|
||||||
override val sortOrders: Set<SortOrder> = EnumSet.of(
|
|
||||||
SortOrder.POPULARITY,
|
|
||||||
SortOrder.NEWEST,
|
|
||||||
)
|
|
||||||
|
|
||||||
override suspend fun getDetails(manga: Manga): Manga {
|
|
||||||
val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.US)
|
|
||||||
val body = JSONObject()
|
|
||||||
body.put("mangaId", manga.url)
|
|
||||||
body.put("pageSize", INFINITE) // Hack lol (no)
|
|
||||||
body.put("page", 1)
|
|
||||||
body.put("sortOrder", "ASC")
|
|
||||||
val chapterRequest = webClient.httpPost(chapterApi, body).parseJson()
|
|
||||||
return manga.copy(
|
|
||||||
chapters = chapterRequest.getJSONArray("data").mapJSONIndexed() { i, jo ->
|
|
||||||
MangaChapter(
|
|
||||||
id = generateUid(jo.getString("id")),
|
|
||||||
name = buildString {
|
|
||||||
append("Том ")
|
|
||||||
append(jo.optString("volume", "0"))
|
|
||||||
append(". ")
|
|
||||||
append("Розділ ")
|
|
||||||
append(jo.optString("chapterNum", "0"))
|
|
||||||
if (jo.optString("title") != "Title") {
|
|
||||||
append(" - ")
|
|
||||||
append(jo.optString("title"))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
number = i + 1,
|
|
||||||
url = jo.optString("chapterResourcesId"),
|
|
||||||
scanlator = null,
|
|
||||||
uploadDate = dateFormat.tryParse(jo.getString("lastUpdated")),
|
|
||||||
branch = null,
|
|
||||||
source = source,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun getListPage(
|
|
||||||
page: Int,
|
|
||||||
query: String?,
|
|
||||||
tags: Set<MangaTag>?,
|
|
||||||
sortOrder: SortOrder,
|
|
||||||
): List<Manga> {
|
|
||||||
val body = JSONObject()
|
|
||||||
body.put("page", page)
|
|
||||||
body.put("pageSize", PAGE_SIZE)
|
|
||||||
val sort = JSONObject()
|
|
||||||
sort.put("sortBy", getSortKey(sortOrder))
|
|
||||||
sort.put("sortOrder", "DESC")
|
|
||||||
body.put("sort", sort)
|
|
||||||
|
|
||||||
val content = when {
|
|
||||||
!tags.isNullOrEmpty() -> {
|
|
||||||
// Tags
|
|
||||||
val filters = JSONArray()
|
|
||||||
val tagFilter = JSONObject()
|
|
||||||
tagFilter.put("filterBy", "genres")
|
|
||||||
tagFilter.put("filterOperator", "ALL")
|
|
||||||
val tag = JSONArray()
|
|
||||||
tags.forEach {
|
|
||||||
tag.put(it.title)
|
|
||||||
}
|
|
||||||
tagFilter.put("filterValue", tag)
|
|
||||||
filters.put(tagFilter)
|
|
||||||
body.put("filters", filters)
|
|
||||||
webClient.httpPost(mangaApi, body).parseJson().getJSONArray("data")
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
!query.isNullOrEmpty() -> {
|
|
||||||
// Search
|
|
||||||
when {
|
|
||||||
query.length < 3 -> throw IllegalArgumentException(
|
|
||||||
"The query must contain at least 3 characters (Запит має містити щонайменше 3 символи)",
|
|
||||||
)
|
|
||||||
|
|
||||||
page == searchPaginator.firstPage -> webClient
|
|
||||||
.httpGet(searchApi + query.urlEncoded())
|
|
||||||
.parseJsonArray()
|
|
||||||
|
|
||||||
else -> JSONArray()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
else -> {
|
|
||||||
// Popular/Newest
|
|
||||||
body.put("filters", JSONArray())
|
|
||||||
webClient.httpPost(mangaApi, body).parseJson().getJSONArray("data")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return content.mapJSON { jo ->
|
|
||||||
val id = jo.getString("id")
|
|
||||||
val posterUrl = jo.getString("posterUrl")
|
|
||||||
Manga(
|
|
||||||
id = generateUid(id),
|
|
||||||
title = jo.getString("title"),
|
|
||||||
altTitle = jo.getStringOrNull("alternativeTitle"),
|
|
||||||
url = id,
|
|
||||||
publicUrl = "https://$domain/book/$id",
|
|
||||||
rating = RATING_UNKNOWN,
|
|
||||||
isNsfw = isNsfw(jo.getStringOrNull("adult")),
|
|
||||||
coverUrl = getCoverUrl(posterUrl, 256),
|
|
||||||
tags = getTitleTags(jo.optJSONArray("genresAndTags")),
|
|
||||||
state = when (jo.getStringOrNull("titleStatus")) {
|
|
||||||
"Онгоінг" -> MangaState.ONGOING
|
|
||||||
"Завершено" -> MangaState.FINISHED
|
|
||||||
else -> null
|
|
||||||
},
|
|
||||||
author = null,
|
|
||||||
largeCoverUrl = getCoverUrl(posterUrl, 1080),
|
|
||||||
description = jo.getStringOrNull("description"),
|
|
||||||
chapters = null,
|
|
||||||
source = source,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
|
|
||||||
val body = JSONObject()
|
|
||||||
body.put("chapterId", chapter.url)
|
|
||||||
val content = webClient.httpPost(framesApi, body).parseJson().getJSONObject("resourceIds")
|
|
||||||
return List(content.length()) { i ->
|
|
||||||
val item = content.getString(i.toString())
|
|
||||||
MangaPage(id = generateUid(item), "$imageStorageUrl/$item", getCoverUrl(item, 256), source)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun getTags(): Set<MangaTag> {
|
|
||||||
// https://data.api.honey-manga.com.ua/genres-tags/genres-list
|
|
||||||
val content = webClient.httpGet(genresListApi).parseJsonArray()
|
|
||||||
val tagsSet = ArraySet<MangaTag>(content.length())
|
|
||||||
repeat(content.length()) { i ->
|
|
||||||
val item = content.getString(i)
|
|
||||||
tagsSet.add(MangaTag(item, item, source))
|
|
||||||
}
|
|
||||||
return tagsSet
|
|
||||||
}
|
|
||||||
|
|
||||||
// Need for disable encoding (with encoding not working)
|
|
||||||
override fun intercept(chain: Interceptor.Chain): Response {
|
|
||||||
val request = chain.request()
|
|
||||||
val newRequest = if (request.header(HEADER_ENCODING) != null) {
|
|
||||||
request.newBuilder().removeHeader(HEADER_ENCODING).build()
|
|
||||||
} else {
|
|
||||||
request
|
|
||||||
}
|
|
||||||
return chain.proceed(newRequest)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun isNsfw(adultValue: String?): Boolean {
|
|
||||||
val intValue = adultValue?.removeSuffix('+')?.toIntOrNull()
|
|
||||||
return intValue != null && intValue >= 18
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getCoverUrl(id: String, w: Int): String {
|
|
||||||
// https://honey-manga.com.ua/_next/image?url=https%3A%2F%2Fhoneymangastorage.b-cdn.net%2Fpublic-resources%2F1c4613c2-ffe3-405a-b26a-2cab59ddd223%3Foptimizer%3Dimage%26width%3D512%26height%3D512&w=3840&q=75
|
|
||||||
return "https://$domain/_next/image?url=https%3A%2F%2Fhoneymangastorage.b-cdn.net%2Fpublic-resources%2F$id&w=$w&q=75"
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getSortKey(order: SortOrder?) = when (order) {
|
|
||||||
SortOrder.POPULARITY -> "likes"
|
|
||||||
SortOrder.NEWEST -> "lastUpdated"
|
|
||||||
else -> "likes"
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getTitleTags(jsonTags: JSONArray): Set<MangaTag> {
|
|
||||||
val tagsSet = ArraySet<MangaTag>(jsonTags.length())
|
|
||||||
repeat(jsonTags.length()) { i ->
|
|
||||||
val item = jsonTags.getString(i)
|
|
||||||
|
|
||||||
tagsSet.add(MangaTag(title = item.toTitleCase(sourceLocale), key = item, source = source))
|
|
||||||
}
|
|
||||||
return tagsSet
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,193 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.parsers.site
|
|
||||||
|
|
||||||
import okhttp3.Headers
|
|
||||||
import org.json.JSONObject
|
|
||||||
import org.jsoup.nodes.Element
|
|
||||||
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
|
||||||
import org.koitharu.kotatsu.parsers.MangaSourceParser
|
|
||||||
import org.koitharu.kotatsu.parsers.PagedMangaParser
|
|
||||||
import org.koitharu.kotatsu.parsers.config.ConfigKey
|
|
||||||
import org.koitharu.kotatsu.parsers.exception.ParseException
|
|
||||||
import org.koitharu.kotatsu.parsers.model.*
|
|
||||||
import org.koitharu.kotatsu.parsers.util.*
|
|
||||||
import org.koitharu.kotatsu.parsers.util.json.getStringOrNull
|
|
||||||
import org.koitharu.kotatsu.parsers.util.json.mapJSON
|
|
||||||
import java.text.SimpleDateFormat
|
|
||||||
import java.util.*
|
|
||||||
|
|
||||||
@MangaSourceParser("JAPSCAN", "JapScan", "fr")
|
|
||||||
internal class JapScanParser(context: MangaLoaderContext) : PagedMangaParser(context, MangaSource.JAPSCAN, 30) {
|
|
||||||
|
|
||||||
override val sortOrders: Set<SortOrder> = EnumSet.of(SortOrder.ALPHABETICAL)
|
|
||||||
|
|
||||||
override val configKeyDomain = ConfigKey.Domain("www.japscan.lol", arrayOf("www.japscan.lol", "japscan.ws"))
|
|
||||||
|
|
||||||
override val headers: Headers = Headers.Builder()
|
|
||||||
.add("User-Agent", "Mozilla/5.0 (X11; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/113.0")
|
|
||||||
.build()
|
|
||||||
|
|
||||||
override suspend fun getListPage(
|
|
||||||
page: Int,
|
|
||||||
query: String?,
|
|
||||||
tags: Set<MangaTag>?,
|
|
||||||
sortOrder: SortOrder,
|
|
||||||
): List<Manga> {
|
|
||||||
require(tags.isNullOrEmpty()) { "Tags are not supported by this source" }
|
|
||||||
if (!query.isNullOrEmpty()) {
|
|
||||||
return if (page == paginator.firstPage) getListPageSearch(query) else emptyList()
|
|
||||||
}
|
|
||||||
val url = urlBuilder()
|
|
||||||
.addPathSegment("mangas")
|
|
||||||
.addPathSegment(page.toString())
|
|
||||||
.build()
|
|
||||||
val root = webClient.httpGet(url).parseHtml()
|
|
||||||
.requireElementById("main")
|
|
||||||
.selectFirstOrThrow(".flex-wrap")
|
|
||||||
return root.select(".mainTitle")
|
|
||||||
.map { p ->
|
|
||||||
val div = checkNotNull(p.parent())
|
|
||||||
val a = div.selectFirstOrThrow("a")
|
|
||||||
val href = a.attrAsRelativeUrl("href")
|
|
||||||
Manga(
|
|
||||||
id = generateUid(href),
|
|
||||||
title = p.text(),
|
|
||||||
altTitle = null,
|
|
||||||
url = href,
|
|
||||||
publicUrl = href.toAbsoluteUrl(domain),
|
|
||||||
rating = RATING_UNKNOWN,
|
|
||||||
isNsfw = false,
|
|
||||||
coverUrl = div.selectFirstOrThrow("img").attrAsAbsoluteUrl("src"),
|
|
||||||
tags = setOf(),
|
|
||||||
state = null,
|
|
||||||
author = null,
|
|
||||||
source = source,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun getDetails(manga: Manga): Manga {
|
|
||||||
val root = webClient.httpGet(manga.url.toAbsoluteUrl(domain)).parseHtml().requireElementById("main")
|
|
||||||
val dateFormat = SimpleDateFormat("dd MMM yyyy", Locale.ENGLISH)
|
|
||||||
return manga.copy(
|
|
||||||
altTitle = root.tableValue("Nom Original:"),
|
|
||||||
isNsfw = root.tableValue("Âge conseillé :")?.extractIntOrNull().let { it != null && it >= 18 },
|
|
||||||
tags = root.tableValue("Type(s):")?.split(", ")?.mapNotNullToSet {
|
|
||||||
it.toTag()
|
|
||||||
}.orEmpty() + root.tableValue("Genre(s):")?.split(", ")?.mapNotNullToSet {
|
|
||||||
it.toTag()
|
|
||||||
}.orEmpty(),
|
|
||||||
state = when (root.tableValue("Statut:")) {
|
|
||||||
"En Cours" -> MangaState.ONGOING
|
|
||||||
"Terminé", "Abondonné" -> MangaState.FINISHED
|
|
||||||
else -> null
|
|
||||||
},
|
|
||||||
author = root.tableValue("Artiste(s):")?.substringBefore(','),
|
|
||||||
description = root.selectFirst("p.list-group-item-primary")?.html(),
|
|
||||||
chapters = root.requireElementById("chapters_list")
|
|
||||||
.select("div.chapters_list")
|
|
||||||
.mapChapters(reversed = true) { i, div ->
|
|
||||||
val a = div.selectFirst("a") ?: return@mapChapters null
|
|
||||||
val href = a.attrAsRelativeUrl("href")
|
|
||||||
MangaChapter(
|
|
||||||
id = generateUid(href),
|
|
||||||
name = a.text(),
|
|
||||||
number = i,
|
|
||||||
url = href,
|
|
||||||
scanlator = null,
|
|
||||||
uploadDate = dateFormat.tryParse(div.selectFirst("span.float-right")?.text()),
|
|
||||||
branch = null,
|
|
||||||
source = source,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
|
|
||||||
val chapterUrl = chapter.url.toAbsoluteUrl(domain)
|
|
||||||
val doc = webClient.httpGet(chapterUrl).parseHtml()
|
|
||||||
val scriptUrl = doc.getElementsByTag("script").firstNotNullOf { script ->
|
|
||||||
script.attrAsAbsoluteUrlOrNull("src")?.takeIf { it.contains("/zjs/") }
|
|
||||||
}
|
|
||||||
val embeddedData = doc.requireElementById("data").attr("data-data")
|
|
||||||
val script = webClient.httpGet(scriptUrl).parseRaw()
|
|
||||||
|
|
||||||
val sample = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz".toList()
|
|
||||||
val keyRegex = Regex("""'([\dA-Z]{62})'""", RegexOption.IGNORE_CASE)
|
|
||||||
|
|
||||||
val keyTables = keyRegex.findAll(script)
|
|
||||||
.mapNotNullTo(ArrayList(2)) { match ->
|
|
||||||
match.groupValues[1].takeIf {
|
|
||||||
it.toList().sorted() == sample
|
|
||||||
}
|
|
||||||
}
|
|
||||||
check(keyTables.size == 2)
|
|
||||||
|
|
||||||
var error: Exception? = null
|
|
||||||
repeat(2) { i ->
|
|
||||||
val key = keyTables[i].zip(keyTables[1 - i]).toMap()
|
|
||||||
try {
|
|
||||||
val unscrambledData = embeddedData.map { key[it] ?: it }.joinToString("")
|
|
||||||
if (unscrambledData.startsWith("ey")) {
|
|
||||||
val array = JSONObject(context.decodeBase64(unscrambledData).toString(Charsets.UTF_8))
|
|
||||||
.getJSONArray("imagesLink")
|
|
||||||
val result = ArrayList<MangaPage>(array.length())
|
|
||||||
repeat(array.length()) { index ->
|
|
||||||
val url = array.getString(index)
|
|
||||||
result += MangaPage(
|
|
||||||
id = generateUid(url),
|
|
||||||
url = url,
|
|
||||||
preview = null,
|
|
||||||
source = source,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
error = e
|
|
||||||
}
|
|
||||||
}
|
|
||||||
throw (error ?: ParseException("Cannot decode pages list", chapterUrl))
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun getTags(): Set<MangaTag> {
|
|
||||||
return emptySet() // not supported
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun getListPageSearch(
|
|
||||||
query: String,
|
|
||||||
): List<Manga> {
|
|
||||||
val json = webClient.httpPost(
|
|
||||||
"https://$domain/live-search/",
|
|
||||||
mapOf("search" to query.urlEncoded()),
|
|
||||||
).parseJsonArray()
|
|
||||||
return json.mapJSON { jo ->
|
|
||||||
val url = jo.getString("url")
|
|
||||||
Manga(
|
|
||||||
id = generateUid(url),
|
|
||||||
title = jo.getString("name"),
|
|
||||||
altTitle = jo.getStringOrNull("alternate_names")?.substringBefore(','),
|
|
||||||
url = url,
|
|
||||||
publicUrl = url.toAbsoluteUrl(domain),
|
|
||||||
rating = RATING_UNKNOWN,
|
|
||||||
isNsfw = false,
|
|
||||||
coverUrl = jo.getString("image").toAbsoluteUrl(domain),
|
|
||||||
tags = emptySet(),
|
|
||||||
state = null,
|
|
||||||
author = null,
|
|
||||||
source = source,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun Element.tableValue(label: String): String? {
|
|
||||||
return getElementsMatchingOwnText(label).firstOrNull()?.parent()?.ownTextOrNull()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun String.extractIntOrNull(): Int? = this.filter(Char::isDigit).toIntOrNull()
|
|
||||||
|
|
||||||
private fun String.toTag() = MangaTag(
|
|
||||||
title = this.toTitleCase(sourceLocale),
|
|
||||||
key = this.replace(' ', '-').lowercase(sourceLocale),
|
|
||||||
source = source,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,15 +1,12 @@
|
|||||||
package org.koitharu.kotatsu.parsers.site
|
package org.koitharu.kotatsu.parsers.site
|
||||||
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
import kotlinx.coroutines.awaitAll
|
|
||||||
import kotlinx.coroutines.coroutineScope
|
import kotlinx.coroutines.coroutineScope
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
||||||
import org.koitharu.kotatsu.parsers.MangaParser
|
import org.koitharu.kotatsu.parsers.MangaParser
|
||||||
import org.koitharu.kotatsu.parsers.MangaSourceParser
|
import org.koitharu.kotatsu.parsers.MangaSourceParser
|
||||||
import org.koitharu.kotatsu.parsers.config.ConfigKey
|
import org.koitharu.kotatsu.parsers.config.ConfigKey
|
||||||
import org.koitharu.kotatsu.parsers.exception.ParseException
|
|
||||||
import org.koitharu.kotatsu.parsers.model.*
|
import org.koitharu.kotatsu.parsers.model.*
|
||||||
import org.koitharu.kotatsu.parsers.util.*
|
import org.koitharu.kotatsu.parsers.util.*
|
||||||
import org.koitharu.kotatsu.parsers.util.json.*
|
import org.koitharu.kotatsu.parsers.util.json.*
|
||||||
@@ -17,16 +14,13 @@ import java.text.SimpleDateFormat
|
|||||||
import java.util.*
|
import java.util.*
|
||||||
|
|
||||||
private const val PAGE_SIZE = 20
|
private const val PAGE_SIZE = 20
|
||||||
private const val CHAPTERS_FIRST_PAGE_SIZE = 120
|
private const val CHAPTERS_PAGE_SIZE = 60
|
||||||
private const val CHAPTERS_MAX_PAGE_SIZE = 500
|
|
||||||
private const val CHAPTERS_PARALLELISM = 3
|
|
||||||
private const val CHAPTERS_MAX_COUNT = 10_000 // strange api behavior, looks like a bug
|
|
||||||
private const val CONTENT_RATING =
|
private const val CONTENT_RATING =
|
||||||
"contentRating[]=safe&contentRating[]=suggestive&contentRating[]=erotica&contentRating[]=pornographic"
|
"contentRating[]=safe&contentRating[]=suggestive&contentRating[]=erotica&contentRating[]=pornographic"
|
||||||
private const val LOCALE_FALLBACK = "en"
|
private const val LOCALE_FALLBACK = "en"
|
||||||
|
|
||||||
@MangaSourceParser("MANGADEX", "MangaDex")
|
@MangaSourceParser("MANGADEX", "MangaDex")
|
||||||
internal class MangaDexParser(context: MangaLoaderContext) : MangaParser(context, MangaSource.MANGADEX) {
|
internal class MangaDexParser(override val context: MangaLoaderContext) : MangaParser(MangaSource.MANGADEX) {
|
||||||
|
|
||||||
override val configKeyDomain = ConfigKey.Domain("mangadex.org", null)
|
override val configKeyDomain = ConfigKey.Domain("mangadex.org", null)
|
||||||
|
|
||||||
@@ -43,7 +37,7 @@ internal class MangaDexParser(context: MangaLoaderContext) : MangaParser(context
|
|||||||
tags: Set<MangaTag>?,
|
tags: Set<MangaTag>?,
|
||||||
sortOrder: SortOrder,
|
sortOrder: SortOrder,
|
||||||
): List<Manga> {
|
): List<Manga> {
|
||||||
val domain = domain
|
val domain = getDomain()
|
||||||
val url = buildString {
|
val url = buildString {
|
||||||
append("https://api.")
|
append("https://api.")
|
||||||
append(domain)
|
append(domain)
|
||||||
@@ -68,7 +62,6 @@ internal class MangaDexParser(context: MangaLoaderContext) : MangaParser(context
|
|||||||
when (sortOrder) {
|
when (sortOrder) {
|
||||||
SortOrder.UPDATED,
|
SortOrder.UPDATED,
|
||||||
-> "[latestUploadedChapter]=desc"
|
-> "[latestUploadedChapter]=desc"
|
||||||
|
|
||||||
SortOrder.ALPHABETICAL -> "[title]=asc"
|
SortOrder.ALPHABETICAL -> "[title]=asc"
|
||||||
SortOrder.NEWEST -> "[createdAt]=desc"
|
SortOrder.NEWEST -> "[createdAt]=desc"
|
||||||
SortOrder.POPULARITY -> "[followedCount]=desc"
|
SortOrder.POPULARITY -> "[followedCount]=desc"
|
||||||
@@ -76,7 +69,7 @@ internal class MangaDexParser(context: MangaLoaderContext) : MangaParser(context
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
val json = webClient.httpGet(url).parseJson().getJSONArray("data")
|
val json = context.httpGet(url).parseJson().getJSONArray("data")
|
||||||
return json.mapJSON { jo ->
|
return json.mapJSON { jo ->
|
||||||
val id = jo.getString("id")
|
val id = jo.getString("id")
|
||||||
val attrs = jo.getJSONObject("attributes")
|
val attrs = jo.getJSONObject("attributes")
|
||||||
@@ -124,14 +117,13 @@ internal class MangaDexParser(context: MangaLoaderContext) : MangaParser(context
|
|||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getDetails(manga: Manga): Manga = coroutineScope {
|
override suspend fun getDetails(manga: Manga): Manga = coroutineScope {
|
||||||
val domain = domain
|
val domain = getDomain()
|
||||||
val mangaId = manga.url.removePrefix("/")
|
|
||||||
val attrsDeferred = async {
|
val attrsDeferred = async {
|
||||||
webClient.httpGet(
|
context.httpGet(
|
||||||
"https://api.$domain/manga/${mangaId}?includes[]=artist&includes[]=author&includes[]=cover_art",
|
"https://api.$domain/manga/${manga.url}?includes[]=artist&includes[]=author&includes[]=cover_art",
|
||||||
).parseJson().getJSONObject("data").getJSONObject("attributes")
|
).parseJson().getJSONObject("data").getJSONObject("attributes")
|
||||||
}
|
}
|
||||||
val feedDeferred = async { loadChapters(mangaId) }
|
val feedDeferred = async { loadChapters(manga) }
|
||||||
val mangaAttrs = attrsDeferred.await()
|
val mangaAttrs = attrsDeferred.await()
|
||||||
val feed = feedDeferred.await()
|
val feed = feedDeferred.await()
|
||||||
// 2022-01-02T00:27:11+00:00
|
// 2022-01-02T00:27:11+00:00
|
||||||
@@ -140,22 +132,22 @@ internal class MangaDexParser(context: MangaLoaderContext) : MangaParser(context
|
|||||||
Locale.ROOT,
|
Locale.ROOT,
|
||||||
)
|
)
|
||||||
manga.copy(
|
manga.copy(
|
||||||
description = mangaAttrs.optJSONObject("description")?.selectByLocale()
|
description = mangaAttrs.getJSONObject("description").selectByLocale()
|
||||||
?: manga.description,
|
?: manga.description,
|
||||||
chapters = feed.mapChapters { _, jo ->
|
chapters = feed.mapNotNull { jo ->
|
||||||
val id = jo.getString("id")
|
val id = jo.getString("id")
|
||||||
val attrs = jo.getJSONObject("attributes")
|
val attrs = jo.getJSONObject("attributes")
|
||||||
if (!attrs.isNull("externalUrl")) {
|
if (!attrs.isNull("externalUrl")) {
|
||||||
return@mapChapters null
|
return@mapNotNull null
|
||||||
}
|
}
|
||||||
val locale = attrs.getStringOrNull("translatedLanguage")?.let { Locale.forLanguageTag(it) }
|
val locale = attrs.getStringOrNull("translatedLanguage")?.let { Locale.forLanguageTag(it) }
|
||||||
val relations = jo.getJSONArray("relationships").associateByKey("type")
|
val relations = jo.getJSONArray("relationships").associateByKey("type")
|
||||||
val number = attrs.getFloatOrDefault("chapter", 0f)
|
val number = attrs.getIntOrDefault("chapter", 0)
|
||||||
MangaChapter(
|
MangaChapter(
|
||||||
id = generateUid(id),
|
id = generateUid(id),
|
||||||
name = attrs.getStringOrNull("title")?.takeUnless(String::isEmpty)
|
name = attrs.getStringOrNull("title")?.takeUnless(String::isEmpty)
|
||||||
?: "Chapter #$number",
|
?: "Chapter #$number",
|
||||||
number = number.toInt(),
|
number = number,
|
||||||
url = id,
|
url = id,
|
||||||
scanlator = relations["scanlation_group"]?.getStringOrNull("name"),
|
scanlator = relations["scanlation_group"]?.getStringOrNull("name"),
|
||||||
uploadDate = dateFormat.tryParse(attrs.getString("publishAt")),
|
uploadDate = dateFormat.tryParse(attrs.getString("publishAt")),
|
||||||
@@ -167,17 +159,19 @@ internal class MangaDexParser(context: MangaLoaderContext) : MangaParser(context
|
|||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
|
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
|
||||||
val domain = domain
|
val domain = getDomain()
|
||||||
val chapterJson = webClient.httpGet("https://api.$domain/at-home/server/${chapter.url}?forcePort443=false")
|
val chapterJson = context.httpGet("https://api.$domain/at-home/server/${chapter.url}?forcePort443=false")
|
||||||
.parseJson()
|
.parseJson()
|
||||||
.getJSONObject("chapter")
|
.getJSONObject("chapter")
|
||||||
val pages = chapterJson.getJSONArray("data")
|
val pages = chapterJson.getJSONArray("data")
|
||||||
val prefix = "https://uploads.$domain/data/${chapterJson.getString("hash")}/"
|
val prefix = "https://uploads.$domain/data/${chapterJson.getString("hash")}/"
|
||||||
|
val referer = "https://$domain/"
|
||||||
return List(pages.length()) { i ->
|
return List(pages.length()) { i ->
|
||||||
val url = prefix + pages.getString(i)
|
val url = prefix + pages.getString(i)
|
||||||
MangaPage(
|
MangaPage(
|
||||||
id = generateUid(url),
|
id = generateUid(url),
|
||||||
url = url,
|
url = url,
|
||||||
|
referer = referer,
|
||||||
preview = null, // TODO prefix + dataSaver.getString(i),
|
preview = null, // TODO prefix + dataSaver.getString(i),
|
||||||
source = source,
|
source = source,
|
||||||
)
|
)
|
||||||
@@ -185,7 +179,7 @@ internal class MangaDexParser(context: MangaLoaderContext) : MangaParser(context
|
|||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getTags(): Set<MangaTag> {
|
override suspend fun getTags(): Set<MangaTag> {
|
||||||
val tags = webClient.httpGet("https://api.${domain}/manga/tag").parseJson()
|
val tags = context.httpGet("https://api.${getDomain()}/manga/tag").parseJson()
|
||||||
.getJSONArray("data")
|
.getJSONArray("data")
|
||||||
return tags.mapJSONToSet { jo ->
|
return tags.mapJSONToSet { jo ->
|
||||||
MangaTag(
|
MangaTag(
|
||||||
@@ -207,67 +201,30 @@ internal class MangaDexParser(context: MangaLoaderContext) : MangaParser(context
|
|||||||
return getStringOrNull(LOCALE_FALLBACK) ?: values().nextOrNull() as? String
|
return getStringOrNull(LOCALE_FALLBACK) ?: values().nextOrNull() as? String
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun loadChapters(mangaId: String): List<JSONObject> {
|
private suspend fun loadChapters(manga: Manga): List<JSONObject> {
|
||||||
val firstPage = loadChapters(mangaId, offset = 0, limit = CHAPTERS_FIRST_PAGE_SIZE)
|
val domain = getDomain()
|
||||||
if (firstPage.size >= firstPage.total) {
|
val result = ArrayList<JSONObject>()
|
||||||
return firstPage.data
|
while (true) {
|
||||||
|
val url = buildString {
|
||||||
|
append("https://api.")
|
||||||
|
append(domain)
|
||||||
|
append("/manga/")
|
||||||
|
append(manga.url)
|
||||||
|
append("/feed")
|
||||||
|
append("?limit=")
|
||||||
|
append(CHAPTERS_PAGE_SIZE)
|
||||||
|
append("&includes[]=scanlation_group&order[volume]=asc&order[chapter]=asc&offset=")
|
||||||
|
append(result.size)
|
||||||
|
append('&')
|
||||||
|
append(CONTENT_RATING)
|
||||||
|
}
|
||||||
|
val json = context.httpGet(url).parseJson()
|
||||||
|
val data = json.optJSONArray("data")
|
||||||
|
if (data.isNullOrEmpty()) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
data.mapJSONTo(result) { it }
|
||||||
}
|
}
|
||||||
val tail = coroutineScope {
|
|
||||||
val leftCount = firstPage.total.coerceAtMost(CHAPTERS_MAX_COUNT) - firstPage.size
|
|
||||||
val pages = (leftCount / CHAPTERS_MAX_PAGE_SIZE.toFloat()).toIntUp()
|
|
||||||
val dispatcher = Dispatchers.Default.limitedParallelism(CHAPTERS_PARALLELISM)
|
|
||||||
List(pages) { page ->
|
|
||||||
val offset = page * CHAPTERS_MAX_PAGE_SIZE + firstPage.size
|
|
||||||
async(dispatcher) {
|
|
||||||
loadChapters(mangaId, offset, CHAPTERS_MAX_PAGE_SIZE)
|
|
||||||
}
|
|
||||||
}.awaitAll()
|
|
||||||
}
|
|
||||||
val result = ArrayList<JSONObject>(firstPage.total)
|
|
||||||
result += firstPage.data
|
|
||||||
tail.flatMapTo(result) { it.data }
|
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
}
|
||||||
private suspend fun loadChapters(mangaId: String, offset: Int, limit: Int): Chapters {
|
|
||||||
val limitedLimit = when {
|
|
||||||
offset >= CHAPTERS_MAX_COUNT -> return Chapters(emptyList(), CHAPTERS_MAX_COUNT)
|
|
||||||
offset + limit > CHAPTERS_MAX_COUNT -> CHAPTERS_MAX_COUNT - offset
|
|
||||||
else -> limit
|
|
||||||
}
|
|
||||||
val url = buildString {
|
|
||||||
append("https://api.")
|
|
||||||
append(domain)
|
|
||||||
append("/manga/")
|
|
||||||
append(mangaId)
|
|
||||||
append("/feed")
|
|
||||||
append("?limit=")
|
|
||||||
append(limitedLimit)
|
|
||||||
append("&includes[]=scanlation_group&order[volume]=asc&order[chapter]=asc&offset=")
|
|
||||||
append(offset)
|
|
||||||
append('&')
|
|
||||||
append(CONTENT_RATING)
|
|
||||||
}
|
|
||||||
val json = webClient.httpGet(url).parseJson()
|
|
||||||
if (json.getString("result") == "ok") {
|
|
||||||
return Chapters(
|
|
||||||
data = json.optJSONArray("data")?.toJSONList().orEmpty(),
|
|
||||||
total = json.getInt("total"),
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
val error = json.optJSONArray("errors").mapJSON { jo ->
|
|
||||||
jo.getString("detail")
|
|
||||||
}.joinToString("\n")
|
|
||||||
throw ParseException(error, url)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private class Chapters(
|
|
||||||
val data: List<JSONObject>,
|
|
||||||
val total: Int,
|
|
||||||
) {
|
|
||||||
|
|
||||||
val size: Int
|
|
||||||
get() = data.size
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -12,8 +12,7 @@ import java.util.*
|
|||||||
private const val DEF_BRANCH_NAME = "Основний переклад"
|
private const val DEF_BRANCH_NAME = "Основний переклад"
|
||||||
|
|
||||||
@MangaSourceParser("MANGAINUA", "MANGA/in/UA", "uk")
|
@MangaSourceParser("MANGAINUA", "MANGA/in/UA", "uk")
|
||||||
class MangaInUaParser(context: MangaLoaderContext) : PagedMangaParser(
|
class MangaInUaParser(override val context: MangaLoaderContext) : PagedMangaParser(
|
||||||
context = context,
|
|
||||||
source = MangaSource.MANGAINUA,
|
source = MangaSource.MANGAINUA,
|
||||||
pageSize = 24,
|
pageSize = 24,
|
||||||
searchPageSize = 10,
|
searchPageSize = 10,
|
||||||
@@ -32,21 +31,21 @@ class MangaInUaParser(context: MangaLoaderContext) : PagedMangaParser(
|
|||||||
): List<Manga> {
|
): List<Manga> {
|
||||||
val url = when {
|
val url = when {
|
||||||
!query.isNullOrEmpty() -> (
|
!query.isNullOrEmpty() -> (
|
||||||
"/index.php?do=search" +
|
"/index.php?do=search" +
|
||||||
"&subaction=search" +
|
"&subaction=search" +
|
||||||
"&search_start=$page" +
|
"&search_start=$page" +
|
||||||
"&full_search=1" +
|
"&full_search=1" +
|
||||||
"&story=$query" +
|
"&story=$query" +
|
||||||
"&titleonly=3"
|
"&titleonly=3"
|
||||||
).toAbsoluteUrl(domain)
|
).toAbsoluteUrl(getDomain())
|
||||||
|
|
||||||
tags.isNullOrEmpty() -> "/mangas/page/$page".toAbsoluteUrl(domain)
|
tags.isNullOrEmpty() -> "/mangas/page/$page".toAbsoluteUrl(getDomain())
|
||||||
tags.size == 1 -> "${tags.first().key}/page/$page"
|
tags.size == 1 -> "${tags.first().key}/page/$page"
|
||||||
tags.size > 1 -> throw IllegalArgumentException("This source supports only 1 genre")
|
tags.size > 1 -> throw IllegalArgumentException("This source supports only 1 genre")
|
||||||
else -> "/mangas/page/$page".toAbsoluteUrl(domain)
|
else -> "/mangas/page/$page".toAbsoluteUrl(getDomain())
|
||||||
}
|
}
|
||||||
val doc = webClient.httpGet(url).parseHtml()
|
val doc = context.httpGet(url).parseHtml()
|
||||||
val container = doc.body().requireElementById("site-content")
|
val container = doc.body().requireElementById("dle-content")
|
||||||
val items = container.select("div.col-6")
|
val items = container.select("div.col-6")
|
||||||
return items.mapNotNull { item ->
|
return items.mapNotNull { item ->
|
||||||
val href = item.selectFirst("a")?.attrAsRelativeUrl("href") ?: return@mapNotNull null
|
val href = item.selectFirst("a")?.attrAsRelativeUrl("href") ?: return@mapNotNull null
|
||||||
@@ -74,17 +73,19 @@ class MangaInUaParser(context: MangaLoaderContext) : PagedMangaParser(
|
|||||||
}
|
}
|
||||||
}.getOrNull().orEmpty(),
|
}.getOrNull().orEmpty(),
|
||||||
state = null,
|
state = null,
|
||||||
publicUrl = href.toAbsoluteUrl(container.host ?: domain),
|
publicUrl = href.toAbsoluteUrl(container.host ?: getDomain()),
|
||||||
source = source,
|
source = source,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getDetails(manga: Manga): Manga {
|
override suspend fun getDetails(manga: Manga): Manga {
|
||||||
val doc = webClient.httpGet(manga.url.toAbsoluteUrl(domain)).parseHtml()
|
val doc = context.httpGet(manga.url.toAbsoluteUrl(getDomain())).parseHtml()
|
||||||
val root = doc.body().requireElementById("site-content")
|
val root = doc.body().requireElementById("dle-content")
|
||||||
val dateFormat = SimpleDateFormat("dd.MM.yyyy", Locale.US)
|
val dateFormat = SimpleDateFormat("dd.MM.yyyy", Locale.US)
|
||||||
val chapterNodes = root.selectFirstOrThrow(".linkstocomics").select(".ltcitems")
|
val chapterNodes = root.selectFirstOrThrow(".linkstocomics")
|
||||||
|
.select(".ltcitems")
|
||||||
|
.filterNot { x -> x.selectFirst(".foruserreadedbar") != null }
|
||||||
var prevChapterName: String? = null
|
var prevChapterName: String? = null
|
||||||
var i = 0
|
var i = 0
|
||||||
return manga.copy(
|
return manga.copy(
|
||||||
@@ -121,8 +122,8 @@ class MangaInUaParser(context: MangaLoaderContext) : PagedMangaParser(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
|
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
|
||||||
val fullUrl = chapter.url.toAbsoluteUrl(domain)
|
val fullUrl = chapter.url.toAbsoluteUrl(getDomain())
|
||||||
val doc = webClient.httpGet(fullUrl).parseHtml()
|
val doc = context.httpGet(fullUrl).parseHtml()
|
||||||
val root = doc.body().requireElementById("comics").selectFirstOrThrow("ul.xfieldimagegallery")
|
val root = doc.body().requireElementById("comics").selectFirstOrThrow("ul.xfieldimagegallery")
|
||||||
return root.select("li").map { ul ->
|
return root.select("li").map { ul ->
|
||||||
val img = ul.selectFirstOrThrow("img")
|
val img = ul.selectFirstOrThrow("img")
|
||||||
@@ -131,14 +132,15 @@ class MangaInUaParser(context: MangaLoaderContext) : PagedMangaParser(
|
|||||||
id = generateUid(url),
|
id = generateUid(url),
|
||||||
url = url,
|
url = url,
|
||||||
preview = null,
|
preview = null,
|
||||||
|
referer = fullUrl,
|
||||||
source = source,
|
source = source,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getTags(): Set<MangaTag> {
|
override suspend fun getTags(): Set<MangaTag> {
|
||||||
val domain = domain
|
val domain = getDomain()
|
||||||
val doc = webClient.httpGet("https://$domain/mangas").parseHtml()
|
val doc = context.httpGet("https://$domain/mangas").parseHtml()
|
||||||
val root = doc.body().requireElementById("menu_1").selectFirstOrThrow("div.menu__wrapper")
|
val root = doc.body().requireElementById("menu_1").selectFirstOrThrow("div.menu__wrapper")
|
||||||
return root.select("li").mapNotNullToSet { li ->
|
return root.select("li").mapNotNullToSet { li ->
|
||||||
val a = li.selectFirst("a") ?: return@mapNotNullToSet null
|
val a = li.selectFirst("a") ?: return@mapNotNullToSet null
|
||||||
@@ -149,4 +151,4 @@ class MangaInUaParser(context: MangaLoaderContext) : PagedMangaParser(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
package org.koitharu.kotatsu.parsers.site
|
||||||
|
|
||||||
|
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
||||||
|
import org.koitharu.kotatsu.parsers.MangaParser
|
||||||
|
import org.koitharu.kotatsu.parsers.MangaSourceParser
|
||||||
|
import org.koitharu.kotatsu.parsers.config.ConfigKey
|
||||||
|
import org.koitharu.kotatsu.parsers.model.*
|
||||||
|
import org.koitharu.kotatsu.parsers.util.*
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.*
|
||||||
|
|
||||||
|
@MangaSourceParser("MANGAOWL", "MangaOwl", "en")
|
||||||
|
internal class MangaOwlParser(override val context: MangaLoaderContext) : MangaParser(MangaSource.MANGAOWL) {
|
||||||
|
|
||||||
|
override val configKeyDomain = ConfigKey.Domain("mangaowls.com", null)
|
||||||
|
|
||||||
|
override val sortOrders: Set<SortOrder> = EnumSet.of(
|
||||||
|
SortOrder.POPULARITY,
|
||||||
|
SortOrder.NEWEST,
|
||||||
|
SortOrder.UPDATED,
|
||||||
|
)
|
||||||
|
|
||||||
|
private val regexNsfw = Regex("(yaoi)|(yuri)|(smut)|(mature)|(adult)", RegexOption.IGNORE_CASE)
|
||||||
|
|
||||||
|
override suspend fun getList(
|
||||||
|
offset: Int,
|
||||||
|
query: String?,
|
||||||
|
tags: Set<MangaTag>?,
|
||||||
|
sortOrder: SortOrder,
|
||||||
|
): List<Manga> {
|
||||||
|
val page = (offset / 36f).toIntUp().inc()
|
||||||
|
val link = buildString {
|
||||||
|
append("https://")
|
||||||
|
append(getDomain())
|
||||||
|
when {
|
||||||
|
!query.isNullOrEmpty() -> {
|
||||||
|
append("/search/$page?search=")
|
||||||
|
append(query.urlEncoded())
|
||||||
|
}
|
||||||
|
|
||||||
|
!tags.isNullOrEmpty() -> {
|
||||||
|
for (tag in tags) {
|
||||||
|
append(tag.key)
|
||||||
|
}
|
||||||
|
append("/$page?type=${getAlternativeSortKey(sortOrder)}")
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
append("/${getSortKey(sortOrder)}/$page")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val doc = context.httpGet(link).parseHtml()
|
||||||
|
val slides = doc.body().selectOrThrow("ul.slides")
|
||||||
|
val items = slides.select("div.col-md-2")
|
||||||
|
return items.mapNotNull { item ->
|
||||||
|
val href = item.selectFirst("h6 a")?.attrAsRelativeUrlOrNull("href") ?: return@mapNotNull null
|
||||||
|
Manga(
|
||||||
|
id = generateUid(href),
|
||||||
|
title = item.selectFirst("h6 a")?.text() ?: return@mapNotNull null,
|
||||||
|
coverUrl = item.select("div.img-responsive").attr("abs:data-background-image"),
|
||||||
|
altTitle = null,
|
||||||
|
author = null,
|
||||||
|
rating = runCatching {
|
||||||
|
item.selectFirst("div.block-stars")
|
||||||
|
?.text()
|
||||||
|
?.toFloatOrNull()
|
||||||
|
?.div(10f)
|
||||||
|
}.getOrNull() ?: RATING_UNKNOWN,
|
||||||
|
url = href,
|
||||||
|
isNsfw = false,
|
||||||
|
tags = emptySet(),
|
||||||
|
state = null,
|
||||||
|
publicUrl = href.toAbsoluteUrl(getDomain()),
|
||||||
|
source = source,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getDetails(manga: Manga): Manga {
|
||||||
|
val doc = context.httpGet(manga.publicUrl).parseHtml()
|
||||||
|
val info = doc.body().selectFirstOrThrow("div.single_detail")
|
||||||
|
val table = doc.body().selectFirstOrThrow("div.single-grid-right")
|
||||||
|
val dateFormat = SimpleDateFormat("MM/dd/yyyy", Locale.US)
|
||||||
|
val trRegex = "window\\['tr'] = '([^']*)';".toRegex(RegexOption.IGNORE_CASE)
|
||||||
|
val trElement = doc.getElementsByTag("script").find { trRegex.find(it.data()) != null }
|
||||||
|
?: doc.parseFailed("Oops, tr not found")
|
||||||
|
val tr = trRegex.find(trElement.data())!!.groups[1]!!.value
|
||||||
|
val s = context.encodeBase64(getDomain().toByteArray())
|
||||||
|
var isNsfw = manga.isNsfw
|
||||||
|
val parsedTags = info.select("div.col-xs-12.col-md-8.single-right-grid-right > p > a[href*=genres]")
|
||||||
|
.mapNotNullToSet {
|
||||||
|
val a = it.selectFirst("a") ?: return@mapNotNullToSet null
|
||||||
|
val name = a.text()
|
||||||
|
if (!isNsfw && isNsfwGenre(name)) {
|
||||||
|
isNsfw = true
|
||||||
|
}
|
||||||
|
MangaTag(
|
||||||
|
title = name.toTitleCase(),
|
||||||
|
key = a.attr("href"),
|
||||||
|
source = source,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return manga.copy(
|
||||||
|
description = info.selectFirst(".description")?.html(),
|
||||||
|
largeCoverUrl = info.select("img").first()?.let { img ->
|
||||||
|
if (img.hasAttr("data-src")) img.attr("abs:data-src") else img.attr("abs:src")
|
||||||
|
},
|
||||||
|
isNsfw = isNsfw,
|
||||||
|
author = info.selectFirst("p.fexi_header_para a.author_link")?.text(),
|
||||||
|
state = parseStatus(info.select("p.fexi_header_para:contains(status)").first()?.ownText()),
|
||||||
|
tags = manga.tags + parsedTags,
|
||||||
|
chapters = table.select("div.table.table-chapter-list").select("li.list-group-item.chapter_list")
|
||||||
|
.asReversed().mapChapters { i, li ->
|
||||||
|
val a = li.select("a")
|
||||||
|
val href = a.attr("data-href").ifEmpty {
|
||||||
|
li.parseFailed("Link is missing")
|
||||||
|
}
|
||||||
|
MangaChapter(
|
||||||
|
id = generateUid(href),
|
||||||
|
name = a.select("label").text(),
|
||||||
|
number = i + 1,
|
||||||
|
url = "$href?tr=$tr&s=$s",
|
||||||
|
scanlator = null,
|
||||||
|
branch = null,
|
||||||
|
uploadDate = dateFormat.tryParse(li.selectFirst("small:last-of-type")?.text()),
|
||||||
|
source = MangaSource.MANGAOWL,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
|
||||||
|
val fullUrl = chapter.url.toAbsoluteUrl(getDomain())
|
||||||
|
val doc = context.httpGet(fullUrl).parseHtml()
|
||||||
|
val root = doc.body().selectOrThrow("div.item img.owl-lazy")
|
||||||
|
return root.map { div ->
|
||||||
|
val url = div?.attrAsRelativeUrlOrNull("data-src") ?: doc.parseFailed("Page image not found")
|
||||||
|
MangaPage(
|
||||||
|
id = generateUid(url),
|
||||||
|
url = url,
|
||||||
|
preview = null,
|
||||||
|
referer = url,
|
||||||
|
source = MangaSource.MANGAOWL,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseStatus(status: String?) = when {
|
||||||
|
status == null -> null
|
||||||
|
status.contains("Ongoing") -> MangaState.ONGOING
|
||||||
|
status.contains("Completed") -> MangaState.FINISHED
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getTags(): Set<MangaTag> {
|
||||||
|
val doc = context.httpGet("https://${getDomain()}/").parseHtml()
|
||||||
|
val root = doc.body().select("ul.dropdown-menu.multi-column.columns-3").select("li")
|
||||||
|
return root.mapToSet { p ->
|
||||||
|
val a = p.selectFirstOrThrow("a")
|
||||||
|
MangaTag(
|
||||||
|
title = a.text().toTitleCase(),
|
||||||
|
key = a.attr("href"),
|
||||||
|
source = source,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getSortKey(sortOrder: SortOrder) =
|
||||||
|
when (sortOrder) {
|
||||||
|
SortOrder.POPULARITY -> "popular"
|
||||||
|
SortOrder.NEWEST -> "new_release"
|
||||||
|
SortOrder.UPDATED -> "lastest"
|
||||||
|
else -> "lastest"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getAlternativeSortKey(sortOrder: SortOrder) =
|
||||||
|
when (sortOrder) {
|
||||||
|
SortOrder.POPULARITY -> "0"
|
||||||
|
SortOrder.NEWEST -> "2"
|
||||||
|
SortOrder.UPDATED -> "3"
|
||||||
|
else -> "3"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isNsfwGenre(name: String): Boolean = regexNsfw.containsMatchIn(name)
|
||||||
|
}
|
||||||
@@ -11,7 +11,7 @@ import java.text.SimpleDateFormat
|
|||||||
import java.util.*
|
import java.util.*
|
||||||
|
|
||||||
@MangaSourceParser("MANGATOWN", "MangaTown", "en")
|
@MangaSourceParser("MANGATOWN", "MangaTown", "en")
|
||||||
internal class MangaTownParser(context: MangaLoaderContext) : MangaParser(context, MangaSource.MANGATOWN) {
|
internal class MangaTownParser(override val context: MangaLoaderContext) : MangaParser(MangaSource.MANGATOWN) {
|
||||||
|
|
||||||
override val configKeyDomain = ConfigKey.Domain("www.mangatown.com", null)
|
override val configKeyDomain = ConfigKey.Domain("www.mangatown.com", null)
|
||||||
|
|
||||||
@@ -42,18 +42,18 @@ internal class MangaTownParser(context: MangaLoaderContext) : MangaParser(contex
|
|||||||
if (offset != 0) {
|
if (offset != 0) {
|
||||||
return emptyList()
|
return emptyList()
|
||||||
}
|
}
|
||||||
"/search?name=${query.urlEncoded()}".toAbsoluteUrl(domain)
|
"/search?name=${query.urlEncoded()}".toAbsoluteUrl(getDomain())
|
||||||
}
|
}
|
||||||
|
|
||||||
tags.isNullOrEmpty() -> "/directory/$page.htm$sortKey".toAbsoluteUrl(domain)
|
tags.isNullOrEmpty() -> "/directory/$page.htm$sortKey".toAbsoluteUrl(getDomain())
|
||||||
tags.size == 1 -> "/directory/${tags.first().key}/$page.htm$sortKey".toAbsoluteUrl(domain)
|
tags.size == 1 -> "/directory/${tags.first().key}/$page.htm$sortKey".toAbsoluteUrl(getDomain())
|
||||||
else -> tags.joinToString(
|
else -> tags.joinToString(
|
||||||
prefix = "/search?page=$page".toAbsoluteUrl(domain),
|
prefix = "/search?page=$page".toAbsoluteUrl(getDomain()),
|
||||||
) { tag ->
|
) { tag ->
|
||||||
"&genres[${tag.key}]=1"
|
"&genres[${tag.key}]=1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val doc = webClient.httpGet(url).parseHtml()
|
val doc = context.httpGet(url).parseHtml()
|
||||||
val root = doc.body().selectFirstOrThrow("ul.manga_pic_list")
|
val root = doc.body().selectFirstOrThrow("ul.manga_pic_list")
|
||||||
return root.select("li").mapNotNull { li ->
|
return root.select("li").mapNotNull { li ->
|
||||||
val a = li.selectFirst("a.manga_cover")
|
val a = li.selectFirst("a.manga_cover")
|
||||||
@@ -87,13 +87,13 @@ internal class MangaTownParser(context: MangaLoaderContext) : MangaParser(contex
|
|||||||
}.orEmpty(),
|
}.orEmpty(),
|
||||||
url = href,
|
url = href,
|
||||||
isNsfw = false,
|
isNsfw = false,
|
||||||
publicUrl = href.toAbsoluteUrl(a.host ?: domain),
|
publicUrl = href.toAbsoluteUrl(a.host ?: getDomain()),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getDetails(manga: Manga): Manga {
|
override suspend fun getDetails(manga: Manga): Manga {
|
||||||
val doc = webClient.httpGet(manga.url.toAbsoluteUrl(domain)).parseHtml()
|
val doc = context.httpGet(manga.url.toAbsoluteUrl(getDomain())).parseHtml()
|
||||||
val root = doc.body().selectFirstOrThrow("section.main")
|
val root = doc.body().selectFirstOrThrow("section.main")
|
||||||
.selectFirstOrThrow("div.article_content")
|
.selectFirstOrThrow("div.article_content")
|
||||||
val info = root.selectFirst("div.detail_info")?.selectFirst("ul")
|
val info = root.selectFirst("div.detail_info")?.selectFirst("ul")
|
||||||
@@ -135,8 +135,8 @@ internal class MangaTownParser(context: MangaLoaderContext) : MangaParser(contex
|
|||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
|
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
|
||||||
val fullUrl = chapter.url.toAbsoluteUrl(domain)
|
val fullUrl = chapter.url.toAbsoluteUrl(getDomain())
|
||||||
val doc = webClient.httpGet(fullUrl).parseHtml()
|
val doc = context.httpGet(fullUrl).parseHtml()
|
||||||
val root = doc.body().selectFirstOrThrow("div.page_select")
|
val root = doc.body().selectFirstOrThrow("div.page_select")
|
||||||
return root.selectFirstOrThrow("select").selectOrThrow("option").mapNotNull {
|
return root.selectFirstOrThrow("select").selectOrThrow("option").mapNotNull {
|
||||||
val href = it.attrAsRelativeUrlOrNull("value")
|
val href = it.attrAsRelativeUrlOrNull("value")
|
||||||
@@ -147,18 +147,19 @@ internal class MangaTownParser(context: MangaLoaderContext) : MangaParser(contex
|
|||||||
id = generateUid(href),
|
id = generateUid(href),
|
||||||
url = href,
|
url = href,
|
||||||
preview = null,
|
preview = null,
|
||||||
|
referer = fullUrl,
|
||||||
source = MangaSource.MANGATOWN,
|
source = MangaSource.MANGATOWN,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getPageUrl(page: MangaPage): String {
|
override suspend fun getPageUrl(page: MangaPage): String {
|
||||||
val doc = webClient.httpGet(page.url.toAbsoluteUrl(domain)).parseHtml()
|
val doc = context.httpGet(page.url.toAbsoluteUrl(getDomain())).parseHtml()
|
||||||
return doc.requireElementById("image").attrAsAbsoluteUrl("src")
|
return doc.requireElementById("image").attrAsAbsoluteUrl("src")
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getTags(): Set<MangaTag> {
|
override suspend fun getTags(): Set<MangaTag> {
|
||||||
val doc = webClient.httpGet("/directory/".toAbsoluteUrl(domain)).parseHtml()
|
val doc = context.httpGet("/directory/".toAbsoluteUrl(getDomain())).parseHtml()
|
||||||
val root = doc.body().selectFirst("aside.right")
|
val root = doc.body().selectFirst("aside.right")
|
||||||
?.getElementsContainingOwnText("Genres")
|
?.getElementsContainingOwnText("Genres")
|
||||||
?.first()
|
?.first()
|
||||||
@@ -187,7 +188,7 @@ internal class MangaTownParser(context: MangaLoaderContext) : MangaParser(contex
|
|||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun bypassLicensedChapters(manga: Manga): List<MangaChapter> {
|
private suspend fun bypassLicensedChapters(manga: Manga): List<MangaChapter> {
|
||||||
val doc = webClient.httpGet(manga.url.toAbsoluteUrl(getDomain("m"))).parseHtml()
|
val doc = context.httpGet(manga.url.toAbsoluteUrl(getDomain("m"))).parseHtml()
|
||||||
val list = doc.body().selectFirst("ul.detail-ch-list") ?: return emptyList()
|
val list = doc.body().selectFirst("ul.detail-ch-list") ?: return emptyList()
|
||||||
val dateFormat = SimpleDateFormat("MMM dd,yyyy", Locale.US)
|
val dateFormat = SimpleDateFormat("MMM dd,yyyy", Locale.US)
|
||||||
return list.select("li").asReversed().mapIndexedNotNull { i, li ->
|
return list.select("li").asReversed().mapIndexedNotNull { i, li ->
|
||||||
@@ -213,4 +214,4 @@ internal class MangaTownParser(context: MangaLoaderContext) : MangaParser(contex
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun String.parseTagKey() = split('/').findLast { regexTag matches it }
|
private fun String.parseTagKey() = split('/').findLast { regexTag matches it }
|
||||||
}
|
}
|
||||||
@@ -1,196 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.parsers.site
|
|
||||||
|
|
||||||
import androidx.collection.ArrayMap
|
|
||||||
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
|
||||||
import org.koitharu.kotatsu.parsers.MangaSourceParser
|
|
||||||
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 java.util.*
|
|
||||||
|
|
||||||
@MangaSourceParser("MANHWA18", "Manhwa18", "en")
|
|
||||||
class Manhwa18Parser(context: MangaLoaderContext) :
|
|
||||||
PagedMangaParser(context, MangaSource.MANHWA18, pageSize = 20, searchPageSize = 20) {
|
|
||||||
|
|
||||||
override val configKeyDomain: ConfigKey.Domain
|
|
||||||
get() = ConfigKey.Domain("manhwa18.net", null)
|
|
||||||
|
|
||||||
override val sortOrders: Set<SortOrder>
|
|
||||||
get() = EnumSet.of(SortOrder.UPDATED, SortOrder.POPULARITY, SortOrder.ALPHABETICAL, SortOrder.NEWEST)
|
|
||||||
|
|
||||||
private val tagsMap = SuspendLazy(::parseTags)
|
|
||||||
|
|
||||||
override suspend fun getFavicons(): Favicons {
|
|
||||||
return Favicons(
|
|
||||||
listOf(
|
|
||||||
Favicon("https://${domain}/uploads/logos/logo-mini.png", 92, null),
|
|
||||||
),
|
|
||||||
domain,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun getDetails(manga: Manga): Manga {
|
|
||||||
val docs = webClient.httpGet(manga.url.toAbsoluteUrl(domain)).parseHtml()
|
|
||||||
val cardInfoElement = docs.selectFirst(".card .manga-info")
|
|
||||||
val author = cardInfoElement?.selectFirst("b:contains(Author(s))")?.parent()
|
|
||||||
?.select("a.btn")
|
|
||||||
?.joinToString(", ") { it.text() }
|
|
||||||
val availableTags = tagsMap.get()
|
|
||||||
val tags = cardInfoElement?.selectFirst("b:contains(Genre(s))")?.parent()
|
|
||||||
?.select("a.btn")
|
|
||||||
?.mapNotNullToSet { availableTags[it.text().lowercase(Locale.ENGLISH)] }
|
|
||||||
val state = cardInfoElement?.selectFirst("b:contains(Status)")?.parent()
|
|
||||||
?.selectFirst("a.btn")
|
|
||||||
?.let {
|
|
||||||
when (it.text()) {
|
|
||||||
"On going" -> MangaState.ONGOING
|
|
||||||
"Completed" -> MangaState.FINISHED
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return manga.copy(
|
|
||||||
altTitle = cardInfoElement?.selectFirst("b:contains(Other names)")?.parent()?.ownText()?.removePrefix(": "),
|
|
||||||
author = author,
|
|
||||||
description = docs.selectFirst(".series-summary .summary-content")?.html(),
|
|
||||||
tags = tags.orEmpty(),
|
|
||||||
state = state,
|
|
||||||
chapters = docs.select(".card-body > .list-chapters > a").mapChapters(reversed = true) { index, element ->
|
|
||||||
// attrAsRelativeUrl only return page url without the '/'
|
|
||||||
val chapterUrl = element.attrAsAbsoluteUrlOrNull("href")?.toRelativeUrl(domain)
|
|
||||||
?: return@mapChapters null
|
|
||||||
val uploadDate = parseUploadDate(element.selectFirst(".chapter-time")?.text())
|
|
||||||
MangaChapter(
|
|
||||||
id = generateUid(chapterUrl),
|
|
||||||
name = element.selectFirst(".chapter-name")?.text().orEmpty(),
|
|
||||||
number = index + 1,
|
|
||||||
url = chapterUrl,
|
|
||||||
scanlator = null,
|
|
||||||
uploadDate = uploadDate,
|
|
||||||
branch = null,
|
|
||||||
source = MangaSource.MANHWA18,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 7 minutes ago
|
|
||||||
// 5 hours ago
|
|
||||||
// 2 days ago
|
|
||||||
// 2 weeks ago
|
|
||||||
// 4 years ago
|
|
||||||
private fun parseUploadDate(timeStr: String?): Long {
|
|
||||||
timeStr ?: return 0
|
|
||||||
|
|
||||||
val timeWords = timeStr.split(' ')
|
|
||||||
if (timeWords.size != 3) return 0
|
|
||||||
val timeWord = timeWords[1]
|
|
||||||
val timeAmount = timeWords[0].toIntOrNull() ?: return 0
|
|
||||||
val timeUnit = when (timeWord) {
|
|
||||||
"minute", "minutes" -> Calendar.MINUTE
|
|
||||||
"hour", "hours" -> Calendar.HOUR
|
|
||||||
"day", "days" -> Calendar.DAY_OF_YEAR
|
|
||||||
"week", "weeks" -> Calendar.WEEK_OF_YEAR
|
|
||||||
"month", "months" -> Calendar.MONTH
|
|
||||||
"year", "years" -> Calendar.YEAR
|
|
||||||
else -> return 0
|
|
||||||
}
|
|
||||||
val cal = Calendar.getInstance()
|
|
||||||
cal.add(timeUnit, -timeAmount)
|
|
||||||
return cal.time.time
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun getListPage(
|
|
||||||
page: Int,
|
|
||||||
query: String?,
|
|
||||||
tags: Set<MangaTag>?,
|
|
||||||
sortOrder: SortOrder,
|
|
||||||
): List<Manga> {
|
|
||||||
val sortQuery = when (sortOrder) {
|
|
||||||
SortOrder.ALPHABETICAL -> "az"
|
|
||||||
SortOrder.POPULARITY -> "top"
|
|
||||||
SortOrder.UPDATED -> "update"
|
|
||||||
SortOrder.NEWEST -> "new"
|
|
||||||
else -> ""
|
|
||||||
}
|
|
||||||
|
|
||||||
val tagQuery = tags?.joinToString(",") { it.key }.orEmpty()
|
|
||||||
val url = buildString {
|
|
||||||
append("https://")
|
|
||||||
append(domain)
|
|
||||||
append("/tim-kiem?page=")
|
|
||||||
append(page)
|
|
||||||
if (!query.isNullOrEmpty()) {
|
|
||||||
append("&q=")
|
|
||||||
append(query.urlEncoded())
|
|
||||||
}
|
|
||||||
append("&accept_genres=$tagQuery")
|
|
||||||
append("&sort=")
|
|
||||||
append(sortQuery)
|
|
||||||
}
|
|
||||||
|
|
||||||
val docs = webClient.httpGet(url).parseHtml()
|
|
||||||
|
|
||||||
return docs.select(".card-body .thumb-item-flow")
|
|
||||||
.map {
|
|
||||||
val titleElement = it.selectFirstOrThrow(".thumb_attr.series-title > a")
|
|
||||||
val absUrl = titleElement.attrAsAbsoluteUrl("href")
|
|
||||||
Manga(
|
|
||||||
id = generateUid(absUrl.toRelativeUrl(domain)),
|
|
||||||
title = titleElement.text(),
|
|
||||||
altTitle = null,
|
|
||||||
url = absUrl.toRelativeUrl(domain),
|
|
||||||
publicUrl = absUrl,
|
|
||||||
rating = RATING_UNKNOWN,
|
|
||||||
isNsfw = true,
|
|
||||||
coverUrl = it.selectFirst("div.img-in-ratio")?.attrAsAbsoluteUrl("data-bg").orEmpty(),
|
|
||||||
tags = emptySet(),
|
|
||||||
state = null,
|
|
||||||
author = null,
|
|
||||||
largeCoverUrl = null,
|
|
||||||
description = null,
|
|
||||||
source = MangaSource.MANHWA18,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
|
|
||||||
val chapterUrl = chapter.url.toAbsoluteUrl(domain)
|
|
||||||
val doc = webClient.httpGet(chapterUrl).parseHtml()
|
|
||||||
return doc.requireElementById("chapter-content").select("img").mapNotNull {
|
|
||||||
val url = it.attrAsRelativeUrlOrNull("data-src")
|
|
||||||
?: it.attrAsRelativeUrlOrNull("src")
|
|
||||||
?: return@mapNotNull null
|
|
||||||
MangaPage(
|
|
||||||
id = generateUid(url),
|
|
||||||
url = url,
|
|
||||||
preview = null,
|
|
||||||
source = MangaSource.MANHWA18,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun getTags(): Set<MangaTag> {
|
|
||||||
return tagsMap.get().values.toSet()
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun parseTags(): Map<String, MangaTag> {
|
|
||||||
val doc = webClient.httpGet("https://$domain/tim-kiem?q=").parseHtml()
|
|
||||||
val list = doc.getElementsByAttribute("data-genre-id")
|
|
||||||
if (list.isEmpty()) {
|
|
||||||
return emptyMap()
|
|
||||||
}
|
|
||||||
val result = ArrayMap<String, MangaTag>(list.size)
|
|
||||||
for (item in list) {
|
|
||||||
val id = item.attr("data-genre-id")
|
|
||||||
val name = item.text()
|
|
||||||
result[name.lowercase(Locale.ENGLISH)] = MangaTag(
|
|
||||||
title = name.toTitleCase(Locale.ENGLISH),
|
|
||||||
key = id,
|
|
||||||
source = source,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -15,7 +15,7 @@ import java.text.SimpleDateFormat
|
|||||||
import java.util.*
|
import java.util.*
|
||||||
|
|
||||||
@MangaSourceParser("NHENTAI", "N-Hentai")
|
@MangaSourceParser("NHENTAI", "N-Hentai")
|
||||||
class NHentaiParser(context: MangaLoaderContext) : PagedMangaParser(context, MangaSource.NHENTAI, pageSize = 25) {
|
class NHentaiParser(override val context: MangaLoaderContext) : PagedMangaParser(MangaSource.NHENTAI, pageSize = 25) {
|
||||||
|
|
||||||
override val configKeyDomain: ConfigKey.Domain
|
override val configKeyDomain: ConfigKey.Domain
|
||||||
get() = ConfigKey.Domain("nhentai.net", null)
|
get() = ConfigKey.Domain("nhentai.net", null)
|
||||||
@@ -32,7 +32,7 @@ class NHentaiParser(context: MangaLoaderContext) : PagedMangaParser(context, Man
|
|||||||
if (query.isNullOrEmpty() && tags != null && tags.size > 1) {
|
if (query.isNullOrEmpty() && tags != null && tags.size > 1) {
|
||||||
return getListPage(page, buildQuery(tags), emptySet(), sortOrder)
|
return getListPage(page, buildQuery(tags), emptySet(), sortOrder)
|
||||||
}
|
}
|
||||||
val domain = domain
|
val domain = getDomain()
|
||||||
val url = buildString {
|
val url = buildString {
|
||||||
append("https://")
|
append("https://")
|
||||||
append(domain)
|
append(domain)
|
||||||
@@ -66,7 +66,7 @@ class NHentaiParser(context: MangaLoaderContext) : PagedMangaParser(context, Man
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val root = webClient.httpGet(url).parseHtml().body().requireElementById("content")
|
val root = context.httpGet(url).parseHtml().body().requireElementById("content")
|
||||||
.selectLastOrThrow("div.index-container")
|
.selectLastOrThrow("div.index-container")
|
||||||
val regexBrackets = Regex("\\[[^]]+]|\\([^)]+\\)")
|
val regexBrackets = Regex("\\[[^]]+]|\\([^)]+\\)")
|
||||||
val regexSpaces = Regex("\\s+")
|
val regexSpaces = Regex("\\s+")
|
||||||
@@ -99,8 +99,8 @@ class NHentaiParser(context: MangaLoaderContext) : PagedMangaParser(context, Man
|
|||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getDetails(manga: Manga): Manga {
|
override suspend fun getDetails(manga: Manga): Manga {
|
||||||
val root = webClient.httpGet(
|
val root = context.httpGet(
|
||||||
url = manga.url.toAbsoluteUrl(domain),
|
url = manga.url.toAbsoluteUrl(getDomain()),
|
||||||
).parseHtml().body().requireElementById("bigcontainer")
|
).parseHtml().body().requireElementById("bigcontainer")
|
||||||
val img = root.requireElementById("cover").selectFirstOrThrow("img")
|
val img = root.requireElementById("cover").selectFirstOrThrow("img")
|
||||||
val tagContainers = root.requireElementById("tags").select(".tag-container")
|
val tagContainers = root.requireElementById("tags").select(".tag-container")
|
||||||
@@ -135,8 +135,8 @@ class NHentaiParser(context: MangaLoaderContext) : PagedMangaParser(context, Man
|
|||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
|
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
|
||||||
val url = chapter.url.toAbsoluteUrl(domain)
|
val url = chapter.url.toAbsoluteUrl(getDomain())
|
||||||
val root = webClient.httpGet(url).parseHtml().requireElementById("thumbnail-container")
|
val root = context.httpGet(url).parseHtml().requireElementById("thumbnail-container")
|
||||||
return root.select(".thumb-container").map { div ->
|
return root.select(".thumb-container").map { div ->
|
||||||
val a = div.selectFirstOrThrow("a")
|
val a = div.selectFirstOrThrow("a")
|
||||||
val img = div.selectFirstOrThrow("img")
|
val img = div.selectFirstOrThrow("img")
|
||||||
@@ -144,6 +144,7 @@ class NHentaiParser(context: MangaLoaderContext) : PagedMangaParser(context, Man
|
|||||||
MangaPage(
|
MangaPage(
|
||||||
id = generateUid(href),
|
id = generateUid(href),
|
||||||
url = href,
|
url = href,
|
||||||
|
referer = url,
|
||||||
preview = img.attrAsAbsoluteUrlOrNull("data-src")
|
preview = img.attrAsAbsoluteUrlOrNull("data-src")
|
||||||
?: img.attrAsAbsoluteUrl("src"),
|
?: img.attrAsAbsoluteUrl("src"),
|
||||||
source = source,
|
source = source,
|
||||||
@@ -152,7 +153,7 @@ class NHentaiParser(context: MangaLoaderContext) : PagedMangaParser(context, Man
|
|||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getPageUrl(page: MangaPage): String {
|
override suspend fun getPageUrl(page: MangaPage): String {
|
||||||
val root = webClient.httpGet(page.url.toAbsoluteUrl(domain)).parseHtml().body()
|
val root = context.httpGet(page.url.toAbsoluteUrl(getDomain())).parseHtml().body()
|
||||||
.requireElementById("image-container")
|
.requireElementById("image-container")
|
||||||
return root.selectFirstOrThrow("img").attrAsAbsoluteUrl("src")
|
return root.selectFirstOrThrow("img").attrAsAbsoluteUrl("src")
|
||||||
}
|
}
|
||||||
@@ -167,7 +168,7 @@ class NHentaiParser(context: MangaLoaderContext) : PagedMangaParser(context, Man
|
|||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun getTags(page: Int): Set<MangaTag> {
|
private suspend fun getTags(page: Int): Set<MangaTag> {
|
||||||
val root = webClient.httpGet("https://${domain}/tags/popular?page=$page").parseHtml().body()
|
val root = context.httpGet("https://${getDomain()}/tags/popular?page=$page").parseHtml().body()
|
||||||
.getElementById("tag-container")
|
.getElementById("tag-container")
|
||||||
return root?.parseTags().orEmpty()
|
return root?.parseTags().orEmpty()
|
||||||
}
|
}
|
||||||
@@ -184,4 +185,4 @@ class NHentaiParser(context: MangaLoaderContext) : PagedMangaParser(context, Man
|
|||||||
private fun buildQuery(tags: Collection<MangaTag>) = tags.joinToString(separator = " ") { tag ->
|
private fun buildQuery(tags: Collection<MangaTag>) = tags.joinToString(separator = " ") { tag ->
|
||||||
"tag:\"${tag.key}\""
|
"tag:\"${tag.key}\""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,226 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.parsers.site
|
|
||||||
|
|
||||||
import androidx.collection.ArrayMap
|
|
||||||
import androidx.collection.ArraySet
|
|
||||||
import kotlinx.coroutines.sync.Mutex
|
|
||||||
import kotlinx.coroutines.sync.withLock
|
|
||||||
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
|
||||||
import org.koitharu.kotatsu.parsers.MangaSourceParser
|
|
||||||
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.util.*
|
|
||||||
import java.text.SimpleDateFormat
|
|
||||||
import java.util.*
|
|
||||||
|
|
||||||
@MangaSourceParser("NETTRUYEN", "NetTruyen", "vi")
|
|
||||||
class NetTruyenParser(context: MangaLoaderContext) :
|
|
||||||
PagedMangaParser(context, MangaSource.NETTRUYEN, pageSize = 36) {
|
|
||||||
|
|
||||||
override val configKeyDomain: ConfigKey.Domain
|
|
||||||
get() = ConfigKey.Domain(
|
|
||||||
"www.nettruyento.com",
|
|
||||||
arrayOf("www.nettruyento.com", "nettruyento.com", "nettruyenin.com"),
|
|
||||||
)
|
|
||||||
|
|
||||||
override val sortOrders: Set<SortOrder>
|
|
||||||
get() = EnumSet.of(SortOrder.UPDATED, SortOrder.POPULARITY, SortOrder.NEWEST, SortOrder.RATING)
|
|
||||||
|
|
||||||
private val mutex = Mutex()
|
|
||||||
private val dateFormat = SimpleDateFormat("dd/MM/yy", Locale.US)
|
|
||||||
private var tagCache: ArrayMap<String, MangaTag>? = null
|
|
||||||
|
|
||||||
override suspend fun getDetails(manga: Manga): Manga {
|
|
||||||
val doc = webClient.httpGet(manga.url.toAbsoluteUrl(domain)).parseHtml()
|
|
||||||
val rating = doc.selectFirst("span[itemprop=ratingValue]")
|
|
||||||
?.ownText()
|
|
||||||
?.toFloatOrNull() ?: 0f
|
|
||||||
|
|
||||||
val chapterElements = doc.getElementById("nt_listchapter")?.select("ul > li") ?: doc.parseFailed()
|
|
||||||
val chapters = chapterElements.mapChapters(reversed = true) { index, element ->
|
|
||||||
val a = element.selectFirst("div.chapter > a") ?: return@mapChapters null
|
|
||||||
val relativeUrl = a.attrAsRelativeUrlOrNull("href") ?: return@mapChapters null
|
|
||||||
val timeText = element.selectFirst("div.col-xs-4.text-center.no-wrap.small")?.text()
|
|
||||||
|
|
||||||
MangaChapter(
|
|
||||||
id = generateUid(relativeUrl),
|
|
||||||
name = a.text(),
|
|
||||||
number = index + 1,
|
|
||||||
url = relativeUrl,
|
|
||||||
scanlator = null,
|
|
||||||
uploadDate = parseChapterTime(timeText),
|
|
||||||
branch = null,
|
|
||||||
source = source,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return manga.copy(
|
|
||||||
rating = rating / 5,
|
|
||||||
chapters = chapters,
|
|
||||||
description = doc.selectFirst("div.detail-content > p")?.html(),
|
|
||||||
isNsfw = doc.selectFirst("div.alert.alert-danger > strong:contains(Cảnh báo độ tuổi)") != null,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 20 giây trước
|
|
||||||
// 52 phút trước
|
|
||||||
// 6 giờ trước
|
|
||||||
// 2 ngày trước
|
|
||||||
// 19:09 30/07
|
|
||||||
// 23/12/21
|
|
||||||
private fun parseChapterTime(timeText: String?): Long {
|
|
||||||
if (timeText.isNullOrEmpty()) {
|
|
||||||
return 0L
|
|
||||||
}
|
|
||||||
|
|
||||||
val timeWords = arrayOf("giây", "phút", "giờ", "ngày")
|
|
||||||
val calendar = Calendar.getInstance()
|
|
||||||
val timeArr = timeText.split(' ')
|
|
||||||
if (WordSet(*timeWords).anyWordIn(timeText)) {
|
|
||||||
val timeSuffix = timeArr.getOrNull(1)
|
|
||||||
val timeDiff = timeArr.getOrNull(0)?.toIntOrNull() ?: return 0L
|
|
||||||
when (timeSuffix) {
|
|
||||||
timeWords[0] -> calendar.add(Calendar.SECOND, -timeDiff)
|
|
||||||
timeWords[1] -> calendar.add(Calendar.MINUTE, -timeDiff)
|
|
||||||
timeWords[2] -> calendar.add(Calendar.HOUR, -timeDiff)
|
|
||||||
timeWords[3] -> calendar.add(Calendar.DATE, -timeDiff)
|
|
||||||
else -> return 0L
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
val relativeDate = timeArr.lastOrNull() ?: return 0L
|
|
||||||
val dateString = when (relativeDate.split('/').size) {
|
|
||||||
2 -> {
|
|
||||||
val currentYear = calendar.get(Calendar.YEAR).toString().takeLast(2)
|
|
||||||
"$relativeDate/$currentYear"
|
|
||||||
}
|
|
||||||
|
|
||||||
3 -> relativeDate
|
|
||||||
else -> return 0L
|
|
||||||
}
|
|
||||||
|
|
||||||
calendar.timeInMillis = dateFormat.tryParse(dateString)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
return calendar.time.time
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun getListPage(
|
|
||||||
page: Int,
|
|
||||||
query: String?,
|
|
||||||
tags: Set<MangaTag>?,
|
|
||||||
sortOrder: SortOrder,
|
|
||||||
): List<Manga> {
|
|
||||||
val isSearching = !query.isNullOrEmpty()
|
|
||||||
val url = buildString {
|
|
||||||
append("https://")
|
|
||||||
append(domain)
|
|
||||||
if (isSearching) {
|
|
||||||
append("/tim-truyen?keyword=")
|
|
||||||
append(query!!.urlEncoded())
|
|
||||||
append("&page=")
|
|
||||||
append(page)
|
|
||||||
} else {
|
|
||||||
val tagQuery = tags.orEmpty().joinToString(",") { it.key }
|
|
||||||
append("/tim-truyen-nang-cao?genres=$tagQuery")
|
|
||||||
append("¬genres=&gender=-1&status=-1&minchapter=1&sort=${getSortOrderKey(sortOrder)}")
|
|
||||||
append("&page=$page")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val response = if (isSearching) {
|
|
||||||
val result = runCatchingCancellable { webClient.httpGet(url) }
|
|
||||||
val exception = result.exceptionOrNull()
|
|
||||||
if (exception is NotFoundException) {
|
|
||||||
return emptyList()
|
|
||||||
}
|
|
||||||
|
|
||||||
result.getOrThrow()
|
|
||||||
} else {
|
|
||||||
webClient.httpGet(url)
|
|
||||||
}
|
|
||||||
|
|
||||||
val itemsElements = response.parseHtml()
|
|
||||||
.select("div.ModuleContent > div.items")
|
|
||||||
.select("div.item")
|
|
||||||
return itemsElements.mapNotNull { item ->
|
|
||||||
val tooltipElement = item.selectFirst("div.box_tootip") ?: return@mapNotNull null
|
|
||||||
val absUrl = item.selectFirst("div.image > a")?.attrAsAbsoluteUrlOrNull("href") ?: return@mapNotNull null
|
|
||||||
val slug = absUrl.substringAfterLast('/')
|
|
||||||
val mangaState = when (tooltipElement.selectFirst("div.message_main > p:contains(Tình trạng)")?.ownText()) {
|
|
||||||
"Đang tiến hành" -> MangaState.ONGOING
|
|
||||||
"Hoàn thành" -> MangaState.FINISHED
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
|
|
||||||
val tagMap = getOrCreateTagMap()
|
|
||||||
val tagsElement = tooltipElement.selectFirst("div.message_main > p:contains(Thể loại)")?.ownText().orEmpty()
|
|
||||||
val mangaTags = tagsElement.split(',').mapNotNullToSet { tagMap[it.trim()] }
|
|
||||||
Manga(
|
|
||||||
id = generateUid(slug),
|
|
||||||
title = tooltipElement.selectFirst("div.title")?.text().orEmpty(),
|
|
||||||
altTitle = null,
|
|
||||||
url = absUrl.toRelativeUrl(domain),
|
|
||||||
publicUrl = absUrl,
|
|
||||||
rating = RATING_UNKNOWN,
|
|
||||||
isNsfw = false,
|
|
||||||
coverUrl = item.selectFirst("div.image a img")?.absUrl("data-original").orEmpty(),
|
|
||||||
largeCoverUrl = null,
|
|
||||||
tags = mangaTags,
|
|
||||||
state = mangaState,
|
|
||||||
author = tooltipElement.selectFirst("div.message_main > p:contains(Tác giả)")?.ownText(),
|
|
||||||
description = tooltipElement.selectFirst("div.box_text")?.text(),
|
|
||||||
chapters = null,
|
|
||||||
source = source,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
|
|
||||||
val pageElements = webClient.httpGet(chapter.url.toAbsoluteUrl(domain)).parseHtml()
|
|
||||||
.select("div.reading-detail.box_doc > div img")
|
|
||||||
return pageElements.map { element ->
|
|
||||||
val url = element.attrAsAbsoluteUrl("data-original")
|
|
||||||
MangaPage(
|
|
||||||
id = generateUid(url),
|
|
||||||
url = url,
|
|
||||||
preview = null,
|
|
||||||
source = source,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun getTags(): Set<MangaTag> {
|
|
||||||
val map = getOrCreateTagMap()
|
|
||||||
val tagSet = ArraySet<MangaTag>(map.size)
|
|
||||||
for (entry in map) {
|
|
||||||
tagSet.add(entry.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
return tagSet
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun getOrCreateTagMap(): ArrayMap<String, MangaTag> = mutex.withLock {
|
|
||||||
tagCache?.let { return@withLock it }
|
|
||||||
val doc = webClient.httpGet("/tim-truyen-nang-cao".toAbsoluteUrl(domain)).parseHtml()
|
|
||||||
val tagItems = doc.select("div.genre-item")
|
|
||||||
val result = ArrayMap<String, MangaTag>(tagItems.size)
|
|
||||||
for (item in tagItems) {
|
|
||||||
val title = item.text().trim()
|
|
||||||
val key = item.select("span[data-id]").attr("data-id")
|
|
||||||
result[title] = MangaTag(title = title, key = key, source = source)
|
|
||||||
}
|
|
||||||
tagCache = result
|
|
||||||
result
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getSortOrderKey(sortOrder: SortOrder) = when (sortOrder) {
|
|
||||||
SortOrder.UPDATED -> 0
|
|
||||||
SortOrder.POPULARITY -> 10
|
|
||||||
SortOrder.NEWEST -> 15
|
|
||||||
SortOrder.RATING -> 20
|
|
||||||
else -> throw IllegalArgumentException("Sort order ${sortOrder.name} not supported")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -11,8 +11,8 @@ private const val STATUS_ONGOING = "連載"
|
|||||||
private const val STATUS_FINISHED = "完結"
|
private const val STATUS_FINISHED = "完結"
|
||||||
|
|
||||||
@MangaSourceParser("NICOVIDEO_SEIGA", "Nicovideo Seiga", "ja")
|
@MangaSourceParser("NICOVIDEO_SEIGA", "Nicovideo Seiga", "ja")
|
||||||
class NicovideoSeigaParser(context: MangaLoaderContext) :
|
class NicovideoSeigaParser(override val context: MangaLoaderContext) :
|
||||||
MangaParser(context, MangaSource.NICOVIDEO_SEIGA),
|
MangaParser(MangaSource.NICOVIDEO_SEIGA),
|
||||||
MangaParserAuthProvider {
|
MangaParserAuthProvider {
|
||||||
|
|
||||||
override val authUrl: String
|
override val authUrl: String
|
||||||
@@ -24,7 +24,7 @@ class NicovideoSeigaParser(context: MangaLoaderContext) :
|
|||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getUsername(): String {
|
override suspend fun getUsername(): String {
|
||||||
val body = webClient.httpGet("https://${getDomain("app")}/my/apps").parseHtml().body()
|
val body = context.httpGet("https://${getDomain("app")}/my/apps").parseHtml().body()
|
||||||
return body.selectFirst("#userinfo > div > div > strong")?.text() ?: throw AuthRequiredException(source)
|
return body.selectFirst("#userinfo > div > div > strong")?.text() ?: throw AuthRequiredException(source)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,12 +48,12 @@ class NicovideoSeigaParser(context: MangaLoaderContext) :
|
|||||||
!query.isNullOrEmpty() -> return if (offset == 0) getSearchList(query, page) else emptyList()
|
!query.isNullOrEmpty() -> return if (offset == 0) getSearchList(query, page) else emptyList()
|
||||||
tags.isNullOrEmpty() -> "https://$domain/manga/list?page=$page&sort=${getSortKey(sortOrder)}"
|
tags.isNullOrEmpty() -> "https://$domain/manga/list?page=$page&sort=${getSortKey(sortOrder)}"
|
||||||
tags.size == 1 -> "https://$domain/manga/list?category=${tags.first().key}&page=$page" +
|
tags.size == 1 -> "https://$domain/manga/list?category=${tags.first().key}&page=$page" +
|
||||||
"&sort=${getSortKey(sortOrder)}"
|
"&sort=${getSortKey(sortOrder)}"
|
||||||
|
|
||||||
tags.size > 1 -> throw IllegalArgumentException("This source supports only 1 category")
|
tags.size > 1 -> throw IllegalArgumentException("This source supports only 1 category")
|
||||||
else -> "https://$domain/manga/list?page=$page&sort=${getSortKey(sortOrder)}"
|
else -> "https://$domain/manga/list?page=$page&sort=${getSortKey(sortOrder)}"
|
||||||
}
|
}
|
||||||
val doc = webClient.httpGet(url).parseHtml()
|
val doc = context.httpGet(url).parseHtml()
|
||||||
val comicList = doc.body().select("#comic_list > ul > li") ?: doc.parseFailed("Container not found")
|
val comicList = doc.body().select("#comic_list > ul > li") ?: doc.parseFailed("Container not found")
|
||||||
val items = comicList.select("div > .description > div > div")
|
val items = comicList.select("div > .description > div > div")
|
||||||
return items.mapNotNull { item ->
|
return items.mapNotNull { item ->
|
||||||
@@ -88,7 +88,7 @@ class NicovideoSeigaParser(context: MangaLoaderContext) :
|
|||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getDetails(manga: Manga): Manga {
|
override suspend fun getDetails(manga: Manga): Manga {
|
||||||
val doc = webClient.httpGet(manga.url.toAbsoluteUrl(getDomain("seiga"))).parseHtml()
|
val doc = context.httpGet(manga.url.toAbsoluteUrl(getDomain("seiga"))).parseHtml()
|
||||||
val contents = doc.body().selectFirstOrThrow("#contents")
|
val contents = doc.body().selectFirstOrThrow("#contents")
|
||||||
val statusText = contents
|
val statusText = contents
|
||||||
.select("div.mg_work_detail > div > div:nth-child(2) > div.tip.content_status.status_series > span")
|
.select("div.mg_work_detail > div > div:nth-child(2) > div.tip.content_status.status_series > span")
|
||||||
@@ -122,7 +122,7 @@ class NicovideoSeigaParser(context: MangaLoaderContext) :
|
|||||||
|
|
||||||
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
|
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
|
||||||
val fullUrl = chapter.url.toAbsoluteUrl(getDomain("seiga"))
|
val fullUrl = chapter.url.toAbsoluteUrl(getDomain("seiga"))
|
||||||
val doc = webClient.httpGet(fullUrl).parseHtml()
|
val doc = context.httpGet(fullUrl).parseHtml()
|
||||||
if (!doc.select("#login_manga").isEmpty())
|
if (!doc.select("#login_manga").isEmpty())
|
||||||
throw AuthRequiredException(source)
|
throw AuthRequiredException(source)
|
||||||
val root = doc.body().select("#page_contents > li")
|
val root = doc.body().select("#page_contents > li")
|
||||||
@@ -132,13 +132,14 @@ class NicovideoSeigaParser(context: MangaLoaderContext) :
|
|||||||
id = generateUid(url),
|
id = generateUid(url),
|
||||||
url = url,
|
url = url,
|
||||||
preview = null,
|
preview = null,
|
||||||
|
referer = fullUrl,
|
||||||
source = source,
|
source = source,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getTags(): Set<MangaTag> {
|
override suspend fun getTags(): Set<MangaTag> {
|
||||||
val doc = webClient.httpGet("https://${getDomain("seiga")}/manga/list").parseHtml()
|
val doc = context.httpGet("https://${getDomain("seiga")}/manga/list").parseHtml()
|
||||||
val root = doc.body().selectOrThrow("#mg_category_list > ul > li")
|
val root = doc.body().selectOrThrow("#mg_category_list > ul > li")
|
||||||
return root.mapToSet { li ->
|
return root.mapToSet { li ->
|
||||||
val a = li.selectFirstOrThrow("a")
|
val a = li.selectFirstOrThrow("a")
|
||||||
@@ -152,7 +153,7 @@ class NicovideoSeigaParser(context: MangaLoaderContext) :
|
|||||||
|
|
||||||
private suspend fun getSearchList(query: String, page: Int): List<Manga> {
|
private suspend fun getSearchList(query: String, page: Int): List<Manga> {
|
||||||
val domain = getDomain("seiga")
|
val domain = getDomain("seiga")
|
||||||
val doc = webClient.httpGet("https://$domain/manga/search/?q=$query&page=$page&sort=score").parseHtml()
|
val doc = context.httpGet("https://$domain/manga/search/?q=$query&page=$page&sort=score").parseHtml()
|
||||||
val root = doc.body().select(".search_result__item")
|
val root = doc.body().select(".search_result__item")
|
||||||
return root.mapNotNull { item ->
|
return root.mapNotNull { item ->
|
||||||
val href = item.selectFirst(".search_result__item__thumbnail > a")
|
val href = item.selectFirst(".search_result__item__thumbnail > a")
|
||||||
@@ -181,4 +182,4 @@ class NicovideoSeigaParser(context: MangaLoaderContext) :
|
|||||||
SortOrder.UPDATED -> "manga_updated"
|
SortOrder.UPDATED -> "manga_updated"
|
||||||
else -> "manga_view"
|
else -> "manga_view"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2,9 +2,6 @@ package org.koitharu.kotatsu.parsers.site
|
|||||||
|
|
||||||
import okhttp3.Headers
|
import okhttp3.Headers
|
||||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||||
import okhttp3.Interceptor
|
|
||||||
import okhttp3.Response
|
|
||||||
import okio.IOException
|
|
||||||
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
||||||
import org.koitharu.kotatsu.parsers.MangaSourceParser
|
import org.koitharu.kotatsu.parsers.MangaSourceParser
|
||||||
import org.koitharu.kotatsu.parsers.PagedMangaParser
|
import org.koitharu.kotatsu.parsers.PagedMangaParser
|
||||||
@@ -15,39 +12,25 @@ import java.text.SimpleDateFormat
|
|||||||
import java.util.*
|
import java.util.*
|
||||||
|
|
||||||
internal abstract class NineMangaParser(
|
internal abstract class NineMangaParser(
|
||||||
context: MangaLoaderContext,
|
final override val context: MangaLoaderContext,
|
||||||
source: MangaSource,
|
source: MangaSource,
|
||||||
defaultDomain: String,
|
defaultDomain: String,
|
||||||
) : PagedMangaParser(context, source, pageSize = 26), Interceptor {
|
) : PagedMangaParser(source, pageSize = 26) {
|
||||||
|
|
||||||
override val configKeyDomain = ConfigKey.Domain(defaultDomain, null)
|
override val configKeyDomain = ConfigKey.Domain(defaultDomain, null)
|
||||||
|
|
||||||
init {
|
init {
|
||||||
context.cookieJar.insertCookies(domain, "ninemanga_template_desk=yes")
|
context.cookieJar.insertCookies(getDomain(), "ninemanga_template_desk=yes")
|
||||||
}
|
}
|
||||||
|
|
||||||
override val headers = Headers.Builder()
|
override val headers = Headers.Builder()
|
||||||
.add("Accept-Language", "en-US;q=0.7,en;q=0.3")
|
.add("Accept-Language", "en-US;q=0.7,en;q=0.3")
|
||||||
.add("User-Agent", "Mozilla/5.0 (Android 13; Mobile; rv:68.0) Gecko/68.0 Firefox/109.0")
|
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
override val sortOrders: Set<SortOrder> = Collections.singleton(
|
override val sortOrders: Set<SortOrder> = Collections.singleton(
|
||||||
SortOrder.POPULARITY,
|
SortOrder.POPULARITY,
|
||||||
)
|
)
|
||||||
|
|
||||||
override fun intercept(chain: Interceptor.Chain): Response {
|
|
||||||
val request = chain.request()
|
|
||||||
.newBuilder()
|
|
||||||
.removeHeader("Referer")
|
|
||||||
.build()
|
|
||||||
val response = chain.proceed(request)
|
|
||||||
val responseUrl = response.request.url
|
|
||||||
if (responseUrl.host != request.url.host) {
|
|
||||||
throw IOException("Unexpected redirect to ${responseUrl.host}")
|
|
||||||
}
|
|
||||||
return response
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun getListPage(
|
override suspend fun getListPage(
|
||||||
page: Int,
|
page: Int,
|
||||||
query: String?,
|
query: String?,
|
||||||
@@ -56,7 +39,7 @@ internal abstract class NineMangaParser(
|
|||||||
): List<Manga> {
|
): List<Manga> {
|
||||||
val url = buildString {
|
val url = buildString {
|
||||||
append("https://")
|
append("https://")
|
||||||
append(domain)
|
append(getDomain())
|
||||||
when {
|
when {
|
||||||
!query.isNullOrEmpty() -> {
|
!query.isNullOrEmpty() -> {
|
||||||
append("/search/?name_sel=&wd=")
|
append("/search/?name_sel=&wd=")
|
||||||
@@ -80,7 +63,7 @@ internal abstract class NineMangaParser(
|
|||||||
append(page)
|
append(page)
|
||||||
append(".html")
|
append(".html")
|
||||||
}
|
}
|
||||||
val doc = webClient.httpGet(url).parseHtml()
|
val doc = context.httpGet(url, headers).parseHtml()
|
||||||
val root = doc.body().selectFirst("ul.direlist")
|
val root = doc.body().selectFirst("ul.direlist")
|
||||||
?: doc.parseFailed("Cannot find root")
|
?: doc.parseFailed("Cannot find root")
|
||||||
val baseHost = root.baseUri().toHttpUrl().host
|
val baseHost = root.baseUri().toHttpUrl().host
|
||||||
@@ -108,8 +91,9 @@ internal abstract class NineMangaParser(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getDetails(manga: Manga): Manga {
|
override suspend fun getDetails(manga: Manga): Manga {
|
||||||
val doc = webClient.httpGet(
|
val doc = context.httpGet(
|
||||||
manga.url.toAbsoluteUrl(domain) + "?waring=1",
|
manga.url.toAbsoluteUrl(getDomain()) + "?waring=1",
|
||||||
|
headers,
|
||||||
).parseHtml()
|
).parseHtml()
|
||||||
val root = doc.body().selectFirstOrThrow("div.manga")
|
val root = doc.body().selectFirstOrThrow("div.manga")
|
||||||
val infoRoot = root.selectFirstOrThrow("div.bookintro")
|
val infoRoot = root.selectFirstOrThrow("div.bookintro")
|
||||||
@@ -127,7 +111,7 @@ internal abstract class NineMangaParser(
|
|||||||
description = infoRoot.getElementsByAttributeValue("itemprop", "description").first()
|
description = infoRoot.getElementsByAttributeValue("itemprop", "description").first()
|
||||||
?.html()?.substringAfter("</b>"),
|
?.html()?.substringAfter("</b>"),
|
||||||
chapters = root.selectFirst("div.chapterbox")?.select("ul.sub_vol_ul > li")
|
chapters = root.selectFirst("div.chapterbox")?.select("ul.sub_vol_ul > li")
|
||||||
?.mapChapters(reversed = true) { i, li ->
|
?.asReversed()?.mapChapters { i, li ->
|
||||||
val a = li.selectFirst("a.chapter_list_a")
|
val a = li.selectFirst("a.chapter_list_a")
|
||||||
val href = a?.attrAsRelativeUrlOrNull("href")
|
val href = a?.attrAsRelativeUrlOrNull("href")
|
||||||
?.replace("%20", " ") ?: li.parseFailed("Link not found")
|
?.replace("%20", " ") ?: li.parseFailed("Link not found")
|
||||||
@@ -146,12 +130,13 @@ internal abstract class NineMangaParser(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
|
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
|
||||||
val doc = webClient.httpGet(chapter.url.toAbsoluteUrl(domain)).parseHtml()
|
val doc = context.httpGet(chapter.url.toAbsoluteUrl(getDomain()), headers).parseHtml()
|
||||||
return doc.body().getElementById("page")?.select("option")?.map { option ->
|
return doc.body().getElementById("page")?.select("option")?.map { option ->
|
||||||
val url = option.attr("value")
|
val url = option.attr("value")
|
||||||
MangaPage(
|
MangaPage(
|
||||||
id = generateUid(url),
|
id = generateUid(url),
|
||||||
url = url,
|
url = url,
|
||||||
|
referer = chapter.url.toAbsoluteUrl(getDomain()),
|
||||||
preview = null,
|
preview = null,
|
||||||
source = source,
|
source = source,
|
||||||
)
|
)
|
||||||
@@ -159,14 +144,14 @@ internal abstract class NineMangaParser(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getPageUrl(page: MangaPage): String {
|
override suspend fun getPageUrl(page: MangaPage): String {
|
||||||
val doc = webClient.httpGet(page.url.toAbsoluteUrl(domain)).parseHtml()
|
val doc = context.httpGet(page.url.toAbsoluteUrl(getDomain()), headers).parseHtml()
|
||||||
val root = doc.body()
|
val root = doc.body()
|
||||||
return root.selectFirst("a.pic_download")?.absUrl("href")
|
return root.selectFirst("a.pic_download")?.absUrl("href")
|
||||||
?: doc.parseFailed("Page image not found")
|
?: doc.parseFailed("Page image not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getTags(): Set<MangaTag> {
|
override suspend fun getTags(): Set<MangaTag> {
|
||||||
val doc = webClient.httpGet("https://${domain}/search/?type=high")
|
val doc = context.httpGet("https://${getDomain()}/search/?type=high", headers)
|
||||||
.parseHtml()
|
.parseHtml()
|
||||||
val root = doc.body().getElementById("search_form")
|
val root = doc.body().getElementById("search_form")
|
||||||
return root?.select("li.cate_list")?.mapNotNullToSet { li ->
|
return root?.select("li.cate_list")?.mapNotNullToSet { li ->
|
||||||
@@ -272,4 +257,4 @@ internal abstract class NineMangaParser(
|
|||||||
MangaSource.NINEMANGA_FR,
|
MangaSource.NINEMANGA_FR,
|
||||||
"fr.ninemanga.com",
|
"fr.ninemanga.com",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -15,19 +15,16 @@ private const val MAX_THUMB_INDEX = 19
|
|||||||
|
|
||||||
@MangaSourceParser("NUDEMOON", "Nude-Moon", "ru")
|
@MangaSourceParser("NUDEMOON", "Nude-Moon", "ru")
|
||||||
internal class NudeMoonParser(
|
internal class NudeMoonParser(
|
||||||
context: MangaLoaderContext,
|
override val context: MangaLoaderContext,
|
||||||
) : MangaParser(context, MangaSource.NUDEMOON), MangaParserAuthProvider {
|
) : MangaParser(MangaSource.NUDEMOON), MangaParserAuthProvider {
|
||||||
|
|
||||||
override val configKeyDomain = ConfigKey.Domain(
|
override val configKeyDomain = ConfigKey.Domain("nude-moon.net", null)
|
||||||
defaultValue = "nude-moon.org",
|
|
||||||
presetValues = arrayOf("nude-moon.org", "nude-moon.net"),
|
|
||||||
)
|
|
||||||
override val authUrl: String
|
override val authUrl: String
|
||||||
get() = "https://${domain}/index.php"
|
get() = "https://${getDomain()}/index.php"
|
||||||
|
|
||||||
override val isAuthorized: Boolean
|
override val isAuthorized: Boolean
|
||||||
get() {
|
get() {
|
||||||
return context.cookieJar.getCookies(domain).any {
|
return context.cookieJar.getCookies(getDomain()).any {
|
||||||
it.name == "fusion_user"
|
it.name == "fusion_user"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -40,7 +37,7 @@ internal class NudeMoonParser(
|
|||||||
|
|
||||||
init {
|
init {
|
||||||
context.cookieJar.insertCookies(
|
context.cookieJar.insertCookies(
|
||||||
domain,
|
getDomain(),
|
||||||
"NMfYa=1;",
|
"NMfYa=1;",
|
||||||
"nm_mobile=0;",
|
"nm_mobile=0;",
|
||||||
)
|
)
|
||||||
@@ -52,7 +49,7 @@ internal class NudeMoonParser(
|
|||||||
tags: Set<MangaTag>?,
|
tags: Set<MangaTag>?,
|
||||||
sortOrder: SortOrder,
|
sortOrder: SortOrder,
|
||||||
): List<Manga> {
|
): List<Manga> {
|
||||||
val domain = domain
|
val domain = getDomain()
|
||||||
val url = when {
|
val url = when {
|
||||||
!query.isNullOrEmpty() -> "https://$domain/search?stext=${query.urlEncoded()}&rowstart=$offset"
|
!query.isNullOrEmpty() -> "https://$domain/search?stext=${query.urlEncoded()}&rowstart=$offset"
|
||||||
!tags.isNullOrEmpty() -> tags.joinToString(
|
!tags.isNullOrEmpty() -> tags.joinToString(
|
||||||
@@ -64,7 +61,7 @@ internal class NudeMoonParser(
|
|||||||
|
|
||||||
else -> "https://$domain/all_manga?${getSortKey(sortOrder)}&rowstart=$offset"
|
else -> "https://$domain/all_manga?${getSortKey(sortOrder)}&rowstart=$offset"
|
||||||
}
|
}
|
||||||
val doc = webClient.httpGet(url).parseHtml()
|
val doc = context.httpGet(url).parseHtml()
|
||||||
val root = doc.body().run {
|
val root = doc.body().run {
|
||||||
selectFirst("td.main-bg") ?: selectFirst("td.main-body")
|
selectFirst("td.main-bg") ?: selectFirst("td.main-body")
|
||||||
} ?: doc.parseFailed("Cannot find root")
|
} ?: doc.parseFailed("Cannot find root")
|
||||||
@@ -103,7 +100,7 @@ internal class NudeMoonParser(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getDetails(manga: Manga): Manga {
|
override suspend fun getDetails(manga: Manga): Manga {
|
||||||
val body = webClient.httpGet(manga.url.toAbsoluteUrl(domain)).parseHtml().body()
|
val body = context.httpGet(manga.url.toAbsoluteUrl(getDomain())).parseHtml().body()
|
||||||
val root = body.selectFirst("table.shoutbox")
|
val root = body.selectFirst("table.shoutbox")
|
||||||
?: body.parseFailed("Cannot find root")
|
?: body.parseFailed("Cannot find root")
|
||||||
val info = root.select("div.tbl2")
|
val info = root.select("div.tbl2")
|
||||||
@@ -139,8 +136,8 @@ internal class NudeMoonParser(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
|
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
|
||||||
val fullUrl = chapter.url.toAbsoluteUrl(domain)
|
val fullUrl = chapter.url.toAbsoluteUrl(getDomain())
|
||||||
val doc = webClient.httpGet(fullUrl).parseHtml()
|
val doc = context.httpGet(fullUrl).parseHtml()
|
||||||
val mangaId = chapter.url.substringAfterLast('/').substringBefore('-').toIntOrNull()
|
val mangaId = chapter.url.substringAfterLast('/').substringBefore('-').toIntOrNull()
|
||||||
|
|
||||||
val script = doc.select("script").firstNotNullOfOrNull {
|
val script = doc.select("script").firstNotNullOfOrNull {
|
||||||
@@ -157,6 +154,7 @@ internal class NudeMoonParser(
|
|||||||
MangaPage(
|
MangaPage(
|
||||||
id = generateUid(url),
|
id = generateUid(url),
|
||||||
url = url,
|
url = url,
|
||||||
|
referer = fullUrl,
|
||||||
preview = if (i <= MAX_THUMB_INDEX && mangaId != null) {
|
preview = if (i <= MAX_THUMB_INDEX && mangaId != null) {
|
||||||
val part2 = url.substringBeforeLast('/')
|
val part2 = url.substringBeforeLast('/')
|
||||||
val part3 = url.substringAfterLast('/')
|
val part3 = url.substringAfterLast('/')
|
||||||
@@ -171,8 +169,8 @@ internal class NudeMoonParser(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getTags(): Set<MangaTag> {
|
override suspend fun getTags(): Set<MangaTag> {
|
||||||
val domain = domain
|
val domain = getDomain()
|
||||||
val doc = webClient.httpGet("https://$domain/all_manga").parseHtml()
|
val doc = context.httpGet("https://$domain/all_manga").parseHtml()
|
||||||
val root = doc.body().getElementsContainingOwnText("Поиск манги по тегам")
|
val root = doc.body().getElementsContainingOwnText("Поиск манги по тегам")
|
||||||
.firstOrNull()?.parents()?.find { it.tag().normalName() == "tbody" }
|
.firstOrNull()?.parents()?.find { it.tag().normalName() == "tbody" }
|
||||||
?.selectFirst("td.textbox")?.selectFirst("td.small")
|
?.selectFirst("td.textbox")?.selectFirst("td.small")
|
||||||
@@ -188,7 +186,7 @@ internal class NudeMoonParser(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getUsername(): String {
|
override suspend fun getUsername(): String {
|
||||||
val body = webClient.httpGet("https://${domain}/").parseHtml()
|
val body = context.httpGet("https://${getDomain()}/").parseHtml()
|
||||||
.body()
|
.body()
|
||||||
return body
|
return body
|
||||||
.getElementsContainingOwnText("Профиль")
|
.getElementsContainingOwnText("Профиль")
|
||||||
@@ -204,6 +202,10 @@ internal class NudeMoonParser(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun getFaviconUrl(): String {
|
||||||
|
return "https://${getDomain()}/favicon.jpg"
|
||||||
|
}
|
||||||
|
|
||||||
private fun getSortKey(sortOrder: SortOrder) =
|
private fun getSortKey(sortOrder: SortOrder) =
|
||||||
when (sortOrder) {
|
when (sortOrder) {
|
||||||
SortOrder.POPULARITY -> "views"
|
SortOrder.POPULARITY -> "views"
|
||||||
@@ -223,4 +225,4 @@ internal class NudeMoonParser(
|
|||||||
val suffix = url.substringAfter('-').trimStart('-')
|
val suffix = url.substringAfter('-').trimStart('-')
|
||||||
return "$prefix-online-$suffix"
|
return "$prefix-online-$suffix"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package org.koitharu.kotatsu.parsers.site
|
package org.koitharu.kotatsu.parsers.site
|
||||||
|
|
||||||
import okhttp3.Headers
|
import okhttp3.Headers
|
||||||
|
import okhttp3.Response
|
||||||
import org.json.JSONArray
|
import org.json.JSONArray
|
||||||
import org.json.JSONException
|
import org.json.JSONException
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
@@ -9,11 +10,16 @@ import org.koitharu.kotatsu.parsers.MangaParserAuthProvider
|
|||||||
import org.koitharu.kotatsu.parsers.MangaSourceParser
|
import org.koitharu.kotatsu.parsers.MangaSourceParser
|
||||||
import org.koitharu.kotatsu.parsers.PagedMangaParser
|
import org.koitharu.kotatsu.parsers.PagedMangaParser
|
||||||
import org.koitharu.kotatsu.parsers.config.ConfigKey
|
import org.koitharu.kotatsu.parsers.config.ConfigKey
|
||||||
|
import org.koitharu.kotatsu.parsers.exception.AuthRequiredException
|
||||||
import org.koitharu.kotatsu.parsers.exception.ContentUnavailableException
|
import org.koitharu.kotatsu.parsers.exception.ContentUnavailableException
|
||||||
import org.koitharu.kotatsu.parsers.exception.ParseException
|
import org.koitharu.kotatsu.parsers.exception.ParseException
|
||||||
import org.koitharu.kotatsu.parsers.model.*
|
import org.koitharu.kotatsu.parsers.model.*
|
||||||
import org.koitharu.kotatsu.parsers.util.*
|
import org.koitharu.kotatsu.parsers.util.*
|
||||||
import org.koitharu.kotatsu.parsers.util.json.*
|
import org.koitharu.kotatsu.parsers.util.json.getStringOrNull
|
||||||
|
import org.koitharu.kotatsu.parsers.util.json.mapJSON
|
||||||
|
import org.koitharu.kotatsu.parsers.util.json.mapJSONTo
|
||||||
|
import org.koitharu.kotatsu.parsers.util.json.mapJSONToSet
|
||||||
|
import java.net.HttpURLConnection
|
||||||
import java.net.URLDecoder
|
import java.net.URLDecoder
|
||||||
import java.text.DateFormat
|
import java.text.DateFormat
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
@@ -25,19 +31,12 @@ private const val STATUS_FINISHED = 0
|
|||||||
|
|
||||||
@MangaSourceParser("REMANGA", "Remanga", "ru")
|
@MangaSourceParser("REMANGA", "Remanga", "ru")
|
||||||
internal class RemangaParser(
|
internal class RemangaParser(
|
||||||
context: MangaLoaderContext,
|
override val context: MangaLoaderContext,
|
||||||
) : PagedMangaParser(context, MangaSource.REMANGA, PAGE_SIZE), MangaParserAuthProvider {
|
) : PagedMangaParser(MangaSource.REMANGA, PAGE_SIZE), MangaParserAuthProvider {
|
||||||
|
|
||||||
private val baseHeaders = Headers.Builder()
|
override val configKeyDomain = ConfigKey.Domain("remanga.org", null)
|
||||||
.add("User-Agent", "Mozilla/5.0 (Android 13; Mobile; rv:68.0) Gecko/68.0 Firefox/109.0")
|
|
||||||
.build()
|
|
||||||
|
|
||||||
override val headers
|
|
||||||
get() = getApiHeaders()
|
|
||||||
|
|
||||||
override val configKeyDomain = ConfigKey.Domain("remanga.org", arrayOf("remanga.org", "реманга.орг"))
|
|
||||||
override val authUrl: String
|
override val authUrl: String
|
||||||
get() = "https://${domain}/user/login"
|
get() = "https://${getDomain()}/user/login"
|
||||||
|
|
||||||
override val sortOrders: Set<SortOrder> = EnumSet.of(
|
override val sortOrders: Set<SortOrder> = EnumSet.of(
|
||||||
SortOrder.UPDATED,
|
SortOrder.UPDATED,
|
||||||
@@ -48,7 +47,7 @@ internal class RemangaParser(
|
|||||||
|
|
||||||
override val isAuthorized: Boolean
|
override val isAuthorized: Boolean
|
||||||
get() {
|
get() {
|
||||||
return context.cookieJar.getCookies(domain).any {
|
return context.cookieJar.getCookies(getDomain()).any {
|
||||||
it.name == "user"
|
it.name == "user"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -62,7 +61,7 @@ internal class RemangaParser(
|
|||||||
sortOrder: SortOrder,
|
sortOrder: SortOrder,
|
||||||
): List<Manga> {
|
): List<Manga> {
|
||||||
copyCookies()
|
copyCookies()
|
||||||
val domain = domain
|
val domain = getDomain()
|
||||||
val urlBuilder = StringBuilder()
|
val urlBuilder = StringBuilder()
|
||||||
.append("https://api.")
|
.append("https://api.")
|
||||||
.append(domain)
|
.append(domain)
|
||||||
@@ -82,7 +81,7 @@ internal class RemangaParser(
|
|||||||
.append(page)
|
.append(page)
|
||||||
.append("&count=")
|
.append("&count=")
|
||||||
.append(PAGE_SIZE)
|
.append(PAGE_SIZE)
|
||||||
val content = webClient.httpGet(urlBuilder.toString()).parseJson()
|
val content = context.httpGet(urlBuilder.toString(), getApiHeaders()).parseJson()
|
||||||
.getJSONArray("content")
|
.getJSONArray("content")
|
||||||
return content.mapJSON { jo ->
|
return content.mapJSON { jo ->
|
||||||
val url = "/manga/${jo.getString("dir")}"
|
val url = "/manga/${jo.getString("dir")}"
|
||||||
@@ -113,12 +112,13 @@ internal class RemangaParser(
|
|||||||
|
|
||||||
override suspend fun getDetails(manga: Manga): Manga {
|
override suspend fun getDetails(manga: Manga): Manga {
|
||||||
copyCookies()
|
copyCookies()
|
||||||
val domain = domain
|
val domain = getDomain()
|
||||||
val slug = manga.url.find(regexLastUrlPath)
|
val slug = manga.url.find(regexLastUrlPath)
|
||||||
?: throw ParseException("Cannot obtain slug from ${manga.url}", manga.publicUrl)
|
?: throw ParseException("Cannot obtain slug from ${manga.url}", manga.publicUrl)
|
||||||
val data = webClient.httpGet(
|
val data = context.httpGet(
|
||||||
url = "https://api.$domain/api/titles$slug/",
|
url = "https://api.$domain/api/titles$slug/",
|
||||||
).parseJson()
|
headers = getApiHeaders(),
|
||||||
|
).handle401().parseJson()
|
||||||
val content = try {
|
val content = try {
|
||||||
data.getJSONObject("content")
|
data.getJSONObject("content")
|
||||||
} catch (e: JSONException) {
|
} catch (e: JSONException) {
|
||||||
@@ -143,19 +143,13 @@ internal class RemangaParser(
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
chapters = chapters.mapChapters { i, jo ->
|
chapters = chapters.mapChapters { i, jo ->
|
||||||
if (
|
|
||||||
jo.getBooleanOrDefault("is_paid", false) &&
|
|
||||||
!jo.getBooleanOrDefault("is_bought", false)
|
|
||||||
) {
|
|
||||||
return@mapChapters null
|
|
||||||
}
|
|
||||||
val id = jo.getLong("id")
|
val id = jo.getLong("id")
|
||||||
val name = jo.getString("name").toTitleCase(Locale.ROOT)
|
val name = jo.getString("name").toTitleCase(Locale.ROOT)
|
||||||
val publishers = jo.optJSONArray("publishers")
|
val publishers = jo.optJSONArray("publishers")
|
||||||
MangaChapter(
|
MangaChapter(
|
||||||
id = generateUid(id),
|
id = generateUid(id),
|
||||||
url = "/api/titles/chapters/$id/",
|
url = "/api/titles/chapters/$id/",
|
||||||
number = jo.getIntOrDefault("index", chapters.size - i),
|
number = chapters.size - i,
|
||||||
name = buildString {
|
name = buildString {
|
||||||
append("Том ")
|
append("Том ")
|
||||||
append(jo.optString("tome", "0"))
|
append(jo.optString("tome", "0"))
|
||||||
@@ -177,7 +171,9 @@ internal class RemangaParser(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
|
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
|
||||||
val content = webClient.httpGet(chapter.url.toAbsoluteUrl(getDomain("api")))
|
val referer = "https://${getDomain()}/"
|
||||||
|
val content = context.httpGet(chapter.url.toAbsoluteUrl(getDomain("api")), getApiHeaders())
|
||||||
|
.handle401()
|
||||||
.parseJson()
|
.parseJson()
|
||||||
.getJSONObject("content")
|
.getJSONObject("content")
|
||||||
val pages = content.optJSONArray("pages")
|
val pages = content.optJSONArray("pages")
|
||||||
@@ -195,8 +191,8 @@ internal class RemangaParser(
|
|||||||
val result = ArrayList<MangaPage>(pages.length())
|
val result = ArrayList<MangaPage>(pages.length())
|
||||||
for (i in 0 until pages.length()) {
|
for (i in 0 until pages.length()) {
|
||||||
when (val item = pages.get(i)) {
|
when (val item = pages.get(i)) {
|
||||||
is JSONObject -> result += parsePage(item)
|
is JSONObject -> result += parsePage(item, referer)
|
||||||
is JSONArray -> item.mapJSONTo(result) { parsePage(it) }
|
is JSONArray -> item.mapJSONTo(result) { parsePage(it, referer) }
|
||||||
else -> throw ParseException("Unknown json item $item", chapter.url)
|
else -> throw ParseException("Unknown json item $item", chapter.url)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -204,8 +200,8 @@ internal class RemangaParser(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getTags(): Set<MangaTag> {
|
override suspend fun getTags(): Set<MangaTag> {
|
||||||
val domain = domain
|
val domain = getDomain()
|
||||||
val content = webClient.httpGet("https://api.$domain/api/forms/titles/?get=genres")
|
val content = context.httpGet("https://api.$domain/api/forms/titles/?get=genres", getApiHeaders())
|
||||||
.parseJson().getJSONObject("content").getJSONArray("genres")
|
.parseJson().getJSONObject("content").getJSONArray("genres")
|
||||||
return content.mapJSONToSet { jo ->
|
return content.mapJSONToSet { jo ->
|
||||||
MangaTag(
|
MangaTag(
|
||||||
@@ -217,23 +213,24 @@ internal class RemangaParser(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getUsername(): String {
|
override suspend fun getUsername(): String {
|
||||||
val jo = webClient.httpGet(
|
val jo = context.httpGet(
|
||||||
url = "https://api.${domain}/api/users/current/",
|
url = "https://api.${getDomain()}/api/users/current/",
|
||||||
).parseJson()
|
headers = getApiHeaders(),
|
||||||
|
).handle401().parseJson()
|
||||||
return jo.getJSONObject("content").getString("username")
|
return jo.getJSONObject("content").getString("username")
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getApiHeaders(): Headers {
|
private fun getApiHeaders(): Headers? {
|
||||||
val userCookie = context.cookieJar.getCookies(domain).find {
|
val userCookie = context.cookieJar.getCookies(getDomain()).find {
|
||||||
it.name == "user"
|
it.name == "user"
|
||||||
} ?: return baseHeaders
|
} ?: return null
|
||||||
val jo = JSONObject(URLDecoder.decode(userCookie.value, Charsets.UTF_8.name()))
|
val jo = JSONObject(URLDecoder.decode(userCookie.value, Charsets.UTF_8.name()))
|
||||||
val accessToken = jo.getStringOrNull("access_token") ?: return baseHeaders
|
val accessToken = jo.getStringOrNull("access_token") ?: return null
|
||||||
return baseHeaders.newBuilder().add("authorization", "bearer $accessToken").build()
|
return Headers.headersOf("authorization", "bearer $accessToken")
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun copyCookies() {
|
private fun copyCookies() {
|
||||||
val domain = domain
|
val domain = getDomain()
|
||||||
context.cookieJar.copyCookies(domain, "api.$domain")
|
context.cookieJar.copyCookies(domain, "api.$domain")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -245,10 +242,11 @@ internal class RemangaParser(
|
|||||||
else -> "-chapter_date"
|
else -> "-chapter_date"
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun parsePage(jo: JSONObject) = MangaPage(
|
private fun parsePage(jo: JSONObject, referer: String) = MangaPage(
|
||||||
id = generateUid(jo.getLong("id")),
|
id = generateUid(jo.getLong("id")),
|
||||||
url = jo.getString("link"),
|
url = jo.getString("link"),
|
||||||
preview = null,
|
preview = null,
|
||||||
|
referer = referer,
|
||||||
source = source,
|
source = source,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -256,9 +254,10 @@ internal class RemangaParser(
|
|||||||
val result = ArrayList<JSONObject>(100)
|
val result = ArrayList<JSONObject>(100)
|
||||||
var page = 1
|
var page = 1
|
||||||
while (true) {
|
while (true) {
|
||||||
val content = webClient.httpGet(
|
val content = context.httpGet(
|
||||||
url = "https://api.$domain/api/titles/chapters/?branch_id=$branchId&page=$page&count=100",
|
url = "https://api.$domain/api/titles/chapters/?branch_id=$branchId&page=$page&count=100",
|
||||||
).parseJson().getJSONArray("content")
|
headers = getApiHeaders(),
|
||||||
|
).handle401().parseJson().getJSONArray("content")
|
||||||
val len = content.length()
|
val len = content.length()
|
||||||
if (len == 0) {
|
if (len == 0) {
|
||||||
break
|
break
|
||||||
@@ -271,4 +270,10 @@ internal class RemangaParser(
|
|||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
private fun Response.handle401() = apply {
|
||||||
|
if (code == HttpURLConnection.HTTP_UNAUTHORIZED) {
|
||||||
|
throw AuthRequiredException(source)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,153 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.parsers.site
|
|
||||||
|
|
||||||
import androidx.collection.ArrayMap
|
|
||||||
import androidx.collection.ArraySet
|
|
||||||
import kotlinx.coroutines.sync.Mutex
|
|
||||||
import kotlinx.coroutines.sync.withLock
|
|
||||||
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
|
||||||
import org.koitharu.kotatsu.parsers.MangaSourceParser
|
|
||||||
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 java.text.SimpleDateFormat
|
|
||||||
import java.util.*
|
|
||||||
|
|
||||||
@MangaSourceParser("TRUYENTRANHLH", "TruyentranhLH", "vi")
|
|
||||||
class TruyentranhLHParser(context: MangaLoaderContext) :
|
|
||||||
PagedMangaParser(context, source = MangaSource.TRUYENTRANHLH, pageSize = 18) {
|
|
||||||
|
|
||||||
override val configKeyDomain: ConfigKey.Domain = ConfigKey.Domain("truyentranhlh.net", null)
|
|
||||||
override val sortOrders: Set<SortOrder> = EnumSet.allOf(SortOrder::class.java)
|
|
||||||
|
|
||||||
private val mutex = Mutex()
|
|
||||||
private var tagCache: Map<String, MangaTag>? = null
|
|
||||||
|
|
||||||
override suspend fun getDetails(manga: Manga): Manga {
|
|
||||||
val docs = webClient.httpGet(manga.url.toAbsoluteUrl(domain)).parseHtml()
|
|
||||||
val infoHeaderEl = docs.selectFirst("main.section-body")
|
|
||||||
val infoEl = docs.selectFirst("main.section-body .series-information")
|
|
||||||
val tags = infoEl?.select(".info-item:contains(Thể loại) > .info-value > a")?.mapNotNullToSet {
|
|
||||||
getOrCreateTagMap()[it.text().trim()]
|
|
||||||
}
|
|
||||||
val state = when (infoEl?.selectFirst(".info-item:contains(Tình trạng) > .info-value")?.text()) {
|
|
||||||
"Đang tiến hành" -> MangaState.ONGOING
|
|
||||||
"Đã hoàn thành" -> MangaState.FINISHED
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
val rating = infoHeaderEl?.let {
|
|
||||||
val like = it.selectFirst("#like .block.feature-name")?.text()?.toIntOrNull()
|
|
||||||
val disLike = it.selectFirst("#dislike .block.feature-name")?.text()?.toIntOrNull()
|
|
||||||
when {
|
|
||||||
like == null || disLike == null -> RATING_UNKNOWN
|
|
||||||
like == 0 && disLike == 0 -> RATING_UNKNOWN
|
|
||||||
else -> like.toFloat() / (like + disLike)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val chapterDateFormat = SimpleDateFormat("dd/MM/yyyy", Locale.US)
|
|
||||||
|
|
||||||
return manga.copy(
|
|
||||||
altTitle = infoEl?.selectFirst(".info-item:contains(Tên khác) > .info-value")?.text(),
|
|
||||||
author = infoEl?.select(".info-item:contains(Tác giả) > .info-value")?.joinToString { it.text() },
|
|
||||||
tags = tags ?: emptySet(),
|
|
||||||
state = state,
|
|
||||||
rating = rating ?: RATING_UNKNOWN,
|
|
||||||
description = infoHeaderEl?.selectFirst(".series-summary .summary-content")?.html(),
|
|
||||||
chapters = docs.select("ul.list-chapters.at-series > a").mapChapters(reversed = true) { index, element ->
|
|
||||||
MangaChapter(
|
|
||||||
id = generateUid(element.attrAsRelativeUrl("href")),
|
|
||||||
name = element.selectFirst(".chapter-name")?.text()?.trim().orEmpty(),
|
|
||||||
number = index + 1,
|
|
||||||
url = element.attrAsRelativeUrl("href"),
|
|
||||||
scanlator = null,
|
|
||||||
uploadDate = chapterDateFormat.tryParse(element.selectFirst(".chapter-time")?.text()),
|
|
||||||
branch = null,
|
|
||||||
source = source,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun getListPage(
|
|
||||||
page: Int,
|
|
||||||
query: String?,
|
|
||||||
tags: Set<MangaTag>?,
|
|
||||||
sortOrder: SortOrder,
|
|
||||||
): List<Manga> {
|
|
||||||
val sortQuery = when (sortOrder) {
|
|
||||||
SortOrder.UPDATED -> "update"
|
|
||||||
SortOrder.NEWEST -> "new"
|
|
||||||
SortOrder.RATING -> "like"
|
|
||||||
SortOrder.POPULARITY -> "top"
|
|
||||||
SortOrder.ALPHABETICAL -> "az"
|
|
||||||
}
|
|
||||||
val url = urlBuilder().apply {
|
|
||||||
addPathSegment("tim-kiem")
|
|
||||||
addQueryParameter("sort", sortQuery)
|
|
||||||
addQueryParameter("page", page.toString())
|
|
||||||
if (!query.isNullOrEmpty()) {
|
|
||||||
addQueryParameter("q", query)
|
|
||||||
}
|
|
||||||
if (!tags.isNullOrEmpty()) {
|
|
||||||
val tagsQuery = tags.joinToString(separator = ",") { it.key }
|
|
||||||
addEncodedQueryParameter("accept_genres", tagsQuery)
|
|
||||||
}
|
|
||||||
}.build()
|
|
||||||
|
|
||||||
return webClient.httpGet(url).parseHtml()
|
|
||||||
.select(".container .card.card-dark .row > .thumb-item-flow")
|
|
||||||
.mapNotNull {
|
|
||||||
val a = it.selectFirstOrThrow(".thumb-wrapper a")
|
|
||||||
Manga(
|
|
||||||
id = generateUid(a.attrAsRelativeUrl("href")),
|
|
||||||
url = a.attrAsRelativeUrl("href"),
|
|
||||||
publicUrl = a.attrAsAbsoluteUrl("href"),
|
|
||||||
title = it.select(".thumb_attr.series-title").text(),
|
|
||||||
altTitle = null,
|
|
||||||
rating = RATING_UNKNOWN,
|
|
||||||
isNsfw = false,
|
|
||||||
coverUrl = a.selectFirst("div[data-bg]")?.attrAsAbsoluteUrl("data-bg").orEmpty(),
|
|
||||||
tags = emptySet(),
|
|
||||||
state = null,
|
|
||||||
author = null,
|
|
||||||
source = source,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
|
|
||||||
val url = chapter.url.toAbsoluteUrl(domain)
|
|
||||||
return webClient.httpGet(url).parseHtml().select("#chapter-content > img").mapNotNull {
|
|
||||||
val imageUrl = it.attrAsRelativeUrlOrNull("data-src")
|
|
||||||
?: it.attrAsRelativeUrlOrNull("src")
|
|
||||||
?: return@mapNotNull null
|
|
||||||
MangaPage(
|
|
||||||
id = generateUid(imageUrl),
|
|
||||||
url = imageUrl,
|
|
||||||
preview = null,
|
|
||||||
source = source,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun getTags(): Set<MangaTag> {
|
|
||||||
return ArraySet(getOrCreateTagMap().values)
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun getOrCreateTagMap(): Map<String, MangaTag> = mutex.withLock {
|
|
||||||
tagCache?.let { return it }
|
|
||||||
val docs = webClient.httpGet("/tim-kiem".toAbsoluteUrl(domain)).parseHtml()
|
|
||||||
val tags = docs.select(".search-border-left .row > .search-gerne_item").mapNotNull {
|
|
||||||
MangaTag(
|
|
||||||
title = it.text().trim(),
|
|
||||||
key = it.selectFirst("label[data-genre-id]")
|
|
||||||
?.attr("data-genre-id")
|
|
||||||
?.trim() ?: return@mapNotNull null,
|
|
||||||
source = source,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
val tagMap = tags.associateByTo(ArrayMap(tags.size)) { it.title }
|
|
||||||
tagCache = tagMap
|
|
||||||
return tagMap
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
package org.koitharu.kotatsu.parsers.site
|
||||||
|
|
||||||
|
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
||||||
|
import org.koitharu.kotatsu.parsers.MangaSourceParser
|
||||||
|
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 java.text.SimpleDateFormat
|
||||||
|
import java.util.*
|
||||||
|
|
||||||
|
@MangaSourceParser("TUMANGAONLINE", "TuMangaOnline", "es")
|
||||||
|
class TuMangaOnlineParser(override val context: MangaLoaderContext) : PagedMangaParser(
|
||||||
|
source = MangaSource.TUMANGAONLINE,
|
||||||
|
pageSize = 24,
|
||||||
|
) {
|
||||||
|
|
||||||
|
override val configKeyDomain = ConfigKey.Domain("lectortmo.com", null)
|
||||||
|
|
||||||
|
override val sortOrders: EnumSet<SortOrder> = EnumSet.of(
|
||||||
|
SortOrder.POPULARITY,
|
||||||
|
SortOrder.ALPHABETICAL,
|
||||||
|
SortOrder.RATING,
|
||||||
|
SortOrder.NEWEST,
|
||||||
|
SortOrder.UPDATED,
|
||||||
|
)
|
||||||
|
|
||||||
|
init {
|
||||||
|
paginator.firstPage = 1
|
||||||
|
searchPaginator.firstPage = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getListPage(
|
||||||
|
page: Int,
|
||||||
|
query: String?,
|
||||||
|
tags: Set<MangaTag>?,
|
||||||
|
sortOrder: SortOrder,
|
||||||
|
): List<Manga> {
|
||||||
|
val url = (
|
||||||
|
"/library" +
|
||||||
|
"?order_item=likes_count" +
|
||||||
|
"&order_dir=desc" +
|
||||||
|
"&filter_by=title" +
|
||||||
|
"&_pg=1" +
|
||||||
|
"&page=$page"
|
||||||
|
).toAbsoluteUrl(getDomain())
|
||||||
|
val doc = context.httpGet(url).parseHtml()
|
||||||
|
val items = doc.body().select("div.element")
|
||||||
|
return items.mapNotNull { item ->
|
||||||
|
val href = item.selectFirst("a")?.attrAsRelativeUrl("href")?.substringAfter(" ") ?: return@mapNotNull null
|
||||||
|
Manga(
|
||||||
|
id = generateUid(href),
|
||||||
|
title = item.selectFirst("h4.text-truncate")?.text() ?: return@mapNotNull null,
|
||||||
|
coverUrl = item.select("style").toString().substringAfter("('").substringBeforeLast("')"),
|
||||||
|
altTitle = null,
|
||||||
|
author = null,
|
||||||
|
rating = item.selectFirst("span.score")
|
||||||
|
?.text()
|
||||||
|
?.toFloatOrNull()
|
||||||
|
?.div(10F) ?: RATING_UNKNOWN,
|
||||||
|
url = href,
|
||||||
|
isNsfw = item.select("i").hasClass("fas fa-heartbeat fa-2x"),
|
||||||
|
tags = emptySet(),
|
||||||
|
state = null,
|
||||||
|
publicUrl = href.toAbsoluteUrl(doc.host ?: getDomain()),
|
||||||
|
source = source,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getDetails(manga: Manga): Manga {
|
||||||
|
val doc = context.httpGet(manga.url.toAbsoluteUrl(getDomain())).parseHtml()
|
||||||
|
val contents = doc.body().selectFirstOrThrow("section.element-header-content")
|
||||||
|
return manga.copy(
|
||||||
|
description = contents.selectFirst("p.element-description")?.html(),
|
||||||
|
largeCoverUrl = contents.selectFirst(".book-thumbnail")?.attrAsAbsoluteUrlOrNull("src"),
|
||||||
|
state = parseStatus(contents.select("span.book-status").text().orEmpty()),
|
||||||
|
author = contents.selectFirst("h5.card-title")?.attr("title")?.substringAfter(", "),
|
||||||
|
chapters = doc.body().select("div.chapters > ul.list-group li.p-0.list-group-item").asReversed()
|
||||||
|
.mapChapters { i, li ->
|
||||||
|
val href = li.selectFirst("div.row > .text-right > a")
|
||||||
|
?.attrAsRelativeUrl("href") ?: li.parseFailed()
|
||||||
|
MangaChapter(
|
||||||
|
id = generateUid(href),
|
||||||
|
name = li.select("div.col-10.text-truncate").text().substringAfter(": ").trim(),
|
||||||
|
number = i + 1,
|
||||||
|
url = href,
|
||||||
|
scanlator = li.select("div.col-md-6.text-truncate").text(),
|
||||||
|
branch = null,
|
||||||
|
uploadDate = li.select("span.badge.badge-primary.p-2").first()?.text()
|
||||||
|
?.let { parseChapterDate(it) }
|
||||||
|
?: 0,
|
||||||
|
source = source,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
|
||||||
|
TODO("Not yet implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getTags(): Set<MangaTag> {
|
||||||
|
TODO("Not yet implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseStatus(status: String) = when {
|
||||||
|
status.contains("Publicándose") -> MangaState.ONGOING
|
||||||
|
status.contains("Finalizado") -> MangaState.FINISHED
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseChapterDate(date: String): Long =
|
||||||
|
SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()).parse(date)?.time ?: 0
|
||||||
|
|
||||||
|
}
|
||||||
@@ -13,7 +13,7 @@ import java.text.SimpleDateFormat
|
|||||||
import java.util.*
|
import java.util.*
|
||||||
|
|
||||||
@MangaSourceParser("UNION_MANGAS", "Union Mangás", "pt")
|
@MangaSourceParser("UNION_MANGAS", "Union Mangás", "pt")
|
||||||
class UnionMangasParser(context: MangaLoaderContext) : PagedMangaParser(context, MangaSource.UNION_MANGAS, 40) {
|
class UnionMangasParser(override val context: MangaLoaderContext) : PagedMangaParser(MangaSource.UNION_MANGAS, 40) {
|
||||||
|
|
||||||
override val sortOrders = EnumSet.of(
|
override val sortOrders = EnumSet.of(
|
||||||
SortOrder.ALPHABETICAL,
|
SortOrder.ALPHABETICAL,
|
||||||
@@ -45,7 +45,7 @@ class UnionMangasParser(context: MangaLoaderContext) : PagedMangaParser(context,
|
|||||||
else -> "visualizacoes"
|
else -> "visualizacoes"
|
||||||
},
|
},
|
||||||
).addPathSegment(page.toString())
|
).addPathSegment(page.toString())
|
||||||
val doc = webClient.httpGet(url.build()).parseHtml()
|
val doc = context.httpGet(url.build()).parseHtml()
|
||||||
val root = doc.selectFirstOrThrow("div.tamanho-bloco-perfil")
|
val root = doc.selectFirstOrThrow("div.tamanho-bloco-perfil")
|
||||||
return root.select(".lista-mangas-novos").map { div ->
|
return root.select(".lista-mangas-novos").map { div ->
|
||||||
val a = div.selectFirstOrThrow("a")
|
val a = div.selectFirstOrThrow("a")
|
||||||
@@ -70,7 +70,7 @@ class UnionMangasParser(context: MangaLoaderContext) : PagedMangaParser(context,
|
|||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getDetails(manga: Manga): Manga {
|
override suspend fun getDetails(manga: Manga): Manga {
|
||||||
val doc = webClient.httpGet(manga.url.toAbsoluteUrl(domain)).parseHtml()
|
val doc = context.httpGet(manga.url.toAbsoluteUrl(getDomain())).parseHtml()
|
||||||
val root = doc.selectFirstOrThrow(".perfil-manga")
|
val root = doc.selectFirstOrThrow(".perfil-manga")
|
||||||
val dateFormat = SimpleDateFormat("dd/MM/yyyy", Locale.ROOT)
|
val dateFormat = SimpleDateFormat("dd/MM/yyyy", Locale.ROOT)
|
||||||
return manga.copy(
|
return manga.copy(
|
||||||
@@ -90,7 +90,7 @@ class UnionMangasParser(context: MangaLoaderContext) : PagedMangaParser(context,
|
|||||||
it.toMangaTag()
|
it.toMangaTag()
|
||||||
} ?: manga.tags,
|
} ?: manga.tags,
|
||||||
isNsfw = root.selectFirst(".alert-danger")?.html()?.contains("18 anos") == true,
|
isNsfw = root.selectFirst(".alert-danger")?.html()?.contains("18 anos") == true,
|
||||||
chapters = root.select("div.row.capitulos").mapChapters(reversed = true) { i, div ->
|
chapters = root.select("div.row.capitulos").asReversed().mapChapters { i, div ->
|
||||||
val a = div.selectFirstOrThrow("a")
|
val a = div.selectFirstOrThrow("a")
|
||||||
val href = a.attrAsRelativeUrl("href")
|
val href = a.attrAsRelativeUrl("href")
|
||||||
val title = a.text()
|
val title = a.text()
|
||||||
@@ -101,7 +101,7 @@ class UnionMangasParser(context: MangaLoaderContext) : PagedMangaParser(context,
|
|||||||
url = href,
|
url = href,
|
||||||
scanlator = div.selectLast("a")?.text()?.takeUnless { it == title },
|
scanlator = div.selectLast("a")?.text()?.takeUnless { it == title },
|
||||||
uploadDate = dateFormat.tryParse(
|
uploadDate = dateFormat.tryParse(
|
||||||
a.nextElementSibling()?.text()?.removeSurrounding("(", ")"),
|
a.nextElementSibling()?.text()?.removeSurrounding('(', ')'),
|
||||||
),
|
),
|
||||||
branch = null,
|
branch = null,
|
||||||
source = source,
|
source = source,
|
||||||
@@ -111,8 +111,8 @@ class UnionMangasParser(context: MangaLoaderContext) : PagedMangaParser(context,
|
|||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
|
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
|
||||||
val fullUrl = chapter.url.toAbsoluteUrl(domain)
|
val fullUrl = chapter.url.toAbsoluteUrl(getDomain())
|
||||||
val doc = webClient.httpGet(fullUrl).parseHtml()
|
val doc = context.httpGet(fullUrl).parseHtml()
|
||||||
val root = doc.body().selectFirstOrThrow("article")
|
val root = doc.body().selectFirstOrThrow("article")
|
||||||
return root.selectOrThrow("img[pag]").mapNotNull { img ->
|
return root.selectOrThrow("img[pag]").mapNotNull { img ->
|
||||||
val href = img.attrAsRelativeUrl("src")
|
val href = img.attrAsRelativeUrl("src")
|
||||||
@@ -122,6 +122,7 @@ class UnionMangasParser(context: MangaLoaderContext) : PagedMangaParser(context,
|
|||||||
MangaPage(
|
MangaPage(
|
||||||
id = generateUid(href),
|
id = generateUid(href),
|
||||||
url = href,
|
url = href,
|
||||||
|
referer = fullUrl,
|
||||||
preview = null,
|
preview = null,
|
||||||
source = source,
|
source = source,
|
||||||
)
|
)
|
||||||
@@ -129,7 +130,7 @@ class UnionMangasParser(context: MangaLoaderContext) : PagedMangaParser(context,
|
|||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getTags(): Set<MangaTag> {
|
override suspend fun getTags(): Set<MangaTag> {
|
||||||
val doc = webClient.httpGet(urlBuilder().addPathSegment("lista-mangas").build()).parseHtml()
|
val doc = context.httpGet(urlBuilder().addPathSegment("lista-mangas").build()).parseHtml()
|
||||||
val ul = doc.body().selectFirstOrThrow(".nav-tabs").selectFirstOrThrow("ul.dropdown-menu")
|
val ul = doc.body().selectFirstOrThrow(".nav-tabs").selectFirstOrThrow("ul.dropdown-menu")
|
||||||
return ul.select("li").mapToSet { li ->
|
return ul.select("li").mapToSet { li ->
|
||||||
li.selectFirstOrThrow("a").toMangaTag()
|
li.selectFirstOrThrow("a").toMangaTag()
|
||||||
@@ -137,8 +138,8 @@ class UnionMangasParser(context: MangaLoaderContext) : PagedMangaParser(context,
|
|||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun search(query: String): List<Manga> {
|
private suspend fun search(query: String): List<Manga> {
|
||||||
val domain = domain
|
val domain = getDomain()
|
||||||
val json = webClient.httpGet(
|
val json = context.httpGet(
|
||||||
urlBuilder()
|
urlBuilder()
|
||||||
.addPathSegments("assets/busca.php")
|
.addPathSegments("assets/busca.php")
|
||||||
.addQueryParameter("nomeManga", query)
|
.addQueryParameter("nomeManga", query)
|
||||||
@@ -169,8 +170,8 @@ class UnionMangasParser(context: MangaLoaderContext) : PagedMangaParser(context,
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun Element.toMangaTag() = MangaTag(
|
private fun Element.toMangaTag() = MangaTag(
|
||||||
title = text().toTitleCase(sourceLocale),
|
title = text().toTitleCase(sourceLocale ?: Locale.ROOT),
|
||||||
key = attr("href").removeSuffix('/').substringAfterLast('/'),
|
key = attr("href").removeSuffix('/').substringAfterLast('/'),
|
||||||
source = source,
|
source = source,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.parsers.site.grouple
|
|
||||||
|
|
||||||
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
|
||||||
import org.koitharu.kotatsu.parsers.MangaSourceParser
|
|
||||||
import org.koitharu.kotatsu.parsers.config.ConfigKey
|
|
||||||
import org.koitharu.kotatsu.parsers.exception.AuthRequiredException
|
|
||||||
import org.koitharu.kotatsu.parsers.exception.ParseException
|
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaPage
|
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
|
||||||
import org.koitharu.kotatsu.parsers.util.domain
|
|
||||||
import org.koitharu.kotatsu.parsers.util.parseFailed
|
|
||||||
import org.koitharu.kotatsu.parsers.util.parseHtml
|
|
||||||
import org.koitharu.kotatsu.parsers.util.urlEncoded
|
|
||||||
|
|
||||||
@MangaSourceParser("ALLHENTAI", "ALlHentai", "ru")
|
|
||||||
internal class AllHentaiParser(
|
|
||||||
context: MangaLoaderContext,
|
|
||||||
) : GroupleParser(context, MangaSource.ALLHENTAI, 1) {
|
|
||||||
|
|
||||||
override val configKeyDomain = ConfigKey.Domain(
|
|
||||||
"2023.allhen.online",
|
|
||||||
null,
|
|
||||||
)
|
|
||||||
override val defaultIsNsfw = true
|
|
||||||
|
|
||||||
override val authUrl: String
|
|
||||||
get() {
|
|
||||||
val targetUri = "https://${domain}/".urlEncoded()
|
|
||||||
return "https://qawa.org/internal/auth/login?targetUri=$targetUri&siteId=1"
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
|
|
||||||
try {
|
|
||||||
return super.getPages(chapter)
|
|
||||||
} catch (e: ParseException) {
|
|
||||||
if (isAuthorized) {
|
|
||||||
throw e
|
|
||||||
} else {
|
|
||||||
throw AuthRequiredException(source)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun getUsername(): String {
|
|
||||||
val root = webClient.httpGet("https://qawa.org/").parseHtml().body()
|
|
||||||
val element = root.selectFirst("img.user-avatar") ?: throw AuthRequiredException(source)
|
|
||||||
val res = element.parent()?.text()
|
|
||||||
return if (res.isNullOrEmpty()) {
|
|
||||||
root.parseFailed("Cannot find username")
|
|
||||||
} else res
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,19 +1,11 @@
|
|||||||
package org.koitharu.kotatsu.parsers.site.grouple
|
package org.koitharu.kotatsu.parsers.site.grouple
|
||||||
|
|
||||||
import kotlinx.coroutines.flow.channelFlow
|
|
||||||
import kotlinx.coroutines.flow.first
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import okhttp3.Headers
|
import okhttp3.Headers
|
||||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
|
||||||
import okhttp3.Interceptor
|
|
||||||
import okhttp3.Response
|
import okhttp3.Response
|
||||||
import okhttp3.internal.headersContentLength
|
|
||||||
import org.json.JSONArray
|
import org.json.JSONArray
|
||||||
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
|
||||||
import org.koitharu.kotatsu.parsers.MangaParser
|
import org.koitharu.kotatsu.parsers.MangaParser
|
||||||
import org.koitharu.kotatsu.parsers.MangaParserAuthProvider
|
import org.koitharu.kotatsu.parsers.MangaParserAuthProvider
|
||||||
import org.koitharu.kotatsu.parsers.config.ConfigKey
|
|
||||||
import org.koitharu.kotatsu.parsers.exception.AuthRequiredException
|
import org.koitharu.kotatsu.parsers.exception.AuthRequiredException
|
||||||
import org.koitharu.kotatsu.parsers.exception.ParseException
|
import org.koitharu.kotatsu.parsers.exception.ParseException
|
||||||
import org.koitharu.kotatsu.parsers.model.*
|
import org.koitharu.kotatsu.parsers.model.*
|
||||||
@@ -26,25 +18,15 @@ private const val PAGE_SIZE = 70
|
|||||||
private const val PAGE_SIZE_SEARCH = 50
|
private const val PAGE_SIZE_SEARCH = 50
|
||||||
private const val NSFW_ALERT = "сексуальные сцены"
|
private const val NSFW_ALERT = "сексуальные сцены"
|
||||||
private const val NOTHING_FOUND = "Ничего не найдено"
|
private const val NOTHING_FOUND = "Ничего не найдено"
|
||||||
private const val MIN_IMAGE_SIZE = 1024L
|
|
||||||
private const val HEADER_ACCEPT = "Accept"
|
|
||||||
|
|
||||||
internal abstract class GroupleParser(
|
internal abstract class GroupleParser(
|
||||||
context: MangaLoaderContext,
|
|
||||||
source: MangaSource,
|
source: MangaSource,
|
||||||
|
userAgent: String,
|
||||||
private val siteId: Int,
|
private val siteId: Int,
|
||||||
) : MangaParser(context, source), MangaParserAuthProvider, Interceptor {
|
) : MangaParser(source), MangaParserAuthProvider {
|
||||||
|
|
||||||
@Volatile
|
override val headers = Headers.Builder()
|
||||||
private var cachedPagesServer: String? = null
|
.add("User-Agent", userAgent)
|
||||||
protected open val defaultIsNsfw = false
|
|
||||||
|
|
||||||
private val userAgentKey = ConfigKey.UserAgent(
|
|
||||||
"Mozilla/5.0 (X11; U; UNICOS lcLinux; en-US) Gecko/20140730 (KHTML, like Gecko, Safari/419.3) Arora/0.8.0",
|
|
||||||
)
|
|
||||||
|
|
||||||
override val headers: Headers = Headers.Builder()
|
|
||||||
.add("User-Agent", config[userAgentKey])
|
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
override val sortOrders: Set<SortOrder> = EnumSet.of(
|
override val sortOrders: Set<SortOrder> = EnumSet.of(
|
||||||
@@ -56,12 +38,12 @@ internal abstract class GroupleParser(
|
|||||||
|
|
||||||
override val authUrl: String
|
override val authUrl: String
|
||||||
get() {
|
get() {
|
||||||
val targetUri = "https://${domain}/".urlEncoded()
|
val targetUri = "https://${getDomain()}/".urlEncoded()
|
||||||
return "https://grouple.co/internal/auth/sso?siteId=$siteId&=targetUri=$targetUri"
|
return "https://grouple.co/internal/auth/sso?siteId=$siteId&=targetUri=$targetUri"
|
||||||
}
|
}
|
||||||
|
|
||||||
override val isAuthorized: Boolean
|
override val isAuthorized: Boolean
|
||||||
get() = context.cookieJar.getCookies(domain).any { it.name == "gwt" }
|
get() = context.cookieJar.getCookies(getDomain()).any { it.name == "gwt" }
|
||||||
|
|
||||||
override suspend fun getList(
|
override suspend fun getList(
|
||||||
offset: Int,
|
offset: Int,
|
||||||
@@ -69,26 +51,29 @@ internal abstract class GroupleParser(
|
|||||||
tags: Set<MangaTag>?,
|
tags: Set<MangaTag>?,
|
||||||
sortOrder: SortOrder,
|
sortOrder: SortOrder,
|
||||||
): List<Manga> {
|
): List<Manga> {
|
||||||
val domain = domain
|
val domain = getDomain()
|
||||||
val doc = when {
|
val doc = when {
|
||||||
!query.isNullOrEmpty() -> webClient.httpPost(
|
!query.isNullOrEmpty() -> context.httpPost(
|
||||||
"https://$domain/search",
|
"https://$domain/search",
|
||||||
mapOf(
|
mapOf(
|
||||||
"q" to query.urlEncoded(),
|
"q" to query.urlEncoded(),
|
||||||
"offset" to (offset upBy PAGE_SIZE_SEARCH).toString(),
|
"offset" to (offset upBy PAGE_SIZE_SEARCH).toString(),
|
||||||
),
|
),
|
||||||
|
headers,
|
||||||
)
|
)
|
||||||
|
|
||||||
tags.isNullOrEmpty() -> webClient.httpGet(
|
tags.isNullOrEmpty() -> context.httpGet(
|
||||||
"https://$domain/list?sortType=${
|
"https://$domain/list?sortType=${
|
||||||
getSortKey(sortOrder)
|
getSortKey(sortOrder)
|
||||||
}&offset=${offset upBy PAGE_SIZE}",
|
}&offset=${offset upBy PAGE_SIZE}",
|
||||||
|
headers,
|
||||||
)
|
)
|
||||||
|
|
||||||
tags.size == 1 -> webClient.httpGet(
|
tags.size == 1 -> context.httpGet(
|
||||||
"https://$domain/list/genre/${tags.first().key}?sortType=${
|
"https://$domain/list/genre/${tags.first().key}?sortType=${
|
||||||
getSortKey(sortOrder)
|
getSortKey(sortOrder)
|
||||||
}&offset=${offset upBy PAGE_SIZE}",
|
}&offset=${offset upBy PAGE_SIZE}",
|
||||||
|
headers,
|
||||||
)
|
)
|
||||||
|
|
||||||
offset > 0 -> return emptyList()
|
offset > 0 -> return emptyList()
|
||||||
@@ -126,13 +111,14 @@ internal abstract class GroupleParser(
|
|||||||
altTitle = descDiv.selectFirst("h4")?.text(),
|
altTitle = descDiv.selectFirst("h4")?.text(),
|
||||||
coverUrl = imgDiv.selectFirst("img.lazy")?.attr("data-original")?.replace("_p.", ".").orEmpty(),
|
coverUrl = imgDiv.selectFirst("img.lazy")?.attr("data-original")?.replace("_p.", ".").orEmpty(),
|
||||||
rating = runCatching {
|
rating = runCatching {
|
||||||
node.selectFirst(".compact-rate")
|
node.selectFirst("div.rating")
|
||||||
?.attr("title")
|
?.attr("title")
|
||||||
|
?.substringBefore(' ')
|
||||||
?.toFloatOrNull()
|
?.toFloatOrNull()
|
||||||
?.div(5f)
|
?.div(10f)
|
||||||
}.getOrNull() ?: RATING_UNKNOWN,
|
}.getOrNull() ?: RATING_UNKNOWN,
|
||||||
author = tileInfo?.selectFirst("a.person-link")?.text(),
|
author = tileInfo?.selectFirst("a.person-link")?.text(),
|
||||||
isNsfw = defaultIsNsfw,
|
isNsfw = false,
|
||||||
tags = runCatching {
|
tags = runCatching {
|
||||||
tileInfo?.select("a.element-link")
|
tileInfo?.select("a.element-link")
|
||||||
?.mapToSet {
|
?.mapToSet {
|
||||||
@@ -155,7 +141,7 @@ internal abstract class GroupleParser(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getDetails(manga: Manga): Manga {
|
override suspend fun getDetails(manga: Manga): Manga {
|
||||||
val doc = webClient.httpGet(manga.url.toAbsoluteUrl(domain)).checkAuthRequired().parseHtml()
|
val doc = context.httpGet(manga.url.toAbsoluteUrl(getDomain()), headers).parseHtml()
|
||||||
val root = doc.body().getElementById("mangaBox")?.selectFirst("div.leftContent")
|
val root = doc.body().getElementById("mangaBox")?.selectFirst("div.leftContent")
|
||||||
?: doc.parseFailed("Cannot find root")
|
?: doc.parseFailed("Cannot find root")
|
||||||
val dateFormat = SimpleDateFormat("dd.MM.yy", Locale.US)
|
val dateFormat = SimpleDateFormat("dd.MM.yy", Locale.US)
|
||||||
@@ -174,10 +160,10 @@ internal abstract class GroupleParser(
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
author = root.selectFirst("a.person-link")?.text() ?: manga.author,
|
author = root.selectFirst("a.person-link")?.text() ?: manga.author,
|
||||||
isNsfw = manga.isNsfw || root.select(".alert-warning").any { it.ownText().contains(NSFW_ALERT) },
|
isNsfw = root.select(".alert-warning").any { it.ownText().contains(NSFW_ALERT) },
|
||||||
chapters = root.selectFirst("div.chapters-link")?.selectFirst("table")
|
chapters = root.selectFirst("div.chapters-link")?.selectFirst("table")
|
||||||
?.select("tr:has(td > a)")?.mapChapters(reversed = true) { i, tr ->
|
?.select("tr:has(td > a)")?.asReversed()?.mapChapters { i, tr ->
|
||||||
val a = tr.selectFirst("a.chapter-link") ?: return@mapChapters null
|
val a = tr.selectFirst("a") ?: return@mapChapters null
|
||||||
val href = a.attrAsRelativeUrl("href")
|
val href = a.attrAsRelativeUrl("href")
|
||||||
var translators = ""
|
var translators = ""
|
||||||
val translatorElement = a.attr("title")
|
val translatorElement = a.attr("title")
|
||||||
@@ -191,7 +177,7 @@ internal abstract class GroupleParser(
|
|||||||
name = tr.selectFirst("a")?.text().orEmpty().removePrefix(manga.title).trim(),
|
name = tr.selectFirst("a")?.text().orEmpty().removePrefix(manga.title).trim(),
|
||||||
number = i + 1,
|
number = i + 1,
|
||||||
url = href,
|
url = href,
|
||||||
uploadDate = dateFormat.tryParse(tr.selectFirst("td.date")?.text()),
|
uploadDate = dateFormat.tryParse(tr.selectFirst("td.d-none")?.text()),
|
||||||
scanlator = translators,
|
scanlator = translators,
|
||||||
source = source,
|
source = source,
|
||||||
branch = null,
|
branch = null,
|
||||||
@@ -201,13 +187,11 @@ internal abstract class GroupleParser(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
|
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
|
||||||
val doc = webClient.httpGet(chapter.url.toAbsoluteUrl(domain) + "?mtr=1")
|
val doc = context.httpGet(chapter.url.toAbsoluteUrl(getDomain()) + "?mtr=1", headers).parseHtml()
|
||||||
.checkAuthRequired()
|
|
||||||
.parseHtml()
|
|
||||||
val scripts = doc.select("script")
|
val scripts = doc.select("script")
|
||||||
for (script in scripts) {
|
for (script in scripts) {
|
||||||
val data = script.html()
|
val data = script.html()
|
||||||
val pos = data.indexOf("rm_h.readerInit( 0,")
|
val pos = data.indexOf("rm_h.initReader(")
|
||||||
if (pos == -1) {
|
if (pos == -1) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -220,7 +204,7 @@ internal abstract class GroupleParser(
|
|||||||
}
|
}
|
||||||
val ja = JSONArray("[$json]")
|
val ja = JSONArray("[$json]")
|
||||||
val pages = ja.getJSONArray(1)
|
val pages = ja.getJSONArray(1)
|
||||||
val servers = ja.getJSONArray(3).mapJSON { it.getString("path") }
|
val servers = ja.getJSONArray(4).mapJSON { it.getString("path") }
|
||||||
val serversStr = servers.joinToString("|")
|
val serversStr = servers.joinToString("|")
|
||||||
return (0 until pages.length()).map { i ->
|
return (0 until pages.length()).map { i ->
|
||||||
val page = pages.getJSONArray(i)
|
val page = pages.getJSONArray(i)
|
||||||
@@ -230,6 +214,7 @@ internal abstract class GroupleParser(
|
|||||||
id = generateUid(url),
|
id = generateUid(url),
|
||||||
url = "$primaryServer|$serversStr|$url",
|
url = "$primaryServer|$serversStr|$url",
|
||||||
preview = null,
|
preview = null,
|
||||||
|
referer = chapter.url,
|
||||||
source = source,
|
source = source,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -239,46 +224,21 @@ internal abstract class GroupleParser(
|
|||||||
|
|
||||||
override suspend fun getPageUrl(page: MangaPage): String {
|
override suspend fun getPageUrl(page: MangaPage): String {
|
||||||
val parts = page.url.split('|')
|
val parts = page.url.split('|')
|
||||||
if (parts.size < 2) {
|
|
||||||
throw ParseException("No servers found for page", page.url)
|
|
||||||
}
|
|
||||||
val path = parts.last()
|
val path = parts.last()
|
||||||
// fast path
|
val servers = parts.dropLast(1).toSet()
|
||||||
cachedPagesServer?.let { host ->
|
val headers = Headers.headersOf("Referer", page.referer)
|
||||||
val url = concatUrl("https://$host/", path)
|
for (server in servers) {
|
||||||
if (tryHead(url)) {
|
val url = server + path
|
||||||
|
if (tryHead(url, headers)) {
|
||||||
return url
|
return url
|
||||||
} else {
|
|
||||||
cachedPagesServer = null
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// slow path
|
val fallbackServer = servers.firstOrNull() ?: throw ParseException("Cannot find any page url", page.url)
|
||||||
val candidates = HashSet<String>((parts.size - 1) * 2)
|
return fallbackServer + path
|
||||||
for (i in 0 until parts.size - 1) {
|
|
||||||
val server = parts[i].trim().ifEmpty { "https://$domain/" }
|
|
||||||
candidates.add(concatUrl(server, path))
|
|
||||||
candidates.add(concatUrl(server, path.substringBeforeLast('?')))
|
|
||||||
}
|
|
||||||
return try {
|
|
||||||
channelFlow {
|
|
||||||
for (url in candidates) {
|
|
||||||
launch {
|
|
||||||
if (tryHead(url)) {
|
|
||||||
send(url)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}.first().also {
|
|
||||||
cachedPagesServer = it.toHttpUrlOrNull()?.host
|
|
||||||
}
|
|
||||||
} catch (e: NoSuchElementException) {
|
|
||||||
assert(false) { e.toString() }
|
|
||||||
candidates.random()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getTags(): Set<MangaTag> {
|
override suspend fun getTags(): Set<MangaTag> {
|
||||||
val doc = webClient.httpGet("https://${domain}/list/genres/sort_name").parseHtml()
|
val doc = context.httpGet("https://${getDomain()}/list/genres/sort_name", headers).parseHtml()
|
||||||
val root = doc.body().getElementById("mangaBox")?.selectFirst("div.leftContent")
|
val root = doc.body().getElementById("mangaBox")?.selectFirst("div.leftContent")
|
||||||
?.selectFirst("table.table") ?: doc.parseFailed("Cannot find root")
|
?.selectFirst("table.table") ?: doc.parseFailed("Cannot find root")
|
||||||
return root.select("a.element-link").mapToSet { a ->
|
return root.select("a.element-link").mapToSet { a ->
|
||||||
@@ -291,7 +251,7 @@ internal abstract class GroupleParser(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getUsername(): String {
|
override suspend fun getUsername(): String {
|
||||||
val root = webClient.httpGet("https://grouple.co/").parseHtml().body()
|
val root = context.httpGet("https://grouple.co/").parseHtml().body()
|
||||||
val element = root.selectFirst("img.user-avatar") ?: throw AuthRequiredException(source)
|
val element = root.selectFirst("img.user-avatar") ?: throw AuthRequiredException(source)
|
||||||
val res = element.parent()?.text()
|
val res = element.parent()?.text()
|
||||||
return if (res.isNullOrEmpty()) {
|
return if (res.isNullOrEmpty()) {
|
||||||
@@ -299,28 +259,6 @@ internal abstract class GroupleParser(
|
|||||||
} else res
|
} else res
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun intercept(chain: Interceptor.Chain): Response {
|
|
||||||
val request = chain.request()
|
|
||||||
if (!request.header(HEADER_ACCEPT).isNullOrEmpty()) {
|
|
||||||
return chain.proceed(request)
|
|
||||||
}
|
|
||||||
val ext = request.url.pathSegments.lastOrNull()?.substringAfterLast('.', "")?.lowercase(Locale.ROOT)
|
|
||||||
return if (ext == "jpg" || ext == "jpeg" || ext == "png" || ext == "webp") {
|
|
||||||
chain.proceed(
|
|
||||||
request.newBuilder()
|
|
||||||
.header(HEADER_ACCEPT, "image/webp,image/png;q=0.9,image/jpeg,*/*;q=0.8")
|
|
||||||
.build(),
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
chain.proceed(request)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCreateConfig(keys: MutableCollection<ConfigKey<*>>) {
|
|
||||||
super.onCreateConfig(keys)
|
|
||||||
keys.add(userAgentKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getSortKey(sortOrder: SortOrder) =
|
private fun getSortKey(sortOrder: SortOrder) =
|
||||||
when (sortOrder) {
|
when (sortOrder) {
|
||||||
SortOrder.ALPHABETICAL -> "name"
|
SortOrder.ALPHABETICAL -> "name"
|
||||||
@@ -333,7 +271,7 @@ internal abstract class GroupleParser(
|
|||||||
private suspend fun advancedSearch(domain: String, tags: Set<MangaTag>): Response {
|
private suspend fun advancedSearch(domain: String, tags: Set<MangaTag>): Response {
|
||||||
val url = "https://$domain/search/advanced"
|
val url = "https://$domain/search/advanced"
|
||||||
// Step 1: map catalog genres names to advanced-search genres ids
|
// Step 1: map catalog genres names to advanced-search genres ids
|
||||||
val tagsIndex = webClient.httpGet(url).parseHtml()
|
val tagsIndex = context.httpGet(url, headers).parseHtml()
|
||||||
.body().selectFirst("form.search-form")
|
.body().selectFirst("form.search-form")
|
||||||
?.select("div.form-group")
|
?.select("div.form-group")
|
||||||
?.get(1) ?: throw ParseException("Genres filter element not found", url)
|
?.get(1) ?: throw ParseException("Genres filter element not found", url)
|
||||||
@@ -364,19 +302,10 @@ internal abstract class GroupleParser(
|
|||||||
payload["s_sale"] = ""
|
payload["s_sale"] = ""
|
||||||
payload["years"] = "1900,2099"
|
payload["years"] = "1900,2099"
|
||||||
payload["+"] = "Искать".urlEncoded()
|
payload["+"] = "Искать".urlEncoded()
|
||||||
return webClient.httpPost(url, payload)
|
return context.httpPost(url, payload, headers)
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun tryHead(url: String): Boolean = runCatchingCancellable {
|
private suspend fun tryHead(url: String, headers: Headers): Boolean = runCatching {
|
||||||
val response = webClient.httpHead(url)
|
context.httpHead(url, headers).isSuccessful
|
||||||
response.isSuccessful && response.headersContentLength() >= MIN_IMAGE_SIZE
|
|
||||||
}.getOrDefault(false)
|
}.getOrDefault(false)
|
||||||
|
}
|
||||||
private fun Response.checkAuthRequired(): Response {
|
|
||||||
val lastPathSegment = request.url.pathSegments.lastOrNull() ?: return this
|
|
||||||
if (lastPathSegment == "login") {
|
|
||||||
throw AuthRequiredException(source)
|
|
||||||
}
|
|
||||||
return this
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -7,12 +7,15 @@ import org.koitharu.kotatsu.parsers.model.MangaSource
|
|||||||
|
|
||||||
@MangaSourceParser("MINTMANGA", "MintManga", "ru")
|
@MangaSourceParser("MINTMANGA", "MintManga", "ru")
|
||||||
internal class MintMangaParser(
|
internal class MintMangaParser(
|
||||||
context: MangaLoaderContext,
|
override val context: MangaLoaderContext,
|
||||||
) : GroupleParser(context, MangaSource.MINTMANGA, 2) {
|
) : GroupleParser(MangaSource.MINTMANGA, "mintmangafun", 2) {
|
||||||
|
|
||||||
override val configKeyDomain = ConfigKey.Domain(
|
override val configKeyDomain = ConfigKey.Domain(
|
||||||
"mintmanga.live",
|
"mintmanga.live",
|
||||||
arrayOf("mintmanga.live", "mintmanga.com"),
|
arrayOf("mintmanga.live", "mintmanga.com"),
|
||||||
)
|
)
|
||||||
|
|
||||||
}
|
override fun getFaviconUrl(): String {
|
||||||
|
return "https://resmm.rmr.rocks/static/apple-touch-icon-8fff291039c140493adb0c7ba81065ad.png"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,12 +7,15 @@ import org.koitharu.kotatsu.parsers.model.MangaSource
|
|||||||
|
|
||||||
@MangaSourceParser("READMANGA_RU", "ReadManga", "ru")
|
@MangaSourceParser("READMANGA_RU", "ReadManga", "ru")
|
||||||
internal class ReadmangaParser(
|
internal class ReadmangaParser(
|
||||||
context: MangaLoaderContext,
|
override val context: MangaLoaderContext,
|
||||||
) : GroupleParser(context, MangaSource.READMANGA_RU, 1) {
|
) : GroupleParser(MangaSource.READMANGA_RU, "readmangafun", 1) {
|
||||||
|
|
||||||
override val configKeyDomain = ConfigKey.Domain(
|
override val configKeyDomain = ConfigKey.Domain(
|
||||||
"readmanga.live",
|
"readmanga.live",
|
||||||
arrayOf("readmanga.io", "readmanga.live", "readmanga.me"),
|
arrayOf("readmanga.io", "readmanga.live", "readmanga.me"),
|
||||||
)
|
)
|
||||||
|
|
||||||
}
|
override fun getFaviconUrl(): String {
|
||||||
|
return "https://resrm.rmr.rocks/static/apple-touch-icon-3162037c9df9f28dca0f9a4092cb0f65.png"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,9 +7,12 @@ import org.koitharu.kotatsu.parsers.model.MangaSource
|
|||||||
|
|
||||||
@MangaSourceParser("SELFMANGA", "SelfManga", "ru")
|
@MangaSourceParser("SELFMANGA", "SelfManga", "ru")
|
||||||
internal class SelfMangaParser(
|
internal class SelfMangaParser(
|
||||||
context: MangaLoaderContext,
|
override val context: MangaLoaderContext,
|
||||||
) : GroupleParser(context, MangaSource.SELFMANGA, 3) {
|
) : GroupleParser(MangaSource.SELFMANGA, "selfmangafun", 3) {
|
||||||
|
|
||||||
override val configKeyDomain = ConfigKey.Domain("selfmanga.live", null)
|
override val configKeyDomain = ConfigKey.Domain("selfmanga.live", null)
|
||||||
|
|
||||||
}
|
override fun getFaviconUrl(): String {
|
||||||
|
return "https://ressm.rmr.rocks/static/apple-touch-icon-a769ea533d811b73ac3eedde658bb1d3.png"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.parsers.site.madara
|
|
||||||
|
|
||||||
import org.jsoup.nodes.Document
|
|
||||||
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.MangaChapter
|
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaTag
|
|
||||||
import org.koitharu.kotatsu.parsers.util.*
|
|
||||||
import java.text.SimpleDateFormat
|
|
||||||
import java.util.*
|
|
||||||
|
|
||||||
@MangaSourceParser("HENTAI_4FREE", "Hentai4Free", "en")
|
|
||||||
internal class Hentai4Free(context: MangaLoaderContext) :
|
|
||||||
MadaraParser(context, MangaSource.HENTAI_4FREE, "hentai4free.net") {
|
|
||||||
|
|
||||||
override val tagPrefix = "hentai-tag/"
|
|
||||||
|
|
||||||
override val isNsfwSource = true
|
|
||||||
|
|
||||||
override suspend fun getTags(): Set<MangaTag> {
|
|
||||||
val doc = webClient.httpGet("https://$domain/").parseHtml()
|
|
||||||
val body = doc.body()
|
|
||||||
val root1 = body.selectFirst("header")?.selectFirst("ul.second-menu")
|
|
||||||
val list = root1?.select("li").orEmpty()
|
|
||||||
val keySet = HashSet<String>(list.size)
|
|
||||||
return list.mapNotNullToSet { li ->
|
|
||||||
val a = li.selectFirst("a") ?: return@mapNotNullToSet null
|
|
||||||
val href = a.attr("href").removeSuffix("/")
|
|
||||||
.substringAfterLast(tagPrefix, "")
|
|
||||||
if (href.isEmpty() || !keySet.add(href)) {
|
|
||||||
return@mapNotNullToSet null
|
|
||||||
}
|
|
||||||
MangaTag(
|
|
||||||
key = href,
|
|
||||||
title = a.ownText().trim().toTitleCase(),
|
|
||||||
source = source,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun getChapters(manga: Manga, doc: Document): List<MangaChapter> {
|
|
||||||
val slug = manga.url.removeSuffix('/').substringAfterLast('/')
|
|
||||||
val doc2 = webClient.httpPost(
|
|
||||||
"https://$domain/hentai/$slug/ajax/chapters/",
|
|
||||||
mapOf(),
|
|
||||||
).parseHtml()
|
|
||||||
val ul = doc2.body().selectFirstOrThrow("ul")
|
|
||||||
val dateFormat = SimpleDateFormat(datePattern, Locale.US)
|
|
||||||
return ul.select("li").mapChapters(reversed = true) { i, li ->
|
|
||||||
val a = li.selectFirst("a")
|
|
||||||
val href = a?.attrAsRelativeUrlOrNull("href") ?: li.parseFailed("Link is missing")
|
|
||||||
MangaChapter(
|
|
||||||
id = generateUid(href),
|
|
||||||
name = a.ownText(),
|
|
||||||
number = i + 1,
|
|
||||||
url = href,
|
|
||||||
uploadDate = parseChapterDate(
|
|
||||||
dateFormat,
|
|
||||||
li.selectFirst("span.chapter-release-date i")?.text(),
|
|
||||||
),
|
|
||||||
source = source,
|
|
||||||
scanlator = null,
|
|
||||||
branch = null,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.parsers.site.madara
|
|
||||||
|
|
||||||
import org.jsoup.nodes.Document
|
|
||||||
import org.jsoup.nodes.Element
|
|
||||||
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.MangaChapter
|
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaTag
|
|
||||||
import org.koitharu.kotatsu.parsers.util.*
|
|
||||||
import java.text.SimpleDateFormat
|
|
||||||
import java.util.*
|
|
||||||
|
|
||||||
@MangaSourceParser("ISEKAISCAN_EU", "IsekaiScan", "en")
|
|
||||||
internal class IsekaiScanEuParser(context: MangaLoaderContext) :
|
|
||||||
MadaraParser(context, MangaSource.ISEKAISCAN_EU, "isekaiscan.to") {
|
|
||||||
|
|
||||||
override val datePattern = "MM/dd/yyyy"
|
|
||||||
|
|
||||||
override suspend fun getChapters(manga: Manga, doc: Document): List<MangaChapter> {
|
|
||||||
doc.selectFirst("ul.version-chap")?.let {
|
|
||||||
return parseChapters(it)
|
|
||||||
}
|
|
||||||
val mangaId = doc.body().requireElementById("manga-chapters-holder").attr("data-id")
|
|
||||||
val ul = webClient.httpPost(
|
|
||||||
"https://${domain}/wp-admin/admin-ajax.php",
|
|
||||||
mapOf(
|
|
||||||
"action" to "manga_get_chapters",
|
|
||||||
"manga" to mangaId,
|
|
||||||
),
|
|
||||||
).parseHtml().body().selectFirstOrThrow("ul")
|
|
||||||
return parseChapters(ul)
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun getTags(): Set<MangaTag> {
|
|
||||||
val doc = webClient.httpGet("https://$domain/mangax/").parseHtml()
|
|
||||||
val body = doc.body()
|
|
||||||
val root1 = body.selectFirst("header")?.selectFirst("ul.second-menu")
|
|
||||||
val root2 = body.selectFirst("div.genres_wrap")?.selectFirst("ul.list-unstyled")
|
|
||||||
if (root1 == null && root2 == null) {
|
|
||||||
doc.parseFailed("Root not found")
|
|
||||||
}
|
|
||||||
val list = root1?.select("li").orEmpty() + root2?.select("li").orEmpty()
|
|
||||||
val keySet = HashSet<String>(list.size)
|
|
||||||
return list.mapNotNullToSet { li ->
|
|
||||||
val a = li.selectFirst("a") ?: return@mapNotNullToSet null
|
|
||||||
val href = a.attr("href").removeSuffix("/")
|
|
||||||
.substringAfterLast(tagPrefix, "")
|
|
||||||
if (href.isEmpty() || !keySet.add(href)) {
|
|
||||||
return@mapNotNullToSet null
|
|
||||||
}
|
|
||||||
MangaTag(
|
|
||||||
key = href,
|
|
||||||
title = a.ownText().toTitleCase(Locale.ENGLISH),
|
|
||||||
source = source,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun parseChapters(ul: Element): List<MangaChapter> {
|
|
||||||
val dateFormat = SimpleDateFormat(datePattern, Locale.US)
|
|
||||||
return ul.select("li").mapChapters(reversed = true) { i, li ->
|
|
||||||
val a = li.selectFirst("a")
|
|
||||||
val href = a?.attrAsRelativeUrlOrNull("href") ?: li.parseFailed("Link is missing")
|
|
||||||
MangaChapter(
|
|
||||||
id = generateUid(href),
|
|
||||||
name = a.ownText(),
|
|
||||||
number = i + 1,
|
|
||||||
url = href,
|
|
||||||
uploadDate = parseChapterDate(
|
|
||||||
dateFormat,
|
|
||||||
li.selectFirst("span.chapter-release-date i")?.text(),
|
|
||||||
),
|
|
||||||
source = source,
|
|
||||||
scanlator = null,
|
|
||||||
branch = null,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -13,10 +13,10 @@ import java.text.SimpleDateFormat
|
|||||||
import java.util.*
|
import java.util.*
|
||||||
|
|
||||||
abstract class Madara5Parser @InternalParsersApi constructor(
|
abstract class Madara5Parser @InternalParsersApi constructor(
|
||||||
context: MangaLoaderContext,
|
override val context: MangaLoaderContext,
|
||||||
source: MangaSource,
|
source: MangaSource,
|
||||||
domain: String,
|
domain: String,
|
||||||
) : PagedMangaParser(context, source, pageSize = 22) {
|
) : PagedMangaParser(source, pageSize = 22) {
|
||||||
|
|
||||||
protected open val datePattern = "MMMM dd, HH:mm"
|
protected open val datePattern = "MMMM dd, HH:mm"
|
||||||
protected open val tagPrefix = "/mangas/"
|
protected open val tagPrefix = "/mangas/"
|
||||||
@@ -32,7 +32,7 @@ abstract class Madara5Parser @InternalParsersApi constructor(
|
|||||||
tags: Set<MangaTag>?,
|
tags: Set<MangaTag>?,
|
||||||
sortOrder: SortOrder,
|
sortOrder: SortOrder,
|
||||||
): List<Manga> {
|
): List<Manga> {
|
||||||
val domain = domain
|
val domain = getDomain()
|
||||||
val url = buildString {
|
val url = buildString {
|
||||||
append("https://")
|
append("https://")
|
||||||
append(domain)
|
append(domain)
|
||||||
@@ -50,7 +50,7 @@ abstract class Madara5Parser @InternalParsersApi constructor(
|
|||||||
append("&op=1&author=&artist=&page=")
|
append("&op=1&author=&artist=&page=")
|
||||||
append(page)
|
append(page)
|
||||||
}
|
}
|
||||||
val root = webClient.httpGet(url).parseHtml().body().selectFirstOrThrow(".search-wrap")
|
val root = context.httpGet(url).parseHtml().body().selectFirstOrThrow(".search-wrap")
|
||||||
return root.select(".c-tabs-item__content").map { div ->
|
return root.select(".c-tabs-item__content").map { div ->
|
||||||
val a = div.selectFirstOrThrow("a")
|
val a = div.selectFirstOrThrow("a")
|
||||||
val img = div.selectLastOrThrow("img")
|
val img = div.selectLastOrThrow("img")
|
||||||
@@ -82,7 +82,7 @@ abstract class Madara5Parser @InternalParsersApi constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getDetails(manga: Manga): Manga {
|
override suspend fun getDetails(manga: Manga): Manga {
|
||||||
val root = webClient.httpGet(manga.url.toAbsoluteUrl(domain)).parseHtml().body()
|
val root = context.httpGet(manga.url.toAbsoluteUrl(getDomain())).parseHtml().body()
|
||||||
.selectFirstOrThrow(".site-content")
|
.selectFirstOrThrow(".site-content")
|
||||||
val postContent = root.selectFirstOrThrow(".post-content")
|
val postContent = root.selectFirstOrThrow(".post-content")
|
||||||
val tags = postContent.getElementsContainingOwnText("Genre")
|
val tags = postContent.getElementsContainingOwnText("Genre")
|
||||||
@@ -105,13 +105,14 @@ abstract class Madara5Parser @InternalParsersApi constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
|
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
|
||||||
val fullUrl = chapter.url.toAbsoluteUrl(domain)
|
val fullUrl = chapter.url.toAbsoluteUrl(getDomain())
|
||||||
val doc = webClient.httpGet(fullUrl).parseHtml()
|
val doc = context.httpGet(fullUrl).parseHtml()
|
||||||
val arrayData = doc.getElementById("arraydata") ?: doc.parseFailed("#arraydata not found")
|
val arrayData = doc.getElementById("arraydata") ?: doc.parseFailed("#arraydata not found")
|
||||||
return arrayData.html().split(',').map { url ->
|
return arrayData.html().split(',').map { url ->
|
||||||
MangaPage(
|
MangaPage(
|
||||||
id = generateUid(url),
|
id = generateUid(url),
|
||||||
url = url,
|
url = url,
|
||||||
|
referer = fullUrl,
|
||||||
preview = null,
|
preview = null,
|
||||||
source = source,
|
source = source,
|
||||||
)
|
)
|
||||||
@@ -119,15 +120,15 @@ abstract class Madara5Parser @InternalParsersApi constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getTags(): Set<MangaTag> {
|
override suspend fun getTags(): Set<MangaTag> {
|
||||||
val doc = webClient.httpGet("http://${domain}/").parseHtml().body()
|
val doc = context.httpGet("http://${getDomain()}/").parseHtml().body()
|
||||||
return doc.getElementsByAttributeValueContaining("href", tagPrefix)
|
return doc.getElementsByAttributeValueContaining("href", tagPrefix)
|
||||||
.mapToSet { it.asMangaTag() }
|
.mapToSet { it.asMangaTag() }
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun loadChapters(mangaId: Long): List<MangaChapter> {
|
private suspend fun loadChapters(mangaId: Long): List<MangaChapter> {
|
||||||
val dateFormat = SimpleDateFormat(datePattern, sourceLocale)
|
val dateFormat = SimpleDateFormat(datePattern, sourceLocale ?: Locale.US)
|
||||||
val doc = webClient.httpGet("https://${domain}/ajax-list-chapter?mangaID=$mangaId").parseHtml()
|
val doc = context.httpGet("https://${getDomain()}/ajax-list-chapter?mangaID=$mangaId").parseHtml()
|
||||||
return doc.select("li.wp-manga-chapter").mapChapters(reversed = true) { i, li ->
|
return doc.select("li.wp-manga-chapter").asReversed().mapChapters { i, li ->
|
||||||
val a = li.selectFirstOrThrow("a")
|
val a = li.selectFirstOrThrow("a")
|
||||||
val href = a.attrAsRelativeUrl("href")
|
val href = a.attrAsRelativeUrl("href")
|
||||||
MangaChapter(
|
MangaChapter(
|
||||||
@@ -165,7 +166,7 @@ abstract class Madara5Parser @InternalParsersApi constructor(
|
|||||||
parseFailed("Cannot find tableValue for node ${text()}")
|
parseFailed("Cannot find tableValue for node ${text()}")
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun String.asMangaState() = when (trim().lowercase(sourceLocale)) {
|
private fun String.asMangaState() = when (trim().lowercase(sourceLocale ?: Locale.US)) {
|
||||||
"ongoing" -> MangaState.ONGOING
|
"ongoing" -> MangaState.ONGOING
|
||||||
"completed" -> MangaState.FINISHED
|
"completed" -> MangaState.FINISHED
|
||||||
else -> null
|
else -> null
|
||||||
@@ -179,5 +180,8 @@ abstract class Madara5Parser @InternalParsersApi constructor(
|
|||||||
)
|
)
|
||||||
|
|
||||||
@MangaSourceParser("MANGAOWLS", "BeautyManga", "en")
|
@MangaSourceParser("MANGAOWLS", "BeautyManga", "en")
|
||||||
class BeautyManga(context: MangaLoaderContext) : Madara5Parser(context, MangaSource.MANGAOWLS, "beautymanga.com")
|
class BeautyManga(context: MangaLoaderContext) : Madara5Parser(context, MangaSource.MANGAOWLS, "beautymanga.com") {
|
||||||
|
|
||||||
|
override fun getFaviconUrl() = "http://${getDomain()}/frontend/images/favico.png"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
|||||||
import org.koitharu.kotatsu.parsers.model.*
|
import org.koitharu.kotatsu.parsers.model.*
|
||||||
import org.koitharu.kotatsu.parsers.util.*
|
import org.koitharu.kotatsu.parsers.util.*
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.*
|
||||||
|
|
||||||
internal abstract class Madara6Parser(
|
internal abstract class Madara6Parser(
|
||||||
context: MangaLoaderContext,
|
context: MangaLoaderContext,
|
||||||
@@ -19,7 +20,7 @@ internal abstract class Madara6Parser(
|
|||||||
override suspend fun getDetails(manga: Manga): Manga {
|
override suspend fun getDetails(manga: Manga): Manga {
|
||||||
return coroutineScope {
|
return coroutineScope {
|
||||||
val chapters = async { loadChapters(manga.url) }
|
val chapters = async { loadChapters(manga.url) }
|
||||||
val body = webClient.httpGet(manga.url.toAbsoluteUrl(domain)).parseHtml().body()
|
val body = context.httpGet(manga.url.toAbsoluteUrl(getDomain())).parseHtml().body()
|
||||||
parseDetails(manga, body, chapters.await())
|
parseDetails(manga, body, chapters.await())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -44,10 +45,10 @@ internal abstract class Madara6Parser(
|
|||||||
)
|
)
|
||||||
|
|
||||||
protected open suspend fun loadChapters(mangaUrl: String): List<MangaChapter> {
|
protected open suspend fun loadChapters(mangaUrl: String): List<MangaChapter> {
|
||||||
val url = mangaUrl.toAbsoluteUrl(domain).removeSuffix('/') + "/ajax/chapters/"
|
val url = mangaUrl.toAbsoluteUrl(getDomain()).removeSuffix('/') + "/ajax/chapters/"
|
||||||
val dateFormat = SimpleDateFormat(datePattern, sourceLocale)
|
val dateFormat = SimpleDateFormat(datePattern, sourceLocale ?: Locale.ROOT)
|
||||||
val doc = webClient.httpPost(url, emptyMap()).parseHtml()
|
val doc = context.httpPost(url, emptyMap()).parseHtml()
|
||||||
return doc.select("li.wp-manga-chapter").mapChapters(reversed = true) { i, li ->
|
return doc.select("li.wp-manga-chapter").asReversed().mapChapters { i, li ->
|
||||||
val a = li.selectFirstOrThrow("a")
|
val a = li.selectFirstOrThrow("a")
|
||||||
val href = a.attrAsRelativeUrl("href")
|
val href = a.attrAsRelativeUrl("href")
|
||||||
MangaChapter(
|
MangaChapter(
|
||||||
@@ -66,4 +67,4 @@ internal abstract class Madara6Parser(
|
|||||||
}
|
}
|
||||||
|
|
||||||
protected abstract fun parseDetails(manga: Manga, body: Element, chapters: List<MangaChapter>): Manga
|
protected abstract fun parseDetails(manga: Manga, body: Element, chapters: List<MangaChapter>): Manga
|
||||||
}
|
}
|
||||||
@@ -1,8 +1,5 @@
|
|||||||
package org.koitharu.kotatsu.parsers.site.madara
|
package org.koitharu.kotatsu.parsers.site.madara
|
||||||
|
|
||||||
import kotlinx.coroutines.async
|
|
||||||
import kotlinx.coroutines.coroutineScope
|
|
||||||
import org.jsoup.nodes.Document
|
|
||||||
import org.jsoup.nodes.Element
|
import org.jsoup.nodes.Element
|
||||||
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
||||||
import org.koitharu.kotatsu.parsers.MangaSourceParser
|
import org.koitharu.kotatsu.parsers.MangaSourceParser
|
||||||
@@ -16,10 +13,10 @@ import java.text.SimpleDateFormat
|
|||||||
import java.util.*
|
import java.util.*
|
||||||
|
|
||||||
internal abstract class MadaraParser(
|
internal abstract class MadaraParser(
|
||||||
context: MangaLoaderContext,
|
override val context: MangaLoaderContext,
|
||||||
source: MangaSource,
|
source: MangaSource,
|
||||||
domain: String,
|
domain: String,
|
||||||
) : PagedMangaParser(context, source, pageSize = 12) {
|
) : PagedMangaParser(source, pageSize = 12) {
|
||||||
|
|
||||||
override val configKeyDomain = ConfigKey.Domain(domain, null)
|
override val configKeyDomain = ConfigKey.Domain(domain, null)
|
||||||
|
|
||||||
@@ -53,29 +50,27 @@ internal abstract class MadaraParser(
|
|||||||
}
|
}
|
||||||
payload["vars[wp-manga-genre]"] = tag?.key.orEmpty()
|
payload["vars[wp-manga-genre]"] = tag?.key.orEmpty()
|
||||||
payload["vars[s]"] = query?.urlEncoded().orEmpty()
|
payload["vars[s]"] = query?.urlEncoded().orEmpty()
|
||||||
val doc = webClient.httpPost(
|
val doc = context.httpPost(
|
||||||
"https://$domain/wp-admin/admin-ajax.php",
|
"https://${getDomain()}/wp-admin/admin-ajax.php",
|
||||||
payload,
|
payload,
|
||||||
).parseHtml()
|
).parseHtml()
|
||||||
return doc.select("div.row.c-tabs-item__content").ifEmpty {
|
return doc.select("div.row.c-tabs-item__content").map { div ->
|
||||||
doc.select("div.page-item-detail.manga")
|
|
||||||
}.map { div ->
|
|
||||||
val href = div.selectFirst("a")?.attrAsRelativeUrlOrNull("href")
|
val href = div.selectFirst("a")?.attrAsRelativeUrlOrNull("href")
|
||||||
?: div.parseFailed("Link not found")
|
?: div.parseFailed("Link not found")
|
||||||
val summary = div.selectFirst(".tab-summary") ?: div.selectFirst(".item-summary")
|
val summary = div.selectFirst(".tab-summary")
|
||||||
Manga(
|
Manga(
|
||||||
id = generateUid(href),
|
id = generateUid(href),
|
||||||
url = href,
|
url = href,
|
||||||
publicUrl = href.toAbsoluteUrl(div.host ?: domain),
|
publicUrl = href.toAbsoluteUrl(div.host ?: getDomain()),
|
||||||
coverUrl = div.selectFirst("img")?.src().orEmpty(),
|
coverUrl = div.selectFirst("img")?.src().orEmpty(),
|
||||||
title = (summary?.selectFirst("h3") ?: summary?.selectFirst("h4"))?.text().orEmpty(),
|
title = (summary?.selectFirst("h3") ?: summary?.selectFirst("h4"))?.text().orEmpty(),
|
||||||
altTitle = null,
|
altTitle = null,
|
||||||
rating = div.selectFirst("span.total_votes")?.ownText()
|
rating = div.selectFirst("span.total_votes")?.ownText()
|
||||||
?.toFloatOrNull()?.div(5f) ?: -1f,
|
?.toFloatOrNull()?.div(5f) ?: -1f,
|
||||||
tags = summary?.selectFirst(".mg_genres")?.select("a")?.mapNotNullToSet { a ->
|
tags = summary?.selectFirst(".mg_genres")?.select("a")?.mapToSet { a ->
|
||||||
MangaTag(
|
MangaTag(
|
||||||
key = a.attr("href").removeSuffix('/').substringAfterLast('/'),
|
key = a.attr("href").removeSuffix("/").substringAfterLast('/'),
|
||||||
title = a.text().ifEmpty { return@mapNotNullToSet null }.toTitleCase(),
|
title = a.text().toTitleCase(),
|
||||||
source = source,
|
source = source,
|
||||||
)
|
)
|
||||||
}.orEmpty(),
|
}.orEmpty(),
|
||||||
@@ -95,7 +90,7 @@ internal abstract class MadaraParser(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getTags(): Set<MangaTag> {
|
override suspend fun getTags(): Set<MangaTag> {
|
||||||
val doc = webClient.httpGet("https://$domain/manga/").parseHtml()
|
val doc = context.httpGet("https://${getDomain()}/manga/").parseHtml()
|
||||||
val body = doc.body()
|
val body = doc.body()
|
||||||
val root1 = body.selectFirst("header")?.selectFirst("ul.second-menu")
|
val root1 = body.selectFirst("header")?.selectFirst("ul.second-menu")
|
||||||
val root2 = body.selectFirst("div.genres_wrap")?.selectFirst("ul.list-unstyled")
|
val root2 = body.selectFirst("div.genres_wrap")?.selectFirst("ul.list-unstyled")
|
||||||
@@ -121,10 +116,9 @@ internal abstract class MadaraParser(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getDetails(manga: Manga): Manga = coroutineScope {
|
override suspend fun getDetails(manga: Manga): Manga {
|
||||||
val fullUrl = manga.url.toAbsoluteUrl(domain)
|
val fullUrl = manga.url.toAbsoluteUrl(getDomain())
|
||||||
val doc = webClient.httpGet(fullUrl).parseHtml()
|
val doc = context.httpGet(fullUrl).parseHtml()
|
||||||
val chaptersDeferred = async { getChapters(manga, doc) }
|
|
||||||
val root = doc.body().selectFirst("div.profile-manga")
|
val root = doc.body().selectFirst("div.profile-manga")
|
||||||
?.selectFirst("div.summary_content")
|
?.selectFirst("div.summary_content")
|
||||||
?.selectFirst("div.post-content")
|
?.selectFirst("div.post-content")
|
||||||
@@ -132,7 +126,8 @@ internal abstract class MadaraParser(
|
|||||||
val root2 = doc.body().selectFirst("div.content-area")
|
val root2 = doc.body().selectFirst("div.content-area")
|
||||||
?.selectFirst("div.c-page")
|
?.selectFirst("div.c-page")
|
||||||
?: throw ParseException("Root2 not found", fullUrl)
|
?: throw ParseException("Root2 not found", fullUrl)
|
||||||
manga.copy(
|
val dateFormat = SimpleDateFormat(datePattern, Locale.US)
|
||||||
|
return manga.copy(
|
||||||
tags = root.selectFirst("div.genres-content")?.select("a")
|
tags = root.selectFirst("div.genres-content")?.select("a")
|
||||||
?.mapNotNullToSet { a ->
|
?.mapNotNullToSet { a ->
|
||||||
MangaTag(
|
MangaTag(
|
||||||
@@ -146,52 +141,46 @@ internal abstract class MadaraParser(
|
|||||||
?.select("p")
|
?.select("p")
|
||||||
?.filterNot { it.ownText().startsWith("A brief description") }
|
?.filterNot { it.ownText().startsWith("A brief description") }
|
||||||
?.joinToString { it.html() },
|
?.joinToString { it.html() },
|
||||||
chapters = chaptersDeferred.await(),
|
chapters = root2.select("li").asReversed().mapChapters { i, li ->
|
||||||
|
val a = li.selectFirst("a")
|
||||||
|
val href = a?.attrAsRelativeUrlOrNull("href") ?: li.parseFailed("Link is missing")
|
||||||
|
MangaChapter(
|
||||||
|
id = generateUid(href),
|
||||||
|
name = a.ownText(),
|
||||||
|
number = i + 1,
|
||||||
|
url = href,
|
||||||
|
uploadDate = parseChapterDate(
|
||||||
|
dateFormat,
|
||||||
|
li.selectFirst("span.chapter-release-date i")?.text(),
|
||||||
|
),
|
||||||
|
source = source,
|
||||||
|
scanlator = null,
|
||||||
|
branch = null,
|
||||||
|
)
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
protected open suspend fun getChapters(manga: Manga, doc: Document): List<MangaChapter> {
|
|
||||||
val root2 = doc.body().selectFirstOrThrow("div.content-area")
|
|
||||||
.selectFirstOrThrow("div.c-page")
|
|
||||||
val dateFormat = SimpleDateFormat(datePattern, sourceLocale)
|
|
||||||
return root2.select("li").mapChapters(reversed = true) { i, li ->
|
|
||||||
val a = li.selectFirst("a")
|
|
||||||
val href = a?.attrAsRelativeUrlOrNull("href") ?: li.parseFailed("Link is missing")
|
|
||||||
MangaChapter(
|
|
||||||
id = generateUid(href),
|
|
||||||
name = a.ownText(),
|
|
||||||
number = i + 1,
|
|
||||||
url = href,
|
|
||||||
uploadDate = parseChapterDate(
|
|
||||||
dateFormat,
|
|
||||||
li.selectFirst("span.chapter-release-date i")?.text(),
|
|
||||||
),
|
|
||||||
source = source,
|
|
||||||
scanlator = null,
|
|
||||||
branch = null,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
|
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
|
||||||
val fullUrl = chapter.url.toAbsoluteUrl(domain)
|
val fullUrl = chapter.url.toAbsoluteUrl(getDomain())
|
||||||
val doc = webClient.httpGet(fullUrl).parseHtml()
|
val doc = context.httpGet(fullUrl).parseHtml()
|
||||||
val root = doc.body().selectFirst("div.main-col-inner")
|
val root = doc.body().selectFirst("div.main-col-inner")
|
||||||
?.selectFirst("div.reading-content")
|
?.selectFirst("div.reading-content")
|
||||||
?: throw ParseException("Root not found", fullUrl)
|
?: throw ParseException("Root not found", fullUrl)
|
||||||
return root.select("div.page-break").map { div ->
|
return root.select("div.page-break").map { div ->
|
||||||
val img = div.selectFirst("img") ?: div.parseFailed("Page image not found")
|
val img = div.selectFirst("img") ?: div.parseFailed("Page image not found")
|
||||||
val url = img.src()?.toRelativeUrl(domain) ?: div.parseFailed("Image src not found")
|
val url = img.src()?.toRelativeUrl(getDomain()) ?: div.parseFailed("Image src not found")
|
||||||
MangaPage(
|
MangaPage(
|
||||||
id = generateUid(url),
|
id = generateUid(url),
|
||||||
url = url,
|
url = url,
|
||||||
preview = null,
|
preview = null,
|
||||||
|
referer = fullUrl,
|
||||||
source = source,
|
source = source,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected fun parseChapterDate(dateFormat: DateFormat, date: String?): Long {
|
private fun parseChapterDate(dateFormat: DateFormat, date: String?): Long {
|
||||||
date ?: return 0
|
date ?: return 0
|
||||||
return when {
|
return when {
|
||||||
date.endsWith(" ago", ignoreCase = true) -> {
|
date.endsWith(" ago", ignoreCase = true) -> {
|
||||||
@@ -284,61 +273,81 @@ internal abstract class MadaraParser(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected fun Element.src(): String? {
|
private fun Element.src(): String? {
|
||||||
var result = absUrl("data-src")
|
return absUrl("data-src").ifEmpty {
|
||||||
if (result.isEmpty()) result = absUrl("data-cfsrc")
|
absUrl("src")
|
||||||
if (result.isEmpty()) result = absUrl("src")
|
}.takeUnless { it.isEmpty() }
|
||||||
return result.ifEmpty { null }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun createRequestTemplate() =
|
private fun createRequestTemplate() =
|
||||||
(
|
(
|
||||||
"action=madara_load_more&page=1&template=madara-core%2Fcontent%2Fcontent-search&vars%5Bs%5D=&vars%5B" +
|
"action=madara_load_more&page=1&template=madara-core%2Fcontent%2Fcontent-search&vars%5Bs%5D=&vars%5B" +
|
||||||
"orderby%5D=meta_value_num&vars%5Bpaged%5D=1&vars%5Btemplate%5D=search&vars%5Bmeta_query" +
|
"orderby%5D=meta_value_num&vars%5Bpaged%5D=1&vars%5Btemplate%5D=search&vars%5Bmeta_query" +
|
||||||
"%5D%5B0%5D%5Brelation%5D=AND&vars%5Bmeta_query%5D%5Brelation%5D=OR&vars%5Bpost_type" +
|
"%5D%5B0%5D%5Brelation%5D=AND&vars%5Bmeta_query%5D%5Brelation%5D=OR&vars%5Bpost_type" +
|
||||||
"%5D=wp-manga&vars%5Bpost_status%5D=publish&vars%5Bmeta_key%5D=_latest_update&vars%5Border" +
|
"%5D=wp-manga&vars%5Bpost_status%5D=publish&vars%5Bmeta_key%5D=_latest_update&vars%5Border" +
|
||||||
"%5D=desc&vars%5Bmanga_archives_item_layout%5D=default"
|
"%5D=desc&vars%5Bmanga_archives_item_layout%5D=default"
|
||||||
).split('&')
|
).split('&')
|
||||||
.map {
|
.map {
|
||||||
val pos = it.indexOf('=')
|
val pos = it.indexOf('=')
|
||||||
it.substring(0, pos) to it.substring(pos + 1)
|
it.substring(0, pos) to it.substring(pos + 1)
|
||||||
}.toMutableMap()
|
}.toMutableMap()
|
||||||
|
|
||||||
|
@MangaSourceParser("MANGAREAD", "MangaRead", "en")
|
||||||
|
class MangaRead(context: MangaLoaderContext) : MadaraParser(context, MangaSource.MANGAREAD, "www.mangaread.org") {
|
||||||
|
override val tagPrefix = "genres/"
|
||||||
|
}
|
||||||
|
|
||||||
@MangaSourceParser("MANGAWEEBS", "MangaWeebs", "en")
|
@MangaSourceParser("MANGAWEEBS", "MangaWeebs", "en")
|
||||||
class MangaWeebs(context: MangaLoaderContext) : MadaraParser(context, MangaSource.MANGAWEEBS, "mangaweebs.in") {
|
class MangaWeebs(context: MangaLoaderContext) : MadaraParser(context, MangaSource.MANGAWEEBS, "mangaweebs.in")
|
||||||
override val datePattern = "dd MMMM HH:mm"
|
|
||||||
|
@MangaSourceParser("KINGMANGA", "KingManga", "en")
|
||||||
|
class KingManga(context: MangaLoaderContext) : MadaraParser(context, MangaSource.KINGMANGA, "king-manga.com") {
|
||||||
|
|
||||||
|
override fun getFaviconUrl(): String {
|
||||||
|
return "https://${getDomain()}/wp-content/uploads/2022/05/cropped-logo-king-02-03-32x32.png"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@MangaSourceParser("HACHIMANGA", "HachiManga", "ja")
|
@MangaSourceParser("MANGAHATACHI", "MangahaTachi", "ja")
|
||||||
class HachiManga(context: MangaLoaderContext) : MadaraParser(context, MangaSource.HACHIMANGA, "hachiraw.com") {
|
class MangahaTachi(context: MangaLoaderContext) :
|
||||||
|
MadaraParser(context, MangaSource.MANGAHATACHI, "mangahatachi.com")
|
||||||
override val sourceLocale: Locale = Locale.ENGLISH
|
|
||||||
}
|
|
||||||
|
|
||||||
@MangaSourceParser("PIANMANGA", "PianManga", "en")
|
@MangaSourceParser("PIANMANGA", "PianManga", "en")
|
||||||
class PianManga(context: MangaLoaderContext) : MadaraParser(context, MangaSource.PIANMANGA, "pianmanga.me")
|
class PianManga(context: MangaLoaderContext) : MadaraParser(context, MangaSource.PIANMANGA, "pianmanga.com")
|
||||||
|
|
||||||
@MangaSourceParser("MANGAROSIE", "MangaRosie", "en")
|
@MangaSourceParser("MANGAROSIE", "MangaRosie", "en")
|
||||||
class MangaRosie(context: MangaLoaderContext) : MadaraParser(context, MangaSource.MANGAROSIE, "mangarosie.in")
|
class MangaRosie(context: MangaLoaderContext) : MadaraParser(context, MangaSource.MANGAROSIE, "mangarosie.me")
|
||||||
|
|
||||||
|
@MangaSourceParser("READMANWHA", "ReadManwha", "en")
|
||||||
|
class ReadManwha(context: MangaLoaderContext) : MadaraParser(context, MangaSource.READMANWHA, "readmanwha.net")
|
||||||
|
|
||||||
@MangaSourceParser("MANGATX", "MangaTx", "en")
|
@MangaSourceParser("MANGATX", "MangaTx", "en")
|
||||||
class MangaTx(context: MangaLoaderContext) : MadaraParser(context, MangaSource.MANGATX, "mangatx.com")
|
class MangaTx(context: MangaLoaderContext) : MadaraParser(context, MangaSource.MANGATX, "mangatx.com")
|
||||||
|
|
||||||
@MangaSourceParser("MANGAEFFECT", "MangaEffect", "en")
|
@MangaSourceParser("MANGAEFFECT", "MangaEffect", "en")
|
||||||
class MangaEffect(context: MangaLoaderContext) : MadaraParser(context, MangaSource.MANGAEFFECT, "mangaeffect.com") {
|
class MangaEffect(context: MangaLoaderContext) : MadaraParser(context, MangaSource.MANGAEFFECT, "mangaeffect.com")
|
||||||
override val datePattern = "dd.MM.yyyy"
|
|
||||||
}
|
|
||||||
|
|
||||||
@MangaSourceParser("AQUAMANGA", "AquaManga", "en")
|
@MangaSourceParser("AQUAMANGA", "AquaManga", "en")
|
||||||
class AquaManga(context: MangaLoaderContext) : MadaraParser(context, MangaSource.AQUAMANGA, "aquamanga.com")
|
class AquaManga(context: MangaLoaderContext) : MadaraParser(context, MangaSource.AQUAMANGA, "aquamanga.com") {
|
||||||
|
|
||||||
|
override fun getFaviconUrl(): String {
|
||||||
|
return "https://${getDomain()}/wp-content/uploads/2021/03/cropped-cropped-favicon-1-32x32.png"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MangaSourceParser("MANGATX_OT", "MangaTx (ot)", "en")
|
||||||
|
class MangaTxOt(context: MangaLoaderContext) : MadaraParser(context, MangaSource.MANGATX_OT, "manga-tx.com")
|
||||||
|
|
||||||
@MangaSourceParser("MANGALEK", "MangaLek", "ar")
|
@MangaSourceParser("MANGALEK", "MangaLek", "ar")
|
||||||
class MangaLek(context: MangaLoaderContext) : MadaraParser(context, MangaSource.MANGALEK, "mangalek.com")
|
class MangaLek(context: MangaLoaderContext) : MadaraParser(context, MangaSource.MANGALEK, "mangalek.com") {
|
||||||
|
|
||||||
|
override fun getFaviconUrl(): String {
|
||||||
|
return "https://${getDomain()}/wp-content/logo192.png"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@MangaSourceParser("HARIMANGA", "HariManga", "en")
|
@MangaSourceParser("HARIMANGA", "HariManga", "en")
|
||||||
class HariManga(context: MangaLoaderContext) : MadaraParser(context, MangaSource.HARIMANGA, "harimanga.com") {
|
class HariManga(context: MangaLoaderContext) : MadaraParser(context, MangaSource.HARIMANGA, "harimanga.com")
|
||||||
override val datePattern = "MM/dd/yyyy"
|
|
||||||
}
|
|
||||||
|
|
||||||
@MangaSourceParser("KISSMANGA", "KissManga", "en")
|
@MangaSourceParser("KISSMANGA", "KissManga", "en")
|
||||||
class KissManga(context: MangaLoaderContext) : MadaraParser(context, MangaSource.KISSMANGA, "kissmanga.in")
|
class KissManga(context: MangaLoaderContext) : MadaraParser(context, MangaSource.KISSMANGA, "kissmanga.in")
|
||||||
@@ -346,56 +355,111 @@ internal abstract class MadaraParser(
|
|||||||
@MangaSourceParser("MANGAROCK", "MangaRock", "en")
|
@MangaSourceParser("MANGAROCK", "MangaRock", "en")
|
||||||
class MangaRock(context: MangaLoaderContext) : MadaraParser(context, MangaSource.MANGAROCK, "mangarockteam.com")
|
class MangaRock(context: MangaLoaderContext) : MadaraParser(context, MangaSource.MANGAROCK, "mangarockteam.com")
|
||||||
|
|
||||||
|
@MangaSourceParser("ISEKAISCAN_EU", "IsekaiScan (eu)", "en")
|
||||||
|
class IsekaiScanEu(context: MangaLoaderContext) : MadaraParser(context, MangaSource.ISEKAISCAN_EU, "isekaiscan.eu")
|
||||||
|
|
||||||
@MangaSourceParser("FREEMANGA", "FreeManga", "en")
|
@MangaSourceParser("FREEMANGA", "FreeManga", "en")
|
||||||
class FreeManga(context: MangaLoaderContext) : MadaraParser(context, MangaSource.FREEMANGA, "freemanga.me")
|
class FreeManga(context: MangaLoaderContext) : MadaraParser(context, MangaSource.FREEMANGA, "freemanga.me")
|
||||||
|
|
||||||
|
@MangaSourceParser("ISEKAISCAN", "IsekaiScan", "en")
|
||||||
|
class IsekaiScan(context: MangaLoaderContext) : MadaraParser(context, MangaSource.ISEKAISCAN, "isekaiscan.com") {
|
||||||
|
|
||||||
|
override fun getFaviconUrl(): String {
|
||||||
|
return "https://${getDomain()}/wp-content/uploads/2021/10/isekai-scan-02-01-150x150.png"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@MangaSourceParser("MANGA_KOMI", "MangaKomi", "en")
|
@MangaSourceParser("MANGA_KOMI", "MangaKomi", "en")
|
||||||
class MangaKomi(context: MangaLoaderContext) : MadaraParser(context, MangaSource.MANGA_KOMI, "mangakomi.io")
|
class MangaKomi(context: MangaLoaderContext) : MadaraParser(context, MangaSource.MANGA_KOMI, "mangakomi.io")
|
||||||
|
|
||||||
|
@MangaSourceParser("READMANHWA", "ReadManhwa", "en")
|
||||||
|
class ReadManhwa(context: MangaLoaderContext) : MadaraParser(context, MangaSource.READMANHWA, "readmanhwa.info")
|
||||||
|
|
||||||
|
@MangaSourceParser("NEATMANGA", "NeatManga", "en")
|
||||||
|
class NeatManga(context: MangaLoaderContext) : MadaraParser(context, MangaSource.NEATMANGA, "neatmanga.com")
|
||||||
|
|
||||||
|
@MangaSourceParser("MANGAKIK", "MangaKik", "en")
|
||||||
|
class MangaKik(context: MangaLoaderContext) : MadaraParser(context, MangaSource.MANGAKIK, "mangakik.net")
|
||||||
|
|
||||||
@MangaSourceParser("MANHWACLAN", "ManhwaClan", "en")
|
@MangaSourceParser("MANHWACLAN", "ManhwaClan", "en")
|
||||||
class ManhwaClan(context: MangaLoaderContext) : MadaraParser(context, MangaSource.MANHWACLAN, "manhwaclan.com")
|
class ManhwaClan(context: MangaLoaderContext) : MadaraParser(context, MangaSource.MANHWACLAN, "manhwaclan.com")
|
||||||
|
|
||||||
@MangaSourceParser("MANGA_3S", "Manga3s", "en")
|
@MangaSourceParser("MANGA_3S", "Manga3s", "en")
|
||||||
class Manga3s(context: MangaLoaderContext) : MadaraParser(context, MangaSource.MANGA_3S, "manga3s.com") {
|
class Manga3s(context: MangaLoaderContext) : MadaraParser(context, MangaSource.MANGA_3S, "manga3s.com")
|
||||||
override val tagPrefix = "manhwa-genre/"
|
|
||||||
}
|
|
||||||
|
|
||||||
@MangaSourceParser("MANHWAKOOL", "Manhwa Kool", "en")
|
@MangaSourceParser("MANHWAKOOL", "Manhwa Kool", "en")
|
||||||
class ManhwaKool(context: MangaLoaderContext) : MadaraParser(context, MangaSource.MANHWAKOOL, "manhwakool.com") {
|
class ManhwaKool(context: MangaLoaderContext) : MadaraParser(context, MangaSource.MANHWAKOOL, "manhwakool.com") {
|
||||||
override val datePattern: String = "MM/dd"
|
override fun getFaviconUrl(): String {
|
||||||
|
return "https://${getDomain()}/wp-content/uploads/2021/10/cropped-logo-kool-32x32.jpeg"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@MangaSourceParser("TOPMANHUA", "Top Manhua", "en")
|
@MangaSourceParser("TOPMANHUA", "Top Manhua", "en")
|
||||||
class TopManhua(context: MangaLoaderContext) : MadaraParser(context, MangaSource.TOPMANHUA, "www.topmanhua.com") {
|
class TopManhua(context: MangaLoaderContext) : MadaraParser(context, MangaSource.TOPMANHUA, "www.topmanhua.com") {
|
||||||
override val tagPrefix = "manhua-genre/"
|
override val tagPrefix = "manhua-genre/"
|
||||||
override val datePattern = "MM/dd/yyyy"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MangaSourceParser("X2MANGA", "X2Manga", "en")
|
||||||
|
class X2Manga(context: MangaLoaderContext) : MadaraParser(context, MangaSource.X2MANGA, "x2manga.com")
|
||||||
|
|
||||||
|
@MangaSourceParser("VINLOAD", "VinLoad", "en")
|
||||||
|
class VinLoad(context: MangaLoaderContext) : MadaraParser(context, MangaSource.VINLOAD, "vinload.com")
|
||||||
|
|
||||||
@MangaSourceParser("S2MANGA", "S2Manga", "en")
|
@MangaSourceParser("S2MANGA", "S2Manga", "en")
|
||||||
class S2Manga(context: MangaLoaderContext) : MadaraParser(context, MangaSource.S2MANGA, "s2manga.com")
|
class S2Manga(context: MangaLoaderContext) : MadaraParser(context, MangaSource.S2MANGA, "s2manga.com")
|
||||||
|
|
||||||
|
@MangaSourceParser("MANHWA", "Manhwa", "en")
|
||||||
|
class Manhwa(context: MangaLoaderContext) : MadaraParser(context, MangaSource.MANHWA, "manhwa.pics")
|
||||||
|
|
||||||
@MangaSourceParser("SKY_MANGA", "Sky Manga", "en")
|
@MangaSourceParser("SKY_MANGA", "Sky Manga", "en")
|
||||||
class SkyManga(context: MangaLoaderContext) : MadaraParser(context, MangaSource.SKY_MANGA, "skymanga.xyz") {
|
class SkyManga(context: MangaLoaderContext) : MadaraParser(context, MangaSource.SKY_MANGA, "skymanga.xyz") {
|
||||||
|
|
||||||
override val isNsfwSource = true
|
override val isNsfwSource = true
|
||||||
|
|
||||||
|
override fun getFaviconUrl(): String {
|
||||||
|
return "https://${getDomain()}/wp-content/uploads/cropped-sky-tv-1-32x32.png"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@MangaSourceParser("BAKAMAN", "BakaMan", "th")
|
@MangaSourceParser("MANGA_DISTRICT", "Manga District", "en")
|
||||||
class BakaMan(context: MangaLoaderContext) : MadaraParser(context, MangaSource.BAKAMAN, "bakaman.net") {
|
class MangaDistrict(context: MangaLoaderContext) :
|
||||||
|
MadaraParser(context, MangaSource.MANGA_DISTRICT, "mangadistrict.com") {
|
||||||
|
|
||||||
override val isNsfwSource = false
|
override val tagPrefix = "publication-genre/"
|
||||||
}
|
|
||||||
|
|
||||||
@MangaSourceParser("HENTAI20", "Hentai20", "en")
|
|
||||||
class Hentai20(context: MangaLoaderContext) :
|
|
||||||
MadaraParser(context, MangaSource.HENTAI20, "hentai20.io") {
|
|
||||||
|
|
||||||
override val tagPrefix = "manga-genre/"
|
|
||||||
|
|
||||||
override val isNsfwSource = true
|
override val isNsfwSource = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MangaSourceParser("HENTAI_4FREE", "Hentai4Free", "en")
|
||||||
|
class Hentai4Free(context: MangaLoaderContext) :
|
||||||
|
MadaraParser(context, MangaSource.HENTAI_4FREE, "hentai4free.net") {
|
||||||
|
|
||||||
|
override val tagPrefix = "hentai-tag/"
|
||||||
|
|
||||||
|
override val isNsfwSource = true
|
||||||
|
|
||||||
|
override suspend fun getTags(): Set<MangaTag> {
|
||||||
|
val doc = context.httpGet("https://${getDomain()}/").parseHtml()
|
||||||
|
val body = doc.body()
|
||||||
|
val root1 = body.selectFirst("header")?.selectFirst("ul.second-menu")
|
||||||
|
val list = root1?.select("li").orEmpty()
|
||||||
|
val keySet = HashSet<String>(list.size)
|
||||||
|
return list.mapNotNullToSet { li ->
|
||||||
|
val a = li.selectFirst("a") ?: return@mapNotNullToSet null
|
||||||
|
val href = a.attr("href").removeSuffix("/")
|
||||||
|
.substringAfterLast(tagPrefix, "")
|
||||||
|
if (href.isEmpty() || !keySet.add(href)) {
|
||||||
|
return@mapNotNullToSet null
|
||||||
|
}
|
||||||
|
MangaTag(
|
||||||
|
key = href,
|
||||||
|
title = a.ownText().trim().toTitleCase(),
|
||||||
|
source = source,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@MangaSourceParser("ALLPORN_COMIC", "All Porn Comic", "en")
|
@MangaSourceParser("ALLPORN_COMIC", "All Porn Comic", "en")
|
||||||
class AllPornComic(context: MangaLoaderContext) :
|
class AllPornComic(context: MangaLoaderContext) :
|
||||||
MadaraParser(context, MangaSource.ALLPORN_COMIC, "allporncomic.com") {
|
MadaraParser(context, MangaSource.ALLPORN_COMIC, "allporncomic.com") {
|
||||||
@@ -404,6 +468,9 @@ internal abstract class MadaraParser(
|
|||||||
|
|
||||||
override val isNsfwSource = true
|
override val isNsfwSource = true
|
||||||
|
|
||||||
|
override fun getFaviconUrl(): String {
|
||||||
|
return "https://cdn.${getDomain()}/wp-content/uploads/2019/01/cropped-cropped-pcround-32x32.png"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@MangaSourceParser("CAT_300", "Cat300", "th")
|
@MangaSourceParser("CAT_300", "Cat300", "th")
|
||||||
@@ -415,25 +482,34 @@ internal abstract class MadaraParser(
|
|||||||
@MangaSourceParser("BIBIMANGA", "BibiManga", "en")
|
@MangaSourceParser("BIBIMANGA", "BibiManga", "en")
|
||||||
class BibiManga(context: MangaLoaderContext) : MadaraParser(context, MangaSource.BIBIMANGA, "bibimanga.com") {
|
class BibiManga(context: MangaLoaderContext) : MadaraParser(context, MangaSource.BIBIMANGA, "bibimanga.com") {
|
||||||
|
|
||||||
override val isNsfwSource = false
|
override val isNsfwSource = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MangaSourceParser("MANHWA_CHILL", "Manhwa Chill", "en")
|
||||||
|
class ManhwaChill(context: MangaLoaderContext) : MadaraParser(context, MangaSource.MANHWA_CHILL, "manhwachill.me")
|
||||||
|
|
||||||
@MangaSourceParser("TREE_MANGA", "Tree Manga", "en")
|
@MangaSourceParser("TREE_MANGA", "Tree Manga", "en")
|
||||||
class TreeManga(context: MangaLoaderContext) : MadaraParser(context, MangaSource.TREE_MANGA, "treemanga.com") {
|
class TreeManga(context: MangaLoaderContext) : MadaraParser(context, MangaSource.TREE_MANGA, "treemanga.com") {
|
||||||
|
|
||||||
override val datePattern = "MM/dd/yyyy"
|
override fun getFaviconUrl(): String {
|
||||||
|
return "https://${getDomain()}/wp-content/uploads/2017/10/lgoo-treemanga-2-1.jpg"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MangaSourceParser("ALLTOPMANGA", "All Top Manga", "en")
|
||||||
|
class AllTopManga(context: MangaLoaderContext) : MadaraParser(context, MangaSource.ALLTOPMANGA, "alltopmanga.com") {
|
||||||
|
|
||||||
|
override fun getFaviconUrl(): String {
|
||||||
|
return "https://${getDomain()}/wp-content/uploads/2021/12/cropped-Screenshot_4-removebg-preview-32x32.png"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@MangaSourceParser("MANGACV", "Manga Cv", "en")
|
@MangaSourceParser("MANGACV", "Manga Cv", "en")
|
||||||
class MangaCv(context: MangaLoaderContext) : MadaraParser(context, MangaSource.MANGACV, "mangacv.com")
|
class MangaCv(context: MangaLoaderContext) : MadaraParser(context, MangaSource.MANGACV, "mangacv.com") {
|
||||||
|
|
||||||
@MangaSourceParser("TOONILY", "Toonily", "en")
|
override fun getFaviconUrl(): String {
|
||||||
class Toonily(context: MangaLoaderContext) : MadaraParser(context, MangaSource.TOONILY, "toonily.com") {
|
return "https://${getDomain()}/wp-content/uploads/2020/10/cropped-mangaCV-32x32.png"
|
||||||
|
}
|
||||||
override val tagPrefix = "webtoon-genre/"
|
|
||||||
|
|
||||||
override val isNsfwSource = false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@MangaSourceParser("MANGA_MANHUA", "Manga Manhua", "en")
|
@MangaSourceParser("MANGA_MANHUA", "Manga Manhua", "en")
|
||||||
@@ -449,10 +525,8 @@ internal abstract class MadaraParser(
|
|||||||
class Manga365(context: MangaLoaderContext) : MadaraParser(context, MangaSource.MANGA_365, "365manga.com")
|
class Manga365(context: MangaLoaderContext) : MadaraParser(context, MangaSource.MANGA_365, "365manga.com")
|
||||||
|
|
||||||
@MangaSourceParser("MANGACLASH", "Mangaclash", "en")
|
@MangaSourceParser("MANGACLASH", "Mangaclash", "en")
|
||||||
class Mangaclash(context: MangaLoaderContext) : MadaraParser(context, MangaSource.MANGACLASH, "mangaclash.com") {
|
class Mangaclash(context: MangaLoaderContext) : MadaraParser(context, MangaSource.MANGACLASH, "mangaclash.com")
|
||||||
override val datePattern = "MM/dd/yyyy"
|
|
||||||
}
|
|
||||||
|
|
||||||
@MangaSourceParser("ZINMANGA", "ZINMANGA", "en")
|
@MangaSourceParser("ZINMANGA", "ZINMANGA", "en")
|
||||||
class Zinmanga(context: MangaLoaderContext) : MadaraParser(context, MangaSource.ZINMANGA, "zinmanga.com")
|
class Zinmanga(context: MangaLoaderContext) : MadaraParser(context, MangaSource.ZINMANGA, "zinmanga.com")
|
||||||
}
|
}
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.parsers.site.madara
|
|
||||||
|
|
||||||
import org.jsoup.nodes.Document
|
|
||||||
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.MangaChapter
|
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
|
||||||
import org.koitharu.kotatsu.parsers.util.*
|
|
||||||
import java.text.SimpleDateFormat
|
|
||||||
import java.util.*
|
|
||||||
|
|
||||||
@MangaSourceParser("MANGA_DISTRICT", "Manga District", "en")
|
|
||||||
internal class MangaDistrict(context: MangaLoaderContext) :
|
|
||||||
MadaraParser(context, MangaSource.MANGA_DISTRICT, "mangadistrict.com") {
|
|
||||||
|
|
||||||
override val tagPrefix = "publication-genre/"
|
|
||||||
|
|
||||||
override val isNsfwSource = true
|
|
||||||
|
|
||||||
override val datePattern = "MMM dd,yyyy"
|
|
||||||
|
|
||||||
override suspend fun getChapters(manga: Manga, doc: Document): List<MangaChapter> {
|
|
||||||
val slug = manga.url.removeSuffix('/').substringAfterLast('/')
|
|
||||||
val doc2 = webClient.httpPost(
|
|
||||||
"https://$domain/read-scan/$slug/ajax/chapters/",
|
|
||||||
mapOf(),
|
|
||||||
).parseHtml()
|
|
||||||
val ul = doc2.body().selectFirstOrThrow("ul")
|
|
||||||
val dateFormat = SimpleDateFormat(datePattern, Locale.US)
|
|
||||||
return ul.select("li").mapChapters(reversed = true) { i, li ->
|
|
||||||
val a = li.selectFirst("a")
|
|
||||||
val href = a?.attrAsRelativeUrlOrNull("href") ?: li.parseFailed("Link is missing")
|
|
||||||
MangaChapter(
|
|
||||||
id = generateUid(href),
|
|
||||||
name = a.ownText(),
|
|
||||||
number = i + 1,
|
|
||||||
url = href,
|
|
||||||
uploadDate = parseChapterDate(
|
|
||||||
dateFormat,
|
|
||||||
li.selectFirst("span.chapter-release-date i")?.text(),
|
|
||||||
),
|
|
||||||
source = source,
|
|
||||||
scanlator = null,
|
|
||||||
branch = null,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,120 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.parsers.site.madara
|
|
||||||
|
|
||||||
import androidx.collection.arraySetOf
|
|
||||||
import org.jsoup.nodes.Element
|
|
||||||
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
|
||||||
import org.koitharu.kotatsu.parsers.MangaSourceParser
|
|
||||||
import org.koitharu.kotatsu.parsers.model.*
|
|
||||||
import org.koitharu.kotatsu.parsers.util.*
|
|
||||||
import java.util.*
|
|
||||||
|
|
||||||
@MangaSourceParser("MANGAREAD", "MangaRead", "en")
|
|
||||||
internal class MangaRead(context: MangaLoaderContext) :
|
|
||||||
MadaraParser(context, MangaSource.MANGAREAD, "www.mangaread.org") {
|
|
||||||
|
|
||||||
override val tagPrefix = "genres/"
|
|
||||||
override val datePattern = "dd.MM.yyyy"
|
|
||||||
private val nsfwTags = arraySetOf("yaoi", "yuri", "mature")
|
|
||||||
|
|
||||||
init {
|
|
||||||
paginator.firstPage = 1
|
|
||||||
searchPaginator.firstPage = 1
|
|
||||||
}
|
|
||||||
|
|
||||||
override val sortOrders: Set<SortOrder> = EnumSet.of(
|
|
||||||
SortOrder.UPDATED,
|
|
||||||
SortOrder.POPULARITY,
|
|
||||||
SortOrder.ALPHABETICAL,
|
|
||||||
SortOrder.RATING,
|
|
||||||
SortOrder.POPULARITY,
|
|
||||||
)
|
|
||||||
|
|
||||||
override suspend fun getListPage(
|
|
||||||
page: Int,
|
|
||||||
query: String?,
|
|
||||||
tags: Set<MangaTag>?,
|
|
||||||
sortOrder: SortOrder,
|
|
||||||
): List<Manga> {
|
|
||||||
val url = urlBuilder()
|
|
||||||
.addPathSegment("page")
|
|
||||||
.addPathSegment(page.toString())
|
|
||||||
.addQueryParameter("s", query.orEmpty())
|
|
||||||
.addQueryParameter("post_type", "wp-manga")
|
|
||||||
tags?.forEachIndexed { index, tag ->
|
|
||||||
url.addQueryParameter("genre[$index]", tag.key)
|
|
||||||
}
|
|
||||||
url.addQueryParameter("op", "")
|
|
||||||
.addQueryParameter("author", "")
|
|
||||||
.addQueryParameter("artist", "")
|
|
||||||
.addQueryParameter("release", "")
|
|
||||||
.addQueryParameter("adult", "")
|
|
||||||
if (query.isNullOrEmpty()) {
|
|
||||||
url.addQueryParameter(
|
|
||||||
"&m_orderby=",
|
|
||||||
when (sortOrder) {
|
|
||||||
SortOrder.RATING -> "trending"
|
|
||||||
SortOrder.ALPHABETICAL -> "alphabet"
|
|
||||||
SortOrder.POPULARITY -> "views"
|
|
||||||
SortOrder.NEWEST -> "new-manga"
|
|
||||||
SortOrder.UPDATED -> "latest"
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
val root = webClient.httpGet(url.build()).parseHtml().body().selectFirstOrThrow(".search-wrap")
|
|
||||||
return root.select(".c-tabs-item__content").map { div ->
|
|
||||||
val a = div.selectFirstOrThrow("a")
|
|
||||||
val img = div.selectLastOrThrow("img")
|
|
||||||
val href = a.attrAsRelativeUrl("href")
|
|
||||||
val postContent = root.selectFirstOrThrow(".post-content")
|
|
||||||
val tagSet = postContent.getElementsContainingOwnText("Genre")
|
|
||||||
.firstOrNull()?.tableValue()
|
|
||||||
?.getElementsByAttributeValueContaining("href", tagPrefix)
|
|
||||||
?.mapToSet { it.asMangaTag() }.orEmpty()
|
|
||||||
Manga(
|
|
||||||
id = generateUid(href),
|
|
||||||
title = a.attr("title"),
|
|
||||||
altTitle = postContent.getElementsContainingOwnText("Alternative")
|
|
||||||
.firstOrNull()?.tableValue()?.text()?.trim(),
|
|
||||||
url = href,
|
|
||||||
publicUrl = a.attrAsAbsoluteUrl("href"),
|
|
||||||
coverUrl = img.src().orEmpty(),
|
|
||||||
author = postContent.getElementsContainingOwnText("Author")
|
|
||||||
.firstOrNull()?.tableValue()?.text()?.trim(),
|
|
||||||
state = postContent.getElementsContainingOwnText("Status")
|
|
||||||
.firstOrNull()?.tableValue()?.text()?.asMangaState(),
|
|
||||||
isNsfw = isNsfw(tagSet),
|
|
||||||
rating = div.selectFirstOrThrow(".score").text()
|
|
||||||
.toFloatOrNull()?.div(5f) ?: RATING_UNKNOWN,
|
|
||||||
tags = tagSet,
|
|
||||||
source = source,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun Element.tableValue(): Element {
|
|
||||||
for (p in parents()) {
|
|
||||||
val children = p.children()
|
|
||||||
if (children.size == 2) {
|
|
||||||
return children[1]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
parseFailed("Cannot find tableValue for node ${text()}")
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun isNsfw(tags: Set<MangaTag>): Boolean {
|
|
||||||
return tags.any { it.key in nsfwTags }
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun String.asMangaState() = when (trim().lowercase(sourceLocale)) {
|
|
||||||
"ongoing" -> MangaState.ONGOING
|
|
||||||
"completed" -> MangaState.FINISHED
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun Element.asMangaTag() = MangaTag(
|
|
||||||
title = ownText(),
|
|
||||||
key = attr("href").removeSuffix('/').substringAfterLast('/')
|
|
||||||
.replace('-', '+'),
|
|
||||||
source = source,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.parsers.site.madara
|
|
||||||
|
|
||||||
import kotlinx.coroutines.async
|
|
||||||
import kotlinx.coroutines.coroutineScope
|
|
||||||
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
|
||||||
import org.koitharu.kotatsu.parsers.MangaSourceParser
|
|
||||||
import org.koitharu.kotatsu.parsers.exception.ParseException
|
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaTag
|
|
||||||
import org.koitharu.kotatsu.parsers.util.*
|
|
||||||
|
|
||||||
@MangaSourceParser("MANGALINK_AR", "Mangalink", "ar")
|
|
||||||
internal class MangalinkParser(context: MangaLoaderContext) :
|
|
||||||
MadaraParser(context, MangaSource.MANGALINK_AR, "mangalink.online") {
|
|
||||||
|
|
||||||
override suspend fun getDetails(manga: Manga): Manga = coroutineScope {
|
|
||||||
val fullUrl = manga.url.toAbsoluteUrl(domain)
|
|
||||||
val doc = webClient.httpGet(fullUrl).parseHtml()
|
|
||||||
val chaptersDeferred = async { getChapters(manga, doc) }
|
|
||||||
val root = doc.body().selectFirst("div.profile-manga")
|
|
||||||
?.selectFirst("div.summary_content")
|
|
||||||
?.selectFirst("div.post-content")
|
|
||||||
?: throw ParseException("Root not found", fullUrl)
|
|
||||||
val root2 = doc.body().selectFirst("div.content-area")
|
|
||||||
?.selectFirst("div.c-page")
|
|
||||||
?: throw ParseException("Root2 not found", fullUrl)
|
|
||||||
manga.copy(
|
|
||||||
tags = root.selectFirst("div.genres-content")?.select("a")
|
|
||||||
?.mapNotNullToSet { a ->
|
|
||||||
MangaTag(
|
|
||||||
key = a.attr("href").removeSuffix("/").substringAfterLast('/'),
|
|
||||||
title = a.text().toTitleCase(),
|
|
||||||
source = source,
|
|
||||||
)
|
|
||||||
} ?: manga.tags,
|
|
||||||
description = root2.selectFirst("div.description-summary")
|
|
||||||
?.selectFirst("div.summary__content")
|
|
||||||
?.select("p")
|
|
||||||
?.filterNot { it.ownText().startsWith("A brief description") }
|
|
||||||
?.joinToString { it.html() },
|
|
||||||
chapters = chaptersDeferred.await(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package org.koitharu.kotatsu.parsers.site.madara
|
||||||
|
|
||||||
|
import org.jsoup.nodes.Element
|
||||||
|
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.MangaChapter
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaState
|
||||||
|
import org.koitharu.kotatsu.parsers.util.attrAsAbsoluteUrlOrNull
|
||||||
|
import org.koitharu.kotatsu.parsers.util.mapToSet
|
||||||
|
import org.koitharu.kotatsu.parsers.util.selectFirstOrThrow
|
||||||
|
import java.util.*
|
||||||
|
|
||||||
|
@MangaSourceParser("MANGAS_ORIGINES", "Mangas Origines", "fr")
|
||||||
|
internal class MangasOriginesParser(context: MangaLoaderContext) :
|
||||||
|
Madara6Parser(context, MangaSource.MANGAS_ORIGINES, "mangas-origines.fr") {
|
||||||
|
|
||||||
|
override val tagPrefix = "catalogues-genre/"
|
||||||
|
|
||||||
|
override fun getFaviconUrl(): String {
|
||||||
|
return "https://${getDomain()}/wp-content/uploads/2020/11/Mangas-150x150.png"
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun parseDetails(manga: Manga, body: Element, chapters: List<MangaChapter>): Manga {
|
||||||
|
val root = body.selectFirstOrThrow(".site-content")
|
||||||
|
val postContent = root.selectFirstOrThrow(".post-content")
|
||||||
|
val tags = postContent.getElementsContainingOwnText("Genre")
|
||||||
|
.firstOrNull()?.tableValue()
|
||||||
|
?.getElementsByAttributeValueContaining("href", tagPrefix)
|
||||||
|
?.mapToSet { a -> a.asMangaTag() } ?: manga.tags
|
||||||
|
return manga.copy(
|
||||||
|
largeCoverUrl = root.selectFirst("picture")
|
||||||
|
?.selectFirst("img[data-src]")
|
||||||
|
?.attrAsAbsoluteUrlOrNull("data-src"),
|
||||||
|
description = (root.selectFirst(".detail-content")
|
||||||
|
?: root.selectFirstOrThrow(".manga-excerpt")).html(),
|
||||||
|
author = postContent.getElementsContainingOwnText("Auteur")
|
||||||
|
.firstOrNull()?.tableValue()?.text()?.trim(),
|
||||||
|
state = postContent.getElementsContainingOwnText("STATUS")
|
||||||
|
.firstOrNull()?.tableValue()?.text()?.asMangaState(),
|
||||||
|
tags = tags,
|
||||||
|
isNsfw = body.hasClass("adult-content"),
|
||||||
|
chapters = chapters,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun String.asMangaState() = when (trim().lowercase(Locale.FRANCE)) {
|
||||||
|
"en cours" -> MangaState.ONGOING
|
||||||
|
"abandonné",
|
||||||
|
"terminé",
|
||||||
|
-> MangaState.FINISHED
|
||||||
|
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.parsers.site.madara
|
|
||||||
|
|
||||||
import kotlinx.coroutines.async
|
|
||||||
import kotlinx.coroutines.coroutineScope
|
|
||||||
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
|
||||||
import org.koitharu.kotatsu.parsers.MangaSourceParser
|
|
||||||
import org.koitharu.kotatsu.parsers.exception.ParseException
|
|
||||||
import org.koitharu.kotatsu.parsers.model.*
|
|
||||||
import org.koitharu.kotatsu.parsers.util.*
|
|
||||||
import java.text.SimpleDateFormat
|
|
||||||
import java.util.*
|
|
||||||
|
|
||||||
@MangaSourceParser("NEATMANGA", "NeatManga", "en")
|
|
||||||
internal class NeatManga(context: MangaLoaderContext) : MadaraParser(context, MangaSource.NEATMANGA, "neatmangas.com") {
|
|
||||||
|
|
||||||
override val datePattern = "dd MMMM yyyy"
|
|
||||||
|
|
||||||
override suspend fun getDetails(manga: Manga): Manga = coroutineScope {
|
|
||||||
val chaptersDeferred = async { getChapters(manga) }
|
|
||||||
val fullUrl = manga.url.toAbsoluteUrl(domain)
|
|
||||||
val doc = webClient.httpGet(fullUrl).parseHtml()
|
|
||||||
val root = doc.body().selectFirst("div.profile-manga")
|
|
||||||
?.selectFirst("div.summary_content")
|
|
||||||
?.selectFirst("div.post-content")
|
|
||||||
?: throw ParseException("Root not found", fullUrl)
|
|
||||||
val root2 = doc.body().selectFirst("div.content-area")
|
|
||||||
?.selectFirst("div.c-page")
|
|
||||||
?: throw ParseException("Root2 not found", fullUrl)
|
|
||||||
manga.copy(
|
|
||||||
tags = root.selectFirst("div.genres-content")?.select("a")
|
|
||||||
?.mapNotNullToSet { a ->
|
|
||||||
MangaTag(
|
|
||||||
key = a.attr("href").removeSuffix("/").substringAfterLast('/'),
|
|
||||||
title = a.text().toTitleCase(),
|
|
||||||
source = source,
|
|
||||||
)
|
|
||||||
} ?: manga.tags,
|
|
||||||
description = root2.getElementsMatchingOwnText("Summary")
|
|
||||||
.firstOrNull()
|
|
||||||
?.nextElementSibling()
|
|
||||||
?.select("p")
|
|
||||||
?.filterNot { it.ownText().startsWith("A brief description") }
|
|
||||||
?.joinToString { it.html() },
|
|
||||||
chapters = chaptersDeferred.await(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun getChapters(manga: Manga): List<MangaChapter> {
|
|
||||||
val slug = manga.url.removeSuffix('/').substringAfterLast('/')
|
|
||||||
val doc2 = webClient.httpPost(
|
|
||||||
"https://$domain/manga/$slug/ajax/chapters/",
|
|
||||||
mapOf(),
|
|
||||||
).parseHtml()
|
|
||||||
val ul = doc2.body().selectFirstOrThrow("ul")
|
|
||||||
val dateFormat = SimpleDateFormat(datePattern, Locale.US)
|
|
||||||
return ul.select("li").mapChapters(reversed = true) { i, li ->
|
|
||||||
val a = li.selectFirst("a")
|
|
||||||
val href = a?.attrAsRelativeUrlOrNull("href") ?: li.parseFailed("Link is missing")
|
|
||||||
MangaChapter(
|
|
||||||
id = generateUid(href),
|
|
||||||
name = a.ownText(),
|
|
||||||
number = i + 1,
|
|
||||||
url = href,
|
|
||||||
uploadDate = parseChapterDate(
|
|
||||||
dateFormat,
|
|
||||||
li.selectFirst("span.chapter-release-date i")?.text(),
|
|
||||||
),
|
|
||||||
source = source,
|
|
||||||
scanlator = null,
|
|
||||||
branch = null,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
|
|
||||||
val fullUrl = chapter.url.toAbsoluteUrl(domain)
|
|
||||||
val doc = webClient.httpGet(fullUrl).parseHtml()
|
|
||||||
val root = doc.body().selectFirst("div.main-col-inner")
|
|
||||||
?.selectFirst("div.reading-content")
|
|
||||||
?: throw ParseException("Root not found", fullUrl)
|
|
||||||
return root.select("div.page-break").mapNotNull { div ->
|
|
||||||
val img = div.selectFirst("img")
|
|
||||||
if (img == null || img.attr("id").isNullOrEmpty()) {
|
|
||||||
return@mapNotNull null
|
|
||||||
}
|
|
||||||
val url = img.src()?.toRelativeUrl(domain) ?: div.parseFailed("Image src not found")
|
|
||||||
MangaPage(
|
|
||||||
id = generateUid(url),
|
|
||||||
url = url,
|
|
||||||
preview = null,
|
|
||||||
source = source,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.parsers.site.madara
|
|
||||||
|
|
||||||
import org.jsoup.nodes.Element
|
|
||||||
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
|
||||||
import org.koitharu.kotatsu.parsers.MangaSourceParser
|
|
||||||
import org.koitharu.kotatsu.parsers.exception.ParseException
|
|
||||||
import org.koitharu.kotatsu.parsers.model.*
|
|
||||||
import org.koitharu.kotatsu.parsers.util.*
|
|
||||||
import java.util.*
|
|
||||||
|
|
||||||
@MangaSourceParser("NEOXSCANS", "NeoxScans", "pt")
|
|
||||||
internal class NeoxScansParser(context: MangaLoaderContext) :
|
|
||||||
Madara6Parser(context, MangaSource.NEOXSCANS, "neoxscans.net") {
|
|
||||||
|
|
||||||
override val tagPrefix = "manga-genre/"
|
|
||||||
|
|
||||||
override val datePattern: String = "dd/MM/yyyy"
|
|
||||||
|
|
||||||
override fun parseDetails(manga: Manga, body: Element, chapters: List<MangaChapter>): Manga {
|
|
||||||
val root = body.selectFirstOrThrow(".site-content")
|
|
||||||
val postContent = root.selectFirstOrThrow(".post-content")
|
|
||||||
val tags = postContent.getElementsContainingOwnText("Genre")
|
|
||||||
.firstOrNull()?.tableValue()
|
|
||||||
?.getElementsByAttributeValueContaining("href", tagPrefix)
|
|
||||||
?.mapToSet { a -> a.asMangaTag() } ?: manga.tags
|
|
||||||
return manga.copy(
|
|
||||||
largeCoverUrl = root.selectFirst("picture")
|
|
||||||
?.selectFirst("img[src]")
|
|
||||||
?.attrAsAbsoluteUrlOrNull("src"),
|
|
||||||
description = (root.selectFirst(".post-content")
|
|
||||||
?: root.selectFirstOrThrow(".manga-excerpt")).html(),
|
|
||||||
author = postContent.getElementsContainingOwnText("Autor")
|
|
||||||
.firstOrNull()?.tableValue()?.text()?.trim(),
|
|
||||||
state = postContent.getElementsContainingOwnText("Status")
|
|
||||||
.firstOrNull()?.tableValue()?.text()?.asMangaState(),
|
|
||||||
tags = tags,
|
|
||||||
isNsfw = body.hasClass("adult-content"),
|
|
||||||
chapters = chapters,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
|
|
||||||
val fullUrl = chapter.url.toAbsoluteUrl(domain)
|
|
||||||
val doc = webClient.httpGet(fullUrl).parseHtml()
|
|
||||||
val root = doc.body().selectFirst("div.main-col-inner")
|
|
||||||
?.selectFirst("div.reading-content")
|
|
||||||
?: throw ParseException("Root not found", fullUrl)
|
|
||||||
return root.select("div.page-break").map { div ->
|
|
||||||
val img = div.selectFirst("img") ?: div.parseFailed("Page image not found")
|
|
||||||
val url = img.attr("src") ?: div.parseFailed("Image src not found")
|
|
||||||
MangaPage(
|
|
||||||
id = generateUid(url),
|
|
||||||
url = url,
|
|
||||||
preview = null,
|
|
||||||
source = source,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun String.asMangaState() = when (trim().lowercase(Locale.forLanguageTag("pt"))) {
|
|
||||||
"Em lançamento" -> MangaState.ONGOING
|
|
||||||
"Completo" -> MangaState.FINISHED
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -10,13 +10,17 @@ import org.koitharu.kotatsu.parsers.model.MangaState
|
|||||||
import org.koitharu.kotatsu.parsers.util.attrAsAbsoluteUrlOrNull
|
import org.koitharu.kotatsu.parsers.util.attrAsAbsoluteUrlOrNull
|
||||||
import org.koitharu.kotatsu.parsers.util.mapToSet
|
import org.koitharu.kotatsu.parsers.util.mapToSet
|
||||||
import org.koitharu.kotatsu.parsers.util.selectFirstOrThrow
|
import org.koitharu.kotatsu.parsers.util.selectFirstOrThrow
|
||||||
|
import java.util.*
|
||||||
|
|
||||||
@MangaSourceParser("PRISMA_SCANS", "Prisma Scans", "pt")
|
@MangaSourceParser("PRISMA_SCANS", "Prisma Scans", "pt")
|
||||||
internal class PrismaScansParser(context: MangaLoaderContext) :
|
internal class PrismaScansParser(context: MangaLoaderContext) :
|
||||||
Madara6Parser(context, MangaSource.PRISMA_SCANS, "prismascans.net") {
|
Madara6Parser(context, MangaSource.PRISMA_SCANS, "prismascans.net") {
|
||||||
|
|
||||||
override val tagPrefix = "manga-genre/"
|
override val tagPrefix = "manga-genre/"
|
||||||
override val datePattern = "MMM dd, yyyy"
|
|
||||||
|
override fun getFaviconUrl(): String {
|
||||||
|
return "https://${getDomain()}/wp-content/uploads/2022/07/cropped-branca-1-192x192.png"
|
||||||
|
}
|
||||||
|
|
||||||
override fun parseDetails(manga: Manga, body: Element, chapters: List<MangaChapter>): Manga {
|
override fun parseDetails(manga: Manga, body: Element, chapters: List<MangaChapter>): Manga {
|
||||||
val root = body.selectFirstOrThrow(".site-content")
|
val root = body.selectFirstOrThrow(".site-content")
|
||||||
@@ -29,7 +33,7 @@ internal class PrismaScansParser(context: MangaLoaderContext) :
|
|||||||
largeCoverUrl = root.selectFirst("picture")
|
largeCoverUrl = root.selectFirst("picture")
|
||||||
?.selectFirst("img[data-src]")
|
?.selectFirst("img[data-src]")
|
||||||
?.attrAsAbsoluteUrlOrNull("data-src"),
|
?.attrAsAbsoluteUrlOrNull("data-src"),
|
||||||
description = root.selectFirstOrThrow(".manga-excerpt").firstElementChild()?.html(),
|
description = root.selectFirstOrThrow(".description-summary").firstElementChild()?.html(),
|
||||||
author = postContent.getElementsContainingOwnText("Artista")
|
author = postContent.getElementsContainingOwnText("Artista")
|
||||||
.firstOrNull()?.tableValue()?.text()?.trim(),
|
.firstOrNull()?.tableValue()?.text()?.trim(),
|
||||||
altTitle = postContent.getElementsContainingOwnText("Título Alternativo")
|
altTitle = postContent.getElementsContainingOwnText("Título Alternativo")
|
||||||
@@ -42,7 +46,7 @@ internal class PrismaScansParser(context: MangaLoaderContext) :
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun String.asMangaState() = when (trim().lowercase(sourceLocale)) {
|
override fun String.asMangaState() = when (trim().lowercase(sourceLocale ?: Locale.ROOT)) {
|
||||||
"em lançamento" -> MangaState.ONGOING
|
"em lançamento" -> MangaState.ONGOING
|
||||||
"completo",
|
"completo",
|
||||||
"cancelado",
|
"cancelado",
|
||||||
@@ -50,4 +54,4 @@ internal class PrismaScansParser(context: MangaLoaderContext) :
|
|||||||
|
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.parsers.site.madara
|
|
||||||
|
|
||||||
import org.jsoup.nodes.Element
|
|
||||||
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
|
||||||
import org.koitharu.kotatsu.parsers.MangaSourceParser
|
|
||||||
import org.koitharu.kotatsu.parsers.model.*
|
|
||||||
import org.koitharu.kotatsu.parsers.util.*
|
|
||||||
import java.util.*
|
|
||||||
|
|
||||||
@MangaSourceParser("REAPER_SCANS_ID", "ReaperScansID", "in")
|
|
||||||
internal class ReaperScansParser(context: MangaLoaderContext) :
|
|
||||||
Madara6Parser(context, MangaSource.REAPER_SCANS_ID, "reaperscans.id") {
|
|
||||||
|
|
||||||
override val datePattern = "MMMM dd, yyyy"
|
|
||||||
override val tagPrefix = "genre/"
|
|
||||||
override val sourceLocale: Locale = Locale.ENGLISH
|
|
||||||
|
|
||||||
override fun String.asMangaState(): MangaState? = when (this) {
|
|
||||||
"OnGoing",
|
|
||||||
"Upcoming",
|
|
||||||
-> MangaState.ONGOING
|
|
||||||
|
|
||||||
"Completed",
|
|
||||||
"Dropped",
|
|
||||||
-> MangaState.FINISHED
|
|
||||||
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun parseDetails(manga: Manga, body: Element, chapters: List<MangaChapter>): Manga {
|
|
||||||
val root = body.selectFirstOrThrow(".site-content")
|
|
||||||
val postContent = root.requireElementById("nav-info")
|
|
||||||
val tags = postContent.getElementsContainingOwnText("Gênero")
|
|
||||||
.firstOrNull()?.tableValue()
|
|
||||||
?.getElementsByAttributeValueContaining("href", tagPrefix)
|
|
||||||
?.mapToSet { a -> a.asMangaTag() } ?: manga.tags
|
|
||||||
return manga.copy(
|
|
||||||
rating = postContent.selectFirstOrThrow(".post-rating")
|
|
||||||
.selectFirstOrThrow(".total_votes").text().toFloat() / 5f,
|
|
||||||
largeCoverUrl = root.selectFirst(".summary_image")
|
|
||||||
?.selectFirst("img[data-src]")
|
|
||||||
?.attrAsAbsoluteUrlOrNull("data-src")
|
|
||||||
.assertNotNull("largeCoverUrl"),
|
|
||||||
description = root.requireElementById("nav-profile")
|
|
||||||
.selectFirstOrThrow(".description-summary")
|
|
||||||
.firstElementChild()?.html(),
|
|
||||||
author = postContent.getElementsContainingOwnText("Author(s)")
|
|
||||||
.firstOrNull()?.tableValue()?.text()?.trim(),
|
|
||||||
altTitle = postContent.getElementsContainingOwnText("Alternative")
|
|
||||||
.firstOrNull()?.tableValue()?.text()?.trim(),
|
|
||||||
state = postContent.getElementsContainingOwnText("Status")
|
|
||||||
.firstOrNull()?.tableValue()?.text()?.asMangaState(),
|
|
||||||
tags = tags,
|
|
||||||
isNsfw = body.hasClass("adult-content"),
|
|
||||||
chapters = chapters,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun getTags(): Set<MangaTag> {
|
|
||||||
val doc = webClient.httpGet("https://${domain}/semua-komik/").parseHtml()
|
|
||||||
val body = doc.body()
|
|
||||||
val root1 = body.selectFirst("header")?.selectFirst("ul.second-menu")
|
|
||||||
val root2 = body.selectFirst("div.genres_wrap")?.selectFirst("ul.list-unstyled")
|
|
||||||
if (root1 == null && root2 == null) {
|
|
||||||
doc.parseFailed("Root not found")
|
|
||||||
}
|
|
||||||
val list = root1?.select("li").orEmpty() + root2?.select("li").orEmpty()
|
|
||||||
val keySet = HashSet<String>(list.size)
|
|
||||||
return list.mapNotNullToSet { li ->
|
|
||||||
val a = li.selectFirst("a") ?: return@mapNotNullToSet null
|
|
||||||
val href = a.attr("href").removeSuffix("/")
|
|
||||||
.substringAfterLast(tagPrefix, "")
|
|
||||||
if (href.isEmpty() || !keySet.add(href)) {
|
|
||||||
return@mapNotNullToSet null
|
|
||||||
}
|
|
||||||
MangaTag(
|
|
||||||
key = href,
|
|
||||||
title = a.ownText().trim().ifEmpty {
|
|
||||||
a.selectFirst(".menu-image-title")?.text()?.trim() ?: return@mapNotNullToSet null
|
|
||||||
}.toTitleCase(),
|
|
||||||
source = source,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -3,10 +3,7 @@ package org.koitharu.kotatsu.parsers.site.madara
|
|||||||
import org.jsoup.nodes.Element
|
import org.jsoup.nodes.Element
|
||||||
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
||||||
import org.koitharu.kotatsu.parsers.MangaSourceParser
|
import org.koitharu.kotatsu.parsers.MangaSourceParser
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
import org.koitharu.kotatsu.parsers.model.*
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaState
|
|
||||||
import org.koitharu.kotatsu.parsers.util.attrAsAbsoluteUrlOrNull
|
import org.koitharu.kotatsu.parsers.util.attrAsAbsoluteUrlOrNull
|
||||||
import org.koitharu.kotatsu.parsers.util.mapToSet
|
import org.koitharu.kotatsu.parsers.util.mapToSet
|
||||||
import org.koitharu.kotatsu.parsers.util.selectFirstOrThrow
|
import org.koitharu.kotatsu.parsers.util.selectFirstOrThrow
|
||||||
@@ -14,37 +11,41 @@ import java.util.*
|
|||||||
|
|
||||||
@MangaSourceParser("TATAKAE_SCANS", "Tatakae Scans", "pt")
|
@MangaSourceParser("TATAKAE_SCANS", "Tatakae Scans", "pt")
|
||||||
internal class TatakaeScansParser(context: MangaLoaderContext) :
|
internal class TatakaeScansParser(context: MangaLoaderContext) :
|
||||||
Madara6Parser(context, MangaSource.TATAKAE_SCANS, "tatakaescan.com") {
|
Madara6Parser(context, MangaSource.TATAKAE_SCANS, "tatakaescan.com") {
|
||||||
|
|
||||||
override val datePattern: String = "dd 'de' MMMMM 'de' yyyy"
|
override val datePattern: String = "dd 'de' MMMMM 'de' yyyy"
|
||||||
|
|
||||||
override fun parseDetails(manga: Manga, body: Element, chapters: List<MangaChapter>): Manga {
|
override fun parseDetails(manga: Manga, body: Element, chapters: List<MangaChapter>): Manga {
|
||||||
val root = body.selectFirstOrThrow(".site-content")
|
val root = body.selectFirstOrThrow(".site-content")
|
||||||
val postContent = root.selectFirstOrThrow(".post-content")
|
val postContent = root.selectFirstOrThrow(".post-content")
|
||||||
val tags = postContent.getElementsContainingOwnText("Gênero")
|
val tags = postContent.getElementsContainingOwnText("Gênero")
|
||||||
.firstOrNull()?.tableValue()
|
.firstOrNull()?.tableValue()
|
||||||
?.getElementsByAttributeValueContaining("href", tagPrefix)
|
?.getElementsByAttributeValueContaining("href", tagPrefix)
|
||||||
?.mapToSet { a -> a.asMangaTag() } ?: manga.tags
|
?.mapToSet { a -> a.asMangaTag() } ?: manga.tags
|
||||||
return manga.copy(
|
return manga.copy(
|
||||||
largeCoverUrl = root.selectFirst("picture")
|
largeCoverUrl = root.selectFirst("picture")
|
||||||
?.selectFirst("img[data-src]")
|
?.selectFirst("img[data-src]")
|
||||||
?.attrAsAbsoluteUrlOrNull("data-src"),
|
?.attrAsAbsoluteUrlOrNull("data-src"),
|
||||||
description = (root.selectFirst(".detail-content")
|
description = (root.selectFirst(".detail-content")
|
||||||
?: root.selectFirstOrThrow(".manga-excerpt")).html(),
|
?: root.selectFirstOrThrow(".manga-excerpt")).html(),
|
||||||
author = postContent.getElementsContainingOwnText("Autor")
|
author = postContent.getElementsContainingOwnText("Autor")
|
||||||
.firstOrNull()?.tableValue()?.text()?.trim(),
|
.firstOrNull()?.tableValue()?.text()?.trim(),
|
||||||
state = postContent.getElementsContainingOwnText("Status")
|
state = postContent.getElementsContainingOwnText("Status")
|
||||||
.firstOrNull()?.tableValue()?.text()?.asMangaState(),
|
.firstOrNull()?.tableValue()?.text()?.asMangaState(),
|
||||||
tags = tags,
|
tags = tags,
|
||||||
isNsfw = body.hasClass("adult-content"),
|
isNsfw = body.hasClass("adult-content"),
|
||||||
chapters = chapters,
|
chapters = chapters,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun String.asMangaState() = when (trim().lowercase(Locale.ROOT)) {
|
override fun String.asMangaState() = when (trim().lowercase(Locale.ROOT)) {
|
||||||
"em lançamento" -> MangaState.ONGOING
|
"em lançamento" -> MangaState.ONGOING
|
||||||
|
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
override fun getFaviconUrl(): String {
|
||||||
|
return "https://${getDomain()}/wp-content/uploads/2022/07/cropped-favicon-180x180.png"
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -1,669 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.parsers.site.mangareader
|
|
||||||
|
|
||||||
import androidx.collection.ArrayMap
|
|
||||||
import kotlinx.coroutines.sync.Mutex
|
|
||||||
import kotlinx.coroutines.sync.withLock
|
|
||||||
import org.json.JSONObject
|
|
||||||
import org.jsoup.nodes.Document
|
|
||||||
import org.jsoup.nodes.Element
|
|
||||||
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
|
||||||
import org.koitharu.kotatsu.parsers.MangaSourceParser
|
|
||||||
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 java.text.SimpleDateFormat
|
|
||||||
import java.util.*
|
|
||||||
|
|
||||||
internal abstract class MangaReaderParser(
|
|
||||||
context: MangaLoaderContext,
|
|
||||||
source: MangaSource,
|
|
||||||
pageSize: Int,
|
|
||||||
searchPageSize: Int,
|
|
||||||
) : PagedMangaParser(context, source, pageSize, searchPageSize) {
|
|
||||||
|
|
||||||
override val sortOrders: Set<SortOrder>
|
|
||||||
get() = EnumSet.of(SortOrder.UPDATED, SortOrder.POPULARITY, SortOrder.ALPHABETICAL, SortOrder.NEWEST)
|
|
||||||
|
|
||||||
protected val idLocale
|
|
||||||
get() = Locale("in", "ID")
|
|
||||||
|
|
||||||
abstract val listUrl: String
|
|
||||||
abstract val tableMode: Boolean
|
|
||||||
open val chapterDateFormat = SimpleDateFormat("MMM d, yyyy", idLocale)
|
|
||||||
|
|
||||||
private var tagCache: ArrayMap<String, MangaTag>? = null
|
|
||||||
private val mutex = Mutex()
|
|
||||||
private var lastSearchPage = 1
|
|
||||||
|
|
||||||
override suspend fun getDetails(manga: Manga): Manga {
|
|
||||||
val docs = webClient.httpGet(manga.url.toAbsoluteUrl(domain)).parseHtml()
|
|
||||||
val chapters = docs.select("#chapterlist > ul > li").mapChapters(reversed = true) { index, element ->
|
|
||||||
val url = element.selectFirst("a")?.attrAsRelativeUrl("href") ?: return@mapChapters null
|
|
||||||
MangaChapter(
|
|
||||||
id = generateUid(url),
|
|
||||||
name = element.selectFirst(".chapternum")?.text() ?: "Chapter ${index + 1}",
|
|
||||||
url = url,
|
|
||||||
number = index + 1,
|
|
||||||
scanlator = null,
|
|
||||||
uploadDate = chapterDateFormat.tryParse(element.selectFirst(".chapterdate")?.text()),
|
|
||||||
branch = null,
|
|
||||||
source = source,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return if (tableMode) parseInfoTable(docs, manga, chapters) else parseInfoList(docs, manga, chapters)
|
|
||||||
}
|
|
||||||
|
|
||||||
open suspend fun parseInfoTable(docs: Document, manga: Manga, chapters: List<MangaChapter>): Manga {
|
|
||||||
val mangaInfo = docs.selectFirst("div.seriestucontent > div.seriestucontentr")
|
|
||||||
val mangaState = mangaInfo?.selectFirst(".infotable td:contains(Status)")?.lastElementSibling()?.let {
|
|
||||||
when (it.text()) {
|
|
||||||
"Ongoing" -> MangaState.ONGOING
|
|
||||||
"Completed" -> MangaState.FINISHED
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val tagMap = getOrCreateTagMap()
|
|
||||||
val tags = mangaInfo?.select(".seriestugenre > a")?.mapNotNullToSet { tagMap[it.text()] }
|
|
||||||
|
|
||||||
return manga.copy(
|
|
||||||
description = mangaInfo?.selectFirst("div.entry-content")?.html(),
|
|
||||||
state = mangaState,
|
|
||||||
author = mangaInfo?.selectFirst(".infotable td:contains(Author)")?.lastElementSibling()?.text(),
|
|
||||||
isNsfw = docs.selectFirst(".restrictcontainer") != null,
|
|
||||||
tags = tags.orEmpty(),
|
|
||||||
chapters = chapters,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
open suspend fun parseInfoList(docs: Document, manga: Manga, chapters: List<MangaChapter>): Manga {
|
|
||||||
val mangaState = docs.selectFirst(".info-left .tsinfo div:contains(Status)")?.lastElementChild()?.let {
|
|
||||||
when (it.text()) {
|
|
||||||
"Ongoing" -> MangaState.ONGOING
|
|
||||||
"Completed" -> MangaState.FINISHED
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val tagMap = getOrCreateTagMap()
|
|
||||||
val tags = docs.select(".info-right .mgen > a").mapNotNullToSet { tagMap[it.text()] }
|
|
||||||
|
|
||||||
return manga.copy(
|
|
||||||
description = docs.selectFirst(".info-right div.entry-content > p")?.html(),
|
|
||||||
state = mangaState,
|
|
||||||
author = docs.selectFirst(".info-left .tsinfo div:contains(Author)")?.lastElementChild()?.text(),
|
|
||||||
isNsfw = docs.selectFirst(".info-right .alr") != null,
|
|
||||||
tags = tags,
|
|
||||||
chapters = chapters,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun getListPage(
|
|
||||||
page: Int,
|
|
||||||
query: String?,
|
|
||||||
tags: Set<MangaTag>?,
|
|
||||||
sortOrder: SortOrder,
|
|
||||||
): List<Manga> {
|
|
||||||
if (!query.isNullOrEmpty()) {
|
|
||||||
if (page > lastSearchPage) {
|
|
||||||
return emptyList()
|
|
||||||
}
|
|
||||||
|
|
||||||
val url = buildString {
|
|
||||||
append("https://")
|
|
||||||
append(domain)
|
|
||||||
append("/page/")
|
|
||||||
append(page)
|
|
||||||
append("/?s=")
|
|
||||||
append(query.urlEncoded())
|
|
||||||
}
|
|
||||||
|
|
||||||
val docs = webClient.httpGet(url).parseHtml()
|
|
||||||
lastSearchPage = docs.selectFirst(".pagination .next")
|
|
||||||
?.previousElementSibling()
|
|
||||||
?.text()?.toIntOrNull() ?: 1
|
|
||||||
return parseMangaList(docs)
|
|
||||||
}
|
|
||||||
|
|
||||||
val sortQuery = when (sortOrder) {
|
|
||||||
SortOrder.ALPHABETICAL -> "title"
|
|
||||||
SortOrder.NEWEST -> "latest"
|
|
||||||
SortOrder.POPULARITY -> "popular"
|
|
||||||
SortOrder.UPDATED -> "update"
|
|
||||||
else -> ""
|
|
||||||
}
|
|
||||||
val tagKey = "genre[]".urlEncoded()
|
|
||||||
val tagQuery =
|
|
||||||
if (tags.isNullOrEmpty()) "" else tags.joinToString(separator = "&", prefix = "&") { "$tagKey=${it.key}" }
|
|
||||||
val url = buildString {
|
|
||||||
append("https://")
|
|
||||||
append(domain)
|
|
||||||
append(listUrl)
|
|
||||||
append("/?order=")
|
|
||||||
append(sortQuery)
|
|
||||||
append(tagQuery)
|
|
||||||
append("&page=")
|
|
||||||
append(page)
|
|
||||||
}
|
|
||||||
|
|
||||||
return parseMangaList(webClient.httpGet(url).parseHtml())
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun parseMangaList(docs: Document): List<Manga> {
|
|
||||||
return docs.select(".postbody .listupd .bs .bsx").mapNotNull {
|
|
||||||
val a = it.selectFirst("a") ?: return@mapNotNull null
|
|
||||||
val relativeUrl = a.attrAsRelativeUrl("href")
|
|
||||||
val rating = it.selectFirst(".numscore")?.text()
|
|
||||||
?.toFloatOrNull()?.div(10) ?: RATING_UNKNOWN
|
|
||||||
|
|
||||||
Manga(
|
|
||||||
id = generateUid(relativeUrl),
|
|
||||||
url = relativeUrl,
|
|
||||||
title = a.attr("title"),
|
|
||||||
altTitle = null,
|
|
||||||
publicUrl = a.attrAsAbsoluteUrl("href"),
|
|
||||||
rating = rating,
|
|
||||||
isNsfw = false,
|
|
||||||
coverUrl = it.selectFirst("img.ts-post-image")?.imageUrl().orEmpty(),
|
|
||||||
tags = emptySet(),
|
|
||||||
state = null,
|
|
||||||
author = null,
|
|
||||||
source = source,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
|
|
||||||
val chapterUrl = chapter.url.toAbsoluteUrl(domain)
|
|
||||||
val docs = webClient.httpGet(chapterUrl).parseHtml()
|
|
||||||
val script = docs.selectFirstOrThrow("script:containsData(ts_reader)")
|
|
||||||
val images = JSONObject(script.data().substringAfter('(').substringBeforeLast(')'))
|
|
||||||
.getJSONArray("sources")
|
|
||||||
.getJSONObject(0)
|
|
||||||
.getJSONArray("images")
|
|
||||||
|
|
||||||
val pages = ArrayList<MangaPage>(images.length())
|
|
||||||
for (i in 0 until images.length()) {
|
|
||||||
pages.add(
|
|
||||||
MangaPage(
|
|
||||||
id = generateUid(images.getString(i)),
|
|
||||||
url = images.getString(i),
|
|
||||||
preview = null,
|
|
||||||
source = source,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return pages
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun getTags(): Set<MangaTag> {
|
|
||||||
return getOrCreateTagMap().values.toSet()
|
|
||||||
}
|
|
||||||
|
|
||||||
protected suspend fun getOrCreateTagMap(): Map<String, MangaTag> = mutex.withLock {
|
|
||||||
tagCache?.let { return@withLock it }
|
|
||||||
val tagMap = ArrayMap<String, MangaTag>()
|
|
||||||
|
|
||||||
val url = listUrl.toAbsoluteUrl(domain)
|
|
||||||
val tagElements = webClient.httpGet(url).parseHtml().select("ul.genrez > li")
|
|
||||||
for (el in tagElements) {
|
|
||||||
if (el.text().isEmpty()) continue
|
|
||||||
|
|
||||||
tagMap[el.text()] = MangaTag(
|
|
||||||
title = el.text(),
|
|
||||||
key = el.selectFirst("input")?.attr("value") ?: continue,
|
|
||||||
source = source,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
tagCache = tagMap
|
|
||||||
return@withLock tagMap
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun Element.imageUrl(): String {
|
|
||||||
return attrAsAbsoluteUrlOrNull("src")
|
|
||||||
?: attrAsAbsoluteUrlOrNull("data-src")
|
|
||||||
?: attrAsAbsoluteUrlOrNull("data-cfsrc")
|
|
||||||
?: ""
|
|
||||||
}
|
|
||||||
|
|
||||||
@MangaSourceParser("MANHWALAND", "Manhwaland", "id")
|
|
||||||
class ManhwaLandParser(context: MangaLoaderContext) :
|
|
||||||
MangaReaderParser(context, MangaSource.MANHWALAND, pageSize = 20, searchPageSize = 10) {
|
|
||||||
override val configKeyDomain: ConfigKey.Domain
|
|
||||||
get() = ConfigKey.Domain("manhwaland.us", arrayOf("manhwaland.us", "manhwaland.guru"))
|
|
||||||
|
|
||||||
override val listUrl: String
|
|
||||||
get() = "/manga"
|
|
||||||
override val tableMode: Boolean
|
|
||||||
get() = false
|
|
||||||
}
|
|
||||||
|
|
||||||
@MangaSourceParser("SEKAIKOMIK", "Sekaikomik", "id")
|
|
||||||
class SekaikomikParser(context: MangaLoaderContext) :
|
|
||||||
MangaReaderParser(context, MangaSource.SEKAIKOMIK, pageSize = 20, searchPageSize = 100) {
|
|
||||||
override val configKeyDomain: ConfigKey.Domain
|
|
||||||
get() = ConfigKey.Domain("sekaikomik.pro", null)
|
|
||||||
|
|
||||||
override val listUrl: String
|
|
||||||
get() = "/manga"
|
|
||||||
override val tableMode: Boolean
|
|
||||||
get() = false
|
|
||||||
override val chapterDateFormat: SimpleDateFormat = SimpleDateFormat("MMMM D, yyyy", idLocale)
|
|
||||||
}
|
|
||||||
|
|
||||||
@MangaSourceParser("MANHWAINDO", "Manhwaindo", "id")
|
|
||||||
class ManhwaIndoParser(context: MangaLoaderContext) :
|
|
||||||
MangaReaderParser(context, MangaSource.MANHWAINDO, pageSize = 20, searchPageSize = 10) {
|
|
||||||
override val configKeyDomain: ConfigKey.Domain
|
|
||||||
get() = ConfigKey.Domain("manhwaindo.id", null)
|
|
||||||
|
|
||||||
override val chapterDateFormat = SimpleDateFormat("MMMM dd, yyyy", Locale.ENGLISH)
|
|
||||||
override val listUrl: String get() = "/series"
|
|
||||||
override val tableMode: Boolean get() = false
|
|
||||||
}
|
|
||||||
|
|
||||||
@MangaSourceParser("MANHWALIST", "Manhwalist", "id")
|
|
||||||
class ManhwalistParser(context: MangaLoaderContext) :
|
|
||||||
MangaReaderParser(context, MangaSource.MANHWALIST, pageSize = 24, searchPageSize = 10) {
|
|
||||||
override val configKeyDomain: ConfigKey.Domain
|
|
||||||
get() = ConfigKey.Domain("manhwalist.in", null)
|
|
||||||
|
|
||||||
override val listUrl: String = "/manga"
|
|
||||||
override val tableMode: Boolean get() = false
|
|
||||||
override val chapterDateFormat: SimpleDateFormat = SimpleDateFormat("MMMM d, yyyy", Locale.ENGLISH)
|
|
||||||
}
|
|
||||||
|
|
||||||
@MangaSourceParser("KIRYUU", "Kiryuu", "id")
|
|
||||||
class KiryuuParser(context: MangaLoaderContext) :
|
|
||||||
MangaReaderParser(context, MangaSource.KIRYUU, pageSize = 20, searchPageSize = 10) {
|
|
||||||
override val configKeyDomain: ConfigKey.Domain
|
|
||||||
get() = ConfigKey.Domain("kiryuu.id", null)
|
|
||||||
|
|
||||||
override val listUrl: String
|
|
||||||
get() = "/manga"
|
|
||||||
override val tableMode: Boolean
|
|
||||||
get() = true
|
|
||||||
override val chapterDateFormat: SimpleDateFormat = SimpleDateFormat("MMMM d, yyyy", idLocale)
|
|
||||||
}
|
|
||||||
|
|
||||||
@MangaSourceParser("TURKTOON", "Turktoon", "tr")
|
|
||||||
class TurktoonParser(context: MangaLoaderContext) :
|
|
||||||
MangaReaderParser(context, MangaSource.TURKTOON, pageSize = 20, searchPageSize = 10) {
|
|
||||||
override val configKeyDomain: ConfigKey.Domain
|
|
||||||
get() = ConfigKey.Domain("turktoon.com", null)
|
|
||||||
|
|
||||||
override val listUrl: String
|
|
||||||
get() = "/manga"
|
|
||||||
override val tableMode: Boolean
|
|
||||||
get() = false
|
|
||||||
override val chapterDateFormat: SimpleDateFormat = SimpleDateFormat("MMMM d, yyyy", Locale("tr", "TR"))
|
|
||||||
|
|
||||||
override suspend fun parseInfoList(docs: Document, manga: Manga, chapters: List<MangaChapter>): Manga {
|
|
||||||
val mangaState = docs.selectFirst(".info-left .tsinfo div:contains(Durum)")?.lastElementChild()?.let {
|
|
||||||
when (it.text()) {
|
|
||||||
"Devam Ediyor" -> MangaState.ONGOING
|
|
||||||
"Tamamlandı" -> MangaState.FINISHED
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return super.parseInfoList(docs, manga, chapters).copy(state = mangaState)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@MangaSourceParser("WESTMANGA", "Westmanga", "id")
|
|
||||||
class WestmangaParser(context: MangaLoaderContext) :
|
|
||||||
MangaReaderParser(context, MangaSource.WESTMANGA, pageSize = 20, searchPageSize = 10) {
|
|
||||||
override val configKeyDomain: ConfigKey.Domain
|
|
||||||
get() = ConfigKey.Domain("westmanga.info", null)
|
|
||||||
|
|
||||||
override val listUrl: String
|
|
||||||
get() = "/manga"
|
|
||||||
override val tableMode: Boolean
|
|
||||||
get() = true
|
|
||||||
override val chapterDateFormat: SimpleDateFormat = SimpleDateFormat("MMMM d, yyyy", Locale.ENGLISH)
|
|
||||||
}
|
|
||||||
|
|
||||||
@MangaSourceParser("TEMPESTFANSUB", "Tempestfansub", "tr")
|
|
||||||
class TempestfansubParser(context: MangaLoaderContext) :
|
|
||||||
MangaReaderParser(context, MangaSource.TEMPESTFANSUB, pageSize = 40, searchPageSize = 40) {
|
|
||||||
override val configKeyDomain: ConfigKey.Domain
|
|
||||||
get() = ConfigKey.Domain("tempestscans.com", null)
|
|
||||||
|
|
||||||
override val listUrl: String get() = "/manga"
|
|
||||||
override val tableMode: Boolean get() = true
|
|
||||||
override val chapterDateFormat: SimpleDateFormat = SimpleDateFormat("MMMM d, yyyy", Locale("tr", "TR"))
|
|
||||||
|
|
||||||
override suspend fun parseInfoTable(docs: Document, manga: Manga, chapters: List<MangaChapter>): Manga {
|
|
||||||
val infoElement = docs.selectFirst("div.infox")
|
|
||||||
return manga.copy(
|
|
||||||
chapters = chapters,
|
|
||||||
description = infoElement?.selectFirst("div.entry-content")?.html(),
|
|
||||||
author = infoElement?.selectFirst(".flex-wrap div:contains(Artist)")?.lastElementSibling()?.text(),
|
|
||||||
tags = infoElement?.select(".wd-full .mgen > a")
|
|
||||||
?.mapNotNullToSet { getOrCreateTagMap()[it.text()] }
|
|
||||||
.orEmpty(),
|
|
||||||
isNsfw = docs.selectFirst(".postbody .alr") != null,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@MangaSourceParser("MANHWADESU", "ManhwaDesu", "id")
|
|
||||||
class ManhwadesuParser(context: MangaLoaderContext) :
|
|
||||||
MangaReaderParser(context, MangaSource.MANHWADESU, pageSize = 20, searchPageSize = 10) {
|
|
||||||
override val configKeyDomain: ConfigKey.Domain
|
|
||||||
get() = ConfigKey.Domain("manhwadesu.pro", arrayOf("manhwadesu.pro", "manhwadesu.org"))
|
|
||||||
|
|
||||||
override val listUrl: String get() = "/komik"
|
|
||||||
override val tableMode: Boolean get() = false
|
|
||||||
}
|
|
||||||
|
|
||||||
@MangaSourceParser("MANGATALE", "MangaTale", "id")
|
|
||||||
class MangaTaleParser(context: MangaLoaderContext) :
|
|
||||||
MangaReaderParser(context, MangaSource.MANGATALE, pageSize = 20, searchPageSize = 10) {
|
|
||||||
override val configKeyDomain: ConfigKey.Domain
|
|
||||||
get() = ConfigKey.Domain("mangatale.co", null)
|
|
||||||
|
|
||||||
override val listUrl: String get() = "/manga"
|
|
||||||
override val tableMode: Boolean get() = false
|
|
||||||
|
|
||||||
override val chapterDateFormat: SimpleDateFormat = SimpleDateFormat("MMMM d, yyyy", Locale.ENGLISH)
|
|
||||||
|
|
||||||
override suspend fun parseInfoList(docs: Document, manga: Manga, chapters: List<MangaChapter>): Manga {
|
|
||||||
val infoElement = docs.selectFirst("div.infox")
|
|
||||||
return manga.copy(
|
|
||||||
chapters = chapters,
|
|
||||||
description = infoElement?.selectFirst("div.entry-content")?.html(),
|
|
||||||
author = infoElement?.selectFirst(".flex-wrap div:contains(Author)")?.lastElementSibling()?.text(),
|
|
||||||
tags = infoElement?.select(".wd-full .mgen > a")
|
|
||||||
?.mapNotNullToSet { getOrCreateTagMap()[it.text()] }
|
|
||||||
.orEmpty(),
|
|
||||||
isNsfw = docs.selectFirst(".postbody .alr") != null,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@MangaSourceParser("DRAGONTRANSLATION", "DragonTranslation", "es")
|
|
||||||
class DragonTranslationParser(context: MangaLoaderContext) :
|
|
||||||
MangaReaderParser(context, MangaSource.DRAGONTRANSLATION, pageSize = 20, searchPageSize = 10) {
|
|
||||||
override val configKeyDomain: ConfigKey.Domain
|
|
||||||
get() = ConfigKey.Domain("dragontranslation.com", null)
|
|
||||||
|
|
||||||
override val listUrl: String get() = "/manga"
|
|
||||||
override val tableMode: Boolean get() = false
|
|
||||||
|
|
||||||
override val chapterDateFormat: SimpleDateFormat = SimpleDateFormat("MMMM d, yyyy", Locale.ENGLISH)
|
|
||||||
|
|
||||||
override suspend fun parseInfoList(docs: Document, manga: Manga, chapters: List<MangaChapter>): Manga {
|
|
||||||
val infoElement = docs.selectFirst("div.infox")
|
|
||||||
return manga.copy(
|
|
||||||
chapters = chapters,
|
|
||||||
description = infoElement?.selectFirst("div.entry-content")?.html(),
|
|
||||||
author = infoElement?.selectFirst(".flex-wrap div:contains(Author)")?.lastElementSibling()?.text(),
|
|
||||||
tags = infoElement?.select(".wd-full .mgen > a")
|
|
||||||
?.mapNotNullToSet { getOrCreateTagMap()[it.text()] }
|
|
||||||
.orEmpty(),
|
|
||||||
isNsfw = docs.selectFirst(".postbody .alr") != null,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@MangaSourceParser("ASURATR", "Asura Scans (tr)", "tr")
|
|
||||||
class AsuraTRParser(context: MangaLoaderContext) :
|
|
||||||
MangaReaderParser(context, MangaSource.ASURATR, pageSize = 30, searchPageSize = 10) {
|
|
||||||
override val configKeyDomain: ConfigKey.Domain
|
|
||||||
get() = ConfigKey.Domain("asurascanstr.com", null)
|
|
||||||
|
|
||||||
override val listUrl: String get() = "/manga"
|
|
||||||
override val tableMode: Boolean get() = false
|
|
||||||
|
|
||||||
override val chapterDateFormat: SimpleDateFormat = SimpleDateFormat("MMM d, yyyy", Locale("tr"))
|
|
||||||
|
|
||||||
override suspend fun parseInfoList(docs: Document, manga: Manga, chapters: List<MangaChapter>): Manga {
|
|
||||||
val infoElement = docs.selectFirst("div.infox")
|
|
||||||
return manga.copy(
|
|
||||||
chapters = chapters,
|
|
||||||
description = infoElement?.selectFirst("div.entry-content")?.html(),
|
|
||||||
author = infoElement?.selectFirst(".flex-wrap div:contains(Yazar)")?.lastElementSibling()?.text(),
|
|
||||||
tags = infoElement?.select(".wd-full .mgen > a")
|
|
||||||
?.mapNotNullToSet { getOrCreateTagMap()[it.text()] }
|
|
||||||
.orEmpty(),
|
|
||||||
isNsfw = docs.selectFirst(".postbody .alr") != null,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@MangaSourceParser("KOMIKTAP", "KomikTap", "id")
|
|
||||||
class KomikTapParser(context: MangaLoaderContext) :
|
|
||||||
MangaReaderParser(context, MangaSource.KOMIKTAP, pageSize = 15, searchPageSize = 10) {
|
|
||||||
override val configKeyDomain: ConfigKey.Domain
|
|
||||||
get() = ConfigKey.Domain("92.87.6.124", null)
|
|
||||||
|
|
||||||
override val listUrl: String
|
|
||||||
get() = "/manga"
|
|
||||||
override val tableMode: Boolean
|
|
||||||
get() = true
|
|
||||||
|
|
||||||
override val chapterDateFormat: SimpleDateFormat = SimpleDateFormat("MMM d, yyyy", idLocale)
|
|
||||||
}
|
|
||||||
|
|
||||||
@MangaSourceParser("KUMAPOI", "KumaPoi", "id")
|
|
||||||
class KumaPoiParser(context: MangaLoaderContext) :
|
|
||||||
MangaReaderParser(context, MangaSource.KUMAPOI, pageSize = 15, searchPageSize = 10) {
|
|
||||||
override val configKeyDomain: ConfigKey.Domain
|
|
||||||
get() = ConfigKey.Domain("kumapoi.me", null)
|
|
||||||
|
|
||||||
override val listUrl: String
|
|
||||||
get() = "/manga"
|
|
||||||
override val tableMode: Boolean
|
|
||||||
get() = true
|
|
||||||
|
|
||||||
override val chapterDateFormat: SimpleDateFormat = SimpleDateFormat("MMM d, yyyy", Locale.ENGLISH)
|
|
||||||
}
|
|
||||||
|
|
||||||
@MangaSourceParser("ASURASCANS", "Asura Scans", "en")
|
|
||||||
class AsuraScansParser(context: MangaLoaderContext) :
|
|
||||||
MangaReaderParser(context, MangaSource.ASURASCANS, pageSize = 20, searchPageSize = 10) {
|
|
||||||
override val configKeyDomain: ConfigKey.Domain
|
|
||||||
get() = ConfigKey.Domain("asurascans.com", null)
|
|
||||||
|
|
||||||
override val listUrl: String
|
|
||||||
get() = "/manga"
|
|
||||||
override val tableMode: Boolean
|
|
||||||
get() = false
|
|
||||||
|
|
||||||
override val chapterDateFormat: SimpleDateFormat = SimpleDateFormat("MMM d, yyyy", Locale.ENGLISH)
|
|
||||||
|
|
||||||
override suspend fun parseInfoList(docs: Document, manga: Manga, chapters: List<MangaChapter>): Manga {
|
|
||||||
val infoElement = docs.selectFirst("div.infox")
|
|
||||||
return manga.copy(
|
|
||||||
chapters = chapters,
|
|
||||||
description = infoElement?.selectFirst("div.entry-content")?.html(),
|
|
||||||
author = infoElement?.selectFirst(".flex-wrap div:contains(Author)")?.lastElementSibling()?.text(),
|
|
||||||
tags = infoElement?.select(".wd-full .mgen > a")
|
|
||||||
?.mapNotNullToSet { getOrCreateTagMap()[it.text()] }
|
|
||||||
.orEmpty(),
|
|
||||||
isNsfw = docs.selectFirst(".postbody .alr") != null,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@MangaSourceParser("TOONHUNTER", "Toon Hunter", "th")
|
|
||||||
class ToonHunterParser(context: MangaLoaderContext) :
|
|
||||||
MangaReaderParser(context, MangaSource.TOONHUNTER, pageSize = 20, searchPageSize = 10) {
|
|
||||||
override val configKeyDomain: ConfigKey.Domain
|
|
||||||
get() = ConfigKey.Domain("toonhunter.com", null)
|
|
||||||
|
|
||||||
override val listUrl: String
|
|
||||||
get() = "/manga"
|
|
||||||
override val tableMode: Boolean
|
|
||||||
get() = false
|
|
||||||
|
|
||||||
override val chapterDateFormat: SimpleDateFormat = SimpleDateFormat("MMM d, yyyy", sourceLocale)
|
|
||||||
|
|
||||||
override suspend fun parseInfoList(docs: Document, manga: Manga, chapters: List<MangaChapter>): Manga {
|
|
||||||
val infoElement = docs.selectFirst("div.infox")
|
|
||||||
return manga.copy(
|
|
||||||
chapters = chapters,
|
|
||||||
description = infoElement?.selectFirst("div.entry-content")?.html(),
|
|
||||||
author = infoElement?.selectFirst(".flex-wrap div:contains(Author)")?.lastElementSibling()?.text(),
|
|
||||||
isNsfw = docs.selectFirst(".postbody .alr") != null,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@MangaSourceParser("COSMICSCANS", "CosmicScans", "en")
|
|
||||||
class CosmicScansParser(context: MangaLoaderContext) :
|
|
||||||
MangaReaderParser(context, MangaSource.COSMICSCANS, pageSize = 20, searchPageSize = 10) {
|
|
||||||
override val configKeyDomain: ConfigKey.Domain
|
|
||||||
get() = ConfigKey.Domain("cosmicscans.com", null)
|
|
||||||
|
|
||||||
override val listUrl: String
|
|
||||||
get() = "/manga"
|
|
||||||
override val tableMode: Boolean
|
|
||||||
get() = false
|
|
||||||
|
|
||||||
override val chapterDateFormat: SimpleDateFormat = SimpleDateFormat("MMM d, yyyy", Locale.ENGLISH)
|
|
||||||
|
|
||||||
override suspend fun parseInfoList(docs: Document, manga: Manga, chapters: List<MangaChapter>): Manga {
|
|
||||||
val infoElement = docs.selectFirst("div.infox")
|
|
||||||
return manga.copy(
|
|
||||||
chapters = chapters,
|
|
||||||
description = infoElement?.selectFirst("div.entry-content")?.html(),
|
|
||||||
author = infoElement?.selectFirst(".flex-wrap div:contains(Author)")?.lastElementSibling()?.text(),
|
|
||||||
tags = infoElement?.select(".wd-full .mgen > a")
|
|
||||||
?.mapNotNullToSet { getOrCreateTagMap()[it.text()] }
|
|
||||||
.orEmpty(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@MangaSourceParser("KOMIKLOKAL", "KomikLokal", "id")
|
|
||||||
class KomikLokalParser(context: MangaLoaderContext) :
|
|
||||||
MangaReaderParser(context, MangaSource.KOMIKLOKAL, pageSize = 20, searchPageSize = 10) {
|
|
||||||
override val configKeyDomain: ConfigKey.Domain
|
|
||||||
get() = ConfigKey.Domain("komikmirror.art", null)
|
|
||||||
|
|
||||||
override val listUrl: String
|
|
||||||
get() = "/manga"
|
|
||||||
override val tableMode: Boolean
|
|
||||||
get() = false
|
|
||||||
|
|
||||||
override val chapterDateFormat: SimpleDateFormat = SimpleDateFormat("MMM d, yyyy", Locale.ENGLISH)
|
|
||||||
|
|
||||||
override suspend fun parseInfoList(docs: Document, manga: Manga, chapters: List<MangaChapter>): Manga {
|
|
||||||
val infoElement = docs.selectFirst("div.infox")
|
|
||||||
return manga.copy(
|
|
||||||
chapters = chapters,
|
|
||||||
tags = infoElement?.select(".wd-full .mgen > a")
|
|
||||||
?.mapNotNullToSet { getOrCreateTagMap()[it.text()] }
|
|
||||||
.orEmpty(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@MangaSourceParser("KOMIKAV", "KomiKav", "id")
|
|
||||||
class KomiKavParser(context: MangaLoaderContext) :
|
|
||||||
MangaReaderParser(context, MangaSource.KOMIKAV, pageSize = 20, searchPageSize = 10) {
|
|
||||||
override val configKeyDomain: ConfigKey.Domain
|
|
||||||
get() = ConfigKey.Domain("komikav.com", null)
|
|
||||||
|
|
||||||
override val listUrl: String
|
|
||||||
get() = "/manga"
|
|
||||||
override val tableMode: Boolean
|
|
||||||
get() = false
|
|
||||||
|
|
||||||
override val chapterDateFormat: SimpleDateFormat = SimpleDateFormat("MMM d, yyyy", Locale.ENGLISH)
|
|
||||||
|
|
||||||
override suspend fun parseInfoList(docs: Document, manga: Manga, chapters: List<MangaChapter>): Manga {
|
|
||||||
val infoElement = docs.selectFirst("div.infox")
|
|
||||||
return manga.copy(
|
|
||||||
chapters = chapters,
|
|
||||||
description = infoElement?.selectFirst("div.entry-content")?.html(),
|
|
||||||
author = infoElement?.selectFirst(".flex-wrap div:contains(Author)")?.lastElementSibling()?.text(),
|
|
||||||
tags = infoElement?.select(".wd-full .mgen > a")
|
|
||||||
?.mapNotNullToSet { getOrCreateTagMap()[it.text()] }
|
|
||||||
.orEmpty(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@MangaSourceParser("KOMIKDEWASA", "KomikDewasa", "id")
|
|
||||||
class KomikDewasaParser(context: MangaLoaderContext) :
|
|
||||||
MangaReaderParser(context, MangaSource.KOMIKDEWASA, pageSize = 20, searchPageSize = 10) {
|
|
||||||
override val configKeyDomain: ConfigKey.Domain
|
|
||||||
get() = ConfigKey.Domain("komikdewasa.us", arrayOf("komikdewasa.us", "komikdewasa.info"))
|
|
||||||
|
|
||||||
override val listUrl: String
|
|
||||||
get() = "/manga"
|
|
||||||
override val tableMode: Boolean
|
|
||||||
get() = false
|
|
||||||
|
|
||||||
override val chapterDateFormat: SimpleDateFormat = SimpleDateFormat("MMM d, yyyy", Locale.ENGLISH)
|
|
||||||
|
|
||||||
override suspend fun parseInfoList(docs: Document, manga: Manga, chapters: List<MangaChapter>): Manga {
|
|
||||||
val infoElement = docs.selectFirst("div.infox")
|
|
||||||
return manga.copy(
|
|
||||||
chapters = chapters,
|
|
||||||
description = infoElement?.selectFirst("div.entry-content")?.html(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@MangaSourceParser("MANGASUSU", "Mangasusu", "id")
|
|
||||||
class MangasusuParser(context: MangaLoaderContext) :
|
|
||||||
MangaReaderParser(context, MangaSource.MANGASUSU, pageSize = 20, searchPageSize = 10) {
|
|
||||||
override val configKeyDomain: ConfigKey.Domain
|
|
||||||
get() = ConfigKey.Domain("mangasusu.co.in", null)
|
|
||||||
|
|
||||||
override val listUrl: String
|
|
||||||
get() = "/project"
|
|
||||||
override val tableMode: Boolean
|
|
||||||
get() = true
|
|
||||||
|
|
||||||
override val chapterDateFormat: SimpleDateFormat = SimpleDateFormat("MMM d, yyyy", Locale.ENGLISH)
|
|
||||||
}
|
|
||||||
|
|
||||||
@MangaSourceParser("KOMIKLAB", "KomikLab", "id")
|
|
||||||
class KomikLabParser(context: MangaLoaderContext) :
|
|
||||||
MangaReaderParser(context, MangaSource.KOMIKLAB, pageSize = 20, searchPageSize = 10) {
|
|
||||||
override val configKeyDomain: ConfigKey.Domain
|
|
||||||
get() = ConfigKey.Domain("komiklab.com", null)
|
|
||||||
|
|
||||||
override val listUrl: String
|
|
||||||
get() = "/project"
|
|
||||||
override val tableMode: Boolean
|
|
||||||
get() = true
|
|
||||||
|
|
||||||
override val chapterDateFormat: SimpleDateFormat = SimpleDateFormat("MMM d, yyyy", Locale.ENGLISH)
|
|
||||||
}
|
|
||||||
|
|
||||||
@MangaSourceParser("KOMIKINDO", "KomikIndo", "id")
|
|
||||||
class KomikIndoParser(context: MangaLoaderContext) :
|
|
||||||
MangaReaderParser(context, MangaSource.KOMIKINDO, pageSize = 20, searchPageSize = 10) {
|
|
||||||
override val configKeyDomain: ConfigKey.Domain
|
|
||||||
get() = ConfigKey.Domain("komikindo.co", null)
|
|
||||||
|
|
||||||
override val listUrl: String
|
|
||||||
get() = "/project"
|
|
||||||
override val tableMode: Boolean
|
|
||||||
get() = true
|
|
||||||
|
|
||||||
override val chapterDateFormat: SimpleDateFormat = SimpleDateFormat("MMM d, yyyy", idLocale)
|
|
||||||
}
|
|
||||||
|
|
||||||
@MangaSourceParser("KOMIKMANGA", "KomikManga", "id")
|
|
||||||
class KomikMangaParser(context: MangaLoaderContext) :
|
|
||||||
MangaReaderParser(context, MangaSource.KOMIKMANGA, pageSize = 20, searchPageSize = 10) {
|
|
||||||
override val configKeyDomain: ConfigKey.Domain
|
|
||||||
get() = ConfigKey.Domain("komikhentai.co", null)
|
|
||||||
|
|
||||||
override val listUrl: String
|
|
||||||
get() = "/project"
|
|
||||||
override val tableMode: Boolean
|
|
||||||
get() = true
|
|
||||||
|
|
||||||
override val chapterDateFormat: SimpleDateFormat = SimpleDateFormat("MMM d, yyyy", Locale.ENGLISH)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
package org.koitharu.kotatsu.parsers.site.multichan
|
package org.koitharu.kotatsu.parsers.site.multichan
|
||||||
|
|
||||||
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
|
||||||
import org.koitharu.kotatsu.parsers.MangaParser
|
import org.koitharu.kotatsu.parsers.MangaParser
|
||||||
import org.koitharu.kotatsu.parsers.MangaParserAuthProvider
|
import org.koitharu.kotatsu.parsers.MangaParserAuthProvider
|
||||||
import org.koitharu.kotatsu.parsers.exception.AuthRequiredException
|
import org.koitharu.kotatsu.parsers.exception.AuthRequiredException
|
||||||
@@ -9,10 +8,7 @@ import org.koitharu.kotatsu.parsers.util.*
|
|||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
import java.util.*
|
import java.util.*
|
||||||
|
|
||||||
internal abstract class ChanParser(
|
internal abstract class ChanParser(source: MangaSource) : MangaParser(source), MangaParserAuthProvider {
|
||||||
context: MangaLoaderContext,
|
|
||||||
source: MangaSource,
|
|
||||||
) : MangaParser(context, source), MangaParserAuthProvider {
|
|
||||||
|
|
||||||
override val sortOrders: Set<SortOrder> = EnumSet.of(
|
override val sortOrders: Set<SortOrder> = EnumSet.of(
|
||||||
SortOrder.NEWEST,
|
SortOrder.NEWEST,
|
||||||
@@ -21,10 +17,10 @@ internal abstract class ChanParser(
|
|||||||
)
|
)
|
||||||
|
|
||||||
override val authUrl: String
|
override val authUrl: String
|
||||||
get() = "https://${domain}"
|
get() = "https://${getDomain()}"
|
||||||
|
|
||||||
override val isAuthorized: Boolean
|
override val isAuthorized: Boolean
|
||||||
get() = context.cookieJar.getCookies(domain).any { it.name == "dle_user_id" }
|
get() = context.cookieJar.getCookies(getDomain()).any { it.name == "dle_user_id" }
|
||||||
|
|
||||||
override suspend fun getList(
|
override suspend fun getList(
|
||||||
offset: Int,
|
offset: Int,
|
||||||
@@ -32,7 +28,7 @@ internal abstract class ChanParser(
|
|||||||
tags: Set<MangaTag>?,
|
tags: Set<MangaTag>?,
|
||||||
sortOrder: SortOrder,
|
sortOrder: SortOrder,
|
||||||
): List<Manga> {
|
): List<Manga> {
|
||||||
val domain = domain
|
val domain = getDomain()
|
||||||
val url = when {
|
val url = when {
|
||||||
!query.isNullOrEmpty() -> {
|
!query.isNullOrEmpty() -> {
|
||||||
if (offset != 0) {
|
if (offset != 0) {
|
||||||
@@ -49,7 +45,7 @@ internal abstract class ChanParser(
|
|||||||
|
|
||||||
else -> "https://$domain/${getSortKey(sortOrder)}?offset=$offset"
|
else -> "https://$domain/${getSortKey(sortOrder)}?offset=$offset"
|
||||||
}
|
}
|
||||||
val doc = webClient.httpGet(url).parseHtml()
|
val doc = context.httpGet(url).parseHtml()
|
||||||
val root = doc.body().selectFirst("div.main_fon")?.getElementById("content")
|
val root = doc.body().selectFirst("div.main_fon")?.getElementById("content")
|
||||||
?: doc.parseFailed("Cannot find root")
|
?: doc.parseFailed("Cannot find root")
|
||||||
return root.select("div.content_row").mapNotNull { row ->
|
return root.select("div.content_row").mapNotNull { row ->
|
||||||
@@ -86,13 +82,13 @@ internal abstract class ChanParser(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getDetails(manga: Manga): Manga {
|
override suspend fun getDetails(manga: Manga): Manga {
|
||||||
val doc = webClient.httpGet(manga.url.toAbsoluteUrl(domain)).parseHtml()
|
val doc = context.httpGet(manga.url.toAbsoluteUrl(getDomain())).parseHtml()
|
||||||
val root = doc.body().getElementById("dle-content") ?: doc.parseFailed("Cannot find root")
|
val root = doc.body().getElementById("dle-content") ?: doc.parseFailed("Cannot find root")
|
||||||
val dateFormat = SimpleDateFormat("yyyy-MM-dd", Locale.US)
|
val dateFormat = SimpleDateFormat("yyyy-MM-dd", Locale.US)
|
||||||
return manga.copy(
|
return manga.copy(
|
||||||
description = root.getElementById("description")?.html()?.substringBeforeLast("<div"),
|
description = root.getElementById("description")?.html()?.substringBeforeLast("<div"),
|
||||||
largeCoverUrl = root.getElementById("cover")?.absUrl("src"),
|
largeCoverUrl = root.getElementById("cover")?.absUrl("src"),
|
||||||
chapters = root.select("table.table_cha tr:gt(1)").mapChapters(reversed = true) { i, tr ->
|
chapters = root.select("table.table_cha tr:gt(1)").reversed().mapChapters { i, tr ->
|
||||||
val href = tr?.selectFirst("a")?.attrAsRelativeUrlOrNull("href")
|
val href = tr?.selectFirst("a")?.attrAsRelativeUrlOrNull("href")
|
||||||
?: return@mapChapters null
|
?: return@mapChapters null
|
||||||
MangaChapter(
|
MangaChapter(
|
||||||
@@ -110,8 +106,8 @@ internal abstract class ChanParser(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
|
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
|
||||||
val fullUrl = chapter.url.toAbsoluteUrl(domain)
|
val fullUrl = chapter.url.toAbsoluteUrl(getDomain())
|
||||||
val doc = webClient.httpGet(fullUrl).parseHtml()
|
val doc = context.httpGet(fullUrl).parseHtml()
|
||||||
val scripts = doc.select("script")
|
val scripts = doc.select("script")
|
||||||
for (script in scripts) {
|
for (script in scripts) {
|
||||||
val data = script.html()
|
val data = script.html()
|
||||||
@@ -121,7 +117,7 @@ internal abstract class ChanParser(
|
|||||||
}
|
}
|
||||||
val json = data.substring(pos).substringAfter('[').substringBefore(';')
|
val json = data.substring(pos).substringAfter('[').substringBefore(';')
|
||||||
.substringBeforeLast(']')
|
.substringBeforeLast(']')
|
||||||
val domain = domain
|
val domain = getDomain()
|
||||||
return json.split(",").mapNotNull {
|
return json.split(",").mapNotNull {
|
||||||
it.trim()
|
it.trim()
|
||||||
.removeSurrounding('"', '\'')
|
.removeSurrounding('"', '\'')
|
||||||
@@ -132,6 +128,7 @@ internal abstract class ChanParser(
|
|||||||
id = generateUid(url),
|
id = generateUid(url),
|
||||||
url = url,
|
url = url,
|
||||||
preview = null,
|
preview = null,
|
||||||
|
referer = fullUrl,
|
||||||
source = source,
|
source = source,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -140,8 +137,8 @@ internal abstract class ChanParser(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getTags(): Set<MangaTag> {
|
override suspend fun getTags(): Set<MangaTag> {
|
||||||
val domain = domain
|
val domain = getDomain()
|
||||||
val doc = webClient.httpGet("https://$domain/mostfavorites&sort=manga").parseHtml()
|
val doc = context.httpGet("https://$domain/mostfavorites&sort=manga").parseHtml()
|
||||||
val root = doc.body().selectFirst("div.main_fon")?.getElementById("side")
|
val root = doc.body().selectFirst("div.main_fon")?.getElementById("side")
|
||||||
?.select("ul")?.last() ?: doc.parseFailed("Cannot find root")
|
?.select("ul")?.last() ?: doc.parseFailed("Cannot find root")
|
||||||
return root.select("li.sidetag").mapToSet { li ->
|
return root.select("li.sidetag").mapToSet { li ->
|
||||||
@@ -155,7 +152,7 @@ internal abstract class ChanParser(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getUsername(): String {
|
override suspend fun getUsername(): String {
|
||||||
val doc = webClient.httpGet("https://${domain}").parseHtml().body()
|
val doc = context.httpGet("https://${getDomain()}").parseHtml().body()
|
||||||
val root = doc.requireElementById("top_user")
|
val root = doc.requireElementById("top_user")
|
||||||
val a = root.getElementsByAttributeValueContaining("href", "/user/").firstOrNull()
|
val a = root.getElementsByAttributeValueContaining("href", "/user/").firstOrNull()
|
||||||
?: throw AuthRequiredException(source)
|
?: throw AuthRequiredException(source)
|
||||||
@@ -179,4 +176,4 @@ internal abstract class ChanParser(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun String.toTagName() = replace('_', ' ').toTitleCase()
|
private fun String.toTagName() = replace('_', ' ').toTitleCase()
|
||||||
}
|
}
|
||||||
@@ -7,54 +7,54 @@ import org.koitharu.kotatsu.parsers.model.*
|
|||||||
import org.koitharu.kotatsu.parsers.util.*
|
import org.koitharu.kotatsu.parsers.util.*
|
||||||
|
|
||||||
@MangaSourceParser("HENCHAN", "Хентай-тян", "ru")
|
@MangaSourceParser("HENCHAN", "Хентай-тян", "ru")
|
||||||
internal class HenChanParser(context: MangaLoaderContext) : ChanParser(context, MangaSource.HENCHAN) {
|
internal class HenChanParser(override val context: MangaLoaderContext) : ChanParser(MangaSource.HENCHAN) {
|
||||||
|
|
||||||
override val configKeyDomain = ConfigKey.Domain(
|
override val configKeyDomain = ConfigKey.Domain(
|
||||||
"y.hentaichan.live",
|
"y.hentaichan.live",
|
||||||
arrayOf("y.hentaichan.live", "xxx.hentaichan.live", "xx.hentaichan.live", "hentaichan.live", "hentaichan.pro"),
|
arrayOf("y.hentaichan.live", "xxx.hentaichan.live", "xx.hentaichan.live", "hentaichan.live", "hentaichan.pro"),
|
||||||
)
|
)
|
||||||
|
|
||||||
override suspend fun getList(
|
override suspend fun getList(
|
||||||
offset: Int,
|
offset: Int,
|
||||||
query: String?,
|
query: String?,
|
||||||
tags: Set<MangaTag>?,
|
tags: Set<MangaTag>?,
|
||||||
sortOrder: SortOrder,
|
sortOrder: SortOrder,
|
||||||
): List<Manga> {
|
): List<Manga> {
|
||||||
return super.getList(offset, query, tags, sortOrder).map {
|
return super.getList(offset, query, tags, sortOrder).map {
|
||||||
it.copy(
|
it.copy(
|
||||||
coverUrl = it.coverUrl.replace("_blur", ""),
|
coverUrl = it.coverUrl.replace("_blur", ""),
|
||||||
isNsfw = true,
|
isNsfw = true,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getDetails(manga: Manga): Manga {
|
override suspend fun getDetails(manga: Manga): Manga {
|
||||||
val doc = webClient.httpGet(manga.url.toAbsoluteUrl(domain)).parseHtml()
|
val doc = context.httpGet(manga.url.toAbsoluteUrl(getDomain())).parseHtml()
|
||||||
val root = doc.body().requireElementById("dle-content")
|
val root = doc.body().requireElementById("dle-content")
|
||||||
val readLink = manga.url.replace("manga", "online")
|
val readLink = manga.url.replace("manga", "online")
|
||||||
return manga.copy(
|
return manga.copy(
|
||||||
description = root.getElementById("description")?.html()?.substringBeforeLast("<div"),
|
description = root.getElementById("description")?.html()?.substringBeforeLast("<div"),
|
||||||
largeCoverUrl = root.getElementById("cover")?.absUrl("src"),
|
largeCoverUrl = root.getElementById("cover")?.absUrl("src"),
|
||||||
tags = root.selectFirst("div.sidetags")?.select("li.sidetag")?.mapToSet {
|
tags = root.selectFirst("div.sidetags")?.select("li.sidetag")?.mapToSet {
|
||||||
val a = it.children().last() ?: doc.parseFailed("Invalid tag")
|
val a = it.children().last() ?: doc.parseFailed("Invalid tag")
|
||||||
MangaTag(
|
MangaTag(
|
||||||
title = a.text().toTitleCase(),
|
title = a.text().toTitleCase(),
|
||||||
key = a.attr("href").substringAfterLast('/'),
|
key = a.attr("href").substringAfterLast('/'),
|
||||||
source = source,
|
source = source,
|
||||||
)
|
)
|
||||||
} ?: manga.tags,
|
} ?: manga.tags,
|
||||||
chapters = listOf(
|
chapters = listOf(
|
||||||
MangaChapter(
|
MangaChapter(
|
||||||
id = generateUid(readLink),
|
id = generateUid(readLink),
|
||||||
url = readLink,
|
url = readLink,
|
||||||
source = source,
|
source = source,
|
||||||
number = 1,
|
number = 1,
|
||||||
uploadDate = 0L,
|
uploadDate = 0L,
|
||||||
name = manga.title,
|
name = manga.title,
|
||||||
scanlator = null,
|
scanlator = null,
|
||||||
branch = null,
|
branch = null,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -6,7 +6,7 @@ import org.koitharu.kotatsu.parsers.config.ConfigKey
|
|||||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
|
|
||||||
@MangaSourceParser("MANGACHAN", "Манга-тян", "ru")
|
@MangaSourceParser("MANGACHAN", "Манга-тян", "ru")
|
||||||
internal class MangaChanParser(context: MangaLoaderContext) : ChanParser(context, MangaSource.MANGACHAN) {
|
internal class MangaChanParser(override val context: MangaLoaderContext) : ChanParser(MangaSource.MANGACHAN) {
|
||||||
|
|
||||||
override val configKeyDomain = ConfigKey.Domain("manga-chan.me", null)
|
override val configKeyDomain = ConfigKey.Domain("manga-chan.me", null)
|
||||||
}
|
}
|
||||||
@@ -9,19 +9,19 @@ import org.koitharu.kotatsu.parsers.model.MangaSource
|
|||||||
import org.koitharu.kotatsu.parsers.util.*
|
import org.koitharu.kotatsu.parsers.util.*
|
||||||
|
|
||||||
@MangaSourceParser("YAOICHAN", "Яой-тян", "ru")
|
@MangaSourceParser("YAOICHAN", "Яой-тян", "ru")
|
||||||
internal class YaoiChanParser(context: MangaLoaderContext) : ChanParser(context, MangaSource.YAOICHAN) {
|
internal class YaoiChanParser(override val context: MangaLoaderContext) : ChanParser(MangaSource.YAOICHAN) {
|
||||||
|
|
||||||
override val configKeyDomain = ConfigKey.Domain("yaoi-chan.me", null)
|
override val configKeyDomain = ConfigKey.Domain("yaoi-chan.me", null)
|
||||||
|
|
||||||
override suspend fun getDetails(manga: Manga): Manga {
|
override suspend fun getDetails(manga: Manga): Manga {
|
||||||
val doc = webClient.httpGet(manga.url.toAbsoluteUrl(domain)).parseHtml()
|
val doc = context.httpGet(manga.url.toAbsoluteUrl(getDomain())).parseHtml()
|
||||||
val root = doc.body().requireElementById("dle-content")
|
val root = doc.body().requireElementById("dle-content")
|
||||||
return manga.copy(
|
return manga.copy(
|
||||||
description = root.getElementById("description")?.html()?.substringBeforeLast("<div"),
|
description = root.getElementById("description")?.html()?.substringBeforeLast("<div"),
|
||||||
largeCoverUrl = root.getElementById("cover")?.absUrl("src"),
|
largeCoverUrl = root.getElementById("cover")?.absUrl("src"),
|
||||||
chapters = root.select("table.table_cha").flatMap { table ->
|
chapters = root.select("table.table_cha").flatMap { table ->
|
||||||
table.select("div.manga")
|
table.select("div.manga")
|
||||||
}.mapNotNull { it.selectFirst("a") }.mapChapters(reversed = true) { i, a ->
|
}.mapNotNull { it.selectFirst("a") }.reversed().mapChapters { i, a ->
|
||||||
val href = a.attrAsRelativeUrl("href")
|
val href = a.attrAsRelativeUrl("href")
|
||||||
MangaChapter(
|
MangaChapter(
|
||||||
id = generateUid(href),
|
id = generateUid(href),
|
||||||
@@ -36,4 +36,4 @@ internal class YaoiChanParser(context: MangaLoaderContext) : ChanParser(context,
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -20,14 +20,14 @@ import java.text.SimpleDateFormat
|
|||||||
import java.util.*
|
import java.util.*
|
||||||
|
|
||||||
internal open class MangaLibParser(
|
internal open class MangaLibParser(
|
||||||
context: MangaLoaderContext,
|
override val context: MangaLoaderContext,
|
||||||
source: MangaSource,
|
source: MangaSource,
|
||||||
) : PagedMangaParser(context, source, pageSize = 60), MangaParserAuthProvider {
|
) : PagedMangaParser(source, pageSize = 60), MangaParserAuthProvider {
|
||||||
|
|
||||||
override val configKeyDomain = ConfigKey.Domain("mangalib.me", null)
|
override val configKeyDomain = ConfigKey.Domain("mangalib.me", null)
|
||||||
|
|
||||||
override val authUrl: String
|
override val authUrl: String
|
||||||
get() = "https://${domain}/login"
|
get() = "https://${getDomain()}/login"
|
||||||
|
|
||||||
override val sortOrders: Set<SortOrder> = EnumSet.of(
|
override val sortOrders: Set<SortOrder> = EnumSet.of(
|
||||||
SortOrder.RATING,
|
SortOrder.RATING,
|
||||||
@@ -48,7 +48,7 @@ internal open class MangaLibParser(
|
|||||||
}
|
}
|
||||||
val url = buildString {
|
val url = buildString {
|
||||||
append("https://")
|
append("https://")
|
||||||
append(domain)
|
append(getDomain())
|
||||||
append("/manga-list?dir=")
|
append("/manga-list?dir=")
|
||||||
append(getSortKey(sortOrder))
|
append(getSortKey(sortOrder))
|
||||||
append("&page=")
|
append("&page=")
|
||||||
@@ -58,7 +58,7 @@ internal open class MangaLibParser(
|
|||||||
append(tag.key)
|
append(tag.key)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val doc = webClient.httpGet(url).parseHtml()
|
val doc = context.httpGet(url).parseHtml()
|
||||||
val root = doc.body().getElementById("manga-list") ?: doc.parseFailed("Root not found")
|
val root = doc.body().getElementById("manga-list") ?: doc.parseFailed("Root not found")
|
||||||
val items = root.selectFirst("div.media-cards-grid")?.select("div.media-card-wrap")
|
val items = root.selectFirst("div.media-cards-grid")?.select("div.media-card-wrap")
|
||||||
?: return emptyList()
|
?: return emptyList()
|
||||||
@@ -73,7 +73,7 @@ internal open class MangaLibParser(
|
|||||||
author = null,
|
author = null,
|
||||||
rating = RATING_UNKNOWN,
|
rating = RATING_UNKNOWN,
|
||||||
url = href,
|
url = href,
|
||||||
publicUrl = href.toAbsoluteUrl(a.host ?: domain),
|
publicUrl = href.toAbsoluteUrl(a.host ?: getDomain()),
|
||||||
tags = emptySet(),
|
tags = emptySet(),
|
||||||
state = null,
|
state = null,
|
||||||
isNsfw = false,
|
isNsfw = false,
|
||||||
@@ -83,12 +83,12 @@ internal open class MangaLibParser(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getDetails(manga: Manga): Manga {
|
override suspend fun getDetails(manga: Manga): Manga {
|
||||||
val fullUrl = manga.url.toAbsoluteUrl(domain)
|
val fullUrl = manga.url.toAbsoluteUrl(getDomain())
|
||||||
val doc = webClient.httpGet("$fullUrl?section=info").parseHtml()
|
val doc = context.httpGet("$fullUrl?section=info").parseHtml()
|
||||||
val root = doc.body().getElementById("main-page") ?: throw ParseException("Root not found", fullUrl)
|
val root = doc.body().getElementById("main-page") ?: throw ParseException("Root not found", fullUrl)
|
||||||
val title = root.selectFirst("div.media-header__wrap")?.children()
|
val title = root.selectFirst("div.media-header__wrap")?.children()
|
||||||
val info = root.selectFirst("div.media-content")
|
val info = root.selectFirst("div.media-content")
|
||||||
val chaptersDoc = webClient.httpGet("$fullUrl?section=chapters").parseHtml()
|
val chaptersDoc = context.httpGet("$fullUrl?section=chapters").parseHtml()
|
||||||
val scripts = chaptersDoc.select("script")
|
val scripts = chaptersDoc.select("script")
|
||||||
val dateFormat = SimpleDateFormat("yyy-MM-dd", Locale.US)
|
val dateFormat = SimpleDateFormat("yyy-MM-dd", Locale.US)
|
||||||
var chapters: ChaptersListBuilder? = null
|
var chapters: ChaptersListBuilder? = null
|
||||||
@@ -98,7 +98,6 @@ internal open class MangaLibParser(
|
|||||||
if (line.startsWith("window.__DATA__")) {
|
if (line.startsWith("window.__DATA__")) {
|
||||||
val json = JSONObject(line.substringAfter('=').substringBeforeLast(';'))
|
val json = JSONObject(line.substringAfter('=').substringBeforeLast(';'))
|
||||||
val list = json.getJSONObject("chapters").getJSONArray("list")
|
val list = json.getJSONObject("chapters").getJSONArray("list")
|
||||||
val id = json.optJSONObject("user")?.getLong("id")?.toString() ?: "not"
|
|
||||||
val total = list.length()
|
val total = list.length()
|
||||||
chapters = ChaptersListBuilder(total)
|
chapters = ChaptersListBuilder(total)
|
||||||
for (i in 0 until total) {
|
for (i in 0 until total) {
|
||||||
@@ -106,21 +105,13 @@ internal open class MangaLibParser(
|
|||||||
val chapterId = item.getLong("chapter_id")
|
val chapterId = item.getLong("chapter_id")
|
||||||
val scanlator = item.getStringOrNull("username")
|
val scanlator = item.getStringOrNull("username")
|
||||||
val url = buildString {
|
val url = buildString {
|
||||||
if (isAuthorized) {
|
append(manga.url)
|
||||||
append(manga.url)
|
append("/v")
|
||||||
append("/v")
|
append(item.getInt("chapter_volume"))
|
||||||
append(item.getInt("chapter_volume"))
|
append("/c")
|
||||||
append("/c")
|
append(item.getString("chapter_number"))
|
||||||
append(item.getString("chapter_number"))
|
append('/')
|
||||||
append("?ui=")
|
append(item.optString("chapter_string"))
|
||||||
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 nameChapter = item.getStringOrNull("chapter_name")
|
||||||
val volume = item.getInt("chapter_volume")
|
val volume = item.getInt("chapter_volume")
|
||||||
@@ -172,8 +163,8 @@ internal open class MangaLibParser(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
|
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
|
||||||
val fullUrl = chapter.url.toAbsoluteUrl(domain)
|
val fullUrl = chapter.url.toAbsoluteUrl(getDomain())
|
||||||
val doc = webClient.httpGet(fullUrl).parseHtml()
|
val doc = context.httpGet(fullUrl).parseHtml()
|
||||||
if (doc.location().substringBefore('?').endsWith("/register")) {
|
if (doc.location().substringBefore('?').endsWith("/register")) {
|
||||||
throw AuthRequiredException(source)
|
throw AuthRequiredException(source)
|
||||||
}
|
}
|
||||||
@@ -202,6 +193,7 @@ internal open class MangaLibParser(
|
|||||||
id = generateUid(pageUrl),
|
id = generateUid(pageUrl),
|
||||||
url = pageUrl,
|
url = pageUrl,
|
||||||
preview = null,
|
preview = null,
|
||||||
|
referer = fullUrl,
|
||||||
source = source,
|
source = source,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -211,8 +203,8 @@ internal open class MangaLibParser(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getTags(): Set<MangaTag> {
|
override suspend fun getTags(): Set<MangaTag> {
|
||||||
val url = "https://${domain}/manga-list"
|
val url = "https://${getDomain()}/manga-list"
|
||||||
val doc = webClient.httpGet(url).parseHtml()
|
val doc = context.httpGet(url).parseHtml()
|
||||||
val scripts = doc.body().select("script")
|
val scripts = doc.body().select("script")
|
||||||
for (script in scripts) {
|
for (script in scripts) {
|
||||||
val raw = script.html().trim()
|
val raw = script.html().trim()
|
||||||
@@ -235,13 +227,13 @@ internal open class MangaLibParser(
|
|||||||
|
|
||||||
override val isAuthorized: Boolean
|
override val isAuthorized: Boolean
|
||||||
get() {
|
get() {
|
||||||
return context.cookieJar.getCookies(domain).any {
|
return context.cookieJar.getCookies(getDomain()).any {
|
||||||
it.name.startsWith("remember_web_")
|
it.name.startsWith("remember_web_")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getUsername(): String {
|
override suspend fun getUsername(): String {
|
||||||
val body = webClient.httpGet("https://${LibConst.LIB_SOCIAL_LINK}/messages").parseHtml().body()
|
val body = context.httpGet("https://${getDomain()}/messages").parseHtml().body()
|
||||||
if (body.baseUri().endsWith("/login")) {
|
if (body.baseUri().endsWith("/login")) {
|
||||||
throw AuthRequiredException(source)
|
throw AuthRequiredException(source)
|
||||||
}
|
}
|
||||||
@@ -265,8 +257,8 @@ internal open class MangaLibParser(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun search(query: String): List<Manga> {
|
private suspend fun search(query: String): List<Manga> {
|
||||||
val domain = domain
|
val domain = getDomain()
|
||||||
val json = webClient.httpGet("https://$domain/search?type=manga&q=$query")
|
val json = context.httpGet("https://$domain/search?type=manga&q=$query")
|
||||||
.parseJsonArray()
|
.parseJsonArray()
|
||||||
return json.mapJSON { jo ->
|
return json.mapJSON { jo ->
|
||||||
val slug = jo.getString("slug")
|
val slug = jo.getString("slug")
|
||||||
@@ -293,10 +285,4 @@ internal open class MangaLibParser(
|
|||||||
|
|
||||||
@MangaSourceParser("MANGALIB", "MangaLib", "ru")
|
@MangaSourceParser("MANGALIB", "MangaLib", "ru")
|
||||||
class Impl(context: MangaLoaderContext) : MangaLibParser(context, MangaSource.MANGALIB)
|
class Impl(context: MangaLoaderContext) : MangaLibParser(context, MangaSource.MANGALIB)
|
||||||
|
}
|
||||||
object LibConst {
|
|
||||||
|
|
||||||
val LIB_SOCIAL_LINK = "lib.social"
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.parsers.util
|
|
||||||
|
|
||||||
fun <T : Any> T?.assertNotNull(name: String): T? {
|
|
||||||
assert(this != null) {
|
|
||||||
"Value $name is null"
|
|
||||||
}
|
|
||||||
return this
|
|
||||||
}
|
|
||||||
@@ -4,14 +4,10 @@ import org.koitharu.kotatsu.parsers.InternalParsersApi
|
|||||||
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
||||||
|
|
||||||
@InternalParsersApi
|
@InternalParsersApi
|
||||||
inline fun <T> List<T>.mapChapters(
|
inline fun <T> Iterable<T>.mapChapters(transform: (index: Int, T) -> MangaChapter?): List<MangaChapter> {
|
||||||
reversed: Boolean = false,
|
|
||||||
transform: (index: Int, T) -> MangaChapter?,
|
|
||||||
): List<MangaChapter> {
|
|
||||||
val builder = ChaptersListBuilder(collectionSize())
|
val builder = ChaptersListBuilder(collectionSize())
|
||||||
var index = 0
|
var index = 0
|
||||||
val elements = if (reversed) this.asReversed() else this
|
for (item in this) {
|
||||||
for (item in elements) {
|
|
||||||
if (builder.add(transform(index, item))) {
|
if (builder.add(transform(index, item))) {
|
||||||
index++
|
index++
|
||||||
}
|
}
|
||||||
@@ -43,4 +39,4 @@ internal class ChaptersListBuilder(initialSize: Int) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun toList(): List<MangaChapter> = list
|
fun toList(): List<MangaChapter> = list
|
||||||
}
|
}
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.parsers.util
|
|
||||||
|
|
||||||
import kotlinx.coroutines.Deferred
|
|
||||||
import kotlinx.coroutines.Job
|
|
||||||
import kotlinx.coroutines.flow.channelFlow
|
|
||||||
import kotlinx.coroutines.flow.first
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlin.coroutines.cancellation.CancellationException
|
|
||||||
|
|
||||||
fun Iterable<Job>.cancelAll(cause: CancellationException? = null) {
|
|
||||||
forEach { it.cancel(cause) }
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun <T> Iterable<Deferred<T>>.awaitFirst(): T {
|
|
||||||
return channelFlow {
|
|
||||||
for (deferred in this@awaitFirst) {
|
|
||||||
launch {
|
|
||||||
send(deferred.await())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}.first().also { this@awaitFirst.cancelAll() }
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun <T> Collection<Deferred<T>>.awaitFirst(condition: (T) -> Boolean): T {
|
|
||||||
return channelFlow {
|
|
||||||
for (deferred in this@awaitFirst) {
|
|
||||||
launch {
|
|
||||||
val result = deferred.await()
|
|
||||||
if (condition(result)) {
|
|
||||||
send(result)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}.first().also { this@awaitFirst.cancelAll() }
|
|
||||||
}
|
|
||||||
@@ -1,92 +1,94 @@
|
|||||||
package org.koitharu.kotatsu.parsers.util
|
package org.koitharu.kotatsu.parsers.util
|
||||||
|
|
||||||
|
import okhttp3.Headers
|
||||||
import org.jsoup.nodes.Element
|
import org.jsoup.nodes.Element
|
||||||
|
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
||||||
import org.koitharu.kotatsu.parsers.model.Favicon
|
import org.koitharu.kotatsu.parsers.model.Favicon
|
||||||
import org.koitharu.kotatsu.parsers.model.Favicons
|
import org.koitharu.kotatsu.parsers.model.Favicons
|
||||||
import org.koitharu.kotatsu.parsers.network.WebClient
|
|
||||||
import org.koitharu.kotatsu.parsers.util.json.mapJSON
|
import org.koitharu.kotatsu.parsers.util.json.mapJSON
|
||||||
|
|
||||||
class FaviconParser(
|
class FaviconParser(
|
||||||
private val webClient: WebClient,
|
private val context: MangaLoaderContext,
|
||||||
private val domain: String,
|
private val domain: String,
|
||||||
|
private val headers: Headers?,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
suspend fun parseFavicons(): Favicons {
|
suspend fun parseFavicons(): Favicons {
|
||||||
val url = "https://$domain"
|
val url = "https://$domain"
|
||||||
val doc = webClient.httpGet(url).parseHtml()
|
val doc = context.httpGet(url, headers).parseHtml()
|
||||||
val result = HashSet<Favicon>()
|
val result = HashSet<Favicon>()
|
||||||
val manifestLink = doc.getElementsByAttributeValue("rel", "manifest").firstOrNull()
|
val manifestLink = doc.getElementsByAttributeValue("rel", "manifest").firstOrNull()
|
||||||
?.attrAsAbsoluteUrlOrNull("href")
|
?.attrAsAbsoluteUrlOrNull("href")
|
||||||
if (manifestLink != null) {
|
if (manifestLink != null) {
|
||||||
result += parseManifest(manifestLink)
|
result += parseManifest(manifestLink)
|
||||||
}
|
}
|
||||||
val links = doc.getElementsByAttributeValueContaining("rel", "icon")
|
val links = doc.getElementsByAttributeValueContaining("rel", "icon")
|
||||||
links.mapNotNullTo(result) { link ->
|
links.mapNotNullTo(result) { link ->
|
||||||
parseLink(link)
|
parseLink(link)
|
||||||
}
|
}
|
||||||
if (result.isEmpty()) {
|
if (result.isEmpty()) {
|
||||||
result.add(createFallback())
|
result.add(createFallback())
|
||||||
}
|
}
|
||||||
return Favicons(result, url)
|
return Favicons(result, url)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun parseLink(link: Element): Favicon? {
|
private fun parseLink(link: Element): Favicon? {
|
||||||
val href = link.attrAsAbsoluteUrlOrNull("href")
|
val href = link.attrAsAbsoluteUrlOrNull("href")
|
||||||
if (href == null || href.endsWith('/')) {
|
if (href == null || href.endsWith('/')) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
val sizes = link.attr("sizes")
|
val sizes = link.attr("sizes")
|
||||||
return Favicon(
|
return Favicon(
|
||||||
url = href,
|
url = href,
|
||||||
size = parseSize(sizes),
|
size = parseSize(sizes),
|
||||||
rel = link.attrOrNull("rel"),
|
rel = link.attrOrNull("rel"),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun parseSize(sizes: String): Int {
|
private fun parseSize(sizes: String): Int {
|
||||||
if (sizes.isEmpty() || sizes == "any") {
|
if (sizes.isEmpty() || sizes == "any") {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
return sizes.substringBefore(' ')
|
return sizes.substringBefore(' ')
|
||||||
.split('x', 'X', '*')
|
.split('x', 'X', '*')
|
||||||
.firstNotNullOfOrNull { it.toIntOrNull() }
|
.firstNotNullOfOrNull { it.toIntOrNull() }
|
||||||
?: 0
|
?: 0
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun parseManifest(url: String): List<Favicon> {
|
private suspend fun parseManifest(url: String): List<Favicon> {
|
||||||
val json = webClient.httpGet(url).parseJson()
|
val json = context.httpGet(url, headers).parseJson()
|
||||||
val icons = json.getJSONArray("icons")
|
val icons = json.getJSONArray("icons")
|
||||||
return icons.mapJSON { jo ->
|
return icons.mapJSON { jo ->
|
||||||
Favicon(
|
Favicon(
|
||||||
url = jo.getString("src").resolveLink(),
|
url = jo.getString("src").resolveLink(),
|
||||||
size = parseSize(jo.getString("sizes")),
|
size = parseSize(jo.getString("sizes")),
|
||||||
rel = null,
|
rel = null,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun createFallback(): Favicon {
|
private fun createFallback(): Favicon {
|
||||||
val href = "https://$domain/favicon.ico"
|
val href = "https://$domain/favicon.ico"
|
||||||
return Favicon(
|
return Favicon(
|
||||||
url = href,
|
url = href,
|
||||||
size = 0,
|
size = 0,
|
||||||
rel = null,
|
rel = null,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun String.resolveLink(): String {
|
private fun String.resolveLink(): String {
|
||||||
return when {
|
return when {
|
||||||
startsWith("http:") || startsWith("https:") -> {
|
startsWith("http:") || startsWith("https:") -> {
|
||||||
this
|
this
|
||||||
}
|
}
|
||||||
|
|
||||||
startsWith('/') -> {
|
startsWith('/') -> {
|
||||||
"https://$domain$this"
|
"https://$domain$this"
|
||||||
}
|
}
|
||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
"https://$domain/$this"
|
"https://$domain/$this"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -32,7 +32,7 @@ fun Element.attrOrNull(attributeKey: String) = attr(attributeKey).takeUnless { i
|
|||||||
*/
|
*/
|
||||||
fun Element.attrAsRelativeUrlOrNull(attributeKey: String): String? {
|
fun Element.attrAsRelativeUrlOrNull(attributeKey: String): String? {
|
||||||
val attr = attr(attributeKey).trim()
|
val attr = attr(attributeKey).trim()
|
||||||
if (attr.isEmpty() || attr.startsWith("data:")) {
|
if (attr.isEmpty()) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
if (attr.startsWith("/")) {
|
if (attr.startsWith("/")) {
|
||||||
@@ -63,7 +63,7 @@ fun Element.attrAsRelativeUrl(attributeKey: String): String {
|
|||||||
*/
|
*/
|
||||||
fun Element.attrAsAbsoluteUrlOrNull(attributeKey: String): String? {
|
fun Element.attrAsAbsoluteUrlOrNull(attributeKey: String): String? {
|
||||||
val attr = attr(attributeKey).trim()
|
val attr = attr(attributeKey).trim()
|
||||||
if (attr.isEmpty() || attr.startsWith("data:")) {
|
if (attr.isEmpty()) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
return (baseUri().toHttpUrlOrNull()?.newBuilder(attr) ?: return null).toString()
|
return (baseUri().toHttpUrlOrNull()?.newBuilder(attr) ?: return null).toString()
|
||||||
@@ -114,8 +114,4 @@ fun Element.selectLast(cssQuery: String): Element? {
|
|||||||
|
|
||||||
fun Element.selectLastOrThrow(cssQuery: String): Element {
|
fun Element.selectLastOrThrow(cssQuery: String): Element {
|
||||||
return selectLast(cssQuery) ?: throw ParseException("Cannot find \"$cssQuery\"", baseUri())
|
return selectLast(cssQuery) ?: throw ParseException("Cannot find \"$cssQuery\"", baseUri())
|
||||||
}
|
}
|
||||||
|
|
||||||
fun Element.textOrNull(): String? = text().takeUnless { it.isEmpty() }
|
|
||||||
|
|
||||||
fun Element.ownTextOrNull(): String? = ownText().takeUnless { it.isEmpty() }
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.parsers.util
|
|
||||||
|
|
||||||
import okhttp3.HttpUrl
|
|
||||||
import org.jsoup.nodes.Element
|
|
||||||
import org.koitharu.kotatsu.parsers.InternalParsersApi
|
|
||||||
import org.koitharu.kotatsu.parsers.MangaParser
|
|
||||||
import org.koitharu.kotatsu.parsers.exception.ParseException
|
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaPage
|
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaTag
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a unique id for [Manga]/[MangaChapter]/[MangaPage].
|
|
||||||
* @param url must be relative url, without a domain
|
|
||||||
* @see [Manga.id]
|
|
||||||
* @see [MangaChapter.id]
|
|
||||||
* @see [MangaPage.id]
|
|
||||||
*/
|
|
||||||
@InternalParsersApi
|
|
||||||
fun MangaParser.generateUid(url: String): Long {
|
|
||||||
var h = 1125899906842597L
|
|
||||||
source.name.forEach { c ->
|
|
||||||
h = 31 * h + c.code
|
|
||||||
}
|
|
||||||
url.forEach { c ->
|
|
||||||
h = 31 * h + c.code
|
|
||||||
}
|
|
||||||
return h
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a unique id for [Manga]/[MangaChapter]/[MangaPage].
|
|
||||||
* @param id an internal identifier
|
|
||||||
* @see [Manga.id]
|
|
||||||
* @see [MangaChapter.id]
|
|
||||||
* @see [MangaPage.id]
|
|
||||||
*/
|
|
||||||
@InternalParsersApi
|
|
||||||
fun MangaParser.generateUid(id: Long): Long {
|
|
||||||
var h = 1125899906842597L
|
|
||||||
source.name.forEach { c ->
|
|
||||||
h = 31 * h + c.code
|
|
||||||
}
|
|
||||||
h = 31 * h + id
|
|
||||||
return h
|
|
||||||
}
|
|
||||||
|
|
||||||
@InternalParsersApi
|
|
||||||
fun Element.parseFailed(message: String? = null): Nothing {
|
|
||||||
throw ParseException(message, ownerDocument()?.location() ?: baseUri(), null)
|
|
||||||
}
|
|
||||||
|
|
||||||
@InternalParsersApi
|
|
||||||
fun Set<MangaTag>?.oneOrThrowIfMany(): MangaTag? {
|
|
||||||
return when {
|
|
||||||
isNullOrEmpty() -> null
|
|
||||||
size == 1 -> first()
|
|
||||||
else -> throw IllegalArgumentException("Multiple genres are not supported by this source")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val MangaParser.domain: String
|
|
||||||
get() {
|
|
||||||
return config[configKeyDomain]
|
|
||||||
}
|
|
||||||
|
|
||||||
fun MangaParser.getDomain(subdomain: String): String {
|
|
||||||
val domain = domain
|
|
||||||
return subdomain + "." + domain.removePrefix("www.")
|
|
||||||
}
|
|
||||||
|
|
||||||
fun MangaParser.urlBuilder(): HttpUrl.Builder {
|
|
||||||
return HttpUrl.Builder()
|
|
||||||
.scheme("https")
|
|
||||||
.host(domain)
|
|
||||||
}
|
|
||||||
@@ -4,26 +4,16 @@ package org.koitharu.kotatsu.parsers.util
|
|||||||
|
|
||||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||||
import okhttp3.Call
|
import okhttp3.Call
|
||||||
import okhttp3.Headers
|
|
||||||
import okhttp3.Response
|
import okhttp3.Response
|
||||||
|
|
||||||
suspend fun Call.await(): Response = suspendCancellableCoroutine { continuation ->
|
suspend fun Call.await(): Response = suspendCancellableCoroutine { continuation ->
|
||||||
val callback = ContinuationCallCallback(this, continuation)
|
val callback = ContinuationCallCallback(this, continuation)
|
||||||
enqueue(callback)
|
enqueue(callback)
|
||||||
continuation.invokeOnCancellation(callback)
|
continuation.invokeOnCancellation(callback)
|
||||||
}
|
}
|
||||||
|
|
||||||
val Response.mimeType: String?
|
val Response.mimeType: String?
|
||||||
get() = body?.contentType()?.run { "$type/$subtype" }
|
get() = body?.contentType()?.run { "$type/$subtype" }
|
||||||
|
|
||||||
val Response.contentDisposition: String?
|
val Response.contentDisposition: String?
|
||||||
get() = header("Content-Disposition")
|
get() = header("Content-Disposition")
|
||||||
|
|
||||||
fun Headers.Builder.mergeWith(other: Headers, replaceExisting: Boolean): Headers.Builder {
|
|
||||||
for ((name, value) in other) {
|
|
||||||
if (replaceExisting || this[name] == null) {
|
|
||||||
this[name] = value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return this
|
|
||||||
}
|
|
||||||
@@ -46,12 +46,6 @@ fun Response.parseJsonArray(): JSONArray = try {
|
|||||||
closeQuietly()
|
closeQuietly()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun Response.parseRaw(): String = try {
|
|
||||||
requireBody().string()
|
|
||||||
} finally {
|
|
||||||
closeQuietly()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert url to relative if it is on [domain]
|
* Convert url to relative if it is on [domain]
|
||||||
* @return an url relative to the [domain] or absolute, if domain is mismatching
|
* @return an url relative to the [domain] or absolute, if domain is mismatching
|
||||||
@@ -73,25 +67,12 @@ fun String.toAbsoluteUrl(domain: String): String = when {
|
|||||||
else -> this
|
else -> this
|
||||||
}
|
}
|
||||||
|
|
||||||
fun concatUrl(host: String, path: String): String {
|
|
||||||
val hostWithSlash = host.endsWith('/')
|
|
||||||
val pathWithSlash = path.startsWith('/')
|
|
||||||
return when {
|
|
||||||
hostWithSlash && pathWithSlash -> host + path.drop(1)
|
|
||||||
!hostWithSlash && !pathWithSlash -> "$host/$path"
|
|
||||||
else -> host + path
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun DateFormat.tryParse(str: String?): Long = if (str.isNullOrEmpty()) {
|
fun DateFormat.tryParse(str: String?): Long = if (str.isNullOrEmpty()) {
|
||||||
assert(false) { "Date string is null or empty" }
|
|
||||||
0L
|
0L
|
||||||
} else {
|
} else {
|
||||||
runCatching {
|
runCatching {
|
||||||
parse(str)?.time ?: 0L
|
parse(str)?.time ?: 0L
|
||||||
}.onFailure {
|
|
||||||
assert(false) { "Cannot parse date $str: ${it.message}" }
|
|
||||||
}.getOrDefault(0L)
|
}.getOrDefault(0L)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun Response.requireBody(): ResponseBody = requireNotNull(body) { "Response body is null" }
|
private fun Response.requireBody(): ResponseBody = requireNotNull(body) { "Response body is null" }
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.parsers.util
|
|
||||||
|
|
||||||
import kotlinx.coroutines.CancellationException
|
|
||||||
|
|
||||||
inline fun <T, R> T.runCatchingCancellable(block: T.() -> R): Result<R> {
|
|
||||||
return try {
|
|
||||||
Result.success(block())
|
|
||||||
} catch (e: CancellationException) {
|
|
||||||
throw e
|
|
||||||
} catch (e: Throwable) {
|
|
||||||
Result.failure(e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.parsers.util
|
|
||||||
|
|
||||||
import kotlinx.coroutines.sync.Mutex
|
|
||||||
import kotlinx.coroutines.sync.withLock
|
|
||||||
|
|
||||||
class SuspendLazy<T>(
|
|
||||||
private val initializer: suspend () -> T,
|
|
||||||
) {
|
|
||||||
|
|
||||||
private val mutex = Mutex()
|
|
||||||
private var cachedValue: Any? = Uninitialized
|
|
||||||
|
|
||||||
@Suppress("UNCHECKED_CAST")
|
|
||||||
suspend fun get(): T {
|
|
||||||
// fast way
|
|
||||||
cachedValue.let {
|
|
||||||
if (it !== Uninitialized) {
|
|
||||||
return it as T
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return mutex.withLock {
|
|
||||||
cachedValue.let {
|
|
||||||
if (it !== Uninitialized) {
|
|
||||||
return it as T
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val result = initializer()
|
|
||||||
cachedValue = result
|
|
||||||
result
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun tryGet() = runCatchingCancellable { get() }
|
|
||||||
|
|
||||||
private object Uninitialized
|
|
||||||
}
|
|
||||||
@@ -136,8 +136,4 @@ fun JSONArray?.isNullOrEmpty(): Boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return this == null || this.length() == 0
|
return this == null || this.length() == 0
|
||||||
}
|
}
|
||||||
|
|
||||||
fun JSONArray.toJSONList(): List<JSONObject> {
|
|
||||||
return List(length()) { i -> getJSONObject(i) }
|
|
||||||
}
|
|
||||||
@@ -4,31 +4,30 @@ import kotlinx.coroutines.test.runTest
|
|||||||
import org.junit.jupiter.api.extension.BeforeAllCallback
|
import org.junit.jupiter.api.extension.BeforeAllCallback
|
||||||
import org.junit.jupiter.api.extension.ExtensionContext
|
import org.junit.jupiter.api.extension.ExtensionContext
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
|
||||||
|
|
||||||
class AuthCheckExtension : BeforeAllCallback {
|
class AuthCheckExtension : BeforeAllCallback {
|
||||||
|
|
||||||
private val loaderContext: MangaLoaderContext = MangaLoaderContextMock
|
private val loaderContext: MangaLoaderContext = MangaLoaderContextMock()
|
||||||
|
|
||||||
override fun beforeAll(context: ExtensionContext) {
|
override fun beforeAll(context: ExtensionContext) {
|
||||||
for (source in MangaSource.values()) {
|
for (source in MangaSource.values()) {
|
||||||
if (source == MangaSource.LOCAL || source == MangaSource.DUMMY) {
|
if (source == MangaSource.LOCAL || source == MangaSource.DUMMY) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
val parser = source.newParser(loaderContext)
|
val parser = source.newParser(loaderContext)
|
||||||
if (parser is MangaParserAuthProvider) {
|
if (parser is MangaParserAuthProvider) {
|
||||||
checkAuthorization(source, parser)
|
checkAuthorization(source, parser)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun checkAuthorization(source: MangaSource, parser: MangaParserAuthProvider) = runTest {
|
private fun checkAuthorization(source: MangaSource, parser: MangaParserAuthProvider) = runTest {
|
||||||
runCatchingCancellable {
|
runCatching {
|
||||||
parser.getUsername()
|
parser.getUsername()
|
||||||
}.onSuccess { username ->
|
}.onSuccess { username ->
|
||||||
println("Signed in to ${source.name} as $username")
|
println("Signed in to ${source.name} as $username")
|
||||||
}.onFailure { error ->
|
}.onFailure { error ->
|
||||||
System.err.println("Auth failed for ${source.name}: ${error.javaClass.name}(${error.message})")
|
System.err.println("Auth failed for ${source.name}: ${error.javaClass.name}(${error.message})")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4,25 +4,22 @@ import okhttp3.Interceptor
|
|||||||
import okhttp3.Response
|
import okhttp3.Response
|
||||||
import okhttp3.internal.closeQuietly
|
import okhttp3.internal.closeQuietly
|
||||||
import org.koitharu.kotatsu.parsers.exception.CloudFlareProtectedException
|
import org.koitharu.kotatsu.parsers.exception.CloudFlareProtectedException
|
||||||
import java.net.HttpURLConnection
|
import java.net.HttpURLConnection.HTTP_FORBIDDEN
|
||||||
|
import java.net.HttpURLConnection.HTTP_UNAVAILABLE
|
||||||
|
|
||||||
private const val HEADER_SERVER = "Server"
|
private const val HEADER_SERVER = "Server"
|
||||||
private const val SERVER_CLOUDFLARE = "cloudflare"
|
private const val SERVER_CLOUDFLARE = "cloudflare"
|
||||||
|
|
||||||
internal class CloudFlareInterceptor : Interceptor {
|
class CloudFlareInterceptor : Interceptor {
|
||||||
|
|
||||||
override fun intercept(chain: Interceptor.Chain): Response {
|
override fun intercept(chain: Interceptor.Chain): Response {
|
||||||
val request = chain.request()
|
val response = chain.proceed(chain.request())
|
||||||
val response = chain.proceed(request)
|
if (response.code == HTTP_FORBIDDEN || response.code == HTTP_UNAVAILABLE) {
|
||||||
if (response.code == HttpURLConnection.HTTP_FORBIDDEN || response.code == HttpURLConnection.HTTP_UNAVAILABLE) {
|
if (response.header(HEADER_SERVER)?.startsWith(SERVER_CLOUDFLARE) == true) {
|
||||||
if (response.header(HEADER_SERVER)?.startsWith(SERVER_CLOUDFLARE) == true) {
|
response.closeQuietly()
|
||||||
response.closeQuietly()
|
throw CloudFlareProtectedException(response.request.url.toString())
|
||||||
throw CloudFlareProtectedException(
|
}
|
||||||
url = response.request.url.toString(),
|
}
|
||||||
headers = request.headers,
|
return response
|
||||||
)
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
return response
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.parsers
|
|
||||||
|
|
||||||
import okhttp3.Interceptor
|
|
||||||
import okhttp3.Request
|
|
||||||
import okhttp3.Response
|
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
|
||||||
import org.koitharu.kotatsu.parsers.util.domain
|
|
||||||
import org.koitharu.kotatsu.parsers.util.mergeWith
|
|
||||||
import java.net.IDN
|
|
||||||
|
|
||||||
private const val HEADER_USER_AGENT = "User-Agent"
|
|
||||||
private const val HEADER_REFERER = "Referer"
|
|
||||||
|
|
||||||
internal class CommonHeadersInterceptor(
|
|
||||||
private val userAgent: String,
|
|
||||||
) : Interceptor {
|
|
||||||
|
|
||||||
override fun intercept(chain: Interceptor.Chain): Response {
|
|
||||||
val request = chain.request()
|
|
||||||
val source = request.tag(MangaSource::class.java)
|
|
||||||
val parser = source?.newParser(MangaLoaderContextMock)
|
|
||||||
val sourceHeaders = parser?.headers
|
|
||||||
val headersBuilder = request.headers.newBuilder()
|
|
||||||
if (sourceHeaders != null) {
|
|
||||||
headersBuilder.mergeWith(sourceHeaders, replaceExisting = false)
|
|
||||||
}
|
|
||||||
if (headersBuilder[HEADER_USER_AGENT] == null) {
|
|
||||||
headersBuilder[HEADER_USER_AGENT] = userAgent
|
|
||||||
}
|
|
||||||
if (headersBuilder[HEADER_REFERER] == null && parser != null) {
|
|
||||||
val idn = IDN.toASCII(parser.domain)
|
|
||||||
headersBuilder[HEADER_REFERER] = "https://$idn/"
|
|
||||||
}
|
|
||||||
val newRequest = request.newBuilder().headers(headersBuilder.build()).build()
|
|
||||||
return if (parser is Interceptor) {
|
|
||||||
parser.intercept(ProxyChain(chain, newRequest))
|
|
||||||
} else {
|
|
||||||
return chain.proceed(newRequest)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private class ProxyChain(
|
|
||||||
private val delegate: Interceptor.Chain,
|
|
||||||
private val request: Request,
|
|
||||||
) : Interceptor.Chain by delegate {
|
|
||||||
|
|
||||||
override fun request(): Request = request
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package org.koitharu.kotatsu.parsers
|
package org.koitharu.kotatsu.parsers
|
||||||
|
|
||||||
import com.koushikdutta.quack.QuackContext
|
import com.koushikdutta.quack.QuackContext
|
||||||
|
import okhttp3.Headers
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
import okhttp3.Response
|
import okhttp3.Response
|
||||||
@@ -9,10 +10,10 @@ import org.koitharu.kotatsu.parsers.model.MangaSource
|
|||||||
import org.koitharu.kotatsu.parsers.util.await
|
import org.koitharu.kotatsu.parsers.util.await
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
internal object MangaLoaderContextMock : MangaLoaderContext() {
|
internal class MangaLoaderContextMock : MangaLoaderContext() {
|
||||||
|
|
||||||
private val userAgent = "Kotatsu/%s (Android %s; %s; %s %s; %s)".format(
|
private val userAgent = "Kotatsu/%s (Android %s; %s; %s %s; %s)".format(
|
||||||
/*BuildConfig.VERSION_NAME*/ "4.4",
|
/*BuildConfig.VERSION_NAME*/ "3.0",
|
||||||
/*Build.VERSION.RELEASE*/ "r",
|
/*Build.VERSION.RELEASE*/ "r",
|
||||||
/*Build.MODEL*/ "",
|
/*Build.MODEL*/ "",
|
||||||
/*Build.BRAND*/ "",
|
/*Build.BRAND*/ "",
|
||||||
@@ -24,7 +25,7 @@ internal object MangaLoaderContextMock : MangaLoaderContext() {
|
|||||||
|
|
||||||
override val httpClient: OkHttpClient = OkHttpClient.Builder()
|
override val httpClient: OkHttpClient = OkHttpClient.Builder()
|
||||||
.cookieJar(cookieJar)
|
.cookieJar(cookieJar)
|
||||||
.addInterceptor(CommonHeadersInterceptor(userAgent))
|
.addInterceptor(UserAgentInterceptor(userAgent))
|
||||||
.addInterceptor(CloudFlareInterceptor())
|
.addInterceptor(CloudFlareInterceptor())
|
||||||
.connectTimeout(20, TimeUnit.SECONDS)
|
.connectTimeout(20, TimeUnit.SECONDS)
|
||||||
.readTimeout(60, TimeUnit.SECONDS)
|
.readTimeout(60, TimeUnit.SECONDS)
|
||||||
@@ -45,12 +46,15 @@ internal object MangaLoaderContextMock : MangaLoaderContext() {
|
|||||||
return SourceConfigMock()
|
return SourceConfigMock()
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun doRequest(url: String, source: MangaSource?): Response {
|
suspend fun doRequest(url: String, referer: String? = null, extraHeaders: Headers? = null): Response {
|
||||||
val request = Request.Builder()
|
val request = Request.Builder()
|
||||||
.get()
|
.get()
|
||||||
.url(url)
|
.url(url)
|
||||||
if (source != null) {
|
if (extraHeaders != null) {
|
||||||
request.tag(MangaSource::class.java, source)
|
request.headers(extraHeaders)
|
||||||
|
}
|
||||||
|
if (referer != null) {
|
||||||
|
request.header("Referer", referer)
|
||||||
}
|
}
|
||||||
return httpClient.newCall(request.build()).await()
|
return httpClient.newCall(request.build()).await()
|
||||||
}
|
}
|
||||||
@@ -61,4 +65,4 @@ internal object MangaLoaderContextMock : MangaLoaderContext() {
|
|||||||
cookieJar.loadFromStream(it)
|
cookieJar.loadFromStream(it)
|
||||||
} ?: println("No cookies loaded!")
|
} ?: println("No cookies loaded!")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -8,22 +8,24 @@ import org.junit.jupiter.params.ParameterizedTest
|
|||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
import org.koitharu.kotatsu.parsers.model.SortOrder
|
import org.koitharu.kotatsu.parsers.model.SortOrder
|
||||||
import org.koitharu.kotatsu.parsers.util.domain
|
|
||||||
import org.koitharu.kotatsu.parsers.util.medianOrNull
|
import org.koitharu.kotatsu.parsers.util.medianOrNull
|
||||||
import org.koitharu.kotatsu.parsers.util.mimeType
|
import org.koitharu.kotatsu.parsers.util.mimeType
|
||||||
import org.koitharu.kotatsu.test_util.*
|
import org.koitharu.kotatsu.test_util.isDistinct
|
||||||
|
import org.koitharu.kotatsu.test_util.isDistinctBy
|
||||||
|
import org.koitharu.kotatsu.test_util.isUrlAbsolute
|
||||||
|
import org.koitharu.kotatsu.test_util.maxDuplicates
|
||||||
|
|
||||||
|
|
||||||
@ExtendWith(AuthCheckExtension::class)
|
@ExtendWith(AuthCheckExtension::class)
|
||||||
internal class MangaParserTest {
|
internal class MangaParserTest {
|
||||||
|
|
||||||
private val context = MangaLoaderContextMock
|
private val context = MangaLoaderContextMock()
|
||||||
|
|
||||||
@ParameterizedTest(name = "{index}|list|{0}")
|
@ParameterizedTest(name = "{index}|list|{0}")
|
||||||
@MangaSources
|
@MangaSources
|
||||||
fun list(source: MangaSource) = runTest {
|
fun list(source: MangaSource) = runTest {
|
||||||
val parser = source.newParser(context)
|
val parser = source.newParser(context)
|
||||||
val list = parser.getList(0, sortOrder = SortOrder.POPULARITY, tags = null)
|
val list = parser.getList(20, sortOrder = SortOrder.POPULARITY, tags = null)
|
||||||
checkMangaList(list, "list")
|
checkMangaList(list, "list")
|
||||||
assert(list.all { it.source == source })
|
assert(list.all { it.source == source })
|
||||||
}
|
}
|
||||||
@@ -34,12 +36,9 @@ internal class MangaParserTest {
|
|||||||
val parser = source.newParser(context)
|
val parser = source.newParser(context)
|
||||||
val page1 = parser.getList(0, sortOrder = null, tags = null)
|
val page1 = parser.getList(0, sortOrder = null, tags = null)
|
||||||
val page2 = parser.getList(page1.size, sortOrder = null, tags = null)
|
val page2 = parser.getList(page1.size, sortOrder = null, tags = null)
|
||||||
assert(page1.isNotEmpty()) { "Page 1 is empty" }
|
|
||||||
assert(page2.isNotEmpty()) { "Page 2 is empty" }
|
|
||||||
assert(page1 != page2) { "Pages are equal" }
|
|
||||||
val intersection = page1.intersect(page2.toSet())
|
val intersection = page1.intersect(page2.toSet())
|
||||||
assert(intersection.isEmpty()) {
|
assert(intersection.isEmpty()) {
|
||||||
"Pages are intersected by " + intersection.size
|
"Pages are intersected: " + intersection.joinToString { it.publicUrl }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,13 +46,12 @@ internal class MangaParserTest {
|
|||||||
@MangaSources
|
@MangaSources
|
||||||
fun search(source: MangaSource) = runTest {
|
fun search(source: MangaSource) = runTest {
|
||||||
val parser = source.newParser(context)
|
val parser = source.newParser(context)
|
||||||
val subject = parser.getList(0, sortOrder = SortOrder.POPULARITY, tags = null).minByOrNull {
|
val subject = parser.getList(20, sortOrder = SortOrder.POPULARITY, tags = null).minByOrNull {
|
||||||
it.title.length
|
it.title.length
|
||||||
} ?: error("No manga found")
|
} ?: error("No manga found")
|
||||||
val query = subject.title
|
val query = subject.title
|
||||||
check(query.isNotBlank()) { "Manga title '$query' is blank" }
|
check(query.isNotBlank()) { "Manga title '$query' is blank" }
|
||||||
val list = parser.getList(0, query)
|
val list = parser.getList(0, query)
|
||||||
assert(list.isNotEmpty()) { "Empty search results by \"$query\"" }
|
|
||||||
assert(list.singleOrNull { it.url == subject.url && it.id == subject.id } != null) {
|
assert(list.singleOrNull { it.url == subject.url && it.id == subject.id } != null) {
|
||||||
"Single subject '${subject.title} (${subject.publicUrl})' not found in search results"
|
"Single subject '${subject.title} (${subject.publicUrl})' not found in search results"
|
||||||
}
|
}
|
||||||
@@ -71,7 +69,7 @@ internal class MangaParserTest {
|
|||||||
assert(keys.isDistinct())
|
assert(keys.isDistinct())
|
||||||
assert("" !in keys)
|
assert("" !in keys)
|
||||||
val titles = tags.map { it.title }
|
val titles = tags.map { it.title }
|
||||||
// assert(titles.isDistinct())
|
assert(titles.isDistinct())
|
||||||
assert("" !in titles)
|
assert("" !in titles)
|
||||||
assert(tags.all { it.source == source })
|
assert(tags.all { it.source == source })
|
||||||
|
|
||||||
@@ -85,7 +83,7 @@ internal class MangaParserTest {
|
|||||||
@MangaSources
|
@MangaSources
|
||||||
fun details(source: MangaSource) = runTest {
|
fun details(source: MangaSource) = runTest {
|
||||||
val parser = source.newParser(context)
|
val parser = source.newParser(context)
|
||||||
val list = parser.getList(0, sortOrder = SortOrder.POPULARITY, tags = null)
|
val list = parser.getList(20, sortOrder = SortOrder.POPULARITY, tags = null)
|
||||||
val manga = list[3]
|
val manga = list[3]
|
||||||
parser.getDetails(manga).apply {
|
parser.getDetails(manga).apply {
|
||||||
assert(!chapters.isNullOrEmpty()) { "Chapters are null or empty" }
|
assert(!chapters.isNullOrEmpty()) { "Chapters are null or empty" }
|
||||||
@@ -103,9 +101,9 @@ internal class MangaParserTest {
|
|||||||
"Chapters are not distinct by number: ${c.maxDuplicates { it.number to it.branch }} for $publicUrl"
|
"Chapters are not distinct by number: ${c.maxDuplicates { it.number to it.branch }} for $publicUrl"
|
||||||
}
|
}
|
||||||
assert(c.all { it.source == source })
|
assert(c.all { it.source == source })
|
||||||
checkImageRequest(coverUrl, source)
|
checkImageRequest(coverUrl, publicUrl)
|
||||||
largeCoverUrl?.let {
|
largeCoverUrl?.let {
|
||||||
checkImageRequest(it, source)
|
checkImageRequest(it, publicUrl)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -114,24 +112,20 @@ internal class MangaParserTest {
|
|||||||
@MangaSources
|
@MangaSources
|
||||||
fun pages(source: MangaSource) = runTest {
|
fun pages(source: MangaSource) = runTest {
|
||||||
val parser = source.newParser(context)
|
val parser = source.newParser(context)
|
||||||
val list = parser.getList(0, sortOrder = SortOrder.UPDATED, tags = null)
|
val list = parser.getList(20, sortOrder = SortOrder.POPULARITY, tags = null)
|
||||||
val manga = list.first()
|
val manga = list.first()
|
||||||
val chapter = parser.getDetails(manga).chapters?.firstOrNull() ?: error("Chapter is null at ${manga.publicUrl}")
|
val chapter = parser.getDetails(manga).chapters?.firstOrNull() ?: error("Chapter is null")
|
||||||
val pages = parser.getPages(chapter)
|
val pages = parser.getPages(chapter)
|
||||||
|
|
||||||
assert(pages.isNotEmpty())
|
assert(pages.isNotEmpty())
|
||||||
assert(pages.isDistinctBy { it.id })
|
assert(pages.isDistinctBy { it.id })
|
||||||
assert(pages.all { it.source == source })
|
assert(pages.all { it.source == source })
|
||||||
|
|
||||||
arrayOf(
|
val page = pages.medianOrNull() ?: error("No page")
|
||||||
pages.first(),
|
val pageUrl = parser.getPageUrl(page)
|
||||||
pages.medianOrNull() ?: error("No page"),
|
assert(pageUrl.isNotEmpty())
|
||||||
).forEach { page ->
|
assert(pageUrl.isUrlAbsolute())
|
||||||
val pageUrl = parser.getPageUrl(page)
|
checkImageRequest(pageUrl, page.referer)
|
||||||
assert(pageUrl.isNotEmpty())
|
|
||||||
assert(pageUrl.isUrlAbsolute())
|
|
||||||
checkImageRequest(pageUrl, page.source)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@ParameterizedTest(name = "{index}|favicon|{0}")
|
@ParameterizedTest(name = "{index}|favicon|{0}")
|
||||||
@@ -147,19 +141,19 @@ internal class MangaParserTest {
|
|||||||
}
|
}
|
||||||
val favicon = favicons.find(24)
|
val favicon = favicons.find(24)
|
||||||
checkNotNull(favicon)
|
checkNotNull(favicon)
|
||||||
checkImageRequest(favicon.url, source)
|
checkImageRequest(favicon.url, favicons.referer)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ParameterizedTest(name = "{index}|domain|{0}")
|
@ParameterizedTest(name = "{index}|domain|{0}")
|
||||||
@MangaSources
|
@MangaSources
|
||||||
fun domain(source: MangaSource) = runTest {
|
fun domain(source: MangaSource) = runTest {
|
||||||
val parser = source.newParser(context)
|
val parser = source.newParser(context)
|
||||||
val defaultDomain = parser.domain
|
val defaultDomain = parser.getDomain()
|
||||||
val url = HttpUrl.Builder()
|
val url = HttpUrl.Builder()
|
||||||
.host(defaultDomain)
|
.host(defaultDomain)
|
||||||
.scheme("https")
|
.scheme("https")
|
||||||
.toString()
|
.toString()
|
||||||
val response = context.doRequest(url, source)
|
val response = context.doRequest(url, extraHeaders = parser.headers)
|
||||||
val realUrl = response.request.url
|
val realUrl = response.request.url
|
||||||
val realDomain = realUrl.topPrivateDomain()
|
val realDomain = realUrl.topPrivateDomain()
|
||||||
val realHost = realUrl.host
|
val realHost = realUrl.host
|
||||||
@@ -191,15 +185,15 @@ internal class MangaParserTest {
|
|||||||
assert(item.publicUrl.isUrlAbsolute())
|
assert(item.publicUrl.isUrlAbsolute())
|
||||||
}
|
}
|
||||||
val testItem = list.random()
|
val testItem = list.random()
|
||||||
checkImageRequest(testItem.coverUrl, testItem.source)
|
checkImageRequest(testItem.coverUrl, testItem.publicUrl)
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun checkImageRequest(url: String, source: MangaSource) {
|
private suspend fun checkImageRequest(url: String, referer: String?) {
|
||||||
context.doRequest(url, source).use {
|
context.doRequest(url, referer).use {
|
||||||
assert(it.isSuccessful) { "Request failed: ${it.code}(${it.message}): $url" }
|
assert(it.isSuccessful) { "Request failed: ${it.code}(${it.message}): $url" }
|
||||||
assert(it.mimeType?.startsWith("image/") == true) {
|
assert(it.mimeType?.startsWith("image/") == true) {
|
||||||
"Wrong response mime type: ${it.mimeType}"
|
"Wrong response mime type: ${it.mimeType}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4,4 +4,4 @@ import org.junit.jupiter.params.provider.EnumSource
|
|||||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
|
|
||||||
@EnumSource(MangaSource::class, names = ["LOCAL", "DUMMY"], mode = EnumSource.Mode.EXCLUDE)
|
@EnumSource(MangaSource::class, names = ["LOCAL", "DUMMY"], mode = EnumSource.Mode.EXCLUDE)
|
||||||
internal annotation class MangaSources
|
internal annotation class MangaSources
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package org.koitharu.kotatsu.parsers
|
||||||
|
|
||||||
|
import okhttp3.Interceptor
|
||||||
|
import okhttp3.Response
|
||||||
|
|
||||||
|
private const val HEADER_USER_AGENT = "User-Agent"
|
||||||
|
internal class UserAgentInterceptor(
|
||||||
|
private val userAgent: String,
|
||||||
|
) : Interceptor {
|
||||||
|
|
||||||
|
override fun intercept(chain: Interceptor.Chain): Response {
|
||||||
|
val request = chain.request()
|
||||||
|
val newRequest = if (request.header(HEADER_USER_AGENT) == null) {
|
||||||
|
request.newBuilder().header(HEADER_USER_AGENT, userAgent).build()
|
||||||
|
} else {
|
||||||
|
request
|
||||||
|
}
|
||||||
|
return chain.proceed(newRequest)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user